mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
feat: finish the tailscale plugin, including SSH auth (B3)
This commit is contained in:
1 parent
c77301577f
commit
d2ff5fc78f
35 files changed
+1251
-526
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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(() => [
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in new issue
Block a user