homelab-codex-ws/kb/runbooks/kb-site-deploy.md
oskar bb3792d219 docs(kb-site): przekaz ACCESS_TOKEN generatorowi w procedurze publikacji
Poprzedni commit nauczyl gen_pages.py dopisywac token do linkow, ale nic
go nie podawalo — publikacja poszlaby stara sciezka i dalaby build
z golymi linkami, czyli stan sprzed fiksa.

kb-site nie ma skryptu deployu: generator wolany jest wylacznie recznie
z runbooka (kroki 2 i 7), wiec to tam token musi wejsc.

Zrodlo tokenu: /opt/homelab/config/kb-site/.env na wezle GENERUJACYM
(SATURN/SOLARIA), nie na PIHA. To swiadome odstepstwo od konwencji
config/<serwis>/ z CLAUDE.md — plik trzyma zwykle sekrety wezla, ktory
serwis uruchamia, a ten token jest potrzebny tam, gdzie serwis sie
generuje. Kontener nginx dalej nie ma zadnej konfiguracji ani sekretow;
odnotowane w service.yaml i env.example, zeby nikt nie szukal .env na PIHA.

Token idzie zmienna srodowiskowa (set -a; . plik; set +a), nie flaga
--access-token: argument z linii polecen laduje w historii shella i jest
widoczny w ps dla kazdego uzytkownika wezla.

Lancuch publikacji z kroku 7 dostal dwa nowe ogniwa przed scp: test -n
"$ACCESS_TOKEN" (pusty token = build nieklikalny) oraz grep -q 'key='
w index.html (token byl, ale nie dojechal do generatora). Oba zatrzymuja
publikacje tak samo jak --check.

Krok 6 weryfikuje teraz wlasciwa rzecz: wyciaga href ze spisu i pobiera
GO, zamiast recznie sklejac URL — czyli testuje to, co faktycznie bylo
zepsute. Doszedl tez negatywny test bramki (bez tokenu ma NIE byc 200).

Tabela problemow: "index sie otwiera, ale klikniecie daje 403" (build bez
tokenu) i "403 takze z tokenem" (rotacja tokenu w NPM rozjechana z plikiem
— stary build zostaje z martwym tokenem w kazdym linku).

kb/services/kb-site.md (public) — sekcja Access: token siedzi teraz
w tresci kazdej serwowanej strony, wiec jedna zapisana strona wydaje go
w calosci. Model zagrozen bez zmian (URL wejsciowy zawsze go niosl), ale
warto, zeby dokument mowil to wprost obok zdania "to obscurity, not
access control".

Test: sekwencje z krokow 2 i 7 przepuszczone na symulowanym pliku tokenu
(prod /opt/homelab nietkniety) — token obecny: Token: TAK, 4x key=
w index.html, lancuch dochodzi do tar; token pusty: staje na pierwszym
ogniwie, brak tgz; build bez tokenu przy ustawionej zmiennej: staje na
grep, brak tgz. check_okf.py exit 0, gen_pages --check exit 0,
service.yaml parsuje sie.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 22:26:48 +02:00

12 KiB

okf type visibility status updated links
0.1 runbook private active 2026-08-05
../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=<token>, chmod 600. To wyjątek od konwencji z CLAUDE.md: config/<serwis>/ 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

# 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.

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.

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=<token> 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:

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.

# 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:

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: <publiczny IP łącza domowego>   # 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):

dig +short kb-e2a24af3.okit.pl @1.1.1.1

5. NPM — proxy host + certyfikat

Wszystkie komendy npm_api.pydry-run domyślnie; realna zmiana dopiero z --apply. Puść najpierw bez --apply i przeczytaj payload.

# 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

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=<token> — 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:

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):

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.