412 lines
20 KiB
Text
412 lines
20 KiB
Text
|
|
# 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
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|