Skip to content
Draft
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
3 changes: 1 addition & 2 deletions go/internal/agent/services/mesh_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,7 @@ func (s *MeshService) MeshDial(stream agentpbv2.WendyMeshService_MeshDialServer)
if open.Port == 0 || open.Port > 65535 {
return status.Errorf(codes.InvalidArgument, "invalid port %d", open.Port)
}
// Same SSRF stance as the broker path (tunnel_broker_client.go:207-213):
// only local services are reachable.
// Mesh peers intentionally reach only services local to this device.
conn, err := s.dialLocal(net.JoinHostPort("127.0.0.1", strconv.Itoa(int(open.Port))), 10*time.Second)
if err != nil {
return status.Errorf(codes.Unavailable, "dialing local port %d: %v", open.Port, err)
Expand Down
30 changes: 17 additions & 13 deletions go/internal/agent/services/tunnel_broker_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -259,23 +259,12 @@ func brokerDialOpts(logger *zap.Logger, orgID, assetID int32, certPEM, keyPEM, c

func (c *TunnelBrokerClient) handleDialRequest(ctx context.Context, client cloudpb.TunnelBrokerServiceClient,
req *cloudpb.DialRequest, devMD metadata.MD) {
// Only allow loopback connections to prevent broker-directed SSRF.
ip := net.ParseIP(req.Host)
if req.Host != "localhost" && (ip == nil || !ip.IsLoopback()) {
c.logger.Error("broker dial request rejected: only loopback targets allowed",
zap.String("host", req.Host))
return
}

if req.GetProtocol() == cloudpb.TunnelProtocol_TUNNEL_PROTOCOL_DATAGRAM {
c.handleDatagramDial(ctx, client, req, devMD)
return
}

port := int(req.Port)
if c.mtlsPort != 0 && port == defaultMTLSPort && c.mtlsPort != defaultMTLSPort {
port = c.mtlsPort
}
port := tunnelDialPort(req.Host, int(req.Port), c.mtlsPort)
addr := net.JoinHostPort(req.Host, fmt.Sprint(port))
c.logger.Info("dialing local service for tunnel",
zap.String("session_id", req.SessionId), zap.String("addr", addr))
Expand Down Expand Up @@ -308,9 +297,24 @@ func (c *TunnelBrokerClient) handleDialRequest(ctx context.Context, client cloud
c.relay(callCtx, cancel, tcpConn, agentStream)
}

// isTunnelLoopbackHost identifies targets for which the well-known agent mTLS
// port may be remapped to this process's actual listen port. Remote targets must
// always receive the port requested by the CLI.
func isTunnelLoopbackHost(host string) bool {
ip := net.ParseIP(host)
return host == "localhost" || (ip != nil && ip.IsLoopback())
}

func tunnelDialPort(host string, requestedPort, mtlsPort int) int {
if isTunnelLoopbackHost(host) && mtlsPort != 0 && requestedPort == defaultMTLSPort && mtlsPort != defaultMTLSPort {
return mtlsPort
}
return requestedPort
}

// handleDatagramDial claims the session and serves a multiplexed datagram
// relay (UDP flows + ICMP echo). Nothing is dialed upfront; UDP sockets are
// created per flow on first sight, restricted to loopback like TCP dials.
// created per flow on first sight and restricted to loopback.
func (c *TunnelBrokerClient) handleDatagramDial(ctx context.Context, client cloudpb.TunnelBrokerServiceClient,
req *cloudpb.DialRequest, devMD metadata.MD) {
callCtx, cancel := context.WithCancel(ctx)
Expand Down
23 changes: 23 additions & 0 deletions go/internal/agent/services/tunnel_broker_client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,29 @@ type testAgentTunnelStream struct {
sent chan *cloudpb.TunnelData
}

func TestIsTunnelLoopbackHost(t *testing.T) {
for _, host := range []string{"localhost", "127.0.0.1", "127.99.5.4", "::1"} {
if !isTunnelLoopbackHost(host) {
t.Errorf("isTunnelLoopbackHost(%q) = false, want true", host)
}
}
for _, host := range []string{"10.0.0.1", "192.168.1.5", "example.com", "127", "127.0.0"} {
if isTunnelLoopbackHost(host) {
t.Errorf("isTunnelLoopbackHost(%q) = true, want false", host)
}
}
}

func TestTunnelDialPortOnlyRemapsLoopbackAgentPort(t *testing.T) {
const actualAgentPort = 50123
if got := tunnelDialPort("localhost", defaultMTLSPort, actualAgentPort); got != actualAgentPort {
t.Errorf("loopback agent port = %d, want remapped port %d", got, actualAgentPort)
}
if got := tunnelDialPort("db.internal", defaultMTLSPort, actualAgentPort); got != defaultMTLSPort {
t.Errorf("remote host port = %d, want requested port %d", got, defaultMTLSPort)
}
}

func (s *testAgentTunnelStream) Send(message *cloudpb.TunnelData) error {
messageCopy := &cloudpb.TunnelData{
SessionId: message.SessionId,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,25 @@
# `wendy cloud tunnel`

Opens a secure tunnel to a cloud-enrolled compute device.
Opens a secure port-forwarding tunnel through a cloud-enrolled compute device.

## Usage

```sh
wendy cloud tunnel [flags]
wendy cloud tunnel <port-forward> [flags]
```

## Description

`wendy cloud tunnel` fetches the list of **online** compute devices from Wendy Cloud and either connects directly when only one device is available, selects the device named or identified by `--device`, or prompts you to choose one interactively.

The port-forward argument accepts these forms:

- `<port>` forwards the same local and remote port on the device.
- `<local-port>:<remote-port>` forwards to the device's loopback interface.
- `<local-port>:<remote-host>:<remote-port>` forwards through the device to another TCP host reachable from it. Hostnames are resolved by the device; bracket IPv6 hosts, for example `8080:[fd00::20]:80`.

Append `/udp` or `/tcp` to select the protocol. Remote-host forwarding is currently TCP-only.

### Selecting a device

`--device` resolves a device in two steps:
Expand Down Expand Up @@ -42,19 +50,25 @@ When `--device` is omitted and multiple devices are online:
Connect, choosing interactively when more than one device is online:

```sh
wendy cloud tunnel
wendy cloud tunnel 8080:80
```

Target a device by name:

```sh
wendy cloud tunnel --device playful-reed
wendy cloud tunnel 8080:80 --device playful-reed
```

Target an unnamed device by its numeric asset ID (from `wendy cloud discover --json`):

```sh
wendy cloud tunnel --device 43
wendy cloud tunnel 8080:80 --device 43
```

Forward through a device to a PostgreSQL server on its LAN:

```sh
wendy cloud tunnel 15432:db.internal:5432 --device playful-reed
```

## See also
Expand Down
24 changes: 21 additions & 3 deletions go/internal/cli/assets/docs/cloud/tunnel.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Cloud Tunnel

The `wendy cloud tunnel` command opens a secure gRPC tunnel from your developer machine to a cloud-enrolled WendyOS device. This lets you use all standard `wendy device` commands against a remote device as if it were on the local network.
The `wendy cloud tunnel` command opens a secure gRPC tunnel from your developer machine through a cloud-enrolled WendyOS device. It can forward to a service on the device itself or to another TCP host reachable from the device.

## Prerequisites

Expand All @@ -10,7 +10,9 @@ The `wendy cloud tunnel` command opens a secure gRPC tunnel from your developer
## Usage

```sh
wendy cloud tunnel [--cloud-grpc <endpoint>] [--device <id|name>]
wendy cloud tunnel <port> [--device <id|name>]
wendy cloud tunnel <local-port>:<remote-port> [--device <id|name>]
wendy cloud tunnel <local-port>:<remote-host>:<remote-port> [--device <id|name>]
```

The CLI:
Expand All @@ -21,7 +23,9 @@ The CLI:
- When `--device` is unset and more than one device is online:
- In an **interactive terminal**, presents the cloud discover TUI in picker mode (`↑/↓` to navigate, `enter` to select, `u` to update a device before connecting, `q` to cancel).
- In a **non-interactive environment**, exits with an error that enumerates available devices as `id=name` pairs (unnamed devices show as `(unnamed)`). Pass `--device <id|name>` to select one directly.
3. Opens a tunnel to the selected device.
3. Listens on `127.0.0.1:<local-port>` and opens a tunnel through the selected device. When `remote-host` is omitted, the service is reached on the device's loopback interface. A supplied hostname is resolved by the device, so it can name a host on the device's LAN.

Append `/udp` to the one- or two-port form to forward UDP instead of TCP. Remote-host forwarding is currently TCP-only. Bracket IPv6 destinations, for example `8080:[fd00::20]:80`.

Only online devices (those with an active broker presence) are shown. If you need to inspect enrolled-but-offline devices, use [`wendy cloud discover --all`](../clients/wendy-cli/commands/cloud/discover.md). Run [`wendy cloud discover --json`](../clients/wendy-cli/commands/cloud/discover.md) to list the numeric asset IDs you can pass to `--device`.

Expand All @@ -32,6 +36,20 @@ Only online devices (those with an active broker presence) are shown. If you nee
| `--cloud-grpc` | Override the cloud gRPC endpoint. Overrides session selection. When multiple sessions are stored and no default is set, an interactive terminal shows a session picker; a non-interactive environment errors. |
| `--device` | Target a specific device by name (case-insensitive exact match) or numeric asset ID. When omitted in a non-interactive context with multiple devices, the command exits with an error listing `id=name` pairs. |

## Examples

Forward local port 8080 to port 80 on the device:

```sh
wendy cloud tunnel 8080:80 --device playful-reed
```

Forward local port 15432 through the device to a PostgreSQL server on its LAN:

```sh
wendy cloud tunnel 15432:db.internal:5432 --device playful-reed
```

## Related

- [Cloud Connectivity](./connectivity.md)
Expand Down
2 changes: 1 addition & 1 deletion go/internal/cli/commands/cloud_datagram_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ func TestParseTunnelArgUDPSuffix(t *testing.T) {
{"0:80/udp", 0, 0, false, true},
}
for _, c := range cases {
l, r, udp, err := parseTunnelArg(c.arg)
l, _, r, udp, err := parseTunnelArg(c.arg)
if c.wantErr {
if err == nil {
t.Errorf("%q: expected error", c.arg)
Expand Down
72 changes: 50 additions & 22 deletions go/internal/cli/commands/cloud_forward.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,18 @@ func newCloudTunnelCmd() *cobra.Command {
var brokerURL string

cmd := &cobra.Command{
Use: "tunnel <local-port>:<remote-port>[/udp]",
Use: "tunnel <port-forward>",
Short: "Forward a local TCP or UDP port to a port on a cloud-enrolled device",
Long: "Listens on <local-port> and forwards each connection through the Wendy Cloud tunnel broker to <remote-port> on the target device.",
Args: cobra.ExactArgs(1),
Long: "Listens on a local port and forwards each connection through the Wendy Cloud tunnel broker. " +
"Use <port>, <local-port>:<remote-port>, or <local-port>:<remote-host>:<remote-port>. " +
"Remote hosts are resolved and reached by the target device. Append /udp (or /tcp) to select the protocol; remote-host forwarding is TCP-only.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
localPort, remotePort, udp, err := parseTunnelArg(args[0])
localPort, remoteHost, remotePort, udp, err := parseTunnelArg(args[0])
if err != nil {
return err
}
return cloudTunnelCommand(cmd.Context(), cloudGRPC, effectiveDeviceName(deviceName), brokerURL, localPort, remotePort, udp)
return cloudTunnelCommand(cmd.Context(), cloudGRPC, effectiveDeviceName(deviceName), brokerURL, localPort, remoteHost, remotePort, udp)
},
}

Expand All @@ -42,40 +44,62 @@ func newCloudTunnelCmd() *cobra.Command {
return cmd
}

// parseTunnelArg parses "localPort:remotePort" or "port", with an optional
// docker-style "/udp" (or explicit "/tcp") protocol suffix.
func parseTunnelArg(arg string) (localPort, remotePort uint32, udp bool, err error) {
// parseTunnelArg parses "port", "localPort:remotePort", or
// "localPort:remoteHost:remotePort", with an optional docker-style "/udp"
// (or explicit "/tcp") protocol suffix. IPv6 remote hosts must be bracketed.
func parseTunnelArg(arg string) (localPort uint32, remoteHost string, remotePort uint32, udp bool, err error) {
if i := strings.LastIndex(arg, "/"); i >= 0 {
switch strings.ToLower(arg[i+1:]) {
case "udp":
udp = true
case "tcp":
default:
return 0, 0, false, fmt.Errorf("unknown protocol %q (use tcp or udp)", arg[i+1:])
return 0, "", 0, false, fmt.Errorf("unknown protocol %q (use tcp or udp)", arg[i+1:])
}
arg = arg[:i]
}
parts := strings.SplitN(arg, ":", 2)
parse := func(s string) (uint32, error) {
n, e := strconv.ParseUint(s, 10, 32)
if e != nil || n == 0 || n > 65535 {
return 0, fmt.Errorf("invalid port %q", s)
}
return uint32(n), nil
}
if len(parts) == 1 {
p, e := parse(parts[0])
return p, p, udp, e

separator := strings.IndexByte(arg, ':')
if separator < 0 {
p, e := parse(arg)
return p, "localhost", p, udp, e
}

lp, e := parse(arg[:separator])
if e != nil {
return 0, "", 0, false, e
}
remote := arg[separator+1:]
if !strings.Contains(remote, ":") {
rp, parseErr := parse(remote)
return lp, "localhost", rp, udp, parseErr
}
lp, e := parse(parts[0])

host, portString, splitErr := net.SplitHostPort(remote)
if splitErr != nil {
return 0, "", 0, false, fmt.Errorf("invalid remote target %q: %w", remote, splitErr)
}
if host == "" {
return 0, "", 0, false, fmt.Errorf("invalid remote host %q", host)
}
Comment on lines +89 to +91
rp, e := parse(portString)
if e != nil {
return 0, 0, false, e
return 0, "", 0, false, e
}
rp, e := parse(parts[1])
return lp, rp, udp, e
if udp && host != "localhost" {
return 0, "", 0, false, fmt.Errorf("remote host forwarding is only supported for TCP tunnels")
}
return lp, host, rp, udp, nil
}

func cloudTunnelCommand(ctx context.Context, cloudGRPC, deviceName, brokerURL string, localPort, remotePort uint32, udp bool) error {
func cloudTunnelCommand(ctx context.Context, cloudGRPC, deviceName, brokerURL string, localPort uint32, remoteHost string, remotePort uint32, udp bool) error {
auth, err := pickAuthEntry(cloudGRPC)
if err != nil {
return err
Expand Down Expand Up @@ -121,7 +145,11 @@ func cloudTunnelCommand(ctx context.Context, cloudGRPC, deviceName, brokerURL st
}
defer ln.Close()

cliSuccess("Forwarding %s → %s:%d (via cloud)", listenAddr, asset.GetName(), remotePort)
if remoteHost == "localhost" {
cliSuccess("Forwarding %s → %s:%d (via cloud)", listenAddr, asset.GetName(), remotePort)
} else {
cliSuccess("Forwarding %s → %s through %s (via cloud)", listenAddr, net.JoinHostPort(remoteHost, strconv.Itoa(int(remotePort))), asset.GetName())
}
cliLogln("Press Ctrl+C to stop.")

go func() {
Expand All @@ -137,14 +165,14 @@ func cloudTunnelCommand(ctx context.Context, cloudGRPC, deviceName, brokerURL st
}
return fmt.Errorf("accepting connection: %w", err)
}
go serveTunnelConn(ctx, tcpConn, brokerConn, auth, asset.GetId(), remotePort)
go serveTunnelConn(ctx, tcpConn, brokerConn, auth, asset.GetId(), remoteHost, remotePort)
}
}

func serveTunnelConn(ctx context.Context, tcpConn net.Conn, brokerConn *grpc.ClientConn, auth *config.AuthConfig, assetID int32, remotePort uint32) {
func serveTunnelConn(ctx context.Context, tcpConn net.Conn, brokerConn *grpc.ClientConn, auth *config.AuthConfig, assetID int32, remoteHost string, remotePort uint32) {
defer tcpConn.Close()

tunnelConn, err := openBrokerTunnel(ctx, brokerConn, auth, assetID, remotePort)
tunnelConn, err := openBrokerTunnelToHost(ctx, brokerConn, auth, assetID, remoteHost, remotePort)
if err != nil {
return
}
Expand Down
26 changes: 17 additions & 9 deletions go/internal/cli/commands/cloud_tunnel.go
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,10 @@ func waitForCloudAgentRestart(ctx context.Context, auth *config.AuthConfig, asse
}

func openBrokerTunnel(ctx context.Context, brokerConn *grpc.ClientConn, auth *config.AuthConfig, assetID int32, remotePort uint32) (net.Conn, error) {
return openBrokerTunnelToHost(ctx, brokerConn, auth, assetID, "localhost", remotePort)
}

func openBrokerTunnelToHost(ctx context.Context, brokerConn *grpc.ClientConn, auth *config.AuthConfig, assetID int32, remoteHost string, remotePort uint32) (net.Conn, error) {
client := cloudpb.NewTunnelBrokerServiceClient(brokerConn)

cloudCtx, err := cloudContext(ctx, auth)
Expand All @@ -279,15 +283,7 @@ func openBrokerTunnel(ctx context.Context, brokerConn *grpc.ClientConn, auth *co
return nil, fmt.Errorf("opening tunnel stream: %w", err)
}

if err := stream.Send(&cloudpb.ClientTunnelMessage{
Content: &cloudpb.ClientTunnelMessage_Open{
Open: &cloudpb.ClientTunnelOpen{
AssetId: assetID,
Host: "localhost",
Port: remotePort,
},
},
}); err != nil {
if err := stream.Send(clientTunnelOpenMessage(assetID, remoteHost, remotePort)); err != nil {
return nil, fmt.Errorf("sending tunnel open: %w", err)
}

Expand Down Expand Up @@ -325,6 +321,18 @@ func openBrokerTunnel(ctx context.Context, brokerConn *grpc.ClientConn, auth *co
return local, nil
}

func clientTunnelOpenMessage(assetID int32, remoteHost string, remotePort uint32) *cloudpb.ClientTunnelMessage {
return &cloudpb.ClientTunnelMessage{
Content: &cloudpb.ClientTunnelMessage_Open{
Open: &cloudpb.ClientTunnelOpen{
AssetId: assetID,
Host: remoteHost,
Port: remotePort,
},
},
}
}

// tunnelUplinkQueueSlots bounds the uplink queue: reads are ≤256KiB, so 128
// slots ≈ 32MB — 2× the tunneled conn's 16MB connection window, more than gRPC
// can have in flight before its own flow control pushes back. Derived from the
Expand Down
Loading
Loading