instanceexec: the trampoline reads the launch file through instancefs and execs the live link (plan 2, task 2)
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YZ1ZvWj7naf73GjTX8MBv5
2 files changed,
+223
−0
internal/instanceexec/exec.go+105 −0internal/instanceexec/exec_test.go+118 −0
diff --git a/internal/instanceexec/exec.go b/internal/instanceexec/exec.go| new file mode 100644 |
| index 000000000..dd89bdf85 |
| --- /dev/null |
| +++ b/internal/instanceexec/exec.go |
| @@ -0,0 +1,105 @@ |
| +// Package instanceexec holds the three programs that run INSIDE an isolated |
| +// instance's namespace, as carlos, launched by the template units of the |
| +// isolated-exec design: the trampoline (instance-exec, §6.2), the advisory |
| +// exit-record writer (instance-exit, §6.5) and the readiness probe |
| +// (instance-probe, §8.5). None of them is for operators; each is an |
| +// ExecStart= or ExecStopPost= of a unit. |
| +package instanceexec |
| + |
| +import ( |
| + "encoding/json" |
| + "errors" |
| + "flag" |
| + "fmt" |
| + "log" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "syscall" |
| + |
| + "github.com/carlosframework/platform/internal/cliflag" |
| + "github.com/carlosframework/platform/internal/instancectl" |
| + "github.com/carlosframework/platform/internal/instancefs" |
| + "github.com/carlosframework/platform/internal/provision" |
| +) |
| + |
| +// LiveDir is where the adoption pass keeps each host's live symlink; the |
| +// trampoline execs nothing that is not directly under it. |
| +const LiveDir = "/opt/carlos/live" |
| + |
| +var errSymlinkOrEscape = errors.New("instance-exec: launch file refused") |
| + |
| +// dataDir is the agent's own resolver (host/agent.go), repeated here because |
| +// this program runs in a namespace where only the instance's directory is |
| +// bound, and it must agree with the agent on where that directory is. |
| +func dataDir() string { |
| + if d := os.Getenv("CARLOS_DATA_DIR"); d != "" { |
| + return d |
| + } |
| + return "/data" |
| +} |
| + |
| +// RunExec is `carlos instance-exec <role> <iid>`. |
| +func RunExec(args []string) { |
| + fs := flag.NewFlagSet("instance-exec", flag.ExitOnError) |
| + fs.Usage = cliflag.Usage(fs, "usage: carlos instance-exec <run|seed|sidecar> <instance-id>") |
| + _ = fs.Parse(args) |
| + if fs.NArg() != 2 { |
| + fs.Usage() |
| + os.Exit(2) |
| + } |
| + role, ok := instancectl.ParseRole(fs.Arg(0)) |
| + if !ok { |
| + log.Fatalf("instance-exec: role %q is not run, seed or sidecar", fs.Arg(0)) |
| + } |
| + if err := execLaunch(dataDir(), role, fs.Arg(1), syscall.Exec); err != nil { |
| + log.Fatalf("instance-exec: %v", err) |
| + } |
| +} |
| + |
| +// execLaunch reads launch.<role>.json from the instance directory through |
| +// instancefs (never through a symlink), validates it, and execs. It returns |
| +// only on refusal; a successful exec never returns. |
| +// |
| +// The validation is defence in depth: the agent wrote the file, but the |
| +// directory is the instance's own, so the instance can rewrite it. What |
| +// the checks guarantee is that whatever is executed is directly under |
| +// LiveDir (the platform's live links, root-written) and named for the host |
| +// in the file, so a rewritten file can execute only what the tenant could |
| +// execute anyway: its own binary with an argv and env it chose. |
| +func execLaunch(dataDir string, role instancectl.Role, iid string, execFn func(argv0 string, argv, env []string) error) error { |
| + dir := filepath.Join(dataDir, "instances", iid) |
| + raw, err := instancefs.ReadBeneath(dir, instancectl.LaunchFileName(role)) |
| + if err != nil { |
| + if errors.Is(err, instancefs.ErrSymlink) || errors.Is(err, instancefs.ErrEscapes) { |
| + return fmt.Errorf("%w: %v", errSymlinkOrEscape, err) |
| + } |
| + return err |
| + } |
| + var l instancectl.Launch |
| + if err := json.Unmarshal(raw, &l); err != nil { |
| + return fmt.Errorf("launch file: %v", err) |
| + } |
| + if l.Schema != instancectl.LaunchSchema { |
| + return fmt.Errorf("launch file schema %d is not %d", l.Schema, instancectl.LaunchSchema) |
| + } |
| + if !provision.ValidHostShape(l.Host) { |
| + return fmt.Errorf("launch file host %q is not a host", l.Host) |
| + } |
| + if len(l.Argv) == 0 { |
| + return errors.New("launch file has no argv") |
| + } |
| + argv0 := l.Argv[0] |
| + if !filepath.IsAbs(argv0) || filepath.Clean(argv0) != argv0 || filepath.Dir(argv0) != LiveDir { |
| + return fmt.Errorf("argv[0] %q is not directly under %s", argv0, LiveDir) |
| + } |
| + if filepath.Base(argv0) != l.Host { |
| + return fmt.Errorf("argv[0] %q is not the live link of host %s", argv0, l.Host) |
| + } |
| + for _, kv := range l.Env { |
| + if k, _, ok := strings.Cut(kv, "="); !ok || k == "" { |
| + return fmt.Errorf("launch file env entry %q is not KEY=value", kv) |
| + } |
| + } |
| + return execFn(argv0, l.Argv, l.Env) |
| +} |
diff --git a/internal/instanceexec/exec_test.go b/internal/instanceexec/exec_test.go| new file mode 100644 |
| index 000000000..454c97866 |
| --- /dev/null |
| +++ b/internal/instanceexec/exec_test.go |
| @@ -0,0 +1,118 @@ |
| +//go:build linux |
| + |
| +package instanceexec |
| + |
| +import ( |
| + "encoding/json" |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/instancectl" |
| +) |
| + |
| +func writeLaunch(t *testing.T, dir string, role instancectl.Role, l instancectl.Launch) { |
| + t.Helper() |
| + if err := os.MkdirAll(dir, 0o700); err != nil { |
| + t.Fatal(err) |
| + } |
| + b, err := json.Marshal(l) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(filepath.Join(dir, instancectl.LaunchFileName(role)), b, 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +type captured struct { |
| + argv0 string |
| + argv []string |
| + env []string |
| + calls int |
| +} |
| + |
| +func (c *captured) exec(argv0 string, argv, env []string) error { |
| + c.calls++ |
| + c.argv0, c.argv, c.env = argv0, argv, env |
| + return nil |
| +} |
| + |
| +func TestExecLaunchExecsExactlyWhatTheAgentWrote(t *testing.T) { |
| + data := t.TempDir() |
| + iid := "bill.example.com" |
| + dir := filepath.Join(data, "instances", iid) |
| + writeLaunch(t, dir, instancectl.RoleRun, instancectl.Launch{ |
| + Schema: 1, Host: "bill.example.com", |
| + Argv: []string{"/opt/carlos/live/bill.example.com", "--socket", "/run/carlos/instances/bill.example.com/app.sock", "--db", "/data/instances/bill.example.com/bill.example.com.db"}, |
| + Env: []string{"PATH=/usr/bin", "CARLOS_ADMIN_TOKEN=abc"}, |
| + }) |
| + var c captured |
| + if err := execLaunch(data, instancectl.RoleRun, iid, c.exec); err != nil { |
| + t.Fatalf("execLaunch: %v", err) |
| + } |
| + if c.calls != 1 || c.argv0 != "/opt/carlos/live/bill.example.com" || len(c.argv) != 5 || c.argv[2] != "/run/carlos/instances/bill.example.com/app.sock" { |
| + t.Errorf("exec = %+v", c) |
| + } |
| + if len(c.env) != 2 || c.env[1] != "CARLOS_ADMIN_TOKEN=abc" { |
| + t.Errorf("env = %v", c.env) |
| + } |
| +} |
| + |
| +func TestExecLaunchRefusesAnArgv0OutsideTheLiveDir(t *testing.T) { |
| + data := t.TempDir() |
| + iid := "h.example.com" |
| + dir := filepath.Join(data, "instances", iid) |
| + for _, argv0 := range []string{"/bin/sh", "/opt/carlos/live/../carlos", "/opt/carlos/live/other.example.com", "opt/carlos/live/h.example.com", ""} { |
| + writeLaunch(t, dir, instancectl.RoleRun, instancectl.Launch{Schema: 1, Host: "h.example.com", Argv: []string{argv0}, Env: nil}) |
| + var c captured |
| + err := execLaunch(data, instancectl.RoleRun, iid, c.exec) |
| + if err == nil || c.calls != 0 { |
| + t.Errorf("argv0 %q: err = %v, calls = %d; want a refusal and no exec", argv0, err, c.calls) |
| + } |
| + } |
| +} |
| + |
| +func TestExecLaunchRefusesUnknownSchemaAndBadEnv(t *testing.T) { |
| + data := t.TempDir() |
| + iid := "h.example.com" |
| + dir := filepath.Join(data, "instances", iid) |
| + good := []string{"/opt/carlos/live/h.example.com"} |
| + writeLaunch(t, dir, instancectl.RoleRun, instancectl.Launch{Schema: 2, Host: "h.example.com", Argv: good}) |
| + var c captured |
| + if err := execLaunch(data, instancectl.RoleRun, iid, c.exec); err == nil || c.calls != 0 { |
| + t.Errorf("schema 2: err = %v, calls = %d", err, c.calls) |
| + } |
| + writeLaunch(t, dir, instancectl.RoleRun, instancectl.Launch{Schema: 1, Host: "h.example.com", Argv: good, Env: []string{"NOEQUALS"}}) |
| + if err := execLaunch(data, instancectl.RoleRun, iid, c.exec); err == nil || c.calls != 0 { |
| + t.Errorf("bad env: err = %v, calls = %d", err, c.calls) |
| + } |
| + writeLaunch(t, dir, instancectl.RoleRun, instancectl.Launch{Schema: 1, Host: "not a host", Argv: []string{"/opt/carlos/live/not a host"}}) |
| + if err := execLaunch(data, instancectl.RoleRun, iid, c.exec); err == nil || c.calls != 0 { |
| + t.Errorf("bad host: err = %v, calls = %d", err, c.calls) |
| + } |
| +} |
| + |
| +func TestExecLaunchRefusesASymlinkedLaunchFile(t *testing.T) { |
| + data := t.TempDir() |
| + iid := "h.example.com" |
| + dir := filepath.Join(data, "instances", iid) |
| + if err := os.MkdirAll(dir, 0o700); err != nil { |
| + t.Fatal(err) |
| + } |
| + other := filepath.Join(t.TempDir(), "launch.run.json") |
| + l := instancectl.Launch{Schema: 1, Host: "h.example.com", Argv: []string{"/opt/carlos/live/h.example.com"}} |
| + b, _ := json.Marshal(l) |
| + if err := os.WriteFile(other, b, 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Symlink(other, filepath.Join(dir, "launch.run.json")); err != nil { |
| + t.Fatal(err) |
| + } |
| + var c captured |
| + err := execLaunch(data, instancectl.RoleRun, iid, c.exec) |
| + if err == nil || c.calls != 0 || !errors.Is(err, errSymlinkOrEscape) { |
| + t.Errorf("symlinked launch file: err = %v, calls = %d", err, c.calls) |
| + } |
| +} |