microsandbox 0.6.10

`microsandbox` is the core library for the microsandbox project.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
//! Integration tests for the `host.microsandbox.internal` alias.

use std::io;
use std::net::{Ipv4Addr, SocketAddr};
use std::sync::Arc;

use ipnetwork::{IpNetwork, Ipv4Network};
use microsandbox::{NetworkPolicy, Sandbox};
use microsandbox_network::policy::{Action, Destination, DestinationGroup, Rule};
use test_utils::msb_test;
use tokio::io::AsyncWriteExt;
use tokio::net::TcpListener;
use tokio::sync::oneshot;
use tokio::task::JoinHandle;

//--------------------------------------------------------------------------------------------------
// Host HTTP fixture
//--------------------------------------------------------------------------------------------------

/// Minimal HTTP server bound only to IPv4 loopback.
///
/// Host-alias access must work even when the guest selects its IPv6 gateway.
struct HostHttp {
    port: u16,
    shutdown: Option<oneshot::Sender<()>>,
    handle: Option<JoinHandle<()>>,
}

impl HostHttp {
    async fn start(body: &'static str) -> io::Result<Self> {
        let v4_listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))).await?;
        let port = v4_listener.local_addr()?.port();

        let (shutdown_tx, shutdown_rx) = oneshot::channel();
        let body = Arc::new(body.to_owned());

        let handle = tokio::spawn(async move {
            let mut shutdown_rx = shutdown_rx;
            loop {
                tokio::select! {
                    _ = &mut shutdown_rx => return,
                    accept = v4_listener.accept() => Self::handle_accept(accept, &body),
                }
            }
        });

        Ok(Self {
            port,
            shutdown: Some(shutdown_tx),
            handle: Some(handle),
        })
    }

    fn handle_accept(accept: io::Result<(tokio::net::TcpStream, SocketAddr)>, body: &Arc<String>) {
        let Ok((mut stream, _)) = accept else { return };
        let body = body.clone();
        tokio::spawn(async move {
            let response = format!(
                "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
                body.len(),
                body,
            );
            let _ = stream.write_all(response.as_bytes()).await;
            let _ = stream.shutdown().await;
        });
    }

    fn port(&self) -> u16 {
        self.port
    }
}

impl Drop for HostHttp {
    fn drop(&mut self) {
        if let Some(tx) = self.shutdown.take() {
            let _ = tx.send(());
        }
        if let Some(h) = self.handle.take() {
            h.abort();
        }
    }
}

//--------------------------------------------------------------------------------------------------
// Helpers
//--------------------------------------------------------------------------------------------------

/// Boot an alpine sandbox with the given policy (or the default when `None`).
async fn spawn_sandbox(name: &str, policy: Option<NetworkPolicy>) -> Sandbox {
    let builder = Sandbox::builder(name)
        .image("mirror.gcr.io/library/alpine")
        .cpus(1)
        .memory(256)
        .replace();
    match policy {
        Some(p) => builder.network(|n| n.policy(p)).create(),
        None => builder.create(),
    }
    .await
    .expect("create sandbox")
}

/// Stop the sandbox and remove it.
async fn teardown(sb: Sandbox, name: &str) {
    drop(sb);
    let handle = Sandbox::get(name).await.expect("get");
    handle.stop().await.expect("stop");
    let _ = Sandbox::remove(name).await;
}

/// Read the sandbox gateway IPv4 from the guest's `/etc/resolv.conf`.
async fn read_gateway_ip(sb: &Sandbox) -> String {
    let out = sb
        .shell("awk '/^nameserver /{print $2; exit}' /etc/resolv.conf")
        .await
        .expect("read resolv.conf");
    out.stdout().expect("utf8").trim().to_owned()
}

/// Read the sandbox gateway IPv6 from the host-alias entry in `/etc/hosts`.
async fn read_gateway_ipv6(sb: &Sandbox) -> String {
    let out = sb
        .shell(
            "awk '$2 == \"host.microsandbox.internal\" && index($1, \":\") {print $1; exit}' /etc/hosts",
        )
        .await
        .expect("read host alias IPv6");
    out.stdout().expect("utf8").trim().to_owned()
}

/// Allow host only; deny everything else.
fn allow_host_only_policy() -> NetworkPolicy {
    NetworkPolicy {
        default_egress: Action::Deny,
        default_ingress: Action::Allow,
        rules: vec![Rule::allow_egress(Destination::Group(
            DestinationGroup::Host,
        ))],
    }
}

/// Deny host only; allow everything else.
fn deny_host_group_policy() -> NetworkPolicy {
    NetworkPolicy {
        default_egress: Action::Allow,
        default_ingress: Action::Allow,
        rules: vec![Rule::deny_egress(Destination::Group(
            DestinationGroup::Host,
        ))],
    }
}

