# 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 { : {...} }. 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 k0s kubectl". def resolve-kubectl [ws: string, override: string]: nothing -> list { 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 :. 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]: 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 ] [-s ] [--wg-risk] [--live]" print " prvng net validate [-w ]" 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 } } }