mirror of
https://github.com/tale/headplane.git
synced 2026-07-26 15:58:14 +00:00
feat: rebuild browser ssh from the ground up
This commit is contained in:
+196
-42
@@ -1,73 +1,227 @@
|
||||
---
|
||||
title: WebSSH
|
||||
title: Browser SSH
|
||||
description: Open SSH sessions to your Tailnet nodes directly from the browser.
|
||||
---
|
||||
|
||||
# WebSSH
|
||||
# Browser SSH
|
||||
|
||||
<figure>
|
||||
<img src="../assets/ssh.png" />
|
||||
<figcaption>SSH access via the browser</figcaption>
|
||||
<img src="../assets/ssh-btop.png" style="width: 100%;" />
|
||||
<figcaption><code>btop</code> running over browser SSH</figcaption>
|
||||
</figure>
|
||||
|
||||
WebSSH lets you open an SSH session to any node directly from the browser. It
|
||||
uses a Go-based WASM shim that creates an ephemeral Tailscale node in the
|
||||
browser and connects to the target node over the Tailnet.
|
||||
Browser SSH allows a user to open an SSH session to any accesible node in the
|
||||
Tailnet directly from the browser. It spins up an ephemeral Tailscale node that
|
||||
joins the tailnet for the duration of the SSH session.
|
||||
|
||||
<figure>
|
||||
<img src="../assets/ssh-fastfetch.png" style="width: 100%;" />
|
||||
<figcaption><code>fastfetch</code> with Nerd Font icons</figcaption>
|
||||
</figure>
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Headscale 0.28 or newer** is required.
|
||||
- Target nodes must have **Tailscale SSH** enabled.
|
||||
- Users must be authenticated via **OIDC** (API key logins cannot use WebSSH).
|
||||
- The **Headplane Agent** must be
|
||||
[enabled and configured](/features/agent) so that ephemeral node cleanup
|
||||
works correctly.
|
||||
- The WASM assets (`hp_ssh.wasm` and `wasm_exec.js`) must be present in the
|
||||
build. These are built automatically by `./build.sh --wasm`.
|
||||
- Target nodes must have **Tailscale SSH** enabled (`tailscale up --ssh`).
|
||||
- Users must be logged-in via **OIDC** (API key logins cannot use browser SSH).
|
||||
- The **Headplane Agent** must be [enabled and configured](/features/agent).
|
||||
|
||||
## How It Works
|
||||
|
||||
:::tip
|
||||
While we use Ghostty (via [restty](https://restty.dev)) to render the terminal,
|
||||
the SSH connection is opened with a `TERM` value of `xterm-256color` for maximum
|
||||
compatibility. Nerd Font glyphs are supported out of the box — the terminal
|
||||
ships with a self-hosted JetBrains Mono Nerd Font.
|
||||
:::
|
||||
|
||||
When a user opens an SSH session from the UI, the browser:
|
||||
|
||||
1. Loads a Go WASM binary that implements a minimal Tailscale node.
|
||||
2. Authenticates to the Tailnet using an ephemeral pre-auth key generated
|
||||
server-side.
|
||||
3. Connects to the target node over the Tailnet using DERP relay servers.
|
||||
4. Opens an SSH session and renders it in an [xterm.js](https://xtermjs.org)
|
||||
terminal.
|
||||
1. Loads a WASM module that runs a minimal Tailscale node using userspace
|
||||
WireGuard. This node will connect to the tailnet via a pre-auth key.
|
||||
2. Opens an SSH session to the target node's Tailscale IP address over the
|
||||
tunnel and passes it to the browser.
|
||||
3. Using [restty](https://restty.dev) (a Ghostty-based WASM terminal emulator),
|
||||
the browser renders a full-featured terminal and proxies the SSH session
|
||||
to it.
|
||||
|
||||
The ephemeral node is automatically cleaned up after the session ends.
|
||||
## Reverse Proxy Configuration
|
||||
|
||||
## DERP Servers on Non-Standard Ports
|
||||
Browser SSH requires that the browser can reach both **Headplane** and
|
||||
**Headscale** directly. If either is behind a reverse proxy, the proxy must be
|
||||
configured to support WebSocket connections — this is how the WASM node
|
||||
communicates with DERP relay servers.
|
||||
|
||||
In the browser, Tailscale connects to DERP relay servers via WebSockets. If
|
||||
your DERP servers run on a non-standard port (e.g. `:8443` instead of `:443`),
|
||||
Headplane includes a patch to Tailscale's DERP client that preserves the port
|
||||
in WebSocket URLs. This patch is applied automatically during the WASM build.
|
||||
### Required Headers
|
||||
|
||||
::: warning
|
||||
If you are building the WASM module manually (outside of `build.sh`), make sure
|
||||
to apply `patches/tailscale-derp-port.patch` to the vendored Tailscale source
|
||||
before compiling. See the `build_wasm()` function in `build.sh` for reference.
|
||||
:::
|
||||
Your reverse proxy must forward these headers for Headscale's DERP endpoint:
|
||||
|
||||
| Header | Value |
|
||||
| ------------------------ | --------------------------------------- |
|
||||
| `Upgrade` | `websocket` |
|
||||
| `Connection` | `Upgrade` |
|
||||
| `Sec-WebSocket-Protocol` | forwarded as-is (Tailscale uses `derp`) |
|
||||
|
||||
### CORS Headers
|
||||
|
||||
Headscale must be accessible from the origin where Headplane is served. If
|
||||
Headplane and Headscale are on different origins (different hosts or ports),
|
||||
your reverse proxy must add CORS headers to Headscale responses:
|
||||
|
||||
| Header | Value |
|
||||
| ------------------------------ | ----------------------------------------------- |
|
||||
| `Access-Control-Allow-Origin` | The origin of your Headplane instance |
|
||||
| `Access-Control-Allow-Methods` | `GET, POST, OPTIONS` |
|
||||
| `Access-Control-Allow-Headers` | `Content-Type, Upgrade, Sec-WebSocket-Protocol` |
|
||||
|
||||
### Example: Caddy
|
||||
|
||||
```caddyfile
|
||||
# Headscale
|
||||
hs.example.com {
|
||||
reverse_proxy localhost:8080
|
||||
|
||||
# If Headplane is on a different origin:
|
||||
header Access-Control-Allow-Origin "https://headplane.example.com"
|
||||
header Access-Control-Allow-Methods "GET, POST, OPTIONS"
|
||||
header Access-Control-Allow-Headers "Content-Type, Upgrade, Sec-WebSocket-Protocol"
|
||||
}
|
||||
```
|
||||
|
||||
Caddy handles WebSocket upgrades automatically — no extra configuration needed.
|
||||
|
||||
### Example: nginx
|
||||
|
||||
```nginx
|
||||
# Headscale
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name hs.example.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
|
||||
# If Headplane is on a different origin:
|
||||
add_header Access-Control-Allow-Origin "https://headplane.example.com" always;
|
||||
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
|
||||
add_header Access-Control-Allow-Headers "Content-Type, Upgrade, Sec-WebSocket-Protocol" always;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Same-Origin Setup
|
||||
|
||||
If Headplane and Headscale share the same origin (e.g. a single reverse proxy
|
||||
routing `/admin` to Headplane and everything else to Headscale), CORS headers
|
||||
are not needed. WebSocket upgrade forwarding is still required.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "WebSSH is not configured in this build"
|
||||
### SSH Not Available
|
||||
|
||||
The WASM assets are missing. Rebuild with `./build.sh --wasm` or ensure your
|
||||
Docker image was built with the `--wasm` flag.
|
||||
**Error:** "This version of Headplane was not built with browser SSH support."
|
||||
|
||||
### "Only OAuth users are allowed to use WebSSH"
|
||||
The WASM assets (`hp_ssh.wasm` and `wasm_exec.js`) are missing. Rebuild with
|
||||
`./build.sh --wasm` or ensure your Docker image was built with the `--wasm`
|
||||
flag.
|
||||
|
||||
WebSSH requires OIDC authentication to generate pre-auth keys tied to a
|
||||
### Agent Required
|
||||
|
||||
**Error:** "Browser SSH is only available when the Headplane agent integration
|
||||
is enabled."
|
||||
|
||||
The Headplane Agent is not enabled. Browser SSH depends on the agent for
|
||||
Tailnet connectivity and ephemeral node cleanup. See the
|
||||
[Agent documentation](/features/agent) for setup instructions.
|
||||
|
||||
### OIDC Required
|
||||
|
||||
**Error:** "Browser SSH is only available when OIDC authentication is enabled."
|
||||
|
||||
Browser SSH requires OIDC authentication to generate pre-auth keys tied to a
|
||||
Headscale user. API key logins do not have an associated Headscale user
|
||||
identity.
|
||||
identity. Log in via your configured OIDC provider instead.
|
||||
|
||||
### Connection hangs or fails to reach the node
|
||||
### User Not Linked
|
||||
|
||||
- Verify that the target node has Tailscale SSH enabled.
|
||||
- If using custom DERP servers on non-standard ports, ensure you are running
|
||||
a build that includes the DERP port patch (any build from `build.sh` or
|
||||
Docker includes it automatically).
|
||||
**Error:** "You'll need to link your user account to a Headscale user before
|
||||
you can use Browser SSH."
|
||||
|
||||
Your OIDC account does not match any user in Headscale. You must authenticate
|
||||
with Headscale at least once before using Browser SSH, so that a Headscale
|
||||
user is created and linked to your OIDC identity.
|
||||
|
||||
### Node Not Found
|
||||
|
||||
**Error:** "No node found with hostname ..."
|
||||
|
||||
The node name in the URL does not match any node registered in Headscale. The
|
||||
node may have been renamed or removed. Navigate back to the machines list and
|
||||
try again.
|
||||
|
||||
### Node Offline
|
||||
|
||||
Headplane checks whether the target node is connected to the Tailnet before
|
||||
attempting an SSH session. If the node is offline, you'll see an error page
|
||||
with a **Retry Connection** button. Ensure the node is running and connected
|
||||
to Headscale, then retry.
|
||||
|
||||
### Connection fails with EOF or hangs
|
||||
|
||||
- **Check `server_url` in your Headscale config.** If Headscale runs on a
|
||||
non-standard port, `server_url` must include it (e.g.
|
||||
`https://hs.example.com:8443`). The embedded DERP server derives its
|
||||
advertised port from this value. Do **not** put the DERP port in Headplane's
|
||||
`headscale.public_url` — that setting is used for display in the UI and
|
||||
changing it will break registration commands and auth key instructions.
|
||||
- **Verify reverse proxy WebSocket support.** The proxy in front of Headscale
|
||||
must forward `Upgrade: websocket` headers. Without this, the DERP connection
|
||||
will fail immediately.
|
||||
- **Check CORS if on different origins.** Open the browser console and look for
|
||||
CORS errors. If Headplane and Headscale are on different origins, CORS headers
|
||||
must be configured on Headscale's proxy.
|
||||
- Verify that the target node has Tailscale SSH enabled (`tailscale up --ssh`).
|
||||
- Check the browser console for WASM errors or DERP connection failures.
|
||||
|
||||
### "failed to look up local user \*"
|
||||
|
||||
This error appears in the terminal when Headscale SSH ACLs use
|
||||
`"users": ["*"]`, which some Tailscale versions interpret as a literal
|
||||
username rather than a wildcard. To fix this, change your ACL SSH rules to
|
||||
use `"autogroup:nonroot"` or explicit usernames instead:
|
||||
|
||||
```jsonc
|
||||
// Before (broken on some versions)
|
||||
{ "action": "accept", "src": ["autogroup:member"], "dst": ["autogroup:self"], "users": ["*"] }
|
||||
|
||||
// After (recommended)
|
||||
{ "action": "accept", "src": ["autogroup:member"], "dst": ["autogroup:self"], "users": ["autogroup:nonroot"] }
|
||||
```
|
||||
|
||||
### Terminal opens but input doesn't work
|
||||
|
||||
- Ensure the target node's Tailscale SSH ACLs permit the user. The SSH
|
||||
connection succeeds at the transport level but the session may be rejected
|
||||
by the node's SSH policy.
|
||||
- Check that the username you entered is a valid Linux user on the target
|
||||
node. If the user doesn't exist, the SSH session will appear to connect
|
||||
but immediately fail.
|
||||
|
||||
### "SSH error: ssh: handshake failed: ssh: no common algorithm"
|
||||
|
||||
The target node's SSH server doesn't support any of the algorithms offered
|
||||
by the Go SSH client. This usually means the target node is running a very
|
||||
old or very new version of OpenSSH with non-default algorithm configuration.
|
||||
Updating Tailscale on the target node typically resolves this.
|
||||
|
||||
### SSH session connects but immediately disconnects
|
||||
|
||||
- The target node may not have an SSH server installed or running. Tailscale
|
||||
SSH (`tailscale up --ssh`) runs its own SSH server, but if Tailscale SSH
|
||||
is not enabled, the node needs a standard SSH server (e.g. `openssh-server`)
|
||||
listening on port 22.
|
||||
- The node may have a firewall blocking port 22 even for Tailnet connections.
|
||||
|
||||
Reference in New Issue
Block a user