Skip to content

Commit ef1dc96

Browse files
Anamika1608urunc-bot[bot]
authored andcommitted
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. The socket is opt-in: it exists only when socket_path is set in the monitor's configuration, so an admin can run a monitor with no socket at all. - 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 (server=on,wait=off) alongside the disabled human monitor. - Cloud Hypervisor exposes its REST API socket (--api-socket). The socket_path option is wired through MonitorConfig, ExecArgs and the state.json annotation passthrough. Each monitor declares whether it exposes a control socket via a new SupportsControlSocket method on the VMM interface, so a new monitor cannot be added without deciding this. After setupUser, urunc creates the socket path's directory (0700) inside the monitor rootfs, so the monitor's user owns it and a non-root monitor can bind there. This runs in both the exec and the libcontainer monitor paths. It fails only if the location is invalid, such as a file already existing at one of the path's components. A stop calls kill with SIGTERM and never runs delete, so the socket is removed on a lethal signal after the monitor has been signalled, resolved through the monitor rootfs with SecureJoin and only when it is an actual socket. Delete needs no explicit removal: the monitor rootfs is removed as a whole. Add unit tests for the new arguments on all three monitors and for SupportsControlSocket, and document the socket_path option. PR: #850 Signed-off-by: Anamika Aggarwal <anamikaagg18@gmail.com> Reviewed-by: Charalampos Mainas <cmainas@nubificus.co.uk> Approved-by: Charalampos Mainas <cmainas@nubificus.co.uk>
1 parent cf8151a commit ef1dc96

16 files changed

Lines changed: 367 additions & 5 deletions

‎docs/configuration.md‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,11 +167,34 @@ Each monitor subsection supports the following options:
167167
| `path` | string | (empty) | Optional custom path to the monitor binary. If not specified, urunc will search for the binary in PATH |
168168
| `data_path` | string | (empty) | Optional custom path for the monitor's data file directory |
169169
| `vhost` | boolean | `false` | Optional: enable `vhost-net` for the monitor's network device, to improve network performance. Currently only honored by the `qemu` monitor |
170+
| `socket_path` | string | (empty) | Optional path for the monitor's control socket. If not set, the monitor runs without a control socket |
170171

171172
Since Qemu is the only currently supported monitor which requires extra data to
172173
boot a VM, `urunc` will first check `/usr/local/share` and then `/usr/share` for
173174
Qemu's data files.
174175

176+
The `socket_path` option applies to the monitors that expose a control socket:
177+
Firecracker (its API socket), Qemu (a QMP socket) and Cloud Hypervisor (its REST
178+
API socket). It has no effect on the other monitors. The control socket is
179+
opt-in: it exists only when `socket_path` is set. If it is not set, the
180+
monitor runs with no control socket at all; an operator can leave it unset if
181+
they do not need the socket, or to keep a smaller attack surface.
182+
183+
When it is set, the monitor creates the socket inside its own (pivoted) rootfs.
184+
`urunc` creates the directory of `socket_path` inside that rootfs after it drops
185+
privileges to the monitor's user, so the path is subject to two constraints:
186+
187+
- Its parent directory must be one the monitor's user can create and write. A
188+
directory that only `root` can write does not work for a non-root monitor.
189+
- It must not sit over an existing file or directory. `urunc` creates the
190+
directory of `socket_path`; that fails cleanly if a file already exists at one
191+
of the directories in the path. The monitor then binds the socket at
192+
`socket_path`; that fails if a file already exists at `socket_path` itself.
193+
194+
`urunc` removes the socket when the container is stopped (on a terminating
195+
signal) and when it is deleted, so a restart that reuses the same `socket_path`
196+
does not find a stale socket.
197+
175198
**Example:**
176199

177200
```toml
@@ -186,6 +209,7 @@ vhost = false
186209
default_memory_mb = 512
187210
default_vcpus = 2
188211
path = "/opt/firecracker/firecracker"
212+
socket_path = "/run/urunc/fc.sock"
189213
```
190214

