Merge branch 'hybrid' into dev

This commit is contained in:
Owen
2025-08-18 15:29:23 -07:00
54 changed files with 3959 additions and 1319 deletions
+6
View File
@@ -36,4 +36,10 @@ bin
.secrets .secrets
test_event.json test_event.json
.idea/ .idea/
public/branding
server/db/index.ts server/db/index.ts
config/openapi.yaml
server/build.ts
postgres/
dynamic/
certificates/
+9 -4
View File
@@ -2,17 +2,22 @@ FROM node:22-alpine AS builder
WORKDIR /app WORKDIR /app
ARG BUILD=oss
ARG DATABASE=sqlite
# COPY package.json package-lock.json ./ # COPY package.json package-lock.json ./
COPY package*.json ./ COPY package*.json ./
RUN npm ci RUN npm ci
COPY . . COPY . .
RUN echo 'export * from "./pg";' > server/db/index.ts RUN echo "export * from \"./$DATABASE\";" > server/db/index.ts
RUN npx drizzle-kit generate --dialect postgresql --schema ./server/db/pg/schema.ts --out init RUN echo "export const build = \"$BUILD\" as any;" > server/build.ts
RUN npm run build:pg 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 RUN npm run build:cli
FROM node:22-alpine AS runner FROM node:22-alpine AS runner
@@ -38,4 +43,4 @@ COPY server/db/names.json ./dist/names.json
COPY public ./public COPY public ./public
CMD ["npm", "run", "start:pg"] CMD ["npm", "run", "start"]
-41
View File
@@ -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"]
+6 -6
View File
@@ -5,10 +5,10 @@ build-release:
echo "Error: tag is required. Usage: make build-release tag=<tag>"; \ echo "Error: tag is required. Usage: make build-release tag=<tag>"; \
exit 1; \ exit 1; \
fi fi
docker buildx build --platform linux/arm64,linux/amd64 -t fosrl/pangolin:latest -f Dockerfile.sqlite --push . docker buildx build --build-arg DATABASE=sqlite --platform linux/arm64,linux/amd64 -t fosrl/pangolin:latest --push .
docker buildx build --platform linux/arm64,linux/amd64 -t fosrl/pangolin:$(tag) -f Dockerfile.sqlite --push . docker buildx build --build-arg DATABASE=sqlite --platform linux/arm64,linux/amd64 -t fosrl/pangolin:$(tag) --push .
docker buildx build --platform linux/arm64,linux/amd64 -t fosrl/pangolin:postgresql-latest -f Dockerfile.pg --push . docker buildx build --build-arg DATABASE=pg --platform linux/arm64,linux/amd64 -t fosrl/pangolin:postgresql-latest --push .
docker buildx build --platform linux/arm64,linux/amd64 -t fosrl/pangolin:postgresql-$(tag) -f Dockerfile.pg --push . docker buildx build --build-arg DATABASE=pg --platform linux/arm64,linux/amd64 -t fosrl/pangolin:postgresql-$(tag) --push .
build-arm: build-arm:
docker buildx build --platform linux/arm64 -t fosrl/pangolin:latest . 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 . docker buildx build --platform linux/amd64 -t fosrl/pangolin:latest .
build-sqlite: build-sqlite:
docker build -t fosrl/pangolin:latest -f Dockerfile.sqlite . docker build --build-arg DATABASE=sqlite -t fosrl/pangolin:latest .
build-pg: build-pg:
docker build -t fosrl/pangolin:postgresql-latest -f Dockerfile.pg . docker build --build-arg DATABASE=pg -t fosrl/pangolin:postgresql-latest .
test: test:
docker run -it -p 3000:3000 -p 3001:3001 -p 3002:3002 -v ./config:/app/config fosrl/pangolin:latest docker run -it -p 3000:3000 -p 3001:3001 -p 3002:3002 -v ./config:/app/config fosrl/pangolin:latest
+1 -2
View File
@@ -22,8 +22,7 @@ services:
command: command:
- --reachableAt=http://gerbil:3003 - --reachableAt=http://gerbil:3003
- --generateAndSaveKeyTo=/var/config/key - --generateAndSaveKeyTo=/var/config/key
- --remoteConfig=http://pangolin:3001/api/v1/gerbil/get-config - --remoteConfig=http://pangolin:3001/api/v1/
- --reportBandwidthTo=http://pangolin:3001/api/v1/gerbil/receive-bandwidth
volumes: volumes:
- ./config/:/var/config - ./config/:/var/config
cap_add: cap_add:
+1 -2
View File
@@ -22,8 +22,7 @@ services:
command: command:
- --reachableAt=http://gerbil:3003 - --reachableAt=http://gerbil:3003
- --generateAndSaveKeyTo=/var/config/key - --generateAndSaveKeyTo=/var/config/key
- --remoteConfig=http://pangolin:3001/api/v1/gerbil/get-config - --remoteConfig=http://pangolin:3001/api/v1/
- --reportBandwidthTo=http://pangolin:3001/api/v1/gerbil/receive-bandwidth
volumes: volumes:
- ./config/:/var/config - ./config/:/var/config
cap_add: cap_add:
+37 -7
View File
@@ -215,7 +215,7 @@ func main() {
} }
} else { } else {
fmt.Println("Looks like you already installed, so I am going to do the setup...") fmt.Println("Looks like you already installed, so I am going to do the setup...")
// Read existing config to get DashboardDomain // Read existing config to get DashboardDomain
traefikConfig, err := ReadTraefikConfig("config/traefik/traefik_config.yml", "config/traefik/dynamic_config.yml") traefikConfig, err := ReadTraefikConfig("config/traefik/traefik_config.yml", "config/traefik/dynamic_config.yml")
if err != nil { if err != nil {
@@ -226,19 +226,28 @@ func main() {
config.DashboardDomain = traefikConfig.DashboardDomain config.DashboardDomain = traefikConfig.DashboardDomain
config.LetsEncryptEmail = traefikConfig.LetsEncryptEmail config.LetsEncryptEmail = traefikConfig.LetsEncryptEmail
config.BadgerVersion = traefikConfig.BadgerVersion config.BadgerVersion = traefikConfig.BadgerVersion
// Show detected values and allow user to confirm or re-enter // Show detected values and allow user to confirm or re-enter
fmt.Println("Detected existing configuration:") fmt.Println("Detected existing configuration:")
fmt.Printf("Dashboard Domain: %s\n", config.DashboardDomain) fmt.Printf("Dashboard Domain: %s\n", config.DashboardDomain)
fmt.Printf("Let's Encrypt Email: %s\n", config.LetsEncryptEmail) fmt.Printf("Let's Encrypt Email: %s\n", config.LetsEncryptEmail)
fmt.Printf("Badger Version: %s\n", config.BadgerVersion) fmt.Printf("Badger Version: %s\n", config.BadgerVersion)
if !readBool(reader, "Are these values correct?", true) { if !readBool(reader, "Are these values correct?", true) {
config = collectUserInput(reader) 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() { if !checkIsCrowdsecInstalledInCompose() {
fmt.Println("\n=== CrowdSec Install ===") fmt.Println("\n=== CrowdSec Install ===")
// check if crowdsec is installed // check if crowdsec is installed
@@ -276,7 +285,7 @@ func main() {
// Setup Token Section // Setup Token Section
fmt.Println("\n=== Setup Token ===") fmt.Println("\n=== Setup Token ===")
// Check if containers were started during this installation // Check if containers were started during this installation
containersStarted := false containersStarted := false
if (isDockerInstalled() && chosenContainer == Docker) || if (isDockerInstalled() && chosenContainer == Docker) ||
@@ -285,7 +294,7 @@ func main() {
containersStarted = true containersStarted = true
printSetupToken(chosenContainer, config.DashboardDomain) printSetupToken(chosenContainer, config.DashboardDomain)
} }
// If containers weren't started or token wasn't found, show instructions // If containers weren't started or token wasn't found, show instructions
if !containersStarted { if !containersStarted {
showSetupTokenInstructions(chosenContainer, config.DashboardDomain) showSetupTokenInstructions(chosenContainer, config.DashboardDomain)
@@ -354,7 +363,7 @@ func collectUserInput(reader *bufio.Reader) Config {
// Basic configuration // Basic configuration
fmt.Println("\n=== Basic Configuration ===") fmt.Println("\n=== Basic Configuration ===")
config.BaseDomain = readString(reader, "Enter your base domain (no subdomain e.g. example.com)", "") config.BaseDomain = readString(reader, "Enter your base domain (no subdomain e.g. example.com)", "")
// Set default dashboard domain after base domain is collected // Set default dashboard domain after base domain is collected
defaultDashboardDomain := "" defaultDashboardDomain := ""
if config.BaseDomain != "" { if config.BaseDomain != "" {
@@ -816,7 +825,7 @@ func waitForContainer(containerName string, containerType SupportedContainer) er
func printSetupToken(containerType SupportedContainer, dashboardDomain string) { func printSetupToken(containerType SupportedContainer, dashboardDomain string) {
fmt.Println("Waiting for Pangolin to generate setup token...") fmt.Println("Waiting for Pangolin to generate setup token...")
// Wait for Pangolin to be healthy // Wait for Pangolin to be healthy
if err := waitForContainer("pangolin", containerType); err != nil { if err := waitForContainer("pangolin", containerType); err != nil {
fmt.Println("Warning: Pangolin container did not become healthy in time.") fmt.Println("Warning: Pangolin container did not become healthy in time.")
@@ -938,3 +947,24 @@ func checkPortsAvailable(port int) error {
} }
return nil 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:"))
}
+3
View File
@@ -94,7 +94,9 @@
"siteNewtTunnelDescription": "Easiest way to create an entrypoint into your network. No extra setup.", "siteNewtTunnelDescription": "Easiest way to create an entrypoint into your network. No extra setup.",
"siteWg": "Basic WireGuard", "siteWg": "Basic WireGuard",
"siteWgDescription": "Use any WireGuard client to establish a tunnel. Manual NAT setup required.", "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.", "siteLocalDescription": "Local resources only. No tunneling.",
"siteLocalDescriptionSaas": "Local resources only. No tunneling. ONLY WORKS ON SELF HOSTED NODES",
"siteSeeAll": "See All Sites", "siteSeeAll": "See All Sites",
"siteTunnelDescription": "Determine how you want to connect to your site", "siteTunnelDescription": "Determine how you want to connect to your site",
"siteNewtCredentials": "Newt Credentials", "siteNewtCredentials": "Newt Credentials",
@@ -971,6 +973,7 @@
"logoutError": "Error logging out", "logoutError": "Error logging out",
"signingAs": "Signed in as", "signingAs": "Signed in as",
"serverAdmin": "Server Admin", "serverAdmin": "Server Admin",
"managedSelfhosted": "Managed Self-Hosted",
"otpEnable": "Enable Two-factor", "otpEnable": "Enable Two-factor",
"otpDisable": "Disable Two-factor", "otpDisable": "Disable Two-factor",
"logout": "Log Out", "logout": "Log Out",
+1 -2
View File
@@ -21,8 +21,7 @@
"db:clear-migrations": "rm -rf server/migrations", "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: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", "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": "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'",
"email": "email dev --dir server/emails/templates --port 3005", "email": "email dev --dir server/emails/templates --port 3005",
"build:cli": "node esbuild.mjs -e cli/index.ts -o dist/cli.mjs" "build:cli": "node esbuild.mjs -e cli/index.ts -o dist/cli.mjs"
}, },
+26
View File
@@ -4,6 +4,9 @@ import { resourceSessions, ResourceSession } from "@server/db";
import { db } from "@server/db"; import { db } from "@server/db";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import config from "@server/lib/config"; 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 = export const SESSION_COOKIE_NAME =
config.getRawConfig().server.session_cookie_name; config.getRawConfig().server.session_cookie_name;
@@ -62,6 +65,29 @@ export async function validateResourceSessionToken(
token: string, token: string,
resourceId: number resourceId: number
): Promise<ResourceSessionValidationResult> { ): Promise<ResourceSessionValidationResult> {
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( const sessionId = encodeHexLowerCase(
sha256(new TextEncoder().encode(token)) sha256(new TextEncoder().encode(token))
); );
+4 -1
View File
@@ -124,7 +124,10 @@ export const exitNodes = pgTable("exitNodes", {
publicKey: varchar("publicKey").notNull(), publicKey: varchar("publicKey").notNull(),
listenPort: integer("listenPort").notNull(), listenPort: integer("listenPort").notNull(),
reachableAt: varchar("reachableAt"), reachableAt: varchar("reachableAt"),
maxConnections: integer("maxConnections") maxConnections: integer("maxConnections"),
online: boolean("online").notNull().default(false),
lastPing: integer("lastPing"),
type: text("type").default("gerbil") // gerbil, remoteExitNode
}); });
export const siteResources = pgTable("siteResources", { // this is for the clients export const siteResources = pgTable("siteResources", { // this is for the clients
+277
View File
@@ -0,0 +1,277 @@
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";
import config from "@server/lib/config";
import logger from "@server/logger";
import { tokenManager } from "@server/lib/tokenManager";
export type ResourceWithAuth = {
resource: Resource | null;
pincode: ResourcePincode | null;
password: ResourcePassword | null;
};
export type UserSessionWithUser = {
session: any;
user: any;
};
/**
* Get resource by domain with pincode and password information
*/
export async function getResourceByDomain(
domain: string
): Promise<ResourceWithAuth | null> {
if (config.isHybridMode()) {
try {
const response = await axios.get(`${config.getRawConfig().hybrid?.endpoint}/api/v1/hybrid/resource/domain/${domain}`, await tokenManager.getAuthHeader());
return response.data.data;
} catch (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;
}
}
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<UserSessionWithUser | null> {
if (config.isHybridMode()) {
try {
const response = await axios.get(`${config.getRawConfig().hybrid?.endpoint}/api/v1/hybrid/session/${userSessionId}`, await tokenManager.getAuthHeader());
return response.data.data;
} catch (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;
}
}
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 (config.isHybridMode()) {
try {
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) {
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;
}
}
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 (config.isHybridMode()) {
try {
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) {
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;
}
}
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 (config.isHybridMode()) {
try {
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) {
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;
}
}
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<ResourceRule[]> {
if (config.isHybridMode()) {
try {
const response = await axios.get(`${config.getRawConfig().hybrid?.endpoint}/api/v1/hybrid/resource/${resourceId}/rules`, await tokenManager.getAuthHeader());
return response.data.data;
} catch (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 [];
}
}
const rules = await db
.select()
.from(resourceRules)
.where(eq(resourceRules.resourceId, resourceId));
return rules;
}
+4 -1
View File
@@ -136,7 +136,10 @@ export const exitNodes = sqliteTable("exitNodes", {
publicKey: text("publicKey").notNull(), publicKey: text("publicKey").notNull(),
listenPort: integer("listenPort").notNull(), listenPort: integer("listenPort").notNull(),
reachableAt: text("reachableAt"), // this is the internal address of the gerbil http server for command control reachableAt: text("reachableAt"), // this is the internal address of the gerbil http server for command control
maxConnections: integer("maxConnections") maxConnections: integer("maxConnections"),
online: integer("online", { mode: "boolean" }).notNull().default(false),
lastPing: integer("lastPing"),
type: text("type").default("gerbil") // gerbil, remoteExitNode
}); });
export const siteResources = sqliteTable("siteResources", { // this is for the clients export const siteResources = sqliteTable("siteResources", { // this is for the clients
+147
View File
@@ -0,0 +1,147 @@
import logger from "@server/logger";
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/traefikConfig";
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...");
// Start the token manager
await tokenManager.start();
const token = await tokenManager.getToken();
const monitor = new TraefikConfigManager();
await monitor.start();
// Create client
const client = createWebSocketClient(
token,
config.getRawConfig().hybrid!.endpoint!,
{
reconnectInterval: 5000,
pingInterval: 30000,
pingTimeout: 10000
}
);
// Register message handlers
client.registerHandler("remoteExitNode/peers/add", async (message) => {
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: publicKey,
allowedIps: allowedIps || []
});
});
client.registerHandler("remoteExitNode/peers/remove", async (message) => {
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, publicKey);
});
// /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) {
// 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("Error updating proxy mapping:", error);
}
}
});
// /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) {
// 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("Error updating destinations:", error);
}
}
});
client.registerHandler("remoteExitNode/traefik/reload", async (message) => {
await monitor.HandleTraefikConfig();
});
// Listen to connection events
client.on("connect", () => {
logger.info("Connected to WebSocket server");
client.sendMessage("remoteExitNode/register", {
remoteExitNodeVersion: APP_VERSION
});
});
client.on("disconnect", () => {
logger.info("Disconnected from WebSocket server");
});
client.on("message", (message) => {
logger.info(
`Received message: ${message.type} ${JSON.stringify(message.data)}`
);
});
// Connect to the server
try {
await client.connect();
logger.info("Connection initiated");
} catch (error) {
logger.error("Failed to connect:", error);
}
client.sendMessageInterval(
"remoteExitNode/ping",
{ timestamp: Date.now() / 1000 },
60000
); // send every minute
}
+16 -2
View File
@@ -7,9 +7,11 @@ import { createNextServer } from "./nextServer";
import { createInternalServer } from "./internalServer"; import { createInternalServer } from "./internalServer";
import { ApiKey, ApiKeyOrg, Session, User, UserOrg } from "@server/db"; import { ApiKey, ApiKeyOrg, Session, User, UserOrg } from "@server/db";
import { createIntegrationApiServer } from "./integrationApiServer"; import { createIntegrationApiServer } from "./integrationApiServer";
import { createHybridClientServer } from "./hybridServer";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { setHostMeta } from "@server/lib/hostMeta"; import { setHostMeta } from "@server/lib/hostMeta";
import { initTelemetryClient } from "./lib/telemetry.js"; import { initTelemetryClient } from "./lib/telemetry.js";
import { TraefikConfigManager } from "./lib/traefikConfig.js";
async function startServers() { async function startServers() {
await setHostMeta(); await setHostMeta();
@@ -22,7 +24,18 @@ async function startServers() {
// Start all servers // Start all servers
const apiServer = createApiServer(); const apiServer = createApiServer();
const internalServer = createInternalServer(); const internalServer = createInternalServer();
const nextServer = await createNextServer();
let hybridClientServer;
let nextServer;
if (config.isHybridMode()) {
hybridClientServer = await createHybridClientServer();
} else {
nextServer = await createNextServer();
if (config.getRawConfig().traefik.file_mode) {
const monitor = new TraefikConfigManager();
await monitor.start();
}
}
let integrationServer; let integrationServer;
if (config.getRawConfig().flags?.enable_integration_api) { if (config.getRawConfig().flags?.enable_integration_api) {
@@ -33,7 +46,8 @@ async function startServers() {
apiServer, apiServer,
nextServer, nextServer,
internalServer, internalServer,
integrationServer integrationServer,
hybridClientServer
}; };
} }
+5 -3
View File
@@ -103,9 +103,7 @@ export class Config {
private async checkKeyStatus() { private async checkKeyStatus() {
const licenseStatus = await license.check(); const licenseStatus = await license.check();
if ( if (!licenseStatus.isHostLicensed) {
!licenseStatus.isHostLicensed
) {
this.checkSupporterKey(); this.checkSupporterKey();
} }
} }
@@ -147,6 +145,10 @@ export class Config {
return false; return false;
} }
public isHybridMode() {
return typeof this.rawConfig?.hybrid === "object";
}
public async checkSupporterKey() { public async checkSupporterKey() {
const [key] = await db.select().from(supporterKey).limit(1); const [key] = await db.select().from(supporterKey).limit(1);
+86
View File
@@ -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<string, string>;
}
/**
* 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<any> Response data for local nodes, undefined for remote nodes
*/
export async function sendToExitNode(
exitNode: ExitNode,
request: ExitNodeRequest
): Promise<any> {
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;
}
}
+59
View File
@@ -0,0 +1,59 @@
import { db, exitNodes } from "@server/db";
import logger from "@server/logger";
import { ExitNodePingResult } from "@server/routers/newt";
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));
// 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;
}
export function selectBestExitNode(
pingResults: ExitNodePingResult[]
): ExitNodePingResult | null {
if (!pingResults || pingResults.length === 0) {
logger.warn("No ping results provided");
return null;
}
return pingResults[0];
}
export async function checkExitNodeOrg(exitNodeId: number, orgId: string) {
return false;
}
+2
View File
@@ -0,0 +1,2 @@
export * from "./exitNodes";
export * from "./shared";
+30
View File
@@ -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<string> {
// 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;
}
+1
View File
@@ -1 +1,2 @@
export * from "./response"; export * from "./response";
export { tokenManager, TokenManager } from "./tokenManager";
+23 -1
View File
@@ -32,6 +32,15 @@ export const configSchema = z
.optional() .optional()
.default({}) .default({})
}), }),
hybrid: z
.object({
name: z.string().optional(),
id: z.string().optional(),
secret: z.string().optional(),
endpoint: z.string().optional(),
redirect_endpoint: z.string().optional()
})
.optional(),
domains: z domains: z
.record( .record(
z.string(), z.string(),
@@ -135,7 +144,20 @@ export const configSchema = z
https_entrypoint: z.string().optional().default("websecure"), https_entrypoint: z.string().optional().default("websecure"),
additional_middlewares: z.array(z.string()).optional(), additional_middlewares: z.array(z.string()).optional(),
cert_resolver: z.string().optional().default("letsencrypt"), 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("/var/certificates"),
monitor_interval: z.number().default(5000),
dynamic_cert_config_path: z
.string()
.optional()
.default("/var/dynamic/cert_config.yml"),
dynamic_router_config_path: z
.string()
.optional()
.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)
}) })
.optional() .optional()
.default({}), .default({}),
@@ -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<string>): 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<string>): Promise<
Array<{
id: number;
domain: string;
certFile: string | null;
keyFile: string | null;
expiresAt: Date | null;
updatedAt?: Date | null;
}>
> {
return []; // stub
}
+1
View File
@@ -0,0 +1 @@
export * from "./certificates";
+73
View File
@@ -0,0 +1,73 @@
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";
import { tokenManager } from "./tokenManager";
/**
* Proxy function that forwards requests to the remote cloud server
*/
export const proxyToRemote = async (
req: Request,
res: Response,
next: NextFunction,
endpoint: string
): Promise<any> => {
try {
const remoteUrl = `${config.getRawConfig().hybrid?.endpoint?.replace(/\/$/, '')}/api/v1/${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',
...(await tokenManager.getAuthHeader()).headers
},
params: req.query,
timeout: 30000, // 30 second timeout
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);
} 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"
)
);
}
}
+274
View File
@@ -0,0 +1,274 @@
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;
private retryInterval: NodeJS.Timeout | null = null;
private retryIntervalMs: number;
private tokenAvailablePromise: Promise<void> | null = null;
private tokenAvailableResolve: (() => void) | null = null;
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();
}
/**
* Set up promise that resolves when token becomes available
*/
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;
}
}
/**
* 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<void> {
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");
}
/**
* Get the current valid token
*/
// TODO: WE SHOULD NOT BE GETTING A TOKEN EVERY TIME WE REQUEST IT
async getToken(): Promise<string> {
// 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 {
throw new Error("No valid token available");
}
}
if (!this.token) {
throw new Error("No valid token available");
}
return this.token;
}
async getAuthHeader() {
return {
headers: {
Authorization: `Bearer ${await this.getToken()}`,
"X-CSRF-Token": "x-csrf-protection",
}
};
}
/**
* Force refresh the token
*/
async refreshToken(): Promise<void> {
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<TokenResponse>(
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) {
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("Error updating proxy mapping:", error);
}
throw new Error("Failed to refresh token");
} 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<void> {
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();
+907
View File
@@ -0,0 +1,907 @@
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";
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;
private isRunning = false;
private activeDomains = new Set<string>();
private timeoutId: NodeJS.Timeout | null = null;
private lastCertificateFetch: Date | null = null;
private lastKnownDomains = new Set<string>();
private lastLocalCertificateState = new Map<
string,
{
exists: boolean;
lastModified: Date | null;
expiresAt: Date | 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<void> {
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
);
// Initialize local certificate state
this.lastLocalCertificateState = await this.scanLocalCertificateState();
logger.info(
`Found ${this.lastLocalCertificateState.size} existing certificate directories`
);
// 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");
}
/**
* Scan local certificate directories to build current state
*/
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();
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<string>): 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
*/
lastActiveDomains: Set<string> = new Set();
public async HandleTraefikConfig(): Promise<void> {
try {
// Get all active domains for this exit node via HTTP call
const getTraefikConfig = await this.getTraefikConfig();
if (!getTraefikConfig) {
logger.error(
"Failed to fetch active domains from traefik config"
);
return;
}
const { domains, traefikConfig } = getTraefikConfig;
// 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);
}
// Scan current local certificate state
this.lastLocalCertificateState =
await this.scanLocalCertificateState();
// 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;
}> = [];
if (this.shouldFetchCertificates(domains)) {
// Get valid certificates for active 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`
);
// 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);
// 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 {
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(
`${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. Has gerbil registered yet?"
);
}
} 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 traefik config monitoring cycle:", error);
}
}
/**
* Get all domains currently in use from traefik config API
*/
private async getTraefikConfig(): Promise<{
domains: Set<string>;
traefikConfig: any;
} | null> {
let traefikConfig;
try {
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
);
}
const domains = new Set<string>();
if (traefikConfig?.http?.routers) {
for (const router of Object.values<any>(
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]);
}
}
}
}
// 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
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("Error fetching traefik config:", error);
}
return null;
}
}
/**
* Write traefik config as YAML to a second dynamic config file if changed
*/
private async writeTraefikDynamicConfig(traefikConfig: any): Promise<void> {
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);
}
}
}
/**
* Update dynamic config from existing local certificates without fetching from remote
*/
private async updateDynamicConfigFromLocalCerts(
domains: Set<string>
): Promise<void> {
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: certPath,
keyFile: 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");
}
}
/**
* 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<void> {
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}`
);
// 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
const certEntry = {
certFile: certPath,
keyFile: 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<boolean> {
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<string>
): Promise<void> {
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 local state tracking
this.lastLocalCertificateState.delete(dirName);
// Remove from dynamic config
const certFilePath = path.join(
domainDir,
"cert.pem"
);
const keyFilePath = 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<void> {
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<boolean> {
try {
fs.accessSync(filePath);
return true;
} catch {
return false;
}
}
/**
* Force a certificate refresh regardless of cache state
*/
public async forceCertificateRefresh(): Promise<void> {
logger.info("Forcing certificate refresh");
this.lastCertificateFetch = null;
this.lastKnownDomains = new Set();
await this.HandleTraefikConfig();
}
/**
* Get current status
*/
getStatus(): {
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,
lastCertificateFetch: this.lastCertificateFetch,
localCertificateCount: this.lastLocalCertificateState.size
};
}
}
+38 -75
View File
@@ -6,20 +6,21 @@ import {
} from "@server/auth/sessions/resource"; } from "@server/auth/sessions/resource";
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken"; import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
import { db } from "@server/db"; import { db } from "@server/db";
import {
getResourceByDomain,
getUserSessionWithUser,
getUserOrgRole,
getRoleResourceAccess,
getUserResourceAccess,
getResourceRules
} from "@server/db/queries/verifySessionQueries";
import { import {
Resource, Resource,
ResourceAccessToken, ResourceAccessToken,
ResourcePassword, ResourcePassword,
resourcePassword,
ResourcePincode, ResourcePincode,
resourcePincode,
ResourceRule, ResourceRule,
resourceRules,
resources,
roleResources,
sessions, sessions,
userOrgs,
userResources,
users users
} from "@server/db"; } from "@server/db";
import config from "@server/lib/config"; import config from "@server/lib/config";
@@ -27,7 +28,6 @@ import { isIpInCidr } from "@server/lib/ip";
import { response } from "@server/lib/response"; import { response } from "@server/lib/response";
import logger from "@server/logger"; import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { and, eq } from "drizzle-orm";
import { NextFunction, Request, Response } from "express"; import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import NodeCache from "node-cache"; import NodeCache from "node-cache";
@@ -137,38 +137,21 @@ export async function verifyResourceSession(
| undefined = cache.get(resourceCacheKey); | undefined = cache.get(resourceCacheKey);
if (!resourceData) { if (!resourceData) {
const [result] = await db const result = await getResourceByDomain(cleanHost);
.select()
.from(resources)
.leftJoin(
resourcePincode,
eq(resourcePincode.resourceId, resources.resourceId)
)
.leftJoin(
resourcePassword,
eq(resourcePassword.resourceId, resources.resourceId)
)
.where(eq(resources.fullDomain, cleanHost))
.limit(1);
if (!result) { if (!result) {
logger.debug("Resource not found", cleanHost); logger.debug(`Resource not found ${cleanHost}`);
return notAllowed(res); return notAllowed(res);
} }
resourceData = { resourceData = result;
resource: result.resources,
pincode: result.resourcePincode,
password: result.resourcePassword
};
cache.set(resourceCacheKey, resourceData); cache.set(resourceCacheKey, resourceData);
} }
const { resource, pincode, password } = resourceData; const { resource, pincode, password } = resourceData;
if (!resource) { if (!resource) {
logger.debug("Resource not found", cleanHost); logger.debug(`Resource not found ${cleanHost}`);
return notAllowed(res); return notAllowed(res);
} }
@@ -208,7 +191,13 @@ export async function verifyResourceSession(
return allowed(res); 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 resource.resourceId
)}?redirect=${encodeURIComponent(originalRequestURL)}`; )}?redirect=${encodeURIComponent(originalRequestURL)}`;
@@ -529,14 +518,13 @@ async function isUserAllowedToAccessResource(
userSessionId: string, userSessionId: string,
resource: Resource resource: Resource
): Promise<BasicUserData | null> { ): Promise<BasicUserData | null> {
const [res] = await db const result = await getUserSessionWithUser(userSessionId);
.select()
.from(sessions)
.leftJoin(users, eq(users.userId, sessions.userId))
.where(eq(sessions.sessionId, userSessionId));
const user = res.user; if (!result) {
const session = res.session; return null;
}
const { user, session } = result;
if (!user || !session) { if (!user || !session) {
return null; return null;
@@ -549,33 +537,18 @@ async function isUserAllowedToAccessResource(
return null; return null;
} }
const userOrgRole = await db const userOrgRole = await getUserOrgRole(user.userId, resource.orgId);
.select()
.from(userOrgs)
.where(
and(
eq(userOrgs.userId, user.userId),
eq(userOrgs.orgId, resource.orgId)
)
)
.limit(1);
if (userOrgRole.length === 0) { if (!userOrgRole) {
return null; return null;
} }
const roleResourceAccess = await db const roleResourceAccess = await getRoleResourceAccess(
.select() resource.resourceId,
.from(roleResources) userOrgRole.roleId
.where( );
and(
eq(roleResources.resourceId, resource.resourceId),
eq(roleResources.roleId, userOrgRole[0].roleId)
)
)
.limit(1);
if (roleResourceAccess.length > 0) { if (roleResourceAccess) {
return { return {
username: user.username, username: user.username,
email: user.email, email: user.email,
@@ -583,18 +556,12 @@ async function isUserAllowedToAccessResource(
}; };
} }
const userResourceAccess = await db const userResourceAccess = await getUserResourceAccess(
.select() user.userId,
.from(userResources) resource.resourceId
.where( );
and(
eq(userResources.userId, user.userId),
eq(userResources.resourceId, resource.resourceId)
)
)
.limit(1);
if (userResourceAccess.length > 0) { if (userResourceAccess) {
return { return {
username: user.username, username: user.username,
email: user.email, email: user.email,
@@ -615,11 +582,7 @@ async function checkRules(
let rules: ResourceRule[] | undefined = cache.get(ruleCacheKey); let rules: ResourceRule[] | undefined = cache.get(ruleCacheKey);
if (!rules) { if (!rules) {
rules = await db rules = await getResourceRules(resourceId);
.select()
.from(resourceRules)
.where(eq(resourceRules.resourceId, resourceId));
cache.set(ruleCacheKey, rules); cache.set(ruleCacheKey, rules);
} }
+5 -15
View File
@@ -24,6 +24,7 @@ import { hashPassword } from "@server/auth/password";
import { isValidCIDR, isValidIP } from "@server/lib/validators"; import { isValidCIDR, isValidIP } from "@server/lib/validators";
import { isIpInCidr } from "@server/lib/ip"; import { isIpInCidr } from "@server/lib/ip";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { listExitNodes } from "@server/lib/exitNodes";
const createClientParamsSchema = z const createClientParamsSchema = z
.object({ .object({
@@ -177,20 +178,9 @@ export async function createClient(
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
// TODO: more intelligent way to pick the exit node // TODO: more intelligent way to pick the exit node
const exitNodesList = await listExitNodes(orgId);
// make sure there is an exit node by counting the exit nodes table const randomExitNode =
const nodes = await db.select().from(exitNodes); exitNodesList[Math.floor(Math.random() * exitNodesList.length)];
if (nodes.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"No exit nodes available"
)
);
}
// get the first exit node
const exitNode = nodes[0];
const adminRole = await trx const adminRole = await trx
.select() .select()
@@ -208,7 +198,7 @@ export async function createClient(
const [newClient] = await trx const [newClient] = await trx
.insert(clients) .insert(clients)
.values({ .values({
exitNodeId: exitNode.exitNodeId, exitNodeId: randomExitNode.exitNodeId,
orgId, orgId,
name, name,
subnet: updatedSubnet, subnet: updatedSubnet,
+96 -106
View File
@@ -17,7 +17,7 @@ import {
addPeer as olmAddPeer, addPeer as olmAddPeer,
deletePeer as olmDeletePeer deletePeer as olmDeletePeer
} from "../olm/peers"; } from "../olm/peers";
import axios from "axios"; import { sendToExitNode } from "../../lib/exitNodeComms";
const updateClientParamsSchema = z const updateClientParamsSchema = z
.object({ .object({
@@ -141,13 +141,15 @@ export async function updateClient(
const isRelayed = true; const isRelayed = true;
// get the clientsite // get the clientsite
const [clientSite] = await db const [clientSite] = await db
.select() .select()
.from(clientSites) .from(clientSites)
.where(and( .where(
eq(clientSites.clientId, client.clientId), and(
eq(clientSites.siteId, siteId) eq(clientSites.clientId, client.clientId),
)) eq(clientSites.siteId, siteId)
)
)
.limit(1); .limit(1);
if (!clientSite || !clientSite.endpoint) { if (!clientSite || !clientSite.endpoint) {
@@ -158,7 +160,7 @@ export async function updateClient(
const site = await newtAddPeer(siteId, { const site = await newtAddPeer(siteId, {
publicKey: client.pubKey, publicKey: client.pubKey,
allowedIps: [`${client.subnet.split("/")[0]}/32`], // we want to only allow from that client allowedIps: [`${client.subnet.split("/")[0]}/32`], // we want to only allow from that client
endpoint: isRelayed ? "" : clientSite.endpoint endpoint: isRelayed ? "" : clientSite.endpoint
}); });
if (!site) { if (!site) {
@@ -270,114 +272,102 @@ export async function updateClient(
} }
} }
// get all sites for this client and join with exit nodes with site.exitNodeId // get all sites for this client and join with exit nodes with site.exitNodeId
const sitesData = await db const sitesData = await db
.select() .select()
.from(sites) .from(sites)
.innerJoin( .innerJoin(clientSites, eq(sites.siteId, clientSites.siteId))
clientSites, .leftJoin(exitNodes, eq(sites.exitNodeId, exitNodes.exitNodeId))
eq(sites.siteId, clientSites.siteId) .where(eq(clientSites.clientId, client.clientId));
)
.leftJoin(
exitNodes,
eq(sites.exitNodeId, exitNodes.exitNodeId)
)
.where(eq(clientSites.clientId, client.clientId));
let exitNodeDestinations: { let exitNodeDestinations: {
reachableAt: string; reachableAt: string;
sourceIp: string; exitNodeId: number;
sourcePort: number; type: string;
destinations: PeerDestination[]; sourceIp: string;
}[] = []; sourcePort: number;
destinations: PeerDestination[];
}[] = [];
for (const site of sitesData) { for (const site of sitesData) {
if (!site.sites.subnet) { if (!site.sites.subnet) {
logger.warn( logger.warn(
`Site ${site.sites.siteId} has no subnet, skipping` `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
); );
continue;
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);
} }
for (const destination of exitNodeDestinations) { if (!site.clientSites.endpoint) {
try { logger.warn(
logger.info( `Site ${site.sites.siteId} has no endpoint, skipping`
`Updating destinations for exit node at ${destination.reachableAt}` );
); continue;
const payload = { }
sourceIp: destination.sourceIp,
sourcePort: destination.sourcePort, // find the destinations in the array
destinations: destination.destinations let destinations = exitNodeDestinations.find(
}; (d) => d.reachableAt === site.exitNodes?.reachableAt
logger.info( );
`Payload for update-destinations: ${JSON.stringify(payload, null, 2)}`
); if (!destinations) {
const response = await axios.post( destinations = {
`${destination.reachableAt}/update-destinations`, reachableAt: site.exitNodes?.reachableAt || "",
payload, 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: { destinationIP: site.sites.subnet.split("/")[0],
"Content-Type": "application/json" destinationPort: site.sites.listenPort || 0
}
} }
); ]
};
logger.info("Destinations updated:", { } else {
peer: response.data.status // add to the existing destinations
}); destinations.destinations.push({
} catch (error) { destinationIP: site.sites.subnet.split("/")[0],
if (axios.isAxiosError(error)) { destinationPort: site.sites.listenPort || 0
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}`
);
}
}
} }
// 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 // Fetch the updated client
const [updatedClient] = await trx const [updatedClient] = await trx
.select() .select()
+7 -4
View File
@@ -872,7 +872,7 @@ authRouter.post(
rateLimit({ rateLimit({
windowMs: 15 * 60 * 1000, windowMs: 15 * 60 * 1000,
max: 900, max: 900,
keyGenerator: (req) => `newtGetToken:${req.body.newtId || req.ip}`, keyGenerator: (req) => `olmGetToken:${req.body.newtId || req.ip}`,
handler: (req, res, next) => { handler: (req, res, next) => {
const message = `You can only request an Olm token ${900} times every ${15} minutes. Please try again later.`; 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)); return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
@@ -951,7 +951,8 @@ authRouter.post(
rateLimit({ rateLimit({
windowMs: 15 * 60 * 1000, windowMs: 15 * 60 * 1000,
max: 15, max: 15,
keyGenerator: (req) => `requestEmailVerificationCode:${req.body.email || req.ip}`, keyGenerator: (req) =>
`requestEmailVerificationCode:${req.body.email || req.ip}`,
handler: (req, res, next) => { handler: (req, res, next) => {
const message = `You can only request an email verification code ${15} times every ${15} minutes. Please try again later.`; 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)); return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
@@ -972,7 +973,8 @@ authRouter.post(
rateLimit({ rateLimit({
windowMs: 15 * 60 * 1000, windowMs: 15 * 60 * 1000,
max: 15, max: 15,
keyGenerator: (req) => `requestPasswordReset:${req.body.email || req.ip}`, keyGenerator: (req) =>
`requestPasswordReset:${req.body.email || req.ip}`,
handler: (req, res, next) => { handler: (req, res, next) => {
const message = `You can only request a password reset ${15} times every ${15} minutes. Please try again later.`; 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)); return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
@@ -1066,7 +1068,8 @@ authRouter.post(
rateLimit({ rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // Allow 5 security key registrations per 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) => { handler: (req, res, next) => {
const message = `You can only register a security key ${5} times every ${15} minutes. Please try again later.`; 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)); return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
+125 -93
View File
@@ -1,6 +1,15 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; 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 { db } from "@server/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
@@ -10,7 +19,7 @@ import { fromError } from "zod-validation-error";
// Define Zod schema for request validation // Define Zod schema for request validation
const getAllRelaysSchema = z.object({ const getAllRelaysSchema = z.object({
publicKey: z.string().optional(), publicKey: z.string().optional()
}); });
// Type for peer destination // Type for peer destination
@@ -44,103 +53,27 @@ export async function getAllRelays(
const { publicKey } = parsedParams.data; const { publicKey } = parsedParams.data;
if (!publicKey) { if (!publicKey) {
return next(createHttpError(HttpCode.BAD_REQUEST, 'publicKey is required')); return next(
createHttpError(HttpCode.BAD_REQUEST, "publicKey is required")
);
} }
// Fetch exit node // 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) { 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 mappings = await generateRelayMappings(exitNode);
const sitesRes = await db.select().from(sites).where(eq(sites.exitNodeId, exitNode.exitNodeId));
if (sitesRes.length === 0) { logger.debug(
return res.status(HttpCode.OK).send({ `Returning mappings for ${Object.keys(mappings).length} endpoints`
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`);
return res.status(HttpCode.OK).send({ mappings }); return res.status(HttpCode.OK).send({ mappings });
} catch (error) { } catch (error) {
logger.error(error); logger.error(error);
@@ -151,4 +84,103 @@ export async function getAllRelays(
) )
); );
} }
} }
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;
}
+49 -58
View File
@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; 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 { db } from "@server/db";
import { eq, isNotNull, and } from "drizzle-orm"; import { eq, isNotNull, and } from "drizzle-orm";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
@@ -11,6 +11,8 @@ import { getUniqueExitNodeEndpointName } from "../../db/names";
import { findNextAvailableCidr } from "@server/lib/ip"; import { findNextAvailableCidr } from "@server/lib/ip";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { getAllowedIps } from "../target/helpers"; import { getAllowedIps } from "../target/helpers";
import { proxyToRemote } from "@server/lib/remoteProxy";
import { getNextAvailableSubnet } from "@server/lib/exitNodes";
// Define Zod schema for request validation // Define Zod schema for request validation
const getConfigSchema = z.object({ const getConfigSchema = z.object({
publicKey: z.string(), publicKey: z.string(),
@@ -101,42 +103,17 @@ export async function getConfig(
); );
} }
const sitesRes = await db // STOP HERE IN HYBRID MODE
.select() if (config.isHybridMode()) {
.from(sites) req.body = {
.where( ...req.body,
and( endpoint: exitNode[0].endpoint,
eq(sites.exitNodeId, exitNode[0].exitNodeId), listenPort: exitNode[0].listenPort
isNotNull(sites.pubKey), }
isNotNull(sites.subnet) return proxyToRemote(req, res, next, "hybrid/gerbil/get-config");
) }
);
const peers = await Promise.all( const configResponse = await generateGerbilConfig(exitNode[0]);
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
};
logger.debug("Sending config: ", configResponse); logger.debug("Sending config: ", configResponse);
@@ -152,31 +129,45 @@ export async function getConfig(
} }
} }
async function getNextAvailableSubnet(): Promise<string> { export async function generateGerbilConfig(exitNode: ExitNode) {
// Get all existing subnets from routes table const sitesRes = await db
const existingAddresses = await db .select()
.select({ .from(sites)
address: exitNodes.address .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: []
};
}) })
.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 const configResponse: GetConfigResponse = {
subnet = listenPort: exitNode.listenPort || 51820,
subnet.split(".").slice(0, 3).join(".") + ipAddress: exitNode.address,
".1" + peers
"/" + };
subnet.split("/")[1];
return subnet; return configResponse;
} }
async function getNextAvailablePort(): Promise<number> { async function getNextAvailablePort(): Promise<number> {
@@ -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<any> {
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..."
)
);
}
}
+2 -1
View File
@@ -1,4 +1,5 @@
export * from "./getConfig"; export * from "./getConfig";
export * from "./receiveBandwidth"; export * from "./receiveBandwidth";
export * from "./updateHolePunch"; export * from "./updateHolePunch";
export * from "./getAllRelays"; export * from "./getAllRelays";
export * from "./getResolvedHostname";
+18 -47
View File
@@ -1,8 +1,8 @@
import axios from "axios";
import logger from "@server/logger"; import logger from "@server/logger";
import { db } from "@server/db"; import { db } from "@server/db";
import { exitNodes } from "@server/db"; import { exitNodes } from "@server/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { sendToExitNode } from "../../lib/exitNodeComms";
export async function addPeer( export async function addPeer(
exitNodeId: number, exitNodeId: number,
@@ -22,34 +22,13 @@ export async function addPeer(
if (!exitNode) { if (!exitNode) {
throw new Error(`Exit node with ID ${exitNodeId} not found`); 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 { return await sendToExitNode(exitNode, {
const response = await axios.post( remoteType: "remoteExitNode/peers/add",
`${exitNode.reachableAt}/peer`, localPath: "/peer",
peer, method: "POST",
{ data: 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}`
);
}
}
} }
export async function deletePeer(exitNodeId: number, publicKey: string) { export async function deletePeer(exitNodeId: number, publicKey: string) {
@@ -64,24 +43,16 @@ export async function deletePeer(exitNodeId: number, publicKey: string) {
if (!exitNode) { if (!exitNode) {
throw new Error(`Exit node with ID ${exitNodeId} not found`); throw new Error(`Exit node with ID ${exitNodeId} not found`);
} }
if (!exitNode.reachableAt) {
throw new Error(`Exit node with ID ${exitNodeId} is not reachable`); return await sendToExitNode(exitNode, {
} remoteType: "remoteExitNode/peers/remove",
try { localPath: "/peer",
const response = await axios.delete( method: "DELETE",
`${exitNode.reachableAt}/peer?public_key=${encodeURIComponent(publicKey)}` data: {
); publicKey: publicKey
logger.info("Peer deleted successfully:", response.data.status); },
return response.data; queryParams: {
} catch (error) { public_key: publicKey
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}`
);
} }
} });
} }
+141 -98
View File
@@ -6,6 +6,7 @@ import logger from "@server/logger";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import response from "@server/lib/response"; import response from "@server/lib/response";
import { checkExitNodeOrg } from "@server/lib/exitNodes";
// Track sites that are already offline to avoid unnecessary queries // Track sites that are already offline to avoid unnecessary queries
const offlineSites = new Set<string>(); const offlineSites = new Set<string>();
@@ -28,103 +29,7 @@ export const receiveBandwidth = async (
throw new Error("Invalid bandwidth data"); throw new Error("Invalid bandwidth data");
} }
const currentTime = new Date(); await updateSiteBandwidth(bandwidthData);
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<string, number>();
const orgUptimeMap = new Map<string, number>();
// 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);
}
}
}
}
});
return response(res, { return response(res, {
data: {}, data: {},
@@ -142,4 +47,142 @@ export const receiveBandwidth = async (
) )
); );
} }
}; };
export async function updateSiteBandwidth(
bandwidthData: PeerBandwidth[],
exitNodeId?: number
) {
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<string, number>();
const orgUptimeMap = new Map<string, number>();
// 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 (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 });
}
}
// 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) {
const [updatedSite] = await trx
.update(sites)
.set({
online: newOnlineStatus
})
.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) {
offlineSites.add(site.pubKey);
}
}
}
}
});
}
+238 -219
View File
@@ -19,6 +19,7 @@ import { fromError } from "zod-validation-error";
import { validateNewtSessionToken } from "@server/auth/sessions/newt"; import { validateNewtSessionToken } from "@server/auth/sessions/newt";
import { validateOlmSessionToken } from "@server/auth/sessions/olm"; import { validateOlmSessionToken } from "@server/auth/sessions/olm";
import axios from "axios"; import axios from "axios";
import { checkExitNodeOrg } from "@server/lib/exitNodes";
// Define Zod schema for request validation // Define Zod schema for request validation
const updateHolePunchSchema = z.object({ const updateHolePunchSchema = z.object({
@@ -66,228 +67,34 @@ export async function updateHolePunch(
publicKey publicKey
} = parsedParams.data; } = parsedParams.data;
let currentSiteId: number | undefined; let exitNode: ExitNode | undefined;
let destinations: PeerDestination[] = []; if (publicKey) {
// Get the exit node by public key
if (olmId) { [exitNode] = await db
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
.select() .select()
.from(olms) .from(exitNodes)
.where(eq(olms.olmId, olmId)); .where(eq(exitNodes.publicKey, publicKey));
} else {
if (!olm || !olm.clientId) { // FOR BACKWARDS COMPATIBILITY IF GERBIL IS STILL =<1.1.0
logger.warn(`Olm not found: ${olmId}`); [exitNode] = await db.select().from(exitNodes).limit(1);
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
// });
// }
// }
// }
// }
} }
// if (destinations.length === 0) { if (!exitNode) {
// logger.warn( logger.warn(`Exit node not found for publicKey: ${publicKey}`);
// `No peer destinations found for olmId: ${olmId} or newtId: ${newtId}` return next(
// ); createHttpError(HttpCode.NOT_FOUND, "Exit node not found")
// return next(createHttpError(HttpCode.NOT_FOUND, "No peer destinations found")); );
// } }
const destinations = await updateAndGenerateEndpointDestinations(
olmId,
newtId,
ip,
port,
timestamp,
token,
exitNode
);
logger.debug( logger.debug(
`Returning ${destinations.length} peer destinations for olmId: ${olmId} or newtId: ${newtId}: ${JSON.stringify(destinations, null, 2)}` `Returning ${destinations.length} peer destinations for olmId: ${olmId} or newtId: ${newtId}: ${JSON.stringify(destinations, null, 2)}`
@@ -307,3 +114,215 @@ 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();
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
.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");
}
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
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;
}
+41 -4
View File
@@ -7,6 +7,8 @@ import * as auth from "@server/routers/auth";
import * as supporterKey from "@server/routers/supporterKey"; import * as supporterKey from "@server/routers/supporterKey";
import * as license from "@server/routers/license"; import * as license from "@server/routers/license";
import * as idp from "@server/routers/idp"; import * as idp from "@server/routers/idp";
import { proxyToRemote } from "@server/lib/remoteProxy";
import config from "@server/lib/config";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { import {
verifyResourceAccess, verifyResourceAccess,
@@ -49,16 +51,51 @@ internalRouter.get("/idp/:idpId", idp.getIdp);
const gerbilRouter = Router(); const gerbilRouter = Router();
internalRouter.use("/gerbil", gerbilRouter); 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("/receive-bandwidth", (req, res, next) =>
proxyToRemote(req, res, next, "hybrid/gerbil/receive-bandwidth")
);
gerbilRouter.post("/update-hole-punch", (req, res, next) =>
proxyToRemote(req, res, next, "hybrid/gerbil/update-hole-punch")
);
gerbilRouter.post("/get-all-relays", (req, res, next) =>
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 {
// Use local gerbil endpoints
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
// SO IT REGISTERS THE EXIT NODE LOCALLY AS WELL
gerbilRouter.post("/get-config", gerbil.getConfig); 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 // Badger routes
const badgerRouter = Router(); const badgerRouter = Router();
internalRouter.use("/badger", badgerRouter); internalRouter.use("/badger", badgerRouter);
badgerRouter.post("/verify-session", badger.verifyResourceSession); badgerRouter.post("/verify-session", badger.verifyResourceSession);
badgerRouter.post("/exchange-session", badger.exchangeSession);
if (config.isHybridMode()) {
badgerRouter.post("/exchange-session", (req, res, next) =>
proxyToRemote(req, res, next, "hybrid/badger/exchange-session")
);
} else {
badgerRouter.post("/exchange-session", badger.exchangeSession);
}
export default internalRouter; export default internalRouter;
+22 -35
View File
@@ -13,7 +13,7 @@ import {
import { clients, clientSites, Newt, sites } from "@server/db"; import { clients, clientSites, Newt, sites } from "@server/db";
import { eq, and, inArray } from "drizzle-orm"; import { eq, and, inArray } from "drizzle-orm";
import { updatePeer } from "../olm/peers"; import { updatePeer } from "../olm/peers";
import axios from "axios"; import { sendToExitNode } from "../../lib/exitNodeComms";
const inputSchema = z.object({ const inputSchema = z.object({
publicKey: z.string(), publicKey: z.string(),
@@ -102,41 +102,28 @@ export const handleGetConfigMessage: MessageHandler = async (context) => {
.from(exitNodes) .from(exitNodes)
.where(eq(exitNodes.exitNodeId, site.exitNodeId)) .where(eq(exitNodes.exitNodeId, site.exitNodeId))
.limit(1); .limit(1);
if (exitNode.reachableAt && existingSite.subnet && existingSite.listenPort) { if (
try { exitNode.reachableAt &&
const response = await axios.post( existingSite.subnet &&
`${exitNode.reachableAt}/update-proxy-mapping`, existingSite.listenPort
{ ) {
oldDestination: { const payload = {
destinationIP: existingSite.subnet?.split("/")[0], oldDestination: {
destinationPort: existingSite.listenPort destinationIP: existingSite.subnet?.split("/")[0],
}, destinationPort: existingSite.listenPort
newDestination: { },
destinationIP: site.subnet?.split("/")[0], newDestination: {
destinationPort: site.listenPort 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}`
);
} }
} };
await sendToExitNode(exitNode, {
remoteType: "remoteExitNode/update-proxy-mapping",
localPath: "/update-proxy-mapping",
method: "POST",
data: payload
});
} }
} }
@@ -4,6 +4,7 @@ import { exitNodes, Newt } from "@server/db";
import logger from "@server/logger"; import logger from "@server/logger";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { ne, eq, or, and, count } from "drizzle-orm"; import { ne, eq, or, and, count } from "drizzle-orm";
import { listExitNodes } from "@server/lib/exitNodes";
export const handleNewtPingRequestMessage: MessageHandler = async (context) => { export const handleNewtPingRequestMessage: MessageHandler = async (context) => {
const { message, client, sendToClient } = context; const { message, client, sendToClient } = context;
@@ -16,12 +17,19 @@ export const handleNewtPingRequestMessage: MessageHandler = async (context) => {
return; return;
} }
// TODO: pick which nodes to send and ping better than just all of them // Get the newt's orgId through the site relationship
let exitNodesList = await db if (!newt.siteId) {
.select() logger.warn("Newt siteId not found");
.from(exitNodes); 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; let lastExitNodeId = null;
if (newt.siteId) { if (newt.siteId) {
@@ -54,9 +62,9 @@ export const handleNewtPingRequestMessage: MessageHandler = async (context) => {
) )
); );
if (currentConnections.count >= maxConnections) { if (currentConnections.count >= maxConnections) {
return null; return null;
} }
weight = weight =
(maxConnections - currentConnections.count) / (maxConnections - currentConnections.count) /
@@ -9,6 +9,7 @@ import {
findNextAvailableCidr, findNextAvailableCidr,
getNextAvailableClientSubnet getNextAvailableClientSubnet
} from "@server/lib/ip"; } from "@server/lib/ip";
import { selectBestExitNode, verifyExitNodeOrgAccess } from "@server/lib/exitNodes";
export type ExitNodePingResult = { export type ExitNodePingResult = {
exitNodeId: number; exitNodeId: number;
@@ -24,7 +25,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
const { message, client, sendToClient } = context; const { message, client, sendToClient } = context;
const newt = client as Newt; const newt = client as Newt;
logger.info("Handling register newt message!"); logger.debug("Handling register newt message!");
if (!newt) { if (!newt) {
logger.warn("Newt not found"); logger.warn("Newt not found");
@@ -64,16 +65,6 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
exitNodeId = bestPingResult.exitNodeId; 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 const [oldSite] = await db
.select() .select()
.from(sites) .from(sites)
@@ -91,6 +82,18 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
// This effectively moves the exit node to the new one // This effectively moves the exit node to the new one
exitNodeIdToQuery = exitNodeId; // Use the provided exitNodeId if it differs from the site's exitNodeId 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 const sitesQuery = await db
.select({ .select({
subnet: sites.subnet subnet: sites.subnet
@@ -98,12 +101,6 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
.from(sites) .from(sites)
.where(eq(sites.exitNodeId, exitNodeId)); .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 blockSize = config.getRawConfig().gerbil.site_block_size;
const subnets = sitesQuery const subnets = sitesQuery
.map((site) => site.subnet) .map((site) => site.subnet)
@@ -162,6 +159,16 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
allowedIps: [siteSubnet] 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));
}
// Get all enabled targets with their resource protocol information // Get all enabled targets with their resource protocol information
const allTargets = await db const allTargets = await db
.select({ .select({
@@ -217,15 +224,4 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
broadcast: false, // Send to all clients broadcast: false, // Send to all clients
excludeSender: false // Include sender in broadcast 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];
}
+12 -7
View File
@@ -1,7 +1,7 @@
import { db } from "@server/db"; import { db } from "@server/db";
import { MessageHandler } from "../ws"; import { MessageHandler } from "../ws";
import { clients, Olm } from "@server/db"; 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"; import logger from "@server/logger";
// Track if the offline checker interval is running // Track if the offline checker interval is running
@@ -13,22 +13,27 @@ const OFFLINE_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes
* Starts the background interval that checks for clients that haven't pinged recently * Starts the background interval that checks for clients that haven't pinged recently
* and marks them as offline * and marks them as offline
*/ */
export const startOfflineChecker = (): void => { export const startOlmOfflineChecker = (): void => {
if (offlineCheckerInterval) { if (offlineCheckerInterval) {
return; // Already running return; // Already running
} }
offlineCheckerInterval = setInterval(async () => { offlineCheckerInterval = setInterval(async () => {
try { 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 // Find clients that haven't pinged in the last 2 minutes and mark them as offline
await db await db
.update(clients) .update(clients)
.set({ online: false }) .set({ online: false })
.where( .where(
eq(clients.online, true) && and(
(lt(clients.lastPing, twoMinutesAgo.getTime() / 1000) || isNull(clients.lastPing)) eq(clients.online, true),
or(
lt(clients.lastPing, twoMinutesAgo),
isNull(clients.lastPing)
)
)
); );
} catch (error) { } catch (error) {
@@ -42,7 +47,7 @@ export const startOfflineChecker = (): void => {
/** /**
* Stops the background interval that checks for offline clients * Stops the background interval that checks for offline clients
*/ */
export const stopOfflineChecker = (): void => { export const stopOlmOfflineChecker = (): void => {
if (offlineCheckerInterval) { if (offlineCheckerInterval) {
clearInterval(offlineCheckerInterval); clearInterval(offlineCheckerInterval);
offlineCheckerInterval = null; offlineCheckerInterval = null;
@@ -72,7 +77,7 @@ export const handleOlmPingMessage: MessageHandler = async (context) => {
await db await db
.update(clients) .update(clients)
.set({ .set({
lastPing: new Date().getTime() / 1000, lastPing: Math.floor(Date.now() / 1000),
online: true, online: true,
}) })
.where(eq(clients.clientId, olm.clientId)); .where(eq(clients.clientId, olm.clientId));
@@ -4,6 +4,7 @@ import { clients, clientSites, exitNodes, Olm, olms, sites } from "@server/db";
import { and, eq, inArray } from "drizzle-orm"; import { and, eq, inArray } from "drizzle-orm";
import { addPeer, deletePeer } from "../newt/peers"; import { addPeer, deletePeer } from "../newt/peers";
import logger from "@server/logger"; import logger from "@server/logger";
import { listExitNodes } from "@server/lib/exitNodes";
export const handleOlmRegisterMessage: MessageHandler = async (context) => { export const handleOlmRegisterMessage: MessageHandler = async (context) => {
logger.info("Handling register olm message!"); 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 // TODO: FOR NOW WE ARE JUST HOLEPUNCHING ALL EXIT NODES BUT IN THE FUTURE WE SHOULD HANDLE THIS BETTER
// Get the exit node // 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) => { const exitNodesHpData = allExitNodes.map((exitNode: ExitNode) => {
return { return {
+28 -1
View File
@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; 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 { roles, userSites, sites, roleSites, Site, orgs } from "@server/db";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
@@ -17,6 +17,7 @@ import { hashPassword } from "@server/auth/password";
import { isValidIP } from "@server/lib/validators"; import { isValidIP } from "@server/lib/validators";
import { isIpInCidr } from "@server/lib/ip"; import { isIpInCidr } from "@server/lib/ip";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { verifyExitNodeOrgAccess } from "@server/lib/exitNodes";
const createSiteParamsSchema = z const createSiteParamsSchema = z
.object({ .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 [newSite] = await trx
.insert(sites) .insert(sites)
.values({ .values({
+20 -20
View File
@@ -6,12 +6,16 @@ import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import logger from "@server/logger"; 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 { generateId } from "@server/auth/sessions/app";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { z } from "zod"; import { z } from "zod";
import { listExitNodes } from "@server/lib/exitNodes";
export type PickSiteDefaultsResponse = { export type PickSiteDefaultsResponse = {
exitNodeId: number; exitNodeId: number;
@@ -65,16 +69,10 @@ export async function pickSiteDefaults(
const { orgId } = parsedParams.data; const { orgId } = parsedParams.data;
// TODO: more intelligent way to pick the exit node // TODO: more intelligent way to pick the exit node
// make sure there is an exit node by counting the exit nodes table const exitNodesList = await listExitNodes(orgId);
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 randomExitNode =
const exitNode = nodes[0]; exitNodesList[Math.floor(Math.random() * exitNodesList.length)];
// TODO: this probably can be optimized... // TODO: this probably can be optimized...
// list all of the sites on that exit node // list all of the sites on that exit node
@@ -83,13 +81,15 @@ export async function pickSiteDefaults(
subnet: sites.subnet subnet: sites.subnet
}) })
.from(sites) .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 // 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 // exclude the exit node address by replacing after the / with a site block size
subnets.push( subnets.push(
exitNode.address.replace( randomExitNode.address.replace(
/\/\d+$/, /\/\d+$/,
`/${config.getRawConfig().gerbil.site_block_size}` `/${config.getRawConfig().gerbil.site_block_size}`
) )
@@ -97,7 +97,7 @@ export async function pickSiteDefaults(
const newSubnet = findNextAvailableCidr( const newSubnet = findNextAvailableCidr(
subnets, subnets,
config.getRawConfig().gerbil.site_block_size, config.getRawConfig().gerbil.site_block_size,
exitNode.address randomExitNode.address
); );
if (!newSubnet) { if (!newSubnet) {
return next( return next(
@@ -125,12 +125,12 @@ export async function pickSiteDefaults(
return response<PickSiteDefaultsResponse>(res, { return response<PickSiteDefaultsResponse>(res, {
data: { data: {
exitNodeId: exitNode.exitNodeId, exitNodeId: randomExitNode.exitNodeId,
address: exitNode.address, address: randomExitNode.address,
publicKey: exitNode.publicKey, publicKey: randomExitNode.publicKey,
name: exitNode.name, name: randomExitNode.name,
listenPort: exitNode.listenPort, listenPort: randomExitNode.listenPort,
endpoint: exitNode.endpoint, endpoint: randomExitNode.endpoint,
// subnet: `${newSubnet.split("/")[0]}/${config.getRawConfig().gerbil.block_size}`, // we want the block size of the whole subnet // subnet: `${newSubnet.split("/")[0]}/${config.getRawConfig().gerbil.block_size}`, // we want the block size of the whole subnet
subnet: newSubnet, subnet: newSubnet,
clientAddress: clientAddress, clientAddress: clientAddress,
+433 -418
View File
@@ -5,252 +5,273 @@ import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { orgs, resources, sites, Target, targets } from "@server/db"; 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; let currentExitNodeId: number;
const redirectHttpsMiddlewareName = "redirect-to-https";
const badgerMiddlewareName = "badger";
export async function getCurrentExitNodeId(): Promise<number> {
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( export async function traefikConfigProvider(
_: Request, _: Request,
res: Response res: Response
): Promise<any> { ): Promise<any> {
try { try {
// Get all resources with related data // First query to get resources with site and org info
const allResources = await db.transaction(async (tx) => { // Get the current exit node name from config
// First query to get resources with site and org info await getCurrentExitNodeId();
// 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) { let traefikConfig = await getTraefikConfig(
currentExitNodeId = exitNode.exitNodeId; currentExitNodeId,
} config.getRawConfig().traefik.site_types
} );
}
// Get resources with their targets and sites in a single optimized query traefikConfig.http.middlewares[badgerMiddlewareName] = {
// Start from sites on this exit node, then join to targets and resources plugin: {
const resourcesWithTargetsAndSites = await tx [badgerMiddlewareName]: {
.select({ apiBaseUrl: new URL(
// Resource fields "/api/v1",
resourceId: resources.resourceId, `http://${
fullDomain: resources.fullDomain, config.getRawConfig().server.internal_hostname
ssl: resources.ssl, }:${config.getRawConfig().server.internal_port}`
http: resources.http, ).href,
proxyPort: resources.proxyPort, userSessionCookieName:
protocol: resources.protocol, config.getRawConfig().server.session_cookie_name,
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 // deprecated
const resourcesMap = new Map(); accessTokenQueryParam:
config.getRawConfig().server
.resource_access_token_param,
resourcesWithTargetsAndSites.forEach((row) => { resourceSessionRequestParam:
const resourceId = row.resourceId; config.getRawConfig().server
.resource_session_request_param
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({
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) {
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) { return res.status(HttpCode.OK).json(traefikConfig);
const targets = resource.targets as TargetWithSite[]; } catch (e) {
logger.error(`Failed to build Traefik config: ${e}`);
return res.status(HttpCode.INTERNAL_SERVER_ERROR).json({
error: "Failed to build Traefik config"
});
}
}
const routerName = `${resource.resourceId}-router`; export async function getTraefikConfig(
const serviceName = `${resource.resourceId}-service`; exitNodeId: number,
const fullDomain = `${resource.fullDomain}`; siteTypes: string[]
const transportName = `${resource.resourceId}-transport`; ): Promise<any> {
const hostHeaderMiddlewareName = `${resource.resourceId}-host-header-middleware`; // Define extended target type with site information
type TargetWithSite = Target & {
site: {
siteId: number;
type: string;
subnet: string | null;
exitNodeId: number | null;
};
};
if (!resource.enabled) { // 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, exitNodeId),
isNull(sites.exitNodeId)
),
inArray(sites.type, siteTypes)
)
);
// Group by resource and include targets with their unique site data
const resourcesMap = new Map();
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({
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) {
return {};
}
const config_output: any = {
http: {
middlewares: {
[redirectHttpsMiddlewareName]: {
redirectScheme: {
scheme: "https"
}
}
}
}
};
for (const resource of allResources) {
const targets = resource.targets;
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; continue;
} }
if (resource.http) { if (!resource.fullDomain) {
if (!resource.domainId) { logger.error(
continue; `Resource ${resource.resourceId} has no fullDomain`
} );
continue;
}
if (!resource.fullDomain) { // add routers and services empty objects if they don't exist
logger.error( if (!config_output.http.routers) {
`Resource ${resource.resourceId} has no fullDomain` config_output.http.routers = {};
); }
continue;
}
// add routers and services empty objects if they don't exist if (!config_output.http.services) {
if (!config_output.http.routers) { config_output.http.services = {};
config_output.http.routers = {}; }
}
if (!config_output.http.services) { const domainParts = fullDomain.split(".");
config_output.http.services = {}; let wildCard;
} if (domainParts.length <= 2) {
wildCard = `*.${domainParts.join(".")}`;
} else {
wildCard = `*.${domainParts.slice(1).join(".")}`;
}
const domainParts = fullDomain.split("."); if (!resource.subdomain) {
let wildCard; wildCard = resource.fullDomain;
if (domainParts.length <= 2) { }
wildCard = `*.${domainParts.join(".")}`;
} else {
wildCard = `*.${domainParts.slice(1).join(".")}`;
}
if (!resource.subdomain) { const configDomain = config.getDomain(resource.domainId);
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;
}
let certResolver: string, preferWildcardCert: boolean; let tls = {};
if (!configDomain) { if (build == "oss") {
certResolver = config.getRawConfig().traefik.cert_resolver; tls = {
preferWildcardCert =
config.getRawConfig().traefik.prefer_wildcard_cert;
} else {
certResolver = configDomain.cert_resolver;
preferWildcardCert = configDomain.prefer_wildcard_cert;
}
const tls = {
certResolver: certResolver, certResolver: certResolver,
...(preferWildcardCert ...(preferWildcardCert
? { ? {
@@ -262,214 +283,208 @@ export async function traefikConfigProvider(
} }
: {}) : {})
}; };
}
const additionalMiddlewares = const additionalMiddlewares =
config.getRawConfig().traefik.additional_middlewares || []; config.getRawConfig().traefik.additional_middlewares || [];
config_output.http.routers![routerName] = { 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: [ entryPoints: [
resource.ssl config.getRawConfig().traefik.http_entrypoint
? config.getRawConfig().traefik.https_entrypoint
: config.getRawConfig().traefik.http_entrypoint
],
middlewares: [
badgerMiddlewareName,
...additionalMiddlewares
], ],
middlewares: [redirectHttpsMiddlewareName],
service: serviceName, service: serviceName,
rule: `Host(\`${fullDomain}\`)`, rule: `Host(\`${fullDomain}\`)`,
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
}
}
}
: {})
}
}; };
} }
config_output.http.services![serviceName] = {
loadBalancer: {
servers: (targets as TargetWithSite[])
.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 as TargetWithSite[])
.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
}
}
}
: {})
}
};
} }
return res.status(HttpCode.OK).json(config_output);
} catch (e) {
logger.error(`Failed to build Traefik config: ${e}`);
return res.status(HttpCode.INTERNAL_SERVER_ERROR).json({
error: "Failed to build Traefik config"
});
} }
return config_output;
} }
+292
View File
@@ -0,0 +1,292 @@
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;
secret: string;
endpoint: string;
}
export interface WSMessage {
type: string;
data: any;
}
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 baseURL: string;
private handlers: Map<string, MessageHandler> = new Map();
private reconnectInterval: number;
private isConnected: boolean = false;
private pingInterval: number;
private pingTimeout: number;
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;
private isConnecting: boolean = false;
constructor(
token: string,
endpoint: string,
options: ClientOptions = {}
) {
super();
this.token = token;
this.baseURL = options.baseURL || endpoint;
this.reconnectInterval = options.reconnectInterval || 5000;
this.pingInterval = options.pingInterval || 30000;
this.pingTimeout = options.pingTimeout || 10000;
}
public async connect(): Promise<void> {
this.shouldReconnect = true;
if (!this.isConnecting) {
await this.connectWithRetry();
}
}
public async close(): Promise<void> {
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<void> {
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
};
logger.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 => {
logger.error('Failed to send initial message:', err);
});
// Set up interval
const intervalId = setInterval(() => {
this.sendMessage(messageType, data).catch(err => {
logger.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 connectWithRetry(): Promise<void> {
if (this.isConnecting) return;
this.isConnecting = true;
while (this.shouldReconnect && !this.isConnected) {
try {
await this.establishConnection();
this.isConnecting = false;
return;
} catch (error) {
logger.error(`Failed to connect: ${error}. Retrying in ${this.reconnectInterval}ms...`);
if (!this.shouldReconnect) {
this.isConnecting = false;
return;
}
await new Promise(resolve => {
this.reconnectTimer = setTimeout(resolve, this.reconnectInterval);
});
}
}
this.isConnecting = false;
}
private async establishConnection(): Promise<void> {
// 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', this.token);
wsURL.searchParams.set('clientType', "remoteExitNode");
return new Promise((resolve, reject) => {
const conn = new WebSocket(wsURL.toString());
conn.on('open', () => {
logger.debug('WebSocket connection established');
this.conn = conn;
this.setConnected(true);
this.isConnecting = false;
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) {
logger.error('Failed to parse message:', error);
}
});
conn.on('close', (code, reason) => {
logger.debug(`WebSocket connection closed: ${code} ${reason}`);
this.handleDisconnect();
});
conn.on('error', (error) => {
logger.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(() => {
logger.error('Ping timeout - no pong received');
this.handleDisconnect();
}, this.pingTimeout);
}
}, this.pingInterval);
}
private handleDisconnect(): void {
this.setConnected(false);
this.isConnecting = 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) {
// Add a small delay before starting reconnection to prevent immediate retry
setTimeout(() => {
this.connectWithRetry();
}, 1000);
}
}
private setConnected(status: boolean): void {
this.isConnected = status;
}
}
// Factory function for easier instantiation
export function createWebSocketClient(
token: string,
endpoint: string,
options?: ClientOptions
): WebSocketClient {
return new WebSocketClient(token, endpoint, options);
}
export default WebSocketClient;
+2 -2
View File
@@ -10,7 +10,7 @@ import {
handleOlmRegisterMessage, handleOlmRegisterMessage,
handleOlmRelayMessage, handleOlmRelayMessage,
handleOlmPingMessage, handleOlmPingMessage,
startOfflineChecker startOlmOfflineChecker
} from "../olm"; } from "../olm";
import { MessageHandler } from "./ws"; import { MessageHandler } from "./ws";
@@ -26,4 +26,4 @@ export const messageHandlers: Record<string, MessageHandler> = {
"newt/ping/request": handleNewtPingRequestMessage "newt/ping/request": handleNewtPingRequestMessage
}; };
startOfflineChecker(); // this is to handle the offline check for olms startOlmOfflineChecker(); // this is to handle the offline check for olms
@@ -59,6 +59,7 @@ import { useParams, useRouter } from "next/navigation";
import { QRCodeCanvas } from "qrcode.react"; import { QRCodeCanvas } from "qrcode.react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { build } from "@server/build";
type SiteType = "newt" | "wireguard" | "local"; type SiteType = "newt" | "wireguard" | "local";
@@ -142,7 +143,7 @@ export default function Page() {
{ {
id: "wireguard" as SiteType, id: "wireguard" as SiteType,
title: t("siteWg"), title: t("siteWg"),
description: t("siteWgDescription"), description: build == "saas" ? t("siteWgDescriptionSaas") : t("siteWgDescription"),
disabled: true disabled: true
} }
]), ]),
@@ -152,7 +153,7 @@ export default function Page() {
{ {
id: "local" as SiteType, id: "local" as SiteType,
title: t("local"), title: t("local"),
description: t("siteLocalDescription") description: build == "saas" ? t("siteLocalDescriptionSaas") : t("siteLocalDescription")
} }
]) ])
]); ]);
+176
View File
@@ -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 (
<>
<SettingsSectionTitle
title="Managed Self-Hosted"
description="More reliable and low-maintenance self-hosted Pangolin server with extra bells and whistles"
/>
<SettingsContainer>
<SettingsSection>
<SettingsSectionBody>
<p className="text-muted-foreground mb-4">
<strong>Managed Self-Hosted Pangolin</strong> is a
deployment option designed for people who want
simplicity and extra reliability while still keeping
their data private and self-hosted.
</p>
<p className="text-muted-foreground mb-6">
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:
</p>
<div className="grid gap-4 md:grid-cols-2 py-4">
<div className="space-y-3">
<div className="flex items-start gap-3">
<CheckCircle className="w-5 h-5 text-green-500 mt-0.5 flex-shrink-0" />
<div>
<h4 className="font-medium">
Simpler operations
</h4>
<p className="text-sm text-muted-foreground">
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.
</p>
</div>
</div>
<div className="flex items-start gap-3">
<RefreshCw className="w-5 h-5 text-blue-500 mt-0.5 flex-shrink-0" />
<div>
<h4 className="font-medium">
Automatic updates
</h4>
<p className="text-sm text-muted-foreground">
The cloud dashboard evolves quickly,
so you get new features and bug
fixes without having to manually
pull new containers every time.
</p>
</div>
</div>
<div className="flex items-start gap-3">
<Wrench className="w-5 h-5 text-orange-500 mt-0.5 flex-shrink-0" />
<div>
<h4 className="font-medium">
Less maintenance
</h4>
<p className="text-sm text-muted-foreground">
No database migrations, backups, or
extra infrastructure to manage. We
handle that in the cloud.
</p>
</div>
</div>
</div>
<div className="space-y-3">
<div className="flex items-start gap-3">
<Activity className="w-5 h-5 text-purple-500 mt-0.5 flex-shrink-0" />
<div>
<h4 className="font-medium">
Cloud failover
</h4>
<p className="text-sm text-muted-foreground">
If your node goes down, your tunnels
can temporarily fail over to our
cloud points of presence until you
bring it back online.
</p>
</div>
</div>
<div className="flex items-start gap-3">
<Shield className="w-5 h-5 text-indigo-500 mt-0.5 flex-shrink-0" />
<div>
<h4 className="font-medium">
High availability (PoPs)
</h4>
<p className="text-sm text-muted-foreground">
You can also attach multiple nodes
to your account for redundancy and
better performance.
</p>
</div>
</div>
<div className="flex items-start gap-3">
<Zap className="w-5 h-5 text-yellow-500 mt-0.5 flex-shrink-0" />
<div>
<h4 className="font-medium">
Future enhancements
</h4>
<p className="text-sm text-muted-foreground">
We're planning to add more
analytics, alerting, and management
tools to make your deployment even
more robust.
</p>
</div>
</div>
</div>
</div>
<Alert
variant="neutral"
className="flex items-center gap-1"
>
Read the docs to learn more about the Managed
Self-Hosted option in our{" "}
<Link
href="https://docs.digpangolin.com/self-host/advanced/convert-to-managed"
target="_blank"
rel="noopener noreferrer"
className="hover:underline text-primary flex items-center gap-1"
>
documentation
<ExternalLink className="w-4 h-4" />
</Link>
.
</Alert>
</SettingsSectionBody>
<SettingsSectionFooter>
<Link
href="https://docs.digpangolin.com/self-host/advanced/convert-to-managed"
target="_blank"
rel="noopener noreferrer"
className="hover:underline text-primary flex items-center gap-1"
>
<Button>
Convert This Node to Managed Self-Hosted
</Button>
</Link>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
</>
);
}
+13 -2
View File
@@ -13,10 +13,12 @@ import {
TicketCheck, TicketCheck,
User, User,
Globe, // Added from 'dev' branch Globe, // Added from 'dev' branch
MonitorUp // Added from 'dev' branch MonitorUp, // Added from 'dev' branch
Zap
} from "lucide-react"; } from "lucide-react";
export type SidebarNavSection = { // Added from 'dev' branch export type SidebarNavSection = {
// Added from 'dev' branch
heading: string; heading: string;
items: SidebarNavItem[]; items: SidebarNavItem[];
}; };
@@ -108,6 +110,15 @@ export const adminNavSections: SidebarNavSection[] = [
{ {
heading: "Admin", heading: "Admin",
items: [ items: [
...(build == "oss"
? [
{
title: "managedSelfhosted",
href: "/admin/managed",
icon: <Zap className="h-4 w-4" />
}
]
: []),
{ {
title: "sidebarAllUsers", title: "sidebarAllUsers",
href: "/admin/users", href: "/admin/users",
+31 -1
View File
@@ -6,7 +6,7 @@ import { OrgSelector } from "@app/components/OrgSelector";
import { cn } from "@app/lib/cn"; import { cn } from "@app/lib/cn";
import { ListUserOrgsResponse } from "@server/routers/org"; import { ListUserOrgsResponse } from "@server/routers/org";
import SupporterStatus from "@app/components/SupporterStatus"; 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 Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useUserContext } from "@app/hooks/useUserContext"; import { useUserContext } from "@app/hooks/useUserContext";
@@ -20,6 +20,7 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger TooltipTrigger
} from "@app/components/ui/tooltip"; } from "@app/components/ui/tooltip";
import { build } from "@server/build";
interface LayoutSidebarProps { interface LayoutSidebarProps {
orgId?: string; orgId?: string;
@@ -73,6 +74,35 @@ export function LayoutSidebar({
<div className="px-2 pt-1"> <div className="px-2 pt-1">
{!isAdminPage && user.serverAdmin && ( {!isAdminPage && user.serverAdmin && (
<div className="pb-4"> <div className="pb-4">
{build === "oss" && (
<Link
href="/admin/managed"
className={cn(
"flex items-center rounded transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-secondary/50 dark:hover:bg-secondary/20 rounded-md",
isSidebarCollapsed
? "px-2 py-2 justify-center"
: "px-3 py-1.5"
)}
title={
isSidebarCollapsed
? t("managedSelfhosted")
: undefined
}
>
<span
className={cn(
"flex-shrink-0",
!isSidebarCollapsed && "mr-2"
)}
>
<Zap className="h-4 w-4" />
</span>
{!isSidebarCollapsed && (
<span>{t("managedSelfhosted")}</span>
)}
</Link>
)}
<Link <Link
href="/admin" href="/admin"
className={cn( className={cn(
+1 -1
View File
@@ -25,5 +25,5 @@ export type Env = {
disableBasicWireguardSites: boolean; disableBasicWireguardSites: boolean;
enableClients: boolean; enableClients: boolean;
hideSupporterKey: boolean; hideSupporterKey: boolean;
}, }
}; };