Skip to content

Commit

Permalink
Add tailscale integration
Browse files Browse the repository at this point in the history
  • Loading branch information
MelleD committed Apr 25, 2024
1 parent a1a1e55 commit 9c098d9
Show file tree
Hide file tree
Showing 8 changed files with 223 additions and 36 deletions.
43 changes: 43 additions & 0 deletions .github/workflows/docker-build-push.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: ci

on:
push:
paths-ignore:
- 'README.md'
branches:
- 'main'

jobs:
docker:
runs-on: ubuntu-latest
steps:
-
name: Checkout
uses: actions/checkout@v4

-
name: Set up QEMU
uses: docker/setup-qemu-action@v3
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
-
name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: eu-west-1
-
name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
-
name: Build, tag, and push image to Amazon ECR
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
ECR_REPOSITORY: ha-custom-lambda-tailscale
IMAGE_TAG: latest
run: |
docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
40 changes: 40 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
FROM python:3.12 as builder

WORKDIR /app
COPY ./lambda/config.py .
COPY ./lambda/const.py .
COPY ./lambda/schemas.py .
COPY ./lambda/language_strings.json .
COPY ./lambda/prompts.py .
COPY ./lambda/requirements.txt .
COPY ./lambda/lambda_function.py .

RUN pip install -t . -r requirements.txt

FROM alpine:latest as tailscale
WORKDIR /app
COPY . ./
ENV TSFILE=tailscale_1.64.0_amd64.tgz
RUN wget https://pkgs.tailscale.com/stable/${TSFILE} && \
tar xzf ${TSFILE} --strip-components=1
COPY . ./


FROM public.ecr.aws/lambda/python:3.12
#can't test locally without it
ADD https://github.com/aws/aws-lambda-runtime-interface-emulator/releases/latest/download/aws-lambda-rie /usr/local/bin/aws-lambda-rie
RUN chmod 755 /usr/local/bin/aws-lambda-rie

COPY ./custom_entrypoint /var/runtime/custom_entrypoint
COPY --from=builder /app/ /var/task
COPY --from=tailscale /app/tailscaled /var/runtime/tailscaled
COPY --from=tailscale /app/tailscale /var/runtime/tailscale
RUN mkdir -p /var/run && ln -s /tmp/tailscale /var/run/tailscale && \
mkdir -p /var/cache && ln -s /tmp/tailscale /var/cache/tailscale && \
mkdir -p /var/lib && ln -s /tmp/tailscale /var/lib/tailscale && \
mkdir -p /var/task && ln -s /tmp/tailscale /var/task/tailscale

# Run on container startup.
EXPOSE 8080
ENTRYPOINT ["/var/runtime/custom_entrypoint"]
CMD [ "lambda_function.lambda_handler" ]
22 changes: 22 additions & 0 deletions custom_entrypoint
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
#!/bin/sh
if [ "${START_TAILSCALE}" = false ]; then
echo No Tailscale start, because local startup
else
echo Tailscale start
mkdir -p /tmp/tailscale
/var/runtime/tailscaled --tun=userspace-networking --socks5-server=localhost:1055 &
until /var/runtime/tailscale up --authkey=${TAILSCALE_AUTHKEY} --hostname=aws-lambda-app
do
sleep 0.1
done
echo Tailscale started
fi


if [ -z "${AWS_LAMBDA_RUNTIME_API}" ]; then
echo Start aws-lambda-rie awslambdaric
exec env ALL_PROXY="socks5://localhost:1055/" /usr/local/bin/aws-lambda-rie /var/lang/bin/python3.12 -m awslambdaric $@
else
echo Start python awslambdaric
exec env ALL_PROXY="socks5://localhost:1055/" /var/lang/bin/python3.12 -m awslambdaric $@
fi
22 changes: 22 additions & 0 deletions lambda/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Built-In Imports
import os

# Local Imports
from const import (
HA_URL,
HA_TOKEN,
SSL_VERIFY,
DEBUG,
AWS_DEFAULT_REGION,
ALL_PROXY
)


configuration = {
HA_URL: os.environ[HA_URL],
HA_TOKEN: os.environ.get(HA_TOKEN, default=""),
SSL_VERIFY: os.environ.get(SSL_VERIFY, default=False),
DEBUG: os.environ.get(DEBUG, default=False),
AWS_DEFAULT_REGION: os.environ.get(AWS_DEFAULT_REGION),
ALL_PROXY: os.environ.get(ALL_PROXY, default=None)
}
7 changes: 7 additions & 0 deletions lambda/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,10 @@
RESPONSE_DURATION = "ResponseDuration"
RESPONSE_STRING = "ResponseString"
RESPONSE_DATE_TIME = "ResponseDateTime"

