arcbox_vm_driver/net.rs
1//! The second port in this crate: what a VM's NIC is attached to.
2//!
3//! [`GuestNetwork`] plans and builds the host side of a guest interface —
4//! an address, a TAP or equivalent, filters — and hands the driver a
5//! [`NicSpec`] to boot with. The Linux TAP adapter, the platform's IPv6
6//! dataplane, and the trivial MAC-minting networks behind the VZ/HV drivers
7//! all sit behind it. Cleanup after a VM is a two-step, token-guarded
8//! protocol ([`NetworkReconcile`]) because host forwarding state outlives
9//! the VM and must be confirmed gone before an address is reused.
10
11use std::net::IpAddr;
12
13use async_trait::async_trait;
14use serde::{Deserialize, Serialize};
15
16use crate::error::Result;
17use crate::spec::{MacAddr, NicSpec, VmId};
18
19/// Plans, builds, and tears down the host side of guest NICs.
20#[async_trait]
21pub trait GuestNetwork: Send + Sync {
22 /// Reserves an address and plans host resources for `vm`; nothing is
23 /// created on the host yet.
24 async fn reserve(&self, vm: &VmId, policy: NetworkPolicy) -> Result<NetworkLease>;
25
26 /// Creates the host side (TAP, filters) for `lease` and returns the NIC
27 /// the driver boots with.
28 async fn activate(&self, lease: &NetworkLease, mode: AttachMode) -> Result<NicSpec>;
29
30 /// Re-registers a lease whose guest is still running, and re-establishes
31 /// whatever host state died with the previous process.
32 ///
33 /// The third way a lease becomes live: a host that restarts while its
34 /// guests keep running replays its durable leases through this, then
35 /// declares the replay done ([`NetworkReconcile::replay_complete`]).
36 /// Unlike [`GuestNetwork::activate`] it must not create or re-address
37 /// the guest's interface — the link is live and carrying traffic.
38 ///
39 /// `mode` is what the lease was activated as, which the caller knows
40 /// and the network does not: it decides whether the guest holds the
41 /// pool address itself or the fixed invariant one.
42 ///
43 /// Not idempotent, unlike [`GuestNetwork::quarantine`]: a lease that is
44 /// already live, or whose address another live lease holds, is an
45 /// error. Two records claiming one address is an inconsistent replay,
46 /// and adopting either of them is a guess.
47 ///
48 /// Fallible on purpose, and the failure is not fatal to the caller: an
49 /// adapter that cannot re-establish says so, and the owner falls back
50 /// to tearing the VM down — the behavior it had before adoption
51 /// existed. Hence no default body: an adapter that silently claimed
52 /// adoption worked would leave a live guest unreachable, which is
53 /// strictly worse than that fallback.
54 async fn adopt(&self, lease: &NetworkLease, mode: AttachMode) -> Result<()>;
55
56 /// Tears the host side down but keeps the address reserved until the
57 /// cleanup protocol confirms host forwarding state is gone. Idempotent.
58 async fn quarantine(&self, lease: NetworkLease) -> Result<()>;
59
60 /// Returns the address to the pool outright, skipping quarantine.
61 async fn release(&self, lease: NetworkLease) -> Result<()>;
62
63 /// Withholds `address` from this process's pool without creating,
64 /// claiming or touching any host state.
65 ///
66 /// For durable state a restart could not read: nothing can name that
67 /// record's lease through this port, so nothing can adopt, quarantine
68 /// or release it — yet its guest may still be running on that address
69 /// with its interface up. Handing the address to the next VM would
70 /// take that interface out from under the guest, since
71 /// [`GuestNetwork::activate`] replaces a device of the same name, and
72 /// forwarding rules installed for the previous occupant outlive the
73 /// process that installed them. So the address is held for this
74 /// process's lifetime instead.
75 ///
76 /// Deliberately neither a lease nor a quarantine: it cannot be listed
77 /// by [`NetworkReconcile::pending_cleanups`], finalized, or released,
78 /// because nothing can prove whose it is. An adapter whose pool does
79 /// not contain `address` has nothing to withhold and does nothing.
80 ///
81 /// Infallible and idempotent, unlike everything above it: this is what
82 /// a caller reaches for once every richer answer has already failed,
83 /// and "could not hold it" would leave that caller nothing to do.
84 fn hold_address(&self, address: IpAddr);
85
86 /// The network as the guest sees it under `mode`: what goes on the
87 /// kernel command line or into a net-reconfigure command.
88 ///
89 /// The mode is the caller's, not the lease's: a lease is reserved
90 /// before anyone knows how it will be attached, and the same lease
91 /// reads differently once activated as [`AttachMode::LegacySnapshot`]
92 /// (the guest owns the pool address) than as [`AttachMode::Invariant`]
93 /// (the address is the host's, translated per interface). Pass what
94 /// [`GuestNetwork::activate`] was given.
95 fn identity(&self, lease: &NetworkLease, mode: AttachMode) -> NetworkIdentity;
96
97 /// How host-side forwarding reaches this guest: the address DNAT must
98 /// target, and any packet mark the datapath needs to see.
99 ///
100 /// Decided by the datapath that actually applied to this lease, not by
101 /// the policy that was asked for — an adapter that falls back at
102 /// activation time reports the fallback's answer here. It is therefore
103 /// not derivable from [`GuestNetwork::identity`] plus the lease: a
104 /// network that gives every guest the same address translated per
105 /// interface has the host target the *lease's* address while the guest
106 /// holds the fixed one, which is the exact inverse of what those two
107 /// values say.
108 ///
109 /// A read, like [`GuestNetwork::identity`]: what applied to a lease is
110 /// state the network already holds by the time anyone can ask.
111 fn host_ingress(&self, lease: &NetworkLease) -> Result<HostIngress>;
112
113 /// The cleanup-token protocol and startup sweep, when the network keeps
114 /// a quarantine ledger.
115 fn reconcile(&self) -> Option<&dyn NetworkReconcile> {
116 None
117 }
118}
119
120/// Where host-side forwarding sends packets destined for one guest.
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122#[non_exhaustive]
123pub enum HostIngress {
124 /// DNAT to the lease's own (pool) address; the datapath translates
125 /// onwards.
126 PoolAddress,
127 /// DNAT to the guest's address, marking packets with `fwmark` so the
128 /// datapath's policy routing picks them up.
129 GuestAddress {
130 /// The mark the forwarding rule must set.
131 fwmark: u32,
132 },
133}
134
135/// What kind of connectivity a VM gets.
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
137pub struct NetworkPolicy {
138 /// The connectivity mode.
139 pub mode: NetworkMode,
140}
141
142/// Connectivity modes a network may offer.
143#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
144#[serde(rename_all = "snake_case")]
145#[non_exhaustive]
146pub enum NetworkMode {
147 /// Host and guest can talk; nothing beyond the host.
148 Isolated,
149 /// Egress through the host's address.
150 Nat,
151 /// The guest appears on the host's link.
152 Bridged,
153}
154
155/// How the host side is attached, for networks that keep the legacy shape.
156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
157pub enum AttachMode {
158 /// Fixed guest identity with per-interface translation host-side: every
159 /// fresh boot and every invariant-snapshot restore.
160 Invariant,
161 /// The interface carries the pool address itself: restores of
162 /// checkpoints taken before the invariant identity existed.
163 LegacySnapshot,
164}
165
166/// A reserved address and the host resources planned for one VM.
167///
168/// Serialized into the VM's durable record so cleanup can be replayed after
169/// a crash; `cleanup_token` is what the two-step cleanup protocol checks.
170///
171/// Deliberately plain — no `#[non_exhaustive]`, because a runtime builds
172/// one from a record it read back and needs every field. What that costs
173/// is that the field *names* are an on-disk contract: a field added here
174/// must carry `#[serde(default)]` so a record written before it still
175/// loads, and nothing may key on the order they are declared in.
176#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
177pub struct NetworkLease {
178 /// The VM this lease belongs to.
179 pub vm: VmId,
180 /// The guest's address.
181 pub ip: IpAddr,
182 /// The subnet prefix length.
183 pub prefix_len: u8,
184 /// The gateway the guest routes through.
185 pub gateway: IpAddr,
186 /// The guest's MAC address.
187 pub mac: MacAddr,
188 /// Opaque; presented to [`NetworkReconcile::finalize_cleanup`] to prove
189 /// the caller is finishing *this* generation of the VM's cleanup.
190 pub cleanup_token: String,
191}
192
193/// The network as the guest sees it.
194#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
195pub struct NetworkIdentity {
196 /// The guest's address.
197 pub ip: IpAddr,
198 /// The subnet prefix length.
199 pub prefix_len: u8,
200 /// The gateway the guest routes through.
201 pub gateway: IpAddr,
202 /// Resolvers the guest should use.
203 pub dns: Vec<IpAddr>,
204 /// The guest's MAC address.
205 pub mac: MacAddr,
206}
207
208/// The quarantine ledger's cleanup protocol.
209///
210/// Two flows share the token discipline. Per VM: after `quarantine`, the
211/// host side (forwarding rules, listeners) is cleaned by someone else, who
212/// then `validate`s and `finalize`s with the lease's token; only then does
213/// the address return to the pool. At startup: the network sweeps what a
214/// previous process left behind and, when there is anything, mints a
215/// startup token that gates the same validate/finalize pair before the
216/// network declares itself settled.
217#[async_trait]
218pub trait NetworkReconcile: Send + Sync {
219 /// VMs whose leases are quarantined, with the token each finalization
220 /// must present.
221 ///
222 /// Fallible because a durable ledger is read back from disk: an entry
223 /// whose id is not a [`VmId`] is one this protocol could never name,
224 /// list, or finalize, and its address would stay out of the pool
225 /// forever. The caller sees that as an error rather than as a shorter
226 /// list.
227 async fn pending_cleanups(&self) -> Result<Vec<(VmId, String)>>;
228
229 /// Checks that `token` names `vm`'s pending cleanup generation, and
230 /// hands back the lease it names.
231 ///
232 /// The lease is the answer, not an acknowledgement: the host cleanup
233 /// this gates is keyed by the address that generation held, and after
234 /// a restart the only place that address survives is the ledger the
235 /// network is reading here.
236 async fn validate_cleanup(&self, vm: &VmId, token: &str) -> Result<NetworkLease>;
237
238 /// Ends `vm`'s quarantine and returns its address to the pool.
239 async fn finalize_cleanup(&self, vm: &VmId, token: &str) -> Result<()>;
240
241 /// The token for the startup sweep's host-side cleanup, while one is
242 /// pending.
243 async fn startup_cleanup_token(&self) -> Option<String>;
244
245 /// Checks that `token` names the pending startup cleanup.
246 async fn validate_startup_cleanup(&self, token: &str) -> Result<()>;
247
248 /// Marks the startup sweep's host-side cleanup done.
249 async fn finalize_startup_cleanup(&self, token: &str) -> Result<()>;
250
251 /// Waits until no startup cleanup is pending.
252 async fn wait_startup_cleanup_complete(&self);
253
254 /// The owner has finished replaying its durable state into this ledger.
255 /// Until it has, the startup sweep cannot be finalized: a host must not
256 /// declare a previous process's forwarding state gone while the owner is
257 /// still discovering what it left behind.
258 fn replay_complete(&self);
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264
265 #[test]
266 fn lease_round_trips_through_json() {
267 let lease = NetworkLease {
268 vm: VmId::new("vm-1").unwrap(),
269 ip: "10.200.0.2".parse().unwrap(),
270 prefix_len: 16,
271 gateway: "10.200.0.1".parse().unwrap(),
272 mac: "02:fa:ce:00:00:02".parse().unwrap(),
273 cleanup_token: "gen-1".into(),
274 };
275 let json = serde_json::to_string(&lease).unwrap();
276 assert_eq!(serde_json::from_str::<NetworkLease>(&json).unwrap(), lease);
277 assert!(json.contains("\"02:fa:ce:00:00:02\""));
278 assert!(json.contains("\"10.200.0.2\""));
279 }
280}