111 lines
6.5 KiB
Markdown
111 lines
6.5 KiB
Markdown
|
|
# 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/<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).
|