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>
This commit is contained in:
oskar 2026-08-05 22:26:48 +02:00
parent f5c6f3b651
commit bb3792d219
4 changed files with 117 additions and 15 deletions

View file

@ -28,6 +28,15 @@ a vhost musi już odpowiadać na porcie 80, zanim zamówisz cert.
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**.
---
@ -57,11 +66,44 @@ docker volume ls | grep kb-site # kb-site_kb-site_content
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=<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:
```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.
@ -190,13 +232,29 @@ wiersz certyfikatu (szczegóły w `kb/runbooks/npm-api.md`).
## 6. Weryfikacja końcowa
```bash
curl -sI https://kb-e2a24af3.okit.pl/ | head -1 # 200
curl -s https://kb-e2a24af3.okit.pl/ | grep -c 'class="cards"' # spis jest
curl -s https://kb-e2a24af3.okit.pl/subsystems/observer.html | tail -5 # stopka: data + commit
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
```
W przeglądarce: `https://kb-e2a24af3.okit.pl` — spis pogrupowany per type, kłódka bez
ostrzeżeń, wejście w dowolną kartę, powrót linkiem „All documents".
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
@ -209,8 +267,12 @@ pilnuje pola `visibility`, ale to człowiek decyduje, co dostaje `public`.
Po każdej zmianie w `kb/**/*.md`, która dotyczy dokumentów `public`:
```bash
python3 scripts/kb/gen_pages.py --base-url https://kb-e2a24af3.okit.pl \
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 \
@ -219,7 +281,9 @@ python3 scripts/kb/gen_pages.py --base-url https://kb-e2a24af3.okit.pl \
&& rm /tmp/kb-site.tgz
```
Łańcuch na `&&` jest celowy: `--check` z exit 1 zatrzymuje publikację.
Ł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.
@ -233,6 +297,8 @@ Kontener nie wymaga restartu — nginx czyta wolumen na bieżąco.
| 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 |

View file

@ -72,10 +72,18 @@ out precisely the readers the token exists to let in. It also worked against its
own goal: a crawler blocked before it can read the page never sees the `noindex`
tag, and a search engine may still list a bare URL it was forbidden to fetch.
Because the gate is enforced on the URL, every internal link the generator emits
carries the token too — otherwise the entry page would open and every click from
it would return 403. The token is therefore a generation-time input, supplied
from outside the repository; a build made without it is a local preview, not a
publishable site.
This is obscurity, not access control. A URL token is written to access logs,
browser history and outbound `Referer` headers, so anyone who obtains a link
keeps it; nothing here resists a deliberate attacker. The leak gate above, not
this, is what keeps private material off the site.
browser history and outbound `Referer` headers, and — since the links carry it —
into the body of every served page, so a single saved page or shared screenshot
of the address bar hands it over in full. Anyone who obtains a link keeps it;
nothing here resists a deliberate attacker. The leak gate above, not this, is
what keeps private material off the site.
## Operations

View file

@ -1,10 +1,27 @@
# kb-site has NO configuration and NO secrets.
# The kb-site CONTAINER has no configuration and no secrets.
#
# The port bind (8250:80) is static and the content lives in the
# kb-site_kb-site_content Docker volume, generated from kb/ by
# scripts/kb/gen_pages.py. This file exists only to keep the
# services/<service>/ layout from CLAUDE.md complete — there is nothing to
# copy to .env.
# scripts/kb/gen_pages.py. Nothing here is copied to a .env on PIHA.
#
# The public address is a generator argument, not an env var:
# python3 scripts/kb/gen_pages.py --base-url https://kb-e2a24af3.okit.pl
#
# ---------------------------------------------------------------------------
# GENERATION-TIME secret — belongs on the node you generate from
# (SATURN/SOLARIA), NOT on PIHA:
#
# /opt/homelab/config/kb-site/.env chmod 600
# ACCESS_TOKEN=<token bramki NPM>
#
# The site sits behind a query-parameter gate in NPM. gen_pages.py reads
# ACCESS_TOKEN from the environment and appends ?key=<token> to every internal
# link; without it every click on the published site returns 403 and only a
# hand-assembled URL works.
#
# Same value as the advanced config of the proxy host in NPM. It lives in the
# NPM database and in that file only — never in this repository. Prefer the
# environment variable over the --access-token flag: a command-line argument
# lands in shell history and is visible in `ps`.
#
# Full procedure: kb/runbooks/kb-site-deploy.md (steps 0, 2 and 7).

View file

@ -23,7 +23,10 @@ service:
paths:
- kb-site_kb-site_content
runtime:
config_files: [] # no .env — the port bind is static, no secrets
# The CONTAINER has no .env and no secrets — the port bind is static. The
# access token below is not a container env var: it is consumed by the
# generator on the node that builds the site, never by nginx on PIHA.
config_files: []
env_vars: []
content:
# Only kb/ documents with `visibility: public` are published; the generator
@ -31,3 +34,11 @@ service:
generator: scripts/kb/gen_pages.py
leak_check: scripts/kb/gen_pages.py --check # must pass before publishing
source: kb/**/*.md
# NPM enforces a ?key=<token> gate in front of this site, so every internal
# href must carry that token or a click lands on 403. The generator reads it
# from ACCESS_TOKEN; the value lives in the NPM database and in
# /opt/homelab/config/kb-site/.env ON THE GENERATING NODE (saturn/solaria) —
# never in this repository. Omitting it yields a valid preview build that
# must not be published. See kb/runbooks/kb-site-deploy.md.
access_token_env: ACCESS_TOKEN
access_token_file: /opt/homelab/config/kb-site/.env # generating node, not piha