834 lines
31 KiB
Text
834 lines
31 KiB
Text
|
|
#!/usr/bin/env nu
|
||
|
|
# provisioning dns — DNS record management via the configured provider middleware.
|
||
|
|
|
||
|
|
use ../../../catalog/providers/prov_lib/middleware.nu [
|
||
|
|
mw_dns_zone_get
|
||
|
|
mw_dns_record_list
|
||
|
|
mw_dns_record_create
|
||
|
|
mw_dns_record_update
|
||
|
|
mw_dns_record_delete
|
||
|
|
mw_dns_record_upsert
|
||
|
|
]
|
||
|
|
def load_capabilities [ws_path: string]: nothing -> record {
|
||
|
|
let cap_file = ($ws_path | path join "infra/libre-wuji/capabilities.ncl")
|
||
|
|
if not ($cap_file | path exists) { return {} }
|
||
|
|
let prov_root = ($env.PROVISIONING? | default "/usr/local/provisioning")
|
||
|
|
let r = (do { ^nickel export --format json --import-path $prov_root $cap_file } | complete)
|
||
|
|
if $r.exit_code != 0 { return {} }
|
||
|
|
($r.stdout | from json).provides?.dns? | default {}
|
||
|
|
}
|
||
|
|
|
||
|
|
def load_zones [workspace?: string]: nothing -> record {
|
||
|
|
let ws_path = ($workspace | default ($env.PROVISIONING_WORKSPACE? | default "."))
|
||
|
|
let zones_file = ($ws_path | path join "infra/lib/dns_zones.ncl")
|
||
|
|
if not ($zones_file | path exists) {
|
||
|
|
error make --unspanned { msg: $"dns_zones.ncl not found at ($zones_file)" }
|
||
|
|
}
|
||
|
|
let r = (do { ^nickel export --format json $zones_file } | complete)
|
||
|
|
if $r.exit_code != 0 {
|
||
|
|
error make --unspanned { msg: $"Failed to evaluate dns_zones.ncl: ($r.stderr)" }
|
||
|
|
}
|
||
|
|
($r.stdout | from json).zones
|
||
|
|
}
|
||
|
|
|
||
|
|
def select_zone [zones: record, zone?: string]: nothing -> string {
|
||
|
|
if ($zone | is-not-empty) { return $zone }
|
||
|
|
let names = ($zones | columns)
|
||
|
|
if ($names | length) == 1 { return ($names | first) }
|
||
|
|
let sel = ($names | input list "Select zone:")
|
||
|
|
if ($sel | is-empty) {
|
||
|
|
error make --unspanned { msg: "No zone selected" }
|
||
|
|
}
|
||
|
|
$sel
|
||
|
|
}
|
||
|
|
|
||
|
|
def resolve_provider [zones: record, zone: string]: nothing -> string {
|
||
|
|
let override = ($env | get -o DNS_PROVIDER_OVERRIDE | default "")
|
||
|
|
if ($override | is-not-empty) { return $override }
|
||
|
|
let z = ($zones | get -o $zone)
|
||
|
|
if ($z | is-empty) {
|
||
|
|
error make --unspanned { msg: $"Zone '($zone)' not found in dns_zones.ncl" }
|
||
|
|
}
|
||
|
|
$z.provider | into string
|
||
|
|
}
|
||
|
|
|
||
|
|
def --env set_cf_token [zones: record, zone: string, ws_path: string]: nothing -> nothing {
|
||
|
|
let override = ($env | get -o DNS_PROVIDER_OVERRIDE | default "")
|
||
|
|
if ($override | is-not-empty) and $override != "cloudflare" { return }
|
||
|
|
let z = ($zones | get -o $zone | default {})
|
||
|
|
let secret_ref = ($z.secret_ref? | default "")
|
||
|
|
if ($secret_ref | is-empty) { return }
|
||
|
|
|
||
|
|
# env var override — CI / pre-exported token
|
||
|
|
let env_key = ($"CF_TOKEN_($secret_ref | str upcase | str replace --all '-' '_')")
|
||
|
|
let from_env = ($env | get -o $env_key | default "")
|
||
|
|
if ($from_env | is-not-empty) {
|
||
|
|
load-env { CF_API_TOKEN: $from_env }
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
# ensure SOPS_AGE_KEY_FILE is set — look for .kage in the infra tree
|
||
|
|
if ($env | get -o SOPS_AGE_KEY_FILE | default "" | is-empty) {
|
||
|
|
let kage_hits = (glob ($"($ws_path)/infra/*/.kage") | append (glob ($"($ws_path)/.p/.kage")))
|
||
|
|
if ($kage_hits | is-not-empty) {
|
||
|
|
load-env { SOPS_AGE_KEY_FILE: ($kage_hits | first) }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
# auto-decrypt SOPS file: infra/*/secrets/<secret_ref>.sops.yaml
|
||
|
|
let matches = (glob ($"($ws_path)/infra/*/secrets/($secret_ref).sops.yaml"))
|
||
|
|
let sops_file = if ($matches | is-empty) { "" } else { $matches | first }
|
||
|
|
if ($sops_file | is-empty) {
|
||
|
|
error make --unspanned { msg: $"No SOPS file found for ($secret_ref) in ($ws_path)/infra/*/secrets/" }
|
||
|
|
}
|
||
|
|
let r = (do { ^sops --decrypt $sops_file } | complete)
|
||
|
|
if $r.exit_code != 0 {
|
||
|
|
error make --unspanned { msg: $"sops --decrypt failed for ($sops_file): ($r.stderr)" }
|
||
|
|
}
|
||
|
|
let token = ($r.stdout | from yaml | get -o token | default "")
|
||
|
|
if ($token | is-empty) {
|
||
|
|
error make --unspanned { msg: $"Key 'token' not found in ($sops_file)" }
|
||
|
|
}
|
||
|
|
load-env { CF_API_TOKEN: $token }
|
||
|
|
}
|
||
|
|
|
||
|
|
# List DNS records for a zone. Filter by --type or --source. Omit zone for interactive selector.
|
||
|
|
# With --all: always shows the full zone selector regardless of how many zones are configured.
|
||
|
|
export def "dns show" [
|
||
|
|
zone?: string
|
||
|
|
--all (-a)
|
||
|
|
--type (-t): string
|
||
|
|
--source: string
|
||
|
|
--raw (-r)
|
||
|
|
--workspace (-w): string
|
||
|
|
]: nothing -> table {
|
||
|
|
let ws = ($workspace | default ($env.PROVISIONING_WORKSPACE? | default "."))
|
||
|
|
let zones = (load_zones $ws)
|
||
|
|
|
||
|
|
let zname = if $all {
|
||
|
|
let sel = ($zones | columns | input list "Select zone:")
|
||
|
|
if ($sel | is-empty) { error make --unspanned { msg: "No zone selected" } }
|
||
|
|
$sel
|
||
|
|
} else {
|
||
|
|
select_zone $zones $zone
|
||
|
|
}
|
||
|
|
|
||
|
|
set_cf_token $zones $zname $ws
|
||
|
|
let provider = (resolve_provider $zones $zname)
|
||
|
|
mut records = (mw_dns_record_list $provider $zname)
|
||
|
|
if ($type | is-not-empty) {
|
||
|
|
$records = ($records | where type == ($type | str upcase))
|
||
|
|
}
|
||
|
|
if ($source | is-not-empty) {
|
||
|
|
$records = ($records | where source == $source)
|
||
|
|
}
|
||
|
|
if $raw { return $records }
|
||
|
|
$records | sort-by type name | each {|r|
|
||
|
|
let val = if $r.type == "MX" and ($r.priority? | is-not-empty) {
|
||
|
|
$"($r.priority) ($r.value)"
|
||
|
|
} else if $r.type == "TXT" and ($r.value | str length) > 80 {
|
||
|
|
$"($r.value | str substring 0..79)…"
|
||
|
|
} else {
|
||
|
|
$r.value
|
||
|
|
}
|
||
|
|
{
|
||
|
|
type: $r.type,
|
||
|
|
name: $r.name,
|
||
|
|
value: $val,
|
||
|
|
ttl: $r.ttl,
|
||
|
|
px: (if ($r.proxied? | default false) { "✓" } else { "-" }),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
# Add a DNS record. Omit args for interactive prompts.
|
||
|
|
# Example: dns add mail.example.com A 1.2.3.4 --zone example.com
|
||
|
|
export def "dns add" [
|
||
|
|
name?: string
|
||
|
|
type?: string
|
||
|
|
value?: string
|
||
|
|
--ttl (-l): int = 300
|
||
|
|
--zone (-z): string
|
||
|
|
--priority: int
|
||
|
|
--workspace (-w): string
|
||
|
|
--proxied (-p)
|
||
|
|
]: nothing -> record {
|
||
|
|
let ws = ($workspace | default ($env.PROVISIONING_WORKSPACE? | default "."))
|
||
|
|
let zones = (load_zones $ws)
|
||
|
|
let resolved_zone = (select_zone $zones $zone)
|
||
|
|
set_cf_token $zones $resolved_zone $ws
|
||
|
|
let provider = (resolve_provider $zones $resolved_zone)
|
||
|
|
|
||
|
|
let rec_name = if ($name | is-not-empty) { $name } else {
|
||
|
|
let v = (input $" name (e.g. mail.($resolved_zone)): ")
|
||
|
|
if ($v | is-empty) { error make --unspanned { msg: "name is required" } }
|
||
|
|
$v
|
||
|
|
}
|
||
|
|
let rec_type = if ($type | is-not-empty) { $type | str upcase } else {
|
||
|
|
let sel = (["A" "AAAA" "CNAME" "MX" "TXT" "CAA" "NS" "SRV"] | input list "Type:")
|
||
|
|
if ($sel | is-empty) { error make --unspanned { msg: "type is required" } }
|
||
|
|
$sel
|
||
|
|
}
|
||
|
|
let rec_value = if ($value | is-not-empty) { $value } else {
|
||
|
|
let v = (input " value: ")
|
||
|
|
if ($v | is-empty) { error make --unspanned { msg: "value is required" } }
|
||
|
|
$v
|
||
|
|
}
|
||
|
|
|
||
|
|
let rec_ttl = if ($name | is-not-empty) { $ttl } else {
|
||
|
|
let v = (input $" ttl [($ttl)]: ")
|
||
|
|
if ($v | is-empty) { $ttl } else { $v | into int }
|
||
|
|
}
|
||
|
|
let rec_proxied = if $proxied { true } else if ($name | is-not-empty) { false } else {
|
||
|
|
(input " proxied y/n [n]: ") == "y"
|
||
|
|
}
|
||
|
|
let rec_priority = if ($priority | is-not-empty) { $priority } else if $rec_type == "MX" {
|
||
|
|
let v = (input " priority [10]: ")
|
||
|
|
if ($v | is-empty) { 10 } else { $v | into int }
|
||
|
|
} else { null }
|
||
|
|
|
||
|
|
mut rec = {
|
||
|
|
zone: $resolved_zone,
|
||
|
|
name: $rec_name,
|
||
|
|
type: $rec_type,
|
||
|
|
value: $rec_value,
|
||
|
|
ttl: $rec_ttl,
|
||
|
|
proxied: $rec_proxied,
|
||
|
|
source: "manual",
|
||
|
|
}
|
||
|
|
if ($rec_priority | is-not-empty) {
|
||
|
|
$rec = ($rec | upsert priority $rec_priority)
|
||
|
|
}
|
||
|
|
mw_dns_record_create $provider $rec
|
||
|
|
}
|
||
|
|
|
||
|
|
# Delete a DNS record by name and type. Requires --confirm.
|
||
|
|
export def "dns delete" [
|
||
|
|
name: string
|
||
|
|
type: string
|
||
|
|
--zone (-z): string
|
||
|
|
--workspace (-w): string
|
||
|
|
--confirm
|
||
|
|
]: nothing -> nothing {
|
||
|
|
if not $confirm {
|
||
|
|
error make --unspanned { msg: "Pass --confirm to delete a DNS record" }
|
||
|
|
}
|
||
|
|
let ws = ($workspace | default ($env.PROVISIONING_WORKSPACE? | default "."))
|
||
|
|
let zones = (load_zones $ws)
|
||
|
|
let resolved_zone = (select_zone $zones $zone)
|
||
|
|
set_cf_token $zones $resolved_zone $ws
|
||
|
|
let provider = (resolve_provider $zones $resolved_zone)
|
||
|
|
let rtype = ($type | str upcase)
|
||
|
|
let live = (mw_dns_record_list $provider $resolved_zone
|
||
|
|
| where { |r| $r.name == $name and $r.type == $rtype })
|
||
|
|
if ($live | is-empty) {
|
||
|
|
print $"No record found: ($name) ($rtype) in ($resolved_zone)"
|
||
|
|
return
|
||
|
|
}
|
||
|
|
mw_dns_record_delete $provider ($live | first).id $resolved_zone
|
||
|
|
print $"Deleted ($name) ($rtype) from ($resolved_zone)"
|
||
|
|
}
|
||
|
|
|
||
|
|
# Edit a DNS record interactively — select from live records, change value/ttl/proxied.
|
||
|
|
export def "dns edit" [
|
||
|
|
zone?: string
|
||
|
|
--workspace (-w): string
|
||
|
|
]: nothing -> nothing {
|
||
|
|
let ws = ($workspace | default ($env.PROVISIONING_WORKSPACE? | default "."))
|
||
|
|
let zones = (load_zones $ws)
|
||
|
|
let zname = (select_zone $zones $zone)
|
||
|
|
set_cf_token $zones $zname $ws
|
||
|
|
let provider = (resolve_provider $zones $zname)
|
||
|
|
let records = (mw_dns_record_list $provider $zname | sort-by type name)
|
||
|
|
|
||
|
|
if ($records | is-empty) {
|
||
|
|
print $"No records found for ($zname)"
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
let labels = ($records | each {|r|
|
||
|
|
let px = if ($r.proxied? | default false) { "⚡" } else { " " }
|
||
|
|
let pri = if $r.type == "MX" and ($r.priority? | is-not-empty) { $"($r.priority) " } else { "" }
|
||
|
|
let val = if ($r.value | str length) > 55 { $"($r.value | str substring 0..54)…" } else { $r.value }
|
||
|
|
$"($r.type | fill -w 6)($px) ($r.name) → ($pri)($val)"
|
||
|
|
})
|
||
|
|
|
||
|
|
let idx = ($labels | input list --index "Select record:")
|
||
|
|
if ($idx | is-empty) { return }
|
||
|
|
let rec = ($records | get $idx)
|
||
|
|
|
||
|
|
print $" (ansi cyan)($rec.type)(ansi reset) ($rec.name)"
|
||
|
|
|
||
|
|
let action = (["Edit" "Delete"] | input list "Action:")
|
||
|
|
if ($action | is-empty) { return }
|
||
|
|
|
||
|
|
if $action == "Delete" {
|
||
|
|
let confirm = (input " Delete this record? y/N: ")
|
||
|
|
if $confirm != "y" { print "Cancelled."; return }
|
||
|
|
mw_dns_record_delete $provider $rec.id $zname
|
||
|
|
print $" deleted: ($rec.type) ($rec.name)"
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
let raw_value = (input $" value [($rec.value)]: ")
|
||
|
|
let new_value = if ($raw_value | is-empty) { $rec.value } else { $raw_value }
|
||
|
|
|
||
|
|
let raw_ttl = (input $" ttl [($rec.ttl)]: ")
|
||
|
|
let new_ttl = if ($raw_ttl | is-empty) { $rec.ttl } else { $raw_ttl | into int }
|
||
|
|
|
||
|
|
let cur_px = if ($rec.proxied? | default false) { "y" } else { "n" }
|
||
|
|
let raw_px = (input $" proxied [($cur_px)] y/n: ")
|
||
|
|
let new_proxied = if ($raw_px | is-empty) { $rec.proxied? | default false } else { $raw_px == "y" }
|
||
|
|
|
||
|
|
if $new_value == $rec.value and $new_ttl == $rec.ttl and $new_proxied == ($rec.proxied? | default false) {
|
||
|
|
print "No changes."
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
let updated = {
|
||
|
|
zone: $zname,
|
||
|
|
name: $rec.name,
|
||
|
|
type: $rec.type,
|
||
|
|
value: $new_value,
|
||
|
|
ttl: $new_ttl,
|
||
|
|
proxied: $new_proxied,
|
||
|
|
}
|
||
|
|
mw_dns_record_update $provider $rec.id $updated | ignore
|
||
|
|
print $" updated: ($rec.type) ($rec.name)"
|
||
|
|
}
|
||
|
|
|
||
|
|
# Reconcile static_records in dns_zones.ncl against live provider state.
|
||
|
|
# Skips records owned by external-dns (txt ownership) and docker-mailserver (label).
|
||
|
|
# With --apply: creates/updates; orphans always require manual confirmation.
|
||
|
|
export def "dns sync" [
|
||
|
|
--zone (-z): string
|
||
|
|
--workspace (-w): string
|
||
|
|
--dry-run
|
||
|
|
--apply
|
||
|
|
]: nothing -> table {
|
||
|
|
let ws = ($workspace | default ($env.PROVISIONING_WORKSPACE? | default "."))
|
||
|
|
let zones = (load_zones $ws)
|
||
|
|
let target_zones = if ($zone | is-not-empty) { [$zone] } else { $zones | columns }
|
||
|
|
|
||
|
|
$target_zones | each {|zname|
|
||
|
|
let z = ($zones | get $zname)
|
||
|
|
set_cf_token $zones $zname $ws
|
||
|
|
let provider = (resolve_provider $zones $zname)
|
||
|
|
let declared = ($z.static_records? | default [])
|
||
|
|
let live = (mw_dns_record_list $provider $zname)
|
||
|
|
|
||
|
|
let live_manual = ($live | where { |r|
|
||
|
|
($r.source? | default "manual") == "manual"
|
||
|
|
})
|
||
|
|
|
||
|
|
let declared_keys = ($declared | each {|d|
|
||
|
|
{ zone: $zname, name: $d.name, type: ($d.type | into string) }
|
||
|
|
})
|
||
|
|
|
||
|
|
let missing = ($declared | where {|d|
|
||
|
|
let key = { zone: $zname, name: $d.name, type: ($d.type | into string) }
|
||
|
|
not ($live_manual | any { |r| $r.name == $d.name and $r.type == ($d.type | into string) })
|
||
|
|
} | each {|d| { action: "create", zone: $zname, name: $d.name, type: ($d.type | into string), live_value: "", declared_value: $d.value }})
|
||
|
|
|
||
|
|
let updates = ($declared | where {|d|
|
||
|
|
$live_manual | any { |r| $r.name == $d.name and $r.type == ($d.type | into string) and $r.value != $d.value }
|
||
|
|
} | each {|d|
|
||
|
|
let live_r = ($live_manual | where { |r| $r.name == $d.name and $r.type == ($d.type | into string) } | first)
|
||
|
|
{ action: "update", zone: $zname, name: $d.name, type: ($d.type | into string), live_value: $live_r.value, declared_value: $d.value }
|
||
|
|
})
|
||
|
|
|
||
|
|
let orphans = ($live_manual | where {|r|
|
||
|
|
not ($declared | any {|d| $d.name == $r.name and ($d.type | into string) == $r.type })
|
||
|
|
} | each {|r|
|
||
|
|
{ action: "orphan", zone: $zname, name: $r.name, type: $r.type, live_value: $r.value, declared_value: "" }
|
||
|
|
})
|
||
|
|
|
||
|
|
let diff_table = ($missing ++ $updates ++ $orphans)
|
||
|
|
|
||
|
|
if $apply and not $dry_run {
|
||
|
|
for row in ($missing ++ $updates) {
|
||
|
|
let rec = ($declared | where { |d| $d.name == $row.name and ($d.type | into string) == $row.type } | first)
|
||
|
|
mw_dns_record_upsert $provider ({ zone: $zname } | merge $rec) | ignore
|
||
|
|
print $" applied ($row.action): ($row.name) ($row.type)"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
$diff_table
|
||
|
|
} | flatten
|
||
|
|
}
|
||
|
|
|
||
|
|
# Show zones with active infra component usage. Use --all to include unused zones.
|
||
|
|
export def "dns list" [
|
||
|
|
--all (-a)
|
||
|
|
--workspace (-w): string
|
||
|
|
]: nothing -> table {
|
||
|
|
let ws = ($workspace | default ($env.PROVISIONING_WORKSPACE? | default "."))
|
||
|
|
let zones = (load_zones $ws)
|
||
|
|
let zone_names = ($zones | columns)
|
||
|
|
let comp_files = (glob ($"($ws)/infra/*/components/*.ncl"))
|
||
|
|
|
||
|
|
let usage = ($comp_files | each {|f|
|
||
|
|
let component = ($f | path basename | str replace --regex '\.ncl$' '')
|
||
|
|
let lines = (open --raw $f | lines)
|
||
|
|
|
||
|
|
$zone_names | each {|zone|
|
||
|
|
let fqdns = ($lines
|
||
|
|
| where { $in | str contains $zone }
|
||
|
|
| each {|line|
|
||
|
|
$line | parse --regex '"([^"]+)"'
|
||
|
|
| get capture0
|
||
|
|
}
|
||
|
|
| flatten
|
||
|
|
| each { $in | str replace --regex '^https?://' '' }
|
||
|
|
| uniq
|
||
|
|
| where { ($in | str ends-with $zone) and not ($in | str contains "@") }
|
||
|
|
)
|
||
|
|
$fqdns | each {|fqdn| { zone: $zone, component: $component, fqdn: $fqdn }}
|
||
|
|
} | flatten
|
||
|
|
} | flatten | uniq | sort-by zone component)
|
||
|
|
|
||
|
|
if not $all { return ($usage | sort-by zone component) }
|
||
|
|
let used_zones = ($usage | get zone | uniq)
|
||
|
|
let unused = ($zone_names
|
||
|
|
| where {|z| not ($used_zones | any {|u| $u == $z }) }
|
||
|
|
| each {|z| { zone: $z, component: "-", fqdn: "-" }}
|
||
|
|
)
|
||
|
|
$usage ++ $unused | sort-by zone component
|
||
|
|
}
|
||
|
|
|
||
|
|
# Query the live DKIM TXT record for a domain. Omit domain for interactive selector.
|
||
|
|
export def "dns dkim show" [
|
||
|
|
domain?: string
|
||
|
|
--selector (-s): string = "mail"
|
||
|
|
--workspace (-w): string
|
||
|
|
]: nothing -> record {
|
||
|
|
let ws = ($workspace | default ($env.PROVISIONING_WORKSPACE? | default "."))
|
||
|
|
let zones = (load_zones $ws)
|
||
|
|
let zname = (select_zone $zones $domain)
|
||
|
|
let provider = (resolve_provider $zones $zname)
|
||
|
|
set_cf_token $zones $zname $ws
|
||
|
|
let dkim_name = $"($selector)._domainkey.($zname)"
|
||
|
|
let records = (mw_dns_record_list $provider $zname
|
||
|
|
| where { |r| $r.name == $dkim_name and $r.type == "TXT" })
|
||
|
|
if ($records | is-empty) {
|
||
|
|
error make --unspanned { msg: $"No DKIM TXT record found for ($dkim_name)" }
|
||
|
|
}
|
||
|
|
$records | first
|
||
|
|
}
|
||
|
|
|
||
|
|
def "main list" [
|
||
|
|
--all (-a)
|
||
|
|
--workspace (-w): string
|
||
|
|
]: nothing -> table {
|
||
|
|
dns list --all=$all --workspace $workspace
|
||
|
|
}
|
||
|
|
|
||
|
|
def "main l" [--workspace (-w): string]: nothing -> table {
|
||
|
|
dns list --workspace $workspace
|
||
|
|
}
|
||
|
|
|
||
|
|
def "main la" [--workspace (-w): string]: nothing -> table {
|
||
|
|
dns list --all --workspace $workspace
|
||
|
|
}
|
||
|
|
|
||
|
|
def "main s" [
|
||
|
|
zone?: string
|
||
|
|
--all (-a)
|
||
|
|
--type (-t): string
|
||
|
|
--source: string
|
||
|
|
--raw (-r)
|
||
|
|
--workspace (-w): string
|
||
|
|
]: nothing -> table {
|
||
|
|
dns show $zone --all=$all --type $type --source $source --raw=$raw --workspace $workspace
|
||
|
|
}
|
||
|
|
|
||
|
|
def "main sa" [
|
||
|
|
--type (-t): string
|
||
|
|
--source: string
|
||
|
|
--raw (-r)
|
||
|
|
--workspace (-w): string
|
||
|
|
]: nothing -> table {
|
||
|
|
dns show --all --type $type --source $source --raw=$raw --workspace $workspace
|
||
|
|
}
|
||
|
|
|
||
|
|
def "main e" [zone?: string --workspace (-w): string]: nothing -> nothing {
|
||
|
|
dns edit $zone --workspace $workspace
|
||
|
|
}
|
||
|
|
|
||
|
|
def "main a" [
|
||
|
|
name?: string
|
||
|
|
type?: string
|
||
|
|
value?: string
|
||
|
|
--ttl (-l): int = 300
|
||
|
|
--zone (-z): string
|
||
|
|
--priority: int
|
||
|
|
--workspace (-w): string
|
||
|
|
--proxied (-p)
|
||
|
|
]: nothing -> record {
|
||
|
|
dns add $name $type $value --ttl $ttl --zone $zone --priority $priority --workspace $workspace --proxied=$proxied
|
||
|
|
}
|
||
|
|
|
||
|
|
def "main d" [
|
||
|
|
name: string
|
||
|
|
type: string
|
||
|
|
--zone (-z): string
|
||
|
|
--workspace (-w): string
|
||
|
|
--confirm
|
||
|
|
]: nothing -> nothing {
|
||
|
|
dns delete $name $type --zone $zone --workspace $workspace --confirm=$confirm
|
||
|
|
}
|
||
|
|
|
||
|
|
def "main sy" [
|
||
|
|
--zone (-z): string
|
||
|
|
--workspace (-w): string
|
||
|
|
--dry-run
|
||
|
|
--apply
|
||
|
|
]: nothing -> table {
|
||
|
|
dns sync --zone $zone --workspace $workspace --dry-run=$dry_run --apply=$apply
|
||
|
|
}
|
||
|
|
|
||
|
|
def "main show" [
|
||
|
|
zone?: string
|
||
|
|
--all (-a)
|
||
|
|
--type (-t): string
|
||
|
|
--source: string
|
||
|
|
--raw (-r)
|
||
|
|
--workspace (-w): string
|
||
|
|
]: nothing -> table {
|
||
|
|
dns show $zone --all=$all --type $type --source $source --raw=$raw --workspace $workspace
|
||
|
|
}
|
||
|
|
|
||
|
|
def "main add" [
|
||
|
|
name?: string
|
||
|
|
type?: string
|
||
|
|
value?: string
|
||
|
|
--ttl (-l): int = 300
|
||
|
|
--zone (-z): string
|
||
|
|
--priority: int
|
||
|
|
--workspace (-w): string
|
||
|
|
--proxied (-p)
|
||
|
|
]: nothing -> record {
|
||
|
|
dns add $name $type $value --ttl $ttl --zone $zone --priority $priority --workspace $workspace --proxied=$proxied
|
||
|
|
}
|
||
|
|
|
||
|
|
def "main edit" [
|
||
|
|
zone?: string
|
||
|
|
--workspace (-w): string
|
||
|
|
]: nothing -> nothing {
|
||
|
|
dns edit $zone --workspace $workspace
|
||
|
|
}
|
||
|
|
|
||
|
|
def "main delete" [
|
||
|
|
name: string
|
||
|
|
type: string
|
||
|
|
--zone (-z): string
|
||
|
|
--workspace (-w): string
|
||
|
|
--confirm
|
||
|
|
]: nothing -> nothing {
|
||
|
|
dns delete $name $type --zone $zone --workspace $workspace --confirm=$confirm
|
||
|
|
}
|
||
|
|
|
||
|
|
def "main sync" [
|
||
|
|
--zone (-z): string
|
||
|
|
--workspace (-w): string
|
||
|
|
--dry-run
|
||
|
|
--apply
|
||
|
|
]: nothing -> table {
|
||
|
|
dns sync --zone $zone --workspace $workspace --dry-run=$dry_run --apply=$apply
|
||
|
|
}
|
||
|
|
|
||
|
|
def "main dkim show" [
|
||
|
|
domain?: string
|
||
|
|
--selector (-s): string = "mail"
|
||
|
|
--workspace (-w): string
|
||
|
|
]: nothing -> record {
|
||
|
|
dns dkim show $domain --selector $selector --workspace $workspace
|
||
|
|
}
|
||
|
|
|
||
|
|
# Collect dns_internal entries from all component NCL files in the workspace.
|
||
|
|
# Returns list of { host, zone, target, ttl, component } records.
|
||
|
|
def collect-dns-internal [ws: string, prov_root: string]: nothing -> list {
|
||
|
|
let comp_glob = ($ws | path join "infra" "*" "components" "*.ncl")
|
||
|
|
let split_file = ($ws | path join "infra" "libre-wuji" "components" "coredns_vpn_split.ncl")
|
||
|
|
|
||
|
|
(glob $comp_glob)
|
||
|
|
| where { $in != $split_file }
|
||
|
|
| each {|f|
|
||
|
|
let r = (do { ^nickel export --format json --import-path $prov_root $f } | complete)
|
||
|
|
if $r.exit_code != 0 { return [] }
|
||
|
|
let data = ($r.stdout | from json)
|
||
|
|
let comp = ($f | path basename | str replace --regex '\.ncl$' '')
|
||
|
|
$data | columns | each {|key|
|
||
|
|
let val = ($data | get $key)
|
||
|
|
let entries = if ($val | describe | str starts-with "record") {
|
||
|
|
$val.dns_internal? | default []
|
||
|
|
} else { [] }
|
||
|
|
$entries | each {|e| { host: $e.host, zone: $e.zone, target: $e.target, ttl: ($e.ttl? | default 300 | into int), component: $comp } }
|
||
|
|
} | flatten
|
||
|
|
} | flatten
|
||
|
|
}
|
||
|
|
|
||
|
|
# Generate the Nickel content for coredns_vpn_split.ncl from grouped zone data.
|
||
|
|
# zones_map: record where keys are zone names, values are lists of { host, target, ttl }.
|
||
|
|
def gen-split-ncl [zones_map: record]: nothing -> string {
|
||
|
|
let entries = ($zones_map | transpose zone records | each {|row|
|
||
|
|
let recs = ($row.records | sort-by host | each {|r|
|
||
|
|
$" \{ name = \"($r.host)\", value = \"($r.target)\", ttl = ($r.ttl), rectype = \"A\", etcd_dns_ttl = 300, etcd_peer_port = 2380, etcd_cli_port = 2379 },"
|
||
|
|
} | str join "\n")
|
||
|
|
$" \{
|
||
|
|
kind = 'private_zone,
|
||
|
|
domain = \"($row.zone)\",
|
||
|
|
port = 53,
|
||
|
|
bind = bind_ip,
|
||
|
|
records = [
|
||
|
|
($recs)
|
||
|
|
],
|
||
|
|
forward = \{ source = \".\", forward_ip = null },
|
||
|
|
use_log = true, use_errors = true, use_cache = false,
|
||
|
|
etcd_cluster_name = \"\",
|
||
|
|
},"
|
||
|
|
} | str join "\n")
|
||
|
|
$"# Generated by `prvng dns build-private` — do not edit manually.
|
||
|
|
# Source of truth: dns_internal fields in infra/*/components/*.ncl
|
||
|
|
# Re-run `prvng dns build-private --apply` to regenerate from current declarations.
|
||
|
|
|
||
|
|
fun bind_ip =>
|
||
|
|
\{
|
||
|
|
entries = [
|
||
|
|
($entries)
|
||
|
|
],
|
||
|
|
}"
|
||
|
|
}
|
||
|
|
|
||
|
|
# List all private DNS entries declared via dns_internal across all workspace components.
|
||
|
|
# Reads every component NCL file and aggregates dns_internal entries into a table.
|
||
|
|
export def "dns priv-list" [
|
||
|
|
--workspace (-w): string
|
||
|
|
--zone (-z): string
|
||
|
|
--raw (-r)
|
||
|
|
]: nothing -> table {
|
||
|
|
let ws = ($workspace | default ($env.PROVISIONING_WORKSPACE? | default "."))
|
||
|
|
let prov_root = ($env.PROVISIONING? | default "/usr/local/provisioning")
|
||
|
|
|
||
|
|
let entries = (collect-dns-internal $ws $prov_root)
|
||
|
|
if ($entries | is-empty) {
|
||
|
|
print "No dns_internal entries found."
|
||
|
|
return []
|
||
|
|
}
|
||
|
|
|
||
|
|
let filtered = if ($zone | is-not-empty) {
|
||
|
|
$entries | where zone == $zone
|
||
|
|
} else { $entries }
|
||
|
|
|
||
|
|
if $raw { return $filtered }
|
||
|
|
|
||
|
|
$filtered
|
||
|
|
| sort-by zone host
|
||
|
|
| each {|r|
|
||
|
|
{
|
||
|
|
zone: $r.zone
|
||
|
|
host: $r.host
|
||
|
|
target: $r.target
|
||
|
|
ttl: $r.ttl
|
||
|
|
component: $r.component
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
# Reconcile dns_internal declarations across all components with coredns_vpn_split.ncl.
|
||
|
|
# Reads every component NCL file, collects dns_internal entries, groups by zone,
|
||
|
|
# and generates the canonical split file. With --apply: writes the file to disk.
|
||
|
|
# After --apply: run `just install-component coredns_vpn libre-wuji-cp-0` to deploy.
|
||
|
|
export def "dns build-private" [
|
||
|
|
--workspace (-w): string
|
||
|
|
--dry-run
|
||
|
|
--apply
|
||
|
|
]: nothing -> table {
|
||
|
|
let ws = ($workspace | default ($env.PROVISIONING_WORKSPACE? | default "."))
|
||
|
|
let prov_root = ($env.PROVISIONING? | default "/usr/local/provisioning")
|
||
|
|
let split_out = ($ws | path join "infra" "libre-wuji" "components" "coredns_vpn_split.ncl" | path expand)
|
||
|
|
|
||
|
|
let all_entries = (collect-dns-internal $ws $prov_root)
|
||
|
|
if ($all_entries | is-empty) {
|
||
|
|
print "No dns_internal entries found in any component."
|
||
|
|
return []
|
||
|
|
}
|
||
|
|
|
||
|
|
let zones_map = ($all_entries | group-by zone | transpose zone records
|
||
|
|
| each {|row|
|
||
|
|
let recs = ($row.records | each {|e| { host: $e.host, target: $e.target, ttl: $e.ttl } })
|
||
|
|
{ $row.zone: $recs }
|
||
|
|
} | reduce --fold {} {|it, acc| $acc | merge $it })
|
||
|
|
|
||
|
|
let new_content = (gen-split-ncl $zones_map)
|
||
|
|
|
||
|
|
# Read existing split file by exporting the function applied to "10.0.8.20".
|
||
|
|
# We write a wrapper NCL expression to a temp file and nickel-export it.
|
||
|
|
let existing_entries = if ($split_out | path exists) {
|
||
|
|
let tmpfile = ($nu.temp-dir | path join "dns_build_private_eval.ncl")
|
||
|
|
let import_expr = $"(char lparen)import \"($split_out)\"(char rparen) \"10.0.8.20\""
|
||
|
|
$import_expr | save --force $tmpfile
|
||
|
|
let r = (do { ^nickel export --format json $tmpfile } | complete)
|
||
|
|
if ($tmpfile | path exists) { rm $tmpfile }
|
||
|
|
if $r.exit_code == 0 {
|
||
|
|
($r.stdout | from json).entries? | default []
|
||
|
|
| each {|e|
|
||
|
|
let zone = ($e.domain? | default "")
|
||
|
|
$e.records? | default [] | each {|rec|
|
||
|
|
{ host: ($rec.name? | default ""), zone: $zone, target: ($rec.value? | default "") }
|
||
|
|
}
|
||
|
|
} | flatten
|
||
|
|
} else { [] }
|
||
|
|
} else { [] }
|
||
|
|
|
||
|
|
let desired_flat = ($all_entries | each {|e| { host: $e.host, zone: $e.zone, target: $e.target }})
|
||
|
|
|
||
|
|
let to_add = ($desired_flat | where {|d|
|
||
|
|
not ($existing_entries | any {|ex| $ex.host == $d.host and $ex.zone == $d.zone and $ex.target == $d.target })
|
||
|
|
} | each {|r| { action: "add", zone: $r.zone, host: $r.host, target: $r.target }})
|
||
|
|
|
||
|
|
let to_remove = ($existing_entries | where {|ex|
|
||
|
|
not ($desired_flat | any {|d| $d.host == $ex.host and $d.zone == $ex.zone })
|
||
|
|
} | each {|r| { action: "remove", zone: $r.zone, host: $r.host, target: $r.target }})
|
||
|
|
|
||
|
|
let diff = ($to_add ++ $to_remove | sort-by zone host)
|
||
|
|
|
||
|
|
if ($diff | is-empty) {
|
||
|
|
print $"(ansi green)✓(ansi reset) coredns_vpn_split.ncl is up to date — ($desired_flat | length) records across ($zones_map | columns | length) zones."
|
||
|
|
return $diff
|
||
|
|
}
|
||
|
|
|
||
|
|
if not $dry_run and $apply {
|
||
|
|
$new_content | save --force $split_out
|
||
|
|
print $"(ansi green)✓(ansi reset) Written ($split_out)"
|
||
|
|
print $" Run: just install-component coredns_vpn libre-wuji-cp-0"
|
||
|
|
} else {
|
||
|
|
let n_changes = ($diff | length)
|
||
|
|
print $"Diff ($n_changes) changes - pass --apply to write ($split_out | path basename):"
|
||
|
|
}
|
||
|
|
|
||
|
|
$diff
|
||
|
|
}
|
||
|
|
|
||
|
|
def print_help []: nothing -> nothing {
|
||
|
|
let title = "DNS Record Management"
|
||
|
|
let bar = (1..($title | str length) | each {|_| "=" } | str join "")
|
||
|
|
print $title
|
||
|
|
print $bar
|
||
|
|
print ""
|
||
|
|
print " Usage: prvng dns <subcommand> [options]"
|
||
|
|
print ""
|
||
|
|
print $" (ansi green)Read:(ansi reset)"
|
||
|
|
print $" (ansi cyan)list \(l\)(ansi reset) [--all/-a] zones with active component usage"
|
||
|
|
print $" (ansi cyan)la(ansi reset) alias: list --all"
|
||
|
|
print $" (ansi cyan)show \(s\)(ansi reset) [zone] [--all] [--type T] live records; --all forces zone selector"
|
||
|
|
print $" (ansi cyan)dkim show(ansi reset) [domain] [--selector S] live DKIM TXT record"
|
||
|
|
print ""
|
||
|
|
print $" (ansi green)Write:(ansi reset)"
|
||
|
|
print $" (ansi cyan)add \(a\)(ansi reset) [name type value] create record; prompts if args omitted"
|
||
|
|
print $" (ansi cyan)edit \(e\)(ansi reset) [zone] interactive editor and delete"
|
||
|
|
print $" (ansi cyan)delete \(d\)(ansi reset) <name> <type> --confirm remove record [--zone Z]"
|
||
|
|
print $" (ansi cyan)sync \(sy\)(ansi reset) [--zone Z] [--apply] reconcile static_records vs live"
|
||
|
|
print ""
|
||
|
|
print $" (ansi green)Flags for show:(ansi reset)"
|
||
|
|
print " --type / -t T filter by type (A, AAAA, CNAME, MX, TXT...)"
|
||
|
|
print " --source S filter by source"
|
||
|
|
print " --raw / -r return raw table, skip formatting"
|
||
|
|
print " --workspace / -w P override workspace path"
|
||
|
|
print ""
|
||
|
|
print $" (ansi green)Private DNS:(ansi reset)"
|
||
|
|
print $" (ansi cyan)priv-list (char lparen)pl(char rparen)(ansi reset) [--zone Z] [--raw] list all dns_internal records from components"
|
||
|
|
print $" (ansi cyan)build-private (char lparen)bp(char rparen)(ansi reset) [--apply] [--dry-run] sync coredns_vpn_split.ncl from dns_internal declarations"
|
||
|
|
print ""
|
||
|
|
print $" (ansi green)Aliases:(ansi reset)"
|
||
|
|
print " l -> list la -> list --all s -> show sa -> show --all"
|
||
|
|
print " e -> edit a -> add d -> delete sy -> sync"
|
||
|
|
print " pl -> priv-list bp -> build-private"
|
||
|
|
}
|
||
|
|
|
||
|
|
def "main help" []: nothing -> nothing { print_help }
|
||
|
|
|
||
|
|
def "main build-private" [--workspace (-w): string --dry-run --apply]: nothing -> table {
|
||
|
|
dns build-private --workspace $workspace --dry-run=$dry_run --apply=$apply
|
||
|
|
}
|
||
|
|
|
||
|
|
def "main bp" [--workspace (-w): string --dry-run --apply]: nothing -> table {
|
||
|
|
dns build-private --workspace $workspace --dry-run=$dry_run --apply=$apply
|
||
|
|
}
|
||
|
|
|
||
|
|
def "main priv-list" [--workspace (-w): string --zone (-z): string --raw (-r)]: nothing -> table {
|
||
|
|
dns priv-list --workspace $workspace --zone $zone --raw=$raw
|
||
|
|
}
|
||
|
|
|
||
|
|
def "main pl" [--workspace (-w): string --zone (-z): string --raw (-r)]: nothing -> table {
|
||
|
|
dns priv-list --workspace $workspace --zone $zone --raw=$raw
|
||
|
|
}
|
||
|
|
|
||
|
|
def main [...args: string]: nothing -> nothing {
|
||
|
|
let ws = ($env.PROVISIONING_WORKSPACE? | default ".")
|
||
|
|
let first = ($args | get -o 0 | default "")
|
||
|
|
|
||
|
|
let dns_cap = (load_capabilities $ws)
|
||
|
|
let aliases = ($dns_cap.provider_aliases? | default {})
|
||
|
|
|
||
|
|
if ($first | is-not-empty) and ($first in ($aliases | columns)) {
|
||
|
|
let provider = ($aliases | get $first | into string)
|
||
|
|
let rest = ($args | skip 1)
|
||
|
|
let sub = ($rest | get -o 0 | default "")
|
||
|
|
let sub_rest = ($rest | skip 1)
|
||
|
|
with-env { DNS_PROVIDER_OVERRIDE: $provider } {
|
||
|
|
match $sub {
|
||
|
|
"show" | "s" => {
|
||
|
|
let z = ($sub_rest | get -o 0)
|
||
|
|
if ($z | is-not-empty) {
|
||
|
|
dns show $z --workspace $ws
|
||
|
|
} else {
|
||
|
|
dns show --workspace $ws
|
||
|
|
}
|
||
|
|
}
|
||
|
|
"add" | "a" => {
|
||
|
|
dns add ($sub_rest | get -o 0) ($sub_rest | get -o 1) ($sub_rest | get -o 2) --workspace $ws
|
||
|
|
}
|
||
|
|
"edit" | "e" => {
|
||
|
|
let z = ($sub_rest | get -o 0)
|
||
|
|
if ($z | is-not-empty) {
|
||
|
|
dns edit $z --workspace $ws
|
||
|
|
} else {
|
||
|
|
dns edit --workspace $ws
|
||
|
|
}
|
||
|
|
}
|
||
|
|
"delete" | "d" => {
|
||
|
|
if ($sub_rest | length) < 2 {
|
||
|
|
error make --unspanned { msg: $"delete requires <name> <type> --confirm" }
|
||
|
|
}
|
||
|
|
dns delete ($sub_rest | get 0) ($sub_rest | get 1) --workspace $ws --confirm
|
||
|
|
}
|
||
|
|
"list" | "l" => { dns list --workspace $ws }
|
||
|
|
"sync" | "sy" => { dns sync --workspace $ws }
|
||
|
|
"dkim show" => {
|
||
|
|
let z = ($sub_rest | get -o 0)
|
||
|
|
if ($z | is-not-empty) {
|
||
|
|
dns dkim show $z --workspace $ws
|
||
|
|
} else {
|
||
|
|
dns dkim show --workspace $ws
|
||
|
|
}
|
||
|
|
}
|
||
|
|
_ => {
|
||
|
|
print $"Unknown subcommand: ($sub)"
|
||
|
|
print ""
|
||
|
|
print_help
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
match $first {
|
||
|
|
"" | "help" | "h" | "-h" | "--help" => { print_help }
|
||
|
|
_ => {
|
||
|
|
print $"Unknown subcommand: ($first)"
|
||
|
|
print ""
|
||
|
|
print_help
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|