provisioning-code/workspace/tools/secrets.nu

849 lines
36 KiB
Text
Raw Normal View History

#!/usr/bin/env nu
# Workspace secret management — SOPS/age encrypted secrets from Nickel component configs.
#
# Required workspace structure:
# <workspace-root>/
# ├── templates/ YAML templates with PLACEHOLDER values (one per secret)
# └── infra/<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/<name>.ncl Nickel component config (used by `new`)
#
# Infra target resolution (first wins):
# 1. --infra <name> 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 <string> 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 <name> 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/<name>/ 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:
<workspace-root>/
├── templates/ YAML templates — one file per secret, keys with PLACEHOLDER values
└── infra/<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/<name>.ncl Nickel component config (required by `new`)
Infra target (first wins): --infra <name> | 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 <string> — 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 <name> [KEY=value ...] Create SOPS-encrypted secret; prefers infra/<infra>/secrets/<name>.sops.yaml.template
over templates/<name>.yaml; staged template is deleted on success
new <name> [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 <name> 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 <name> Decrypt and print for a human — plaintext, warning banner, no --out/-e
get <name> [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 <string>: same, with <string> prepended to each KEY
edit <name> 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<string>] {
if ($args | is-empty) {
error make --unspanned { msg: "Usage: make [--force] <name|pattern> [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<string>, 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<string>] {
if ($args | is-empty) {
error make --unspanned { msg: "Usage: new <name> [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 <name>" }
}
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 <name> 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 <name>" }
}
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 <name>` — all keys: text (KEY: value), --out json, or -e/--env-var (export lines)
# `get <name> <key>` — one key: bare value (text), {key,value} (json), or export line (-e)
def cmd-get [ctx: record, args: list<string>] {
if ($args | is-empty) {
error make --unspanned { msg: "Usage: get <name> [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 <name>" }
}
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<string>]: 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<string>]: 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" }
}
}