Files
orca/internal/emitter/traefik.go
T
Jon Chery 8d1cdceb5c feat(P15.5): threat model (C-19) + ingress hybrid (R-017, REQ-099..102) + doctor mTLS (REQ-118)
Sub-wave 1: internal/emitter/nft.go (nftables emitter, DNAT :443→127.0.0.1:8443, rate-limit, SYN-flood filter); Traefik static config 127.0.0.1:8443 binding (D-220); orca doctor nft; orca nft CLI (show/diff/doctor/country-block/rate-limit).
Sub-wave 2: docs/threat-model.md (R-017 trust boundary, R-020 deadlock, D-234 secret exclusion, orca user blast radius, step-ca SPOF); orca doctor mTLS (chain verification + live handshake probe, C5).

---ci---
project: orca
phase: 15.5
milestone: v0.11
status: execute
---/ci---
2026-08-07 08:35:06 +00:00

300 lines
12 KiB
Go

package emitter
import (
"errors"
"fmt"
"net"
"strings"
"git.cloudinit.dev/coreci/orca/internal/jobspec"
)
// TraefikEmitter is the Layer-4 emitter for the Traefik dynamic-config
// file (REQ-077). It renders /etc/traefik/dynamic/orca-<spec.Name>.yaml
// — a single Traefik dynamic-config file describing the routers,
// services (servers = the R-007 socket paths), TLS config pointing at
// the step-ca root CA, and the service health check.
//
// Registered on the emitter.Registry under the service-kind keys:
//
// - service:process
// - service:podman
// - service:wasm
//
// RegisterTraefik wires all three; callers can also call Register
// directly with TraefikEmitter{} for a single runtime.
//
// Atomic reload (gate C-10): the Traefik dynamic-config file is written
// atomically via the SSH-push transport (sshpush.WriteFileIdempotent
// performs temp-file + fsync + rename, and WriteTraefikDynamic wraps
// it with an explicit tmp+mv so fsnotify sees a single rename event).
// Traefik watches the dynamic dir with fsnotify; the rename triggers a
// reload. On a malformed config Traefik logs an error and holds the
// last-good config (documented Traefik behavior; the C-10 test
// verifies the tmp+rename sequence so a half-written file is never
// observed by Traefik). Drain is rendered by setting the backend
// server's weight to 0 (or removing it) — see RenderDrain.
//
// The orca-v1- prefix is NOT applied to Traefik dynamic-config paths
// (the prefix is only for systemd unit names; the Traefik file is named
// orca-<spec.Name>.yaml and is the single source of truth for the
// service route — there is no dual-write window for Traefik configs).
type TraefikEmitter struct{}
// traefikDynamicDir is the canonical Traefik dynamic-config directory
// (R-006). The emitter writes one file per service at
// /etc/traefik/dynamic/orca-<spec.Name>.yaml.
const traefikDynamicDir = "/etc/traefik/dynamic"
// traefikRouterTLSCertResolver is the Traefik cert-resolver name that
// the orca step-ca integration configures on the Traefik static config
// (P10 / v0.10 wires the step-ca root into this resolver). The
// dynamic-config file references it by name.
const traefikRouterTLSCertResolver = "orca"
// defaultTrustDomain is the SPIFFE trust domain used in the rendered
// TLS stanza when the spec does not carry an explicit trust domain.
// The step-ca provisioner (P10) overrides this at render time via the
// node argument; for P02 the emitter renders the placeholder.
const defaultTrustDomain = "cluster.orca.local"
// Render renders the Traefik dynamic-config YAML for a Service
// workload. The output is a single File whose Path is
// /etc/traefik/dynamic/orca-<spec.Name>.yaml, Content is the rendered
// YAML, and Mode is 0644.
//
// Returns an error if the spec is nil, the name is empty, the spec has
// no ports (a Service with no ports has no backends to route to), or a
// service.bind value (when present) is not a valid IP address (R-007).
func (TraefikEmitter) Render(spec *jobspec.WorkloadSpec, node *Node) ([]File, error) {
if spec == nil {
return nil, errors.New("emitter/traefik: spec is nil")
}
if strings.TrimSpace(spec.Name) == "" {
return nil, errors.New("emitter/traefik: spec name is empty")
}
if len(spec.Ports) == 0 {
return nil, errors.New("emitter/traefik: service has no ports (no backends to route to)")
}
if spec.Service != nil {
if b := strings.TrimSpace(spec.Service.Bind); b != "" && net.ParseIP(b) == nil {
return nil, fmt.Errorf("emitter/traefik: service.bind %q is not a valid IP (R-007)", b)
}
}
content, err := renderTraefikYAML(spec, node)
if err != nil {
return nil, err
}
path := fmt.Sprintf("%s/orca-%s.yaml", traefikDynamicDir, spec.Name)
return []File{{Path: path, Content: content, Mode: "0644"}}, nil
}
// RenderDrain renders a Traefik dynamic-config that drains the service
// by setting every backend server's weight to 0 (I-B-005 drain). The
// path matches the live config so the atomic rename overwrites the
// routing config with the drained config (Traefik reloads and stops
// sending traffic). The caller writes the result via
// WriteTraefikDynamic for the C-10 atomicity protocol.
func (e TraefikEmitter) RenderDrain(spec *jobspec.WorkloadSpec, node *Node) ([]File, error) {
if spec == nil {
return nil, errors.New("emitter/traefik: spec is nil")
}
if strings.TrimSpace(spec.Name) == "" {
return nil, errors.New("emitter/traefik: spec name is empty")
}
if len(spec.Ports) == 0 {
return nil, errors.New("emitter/traefik: service has no ports (no backends to drain)")
}
content, err := renderTraefikYAMLDrain(spec, node)
if err != nil {
return nil, err
}
path := fmt.Sprintf("%s/orca-%s.yaml", traefikDynamicDir, spec.Name)
return []File{{Path: path, Content: content, Mode: "0644"}}, nil
}
// RegisterTraefik registers the TraefikEmitter on the given Registry
// under the three service-kind runtime keys (service:process,
// service:podman, service:wasm). The emitter is the same instance for
// all three runtimes — the rendered Traefik config is runtime-agnostic
// (the backend server URL is the R-007 socket path, which the runtime
// layer binds regardless of process/wasm/podman).
func RegisterTraefik(reg *Registry) {
e := TraefikEmitter{}
reg.Register("service:process", e)
reg.Register("service:podman", e)
reg.Register("service:wasm", e)
}
// renderTraefikYAML renders the Traefik dynamic-config YAML for the
// given spec + node. The shape (verified by the Traefik docs) is:
//
// http:
// routers:
// orca-<name>:
// rule: PathPrefix("/<name>")
// service: orca-<name>
// tls:
// certResolver: orca
// domains:
// - main: "<trust-domain>"
// services:
// orca-<name>:
// loadBalancer:
// servers:
// - url: "unix:///run/orca/alloc-<allocID>/port-<portName>.sock"
// healthCheck:
// path: /healthz
// interval: <interval>
// timeout: <timeout>
//
// The alloc-id placeholder is "<allocID>" pending the P08 socket
// layer; Traefik will reject the URL until a real alloc-id is
// substituted. For P02 the emitter renders the placeholder so the
// C-10 atomicity protocol is testable end-to-end; the socket layer
// (P08) replaces the placeholder with the live alloc-id.
func renderTraefikYAML(spec *jobspec.WorkloadSpec, node *Node) (string, error) {
return renderTraefikYAMLWeighted(spec, node, false)
}
// renderTraefikYAMLDrain renders the drained Traefik dynamic-config
// (every backend server has weight: 0). The shape mirrors the live
// config so the rename overwrites the live route with the drain.
func renderTraefikYAMLDrain(spec *jobspec.WorkloadSpec, node *Node) (string, error) {
return renderTraefikYAMLWeighted(spec, node, true)
}
// renderTraefikYAMLWeighted renders the Traefik dynamic-config YAML.
// When drain is true, every server entry is emitted with `weight: 0`
// (I-B-005). When drain is false, no weight is emitted (Traefik
// defaults to 1 — equal weighting across servers).
func renderTraefikYAMLWeighted(spec *jobspec.WorkloadSpec, node *Node, drain bool) (string, error) {
var b strings.Builder
routerName := "orca-" + spec.Name
serviceName := "orca-" + spec.Name
rule := fmt.Sprintf("PathPrefix(\"/%s\")", spec.Name)
trustDomain := defaultTrustDomain
b.WriteString("http:\n")
b.WriteString(" routers:\n")
b.WriteString(fmt.Sprintf(" %s:\n", routerName))
b.WriteString(fmt.Sprintf(" rule: %s\n", rule))
b.WriteString(fmt.Sprintf(" service: %s\n", serviceName))
b.WriteString(" tls:\n")
b.WriteString(fmt.Sprintf(" certResolver: %s\n", traefikRouterTLSCertResolver))
b.WriteString(" domains:\n")
b.WriteString(fmt.Sprintf(" - main: %q\n", trustDomain))
b.WriteString(" services:\n")
b.WriteString(fmt.Sprintf(" %s:\n", serviceName))
b.WriteString(" loadBalancer:\n")
b.WriteString(" servers:\n")
allocID := allocIDFor(node)
for _, p := range spec.Ports {
sock := fmt.Sprintf("unix:///run/orca/alloc-%s/port-%s.sock", allocID, p.Name)
b.WriteString(" - url: ")
b.WriteString(fmt.Sprintf("%q\n", sock))
if drain {
b.WriteString(" weight: 0\n")
}
}
if spec.Health != nil {
b.WriteString(" healthCheck:\n")
path := "/healthz"
b.WriteString(fmt.Sprintf(" path: %s\n", path))
if spec.Health.Interval != "" {
b.WriteString(fmt.Sprintf(" interval: %s\n", spec.Health.Interval))
}
if spec.Health.Timeout != "" {
b.WriteString(fmt.Sprintf(" timeout: %s\n", spec.Health.Timeout))
}
}
return b.String(), nil
}
// allocIDFor returns the alloc-id placeholder for the node. P08 will
// substitute the live alloc-id from the socket layer; for P02 we use a
// deterministic placeholder derived from the node hostname so the
// rendered config is stable across re-renders (the C-10 idempotency
// check depends on a stable hash). When the node is nil or has no
// hostname, the literal placeholder "<allocID>" is emitted.
func allocIDFor(node *Node) string {
if node == nil || strings.TrimSpace(node.Hostname) == "" {
return "<allocID>"
}
return node.Hostname
}
// traefikStaticConfigPath is the canonical on-peer path for the
// Traefik static config (R-017, D-220).
const traefikStaticConfigPath = "/etc/traefik/traefik.yml"
// TraefikStaticOpts controls the Traefik static-config binding. The
// default (PublicBinding="hybrid", R-017) binds the entrypoints to the
// loopback so nftables owns the public surface; the opt-out
// ("traefik-on-public-ip") binds them to the wildcard so Traefik owns
// the public surface directly (legacy / single-host deployments).
type TraefikStaticOpts struct {
// PublicBinding selects the binding model:
// "hybrid" (default, R-017): loopback bind + nft DNAT.
// "traefik-on-public-ip": Traefik binds :443/:80 directly.
PublicBinding string
}
// withDefaults returns a copy of o with the default PublicBinding
// applied when empty.
func (o TraefikStaticOpts) withDefaults() TraefikStaticOpts {
out := o
if strings.TrimSpace(out.PublicBinding) == "" {
out.PublicBinding = "hybrid"
}
return out
}
func (o TraefikStaticOpts) publicWebSecure() string {
if o.PublicBinding == "traefik-on-public-ip" {
return ":443"
}
return "127.0.0.1:8443"
}
func (o TraefikStaticOpts) publicWeb() string {
if o.PublicBinding == "traefik-on-public-ip" {
return ":80"
}
return "127.0.0.1:8080"
}
// RenderTraefikStaticConfig renders /etc/traefik/traefik.yml (REQ-100,
// D-220). The static config pins the entrypoint bind addresses (R-017
// hybrid default: loopback + nft DNAT; opt-out: Traefik on the public
// IP), the file provider (watch=/etc/traefik/dynamic/orca.yml), and
// json log + access log.
func (TraefikEmitter) RenderTraefikStaticConfig(opts TraefikStaticOpts) ([]File, error) {
o := opts.withDefaults()
content := renderTraefikStaticYAML(o)
return []File{{Path: traefikStaticConfigPath, Content: content, Mode: "0644"}}, nil
}
// renderTraefikStaticYAML builds the static-config YAML. The shape is
// load-bearing for `orca doctor ingress` (which greps the live file
// for the bind addresses).
func renderTraefikStaticYAML(o TraefikStaticOpts) string {
var b strings.Builder
b.WriteString("entryPoints:\n")
b.WriteString(" websecure:\n")
b.WriteString(fmt.Sprintf(" address: %q\n", o.publicWebSecure()))
b.WriteString(" web:\n")
b.WriteString(fmt.Sprintf(" address: %q\n", o.publicWeb()))
b.WriteString(" traefik:\n")
b.WriteString(fmt.Sprintf(" address: %q\n", "127.0.0.1:8081"))
b.WriteString("\nproviders:\n")
b.WriteString(" file:\n")
b.WriteString(fmt.Sprintf(" filename: %q\n", "/etc/traefik/dynamic/orca.yml"))
b.WriteString(" watch: true\n")
b.WriteString("\nlog:\n")
b.WriteString(" level: INFO\n")
b.WriteString(" format: json\n")
b.WriteString("\naccessLog:\n")
b.WriteString(" format: json\n")
return b.String()
}