Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion crates/openshell-cli/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2166,6 +2166,13 @@ pub async fn service_forward_tcp(
tls: &TlsOptions,
workspace: &str,
) -> Result<()> {
let target_host = openshell_core::net::normalize_tcp_forward_host(target_host)
.map_err(|message| miette::miette!(message))?;
if target_port == 0 {
return Err(miette::miette!(
"tcp target port must be between 1 and 65535"
));
}
let (bind_addr, bind_port) = parse_tcp_forward_spec(local, target_port)?;
let mut client = grpc_client(server, tls).await?;

Expand Down Expand Up @@ -2214,7 +2221,7 @@ pub async fn service_forward_tcp(
let mut client = client.clone();
let sandbox_name = sandbox_name.clone();
let sandbox_workspace = sandbox_workspace.clone();
let target_host = target_host.to_string();
let target_host = target_host.clone();
let service_id = format!("service-forward:{name}:{target_host}:{target_port}");
let fatal_tx = fatal_tx.clone();
let legacy_session_tokens = legacy_session_tokens.clone();
Expand Down Expand Up @@ -7135,6 +7142,36 @@ mod tests {
};
use openshell_core::proto::TcpForwardFrame;

#[tokio::test]
async fn forward_rejects_invalid_target_before_gateway_or_local_bind() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let local = listener.local_addr().unwrap().to_string();
for (host, port, message) in [
("example.com", 8080, "tcp target host must be loopback"),
("192.0.2.1", 8080, "tcp target host must be loopback"),
("::ffff:127.0.0.1", 8080, "tcp target host must be loopback"),
("", 8080, "tcp target host is required"),
(
"127.0.0.1",
0,
"tcp target port must be between 1 and 65535",
),
] {
let error = super::service_forward_tcp(
"invalid gateway",
"sandbox",
Some(&local),
host,
port,
&crate::tls::TlsOptions::default(),
"default",
)
.await
.unwrap_err();
assert_eq!(error.to_string(), message);
}
}

#[test]
fn lifecycle_wait_fails_fast_on_phases_that_block_the_target() {
use super::lifecycle_phase_blocks_target;
Expand Down
25 changes: 25 additions & 0 deletions crates/openshell-core/src/net.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,31 @@ use ipnet::{IpNet, Ipv4Net, Ipv6Net};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use tokio::net::TcpStream;

/// Normalize a TCP forward destination without DNS resolution.
///
/// Forward targets must be sandbox loopback addresses. `localhost` uses IPv4
/// loopback consistently across clients and the gateway.
///
/// # Errors
/// Returns an error for an empty host or a non-loopback destination.
pub fn normalize_tcp_forward_host(host: &str) -> Result<String, &'static str> {
let host = host.trim();
if host.is_empty() {
return Err("tcp target host is required");
}
if host.eq_ignore_ascii_case("localhost") {
return Ok("127.0.0.1".to_string());
}
let ip: IpAddr = host
.parse()
.map_err(|_| "tcp target host must be loopback")?;
if ip.is_loopback() {
Ok(ip.to_string())
} else {
Err("tcp target host must be loopback")
}
}

/// Check if a hostname is a known cloud metadata hostname that resolves to an
/// always-blocked metadata service.
///
Expand Down
38 changes: 14 additions & 24 deletions crates/openshell-server/src/grpc/sandbox.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,6 @@ use openshell_core::{GetResourceVersion, ObjectId, ObjectName, ObjectWorkspace};
use prost::Message;
use prost_types::{Struct, Value, value::Kind};
use std::collections::{HashMap, HashSet};
use std::net::IpAddr;
use std::pin::Pin;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
Expand Down Expand Up @@ -3111,28 +3110,12 @@ fn validate_tcp_forward_target(target: &TcpRelayTarget) -> Result<TcpRelayTarget
));
}

validate_tcp_target_parts(target.host.trim(), target.port).map(|host| TcpRelayTarget {
host,
port: target.port,
})
}

fn validate_tcp_target_parts(host: &str, _port: u32) -> Result<String, Status> {
if host.is_empty() {
return Err(Status::invalid_argument("tcp target host is required"));
}
if host.eq_ignore_ascii_case("localhost") {
return Ok("127.0.0.1".to_string());
}

let ip: IpAddr = host
.parse()
.map_err(|_| Status::invalid_argument("tcp target host must be loopback"))?;
if ip.is_loopback() {
Ok(ip.to_string())
} else {
Err(Status::invalid_argument("tcp target host must be loopback"))
}
openshell_core::net::normalize_tcp_forward_host(&target.host)
.map_err(Status::invalid_argument)
.map(|host| TcpRelayTarget {
host,
port: target.port,
})
}

