Strona dla gościa: brama zamknięta, otwiera się, otwarta, zamyka się


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.