Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 41 additions & 12 deletions cmd/ephemerd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -263,12 +263,19 @@ func serve(ctx context.Context, configFile, imagesDirFlag string, containerdTCPP
controlPorts := []int{int(containerdTCPPort), int(containerdTCPPort) + 1, int(containerdTCPPort) + 2}

net, err := networking.New(networking.Config{
DataDir: configDir,
Subnet: cfg.Network.Subnet,
MTU: cfg.Network.MTU,
CNIBinDir: cm.Dir(),
ControlPorts: controlPorts,
Log: log,
DataDir: configDir,
Subnet: cfg.Network.Subnet,
MTU: cfg.Network.MTU,
CNIBinDir: cm.Dir(),
ControlPorts: controlPorts,
L2BridgeEgress: cfg.Network.L2BridgeEgress,
HostNIC: cfg.Network.HostNIC,
IPPool: cfg.Network.IPPool,
Gateway: cfg.Network.Gateway,
PublicDNS: cfg.Network.PublicDNS,
ExtraAllowedCIDRs: cfg.Network.ExtraAllowedDestinations,
AllowHostAccess: needsHostAccess(cfg),
Log: log,
})
if err != nil {
return fmt.Errorf("initializing networking: %w", err)
Expand Down Expand Up @@ -427,12 +434,19 @@ func serve(ctx context.Context, configFile, imagesDirFlag string, containerdTCPP

// Initialize container networking
net, err := networking.New(networking.Config{
DataDir: configDir,
Subnet: cfg.Network.Subnet,
MTU: cfg.Network.MTU,
CNIBinDir: cm.Dir(),
GatewayPorts: gatewayPorts,
Log: log,
DataDir: configDir,
Subnet: cfg.Network.Subnet,
MTU: cfg.Network.MTU,
CNIBinDir: cm.Dir(),
GatewayPorts: gatewayPorts,
L2BridgeEgress: cfg.Network.L2BridgeEgress,
HostNIC: cfg.Network.HostNIC,
IPPool: cfg.Network.IPPool,
Gateway: cfg.Network.Gateway,
PublicDNS: cfg.Network.PublicDNS,
ExtraAllowedCIDRs: cfg.Network.ExtraAllowedDestinations,
AllowHostAccess: needsHostAccess(cfg),
Log: log,
})
if err != nil {
return fmt.Errorf("initializing networking: %w", err)
Expand Down Expand Up @@ -1137,6 +1151,21 @@ func crictlCmd() *cli.Command {
}
}

// needsHostAccess reports whether ephemerd runs anything that job containers
// must be able to reach over the network on the host address:
//
// - dind: the per-job Docker API listener, which on Windows is a TCP listener
// on the host address handed to the job as DOCKER_HOST.
// - the Go module proxy: bound to the same address and injected as GOPROXY.
//
// It only affects the Windows L2Bridge egress path, where the ACL ladder
// otherwise blocks the host along with the rest of RFC1918 — with neither
// feature enabled the strictest posture (host unreachable) applies. On NAT and
// on Linux the gateway is already reachable and this changes nothing.
func needsHostAccess(cfg *config.Config) bool {
return cfg.Dind.Enabled || cfg.ModuleProxy.Enabled
}

func joinPath(parts ...string) string {
result := parts[0]
for _, p := range parts[1:] {
Expand Down
81 changes: 69 additions & 12 deletions cmd/ephemerd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,34 +157,91 @@ func runWorkflow(ctx context.Context, workflowPath string, jobFilter string, ima
socketPath = `\\.\pipe\ephemerd-run-` + filepath.Base(tmpDir)
}

image := resolveRunImage(imageFlag, platform)
// Load the service config once. A local run must use the SAME container
// network the host is configured for — building its own would put the job
// on an unfiltered network on a host that was deliberately configured to
// filter, and on Windows would also install the NAT-era netsh rules that
// block RFC1918 host-wide. A missing or unreadable config is not fatal:
// the run falls back to the built-in default network.
cfg := loadRunConfig()

runner := &workflow.Runner{
DataDir: tmpDir,
SocketPath: socketPath,
Image: image,
Image: resolveRunImage(imageFlag, platform, cfg),
Network: runNetworkOptions(cfg, log),
Log: log,
}

return runner.RunJob(ctx, jobName, job, repoDir)
}

// loadRunConfig reads the service config for a local run. A missing or
// malformed config.toml is not an error here — the run simply falls back to the
// built-in defaults for both image and network — so it returns nil rather than
// failing the command.
func loadRunConfig() *config.Config {
cfg, err := config.Load(filepath.Join(configDir, "config.toml"))
if err != nil {
return nil
}
return cfg
}

// runNetworkOptions maps the host's [network] config onto a local run and warns
// when the job will land on the default, unfilterable container network.
//
// On Windows the default is an HNS NAT network on the Hyper-V vSwitch. Container
// egress there CANNOT be filtered in software — WFP, the Hyper-V firewall, VFP
// on a NAT switch and netsh have all been ruled out on metal, because WinNAT's
// translation path never presents the packet to an inspectable filtering layer.
// A job on that network can reach anything the host can, including the LAN and
// any management planes on it. network.l2bridge_egress is the only path that
// actually enforces. See docs/guides/security.md ("Network Firewall").
func runNetworkOptions(cfg *config.Config, log *slog.Logger) workflow.NetworkOptions {
if cfg == nil {
warnUnfilteredRunNetwork(log, "no readable config.toml")
return workflow.NetworkOptions{}
}

opts := workflow.NetworkOptions{
Subnet: cfg.Network.Subnet,
MTU: cfg.Network.MTU,
L2BridgeEgress: cfg.Network.L2BridgeEgress,
HostNIC: cfg.Network.HostNIC,
IPPool: cfg.Network.IPPool,
Gateway: cfg.Network.Gateway,
PublicDNS: cfg.Network.PublicDNS,
ExtraAllowedCIDRs: cfg.Network.ExtraAllowedDestinations,
AllowHostAccess: needsHostAccess(cfg),
}

if runtime.GOOS == "windows" && !opts.L2BridgeEgress {
warnUnfilteredRunNetwork(log, "network.l2bridge_egress is not enabled")
}
return opts
}

func warnUnfilteredRunNetwork(log *slog.Logger, reason string) {
if runtime.GOOS != "windows" {
return
}
log.Warn("this job's container egress is NOT filtered: it runs on the default Hyper-V vSwitch (HNS NAT), "+
"where container egress cannot be filtered in software — the job can reach your LAN and anything on it. "+
"Set network.l2bridge_egress (requires a WIRED adapter and a reserved network.ip_pool) to enforce egress",
"reason", reason)
}

// resolveRunImage determines the container image for a run job.
// Priority: --image flag → service config.toml → empty (caller applies the
// built-in default — see workflow.Runner.RunJob, which substitutes
// defaultImage when this returns "").
func resolveRunImage(flagValue string, platform workflow.TargetPlatform) string {
func resolveRunImage(flagValue string, platform workflow.TargetPlatform, cfg *config.Config) string {
if flagValue != "" {
return flagValue
}

osName := platform.String()
cfgPath := filepath.Join(configDir, "config.toml")
if cfg, err := config.Load(cfgPath); err == nil {
if img := cfg.GitHub.DefaultImageFor(osName); img != "" {
return img
}
if cfg == nil {
return ""
}

return ""
return cfg.GitHub.DefaultImageFor(platform.String())
}
114 changes: 108 additions & 6 deletions cmd/ephemerd/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ default_image_windows = "ghcr.io/from-config:windows"
configDir = dir
t.Setenv("GITHUB_TOKEN", "ghp_test")

if got := resolveRunImage("ghcr.io/explicit:v1", workflow.PlatformLinux); got != "ghcr.io/explicit:v1" {
if got := resolveRunImage("ghcr.io/explicit:v1", workflow.PlatformLinux, loadRunConfig()); got != "ghcr.io/explicit:v1" {
t.Errorf("flag-wins: got %q, want explicit override", got)
}
}
Expand All @@ -58,7 +58,7 @@ default_image_windows = "ghcr.io/from-config:windows"
configDir = dir
t.Setenv("GITHUB_TOKEN", "ghp_test")

got := resolveRunImage("", workflow.PlatformLinux)
got := resolveRunImage("", workflow.PlatformLinux, loadRunConfig())
if got != "ghcr.io/from-config:linux" {
t.Errorf("config-wins linux: got %q, want %q", got, "ghcr.io/from-config:linux")
}
Expand All @@ -76,7 +76,7 @@ default_image_windows = "ghcr.io/from-config:windows"
configDir = dir
t.Setenv("GITHUB_TOKEN", "ghp_test")

got := resolveRunImage("", workflow.PlatformWindows)
got := resolveRunImage("", workflow.PlatformWindows, loadRunConfig())
if got != "ghcr.io/from-config:windows" {
t.Errorf("config-wins windows: got %q, want %q", got, "ghcr.io/from-config:windows")
}
Expand All @@ -88,7 +88,7 @@ func TestResolveRunImage_NoConfigFile(t *testing.T) {
defer configDirGuard(t)()
configDir = t.TempDir()

if got := resolveRunImage("", workflow.PlatformLinux); got != "" {
if got := resolveRunImage("", workflow.PlatformLinux, loadRunConfig()); got != "" {
t.Errorf("no-config: got %q, want empty (caller defaults)", got)
}
}
Expand All @@ -103,7 +103,7 @@ func TestResolveRunImage_ConfigParseError(t *testing.T) {
writeConfig(t, dir, "this is not valid TOML [\n")
configDir = dir

if got := resolveRunImage("", workflow.PlatformLinux); got != "" {
if got := resolveRunImage("", workflow.PlatformLinux, loadRunConfig()); got != "" {
t.Errorf("config-parse-error: got %q, want empty fallback", got)
}
}
Expand All @@ -123,7 +123,109 @@ owner = "testorg"
// Windows has no built-in default image (the runtime picks one from
// the host build number), so DefaultImageFor("windows") returns "" —
// resolver must propagate the empty string.
if got := resolveRunImage("", workflow.PlatformWindows); got != "" {
if got := resolveRunImage("", workflow.PlatformWindows, loadRunConfig()); got != "" {
t.Errorf("no-windows-override: got %q, want empty (caller defaults)", got)
}
}

// A local run must inherit the host's L2Bridge settings. Before this, `ephemerd
// run` built its own default network: on Windows an HNS NAT network plus the
// NAT-era netsh rules that block RFC1918 host-wide — which severs the host's own
// DNS when its resolver is a LAN address — and put the job on an UNFILTERED
// network on a host deliberately configured to filter.
func TestRunNetworkOptions_CarriesL2BridgeConfig(t *testing.T) {
defer configDirGuard(t)()
dir := t.TempDir()
writeConfig(t, dir, `
[github]
owner = "testorg"

[network]
l2bridge_egress = true
host_nic = "Ethernet"
ip_pool = "192.0.2.192/27"
public_dns = ["9.9.9.9"]
extra_allowed_destinations = ["198.51.100.0/24"]
`)
configDir = dir
t.Setenv("GITHUB_TOKEN", "ghp_test")

opts := runNetworkOptions(loadRunConfig(), quietLog())

if !opts.L2BridgeEgress {
t.Error("L2BridgeEgress not carried into the run; the job would land on the unfiltered NAT network")
}
if opts.HostNIC != "Ethernet" {
t.Errorf("HostNIC: got %q, want %q", opts.HostNIC, "Ethernet")
}
if opts.IPPool != "192.0.2.192/27" {
t.Errorf("IPPool: got %q, want %q", opts.IPPool, "192.0.2.192/27")
}
if len(opts.PublicDNS) != 1 || opts.PublicDNS[0] != "9.9.9.9" {
t.Errorf("PublicDNS: got %v, want [9.9.9.9]", opts.PublicDNS)
}
if len(opts.ExtraAllowedCIDRs) != 1 || opts.ExtraAllowedCIDRs[0] != "198.51.100.0/24" {
t.Errorf("ExtraAllowedCIDRs: got %v, want [198.51.100.0/24]", opts.ExtraAllowedCIDRs)
}
}

// AllowHostAccess must follow the same rule serve uses (needsHostAccess): the
// host /32 allow exists only because dind and the module proxy serve job
// containers over the network. With neither enabled the strict posture applies.
func TestRunNetworkOptions_AllowHostAccessFollowsDind(t *testing.T) {
defer configDirGuard(t)()
dir := t.TempDir()
writeConfig(t, dir, `
[github]
owner = "testorg"

[network]
l2bridge_egress = true
host_nic = "Ethernet"
ip_pool = "192.0.2.192/27"

[dind]
enabled = true
`)
configDir = dir
t.Setenv("GITHUB_TOKEN", "ghp_test")

if opts := runNetworkOptions(loadRunConfig(), quietLog()); !opts.AllowHostAccess {
t.Error("AllowHostAccess must be set when dind is enabled, or dind cannot reach the host and jobs fail to provision")
}
}

func TestRunNetworkOptions_StrictWhenNothingServesContainers(t *testing.T) {
defer configDirGuard(t)()
dir := t.TempDir()
writeConfig(t, dir, `
[github]
owner = "testorg"

[network]
l2bridge_egress = true
host_nic = "Ethernet"
ip_pool = "192.0.2.192/27"

[dind]
enabled = false
`)
configDir = dir
t.Setenv("GITHUB_TOKEN", "ghp_test")

if opts := runNetworkOptions(loadRunConfig(), quietLog()); opts.AllowHostAccess {
t.Error("AllowHostAccess must stay false when nothing serves containers — that is the strictest posture")
}
}

// No config at all is not fatal: the run falls back to the built-in default
// network (and warns on Windows that egress is unfiltered).
func TestRunNetworkOptions_NoConfigFallsBack(t *testing.T) {
defer configDirGuard(t)()
configDir = t.TempDir()

opts := runNetworkOptions(loadRunConfig(), quietLog())
if opts.L2BridgeEgress || opts.HostNIC != "" || opts.IPPool != "" {
t.Errorf("no-config: expected zero-value options, got %+v", opts)
}
}
Loading