From 3e02d31352d077cd9625e1243a278036a76af15a Mon Sep 17 00:00:00 2001 From: Anamika Aggarwal Date: Tue, 28 Jul 2026 17:32:07 +0530 Subject: [PATCH 1/6] 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 c8600957b..9633c6617 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 c6388e2cc..89a3e9ebf 100644 --- a/pkg/unikontainers/types/types.go +++ b/pkg/unikontainers/types/types.go @@ -106,6 +106,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 } @@ -133,7 +134,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 84172aca4..a61fc5baa 100644 --- a/pkg/unikontainers/unikontainers.go +++ b/pkg/unikontainers/unikontainers.go @@ -402,6 +402,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{ @@ -411,6 +412,7 @@ func (u *Unikontainer) Exec(metrics m.Writer) error { Seccomp: true, // Enable Seccomp by default MemSizeB: uint64(defaultMemSizeMB * 1024 * 1024), VCPUs: uint(defaultVCPUs), + SocketPath: socketPath, Environment: os.Environ(), } @@ -648,6 +650,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 b3a895f1671f5362cc0cbbc63ea3f406765410ee Mon Sep 17 00:00:00 2001 From: Anamika Aggarwal Date: Tue, 4 Aug 2026 13:03:47 +0530 Subject: [PATCH 2/6] 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 a61fc5baa..ae266055b 100644 --- a/pkg/unikontainers/unikontainers.go +++ b/pkg/unikontainers/unikontainers.go @@ -657,7 +657,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 cce7add653bf95eb0e237d2073198af770c4de20 Mon Sep 17 00:00:00 2001 From: Anamika Aggarwal Date: Tue, 4 Aug 2026 13:09:49 +0530 Subject: [PATCH 3/6] 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 ae266055b..c93082b96 100644 --- a/pkg/unikontainers/unikontainers.go +++ b/pkg/unikontainers/unikontainers.go @@ -650,19 +650,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) @@ -670,6 +657,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 841e2e0091b81882b724795127b3dc938f3358e9 Mon Sep 17 00:00:00 2001 From: Anamika Aggarwal Date: Tue, 4 Aug 2026 13:14:09 +0530 Subject: [PATCH 4/6] 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 863c3227c60aeb9d93c0cc60fc5e49c0fc4e38c9 Mon Sep 17 00:00:00 2001 From: Anamika Aggarwal Date: Tue, 4 Aug 2026 13:41:27 +0530 Subject: [PATCH 5/6] 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 89a3e9ebf..1993681dd 100644 --- a/pkg/unikontainers/types/types.go +++ b/pkg/unikontainers/types/types.go @@ -106,7 +106,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 } @@ -137,5 +137,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 295a902966be4008bfdc34212885dde1d87bf937 Mon Sep 17 00:00:00 2001 From: Anamika Aggarwal Date: Tue, 4 Aug 2026 13:56:45 +0530 Subject: [PATCH 6/6] 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 07a4855b5..7d4e24a05 100644 --- a/.github/linters/urunc-dict.txt +++ b/.github/linters/urunc-dict.txt @@ -421,3 +421,4 @@ ESRCH Prafful praffq libcontainers +nowait