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
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,29 @@ All notable changes to edvabe land here. Format roughly follows

## Unreleased

## v0.3.0 — 2026-06-03

### Added

- **Configurable container security via `EDVABE_SECURITY_OPT`.** Sets
`HostConfig.SecurityOpt` (comma-separated) on every spawned sandbox.
When set it replaces edvabe's default `seccomp=unconfined,
apparmor=unconfined`; unset keeps that default. `systempaths=unconfined`
is recognized specially and translated to empty
`MaskedPaths`/`ReadonlyPaths` (Docker CLI sugar the daemon rejects as an
API-level opt) so in-sandbox bubblewrap can mount a fresh `/proc`.
Opt-in; relaxes the sandbox's isolation.
- **Extra bind mounts via `EDVABE_EXTRA_BINDS`.** Adds bind mounts
(`host:ctr[:ro]`, comma-separated) to every spawned sandbox on top of
the per-request ones. Lets the operator share host fixtures (e.g. LLM
replay snapshots) with sandboxes for deterministic local testing.
Opt-in.

### Fixed

- Dashboard overview now lists sandboxes and templates in a stable order
(by `CreatedAt`, then `ID`) instead of Go map iteration order.

## v0.2.0 — 2026-04-24

### Fixed
Expand Down
13 changes: 13 additions & 0 deletions internal/api/dashboard/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"encoding/json"
"errors"
"net/http"
"sort"
"strings"

"github.com/contember/edvabe/internal/runtime"
Expand Down Expand Up @@ -122,6 +123,12 @@ type overviewSummary struct {

func serveOverview(opts HandlerOptions, w http.ResponseWriter, r *http.Request) {
list := opts.Manager.List()
sort.Slice(list, func(i, j int) bool {
if list[i].CreatedAt.Equal(list[j].CreatedAt) {
return list[i].ID < list[j].ID
}
return list[i].CreatedAt.Before(list[j].CreatedAt)
})

resp := overviewResponse{
Sandboxes: make([]overviewSandbox, 0, len(list)),
Expand Down Expand Up @@ -177,6 +184,12 @@ func serveOverview(opts HandlerOptions, w http.ResponseWriter, r *http.Request)

if opts.Templates != nil {
tpls := opts.Templates.List()
sort.Slice(tpls, func(i, j int) bool {
if tpls[i].CreatedAt.Equal(tpls[j].CreatedAt) {
return tpls[i].ID < tpls[j].ID
}
return tpls[i].CreatedAt.Before(tpls[j].CreatedAt)
})
resp.Templates = make([]overviewTemplate, 0, len(tpls))
for _, t := range tpls {
ot := overviewTemplate{
Expand Down
16 changes: 15 additions & 1 deletion internal/runtime/docker/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ func (r *Runtime) Create(ctx context.Context, req runtime.CreateRequest) (*runti
Target: ctrPath,
})
}
mounts = append(mounts, r.extraBinds...)

cfg := &container.Config{
Image: req.Image,
Expand All @@ -61,11 +62,24 @@ func (r *Runtime) Create(ctx context.Context, req runtime.CreateRequest) (*runti
// podman, flatpak-builder, …) work inside the sandbox container.
// Safe under edvabe's single-user local-dev threat model; apparmor=
// unconfined is silently ignored on hosts without AppArmor (macOS
// Docker Desktop, Arch, Fedora).
// Docker Desktop, Arch, Fedora). EDVABE_SECURITY_OPT overrides this default.
hostCfg := &container.HostConfig{
Mounts: mounts,
SecurityOpt: []string{"seccomp=unconfined", "apparmor=unconfined"},
}
if len(r.securityOpt) > 0 {
hostCfg.SecurityOpt = r.securityOpt
}
if r.systempathsUnconfined {
// Empty (non-nil) slices override Docker's default /proc + /sys masking
// with NO masking — the same effect as `docker run --security-opt
// systempaths=unconfined`. Required so in-sandbox bubblewrap can mount a
// fresh /proc (Docker masks /proc/* by default, which makes bwrap fail
// with "Can't mount proc on /newroot/proc"). Opt-in via
// EDVABE_SECURITY_OPT; relaxes the sandbox's isolation.
hostCfg.MaskedPaths = []string{}
hostCfg.ReadonlyPaths = []string{}
}
if r.network != "" {
hostCfg.NetworkMode = container.NetworkMode(r.network)
}
Expand Down
81 changes: 77 additions & 4 deletions internal/runtime/docker/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"sync"

"github.com/moby/moby/api/types/mount"
"github.com/moby/moby/client"

