diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f23182..9110e69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/internal/api/dashboard/handler.go b/internal/api/dashboard/handler.go index b3957c0..ed69fb7 100644 --- a/internal/api/dashboard/handler.go +++ b/internal/api/dashboard/handler.go @@ -9,6 +9,7 @@ import ( "encoding/json" "errors" "net/http" + "sort" "strings" "github.com/contember/edvabe/internal/runtime" @@ -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)), @@ -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{ diff --git a/internal/runtime/docker/create.go b/internal/runtime/docker/create.go index 67d7547..830ad21 100644 --- a/internal/runtime/docker/create.go +++ b/internal/runtime/docker/create.go @@ -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, @@ -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) } diff --git a/internal/runtime/docker/runtime.go b/internal/runtime/docker/runtime.go index 3fdba71..0de971c 100644 --- a/internal/runtime/docker/runtime.go +++ b/internal/runtime/docker/runtime.go @@ -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" @@ -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 } @@ -78,11 +100,15 @@ 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 } @@ -90,6 +116,53 @@ func New() (*Runtime, error) { // 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. diff --git a/internal/runtime/docker/runtime_test.go b/internal/runtime/docker/runtime_test.go index dfbc54e..0ecbd82 100644 --- a/internal/runtime/docker/runtime_test.go +++ b/internal/runtime/docker/runtime_test.go @@ -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()