microsandbox-rb 0.16.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +98 -0
- data/Cargo.lock +194 -158
- data/DESIGN.md +1 -1
- data/README.md +46 -3
- data/ext/microsandbox/Cargo.toml +4 -4
- data/ext/microsandbox/src/error.rs +7 -0
- data/ext/microsandbox/src/sandbox.rs +82 -0
- data/lib/microsandbox/outbound_proxy.rb +332 -0
- data/lib/microsandbox/sandbox.rb +20 -1
- data/lib/microsandbox/version.rb +2 -2
- data/lib/microsandbox.rb +1 -0
- data/sig/microsandbox.rbs +40 -1
- metadata +2 -1
data/DESIGN.md
CHANGED
|
@@ -184,7 +184,7 @@ extension.
|
|
|
184
184
|
|
|
185
185
|
`ext/microsandbox/Cargo.toml` depends on the core crate via a **pinned git tag**
|
|
186
186
|
(`microsandbox` / `microsandbox-network`, pinned to the same tag as
|
|
187
|
-
`Microsandbox::RUNTIME_VERSION` — currently `v0.6.
|
|
187
|
+
`Microsandbox::RUNTIME_VERSION` — currently `v0.6.18`), so the gem builds anywhere
|
|
188
188
|
— CI, `rake-compiler-dock` release containers, and end-user source installs —
|
|
189
189
|
without an adjacent checkout. For fast local development against a sibling
|
|
190
190
|
microsandbox checkout, copy `.cargo/config.toml.example` to `.cargo/config.toml`
|
data/README.md
CHANGED
|
@@ -245,6 +245,47 @@ Microsandbox::Sandbox.create(
|
|
|
245
245
|
end
|
|
246
246
|
```
|
|
247
247
|
|
|
248
|
+
**Outbound proxy** (runtime `v0.6.17`) — route the sandbox's egress through a
|
|
249
|
+
SOCKS4 (TCP) or SOCKS5 (TCP + non-DNS UDP) proxy with `proxy:`. The proxy is
|
|
250
|
+
dialed by the runtime's host-side network stack, so its address is resolved
|
|
251
|
+
from the host (`127.0.0.1` is the host's loopback), and the egress policy
|
|
252
|
+
(`network:`) still governs which destinations may be reached. A SOCKS5 password
|
|
253
|
+
comes from a host environment variable via `SecretSource.env` — only the
|
|
254
|
+
variable's *name* is handed to the runtime. Local backend only.
|
|
255
|
+
|
|
256
|
+
```ruby
|
|
257
|
+
Microsandbox::Sandbox.create("worker", image: "python",
|
|
258
|
+
proxy: Microsandbox::OutboundProxy.socks5("127.0.0.1:1080"))
|
|
259
|
+
|
|
260
|
+
Microsandbox::Sandbox.create("worker", image: "python",
|
|
261
|
+
proxy: Microsandbox::OutboundProxy.socks5("10.0.0.5:1080")
|
|
262
|
+
.credentials("sandbox", Microsandbox::SecretSource.env("PROXY_PASSWORD")))
|
|
263
|
+
|
|
264
|
+
Microsandbox::Sandbox.create("worker", image: "python",
|
|
265
|
+
proxy: Microsandbox::OutboundProxy.socks4("127.0.0.1:1080", user_id: "ci"))
|
|
266
|
+
|
|
267
|
+
# The equivalent plain Hash works too:
|
|
268
|
+
Microsandbox::Sandbox.create("worker", image: "python",
|
|
269
|
+
proxy: { protocol: :socks5, address: "10.0.0.5:1080",
|
|
270
|
+
credentials: { username: "sandbox", password: { env: "PROXY_PASSWORD" } } })
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
**Strict hostname policy** (runtime `v0.6.18`) — `strict: true` makes a
|
|
274
|
+
hostname-rule allow fail closed unless the runtime can actually see the request
|
|
275
|
+
authority (plain-HTTP `Host`, or SNI/`:authority` under TLS interception); a
|
|
276
|
+
bypassed or non-intercepted HTTPS flow allowed only by a hostname rule is then
|
|
277
|
+
denied before the upstream dial. Default `false`; create-only. Independently of
|
|
278
|
+
`strict:`, any policy with domain rules now checks plain-HTTP `Host` headers
|
|
279
|
+
against it.
|
|
280
|
+
|
|
281
|
+
```ruby
|
|
282
|
+
Microsandbox::Sandbox.create("worker", image: "python",
|
|
283
|
+
network: Microsandbox::NetworkPolicy.custom(default_egress: :deny,
|
|
284
|
+
rules: [{ action: :allow, direction: :egress, protocol: :tcp, port: 443,
|
|
285
|
+
destination: Microsandbox::Destination.domain("api.example.com") }]),
|
|
286
|
+
strict: true)
|
|
287
|
+
```
|
|
288
|
+
|
|
248
289
|
### Executing commands
|
|
249
290
|
|
|
250
291
|
```ruby
|
|
@@ -559,8 +600,8 @@ change diverged the two numbers — the gem version is **not** a reliable indica
|
|
|
559
600
|
of the embedded runtime version. To learn which runtime a build wraps, ask it:
|
|
560
601
|
|
|
561
602
|
```ruby
|
|
562
|
-
Microsandbox::VERSION # => "0.
|
|
563
|
-
Microsandbox.runtime_version # => "v0.6.
|
|
603
|
+
Microsandbox::VERSION # => "0.17.0" (the gem's own version)
|
|
604
|
+
Microsandbox.runtime_version # => "v0.6.18" (the embedded upstream runtime tag)
|
|
564
605
|
```
|
|
565
606
|
|
|
566
607
|
The companion [`microsandbox-rb-binaries`](#the-runtime-binaries) gem is
|
|
@@ -594,6 +635,7 @@ stale.
|
|
|
594
635
|
| `0.14.0` | `v0.6.9` | two-gem split: SDK-only gem (no build-time runtime download) + companion `microsandbox-rb-binaries` platform gems |
|
|
595
636
|
| `0.15.0` | `v0.6.14` | adopts upstream `v0.6.10`–`v0.6.14` step by step: bind-mount correctness, guest bootstrap off the kernel command line, DNS pins for deferred domain allows, Linux glibc 2.28 baseline for the prebuilt runtime, legacy ext4 upper-disk resize, `msb_krun` 0.1.32. Parity: `ssh.open_client`/`prepare_server` accept `inactivity_timeout:` (seconds; `0` disables, `nil` inherits the 600s global default) |
|
|
596
637
|
| `0.16.0` | `v0.6.16` | adopts upstream `v0.6.15`+`v0.6.16` step by step: mount fallback ownership, readonly-mount write-probe fix, log retrieval rerouted through the SDK backends, config overlaid by field presence, network-slot recycling. Parity: per-mount `uid:`/`gid:`, and the convergent lifecycle — `Sandbox.connect_or_create`, `#id`, `#wait_for_status`, `#restart`, `#destroy`, `SandboxHandle#connect_or_start`, `SandboxReplacedError` |
|
|
638
|
+
| `0.17.0` | `v0.6.18` | adopts upstream `v0.6.17`+`v0.6.18` step by step: outbound SOCKS4/SOCKS5 proxies (SOCKS5 UDP + credentials), migration-order fix for databases last opened by a `v0.6.15` `msb`, security hardening — plain-HTTP `Host`/`:authority` now checked against domain-rule policies even in non-strict mode, plus fail-closed strict hostname mode. Parity: `proxy:` (`Microsandbox::OutboundProxy` / `SecretSource`), `strict:` |
|
|
597
639
|
|
|
598
640
|
**Going forward** — the gem version moves on its own semver track and no longer
|
|
599
641
|
mirrors the upstream tag:
|
|
@@ -708,7 +750,8 @@ image-pull progress (`Sandbox.create_with_progress` → `PullSession`),
|
|
|
708
750
|
(`root_disk:` managed/tmpfs/disk via `Microsandbox::RootDisk`), **network
|
|
709
751
|
configuration** (composable profiles, custom per-rule
|
|
710
752
|
`Microsandbox::NetworkPolicy`/`Rule`/`Destination`, plus DNS, TLS interception,
|
|
711
|
-
IPv4/IPv6 pools, `max_connections`, `trust_host_cas`
|
|
753
|
+
IPv4/IPv6 pools, `max_connections`, `trust_host_cas`, `strict` hostname mode,
|
|
754
|
+
outbound SOCKS4/SOCKS5 `proxy:`), **secrets** (multi-host /
|
|
712
755
|
wildcard allow-lists, injection toggles, per-secret + sandbox-level violation
|
|
713
756
|
policy), **SSH** (`Sandbox#ssh` → `SshClient`/`SftpClient`/`SshServer`), and the
|
|
714
757
|
**raw agent client** (`Microsandbox::AgentClient`). Create options span
|
data/ext/microsandbox/Cargo.toml
CHANGED
|
@@ -7,7 +7,7 @@ description = "Ruby SDK native extension for microsandbox — secure, fast micro
|
|
|
7
7
|
# Must equal Microsandbox::VERSION (lib/microsandbox/version.rb) — Native.version
|
|
8
8
|
# returns this via env!("CARGO_PKG_VERSION") and version_spec.rb asserts equality.
|
|
9
9
|
# The core-crate dependency below stays pinned at its own tag (v0.6.16).
|
|
10
|
-
version = "0.
|
|
10
|
+
version = "0.17.0"
|
|
11
11
|
authors = ["Super Rad Company <development@superrad.company>"]
|
|
12
12
|
repository = "https://github.com/superradcompany/microsandbox"
|
|
13
13
|
license = "Apache-2.0"
|
|
@@ -42,8 +42,8 @@ rb-sys = "0.9"
|
|
|
42
42
|
# first-use download into ~/.microsandbox (`setup::install`, which is not
|
|
43
43
|
# gated on "prebuilt"). "net"/"ssh"/"keyring" are the features the official
|
|
44
44
|
# SDKs ship with.
|
|
45
|
-
microsandbox = { git = "https://github.com/superradcompany/microsandbox", tag = "v0.6.
|
|
46
|
-
microsandbox-network = { git = "https://github.com/superradcompany/microsandbox", tag = "v0.6.
|
|
45
|
+
microsandbox = { git = "https://github.com/superradcompany/microsandbox", tag = "v0.6.18", default-features = false, features = ["keyring", "net", "ssh"] }
|
|
46
|
+
microsandbox-network = { git = "https://github.com/superradcompany/microsandbox", tag = "v0.6.18" }
|
|
47
47
|
# The *guest* agent (`agentd`, PID 1 inside every microVM) is embedded into the
|
|
48
48
|
# extension by microsandbox-filesystem's build.rs, which without its "prebuilt"
|
|
49
49
|
# feature demands a locally built `build/agentd` (workspace-only, `just
|
|
@@ -52,7 +52,7 @@ microsandbox-network = { git = "https://github.com/superradcompany/microsandbox"
|
|
|
52
52
|
# prebuilt agentd for the target arch is fetched (same behaviour as before)
|
|
53
53
|
# while the SDK-level msb download above stays off. Feature unification means
|
|
54
54
|
# this adds no new crates; keep it on the same tag as the deps above.
|
|
55
|
-
microsandbox-runtime = { git = "https://github.com/superradcompany/microsandbox", tag = "v0.6.
|
|
55
|
+
microsandbox-runtime = { git = "https://github.com/superradcompany/microsandbox", tag = "v0.6.18", default-features = false, features = ["prebuilt"] }
|
|
56
56
|
|
|
57
57
|
# Async core bridged to Ruby's synchronous API via a blocking tokio runtime.
|
|
58
58
|
tokio = { version = "1", features = ["rt-multi-thread", "sync", "time"] }
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
use magnus::{value::ReprValue, Error, ExceptionClass, Module, RClass, RModule, Ruby};
|
|
9
9
|
use microsandbox::{AgentClientError, MicrosandboxError, Operation, UnsupportedReason};
|
|
10
|
+
use microsandbox_network::policy::BuildError;
|
|
10
11
|
|
|
11
12
|
/// The Ruby class (relative to the `Microsandbox` module) for a core error.
|
|
12
13
|
/// `"Error"` is the base class; anything else is a named subclass.
|
|
@@ -60,6 +61,12 @@ fn class_name(err: &MicrosandboxError) -> &'static str {
|
|
|
60
61
|
// migration (run at backend connect / artifact open) failed and needs
|
|
61
62
|
// repair. Mirrors the Python `SnapshotMigrationError`.
|
|
62
63
|
SnapshotMigration { .. } => "SnapshotMigrationError",
|
|
64
|
+
// v0.6.17: a malformed `proxy:` (unparseable `IP:port`, invalid SOCKS4
|
|
65
|
+
// user ID, ...) also arrives as a `NetworkBuilder` error, but it is a
|
|
66
|
+
// *configuration* mistake, not a policy one — route it to
|
|
67
|
+
// `InvalidConfigError` like every other bad create option. Matched on
|
|
68
|
+
// the variant, never on the message text.
|
|
69
|
+
NetworkBuilder(BuildError::InvalidOutboundProxy { .. }) => "InvalidConfigError",
|
|
63
70
|
// Give the already-defined-but-orphaned `NetworkPolicyError` a mapping:
|
|
64
71
|
// a builder parse/validation error from `network(|n| ...)`. The gem
|
|
65
72
|
// unconditionally enables the core's `net` feature (default-features),
|
|
@@ -390,12 +390,22 @@ impl Sandbox {
|
|
|
390
390
|
.transpose()?;
|
|
391
391
|
let max_connections = conv::opt::<usize>(opts, "max_connections")?;
|
|
392
392
|
let trust_host_cas = conv::opt::<bool>(opts, "trust_host_cas")?;
|
|
393
|
+
// strict: fail-closed hostname-policy enforcement (v0.6.18). A
|
|
394
|
+
// hostname-rule allow must be backed by an inspectable request
|
|
395
|
+
// authority (plain-HTTP Host, or SNI/authority under TLS interception);
|
|
396
|
+
// otherwise the flow is denied before the upstream dial. Mirrors the
|
|
397
|
+
// Python SDK's `Network(strict=...)`. Create-only here to match the
|
|
398
|
+
// Python SDK's `modify` surface, which does not expose `strict`; the
|
|
399
|
+
// core's generated network config patch (ConfigPatch derive) does
|
|
400
|
+
// carry a `strict` field, so this is SDK parity, not a core limit.
|
|
401
|
+
let strict = conv::opt::<bool>(opts, "strict")?;
|
|
393
402
|
if dns.is_some()
|
|
394
403
|
|| tls.is_some()
|
|
395
404
|
|| ipv4_pool.is_some()
|
|
396
405
|
|| ipv6_pool.is_some()
|
|
397
406
|
|| max_connections.is_some()
|
|
398
407
|
|| trust_host_cas.is_some()
|
|
408
|
+
|| strict.is_some()
|
|
399
409
|
{
|
|
400
410
|
b = b.network(move |mut n| {
|
|
401
411
|
if let Some(dns) = dns {
|
|
@@ -450,6 +460,9 @@ impl Sandbox {
|
|
|
450
460
|
if let Some(t) = trust_host_cas {
|
|
451
461
|
n = n.trust_host_cas(t);
|
|
452
462
|
}
|
|
463
|
+
if let Some(s) = strict {
|
|
464
|
+
n = n.strict(s);
|
|
465
|
+
}
|
|
453
466
|
n
|
|
454
467
|
});
|
|
455
468
|
}
|
|
@@ -475,6 +488,18 @@ impl Sandbox {
|
|
|
475
488
|
})
|
|
476
489
|
});
|
|
477
490
|
}
|
|
491
|
+
// proxy: outbound SOCKS4/SOCKS5 proxy for egress traffic (v0.6.17,
|
|
492
|
+
// upstream #1234/#1507). The Ruby layer (`OutboundProxy.coerce`) has
|
|
493
|
+
// already validated the shape and normalized it to the Python SDK's
|
|
494
|
+
// `_to_dict()` wire form: {protocol:, address:, user_id?:,
|
|
495
|
+
// credentials?: {username:, password: {kind: "env", var:}}}. Only the
|
|
496
|
+
// password's env var NAME travels; the core resolves it host-side.
|
|
497
|
+
// Applied exactly as sdk/python/src/helpers.rs does; the address is
|
|
498
|
+
// parsed by the core's proxy builder (an invalid one surfaces as
|
|
499
|
+
// NetworkBuilder(InvalidOutboundProxy) → InvalidConfigError, see error.rs).
|
|
500
|
+
if let Some(proxy) = conv::opt::<RHash>(opts, "proxy")? {
|
|
501
|
+
b = apply_outbound_proxy(b, proxy)?;
|
|
502
|
+
}
|
|
478
503
|
// init: hand guest PID 1 to an init system. The Ruby layer normalizes
|
|
479
504
|
// `init:` to a Hash { cmd:, args?:, env?: }. `init_with` with empty
|
|
480
505
|
// args/env builds the same HandoffInit as the plain `init(cmd)`, so route
|
|
@@ -1420,6 +1445,63 @@ fn parse_dns(d: RHash) -> Result<DnsSpec, Error> {
|
|
|
1420
1445
|
})
|
|
1421
1446
|
}
|
|
1422
1447
|
|
|
1448
|
+
/// Apply the normalized `proxy` create option (v0.6.17) to the builder.
|
|
1449
|
+
/// Mirrors `sdk/python/src/helpers.rs`: `socks4` takes an optional `user_id`,
|
|
1450
|
+
/// `socks5` optional `credentials` whose password is an env-backed
|
|
1451
|
+
/// [`SecretSource`] (the only kind). Unknown protocols and non-env password
|
|
1452
|
+
/// sources are rejected here (the Ruby layer already does, so these are
|
|
1453
|
+
/// belt-and-braces for callers of the native API).
|
|
1454
|
+
fn apply_outbound_proxy(b: SandboxBuilder, proxy: RHash) -> Result<SandboxBuilder, Error> {
|
|
1455
|
+
let protocol = conv::opt_string(proxy, "protocol")?
|
|
1456
|
+
.ok_or_else(|| error::base_error("proxy requires protocol:"))?;
|
|
1457
|
+
let address = conv::opt_string(proxy, "address")?
|
|
1458
|
+
.ok_or_else(|| error::base_error("proxy requires address:"))?;
|
|
1459
|
+
match protocol.as_str() {
|
|
1460
|
+
"socks4" => {
|
|
1461
|
+
let user_id = conv::opt_string(proxy, "user_id")?;
|
|
1462
|
+
Ok(b.proxy(move |p| {
|
|
1463
|
+
let p = p.socks4(address);
|
|
1464
|
+
match user_id {
|
|
1465
|
+
Some(user_id) => p.user_id(user_id),
|
|
1466
|
+
None => p,
|
|
1467
|
+
}
|
|
1468
|
+
}))
|
|
1469
|
+
}
|
|
1470
|
+
"socks5" => {
|
|
1471
|
+
let credentials = match conv::opt::<RHash>(proxy, "credentials")? {
|
|
1472
|
+
None => None,
|
|
1473
|
+
Some(c) => {
|
|
1474
|
+
let username = conv::opt_string(c, "username")?
|
|
1475
|
+
.ok_or_else(|| error::base_error("SOCKS5 credentials require username:"))?;
|
|
1476
|
+
let password = conv::opt::<RHash>(c, "password")?
|
|
1477
|
+
.ok_or_else(|| error::base_error("SOCKS5 credentials require password:"))?;
|
|
1478
|
+
let kind = conv::opt_string(password, "kind")?.ok_or_else(|| {
|
|
1479
|
+
error::base_error("SOCKS5 password source requires kind:")
|
|
1480
|
+
})?;
|
|
1481
|
+
if kind != "env" {
|
|
1482
|
+
return Err(error::base_error(format!(
|
|
1483
|
+
"unsupported SOCKS5 password source {kind:?}; only env is supported"
|
|
1484
|
+
)));
|
|
1485
|
+
}
|
|
1486
|
+
let var = conv::opt_string(password, "var")?
|
|
1487
|
+
.ok_or_else(|| error::base_error("SOCKS5 password source requires var:"))?;
|
|
1488
|
+
Some((username, SecretSource::env(var)))
|
|
1489
|
+
}
|
|
1490
|
+
};
|
|
1491
|
+
Ok(b.proxy(move |p| {
|
|
1492
|
+
let p = p.socks5(address);
|
|
1493
|
+
match credentials {
|
|
1494
|
+
Some((username, password)) => p.credentials(username, password),
|
|
1495
|
+
None => p,
|
|
1496
|
+
}
|
|
1497
|
+
}))
|
|
1498
|
+
}
|
|
1499
|
+
other => Err(error::base_error(format!(
|
|
1500
|
+
"unsupported outbound proxy protocol {other:?}; expected socks4 or socks5"
|
|
1501
|
+
))),
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1423
1505
|
/// One token bucket of the `rate_limiter` create option (v0.6.9):
|
|
1424
1506
|
/// `(size, refill_time_ms, one_time_burst)`. Size is bytes for bandwidth
|
|
1425
1507
|
/// buckets, frames for ops buckets.
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Microsandbox
|
|
4
|
+
# Host-side source for secret material (runtime v0.6.17). The only kind is
|
|
5
|
+
# `env`: the secret is read from an environment variable of the **host**
|
|
6
|
+
# process (the one running this gem) when the sandbox is created — the
|
|
7
|
+
# variable's *name* travels to the runtime, never its value.
|
|
8
|
+
#
|
|
9
|
+
# Used for the password of an authenticated SOCKS5 {OutboundProxy}. Mirrors
|
|
10
|
+
# the Python SDK's frozen `SecretSource` dataclass.
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# Microsandbox::SecretSource.env("PROXY_PASSWORD")
|
|
14
|
+
class SecretSource
|
|
15
|
+
KINDS = %w[env].freeze
|
|
16
|
+
|
|
17
|
+
# @return [String] the source kind (always `"env"`)
|
|
18
|
+
attr_reader :kind
|
|
19
|
+
# @return [String] the host environment variable name
|
|
20
|
+
attr_reader :var
|
|
21
|
+
|
|
22
|
+
# Resolve the secret from a host environment variable.
|
|
23
|
+
# @param variable [String, Symbol] the variable name (non-empty)
|
|
24
|
+
# @return [SecretSource]
|
|
25
|
+
def self.env(variable)
|
|
26
|
+
new("env", variable)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Coerce a user-facing value into a {SecretSource}: an instance passes
|
|
30
|
+
# through, a Hash must be `{ env: "VAR" }` or the wire form
|
|
31
|
+
# `{ kind: "env", var: "VAR" }`.
|
|
32
|
+
#
|
|
33
|
+
# A rejected value is NEVER rendered into the error: a caller who reaches
|
|
34
|
+
# for `{ value: "hunter2" }` or `{ store: ... }` by analogy with other
|
|
35
|
+
# secret APIs has just handed us a real password, and the exception
|
|
36
|
+
# message is the one place it must not resurface (logs, bug reports).
|
|
37
|
+
# Errors describe the expected shape and, at most, the offending class.
|
|
38
|
+
# @api private
|
|
39
|
+
def self.coerce(value, context = "password")
|
|
40
|
+
case value
|
|
41
|
+
when SecretSource then value
|
|
42
|
+
when Hash
|
|
43
|
+
env = fetch_key(value, :env)
|
|
44
|
+
return new("env", env) unless env.nil?
|
|
45
|
+
|
|
46
|
+
kind = fetch_key(value, :kind)
|
|
47
|
+
var = fetch_key(value, :var)
|
|
48
|
+
if kind.nil? && var.nil?
|
|
49
|
+
raise ArgumentError,
|
|
50
|
+
"#{context}: expects { env: \"VAR\" } naming a host environment variable " \
|
|
51
|
+
"(got a Hash without env:; plaintext password values are not accepted)"
|
|
52
|
+
end
|
|
53
|
+
new(kind, var)
|
|
54
|
+
else
|
|
55
|
+
raise ArgumentError,
|
|
56
|
+
"#{context}: expects a Microsandbox::SecretSource (SecretSource.env(\"VAR\")) " \
|
|
57
|
+
"or a Hash { env: \"VAR\" } (got #{value.class}; plaintext password values " \
|
|
58
|
+
"are not accepted)"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# @api private
|
|
63
|
+
def self.fetch_key(hash, key)
|
|
64
|
+
hash.key?(key) ? hash[key] : hash[key.to_s]
|
|
65
|
+
end
|
|
66
|
+
private_class_method :fetch_key
|
|
67
|
+
|
|
68
|
+
# @api private — use {.env}.
|
|
69
|
+
#
|
|
70
|
+
# Retained Strings are private frozen copies: the caller's originals are
|
|
71
|
+
# neither frozen nor aliased, so mutating them later (or a String obtained
|
|
72
|
+
# from {#var}/{#to_h}) cannot change this object's state. Only a
|
|
73
|
+
# String/Symbol kind or var is described in an error, and only by the
|
|
74
|
+
# allowed-shape wording — a nested Hash/other object is named by class so
|
|
75
|
+
# a misplaced secret is never rendered.
|
|
76
|
+
def initialize(kind, var)
|
|
77
|
+
unless kind.is_a?(String) || kind.is_a?(Symbol)
|
|
78
|
+
raise ArgumentError,
|
|
79
|
+
"secret source kind must be \"env\" (got #{kind.class})"
|
|
80
|
+
end
|
|
81
|
+
# An unsupported kind is reported by class only, never by value: a
|
|
82
|
+
# caller who mis-keys a password into `kind:` must not see it echoed.
|
|
83
|
+
unless KINDS.include?(kind.to_s)
|
|
84
|
+
raise ArgumentError,
|
|
85
|
+
"secret source kind must be \"env\" (only environment-backed secret sources " \
|
|
86
|
+
"are supported; got an unsupported #{kind.class})"
|
|
87
|
+
end
|
|
88
|
+
kind = kind.to_s
|
|
89
|
+
unless var.is_a?(String) || var.is_a?(Symbol)
|
|
90
|
+
raise ArgumentError,
|
|
91
|
+
"secret source environment variable must be a String name (got #{var.class})"
|
|
92
|
+
end
|
|
93
|
+
var = var.to_s
|
|
94
|
+
raise ArgumentError, "secret source environment variable must not be empty" if var.empty?
|
|
95
|
+
@kind = kind.dup.freeze
|
|
96
|
+
@var = var.dup.freeze
|
|
97
|
+
freeze
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Wire form for the native layer (Python's `_to_dict`).
|
|
101
|
+
# @return [Hash{String => String}]
|
|
102
|
+
def to_h
|
|
103
|
+
{"kind" => @kind, "var" => @var}
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def ==(other)
|
|
107
|
+
other.is_a?(SecretSource) && other.kind == kind && other.var == var
|
|
108
|
+
end
|
|
109
|
+
alias_method :eql?, :==
|
|
110
|
+
|
|
111
|
+
def hash
|
|
112
|
+
[SecretSource, @kind, @var].hash
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def inspect
|
|
116
|
+
"#<Microsandbox::SecretSource env=#{@var}>"
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# An outbound proxy for a sandbox's egress traffic (runtime v0.6.17, upstream
|
|
121
|
+
# #1234 / #1507), passed to {Sandbox.create} via `proxy:`. The runtime's
|
|
122
|
+
# host-side network stack dials the proxy — so the address is resolved from
|
|
123
|
+
# the **host**, and `127.0.0.1` names the host's loopback, not the guest's.
|
|
124
|
+
#
|
|
125
|
+
# - {socks4} — SOCKS4 for TCP, with an optional `user_id:` sent in the
|
|
126
|
+
# handshake.
|
|
127
|
+
# - {socks5} — SOCKS5 for TCP and non-DNS UDP; chain {#credentials} for
|
|
128
|
+
# username/password authentication, the password coming from a host
|
|
129
|
+
# {SecretSource} (only its variable name reaches the runtime).
|
|
130
|
+
#
|
|
131
|
+
# The proxy applies uniformly to TLS-intercepted and bypassed/plain TCP; the
|
|
132
|
+
# egress policy (`network:`) still decides which destinations may be reached.
|
|
133
|
+
# Not accepted by the cloud backend (`UnsupportedError`).
|
|
134
|
+
#
|
|
135
|
+
# Instances are immutable: {#credentials} returns a new proxy. `proxy:` also
|
|
136
|
+
# accepts the equivalent plain Hash (see {.coerce}).
|
|
137
|
+
#
|
|
138
|
+
# @example
|
|
139
|
+
# Sandbox.create("worker", image: "python",
|
|
140
|
+
# proxy: Microsandbox::OutboundProxy.socks5("127.0.0.1:1080"))
|
|
141
|
+
# Sandbox.create("worker", image: "python",
|
|
142
|
+
# proxy: Microsandbox::OutboundProxy.socks5("10.0.0.5:1080")
|
|
143
|
+
# .credentials("sandbox", Microsandbox::SecretSource.env("PROXY_PASSWORD")))
|
|
144
|
+
# Sandbox.create("worker", image: "python",
|
|
145
|
+
# proxy: { protocol: :socks4, address: "127.0.0.1:1080", user_id: "ci" })
|
|
146
|
+
#
|
|
147
|
+
# Mirrors the Python SDK's frozen `OutboundProxy` dataclass (`socks4` /
|
|
148
|
+
# `socks5` / `credentials`).
|
|
149
|
+
class OutboundProxy
|
|
150
|
+
PROTOCOLS = %w[socks4 socks5].freeze
|
|
151
|
+
|
|
152
|
+
# @return [String] `"socks4"` or `"socks5"`
|
|
153
|
+
attr_reader :protocol
|
|
154
|
+
# @return [String] the proxy's `IP:port` address, as seen from the host
|
|
155
|
+
attr_reader :address
|
|
156
|
+
# @return [String, nil] SOCKS4 user ID
|
|
157
|
+
attr_reader :user_id
|
|
158
|
+
# @return [String, nil] SOCKS5 username
|
|
159
|
+
attr_reader :username
|
|
160
|
+
# @return [SecretSource, nil] SOCKS5 password source
|
|
161
|
+
attr_reader :password
|
|
162
|
+
|
|
163
|
+
# A SOCKS4 outbound proxy.
|
|
164
|
+
# @param address [String] `IP:port` of the proxy, resolved from the host
|
|
165
|
+
# @param user_id [String, nil] optional user ID sent in the SOCKS4 handshake
|
|
166
|
+
# @return [OutboundProxy]
|
|
167
|
+
def self.socks4(address, user_id: nil)
|
|
168
|
+
new(protocol: "socks4", address: address, user_id: user_id)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# A SOCKS5 outbound proxy (unauthenticated; chain {#credentials}).
|
|
172
|
+
# @param address [String] `IP:port` of the proxy, resolved from the host
|
|
173
|
+
# @return [OutboundProxy]
|
|
174
|
+
def self.socks5(address)
|
|
175
|
+
new(protocol: "socks5", address: address)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Coerce a user-facing `proxy:` value into the normalized wire Hash:
|
|
179
|
+
# an {OutboundProxy}, or a Hash `{ protocol: :socks4|:socks5, address:,
|
|
180
|
+
# user_id:, credentials: { username:, password: SecretSource | { env: } } }`.
|
|
181
|
+
# @api private
|
|
182
|
+
# @return [Hash{String => untyped}]
|
|
183
|
+
def self.coerce(value)
|
|
184
|
+
case value
|
|
185
|
+
# No re-validation for an existing instance, and none is needed: the
|
|
186
|
+
# constructor is the only writer, the object is frozen (no ivar can be
|
|
187
|
+
# reassigned), and every retained String is a private frozen copy, so
|
|
188
|
+
# {#to_h} is a pure function of already-validated state. Re-running the
|
|
189
|
+
# checks would only re-examine data the constructor itself produced.
|
|
190
|
+
when OutboundProxy then value.to_h
|
|
191
|
+
when Hash then from_hash(value).to_h
|
|
192
|
+
else
|
|
193
|
+
raise ArgumentError,
|
|
194
|
+
"proxy: expects a Microsandbox::OutboundProxy (OutboundProxy.socks4/socks5) " \
|
|
195
|
+
"or a Hash { protocol:, address:, ... } (got #{value.class})"
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# @api private
|
|
200
|
+
def self.from_hash(hash)
|
|
201
|
+
protocol = fetch_key(hash, :protocol)
|
|
202
|
+
raise ArgumentError, "proxy: requires protocol: (:socks4 or :socks5)" if protocol.nil?
|
|
203
|
+
address = fetch_key(hash, :address)
|
|
204
|
+
raise ArgumentError, "proxy: requires address:" if address.nil?
|
|
205
|
+
user_id = fetch_key(hash, :user_id)
|
|
206
|
+
credentials = fetch_key(hash, :credentials)
|
|
207
|
+
username = nil
|
|
208
|
+
password = nil
|
|
209
|
+
unless credentials.nil?
|
|
210
|
+
unless credentials.is_a?(Hash)
|
|
211
|
+
raise ArgumentError,
|
|
212
|
+
"proxy credentials: must be a Hash { username:, password: } (got #{credentials.class})"
|
|
213
|
+
end
|
|
214
|
+
username = fetch_key(credentials, :username)
|
|
215
|
+
password = fetch_key(credentials, :password)
|
|
216
|
+
if username.nil? || password.nil?
|
|
217
|
+
raise ArgumentError, "proxy credentials: requires both username: and password:"
|
|
218
|
+
end
|
|
219
|
+
password = SecretSource.coerce(password, "proxy credentials password")
|
|
220
|
+
end
|
|
221
|
+
new(protocol: protocol, address: address, user_id: user_id,
|
|
222
|
+
username: username, password: password)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# @api private
|
|
226
|
+
def self.fetch_key(hash, key)
|
|
227
|
+
hash.key?(key) ? hash[key] : hash[key.to_s]
|
|
228
|
+
end
|
|
229
|
+
private_class_method :fetch_key
|
|
230
|
+
|
|
231
|
+
# @api private — use {.socks4} / {.socks5}.
|
|
232
|
+
def initialize(protocol:, address:, user_id: nil, username: nil, password: nil)
|
|
233
|
+
unless protocol.is_a?(String) || protocol.is_a?(Symbol)
|
|
234
|
+
raise ArgumentError,
|
|
235
|
+
"unsupported outbound proxy protocol (got #{protocol.class}; expected :socks4 or :socks5)"
|
|
236
|
+
end
|
|
237
|
+
protocol = protocol.to_s.downcase
|
|
238
|
+
unless PROTOCOLS.include?(protocol)
|
|
239
|
+
raise ArgumentError,
|
|
240
|
+
"unsupported outbound proxy protocol (expected :socks4 or :socks5)"
|
|
241
|
+
end
|
|
242
|
+
unless address.is_a?(String)
|
|
243
|
+
raise ArgumentError,
|
|
244
|
+
"proxy address must be a non-empty \"IP:port\" String (got #{address.class})"
|
|
245
|
+
end
|
|
246
|
+
if address.empty?
|
|
247
|
+
raise ArgumentError, "proxy address must be a non-empty \"IP:port\" String (got \"\")"
|
|
248
|
+
end
|
|
249
|
+
# Same rules as the Python SDK's OutboundProxy.__post_init__; the address
|
|
250
|
+
# itself is parsed by the core (which reports e.g. "invalid SOCKS5 proxy
|
|
251
|
+
# address" as an InvalidConfigError at create time).
|
|
252
|
+
# Identifiers are documented as Strings (RBS: String?). A misplaced
|
|
253
|
+
# container (a credentials Hash, an Array) must not be stringified into
|
|
254
|
+
# the wire hash / #inspect; reject it by class, never rendering it.
|
|
255
|
+
unless user_id.nil? || user_id.is_a?(String) || user_id.is_a?(Symbol)
|
|
256
|
+
raise ArgumentError, "SOCKS4 user_id must be a String (got #{user_id.class})"
|
|
257
|
+
end
|
|
258
|
+
unless username.nil? || username.is_a?(String) || username.is_a?(Symbol)
|
|
259
|
+
raise ArgumentError, "SOCKS5 username must be a String (got #{username.class})"
|
|
260
|
+
end
|
|
261
|
+
if protocol != "socks4" && !user_id.nil?
|
|
262
|
+
raise ArgumentError, "user_id is only supported for SOCKS4 proxies"
|
|
263
|
+
end
|
|
264
|
+
if protocol != "socks5" && !(username.nil? && password.nil?)
|
|
265
|
+
raise ArgumentError, "credentials are only supported for SOCKS5 proxies"
|
|
266
|
+
end
|
|
267
|
+
if username.nil? != password.nil?
|
|
268
|
+
raise ArgumentError, "SOCKS5 username and password must be provided together"
|
|
269
|
+
end
|
|
270
|
+
unless password.nil? || password.is_a?(SecretSource)
|
|
271
|
+
raise ArgumentError,
|
|
272
|
+
"SOCKS5 password must be a Microsandbox::SecretSource (SecretSource.env(\"VAR\")), " \
|
|
273
|
+
"got #{password.class}"
|
|
274
|
+
end
|
|
275
|
+
# Private frozen copies (see SecretSource#initialize): the caller keeps
|
|
276
|
+
# its own, unfrozen Strings; readers and {#to_h} hand out these frozen
|
|
277
|
+
# ones, so neither side can mutate stored state after construction.
|
|
278
|
+
@protocol = protocol.dup.freeze
|
|
279
|
+
@address = address.dup.freeze
|
|
280
|
+
@user_id = user_id&.to_s&.dup&.freeze
|
|
281
|
+
@username = username&.to_s&.dup&.freeze
|
|
282
|
+
@password = password
|
|
283
|
+
freeze
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Username/password authentication for a SOCKS5 proxy. Returns a **new**
|
|
287
|
+
# proxy; the receiver is unchanged.
|
|
288
|
+
# @param username [String]
|
|
289
|
+
# @param password [SecretSource] host-side password source
|
|
290
|
+
# ({SecretSource.env}); its variable *name* is what reaches the runtime
|
|
291
|
+
# @return [OutboundProxy]
|
|
292
|
+
# @raise [ArgumentError] on a SOCKS4 proxy
|
|
293
|
+
def credentials(username, password)
|
|
294
|
+
raise ArgumentError, "credentials are only supported for SOCKS5 proxies" unless socks5?
|
|
295
|
+
self.class.new(protocol: @protocol, address: @address,
|
|
296
|
+
username: username, password: SecretSource.coerce(password, "proxy credentials password"))
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def socks4? = @protocol == "socks4"
|
|
300
|
+
|
|
301
|
+
def socks5? = @protocol == "socks5"
|
|
302
|
+
|
|
303
|
+
# Wire form for the native layer (Python's `_to_dict`): `user_id` only when
|
|
304
|
+
# set, `credentials` only when both halves are set.
|
|
305
|
+
# @return [Hash{String => untyped}]
|
|
306
|
+
def to_h
|
|
307
|
+
h = {"protocol" => @protocol, "address" => @address}
|
|
308
|
+
h["user_id"] = @user_id unless @user_id.nil?
|
|
309
|
+
if @username && @password
|
|
310
|
+
h["credentials"] = {"username" => @username, "password" => @password.to_h}
|
|
311
|
+
end
|
|
312
|
+
h
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def ==(other)
|
|
316
|
+
other.is_a?(OutboundProxy) && other.to_h == to_h
|
|
317
|
+
end
|
|
318
|
+
alias_method :eql?, :==
|
|
319
|
+
|
|
320
|
+
def hash
|
|
321
|
+
[OutboundProxy, to_h].hash
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# Never renders a password value — there is none; only the env var name.
|
|
325
|
+
def inspect
|
|
326
|
+
parts = ["#{@protocol} #{@address}"]
|
|
327
|
+
parts << "user_id=#{@user_id}" if @user_id
|
|
328
|
+
parts << "username=#{@username} password=env:#{@password.var}" if @username
|
|
329
|
+
"#<Microsandbox::OutboundProxy #{parts.join(" ")}>"
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
end
|
data/lib/microsandbox/sandbox.rb
CHANGED
|
@@ -408,12 +408,28 @@ module Microsandbox
|
|
|
408
408
|
# @param ipv6_pool [String, nil] guest IPv6 address pool CIDR
|
|
409
409
|
# @param max_connections [Integer, nil] cap on concurrent proxied connections
|
|
410
410
|
# @param trust_host_cas [Boolean, nil] trust the host's CA bundle for upstream TLS
|
|
411
|
+
# @param strict [Boolean, nil] strict hostname-policy mode (runtime
|
|
412
|
+
# v0.6.18, default false): a hostname-rule allow must be backed by an
|
|
413
|
+
# inspectable request authority — plain-HTTP `Host`, or SNI/authority
|
|
414
|
+
# under TLS interception. Without that visibility (e.g. bypassed or
|
|
415
|
+
# non-intercepted HTTPS) a flow allowed only by a hostname rule is
|
|
416
|
+
# denied before the upstream dial. Create-only; `modify` cannot change it.
|
|
411
417
|
# @param rate_limiter [Hash, nil] per-sandbox egress/ingress token-bucket
|
|
412
418
|
# limits (runtime v0.6.9, local backend):
|
|
413
419
|
# `{ egress: { bandwidth: { size: 1_048_576, refill_time_ms: 1000,
|
|
414
420
|
# one_time_burst: 0 }, ops: { size: 1000, refill_time_ms: 1000 } },
|
|
415
421
|
# ingress: { ... } }`. `bandwidth` buckets meter bytes, `ops` buckets
|
|
416
422
|
# meter network frames; an omitted bucket or direction is unlimited.
|
|
423
|
+
# @param proxy [OutboundProxy, Hash, nil] outbound SOCKS proxy for the
|
|
424
|
+
# sandbox's egress traffic (runtime v0.6.17, local backend):
|
|
425
|
+
# {OutboundProxy.socks4}(address, user_id:) or {OutboundProxy.socks5}
|
|
426
|
+
# (address), optionally `.credentials(username, SecretSource.env("VAR"))`
|
|
427
|
+
# — or the equivalent Hash `{ protocol: :socks5, address: "IP:port",
|
|
428
|
+
# credentials: { username:, password: { env: "VAR" } } }`. The address is
|
|
429
|
+
# dialed from the host; only the password's env var *name* is sent. The
|
|
430
|
+
# cloud backend rejects it with {UnsupportedError}. An unparseable
|
|
431
|
+
# address is rejected by the core at create time with
|
|
432
|
+
# {InvalidConfigError}, before any boot.
|
|
417
433
|
# @param vsock [Hash, Array, nil] host sockets exposed on guest-to-host
|
|
418
434
|
# vsock ports (runtime v0.6.9): `{ "/host/api.sock" => 5000 }` (stream
|
|
419
435
|
# sockets), or an Array of
|
|
@@ -576,7 +592,8 @@ module Microsandbox
|
|
|
576
592
|
shell: nil, user: nil, hostname: nil, labels: nil, scripts: nil,
|
|
577
593
|
entrypoint: nil, cmd: nil, ports: nil, ports_udp: nil, volumes: nil, network: nil,
|
|
578
594
|
dns: nil, tls: nil, ipv4_pool: nil, ipv6_pool: nil,
|
|
579
|
-
max_connections: nil, trust_host_cas: nil, rate_limiter: nil,
|
|
595
|
+
max_connections: nil, trust_host_cas: nil, strict: nil, rate_limiter: nil, proxy: nil,
|
|
596
|
+
vsock: nil,
|
|
580
597
|
patches: nil,
|
|
581
598
|
from_snapshot: nil, fstype: nil, init: nil, ephemeral: false,
|
|
582
599
|
log_level: nil, quiet_logs: false, security: nil,
|
|
@@ -637,7 +654,9 @@ module Microsandbox
|
|
|
637
654
|
opts["ipv6_pool"] = ipv6_pool.to_s if ipv6_pool
|
|
638
655
|
opts["max_connections"] = Integer(max_connections) if max_connections
|
|
639
656
|
set_bool(opts, "trust_host_cas", trust_host_cas)
|
|
657
|
+
set_bool(opts, "strict", strict)
|
|
640
658
|
opts["rate_limiter"] = normalize_rate_limiter(rate_limiter) if rate_limiter
|
|
659
|
+
opts["proxy"] = OutboundProxy.coerce(proxy) unless proxy.nil?
|
|
641
660
|
opts["vsock"] = normalize_vsock(vsock) if vsock
|
|
642
661
|
opts["log_level"] = log_level.to_s if log_level
|
|
643
662
|
opts["quiet_logs"] = true if quiet_logs
|
data/lib/microsandbox/version.rb
CHANGED
|
@@ -8,12 +8,12 @@ module Microsandbox
|
|
|
8
8
|
# Versioning section of the README for the full gem-to-runtime map. Must equal
|
|
9
9
|
# the native ext's Cargo crate version (`Native.version`), enforced by
|
|
10
10
|
# spec/unit/version_spec.rb.
|
|
11
|
-
VERSION = "0.
|
|
11
|
+
VERSION = "0.17.0"
|
|
12
12
|
|
|
13
13
|
# The upstream microsandbox runtime release this gem build embeds — the `tag`
|
|
14
14
|
# pinned on the `microsandbox`/`microsandbox-network` git deps in
|
|
15
15
|
# ext/microsandbox/Cargo.toml. Exposed at runtime as
|
|
16
16
|
# {Microsandbox.runtime_version}. spec/unit/version_spec.rb asserts it stays in
|
|
17
17
|
# sync with the Cargo tag so it can't silently drift out of date.
|
|
18
|
-
RUNTIME_VERSION = "v0.6.
|
|
18
|
+
RUNTIME_VERSION = "v0.6.18"
|
|
19
19
|
end
|
data/lib/microsandbox.rb
CHANGED
|
@@ -30,6 +30,7 @@ require_relative "microsandbox/snapshot"
|
|
|
30
30
|
require_relative "microsandbox/patch"
|
|
31
31
|
require_relative "microsandbox/root_disk"
|
|
32
32
|
require_relative "microsandbox/network"
|
|
33
|
+
require_relative "microsandbox/outbound_proxy"
|
|
33
34
|
require_relative "microsandbox/agent"
|
|
34
35
|
require_relative "microsandbox/ssh"
|
|
35
36
|
require_relative "microsandbox/modification"
|