add bootstrap_secrets.py, fix harden pipeline rate limit
Some checks are pending
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/deploy Pipeline is pending
ci/woodpecker/push/infra-ci Pipeline is pending

- dev/scripts/bootstrap_secrets.py: one-time setup of all secrets
  across .env, PostgreSQL, Gitea, and Woodpecker. Includes --verify.
- Remove pull_image:true from harden.yml (Docker Hub 429 rate limit)
- CI pipeline is build+scan+report only — no credential rotation
- Future rotation deferred to P18 milestone
This commit is contained in:
kert
2026-03-23 16:09:45 -04:00
parent 8d83fbc245
commit 1ac93b62bf
2 changed files with 388 additions and 5 deletions

View File

@@ -36,7 +36,6 @@ steps:
dockerfile: notebooks/Dockerfile dockerfile: notebooks/Dockerfile
context: notebooks/ context: notebooks/
tags: [hardened, latest] tags: [hardened, latest]
pull_image: true
no_cache: true no_cache: true
- name: build-zotero - name: build-zotero
@@ -47,7 +46,6 @@ steps:
dockerfile: zotero/Dockerfile dockerfile: zotero/Dockerfile
context: zotero/ context: zotero/
tags: [hardened, latest] tags: [hardened, latest]
pull_image: true
no_cache: true no_cache: true
- name: build-docs - name: build-docs
@@ -58,7 +56,6 @@ steps:
dockerfile: docs/Dockerfile dockerfile: docs/Dockerfile
context: . context: .
tags: [hardened, latest] tags: [hardened, latest]
pull_image: true
no_cache: true no_cache: true
- name: build-api - name: build-api
@@ -69,7 +66,6 @@ steps:
dockerfile: api/Dockerfile dockerfile: api/Dockerfile
context: . context: .
tags: [hardened, latest] tags: [hardened, latest]
pull_image: true
no_cache: true no_cache: true
- name: build-mc - name: build-mc
@@ -80,7 +76,6 @@ steps:
dockerfile: rustfs/Dockerfile.mc dockerfile: rustfs/Dockerfile.mc
context: rustfs/ context: rustfs/
tags: [hardened, latest] tags: [hardened, latest]
pull_image: true
no_cache: true no_cache: true
# ── Scan hardened images ─────────────────────────────────── # ── Scan hardened images ───────────────────────────────────

View File

