From 4bf4177f537a65f4c24281753bd76b95840b59cd Mon Sep 17 00:00:00 2001 From: Anamika Aggarwal Date: Tue, 28 Jul 2026 17:32:07 +0530 Subject: [PATCH 01/14] feat(monitors): expose a configurable control socket for each monitor Expose each monitor's control socket in the normal boot flow, so the runtime can keep talking to the VMM after the guest starts. Every monitor boots exactly as before; the only change is that its control socket stays open and reachable: - Firecracker launches with --api-sock instead of --no-api, keeping --config-file so the guest still boots from the config file. - QEMU exposes a QMP Unix socket in server mode, configured not to wait for a client before booting, alongside the disabled human monitor. - Cloud Hypervisor exposes its REST API socket (--api-socket). The socket location is configurable through a new socket_path option under a monitor's configuration, wired through MonitorConfig, ExecArgs and the state.json annotation passthrough, with a per-container default of /tmp/.sock behind a DefaultSocketDir constant and a shared resolveSocketPath helper. After changeRoot, urunc creates the socket path's directory inside the monitor rootfs, so any custom path works; it fails only if the location is invalid, such as a file already existing at one of the path's components. Extend the QEMU BuildExecCmd tests to cover the new argument and document the socket_path option. Signed-off-by: Anamika Aggarwal --- docs/configuration.md | 10 +++++++++ .../hypervisors/cloud_hypervisor.go | 4 ++++ pkg/unikontainers/hypervisors/firecracker.go | 8 +++++-- pkg/unikontainers/hypervisors/qemu.go | 4 ++++ pkg/unikontainers/hypervisors/qemu_test.go | 21 +++++++++++++++++++ pkg/unikontainers/hypervisors/utils.go | 17 +++++++++++++++ pkg/unikontainers/hypervisors/vmm.go | 11 ++++++++++ pkg/unikontainers/types/types.go | 8 ++++--- pkg/unikontainers/unikontainers.go | 15 +++++++++++++ pkg/unikontainers/urunc_config.go | 3 +++ 10 files changed, 96 insertions(+), 5 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index a2daa4ba4..baf48157c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -112,11 +112,20 @@ Each monitor subsection supports the following options: | `default_vcpus` | integer | `1` | Default number of virtual CPUs | | `path` | string | (empty) | Optional custom path to the monitor binary. If not specified, urunc will search for the binary in PATH | | `data_path` | string | (empty) | Optional custom path for the monitor's data file directory | +| `socket_path` | string | (empty) | Optional path for the monitor's control socket. If not specified, urunc uses a per-container default (`/tmp/.sock`) | Since Qemu is the only currently supported monitor which requires extra data to boot a VM, `urunc` will first check `/usr/local/share` and then `/usr/share` for Qemu's data files. +The `socket_path` option applies to the monitors that expose a control socket: +Firecracker (its API socket), Qemu (a QMP socket) and Cloud Hypervisor (its REST +API socket). It has no effect on the other monitors. The monitor creates the +socket inside its own (pivoted) rootfs; `urunc` creates the directory of a +custom `socket_path` there for you, so the path can be anywhere. It only fails +if the location is invalid, for example when a file already exists at one of the +directories in the path. + **Example:** ```toml @@ -130,6 +139,7 @@ data_path = "/usr/local/share/" default_memory_mb = 512 default_vcpus = 2 path = "/opt/firecracker/firecracker" +socket_path = "/run/urunc/fc.sock" ``` ### Extra binaries Configuration diff --git a/pkg/unikontainers/hypervisors/cloud_hypervisor.go b/pkg/unikontainers/hypervisors/cloud_hypervisor.go index 606a3c02e..c334b752b 100644 --- a/pkg/unikontainers/hypervisors/cloud_hypervisor.go +++ b/pkg/unikontainers/hypervisors/cloud_hypervisor.go @@ -70,6 +70,10 @@ func (ch *CloudHypervisor) BuildExecCmd(args types.ExecArgs, ukernel types.Unike // Start building the command exArgs := []string{ch.binaryPath} + // Expose the REST API over a control socket so the runtime can talk to + // Cloud Hypervisor after boot (e.g. for graceful shutdown). + exArgs = append(exArgs, "--api-socket", "path="+ResolveSocketPath(args)) + // Memory configuration if args.Sharedfs.Type == "virtiofs" { exArgs = append(exArgs, "--memory", fmt.Sprintf("size=%sM,shared=on", chMem)) diff --git a/pkg/unikontainers/hypervisors/firecracker.go b/pkg/unikontainers/hypervisors/firecracker.go index 9588a45bf..b8f169998 100644 --- a/pkg/unikontainers/hypervisors/firecracker.go +++ b/pkg/unikontainers/hypervisors/firecracker.go @@ -108,9 +108,13 @@ func (fc *Firecracker) BuildExecCmd(args types.ExecArgs, ukernel types.Unikernel // options in FC, since the string return value of the Monitor related // functions in the unikernel interface do not integrate well with FC's // json configuration. - cmdString := fc.Path() + " --no-api --config-file " + // Launch Firecracker with its API socket enabled (drop --no-api) while + // still booting the guest from the config file. This preserves today's + // boot behavior and additionally leaves the control socket open for use + // after the guest has started. + apiSockPath := ResolveSocketPath(args) JSONConfigFile := filepath.Join("/tmp/", FCJsonFilename) - cmdString += JSONConfigFile + cmdString := fc.Path() + " --api-sock " + apiSockPath + " --config-file " + JSONConfigFile if !args.Seccomp { cmdString += " --no-seccomp" } diff --git a/pkg/unikontainers/hypervisors/qemu.go b/pkg/unikontainers/hypervisors/qemu.go index 1ac77f870..269077bd8 100644 --- a/pkg/unikontainers/hypervisors/qemu.go +++ b/pkg/unikontainers/hypervisors/qemu.go @@ -67,6 +67,10 @@ func (q *Qemu) BuildExecCmd(args types.ExecArgs, ukernel types.Unikernel) ([]str cmdString += " -cpu host" // Choose CPU cmdString += " -enable-kvm" // Enable KVM to use CPU virt extensions cmdString += " -display none -vga none -serial stdio -monitor null" // Disable graphic output + // Expose a QMP control socket so the runtime can talk to QEMU after boot + // (e.g. for graceful shutdown). server,nowait lets QEMU boot without + // waiting for a client to connect. + cmdString += " -qmp unix:" + ResolveSocketPath(args) + ",server,nowait" if args.VCPUs > 0 { cmdString += fmt.Sprintf(" -smp %d", args.VCPUs) diff --git a/pkg/unikontainers/hypervisors/qemu_test.go b/pkg/unikontainers/hypervisors/qemu_test.go index fff45244c..0d81ff52c 100644 --- a/pkg/unikontainers/hypervisors/qemu_test.go +++ b/pkg/unikontainers/hypervisors/qemu_test.go @@ -80,6 +80,7 @@ func TestQemuBuildExecCmd(t *testing.T) { "-vga none", "-serial stdio", "-monitor null", + "-qmp unix:/tmp/.sock,server,nowait", "-m 256M", "-kernel " + testKernelPath, "-nic none", @@ -94,6 +95,26 @@ func TestQemuBuildExecCmd(t *testing.T) { "vhost-vsock-pci", }, }, + { + name: "configured SocketPath renders -qmp on that path", + args: types.ExecArgs{ + UnikernelPath: testKernelPath, + Command: testCommand, + SocketPath: "/run/urunc/q.sock", + }, + unikernel: &fakeUnikernel{}, + mustContain: []string{"-qmp unix:/run/urunc/q.sock,server,nowait"}, + }, + { + name: "default SocketPath uses the container-id path", + args: types.ExecArgs{ + UnikernelPath: testKernelPath, + Command: testCommand, + ContainerID: "abc123", + }, + unikernel: &fakeUnikernel{}, + mustContain: []string{"-qmp unix:/tmp/abc123.sock,server,nowait"}, + }, { name: "custom MemSizeB renders -m in MB", args: types.ExecArgs{ diff --git a/pkg/unikontainers/hypervisors/utils.go b/pkg/unikontainers/hypervisors/utils.go index 1bee33104..1882dcb56 100644 --- a/pkg/unikontainers/hypervisors/utils.go +++ b/pkg/unikontainers/hypervisors/utils.go @@ -17,13 +17,30 @@ package hypervisors import ( "errors" "fmt" + "path/filepath" "runtime" "strconv" "time" + "github.com/urunc-dev/urunc/pkg/unikontainers/types" "golang.org/x/sys/unix" ) +// DefaultSocketDir is the directory used for a monitor's control socket when +// no socket_path is configured. It always exists inside the monitor rootfs, +// so the default path needs no extra directory setup. +const DefaultSocketDir = "/tmp" + +// ResolveSocketPath returns the path for a monitor's control socket: the +// configured SocketPath when set, otherwise a per-container default under +// DefaultSocketDir. Shared by every monitor that exposes a control socket. +func ResolveSocketPath(args types.ExecArgs) string { + if args.SocketPath != "" { + return args.SocketPath + } + return filepath.Join(DefaultSocketDir, args.ContainerID+".sock") +} + func cpuArch() string { switch runtime.GOARCH { case "arm64": diff --git a/pkg/unikontainers/hypervisors/vmm.go b/pkg/unikontainers/hypervisors/vmm.go index f724d6ea8..e7cc730de 100644 --- a/pkg/unikontainers/hypervisors/vmm.go +++ b/pkg/unikontainers/hypervisors/vmm.go @@ -27,6 +27,17 @@ const DefaultMemory uint64 = 256 // The default memory for every hypervisor: 256 type VmmType string +// UsesControlSocket reports whether a monitor exposes a control socket whose +// path (socket_path) urunc must make reachable before the monitor launches. +func UsesControlSocket(vmmType VmmType) bool { + switch vmmType { + case FirecrackerVmm, QemuVmm, CloudHypervisorVmm: + return true + default: + return false + } +} + var ErrVMMNotInstalled = errors.New("vmm not found") var vmmLog = logrus.WithField("subsystem", "monitors") diff --git a/pkg/unikontainers/types/types.go b/pkg/unikontainers/types/types.go index f5d668b0d..8cf6a8a78 100644 --- a/pkg/unikontainers/types/types.go +++ b/pkg/unikontainers/types/types.go @@ -111,6 +111,7 @@ type ExecArgs struct { VAccelType string // Specifies the vAccel acceleration type(e.g. vsock). When empty, vAccel is disabled VSockDevPath string // The host directory where the fc unix socket is created VSockDevID int // The guest-cid + SocketPath string // The path of the monitor's control socket (empty means the monitor's default) Net NetDevParams Sharedfs SharedfsParams } @@ -138,7 +139,8 @@ type ExtraBinConfig struct { type MonitorConfig struct { DefaultMemoryMB uint `toml:"default_memory_mb"` DefaultVCPUs uint `toml:"default_vcpus"` - BinaryPath string `toml:"path,omitempty"` // Optional path to the hypervisor binary - DataPath string `toml:"data_path,omitempty"` // Optional path to the hypervisor data files (e.g. qemu bios stuff) - Vhost bool `toml:"vhost,omitempty"` // Optional: enable vhost for network performance optimization + BinaryPath string `toml:"path,omitempty"` // Optional path to the hypervisor binary + DataPath string `toml:"data_path,omitempty"` // Optional path to the hypervisor data files (e.g. qemu bios stuff) + Vhost bool `toml:"vhost,omitempty"` // Optional: enable vhost for network performance optimization + SocketPath string `toml:"socket_path,omitempty"` // Optional path for the monitor's control socket (falls back to a per-container default) } diff --git a/pkg/unikontainers/unikontainers.go b/pkg/unikontainers/unikontainers.go index ad0bf2897..9dcb156a6 100644 --- a/pkg/unikontainers/unikontainers.go +++ b/pkg/unikontainers/unikontainers.go @@ -498,6 +498,7 @@ func (u *Unikontainer) Exec(metrics m.Writer) error { defaultVCPUs = 1 } defaultMemSizeMB := u.UruncCfg.Monitors[vmmType].DefaultMemoryMB + socketPath := u.UruncCfg.Monitors[vmmType].SocketPath // ExecArgs vmmArgs := types.ExecArgs{ @@ -507,6 +508,7 @@ func (u *Unikontainer) Exec(metrics m.Writer) error { Seccomp: true, // Enable Seccomp by default MemSizeB: monitorMemoryBytes(defaultMemSizeMB, u.Spec.Linux.Resources), VCPUs: uint(defaultVCPUs), + SocketPath: socketPath, Environment: os.Environ(), } @@ -710,6 +712,19 @@ func (u *Unikontainer) Exec(metrics m.Writer) error { return err } + // Ensure the monitor's control socket directory exists inside the monitor + // rootfs, so the monitor can bind its socket there. changeRoot has already + // made this process' root the monitor rootfs, so the socket path is + // created relative to it. The default (/tmp) already exists; a custom + // socket_path may point at a directory that does not, and MkdirAll fails + // if that location is invalid (e.g. a file already exists there). + if hypervisors.UsesControlSocket(hypervisors.VmmType(vmmType)) { + sockDir := filepath.Dir(hypervisors.ResolveSocketPath(vmmArgs)) + if err = os.MkdirAll(sockDir, 0o755); err != nil { + return fmt.Errorf("failed to create control socket directory %q: %w", sockDir, err) + } + } + // uid/gid // Setup uid, gid and additional groups for the monitor process err = setupUser(u.Spec.Process.User) diff --git a/pkg/unikontainers/urunc_config.go b/pkg/unikontainers/urunc_config.go index 22573f43c..3037e933a 100644 --- a/pkg/unikontainers/urunc_config.go +++ b/pkg/unikontainers/urunc_config.go @@ -145,6 +145,7 @@ func (p *UruncConfig) Map() map[string]string { cfgMap[prefix+"binary_path"] = hvCfg.BinaryPath cfgMap[prefix+"data_path"] = hvCfg.DataPath cfgMap[prefix+"vhost"] = strconv.FormatBool(hvCfg.Vhost) + cfgMap[prefix+"socket_path"] = hvCfg.SocketPath } for eb, ebCfg := range p.ExtraBins { prefix := "urunc_config.extra_binaries." + eb + "." @@ -191,6 +192,8 @@ func UruncConfigFromMap(cfgMap map[string]string) *UruncConfig { hvCfg.BinaryPath = val case "data_path": hvCfg.DataPath = val + case "socket_path": + hvCfg.SocketPath = val case "vhost": boolVal, err := strconv.ParseBool(val) if err != nil { From ec625dfb1ba4c70e06edf1846d1441ed78302370 Mon Sep 17 00:00:00 2001 From: Anamika Aggarwal Date: Tue, 4 Aug 2026 13:03:47 +0530 Subject: [PATCH 02/14] feat(monitors): expose the control socket only when configured Remove the default /tmp/.sock socket path. A monitor now gets a control socket only when socket_path is set in config; with no socket_path it launches with no control socket, exactly like upstream. Firecracker restores --no-api in that case. Signed-off-by: Anamika Aggarwal --- .../hypervisors/cloud_hypervisor.go | 9 +- .../hypervisors/cloud_hypervisor_test.go | 79 ++++++++++++++++++ pkg/unikontainers/hypervisors/firecracker.go | 20 +++-- .../hypervisors/firecracker_test.go | 82 +++++++++++++++++++ pkg/unikontainers/hypervisors/qemu.go | 11 ++- pkg/unikontainers/hypervisors/qemu_test.go | 8 +- pkg/unikontainers/hypervisors/utils.go | 17 ---- pkg/unikontainers/unikontainers.go | 2 +- 8 files changed, 193 insertions(+), 35 deletions(-) create mode 100644 pkg/unikontainers/hypervisors/cloud_hypervisor_test.go create mode 100644 pkg/unikontainers/hypervisors/firecracker_test.go diff --git a/pkg/unikontainers/hypervisors/cloud_hypervisor.go b/pkg/unikontainers/hypervisors/cloud_hypervisor.go index c334b752b..9f4c55007 100644 --- a/pkg/unikontainers/hypervisors/cloud_hypervisor.go +++ b/pkg/unikontainers/hypervisors/cloud_hypervisor.go @@ -70,9 +70,12 @@ func (ch *CloudHypervisor) BuildExecCmd(args types.ExecArgs, ukernel types.Unike // Start building the command exArgs := []string{ch.binaryPath} - // Expose the REST API over a control socket so the runtime can talk to - // Cloud Hypervisor after boot (e.g. for graceful shutdown). - exArgs = append(exArgs, "--api-socket", "path="+ResolveSocketPath(args)) + // Expose the REST API over a control socket only when a socket_path is + // configured, so the runtime can talk to Cloud Hypervisor after boot (e.g. + // for graceful shutdown). With no configured path no control socket is set up. + if args.SocketPath != "" { + exArgs = append(exArgs, "--api-socket", "path="+args.SocketPath) + } // Memory configuration if args.Sharedfs.Type == "virtiofs" { diff --git a/pkg/unikontainers/hypervisors/cloud_hypervisor_test.go b/pkg/unikontainers/hypervisors/cloud_hypervisor_test.go new file mode 100644 index 000000000..ceeebe50e --- /dev/null +++ b/pkg/unikontainers/hypervisors/cloud_hypervisor_test.go @@ -0,0 +1,79 @@ +// Copyright (c) 2023-2026, Nubificus LTD +// +// 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 +// +// http://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 hypervisors + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/urunc-dev/urunc/pkg/unikontainers/types" +) + +const testCHBinary = "/usr/bin/cloud-hypervisor" + +// TestCloudHypervisorBuildExecCmdSocket verifies that Cloud Hypervisor exposes +// its REST API control socket only when a socket_path is configured. With no +// configured path, no --api-socket flag is emitted. +func TestCloudHypervisorBuildExecCmdSocket(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args types.ExecArgs + mustContain []string + mustNotContain []string + }{ + { + name: "configured SocketPath renders --api-socket on that path", + args: types.ExecArgs{ + UnikernelPath: testKernelPath, + Command: testCommand, + SocketPath: "/run/urunc/ch.sock", + }, + mustContain: []string{"--api-socket path=/run/urunc/ch.sock"}, + }, + { + name: "unset SocketPath omits --api-socket", + args: types.ExecArgs{ + UnikernelPath: testKernelPath, + Command: testCommand, + ContainerID: "abc123", + }, + mustNotContain: []string{"--api-socket"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ch := &CloudHypervisor{binary: CloudHypervisorBinary, binaryPath: testCHBinary} + out, err := ch.BuildExecCmd(tt.args, &fakeUnikernel{}) + assert.NoError(t, err) + assert.NotEmpty(t, out) + + assert.Equal(t, testCHBinary, out[0], "binary path must be the first element") + joined := strings.Join(out, " ") + + for _, want := range tt.mustContain { + assert.Contains(t, joined, want, "expected %q to be present", want) + } + for _, notWant := range tt.mustNotContain { + assert.NotContains(t, joined, notWant, "expected %q to be absent", notWant) + } + }) + } +} diff --git a/pkg/unikontainers/hypervisors/firecracker.go b/pkg/unikontainers/hypervisors/firecracker.go index b8f169998..13cbcfb7a 100644 --- a/pkg/unikontainers/hypervisors/firecracker.go +++ b/pkg/unikontainers/hypervisors/firecracker.go @@ -108,13 +108,21 @@ func (fc *Firecracker) BuildExecCmd(args types.ExecArgs, ukernel types.Unikernel // options in FC, since the string return value of the Monitor related // functions in the unikernel interface do not integrate well with FC's // json configuration. - // Launch Firecracker with its API socket enabled (drop --no-api) while - // still booting the guest from the config file. This preserves today's - // boot behavior and additionally leaves the control socket open for use - // after the guest has started. - apiSockPath := ResolveSocketPath(args) + // Launch Firecracker in one of two modes, both booting the guest from the + // config file: + // - With a socket_path configured, enable the API socket + // (--api-sock ) so the control socket stays open for use after + // the guest has started. + // - With no socket_path, launch with --no-api (upstream default) so no + // control socket is exposed. JSONConfigFile := filepath.Join("/tmp/", FCJsonFilename) - cmdString := fc.Path() + " --api-sock " + apiSockPath + " --config-file " + JSONConfigFile + cmdString := fc.Path() + if args.SocketPath != "" { + cmdString += " --api-sock " + args.SocketPath + } else { + cmdString += " --no-api" + } + cmdString += " --config-file " + JSONConfigFile if !args.Seccomp { cmdString += " --no-seccomp" } diff --git a/pkg/unikontainers/hypervisors/firecracker_test.go b/pkg/unikontainers/hypervisors/firecracker_test.go new file mode 100644 index 000000000..556ff9d20 --- /dev/null +++ b/pkg/unikontainers/hypervisors/firecracker_test.go @@ -0,0 +1,82 @@ +// Copyright (c) 2023-2026, Nubificus LTD +// +// 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 +// +// http://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 hypervisors + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/urunc-dev/urunc/pkg/unikontainers/types" +) + +const testFCBinary = "/usr/bin/firecracker" + +// TestFirecrackerBuildExecCmdSocket verifies that Firecracker enables its API +// socket only when a socket_path is configured. With no configured path it +// restores the upstream launch mode (--no-api --config-file), which boots the +// guest from the config file without exposing a control socket. +func TestFirecrackerBuildExecCmdSocket(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args types.ExecArgs + mustContain []string + mustNotContain []string + }{ + { + name: "configured SocketPath renders --api-sock and keeps --config-file", + args: types.ExecArgs{ + UnikernelPath: testKernelPath, + Command: testCommand, + SocketPath: "/run/urunc/fc.sock", + }, + mustContain: []string{"--api-sock /run/urunc/fc.sock", "--config-file"}, + mustNotContain: []string{"--no-api"}, + }, + { + name: "unset SocketPath restores --no-api and omits --api-sock", + args: types.ExecArgs{ + UnikernelPath: testKernelPath, + Command: testCommand, + ContainerID: "abc123", + }, + mustContain: []string{"--no-api", "--config-file"}, + mustNotContain: []string{"--api-sock"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + fc := &Firecracker{binary: FirecrackerBinary, binaryPath: testFCBinary} + out, err := fc.BuildExecCmd(tt.args, &fakeUnikernel{}) + assert.NoError(t, err) + assert.NotEmpty(t, out) + + assert.Equal(t, testFCBinary, out[0], "binary path must be the first element") + joined := strings.Join(out, " ") + + for _, want := range tt.mustContain { + assert.Contains(t, joined, want, "expected %q to be present", want) + } + for _, notWant := range tt.mustNotContain { + assert.NotContains(t, joined, notWant, "expected %q to be absent", notWant) + } + }) + } +} diff --git a/pkg/unikontainers/hypervisors/qemu.go b/pkg/unikontainers/hypervisors/qemu.go index 269077bd8..456c3e098 100644 --- a/pkg/unikontainers/hypervisors/qemu.go +++ b/pkg/unikontainers/hypervisors/qemu.go @@ -67,10 +67,13 @@ func (q *Qemu) BuildExecCmd(args types.ExecArgs, ukernel types.Unikernel) ([]str cmdString += " -cpu host" // Choose CPU cmdString += " -enable-kvm" // Enable KVM to use CPU virt extensions cmdString += " -display none -vga none -serial stdio -monitor null" // Disable graphic output - // Expose a QMP control socket so the runtime can talk to QEMU after boot - // (e.g. for graceful shutdown). server,nowait lets QEMU boot without - // waiting for a client to connect. - cmdString += " -qmp unix:" + ResolveSocketPath(args) + ",server,nowait" + // Expose a QMP control socket only when a socket_path is configured, so the + // runtime can talk to QEMU after boot (e.g. for graceful shutdown). With no + // configured path QEMU boots with no control socket. server,nowait lets QEMU + // boot without waiting for a client to connect. + if args.SocketPath != "" { + cmdString += " -qmp unix:" + args.SocketPath + ",server,nowait" + } if args.VCPUs > 0 { cmdString += fmt.Sprintf(" -smp %d", args.VCPUs) diff --git a/pkg/unikontainers/hypervisors/qemu_test.go b/pkg/unikontainers/hypervisors/qemu_test.go index 0d81ff52c..e959165f4 100644 --- a/pkg/unikontainers/hypervisors/qemu_test.go +++ b/pkg/unikontainers/hypervisors/qemu_test.go @@ -80,7 +80,6 @@ func TestQemuBuildExecCmd(t *testing.T) { "-vga none", "-serial stdio", "-monitor null", - "-qmp unix:/tmp/.sock,server,nowait", "-m 256M", "-kernel " + testKernelPath, "-nic none", @@ -93,6 +92,7 @@ func TestQemuBuildExecCmd(t *testing.T) { "vhost-user-fs-pci", "virtio-blk-pci", "vhost-vsock-pci", + "-qmp", }, }, { @@ -106,14 +106,14 @@ func TestQemuBuildExecCmd(t *testing.T) { mustContain: []string{"-qmp unix:/run/urunc/q.sock,server,nowait"}, }, { - name: "default SocketPath uses the container-id path", + name: "unset SocketPath omits -qmp", args: types.ExecArgs{ UnikernelPath: testKernelPath, Command: testCommand, ContainerID: "abc123", }, - unikernel: &fakeUnikernel{}, - mustContain: []string{"-qmp unix:/tmp/abc123.sock,server,nowait"}, + unikernel: &fakeUnikernel{}, + mustNotContain: []string{"-qmp"}, }, { name: "custom MemSizeB renders -m in MB", diff --git a/pkg/unikontainers/hypervisors/utils.go b/pkg/unikontainers/hypervisors/utils.go index 1882dcb56..1bee33104 100644 --- a/pkg/unikontainers/hypervisors/utils.go +++ b/pkg/unikontainers/hypervisors/utils.go @@ -17,30 +17,13 @@ package hypervisors import ( "errors" "fmt" - "path/filepath" "runtime" "strconv" "time" - "github.com/urunc-dev/urunc/pkg/unikontainers/types" "golang.org/x/sys/unix" ) -// DefaultSocketDir is the directory used for a monitor's control socket when -// no socket_path is configured. It always exists inside the monitor rootfs, -// so the default path needs no extra directory setup. -const DefaultSocketDir = "/tmp" - -// ResolveSocketPath returns the path for a monitor's control socket: the -// configured SocketPath when set, otherwise a per-container default under -// DefaultSocketDir. Shared by every monitor that exposes a control socket. -func ResolveSocketPath(args types.ExecArgs) string { - if args.SocketPath != "" { - return args.SocketPath - } - return filepath.Join(DefaultSocketDir, args.ContainerID+".sock") -} - func cpuArch() string { switch runtime.GOARCH { case "arm64": diff --git a/pkg/unikontainers/unikontainers.go b/pkg/unikontainers/unikontainers.go index 9dcb156a6..c91e3c800 100644 --- a/pkg/unikontainers/unikontainers.go +++ b/pkg/unikontainers/unikontainers.go @@ -719,7 +719,7 @@ func (u *Unikontainer) Exec(metrics m.Writer) error { // socket_path may point at a directory that does not, and MkdirAll fails // if that location is invalid (e.g. a file already exists there). if hypervisors.UsesControlSocket(hypervisors.VmmType(vmmType)) { - sockDir := filepath.Dir(hypervisors.ResolveSocketPath(vmmArgs)) + sockDir := filepath.Dir(vmmArgs.SocketPath) if err = os.MkdirAll(sockDir, 0o755); err != nil { return fmt.Errorf("failed to create control socket directory %q: %w", sockDir, err) } From cb671151d888f77a50c2cf63dcd5b0312fa08b77 Mon Sep 17 00:00:00 2001 From: Anamika Aggarwal Date: Tue, 4 Aug 2026 13:09:49 +0530 Subject: [PATCH 03/14] fix(monitors): set up the control socket after dropping privileges Move the control socket directory creation from between changeRoot and setupUser to right after setupUser, so it runs as the monitor's user and a non-root monitor can create and use it. Also remove a stale socket left at the same path by a previous instance before the monitor binds, so a restart reusing the same socket_path does not fail to bind. Signed-off-by: Anamika Aggarwal --- pkg/unikontainers/unikontainers.go | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/pkg/unikontainers/unikontainers.go b/pkg/unikontainers/unikontainers.go index c91e3c800..0a0652c25 100644 --- a/pkg/unikontainers/unikontainers.go +++ b/pkg/unikontainers/unikontainers.go @@ -712,19 +712,6 @@ func (u *Unikontainer) Exec(metrics m.Writer) error { return err } - // Ensure the monitor's control socket directory exists inside the monitor - // rootfs, so the monitor can bind its socket there. changeRoot has already - // made this process' root the monitor rootfs, so the socket path is - // created relative to it. The default (/tmp) already exists; a custom - // socket_path may point at a directory that does not, and MkdirAll fails - // if that location is invalid (e.g. a file already exists there). - if hypervisors.UsesControlSocket(hypervisors.VmmType(vmmType)) { - sockDir := filepath.Dir(vmmArgs.SocketPath) - if err = os.MkdirAll(sockDir, 0o755); err != nil { - return fmt.Errorf("failed to create control socket directory %q: %w", sockDir, err) - } - } - // uid/gid // Setup uid, gid and additional groups for the monitor process err = setupUser(u.Spec.Process.User) @@ -732,6 +719,22 @@ func (u *Unikontainer) Exec(metrics m.Writer) error { return err } + // Set up the monitor's control socket, only when one is configured. + // This runs after setupUser so the directory and any stale socket are + // handled as the monitor's user, and the monitor (which may be non-root) + // can bind its socket there. + if hypervisors.UsesControlSocket(hypervisors.VmmType(vmmType)) && vmmArgs.SocketPath != "" { + sockDir := filepath.Dir(vmmArgs.SocketPath) + if err = os.MkdirAll(sockDir, 0o755); err != nil { + return fmt.Errorf("failed to create control socket directory %q: %w", sockDir, err) + } + // Remove a stale socket left by a previous instance (e.g. a restart + // reusing the same socket_path) so the monitor can bind it again. + if err = os.Remove(vmmArgs.SocketPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to remove stale control socket %q: %w", vmmArgs.SocketPath, err) + } + } + // execute hooks // NOTE: StartContainer hooks are supposed to run right before the init of // the container. However, in the case of a Linux-based container, the init From a3b66f20c30fa61c1871751e408867a089733e1f Mon Sep 17 00:00:00 2001 From: Anamika Aggarwal Date: Tue, 4 Aug 2026 13:14:09 +0530 Subject: [PATCH 04/14] docs(configuration): socket_path is opt-in with no default Signed-off-by: Anamika Aggarwal --- docs/configuration.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index baf48157c..c74426427 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -112,7 +112,7 @@ Each monitor subsection supports the following options: | `default_vcpus` | integer | `1` | Default number of virtual CPUs | | `path` | string | (empty) | Optional custom path to the monitor binary. If not specified, urunc will search for the binary in PATH | | `data_path` | string | (empty) | Optional custom path for the monitor's data file directory | -| `socket_path` | string | (empty) | Optional path for the monitor's control socket. If not specified, urunc uses a per-container default (`/tmp/.sock`) | +| `socket_path` | string | (empty) | Optional path for the monitor's control socket. If not set, the monitor runs without a control socket | Since Qemu is the only currently supported monitor which requires extra data to boot a VM, `urunc` will first check `/usr/local/share` and then `/usr/share` for @@ -120,11 +120,14 @@ Qemu's data files. The `socket_path` option applies to the monitors that expose a control socket: Firecracker (its API socket), Qemu (a QMP socket) and Cloud Hypervisor (its REST -API socket). It has no effect on the other monitors. The monitor creates the -socket inside its own (pivoted) rootfs; `urunc` creates the directory of a -custom `socket_path` there for you, so the path can be anywhere. It only fails -if the location is invalid, for example when a file already exists at one of the -directories in the path. +API socket). It has no effect on the other monitors. The control socket is +opt-in: it exists only when `socket_path` is set. If it is not set, the +monitor runs with no control socket at all; an operator can leave it unset if +they do not need the socket, or to keep a smaller attack surface. When it is +set, the monitor creates the socket inside its own (pivoted) rootfs; `urunc` +creates the directory of a custom `socket_path` there for you, so the path +can be anywhere. It fails cleanly if the location is invalid, for example +when a file already exists at one of the directories in the path. **Example:** From c19b85ce22f460f0c19e71a57366bd1c587e3405 Mon Sep 17 00:00:00 2001 From: Anamika Aggarwal Date: Tue, 4 Aug 2026 13:41:27 +0530 Subject: [PATCH 05/14] docs(monitors): fix stale socket_path field comments Signed-off-by: Anamika Aggarwal --- pkg/unikontainers/types/types.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/unikontainers/types/types.go b/pkg/unikontainers/types/types.go index 8cf6a8a78..d4fc0d705 100644 --- a/pkg/unikontainers/types/types.go +++ b/pkg/unikontainers/types/types.go @@ -111,7 +111,7 @@ type ExecArgs struct { VAccelType string // Specifies the vAccel acceleration type(e.g. vsock). When empty, vAccel is disabled VSockDevPath string // The host directory where the fc unix socket is created VSockDevID int // The guest-cid - SocketPath string // The path of the monitor's control socket (empty means the monitor's default) + SocketPath string // The path of the monitor's control socket (empty means no control socket) Net NetDevParams Sharedfs SharedfsParams } @@ -142,5 +142,5 @@ type MonitorConfig struct { BinaryPath string `toml:"path,omitempty"` // Optional path to the hypervisor binary DataPath string `toml:"data_path,omitempty"` // Optional path to the hypervisor data files (e.g. qemu bios stuff) Vhost bool `toml:"vhost,omitempty"` // Optional: enable vhost for network performance optimization - SocketPath string `toml:"socket_path,omitempty"` // Optional path for the monitor's control socket (falls back to a per-container default) + SocketPath string `toml:"socket_path,omitempty"` // Optional path for the monitor's control socket (unset means no control socket) } From 224cf3bd97bb53cdd275383c728622d31e3e58dd Mon Sep 17 00:00:00 2001 From: Anamika Aggarwal Date: Tue, 4 Aug 2026 13:56:45 +0530 Subject: [PATCH 06/14] chore(lint): add nowait to the cspell dictionary The QEMU QMP flag string "server,nowait" trips cspell in qemu.go and qemu_test.go. Signed-off-by: Anamika Aggarwal --- .github/linters/urunc-dict.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/linters/urunc-dict.txt b/.github/linters/urunc-dict.txt index 69b63867c..f220e5a2a 100644 --- a/.github/linters/urunc-dict.txt +++ b/.github/linters/urunc-dict.txt @@ -429,3 +429,4 @@ hyperlight Hyperlight Odysseas Kalaitsidis +nowait From 46168dd15315cb62b838b832c2376ae82182823b Mon Sep 17 00:00:00 2001 From: Anamika Aggarwal Date: Fri, 7 Aug 2026 06:20:15 +0530 Subject: [PATCH 07/14] fix(monitors): rework the control socket setup per review Address the review on the control-socket configuration: - Make UsesControlSocket a method on the VMM interface instead of a switch over the monitor type in vmm.go. Every monitor now declares whether it exposes a control socket, so a new monitor cannot be added without deciding this (the switch could be silently forgotten). Qemu, Firecracker and Cloud Hypervisor return true; hvt, spt and hedge return false. - Create the control socket directory with 0o700 instead of 0o755. The directory is owned by the monitor's user and only the monitor needs it. - Move the stale socket removal out of Exec into Delete, where the rest of the container teardown happens. Delete removes the socket at its real path inside the monitor rootfs, and only when it is actually a socket, so a misconfigured socket_path over a regular file is never deleted. Removing the unlink from Exec also means the monitor now fails to bind on a pre-existing file instead of urunc deleting it. - Move the Firecracker two-mode comment out of the FIXME block to right before the if/else and trim it. Signed-off-by: Anamika Aggarwal --- .../hypervisors/cloud_hypervisor.go | 6 ++++ pkg/unikontainers/hypervisors/firecracker.go | 15 ++++---- pkg/unikontainers/hypervisors/hedge.go | 5 +++ pkg/unikontainers/hypervisors/hvt.go | 5 +++ pkg/unikontainers/hypervisors/hyperlight.go | 5 +++ pkg/unikontainers/hypervisors/qemu.go | 5 +++ pkg/unikontainers/hypervisors/spt.go | 5 +++ pkg/unikontainers/hypervisors/vmm.go | 11 ------ pkg/unikontainers/hypervisors/vmm_test.go | 30 ++++++++++++++++ pkg/unikontainers/types/types.go | 4 +++ pkg/unikontainers/unikontainers.go | 35 +++++++++++++------ 11 files changed, 97 insertions(+), 29 deletions(-) diff --git a/pkg/unikontainers/hypervisors/cloud_hypervisor.go b/pkg/unikontainers/hypervisors/cloud_hypervisor.go index 9f4c55007..345d9b822 100644 --- a/pkg/unikontainers/hypervisors/cloud_hypervisor.go +++ b/pkg/unikontainers/hypervisors/cloud_hypervisor.go @@ -50,6 +50,12 @@ func (ch *CloudHypervisor) UsesKVM() bool { } // SupportsSharedfs returns true as Cloud Hypervisor supports virtiofs +// UsesControlSocket reports that Cloud Hypervisor exposes a control socket (its +// REST API socket). +func (ch *CloudHypervisor) UsesControlSocket() bool { + return true +} + func (ch *CloudHypervisor) SupportsSharedfs(fsType string) bool { switch fsType { case "virtio": diff --git a/pkg/unikontainers/hypervisors/firecracker.go b/pkg/unikontainers/hypervisors/firecracker.go index 13cbcfb7a..d13376d85 100644 --- a/pkg/unikontainers/hypervisors/firecracker.go +++ b/pkg/unikontainers/hypervisors/firecracker.go @@ -97,6 +97,12 @@ func (fc *Firecracker) SupportsSharedfs(_ string) bool { return false } +// UsesControlSocket reports that Firecracker exposes a control socket (its API +// socket). +func (fc *Firecracker) UsesControlSocket() bool { + return true +} + func (fc *Firecracker) Path() string { return fc.binaryPath } @@ -108,15 +114,10 @@ func (fc *Firecracker) BuildExecCmd(args types.ExecArgs, ukernel types.Unikernel // options in FC, since the string return value of the Monitor related // functions in the unikernel interface do not integrate well with FC's // json configuration. - // Launch Firecracker in one of two modes, both booting the guest from the - // config file: - // - With a socket_path configured, enable the API socket - // (--api-sock ) so the control socket stays open for use after - // the guest has started. - // - With no socket_path, launch with --no-api (upstream default) so no - // control socket is exposed. JSONConfigFile := filepath.Join("/tmp/", FCJsonFilename) cmdString := fc.Path() + // Enable the API socket when a socket_path is configured, otherwise keep the + // upstream --no-api. Either way the guest boots from the config file. if args.SocketPath != "" { cmdString += " --api-sock " + args.SocketPath } else { diff --git a/pkg/unikontainers/hypervisors/hedge.go b/pkg/unikontainers/hypervisors/hedge.go index 7051ba7f0..a53416894 100644 --- a/pkg/unikontainers/hypervisors/hedge.go +++ b/pkg/unikontainers/hypervisors/hedge.go @@ -51,6 +51,11 @@ func (h *Hedge) SupportsSharedfs(_ string) bool { return false } +// UsesControlSocket reports that Hedge exposes no control socket. +func (h *Hedge) UsesControlSocket() bool { + return false +} + func (h *Hedge) Path() string { return "" } diff --git a/pkg/unikontainers/hypervisors/hvt.go b/pkg/unikontainers/hypervisors/hvt.go index 69cb12b91..55cd84301 100644 --- a/pkg/unikontainers/hypervisors/hvt.go +++ b/pkg/unikontainers/hypervisors/hvt.go @@ -139,6 +139,11 @@ func (h *HVT) SupportsSharedfs(_ string) bool { return false } +// UsesControlSocket reports that HVT exposes no control socket. +func (h *HVT) UsesControlSocket() bool { + return false +} + // Path returns the path to the hvt binary. func (h *HVT) Path() string { return h.binaryPath diff --git a/pkg/unikontainers/hypervisors/hyperlight.go b/pkg/unikontainers/hypervisors/hyperlight.go index 67d67be6d..dbe4d204c 100644 --- a/pkg/unikontainers/hypervisors/hyperlight.go +++ b/pkg/unikontainers/hypervisors/hyperlight.go @@ -46,6 +46,11 @@ func (h *Hyperlight) SupportsSharedfs(_ string) bool { return false } +// UsesControlSocket reports that Hyperlight exposes no control socket. +func (h *Hyperlight) UsesControlSocket() bool { + return false +} + // Path returns the path to the hyperlight binary. func (h *Hyperlight) Path() string { return h.binaryPath diff --git a/pkg/unikontainers/hypervisors/qemu.go b/pkg/unikontainers/hypervisors/qemu.go index 456c3e098..010bd9e8e 100644 --- a/pkg/unikontainers/hypervisors/qemu.go +++ b/pkg/unikontainers/hypervisors/qemu.go @@ -56,6 +56,11 @@ func (q *Qemu) SupportsSharedfs(_ string) bool { return true } +// UsesControlSocket reports that QEMU exposes a control socket (QMP). +func (q *Qemu) UsesControlSocket() bool { + return true +} + func (q *Qemu) Path() string { return q.binaryPath } diff --git a/pkg/unikontainers/hypervisors/spt.go b/pkg/unikontainers/hypervisors/spt.go index 60b5401f6..33ea94ad9 100644 --- a/pkg/unikontainers/hypervisors/spt.go +++ b/pkg/unikontainers/hypervisors/spt.go @@ -51,6 +51,11 @@ func (s *SPT) SupportsSharedfs(_ string) bool { return false } +// UsesControlSocket reports that SPT exposes no control socket. +func (s *SPT) UsesControlSocket() bool { + return false +} + // Path returns the path to the spt binary. func (s *SPT) Path() string { return s.binaryPath diff --git a/pkg/unikontainers/hypervisors/vmm.go b/pkg/unikontainers/hypervisors/vmm.go index e7cc730de..f724d6ea8 100644 --- a/pkg/unikontainers/hypervisors/vmm.go +++ b/pkg/unikontainers/hypervisors/vmm.go @@ -27,17 +27,6 @@ const DefaultMemory uint64 = 256 // The default memory for every hypervisor: 256 type VmmType string -// UsesControlSocket reports whether a monitor exposes a control socket whose -// path (socket_path) urunc must make reachable before the monitor launches. -func UsesControlSocket(vmmType VmmType) bool { - switch vmmType { - case FirecrackerVmm, QemuVmm, CloudHypervisorVmm: - return true - default: - return false - } -} - var ErrVMMNotInstalled = errors.New("vmm not found") var vmmLog = logrus.WithField("subsystem", "monitors") diff --git a/pkg/unikontainers/hypervisors/vmm_test.go b/pkg/unikontainers/hypervisors/vmm_test.go index 6d0f7ebdb..2fec30b10 100644 --- a/pkg/unikontainers/hypervisors/vmm_test.go +++ b/pkg/unikontainers/hypervisors/vmm_test.go @@ -18,8 +18,38 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/urunc-dev/urunc/pkg/unikontainers/types" ) +// TestUsesControlSocket verifies every monitor reports whether it exposes a +// control socket. The monitors that expose one (Qemu, Firecracker, Cloud +// Hypervisor) must return true; the rest must return false. Because it is an +// interface method, a new monitor cannot compile without declaring it. +func TestUsesControlSocket(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + vmm types.VMM + want bool + }{ + {"qemu", &Qemu{}, true}, + {"firecracker", &Firecracker{}, true}, + {"cloud-hypervisor", &CloudHypervisor{}, true}, + {"hvt", &HVT{}, false}, + {"spt", &SPT{}, false}, + {"hedge", &Hedge{}, false}, + {"hyperlight", &Hyperlight{}, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, tt.vmm.UsesControlSocket()) + }) + } +} + func TestVMMFactoryQemuVhostFalse(t *testing.T) { t.Parallel() factory, exists := vmmFactories[QemuVmm] diff --git a/pkg/unikontainers/types/types.go b/pkg/unikontainers/types/types.go index d4fc0d705..8732af9b2 100644 --- a/pkg/unikontainers/types/types.go +++ b/pkg/unikontainers/types/types.go @@ -41,6 +41,10 @@ type VMM interface { Path() string UsesKVM() bool SupportsSharedfs(string) bool + // UsesControlSocket reports whether the monitor exposes a control socket + // (configured through socket_path) that urunc must make reachable before + // the monitor launches. + UsesControlSocket() bool Ok() error } diff --git a/pkg/unikontainers/unikontainers.go b/pkg/unikontainers/unikontainers.go index 0a0652c25..54cc5e4ba 100644 --- a/pkg/unikontainers/unikontainers.go +++ b/pkg/unikontainers/unikontainers.go @@ -719,20 +719,15 @@ func (u *Unikontainer) Exec(metrics m.Writer) error { return err } - // Set up the monitor's control socket, only when one is configured. - // This runs after setupUser so the directory and any stale socket are - // handled as the monitor's user, and the monitor (which may be non-root) - // can bind its socket there. - if hypervisors.UsesControlSocket(hypervisors.VmmType(vmmType)) && vmmArgs.SocketPath != "" { + // Create the directory for the monitor's control socket, only when one is + // configured. This runs after setupUser so the directory is owned by the + // monitor's user and a non-root monitor can bind its socket there. The + // socket file itself is created by the monitor and removed in Delete. + if vmm.UsesControlSocket() && vmmArgs.SocketPath != "" { sockDir := filepath.Dir(vmmArgs.SocketPath) - if err = os.MkdirAll(sockDir, 0o755); err != nil { + if err = os.MkdirAll(sockDir, 0o700); err != nil { return fmt.Errorf("failed to create control socket directory %q: %w", sockDir, err) } - // Remove a stale socket left by a previous instance (e.g. a restart - // reusing the same socket_path) so the monitor can bind it again. - if err = os.Remove(vmmArgs.SocketPath); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("failed to remove stale control socket %q: %w", vmmArgs.SocketPath, err) - } } // execute hooks @@ -861,6 +856,7 @@ func (u *Unikontainer) Kill() error { func (u *Unikontainer) Delete() error { var dirs []string var prefPath string + var monitorRoot string if u.isRunning() { return fmt.Errorf("cannot delete running container: %s", u.State.ID) @@ -908,6 +904,7 @@ func (u *Unikontainer) Delete() error { // clean it up. dirs = append(dirs, monitorRootfsDirName) prefPath = bundleDir + monitorRoot = monRootfs } else { // Otherwise remove the enw directories we created inside the // container's rootfs. @@ -924,6 +921,22 @@ func (u *Unikontainer) Delete() error { } dirs = append(dirs, vmm.Path()) prefPath = rootfsDir + monitorRoot = rootfsDir + } + + // Remove the monitor's control socket, if one was configured, so that a + // restart reusing the same socket_path does not find a stale socket. The + // socket lives inside the monitor rootfs (the monitor binds it there after + // the pivot in Exec); at delete time that rootfs is reachable at its real + // path. Only an actual socket is removed, so a misconfigured socket_path + // pointing at a regular file is never deleted. + if socketPath := u.UruncCfg.Monitors[vmmType].SocketPath; socketPath != "" && vmm.UsesControlSocket() { + sockRealPath := filepath.Join(monitorRoot, socketPath) + if info, statErr := os.Lstat(sockRealPath); statErr == nil && info.Mode()&os.ModeSocket != 0 { + if err = os.Remove(sockRealPath); err != nil { + return fmt.Errorf("failed to remove control socket %q: %w", sockRealPath, err) + } + } } err = rmMultipleDirs(prefPath, dirs) From cd63c7731d3eebdca3b62b7f98eedab74fbdbf07 Mon Sep 17 00:00:00 2001 From: Anamika Aggarwal Date: Fri, 7 Aug 2026 06:20:21 +0530 Subject: [PATCH 08/14] docs(configuration): clarify socket_path constraints and cleanup Drop the claim that socket_path can be anywhere and use third person (remove "for you"). Document that urunc creates the directory after it drops privileges to the monitor's user, so the parent must be writable by that user and the path must not sit over an existing file. Note that urunc removes the socket on delete, so a restart reusing the same path does not find a stale socket. Signed-off-by: Anamika Aggarwal --- docs/configuration.md | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index c74426427..5a9aab6b2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -123,11 +123,21 @@ Firecracker (its API socket), Qemu (a QMP socket) and Cloud Hypervisor (its REST API socket). It has no effect on the other monitors. The control socket is opt-in: it exists only when `socket_path` is set. If it is not set, the monitor runs with no control socket at all; an operator can leave it unset if -they do not need the socket, or to keep a smaller attack surface. When it is -set, the monitor creates the socket inside its own (pivoted) rootfs; `urunc` -creates the directory of a custom `socket_path` there for you, so the path -can be anywhere. It fails cleanly if the location is invalid, for example -when a file already exists at one of the directories in the path. +they do not need the socket, or to keep a smaller attack surface. + +When it is set, the monitor creates the socket inside its own (pivoted) rootfs. +`urunc` creates the directory of `socket_path` inside that rootfs after it drops +privileges to the monitor's user, so the path is subject to two constraints: + +- Its parent directory must be one the monitor's user can create and write. A + directory that only `root` can write does not work for a non-root monitor. +- It must not sit over an existing file or directory. `urunc` creates the + directory of `socket_path`; that fails cleanly if a file already exists at one + of the directories in the path. The monitor then binds the socket at + `socket_path`; that fails if a file already exists at `socket_path` itself. + +`urunc` removes the socket when the container is deleted, so a restart that +reuses the same `socket_path` does not find a stale socket. **Example:** From 0ddb627a124c0f518871a6d4437c6eaaaa4ea9e9 Mon Sep 17 00:00:00 2001 From: Anamika Aggarwal Date: Wed, 12 Aug 2026 01:35:44 +0530 Subject: [PATCH 09/14] fix(monitors): remove the control socket on a lethal signal A docker/nerdctl stop goes through urunc kill with SIGTERM and never runs delete, so the control socket was left behind until the next delete. Remove it on a terminating signal (SIGTERM or SIGKILL) as well, while the monitor is still alive and its socket is reachable at its real path inside the monitor rootfs. Factor the removal into a shared helper used by both the kill path (Signal) and Delete: it resolves the monitor rootfs, and removes the file only when it is an actual socket, so a misconfigured socket_path over a regular file is never deleted. The start path performs no cleanup. The removal in Signal is best-effort and never blocks the kill. Signed-off-by: Anamika Aggarwal --- docs/configuration.md | 5 +- pkg/unikontainers/unikontainers.go | 75 +++++++++++++++++++++++------- 2 files changed, 60 insertions(+), 20 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 5a9aab6b2..8ec6b1149 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -136,8 +136,9 @@ privileges to the monitor's user, so the path is subject to two constraints: of the directories in the path. The monitor then binds the socket at `socket_path`; that fails if a file already exists at `socket_path` itself. -`urunc` removes the socket when the container is deleted, so a restart that -reuses the same `socket_path` does not find a stale socket. +`urunc` removes the socket when the container is stopped (on a terminating +signal) and when it is deleted, so a restart that reuses the same `socket_path` +does not find a stale socket. **Example:** diff --git a/pkg/unikontainers/unikontainers.go b/pkg/unikontainers/unikontainers.go index 54cc5e4ba..462b77620 100644 --- a/pkg/unikontainers/unikontainers.go +++ b/pkg/unikontainers/unikontainers.go @@ -805,6 +805,49 @@ func setupUser(user specs.User) error { } // Signal sends a specified signal to container's init. +// monitorRootfs returns the host path of the monitor's rootfs: the separate +// one under the bundle if it exists, else the container's own rootfs. +func (u *Unikontainer) monitorRootfs() string { + bundleDir := filepath.Clean(u.State.Bundle) + rootfsDir := filepath.Clean(u.Spec.Root.Path) + if !filepath.IsAbs(rootfsDir) { + rootfsDir = filepath.Join(bundleDir, rootfsDir) + } + monRootfs := filepath.Join(bundleDir, monitorRootfsDirName) + if _, err := os.Stat(monRootfs); !os.IsNotExist(err) { + return monRootfs + } + return rootfsDir +} + +// removeControlSocket deletes the monitor's control socket, if one is set. It +// skips a missing path and never deletes a non-socket file. Signal (on a +// lethal signal) and Delete both use it. +func (u *Unikontainer) removeControlSocket(vmm types.VMM, vmmType string) error { + socketPath := u.UruncCfg.Monitors[vmmType].SocketPath + if socketPath == "" || !vmm.UsesControlSocket() { + return nil + } + sockRealPath := filepath.Join(u.monitorRootfs(), socketPath) + info, err := os.Lstat(sockRealPath) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + if info.Mode()&os.ModeSocket == 0 { + return nil + } + return os.Remove(sockRealPath) +} + +// isLethalSignal reports whether the signal stops the container. Only SIGKILL +// and SIGTERM count (the signals a stop sends). +func isLethalSignal(signal unix.Signal) bool { + return signal == unix.SIGKILL || signal == unix.SIGTERM +} + func (u *Unikontainer) Signal(signal unix.Signal) error { vmmType := u.State.Annotations[annotHypervisor] vmm, err := hypervisors.NewVMM(hypervisors.VmmType(vmmType), u.UruncCfg.Monitors) @@ -812,6 +855,15 @@ func (u *Unikontainer) Signal(signal unix.Signal) error { return err } + // A stop calls kill with SIGTERM and never runs Delete, so remove the + // socket here too, while the monitor is still alive. Best-effort: never + // block the kill. + if isLethalSignal(signal) { + if rmErr := u.removeControlSocket(vmm, vmmType); rmErr != nil { + uniklog.Warnf("failed to remove control socket: %v", rmErr) + } + } + return vmm.Signal(u.State.Pid, signal) } @@ -856,7 +908,6 @@ func (u *Unikontainer) Kill() error { func (u *Unikontainer) Delete() error { var dirs []string var prefPath string - var monitorRoot string if u.isRunning() { return fmt.Errorf("cannot delete running container: %s", u.State.ID) @@ -904,7 +955,6 @@ func (u *Unikontainer) Delete() error { // clean it up. dirs = append(dirs, monitorRootfsDirName) prefPath = bundleDir - monitorRoot = monRootfs } else { // Otherwise remove the enw directories we created inside the // container's rootfs. @@ -921,22 +971,11 @@ func (u *Unikontainer) Delete() error { } dirs = append(dirs, vmm.Path()) prefPath = rootfsDir - monitorRoot = rootfsDir - } - - // Remove the monitor's control socket, if one was configured, so that a - // restart reusing the same socket_path does not find a stale socket. The - // socket lives inside the monitor rootfs (the monitor binds it there after - // the pivot in Exec); at delete time that rootfs is reachable at its real - // path. Only an actual socket is removed, so a misconfigured socket_path - // pointing at a regular file is never deleted. - if socketPath := u.UruncCfg.Monitors[vmmType].SocketPath; socketPath != "" && vmm.UsesControlSocket() { - sockRealPath := filepath.Join(monitorRoot, socketPath) - if info, statErr := os.Lstat(sockRealPath); statErr == nil && info.Mode()&os.ModeSocket != 0 { - if err = os.Remove(sockRealPath); err != nil { - return fmt.Errorf("failed to remove control socket %q: %w", sockRealPath, err) - } - } + } + + // Remove the control socket so a restart on the same socket_path is clean. + if err = u.removeControlSocket(vmm, vmmType); err != nil { + return fmt.Errorf("failed to remove control socket: %w", err) } err = rmMultipleDirs(prefPath, dirs) From fb22e0c5bda55ed52cf9ba0abcc10f24f0e9b40c Mon Sep 17 00:00:00 2001 From: Anamika Aggarwal Date: Wed, 12 Aug 2026 02:00:58 +0530 Subject: [PATCH 10/14] style(monitors): shorten verbose code comments Trim the multi-line comments added for the control socket work down to one or two short sentences. No code change. Signed-off-by: Anamika Aggarwal --- pkg/unikontainers/hypervisors/cloud_hypervisor.go | 5 ++--- pkg/unikontainers/hypervisors/cloud_hypervisor_test.go | 5 ++--- pkg/unikontainers/hypervisors/firecracker_test.go | 6 ++---- pkg/unikontainers/hypervisors/qemu.go | 6 ++---- pkg/unikontainers/hypervisors/vmm_test.go | 6 ++---- pkg/unikontainers/types/types.go | 3 +-- pkg/unikontainers/unikontainers.go | 7 +++---- 7 files changed, 14 insertions(+), 24 deletions(-) diff --git a/pkg/unikontainers/hypervisors/cloud_hypervisor.go b/pkg/unikontainers/hypervisors/cloud_hypervisor.go index 345d9b822..8158aa34f 100644 --- a/pkg/unikontainers/hypervisors/cloud_hypervisor.go +++ b/pkg/unikontainers/hypervisors/cloud_hypervisor.go @@ -76,9 +76,8 @@ func (ch *CloudHypervisor) BuildExecCmd(args types.ExecArgs, ukernel types.Unike // Start building the command exArgs := []string{ch.binaryPath} - // Expose the REST API over a control socket only when a socket_path is - // configured, so the runtime can talk to Cloud Hypervisor after boot (e.g. - // for graceful shutdown). With no configured path no control socket is set up. + // Expose the REST API socket only when socket_path is set, so the runtime + // can talk to Cloud Hypervisor after boot. if args.SocketPath != "" { exArgs = append(exArgs, "--api-socket", "path="+args.SocketPath) } diff --git a/pkg/unikontainers/hypervisors/cloud_hypervisor_test.go b/pkg/unikontainers/hypervisors/cloud_hypervisor_test.go index ceeebe50e..708f97b2c 100644 --- a/pkg/unikontainers/hypervisors/cloud_hypervisor_test.go +++ b/pkg/unikontainers/hypervisors/cloud_hypervisor_test.go @@ -24,9 +24,8 @@ import ( const testCHBinary = "/usr/bin/cloud-hypervisor" -// TestCloudHypervisorBuildExecCmdSocket verifies that Cloud Hypervisor exposes -// its REST API control socket only when a socket_path is configured. With no -// configured path, no --api-socket flag is emitted. +// TestCloudHypervisorBuildExecCmdSocket verifies Cloud Hypervisor emits +// --api-socket only when socket_path is set. func TestCloudHypervisorBuildExecCmdSocket(t *testing.T) { t.Parallel() diff --git a/pkg/unikontainers/hypervisors/firecracker_test.go b/pkg/unikontainers/hypervisors/firecracker_test.go index 556ff9d20..e5602bc35 100644 --- a/pkg/unikontainers/hypervisors/firecracker_test.go +++ b/pkg/unikontainers/hypervisors/firecracker_test.go @@ -24,10 +24,8 @@ import ( const testFCBinary = "/usr/bin/firecracker" -// TestFirecrackerBuildExecCmdSocket verifies that Firecracker enables its API -// socket only when a socket_path is configured. With no configured path it -// restores the upstream launch mode (--no-api --config-file), which boots the -// guest from the config file without exposing a control socket. +// TestFirecrackerBuildExecCmdSocket verifies Firecracker enables --api-sock +// only when socket_path is set, and restores --no-api otherwise. func TestFirecrackerBuildExecCmdSocket(t *testing.T) { t.Parallel() diff --git a/pkg/unikontainers/hypervisors/qemu.go b/pkg/unikontainers/hypervisors/qemu.go index 010bd9e8e..2a38c20fc 100644 --- a/pkg/unikontainers/hypervisors/qemu.go +++ b/pkg/unikontainers/hypervisors/qemu.go @@ -72,10 +72,8 @@ func (q *Qemu) BuildExecCmd(args types.ExecArgs, ukernel types.Unikernel) ([]str cmdString += " -cpu host" // Choose CPU cmdString += " -enable-kvm" // Enable KVM to use CPU virt extensions cmdString += " -display none -vga none -serial stdio -monitor null" // Disable graphic output - // Expose a QMP control socket only when a socket_path is configured, so the - // runtime can talk to QEMU after boot (e.g. for graceful shutdown). With no - // configured path QEMU boots with no control socket. server,nowait lets QEMU - // boot without waiting for a client to connect. + // Expose the QMP socket only when socket_path is set. server,nowait lets + // QEMU boot without waiting for a client. if args.SocketPath != "" { cmdString += " -qmp unix:" + args.SocketPath + ",server,nowait" } diff --git a/pkg/unikontainers/hypervisors/vmm_test.go b/pkg/unikontainers/hypervisors/vmm_test.go index 2fec30b10..2b5c5fa43 100644 --- a/pkg/unikontainers/hypervisors/vmm_test.go +++ b/pkg/unikontainers/hypervisors/vmm_test.go @@ -21,10 +21,8 @@ import ( "github.com/urunc-dev/urunc/pkg/unikontainers/types" ) -// TestUsesControlSocket verifies every monitor reports whether it exposes a -// control socket. The monitors that expose one (Qemu, Firecracker, Cloud -// Hypervisor) must return true; the rest must return false. Because it is an -// interface method, a new monitor cannot compile without declaring it. +// TestUsesControlSocket verifies each monitor reports whether it exposes a +// control socket (Qemu, Firecracker, Cloud Hypervisor true; the rest false). func TestUsesControlSocket(t *testing.T) { t.Parallel() diff --git a/pkg/unikontainers/types/types.go b/pkg/unikontainers/types/types.go index 8732af9b2..8d0ec0f4a 100644 --- a/pkg/unikontainers/types/types.go +++ b/pkg/unikontainers/types/types.go @@ -42,8 +42,7 @@ type VMM interface { UsesKVM() bool SupportsSharedfs(string) bool // UsesControlSocket reports whether the monitor exposes a control socket - // (configured through socket_path) that urunc must make reachable before - // the monitor launches. + // (set through socket_path). UsesControlSocket() bool Ok() error } diff --git a/pkg/unikontainers/unikontainers.go b/pkg/unikontainers/unikontainers.go index 462b77620..be3ae05e4 100644 --- a/pkg/unikontainers/unikontainers.go +++ b/pkg/unikontainers/unikontainers.go @@ -719,10 +719,9 @@ func (u *Unikontainer) Exec(metrics m.Writer) error { return err } - // Create the directory for the monitor's control socket, only when one is - // configured. This runs after setupUser so the directory is owned by the - // monitor's user and a non-root monitor can bind its socket there. The - // socket file itself is created by the monitor and removed in Delete. + // Create the socket directory after setupUser, so the monitor's user owns + // it and a non-root monitor can bind there. The monitor creates the socket; + // kill and Delete remove it. if vmm.UsesControlSocket() && vmmArgs.SocketPath != "" { sockDir := filepath.Dir(vmmArgs.SocketPath) if err = os.MkdirAll(sockDir, 0o700); err != nil { From d00902b3a7f9809ab76e88c616268fe2a09a195f Mon Sep 17 00:00:00 2001 From: Anamika Aggarwal Date: Tue, 18 Aug 2026 10:04:37 +0530 Subject: [PATCH 11/14] refactor(monitors): rename UsesControlSocket to SupportsControlSocket Signed-off-by: Anamika Aggarwal --- pkg/unikontainers/hypervisors/cloud_hypervisor.go | 4 ++-- pkg/unikontainers/hypervisors/firecracker.go | 4 ++-- pkg/unikontainers/hypervisors/hedge.go | 4 ++-- pkg/unikontainers/hypervisors/hvt.go | 4 ++-- pkg/unikontainers/hypervisors/hyperlight.go | 4 ++-- pkg/unikontainers/hypervisors/qemu.go | 4 ++-- pkg/unikontainers/hypervisors/spt.go | 4 ++-- pkg/unikontainers/hypervisors/vmm_test.go | 6 +++--- pkg/unikontainers/types/types.go | 4 ++-- pkg/unikontainers/unikontainers.go | 4 ++-- 10 files changed, 21 insertions(+), 21 deletions(-) diff --git a/pkg/unikontainers/hypervisors/cloud_hypervisor.go b/pkg/unikontainers/hypervisors/cloud_hypervisor.go index 8158aa34f..76e48f709 100644 --- a/pkg/unikontainers/hypervisors/cloud_hypervisor.go +++ b/pkg/unikontainers/hypervisors/cloud_hypervisor.go @@ -50,9 +50,9 @@ func (ch *CloudHypervisor) UsesKVM() bool { } // SupportsSharedfs returns true as Cloud Hypervisor supports virtiofs -// UsesControlSocket reports that Cloud Hypervisor exposes a control socket (its +// SupportsControlSocket reports that Cloud Hypervisor exposes a control socket (its // REST API socket). -func (ch *CloudHypervisor) UsesControlSocket() bool { +func (ch *CloudHypervisor) SupportsControlSocket() bool { return true } diff --git a/pkg/unikontainers/hypervisors/firecracker.go b/pkg/unikontainers/hypervisors/firecracker.go index d13376d85..5911fb862 100644 --- a/pkg/unikontainers/hypervisors/firecracker.go +++ b/pkg/unikontainers/hypervisors/firecracker.go @@ -97,9 +97,9 @@ func (fc *Firecracker) SupportsSharedfs(_ string) bool { return false } -// UsesControlSocket reports that Firecracker exposes a control socket (its API +// SupportsControlSocket reports that Firecracker exposes a control socket (its API // socket). -func (fc *Firecracker) UsesControlSocket() bool { +func (fc *Firecracker) SupportsControlSocket() bool { return true } diff --git a/pkg/unikontainers/hypervisors/hedge.go b/pkg/unikontainers/hypervisors/hedge.go index a53416894..ed2c6fe6c 100644 --- a/pkg/unikontainers/hypervisors/hedge.go +++ b/pkg/unikontainers/hypervisors/hedge.go @@ -51,8 +51,8 @@ func (h *Hedge) SupportsSharedfs(_ string) bool { return false } -// UsesControlSocket reports that Hedge exposes no control socket. -func (h *Hedge) UsesControlSocket() bool { +// SupportsControlSocket reports that Hedge exposes no control socket. +func (h *Hedge) SupportsControlSocket() bool { return false } diff --git a/pkg/unikontainers/hypervisors/hvt.go b/pkg/unikontainers/hypervisors/hvt.go index 55cd84301..e8f791862 100644 --- a/pkg/unikontainers/hypervisors/hvt.go +++ b/pkg/unikontainers/hypervisors/hvt.go @@ -139,8 +139,8 @@ func (h *HVT) SupportsSharedfs(_ string) bool { return false } -// UsesControlSocket reports that HVT exposes no control socket. -func (h *HVT) UsesControlSocket() bool { +// SupportsControlSocket reports that HVT exposes no control socket. +func (h *HVT) SupportsControlSocket() bool { return false } diff --git a/pkg/unikontainers/hypervisors/hyperlight.go b/pkg/unikontainers/hypervisors/hyperlight.go index dbe4d204c..b12981ce8 100644 --- a/pkg/unikontainers/hypervisors/hyperlight.go +++ b/pkg/unikontainers/hypervisors/hyperlight.go @@ -46,8 +46,8 @@ func (h *Hyperlight) SupportsSharedfs(_ string) bool { return false } -// UsesControlSocket reports that Hyperlight exposes no control socket. -func (h *Hyperlight) UsesControlSocket() bool { +// SupportsControlSocket reports that Hyperlight exposes no control socket. +func (h *Hyperlight) SupportsControlSocket() bool { return false } diff --git a/pkg/unikontainers/hypervisors/qemu.go b/pkg/unikontainers/hypervisors/qemu.go index 2a38c20fc..bb5e12bf3 100644 --- a/pkg/unikontainers/hypervisors/qemu.go +++ b/pkg/unikontainers/hypervisors/qemu.go @@ -56,8 +56,8 @@ func (q *Qemu) SupportsSharedfs(_ string) bool { return true } -// UsesControlSocket reports that QEMU exposes a control socket (QMP). -func (q *Qemu) UsesControlSocket() bool { +// SupportsControlSocket reports that QEMU exposes a control socket (QMP). +func (q *Qemu) SupportsControlSocket() bool { return true } diff --git a/pkg/unikontainers/hypervisors/spt.go b/pkg/unikontainers/hypervisors/spt.go index 33ea94ad9..08940a9c5 100644 --- a/pkg/unikontainers/hypervisors/spt.go +++ b/pkg/unikontainers/hypervisors/spt.go @@ -51,8 +51,8 @@ func (s *SPT) SupportsSharedfs(_ string) bool { return false } -// UsesControlSocket reports that SPT exposes no control socket. -func (s *SPT) UsesControlSocket() bool { +// SupportsControlSocket reports that SPT exposes no control socket. +func (s *SPT) SupportsControlSocket() bool { return false } diff --git a/pkg/unikontainers/hypervisors/vmm_test.go b/pkg/unikontainers/hypervisors/vmm_test.go index 2b5c5fa43..8e471c7f3 100644 --- a/pkg/unikontainers/hypervisors/vmm_test.go +++ b/pkg/unikontainers/hypervisors/vmm_test.go @@ -21,9 +21,9 @@ import ( "github.com/urunc-dev/urunc/pkg/unikontainers/types" ) -// TestUsesControlSocket verifies each monitor reports whether it exposes a +// TestSupportsControlSocket verifies each monitor reports whether it exposes a // control socket (Qemu, Firecracker, Cloud Hypervisor true; the rest false). -func TestUsesControlSocket(t *testing.T) { +func TestSupportsControlSocket(t *testing.T) { t.Parallel() tests := []struct { @@ -43,7 +43,7 @@ func TestUsesControlSocket(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - assert.Equal(t, tt.want, tt.vmm.UsesControlSocket()) + assert.Equal(t, tt.want, tt.vmm.SupportsControlSocket()) }) } } diff --git a/pkg/unikontainers/types/types.go b/pkg/unikontainers/types/types.go index 8d0ec0f4a..f9f272f6d 100644 --- a/pkg/unikontainers/types/types.go +++ b/pkg/unikontainers/types/types.go @@ -41,9 +41,9 @@ type VMM interface { Path() string UsesKVM() bool SupportsSharedfs(string) bool - // UsesControlSocket reports whether the monitor exposes a control socket + // SupportsControlSocket reports whether the monitor exposes a control socket // (set through socket_path). - UsesControlSocket() bool + SupportsControlSocket() bool Ok() error } diff --git a/pkg/unikontainers/unikontainers.go b/pkg/unikontainers/unikontainers.go index be3ae05e4..3c9825310 100644 --- a/pkg/unikontainers/unikontainers.go +++ b/pkg/unikontainers/unikontainers.go @@ -722,7 +722,7 @@ func (u *Unikontainer) Exec(metrics m.Writer) error { // Create the socket directory after setupUser, so the monitor's user owns // it and a non-root monitor can bind there. The monitor creates the socket; // kill and Delete remove it. - if vmm.UsesControlSocket() && vmmArgs.SocketPath != "" { + if vmm.SupportsControlSocket() && vmmArgs.SocketPath != "" { sockDir := filepath.Dir(vmmArgs.SocketPath) if err = os.MkdirAll(sockDir, 0o700); err != nil { return fmt.Errorf("failed to create control socket directory %q: %w", sockDir, err) @@ -824,7 +824,7 @@ func (u *Unikontainer) monitorRootfs() string { // lethal signal) and Delete both use it. func (u *Unikontainer) removeControlSocket(vmm types.VMM, vmmType string) error { socketPath := u.UruncCfg.Monitors[vmmType].SocketPath - if socketPath == "" || !vmm.UsesControlSocket() { + if socketPath == "" || !vmm.SupportsControlSocket() { return nil } sockRealPath := filepath.Join(u.monitorRootfs(), socketPath) From e6f2f5d9ea83609438430cc1ab76cce26c05d29f Mon Sep 17 00:00:00 2001 From: Anamika Aggarwal Date: Tue, 18 Aug 2026 10:08:51 +0530 Subject: [PATCH 12/14] docs(monitors): fix misplaced and stale control socket comments Signed-off-by: Anamika Aggarwal --- pkg/unikontainers/hypervisors/cloud_hypervisor.go | 6 +++--- pkg/unikontainers/types/types.go | 2 -- pkg/unikontainers/unikontainers.go | 8 +++----- 3 files changed, 6 insertions(+), 10 deletions(-) diff --git a/pkg/unikontainers/hypervisors/cloud_hypervisor.go b/pkg/unikontainers/hypervisors/cloud_hypervisor.go index 76e48f709..2c0ab4118 100644 --- a/pkg/unikontainers/hypervisors/cloud_hypervisor.go +++ b/pkg/unikontainers/hypervisors/cloud_hypervisor.go @@ -49,13 +49,13 @@ func (ch *CloudHypervisor) UsesKVM() bool { return true } -// SupportsSharedfs returns true as Cloud Hypervisor supports virtiofs -// SupportsControlSocket reports that Cloud Hypervisor exposes a control socket (its -// REST API socket). +// SupportsControlSocket reports that Cloud Hypervisor exposes a control socket +// (its REST API socket). func (ch *CloudHypervisor) SupportsControlSocket() bool { return true } +// SupportsSharedfs returns true as Cloud Hypervisor supports virtiofs func (ch *CloudHypervisor) SupportsSharedfs(fsType string) bool { switch fsType { case "virtio": diff --git a/pkg/unikontainers/types/types.go b/pkg/unikontainers/types/types.go index f9f272f6d..2285603ff 100644 --- a/pkg/unikontainers/types/types.go +++ b/pkg/unikontainers/types/types.go @@ -41,8 +41,6 @@ type VMM interface { Path() string UsesKVM() bool SupportsSharedfs(string) bool - // SupportsControlSocket reports whether the monitor exposes a control socket - // (set through socket_path). SupportsControlSocket() bool Ok() error } diff --git a/pkg/unikontainers/unikontainers.go b/pkg/unikontainers/unikontainers.go index 3c9825310..ce4f3cc54 100644 --- a/pkg/unikontainers/unikontainers.go +++ b/pkg/unikontainers/unikontainers.go @@ -720,8 +720,7 @@ func (u *Unikontainer) Exec(metrics m.Writer) error { } // Create the socket directory after setupUser, so the monitor's user owns - // it and a non-root monitor can bind there. The monitor creates the socket; - // kill and Delete remove it. + // it and a non-root monitor can bind there. The monitor creates the socket. if vmm.SupportsControlSocket() && vmmArgs.SocketPath != "" { sockDir := filepath.Dir(vmmArgs.SocketPath) if err = os.MkdirAll(sockDir, 0o700); err != nil { @@ -803,7 +802,6 @@ func setupUser(user specs.User) error { return nil } -// Signal sends a specified signal to container's init. // monitorRootfs returns the host path of the monitor's rootfs: the separate // one under the bundle if it exists, else the container's own rootfs. func (u *Unikontainer) monitorRootfs() string { @@ -820,8 +818,7 @@ func (u *Unikontainer) monitorRootfs() string { } // removeControlSocket deletes the monitor's control socket, if one is set. It -// skips a missing path and never deletes a non-socket file. Signal (on a -// lethal signal) and Delete both use it. +// skips a missing path and never deletes a non-socket file. func (u *Unikontainer) removeControlSocket(vmm types.VMM, vmmType string) error { socketPath := u.UruncCfg.Monitors[vmmType].SocketPath if socketPath == "" || !vmm.SupportsControlSocket() { @@ -847,6 +844,7 @@ func isLethalSignal(signal unix.Signal) bool { return signal == unix.SIGKILL || signal == unix.SIGTERM } +// Signal sends a specified signal to container's init. func (u *Unikontainer) Signal(signal unix.Signal) error { vmmType := u.State.Annotations[annotHypervisor] vmm, err := hypervisors.NewVMM(hypervisors.VmmType(vmmType), u.UruncCfg.Monitors) From 7d45b6059f82f426ea362409d60b97d18f24f6ed Mon Sep 17 00:00:00 2001 From: Anamika Aggarwal Date: Tue, 18 Aug 2026 10:12:36 +0530 Subject: [PATCH 13/14] refactor(monitors): simplify control socket removal Signed-off-by: Anamika Aggarwal --- pkg/unikontainers/unikontainers.go | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/pkg/unikontainers/unikontainers.go b/pkg/unikontainers/unikontainers.go index ce4f3cc54..540bb1468 100644 --- a/pkg/unikontainers/unikontainers.go +++ b/pkg/unikontainers/unikontainers.go @@ -29,6 +29,7 @@ import ( "sync" "syscall" + securejoin "github.com/cyphar/filepath-securejoin" "github.com/urunc-dev/urunc/pkg/network" "github.com/urunc-dev/urunc/pkg/unikontainers/hypervisors" "github.com/urunc-dev/urunc/pkg/unikontainers/types" @@ -817,14 +818,13 @@ func (u *Unikontainer) monitorRootfs() string { return rootfsDir } -// removeControlSocket deletes the monitor's control socket, if one is set. It -// skips a missing path and never deletes a non-socket file. -func (u *Unikontainer) removeControlSocket(vmm types.VMM, vmmType string) error { - socketPath := u.UruncCfg.Monitors[vmmType].SocketPath - if socketPath == "" || !vmm.SupportsControlSocket() { - return nil +// removeControlSocket deletes the monitor's control socket. It skips a missing +// path and never deletes a non-socket file. +func (u *Unikontainer) removeControlSocket(socketPath string) error { + sockRealPath, err := securejoin.SecureJoin(u.monitorRootfs(), socketPath) + if err != nil { + return err } - sockRealPath := filepath.Join(u.monitorRootfs(), socketPath) info, err := os.Lstat(sockRealPath) if err != nil { if os.IsNotExist(err) { @@ -855,8 +855,9 @@ func (u *Unikontainer) Signal(signal unix.Signal) error { // A stop calls kill with SIGTERM and never runs Delete, so remove the // socket here too, while the monitor is still alive. Best-effort: never // block the kill. - if isLethalSignal(signal) { - if rmErr := u.removeControlSocket(vmm, vmmType); rmErr != nil { + socketPath := u.UruncCfg.Monitors[vmmType].SocketPath + if isLethalSignal(signal) && socketPath != "" && vmm.SupportsControlSocket() { + if rmErr := u.removeControlSocket(socketPath); rmErr != nil { uniklog.Warnf("failed to remove control socket: %v", rmErr) } } @@ -971,8 +972,11 @@ func (u *Unikontainer) Delete() error { } // Remove the control socket so a restart on the same socket_path is clean. - if err = u.removeControlSocket(vmm, vmmType); err != nil { - return fmt.Errorf("failed to remove control socket: %w", err) + socketPath := u.UruncCfg.Monitors[vmmType].SocketPath + if socketPath != "" && vmm.SupportsControlSocket() { + if err = u.removeControlSocket(socketPath); err != nil { + return fmt.Errorf("failed to remove control socket: %w", err) + } } err = rmMultipleDirs(prefPath, dirs) From fb624fa40553313956e9e949b7df8b60d89f87e3 Mon Sep 17 00:00:00 2001 From: Anamika Aggarwal Date: Tue, 18 Aug 2026 10:16:19 +0530 Subject: [PATCH 14/14] fix(monitors): remove the control socket after signalling Signed-off-by: Anamika Aggarwal --- pkg/unikontainers/unikontainers.go | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/pkg/unikontainers/unikontainers.go b/pkg/unikontainers/unikontainers.go index 540bb1468..953f14268 100644 --- a/pkg/unikontainers/unikontainers.go +++ b/pkg/unikontainers/unikontainers.go @@ -838,12 +838,6 @@ func (u *Unikontainer) removeControlSocket(socketPath string) error { return os.Remove(sockRealPath) } -// isLethalSignal reports whether the signal stops the container. Only SIGKILL -// and SIGTERM count (the signals a stop sends). -func isLethalSignal(signal unix.Signal) bool { - return signal == unix.SIGKILL || signal == unix.SIGTERM -} - // Signal sends a specified signal to container's init. func (u *Unikontainer) Signal(signal unix.Signal) error { vmmType := u.State.Annotations[annotHypervisor] @@ -852,17 +846,20 @@ func (u *Unikontainer) Signal(signal unix.Signal) error { return err } - // A stop calls kill with SIGTERM and never runs Delete, so remove the - // socket here too, while the monitor is still alive. Best-effort: never - // block the kill. + if err = vmm.Signal(u.State.Pid, signal); err != nil { + return err + } + + // A stop calls kill with SIGTERM and never runs Delete, so the socket is + // removed here too. Best-effort: a failure must not fail the signal. socketPath := u.UruncCfg.Monitors[vmmType].SocketPath - if isLethalSignal(signal) && socketPath != "" && vmm.SupportsControlSocket() { + if (signal == unix.SIGKILL || signal == unix.SIGTERM) && socketPath != "" && vmm.SupportsControlSocket() { if rmErr := u.removeControlSocket(socketPath); rmErr != nil { uniklog.Warnf("failed to remove control socket: %v", rmErr) } } - return vmm.Signal(u.State.Pid, signal) + return nil } // Kill stops the VMM process, first by asking the VMM struct to stop