feat: serve plugin http and websockets from one mount instead of ports

This commit is contained in:
LukeGus committed 2026-09-22 16:39:41 -05:00
1 parent c496a093ca
commit 8431570ff7
102 files changed
+2894 -2308

No files matched your search

+29 -20
View File
@@ -52,7 +52,7 @@ export interface AiStatus {
export async function getAiStatus(): Promise<AiStatus> {
try {
return (await authApi.get("/ai/status")).data;
return (await authApi.get("/plugin-api/ai/status")).data;
} catch (error) {
throw handleApiError(error, "get AI status");
}
@@ -60,7 +60,7 @@ export async function getAiStatus(): Promise<AiStatus> {
export async function getAiProviders(): Promise<AiProvider[]> {
try {
return (await authApi.get("/ai/providers")).data.providers;
return (await authApi.get("/plugin-api/ai/providers")).data.providers;
} catch (error) {
throw handleApiError(error, "list AI providers");
}
@@ -74,7 +74,8 @@ export async function createAiProvider(input: {
defaultModel?: string | null;
}): Promise<AiProvider> {
try {
return (await authApi.post("/ai/providers", input)).data.provider;
return (await authApi.post("/plugin-api/ai/providers", input)).data
.provider;
} catch (error) {
throw handleApiError(error, "create AI provider");
}
@@ -91,7 +92,8 @@ export async function updateAiProvider(
}>,
): Promise<AiProvider> {
try {
return (await authApi.patch(`/ai/providers/${id}`, input)).data.provider;
return (await authApi.patch(`/plugin-api/ai/providers/${id}`, input)).data
.provider;
} catch (error) {
throw handleApiError(error, "update AI provider");
}
@@ -99,7 +101,7 @@ export async function updateAiProvider(
export async function deleteAiProvider(id: number): Promise<void> {
try {
await authApi.delete(`/ai/providers/${id}`);
await authApi.delete(`/plugin-api/ai/providers/${id}`);
} catch (error) {
throw handleApiError(error, "delete AI provider");
}
@@ -120,7 +122,7 @@ export async function probeAiModels(input: {
warning?: string;
}> {
try {
return (await authApi.post("/ai/probe-models", input)).data;
return (await authApi.post("/plugin-api/ai/probe-models", input)).data;
} catch (error) {
throw handleApiError(error, "detect models");
}
@@ -128,7 +130,8 @@ export async function probeAiModels(input: {
export async function getAiProviderModels(id: number): Promise<string[]> {
try {
return (await authApi.get(`/ai/providers/${id}/models`)).data.models;
return (await authApi.get(`/plugin-api/ai/providers/${id}/models`)).data
.models;
} catch (error) {
throw handleApiError(error, "list provider models");
}
@@ -136,7 +139,8 @@ export async function getAiProviderModels(id: number): Promise<string[]> {
export async function getAiConversations(): Promise<AiConversation[]> {
try {
return (await authApi.get("/ai/conversations")).data.conversations;
return (await authApi.get("/plugin-api/ai/conversations")).data
.conversations;
} catch (error) {
throw handleApiError(error, "list AI conversations");
}
@@ -148,7 +152,7 @@ export async function getAiConversation(id: number): Promise<{
proposals: AiProposal[];
}> {
try {
return (await authApi.get(`/ai/conversations/${id}`)).data;
return (await authApi.get(`/plugin-api/ai/conversations/${id}`)).data;
} catch (error) {
throw handleApiError(error, "load AI conversation");
}
@@ -156,7 +160,7 @@ export async function getAiConversation(id: number): Promise<{
export async function deleteAiConversation(id: number): Promise<void> {
try {
await authApi.delete(`/ai/conversations/${id}`);
await authApi.delete(`/plugin-api/ai/conversations/${id}`);
} catch (error) {
throw handleApiError(error, "delete AI conversation");
}
@@ -166,7 +170,7 @@ export async function applyAiProposal(
id: number,
): Promise<{ success: boolean; summary: string }> {
try {
return (await authApi.post(`/ai/proposals/${id}/apply`)).data;
return (await authApi.post(`/plugin-api/ai/proposals/${id}/apply`)).data;
} catch (error) {
throw handleApiError(error, "apply AI proposal");
}
@@ -174,7 +178,7 @@ export async function applyAiProposal(
export async function rejectAiProposal(id: number): Promise<void> {
try {
await authApi.post(`/ai/proposals/${id}/reject`);
await authApi.post(`/plugin-api/ai/proposals/${id}/reject`);
} catch (error) {
throw handleApiError(error, "reject AI proposal");
}
@@ -191,10 +195,13 @@ export async function markAiProposalRunInTerminal(
): Promise<{ success: boolean; summary: string }> {
try {
return (
await authApi.post(`/ai/proposals/${id}/mark-run-in-terminal`, {
hostId,
summary,
})
await authApi.post(
`/plugin-api/ai/proposals/${id}/mark-run-in-terminal`,
{
hostId,
summary,
},
)
).data;
} catch (error) {
throw handleApiError(error, "mark AI proposal run in terminal");
@@ -205,7 +212,7 @@ export async function markAiProposalRunInTerminal(
export async function getAiGloballyEnabled(): Promise<boolean> {
try {
return (await authApi.get("/ai/enabled")).data.enabled;
return (await authApi.get("/plugin-api/ai/enabled")).data.enabled;
} catch (error) {
throw handleApiError(error, "get AI enabled setting");
}
@@ -213,7 +220,8 @@ export async function getAiGloballyEnabled(): Promise<boolean> {
export async function setAiGloballyEnabled(enabled: boolean): Promise<boolean> {
try {
return (await authApi.patch("/ai/enabled", { enabled })).data.enabled;
return (await authApi.patch("/plugin-api/ai/enabled", { enabled })).data
.enabled;
} catch (error) {
throw handleApiError(error, "update AI enabled setting");
}
@@ -221,7 +229,7 @@ export async function setAiGloballyEnabled(enabled: boolean): Promise<boolean> {
export async function getAiPrivateEndpoints(): Promise<string[]> {
try {
return (await authApi.get("/ai/private-endpoints")).data.hosts;
return (await authApi.get("/plugin-api/ai/private-endpoints")).data.hosts;
} catch (error) {
throw handleApiError(error, "get AI endpoint allowlist");
}
@@ -231,7 +239,8 @@ export async function setAiPrivateEndpoints(
hosts: string[],
): Promise<string[]> {
try {
return (await authApi.patch("/ai/private-endpoints", { hosts })).data.hosts;
return (await authApi.patch("/plugin-api/ai/private-endpoints", { hosts }))
.data.hosts;
} catch (error) {
throw handleApiError(error, "update AI endpoint allowlist");
}
+41 -22
View File
@@ -60,7 +60,7 @@ export type FleetPackageAction = "install" | "remove" | "upgrade-all";
export async function listFleets(): Promise<FleetRow[]> {
try {
const response = await authApi.get("/fleets");
const response = await authApi.get("/plugin-api/fleets");
return response.data;
} catch (error) {
throw handleApiError(error, "fetch fleets");
@@ -75,7 +75,7 @@ export async function createFleet(fleetData: {
tagRules?: string[];
}): Promise<FleetRow> {
try {
const response = await authApi.post("/fleets", fleetData);
const response = await authApi.post("/plugin-api/fleets", fleetData);
return response.data;
} catch (error) {
throw handleApiError(error, "create fleet");
@@ -93,7 +93,10 @@ export async function updateFleet(
},
): Promise<FleetRow> {
try {
const response = await authApi.patch(`/fleets/${fleetId}`, fleetData);
const response = await authApi.patch(
`/plugin-api/fleets/${fleetId}`,
fleetData,
);
return response.data;
} catch (error) {
throw handleApiError(error, "update fleet");
@@ -104,7 +107,7 @@ export async function deleteFleet(
fleetId: number,
): Promise<{ success: boolean }> {
try {
const response = await authApi.delete(`/fleets/${fleetId}`);
const response = await authApi.delete(`/plugin-api/fleets/${fleetId}`);
return response.data;
} catch (error) {
throw handleApiError(error, "delete fleet");
@@ -115,7 +118,7 @@ export async function getFleetMembers(
fleetId: number,
): Promise<FleetMemberRow[]> {
try {
const response = await authApi.get(`/fleets/${fleetId}/members`);
const response = await authApi.get(`/plugin-api/fleets/${fleetId}/members`);
return response.data;
} catch (error) {
throw handleApiError(error, "fetch fleet members");
@@ -127,9 +130,12 @@ export async function addFleetMember(
hostId: number,
): Promise<{ success: boolean }> {
try {
const response = await authApi.post(`/fleets/${fleetId}/members`, {
hostId,
});
const response = await authApi.post(
`/plugin-api/fleets/${fleetId}/members`,
{
hostId,
},
);
return response.data;
} catch (error) {
throw handleApiError(error, "add fleet member");
@@ -142,7 +148,7 @@ export async function removeFleetMember(
): Promise<{ success: boolean }> {
try {
const response = await authApi.delete(
`/fleets/${fleetId}/members/${hostId}`,
`/plugin-api/fleets/${fleetId}/members/${hostId}`,
);
return response.data;
} catch (error) {
@@ -156,10 +162,13 @@ export async function runFleetCommand(
inputValues?: Record<string, string>,
): Promise<{ results: FleetHostResult[] }> {
try {
const response = await authApi.post(`/fleets/${fleetId}/execute`, {
command,
...(inputValues ? { inputValues } : {}),
});
const response = await authApi.post(
`/plugin-api/fleets/${fleetId}/execute`,
{
command,
...(inputValues ? { inputValues } : {}),
},
);
return response.data;
} catch (error) {
throw handleApiError(error, "run fleet command");
@@ -176,7 +185,7 @@ export async function pushFleetFile(
form.append("file", file);
form.append("remotePath", remotePath);
const response = await authApi.post(
`/fleets/${fleetId}/transfer/push`,
`/plugin-api/fleets/${fleetId}/transfer/push`,
form,
{ headers: { "Content-Type": "multipart/form-data" } },
);
@@ -192,7 +201,7 @@ export async function pullFleetFile(
): Promise<{ results: FleetHostResult[]; blob: Blob; fileName: string }> {
try {
const response = await authApi.post(
`/fleets/${fleetId}/transfer/pull`,
`/plugin-api/fleets/${fleetId}/transfer/pull`,
{ remotePath },
{ responseType: "blob" },
);
@@ -217,7 +226,9 @@ export async function getFleetInventory(
fleetId: number,
): Promise<FleetInventoryEntry[]> {
try {
const response = await authApi.get(`/fleets/${fleetId}/inventory`);
const response = await authApi.get(
`/plugin-api/fleets/${fleetId}/inventory`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "fetch fleet inventory");
@@ -228,7 +239,9 @@ export async function refreshFleetInventory(
fleetId: number,
): Promise<{ results: FleetHostResult[] }> {
try {
const response = await authApi.post(`/fleets/${fleetId}/inventory`);
const response = await authApi.post(
`/plugin-api/fleets/${fleetId}/inventory`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "refresh fleet inventory");
@@ -241,10 +254,13 @@ export async function runFleetPackageAction(
packageName?: string,
): Promise<{ results: FleetHostResult[] }> {
try {
const response = await authApi.post(`/fleets/${fleetId}/packages`, {
action,
...(packageName ? { package: packageName } : {}),
});
const response = await authApi.post(
`/plugin-api/fleets/${fleetId}/packages`,
{
action,
...(packageName ? { package: packageName } : {}),
},
);
return response.data;
} catch (error) {
throw handleApiError(error, "run fleet package action");
@@ -273,7 +289,10 @@ export async function shareFleet(
hostResults: FleetShareHostResult[];
}> {
try {
const response = await authApi.post(`/fleets/${fleetId}/share`, shareData);
const response = await authApi.post(
`/plugin-api/fleets/${fleetId}/share`,
shareData,
);
return response.data;
} catch (error) {
throw handleApiError(error, "share fleet");
+4 -2
View File
@@ -102,7 +102,7 @@ export async function executeSnippet(
export async function getNetworkTopology(): Promise<NetworkTopologyData | null> {
try {
const response = await authApi.get("/network-topology/");
const response = await authApi.get("/plugin-api/network-topology/");
return response.data;
} catch (error) {
throw handleApiError(error, "fetch network topology");
@@ -113,7 +113,9 @@ export async function saveNetworkTopology(
topology: NetworkTopologyData,
): Promise<{ success: boolean }> {
try {
const response = await authApi.post("/network-topology/", { topology });
const response = await authApi.post("/plugin-api/network-topology/", {
topology,
});
return response.data;
} catch (error) {
throw handleApiError(error, "save network topology");
+3 -3
View File
@@ -188,7 +188,7 @@ export async function discoverProxmoxGuests(
): Promise<ProxmoxDiscoverResult> {
try {
const response = await authApi.post(
"/proxmox/discover",
"/plugin-api/proxmox/discover",
{ hostId },
{ timeout: 120000 },
);
@@ -208,7 +208,7 @@ export function discoverProxmoxGuestsStream(
): () => void {
const baseURL = (authApi.defaults.baseURL || "").replace(/\/$/, "");
const source = new EventSource(
`${baseURL}/proxmox/discover/stream?hostId=${encodeURIComponent(
`${baseURL}/plugin-api/proxmox/discover/stream?hostId=${encodeURIComponent(
String(hostId),
)}`,
{ withCredentials: true },
@@ -257,7 +257,7 @@ export async function syncProxmoxGuests(
): Promise<ProxmoxSyncResult> {
try {
const response = await authApi.post(
"/proxmox/sync",
"/plugin-api/proxmox/sync",
{ hostId },
{ timeout: 120000 },
);
+14 -24
View File
@@ -38,8 +38,7 @@ import { getGuacamoleTokenFromHost } from "../../../../plugins/remote-desktop/sr
import { getSSHHosts, getUserList, type SSHHostWithStatus } from "@/main-axios";
import { getRoles } from "@/api/rbac-api";
import type { Role } from "@/main-axios";
import { getBasePath } from "@/lib/base-path";
import { isElectron } from "@/lib/electron";
import { pluginWsUrl } from "@/lib/plugin-transport";
import { getErrorMessage } from "@/lib/error-message";
import {
deleteCollabRoom,
@@ -62,23 +61,11 @@ import { resolveConnectionOrigin } from "@/lib/connection-origin";
const PING_INTERVAL_MS = 30000;
const POLL_FALLBACK_MS = 15000;
// Mirrors SharedSessionView's construction (dev/electron/prod); authentication
// rides on the jwt cookie the way every terminal WS connection does.
function roomEventsWsUrl(): string {
const isDev =
!isElectron() &&
process.env.NODE_ENV === "development" &&
(window.location.port === "3000" ||
window.location.port === "5173" ||
window.location.port === "");
if (isDev) {
return `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30002`;
}
if (isElectron()) {
return "ws://127.0.0.1:30002";
}
const wsProtocol = window.location.protocol === "https:" ? "wss" : "ws";
return `${wsProtocol}://${window.location.host}${getBasePath()}/ssh/websocket/`;
// The terminal socket the ssh-terminal plugin serves. Authentication rides on
// the jwt cookie the way every terminal WS connection does.
async function roomEventsWsUrl(): Promise<string | null> {
const target = await pluginWsUrl("ssh-terminal", "/terminal");
return target?.url ?? null;
}
type PresentDraft =
@@ -194,13 +181,16 @@ export function CollabRoomTab({
if (pollTimer) clearInterval(pollTimer);
pollTimer = null;
};
const connect = () => {
const connect = async () => {
if (cancelled) return;
try {
ws = new WebSocket(roomEventsWsUrl());
const url = await roomEventsWsUrl();
if (cancelled) return;
if (!url) throw new Error("No terminal endpoint is available");
ws = new WebSocket(url);
} catch {
startPolling();
reconnectTimer = setTimeout(connect, 5000);
reconnectTimer = setTimeout(() => void connect(), 5000);
return;
}
ws.onopen = () => {
@@ -246,12 +236,12 @@ export function CollabRoomTab({
pingTimer = null;
startPolling();
const delay = Math.min(1000 * 2 ** reconnectAttempt++, 15000);
reconnectTimer = setTimeout(connect, delay);
reconnectTimer = setTimeout(() => void connect(), delay);
};
ws.onerror = () => ws?.close();
};
connect();
void connect();
return () => {
cancelled = true;
if (pingTimer) clearInterval(pingTimer);
@@ -9,8 +9,7 @@ import {
type ShareLinkErrorKind,
} from "@/api/session-sharing-api";
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
import { getBasePath } from "@/lib/base-path";
import { isElectron } from "@/lib/electron";
import { pluginWsUrl } from "@/lib/plugin-transport";
import { GuacamoleDisplay } from "../../../../plugins/remote-desktop/src/frontend/GuacamoleDisplay.tsx";
const PING_INTERVAL_MS = 30000;
@@ -27,21 +26,9 @@ interface TerminalWsMessage {
// embedded local backend -- joining a session hosted on someone else's
// remote server isn't supported from the desktop app today.
async function resolveTerminalWsBaseUrl(): Promise<string> {
const isDev =
!isElectron() &&
process.env.NODE_ENV === "development" &&
(window.location.port === "3000" ||
window.location.port === "5173" ||
window.location.port === "");
if (isDev) {
return `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30002`;
}
if (isElectron()) {
return "ws://127.0.0.1:30002";
}
const wsProtocol = window.location.protocol === "https:" ? "wss" : "ws";
return `${wsProtocol}://${window.location.host}${getBasePath()}/ssh/websocket/`;
const target = await pluginWsUrl("ssh-terminal", "/terminal");
if (!target) throw new Error("No terminal endpoint is available");
return target.url;
}
export interface SessionParticipantInfo {
+12 -27
View File
@@ -27,11 +27,8 @@ import { Unicode11Addon } from "@xterm/addon-unicode11";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { SearchAddon } from "@xterm/addon-search";
import { useTranslation } from "react-i18next";
import { getBasePath } from "@/lib/base-path";
import {
resolveConnectionOrigin,
buildOriginWsUrl,
} from "@/lib/connection-origin.ts";
import { resolveConnectionOrigin } from "@/lib/connection-origin.ts";
import { pluginWsUrl } from "@/lib/plugin-transport";
import {
getCookie,
isElectron,
@@ -1536,30 +1533,20 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
shouldNotReconnectRef.current = false;
}
const isDev =
!isElectron() &&
process.env.NODE_ENV === "development" &&
(window.location.port === "3000" ||
window.location.port === "5173" ||
window.location.port === "");
let baseWsUrl: string;
let wsProtocols: string[] = [];
let outboundHostConfig = hostConfig;
if (isDev) {
baseWsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30002`;
} else if (isElectron()) {
const origin = await resolveConnectionOrigin({
connectionType: "ssh",
connectionOrigin: hostConfig.connectionOrigin as
"local" | "remote" | null | undefined,
});
const resolvedUrl = await buildOriginWsUrl({
{
const origin = isElectron()
? await resolveConnectionOrigin({
connectionType: "ssh",
connectionOrigin: hostConfig.connectionOrigin as
"local" | "remote" | null | undefined,
})
: "local";
const resolvedUrl = await pluginWsUrl("ssh-terminal", "/terminal", {
origin,
localPort: 30002,
localPath: "",
remotePath: "/ssh/websocket/",
});
if (!resolvedUrl) {
setIsConnected(false);
@@ -1568,7 +1555,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
isConnectingRef.current = false;
return;
}
if (origin === "local") {
if (isElectron() && origin === "local") {
try {
outboundHostConfig = await hydrateLocalSharedHostAuth(hostConfig);
} catch (error) {
@@ -1586,8 +1573,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
}
baseWsUrl = resolvedUrl.url;
wsProtocols = resolvedUrl.protocols;
} else {
baseWsUrl = `${getBasePath()}/ssh/websocket/`;
}
if (
+126
View File
@@ -0,0 +1,126 @@
/**
* How a plugin frontend reaches its own backend.
*
* Every plugin serves HTTP under /plugin-api/<id>/ and WebSockets under
* /plugin-ws/<id>/<path> on the main backend. No plugin has a port of its own,
* so these two helpers are all a plugin frontend needs, in every deployment:
*
* - web, same origin, optionally under a base path;
* - Docker behind nginx, which proxies both prefixes;
* - the Vite dev server, which proxies through /__termix_api/30001;
* - Electron with its embedded local backend;
* - Electron pointed at a remote Termix server, for hosts whose connection
* origin resolves there.
*
* A7 re-exports both through @termix/plugin-sdk/frontend. They live here for
* now because plugin frontends are still bundled with the shell.
*/
import type { AxiosInstance } from "axios";
import { authApi } from "@/main-axios";
import { getBasePath } from "@/lib/base-path";
import { isElectron } from "@/lib/electron";
import { websocketAuthProtocols } from "@/lib/ws-auth";
import {
buildOriginWsUrl,
type ConnectionOrigin,
type WebSocketConnectionTarget,
} from "@/lib/connection-origin";
/** The backend port. Everything plugin-facing rides the main server. */
const BACKEND_PORT = 30001;
export function pluginApiPath(pluginId: string, path = ""): string {
const suffix = path && !path.startsWith("/") ? `/${path}` : path;
return `/plugin-api/${pluginId}${suffix}`;
}
/**
* An axios client rooted at this plugin's /plugin-api mount point.
*
* Built on the shared authenticated instance, so a plugin inherits the same
* interceptors as core: auth headers, session expiry handling and error
* reporting all behave identically.
*/
export function createPluginApi(pluginId: string): AxiosInstance {
const prefix = pluginApiPath(pluginId);
// A thin proxy rather than a new axios instance: the shared one resolves its
// baseURL per deployment already, and duplicating that here is how the two
// drift apart.
return new Proxy(authApi, {
get(target, property, receiver) {
const value = Reflect.get(target, property, receiver);
if (typeof value !== "function") return value;
if (
property === "get" ||
property === "delete" ||
property === "head" ||
property === "options" ||
property === "post" ||
property === "put" ||
property === "patch"
) {
return (url: string, ...rest: unknown[]) =>
(value as (...args: unknown[]) => unknown).call(
target,
`${prefix}${url.startsWith("/") ? url : `/${url}`}`,
...rest,
);
}
return value.bind(target);
},
}) as AxiosInstance;
}
/**
* The WebSocket URL for /plugin-ws/<id>/<path>, plus the subprotocols that
* carry the JWT.
*
* In Electron the right backend depends on where the host actually lives, so
* pass the resolved origin; without one it targets the embedded local backend,
* which is what a plugin with no per-host notion of origin wants.
*/
export async function pluginWsUrl(
pluginId: string,
path: string,
options: { origin?: ConnectionOrigin } = {},
): Promise<WebSocketConnectionTarget | null> {
const suffix = path.startsWith("/") ? path : `/${path}`;
const route = `/plugin-ws/${pluginId}${suffix}`;
if (isElectron()) {
return buildOriginWsUrl({
origin: options.origin ?? "local",
localPort: BACKEND_PORT,
localPath: route,
remotePath: route,
});
}
const token = localStorage.getItem("jwt");
const protocols = websocketAuthProtocols(token);
// Dev without a configured API host goes through Vite's proxy, which is
// reached on the page's own origin rather than the backend port.
const devProxy =
process.env.NODE_ENV === "development" &&
!import.meta.env.VITE_API_HOST &&
(window.location.port === "3000" || window.location.port === "5173");
const wsProtocol = window.location.protocol === "https:" ? "wss" : "ws";
if (devProxy) {
return {
url: `${wsProtocol}://${window.location.host}/__termix_api/${BACKEND_PORT}${route}`,
protocols,
};
}
return {
url: `${wsProtocol}://${window.location.host}${getBasePath()}${route}`,
protocols,
};
}
+2 -2
View File
@@ -1,5 +1,5 @@
import type { AxiosInstance } from "axios";
import { getRemoteStatsApi } from "@/main-axios";
import { getRemoteCoreApi } from "@/main-axios";
import { isElectron } from "@/lib/electron";
import type { SSHHost } from "@/types/index";
@@ -77,7 +77,7 @@ export async function getConnectedRemoteApi(): Promise<AxiosInstance | null> {
const config = (await window.electronAPI?.invoke?.(
"get-remote-sync-config",
)) as { serverUrl?: string } | null;
return config?.serverUrl ? getRemoteStatsApi() : null;
return config?.serverUrl ? getRemoteCoreApi() : null;
} catch {
return null;
}
+30 -7
View File
@@ -833,6 +833,7 @@ function createRemoteOriginApiInstance(path: string): AxiosInstance {
let remoteFileManagerApi: AxiosInstance | null = null;
let remoteTunnelApi: AxiosInstance | null = null;
let remoteStatsApi: AxiosInstance | null = null;
let remoteCoreApi: AxiosInstance | null = null;
let remoteGuacamoleApi: AxiosInstance | null = null;
export function getRemoteFileManagerApi(): AxiosInstance {
@@ -851,11 +852,25 @@ export function getRemoteTunnelApi(): AxiosInstance {
export function getRemoteStatsApi(): AxiosInstance {
if (!remoteStatsApi) {
remoteStatsApi = createRemoteOriginApiInstance("");
remoteStatsApi = createRemoteOriginApiInstance("/plugin-api/host-metrics");
}
return remoteStatsApi;
}
/**
* The remote server's core routes, unprefixed.
*
* Separate from getRemoteStatsApi because that one is rooted at the
* host-metrics plugin's mount point, and callers reaching core routes such as
* /sync/hosts must not inherit it.
*/
export function getRemoteCoreApi(): AxiosInstance {
if (!remoteCoreApi) {
remoteCoreApi = createRemoteOriginApiInstance("");
}
return remoteCoreApi;
}
export function getRemoteGuacamoleApi(): AxiosInstance {
if (!remoteGuacamoleApi) {
remoteGuacamoleApi = createRemoteOriginApiInstance("");
@@ -917,8 +932,13 @@ function initializeApiInstances() {
"FILE_MANAGER",
);
// Server Statistics API (port 30005)
statsApi = createApiInstance(getApiUrl("", 30005), "STATS");
// Host Metrics API - the host-metrics plugin, mounted on the main backend.
// Every existing /status, /metrics/*, /host-metrics/* and /proxmox-stats/*
// path still resolves, because the base carries the plugin mount point.
statsApi = createApiInstance(
getApiUrl("/plugin-api/host-metrics", 30001),
"STATS",
);
// Authentication API (port 30001)
authApi = createApiInstance(getApiUrl("", 30001), "AUTH");
@@ -929,8 +949,11 @@ function initializeApiInstances() {
// RBAC API (port 30001)
rbacApi = createApiInstance(getApiUrl("", 30001), "RBAC");
// Docker Management API (port 30007)
dockerApi = createApiInstance(getApiUrl("/docker", 30007), "DOCKER");
// Docker Management API - the docker plugin, mounted on the main backend.
dockerApi = createApiInstance(
getApiUrl("/plugin-api/docker/docker", 30001),
"DOCKER",
);
// Tmux Monitor API (port 30010) --- tmux-monitor ---
tmuxMonitorApi = createApiInstance(
@@ -953,7 +976,7 @@ export let tunnelApi: AxiosInstance;
// File Manager Operations API (port 30004)
export let fileManagerApi: AxiosInstance;
// Server Statistics API (port 30005)
// Host Metrics API (host-metrics plugin)
export let statsApi: AxiosInstance;
// Authentication API (port 30001)
@@ -965,7 +988,7 @@ export let dashboardApi: AxiosInstance;
// RBAC API (port 30001)
export let rbacApi: AxiosInstance;
// Docker Management API (port 30007)
// Docker Management API (docker plugin)
export let dockerApi: AxiosInstance;
// Tmux Monitor API (port 30010) --- tmux-monitor ---
@@ -114,7 +114,7 @@ describe("SharedSessionView", () => {
api.resolveShareLink.mockResolvedValue({
protocol: "rdp",
permissionLevel: "read-only",
wsPath: "/guacamole/websocket/",
wsPath: "/plugin-ws/remote-desktop/display",
connectParams: { token: "guac-join-token" },
});
+24 -23
View File
@@ -127,14 +127,15 @@ describe("resolveConnectionOrigin", () => {
});
/**
* The embedded backend authenticates a local WebSocket from `?token=`, because
* the browser WebSocket API cannot set an Authorization header. Electron's main
* process does inject a JWT cookie, but only on an exact origin match, and the
* remembered cookie belongs to the API origin (`localhost:30001`) — so nothing
* is attached to a `127.0.0.1:30009` connection.
* The embedded backend authenticates a local WebSocket from its subprotocol,
* because the browser WebSocket API cannot set an Authorization header.
* Electron's main process does inject a JWT cookie, but only on an exact
* origin match, so a socket has to carry the credential itself.
*
* The Docker console opted out of the query token and had no other credential
* left, so its handshake was closed with 1008 while logs and stats kept working.
* This caught a real bug: the Docker console opted out of the token and had no
* other credential left, so its handshake was closed with 1008 while logs and
* stats kept working. The fixtures below use the plugin socket routes these
* channels actually use now; every plugin shares the backend port.
*/
describe("buildOriginWsUrl", () => {
const store: Record<string, string> = {};
@@ -154,13 +155,13 @@ describe("buildOriginWsUrl", () => {
// Every interactive channel on the embedded backend relies on this.
const target = await buildOriginWsUrl({
origin: "local",
localPort: 30009,
localPath: "/docker/console/",
remotePath: "/docker/console/",
localPort: 30001,
localPath: "/plugin-ws/docker/console",
remotePath: "/plugin-ws/docker/console",
});
expect(target).toEqual({
url: "ws://127.0.0.1:30009/docker/console/",
url: "ws://127.0.0.1:30001/plugin-ws/docker/console",
protocols: ["termix.jwt.local-jwt"],
});
});
@@ -168,14 +169,14 @@ describe("buildOriginWsUrl", () => {
it("omits it only when a caller asks", async () => {
const target = await buildOriginWsUrl({
origin: "local",
localPort: 30009,
localPath: "/docker/console/",
remotePath: "/docker/console/",
localPort: 30001,
localPath: "/plugin-ws/docker/console",
remotePath: "/plugin-ws/docker/console",
includeJwt: false,
});
expect(target).toEqual({
url: "ws://127.0.0.1:30009/docker/console/",
url: "ws://127.0.0.1:30001/plugin-ws/docker/console",
protocols: [],
});
});
@@ -193,14 +194,14 @@ describe("buildOriginWsUrl", () => {
const target = await buildOriginWsUrl({
origin: "remote",
localPort: 30008,
localPath: "/guacamole/websocket/",
remotePath: "/guacamole/websocket/",
localPort: 30001,
localPath: "/plugin-ws/remote-desktop/display",
remotePath: "/plugin-ws/remote-desktop/display",
includeJwt: false,
});
expect(target).toEqual({
url: "wss://termix.example/guacamole/websocket/",
url: "wss://termix.example/plugin-ws/remote-desktop/display",
protocols: [],
});
});
@@ -210,13 +211,13 @@ describe("buildOriginWsUrl", () => {
const target = await buildOriginWsUrl({
origin: "local",
localPort: 30002,
localPath: "",
remotePath: "/ssh/websocket/",
localPort: 30001,
localPath: "/plugin-ws/ssh-terminal/terminal",
remotePath: "/plugin-ws/ssh-terminal/terminal",
});
expect(target).toEqual({
url: "ws://127.0.0.1:30002",
url: "ws://127.0.0.1:30001/plugin-ws/ssh-terminal/terminal",
protocols: [],
});
});
+1 -1
View File
@@ -5,7 +5,7 @@ const isElectron = vi.hoisted(() => vi.fn());
const remoteApi = vi.hoisted(() => ({ get: vi.fn() }));
vi.mock("@/lib/electron", () => ({ isElectron }));
vi.mock("@/main-axios", () => ({ getRemoteStatsApi: () => remoteApi }));
vi.mock("@/main-axios", () => ({ getRemoteCoreApi: () => remoteApi }));
import {
getConnectedRemoteApi,