Skip to content
Open
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
8 changes: 5 additions & 3 deletions pkg/build/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -1018,11 +1018,13 @@ func (b *Build) buildWorkspaceConfig(ctx context.Context) *container.Config {
}
}

// TODO(kaniini): Disable networking capability according to the pipeline requirements.
caps := container.Capabilities{
Networking: true,
networking := true
if b.Configuration.Capabilities.Networking != nil {
networking = *b.Configuration.Capabilities.Networking
}

caps := container.Capabilities{Networking: networking}

cfg := container.Config{
Arch: b.Arch,
PackageName: b.Configuration.Package.Name,
Expand Down
8 changes: 5 additions & 3 deletions pkg/build/test.go
Original file line number Diff line number Diff line change
Expand Up @@ -442,11 +442,13 @@ func (t *Test) buildWorkspaceConfig(ctx context.Context, imgRef, pkgName string,
}
}

// TODO(kaniini): Disable networking capability according to the pipeline requirements.
caps := container.Capabilities{
Networking: true,
networking := true
if t.Configuration.Capabilities.Networking != nil {
networking = *t.Configuration.Capabilities.Networking
}

caps := container.Capabilities{Networking: networking}

cfg := container.Config{
PackageName: pkgName,
Mounts: mounts,
Expand Down
14 changes: 14 additions & 0 deletions pkg/build/test_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ func TestBuildWorkspaceConfig(t *testing.T) {
},
}

networkingFalse := false

tests := []struct {
name string
env map[string]string
Expand Down Expand Up @@ -127,6 +129,18 @@ func TestBuildWorkspaceConfig(t *testing.T) {
want.CacheDir = tmpDirReal
return &want
}(),
}, {
name: "test - networking disabled",
t: func() *Test {
cacheT := baseTest
cacheT.Configuration.Capabilities.Networking = &networkingFalse
return &cacheT
}(),
want: func() *container.Config {
want := wantBase
want.Capabilities.Networking = false
return &want
}(),
},
}
for _, tt := range tests {
Expand Down
8 changes: 8 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -875,6 +875,14 @@ type Capabilities struct {
Add []string `json:"add,omitempty" yaml:"add,omitempty"`
// Linux process capabilities to drop from the pipeline container.
Drop []string `json:"drop,omitempty" yaml:"drop,omitempty"`

// Controls whether the build/test runner sandbox has access to the
// network. When omitted, melange keeps the existing behavior (network
// enabled).
//
// Note: network access may still be required for pipeline steps like
// fetch/git-checkout unless inputs are preloaded via cache/local sources.
Networking *bool `json:"networking,omitempty" yaml:"networking,omitempty"`
}

// Configuration is the root melange configuration.
Expand Down
6 changes: 6 additions & 0 deletions pkg/config/schema.cue
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@

// Linux process capabilities to drop from the pipeline container.
drop?: [...string]

// When set to false, melange will run the build/test runner sandbox
// without network access.
//
// When omitted, melange keeps the existing default (network enabled).
networking?: bool
})

// Capability stores paths and an associated map of capabilities
Expand Down
4 changes: 4 additions & 0 deletions pkg/config/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,10 @@
},
"type": "array",
"description": "Linux process capabilities to drop from the pipeline container."
},
"networking": {
"type": "boolean",
"description": "When set to false, melange will run the build/test runner sandbox without network access. When omitted, melange keeps the existing default (network enabled)."
}
},
"additionalProperties": false,
Expand Down
16 changes: 16 additions & 0 deletions pkg/container/docker/docker_runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,18 @@ func (dk *docker) Close() error {
return dk.cli.Close()
}

// dockerNetworkMode returns the Docker NetworkMode for the pod based on the
// networking capability. When networking is disabled it returns "none", which
// gives the container only a loopback interface with no route to the host or
// the outside world. When enabled it returns the empty NetworkMode, leaving
// Docker's default networking in place.
func dockerNetworkMode(networking bool) container.NetworkMode {
if !networking {
return "none"
}
return ""
}