"github.com/contember/edvabe/internal/runtime"
Expand All @@ -39,6 +41,26 @@ type Runtime struct {
host string
network string // Docker network sandboxes are attached to; empty = default bridge

// securityOpt is applied verbatim as HostConfig.SecurityOpt on every spawned
// sandbox (from EDVABE_SECURITY_OPT). Empty = Docker defaults. Use e.g.
// "seccomp=unconfined" to let in-sandbox tooling create user namespaces
// (bubblewrap). Opt-in: it relaxes the sandbox's isolation.
securityOpt []string

// systempathsUnconfined clears MaskedPaths/ReadonlyPaths on every spawned
// sandbox (set when EDVABE_SECURITY_OPT contains "systempaths=unconfined").
// Docker masks /proc/* by default; clearing it lets in-sandbox bubblewrap
// mount a fresh /proc. Translated rather than passed through because the
// daemon rejects systempaths as an API-level SecurityOpt. Opt-in: relaxes
// the sandbox's isolation.
systempathsUnconfined bool

// extraBinds are bind mounts (from EDVABE_EXTRA_BINDS) added to every spawned
// sandbox in addition to the per-request ones. Format per entry:
// "hostPath:containerPath[:ro]". Useful to share host fixtures (e.g. LLM
// snapshots) with sandboxes for deterministic local testing. Opt-in.
extraBinds []mount.Mount

mu sync.RWMutex
endpoints map[string]endpoint
}
Expand Down Expand Up @@ -78,18 +100,69 @@ func New() (*Runtime, error) {
if network == "" {
network = detectOwnNetwork(cli)
}
securityOpt, systempathsUnconfined := parseSecurityOpt(os.Getenv("EDVABE_SECURITY_OPT"))
return &Runtime{
cli: cli,
host: host,
network: network,
endpoints: make(map[string]endpoint),
cli: cli,
host: host,
network: network,
securityOpt: securityOpt,
systempathsUnconfined: systempathsUnconfined,
extraBinds: parseExtraBinds(os.Getenv("EDVABE_EXTRA_BINDS")),
endpoints: make(map[string]endpoint),
}, nil
}

// Network reports the Docker network name sandbox containers are
// attached to ("" means default bridge).
func (r *Runtime) Network() string { return r.network }

// parseSecurityOpt splits a comma-separated EDVABE_SECURITY_OPT value into the
// list passed to HostConfig.SecurityOpt (e.g. "seccomp=unconfined,apparmor=unconfined").
// Blank entries are dropped; an empty/unset value yields nil (Docker defaults).
//
// "systempaths=unconfined" is handled specially: it is Docker CLI sugar, not an
// API-level SecurityOpt (the daemon rejects it verbatim), so it is consumed here
// and reported via the bool. The caller translates it into empty
// MaskedPaths/ReadonlyPaths — exactly like `docker run --security-opt
// systempaths=unconfined`.
func parseSecurityOpt(raw string) (opts []string, systempathsUnconfined bool) {
for _, part := range strings.Split(raw, ",") {
p := strings.TrimSpace(part)
if p == "" {
continue
}
if strings.EqualFold(p, "systempaths=unconfined") {
systempathsUnconfined = true
continue
}
opts = append(opts, p)
}
return opts, systempathsUnconfined
}

// parseExtraBinds parses EDVABE_EXTRA_BINDS ("host:ctr[:ro],host2:ctr2") into
// bind mounts applied to every sandbox. Malformed entries are skipped.
func parseExtraBinds(raw string) []mount.Mount {
var binds []mount.Mount
for _, part := range strings.Split(raw, ",") {
p := strings.TrimSpace(part)
if p == "" {
continue
}
segs := strings.Split(p, ":")
if len(segs) < 2 || segs[0] == "" || segs[1] == "" {
continue
}
binds = append(binds, mount.Mount{
Type: mount.TypeBind,
Source: segs[0],
Target: segs[1],
ReadOnly: len(segs) >= 3 && segs[2] == "ro",
})
}
return binds
}

// OwnIPv4 returns edvabe's own IPv4 address on the sandbox network, or
// "" when it can't be determined (not containerized, inspection failed,
// etc.). Used to default --dns-answer.
Expand Down
28 changes: 28 additions & 0 deletions internal/runtime/docker/runtime_test.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,37 @@
package docker

import (
"slices"
"testing"
)

func TestParseSecurityOpt(t *testing.T) {
cases := []struct {
name string
raw string
wantOpts []string
wantSysUC bool
}{
{"empty", "", nil, false},
{"single", "seccomp=unconfined", []string{"seccomp=unconfined"}, false},
{"blanks trimmed", " seccomp=unconfined , , apparmor=unconfined ", []string{"seccomp=unconfined", "apparmor=unconfined"}, false},
{"systempaths consumed", "seccomp=unconfined,apparmor=unconfined,systempaths=unconfined", []string{"seccomp=unconfined", "apparmor=unconfined"}, true},
{"systempaths only", "systempaths=unconfined", nil, true},
{"systempaths case-insensitive", "SystemPaths=Unconfined", nil, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
opts, sysUC := parseSecurityOpt(tc.raw)
if !slices.Equal(opts, tc.wantOpts) {
t.Errorf("opts = %#v, want %#v", opts, tc.wantOpts)
}
if sysUC != tc.wantSysUC {
t.Errorf("systempathsUnconfined = %v, want %v", sysUC, tc.wantSysUC)
}
})
}
}

func TestDiscoverHostHonorsDockerHostEnv(t *testing.T) {
t.Setenv("DOCKER_HOST", "tcp://example.invalid:2375")
host, err := DiscoverHost()
Expand Down
Loading