/// Deny the given gateway IPv4 `/32`; allow everything else.
fn deny_gateway_cidr_policy(gateway_ip: &str) -> NetworkPolicy {
    let addr: Ipv4Addr = gateway_ip.parse().expect("valid gateway ipv4");
    NetworkPolicy {
        default_egress: Action::Allow,
        default_ingress: Action::Allow,
        rules: vec![Rule::deny_egress(Destination::Cidr(IpNetwork::V4(
            Ipv4Network::new(addr, 32).expect("valid /32"),
        )))],
    }
}

//--------------------------------------------------------------------------------------------------
// Tests
//--------------------------------------------------------------------------------------------------

/// Baseline reachability by hostname and by raw gateway IPv4, plus `/etc/hosts` entry.
#[msb_test]
async fn host_alias_reachable_by_hostname_and_gateway_ip() {
    let server = HostHttp::start("hello from host")
        .await
        .expect("http fixture");
    let port = server.port();

    let name = "host-alias-baseline";
    let sb = spawn_sandbox(name, Some(NetworkPolicy::allow_all())).await;

    let out = sb
        .shell(format!(
            "wget -qO- --timeout=10 http://host.microsandbox.internal:{port}/"
        ))
        .await
        .expect("wget hostname");
    assert_eq!(
        out.stdout().unwrap().trim(),
        "hello from host",
        "hostname path body mismatch (stderr: {})",
        out.stderr().unwrap_or_default()
    );

    let gw = read_gateway_ip(&sb).await;
    let out = sb
        .shell(format!("wget -qO- --timeout=10 http://{gw}:{port}/"))
        .await
        .expect("wget gateway");
    assert_eq!(
        out.stdout().unwrap().trim(),
        "hello from host",
        "gateway-IP path body mismatch (stderr: {})",
        out.stderr().unwrap_or_default()
    );

    let hosts = sb
        .shell("cat /etc/hosts")
        .await
        .expect("cat /etc/hosts")
        .stdout()
        .unwrap()
        .to_owned();
    assert!(
        hosts.contains("host.microsandbox.internal"),
        "expected host.microsandbox.internal in /etc/hosts, got:\n{hosts}"
    );
    assert!(
        hosts.contains(&gw),
        "expected gateway IPv4 {gw} in /etc/hosts, got:\n{hosts}"
    );

    teardown(sb, name).await;
}

/// An IPv6 host-alias choice must still reach a service bound only on IPv4 loopback.
#[msb_test]
async fn host_alias_ipv6_falls_back_to_ipv4_only_listener() {
    let server = HostHttp::start("hello from ipv4")
        .await
        .expect("IPv4-only HTTP fixture");
    let port = server.port();

    let name = "host-alias-ipv6-to-ipv4";
    let sb = spawn_sandbox(name, Some(NetworkPolicy::allow_all())).await;
    let gateway_ipv6 = read_gateway_ipv6(&sb).await;
    if gateway_ipv6.is_empty() {
        eprintln!("skip: host has no IPv6 route — guest IPv6 gateway was not provisioned");
        teardown(sb, name).await;
        return;
    }

    let out = sb
        .shell(format!(
            "wget -qO- --timeout=10 'http://[{gateway_ipv6}]:{port}/'"
        ))
        .await
        .expect("wget IPv6 gateway");
    assert_eq!(
        out.stdout().unwrap().trim(),
        "hello from ipv4",
        "IPv6-to-IPv4 fallback body mismatch (stderr: {})",
        out.stderr().unwrap_or_default()
    );

    teardown(sb, name).await;
}

/// `dig` skips `/etc/hosts`, so a successful answer proves the forwarder synthesises the alias.
#[msb_test]
async fn host_alias_dns_synth_bypasses_hosts_file() {
    let name = "host-alias-dns-synth";
    let sb = spawn_sandbox(name, None).await;

    sb.shell("apk add --quiet --no-progress bind-tools >/dev/null 2>&1")
        .await
        .expect("install bind-tools");

    let gw = read_gateway_ip(&sb).await;
    let out = sb
        .shell("dig +short +time=3 +tries=1 host.microsandbox.internal A")
        .await
        .expect("dig alias");
    let answer = out.stdout().unwrap().trim().to_owned();
    assert_eq!(
        answer,
        gw,
        "expected DNS synth to return gateway {gw}, got {answer:?} (stderr: {})",
        out.stderr().unwrap_or_default()
    );

    teardown(sb, name).await;
}

