Skip to content

Commit 9eee68f

Browse files
pimlockdrew
authored andcommitted
feat(relay): route forwarding through ForwardTcp
1 parent 7ad823e commit 9eee68f

35 files changed

Lines changed: 1923 additions & 1042 deletions

.agents/skills/debug-openshell-cluster/SKILL.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,10 @@ helm -n openshell get values openshell | grep -E 'repository|tag|supervisorImage
128128

129129
The gateway image and `server.supervisorImage` should use the same build tag in branch and E2E deploys. A stale supervisor image can make sandbox behavior lag behind gateway policy or proto changes.
130130

131+
For local/external pull mode (the default local path via `mise run cluster`), local images are tagged to the configured local registry base, pushed to that registry, and pulled by k3s via the `registries.yaml` mirror endpoint. The `cluster` task rebuilds the local gateway image before tagging and pushing it, so a fresh bootstrap should not reuse stale `openshell/gateway:dev` bits from a previous source revision.
132+
133+
Gateway image builds stage a partial Rust workspace from `deploy/docker/Dockerfile.images`. If cargo fails with a missing manifest under `/build/crates/...`, or an imported symbol exists locally but is missing in the image build, verify that every current gateway dependency crate is copied into the staged workspace there.
134+
131135
For plaintext local evaluation, confirm the chart has:
132136

133137
```bash

architecture/gateway.md

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,12 @@ workloads.
99

1010
- Authenticate clients and sandbox callbacks.
1111
- Serve gRPC APIs for sandbox lifecycle, provider management, policy updates,
12-
settings, inference configuration, logs, and watch streams.
13-
- Serve HTTP endpoints for health, SSH tunnel upgrades, and edge-auth flows.
12+
settings, inference configuration, logs, watch streams, and relay forwarding.
13+
- Serve HTTP endpoints for health, WebSocket tunnels, and edge-auth flows.
1414
- Persist domain objects in SQLite or Postgres.
1515
- Resolve provider credentials and inference bundles for sandbox supervisors.
16-
- Coordinate supervisor relay sessions for connect, exec, and file sync.
16+
- Coordinate supervisor relay sessions for connect, exec, file sync, and
17+
service forwarding.
1718

1819
The gateway does not enforce agent network policy at request time. That happens
1920
inside each sandbox, where the supervisor and proxy can observe local process
@@ -44,7 +45,7 @@ The gateway API is organized around platform objects and operational streams:
4445

4546
| Area | Examples |
4647
|---|---|
47-
| Sandbox lifecycle | Create, list, delete, watch, exec, SSH session bootstrap. |
48+
| Sandbox lifecycle | Create, list, delete, watch, exec, SSH session bootstrap, ForwardTcp service forwarding. |
4849
| Providers | Store provider records, discover credentials, resolve runtime environment. |
4950
| Policy and settings | Get effective sandbox config, update sandbox policy, manage global settings. |
5051
| Inference | Set gateway-level model/provider config and resolve sandbox route bundles. |
@@ -115,22 +116,35 @@ sequenceDiagram
115116
participant CLI
116117
participant GW as Gateway
117118
participant SUP as Sandbox supervisor
118-
participant SSH as Sandbox SSH socket
119+
participant Target as Sandbox target
119120
120121
SUP->>GW: ConnectSupervisor stream
121-
CLI->>GW: connect / exec / sync request
122-
GW->>SUP: RelayOpen(channel)
122+
CLI->>GW: ForwardTcp / exec / sync request
123+
GW->>SUP: RelayOpen(channel, target)
124+
SUP->>Target: Dial SSH socket or loopback service
123125
SUP->>GW: RelayStream(channel)
124-
SUP->>SSH: Bridge bytes to Unix socket
125126
CLI->>GW: Client bytes
126127
GW-->>CLI: Client bytes
127128
GW->>SUP: Relay bytes
128129
SUP-->>GW: Relay bytes
129130
```
130131