HA_URL = "HA_URL"
HA_TOKEN = "HA_TOKEN"
SSL_VERIFY = "SSL_VERIFY"
DEBUG = "DEBUG"
AWS_DEFAULT_REGION = "AWS_DEFAULT_REGION"
ALL_PROXY = "ALL_PROXY"
108 changes: 78 additions & 30 deletions lambda/lambda_function.py
Original file line number Diff line number Diff line change
@@ -1,39 +1,37 @@
# VERSION 0.12.0

# UPDATE THESE VARIABLES WITH YOUR CONFIG
HOME_ASSISTANT_URL = "https://yourinstall.com" # REPLACE WITH THE URL FOR YOUR HOME ASSISTANT
VERIFY_SSL = True # SET TO FALSE IF YOU DO NOT HAVE VALID CERTS
TOKEN = "" # ADD YOUR LONG LIVED TOKEN IF NEEDED OTHERWISE LEAVE BLANK
DEBUG = False # SET TO TRUE IF YOU WANT TO SEE MORE DETAILS IN THE LOGS

""" NO NEED TO EDIT ANYTHING UNDER THE LINE """
# Built-In Imports
import json
from typing import Union, Optional
import logging


# 3rd-Party Imports
import isodate
import urllib3
from ask_sdk_core.dispatch_components import AbstractExceptionHandler
from ask_sdk_core.dispatch_components import AbstractRequestHandler
from ask_sdk_core.dispatch_components import AbstractRequestInterceptor
from urllib3.contrib.socks import SOCKSProxyManager
import isodate
from ask_sdk_core.dispatch_components.exception_components import AbstractExceptionHandler
from ask_sdk_core.dispatch_components.request_components import AbstractRequestHandler
from ask_sdk_core.dispatch_components.request_components import AbstractRequestInterceptor
from ask_sdk_core.skill_builder import SkillBuilder
from ask_sdk_core.utils import (
get_account_linking_access_token,
from ask_sdk_core.utils.predicate import (
is_request_type,
is_intent_name,
)

from ask_sdk_core.utils.request_util import (
get_intent_name,
get_slot,
get_slot_value,
)
from ask_sdk_model import SessionEndedReason
from ask_sdk_model.slu.entityresolution import StatusCode
from ask_sdk_model.session_ended_reason import SessionEndedReason
from ask_sdk_model.slu.entityresolution.status_code import StatusCode
from urllib3 import HTTPResponse

# Local Imports
import prompts
from config import configuration
from schemas import HaState, HaStateError
from utils import get_logger
from const import (
INPUT_TEXT_ENTITY,
RESPONSE_YES,
Expand All @@ -44,11 +42,15 @@
RESPONSE_DURATION,
RESPONSE_STRING,
RESPONSE_DATE_TIME,
HA_URL,
HA_TOKEN,
SSL_VERIFY,
DEBUG,
AWS_DEFAULT_REGION,
ALL_PROXY
)

HOME_ASSISTANT_URL = HOME_ASSISTANT_URL.rstrip("/")

logger = get_logger(DEBUG)
logger = logging.getLogger()


def _handle_response(handler, speak_out: Optional[str]):
Expand Down Expand Up @@ -76,9 +78,10 @@ def __init__(self):
self.__dict__ = self._shared_state


def _init_http_pool():
def _init_http_pool(ssl_verfiy: bool):
return urllib3.PoolManager(
cert_reqs="CERT_REQUIRED" if VERIFY_SSL else "CERT_NONE", timeout=urllib3.Timeout(connect=10.0, read=10.0)
cert_reqs="CERT_REQUIRED" if ssl_verfiy else "CERT_NONE",
timeout=urllib3.Timeout(connect=10.0, read=10.0),
)


Expand Down Expand Up @@ -114,24 +117,50 @@ class HomeAssistant(Borg):
def __init__(self, handler_input=None):
Borg.__init__(self)

self.url = configuration[HA_URL]
self.bearer_token = configuration[HA_TOKEN]
self.ssl_verify = configuration[SSL_VERIFY]
self.debug = configuration[DEBUG]
self.aws_region = configuration[AWS_DEFAULT_REGION]
self.proxy = configuration[ALL_PROXY]
logger.setLevel(logging.INFO)

if self.debug:
logger.setLevel(logging.DEBUG)

logger.debug(f"self.debug is { self.debug}")
logger.debug(f"logger.debug is { logger.getEffectiveLevel()}")
logger.debug(f"HA_URL is { self.url}")

# Define class vars
self.ha_state = None
self.http = _init_http_pool()

if(self.proxy is None):
self.http = _init_http_pool(self.ssl_verify)
else:
logger.debug(f"Proxy is { self.proxy}")
self.http = SOCKSProxyManager(self.proxy)

if handler_input:
self.handler_input = handler_input

# Gets data from langua_strings.json file according to the locale
self.language_strings = self.handler_input.attributes_manager.request_attributes["_"]

self.token = self._fetch_token() if TOKEN == "" else TOKEN
self.token = self._fetch_token() if self.token == "" else self.token

self.get_ha_state()

def _fetch_token(self):
logger.debug("Fetching Home Assistant token from Alexa")
return get_account_linking_access_token(self.handler_input)

def get_ha_url(self):
"""Returns Home Assistant base url without."""
url = self.url
if not url:
raise ValueError('Property "url" is missing in config')
return url.replace("/api", "").rstrip("/")

def _set_ha_error(self, prompt: str):
"""
Sets the self.ha_state to the error prompt
Expand All @@ -144,15 +173,18 @@ def _set_ha_error(self, prompt: str):
"""
self.ha_state = HaStateError(text=self.language_strings[prompt])

@staticmethod
def _build_url(*path: str):
def _build_url(self, *path: str):
"""
Builds the url from paths given
:param path:
:return:
"""
return f"{HOME_ASSISTANT_URL}/" + "/".join(path)
home_assistant_url = self.get_ha_url()
logger.debug(f"Home Assistant URL: {home_assistant_url}")
home_assistant_url = f"{home_assistant_url}/" + "/".join(path)
logger.debug(f"Home Assistant Api URL: {home_assistant_url}")
return home_assistant_url

def _get_headers(self):
"""
Expand All @@ -161,7 +193,18 @@ def _get_headers(self):
:return:
"""

return {"Authorization": f"Bearer {self.token}", "Content-Type": "application/json"}
return {
"Authorization": f"Bearer {self.bearer_token}",
"Content-Type": "application/json",
# "User-Agent": self.get_user_agent(self),
}

def get_user_agent(self):
library = "Home Assistant Alexa Notification Skill"
aws_region = self.aws_region
logger.debug(f"AWS_DEFAULT_REGION is { aws_region}")
default_user_agent = "Alexa Notification Agent"
return f"{library} - {aws_region} - {default_user_agent}"

def _check_response_errors(self, response: HTTPResponse) -> Union[bool, str]:
if response.status == 401:
Expand Down Expand Up @@ -290,7 +333,11 @@ def post_ha_event(self, response: str, response_type: str, **kwargs) -> Optional
:param kwargs: Additional parameters to send to the Home Assistant server.
:return: The text to speak to the user.
"""
body = {"event_id": self.ha_state.event_id, "event_response": response, "event_response_type": response_type}
body = {
"event_id": self.ha_state.event_id,
"event_response": response,
"event_response_type": response_type,
}
body.update(kwargs)

if self.handler_input.request_envelope.context.system.person:
Expand Down Expand Up @@ -636,7 +683,8 @@ def process(self, handler_input):
handler_input.attributes_manager.request_attributes["_"] = data


"""

"""
The SkillBuilder object acts as the entry point for your skill, routing all request and response
payloads to the handlers above. Make sure any new handlers or interceptors you've
defined are included below.
Expand Down
13 changes: 9 additions & 4 deletions lambda/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
isodate~=0.6.0
pydantic~=1.10.4
typing-extensions~=4.10.0
ask-sdk~=1.19.0
isodate
pydantic
typing-extensions
urllib3
urllib3[socks]
pysocks
ask_sdk_core
ask_sdk_model
awslambdaric
4 changes: 2 additions & 2 deletions lambda/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,12 @@


class HaStateError(BaseModel):
_error: bool = Field(default=True, alias="error", title="error")
error: bool = Field(default=True, alias="error", title="error")
text: str


class HaState(BaseModel):
_error: bool = Field(default=False, alias="error", title="error")
error: bool = Field(default=False, alias="error", title="error")
event_id: Optional[str]
suppress_confirmation: bool = Field(default=False)
text: str

0 comments on commit 9c098d9

Please sign in to comment.