201 lines
9.8 KiB
Text
201 lines
9.8 KiB
Text
# 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,
|
|
}
|