// 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" "crypto/rand" "encoding/hex" "fmt" "log/slog" "os" "os/exec" "path/filepath" "strings" "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" // RemoteExecFunc runs a command on a remote host and returns combined // output. Same signature as traefik.RemoteExecFunc. type RemoteExecFunc func(cmd string) ([]byte, error) // BootstrapRemoteIngress ensures the complete ingress stack is running // on a remote host (linux worker). It is called from // `orca node join --type linux` after the user setup. // // Steps (each non-fatal — logs a warning and continues): // 1. mkdir -p /etc/traefik/dynamic /etc/orca (remote) // 2. Push step-ca root CA to remote /etc/orca/step-ca-root.crt // (C-60: certpaths.CACertPath) // 3. Render + write static config to remote /etc/traefik/traefik.yml // (C-58: preserves traefik-on-public-ip opt-out) // 4. Render + write nft ruleset to remote /etc/nftables.d/orca.nft // 5. Pre-create nft table (C-55: avoids first-apply flush-table error) // 6. Apply: nft -f (remote) // 7. Ensure podman traefik container running (remote) func BootstrapRemoteIngress(ctx context.Context, version string, execFn RemoteExecFunc) error { var errs []error log := slog.Default() // Step 1: ensure directories. if _, err := execFn("mkdir -p /etc/traefik/dynamic /etc/orca"); err != nil { log.Warn("ingress: remote mkdir failed", "err", err) errs = append(errs, fmt.Errorf("remote mkdir: %w", err)) } // Step 2: push step-ca root CA (C-60: CACertPath, not CAPath). if caData, err := os.ReadFile(certpaths.CACertPath()); err == nil { if err := remoteWriteFile(execFn, "/etc/orca/step-ca-root.crt", caData, "0644"); err != nil { log.Warn("ingress: remote step-ca CA write failed", "err", err) errs = append(errs, fmt.Errorf("remote write step-ca-root.crt: %w", err)) } } else { // Write a placeholder so the podman volume mount doesn't fail. _ = remoteWriteFile(execFn, "/etc/orca/step-ca-root.crt", []byte{}, "0644") log.Warn("ingress: step-ca root CA not found locally, wrote remote placeholder") } // 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 { _, _ = execFn(fmt.Sprintf("mkdir -p %s", filepath.Dir(f.Path))) if err := remoteWriteFile(execFn, f.Path, []byte(f.Content), f.Mode); err != nil { log.Warn("ingress: remote write traefik static config failed", "path", f.Path, "err", err) errs = append(errs, fmt.Errorf("remote 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 { _, _ = execFn(fmt.Sprintf("mkdir -p %s", filepath.Dir(f.Path))) if err := remoteWriteFile(execFn, f.Path, []byte(f.Content), f.Mode); err != nil { log.Warn("ingress: remote write nft config failed", "path", f.Path, "err", err) errs = append(errs, fmt.Errorf("remote write %s: %w", f.Path, err)) } } // Step 5: pre-create nft table (C-55). _, _ = execFn("nft add table inet orca-ingress 2>/dev/null || true") // Step 6: apply nft ruleset. if out, err := execFn("nft -f /etc/nftables.d/orca.nft 2>&1"); err != nil { log.Warn("ingress: remote nft apply failed", "err", err, "output", string(out)) errs = append(errs, fmt.Errorf("remote nft -f: %w (output: %s)", err, string(out))) } } // Step 7: ensure podman traefik container (C-50). traefikExecFn := traefik.RemoteExecFunc(execFn) if err := traefik.EnsureTraefikContainerRemote(ctx, version, traefikExecFn); err != nil { log.Warn("ingress: remote ensure traefik container failed", "err", err) errs = append(errs, fmt.Errorf("remote ensure traefik container: %w", err)) } if len(errs) > 0 { return fmt.Errorf("remote ingress bootstrap: %d errors (first: %w)", len(errs), errs[0]) } return nil } // remoteWriteFile writes content to a remote path via a heredoc // (same pattern as sshpush.idempotency.writeFile). The heredoc // delimiter is a random hex string verified absent from the content // (F9 injection guard). func remoteWriteFile(execFn RemoteExecFunc, path string, content []byte, mode string) error { // Generate a random delimiter unlikely to be in the content. delim := "EOF_" for { b := make([]byte, 8) if _, err := rand.Read(b); err != nil { return fmt.Errorf("rand: %w", err) } delim = "EOF_" + hex.EncodeToString(b) if !strings.Contains(string(content), delim) { break } } dir := filepath.Dir(path) cmd := fmt.Sprintf("mkdir -p %s && cat > %s <<'%s'\n%s\n%s\nchmod %s %s", dir, path, delim, string(content), delim, mode, path) if out, err := execFn(cmd); err != nil { return fmt.Errorf("remote write %s: %w (output: %s)", path, err, string(out)) } return nil }