diff --git a/.env.example b/.env.example index e457372..76a8353 100644 --- a/.env.example +++ b/.env.example @@ -65,6 +65,16 @@ BACKEND_STORAGE_DRIVER=local # can't reach. Presigned URLs are valid only at the host they were minted for. #S3_PUBLIC_ENDPOINT=https://objects.example.com +# Functions execution. "exec" runs the runtime binary (bun/deno) directly on +# the backend host — simple, but function code inherits backend privileges. +# "docker" runs each invoke in an ephemeral locked-down container (no +# network, read-only fs, 128 MiB mem, non-root) and requires the Docker +# socket mounted into the backend plus the runtime images pulled. +#FUNCTIONS_DRIVER=exec +#FUNCTIONS_RUNTIME=auto +#FUNCTIONS_TIMEOUT_SECONDS=30 +#FUNCTIONS_MAX_OUTPUT_BYTES=65536 + # Mail defaults — each is overridable in-app (Settings → Instance). In-app # values win over these env vars; deleting the in-app value reverts here. RESEND_API_KEY= diff --git a/CHANGELOG.md b/CHANGELOG.md index f7f9477..f8240cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ All notable changes to Primora. Format follows [Keep a Changelog](https://keepac - **Presigned object URLs** — `POST /buckets/:bucketID/object-presigns` mints SigV4 query-signed `GET`/`PUT` URLs (60 s–1 h TTL) so clients transfer large objects directly against S3 without proxying through the backend. Read role for downloads (object must exist — no key probing), write role for uploads. `S3_PUBLIC_ENDPOINT` rewrites the client-facing host for internal `S3_ENDPOINT`s; signatures bind the host so URLs are valid only where they were minted. The local driver returns a clear error — a presigned URL needs a real object API. - **Desktop auto-updater** — `tauri-plugin-updater` wired end to end: minisign keypair, `createUpdaterArtifacts`, `latest.json` manifest built from signed artifacts on every release, and a silent background check on the connect screen offering "Update & restart" (download → verify → install → relaunch). Unsigned artifacts can never install — missing signature means no manifest, not a bypass. CI signs via `TAURI_SIGNING_PRIVATE_KEY` secrets; OS-level signing (notarization/Authenticode) remains open. - **Release SBOMs** — CycloneDX SBOM for each published GHCR image (backend/auth/frontend) plus one for the source tree, attached to every GitHub Release via `anchore/sbom-action`. +- **Sandboxed function driver** — `FUNCTIONS_DRIVER=docker` runs every invoke in an ephemeral locked-down container: no network, read-only rootfs, 128 MiB memory, 0.5 CPU, 64 pids, non-root, all capabilities dropped. Source is injected via base64 env (no bind mount — works on Docker Desktop and remote daemons); timeout force-removes the container so nothing lingers. Verified live: code runs, network is unreachable inside, and deadline kills land. - **Function triggers** (migration 00016) — three new ways to run a function, all through the same executor and run log. Scheduled jobs accept `function_id` instead of `url` (run status mirrors the exit code); inbound hooks gain `mode: "function"` — the received body becomes the function's stdin payload, acked immediately with the run recorded async; and `event_pattern` on a function (`document.*`, `*`, or an exact type) fires it detached whenever a matching domain event publishes — `{event, project_id, occurred_at, data}` as payload. Runs record trigger source: `manual`/`schedule`/`hook`/`event`. Dashboard: function event-trigger field, job target picker (URL or function), and inbound-hook function mode. ## [0.7.1] - 2026-09-23 diff --git a/DEPLOYMENT_GUIDE.md b/DEPLOYMENT_GUIDE.md index 854efda..cdc0693 100644 --- a/DEPLOYMENT_GUIDE.md +++ b/DEPLOYMENT_GUIDE.md @@ -47,7 +47,8 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d | `S3_ACCESS_KEY_ID` / `S3_SECRET_ACCESS_KEY` | S3 credentials | with `s3` | | `S3_PREFIX` / `S3_PATH_STYLE` | optional key prefix; path-style URLs (default `true`, needed by MinIO/Garage — set `false` for AWS virtual-hosted) | no | | `S3_PUBLIC_ENDPOINT` | client-facing S3 endpoint used for presigned URLs when `S3_ENDPOINT` is internal-only (e.g. `http://minio:9000`); signatures bind the public host | no | -| `FUNCTIONS_RUNTIME` / `FUNCTIONS_TIMEOUT_SECONDS` / `FUNCTIONS_MAX_OUTPUT_BYTES` | Functions: runtime binary (`auto` resolves `bun`/`deno` from the backend's PATH — the backend image must ship one, or set this to its path), per-run timeout (default 30 s), captured-output cap (default 64 KiB) | no | +| `FUNCTIONS_DRIVER` | Function execution driver: `exec` (default — runtime binary on the backend host; code runs with backend privileges) or `docker` — each invoke runs in an ephemeral locked-down container (no network, read-only fs, 128 MiB, non-root). Docker mode needs `/var/run/docker.sock` mounted into the backend (see the commented volume in `docker-compose.yml`) and the runtime images pulled (`oven/bun`, `denoland/deno`). Source code is capped at 256 KiB under the docker driver | no | +| `FUNCTIONS_RUNTIME` / `FUNCTIONS_TIMEOUT_SECONDS` / `FUNCTIONS_MAX_OUTPUT_BYTES` | Functions: runtime binary (`auto` resolves `bun`/`deno` — exec driver only; docker driver uses the matching official image), per-run timeout (default 30 s), captured-output cap (default 64 KiB) | no | | `AUTH_ADMIN_EMAILS` | comma-separated emails promoted to auth admin on boot | no | | `USER_RATE_LIMIT_PER_MINUTE` / `API_KEY_RATE_LIMIT_PER_MINUTE` | per-identity API rate limits (defaults 240 / 600) | no | | `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASSWORD` / `SMTP_SECURE` / `MAIL_FROM` | transactional mail; without it verification/reset emails go nowhere | production | @@ -140,11 +141,20 @@ variables above (AWS, MinIO, Garage, R2 all work). ## Functions trust model -Function code runs as a child process of the backend — same host, same -privileges, no sandbox. Treat every project member who can edit a function -as holding a shell on the backend host. If that is unacceptable for your -deployment, do not enable untrusted editors; isolated runtimes are on the -roadmap. +Two drivers, two trust levels: + +- **`exec` (default)** — function code runs as a child process of the + backend: same host, same privileges, no sandbox. Treat every project + member who can edit a function as holding a shell on the backend host. + Appropriate for single-team self-hosted deployments. +- **`docker`** — each invoke runs in an ephemeral container: no network, + read-only rootfs, 128 MiB memory, half a CPU, 64 pids, non-root, all + capabilities dropped. Requires the Docker socket mounted into the + backend (commented volume in `docker-compose.yml`) — note the socket is + itself root-equivalent on the host, so this isolates *function code*, + not operators. Source code capped at 256 KiB. For hostile multi-tenant + workloads, a dedicated microVM runtime (Firecracker/gVisor) remains the + roadmap item. ## Observability diff --git a/apps/backend/internal/app/app.go b/apps/backend/internal/app/app.go index 2d6661b..a6b99cb 100644 --- a/apps/backend/internal/app/app.go +++ b/apps/backend/internal/app/app.go @@ -113,7 +113,13 @@ func Bootstrap(ctx context.Context) (*App, error) { if err != nil { return nil, fmt.Errorf("encryption key: %w", err) } - platform := services.NewPlatformService(repo, store, services.NewMailer(cfg), os.Getenv("VITE_APP_URL"), dbxClient, encryptor, logger, services.NewExecRunner(cfg.FunctionsRuntime, cfg.FunctionsTimeout, cfg.FunctionsMaxBytes)) + var fnRunner services.FunctionRunner + if cfg.FunctionsDriver == "docker" { + fnRunner = services.NewDockerRunner(cfg.FunctionsTimeout, cfg.FunctionsMaxBytes) + } else { + fnRunner = services.NewExecRunner(cfg.FunctionsRuntime, cfg.FunctionsTimeout, cfg.FunctionsMaxBytes) + } + platform := services.NewPlatformService(repo, store, services.NewMailer(cfg), os.Getenv("VITE_APP_URL"), dbxClient, encryptor, logger, fnRunner) settings := services.NewSettingsService(repo, encryptor) if cfg.Env == "production" { diff --git a/apps/backend/internal/config/config.go b/apps/backend/internal/config/config.go index f707540..231fa0f 100644 --- a/apps/backend/internal/config/config.go +++ b/apps/backend/internal/config/config.go @@ -48,8 +48,11 @@ type Config struct { S3Prefix string S3PathStyle bool - // Functions runtime: binary name/path ("auto" resolves per-function - // runtime from PATH — "bun" or "deno"), timeout and output cap. + // Functions runtime: driver "exec" (runtime binary on the backend host) + // or "docker" (ephemeral locked-down container), binary name/path for + // exec ("auto" resolves per-function runtime from PATH), timeout and + // output cap. + FunctionsDriver string FunctionsRuntime string FunctionsTimeout time.Duration FunctionsMaxBytes int64 @@ -89,6 +92,7 @@ func Load() (Config, error) { S3SecretAccessKey: os.Getenv("S3_SECRET_ACCESS_KEY"), S3PublicEndpoint: os.Getenv("S3_PUBLIC_ENDPOINT"), S3Prefix: os.Getenv("S3_PREFIX"), + FunctionsDriver: getenv("FUNCTIONS_DRIVER", "exec"), FunctionsRuntime: getenv("FUNCTIONS_RUNTIME", "auto"), FunctionsMaxBytes: 65536, } @@ -135,6 +139,12 @@ func Load() (Config, error) { } cfg.FunctionsTimeout = time.Duration(fnTimeout) * time.Second + switch cfg.FunctionsDriver { + case "exec", "docker": + default: + return Config{}, fmt.Errorf("FUNCTIONS_DRIVER %q must be exec or docker", cfg.FunctionsDriver) + } + var missing []string if cfg.DatabaseURL == "" { missing = append(missing, "DATABASE_URL") diff --git a/apps/backend/internal/services/function_docker.go b/apps/backend/internal/services/function_docker.go new file mode 100644 index 0000000..83cd030 --- /dev/null +++ b/apps/backend/internal/services/function_docker.go @@ -0,0 +1,137 @@ +package services + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "os/exec" + "strings" + "time" +) + +// dockerRunner executes functions in an ephemeral, locked-down container: +// no network, read-only rootfs, memory/CPU/pid caps, non-root, all +// capabilities dropped. The source is passed through the environment as +// base64 and materialized inside the container's tmpfs — no bind mount, +// so this works on Docker Desktop (VM daemon) and remote daemons where +// host paths aren't shared. The payload arrives on stdin — same contract +// as execRunner, but user code no longer inherits backend privileges. +// Requires the Docker socket (compose: mount /var/run/docker.sock) — the +// documented trade-off: socket access is root-equivalent on the host, so +// this driver isolates function code, not operators. +type dockerRunner struct { + timeout time.Duration + maxOutput int64 + images map[string]string // runtime -> image ref +} + +// maxFnSourceBytes caps function source for the docker driver — the source +// travels in the container spec (env), which kernels limit to ~2 MB total; +// 256 KiB keeps argv+env comfortably inside it. +const maxFnSourceBytes = 256 << 10 + +func NewDockerRunner(timeout time.Duration, maxOutput int64) *dockerRunner { + return &dockerRunner{ + timeout: timeout, + maxOutput: maxOutput, + images: map[string]string{ + "bun": "oven/bun:1.3.14", + "deno": "denoland/deno:2.5.6", + }, + } +} + +func (r *dockerRunner) Run(ctx context.Context, runtime, code string, payload json.RawMessage) FunctionResult { + image, ok := r.images[runtime] + if !ok { + return FunctionResult{Status: "error", Stderr: "no container image configured for runtime " + runtime} + } + if len(code) > maxFnSourceBytes { + return FunctionResult{Status: "error", Stderr: fmt.Sprintf("function source exceeds %d KiB docker-driver limit", maxFnSourceBytes>>10)} + } + if _, err := exec.LookPath("docker"); err != nil { + return FunctionResult{Status: "error", Stderr: "FUNCTIONS_DRIVER=docker but docker CLI not found"} + } + + ctx, cancel := context.WithTimeout(ctx, r.timeout) + defer cancel() + + // Named container so a timed-out client doesn't orphan a running fn. + var suffix [4]byte + _, _ = rand.Read(suffix[:]) + name := "primora-fn-" + hex.EncodeToString(suffix[:]) + + // Write source inside the container, then run it. base64 keeps the + // code inert data — it is decoded by the shell, never interpreted. + entrypoint := fmt.Sprintf( + `echo "$PRIMORA_CODE" | base64 -d > /tmp/fn.ts && %s /tmp/fn.ts`, + map[bool]string{true: "deno run --no-prompt", false: "bun run"}[runtime == "deno"], + ) + + args := []string{ + "run", "--rm", "-i", "--name", name, + "--network", "none", + "--memory", "128m", "--memory-swap", "128m", + "--cpus", "0.5", + "--pids-limit", "64", + "--read-only", + "--tmpfs", "/tmp:rw,noexec,nosuid,size=16m", + "--cap-drop", "ALL", + "--security-opt", "no-new-privileges", + "--user", "65534:65534", + "-e", "PRIMORA_CODE=" + base64.StdEncoding.EncodeToString([]byte(code)), + "-e", "PRIMORA_PAYLOAD=" + string(payload), + "-e", "PRIMORA_FUNCTION_RUNTIME=" + runtime, + "--entrypoint", "sh", + image, "-c", entrypoint, + } + + cmd := exec.CommandContext(ctx, "docker", args...) + cmd.Stdin = bytes.NewReader(payload) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &limitedWriter{w: &stdout, max: r.maxOutput} + cmd.Stderr = &limitedWriter{w: &stderr, max: r.maxOutput} + + // ctx cancel kills the docker CLI but the daemon keeps the container + // running — force-remove it so a stuck function can't linger. + done := make(chan struct{}) + go func() { + select { + case <-ctx.Done(): + _ = exec.Command("docker", "rm", "-f", name).Run() + case <-done: + } + }() + defer close(done) + + start := time.Now() + runErr := cmd.Run() + duration := time.Since(start).Milliseconds() + + res := FunctionResult{ + Status: "success", + Stdout: stdout.String(), + Stderr: stderr.String(), + DurationMs: duration, + } + if ctx.Err() == context.DeadlineExceeded { + res.Status = "timeout" + res.Stderr = strings.TrimSpace(res.Stderr + "\nexecution timed out") + return res + } + if runErr != nil { + res.Status = "error" + if exitErr, ok := runErr.(*exec.ExitError); ok { + code := int32(exitErr.ExitCode()) + res.ExitCode = &code + } else { + res.Stderr = strings.TrimSpace(fmt.Sprintf("%v\n%s", runErr, res.Stderr)) + } + } + return res +} diff --git a/apps/backend/internal/services/function_docker_test.go b/apps/backend/internal/services/function_docker_test.go new file mode 100644 index 0000000..dbbd2a0 --- /dev/null +++ b/apps/backend/internal/services/function_docker_test.go @@ -0,0 +1,59 @@ +package services + +import ( + "context" + "encoding/json" + "os/exec" + "strings" + "testing" + "time" +) + +func dockerAvailable(t *testing.T) { + t.Helper() + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker CLI not installed") + } + if err := exec.Command("docker", "info").Run(); err != nil { + t.Skip("docker daemon not reachable") + } + // The test pulls the runtime image on first run. + if err := exec.Command("docker", "image", "inspect", "oven/bun:1.3.14").Run(); err != nil { + t.Skip("oven/bun:1.3.14 image not pulled") + } +} + +func TestDockerRunnerRunsCode(t *testing.T) { + dockerAvailable(t) + r := NewDockerRunner(30*time.Second, 65536) + res := r.Run(context.Background(), "bun", + `const p = JSON.parse(await new Response(Bun.stdin.stream()).text()); console.log("hi " + p.name);`, + json.RawMessage(`{"name":"sandbox"}`)) + if res.Status != "success" { + t.Fatalf("status = %q stderr = %q", res.Status, res.Stderr) + } + if !strings.Contains(res.Stdout, "hi sandbox") { + t.Fatalf("stdout = %q", res.Stdout) + } +} + +func TestDockerRunnerIsolatesNetwork(t *testing.T) { + dockerAvailable(t) + r := NewDockerRunner(30*time.Second, 65536) + // --network none: even a DNS/connect attempt must fail inside. + res := r.Run(context.Background(), "bun", + `try { await fetch("http://169.254.169.254/"); console.log("NET-OPEN") } catch { console.log("NET-CLOSED") }`, + json.RawMessage(`{}`)) + if !strings.Contains(res.Stdout, "NET-CLOSED") { + t.Fatalf("network should be unreachable, stdout = %q stderr = %q status = %q", res.Stdout, res.Stderr, res.Status) + } +} + +func TestDockerRunnerTimesOut(t *testing.T) { + dockerAvailable(t) + r := NewDockerRunner(2*time.Second, 65536) + res := r.Run(context.Background(), "bun", `while (true) {}`, json.RawMessage(`{}`)) + if res.Status != "timeout" { + t.Fatalf("status = %q", res.Status) + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 48fb91b..1ec24ae 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -48,6 +48,10 @@ services: condition: service_started volumes: - backend_storage:/data/storage + # Uncomment for FUNCTIONS_DRIVER=docker — sibling containers for + # sandboxed function runs. Socket access is root-equivalent on the + # host; leave commented unless you run untrusted function code. + # - /var/run/docker.sock:/var/run/docker.sock healthcheck: test: ["CMD-SHELL", "curl -fsS http://localhost:8080/api/v1/health/liveness || exit 1"] interval: 15s