Compare commits

..

6 Commits

Author SHA1 Message Date
Owen 7d922ac95f Add internal api get for proxy information 2026-05-13 21:54:58 -07:00
Owen 795a3d351e Reinstall packages 2026-05-13 21:16:40 -07:00
Owen 4b4c86b4b7 Clean up forms a bit 2026-05-13 21:16:00 -07:00
Owen 013af49137 Add favicon passthrough 2026-05-13 21:11:25 -07:00
Owen a6ae9290f2 Serve the resource from the right place 2026-05-13 18:01:36 -07:00
Owen de70d72e0d Add gateway endpoints into the traefik config 2026-05-13 17:33:16 -07:00
15 changed files with 581 additions and 255 deletions
+1 -1
View File
@@ -1473,7 +1473,7 @@
"node_modules/@devolutions/iron-remote-desktop-rdp": {
"version": "0.0.0",
"resolved": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.0.tgz",
"integrity": "sha512-qkpqYOMmSU6jIdDKOWpnLL7pb0sA/Y7Yjm4wI0/VbBIRfZPH8WdKxDU5DNp8jFF60l1zbcSJeRIq5yIAvws3Hw=="
"integrity": "sha512-O0YVpOJDwUzekH3N2QKj+48WP+56wI0sj4VmaJkGoW5XgyAj2ONn2k3i+vk17Eavx+Vg6vAg3lwYRAOK4kKIDQ=="
},
"node_modules/@dotenvx/dotenvx": {
"version": "1.54.1",
+298 -3
View File
@@ -12,6 +12,7 @@
*/
import {
browserGatewayTarget,
certificates,
db,
domainNamespaces,
@@ -277,6 +278,115 @@ export async function getTraefikConfig(
});
});
// Query browser gateway targets for this exit node
const browserGatewayRows = await db
.select({
// Resource fields
resourceId: resources.resourceId,
resourceName: resources.name,
fullDomain: resources.fullDomain,
ssl: resources.ssl,
subdomain: resources.subdomain,
domainId: resources.domainId,
enabled: resources.enabled,
wildcard: resources.wildcard,
domainCertResolver: domains.certResolver,
preferWildcardCert: domains.preferWildcardCert,
domainNamespaceId: domainNamespaces.domainNamespaceId,
// Browser gateway target fields
browserGatewayTargetId: browserGatewayTarget.browserGatewayTargetId,
bgType: browserGatewayTarget.type,
// Site fields
siteId: sites.siteId,
siteType: sites.type,
siteOnline: sites.online,
subnet: sites.subnet,
siteExitNodeId: sites.exitNodeId
})
.from(browserGatewayTarget)
.innerJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId))
.innerJoin(
resources,
eq(resources.resourceId, browserGatewayTarget.resourceId)
)
.leftJoin(domains, eq(domains.domainId, resources.domainId))
.leftJoin(
domainNamespaces,
eq(domainNamespaces.domainId, resources.domainId)
)
.where(
and(
eq(resources.enabled, true),
or(
eq(sites.exitNodeId, exitNodeId),
and(
isNull(sites.exitNodeId),
sql`(${siteTypes.includes("local") ? 1 : 0} = 1)`,
eq(sites.type, "local"),
sql`(${build != "saas" ? 1 : 0} = 1)`
)
),
inArray(sites.type, siteTypes)
)
);
// Group browser gateway targets by resource
type BrowserGatewayResourceEntry = {
resourceId: number;
name: string;
fullDomain: string | null;
ssl: boolean | null;
subdomain: string | null;
domainId: string | null;
enabled: boolean | null;
wildcard: boolean | null;
domainCertResolver: string | null;
preferWildcardCert: boolean | null;
targets: {
browserGatewayTargetId: number;
bgType: string;
siteId: number;
siteType: string;
siteOnline: boolean | null;
subnet: string | null;
siteExitNodeId: number | null;
}[];
};
const browserGatewayResourcesMap = new Map<
number,
BrowserGatewayResourceEntry
>();
for (const row of browserGatewayRows) {
if (filterOutNamespaceDomains && row.domainNamespaceId) {
continue;
}
if (!browserGatewayResourcesMap.has(row.resourceId)) {
browserGatewayResourcesMap.set(row.resourceId, {
resourceId: row.resourceId,
name: sanitize(row.resourceName) || "",
fullDomain: row.fullDomain,
ssl: row.ssl,
subdomain: row.subdomain,
domainId: row.domainId,
enabled: row.enabled,
wildcard: row.wildcard,
domainCertResolver: row.domainCertResolver,
preferWildcardCert: row.preferWildcardCert,
targets: []
});
}
browserGatewayResourcesMap.get(row.resourceId)!.targets.push({
browserGatewayTargetId: row.browserGatewayTargetId,
bgType: row.bgType,
siteId: row.siteId,
siteType: row.siteType,
siteOnline: row.siteOnline,
subnet: row.subnet,
siteExitNodeId: row.siteExitNodeId
});
}
let siteResourcesWithFullDomain: {
siteResourceId: number;
fullDomain: string | null;
@@ -324,6 +434,12 @@ export async function getTraefikConfig(
domains.add(sr.fullDomain);
}
}
// Include browser gateway resource domains
for (const bgResource of browserGatewayResourcesMap.values()) {
if (bgResource.enabled && bgResource.ssl && bgResource.fullDomain) {
domains.add(bgResource.fullDomain);
}
}
// get the valid certs for these domains
validCerts = await getValidCertificatesForDomains(domains, true); // we are caching here because this is called often
// logger.debug(`Valid certs for domains: ${JSON.stringify(validCerts)}`);
@@ -589,7 +705,7 @@ export async function getTraefikConfig(
resource.ssl ? entrypointHttps : entrypointHttp
],
service: maintenanceServiceName,
rule: `${rule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`))`,
rule: `${rule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`)) `,
priority: 2001,
...(resource.ssl ? { tls } : {})
};
@@ -925,6 +1041,185 @@ export async function getTraefikConfig(
}
}
// Generate Traefik config for browser gateway resources
const browserGatewayPort = 39999;
for (const [, bgResource] of browserGatewayResourcesMap.entries()) {
if (!bgResource.enabled) continue;
if (!bgResource.domainId) continue;
if (!bgResource.fullDomain) continue;
if (!config_output.http.routers) config_output.http.routers = {};
if (!config_output.http.services) config_output.http.services = {};
const fullDomain = bgResource.fullDomain;
const additionalMiddlewares =
config.getRawConfig().traefik.additional_middlewares || [];
const routerMiddlewares = [
badgerMiddlewareName,
...additionalMiddlewares
];
const hostRule = `Host(\`${fullDomain}\`)`;
// Build TLS config
let tls = {};
if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
const domainParts = fullDomain.split(".");
let wildCard: string;
if (domainParts.length <= 2) {
wildCard = `*.${domainParts.join(".")}`;
} else {
wildCard = `*.${domainParts.slice(1).join(".")}`;
}
if (!bgResource.subdomain) {
wildCard = fullDomain;
}
const globalDefaultResolver =
config.getRawConfig().traefik.cert_resolver;
const globalDefaultPreferWildcard =
config.getRawConfig().traefik.prefer_wildcard_cert;
const resolverName = bgResource.domainCertResolver
? bgResource.domainCertResolver.trim()
: globalDefaultResolver;
const preferWildcard =
bgResource.preferWildcardCert !== undefined &&
bgResource.preferWildcardCert !== null
? bgResource.preferWildcardCert
: globalDefaultPreferWildcard;
tls = {
certResolver: resolverName,
...(preferWildcard ? { domains: [{ main: wildCard }] } : {})
};
} else {
const matchingCert = validCerts.find(
(cert) => cert.queriedDomain === fullDomain
);
if (!matchingCert) {
logger.debug(
`No matching certificate found for browser gateway domain: ${fullDomain}`
);
continue;
}
}
const bgUiServiceName = `bg-r${bgResource.resourceId}-ui-service`;
if (bgResource.ssl) {
const redirectRouterName = `bg-r${bgResource.resourceId}-redirect`;
config_output.http.routers![redirectRouterName] = {
entryPoints: [config.getRawConfig().traefik.http_entrypoint],
middlewares: [redirectHttpsMiddlewareName],
service: bgUiServiceName,
rule: hostRule,
priority: 100
};
}
// Collect online sites for this resource (for any type)
const anySiteOnline = bgResource.targets.some((t) => t.siteOnline);
// Group targets by type and generate per-type websocket routers and services
const typeMap = new Map<string, typeof bgResource.targets>();
for (const t of bgResource.targets) {
if (!typeMap.has(t.bgType)) typeMap.set(t.bgType, []);
typeMap.get(t.bgType)!.push(t);
}
for (const [bgType, typedTargets] of typeMap.entries()) {
const bgKey = `bg-r${bgResource.resourceId}-${bgType}`;
const bgRouterName = `${bgKey}-router`;
const bgServiceName = `${bgKey}-service`;
const bgRule = `${hostRule} && PathPrefix(\`/gateway/${bgType}\`)`;
const servers = typedTargets
.filter((t) => {
if (!t.siteOnline && anySiteOnline) return false;
if (t.siteType === "newt") return !!t.subnet;
return false; // browser gateway only supported on newt sites
})
.map((t) => ({
url: `http://${t.subnet!.split("/")[0]}:${browserGatewayPort}`
}))
.filter((v, i, a) => a.findIndex((u) => u.url === v.url) === i);
config_output.http.routers![bgRouterName] = {
entryPoints: [
bgResource.ssl
? config.getRawConfig().traefik.https_entrypoint
: config.getRawConfig().traefik.http_entrypoint
],
middlewares: routerMiddlewares,
service: bgServiceName,
rule: bgRule,
priority: 110, // highest - websocket path takes precedence
...(bgResource.ssl ? { tls } : {})
};
config_output.http.services![bgServiceName] = {
loadBalancer: {
servers
}
};
}
// UI: serve the browser gateway page from the internal pangolin instance.
// The primary type is used for the path rewrite (e.g. /rdp), mirroring
// how the maintenance page rewrites everything to /maintenance-screen.
const primaryType = typeMap.keys().next().value as string;
const internalHost = config.getRawConfig().server.internal_hostname;
const internalPort = config.getRawConfig().server.next_port;
const uiRewriteMiddlewareName = `bg-r${bgResource.resourceId}-ui-rewrite`;
const entrypoint = bgResource.ssl
? config.getRawConfig().traefik.https_entrypoint
: config.getRawConfig().traefik.http_entrypoint;
if (!config_output.http.middlewares) {
config_output.http.middlewares = {};
}
config_output.http.middlewares![uiRewriteMiddlewareName] = {
replacePathRegex: {
regex: "^/(.*)",
replacement: `/${primaryType}`
}
};
config_output.http.services![bgUiServiceName] = {
loadBalancer: {
servers: [
{
url: `http://${internalHost}:${internalPort}`
}
]
}
};
// Assets router at higher priority so /_next files load without rewrite
config_output.http.routers![
`bg-r${bgResource.resourceId}-assets-router`
] = {
entryPoints: [entrypoint],
middlewares: routerMiddlewares,
service: bgUiServiceName,
rule: `${hostRule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`,
priority: 101,
...(bgResource.ssl ? { tls } : {})
};
// Catch-all router rewrites everything on the domain to /{primaryType}
config_output.http.routers![`bg-r${bgResource.resourceId}-ui-router`] =
{
entryPoints: [entrypoint],
middlewares: [...routerMiddlewares, uiRewriteMiddlewareName],
service: bgUiServiceName,
rule: hostRule,
priority: 100,
...(bgResource.ssl ? { tls } : {})
};
}
// Add Traefik routes for siteResource aliases (HTTP mode + SSL) so that
// Traefik generates TLS certificates for those domains even when no
// matching resource exists yet.
@@ -1040,7 +1335,7 @@ export async function getTraefikConfig(
config_output.http.routers[`${siteResourceRouterName}-assets`] = {
entryPoints: [config.getRawConfig().traefik.https_entrypoint],
service: siteResourceServiceName,
rule: `Host(\`${fullDomain}\`) && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`))`,
rule: `Host(\`${fullDomain}\`) && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`,
priority: 101,
tls
};
@@ -1143,7 +1438,7 @@ export async function getTraefikConfig(
config.getRawConfig().traefik.https_entrypoint
],
service: "landing-service",
rule: `Host(\`${fullDomain}\`) && (PathRegexp(\`^/auth/resource/[^/]+$\`) || PathRegexp(\`^/auth/idp/[0-9]+/oidc/callback\`) || PathPrefix(\`/_next\`) || Path(\`/auth/org\`) || PathRegexp(\`^/__nextjs*\`))`,
rule: `Host(\`${fullDomain}\`) && (PathRegexp(\`^/auth/resource/[^/]+$\`) || PathRegexp(\`^/auth/idp/[0-9]+/oidc/callback\`) || PathPrefix(\`/_next\`) || Path(\`/auth/org\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`,
priority: 203,
tls: tls
};
+2
View File
@@ -42,6 +42,8 @@ internalRouter.get("/idp", idp.listIdps);
internalRouter.get("/idp/:idpId", idp.getIdp);
internalRouter.get("/resource/browser-target", resource.getBrowserTarget);
// Gerbil routes
const gerbilRouter = Router();
internalRouter.use("/gerbil", gerbilRouter);
+14 -10
View File
@@ -235,6 +235,11 @@ export async function buildTargetConfigurationForNewtClient(
.from(targetHealthCheck)
.where(eq(targetHealthCheck.siteId, siteId));
const allBrowserGatewayTargets = await db
.select()
.from(browserGatewayTarget)
.where(eq(browserGatewayTarget.siteId, siteId));
const { tcpTargets, udpTargets } = allTargets.reduce(
(acc, target) => {
// Filter out invalid targets
@@ -306,18 +311,17 @@ export async function buildTargetConfigurationForNewtClient(
(target) => target !== null
);
const browserGatewayTargets = allBrowserGatewayTargets.map((t) => ({
id: t.browserGatewayTargetId,
type: t.type,
destination: t.destination,
destinationPort: t.destinationPort
}));
return {
validHealthCheckTargets,
tcpTargets,
udpTargets
udpTargets,
browserGatewayTargets
};
}
export async function buildBrowserGatewayTargetConfigurationForNewtClient(
siteId: number
): Promise<BrowserGatewayTarget[]> {
return await db
.select()
.from(browserGatewayTarget)
.where(eq(browserGatewayTarget.siteId, siteId));
}
@@ -43,8 +43,13 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
const siteId = newt.siteId;
const { publicKey, pingResults, newtVersion, backwardsCompatible, chainId } =
message.data;
const {
publicKey,
pingResults,
newtVersion,
backwardsCompatible,
chainId
} = message.data;
if (!publicKey) {
logger.warn("Public key not provided");
return;
@@ -191,8 +196,12 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
.where(eq(newts.newtId, newt.newtId));
}
const { tcpTargets, udpTargets, validHealthCheckTargets } =
await buildTargetConfigurationForNewtClient(siteId, newtVersion);
const {
tcpTargets,
udpTargets,
validHealthCheckTargets,
browserGatewayTargets
} = await buildTargetConfigurationForNewtClient(siteId, newtVersion);
logger.debug(
`Sending health check targets to newt ${newt.newtId}: ${JSON.stringify(validHealthCheckTargets)}`
@@ -212,6 +221,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
tcp: tcpTargets
},
healthCheckTargets: validHealthCheckTargets,
browserGatewayTargets: browserGatewayTargets,
chainId: chainId
}
},
+7 -14
View File
@@ -3,18 +3,18 @@ import { eq } from "drizzle-orm";
import { sendToClient } from "#dynamic/routers/ws";
import logger from "@server/logger";
import {
buildBrowserGatewayTargetConfigurationForNewtClient,
buildClientConfigurationForNewtClient,
buildTargetConfigurationForNewtClient
} from "./buildConfiguration";
import { canCompress } from "@server/lib/clientVersionChecks";
export async function sendNewtSyncMessage(newt: Newt, site: Site) {
const { tcpTargets, udpTargets, validHealthCheckTargets } =
await buildTargetConfigurationForNewtClient(site.siteId);
const browserGatewayTargets =
await buildBrowserGatewayTargetConfigurationForNewtClient(site.siteId);
const {
tcpTargets,
udpTargets,
validHealthCheckTargets,
browserGatewayTargets
} = await buildTargetConfigurationForNewtClient(site.siteId);
let exitNode: ExitNode | undefined;
if (site.exitNodeId) {
@@ -41,14 +41,7 @@ export async function sendNewtSyncMessage(newt: Newt, site: Site) {
healthCheckTargets: validHealthCheckTargets,
peers: peers,
clientTargets: targets,
browserGatewayTargets: browserGatewayTargets.map((t) => ({
id: t.browserGatewayTargetId,
resourceId: t.resourceId,
siteId: t.siteId,
type: t.type,
destination: t.destination,
destinationPort: t.destinationPort
}))
browserGatewayTargets: browserGatewayTargets
}
},
{
@@ -0,0 +1,76 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { resources, targets } from "@server/db";
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import { fromError } from "zod-validation-error";
import logger from "@server/logger";
const getBrowserTargetSchema = z
.object({
fullDomain: z.string().min(1, "fullDomain is required")
})
.strict();
export type GetBrowserTargetResponse = {
ip: string;
port: number;
};
export async function getBrowserTarget(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsed = getBrowserTargetSchema.safeParse(req.query);
if (!parsed.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsed.error).toString()
)
);
}
const { fullDomain } = parsed.data;
const [row] = await db
.select({
ip: targets.ip,
port: targets.port
})
.from(targets)
.innerJoin(resources, eq(targets.resourceId, resources.resourceId))
.where(eq(resources.fullDomain, fullDomain))
.limit(1);
if (!row) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"No resource found for this domain"
)
);
}
return response<GetBrowserTargetResponse>(res, {
data: { ip: row.ip, port: row.port },
success: true,
error: false,
message: "Browser target retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"An error occurred while retrieving the browser target"
)
);
}
}
+1
View File
@@ -33,3 +33,4 @@ export * from "./removeUserFromResource";
export * from "./listAllResourceNames";
export * from "./removeEmailFromResourceWhitelist";
export * from "./getStatusHistory";
export * from "./getBrowserTarget";
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+32 -49
View File
@@ -32,13 +32,15 @@ declare module "react" {
}
}
type Target = {
ip: string;
port: number;
};
type FormState = {
username: string;
password: string;
gatewayAddress: string;
hostname: string;
domain: string;
authtoken: string;
kdcProxyUrl: string;
pcb: string;
desktopWidth: number;
@@ -55,14 +57,17 @@ const isIronError = (error: unknown): error is IronError => {
);
};
export default function RdpClient() {
export default function RdpClient({
target,
error
}: {
target: Target | null;
error: string | null;
}) {
const [form, setForm] = useState<FormState>({
username: "Administrator",
password: "Wdvwy1W*ITK-(OK.sW?nVK%?mTl30wL0",
gatewayAddress: "ws://localhost:7171/jet/rdp",
hostname: "172.31.3.58:3389",
username: "",
password: "",
domain: "",
authtoken: "abc123",
kdcProxyUrl: "",
pcb: "",
desktopWidth: 1280,
@@ -161,16 +166,6 @@ export default function RdpClient() {
return;
}
if (form.authtoken === "") {
toast({
variant: "destructive",
title: "Missing auth token",
description:
"An auth token is required to connect through the gateway"
});
return;
}
toast({
title: "Connecting...",
description: "Connection in progress"
@@ -228,14 +223,20 @@ export default function RdpClient() {
);
}
const destination = target
? `${target.ip}:${target.port}`
: "";
const builder = userInteraction
.configBuilder()
.withUsername(form.username)
.withPassword(form.password)
.withDestination(form.hostname)
.withProxyAddress(form.gatewayAddress)
.withDestination(destination)
.withProxyAddress(
`${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/rdp`
)
.withServerDomain(form.domain)
.withAuthToken(form.authtoken)
.withAuthToken("test-token")
.withDesktopSize({
width: form.desktopWidth,
height: form.desktopHeight
@@ -295,6 +296,14 @@ export default function RdpClient() {
setCursorOverrideActive((v) => !v);
};
if (error) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<p className="text-destructive">{error}</p>
</div>
);
}
return (
<div className="min-h-screen bg-background">
{showLogin && (
@@ -304,15 +313,6 @@ export default function RdpClient() {
</h1>
<div className="space-y-4">
<Field label="Hostname" id="hostname">
<Input
id="hostname"
value={form.hostname}
onChange={(e) =>
update("hostname", e.target.value)
}
/>
</Field>
<Field label="Domain" id="domain">
<Input
id="domain"
@@ -341,24 +341,7 @@ export default function RdpClient() {
}
/>
</Field>
<Field label="Gateway Address" id="gatewayAddress">
<Input
id="gatewayAddress"
value={form.gatewayAddress}
onChange={(e) =>
update("gatewayAddress", e.target.value)
}
/>
</Field>
{/* <Field label="Auth Token" id="authtoken">
<Input
id="authtoken"
value={form.authtoken}
onChange={(e) =>
update("authtoken", e.target.value)
}
/>
</Field>
{/*
<Field label="Pre Connection Blob (optional)" id="pcb">
<Input
id="pcb"
+22 -2
View File
@@ -1,3 +1,7 @@
import { headers } from "next/headers";
import { internal } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { GetBrowserTargetResponse } from "@server/routers/resource";
import RdpClient from "./RdpClient";
export const dynamic = "force-dynamic";
@@ -6,6 +10,22 @@ export const metadata = {
title: "RDP Test"
};
export default function RdpPage() {
return <RdpClient />;
export default async function RdpPage() {
const headersList = await headers();
const host = headersList.get("host") || "";
const hostname = host.split(":")[0];
let target: { ip: string; port: number } | null = null;
let error: string | null = null;
try {
const res = await internal.get<AxiosResponse<GetBrowserTargetResponse>>(
`/resource/browser-target?fullDomain=${encodeURIComponent(hostname)}`
);
target = res.data.data;
} catch {
error = "No resource found for this domain";
}
return <RdpClient target={target} error={error} />;
}
+38 -110
View File
@@ -6,28 +6,31 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
type FormState = {
gatewayAddress: string;
hostname: string;
port: string;
username: string;
password: string;
authToken: string;
type Target = {
ip: string;
port: number;
};
export default function SshClient() {
type FormState = {
username: string;
password: string;
};
export default function SshClient({
target,
error
}: {
target: Target | null;
error: string | null;
}) {
const [form, setForm] = useState<FormState>({
gatewayAddress: "ws://localhost:7171/jet/ssh",
hostname: "",
port: "22",
username: "",
password: "",
authToken: "abc123"
password: ""
});
const [connected, setConnected] = useState(false);
const [connecting, setConnecting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [connectError, setConnectError] = useState<string | null>(null);
const terminalRef = useRef<HTMLDivElement>(null);
const xtermRef = useRef<import("@xterm/xterm").Terminal | null>(null);
@@ -119,18 +122,15 @@ export default function SshClient() {
}, []);
function connect() {
setError(null);
setConnectError(null);
setConnecting(true);
const url = new URL(form.gatewayAddress);
// Pass connection parameters as query params so the proxy can route
// before any application-level framing is needed.
url.searchParams.set("host", form.hostname);
url.searchParams.set("port", form.port);
const proxyAddress = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/ssh`;
const url = new URL(proxyAddress);
url.searchParams.set("host", target?.ip ?? "");
url.searchParams.set("port", String(target?.port ?? 22));
url.searchParams.set("username", form.username);
// Auth token is sent as a query param; the proxy validates it before
// forwarding any data.
url.searchParams.set("authToken", form.authToken);
url.searchParams.set("authToken", "test-token");
const ws = new WebSocket(url.toString(), ["ssh"]);
wsRef.current = ws;
@@ -169,7 +169,7 @@ export default function SshClient() {
ws.onerror = () => {
setConnecting(false);
setConnected(false);
setError("WebSocket connection failed");
setConnectError("WebSocket connection failed");
};
ws.onclose = (evt) => {
@@ -188,6 +188,14 @@ export default function SshClient() {
setConnected(false);
}
if (error) {
return (
<div className="flex flex-col h-screen bg-black text-white p-4 items-center justify-center">
<p className="text-red-400">{error}</p>
</div>
);
}
return (
<div className="flex flex-col h-screen bg-black text-white p-4 gap-4">
<h1 className="text-xl font-semibold text-white">SSH Terminal</h1>
@@ -195,64 +203,7 @@ export default function SshClient() {
{!connected && (
<div className="bg-neutral-900 rounded-lg p-6 max-w-lg w-full mx-auto flex flex-col gap-4">
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2 flex flex-col gap-1">
<Label
htmlFor="gatewayAddress"
className="text-neutral-300"
>
Gateway Address
</Label>
<Input
id="gatewayAddress"
value={form.gatewayAddress}
onChange={(e) =>
setForm({
...form,
gatewayAddress: e.target.value
})
}
placeholder="ws://localhost:7171/jet/ssh"
className="bg-neutral-800 border-neutral-700 text-white"
/>
</div>
<div className="flex flex-col gap-1">
<Label
htmlFor="hostname"
className="text-neutral-300"
>
Host
</Label>
<Input
id="hostname"
value={form.hostname}
onChange={(e) =>
setForm({
...form,
hostname: e.target.value
})
}
placeholder="192.168.1.1"
className="bg-neutral-800 border-neutral-700 text-white"
/>
</div>
<div className="flex flex-col gap-1">
<Label htmlFor="port" className="text-neutral-300">
Port
</Label>
<Input
id="port"
value={form.port}
onChange={(e) =>
setForm({ ...form, port: e.target.value })
}
placeholder="22"
className="bg-neutral-800 border-neutral-700 text-white"
/>
</div>
<div className="flex flex-col gap-1">
<div className="flex flex-col gap-1 col-span-2">
<Label
htmlFor="username"
className="text-neutral-300"
@@ -273,7 +224,7 @@ export default function SshClient() {
/>
</div>
<div className="flex flex-col gap-1">
<div className="flex flex-col gap-1 col-span-2">
<Label
htmlFor="password"
className="text-neutral-300"
@@ -293,38 +244,15 @@ export default function SshClient() {
className="bg-neutral-800 border-neutral-700 text-white"
/>
</div>
<div className="col-span-2 flex flex-col gap-1">
<Label
htmlFor="authToken"
className="text-neutral-300"
>
Auth Token
</Label>
<Input
id="authToken"
value={form.authToken}
onChange={(e) =>
setForm({
...form,
authToken: e.target.value
})
}
className="bg-neutral-800 border-neutral-700 text-white"
/>
</div>
</div>
{error && <p className="text-red-400 text-sm">{error}</p>}
{connectError && (
<p className="text-red-400 text-sm">{connectError}</p>
)}
<Button
onClick={connect}
disabled={
connecting ||
!form.hostname ||
!form.username ||
!form.authToken
}
disabled={connecting || !form.username}
className="w-full"
>
{connecting ? "Connecting…" : "Connect"}
+22 -2
View File
@@ -1,3 +1,7 @@
import { headers } from "next/headers";
import { internal } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { GetBrowserTargetResponse } from "@server/routers/resource";
import SshClient from "./SshClient";
export const dynamic = "force-dynamic";
@@ -6,6 +10,22 @@ export const metadata = {
title: "SSH Terminal"
};
export default function SshPage() {
return <SshClient />;
export default async function SshPage() {
const headersList = await headers();
const host = headersList.get("host") || "";
const hostname = host.split(":")[0];
let target: { ip: string; port: number } | null = null;
let error: string | null = null;
try {
const res = await internal.get<AxiosResponse<GetBrowserTargetResponse>>(
`/resource/browser-target?fullDomain=${encodeURIComponent(hostname)}`
);
target = res.data.data;
} catch {
error = "No resource found for this domain";
}
return <SshClient target={target} error={error} />;
}
+32 -58
View File
@@ -6,21 +6,24 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { toast } from "@app/hooks/useToast";
type FormState = {
proxyAddress: string;
host: string;
port: string;
password: string;
authToken: string;
type Target = {
ip: string;
port: number;
};
export default function VncClient() {
type FormState = {
password: string;
};
export default function VncClient({
target,
error
}: {
target: Target | null;
error: string | null;
}) {
const [form, setForm] = useState<FormState>({
proxyAddress: "ws://localhost:7171/jet/vnc",
host: "",
port: "5900",
password: "",
authToken: "abc123"
password: ""
});
const [connected, setConnected] = useState(false);
@@ -47,11 +50,11 @@ export default function VncClient() {
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const connect = async () => {
if (!form.host) {
if (!target) {
toast({
variant: "destructive",
title: "Missing host",
description: "Enter the VNC server hostname or IP"
title: "No target",
description: "No resource target is available"
});
return;
}
@@ -82,12 +85,13 @@ export default function VncClient() {
}
// Build the proxy WebSocket URL:
// ws://<proxyAddress>?authToken=<token>&host=<host>&port=<port>
const base = form.proxyAddress.replace(/\/$/, "");
// ws://<proxyAddress>?authToken=<token>&host=<ip>&port=<port>
const proxyAddress = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/vnc`;
const base = proxyAddress.replace(/\/$/, "");
const params = new URLSearchParams({
authToken: form.authToken,
host: form.host,
port: form.port
host: target.ip,
port: String(target.port),
authToken: "test-token"
});
const wsUrl = `${base}?${params.toString()}`;
@@ -138,6 +142,14 @@ export default function VncClient() {
rfbRef.current = rfb;
};
if (error) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<p className="text-destructive">{error}</p>
</div>
);
}
return (
<div className="min-h-screen bg-background">
{!connected && (
@@ -147,24 +159,6 @@ export default function VncClient() {
</h1>
<div className="space-y-4">
<Field label="VNC Host" id="host">
<Input
id="host"
placeholder="192.168.1.100"
value={form.host}
onChange={(e) => update("host", e.target.value)}
/>
</Field>
<Field label="VNC Port" id="port">
<Input
id="port"
type="number"
value={form.port}
onChange={(e) => update("port", e.target.value)}
/>
</Field>
<Field label="Password (optional)" id="password">
<Input
id="password"
@@ -176,26 +170,6 @@ export default function VncClient() {
/>
</Field>
<Field label="Proxy Address" id="proxyAddress">
<Input
id="proxyAddress"
value={form.proxyAddress}
onChange={(e) =>
update("proxyAddress", e.target.value)
}
/>
</Field>
<Field label="Auth Token" id="authToken">
<Input
id="authToken"
value={form.authToken}
onChange={(e) =>
update("authToken", e.target.value)
}
/>
</Field>
<Button onClick={connect} className="w-full">
Connect
</Button>
+22 -2
View File
@@ -1,3 +1,7 @@
import { headers } from "next/headers";
import { internal } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { GetBrowserTargetResponse } from "@server/routers/resource";
import VncClient from "./VncClient";
export const dynamic = "force-dynamic";
@@ -6,6 +10,22 @@ export const metadata = {
title: "VNC Test"
};
export default function VncPage() {
return <VncClient />;
export default async function VncPage() {
const headersList = await headers();
const host = headersList.get("host") || "";
const hostname = host.split(":")[0];
let target: { ip: string; port: number } | null = null;
let error: string | null = null;
try {
const res = await internal.get<AxiosResponse<GetBrowserTargetResponse>>(
`/resource/browser-target?fullDomain=${encodeURIComponent(hostname)}`
);
target = res.data.data;
} catch {
error = "No resource found for this domain";
}
return <VncClient target={target} error={error} />;
}