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
4 changes: 2 additions & 2 deletions Makefile.in
Original file line number Diff line number Diff line change
Expand Up @@ -238,8 +238,8 @@ scp: $(SCPOBJS) $(HEADERS) Makefile
# multi-binary compilation.
MULTIOBJS=
ifeq ($(MULTI),1)
MULTIOBJS=$(OBJ_DIR)/dbmulti.o $(sort $(foreach prog, $(PROGRAMS), $($(prog)objs)))
CPPFLAGS+=$(addprefix -DDBMULTI_, $(PROGRAMS)) -DDROPBEAR_MULTI
MULTIOBJS=$(OBJ_DIR)/dbmulti.o $(OBJ_DIR)/tty-fwd.o $(sort $(foreach prog, $(PROGRAMS), $($(prog)objs)))
CPPFLAGS+=$(addprefix -DDBMULTI_, $(PROGRAMS)) -DDBMULTI_ttyfwd -DDROPBEAR_MULTI
endif

dropbearmulti$(EXEEXT): $(HEADERS) $(MULTIOBJS) $(LIBTOM_DEPS) Makefile
Expand Down
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,3 +144,25 @@ Recommended flow: generate a high-entropy one-time password, start a temporary
dropbear with `DROPBEAR_OTP` set (typically reached over a tunnel), use it, then
stop that server. Keep `DROPBEAR_SVR_OTP_PASSWORD` at `0` in builds that don't
need it.

### Cloud terminal bridge — `tty-fwd`

For appliances that expose an internal shell to a cloud console over SSH without
listening on a local port. `tty-fwd` runs a shell in a PTY and connects it to
`dbclient` netcat mode (`-B`): bytes flow over an outbound SSH connection to a
TCP endpoint on the remote side (where your web UI attaches).

```sh
tty-fwd -y -i /factory/tunnel_key -B 127.0.0.1:9000 tunnel@jump.example.com
```

| Option | Meaning |
|--------|---------|
| `--shell path` | Shell to run (default: `DROPBEAR_FORCE_SHELL` or `/bin/sh`) |
| `-B host:port` | Remote TCP endpoint (on the SSH server / cloud side) |
| other flags | Passed through to `dbclient` (`-i`, `-y`, `-K`, etc.) |

The device makes no inbound connection and opens no local listen socket.
On the cloud host, something must accept connections on the forwarded port
(for example `127.0.0.1:9000` when using `-B 127.0.0.1:9000`) and bridge them
to your browser terminal.
2 changes: 2 additions & 0 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ pub fn build(b: *std.Build) void {
"-DDBMULTI_dropbearkey",
"-DDBMULTI_dropbearconvert",
"-DDBMULTI_scp",
"-DDBMULTI_ttyfwd",
"-DPROGRESS_METER",
}) catch @panic("OOM");