/// CIDR deny on the gateway `/32` blocks host access (policy fires before the proxy rewrite).
#[msb_test]
async fn host_alias_denied_by_gateway_cidr_policy() {
    let server = HostHttp::start("should not see")
        .await
        .expect("http fixture");
    let port = server.port();

    // Boot once to read the gateway, tear down, then boot again with the CIDR policy.
    let probe_name = "host-alias-policy-probe";
    let probe = spawn_sandbox(probe_name, None).await;
    let gw = read_gateway_ip(&probe).await;
    teardown(probe, probe_name).await;

    let name = "host-alias-policy-deny";
    let sb = spawn_sandbox(name, Some(deny_gateway_cidr_policy(&gw))).await;

    // Dial v4 directly; via the hostname, happy-eyeballs could pick the v6 entry (not covered).
    let out = sb
        .shell(format!(
            "wget -qO- --timeout=5 http://{gw}:{port}/; echo status=$?"
        ))
        .await
        .expect("wget");
    let stdout = out.stdout().unwrap();
    assert!(
        stdout.contains("status=") && !stdout.trim_end().ends_with("status=0"),
        "expected wget to fail when gateway denied by policy; got: {stdout:?} (stderr: {})",
        out.stderr().unwrap_or_default()
    );

    teardown(sb, name).await;
}

/// The default public profile must block host access; users opt in explicitly.
#[msb_test]
async fn host_alias_denied_by_default_policy() {
    let server = HostHttp::start("should not see")
        .await
        .expect("http fixture");
    let port = server.port();

    let name = "host-alias-default-deny";
    let sb = spawn_sandbox(name, None).await;

    let out = sb
        .shell(format!(
            "wget -qO- --timeout=5 http://host.microsandbox.internal:{port}/; echo status=$?"
        ))
        .await
        .expect("wget");
    let stdout = out.stdout().unwrap();
    assert!(
        stdout.contains("status=") && !stdout.trim_end().ends_with("status=0"),
        "expected wget to fail under default public profile; got: {stdout:?} (stderr: {})",
        out.stderr().unwrap_or_default()
    );
    assert!(
        !stdout.contains("should not see"),
        "host body leaked through default-denied policy: {stdout:?}"
    );

    teardown(sb, name).await;
}

/// `Group::Host` allow on a deny-all base: host reachable, everything else denied.
#[msb_test]
async fn group_host_allow_narrows_to_gateway() {
    let server = HostHttp::start("host ok").await.expect("http fixture");
    let port = server.port();

    let name = "group-host-allow";
    let sb = spawn_sandbox(name, Some(allow_host_only_policy())).await;

    let out = sb
        .shell(format!(
            "wget -qO- --timeout=5 http://host.microsandbox.internal:{port}/"
        ))
        .await
        .expect("wget host");
    assert_eq!(
        out.stdout().unwrap().trim(),
        "host ok",
        "group host allow should let guest reach the host (stderr: {})",
        out.stderr().unwrap_or_default()
    );

    let out = sb
        .shell("wget -qO- --timeout=5 http://8.8.8.8/ ; echo status=$?")
        .await
        .expect("wget external");
    let stdout = out.stdout().unwrap();
    assert!(
        !stdout.trim_end().ends_with("status=0"),
        "non-host traffic should be denied under allow-host-only policy; got: {stdout:?}"
    );

    teardown(sb, name).await;
}

/// `Group::Host` deny on an allow-all base: host blocked, rest still works.
#[msb_test]
async fn group_host_deny_blocks_host_only() {
    let server = HostHttp::start("unreachable").await.expect("http fixture");
    let port = server.port();

    let name = "group-host-deny";
    let sb = spawn_sandbox(name, Some(deny_host_group_policy())).await;

    let out = sb
        .shell(format!(
            "wget -qO- --timeout=5 http://host.microsandbox.internal:{port}/ ; echo status=$?"
        ))
        .await
        .expect("wget host");
    let stdout = out.stdout().unwrap();
    assert!(
        !stdout.trim_end().ends_with("status=0"),
        "host should be denied by group-host deny rule; got: {stdout:?}"
    );

    teardown(sb, name).await;
}

/// Guest ULA IPv6 address is present and in preferred state on boot.
///
/// Skipped when the host has no IPv6 route — the runtime does not provision
/// a guest IPv6 address on IPv4-only hosts.
#[msb_test]
async fn guest_ipv6_address_is_preferred() {
    use std::net::{Ipv6Addr, UdpSocket};

    let host_has_ipv6 = UdpSocket::bind((Ipv6Addr::UNSPECIFIED, 0))
        .and_then(|s| s.connect((Ipv6Addr::new(0x2001, 0x0db8, 0, 0, 0, 0, 0, 1), 443)))
        .is_ok();
    if !host_has_ipv6 {
        eprintln!("skip: host has no IPv6 route — guest IPv6 would not be provisioned");
        return;
    }

    let name = "host-ipv6-addr";
    let sb = spawn_sandbox(name, Some(NetworkPolicy::allow_all())).await;

    let out = sb.shell("ip -6 addr show dev eth0").await.expect("ip addr");
    let stdout = out.stdout().unwrap_or_default();
    teardown(sb, name).await;

    let ula_line = stdout
        .lines()
        .find(|l| l.contains("fd42:"))
        .unwrap_or_else(|| panic!("expected ULA IPv6 address (fd42:…) on eth0; got:\n{stdout}"));

    assert!(
        !ula_line.contains("tentative"),
        "ULA IPv6 address is still tentative — DAD did not complete; got:\n{ula_line}"
    );
}