@@ -0,0 +1,388 @@
"""Bootstrap all secrets for the homelab stack.
Run once after cloning the repo to set up all credentials across:
- .env file (all secrets)
- PostgreSQL roles and databases
- Woodpecker CI repo secrets
- RustFS IAM (via mc)
Prerequisites:
- docker compose services running (postgres, gitea, rustfs, woodpecker-server)
- No prior .env (or willing to overwrite managed keys)
Usage:
uv run python dev/scripts/bootstrap_secrets.py
uv run python dev/scripts/bootstrap_secrets.py --verify # check consistency only
"""
from __future__ import annotations
import argparse
import base64
import logging
import secrets
import subprocess
import sys
from pathlib import Path
log = logging.getLogger(__name__)
REPO_ROOT = Path(__file__).resolve().parents[2]
ENV_PATH = REPO_ROOT / ".env"
# Secrets to generate (name → length in bytes)
SECRETS = {
"POSTGRES_PASSWORD": 24,
"GITEA_DB_PASSWORD": 24,
"GITEA_ADMIN_PASSWORD": 24,
"WOODPECKER_DB_PASSWORD": 24,
"NESSIE_DB_PASSWORD": 24,
"POLARIS_DB_PASSWORD": 24,
"RUSTFS_ACCESS_KEY": 32,
"RUSTFS_SECRET_KEY": 32,
"WOODPECKER_AGENT_SECRET": 32,
"POLARIS_ROOT_SECRET": 24,
"GF_ADMIN_PASSWORD": 24,
"ROOT_KEY": 32,
}
# Secrets that use hex encoding (rest use base64url)
HEX_SECRETS = {
"RUSTFS_ACCESS_KEY",
"RUSTFS_SECRET_KEY",
"WOODPECKER_AGENT_SECRET",
"ROOT_KEY",
}
# PostgreSQL roles: role_name → (env_var for password, database_name)
PG_ROLES = {
"git": ("GITEA_DB_PASSWORD", "gitea"),
"woodpecker": ("WOODPECKER_DB_PASSWORD", "woodpecker"),
"nessie": ("NESSIE_DB_PASSWORD", "nessie"),
"polaris": ("POLARIS_DB_PASSWORD", "polaris"),
}
# Woodpecker secrets: wp_secret_name → env_var
WP_SECRETS = {
"registry_user": ("kert", None), # static value, not from .env
"registry_pass": (None, "GITEA_ADMIN_PASSWORD"),
"gitea_token": (None, "GITEA_TOKEN"),
"gitea_url": ("http://gitea:3000", None),
"s3_access_key": (None, "RUSTFS_ACCESS_KEY"),
"s3_secret_key": (None, "RUSTFS_SECRET_KEY"),
"root_key": (None, "ROOT_KEY"),
}
# Alias mappings: these .env vars copy from another
ALIASES = {
"GITEA_S3_ACCESS_KEY": "RUSTFS_ACCESS_KEY",
"GITEA_S3_SECRET_KEY": "RUSTFS_SECRET_KEY",
"NESSIE_S3_ACCESS_KEY": "RUSTFS_ACCESS_KEY",
"NESSIE_S3_SECRET_KEY": "RUSTFS_SECRET_KEY",
"POLARIS_S3_ACCESS_KEY": "RUSTFS_ACCESS_KEY",
"POLARIS_S3_SECRET_KEY": "RUSTFS_SECRET_KEY",
}
def _generate(name: str, length: int) -> str:
raw = secrets.token_bytes(length)
if name in HEX_SECRETS:
return raw.hex()
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
def _read_env(path: Path) -> dict[str, str]:
env: dict[str, str] = {}
if path.exists():
for line in path.read_text().splitlines():
line = line.strip()
if line and not line.startswith("#") and "=" in line:
k, _, v = line.partition("=")
env[k.strip()] = v.strip()
return env
def _write_env(env: dict[str, str], path: Path) -> None:
lines = [f"{k}={v}" for k, v in sorted(env.items())]
path.write_text("\n".join(lines) + "\n")
def _psql(sql: str, *, pw: str, container: str = "postgres") -> bool:
r = subprocess.run(
[
"docker",
"exec",
"-e",
f"PGPASSWORD={pw}",
container,
"psql",
"-U",
"postgres",
"-c",
sql,
],
capture_output=True,
text=True,
)
return r.returncode == 0
def generate_secrets() -> dict[str, str]:
"""Generate all secrets, preserving existing .env values."""
env = _read_env(ENV_PATH)
for name, length in SECRETS.items():
if name not in env or not env[name]:
env[name] = _generate(name, length)
log.info("Generated %s", name)
else:
log.info("Kept existing %s", name)
for alias, source in ALIASES.items():
env[alias] = env[source]
return env
def bootstrap_postgres(env: dict[str, str]) -> list[str]:
"""Create PostgreSQL roles and databases."""
errors = []
pw = env["POSTGRES_PASSWORD"]
# Set superuser password
if not _psql(f"ALTER ROLE postgres PASSWORD '{pw}'", pw=pw):
# Try without password (first run, trust auth)
_psql(f"ALTER ROLE postgres PASSWORD '{pw}'", pw="")
for role, (env_var, db) in PG_ROLES.items():
role_pw = env[env_var]
_psql(
f"DO $$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname='{role}') "
f"THEN CREATE ROLE {role} LOGIN; END IF; END $$;",
pw=pw,
)
if not _psql(f"ALTER ROLE {role} PASSWORD '{role_pw}'", pw=pw):
errors.append(f"Failed to set password for {role}")
_psql(f"CREATE DATABASE {db} OWNER {role}", pw=pw) # idempotent fail ok
log.info("PostgreSQL role %s → db %s", role, db)
return errors
def bootstrap_gitea(env: dict[str, str]) -> list[str]:
"""Set Gitea admin password and create API token."""
errors = []
pw = env["GITEA_ADMIN_PASSWORD"]
r = subprocess.run(
[
"docker",
"exec",
"gitea",
"gitea",
"admin",
"user",
"change-password",
"-u",
"kert",
"-p",
pw,
"--must-change-password=false",
],
capture_output=True,
text=True,
)
if r.returncode != 0:
errors.append(f"Gitea password change failed: {r.stderr.strip()}")
return errors
# Create token via API
import json
r = subprocess.run(
[
"docker",
"exec",
"gitea",
"curl",
"-s",
"-u",
f"kert:{pw}",
"-H",
"Content-Type: application/json",
"-X",
"POST",
"http://localhost:3000/api/v1/users/kert/tokens",
"-d",
json.dumps(
{"name": f"bootstrap-{secrets.token_hex(4)}", "scopes": ["all"]}
),
],
capture_output=True,
text=True,
)
try:
token = json.loads(r.stdout)["sha1"]
env["GITEA_TOKEN"] = token
log.info("Gitea token created: %s...", token[:12])
except (json.JSONDecodeError, KeyError):
errors.append(f"Gitea token creation failed: {r.stdout}")
return errors
def bootstrap_woodpecker(env: dict[str, str]) -> list[str]:
"""Set Woodpecker repo secrets via database."""
errors = []
wp_pw = env.get("WOODPECKER_DB_PASSWORD", "")
for name, (static_val, env_var) in WP_SECRETS.items():
val = static_val if static_val else env.get(env_var, "")
if not val:
errors.append(f"No value for WP secret {name}")
continue
events = '["push","pull_request","cron","manual","deployment","tag","release"]'
r = subprocess.run(
[
"docker",
"exec",
"-e",
f"PGPASSWORD={wp_pw}",
"postgres",
"psql",
"-U",
"woodpecker",
"-d",
"woodpecker",
"-c",
f"INSERT INTO secrets (repo_id, name, value, events) "
f"VALUES (1, '{name}', '{val}', '{events}') "
f"ON CONFLICT (repo_id, name) DO UPDATE SET value='{val}', events='{events}';",
],
capture_output=True,
text=True,
)
if r.returncode != 0:
errors.append(f"WP secret {name}: {r.stderr.strip()}")
else:
log.info("WP secret %s set", name)
return errors
def verify(env: dict[str, str]) -> list[str]:
"""Verify all secrets are consistent across systems."""
errors = []
# PostgreSQL roles
for role, (env_var, db) in PG_ROLES.items():
role_pw = env.get(env_var, "")
r = subprocess.run(
[
"docker",
"exec",
"-e",
f"PGPASSWORD={role_pw}",
"postgres",
"psql",
"-U",
role,
"-d",
db,
"-c",
"SELECT 1",
],
capture_output=True,
text=True,
)
if r.returncode != 0:
errors.append(f"postgres/{role}: auth failed")
else:
log.info("postgres/%s: OK", role)
# Gitea token
token = env.get("GITEA_TOKEN", "")
if token:
r = subprocess.run(
[
"docker",
"exec",
"gitea",
"curl",
"-sf",
"-H",
f"Authorization: token {token}",
"http://localhost:3000/api/v1/user",
],
capture_output=True,
text=True,
)
if r.returncode != 0:
errors.append("gitea: token invalid")
else:
log.info("gitea: OK")
# Gitea registry auth
admin_pw = env.get("GITEA_ADMIN_PASSWORD", "")
if admin_pw:
r = subprocess.run(
[
"docker",
"exec",
"gitea",
"curl",
"-sf",
"-u",
f"kert:{admin_pw}",
"http://localhost:3000/api/v1/user",
],
capture_output=True,
text=True,
)
if r.returncode != 0:
errors.append("gitea registry: admin password invalid")
else:
log.info("gitea registry: OK")
return errors
def bootstrap() -> int:
"""Full bootstrap: generate → postgres → gitea → woodpecker → verify."""
env = generate_secrets()
_write_env(env, ENV_PATH)
log.info("Wrote %d keys to %s", len(env), ENV_PATH)
all_errors = []
all_errors.extend(bootstrap_postgres(env))
all_errors.extend(bootstrap_gitea(env))
_write_env(env, ENV_PATH) # re-write with GITEA_TOKEN
all_errors.extend(bootstrap_woodpecker(env))
# Verify
log.info("Verifying...")
verify_errors = verify(env)
all_errors.extend(verify_errors)
if all_errors:
for e in all_errors:
log.error("FAIL: %s", e)
return 1
log.info("Bootstrap complete — all systems consistent")
return 0
def main() -> int:
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--verify", action="store_true", help="verify only, don't generate"
)
args = parser.parse_args()
if args.verify:
env = _read_env(ENV_PATH)
errors = verify(env)
if errors:
for e in errors:
log.error("FAIL: %s", e)
return 1
log.info("All checks passed")
return 0
return bootstrap()
if __name__ == "__main__":
sys.exit(main())