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
|
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 ───────────────────────────────────
|
||||||
|
|||||||
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