headscale: an ssh console for when the api cannot answer

Every diagnostic in this app goes through headscale's API, which is exactly the
channel that is gone when you most need it — headscale crashed, the tailnet is
down, the logs are the only evidence. This adds the escape hatch: a per-server
SSH address and a Console section that opens a shell on that machine.

Deliberately thin. `headscale_servers.ssh_host` stores where to point ssh and
nothing else: no password, no key, no port. The console runs plain `ssh <host>`
in the same pty every other terminal panel uses, authenticating with whatever
~/.ssh on this box already knows. There is no credential here to protect and
this file must never grow one.

The address is NOT derived from the control-server URL and the form warns when
you type the same host into both — a console that resolves through the name
headscale serves goes down with it, which is the one thing it exists to survive.
It is also not validated on save, for the same reason: refusing to store the
escape hatch because the machine is unreachable is precisely backwards. Reaching
it is a separate, explicit Test connection button (BatchMode=yes, so a key that
needs a passphrase fails visibly instead of hanging on a prompt).

The host is validated to a conservative charset rather than quoted, because it
is typed into an interactive shell — rejecting `1.2.3.4; rm -rf /` while the
form is still open beats letting it survive to the shell as someone else's
problem. A jump host or an odd port belongs in ~/.ssh/config as a Host alias,
which the field accepts by name.

Also fixes a latent bug this would have hit immediately: TerminalView's
`initialInput` guard is scoped to a mount, so a remount typed the command again
into a live shell. A `replay` frame proves the session already ran it, so treat
it as sent. Harmless for `ls`; for the console it meant an ssh nested inside the
ssh you were already in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 14:10:33 +00:00
co-authored by Claude Opus 5
parent 2f92f9b15c
commit 208f26ad89
12 changed files with 365 additions and 11 deletions
@@ -19,6 +19,7 @@ export type HeadscaleServer = {
name: string;
url: string;
version: string | null;
sshHost: string | null;
isActive: boolean;
lastSeenAt: Date | null;
createdAt: Date;
@@ -31,6 +32,7 @@ const serverCols = {
name: headscaleServers.name,
url: headscaleServers.url,
version: headscaleServers.version,
sshHost: headscaleServers.sshHost,
isActive: headscaleServers.isActive,
lastSeenAt: headscaleServers.lastSeenAt,
createdAt: headscaleServers.createdAt,
@@ -71,13 +73,15 @@ type CreateHeadscaleServerParams = {
url: string;
apiKey: string;
version: string | null;
/** Optional SSH target for the console. Null when the owner hasn't set one. */
sshHost: string | null;
/** Make it the active server. True for the first registration, so the UI is never left with none selected. */
activate: boolean;
};
/** Register a server. The key is encrypted before write; the returned row carries no key. */
export async function createHeadscaleServer(params: CreateHeadscaleServerParams): Promise<HeadscaleServer> {
const { userId, name, url, apiKey, version, activate } = params;
const { userId, name, url, apiKey, version, sshHost, activate } = params;
return db.transaction(async (tx) => {
if (activate) {
await tx
@@ -93,6 +97,7 @@ export async function createHeadscaleServer(params: CreateHeadscaleServerParams)
url,
apiKey: encryptSecret(apiKey),
version,
sshHost,
isActive: activate,
lastSeenAt: version ? new Date() : null,
})
@@ -101,7 +106,9 @@ export async function createHeadscaleServer(params: CreateHeadscaleServerParams)
});
}
type UpdateHeadscaleServerParams = { name?: string; url?: string; apiKey?: string };
// `sshHost: null` clears the console target; omitting the field leaves it alone. The two must stay
// distinguishable, which is why this is `string | null` and not `string`.
type UpdateHeadscaleServerParams = { name?: string; url?: string; apiKey?: string; sshHost?: string | null };
/** Edit a registration. Omitted fields are left alone; a supplied key is re-encrypted. */
export async function updateHeadscaleServer(
@@ -113,6 +120,7 @@ export async function updateHeadscaleServer(
if (params.name !== undefined) set.name = params.name;
if (params.url !== undefined) set.url = params.url;
if (params.apiKey !== undefined) set.apiKey = encryptSecret(params.apiKey);
if (params.sshHost !== undefined) set.sshHost = params.sshHost;
const [row] = await db
.update(headscaleServers)
@@ -30,6 +30,12 @@ export const headscaleServers = pgTable(
// Last version seen from the server's unauthenticated GET /version. Null until first probed; the
// literal 'dev' when the server was built without VCS info, which is unknown rather than too-old.
version: text('version'),
// Where to SSH for a shell on the box running this Headscale — the last-resort escape hatch for when the
// API cannot answer (headscale is down, the tailnet is down, the logs are the only evidence). Deliberately
// NOT derived from `url`: the whole point is to reach the machine when the control plane's own hostname
// stops resolving, so this is usually a raw IP on a different path. No port, user or key material — the
// connection uses whatever ~/.ssh already knows, so there is no credential here to protect.
sshHost: text('ssh_host'),
isActive: boolean('is_active').notNull().default(false),
// Last successful probe, so the UI can distinguish "never reached" from "was reachable, now isn't".
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }),