# Service Exposure — typed declarative surface for how a component is reached. # Sibling of ServiceConcerns (concerns.ncl): where concerns answer "what does # this service do about tls/dns/backup", exposure answers "from where is it # reachable, over which datapath, on which ports, terminating TLS where". # # It unifies the six exposure idioms that today live in parallel scalar fields # (gateway_fip · nested gateway{} · gateway_enabled/gateway_ip · private_lb_ip · # routes[] · fip_name/external_addr) into one closed record so a misspelled key # blames at `nickel export` instead of silently deriving. # # Records are CLOSED (no `..`): extra/misspelled fields are a contract error. # The Reachability variants are the exact kinds `prvng net map` classifies, so # the runtime view and the static schema share one taxonomy. # # `Exposure` carries invariants (from_validator) that fail at `nickel export`. # Each one encodes a real failure this session produced. let _gateway_l7_kinds = [ 'gateway_l7_public, 'gateway_l7_private, 'gateway_l7_nested ] in let _public_kinds = [ 'gateway_l7_public, 'l4_lb_public, 'fip_hostnet ] in let _member = fun x xs => std.array.any (fun e => e == x) xs in # Invariants forced when an Exposure is evaluated. Runs after the record shape # (defaults filled), so `ports`/`strict_chain_clients` are always present. let _valid_exposure = std.contract.from_validator (fun exp => let k = exp.binding.kind in # 1. reg-class bug: a VPN/private hostname must not ride a Cilium Gateway VIP — # the eBPF TPROXY redirect never fires for WireGuard host-netns traffic. if (exp.surface == 'vpn || exp.surface == 'private) && (_member k _gateway_l7_kinds) then 'Error { message = "vpn/private surface cannot use a gateway_l7 binding: Cilium Gateway VIPs are unreachable from the WireGuard datapath (the reg bug). Use 'l4_host_router or 'l4_lb." } # 2. a public hostname must land on a public path. else if exp.surface == 'public && !(_member k _public_kinds) then 'Error { message = "public surface must bind via 'gateway_l7_public, 'l4_lb_public or 'fip_hostnet." } # 2b. a shared-FIP gateway binding must name the FIP it rides. else if k == 'gateway_l7_public && !(std.record.has_field "fip_name" exp.binding) then 'Error { message = "'gateway_l7_public binding must declare binding.fip_name." } # 3. Sozu/lian-build failure: strict-chain clients must terminate on a hop we # control the chain on ('pod_terminate), never at the shared Gateway. else if exp.strict_chain_clients && !(std.array.all (fun p => p.tls != 'gateway_terminate) exp.ports) then 'Error { message = "strict_chain_clients forbids tls='gateway_terminate: the terminating hop must serve the full chain (Sozu leaf-only broke lian-build pushes to reg). Use tls='pod_terminate." } else 'Ok ) in { # === Enums ================================================================ # Intended audience / datapath the hostnames must be reachable from. This is # the axis that no existing field captures: `vpn` = reachable over WireGuard. # Distinct from security.posture (public/private/internal), which states intent # but not the datapath that actually carries the traffic. Surface = [| 'cluster, 'private, 'vpn, 'public |], # How entry traffic lands on the workload. One variant per idiom found in the # census; the two Cilium-Gateway variants are split public/private because they # sit on different VLANs with different WireGuard reachability (the reg-class bug # lives entirely in the gateway_l7_* variants). Reachability = [| 'gateway_l7_public, # shared-FIP Cilium Gateway on the public VLAN VIP (gateway_fip / gateway_enabled) 'gateway_l7_private, # private Cilium Gateway (private_gateway_*): unreachable from WireGuard datapath 'gateway_l7_nested, # nested gateway{}/tcp_gateway{} block (radicle) 'l4_host_router, # Sozu private_ingress routes[]: L4 LB DNAT, WireGuard-reachable 'l4_lb, # per-service private LoadBalancer (private_lb_ip) 'l4_lb_public, # public LoadBalancer via LB-IPAM (lb_ipam_ip) 'fip_hostnet, # dedicated FIP / hostNetwork (fip_name / external_addr / host_network) 'cluster, # ClusterIP only — flat wuwei, no external entry |], # Where the TLS session terminates. Only modes observed in the current census # are variants; `gateway_mode` is always "terminate" today, no passthrough # exists. `'passthrough` is intentionally absent until a component declares it — # adding a speculative variant would let invariants pass on unobserved states. TlsMode = [| 'gateway_terminate, # Cilium Gateway/Envoy terminates (gateway_mode = "terminate") 'pod_terminate, # the workload pod terminates (pod_tls, sozu sidecar) 'sidecar_reload, # a reloader sidecar rotates the cert in place (tls_rotation = 'reloader) 'none, # no TLS at this hop (plain HTTP / cluster-internal) |], # === Records (all closed) ================================================= PortMap = { name | String | optional | doc "Human label (e.g. \"https\", \"ssh\", \"smtp\")", listen | Number | doc "Port the entry point listens on", target | Number | doc "Container/service port traffic is forwarded to", protocol | [| 'TCP, 'UDP |] | default = 'TCP, tls | TlsMode | doc "TLS termination for this port" | default = 'none, }, # The entry point: which datapath, and the statically-known address/name when # there is one. entry_ip is optional because FIP-backed entries only resolve # to a concrete IP at the cluster (surfaced by `prvng net map --live`). Binding = { kind | Reachability, entry_ip | String | optional | doc "VIP / LoadBalancer IP when known at eval time", fip_name | String | optional | doc "Hetzner FIP resource name (gateway_l7_public, fip_hostnet)", node | String | optional | doc "Pinned node for hostNetwork / FIP-follows-pod", }, # One reachability path for a set of hostnames. A service with both a public # gateway path and a VPN L4 path declares two Exposure entries. ExposureShape = { hostnames | Array String | doc "FQDNs that resolve to this binding", surface | Surface, binding | Binding, ports | Array PortMap | default = [], strict_chain_clients | Bool | doc "Clients require the full cert chain (not just the leaf) — forbids terminating on a hop that serves an incomplete chain" | default = false, ingress_restrictions | Array String | doc "Source constraints (CIDRs, NetworkPolicy refs) applied at the entry point" | default = [], dns_zone | String | doc "Explicit split-DNS zone for these hostnames; defaults to the hostname minus its first label (apex domains must set it)" | optional, }, # The public contract: shape + invariants. `Array Exposure` in ServiceExposure # forces every entry through both, so a bad surface/kind pair fails export. Exposure = std.contract.Sequence [ ExposureShape, _valid_exposure ], ServiceExposure = { service | String, namespace | String, exposures | Array Exposure, }, # === Builders ============================================================= # Mirror concerns.ncl's builder style: components/migrations construct entries # without repeating the Binding plumbing. gateway_public = fun fip hostnames ports => { surface = 'public, binding = { kind = 'gateway_l7_public, fip_name = fip }, ports = ports, }, gateway_private = fun entry hostnames ports => { surface = 'vpn, binding = { kind = 'gateway_l7_private, entry_ip = entry }, ports = ports, }, l4_private = fun entry hostnames ports => { surface = 'vpn, binding = { kind = 'l4_lb, entry_ip = entry }, ports = ports, }, host_router = fun entry hostnames ports => { surface = 'vpn, binding = { kind = 'l4_host_router, entry_ip = entry }, ports = ports, }, cluster_only = fun hostnames ports => { surface = 'cluster, binding = { kind = 'cluster }, ports = ports, }, # Build a host-router ServiceExposure from a list of hostnames served by one # L4 entry point (e.g. the Sozu private_ingress at .6). One vpn exposure per # host; used as the single source that both routes[] and dns_internal follow, # so a fresh deploy cannot drift between the router config and the split DNS. host_router_exposure = fun svc ns ip hs => { service = svc, namespace = ns, exposures = std.array.map (fun h => { hostnames = [h], surface = 'vpn, binding = { kind = 'l4_host_router, entry_ip = ip }, ports = [{ name = "https", listen = 443, target = 443, tls = 'pod_terminate }], }) hs, } | ServiceExposure, # === Derivation =========================================================== # Split-DNS routes derived from the exposure surface, so `dns_internal` stops # being a hand-maintained parallel field. Emits { host, zone, target, ttl } — # the exact shape dns_private.make_route produces — for every hostname of every # exposure that has a statically-known entry_ip. Zone = explicit dns_zone, or # the hostname minus its first label. Exposures with no entry_ip (pure-FIP, # resolved at the cluster) contribute nothing. derive_dns_internal = fun svc_exp => std.array.flat_map (fun exp => if std.record.has_field "entry_ip" exp.binding then let zone_of = fun h => if std.record.has_field "dns_zone" exp then exp.dns_zone else let parts = std.string.split "." h in std.string.join "." (std.array.slice 1 (std.array.length parts) parts) in std.array.map (fun h => { host = h, zone = zone_of h, target = exp.binding.entry_ip, ttl = 300 }) exp.hostnames else []) svc_exp.exposures, }