191215
### Extra binaries Configuration

‎pkg/unikontainers/hypervisors/cloud_hypervisor.go‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,12 @@ func (ch *CloudHypervisor) UsesKVM() bool {
4949
return true
5050
}
5151

52+
// SupportsControlSocket reports that Cloud Hypervisor exposes a control socket
53+
// (its REST API socket).
54+
func (ch *CloudHypervisor) SupportsControlSocket() bool {
55+
return true
56+
}
57+
5258
// SupportsSharedfs returns true as Cloud Hypervisor supports virtiofs
5359
func (ch *CloudHypervisor) SupportsSharedfs(fsType string) bool {
5460
switch fsType {
@@ -70,6 +76,12 @@ func (ch *CloudHypervisor) BuildExecCmd(args types.ExecArgs, ukernel types.Unike
7076
// Start building the command
7177
exArgs := []string{ch.binaryPath}
7278

79+
// Expose the REST API socket only when socket_path is set, so the runtime
80+
// can talk to Cloud Hypervisor after boot.
81+
if args.SocketPath != "" {
82+
exArgs = append(exArgs, "--api-socket", "path="+args.SocketPath)
83+
}
84+
7385
// Memory configuration
7486
if args.Sharedfs.Type == "virtiofs" {
7587
memArg := "size=" + chMem + "M,shared=on"
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// Copyright (c) 2023-2026, Nubificus LTD
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package hypervisors
16+
17+
import (
18+
"strings"
19+
"testing"
20+
21+
"github.com/stretchr/testify/assert"
22+
"github.com/urunc-dev/urunc/pkg/unikontainers/types"
23+
)
24+
25+
const testCHBinary = "/usr/bin/cloud-hypervisor"
26+
27+
// TestCloudHypervisorBuildExecCmdSocket verifies Cloud Hypervisor emits
28+
// --api-socket only when socket_path is set.
29+
func TestCloudHypervisorBuildExecCmdSocket(t *testing.T) {
30+
t.Parallel()
31+
32+
tests := []struct {
33+
name string
34+
args types.ExecArgs
35+
mustContain []string
36+
mustNotContain []string
37+
}{
38+
{
39+
name: "configured SocketPath renders --api-socket on that path",
40+
args: types.ExecArgs{
41+
UnikernelPath: testKernelPath,
42+
Command: testCommand,
43+
SocketPath: "/run/urunc/ch.sock",
44+
},
45+
mustContain: []string{"--api-socket path=/run/urunc/ch.sock"},
46+
},
47+
{
48+
name: "unset SocketPath omits --api-socket",
49+
args: types.ExecArgs{
50+
UnikernelPath: testKernelPath,
51+
Command: testCommand,
52+
ContainerID: "abc123",
53+
},
54+
mustNotContain: []string{"--api-socket"},
55+
},
56+
}
57+
58+
for _, tt := range tests {
59+
t.Run(tt.name, func(t *testing.T) {
60+
t.Parallel()
61+
62+
ch := &CloudHypervisor{binary: CloudHypervisorBinary, binaryPath: testCHBinary}
63+
out, err := ch.BuildExecCmd(tt.args, &fakeUnikernel{})
64+
assert.NoError(t, err)
65+
assert.NotEmpty(t, out)
66+
67+
assert.Equal(t, testCHBinary, out[0], "binary path must be the first element")
68+
joined := strings.Join(out, " ")
69+
70+
for _, want := range tt.mustContain {
71+
assert.Contains(t, joined, want, "expected %q to be present", want)
72+
}
73+
for _, notWant := range tt.mustNotContain {
74+
assert.NotContains(t, joined, notWant, "expected %q to be absent", notWant)
75+
}
76+
})
77+
}
78+
}

‎pkg/unikontainers/hypervisors/firecracker.go‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,12 @@ func (fc *Firecracker) SupportsSharedfs(_ string) bool {
9999
return false
100100
}
101101

102+
// SupportsControlSocket reports that Firecracker exposes a control socket (its API
103+
// socket).
104+
func (fc *Firecracker) SupportsControlSocket() bool {
105+
return true
106+
}
107+
102108
func (fc *Firecracker) Path() string {
103109
return fc.binaryPath
104110
}
@@ -111,7 +117,15 @@ func (fc *Firecracker) BuildExecCmd(args types.ExecArgs, ukernel types.Unikernel
111117
// functions in the unikernel interface do not integrate well with FC's
112118
// json configuration.
113119
JSONConfigFile := filepath.Join("/tmp/", FCJsonFilename)
114-
exArgs := []string{fc.Path(), "--no-api", "--config-file", JSONConfigFile}
120+
exArgs := []string{fc.Path()}
121+
// Enable the API socket when a socket_path is configured, otherwise keep the
122+
// upstream --no-api. Either way the guest boots from the config file.
123+
if args.SocketPath != "" {
124+
exArgs = append(exArgs, "--api-sock", args.SocketPath)
125+
} else {
126+
exArgs = append(exArgs, "--no-api")
127+
}
128+
exArgs = append(exArgs, "--config-file", JSONConfigFile)
115129
if !args.Seccomp {
116130
exArgs = append(exArgs, "--no-seccomp")
117131
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// Copyright (c) 2023-2026, Nubificus LTD
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package hypervisors
16+
17+
import (
18+
"strings"
19+
"testing"
20+
21+
"github.com/stretchr/testify/assert"
22+
"github.com/urunc-dev/urunc/pkg/unikontainers/types"
23+
)
24+
25+
const testFCBinary = "/usr/bin/firecracker"
26+
27+
// TestFirecrackerBuildExecCmdSocket verifies Firecracker enables --api-sock
28+
// only when socket_path is set, and restores --no-api otherwise.
29+
func TestFirecrackerBuildExecCmdSocket(t *testing.T) {
30+
t.Parallel()
31+
32+
tests := []struct {
33+
name string
34+
args types.ExecArgs
35+
mustContain []string
36+
mustNotContain []string
37+
}{
38+
{
39+
name: "configured SocketPath renders --api-sock and keeps --config-file",
40+
args: types.ExecArgs{
41+
UnikernelPath: testKernelPath,
42+
Command: testCommand,
43+
SocketPath: "/run/urunc/fc.sock",
44+
},
45+
mustContain: []string{"--api-sock /run/urunc/fc.sock", "--config-file"},
46+
mustNotContain: []string{"--no-api"},
47+
},
48+
{
49+
name: "unset SocketPath restores --no-api and omits --api-sock",
50+
args: types.ExecArgs{
51+
UnikernelPath: testKernelPath,
52+
Command: testCommand,
53+
ContainerID: "abc123",
54+
},
55+
mustContain: []string{"--no-api", "--config-file"},
56+
mustNotContain: []string{"--api-sock"},
57+
},
58+
}
59+
60+
for _, tt := range tests {
61+
t.Run(tt.name, func(t *testing.T) {
62+
t.Parallel()
63+
64+
fc := &Firecracker{binary: FirecrackerBinary, binaryPath: testFCBinary}
65+
out, err := fc.BuildExecCmd(tt.args, &fakeUnikernel{})
66+
assert.NoError(t, err)
67+
assert.NotEmpty(t, out)
68+
69+
assert.Equal(t, testFCBinary, out[0], "binary path must be the first element")
70+
joined := strings.Join(out, " ")
71+
72+
for _, want := range tt.mustContain {
73+
assert.Contains(t, joined, want, "expected %q to be present", want)
74+
}
75+
for _, notWant := range tt.mustNotContain {
76+
assert.NotContains(t, joined, notWant, "expected %q to be absent", notWant)
77+
}
78+
})
79+
}
80+
}

‎pkg/unikontainers/hypervisors/hedge.go‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,11 @@ func (h *Hedge) SupportsSharedfs(_ string) bool {
5151
return false
5252
}
5353

54+
// SupportsControlSocket reports that Hedge exposes no control socket.
55+
func (h *Hedge) SupportsControlSocket() bool {
56+
return false
57+
}
58+
5459
func (h *Hedge) Path() string {
5560
return ""
5661
}

‎pkg/unikontainers/hypervisors/hvt.go‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,11 @@ func (h *HVT) SupportsSharedfs(_ string) bool {
140140
return false
141141
}
142142

143+
// SupportsControlSocket reports that HVT exposes no control socket.
144+
func (h *HVT) SupportsControlSocket() bool {
145+
return false
146+
}
147+
143148
// Path returns the path to the hvt binary.
144149
func (h *HVT) Path() string {
145150
return h.binaryPath

‎pkg/unikontainers/hypervisors/hyperlight.go‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ func (h *Hyperlight) SupportsSharedfs(_ string) bool {
4646
return false
4747
}
4848

49+
// SupportsControlSocket reports that Hyperlight exposes no control socket.
50+
func (h *Hyperlight) SupportsControlSocket() bool {
51+
return false
52+
}
53+
4954
// Path returns the path to the hyperlight binary.
5055
func (h *Hyperlight) Path() string {
5156
return h.binaryPath

‎pkg/unikontainers/hypervisors/qemu.go‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ func (q *Qemu) SupportsSharedfs(_ string) bool {
5656
return true
5757
}
5858

59+
// SupportsControlSocket reports that QEMU exposes a control socket (QMP).
60+
func (q *Qemu) SupportsControlSocket() bool {
61+
return true
62+
}
63+
5964
func (q *Qemu) Path() string {
6065
return q.binaryPath
6166
}
@@ -73,6 +78,11 @@ func (q *Qemu) BuildExecCmd(args types.ExecArgs, ukernel types.Unikernel) ([]str
7378
"-serial", "stdio",
7479
"-monitor", "null",
7580
}
81+
// Expose the QMP socket only when socket_path is set. server=on,wait=off lets
82+
// QEMU boot without waiting for a client.
83+
if args.SocketPath != "" {
84+
exArgs = append(exArgs, "-qmp", "unix:"+args.SocketPath+",server=on,wait=off")
85+
}
7686

7787
if args.VCPUs > 0 {
7888
exArgs = append(exArgs, "-smp", strconv.FormatUint(uint64(args.VCPUs), 10))

‎pkg/unikontainers/hypervisors/qemu_test.go‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,8 +95,29 @@ func TestQemuBuildExecCmd(t *testing.T) {
9595
"vhost-user-fs-pci",
9696
"virtio-blk-pci",
9797
"vhost-vsock-pci",
98+
"-qmp",
9899
},
99100
},
101+
{
102+
name: "configured SocketPath renders -qmp on that path",
103+
args: types.ExecArgs{
104+
UnikernelPath: testKernelPath,
105+
Command: testCommand,
106+
SocketPath: "/run/urunc/q.sock",
107+
},
108+
unikernel: &fakeUnikernel{},
109+
mustContain: []string{"-qmp unix:/run/urunc/q.sock,server=on,wait=off"},
110+
},
111+
{
112+
name: "unset SocketPath omits -qmp",
113+
args: types.ExecArgs{
114+
UnikernelPath: testKernelPath,
115+
Command: testCommand,
116+
ContainerID: "abc123",
117+
},
118+
unikernel: &fakeUnikernel{},
119+
mustNotContain: []string{"-qmp"},
120+
},
100121
{
101122
name: "custom MemSizeB renders -m in MB",
102123
args: types.ExecArgs{

0 commit comments

Comments
 (0)