feat: finish the tailscale plugin, including SSH auth (B3)

This commit is contained in:
LukeGus committed 2026-09-23 09:41:20 -05:00
1 parent c77301577f
commit d2ff5fc78f
35 files changed
+1251 -526

No files matched your search

-49
View File
@@ -31,9 +31,6 @@ const VAULT_REQUIRED_MESSAGE =
const AUTH_FAILED_PATTERN = /All configured authentication methods failed/i;
/** How long a Tailscale check-mode login may sit waiting for approval. */
export const TAILSCALE_CHECK_TIMEOUT_MS = 1_800_000;
function issuedCertificateProvider(
type: "opkssh" | "stepca",
labelKey: string,
@@ -207,51 +204,6 @@ const vaultProvider: SshAuthProvider = {
},
};
const tailscaleProvider: SshAuthProvider = {
type: "tailscale",
pluginId: "core",
labelKey: "hosts.filterAuthTailscale",
connectOptions: (_host, purpose) =>
purpose === "terminal"
? {
tryKeyboard: false,
readyTimeout: TAILSCALE_CHECK_TIMEOUT_MS,
// The socket sits idle while a check-mode login is pending.
timeout: TAILSCALE_CHECK_TIMEOUT_MS,
}
: { tryKeyboard: false },
prepare: async (_config, _host, env) => {
env.log("info", "Using Tailscale SSH");
return { status: "ready" };
},
onAuthFailed: (host, _env, context) => {
const failed =
context.methodNotAvailable ||
AUTH_FAILED_PATTERN.test(context.error.message);
if (!failed) return;
// Tailscale documents the "+password" suffix for clients that mishandle a
// successful "none" reply. The password value is ignored.
if (context.retries === 0 && context.canRetry) {
return {
status: "retry",
message: "Retrying Tailscale SSH in forced password mode",
patch: {
username: `${host.username}+password`,
password: "termix",
tryKeyboard: false,
},
};
}
return {
status: "error",
code: "failed",
message: `Tailscale SSH authentication failed for user "${host.username}". Ensure Tailscale is running on the server, SSH is advertised (tailscale set --ssh), and your ACL policy grants the "${host.username}" user to your identity. If your Tailscale identity maps to a different Unix user, update the username on this host.`,
};
},
};
const WARPGATE_PATTERN = /warpgate\s+authentication/i;
let sshRegistered = false;
@@ -267,7 +219,6 @@ export function registerLegacySshAuthProviders(): void {
issuedCertificateProvider("stepca", "hosts.filterAuthStepca"),
);
registerSshAuthProvider(vaultProvider);
registerSshAuthProvider(tailscaleProvider);
// Warpgate is a flag on a host, not an auth type: it shows up as a
// keyboard-interactive round with a sign-in URL.
+28
View File
@@ -193,8 +193,36 @@ export interface SshAuthProvider {
env: SshAuthEnv,
context: SshAuthFailureContext,
) => SshAuthOutcome | undefined;
/**
* Claims an ssh2 "banner" event during the handshake, for a provider whose
* server holds the connection open pending an out-of-band step (Tailscale
* SSH check mode). A transport that can show this to a person sends
* `<type>_check_required` / `<type>_check_completed` over its socket;
* `details` is merged into that message. Returning nothing leaves the
* banner unhandled.
*/
onBanner?: (
banner: string,
host: SshConnectHost,
env: SshAuthEnv,
) => SshBannerDecision | undefined;
}
export type SshBannerDecision =
| {
/** Hold the connect timeout open and wait for the out-of-band step. */
action: "hold";
/** How long to wait before failing the connection. */
timeoutMs: number;
message: string;
details?: Record<string, unknown>;
}
| {
/** The out-of-band step finished; resume the normal connect timeout. */
action: "release";
details?: Record<string, unknown>;
};
export interface KeyboardInteractivePrompt {
prompt: string;
echo?: boolean;
-42
View File
@@ -1,42 +0,0 @@
// Tailscale SSH "check mode" sends its re-authentication prompt as an SSH auth
// banner during the "none" auth method, then long-polls its control plane while
// the connection stays open. The banner text itself comes from the control plane
// (it is not in the tailscaled source), so match on the login URL rather than the
// surrounding wording, which can change without notice.
const TAILSCALE_CHECK_URL = /https:\/\/login\.tailscale\.com\/a\/[A-Za-z0-9]+/;
const CHECK_COMPLETE = /authentication checked/i;
export interface TailscaleCheckBanner {
url: string;
message: string;
}
function stripCommentMarkers(banner: string): string {
return banner
.split(/\r?\n/)
.map((line) => line.replace(/^\s*#\s?/, "").trim())
.filter((line) => line.length > 0)
.join("\n")
.trim();
}
export function parseTailscaleCheckBanner(
banner: string,
): TailscaleCheckBanner | null {
if (!banner) return null;
const match = banner.match(TAILSCALE_CHECK_URL);
if (!match) return null;
return {
url: match[0],
message: stripCommentMarkers(banner),
};
}
export function isTailscaleCheckCompleteBanner(banner: string): boolean {
if (!banner) return false;
return CHECK_COMPLETE.test(banner);
}
+37 -38
View File
@@ -37,10 +37,6 @@ import type {
SshConnectHost,
} from "../connect/types.js";
import { JumpHostChainError } from "../jump-host-chain.js";
import {
parseTailscaleCheckBanner,
isTailscaleCheckCompleteBanner,
} from "../tailscale-check.js";
import {
sessionManager,
isMessageAllowedForParticipant,
@@ -130,10 +126,6 @@ interface TOTPResponseData {
const authManager = AuthManager.getInstance();
// Tailscale holds a check-mode connection open for up to 30 minutes while the
// user completes the browser login, so match that rather than timing out first.
const TAILSCALE_CHECK_TIMEOUT_MS = 1_800_000;
const userConnections = new Map<string, Set<WebSocket>>();
// Re-exported so the ssh-terminal plugin can publish it on the service
@@ -1632,11 +1624,12 @@ wss.on("connection", async (ws: WebSocket, req) => {
}
};
// Reassigned when Tailscale check mode starts, so the short connect timeout
// does not tear down a connection the server is deliberately holding open.
// Reassigned when an auth provider's onBanner holds the connection open
// (Tailscale SSH check mode), so the short connect timeout does not tear
// down a connection the server is deliberately waiting on.
let connectionTimeout = setTimeout(onConnectionTimeout, 120000);
let tailscaleCheckPending = false;
let bannerHoldPending = false;
let authRetries = 0;
let isAuthRetrying = false;
let connectProvider: SshAuthProvider | null = null;
@@ -1912,41 +1905,44 @@ wss.on("connection", async (ws: WebSocket, req) => {
}
sendLog("tcp", "info", `Connecting to ${ip} port ${port}`);
// Tailscale SSH check mode delivers its re-auth URL as an auth banner and then
// blocks for up to 30 minutes while the user logs in via the browser.
// A provider can hold the handshake open pending an out-of-band step
// (Tailscale SSH check mode delivers its re-auth URL as an auth banner,
// then blocks while the user logs in via the browser).
sshConn.on("banner", (banner: string) => {
const check = parseTailscaleCheckBanner(banner);
if (check) {
tailscaleCheckPending = true;
if (!connectProvider?.onBanner || !connectTarget || !connectEnv) return;
const decision = connectProvider.onBanner(
banner,
connectTarget,
connectEnv,
);
if (!decision) return;
const authType = connectProvider.type;
if (decision.action === "hold") {
bannerHoldPending = true;
clearTimeout(connectionTimeout);
connectionTimeout = setTimeout(
onConnectionTimeout,
TAILSCALE_CHECK_TIMEOUT_MS,
);
sendLog(
"auth",
"info",
`Tailscale SSH requires an additional check. Waiting for browser authentication at ${check.url}`,
);
connectionTimeout = setTimeout(onConnectionTimeout, decision.timeoutMs);
sendLog("auth", "info", decision.message);
ws.send(
JSON.stringify({
type: "tailscale_check_required",
type: `${authType}_check_required`,
hostId: id,
url: check.url,
message: check.message,
...decision.details,
}),
);
return;
}
if (tailscaleCheckPending && isTailscaleCheckCompleteBanner(banner)) {
tailscaleCheckPending = false;
sendLog("auth", "info", "Tailscale SSH check completed");
if (bannerHoldPending) {
bannerHoldPending = false;
sendLog("auth", "info", `${authType} check completed`);
ws.send(
JSON.stringify({ type: "tailscale_check_completed", hostId: id }),
JSON.stringify({
type: `${authType}_check_completed`,
hostId: id,
...decision.details,
}),
);
}
});
@@ -1955,10 +1951,13 @@ wss.on("connection", async (ws: WebSocket, req) => {
clearOnlineStatus ??= hostSessionStatus.register(serverHostId);
clearTimeout(connectionTimeout);
isAuthRetrying = false;
if (tailscaleCheckPending) {
tailscaleCheckPending = false;
if (bannerHoldPending) {
bannerHoldPending = false;
ws.send(
JSON.stringify({ type: "tailscale_check_completed", hostId: id }),
JSON.stringify({
type: `${connectProvider?.type}_check_completed`,
hostId: id,
}),
);
}
sshLogger.success("SSH connection established", {
@@ -2518,7 +2517,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
? connectProvider.onAuthFailed(connectTarget, connectEnv, {
error: err,
retries: authRetries,
canRetry: !connectConfig.sock && !tailscaleCheckPending,
canRetry: !connectConfig.sock && !bannerHoldPending,
methodNotAvailable: authMethodNotAvailable,
})
: undefined;
+5
View File
@@ -324,6 +324,11 @@ export function createPluginAuth({ manifest, bag, audit }: Deps): PluginAuth {
) as never;
},
};
if (provider.onBanner) {
const onBanner = provider.onBanner;
wrapped.onBanner = (banner, host, env) =>
onBanner(banner, host as PluginSshHost, env as never) as never;
}
ensureCoreSshAuthProviders();
const disposers = [registerSshAuthProvider(wrapped)];
if (provider.onKeyboardInteractive) {
@@ -94,7 +94,6 @@ import {
setSshAuthTypeOwnerSource,
} from "../../../hosts/connect/auth-provider-registry.js";
import { ensureCoreSshAuthProviders } from "../../../hosts/connect/core-providers.js";
import { TAILSCALE_CHECK_TIMEOUT_MS } from "../../../auth/legacy-providers.js";
import type { SshConnectHost } from "../../../hosts/connect/types.js";
const plainKey = ssh2.utils.generateKeyPairSync("ed25519").private;
@@ -325,48 +324,8 @@ describe("buildConnectConfig per auth type", () => {
expect(mocks.getVaultCert).toHaveBeenLastCalledWith("user-1", 3);
});
it("tailscale turns off keyboard-interactive, with long timeouts in the terminal", async () => {
const terminal = await build(host({ authType: "tailscale" }));
expect(terminal.config).toMatchObject({
tryKeyboard: false,
readyTimeout: TAILSCALE_CHECK_TIMEOUT_MS,
timeout: TAILSCALE_CHECK_TIMEOUT_MS,
});
const fleet = await build(host({ authType: "tailscale" }), "fleet");
expect(fleet.config.tryKeyboard).toBe(false);
expect(fleet.config.readyTimeout).toBe(30000);
});
it("tailscale retries once with +password, then reports the failure", async () => {
ensureCoreSshAuthProviders();
const provider = getSshAuthProvider("tailscale")!;
const env = {} as never;
const failure = new Error("All configured authentication methods failed");
const first = provider.onAuthFailed!(host({ authType: "tailscale" }), env, {
error: failure,
retries: 0,
canRetry: true,
methodNotAvailable: false,
});
expect(first).toMatchObject({
status: "retry",
patch: { username: "root+password", tryKeyboard: false },
});
const second = provider.onAuthFailed!(
host({ authType: "tailscale" }),
env,
{
error: failure,
retries: 1,
canRetry: true,
methodNotAvailable: false,
},
);
expect(second).toMatchObject({ status: "error" });
});
// Tailscale is a plugin-registered SSH auth provider now: see
// plugins/tailscale/tests/backend/ssh-auth-provider.test.ts.
it("a type nobody provides names the plugin that would", async () => {
setSshAuthTypeOwnerSource(() => [
+3
View File
@@ -25,6 +25,7 @@ import { knownPluginIds } from "./plugin-store";
import { tabsApi, useShellHosts } from "./shell-bridge";
import { invokeAction } from "@/shell/action-registry";
import { useSshAuthProviders } from "@/hooks/useSshAuthProviders";
import { useActionSlot } from "@/hooks/use-action-slot";
/**
* Core permission groups, mirroring RESERVED_PERMISSION_PREFIXES in the SDK
@@ -270,6 +271,8 @@ export const pluginHostBridge: PluginHostBridge = {
})),
};
},
useSlotContributions: (slotId) => useActionSlot(slotId),
};
/** Installs the bridge. Idempotent. */