homelab-codex-ws/docs/sessions/2026-07-23-control-plane-remediation-e2e.md
oskar 510fe0b600 feat(kb): frontmatter OKF dla 39 session logow
Session logi zostaja w docs/sessions/ (decyzja z etapu 1). Dodany wylacznie
blok frontmattera: type: session-log, visibility: private, status: active,
updated = data ostatniego commita pliku.

Tresc nietknieta — kazdy plik to +9/-0 linii.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 16:58:04 +02:00

6.6 KiB

okf type visibility status updated links
0.1 session-log private active 2026-07-23

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/mutatemutate_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/<node>/<action_id>.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).