Skip to content

Commit f52f0aa

Browse files
authored
feat(go): run Chromium in Linux namespaces (#835)
1 parent 26f7b91 commit f52f0aa

File tree

12 files changed

+638
-44
lines changed

12 files changed

+638
-44
lines changed

.github/workflows/docker-test.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ jobs:
6262
echo "$SECRET" > "$LPATH"
6363
echo "LICENSE_JWT=$LPATH" >> "$GITHUB_ENV"
6464
65+
- name: Enable unprivileged user namespaces
66+
run: |
67+
sudo sysctl kernel.unprivileged_userns_clone=1
68+
sudo sysctl kernel.apparmor_restrict_unprivileged_userns=0
6569
- name: go test ./tests/acceptance/...
6670
run: go test ./tests/acceptance/... -count=1
6771
env:

cmd/root.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"log/slog"
77

88
"github.com/grafana/grafana-image-renderer/cmd/healthcheck"
9+
"github.com/grafana/grafana-image-renderer/cmd/sandbox"
910
"github.com/grafana/grafana-image-renderer/cmd/server"
1011
"github.com/grafana/grafana-image-renderer/pkg/config"
1112
"github.com/grafana/grafana-image-renderer/pkg/service"
@@ -39,6 +40,7 @@ func NewRootCmd() *cli.Command {
3940
Commands: []*cli.Command{
4041
healthcheck.NewCmd(),
4142
server.NewCmd(),
43+
sandbox.NewCmd(),
4244
},
4345
}
4446
}

