fix: patch tailscale to support a custom DERP port

This commit is contained in:
Aarnav Tale
2026-04-07 00:23:34 -04:00
parent 33f7bbb0cf
commit 44dffeaff0
8 changed files with 146 additions and 7 deletions
+1
View File
@@ -13,3 +13,4 @@ node_modules
/docs/.vitepress/dist/
/docs/.vitepress/cache/
/.direnv
/vendor
+16 -4
View File
@@ -1,11 +1,15 @@
# 0.7.0-beta.1 (March 27, 2026)
# 0.7.0-beta.2 (April 7, 2026)
> This is a beta release. Please report any issues you encounter.
- **Migrated all UI components from react-aria/react-stately to @base-ui-components/react.**
- Removed `react-aria`, `react-stately`, and `tailwindcss-react-aria-components` as dependencies.
- **Rearchitected the Headplane Agent** from a long-running stdin/stdout daemon to a one-shot sync model (closes [#350](https://github.com/tale/headplane/issues/350), closes [#455](https://github.com/tale/headplane/issues/455)).
- The Go binary connects to the Tailnet, fetches all peer hostinfo as JSON, and exits.
- **Replaced `openid-client` with a clean-room OIDC implementation.**
- Removed the `openid-client` dependency entirely.
- Fixed `client_secret_basic` auth method not working with Google SSO and other providers (closes [#493](https://github.com/tale/headplane/issues/493)).
- Fixed OIDC connector initialization failures on beta.1 (closes [#516](https://github.com/tale/headplane/issues/516)).
- **Rearchitected the Headplane Agent** with a new sync model (closes [#350](https://github.com/tale/headplane/issues/350), closes [#455](https://github.com/tale/headplane/issues/455)).
- The Go binary connects to the Tailnet and fetches all peer hostinfo as JSON.
- The Node.js manager auto-generates ephemeral tag-only pre-auth keys (requires Headscale 0.28+).
- Deprecated `integration.agent.pre_authkey` and `integration.agent.cache_path` config fields.
- Added `integration.agent.executable_path` config field.
@@ -16,15 +20,23 @@
- Added an agent status page at `/settings/agent` showing sync status, node count, errors, and a "Sync Now" button.
- Added additional machine list filters for user, tag, status, and route (via [#507](https://github.com/tale/headplane/pull/507), closes [#506](https://github.com/tale/headplane/issues/506)).
- Added self-service pre-auth key creation for auditor role users (via [#478](https://github.com/tale/headplane/pull/478), closes [#453](https://github.com/tale/headplane/issues/453)).
- Fetch OIDC profile pictures server-side when the URL requires authentication (via [#510](https://github.com/tale/headplane/pull/510), closes [#326](https://github.com/tale/headplane/issues/326)).
- Store OIDC profile pictures in the database to prevent cookie header overload (closes [#326](https://github.com/tale/headplane/issues/326), via [#510](https://github.com/tale/headplane/pull/510)).
- Fixed pre-auth key expiration on Headscale 0.28+ (closes [#519](https://github.com/tale/headplane/issues/519)).
- Fixed OIDC subject matching for providers that use special characters in user IDs (e.g. Auth0 `github|12345`) (closes [#428](https://github.com/tale/headplane/issues/428)).
- Fixed `headscale.api_key` not being used consistently across all code paths.
- Fixed intermittent SSR crash on the Access Control page caused by client-only CodeMirror imports.
- Fixed first user not being assigned the owner role on OIDC login (via [#480](https://github.com/tale/headplane/pull/480), closes [#266](https://github.com/tale/headplane/issues/266)).
- Fixed login errors throwing an unexpected server error instead of showing form validation (via [#475](https://github.com/tale/headplane/pull/475), closes [#474](https://github.com/tale/headplane/issues/474)).
- Fixed agent HostInfo not refreshing periodically using `cache_ttl` (via [#477](https://github.com/tale/headplane/pull/477), closes [#427](https://github.com/tale/headplane/issues/427)).
- Fixed agent working directory being wiped on restart.
- Fixed a race condition where the SSE controller could be used after being closed.
- Fixed WebSSH dropping DERP port information on non-standard ports (e.g. `:8443`), which caused connections to fail (closes [#515](https://github.com/tale/headplane/issues/515)).
- Fixed WebSSH WASM prefix paths for correct asset loading (closes [#386](https://github.com/tale/headplane/issues/386)).
- Fixed Dockerfile WASM copy paths.
- Fixed CodeMirror version mismatch override in the ACL editor.
- Fixed cookie secret generation using incorrect byte length (via [#501](https://github.com/tale/headplane/pull/501)).
- Fixed OIDC configuration error troubleshooting link (via [#518](https://github.com/tale/headplane/pull/518), closes [#517](https://github.com/tale/headplane/issues/517)).
- Fixed deprecated Nix package attributes (via [#521](https://github.com/tale/headplane/pull/521)).
- Detect unsupported Docker API versions early with a clear error message (via [#497](https://github.com/tale/headplane/pull/497)).
- Updated NixOS module options: removed deprecated agent fields, added `headscale.api_key_path` and `integration.agent.executable_path`.
+1
View File
@@ -2,6 +2,7 @@ FROM --platform=$BUILDPLATFORM golang:1.25.1 AS go-base
WORKDIR /run
COPY go.mod go.sum build.sh ./
COPY patches/ ./patches/
RUN go mod download
COPY cmd/ ./cmd/
+15 -1
View File
@@ -137,7 +137,21 @@ build_wasm() {
cat "$(go env GOROOT)/lib/wasm/wasm_exec.js" >> \
"$(dirname "$WASM_OUTPUT")/wasm_exec.js"
GOOS=js GOARCH=wasm go build -o "$WASM_OUTPUT" ./cmd/hp_ssh
# Vendor dependencies and apply the DERP port patch.
# Tailscale's derphttp WebSocket URL builder ignores DERPPort,
# which breaks WASM connections to non-443 DERP servers.
echo "==> Vendoring Go dependencies for WASM patch"
go mod vendor
DERP_PATCH="$ROOT_DIR/patches/tailscale-derp-port.patch"
if [ -f "$DERP_PATCH" ]; then
echo "==> Applying DERP port patch"
patch -d vendor/tailscale.com -p1 < "$DERP_PATCH" || \
die "failed to apply DERP port patch"
fi
GOOS=js GOARCH=wasm go build -mod=vendor -o "$WASM_OUTPUT" ./cmd/hp_ssh
rm -rf vendor
}
build_app() {
+2 -1
View File
@@ -45,7 +45,8 @@ export default defineConfig({
text: "Features",
items: [
{ text: "Single Sign-On (SSO)", link: "/features/sso" },
{ text: "Headplane Agent / SSH", link: "/features/agent" },
{ text: "Headplane Agent", link: "/features/agent" },
{ text: "WebSSH", link: "/features/ssh" },
],
},
{
+73
View File
@@ -0,0 +1,73 @@
---
title: WebSSH
description: Open SSH sessions to your Tailnet nodes directly from the browser.
---
# WebSSH
<figure>
<img src="../assets/ssh.png" />
<figcaption>SSH access via the browser</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.
## 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`.
## How It Works
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.
The ephemeral node is automatically cleaned up after the session ends.
## DERP Servers on Non-Standard Ports
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.
::: 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.
:::
## Troubleshooting
### "WebSSH is not configured in this build"
The WASM assets are missing. Rebuild with `./build.sh --wasm` or ensure your
Docker image was built with the `--wasm` flag.
### "Only OAuth users are allowed to use WebSSH"
WebSSH requires OIDC authentication to generate pre-auth keys tied to a
Headscale user. API key logins do not have an associated Headscale user
identity.
### Connection hangs or fails to reach the node
- 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).
- Check the browser console for WASM errors or DERP connection failures.
+8 -1
View File
@@ -22,7 +22,14 @@ in
buildPhase = ''
export GOOS=js
export GOARCH=wasm
go build -o hp_ssh.wasm ./cmd/hp_ssh
# Patch Tailscale's derphttp to include DERPPort in WebSocket URLs.
# Without this, DERP servers on non-443 ports fail in WASM builds.
if [ -f patches/tailscale-derp-port.patch ]; then
patch -d vendor -p1 < patches/tailscale-derp-port.patch
fi
go build -mod=vendor -o hp_ssh.wasm ./cmd/hp_ssh
'';
installPhase = ''
+30
View File
@@ -0,0 +1,30 @@
Fix DERP WebSocket URL to include non-standard ports.
Tailscale's derphttp client ignores DERPPort when building WebSocket
URLs, causing connections to fail when DERP servers run on non-443
ports (e.g. :8443). The TCP dial path correctly handles DERPPort
but the WebSocket path (used in WASM/browser builds) does not.
--- a/derp/derphttp/derphttp_client.go
+++ b/derp/derphttp/derphttp_client.go
@@ -279,10 +279,19 @@
return c.url.String()
}
proto := "https"
+ var port string
if debugUseDERPHTTP() {
proto = "http"
+ port = "3340"
}
- return fmt.Sprintf("%s://%s/derp", proto, node.HostName)
+ if node != nil && node.DERPPort != 0 {
+ port = fmt.Sprint(node.DERPPort)
+ }
+ host := node.HostName
+ if port != "" {
+ host = net.JoinHostPort(node.HostName, port)
+ }
+ return fmt.Sprintf("%s://%s/derp", proto, host)
}
// AddressFamilySelector decides whether IPv6 is preferred for