Problem
Dostępu do parkingu na naszym osiedlu strzeże brama.
Można ją otworzyć na dwa sposoby: pilotem lub dzwoniąc na specjalny numer telefonu. Brama sprawdza, czy numer, który do niej dzwoni, jest na liście zapisanej w jej pamięci – jeśli tak, otwiera się.
Działa to świetnie dla domowników. Gorzej z gośćmi: ich numerów nie ma na liście, więc to ja muszę im otwierać. Czekając na przyjazd gości, trzeba mieć telefon na oku i wypatrywać wiadomości od nich, żeby w miarę sprawnie otworzyć bramę.
Listą numerów w sterowniku zarządza spółdzielnia mieszkaniowa. Szybkie dopisanie numeru graniczy z cudem.
Pilot ma też dość ograniczony zasięg: żeby nadać sygnał, trzeba być stosunkowo blisko. Trzeba więc podjechać pod bramę, zatrzymać się, poczekać, aż się otworzy, i dopiero jechać dalej.
W praktyce, dojeżdżając do domu, używałem opcji telefonicznej. Z czasem opanowałem to tak dobrze, że wiedziałem, iż za zakrętem, w połowie drogi do progu zwalniającego, trzeba nacisnąć przycisk wybierania numeru, żeby brama kończyła się otwierać dokładnie wtedy, gdy do niej dojadę.
Miało to jednak zasadniczy minus: głośny sygnał wybierania numeru i komunikat, że brama odrzuciła połączenie. Nigdy nie pamiętałem, żeby wyciszyć telefon, a było to irytujące. Zwłaszcza gdy na pokładzie ktoś przed chwilą zdążył zasnąć i miałem nadzieję na udany transfer do łóżeczka i spokojny wieczór.
Zacząłem więc kombinować.
Rozwiązania
Wiedziałem, że chcę mieć coś, co pozwoli mi otwierać bramę z daleka i po cichu.
Pierwszą rzeczą, o której pomyślałem, była inżynieria wsteczna pilota. Znalazłem nawet dokumentację układu, który był w nim zamontowany. Okazało się jednak, że za każdym razem nadaje on inny kod (tzw. kod zmienny, ang. rolling code), więc prosty atak typu replay, czyli przechwycenie i ponowne odtworzenie sygnału, nie wchodził w grę. Wydaje mi się, że przy rejestracji pilota ustalany jest jakiś sekret, zapisany w pamięci pilota i sterownika, z którego wyliczane są kolejne kody. Bez dostępu do sterownika dorobienie dodatkowego pilota nie było więc takie oczywiste.
Przemknęło mi też przez myśl wykorzystanie oryginalnego pilota i dorobienie elektroniki, która „naciskałaby" jego przycisk. Tu pojawił się jednak kolejny duży problem: brama jest dość daleko od mieszkania. Na tyle daleko, że sygnał z pilota nie ma w ogóle szans tam dotrzeć – bez dodatkowych modyfikacji zwiększających jego moc.
Pomysł ze sterowaniem radiowym został więc porzucony.
Na szczęście została jeszcze jedna droga: telefoniczna.
Od lat mam numer VoIP od ipfon.pl. Zarejestrowałem go w spółdzielni jako numer otwierający bramę. Znalazłem też świetny dodatek do Home Assistanta: ha-sip, który potrafi zadzwonić przez SIP na numer sterownika.
W ten sposób zyskałem w Home Assistancie przycisk, który po cichutku dzwoni do bramy i ją otwiera. Połowa problemu rozwiązana!
Brakowało tylko czegoś, co mogę wysłać gościowi i co działa bez logowania, bez aplikacji i tylko przez określony czas.
Backend
Plan był w miarę prosty:
telefon gościa ──HTTPS──▶ ingress ──▶ gate-opener
│
POST webhook
▼
Home Assistant ──▶ ha-sip ──▶ ☎ brama
Założenia były mniej więcej takie:
- chcę móc wygenerować link, który po udostępnieniu gościowi pozwoli otworzyć bramę,
- interfejs musi mieć duży przycisk, który łatwo trafić palcem :D
- po jego naciśnięciu ma zostać wysłane polecenie do Home Assistanta, żeby zadzwonił do bramy,
- chcę zminimalizować uprawnienia w Home Assistancie tak, żeby dało się tylko otworzyć bramę i nic więcej,
- chcę napisać to w Go, najlepiej tylko ze standardową biblioteką, żeby łatwo było to utrzymać,
- nie chcę przechowywać żadnego stanu: żadnych kont, baz danych ani logowań,
- link ma mieć określony czas ważności i być w miarę krótki,
- chcę móc unieważnić link.
Ostatecznie plan wykrystalizował się następująco. Do linku doklejany jest token w poniższym formacie (rozmiary w bajtach), podpisany HMAC-SHA256:
wersja(1) | ważny do(8) | id(6) | imię gościa(0..40) | hmac-sha256[:16]
→ zakodowane base64url
Serwer sprawdza tylko podpis i datę ważności. Żeby unieważnić pojedynczy link, dopisuję jego ID do zmiennej środowiskowej GATE_REVOKED. Żeby unieważnić wszystkie, zmieniam klucz podpisujący (sekret HMAC).
Linki generuję poleceniem uruchamianym w kontenerze, bo klucz podpisujący nigdy go nie opuszcza:
kubectl -n gate exec deploy/gate-opener -- /gate-opener token -ttl 2d -name Ania
W odpowiedzi dostaję gotowy link, datę ważności i jego ID, którego potem użyję, jeśli będę chciał go unieważnić.
Aplikacja nie ma tokena do Home Assistanta. Jest za to automatyzacja uruchamiana przez webhook. Aplikacja zna tylko sekretny adres webhooka, a webhook ma ustawione local_only: true, więc z Internetu nikt go nie wywoła (zresztą Home Assistant jest dostępny tylko w sieci lokalnej).
Token siedzi w linku po znaku # (https://brama.example.com/#AQAAAABq…). Przeglądarka nigdy nie wysyła tej części adresu do serwera, więc token nie trafia do logów dostępu ani do nagłówka Referer. Strona ładuje się każdemu, a token jedzie do aplikacji dopiero w treści żądania POST, które wysyła przycisk.
Samo wejście na stronę (GET) nigdy nie otwiera bramy, więc podglądy linków w komunikatorach są niegroźne. Bramę otwiera dopiero POST wysyłany z tej strony, o ile token jest poprawny i ważny.
Uczciwie trzeba dodać, czego to nie rozwiązuje: link działa dla każdego, kto go ma, aż do wygaśnięcia, a token zostaje w historii przeglądarki gościa i w czacie, którym go wysłałem. Dlatego liczę na krótkie czasy ważności i możliwość unieważniania.
Frontend
Zrobienie czegokolwiek estetycznego przerasta moje zdolności, więc frontend zleciłem Claude’owi.
Brama nie raportuje swojego stanu. Wiem tylko, że otwiera się 25 sekund, stoi otwarta 10 sekund i zamyka się przez kolejne 25. Poprosiłem, żeby ikona to odzwierciedlała, i dostałem:
- ikonę bramy, której skrzydła zwężają się w trakcie otwierania, z pierścieniem postępu i odliczaniem,
- blokadę przycisku, gdy brama się otwiera albo stoi otwarta.
Stan cyklu pamięta serwer, a nie przeglądarka, więc odświeżenie strony albo drugi gość z tym samym linkiem widzą dokładnie to samo. Podczas zamykania przycisk jest znowu aktywny, więc kto nie zdąży, może otworzyć bramę jeszcze raz. Czasy cyklu są konfigurowalne (GATE_OPENING, GATE_OPEN_HOLD, GATE_CLOSING), więc przy innej bramie wystarczy je zmierzyć stoperem i wpisać.
Wszystko jest pięknie animowane, czytelne i – co najważniejsze – działa! Jestem zachwycony :D
Kto otworzył bramę?
Chciałem jeszcze dostawać powiadomienie o tym, kto i kiedy otworzył bramę.
Webhook dostaje w JSON-ie imię gościa z linku, ID linku i adres IP. Automatyzacja w Home Assistancie otwiera bramę, wysyła mi powiadomienie na telefon i zapisuje wpis w dzienniku:
triggers:
- trigger: webhook
webhook_id: CHANGE_ME_WEBHOOK_ID
allowed_methods: [POST]
local_only: true
actions:
- action: hassio.addon_stdin
data:
addon: CHANGE_ME_ha-sip
input:
command: dial
number: sip:CHANGE_ME@sip.example.com
sip_account: 1
ring_timeout: 30
menu:
timeout: 15
post_action: hangup
- action: notify.mobile_app_CHANGE_ME
continue_on_error: true
data:
title: Brama
message: >-
Otwarta przez gościa: {{ trigger.json.name | default('', true) or 'bez imienia' }},
{{ now().strftime('%d.%m o %H:%M') }}
- action: logbook.log
data:
name: Brama
message: >-
otwarta przez gościa {{ trigger.json.name | default('', true) or 'bez imienia' }}
(link {{ trigger.json.token_id | default('?') }}, IP {{ trigger.json.ip | default('?') }})
continue_on_error jest ważne: błąd powiadomienia (np. gdy telefon jest offline) nie może przerwać automatyzacji, więc kolejne kroki, jak wpis w dzienniku, i tak się wykonają.
Open-source
Skoro działa, może komuś się przydać.
Jeśli masz bramę, którą Home Assistant potrafi otworzyć (nie tylko przez SIP, ale w jakikolwiek sposób!), kod jest na GitHubie.
W repozytorium jest gotowe wdrożenie na Docker Compose i Kubernetesa (Kustomize), obraz distroless o rozmiarze ok. 10 MB na amd64, arm64 i armv7 (czyli także na Raspberry Pi), interfejs po polsku i angielsku, wybierany według języka telefonu.
Uwagi i pull requesty mile widziane.