cmd/sandbox/cmd.go

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
package sandbox
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"log/slog"
7+
"os"
8+
"os/exec"
9+
"path/filepath"
10+
"strings"
11+
"syscall"
12+
13+
"github.com/grafana/grafana-image-renderer/pkg/sandbox"
14+
"github.com/urfave/cli/v3"
15+
"go.opentelemetry.io/otel/trace"
16+
libcap "kernel.org/pub/linux/libs/security/libcap/cap"
17+
)
18+
19+
func NewCmd() *cli.Command {
20+
return &cli.Command{
21+
Name: "_internal_sandbox",
22+
Usage: "Starts the browser in a best-effort sandbox.",
23+
Hidden: true,
24+
Commands: []*cli.Command{
25+
{
26+
Name: "supported",
27+
Usage: "Check if the current environment supports sandboxing. This is best-effort.",
28+
Action: func(ctx context.Context, c *cli.Command) error {
29+
if !sandbox.Supported(ctx) {
30+
return fmt.Errorf("sandboxing is not supported in this environment")
31+
}
32+
return nil
33+
},
34+
},
35+
{
36+
Name: "run",
37+
Usage: "Run a command inside the sandbox.",
38+
Flags: []cli.Flag{
39+
&cli.StringSliceFlag{
40+
Name: "mount",
41+
Usage: "Additional mount points to bind into the sandbox in the form of host_path:container_path(:rw)",
42+
Validator: func(s []string) error {
43+
for _, s := range s {
44+
if _, err := parseBindMount(s); err != nil {
45+
return fmt.Errorf("invalid --mount value %q: %w", s, err)
46+
}
47+
}
48+
return nil
49+
},
50+
},
51+
&cli.StringFlag{
52+
Name: "cwd",
53+
Usage: "The working directory inside the sandbox.",
54+
Value: "/tmp",
55+
},
56+
&cli.StringFlag{
57+
Name: "trace",
58+
Usage: "The OpenTelemetry trace ID to use in logs. This does not change anything about the browser, only the sandbox's log output.",
59+
},
60+
&cli.StringFlag{
61+
Name: "tmp",
62+
Usage: "The directory to mount as /tmp insidee the sandbox. This is intended to be a read-write bind mount.",
63+
Required: true,
64+
},
65+
},
66+
Action: run,
67+
},
68+
{
69+
Name: "bootstrap",
70+
Usage: "Bootstrap the sandbox environment.",
71+
SkipFlagParsing: true,
72+
Action: func(ctx context.Context, c *cli.Command) error {
73+
newRoot, err := os.MkdirTemp("", "")
74+
if err != nil {
75+
return fmt.Errorf("failed to create temp dir: %w", err)
76+
}
77+
defer func() { _ = os.RemoveAll(newRoot) }()
78+
79+
cmd := exec.CommandContext(ctx, "/proc/self/exe", append([]string{"_internal_sandbox", "run"}, c.Args().Slice()...)...)
80+
cmd.Dir = newRoot
81+
cmd.Stdin = os.Stdin
82+
cmd.Stdout = os.Stdout
83+
cmd.Stderr = os.Stderr
84+
cmd.SysProcAttr = &syscall.SysProcAttr{
85+
Pdeathsig: syscall.SIGKILL,
86+
Cloneflags: syscall.CLONE_NEWNS | syscall.CLONE_NEWPID | syscall.CLONE_NEWUSER,
87+
UidMappings: []syscall.SysProcIDMap{
88+
{
89+
ContainerID: 0,
90+
HostID: os.Getuid(),
91+
Size: 1,
92+
},
93+
},
94+
GidMappings: []syscall.SysProcIDMap{
95+
{
96+
ContainerID: 0,
97+
HostID: os.Getgid(),
98+
Size: 1,
99+
},
100+
},
101+
}
102+
103+
return cmd.Run()
104+
},
105+
},
106+
},
107+
}
108+
}
109+
110+
func run(ctx context.Context, c *cli.Command) error {
111+
ctx, err := adoptTrace(ctx, c.String("trace"))
112+
if err != nil {
113+
slog.WarnContext(ctx, "failed to adopt trace", "error", err)
114+
}
115+
116+
var bindMounts []sandbox.BindMount
117+
for _, s := range c.StringSlice("mount") {
118+
bm, err := parseBindMount(s)
119+
if err != nil {
120+
// should be unreachable, but easy to just return the error :P
121+
return fmt.Errorf("invalid --mount value %q: %w", s, err)
122+
}
123+
bindMounts = append(bindMounts, bm)
124+
}
125+
bindMounts = append(bindMounts, sandbox.BindMount{
126+
Source: c.String("tmp"),
127+
Destination: "/tmp",
128+
ReadWrite: true,
129+
})
130+
131+
command := c.Args().Slice()
132+
if len(command) == 0 {
133+
return fmt.Errorf("no command specified to run in sandbox")
134+
}
135+
136+
cwd, err := os.Getwd()
137+
if err != nil {
138+
return fmt.Errorf("failed to get current working directory: %w", err)
139+
}
140+
141+
if err := sandbox.SetupFS(ctx, cwd, bindMounts); err != nil {
142+
return fmt.Errorf("failed to setup sandbox filesystem: %w", err)
143+
}
144+
145+
if err := shedCapabilities(); err != nil {
146+
slog.WarnContext(ctx, "failed to shed capabilities", "error", err)
147+
}
148+
149+
cmd := exec.CommandContext(ctx, command[0], command[1:]...)
150+
cmd.Stdin = os.Stdin
151+
cmd.Stdout = os.Stdout
152+
cmd.Stderr = os.Stderr
153+
cmd.Dir = c.String("cwd")
154+
if cmd.SysProcAttr == nil {
155+
cmd.SysProcAttr = &syscall.SysProcAttr{}
156+
}
157+
cmd.SysProcAttr.Pdeathsig = syscall.SIGKILL
158+
159+
// TODO: Ensure this respects signals properly?
160+
return cmd.Run()
161+
}
162+
163+
func parseBindMount(s string) (sandbox.BindMount, error) {
164+
host, container, found := strings.Cut(s, ":")
165+
if !found {
166+
return sandbox.BindMount{}, fmt.Errorf("invalid mount format, expected host_path:container_path(:rw)")
167+
}
168+
container, rw, _ := strings.Cut(container, ":")
169+
170+
if !filepath.IsAbs(host) {
171+
return sandbox.BindMount{}, fmt.Errorf("host path must be absolute: %s", host)
172+
} else if !filepath.IsAbs(container) {
173+
return sandbox.BindMount{}, fmt.Errorf("container path must be absolute: %s", container)
174+
} else if rw != "" && rw != "rw" {
175+
return sandbox.BindMount{}, fmt.Errorf("invalid mount option (must be rw or absent): %s", rw)
176+
}
177+
178+
return sandbox.BindMount{
179+
Source: host,
180+
Destination: container,
181+
ReadWrite: rw == "rw",
182+
}, nil
183+
}
184+
185+
func adoptTrace(ctx context.Context, traceID string) (context.Context, error) {
186+
if traceID == "" {
187+
return ctx, nil
188+
}
189+
tid, err := trace.TraceIDFromHex(traceID)
190+
if err != nil {
191+
return ctx, fmt.Errorf("invalid trace ID: %w", err)
192+
}
193+
return trace.ContextWithRemoteSpanContext(ctx, trace.NewSpanContext(trace.SpanContextConfig{
194+
TraceID: tid,
195+
Remote: true,
196+
})), nil
197+
}
198+
199+
func shedCapabilities() error {
200+
if err := libcap.DropBound(libcap.SYS_CHROOT, libcap.SYS_ADMIN, libcap.SETPCAP); err != nil {
201+
return fmt.Errorf("failed to drop capabilities: %w", err)
202+
}
203+
return nil
204+
}

