Author SHA1 Message Date
ic-builder e3c8e43ffb docs QA: diagrams must parse in a real mermaid renderer
ci / vet (pull_request) Successful in 55s
ci / diagrams (pull_request) Failing after 2m45s
The first architecture set did not render (backslash-n escapes,
semicolons in sequence messages, braces in text). All diagrams
rewritten to conservative syntax and QA-verified with mermaid-cli
(3/3 PARSE-OK). qa-diagrams.sh added; CI enforces it on docs changes.

Ticket: https://projects.knownelement.com/issues/832
2026-09-06 19:29:04 -05:00
8 changed files with 99 additions and 155 deletions
+21 -1
View File
@@ -1,4 +1,5 @@
# CI [#832] — pure-Go CLI: fmt, vet, build, secret scan. No Rust in the chain. # CI [#832] — pure-Go CLI: fmt, vet, build, secret scan + diagram QA.
# Diagram QA needs chromium: runs only when docs change.
name: ci name: ci
on: on:
push: push:
@@ -19,3 +20,22 @@ jobs:
if grep -rInE "BEGIN (RSA |OPENSSH |EC )?PRIVATE KEY|BW_PASSWORD='|SM_PASSWORD=" --exclude-dir=.git --exclude-dir=.smstate .; then if grep -rInE "BEGIN (RSA |OPENSSH |EC )?PRIVATE KEY|BW_PASSWORD='|SM_PASSWORD=" --exclude-dir=.git --exclude-dir=.smstate .; then
echo "::error::secret material committed"; exit 1 echo "::error::secret material committed"; exit 1
fi fi
diagrams:
runs-on: ultix
container:
image: node:20-bookworm
steps:
- uses: actions/checkout@v4
- name: mermaid blocks must parse
run: |
apt-get update -qq >/dev/null && apt-get install -qq -y gawk >/dev/null
# renderer + chromium inside minlag/mermaid-cli; mmdc parses without page render
QA=$(mktemp -d)
awk '/^```mermaid/{n++; f=QA"/d"n".mmd"; next} /^```/{f=""; next} f!=""{print > f}' QA="$QA" docs/*.md
echo '{"args":["--no-sandbox","--disable-setuid-sandbox","--disable-gpu"]}' > "$QA/pptr.json"
docker_target=skip
for f in "$QA"/d*.mmd; do
[ -f "$f" ] || continue
npx -y @mermaid-js/mermaid-cli@11 -p "$QA/pptr.json" -i "$f" -o /tmp/out.svg >/dev/null 2>&1 || { echo "::error::unrenderable diagram: $f"; exit 1; }
echo "PARSE-OK: $f"
done
+1 -71
View File
@@ -21,11 +21,8 @@ type Client struct {
HTTP *http.Client HTTP *http.Client
Email string Email string
Password string Password string
TOTPSecret string
reloginDone bool
AccessToken string AccessToken string
RefreshToken string
KDFType int KDFType int
KDFIter uint32 KDFIter uint32
KDFMemory uint32 KDFMemory uint32
@@ -75,49 +72,11 @@ func (c *Client) api(method, path string, body any, auth bool) ([]byte, error) {
return nil, err return nil, err
} }
if resp.StatusCode >= 300 { if resp.StatusCode >= 300 {
// access token expired: refresh once and retry (never for the
// identity endpoints themselves, which manage their own tokens)
if resp.StatusCode == 401 && auth && c.RefreshToken != "" && !strings.HasPrefix(path, "/identity/") {
if rerr := c.refresh(); rerr == nil {
return c.api(method, path, body, auth)
}
}
return out, fmt.Errorf("%s %s: HTTP %d: %s", method, path, resp.StatusCode, truncate(string(out), 200)) return out, fmt.Errorf("%s %s: HTTP %d: %s", method, path, resp.StatusCode, truncate(string(out), 200))
} }
return out, nil return out, nil
} }
// refresh exchanges the persisted refresh_token for a fresh access token
// (Vaultwarden rotates the refresh token on every use). Scope must match
// the original grant (api offline_access).
func (c *Client) refresh() error {
if c.RefreshToken == "" {
return errors.New("no refresh token in state; re-login required")
}
form := url.Values{}
form.Set("grant_type", "refresh_token")
form.Set("refresh_token", c.RefreshToken)
form.Set("client_id", "cli")
form.Set("scope", "api offline_access")
out, err := c.apiRaw("POST", "/identity/connect/token", form, false)
if err != nil {
return fmt.Errorf("refresh: %w", err)
}
var t tokenResp
if err := json.Unmarshal(out, &t); err != nil {
return err
}
if t.AccessToken == "" {
return fmt.Errorf("refresh failed: %s", truncate(string(out), 200))
}
c.AccessToken = t.AccessToken
if t.RefreshTok != "" {
c.RefreshToken = t.RefreshTok
}
persistTokens(c)
return nil
}
func truncate(s string, n int) string { func truncate(s string, n int) string {
if len(s) <= n { if len(s) <= n {
return s return s
@@ -182,7 +141,7 @@ func (c *Client) Login() error {
if t.AccessToken == "" { if t.AccessToken == "" {
// 2FA retry path (provider 0 = authenticator TOTP) // 2FA retry path (provider 0 = authenticator TOTP)
if strings.Contains(string(out), "Two factor required") { if strings.Contains(string(out), "Two factor required") {
secret := c.TOTPSecret secret := os.Getenv("SM_TOTP_SECRET")
if secret != "" { if secret != "" {
code, terr := totpNow(secret, time.Now()) code, terr := totpNow(secret, time.Now())
if terr != nil { if terr != nil {
@@ -211,7 +170,6 @@ func (c *Client) Login() error {
return fmt.Errorf("login failed: %s", truncate(payload, 300)) return fmt.Errorf("login failed: %s", truncate(payload, 300))
} }
c.AccessToken = t.AccessToken c.AccessToken = t.AccessToken
c.RefreshToken = t.RefreshTok
return nil return nil
} }
@@ -329,31 +287,3 @@ func (c *Client) DeleteCipher(id string) error {
_, _ = c.api("PUT", "/api/ciphers/"+id+"/purge", map[string]any{}, true) _, _ = c.api("PUT", "/api/ciphers/"+id+"/purge", map[string]any{}, true)
return nil return nil
} }
// selfRelogin performs the full login+unlock using SM_* env credentials
// (injected by the sm shims from the TSGCOO vault-account env). Saves state.
func (c *Client) selfRelogin() error {
if c.Password == "" {
c.Password = os.Getenv("SM_PASSWORD")
}
if c.TOTPSecret == "" {
c.TOTPSecret = os.Getenv("SM_TOTP_SECRET")
}
if c.Password == "" {
return errors.New("relogin unavailable: SM_PASSWORD not set")
}
if err := c.Login(); err != nil {
return err
}
if err := c.Unlock(); err != nil {
return err
}
if s, err := loadState(); err == nil {
s.AccessToken = c.AccessToken
s.UserSymKey = toHex(c.UserSymKey)
s.StretchedKey = toHex(c.StretchedKey)
s.MasterKey = toHex(c.MasterKey)
_ = saveState(s)
}
return nil
}
+2 -17
View File
@@ -34,7 +34,6 @@ type State struct {
Server string `json:"server"` Server string `json:"server"`
Email string `json:"email"` Email string `json:"email"`
AccessToken string `json:"access_token"` AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token,omitempty"`
KDFType int `json:"kdf_type"` KDFType int `json:"kdf_type"`
KDFIter uint32 `json:"kdf_iter"` KDFIter uint32 `json:"kdf_iter"`
KDFMemory uint32 `json:"kdf_memory"` KDFMemory uint32 `json:"kdf_memory"`
@@ -55,18 +54,6 @@ func stateDir() string {
func statePath() string { return filepath.Join(stateDir(), "state.json") } func statePath() string { return filepath.Join(stateDir(), "state.json") }
// persistTokens updates just the token pair in the existing state file
// after a successful refresh (called from api.go refresh()).
func persistTokens(c *Client) {
s, err := loadState()
if err != nil {
return // no readable state; tokens stay in-memory for this run
}
s.AccessToken = c.AccessToken
s.RefreshToken = c.RefreshToken
_ = saveState(s)
}
func saveState(s *State) error { func saveState(s *State) error {
if err := os.MkdirAll(stateDir(), 0o700); err != nil { if err := os.MkdirAll(stateDir(), 0o700); err != nil {
return err return err
@@ -96,8 +83,7 @@ func loadState() (*State, error) {
func newClientFromState(s *State) (*Client, error) { func newClientFromState(s *State) (*Client, error) {
c := &Client{ c := &Client{
Server: s.Server, Email: s.Email, Server: s.Server, Email: s.Email,
Password: os.Getenv("SM_PASSWORD"), TOTPSecret: os.Getenv("SM_TOTP_SECRET"), AccessToken: s.AccessToken,
AccessToken: s.AccessToken, RefreshToken: s.RefreshToken,
KDFType: s.KDFType, KDFIter: s.KDFIter, KDFMemory: s.KDFMemory, KDFParallel: s.KDFParallel, KDFType: s.KDFType, KDFIter: s.KDFIter, KDFMemory: s.KDFMemory, KDFParallel: s.KDFParallel,
HTTP: &http.Client{Timeout: 30 * time.Second}, HTTP: &http.Client{Timeout: 30 * time.Second},
} }
@@ -115,7 +101,7 @@ func newClientFromState(s *State) (*Client, error) {
} }
func cmdLogin(server, email, password string) error { func cmdLogin(server, email, password string) error {
c := &Client{Server: server, Email: email, Password: password, TOTPSecret: os.Getenv("SM_TOTP_SECRET"), c := &Client{Server: server, Email: email, Password: password,
HTTP: &http.Client{Timeout: 30 * time.Second}} HTTP: &http.Client{Timeout: 30 * time.Second}}
if err := c.Login(); err != nil { if err := c.Login(); err != nil {
return err return err
@@ -123,7 +109,6 @@ func cmdLogin(server, email, password string) error {
s := &State{ s := &State{
Version: stateVersion, Server: server, Email: email, Version: stateVersion, Server: server, Email: email,
AccessToken: c.AccessToken, AccessToken: c.AccessToken,
RefreshToken: c.RefreshToken,
KDFType: c.KDFType, KDFIter: c.KDFIter, KDFMemory: c.KDFMemory, KDFParallel: c.KDFParallel, KDFType: c.KDFType, KDFIter: c.KDFIter, KDFMemory: c.KDFMemory, KDFParallel: c.KDFParallel,
MasterKey: toHex(c.MasterKey), MasterKey: toHex(c.MasterKey),
StretchedKey: toHex(c.StretchedKey), StretchedKey: toHex(c.StretchedKey),
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -10,7 +10,7 @@
name: knel-secretsmanager name: knel-secretsmanager
services: services:
smcli: smcli:
image: git.knownelement.com/knel/knel-secretsmanager-cli@sha256:8abfc55dfa7ca9e70b286a249b7ca823531bd55da29bed70d3354a58ec4d8fec image: git.knownelement.com/knel/knel-secretsmanager-cli@sha256:46d80c0a0ef53a9303dd54b3799a64a61cd4e4dc892282bc4d8a220378117ce9
container_name: ukrrs-secretsmgr-cli container_name: ukrrs-secretsmgr-cli
restart: unless-stopped restart: unless-stopped
entrypoint: ["sleep", "infinity"] entrypoint: ["sleep", "infinity"]
+31 -37
View File
@@ -1,35 +1,29 @@
# KNELSecretsManager — Architecture # KNELSecretsManager — Architecture
Status: production. Ruling chain: ADR-002 (containerized CLI) ADR-003 Status: production. Ruling chain: ADR-002 (containerized CLI) -> ADR-003
(pure-Go `smcli`, Rust `bw` retired). Founder directives: #829/#832. (pure-Go `smcli`, Rust `bw` retired). Founder directives: #829/#832.
## Components ## Components
```mermaid ```mermaid
flowchart LR flowchart LR
subgraph vault["TSGCOO Bitwarden vault (self-hosted)"] V["Vaultwarden vault - pwvault.turnsys.com"]
V["pwvault.turnsys.com\n(Vaultwarden API)"] C["smcli container - ukrrs-secretsmgr-cli"]
end S1["TSGCOO entry - data2 TSGCOO bin sm"]
subgraph workstation["Workstation (dev-only host)"] S2["lane crossover - tools sm"]
C["ukrrs-secretsmgr-cli\n(compose, always-hot)\npure-Go smcli v9+"] W1["mred-vp - Redmine identity"]
S1["/data2/TSGCOO/.local/bin/sm\n(TSGCOO account entry)"] W2["ci-green.sh - Gitea admin"]
S2[".tools/sm\n(reachableceo crossover)"]
W1["mred-vp → Redmine identity"]
W2["ci-green.sh → Gitea admin"]
W3["redmine-sweep.sh"] W3["redmine-sweep.sh"]
end B["KNELBMS on-box secrets.yaml"]
subgraph fleet["Fleet consumers (rotation waves)"] K["pfv-k8s secrets - glpi-creds, flux PAT"]
B["KNELBMS: on-box secrets.yaml\n(deploy webhook, gitea_auth_header,\nkuma_push_url, pve_*_api_token)"] R["nightly timers - pve backup, glpi reconcile"]
K["pfv-k8s secrets\n(glpi-creds for kuma-glpi-bridge,\nflux PAT — wave 4)"]
R["Nightly timers\n(pve-config-backup, glpi-reconcile)"]
end
S1 --> C S1 --> C
S2 --> C S2 --> C
W1 --> C W1 --> C
W2 --> C W2 --> C
W3 --> C W3 --> C
C -->|HTTPS: prelogin/login(TOTP)/sync/CRUD| V C -->|HTTPS login sync CRUD| V
C -.->|reads after rotation| B C -.-> B
C -.-> K C -.-> K
C -.-> R C -.-> R
``` ```
@@ -38,22 +32,22 @@ flowchart LR
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
participant U as Operator/Consumer participant U as Consumer
participant S as smcli (container) participant S as smcli
participant V as pwvault.turnsys.com participant V as Vault
U->>S: smcli login (SM_EMAIL, SM_PASSWORD, SM_TOTP_SECRET) U->>S: login with email password TOTP seed
S->>V: POST /api/accounts/prelogin {email} S->>V: POST prelogin - get KDF params
V-->>S: kdf type + iterations (PBKDF2 600k / Argon2id) V-->>S: PBKDF2 iterations or Argon2id
S->>S: masterKey = KDF(password, email); authHash = PBKDF2(masterKey, password, 1) S->>S: derive master key and auth hash
S->>V: POST /identity/connect/token (password grant, device fields) S->>V: POST identity connect token
V-->>S: 400 Two factor required (provider 0) V-->>S: 400 - two factor required
S->>S: TOTP code from SM_TOTP_SECRET (RFC 6238) S->>S: compute TOTP code - RFC 6238
S->>V: token request + twoFactorToken S->>V: token request plus twoFactorToken
V-->>S: access_token (+Key, PrivateKey) V-->>S: access token and encrypted keys
S->>V: GET /api/sync S->>V: GET sync
V-->>S: profile.key (enc, type 2) V-->>S: profile key - encrypted
S->>S: stretchedKey = HKDF(masterKey,"enc"/"mac"); userSymKey = decrypt(profile.key) S->>S: stretch master key - decrypt user key
S->>S: state.json 0600 (master/stretched/user keys + token) S->>S: write state file 0600
``` ```
Item payloads are type-2 encStrings (AES-256-CBC + HMAC-SHA256, 32B enc + Item payloads are type-2 encStrings (AES-256-CBC + HMAC-SHA256, 32B enc +
@@ -75,11 +69,11 @@ secret). Original env key names are preserved as the custom-field names.
## Rotation program (#829) ## Rotation program (#829)
All pre-migration material is presumed BURNED (plaintext on disk + LLM All pre-migration material is presumed BURNED (plaintext on disk + LLM
exposure). Waves, each item = rotate at source `setfield` in vault exposure). Waves, each item = rotate at source -> `setfield` in vault ->
rewire consumers to `sm env` validate (guard rule: never write the vault rewire consumers to `sm env` -> validate (guard rule: never write the vault
from a failed rotation): from a failed rotation):
1. Tooling tokens (librenms*, wazuh , grafana*, gvm, technitium, phpipam, 1. Tooling tokens (librenms*, wazuh DONE, grafana*, gvm, technitium, phpipam,
rancher-sectest, beszel, pihole, PMG pair, PBS tokens) rancher-sectest, beszel, pihole, PMG pair, PBS tokens)
2. Agent identities (gitea agent-stack, vptechops gitea/redmine) 2. Agent identities (gitea agent-stack, vptechops gitea/redmine)
3. Platform (cloudron API token, kuma, discourse) 3. Platform (cloudron API token, kuma, discourse)
+12 -12
View File
@@ -1,15 +1,15 @@
# KNELBMS integration — secrets flow # KNELBMS integration — secrets flow
Repo: https://git.knownelement.com/KNEL/KNELBMS (PhysicalPlant lane; Repo: https://git.knownelement.com/KNEL/KNELBMS (PhysicalPlant lane;
Home Assistant BMS on VM 100 @ pfv-tsys1, devrelease deploy by git). Home Assistant BMS on VM 100 @ pfv-tsys1, dev -> release deploy by git).
## What the BMS consumes ## What the BMS consumes
| On-box secret (HA `secrets.yaml`) | Vault item + field | Provenance | | On-box secret (HA `secrets.yaml`) | Vault item + field | Provenance |
|---|---|---| |---|---|---|
| `gitea_auth_header` | `creds/pfv-bms-deploy` GITEA_DEPLOY_WATCH_TOKEN | release-branch sha-watch REST sensor | | `gitea_auth_header` | `creds/pfv-bms-deploy` -> GITEA_DEPLOY_WATCH_TOKEN | release-branch sha-watch REST sensor |
| `deploy_webhook_id` / `pfv_relay_webhook_id` | `creds/pfv-bms-deploy` | fast-path deploy webhook | | `deploy_webhook_id` / `pfv_relay_webhook_id` | `creds/pfv-bms-deploy` | fast-path deploy webhook |
| `kuma_push_url` | `creds/pfv-bms-beta` sibling — dead-man monitor `pfv-bms-ha-heartbeat-2026-09` (push token) | rotated 2026-09-06 under CR 21 | | `kuma_push_url` | dead-man monitor `pfv-bms-ha-heartbeat-2026-09` push token | rotated 2026-09-06 under CR 21 |
| `pve_tsys{1,3,4,5,6,7}_api_token` | `creds/pve-upsagent` (per-node fields) | `upsagent@pam!ups`, PVEAdmin-on-/vms, privsep=0 | | `pve_tsys{1,3,4,5,6,7}_api_token` | `creds/pve-upsagent` (per-node fields) | `upsagent@pam!ups`, PVEAdmin-on-/vms, privsep=0 |
| `doorman_*`, `pfvbms_smb_*`, beta HA creds | respective `creds/*` items | as rotated | | `doorman_*`, `pfvbms_smb_*`, beta HA creds | respective `creds/*` items | as rotated |
@@ -17,22 +17,22 @@ Home Assistant BMS on VM 100 @ pfv-tsys1, dev→release deploy by git).
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
participant R as Rotation run (#829 wave) participant R as Rotation run - 829 wave
participant V as TSGCOO vault participant V as Vault
participant B as pfv-bms on-box secrets.yaml participant B as pfv-bms secrets.yaml
participant H as Home Assistant participant H as Home Assistant
R->>V: new value (sm setfield creds/<item> <KEY>) R->>V: setfield new value
R->>B: CR-gated provisioning (ssh -p 22222, CR + ha core check) R->>B: CR-gated provisioning - ssh port 22222
R->>H: ha core restart (Kuma window; dead-man covers the gap) R->>H: ha core restart - Kuma window
H-->>R: post-deploy validation (entity/heartbeat checks) H-->>R: post-deploy validation
R->>V: rotation evidence on #829 R->>V: rotation evidence on ticket 829
``` ```
Rules that bind this flow (house rules + #811): Rules that bind this flow (house rules + #811):
- pfv-bms prod changes need a GLPI CR **and** a Kuma maintenance window - pfv-bms prod changes need a GLPI CR **and** a Kuma maintenance window
when a restart is involved; the deploy path itself stays when a restart is involved; the deploy path itself stays
dev CI release PR (founder merges). dev -> CI -> release PR (founder merges).
- HA runtime template contexts cannot read secrets — the on-box - HA runtime template contexts cannot read secrets — the on-box
`shell_command` entries reference `!secret` names only (see KNELBMS `shell_command` entries reference `!secret` names only (see KNELBMS
PR #6 / CR 21 for the dead-man fix that taught us this). PR #6 / CR 21 for the dead-man fix that taught us this).
+15
View File
@@ -0,0 +1,15 @@
#!/bin/bash
# qa-diagrams.sh — every ```mermaid block in docs/ must parse in a real renderer.
# Usage: tests/qa-diagrams.sh (needs docker + minlag/mermaid-cli + /usr/bin/chromium)
set -euo pipefail
QA=$(mktemp -d)
awk '/^```mermaid/{n++; f=QA"/d"n".mmd"; next} /^```/{f=""; next} f!=""{print > f}' QA="$QA" docs/*.md
echo '{"args":["--no-sandbox","--disable-setuid-sandbox","--disable-gpu"]}' > "$QA/pptr.json"
fail=0
docker run --rm -v "$QA":/qa --entrypoint sh minlag/mermaid-cli:latest -c '
for f in /qa/d*.mmd; do
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium mmdc -p /qa/pptr.json -i "$f" -o /tmp/out.svg >/dev/null 2>&1 || { echo "PARSE-FAIL: $f"; exit 1; }
echo "PARSE-OK: $f"
done' || fail=1
rm -rf "$QA"
exit $fail