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

254 lines
6.0 KiB
Go

package sandbox
import (
"bytes"
"context"
"fmt"
"io"
"net"
"os"
"sync"
"time"
"github.com/pkg/sftp"
"golang.org/x/crypto/ssh"
)
// SSHConfig holds configuration for SSH connections to sandbox containers.
type SSHConfig struct {
// User is the SSH username (default "sandbox").
User string
// Signer is the SSH private key signer for authentication.
Signer ssh.Signer
// ConnectTimeout is the maximum time to wait for an SSH connection (default 10s).
ConnectTimeout time.Duration
// CommandTimeout is the default maximum time for a single command execution (default 60s).
CommandTimeout time.Duration
}
// SSHExecutor manages SSH and SFTP connections to a sandbox container.
type SSHExecutor struct {
host string
config SSHConfig
mu sync.Mutex
sshClient *ssh.Client
sftpClient *sftp.Client
}
// NewSSHExecutor creates a new SSH executor for the given host.
func NewSSHExecutor(host string, config SSHConfig) *SSHExecutor {
if config.User == "" {
config.User = "sandbox"
}
if config.ConnectTimeout <= 0 {
config.ConnectTimeout = 10 * time.Second
}
if config.CommandTimeout <= 0 {
config.CommandTimeout = 60 * time.Second
}
return &SSHExecutor{
host: host,
config: config,
}
}
// Connect establishes SSH and SFTP connections to the container.
// It polls until the connection succeeds or the context is cancelled,
// which is useful when waiting for a freshly started container to boot.
func (s *SSHExecutor) Connect(ctx context.Context) error {
sshConfig := &ssh.ClientConfig{
User: s.config.User,
Auth: []ssh.AuthMethod{
ssh.PublicKeys(s.config.Signer),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: s.config.ConnectTimeout,
}
addr := net.JoinHostPort(s.host, "22")
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
var lastErr error
for {
client, err := ssh.Dial("tcp", addr, sshConfig)
if err == nil {
sftpClient, err := sftp.NewClient(client)
if err != nil {
client.Close()
return fmt.Errorf("create SFTP client: %w", err)
}
s.mu.Lock()
s.sshClient = client
s.sftpClient = sftpClient
s.mu.Unlock()
return nil
}
lastErr = err
select {
case <-ctx.Done():
return fmt.Errorf("ssh connect to %s: %w (last error: %v)", addr, ctx.Err(), lastErr)
case <-ticker.C:
}
}
}
// ExecResult contains the output and exit status of a command execution.
type ExecResult struct {
Output string
ExitCode int
}
// Exec runs a shell command on the container and returns the combined stdout/stderr
// output and exit code.
func (s *SSHExecutor) Exec(ctx context.Context, command string) (ExecResult, error) {
s.mu.Lock()
client := s.sshClient
s.mu.Unlock()
if client == nil {
return ExecResult{}, fmt.Errorf("ssh not connected")
}
session, err := client.NewSession()
if err != nil {
return ExecResult{}, fmt.Errorf("create session: %w", err)
}
defer session.Close()
var buf bytes.Buffer
session.Stdout = &buf
session.Stderr = &buf
// Apply context timeout.
done := make(chan error, 1)
go func() {
done <- session.Run(command)
}()
select {
case <-ctx.Done():
_ = session.Signal(ssh.SIGKILL)
return ExecResult{}, fmt.Errorf("exec timed out: %w", ctx.Err())
case err := <-done:
output := buf.String()
if err != nil {
if exitErr, ok := err.(*ssh.ExitError); ok {
return ExecResult{
Output: output,
ExitCode: exitErr.ExitStatus(),
}, nil
}
return ExecResult{Output: output}, fmt.Errorf("exec: %w", err)
}
return ExecResult{Output: output, ExitCode: 0}, nil
}
}
// Upload writes data from an io.Reader to a file on the container.
func (s *SSHExecutor) Upload(ctx context.Context, reader io.Reader, remotePath string, mode os.FileMode) error {
s.mu.Lock()
client := s.sftpClient
s.mu.Unlock()
if client == nil {
return fmt.Errorf("sftp not connected")
}
f, err := client.OpenFile(remotePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC)
if err != nil {
return fmt.Errorf("open remote file %s: %w", remotePath, err)
}
defer f.Close()
if _, err := io.Copy(f, reader); err != nil {
return fmt.Errorf("write to %s: %w", remotePath, err)
}
if err := client.Chmod(remotePath, mode); err != nil {
return fmt.Errorf("chmod %s: %w", remotePath, err)
}
return nil
}
// Download reads a file from the container and returns its contents as an io.ReadCloser.
// The caller must close the returned reader.
func (s *SSHExecutor) Download(ctx context.Context, remotePath string) (io.ReadCloser, error) {
s.mu.Lock()
client := s.sftpClient
s.mu.Unlock()
if client == nil {
return nil, fmt.Errorf("sftp not connected")
}
f, err := client.Open(remotePath)
if err != nil {
return nil, fmt.Errorf("open remote file %s: %w", remotePath, err)
}
return f, nil
}
// Close tears down both SFTP and SSH connections.
func (s *SSHExecutor) Close() error {
s.mu.Lock()
defer s.mu.Unlock()
var errs []error
if s.sftpClient != nil {
if err := s.sftpClient.Close(); err != nil {
errs = append(errs, fmt.Errorf("close SFTP: %w", err))
}
s.sftpClient = nil
}
if s.sshClient != nil {
if err := s.sshClient.Close(); err != nil {
errs = append(errs, fmt.Errorf("close SSH: %w", err))
}
s.sshClient = nil
}
if len(errs) > 0 {
return fmt.Errorf("close ssh executor: %v", errs)
}
return nil
}
// IsConnected returns true if the SSH connection is established.
func (s *SSHExecutor) IsConnected() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.sshClient != nil
}
// LoadSSHKey reads a PEM-encoded private key file and returns an ssh.Signer.
func LoadSSHKey(path string) (ssh.Signer, error) {
keyData, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read SSH key %s: %w", path, err)
}
signer, err := ssh.ParsePrivateKey(keyData)
if err != nil {
return nil, fmt.Errorf("parse SSH key: %w", err)
}
return signer, nil
}
// ParseSSHKey parses a PEM-encoded private key from bytes and returns an ssh.Signer.
func ParseSSHKey(pemBytes []byte) (ssh.Signer, error) {
signer, err := ssh.ParsePrivateKey(pemBytes)
if err != nil {
return nil, fmt.Errorf("parse SSH key: %w", err)
}
return signer, nil
}