Files
go-llm/v2/sandbox/sandbox_test.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

615 lines
16 KiB
Go

package sandbox
import (
"context"
"crypto/rand"
"crypto/rsa"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"golang.org/x/crypto/ssh"
)
// --- Proxmox API mock server ---
type mockProxmoxHandler struct {
containers map[int]ContainerStatus
nextID int
tasks map[string]string // taskID → exitstatus
}
func newMockProxmoxHandler() *mockProxmoxHandler {
return &mockProxmoxHandler{
containers: make(map[int]ContainerStatus),
nextID: 200,
tasks: make(map[string]string),
}
}
func (m *mockProxmoxHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
// Verify auth header is present.
auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, "PVEAPIToken=") {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
switch {
case path == "/api2/json/cluster/nextid" && r.Method == http.MethodGet:
m.handleNextID(w)
case strings.HasSuffix(path, "/clone") && r.Method == http.MethodPost:
m.handleClone(w, r)
case strings.HasSuffix(path, "/config") && r.Method == http.MethodPut:
m.handleConfig(w)
case strings.HasSuffix(path, "/status/start") && r.Method == http.MethodPost:
m.handleStart(w, r)
case strings.HasSuffix(path, "/status/stop") && r.Method == http.MethodPost:
m.handleStop(w, r)
case strings.HasSuffix(path, "/status/current") && r.Method == http.MethodGet:
m.handleStatusCurrent(w, r)
case strings.HasSuffix(path, "/interfaces") && r.Method == http.MethodGet:
m.handleInterfaces(w, r)
case strings.Contains(path, "/tasks/") && strings.HasSuffix(path, "/status"):
m.handleTaskStatus(w, r)
case r.Method == http.MethodDelete && strings.Contains(path, "/lxc/"):
m.handleDelete(w, r)
case strings.HasSuffix(path, "/execute") && r.Method == http.MethodPost:
m.handleExecute(w)
default:
http.Error(w, fmt.Sprintf("unhandled: %s %s", r.Method, path), http.StatusNotFound)
}
}
func (m *mockProxmoxHandler) handleNextID(w http.ResponseWriter) {
id := m.nextID
m.nextID++
json.NewEncoder(w).Encode(map[string]any{"data": id})
}
func (m *mockProxmoxHandler) handleClone(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
taskID := "UPID:pve:clone-task"
m.tasks[taskID] = "OK"
json.NewEncoder(w).Encode(map[string]any{"data": taskID})
}
func (m *mockProxmoxHandler) handleConfig(w http.ResponseWriter) {
json.NewEncoder(w).Encode(map[string]any{"data": nil})
}
func (m *mockProxmoxHandler) handleStart(w http.ResponseWriter, r *http.Request) {
// Extract VMID from path.
parts := strings.Split(r.URL.Path, "/")
for i, p := range parts {
if p == "lxc" && i+1 < len(parts) {
var vmid int
fmt.Sscanf(parts[i+1], "%d", &vmid)
m.containers[vmid] = ContainerStatus{Status: "running"}
break
}
}
taskID := "UPID:pve:start-task"
m.tasks[taskID] = "OK"
json.NewEncoder(w).Encode(map[string]any{"data": taskID})
}
func (m *mockProxmoxHandler) handleStop(w http.ResponseWriter, r *http.Request) {
parts := strings.Split(r.URL.Path, "/")
for i, p := range parts {
if p == "lxc" && i+1 < len(parts) {
var vmid int
fmt.Sscanf(parts[i+1], "%d", &vmid)
m.containers[vmid] = ContainerStatus{Status: "stopped"}
break
}
}
taskID := "UPID:pve:stop-task"
m.tasks[taskID] = "OK"
json.NewEncoder(w).Encode(map[string]any{"data": taskID})
}
func (m *mockProxmoxHandler) handleStatusCurrent(w http.ResponseWriter, r *http.Request) {
parts := strings.Split(r.URL.Path, "/")
for i, p := range parts {
if p == "lxc" && i+1 < len(parts) {
var vmid int
fmt.Sscanf(parts[i+1], "%d", &vmid)
status, ok := m.containers[vmid]
if !ok {
status = ContainerStatus{Status: "stopped"}
}
json.NewEncoder(w).Encode(map[string]any{"data": status})
return
}
}
http.Error(w, "not found", http.StatusNotFound)
}
func (m *mockProxmoxHandler) handleInterfaces(w http.ResponseWriter, r *http.Request) {
ifaces := []map[string]string{
{"name": "lo", "inet": "127.0.0.1/8"},
{"name": "eth0", "inet": "10.99.1.5/16", "hwaddr": "AA:BB:CC:DD:EE:FF"},
}
json.NewEncoder(w).Encode(map[string]any{"data": ifaces})
}
func (m *mockProxmoxHandler) handleTaskStatus(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]any{
"data": map[string]any{
"status": "stopped",
"exitstatus": "OK",
},
})
}
func (m *mockProxmoxHandler) handleDelete(w http.ResponseWriter, r *http.Request) {
parts := strings.Split(r.URL.Path, "/")
for i, p := range parts {
if p == "lxc" && i+1 < len(parts) {
var vmid int
fmt.Sscanf(parts[i+1], "%d", &vmid)
delete(m.containers, vmid)
break
}
}
taskID := "UPID:pve:delete-task"
m.tasks[taskID] = "OK"
json.NewEncoder(w).Encode(map[string]any{"data": taskID})
}
func (m *mockProxmoxHandler) handleExecute(w http.ResponseWriter) {
json.NewEncoder(w).Encode(map[string]any{"data": ""})
}
// --- Test helpers ---
func newTestProxmoxClient(t *testing.T, handler *mockProxmoxHandler) (*ProxmoxClient, *httptest.Server) {
t.Helper()
server := httptest.NewTLSServer(handler)
client := NewProxmoxClient(ProxmoxConfig{
BaseURL: server.URL,
TokenID: "test@pve!test-token",
Secret: "test-secret",
Node: "pve",
TemplateID: 9000,
Pool: "sandbox-pool",
Bridge: "vmbr1",
InsecureSkipVerify: true,
})
// Use the test server's TLS client.
client.http = server.Client()
return client, server
}
func generateTestSigner(t *testing.T) ssh.Signer {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("generate RSA key: %v", err)
}
signer, err := ssh.NewSignerFromKey(key)
if err != nil {
t.Fatalf("create signer: %v", err)
}
return signer
}
// --- Proxmox client tests ---
func TestProxmoxNextAvailableID(t *testing.T) {
handler := newMockProxmoxHandler()
client, server := newTestProxmoxClient(t, handler)
defer server.Close()
id, err := client.NextAvailableID(context.Background())
if err != nil {
t.Fatalf("NextAvailableID: %v", err)
}
if id != 200 {
t.Errorf("expected VMID 200, got %d", id)
}
// Second call should return 201.
id2, err := client.NextAvailableID(context.Background())
if err != nil {
t.Fatalf("NextAvailableID (2nd): %v", err)
}
if id2 != 201 {
t.Errorf("expected VMID 201, got %d", id2)
}
}
func TestProxmoxCloneTemplate(t *testing.T) {
handler := newMockProxmoxHandler()
client, server := newTestProxmoxClient(t, handler)
defer server.Close()
err := client.CloneTemplate(context.Background(), 200, ContainerConfig{
Hostname: "test-sandbox",
})
if err != nil {
t.Fatalf("CloneTemplate: %v", err)
}
}
func TestProxmoxContainerLifecycle(t *testing.T) {
handler := newMockProxmoxHandler()
client, server := newTestProxmoxClient(t, handler)
defer server.Close()
ctx := context.Background()
// Start container.
if err := client.StartContainer(ctx, 200); err != nil {
t.Fatalf("StartContainer: %v", err)
}
// Get status — should be running.
status, err := client.GetContainerStatus(ctx, 200)
if err != nil {
t.Fatalf("GetContainerStatus: %v", err)
}
if status.Status != "running" {
t.Errorf("expected status 'running', got %q", status.Status)
}
// Stop container.
if err := client.StopContainer(ctx, 200); err != nil {
t.Fatalf("StopContainer: %v", err)
}
// Get status — should be stopped.
status, err = client.GetContainerStatus(ctx, 200)
if err != nil {
t.Fatalf("GetContainerStatus: %v", err)
}
if status.Status != "stopped" {
t.Errorf("expected status 'stopped', got %q", status.Status)
}
}
func TestProxmoxGetContainerIP(t *testing.T) {
handler := newMockProxmoxHandler()
client, server := newTestProxmoxClient(t, handler)
defer server.Close()
ip, err := client.GetContainerIP(context.Background(), 200)
if err != nil {
t.Fatalf("GetContainerIP: %v", err)
}
if ip != "10.99.1.5" {
t.Errorf("expected IP 10.99.1.5, got %q", ip)
}
}
func TestProxmoxDestroyContainer(t *testing.T) {
handler := newMockProxmoxHandler()
client, server := newTestProxmoxClient(t, handler)
defer server.Close()
ctx := context.Background()
// Start it first so it has a status.
if err := client.StartContainer(ctx, 200); err != nil {
t.Fatalf("StartContainer: %v", err)
}
// Destroy it.
if err := client.DestroyContainer(ctx, 200); err != nil {
t.Fatalf("DestroyContainer: %v", err)
}
// Container should be gone from the handler's map.
if _, exists := handler.containers[200]; exists {
t.Error("container 200 should have been deleted")
}
}
func TestProxmoxConfigureContainer(t *testing.T) {
handler := newMockProxmoxHandler()
client, server := newTestProxmoxClient(t, handler)
defer server.Close()
err := client.ConfigureContainer(context.Background(), 200, ContainerConfig{
CPUs: 2,
MemoryMB: 2048,
})
if err != nil {
t.Fatalf("ConfigureContainer: %v", err)
}
}
func TestProxmoxEnableDisableInternet(t *testing.T) {
handler := newMockProxmoxHandler()
client, server := newTestProxmoxClient(t, handler)
defer server.Close()
ctx := context.Background()
if err := client.EnableInternet(ctx, "10.99.1.5"); err != nil {
t.Fatalf("EnableInternet: %v", err)
}
if err := client.DisableInternet(ctx, "10.99.1.5"); err != nil {
t.Fatalf("DisableInternet: %v", err)
}
}
func TestProxmoxAuthRequired(t *testing.T) {
// Mock that rejects requests without a valid token.
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization")
if auth != "PVEAPIToken=valid@pve!tok=secret123" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"data": 200})
})
server := httptest.NewTLSServer(handler)
defer server.Close()
// Client with wrong credentials should fail.
client := NewProxmoxClient(ProxmoxConfig{
BaseURL: server.URL,
TokenID: "wrong@pve!tok",
Secret: "wrong",
Node: "pve",
InsecureSkipVerify: true,
})
client.http = server.Client()
_, err := client.NextAvailableID(context.Background())
if err == nil {
t.Fatal("expected error with wrong auth, got nil")
}
if !strings.Contains(err.Error(), "401") {
t.Errorf("expected 401 error, got: %v", err)
}
// Client with correct credentials should succeed.
client2 := NewProxmoxClient(ProxmoxConfig{
BaseURL: server.URL,
TokenID: "valid@pve!tok",
Secret: "secret123",
Node: "pve",
InsecureSkipVerify: true,
})
client2.http = server.Client()
id, err := client2.NextAvailableID(context.Background())
if err != nil {
t.Fatalf("expected success with correct auth, got: %v", err)
}
if id != 200 {
t.Errorf("expected VMID 200, got %d", id)
}
}
func TestProxmoxContextCancellation(t *testing.T) {
handler := newMockProxmoxHandler()
client, server := newTestProxmoxClient(t, handler)
defer server.Close()
ctx, cancel := context.WithCancel(context.Background())
cancel() // Cancel immediately.
_, err := client.NextAvailableID(ctx)
if err == nil {
t.Fatal("expected error with cancelled context, got nil")
}
}
// --- SSH executor tests (unit tests without real SSH) ---
func TestSSHExecutorDefaults(t *testing.T) {
signer := generateTestSigner(t)
exec := NewSSHExecutor("10.99.1.5", SSHConfig{Signer: signer})
if exec.config.User != "sandbox" {
t.Errorf("expected default user 'sandbox', got %q", exec.config.User)
}
if exec.config.ConnectTimeout != 10e9 {
t.Errorf("expected default connect timeout 10s, got %v", exec.config.ConnectTimeout)
}
if exec.config.CommandTimeout != 60e9 {
t.Errorf("expected default command timeout 60s, got %v", exec.config.CommandTimeout)
}
}
func TestSSHExecutorNotConnected(t *testing.T) {
signer := generateTestSigner(t)
exec := NewSSHExecutor("10.99.1.5", SSHConfig{Signer: signer})
_, err := exec.Exec(context.Background(), "echo hello")
if err == nil {
t.Fatal("expected error when not connected, got nil")
}
if !strings.Contains(err.Error(), "not connected") {
t.Errorf("expected 'not connected' error, got: %v", err)
}
}
func TestSSHExecutorUploadNotConnected(t *testing.T) {
signer := generateTestSigner(t)
exec := NewSSHExecutor("10.99.1.5", SSHConfig{Signer: signer})
err := exec.Upload(context.Background(), strings.NewReader("test"), "/tmp/test", 0644)
if err == nil {
t.Fatal("expected error when not connected, got nil")
}
}
func TestSSHExecutorDownloadNotConnected(t *testing.T) {
signer := generateTestSigner(t)
exec := NewSSHExecutor("10.99.1.5", SSHConfig{Signer: signer})
_, err := exec.Download(context.Background(), "/tmp/test")
if err == nil {
t.Fatal("expected error when not connected, got nil")
}
}
func TestSSHExecutorIsConnected(t *testing.T) {
signer := generateTestSigner(t)
exec := NewSSHExecutor("10.99.1.5", SSHConfig{Signer: signer})
if exec.IsConnected() {
t.Error("should not be connected initially")
}
}
func TestSSHExecutorCloseIdempotent(t *testing.T) {
signer := generateTestSigner(t)
exec := NewSSHExecutor("10.99.1.5", SSHConfig{Signer: signer})
// Close without connecting should not error.
if err := exec.Close(); err != nil {
t.Errorf("Close on unconnected executor: %v", err)
}
}
// --- LoadSSHKey / ParseSSHKey tests ---
func TestLoadSSHKeyNotFound(t *testing.T) {
_, err := LoadSSHKey("/nonexistent/path/to/key")
if err == nil {
t.Fatal("expected error for nonexistent key, got nil")
}
}
func TestParseSSHKeyInvalid(t *testing.T) {
_, err := ParseSSHKey([]byte("not a valid PEM key"))
if err == nil {
t.Fatal("expected error for invalid key, got nil")
}
}
// --- Sandbox / Manager tests (using mock Proxmox, no real SSH) ---
func TestManagerRequiresSigner(t *testing.T) {
_, err := NewManager(Config{})
if err == nil {
t.Fatal("expected error when no SSH signer provided")
}
if !strings.Contains(err.Error(), "SSH signer") {
t.Errorf("expected SSH signer error, got: %v", err)
}
}
func TestSandboxDestroyClosesConnections(t *testing.T) {
handler := newMockProxmoxHandler()
_, server := newTestProxmoxClient(t, handler)
defer server.Close()
signer := generateTestSigner(t)
proxmoxClient := NewProxmoxClient(ProxmoxConfig{
BaseURL: server.URL,
TokenID: "test@pve!test-token",
Secret: "test-secret",
Node: "pve",
TemplateID: 9000,
InsecureSkipVerify: true,
})
proxmoxClient.http = server.Client()
// Start a container so destroy can check its status.
ctx := context.Background()
if err := proxmoxClient.StartContainer(ctx, 200); err != nil {
t.Fatalf("start: %v", err)
}
sshExec := NewSSHExecutor("10.99.1.5", SSHConfig{Signer: signer})
sb := &Sandbox{
ID: 200,
IP: "10.99.1.5",
Internet: false,
proxmox: proxmoxClient,
ssh: sshExec,
}
// Destroy should succeed even with unconnected SSH (no SFTP/SSH to close).
if err := sb.Destroy(ctx); err != nil {
t.Fatalf("Destroy: %v", err)
}
}
func TestSandboxWriteFileAndReadFileRequireConnection(t *testing.T) {
signer := generateTestSigner(t)
sshExec := NewSSHExecutor("10.99.1.5", SSHConfig{Signer: signer})
sb := &Sandbox{ssh: sshExec}
err := sb.WriteFile(context.Background(), "/tmp/test.txt", "hello")
if err == nil {
t.Fatal("expected error when SSH not connected")
}
_, err = sb.ReadFile(context.Background(), "/tmp/test.txt")
if err == nil {
t.Fatal("expected error when SSH not connected")
}
}
func TestContainerConfigDefaults(t *testing.T) {
// Verify that zero-value createOpts get proper defaults in the Create flow.
o := &createOpts{}
if o.cpus != 0 {
t.Errorf("expected zero cpus, got %d", o.cpus)
}
// Apply options.
WithCPUs(2)(o)
WithMemoryMB(2048)(o)
WithDiskGB(16)(o)
WithHostname("test")(o)
WithInternet(true)(o)
if o.cpus != 2 {
t.Errorf("expected cpus=2, got %d", o.cpus)
}
if o.memoryMB != 2048 {
t.Errorf("expected memoryMB=2048, got %d", o.memoryMB)
}
if o.diskGB != 16 {
t.Errorf("expected diskGB=16, got %d", o.diskGB)
}
if o.hostname != "test" {
t.Errorf("expected hostname='test', got %q", o.hostname)
}
if !o.internet {
t.Error("expected internet=true")
}
}
func TestExecResultFields(t *testing.T) {
r := ExecResult{Output: "hello\n", ExitCode: 0}
if r.Output != "hello\n" {
t.Errorf("unexpected output: %q", r.Output)
}
if r.ExitCode != 0 {
t.Errorf("unexpected exit code: %d", r.ExitCode)
}
}