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
66 changes: 51 additions & 15 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,7 @@ type SandboxConfig struct {
DefaultHostname string
DiskFormat string
Seccomp bool
BalloonEnabled bool
}

// Health monitor configuration
Expand Down Expand Up @@ -186,6 +187,7 @@ const (
DefaultAuthLocalMode = false
DefaultSandboxDiskFormat = "qcow2"
DefaultSandboxSeccomp = true
DefaultSandboxBalloonEnabled = true
// Health monitor defaults
DefaultHealthEnabled = true
DefaultHealthIntervalSec = 60
Expand Down Expand Up @@ -214,11 +216,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 +296,7 @@ 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),
},
Health: HealthConfig{
Enabled: getEnvBool("HEALTH_ENABLED", DefaultHealthEnabled),
Expand All @@ -317,11 +320,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 +344,42 @@ 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)
}

return c
}

// 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
3 changes: 2 additions & 1 deletion 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 Down
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
46 changes: 8 additions & 38 deletions handler/sandbox.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,62 +126,32 @@ 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))
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 {
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)
}
c.JSON(http.StatusOK, model.NewSuccessResponse("Sandbox resumed", nil))
c.JSON(http.StatusOK, model.NewSuccessResponse("Sandbox restored", 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
6 changes: 3 additions & 3 deletions mcp/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ func toolCreateSandbox() mcp.Tool {
mcp.Description("Unique name for the sandbox (DNS-1123 subdomain format: lowercase alphanumeric and hyphens)"),
),
mcp.WithString("image",
mcp.Description("Image name in name or name:ver form (e.g. code, max, docker). Defaults to code if omitted."),
mcp.Description("Image name in name or name:ver form (e.g. code, docker-lite, max, docker). Defaults to code if omitted."),
),
mcp.WithNumber("cpu",
mcp.Description("Number of vCPUs (1-8). Defaults to 1."),
Expand All @@ -58,7 +58,7 @@ func toolCreateSandbox() mcp.Tool {
mcp.Description("Environment variables for the sandbox (string map)."),
),
mcp.WithBoolean("autoSleep",
mcp.Description("If true, auto-pause the VM after idle time."),
mcp.Description("If true, auto-snapshot the VM after idle time."),
),
mcp.WithString("region",
mcp.Description("Target region when supported by your account."),
Expand Down Expand Up @@ -109,7 +109,7 @@ func toolDeleteSandbox() mcp.Tool {
func toolExecuteCommand() mcp.Tool {
return mcp.NewTool(
"execute_command",
mcp.WithDescription("Execute a shell command in a sandbox and return the output. The sandbox must be running (it will be auto-resumed if paused)."),
mcp.WithDescription("Execute a shell command in a sandbox and return the output. The sandbox must be running (it will be auto-restored if snapshotted)."),
mcp.WithString("id",
mcp.Required(),
mcp.Description("The sandbox ID"),
Expand Down
4 changes: 2 additions & 2 deletions model/sandbox.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,7 @@ type Sandbox struct {
Status string `bson:"status" json:"status"`
AutoSleep bool `bson:"autoSleep" json:"autoSleep"`
LastActivityAt *time.Time `bson:"lastActivityAt,omitempty" json:"-"`
PausedAt *time.Time `bson:"pausedAt,omitempty" json:"-"`
StoppedAt *time.Time `bson:"stoppedAt,omitempty" json:"-"`
SnapshottedAt *time.Time `bson:"snapshottedAt,omitempty" json:"-"`
CreatedAt time.Time `bson:"createdAt" json:"createdAt"`
CreatedBy primitive.ObjectID `bson:"createdBy" json:"createdBy"`
OrgID primitive.ObjectID `bson:"orgId" json:"orgId"`
Expand All @@ -28,6 +27,7 @@ type Sandbox struct {
RefID string `bson:"refId,omitempty" json:"refId,omitempty"`
TapName string `bson:"tapName,omitempty" json:"-"`
NetNSName string `bson:"netnsName,omitempty" json:"-"`
MacAddress string `bson:"macAddress,omitempty" json:"-"`
TapDeleted bool `bson:"tapDeleted,omitempty" json:"-"`
BillingCompleted bool `bson:"billingCompleted,omitempty" json:"-"`
}
Expand Down
Loading