From ea42a174748ff14a6d6a6d0f0b5fbb985f9ad525 Mon Sep 17 00:00:00 2001 From: Jon Chery Date: Mon, 10 Aug 2026 20:11:32 +0000 Subject: [PATCH] feat(P4): linux node join remote ingress bootstrap (REQ-174) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ingress.BootstrapRemoteIngress: renders+writes traefik static config, renders+writes+applies nft DNAT/SNAT, pushes step-ca root CA, ensures podman traefik container — all over SSH exec. Uses a heredoc- based remoteWriteFile with a random delimiter (F9 injection guard). Wired into linux/bootstrap.go Step 4d, replacing the standalone EnsureTraefikContainerRemote call with the full ingress stack. C-60: uses certpaths.CACertPath() (not CAPath). C-58: mounts host-side traefik.yml (preserves REQ-100 opt-out). C-55: pre-creates nft table before nft -f. ---ci--- project: orca phase: 4 milestone: v0.14 status: execute ---/ci--- --- internal/ingress/bootstrap.go | 121 ++++++++++++++++++++++++++++++++++ internal/linux/bootstrap.go | 16 +++-- 2 files changed, 130 insertions(+), 7 deletions(-) diff --git a/internal/ingress/bootstrap.go b/internal/ingress/bootstrap.go index 54ae701..5fdc85e 100644 --- a/internal/ingress/bootstrap.go +++ b/internal/ingress/bootstrap.go @@ -9,11 +9,14 @@ 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" @@ -117,3 +120,121 @@ func BootstrapLocalIngress(ctx context.Context, version string) error { // 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 +} diff --git a/internal/linux/bootstrap.go b/internal/linux/bootstrap.go index ee7f364..787d775 100644 --- a/internal/linux/bootstrap.go +++ b/internal/linux/bootstrap.go @@ -32,9 +32,9 @@ import ( "golang.org/x/crypto/ssh" "git.cloudinit.dev/coreci/orca/internal/certpaths" + "git.cloudinit.dev/coreci/orca/internal/ingress" "git.cloudinit.dev/coreci/orca/internal/proxmox" "git.cloudinit.dev/coreci/orca/internal/security" - "git.cloudinit.dev/coreci/orca/internal/traefik" ) // DefaultSSHUser is the default SSH username for the initial connection. @@ -157,8 +157,10 @@ func BootstrapLinux(ctx context.Context, opts Options) (*Result, error) { } opts.Logger.Info("linux bootstrap: user created", "user", opts.OrcaUser) - // Step 4d: Ensure orca-traefik podman container on the remote host - // (REQ-172, R-024). Replaces v0.13 binary+systemd install. + // Step 4d: Ensure complete ingress stack on the remote host (R-024). + // Renders+applies nft DNAT/SNAT, pushes step-ca root CA, renders+ + // writes traefik static config, ensures podman container running. + // All non-fatal (offline host tolerance). sshExecFn := func(cmd string) ([]byte, error) { session, err := client.NewSession() if err != nil { @@ -167,10 +169,10 @@ func BootstrapLinux(ctx context.Context, opts Options) (*Result, error) { defer session.Close() return session.CombinedOutput(cmd) } - ctx, cancelContainer := context.WithTimeout(ctx, 120*time.Second) - defer cancelContainer() - if err := traefik.EnsureTraefikContainerRemote(ctx, "", sshExecFn); err != nil { - opts.Logger.Warn("linux bootstrap: traefik container ensure failed", "err", err) + ctx, cancelIngress := context.WithTimeout(ctx, 120*time.Second) + defer cancelIngress() + if err := ingress.BootstrapRemoteIngress(ctx, "", ingress.RemoteExecFunc(sshExecFn)); err != nil { + opts.Logger.Warn("linux bootstrap: ingress bootstrap failed", "err", err) } // Step 5: Create the drift-events directory.