add bootstrap_secrets.py, fix harden pipeline rate limit
- 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:
@@ -36,7 +36,6 @@ steps:
|
||||
dockerfile: notebooks/Dockerfile
|
||||
context: notebooks/
|
||||
tags: [hardened, latest]
|
||||
pull_image: true
|
||||
no_cache: true
|
||||
|
||||
- name: build-zotero
|
||||
@@ -47,7 +46,6 @@ steps:
|
||||
dockerfile: zotero/Dockerfile
|
||||
context: zotero/
|
||||
tags: [hardened, latest]
|
||||
pull_image: true
|
||||
no_cache: true
|
||||
|
||||
- name: build-docs
|
||||
@@ -58,7 +56,6 @@ steps:
|
||||
dockerfile: docs/Dockerfile
|
||||
context: .
|
||||
tags: [hardened, latest]
|
||||
pull_image: true
|
||||
no_cache: true
|
||||
|
||||
- name: build-api
|
||||
@@ -69,7 +66,6 @@ steps:
|
||||
dockerfile: api/Dockerfile
|
||||
context: .
|
||||
tags: [hardened, latest]
|
||||
pull_image: true
|
||||
no_cache: true
|
||||
|
||||
- name: build-mc
|
||||
@@ -80,7 +76,6 @@ steps:
|
||||
dockerfile: rustfs/Dockerfile.mc
|
||||
context: rustfs/
|
||||
tags: [hardened, latest]
|
||||
pull_image: true
|
||||
no_cache: true
|
||||
|
||||
# ── Scan hardened images ───────────────────────────────────
|
||||
|
||||
388
dev/scripts/bootstrap_secrets.py
Normal file
388
dev/scripts/bootstrap_secrets.py
Normal 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())
|
||||
Reference in New Issue
Block a user