#!/usr/bin/env python3 """secret — fleet credential store & retrieve over Vaultwarden (bw-backed). Stores each secret as an item in the `infra-ops` org's Default collection (so the operator's primary account, an org member, sees it too), organised by folder + a `//` name convention. Text secrets (env files, tokens) live in the item note; binary secrets (keys/certs) are base64'd into a hidden field. Auth bootstraps from ~/.config/secrets-broker/bootstrap.env (0600): apikey login + master-password unlock -> per-invocation session. Secret VALUES are never printed except by an explicit `get`. Commands: secret put (--file P | --stdin) [--folder C] [--field k=v]... secret get [--field F] [--file OUT] secret list [--prefix P] secret backfill --host H [--dry-run] # enumerate a host's .env/env.sh, upsert """ import argparse import base64 import hashlib import json import os import shlex import subprocess import sys import time from pathlib import Path BW = os.environ.get("BW_BIN", str(Path.home() / ".local/bin/bw")) CFG = Path(os.environ.get("SECRET_CFG_DIR", str(Path.home() / ".config/secrets-broker"))) BOOTSTRAP = CFG / "bootstrap.env" SERVER = "https://vaultwarden.phasefinal.com" ORG_ID = "d30c6b58-773c-4e39-916e-8e33ca7f8b81" COLLECTION_ID = "b829376a-9db1-4091-a4ae-9a36132ad4c2" def die(msg): print(f"secret: {msg}", file=sys.stderr) sys.exit(1) def load_env(): if not BOOTSTRAP.is_file(): die(f"no bootstrap creds at {BOOTSTRAP}") if oct(BOOTSTRAP.stat().st_mode)[-3:] not in ("600", "400"): die(f"{BOOTSTRAP} must be 0600") creds = {} for line in BOOTSTRAP.read_text().splitlines(): for k in ("BW_CLIENTID", "BW_CLIENTSECRET", "RBW_MASTER_PW"): if line.startswith(k + "="): creds[k] = line.split("=", 1)[1] env = dict(os.environ) env["BW_CLIENTID"] = creds.get("BW_CLIENTID", "") env["BW_CLIENTSECRET"] = creds.get("BW_CLIENTSECRET", "") env["BW_PASSWORD"] = creds.get("RBW_MASTER_PW", "") return env def bw(args, env, session=None, stdin=None, check=True): cmd = [BW] + args + (["--session", session] if session else []) r = subprocess.run(cmd, env=env, input=stdin, capture_output=True) if check and r.returncode != 0: die(f"bw {args[0]} failed: {r.stderr.decode(errors='replace').strip()}") return r def status(env): r = subprocess.run([BW, "status"], env=env, capture_output=True) try: return json.loads(r.stdout.decode()).get("status", "unauthenticated") except Exception: return "unauthenticated" def session(env): if status(env) == "unauthenticated": subprocess.run([BW, "config", "server", SERVER], env=env, capture_output=True) bw(["login", "--apikey"], env) s = bw(["unlock", "--passwordenv", "BW_PASSWORD", "--raw"], env).stdout.decode().strip() return s or die("unlock produced no session") def encode(obj, env): return bw(["encode"], env, stdin=json.dumps(obj).encode()).stdout def find(env, s, name): r = bw(["list", "items", "--search", name], env, s) for it in json.loads(r.stdout.decode() or "[]"): if it.get("name") == name: return it return None def folder_id(env, s, cls): if not cls: return None for f in json.loads(bw(["list", "folders"], env, s).stdout.decode() or "[]"): if f.get("name") == cls: return f["id"] r = bw(["create", "folder"], env, s, stdin=encode({"name": cls}, env)) return json.loads(r.stdout.decode())["id"] def cmd_put(a): env = load_env() s = session(env) if a.file: data = Path(a.file).read_bytes() elif a.stdin: data = sys.stdin.buffer.read() else: die("put needs --file or --stdin") sha = hashlib.sha256(data).hexdigest() try: notes, binary = data.decode("utf-8"), False except UnicodeDecodeError: notes, binary = "", True fields = [ {"name": "sha256", "value": sha, "type": 0}, {"name": "synced_at", "value": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "type": 0}, ] for kv in (a.field or []): k, v = kv.split("=", 1) fields.append({"name": k, "value": v, "type": 0}) if binary: fields.append({"name": "content_b64", "value": base64.b64encode(data).decode(), "type": 1}) item = { "type": 2, "name": a.name, "notes": notes, "organizationId": ORG_ID, "collectionIds": [COLLECTION_ID], "folderId": folder_id(env, s, a.folder), "fields": fields, "secureNote": {"type": 0}, } existing = find(env, s, a.name) if existing: item["id"] = existing["id"] bw(["edit", "item", existing["id"]], env, s, stdin=encode(item, env)) action = "updated" else: bw(["create", "item"], env, s, stdin=encode(item, env)) action = "created" print(f"{action}: {a.name} (sha256 {sha[:12]}, {len(data)} bytes{', binary' if binary else ''})") def cmd_get(a): env = load_env() s = session(env) it = find(env, s, a.name) or die(f"not found: {a.name}") full = json.loads(bw(["get", "item", it["id"]], env, s).stdout.decode()) if a.field: for f in full.get("fields") or []: if f["name"] == a.field: if a.field == "content_b64" and a.file: Path(a.file).write_bytes(base64.b64decode(f["value"])) os.chmod(a.file, 0o600) return print(f"wrote {a.file} (0600)") return print(f["value"]) die(f"no field '{a.field}' on {a.name}") content = full.get("notes") or "" if a.file: Path(a.file).write_text(content) os.chmod(a.file, 0o600) print(f"wrote {a.file} (0600)") else: sys.stdout.write(content if content.endswith("\n") else content + "\n") def cmd_list(a): env = load_env() s = session(env) items = json.loads(bw(["list", "items"], env, s).stdout.decode() or "[]") n = 0 for it in sorted(items, key=lambda x: x.get("name", "")): name = it.get("name", "") if a.prefix and not name.startswith(a.prefix): continue meta = {f["name"]: f["value"] for f in (it.get("fields") or [])} print(f"{name}\t{meta.get('synced_at', '?')}\t{meta.get('sha256', '')[:12]}") n += 1 print(f"# {n} item(s)", file=sys.stderr) def _host_secret_files(host): find_cmd = (r"find /opt/docker -maxdepth 4 \( -name .env -o -name env.sh \) " r"! -name '*.example' -type f 2>/dev/null") r = subprocess.run(["ssh", host, find_cmd], capture_output=True, text=True) return [p for p in r.stdout.split("\n") if p.strip()] def _name_for(host, path): parts = Path(path).parts # e.g. /opt/docker/compose/gitea/.env stack = parts[-2] if len(parts) >= 2 else "root" return f"{host}/{stack}/{Path(path).name}" def cmd_backfill(a): host = a.host files = _host_secret_files(host) if not files: return print(f"{host}: no .env/env.sh found under /opt/docker") env = load_env() s = None if a.dry_run else session(env) print(f"{host}: {len(files)} secret file(s)") for path in files: name = _name_for(host, path) data = subprocess.run(["ssh", host, f"cat {shlex.quote(path)}"], capture_output=True).stdout sha = hashlib.sha256(data).hexdigest() if a.dry_run: print(f" WOULD store {name}\t({len(data)} bytes, sha {sha[:12]}) <- {path}") continue try: notes = data.decode("utf-8") except UnicodeDecodeError: notes = "" fields = [ {"name": "sha256", "value": sha, "type": 0}, {"name": "source_host", "value": host, "type": 0}, {"name": "source_path", "value": path, "type": 0}, {"name": "synced_at", "value": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "type": 0}, ] item = {"type": 2, "name": name, "notes": notes, "organizationId": ORG_ID, "collectionIds": [COLLECTION_ID], "folderId": folder_id(env, s, "hosts"), "fields": fields, "secureNote": {"type": 0}} existing = find(env, s, name) if existing: item["id"] = existing["id"] bw(["edit", "item", existing["id"]], env, s, stdin=encode(item, env)) verb = "updated" else: bw(["create", "item"], env, s, stdin=encode(item, env)) verb = "stored " # round-trip verify back = json.loads(bw(["get", "item", find(env, s, name)["id"]], env, s).stdout.decode()) ok = hashlib.sha256((back.get("notes") or "").encode()).hexdigest() == sha print(f" {verb} {name}\t({len(data)}b) verify={'OK' if ok else 'MISMATCH'}") def main(): p = argparse.ArgumentParser(prog="secret", description="fleet credential store over Vaultwarden") sub = p.add_subparsers(dest="cmd", required=True) pp = sub.add_parser("put"); pp.add_argument("name") pp.add_argument("--file"); pp.add_argument("--stdin", action="store_true") pp.add_argument("--folder"); pp.add_argument("--field", action="append") pp.set_defaults(fn=cmd_put) pg = sub.add_parser("get"); pg.add_argument("name") pg.add_argument("--field"); pg.add_argument("--file"); pg.set_defaults(fn=cmd_get) pl = sub.add_parser("list"); pl.add_argument("--prefix"); pl.set_defaults(fn=cmd_list) pb = sub.add_parser("backfill"); pb.add_argument("--host", required=True) pb.add_argument("--dry-run", action="store_true"); pb.set_defaults(fn=cmd_backfill) a = p.parse_args() a.fn(a) if __name__ == "__main__": main()