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
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseTailscaleData } from "../../src/backend/host-metrics-manager.js";
|
||||
|
||||
describe("parseTailscaleData", () => {
|
||||
it("reports not installed when the probe finds no binary", () => {
|
||||
const result = parseTailscaleData("ts_installed=0\n");
|
||||
expect(result.installed).toBe(false);
|
||||
expect(result.running).toBe(false);
|
||||
});
|
||||
|
||||
it("parses a running tailscale status", () => {
|
||||
const status = JSON.stringify({
|
||||
BackendState: "Running",
|
||||
Self: { HostName: "my-server", TailscaleIPs: ["100.1.2.3"] },
|
||||
Peer: {
|
||||
abc: {
|
||||
HostName: "peer-1",
|
||||
TailscaleIPs: ["100.1.2.4"],
|
||||
Online: true,
|
||||
ExitNode: false,
|
||||
},
|
||||
},
|
||||
CurrentExitNode: "",
|
||||
});
|
||||
const result = parseTailscaleData(`ts_installed=1\n${status}`);
|
||||
|
||||
expect(result.installed).toBe(true);
|
||||
expect(result.running).toBe(true);
|
||||
expect(result.hostname).toBe("my-server");
|
||||
expect(result.tailscaleIPs).toEqual(["100.1.2.3"]);
|
||||
expect(result.peers).toHaveLength(1);
|
||||
expect(result.peers[0]).toMatchObject({
|
||||
hostname: "peer-1",
|
||||
online: true,
|
||||
});
|
||||
expect(result.exitNodeInUse).toBe(false);
|
||||
});
|
||||
|
||||
it("reports installed but not running on malformed JSON", () => {
|
||||
const result = parseTailscaleData("ts_installed=1\nnot json");
|
||||
expect(result.installed).toBe(true);
|
||||
expect(result.running).toBe(false);
|
||||
});
|
||||
|
||||
it("detects an active exit node", () => {
|
||||
const status = JSON.stringify({
|
||||
BackendState: "Running",
|
||||
Self: {},
|
||||
Peer: {},
|
||||
CurrentExitNode: "abc123",
|
||||
});
|
||||
const result = parseTailscaleData(`ts_installed=1\n${status}`);
|
||||
expect(result.exitNodeInUse).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -43,12 +43,12 @@ describe("tailscale permissions", () => {
|
||||
}
|
||||
});
|
||||
|
||||
// No wildcard covered tailscale in SYSTEM_ROLE_DEFAULTS, so nothing is
|
||||
// granted by default and an admin has to opt in.
|
||||
it("grants nothing by default", () => {
|
||||
for (const permission of manifest.manifest?.contributes?.permissions ??
|
||||
[]) {
|
||||
expect(permission.defaultRoles).toBeUndefined();
|
||||
}
|
||||
// The device list is a full tailnet inventory, so only admins get it by
|
||||
// default; everyone else needs an explicit grant.
|
||||
it("grants devices.view to admins by default", () => {
|
||||
const devicesView = manifest.manifest?.contributes?.permissions?.find(
|
||||
(permission) => permission.name === "devices.view",
|
||||
);
|
||||
expect(devicesView?.defaultRoles).toEqual(["admin"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { createFakeContext } from "@termix/plugin-sdk/testing";
|
||||
import { registerTailscaleSshAuth } from "../../src/backend/ssh-auth-provider.js";
|
||||
import { TAILSCALE_CHECK_TIMEOUT_MS } from "../../src/backend/tailscale-check.js";
|
||||
|
||||
function register() {
|
||||
const { ctx, auth } = createFakeContext({ pluginId: "tailscale" });
|
||||
registerTailscaleSshAuth(ctx);
|
||||
return auth.sshAuthProviders.find(
|
||||
(provider) => provider.type === "tailscale",
|
||||
)!;
|
||||
}
|
||||
|
||||
describe("tailscale SSH auth provider", () => {
|
||||
it("registers as type tailscale", () => {
|
||||
expect(register()).toBeTruthy();
|
||||
});
|
||||
|
||||
it("turns off keyboard-interactive, with a long timeout for the terminal", () => {
|
||||
const provider = register();
|
||||
const terminal = provider.connectOptions?.(
|
||||
{ id: 1, ip: "100.1.1.1", port: 22, username: "root" },
|
||||
"terminal",
|
||||
);
|
||||
expect(terminal).toMatchObject({
|
||||
tryKeyboard: false,
|
||||
readyTimeout: TAILSCALE_CHECK_TIMEOUT_MS,
|
||||
timeout: TAILSCALE_CHECK_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
const fleet = provider.connectOptions?.(
|
||||
{ id: 1, ip: "100.1.1.1", port: 22, username: "root" },
|
||||
"fleet",
|
||||
);
|
||||
expect(fleet).toEqual({ tryKeyboard: false });
|
||||
});
|
||||
|
||||
it("prepare always reports ready", async () => {
|
||||
const provider = register();
|
||||
const outcome = await provider.prepare(
|
||||
{},
|
||||
{ id: 1, ip: "100.1.1.1", port: 22, username: "root" },
|
||||
{
|
||||
client: {},
|
||||
userId: "u1",
|
||||
hostId: 1,
|
||||
purpose: "terminal",
|
||||
interactive: true,
|
||||
log: () => {},
|
||||
},
|
||||
);
|
||||
expect(outcome).toEqual({ status: "ready" });
|
||||
});
|
||||
|
||||
describe("onBanner", () => {
|
||||
it("holds the connection when the banner carries a check URL", () => {
|
||||
const provider = register();
|
||||
const decision = provider.onBanner?.(
|
||||
"# Tailscale SSH requires an additional check.\n# To authenticate, visit: https://login.tailscale.com/a/abc123\n",
|
||||
{ id: 1, ip: "100.1.1.1", port: 22, username: "root" },
|
||||
{
|
||||
client: {},
|
||||
userId: "u1",
|
||||
hostId: 1,
|
||||
purpose: "terminal",
|
||||
interactive: true,
|
||||
log: () => {},
|
||||
},
|
||||
);
|
||||
expect(decision).toMatchObject({
|
||||
action: "hold",
|
||||
details: { url: "https://login.tailscale.com/a/abc123" },
|
||||
});
|
||||
});
|
||||
|
||||
it("releases the hold on the completion banner", () => {
|
||||
const provider = register();
|
||||
const decision = provider.onBanner?.(
|
||||
"Authentication checked with Tailscale SSH.",
|
||||
{ id: 1, ip: "100.1.1.1", port: 22, username: "root" },
|
||||
{
|
||||
client: {},
|
||||
userId: "u1",
|
||||
hostId: 1,
|
||||
purpose: "terminal",
|
||||
interactive: true,
|
||||
log: () => {},
|
||||
},
|
||||
);
|
||||
expect(decision).toEqual({ action: "release" });
|
||||
});
|
||||
|
||||
it("ignores an ordinary MOTD banner", () => {
|
||||
const provider = register();
|
||||
const decision = provider.onBanner?.(
|
||||
"Welcome to Ubuntu 24.04 LTS\n",
|
||||
{ id: 1, ip: "100.1.1.1", port: 22, username: "root" },
|
||||
{
|
||||
client: {},
|
||||
userId: "u1",
|
||||
hostId: 1,
|
||||
purpose: "terminal",
|
||||
interactive: true,
|
||||
log: () => {},
|
||||
},
|
||||
);
|
||||
expect(decision).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("onAuthFailed", () => {
|
||||
it("retries once in forced password mode", () => {
|
||||
const provider = register();
|
||||
const outcome = provider.onAuthFailed?.(
|
||||
{ id: 1, ip: "100.1.1.1", port: 22, username: "root" },
|
||||
{
|
||||
client: {},
|
||||
userId: "u1",
|
||||
hostId: 1,
|
||||
purpose: "terminal",
|
||||
interactive: true,
|
||||
log: () => {},
|
||||
},
|
||||
{
|
||||
error: new Error("All configured authentication methods failed"),
|
||||
retries: 0,
|
||||
canRetry: true,
|
||||
methodNotAvailable: false,
|
||||
},
|
||||
);
|
||||
expect(outcome).toMatchObject({
|
||||
status: "retry",
|
||||
patch: { username: "root+password" },
|
||||
});
|
||||
});
|
||||
|
||||
it("fails after a retry has already happened", () => {
|
||||
const provider = register();
|
||||
const outcome = provider.onAuthFailed?.(
|
||||
{ id: 1, ip: "100.1.1.1", port: 22, username: "root" },
|
||||
{
|
||||
client: {},
|
||||
userId: "u1",
|
||||
hostId: 1,
|
||||
purpose: "terminal",
|
||||
interactive: true,
|
||||
log: () => {},
|
||||
},
|
||||
{
|
||||
error: new Error("All configured authentication methods failed"),
|
||||
retries: 1,
|
||||
canRetry: true,
|
||||
methodNotAvailable: false,
|
||||
},
|
||||
);
|
||||
expect(outcome).toMatchObject({ status: "error", code: "failed" });
|
||||
});
|
||||
|
||||
it("does nothing for an unrelated failure", () => {
|
||||
const provider = register();
|
||||
const outcome = provider.onAuthFailed?.(
|
||||
{ id: 1, ip: "100.1.1.1", port: 22, username: "root" },
|
||||
{
|
||||
client: {},
|
||||
userId: "u1",
|
||||
hostId: 1,
|
||||
purpose: "terminal",
|
||||
interactive: true,
|
||||
log: () => {},
|
||||
},
|
||||
{
|
||||
error: new Error("connection reset"),
|
||||
retries: 0,
|
||||
canRetry: true,
|
||||
methodNotAvailable: false,
|
||||
},
|
||||
);
|
||||
expect(outcome).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
parseTailscaleCheckBanner,
|
||||
isTailscaleCheckCompleteBanner,
|
||||
} from "../../../../src/backend/hosts/tailscale-check.js";
|
||||
} from "../../src/backend/tailscale-check.js";
|
||||
|
||||
describe("parseTailscaleCheckBanner", () => {
|
||||
it("extracts the login URL from a real check-mode banner", () => {
|
||||
|
||||
Reference in new issue
Block a user