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.
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.16`), so the gem builds anywhere
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.16.0" (the gem's own version)
563
- Microsandbox.runtime_version # => "v0.6.16" (the embedded upstream runtime tag)
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`), **secrets** (multi-host /
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
@@ -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.16.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.16", default-features = false, features = ["keyring", "net", "ssh"] }
46
- microsandbox-network = { git = "https://github.com/superradcompany/microsandbox", tag = "v0.6.16" }
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.16", default-features = false, features = ["prebuilt"] }
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
@@ -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, vsock: 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
@@ -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.16.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.16"
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"