microsandbox-rb 0.15.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 +197 -0
- data/Cargo.lock +219 -171
- data/DESIGN.md +1 -1
- data/README.md +83 -4
- data/ext/microsandbox/Cargo.toml +5 -5
- data/ext/microsandbox/src/error.rs +12 -0
- data/ext/microsandbox/src/sandbox.rs +264 -16
- data/lib/microsandbox/errors.rb +7 -0
- data/lib/microsandbox/outbound_proxy.rb +332 -0
- data/lib/microsandbox/sandbox.rb +316 -3
- data/lib/microsandbox/version.rb +2 -2
- data/lib/microsandbox.rb +1 -0
- data/sig/microsandbox.rbs +56 -1
- metadata +2 -1
|
@@ -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
|