131-
The same relay pattern backs interactive SSH, command execution, and file sync.
132-
The gateway tracks live sessions in memory and persists session records so
133-
tokens can expire or be revoked.
132+
The same relay pattern backs interactive SSH, command execution, file sync, and
133+
local service forwarding. The gateway tracks live sessions in memory and
134+
persists session records so tokens can expire or be revoked.
135+
136+
`ForwardTcp` is the client-facing byte stream for SSH and service forwarding.
137+
The first frame is a `TcpForwardInit` that carries the sandbox ID, an
138+
authorization token from `CreateSshSession`, and an explicit target:
139+
`target.ssh` for the sandbox SSH socket or `target.tcp` for a loopback service
140+
inside the sandbox. The gateway validates the token and sandbox readiness,
141+
sends a targeted `RelayOpen` to the supervisor, then bridges
142+
`TcpForwardFrame::Data` to `RelayFrame::Data` until either side closes.
143+
144+
For `target.tcp`, the gateway only accepts loopback destinations such as
145+
`localhost`, `127.0.0.0/8`, or `::1`. The gateway never needs to know or dial a
146+
sandbox pod IP; supervisors connect outbound and bridge only the explicit target
147+
requested for that relay.
134148

135149
## PKI Bootstrap
136150

@@ -143,13 +157,13 @@ created. Both deployment paths use it:
143157
| Filesystem | `--output-dir <DIR>` | `<dir>/{ca.crt, ca.key, server/tls.{crt,key}, client/tls.{crt,key}}`. Also copies client materials to `$XDG_CONFIG_HOME/openshell/gateways/openshell/mtls/` for CLI auto-discovery. |
144158

145159
On Kubernetes, the Helm chart runs the command via a pre-install/pre-upgrade
146-
hook Job using the gateway image itself no separate cert-generation image,
160+
hook Job using the gateway image itself -- no separate cert-generation image,
147161
no extra mirror burden in air-gapped environments. On the RPM gateway, the
148162
same command runs from the systemd unit's `ExecStartPre` to bootstrap PKI
149163
into the user's state directory on first start.
150164

151-
Both modes share the same idempotency contract: all targets present skip;
152-
partial state fail with a recovery hint; nothing present generate and
165+
Both modes share the same idempotency contract: all targets present -> skip;
166+
partial state -> fail with a recovery hint; nothing present -> generate and
153167
write. This guards mTLS continuity across restarts and upgrades while still
154168
recovering cleanly if an operator deletes everything and starts over.
155169

crates/openshell-cli/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ tokio-tungstenite = { workspace = true }
6868

6969
# Streams
7070
futures = { workspace = true }
71+
tokio-stream = { workspace = true }
7172
nix = { workspace = true }
7273

7374
# URL parsing

crates/openshell-cli/src/main.rs

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use clap_complete::engine::ArgValueCompleter;
88
use clap_complete::env::CompleteEnv;
99
use miette::Result;
1010
use owo_colors::OwoColorize;
11+
use std::collections::HashMap;
1112
use std::io::Write;
1213
use std::path::PathBuf;
1314