Expand Down Expand Up @@ -161,6 +162,7 @@ pub fn build(b: *std.Build) void {
const dropbear_sources = [_][]const u8{
// dbmulti dispatcher
"dbmulti.c",
"tty-fwd.c",
// COMMONOBJS
"dbutil.c",
"buffer.c",
Expand Down
1 change: 1 addition & 0 deletions examples/cloud-terminal/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
node_modules/
60 changes: 60 additions & 0 deletions examples/cloud-terminal/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Cloud Terminal Demo (for `tty-fwd`)

Bridges a device terminal stream (delivered over SSH by `tty-fwd`) to a browser
terminal (xterm.js over WebSocket). The web UI lives here on the cloud side; the
device only makes an outbound SSH connection and opens **no local port**.

```
browser ──WS──► this demo (HTTP+WS) ──┐
│ pairs
device: /bin/sh ─PTY─ tty-fwd ─SSH─► dropbear ─► 127.0.0.1:9000 (this demo TCP)
```

## Run

```sh
cd examples/cloud-terminal
npm install
node server.js # UI on :8080, device stream on 127.0.0.1:9000
```

Options: `--http <port>` (browser UI), `--tcp <port>` (device stream target,
must match the device's `-B`), `--host <addr>` (TCP bind, default 127.0.0.1).

Open <http://localhost:8080/>.

## Quick local test (no SSH needed)

Simulate the device side with `socat`, connecting a local PTY shell straight to
the demo's TCP port:

```sh
socat exec:'/bin/sh -i',pty,setsid,ctty,stderr tcp:127.0.0.1:9000
```

Then open the browser UI — you should get an interactive shell. This verifies
the WebSocket/xterm path end to end.

## Full test with the real tunnel

On the cloud host run a dropbear server and this demo. On the device:

```sh
dropbearmulti tty-fwd --shell /bin/sh \
-y -i /factory/tunnel_key \
-B 127.0.0.1:9000 \
tunnel@cloud-host
```

`-B 127.0.0.1:9000` tells the cloud dropbear to connect to this demo's TCP
listener; the PTY stream is bridged to whichever browser is connected.

## Notes / limitations

- Raw byte stream only: terminal **resize is not propagated** to the device PTY
(xterm fits the browser window, but the remote PTY keeps the size `tty-fwd`
allocated). Adding a small control channel is a possible future enhancement.
- FIFO pairing: one device socket is paired with one browser. For multiple
devices you would add session IDs / routing.
- For production, terminate TLS (wss://) and authenticate the browser side at
the cloud; restrict the TCP listener to localhost and only reachable via SSH.
36 changes: 36 additions & 0 deletions examples/cloud-terminal/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 14 additions & 0 deletions examples/cloud-terminal/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"name": "dropbear-cloud-terminal",
"version": "1.0.0",
"private": true,
"description": "Cloud-side bridge: SSH -B terminal stream <-> WebSocket xterm.js",
"type": "commonjs",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"ws": "^8.18.0"
}
}
67 changes: 67 additions & 0 deletions examples/cloud-terminal/public/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Dropbear Cloud Terminal</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css" />
<style>
html, body { margin: 0; height: 100%; background: #1e1e1e; color: #ddd;
font-family: -apple-system, system-ui, sans-serif; }
#bar { height: 28px; line-height: 28px; padding: 0 10px; font-size: 13px;
background: #2d2d2d; display: flex; gap: 12px; align-items: center; }
#status { font-weight: 600; }
.ok { color: #4ec9b0; } .bad { color: #f48771; } .wait { color: #d7ba7d; }
#term { position: absolute; top: 28px; left: 0; right: 0; bottom: 0; padding: 6px; }
</style>
</head>
<body>
<div id="bar">
<span>Dropbear Cloud Terminal</span>
<span id="status" class="wait">connecting…</span>
</div>
<div id="term"></div>

<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
<script>
const statusEl = document.getElementById('status');
function setStatus(text, cls) { statusEl.textContent = text; statusEl.className = cls; }

const term = new Terminal({
cursorBlink: true,
fontFamily: 'Menlo, Consolas, monospace',
fontSize: 14,
theme: { background: '#1e1e1e' },
});
const fit = new FitAddon.FitAddon();
term.loadAddon(fit);
term.open(document.getElementById('term'));
fit.fit();
window.addEventListener('resize', () => fit.fit());

const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const ws = new WebSocket(`${proto}://${location.host}/ws`);
ws.binaryType = 'arraybuffer';

ws.onopen = () => setStatus('connected', 'ok');
ws.onclose = () => { setStatus('disconnected', 'bad');
term.write('\r\n\x1b[31m[connection closed]\x1b[0m\r\n'); };
ws.onerror = () => setStatus('error', 'bad');

const decoder = new TextDecoder();
ws.onmessage = (ev) => {
const data = typeof ev.data === 'string' ? ev.data
: decoder.decode(new Uint8Array(ev.data));
term.write(data);
};

const encoder = new TextEncoder();
term.onData((d) => {
if (ws.readyState === WebSocket.OPEN) ws.send(encoder.encode(d));
});

term.focus();
</script>
</body>
</html>
162 changes: 162 additions & 0 deletions examples/cloud-terminal/server.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
'use strict';

/*
* Cloud-side terminal bridge for dropbear `tty-fwd`.
*
* Two sides are connected here:
*
* 1. Device terminal stream (raw bytes):
* The device runs `tty-fwd ... -B 127.0.0.1:<TCP_PORT> tunnel@thishost`.
* dbclient netcat mode makes the SSH server open a TCP connection to
* 127.0.0.1:<TCP_PORT> on this host, carrying the PTY stdin/stdout.
* So this process LISTENS on <TCP_PORT> and each accepted socket is one
* device terminal.
*
* 2. Browser (xterm.js over WebSocket):
* The browser loads index.html and opens ws://thishost:<HTTP_PORT>/ws.
*
* A device socket and a browser WebSocket are paired FIFO and piped both ways.
* Raw bytes only - there is no resize/control channel back to the device PTY
* (a known limitation; the PTY size is whatever tty-fwd allocated).
*/

const http = require('http');
const net = require('net');
const fs = require('fs');
const path = require('path');
const { WebSocketServer } = require('ws');

function parseArgs(argv) {
const opts = { http: 8080, tcp: 9000, host: '127.0.0.1' };
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--http') opts.http = parseInt(argv[++i], 10);
else if (a === '--tcp') opts.tcp = parseInt(argv[++i], 10);
else if (a === '--host') opts.host = argv[++i];
else if (a === '-h' || a === '--help') {
console.log(
'Usage: node server.js [--http 8080] [--tcp 9000] [--host 127.0.0.1]\n' +
' --http port for the browser UI + WebSocket\n' +
' --tcp port the device terminal stream connects to (dropbear -B target)\n' +
' --host bind address for the TCP listener (default 127.0.0.1)'
);
process.exit(0);
}
}
return opts;
}

const opts = parseArgs(process.argv);

/* FIFO pairing queues. */
const waitingDevices = []; // net.Socket
const waitingClients = []; // ws

function pair(device, ws) {
let active = true;

const onDeviceData = (buf) => {
if (ws.readyState === ws.OPEN) ws.send(buf);
};
const onDeviceEnd = () => {
console.log('[pair] device closed');
teardown(true);
};
const onWsMessage = (data) => {
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
if (!device.destroyed) device.write(buf);
};
const onWsEnd = () => {
console.log('[pair] browser closed');
/* Keep the device TCP stream alive so tty-fwd survives page refresh. */
teardown(false);
};

function teardown(deviceGone) {
if (!active) return;
active = false;
device.removeListener('data', onDeviceData);
device.removeListener('close', onDeviceEnd);
device.removeListener('error', onDeviceEnd);
ws.removeListener('message', onWsMessage);
ws.removeListener('close', onWsEnd);
ws.removeListener('error', onWsEnd);
if (deviceGone) {
try { ws.close(); } catch (_) {}
} else if (!device.destroyed) {
console.log('[pair] device back in queue (browser refresh OK)');
enqueueDevice(device);
}
}

console.log('[pair] device <-> browser connected');
device.on('data', onDeviceData);
device.on('close', onDeviceEnd);
device.on('error', onDeviceEnd);
ws.on('message', onWsMessage);
ws.on('close', onWsEnd);
ws.on('error', onWsEnd);
}

function enqueueDevice(socket) {
const ws = waitingClients.shift();
if (ws) pair(socket, ws);
else {
waitingDevices.push(socket);
socket.on('close', () => {
const idx = waitingDevices.indexOf(socket);
if (idx >= 0) waitingDevices.splice(idx, 1);
});
}
}

function enqueueClient(ws) {
const device = waitingDevices.shift();
if (device) pair(device, ws);
else {
waitingClients.push(ws);
ws.on('close', () => {
const idx = waitingClients.indexOf(ws);
if (idx >= 0) waitingClients.splice(idx, 1);
});
if (ws.readyState === ws.OPEN) {
ws.send(Buffer.from('\r\n[cloud-terminal] waiting for a device to connect...\r\n'));
}
}
}

/* ---- Device terminal listener (raw TCP) -------------------------------- */
const tcpServer = net.createServer((socket) => {
socket.setNoDelay(true);
console.log(`[tcp] device connected from ${socket.remoteAddress}:${socket.remotePort}`);
enqueueDevice(socket);
});
tcpServer.listen(opts.tcp, opts.host, () => {
console.log(`[tcp] listening on ${opts.host}:${opts.tcp} (dropbear -B target)`);
});

/* ---- Browser UI + WebSocket -------------------------------------------- */
const indexPath = path.join(__dirname, 'public', 'index.html');

const httpServer = http.createServer((req, res) => {
if (req.url === '/' || req.url === '/index.html') {
fs.readFile(indexPath, (err, body) => {
if (err) { res.writeHead(500); res.end('index.html missing'); return; }
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(body);
});
return;
}
res.writeHead(404);
res.end('not found');
});

const wss = new WebSocketServer({ server: httpServer, path: '/ws' });
wss.on('connection', (ws) => {
console.log('[ws] browser connected');
enqueueClient(ws);
});

httpServer.listen(opts.http, () => {
console.log(`[http] terminal UI on http://localhost:${opts.http}/`);
});
Loading