provisioning-core/nulib/main_provisioning/net.nu

412 lines
20 KiB
Text
Raw Normal View History

# Network exposure map — composes the per-service exposure surface declared
# across component NCL files into a single table, and (with --live) cross-checks
# it against the running cluster. Read-only: never mutates workspace or cluster.
def prov-root []: nothing -> string {
$env.PROVISIONING? | default ($env.PWD | path dirname)
}
# Render a table/record as the requested structured format, or pass it through
# unchanged (default table rendering) when out is empty. `to toml` only accepts a
# record, so a list is wrapped as { rows: [...] }.
def format-out [out: string]: any -> any {
let data = $in
match ($out | str downcase) {
"" => $data
"json" => ($data | to json)
"yaml" | "yml" => ($data | to yaml)
"toml" => (if (($data | describe) | str starts-with "table") { { rows: $data } | to toml } else { $data | to toml })
_ => $data
}
}
# The workspace's own Cilium Gateway VIPs (public/private), read from its
# settings `gateways = { public, private }` block — NOT hardcoded, so the
# wg-risk check works per workspace (wuji 10.200.3.x, daoshi 78.47.x/10.0.8.x).
# A gateway VIP is what a hostname must NOT resolve to over VPN if it sits on a
# Cilium Gateway VLAN unreachable from WireGuard; each workspace decides whether
# its own gateway is WG-routable (daoshi's flat wuwei is, wuji's TPROXY VLAN isn't).
def gateway-vips [ws: string, prov_root: string]: nothing -> record {
let settings = (glob ($ws | path join "infra" "*" "settings.ncl") | get 0? | default "")
if ($settings | is-empty) or (not ($settings | path exists)) { return { public: "", private: "" } }
# nushell `parse --regex` is line-oriented, so scan the gateways = { ... } block
# by lines rather than a multiline match.
let lines = (open --raw $settings | lines)
let gw_idx = ($lines | enumerate | where {|it| $it.item =~ 'gateways\s*=\s*\{' } | get index.0? | default (-1))
let block = (if $gw_idx >= 0 { $lines | skip ($gw_idx + 1) | take until {|l| $l =~ '\}' } } else { [] })
let pub = ($block | where {|l| $l =~ 'public' } | get 0? | default "" | parse --regex '"([0-9.]+)"' | get capture0? | default [] | get 0? | default "")
let prv = ($block | where {|l| $l =~ 'private' } | get 0? | default "" | parse --regex '"([0-9.]+)"' | get capture0? | default [] | get 0? | default "")
{ public: $pub, private: $prv }
}
# Cascade classifier (priority-ordered): map one exported component record to its
# exposure family. Returns { kind, tls, entry_hint, source } — the taxonomy the
# ServiceExposure schema (Task 2) will formalise.
def classify [rec: record, gw: record]: nothing -> record {
let cols = ($rec | columns)
let tls = (detect-tls $rec)
# 1. Shared-FIP public L7 (14 apps)
if ("gateway_fip" in $cols) {
return { kind: "gateway_l7_public", tls: $tls, entry_hint: (($rec.gateway_fip? | default "?") + " → " + $gw.public), source: "gateway_fip" }
}
# 2. Flat zot-style public L7
if ("gateway_enabled" in $cols) {
return { kind: "gateway_l7_public", tls: $tls, entry_hint: ($rec.gateway_ip? | default $gw.public), source: "gateway_enabled" }
}
# 3. private_gateway_* private L7 (longhorn + ontoref panels + cilium)
if ("private_gateway_name" in $cols) {
return { kind: "gateway_l7_private", tls: $tls, entry_hint: $gw.private, source: "private_gateway_*" }
}
# 4. Nested gateway / tcp_gateway block (radicle)
if ("gateway" in $cols) or ("tcp_gateway" in $cols) {
let gip = ($rec.gateway?.gateway_ip? | default $gw.private)
return { kind: "gateway_l7_nested", tls: $tls, entry_hint: $gip, source: "gateway{}" }
}
# 5. L4 host-router (private_ingress: Sozu)
if ("routes" in $cols) {
return { kind: "l4_host_router", tls: $tls, entry_hint: ($rec.private_lb_ip? | default "?"), source: "routes[]" }
}
# 6. L4 per-service LoadBalancer
if ("private_lb_ip" in $cols) {
return { kind: "l4_lb", tls: $tls, entry_hint: $rec.private_lb_ip, source: "private_lb_ip" }
}
if ("lb_ipam_ip" in $cols) {
return { kind: "l4_lb_public", tls: $tls, entry_hint: $rec.lb_ipam_ip, source: "lb_ipam_ip" }
}
# 7. Dedicated FIP / hostNetwork
if ("fip_name" in $cols) or ("external_addr" in $cols) or ("fips" in $cols) {
let hint = ($rec.external_addr? | default ($rec.fip_name? | default "fip-controller"))
return { kind: "fip_hostnet", tls: $tls, entry_hint: $hint, source: "fip_name/external_addr" }
}
# 8. Cluster-internal only (flat wuwei)
{ kind: "cluster", tls: $tls, entry_hint: "-", source: "requires.ports" }
}
# Which of the ~13 TLS spellings this component uses. First match wins.
def detect-tls [rec: record]: nothing -> string {
let cols = ($rec | columns)
if ("gateway_mode" in $cols) { return $"gateway:($rec.gateway_mode)" }
if ("gateway_cert_secret" in $cols) { return "gateway_cert_secret" }
if ("ui_ingress_tls_secret" in $cols) { return "ui_ingress_tls_secret" }
if ("tls_rotation" in $cols) { return $"reloader:($rec.tls_rotation)" }
if ("cert" in $cols) { return "cert{}" }
if ("tls_secret" in $cols) { return "tls_secret" }
if ("cluster_issuer" in $cols) { return "cluster_issuer" }
"-"
}
# Compose the normalized exposure rows from every component in the workspace.
# Row spine is dns_internal (what actually resolves over VPN) joined with the
# classified family; components without dns_internal emit one summary row.
# One exported component record → normalized exposure rows (one per dns_internal
# entry, or a single summary row when it has none).
def rec-to-rows [key: string, rec: record, gw: record]: nothing -> table {
let cls = (classify $rec $gw)
let gw_vips = ([$gw.public, $gw.private] | where {|x| ($x | is-not-empty) })
let posture = ($rec.context?.security?.posture? | default "-")
let ports = ($rec.requires?.ports? | default [] | each {|p| $p.port? | default null } | compact | str join ",")
let dns = ($rec.dns_internal? | default [])
if ($dns | is-empty) {
[{
service: $key, ns: ($rec.namespace? | default "-"), hostname: "-",
kind: $cls.kind, target: "-", posture: $posture, ports: $ports,
tls: $cls.tls, wg_risk: false, source: $cls.source,
}]
} else {
$dns | each {|e|
let tgt = ($e.target? | default "-")
{
service: $key, ns: ($rec.namespace? | default "-"),
hostname: ($e.host? | default "-"),
kind: $cls.kind, target: $tgt, posture: $posture, ports: $ports,
tls: $cls.tls, wg_risk: ($tgt in $gw_vips), source: $cls.source,
}
}
}
}
export def "collect-exposures" [ws: string, prov_root: string]: nothing -> table {
let comp_glob = ($ws | path join "infra" "*" "components" "*.ncl")
let gw = (gateway-vips $ws $prov_root)
# Exclude the generated split-DNS file itself (its basename varies per workspace:
# coredns_vpn_split.ncl in wuji, coredns_vpn.ncl in daoshi) — it is output, not a
# component to classify.
let files = (glob $comp_glob | where {|f| ($f | path basename) !~ 'coredns.*vpn' })
# One nickel invocation for ALL components: a wrapper record that imports each
# file, exported in a single call. Pays the interpreter + import-path startup
# ONCE instead of once per file (the dominant cost on a cold ncl cache).
let tmp = ($nu.temp-dir | path join $"net-collect-(($files | length)).ncl")
let body = ($files | enumerate | each {|it| $' "c($it.index)" = import "($it.item)",' } | str join "\n")
$"{\n($body)\n}" | save -f $tmp
let r = (do { ^nickel export --format json --import-path $prov_root $tmp } | complete)
if ($tmp | path exists) { rm -f $tmp }
# Each bundle value is one file's export record { <component>: {...} }. On a
# bundle failure (one bad component), fall back to per-file so a single broken
# component doesn't blank the whole map.
let bundles = (
if $r.exit_code == 0 {
$r.stdout | from json | values
} else {
$files | each {|f|
let rr = (do { ^nickel export --format json --import-path $prov_root $f } | complete)
if $rr.exit_code == 0 { $rr.stdout | from json } else { null }
} | compact
}
)
$bundles | each {|file_rec|
$file_rec | columns | each {|key|
let rec = ($file_rec | get $key)
if (($rec | describe) !~ "^record") { [] } else { rec-to-rows $key $rec $gw }
} | flatten
} | flatten
}
# Show the full exposure table. --wg-risk filters to the reg-class rows.
# --live cross-checks DNS targets against the running cluster.
export def "net map" [
--workspace (-w): string
--service (-s): string
--wg-risk
--live
--kubectl: string = "" # override invocation, e.g. "ssh libre-wuji-cp-0 k0s kubectl"
--out (-o): string = "" # output format: json | yaml | toml (default: table)
]: nothing -> any {
let ws = (resolve-ws $workspace)
let prov = (prov-root)
mut rows = (collect-exposures $ws $prov)
if ($service | is-not-empty) { $rows = ($rows | where service == $service) }
if $wg_risk { $rows = ($rows | where wg_risk == true) }
if $live {
let kx = (resolve-kubectl $ws $kubectl)
let live_svc = (live-service-vips $kx)
$rows = ($rows | each {|row|
let match = ($live_svc | where clusterip == $row.target or externalip == $row.target)
let drift = (if ($row.target == "-") { "-" } else if ($match | is-empty) { "MISSING" } else { "ok" })
$row | insert live $drift
})
}
$rows | sort-by wg_risk kind service --reverse | format-out $out
}
# Static coherence checks — a preview of the Task 3 invariants without touching
# nickel. Returns only the violating rows so an empty table means clean.
export def "net validate" [
--workspace (-w): string
--out (-o): string = "" # output format: json | yaml | toml (default: table)
]: nothing -> any {
let ws = (resolve-ws $workspace)
let prov = (prov-root)
let rows = (collect-exposures $ws $prov)
let wg_bugs = ($rows | where wg_risk == true | each {|r|
{ check: "vpn-target-is-gateway-vip", service: $r.service, detail: $"($r.hostname) → ($r.target) (($r.kind))" }
})
# posture=public but the resolving target is a private/gateway path
let posture_bugs = ($rows
| where posture == "public" and wg_risk == true
| each {|r| { check: "public-posture-unreachable-vpn", service: $r.service, detail: $"($r.hostname) → ($r.target)" } })
# duplicate DNS A records (same hostname, >1 distinct target)
let dup = ($rows | where hostname != "-" | group-by hostname | items {|host, g|
let targets = ($g | get target | uniq)
if (($targets | length) > 1) { { check: "duplicate-dns-target", service: $host, detail: ($targets | str join " vs ") } } else { null }
} | compact)
[$wg_bugs $posture_bugs $dup] | flatten | format-out $out
}
# Resolve how to invoke kubectl WITHOUT requiring KUBECONFIG in the env, as a
# token list (kx-parts convention shared with scripts/detect-longhorn-readonly.nu).
# Precedence: explicit override → $env.KUBECTL → $env.KUBECONFIG (plain kubectl) →
# the workspace's own admin.conf → SSH to the control-plane node (last resort).
# For a k0s cluster pass --kubectl "ssh <node> k0s kubectl".
def resolve-kubectl [ws: string, override: string]: nothing -> list<string> {
let raw = (
if ($override | is-not-empty) { $override }
else if ($env.KUBECTL? | default "" | is-not-empty) { $env.KUBECTL }
else if ($env.KUBECONFIG? | default "" | is-not-empty) { "kubectl" }
else {
let kc = (glob ($ws | path join "infra" "*" ".kube" "admin.conf") | get 0? | default "")
if ($kc | is-not-empty) { $"kubectl --kubeconfig ($kc)" } else { "ssh libre-wuji-cp-0 kubectl" }
}
)
$raw | split row ' ' | where {|p| ($p | str trim) | is-not-empty }
}
# TCP reachability probe to <ip>:<port>. Runs from the operator's host (local nc)
# when kubectl resolves locally — that IS the VPN client's viewpoint, so a Cilium
# Gateway VIP that WireGuard can't reach fails here — or via the ssh node when
# kubectl is ssh-based. Empty probe_host = local. Returns true if the port accepts.
def probe-tcp [probe_host: string, ip: string, port: int]: nothing -> bool {
if ($ip == "-" or ($ip | is-empty)) { return false }
let r = if ($probe_host | is-empty) {
# BSD/macOS nc ignores -w for the connect phase (a routed-but-dead port
# hangs ~54s) — it needs -G for the connect timeout. Linux nc uses -w.
let flags = (if ($nu.os-info.name == "macos") { ["-z", "-G", "2", "-w", "2"] } else { ["-z", "-w", "2"] })
do { ^nc ...$flags $ip ($port | into string) } | complete
} else {
do { ^ssh -o ConnectTimeout=5 -o BatchMode=yes $probe_host $"nc -z -w 2 ($ip) ($port)" } | complete
}
$r.exit_code == 0
}
# Verify each service operates as its exposure declares. Per resolvable hostname:
# dns_unique — exactly one split-DNS target (a duplicate A-record fails)
# ports — the declared container ports (info; the probe tests the 443 listen)
# reachable — (with --live) a TCP connect to target:443 succeeds. Probed from the
# operator host (VPN viewpoint) so a WG-unreachable Gateway VIP fails,
# and an external-but-reachable target (daoreg/daotermas) passes —
# unlike a cluster-svc-IP membership test.
# verdict PASS only when every applicable check holds. Non-empty FAIL rows = drift.
export def "net verify" [
--workspace (-w): string
--live
--kubectl: string = ""
--port: int = 443 # TCP port to probe (the VPN https listen)
--out (-o): string = "" # output format: json | yaml | toml (default: table)
]: nothing -> any {
let ws = (resolve-ws $workspace)
let prov = (prov-root)
let rows = (collect-exposures $ws $prov | where hostname != "-")
let ntargets = ($rows | group-by hostname | items {|h, g| { hostname: $h, n: ($g | get target | uniq | length) } })
let probe_host = (
if $live {
let kx = (resolve-kubectl $ws $kubectl)
if (($kx | get 0? | default "") == "ssh") { $kx | get 1 } else { "" }
} else { "" }
)
# Which port a target actually listens on depends on its datapath: a Cilium
# Gateway / FIP / Sozu host-router terminates https on 443; a per-service L4 LB
# (grafana .4:3000, mail .1:587) listens on the workload's own ports. Probe the
# right one per kind — probing 443 on grafana or 3000 on the FIP is a false miss.
# All (target,port) pairs run through ONE flat par-each (max parallelism), then
# aggregate: target reachable = any of its ports accepts.
let reach = (
if $live {
let pairs = (
$rows | each {|r|
let ps = (
if ($r.kind in ["l4_lb", "l4_lb_public"]) {
$r.ports | split row "," | each {|x| $x | str trim } | where {|x| $x != "-" and $x != "" } | each {|x| $x | into int }
} else { [$port] }
)
$ps | each {|p| { target: $r.target, port: $p } }
}
| flatten | uniq
)
$pairs
| par-each {|pp| { target: $pp.target, ok: (probe-tcp $probe_host $pp.target $pp.port) } }
| group-by target
| items {|t, g| { ip: $t, ok: ($g | any {|x| $x.ok }) } }
} else { [] }
)
$rows | each {|r|
let dns_unique = (($ntargets | where hostname == $r.hostname | get n.0) == 1)
let is_private_path = (($r.kind | str starts-with "l4") or ($r.posture in ["private", "internal"]))
let reachable = (if $live { ($reach | where ip == $r.target | get ok.0? | default false) } else { null })
let verdict = (
if (not $dns_unique) { "FAIL:dup-dns" }
else if $live { (
if (not $reachable) { "FAIL:unreachable" }
else if $r.wg_risk { "WARN:reachable-gateway-vip" }
else { "PASS" }
) }
else if ($r.wg_risk and $is_private_path) { "FAIL:vpn-unreachable" }
else if ($r.wg_risk) { "WARN:public-via-gateway" }
else { "PASS" }
)
# listen = the client-facing port. Cilium Gateway / FIP / Sozu host-router
# terminate https on 443; only a per-service L4 LB listens on the workload's
# own ports. target_ports = the backend/container ports (behind the .443 hop).
let listen = (if ($r.kind in ["l4_lb", "l4_lb_public"]) { ($r.ports | default ($port | into string)) } else { ($port | into string) })
let row = {
service: $r.service, hostname: $r.hostname, posture: $r.posture,
kind: $r.kind, target: $r.target, listen: $listen, target_ports: ($r.ports | default "-"),
}
# `reachable` only exists when --live actually probed; hide the column otherwise.
(if $live { $row | insert reachable $reachable } else { $row }) | insert verdict $verdict
} | sort-by verdict hostname | format-out $out
}
# kubectl get svc across namespaces → clusterIP/externalIP table. Empty on failure.
def live-service-vips [kx: list<string>]: nothing -> table {
let r = (do { ^($kx | first) ...($kx | skip 1) get svc -A -o json } | complete)
if $r.exit_code != 0 {
print -e $"(ansi yellow)net map --live: kubectl unreachable via `($kx | str join ' ')` — showing declared data only.(ansi reset)"
print -e $" ($r.stderr | lines | get 0? | default '')"
return []
}
$r.stdout | from json | get items | each {|s|
{
ns: $s.metadata.namespace, name: $s.metadata.name,
clusterip: ($s.spec.clusterIP? | default "-"),
externalip: ($s.status?.loadBalancer?.ingress? | default [] | get ip? | default [] | get 0? | default "-"),
}
}
}
def resolve-ws [ws?: string]: nothing -> string {
if ($ws | is-not-empty) {
if ($ws | path exists) { $ws } else { ($env.PWD) }
} else { $env.PWD }
}
def print_help []: nothing -> nothing {
print "prvng net — declarative network-exposure map"
print ""
print "USAGE:"
print " prvng net map [-w <ws>] [-s <service>] [--wg-risk] [--live]"
print " prvng net validate [-w <ws>]"
print ""
print " map Compose the per-service exposure table from component NCL."
print " --wg-risk only rows whose VPN DNS target is a Gateway VIP"
print " (unreachable from WireGuard — the reg-class bug)."
print " --live cross-check DNS targets against kubectl get svc."
print " No KUBECONFIG needed: auto-uses infra/*/.kube/admin.conf,"
print " falls back to `ssh libre-wuji-cp-0 kubectl`."
print " --kubectl override, e.g. \"ssh libre-wuji-cp-0 k0s kubectl\" for k0s."
print " validate Static coherence checks (preview of the nickel invariants):"
print " vpn-target-is-gateway-vip, public-posture-unreachable-vpn,"
print " duplicate-dns-target. Empty output = clean."
print " verify Per-hostname operability vs declared exposure: dns_unique,"
print " WG-reachability by surface, and (--live) a TCP probe to the"
print " client listen port. verdict PASS/WARN/FAIL. --live/--kubectl as in map."
print ""
print " -o, --out json|yaml|toml structured output for any subcommand (default: table)."
}
def "main map" [
--workspace (-w): string
--service (-s): string
--wg-risk
--live
--kubectl: string = ""
--out (-o): string = ""
]: nothing -> any {
net map --workspace $workspace --service $service --wg-risk=$wg_risk --live=$live --kubectl $kubectl --out $out
}
def "main validate" [ --workspace (-w): string, --out (-o): string = "" ]: nothing -> any {
net validate --workspace $workspace --out $out
}
def "main verify" [ --workspace (-w): string, --live, --kubectl: string = "", --port: int = 443, --out (-o): string = "" ]: nothing -> any {
net verify --workspace $workspace --live=$live --kubectl $kubectl --port $port --out $out
}
def main [...args: string]: nothing -> nothing {
let first = ($args | get -o 0 | default "")
match $first {
"" | "help" | "h" | "-h" | "--help" => { print_help }
_ => {
print $"Unknown subcommand: ($first)"
print ""
print_help
}
}
}