mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
feat: serve plugin http and websockets from one mount instead of ports
This commit is contained in:
1 parent
c496a093ca
commit
8431570ff7
102 files changed
+2894
-2308
No files matched your search
+29
-20
@@ -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
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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" },
|
||||
});
|
||||
|
||||
|
||||
@@ -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: [],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user