mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 07:05:24 +00:00
feat: convert ssh tunnels into a plugin
This commit is contained in:
1 parent
c85c1fdb86
commit
1682861853
220 files changed
+36928
-11374
No files matched your search
@@ -22,12 +22,9 @@ Each host can define up to 16 endpoints (`MAX_WEB_ENDPOINTS`). An endpoint is ei
|
||||
|
||||
## What stays in core
|
||||
|
||||
This plugin owns its lifecycle, not all of its code. Four pieces deliberately stay in
|
||||
This plugin owns its lifecycle, not all of its code. Three pieces deliberately stay in
|
||||
Termix proper:
|
||||
|
||||
- **The tunnel manager** (`src/backend/hosts/tunnel/manager.ts`). It serves the whole
|
||||
server-tunnels feature. The reserved `web:` tunnel-name prefix that exempts these
|
||||
tunnels from the retry machinery is part of its own disconnect path.
|
||||
- **`host-web-endpoints.ts`** (`src/backend/database/routes/`). Despite sitting in
|
||||
`routes/` it has no router — it is the `webUiConfig` normalizer, imported by `host.ts`,
|
||||
`host-bulk-routes.ts` and `host-normalizers.ts` to sanitize the column on every host
|
||||
@@ -44,21 +41,18 @@ Termix proper:
|
||||
The `enableWebUi` and `webUiConfig` columns stay on core's `ssh_data` table, declared
|
||||
here through `contributes.hostCapability`.
|
||||
|
||||
## Why it runs in-process
|
||||
## Tunnels
|
||||
|
||||
The open route polls the tunnel manager's live maps, builds a `TunnelConfig` from
|
||||
plaintext host credentials that `ctx.hosts` deliberately withholds, and hands the
|
||||
resulting live `ssh2.Client` to `forwardOut`. A structured-clone postMessage boundary
|
||||
can carry none of those, so this is a genuine transport dependency — see
|
||||
`src/backend/plugins/first-party.ts`.
|
||||
A tunnel endpoint opens through the tunnels plugin, a hard dependency. `POST /open`
|
||||
calls `forward()` on its `tunnels.access` service, which connects through core's SSH
|
||||
pipeline, binds the local listener, probes the target once and only then returns the
|
||||
bound port. The forward runs under the reserved `web:<hostId>:<endpointId>` name, so
|
||||
the tunnels plugin never retries it and closes it after ten idle minutes.
|
||||
|
||||
## Routing
|
||||
|
||||
The route is served by the tunnel service on port 30003, so the dispatcher lives at
|
||||
`src/backend/hosts/tunnel/web-endpoint-dispatch.ts` rather than in `database.ts` with
|
||||
the others. The public path stays `/ssh/tunnel/web-endpoint/open`, which the generic
|
||||
`location /ssh/tunnel/` block in both nginx configs already proxies — so no nginx change
|
||||
was needed.
|
||||
Core mounts the route at `/plugin-api/web-endpoint/open` through `ctx.http.router()`,
|
||||
which the generic `/plugin-api/` block in both nginx configs already proxies.
|
||||
|
||||
## Tests
|
||||
|
||||
|
||||
@@ -14,12 +14,15 @@
|
||||
"termix": ">=2.9.0",
|
||||
"api": "1"
|
||||
},
|
||||
"capabilities": [
|
||||
"hosts:read",
|
||||
"credentials:use",
|
||||
"ssh:connect",
|
||||
"network:serve",
|
||||
"ui:surface"
|
||||
"capabilities": ["hosts:read", "network:serve", "ui:surface"],
|
||||
"dependencies": {
|
||||
"tunnels": "^1.0.0"
|
||||
},
|
||||
"requires": [
|
||||
{
|
||||
"service": "tunnels.access",
|
||||
"versionRange": "^1.0.0"
|
||||
}
|
||||
],
|
||||
"contributes": {
|
||||
"tabs": [
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { setPluginSsh } from "./ssh.js";
|
||||
import { startWebEndpointService, stopWebEndpointService } from "./routes.js";
|
||||
|
||||
export async function activate(ctx: PluginContext) {
|
||||
setPluginSsh(ctx.ssh);
|
||||
ctx.disposables.add(() => setPluginSsh(null));
|
||||
startWebEndpointService(ctx.http.router());
|
||||
startWebEndpointService(ctx.http.router(), ctx);
|
||||
ctx.disposables.add(() => stopWebEndpointService());
|
||||
ctx.log.info("Web Endpoint routes mounted at /plugin-api/web-endpoint");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,133 +1,45 @@
|
||||
import express, { type Router } from "express";
|
||||
import { Client } from "ssh2";
|
||||
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
|
||||
import { tunnelLogger } from "../../../../src/backend/utils/logger.js";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { parseWebUiConfig } from "../../../../src/backend/database/routes/host-web-endpoints.js";
|
||||
import {
|
||||
activeTunnelRuntimes,
|
||||
cleanupTunnelResources,
|
||||
connectSSHTunnel,
|
||||
connectionStatus,
|
||||
tunnelConnecting,
|
||||
} from "../../../../src/backend/hosts/tunnel/manager.js";
|
||||
import { forwardOut } from "../../../../src/backend/hosts/tunnel/ssh-primitives.js";
|
||||
import { buildWebEndpointTunnelName } from "../../../../src/backend/hosts/tunnel/utils.js";
|
||||
import type { TunnelConfig, WebEndpoint } from "../../../../src/types/index.js";
|
||||
|
||||
/** Matches the spec's ten minutes. */
|
||||
const WEB_ENDPOINT_IDLE_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Long enough for a slow SSH handshake, short enough that a wedged connection
|
||||
* does not hold the HTTP request open indefinitely. One second past the
|
||||
* manager's own connection timeout so the manager reports first.
|
||||
*/
|
||||
const TUNNEL_READY_TIMEOUT_MS = 61_000;
|
||||
const TUNNEL_READY_POLL_MS = 100;
|
||||
const TARGET_PROBE_TIMEOUT_MS = 10_000;
|
||||
/** The tunnels plugin, as ctx.services.get("tunnels.access") returns it. */
|
||||
interface TunnelsAccess {
|
||||
forward(
|
||||
sourceHostId: number,
|
||||
target: {
|
||||
targetHost: string;
|
||||
targetPort: number;
|
||||
bindHost?: string;
|
||||
bindPort?: number;
|
||||
},
|
||||
options?: { name?: string; idleTimeoutMs?: number },
|
||||
): Promise<{ bindHost: string; bindPort: number }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the live forward was built for. Compared on reuse so an endpoint edited
|
||||
* under a live tunnel is reopened rather than silently forwarding to the old
|
||||
* target.
|
||||
*
|
||||
* JSON rather than a joined string: no separator can be smuggled in by a field
|
||||
* value, and the result stays readable in a log. The SSH identity is included
|
||||
* because a host repointed at a different server must also invalidate the
|
||||
* forward, not just a changed endpoint port.
|
||||
* Reserved by the tunnels plugin for on-demand forwards: never retried, and
|
||||
* the host id in it is what the tunnels plugin checks before letting anyone
|
||||
* stop one by name.
|
||||
*/
|
||||
function targetFingerprint(
|
||||
endpoint: Pick<
|
||||
WebEndpoint,
|
||||
"scheme" | "port" | "access" | "bindHost" | "localPort"
|
||||
>,
|
||||
host: { ip?: string; port?: number; username?: string },
|
||||
export function webEndpointTunnelName(
|
||||
hostId: number,
|
||||
endpointId: string,
|
||||
): string {
|
||||
return JSON.stringify({
|
||||
scheme: endpoint.scheme,
|
||||
port: endpoint.port,
|
||||
access: endpoint.access,
|
||||
bindHost: endpoint.bindHost ?? null,
|
||||
localPort: endpoint.localPort ?? null,
|
||||
ip: host.ip ?? null,
|
||||
sshPort: host.port ?? null,
|
||||
username: host.username ?? null,
|
||||
});
|
||||
return `web:${hostId}:${endpointId}`;
|
||||
}
|
||||
|
||||
const openedTargets = new Map<string, string>();
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* connectSSHTunnel(config) returns Promise<void> and NEVER REJECTS: every
|
||||
* failure path inside it reports over broadcastTunnelStatus and returns
|
||||
* normally, and its own promise resolves right after conn.connect(), long
|
||||
* before the SSH "ready" event that would populate activeTunnelRuntimes.
|
||||
*
|
||||
* So "awaited" is not "connected". This is what actually waits.
|
||||
*
|
||||
* Polling the maps the manager already exports, rather than adding a
|
||||
* promise-returning variant to manager.ts: every existing caller of
|
||||
* connectSSHTunnel is fire-and-forget by design (the connect route responds
|
||||
* "Connection request received" immediately and reports asynchronously over
|
||||
* the status broadcast), so changing that contract to serve one new caller is
|
||||
* a far larger blast radius than polling here.
|
||||
*/
|
||||
async function waitForTunnelSettled(tunnelName: string): Promise<void> {
|
||||
const deadline = Date.now() + TUNNEL_READY_TIMEOUT_MS;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (activeTunnelRuntimes.has(tunnelName)) return;
|
||||
|
||||
const status = connectionStatus.get(tunnelName);
|
||||
if (!tunnelConnecting.has(tunnelName) && status && !status.connected) {
|
||||
throw new Error(status.reason || "The endpoint tunnel failed to connect");
|
||||
}
|
||||
await sleep(TUNNEL_READY_POLL_MS);
|
||||
}
|
||||
|
||||
throw new Error("Timed out establishing the endpoint tunnel");
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens one channel to the target and closes it immediately.
|
||||
*
|
||||
* Without this the route returns 200 the moment tcpServer.listen succeeds, and
|
||||
* forwardOut is only attempted per inbound socket -- where a failure is
|
||||
* swallowed by the socket's own error handling. The user then gets a blank
|
||||
* frame and no message for the single likeliest error: nothing is actually
|
||||
* listening on that port. Costs one round trip per open.
|
||||
*/
|
||||
async function probeEndpointTarget(
|
||||
sourceClient: Client,
|
||||
targetPort: number,
|
||||
tunnelName: string,
|
||||
): Promise<void> {
|
||||
const channel = await Promise.race([
|
||||
forwardOut(sourceClient, "127.0.0.1", targetPort, tunnelName),
|
||||
new Promise<never>((_resolve, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error("Timed out reaching the endpoint port")),
|
||||
TARGET_PROBE_TIMEOUT_MS,
|
||||
),
|
||||
),
|
||||
]);
|
||||
try {
|
||||
channel.end();
|
||||
} catch {
|
||||
// Already gone; the probe succeeded either way.
|
||||
}
|
||||
}
|
||||
let current: PluginContext | null = null;
|
||||
|
||||
export async function handleWebEndpointOpen(
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
): Promise<express.Response | void> {
|
||||
const userId = (req as { userId?: string }).userId;
|
||||
if (!userId) {
|
||||
const ctx = current;
|
||||
const userId = ctx?.currentActor();
|
||||
if (!ctx || !userId) {
|
||||
return res.status(401).json({ error: "Authentication required" });
|
||||
}
|
||||
|
||||
@@ -141,10 +53,8 @@ export async function handleWebEndpointOpen(
|
||||
}
|
||||
|
||||
// Deliberately NOT gated to the desktop. The forward binds wherever this
|
||||
// backend runs, exactly as the server tunnels feature does, and the
|
||||
// endpoint's own bindHost decides whether that is reachable from the
|
||||
// browser -- loopback for the desktop, an address the server answers on for
|
||||
// a web deployment.
|
||||
// backend runs, and the endpoint's own bindHost decides whether that is
|
||||
// reachable from the browser.
|
||||
|
||||
const { resolveHostById } =
|
||||
await import("../../../../src/backend/hosts/host-resolver.js");
|
||||
@@ -153,11 +63,9 @@ export async function handleWebEndpointOpen(
|
||||
return res.status(403).json({ error: "Host not found or access denied" });
|
||||
}
|
||||
|
||||
// All three write paths null the config on disable, so a configured-but-
|
||||
// disabled host is not normally reachable -- except a bulk update that sends
|
||||
// only { webUiConfig } while the stored enable_web_ui is still 0. The UI
|
||||
// reads as off everywhere in that state, so the endpoint merely being
|
||||
// present in the config is not on its own a licence to open a tunnel.
|
||||
// A bulk update that sends only { webUiConfig } can leave the endpoint in
|
||||
// the config while the feature is off. The UI reads as off in that state,
|
||||
// so the endpoint being listed is not on its own a licence to open a tunnel.
|
||||
if (!host.enableWebUi) {
|
||||
return res
|
||||
.status(400)
|
||||
@@ -178,118 +86,42 @@ export async function handleWebEndpointOpen(
|
||||
.json({ error: "This endpoint does not use a tunnel" });
|
||||
}
|
||||
|
||||
const tunnelName = buildWebEndpointTunnelName(hostId, endpointId);
|
||||
const fingerprint = targetFingerprint(endpoint, host);
|
||||
const existing = activeTunnelRuntimes.get(tunnelName);
|
||||
|
||||
if (existing) {
|
||||
const recorded = openedTargets.get(tunnelName);
|
||||
// The runtime is the source of truth and openedTargets is derived from it,
|
||||
// so an ABSENT fingerprint means reuse, not staleness. A reserved-prefixed
|
||||
// runtime can only have been created by this route.
|
||||
if (recorded === undefined || recorded === fingerprint) {
|
||||
openedTargets.set(tunnelName, fingerprint);
|
||||
return res.status(200).json({ port: existing.bindPort });
|
||||
}
|
||||
|
||||
tunnelLogger.info("Reopening web endpoint tunnel after config change", {
|
||||
operation: "web_endpoint_reopen",
|
||||
tunnelName,
|
||||
});
|
||||
// Forced, for the same reason handleDisconnect's reserved-name branch
|
||||
// forces: cleanupTunnelResources no-ops while tunnelConnecting holds the
|
||||
// name, and a web tunnel has no retry pass to self-heal a runtime left
|
||||
// behind. An unforced call could leave a dead listener registered.
|
||||
await cleanupTunnelResources(tunnelName, true);
|
||||
openedTargets.delete(tunnelName);
|
||||
let tunnels: TunnelsAccess;
|
||||
try {
|
||||
tunnels = ctx.services.get<TunnelsAccess>("tunnels.access", { userId });
|
||||
} catch {
|
||||
return res
|
||||
.status(503)
|
||||
.json({ error: "The tunnels plugin is not available" });
|
||||
}
|
||||
|
||||
const tunnelConfig: TunnelConfig = {
|
||||
name: tunnelName,
|
||||
scope: "s2s",
|
||||
mode: "local",
|
||||
tunnelType: "local",
|
||||
// Loopback unless the endpoint asks otherwise. A non-loopback bind
|
||||
// publishes the target's web UI to anyone who can reach the port, with no
|
||||
// authentication in front of it -- the same trade the server tunnels
|
||||
// feature exposes, and only ever an explicit per-endpoint choice. The
|
||||
// value is validated to a bare host literal by normalizeBindHost.
|
||||
bindHost: endpoint.bindHost || "127.0.0.1",
|
||||
sourceHostId: host.id ?? hostId,
|
||||
tunnelIndex: 0,
|
||||
requestingUserId: userId,
|
||||
hostName: host.name || `${host.username}@${host.ip}`,
|
||||
sourceIP: host.ip,
|
||||
sourceSSHPort: host.port,
|
||||
sourceUsername: host.username,
|
||||
sourcePassword: host.password,
|
||||
sourceAuthMethod: host.authType,
|
||||
sourceSSHKey: host.key,
|
||||
sourceKeyPassword: host.keyPassword,
|
||||
sourceKeyType: host.keyType,
|
||||
sourceCredentialId: host.credentialId,
|
||||
// connectSSHTunnel reads sourceUserId when building its client.
|
||||
sourceUserId: host.userId,
|
||||
endpointIP: host.ip,
|
||||
endpointSSHPort: host.port,
|
||||
endpointUsername: host.username,
|
||||
// Load-bearing: connectSSHTunnel picks its strategy via
|
||||
// shouldEstablishDirectTunnel -> isSingleHostTunnel, which returns true
|
||||
// when endpointHost is "127.0.0.1". That is what selects
|
||||
// establishDirectTunnel's local branch -- the bind-locally-and-forwardOut
|
||||
// path this feature needs. Setting only targetHost would NOT select it.
|
||||
endpointHost: "127.0.0.1",
|
||||
endpointAuthMethod: host.authType,
|
||||
endpointSSHKey: host.key,
|
||||
endpointKeyPassword: host.keyPassword,
|
||||
endpointKeyType: host.keyType,
|
||||
endpointCredentialId: host.credentialId,
|
||||
endpointUserId: host.userId,
|
||||
// A fixed port when the endpoint names one, as the server tunnels
|
||||
// feature's Source Port does -- a container can only publish ports it
|
||||
// knows in advance. Otherwise 0 and the kernel picks; the manager reports
|
||||
// back what it actually bound either way.
|
||||
sourcePort: endpoint.localPort ?? 0,
|
||||
endpointPort: endpoint.port,
|
||||
// Documentation only: maxRetries 0 does NOT disable retries, because the
|
||||
// manager reads `maxRetries || 3`. Retry is actually suppressed by the
|
||||
// reserved-name early return in handleDisconnect.
|
||||
maxRetries: 0,
|
||||
retryInterval: 0,
|
||||
autoStart: false,
|
||||
isPinned: Boolean(host.pin),
|
||||
idleTimeoutMs: WEB_ENDPOINT_IDLE_TIMEOUT_MS,
|
||||
useSocks5: Boolean(host.useSocks5),
|
||||
socks5Host: host.socks5Host,
|
||||
socks5Port: host.socks5Port,
|
||||
socks5Username: host.socks5Username,
|
||||
socks5Password: host.socks5Password,
|
||||
socks5ProxyChain: host.socks5ProxyChain,
|
||||
} as TunnelConfig;
|
||||
|
||||
try {
|
||||
await connectSSHTunnel(tunnelConfig);
|
||||
await waitForTunnelSettled(tunnelName);
|
||||
|
||||
const runtime = activeTunnelRuntimes.get(tunnelName);
|
||||
if (!runtime) {
|
||||
throw new Error("The endpoint tunnel closed before it could be used");
|
||||
}
|
||||
|
||||
await probeEndpointTarget(runtime.sourceClient, endpoint.port, tunnelName);
|
||||
|
||||
openedTargets.set(tunnelName, fingerprint);
|
||||
return res.status(200).json({ port: runtime.bindPort });
|
||||
const handle = await tunnels.forward(
|
||||
hostId,
|
||||
{
|
||||
targetHost: "127.0.0.1",
|
||||
targetPort: endpoint.port,
|
||||
// Loopback unless the endpoint asks otherwise. A non-loopback bind
|
||||
// publishes the target's web UI to anyone who can reach the port, with
|
||||
// no authentication in front of it, and is only ever an explicit
|
||||
// per-endpoint choice.
|
||||
bindHost: endpoint.bindHost || "127.0.0.1",
|
||||
// A fixed port when the endpoint names one, since a container can only
|
||||
// publish ports it knows in advance. Otherwise the kernel picks.
|
||||
bindPort: endpoint.localPort ?? undefined,
|
||||
},
|
||||
{
|
||||
name: webEndpointTunnelName(hostId, endpointId),
|
||||
idleTimeoutMs: WEB_ENDPOINT_IDLE_TIMEOUT_MS,
|
||||
},
|
||||
);
|
||||
return res.status(200).json({ port: handle.bindPort });
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
tunnelLogger.error("Failed to open web endpoint tunnel", error, {
|
||||
operation: "web_endpoint_open_failed",
|
||||
tunnelName,
|
||||
});
|
||||
// Leave nothing half-built: a listener with no working forward behind it
|
||||
// would be handed out on the next open as a live tunnel.
|
||||
await cleanupTunnelResources(tunnelName, true).catch(() => undefined);
|
||||
openedTargets.delete(tunnelName);
|
||||
ctx.log.error(
|
||||
`Failed to open web endpoint tunnel for host ${hostId}: ${reason}`,
|
||||
error instanceof Error ? error : undefined,
|
||||
);
|
||||
return res.status(502).json({ error: reason });
|
||||
}
|
||||
}
|
||||
@@ -298,22 +130,18 @@ export const router = express.Router();
|
||||
|
||||
let started = false;
|
||||
|
||||
export function startWebEndpointService(mountOn: Router): void {
|
||||
export function startWebEndpointService(
|
||||
mountOn: Router,
|
||||
ctx: PluginContext,
|
||||
): void {
|
||||
current = ctx;
|
||||
if (!started) {
|
||||
// Built on first start rather than at module scope so importing this
|
||||
// module for a handler unit test does not construct the auth singleton.
|
||||
const authenticateJWT = AuthManager.getInstance().createAuthMiddleware();
|
||||
// Only "/open" here: the dispatcher this router is registered with is
|
||||
// mounted at /ssh/tunnel/web-endpoint, so the full path stays
|
||||
// /ssh/tunnel/web-endpoint/open exactly as before. The "/ssh" prefix is
|
||||
// part of the path the tunnel service serves -- nginx proxies /ssh/tunnel/
|
||||
// through with the path intact.
|
||||
router.post("/open", authenticateJWT, handleWebEndpointOpen);
|
||||
router.post("/open", handleWebEndpointOpen);
|
||||
started = true;
|
||||
}
|
||||
mountOn.use(router);
|
||||
}
|
||||
|
||||
export function stopWebEndpointService(): void {
|
||||
// Unmounted by the runtime when the plugin deactivates.
|
||||
current = null;
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import type { Client } from "ssh2";
|
||||
import type {
|
||||
PluginSsh,
|
||||
PluginSshConnection,
|
||||
PluginSshConnectOptions,
|
||||
PluginSshHost,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
|
||||
let current: PluginSsh | null = null;
|
||||
|
||||
/** Set in activate, cleared on deactivate. */
|
||||
export function setPluginSsh(ssh: PluginSsh | null): void {
|
||||
current = ssh;
|
||||
}
|
||||
|
||||
export function pluginSsh(): PluginSsh {
|
||||
if (!current) throw new Error("The plugin is not active");
|
||||
return current;
|
||||
}
|
||||
|
||||
/** withConnection with ssh2's Client type filled in. */
|
||||
export function withSshConnection<T>(
|
||||
host: number | PluginSshHost,
|
||||
options: PluginSshConnectOptions & { pool: string },
|
||||
fn: (client: Client) => Promise<T>,
|
||||
): Promise<T> {
|
||||
return pluginSsh().withConnection<T, Client>(host, options, fn);
|
||||
}
|
||||
|
||||
/** connect with ssh2's Client type filled in. */
|
||||
export function connectSsh(
|
||||
host: number | PluginSshHost,
|
||||
options?: PluginSshConnectOptions,
|
||||
): Promise<PluginSshConnection<Client>> {
|
||||
return pluginSsh().connect<Client>(host, options);
|
||||
}
|
||||
@@ -12,7 +12,10 @@ import type { Host } from "@/types/ui-types";
|
||||
import type { WebEndpoint } from "@/types";
|
||||
import { WebEndpointTab } from "./WebEndpointTab";
|
||||
import { HostWebUiTab } from "./HostWebUiTab";
|
||||
import { openWebEndpointExternally } from "./web-endpoint-api";
|
||||
import {
|
||||
openWebEndpointExternally,
|
||||
setWebEndpointApi,
|
||||
} from "./web-endpoint-api";
|
||||
|
||||
const TAB_TYPE = "web-endpoint";
|
||||
|
||||
@@ -73,6 +76,9 @@ function WebUiSection({ form, setField, protocols }: HostEditorSectionProps) {
|
||||
}
|
||||
|
||||
export function activate(app: TermixApp): void {
|
||||
setWebEndpointApi(app.api);
|
||||
app.onDispose(() => setWebEndpointApi(null));
|
||||
|
||||
app.registerTab(TAB_TYPE, EndpointTab, {
|
||||
icon: Globe,
|
||||
titleKey: "hosts.tabWebUi",
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { currentTunnelHost, resolveWebEndpointUrl } from "./web-endpoint-url";
|
||||
import axios from "axios";
|
||||
import { handleApiError, tunnelApi } from "@/main-axios";
|
||||
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
|
||||
import { handleApiError } from "@/main-axios";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import type { WebEndpoint } from "@/types/index";
|
||||
|
||||
let pluginApi: PluginApiClient | null = null;
|
||||
|
||||
/** Set from activate with app.api, cleared on deactivate. */
|
||||
export function setWebEndpointApi(api: PluginApiClient | null): void {
|
||||
pluginApi = api;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when the backend rejects a web endpoint tunnel open with a specific,
|
||||
* actionable reason -- most commonly a 502 carrying the real cause: SSH auth
|
||||
@@ -32,10 +40,9 @@ export async function openWebEndpointTunnel(
|
||||
endpointId: string,
|
||||
): Promise<number> {
|
||||
try {
|
||||
// Relative to the tunnel API base, which already includes /ssh
|
||||
// (getApiUrl("/ssh", 30003)). A path beginning "/ssh" here would resolve
|
||||
// to /ssh/ssh/... and 404 on every call.
|
||||
const response = await tunnelApi.post("/tunnel/web-endpoint/open", {
|
||||
if (!pluginApi) throw new Error("The web endpoint plugin is not active");
|
||||
// Relative to the plugin mount, /plugin-api/web-endpoint.
|
||||
const response = await pluginApi.post("/open", {
|
||||
hostId,
|
||||
endpointId,
|
||||
});
|
||||
|
||||
@@ -1,29 +1,14 @@
|
||||
import type express from "express";
|
||||
import express from "express";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createFakeContext } from "@termix/plugin-sdk/testing";
|
||||
|
||||
const resolveHostById = vi.hoisted(() => vi.fn());
|
||||
vi.mock("../../../../src/backend/hosts/host-resolver.js", () => ({
|
||||
resolveHostById,
|
||||
}));
|
||||
|
||||
const manager = vi.hoisted(() => ({
|
||||
activeTunnelRuntimes: new Map<string, unknown>(),
|
||||
connectionStatus: new Map<string, unknown>(),
|
||||
tunnelConnecting: new Set<string>(),
|
||||
connectSSHTunnel: vi.fn(),
|
||||
cleanupTunnelResources: vi.fn(async () => undefined),
|
||||
}));
|
||||
vi.mock("../../../../src/backend/hosts/tunnel/manager.js", () => manager);
|
||||
|
||||
const forwardOut = vi.hoisted(() => vi.fn(async () => ({ end: vi.fn() })));
|
||||
vi.mock("../../../../src/backend/hosts/tunnel/ssh-primitives.js", () => ({
|
||||
forwardOut,
|
||||
}));
|
||||
|
||||
vi.mock("../../../../src/backend/utils/auth-manager.js", () => ({
|
||||
AuthManager: { getInstance: () => ({ createAuthMiddleware: () => vi.fn() }) },
|
||||
}));
|
||||
const forward = vi.fn();
|
||||
|
||||
function response() {
|
||||
const res = {
|
||||
@@ -44,8 +29,8 @@ function response() {
|
||||
};
|
||||
}
|
||||
|
||||
function request(body: unknown, userId: string | undefined = "u1") {
|
||||
return { body, userId } as unknown as express.Request;
|
||||
function request(body: unknown) {
|
||||
return { body } as unknown as express.Request;
|
||||
}
|
||||
|
||||
const endpoint = (overrides: Record<string, unknown> = {}) => ({
|
||||
@@ -67,34 +52,29 @@ function host(overrides: Record<string, unknown> = {}) {
|
||||
port: 22,
|
||||
username: "root",
|
||||
authType: "password",
|
||||
password: "pw",
|
||||
enableWebUi: true,
|
||||
webUiConfig: JSON.stringify({ endpoints: [endpoint()] }),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Makes connectSSHTunnel behave like a successful open. */
|
||||
function tunnelConnectsOn(port: number) {
|
||||
manager.connectSSHTunnel.mockImplementation(
|
||||
async (config: { name: string }) => {
|
||||
manager.activeTunnelRuntimes.set(config.name, {
|
||||
bindPort: port,
|
||||
sourceClient: {},
|
||||
close: vi.fn(),
|
||||
});
|
||||
},
|
||||
);
|
||||
/** Activates the routes against a fake ctx, with or without the tunnels service. */
|
||||
async function load(options: { tunnels?: boolean; actor?: string } = {}) {
|
||||
const fake = createFakeContext({
|
||||
pluginId: "web-endpoint",
|
||||
actor: options.actor ?? "u1",
|
||||
});
|
||||
if (options.tunnels !== false) {
|
||||
fake.ctx.services.provide("tunnels.access", { forward });
|
||||
}
|
||||
const routes = await import("../../src/backend/routes.js");
|
||||
routes.startWebEndpointService(express.Router(), fake.ctx);
|
||||
return routes;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
manager.activeTunnelRuntimes.clear();
|
||||
manager.connectionStatus.clear();
|
||||
manager.tunnelConnecting.clear();
|
||||
manager.connectSSHTunnel.mockReset();
|
||||
manager.cleanupTunnelResources.mockClear();
|
||||
forwardOut.mockClear();
|
||||
forwardOut.mockResolvedValue({ end: vi.fn() });
|
||||
forward.mockReset();
|
||||
forward.mockResolvedValue({ bindHost: "127.0.0.1", bindPort: 41234 });
|
||||
resolveHostById.mockReset();
|
||||
resolveHostById.mockResolvedValue(host());
|
||||
});
|
||||
@@ -103,34 +83,29 @@ afterEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe("POST /tunnel/web-endpoint/open", () => {
|
||||
describe("POST /open", () => {
|
||||
it("refuses a host the user cannot resolve, without opening anything", async () => {
|
||||
resolveHostById.mockResolvedValue(null);
|
||||
const { handleWebEndpointOpen } =
|
||||
await import("../../src/backend/routes.js");
|
||||
const { handleWebEndpointOpen } = await load();
|
||||
const res = response();
|
||||
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(manager.connectSSHTunnel).not.toHaveBeenCalled();
|
||||
expect(forward).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses when the feature is disabled even though the config still lists the endpoint", async () => {
|
||||
// Reachable via a bulk update that sends only { webUiConfig } while the
|
||||
// stored enable_web_ui is still 0. The UI reads as off in that state.
|
||||
resolveHostById.mockResolvedValue(host({ enableWebUi: false }));
|
||||
const { handleWebEndpointOpen } =
|
||||
await import("../../src/backend/routes.js");
|
||||
const { handleWebEndpointOpen } = await load();
|
||||
const res = response();
|
||||
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(manager.connectSSHTunnel).not.toHaveBeenCalled();
|
||||
expect(forward).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses an endpoint id that matches nothing", async () => {
|
||||
const { handleWebEndpointOpen } =
|
||||
await import("../../src/backend/routes.js");
|
||||
const { handleWebEndpointOpen } = await load();
|
||||
const res = response();
|
||||
await handleWebEndpointOpen(
|
||||
request({ hostId: 7, endpointId: "nope" }),
|
||||
@@ -139,10 +114,8 @@ describe("POST /tunnel/web-endpoint/open", () => {
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("returns the real bound port", async () => {
|
||||
tunnelConnectsOn(41234);
|
||||
const { handleWebEndpointOpen } =
|
||||
await import("../../src/backend/routes.js");
|
||||
it("returns the port the tunnels service bound", async () => {
|
||||
const { handleWebEndpointOpen } = await load();
|
||||
const res = response();
|
||||
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
|
||||
|
||||
@@ -150,41 +123,26 @@ describe("POST /tunnel/web-endpoint/open", () => {
|
||||
expect(res.body.port).toBe(41234);
|
||||
});
|
||||
|
||||
it("selects the local-forward path via endpointHost, not targetHost", async () => {
|
||||
// connectSSHTunnel routes on isSingleHostTunnel, which keys on
|
||||
// endpointHost === "127.0.0.1". Setting only targetHost would silently
|
||||
// select a different strategy and the forward would never bind locally.
|
||||
tunnelConnectsOn(41234);
|
||||
const { handleWebEndpointOpen } =
|
||||
await import("../../src/backend/routes.js");
|
||||
it("asks for a loopback forward under the reserved web name", async () => {
|
||||
const { handleWebEndpointOpen } = await load();
|
||||
await handleWebEndpointOpen(
|
||||
request({ hostId: 7, endpointId: "e1" }),
|
||||
response(),
|
||||
);
|
||||
|
||||
const config = manager.connectSSHTunnel.mock.calls[0][0];
|
||||
expect(config.endpointHost).toBe("127.0.0.1");
|
||||
expect(config.mode).toBe("local");
|
||||
expect(config.endpointPort).toBe(8006);
|
||||
// Carries the source SSH identity -- connectSSHTunnel builds its client
|
||||
// from these, and reads sourceUserId specifically.
|
||||
expect(config.sourceIP).toBe("10.0.0.5");
|
||||
expect(config.sourceUserId).toBe("u1");
|
||||
expect(forward).toHaveBeenCalledWith(
|
||||
7,
|
||||
{
|
||||
targetHost: "127.0.0.1",
|
||||
targetPort: 8006,
|
||||
bindHost: "127.0.0.1",
|
||||
bindPort: undefined,
|
||||
},
|
||||
{ name: "web:7:e1", idleTimeoutMs: 10 * 60 * 1000 },
|
||||
);
|
||||
});
|
||||
|
||||
it("binds loopback by default and the endpoint's choice when given", async () => {
|
||||
tunnelConnectsOn(41234);
|
||||
const { handleWebEndpointOpen } =
|
||||
await import("../../src/backend/routes.js");
|
||||
await handleWebEndpointOpen(
|
||||
request({ hostId: 7, endpointId: "e1" }),
|
||||
response(),
|
||||
);
|
||||
expect(manager.connectSSHTunnel.mock.calls[0][0].bindHost).toBe(
|
||||
"127.0.0.1",
|
||||
);
|
||||
|
||||
manager.activeTunnelRuntimes.clear();
|
||||
it("passes the endpoint's own bind address and fixed port through", async () => {
|
||||
resolveHostById.mockResolvedValue(
|
||||
host({
|
||||
webUiConfig: JSON.stringify({
|
||||
@@ -192,105 +150,39 @@ describe("POST /tunnel/web-endpoint/open", () => {
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await handleWebEndpointOpen(
|
||||
request({ hostId: 7, endpointId: "e1" }),
|
||||
response(),
|
||||
);
|
||||
const config = manager.connectSSHTunnel.mock.calls[1][0];
|
||||
expect(config.bindHost).toBe("0.0.0.0");
|
||||
expect(config.sourcePort).toBe(38080);
|
||||
});
|
||||
|
||||
it("reuses a live tunnel instead of opening a second one", async () => {
|
||||
tunnelConnectsOn(41234);
|
||||
const { handleWebEndpointOpen } =
|
||||
await import("../../src/backend/routes.js");
|
||||
const first = response();
|
||||
await handleWebEndpointOpen(
|
||||
request({ hostId: 7, endpointId: "e1" }),
|
||||
first,
|
||||
);
|
||||
const second = response();
|
||||
await handleWebEndpointOpen(
|
||||
request({ hostId: 7, endpointId: "e1" }),
|
||||
second,
|
||||
);
|
||||
|
||||
expect(second.body.port).toBe(41234);
|
||||
expect(manager.connectSSHTunnel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reopens when the endpoint's target changed under a live tunnel", async () => {
|
||||
tunnelConnectsOn(41234);
|
||||
const { handleWebEndpointOpen } =
|
||||
await import("../../src/backend/routes.js");
|
||||
const { handleWebEndpointOpen } = await load();
|
||||
await handleWebEndpointOpen(
|
||||
request({ hostId: 7, endpointId: "e1" }),
|
||||
response(),
|
||||
);
|
||||
|
||||
// Same id, different port: the live forward now points at the wrong place.
|
||||
resolveHostById.mockResolvedValue(
|
||||
host({
|
||||
webUiConfig: JSON.stringify({ endpoints: [endpoint({ port: 9000 })] }),
|
||||
}),
|
||||
);
|
||||
tunnelConnectsOn(51234);
|
||||
const res = response();
|
||||
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
|
||||
|
||||
expect(manager.cleanupTunnelResources).toHaveBeenCalledWith(
|
||||
"web:7:e1",
|
||||
true,
|
||||
);
|
||||
expect(res.body.port).toBe(51234);
|
||||
const target = forward.mock.calls[0][1];
|
||||
expect(target.bindHost).toBe("0.0.0.0");
|
||||
expect(target.bindPort).toBe(38080);
|
||||
});
|
||||
|
||||
it("reports the target port being closed rather than returning a dead 200", async () => {
|
||||
// The likeliest real error: the listener binds fine, but nothing is
|
||||
// serving on the endpoint's port. Without the probe this returns 200 and
|
||||
// the user gets a blank frame with no message.
|
||||
tunnelConnectsOn(41234);
|
||||
forwardOut.mockRejectedValue(
|
||||
it("reports the service's reason as a 502 instead of a dead 200", async () => {
|
||||
forward.mockRejectedValue(
|
||||
new Error("Channel open failure: connect failed"),
|
||||
);
|
||||
const { handleWebEndpointOpen } =
|
||||
await import("../../src/backend/routes.js");
|
||||
const { handleWebEndpointOpen } = await load();
|
||||
const res = response();
|
||||
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
|
||||
|
||||
expect(res.statusCode).toBe(502);
|
||||
expect(String(res.body.error)).toMatch(/connect failed/);
|
||||
// Nothing half-built is left registered.
|
||||
expect(manager.cleanupTunnelResources).toHaveBeenCalledWith(
|
||||
"web:7:e1",
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("surfaces a connection failure reported only over the status map", async () => {
|
||||
// connectSSHTunnel never rejects and resolves before the SSH handshake, so
|
||||
// without waitForTunnelSettled this path would read as success.
|
||||
manager.connectSSHTunnel.mockImplementation(
|
||||
async (config: { name: string }) => {
|
||||
manager.connectionStatus.set(config.name, {
|
||||
connected: false,
|
||||
reason: "All authentication methods failed",
|
||||
});
|
||||
},
|
||||
);
|
||||
const { handleWebEndpointOpen } =
|
||||
await import("../../src/backend/routes.js");
|
||||
it("answers 503 while the tunnels plugin is not available", async () => {
|
||||
const { handleWebEndpointOpen } = await load({ tunnels: false });
|
||||
const res = response();
|
||||
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
|
||||
|
||||
expect(res.statusCode).toBe(502);
|
||||
expect(String(res.body.error)).toMatch(/authentication methods failed/);
|
||||
expect(res.statusCode).toBe(503);
|
||||
});
|
||||
|
||||
it("rejects a malformed request before touching the database", async () => {
|
||||
const { handleWebEndpointOpen } =
|
||||
await import("../../src/backend/routes.js");
|
||||
const { handleWebEndpointOpen } = await load();
|
||||
for (const body of [
|
||||
{},
|
||||
{ hostId: "7", endpointId: "e1" },
|
||||
@@ -305,27 +197,13 @@ describe("POST /tunnel/web-endpoint/open", () => {
|
||||
});
|
||||
|
||||
/**
|
||||
* The client path and the server path have to agree.
|
||||
*
|
||||
* This guards a real bug: the route once served the unprefixed form while the
|
||||
* client called the full one, so every call 404d and no handler unit test
|
||||
* noticed, because those call the handler directly. It was caught only by
|
||||
* opening a tunnel against a deployed build.
|
||||
*
|
||||
* The shape changed with A4. The route used to be composed of a mount point in
|
||||
* the tunnel service (/ssh/tunnel/web-endpoint, port 30003) plus "/open" on
|
||||
* this router. Now core mounts every plugin at /plugin-api/<id>, so the client
|
||||
* calls /plugin-api/web-endpoint/open and the only half this plugin owns is
|
||||
* "/open". Asserting that keeps the guard meaningful without re-encoding a
|
||||
* prefix the plugin no longer controls.
|
||||
* The client path and the server path have to agree. Core mounts every
|
||||
* plugin at /plugin-api/<id>, so the client calls /plugin-api/web-endpoint/open
|
||||
* and the only half this plugin owns is "/open".
|
||||
*/
|
||||
describe("route registration", () => {
|
||||
it("registers /open on the router core hands it", async () => {
|
||||
const routes = await import("../../src/backend/routes.js");
|
||||
const express = (await import("express")).default;
|
||||
const mountOn = express.Router();
|
||||
|
||||
routes.startWebEndpointService(mountOn);
|
||||
const routes = await load();
|
||||
|
||||
const paths = (
|
||||
routes.router as unknown as {
|
||||
@@ -344,7 +222,6 @@ describe("route registration", () => {
|
||||
"utf8",
|
||||
);
|
||||
|
||||
// ctx.http.router() is what puts this plugin at /plugin-api/web-endpoint.
|
||||
expect(source).toContain("ctx.http.router()");
|
||||
});
|
||||
});
|
||||
@@ -1,136 +0,0 @@
|
||||
import net from "node:net";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* Exercises establishDirectTunnel's local branch against a REAL TCP listener.
|
||||
* The two behaviours here are the ones a mocked test cannot show:
|
||||
*
|
||||
* - under sourcePort 0 the kernel assigns the port, so the runtime has to
|
||||
* record what it actually bound rather than the 0 it was asked for; and
|
||||
* - an idle tunnel closes itself and REMOVES its runtime entry, so nothing
|
||||
* keeps handing out a port whose listener is gone.
|
||||
*
|
||||
* The SSH half is stubbed -- these behaviours live entirely in the listener
|
||||
* and the runtime bookkeeping.
|
||||
*/
|
||||
const sshPrimitives = vi.hoisted(() => ({
|
||||
forwardOut: vi.fn(async () => {
|
||||
throw new Error("not used in these tests");
|
||||
}),
|
||||
pipeTunnelStreams: vi.fn(),
|
||||
handleSocks5Connect: vi.fn(),
|
||||
}));
|
||||
vi.mock(
|
||||
"../../../../src/backend/hosts/tunnel/ssh-primitives.js",
|
||||
() => sshPrimitives,
|
||||
);
|
||||
|
||||
function tunnelConfig(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
name: "web:7:e1",
|
||||
scope: "s2s",
|
||||
mode: "local",
|
||||
tunnelType: "local",
|
||||
bindHost: "127.0.0.1",
|
||||
sourcePort: 0,
|
||||
endpointHost: "127.0.0.1",
|
||||
endpointPort: 9,
|
||||
...overrides,
|
||||
} as never;
|
||||
}
|
||||
|
||||
const fakeClient = () => ({ on: vi.fn(), end: vi.fn() }) as never;
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe("establishDirectTunnel records the port it actually bound", () => {
|
||||
it("reports the kernel-assigned port, never the requested 0", async () => {
|
||||
const manager =
|
||||
await import("../../../../src/backend/hosts/tunnel/manager.js");
|
||||
await manager.establishDirectTunnel(fakeClient(), tunnelConfig());
|
||||
|
||||
const runtime = manager.activeTunnelRuntimes.get("web:7:e1");
|
||||
expect(runtime).toBeDefined();
|
||||
// The whole point: 0 means "pick one for me", so a runtime advertising 0
|
||||
// would make every caller that reuses the port fail.
|
||||
expect(runtime?.bindPort).toBeGreaterThan(0);
|
||||
|
||||
// And it is genuinely listening there.
|
||||
const port = runtime!.bindPort as number;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const socket = net.connect(port, "127.0.0.1", () => {
|
||||
socket.destroy();
|
||||
resolve();
|
||||
});
|
||||
socket.once("error", reject);
|
||||
});
|
||||
|
||||
runtime?.close();
|
||||
manager.activeTunnelRuntimes.delete("web:7:e1");
|
||||
});
|
||||
|
||||
it("honours a fixed source port when one is asked for", async () => {
|
||||
const manager =
|
||||
await import("../../../../src/backend/hosts/tunnel/manager.js");
|
||||
// Borrow a free port, release it, then ask for it explicitly.
|
||||
const probe = net.createServer();
|
||||
const wanted = await new Promise<number>((resolve) => {
|
||||
probe.listen(0, "127.0.0.1", () =>
|
||||
resolve((probe.address() as { port: number }).port),
|
||||
);
|
||||
});
|
||||
await new Promise<void>((resolve) => probe.close(() => resolve()));
|
||||
|
||||
await manager.establishDirectTunnel(
|
||||
fakeClient(),
|
||||
tunnelConfig({ name: "web:7:fixed", sourcePort: wanted }),
|
||||
);
|
||||
const runtime = manager.activeTunnelRuntimes.get("web:7:fixed");
|
||||
expect(runtime?.bindPort).toBe(wanted);
|
||||
|
||||
runtime?.close();
|
||||
manager.activeTunnelRuntimes.delete("web:7:fixed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("idle close", () => {
|
||||
it("removes the runtime entry, not just the listener", async () => {
|
||||
// Real timers deliberately: establishDirectTunnel awaits real socket I/O,
|
||||
// and mixing fake timers with that is a known route to a hanging test.
|
||||
const manager =
|
||||
await import("../../../../src/backend/hosts/tunnel/manager.js");
|
||||
await manager.establishDirectTunnel(
|
||||
fakeClient(),
|
||||
tunnelConfig({ name: "web:7:idle", idleTimeoutMs: 60 }),
|
||||
);
|
||||
expect(manager.activeTunnelRuntimes.has("web:7:idle")).toBe(true);
|
||||
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
// Deleting the entry is the assertion. close() alone would stop the
|
||||
// listener but leave the entry behind, so the open route would keep
|
||||
// returning a port nothing is listening on.
|
||||
expect(manager.activeTunnelRuntimes.has("web:7:idle")).toBe(false);
|
||||
},
|
||||
{ timeout: 4000, interval: 25 },
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a tunnel with no idle timeout running", async () => {
|
||||
// Without this, an idle-close bug that fires unconditionally would still
|
||||
// pass the test above.
|
||||
const manager =
|
||||
await import("../../../../src/backend/hosts/tunnel/manager.js");
|
||||
await manager.establishDirectTunnel(
|
||||
fakeClient(),
|
||||
tunnelConfig({ name: "web:7:forever" }),
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
expect(manager.activeTunnelRuntimes.has("web:7:forever")).toBe(true);
|
||||
|
||||
manager.activeTunnelRuntimes.get("web:7:forever")?.close();
|
||||
manager.activeTunnelRuntimes.delete("web:7:forever");
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,6 @@ vi.mock("@/lib/electron", () => ({ isElectron }));
|
||||
|
||||
const tunnelPost = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/main-axios", () => ({
|
||||
tunnelApi: { post: tunnelPost },
|
||||
handleApiError: (error: unknown) => {
|
||||
throw new Error(`generic: ${String(error)}`);
|
||||
},
|
||||
@@ -32,7 +31,10 @@ let pageHostname = "localhost";
|
||||
const realLocation = window.location;
|
||||
const windowOpen = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
const { setWebEndpointApi } =
|
||||
await import("../../src/frontend/web-endpoint-api");
|
||||
setWebEndpointApi({ post: tunnelPost } as never);
|
||||
pageHostname = "localhost";
|
||||
isElectron.mockReturnValue(false);
|
||||
tunnelPost.mockReset();
|
||||
@@ -54,17 +56,14 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("openWebEndpointTunnel", () => {
|
||||
it("posts a path relative to the tunnel base, never one starting /ssh", async () => {
|
||||
// tunnelApi's baseURL already includes /ssh; a leading "/ssh" here would
|
||||
// resolve to /ssh/ssh/... and 404 on every call. Asserted on the captured
|
||||
// runtime argument rather than by scanning source, so quoting style and
|
||||
// indirection cannot fool it.
|
||||
it("posts to the plugin's own /open route", async () => {
|
||||
// app.api is already rooted at /plugin-api/web-endpoint, so the path is
|
||||
// relative to that and must not repeat the prefix.
|
||||
const { openWebEndpointTunnel } =
|
||||
await import("../../src/frontend/web-endpoint-api");
|
||||
await openWebEndpointTunnel(7, "e1");
|
||||
|
||||
expect(tunnelPost.mock.calls[0][0]).not.toMatch(/^\/ssh\//);
|
||||
expect(tunnelPost).toHaveBeenCalledWith("/tunnel/web-endpoint/open", {
|
||||
expect(tunnelPost).toHaveBeenCalledWith("/open", {
|
||||
hostId: 7,
|
||||
endpointId: "e1",
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user