Solo5 hvt ABI and MFT1/ABI1 note format as wire codecs
15 kB
334 lines
1(* The tender: lay out the boot info and manifest, initialise the CPU, then run
2 the guest, decoding each exit and servicing the Solo5 hypercalls. Network and
3 block hypercalls are serviced when their device is attached, and fail hard
4 otherwise. The backend-independent core; a {!Machine.S} backend supplies the
5 VM and the exit boundary, and {!Make} drives it. *)
6
7let i64 = Int64.of_int
8let fail fmt = Fmt.failwith fmt
9
10(* {1 Boot setup} *)
11
12(* The low-memory boot block, laid out exactly as Solo5's hvt_boot_info_init
13 (tenders/hvt/hvt_boot_info.c): the boot info, then the manifest, then the
14 command line, packed contiguously upward from [Solo5_abi.Abi.boot_info_base].
15 It sits below the kernel load address [Solo5_abi.Abi.guest_min_base], in the
16 read-only region the guest never heaps into -- the guest's heap starts
17 page-rounded above the image's _end (Solo5's bindings/mem.c), far above the
18 kernel. A manifest left at or above the image end is overwritten by the
19 guest's first allocation, detaching its devices before solo5_block_acquire
20 reads them. *)
21type layout = {
22 boot_info : Solo5_abi.Abi.boot_info;
23 (** written at [Solo5_abi.Abi.boot_info_base] *)
24 mft_gpa : int; (** address the manifest is written at *)
25 manifest : string;
26 cmdline_gpa : int;
27 (** address the NUL-terminated command line is written at *)
28 cmdline : string;
29}
30
31let layout ~mem_size ~cpu_cycle_freq ~kernel_end ~manifest ~cmdline =
32 let mft_gpa = Solo5_abi.Abi.boot_info_base + Solo5_abi.Abi.boot_info_size in
33 let cmdline = cmdline ^ "\000" in
34 let cmdline_gpa = mft_gpa + String.length manifest in
35 let boot_info =
36 (* No ring I/O offered: a v0.11+ guest reads host_features = 0 and uses
37 hypercall I/O; the explicit zeros keep that independent of the guest RAM
38 being zero-initialised. *)
39 {
40 Solo5_abi.Abi.mem_size = i64 mem_size;
41 kernel_end = i64 kernel_end;
42 cpu_cycle_freq;
43 cmdline = i64 cmdline_gpa;
44 mft = i64 mft_gpa;
45 host_features = Optint.zero;
46 guest_features = Optint.zero;
47 net_ring = 0L;
48 }
49 in
50 { boot_info; mft_gpa; manifest; cmdline_gpa; cmdline }
51
52(* {1 Hypercall handlers} *)
53
54let now_ns () = int_of_float (Unix.gettimeofday () *. 1e9)
55let handle_walltime mem ~gpa = Hc.set_walltime_nsecs mem ~gpa (now_ns ())
56
57(* A fixed buffer the guest's puts data is streamed through in bounded chunks.
58 Single-vCPU: one hypercall is serviced at a time, so the shared buffer is
59 race-free. *)
60let puts_scratch = Bytes.create 4096
61
62(* Escape the guest's console bytes when the tender's stdout is an interactive
63 terminal, so an untrusted guest cannot inject terminal control sequences (see
64 {!Console}); redirected output (a CI log, a file, a pipe) is left verbatim,
65 so structured output is not corrupted and that path stays allocation-free. *)
66let console_escapes = lazy (Unix.isatty Unix.stdout)
67
68(* Stream [remaining] bytes of guest memory at [data + off] to stdout in
69 [puts_scratch]-sized chunks. Top-level (not a closure nested in handle_puts)
70 so it captures nothing and a puts allocates none per call: [mem], [data], and
71 [escape] are passed as arguments. *)
72let rec puts_loop mem ~data ~escape off remaining =
73 if remaining > 0 then begin
74 let n = min remaining (Bytes.length puts_scratch) in
75 Guest.read_into mem ~gpa:(data + off) puts_scratch ~off:0 ~len:n;
76 if escape then Console.output stdout puts_scratch ~off:0 ~len:n
77 else output_substring stdout (Bytes.unsafe_to_string puts_scratch) 0 n;
78 puts_loop mem ~data ~escape (off + n) (remaining - n)
79 end
80
81let handle_puts mem ~gpa =
82 let data = Hc.puts_data mem ~gpa in
83 let len = Hc.puts_len mem ~gpa in
84 Guest.check mem ~gpa:data ~len;
85 let escape = Lazy.force console_escapes in
86 puts_loop mem ~data ~escape 0 len;
87 flush stdout
88
89(* Service a POLL: wait up to the deadline for a pollable device to become
90 readable and report which are ready in [ready_set] (bit i = device handle i).
91 Solo5 only polls net devices; [net_handle] is the net device's manifest-entry
92 index, the handle the guest acquired it under -- so the ready bit is set at
93 that handle, not a fixed bit 0. With no device, POLL is the bounded sleep the
94 deadline asks for. *)
95let handle_poll ~poll mem ~net ~net_handle ~gpa =
96 let timeout = Float.of_int (Hc.poll_timeout_nsecs mem ~gpa) /. 1e9 in
97 let ready_set =
98 match (net, net_handle) with
99 | Some dev, Some h ->
100 if Net.wait dev timeout then Int64.shift_left 1L h else 0L
101 | _ ->
102 (* No pollable device: honour the deadline as a bounded sleep through
103 the backend's wait ([poll]), which uses only allowlisted syscalls
104 (epoll_pwait on Linux, select on macOS) -- never a raw [Unix.select]
105 that the kvm sandbox would kill. *)
106 if timeout > 0. then ignore (poll timeout : bool);
107 0L
108 in
109 Hc.set_poll_ready_set mem ~gpa ready_set;
110 Hc.set_poll_ret mem ~gpa 0
111
112let handle_halt mem ~gpa = Hc.halt_exit_status mem ~gpa
113
114(* {1 Hypercall dispatch} *)
115
116(* The outcome of servicing one guest MMIO access: keep running ([Continue]) or
117 halt with a guest exit status ([Halt]). A load's result is delivered through
118 the [complete] callback {!service} is given, not carried in the variant, so
119 the common path returns the constant [Continue] and allocates nothing. *)
120type step = Continue | Halt of int
121
122(* Per-hypercall span for [--probe] captures. A single [nr] field, so passing it
123 allocates nothing on the hot path; emission is a no-op unless a capture is
124 active. The span end carries the body's minor_words delta, so a capture
125 attributes per-hypercall allocation by number. *)
126let hypercall_span =
127 Probe.span "solo5.hypercall" ~doc:"Service one guest hypercall"
128 Probe.Fields.(
129 obj (fun nr -> nr) |> field "nr" int ~enc:(fun nr -> nr) |> seal)
130
131(* Read and validate the device handle a hypercall carries as its first field: a
132 manifest-entry index, so it must fall in [0, MFT_MAX_ENTRIES). {!Guest.u64}
133 saturates a high-bit value to [max_int], which fails [h >= max_entries] here
134 rather than aliasing into a low, valid handle. *)
135let guest_handle mem ~gpa =
136 let h = Guest.u64 mem ~gpa in
137 if h < 0 || h >= Solo5_abi.Manifest.max_entries then
138 fail "guest used device handle 0x%x, outside the manifest" h;
139 h
140
141(* Select the block device bound to [handle], or fail. A direct walk returns the
142 device unwrapped, where [List.assoc_opt] would box a [Some] on every
143 hypercall. *)
144let rec block_by_handle handle = function
145 | [] -> fail "guest used block device handle %d, but none is attached" handle
146 | (h, dev) :: _ when h = handle -> dev
147 | _ :: rest -> block_by_handle handle rest
148
149(* A block hypercall carries the device handle (the manifest-entry index) as its
150 first field; select the bound device by it. *)
151let block_dispatch blocks mem ~gpa handler =
152 let handle = guest_handle mem ~gpa in
153 handler (block_by_handle handle blocks) mem ~gpa;
154 Continue
155
156(* A net hypercall carries the device handle (the manifest-entry index) as its
157 first field, the same handle the guest acquired the net device under. Service
158 it only for the attached net device's handle; any other handle is a guest the
159 tender never granted that device. *)
160let net_dispatch net net_handle mem ~gpa handler =
161 match (net, net_handle) with
162 | Some dev, Some h ->
163 let handle = guest_handle mem ~gpa in
164 if handle = h then begin
165 handler dev mem ~gpa;
166 Continue
167 end
168 else
169 fail "guest used net device handle %d, but the net device is handle %d"
170 handle h
171 | _ -> fail "guest used the network, but no net device is attached"
172
173(* Decode and service one guest hypercall. Network and block hypercalls need
174 their device configured; a guest that uses one with no device attached fails
175 hard. [blocks] maps each block-device handle to its device. *)
176let dispatch_body ~poll mem ~net ~net_handle ~blocks ~nr ~gpa =
177 match Solo5_abi.Abi.hypercall_of_int nr with
178 | Some Solo5_abi.Abi.Halt -> Halt (handle_halt mem ~gpa)
179 | Some Solo5_abi.Abi.Walltime ->
180 handle_walltime mem ~gpa;
181 Continue
182 | Some Solo5_abi.Abi.Puts ->
183 handle_puts mem ~gpa;
184 Continue
185 | Some Solo5_abi.Abi.Poll ->
186 handle_poll ~poll mem ~net ~net_handle ~gpa;
187 Continue
188 | Some Solo5_abi.Abi.Net_write ->
189 net_dispatch net net_handle mem ~gpa Net.handle_write
190 | Some Solo5_abi.Abi.Net_read ->
191 net_dispatch net net_handle mem ~gpa Net.handle_read
192 | Some Solo5_abi.Abi.Block_write ->
193 block_dispatch blocks mem ~gpa Block.handle_write
194 | Some Solo5_abi.Abi.Block_read ->
195 block_dispatch blocks mem ~gpa Block.handle_read
196 | None -> fail "invalid hypercall %d" nr
197
198let dispatch_body_args poll mem net net_handle blocks nr gpa =
199 dispatch_body ~poll mem ~net ~net_handle ~blocks ~nr ~gpa
200
201let dispatch ~poll mem ~net ~net_handle ~blocks ~nr ~gpa =
202 if Probe.active () then
203 Probe.with_span hypercall_span nr (fun () ->
204 dispatch_body_args poll mem net net_handle blocks nr gpa)
205 else dispatch_body_args poll mem net net_handle blocks nr gpa
206
207(* Service a guest MMIO access: a virtio-mmio register access (the guest polls
208 the device through these) or a hypercall store in the MMIO window; anything
209 outside both windows is a guest the tender never mapped. A load's result is
210 handed to [complete ~read] ([~read:(-1)] for a store), rather than carried in
211 the return, so the common outcome is the constant [Continue]. *)
212let service ~complete ~poll mem ~net ~net_handle ~blocks ~virtio ~addr ~write =
213 if Solo5_abi.Abi.in_virtio addr then begin
214 (* A virtio-mmio register access: a store carries its value in [write] (>=
215 0); a load ([write = -1]) is answered by [Virtio_net.read]. The guest
216 polls the device through these accesses, so first deliver any host frames
217 into the receive queue (a no-op until the guest has set that queue up); a
218 read of InterruptStatus then reflects them. *)
219 ignore (Virtio_net.deliver virtio mem : bool);
220 let off = Solo5_abi.Abi.virtio_offset addr in
221 let read =
222 if write >= 0 then (
223 Virtio_net.write virtio mem off write;
224 -1)
225 else Virtio_net.read virtio off
226 in
227 complete ~read;
228 Continue
229 end
230 else if
231 addr < Solo5_abi.Abi.mmio_base
232 || addr >= Solo5_abi.Abi.hypercall_address Solo5_abi.Abi.hypercall_max
233 then fail "invalid guest MMIO access at 0x%x" addr
234 else
235 let nr = Solo5_abi.Abi.hypercall_nr addr in
236 (* A hypercall is always a store; the argument-struct gpa is the value
237 written (the low 32 bits; a store of the zero register is a null gpa).
238 [write = -1] means a load, which is never a valid hypercall. *)
239 if write < 0 then
240 fail "guest issued hypercall %d via a load at 0x%x" nr addr;
241 match dispatch ~poll mem ~net ~net_handle ~blocks ~nr ~gpa:write with
242 | Halt _ as h -> h
243 | Continue ->
244 complete ~read:(-1);
245 Continue
246
247(* {1 Run loop} *)
248
249module Make (M : Machine.S) = struct
250 module Cpu = Cpu.Make (M)
251
252 let setup mem ~mem_size ~(loaded : Loader.t) ~cmdline ~net ~blocks =
253 (* Attach the tender's devices into the manifest so the guest's
254 solo5_*_acquire succeed, then place the boot block in low memory and
255 initialise the CPU. The block bindings (handle -> device) drive the block
256 hypercall dispatch. *)
257 let attached = Manifest.attach loaded.manifest ~net ~blocks in
258 let l =
259 layout ~mem_size ~cpu_cycle_freq:(M.counter_frequency ())
260 ~kernel_end:loaded.kernel_end ~manifest:attached.manifest ~cmdline
261 in
262 Guest.write_string mem ~gpa:l.mft_gpa l.manifest;
263 Guest.write_string mem ~gpa:l.cmdline_gpa l.cmdline;
264 Guest.encode mem ~gpa:Solo5_abi.Abi.boot_info_base
265 Solo5_abi.Abi.boot_info_codec l.boot_info;
266 (* W^X: code segments (executable, not writable) are mapped read-only so a
267 guest cannot rewrite its own code; data segments (writable, not
268 executable) are mapped no-execute so it cannot run from its writable
269 memory. *)
270 let range (s : Loader.segment) = (s.addr, s.addr + s.size) in
271 let code_ranges =
272 List.filter_map
273 (fun s ->
274 if s.Loader.executable && not s.writable then Some (range s) else None)
275 loaded.segments
276 in
277 let data_ranges =
278 List.filter_map
279 (fun s ->
280 if s.Loader.writable && not s.executable then Some (range s) else None)
281 loaded.segments
282 in
283 Cpu.init mem ~mem_size ~entry:loaded.entry ~code_ranges ~data_ranges;
284 (attached.net, attached.blocks)
285
286 let run ?(watchdog = 0.) mem ~mem_size ~loaded ~cmdline ~net ~blocks =
287 let net_handle, blocks =
288 setup mem ~mem_size ~loaded ~cmdline ~net ~blocks
289 in
290 (* The virtio-mmio net device backing the side-channel region. With no host
291 net backend it loops back; the guest discovers it via the manifest. *)
292 let virtio =
293 Virtio_net.v ~net:(match net with Some n -> n | None -> Net.loopback ())
294 in
295 (* Probe the environment for anything lazy that does a one-off syscall, now,
296 before the sandbox: the console's isatty check does an ioctl(TCGETS),
297 which the kvm sandbox's ioctl=KVM_RUN-only filter would otherwise SIGSYS
298 at the first PUTS. Forcing it here caches the result, so the run loop
299 makes only allowlisted syscalls. *)
300 ignore (Lazy.force console_escapes : bool);
301 (* Arm the runaway-guest watchdog before the sandbox: the backend spawns
302 its watchdog thread here, which the sandbox would deny later. *)
303 if watchdog > 0. then M.watchdog watchdog;
304 (* Drop privileges before entering the guest: with the VM and devices
305 already set up, the tender needs no new files, no process spawning, and
306 no host network of its own -- a slirp gateway, when used, runs in a
307 separate process and the tender only reads and writes its socketpair end.
308 The sandbox denies all three, fail-closed. *)
309 M.sandbox ();
310 let rec loop () =
311 match M.run () with
312 | Machine.Vtimer ->
313 M.mask_vtimer true;
314 loop ()
315 | Machine.Canceled ->
316 (* A cancel is either the watchdog powering off a guest that made no
317 VM exit within its budget, or a stray interruption to resume. *)
318 if M.watchdog_fired () then
319 fail "guest watchdog: no VM exit within %gs (runaway guest)"
320 watchdog
321 else loop ()
322 | Machine.Fatal -> fail "%s" (M.fatal_message ())
323 | Machine.Mmio -> (
324 (* [service] delivers a load's result through [M.complete] itself, so
325 the common outcome is the constant [Continue] with no allocation. *)
326 match
327 service ~complete:M.complete ~poll:M.poll_wait mem ~net ~net_handle
328 ~blocks ~virtio ~addr:(M.mmio_addr ()) ~write:(M.mmio_write ())
329 with
330 | Continue -> loop ()
331 | Halt status -> status)
332 in
333 loop ()
334end