homelab-codex-ws/docs/sessions/2026-07-23-control-plane-remediation-e2e.md
oskar 01db57ab82 fix(kb): przepiecie wszystkich odwolan wewnetrznych po migracji
126 plikow (md, yaml, sh, py) odwolywalo sie do sciezek sprzed migracji.

  15  markdown-linkow [..](..) -> policzona sciezka WZGLEDNA wobec pliku
      odsylajacego (wczesniej czesc z nich byla repo-root-relative i nie
      rozwiazywala sie z katalogu, w ktorym lezala)
 200  odwolan tekstowych (backticki, proza, yaml, importy w kodzie)
      -> nowa sciezka repo-root-relative, zgodnie z konwencja repo
   5  linkow rodzenstwa (gole nazwy plikow, np. "](DEPLOY.md)") — dzialaly
      tylko w starym katalogu; przeliczone recznie

Objete m.in.: CLAUDE.md (scripts/onboard/README.md -> kb/runbooks/
node-onboarding-tool.md, docs/backlog.md -> kb/phases/backlog.md),
README.md, .claude/skills/, 20 session logow, kod jobow.

Ostatnie 5 odwolan pochodzi z tresci wciagnietej rebasem z origin/master
(session log 2026-07-31, override node-agenta na SOLARII, dwie pozycje
backlogu) — wskazywaly na docs/incidents/, docs/kb/modules/ i
services/narty27/README.md sprzed migracji.

Dodany wzajemny link miedzy kb/services/control-plane.md (stub kodu)
a kb/subsystems/control-plane.md (opis, deprecated) — dwa dokumenty o tym
samym systemie, latwe do pomylenia.

Weryfikacja na 790 plikach: 0 odwolan do starych sciezek,
0 martwych linkow markdown. Lint OKF: 190/190 plikow ZGODNE.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 16:53:57 +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 kb/phases/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 kb/phases/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).