Compare commits

...

5 Commits

Author SHA1 Message Date
Owen f213e81cde Reconnect newts when a exit node comes back online 2026-06-08 11:12:14 -07:00
Owen 7b6b1f0a4c Add exit node if the sites dont have one 2026-06-08 11:11:46 -07:00
Owen 8daf7c2872 Rename and add browser target update 2026-06-07 12:07:08 -07:00
Owen c394490473 Update browser targets 2026-06-07 10:43:16 -07:00
Owen 3b6b78b3e1 Update traefik config 2026-06-06 16:14:20 -07:00
16 changed files with 620 additions and 109 deletions
+34 -14
View File
@@ -10,16 +10,22 @@ import {
clientSiteResources clientSiteResources
} from "@server/db"; } from "@server/db";
import { Config, ConfigSchema } from "./types"; import { Config, ConfigSchema } from "./types";
import { ProxyResourcesResults, updateProxyResources } from "./proxyResources"; import {
PublicResourcesResults,
updatePublicResources
} from "./publicResources";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import logger from "@server/logger"; import logger from "@server/logger";
import { sites } from "@server/db"; import { sites } from "@server/db";
import { eq, and, isNotNull } from "drizzle-orm"; import { eq, and, isNotNull } from "drizzle-orm";
import { addTargets as addProxyTargets } from "@server/routers/newt/targets"; import {
addTargets as addProxyTargets,
sendBrowserGatewayTargets
} from "@server/routers/newt/targets";
import { import {
ClientResourcesResults, ClientResourcesResults,
updateClientResources updatePrivateResources
} from "./clientResources"; } from "./privateResources";
import { updateResourcePolicies } from "./resourcePolicies"; import { updateResourcePolicies } from "./resourcePolicies";
import { BlueprintSource } from "@server/routers/blueprints/types"; import { BlueprintSource } from "@server/routers/blueprints/types";
import { stringify as stringifyYaml } from "yaml"; import { stringify as stringifyYaml } from "yaml";
@@ -54,18 +60,18 @@ export async function applyBlueprint({
let error: any | null = null; let error: any | null = null;
try { try {
let proxyResourcesResults: ProxyResourcesResults = []; let proxyResourcesResults: PublicResourcesResults = [];
let clientResourcesResults: ClientResourcesResults = []; let clientResourcesResults: ClientResourcesResults = [];
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
await updateResourcePolicies(orgId, config, trx); await updateResourcePolicies(orgId, config, trx);
proxyResourcesResults = await updateProxyResources( proxyResourcesResults = await updatePublicResources(
orgId, orgId,
config, config,
trx, trx,
siteId siteId
); );
clientResourcesResults = await updateClientResources( clientResourcesResults = await updatePrivateResources(
orgId, orgId,
config, config,
trx, trx,
@@ -104,13 +110,27 @@ export async function applyBlueprint({
(hc) => hc.targetId === target.targetId (hc) => hc.targetId === target.targetId
); );
await addProxyTargets( if (["http", "tcp", "udp"].includes(target.mode)) {
site.newt.newtId, await addProxyTargets(
[target], site.newt.newtId,
matchingHealthcheck ? [matchingHealthcheck] : [], [target],
result.proxyResource.mode === "udp" ? "udp" : "tcp", matchingHealthcheck
site.newt.version ? [matchingHealthcheck]
); : [],
result.proxyResource.mode === "udp"
? "udp"
: "tcp",
site.newt.version
);
} else if (
["ssh", "rdp", "vnc"].includes(target.mode)
) {
await sendBrowserGatewayTargets(
site.newt.newtId,
[target],
site.newt.version
);
}
} }
} }
} }
@@ -105,7 +105,7 @@ export type ClientResourcesResults = {
oldSites: { siteId: number }[]; oldSites: { siteId: number }[];
}[]; }[];
export async function updateClientResources( export async function updatePrivateResources(
orgId: string, orgId: string,
config: Config, config: Config,
trx: Transaction, trx: Transaction,
@@ -52,19 +52,19 @@ import { encrypt } from "@server/lib/crypto";
import { generateId } from "@server/auth/sessions/app"; import { generateId } from "@server/auth/sessions/app";
import serverConfig from "@server/lib/config"; import serverConfig from "@server/lib/config";
export type ProxyResourcesResults = { export type PublicResourcesResults = {
proxyResource: Resource; proxyResource: Resource;
targetsToUpdate: Target[]; targetsToUpdate: Target[];
healthchecksToUpdate: TargetHealthCheck[]; healthchecksToUpdate: TargetHealthCheck[];
}[]; }[];
export async function updateProxyResources( export async function updatePublicResources(
orgId: string, orgId: string,
config: Config, config: Config,
trx: Transaction, trx: Transaction,
siteId?: number siteId?: number
): Promise<ProxyResourcesResults> { ): Promise<PublicResourcesResults> {
const results: ProxyResourcesResults = []; const results: PublicResourcesResults = [];
for (const [resourceNiceId, resourceData] of Object.entries( for (const [resourceNiceId, resourceData] of Object.entries(
config["proxy-resources"] config["proxy-resources"]
+15 -8
View File
@@ -171,8 +171,15 @@ export async function getTraefikConfig(
), ),
inArray(sites.type, siteTypes), inArray(sites.type, siteTypes),
allowRawResources allowRawResources
? inArray(resources.mode, ["http", "udp", "tcp"]) // allow all three ? inArray(resources.mode, [
: eq(resources.mode, "http") "http",
"udp",
"tcp",
"vnc",
"ssh",
"rdp"
]) // allow all three
: inArray(resources.mode, ["http", "vnc", "ssh", "rdp"])
) )
) )
.orderBy(desc(targets.priority), targets.targetId); // stable ordering .orderBy(desc(targets.priority), targets.targetId); // stable ordering
@@ -180,9 +187,9 @@ export async function getTraefikConfig(
// Group by resource and include targets with their unique site data // Group by resource and include targets with their unique site data
const resourcesMap = new Map(); const resourcesMap = new Map();
resourcesWithTargetsAndSites.forEach((row) => { for (const row of resourcesWithTargetsAndSites) {
if (!["http", "tcp", "udp"].includes(row.mode)) { if (!["http", "tcp", "udp"].includes(row.mode)) {
return; continue;
} }
const resourceId = row.resourceId; const resourceId = row.resourceId;
const resourceName = sanitize(row.resourceName) || ""; const resourceName = sanitize(row.resourceName) || "";
@@ -193,7 +200,7 @@ export async function getTraefikConfig(
const priority = row.priority ?? 100; const priority = row.priority ?? 100;
if (filterOutNamespaceDomains && row.domainNamespaceId) { if (filterOutNamespaceDomains && row.domainNamespaceId) {
return; continue;
} }
// Create a unique key combining resourceId, path config, and rewrite config // Create a unique key combining resourceId, path config, and rewrite config
@@ -220,7 +227,7 @@ export async function getTraefikConfig(
logger.debug( logger.debug(
`Invalid path rewrite configuration for resource ${resourceId}: ${validation.error}` `Invalid path rewrite configuration for resource ${resourceId}: ${validation.error}`
); );
return; continue;
} }
resourcesMap.set(mapKey, { resourcesMap.set(mapKey, {
@@ -277,7 +284,7 @@ export async function getTraefikConfig(
online: row.siteOnline online: row.siteOnline
} }
}); });
}); }
// Group browser gateway targets by resource // Group browser gateway targets by resource
type BrowserGatewayResourceEntry = { type BrowserGatewayResourceEntry = {
@@ -313,7 +320,7 @@ export async function getTraefikConfig(
if (allowBrowserGatewayResources) { if (allowBrowserGatewayResources) {
for (const row of resourcesWithTargetsAndSites) { for (const row of resourcesWithTargetsAndSites) {
if (!["ssh", "vnc", "rdp"].includes(row.mode)) { if (!["ssh", "vnc", "rdp"].includes(row.mode)) {
return; continue;
} }
if (filterOutNamespaceDomains && row.domainNamespaceId) { if (filterOutNamespaceDomains && row.domainNamespaceId) {
continue; continue;
@@ -0,0 +1,202 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import axios from "axios";
import { db, exitNodes, newts, sites } from "@server/db";
import { eq } from "drizzle-orm";
import logger from "@server/logger";
import redisManager from "#private/lib/redis";
import { sendToClient } from "#private/routers/ws";
const INITIAL_DELAY_MS = 15 * 1000; // 15 seconds before first check
const CHECK_INTERVAL_MS = 10 * 1000; // Check every 10 seconds
const MAX_DURATION_MS = 5 * 60 * 1000; // Give up after 5 minutes
const REDIS_PENDING_SET = "exit-node-reconnect-pending";
const REDIS_HASH_PREFIX = "exit-node-reconnect:";
interface PendingReconnect {
startTime: number;
reachableAt: string;
}
// In-memory tracking for this node
const pendingReconnects = new Map<number, PendingReconnect>();
let schedulerInterval: NodeJS.Timeout | null = null;
/**
* Schedules a reconnect check for newts connected to the given exit node.
* Called when an exit node transitions from offline to online.
*/
export async function scheduleExitNodeReconnect(
exitNodeId: number,
reachableAt: string
): Promise<void> {
logger.info(
`Scheduling newt reconnect for exit node ${exitNodeId} (reachableAt: ${reachableAt})`
);
const entry: PendingReconnect = {
startTime: Date.now(),
reachableAt
};
pendingReconnects.set(exitNodeId, entry);
// Store in Redis if available for cross-node coordination
if (redisManager.isRedisEnabled()) {
await redisManager.sadd(REDIS_PENDING_SET, exitNodeId.toString());
await redisManager.hset(
`${REDIS_HASH_PREFIX}${exitNodeId}`,
"startTime",
entry.startTime.toString()
);
await redisManager.hset(
`${REDIS_HASH_PREFIX}${exitNodeId}`,
"reachableAt",
reachableAt
);
}
}
/**
* Starts the background interval that checks pending exit node reconnects.
*/
export function startExitNodeReconnectScheduler(): void {
if (schedulerInterval) {
return;
}
schedulerInterval = setInterval(async () => {
try {
await processPendingReconnects();
} catch (error) {
logger.error("Error in exit node reconnect scheduler", { error });
}
}, CHECK_INTERVAL_MS);
logger.debug("Started exit node reconnect scheduler");
}
async function processPendingReconnects(): Promise<void> {
// Merge in-memory and Redis-tracked pending reconnects
const toProcess = new Map(pendingReconnects);
if (redisManager.isRedisEnabled()) {
const redisIds = await redisManager.smembers(REDIS_PENDING_SET);
for (const idStr of redisIds) {
const id = parseInt(idStr, 10);
if (!toProcess.has(id)) {
const startTimeStr = await redisManager.hget(
`${REDIS_HASH_PREFIX}${id}`,
"startTime"
);
const reachableAt = await redisManager.hget(
`${REDIS_HASH_PREFIX}${id}`,
"reachableAt"
);
if (startTimeStr && reachableAt) {
toProcess.set(id, {
startTime: parseInt(startTimeStr, 10),
reachableAt
});
}
}
}
}
const now = Date.now();
for (const [exitNodeId, entry] of toProcess) {
const elapsed = now - entry.startTime;
// Give up after max duration
if (elapsed >= MAX_DURATION_MS) {
logger.warn(
`Exit node reconnect check timed out for exit node ${exitNodeId} after 5 minutes`
);
await removePending(exitNodeId);
continue;
}
// Respect initial delay
if (elapsed < INITIAL_DELAY_MS) {
continue;
}
// Check if the exit node HTTP endpoint is reachable
const pingUrl = `${entry.reachableAt}/ping`;
try {
await axios.get(pingUrl, { timeout: 5000 });
} catch {
logger.debug(
`Exit node ${exitNodeId} not yet reachable at ${pingUrl}`
);
continue;
}
// Node is reachable — send reconnect to all connected newts
logger.info(
`Exit node ${exitNodeId} is reachable. Sending newt/wg/reconnect to connected newts.`
);
await sendReconnectToNewts(exitNodeId);
await removePending(exitNodeId);
}
}
async function sendReconnectToNewts(exitNodeId: number): Promise<void> {
try {
const connectedNewts = await db
.select({ newtId: newts.newtId })
.from(newts)
.innerJoin(sites, eq(newts.siteId, sites.siteId))
.where(eq(sites.exitNodeId, exitNodeId));
if (connectedNewts.length === 0) {
logger.debug(
`No newts found for exit node ${exitNodeId}, nothing to reconnect`
);
return;
}
logger.info(
`Sending newt/wg/reconnect to ${connectedNewts.length} newt(s) for exit node ${exitNodeId}`
);
const reconnectMessage = {
type: "newt/wg/reconnect",
data: {}
};
await Promise.allSettled(
connectedNewts.map(({ newtId }) =>
sendToClient(newtId, reconnectMessage)
)
);
} catch (error) {
logger.error(
`Failed to send reconnect messages for exit node ${exitNodeId}`,
{ error }
);
}
}
async function removePending(exitNodeId: number): Promise<void> {
pendingReconnects.delete(exitNodeId);
if (redisManager.isRedisEnabled()) {
await redisManager.srem(REDIS_PENDING_SET, exitNodeId.toString());
await redisManager.del(`${REDIS_HASH_PREFIX}${exitNodeId}`);
}
}
@@ -16,6 +16,7 @@ import { MessageHandler } from "@server/routers/ws";
import { RemoteExitNode } from "@server/db"; import { RemoteExitNode } from "@server/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";
import { scheduleExitNodeReconnect } from "./exitNodeReconnectScheduler";
/** /**
* Handles ping messages from clients and responds with pong * Handles ping messages from clients and responds with pong
@@ -37,6 +38,13 @@ export const handleRemoteExitNodePingMessage: MessageHandler = async (
} }
try { try {
// Fetch the current state before updating so we can detect the offline→online transition
const [currentExitNode] = await db
.select({ online: exitNodes.online, reachableAt: exitNodes.reachableAt })
.from(exitNodes)
.where(eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId))
.limit(1);
// Update the exit node's last ping timestamp // Update the exit node's last ping timestamp
await db await db
.update(exitNodes) .update(exitNodes)
@@ -45,6 +53,16 @@ export const handleRemoteExitNodePingMessage: MessageHandler = async (
online: true online: true
}) })
.where(eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId)); .where(eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId));
// If the exit node was offline and is now coming online, schedule newt reconnects
if (currentExitNode && !currentExitNode.online && currentExitNode.reachableAt) {
scheduleExitNodeReconnect(
remoteExitNode.exitNodeId,
currentExitNode.reachableAt
).catch((error) => {
logger.error("Failed to schedule exit node reconnect", { error });
});
}
} catch (error) { } catch (error) {
logger.error("Error handling ping message", { error }); logger.error("Error handling ping message", { error });
} }
@@ -22,3 +22,4 @@ export * from "./listRemoteExitNodes";
export * from "./pickRemoteExitNodeDefaults"; export * from "./pickRemoteExitNodeDefaults";
export * from "./quickStartRemoteExitNode"; export * from "./quickStartRemoteExitNode";
export * from "./offlineChecker"; export * from "./offlineChecker";
export * from "./exitNodeReconnectScheduler";
+3 -1
View File
@@ -14,7 +14,8 @@
import { import {
handleRemoteExitNodeRegisterMessage, handleRemoteExitNodeRegisterMessage,
handleRemoteExitNodePingMessage, handleRemoteExitNodePingMessage,
startRemoteExitNodeOfflineChecker startRemoteExitNodeOfflineChecker,
startExitNodeReconnectScheduler
} from "#private/routers/remoteExitNode"; } from "#private/routers/remoteExitNode";
import { MessageHandler } from "@server/routers/ws"; import { MessageHandler } from "@server/routers/ws";
import { build } from "@server/build"; import { build } from "@server/build";
@@ -29,4 +30,5 @@ export const messageHandlers: Record<string, MessageHandler> = {
if (build != "saas") { if (build != "saas") {
startRemoteExitNodeOfflineChecker(); // this is to handle the offline check for remote exit nodes startRemoteExitNodeOfflineChecker(); // this is to handle the offline check for remote exit nodes
startExitNodeReconnectScheduler(); // check pending exit node reconnects and notify newts
} }
+73 -2
View File
@@ -13,7 +13,7 @@ import {
import { olms } from "@server/db"; import { olms } from "@server/db";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import response from "@server/lib/response"; import response from "@server/lib/response";
import { and, eq, inArray } from "drizzle-orm"; import { and, count, eq, inArray } from "drizzle-orm";
import { NextFunction, Request, Response } from "express"; import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import { z } from "zod"; import { z } from "zod";
@@ -24,6 +24,7 @@ import {
EXPIRES EXPIRES
} from "@server/auth/sessions/olm"; } from "@server/auth/sessions/olm";
import { getOrCreateCachedToken } from "#dynamic/lib/tokenCache"; import { getOrCreateCachedToken } from "#dynamic/lib/tokenCache";
import { listExitNodes } from "#dynamic/lib/exitNodes";
import { verifyPassword } from "@server/auth/password"; import { verifyPassword } from "@server/auth/password";
import logger from "@server/logger"; import logger from "@server/logger";
import config from "@server/lib/config"; import config from "@server/lib/config";
@@ -150,6 +151,7 @@ export async function getOlmToken(
); );
let clientIdToUse; let clientIdToUse;
let orgIdToUse: string;
if (orgId) { if (orgId) {
// we did provide the org // we did provide the org
const [client] = await db const [client] = await db
@@ -183,6 +185,7 @@ export async function getOlmToken(
} }
clientIdToUse = client.clientId; clientIdToUse = client.clientId;
orgIdToUse = orgId;
} else { } else {
if (!existingOlm.clientId) { if (!existingOlm.clientId) {
return next( return next(
@@ -209,6 +212,7 @@ export async function getOlmToken(
} }
clientIdToUse = client.clientId; clientIdToUse = client.clientId;
orgIdToUse = client.orgId;
} }
// Get all exit nodes from sites where the client has peers // Get all exit nodes from sites where the client has peers
@@ -265,7 +269,7 @@ export async function getOlmToken(
} }
} }
const exitNodesHpData = allExitNodes.map((exitNode: ExitNode) => { let exitNodesHpData = allExitNodes.map((exitNode: ExitNode) => {
return { return {
publicKey: exitNode.publicKey, publicKey: exitNode.publicKey,
relayPort: config.getRawConfig().gerbil.clients_start_port, relayPort: config.getRawConfig().gerbil.clients_start_port,
@@ -274,6 +278,73 @@ export async function getOlmToken(
}; };
}); });
// If no exit nodes were found for the client's sites, fall back to
// finding an available node in the same region (as newt does on ping).
if (exitNodesHpData.length === 0) {
logger.debug(
`No exit nodes found for olm ${olmId} client sites; falling back to region node selection`
);
const fallbackNodes = await listExitNodes(orgIdToUse!, true);
const weightedNodes = await Promise.all(
fallbackNodes.map(async (node) => {
let weight = 1;
const maxConnections = node.maxConnections;
if (
maxConnections !== null &&
maxConnections !== undefined
) {
const [currentConnections] = await db
.select({ count: count() })
.from(sites)
.where(
and(
eq(sites.exitNodeId, node.exitNodeId),
eq(sites.online, true)
)
);
if (currentConnections.count >= maxConnections) {
return null;
}
weight =
(maxConnections - currentConnections.count) /
maxConnections;
}
return { node, weight };
})
);
const availableNodes = weightedNodes
.filter(
(
n
): n is {
node: (typeof fallbackNodes)[0];
weight: number;
} => n !== null
)
.sort((a, b) => b.weight - a.weight);
if (availableNodes.length > 0) {
const best = availableNodes[0].node;
exitNodesHpData = [
{
publicKey: best.publicKey,
relayPort:
config.getRawConfig().gerbil.clients_start_port,
endpoint: best.endpoint,
siteIds: []
// it should still HP without the site ids but it will get stuck in the client
// if a site is removed or something because its not tied to a site which is okay for the session
}
];
} else {
logger.warn(
`No available fallback exit nodes found for olm ${olmId}`
);
}
}
logger.debug("Token created successfully"); logger.debug("Token created successfully");
return response<{ return response<{
+16 -7
View File
@@ -27,6 +27,7 @@ import {
import { encrypt } from "@server/lib/crypto"; import { encrypt } from "@server/lib/crypto";
import { generateId } from "@server/auth/sessions/app"; import { generateId } from "@server/auth/sessions/app";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { sendBrowserGatewayTargets } from "@server/routers/newt/targets";
const createTargetParamsSchema = z.strictObject({ const createTargetParamsSchema = z.strictObject({
resourceId: z.coerce.number().int().positive() resourceId: z.coerce.number().int().positive()
@@ -342,13 +343,21 @@ export async function createTarget(
.where(eq(newts.siteId, site.siteId)) .where(eq(newts.siteId, site.siteId))
.limit(1); .limit(1);
await addTargets( if (["http", "tcp", "udp"].includes(newTarget[0].mode)) {
newt.newtId, await addTargets(
newTarget, newt.newtId,
healthCheck, newTarget,
resource.mode === "udp" ? "udp" : "tcp", healthCheck,
newt.version resource.mode === "udp" ? "udp" : "tcp",
); newt.version
);
} else if (["ssh", "rdp", "vnc"].includes(newTarget[0].mode)) {
await sendBrowserGatewayTargets(
newt.newtId,
newTarget,
newt.version
);
}
} }
} }
+17 -8
View File
@@ -11,6 +11,7 @@ import { fromError } from "zod-validation-error";
import { removeTargets } from "../newt/targets"; import { removeTargets } from "../newt/targets";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { targetHealthCheck } from "@server/db"; import { targetHealthCheck } from "@server/db";
import { removeBrowserGatewayTarget } from "@server/routers/newt/targets";
const deleteTargetSchema = z.strictObject({ const deleteTargetSchema = z.strictObject({
targetId: z.coerce.number().int().positive() targetId: z.coerce.number().int().positive()
@@ -136,14 +137,22 @@ export async function deleteTarget(
.where(eq(newts.siteId, site.siteId)) .where(eq(newts.siteId, site.siteId))
.limit(1); .limit(1);
await removeTargets( if (["http", "tcp", "udp"].includes(deletedTarget.mode)) {
newt.newtId, await removeTargets(
// [deletedTarget], newt.newtId,
[], // deleting the target from newt causes issues because we cant unbind the port. this needs to be fixed in newt before we can do this // [deletedTarget],
[deletedHealthCheck], [], // deleting the target from newt causes issues because we cant unbind the port. this needs to be fixed in newt before we can do this
resource.mode === "udp" ? "udp" : "tcp", [deletedHealthCheck],
newt.version resource.mode === "udp" ? "udp" : "tcp",
); newt.version
);
} else if (["ssh", "rdp", "vnc"].includes(deletedTarget.mode)) {
await removeBrowserGatewayTarget(
newt.newtId,
deletedTarget.targetId,
newt.version
);
}
} }
} }
+16 -7
View File
@@ -18,6 +18,7 @@ import {
import { pickPort } from "./helpers"; import { pickPort } from "./helpers";
import { isTargetValid } from "@server/lib/validators"; import { isTargetValid } from "@server/lib/validators";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { sendBrowserGatewayTargets } from "@server/routers/newt/targets";
const updateTargetParamsSchema = z.strictObject({ const updateTargetParamsSchema = z.strictObject({
targetId: z.coerce.number().int().positive() targetId: z.coerce.number().int().positive()
@@ -350,13 +351,21 @@ export async function updateTarget(
.where(eq(newts.siteId, site.siteId)) .where(eq(newts.siteId, site.siteId))
.limit(1); .limit(1);
await addTargets( if (["http", "tcp", "udp"].includes(updatedTarget.mode)) {
newt.newtId, await addTargets(
[updatedTarget], newt.newtId,
[updatedHc], [updatedTarget],
resource.mode === "udp" ? "udp" : "tcp", [updatedHc],
newt.version resource.mode === "udp" ? "udp" : "tcp",
); newt.version
);
} else if (["ssh", "rdp", "vnc"].includes(updatedTarget.mode)) {
await sendBrowserGatewayTargets(
newt.newtId,
[updatedTarget],
newt.version
);
}
} }
} }
+34 -22
View File
@@ -37,6 +37,10 @@ import BrandedAuthSurface from "@app/components/BrandedAuthSurface";
import PoweredByPangolin from "@app/components/PoweredByPangolin"; import PoweredByPangolin from "@app/components/PoweredByPangolin";
import AuthPageFooterNotices from "@app/components/AuthPageFooterNotices"; import AuthPageFooterNotices from "@app/components/AuthPageFooterNotices";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import {
loadEncryptedLocalStorage,
saveEncryptedLocalStorage
} from "@app/lib/secureLocalStorage";
declare module "react" { declare module "react" {
namespace JSX { namespace JSX {
@@ -63,22 +67,14 @@ type RdpCredentialsForm = {
enableClipboard: boolean; enableClipboard: boolean;
}; };
function loadStoredCredentials(key: string): RdpCredentialsForm { const DEFAULT_RDP_CREDENTIALS: RdpCredentialsForm = {
try { username: "",
const saved = localStorage.getItem(key); password: "",
if (saved) return JSON.parse(saved) as RdpCredentialsForm; domain: "",
} catch { kdcProxyUrl: "",
// ignore pcb: "",
} enableClipboard: true
return { };
username: "",
password: "",
domain: "",
kdcProxyUrl: "",
pcb: "",
enableClipboard: true
};
}
const isIronError = (error: unknown): error is IronError => { const isIronError = (error: unknown): error is IronError => {
return ( return (
@@ -113,9 +109,25 @@ export default function RdpClient({
const form = useForm<RdpCredentialsForm>({ const form = useForm<RdpCredentialsForm>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: loadStoredCredentials(STORAGE_KEY) defaultValues: DEFAULT_RDP_CREDENTIALS
}); });
useEffect(() => {
let cancelled = false;
void loadEncryptedLocalStorage<RdpCredentialsForm>(
STORAGE_KEY,
target?.authToken
).then((saved) => {
if (cancelled || !saved) return;
form.reset({ ...DEFAULT_RDP_CREDENTIALS, ...saved });
});
return () => {
cancelled = true;
};
}, [form, target?.authToken]);
const [showLogin, setShowLogin] = useState(true); const [showLogin, setShowLogin] = useState(true);
const [moduleReady, setModuleReady] = useState(false); const [moduleReady, setModuleReady] = useState(false);
const [connecting, setConnecting] = useState(false); const [connecting, setConnecting] = useState(false);
@@ -293,11 +305,11 @@ export default function RdpClient({
try { try {
const sessionInfo = await userInteraction.connect(builder.build()); const sessionInfo = await userInteraction.connect(builder.build());
try { void saveEncryptedLocalStorage(
localStorage.setItem(STORAGE_KEY, JSON.stringify(values)); STORAGE_KEY,
} catch { values,
// ignore target.authToken
} );
setConnecting(false); setConnecting(false);
setShowLogin(false); setShowLogin(false);
userInteraction.setVisibility(true); userInteraction.setVisibility(true);
+33 -20
View File
@@ -32,6 +32,10 @@ import { useTranslations } from "next-intl";
import BrandedAuthSurface from "@app/components/BrandedAuthSurface"; import BrandedAuthSurface from "@app/components/BrandedAuthSurface";
import PoweredByPangolin from "@app/components/PoweredByPangolin"; import PoweredByPangolin from "@app/components/PoweredByPangolin";
import AuthPageFooterNotices from "@app/components/AuthPageFooterNotices"; import AuthPageFooterNotices from "@app/components/AuthPageFooterNotices";
import {
loadEncryptedLocalStorage,
saveEncryptedLocalStorage
} from "@app/lib/secureLocalStorage";
type AuthTab = "password" | "privateKey"; type AuthTab = "password" | "privateKey";
@@ -48,15 +52,11 @@ type ConnectCredentials = {
certificate?: string; certificate?: string;
}; };
function loadStoredCredentials(key: string): SshCredentialsForm { const DEFAULT_SSH_CREDENTIALS: SshCredentialsForm = {
try { username: "",
const saved = localStorage.getItem(key); password: "",
if (saved) return JSON.parse(saved) as SshCredentialsForm; privateKey: ""
} catch { };
// ignore
}
return { username: "", password: "", privateKey: "" };
}
export default function SshClient({ export default function SshClient({
target, target,
@@ -86,9 +86,25 @@ export default function SshClient({
}); });
const form = useForm<SshCredentialsForm>({ const form = useForm<SshCredentialsForm>({
defaultValues: loadStoredCredentials(STORAGE_KEY) defaultValues: DEFAULT_SSH_CREDENTIALS
}); });
useEffect(() => {
let cancelled = false;
void loadEncryptedLocalStorage<SshCredentialsForm>(
STORAGE_KEY,
target?.authToken
).then((saved) => {
if (cancelled || !saved) return;
form.reset({ ...DEFAULT_SSH_CREDENTIALS, ...saved });
});
return () => {
cancelled = true;
};
}, [form, target?.authToken]);
function handleKeyFile(e: React.ChangeEvent<HTMLInputElement>) { function handleKeyFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
@@ -252,14 +268,11 @@ export default function SshClient({
}) })
); );
if (!override) { if (!override) {
try { void saveEncryptedLocalStorage(
localStorage.setItem( STORAGE_KEY,
STORAGE_KEY, form.getValues(),
JSON.stringify(form.getValues()) target.authToken
); );
} catch {
// ignore
}
} }
}; };
@@ -625,7 +638,7 @@ export default function SshClient({
{connected && ( {connected && (
<div className="fixed inset-0 z-50 flex flex-col bg-neutral-900"> <div className="fixed inset-0 z-50 flex flex-col bg-neutral-900">
<div className="flex flex-wrap items-center gap-2 bg-black p-2 text-white"> {/* <div className="flex flex-wrap items-center gap-2 bg-black p-2 text-white">
<Button <Button
size="sm" size="sm"
variant="destructive" variant="destructive"
@@ -633,7 +646,7 @@ export default function SshClient({
> >
{t("sshTerminate")} {t("sshTerminate")}
</Button> </Button>
</div> </div> */}
<div <div
ref={terminalRef} ref={terminalRef}
className="flex-1 overflow-hidden" className="flex-1 overflow-hidden"
+29 -15
View File
@@ -28,20 +28,18 @@ import BrandedAuthSurface from "@app/components/BrandedAuthSurface";
import PoweredByPangolin from "@app/components/PoweredByPangolin"; import PoweredByPangolin from "@app/components/PoweredByPangolin";
import AuthPageFooterNotices from "@app/components/AuthPageFooterNotices"; import AuthPageFooterNotices from "@app/components/AuthPageFooterNotices";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import {
loadEncryptedLocalStorage,
saveEncryptedLocalStorage
} from "@app/lib/secureLocalStorage";
type VncCredentialsForm = { type VncCredentialsForm = {
password: string; password: string;
}; };
function loadStoredCredentials(key: string): VncCredentialsForm { const DEFAULT_VNC_CREDENTIALS: VncCredentialsForm = {
try { password: ""
const saved = localStorage.getItem(key); };
if (saved) return JSON.parse(saved) as VncCredentialsForm;
} catch {
// ignore
}
return { password: "" };
}
export default function VncClient({ export default function VncClient({
target, target,
@@ -62,9 +60,25 @@ export default function VncClient({
const form = useForm<VncCredentialsForm>({ const form = useForm<VncCredentialsForm>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: loadStoredCredentials(STORAGE_KEY) defaultValues: DEFAULT_VNC_CREDENTIALS
}); });
useEffect(() => {
let cancelled = false;
void loadEncryptedLocalStorage<VncCredentialsForm>(
STORAGE_KEY,
target?.authToken
).then((saved) => {
if (cancelled || !saved) return;
form.reset({ ...DEFAULT_VNC_CREDENTIALS, ...saved });
});
return () => {
cancelled = true;
};
}, [form, target?.authToken]);
const [connected, setConnected] = useState(false); const [connected, setConnected] = useState(false);
const [connectError, setConnectError] = useState<string | null>(null); const [connectError, setConnectError] = useState<string | null>(null);
const rfbRef = useRef<any>(null); const rfbRef = useRef<any>(null);
@@ -132,11 +146,11 @@ export default function VncClient({
rfb.resizeSession = true; rfb.resizeSession = true;
rfb.addEventListener("connect", () => { rfb.addEventListener("connect", () => {
try { void saveEncryptedLocalStorage(
localStorage.setItem(STORAGE_KEY, JSON.stringify(values)); STORAGE_KEY,
} catch { values,
// ignore target.authToken
} );
setConnected(true); setConnected(true);
}); });
+124
View File
@@ -0,0 +1,124 @@
type EncryptedStorageEnvelope = {
v: 1;
s: string;
i: string;
d: string;
};
const PBKDF2_ITERATIONS = 120000;
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
return bytes.buffer.slice(
bytes.byteOffset,
bytes.byteOffset + bytes.byteLength
) as ArrayBuffer;
}
function bytesToBase64(bytes: Uint8Array): string {
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary);
}
function base64ToBytes(value: string): Uint8Array {
const binary = atob(value);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
async function deriveKey(authToken: string, salt: ArrayBuffer) {
const subtle = window.crypto?.subtle;
if (!subtle) {
throw new Error("Web Crypto is unavailable");
}
const tokenKey = await subtle.importKey(
"raw",
toArrayBuffer(new TextEncoder().encode(authToken)),
"PBKDF2",
false,
["deriveKey"]
);
return subtle.deriveKey(
{
name: "PBKDF2",
salt,
iterations: PBKDF2_ITERATIONS,
hash: "SHA-256"
},
tokenKey,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"]
);
}
export async function saveEncryptedLocalStorage<T>(
storageKey: string,
value: T,
authToken: string | null | undefined
) {
if (typeof window === "undefined") return;
if (!authToken) {
window.localStorage.removeItem(storageKey);
return;
}
const salt = window.crypto.getRandomValues(new Uint8Array(16));
const iv = window.crypto.getRandomValues(new Uint8Array(12));
const key = await deriveKey(authToken, toArrayBuffer(salt));
const plaintext = new TextEncoder().encode(JSON.stringify(value));
const encrypted = await window.crypto.subtle.encrypt(
{ name: "AES-GCM", iv: toArrayBuffer(iv) },
key,
toArrayBuffer(plaintext)
);
const payload: EncryptedStorageEnvelope = {
v: 1,
s: bytesToBase64(salt),
i: bytesToBase64(iv),
d: bytesToBase64(new Uint8Array(encrypted))
};
window.localStorage.setItem(storageKey, JSON.stringify(payload));
}
export async function loadEncryptedLocalStorage<T>(
storageKey: string,
authToken: string | null | undefined
): Promise<T | null> {
if (typeof window === "undefined") return null;
if (!authToken) return null;
const raw = window.localStorage.getItem(storageKey);
if (!raw) return null;
try {
const payload = JSON.parse(raw) as EncryptedStorageEnvelope;
if (payload.v !== 1 || !payload.s || !payload.i || !payload.d) {
throw new Error("Invalid encrypted payload");
}
const salt = base64ToBytes(payload.s);
const iv = base64ToBytes(payload.i);
const data = base64ToBytes(payload.d);
const key = await deriveKey(authToken, toArrayBuffer(salt));
const decrypted = await window.crypto.subtle.decrypt(
{ name: "AES-GCM", iv: toArrayBuffer(iv) },
key,
toArrayBuffer(data)
);
const json = new TextDecoder().decode(decrypted);
return JSON.parse(json) as T;
} catch {
window.localStorage.removeItem(storageKey);
return null;
}
}