// StartPod starts a pod for supporting a Docker task, if
// necessary.
func (dk *docker) StartPod(ctx context.Context, cfg *mcontainer.Config) error {
Expand Down Expand Up @@ -113,6 +125,10 @@ func (dk *docker) StartPod(ctx context.Context, cfg *mcontainer.Config) error {
if len(cfg.Capabilities.Drop) > 0 {
hostConfig.CapDrop = cfg.Capabilities.Drop
}
// Disable the container network when the networking capability is turned
// off. NetworkMode "none" gives the container only a loopback interface,
// with no route to the host or the outside world.
hostConfig.NetworkMode = dockerNetworkMode(cfg.Capabilities.Networking)

platform := &image_spec.Platform{
Architecture: cfg.Arch.String(),
Expand Down
37 changes: 37 additions & 0 deletions pkg/container/docker/networking_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Copyright 2026 Chainguard, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package docker

import "testing"

// TestDockerNetworkMode asserts the Docker runner selects NetworkMode "none"
// (loopback only, no route off-box) when the networking capability is
// disabled, and the default NetworkMode otherwise.
func TestDockerNetworkMode(t *testing.T) {
for _, tt := range []struct {
name string
networking bool
want string
}{
{"networking enabled", true, ""},
{"networking disabled", false, "none"},
} {
t.Run(tt.name, func(t *testing.T) {
if got := string(dockerNetworkMode(tt.networking)); got != tt.want {
t.Fatalf("networking=%v: NetworkMode=%q, want %q", tt.networking, got, tt.want)
}
})
}
}
81 changes: 81 additions & 0 deletions pkg/container/networking_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// Copyright 2026 Chainguard, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package container

import (
"strings"
"testing"

"github.com/chainguard-dev/clog/slogtest"
)

// TestBubblewrapNetworking asserts the bubblewrap runner unshares the network
// namespace (--unshare-net) when, and only when, the networking capability is
// disabled.
func TestBubblewrapNetworking(t *testing.T) {
for _, tt := range []struct {
name string
networking bool
wantUnshare bool
}{
{"networking enabled", true, false},
{"networking disabled", false, true},
} {
t.Run(tt.name, func(t *testing.T) {
ctx := slogtest.Context(t)
cfg := &Config{Capabilities: Capabilities{Networking: tt.networking}}
cmd := new(bubblewrap).cmd(ctx, cfg, false, nil)
got := strings.Contains(strings.Join(cmd.Args, " "), "--unshare-net")
if got != tt.wantUnshare {
t.Fatalf("networking=%v: --unshare-net present=%v, want=%v\nargs: %s",
tt.networking, got, tt.wantUnshare, strings.Join(cmd.Args, " "))
}
})
}
}

// TestQemuNetdevArgs asserts the QEMU runner adds SLIRP restrict=on (guest
// network isolation) when, and only when, the networking capability is
// disabled, while always preserving the hostfwd control channel.
func TestQemuNetdevArgs(t *testing.T) {
const sshAddr = "127.0.0.1:2022"
const sshCtrlAddr = "127.0.0.1:2223"

for _, tt := range []struct {
name string
networking bool
wantRestrict bool
}{
{"networking enabled", true, false},
{"networking disabled", false, true},
} {
t.Run(tt.name, func(t *testing.T) {
got := qemuNetdevArgs(sshAddr, sshCtrlAddr, tt.networking)

// hostfwd control channel must always be present so melange can
// drive the guest over SSH.
if !strings.Contains(got, "hostfwd=tcp:"+sshAddr+"-:22") ||
!strings.Contains(got, "hostfwd=tcp:"+sshCtrlAddr+"-:2223") {
t.Fatalf("hostfwd rules missing from netdev args: %s", got)
}

gotRestrict := strings.Contains(got, "restrict=on")
if gotRestrict != tt.wantRestrict {
t.Fatalf("networking=%v: restrict=on present=%v, want=%v\nargs: %s",
tt.networking, gotRestrict, tt.wantRestrict, got)
}
})
}
}
18 changes: 17 additions & 1 deletion pkg/container/qemu_runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -797,7 +797,7 @@ func createMicroVM(ctx context.Context, cfg *Config) error {
baseargs = append(baseargs, "-nodefaults")
baseargs = append(baseargs, serialArgs...)
// use -netdev + -device instead of -nic, as this is better supported by microvm machine type
netdevArgs := "user,id=id1,hostfwd=tcp:" + cfg.SSHAddress + "-:22,hostfwd=tcp:" + cfg.SSHControlAddress + "-:2223"
netdevArgs := qemuNetdevArgs(cfg.SSHAddress, cfg.SSHControlAddress, cfg.Capabilities.Networking)
// QEMU_DNS_SEARCH allows configuring DNS search domains inside the guest VM.
// This is useful for builds that need to resolve short hostnames via search
// domains, or when the build environment requires specific DNS resolution
Expand Down Expand Up @@ -2543,6 +2543,22 @@ func parseDNSSearchDomains(input string) ([]string, error) {
return domains, nil
}

// qemuNetdevArgs builds the QEMU -netdev option string for the guest's
// user-mode (SLIRP) network. The hostfwd rules expose the guest SSH and
// control ports on the host, which is how melange drives the guest.
//
// When networking is disabled, SLIRP restrict=on isolates the guest: it
// blocks all guest-initiated traffic to the host and the outside world while
// leaving the explicit hostfwd rules intact, so the SSH control channel keeps
// working. DNS search domains are appended by the caller.
func qemuNetdevArgs(sshAddress, sshControlAddress string, networking bool) string {
args := "user,id=id1,hostfwd=tcp:" + sshAddress + "-:22,hostfwd=tcp:" + sshControlAddress + "-:2223"
if !networking {
args += ",restrict=on"
}
return args
}

// buildDNSSearchNetdevArgs constructs the QEMU netdev dnssearch options string.
// Returns empty string if no domains provided.
// Each domain produces a separate ",dnssearch=<domain>" option.
Expand Down