8d1cdceb5c
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---
300 lines
12 KiB
Go
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()
|
|
}
|