From 22545cac8b6cf5b539fd18c57b5019b7bf153e10 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 12 Aug 2025 13:40:59 -0700 Subject: [PATCH 01/63] Basic verify session breakout --- server/db/queries/verifySessionQueries.ts | 211 ++++++++++++++++++++++ server/routers/badger/verifySession.ts | 101 +++-------- 2 files changed, 240 insertions(+), 72 deletions(-) create mode 100644 server/db/queries/verifySessionQueries.ts diff --git a/server/db/queries/verifySessionQueries.ts b/server/db/queries/verifySessionQueries.ts new file mode 100644 index 00000000..1e159304 --- /dev/null +++ b/server/db/queries/verifySessionQueries.ts @@ -0,0 +1,211 @@ +import { db } from "@server/db"; +import { + Resource, + ResourcePassword, + ResourcePincode, + ResourceRule, + resourcePassword, + resourcePincode, + resourceRules, + resources, + roleResources, + sessions, + userOrgs, + userResources, + users +} from "@server/db"; +import { and, eq } from "drizzle-orm"; +import axios from "axios"; + +export type ResourceWithAuth = { + resource: Resource | null; + pincode: ResourcePincode | null; + password: ResourcePassword | null; +}; + +export type UserSessionWithUser = { + session: any; + user: any; +}; + +const MODE = "remote"; +const remoteEndpoint = "https://api.example.com"; + +/** + * Get resource by domain with pincode and password information + */ +export async function getResourceByDomain( + domain: string +): Promise { + if (MODE === "remote") { + try { + const response = await axios.get(`${remoteEndpoint}/resource/domain/${domain}`); + return response.data; + } catch (error) { + console.error("Error fetching resource by domain:", error); + return null; + } + } + + const [result] = await db + .select() + .from(resources) + .leftJoin( + resourcePincode, + eq(resourcePincode.resourceId, resources.resourceId) + ) + .leftJoin( + resourcePassword, + eq(resourcePassword.resourceId, resources.resourceId) + ) + .where(eq(resources.fullDomain, domain)) + .limit(1); + + if (!result) { + return null; + } + + return { + resource: result.resources, + pincode: result.resourcePincode, + password: result.resourcePassword + }; +} + +/** + * Get user session with user information + */ +export async function getUserSessionWithUser( + userSessionId: string +): Promise { + if (MODE === "remote") { + try { + const response = await axios.get(`${remoteEndpoint}/session/${userSessionId}`); + return response.data; + } catch (error) { + console.error("Error fetching user session:", error); + return null; + } + } + + const [res] = await db + .select() + .from(sessions) + .leftJoin(users, eq(users.userId, sessions.userId)) + .where(eq(sessions.sessionId, userSessionId)); + + if (!res) { + return null; + } + + return { + session: res.session, + user: res.user + }; +} + +/** + * Get user organization role + */ +export async function getUserOrgRole(userId: string, orgId: string) { + if (MODE === "remote") { + try { + const response = await axios.get(`${remoteEndpoint}/user/${userId}/org/${orgId}/role`); + return response.data; + } catch (error) { + console.error("Error fetching user org role:", error); + return null; + } + } + + const userOrgRole = await db + .select() + .from(userOrgs) + .where( + and( + eq(userOrgs.userId, userId), + eq(userOrgs.orgId, orgId) + ) + ) + .limit(1); + + return userOrgRole.length > 0 ? userOrgRole[0] : null; +} + +/** + * Check if role has access to resource + */ +export async function getRoleResourceAccess(resourceId: number, roleId: number) { + if (MODE === "remote") { + try { + const response = await axios.get(`${remoteEndpoint}/role/${roleId}/resource/${resourceId}/access`); + return response.data; + } catch (error) { + console.error("Error fetching role resource access:", error); + return null; + } + } + + const roleResourceAccess = await db + .select() + .from(roleResources) + .where( + and( + eq(roleResources.resourceId, resourceId), + eq(roleResources.roleId, roleId) + ) + ) + .limit(1); + + return roleResourceAccess.length > 0 ? roleResourceAccess[0] : null; +} + +/** + * Check if user has direct access to resource + */ +export async function getUserResourceAccess(userId: string, resourceId: number) { + if (MODE === "remote") { + try { + const response = await axios.get(`${remoteEndpoint}/user/${userId}/resource/${resourceId}/access`); + return response.data; + } catch (error) { + console.error("Error fetching user resource access:", error); + return null; + } + } + + const userResourceAccess = await db + .select() + .from(userResources) + .where( + and( + eq(userResources.userId, userId), + eq(userResources.resourceId, resourceId) + ) + ) + .limit(1); + + return userResourceAccess.length > 0 ? userResourceAccess[0] : null; +} + +/** + * Get resource rules for a given resource + */ +export async function getResourceRules(resourceId: number): Promise { + if (MODE === "remote") { + try { + const response = await axios.get(`${remoteEndpoint}/resource/${resourceId}/rules`); + return response.data; + } catch (error) { + console.error("Error fetching resource rules:", error); + return []; + } + } + + const rules = await db + .select() + .from(resourceRules) + .where(eq(resourceRules.resourceId, resourceId)); + + return rules; +} diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index 48d7c064..54a2e0c9 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -6,20 +6,21 @@ import { } from "@server/auth/sessions/resource"; import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken"; import { db } from "@server/db"; +import { + getResourceByDomain, + getUserSessionWithUser, + getUserOrgRole, + getRoleResourceAccess, + getUserResourceAccess, + getResourceRules +} from "@server/db/queries/verifySessionQueries"; import { Resource, ResourceAccessToken, ResourcePassword, - resourcePassword, ResourcePincode, - resourcePincode, ResourceRule, - resourceRules, - resources, - roleResources, sessions, - userOrgs, - userResources, users } from "@server/db"; import config from "@server/lib/config"; @@ -27,7 +28,6 @@ import { isIpInCidr } from "@server/lib/ip"; import { response } from "@server/lib/response"; import logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; -import { and, eq } from "drizzle-orm"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import NodeCache from "node-cache"; @@ -137,31 +137,14 @@ export async function verifyResourceSession( | undefined = cache.get(resourceCacheKey); if (!resourceData) { - const [result] = await db - .select() - .from(resources) - .leftJoin( - resourcePincode, - eq(resourcePincode.resourceId, resources.resourceId) - ) - .leftJoin( - resourcePassword, - eq(resourcePassword.resourceId, resources.resourceId) - ) - .where(eq(resources.fullDomain, cleanHost)) - .limit(1); + const result = await getResourceByDomain(cleanHost); if (!result) { logger.debug("Resource not found", cleanHost); return notAllowed(res); } - resourceData = { - resource: result.resources, - pincode: result.resourcePincode, - password: result.resourcePassword - }; - + resourceData = result; cache.set(resourceCacheKey, resourceData); } @@ -529,14 +512,13 @@ async function isUserAllowedToAccessResource( userSessionId: string, resource: Resource ): Promise { - const [res] = await db - .select() - .from(sessions) - .leftJoin(users, eq(users.userId, sessions.userId)) - .where(eq(sessions.sessionId, userSessionId)); + const result = await getUserSessionWithUser(userSessionId); - const user = res.user; - const session = res.session; + if (!result) { + return null; + } + + const { user, session } = result; if (!user || !session) { return null; @@ -549,33 +531,18 @@ async function isUserAllowedToAccessResource( return null; } - const userOrgRole = await db - .select() - .from(userOrgs) - .where( - and( - eq(userOrgs.userId, user.userId), - eq(userOrgs.orgId, resource.orgId) - ) - ) - .limit(1); + const userOrgRole = await getUserOrgRole(user.userId, resource.orgId); - if (userOrgRole.length === 0) { + if (!userOrgRole) { return null; } - const roleResourceAccess = await db - .select() - .from(roleResources) - .where( - and( - eq(roleResources.resourceId, resource.resourceId), - eq(roleResources.roleId, userOrgRole[0].roleId) - ) - ) - .limit(1); + const roleResourceAccess = await getRoleResourceAccess( + resource.resourceId, + userOrgRole.roleId + ); - if (roleResourceAccess.length > 0) { + if (roleResourceAccess) { return { username: user.username, email: user.email, @@ -583,18 +550,12 @@ async function isUserAllowedToAccessResource( }; } - const userResourceAccess = await db - .select() - .from(userResources) - .where( - and( - eq(userResources.userId, user.userId), - eq(userResources.resourceId, resource.resourceId) - ) - ) - .limit(1); + const userResourceAccess = await getUserResourceAccess( + user.userId, + resource.resourceId + ); - if (userResourceAccess.length > 0) { + if (userResourceAccess) { return { username: user.username, email: user.email, @@ -615,11 +576,7 @@ async function checkRules( let rules: ResourceRule[] | undefined = cache.get(ruleCacheKey); if (!rules) { - rules = await db - .select() - .from(resourceRules) - .where(eq(resourceRules.resourceId, resourceId)); - + rules = await getResourceRules(resourceId); cache.set(ruleCacheKey, rules); } From 15f900317a87978f51dca346c13d57164437b61e Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 12 Aug 2025 13:53:57 -0700 Subject: [PATCH 02/63] Basic client --- server/routers/ws/client.ts | 342 ++++++++++++++++++++++++++++++++++++ 1 file changed, 342 insertions(+) create mode 100644 server/routers/ws/client.ts diff --git a/server/routers/ws/client.ts b/server/routers/ws/client.ts new file mode 100644 index 00000000..2cd5cfd7 --- /dev/null +++ b/server/routers/ws/client.ts @@ -0,0 +1,342 @@ +import WebSocket from 'ws'; +import axios from 'axios'; +import { URL } from 'url'; +import { EventEmitter } from 'events'; + +export interface Config { + id: string; + secret: string; + endpoint: string; +} + +export interface WSMessage { + type: string; + data: any; +} + +export interface TokenResponse { + success: boolean; + message?: string; + data: { + token: string; + }; +} + +export type MessageHandler = (message: WSMessage) => void; + +export interface ClientOptions { + baseURL?: string; + reconnectInterval?: number; + pingInterval?: number; + pingTimeout?: number; +} + +export class WebSocketClient extends EventEmitter { + private conn: WebSocket | null = null; + private config: Config; + private baseURL: string; + private handlers: Map = new Map(); + private reconnectInterval: number; + private isConnected: boolean = false; + private pingInterval: number; + private pingTimeout: number; + private clientType: string; + private shouldReconnect: boolean = true; + private reconnectTimer: NodeJS.Timeout | null = null; + private pingTimer: NodeJS.Timeout | null = null; + private pingTimeoutTimer: NodeJS.Timeout | null = null; + + constructor( + clientType: string, + id: string, + secret: string, + endpoint: string, + options: ClientOptions = {} + ) { + super(); + + this.clientType = clientType; + this.config = { + id, + secret, + endpoint + }; + + this.baseURL = options.baseURL || endpoint; + this.reconnectInterval = options.reconnectInterval || 3000; + this.pingInterval = options.pingInterval || 30000; + this.pingTimeout = options.pingTimeout || 10000; + } + + public getConfig(): Config { + return this.config; + } + + public async connect(): Promise { + this.shouldReconnect = true; + await this.connectWithRetry(); + } + + public async close(): Promise { + this.shouldReconnect = false; + + // Clear timers + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + if (this.pingTimer) { + clearInterval(this.pingTimer); + this.pingTimer = null; + } + if (this.pingTimeoutTimer) { + clearTimeout(this.pingTimeoutTimer); + this.pingTimeoutTimer = null; + } + + if (this.conn) { + this.conn.close(1000, 'Client closing'); + this.conn = null; + } + + this.setConnected(false); + } + + public sendMessage(messageType: string, data: any): Promise { + return new Promise((resolve, reject) => { + if (!this.conn || this.conn.readyState !== WebSocket.OPEN) { + reject(new Error('Not connected')); + return; + } + + const message: WSMessage = { + type: messageType, + data: data + }; + + console.debug(`Sending message: ${messageType}`, data); + + this.conn.send(JSON.stringify(message), (error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); + } + + public sendMessageInterval( + messageType: string, + data: any, + interval: number + ): () => void { + // Send immediately + this.sendMessage(messageType, data).catch(err => { + console.error('Failed to send initial message:', err); + }); + + // Set up interval + const intervalId = setInterval(() => { + this.sendMessage(messageType, data).catch(err => { + console.error('Failed to send message:', err); + }); + }, interval); + + // Return stop function + return () => { + clearInterval(intervalId); + }; + } + + public registerHandler(messageType: string, handler: MessageHandler): void { + this.handlers.set(messageType, handler); + } + + public unregisterHandler(messageType: string): void { + this.handlers.delete(messageType); + } + + public isClientConnected(): boolean { + return this.isConnected; + } + + private async getToken(): Promise { + const baseURL = new URL(this.baseURL); + const tokenEndpoint = `${baseURL.origin}/api/v1/auth/${this.clientType}/get-token`; + + const tokenData = this.clientType === 'newt' + ? { newtId: this.config.id, secret: this.config.secret } + : { olmId: this.config.id, secret: this.config.secret }; + + try { + const response = await axios.post(tokenEndpoint, tokenData, { + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': 'x-csrf-protection' + }, + timeout: 10000 // 10 second timeout + }); + + if (!response.data.success) { + throw new Error(`Failed to get token: ${response.data.message}`); + } + + if (!response.data.data.token) { + throw new Error('Received empty token from server'); + } + + console.debug(`Received token: ${response.data.data.token}`); + return response.data.data.token; + } catch (error) { + if (axios.isAxiosError(error)) { + if (error.response) { + throw new Error(`Failed to get token with status code: ${error.response.status}`); + } else if (error.request) { + throw new Error('Failed to request new token: No response received'); + } else { + throw new Error(`Failed to request new token: ${error.message}`); + } + } else { + throw new Error(`Failed to get token: ${error}`); + } + } + } + + private async connectWithRetry(): Promise { + while (this.shouldReconnect) { + try { + await this.establishConnection(); + return; + } catch (error) { + console.error(`Failed to connect: ${error}. Retrying in ${this.reconnectInterval}ms...`); + + if (!this.shouldReconnect) return; + + await new Promise(resolve => { + this.reconnectTimer = setTimeout(resolve, this.reconnectInterval); + }); + } + } + } + + private async establishConnection(): Promise { + // Get token for authentication + const token = await this.getToken(); + this.emit('tokenUpdate', token); + + // Parse the base URL to determine protocol and hostname + const baseURL = new URL(this.baseURL); + const wsProtocol = baseURL.protocol === 'https:' ? 'wss' : 'ws'; + const wsURL = new URL(`${wsProtocol}://${baseURL.host}/api/v1/ws`); + + // Add token and client type to query parameters + wsURL.searchParams.set('token', token); + wsURL.searchParams.set('clientType', this.clientType); + + return new Promise((resolve, reject) => { + const conn = new WebSocket(wsURL.toString()); + + conn.on('open', () => { + console.debug('WebSocket connection established'); + this.conn = conn; + this.setConnected(true); + this.startPingMonitor(); + this.emit('connect'); + resolve(); + }); + + conn.on('message', (data: WebSocket.Data) => { + try { + const message: WSMessage = JSON.parse(data.toString()); + const handler = this.handlers.get(message.type); + if (handler) { + handler(message); + } + this.emit('message', message); + } catch (error) { + console.error('Failed to parse message:', error); + } + }); + + conn.on('close', (code, reason) => { + console.debug(`WebSocket connection closed: ${code} ${reason}`); + this.handleDisconnect(); + }); + + conn.on('error', (error) => { + console.error('WebSocket error:', error); + if (this.conn === null) { + // Connection failed during establishment + reject(error); + } else { + this.handleDisconnect(); + } + }); + + conn.on('pong', () => { + if (this.pingTimeoutTimer) { + clearTimeout(this.pingTimeoutTimer); + this.pingTimeoutTimer = null; + } + }); + }); + } + + private startPingMonitor(): void { + this.pingTimer = setInterval(() => { + if (this.conn && this.conn.readyState === WebSocket.OPEN) { + this.conn.ping(); + + // Set timeout for pong response + this.pingTimeoutTimer = setTimeout(() => { + console.error('Ping timeout - no pong received'); + this.handleDisconnect(); + }, this.pingTimeout); + } + }, this.pingInterval); + } + + private handleDisconnect(): void { + this.setConnected(false); + + // Clear ping timers + if (this.pingTimer) { + clearInterval(this.pingTimer); + this.pingTimer = null; + } + if (this.pingTimeoutTimer) { + clearTimeout(this.pingTimeoutTimer); + this.pingTimeoutTimer = null; + } + + if (this.conn) { + this.conn.removeAllListeners(); + this.conn = null; + } + + this.emit('disconnect'); + + // Reconnect if needed + if (this.shouldReconnect) { + this.connectWithRetry(); + } + } + + private setConnected(status: boolean): void { + this.isConnected = status; + } +} + +// Factory function for easier instantiation +export function createWebSocketClient( + clientType: string, + id: string, + secret: string, + endpoint: string, + options?: ClientOptions +): WebSocketClient { + return new WebSocketClient(clientType, id, secret, endpoint, options); +} + +export default WebSocketClient; \ No newline at end of file From b6c2f123e8672cdc3a80ffeabc2d675e86d1ac38 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 12 Aug 2025 14:30:23 -0700 Subject: [PATCH 03/63] Add basic ws client --- server/hybridClientServer.ts | 76 ++++++++++++++++++++++++++++++++++++ server/index.ts | 9 ++++- server/lib/readConfigFile.ts | 8 +++- 3 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 server/hybridClientServer.ts diff --git a/server/hybridClientServer.ts b/server/hybridClientServer.ts new file mode 100644 index 00000000..939fc5fe --- /dev/null +++ b/server/hybridClientServer.ts @@ -0,0 +1,76 @@ +import next from "next"; +import express from "express"; +import { parse } from "url"; +import logger from "@server/logger"; +import config from "@server/lib/config"; +import { WebSocketClient, createWebSocketClient } from "./routers/ws/client"; +import { addPeer, deletePeer } from "./routers/gerbil/peers"; +import { db, exitNodes } from "./db"; + +export async function createHybridClientServer() { + if ( + !config.getRawConfig().hybrid?.id || + !config.getRawConfig().hybrid?.secret || + !config.getRawConfig().hybrid?.endpoint + ) { + throw new Error("Hybrid configuration is not defined"); + } + + // Create client + const client = createWebSocketClient( + "remoteExitNode", // or 'olm' + config.getRawConfig().hybrid!.id!, + config.getRawConfig().hybrid!.secret!, + config.getRawConfig().hybrid!.endpoint!, + { + reconnectInterval: 5000, + pingInterval: 30000, + pingTimeout: 10000 + } + ); + + // Register message handlers + client.registerHandler("remote/peers/add", async (message) => { + const { pubKey, allowedIps } = message.data; + + // TODO: we are getting the exit node twice here + // NOTE: there should only be one gerbil registered so... + const [exitNode] = await db.select().from(exitNodes).limit(1); + await addPeer(exitNode.exitNodeId, { + publicKey: pubKey, + allowedIps: allowedIps || [] + }); + }); + + client.registerHandler("remote/peers/remove", async (message) => { + const { pubKey } = message.data; + + // TODO: we are getting the exit node twice here + // NOTE: there should only be one gerbil registered so... + const [exitNode] = await db.select().from(exitNodes).limit(1); + await deletePeer(exitNode.exitNodeId, pubKey); + }); + + // Listen to connection events + client.on("connect", () => { + console.log("Connected to WebSocket server"); + }); + + client.on("disconnect", () => { + console.log("Disconnected from WebSocket server"); + }); + + client.on("message", (message) => { + console.log("Received message:", message.type, message.data); + }); + + // Connect to the server + try { + await client.connect(); + console.log("Connection initiated"); + } catch (error) { + console.error("Failed to connect:", error); + } + + client.sendMessageInterval("heartbeat", { timestamp: Date.now() }, 10000); +} diff --git a/server/index.ts b/server/index.ts index d3f90281..b0d6d3d7 100644 --- a/server/index.ts +++ b/server/index.ts @@ -7,6 +7,7 @@ import { createNextServer } from "./nextServer"; import { createInternalServer } from "./internalServer"; import { ApiKey, ApiKeyOrg, Session, User, UserOrg } from "@server/db"; import { createIntegrationApiServer } from "./integrationApiServer"; +import { createHybridClientServer } from "./hybridClientServer"; import config from "@server/lib/config"; async function startServers() { @@ -18,6 +19,11 @@ async function startServers() { const internalServer = createInternalServer(); const nextServer = await createNextServer(); + let hybridClientServer; + if (config.getRawConfig().hybrid) { + hybridClientServer = createHybridClientServer(); + } + let integrationServer; if (config.getRawConfig().flags?.enable_integration_api) { integrationServer = createIntegrationApiServer(); @@ -27,7 +33,8 @@ async function startServers() { apiServer, nextServer, internalServer, - integrationServer + integrationServer, + hybridClientServer }; } diff --git a/server/lib/readConfigFile.ts b/server/lib/readConfigFile.ts index 1bc119fa..e6e7c548 100644 --- a/server/lib/readConfigFile.ts +++ b/server/lib/readConfigFile.ts @@ -4,6 +4,7 @@ import { configFilePath1, configFilePath2 } from "./consts"; import { z } from "zod"; import stoi from "./stoi"; import { build } from "@server/build"; +import { setAdminCredentials } from "@cli/commands/setAdminCredentials"; const portSchema = z.number().positive().gt(0).lte(65535); @@ -25,8 +26,13 @@ export const configSchema = z .optional() .default("info"), save_logs: z.boolean().optional().default(false), - log_failed_attempts: z.boolean().optional().default(false) + log_failed_attempts: z.boolean().optional().default(false), }), + hybrid: z.object({ + id: z.string().optional(), + secret: z.string().optional(), + endpoint: z.string().optional() + }).optional(), domains: z .record( z.string(), From 30dbabd73d234be7b52f7187bb7adc590d8273e8 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 12 Aug 2025 15:27:03 -0700 Subject: [PATCH 04/63] Add internal proxy for gerbil endpoints --- server/routers/gerbil/index.ts | 3 +- server/routers/gerbil/proxy.ts | 101 +++++++++++++++++++++++++++++++++ server/routers/internal.ts | 16 ++++-- 3 files changed, 115 insertions(+), 5 deletions(-) create mode 100644 server/routers/gerbil/proxy.ts diff --git a/server/routers/gerbil/index.ts b/server/routers/gerbil/index.ts index 4a4f3b60..7cf4dfaa 100644 --- a/server/routers/gerbil/index.ts +++ b/server/routers/gerbil/index.ts @@ -1,4 +1,5 @@ export * from "./getConfig"; export * from "./receiveBandwidth"; export * from "./updateHolePunch"; -export * from "./getAllRelays"; \ No newline at end of file +export * from "./getAllRelays"; +export { default as proxyRouter } from "./proxy"; \ No newline at end of file diff --git a/server/routers/gerbil/proxy.ts b/server/routers/gerbil/proxy.ts new file mode 100644 index 00000000..9a6eb98e --- /dev/null +++ b/server/routers/gerbil/proxy.ts @@ -0,0 +1,101 @@ +import { Request, Response, NextFunction } from "express"; +import { Router } from "express"; +import axios from "axios"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import config from "@server/lib/config"; + +const proxyRouter = Router(); + +/** + * Proxy function that forwards requests to the remote cloud server + */ +async function proxyToRemote( + req: Request, + res: Response, + next: NextFunction, + endpoint: string +): Promise { + try { + const remoteConfig = config.getRawConfig().hybrid; + + if (!remoteConfig?.endpoint) { + logger.error("Remote endpoint not configured in hybrid.endpoint config"); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Remote endpoint not configured" + ) + ); + } + + const remoteUrl = `${remoteConfig.endpoint.replace(/\/$/, '')}/api/v1/gerbil/${endpoint}`; + + logger.debug(`Proxying request to remote server: ${remoteUrl}`); + + // Forward the request to the remote server + const response = await axios({ + method: req.method as any, + url: remoteUrl, + data: req.body, + headers: { + 'Content-Type': 'application/json', + }, + params: req.query, + timeout: 30000, // 30 second timeout + validateStatus: () => true // Don't throw on non-2xx status codes + }); + + // Forward the response status and data + return res.status(response.status).json(response.data); + + } catch (error) { + logger.error("Error proxying request to remote server:", error); + + if (axios.isAxiosError(error)) { + if (error.code === 'ECONNREFUSED' || error.code === 'ENOTFOUND') { + return next( + createHttpError( + HttpCode.SERVICE_UNAVAILABLE, + "Remote server is unavailable" + ) + ); + } + if (error.code === 'ECONNABORTED') { + return next( + createHttpError( + HttpCode.REQUEST_TIMEOUT, + "Request to remote server timed out" + ) + ); + } + } + + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error communicating with remote server" + ) + ); + } +} + +// Proxy endpoints for each gerbil route +proxyRouter.post("/get-config", (req, res, next) => + proxyToRemote(req, res, next, "get-config") +); + +proxyRouter.post("/receive-bandwidth", (req, res, next) => + proxyToRemote(req, res, next, "receive-bandwidth") +); + +proxyRouter.post("/update-hole-punch", (req, res, next) => + proxyToRemote(req, res, next, "update-hole-punch") +); + +proxyRouter.post("/get-all-relays", (req, res, next) => + proxyToRemote(req, res, next, "get-all-relays") +); + +export default proxyRouter; diff --git a/server/routers/internal.ts b/server/routers/internal.ts index 118c8ae3..3fa32d7c 100644 --- a/server/routers/internal.ts +++ b/server/routers/internal.ts @@ -7,6 +7,8 @@ import * as auth from "@server/routers/auth"; import * as supporterKey from "@server/routers/supporterKey"; import * as license from "@server/routers/license"; import * as idp from "@server/routers/idp"; +import proxyRouter from "@server/routers/gerbil/proxy"; +import config from "@server/lib/config"; import HttpCode from "@server/types/HttpCode"; import { verifyResourceAccess, @@ -49,10 +51,16 @@ internalRouter.get("/idp/:idpId", idp.getIdp); const gerbilRouter = Router(); internalRouter.use("/gerbil", gerbilRouter); -gerbilRouter.post("/get-config", gerbil.getConfig); -gerbilRouter.post("/receive-bandwidth", gerbil.receiveBandwidth); -gerbilRouter.post("/update-hole-punch", gerbil.updateHolePunch); -gerbilRouter.post("/get-all-relays", gerbil.getAllRelays); +if (config.getRawConfig().hybrid) { + // Use proxy router to forward requests to remote cloud server + gerbilRouter.use("/", proxyRouter); +} else { + // Use local gerbil endpoints + gerbilRouter.post("/get-config", gerbil.getConfig); + gerbilRouter.post("/receive-bandwidth", gerbil.receiveBandwidth); + gerbilRouter.post("/update-hole-punch", gerbil.updateHolePunch); + gerbilRouter.post("/get-all-relays", gerbil.getAllRelays); +} // Badger routes const badgerRouter = Router(); From 25ed3d65f891b128817b25f6bc597880a68810de Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 12 Aug 2025 15:58:20 -0700 Subject: [PATCH 05/63] Make the proxy more general --- .../gerbil/proxy.ts => remoteProxy.ts} | 30 ++++--------------- server/routers/gerbil/index.ts | 3 +- server/routers/internal.ts | 28 +++++++++++++++-- 3 files changed, 31 insertions(+), 30 deletions(-) rename server/{routers/gerbil/proxy.ts => remoteProxy.ts} (79%) diff --git a/server/routers/gerbil/proxy.ts b/server/remoteProxy.ts similarity index 79% rename from server/routers/gerbil/proxy.ts rename to server/remoteProxy.ts index 9a6eb98e..4e70dd04 100644 --- a/server/routers/gerbil/proxy.ts +++ b/server/remoteProxy.ts @@ -6,17 +6,16 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import config from "@server/lib/config"; -const proxyRouter = Router(); - /** * Proxy function that forwards requests to the remote cloud server */ -async function proxyToRemote( + +export const proxyToRemote = async ( req: Request, res: Response, next: NextFunction, endpoint: string -): Promise { +): Promise => { try { const remoteConfig = config.getRawConfig().hybrid; @@ -30,7 +29,7 @@ async function proxyToRemote( ); } - const remoteUrl = `${remoteConfig.endpoint.replace(/\/$/, '')}/api/v1/gerbil/${endpoint}`; + const remoteUrl = `${remoteConfig.endpoint.replace(/\/$/, '')}/api/v1/${endpoint}`; logger.debug(`Proxying request to remote server: ${remoteUrl}`); @@ -79,23 +78,4 @@ async function proxyToRemote( ) ); } -} - -// Proxy endpoints for each gerbil route -proxyRouter.post("/get-config", (req, res, next) => - proxyToRemote(req, res, next, "get-config") -); - -proxyRouter.post("/receive-bandwidth", (req, res, next) => - proxyToRemote(req, res, next, "receive-bandwidth") -); - -proxyRouter.post("/update-hole-punch", (req, res, next) => - proxyToRemote(req, res, next, "update-hole-punch") -); - -proxyRouter.post("/get-all-relays", (req, res, next) => - proxyToRemote(req, res, next, "get-all-relays") -); - -export default proxyRouter; +} \ No newline at end of file diff --git a/server/routers/gerbil/index.ts b/server/routers/gerbil/index.ts index 7cf4dfaa..4a4f3b60 100644 --- a/server/routers/gerbil/index.ts +++ b/server/routers/gerbil/index.ts @@ -1,5 +1,4 @@ export * from "./getConfig"; export * from "./receiveBandwidth"; export * from "./updateHolePunch"; -export * from "./getAllRelays"; -export { default as proxyRouter } from "./proxy"; \ No newline at end of file +export * from "./getAllRelays"; \ No newline at end of file diff --git a/server/routers/internal.ts b/server/routers/internal.ts index 3fa32d7c..dc212b8b 100644 --- a/server/routers/internal.ts +++ b/server/routers/internal.ts @@ -7,7 +7,7 @@ import * as auth from "@server/routers/auth"; import * as supporterKey from "@server/routers/supporterKey"; import * as license from "@server/routers/license"; import * as idp from "@server/routers/idp"; -import proxyRouter from "@server/routers/gerbil/proxy"; +import { proxyToRemote } from "@server/remoteProxy"; import config from "@server/lib/config"; import HttpCode from "@server/types/HttpCode"; import { @@ -53,7 +53,22 @@ internalRouter.use("/gerbil", gerbilRouter); if (config.getRawConfig().hybrid) { // Use proxy router to forward requests to remote cloud server - gerbilRouter.use("/", proxyRouter); + // Proxy endpoints for each gerbil route + gerbilRouter.post("/get-config", (req, res, next) => + proxyToRemote(req, res, next, "gerbil/get-config") + ); + + gerbilRouter.post("/receive-bandwidth", (req, res, next) => + proxyToRemote(req, res, next, "gerbil/receive-bandwidth") + ); + + gerbilRouter.post("/update-hole-punch", (req, res, next) => + proxyToRemote(req, res, next, "gerbil/update-hole-punch") + ); + + gerbilRouter.post("/get-all-relays", (req, res, next) => + proxyToRemote(req, res, next, "gerbil/get-all-relays") + ); } else { // Use local gerbil endpoints gerbilRouter.post("/get-config", gerbil.getConfig); @@ -67,6 +82,13 @@ const badgerRouter = Router(); internalRouter.use("/badger", badgerRouter); badgerRouter.post("/verify-session", badger.verifyResourceSession); -badgerRouter.post("/exchange-session", badger.exchangeSession); + +if (config.getRawConfig().hybrid) { + badgerRouter.post("/exchange-session", (req, res, next) => + proxyToRemote(req, res, next, "badger/exchange-session") + ); +} else { + badgerRouter.post("/exchange-session", badger.exchangeSession); +} export default internalRouter; From f219f1e36b88f6e01fa9e7f74b5511c470779655 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 12 Aug 2025 16:27:34 -0700 Subject: [PATCH 06/63] Move remote proxy --- server/{ => lib}/remoteProxy.ts | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename server/{ => lib}/remoteProxy.ts (100%) diff --git a/server/remoteProxy.ts b/server/lib/remoteProxy.ts similarity index 100% rename from server/remoteProxy.ts rename to server/lib/remoteProxy.ts From 39e35bc1d6e5e1f64ce7640f95deaff22a9a72ab Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 12 Aug 2025 16:27:41 -0700 Subject: [PATCH 07/63] Add traefik config management --- server/lib/readConfigFile.ts | 34 +- server/lib/remoteTraefikConfig.ts | 582 ++++++++++++++++++++++++++++++ 2 files changed, 607 insertions(+), 9 deletions(-) create mode 100644 server/lib/remoteTraefikConfig.ts diff --git a/server/lib/readConfigFile.ts b/server/lib/readConfigFile.ts index e6e7c548..5fb7b955 100644 --- a/server/lib/readConfigFile.ts +++ b/server/lib/readConfigFile.ts @@ -26,13 +26,15 @@ export const configSchema = z .optional() .default("info"), save_logs: z.boolean().optional().default(false), - log_failed_attempts: z.boolean().optional().default(false), + log_failed_attempts: z.boolean().optional().default(false) }), - hybrid: z.object({ - id: z.string().optional(), - secret: z.string().optional(), - endpoint: z.string().optional() - }).optional(), + hybrid: z + .object({ + id: z.string().optional(), + secret: z.string().optional(), + endpoint: z.string().optional() + }) + .optional(), domains: z .record( z.string(), @@ -136,7 +138,18 @@ export const configSchema = z https_entrypoint: z.string().optional().default("websecure"), additional_middlewares: z.array(z.string()).optional(), cert_resolver: z.string().optional().default("letsencrypt"), - prefer_wildcard_cert: z.boolean().optional().default(false) + prefer_wildcard_cert: z.boolean().optional().default(false), + certificates_path: z.string().default("./certificates"), + monitor_interval: z.number().default(5000), + dynamic_cert_config_path: z + .string() + .optional() + .default("./dynamic/cert_config.yml"), + dynamic_router_config_path: z + .string() + .optional() + .default("./dynamic/router_config.yml"), + staticDomains: z.array(z.string()).optional().default([]) }) .optional() .default({}), @@ -219,7 +232,10 @@ export const configSchema = z smtp_host: z.string().optional(), smtp_port: portSchema.optional(), smtp_user: z.string().optional(), - smtp_pass: z.string().optional().transform(getEnvOrYaml("EMAIL_SMTP_PASS")), + smtp_pass: z + .string() + .optional() + .transform(getEnvOrYaml("EMAIL_SMTP_PASS")), smtp_secure: z.boolean().optional(), smtp_tls_reject_unauthorized: z.boolean().optional(), no_reply: z.string().email().optional() @@ -235,7 +251,7 @@ export const configSchema = z disable_local_sites: z.boolean().optional(), disable_basic_wireguard_sites: z.boolean().optional(), disable_config_managed_domains: z.boolean().optional(), - enable_clients: z.boolean().optional().default(true), + enable_clients: z.boolean().optional().default(true) }) .optional(), dns: z diff --git a/server/lib/remoteTraefikConfig.ts b/server/lib/remoteTraefikConfig.ts new file mode 100644 index 00000000..755a14ae --- /dev/null +++ b/server/lib/remoteTraefikConfig.ts @@ -0,0 +1,582 @@ +import * as fs from "fs"; +import * as path from "path"; +import config from "@server/lib/config"; +import logger from "@server/logger"; +import * as yaml from "js-yaml"; +import axios from "axios"; +import { db, exitNodes } from "@server/db"; + +export class TraefikConfigManager { + private intervalId: NodeJS.Timeout | null = null; + private isRunning = false; + private activeDomains = new Set(); + private timeoutId: NodeJS.Timeout | null = null; + + constructor() {} + + /** + * Start monitoring certificates + */ + private scheduleNextExecution(): void { + const intervalMs = config.getRawConfig().traefik.monitor_interval; + const now = Date.now(); + const nextExecution = Math.ceil(now / intervalMs) * intervalMs; + const delay = nextExecution - now; + + this.timeoutId = setTimeout(async () => { + try { + await this.HandleTraefikConfig(); + } catch (error) { + logger.error("Error during certificate monitoring:", error); + } + + if (this.isRunning) { + this.scheduleNextExecution(); // Schedule the next execution + } + }, delay); + } + + async start(): Promise { + if (this.isRunning) { + logger.info("Certificate monitor is already running"); + return; + } + this.isRunning = true; + logger.info(`Starting certificate monitor for exit node`); + + // Ensure certificates directory exists + await this.ensureDirectoryExists( + config.getRawConfig().traefik.certificates_path + ); + + // Run initial check + await this.HandleTraefikConfig(); + + // Start synchronized scheduling + this.scheduleNextExecution(); + + logger.info( + `Certificate monitor started with synchronized ${ + config.getRawConfig().traefik.monitor_interval + }ms interval` + ); + } + /** + * Stop monitoring certificates + */ + stop(): void { + if (!this.isRunning) { + logger.info("Certificate monitor is not running"); + return; + } + + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; + } + + this.isRunning = false; + logger.info("Certificate monitor stopped"); + } + + /** + * Main monitoring logic + */ + lastActiveDomains: Set = new Set(); + public async HandleTraefikConfig(): Promise { + try { + // Get all active domains for this exit node via HTTP call + const getActiveDomainsFromTraefik = + await this.getActiveDomainsFromTraefik(); + + if (!getActiveDomainsFromTraefik) { + logger.error( + "Failed to fetch active domains from traefik config" + ); + return; + } + + const { domains, traefikConfig } = getActiveDomainsFromTraefik; + + // Add static domains from config + // const staticDomains = [config.getRawConfig().app.dashboard_url]; + // staticDomains.forEach((domain) => domains.add(domain)); + + // Log if domains changed + if ( + this.lastActiveDomains.size !== domains.size || + !Array.from(this.lastActiveDomains).every((domain) => + domains.has(domain) + ) + ) { + logger.info( + `Active domains changed for exit node: ${Array.from(domains).join(", ")}` + ); + this.lastActiveDomains = new Set(domains); + } + + // Get valid certificates for active domains + const validCertificates = + await this.getValidCertificatesForDomains(domains); + + // Download and decrypt new certificates + await this.processValidCertificates(validCertificates); + + // Clean up certificates for domains no longer in use + await this.cleanupUnusedCertificates(domains); + + // wait 1 second for traefik to pick up the new certificates + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Write traefik config as YAML to a second dynamic config file if changed + await this.writeTraefikDynamicConfig(traefikConfig); + + // Send domains to SNI proxy + try { + const [exitNode] = await db.select().from(exitNodes).limit(1); + if (exitNode) { + logger.error("No exit node found"); + await axios.post( + `${exitNode.reachableAt}/full-domains`, + { fullDomains: Array.from(domains) }, + { headers: { "Content-Type": "application/json" } } + ); + } + } catch (err) { + logger.error("Failed to post domains to SNI proxy:", err); + } + + // Update active domains tracking + this.activeDomains = domains; + } catch (error) { + logger.error("Error in certificate monitoring cycle:", error); + } + } + + /** + * Get all domains currently in use from traefik config API + */ + private async getActiveDomainsFromTraefik(): Promise<{ + domains: Set; + traefikConfig: any; + } | null> { + try { + const resp = await axios.get( + `${config.getRawConfig().hybrid?.endpoint}/get-traefik-config` + ); + + if (resp.status !== 200) { + logger.error( + `Failed to fetch traefik config: ${resp.status} ${resp.statusText}` + ); + return null; + } + + const traefikConfig = resp.data; + const domains = new Set(); + + if (traefikConfig?.http?.routers) { + for (const router of Object.values( + traefikConfig.http.routers + )) { + if (router.rule && typeof router.rule === "string") { + // Match Host(`domain`) + const match = router.rule.match(/Host\(`([^`]+)`\)/); + if (match && match[1]) { + domains.add(match[1]); + } + } + } + } + return { domains, traefikConfig }; + } catch (err) { + logger.error("Failed to fetch traefik config:", err); + return null; + } + } + + /** + * Write traefik config as YAML to a second dynamic config file if changed + */ + private async writeTraefikDynamicConfig(traefikConfig: any): Promise { + const traefikDynamicConfigPath = + config.getRawConfig().traefik.dynamic_router_config_path; + let shouldWrite = false; + let oldJson = ""; + if (fs.existsSync(traefikDynamicConfigPath)) { + try { + const oldContent = fs.readFileSync( + traefikDynamicConfigPath, + "utf8" + ); + // Try to parse as YAML then JSON.stringify for comparison + const oldObj = yaml.load(oldContent); + oldJson = JSON.stringify(oldObj); + } catch { + oldJson = ""; + } + } + const newJson = JSON.stringify(traefikConfig); + if (oldJson !== newJson) { + shouldWrite = true; + } + if (shouldWrite) { + try { + fs.writeFileSync( + traefikDynamicConfigPath, + yaml.dump(traefikConfig, { noRefs: true }), + "utf8" + ); + logger.info("Traefik dynamic config updated"); + } catch (err) { + logger.error("Failed to write traefik dynamic config:", err); + } + } + } + + /** + * Get valid certificates for the specified domains + */ + private async getValidCertificatesForDomains(domains: Set): Promise< + Array<{ + id: number; + domain: string; + certFile: string | null; + keyFile: string | null; + expiresAt: Date | null; + updatedAt?: Date | null; + }> + > { + if (domains.size === 0) { + return []; + } + + const domainArray = Array.from(domains); + + try { + const response = await axios.get( + `${config.getRawConfig().hybrid?.endpoint}/certificates/domains`, + { + params: { + domains: domainArray + } + } + ); + return response.data; + } catch (error) { + console.error("Error fetching resource by domain:", error); + return []; + } + } + + /** + * Process valid certificates - download and decrypt them + */ + private async processValidCertificates( + validCertificates: Array<{ + id: number; + domain: string; + certFile: string | null; + keyFile: string | null; + expiresAt: Date | null; + updatedAt?: Date | null; + }> + ): Promise { + const dynamicConfigPath = + config.getRawConfig().traefik.dynamic_cert_config_path; + + // Load existing dynamic config if it exists, otherwise initialize + let dynamicConfig: any = { tls: { certificates: [] } }; + if (fs.existsSync(dynamicConfigPath)) { + try { + const fileContent = fs.readFileSync(dynamicConfigPath, "utf8"); + dynamicConfig = yaml.load(fileContent) || dynamicConfig; + if (!dynamicConfig.tls) + dynamicConfig.tls = { certificates: [] }; + if (!Array.isArray(dynamicConfig.tls.certificates)) { + dynamicConfig.tls.certificates = []; + } + } catch (err) { + logger.error("Failed to load existing dynamic config:", err); + } + } + + // Keep a copy of the original config for comparison + const originalConfigYaml = yaml.dump(dynamicConfig, { noRefs: true }); + + for (const cert of validCertificates) { + try { + if (!cert.certFile || !cert.keyFile) { + logger.warn( + `Certificate for domain ${cert.domain} is missing cert or key file` + ); + continue; + } + + const domainDir = path.join( + config.getRawConfig().traefik.certificates_path, + cert.domain + ); + await this.ensureDirectoryExists(domainDir); + + const certPath = path.join(domainDir, "cert.pem"); + const keyPath = path.join(domainDir, "key.pem"); + const lastUpdatePath = path.join(domainDir, ".last_update"); + + // Check if we need to update the certificate + const shouldUpdate = await this.shouldUpdateCertificate( + cert, + certPath, + keyPath, + lastUpdatePath + ); + + if (shouldUpdate) { + logger.info( + `Processing certificate for domain: ${cert.domain}` + ); + + fs.writeFileSync(certPath, cert.certFile, "utf8"); + fs.writeFileSync(keyPath, cert.keyFile, "utf8"); + + // Set appropriate permissions (readable by owner only for key file) + fs.chmodSync(certPath, 0o644); + fs.chmodSync(keyPath, 0o600); + + // Write/update .last_update file with current timestamp + fs.writeFileSync( + lastUpdatePath, + new Date().toISOString(), + "utf8" + ); + + logger.info( + `Certificate updated for domain: ${cert.domain}` + ); + } + + // Always ensure the config entry exists and is up to date + const certEntry = { + certFile: `/var/${certPath}`, + keyFile: `/var/${keyPath}` + }; + // Remove any existing entry for this cert/key path + dynamicConfig.tls.certificates = + dynamicConfig.tls.certificates.filter( + (entry: any) => + entry.certFile !== certEntry.certFile || + entry.keyFile !== certEntry.keyFile + ); + dynamicConfig.tls.certificates.push(certEntry); + } catch (error) { + logger.error( + `Error processing certificate for domain ${cert.domain}:`, + error + ); + } + } + + // Only write the config if it has changed + const newConfigYaml = yaml.dump(dynamicConfig, { noRefs: true }); + if (newConfigYaml !== originalConfigYaml) { + fs.writeFileSync(dynamicConfigPath, newConfigYaml, "utf8"); + logger.info("Dynamic cert config updated"); + } + } + + /** + * Check if certificate should be updated + */ + private async shouldUpdateCertificate( + cert: { + id: number; + domain: string; + expiresAt: Date | null; + updatedAt?: Date | null; + }, + certPath: string, + keyPath: string, + lastUpdatePath: string + ): Promise { + try { + // If files don't exist, we need to create them + const certExists = await this.fileExists(certPath); + const keyExists = await this.fileExists(keyPath); + const lastUpdateExists = await this.fileExists(lastUpdatePath); + + if (!certExists || !keyExists || !lastUpdateExists) { + return true; + } + + // Read last update time from .last_update file + let lastUpdateTime: Date | null = null; + try { + const lastUpdateStr = fs + .readFileSync(lastUpdatePath, "utf8") + .trim(); + lastUpdateTime = new Date(lastUpdateStr); + } catch { + lastUpdateTime = null; + } + + // Use updatedAt from cert, fallback to expiresAt if not present + const dbUpdateTime = cert.updatedAt ?? cert.expiresAt; + + if (!dbUpdateTime) { + // If no update time in DB, always update + return true; + } + + // If DB updatedAt is newer than last update file, update + if (!lastUpdateTime || dbUpdateTime > lastUpdateTime) { + return true; + } + + return false; + } catch (error) { + logger.error( + `Error checking certificate update status for ${cert.domain}:`, + error + ); + return true; // When in doubt, update + } + } + + /** + * Clean up certificates for domains no longer in use + */ + private async cleanupUnusedCertificates( + currentActiveDomains: Set + ): Promise { + try { + const certsPath = config.getRawConfig().traefik.certificates_path; + const dynamicConfigPath = + config.getRawConfig().traefik.dynamic_cert_config_path; + + // Load existing dynamic config if it exists + let dynamicConfig: any = { tls: { certificates: [] } }; + if (fs.existsSync(dynamicConfigPath)) { + try { + const fileContent = fs.readFileSync( + dynamicConfigPath, + "utf8" + ); + dynamicConfig = yaml.load(fileContent) || dynamicConfig; + if (!dynamicConfig.tls) + dynamicConfig.tls = { certificates: [] }; + if (!Array.isArray(dynamicConfig.tls.certificates)) { + dynamicConfig.tls.certificates = []; + } + } catch (err) { + logger.error( + "Failed to load existing dynamic config:", + err + ); + } + } + + const certDirs = fs.readdirSync(certsPath, { + withFileTypes: true + }); + + let configChanged = false; + + for (const dirent of certDirs) { + if (!dirent.isDirectory()) continue; + + const dirName = dirent.name; + // Only delete if NO current domain is exactly the same or ends with `.${dirName}` + const shouldDelete = !Array.from(currentActiveDomains).some( + (domain) => + domain === dirName || domain.endsWith(`.${dirName}`) + ); + + if (shouldDelete) { + const domainDir = path.join(certsPath, dirName); + logger.info( + `Cleaning up unused certificate directory: ${dirName}` + ); + fs.rmSync(domainDir, { recursive: true, force: true }); + + // Remove from dynamic config + const certFilePath = `/var/${path.join( + domainDir, + "cert.pem" + )}`; + const keyFilePath = `/var/${path.join( + domainDir, + "key.pem" + )}`; + const before = dynamicConfig.tls.certificates.length; + dynamicConfig.tls.certificates = + dynamicConfig.tls.certificates.filter( + (entry: any) => + entry.certFile !== certFilePath && + entry.keyFile !== keyFilePath + ); + if (dynamicConfig.tls.certificates.length !== before) { + configChanged = true; + } + } + } + + if (configChanged) { + try { + fs.writeFileSync( + dynamicConfigPath, + yaml.dump(dynamicConfig, { noRefs: true }), + "utf8" + ); + logger.info("Dynamic config updated after cleanup"); + } catch (err) { + logger.error( + "Failed to update dynamic config after cleanup:", + err + ); + } + } + } catch (error) { + logger.error("Error during certificate cleanup:", error); + } + } + + /** + * Ensure directory exists + */ + private async ensureDirectoryExists(dirPath: string): Promise { + try { + fs.mkdirSync(dirPath, { recursive: true }); + } catch (error) { + logger.error(`Error creating directory ${dirPath}:`, error); + throw error; + } + } + + /** + * Check if file exists + */ + private async fileExists(filePath: string): Promise { + try { + fs.accessSync(filePath); + return true; + } catch { + return false; + } + } + + /** + * Get current status + */ + getStatus(): { + isRunning: boolean; + activeDomains: string[]; + monitorInterval: number; + } { + return { + isRunning: this.isRunning, + activeDomains: Array.from(this.activeDomains), + monitorInterval: + config.getRawConfig().traefik.monitor_interval || 5000 + }; + } +} From 880a123149937ea41760cb05e870070ef4a62de0 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 12 Aug 2025 16:31:53 -0700 Subject: [PATCH 08/63] Import tcm --- server/hybridClientServer.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/server/hybridClientServer.ts b/server/hybridClientServer.ts index 939fc5fe..8a16f985 100644 --- a/server/hybridClientServer.ts +++ b/server/hybridClientServer.ts @@ -6,8 +6,13 @@ import config from "@server/lib/config"; import { WebSocketClient, createWebSocketClient } from "./routers/ws/client"; import { addPeer, deletePeer } from "./routers/gerbil/peers"; import { db, exitNodes } from "./db"; +import { TraefikConfigManager } from "./lib/remoteTraefikConfig"; export async function createHybridClientServer() { + const monitor = new TraefikConfigManager(); + + await monitor.start(); + if ( !config.getRawConfig().hybrid?.id || !config.getRawConfig().hybrid?.secret || @@ -33,7 +38,7 @@ export async function createHybridClientServer() { client.registerHandler("remote/peers/add", async (message) => { const { pubKey, allowedIps } = message.data; - // TODO: we are getting the exit node twice here + // TODO: we are getting the exit node twice here // NOTE: there should only be one gerbil registered so... const [exitNode] = await db.select().from(exitNodes).limit(1); await addPeer(exitNode.exitNodeId, { @@ -45,7 +50,7 @@ export async function createHybridClientServer() { client.registerHandler("remote/peers/remove", async (message) => { const { pubKey } = message.data; - // TODO: we are getting the exit node twice here + // TODO: we are getting the exit node twice here // NOTE: there should only be one gerbil registered so... const [exitNode] = await db.select().from(exitNodes).limit(1); await deletePeer(exitNode.exitNodeId, pubKey); From 3d8869066ad8226815b14dd444b997f300f6000f Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 12 Aug 2025 16:47:59 -0700 Subject: [PATCH 09/63] Adjust pulling in config --- server/db/queries/verifySessionQueries.ts | 28 +++++++++++------------ server/hybridClientServer.ts | 4 ++++ server/index.ts | 2 +- server/lib/config.ts | 4 ++++ server/lib/remoteProxy.ts | 14 +----------- server/routers/internal.ts | 6 ++--- 6 files changed, 26 insertions(+), 32 deletions(-) diff --git a/server/db/queries/verifySessionQueries.ts b/server/db/queries/verifySessionQueries.ts index 1e159304..44982f64 100644 --- a/server/db/queries/verifySessionQueries.ts +++ b/server/db/queries/verifySessionQueries.ts @@ -16,6 +16,7 @@ import { } from "@server/db"; import { and, eq } from "drizzle-orm"; import axios from "axios"; +import config from "@server/lib/config"; export type ResourceWithAuth = { resource: Resource | null; @@ -28,18 +29,15 @@ export type UserSessionWithUser = { user: any; }; -const MODE = "remote"; -const remoteEndpoint = "https://api.example.com"; - /** * Get resource by domain with pincode and password information */ export async function getResourceByDomain( domain: string ): Promise { - if (MODE === "remote") { + if (config.isHybridMode()) { try { - const response = await axios.get(`${remoteEndpoint}/resource/domain/${domain}`); + const response = await axios.get(`${config.getRawConfig().hybrid?.endpoint}/resource/domain/${domain}`); return response.data; } catch (error) { console.error("Error fetching resource by domain:", error); @@ -78,9 +76,9 @@ export async function getResourceByDomain( export async function getUserSessionWithUser( userSessionId: string ): Promise { - if (MODE === "remote") { + if (config.isHybridMode()) { try { - const response = await axios.get(`${remoteEndpoint}/session/${userSessionId}`); + const response = await axios.get(`${config.getRawConfig().hybrid?.endpoint}/session/${userSessionId}`); return response.data; } catch (error) { console.error("Error fetching user session:", error); @@ -108,9 +106,9 @@ export async function getUserSessionWithUser( * Get user organization role */ export async function getUserOrgRole(userId: string, orgId: string) { - if (MODE === "remote") { + if (config.isHybridMode()) { try { - const response = await axios.get(`${remoteEndpoint}/user/${userId}/org/${orgId}/role`); + const response = await axios.get(`${config.getRawConfig().hybrid?.endpoint}/user/${userId}/org/${orgId}/role`); return response.data; } catch (error) { console.error("Error fetching user org role:", error); @@ -136,9 +134,9 @@ export async function getUserOrgRole(userId: string, orgId: string) { * Check if role has access to resource */ export async function getRoleResourceAccess(resourceId: number, roleId: number) { - if (MODE === "remote") { + if (config.isHybridMode()) { try { - const response = await axios.get(`${remoteEndpoint}/role/${roleId}/resource/${resourceId}/access`); + const response = await axios.get(`${config.getRawConfig().hybrid?.endpoint}/role/${roleId}/resource/${resourceId}/access`); return response.data; } catch (error) { console.error("Error fetching role resource access:", error); @@ -164,9 +162,9 @@ export async function getRoleResourceAccess(resourceId: number, roleId: number) * Check if user has direct access to resource */ export async function getUserResourceAccess(userId: string, resourceId: number) { - if (MODE === "remote") { + if (config.isHybridMode()) { try { - const response = await axios.get(`${remoteEndpoint}/user/${userId}/resource/${resourceId}/access`); + const response = await axios.get(`${config.getRawConfig().hybrid?.endpoint}/user/${userId}/resource/${resourceId}/access`); return response.data; } catch (error) { console.error("Error fetching user resource access:", error); @@ -192,9 +190,9 @@ export async function getUserResourceAccess(userId: string, resourceId: number) * Get resource rules for a given resource */ export async function getResourceRules(resourceId: number): Promise { - if (MODE === "remote") { + if (config.isHybridMode()) { try { - const response = await axios.get(`${remoteEndpoint}/resource/${resourceId}/rules`); + const response = await axios.get(`${config.getRawConfig().hybrid?.endpoint}/resource/${resourceId}/rules`); return response.data; } catch (error) { console.error("Error fetching resource rules:", error); diff --git a/server/hybridClientServer.ts b/server/hybridClientServer.ts index 8a16f985..074fcd2e 100644 --- a/server/hybridClientServer.ts +++ b/server/hybridClientServer.ts @@ -56,6 +56,10 @@ export async function createHybridClientServer() { await deletePeer(exitNode.exitNodeId, pubKey); }); + client.registerHandler("remote/traefik/reload", async (message) => { + await monitor.HandleTraefikConfig(); + }); + // Listen to connection events client.on("connect", () => { console.log("Connected to WebSocket server"); diff --git a/server/index.ts b/server/index.ts index b0d6d3d7..42f85da6 100644 --- a/server/index.ts +++ b/server/index.ts @@ -20,7 +20,7 @@ async function startServers() { const nextServer = await createNextServer(); let hybridClientServer; - if (config.getRawConfig().hybrid) { + if (config.isHybridMode()) { hybridClientServer = createHybridClientServer(); } diff --git a/server/lib/config.ts b/server/lib/config.ts index 023ae054..c8c7b7c4 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -147,6 +147,10 @@ export class Config { return false; } + public isHybridMode() { + return this.rawConfig?.hybrid; + } + public async checkSupporterKey() { const [key] = await db.select().from(supporterKey).limit(1); diff --git a/server/lib/remoteProxy.ts b/server/lib/remoteProxy.ts index 4e70dd04..080c3bd3 100644 --- a/server/lib/remoteProxy.ts +++ b/server/lib/remoteProxy.ts @@ -17,20 +17,8 @@ export const proxyToRemote = async ( endpoint: string ): Promise => { try { - const remoteConfig = config.getRawConfig().hybrid; - - if (!remoteConfig?.endpoint) { - logger.error("Remote endpoint not configured in hybrid.endpoint config"); - return next( - createHttpError( - HttpCode.INTERNAL_SERVER_ERROR, - "Remote endpoint not configured" - ) - ); - } + const remoteUrl = `${config.getRawConfig().hybrid?.endpoint?.replace(/\/$/, '')}/api/v1/${endpoint}`; - const remoteUrl = `${remoteConfig.endpoint.replace(/\/$/, '')}/api/v1/${endpoint}`; - logger.debug(`Proxying request to remote server: ${remoteUrl}`); // Forward the request to the remote server diff --git a/server/routers/internal.ts b/server/routers/internal.ts index dc212b8b..a84f6976 100644 --- a/server/routers/internal.ts +++ b/server/routers/internal.ts @@ -7,7 +7,7 @@ import * as auth from "@server/routers/auth"; import * as supporterKey from "@server/routers/supporterKey"; import * as license from "@server/routers/license"; import * as idp from "@server/routers/idp"; -import { proxyToRemote } from "@server/remoteProxy"; +import { proxyToRemote } from "@server/lib/remoteProxy"; import config from "@server/lib/config"; import HttpCode from "@server/types/HttpCode"; import { @@ -51,7 +51,7 @@ internalRouter.get("/idp/:idpId", idp.getIdp); const gerbilRouter = Router(); internalRouter.use("/gerbil", gerbilRouter); -if (config.getRawConfig().hybrid) { +if (config.isHybridMode()) { // Use proxy router to forward requests to remote cloud server // Proxy endpoints for each gerbil route gerbilRouter.post("/get-config", (req, res, next) => @@ -83,7 +83,7 @@ internalRouter.use("/badger", badgerRouter); badgerRouter.post("/verify-session", badger.verifyResourceSession); -if (config.getRawConfig().hybrid) { +if (config.isHybridMode()) { badgerRouter.post("/exchange-session", (req, res, next) => proxyToRemote(req, res, next, "badger/exchange-session") ); From ddd8eb1da05a3ff6e7c1bd5911a406dd4f0ec5d0 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 13 Aug 2025 16:02:03 -0700 Subject: [PATCH 10/63] Change sni proxy url --- server/lib/remoteTraefikConfig.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/lib/remoteTraefikConfig.ts b/server/lib/remoteTraefikConfig.ts index 755a14ae..08b2ab98 100644 --- a/server/lib/remoteTraefikConfig.ts +++ b/server/lib/remoteTraefikConfig.ts @@ -137,7 +137,7 @@ export class TraefikConfigManager { if (exitNode) { logger.error("No exit node found"); await axios.post( - `${exitNode.reachableAt}/full-domains`, + `${exitNode.reachableAt}/update-local-snis`, { fullDomains: Array.from(domains) }, { headers: { "Content-Type": "application/json" } } ); From 1f6379a7e6d7307cf54f3e64e95b0216db5d994e Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 13 Aug 2025 16:15:23 -0700 Subject: [PATCH 11/63] Break out traefik config --- server/routers/traefik/getTraefikConfig.ts | 847 +++++++++++---------- 1 file changed, 424 insertions(+), 423 deletions(-) diff --git a/server/routers/traefik/getTraefikConfig.ts b/server/routers/traefik/getTraefikConfig.ts index 882a296a..da013878 100644 --- a/server/routers/traefik/getTraefikConfig.ts +++ b/server/routers/traefik/getTraefikConfig.ts @@ -13,434 +13,37 @@ export async function traefikConfigProvider( res: Response ): Promise { try { - // Get all resources with related data - const allResources = await db.transaction(async (tx) => { - // First query to get resources with site and org info - // Get the current exit node name from config - if (!currentExitNodeId) { - if (config.getRawConfig().gerbil.exit_node_name) { - const exitNodeName = - config.getRawConfig().gerbil.exit_node_name!; - const [exitNode] = await tx - .select({ - exitNodeId: exitNodes.exitNodeId - }) - .from(exitNodes) - .where(eq(exitNodes.name, exitNodeName)); - if (exitNode) { - currentExitNodeId = exitNode.exitNodeId; - } - } else { - const [exitNode] = await tx - .select({ - exitNodeId: exitNodes.exitNodeId - }) - .from(exitNodes) - .limit(1); - - if (exitNode) { - currentExitNodeId = exitNode.exitNodeId; - } - } - } - - // Get the site(s) on this exit node - const resourcesWithRelations = await tx - .select({ - // Resource fields - resourceId: resources.resourceId, - fullDomain: resources.fullDomain, - ssl: resources.ssl, - http: resources.http, - proxyPort: resources.proxyPort, - protocol: resources.protocol, - subdomain: resources.subdomain, - domainId: resources.domainId, - // Site fields - site: { - siteId: sites.siteId, - type: sites.type, - subnet: sites.subnet, - exitNodeId: sites.exitNodeId - }, - enabled: resources.enabled, - stickySession: resources.stickySession, - tlsServerName: resources.tlsServerName, - setHostHeader: resources.setHostHeader, - enableProxy: resources.enableProxy - }) - .from(resources) - .innerJoin(sites, eq(sites.siteId, resources.siteId)) - .where( - or( - eq(sites.exitNodeId, currentExitNodeId), - isNull(sites.exitNodeId) - ) - ); - - // Get all resource IDs from the first query - const resourceIds = resourcesWithRelations.map((r) => r.resourceId); - - // Second query to get all enabled targets for these resources - const allTargets = - resourceIds.length > 0 - ? await tx - .select({ - resourceId: targets.resourceId, - targetId: targets.targetId, - ip: targets.ip, - method: targets.method, - port: targets.port, - internalPort: targets.internalPort, - enabled: targets.enabled - }) - .from(targets) - .where( - and( - inArray(targets.resourceId, resourceIds), - eq(targets.enabled, true) - ) - ) - : []; - - // Create a map for fast target lookup by resourceId - const targetsMap = allTargets.reduce((map, target) => { - if (!map.has(target.resourceId)) { - map.set(target.resourceId, []); - } - map.get(target.resourceId).push(target); - return map; - }, new Map()); - - // Combine the data - return resourcesWithRelations.map((resource) => ({ - ...resource, - targets: targetsMap.get(resource.resourceId) || [] - })); - }); - - if (!allResources.length) { - return res.status(HttpCode.OK).json({}); - } - - const badgerMiddlewareName = "badger"; - const redirectHttpsMiddlewareName = "redirect-to-https"; - - const config_output: any = { - http: { - middlewares: { - [badgerMiddlewareName]: { - plugin: { - [badgerMiddlewareName]: { - apiBaseUrl: new URL( - "/api/v1", - `http://${ - config.getRawConfig().server - .internal_hostname - }:${ - config.getRawConfig().server - .internal_port - }` - ).href, - userSessionCookieName: - config.getRawConfig().server - .session_cookie_name, - - // deprecated - accessTokenQueryParam: - config.getRawConfig().server - .resource_access_token_param, - - resourceSessionRequestParam: - config.getRawConfig().server - .resource_session_request_param - } - } - }, - [redirectHttpsMiddlewareName]: { - redirectScheme: { - scheme: "https" - } - } - } - } - }; - - for (const resource of allResources) { - const targets = resource.targets as Target[]; - const site = resource.site; - - const routerName = `${resource.resourceId}-router`; - const serviceName = `${resource.resourceId}-service`; - const fullDomain = `${resource.fullDomain}`; - const transportName = `${resource.resourceId}-transport`; - const hostHeaderMiddlewareName = `${resource.resourceId}-host-header-middleware`; - - if (!resource.enabled) { - continue; - } - - if (resource.http) { - if (!resource.domainId) { - continue; - } - - if (!resource.fullDomain) { - logger.error( - `Resource ${resource.resourceId} has no fullDomain` - ); - continue; - } - - // add routers and services empty objects if they don't exist - if (!config_output.http.routers) { - config_output.http.routers = {}; - } - - if (!config_output.http.services) { - config_output.http.services = {}; - } - - const domainParts = fullDomain.split("."); - let wildCard; - if (domainParts.length <= 2) { - wildCard = `*.${domainParts.join(".")}`; - } else { - wildCard = `*.${domainParts.slice(1).join(".")}`; - } - - if (!resource.subdomain) { - wildCard = resource.fullDomain; - } - - const configDomain = config.getDomain(resource.domainId); - - let certResolver: string, preferWildcardCert: boolean; - if (!configDomain) { - certResolver = config.getRawConfig().traefik.cert_resolver; - preferWildcardCert = - config.getRawConfig().traefik.prefer_wildcard_cert; - } else { - certResolver = configDomain.cert_resolver; - preferWildcardCert = configDomain.prefer_wildcard_cert; - } - - const tls = { - certResolver: certResolver, - ...(preferWildcardCert - ? { - domains: [ - { - main: wildCard - } - ] - } - : {}) - }; - - const additionalMiddlewares = - config.getRawConfig().traefik.additional_middlewares || []; - - config_output.http.routers![routerName] = { - entryPoints: [ - resource.ssl - ? config.getRawConfig().traefik.https_entrypoint - : config.getRawConfig().traefik.http_entrypoint - ], - middlewares: [ - badgerMiddlewareName, - ...additionalMiddlewares - ], - service: serviceName, - rule: `Host(\`${fullDomain}\`)`, - priority: 100, - ...(resource.ssl ? { tls } : {}) - }; - - if (resource.ssl) { - config_output.http.routers![routerName + "-redirect"] = { - entryPoints: [ - config.getRawConfig().traefik.http_entrypoint - ], - middlewares: [redirectHttpsMiddlewareName], - service: serviceName, - rule: `Host(\`${fullDomain}\`)`, - priority: 100 - }; - } - - config_output.http.services![serviceName] = { - loadBalancer: { - servers: targets - .filter((target: Target) => { - if (!target.enabled) { - return false; - } - if ( - site.type === "local" || - site.type === "wireguard" - ) { - if ( - !target.ip || - !target.port || - !target.method - ) { - return false; - } - } else if (site.type === "newt") { - if ( - !target.internalPort || - !target.method || - !site.subnet - ) { - return false; - } - } - return true; - }) - .map((target: Target) => { - if ( - site.type === "local" || - site.type === "wireguard" - ) { - return { - url: `${target.method}://${target.ip}:${target.port}` - }; - } else if (site.type === "newt") { - const ip = site.subnet!.split("/")[0]; - return { - url: `${target.method}://${ip}:${target.internalPort}` - }; - } - }), - ...(resource.stickySession - ? { - sticky: { - cookie: { - name: "p_sticky", // TODO: make this configurable via config.yml like other cookies - secure: resource.ssl, - httpOnly: true - } - } - } - : {}) - } - }; - - // Add the serversTransport if TLS server name is provided - if (resource.tlsServerName) { - if (!config_output.http.serversTransports) { - config_output.http.serversTransports = {}; - } - config_output.http.serversTransports![transportName] = { - serverName: resource.tlsServerName, - //unfortunately the following needs to be set. traefik doesn't merge the default serverTransport settings - // if defined in the static config and here. if not set, self-signed certs won't work - insecureSkipVerify: true - }; - config_output.http.services![ - serviceName - ].loadBalancer.serversTransport = transportName; - } - - // Add the host header middleware - if (resource.setHostHeader) { - if (!config_output.http.middlewares) { - config_output.http.middlewares = {}; - } - config_output.http.middlewares[hostHeaderMiddlewareName] = { - headers: { - customRequestHeaders: { - Host: resource.setHostHeader - } - } - }; - if (!config_output.http.routers![routerName].middlewares) { - config_output.http.routers![routerName].middlewares = - []; - } - config_output.http.routers![routerName].middlewares = [ - ...config_output.http.routers![routerName].middlewares, - hostHeaderMiddlewareName - ]; + // First query to get resources with site and org info + // Get the current exit node name from config + if (!currentExitNodeId) { + if (config.getRawConfig().gerbil.exit_node_name) { + const exitNodeName = + config.getRawConfig().gerbil.exit_node_name!; + const [exitNode] = await db + .select({ + exitNodeId: exitNodes.exitNodeId + }) + .from(exitNodes) + .where(eq(exitNodes.name, exitNodeName)); + if (exitNode) { + currentExitNodeId = exitNode.exitNodeId; } } else { - // Non-HTTP (TCP/UDP) configuration - if (!resource.enableProxy) { - continue; + const [exitNode] = await db + .select({ + exitNodeId: exitNodes.exitNodeId + }) + .from(exitNodes) + .limit(1); + + if (exitNode) { + currentExitNodeId = exitNode.exitNodeId; } - - const protocol = resource.protocol.toLowerCase(); - const port = resource.proxyPort; - - if (!port) { - continue; - } - - if (!config_output[protocol]) { - config_output[protocol] = { - routers: {}, - services: {} - }; - } - - config_output[protocol].routers[routerName] = { - entryPoints: [`${protocol}-${port}`], - service: serviceName, - ...(protocol === "tcp" ? { rule: "HostSNI(`*`)" } : {}) - }; - - config_output[protocol].services[serviceName] = { - loadBalancer: { - servers: targets - .filter((target: Target) => { - if (!target.enabled) { - return false; - } - if ( - site.type === "local" || - site.type === "wireguard" - ) { - if (!target.ip || !target.port) { - return false; - } - } else if (site.type === "newt") { - if (!target.internalPort || !site.subnet) { - return false; - } - } - return true; - }) - .map((target: Target) => { - if ( - site.type === "local" || - site.type === "wireguard" - ) { - return { - address: `${target.ip}:${target.port}` - }; - } else if (site.type === "newt") { - const ip = site.subnet!.split("/")[0]; - return { - address: `${ip}:${target.internalPort}` - }; - } - }), - ...(resource.stickySession - ? { - sticky: { - ipStrategy: { - depth: 0, - sourcePort: true - } - } - } - : {}) - } - }; } } - return res.status(HttpCode.OK).json(config_output); + + const traefikConfig = await getTraefikConfig(currentExitNodeId); + return res.status(HttpCode.OK).json(traefikConfig); } catch (e) { logger.error(`Failed to build Traefik config: ${e}`); return res.status(HttpCode.INTERNAL_SERVER_ERROR).json({ @@ -448,3 +51,401 @@ export async function traefikConfigProvider( }); } } + +export async function getTraefikConfig( + exitNodeId: number +): Promise { + // Get all resources with related data + const allResources = await db.transaction(async (tx) => { + + // Get the site(s) on this exit node + const resourcesWithRelations = await tx + .select({ + // Resource fields + resourceId: resources.resourceId, + fullDomain: resources.fullDomain, + ssl: resources.ssl, + http: resources.http, + proxyPort: resources.proxyPort, + protocol: resources.protocol, + subdomain: resources.subdomain, + domainId: resources.domainId, + // Site fields + site: { + siteId: sites.siteId, + type: sites.type, + subnet: sites.subnet, + exitNodeId: sites.exitNodeId + }, + enabled: resources.enabled, + stickySession: resources.stickySession, + tlsServerName: resources.tlsServerName, + setHostHeader: resources.setHostHeader, + enableProxy: resources.enableProxy + }) + .from(resources) + .innerJoin(sites, eq(sites.siteId, resources.siteId)) + .where( + or( + eq(sites.exitNodeId, exitNodeId), + isNull(sites.exitNodeId) + ) + ); + + // Get all resource IDs from the first query + const resourceIds = resourcesWithRelations.map((r) => r.resourceId); + + // Second query to get all enabled targets for these resources + const allTargets = + resourceIds.length > 0 + ? await tx + .select({ + resourceId: targets.resourceId, + targetId: targets.targetId, + ip: targets.ip, + method: targets.method, + port: targets.port, + internalPort: targets.internalPort, + enabled: targets.enabled + }) + .from(targets) + .where( + and( + inArray(targets.resourceId, resourceIds), + eq(targets.enabled, true) + ) + ) + : []; + + // Create a map for fast target lookup by resourceId + const targetsMap = allTargets.reduce((map, target) => { + if (!map.has(target.resourceId)) { + map.set(target.resourceId, []); + } + map.get(target.resourceId).push(target); + return map; + }, new Map()); + + // Combine the data + return resourcesWithRelations.map((resource) => ({ + ...resource, + targets: targetsMap.get(resource.resourceId) || [] + })); + }); + + if (!allResources.length) { + return {} + } + + const badgerMiddlewareName = "badger"; + const redirectHttpsMiddlewareName = "redirect-to-https"; + + const config_output: any = { + http: { + middlewares: { + [badgerMiddlewareName]: { + plugin: { + [badgerMiddlewareName]: { + apiBaseUrl: new URL( + "/api/v1", + `http://${ + config.getRawConfig().server + .internal_hostname + }:${config.getRawConfig().server.internal_port}` + ).href, + userSessionCookieName: + config.getRawConfig().server + .session_cookie_name, + + // deprecated + accessTokenQueryParam: + config.getRawConfig().server + .resource_access_token_param, + + resourceSessionRequestParam: + config.getRawConfig().server + .resource_session_request_param + } + } + }, + [redirectHttpsMiddlewareName]: { + redirectScheme: { + scheme: "https" + } + } + } + } + }; + + for (const resource of allResources) { + const targets = resource.targets as Target[]; + const site = resource.site; + + const routerName = `${resource.resourceId}-router`; + const serviceName = `${resource.resourceId}-service`; + const fullDomain = `${resource.fullDomain}`; + const transportName = `${resource.resourceId}-transport`; + const hostHeaderMiddlewareName = `${resource.resourceId}-host-header-middleware`; + + if (!resource.enabled) { + continue; + } + + if (resource.http) { + if (!resource.domainId) { + continue; + } + + if (!resource.fullDomain) { + logger.error( + `Resource ${resource.resourceId} has no fullDomain` + ); + continue; + } + + // add routers and services empty objects if they don't exist + if (!config_output.http.routers) { + config_output.http.routers = {}; + } + + if (!config_output.http.services) { + config_output.http.services = {}; + } + + const domainParts = fullDomain.split("."); + let wildCard; + if (domainParts.length <= 2) { + wildCard = `*.${domainParts.join(".")}`; + } else { + wildCard = `*.${domainParts.slice(1).join(".")}`; + } + + if (!resource.subdomain) { + wildCard = resource.fullDomain; + } + + const configDomain = config.getDomain(resource.domainId); + + let certResolver: string, preferWildcardCert: boolean; + if (!configDomain) { + certResolver = config.getRawConfig().traefik.cert_resolver; + preferWildcardCert = + config.getRawConfig().traefik.prefer_wildcard_cert; + } else { + certResolver = configDomain.cert_resolver; + preferWildcardCert = configDomain.prefer_wildcard_cert; + } + + const tls = { + certResolver: certResolver, + ...(preferWildcardCert + ? { + domains: [ + { + main: wildCard + } + ] + } + : {}) + }; + + const additionalMiddlewares = + config.getRawConfig().traefik.additional_middlewares || []; + + config_output.http.routers![routerName] = { + entryPoints: [ + resource.ssl + ? config.getRawConfig().traefik.https_entrypoint + : config.getRawConfig().traefik.http_entrypoint + ], + middlewares: [badgerMiddlewareName, ...additionalMiddlewares], + service: serviceName, + rule: `Host(\`${fullDomain}\`)`, + priority: 100, + ...(resource.ssl ? { tls } : {}) + }; + + if (resource.ssl) { + config_output.http.routers![routerName + "-redirect"] = { + entryPoints: [ + config.getRawConfig().traefik.http_entrypoint + ], + middlewares: [redirectHttpsMiddlewareName], + service: serviceName, + rule: `Host(\`${fullDomain}\`)`, + priority: 100 + }; + } + + config_output.http.services![serviceName] = { + loadBalancer: { + servers: targets + .filter((target: Target) => { + if (!target.enabled) { + return false; + } + if ( + site.type === "local" || + site.type === "wireguard" + ) { + if ( + !target.ip || + !target.port || + !target.method + ) { + return false; + } + } else if (site.type === "newt") { + if ( + !target.internalPort || + !target.method || + !site.subnet + ) { + return false; + } + } + return true; + }) + .map((target: Target) => { + if ( + site.type === "local" || + site.type === "wireguard" + ) { + return { + url: `${target.method}://${target.ip}:${target.port}` + }; + } else if (site.type === "newt") { + const ip = site.subnet!.split("/")[0]; + return { + url: `${target.method}://${ip}:${target.internalPort}` + }; + } + }), + ...(resource.stickySession + ? { + sticky: { + cookie: { + name: "p_sticky", // TODO: make this configurable via config.yml like other cookies + secure: resource.ssl, + httpOnly: true + } + } + } + : {}) + } + }; + + // Add the serversTransport if TLS server name is provided + if (resource.tlsServerName) { + if (!config_output.http.serversTransports) { + config_output.http.serversTransports = {}; + } + config_output.http.serversTransports![transportName] = { + serverName: resource.tlsServerName, + //unfortunately the following needs to be set. traefik doesn't merge the default serverTransport settings + // if defined in the static config and here. if not set, self-signed certs won't work + insecureSkipVerify: true + }; + config_output.http.services![ + serviceName + ].loadBalancer.serversTransport = transportName; + } + + // Add the host header middleware + if (resource.setHostHeader) { + if (!config_output.http.middlewares) { + config_output.http.middlewares = {}; + } + config_output.http.middlewares[hostHeaderMiddlewareName] = { + headers: { + customRequestHeaders: { + Host: resource.setHostHeader + } + } + }; + if (!config_output.http.routers![routerName].middlewares) { + config_output.http.routers![routerName].middlewares = []; + } + config_output.http.routers![routerName].middlewares = [ + ...config_output.http.routers![routerName].middlewares, + hostHeaderMiddlewareName + ]; + } + } else { + // Non-HTTP (TCP/UDP) configuration + if (!resource.enableProxy) { + continue; + } + + const protocol = resource.protocol.toLowerCase(); + const port = resource.proxyPort; + + if (!port) { + continue; + } + + if (!config_output[protocol]) { + config_output[protocol] = { + routers: {}, + services: {} + }; + } + + config_output[protocol].routers[routerName] = { + entryPoints: [`${protocol}-${port}`], + service: serviceName, + ...(protocol === "tcp" ? { rule: "HostSNI(`*`)" } : {}) + }; + + config_output[protocol].services[serviceName] = { + loadBalancer: { + servers: targets + .filter((target: Target) => { + if (!target.enabled) { + return false; + } + if ( + site.type === "local" || + site.type === "wireguard" + ) { + if (!target.ip || !target.port) { + return false; + } + } else if (site.type === "newt") { + if (!target.internalPort || !site.subnet) { + return false; + } + } + return true; + }) + .map((target: Target) => { + if ( + site.type === "local" || + site.type === "wireguard" + ) { + return { + address: `${target.ip}:${target.port}` + }; + } else if (site.type === "newt") { + const ip = site.subnet!.split("/")[0]; + return { + address: `${ip}:${target.internalPort}` + }; + } + }), + ...(resource.stickySession + ? { + sticky: { + ipStrategy: { + depth: 0, + sourcePort: true + } + } + } + : {}) + } + }; + } + } + return config_output; +} From 2c8bf4f18c04c67f9193c6eca603a70e37597e47 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 13 Aug 2025 16:23:24 -0700 Subject: [PATCH 12/63] Handle oss tls --- server/routers/traefik/getTraefikConfig.ts | 40 ++++++++++------------ 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/server/routers/traefik/getTraefikConfig.ts b/server/routers/traefik/getTraefikConfig.ts index da013878..89afee2c 100644 --- a/server/routers/traefik/getTraefikConfig.ts +++ b/server/routers/traefik/getTraefikConfig.ts @@ -5,6 +5,7 @@ import logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; import config from "@server/lib/config"; import { orgs, resources, sites, Target, targets } from "@server/db"; +import { build } from "@server/build"; let currentExitNodeId: number; @@ -52,12 +53,9 @@ export async function traefikConfigProvider( } } -export async function getTraefikConfig( - exitNodeId: number -): Promise { +export async function getTraefikConfig(exitNodeId: number): Promise { // Get all resources with related data const allResources = await db.transaction(async (tx) => { - // Get the site(s) on this exit node const resourcesWithRelations = await tx .select({ @@ -86,10 +84,7 @@ export async function getTraefikConfig( .from(resources) .innerJoin(sites, eq(sites.siteId, resources.siteId)) .where( - or( - eq(sites.exitNodeId, exitNodeId), - isNull(sites.exitNodeId) - ) + or(eq(sites.exitNodeId, exitNodeId), isNull(sites.exitNodeId)) ); // Get all resource IDs from the first query @@ -134,7 +129,7 @@ export async function getTraefikConfig( }); if (!allResources.length) { - return {} + return {}; } const badgerMiddlewareName = "badger"; @@ -236,18 +231,21 @@ export async function getTraefikConfig( preferWildcardCert = configDomain.prefer_wildcard_cert; } - const tls = { - certResolver: certResolver, - ...(preferWildcardCert - ? { - domains: [ - { - main: wildCard - } - ] - } - : {}) - }; + let tls = {}; + if (build == "oss") { + tls = { + certResolver: certResolver, + ...(preferWildcardCert + ? { + domains: [ + { + main: wildCard + } + ] + } + : {}) + }; + } const additionalMiddlewares = config.getRawConfig().traefik.additional_middlewares || []; From dc50190dc3a091d984a309d27a089909469c1194 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 13 Aug 2025 17:30:59 -0700 Subject: [PATCH 13/63] Handle token --- ...{hybridClientServer.ts => hybridServer.ts} | 12 +- server/index.ts | 2 +- server/lib/index.ts | 1 + server/lib/remoteProxy.ts | 2 + server/lib/remoteTraefikConfig.ts | 4 +- server/lib/tokenManager.ts | 209 ++++++++++++++++++ server/routers/ws/client.ts | 85 +------ 7 files changed, 232 insertions(+), 83 deletions(-) rename server/{hybridClientServer.ts => hybridServer.ts} (91%) create mode 100644 server/lib/tokenManager.ts diff --git a/server/hybridClientServer.ts b/server/hybridServer.ts similarity index 91% rename from server/hybridClientServer.ts rename to server/hybridServer.ts index 074fcd2e..7ce7efd7 100644 --- a/server/hybridClientServer.ts +++ b/server/hybridServer.ts @@ -3,10 +3,11 @@ import express from "express"; import { parse } from "url"; import logger from "@server/logger"; import config from "@server/lib/config"; -import { WebSocketClient, createWebSocketClient } from "./routers/ws/client"; +import { createWebSocketClient } from "./routers/ws/client"; import { addPeer, deletePeer } from "./routers/gerbil/peers"; import { db, exitNodes } from "./db"; import { TraefikConfigManager } from "./lib/remoteTraefikConfig"; +import { tokenManager } from "./lib/tokenManager"; export async function createHybridClientServer() { const monitor = new TraefikConfigManager(); @@ -21,11 +22,14 @@ export async function createHybridClientServer() { throw new Error("Hybrid configuration is not defined"); } + // Start the token manager + await tokenManager.start(); + + const token = await tokenManager.getToken(); + // Create client const client = createWebSocketClient( - "remoteExitNode", // or 'olm' - config.getRawConfig().hybrid!.id!, - config.getRawConfig().hybrid!.secret!, + token, config.getRawConfig().hybrid!.endpoint!, { reconnectInterval: 5000, diff --git a/server/index.ts b/server/index.ts index 42f85da6..7fd328c2 100644 --- a/server/index.ts +++ b/server/index.ts @@ -7,7 +7,7 @@ import { createNextServer } from "./nextServer"; import { createInternalServer } from "./internalServer"; import { ApiKey, ApiKeyOrg, Session, User, UserOrg } from "@server/db"; import { createIntegrationApiServer } from "./integrationApiServer"; -import { createHybridClientServer } from "./hybridClientServer"; +import { createHybridClientServer } from "./privateHybridServer.js"; import config from "@server/lib/config"; async function startServers() { diff --git a/server/lib/index.ts b/server/lib/index.ts index 9d2cfb1f..7705e0af 100644 --- a/server/lib/index.ts +++ b/server/lib/index.ts @@ -1 +1,2 @@ export * from "./response"; +export { tokenManager, TokenManager } from "./tokenManager"; diff --git a/server/lib/remoteProxy.ts b/server/lib/remoteProxy.ts index 080c3bd3..e53f53f6 100644 --- a/server/lib/remoteProxy.ts +++ b/server/lib/remoteProxy.ts @@ -5,6 +5,7 @@ import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import config from "@server/lib/config"; +import { tokenManager } from "./tokenManager"; /** * Proxy function that forwards requests to the remote cloud server @@ -28,6 +29,7 @@ export const proxyToRemote = async ( data: req.body, headers: { 'Content-Type': 'application/json', + ...(await tokenManager.getAuthHeader()).headers }, params: req.query, timeout: 30000, // 30 second timeout diff --git a/server/lib/remoteTraefikConfig.ts b/server/lib/remoteTraefikConfig.ts index 08b2ab98..2e8ff529 100644 --- a/server/lib/remoteTraefikConfig.ts +++ b/server/lib/remoteTraefikConfig.ts @@ -5,6 +5,7 @@ import logger from "@server/logger"; import * as yaml from "js-yaml"; import axios from "axios"; import { db, exitNodes } from "@server/db"; +import { tokenManager } from "./tokenManager"; export class TraefikConfigManager { private intervalId: NodeJS.Timeout | null = null; @@ -162,7 +163,8 @@ export class TraefikConfigManager { } | null> { try { const resp = await axios.get( - `${config.getRawConfig().hybrid?.endpoint}/get-traefik-config` + `${config.getRawConfig().hybrid?.endpoint}/traefik-config`, + await tokenManager.getAuthHeader() ); if (resp.status !== 200) { diff --git a/server/lib/tokenManager.ts b/server/lib/tokenManager.ts new file mode 100644 index 00000000..040dc609 --- /dev/null +++ b/server/lib/tokenManager.ts @@ -0,0 +1,209 @@ +import axios from "axios"; +import config from "@server/lib/config"; +import logger from "@server/logger"; + +export interface TokenResponse { + success: boolean; + message?: string; + data: { + token: string; + }; +} + +/** + * Token Manager - Handles automatic token refresh for hybrid server authentication + * + * Usage throughout the application: + * ```typescript + * import { tokenManager } from "@server/lib/tokenManager"; + * + * // Get the current valid token + * const token = await tokenManager.getToken(); + * + * // Force refresh if needed + * await tokenManager.refreshToken(); + * ``` + * + * The token manager automatically refreshes tokens every 24 hours by default + * and is started once in the privateHybridServer.ts file. + */ + +export class TokenManager { + private token: string | null = null; + private refreshInterval: NodeJS.Timeout | null = null; + private isRefreshing: boolean = false; + private refreshIntervalMs: number; + + constructor(refreshIntervalMs: number = 24 * 60 * 60 * 1000) { + // Default to 24 hours + this.refreshIntervalMs = refreshIntervalMs; + } + + /** + * Start the token manager - gets initial token and sets up refresh interval + */ + async start(): Promise { + try { + await this.refreshToken(); + this.setupRefreshInterval(); + logger.info("Token manager started successfully"); + } catch (error) { + logger.error("Failed to start token manager:", error); + throw error; + } + } + + /** + * Stop the token manager and clear refresh interval + */ + stop(): void { + if (this.refreshInterval) { + clearInterval(this.refreshInterval); + this.refreshInterval = null; + } + logger.info("Token manager stopped"); + } + + /** + * Get the current valid token + */ + async getToken(): Promise { + if (!this.token) { + if (this.isRefreshing) { + // Wait for current refresh to complete + await this.waitForRefresh(); + } else { + await this.refreshToken(); + } + } + + if (!this.token) { + throw new Error("No valid token available"); + } + + return this.token; + } + + async getAuthHeader() { + return { + headers: { + Authorization: `Bearer ${await this.getToken()}` + } + }; + } + + /** + * Force refresh the token + */ + async refreshToken(): Promise { + if (this.isRefreshing) { + await this.waitForRefresh(); + return; + } + + this.isRefreshing = true; + + try { + const hybridConfig = config.getRawConfig().hybrid; + + if ( + !hybridConfig?.id || + !hybridConfig?.secret || + !hybridConfig?.endpoint + ) { + throw new Error("Hybrid configuration is not defined"); + } + + const tokenEndpoint = `${hybridConfig.endpoint}/api/v1/auth/remoteExitNode/get-token`; + + const tokenData = { + remoteExitNodeId: hybridConfig.id, + secret: hybridConfig.secret + }; + + logger.debug("Requesting new token from server"); + + const response = await axios.post( + tokenEndpoint, + tokenData, + { + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": "x-csrf-protection" + }, + timeout: 10000 // 10 second timeout + } + ); + + if (!response.data.success) { + throw new Error( + `Failed to get token: ${response.data.message}` + ); + } + + if (!response.data.data.token) { + throw new Error("Received empty token from server"); + } + + this.token = response.data.data.token; + logger.debug("Token refreshed successfully"); + } catch (error) { + logger.error("Failed to refresh token:", error); + + if (axios.isAxiosError(error)) { + if (error.response) { + throw new Error( + `Failed to get token with status code: ${error.response.status}` + ); + } else if (error.request) { + throw new Error( + "Failed to request new token: No response received" + ); + } else { + throw new Error( + `Failed to request new token: ${error.message}` + ); + } + } else { + throw new Error(`Failed to get token: ${error}`); + } + } finally { + this.isRefreshing = false; + } + } + + /** + * Set up automatic token refresh interval + */ + private setupRefreshInterval(): void { + if (this.refreshInterval) { + clearInterval(this.refreshInterval); + } + + this.refreshInterval = setInterval(async () => { + try { + logger.debug("Auto-refreshing token"); + await this.refreshToken(); + } catch (error) { + logger.error("Failed to auto-refresh token:", error); + } + }, this.refreshIntervalMs); + } + + /** + * Wait for current refresh operation to complete + */ + private async waitForRefresh(): Promise { + return new Promise((resolve) => { + const checkInterval = setInterval(() => { + if (!this.isRefreshing) { + clearInterval(checkInterval); + resolve(); + } + }, 100); + }); + } +} + +// Export a singleton instance for use throughout the application +export const tokenManager = new TokenManager(); diff --git a/server/routers/ws/client.ts b/server/routers/ws/client.ts index 2cd5cfd7..3f1fbf54 100644 --- a/server/routers/ws/client.ts +++ b/server/routers/ws/client.ts @@ -14,14 +14,6 @@ export interface WSMessage { data: any; } -export interface TokenResponse { - success: boolean; - message?: string; - data: { - token: string; - }; -} - export type MessageHandler = (message: WSMessage) => void; export interface ClientOptions { @@ -33,45 +25,32 @@ export interface ClientOptions { export class WebSocketClient extends EventEmitter { private conn: WebSocket | null = null; - private config: Config; private baseURL: string; private handlers: Map = new Map(); private reconnectInterval: number; private isConnected: boolean = false; private pingInterval: number; private pingTimeout: number; - private clientType: string; private shouldReconnect: boolean = true; private reconnectTimer: NodeJS.Timeout | null = null; private pingTimer: NodeJS.Timeout | null = null; private pingTimeoutTimer: NodeJS.Timeout | null = null; + private token: string; constructor( - clientType: string, - id: string, - secret: string, + token: string, endpoint: string, options: ClientOptions = {} ) { super(); - this.clientType = clientType; - this.config = { - id, - secret, - endpoint - }; - + this.token = token; this.baseURL = options.baseURL || endpoint; this.reconnectInterval = options.reconnectInterval || 3000; this.pingInterval = options.pingInterval || 30000; this.pingTimeout = options.pingTimeout || 10000; } - public getConfig(): Config { - return this.config; - } - public async connect(): Promise { this.shouldReconnect = true; await this.connectWithRetry(); @@ -161,48 +140,6 @@ export class WebSocketClient extends EventEmitter { return this.isConnected; } - private async getToken(): Promise { - const baseURL = new URL(this.baseURL); - const tokenEndpoint = `${baseURL.origin}/api/v1/auth/${this.clientType}/get-token`; - - const tokenData = this.clientType === 'newt' - ? { newtId: this.config.id, secret: this.config.secret } - : { olmId: this.config.id, secret: this.config.secret }; - - try { - const response = await axios.post(tokenEndpoint, tokenData, { - headers: { - 'Content-Type': 'application/json', - 'X-CSRF-Token': 'x-csrf-protection' - }, - timeout: 10000 // 10 second timeout - }); - - if (!response.data.success) { - throw new Error(`Failed to get token: ${response.data.message}`); - } - - if (!response.data.data.token) { - throw new Error('Received empty token from server'); - } - - console.debug(`Received token: ${response.data.data.token}`); - return response.data.data.token; - } catch (error) { - if (axios.isAxiosError(error)) { - if (error.response) { - throw new Error(`Failed to get token with status code: ${error.response.status}`); - } else if (error.request) { - throw new Error('Failed to request new token: No response received'); - } else { - throw new Error(`Failed to request new token: ${error.message}`); - } - } else { - throw new Error(`Failed to get token: ${error}`); - } - } - } - private async connectWithRetry(): Promise { while (this.shouldReconnect) { try { @@ -221,18 +158,14 @@ export class WebSocketClient extends EventEmitter { } private async establishConnection(): Promise { - // Get token for authentication - const token = await this.getToken(); - this.emit('tokenUpdate', token); - // Parse the base URL to determine protocol and hostname const baseURL = new URL(this.baseURL); const wsProtocol = baseURL.protocol === 'https:' ? 'wss' : 'ws'; const wsURL = new URL(`${wsProtocol}://${baseURL.host}/api/v1/ws`); // Add token and client type to query parameters - wsURL.searchParams.set('token', token); - wsURL.searchParams.set('clientType', this.clientType); + wsURL.searchParams.set('token', this.token); + wsURL.searchParams.set('clientType', "remoteExitNode"); return new Promise((resolve, reject) => { const conn = new WebSocket(wsURL.toString()); @@ -330,13 +263,11 @@ export class WebSocketClient extends EventEmitter { // Factory function for easier instantiation export function createWebSocketClient( - clientType: string, - id: string, - secret: string, - endpoint: string, + token: string, + endpoint: string, options?: ClientOptions ): WebSocketClient { - return new WebSocketClient(clientType, id, secret, endpoint, options); + return new WebSocketClient(token, endpoint, options); } export default WebSocketClient; \ No newline at end of file From 285e24cdc7f0c3f300e52036d335783d9be0e9de Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 13 Aug 2025 20:26:50 -0700 Subject: [PATCH 14/63] Use an epoch number for the clients online to fix query --- server/db/pg/schema.ts | 2 +- server/db/sqlite/schema.ts | 2 +- server/routers/olm/handleOlmPingMessage.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/server/db/pg/schema.ts b/server/db/pg/schema.ts index d307f399..33d3fef0 100644 --- a/server/db/pg/schema.ts +++ b/server/db/pg/schema.ts @@ -513,7 +513,7 @@ export const clients = pgTable("clients", { megabytesIn: real("bytesIn"), megabytesOut: real("bytesOut"), lastBandwidthUpdate: varchar("lastBandwidthUpdate"), - lastPing: varchar("lastPing"), + lastPing: integer("lastPing"), type: varchar("type").notNull(), // "olm" online: boolean("online").notNull().default(false), // endpoint: varchar("endpoint"), diff --git a/server/db/sqlite/schema.ts b/server/db/sqlite/schema.ts index 10f6686e..77136c68 100644 --- a/server/db/sqlite/schema.ts +++ b/server/db/sqlite/schema.ts @@ -213,7 +213,7 @@ export const clients = sqliteTable("clients", { megabytesIn: integer("bytesIn"), megabytesOut: integer("bytesOut"), lastBandwidthUpdate: text("lastBandwidthUpdate"), - lastPing: text("lastPing"), + lastPing: integer("lastPing"), type: text("type").notNull(), // "olm" online: integer("online", { mode: "boolean" }).notNull().default(false), // endpoint: text("endpoint"), diff --git a/server/routers/olm/handleOlmPingMessage.ts b/server/routers/olm/handleOlmPingMessage.ts index c95f36af..04659bb3 100644 --- a/server/routers/olm/handleOlmPingMessage.ts +++ b/server/routers/olm/handleOlmPingMessage.ts @@ -28,7 +28,7 @@ export const startOfflineChecker = (): void => { .set({ online: false }) .where( eq(clients.online, true) && - (lt(clients.lastPing, twoMinutesAgo.toISOString()) || isNull(clients.lastPing)) + (lt(clients.lastPing, twoMinutesAgo.getTime() / 1000) || isNull(clients.lastPing)) ); } catch (error) { @@ -72,7 +72,7 @@ export const handleOlmPingMessage: MessageHandler = async (context) => { await db .update(clients) .set({ - lastPing: new Date().toISOString(), + lastPing: new Date().getTime() / 1000, online: true, }) .where(eq(clients.clientId, olm.clientId)); From b638adedff78efd23af2defe3defbc543ed7d5ba Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 13 Aug 2025 20:27:48 -0700 Subject: [PATCH 15/63] Seperate get gerbil config --- server/hybridServer.ts | 4 ++ server/routers/gerbil/getConfig.ts | 84 +++++++++++++++++------------- server/routers/internal.ts | 7 ++- 3 files changed, 55 insertions(+), 40 deletions(-) diff --git a/server/hybridServer.ts b/server/hybridServer.ts index 7ce7efd7..f4e18b34 100644 --- a/server/hybridServer.ts +++ b/server/hybridServer.ts @@ -8,6 +8,7 @@ import { addPeer, deletePeer } from "./routers/gerbil/peers"; import { db, exitNodes } from "./db"; import { TraefikConfigManager } from "./lib/remoteTraefikConfig"; import { tokenManager } from "./lib/tokenManager"; +import { APP_VERSION } from "./lib/consts"; export async function createHybridClientServer() { const monitor = new TraefikConfigManager(); @@ -67,6 +68,9 @@ export async function createHybridClientServer() { // Listen to connection events client.on("connect", () => { console.log("Connected to WebSocket server"); + client.sendMessage("remoteExitNode/register", { + remoteExitNodeVersion: APP_VERSION + }); }); client.on("disconnect", () => { diff --git a/server/routers/gerbil/getConfig.ts b/server/routers/gerbil/getConfig.ts index 696e7ea2..0a1c0f23 100644 --- a/server/routers/gerbil/getConfig.ts +++ b/server/routers/gerbil/getConfig.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { sites, resources, targets, exitNodes } from "@server/db"; +import { sites, resources, targets, exitNodes, ExitNode } from "@server/db"; import { db } from "@server/db"; import { eq, isNotNull, and } from "drizzle-orm"; import HttpCode from "@server/types/HttpCode"; @@ -11,6 +11,7 @@ import { getUniqueExitNodeEndpointName } from "../../db/names"; import { findNextAvailableCidr } from "@server/lib/ip"; import { fromError } from "zod-validation-error"; import { getAllowedIps } from "../target/helpers"; +import { proxyToRemote } from "@server/lib/remoteProxy"; // Define Zod schema for request validation const getConfigSchema = z.object({ publicKey: z.string(), @@ -101,42 +102,12 @@ export async function getConfig( ); } - const sitesRes = await db - .select() - .from(sites) - .where( - and( - eq(sites.exitNodeId, exitNode[0].exitNodeId), - isNotNull(sites.pubKey), - isNotNull(sites.subnet) - ) - ); + // STOP HERE IN HYBRID MODE + if (config.isHybridMode()) { + return proxyToRemote(req, res, next, "gerbil/get-config"); + } - const peers = await Promise.all( - sitesRes.map(async (site) => { - if (site.type === "wireguard") { - return { - publicKey: site.pubKey, - allowedIps: await getAllowedIps(site.siteId) - }; - } else if (site.type === "newt") { - return { - publicKey: site.pubKey, - allowedIps: [site.subnet!] - }; - } - return { - publicKey: null, - allowedIps: [] - }; - }) - ); - - const configResponse: GetConfigResponse = { - listenPort: exitNode[0].listenPort || 51820, - ipAddress: exitNode[0].address, - peers - }; + const configResponse = await generateGerbilConfig(exitNode[0]); logger.debug("Sending config: ", configResponse); @@ -152,6 +123,47 @@ export async function getConfig( } } +async function generateGerbilConfig(exitNode: ExitNode) { + const sitesRes = await db + .select() + .from(sites) + .where( + and( + eq(sites.exitNodeId, exitNode.exitNodeId), + isNotNull(sites.pubKey), + isNotNull(sites.subnet) + ) + ); + + const peers = await Promise.all( + sitesRes.map(async (site) => { + if (site.type === "wireguard") { + return { + publicKey: site.pubKey, + allowedIps: await getAllowedIps(site.siteId) + }; + } else if (site.type === "newt") { + return { + publicKey: site.pubKey, + allowedIps: [site.subnet!] + }; + } + return { + publicKey: null, + allowedIps: [] + }; + }) + ); + + const configResponse: GetConfigResponse = { + listenPort: exitNode.listenPort || 51820, + ipAddress: exitNode.address, + peers + }; + + return configResponse; +} + async function getNextAvailableSubnet(): Promise { // Get all existing subnets from routes table const existingAddresses = await db diff --git a/server/routers/internal.ts b/server/routers/internal.ts index a84f6976..e91446dc 100644 --- a/server/routers/internal.ts +++ b/server/routers/internal.ts @@ -54,10 +54,6 @@ internalRouter.use("/gerbil", gerbilRouter); if (config.isHybridMode()) { // Use proxy router to forward requests to remote cloud server // Proxy endpoints for each gerbil route - gerbilRouter.post("/get-config", (req, res, next) => - proxyToRemote(req, res, next, "gerbil/get-config") - ); - gerbilRouter.post("/receive-bandwidth", (req, res, next) => proxyToRemote(req, res, next, "gerbil/receive-bandwidth") ); @@ -69,6 +65,9 @@ if (config.isHybridMode()) { gerbilRouter.post("/get-all-relays", (req, res, next) => proxyToRemote(req, res, next, "gerbil/get-all-relays") ); + + // GET CONFIG IS HANDLED IN THE ORIGINAL HANDLER + // SO IT CAN REGISTER THE LOCAL EXIT NODE } else { // Use local gerbil endpoints gerbilRouter.post("/get-config", gerbil.getConfig); From 34d705a54ef4d3f7e1b4d4ed74a6775473c89a6d Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 13 Aug 2025 20:31:48 -0700 Subject: [PATCH 16/63] Rename olm offline --- server/routers/olm/handleOlmPingMessage.ts | 4 ++-- server/routers/ws/messageHandlers.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/server/routers/olm/handleOlmPingMessage.ts b/server/routers/olm/handleOlmPingMessage.ts index 04659bb3..2425c4d8 100644 --- a/server/routers/olm/handleOlmPingMessage.ts +++ b/server/routers/olm/handleOlmPingMessage.ts @@ -13,7 +13,7 @@ const OFFLINE_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes * Starts the background interval that checks for clients that haven't pinged recently * and marks them as offline */ -export const startOfflineChecker = (): void => { +export const startOlmOfflineChecker = (): void => { if (offlineCheckerInterval) { return; // Already running } @@ -42,7 +42,7 @@ export const startOfflineChecker = (): void => { /** * Stops the background interval that checks for offline clients */ -export const stopOfflineChecker = (): void => { +export const stopOlmOfflineChecker = (): void => { if (offlineCheckerInterval) { clearInterval(offlineCheckerInterval); offlineCheckerInterval = null; diff --git a/server/routers/ws/messageHandlers.ts b/server/routers/ws/messageHandlers.ts index d85cc277..01889a8c 100644 --- a/server/routers/ws/messageHandlers.ts +++ b/server/routers/ws/messageHandlers.ts @@ -10,7 +10,7 @@ import { handleOlmRegisterMessage, handleOlmRelayMessage, handleOlmPingMessage, - startOfflineChecker + startOlmOfflineChecker } from "../olm"; import { MessageHandler } from "./ws"; @@ -26,4 +26,4 @@ export const messageHandlers: Record = { "newt/ping/request": handleNewtPingRequestMessage, }; -startOfflineChecker(); // this is to handle the offline check for olms +startOlmOfflineChecker(); // this is to handle the offline check for olms From b573d636480b94d989c33106958995d71c825da1 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 13 Aug 2025 20:41:29 -0700 Subject: [PATCH 17/63] Add cols to exit node --- server/db/pg/schema.ts | 10 ++++++++-- server/db/sqlite/schema.ts | 8 +++++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/server/db/pg/schema.ts b/server/db/pg/schema.ts index 33d3fef0..8be65957 100644 --- a/server/db/pg/schema.ts +++ b/server/db/pg/schema.ts @@ -96,7 +96,7 @@ export const resources = pgTable("resources", { stickySession: boolean("stickySession").notNull().default(false), tlsServerName: varchar("tlsServerName"), setHostHeader: varchar("setHostHeader"), - enableProxy: boolean("enableProxy").default(true), + enableProxy: boolean("enableProxy").default(true) }); export const targets = pgTable("targets", { @@ -121,7 +121,13 @@ export const exitNodes = pgTable("exitNodes", { publicKey: varchar("publicKey").notNull(), listenPort: integer("listenPort").notNull(), reachableAt: varchar("reachableAt"), - maxConnections: integer("maxConnections") + maxConnections: integer("maxConnections"), + orgId: text("orgId").references(() => orgs.orgId, { + onDelete: "cascade" + }), + online: boolean("online").notNull().default(false), + lastPing: integer("lastPing"), + type: text("type").default("gerbil") // gerbil, remoteExitNode }); export const users = pgTable("user", { diff --git a/server/db/sqlite/schema.ts b/server/db/sqlite/schema.ts index 77136c68..33442075 100644 --- a/server/db/sqlite/schema.ts +++ b/server/db/sqlite/schema.ts @@ -133,7 +133,13 @@ export const exitNodes = sqliteTable("exitNodes", { publicKey: text("publicKey").notNull(), listenPort: integer("listenPort").notNull(), reachableAt: text("reachableAt"), // this is the internal address of the gerbil http server for command control - maxConnections: integer("maxConnections") + maxConnections: integer("maxConnections"), + orgId: text("orgId").references(() => orgs.orgId, { + onDelete: "cascade" + }), + online: integer("online", { mode: "boolean" }).notNull().default(false), + lastPing: integer("lastPing"), + type: text("type").default("gerbil") // gerbil, remoteExitNode }); export const users = sqliteTable("user", { From 23079d9ac02e5edf02b1a818acf0b28b0ab2afb1 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 13 Aug 2025 20:48:54 -0700 Subject: [PATCH 18/63] Fix exit node ping message --- server/hybridServer.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/server/hybridServer.ts b/server/hybridServer.ts index f4e18b34..64467d1b 100644 --- a/server/hybridServer.ts +++ b/server/hybridServer.ts @@ -89,5 +89,9 @@ export async function createHybridClientServer() { console.error("Failed to connect:", error); } - client.sendMessageInterval("heartbeat", { timestamp: Date.now() }, 10000); + client.sendMessageInterval( + "remoteExitNode/ping", + { timestamp: Date.now() / 1000 }, + 60000 + ); // send every minute } From ac87345b7a98f1320146fd5692e85f5a19955633 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 13 Aug 2025 21:35:06 -0700 Subject: [PATCH 19/63] Seperate get relays --- server/routers/gerbil/getAllRelays.ts | 218 +++++++++++++++----------- 1 file changed, 125 insertions(+), 93 deletions(-) diff --git a/server/routers/gerbil/getAllRelays.ts b/server/routers/gerbil/getAllRelays.ts index a64fd78f..6eaf87e2 100644 --- a/server/routers/gerbil/getAllRelays.ts +++ b/server/routers/gerbil/getAllRelays.ts @@ -1,6 +1,15 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { clients, exitNodes, newts, olms, Site, sites, clientSites } from "@server/db"; +import { + clients, + exitNodes, + newts, + olms, + Site, + sites, + clientSites, + ExitNode +} from "@server/db"; import { db } from "@server/db"; import { eq } from "drizzle-orm"; import HttpCode from "@server/types/HttpCode"; @@ -10,7 +19,7 @@ import { fromError } from "zod-validation-error"; // Define Zod schema for request validation const getAllRelaysSchema = z.object({ - publicKey: z.string().optional(), + publicKey: z.string().optional() }); // Type for peer destination @@ -44,103 +53,27 @@ export async function getAllRelays( const { publicKey } = parsedParams.data; if (!publicKey) { - return next(createHttpError(HttpCode.BAD_REQUEST, 'publicKey is required')); + return next( + createHttpError(HttpCode.BAD_REQUEST, "publicKey is required") + ); } // Fetch exit node - const [exitNode] = await db.select().from(exitNodes).where(eq(exitNodes.publicKey, publicKey)); + const [exitNode] = await db + .select() + .from(exitNodes) + .where(eq(exitNodes.publicKey, publicKey)); if (!exitNode) { - return next(createHttpError(HttpCode.NOT_FOUND, "Exit node not found")); + return next( + createHttpError(HttpCode.NOT_FOUND, "Exit node not found") + ); } - // Fetch sites for this exit node - const sitesRes = await db.select().from(sites).where(eq(sites.exitNodeId, exitNode.exitNodeId)); + const mappings = await generateRelayMappings(exitNode); - if (sitesRes.length === 0) { - return res.status(HttpCode.OK).send({ - mappings: {} - }); - } - - // Initialize mappings object for multi-peer support - const mappings: { [key: string]: ProxyMapping } = {}; - - // Process each site - for (const site of sitesRes) { - if (!site.endpoint || !site.subnet || !site.listenPort) { - continue; - } - - // Find all clients associated with this site through clientSites - const clientSitesRes = await db - .select() - .from(clientSites) - .where(eq(clientSites.siteId, site.siteId)); - - for (const clientSite of clientSitesRes) { - if (!clientSite.endpoint) { - continue; - } - - // Add this site as a destination for the client - if (!mappings[clientSite.endpoint]) { - mappings[clientSite.endpoint] = { destinations: [] }; - } - - // Add site as a destination for this client - const destination: PeerDestination = { - destinationIP: site.subnet.split("/")[0], - destinationPort: site.listenPort - }; - - // Check if this destination is already in the array to avoid duplicates - const isDuplicate = mappings[clientSite.endpoint].destinations.some( - dest => dest.destinationIP === destination.destinationIP && - dest.destinationPort === destination.destinationPort - ); - - if (!isDuplicate) { - mappings[clientSite.endpoint].destinations.push(destination); - } - } - - // Also handle site-to-site communication (all sites in the same org) - if (site.orgId) { - const orgSites = await db - .select() - .from(sites) - .where(eq(sites.orgId, site.orgId)); - - for (const peer of orgSites) { - // Skip self - if (peer.siteId === site.siteId || !peer.endpoint || !peer.subnet || !peer.listenPort) { - continue; - } - - // Add peer site as a destination for this site - if (!mappings[site.endpoint]) { - mappings[site.endpoint] = { destinations: [] }; - } - - const destination: PeerDestination = { - destinationIP: peer.subnet.split("/")[0], - destinationPort: peer.listenPort - }; - - // Check for duplicates - const isDuplicate = mappings[site.endpoint].destinations.some( - dest => dest.destinationIP === destination.destinationIP && - dest.destinationPort === destination.destinationPort - ); - - if (!isDuplicate) { - mappings[site.endpoint].destinations.push(destination); - } - } - } - } - - logger.debug(`Returning mappings for ${Object.keys(mappings).length} endpoints`); + logger.debug( + `Returning mappings for ${Object.keys(mappings).length} endpoints` + ); return res.status(HttpCode.OK).send({ mappings }); } catch (error) { logger.error(error); @@ -151,4 +84,103 @@ export async function getAllRelays( ) ); } -} \ No newline at end of file +} + +export async function generateRelayMappings(exitNode: ExitNode) { + // Fetch sites for this exit node + const sitesRes = await db + .select() + .from(sites) + .where(eq(sites.exitNodeId, exitNode.exitNodeId)); + + if (sitesRes.length === 0) { + return {}; + } + + // Initialize mappings object for multi-peer support + const mappings: { [key: string]: ProxyMapping } = {}; + + // Process each site + for (const site of sitesRes) { + if (!site.endpoint || !site.subnet || !site.listenPort) { + continue; + } + + // Find all clients associated with this site through clientSites + const clientSitesRes = await db + .select() + .from(clientSites) + .where(eq(clientSites.siteId, site.siteId)); + + for (const clientSite of clientSitesRes) { + if (!clientSite.endpoint) { + continue; + } + + // Add this site as a destination for the client + if (!mappings[clientSite.endpoint]) { + mappings[clientSite.endpoint] = { destinations: [] }; + } + + // Add site as a destination for this client + const destination: PeerDestination = { + destinationIP: site.subnet.split("/")[0], + destinationPort: site.listenPort + }; + + // Check if this destination is already in the array to avoid duplicates + const isDuplicate = mappings[clientSite.endpoint].destinations.some( + (dest) => + dest.destinationIP === destination.destinationIP && + dest.destinationPort === destination.destinationPort + ); + + if (!isDuplicate) { + mappings[clientSite.endpoint].destinations.push(destination); + } + } + + // Also handle site-to-site communication (all sites in the same org) + if (site.orgId) { + const orgSites = await db + .select() + .from(sites) + .where(eq(sites.orgId, site.orgId)); + + for (const peer of orgSites) { + // Skip self + if ( + peer.siteId === site.siteId || + !peer.endpoint || + !peer.subnet || + !peer.listenPort + ) { + continue; + } + + // Add peer site as a destination for this site + if (!mappings[site.endpoint]) { + mappings[site.endpoint] = { destinations: [] }; + } + + const destination: PeerDestination = { + destinationIP: peer.subnet.split("/")[0], + destinationPort: peer.listenPort + }; + + // Check for duplicates + const isDuplicate = mappings[site.endpoint].destinations.some( + (dest) => + dest.destinationIP === destination.destinationIP && + dest.destinationPort === destination.destinationPort + ); + + if (!isDuplicate) { + mappings[site.endpoint].destinations.push(destination); + } + } + } + } + + return mappings; +} From aaddde0a9bd5df89aa3645162a600b613f5b5f88 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 13 Aug 2025 21:41:33 -0700 Subject: [PATCH 20/63] Add export --- server/routers/gerbil/getConfig.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/routers/gerbil/getConfig.ts b/server/routers/gerbil/getConfig.ts index 0a1c0f23..4a6bcf05 100644 --- a/server/routers/gerbil/getConfig.ts +++ b/server/routers/gerbil/getConfig.ts @@ -123,7 +123,7 @@ export async function getConfig( } } -async function generateGerbilConfig(exitNode: ExitNode) { +export async function generateGerbilConfig(exitNode: ExitNode) { const sitesRes = await db .select() .from(sites) From 50cf28427391ee7128e781022d5135a9933c9aa5 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 13 Aug 2025 21:45:44 -0700 Subject: [PATCH 21/63] Break out bandwidth --- server/routers/gerbil/receiveBandwidth.ts | 207 ++++++++++++---------- 1 file changed, 109 insertions(+), 98 deletions(-) diff --git a/server/routers/gerbil/receiveBandwidth.ts b/server/routers/gerbil/receiveBandwidth.ts index caadf7bb..350228ec 100644 --- a/server/routers/gerbil/receiveBandwidth.ts +++ b/server/routers/gerbil/receiveBandwidth.ts @@ -28,103 +28,7 @@ export const receiveBandwidth = async ( throw new Error("Invalid bandwidth data"); } - const currentTime = new Date(); - const oneMinuteAgo = new Date(currentTime.getTime() - 60000); // 1 minute ago - - // logger.debug(`Received data: ${JSON.stringify(bandwidthData)}`); - - await db.transaction(async (trx) => { - // First, handle sites that are actively reporting bandwidth - const activePeers = bandwidthData.filter(peer => peer.bytesIn > 0); // Bytesout will have data as it tries to send keep alive messages - - if (activePeers.length > 0) { - // Remove any active peers from offline tracking since they're sending data - activePeers.forEach(peer => offlineSites.delete(peer.publicKey)); - - // Aggregate usage data by organization - const orgUsageMap = new Map(); - const orgUptimeMap = new Map(); - - // Update all active sites with bandwidth data and get the site data in one operation - const updatedSites = []; - for (const peer of activePeers) { - const updatedSite = await trx - .update(sites) - .set({ - megabytesOut: sql`${sites.megabytesOut} + ${peer.bytesIn}`, - megabytesIn: sql`${sites.megabytesIn} + ${peer.bytesOut}`, - lastBandwidthUpdate: currentTime.toISOString(), - online: true - }) - .where(eq(sites.pubKey, peer.publicKey)) - .returning({ - online: sites.online, - orgId: sites.orgId, - siteId: sites.siteId, - lastBandwidthUpdate: sites.lastBandwidthUpdate, - }); - - if (updatedSite.length > 0) { - updatedSites.push({ ...updatedSite[0], peer }); - } - } - - // Calculate org usage aggregations using the updated site data - for (const { peer, ...site } of updatedSites) { - // Aggregate bandwidth usage for the org - const totalBandwidth = peer.bytesIn + peer.bytesOut; - const currentOrgUsage = orgUsageMap.get(site.orgId) || 0; - orgUsageMap.set(site.orgId, currentOrgUsage + totalBandwidth); - - // Add 10 seconds of uptime for each active site - const currentOrgUptime = orgUptimeMap.get(site.orgId) || 0; - orgUptimeMap.set(site.orgId, currentOrgUptime + 10 / 60); // Store in minutes and jut add 10 seconds - } - } - - // Handle sites that reported zero bandwidth but need online status updated - const zeroBandwidthPeers = bandwidthData.filter(peer => - peer.bytesIn === 0 && !offlineSites.has(peer.publicKey) // Bytesout will have data as it tries to send keep alive messages - ); - - if (zeroBandwidthPeers.length > 0) { - const zeroBandwidthSites = await trx - .select() - .from(sites) - .where(inArray(sites.pubKey, zeroBandwidthPeers.map(p => p.publicKey))); - - for (const site of zeroBandwidthSites) { - let newOnlineStatus = site.online; - - // Check if site should go offline based on last bandwidth update WITH DATA - if (site.lastBandwidthUpdate) { - const lastUpdateWithData = new Date(site.lastBandwidthUpdate); - if (lastUpdateWithData < oneMinuteAgo) { - newOnlineStatus = false; - } - } else { - // No previous data update recorded, set to offline - newOnlineStatus = false; - } - - // Always update lastBandwidthUpdate to show this instance is receiving reports - // Only update online status if it changed - if (site.online !== newOnlineStatus) { - await trx - .update(sites) - .set({ - online: newOnlineStatus - }) - .where(eq(sites.siteId, site.siteId)); - - // If site went offline, add it to our tracking set - if (!newOnlineStatus && site.pubKey) { - offlineSites.add(site.pubKey); - } - } - } - } - }); + await updateSiteBandwidth(bandwidthData); return response(res, { data: {}, @@ -142,4 +46,111 @@ export const receiveBandwidth = async ( ) ); } -}; \ No newline at end of file +}; + +export async function updateSiteBandwidth(bandwidthData: PeerBandwidth[]) { + const currentTime = new Date(); + const oneMinuteAgo = new Date(currentTime.getTime() - 60000); // 1 minute ago + + // logger.debug(`Received data: ${JSON.stringify(bandwidthData)}`); + + await db.transaction(async (trx) => { + // First, handle sites that are actively reporting bandwidth + const activePeers = bandwidthData.filter((peer) => peer.bytesIn > 0); // Bytesout will have data as it tries to send keep alive messages + + if (activePeers.length > 0) { + // Remove any active peers from offline tracking since they're sending data + activePeers.forEach((peer) => offlineSites.delete(peer.publicKey)); + + // Aggregate usage data by organization + const orgUsageMap = new Map(); + const orgUptimeMap = new Map(); + + // Update all active sites with bandwidth data and get the site data in one operation + const updatedSites = []; + for (const peer of activePeers) { + const updatedSite = await trx + .update(sites) + .set({ + megabytesOut: sql`${sites.megabytesOut} + ${peer.bytesIn}`, + megabytesIn: sql`${sites.megabytesIn} + ${peer.bytesOut}`, + lastBandwidthUpdate: currentTime.toISOString(), + online: true + }) + .where(eq(sites.pubKey, peer.publicKey)) + .returning({ + online: sites.online, + orgId: sites.orgId, + siteId: sites.siteId, + lastBandwidthUpdate: sites.lastBandwidthUpdate + }); + + if (updatedSite.length > 0) { + updatedSites.push({ ...updatedSite[0], peer }); + } + } + + // Calculate org usage aggregations using the updated site data + for (const { peer, ...site } of updatedSites) { + // Aggregate bandwidth usage for the org + const totalBandwidth = peer.bytesIn + peer.bytesOut; + const currentOrgUsage = orgUsageMap.get(site.orgId) || 0; + orgUsageMap.set(site.orgId, currentOrgUsage + totalBandwidth); + + // Add 10 seconds of uptime for each active site + const currentOrgUptime = orgUptimeMap.get(site.orgId) || 0; + orgUptimeMap.set(site.orgId, currentOrgUptime + 10 / 60); // Store in minutes and jut add 10 seconds + } + } + + // Handle sites that reported zero bandwidth but need online status updated + const zeroBandwidthPeers = bandwidthData.filter( + (peer) => peer.bytesIn === 0 && !offlineSites.has(peer.publicKey) // Bytesout will have data as it tries to send keep alive messages + ); + + if (zeroBandwidthPeers.length > 0) { + const zeroBandwidthSites = await trx + .select() + .from(sites) + .where( + inArray( + sites.pubKey, + zeroBandwidthPeers.map((p) => p.publicKey) + ) + ); + + for (const site of zeroBandwidthSites) { + let newOnlineStatus = site.online; + + // Check if site should go offline based on last bandwidth update WITH DATA + if (site.lastBandwidthUpdate) { + const lastUpdateWithData = new Date( + site.lastBandwidthUpdate + ); + if (lastUpdateWithData < oneMinuteAgo) { + newOnlineStatus = false; + } + } else { + // No previous data update recorded, set to offline + newOnlineStatus = false; + } + + // Always update lastBandwidthUpdate to show this instance is receiving reports + // Only update online status if it changed + if (site.online !== newOnlineStatus) { + await trx + .update(sites) + .set({ + online: newOnlineStatus + }) + .where(eq(sites.siteId, site.siteId)); + + // If site went offline, add it to our tracking set + if (!newOnlineStatus && site.pubKey) { + offlineSites.add(site.pubKey); + } + } + } + } + }); +} From fcc86b07baeda7c19ea45cfacf16b677553ec5ab Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 13 Aug 2025 22:05:26 -0700 Subject: [PATCH 22/63] Break out hole punch --- server/routers/gerbil/updateHolePunch.ts | 436 +++++++++++------------ 1 file changed, 217 insertions(+), 219 deletions(-) diff --git a/server/routers/gerbil/updateHolePunch.ts b/server/routers/gerbil/updateHolePunch.ts index 1d30b1ea..0eaa447e 100644 --- a/server/routers/gerbil/updateHolePunch.ts +++ b/server/routers/gerbil/updateHolePunch.ts @@ -66,228 +66,34 @@ export async function updateHolePunch( publicKey } = parsedParams.data; - let currentSiteId: number | undefined; - let destinations: PeerDestination[] = []; - - if (olmId) { - logger.debug( - `Got hole punch with ip: ${ip}, port: ${port} for olmId: ${olmId}${publicKey ? ` with exit node publicKey: ${publicKey}` : ""}` - ); - - const { session, olm: olmSession } = - await validateOlmSessionToken(token); - if (!session || !olmSession) { - return next( - createHttpError(HttpCode.UNAUTHORIZED, "Unauthorized") - ); - } - - if (olmId !== olmSession.olmId) { - logger.warn( - `Olm ID mismatch: ${olmId} !== ${olmSession.olmId}` - ); - return next( - createHttpError(HttpCode.UNAUTHORIZED, "Unauthorized") - ); - } - - const [olm] = await db + let exitNode: ExitNode | undefined; + if (publicKey) { + // Get the exit node by public key + [exitNode] = await db .select() - .from(olms) - .where(eq(olms.olmId, olmId)); - - if (!olm || !olm.clientId) { - logger.warn(`Olm not found: ${olmId}`); - return next( - createHttpError(HttpCode.NOT_FOUND, "Olm not found") - ); - } - - const [client] = await db - .update(clients) - .set({ - lastHolePunch: timestamp - }) - .where(eq(clients.clientId, olm.clientId)) - .returning(); - - let exitNode: ExitNode | undefined; - if (publicKey) { - // Get the exit node by public key - [exitNode] = await db - .select() - .from(exitNodes) - .where(eq(exitNodes.publicKey, publicKey)); - } else { - // FOR BACKWARDS COMPATIBILITY IF GERBIL IS STILL =<1.1.0 - [exitNode] = await db.select().from(exitNodes).limit(1); - } - - if (!exitNode) { - logger.warn(`Exit node not found for publicKey: ${publicKey}`); - return next( - createHttpError(HttpCode.NOT_FOUND, "Exit node not found") - ); - } - - // Get sites that are on this specific exit node and connected to this client - const sitesOnExitNode = await db - .select({ siteId: sites.siteId, subnet: sites.subnet, listenPort: sites.listenPort }) - .from(sites) - .innerJoin(clientSites, eq(sites.siteId, clientSites.siteId)) - .where( - and( - eq(sites.exitNodeId, exitNode.exitNodeId), - eq(clientSites.clientId, olm.clientId) - ) - ); - - // Update clientSites for each site on this exit node - for (const site of sitesOnExitNode) { - logger.debug( - `Updating site ${site.siteId} on exit node with publicKey: ${publicKey}` - ); - - await db - .update(clientSites) - .set({ - endpoint: `${ip}:${port}` - }) - .where( - and( - eq(clientSites.clientId, olm.clientId), - eq(clientSites.siteId, site.siteId) - ) - ); - } - - logger.debug( - `Updated ${sitesOnExitNode.length} sites on exit node with publicKey: ${publicKey}` - ); - if (!client) { - logger.warn(`Client not found for olm: ${olmId}`); - return next( - createHttpError(HttpCode.NOT_FOUND, "Client not found") - ); - } - - // Create a list of the destinations from the sites - for (const site of sitesOnExitNode) { - if (site.subnet && site.listenPort) { - destinations.push({ - destinationIP: site.subnet.split("/")[0], - destinationPort: site.listenPort - }); - } - } - - } else if (newtId) { - logger.debug( - `Got hole punch with ip: ${ip}, port: ${port} for newtId: ${newtId}` - ); - - const { session, newt: newtSession } = - await validateNewtSessionToken(token); - - if (!session || !newtSession) { - return next( - createHttpError(HttpCode.UNAUTHORIZED, "Unauthorized") - ); - } - - if (newtId !== newtSession.newtId) { - logger.warn( - `Newt ID mismatch: ${newtId} !== ${newtSession.newtId}` - ); - return next( - createHttpError(HttpCode.UNAUTHORIZED, "Unauthorized") - ); - } - - const [newt] = await db - .select() - .from(newts) - .where(eq(newts.newtId, newtId)); - - if (!newt || !newt.siteId) { - logger.warn(`Newt not found: ${newtId}`); - return next( - createHttpError(HttpCode.NOT_FOUND, "New not found") - ); - } - - currentSiteId = newt.siteId; - - // Update the current site with the new endpoint - const [updatedSite] = await db - .update(sites) - .set({ - endpoint: `${ip}:${port}`, - lastHolePunch: timestamp - }) - .where(eq(sites.siteId, newt.siteId)) - .returning(); - - if (!updatedSite || !updatedSite.subnet) { - logger.warn(`Site not found: ${newt.siteId}`); - return next( - createHttpError(HttpCode.NOT_FOUND, "Site not found") - ); - } - - // Find all clients that connect to this site - // const sitesClientPairs = await db - // .select() - // .from(clientSites) - // .where(eq(clientSites.siteId, newt.siteId)); - - // THE NEWT IS NOT SENDING RAW WG TO THE GERBIL SO IDK IF WE REALLY NEED THIS - REMOVING - // Get client details for each client - // for (const pair of sitesClientPairs) { - // const [client] = await db - // .select() - // .from(clients) - // .where(eq(clients.clientId, pair.clientId)); - - // if (client && client.endpoint) { - // const [host, portStr] = client.endpoint.split(':'); - // if (host && portStr) { - // destinations.push({ - // destinationIP: host, - // destinationPort: parseInt(portStr, 10) - // }); - // } - // } - // } - - // If this is a newt/site, also add other sites in the same org - // if (updatedSite.orgId) { - // const orgSites = await db - // .select() - // .from(sites) - // .where(eq(sites.orgId, updatedSite.orgId)); - - // for (const site of orgSites) { - // // Don't add the current site to the destinations - // if (site.siteId !== currentSiteId && site.subnet && site.endpoint && site.listenPort) { - // const [host, portStr] = site.endpoint.split(':'); - // if (host && portStr) { - // destinations.push({ - // destinationIP: host, - // destinationPort: site.listenPort - // }); - // } - // } - // } - // } + .from(exitNodes) + .where(eq(exitNodes.publicKey, publicKey)); + } else { + // FOR BACKWARDS COMPATIBILITY IF GERBIL IS STILL =<1.1.0 + [exitNode] = await db.select().from(exitNodes).limit(1); } - // if (destinations.length === 0) { - // logger.warn( - // `No peer destinations found for olmId: ${olmId} or newtId: ${newtId}` - // ); - // return next(createHttpError(HttpCode.NOT_FOUND, "No peer destinations found")); - // } + if (!exitNode) { + logger.warn(`Exit node not found for publicKey: ${publicKey}`); + return next( + createHttpError(HttpCode.NOT_FOUND, "Exit node not found") + ); + } + + const destinations = await updateAndGenerateEndpointDestinations( + olmId, + newtId, + ip, + port, + timestamp, + token, + exitNode + ); logger.debug( `Returning ${destinations.length} peer destinations for olmId: ${olmId} or newtId: ${newtId}: ${JSON.stringify(destinations, null, 2)}` @@ -307,3 +113,195 @@ export async function updateHolePunch( ); } } + +export async function updateAndGenerateEndpointDestinations( + olmId: string | undefined, + newtId: string | undefined, + ip: string, + port: number, + timestamp: number, + token: string, + exitNode: ExitNode +) { + let currentSiteId: number | undefined; + let destinations: PeerDestination[] = []; + + if (olmId) { + logger.debug( + `Got hole punch with ip: ${ip}, port: ${port} for olmId: ${olmId}` + ); + + const { session, olm: olmSession } = + await validateOlmSessionToken(token); + if (!session || !olmSession) { + throw new Error("Unauthorized"); + } + + if (olmId !== olmSession.olmId) { + logger.warn(`Olm ID mismatch: ${olmId} !== ${olmSession.olmId}`); + throw new Error("Unauthorized"); + } + + const [olm] = await db.select().from(olms).where(eq(olms.olmId, olmId)); + + if (!olm || !olm.clientId) { + logger.warn(`Olm not found: ${olmId}`); + throw new Error("Olm not found"); + } + + const [client] = await db + .update(clients) + .set({ + lastHolePunch: timestamp + }) + .where(eq(clients.clientId, olm.clientId)) + .returning(); + + + + // Get sites that are on this specific exit node and connected to this client + const sitesOnExitNode = await db + .select({ + siteId: sites.siteId, + subnet: sites.subnet, + listenPort: sites.listenPort + }) + .from(sites) + .innerJoin(clientSites, eq(sites.siteId, clientSites.siteId)) + .where( + and( + eq(sites.exitNodeId, exitNode.exitNodeId), + eq(clientSites.clientId, olm.clientId) + ) + ); + + // Update clientSites for each site on this exit node + for (const site of sitesOnExitNode) { + logger.debug( + `Updating site ${site.siteId} on exit node ${exitNode.exitNodeId}` + ); + + await db + .update(clientSites) + .set({ + endpoint: `${ip}:${port}` + }) + .where( + and( + eq(clientSites.clientId, olm.clientId), + eq(clientSites.siteId, site.siteId) + ) + ); + } + + logger.debug( + `Updated ${sitesOnExitNode.length} sites on exit node ${exitNode.exitNodeId}` + ); + if (!client) { + logger.warn(`Client not found for olm: ${olmId}`); + throw new Error("Client not found"); + } + + // Create a list of the destinations from the sites + for (const site of sitesOnExitNode) { + if (site.subnet && site.listenPort) { + destinations.push({ + destinationIP: site.subnet.split("/")[0], + destinationPort: site.listenPort + }); + } + } + } else if (newtId) { + logger.debug( + `Got hole punch with ip: ${ip}, port: ${port} for newtId: ${newtId}` + ); + + const { session, newt: newtSession } = + await validateNewtSessionToken(token); + + if (!session || !newtSession) { + throw new Error("Unauthorized"); + } + + if (newtId !== newtSession.newtId) { + logger.warn( + `Newt ID mismatch: ${newtId} !== ${newtSession.newtId}` + ); + throw new Error("Unauthorized"); + } + + const [newt] = await db + .select() + .from(newts) + .where(eq(newts.newtId, newtId)); + + if (!newt || !newt.siteId) { + logger.warn(`Newt not found: ${newtId}`); + throw new Error("Newt not found"); + } + + currentSiteId = newt.siteId; + + // Update the current site with the new endpoint + const [updatedSite] = await db + .update(sites) + .set({ + endpoint: `${ip}:${port}`, + lastHolePunch: timestamp + }) + .where(eq(sites.siteId, newt.siteId)) + .returning(); + + if (!updatedSite || !updatedSite.subnet) { + logger.warn(`Site not found: ${newt.siteId}`); + throw new Error("Site not found"); + } + + // Find all clients that connect to this site + // const sitesClientPairs = await db + // .select() + // .from(clientSites) + // .where(eq(clientSites.siteId, newt.siteId)); + + // THE NEWT IS NOT SENDING RAW WG TO THE GERBIL SO IDK IF WE REALLY NEED THIS - REMOVING + // Get client details for each client + // for (const pair of sitesClientPairs) { + // const [client] = await db + // .select() + // .from(clients) + // .where(eq(clients.clientId, pair.clientId)); + + // if (client && client.endpoint) { + // const [host, portStr] = client.endpoint.split(':'); + // if (host && portStr) { + // destinations.push({ + // destinationIP: host, + // destinationPort: parseInt(portStr, 10) + // }); + // } + // } + // } + + // If this is a newt/site, also add other sites in the same org + // if (updatedSite.orgId) { + // const orgSites = await db + // .select() + // .from(sites) + // .where(eq(sites.orgId, updatedSite.orgId)); + + // for (const site of orgSites) { + // // Don't add the current site to the destinations + // if (site.siteId !== currentSiteId && site.subnet && site.endpoint && site.listenPort) { + // const [host, portStr] = site.endpoint.split(':'); + // if (host && portStr) { + // destinations.push({ + // destinationIP: host, + // destinationPort: site.listenPort + // }); + // } + // } + // } + // } + } + return destinations; +} From d5a11edd0cc93cff22d42c47aacc4fce3a8f14df Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 14 Aug 2025 10:38:22 -0700 Subject: [PATCH 23/63] Remove orgId --- server/db/pg/schema.ts | 3 --- server/db/sqlite/schema.ts | 3 --- server/lib/remoteTraefikConfig.ts | 3 ++- 3 files changed, 2 insertions(+), 7 deletions(-) diff --git a/server/db/pg/schema.ts b/server/db/pg/schema.ts index 8be65957..50355abd 100644 --- a/server/db/pg/schema.ts +++ b/server/db/pg/schema.ts @@ -122,9 +122,6 @@ export const exitNodes = pgTable("exitNodes", { listenPort: integer("listenPort").notNull(), reachableAt: varchar("reachableAt"), maxConnections: integer("maxConnections"), - orgId: text("orgId").references(() => orgs.orgId, { - onDelete: "cascade" - }), online: boolean("online").notNull().default(false), lastPing: integer("lastPing"), type: text("type").default("gerbil") // gerbil, remoteExitNode diff --git a/server/db/sqlite/schema.ts b/server/db/sqlite/schema.ts index 33442075..1ddf0f4c 100644 --- a/server/db/sqlite/schema.ts +++ b/server/db/sqlite/schema.ts @@ -134,9 +134,6 @@ export const exitNodes = sqliteTable("exitNodes", { listenPort: integer("listenPort").notNull(), reachableAt: text("reachableAt"), // this is the internal address of the gerbil http server for command control maxConnections: integer("maxConnections"), - orgId: text("orgId").references(() => orgs.orgId, { - onDelete: "cascade" - }), online: integer("online", { mode: "boolean" }).notNull().default(false), lastPing: integer("lastPing"), type: text("type").default("gerbil") // gerbil, remoteExitNode diff --git a/server/lib/remoteTraefikConfig.ts b/server/lib/remoteTraefikConfig.ts index 2e8ff529..9f1067e8 100644 --- a/server/lib/remoteTraefikConfig.ts +++ b/server/lib/remoteTraefikConfig.ts @@ -261,7 +261,8 @@ export class TraefikConfigManager { { params: { domains: domainArray - } + }, + headers: (await tokenManager.getAuthHeader()).headers } ); return response.data; From aabfa91f801cdc3cecdbfb03365eb1d88f3b9798 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 14 Aug 2025 11:11:01 -0700 Subject: [PATCH 24/63] Fix ping new integer --- server/index.ts | 2 +- server/routers/olm/handleOlmPingMessage.ts | 15 ++++++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/server/index.ts b/server/index.ts index 7fd328c2..3e8c6769 100644 --- a/server/index.ts +++ b/server/index.ts @@ -7,7 +7,7 @@ import { createNextServer } from "./nextServer"; import { createInternalServer } from "./internalServer"; import { ApiKey, ApiKeyOrg, Session, User, UserOrg } from "@server/db"; import { createIntegrationApiServer } from "./integrationApiServer"; -import { createHybridClientServer } from "./privateHybridServer.js"; +import { createHybridClientServer } from "./hybridServer"; import config from "@server/lib/config"; async function startServers() { diff --git a/server/routers/olm/handleOlmPingMessage.ts b/server/routers/olm/handleOlmPingMessage.ts index 2425c4d8..6c4b5600 100644 --- a/server/routers/olm/handleOlmPingMessage.ts +++ b/server/routers/olm/handleOlmPingMessage.ts @@ -1,7 +1,7 @@ import { db } from "@server/db"; import { MessageHandler } from "../ws"; import { clients, Olm } from "@server/db"; -import { eq, lt, isNull } from "drizzle-orm"; +import { eq, lt, isNull, and, or } from "drizzle-orm"; import logger from "@server/logger"; // Track if the offline checker interval is running @@ -20,15 +20,20 @@ export const startOlmOfflineChecker = (): void => { offlineCheckerInterval = setInterval(async () => { try { - const twoMinutesAgo = new Date(Date.now() - OFFLINE_THRESHOLD_MS); + const twoMinutesAgo = Math.floor((Date.now() - OFFLINE_THRESHOLD_MS) / 1000); // Find clients that haven't pinged in the last 2 minutes and mark them as offline await db .update(clients) .set({ online: false }) .where( - eq(clients.online, true) && - (lt(clients.lastPing, twoMinutesAgo.getTime() / 1000) || isNull(clients.lastPing)) + and( + eq(clients.online, true), + or( + lt(clients.lastPing, twoMinutesAgo), + isNull(clients.lastPing) + ) + ) ); } catch (error) { @@ -72,7 +77,7 @@ export const handleOlmPingMessage: MessageHandler = async (context) => { await db .update(clients) .set({ - lastPing: new Date().getTime() / 1000, + lastPing: Math.floor(Date.now() / 1000), online: true, }) .where(eq(clients.clientId, olm.clientId)); From 200e3af3844cd37baa3ee82ab288f371d4c7dd94 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 14 Aug 2025 11:58:08 -0700 Subject: [PATCH 25/63] Websocket connects --- server/hybridServer.ts | 12 +++++++----- server/index.ts | 6 ++++-- server/lib/config.ts | 2 +- server/lib/tokenManager.ts | 2 ++ server/routers/external.ts | 2 +- server/routers/ws/client.ts | 28 +++++++++++++++++++++++----- 6 files changed, 38 insertions(+), 14 deletions(-) diff --git a/server/hybridServer.ts b/server/hybridServer.ts index 64467d1b..7339db1c 100644 --- a/server/hybridServer.ts +++ b/server/hybridServer.ts @@ -11,6 +11,13 @@ import { tokenManager } from "./lib/tokenManager"; import { APP_VERSION } from "./lib/consts"; export async function createHybridClientServer() { + logger.info("Starting hybrid client server..."); + + // Start the token manager + await tokenManager.start(); + + const token = await tokenManager.getToken(); + const monitor = new TraefikConfigManager(); await monitor.start(); @@ -23,11 +30,6 @@ export async function createHybridClientServer() { throw new Error("Hybrid configuration is not defined"); } - // Start the token manager - await tokenManager.start(); - - const token = await tokenManager.getToken(); - // Create client const client = createWebSocketClient( token, diff --git a/server/index.ts b/server/index.ts index 3e8c6769..73f3ac90 100644 --- a/server/index.ts +++ b/server/index.ts @@ -17,11 +17,13 @@ async function startServers() { // Start all servers const apiServer = createApiServer(); const internalServer = createInternalServer(); - const nextServer = await createNextServer(); let hybridClientServer; + let nextServer; if (config.isHybridMode()) { - hybridClientServer = createHybridClientServer(); + hybridClientServer = await createHybridClientServer(); + } else { + nextServer = await createNextServer(); } let integrationServer; diff --git a/server/lib/config.ts b/server/lib/config.ts index c8c7b7c4..6b41df79 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -148,7 +148,7 @@ export class Config { } public isHybridMode() { - return this.rawConfig?.hybrid; + return typeof this.rawConfig?.hybrid === "object"; } public async checkSupporterKey() { diff --git a/server/lib/tokenManager.ts b/server/lib/tokenManager.ts index 040dc609..8abfd969 100644 --- a/server/lib/tokenManager.ts +++ b/server/lib/tokenManager.ts @@ -67,6 +67,8 @@ export class TokenManager { /** * Get the current valid token */ + + // TODO: WE SHOULD NOT BE GETTING A TOKEN EVERY TIME WE REQUEST IT async getToken(): Promise { if (!this.token) { if (this.isRefreshing) { diff --git a/server/routers/external.ts b/server/routers/external.ts index 5bae553e..776db454 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -848,7 +848,7 @@ authRouter.post( rateLimit({ windowMs: 15 * 60 * 1000, max: 900, - keyGenerator: (req) => `newtGetToken:${req.body.newtId || req.ip}`, + keyGenerator: (req) => `olmGetToken:${req.body.newtId || req.ip}`, handler: (req, res, next) => { const message = `You can only request an Olm token ${900} times every ${15} minutes. Please try again later.`; return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); diff --git a/server/routers/ws/client.ts b/server/routers/ws/client.ts index 3f1fbf54..c40c976f 100644 --- a/server/routers/ws/client.ts +++ b/server/routers/ws/client.ts @@ -36,6 +36,7 @@ export class WebSocketClient extends EventEmitter { private pingTimer: NodeJS.Timeout | null = null; private pingTimeoutTimer: NodeJS.Timeout | null = null; private token: string; + private isConnecting: boolean = false; constructor( token: string, @@ -46,14 +47,16 @@ export class WebSocketClient extends EventEmitter { this.token = token; this.baseURL = options.baseURL || endpoint; - this.reconnectInterval = options.reconnectInterval || 3000; + this.reconnectInterval = options.reconnectInterval || 5000; this.pingInterval = options.pingInterval || 30000; this.pingTimeout = options.pingTimeout || 10000; } public async connect(): Promise { this.shouldReconnect = true; - await this.connectWithRetry(); + if (!this.isConnecting) { + await this.connectWithRetry(); + } } public async close(): Promise { @@ -141,20 +144,30 @@ export class WebSocketClient extends EventEmitter { } private async connectWithRetry(): Promise { - while (this.shouldReconnect) { + if (this.isConnecting) return; + + this.isConnecting = true; + + while (this.shouldReconnect && !this.isConnected) { try { await this.establishConnection(); + this.isConnecting = false; return; } catch (error) { console.error(`Failed to connect: ${error}. Retrying in ${this.reconnectInterval}ms...`); - if (!this.shouldReconnect) return; + if (!this.shouldReconnect) { + this.isConnecting = false; + return; + } await new Promise(resolve => { this.reconnectTimer = setTimeout(resolve, this.reconnectInterval); }); } } + + this.isConnecting = false; } private async establishConnection(): Promise { @@ -174,6 +187,7 @@ export class WebSocketClient extends EventEmitter { console.debug('WebSocket connection established'); this.conn = conn; this.setConnected(true); + this.isConnecting = false; this.startPingMonitor(); this.emit('connect'); resolve(); @@ -232,6 +246,7 @@ export class WebSocketClient extends EventEmitter { private handleDisconnect(): void { this.setConnected(false); + this.isConnecting = false; // Clear ping timers if (this.pingTimer) { @@ -252,7 +267,10 @@ export class WebSocketClient extends EventEmitter { // Reconnect if needed if (this.shouldReconnect) { - this.connectWithRetry(); + // Add a small delay before starting reconnection to prevent immediate retry + setTimeout(() => { + this.connectWithRetry(); + }, 1000); } } From 65bdb232f410ad01e37274b99a9ae2826a920437 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 14 Aug 2025 12:01:07 -0700 Subject: [PATCH 26/63] Use right logging --- server/hybridServer.ts | 10 +++++----- server/routers/ws/client.ts | 19 ++++++++++--------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/server/hybridServer.ts b/server/hybridServer.ts index 7339db1c..342cf8c0 100644 --- a/server/hybridServer.ts +++ b/server/hybridServer.ts @@ -69,26 +69,26 @@ export async function createHybridClientServer() { // Listen to connection events client.on("connect", () => { - console.log("Connected to WebSocket server"); + logger.info("Connected to WebSocket server"); client.sendMessage("remoteExitNode/register", { remoteExitNodeVersion: APP_VERSION }); }); client.on("disconnect", () => { - console.log("Disconnected from WebSocket server"); + logger.info("Disconnected from WebSocket server"); }); client.on("message", (message) => { - console.log("Received message:", message.type, message.data); + logger.info("Received message:", message.type, message.data); }); // Connect to the server try { await client.connect(); - console.log("Connection initiated"); + logger.info("Connection initiated"); } catch (error) { - console.error("Failed to connect:", error); + logger.error("Failed to connect:", error); } client.sendMessageInterval( diff --git a/server/routers/ws/client.ts b/server/routers/ws/client.ts index c40c976f..fda1e62c 100644 --- a/server/routers/ws/client.ts +++ b/server/routers/ws/client.ts @@ -2,6 +2,7 @@ import WebSocket from 'ws'; import axios from 'axios'; import { URL } from 'url'; import { EventEmitter } from 'events'; +import logger from '@server/logger'; export interface Config { id: string; @@ -96,7 +97,7 @@ export class WebSocketClient extends EventEmitter { data: data }; - console.debug(`Sending message: ${messageType}`, data); + logger.debug(`Sending message: ${messageType}`, data); this.conn.send(JSON.stringify(message), (error) => { if (error) { @@ -115,13 +116,13 @@ export class WebSocketClient extends EventEmitter { ): () => void { // Send immediately this.sendMessage(messageType, data).catch(err => { - console.error('Failed to send initial message:', err); + logger.error('Failed to send initial message:', err); }); // Set up interval const intervalId = setInterval(() => { this.sendMessage(messageType, data).catch(err => { - console.error('Failed to send message:', err); + logger.error('Failed to send message:', err); }); }, interval); @@ -154,7 +155,7 @@ export class WebSocketClient extends EventEmitter { this.isConnecting = false; return; } catch (error) { - console.error(`Failed to connect: ${error}. Retrying in ${this.reconnectInterval}ms...`); + logger.error(`Failed to connect: ${error}. Retrying in ${this.reconnectInterval}ms...`); if (!this.shouldReconnect) { this.isConnecting = false; @@ -184,7 +185,7 @@ export class WebSocketClient extends EventEmitter { const conn = new WebSocket(wsURL.toString()); conn.on('open', () => { - console.debug('WebSocket connection established'); + logger.debug('WebSocket connection established'); this.conn = conn; this.setConnected(true); this.isConnecting = false; @@ -202,17 +203,17 @@ export class WebSocketClient extends EventEmitter { } this.emit('message', message); } catch (error) { - console.error('Failed to parse message:', error); + logger.error('Failed to parse message:', error); } }); conn.on('close', (code, reason) => { - console.debug(`WebSocket connection closed: ${code} ${reason}`); + logger.debug(`WebSocket connection closed: ${code} ${reason}`); this.handleDisconnect(); }); conn.on('error', (error) => { - console.error('WebSocket error:', error); + logger.error('WebSocket error:', error); if (this.conn === null) { // Connection failed during establishment reject(error); @@ -237,7 +238,7 @@ export class WebSocketClient extends EventEmitter { // Set timeout for pong response this.pingTimeoutTimer = setTimeout(() => { - console.error('Ping timeout - no pong received'); + logger.error('Ping timeout - no pong received'); this.handleDisconnect(); }, this.pingTimeout); } From f7b82f0a7a43d4952a1897b795759f8fc416e77b Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 14 Aug 2025 12:35:33 -0700 Subject: [PATCH 27/63] Work on pulling in remote traefik --- .gitignore | 2 + server/hybridServer.ts | 2 +- server/lib/remoteTraefikConfig.ts | 77 +++++++++++++++++++++++++------ 3 files changed, 67 insertions(+), 14 deletions(-) diff --git a/.gitignore b/.gitignore index 167b4a91..2f1749ef 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,5 @@ bin test_event.json .idea/ server/db/index.ts +dynamic/ +certificates/ diff --git a/server/hybridServer.ts b/server/hybridServer.ts index 342cf8c0..d5a61a9e 100644 --- a/server/hybridServer.ts +++ b/server/hybridServer.ts @@ -80,7 +80,7 @@ export async function createHybridClientServer() { }); client.on("message", (message) => { - logger.info("Received message:", message.type, message.data); + logger.info(`Received message: ${message.type} ${JSON.stringify(message.data)}`); }); // Connect to the server diff --git a/server/lib/remoteTraefikConfig.ts b/server/lib/remoteTraefikConfig.ts index 9f1067e8..fba86c12 100644 --- a/server/lib/remoteTraefikConfig.ts +++ b/server/lib/remoteTraefikConfig.ts @@ -87,17 +87,17 @@ export class TraefikConfigManager { public async HandleTraefikConfig(): Promise { try { // Get all active domains for this exit node via HTTP call - const getActiveDomainsFromTraefik = - await this.getActiveDomainsFromTraefik(); + const getTraefikConfig = + await this.getTraefikConfig(); - if (!getActiveDomainsFromTraefik) { + if (!getTraefikConfig) { logger.error( "Failed to fetch active domains from traefik config" ); return; } - const { domains, traefikConfig } = getActiveDomainsFromTraefik; + const { domains, traefikConfig } = getTraefikConfig; // Add static domains from config // const staticDomains = [config.getRawConfig().app.dashboard_url]; @@ -150,31 +150,32 @@ export class TraefikConfigManager { // Update active domains tracking this.activeDomains = domains; } catch (error) { - logger.error("Error in certificate monitoring cycle:", error); + logger.error("Error in traefik config monitoring cycle:", error); } } /** * Get all domains currently in use from traefik config API */ - private async getActiveDomainsFromTraefik(): Promise<{ + private async getTraefikConfig(): Promise<{ domains: Set; traefikConfig: any; } | null> { try { const resp = await axios.get( - `${config.getRawConfig().hybrid?.endpoint}/traefik-config`, + `${config.getRawConfig().hybrid?.endpoint}/api/v1/hybrid/traefik-config`, await tokenManager.getAuthHeader() ); if (resp.status !== 200) { logger.error( - `Failed to fetch traefik config: ${resp.status} ${resp.statusText}` + `Failed to fetch traefik config: ${resp.status} ${resp.statusText}`, + { responseData: resp.data } ); return null; } - const traefikConfig = resp.data; + const traefikConfig = resp.data.data; const domains = new Set(); if (traefikConfig?.http?.routers) { @@ -190,9 +191,29 @@ export class TraefikConfigManager { } } } + + logger.debug( + `Successfully retrieved traefik config: ${JSON.stringify(traefikConfig)}` + ); + return { domains, traefikConfig }; } catch (err) { - logger.error("Failed to fetch traefik config:", err); + // Extract useful information from axios error without circular references + if (err && typeof err === 'object' && 'response' in err) { + const axiosError = err as any; + logger.error("Failed to fetch traefik config:", { + status: axiosError.response?.status, + statusText: axiosError.response?.statusText, + data: axiosError.response?.data, + message: axiosError.message, + url: axiosError.config?.url + }); + } else { + logger.error("Failed to fetch traefik config:", { + message: err instanceof Error ? err.message : String(err), + stack: err instanceof Error ? err.stack : undefined + }); + } return null; } } @@ -257,7 +278,7 @@ export class TraefikConfigManager { try { const response = await axios.get( - `${config.getRawConfig().hybrid?.endpoint}/certificates/domains`, + `${config.getRawConfig().hybrid?.endpoint}/api/v1/hybrid/certificates/domains`, { params: { domains: domainArray @@ -265,9 +286,39 @@ export class TraefikConfigManager { headers: (await tokenManager.getAuthHeader()).headers } ); - return response.data; + + if (response.status !== 200) { + logger.error( + `Failed to fetch certificates for domains: ${response.status} ${response.statusText}`, + { responseData: response.data, domains: domainArray } + ); + return []; + } + + logger.debug( + `Successfully retrieved ${response.data.data?.length || 0} certificates for ${domainArray.length} domains` + ); + + return response.data.data; } catch (error) { - console.error("Error fetching resource by domain:", error); + // Extract useful information from axios error without circular references + if (error && typeof error === 'object' && 'response' in error) { + const axiosError = error as any; + logger.error("Error fetching certificates for domains:", { + status: axiosError.response?.status, + statusText: axiosError.response?.statusText, + data: axiosError.response?.data, + message: axiosError.message, + url: axiosError.config?.url, + domains: domainArray + }); + } else { + logger.error("Error fetching certificates for domains:", { + message: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + domains: domainArray + }); + } return []; } } From 6600de732008fc33d6aba40773fbf8350e41fd3f Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 14 Aug 2025 14:47:07 -0700 Subject: [PATCH 28/63] Traefik config & gerbil config working? --- server/hybridServer.ts | 8 -------- server/lib/remoteProxy.ts | 2 ++ server/lib/remoteTraefikConfig.ts | 2 ++ server/routers/gerbil/getConfig.ts | 2 +- server/routers/internal.ts | 11 +++++++---- 5 files changed, 12 insertions(+), 13 deletions(-) diff --git a/server/hybridServer.ts b/server/hybridServer.ts index d5a61a9e..22ba64c8 100644 --- a/server/hybridServer.ts +++ b/server/hybridServer.ts @@ -22,14 +22,6 @@ export async function createHybridClientServer() { await monitor.start(); - if ( - !config.getRawConfig().hybrid?.id || - !config.getRawConfig().hybrid?.secret || - !config.getRawConfig().hybrid?.endpoint - ) { - throw new Error("Hybrid configuration is not defined"); - } - // Create client const client = createWebSocketClient( token, diff --git a/server/lib/remoteProxy.ts b/server/lib/remoteProxy.ts index e53f53f6..3b9dcd69 100644 --- a/server/lib/remoteProxy.ts +++ b/server/lib/remoteProxy.ts @@ -36,6 +36,8 @@ export const proxyToRemote = async ( validateStatus: () => true // Don't throw on non-2xx status codes }); + logger.debug(`Proxy response: ${JSON.stringify(response.data)}`); + // Forward the response status and data return res.status(response.status).json(response.data); diff --git a/server/lib/remoteTraefikConfig.ts b/server/lib/remoteTraefikConfig.ts index fba86c12..36bdad24 100644 --- a/server/lib/remoteTraefikConfig.ts +++ b/server/lib/remoteTraefikConfig.ts @@ -120,6 +120,8 @@ export class TraefikConfigManager { const validCertificates = await this.getValidCertificatesForDomains(domains); + // logger.debug(`Valid certs array: ${JSON.stringify(validCertificates)}`); + // Download and decrypt new certificates await this.processValidCertificates(validCertificates); diff --git a/server/routers/gerbil/getConfig.ts b/server/routers/gerbil/getConfig.ts index 4a6bcf05..d8f4c56e 100644 --- a/server/routers/gerbil/getConfig.ts +++ b/server/routers/gerbil/getConfig.ts @@ -104,7 +104,7 @@ export async function getConfig( // STOP HERE IN HYBRID MODE if (config.isHybridMode()) { - return proxyToRemote(req, res, next, "gerbil/get-config"); + return proxyToRemote(req, res, next, "hybrid/gerbil/get-config"); } const configResponse = await generateGerbilConfig(exitNode[0]); diff --git a/server/routers/internal.ts b/server/routers/internal.ts index e91446dc..977248e5 100644 --- a/server/routers/internal.ts +++ b/server/routers/internal.ts @@ -55,27 +55,30 @@ if (config.isHybridMode()) { // Use proxy router to forward requests to remote cloud server // Proxy endpoints for each gerbil route gerbilRouter.post("/receive-bandwidth", (req, res, next) => - proxyToRemote(req, res, next, "gerbil/receive-bandwidth") + proxyToRemote(req, res, next, "hybrid/gerbil/receive-bandwidth") ); gerbilRouter.post("/update-hole-punch", (req, res, next) => - proxyToRemote(req, res, next, "gerbil/update-hole-punch") + proxyToRemote(req, res, next, "hybrid/gerbil/update-hole-punch") ); gerbilRouter.post("/get-all-relays", (req, res, next) => - proxyToRemote(req, res, next, "gerbil/get-all-relays") + proxyToRemote(req, res, next, "hybrid/gerbil/get-all-relays") ); // GET CONFIG IS HANDLED IN THE ORIGINAL HANDLER // SO IT CAN REGISTER THE LOCAL EXIT NODE } else { // Use local gerbil endpoints - gerbilRouter.post("/get-config", gerbil.getConfig); gerbilRouter.post("/receive-bandwidth", gerbil.receiveBandwidth); gerbilRouter.post("/update-hole-punch", gerbil.updateHolePunch); gerbilRouter.post("/get-all-relays", gerbil.getAllRelays); } +// WE HANDLE THE PROXY INSIDE OF THIS FUNCTION +// SO IT REGISTERS THE EXIT NODE LOCALLY AS WELL +gerbilRouter.post("/get-config", gerbil.getConfig); + // Badger routes const badgerRouter = Router(); internalRouter.use("/badger", badgerRouter); From 04ecf41c5ac7620415d5eb9e956cde813563803f Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 14 Aug 2025 15:39:05 -0700 Subject: [PATCH 29/63] Move exit node comms to new file --- server/hybridServer.ts | 76 ++++++- server/lib/exitNodeComms.ts | 86 ++++++++ server/routers/client/updateClient.ts | 202 +++++++++--------- server/routers/gerbil/peers.ts | 65 ++---- server/routers/newt/handleGetConfigMessage.ts | 63 +++--- 5 files changed, 295 insertions(+), 197 deletions(-) create mode 100644 server/lib/exitNodeComms.ts diff --git a/server/hybridServer.ts b/server/hybridServer.ts index 22ba64c8..adf9ce25 100644 --- a/server/hybridServer.ts +++ b/server/hybridServer.ts @@ -1,6 +1,3 @@ -import next from "next"; -import express from "express"; -import { parse } from "url"; import logger from "@server/logger"; import config from "@server/lib/config"; import { createWebSocketClient } from "./routers/ws/client"; @@ -9,6 +6,7 @@ import { db, exitNodes } from "./db"; import { TraefikConfigManager } from "./lib/remoteTraefikConfig"; import { tokenManager } from "./lib/tokenManager"; import { APP_VERSION } from "./lib/consts"; +import axios from "axios"; export async function createHybridClientServer() { logger.info("Starting hybrid client server..."); @@ -34,7 +32,7 @@ export async function createHybridClientServer() { ); // Register message handlers - client.registerHandler("remote/peers/add", async (message) => { + client.registerHandler("remoteExitNode/peers/add", async (message) => { const { pubKey, allowedIps } = message.data; // TODO: we are getting the exit node twice here @@ -46,7 +44,7 @@ export async function createHybridClientServer() { }); }); - client.registerHandler("remote/peers/remove", async (message) => { + client.registerHandler("remoteExitNode/peers/remove", async (message) => { const { pubKey } = message.data; // TODO: we are getting the exit node twice here @@ -55,7 +53,69 @@ export async function createHybridClientServer() { await deletePeer(exitNode.exitNodeId, pubKey); }); - client.registerHandler("remote/traefik/reload", async (message) => { + // /update-proxy-mapping + client.registerHandler("remoteExitNode/update-proxy-mapping", async (message) => { + try { + const [exitNode] = await db.select().from(exitNodes).limit(1); + if (!exitNode) { + logger.error("No exit node found for proxy mapping update"); + return; + } + + const response = await axios.post(`${exitNode.endpoint}/update-proxy-mapping`, message.data); + logger.info(`Successfully updated proxy mapping: ${response.status}`); + } catch (error) { + // Extract useful information from axios error without circular references + if (error && typeof error === 'object' && 'response' in error) { + const axiosError = error as any; + logger.error("Failed to update proxy mapping:", { + status: axiosError.response?.status, + statusText: axiosError.response?.statusText, + data: axiosError.response?.data, + message: axiosError.message, + url: axiosError.config?.url + }); + } else { + logger.error("Failed to update proxy mapping:", { + message: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined + }); + } + } + }); + + // /update-destinations + client.registerHandler("remoteExitNode/update-destinations", async (message) => { + try { + const [exitNode] = await db.select().from(exitNodes).limit(1); + if (!exitNode) { + logger.error("No exit node found for destinations update"); + return; + } + + const response = await axios.post(`${exitNode.endpoint}/update-destinations`, message.data); + logger.info(`Successfully updated destinations: ${response.status}`); + } catch (error) { + // Extract useful information from axios error without circular references + if (error && typeof error === 'object' && 'response' in error) { + const axiosError = error as any; + logger.error("Failed to update destinations:", { + status: axiosError.response?.status, + statusText: axiosError.response?.statusText, + data: axiosError.response?.data, + message: axiosError.message, + url: axiosError.config?.url + }); + } else { + logger.error("Failed to update proxy mapping:", { + message: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined + }); + } + } + }); + + client.registerHandler("remoteExitNode/traefik/reload", async (message) => { await monitor.HandleTraefikConfig(); }); @@ -72,7 +132,9 @@ export async function createHybridClientServer() { }); client.on("message", (message) => { - logger.info(`Received message: ${message.type} ${JSON.stringify(message.data)}`); + logger.info( + `Received message: ${message.type} ${JSON.stringify(message.data)}` + ); }); // Connect to the server diff --git a/server/lib/exitNodeComms.ts b/server/lib/exitNodeComms.ts new file mode 100644 index 00000000..f79b718f --- /dev/null +++ b/server/lib/exitNodeComms.ts @@ -0,0 +1,86 @@ +import axios from "axios"; +import logger from "@server/logger"; +import { ExitNode } from "@server/db"; + +interface ExitNodeRequest { + remoteType: string; + localPath: string; + method?: "POST" | "DELETE" | "GET" | "PUT"; + data?: any; + queryParams?: Record; +} + +/** + * Sends a request to an exit node, handling both remote and local exit nodes + * @param exitNode The exit node to send the request to + * @param request The request configuration + * @returns Promise Response data for local nodes, undefined for remote nodes + */ +export async function sendToExitNode( + exitNode: ExitNode, + request: ExitNodeRequest +): Promise { + if (!exitNode.reachableAt) { + throw new Error( + `Exit node with ID ${exitNode.exitNodeId} is not reachable` + ); + } + + // Handle local exit node with HTTP API + const method = request.method || "POST"; + let url = `${exitNode.reachableAt}${request.localPath}`; + + // Add query parameters if provided + if (request.queryParams) { + const params = new URLSearchParams(request.queryParams); + url += `?${params.toString()}`; + } + + try { + let response; + + switch (method) { + case "POST": + response = await axios.post(url, request.data, { + headers: { + "Content-Type": "application/json" + } + }); + break; + case "DELETE": + response = await axios.delete(url); + break; + case "GET": + response = await axios.get(url); + break; + case "PUT": + response = await axios.put(url, request.data, { + headers: { + "Content-Type": "application/json" + } + }); + break; + default: + throw new Error(`Unsupported HTTP method: ${method}`); + } + + logger.info(`Exit node request successful:`, { + method, + url, + status: response.data.status + }); + + return response.data; + } catch (error) { + if (axios.isAxiosError(error)) { + logger.error( + `Error making ${method} request (can Pangolin see Gerbil HTTP API?) for exit node at ${exitNode.reachableAt} (status: ${error.response?.status}): ${error.message}` + ); + } else { + logger.error( + `Error making ${method} request for exit node at ${exitNode.reachableAt}: ${error}` + ); + } + throw error; + } +} diff --git a/server/routers/client/updateClient.ts b/server/routers/client/updateClient.ts index de4a7b5e..81ee4278 100644 --- a/server/routers/client/updateClient.ts +++ b/server/routers/client/updateClient.ts @@ -17,7 +17,7 @@ import { addPeer as olmAddPeer, deletePeer as olmDeletePeer } from "../olm/peers"; -import axios from "axios"; +import { sendToExitNode } from "../../lib/exitNodeComms"; const updateClientParamsSchema = z .object({ @@ -141,13 +141,15 @@ export async function updateClient( const isRelayed = true; // get the clientsite - const [clientSite] = await db + const [clientSite] = await db .select() .from(clientSites) - .where(and( - eq(clientSites.clientId, client.clientId), - eq(clientSites.siteId, siteId) - )) + .where( + and( + eq(clientSites.clientId, client.clientId), + eq(clientSites.siteId, siteId) + ) + ) .limit(1); if (!clientSite || !clientSite.endpoint) { @@ -158,7 +160,7 @@ export async function updateClient( const site = await newtAddPeer(siteId, { publicKey: client.pubKey, allowedIps: [`${client.subnet.split("/")[0]}/32`], // we want to only allow from that client - endpoint: isRelayed ? "" : clientSite.endpoint + endpoint: isRelayed ? "" : clientSite.endpoint }); if (!site) { @@ -270,114 +272,102 @@ export async function updateClient( } } - // get all sites for this client and join with exit nodes with site.exitNodeId - const sitesData = await db - .select() - .from(sites) - .innerJoin( - clientSites, - eq(sites.siteId, clientSites.siteId) - ) - .leftJoin( - exitNodes, - eq(sites.exitNodeId, exitNodes.exitNodeId) - ) - .where(eq(clientSites.clientId, client.clientId)); + // get all sites for this client and join with exit nodes with site.exitNodeId + const sitesData = await db + .select() + .from(sites) + .innerJoin(clientSites, eq(sites.siteId, clientSites.siteId)) + .leftJoin(exitNodes, eq(sites.exitNodeId, exitNodes.exitNodeId)) + .where(eq(clientSites.clientId, client.clientId)); - let exitNodeDestinations: { - reachableAt: string; - sourceIp: string; - sourcePort: number; - destinations: PeerDestination[]; - }[] = []; + let exitNodeDestinations: { + reachableAt: string; + exitNodeId: number; + type: string; + sourceIp: string; + sourcePort: number; + destinations: PeerDestination[]; + }[] = []; - for (const site of sitesData) { - if (!site.sites.subnet) { - logger.warn( - `Site ${site.sites.siteId} has no subnet, skipping` - ); - continue; - } - - if (!site.clientSites.endpoint) { - logger.warn( - `Site ${site.sites.siteId} has no endpoint, skipping` - ); - continue; - } - - // find the destinations in the array - let destinations = exitNodeDestinations.find( - (d) => d.reachableAt === site.exitNodes?.reachableAt + for (const site of sitesData) { + if (!site.sites.subnet) { + logger.warn( + `Site ${site.sites.siteId} has no subnet, skipping` ); - - if (!destinations) { - destinations = { - reachableAt: site.exitNodes?.reachableAt || "", - sourceIp: site.clientSites.endpoint.split(":")[0] || "", - sourcePort: parseInt(site.clientSites.endpoint.split(":")[1]) || 0, - destinations: [ - { - destinationIP: - site.sites.subnet.split("/")[0], - destinationPort: site.sites.listenPort || 0 - } - ] - }; - } else { - // add to the existing destinations - destinations.destinations.push({ - destinationIP: site.sites.subnet.split("/")[0], - destinationPort: site.sites.listenPort || 0 - }); - } - - // update it in the array - exitNodeDestinations = exitNodeDestinations.filter( - (d) => d.reachableAt !== site.exitNodes?.reachableAt - ); - exitNodeDestinations.push(destinations); + continue; } - for (const destination of exitNodeDestinations) { - try { - logger.info( - `Updating destinations for exit node at ${destination.reachableAt}` - ); - const payload = { - sourceIp: destination.sourceIp, - sourcePort: destination.sourcePort, - destinations: destination.destinations - }; - logger.info( - `Payload for update-destinations: ${JSON.stringify(payload, null, 2)}` - ); - const response = await axios.post( - `${destination.reachableAt}/update-destinations`, - payload, + if (!site.clientSites.endpoint) { + logger.warn( + `Site ${site.sites.siteId} has no endpoint, skipping` + ); + continue; + } + + // find the destinations in the array + let destinations = exitNodeDestinations.find( + (d) => d.reachableAt === site.exitNodes?.reachableAt + ); + + if (!destinations) { + destinations = { + reachableAt: site.exitNodes?.reachableAt || "", + exitNodeId: site.exitNodes?.exitNodeId || 0, + type: site.exitNodes?.type || "", + sourceIp: site.clientSites.endpoint.split(":")[0] || "", + sourcePort: + parseInt(site.clientSites.endpoint.split(":")[1]) || + 0, + destinations: [ { - headers: { - "Content-Type": "application/json" - } + destinationIP: site.sites.subnet.split("/")[0], + destinationPort: site.sites.listenPort || 0 } - ); - - logger.info("Destinations updated:", { - peer: response.data.status - }); - } catch (error) { - if (axios.isAxiosError(error)) { - logger.error( - `Error updating destinations (can Pangolin see Gerbil HTTP API?) for exit node at ${destination.reachableAt} (status: ${error.response?.status}): ${JSON.stringify(error.response?.data, null, 2)}` - ); - } else { - logger.error( - `Error updating destinations for exit node at ${destination.reachableAt}: ${error}` - ); - } - } + ] + }; + } else { + // add to the existing destinations + destinations.destinations.push({ + destinationIP: site.sites.subnet.split("/")[0], + destinationPort: site.sites.listenPort || 0 + }); } + // update it in the array + exitNodeDestinations = exitNodeDestinations.filter( + (d) => d.reachableAt !== site.exitNodes?.reachableAt + ); + exitNodeDestinations.push(destinations); + } + + for (const destination of exitNodeDestinations) { + logger.info( + `Updating destinations for exit node at ${destination.reachableAt}` + ); + const payload = { + sourceIp: destination.sourceIp, + sourcePort: destination.sourcePort, + destinations: destination.destinations + }; + logger.info( + `Payload for update-destinations: ${JSON.stringify(payload, null, 2)}` + ); + + // Create an ExitNode-like object for sendToExitNode + const exitNodeForComm = { + exitNodeId: destination.exitNodeId, + type: destination.type, + reachableAt: destination.reachableAt + } as any; // Using 'as any' since we know sendToExitNode will handle this correctly + + await sendToExitNode(exitNodeForComm, { + remoteType: "remoteExitNode/update-destinations", + localPath: "/update-destinations", + method: "POST", + data: payload + }); + } + // Fetch the updated client const [updatedClient] = await trx .select() diff --git a/server/routers/gerbil/peers.ts b/server/routers/gerbil/peers.ts index 40203c41..51a338a7 100644 --- a/server/routers/gerbil/peers.ts +++ b/server/routers/gerbil/peers.ts @@ -1,8 +1,8 @@ -import axios from "axios"; import logger from "@server/logger"; import { db } from "@server/db"; import { exitNodes } from "@server/db"; import { eq } from "drizzle-orm"; +import { sendToExitNode } from "../../lib/exitNodeComms"; export async function addPeer( exitNodeId: number, @@ -22,34 +22,13 @@ export async function addPeer( if (!exitNode) { throw new Error(`Exit node with ID ${exitNodeId} not found`); } - if (!exitNode.reachableAt) { - throw new Error(`Exit node with ID ${exitNodeId} is not reachable`); - } - try { - const response = await axios.post( - `${exitNode.reachableAt}/peer`, - peer, - { - headers: { - "Content-Type": "application/json" - } - } - ); - - logger.info("Peer added successfully:", { peer: response.data.status }); - return response.data; - } catch (error) { - if (axios.isAxiosError(error)) { - logger.error( - `Error adding peer (can Pangolin see Gerbil HTTP API?) for exit node at ${exitNode.reachableAt} (status: ${error.response?.status}): ${error.message}` - ); - } else { - logger.error( - `Error adding peer for exit node at ${exitNode.reachableAt}: ${error}` - ); - } - } + return await sendToExitNode(exitNode, { + remoteType: "remoteExitNode/peers/add", + localPath: "/peer", + method: "POST", + data: peer + }); } export async function deletePeer(exitNodeId: number, publicKey: string) { @@ -64,24 +43,16 @@ export async function deletePeer(exitNodeId: number, publicKey: string) { if (!exitNode) { throw new Error(`Exit node with ID ${exitNodeId} not found`); } - if (!exitNode.reachableAt) { - throw new Error(`Exit node with ID ${exitNodeId} is not reachable`); - } - try { - const response = await axios.delete( - `${exitNode.reachableAt}/peer?public_key=${encodeURIComponent(publicKey)}` - ); - logger.info("Peer deleted successfully:", response.data.status); - return response.data; - } catch (error) { - if (axios.isAxiosError(error)) { - logger.error( - `Error deleting peer (can Pangolin see Gerbil HTTP API?) for exit node at ${exitNode.reachableAt} (status: ${error.response?.status}): ${error.message}` - ); - } else { - logger.error( - `Error deleting peer for exit node at ${exitNode.reachableAt}: ${error}` - ); + + return await sendToExitNode(exitNode, { + remoteType: "remoteExitNode/peers/remove", + localPath: "/peer", + method: "DELETE", + data: { + publicKey: publicKey + }, + queryParams: { + public_key: publicKey } - } + }); } diff --git a/server/routers/newt/handleGetConfigMessage.ts b/server/routers/newt/handleGetConfigMessage.ts index b2594a71..6142cb05 100644 --- a/server/routers/newt/handleGetConfigMessage.ts +++ b/server/routers/newt/handleGetConfigMessage.ts @@ -13,7 +13,7 @@ import { import { clients, clientSites, Newt, sites } from "@server/db"; import { eq, and, inArray } from "drizzle-orm"; import { updatePeer } from "../olm/peers"; -import axios from "axios"; +import { sendToExitNode } from "../../lib/exitNodeComms"; const inputSchema = z.object({ publicKey: z.string(), @@ -102,41 +102,28 @@ export const handleGetConfigMessage: MessageHandler = async (context) => { .from(exitNodes) .where(eq(exitNodes.exitNodeId, site.exitNodeId)) .limit(1); - if (exitNode.reachableAt && existingSite.subnet && existingSite.listenPort) { - try { - const response = await axios.post( - `${exitNode.reachableAt}/update-proxy-mapping`, - { - oldDestination: { - destinationIP: existingSite.subnet?.split("/")[0], - destinationPort: existingSite.listenPort - }, - newDestination: { - destinationIP: site.subnet?.split("/")[0], - destinationPort: site.listenPort - } - }, - { - headers: { - "Content-Type": "application/json" - } - } - ); - - logger.info("Destinations updated:", { - peer: response.data.status - }); - } catch (error) { - if (axios.isAxiosError(error)) { - logger.error( - `Error updating proxy mapping (can Pangolin see Gerbil HTTP API?) for exit node at ${exitNode.reachableAt} (status: ${error.response?.status}): ${error.message}` - ); - } else { - logger.error( - `Error updating proxy mapping for exit node at ${exitNode.reachableAt}: ${error}` - ); + if ( + exitNode.reachableAt && + existingSite.subnet && + existingSite.listenPort + ) { + const payload = { + oldDestination: { + destinationIP: existingSite.subnet?.split("/")[0], + destinationPort: existingSite.listenPort + }, + newDestination: { + destinationIP: site.subnet?.split("/")[0], + destinationPort: site.listenPort } - } + }; + + await sendToExitNode(exitNode, { + remoteType: "remoteExitNode/update-proxy-mapping", + localPath: "/update-proxy-mapping", + method: "POST", + data: payload + }); } } @@ -237,7 +224,9 @@ export const handleGetConfigMessage: MessageHandler = async (context) => { protocol: resources.protocol }) .from(resources) - .where(and(eq(resources.siteId, siteId), eq(resources.http, false))); + .where( + and(eq(resources.siteId, siteId), eq(resources.http, false)) + ); // Get all enabled targets for these resources in a single query const resourceIds = resourcesList.map((r) => r.resourceId); @@ -251,7 +240,7 @@ export const handleGetConfigMessage: MessageHandler = async (context) => { method: targets.method, port: targets.port, internalPort: targets.internalPort, - enabled: targets.enabled, + enabled: targets.enabled }) .from(targets) .where( From 2c96eb78512d925dc993c6cb4fd9d0938cfb831f Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 14 Aug 2025 17:57:50 -0700 Subject: [PATCH 30/63] Adding and removing peers working; better axios errors --- server/hybridServer.ts | 54 +++++++++----------- server/lib/remoteTraefikConfig.ts | 83 +++++++++++++++++-------------- 2 files changed, 71 insertions(+), 66 deletions(-) diff --git a/server/hybridServer.ts b/server/hybridServer.ts index adf9ce25..c0d342cf 100644 --- a/server/hybridServer.ts +++ b/server/hybridServer.ts @@ -33,24 +33,24 @@ export async function createHybridClientServer() { // Register message handlers client.registerHandler("remoteExitNode/peers/add", async (message) => { - const { pubKey, allowedIps } = message.data; + const { publicKey, allowedIps } = message.data; // TODO: we are getting the exit node twice here // NOTE: there should only be one gerbil registered so... const [exitNode] = await db.select().from(exitNodes).limit(1); await addPeer(exitNode.exitNodeId, { - publicKey: pubKey, + publicKey: publicKey, allowedIps: allowedIps || [] }); }); client.registerHandler("remoteExitNode/peers/remove", async (message) => { - const { pubKey } = message.data; + const { publicKey } = message.data; // TODO: we are getting the exit node twice here // NOTE: there should only be one gerbil registered so... const [exitNode] = await db.select().from(exitNodes).limit(1); - await deletePeer(exitNode.exitNodeId, pubKey); + await deletePeer(exitNode.exitNodeId, publicKey); }); // /update-proxy-mapping @@ -65,21 +65,18 @@ export async function createHybridClientServer() { const response = await axios.post(`${exitNode.endpoint}/update-proxy-mapping`, message.data); logger.info(`Successfully updated proxy mapping: ${response.status}`); } catch (error) { - // Extract useful information from axios error without circular references - if (error && typeof error === 'object' && 'response' in error) { - const axiosError = error as any; - logger.error("Failed to update proxy mapping:", { - status: axiosError.response?.status, - statusText: axiosError.response?.statusText, - data: axiosError.response?.data, - message: axiosError.message, - url: axiosError.config?.url + // pull data out of the axios error to log + if (axios.isAxiosError(error)) { + logger.error("Error updating proxy mapping:", { + message: error.message, + code: error.code, + status: error.response?.status, + statusText: error.response?.statusText, + url: error.config?.url, + method: error.config?.method }); } else { - logger.error("Failed to update proxy mapping:", { - message: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined - }); + logger.error("Error updating proxy mapping:", error); } } }); @@ -96,21 +93,18 @@ export async function createHybridClientServer() { const response = await axios.post(`${exitNode.endpoint}/update-destinations`, message.data); logger.info(`Successfully updated destinations: ${response.status}`); } catch (error) { - // Extract useful information from axios error without circular references - if (error && typeof error === 'object' && 'response' in error) { - const axiosError = error as any; - logger.error("Failed to update destinations:", { - status: axiosError.response?.status, - statusText: axiosError.response?.statusText, - data: axiosError.response?.data, - message: axiosError.message, - url: axiosError.config?.url + // pull data out of the axios error to log + if (axios.isAxiosError(error)) { + logger.error("Error updating destinations:", { + message: error.message, + code: error.code, + status: error.response?.status, + statusText: error.response?.statusText, + url: error.config?.url, + method: error.config?.method }); } else { - logger.error("Failed to update proxy mapping:", { - message: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined - }); + logger.error("Error updating destinations:", error); } } }); diff --git a/server/lib/remoteTraefikConfig.ts b/server/lib/remoteTraefikConfig.ts index 36bdad24..88ea011a 100644 --- a/server/lib/remoteTraefikConfig.ts +++ b/server/lib/remoteTraefikConfig.ts @@ -87,8 +87,7 @@ export class TraefikConfigManager { public async HandleTraefikConfig(): Promise { try { // Get all active domains for this exit node via HTTP call - const getTraefikConfig = - await this.getTraefikConfig(); + const getTraefikConfig = await this.getTraefikConfig(); if (!getTraefikConfig) { logger.error( @@ -138,12 +137,32 @@ export class TraefikConfigManager { try { const [exitNode] = await db.select().from(exitNodes).limit(1); if (exitNode) { + try { + await axios.post( + `${exitNode.reachableAt}/update-local-snis`, + { fullDomains: Array.from(domains) }, + { headers: { "Content-Type": "application/json" } } + ); + } catch (error) { + // pull data out of the axios error to log + if (axios.isAxiosError(error)) { + logger.error("Error updating local SNI:", { + message: error.message, + code: error.code, + status: error.response?.status, + statusText: error.response?.statusText, + url: error.config?.url, + method: error.config?.method + }); + } else { + logger.error( + "Error updating local SNI:", + error + ); + } + } + } else { logger.error("No exit node found"); - await axios.post( - `${exitNode.reachableAt}/update-local-snis`, - { fullDomains: Array.from(domains) }, - { headers: { "Content-Type": "application/json" } } - ); } } catch (err) { logger.error("Failed to post domains to SNI proxy:", err); @@ -199,22 +218,19 @@ export class TraefikConfigManager { ); return { domains, traefikConfig }; - } catch (err) { - // Extract useful information from axios error without circular references - if (err && typeof err === 'object' && 'response' in err) { - const axiosError = err as any; - logger.error("Failed to fetch traefik config:", { - status: axiosError.response?.status, - statusText: axiosError.response?.statusText, - data: axiosError.response?.data, - message: axiosError.message, - url: axiosError.config?.url + } catch (error) { + // pull data out of the axios error to log + if (axios.isAxiosError(error)) { + logger.error("Error fetching traefik config:", { + message: error.message, + code: error.code, + status: error.response?.status, + statusText: error.response?.statusText, + url: error.config?.url, + method: error.config?.method }); } else { - logger.error("Failed to fetch traefik config:", { - message: err instanceof Error ? err.message : String(err), - stack: err instanceof Error ? err.stack : undefined - }); + logger.error("Error fetching traefik config:", error); } return null; } @@ -303,23 +319,18 @@ export class TraefikConfigManager { return response.data.data; } catch (error) { - // Extract useful information from axios error without circular references - if (error && typeof error === 'object' && 'response' in error) { - const axiosError = error as any; - logger.error("Error fetching certificates for domains:", { - status: axiosError.response?.status, - statusText: axiosError.response?.statusText, - data: axiosError.response?.data, - message: axiosError.message, - url: axiosError.config?.url, - domains: domainArray + // pull data out of the axios error to log + if (axios.isAxiosError(error)) { + logger.error("Error getting certificates:", { + message: error.message, + code: error.code, + status: error.response?.status, + statusText: error.response?.statusText, + url: error.config?.url, + method: error.config?.method }); } else { - logger.error("Error fetching certificates for domains:", { - message: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - domains: domainArray - }); + logger.error("Error getting certificates:", error); } return []; } From f9184cf489553e39ab919efb8e14aaa35ee85271 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 14 Aug 2025 20:30:07 -0700 Subject: [PATCH 31/63] Handle badger config correctly --- server/lib/remoteTraefikConfig.ts | 37 +++++++++++--- server/routers/traefik/getTraefikConfig.ts | 57 +++++++++++----------- 2 files changed, 59 insertions(+), 35 deletions(-) diff --git a/server/lib/remoteTraefikConfig.ts b/server/lib/remoteTraefikConfig.ts index 88ea011a..bca59ab2 100644 --- a/server/lib/remoteTraefikConfig.ts +++ b/server/lib/remoteTraefikConfig.ts @@ -213,9 +213,34 @@ export class TraefikConfigManager { } } - logger.debug( - `Successfully retrieved traefik config: ${JSON.stringify(traefikConfig)}` - ); + const badgerMiddlewareName = "badger"; + traefikConfig.http.middlewares[badgerMiddlewareName] = { + plugin: { + [badgerMiddlewareName]: { + apiBaseUrl: new URL( + "/api/v0", + `http://${ + config.getRawConfig().server.internal_hostname + }:${config.getRawConfig().server.internal_port}` + ).href, + userSessionCookieName: + config.getRawConfig().server.session_cookie_name, + + // deprecated + accessTokenQueryParam: + config.getRawConfig().server + .resource_access_token_param, + + resourceSessionRequestParam: + config.getRawConfig().server + .resource_session_request_param + } + } + }; + + // logger.debug( + // `Successfully retrieved traefik config: ${JSON.stringify(traefikConfig)}` + // ); return { domains, traefikConfig }; } catch (error) { @@ -313,9 +338,9 @@ export class TraefikConfigManager { return []; } - logger.debug( - `Successfully retrieved ${response.data.data?.length || 0} certificates for ${domainArray.length} domains` - ); + // logger.debug( + // `Successfully retrieved ${response.data.data?.length || 0} certificates for ${domainArray.length} domains` + // ); return response.data.data; } catch (error) { diff --git a/server/routers/traefik/getTraefikConfig.ts b/server/routers/traefik/getTraefikConfig.ts index 89afee2c..a0e22acb 100644 --- a/server/routers/traefik/getTraefikConfig.ts +++ b/server/routers/traefik/getTraefikConfig.ts @@ -8,6 +8,8 @@ import { orgs, resources, sites, Target, targets } from "@server/db"; import { build } from "@server/build"; let currentExitNodeId: number; +const redirectHttpsMiddlewareName = "redirect-to-https"; +const badgerMiddlewareName = "badger"; export async function traefikConfigProvider( _: Request, @@ -43,7 +45,32 @@ export async function traefikConfigProvider( } } - const traefikConfig = await getTraefikConfig(currentExitNodeId); + let traefikConfig = await getTraefikConfig(currentExitNodeId); + + traefikConfig.http.middlewares[badgerMiddlewareName] = { + plugin: { + [badgerMiddlewareName]: { + apiBaseUrl: new URL( + "/api/v0", + `http://${ + config.getRawConfig().server.internal_hostname + }:${config.getRawConfig().server.internal_port}` + ).href, + userSessionCookieName: + config.getRawConfig().server.session_cookie_name, + + // deprecated + accessTokenQueryParam: + config.getRawConfig().server + .resource_access_token_param, + + resourceSessionRequestParam: + config.getRawConfig().server + .resource_session_request_param + } + } + }; + return res.status(HttpCode.OK).json(traefikConfig); } catch (e) { logger.error(`Failed to build Traefik config: ${e}`); @@ -132,37 +159,9 @@ export async function getTraefikConfig(exitNodeId: number): Promise { return {}; } - const badgerMiddlewareName = "badger"; - const redirectHttpsMiddlewareName = "redirect-to-https"; - const config_output: any = { http: { middlewares: { - [badgerMiddlewareName]: { - plugin: { - [badgerMiddlewareName]: { - apiBaseUrl: new URL( - "/api/v1", - `http://${ - config.getRawConfig().server - .internal_hostname - }:${config.getRawConfig().server.internal_port}` - ).href, - userSessionCookieName: - config.getRawConfig().server - .session_cookie_name, - - // deprecated - accessTokenQueryParam: - config.getRawConfig().server - .resource_access_token_param, - - resourceSessionRequestParam: - config.getRawConfig().server - .resource_session_request_param - } - } - }, [redirectHttpsMiddlewareName]: { redirectScheme: { scheme: "https" From 825bff5d6018ab2a529e97fb039aedcc52338e80 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 14 Aug 2025 21:48:14 -0700 Subject: [PATCH 32/63] Badger & traefik working now? --- server/auth/sessions/resource.ts | 26 ++++++ server/db/queries/verifySessionQueries.ts | 104 +++++++++++++++++---- server/lib/readConfigFile.ts | 3 +- server/lib/remoteTraefikConfig.ts | 2 +- server/routers/badger/verifySession.ts | 12 ++- server/routers/internal.ts | 2 +- server/routers/traefik/getTraefikConfig.ts | 2 +- 7 files changed, 126 insertions(+), 25 deletions(-) diff --git a/server/auth/sessions/resource.ts b/server/auth/sessions/resource.ts index f29a8b75..8d676bec 100644 --- a/server/auth/sessions/resource.ts +++ b/server/auth/sessions/resource.ts @@ -4,6 +4,9 @@ import { resourceSessions, ResourceSession } from "@server/db"; import { db } from "@server/db"; import { eq, and } from "drizzle-orm"; import config from "@server/lib/config"; +import axios from "axios"; +import logger from "@server/logger"; +import { tokenManager } from "@server/lib/tokenManager"; export const SESSION_COOKIE_NAME = config.getRawConfig().server.session_cookie_name; @@ -62,6 +65,29 @@ export async function validateResourceSessionToken( token: string, resourceId: number ): Promise { + if (config.isHybridMode()) { + try { + const response = await axios.post(`${config.getRawConfig().hybrid?.endpoint}/api/v1/hybrid/resource/${resourceId}/session/validate`, { + token: token + }, await tokenManager.getAuthHeader()); + return response.data.data; + } catch (error) { + if (axios.isAxiosError(error)) { + logger.error("Error validating resource session token in hybrid mode:", { + message: error.message, + code: error.code, + status: error.response?.status, + statusText: error.response?.statusText, + url: error.config?.url, + method: error.config?.method + }); + } else { + logger.error("Error validating resource session token in hybrid mode:", error); + } + return { resourceSession: null }; + } + } + const sessionId = encodeHexLowerCase( sha256(new TextEncoder().encode(token)) ); diff --git a/server/db/queries/verifySessionQueries.ts b/server/db/queries/verifySessionQueries.ts index 44982f64..4c800125 100644 --- a/server/db/queries/verifySessionQueries.ts +++ b/server/db/queries/verifySessionQueries.ts @@ -17,6 +17,8 @@ import { import { and, eq } from "drizzle-orm"; import axios from "axios"; import config from "@server/lib/config"; +import logger from "@server/logger"; +import { tokenManager } from "@server/lib/tokenManager"; export type ResourceWithAuth = { resource: Resource | null; @@ -37,10 +39,21 @@ export async function getResourceByDomain( ): Promise { if (config.isHybridMode()) { try { - const response = await axios.get(`${config.getRawConfig().hybrid?.endpoint}/resource/domain/${domain}`); - return response.data; + const response = await axios.get(`${config.getRawConfig().hybrid?.endpoint}/api/v1/hybrid/resource/domain/${domain}`, await tokenManager.getAuthHeader()); + return response.data.data; } catch (error) { - console.error("Error fetching resource by domain:", error); + if (axios.isAxiosError(error)) { + logger.error("Error fetching config in verify session:", { + message: error.message, + code: error.code, + status: error.response?.status, + statusText: error.response?.statusText, + url: error.config?.url, + method: error.config?.method + }); + } else { + logger.error("Error fetching config in verify session:", error); + } return null; } } @@ -78,10 +91,21 @@ export async function getUserSessionWithUser( ): Promise { if (config.isHybridMode()) { try { - const response = await axios.get(`${config.getRawConfig().hybrid?.endpoint}/session/${userSessionId}`); - return response.data; + const response = await axios.get(`${config.getRawConfig().hybrid?.endpoint}/api/v1/hybrid/session/${userSessionId}`, await tokenManager.getAuthHeader()); + return response.data.data; } catch (error) { - console.error("Error fetching user session:", error); + if (axios.isAxiosError(error)) { + logger.error("Error fetching config in verify session:", { + message: error.message, + code: error.code, + status: error.response?.status, + statusText: error.response?.statusText, + url: error.config?.url, + method: error.config?.method + }); + } else { + logger.error("Error fetching config in verify session:", error); + } return null; } } @@ -108,10 +132,21 @@ export async function getUserSessionWithUser( export async function getUserOrgRole(userId: string, orgId: string) { if (config.isHybridMode()) { try { - const response = await axios.get(`${config.getRawConfig().hybrid?.endpoint}/user/${userId}/org/${orgId}/role`); - return response.data; + const response = await axios.get(`${config.getRawConfig().hybrid?.endpoint}/api/v1/hybrid/user/${userId}/org/${orgId}/role`, await tokenManager.getAuthHeader()); + return response.data.data; } catch (error) { - console.error("Error fetching user org role:", error); + if (axios.isAxiosError(error)) { + logger.error("Error fetching config in verify session:", { + message: error.message, + code: error.code, + status: error.response?.status, + statusText: error.response?.statusText, + url: error.config?.url, + method: error.config?.method + }); + } else { + logger.error("Error fetching config in verify session:", error); + } return null; } } @@ -136,10 +171,21 @@ export async function getUserOrgRole(userId: string, orgId: string) { export async function getRoleResourceAccess(resourceId: number, roleId: number) { if (config.isHybridMode()) { try { - const response = await axios.get(`${config.getRawConfig().hybrid?.endpoint}/role/${roleId}/resource/${resourceId}/access`); - return response.data; + const response = await axios.get(`${config.getRawConfig().hybrid?.endpoint}/api/v1/hybrid/role/${roleId}/resource/${resourceId}/access`, await tokenManager.getAuthHeader()); + return response.data.data; } catch (error) { - console.error("Error fetching role resource access:", error); + if (axios.isAxiosError(error)) { + logger.error("Error fetching config in verify session:", { + message: error.message, + code: error.code, + status: error.response?.status, + statusText: error.response?.statusText, + url: error.config?.url, + method: error.config?.method + }); + } else { + logger.error("Error fetching config in verify session:", error); + } return null; } } @@ -164,10 +210,21 @@ export async function getRoleResourceAccess(resourceId: number, roleId: number) export async function getUserResourceAccess(userId: string, resourceId: number) { if (config.isHybridMode()) { try { - const response = await axios.get(`${config.getRawConfig().hybrid?.endpoint}/user/${userId}/resource/${resourceId}/access`); - return response.data; + const response = await axios.get(`${config.getRawConfig().hybrid?.endpoint}/api/v1/hybrid/user/${userId}/resource/${resourceId}/access`, await tokenManager.getAuthHeader()); + return response.data.data; } catch (error) { - console.error("Error fetching user resource access:", error); + if (axios.isAxiosError(error)) { + logger.error("Error fetching config in verify session:", { + message: error.message, + code: error.code, + status: error.response?.status, + statusText: error.response?.statusText, + url: error.config?.url, + method: error.config?.method + }); + } else { + logger.error("Error fetching config in verify session:", error); + } return null; } } @@ -192,10 +249,21 @@ export async function getUserResourceAccess(userId: string, resourceId: number) export async function getResourceRules(resourceId: number): Promise { if (config.isHybridMode()) { try { - const response = await axios.get(`${config.getRawConfig().hybrid?.endpoint}/resource/${resourceId}/rules`); - return response.data; + const response = await axios.get(`${config.getRawConfig().hybrid?.endpoint}/api/v1/hybrid/resource/${resourceId}/rules`, await tokenManager.getAuthHeader()); + return response.data.data; } catch (error) { - console.error("Error fetching resource rules:", error); + if (axios.isAxiosError(error)) { + logger.error("Error fetching config in verify session:", { + message: error.message, + code: error.code, + status: error.response?.status, + statusText: error.response?.statusText, + url: error.config?.url, + method: error.config?.method + }); + } else { + logger.error("Error fetching config in verify session:", error); + } return []; } } diff --git a/server/lib/readConfigFile.ts b/server/lib/readConfigFile.ts index 5fb7b955..93a716c5 100644 --- a/server/lib/readConfigFile.ts +++ b/server/lib/readConfigFile.ts @@ -32,7 +32,8 @@ export const configSchema = z .object({ id: z.string().optional(), secret: z.string().optional(), - endpoint: z.string().optional() + endpoint: z.string().optional(), + redirect_endpoint: z.string().optional() }) .optional(), domains: z diff --git a/server/lib/remoteTraefikConfig.ts b/server/lib/remoteTraefikConfig.ts index bca59ab2..e192ab67 100644 --- a/server/lib/remoteTraefikConfig.ts +++ b/server/lib/remoteTraefikConfig.ts @@ -218,7 +218,7 @@ export class TraefikConfigManager { plugin: { [badgerMiddlewareName]: { apiBaseUrl: new URL( - "/api/v0", + "/api/v1", `http://${ config.getRawConfig().server.internal_hostname }:${config.getRawConfig().server.internal_port}` diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index 54a2e0c9..50b9ed68 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -140,7 +140,7 @@ export async function verifyResourceSession( const result = await getResourceByDomain(cleanHost); if (!result) { - logger.debug("Resource not found", cleanHost); + logger.debug(`Resource not found ${cleanHost}`); return notAllowed(res); } @@ -151,7 +151,7 @@ export async function verifyResourceSession( const { resource, pincode, password } = resourceData; if (!resource) { - logger.debug("Resource not found", cleanHost); + logger.debug(`Resource not found ${cleanHost}`); return notAllowed(res); } @@ -191,7 +191,13 @@ export async function verifyResourceSession( return allowed(res); } - const redirectUrl = `${config.getRawConfig().app.dashboard_url}/auth/resource/${encodeURIComponent( + let endpoint: string; + if (config.isHybridMode()) { + endpoint = config.getRawConfig().hybrid?.redirect_endpoint || config.getRawConfig().hybrid?.endpoint || ""; + } else { + endpoint = config.getRawConfig().app.dashboard_url; + } + const redirectUrl = `${endpoint}/auth/resource/${encodeURIComponent( resource.resourceId )}?redirect=${encodeURIComponent(originalRequestURL)}`; diff --git a/server/routers/internal.ts b/server/routers/internal.ts index 977248e5..d19355b7 100644 --- a/server/routers/internal.ts +++ b/server/routers/internal.ts @@ -87,7 +87,7 @@ badgerRouter.post("/verify-session", badger.verifyResourceSession); if (config.isHybridMode()) { badgerRouter.post("/exchange-session", (req, res, next) => - proxyToRemote(req, res, next, "badger/exchange-session") + proxyToRemote(req, res, next, "hybrid/badger/exchange-session") ); } else { badgerRouter.post("/exchange-session", badger.exchangeSession); diff --git a/server/routers/traefik/getTraefikConfig.ts b/server/routers/traefik/getTraefikConfig.ts index a0e22acb..ac1369c9 100644 --- a/server/routers/traefik/getTraefikConfig.ts +++ b/server/routers/traefik/getTraefikConfig.ts @@ -51,7 +51,7 @@ export async function traefikConfigProvider( plugin: { [badgerMiddlewareName]: { apiBaseUrl: new URL( - "/api/v0", + "/api/v1", `http://${ config.getRawConfig().server.internal_hostname }:${config.getRawConfig().server.internal_port}` From 2fea091e1f08b7a8b740aab66473504627b75eef Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 15 Aug 2025 12:24:54 -0700 Subject: [PATCH 33/63] Move newt version --- .../routers/newt/handleNewtRegisterMessage.ts | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/server/routers/newt/handleNewtRegisterMessage.ts b/server/routers/newt/handleNewtRegisterMessage.ts index 71a6fd5c..bb982c24 100644 --- a/server/routers/newt/handleNewtRegisterMessage.ts +++ b/server/routers/newt/handleNewtRegisterMessage.ts @@ -64,16 +64,6 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { exitNodeId = bestPingResult.exitNodeId; } - if (newtVersion) { - // update the newt version in the database - await db - .update(newts) - .set({ - version: newtVersion as string - }) - .where(eq(newts.newtId, newt.newtId)); - } - const [oldSite] = await db .select() .from(sites) @@ -160,6 +150,16 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { allowedIps: [siteSubnet] }); + if (newtVersion && newtVersion !== newt.version) { + // update the newt version in the database + await db + .update(newts) + .set({ + version: newtVersion as string + }) + .where(eq(newts.newtId, newt.newtId)); + } + // Improved version const allResources = await db.transaction(async (tx) => { // First get all resources for the site From 69a9bcb3da20d99ca355cb171bbb1ce90ee01816 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 15 Aug 2025 15:34:31 -0700 Subject: [PATCH 34/63] Add exit node helper functions --- server/lib/exitNodes/exitNodes.ts | 43 +++++++++++++++++++++++++++++++ server/lib/exitNodes/index.ts | 1 + 2 files changed, 44 insertions(+) create mode 100644 server/lib/exitNodes/exitNodes.ts create mode 100644 server/lib/exitNodes/index.ts diff --git a/server/lib/exitNodes/exitNodes.ts b/server/lib/exitNodes/exitNodes.ts new file mode 100644 index 00000000..7b25873e --- /dev/null +++ b/server/lib/exitNodes/exitNodes.ts @@ -0,0 +1,43 @@ +import { db, exitNodes } from "@server/db"; +import logger from "@server/logger"; +import { eq, and, or } from "drizzle-orm"; + +export async function privateVerifyExitNodeOrgAccess( + exitNodeId: number, + orgId: string +) { + const [exitNode] = await db + .select() + .from(exitNodes) + .where(eq(exitNodes.exitNodeId, exitNodeId)); + + // For any other type, deny access + return { hasAccess: true, exitNode }; +} + +export async function listExitNodes(orgId: string, filterOnline = false) { + // TODO: pick which nodes to send and ping better than just all of them that are not remote + const allExitNodes = await db + .select({ + exitNodeId: exitNodes.exitNodeId, + name: exitNodes.name, + address: exitNodes.address, + endpoint: exitNodes.endpoint, + publicKey: exitNodes.publicKey, + listenPort: exitNodes.listenPort, + reachableAt: exitNodes.reachableAt, + maxConnections: exitNodes.maxConnections, + online: exitNodes.online, + lastPing: exitNodes.lastPing, + type: exitNodes.type, + }) + .from(exitNodes); + + // Filter the nodes. If there are NO remoteExitNodes then do nothing. If there are then remove all of the non-remoteExitNodes + if (allExitNodes.length === 0) { + logger.warn("No exit nodes found!"); + return []; + } + + return allExitNodes; +} diff --git a/server/lib/exitNodes/index.ts b/server/lib/exitNodes/index.ts new file mode 100644 index 00000000..b29bce93 --- /dev/null +++ b/server/lib/exitNodes/index.ts @@ -0,0 +1 @@ +export * from "./privateExitNodes"; \ No newline at end of file From 5c94887949a5e6e2be68643b13e5ece0cc51a6d1 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 15 Aug 2025 15:45:45 -0700 Subject: [PATCH 35/63] Use new exit node functions --- server/lib/exitNodes/exitNodes.ts | 2 +- server/routers/client/createClient.ts | 20 +++------- .../newt/handleNewtPingRequestMessage.ts | 24 +++++++---- .../routers/newt/handleNewtRegisterMessage.ts | 25 ++++++++---- server/routers/site/createSite.ts | 29 +++++++++++++- server/routers/site/pickSiteDefaults.ts | 40 +++++++++---------- 6 files changed, 87 insertions(+), 53 deletions(-) diff --git a/server/lib/exitNodes/exitNodes.ts b/server/lib/exitNodes/exitNodes.ts index 7b25873e..f5854e27 100644 --- a/server/lib/exitNodes/exitNodes.ts +++ b/server/lib/exitNodes/exitNodes.ts @@ -2,7 +2,7 @@ import { db, exitNodes } from "@server/db"; import logger from "@server/logger"; import { eq, and, or } from "drizzle-orm"; -export async function privateVerifyExitNodeOrgAccess( +export async function verifyExitNodeOrgAccess( exitNodeId: number, orgId: string ) { diff --git a/server/routers/client/createClient.ts b/server/routers/client/createClient.ts index 4e9dcdce..e7762223 100644 --- a/server/routers/client/createClient.ts +++ b/server/routers/client/createClient.ts @@ -24,6 +24,7 @@ import { hashPassword } from "@server/auth/password"; import { isValidCIDR, isValidIP } from "@server/lib/validators"; import { isIpInCidr } from "@server/lib/ip"; import { OpenAPITags, registry } from "@server/openApi"; +import { listExitNodes } from "@server/lib/exitNodes"; const createClientParamsSchema = z .object({ @@ -177,20 +178,9 @@ export async function createClient( await db.transaction(async (trx) => { // TODO: more intelligent way to pick the exit node - - // make sure there is an exit node by counting the exit nodes table - const nodes = await db.select().from(exitNodes); - if (nodes.length === 0) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - "No exit nodes available" - ) - ); - } - - // get the first exit node - const exitNode = nodes[0]; + const exitNodesList = await listExitNodes(orgId); + const randomExitNode = + exitNodesList[Math.floor(Math.random() * exitNodesList.length)]; const adminRole = await trx .select() @@ -208,7 +198,7 @@ export async function createClient( const [newClient] = await trx .insert(clients) .values({ - exitNodeId: exitNode.exitNodeId, + exitNodeId: randomExitNode.exitNodeId, orgId, name, subnet: updatedSubnet, diff --git a/server/routers/newt/handleNewtPingRequestMessage.ts b/server/routers/newt/handleNewtPingRequestMessage.ts index 65edea61..f93862f6 100644 --- a/server/routers/newt/handleNewtPingRequestMessage.ts +++ b/server/routers/newt/handleNewtPingRequestMessage.ts @@ -4,6 +4,7 @@ import { exitNodes, Newt } from "@server/db"; import logger from "@server/logger"; import config from "@server/lib/config"; import { ne, eq, or, and, count } from "drizzle-orm"; +import { listExitNodes } from "@server/lib/exitNodes"; export const handleNewtPingRequestMessage: MessageHandler = async (context) => { const { message, client, sendToClient } = context; @@ -16,12 +17,19 @@ export const handleNewtPingRequestMessage: MessageHandler = async (context) => { return; } - // TODO: pick which nodes to send and ping better than just all of them - let exitNodesList = await db - .select() - .from(exitNodes); + // Get the newt's orgId through the site relationship + if (!newt.siteId) { + logger.warn("Newt siteId not found"); + return; + } - exitNodesList = exitNodesList.filter((node) => node.maxConnections !== 0); + const [site] = await db + .select({ orgId: sites.orgId }) + .from(sites) + .where(eq(sites.siteId, newt.siteId)) + .limit(1); + + const exitNodesList = await listExitNodes(site.orgId, true); // filter for only the online ones let lastExitNodeId = null; if (newt.siteId) { @@ -54,9 +62,9 @@ export const handleNewtPingRequestMessage: MessageHandler = async (context) => { ) ); - if (currentConnections.count >= maxConnections) { - return null; - } + if (currentConnections.count >= maxConnections) { + return null; + } weight = (maxConnections - currentConnections.count) / diff --git a/server/routers/newt/handleNewtRegisterMessage.ts b/server/routers/newt/handleNewtRegisterMessage.ts index bb982c24..26aa3477 100644 --- a/server/routers/newt/handleNewtRegisterMessage.ts +++ b/server/routers/newt/handleNewtRegisterMessage.ts @@ -9,6 +9,7 @@ import { findNextAvailableCidr, getNextAvailableClientSubnet } from "@server/lib/ip"; +import { verifyExitNodeOrgAccess } from "@server/lib/exitNodes"; export type ExitNodePingResult = { exitNodeId: number; @@ -24,7 +25,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { const { message, client, sendToClient } = context; const newt = client as Newt; - logger.info("Handling register newt message!"); + logger.debug("Handling register newt message!"); if (!newt) { logger.warn("Newt not found"); @@ -81,6 +82,18 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { // This effectively moves the exit node to the new one exitNodeIdToQuery = exitNodeId; // Use the provided exitNodeId if it differs from the site's exitNodeId + const { exitNode, hasAccess } = await verifyExitNodeOrgAccess(exitNodeIdToQuery, oldSite.orgId); + + if (!exitNode) { + logger.warn("Exit node not found"); + return; + } + + if (!hasAccess) { + logger.warn("Not authorized to use this exit node"); + return; + } + const sitesQuery = await db .select({ subnet: sites.subnet @@ -88,14 +101,10 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { .from(sites) .where(eq(sites.exitNodeId, exitNodeId)); - const [exitNode] = await db - .select() - .from(exitNodes) - .where(eq(exitNodes.exitNodeId, exitNodeIdToQuery)) - .limit(1); - const blockSize = config.getRawConfig().gerbil.site_block_size; - const subnets = sitesQuery.map((site) => site.subnet).filter((subnet) => subnet !== null); + const subnets = sitesQuery + .map((site) => site.subnet) + .filter((subnet) => subnet !== null); subnets.push(exitNode.address.replace(/\/\d+$/, `/${blockSize}`)); const newSubnet = findNextAvailableCidr( subnets, diff --git a/server/routers/site/createSite.ts b/server/routers/site/createSite.ts index fb1170cd..af8e4073 100644 --- a/server/routers/site/createSite.ts +++ b/server/routers/site/createSite.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { clients, db } from "@server/db"; +import { clients, db, exitNodes } from "@server/db"; import { roles, userSites, sites, roleSites, Site, orgs } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -17,6 +17,7 @@ import { hashPassword } from "@server/auth/password"; import { isValidIP } from "@server/lib/validators"; import { isIpInCidr } from "@server/lib/ip"; import config from "@server/lib/config"; +import { verifyExitNodeOrgAccess } from "@server/lib/exitNodes"; const createSiteParamsSchema = z .object({ @@ -217,6 +218,32 @@ export async function createSite( ); } + const { exitNode, hasAccess } = + await verifyExitNodeOrgAccess( + exitNodeId, + orgId + ); + + if (!exitNode) { + logger.warn("Exit node not found"); + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Exit node not found" + ) + ); + } + + if (!hasAccess) { + logger.warn("Not authorized to use this exit node"); + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Not authorized to use this exit node" + ) + ); + } + [newSite] = await trx .insert(sites) .values({ diff --git a/server/routers/site/pickSiteDefaults.ts b/server/routers/site/pickSiteDefaults.ts index d6309d0c..2e705c56 100644 --- a/server/routers/site/pickSiteDefaults.ts +++ b/server/routers/site/pickSiteDefaults.ts @@ -6,12 +6,16 @@ import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; -import { findNextAvailableCidr, getNextAvailableClientSubnet } from "@server/lib/ip"; +import { + findNextAvailableCidr, + getNextAvailableClientSubnet +} from "@server/lib/ip"; import { generateId } from "@server/auth/sessions/app"; import config from "@server/lib/config"; import { OpenAPITags, registry } from "@server/openApi"; import { fromError } from "zod-validation-error"; import { z } from "zod"; +import { listExitNodes } from "@server/lib/exitNodes"; export type PickSiteDefaultsResponse = { exitNodeId: number; @@ -65,16 +69,10 @@ export async function pickSiteDefaults( const { orgId } = parsedParams.data; // TODO: more intelligent way to pick the exit node - // make sure there is an exit node by counting the exit nodes table - const nodes = await db.select().from(exitNodes); - if (nodes.length === 0) { - return next( - createHttpError(HttpCode.NOT_FOUND, "No exit nodes available") - ); - } + const exitNodesList = await listExitNodes(orgId); - // get the first exit node - const exitNode = nodes[0]; + const randomExitNode = + exitNodesList[Math.floor(Math.random() * exitNodesList.length)]; // TODO: this probably can be optimized... // list all of the sites on that exit node @@ -83,13 +81,15 @@ export async function pickSiteDefaults( subnet: sites.subnet }) .from(sites) - .where(eq(sites.exitNodeId, exitNode.exitNodeId)); + .where(eq(sites.exitNodeId, randomExitNode.exitNodeId)); // TODO: we need to lock this subnet for some time so someone else does not take it - const subnets = sitesQuery.map((site) => site.subnet).filter((subnet) => subnet !== null); + const subnets = sitesQuery + .map((site) => site.subnet) + .filter((subnet) => subnet !== null); // exclude the exit node address by replacing after the / with a site block size subnets.push( - exitNode.address.replace( + randomExitNode.address.replace( /\/\d+$/, `/${config.getRawConfig().gerbil.site_block_size}` ) @@ -97,7 +97,7 @@ export async function pickSiteDefaults( const newSubnet = findNextAvailableCidr( subnets, config.getRawConfig().gerbil.site_block_size, - exitNode.address + randomExitNode.address ); if (!newSubnet) { return next( @@ -125,12 +125,12 @@ export async function pickSiteDefaults( return response(res, { data: { - exitNodeId: exitNode.exitNodeId, - address: exitNode.address, - publicKey: exitNode.publicKey, - name: exitNode.name, - listenPort: exitNode.listenPort, - endpoint: exitNode.endpoint, + exitNodeId: randomExitNode.exitNodeId, + address: randomExitNode.address, + publicKey: randomExitNode.publicKey, + name: randomExitNode.name, + listenPort: randomExitNode.listenPort, + endpoint: randomExitNode.endpoint, // subnet: `${newSubnet.split("/")[0]}/${config.getRawConfig().gerbil.block_size}`, // we want the block size of the whole subnet subnet: newSubnet, clientAddress: clientAddress, From 21ce678e5b8a1be75c25224295088d6b36b830c6 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 15 Aug 2025 15:52:09 -0700 Subject: [PATCH 36/63] Move exit node function --- server/lib/exitNodes/exitNodes.ts | 12 ++++++++++++ server/routers/newt/handleNewtRegisterMessage.ts | 15 ++------------- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/server/lib/exitNodes/exitNodes.ts b/server/lib/exitNodes/exitNodes.ts index f5854e27..f49b9cdb 100644 --- a/server/lib/exitNodes/exitNodes.ts +++ b/server/lib/exitNodes/exitNodes.ts @@ -1,5 +1,6 @@ import { db, exitNodes } from "@server/db"; import logger from "@server/logger"; +import { ExitNodePingResult } from "@server/routers/newt"; import { eq, and, or } from "drizzle-orm"; export async function verifyExitNodeOrgAccess( @@ -41,3 +42,14 @@ export async function listExitNodes(orgId: string, filterOnline = false) { return allExitNodes; } + +export function selectBestExitNode( + pingResults: ExitNodePingResult[] +): ExitNodePingResult | null { + if (!pingResults || pingResults.length === 0) { + logger.warn("No ping results provided"); + return null; + } + + return pingResults[0]; +} diff --git a/server/routers/newt/handleNewtRegisterMessage.ts b/server/routers/newt/handleNewtRegisterMessage.ts index 26aa3477..b274a474 100644 --- a/server/routers/newt/handleNewtRegisterMessage.ts +++ b/server/routers/newt/handleNewtRegisterMessage.ts @@ -9,7 +9,7 @@ import { findNextAvailableCidr, getNextAvailableClientSubnet } from "@server/lib/ip"; -import { verifyExitNodeOrgAccess } from "@server/lib/exitNodes"; +import { selectBestExitNode, verifyExitNodeOrgAccess } from "@server/lib/exitNodes"; export type ExitNodePingResult = { exitNodeId: number; @@ -265,15 +265,4 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { broadcast: false, // Send to all clients excludeSender: false // Include sender in broadcast }; -}; - -function selectBestExitNode( - pingResults: ExitNodePingResult[] -): ExitNodePingResult | null { - if (!pingResults || pingResults.length === 0) { - logger.warn("No ping results provided"); - return null; - } - - return pingResults[0]; -} +}; \ No newline at end of file From e043d0e654093752bf62feab9df7f556f9c96c82 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 15 Aug 2025 15:59:38 -0700 Subject: [PATCH 37/63] Use new function --- server/routers/olm/handleOlmRegisterMessage.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/routers/olm/handleOlmRegisterMessage.ts b/server/routers/olm/handleOlmRegisterMessage.ts index 64443e07..c892b051 100644 --- a/server/routers/olm/handleOlmRegisterMessage.ts +++ b/server/routers/olm/handleOlmRegisterMessage.ts @@ -4,6 +4,7 @@ import { clients, clientSites, exitNodes, Olm, olms, sites } from "@server/db"; import { and, eq, inArray } from "drizzle-orm"; import { addPeer, deletePeer } from "../newt/peers"; import logger from "@server/logger"; +import { listExitNodes } from "@server/lib/exitNodes"; export const handleOlmRegisterMessage: MessageHandler = async (context) => { logger.info("Handling register olm message!"); @@ -48,7 +49,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { // TODO: FOR NOW WE ARE JUST HOLEPUNCHING ALL EXIT NODES BUT IN THE FUTURE WE SHOULD HANDLE THIS BETTER // Get the exit node - const allExitNodes = await db.select().from(exitNodes); + const allExitNodes = await listExitNodes(client.orgId, true); // FILTER THE ONLINE ONES const exitNodesHpData = allExitNodes.map((exitNode: ExitNode) => { return { From e73383cc79a05922fe313b4c72971b620e4cd090 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 15 Aug 2025 16:53:30 -0700 Subject: [PATCH 38/63] Add auth to gerbil calls --- server/lib/exitNodes/exitNodes.ts | 7 ++++ server/routers/gerbil/receiveBandwidth.ts | 44 +++++++++++++++++++---- server/routers/gerbil/updateHolePunch.ts | 23 +++++++++++- 3 files changed, 67 insertions(+), 7 deletions(-) diff --git a/server/lib/exitNodes/exitNodes.ts b/server/lib/exitNodes/exitNodes.ts index f49b9cdb..f607371d 100644 --- a/server/lib/exitNodes/exitNodes.ts +++ b/server/lib/exitNodes/exitNodes.ts @@ -53,3 +53,10 @@ export function selectBestExitNode( return pingResults[0]; } + +export async function checkExitNodeOrg( + exitNodeId: number, + orgId: string +) { + return false; +} \ No newline at end of file diff --git a/server/routers/gerbil/receiveBandwidth.ts b/server/routers/gerbil/receiveBandwidth.ts index 350228ec..fb7723ee 100644 --- a/server/routers/gerbil/receiveBandwidth.ts +++ b/server/routers/gerbil/receiveBandwidth.ts @@ -6,6 +6,7 @@ import logger from "@server/logger"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import response from "@server/lib/response"; +import { checkExitNodeOrg } from "@server/lib/exitNodes"; // Track sites that are already offline to avoid unnecessary queries const offlineSites = new Set(); @@ -48,7 +49,10 @@ export const receiveBandwidth = async ( } }; -export async function updateSiteBandwidth(bandwidthData: PeerBandwidth[]) { +export async function updateSiteBandwidth( + bandwidthData: PeerBandwidth[], + exitNodeId?: number +) { const currentTime = new Date(); const oneMinuteAgo = new Date(currentTime.getTime() - 60000); // 1 minute ago @@ -69,7 +73,7 @@ export async function updateSiteBandwidth(bandwidthData: PeerBandwidth[]) { // Update all active sites with bandwidth data and get the site data in one operation const updatedSites = []; for (const peer of activePeers) { - const updatedSite = await trx + const [updatedSite] = await trx .update(sites) .set({ megabytesOut: sql`${sites.megabytesOut} + ${peer.bytesIn}`, @@ -85,8 +89,19 @@ export async function updateSiteBandwidth(bandwidthData: PeerBandwidth[]) { lastBandwidthUpdate: sites.lastBandwidthUpdate }); - if (updatedSite.length > 0) { - updatedSites.push({ ...updatedSite[0], peer }); + if (exitNodeId) { + if (await checkExitNodeOrg(exitNodeId, updatedSite.orgId)) { + // not allowed + logger.warn( + `Exit node ${exitNodeId} is not allowed for org ${updatedSite.orgId}` + ); + // THIS SHOULD TRIGGER THE TRANSACTION TO FAIL? + throw new Error("Exit node not allowed"); + } + } + + if (updatedSite) { + updatedSites.push({ ...updatedSite, peer }); } } @@ -138,12 +153,29 @@ export async function updateSiteBandwidth(bandwidthData: PeerBandwidth[]) { // Always update lastBandwidthUpdate to show this instance is receiving reports // Only update online status if it changed if (site.online !== newOnlineStatus) { - await trx + const [updatedSite] = await trx .update(sites) .set({ online: newOnlineStatus }) - .where(eq(sites.siteId, site.siteId)); + .where(eq(sites.siteId, site.siteId)) + .returning(); + + if (exitNodeId) { + if ( + await checkExitNodeOrg( + exitNodeId, + updatedSite.orgId + ) + ) { + // not allowed + logger.warn( + `Exit node ${exitNodeId} is not allowed for org ${updatedSite.orgId}` + ); + // THIS SHOULD TRIGGER THE TRANSACTION TO FAIL? + throw new Error("Exit node not allowed"); + } + } // If site went offline, add it to our tracking set if (!newOnlineStatus && site.pubKey) { diff --git a/server/routers/gerbil/updateHolePunch.ts b/server/routers/gerbil/updateHolePunch.ts index 0eaa447e..9e2ec8b8 100644 --- a/server/routers/gerbil/updateHolePunch.ts +++ b/server/routers/gerbil/updateHolePunch.ts @@ -19,6 +19,7 @@ import { fromError } from "zod-validation-error"; import { validateNewtSessionToken } from "@server/auth/sessions/newt"; import { validateOlmSessionToken } from "@server/auth/sessions/olm"; import axios from "axios"; +import { checkExitNodeOrg } from "@server/lib/exitNodes"; // Define Zod schema for request validation const updateHolePunchSchema = z.object({ @@ -157,7 +158,13 @@ export async function updateAndGenerateEndpointDestinations( .where(eq(clients.clientId, olm.clientId)) .returning(); - + if (await checkExitNodeOrg(exitNode.exitNodeId, client.orgId)) { + // not allowed + logger.warn( + `Exit node ${exitNode.exitNodeId} is not allowed for org ${client.orgId}` + ); + throw new Error("Exit node not allowed"); + } // Get sites that are on this specific exit node and connected to this client const sitesOnExitNode = await db @@ -240,6 +247,20 @@ export async function updateAndGenerateEndpointDestinations( throw new Error("Newt not found"); } + const [site] = await db + .select() + .from(sites) + .where(eq(sites.siteId, newt.siteId)) + .limit(1); + + if (await checkExitNodeOrg(exitNode.exitNodeId, site.orgId)) { + // not allowed + logger.warn( + `Exit node ${exitNode.exitNodeId} is not allowed for org ${site.orgId}` + ); + throw new Error("Exit node not allowed"); + } + currentSiteId = newt.siteId; // Update the current site with the new endpoint From f07cd8aee3411bb09a241c66544a6ce35e4cfcb7 Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 16 Aug 2025 12:07:15 -0700 Subject: [PATCH 39/63] Fix traefik config merge --- server/routers/traefik/getTraefikConfig.ts | 349 +++++---------------- 1 file changed, 81 insertions(+), 268 deletions(-) diff --git a/server/routers/traefik/getTraefikConfig.ts b/server/routers/traefik/getTraefikConfig.ts index 325c4205..7349df02 100644 --- a/server/routers/traefik/getTraefikConfig.ts +++ b/server/routers/traefik/getTraefikConfig.ts @@ -94,94 +94,94 @@ export async function getTraefikConfig(exitNodeId: number): Promise { // Get all resources with related data const allResources = await db.transaction(async (tx) => { // Get resources with their targets and sites in a single optimized query - // Start from sites on this exit node, then join to targets and resources - const resourcesWithTargetsAndSites = await tx - .select({ - // Resource fields - resourceId: resources.resourceId, - fullDomain: resources.fullDomain, - ssl: resources.ssl, - http: resources.http, - proxyPort: resources.proxyPort, - protocol: resources.protocol, - subdomain: resources.subdomain, - domainId: resources.domainId, - enabled: resources.enabled, - stickySession: resources.stickySession, - tlsServerName: resources.tlsServerName, - setHostHeader: resources.setHostHeader, - enableProxy: resources.enableProxy, - // Target fields - targetId: targets.targetId, - targetEnabled: targets.enabled, - ip: targets.ip, - method: targets.method, - port: targets.port, - internalPort: targets.internalPort, - // Site fields - siteId: sites.siteId, - siteType: sites.type, - subnet: sites.subnet, - exitNodeId: sites.exitNodeId - }) - .from(sites) - .innerJoin(targets, eq(targets.siteId, sites.siteId)) - .innerJoin(resources, eq(resources.resourceId, targets.resourceId)) - .where( - and( - eq(targets.enabled, true), - eq(resources.enabled, true), - or( - eq(sites.exitNodeId, currentExitNodeId), - isNull(sites.exitNodeId) - ) + // Start from sites on this exit node, then join to targets and resources + const resourcesWithTargetsAndSites = await tx + .select({ + // Resource fields + resourceId: resources.resourceId, + fullDomain: resources.fullDomain, + ssl: resources.ssl, + http: resources.http, + proxyPort: resources.proxyPort, + protocol: resources.protocol, + subdomain: resources.subdomain, + domainId: resources.domainId, + enabled: resources.enabled, + stickySession: resources.stickySession, + tlsServerName: resources.tlsServerName, + setHostHeader: resources.setHostHeader, + enableProxy: resources.enableProxy, + // Target fields + targetId: targets.targetId, + targetEnabled: targets.enabled, + ip: targets.ip, + method: targets.method, + port: targets.port, + internalPort: targets.internalPort, + // Site fields + siteId: sites.siteId, + siteType: sites.type, + subnet: sites.subnet, + exitNodeId: sites.exitNodeId + }) + .from(sites) + .innerJoin(targets, eq(targets.siteId, sites.siteId)) + .innerJoin(resources, eq(resources.resourceId, targets.resourceId)) + .where( + and( + eq(targets.enabled, true), + eq(resources.enabled, true), + or( + eq(sites.exitNodeId, currentExitNodeId), + isNull(sites.exitNodeId) ) - ); + ) + ); - // Group by resource and include targets with their unique site data - const resourcesMap = new Map(); + // Group by resource and include targets with their unique site data + const resourcesMap = new Map(); - resourcesWithTargetsAndSites.forEach((row) => { - const resourceId = row.resourceId; + resourcesWithTargetsAndSites.forEach((row) => { + const resourceId = row.resourceId; - if (!resourcesMap.has(resourceId)) { - resourcesMap.set(resourceId, { - resourceId: row.resourceId, - fullDomain: row.fullDomain, - ssl: row.ssl, - http: row.http, - proxyPort: row.proxyPort, - protocol: row.protocol, - subdomain: row.subdomain, - domainId: row.domainId, - enabled: row.enabled, - stickySession: row.stickySession, - tlsServerName: row.tlsServerName, - setHostHeader: row.setHostHeader, - enableProxy: row.enableProxy, - targets: [] - }); - } - - // Add target with its associated site data - resourcesMap.get(resourceId).targets.push({ + if (!resourcesMap.has(resourceId)) { + resourcesMap.set(resourceId, { resourceId: row.resourceId, - targetId: row.targetId, - ip: row.ip, - method: row.method, - port: row.port, - internalPort: row.internalPort, - enabled: row.targetEnabled, - site: { - siteId: row.siteId, - type: row.siteType, - subnet: row.subnet, - exitNodeId: row.exitNodeId - } + fullDomain: row.fullDomain, + ssl: row.ssl, + http: row.http, + proxyPort: row.proxyPort, + protocol: row.protocol, + subdomain: row.subdomain, + domainId: row.domainId, + enabled: row.enabled, + stickySession: row.stickySession, + tlsServerName: row.tlsServerName, + setHostHeader: row.setHostHeader, + enableProxy: row.enableProxy, + targets: [] }); - }); + } - return Array.from(resourcesMap.values()); + // Add target with its associated site data + resourcesMap.get(resourceId).targets.push({ + resourceId: row.resourceId, + targetId: row.targetId, + ip: row.ip, + method: row.method, + port: row.port, + internalPort: row.internalPort, + enabled: row.targetEnabled, + site: { + siteId: row.siteId, + type: row.siteType, + subnet: row.subnet, + exitNodeId: row.exitNodeId + } + }); + }); + + return Array.from(resourcesMap.values()); }); if (!allResources.length) { @@ -299,194 +299,7 @@ export async function getTraefikConfig(exitNodeId: number): Promise { middlewares: [redirectHttpsMiddlewareName], service: serviceName, rule: `Host(\`${fullDomain}\`)`, -<<<<<<< HEAD priority: 100 -======= - priority: 100, - ...(resource.ssl ? { tls } : {}) - }; - - if (resource.ssl) { - config_output.http.routers![routerName + "-redirect"] = { - entryPoints: [ - config.getRawConfig().traefik.http_entrypoint - ], - middlewares: [redirectHttpsMiddlewareName], - service: serviceName, - rule: `Host(\`${fullDomain}\`)`, - priority: 100 - }; - } - - config_output.http.services![serviceName] = { - loadBalancer: { - servers: targets - .filter((target: TargetWithSite) => { - if (!target.enabled) { - return false; - } - if ( - target.site.type === "local" || - target.site.type === "wireguard" - ) { - if ( - !target.ip || - !target.port || - !target.method - ) { - return false; - } - } else if (target.site.type === "newt") { - if ( - !target.internalPort || - !target.method || - !target.site.subnet - ) { - return false; - } - } - return true; - }) - .map((target: TargetWithSite) => { - if ( - target.site.type === "local" || - target.site.type === "wireguard" - ) { - return { - url: `${target.method}://${target.ip}:${target.port}` - }; - } else if (target.site.type === "newt") { - const ip = target.site.subnet!.split("/")[0]; - return { - url: `${target.method}://${ip}:${target.internalPort}` - }; - } - }), - ...(resource.stickySession - ? { - sticky: { - cookie: { - name: "p_sticky", // TODO: make this configurable via config.yml like other cookies - secure: resource.ssl, - httpOnly: true - } - } - } - : {}) - } - }; - - // Add the serversTransport if TLS server name is provided - if (resource.tlsServerName) { - if (!config_output.http.serversTransports) { - config_output.http.serversTransports = {}; - } - config_output.http.serversTransports![transportName] = { - serverName: resource.tlsServerName, - //unfortunately the following needs to be set. traefik doesn't merge the default serverTransport settings - // if defined in the static config and here. if not set, self-signed certs won't work - insecureSkipVerify: true - }; - config_output.http.services![ - serviceName - ].loadBalancer.serversTransport = transportName; - } - - // Add the host header middleware - if (resource.setHostHeader) { - if (!config_output.http.middlewares) { - config_output.http.middlewares = {}; - } - config_output.http.middlewares[hostHeaderMiddlewareName] = { - headers: { - customRequestHeaders: { - Host: resource.setHostHeader - } - } - }; - if (!config_output.http.routers![routerName].middlewares) { - config_output.http.routers![routerName].middlewares = - []; - } - config_output.http.routers![routerName].middlewares = [ - ...config_output.http.routers![routerName].middlewares, - hostHeaderMiddlewareName - ]; - } - } else { - // Non-HTTP (TCP/UDP) configuration - if (!resource.enableProxy) { - continue; - } - - const protocol = resource.protocol.toLowerCase(); - const port = resource.proxyPort; - - if (!port) { - continue; - } - - if (!config_output[protocol]) { - config_output[protocol] = { - routers: {}, - services: {} - }; - } - - config_output[protocol].routers[routerName] = { - entryPoints: [`${protocol}-${port}`], - service: serviceName, - ...(protocol === "tcp" ? { rule: "HostSNI(`*`)" } : {}) - }; - - config_output[protocol].services[serviceName] = { - loadBalancer: { - servers: targets - .filter((target: TargetWithSite) => { - if (!target.enabled) { - return false; - } - if ( - target.site.type === "local" || - target.site.type === "wireguard" - ) { - if (!target.ip || !target.port) { - return false; - } - } else if (target.site.type === "newt") { - if (!target.internalPort || !target.site.subnet) { - return false; - } - } - return true; - }) - .map((target: TargetWithSite) => { - if ( - target.site.type === "local" || - target.site.type === "wireguard" - ) { - return { - address: `${target.ip}:${target.port}` - }; - } else if (target.site.type === "newt") { - const ip = target.site.subnet!.split("/")[0]; - return { - address: `${ip}:${target.internalPort}` - }; - } - }), - ...(resource.stickySession - ? { - sticky: { - ipStrategy: { - depth: 0, - sourcePort: true - } - } - } - : {}) - } ->>>>>>> dev }; } From d548563e652fa92110d2c2fa40f03a95247af088 Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 16 Aug 2025 14:54:16 -0700 Subject: [PATCH 40/63] Export the right thing --- server/lib/exitNodes/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/lib/exitNodes/index.ts b/server/lib/exitNodes/index.ts index b29bce93..caae390a 100644 --- a/server/lib/exitNodes/index.ts +++ b/server/lib/exitNodes/index.ts @@ -1 +1 @@ -export * from "./privateExitNodes"; \ No newline at end of file +export * from "./exitNodes"; \ No newline at end of file From d771317e3f8f47bec43e6ff417a5ef6f4cfba7a7 Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 16 Aug 2025 14:57:19 -0700 Subject: [PATCH 41/63] Fix traefik config --- server/routers/traefik/getTraefikConfig.ts | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/server/routers/traefik/getTraefikConfig.ts b/server/routers/traefik/getTraefikConfig.ts index 7349df02..e25a48cf 100644 --- a/server/routers/traefik/getTraefikConfig.ts +++ b/server/routers/traefik/getTraefikConfig.ts @@ -7,16 +7,6 @@ import config from "@server/lib/config"; import { orgs, resources, sites, Target, targets } from "@server/db"; import { build } from "@server/build"; -// Extended Target interface that includes site information -interface TargetWithSite extends Target { - site: { - siteId: number; - type: string; - subnet: string | null; - exitNodeId: number | null; - }; -} - let currentExitNodeId: number; const redirectHttpsMiddlewareName = "redirect-to-https"; const badgerMiddlewareName = "badger"; @@ -134,7 +124,7 @@ export async function getTraefikConfig(exitNodeId: number): Promise { or( eq(sites.exitNodeId, currentExitNodeId), isNull(sites.exitNodeId) - ) + ), ) ); @@ -201,7 +191,7 @@ export async function getTraefikConfig(exitNodeId: number): Promise { }; for (const resource of allResources) { - const targets = resource.targets as Target[]; + const targets = resource.targets; const site = resource.site; const routerName = `${resource.resourceId}-router`; From 609435328ed65cfbdc5c3bf230382aabb03063f9 Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 16 Aug 2025 16:42:34 -0700 Subject: [PATCH 42/63] Smoothing over initial connection issues --- server/lib/exitNodes/index.ts | 3 +- server/lib/exitNodes/shared.ts | 30 +++++++++++++++ server/lib/remoteTraefikConfig.ts | 61 +++++++++++++++--------------- server/lib/tokenManager.ts | 27 ++++++------- server/routers/gerbil/getConfig.ts | 33 +++------------- 5 files changed, 80 insertions(+), 74 deletions(-) create mode 100644 server/lib/exitNodes/shared.ts diff --git a/server/lib/exitNodes/index.ts b/server/lib/exitNodes/index.ts index caae390a..8889bc35 100644 --- a/server/lib/exitNodes/index.ts +++ b/server/lib/exitNodes/index.ts @@ -1 +1,2 @@ -export * from "./exitNodes"; \ No newline at end of file +export * from "./exitNodes"; +export * from "./shared"; \ No newline at end of file diff --git a/server/lib/exitNodes/shared.ts b/server/lib/exitNodes/shared.ts new file mode 100644 index 00000000..c06f1d05 --- /dev/null +++ b/server/lib/exitNodes/shared.ts @@ -0,0 +1,30 @@ +import { db, exitNodes } from "@server/db"; +import config from "@server/lib/config"; +import { findNextAvailableCidr } from "@server/lib/ip"; + +export async function getNextAvailableSubnet(): Promise { + // Get all existing subnets from routes table + const existingAddresses = await db + .select({ + address: exitNodes.address + }) + .from(exitNodes); + + const addresses = existingAddresses.map((a) => a.address); + let subnet = findNextAvailableCidr( + addresses, + config.getRawConfig().gerbil.block_size, + config.getRawConfig().gerbil.subnet_group + ); + if (!subnet) { + throw new Error("No available subnets remaining in space"); + } + + // replace the last octet with 1 + subnet = + subnet.split(".").slice(0, 3).join(".") + + ".1" + + "/" + + subnet.split("/")[1]; + return subnet; +} \ No newline at end of file diff --git a/server/lib/remoteTraefikConfig.ts b/server/lib/remoteTraefikConfig.ts index e192ab67..d6289dea 100644 --- a/server/lib/remoteTraefikConfig.ts +++ b/server/lib/remoteTraefikConfig.ts @@ -155,14 +155,11 @@ export class TraefikConfigManager { method: error.config?.method }); } else { - logger.error( - "Error updating local SNI:", - error - ); + logger.error("Error updating local SNI:", error); } } } else { - logger.error("No exit node found"); + logger.error("No exit node found. Has gerbil registered yet?"); } } catch (err) { logger.error("Failed to post domains to SNI proxy:", err); @@ -213,35 +210,39 @@ export class TraefikConfigManager { } } - const badgerMiddlewareName = "badger"; - traefikConfig.http.middlewares[badgerMiddlewareName] = { - plugin: { - [badgerMiddlewareName]: { - apiBaseUrl: new URL( - "/api/v1", - `http://${ - config.getRawConfig().server.internal_hostname - }:${config.getRawConfig().server.internal_port}` - ).href, - userSessionCookieName: - config.getRawConfig().server.session_cookie_name, - - // deprecated - accessTokenQueryParam: - config.getRawConfig().server - .resource_access_token_param, - - resourceSessionRequestParam: - config.getRawConfig().server - .resource_session_request_param - } - } - }; - // logger.debug( // `Successfully retrieved traefik config: ${JSON.stringify(traefikConfig)}` // ); + const badgerMiddlewareName = "badger"; + if (traefikConfig?.http?.middlewares) { + traefikConfig.http.middlewares[badgerMiddlewareName] = { + plugin: { + [badgerMiddlewareName]: { + apiBaseUrl: new URL( + "/api/v1", + `http://${ + config.getRawConfig().server + .internal_hostname + }:${config.getRawConfig().server.internal_port}` + ).href, + userSessionCookieName: + config.getRawConfig().server + .session_cookie_name, + + // deprecated + accessTokenQueryParam: + config.getRawConfig().server + .resource_access_token_param, + + resourceSessionRequestParam: + config.getRawConfig().server + .resource_session_request_param + } + } + }; + } + return { domains, traefikConfig }; } catch (error) { // pull data out of the axios error to log diff --git a/server/lib/tokenManager.ts b/server/lib/tokenManager.ts index 8abfd969..99330a3c 100644 --- a/server/lib/tokenManager.ts +++ b/server/lib/tokenManager.ts @@ -150,25 +150,20 @@ export class TokenManager { this.token = response.data.data.token; logger.debug("Token refreshed successfully"); } catch (error) { - logger.error("Failed to refresh token:", error); - if (axios.isAxiosError(error)) { - if (error.response) { - throw new Error( - `Failed to get token with status code: ${error.response.status}` - ); - } else if (error.request) { - throw new Error( - "Failed to request new token: No response received" - ); - } else { - throw new Error( - `Failed to request new token: ${error.message}` - ); - } + logger.error("Error updating proxy mapping:", { + message: error.message, + code: error.code, + status: error.response?.status, + statusText: error.response?.statusText, + url: error.config?.url, + method: error.config?.method + }); } else { - throw new Error(`Failed to get token: ${error}`); + logger.error("Error updating proxy mapping:", error); } + + throw new Error("Failed to refresh token"); } finally { this.isRefreshing = false; } diff --git a/server/routers/gerbil/getConfig.ts b/server/routers/gerbil/getConfig.ts index d8f4c56e..7cf69245 100644 --- a/server/routers/gerbil/getConfig.ts +++ b/server/routers/gerbil/getConfig.ts @@ -12,6 +12,7 @@ import { findNextAvailableCidr } from "@server/lib/ip"; import { fromError } from "zod-validation-error"; import { getAllowedIps } from "../target/helpers"; import { proxyToRemote } from "@server/lib/remoteProxy"; +import { getNextAvailableSubnet } from "@server/lib/exitNodes"; // Define Zod schema for request validation const getConfigSchema = z.object({ publicKey: z.string(), @@ -104,6 +105,11 @@ export async function getConfig( // STOP HERE IN HYBRID MODE if (config.isHybridMode()) { + req.body = { + ...req.body, + endpoint: exitNode[0].endpoint, + listenPort: exitNode[0].listenPort + } return proxyToRemote(req, res, next, "hybrid/gerbil/get-config"); } @@ -164,33 +170,6 @@ export async function generateGerbilConfig(exitNode: ExitNode) { return configResponse; } -async function getNextAvailableSubnet(): Promise { - // Get all existing subnets from routes table - const existingAddresses = await db - .select({ - address: exitNodes.address - }) - .from(exitNodes); - - const addresses = existingAddresses.map((a) => a.address); - let subnet = findNextAvailableCidr( - addresses, - config.getRawConfig().gerbil.block_size, - config.getRawConfig().gerbil.subnet_group - ); - if (!subnet) { - throw new Error("No available subnets remaining in space"); - } - - // replace the last octet with 1 - subnet = - subnet.split(".").slice(0, 3).join(".") + - ".1" + - "/" + - subnet.split("/")[1]; - return subnet; -} - async function getNextAvailablePort(): Promise { // Get all existing ports from exitNodes table const existingPorts = await db From 7ca507b1ceee1b7f5582ac9ecc76fec382b9d372 Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 16 Aug 2025 17:16:19 -0700 Subject: [PATCH 43/63] Fixing traefik problems --- server/lib/remoteTraefikConfig.ts | 6 +-- server/routers/traefik/getTraefikConfig.ts | 58 +++++++++++++--------- 2 files changed, 37 insertions(+), 27 deletions(-) diff --git a/server/lib/remoteTraefikConfig.ts b/server/lib/remoteTraefikConfig.ts index d6289dea..a31aee29 100644 --- a/server/lib/remoteTraefikConfig.ts +++ b/server/lib/remoteTraefikConfig.ts @@ -210,9 +210,9 @@ export class TraefikConfigManager { } } - // logger.debug( - // `Successfully retrieved traefik config: ${JSON.stringify(traefikConfig)}` - // ); + logger.debug( + `Successfully retrieved traefik config: ${JSON.stringify(traefikConfig)}` + ); const badgerMiddlewareName = "badger"; if (traefikConfig?.http?.middlewares) { diff --git a/server/routers/traefik/getTraefikConfig.ts b/server/routers/traefik/getTraefikConfig.ts index e25a48cf..441b4328 100644 --- a/server/routers/traefik/getTraefikConfig.ts +++ b/server/routers/traefik/getTraefikConfig.ts @@ -81,6 +81,16 @@ export async function traefikConfigProvider( } export async function getTraefikConfig(exitNodeId: number): Promise { + // Define extended target type with site information + type TargetWithSite = Target & { + site: { + siteId: number; + type: string; + subnet: string | null; + exitNodeId: number | null; + }; + }; + // Get all resources with related data const allResources = await db.transaction(async (tx) => { // Get resources with their targets and sites in a single optimized query @@ -122,9 +132,10 @@ export async function getTraefikConfig(exitNodeId: number): Promise { eq(targets.enabled, true), eq(resources.enabled, true), or( - eq(sites.exitNodeId, currentExitNodeId), + eq(sites.exitNodeId, exitNodeId), isNull(sites.exitNodeId) ), + ne(targetHealthCheck.hcHealth, "unhealthy") ) ); @@ -192,7 +203,6 @@ export async function getTraefikConfig(exitNodeId: number): Promise { for (const resource of allResources) { const targets = resource.targets; - const site = resource.site; const routerName = `${resource.resourceId}-router`; const serviceName = `${resource.resourceId}-service`; @@ -295,14 +305,14 @@ export async function getTraefikConfig(exitNodeId: number): Promise { config_output.http.services![serviceName] = { loadBalancer: { - servers: targets - .filter((target: Target) => { + servers: (targets as TargetWithSite[]) + .filter((target: TargetWithSite) => { if (!target.enabled) { return false; } if ( - site.type === "local" || - site.type === "wireguard" + target.site.type === "local" || + target.site.type === "wireguard" ) { if ( !target.ip || @@ -311,27 +321,27 @@ export async function getTraefikConfig(exitNodeId: number): Promise { ) { return false; } - } else if (site.type === "newt") { + } else if (target.site.type === "newt") { if ( !target.internalPort || !target.method || - !site.subnet + !target.site.subnet ) { return false; } } return true; }) - .map((target: Target) => { + .map((target: TargetWithSite) => { if ( - site.type === "local" || - site.type === "wireguard" + target.site.type === "local" || + target.site.type === "wireguard" ) { return { url: `${target.method}://${target.ip}:${target.port}` }; - } else if (site.type === "newt") { - const ip = site.subnet!.split("/")[0]; + } else if (target.site.type === "newt") { + const ip = target.site.subnet!.split("/")[0]; return { url: `${target.method}://${ip}:${target.internalPort}` }; @@ -415,35 +425,35 @@ export async function getTraefikConfig(exitNodeId: number): Promise { config_output[protocol].services[serviceName] = { loadBalancer: { - servers: targets - .filter((target: Target) => { + servers: (targets as TargetWithSite[]) + .filter((target: TargetWithSite) => { if (!target.enabled) { return false; } if ( - site.type === "local" || - site.type === "wireguard" + target.site.type === "local" || + target.site.type === "wireguard" ) { if (!target.ip || !target.port) { return false; } - } else if (site.type === "newt") { - if (!target.internalPort || !site.subnet) { + } else if (target.site.type === "newt") { + if (!target.internalPort || !target.site.subnet) { return false; } } return true; }) - .map((target: Target) => { + .map((target: TargetWithSite) => { if ( - site.type === "local" || - site.type === "wireguard" + target.site.type === "local" || + target.site.type === "wireguard" ) { return { address: `${target.ip}:${target.port}` }; - } else if (site.type === "newt") { - const ip = site.subnet!.split("/")[0]; + } else if (target.site.type === "newt") { + const ip = target.site.subnet!.split("/")[0]; return { address: `${ip}:${target.internalPort}` }; From 83a696f74399620d73c6988e618bb2485f2e6a78 Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 16 Aug 2025 17:29:27 -0700 Subject: [PATCH 44/63] Make traefik config wor --- server/lib/remoteTraefikConfig.ts | 6 +++--- server/routers/traefik/getTraefikConfig.ts | 1 - 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/server/lib/remoteTraefikConfig.ts b/server/lib/remoteTraefikConfig.ts index a31aee29..d6289dea 100644 --- a/server/lib/remoteTraefikConfig.ts +++ b/server/lib/remoteTraefikConfig.ts @@ -210,9 +210,9 @@ export class TraefikConfigManager { } } - logger.debug( - `Successfully retrieved traefik config: ${JSON.stringify(traefikConfig)}` - ); + // logger.debug( + // `Successfully retrieved traefik config: ${JSON.stringify(traefikConfig)}` + // ); const badgerMiddlewareName = "badger"; if (traefikConfig?.http?.middlewares) { diff --git a/server/routers/traefik/getTraefikConfig.ts b/server/routers/traefik/getTraefikConfig.ts index 441b4328..d93f7ac0 100644 --- a/server/routers/traefik/getTraefikConfig.ts +++ b/server/routers/traefik/getTraefikConfig.ts @@ -135,7 +135,6 @@ export async function getTraefikConfig(exitNodeId: number): Promise { eq(sites.exitNodeId, exitNodeId), isNull(sites.exitNodeId) ), - ne(targetHealthCheck.hcHealth, "unhealthy") ) ); From 8355d3664e067fa63caa501bd1b618a771a33a4c Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 16 Aug 2025 17:53:33 -0700 Subject: [PATCH 45/63] Retry the token request --- server/lib/tokenManager.ts | 93 ++++++++++++++++++++++++++++++++------ 1 file changed, 80 insertions(+), 13 deletions(-) diff --git a/server/lib/tokenManager.ts b/server/lib/tokenManager.ts index 99330a3c..e6a03067 100644 --- a/server/lib/tokenManager.ts +++ b/server/lib/tokenManager.ts @@ -33,34 +33,96 @@ export class TokenManager { private refreshInterval: NodeJS.Timeout | null = null; private isRefreshing: boolean = false; private refreshIntervalMs: number; + private retryInterval: NodeJS.Timeout | null = null; + private retryIntervalMs: number; + private tokenAvailablePromise: Promise | null = null; + private tokenAvailableResolve: (() => void) | null = null; - constructor(refreshIntervalMs: number = 24 * 60 * 60 * 1000) { - // Default to 24 hours + constructor(refreshIntervalMs: number = 24 * 60 * 60 * 1000, retryIntervalMs: number = 5000) { + // Default to 24 hours for refresh, 5 seconds for retry this.refreshIntervalMs = refreshIntervalMs; + this.retryIntervalMs = retryIntervalMs; + this.setupTokenAvailablePromise(); } /** - * Start the token manager - gets initial token and sets up refresh interval + * Set up promise that resolves when token becomes available */ - async start(): Promise { - try { - await this.refreshToken(); - this.setupRefreshInterval(); - logger.info("Token manager started successfully"); - } catch (error) { - logger.error("Failed to start token manager:", error); - throw error; + private setupTokenAvailablePromise(): void { + this.tokenAvailablePromise = new Promise((resolve) => { + this.tokenAvailableResolve = resolve; + }); + } + + /** + * Resolve the token available promise + */ + private resolveTokenAvailable(): void { + if (this.tokenAvailableResolve) { + this.tokenAvailableResolve(); + this.tokenAvailableResolve = null; } } /** - * Stop the token manager and clear refresh interval + * Start the token manager - gets initial token and sets up refresh interval + * If initial token fetch fails, keeps retrying every few seconds until successful + */ + async start(): Promise { + logger.info("Starting token manager..."); + + try { + await this.refreshToken(); + this.setupRefreshInterval(); + this.resolveTokenAvailable(); + logger.info("Token manager started successfully"); + } catch (error) { + logger.warn(`Failed to get initial token, will retry in ${this.retryIntervalMs / 1000} seconds:`, error); + this.setupRetryInterval(); + } + } + + /** + * Set up retry interval for initial token acquisition + */ + private setupRetryInterval(): void { + if (this.retryInterval) { + clearInterval(this.retryInterval); + } + + this.retryInterval = setInterval(async () => { + try { + logger.debug("Retrying initial token acquisition"); + await this.refreshToken(); + this.setupRefreshInterval(); + this.clearRetryInterval(); + this.resolveTokenAvailable(); + logger.info("Token manager started successfully after retry"); + } catch (error) { + logger.debug("Token acquisition retry failed, will try again"); + } + }, this.retryIntervalMs); + } + + /** + * Clear retry interval + */ + private clearRetryInterval(): void { + if (this.retryInterval) { + clearInterval(this.retryInterval); + this.retryInterval = null; + } + } + + /** + * Stop the token manager and clear all intervals */ stop(): void { if (this.refreshInterval) { clearInterval(this.refreshInterval); this.refreshInterval = null; } + this.clearRetryInterval(); logger.info("Token manager stopped"); } @@ -70,12 +132,17 @@ export class TokenManager { // TODO: WE SHOULD NOT BE GETTING A TOKEN EVERY TIME WE REQUEST IT async getToken(): Promise { + // If we don't have a token yet, wait for it to become available + if (!this.token && this.tokenAvailablePromise) { + await this.tokenAvailablePromise; + } + if (!this.token) { if (this.isRefreshing) { // Wait for current refresh to complete await this.waitForRefresh(); } else { - await this.refreshToken(); + throw new Error("No valid token available"); } } From 3b8d1f40a76fc6f131a06f977baaa4d4a96b5665 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 17 Aug 2025 11:23:43 -0700 Subject: [PATCH 46/63] Include get hostname, filter sites fix gerbil conf --- .gitignore | 4 ++ docker-compose.example.yml | 3 +- install/config/docker-compose.yml | 3 +- server/lib/exitNodes/exitNodes.ts | 15 +++---- server/routers/gerbil/getResolvedHostname.ts | 46 ++++++++++++++++++++ server/routers/gerbil/index.ts | 3 +- server/routers/internal.ts | 5 +++ server/routers/traefik/getTraefikConfig.ts | 5 ++- 8 files changed, 68 insertions(+), 16 deletions(-) create mode 100644 server/routers/gerbil/getResolvedHostname.ts diff --git a/.gitignore b/.gitignore index 2f1749ef..78ce996b 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,10 @@ bin .secrets test_event.json .idea/ +public/branding server/db/index.ts +config/openapi.yaml +server/build.ts +postgres/ dynamic/ certificates/ diff --git a/docker-compose.example.yml b/docker-compose.example.yml index 703c47c6..28097f32 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -22,8 +22,7 @@ services: command: - --reachableAt=http://gerbil:3003 - --generateAndSaveKeyTo=/var/config/key - - --remoteConfig=http://pangolin:3001/api/v1/gerbil/get-config - - --reportBandwidthTo=http://pangolin:3001/api/v1/gerbil/receive-bandwidth + - --remoteConfig=http://pangolin:3001/api/v1/ volumes: - ./config/:/var/config cap_add: diff --git a/install/config/docker-compose.yml b/install/config/docker-compose.yml index 70a4602f..44af4199 100644 --- a/install/config/docker-compose.yml +++ b/install/config/docker-compose.yml @@ -22,8 +22,7 @@ services: command: - --reachableAt=http://gerbil:3003 - --generateAndSaveKeyTo=/var/config/key - - --remoteConfig=http://pangolin:3001/api/v1/gerbil/get-config - - --reportBandwidthTo=http://pangolin:3001/api/v1/gerbil/receive-bandwidth + - --remoteConfig=http://pangolin:3001/api/v1/ volumes: - ./config/:/var/config cap_add: diff --git a/server/lib/exitNodes/exitNodes.ts b/server/lib/exitNodes/exitNodes.ts index f607371d..06539bb0 100644 --- a/server/lib/exitNodes/exitNodes.ts +++ b/server/lib/exitNodes/exitNodes.ts @@ -1,16 +1,16 @@ import { db, exitNodes } from "@server/db"; import logger from "@server/logger"; import { ExitNodePingResult } from "@server/routers/newt"; -import { eq, and, or } from "drizzle-orm"; +import { eq } from "drizzle-orm"; export async function verifyExitNodeOrgAccess( exitNodeId: number, orgId: string ) { const [exitNode] = await db - .select() - .from(exitNodes) - .where(eq(exitNodes.exitNodeId, exitNodeId)); + .select() + .from(exitNodes) + .where(eq(exitNodes.exitNodeId, exitNodeId)); // For any other type, deny access return { hasAccess: true, exitNode }; @@ -30,7 +30,7 @@ export async function listExitNodes(orgId: string, filterOnline = false) { maxConnections: exitNodes.maxConnections, online: exitNodes.online, lastPing: exitNodes.lastPing, - type: exitNodes.type, + type: exitNodes.type }) .from(exitNodes); @@ -54,9 +54,6 @@ export function selectBestExitNode( return pingResults[0]; } -export async function checkExitNodeOrg( - exitNodeId: number, - orgId: string -) { +export async function checkExitNodeOrg(exitNodeId: number, orgId: string) { return false; } \ No newline at end of file diff --git a/server/routers/gerbil/getResolvedHostname.ts b/server/routers/gerbil/getResolvedHostname.ts new file mode 100644 index 00000000..da2ab39a --- /dev/null +++ b/server/routers/gerbil/getResolvedHostname.ts @@ -0,0 +1,46 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; + +// Define Zod schema for request validation +const getResolvedHostnameSchema = z.object({ + hostname: z.string(), + publicKey: z.string() +}); + +export async function getResolvedHostname( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + // Validate request parameters + const parsedParams = getResolvedHostnameSchema.safeParse( + req.body + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + // return the endpoints + return res.status(HttpCode.OK).send({ + endpoints: [] // ALWAYS ROUTE LOCALLY + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "An error occurred..." + ) + ); + } +} diff --git a/server/routers/gerbil/index.ts b/server/routers/gerbil/index.ts index 4a4f3b60..bff57d05 100644 --- a/server/routers/gerbil/index.ts +++ b/server/routers/gerbil/index.ts @@ -1,4 +1,5 @@ export * from "./getConfig"; export * from "./receiveBandwidth"; export * from "./updateHolePunch"; -export * from "./getAllRelays"; \ No newline at end of file +export * from "./getAllRelays"; +export * from "./getResolvedHostname"; \ No newline at end of file diff --git a/server/routers/internal.ts b/server/routers/internal.ts index d19355b7..805e284f 100644 --- a/server/routers/internal.ts +++ b/server/routers/internal.ts @@ -66,6 +66,10 @@ if (config.isHybridMode()) { proxyToRemote(req, res, next, "hybrid/gerbil/get-all-relays") ); + gerbilRouter.post("/get-resolved-hostname", (req, res, next) => + proxyToRemote(req, res, next, `hybrid/gerbil/get-resolved-hostname`) + ); + // GET CONFIG IS HANDLED IN THE ORIGINAL HANDLER // SO IT CAN REGISTER THE LOCAL EXIT NODE } else { @@ -73,6 +77,7 @@ if (config.isHybridMode()) { gerbilRouter.post("/receive-bandwidth", gerbil.receiveBandwidth); gerbilRouter.post("/update-hole-punch", gerbil.updateHolePunch); gerbilRouter.post("/get-all-relays", gerbil.getAllRelays); + gerbilRouter.post("/get-resolved-hostname", gerbil.getResolvedHostname); } // WE HANDLE THE PROXY INSIDE OF THIS FUNCTION diff --git a/server/routers/traefik/getTraefikConfig.ts b/server/routers/traefik/getTraefikConfig.ts index d93f7ac0..422f9739 100644 --- a/server/routers/traefik/getTraefikConfig.ts +++ b/server/routers/traefik/getTraefikConfig.ts @@ -45,7 +45,7 @@ export async function traefikConfigProvider( } } - let traefikConfig = await getTraefikConfig(currentExitNodeId); + let traefikConfig = await getTraefikConfig(currentExitNodeId, ["newt", "local", "wireguard"]); traefikConfig.http.middlewares[badgerMiddlewareName] = { plugin: { @@ -80,7 +80,7 @@ export async function traefikConfigProvider( } } -export async function getTraefikConfig(exitNodeId: number): Promise { +export async function getTraefikConfig(exitNodeId: number, siteTypes: string[]): Promise { // Define extended target type with site information type TargetWithSite = Target & { site: { @@ -135,6 +135,7 @@ export async function getTraefikConfig(exitNodeId: number): Promise { eq(sites.exitNodeId, exitNodeId), isNull(sites.exitNodeId) ), + inArray(sites.type, siteTypes), ) ); From af2088df4e79a91bd65770acdadd4f767b87a0d5 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 17 Aug 2025 18:01:36 -0700 Subject: [PATCH 47/63] Control which types of sites work and tell user --- messages/en-US.json | 2 ++ server/lib/readConfigFile.ts | 3 ++- server/routers/traefik/getTraefikConfig.ts | 2 +- src/app/[orgId]/settings/sites/create/page.tsx | 5 +++-- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 6f80cbe9..7f00e40d 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -94,7 +94,9 @@ "siteNewtTunnelDescription": "Easiest way to create an entrypoint into your network. No extra setup.", "siteWg": "Basic WireGuard", "siteWgDescription": "Use any WireGuard client to establish a tunnel. Manual NAT setup required.", + "siteWgDescriptionSaas": "Use any WireGuard client to establish a tunnel. Manual NAT setup required. ONLY WORKS ON SELF HOSTED NODES", "siteLocalDescription": "Local resources only. No tunneling.", + "siteLocalDescriptionSaas": "Local resources only. No tunneling. ONLY WORKS ON SELF HOSTED NODES", "siteSeeAll": "See All Sites", "siteTunnelDescription": "Determine how you want to connect to your site", "siteNewtCredentials": "Newt Credentials", diff --git a/server/lib/readConfigFile.ts b/server/lib/readConfigFile.ts index f52e1f99..23db4e52 100644 --- a/server/lib/readConfigFile.ts +++ b/server/lib/readConfigFile.ts @@ -154,7 +154,8 @@ export const configSchema = z .string() .optional() .default("./dynamic/router_config.yml"), - staticDomains: z.array(z.string()).optional().default([]) + static_domains: z.array(z.string()).optional().default([]), + site_types: z.array(z.string()).optional().default(["newt", "wireguard", "local"]) }) .optional() .default({}), diff --git a/server/routers/traefik/getTraefikConfig.ts b/server/routers/traefik/getTraefikConfig.ts index 422f9739..4ec2908c 100644 --- a/server/routers/traefik/getTraefikConfig.ts +++ b/server/routers/traefik/getTraefikConfig.ts @@ -45,7 +45,7 @@ export async function traefikConfigProvider( } } - let traefikConfig = await getTraefikConfig(currentExitNodeId, ["newt", "local", "wireguard"]); + let traefikConfig = await getTraefikConfig(currentExitNodeId, config.getRawConfig().traefik.site_types); traefikConfig.http.middlewares[badgerMiddlewareName] = { plugin: { diff --git a/src/app/[orgId]/settings/sites/create/page.tsx b/src/app/[orgId]/settings/sites/create/page.tsx index 87524d1c..26cba229 100644 --- a/src/app/[orgId]/settings/sites/create/page.tsx +++ b/src/app/[orgId]/settings/sites/create/page.tsx @@ -59,6 +59,7 @@ import { useParams, useRouter } from "next/navigation"; import { QRCodeCanvas } from "qrcode.react"; import { useTranslations } from "next-intl"; +import { build } from "@server/build"; type SiteType = "newt" | "wireguard" | "local"; @@ -142,7 +143,7 @@ export default function Page() { { id: "wireguard" as SiteType, title: t("siteWg"), - description: t("siteWgDescription"), + description: build == "saas" ? t("siteWgDescriptionSaas") : t("siteWgDescription"), disabled: true } ]), @@ -152,7 +153,7 @@ export default function Page() { { id: "local" as SiteType, title: t("local"), - description: t("siteLocalDescription") + description: build == "saas" ? t("siteLocalDescriptionSaas") : t("siteLocalDescription") } ]) ]); From b805daec5174855afb274cf3ca63ac3df64054f2 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 17 Aug 2025 18:18:26 -0700 Subject: [PATCH 48/63] Move to build arg --- Dockerfile | 46 ++++++++++++++++++++++++++++++++++++++++++++++ Makefile | 12 ++++++------ 2 files changed, 52 insertions(+), 6 deletions(-) create mode 100644 Dockerfile diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..5da62593 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,46 @@ +FROM node:22-alpine AS builder + +WORKDIR /app + +ARG BUILD=oss +ARG DATABASE=sqlite + +# COPY package.json package-lock.json ./ +COPY package*.json ./ +RUN npm ci + +COPY . . + +RUN echo 'export * from "./\"$DATABASE\";' > server/db/index.ts + +RUN echo "export const build = \"$BUILD\" as any;" > server/build.ts + +RUN npx drizzle-kit generate --dialect sqlite --schema ./server/db/sqlite/schema.ts --out init + +RUN npm run build:sqlite +RUN npm run build:cli + +FROM node:22-alpine AS runner + +WORKDIR /app + +# Curl used for the health checks +RUN apk add --no-cache curl + +# COPY package.json package-lock.json ./ +COPY package*.json ./ +RUN npm ci --omit=dev && npm cache clean --force + +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/init ./dist/init + +COPY ./cli/wrapper.sh /usr/local/bin/pangctl +RUN chmod +x /usr/local/bin/pangctl ./dist/cli.mjs + +COPY server/db/names.json ./dist/names.json + +COPY public ./public + +CMD ["npm", "run", "start:sqlite"] diff --git a/Makefile b/Makefile index 0e0394b4..de67a5f2 100644 --- a/Makefile +++ b/Makefile @@ -5,10 +5,10 @@ build-release: echo "Error: tag is required. Usage: make build-release tag="; \ exit 1; \ fi - docker buildx build --platform linux/arm64,linux/amd64 -t fosrl/pangolin:latest -f Dockerfile.sqlite --push . - docker buildx build --platform linux/arm64,linux/amd64 -t fosrl/pangolin:$(tag) -f Dockerfile.sqlite --push . - docker buildx build --platform linux/arm64,linux/amd64 -t fosrl/pangolin:postgresql-latest -f Dockerfile.pg --push . - docker buildx build --platform linux/arm64,linux/amd64 -t fosrl/pangolin:postgresql-$(tag) -f Dockerfile.pg --push . + docker buildx build --build-arg DATABASE=sqlite --platform linux/arm64,linux/amd64 -t fosrl/pangolin:latest --push . + docker buildx build --build-arg DATABASE=sqlite --platform linux/arm64,linux/amd64 -t fosrl/pangolin:$(tag) --push . + docker buildx build --build-arg DATABASE=pg --platform linux/arm64,linux/amd64 -t fosrl/pangolin:postgresql-latest --push . + docker buildx build --build-arg DATABASE=pg --platform linux/arm64,linux/amd64 -t fosrl/pangolin:postgresql-$(tag) --push . build-arm: docker buildx build --platform linux/arm64 -t fosrl/pangolin:latest . @@ -17,10 +17,10 @@ build-x86: docker buildx build --platform linux/amd64 -t fosrl/pangolin:latest . build-sqlite: - docker build -t fosrl/pangolin:latest -f Dockerfile.sqlite . + docker build --build-arg DATABASE=sqlite -t fosrl/pangolin:latest . build-pg: - docker build -t fosrl/pangolin:postgresql-latest -f Dockerfile.pg . + docker build --build-arg DATABASE=pg -t fosrl/pangolin:postgresql-latest . test: docker run -it -p 3000:3000 -p 3001:3001 -p 3002:3002 -v ./config:/app/config fosrl/pangolin:latest From c1d75d32c273d5757ef8363918be6a1956db4394 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 17 Aug 2025 18:19:33 -0700 Subject: [PATCH 49/63] Remove old docker files --- Dockerfile.pg | 41 ----------------------------------------- Dockerfile.sqlite | 41 ----------------------------------------- 2 files changed, 82 deletions(-) delete mode 100644 Dockerfile.pg delete mode 100644 Dockerfile.sqlite diff --git a/Dockerfile.pg b/Dockerfile.pg deleted file mode 100644 index 8e45068d..00000000 --- a/Dockerfile.pg +++ /dev/null @@ -1,41 +0,0 @@ -FROM node:22-alpine AS builder - -WORKDIR /app - -# COPY package.json package-lock.json ./ -COPY package*.json ./ -RUN npm ci - -COPY . . - -RUN echo 'export * from "./pg";' > server/db/index.ts - -RUN npx drizzle-kit generate --dialect postgresql --schema ./server/db/pg/schema.ts --out init - -RUN npm run build:pg -RUN npm run build:cli - -FROM node:22-alpine AS runner - -WORKDIR /app - -# Curl used for the health checks -RUN apk add --no-cache curl - -# COPY package.json package-lock.json ./ -COPY package*.json ./ -RUN npm ci --omit=dev && npm cache clean --force - -COPY --from=builder /app/.next/standalone ./ -COPY --from=builder /app/.next/static ./.next/static -COPY --from=builder /app/dist ./dist -COPY --from=builder /app/init ./dist/init - -COPY ./cli/wrapper.sh /usr/local/bin/pangctl -RUN chmod +x /usr/local/bin/pangctl ./dist/cli.mjs - -COPY server/db/names.json ./dist/names.json - -COPY public ./public - -CMD ["npm", "run", "start:pg"] diff --git a/Dockerfile.sqlite b/Dockerfile.sqlite deleted file mode 100644 index 6a24a4af..00000000 --- a/Dockerfile.sqlite +++ /dev/null @@ -1,41 +0,0 @@ -FROM node:22-alpine AS builder - -WORKDIR /app - -# COPY package.json package-lock.json ./ -COPY package*.json ./ -RUN npm ci - -COPY . . - -RUN echo 'export * from "./sqlite";' > server/db/index.ts - -RUN npx drizzle-kit generate --dialect sqlite --schema ./server/db/sqlite/schema.ts --out init - -RUN npm run build:sqlite -RUN npm run build:cli - -FROM node:22-alpine AS runner - -WORKDIR /app - -# Curl used for the health checks -RUN apk add --no-cache curl - -# COPY package.json package-lock.json ./ -COPY package*.json ./ -RUN npm ci --omit=dev && npm cache clean --force - -COPY --from=builder /app/.next/standalone ./ -COPY --from=builder /app/.next/static ./.next/static -COPY --from=builder /app/dist ./dist -COPY --from=builder /app/init ./dist/init - -COPY ./cli/wrapper.sh /usr/local/bin/pangctl -RUN chmod +x /usr/local/bin/pangctl ./dist/cli.mjs - -COPY server/db/names.json ./dist/names.json - -COPY public ./public - -CMD ["npm", "run", "start:sqlite"] From c8bea4d7de245d9da55fa5ed686d9c246e97cc52 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 17 Aug 2025 18:20:53 -0700 Subject: [PATCH 50/63] Finish adding arg --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 5da62593..306705ec 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,7 +17,7 @@ RUN echo "export const build = \"$BUILD\" as any;" > server/build.ts RUN npx drizzle-kit generate --dialect sqlite --schema ./server/db/sqlite/schema.ts --out init -RUN npm run build:sqlite +RUN npm run build:$DATABASE RUN npm run build:cli FROM node:22-alpine AS runner @@ -43,4 +43,4 @@ COPY server/db/names.json ./dist/names.json COPY public ./public -CMD ["npm", "run", "start:sqlite"] +CMD ["npm", "run", "start:$DATABASE"] From 632333c49f11eb2bee82142b5d83cedd536bbef1 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 17 Aug 2025 18:31:08 -0700 Subject: [PATCH 51/63] Fix build args again --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 306705ec..241666ed 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,11 +11,11 @@ RUN npm ci COPY . . -RUN echo 'export * from "./\"$DATABASE\";' > server/db/index.ts +RUN echo "export * from ./\"$DATABASE\";" > server/db/index.ts RUN echo "export const build = \"$BUILD\" as any;" > server/build.ts -RUN npx drizzle-kit generate --dialect sqlite --schema ./server/db/sqlite/schema.ts --out init +RUN if [ "$DATABASE" = "pg" ]; then npx drizzle-kit generate --dialect postgresql --schema ./server/db/pg/schema.ts --out init; else npx drizzle-kit generate --dialect $DATABASE --schema ./server/db/$DATABASE/schema.ts --out init; fi RUN npm run build:$DATABASE RUN npm run build:cli From 33a2ac402c7643e2c0727d71d3f134075fd2c764 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 17 Aug 2025 18:36:23 -0700 Subject: [PATCH 52/63] Fix " --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 241666ed..f84cb36d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,7 @@ RUN npm ci COPY . . -RUN echo "export * from ./\"$DATABASE\";" > server/db/index.ts +RUN echo "export * from \"./$DATABASE\";" > server/db/index.ts RUN echo "export const build = \"$BUILD\" as any;" > server/build.ts From 8c8a981452d358c410f014c2aa3cbd7ff4cc664b Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 17 Aug 2025 20:18:10 -0700 Subject: [PATCH 53/63] Make more efficient the cert get --- server/lib/remoteTraefikConfig.ts | 232 +++++++++++++++++++++++++++++- 1 file changed, 225 insertions(+), 7 deletions(-) diff --git a/server/lib/remoteTraefikConfig.ts b/server/lib/remoteTraefikConfig.ts index d6289dea..72e3492e 100644 --- a/server/lib/remoteTraefikConfig.ts +++ b/server/lib/remoteTraefikConfig.ts @@ -12,6 +12,13 @@ export class TraefikConfigManager { private isRunning = false; private activeDomains = new Set(); private timeoutId: NodeJS.Timeout | null = null; + private lastCertificateFetch: Date | null = null; + private lastKnownDomains = new Set(); + private lastLocalCertificateState = new Map(); constructor() {} @@ -50,6 +57,10 @@ export class TraefikConfigManager { config.getRawConfig().traefik.certificates_path ); + // Initialize local certificate state + this.lastLocalCertificateState = await this.scanLocalCertificateState(); + logger.info(`Found ${this.lastLocalCertificateState.size} existing certificate directories`); + // Run initial check await this.HandleTraefikConfig(); @@ -80,6 +91,113 @@ export class TraefikConfigManager { logger.info("Certificate monitor stopped"); } + /** + * Scan local certificate directories to build current state + */ + private async scanLocalCertificateState(): Promise> { + const state = new Map(); + const certsPath = config.getRawConfig().traefik.certificates_path; + + try { + if (!fs.existsSync(certsPath)) { + return state; + } + + const certDirs = fs.readdirSync(certsPath, { withFileTypes: true }); + + for (const dirent of certDirs) { + if (!dirent.isDirectory()) continue; + + const domain = dirent.name; + const domainDir = path.join(certsPath, domain); + const certPath = path.join(domainDir, "cert.pem"); + const keyPath = path.join(domainDir, "key.pem"); + const lastUpdatePath = path.join(domainDir, ".last_update"); + + const certExists = await this.fileExists(certPath); + const keyExists = await this.fileExists(keyPath); + const lastUpdateExists = await this.fileExists(lastUpdatePath); + + let lastModified: Date | null = null; + let expiresAt: Date | null = null; + + if (lastUpdateExists) { + try { + const lastUpdateStr = fs.readFileSync(lastUpdatePath, "utf8").trim(); + lastModified = new Date(lastUpdateStr); + } catch { + // If we can't read the last update, fall back to file stats + try { + const stats = fs.statSync(certPath); + lastModified = stats.mtime; + } catch { + lastModified = null; + } + } + } + + state.set(domain, { + exists: certExists && keyExists, + lastModified, + expiresAt + }); + } + } catch (error) { + logger.error("Error scanning local certificate state:", error); + } + + return state; + } + + /** + * Check if we need to fetch certificates from remote + */ + private shouldFetchCertificates(currentDomains: Set): boolean { + // Always fetch on first run + if (!this.lastCertificateFetch) { + return true; + } + + // Fetch if it's been more than 24 hours (for renewals) + const dayInMs = 24 * 60 * 60 * 1000; + const timeSinceLastFetch = Date.now() - this.lastCertificateFetch.getTime(); + if (timeSinceLastFetch > dayInMs) { + logger.info("Fetching certificates due to 24-hour renewal check"); + return true; + } + + // Fetch if domains have changed + if (this.lastKnownDomains.size !== currentDomains.size || + !Array.from(this.lastKnownDomains).every(domain => currentDomains.has(domain))) { + logger.info("Fetching certificates due to domain changes"); + return true; + } + + // Check if any local certificates are missing or appear to be outdated + for (const domain of currentDomains) { + const localState = this.lastLocalCertificateState.get(domain); + if (!localState || !localState.exists) { + logger.info(`Fetching certificates due to missing local cert for ${domain}`); + return true; + } + + // Check if certificate is expiring soon (within 30 days) + if (localState.expiresAt) { + const daysUntilExpiry = (localState.expiresAt.getTime() - Date.now()) / (1000 * 60 * 60 * 24); + if (daysUntilExpiry < 30) { + logger.info(`Fetching certificates due to upcoming expiry for ${domain} (${Math.round(daysUntilExpiry)} days remaining)`); + return true; + } + } + } + + return false; + } + /** * Main monitoring logic */ @@ -115,14 +233,37 @@ export class TraefikConfigManager { this.lastActiveDomains = new Set(domains); } - // Get valid certificates for active domains - const validCertificates = - await this.getValidCertificatesForDomains(domains); + // Scan current local certificate state + this.lastLocalCertificateState = await this.scanLocalCertificateState(); - // logger.debug(`Valid certs array: ${JSON.stringify(validCertificates)}`); + // Only fetch certificates if needed (domain changes, missing certs, or daily renewal check) + let validCertificates: Array<{ + id: number; + domain: string; + certFile: string | null; + keyFile: string | null; + expiresAt: Date | null; + updatedAt?: Date | null; + }> = []; - // Download and decrypt new certificates - await this.processValidCertificates(validCertificates); + if (this.shouldFetchCertificates(domains)) { + // Get valid certificates for active domains + validCertificates = await this.getValidCertificatesForDomains(domains); + this.lastCertificateFetch = new Date(); + this.lastKnownDomains = new Set(domains); + + logger.info(`Fetched ${validCertificates.length} certificates from remote`); + + // Download and decrypt new certificates + await this.processValidCertificates(validCertificates); + } else { + const timeSinceLastFetch = this.lastCertificateFetch ? + Math.round((Date.now() - this.lastCertificateFetch.getTime()) / (1000 * 60)) : 0; + logger.debug(`Skipping certificate fetch - no changes detected and within 24-hour window (last fetch: ${timeSinceLastFetch} minutes ago)`); + + // Still need to ensure config is up to date with existing certificates + await this.updateDynamicConfigFromLocalCerts(domains); + } // Clean up certificates for domains no longer in use await this.cleanupUnusedCertificates(domains); @@ -301,6 +442,59 @@ export class TraefikConfigManager { } } + /** + * Update dynamic config from existing local certificates without fetching from remote + */ + private async updateDynamicConfigFromLocalCerts(domains: Set): Promise { + const dynamicConfigPath = config.getRawConfig().traefik.dynamic_cert_config_path; + + // Load existing dynamic config if it exists, otherwise initialize + let dynamicConfig: any = { tls: { certificates: [] } }; + if (fs.existsSync(dynamicConfigPath)) { + try { + const fileContent = fs.readFileSync(dynamicConfigPath, "utf8"); + dynamicConfig = yaml.load(fileContent) || dynamicConfig; + if (!dynamicConfig.tls) dynamicConfig.tls = { certificates: [] }; + if (!Array.isArray(dynamicConfig.tls.certificates)) { + dynamicConfig.tls.certificates = []; + } + } catch (err) { + logger.error("Failed to load existing dynamic config:", err); + } + } + + // Keep a copy of the original config for comparison + const originalConfigYaml = yaml.dump(dynamicConfig, { noRefs: true }); + + // Clear existing certificates and rebuild from local state + dynamicConfig.tls.certificates = []; + + for (const domain of domains) { + const localState = this.lastLocalCertificateState.get(domain); + if (localState && localState.exists) { + const domainDir = path.join( + config.getRawConfig().traefik.certificates_path, + domain + ); + const certPath = path.join(domainDir, "cert.pem"); + const keyPath = path.join(domainDir, "key.pem"); + + const certEntry = { + certFile: `/var/${certPath}`, + keyFile: `/var/${keyPath}` + }; + dynamicConfig.tls.certificates.push(certEntry); + } + } + + // Only write the config if it has changed + const newConfigYaml = yaml.dump(dynamicConfig, { noRefs: true }); + if (newConfigYaml !== originalConfigYaml) { + fs.writeFileSync(dynamicConfigPath, newConfigYaml, "utf8"); + logger.info("Dynamic cert config updated from local certificates"); + } + } + /** * Get valid certificates for the specified domains */ @@ -446,6 +640,13 @@ export class TraefikConfigManager { logger.info( `Certificate updated for domain: ${cert.domain}` ); + + // Update local state tracking + this.lastLocalCertificateState.set(cert.domain, { + exists: true, + lastModified: new Date(), + expiresAt: cert.expiresAt + }); } // Always ensure the config entry exists and is up to date @@ -591,6 +792,9 @@ export class TraefikConfigManager { ); fs.rmSync(domainDir, { recursive: true, force: true }); + // Remove from local state tracking + this.lastLocalCertificateState.delete(dirName); + // Remove from dynamic config const certFilePath = `/var/${path.join( domainDir, @@ -657,6 +861,16 @@ export class TraefikConfigManager { } } + /** + * Force a certificate refresh regardless of cache state + */ + public async forceCertificateRefresh(): Promise { + logger.info("Forcing certificate refresh"); + this.lastCertificateFetch = null; + this.lastKnownDomains = new Set(); + await this.HandleTraefikConfig(); + } + /** * Get current status */ @@ -664,12 +878,16 @@ export class TraefikConfigManager { isRunning: boolean; activeDomains: string[]; monitorInterval: number; + lastCertificateFetch: Date | null; + localCertificateCount: number; } { return { isRunning: this.isRunning, activeDomains: Array.from(this.activeDomains), monitorInterval: - config.getRawConfig().traefik.monitor_interval || 5000 + config.getRawConfig().traefik.monitor_interval || 5000, + lastCertificateFetch: this.lastCertificateFetch, + localCertificateCount: this.lastLocalCertificateState.size }; } } From 36c0d9aba2997d4583d31f7efda6bf55a89bf9b2 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sun, 17 Aug 2025 21:29:07 -0700 Subject: [PATCH 54/63] add hybrid splash --- messages/en-US.json | 1 + server/lib/config.ts | 4 +- server/lib/readConfigFile.ts | 1 + server/routers/external.ts | 10 +- src/app/admin/managed/page.tsx | 176 +++++++++++++++++++++++++++++++ src/app/navigation.tsx | 15 ++- src/components/LayoutSidebar.tsx | 32 +++++- src/lib/types/env.ts | 2 +- 8 files changed, 231 insertions(+), 10 deletions(-) create mode 100644 src/app/admin/managed/page.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 7f00e40d..602754d8 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -973,6 +973,7 @@ "logoutError": "Error logging out", "signingAs": "Signed in as", "serverAdmin": "Server Admin", + "managedSelfhosted": "Managed Self-Hosted", "otpEnable": "Enable Two-factor", "otpDisable": "Disable Two-factor", "logout": "Log Out", diff --git a/server/lib/config.ts b/server/lib/config.ts index 6b41df79..82932441 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -103,9 +103,7 @@ export class Config { private async checkKeyStatus() { const licenseStatus = await license.check(); - if ( - !licenseStatus.isHostLicensed - ) { + if (!licenseStatus.isHostLicensed) { this.checkSupporterKey(); } } diff --git a/server/lib/readConfigFile.ts b/server/lib/readConfigFile.ts index 23db4e52..8107385c 100644 --- a/server/lib/readConfigFile.ts +++ b/server/lib/readConfigFile.ts @@ -34,6 +34,7 @@ export const configSchema = z }), hybrid: z .object({ + name: z.string().optional(), id: z.string().optional(), secret: z.string().optional(), endpoint: z.string().optional(), diff --git a/server/routers/external.ts b/server/routers/external.ts index fd7fff50..95add3ed 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -15,6 +15,7 @@ import * as accessToken from "./accessToken"; import * as idp from "./idp"; import * as license from "./license"; import * as apiKeys from "./apiKeys"; +import * as hybrid from "./hybrid"; import HttpCode from "@server/types/HttpCode"; import { verifyAccessTokenAccess, @@ -951,7 +952,8 @@ authRouter.post( rateLimit({ windowMs: 15 * 60 * 1000, max: 15, - keyGenerator: (req) => `requestEmailVerificationCode:${req.body.email || req.ip}`, + keyGenerator: (req) => + `requestEmailVerificationCode:${req.body.email || req.ip}`, handler: (req, res, next) => { const message = `You can only request an email verification code ${15} times every ${15} minutes. Please try again later.`; return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); @@ -972,7 +974,8 @@ authRouter.post( rateLimit({ windowMs: 15 * 60 * 1000, max: 15, - keyGenerator: (req) => `requestPasswordReset:${req.body.email || req.ip}`, + keyGenerator: (req) => + `requestPasswordReset:${req.body.email || req.ip}`, handler: (req, res, next) => { const message = `You can only request a password reset ${15} times every ${15} minutes. Please try again later.`; return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); @@ -1066,7 +1069,8 @@ authRouter.post( rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 5, // Allow 5 security key registrations per 15 minutes - keyGenerator: (req) => `securityKeyRegister:${req.user?.userId || req.ip}`, + keyGenerator: (req) => + `securityKeyRegister:${req.user?.userId || req.ip}`, handler: (req, res, next) => { const message = `You can only register a security key ${5} times every ${15} minutes. Please try again later.`; return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); diff --git a/src/app/admin/managed/page.tsx b/src/app/admin/managed/page.tsx new file mode 100644 index 00000000..cb25ba5d --- /dev/null +++ b/src/app/admin/managed/page.tsx @@ -0,0 +1,176 @@ +"use client"; + +import { + SettingsContainer, + SettingsSection, + SettingsSectionTitle as SectionTitle, + SettingsSectionBody, + SettingsSectionFooter +} from "@app/components/Settings"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { Alert } from "@app/components/ui/alert"; +import { Button } from "@app/components/ui/button"; +import { + Shield, + Zap, + RefreshCw, + Activity, + Wrench, + CheckCircle, + ExternalLink +} from "lucide-react"; +import Link from "next/link"; + +export default async function ManagedPage() { + return ( + <> + + + + + +

+ Managed Self-Hosted Pangolin is a + deployment option designed for people who want + simplicity and extra reliability while still keeping + their data private and self-hosted. +

+

+ With this option, you still run your own Pangolin + node — your tunnels, SSL termination, and traffic + all stay on your server. The difference is that + management and monitoring are handled through our + cloud dashboard, which unlocks a number of benefits: +

+ +
+
+
+ +
+

+ Simpler operations +

+

+ No need to run your own mail server + or set up complex alerting. You'll + get health checks and downtime + alerts out of the box. +

+
+
+ +
+ +
+

+ Automatic updates +

+

+ The cloud dashboard evolves quickly, + so you get new features and bug + fixes without having to manually + pull new containers every time. +

+
+
+ +
+ +
+

+ Less maintenance +

+

+ No database migrations, backups, or + extra infrastructure to manage. We + handle that in the cloud. +

+
+
+
+ +
+
+ +
+

+ Cloud failover +

+

+ If your node goes down, your tunnels + can temporarily fail over to our + cloud points of presence until you + bring it back online. +

+
+
+
+ +
+

+ High availability (PoPs) +

+

+ You can also attach multiple nodes + to your account for redundancy and + better performance. +

+
+
+ +
+ +
+

+ Future enhancements +

+

+ We're planning to add more + analytics, alerting, and management + tools to make your deployment even + more robust. +

+
+
+
+
+ + + Read the docs to learn more about the Managed + Self-Hosted option in our{" "} + + documentation + + + . + +
+ + + + + +
+
+ + ); +} diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index b26b98ec..f77bf3a9 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -13,10 +13,12 @@ import { TicketCheck, User, Globe, // Added from 'dev' branch - MonitorUp // Added from 'dev' branch + MonitorUp, // Added from 'dev' branch + Zap } from "lucide-react"; -export type SidebarNavSection = { // Added from 'dev' branch +export type SidebarNavSection = { + // Added from 'dev' branch heading: string; items: SidebarNavItem[]; }; @@ -108,6 +110,15 @@ export const adminNavSections: SidebarNavSection[] = [ { heading: "Admin", items: [ + ...(build == "oss" + ? [ + { + title: "managedSelfhosted", + href: "/admin/managed", + icon: + } + ] + : []), { title: "sidebarAllUsers", href: "/admin/users", diff --git a/src/components/LayoutSidebar.tsx b/src/components/LayoutSidebar.tsx index d309c11f..cfc21144 100644 --- a/src/components/LayoutSidebar.tsx +++ b/src/components/LayoutSidebar.tsx @@ -6,7 +6,7 @@ import { OrgSelector } from "@app/components/OrgSelector"; import { cn } from "@app/lib/cn"; import { ListUserOrgsResponse } from "@server/routers/org"; import SupporterStatus from "@app/components/SupporterStatus"; -import { ExternalLink, Server, BookOpenText } from "lucide-react"; +import { ExternalLink, Server, BookOpenText, Zap } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { useUserContext } from "@app/hooks/useUserContext"; @@ -20,6 +20,7 @@ import { TooltipProvider, TooltipTrigger } from "@app/components/ui/tooltip"; +import { build } from "@server/build"; interface LayoutSidebarProps { orgId?: string; @@ -73,6 +74,35 @@ export function LayoutSidebar({
{!isAdminPage && user.serverAdmin && (
+ {build === "oss" && ( + + + + + {!isSidebarCollapsed && ( + {t("managedSelfhosted")} + )} + + )} + Date: Sun, 17 Aug 2025 21:44:28 -0700 Subject: [PATCH 55/63] Also allow local traefikConfig --- server/lib/remoteCertificates/certificates.ts | 78 ++++++ server/lib/remoteCertificates/index.ts | 1 + ...emoteTraefikConfig.ts => traefikConfig.ts} | 249 +++++++++--------- server/routers/traefik/getTraefikConfig.ts | 73 ++--- 4 files changed, 253 insertions(+), 148 deletions(-) create mode 100644 server/lib/remoteCertificates/certificates.ts create mode 100644 server/lib/remoteCertificates/index.ts rename server/lib/{remoteTraefikConfig.ts => traefikConfig.ts} (85%) diff --git a/server/lib/remoteCertificates/certificates.ts b/server/lib/remoteCertificates/certificates.ts new file mode 100644 index 00000000..f9d98e93 --- /dev/null +++ b/server/lib/remoteCertificates/certificates.ts @@ -0,0 +1,78 @@ +import axios from "axios"; +import { tokenManager } from "../tokenManager"; +import logger from "@server/logger"; +import config from "../config"; + +/** + * Get valid certificates for the specified domains + */ +export async function getValidCertificatesForDomainsHybrid(domains: Set): Promise< + Array<{ + id: number; + domain: string; + certFile: string | null; + keyFile: string | null; + expiresAt: Date | null; + updatedAt?: Date | null; + }> +> { + if (domains.size === 0) { + return []; + } + + const domainArray = Array.from(domains); + + try { + const response = await axios.get( + `${config.getRawConfig().hybrid?.endpoint}/api/v1/hybrid/certificates/domains`, + { + params: { + domains: domainArray + }, + headers: (await tokenManager.getAuthHeader()).headers + } + ); + + if (response.status !== 200) { + logger.error( + `Failed to fetch certificates for domains: ${response.status} ${response.statusText}`, + { responseData: response.data, domains: domainArray } + ); + return []; + } + + // logger.debug( + // `Successfully retrieved ${response.data.data?.length || 0} certificates for ${domainArray.length} domains` + // ); + + return response.data.data; + } catch (error) { + // pull data out of the axios error to log + if (axios.isAxiosError(error)) { + logger.error("Error getting certificates:", { + message: error.message, + code: error.code, + status: error.response?.status, + statusText: error.response?.statusText, + url: error.config?.url, + method: error.config?.method + }); + } else { + logger.error("Error getting certificates:", error); + } + return []; + } +} + +export async function getValidCertificatesForDomains(domains: Set): Promise< + Array<{ + id: number; + domain: string; + certFile: string | null; + keyFile: string | null; + expiresAt: Date | null; + updatedAt?: Date | null; + }> +> { + return []; // stub +} \ No newline at end of file diff --git a/server/lib/remoteCertificates/index.ts b/server/lib/remoteCertificates/index.ts new file mode 100644 index 00000000..53051b6c --- /dev/null +++ b/server/lib/remoteCertificates/index.ts @@ -0,0 +1 @@ +export * from "./certificates"; \ No newline at end of file diff --git a/server/lib/remoteTraefikConfig.ts b/server/lib/traefikConfig.ts similarity index 85% rename from server/lib/remoteTraefikConfig.ts rename to server/lib/traefikConfig.ts index 72e3492e..d62c5f7f 100644 --- a/server/lib/remoteTraefikConfig.ts +++ b/server/lib/traefikConfig.ts @@ -5,7 +5,16 @@ import logger from "@server/logger"; import * as yaml from "js-yaml"; import axios from "axios"; import { db, exitNodes } from "@server/db"; +import { eq } from "drizzle-orm"; import { tokenManager } from "./tokenManager"; +import { + getCurrentExitNodeId, + getTraefikConfig +} from "@server/routers/traefik"; +import { + getValidCertificatesForDomains, + getValidCertificatesForDomainsHybrid +} from "./remoteCertificates"; export class TraefikConfigManager { private intervalId: NodeJS.Timeout | null = null; @@ -14,11 +23,14 @@ export class TraefikConfigManager { private timeoutId: NodeJS.Timeout | null = null; private lastCertificateFetch: Date | null = null; private lastKnownDomains = new Set(); - private lastLocalCertificateState = new Map(); + private lastLocalCertificateState = new Map< + string, + { + exists: boolean; + lastModified: Date | null; + expiresAt: Date | null; + } + >(); constructor() {} @@ -59,7 +71,9 @@ export class TraefikConfigManager { // Initialize local certificate state this.lastLocalCertificateState = await this.scanLocalCertificateState(); - logger.info(`Found ${this.lastLocalCertificateState.size} existing certificate directories`); + logger.info( + `Found ${this.lastLocalCertificateState.size} existing certificate directories` + ); // Run initial check await this.HandleTraefikConfig(); @@ -94,40 +108,47 @@ export class TraefikConfigManager { /** * Scan local certificate directories to build current state */ - private async scanLocalCertificateState(): Promise> { + private async scanLocalCertificateState(): Promise< + Map< + string, + { + exists: boolean; + lastModified: Date | null; + expiresAt: Date | null; + } + > + > { const state = new Map(); const certsPath = config.getRawConfig().traefik.certificates_path; - + try { if (!fs.existsSync(certsPath)) { return state; } const certDirs = fs.readdirSync(certsPath, { withFileTypes: true }); - + for (const dirent of certDirs) { if (!dirent.isDirectory()) continue; - + const domain = dirent.name; const domainDir = path.join(certsPath, domain); const certPath = path.join(domainDir, "cert.pem"); const keyPath = path.join(domainDir, "key.pem"); const lastUpdatePath = path.join(domainDir, ".last_update"); - + const certExists = await this.fileExists(certPath); const keyExists = await this.fileExists(keyPath); const lastUpdateExists = await this.fileExists(lastUpdatePath); - + let lastModified: Date | null = null; let expiresAt: Date | null = null; - + if (lastUpdateExists) { try { - const lastUpdateStr = fs.readFileSync(lastUpdatePath, "utf8").trim(); + const lastUpdateStr = fs + .readFileSync(lastUpdatePath, "utf8") + .trim(); lastModified = new Date(lastUpdateStr); } catch { // If we can't read the last update, fall back to file stats @@ -139,7 +160,7 @@ export class TraefikConfigManager { } } } - + state.set(domain, { exists: certExists && keyExists, lastModified, @@ -149,7 +170,7 @@ export class TraefikConfigManager { } catch (error) { logger.error("Error scanning local certificate state:", error); } - + return state; } @@ -161,40 +182,51 @@ export class TraefikConfigManager { if (!this.lastCertificateFetch) { return true; } - + // Fetch if it's been more than 24 hours (for renewals) const dayInMs = 24 * 60 * 60 * 1000; - const timeSinceLastFetch = Date.now() - this.lastCertificateFetch.getTime(); + const timeSinceLastFetch = + Date.now() - this.lastCertificateFetch.getTime(); if (timeSinceLastFetch > dayInMs) { logger.info("Fetching certificates due to 24-hour renewal check"); return true; } - + // Fetch if domains have changed - if (this.lastKnownDomains.size !== currentDomains.size || - !Array.from(this.lastKnownDomains).every(domain => currentDomains.has(domain))) { + if ( + this.lastKnownDomains.size !== currentDomains.size || + !Array.from(this.lastKnownDomains).every((domain) => + currentDomains.has(domain) + ) + ) { logger.info("Fetching certificates due to domain changes"); return true; } - + // Check if any local certificates are missing or appear to be outdated for (const domain of currentDomains) { const localState = this.lastLocalCertificateState.get(domain); if (!localState || !localState.exists) { - logger.info(`Fetching certificates due to missing local cert for ${domain}`); + logger.info( + `Fetching certificates due to missing local cert for ${domain}` + ); return true; } - + // Check if certificate is expiring soon (within 30 days) if (localState.expiresAt) { - const daysUntilExpiry = (localState.expiresAt.getTime() - Date.now()) / (1000 * 60 * 60 * 24); + const daysUntilExpiry = + (localState.expiresAt.getTime() - Date.now()) / + (1000 * 60 * 60 * 24); if (daysUntilExpiry < 30) { - logger.info(`Fetching certificates due to upcoming expiry for ${domain} (${Math.round(daysUntilExpiry)} days remaining)`); + logger.info( + `Fetching certificates due to upcoming expiry for ${domain} (${Math.round(daysUntilExpiry)} days remaining)` + ); return true; } } } - + return false; } @@ -234,7 +266,8 @@ export class TraefikConfigManager { } // Scan current local certificate state - this.lastLocalCertificateState = await this.scanLocalCertificateState(); + this.lastLocalCertificateState = + await this.scanLocalCertificateState(); // Only fetch certificates if needed (domain changes, missing certs, or daily renewal check) let validCertificates: Array<{ @@ -248,19 +281,33 @@ export class TraefikConfigManager { if (this.shouldFetchCertificates(domains)) { // Get valid certificates for active domains - validCertificates = await this.getValidCertificatesForDomains(domains); + if (config.isHybridMode()) { + validCertificates = + await getValidCertificatesForDomainsHybrid(domains); + } else { + validCertificates = + await getValidCertificatesForDomains(domains); + } this.lastCertificateFetch = new Date(); this.lastKnownDomains = new Set(domains); - - logger.info(`Fetched ${validCertificates.length} certificates from remote`); + + logger.info( + `Fetched ${validCertificates.length} certificates from remote` + ); // Download and decrypt new certificates await this.processValidCertificates(validCertificates); } else { - const timeSinceLastFetch = this.lastCertificateFetch ? - Math.round((Date.now() - this.lastCertificateFetch.getTime()) / (1000 * 60)) : 0; - logger.debug(`Skipping certificate fetch - no changes detected and within 24-hour window (last fetch: ${timeSinceLastFetch} minutes ago)`); - + const timeSinceLastFetch = this.lastCertificateFetch + ? Math.round( + (Date.now() - this.lastCertificateFetch.getTime()) / + (1000 * 60) + ) + : 0; + logger.debug( + `Skipping certificate fetch - no changes detected and within 24-hour window (last fetch: ${timeSinceLastFetch} minutes ago)` + ); + // Still need to ensure config is up to date with existing certificates await this.updateDynamicConfigFromLocalCerts(domains); } @@ -276,7 +323,18 @@ export class TraefikConfigManager { // Send domains to SNI proxy try { - const [exitNode] = await db.select().from(exitNodes).limit(1); + let exitNode; + if (config.getRawConfig().gerbil.exit_node_name) { + const exitNodeName = + config.getRawConfig().gerbil.exit_node_name!; + [exitNode] = await db + .select() + .from(exitNodes) + .where(eq(exitNodes.name, exitNodeName)) + .limit(1); + } else { + [exitNode] = await db.select().from(exitNodes).limit(1); + } if (exitNode) { try { await axios.post( @@ -300,7 +358,9 @@ export class TraefikConfigManager { } } } else { - logger.error("No exit node found. Has gerbil registered yet?"); + logger.error( + "No exit node found. Has gerbil registered yet?" + ); } } catch (err) { logger.error("Failed to post domains to SNI proxy:", err); @@ -320,21 +380,31 @@ export class TraefikConfigManager { domains: Set; traefikConfig: any; } | null> { + let traefikConfig; try { - const resp = await axios.get( - `${config.getRawConfig().hybrid?.endpoint}/api/v1/hybrid/traefik-config`, - await tokenManager.getAuthHeader() - ); - - if (resp.status !== 200) { - logger.error( - `Failed to fetch traefik config: ${resp.status} ${resp.statusText}`, - { responseData: resp.data } + if (config.isHybridMode()) { + const resp = await axios.get( + `${config.getRawConfig().hybrid?.endpoint}/api/v1/hybrid/traefik-config`, + await tokenManager.getAuthHeader() + ); + + if (resp.status !== 200) { + logger.error( + `Failed to fetch traefik config: ${resp.status} ${resp.statusText}`, + { responseData: resp.data } + ); + return null; + } + + traefikConfig = resp.data.data; + } else { + const currentExitNode = await getCurrentExitNodeId(); + traefikConfig = await getTraefikConfig( + currentExitNode, + config.getRawConfig().traefik.site_types ); - return null; } - const traefikConfig = resp.data.data; const domains = new Set(); if (traefikConfig?.http?.routers) { @@ -445,16 +515,20 @@ export class TraefikConfigManager { /** * Update dynamic config from existing local certificates without fetching from remote */ - private async updateDynamicConfigFromLocalCerts(domains: Set): Promise { - const dynamicConfigPath = config.getRawConfig().traefik.dynamic_cert_config_path; - + private async updateDynamicConfigFromLocalCerts( + domains: Set + ): Promise { + const dynamicConfigPath = + config.getRawConfig().traefik.dynamic_cert_config_path; + // Load existing dynamic config if it exists, otherwise initialize let dynamicConfig: any = { tls: { certificates: [] } }; if (fs.existsSync(dynamicConfigPath)) { try { const fileContent = fs.readFileSync(dynamicConfigPath, "utf8"); dynamicConfig = yaml.load(fileContent) || dynamicConfig; - if (!dynamicConfig.tls) dynamicConfig.tls = { certificates: [] }; + if (!dynamicConfig.tls) + dynamicConfig.tls = { certificates: [] }; if (!Array.isArray(dynamicConfig.tls.certificates)) { dynamicConfig.tls.certificates = []; } @@ -495,67 +569,6 @@ export class TraefikConfigManager { } } - /** - * Get valid certificates for the specified domains - */ - private async getValidCertificatesForDomains(domains: Set): Promise< - Array<{ - id: number; - domain: string; - certFile: string | null; - keyFile: string | null; - expiresAt: Date | null; - updatedAt?: Date | null; - }> - > { - if (domains.size === 0) { - return []; - } - - const domainArray = Array.from(domains); - - try { - const response = await axios.get( - `${config.getRawConfig().hybrid?.endpoint}/api/v1/hybrid/certificates/domains`, - { - params: { - domains: domainArray - }, - headers: (await tokenManager.getAuthHeader()).headers - } - ); - - if (response.status !== 200) { - logger.error( - `Failed to fetch certificates for domains: ${response.status} ${response.statusText}`, - { responseData: response.data, domains: domainArray } - ); - return []; - } - - // logger.debug( - // `Successfully retrieved ${response.data.data?.length || 0} certificates for ${domainArray.length} domains` - // ); - - return response.data.data; - } catch (error) { - // pull data out of the axios error to log - if (axios.isAxiosError(error)) { - logger.error("Error getting certificates:", { - message: error.message, - code: error.code, - status: error.response?.status, - statusText: error.response?.statusText, - url: error.config?.url, - method: error.config?.method - }); - } else { - logger.error("Error getting certificates:", error); - } - return []; - } - } - /** * Process valid certificates - download and decrypt them */ @@ -640,7 +653,7 @@ export class TraefikConfigManager { logger.info( `Certificate updated for domain: ${cert.domain}` ); - + // Update local state tracking this.lastLocalCertificateState.set(cert.domain, { exists: true, diff --git a/server/routers/traefik/getTraefikConfig.ts b/server/routers/traefik/getTraefikConfig.ts index 4ec2908c..918df3fd 100644 --- a/server/routers/traefik/getTraefikConfig.ts +++ b/server/routers/traefik/getTraefikConfig.ts @@ -11,6 +11,35 @@ let currentExitNodeId: number; const redirectHttpsMiddlewareName = "redirect-to-https"; const badgerMiddlewareName = "badger"; +export async function getCurrentExitNodeId(): Promise { + if (!currentExitNodeId) { + if (config.getRawConfig().gerbil.exit_node_name) { + const exitNodeName = config.getRawConfig().gerbil.exit_node_name!; + const [exitNode] = await db + .select({ + exitNodeId: exitNodes.exitNodeId + }) + .from(exitNodes) + .where(eq(exitNodes.name, exitNodeName)); + if (exitNode) { + currentExitNodeId = exitNode.exitNodeId; + } + } else { + const [exitNode] = await db + .select({ + exitNodeId: exitNodes.exitNodeId + }) + .from(exitNodes) + .limit(1); + + if (exitNode) { + currentExitNodeId = exitNode.exitNodeId; + } + } + } + return currentExitNodeId; +} + export async function traefikConfigProvider( _: Request, res: Response @@ -18,34 +47,12 @@ export async function traefikConfigProvider( try { // First query to get resources with site and org info // Get the current exit node name from config - if (!currentExitNodeId) { - if (config.getRawConfig().gerbil.exit_node_name) { - const exitNodeName = - config.getRawConfig().gerbil.exit_node_name!; - const [exitNode] = await db - .select({ - exitNodeId: exitNodes.exitNodeId - }) - .from(exitNodes) - .where(eq(exitNodes.name, exitNodeName)); - if (exitNode) { - currentExitNodeId = exitNode.exitNodeId; - } - } else { - const [exitNode] = await db - .select({ - exitNodeId: exitNodes.exitNodeId - }) - .from(exitNodes) - .limit(1); + await getCurrentExitNodeId(); - if (exitNode) { - currentExitNodeId = exitNode.exitNodeId; - } - } - } - - let traefikConfig = await getTraefikConfig(currentExitNodeId, config.getRawConfig().traefik.site_types); + let traefikConfig = await getTraefikConfig( + currentExitNodeId, + config.getRawConfig().traefik.site_types + ); traefikConfig.http.middlewares[badgerMiddlewareName] = { plugin: { @@ -80,7 +87,10 @@ export async function traefikConfigProvider( } } -export async function getTraefikConfig(exitNodeId: number, siteTypes: string[]): Promise { +export async function getTraefikConfig( + exitNodeId: number, + siteTypes: string[] +): Promise { // Define extended target type with site information type TargetWithSite = Target & { site: { @@ -135,7 +145,7 @@ export async function getTraefikConfig(exitNodeId: number, siteTypes: string[]): eq(sites.exitNodeId, exitNodeId), isNull(sites.exitNodeId) ), - inArray(sites.type, siteTypes), + inArray(sites.type, siteTypes) ) ); @@ -438,7 +448,10 @@ export async function getTraefikConfig(exitNodeId: number, siteTypes: string[]): return false; } } else if (target.site.type === "newt") { - if (!target.internalPort || !target.site.subnet) { + if ( + !target.internalPort || + !target.site.subnet + ) { return false; } } From 7dc74cb61b3e5eb5fddd97aec0bad030b7194c4e Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 17 Aug 2025 21:45:17 -0700 Subject: [PATCH 56/63] Fix import for traefikConfig --- server/hybridServer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/hybridServer.ts b/server/hybridServer.ts index c0d342cf..e38ca088 100644 --- a/server/hybridServer.ts +++ b/server/hybridServer.ts @@ -3,7 +3,7 @@ import config from "@server/lib/config"; import { createWebSocketClient } from "./routers/ws/client"; import { addPeer, deletePeer } from "./routers/gerbil/peers"; import { db, exitNodes } from "./db"; -import { TraefikConfigManager } from "./lib/remoteTraefikConfig"; +import { TraefikConfigManager } from "./lib/traefikConfig"; import { tokenManager } from "./lib/tokenManager"; import { APP_VERSION } from "./lib/consts"; import axios from "axios"; From 97fcaed9b46b4f965ed5a884354aea99a864b39e Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 17 Aug 2025 21:58:27 -0700 Subject: [PATCH 57/63] Optionally use file mode --- server/index.ts | 5 +++++ server/lib/readConfigFile.ts | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/server/index.ts b/server/index.ts index 58a0fd24..c8aaff73 100644 --- a/server/index.ts +++ b/server/index.ts @@ -11,6 +11,7 @@ import { createHybridClientServer } from "./hybridServer"; import config from "@server/lib/config"; import { setHostMeta } from "@server/lib/hostMeta"; import { initTelemetryClient } from "./lib/telemetry.js"; +import { TraefikConfigManager } from "./lib/traefikConfig.js"; async function startServers() { await setHostMeta(); @@ -30,6 +31,10 @@ async function startServers() { hybridClientServer = await createHybridClientServer(); } else { nextServer = await createNextServer(); + if (config.getRawConfig().traefik.file_mode) { + const monitor = new TraefikConfigManager(); + await monitor.start(); + } } let integrationServer; diff --git a/server/lib/readConfigFile.ts b/server/lib/readConfigFile.ts index 8107385c..b13d477c 100644 --- a/server/lib/readConfigFile.ts +++ b/server/lib/readConfigFile.ts @@ -156,7 +156,8 @@ export const configSchema = z .optional() .default("./dynamic/router_config.yml"), static_domains: z.array(z.string()).optional().default([]), - site_types: z.array(z.string()).optional().default(["newt", "wireguard", "local"]) + site_types: z.array(z.string()).optional().default(["newt", "wireguard", "local"]), + file_mode: z.boolean().optional().default(false) }) .optional() .default({}), From 9d561ba94dee450d70b2204c1570f88539e51519 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 17 Aug 2025 22:01:30 -0700 Subject: [PATCH 58/63] Remove bad import --- server/routers/external.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/server/routers/external.ts b/server/routers/external.ts index 95add3ed..baa1fd69 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -15,7 +15,6 @@ import * as accessToken from "./accessToken"; import * as idp from "./idp"; import * as license from "./license"; import * as apiKeys from "./apiKeys"; -import * as hybrid from "./hybrid"; import HttpCode from "@server/types/HttpCode"; import { verifyAccessTokenAccess, From 117062f1d1f487ffd3f05e6e687e327773d9ac74 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 17 Aug 2025 22:18:25 -0700 Subject: [PATCH 59/63] One start command --- Dockerfile | 2 +- package.json | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index f84cb36d..996ef057 100644 --- a/Dockerfile +++ b/Dockerfile @@ -43,4 +43,4 @@ COPY server/db/names.json ./dist/names.json COPY public ./public -CMD ["npm", "run", "start:$DATABASE"] +CMD ["npm", "run", "start"] diff --git a/package.json b/package.json index 7b3464a8..e5f2238b 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,7 @@ "db:clear-migrations": "rm -rf server/migrations", "build:sqlite": "mkdir -p dist && next build && node esbuild.mjs -e server/index.ts -o dist/server.mjs && node esbuild.mjs -e server/setup/migrationsSqlite.ts -o dist/migrations.mjs", "build:pg": "mkdir -p dist && next build && node esbuild.mjs -e server/index.ts -o dist/server.mjs && node esbuild.mjs -e server/setup/migrationsPg.ts -o dist/migrations.mjs", - "start:sqlite": "DB_TYPE=sqlite NODE_OPTIONS=--enable-source-maps NODE_ENV=development ENVIRONMENT=prod sh -c 'node dist/migrations.mjs && node dist/server.mjs'", - "start:pg": "DB_TYPE=pg NODE_OPTIONS=--enable-source-maps NODE_ENV=development ENVIRONMENT=prod sh -c 'node dist/migrations.mjs && node dist/server.mjs'", + "start": "DB_TYPE=sqlite NODE_OPTIONS=--enable-source-maps NODE_ENV=development ENVIRONMENT=prod sh -c 'node dist/migrations.mjs && node dist/server.mjs'", "email": "email dev --dir server/emails/templates --port 3005", "build:cli": "node esbuild.mjs -e cli/index.ts -o dist/cli.mjs" }, From cd348201381d8ad2bb3c13a1ce0f4825a1a4c75c Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 18 Aug 2025 12:06:59 -0700 Subject: [PATCH 60/63] prompt for convert node in installer --- install/main.go | 44 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 37 insertions(+), 7 deletions(-) diff --git a/install/main.go b/install/main.go index b08f0073..ca68a769 100644 --- a/install/main.go +++ b/install/main.go @@ -215,7 +215,7 @@ func main() { } } else { fmt.Println("Looks like you already installed, so I am going to do the setup...") - + // Read existing config to get DashboardDomain traefikConfig, err := ReadTraefikConfig("config/traefik/traefik_config.yml", "config/traefik/dynamic_config.yml") if err != nil { @@ -226,19 +226,28 @@ func main() { config.DashboardDomain = traefikConfig.DashboardDomain config.LetsEncryptEmail = traefikConfig.LetsEncryptEmail config.BadgerVersion = traefikConfig.BadgerVersion - + // Show detected values and allow user to confirm or re-enter fmt.Println("Detected existing configuration:") fmt.Printf("Dashboard Domain: %s\n", config.DashboardDomain) fmt.Printf("Let's Encrypt Email: %s\n", config.LetsEncryptEmail) fmt.Printf("Badger Version: %s\n", config.BadgerVersion) - + if !readBool(reader, "Are these values correct?", true) { config = collectUserInput(reader) } } } + // Check if Pangolin is already installed with hybrid section + if checkIsPangolinInstalledWithHybrid() { + fmt.Println("\n=== Convert to Self-Host Node ===") + if readBool(reader, "Do you want to convert this Pangolin instance into a manage self-host node?", true) { + fmt.Println("hello world") + return + } + } + if !checkIsCrowdsecInstalledInCompose() { fmt.Println("\n=== CrowdSec Install ===") // check if crowdsec is installed @@ -276,7 +285,7 @@ func main() { // Setup Token Section fmt.Println("\n=== Setup Token ===") - + // Check if containers were started during this installation containersStarted := false if (isDockerInstalled() && chosenContainer == Docker) || @@ -285,7 +294,7 @@ func main() { containersStarted = true printSetupToken(chosenContainer, config.DashboardDomain) } - + // If containers weren't started or token wasn't found, show instructions if !containersStarted { showSetupTokenInstructions(chosenContainer, config.DashboardDomain) @@ -354,7 +363,7 @@ func collectUserInput(reader *bufio.Reader) Config { // Basic configuration fmt.Println("\n=== Basic Configuration ===") config.BaseDomain = readString(reader, "Enter your base domain (no subdomain e.g. example.com)", "") - + // Set default dashboard domain after base domain is collected defaultDashboardDomain := "" if config.BaseDomain != "" { @@ -816,7 +825,7 @@ func waitForContainer(containerName string, containerType SupportedContainer) er func printSetupToken(containerType SupportedContainer, dashboardDomain string) { fmt.Println("Waiting for Pangolin to generate setup token...") - + // Wait for Pangolin to be healthy if err := waitForContainer("pangolin", containerType); err != nil { fmt.Println("Warning: Pangolin container did not become healthy in time.") @@ -938,3 +947,24 @@ func checkPortsAvailable(port int) error { } return nil } + +func checkIsPangolinInstalledWithHybrid() bool { + // Check if docker-compose.yml exists (indicating Pangolin is installed) + if _, err := os.Stat("docker-compose.yml"); err != nil { + return false + } + + // Check if config/config.yml exists and contains hybrid section + if _, err := os.Stat("config/config.yml"); err != nil { + return false + } + + // Read config file to check for hybrid section + content, err := os.ReadFile("config/config.yml") + if err != nil { + return false + } + + // Check for hybrid section + return bytes.Contains(content, []byte("hybrid:")) +} From c29cd05db8019e2e0fc662c728942a67adb5df22 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 18 Aug 2025 11:55:57 -0700 Subject: [PATCH 61/63] Update to pull defaults from var --- server/lib/readConfigFile.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/lib/readConfigFile.ts b/server/lib/readConfigFile.ts index b13d477c..fa05aebd 100644 --- a/server/lib/readConfigFile.ts +++ b/server/lib/readConfigFile.ts @@ -145,16 +145,16 @@ export const configSchema = z additional_middlewares: z.array(z.string()).optional(), cert_resolver: z.string().optional().default("letsencrypt"), prefer_wildcard_cert: z.boolean().optional().default(false), - certificates_path: z.string().default("./certificates"), + certificates_path: z.string().default("/var/certificates"), monitor_interval: z.number().default(5000), dynamic_cert_config_path: z .string() .optional() - .default("./dynamic/cert_config.yml"), + .default("/var/dynamic/cert_config.yml"), dynamic_router_config_path: z .string() .optional() - .default("./dynamic/router_config.yml"), + .default("/var/dynamic/router_config.yml"), static_domains: z.array(z.string()).optional().default([]), site_types: z.array(z.string()).optional().default(["newt", "wireguard", "local"]), file_mode: z.boolean().optional().default(false) From 9bdf31ee972c40239a90e5d5504d15c8da616487 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 18 Aug 2025 12:22:32 -0700 Subject: [PATCH 62/63] Add csrf to auth --- server/lib/tokenManager.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/lib/tokenManager.ts b/server/lib/tokenManager.ts index e6a03067..45f280ba 100644 --- a/server/lib/tokenManager.ts +++ b/server/lib/tokenManager.ts @@ -156,7 +156,8 @@ export class TokenManager { async getAuthHeader() { return { headers: { - Authorization: `Bearer ${await this.getToken()}` + Authorization: `Bearer ${await this.getToken()}`, + "X-CSRF-Token": "x-csrf-protection", } }; } From ffe2512734d1917cb89f53d5748857bf129e365c Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 18 Aug 2025 15:27:59 -0700 Subject: [PATCH 63/63] Update --- server/lib/traefikConfig.ts | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/server/lib/traefikConfig.ts b/server/lib/traefikConfig.ts index d62c5f7f..a168ea0b 100644 --- a/server/lib/traefikConfig.ts +++ b/server/lib/traefikConfig.ts @@ -304,9 +304,10 @@ export class TraefikConfigManager { (1000 * 60) ) : 0; - logger.debug( - `Skipping certificate fetch - no changes detected and within 24-hour window (last fetch: ${timeSinceLastFetch} minutes ago)` - ); + + // logger.debug( + // `Skipping certificate fetch - no changes detected and within 24-hour window (last fetch: ${timeSinceLastFetch} minutes ago)` + // ); // Still need to ensure config is up to date with existing certificates await this.updateDynamicConfigFromLocalCerts(domains); @@ -554,8 +555,8 @@ export class TraefikConfigManager { const keyPath = path.join(domainDir, "key.pem"); const certEntry = { - certFile: `/var/${certPath}`, - keyFile: `/var/${keyPath}` + certFile: certPath, + keyFile: keyPath }; dynamicConfig.tls.certificates.push(certEntry); } @@ -664,8 +665,8 @@ export class TraefikConfigManager { // Always ensure the config entry exists and is up to date const certEntry = { - certFile: `/var/${certPath}`, - keyFile: `/var/${keyPath}` + certFile: certPath, + keyFile: keyPath }; // Remove any existing entry for this cert/key path dynamicConfig.tls.certificates = @@ -809,14 +810,14 @@ export class TraefikConfigManager { this.lastLocalCertificateState.delete(dirName); // Remove from dynamic config - const certFilePath = `/var/${path.join( + const certFilePath = path.join( domainDir, "cert.pem" - )}`; - const keyFilePath = `/var/${path.join( + ); + const keyFilePath = path.join( domainDir, "key.pem" - )}`; + ); const before = dynamicConfig.tls.certificates.length; dynamicConfig.tls.certificates = dynamicConfig.tls.certificates.filter(