Skip to content

Commit 59abbb4

Browse files
Lutherwavesclaude
andauthored
feat(openbloxd): broker the Docker API so callers never need socket access (#22)
Adds `openbloxd`, a policy broker for the Docker API, and `pkg/brokerclient`, a drop-in `sandbox.Backend` that reaches it over a Unix socket. Closes #2. A caller names a sandbox and a profile. Every setting that could weaken isolation — runtime, egress, image, user, resource bounds, lifetime, command timeouts — lives in the daemon's config file and is unreachable from a request. A request naming one is a 400, never a silently ignored field. openblox is a library, so the process importing it talks to the Docker daemon and needs the socket. When that process is containerized, mounting /var/run/docker.sock grants it root-equivalent host access: bind-mount /, or start a privileged container, and you are on the host. A filtering socket proxy does not close this, because POST /containers/create IS the dangerous verb — Binds, Privileged, PidMode and Runtime are fields inside a request the proxy already permits. With the broker, compromising a caller buys sandboxes, which it can create by design, rather than the host. - cmd/openbloxd — the daemon, plus a systemd unit and reference config - internal/daemon — config, listener, routes, policy resolution, reaper - pkg/brokerapi — the wire types both sides speak - pkg/brokerclient — sandbox.Backend + sandbox.Sandbox + preview.Dialer - pkg/docker — WithRegistryAuth (closes the anonymous-pull gap), Info.Labels The caller keeps the browser-facing surface; the daemon keeps the dangerous one; neither holds both. Design and rationale, including a "Corrections discovered during implementation" addendum: specs/2026-08-15-openbloxd-policy-broker-design.md Notes for future readers: - sandbox.Info is no longer a comparable struct. The added Labels map breaks any downstream `info1 == info2` at compile time. Additive-looking, but technically breaking; recorded in the CHANGELOG. - The daemon refuses to start on two conditions, both deliberate: a socket it cannot lock to 0660, and two profiles carrying different registry credentials. - RuntimeDirectoryMode=0750 in the systemd unit is load-bearing, not tidiness. - `..` traversal inside a sandbox path is permitted, matching pkg/docker's existing path.IsAbs-only guard. Tightening it is a both-sides change. - Known gap: peer-credential recording (SO_PEERCRED) is the seam for future profile-to-identity binding and is NOT implemented. Stated as such in the spec and docs/security.md. - Deferred: policyFields cannot detect a caller passing a policy option set to its library default, so that option is dropped in favour of the profile. Isolation is unaffected — daemon policy still wins — but the caller is not told. A real fix needs option intent tracked before Spec resolution. The the caller migration and the the deployment compose change are a separate repo and get their own PR. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 3ce0a2b commit 59abbb4

50 files changed

Lines changed: 7685 additions & 25 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,6 @@ go.work.sum
3333

3434
# mkdocs build output
3535
site/
36+
37+
# make build-daemon output
38+
bin/

ARCHITECTURE.md

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,10 @@ openblox is the wrong tool for it.
2727
│ │
2828
│ imports │ HTTP
2929
▼ ▼
30-
┌────────────────────┐ ┌──────────────────┐
31-
│ pkg/sandbox │◄─────────│ cmd/openbloxd │
32-
│ the library │ │ thin transport
33-
└─────────┬──────────┘ └──────────────────┘
30+
┌────────────────────┐ ┌────────────────────
31+
│ pkg/sandbox │◄─────────│ cmd/openbloxd
32+
│ the library │ │ the policy broker
33+
└─────────┬──────────┘ └────────────────────
3434
3535
│ Backend interface
3636
@@ -40,9 +40,13 @@ openblox is the wrong tool for it.
4040
└────────────────────┘
4141
```
4242

43-
The library is the product. `openbloxd` is a transport wrapper over it and must
44-
never hold logic the library doesn't. If a behaviour can only be reached over
45-
HTTP, it's in the wrong place.
43+
The library is the product. `openbloxd` holds no *sandbox* behaviour the library
44+
doesn't — if a way to run code can only be reached over HTTP, it's in the wrong
45+
place. It does hold *policy*, and that is the point: the daemon exists so a
46+
caller can create sandboxes without holding the Docker socket, which is only
47+
true if the isolation policy lives on the daemon's side of the socket. Options
48+
such as `WithRuntime` and `WithEgress` are therefore configuration in
49+
`openbloxd` and are unreachable from a request.
4650

4751
## Core interfaces
4852

@@ -212,10 +216,13 @@ would read as a broken server.
212216
## Repository layout
213217

214218
```
215-
pkg/sandbox/ the library — Backend, Sandbox, options, errors
216-
pkg/proxy/ preview-link signing + reverse proxy
217-
cmd/openbloxd/ HTTP transport over the library
218-
internal/ anything not part of the public contract
219+
pkg/sandbox/ the library — Backend, Sandbox, options, errors
220+
pkg/preview/ preview-link signing + reverse proxy
221+
pkg/brokerapi/ the wire types openbloxd speaks
222+
pkg/brokerclient/ a Backend that reaches openbloxd instead of Docker
223+
cmd/openbloxd/ the policy broker
224+
internal/daemon/ openbloxd's internals — config, routes, policy
225+
deploy/ systemd unit and reference config
219226
```
220227

221228
`internal/` is load-bearing: everything outside it is API we are promising to keep.

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,19 @@ Releases are cut automatically from [Conventional Commits](https://www.conventio
2222
handler that proxies to a port inside a sandbox without giving it a network.
2323
- `Reap` for idle and max-age lifetime bounds, holding no state of its own.
2424
- Images are pulled when absent.
25+
- `docker.WithRegistryAuth` for pulling from a private registry.
26+
- `Info.Labels`, so a caller's own bookkeeping labels round-trip through
27+
`List`/`Open`.
28+
- `openbloxd`: a daemon that owns the Docker connection so its callers never
29+
need `/var/run/docker.sock`, with per-profile isolation policy resolved
30+
server-side from configuration alone.
31+
- `pkg/brokerclient`: a drop-in `sandbox.Backend` that talks to `openbloxd`
32+
over its Unix socket, satisfying the same contract the Docker backend does.
33+
34+
### Changed
35+
36+
- `sandbox.Info` gained a `Labels map[string]string` field and is no longer
37+
comparable. `info1 == info2` and `map[sandbox.Info]T` now fail to compile.
38+
This is additive in shape but breaking in Go's comparability sense — a
39+
reasonable trade pre-1.0, but callers relying on `Info` being comparable
40+
need to switch to comparing the fields they care about.

Makefile

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.PHONY: all vet lint test test-integration cover tidy image image-verify
1+
.PHONY: all vet lint test test-integration cover tidy image image-verify build-daemon
22

33
# The reference sandbox image. See image/README.md for the contract it satisfies.
44
IMAGE ?= openblox-sandbox:dev
@@ -24,6 +24,9 @@ cover:
2424
tidy:
2525
go mod tidy
2626

27+
build-daemon:
28+
CGO_ENABLED=0 go build -trimpath -o bin/openbloxd ./cmd/openbloxd
29+
2730
image:
2831
docker build -t $(IMAGE) image/
2932

README.md

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -122,15 +122,18 @@ vulnerabilities. The integration suite runs there too, against a real gVisor
122122
daemon on a hosted runner — including the adversarial cases that try to break
123123
the resource caps.
124124

125-
Honest about the gaps: using openblox today means giving your service access to
126-
the Docker socket, which is root-equivalent on the host; closing that is
127-
[#2](https://github.com/blox-eng/openblox/issues/2), the most consequential open
128-
issue. The [open issues](https://github.com/blox-eng/openblox/issues) are the
129-
honest roadmap.
125+
Honest about the gaps: importing the library still means giving your service
126+
access to the Docker socket, which is root-equivalent on the host. `openbloxd`
127+
closes that — a daemon that owns the socket and exposes only openblox's own
128+
surface, policy fixed daemon-side and not settable per request — see
129+
[Security](https://openblox.sh/security/#deploying-the-policy-broker-openbloxd).
130+
The [open issues](https://github.com/blox-eng/openblox/issues) are the honest
131+
roadmap for what's left.
130132

131133
Written for [Blox](https://blox.bg), where it is the only sandbox backend and
132-
replaced a hosted platform. Its own production rollout is gated on #2. The API
133-
is unstable pre-1.0 — expect breaking changes on minor versions. No support SLA.
134+
replaced a hosted platform. Its own production rollout is gated on migrating its
135+
callers off the Docker socket and onto `openbloxd`. The API is unstable
136+
pre-1.0 — expect breaking changes on minor versions. No support SLA.
134137

135138
## Contributing
136139

cmd/openbloxd/main.go

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
// Command openbloxd brokers the Docker API so callers never need socket access.
2+
//
3+
// It owns the Docker connection and exposes only openblox's own surface, under
4+
// a policy read from its config file. A caller that is compromised can create
5+
// sandboxes, which it can do by design; it cannot mount the host filesystem or
6+
// start a privileged container.
7+
package main
8+
9+
import (
10+
"context"
11+
"errors"
12+
"flag"
13+
"fmt"
14+
"log/slog"
15+
"net"
16+
"net/http"
17+
"os"
18+
"os/signal"
19+
"syscall"
20+
"time"
21+
22+
"github.com/blox-eng/openblox/internal/daemon"
23+
"github.com/blox-eng/openblox/pkg/docker"
24+
)
25+
26+
func main() {
27+
configPath := flag.String("config", "/etc/openbloxd/config.yaml", "path to the config file")
28+
flag.Parse()
29+
30+
if err := run(*configPath); err != nil {
31+
slog.Error("openbloxd: exiting", slog.Any("error", err))
32+
os.Exit(1)
33+
}
34+
}
35+
36+
func run(configPath string) error {
37+
cfg, err := daemon.Load(configPath)
38+
if err != nil {
39+
return err
40+
}
41+
42+
var opts []docker.Option
43+
// One Docker connection carries one credential, and Config.validate has
44+
// already refused profiles whose registry_auth differs — so every non-nil
45+
// value here is the same one, and applying it once says that plainly.
46+
var auth *daemon.RegistryAuth
47+
for name, p := range cfg.Profiles {
48+
if !p.DigestPinned() {
49+
slog.Warn("profile image is not pinned to a digest; whoever controls the registry can repoint the tag",
50+
slog.String("profile", name), slog.String("image", p.Image))
51+
}
52+
if p.RegistryAuth != nil && auth == nil {
53+
auth = p.RegistryAuth
54+
}
55+
}
56+
if auth != nil {
57+
opts = append(opts, docker.WithRegistryAuth(auth.Username, auth.Password))
58+
}
59+
60+
backend, err := docker.New(opts...)
61+
if err != nil {
62+
return err
63+
}
64+
defer func() { _ = backend.Close() }()
65+
66+
ln, err := daemon.Listen(cfg.Socket, cfg.SocketGroup)
67+
if err != nil {
68+
return err
69+
}
70+
71+
srv := daemon.New(backend, cfg)
72+
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
73+
defer stop()
74+
go srv.RunReaper(ctx)
75+
76+
// No read or write timeout: exec can legitimately run for minutes and a
77+
// dialled preview stream is open for as long as the page is. The library's
78+
// own command timeouts are the bound that applies here.
79+
//
80+
// ReadHeaderTimeout is different: it only bounds the time to read the
81+
// request line and headers, which completes before Hijack (dial.go) or
82+
// the handler body (exec.go) ever runs — so it can't truncate a long exec
83+
// or a long-lived dialled stream. It closes a real Slowloris hole (a peer
84+
// that trickles headers forever) on the socket that holds the Docker
85+
// connection, even though that peer is local.
86+
httpSrv := &http.Server{Handler: srv.Handler(), ReadHeaderTimeout: 10 * time.Second}
87+
88+
slog.Info("openbloxd listening", slog.String("socket", cfg.Socket), slog.Int("profiles", len(cfg.Profiles)))
89+
return serve(ctx, httpSrv, ln)
90+
}
91+
92+
// serve runs httpSrv on ln until ctx is cancelled or Serve fails on its own.
93+
//
94+
// A Serve failure with no signal must return promptly rather than wait on
95+
// ctx.Done(), which may never fire: Restart=on-failure in the unit only
96+
// triggers if the process actually exits, and a process that hangs after
97+
// Serve dies looks "active (running)" to systemd while accepting nothing.
98+
func serve(ctx context.Context, httpSrv *http.Server, ln net.Listener) error {
99+
serveErr := make(chan error, 1)
100+
go func() { serveErr <- httpSrv.Serve(ln) }()
101+
102+
select {
103+
case err := <-serveErr:
104+
if err != nil && !errors.Is(err, http.ErrServerClosed) {
105+
return fmt.Errorf("serve: %w", err)
106+
}
107+
return nil
108+
case <-ctx.Done():
109+
}
110+
111+
// Shutdown closes the listener the instant it's called, before it starts
112+
// waiting on in-flight connections — so it must run synchronously here and
113+
// block until it (or its 10s budget) is done. Do this on a goroutine
114+
// instead and the caller returns the moment Serve unblocks, killing that
115+
// goroutine mid-wait and turning the grace period into dead code.
116+
//
117+
// This still can't wait out a hijacked preview stream: once a connection is
118+
// hijacked it's invisible to Shutdown's in-flight accounting, so the 10s
119+
// budget — not a graceful drain — is what bounds it.
120+
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
121+
defer cancel()
122+
if err := httpSrv.Shutdown(shutdownCtx); err != nil {
123+
slog.Warn("openbloxd: graceful shutdown did not complete in time", slog.Any("error", err))
124+
}
125+
126+
if err := <-serveErr; err != nil && !errors.Is(err, http.ErrServerClosed) {
127+
return fmt.Errorf("serve: %w", err)
128+
}
129+
return nil
130+
}

cmd/openbloxd/main_test.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"net"
6+
"net/http"
7+
"testing"
8+
"time"
9+
)
10+
11+
// TestServeReturnsPromptlyOnAcceptFailure covers a real regression: serve()
12+
// used to only select on ctx.Done(), so a Serve failure with no signal in
13+
// flight left it blocked forever waiting for a signal that would never come.
14+
// A closed listener makes Serve fail on its own immediately, standing in for
15+
// any Accept-loop failure — the assertion is that serve() notices and
16+
// returns without needing ctx to be cancelled.
17+
func TestServeReturnsPromptlyOnAcceptFailure(t *testing.T) {
18+
ln, err := net.Listen("tcp", "127.0.0.1:0")
19+
if err != nil {
20+
t.Fatalf("listen: %v", err)
21+
}
22+
if err := ln.Close(); err != nil {
23+
t.Fatalf("close: %v", err)
24+
}
25+
26+
// Never cancelled: if serve() regresses to selecting only on ctx.Done(),
27+
// this test hangs until the timeout below fires it as a failure.
28+
ctx := context.Background()
29+
30+
done := make(chan error, 1)
31+
go func() { done <- serve(ctx, &http.Server{}, ln) }()
32+
33+
select {
34+
case err := <-done:
35+
if err == nil {
36+
t.Fatal("serve: expected an error from Serve failing on a closed listener, got nil")
37+
}
38+
case <-time.After(2 * time.Second):
39+
t.Fatal("serve() hung instead of returning promptly when Serve failed with no signal")
40+
}
41+
}
42+
43+
// TestServeShutsDownOnContextCancel exercises the other arm of the same
44+
// select: the signal path must still work now that serve() also races
45+
// against serveErr. This is a structural check that the select didn't break —
46+
// it has no in-flight request to drain, so draining itself is http.Server's
47+
// own documented Shutdown behaviour, not something this test needs to
48+
// reprove.
49+
func TestServeShutsDownOnContextCancel(t *testing.T) {
50+
ln, err := net.Listen("tcp", "127.0.0.1:0")
51+
if err != nil {
52+
t.Fatalf("listen: %v", err)
53+
}
54+
55+
ctx, cancel := context.WithCancel(context.Background())
56+
done := make(chan error, 1)
57+
go func() { done <- serve(ctx, &http.Server{}, ln) }()
58+
59+
// Give the Serve goroutine a moment to start accepting before cancelling,
60+
// so this exercises the ctx.Done() arm rather than winning the select on
61+
// a Serve that hasn't even started yet.
62+
time.Sleep(50 * time.Millisecond)
63+
cancel()
64+
65+
select {
66+
case err := <-done:
67+
if err != nil {
68+
t.Fatalf("serve: %v", err)
69+
}
70+
case <-time.After(2 * time.Second):
71+
t.Fatal("serve() did not return after ctx cancellation")
72+
}
73+
}

deploy/openbloxd.example.yaml

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# openbloxd reference configuration.
2+
#
3+
# Everything that could weaken a sandbox's isolation is here and nowhere else.
4+
# No request field maps to any of it: a request naming one is rejected.
5+
6+
socket: /run/openbloxd/openbloxd.sock
7+
# The group that may reach the socket. This is the whole access control list:
8+
# membership grants sandbox creation.
9+
socket_group: openbloxd
10+
reap_interval: 1m
11+
12+
profiles:
13+
code-exec:
14+
# Pin a digest. A tag can be repointed by whoever controls the registry,
15+
# and the image is the sandbox's entire userland.
16+
image: ghcr.io/blox-eng/openblox-sandbox@sha256:CHANGEME
17+
runtime: runsc # gVisor. Anything else trades away the isolation.
18+
egress: none # No interface at all: no resolver, no route.
19+
user: "1000:1000"
20+
cpus: 2
21+
memory_mb: 2048
22+
disk_mb: 1024 # tmpfs, drawn from memory; must not exceed it.
23+
max_processes: 256 # Without this a fork bomb exhausts host PIDs.
24+
idle_timeout: 30m
25+
max_age: 4h
26+
default_timeout: 60s
27+
max_timeout: 10m
28+
29+
browser:
30+
image: ghcr.io/example/headless-browser@sha256:CHANGEME
31+
runtime: runsc
32+
egress: none
33+
user: "1000:1000"
34+
cpus: 2
35+
memory_mb: 4096
36+
disk_mb: 2048
37+
max_processes: 256
38+
idle_timeout: 30m
39+
max_age: 4h

deploy/openbloxd.service

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
[Unit]
2+
Description=openbloxd sandbox policy broker
3+
After=docker.service
4+
Requires=docker.service
5+
6+
[Service]
7+
Type=exec
8+
ExecStart=/usr/local/bin/openbloxd --config /etc/openbloxd/config.yaml
9+
User=openbloxd
10+
# The Docker socket is the daemon's whole privilege. It holds it so nothing
11+
# else has to.
12+
SupplementaryGroups=docker
13+
RuntimeDirectory=openbloxd
14+
# There is a window between net.Listen creating the socket and os.Chmod
15+
# narrowing it to 0660: for that window the socket carries whatever mode the
16+
# umask gives it. This runtime directory mode is what gates access during
17+
# that window, so do not loosen it.
18+
RuntimeDirectoryMode=0750
19+
Restart=on-failure
20+
21+
NoNewPrivileges=true
22+
ProtectSystem=strict
23+
ProtectHome=true
24+
PrivateTmp=true
25+
26+
[Install]
27+
WantedBy=multi-user.target

0 commit comments

Comments
 (0)