Files
go-llm/v2/sandbox/sandbox.go
Steve Dudenhoeffer 23c9068022
All checks were successful
CI / V2 Module (push) Successful in 11m46s
CI / Root Module (push) Successful in 11m50s
CI / Lint (push) Successful in 9m28s
Add sandbox package for isolated Linux containers via Proxmox LXC
Provides a complete lifecycle manager for ephemeral sandbox environments:
- ProxmoxClient: thin REST wrapper for container CRUD, IP discovery, internet toggle
- SSHExecutor: persistent SSH/SFTP for command execution and file transfer
- Manager/Sandbox: high-level orchestrator tying Proxmox + SSH together
- 22 unit tests with mock Proxmox HTTP server
- Proxmox setup & hardening guide (docs/sandbox-setup.md)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 00:47:45 -05:00

311 lines
8.1 KiB
Go

package sandbox
import (
"context"
"fmt"
"io"
"os"
"strings"
"time"
"golang.org/x/crypto/ssh"
)
// Config holds all configuration for creating sandboxes.
type Config struct {
Proxmox ProxmoxConfig
SSH SSHConfig
Defaults ContainerConfig
}
// Option configures a Sandbox before creation.
type Option func(*createOpts)
type createOpts struct {
hostname string
cpus int
memoryMB int
diskGB int
internet bool
}
// WithHostname sets the container hostname.
func WithHostname(name string) Option {
return func(o *createOpts) { o.hostname = name }
}
// WithCPUs sets the number of CPU cores for the container.
func WithCPUs(n int) Option {
return func(o *createOpts) { o.cpus = n }
}
// WithMemoryMB sets the memory limit in megabytes.
func WithMemoryMB(mb int) Option {
return func(o *createOpts) { o.memoryMB = mb }
}
// WithDiskGB sets the root filesystem size in gigabytes.
func WithDiskGB(gb int) Option {
return func(o *createOpts) { o.diskGB = gb }
}
// WithInternet enables outbound HTTP/HTTPS access on creation.
func WithInternet(enabled bool) Option {
return func(o *createOpts) { o.internet = enabled }
}
// Sandbox represents an isolated Linux container environment with SSH access.
// It wraps a Proxmox LXC container and provides command execution and file operations.
type Sandbox struct {
// ID is the Proxmox VMID of this container.
ID int
// IP is the container's IP address on the isolated bridge.
IP string
// Internet indicates whether outbound HTTP/HTTPS is enabled.
Internet bool
proxmox *ProxmoxClient
ssh *SSHExecutor
}
// Manager creates and manages sandbox instances.
type Manager struct {
proxmox *ProxmoxClient
sshKey ssh.Signer
defaults ContainerConfig
sshCfg SSHConfig
}
// NewManager creates a new sandbox manager from the given configuration.
func NewManager(cfg Config) (*Manager, error) {
if cfg.SSH.Signer == nil {
return nil, fmt.Errorf("SSH signer is required")
}
return &Manager{
proxmox: NewProxmoxClient(cfg.Proxmox),
sshKey: cfg.SSH.Signer,
defaults: cfg.Defaults,
sshCfg: cfg.SSH,
}, nil
}
// Create provisions a new sandbox container: clones the template, starts it,
// waits for SSH, and optionally enables internet access.
// The returned Sandbox must be destroyed with Destroy when no longer needed.
func (m *Manager) Create(ctx context.Context, opts ...Option) (*Sandbox, error) {
o := &createOpts{
hostname: m.defaults.Hostname,
cpus: m.defaults.CPUs,
memoryMB: m.defaults.MemoryMB,
diskGB: m.defaults.DiskGB,
}
for _, opt := range opts {
opt(o)
}
// Apply defaults for zero values.
if o.cpus <= 0 {
o.cpus = 1
}
if o.memoryMB <= 0 {
o.memoryMB = 1024
}
if o.diskGB <= 0 {
o.diskGB = 8
}
// Get next VMID.
vmid, err := m.proxmox.NextAvailableID(ctx)
if err != nil {
return nil, fmt.Errorf("get next VMID: %w", err)
}
containerCfg := ContainerConfig{
Hostname: o.hostname,
CPUs: o.cpus,
MemoryMB: o.memoryMB,
DiskGB: o.diskGB,
}
// Clone template.
if err := m.proxmox.CloneTemplate(ctx, vmid, containerCfg); err != nil {
return nil, fmt.Errorf("clone template: %w", err)
}
// Configure container resources.
if err := m.proxmox.ConfigureContainer(ctx, vmid, containerCfg); err != nil {
// Clean up the cloned container on failure.
_ = m.proxmox.DestroyContainer(ctx, vmid)
return nil, fmt.Errorf("configure container: %w", err)
}
// Start container.
if err := m.proxmox.StartContainer(ctx, vmid); err != nil {
_ = m.proxmox.DestroyContainer(ctx, vmid)
return nil, fmt.Errorf("start container: %w", err)
}
// Discover IP address (with timeout).
ipCtx, ipCancel := context.WithTimeout(ctx, 30*time.Second)
defer ipCancel()
ip, err := m.proxmox.GetContainerIP(ipCtx, vmid)
if err != nil {
_ = m.proxmox.DestroyContainer(ctx, vmid)
return nil, fmt.Errorf("discover IP: %w", err)
}
// Connect SSH (with timeout).
sshExec := NewSSHExecutor(ip, m.sshCfg)
sshCtx, sshCancel := context.WithTimeout(ctx, 30*time.Second)
defer sshCancel()
if err := sshExec.Connect(sshCtx); err != nil {
_ = m.proxmox.DestroyContainer(ctx, vmid)
return nil, fmt.Errorf("ssh connect: %w", err)
}
sb := &Sandbox{
ID: vmid,
IP: ip,
proxmox: m.proxmox,
ssh: sshExec,
}
// Enable internet if requested.
if o.internet {
if err := sb.SetInternet(ctx, true); err != nil {
sb.Destroy(ctx)
return nil, fmt.Errorf("enable internet: %w", err)
}
}
return sb, nil
}
// Attach reconnects to an existing sandbox container by VMID.
// This is useful for recovering sessions after a restart.
func (m *Manager) Attach(ctx context.Context, vmid int) (*Sandbox, error) {
status, err := m.proxmox.GetContainerStatus(ctx, vmid)
if err != nil {
return nil, fmt.Errorf("get container status: %w", err)
}
if status.Status != "running" {
return nil, fmt.Errorf("container %d is not running (status: %s)", vmid, status.Status)
}
ip, err := m.proxmox.GetContainerIP(ctx, vmid)
if err != nil {
return nil, fmt.Errorf("get container IP: %w", err)
}
sshExec := NewSSHExecutor(ip, m.sshCfg)
if err := sshExec.Connect(ctx); err != nil {
return nil, fmt.Errorf("ssh connect: %w", err)
}
return &Sandbox{
ID: vmid,
IP: ip,
proxmox: m.proxmox,
ssh: sshExec,
}, nil
}
// Exec runs a shell command in the sandbox and returns the result.
func (s *Sandbox) Exec(ctx context.Context, command string) (ExecResult, error) {
return s.ssh.Exec(ctx, command)
}
// WriteFile creates or overwrites a file in the sandbox.
func (s *Sandbox) WriteFile(ctx context.Context, path, content string) error {
return s.ssh.Upload(ctx, strings.NewReader(content), path, 0644)
}
// ReadFile reads a file from the sandbox and returns its contents.
func (s *Sandbox) ReadFile(ctx context.Context, path string) (string, error) {
rc, err := s.ssh.Download(ctx, path)
if err != nil {
return "", err
}
defer rc.Close()
data, err := io.ReadAll(rc)
if err != nil {
return "", fmt.Errorf("read file %s: %w", path, err)
}
return string(data), nil
}
// Upload copies data from an io.Reader to a file in the sandbox.
func (s *Sandbox) Upload(ctx context.Context, reader io.Reader, remotePath string, mode os.FileMode) error {
return s.ssh.Upload(ctx, reader, remotePath, mode)
}
// Download returns an io.ReadCloser for a file in the sandbox.
// The caller must close the returned reader.
func (s *Sandbox) Download(ctx context.Context, remotePath string) (io.ReadCloser, error) {
return s.ssh.Download(ctx, remotePath)
}
// SetInternet enables or disables outbound HTTP/HTTPS access for the sandbox.
func (s *Sandbox) SetInternet(ctx context.Context, enabled bool) error {
if enabled {
if err := s.proxmox.EnableInternet(ctx, s.IP); err != nil {
return err
}
} else {
if err := s.proxmox.DisableInternet(ctx, s.IP); err != nil {
return err
}
}
s.Internet = enabled
return nil
}
// Status returns the current resource usage of the sandbox container.
func (s *Sandbox) Status(ctx context.Context) (ContainerStatus, error) {
return s.proxmox.GetContainerStatus(ctx, s.ID)
}
// IsConnected returns true if the SSH connection to the sandbox is active.
func (s *Sandbox) IsConnected() bool {
return s.ssh.IsConnected()
}
// Destroy stops the container, removes internet access, closes SSH connections,
// and permanently deletes the container from Proxmox.
func (s *Sandbox) Destroy(ctx context.Context) error {
var errs []error
// Remove internet access first (ignore errors — container is being destroyed).
if s.Internet {
_ = s.proxmox.DisableInternet(ctx, s.IP)
}
// Close SSH connections.
if err := s.ssh.Close(); err != nil {
errs = append(errs, fmt.Errorf("close ssh: %w", err))
}
// Destroy the container.
if err := s.proxmox.DestroyContainer(ctx, s.ID); err != nil {
errs = append(errs, fmt.Errorf("destroy container: %w", err))
}
if len(errs) > 0 {
return fmt.Errorf("destroy sandbox %d: %v", s.ID, errs)
}
return nil
}
// DestroyByID destroys a container by VMID without requiring an active SSH connection.
// This is useful for cleaning up orphaned containers after a restart.
func (m *Manager) DestroyByID(ctx context.Context, vmid int) error {
return m.proxmox.DestroyContainer(ctx, vmid)
}