microsandbox-rb 0.5.7 → 0.5.9
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 +75 -1
- data/Cargo.lock +1 -1
- data/DESIGN.md +46 -18
- data/README.md +103 -33
- data/ext/microsandbox/Cargo.toml +4 -1
- data/ext/microsandbox/extconf.rb +35 -0
- data/ext/microsandbox/src/agent.rs +166 -0
- data/ext/microsandbox/src/conv.rs +19 -1
- data/ext/microsandbox/src/lib.rs +4 -0
- data/ext/microsandbox/src/sandbox.rs +562 -3
- data/ext/microsandbox/src/ssh.rs +317 -0
- data/lib/microsandbox/agent.rb +181 -0
- data/lib/microsandbox/network.rb +300 -0
- data/lib/microsandbox/patch.rb +98 -0
- data/lib/microsandbox/sandbox.rb +119 -4
- data/lib/microsandbox/ssh.rb +247 -0
- data/lib/microsandbox/version.rb +5 -3
- data/lib/microsandbox.rb +47 -2
- data/sig/microsandbox.rbs +136 -1
- metadata +7 -1
|
@@ -15,11 +15,15 @@ use microsandbox::logs::{
|
|
|
15
15
|
LogCursor, LogEntry, LogOptions, LogSource, LogStreamOptions, LogStreamStart,
|
|
16
16
|
};
|
|
17
17
|
use microsandbox::sandbox::{
|
|
18
|
-
FsEntry, FsEntryKind, FsMetadata, PullPolicy, RlimitResource,
|
|
19
|
-
SandboxMetrics, SandboxStatus, SandboxStopResult,
|
|
18
|
+
AttachOptionsBuilder, FsEntry, FsEntryKind, FsMetadata, Patch, PullPolicy, RlimitResource,
|
|
19
|
+
SandboxFilter, SandboxHandle, SandboxMetrics, SandboxStatus, SandboxStopResult,
|
|
20
|
+
SecurityProfile,
|
|
20
21
|
};
|
|
21
22
|
use microsandbox::LogLevel;
|
|
22
|
-
use
|
|
23
|
+
use microsandbox::RegistryAuth;
|
|
24
|
+
use microsandbox_network::policy::{
|
|
25
|
+
Action, Destination, DestinationGroup, Direction, NetworkPolicy, PortRange, Protocol, Rule,
|
|
26
|
+
};
|
|
23
27
|
|
|
24
28
|
use crate::conv;
|
|
25
29
|
use crate::error;
|
|
@@ -107,6 +111,12 @@ impl Sandbox {
|
|
|
107
111
|
}
|
|
108
112
|
});
|
|
109
113
|
}
|
|
114
|
+
// patches: rootfs modifications applied before boot. The Ruby layer
|
|
115
|
+
// normalizes each `Microsandbox::Patch.*` into a string-keyed Hash with
|
|
116
|
+
// a `kind` discriminator; mirrors the Python binding's `apply_patch`.
|
|
117
|
+
for patch in parse_patches(opts)? {
|
|
118
|
+
b = b.add_patch(patch);
|
|
119
|
+
}
|
|
110
120
|
if let Some(net) = conv::opt_string(opts, "network")? {
|
|
111
121
|
match net.as_str() {
|
|
112
122
|
"none" | "disabled" | "disable" | "airgapped" => b = b.disable_network(),
|
|
@@ -122,6 +132,13 @@ impl Sandbox {
|
|
|
122
132
|
}
|
|
123
133
|
}
|
|
124
134
|
}
|
|
135
|
+
// Custom network policy: an ordered allow/deny rule list with per-direction
|
|
136
|
+
// defaults and bulk domain denials. The Ruby layer routes bare presets to
|
|
137
|
+
// the `network` key above and full policies here; mirrors the Python
|
|
138
|
+
// binding's `apply_network`.
|
|
139
|
+
if let Some(policy) = parse_network_policy(opts)? {
|
|
140
|
+
b = b.network(move |n| n.policy(policy));
|
|
141
|
+
}
|
|
125
142
|
if let Some(level) = conv::opt_string(opts, "log_level")? {
|
|
126
143
|
b = b.log_level(log_level_from_str(&level)?);
|
|
127
144
|
}
|
|
@@ -137,6 +154,14 @@ impl Sandbox {
|
|
|
137
154
|
if let Some(mib) = conv::opt_u32(opts, "oci_upper_size")? {
|
|
138
155
|
b = b.oci_upper_size(mib);
|
|
139
156
|
}
|
|
157
|
+
// Registry connection settings, for private / non-default registries:
|
|
158
|
+
// Basic auth (username + password/token), plain-HTTP `insecure`, and
|
|
159
|
+
// extra PEM CA roots. The Ruby layer flattens `registry_auth: {...}`
|
|
160
|
+
// into these keys; mirrors the Python `registry_auth=` and Node
|
|
161
|
+
// `.registry(r => r.auth(...))` surfaces.
|
|
162
|
+
if let Some(rc) = parse_registry_config(opts)? {
|
|
163
|
+
b = b.registry(move |r| rc.apply(r));
|
|
164
|
+
}
|
|
140
165
|
if let Some(secs) = conv::opt::<u64>(opts, "max_duration")? {
|
|
141
166
|
b = b.max_duration(secs);
|
|
142
167
|
}
|
|
@@ -425,6 +450,120 @@ impl Sandbox {
|
|
|
425
450
|
let fs = self.inner.fs();
|
|
426
451
|
block_on(fs.copy_to_host(&guest_path, &host_path)).map_err(error::to_ruby)
|
|
427
452
|
}
|
|
453
|
+
|
|
454
|
+
//----------------------------------------------------------------------
|
|
455
|
+
// SSH (mirror SandboxSshOps)
|
|
456
|
+
//----------------------------------------------------------------------
|
|
457
|
+
|
|
458
|
+
/// Open a native in-process SSH client to this sandbox. `opts`: user, term,
|
|
459
|
+
/// sftp (bool, default true).
|
|
460
|
+
fn ssh_open_client(&self, opts: RHash) -> Result<crate::ssh::SshClient, Error> {
|
|
461
|
+
let user = conv::opt_string(opts, "user")?;
|
|
462
|
+
let term = conv::opt_string(opts, "term")?;
|
|
463
|
+
let sftp = conv::opt::<bool>(opts, "sftp")?.unwrap_or(true);
|
|
464
|
+
let ssh = self.inner.ssh();
|
|
465
|
+
let client = block_on(ssh.open_client_with(move |mut b| {
|
|
466
|
+
if let Some(u) = user {
|
|
467
|
+
b = b.user(u);
|
|
468
|
+
}
|
|
469
|
+
if let Some(t) = term {
|
|
470
|
+
b = b.term(t);
|
|
471
|
+
}
|
|
472
|
+
b.sftp(sftp)
|
|
473
|
+
}))
|
|
474
|
+
.map_err(error::to_ruby)?;
|
|
475
|
+
Ok(crate::ssh::SshClient::from_core(client))
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/// Prepare a reusable SSH server endpoint. `opts`: host_key_path,
|
|
479
|
+
/// authorized_keys_path, user, sftp (bool, default true).
|
|
480
|
+
fn ssh_prepare_server(&self, opts: RHash) -> Result<crate::ssh::SshServer, Error> {
|
|
481
|
+
let host_key_path = conv::opt_string(opts, "host_key_path")?;
|
|
482
|
+
let authorized_keys_path = conv::opt_string(opts, "authorized_keys_path")?;
|
|
483
|
+
let user = conv::opt_string(opts, "user")?;
|
|
484
|
+
let sftp = conv::opt::<bool>(opts, "sftp")?.unwrap_or(true);
|
|
485
|
+
let ssh = self.inner.ssh();
|
|
486
|
+
let server = block_on(ssh.prepare_server_with(move |mut b| {
|
|
487
|
+
if let Some(p) = host_key_path {
|
|
488
|
+
b = b.host_key_path(p);
|
|
489
|
+
}
|
|
490
|
+
if let Some(p) = authorized_keys_path {
|
|
491
|
+
b = b.authorized_keys_path(p);
|
|
492
|
+
}
|
|
493
|
+
if let Some(u) = user {
|
|
494
|
+
b = b.user(u);
|
|
495
|
+
}
|
|
496
|
+
b.sftp(sftp)
|
|
497
|
+
}))
|
|
498
|
+
.map_err(error::to_ruby)?;
|
|
499
|
+
Ok(crate::ssh::SshServer::from_core(server))
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
//----------------------------------------------------------------------
|
|
503
|
+
// Interactive attach (host-TTY coupled)
|
|
504
|
+
//----------------------------------------------------------------------
|
|
505
|
+
|
|
506
|
+
/// Attach an interactive terminal to a command in the sandbox; returns its
|
|
507
|
+
/// exit code. Puts the host terminal in raw mode (requires a real tty) and
|
|
508
|
+
/// blocks until the command exits or the detach sequence is typed. `opts`:
|
|
509
|
+
/// cwd, user, env, detach_keys, rlimits.
|
|
510
|
+
fn attach(&self, cmd: String, args: Vec<String>, opts: RHash) -> Result<i32, Error> {
|
|
511
|
+
let parsed = AttachOpts::parse(args, opts)?;
|
|
512
|
+
block_on(self.inner.attach_with(cmd, move |b| parsed.apply(b))).map_err(error::to_ruby)
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/// Attach an interactive terminal running the sandbox's default shell.
|
|
516
|
+
fn attach_shell(&self) -> Result<i32, Error> {
|
|
517
|
+
block_on(self.inner.attach_shell()).map_err(error::to_ruby)
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
//--------------------------------------------------------------------------------------------------
|
|
522
|
+
// Attach option parsing
|
|
523
|
+
//--------------------------------------------------------------------------------------------------
|
|
524
|
+
|
|
525
|
+
struct AttachOpts {
|
|
526
|
+
args: Vec<String>,
|
|
527
|
+
cwd: Option<String>,
|
|
528
|
+
user: Option<String>,
|
|
529
|
+
env: Vec<(String, String)>,
|
|
530
|
+
detach_keys: Option<String>,
|
|
531
|
+
rlimits: Vec<(RlimitResource, u64, u64)>,
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
impl AttachOpts {
|
|
535
|
+
fn parse(args: Vec<String>, opts: RHash) -> Result<Self, Error> {
|
|
536
|
+
Ok(Self {
|
|
537
|
+
args,
|
|
538
|
+
cwd: conv::opt_string(opts, "cwd")?,
|
|
539
|
+
user: conv::opt_string(opts, "user")?,
|
|
540
|
+
env: conv::opt_string_map(opts, "env")?,
|
|
541
|
+
detach_keys: conv::opt_string(opts, "detach_keys")?,
|
|
542
|
+
rlimits: parse_rlimits(opts)?,
|
|
543
|
+
})
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
fn apply(self, mut b: AttachOptionsBuilder) -> AttachOptionsBuilder {
|
|
547
|
+
if !self.args.is_empty() {
|
|
548
|
+
b = b.args(self.args);
|
|
549
|
+
}
|
|
550
|
+
if let Some(cwd) = self.cwd {
|
|
551
|
+
b = b.cwd(cwd);
|
|
552
|
+
}
|
|
553
|
+
if let Some(user) = self.user {
|
|
554
|
+
b = b.user(user);
|
|
555
|
+
}
|
|
556
|
+
for (k, v) in self.env {
|
|
557
|
+
b = b.env(k, v);
|
|
558
|
+
}
|
|
559
|
+
if let Some(keys) = self.detach_keys {
|
|
560
|
+
b = b.detach_keys(keys);
|
|
561
|
+
}
|
|
562
|
+
for (resource, soft, hard) in self.rlimits {
|
|
563
|
+
b = b.rlimit_range(resource, soft, hard);
|
|
564
|
+
}
|
|
565
|
+
b
|
|
566
|
+
}
|
|
428
567
|
}
|
|
429
568
|
|
|
430
569
|
//--------------------------------------------------------------------------------------------------
|
|
@@ -502,6 +641,417 @@ fn parse_rlimits(opts: RHash) -> Result<Vec<(RlimitResource, u64, u64)>, Error>
|
|
|
502
641
|
Ok(out)
|
|
503
642
|
}
|
|
504
643
|
|
|
644
|
+
//--------------------------------------------------------------------------------------------------
|
|
645
|
+
// Patch parsing (mirrors the Python binding's `apply_patch`)
|
|
646
|
+
//--------------------------------------------------------------------------------------------------
|
|
647
|
+
|
|
648
|
+
/// Read a required string field from a per-patch Hash.
|
|
649
|
+
fn patch_str(h: RHash, key: &str) -> Result<String, Error> {
|
|
650
|
+
conv::opt_string(h, key)?
|
|
651
|
+
.ok_or_else(|| error::base_error(format!("patch is missing required key :{key}")))
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/// Read a required field as raw bytes (for the binary `file` patch content).
|
|
655
|
+
fn patch_bytes(h: RHash, key: &str) -> Result<Vec<u8>, Error> {
|
|
656
|
+
let s = conv::opt::<RString>(h, key)?
|
|
657
|
+
.ok_or_else(|| error::base_error(format!("patch is missing required key :{key}")))?;
|
|
658
|
+
// Copy out while the GVL is held; the buffer is consumed synchronously here.
|
|
659
|
+
Ok(unsafe { s.as_slice() }.to_vec())
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/// Parse the `patches` option into core `Patch` operations. The Ruby layer
|
|
663
|
+
/// normalizes each `Microsandbox::Patch.*` into a string-keyed Hash carrying a
|
|
664
|
+
/// `kind` discriminator plus the variant-specific fields.
|
|
665
|
+
fn parse_patches(opts: RHash) -> Result<Vec<Patch>, Error> {
|
|
666
|
+
let mut out = Vec::new();
|
|
667
|
+
for h in conv::opt_hash_vec(opts, "patches")? {
|
|
668
|
+
let kind = patch_str(h, "kind")?;
|
|
669
|
+
let mode = conv::opt_u32(h, "mode")?;
|
|
670
|
+
let replace = conv::opt_bool(h, "replace")?;
|
|
671
|
+
let patch = match kind.as_str() {
|
|
672
|
+
"text" => Patch::Text {
|
|
673
|
+
path: patch_str(h, "path")?,
|
|
674
|
+
content: patch_str(h, "content")?,
|
|
675
|
+
mode,
|
|
676
|
+
replace,
|
|
677
|
+
},
|
|
678
|
+
"file" => Patch::File {
|
|
679
|
+
path: patch_str(h, "path")?,
|
|
680
|
+
content: patch_bytes(h, "content")?,
|
|
681
|
+
mode,
|
|
682
|
+
replace,
|
|
683
|
+
},
|
|
684
|
+
"append" => Patch::Append {
|
|
685
|
+
path: patch_str(h, "path")?,
|
|
686
|
+
content: patch_str(h, "content")?,
|
|
687
|
+
},
|
|
688
|
+
"copy_file" => Patch::CopyFile {
|
|
689
|
+
src: patch_str(h, "src")?.into(),
|
|
690
|
+
dst: patch_str(h, "dst")?,
|
|
691
|
+
mode,
|
|
692
|
+
replace,
|
|
693
|
+
},
|
|
694
|
+
"copy_dir" => Patch::CopyDir {
|
|
695
|
+
src: patch_str(h, "src")?.into(),
|
|
696
|
+
dst: patch_str(h, "dst")?,
|
|
697
|
+
replace,
|
|
698
|
+
},
|
|
699
|
+
"symlink" => Patch::Symlink {
|
|
700
|
+
target: patch_str(h, "target")?,
|
|
701
|
+
link: patch_str(h, "link")?,
|
|
702
|
+
replace,
|
|
703
|
+
},
|
|
704
|
+
"mkdir" => Patch::Mkdir {
|
|
705
|
+
path: patch_str(h, "path")?,
|
|
706
|
+
mode,
|
|
707
|
+
},
|
|
708
|
+
"remove" => Patch::Remove {
|
|
709
|
+
path: patch_str(h, "path")?,
|
|
710
|
+
},
|
|
711
|
+
other => {
|
|
712
|
+
return Err(error::base_error(format!(
|
|
713
|
+
"unknown patch kind {other:?} (expected one of \
|
|
714
|
+
text/file/append/copy_file/copy_dir/symlink/mkdir/remove)"
|
|
715
|
+
)))
|
|
716
|
+
}
|
|
717
|
+
};
|
|
718
|
+
out.push(patch);
|
|
719
|
+
}
|
|
720
|
+
Ok(out)
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
//--------------------------------------------------------------------------------------------------
|
|
724
|
+
// Network policy parsing (mirrors the Python binding's `apply_network`)
|
|
725
|
+
//--------------------------------------------------------------------------------------------------
|
|
726
|
+
|
|
727
|
+
/// Parse the `network_policy` option (a Hash normalized by the Ruby layer) into
|
|
728
|
+
/// a core `NetworkPolicy`. Returns `None` when the option is absent (bare
|
|
729
|
+
/// presets travel via the separate `network` key handled in `create`).
|
|
730
|
+
///
|
|
731
|
+
/// Composition (mirrors the Go SDK's `NetworkConfig`): bulk domain-deny rules
|
|
732
|
+
/// come first (so they outrank later allow rules), then a preset's rules (if a
|
|
733
|
+
/// preset base is given), then the caller's explicit `rules`. Per-direction
|
|
734
|
+
/// defaults come from the explicit `default_egress`/`default_ingress` when set,
|
|
735
|
+
/// else the preset's defaults, else the asymmetric default (deny egress / allow
|
|
736
|
+
/// ingress).
|
|
737
|
+
fn parse_network_policy(opts: RHash) -> Result<Option<NetworkPolicy>, Error> {
|
|
738
|
+
let Some(np) = conv::opt::<RHash>(opts, "network_policy")? else {
|
|
739
|
+
return Ok(None);
|
|
740
|
+
};
|
|
741
|
+
|
|
742
|
+
// Bulk domain denials → prepended deny-egress rules.
|
|
743
|
+
let mut rules: Vec<Rule> = Vec::new();
|
|
744
|
+
for d in conv::opt_string_vec(np, "deny_domains")? {
|
|
745
|
+
let domain = d
|
|
746
|
+
.parse()
|
|
747
|
+
.map_err(|e| error::base_error(format!("deny_domains {d:?}: {e}")))?;
|
|
748
|
+
rules.push(Rule::deny_egress(Destination::Domain(domain)));
|
|
749
|
+
}
|
|
750
|
+
for s in conv::opt_string_vec(np, "deny_domain_suffixes")? {
|
|
751
|
+
let suffix = s
|
|
752
|
+
.parse()
|
|
753
|
+
.map_err(|e| error::base_error(format!("deny_domain_suffixes {s:?}: {e}")))?;
|
|
754
|
+
rules.push(Rule::deny_egress(Destination::DomainSuffix(suffix)));
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// Optional preset base (its rules and defaults seed the policy).
|
|
758
|
+
let (preset_egress, preset_ingress) = match conv::opt_string(np, "preset")? {
|
|
759
|
+
Some(p) => {
|
|
760
|
+
let mut base = network_preset(&p)?;
|
|
761
|
+
rules.append(&mut base.rules);
|
|
762
|
+
(Some(base.default_egress), Some(base.default_ingress))
|
|
763
|
+
}
|
|
764
|
+
None => (None, None),
|
|
765
|
+
};
|
|
766
|
+
|
|
767
|
+
// Caller's explicit rules come after preset rules.
|
|
768
|
+
for rd in conv::opt_hash_vec(np, "rules")? {
|
|
769
|
+
rules.push(parse_rule(rd)?);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
let default_egress = match conv::opt_string(np, "default_egress")? {
|
|
773
|
+
Some(s) => action_from_str(&s)?,
|
|
774
|
+
None => preset_egress.unwrap_or(Action::Deny),
|
|
775
|
+
};
|
|
776
|
+
let default_ingress = match conv::opt_string(np, "default_ingress")? {
|
|
777
|
+
Some(s) => action_from_str(&s)?,
|
|
778
|
+
None => preset_ingress.unwrap_or(Action::Allow),
|
|
779
|
+
};
|
|
780
|
+
|
|
781
|
+
Ok(Some(NetworkPolicy {
|
|
782
|
+
default_egress,
|
|
783
|
+
default_ingress,
|
|
784
|
+
rules,
|
|
785
|
+
}))
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
fn network_preset(p: &str) -> Result<NetworkPolicy, Error> {
|
|
789
|
+
Ok(match p {
|
|
790
|
+
"none" | "disabled" | "disable" | "airgapped" => NetworkPolicy::none(),
|
|
791
|
+
"public" | "public_only" | "public-only" | "default" => NetworkPolicy::public_only(),
|
|
792
|
+
"all" | "allow_all" | "allow-all" => NetworkPolicy::allow_all(),
|
|
793
|
+
"non_local" | "non-local" | "nonlocal" => NetworkPolicy::non_local(),
|
|
794
|
+
other => {
|
|
795
|
+
return Err(error::base_error(format!(
|
|
796
|
+
"unknown network preset {other:?} (expected one of \
|
|
797
|
+
public_only/none/allow_all/non_local)"
|
|
798
|
+
)))
|
|
799
|
+
}
|
|
800
|
+
})
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
fn action_from_str(s: &str) -> Result<Action, Error> {
|
|
804
|
+
match s {
|
|
805
|
+
"allow" => Ok(Action::Allow),
|
|
806
|
+
"deny" => Ok(Action::Deny),
|
|
807
|
+
other => Err(error::base_error(format!(
|
|
808
|
+
"unknown network action {other:?} (expected allow/deny)"
|
|
809
|
+
))),
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
fn direction_from_str(s: &str) -> Result<Direction, Error> {
|
|
814
|
+
match s {
|
|
815
|
+
"egress" => Ok(Direction::Egress),
|
|
816
|
+
"ingress" => Ok(Direction::Ingress),
|
|
817
|
+
"any" => Ok(Direction::Any),
|
|
818
|
+
other => Err(error::base_error(format!(
|
|
819
|
+
"unknown rule direction {other:?} (expected egress/ingress/any)"
|
|
820
|
+
))),
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
fn protocol_from_str(s: &str) -> Result<Protocol, Error> {
|
|
825
|
+
match s {
|
|
826
|
+
"tcp" => Ok(Protocol::Tcp),
|
|
827
|
+
"udp" => Ok(Protocol::Udp),
|
|
828
|
+
"icmpv4" => Ok(Protocol::Icmpv4),
|
|
829
|
+
"icmpv6" => Ok(Protocol::Icmpv6),
|
|
830
|
+
other => Err(error::base_error(format!(
|
|
831
|
+
"unknown protocol {other:?} (expected tcp/udp/icmpv4/icmpv6)"
|
|
832
|
+
))),
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/// Parse a single rule Hash into a core `Rule`.
|
|
837
|
+
fn parse_rule(rd: RHash) -> Result<Rule, Error> {
|
|
838
|
+
let action = action_from_str(&patch_str(rd, "action")?)?;
|
|
839
|
+
let direction = match conv::opt_string(rd, "direction")? {
|
|
840
|
+
Some(s) => direction_from_str(&s)?,
|
|
841
|
+
None => Direction::Egress,
|
|
842
|
+
};
|
|
843
|
+
let kind = conv::opt_string(rd, "destination_kind")?;
|
|
844
|
+
let raw = conv::opt_string(rd, "destination")?;
|
|
845
|
+
let destination = parse_destination(kind.as_deref(), raw.as_deref())?;
|
|
846
|
+
|
|
847
|
+
let mut protocols = Vec::new();
|
|
848
|
+
for p in conv::opt_string_vec(rd, "protocols")? {
|
|
849
|
+
let proto = protocol_from_str(&p)?;
|
|
850
|
+
if !protocols.contains(&proto) {
|
|
851
|
+
protocols.push(proto);
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
let mut ports = Vec::new();
|
|
856
|
+
for p in conv::opt_string_vec(rd, "ports")? {
|
|
857
|
+
let range = parse_port_range(&p)?;
|
|
858
|
+
if !ports.contains(&range) {
|
|
859
|
+
ports.push(range);
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
Ok(Rule {
|
|
864
|
+
direction,
|
|
865
|
+
destination,
|
|
866
|
+
protocols,
|
|
867
|
+
ports,
|
|
868
|
+
action,
|
|
869
|
+
})
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/// Parse a port string into a `PortRange`. Accepts a single port (`"443"`) or
|
|
873
|
+
/// an inclusive range (`"8000-9000"`).
|
|
874
|
+
fn parse_port_range(raw: &str) -> Result<PortRange, Error> {
|
|
875
|
+
let invalid = || error::base_error(format!("invalid port {raw:?} (expected N or N-M)"));
|
|
876
|
+
if let Some((lo, hi)) = raw.split_once('-') {
|
|
877
|
+
let lo: u16 = lo.trim().parse().map_err(|_| invalid())?;
|
|
878
|
+
let hi: u16 = hi.trim().parse().map_err(|_| invalid())?;
|
|
879
|
+
if lo > hi {
|
|
880
|
+
return Err(error::base_error(format!(
|
|
881
|
+
"invalid port range {raw:?}: low {lo} exceeds high {hi}"
|
|
882
|
+
)));
|
|
883
|
+
}
|
|
884
|
+
Ok(PortRange::range(lo, hi))
|
|
885
|
+
} else {
|
|
886
|
+
let p: u16 = raw.trim().parse().map_err(|_| invalid())?;
|
|
887
|
+
Ok(PortRange::single(p))
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/// Resolve a destination from an explicit `kind` + raw value, or — when `kind`
|
|
892
|
+
/// is absent — classify the raw shorthand string. Mirrors the Python binding's
|
|
893
|
+
/// `parse_network_destination` / `parse_shorthand_destination`.
|
|
894
|
+
fn parse_destination(kind: Option<&str>, raw: Option<&str>) -> Result<Destination, Error> {
|
|
895
|
+
let required = |raw: Option<&str>, kind: &str| -> Result<String, Error> {
|
|
896
|
+
raw.map(str::to_string).ok_or_else(|| {
|
|
897
|
+
error::base_error(format!(
|
|
898
|
+
"destination is required for destination kind {kind:?}"
|
|
899
|
+
))
|
|
900
|
+
})
|
|
901
|
+
};
|
|
902
|
+
match kind {
|
|
903
|
+
Some("any") => Ok(Destination::Any),
|
|
904
|
+
Some("ip") => parse_ip_destination(&required(raw, "ip")?),
|
|
905
|
+
Some("cidr") => parse_cidr_destination(&required(raw, "cidr")?),
|
|
906
|
+
Some("domain") => parse_domain_destination(&required(raw, "domain")?),
|
|
907
|
+
Some("domain_suffix") | Some("domain-suffix") => {
|
|
908
|
+
parse_domain_suffix_destination(&required(raw, "domain_suffix")?)
|
|
909
|
+
}
|
|
910
|
+
Some("group") => parse_group_destination(&required(raw, "group")?),
|
|
911
|
+
Some(other) => Err(error::base_error(format!(
|
|
912
|
+
"unknown destination kind {other:?}"
|
|
913
|
+
))),
|
|
914
|
+
None => parse_shorthand_destination(raw),
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
fn parse_shorthand_destination(raw: Option<&str>) -> Result<Destination, Error> {
|
|
919
|
+
let Some(raw) = raw else {
|
|
920
|
+
return Ok(Destination::Any);
|
|
921
|
+
};
|
|
922
|
+
if raw == "*" {
|
|
923
|
+
return Ok(Destination::Any);
|
|
924
|
+
}
|
|
925
|
+
if let Some(rest) = raw.strip_prefix("domain=") {
|
|
926
|
+
return parse_domain_destination(rest);
|
|
927
|
+
}
|
|
928
|
+
if let Some(rest) = raw.strip_prefix("suffix=") {
|
|
929
|
+
return parse_domain_suffix_destination(rest);
|
|
930
|
+
}
|
|
931
|
+
if let Some(dest) = maybe_group_destination(raw) {
|
|
932
|
+
return Ok(dest);
|
|
933
|
+
}
|
|
934
|
+
if raw.starts_with('.') {
|
|
935
|
+
return parse_domain_suffix_destination(raw);
|
|
936
|
+
}
|
|
937
|
+
if raw.contains('/') {
|
|
938
|
+
return parse_cidr_destination(raw);
|
|
939
|
+
}
|
|
940
|
+
if raw.parse::<std::net::IpAddr>().is_ok() {
|
|
941
|
+
return parse_ip_destination(raw);
|
|
942
|
+
}
|
|
943
|
+
parse_domain_destination(raw)
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
fn parse_ip_destination(raw: &str) -> Result<Destination, Error> {
|
|
947
|
+
let ip: std::net::IpAddr = raw
|
|
948
|
+
.parse()
|
|
949
|
+
.map_err(|e| error::base_error(format!("invalid IP address {raw:?}: {e}")))?;
|
|
950
|
+
let prefix = if ip.is_ipv4() { 32 } else { 128 };
|
|
951
|
+
let cidr = ipnetwork::IpNetwork::new(ip, prefix)
|
|
952
|
+
.map_err(|e| error::base_error(format!("invalid IP address {raw:?}: {e}")))?;
|
|
953
|
+
Ok(Destination::Cidr(cidr))
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
fn parse_cidr_destination(raw: &str) -> Result<Destination, Error> {
|
|
957
|
+
let cidr: ipnetwork::IpNetwork = raw
|
|
958
|
+
.parse()
|
|
959
|
+
.map_err(|e| error::base_error(format!("invalid CIDR {raw:?}: {e}")))?;
|
|
960
|
+
Ok(Destination::Cidr(cidr))
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
fn parse_domain_destination(raw: &str) -> Result<Destination, Error> {
|
|
964
|
+
let name = raw
|
|
965
|
+
.parse()
|
|
966
|
+
.map_err(|e| error::base_error(format!("invalid domain {raw:?}: {e}")))?;
|
|
967
|
+
Ok(Destination::Domain(name))
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
fn parse_domain_suffix_destination(raw: &str) -> Result<Destination, Error> {
|
|
971
|
+
let name = raw
|
|
972
|
+
.parse()
|
|
973
|
+
.map_err(|e| error::base_error(format!("invalid domain suffix {raw:?}: {e}")))?;
|
|
974
|
+
Ok(Destination::DomainSuffix(name))
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
fn parse_group_destination(raw: &str) -> Result<Destination, Error> {
|
|
978
|
+
maybe_group_destination(raw)
|
|
979
|
+
.ok_or_else(|| error::base_error(format!("unknown destination group {raw:?}")))
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
fn maybe_group_destination(raw: &str) -> Option<Destination> {
|
|
983
|
+
let group = match raw {
|
|
984
|
+
"public" => DestinationGroup::Public,
|
|
985
|
+
"loopback" => DestinationGroup::Loopback,
|
|
986
|
+
"private" => DestinationGroup::Private,
|
|
987
|
+
"link-local" | "link_local" => DestinationGroup::LinkLocal,
|
|
988
|
+
"metadata" => DestinationGroup::Metadata,
|
|
989
|
+
"multicast" => DestinationGroup::Multicast,
|
|
990
|
+
"host" => DestinationGroup::Host,
|
|
991
|
+
_ => return None,
|
|
992
|
+
};
|
|
993
|
+
Some(Destination::Group(group))
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
//--------------------------------------------------------------------------------------------------
|
|
997
|
+
// Registry option parsing
|
|
998
|
+
//--------------------------------------------------------------------------------------------------
|
|
999
|
+
|
|
1000
|
+
/// Parsed registry connection settings (auth + transport). Built from the flat
|
|
1001
|
+
/// `registry_*` option keys the Ruby layer normalizes `registry_auth:` into.
|
|
1002
|
+
struct RegistryConfig {
|
|
1003
|
+
auth: Option<RegistryAuth>,
|
|
1004
|
+
insecure: bool,
|
|
1005
|
+
ca_certs: Vec<String>,
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
impl RegistryConfig {
|
|
1009
|
+
fn apply(
|
|
1010
|
+
self,
|
|
1011
|
+
mut r: microsandbox::sandbox::RegistryConfigBuilder,
|
|
1012
|
+
) -> microsandbox::sandbox::RegistryConfigBuilder {
|
|
1013
|
+
if let Some(auth) = self.auth {
|
|
1014
|
+
r = r.auth(auth);
|
|
1015
|
+
}
|
|
1016
|
+
if self.insecure {
|
|
1017
|
+
r = r.insecure();
|
|
1018
|
+
}
|
|
1019
|
+
for pem in self.ca_certs {
|
|
1020
|
+
r = r.ca_certs(pem.into_bytes());
|
|
1021
|
+
}
|
|
1022
|
+
r
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
/// Read the `registry_*` options, returning `None` when none are set (so the
|
|
1027
|
+
/// default credential-resolution chain in the core is left untouched).
|
|
1028
|
+
fn parse_registry_config(opts: RHash) -> Result<Option<RegistryConfig>, Error> {
|
|
1029
|
+
let username = conv::opt_string(opts, "registry_username")?;
|
|
1030
|
+
let password = conv::opt_string(opts, "registry_password")?;
|
|
1031
|
+
let insecure = conv::opt_bool(opts, "registry_insecure")?;
|
|
1032
|
+
let ca_certs = conv::opt_string_vec(opts, "registry_ca_certs")?;
|
|
1033
|
+
|
|
1034
|
+
let auth = match (username, password) {
|
|
1035
|
+
(Some(username), Some(password)) => Some(RegistryAuth::Basic { username, password }),
|
|
1036
|
+
(None, None) => None,
|
|
1037
|
+
// A half-specified credential is a caller bug, not a silent anonymous pull.
|
|
1038
|
+
_ => {
|
|
1039
|
+
return Err(error::base_error(
|
|
1040
|
+
"registry_auth requires both :username and :password",
|
|
1041
|
+
))
|
|
1042
|
+
}
|
|
1043
|
+
};
|
|
1044
|
+
|
|
1045
|
+
if auth.is_none() && !insecure && ca_certs.is_empty() {
|
|
1046
|
+
return Ok(None);
|
|
1047
|
+
}
|
|
1048
|
+
Ok(Some(RegistryConfig {
|
|
1049
|
+
auth,
|
|
1050
|
+
insecure,
|
|
1051
|
+
ca_certs,
|
|
1052
|
+
}))
|
|
1053
|
+
}
|
|
1054
|
+
|
|
505
1055
|
//--------------------------------------------------------------------------------------------------
|
|
506
1056
|
// Exec option parsing
|
|
507
1057
|
//--------------------------------------------------------------------------------------------------
|
|
@@ -808,5 +1358,14 @@ pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
|
|
|
808
1358
|
class.define_method("fs_copy_from_host", method!(Sandbox::fs_copy_from_host, 2))?;
|
|
809
1359
|
class.define_method("fs_copy_to_host", method!(Sandbox::fs_copy_to_host, 2))?;
|
|
810
1360
|
|
|
1361
|
+
class.define_method("ssh_open_client", method!(Sandbox::ssh_open_client, 1))?;
|
|
1362
|
+
class.define_method(
|
|
1363
|
+
"ssh_prepare_server",
|
|
1364
|
+
method!(Sandbox::ssh_prepare_server, 1),
|
|
1365
|
+
)?;
|
|
1366
|
+
|
|
1367
|
+
class.define_method("attach", method!(Sandbox::attach, 3))?;
|
|
1368
|
+
class.define_method("attach_shell", method!(Sandbox::attach_shell, 0))?;
|
|
1369
|
+
|
|
811
1370
|
Ok(())
|
|
812
1371
|
}
|