Decyzja operatora 2026-08-04: /opt/homelab to standardowa sciezka deploy rootu homelaba, ta sama na kazdym wezle, opisana wprost w publicznej czesci modelu (standards, service-model, observer, event-system). Nie ujawnia sekretow ani topologii, wiec zostaje na stronie publicznej. scripts/kb/check_whitelist.txt: wpis `/opt/homelab` z uzasadnieniem i data. Zakres wyjatku jest waski — sprawdzone, ze wycisza wylacznie warianty `/opt/homelab/...`; `/home/oskar/...`, `/opt/other/...`, adresy RFC1918 i porty dalej zapalaja czerwone. gen_pages.py: load_whitelist() obcina komentarz `#` w dowolnym miejscu linii, nie tylko na jej poczatku — wyjatek ma stac obok uzasadnienia, a nie osobno. Zaden ze skanowanych wzorcow nie zawiera `#`, wiec obciecie jest bezpieczne. kb/runbooks/kb-site-deploy.md §2: zapisany aktualny stan bramki (exit 0, 22 trafienia wyciszone) zamiast opisu decyzji do podjecia. Po zmianie: python3 scripts/kb/gen_pages.py --check -> CZYSTO, exit 0.
854 lines
30 KiB
Python
854 lines
30 KiB
Python
#!/usr/bin/env python3
|
|
"""Generator publicznej warstwy bazy wiedzy (kb.okit.pl) z dokumentów OKF v0.1.
|
|
|
|
Wzorzec: ~/narty-2027/saalbach-kb/gen_pages.py — renderer markdown na samej
|
|
bibliotece standardowej plus prosty, czytelny szablon HTML. Tutaj dochodzi
|
|
jedna reguła nadrzędna nad wszystkim innym:
|
|
|
|
PUBLIKUJEMY WYŁĄCZNIE DOKUMENTY Z `visibility: public`.
|
|
|
|
Kwalifikacja jest **fail-closed** — dokument trafia na stronę tylko wtedy, gdy
|
|
da się jednoznacznie stwierdzić, że jest publiczny. Brak frontmattera, niepoprawny
|
|
YAML, brak pola `visibility`, pusta albo nierozpoznana wartość = PRIVATE. Każdy
|
|
inny wariant (np. „domyślnie public, gdy nic nie napisano") oznaczałby, że
|
|
przeoczony frontmatter wypycha wewnętrzny dokument do internetu.
|
|
|
|
Ta sama reguła obowiązuje linki: odnośnik do dokumentu, który nie został
|
|
opublikowany, NIE jest renderowany jako link — zostaje sam tekst etykiety
|
|
z dopiskiem `[private]`. Dzięki temu strona publiczna nigdy nie wskazuje
|
|
ścieżek prywatnych dokumentów ani nie generuje martwych 404.
|
|
|
|
Wejście: kb/**/*.md
|
|
Wyjście: build/kb-site/
|
|
index.html spis stron pogrupowany per `type`
|
|
<katalog>/<nazwa>.html strona na dokument (lustro drzewa kb/)
|
|
|
|
Stopka każdej strony: data generacji + krótki hash commita (`git rev-parse --short HEAD`).
|
|
|
|
Tryb `--check` nie generuje niczego — skanuje JUŻ WYGENEROWANY katalog wyjściowy
|
|
w poszukiwaniu wycieków (adresy IP, porty, ścieżki hosta, tokeny). Świadome
|
|
wyjątki trzymamy w scripts/kb/check_whitelist.txt. Trafienie = exit 1.
|
|
|
|
Tylko biblioteka standardowa (jak scripts/npm/npm_api.py) — skrypt uruchamiany
|
|
doraźnie z SATURN/SOLARIA, bez własnego obrazu i bez `pip install`. Parser
|
|
frontmattera jest współdzielony z check_okf.py, żeby obie ścieżki widziały
|
|
frontmatter dokładnie tak samo.
|
|
|
|
Uruchomienie:
|
|
python3 scripts/kb/gen_pages.py [--base-url URL] [--out KATALOG]
|
|
python3 scripts/kb/gen_pages.py --check [--out KATALOG] [--whitelist PLIK]
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import html
|
|
import posixpath
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
# Ten sam parser frontmattera co walidator OKF — check_okf.py leży w tym samym
|
|
# katalogu, więc import działa niezależnie od katalogu uruchomienia.
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
from check_okf import parse_yaml, split_frontmatter # noqa: E402
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
KB_DIR = REPO_ROOT / "kb"
|
|
DEFAULT_OUT = REPO_ROOT / "build" / "kb-site"
|
|
DEFAULT_BASE_URL = "https://kb.okit.pl"
|
|
DEFAULT_WHITELIST = Path(__file__).resolve().parent / "check_whitelist.txt"
|
|
|
|
SITE_NAME = "homelab-codex — knowledge base"
|
|
SITE_LEAD = (
|
|
"Public slice of the homelab knowledge base. Only documents marked "
|
|
"<code>visibility: public</code> are published here; everything else stays "
|
|
"in the private repository."
|
|
)
|
|
|
|
PUBLIC = "public"
|
|
|
|
# Nagłówki grup na stronie spisu. Kolejność listy = kolejność sekcji; typy spoza
|
|
# listy trafiają na koniec, alfabetycznie, pod własną nazwą.
|
|
TYPE_ORDER = [
|
|
("subsystem", "Subsystems"),
|
|
("service", "Services"),
|
|
("node", "Nodes"),
|
|
("runbook", "Runbooks"),
|
|
("decision", "Decisions"),
|
|
("incident", "Incidents"),
|
|
("phase", "Phases"),
|
|
("audit", "Audits"),
|
|
("session-log", "Session logs"),
|
|
]
|
|
|
|
|
|
# --- model ------------------------------------------------------------
|
|
|
|
|
|
@dataclass
|
|
class Doc:
|
|
id: str # ścieżka względem kb/ bez rozszerzenia, np. "subsystems/observer"
|
|
path: Path
|
|
frontmatter: dict
|
|
body: str
|
|
public: bool
|
|
|
|
@property
|
|
def page(self) -> str:
|
|
"""Ścieżka strony względem katalogu wyjściowego."""
|
|
return f"{self.id}.html"
|
|
|
|
@property
|
|
def doc_type(self) -> str:
|
|
return str(self.frontmatter.get("type") or "").strip() or "?"
|
|
|
|
@property
|
|
def title(self) -> str:
|
|
heading = first_heading(self.body)
|
|
return heading or self.id.rsplit("/", 1)[-1].replace("-", " ")
|
|
|
|
|
|
# Nie każdy dokument zaczyna się od `#` — np. kb/subsystems/action-approval-model.md
|
|
# otwiera się `###`. Tytułem strony jest pierwszy nagłówek DOWOLNEGO poziomu.
|
|
_ANY_HEADING_RE = re.compile(r"^#{1,6}\s+(.+)$", re.M)
|
|
|
|
|
|
def first_heading(body: str) -> str | None:
|
|
match = _ANY_HEADING_RE.search(body)
|
|
return match.group(1).strip() if match else None
|
|
|
|
|
|
def is_public(frontmatter: dict | None) -> bool:
|
|
"""Fail-closed: publiczny jest wyłącznie dokument z jawnym `visibility: public`."""
|
|
if not isinstance(frontmatter, dict):
|
|
return False
|
|
return str(frontmatter.get("visibility", "")).strip() == PUBLIC
|
|
|
|
|
|
def load_docs() -> list[Doc]:
|
|
docs: list[Doc] = []
|
|
for path in sorted(KB_DIR.rglob("*.md")):
|
|
if ".git" in path.parts:
|
|
continue
|
|
text = path.read_text(encoding="utf-8")
|
|
frontmatter: dict = {}
|
|
body = text
|
|
try:
|
|
block, rest = split_frontmatter(text)
|
|
if block is not None:
|
|
# Niepoprawny YAML => frontmatter nieznany => dokument prywatny.
|
|
frontmatter = parse_yaml(block)
|
|
body = rest
|
|
except ValueError:
|
|
frontmatter = {}
|
|
body = text
|
|
docs.append(
|
|
Doc(
|
|
id=path.relative_to(KB_DIR).with_suffix("").as_posix(),
|
|
path=path,
|
|
frontmatter=frontmatter,
|
|
body=body.lstrip("\n"),
|
|
public=is_public(frontmatter),
|
|
)
|
|
)
|
|
return docs
|
|
|
|
|
|
# --- linki ------------------------------------------------------------
|
|
|
|
# Etykieta linku bywa złamana na dwie linie (np. kb/decisions/ai-cluster-legacy.md),
|
|
# więc klasa [^\]] musi łapać też znak nowej linii — domyślnie łapie.
|
|
_MD_LINK_RE = re.compile(r"\[([^\]]*)\]\(([^)\s]+)((?:#[^)\s]*)?)\)")
|
|
|
|
_EXTERNAL_PREFIXES = ("http://", "https://", "mailto:", "#")
|
|
|
|
|
|
def rewrite_links(doc: Doc, published: dict[str, str]) -> str:
|
|
"""Linki wewnątrz-repowe → strony; wszystko nieopublikowane → zwykły tekst.
|
|
|
|
`published` mapuje id dokumentu (ścieżka względem kb/ bez rozszerzenia) na
|
|
ścieżkę wygenerowanej strony. Cel spoza tej mapy jest z definicji niepubliczny
|
|
(private albo w ogóle nie jest dokumentem KB) i traci link.
|
|
"""
|
|
here = posixpath.dirname(doc.page)
|
|
|
|
def repl(match: re.Match) -> str:
|
|
label, target, fragment = match.group(1), match.group(2), match.group(3)
|
|
if target.startswith(_EXTERNAL_PREFIXES):
|
|
return match.group(0)
|
|
|
|
if not target.endswith(".md"):
|
|
# Odnośnik do pliku repo (compose, yaml, skrypt) — na stronie
|
|
# publicznej nie ma czego pokazać, więc zostaje sama etykieta.
|
|
return label
|
|
|
|
if target.startswith("/"):
|
|
rel = target.lstrip("/")
|
|
rel = rel[3:] if rel.startswith("kb/") else rel
|
|
else:
|
|
rel = posixpath.normpath(
|
|
posixpath.join(posixpath.dirname(doc.id), target)
|
|
)
|
|
doc_id = rel[:-3]
|
|
|
|
page = published.get(doc_id)
|
|
if page is None:
|
|
return f"{label} [private]"
|
|
return f"[{label}]({posixpath.relpath(page, here or '.')}{fragment})"
|
|
|
|
return _MD_LINK_RE.sub(repl, doc.body)
|
|
|
|
|
|
# --- renderer markdown (biblioteka standardowa) ------------------------
|
|
|
|
_CODE_RE = re.compile(r"`([^`]+)`")
|
|
_LINK_RE = re.compile(r"\[([^\]]*)\]\(([^)\s]+)\)")
|
|
_BOLD_RE = re.compile(r"\*\*(.+?)\*\*", re.S)
|
|
_ITALIC_STAR_RE = re.compile(r"(?<!\*)\*(?!\s)([^*]+?)(?<!\s)\*(?!\*)")
|
|
_ITALIC_UNDER_RE = re.compile(r"(?<![\w\\])_(?!\s)([^_]+?)(?<!\s)_(?![\w])")
|
|
_HEADING_RE = re.compile(r"^(#{1,6})\s+(.*)$")
|
|
_HR_RE = re.compile(r"^(-{3,}|\*{3,}|_{3,})$")
|
|
_LIST_RE = re.compile(r"^\s*([-*+]|\d+[.)])\s+(.*)$")
|
|
_TASK_RE = re.compile(r"^\[([ xX])\]\s+(.*)$")
|
|
_TABLE_SEP_RE = re.compile(r"^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$")
|
|
|
|
|
|
def _linkable(href: str) -> bool:
|
|
"""Druga bramka fail-closed: linkujemy tylko adresy zewnętrzne, kotwice
|
|
i wygenerowane strony. Cokolwiek innego przeciekło przez rewrite_links()
|
|
(ścieżka repo, .md bez odpowiednika) zostaje tekstem, nie martwym linkiem."""
|
|
return href.startswith(_EXTERNAL_PREFIXES) or href.split("#", 1)[0].endswith(".html")
|
|
|
|
|
|
def render_inline(text: str) -> str:
|
|
codes: list[str] = []
|
|
|
|
def stash(match: re.Match) -> str:
|
|
codes.append(match.group(1))
|
|
return f"\x00c{len(codes) - 1}\x00"
|
|
|
|
text = _CODE_RE.sub(stash, text)
|
|
text = html.escape(text, quote=False)
|
|
|
|
def link(match: re.Match) -> str:
|
|
label, href = match.group(1), match.group(2)
|
|
if not _linkable(href):
|
|
return label
|
|
attrs = ""
|
|
if href.startswith(("http://", "https://", "mailto:")):
|
|
attrs = ' target="_blank" rel="noopener" class="ext"'
|
|
return f'<a href="{html.escape(href, quote=True)}"{attrs}>{label}</a>'
|
|
|
|
text = _LINK_RE.sub(link, text)
|
|
text = _BOLD_RE.sub(r"<strong>\1</strong>", text)
|
|
text = _ITALIC_STAR_RE.sub(r"<em>\1</em>", text)
|
|
text = _ITALIC_UNDER_RE.sub(r"<em>\1</em>", text)
|
|
|
|
for i, code in enumerate(codes):
|
|
text = text.replace(f"\x00c{i}\x00", f"<code>{html.escape(code, quote=False)}</code>")
|
|
return text
|
|
|
|
|
|
def _alignments(sep: str) -> list[str]:
|
|
out = []
|
|
for cell in _cells(sep):
|
|
left, right = cell.startswith(":"), cell.endswith(":")
|
|
out.append("center" if left and right else "right" if right else "left")
|
|
return out
|
|
|
|
|
|
def _cells(row: str) -> list[str]:
|
|
return [c.strip() for c in row.strip().strip("|").split("|")]
|
|
|
|
|
|
def _starts_block(line: str) -> bool:
|
|
s = line.strip()
|
|
return (
|
|
not s
|
|
or bool(_HEADING_RE.match(s))
|
|
or bool(_HR_RE.match(s))
|
|
or s.startswith(("```", ">"))
|
|
or bool(_LIST_RE.match(line))
|
|
)
|
|
|
|
|
|
def render_blocks(text: str) -> str:
|
|
lines = text.split("\n")
|
|
out: list[str] = []
|
|
i = 0
|
|
while i < len(lines):
|
|
line = lines[i]
|
|
stripped = line.strip()
|
|
|
|
if not stripped:
|
|
i += 1
|
|
continue
|
|
|
|
if stripped.startswith("```"):
|
|
i += 1
|
|
code: list[str] = []
|
|
while i < len(lines) and not lines[i].strip().startswith("```"):
|
|
code.append(lines[i])
|
|
i += 1
|
|
i += 1
|
|
out.append(f"<pre><code>{html.escape(chr(10).join(code), quote=False)}</code></pre>")
|
|
continue
|
|
|
|
heading = _HEADING_RE.match(stripped)
|
|
if heading:
|
|
level = len(heading.group(1))
|
|
out.append(f"<h{level}>{render_inline(heading.group(2))}</h{level}>")
|
|
i += 1
|
|
continue
|
|
|
|
if _HR_RE.match(stripped):
|
|
out.append("<hr>")
|
|
i += 1
|
|
continue
|
|
|
|
if "|" in stripped and i + 1 < len(lines) and _TABLE_SEP_RE.match(lines[i + 1]):
|
|
header = _cells(stripped)
|
|
align = _alignments(lines[i + 1])
|
|
i += 2
|
|
rows = []
|
|
while i < len(lines) and "|" in lines[i] and lines[i].strip():
|
|
rows.append(_cells(lines[i]))
|
|
i += 1
|
|
head = "".join(
|
|
f'<th style="text-align:{align[n] if n < len(align) else "left"}">'
|
|
f"{render_inline(c)}</th>"
|
|
for n, c in enumerate(header)
|
|
)
|
|
body = "".join(
|
|
"<tr>"
|
|
+ "".join(
|
|
f'<td style="text-align:{align[n] if n < len(align) else "left"}">'
|
|
f"{render_inline(c)}</td>"
|
|
for n, c in enumerate(row)
|
|
)
|
|
+ "</tr>"
|
|
for row in rows
|
|
)
|
|
out.append(
|
|
f'<div class="table-wrap"><table><thead><tr>{head}</tr></thead>'
|
|
f"<tbody>{body}</tbody></table></div>"
|
|
)
|
|
continue
|
|
|
|
if stripped.startswith(">"):
|
|
quoted = []
|
|
while i < len(lines) and lines[i].strip().startswith(">"):
|
|
quoted.append(lines[i].strip()[1:].lstrip())
|
|
i += 1
|
|
out.append(f"<blockquote>{render_blocks(chr(10).join(quoted))}</blockquote>")
|
|
continue
|
|
|
|
item = _LIST_RE.match(line)
|
|
if item:
|
|
ordered = item.group(1)[0].isdigit()
|
|
items: list[str] = []
|
|
while i < len(lines):
|
|
current = lines[i]
|
|
match = _LIST_RE.match(current)
|
|
if match:
|
|
items.append(match.group(2))
|
|
i += 1
|
|
elif current.startswith((" ", "\t")) and current.strip() and items:
|
|
items[-1] += " " + current.strip()
|
|
i += 1
|
|
else:
|
|
break
|
|
rendered = []
|
|
for raw in items:
|
|
task = _TASK_RE.match(raw)
|
|
if task:
|
|
checked = " checked" if task.group(1).lower() == "x" else ""
|
|
rendered.append(
|
|
f'<li class="task"><input type="checkbox" disabled{checked}> '
|
|
f"{render_inline(task.group(2))}</li>"
|
|
)
|
|
else:
|
|
rendered.append(f"<li>{render_inline(raw)}</li>")
|
|
tag = "ol" if ordered else "ul"
|
|
out.append(f"<{tag}>{''.join(rendered)}</{tag}>")
|
|
continue
|
|
|
|
paragraph = [stripped]
|
|
i += 1
|
|
while i < len(lines) and not _starts_block(lines[i]):
|
|
if "|" in lines[i] and i + 1 < len(lines) and _TABLE_SEP_RE.match(lines[i + 1]):
|
|
break
|
|
paragraph.append(lines[i].strip())
|
|
i += 1
|
|
out.append(f"<p>{render_inline(' '.join(paragraph))}</p>")
|
|
|
|
return "\n".join(out)
|
|
|
|
|
|
# --- szablon ----------------------------------------------------------
|
|
|
|
CSS = """
|
|
:root { color-scheme: light dark; --fg:#0f172a; --muted:#64748b; --bg:#ffffff;
|
|
--line:#e2e8f0; --accent:#0d9488; --chip:#f1f5f9; --quote:#f8fafc; }
|
|
@media (prefers-color-scheme: dark) {
|
|
:root { --fg:#e2e8f0; --muted:#94a3b8; --bg:#0f172a; --line:#334155;
|
|
--accent:#2dd4bf; --chip:#1e293b; --quote:#1e293b; }
|
|
}
|
|
* { box-sizing: border-box; }
|
|
body { margin: 0 auto; padding: 1.25rem 1rem 4rem; max-width: 46rem;
|
|
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
font-size: 17px; line-height: 1.55; color: var(--fg); background: var(--bg);
|
|
-webkit-text-size-adjust: 100%; }
|
|
nav { font-size: .85rem; margin-bottom: 1.25rem; }
|
|
nav a { color: var(--muted); }
|
|
h1 { font-size: 1.55rem; line-height: 1.25; margin: 0 0 .35rem; }
|
|
h2 { font-size: 1.2rem; margin: 2rem 0 .5rem; padding-top: .5rem;
|
|
border-top: 1px solid var(--line); }
|
|
h3 { font-size: 1.02rem; margin: 1.4rem 0 .4rem; }
|
|
h4, h5, h6 { font-size: .95rem; margin: 1.1rem 0 .3rem; }
|
|
p, ul, ol { margin: .6rem 0; }
|
|
ul, ol { padding-left: 1.3rem; }
|
|
li { margin: .25rem 0; }
|
|
li.task { list-style: none; margin-left: -1.3rem; }
|
|
li.task input { margin-right: .45rem; }
|
|
a { color: var(--accent); }
|
|
a.ext::after { content: " \\2197"; font-size: .8em; color: var(--muted); }
|
|
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .88em;
|
|
background: var(--chip); padding: .1em .35em; border-radius: 4px; }
|
|
pre { background: var(--chip); padding: .75rem; border-radius: 6px; overflow-x: auto; }
|
|
pre code { background: none; padding: 0; }
|
|
blockquote { margin: .9rem 0; padding: .6rem .9rem; background: var(--quote);
|
|
border-left: 3px solid var(--accent); border-radius: 0 6px 6px 0; }
|
|
blockquote p { margin: .2rem 0; }
|
|
hr { border: 0; border-top: 1px solid var(--line); margin: 1.5rem 0; }
|
|
.table-wrap { overflow-x: auto; margin: .8rem 0; -webkit-overflow-scrolling: touch; }
|
|
table { border-collapse: collapse; width: 100%; font-size: .92rem; }
|
|
th, td { border: 1px solid var(--line); padding: .4rem .55rem; vertical-align: top; }
|
|
th { background: var(--chip); font-weight: 600; }
|
|
.chip { display: inline-block; background: var(--chip); color: var(--muted);
|
|
font-size: .72rem; text-transform: uppercase; letter-spacing: .04em;
|
|
padding: .15rem .5rem; border-radius: 999px; }
|
|
.lead { color: var(--muted); margin: .35rem 0 1.25rem; }
|
|
.meta { color: var(--muted); font-size: .82rem; margin: .2rem 0 1.5rem; }
|
|
.cards { list-style: none; padding: 0; margin: 0; }
|
|
.cards li { border: 1px solid var(--line); border-radius: 8px; padding: .8rem .9rem;
|
|
margin: .6rem 0; }
|
|
.cards a { font-weight: 600; text-decoration: none; }
|
|
.cards p { margin: .3rem 0 0; font-size: .9rem; color: var(--muted); }
|
|
.cards .chip { margin-left: .35rem; }
|
|
footer { margin-top: 3rem; padding-top: 1rem; border-top: 1px solid var(--line);
|
|
font-size: .78rem; color: var(--muted); }
|
|
"""
|
|
|
|
|
|
def page_shell(
|
|
title: str,
|
|
depth: int,
|
|
content: str,
|
|
*,
|
|
canonical: str,
|
|
stamp: str,
|
|
nav: str | None = None,
|
|
) -> str:
|
|
up = "../" * depth
|
|
if nav is None:
|
|
nav = f'<a href="{up}index.html">← All documents</a>'
|
|
nav_html = f"<nav>{nav}</nav>\n" if nav else ""
|
|
return f"""<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>{html.escape(title)} — {html.escape(SITE_NAME)}</title>
|
|
<link rel="canonical" href="{html.escape(canonical, quote=True)}">
|
|
<style>{CSS}</style>
|
|
</head>
|
|
<body>
|
|
{nav_html}{content}
|
|
<footer>{stamp}</footer>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
|
|
def meta_line(doc: Doc) -> str:
|
|
"""Krótki pasek metadanych. Świadomie BEZ pola `links` — wpisy potrafią
|
|
wskazywać dokumenty prywatne, a sama ścieżka to już wyciek nazwy."""
|
|
parts = [f'<span class="chip">{html.escape(doc.doc_type)}</span>']
|
|
status = str(doc.frontmatter.get("status") or "").strip()
|
|
updated = str(doc.frontmatter.get("updated") or "").strip()
|
|
if status:
|
|
parts.append(html.escape(status))
|
|
if updated:
|
|
parts.append("updated " + html.escape(updated))
|
|
return '<p class="meta">' + " · ".join(parts) + "</p>"
|
|
|
|
|
|
def render_page(doc: Doc, published: dict[str, str], base_url: str, stamp: str) -> str:
|
|
rendered = render_blocks(rewrite_links(doc, published))
|
|
|
|
# Pierwszy nagłówek treści awansujemy na <h1> strony zamiast dokładać drugi
|
|
# tytuł nad nim (dokument może zaczynać się od `#` albo od `###`).
|
|
match = re.match(r"\s*<h[1-6]>(.*?)</h[1-6]>", rendered, re.S)
|
|
if match:
|
|
headline = match.group(1)
|
|
rendered = rendered[match.end() :]
|
|
else:
|
|
headline = html.escape(doc.title)
|
|
|
|
content = f"<h1>{headline}</h1>\n{meta_line(doc)}\n{rendered}"
|
|
canonical = f"{base_url.rstrip('/')}/{doc.page}"
|
|
return page_shell(doc.title, doc.id.count("/"), content, canonical=canonical, stamp=stamp)
|
|
|
|
|
|
_INLINE_MARKUP_RE = re.compile(r"[*`_]|\[private\]")
|
|
|
|
|
|
def summary(doc: Doc, limit: int = 190) -> str:
|
|
"""Pierwszy akapit treści (bez nagłówka) jako zajawka na spisie."""
|
|
body = _ANY_HEADING_RE.sub("", doc.body, count=1)
|
|
paragraph = ""
|
|
for chunk in body.split("\n\n"):
|
|
text = " ".join(line.strip() for line in chunk.strip().splitlines())
|
|
if not text or text.startswith(("#", "|", ">", "```", "-", "*")):
|
|
continue
|
|
paragraph = text
|
|
break
|
|
if not paragraph:
|
|
return ""
|
|
paragraph = _MD_LINK_RE.sub(r"\1", paragraph)
|
|
paragraph = _INLINE_MARKUP_RE.sub("", paragraph)
|
|
if len(paragraph) > limit:
|
|
paragraph = paragraph[:limit].rsplit(" ", 1)[0] + "…"
|
|
return paragraph
|
|
|
|
|
|
def render_index(docs: list[Doc], base_url: str, stamp: str) -> str:
|
|
known = [t for t, _ in TYPE_ORDER]
|
|
labels = dict(TYPE_ORDER)
|
|
by_type: dict[str, list[Doc]] = {}
|
|
for doc in docs:
|
|
by_type.setdefault(doc.doc_type, []).append(doc)
|
|
|
|
order = [t for t in known if t in by_type]
|
|
order += sorted(t for t in by_type if t not in known)
|
|
|
|
sections = []
|
|
for doc_type in order:
|
|
items = sorted(by_type[doc_type], key=lambda d: d.title.lower())
|
|
cards = []
|
|
for doc in items:
|
|
lead = summary(doc)
|
|
cards.append(
|
|
f'<li><a href="{html.escape(doc.page, quote=True)}">'
|
|
f"{html.escape(doc.title)}</a>"
|
|
f'<span class="chip">{html.escape(doc.doc_type)}</span>'
|
|
+ (f"<p>{html.escape(lead)}</p>" if lead else "")
|
|
+ "</li>"
|
|
)
|
|
heading = labels.get(doc_type, doc_type)
|
|
sections.append(
|
|
f"<h2>{html.escape(heading)} <span class=\"chip\">{len(items)}</span></h2>\n"
|
|
f'<ul class="cards">{"".join(cards)}</ul>'
|
|
)
|
|
|
|
content = (
|
|
f"<h1>{html.escape(SITE_NAME)}</h1>\n"
|
|
f'<p class="lead">{SITE_LEAD}</p>\n' + "\n".join(sections)
|
|
)
|
|
return page_shell(
|
|
"Index",
|
|
0,
|
|
content,
|
|
canonical=base_url.rstrip("/") + "/",
|
|
stamp=stamp,
|
|
nav="",
|
|
)
|
|
|
|
|
|
# --- budowa -----------------------------------------------------------
|
|
|
|
|
|
def git_commit() -> str:
|
|
try:
|
|
out = subprocess.run(
|
|
["git", "rev-parse", "--short", "HEAD"],
|
|
cwd=REPO_ROOT,
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
)
|
|
return out.stdout.strip() or "unknown"
|
|
except (OSError, subprocess.CalledProcessError):
|
|
return "unknown"
|
|
|
|
|
|
def build(out_dir: Path, base_url: str) -> list[Doc]:
|
|
docs = load_docs()
|
|
public = [d for d in docs if d.public]
|
|
published = {d.id: d.page for d in public}
|
|
|
|
generated = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
|
stamp = (
|
|
f"Generated {generated} · commit <code>{html.escape(git_commit())}</code> "
|
|
f"· <code>scripts/kb/gen_pages.py</code> — do not edit the HTML, "
|
|
f"edit the source document and regenerate."
|
|
)
|
|
|
|
if out_dir.exists():
|
|
shutil.rmtree(out_dir)
|
|
out_dir.mkdir(parents=True)
|
|
|
|
for doc in public:
|
|
target = out_dir / doc.page
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
target.write_text(render_page(doc, published, base_url, stamp), encoding="utf-8")
|
|
|
|
(out_dir / "index.html").write_text(
|
|
render_index(public, base_url, stamp), encoding="utf-8"
|
|
)
|
|
|
|
print(f"Repo: {REPO_ROOT}")
|
|
print(f"Źródło: {KB_DIR.relative_to(REPO_ROOT)}/**/*.md")
|
|
print(f"Wyjście: {out_dir}")
|
|
print(f"BASE_URL: {base_url}")
|
|
print(
|
|
f"Dokumenty: {len(docs)} razem, {len(public)} public, "
|
|
f"{len(docs) - len(public)} pominiętych (private / brak frontmattera)"
|
|
)
|
|
|
|
by_type: dict[str, list[Doc]] = {}
|
|
for doc in public:
|
|
by_type.setdefault(doc.doc_type, []).append(doc)
|
|
print()
|
|
for doc_type in sorted(by_type):
|
|
print(f" {doc_type} ({len(by_type[doc_type])}):")
|
|
for doc in sorted(by_type[doc_type], key=lambda d: d.id):
|
|
print(f" {doc.page} ← kb/{doc.id}.md")
|
|
print()
|
|
print(f" index.html ({len(public)} pozycji)")
|
|
print()
|
|
print("Kontrola wycieków: python3 scripts/kb/gen_pages.py --check")
|
|
return public
|
|
|
|
|
|
# --- tryb --check: skan wygenerowanego HTML ---------------------------
|
|
|
|
_OCTET = r"(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)"
|
|
_IPV4_RE = re.compile(rf"(?<![\w.]){_OCTET}(?:\.{_OCTET}){{3}}(?![\w.])")
|
|
|
|
_PRIVATE_IPV4_RE = re.compile(
|
|
r"^(?:192\.168\.|10\.|172\.(?:1[6-9]|2\d|3[01])\.)"
|
|
)
|
|
_TAILSCALE_IPV4_RE = re.compile(
|
|
r"^100\.(?:6[4-9]|[7-9]\d|1[01]\d|12[0-7])\."
|
|
)
|
|
# Adresy, które nie są wyciekiem: loopback, link-local, multicast/broadcast,
|
|
# „to sieć", maski i pule dokumentacyjne z RFC 5737.
|
|
_NEUTRAL_IPV4_RE = re.compile(
|
|
r"^(?:127\.|169\.254\.|0\.|22[4-9]\.|2[3-5]\d\.|255\.|"
|
|
r"192\.0\.2\.|198\.51\.100\.|203\.0\.113\.)"
|
|
)
|
|
|
|
# IPv6: wymagamy albo "::", albo >=4 grup — trzy grupy to zwykle godzina 10:15:30.
|
|
_IPV6_RE = re.compile(
|
|
r"(?<![\w:])(?:"
|
|
r"(?:[0-9a-fA-F]{1,4}:){3,7}[0-9a-fA-F]{1,4}"
|
|
r"|(?:[0-9a-fA-F]{1,4}:){1,7}:(?:[0-9a-fA-F]{1,4}(?::[0-9a-fA-F]{1,4}){0,6})?"
|
|
r"|::(?:[0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{1,4}"
|
|
r")(?![\w:])"
|
|
)
|
|
_NEUTRAL_IPV6 = {"::", "::1"}
|
|
|
|
# Port leci zaraz po hoście albo adresie (`localhost:8123`, `192.168.31.5:8240`),
|
|
# więc przed dwukropkiem stoi znak słowa — żadnego lookbehindu na \w. Odsiewamy
|
|
# tylko `::` z adresów IPv6; te i tak raportuje wzorzec `ip-v6`.
|
|
_PORT_RE = re.compile(r"(?<!:):(\d{4,5})(?!\d)")
|
|
_PATH_RE = re.compile(r"/(?:home|opt)/[\w.\-/]*")
|
|
_HEX_TOKEN_RE = re.compile(r"(?<![\w])[0-9a-fA-F]{32,}(?![\w])")
|
|
_B64_TOKEN_RE = re.compile(r"(?<![\w+/=-])[A-Za-z0-9+/_-]{40,}={0,2}(?![\w+/=-])")
|
|
|
|
|
|
def _ipv4_hits(text: str) -> list[tuple[str, str]]:
|
|
hits = []
|
|
for match in _IPV4_RE.finditer(text):
|
|
value = match.group(0)
|
|
if _PRIVATE_IPV4_RE.match(value):
|
|
hits.append(("ip-rfc1918", value))
|
|
elif _TAILSCALE_IPV4_RE.match(value):
|
|
hits.append(("ip-tailscale", value))
|
|
elif _NEUTRAL_IPV4_RE.match(value):
|
|
continue
|
|
else:
|
|
hits.append(("ip-public-v4", value))
|
|
return hits
|
|
|
|
|
|
def _ipv6_hits(text: str) -> list[tuple[str, str]]:
|
|
hits = []
|
|
for match in _IPV6_RE.finditer(text):
|
|
value = match.group(0)
|
|
if value in _NEUTRAL_IPV6:
|
|
continue
|
|
hits.append(("ip-v6", value))
|
|
return hits
|
|
|
|
|
|
def _port_hits(text: str) -> list[tuple[str, str]]:
|
|
hits = []
|
|
for match in _PORT_RE.finditer(text):
|
|
port = int(match.group(1))
|
|
if 1024 <= port <= 65535:
|
|
hits.append(("port", match.group(0)))
|
|
return hits
|
|
|
|
|
|
def _token_hits(text: str) -> list[tuple[str, str]]:
|
|
hits = []
|
|
for match in _HEX_TOKEN_RE.finditer(text):
|
|
hits.append(("token-hex", match.group(0)))
|
|
for match in _B64_TOKEN_RE.finditer(text):
|
|
value = match.group(0)
|
|
# Ścieżka absolutna nie jest tokenem — alfabet base64 zawiera "/", więc
|
|
# długie `/opt/homelab/events/...` łapało się tu jako fałszywy alarm.
|
|
# Ścieżki hosta i tak raportuje osobny wzorzec `path-host`.
|
|
if value.startswith("/"):
|
|
continue
|
|
# Bez cyfry i bez litery to nie jest sekret, tylko długie słowo albo
|
|
# ciąg myślników — sekrety mieszają jedno z drugim.
|
|
if any(c.isdigit() for c in value) and any(c.isalpha() for c in value):
|
|
hits.append(("token-b64", value))
|
|
return hits
|
|
|
|
|
|
def scan_line(text: str) -> list[tuple[str, str]]:
|
|
hits = _ipv4_hits(text) + _ipv6_hits(text) + _port_hits(text)
|
|
hits += [("path-host", m.group(0)) for m in _PATH_RE.finditer(text)]
|
|
hits += _token_hits(text)
|
|
return hits
|
|
|
|
|
|
def load_whitelist(path: Path) -> list[tuple[str | None, str]]:
|
|
"""Wpisy: `<fragment>` albo `<ścieżka strony>|<fragment>`.
|
|
|
|
Fragment jest dopasowywany jako podciąg trafienia, więc jeden wpis
|
|
`/opt/homelab` wycisza wszystkie warianty `/opt/homelab/...`.
|
|
|
|
`#` zaczyna komentarz w dowolnym miejscu linii — wyjątek bez uzasadnienia
|
|
obok siebie szybko staje się wyjątkiem, którego nikt już nie rozumie.
|
|
Żaden ze skanowanych wzorców (adresy, porty, ścieżki, tokeny) nie zawiera
|
|
`#`, więc obcięcie ogona jest bezpieczne.
|
|
"""
|
|
if not path.is_file():
|
|
return []
|
|
entries: list[tuple[str | None, str]] = []
|
|
for raw in path.read_text(encoding="utf-8").splitlines():
|
|
line = raw.split("#", 1)[0].strip()
|
|
if not line:
|
|
continue
|
|
if "|" in line:
|
|
scope, _, fragment = line.partition("|")
|
|
entries.append((scope.strip(), fragment.strip()))
|
|
else:
|
|
entries.append((None, line))
|
|
return entries
|
|
|
|
|
|
def whitelisted(entries: list[tuple[str | None, str]], rel: str, value: str) -> bool:
|
|
return any(
|
|
fragment and fragment in value and (scope is None or scope == rel)
|
|
for scope, fragment in entries
|
|
)
|
|
|
|
|
|
def check(out_dir: Path, whitelist_path: Path) -> int:
|
|
if not out_dir.is_dir():
|
|
print(f"BRAK katalogu {out_dir} — najpierw wygeneruj strony (bez --check).")
|
|
return 1
|
|
|
|
entries = load_whitelist(whitelist_path)
|
|
files = sorted(out_dir.rglob("*.html"))
|
|
|
|
print(f"Skan: {out_dir}")
|
|
print(f"Whitelist: {whitelist_path} ({len(entries)} wpis(ów))")
|
|
print(f"Plików: {len(files)}")
|
|
print()
|
|
|
|
findings: list[tuple[str, int, str, str]] = []
|
|
suppressed = 0
|
|
for path in files:
|
|
rel = path.relative_to(out_dir).as_posix()
|
|
for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
|
for pattern, value in scan_line(line):
|
|
if whitelisted(entries, rel, value):
|
|
suppressed += 1
|
|
continue
|
|
findings.append((rel, lineno, pattern, value))
|
|
|
|
if not findings:
|
|
print(f"CZYSTO — 0 trafień ({suppressed} wyciszonych whitelistą).")
|
|
return 0
|
|
|
|
by_pattern: dict[str, int] = {}
|
|
for rel, lineno, pattern, value in findings:
|
|
by_pattern[pattern] = by_pattern.get(pattern, 0) + 1
|
|
print(f" ✗ {rel}:{lineno} [{pattern}] {value}")
|
|
|
|
print()
|
|
print(f"WYCIEK — {len(findings)} trafień ({suppressed} wyciszonych whitelistą):")
|
|
for pattern, count in sorted(by_pattern.items(), key=lambda kv: -kv[1]):
|
|
print(f" {pattern}: {count}")
|
|
print()
|
|
print(
|
|
"Napraw źródło w kb/ (usuń adres/ścieżkę/token z dokumentu public) albo "
|
|
f"dopisz świadomy wyjątek do {whitelist_path.relative_to(REPO_ROOT)}."
|
|
)
|
|
return 1
|
|
|
|
|
|
# --- CLI --------------------------------------------------------------
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(
|
|
description="Generator publicznej warstwy KB (kb.okit.pl) z dokumentów OKF."
|
|
)
|
|
parser.add_argument(
|
|
"--base-url",
|
|
default=DEFAULT_BASE_URL,
|
|
help=f"publiczny adres wystawki (domyślnie {DEFAULT_BASE_URL})",
|
|
)
|
|
parser.add_argument(
|
|
"--out",
|
|
type=Path,
|
|
default=DEFAULT_OUT,
|
|
help="katalog wyjściowy (domyślnie build/kb-site)",
|
|
)
|
|
parser.add_argument(
|
|
"--check",
|
|
action="store_true",
|
|
help="nie generuj — przeskanuj wygenerowany HTML pod kątem wycieków",
|
|
)
|
|
parser.add_argument(
|
|
"--whitelist",
|
|
type=Path,
|
|
default=DEFAULT_WHITELIST,
|
|
help="plik świadomych wyjątków dla --check",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
if args.check:
|
|
return check(args.out.resolve(), args.whitelist)
|
|
|
|
build(args.out.resolve(), args.base_url)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|