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
//! The namespace helper: the one place a confined child's loopback-only
//! network is actually built.
//!
//! # Order, and why it is the whole design
//!
//! Three things must happen and only one order works.
//!
//! 1. Create the namespaces and raise loopback. This needs `unshare`, which
//! the sandbox's syscall filter does not admit, so it must happen before
//! any confinement is installed.
//! 2. Enter the confinement handed over from the parent.
//! 3. Exec the payload.
//!
//! Doing (2) before (1) is what the obvious implementation does and it cannot
//! work: the filter is a default-deny allowlist carrying no namespace calls,
//! so the helper dies at `unshare` with a bare permission error. Doing (3)
//! before (2) runs the payload unconfined while every layer above reports the
//! profile as enforced, which is the silent failure this handover exists to
//! end. So the confinement travels as data, and this process applies it in
//! the window between the namespace existing and the payload starting.
//!
//! # What the namespace buys
//!
//! A private network namespace with only loopback raised is the only
//! mechanism that gives a build tool what it asks for without leaving a hole.
//! Admitting the IP socket family in the filter alone lets a child complete an
//! outbound connection to a public address. Pairing that with the kernel's
//! network access rights at zero permitted ports denies streams in both
//! directions, but those rights scope by port and never by address, so
//! "loopback only" is not expressible, and they do not mediate datagrams at
//! all, so a packet still leaves the host. Inside a private namespace there is
//! no route off the host to deny in the first place.
use std::convert::Infallible;
use std::io;
#[cfg(target_os = "linux")]
use std::os::unix::process::CommandExt;
use std::process::Command;
use crate::cli::NetnsLaunchArgs;
/// Exec the payload inside a private network namespace, confined.
///
/// The success type is uninhabited on purpose: a launch that worked has
/// replaced this process, so there is no "finished successfully" state for a
/// caller to mishandle. Every return is a failure.
pub(crate) fn run(args: NetnsLaunchArgs) -> Result<Infallible, String> {
let (program, payload_args) = args
.payload
.split_first()
.ok_or_else(|| "namespace helper: no payload program".to_string())?;
run_impl(args.ruleset_fd, &args.seccomp_hex, program, payload_args)
}
#[cfg(target_os = "linux")]
fn run_impl(
ruleset_fd: Option<i32>,
seccomp_hex: &str,
program: &str,
payload_args: &[String],
) -> Result<Infallible, String> {
use harn_vm::process_sandbox::TransferableConfinement;
let seccomp = harn_vm::process_sandbox::decode_seccomp_hex(seccomp_hex)
.map_err(|error| format!("namespace helper: {error}"))?;
// Built before the namespace so the allocation it needs happens while
// allocation is still unambiguously safe, and so a malformed handover is
// refused before any host state has been touched.
let confinement = TransferableConfinement::from_parts(ruleset_fd, &seccomp)
.map_err(|error| format!("namespace helper: {error}"))?;
enter_private_network_namespace().map_err(|error| {
format!(
"namespace helper: could not build a private network namespace: {error}. On a host \
that restricts unprivileged namespaces this executable needs its own policy grant; \
the grant names this path and no other."
)
})?;
raise_loopback()
.map_err(|error| format!("namespace helper: could not raise loopback: {error}"))?;
let mut command = Command::new(program);
command.args(payload_args);
// The confinement is entered in the child of this fork, immediately before
// exec, exactly as the direct spawn path does it. Entering it here in the
// parent instead would work too, but it would also confine this process's
// own error reporting, so a failure to exec could no longer be explained.
//
// SAFETY: `pre_exec` may only call async-signal-safe functions. `enter`
// makes two raw Landlock syscalls and one seccomp syscall and performs no
// allocation, locking, or I/O.
unsafe {
command.pre_exec(move || confinement.enter());
}
Err(format!(
"namespace helper: could not exec {program}: {}",
command.exec()
))
}
/// Unshare into a new user and network namespace and map the current user to
/// root inside it.
///
/// The user namespace is what makes the network namespace available without
/// privilege, and the mapping is what makes the interface configurable from
/// inside. `setgroups` must be denied before the group map is written, which
/// the kernel requires and which also keeps the child from gaining any group
/// it did not already hold.
#[cfg(target_os = "linux")]
fn enter_private_network_namespace() -> io::Result<()> {
let uid = unsafe { libc::getuid() };
let gid = unsafe { libc::getgid() };
if unsafe { libc::unshare(libc::CLONE_NEWUSER | libc::CLONE_NEWNET) } != 0 {
return Err(io::Error::last_os_error());
}
std::fs::write("/proc/self/setgroups", "deny")?;
std::fs::write("/proc/self/uid_map", format!("0 {uid} 1\n"))?;
std::fs::write("/proc/self/gid_map", format!("0 {gid} 1\n"))?;
Ok(())
}
/// Bring `lo` up inside the namespace just created.
///
/// A fresh network namespace has a loopback interface that exists and is
/// down, so a daemon binding `127.0.0.1` fails with a message about an
/// unusable address rather than about a permission. Raising it is what turns
/// the namespace from a denial into the grant that was asked for.
#[cfg(target_os = "linux")]
fn raise_loopback() -> io::Result<()> {
const IFNAME: &[u8] = b"lo\0";
let socket = unsafe { libc::socket(libc::AF_INET, libc::SOCK_DGRAM, 0) };
if socket < 0 {
return Err(io::Error::last_os_error());
}
let mut request: libc::ifreq = unsafe { std::mem::zeroed() };
for (slot, byte) in request.ifr_name.iter_mut().zip(IFNAME.iter()) {
*slot = *byte as libc::c_char;
}
let result = unsafe {
if libc::ioctl(socket, libc::SIOCGIFFLAGS, &raw mut request) < 0 {
Err(io::Error::last_os_error())
} else {
request.ifr_ifru.ifru_flags |= (libc::IFF_UP | libc::IFF_RUNNING) as libc::c_short;
if libc::ioctl(socket, libc::SIOCSIFFLAGS, &raw const request) < 0 {
Err(io::Error::last_os_error())
} else {
Ok(())
}
}
};
unsafe {
libc::close(socket);
}
result
}
/// Every other platform refuses rather than pretending.
///
/// The helper is only ever invoked by the Linux backend, so reaching this is a
/// wiring mistake, and saying so plainly beats a silent unconfined exec.
#[cfg(not(target_os = "linux"))]
fn run_impl(
_ruleset_fd: Option<i32>,
_seccomp_hex: &str,
_program: &str,
_payload_args: &[String],
) -> Result<Infallible, String> {
let _ = (io::ErrorKind::Unsupported, Command::new("true"));
Err("namespace helper: private network namespaces are a Linux mechanism".to_string())
}