5013209e31
nft emitter (internal/emitter/nft.go): - Add DNATTarget field (C-51: validated via net.ParseIP; injection guard). Default 127.0.0.1; proxmox native uses LXC bridge IP. - Add EnableSNAT field (default true for zero-value config). - Add postrouting masquerade chain (research Topic 1): ip saddr 127.0.0.0/8 oifname != lo masquerade - Shift input/forward priority from filter (=0) to -10 (research Topic 2: pve-firewall coexistence — avoids same-priority undefined evaluation order). internal/ingress/bootstrap.go (new): - BootstrapLocalIngress: mkdir dirs, push step-ca root CA (C-60: certpaths.CACertPath not CAPath), render+write traefik static config (C-58: preserves traefik-on-public-ip opt-out), render+ write+apply nft ruleset, pre-create table (C-55: avoids first- apply flush-table error), ensure podman container. All non-fatal. init.go: Step 4d now calls ingress.BootstrapLocalIngress (R-024). doctor_nft.go: assert postrouting masquerade + priority -10. Tests: nft_test.go — DNATTarget substitution, invalid DNATTarget rejection (C-51), EnableSNAT=false omits postrouting, priority -10. ---ci--- project: orca phase: 3 milestone: v0.14 status: execute ---/ci---
120 lines
4.6 KiB
Go
120 lines
4.6 KiB
Go
// Package ingress implements the R-024 ingress bootstrap: nft DNAT
|
|
// + SNAT/MASQUERADE + traefik podman container + step-ca root CA on
|
|
// every orca-managed node. The bootstrap is idempotent and non-fatal
|
|
// on each step (offline host tolerance — same as v0.13 traefik install).
|
|
//
|
|
// BootstrapLocalIngress runs on the lead (during `orca init`).
|
|
// BootstrapRemoteIngress runs on workers (during `orca node join`).
|
|
package ingress
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
|
|
"git.cloudinit.dev/coreci/orca/internal/certpaths"
|
|
"git.cloudinit.dev/coreci/orca/internal/emitter"
|
|
"git.cloudinit.dev/coreci/orca/internal/traefik"
|
|
)
|
|
|
|
// BootstrapLocalIngress ensures the complete ingress stack is running
|
|
// on the local host (lead node). It is called from `orca init` after
|
|
// EnsureTraefikContainerLocal.
|
|
//
|
|
// Steps (each non-fatal — logs a warning and continues):
|
|
// 1. mkdir -p /etc/traefik/dynamic /etc/orca
|
|
// 2. Push cluster root CA to /etc/orca/step-ca-root.crt (C-60:
|
|
// certpaths.CACertPath(), not CAPath)
|
|
// 3. Render static config via emitter.RenderTraefikStaticConfig to
|
|
// /etc/traefik/traefik.yml (preserves traefik-on-public-ip opt-out,
|
|
// C-58)
|
|
// 4. Render orca.nft via emitter.NftEmitter.RenderNftConfig + write
|
|
// to /etc/nftables.d/orca.nft
|
|
// 5. Pre-create nft table (C-55: avoids flush-table error on first
|
|
// apply)
|
|
// 6. Apply: nft -f /etc/nftables.d/orca.nft
|
|
func BootstrapLocalIngress(ctx context.Context, version string) error {
|
|
var errs []error
|
|
log := slog.Default()
|
|
|
|
// Step 1: ensure directories.
|
|
for _, dir := range []string{"/etc/traefik/dynamic", "/etc/orca"} {
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
log.Warn("ingress: mkdir failed", "dir", dir, "err", err)
|
|
errs = append(errs, fmt.Errorf("mkdir %s: %w", dir, err))
|
|
}
|
|
}
|
|
|
|
// Step 2: push cluster root CA (C-60: CACertPath, not CAPath).
|
|
caPath := certpaths.CACertPath()
|
|
if caData, err := os.ReadFile(caPath); err == nil {
|
|
if err := os.WriteFile("/etc/orca/step-ca-root.crt", caData, 0o644); err != nil {
|
|
log.Warn("ingress: step-ca root CA write failed", "err", err)
|
|
errs = append(errs, fmt.Errorf("write step-ca-root.crt: %w", err))
|
|
}
|
|
} else {
|
|
// CA may not exist yet (fresh init before step-ca). Write a
|
|
// placeholder so the podman volume mount doesn't fail.
|
|
_ = os.WriteFile("/etc/orca/step-ca-root.crt", []byte{}, 0o644)
|
|
log.Warn("ingress: step-ca root CA not found, wrote placeholder", "path", caPath)
|
|
}
|
|
|
|
// Step 3: render + write static config (C-58).
|
|
staticFiles, err := emitter.TraefikEmitter{}.RenderTraefikStaticConfig(emitter.TraefikStaticOpts{})
|
|
if err != nil {
|
|
log.Warn("ingress: render traefik static config failed", "err", err)
|
|
errs = append(errs, fmt.Errorf("render traefik static: %w", err))
|
|
} else {
|
|
for _, f := range staticFiles {
|
|
if err := os.WriteFile(f.Path, []byte(f.Content), 0o644); err != nil {
|
|
log.Warn("ingress: write traefik static config failed", "path", f.Path, "err", err)
|
|
errs = append(errs, fmt.Errorf("write %s: %w", f.Path, err))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Step 4: render + write nft ruleset.
|
|
nftFiles, err := emitter.NftEmitter{}.RenderNftConfig(emitter.NftClusterConfig{})
|
|
if err != nil {
|
|
log.Warn("ingress: render nft config failed", "err", err)
|
|
errs = append(errs, fmt.Errorf("render nft: %w", err))
|
|
} else {
|
|
for _, f := range nftFiles {
|
|
dir := filepath.Dir(f.Path)
|
|
_ = os.MkdirAll(dir, 0o755)
|
|
if err := os.WriteFile(f.Path, []byte(f.Content), 0o644); err != nil {
|
|
log.Warn("ingress: write nft config failed", "path", f.Path, "err", err)
|
|
errs = append(errs, fmt.Errorf("write %s: %w", f.Path, err))
|
|
}
|
|
}
|
|
|
|
// Step 5: pre-create nft table (C-55: flush table on non-existent
|
|
// table errors — pre-create avoids the first-apply failure).
|
|
_ = exec.CommandContext(ctx, "nft", "add", "table", "inet", "orca-ingress").Run()
|
|
|
|
// Step 6: apply nft ruleset.
|
|
if out, err := exec.CommandContext(ctx, "nft", "-f", nftConfigPath).CombinedOutput(); err != nil {
|
|
log.Warn("ingress: nft apply failed", "err", err, "output", string(out))
|
|
errs = append(errs, fmt.Errorf("nft -f: %w (output: %s)", err, string(out)))
|
|
}
|
|
}
|
|
|
|
// Ensure the podman traefik container is running (Step 7 — C-50:
|
|
// installs podman if absent).
|
|
if err := traefik.EnsureTraefikContainerLocal(ctx, version); err != nil {
|
|
log.Warn("ingress: ensure traefik container failed", "err", err)
|
|
errs = append(errs, fmt.Errorf("ensure traefik container: %w", err))
|
|
}
|
|
|
|
if len(errs) > 0 {
|
|
return fmt.Errorf("ingress bootstrap: %d errors (first: %w)", len(errs), errs[0])
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// nftConfigPath mirrors the emitter constant.
|
|
const nftConfigPath = "/etc/nftables.d/orca.nft"
|