Обновление снапшота с v0.0.0-20250811.0 на пин, которого требует sing-box после мержа 235 коммитов (upstream d620bbbf2 "Update gvisor to 20260727.0"). Прежний снапшот был взят 2026-08-04 ровно с той версии, на которой тогда стоял апстрим; разрыв возник 2026-08-05 вместе с его бампом. За год апстрим-gvisor изменил ~14 000 строк в 292 файлах. Значимое для нас — сетевой стек: tcp/connect.go (PMTU-discovery + исправление начального RTT/RTO: раньше задержка ACK внутри стека завышала стартовый таймаут на несколько RTT), tcp/snd.go, tcp/rcv.go, stack/conntrack.go, stack/packet_buffer.go. Всего 30 файлов в TCP и 37 в stack. Баг SPEC 048 апстрим НЕ исправил — проверено по коду новой версии: handleConnecting по-прежнему проверяет состояние endpoint'а, но не ep.h, а performHandshake так же зануляет h и отпускает мьютекс до Close(). Поэтому guard перенесён (12 строк) вместе со своим тестом (45 строк). Red/green проверен на новой базе: без guard'а тест падает с той же nil-паникой, что в полевом крашдампе; с ним зелёный.
528 lines
15 KiB
Go
528 lines
15 KiB
Go
// Copyright 2026 The gVisor Authors.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// https://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
// Package sandbox provides a simple Go API for creating gVisor sandbox
|
|
// and executing commands in the sandbox.
|
|
package sandbox
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"math/rand"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Options holds the configuration for a Sandbox.
|
|
type Options struct {
|
|
runtimeDir string
|
|
id string
|
|
enableNetworking bool
|
|
mounts []Mount
|
|
snapshot *Snapshot
|
|
env []string
|
|
err error
|
|
workingDir string
|
|
hostname string
|
|
}
|
|
|
|
// Option configures the Options struct.
|
|
type Option func(*Options)
|
|
|
|
// MountType represents the type of a mount point inside the sandbox.
|
|
type MountType int
|
|
|
|
const (
|
|
// MountTypeBind represents a host bind mount.
|
|
MountTypeBind MountType = iota
|
|
// MountTypeTmpfs represents an in-memory tmpfs mount.
|
|
MountTypeTmpfs
|
|
// MountTypeProc represents a procfs mount.
|
|
MountTypeProc
|
|
)
|
|
|
|
// Mount holds settings for a custom host bind directory or in-memory mount.
|
|
type Mount struct {
|
|
Source string
|
|
Destination string
|
|
Type MountType
|
|
ReadOnly bool
|
|
}
|
|
|
|
// WithRuntimeDir sets a custom runtime directory where bundle and state files are written.
|
|
func WithRuntimeDir(runtimeDir string) Option {
|
|
return func(o *Options) {
|
|
o.runtimeDir = runtimeDir
|
|
}
|
|
}
|
|
|
|
// WithID sets a specific sandbox ID. If not set, a unique ID will be generated automatically.
|
|
func WithID(id string) Option {
|
|
return func(o *Options) {
|
|
o.id = id
|
|
}
|
|
}
|
|
|
|
// WithNetworking configures whether networking is enabled inside the sandbox.
|
|
func WithNetworking(enabled bool) Option {
|
|
return func(o *Options) {
|
|
o.enableNetworking = enabled
|
|
}
|
|
}
|
|
|
|
// WithBindMount adds a custom bind mount from host's source path to the sandbox's destination path.
|
|
func WithBindMount(source, destination string, readOnly bool) Option {
|
|
return func(o *Options) {
|
|
o.mounts = append(o.mounts, Mount{
|
|
Source: filepath.Clean(source),
|
|
Destination: filepath.Clean(destination),
|
|
Type: MountTypeBind,
|
|
ReadOnly: readOnly,
|
|
})
|
|
}
|
|
}
|
|
|
|
// WithTmpfsMount adds an in-memory tmpfs filesystem at the destination path inside the sandbox.
|
|
func WithTmpfsMount(destination string) Option {
|
|
return func(o *Options) {
|
|
o.mounts = append(o.mounts, Mount{
|
|
Destination: filepath.Clean(destination),
|
|
Type: MountTypeTmpfs,
|
|
})
|
|
}
|
|
}
|
|
|
|
// WithProcMount adds a procfs mount at the destination path inside the sandbox.
|
|
func WithProcMount(destination string) Option {
|
|
return func(o *Options) {
|
|
o.mounts = append(o.mounts, Mount{
|
|
Destination: filepath.Clean(destination),
|
|
Type: MountTypeProc,
|
|
})
|
|
}
|
|
}
|
|
|
|
// WithHostname sets the hostname for the sandbox.
|
|
func WithHostname(hostname string) Option {
|
|
return func(o *Options) {
|
|
o.hostname = hostname
|
|
}
|
|
}
|
|
|
|
// WithSnapshot configures the sandbox to restore state from the given snapshot.
|
|
// The sandbox automatically reads the snapshot metadata to determine if it is a
|
|
// full Checkpoint/Restore, Filesystem snapshot, or Rootfs Tar snapshot.
|
|
func WithSnapshot(snapshot *Snapshot) Option {
|
|
return func(o *Options) {
|
|
o.snapshot = snapshot
|
|
}
|
|
}
|
|
|
|
// WithEnv sets one or more environment variables in the sandbox process.
|
|
// Each env string must be in the "KEY=VALUE" format.
|
|
func WithEnv(envs ...string) Option {
|
|
return func(o *Options) {
|
|
for _, env := range envs {
|
|
if !strings.Contains(env, "=") {
|
|
o.err = fmt.Errorf("invalid environment variable format, expected KEY=VALUE: %q", env)
|
|
return
|
|
}
|
|
}
|
|
o.env = append(o.env, envs...)
|
|
}
|
|
}
|
|
|
|
// WithWorkingDir sets the current working directory for the sandbox process.
|
|
// If the path is relative, it will be resolved as an absolute path from the root directory "/".
|
|
// This is not a bind mount; it is simply setting the cwd inside the sandbox process.
|
|
// Defaults to "/".
|
|
func WithWorkingDir(cwd string) Option {
|
|
return func(o *Options) {
|
|
if cwd == "" {
|
|
o.err = fmt.Errorf("working directory cannot be empty")
|
|
return
|
|
}
|
|
// ensure absolute path inside sandbox
|
|
if !filepath.IsAbs(cwd) {
|
|
cwd = filepath.Join("/", cwd)
|
|
}
|
|
cwd = filepath.Clean(cwd)
|
|
o.workingDir = cwd
|
|
}
|
|
}
|
|
|
|
// Sandbox represents a running gVisor sandbox where applications
|
|
// run inside.
|
|
type Sandbox struct {
|
|
id string
|
|
bundleDir string
|
|
runscPath string
|
|
rootState string
|
|
}
|
|
|
|
// newID returns a unique ID for the sandbox.
|
|
func newID() string {
|
|
b := make([]byte, 16)
|
|
if _, err := rand.Read(b); err != nil {
|
|
// rand.Read never returns an error, and always fills b entirely.
|
|
panic(fmt.Sprintf("failed to generate random bytes for sandbox ID: %v", err))
|
|
}
|
|
return fmt.Sprintf("%x", b)
|
|
}
|
|
|
|
// Look for runsc binary from the environment variable RUNSC_PATH,
|
|
// then in system PATH.
|
|
func runscPath() string {
|
|
if path := os.Getenv("RUNSC_PATH"); path != "" {
|
|
return path
|
|
}
|
|
path, err := exec.LookPath("runsc")
|
|
if err == nil {
|
|
return path
|
|
}
|
|
panic("runsc binary is not found")
|
|
}
|
|
|
|
// New spawns a new sandbox as a subprocess, the sandbox
|
|
// will be started and running in detached mode.
|
|
func New(ctx context.Context, opts ...Option) (*Sandbox, error) {
|
|
options := Options{
|
|
enableNetworking: true,
|
|
workingDir: "/",
|
|
}
|
|
for _, o := range opts {
|
|
o(&options)
|
|
}
|
|
|
|
if options.err != nil {
|
|
return nil, options.err
|
|
}
|
|
|
|
if options.runtimeDir == "" {
|
|
dir, err := os.MkdirTemp("", "gvisor-sandbox-*")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create runtime directory: %v", err)
|
|
}
|
|
options.runtimeDir = dir
|
|
}
|
|
|
|
if options.id == "" {
|
|
options.id = newID()
|
|
}
|
|
|
|
if os.Geteuid() != 0 && options.enableNetworking {
|
|
return nil, fmt.Errorf("enabling networking requires running as root")
|
|
}
|
|
|
|
runDir := options.runtimeDir
|
|
stateDir := filepath.Join(runDir, "state")
|
|
if err := os.MkdirAll(stateDir, 0o700); err != nil {
|
|
return nil, fmt.Errorf("failed to create sandbox state directory: %v", err)
|
|
}
|
|
// Verify that the state directory actually has 0700 permissions.
|
|
fi, err := os.Stat(stateDir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to stat sandbox state directory: %v", err)
|
|
}
|
|
if fi.Mode().Perm() != 0o700 {
|
|
return nil, fmt.Errorf("sandbox state directory has incorrect permissions: got %v, want %v", fi.Mode().Perm(), os.FileMode(0o700))
|
|
}
|
|
|
|
var annotations map[string]string
|
|
var globalFlags []string
|
|
var runFlags []string
|
|
var isCheckpointRestore bool
|
|
var checkpointRestoreDir string
|
|
|
|
if options.snapshot != nil {
|
|
store := options.snapshot.Storage
|
|
snapshotID := options.snapshot.ID
|
|
if store == nil {
|
|
return nil, fmt.Errorf("no snapshot storage configured for restore")
|
|
}
|
|
|
|
// Fetch metadata.json from store.
|
|
metaReader, err := store.GetReader(ctx, snapshotID, MetadataAsset)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read snapshot metadata: %w", err)
|
|
}
|
|
defer metaReader.Close()
|
|
|
|
var meta SnapshotMetadata
|
|
if err := json.NewDecoder(metaReader).Decode(&meta); err != nil {
|
|
return nil, fmt.Errorf("failed to parse snapshot metadata: %w", err)
|
|
}
|
|
|
|
// Perform restore based on type.
|
|
switch meta.Type {
|
|
case RootfsTarSnapshot:
|
|
tarPath, err := readRootfsTar(ctx, snapshotID, store)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer os.Remove(tarPath)
|
|
|
|
annotations = map[string]string{
|
|
"dev.gvisor.tar.rootfs.upper": tarPath,
|
|
}
|
|
globalFlags = append(globalFlags, "--allow-rootfs-tar-annotation")
|
|
|
|
case FilesystemSnapshot:
|
|
fsRestoreDir := filepath.Join(stateDir, "fs-restore")
|
|
if err := os.MkdirAll(fsRestoreDir, 0o700); err != nil {
|
|
return nil, err
|
|
}
|
|
// TODO: List assets in store and download all filesystem image assets to fsRestoreDir.
|
|
runFlags = append(runFlags, fmt.Sprintf("--fs-restore-image-path=%s", fsRestoreDir))
|
|
|
|
case CheckpointRestore:
|
|
checkpointRestoreDir = filepath.Join(stateDir, "checkpoint-restore")
|
|
if err := os.MkdirAll(checkpointRestoreDir, 0o700); err != nil {
|
|
return nil, err
|
|
}
|
|
// TODO: List assets in store and download all checkpoint image assets to checkpointRestoreDir.
|
|
isCheckpointRestore = true
|
|
}
|
|
}
|
|
bundleDir, err := NewBundle(BundleConfig{
|
|
ID: options.id,
|
|
RuntimeDir: runDir,
|
|
EnableNetworking: options.enableNetworking,
|
|
Mounts: options.mounts,
|
|
Env: options.env,
|
|
Annotations: annotations,
|
|
WorkingDir: options.workingDir,
|
|
Hostname: options.hostname,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create OCI bundle: %v", err)
|
|
}
|
|
|
|
sb := &Sandbox{
|
|
id: options.id,
|
|
bundleDir: bundleDir,
|
|
runscPath: runscPath(),
|
|
rootState: stateDir,
|
|
}
|
|
|
|
// Launch the sandbox in detached mode via os/exec.
|
|
args := []string{"--root", sb.rootState}
|
|
if os.Geteuid() != 0 {
|
|
args = append(args, "--ignore-cgroups")
|
|
}
|
|
if !options.enableNetworking {
|
|
args = append(args, "--network=none")
|
|
}
|
|
args = append(args, globalFlags...)
|
|
|
|
if isCheckpointRestore {
|
|
args = append(args, "restore", "--image-path", checkpointRestoreDir, "--detach", sb.id)
|
|
} else {
|
|
args = append(args, "run")
|
|
args = append(args, runFlags...)
|
|
args = append(args, "--bundle", sb.bundleDir, "--detach", sb.id)
|
|
}
|
|
cmd := exec.CommandContext(ctx, sb.runscPath, args...)
|
|
if err := cmd.Run(); err != nil {
|
|
return nil, fmt.Errorf("failed to create sandbox via subprocess: %v", err)
|
|
}
|
|
|
|
return sb, nil
|
|
}
|
|
|
|
// Exec runs the given command inside the running sandbox and returns the output.
|
|
func (s *Sandbox) Exec(ctx context.Context, cmd string, opts ...string) (stdout string, stderr string, err error) {
|
|
args := append([]string{"--root", s.rootState, "exec", s.id, cmd}, opts...)
|
|
|
|
commandContext := exec.CommandContext(ctx, s.runscPath, args...)
|
|
|
|
var stdoutBuf, stderrBuf bytes.Buffer
|
|
|
|
commandContext.Stdout = io.MultiWriter(&stdoutBuf)
|
|
commandContext.Stderr = io.MultiWriter(&stderrBuf)
|
|
|
|
err = commandContext.Run()
|
|
if err != nil {
|
|
return "", stderrBuf.String(), fmt.Errorf("exec failed: %v", err)
|
|
}
|
|
|
|
return stdoutBuf.String(), stderrBuf.String(), err
|
|
}
|
|
|
|
// Close kills the sandbox processes and cleans up the state directory.
|
|
func (s *Sandbox) Close(ctx context.Context) error {
|
|
killArgs := []string{"--root", s.rootState, "kill", s.id, "SIGKILL"}
|
|
_ = exec.CommandContext(ctx, s.runscPath, killArgs...).Run()
|
|
|
|
deleteArgs := []string{"--root", s.rootState, "delete", "--force", s.id}
|
|
if err := exec.CommandContext(ctx, s.runscPath, deleteArgs...).Run(); err != nil {
|
|
return fmt.Errorf("failed to clean up sandbox state: %v", err)
|
|
}
|
|
|
|
if err := os.RemoveAll(s.bundleDir); err != nil {
|
|
return fmt.Errorf("failed to clean up sandbox bundle directory: %v", err)
|
|
}
|
|
|
|
if err := os.RemoveAll(s.rootState); err != nil {
|
|
return fmt.Errorf("failed to clean up sandbox state directory: %v", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Bundle returns the path to the OCI bundle directory for this sandbox.
|
|
func (s *Sandbox) Bundle() string {
|
|
return s.bundleDir
|
|
}
|
|
|
|
// SnapshotOptions holds configuration for taking a snapshot.
|
|
type SnapshotOptions struct {
|
|
LeaveRunning bool
|
|
}
|
|
|
|
// SnapshotOption configures SnapshotOptions.
|
|
type SnapshotOption func(*SnapshotOptions)
|
|
|
|
// WithLeaveRunning keeps the sandbox running after taking the snapshot.
|
|
func WithLeaveRunning(leaveRunning bool) SnapshotOption {
|
|
return func(o *SnapshotOptions) {
|
|
o.LeaveRunning = leaveRunning
|
|
}
|
|
}
|
|
|
|
func newSnapshotID() SnapshotID {
|
|
b := make([]byte, 16)
|
|
if _, err := rand.Read(b); err != nil {
|
|
panic(fmt.Sprintf("failed to generate random bytes for snapshot ID: %v", err))
|
|
}
|
|
return SnapshotID(fmt.Sprintf("snap-%x", b))
|
|
}
|
|
|
|
// Snapshot serializes and saves the sandbox state to storage, returning the generated snapshot.
|
|
// Depending on the snapshotType, it will perform a full Checkpoint, a Filesystem Snapshot, or a Rootfs Tar Snapshot.
|
|
// It also automatically generates and writes "metadata.json" into the storage.
|
|
func (s *Sandbox) Snapshot(ctx context.Context, snapshotType SnapshotType, storage SnapshotStorage, opts ...SnapshotOption) (*Snapshot, error) {
|
|
options := SnapshotOptions{
|
|
LeaveRunning: false, // Default is false.
|
|
}
|
|
for _, o := range opts {
|
|
o(&options)
|
|
}
|
|
|
|
snapshotID := newSnapshotID()
|
|
|
|
switch snapshotType {
|
|
case RootfsTarSnapshot:
|
|
if err := s.snapshotRootfsTar(ctx, snapshotID, storage); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
case FilesystemSnapshot:
|
|
// TODO: Run `runsc fscheckpoint --image-path=<localTempDir> [--leave-running] <sandboxID>`.
|
|
// TODO: Walk `<localTempDir>` and upload each file to storage.
|
|
|
|
case CheckpointRestore:
|
|
// TODO: Run `runsc checkpoint --image-path=<localTempDir> [--leave-running] <sandboxID>`.
|
|
// TODO: Walk `<localTempDir>` and upload each file to storage.
|
|
}
|
|
|
|
meta := SnapshotMetadata{
|
|
Type: snapshotType,
|
|
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
|
}
|
|
|
|
metaWriter, err := storage.PutWriter(ctx, snapshotID, MetadataAsset)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create metadata.json in storage: %w", err)
|
|
}
|
|
defer metaWriter.Close()
|
|
|
|
if err := json.NewEncoder(metaWriter).Encode(&meta); err != nil {
|
|
return nil, fmt.Errorf("failed to write metadata.json to storage: %w", err)
|
|
}
|
|
|
|
return &Snapshot{
|
|
ID: snapshotID,
|
|
Storage: storage,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Sandbox) snapshotRootfsTar(ctx context.Context, snapshotID SnapshotID, storage SnapshotStorage) error {
|
|
tarFile, err := os.CreateTemp(os.TempDir(), "rootfs-*.tar")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create temp tar file: %w", err)
|
|
}
|
|
tarPath := tarFile.Name()
|
|
tarFile.Close()
|
|
defer os.Remove(tarPath)
|
|
|
|
cmd := exec.CommandContext(ctx, s.runscPath, "--root", s.rootState, "tar", "rootfs-upper", "--file", tarPath, s.id)
|
|
var stderr bytes.Buffer
|
|
cmd.Stderr = &stderr
|
|
if err := cmd.Run(); err != nil {
|
|
return fmt.Errorf("runsc tar failed: %v (stderr: %q)", err, stderr.String())
|
|
}
|
|
|
|
localFile, err := os.Open(tarPath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to open temp tar file: %w", err)
|
|
}
|
|
defer localFile.Close()
|
|
|
|
storageWriter, err := storage.PutWriter(ctx, snapshotID, RootfsAsset)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create storage writer: %w", err)
|
|
}
|
|
defer storageWriter.Close()
|
|
|
|
if _, err := io.Copy(storageWriter, localFile); err != nil {
|
|
return fmt.Errorf("failed to upload rootfs tar: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func readRootfsTar(ctx context.Context, snapshotID SnapshotID, store SnapshotStorage) (string, error) {
|
|
tarFile, err := os.CreateTemp(os.TempDir(), "rootfs-*.tar")
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create temp tar file: %w", err)
|
|
}
|
|
tarPath := tarFile.Name()
|
|
defer tarFile.Close()
|
|
|
|
cleanup := true
|
|
defer func() {
|
|
if cleanup {
|
|
os.Remove(tarPath)
|
|
}
|
|
}()
|
|
|
|
storageReader, err := store.GetReader(ctx, snapshotID, RootfsAsset)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to get rootfs reader from storage: %w", err)
|
|
}
|
|
defer storageReader.Close()
|
|
|
|
if _, err := io.Copy(tarFile, storageReader); err != nil {
|
|
return "", fmt.Errorf("failed to download rootfs asset: %w", err)
|
|
}
|
|
|
|
cleanup = false
|
|
return tarPath, nil
|
|
}
|