cmd/server/cmd.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package server
33
import (
44
"context"
55
"fmt"
6+
"log/slog"
67
"slices"
78

89
"github.com/grafana/grafana-image-renderer/pkg/api"
@@ -13,6 +14,7 @@ import (
1314
"github.com/urfave/cli/v3"
1415
"go.opentelemetry.io/otel"
1516
"go.opentelemetry.io/otel/propagation"
17+
"go.uber.org/automaxprocs/maxprocs"
1618
)
1719

1820
func NewCmd() *cli.Command {
@@ -25,6 +27,15 @@ func NewCmd() *cli.Command {
2527
}
2628

2729
func run(ctx context.Context, c *cli.Command) error {
30+
_, err := maxprocs.Set(
31+
// We use maxprocs over automaxprocs because we need a new minimum value.
32+
// 2 is the absolute minimum we can handle, because we use multiple goroutines many places for timeouts.
33+
maxprocs.Min(2),
34+
maxprocs.Logger(maxProcsLog))
35+
if err != nil {
36+
slog.Info("failed to set GOMAXPROCS", "err", err)
37+
}
38+
2839
serverConfig, err := config.ServerConfigFromCommand(c)
2940
if err != nil {
3041
return fmt.Errorf("failed to parse server config: %w", err)
@@ -61,3 +72,7 @@ func run(ctx context.Context, c *cli.Command) error {
6172
}
6273
return api.ListenAndServe(ctx, serverConfig, handler)
6374
}
75+
76+
func maxProcsLog(format string, args ...any) {
77+
slog.Debug(fmt.Sprintf(format, args...), "component", "automaxprocs")
78+
}

devenv/docker/go-build/docker-compose.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,17 @@ services:
4343
build:
4444
context: ../../../
4545
dockerfile: go.Dockerfile
46+
cap_add:
47+
- CAP_SYS_ADMIN # for clone, mount, unmount, unshare,
48+
- CAP_SYS_CHROOT # for chroot
4649
environment:
4750
TRACING_ENDPOINT: http://tempo:4318/v1/traces
4851
LOG_LEVEL: debug
4952
command:
5053
- server
5154
# 1 GiB
5255
- --rate-limit.max-available=1073741824
56+
- --browser.namespaced
5357
ports:
5458
- 8081:8081
5559
depends_on:

go.mod

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@ go 1.25.3
55
require (
66
github.com/Masterminds/semver/v3 v3.4.0
77
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d
8-
github.com/chromedp/chromedp v0.14.2
98
github.com/docker/docker v28.3.3+incompatible
109
github.com/docker/go-connections v0.6.0
1110
github.com/gen2brain/go-fitz v1.24.15
1211
github.com/go-jose/go-jose/v4 v4.1.2
12+
github.com/grafana/chromedp v0.1.0
1313
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58
1414
github.com/prometheus/client_golang v1.23.2
1515
github.com/shirou/gopsutil/v4 v4.25.9
@@ -28,6 +28,7 @@ require (
2828
go.uber.org/automaxprocs v1.6.0
2929
golang.org/x/sync v0.17.0
3030
google.golang.org/grpc v1.76.0
31+
kernel.org/pub/linux/libs/security/libcap/cap v1.2.77
3132
)
3233

3334
require (
@@ -101,6 +102,7 @@ require (
101102
google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda // indirect
102103
google.golang.org/protobuf v1.36.10 // indirect
103104
gopkg.in/yaml.v3 v3.0.1 // indirect
105+
kernel.org/pub/linux/libs/security/libcap/psx v1.2.77 // indirect
104106
)
105107

106108
tool golang.org/x/tools/cmd/goimports

go.sum

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,6 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
1818
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
1919
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d h1:ZtA1sedVbEW7EW80Iz2GR3Ye6PwbJAJXjv7D74xG6HU=
2020
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k=
21-
github.com/chromedp/chromedp v0.14.2 h1:r3b/WtwM50RsBZHMUm9fsNhhzRStTHrKdr2zmwbZSzM=
22-
github.com/chromedp/chromedp v0.14.2/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo=
2321
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
2422
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
2523
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
@@ -77,6 +75,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
7775
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
7876
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
7977
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
78+
github.com/grafana/chromedp v0.1.0 h1:aUTEQyZA3syEVu6dTlbQjNwltgvF7HSNwFL6255fTfM=
79+
github.com/grafana/chromedp v0.1.0/go.mod h1:Ub0oglYtrhtNKo31O9Cl6IxkaeM3OISlWIIbDmNHtmc=
8080
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg=
8181
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4=
8282
github.com/jupiterrider/ffi v0.5.1 h1:l7ANXU+Ex33LilVa283HNaf/sTzCrrht7D05k6T6nlc=
@@ -270,3 +270,7 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
270270
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
271271
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
272272
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
273+
kernel.org/pub/linux/libs/security/libcap/cap v1.2.77 h1:iQtQTjFUOcTT19fI8sTCzYXsjeVs56et3D8AbKS2Uks=
274+
kernel.org/pub/linux/libs/security/libcap/cap v1.2.77/go.mod h1:oV+IO8kGh0B7TxErbydDe2+BRmi9g/W0CkpVV+QBTJU=
275+
kernel.org/pub/linux/libs/security/libcap/psx v1.2.77 h1:Z06sMOzc0GNCwp6efaVrIrz4ywGJ1v+DP0pjVkOfDuA=
276+
kernel.org/pub/linux/libs/security/libcap/psx v1.2.77/go.mod h1:+l6Ee2F59XiJ2I6WR5ObpC1utCQJZ/VLsEbQCD8RG24=

main.go

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,12 @@ package main
33
import (
44
"context"
55
"errors"
6-
"fmt"
76
"log/slog"
87
"os"
98
"os/signal"
109
"syscall"
1110

1211
"github.com/grafana/grafana-image-renderer/cmd"
13-
"go.uber.org/automaxprocs/maxprocs"
1412
)
1513

1614
func main() {
@@ -21,15 +19,6 @@ func run(args []string) int {
2119
// Until the command actually does some work to parse log level and whatnot, we will log everything in logfmt format for DEBUG level.
2220
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{AddSource: true, Level: slog.LevelDebug})))
2321

24-
_, err := maxprocs.Set(
25-
// We use maxprocs over automaxprocs because we need a new minimum value.
26-
// 2 is the absolute minimum we can handle, because we use multiple goroutines many places for timeouts.
27-
maxprocs.Min(2),
28-
maxprocs.Logger(maxProcsLog))
29-
if err != nil {
30-
slog.Info("failed to set GOMAXPROCS", "err", err)
31-
}
32-
3322
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
3423
defer cancel()
3524

@@ -40,7 +29,3 @@ func run(args []string) int {
4029

4130
return 0
4231
}
43-
44-
func maxProcsLog(format string, args ...interface{}) {
45-
slog.Info(fmt.Sprintf(format, args...), "component", "automaxprocs")
46-
}

0 commit comments

Comments
 (0)