@@ -198,6 +199,7 @@ const HELP_TEMPLATE: &str = "\
198199
\x1b[1mSANDBOX COMMANDS\x1b[0m
199200
sandbox: Manage sandboxes
200201
forward: Manage port forwarding to a sandbox
202+
service: Forward sandbox services over gRPC
201203
logs: View sandbox logs
202204
policy: Manage sandbox policy
203205
settings: Manage sandbox and global settings
@@ -270,6 +272,12 @@ const FORWARD_EXAMPLES: &str = "\x1b[1mALIAS\x1b[0m
270272
$ openshell forward list
271273
";
272274

275+
const SERVICE_EXAMPLES: &str = "\x1b[1mEXAMPLES\x1b[0m
276+
$ openshell service forward my-sandbox --target-port 8080
277+
$ openshell service forward my-sandbox --target-port 5432 --local 15432
278+
$ openshell service forward my-sandbox --target-port 3000 --local 127.0.0.1:0
279+
";
280+
273281
const LOGS_EXAMPLES: &str = "\x1b[1mALIAS\x1b[0m
274282
lg
275283
@@ -407,6 +415,13 @@ enum Commands {
407415
command: Option<ForwardCommands>,
408416
},
409417

418+
/// Forward sandbox services over gRPC.
419+
#[command(after_help = SERVICE_EXAMPLES, help_template = SUBCOMMAND_HELP_TEMPLATE)]
420+
Service {
421+
#[command(subcommand)]
422+
command: Option<ServiceCommands>,
423+
},
424+
410425
/// View sandbox logs.
411426
#[command(alias = "lg", after_help = LOGS_EXAMPLES, help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
412427
Logs {
@@ -1613,6 +1628,29 @@ enum ForwardCommands {
16131628
List,
16141629
}
16151630

1631+
#[derive(Subcommand, Debug)]
1632+
enum ServiceCommands {
1633+
/// Forward a local TCP port to a loopback service inside a sandbox over gRPC.
1634+
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
1635+
Forward {
1636+
/// Sandbox name (defaults to last-used sandbox).
1637+
#[arg(add = ArgValueCompleter::new(completers::complete_sandbox_names))]
1638+
name: Option<String>,
1639+
1640+
/// Target service port inside the sandbox.
1641+
#[arg(long)]
1642+
target_port: u16,
1643+
1644+
/// Target service host inside the sandbox. Phase 1 accepts loopback only.
1645+
#[arg(long, default_value = "127.0.0.1")]
1646+
target_host: String,
1647+
1648+
/// Local bind address and port: [bind_address:]port. Use port 0 for dynamic assignment.
1649+
#[arg(long)]
1650+
local: Option<String>,
1651+
},
1652+
}
1653+
16161654
#[tokio::main]
16171655
#[allow(clippy::large_stack_frames)] // CLI dispatch holds many futures; OK at top level.
16181656
async fn main() -> Result<()> {
@@ -1777,6 +1815,38 @@ async fn main() -> Result<()> {
17771815
}
17781816
}
17791817

1818+
Some(Commands::Service {
1819+
command:
1820+
Some(ServiceCommands::Forward {
1821+
name,
1822+
target_port,
1823+
target_host,
1824+
local,
1825+
}),
1826+
}) => {
1827+
let ctx = resolve_gateway(&cli.gateway, &cli.gateway_endpoint)?;
1828+
let mut tls = tls.with_gateway_name(&ctx.name);
1829+
apply_edge_auth(&mut tls, &ctx.name);
1830+
let name = resolve_sandbox_name(name, &ctx.name)?;
1831+
run::service_forward_tcp(
1832+
&ctx.endpoint,
1833+
&name,
1834+
local.as_deref(),
1835+
&target_host,
1836+
target_port,
1837+
&tls,
1838+
)
1839+
.await?;
1840+
}
1841+
1842+
Some(Commands::Service { command: None }) => {
1843+
Cli::command()
1844+
.find_subcommand_mut("service")
1845+
.expect("service subcommand exists")
1846+
.print_help()
1847+
.expect("Failed to print help");
1848+
}
1849+
17801850
// -----------------------------------------------------------
17811851
// Top-level forward (was `sandbox forward`)
17821852
// -----------------------------------------------------------
@@ -2236,7 +2306,7 @@ async fn main() -> Result<()> {
22362306
};
22372307

22382308
// Parse --label flags into a HashMap<String, String>.
2239-
let mut labels_map = std::collections::HashMap::new();
2309+
let mut labels_map = HashMap::new();
22402310
for label_str in &labels {
22412311
let parts: Vec<&str> = label_str.splitn(2, '=').collect();
22422312
if parts.len() != 2 {

0 commit comments

Comments
 (0)