mirror of
https://github.com/wirenboard/agent-vm.git
synced 2026-07-09 16:00:54 +00:00
run: --allow-egress <IP|CIDR> + --allow-lan to open egress per-launch
Two new CLI flags to punch holes through the default `public_only`
policy without forcing users to rebuild a full NetworkPolicy:
--allow-egress 10.100.1.75
--allow-egress 10.100.1.0/24 # CIDR
--allow-egress fd00::1/128 # IPv6
--allow-egress [::1] # bare IP gets a /32 or /128
--allow-lan # opens the entire Private group
Repeatable; per-IP and group flags compose. Loopback, link-local,
and metadata groups stay denied even under --allow-lan — Private
is intentionally disjoint from those.
5 unit tests for the parser (bare v4, v4 CIDR, bare v6, v6 CIDR,
rejects garbage). Live e2e:
baseline (no flag):
169.254.169.254:80 ECONNREFUSED
10.100.1.75:80 ECONNREFUSED
--allow-egress 169.254.169.254 --allow-lan:
169.254.169.254:80 OPEN
10.100.1.75:80 OPEN
192.168.1.1:80 OPEN
127.0.0.1:80 ECONNREFUSED (loopback group not opened)
--allow-egress 10.100.1.0/24:
10.100.1.75:80 OPEN
10.100.2.1:80 ECONNREFUSED (out of CIDR)
Vendor bump (f230c29) carries the matching NetworkBuilder helpers
and the new microsandbox::microsandbox_network re-export so we can
name DestinationGroup without a direct path dep.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5a731501d5
commit
c60b76597f
4 changed files with 119 additions and 2 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -28,6 +28,7 @@ dependencies = [
|
|||
"clap",
|
||||
"console",
|
||||
"indicatif",
|
||||
"ipnetwork",
|
||||
"libc",
|
||||
"microsandbox",
|
||||
"microsandbox-network",
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ indicatif = "0.18"
|
|||
console = "0.16"
|
||||
reqwest = { version = "0.13", default-features = false, features = ["rustls", "json"] }
|
||||
libc = "0.2"
|
||||
ipnetwork = "0.21"
|
||||
|
||||
# Integration tests that drive the actual secret-substitution layer
|
||||
# end-to-end with the hook's anonymisation logic. Kept out of release
|
||||
|
|
|
|||
|
|
@ -162,6 +162,35 @@ pub struct Args {
|
|||
#[arg(long = "auto-publish", default_value_t = false)]
|
||||
auto_publish: bool,
|
||||
|
||||
/// Punch a hole through the default egress policy for one IP
|
||||
/// or CIDR. Repeatable. Examples:
|
||||
/// `--allow-egress 10.100.1.75` (single host)
|
||||
/// `--allow-egress 10.100.1.0/24` (CIDR)
|
||||
/// `--allow-egress fd00::1/128` (IPv6)
|
||||
///
|
||||
/// The default policy (`NetworkPolicy::public_only`) only
|
||||
/// allows DNS and the `Public` destination group, so RFC1918
|
||||
/// (10/8, 172.16/12, 192.168/16, 100.64/10), loopback, link-
|
||||
/// local, and metadata addresses are all denied with
|
||||
/// ECONNREFUSED. Use this flag to reach a specific dev box on
|
||||
/// the same LAN as the host. Use `--allow-lan` instead if you
|
||||
/// want to open the entire Private group at once.
|
||||
#[arg(long = "allow-egress")]
|
||||
allow_egress: Vec<String>,
|
||||
|
||||
/// Switch the egress policy from `public_only` to `non_local`
|
||||
/// — adds the entire `DestinationGroup::Private` (10/8,
|
||||
/// 172.16/12, 192.168/16, 100.64/10, fc00::/7) to the allow
|
||||
/// list. Coarser than `--allow-egress <CIDR>`; useful for
|
||||
/// "trust everything on my LAN". Loopback, link-local, and
|
||||
/// metadata are still denied.
|
||||
///
|
||||
/// Security note: a compromised in-guest process gets full
|
||||
/// access to every device on your LAN with this flag. Prefer
|
||||
/// `--allow-egress <CIDR>` for production-ish uses.
|
||||
#[arg(long = "allow-lan", default_value_t = false)]
|
||||
allow_lan: bool,
|
||||
|
||||
/// Override the OCI image reference. Default:
|
||||
/// `ghcr.io/wirenboard/agent-vm-template:latest`. Use a timestamped tag
|
||||
/// (`...:YYYY-MM-DDTHH`) to pin a reproducible image.
|
||||
|
|
@ -475,6 +504,20 @@ pub async fn launch(agent: Agent, args: Args) -> Result<i32> {
|
|||
);
|
||||
}
|
||||
|
||||
// Parse --allow-egress entries into IpNetwork values. Same
|
||||
// up-front-bail rule as --publish: syntax errors fail before
|
||||
// we boot the sandbox.
|
||||
let allow_egress_cidrs =
|
||||
parse_allow_egress(&args.allow_egress).context("parsing --allow-egress")?;
|
||||
for cidr in &allow_egress_cidrs {
|
||||
eprintln!("==> Egress policy: allowing {cidr}");
|
||||
}
|
||||
if args.allow_lan {
|
||||
eprintln!(
|
||||
"==> Egress policy: --allow-lan enabled (Private RFC1918 + 100.64/10 + fc00::/7 reachable)"
|
||||
);
|
||||
}
|
||||
|
||||
// For each provider with a host credential file, register a
|
||||
// SecretValue::File secret keyed on the placeholder string the
|
||||
// guest will send, then register the OAuth refresh endpoint as a
|
||||
|
|
@ -490,7 +533,9 @@ pub async fn launch(agent: Agent, args: Args) -> Result<i32> {
|
|||
|| creds.openai_token_file.is_some()
|
||||
|| creds.gh_token_file.is_some();
|
||||
let auto_publish = args.auto_publish;
|
||||
if has_creds || !publish_ports.is_empty() || auto_publish {
|
||||
let allow_lan = args.allow_lan;
|
||||
let has_egress_overrides = !allow_egress_cidrs.is_empty() || allow_lan;
|
||||
if has_creds || !publish_ports.is_empty() || auto_publish || has_egress_overrides {
|
||||
use crate::secrets::*;
|
||||
let anthropic = creds.anthropic_token_file.clone();
|
||||
let openai = creds.openai_token_file.clone();
|
||||
|
|
@ -501,6 +546,7 @@ pub async fn launch(agent: Agent, args: Args) -> Result<i32> {
|
|||
let self_path = std::env::current_exe().context("std::env::current_exe")?;
|
||||
let state_dir = session.state_dir.clone();
|
||||
let publish_ports_for_net = publish_ports.clone();
|
||||
let allow_egress_for_net = allow_egress_cidrs.clone();
|
||||
builder = builder.network(move |mut n| {
|
||||
n = n.tls(|t| t);
|
||||
for p in &publish_ports_for_net {
|
||||
|
|
@ -513,6 +559,13 @@ pub async fn launch(agent: Agent, args: Args) -> Result<i32> {
|
|||
if auto_publish {
|
||||
n = n.auto_publish();
|
||||
}
|
||||
if allow_lan {
|
||||
use microsandbox::microsandbox_network::policy::DestinationGroup;
|
||||
n = n.allow_egress_group(DestinationGroup::Private);
|
||||
}
|
||||
for cidr in &allow_egress_for_net {
|
||||
n = n.allow_egress_cidr(*cidr);
|
||||
}
|
||||
if !has_creds {
|
||||
return n;
|
||||
}
|
||||
|
|
@ -1203,6 +1256,30 @@ fn parse_port(entry: &str, field: &str, s: &str) -> Result<u16> {
|
|||
.with_context(|| format!("--publish {entry:?}: {field} {s:?} is not a u16"))
|
||||
}
|
||||
|
||||
/// Parse `--allow-egress` entries. Each entry is an IP literal or
|
||||
/// a CIDR (e.g. `10.100.1.75` or `10.100.1.0/24`). A bare IP is
|
||||
/// expanded to a /32 (v4) or /128 (v6) CIDR — that matches the
|
||||
/// shape the policy builder's `Destination::Cidr` expects.
|
||||
fn parse_allow_egress(raw: &[String]) -> Result<Vec<ipnetwork::IpNetwork>> {
|
||||
let mut out = Vec::with_capacity(raw.len());
|
||||
for entry in raw {
|
||||
// Try CIDR first (foo/N); fall back to bare IP.
|
||||
let cidr = if entry.contains('/') {
|
||||
entry
|
||||
.parse::<ipnetwork::IpNetwork>()
|
||||
.with_context(|| format!("--allow-egress {entry:?}: not a valid CIDR"))?
|
||||
} else {
|
||||
let ip: std::net::IpAddr = entry.parse().with_context(|| {
|
||||
format!("--allow-egress {entry:?}: not an IP address or CIDR")
|
||||
})?;
|
||||
// /32 for v4, /128 for v6 — single-host rule.
|
||||
ipnetwork::IpNetwork::from(ip)
|
||||
};
|
||||
out.push(cidr);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Parse the raw `--mount` argv strings into `(host, guest)` pairs.
|
||||
/// `HOST` alone defaults `GUEST` to the same absolute path (mirror).
|
||||
/// `HOST:GUEST` lets you remap. Errors clearly on absolute-path
|
||||
|
|
@ -1680,6 +1757,44 @@ mod tests {
|
|||
assert!(parse_publish_args(&["[::1:8080:80".into()]).is_err());
|
||||
}
|
||||
|
||||
// ── parse_allow_egress ───────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn parse_allow_egress_accepts_bare_ipv4() {
|
||||
let r = parse_allow_egress(&["10.100.1.75".into()]).expect("ok");
|
||||
assert_eq!(r.len(), 1);
|
||||
// Bare IPv4 → /32 single-host CIDR.
|
||||
assert_eq!(r[0].prefix(), 32);
|
||||
assert_eq!(r[0].network().to_string(), "10.100.1.75");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_allow_egress_accepts_ipv4_cidr() {
|
||||
let r = parse_allow_egress(&["10.100.1.0/24".into()]).expect("ok");
|
||||
assert_eq!(r.len(), 1);
|
||||
assert_eq!(r[0].prefix(), 24);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_allow_egress_accepts_ipv6_cidr() {
|
||||
let r = parse_allow_egress(&["fd00::/64".into()]).expect("ok");
|
||||
assert_eq!(r.len(), 1);
|
||||
assert_eq!(r[0].prefix(), 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_allow_egress_accepts_bare_ipv6() {
|
||||
let r = parse_allow_egress(&["fd00::1".into()]).expect("ok");
|
||||
assert_eq!(r[0].prefix(), 128);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_allow_egress_rejects_garbage() {
|
||||
assert!(parse_allow_egress(&["not-an-ip".into()]).is_err());
|
||||
assert!(parse_allow_egress(&["10.0.0.1/99".into()]).is_err());
|
||||
assert!(parse_allow_egress(&["".into()]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_publish_args_rejects_bad_input() {
|
||||
assert!(parse_publish_args(&["80".into()]).is_err());
|
||||
|
|
|
|||
2
vendor/microsandbox
vendored
2
vendor/microsandbox
vendored
|
|
@ -1 +1 @@
|
|||
Subproject commit 29ab515e2bf27261f45a1215e9a6a78a25c69610
|
||||
Subproject commit f230c29505a17a3599a65e19a3b01804e25bff14
|
||||
Loading…
Add table
Add a link
Reference in a new issue