--- okf: "0.1" type: runbook visibility: private status: active updated: 2026-08-05 links: - ../services/kb-site.md - ./npm-api.md - ../phases/okit-cloudflare.md --- # kb-site — deploy, publikacja treści, ingress (kb-e2a24af3.okit.pl) Serwis: `services/kb-site/` (nginx:alpine na PIHA, host port **8250**). Generator: `scripts/kb/gen_pages.py`. Publiczny adres: `https://kb-e2a24af3.okit.pl`. Kolejność ma znaczenie: **DNS → NPM host → cert**. Certyfikat Let's Encrypt leci challenge'em HTTP-01, więc rekord A musi już wskazywać na łącze domowe, a vhost musi już odpowiadać na porcie 80, zanim zamówisz cert. --- ## 0. Wymagania wstępne - PIHA ma checkout repo w `~/homelab-codex-ws` i działającego Dockera. - npm@PIHA działa (`http://192.168.31.5:81`), port 80/443 z routera jest przekierowany na PIHA — tak samo jak dla `vikunja.okit.pl`. - `scripts/npm/.env` wypełniony poświadczeniami (patrz `kb/runbooks/npm-api.md`). - Strefa `okit.pl` w Cloudflare (patrz `kb/phases/okit-cloudflare.md`). - **Token bramki dostępny na węźle, z którego generujesz** (SATURN/SOLARIA — nie na PIHA): plik `/opt/homelab/config/kb-site/.env` z jedną linią `ACCESS_TOKEN=`, `chmod 600`. To wyjątek od konwencji z CLAUDE.md: `config//` trzyma zwykle sekrety węzła, który serwis *uruchamia*, a ten token jest potrzebny tam, gdzie serwis się *generuje*. Kontener nginx na PIHA nadal nie ma żadnej konfiguracji ani sekretów. Wartość to ten sam token, co w advanced config proxy hosta w NPM. Żyje wyłącznie w bazie NPM i w tym pliku — **nigdy w repo**. --- ## 1. Deploy serwisu na PIHA ```bash # na PIHA cd ~/homelab-codex-ws git pull docker compose -f services/kb-site/docker-compose.yml up -d ``` Bez `.env` i bez override'a — serwis nie ma konfiguracji ani sekretów. Kontener wstanie, ale healthcheck będzie **unhealthy do czasu wgrania treści** (pusty wolumen = brak `index.html`). To normalne między krokiem 1 a 3. ```bash docker ps --filter name=kb-site docker volume ls | grep kb-site # kb-site_kb-site_content ``` --- ## 2. Generacja treści + kontrola wycieków (SATURN / SOLARIA) Generujemy w checkoucie repo, na węźle z którego pracujesz — nie na PIHA. ```bash set -a; . /opt/homelab/config/kb-site/.env; set +a # ACCESS_TOKEN test -n "$ACCESS_TOKEN" || echo "BRAK TOKENU — nie publikuj tego builda" python3 scripts/kb/gen_pages.py --base-url https://kb-e2a24af3.okit.pl python3 scripts/kb/gen_pages.py --check echo $? # 0 = czysto, 1 = trafienia ``` ### Token w linkach Generator czyta `ACCESS_TOKEN` ze środowiska i dopisuje `?key=` do **każdego linku wewnętrznego** (karty na spisie, linki między dokumentami, powrót „All documents"). Bez tego bramka NPM odbija 403 przy każdym kliknięciu i działa wyłącznie ręcznie sklejony URL — strona jest wtedy technicznie żywa, ale nie da się po niej chodzić. Token podajemy zmienną środowiskową, nie flagą `--access-token`: argument z linii poleceń ląduje w historii shella i jest widoczny w `ps` dla każdego użytkownika węzła. Flaga istnieje do jednorazowych generacji testowych. Sanity check tuż po generacji — `Token: TAK` w wyjściu generatora, a w HTML: ```bash grep -c 'key=' build/kb-site/index.html # > 0; 0 = build bez tokenu ``` Generacja bez tokenu **nie jest błędem** — to tryb lokalnego podglądu (`build/` do przejrzenia w przeglądarce, bez publikacji). Nie wolno jej tylko wgrać na PIHA. Token nie jest sekretem chroniącym treść — jest zaporą przed przypadkowym trafieniem. Po tej zmianie siedzi w treści każdej serwowanej strony, więc jedna zapisana strona wydaje go w całości. To nie zmienia modelu zagrożeń (URL wejściowy zawsze go niósł), ale przesądza, że bramką prywatności jest `--check` i pole `visibility`, nie ten token. ### Kontrola wycieków `--check` skanuje **wygenerowany HTML**, nie źródła: adresy IP (RFC1918, Tailscale 100.64/10, publiczne v4/v6), porty 1024-65535, ścieżki `/home/` i `/opt/`, długie hexy i ciągi base64 wyglądające na tokeny. **Kontrola jest bramką publikacji — nie kopiuj treści na PIHA przy exit 1.** Stan na 2026-08-04 (11 dokumentów public): **exit 0 — czysto, 22 trafienia wyciszone whitelistą.** Wszystkie wyciszone to `path-host` `/opt/homelab/...` w dokumentach opisujących layout runtime (`subsystems/observer`, `subsystems/standards`, `subsystems/service-model`, `subsystems/event-system`, `subsystems/agent-operating-procedures`, `subsystems/action-approval-model`, `runbooks/node-onboarding`). Zero IP, zero portów, zero tokenów. Decyzja operatora 2026-08-04: `/opt/homelab` jest świadomym wyjątkiem — standardowa ścieżka deploy rootu, ta sama na każdym węźle, nie ujawnia sekretów ani topologii. Wpis siedzi w `scripts/kb/check_whitelist.txt` razem z uzasadnieniem. Wycisza wyłącznie warianty `/opt/homelab/...`; `/home/...`, adresy, porty i tokeny dalej zapalają czerwone. Każdy kolejny wyjątek podlega tej samej regule: najpierw próba wyczyszczenia źródła, wpis do whitelisty dopiero jako świadoma decyzja, zawsze z komentarzem i datą. --- ## 3. Wgranie treści do wolumenu (helper alpine) Wzorzec z `services/narty27/README.md`, rozszerzony z jednego pliku na drzewo: `docker cp` nie zapisze do montowania `:ro`, więc treść wjeżdża jednorazowym kontenerem pomocniczym, który montuje wolumen zapisywalnie. ```bash # 1. spakuj build (na węźle generującym) tar -czf /tmp/kb-site.tgz -C build/kb-site . # 2. wyślij na PIHA scp /tmp/kb-site.tgz piha:/tmp/kb-site.tgz # 3. podmień zawartość wolumenu przez helper ssh piha 'docker run --rm \ -v kb-site_kb-site_content:/content \ -v /tmp:/src:ro \ alpine sh -c "rm -rf /content/* /content/.[!.]* 2>/dev/null; \ tar -xzf /src/kb-site.tgz -C /content"' # 4. posprzątaj staging rm /tmp/kb-site.tgz ssh piha 'rm /tmp/kb-site.tgz' ``` `rm -rf /content/*` przed rozpakowaniem jest **obowiązkowe**: bez tego strona dokumentu przełączonego z `public` na `private` zostałaby w wolumenie i dalej serwowała treść, której już nie publikujemy. Publikacja jest podmianą całości, nie dogrywaniem plików. Weryfikacja z PIHA: ```bash services/kb-site/healthcheck.sh curl -sI http://192.168.31.5:8250/index.html | head -1 ``` --- ## 4. Cloudflare — rekord A ``` Type: A Name: kb-e2a24af3 Content: # ten sam, co ma vikunja.okit.pl Proxy: DNS only (szara chmurka) TTL: Auto ``` Docelowy IP weź z istniejącego rekordu `vikunja` w tej samej strefie zamiast wpisywać z pamięci — łącze domowe potrafi zmienić adres. **DNS only, nie proxied**: challenge HTTP-01 w kroku 5 musi trafić na nginx, a nie na krawędź Cloudflare. Włączenie proxy to osobna decyzja, po tym jak cert już działa. Split-horizon (patrz `kb/phases/okit-cloudflare.md`): w Pi-hole na PIHA dodaj Local DNS Record `kb-e2a24af3.okit.pl → 192.168.31.5`, żeby klienci w LAN szli prosto do npm, a nie przez hairpin NAT na routerze. Sprawdzenie propagacji (z hosta poza LAN albo przez publiczny resolver): ```bash dig +short kb-e2a24af3.okit.pl @1.1.1.1 ``` --- ## 5. NPM — proxy host + certyfikat Wszystkie komendy `npm_api.py` są **dry-run domyślnie**; realna zmiana dopiero z `--apply`. Puść najpierw bez `--apply` i przeczytaj payload. ```bash # 5a. host: kb-e2a24af3.okit.pl -> PIHA:8250 python3 scripts/npm/npm_api.py --npm piha create-host \ --domain kb-e2a24af3.okit.pl \ --forward-host 192.168.31.5 --forward-port 8250 \ --block-exploits --http2-support # ...i to samo z --apply # 5b. cert Let's Encrypt (HTTP-01 — wymaga działającego kroku 4 i 5a) python3 scripts/npm/npm_api.py --npm piha create-cert --domain kb-e2a24af3.okit.pl --apply # 5c. podepnij cert pod host i wymuś HTTPS python3 scripts/npm/npm_api.py --npm piha list-hosts # weź HOST_ID python3 scripts/npm/npm_api.py --npm piha list-certs # weź CERT_ID python3 scripts/npm/npm_api.py --npm piha set-cert HOST_ID CERT_ID --ssl-forced --apply ``` Jeśli w npm@PIHA istnieje już wildcard `*.okit.pl` (faza 1 z `kb/phases/okit-cloudflare.md`), pomiń 5b i w 5c podepnij jego `CERT_ID` — jeden cert DNS-01 zamiast kolejnego per-host HTTP-01. Diagnostyka nieudanego certu: `ssh piha 'docker logs npm --tail 100'` — NPM zwraca wyjście certbota w treści błędu, a przy porażce challenge'u kasuje wiersz certyfikatu (szczegóły w `kb/runbooks/npm-api.md`). --- ## 6. Weryfikacja końcowa ```bash set -a; . /opt/homelab/config/kb-site/.env; set +a BASE=https://kb-e2a24af3.okit.pl curl -sI "$BASE/?key=$ACCESS_TOKEN" | head -1 # 200 curl -s "$BASE/?key=$ACCESS_TOKEN" | grep -c 'class="cards"' # spis jest curl -s "$BASE/subsystems/observer.html?key=$ACCESS_TOKEN" | tail -5 # stopka: data + commit # bramka faktycznie broni — bez tokenu ma NIE być 200 curl -sI "$BASE/" | head -1 # przejście po linku ze spisu, tak jak zrobi to przeglądarka: # wyciągnij pierwszy href z indexu i pobierz go bez dopisywania niczego ręcznie LINK=$(curl -s "$BASE/?key=$ACCESS_TOKEN" \ | grep -o 'href="[^"]*key=[^"]*"' | head -1 | cut -d'"' -f2) curl -sI "$BASE/$LINK" | head -1 # 200 ``` Ostatni test jest właściwą weryfikacją tej bramki: sprawdza, że link *wygenerowany przez generator* przechodzi przez NPM, a nie że ręcznie sklejony URL działa. W przeglądarce: `https://kb-e2a24af3.okit.pl/?key=` — spis pogrupowany per type, kłódka bez ostrzeżeń, wejście w dowolną kartę **kliknięciem** (nie przez pasek adresu), powrót linkiem „All documents", i z powrotem w inną kartę. Kontrola treści (ręczna, jednorazowa po pierwszej publikacji): przejrzyj spis i potwierdź, że nie ma tam nic, czego nie chcesz mieć w internecie. Generator pilnuje pola `visibility`, ale to człowiek decyduje, co dostaje `public`. --- ## 7. Rutynowa aktualizacja treści Po każdej zmianie w `kb/**/*.md`, która dotyczy dokumentów `public`: ```bash set -a; . /opt/homelab/config/kb-site/.env; set +a # ACCESS_TOKEN test -n "$ACCESS_TOKEN" \ && python3 scripts/kb/gen_pages.py --base-url https://kb-e2a24af3.okit.pl \ && python3 scripts/kb/gen_pages.py --check \ && grep -q 'key=' build/kb-site/index.html \ && tar -czf /tmp/kb-site.tgz -C build/kb-site . \ && scp /tmp/kb-site.tgz piha:/tmp/kb-site.tgz \ && ssh piha 'docker run --rm -v kb-site_kb-site_content:/content -v /tmp:/src:ro \ alpine sh -c "rm -rf /content/* /content/.[!.]* 2>/dev/null; tar -xzf /src/kb-site.tgz -C /content"' \ && ssh piha 'rm /tmp/kb-site.tgz' \ && rm /tmp/kb-site.tgz ``` Łańcuch na `&&` jest celowy — każde ogniwo zatrzymuje publikację: pusty `ACCESS_TOKEN` (build byłby nieklikalny), `--check` z exit 1 (wyciek), brak `key=` w spisie (token był pusty albo nie dojechał do generatora). Kontener nie wymaga restartu — nginx czyta wolumen na bieżąco. --- ## 8. Rollback i typowe problemy | Objaw | Przyczyna | Reakcja | |---|---|---| | healthcheck unhealthy, 404 na `/` | pusty wolumen — treść nigdy nie wjechała | krok 3 | | stara strona po publikacji | pominięte `rm -rf /content/*` | powtórz krok 3 w całości | | dokument nie pojawia się na stronie | brak `visibility: public` albo niepoprawny frontmatter (fail-closed) | `python3 scripts/kb/check_okf.py`, potem regeneracja | | link renderuje się jako tekst z `[private]` | cel jest prywatny albo nie istnieje | tak ma być — to nie błąd | | index otwiera się z tokenem, ale **kliknięcie w kartę daje 403** | build wygenerowany bez `ACCESS_TOKEN` — linki nie niosą `?key=` | ustaw token (krok 0) i powtórz kroki 2-3; `grep -c 'key=' build/kb-site/index.html` | | 403 także na URL z tokenem | token w NPM został wymieniony i rozjechał się z `/opt/homelab/config/kb-site/.env` | zsynchronizuj plik z advanced config w NPM, przegeneruj i opublikuj — stary build zostaje z martwym tokenem w każdym linku | | `--check` zapala nowe trafienie | do dokumentu `public` wjechał adres/ścieżka/token | popraw źródło; whitelist tylko świadomie | | cert nie schodzi | rekord A nie propagował, proxy Cloudflare włączone, albo port 80 niedostępny | krok 4, potem 5b ponownie | Awaryjne wygaszenie wystawki (bez ruszania NPM i DNS): ```bash ssh piha 'cd ~/homelab-codex-ws && docker compose -f services/kb-site/docker-compose.yml down' ``` Wolumen zostaje; `up -d` przywraca stan sprzed. Pełne odtworzenie od zera to kroki 1-3 — treść jest artefaktem repo, nie danymi.