async fn bridge_forward_tcp_stream<S>(
Expand Down Expand Up @@ -4972,7 +4955,14 @@ mod tests {

#[test]
fn tcp_forward_init_allows_loopback_targets() {
for host in ["127.0.0.1", "::1", "localhost"] {
for host in [
"127.0.0.1",
"127.2.3.4",
"::1",
"0:0:0:0:0:0:0:1",
" localhost ",
"LOCALHOST",
] {
let init = TcpForwardInit {
sandbox: "sbx".to_string(),
workspace: String::new(),
Expand Down
12 changes: 12 additions & 0 deletions docs/how-it-works/sandboxes/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -876,6 +876,18 @@ openshell sandbox create --from registry.example.com/your-org/claude-agent:lates

</Tip>

### Forward a TCP service through the gateway

Use the gateway's TCP relay to forward a loopback service:

```shell
openshell forward service my-sandbox --target-host 127.0.0.1 --target-port 8080 --local 0
```

The target host must be a loopback IP address or `localhost`, and the target
port must be between 1 and 65535. Invalid targets are rejected before a local
listener opens.

## SSH Config

Each sandbox has its own SSH host key. The key stays the same when the
Expand Down
32 changes: 32 additions & 0 deletions sdk/typescript/src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2167,6 +2167,38 @@ describe('ssh sessions', () => {
});

describe('forward', () => {
it('rejects invalid targets before gateway access or binding a local port', async () => {
let reads = 0;
const sandbox = client({
getSandbox: () => {
reads++;
return readySandbox('sb', 'id');
},
});
for (const targetHost of ['example.com', '192.0.2.1', '::ffff:127.0.0.1', '::1%lo0', '[::1]', '']) {
await expect(sandbox.forward('sb', { targetHost, targetPort: 9000 })).rejects.toMatchObject({
code: 'invalid_config',
message: expect.stringContaining('tcp target host'),
});
}
for (const targetPort of [0, -1, 65536, 1.5, Number.NaN]) {
await expect(sandbox.forward('sb', { targetPort })).rejects.toMatchObject({
code: 'invalid_config',
message: '[invalid_config] tcp target port must be between 1 and 65535',
});
}
expect(reads).toBe(0);
for (const [targetHost, normalized] of [
[' LOCALHOST ', '127.0.0.1'],
['127.2.3.4', '127.2.3.4'],
['0:0:0:0:0:0:0:1', '::1'],
]) {
const handle = await sandbox.forward('sb', { targetHost, targetPort: 9000 });
expect(handle.targetHost).toBe(normalized);
await handle.close();
}
});

it('binds a local port and relays bytes both ways, minting + revoking a token', async () => {
let sshReq: ScopedRequest = {};
let revokedToken: string | undefined;
Expand Down
15 changes: 14 additions & 1 deletion sdk/typescript/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,16 @@ import { heldUntilReady, OWNER_REPLICA_HEADER, OWNER_REQUEST_HEADER, routedStrea
import { validateSshResponse } from './ssh-validate.js';
import { buildTransport, type ConnectOptions } from './transport.js';

// Match the gateway's loopback-only forward target contract without DNS.
function normalizeForwardHost(value: string): string {
const host = value.trim();
if (!host) throw new SdkError('invalid_config', 'tcp target host is required');
if (host.toLowerCase() === 'localhost') return '127.0.0.1';
if (net.isIPv4(host) && host.startsWith('127.')) return host;
if (net.isIPv6(host) && !host.includes('%') && new URL(`http://[${host}]`).hostname === '[::1]') return '::1';
throw new SdkError('invalid_config', 'tcp target host must be loopback');
}

function durationFromSeconds(seconds: number) {
if (!Number.isFinite(seconds) || seconds < 0) {
throw new RangeError('timeoutSecs must be a finite, non-negative number');
Expand Down Expand Up @@ -1736,8 +1746,11 @@ export class SandboxClient {
// the `init` (TCP target + token), relay bytes both ways in ~64 KiB chunks,
// and revoke the token on close. Process-lifetime only.
async forward(name: string, opts: ForwardOptions): Promise<ForwardHandle> {
const targetHost = opts.targetHost ?? '127.0.0.1';
const targetHost = normalizeForwardHost(opts.targetHost ?? '127.0.0.1');
const targetPort = opts.targetPort;
if (!Number.isInteger(targetPort) || targetPort < 1 || targetPort > 65535) {
throw new SdkError('invalid_config', 'tcp target port must be between 1 and 65535');
}
const localHost = opts.localHost ?? '127.0.0.1';
const localPort = opts.localPort ?? 0;

Expand Down
6 changes: 4 additions & 2 deletions tests/tmachine/ansible/playbooks/k3s.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,12 @@
state: present
update_cache: true

# get.k3s.io serves the latest script, so pin the one from the k3s_version
# release tag. Update the checksum whenever k3s_version changes.
- name: Download K3s installer
ansible.builtin.get_url:
url: https://get.k3s.io
checksum: sha256:e5cc3b3d9dfc1662c2d9be6da5abc9a4cd317d6abc3a5ffc02e3dd3248207fee
url: "https://raw.githubusercontent.com/k3s-io/k3s/v{{ k3s_version | urlencode }}/install.sh"
checksum: sha256:3ce239d57d43b2d836d2b561043433e6decae8b9dc41f5d13908c0fafb0340cd
dest: /tmp/install-k3s.sh
mode: "0755"

Expand Down
Loading