Skip to content
Open
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
144 changes: 110 additions & 34 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,11 @@ type SystemUserConfig struct {

// AutoLifecycleConfig controls automatic sandbox lifecycle transitions
type AutoLifecycleConfig struct {
Enabled bool
PauseAfterIdleSec int // auto-pause after N seconds of inactivity (default: 60)
StopAfterPausedSec int // auto-stop after N seconds of being paused (default: 900)
DeleteAfterStoppedSec int // auto-delete after N seconds of being stopped (default: 604800)
CheckIntervalSec int // how often the manager scans (default: 30)
Enabled bool
SnapshotAfterIdleSec int // auto-snapshot after N seconds of inactivity (default: 60)
DeleteAfterSnapshottedSec int // auto-delete after N seconds of being snapshotted (default: 604800)
CheckIntervalSec int // how often the manager scans (default: 30)
Concurrency int // max concurrent snapshot/delete operations (default: 10)
}

// Config holds all application configuration
Expand Down Expand Up @@ -114,6 +114,13 @@ type SandboxConfig struct {
DefaultHostname string
DiskFormat string
Seccomp bool
BalloonEnabled bool
MemoryShared bool // sparse snapshot dump when true (shared=on)
MemoryHugepages bool // host hugetlbfs must be configured
MemoryPrefault bool // touch all pages at boot/restore
MemoryRestoreMode string // "auto", "copy", or "ondemand"
DecoupledSnapshot bool // metadata-only snapshot + external RAM file
MemoryBackingMode string // "legacy", "shared-shm", or "private-tmpfs"
}

// Health monitor configuration
Expand Down Expand Up @@ -167,25 +174,35 @@ const (
DefaultMongoDB = "vr-db"
DefaultJWTSecret = "change-me-in-production"
// Clerk defaults
DefaultClerkSecretKey = ""
DefaultClerkPublishableKey = ""
DefaultClerkJWKSURL = ""
DefaultClerkEnabled = false
DefaultLocalMode = false
DefaultSystemUserName = "System"
DefaultSystemUserEmail = "system@local"
DefaultSandboxVCPUs = 1
DefaultSandboxMemoryMB = 1024
DefaultSandboxDiskMB = 5120 // 5GB
DefaultSandboxImage = "code"
DefaultSandboxKernelCmdline = "root=/dev/vda rw init=/sbin/init net.ifnames=0 biosdevname=0"
DefaultSandboxSyncTimeoutSec = 10
DefaultSandboxDebugBootConsole = false
DefaultOverlayImage = "overlay.qcow2"
DefaultSandboxHostname = "voidrun"
DefaultAuthLocalMode = false
DefaultSandboxDiskFormat = "qcow2"
DefaultSandboxSeccomp = true
DefaultClerkSecretKey = ""
DefaultClerkPublishableKey = ""
DefaultClerkJWKSURL = ""
DefaultClerkEnabled = false
DefaultLocalMode = false
DefaultSystemUserName = "System"
DefaultSystemUserEmail = "system@local"
DefaultSandboxVCPUs = 1
DefaultSandboxMemoryMB = 1024
DefaultSandboxDiskMB = 5120 // 5GB
DefaultSandboxImage = "code"
DefaultSandboxKernelCmdline = "root=/dev/vda rw init=/sbin/init net.ifnames=0 biosdevname=0"
DefaultSandboxSyncTimeoutSec = 10
DefaultSandboxDebugBootConsole = false
DefaultOverlayImage = "overlay.qcow2"
DefaultSandboxHostname = "voidrun"
DefaultAuthLocalMode = false
DefaultSandboxDiskFormat = "qcow2"
DefaultSandboxSeccomp = true
DefaultSandboxBalloonEnabled = true
DefaultSandboxMemoryShared = false
DefaultSandboxMemoryHugepages = false
DefaultSandboxMemoryPrefault = false
DefaultSandboxMemoryRestoreMode = "auto"
DefaultSandboxDecoupledSnapshot = false
DefaultSandboxMemoryBackingMode = "legacy"
MemBackingLegacy = "legacy"
MemBackingSharedShm = "shared-shm"
MemBackingPrivateTmpfs = "private-tmpfs"
// Health monitor defaults
DefaultHealthEnabled = true
DefaultHealthIntervalSec = 60
Expand Down Expand Up @@ -214,11 +231,11 @@ const (
DefaultRedisPassword = ""
DefaultRedisDB = 0
// Auto-lifecycle defaults
DefaultAutoLifecycleEnabled = true
DefaultAutoLifecyclePauseAfterIdleSec = 60 // 1 minute
DefaultAutoLifecycleStopAfterPausedSec = 300 // 5 minutes
DefaultAutoLifecycleDeleteAfterStoppedSec = 604800 // 1 week
DefaultAutoLifecycleCheckIntervalSec = 30 // 30 seconds
DefaultAutoLifecycleEnabled = true
DefaultAutoLifecycleSnapshotAfterIdleSec = 60 // 1 minute
DefaultAutoLifecycleDeleteAfterSnapshottedSec = 604800 // 1 week
DefaultAutoLifecycleCheckIntervalSec = 30 // 30 seconds
DefaultAutoLifecycleConcurrency = 10
// Monitor defaults
DefaultMonitorEnabled = true
// Pagination defaults
Expand Down Expand Up @@ -294,6 +311,13 @@ func New() *Config {
DefaultHostname: getEnv("SANDBOX_DEFAULT_HOSTNAME", DefaultSandboxHostname),
DiskFormat: getEnv("SANDBOX_DISK_FORMAT", DefaultSandboxDiskFormat),
Seccomp: getEnvBool("SANDBOX_SECCOMP", DefaultSandboxSeccomp),
BalloonEnabled: getEnvBool("SANDBOX_BALLOON_ENABLED", DefaultSandboxBalloonEnabled),
MemoryShared: getEnvBool("SANDBOX_MEMORY_SHARED", DefaultSandboxMemoryShared),
MemoryHugepages: getEnvBool("SANDBOX_MEMORY_HUGEPAGES", DefaultSandboxMemoryHugepages),
MemoryPrefault: getEnvBool("SANDBOX_MEMORY_PREFAULT", DefaultSandboxMemoryPrefault),
MemoryRestoreMode: getEnv("SANDBOX_MEMORY_RESTORE_MODE", DefaultSandboxMemoryRestoreMode),
DecoupledSnapshot: getEnvBool("SANDBOX_DECOUPLED_SNAPSHOT", DefaultSandboxDecoupledSnapshot),
MemoryBackingMode: getEnv("SANDBOX_MEMORY_BACKING_MODE", DefaultSandboxMemoryBackingMode),
},
Health: HealthConfig{
Enabled: getEnvBool("HEALTH_ENABLED", DefaultHealthEnabled),
Expand All @@ -317,11 +341,11 @@ func New() *Config {
MaxAgeSec: getEnvInt("CORS_MAX_AGE_SEC", DefaultCORSMaxAgeSec),
},
AutoLifecycle: AutoLifecycleConfig{
Enabled: getEnvBool("AUTO_LIFECYCLE_ENABLED", DefaultAutoLifecycleEnabled),
PauseAfterIdleSec: getEnvInt("AUTO_LIFECYCLE_PAUSE_AFTER_IDLE_SEC", DefaultAutoLifecyclePauseAfterIdleSec),
StopAfterPausedSec: getEnvInt("AUTO_LIFECYCLE_STOP_AFTER_PAUSED_SEC", DefaultAutoLifecycleStopAfterPausedSec),
DeleteAfterStoppedSec: getEnvInt("AUTO_LIFECYCLE_DELETE_AFTER_STOPPED_SEC", DefaultAutoLifecycleDeleteAfterStoppedSec),
CheckIntervalSec: getEnvInt("AUTO_LIFECYCLE_CHECK_INTERVAL_SEC", DefaultAutoLifecycleCheckIntervalSec),
Enabled: getEnvBool("AUTO_LIFECYCLE_ENABLED", DefaultAutoLifecycleEnabled),
SnapshotAfterIdleSec: getEnvInt("AUTO_LIFECYCLE_SNAPSHOT_AFTER_IDLE_SEC", DefaultAutoLifecycleSnapshotAfterIdleSec),
DeleteAfterSnapshottedSec: getEnvInt("AUTO_LIFECYCLE_DELETE_AFTER_SNAPSHOTTED_SEC", DefaultAutoLifecycleDeleteAfterSnapshottedSec),
CheckIntervalSec: getEnvInt("AUTO_LIFECYCLE_CHECK_INTERVAL_SEC", DefaultAutoLifecycleCheckIntervalSec),
Concurrency: getEnvInt("AUTO_LIFECYCLE_CONCURRENCY", DefaultAutoLifecycleConcurrency),
},
Monitor: MonitorConfig{
Enabled: getEnvBool("MONITOR_ENABLED", DefaultMonitorEnabled),
Expand All @@ -341,9 +365,61 @@ func New() *Config {
log.Fatalf("Network.Prefix (NET_PREFIX) must be 4 characters or fewer, got %d chars: %s", len(c.Network.Prefix), c.Network.Prefix)
}

// Validate DNS_NAMESERVERS strictly: these values are interpolated verbatim
// into the per-sandbox iptables-restore ruleset (see runtime/network.go).
// An invalid or attacker-shaped value (newline, CIDR, blank, etc.) would
// either break sandbox networking or weaken egress isolation fleet-wide.
if err := validateNameservers(c.Network.Nameservers); err != nil {
log.Fatalf("DNS_NAMESERVERS invalid: %v", err)
}

if err := validateMemoryBackingMode(c.Sandbox.MemoryBackingMode, c.Sandbox.DecoupledSnapshot); err != nil {
log.Fatalf("SANDBOX_MEMORY_BACKING_MODE invalid: %v", err)
}

return c
}

// validateMemoryBackingMode rejects unknown modes; decoupled snapshot needs file-backed RAM.
func validateMemoryBackingMode(mode string, decoupled bool) error {
switch mode {
case MemBackingLegacy, MemBackingSharedShm, MemBackingPrivateTmpfs:
default:
return fmt.Errorf("unknown backing mode %q (want one of %q,%q,%q)",
mode, MemBackingLegacy, MemBackingSharedShm, MemBackingPrivateTmpfs)
}
if decoupled && mode == MemBackingLegacy {
return fmt.Errorf("SANDBOX_DECOUPLED_SNAPSHOT=true requires SANDBOX_MEMORY_BACKING_MODE=%q or %q, got %q",
MemBackingSharedShm, MemBackingPrivateTmpfs, mode)
}
return nil
}

// validateNameservers enforces that each entry is a single, well-formed,
// public unicast IP literal. It rejects CIDRs, blank entries, multicast,
// loopback, link-local, private-range, and unspecified addresses so a
// misconfigured env var cannot silently broaden sandbox egress.
func validateNameservers(nameservers []string) error {
if len(nameservers) == 0 {
return fmt.Errorf("at least one nameserver is required")
}
for _, ns := range nameservers {
if ns != strings.TrimSpace(ns) || ns == "" {
return fmt.Errorf("nameserver %q must be a non-empty, trimmed IP literal", ns)
}
ip := net.ParseIP(ns)
if ip == nil {
return fmt.Errorf("nameserver %q is not a valid IP literal (CIDRs and hostnames are not allowed)", ns)
}
if ip.IsUnspecified() || ip.IsLoopback() || ip.IsMulticast() ||
ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
ip.IsPrivate() {
return fmt.Errorf("nameserver %q must be a public unicast address", ns)
}
}
return nil
}

// Address returns the server address string
func (c *ServerConfig) Address() string {
return c.Host + ":" + c.Port
Expand Down
5 changes: 3 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ toolchain go1.24.11

require (
github.com/3th1nk/cidr v0.3.0
github.com/cenkalti/backoff/v4 v4.3.0
github.com/clerk/clerk-sdk-go/v2 v2.5.1
github.com/gorilla/websocket v1.5.1
github.com/joho/godotenv v1.5.1
Expand All @@ -14,6 +15,7 @@ require (
github.com/vishvananda/netlink v1.3.1
go.mongodb.org/mongo-driver v1.16.1
golang.org/x/crypto v0.46.0
golang.org/x/sync v0.19.0
)

require (
Expand Down Expand Up @@ -60,7 +62,6 @@ require (
go.uber.org/mock v0.6.0 // indirect
golang.org/x/arch v0.23.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/text v0.33.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)
Expand All @@ -70,5 +71,5 @@ require (
github.com/gin-gonic/gin v1.11.0
github.com/prometheus/client_golang v1.20.5
github.com/vishvananda/netns v0.0.5
golang.org/x/sys v0.41.0 // indirect
golang.org/x/sys v0.41.0
)
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPII
github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980=
github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o=
github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/clerk/clerk-sdk-go/v2 v2.5.1 h1:RsakGNW6ie83b9KIRtKzqDXBJ//cURy9SJUbGhrsIKg=
Expand Down
23 changes: 4 additions & 19 deletions handler/handler_util.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import (
"voidrun/util"

"github.com/gin-gonic/gin"
"go.mongodb.org/mongo-driver/bson/primitive"
)

// HandlerFunc is like gin.HandlerFunc but returns an error.
Expand All @@ -36,38 +35,24 @@ func Handle(fn HandlerFunc) gin.HandlerFunc {
}
}

// ensureSandboxRunning validates the org auth context, checks the sandbox is
// running, and fires a background TouchActivity call.
func ensureSandboxRunning(
c *gin.Context,
sandboxSvc *service.SandboxService,
sandboxID string,
) error {
_, err := ensureSandboxRunningWithOrg(c, sandboxSvc, sandboxID)
return err
}

// ensureSandboxRunningWithOrg is the same as ensureSandboxRunning but also
// returns the resolved orgID for callers that need it.
func ensureSandboxRunningWithOrg(
c *gin.Context,
sandboxSvc *service.SandboxService,
sandboxID string,
) (primitive.ObjectID, error) {
orgID, err := util.GetOrgIDFromContext(c)
if err != nil {
return primitive.NilObjectID, err
return err
}


if err = sandboxSvc.EnsureRunning(c.Request.Context(), orgID, sandboxID); err != nil {
return primitive.NilObjectID, util.ErrNotFound(err.Error())
return util.ErrNotFound(err.Error())
}

// Touch activity for auto-pause tracking (async, fire-and-forget)
go sandboxSvc.TouchActivity(c.Request.Context(), orgID, sandboxID)
go sandboxSvc.TouchActivity(c.Request.Context(), sandboxID)

return orgID, nil
return nil
}

// HandleJSONResponse proxies the agent HTTP response back to the client in our
Expand Down
6 changes: 6 additions & 0 deletions handler/pty.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ var wsUpgrader = websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { re
func (h *PTYHandler) Proxy(c *gin.Context) error {
sbxInstance := c.Param("id")

id := c.Param("id")

if err := ensureSandboxRunning(c, h.sandboxService, id); err != nil {
return err
}

clientConn, err := wsUpgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
// Upgrader already wrote an HTTP error response; WriteError will no-op.
Expand Down
39 changes: 12 additions & 27 deletions handler/sandbox.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,62 +126,47 @@ func (h *SandboxHandler) Delete(c *gin.Context) error {
return nil
}

func (h *SandboxHandler) Start(c *gin.Context) error {
func (h *SandboxHandler) Snapshot(c *gin.Context) error {
id := c.Param("id")

orgID, err := util.GetOrgIDFromContext(c)
if err != nil {
return err
}

if err := h.sandboxService.Start(c.Request.Context(), orgID, id); err != nil {
return util.ErrInternal("Start failed", err)
if err := h.sandboxService.Snapshot(c.Request.Context(), orgID, id); err != nil {
return util.ErrInternal("Snapshot failed", err)
}
c.JSON(http.StatusOK, model.NewSuccessResponse("Sandbox started", nil))
c.JSON(http.StatusOK, model.NewSuccessResponse("Sandbox snapshotted", nil))
return nil
}

func (h *SandboxHandler) Stop(c *gin.Context) error {
func (h *SandboxHandler) Restore(c *gin.Context) error {
id := c.Param("id")

orgID, err := util.GetOrgIDFromContext(c)
if err != nil {
return err
}

if err := h.sandboxService.Stop(c.Request.Context(), orgID, id); err != nil {
return util.ErrInternal("Stop failed", err)
if err := h.sandboxService.Restore(c.Request.Context(), orgID, id); err != nil {
return util.ErrInternal("Restore failed", err)
}
c.JSON(http.StatusOK, model.NewSuccessResponse("Sandbox stopped", nil))
c.JSON(http.StatusOK, model.NewSuccessResponse("Sandbox restored", nil))
return nil
}

func (h *SandboxHandler) Pause(c *gin.Context) error {
id := c.Param("id")

orgID, err := util.GetOrgIDFromContext(c)
if err != nil {
return err
}

if err := h.sandboxService.Pause(c.Request.Context(), orgID, id); err != nil {
return util.ErrInternal("Pause failed", err)
}
c.JSON(http.StatusOK, model.NewSuccessResponse("Sandbox paused", nil))
return nil
}

func (h *SandboxHandler) Resume(c *gin.Context) error {
func (h *SandboxHandler) Start(c *gin.Context) error {
id := c.Param("id")

orgID, err := util.GetOrgIDFromContext(c)
if err != nil {
return err
}

if err := h.sandboxService.Resume(c.Request.Context(), orgID, id); err != nil {
return util.ErrInternal("Resume failed", err)
if err := h.sandboxService.Start(c.Request.Context(), orgID, id); err != nil {
return util.ErrInternal("Start failed", err)
}
c.JSON(http.StatusOK, model.NewSuccessResponse("Sandbox resumed", nil))
c.JSON(http.StatusOK, model.NewSuccessResponse("Sandbox started", nil))
return nil
}
2 changes: 1 addition & 1 deletion mcp/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ func (h *Handlers) ensureRunning(ctx context.Context, orgID primitive.ObjectID,
if err := h.SandboxService.EnsureRunning(ctx, orgID, sandboxID); err != nil {
return err
}
go h.SandboxService.TouchActivity(ctx, orgID, sandboxID)
go h.SandboxService.TouchActivity(ctx, sandboxID)
return nil
}

Expand Down
Loading