From cbce8a1b48b17b8d924fe3c2d15c591924930466 Mon Sep 17 00:00:00 2001 From: oskar Date: Thu, 23 Jul 2026 15:10:20 +0200 Subject: [PATCH] =?UTF-8?q?docs(sessions):=202026-07-22/23=20control-plane?= =?UTF-8?q?=20=E2=80=94=20dziura=20operator=5Fui=20+=20pierwszy=20pe=C5=82?= =?UTF-8?q?ny=20cykl=20remediacji=20bez=20SSH?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zamknięcie wątku "czemu mózg nie leczy floty": fix publicznego bindu 18180 bez autoryzacji (9a5c160), remediacja pull-based przez node-agent bez SSH (2dac154), fix uprawnień actions/ na PIHA, pierwszy udany cykl E2E (31s). Co-Authored-By: Claude Sonnet 5 --- ...026-07-23-control-plane-remediation-e2e.md | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 docs/sessions/2026-07-23-control-plane-remediation-e2e.md diff --git a/docs/sessions/2026-07-23-control-plane-remediation-e2e.md b/docs/sessions/2026-07-23-control-plane-remediation-e2e.md new file mode 100644 index 0000000..a3cab52 --- /dev/null +++ b/docs/sessions/2026-07-23-control-plane-remediation-e2e.md @@ -0,0 +1,110 @@ +# 2026-07-22/23 — Control-plane: dziura w operator_ui + pierwszy pełny cykl remediacji bez SSH + +Zamknięcie wielosesyjnego wątku "czemu mózg nie leczy floty". Dwa dni pracy, +zakończone pierwszym w historii systemu pełnym cyklem remediacji: supervisor → +approval → executor → node-agent → docker restart → completed. + +## Wykonane + +1. **Odkrycie bezpieczeństwa + fix (commit `9a5c160`, 2026-07-22).** Przy + reconie kanału dla remediacji CC odkrył, że `operator_ui.py` (port 18180) + nasłuchiwał na `0.0.0.0` na PUBLICZNYM VPS (`135.181.153.108`) BEZ ŻADNEJ + autoryzacji. `curl` z zewnątrz do `/actions` zwracał HTTP 200. Gorzej: + `do_POST` obsługuje `/action/mutate` → `mutate_action(action_id, + target_status)`, które przenosi akcje między stanami WŁĄCZNIE z + `"approved"` — dowolna osoba z internetu mogła zatwierdzić akcję + remediacyjną. Jedyne co chroniło: executor w tamtym momencie nie umiał + jeszcze wykonać akcji (brak SSH) — czyli przypadek, nie zabezpieczenie. + Fix: dual-bind wg wzorca fleet-prometheus — `127.0.0.1:18180:8080` + + `${TAILSCALE_BIND_IP}:18180:8080`, nowy `services/control-plane/env.example`. + Zweryfikowane po deployu: `curl` na publiczny IP → HTTP 000 (brak + odpowiedzi), `curl` po Tailscale → HTTP 200. Konsumenty nietknięte: + node-agent na VPS (`network_mode: host`, localhost działa dzięki bindowi + 127.0.0.1), materializer PIHA (łączy się po `100.95.58.48`). + **FOOTGUN do zapamiętania:** gdy brakuje `.env`, `docker compose` tylko + OSTRZEGA i po cichu wraca do bindu `0.0.0.0` — nie failuje. + +2. **Remediacja bez SSH (commit `2dac154`, 2026-07-22).** Diagnoza stanu + wyjściowego: łańcuch działał aż do wykonania — supervisor generuje akcje + (18 pending), UI pokazuje z Approve/Reject, przejście + pending→approved→running OK, executor podejmuje i dispatchuje. Padało + TYLKO wykonanie: executor robił `ssh oskar@{node} docker restart + {container}`, a w kontenerze NIE MA klienta ssh (fail w 6ms = + `FileNotFoundError`), nie ma klucza, nazwa "piha" nie rozwiązuje się z + VPS, a klucz VPS nie był autoryzowany na piha. + + **Decyzja architektoniczna:** nie dodawać SSH do executora (kontener na + publicznym VPS z powłoką na całą flotę = zły blast radius). Zamiast tego: + executor ZLECA, node-agent na docelowym węźle WYKONUJE lokalnie przez + swój `docker.sock`. Kierunek PULL — węzeł sam sięga po zlecenia, VPS nie + ma dostępu do węzłów. + + Wybrany kanał: rsync-pull istniejącym kluczem agenta (tym samym co + `_ship_events_to_vps`). Odrzucono HTTP pull — bo właśnie odkryto, że 18180 + jest publiczny i bez auth (patrz pkt 1), dokładanie tam kanału zleceń + zwiększałoby powierzchnię ataku. + + Przepływ: supervisor → pending → (operator) approved → executor + `_execute_action` → running + zapis + `actions/dispatch//.json` → agent pull (co + `CHECK_INTERVAL` 60s) → walidacja → docker SDK `.restart()` → event + `action_result` → rsync do VPS → executor `_reconcile_running_actions` → + completed/failed. Timeout `ACTION_TIMEOUT_SECS` (300s) → failed z jasnym + powodem. + + Zabezpieczenia: walidacja `node == self.node_name`; whitelist typów = + `{container_restart}` (wszystko inne odrzucone z jawnym `action_result`); + guard przed restartem samego node-agenta; brak wykonywania dowolnych + poleceń z payloadu; idempotencja (ponowne zlecenie = no-op). 183 testy. + +3. **Fix uprawnień na PIHA (ręcznie, 2026-07-23 — NIE w repo).** Pierwszy + test E2E padł: agent rzucał `[Errno 13] Permission denied: + /opt/homelab/actions/dispatch` co cykl. Root cause to ZNANY, POWRACAJĄCY + (już 4. raz — patrz sekcja "Tech-debt: globalny porządek uid/gid/uprawnień + we flocie" w `docs/backlog.md`) motyw + uid/gid na PIHA: oskar ma uid 1004, kontener agenta biega jako uid 1000 + (= user `pi` na hoście). `/opt/homelab/actions` było `oskar:oskar + drwxr-xr-x` (utworzone w maju), podczas gdy DZIAŁAJĄCY wzorzec to + `/opt/homelab/events` = `oskar:pi drwxrwsr-x` (grupa `pi`, zapis dla + grupy, setgid). Fix ręcznie: `chgrp -R pi` + `chmod -R g+w` + `chmod g+s` + na katalogach. Executor tymczasem zachował się wzorowo: po 300s timeoutu + przeniósł akcję do `failed` z czytelnym powodem, nic nie zawisło. + +4. **Pierwszy działający cykl remediacji (2026-07-23) — CEL OSIĄGNIĘTY.** Po + fixie uprawnień agent od razu podjął zalegle zlecenie z poprzedniego dnia + i zrestartował `node_exporter`, a przy kolejnych cyklach logował "already + processed — skipping (idempotency)" — idempotencja potwierdzona w boju. + + Czysty test E2E (`action_id test-e2e-b`): 12:05:25 Executing → 12:05:25 + Dispatched `container_restart` do node-agent on piha → 12:05:56 Action + completed. **CAŁY CYKL 31 SEKUND.** Dowód po drugiej stronie: + `node_exporter` na PIHA uptime spadł z 30 godzin do minut. Restart + wykonany na zdalnym węźle BEZ ANI JEDNEGO połączenia SSH z control-plane. + +## Lessons learned + +- Recon pod jedno zadanie odkrył poważniejszy problem niż samo zadanie + (publiczny 18180) — kolejność prac trzeba było odwrócić: najpierw zamknąć + bramę, potem włączać realne wykonywanie akcji. Do momentu fixu remediacji + chroniła nas WYŁĄCZNIE niesprawność executora. +- Ten sam motyw uid/gid (host oskar 1004 vs kontener 1000) ugryzł już + czwarty raz: klucz SSH agenta, docker.sock, `events/`, teraz `actions/`. + Wzorzec działający = grupa współdzielona + setgid. +- Zepsuty JSON w `approved/` powodował, że executor próbował go przetworzyć + W KÓŁKO co 10s ("Failed to move ... to running: Expecting value") — brak + przeniesienia do `failed/`/`rejected/`. +- Cytowanie w zagnieżdżonych heredoc przez ssh: `'$VAR'` wewnątrz + pojedynczych cudzysłowów podstawia się LOKALNIE — zepsuło JSON akcji + testowej. Bezpieczne: heredoc z `<<"JSON"` i wartości na sztywno. + +## Stan + +Pierwszy w historii systemu pełny cykl remediacji end-to-end potwierdzony w +produkcji (PIHA), bez SSH z control-plane do węzłów. Publiczna dziura +autoryzacji na `operator_ui.py:18180` zamknięta (bind ograniczony do +Tailscale). Otwarte follow-upy — patrz `docs/backlog.md` (retry-w-nieskończoność +zepsutego JSON, uprawnienia `actions/` na innych węzłach, brak twardego checka +`.env`/`TAILSCALE_BIND_IP` w `deploy-local.sh`, brak autoryzacji w +`operator_ui.py`, zapchana approval queue przez `alert_only`, brak +crash-loop→`container_restart`, brak Telegram yes/no dla pending, martwy +`homeassistant5` na PIHA, repo-less node-agent na lustro).