#!/usr/bin/env nu # Workspace secret management — SOPS/age encrypted secrets from Nickel component configs. # # Required workspace structure: # / # ├── templates/ YAML templates with PLACEHOLDER values (one per secret) # └── infra// # ├── sops.yaml SOPS creation rules (age recipient + encrypted_regex) # ├── .kage Age private key — NEVER commit, back up securely # ├── secrets/ Encrypted *.sops.yaml files (safe to commit) # └── components/.ncl Nickel component config (used by `new`) # # Infra target resolution (first wins): # 1. --infra flag # 2. WORKSPACE_INFRA env var # # Workspace root resolution (first wins): # 1. WORKSPACE_ROOT env var # 2. Current working directory ($env.PWD) # # Output format (--out/-o, default text; get only): # text KEY: value listing (all-keys) or bare value (single-key) — default # json structured — supported by: list, pending, info, get # # --env-var/-e (get only): emit `export KEY='value'` lines instead of --out. # --env-prefix/-p prepends a prefix to each KEY (e.g. -p MYAPP_ -> MYAPP_FOO=...); # passing --env-prefix with a non-empty value implies -e, so -e itself is optional then. # The resulting var name (prefix + key) is always upper-cased regardless of the # secret's own key casing — text/json output is unaffected and keeps original casing. # Both are mutually exclusive with --out json. # Note: Nushell reserves the bare identifier `env` for its builtin $env record, # so the long flag is --env-var rather than --env — the short form -e is unaffected. # # Usage: # nu secrets.nu --infra libre-wuji gen # nu secrets.nu --infra libre-wuji make postgresql POSTGRES_PASSWORD=secret # nu secrets.nu --infra libre-wuji new postgresql POSTGRES_PASSWORD=secret # nu secrets.nu --infra libre-wuji list # nu secrets.nu --infra libre-wuji --out json list # nu secrets.nu --infra libre-wuji show postgresql # human-readable decrypt # nu secrets.nu --infra libre-wuji get postgresql # all keys, plain KEY: value # nu secrets.nu --infra libre-wuji get postgresql POSTGRES_PASSWORD # single key, bare value # nu secrets.nu --infra libre-wuji -o json get postgresql # all keys, JSON # source <(nu secrets.nu --infra libre-wuji -e get wireguard) # export lines, no prefix # source <(nu secrets.nu --infra libre-wuji -p WG_ get wireguard) # export WG_KEY=value lines # nu secrets.nu --infra libre-wuji edit docker_mailserver # # WORKSPACE_INFRA=libre-wuji nu secrets.nu make postgresql POSTGRES_PASSWORD=secret def main [ --infra (-i): string = "" # Infra name; falls back to WORKSPACE_INFRA env var --out (-o): string = "text" # Output format: text | json (list, pending, info, get) --env-var (-e) # get only: emit `export KEY='value'` lines --env-prefix (-p): string = "" # get only: prefix prepended to each KEY in -e output (implies -e) cmd?: string # gen | make | new | list | show | get | edit ...args: string ]: nothing -> nothing { let infra_name = if ($infra | is-not-empty) { $infra } else { $env.WORKSPACE_INFRA? | default "" } if ($infra_name | is-empty) { show-help error make --unspanned { msg: "Infra name required — pass --infra or set WORKSPACE_INFRA" } } if $out not-in ["text" "json"] { error make --unspanned { msg: $"Invalid --out '($out)' — must be 'text' or 'json'" } } let env_mode = ($env_var or ($env_prefix | is-not-empty)) if $env_mode and $out == "json" { error make --unspanned { msg: "--env-var/-e (or --env-prefix) and --out json are mutually exclusive" } } let ws_root = ($env.WORKSPACE_ROOT? | default $env.PWD) let infra_dir = ($ws_root | path join "infra" $infra_name) let infra_root = ($ws_root | path join "infra") let secrets_dir = ($infra_dir | path join "secrets") let templates_dir = ($ws_root | path join "templates") # sops.yaml and .kage may live in infra// or fall back to infra/ let sops_yaml = do { let specific = ($infra_dir | path join "sops.yaml") if ($specific | path exists) { $specific } else { $infra_root | path join "sops.yaml" } } let kage_file = do { let specific = ($infra_dir | path join ".kage") if ($specific | path exists) { $specific } else { $infra_root | path join ".kage" } } let ctx = { infra_name: $infra_name, ws_root: $ws_root, infra_dir: $infra_dir, secrets_dir: $secrets_dir, templates_dir: $templates_dir, sops_yaml: $sops_yaml, kage_file: $kage_file, out_format: $out, env_var: $env_mode, env_prefix: $env_prefix, } let c = ($cmd | default "help") match $c { "gen" => { cmd-gen $ctx } "make" => { cmd-make $ctx $args } "new" => { cmd-new $ctx $args } "info" => { cmd-info $ctx ($args | get 0? | default "") } "list" => { cmd-list $ctx } "pending" => { cmd-pending $ctx } "show" => { cmd-show $ctx ($args | get 0? | default "") } "get" => { cmd-get $ctx $args } "edit" => { cmd-edit $ctx ($args | get 0? | default "") } _ => { show-help } } } def show-help [] { print " Workspace secret management (shared tool) Required workspace structure: / ├── templates/ YAML templates — one file per secret, keys with PLACEHOLDER values └── infra// ├── sops.yaml SOPS creation rules (age recipient, encrypted_regex) ├── .kage Age private key (NEVER commit — back up to vault) ├── secrets/ Encrypted *.sops.yaml output (safe to commit) └── components/.ncl Nickel component config (required by `new`) Infra target (first wins): --infra | WORKSPACE_INFRA env var Workspace root (first wins): WORKSPACE_ROOT env var | current directory Output format: --out/-o text|json (default text) — list, pending, info, get --env-var/-e — get only: 'export KEY=value' lines --env-prefix/-p — get only: prefix each KEY (implies -e) (-e/-p and --out json are mutually exclusive) Commands: gen Generate age keypair, write .kage, update sops.yaml make [KEY=value ...] Create SOPS-encrypted secret; prefers infra//secrets/.sops.yaml.template over templates/.yaml; staged template is deleted on success new [KEY=value ...] Derive template from component requires.credentials, then encrypt Keys with credential_generators are auto-generated (no KEY=value needed) Override any auto-generated key by supplying KEY=value explicitly info Show credentials and generators declared by a component list List encrypted secrets; appends ??? section for pending staged templates pending List only staged templates (*.sops.yaml.template) without encrypted secret show Decrypt and print for a human — plaintext, warning banner, no --out/-e get [key] Decrypt for script consumption — all keys or one key text (default): KEY: value listing, or bare value for a single key --out json: {name, values} or {name, key, value} --env-var/-e: export KEY='value' lines (all keys or the one key) --env-prefix/-p : same, with prepended to each KEY edit Open SOPS secret in \$EDITOR (decrypt/re-encrypt transparently) Credential generators (declared in component NCL as credential_generators): random_password Generates a random URL-safe password (length configurable) wg_privkey Calls `wg genkey` — requires wireguard-tools wg_pubkey Derives public key from an already-generated private key bcrypt_password Calls `htpasswd -nbB` — derives bcrypt hash from a source key Examples: nu secrets.nu --infra libre-wuji gen nu secrets.nu --infra libre-wuji info wireguard # show generators before creating nu secrets.nu --infra libre-wuji new wireguard # all keys auto-generated nu secrets.nu --infra libre-wuji new postgresql POSTGRES_PASSWORD=hunter2 nu secrets.nu --infra libre-wuji make hcloud token=hcloud_xxxxx nu secrets.nu --infra libre-wuji list nu secrets.nu --infra libre-wuji --out json list nu secrets.nu --infra libre-wuji show postgresql nu secrets.nu --infra libre-wuji get postgresql POSTGRES_PASSWORD nu secrets.nu --infra libre-wuji -o json get postgresql source <(nu secrets.nu --infra libre-wuji -e get wireguard) source <(nu secrets.nu --infra libre-wuji -p WG_ get wireguard) WORKSPACE_INFRA=libre-wuji nu secrets.nu edit docker_mailserver " } # Generate age keypair and write sops.yaml def cmd-gen [ctx: record] { if (^which age-keygen | complete).exit_code != 0 { error make --unspanned { msg: "age-keygen not found — install: brew install age" } } if ($ctx.kage_file | path exists) { print $"⚠️ ($ctx.kage_file) already exists" print "Overwrite? This invalidates all existing encrypted secrets. [y/N] " let ans = (input "") if $ans not-in ["y", "Y"] { print "Aborted."; return } ^rm -f $ctx.kage_file } let result = (do { ^age-keygen -o $ctx.kage_file } | complete) if $result.exit_code != 0 { error make --unspanned { msg: $"age-keygen failed: ($result.stderr)" } } let pub_key = ( open --raw $ctx.kage_file | lines | where { $in | str starts-with "# public key: " } | first | str replace "# public key: " "" | str trim ) print $"✓ Age keypair generated" print $" private key: ($ctx.kage_file)" print $" public key: ($pub_key)" let base_keys = ["api_key" "certificate" "key" "password" "secret" "token" "AWS_ACCESS_KEY_ID" "AWS_SECRET_ACCESS_KEY" "RESTIC_PASSWORD" "RESTIC_REPOSITORY" "KOPIA_PASSWORD" "RELAY_USER" "RELAY_USERNAME" "RELAY_PASSWORD" "ADMIN_PASSWORD"] let regex = $"^(($base_keys | sort | str join '|'))$" let sops_content = ([ "creation_rules:" ' - path_regex: "secrets/.*\\.sops\\.yaml$"' $" age: ($pub_key)" $' encrypted_regex: "($regex)"' ] | str join "\n") $"($sops_content)\n" | save --force $ctx.sops_yaml print $"✓ ($ctx.sops_yaml) updated" let gitignore = ($ctx.infra_dir | path join ".gitignore") let required_entries = [".kage" "secrets/*.sops.yaml.template"] let existing = if ($gitignore | path exists) { open --raw $gitignore } else { "" } let missing = ($required_entries | where {|e| not ($existing | str contains $e)}) if ($missing | is-not-empty) { if not ($gitignore | path exists) { "# do not commit age private keys or staged secret templates\n" | save $gitignore } for e in $missing { $"\n($e)\n" | save --append $gitignore } } print "⚠️ Back up .kage securely — it cannot be recovered if lost." } # Create SOPS-encrypted secret(s) from existing template(s) + KEY=value pairs. # Accepts multiple names and glob patterns expanded against templates/. def cmd-make [ctx: record, args: list] { if ($args | is-empty) { error make --unspanned { msg: "Usage: make [--force] [name ...] [KEY=value ...]" } } let force = ("--force" in $args) or ("-f" in $args) let rest = ($args | where { |a| $a != "--force" and $a != "-f" }) let pairs = ($rest | where { |a| $a | str contains "=" }) let raw_names = ($rest | where { |a| not ($a | str contains "=") } | each { str replace --regex '\.yaml$' "" }) let names = ($raw_names | each {|n| if ($n | str contains "*") or ($n | str contains "?") { let matches = (try { glob ($ctx.templates_dir | path join $"($n).yaml") } catch { [] }) if ($matches | is-empty) { error make --unspanned { msg: $"Pattern '($n)' matched no templates in ($ctx.templates_dir)" } } $matches | each {|f| $f | path basename | str replace --regex '\.yaml$' "" } } else { [$n] } } | flatten) if ($names | is-empty) { error make --unspanned { msg: "No names resolved from arguments" } } if ($names | length) == 1 { _cmd-make-one $ctx ($names | first) $pairs $force } else { for name in $names { print $"── ($name)" _cmd-make-one $ctx $name $pairs $force } } } def _cmd-make-one [ctx: record, name: string, pairs: list, force: bool] { let staged_path = ($ctx.secrets_dir | path join $"($name).sops.yaml.template") let infra_tpl = ($ctx.secrets_dir | path join "templates" $"($name).yaml") let generic_path = ($ctx.templates_dir | path join $"($name).yaml") let prov_root = ($env.PROVISIONING? | default "/usr/local/provisioning") let global_path = ($prov_root | path join "workspace" "tools" "templates" $"($name).yaml") let tpl = if ($staged_path | path exists) { { path: $staged_path, staged: true } } else if ($infra_tpl | path exists) { { path: $infra_tpl, staged: false } } else if ($generic_path | path exists) { { path: $generic_path, staged: false } } else if ($global_path | path exists) { { path: $global_path, staged: false } } else { let avail = (try { ls $ctx.templates_dir | where type == file | get name | each {|f| $f | path basename | str replace ".yaml" ""} | str join ", " } catch { "" }) let infra_tpl_avail = (try { ls ($ctx.secrets_dir | path join "templates") | where type == file | get name | each {|f| $f | path basename | str replace ".yaml" ""} | str join ", " } catch { "" }) let global_avail = (try { ls ($prov_root | path join "workspace" "tools" "templates") | where type == file | get name | each {|f| $f | path basename | str replace ".yaml" ""} | str join ", " } catch { "" }) error make --unspanned { msg: $"Template '($name)' not found. Checked:\n ($staged_path)\n ($infra_tpl)\n ($generic_path)\n ($global_path)\nAvailable in secrets/templates/: ($infra_tpl_avail)\nAvailable in templates/: ($avail)\nAvailable in global templates/: ($global_avail)" } } _assert-sops-ready $ctx mkdir $ctx.secrets_dir let output = ($ctx.secrets_dir | path join $"($name).sops.yaml") if ($output | path exists) and not $force { print $" ($output) already exists" let ans = (input " Overwrite? [y/N] ") if $ans not-in ["y", "Y"] { print " skipped" return } } let overrides = (_parse-pairs $pairs) # Staged templates: YAML-based key overrides (values may be real or CHANGE_ME sentinels). # Generic templates: positional PLACEHOLDER replacement (original behaviour). let content = if $tpl.staged and not ($overrides | is-empty) { mut rec = (open --raw $tpl.path | from yaml) for kv in ($overrides | items {|k v| {key: $k, val: $v}}) { $rec = ($rec | upsert $kv.key $kv.val) } $rec | to yaml } else if not $tpl.staged { mut c = (open --raw $tpl.path) for kv in ($overrides | items {|k v| {key: $k, val: $v}}) { $c = ($c | str replace "PLACEHOLDER" $kv.val) } $c } else { open --raw $tpl.path } # Keys come from the actual content to encrypt — covers both standalone and cmd-new paths. # Must happen before sops encrypt so the regex is up to date. let content_keys = (try { $content | from yaml | columns } | default []) let regex_updated = (_ensure-keys-in-regex $ctx $content_keys) $content | save --force $output let enc = (do { with-env { SOPS_AGE_KEY_FILE: $ctx.kage_file } { ^sops --encrypt --in-place --config $ctx.sops_yaml $output } } | complete) if $enc.exit_code != 0 { ^rm -f $output error make --unspanned { msg: $"sops encrypt failed:\n($enc.stderr)" } } print $"✓ ($output) created" print $" Keys encrypted: ($content_keys | str join ', ')" if $tpl.staged { ^rm -f $tpl.path print $" Staged template removed: ($tpl.path | path basename)" } } # Derive template from component requires.credentials, then call cmd-make def cmd-new [ctx: record, args: list] { if ($args | is-empty) { error make --unspanned { msg: "Usage: new [KEY=value ...]" } } let name = ($args | first | str replace --regex '\.yaml$' "") let pairs = ($args | skip 1) let ncl_path = do { let p1 = ($ctx.infra_dir | path join "components" $"($name).ncl") let p2 = ($ctx.infra_dir | path join "components" $"($name | str replace --all '_' '-').ncl") if ($p1 | path exists) { $p1 } else { $p2 } } if not ($ncl_path | path exists) { cmd-make $ctx $args return } # Guard: staged template takes priority in _cmd-make-one, which would orphan the # workspace template that cmd-new is about to generate with real credential values. let staged_path = ($ctx.secrets_dir | path join $"($name).sops.yaml.template") if ($staged_path | path exists) { error make --unspanned { msg: $"Staged template already exists: ($staged_path)\nEncrypt it with 'secret-make ($name)', or remove it to regenerate credentials via 'secret-new'." } } let prov_root = ($env.PROVISIONING? | default "/usr/local/provisioning") let export = (do { ^nickel export --format json --import-path $prov_root $ncl_path } | complete) if $export.exit_code != 0 { error make --unspanned { msg: $"nickel export failed for ($name):\n($export.stderr)" } } let comp = ($export.stdout | from json) let name_key = ($name | str replace --all '-' '_') let entry = ($comp | get -o $name_key | default ($comp | get -o $name | default {})) let creds = ($entry | get -o requires | default {} | get -o credentials | default []) if ($creds | is-empty) { error make --unspanned { msg: $"Component '($name)' has no requires.credentials — nothing to template" } } let generators = ($entry | get -o credential_generators | default {}) # Generate values: process in order so dependent generators (wg_pubkey) see already-generated keys. mut generated: record = {} for k in $creds { let gen = ($generators | get -o $k | default {}) let supplied = ($pairs | where {|p| $p | str starts-with $"($k)=" } | get 0? | default "") let value = if ($supplied | is-not-empty) { $supplied | str replace $"($k)=" "" } else if ($gen | is-not-empty) { _generate-value $k $gen $generated } else { "PLACEHOLDER" } $generated = ($generated | insert $k $value) } let values_map = $generated # immutable copy — mut can't be captured in closures mkdir $ctx.templates_dir let template_path = ($ctx.templates_dir | path join $"($name).yaml") let content = ($creds | each {|k| $"($k): ($values_map | get $k)" } | str join "\n") $"($content)\n" | save --force $template_path print $"✓ Template generated: ($template_path)" print $" Credentials: ($creds | str join ', ')" if ($generators | is-not-empty) { let auto = ($creds | where {|k| ($generators | get -o $k | default {} | is-not-empty) and ($pairs | where {|p| $p | str starts-with $"($k)=" } | is-empty) }) if ($auto | is-not-empty) { print $" Auto-generated: ($auto | str join ', ')" } } _cmd-make-one $ctx $name [] false } # Show credentials and generators declared by a component def cmd-info [ctx: record, name: string] { if ($name | is-empty) { error make --unspanned { msg: "Usage: info " } } let name = ($name | str replace --regex '\.yaml$' "") let ncl_path = ($ctx.infra_dir | path join "components" $"($name).ncl") if not ($ncl_path | path exists) { error make --unspanned { msg: $"Component config not found: ($ncl_path)" } } let prov_root = ($env.PROVISIONING? | default "/usr/local/provisioning") let export = (do { ^nickel export --format json --import-path $prov_root $ncl_path } | complete) if $export.exit_code != 0 { error make --unspanned { msg: $"nickel export failed for ($name):\n($export.stderr)" } } let entry = ($export.stdout | from json | get -o $name | default {}) let creds = ($entry | get -o requires | default {} | get -o credentials | default []) let gens = ($entry | get -o credential_generators | default {}) let secret_path = ($ctx.secrets_dir | path join $"($name).sops.yaml") let secret_status = if ($secret_path | path exists) { "exists" } else { "not created" } if $ctx.out_format == "json" { let credentials = ($creds | each {|k| let gen = ($gens | get -o $k | default {}) let kind = ($gen.kind? | default "") { key: $k, generator: $kind, auto: ($kind | is-not-empty), } }) { component: $name, secret_path: $secret_path, secret_status: $secret_status, credentials: $credentials, } | to json | print return } print $"\nComponent: ($name)" print $"Secret: ($secret_path) \(($secret_status)\)" print "" if ($creds | is-empty) { print " No credentials declared (requires.credentials is empty)" } else { print " Key Generator Action" print " ─────────────────── ───────────────── ──────────────────────────────" for k in $creds { let gen = ($gens | get -o $k | default {}) let kind = ($gen.kind? | default "") let gen_label = match $kind { "random_password" => $"random_password \(len=($gen.length? | default 32)\)" "wg_privkey" => "wg genkey" "wg_pubkey" => $"wg pubkey ← ($gen.source? | default 'WG_PRIVATE_KEY')" "bcrypt_password" => $"htpasswd -nbB ← ($gen.source? | default 'WGUI_PASSWORD')" "" => "— manual (PLACEHOLDER)" _ => $kind } let action = if ($kind | is-not-empty) { "auto-generated" } else { "supply KEY=value" } print $" ($k | fill -w 20) ($gen_label | fill -w 18) ($action)" } } print "" if ($secret_path | path exists) { print $" Run: just secret-show ($name) to inspect current values" } else { print $" Run: just secret-new ($name) to create (auto-generates keys above)" } print "" } # List encrypted secrets and flag staged templates that have not been encrypted yet def cmd-list [ctx: record] { let sops_files = (try { glob $"($ctx.secrets_dir)/*.sops.yaml" } catch { [] }) let tpl_files = (try { glob $"($ctx.secrets_dir)/*.sops.yaml.template" } catch { [] }) let secrets = ( $sops_files | each {|f| let info = (ls $f | first) { name: ($f | path basename | str replace ".sops.yaml" ""), size: $info.size, modified: $info.modified, } } ) let pending = ( $tpl_files | each {|t| let name = ($t | path basename | str replace ".sops.yaml.template" "") let sops = ($ctx.secrets_dir | path join $"($name).sops.yaml") if not ($sops | path exists) { [$name] } else { [] } } | flatten ) if $ctx.out_format == "json" { { secrets_dir: $ctx.secrets_dir, secrets: $secrets, pending: $pending } | to json | print return } if ($sops_files | is-empty) and ($tpl_files | is-empty) { print $"No encrypted secrets or staged templates found in ($ctx.secrets_dir)" return } if ($sops_files | is-not-empty) { print $"\nEncrypted secrets in ($ctx.secrets_dir):\n" for s in $secrets { let mod = ($s.modified | format date "%Y-%m-%d %H:%M") print $" ($s.name) ($s.size) ($mod)" } } if ($pending | is-not-empty) { print $"\n Pending — template staged, secret not yet encrypted:\n" for name in $pending { print $" ??? ($name)" } print $"\n Run: just secret-make to encrypt a pending secret" } print "" } # List only staged templates (*.sops.yaml.template) without a corresponding encrypted secret def cmd-pending [ctx: record] { let tpl_files = (try { glob $"($ctx.secrets_dir)/*.sops.yaml.template" } catch { [] }) let pending = ( $tpl_files | each {|t| let name = ($t | path basename | str replace ".sops.yaml.template" "") let sops = ($ctx.secrets_dir | path join $"($name).sops.yaml") if not ($sops | path exists) { [$name] } else { [] } } | flatten ) if $ctx.out_format == "json" { { secrets_dir: $ctx.secrets_dir, pending: $pending } | to json | print return } if ($tpl_files | is-empty) { print "No staged templates found" return } if ($pending | is-empty) { print "No pending secrets — all staged templates have a corresponding encrypted secret" return } print $"\nPending secrets in ($ctx.secrets_dir):\n" for name in $pending { print $" ??? ($name | fill -w 30) run: just secret-make ($name)" } print "" } # Decrypt and print a secret for human inspection (plaintext, with warning banner). def cmd-show [ctx: record, name: string] { if ($name | is-empty) { error make --unspanned { msg: "Usage: show " } } let name = ($name | str replace --regex '\.yaml$' "") let path = ($ctx.secrets_dir | path join $"($name).sops.yaml") _assert-secret-exists $path $name _assert-kage-exists $ctx.kage_file print $"⚠️ Decrypted content of ($name):\n" with-env { SOPS_AGE_KEY_FILE: $ctx.kage_file } { ^sops --decrypt --config $ctx.sops_yaml $path } } # Decrypt and return a secret's value(s) for script consumption. # `get ` — all keys: text (KEY: value), --out json, or -e/--env-var (export lines) # `get ` — one key: bare value (text), {key,value} (json), or export line (-e) def cmd-get [ctx: record, args: list] { if ($args | is-empty) { error make --unspanned { msg: "Usage: get [key]" } } let name = ($args | first | str replace --regex '\.yaml$' "") let key = ($args | get 1? | default "") let path = ($ctx.secrets_dir | path join $"($name).sops.yaml") _assert-secret-exists $path $name _assert-kage-exists $ctx.kage_file let dec = (with-env { SOPS_AGE_KEY_FILE: $ctx.kage_file } { ^sops --decrypt --config $ctx.sops_yaml $path } | complete) if $dec.exit_code != 0 { error make --unspanned { msg: $"sops decrypt failed:\n($dec.stderr)" } } let values = ($dec.stdout | from yaml) if ($key | is-not-empty) { if $key not-in ($values | columns) { error make --unspanned { msg: $"Key '($key)' not found in secret '($name)' — available: ($values | columns | str join ', ')" } } let val = ($values | get $key) if $ctx.env_var { let var_name = ($"($ctx.env_prefix)($key)" | str upcase) print $"export ($var_name)=(_shell-quote ($val | into string))" } else if $ctx.out_format == "json" { { name: $name, key: $key, value: $val } | to json | print } else { print $val } return } if $ctx.env_var { for kv in ($values | items {|k, v| {key: $k, val: $v} }) { let var_name = ($"($ctx.env_prefix)($kv.key)" | str upcase) print $"export ($var_name)=(_shell-quote ($kv.val | into string))" } } else if $ctx.out_format == "json" { { name: $name, values: $values } | to json | print } else { for kv in ($values | items {|k, v| {key: $k, val: $v} }) { print $"($kv.key): ($kv.val)" } } } # Single-quote a value for safe use in a POSIX shell `export KEY=value` line. def _shell-quote [val: string]: nothing -> string { $"'($val | str replace --all "'" "'\\''")'" } # Open SOPS secret in $EDITOR — decrypt on open, re-encrypt on save def cmd-edit [ctx: record, name: string] { if ($name | is-empty) { error make --unspanned { msg: "Usage: edit " } } let name = ($name | str replace --regex '\.yaml$' "") let path = ($ctx.secrets_dir | path join $"($name).sops.yaml") _assert-secret-exists $path $name _assert-kage-exists $ctx.kage_file let editor = (match ($env.EDITOR? | default "vim") { "zed" => "vim", $e => $e, }) with-env { SOPS_AGE_KEY_FILE: $ctx.kage_file, EDITOR: $editor } { do --ignore-errors { ^sops --config $ctx.sops_yaml $path } } let code = $env.LAST_EXIT_CODE if $code != 0 and $code != 200 { error make --unspanned { msg: $"sops failed (exit ($code))" } } } # Generate a credential value using the generator spec from the component config. # `generated` carries already-generated values so dependent keys (e.g. wg_pubkey) can reference them. def _generate-value [key: string, gen: record, generated: record]: nothing -> string { match ($gen.kind? | default "") { "random_password" => { let len = ($gen.length? | default 32 | into int) let r = (do { ^openssl rand -base64 64 } | complete) if $r.exit_code == 0 { $r.stdout | str trim | str replace --all "=" "" | str replace --all "/" "_" | str replace --all "+" "-" | str substring 0..$len } else { random chars --length $len } } "wg_privkey" => { if (^which wg | complete).exit_code != 0 { error make --unspanned { msg: $"wg_privkey: `wg` not found — install wireguard-tools\n macOS: brew install wireguard-tools\n Debian: apt-get install -y wireguard-tools" } } let r = (do { ^wg genkey } | complete) if $r.exit_code == 0 { $r.stdout | str trim } else { error make --unspanned { msg: $"wg genkey failed: ($r.stderr | str trim)" } } } "wg_pubkey" => { if (^which wg | complete).exit_code != 0 { error make --unspanned { msg: $"wg_pubkey: `wg` not found — install wireguard-tools\n macOS: brew install wireguard-tools\n Debian: apt-get install -y wireguard-tools" } } let source = ($gen.source? | default "WG_PRIVATE_KEY") let priv = ($generated | get -o $source | default "") if ($priv | is-empty) { error make --unspanned { msg: $"wg_pubkey: source key ($source) must be generated before ($key)" } } let r = (do { $priv | ^wg pubkey } | complete) if $r.exit_code == 0 { $r.stdout | str trim } else { error make --unspanned { msg: $"wg pubkey failed: ($r.stderr | str trim)" } } } "bcrypt_password" => { if (^which htpasswd | complete).exit_code != 0 { error make --unspanned { msg: $"bcrypt_password: `htpasswd` not found — install apache2-utils/httpd\n macOS: brew install httpd\n Debian: apt-get install -y apache2-utils" } } let source = ($gen.source? | default "WGUI_PASSWORD") let plaintext = ($generated | get -o $source | default "") if ($plaintext | is-empty) { error make --unspanned { msg: $"bcrypt_password: source key ($source) must be generated before ($key)" } } let r = (do { ^htpasswd -nbB -C 12 wg $plaintext } | complete) if $r.exit_code == 0 { $r.stdout | str trim | split row ":" | get 1 | str trim } else { error make --unspanned { msg: $"htpasswd failed: ($r.stderr | str trim)" } } } _ => { "PLACEHOLDER" } } } # Parse KEY=value pairs into a record def _parse-pairs [pairs: list]: nothing -> record { $pairs | reduce --fold {} {|pair, acc| let parts = ($pair | split row "=" | collect) if ($parts | length) < 2 { error make --unspanned { msg: $"Invalid KEY=value pair: ($pair)" } } let k = ($parts | first) let v = ($parts | skip 1 | str join "=") $acc | insert $k $v } } # Ensure all given keys appear in the sops.yaml encrypted_regex. # Parses the current regex, adds missing keys (sorted, deduplicated), writes back. # Returns true if the regex was updated, false if already complete. def _ensure-keys-in-regex [ctx: record, keys: list]: nothing -> bool { if not ($ctx.sops_yaml | path exists) { return false } let raw = (open --raw $ctx.sops_yaml) let regex_line = ($raw | lines | where {|l| $l | str contains "encrypted_regex"} | get 0?) if ($regex_line | default "" | is-empty) { return false } let current_keys = ( $regex_line | str replace --regex '.*encrypted_regex:\s*"' "" | str replace --regex '".*' "" | str replace --regex '^\^?\(' "" | str replace --regex '\)\$?$' "" | split row "|" | each { str trim } | where { is-not-empty } ) let missing = ($keys | where {|k| ($k | is-not-empty) and ($k not-in $current_keys) }) if ($missing | is-empty) { return false } let updated_keys = ($current_keys ++ $missing | uniq | sort) let new_regex = $"^(($updated_keys | str join '|'))$" let new_line = ($regex_line | str replace --regex 'encrypted_regex:.*' $"encrypted_regex: \"($new_regex)\"") let new_raw = ($raw | str replace $regex_line $new_line) $new_raw | save --force $ctx.sops_yaml print $" [+] sops.yaml regex updated — added: ($missing | str join ', ')" true } def _assert-sops-ready [ctx: record] { if not ($ctx.sops_yaml | path exists) { error make --unspanned { msg: $"($ctx.sops_yaml) not found — run: secrets.nu --infra ($ctx.infra_name) gen" } } if not ($ctx.kage_file | path exists) { error make --unspanned { msg: $"($ctx.kage_file) not found — run: secrets.nu --infra ($ctx.infra_name) gen" } } } def _assert-secret-exists [path: string, name: string] { if not ($path | path exists) { error make --unspanned { msg: $"Secret '($name)' not found at ($path)" } } } def _assert-kage-exists [kage_file: string] { if not ($kage_file | path exists) { error make --unspanned { msg: $"($kage_file) not found — cannot decrypt" } } }