mirror of
https://github.com/fosrl/pangolin.git
synced 2026-09-11 05:26:32 +02:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7506c69c3c | |||
| eb133f6254 | |||
| 251f2dd1de |
@@ -23,7 +23,7 @@ export const clearExitNodes: CommandModule<
|
|||||||
// Delete all exit nodes
|
// Delete all exit nodes
|
||||||
const deletedCount = await db
|
const deletedCount = await db
|
||||||
.delete(exitNodes)
|
.delete(exitNodes)
|
||||||
.where(eq(exitNodes.exitNodeId, exitNodes.exitNodeId)) .returning();; // delete all
|
.where(eq(exitNodes.exitNodeId, exitNodes.exitNodeId)).returning();; // delete all
|
||||||
|
|
||||||
console.log(`Deleted ${deletedCount.length} exit node(s) from the database`);
|
console.log(`Deleted ${deletedCount.length} exit node(s) from the database`);
|
||||||
|
|
||||||
|
|||||||
+6
-5
@@ -152,7 +152,7 @@
|
|||||||
"siteResourcesHowToAccess": "How to access",
|
"siteResourcesHowToAccess": "How to access",
|
||||||
"siteResourcesTargetsOnSite": "Targets on this site",
|
"siteResourcesTargetsOnSite": "Targets on this site",
|
||||||
"siteSetting": "{siteName} Settings",
|
"siteSetting": "{siteName} Settings",
|
||||||
"siteNewtTunnel": "Newt Site (Recommended)",
|
"siteNewtTunnel": "Pangolin Site (Recommended)",
|
||||||
"siteNewtTunnelDescription": "Easiest way to create an entrypoint into any network. No extra setup.",
|
"siteNewtTunnelDescription": "Easiest way to create an entrypoint into any 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.",
|
||||||
@@ -2898,7 +2898,7 @@
|
|||||||
"editInternalResourceDialogAlias": "Alias",
|
"editInternalResourceDialogAlias": "Alias",
|
||||||
"editInternalResourceDialogAliasDescription": "An optional internal DNS alias for this resource.",
|
"editInternalResourceDialogAliasDescription": "An optional internal DNS alias for this resource.",
|
||||||
"createInternalResourceDialogNoSitesAvailable": "No Sites Available",
|
"createInternalResourceDialogNoSitesAvailable": "No Sites Available",
|
||||||
"createInternalResourceDialogNoSitesAvailableDescription": "You need to have at least one Newt site with a subnet configured to create private resources.",
|
"createInternalResourceDialogNoSitesAvailableDescription": "You need to have at least one site with a subnet configured to create private resources.",
|
||||||
"createInternalResourceDialogClose": "Close",
|
"createInternalResourceDialogClose": "Close",
|
||||||
"createInternalResourceDialogCreateClientResource": "Create Private Resource",
|
"createInternalResourceDialogCreateClientResource": "Create Private Resource",
|
||||||
"createInternalResourceDialogCreateClientResourceDescription": "Create a new resource that will only be accessible to clients connected to the organization",
|
"createInternalResourceDialogCreateClientResourceDescription": "Create a new resource that will only be accessible to clients connected to the organization",
|
||||||
@@ -3477,8 +3477,7 @@
|
|||||||
},
|
},
|
||||||
"priority": "Priority",
|
"priority": "Priority",
|
||||||
"priorityDescription": "Higher priority routes are evaluated first. Priority = 100 means automatic ordering (system decides). Use another number to enforce manual priority.",
|
"priorityDescription": "Higher priority routes are evaluated first. Priority = 100 means automatic ordering (system decides). Use another number to enforce manual priority.",
|
||||||
"instanceName": "Server ID",
|
"instanceName": "Instance Name",
|
||||||
"clearInstanceName": "Reset Server Association",
|
|
||||||
"pathMatchModalTitle": "Configure Path Matching",
|
"pathMatchModalTitle": "Configure Path Matching",
|
||||||
"pathMatchModalDescription": "Set up how incoming requests should be matched based on their path.",
|
"pathMatchModalDescription": "Set up how incoming requests should be matched based on their path.",
|
||||||
"pathMatchType": "Match Type",
|
"pathMatchType": "Match Type",
|
||||||
@@ -3768,6 +3767,7 @@
|
|||||||
"noData": "No Data",
|
"noData": "No Data",
|
||||||
"machineClients": "Machine Clients",
|
"machineClients": "Machine Clients",
|
||||||
"install": "Install",
|
"install": "Install",
|
||||||
|
"downloadInstaller": "Download Installer",
|
||||||
"run": "Run",
|
"run": "Run",
|
||||||
"envFile": "Environment File",
|
"envFile": "Environment File",
|
||||||
"serviceFile": "Service File",
|
"serviceFile": "Service File",
|
||||||
@@ -4364,5 +4364,6 @@
|
|||||||
"rdpUnicodeKeyboardMode": "Unicode keyboard mode",
|
"rdpUnicodeKeyboardMode": "Unicode keyboard mode",
|
||||||
"sessionToolbarShow": "Show toolbar",
|
"sessionToolbarShow": "Show toolbar",
|
||||||
"sessionToolbarHide": "Hide toolbar",
|
"sessionToolbarHide": "Hide toolbar",
|
||||||
"actionUpdateSiteApprovals": "Update Site Approvals"
|
"actionUpdateSiteApprovals": "Update Site Approvals",
|
||||||
|
"check": "Check"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
-56
@@ -50,7 +50,6 @@
|
|||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
"@xterm/addon-web-links": "^0.12.0",
|
"@xterm/addon-web-links": "^0.12.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
"acme-client": "^5.4.0",
|
|
||||||
"arctic": "3.7.0",
|
"arctic": "3.7.0",
|
||||||
"axios": "1.20.0",
|
"axios": "1.20.0",
|
||||||
"better-sqlite3": "11.9.1",
|
"better-sqlite3": "11.9.1",
|
||||||
@@ -62,7 +61,6 @@
|
|||||||
"cors": "2.8.6",
|
"cors": "2.8.6",
|
||||||
"crypto-js": "4.2.0",
|
"crypto-js": "4.2.0",
|
||||||
"d3": "7.9.0",
|
"d3": "7.9.0",
|
||||||
"dns-packet": "^5.6.1",
|
|
||||||
"drizzle-orm": "0.45.2",
|
"drizzle-orm": "0.45.2",
|
||||||
"express": "5.2.1",
|
"express": "5.2.1",
|
||||||
"express-rate-limit": "8.7.0",
|
"express-rate-limit": "8.7.0",
|
||||||
@@ -126,7 +124,6 @@
|
|||||||
"@types/cors": "2.8.19",
|
"@types/cors": "2.8.19",
|
||||||
"@types/crypto-js": "4.2.2",
|
"@types/crypto-js": "4.2.2",
|
||||||
"@types/d3": "7.4.3",
|
"@types/d3": "7.4.3",
|
||||||
"@types/dns-packet": "^5.6.5",
|
|
||||||
"@types/express": "5.0.6",
|
"@types/express": "5.0.6",
|
||||||
"@types/express-session": "1.19.0",
|
"@types/express-session": "1.19.0",
|
||||||
"@types/jmespath": "0.15.2",
|
"@types/jmespath": "0.15.2",
|
||||||
@@ -2448,12 +2445,6 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@leichtgewicht/ip-codec": {
|
|
||||||
"version": "2.0.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz",
|
|
||||||
"integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@levischuck/tiny-cbor": {
|
"node_modules/@levischuck/tiny-cbor": {
|
||||||
"version": "0.2.11",
|
"version": "0.2.11",
|
||||||
"resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz",
|
"resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz",
|
||||||
@@ -6773,16 +6764,6 @@
|
|||||||
"@types/d3-selection": "*"
|
"@types/d3-selection": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/dns-packet": {
|
|
||||||
"version": "5.6.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/dns-packet/-/dns-packet-5.6.5.tgz",
|
|
||||||
"integrity": "sha512-qXOC7XLOEe43ehtWJCMnQXvgcIpv6rPmQ1jXT98Ad8A3TB1Ue50jsCbSSSyuazScEuZ/Q026vHbrOTVkmwA+7Q==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/node": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/esrecurse": {
|
"node_modules/@types/esrecurse": {
|
||||||
"version": "4.3.1",
|
"version": "4.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
|
||||||
@@ -7454,22 +7435,6 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/acme-client": {
|
|
||||||
"version": "5.4.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/acme-client/-/acme-client-5.4.0.tgz",
|
|
||||||
"integrity": "sha512-mORqg60S8iML6XSmVjqjGHJkINrCGLMj2QvDmFzI9vIlv1RGlyjmw3nrzaINJjkNsYXC41XhhD5pfy7CtuGcbA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@peculiar/x509": "^1.11.0",
|
|
||||||
"asn1js": "^3.0.5",
|
|
||||||
"axios": "^1.7.2",
|
|
||||||
"debug": "^4.3.5",
|
|
||||||
"node-forge": "^1.3.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 16"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/acorn": {
|
"node_modules/acorn": {
|
||||||
"version": "8.16.0",
|
"version": "8.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||||
@@ -9296,18 +9261,6 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/dns-packet": {
|
|
||||||
"version": "5.6.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz",
|
|
||||||
"integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@leichtgewicht/ip-codec": "^2.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/doctrine": {
|
"node_modules/doctrine": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
|
||||||
@@ -13187,15 +13140,6 @@
|
|||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/node-forge": {
|
|
||||||
"version": "1.4.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz",
|
|
||||||
"integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==",
|
|
||||||
"license": "(BSD-3-Clause OR GPL-2.0)",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 6.13.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/node-releases": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.54",
|
"version": "2.0.54",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz",
|
||||||
|
|||||||
@@ -73,7 +73,6 @@
|
|||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
"@xterm/addon-web-links": "^0.12.0",
|
"@xterm/addon-web-links": "^0.12.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
"acme-client": "^5.4.0",
|
|
||||||
"arctic": "3.7.0",
|
"arctic": "3.7.0",
|
||||||
"axios": "1.20.0",
|
"axios": "1.20.0",
|
||||||
"better-sqlite3": "11.9.1",
|
"better-sqlite3": "11.9.1",
|
||||||
@@ -85,7 +84,6 @@
|
|||||||
"cors": "2.8.6",
|
"cors": "2.8.6",
|
||||||
"crypto-js": "4.2.0",
|
"crypto-js": "4.2.0",
|
||||||
"d3": "7.9.0",
|
"d3": "7.9.0",
|
||||||
"dns-packet": "^5.6.1",
|
|
||||||
"drizzle-orm": "0.45.2",
|
"drizzle-orm": "0.45.2",
|
||||||
"express": "5.2.1",
|
"express": "5.2.1",
|
||||||
"express-rate-limit": "8.7.0",
|
"express-rate-limit": "8.7.0",
|
||||||
@@ -149,7 +147,6 @@
|
|||||||
"@types/cors": "2.8.19",
|
"@types/cors": "2.8.19",
|
||||||
"@types/crypto-js": "4.2.2",
|
"@types/crypto-js": "4.2.2",
|
||||||
"@types/d3": "7.4.3",
|
"@types/d3": "7.4.3",
|
||||||
"@types/dns-packet": "^5.6.5",
|
|
||||||
"@types/express": "5.0.6",
|
"@types/express": "5.0.6",
|
||||||
"@types/express-session": "1.19.0",
|
"@types/express-session": "1.19.0",
|
||||||
"@types/jmespath": "0.15.2",
|
"@types/jmespath": "0.15.2",
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
export async function startCertificateManager() {
|
|
||||||
// No-op: ACME certificate generation/management is only available in
|
|
||||||
// builds that include the private/enterprise feature set.
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function stopCertificateManager() {
|
|
||||||
// No-op counterpart to startCertificateManager.
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
export async function startDnsServer() {
|
|
||||||
// No-op: the authoritative DNS server is only available in builds
|
|
||||||
// that include the private/enterprise feature set.
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function stopDnsServer() {
|
|
||||||
// No-op counterpart to startDnsServer.
|
|
||||||
}
|
|
||||||
@@ -25,8 +25,6 @@ import { setHostMeta } from "@server/lib/hostMeta";
|
|||||||
import { TraefikConfigManager } from "@server/lib/traefik/TraefikConfigManager";
|
import { TraefikConfigManager } from "@server/lib/traefik/TraefikConfigManager";
|
||||||
import { initCleanup } from "#dynamic/cleanup";
|
import { initCleanup } from "#dynamic/cleanup";
|
||||||
import { startSchedulers } from "#dynamic/startSchedulers";
|
import { startSchedulers } from "#dynamic/startSchedulers";
|
||||||
import { startDnsServer } from "#dynamic/dns";
|
|
||||||
import { startCertificateManager } from "#dynamic/certificates";
|
|
||||||
import license from "#dynamic/license/license";
|
import license from "#dynamic/license/license";
|
||||||
import { fetchServerIp } from "@server/lib/serverIpService";
|
import { fetchServerIp } from "@server/lib/serverIpService";
|
||||||
import { initAiModelCatalog } from "@server/lib/aiModelCatalog";
|
import { initAiModelCatalog } from "@server/lib/aiModelCatalog";
|
||||||
@@ -47,10 +45,6 @@ async function startServers() {
|
|||||||
|
|
||||||
startSchedulers();
|
startSchedulers();
|
||||||
|
|
||||||
await startDnsServer();
|
|
||||||
|
|
||||||
await startCertificateManager();
|
|
||||||
|
|
||||||
// Start all servers
|
// Start all servers
|
||||||
const apiServer = createApiServer();
|
const apiServer = createApiServer();
|
||||||
const internalServer = createInternalServer();
|
const internalServer = createInternalServer();
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
export function createCname(domainId: string, baseDomain: string) {}
|
|
||||||
|
|
||||||
export function createNs() {}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
// Tracks, per process lifetime, whether a given exit node has ever checked in
|
|
||||||
// (called /gerbil/get-config) since this Pangolin instance started. This lets
|
|
||||||
// callers distinguish "gerbil hasn't come up yet" (expected briefly after a
|
|
||||||
// restart, since gerbil depends on pangolin's container starting first) from
|
|
||||||
// "gerbil was reachable and now isn't" (a real problem worth an error log).
|
|
||||||
const checkedInExitNodeIds = new Set<number>();
|
|
||||||
|
|
||||||
export function markExitNodeCheckedIn(exitNodeId: number): void {
|
|
||||||
checkedInExitNodeIds.add(exitNodeId);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hasExitNodeCheckedIn(exitNodeId: number): boolean {
|
|
||||||
return checkedInExitNodeIds.has(exitNodeId);
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { ExitNode } from "@server/db";
|
import { ExitNode } from "@server/db";
|
||||||
import { hasExitNodeCheckedIn } from "./exitNodeCheckIn";
|
|
||||||
|
|
||||||
interface ExitNodeRequest {
|
interface ExitNodeRequest {
|
||||||
remoteType?: string;
|
remoteType?: string;
|
||||||
@@ -73,19 +72,13 @@ export async function sendToExitNode(
|
|||||||
|
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = axios.isAxiosError(error)
|
if (axios.isAxiosError(error)) {
|
||||||
? `Error making ${method} request (can Pangolin see Gerbil HTTP API?) for exit node at ${exitNode.reachableAt} (status: ${error.response?.status}): ${error.message}`
|
logger.error(
|
||||||
: `Error making ${method} request for exit node at ${exitNode.reachableAt}: ${error}`;
|
`Error making ${method} request (can Pangolin see Gerbil HTTP API?) for exit node at ${exitNode.reachableAt} (status: ${error.response?.status}): ${error.message}`
|
||||||
|
);
|
||||||
// The exit node (gerbil) may still be starting up and not yet
|
|
||||||
// reachable. Until it has checked in at least once, log this at a
|
|
||||||
// lower level since it's expected; once it has checked in, a
|
|
||||||
// connection failure is a real problem.
|
|
||||||
if (hasExitNodeCheckedIn(exitNode.exitNodeId)) {
|
|
||||||
logger.error(message);
|
|
||||||
} else {
|
} else {
|
||||||
logger.warn(
|
logger.error(
|
||||||
`${message} (exit node has not checked in yet since startup, this is expected briefly)`
|
`Error making ${method} request for exit node at ${exitNode.reachableAt}: ${error}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
export * from "./exitNodes";
|
export * from "./exitNodes";
|
||||||
export * from "./exitNodeComms";
|
export * from "./exitNodeComms";
|
||||||
export * from "./exitNodeCheckIn";
|
|
||||||
export * from "./subnet";
|
export * from "./subnet";
|
||||||
export * from "./getCurrentExitNodeId";
|
export * from "./getCurrentExitNodeId";
|
||||||
export * from "./calculateExitNodeWeight";
|
export * from "./calculateExitNodeWeight";
|
||||||
|
|||||||
@@ -493,6 +493,23 @@ export const configSchema = z
|
|||||||
.prefault({})
|
.prefault({})
|
||||||
})
|
})
|
||||||
.optional()
|
.optional()
|
||||||
|
.prefault({}),
|
||||||
|
dns: z
|
||||||
|
.object({
|
||||||
|
nameservers: z
|
||||||
|
.array(z.string().optional().optional())
|
||||||
|
.optional()
|
||||||
|
.default([
|
||||||
|
"ns1.pangolin.net",
|
||||||
|
"ns2.pangolin.net",
|
||||||
|
"ns3.pangolin.net"
|
||||||
|
]),
|
||||||
|
cname_extension: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.default("cname.pangolin.net")
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
.prefault({})
|
.prefault({})
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
import logger from "@server/logger";
|
|
||||||
|
|
||||||
export async function withRetry<T>(
|
|
||||||
fn: () => Promise<T>,
|
|
||||||
options: {
|
|
||||||
retries?: number;
|
|
||||||
baseDelayMs?: number;
|
|
||||||
label?: string;
|
|
||||||
// Called with each caught error to decide whether it's worth
|
|
||||||
// retrying. Defaults to retrying everything (existing behavior) -
|
|
||||||
// pass this to exclude errors that are known to be permanent (e.g.
|
|
||||||
// an upstream rate limit or validation rejection) rather than
|
|
||||||
// transient, so they fail fast instead of wasting retry attempts.
|
|
||||||
shouldRetry?: (error: unknown) => boolean;
|
|
||||||
} = {}
|
|
||||||
): Promise<T> {
|
|
||||||
const {
|
|
||||||
retries = 3,
|
|
||||||
baseDelayMs = 250,
|
|
||||||
label = "operation",
|
|
||||||
shouldRetry = () => true
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
let attempt = 0;
|
|
||||||
while (true) {
|
|
||||||
try {
|
|
||||||
return await fn();
|
|
||||||
} catch (error) {
|
|
||||||
attempt++;
|
|
||||||
if (attempt > retries || !shouldRetry(error)) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exponential backoff with jitter so retries don't all land at once.
|
|
||||||
const delay =
|
|
||||||
baseDelayMs * 2 ** (attempt - 1) * (0.5 + Math.random());
|
|
||||||
|
|
||||||
logger.warn(
|
|
||||||
`${label} failed (attempt ${attempt}/${retries + 1}), retrying in ${delay.toFixed(0)}ms`,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bounds an operation that has no timeout of its own (e.g. acme-client's
|
|
||||||
// axios instance never sets one, so a stalled TCP connection to the ACME
|
|
||||||
// server hangs forever instead of erroring). Without this, a single hung
|
|
||||||
// call can leave its caller's promise permanently unsettled - fatal for
|
|
||||||
// code that gates future work on that promise resolving, like the
|
|
||||||
// scheduler's runExclusive() waiting on a batch's Promise.all.
|
|
||||||
export async function withTimeout<T>(
|
|
||||||
promise: Promise<T>,
|
|
||||||
ms: number,
|
|
||||||
label = "operation"
|
|
||||||
): Promise<T> {
|
|
||||||
let timer: NodeJS.Timeout;
|
|
||||||
const timeout = new Promise<never>((_, reject) => {
|
|
||||||
timer = setTimeout(
|
|
||||||
() => reject(new Error(`${label} timed out after ${ms}ms`)),
|
|
||||||
ms
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
return await Promise.race([promise, timeout]);
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timer!);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,10 +6,7 @@ import * as yaml from "js-yaml";
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { db, exitNodes } from "@server/db";
|
import { db, exitNodes } from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import {
|
import { getCurrentExitNodeId } from "@server/lib/exitNodes";
|
||||||
getCurrentExitNodeId,
|
|
||||||
hasExitNodeCheckedIn
|
|
||||||
} from "@server/lib/exitNodes";
|
|
||||||
import { getTraefikConfig } from "#dynamic/lib/traefik";
|
import { getTraefikConfig } from "#dynamic/lib/traefik";
|
||||||
import { getValidCertificatesForDomains } from "@server/lib/certificates";
|
import { getValidCertificatesForDomains } from "@server/lib/certificates";
|
||||||
import { sendToExitNode } from "#dynamic/lib/exitNodes";
|
import { sendToExitNode } from "#dynamic/lib/exitNodes";
|
||||||
@@ -344,6 +341,10 @@ export class TraefikConfigManager {
|
|||||||
|
|
||||||
const { domains, traefikConfig } = getTraefikConfig;
|
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
|
// Log if domains changed
|
||||||
if (
|
if (
|
||||||
this.lastActiveDomains.size !== domains.size ||
|
this.lastActiveDomains.size !== domains.size ||
|
||||||
@@ -357,7 +358,7 @@ export class TraefikConfigManager {
|
|||||||
this.lastActiveDomains = new Set(domains);
|
this.lastActiveDomains = new Set(domains);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.env.CERT_MODE === "pangolin" && build != "oss") {
|
if (process.env.USE_PANGOLIN_DNS === "true" && build != "oss") {
|
||||||
// Scan current local certificate state
|
// Scan current local certificate state
|
||||||
this.lastLocalCertificateState =
|
this.lastLocalCertificateState =
|
||||||
await this.scanLocalCertificateState();
|
await this.scanLocalCertificateState();
|
||||||
@@ -438,13 +439,13 @@ export class TraefikConfigManager {
|
|||||||
// Always ensure all existing certificates (including wildcards) are in the config
|
// Always ensure all existing certificates (including wildcards) are in the config
|
||||||
await this.updateDynamicConfigFromLocalCerts(domains);
|
await this.updateDynamicConfigFromLocalCerts(domains);
|
||||||
} else {
|
} else {
|
||||||
// const timeSinceLastFetch = this.lastCertificateFetch
|
const timeSinceLastFetch = this.lastCertificateFetch
|
||||||
// ? Math.round(
|
? Math.round(
|
||||||
// (Date.now() -
|
(Date.now() -
|
||||||
// this.lastCertificateFetch.getTime()) /
|
this.lastCertificateFetch.getTime()) /
|
||||||
// (1000 * 60)
|
(1000 * 60)
|
||||||
// )
|
)
|
||||||
// : 0;
|
: 0;
|
||||||
|
|
||||||
// logger.debug(
|
// logger.debug(
|
||||||
// `Skipping certificate fetch - no changes detected and within 24-hour window (last fetch: ${timeSinceLastFetch} minutes ago)`
|
// `Skipping certificate fetch - no changes detected and within 24-hour window (last fetch: ${timeSinceLastFetch} minutes ago)`
|
||||||
@@ -465,52 +466,33 @@ export class TraefikConfigManager {
|
|||||||
await this.writeTraefikDynamicConfig(traefikConfig);
|
await this.writeTraefikDynamicConfig(traefikConfig);
|
||||||
|
|
||||||
// Send domains to SNI proxy
|
// Send domains to SNI proxy
|
||||||
let exitNodeForSni: typeof exitNodes.$inferSelect | undefined;
|
|
||||||
try {
|
try {
|
||||||
|
let exitNode;
|
||||||
if (config.getRawConfig().gerbil.exit_node_name) {
|
if (config.getRawConfig().gerbil.exit_node_name) {
|
||||||
const exitNodeName =
|
const exitNodeName =
|
||||||
config.getRawConfig().gerbil.exit_node_name!;
|
config.getRawConfig().gerbil.exit_node_name!;
|
||||||
[exitNodeForSni] = await db
|
[exitNode] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(exitNodes)
|
.from(exitNodes)
|
||||||
.where(eq(exitNodes.name, exitNodeName))
|
.where(eq(exitNodes.name, exitNodeName))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
} else {
|
} else {
|
||||||
[exitNodeForSni] = await db
|
[exitNode] = await db.select().from(exitNodes).limit(1);
|
||||||
.select()
|
|
||||||
.from(exitNodes)
|
|
||||||
.limit(1);
|
|
||||||
}
|
}
|
||||||
if (exitNodeForSni) {
|
if (exitNode) {
|
||||||
await sendToExitNode(exitNodeForSni, {
|
await sendToExitNode(exitNode, {
|
||||||
localPath: "/update-local-snis",
|
localPath: "/update-local-snis",
|
||||||
method: "POST",
|
method: "POST",
|
||||||
data: {
|
data: { fullDomains: Array.from(domains) }
|
||||||
fullDomains: [
|
|
||||||
...Array.from(domains),
|
|
||||||
...config.getRawConfig().traefik.static_domains
|
|
||||||
]
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
logger.warn(
|
logger.error(
|
||||||
"No exit node found. Has gerbil registered yet?"
|
"No exit node found. Has gerbil registered yet?"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// sendToExitNode already logs the underlying connection
|
|
||||||
// error at the appropriate level (warn before the exit node
|
|
||||||
// has checked in since startup, error after), so avoid
|
|
||||||
// double-logging it as an error here.
|
|
||||||
if (
|
|
||||||
exitNodeForSni &&
|
|
||||||
!hasExitNodeCheckedIn(exitNodeForSni.exitNodeId)
|
|
||||||
) {
|
|
||||||
logger.warn("Failed to post domains to SNI proxy:", err);
|
|
||||||
} else {
|
|
||||||
logger.error("Failed to post domains to SNI proxy:", err);
|
logger.error("Failed to post domains to SNI proxy:", err);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Update active domains tracking
|
// Update active domains tracking
|
||||||
this.activeDomains = domains;
|
this.activeDomains = domains;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
// Build the Host()/HostRegexp() Traefik rule for a resource's domain.
|
/**
|
||||||
// Wildcard resources match any single subdomain via HostRegexp.
|
* Build the Host()/HostRegexp() Traefik rule for a resource's domain.
|
||||||
|
* Wildcard resources match any single subdomain via HostRegexp.
|
||||||
|
*/
|
||||||
export function buildHostRule(
|
export function buildHostRule(
|
||||||
fullDomain: string,
|
fullDomain: string,
|
||||||
wildcard?: boolean | null
|
wildcard?: boolean | null
|
||||||
@@ -12,8 +14,10 @@ export function buildHostRule(
|
|||||||
return `Host(\`${fullDomain}\`)`;
|
return `Host(\`${fullDomain}\`)`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Append a path-matching clause to a Traefik rule based on the resource's
|
/**
|
||||||
// configured path and pathMatchType.
|
* Append a path-matching clause to a Traefik rule based on the resource's
|
||||||
|
* configured path and pathMatchType.
|
||||||
|
*/
|
||||||
export function appendPathMatch(
|
export function appendPathMatch(
|
||||||
rule: string,
|
rule: string,
|
||||||
path: string | null | undefined,
|
path: string | null | undefined,
|
||||||
@@ -36,8 +40,10 @@ export function appendPathMatch(
|
|||||||
return rule;
|
return rule;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute the router priority for a resource, favoring an explicit override
|
/**
|
||||||
// and otherwise deriving it from the path match specificity.
|
* Compute the router priority for a resource, favoring an explicit override
|
||||||
|
* and otherwise deriving it from the path match specificity.
|
||||||
|
*/
|
||||||
export function computeRoutePriority(
|
export function computeRoutePriority(
|
||||||
priority: number | null | undefined,
|
priority: number | null | undefined,
|
||||||
path: string | null | undefined,
|
path: string | null | undefined,
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of a proprietary work.
|
|
||||||
*
|
|
||||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
|
||||||
* All rights reserved.
|
|
||||||
*
|
|
||||||
* This file is licensed under the Fossorial Commercial License.
|
|
||||||
* You may not use this file except in compliance with the License.
|
|
||||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
|
||||||
*
|
|
||||||
* This file is not licensed under the AGPLv3.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export {
|
|
||||||
startCertificateManager,
|
|
||||||
stopCertificateManager
|
|
||||||
} from "./lib/certificates";
|
|
||||||
@@ -20,8 +20,6 @@ import { flushSiteBandwidthToDb } from "@server/routers/gerbil/receiveBandwidth"
|
|||||||
import { stopPingAccumulator } from "@server/routers/newt/pingAccumulator";
|
import { stopPingAccumulator } from "@server/routers/newt/pingAccumulator";
|
||||||
import { shutdownUsageRecorder } from "@server/lib/aiBudgetEnforcement";
|
import { shutdownUsageRecorder } from "@server/lib/aiBudgetEnforcement";
|
||||||
import { shutdownAiSessionLogger } from "@server/routers/aiGateway/logAiSession";
|
import { shutdownAiSessionLogger } from "@server/routers/aiGateway/logAiSession";
|
||||||
import { stopDnsServer } from "./dns";
|
|
||||||
import { stopCertificateManager } from "./certificates";
|
|
||||||
|
|
||||||
async function cleanup() {
|
async function cleanup() {
|
||||||
await stopPingAccumulator();
|
await stopPingAccumulator();
|
||||||
@@ -33,8 +31,6 @@ async function cleanup() {
|
|||||||
await rateLimitService.cleanup();
|
await rateLimitService.cleanup();
|
||||||
await wsCleanup();
|
await wsCleanup();
|
||||||
await logStreamingManager.shutdown();
|
await logStreamingManager.shutdown();
|
||||||
await stopDnsServer();
|
|
||||||
await stopCertificateManager();
|
|
||||||
|
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of a proprietary work.
|
|
||||||
*
|
|
||||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
|
||||||
* All rights reserved.
|
|
||||||
*
|
|
||||||
* This file is licensed under the Fossorial Commercial License.
|
|
||||||
* You may not use this file except in compliance with the License.
|
|
||||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
|
||||||
*
|
|
||||||
* This file is not licensed under the AGPLv3.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { AuthoritativeDNSServer } from "#private/lib/dns";
|
|
||||||
import { privateConfig } from "#private/lib/config";
|
|
||||||
|
|
||||||
let dnsServer: AuthoritativeDNSServer | undefined;
|
|
||||||
|
|
||||||
export async function startDnsServer() {
|
|
||||||
const dnsConfig = privateConfig.getRawPrivateConfig().dns;
|
|
||||||
if (!dnsConfig || !dnsConfig.enabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cacheOptions = {
|
|
||||||
stdTTL: 300, // 5 minutes default TTL
|
|
||||||
checkperiod: 60, // Check for expired keys every 60 seconds
|
|
||||||
useClones: false // Better performance
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create DNS server
|
|
||||||
dnsServer = new AuthoritativeDNSServer(dnsConfig.listen_port, cacheOptions);
|
|
||||||
|
|
||||||
await dnsServer.start();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function stopDnsServer() {
|
|
||||||
if (!dnsServer) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await dnsServer.stop();
|
|
||||||
dnsServer = undefined;
|
|
||||||
}
|
|
||||||
+11
-11
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
import NodeCache from "node-cache";
|
import NodeCache from "node-cache";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { redisManager, regionalRedisManager } from "#private/lib/redis";
|
import { redisManager, regionalRedisManager } from "@server/private/lib/redis";
|
||||||
|
|
||||||
// Create local cache with maxKeys limit to prevent memory leaks
|
// Create local cache with maxKeys limit to prevent memory leaks
|
||||||
// With ~10k requests/day and 5min TTL, 10k keys should be more than sufficient
|
// With ~10k requests/day and 5min TTL, 10k keys should be more than sufficient
|
||||||
@@ -97,11 +97,11 @@ class AdaptiveCache {
|
|||||||
const value = await redisManager.get(key);
|
const value = await redisManager.get(key);
|
||||||
|
|
||||||
if (value !== null) {
|
if (value !== null) {
|
||||||
// logger.debug(`Cache hit in Redis: ${key}`);
|
logger.debug(`Cache hit in Redis: ${key}`);
|
||||||
return JSON.parse(value) as T;
|
return JSON.parse(value) as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
// logger.debug(`Cache miss in Redis: ${key}`);
|
logger.debug(`Cache miss in Redis: ${key}`);
|
||||||
return undefined;
|
return undefined;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Redis get error for key ${key}:`, error);
|
logger.error(`Redis get error for key ${key}:`, error);
|
||||||
@@ -134,7 +134,7 @@ class AdaptiveCache {
|
|||||||
const success = await redisManager.del(k);
|
const success = await redisManager.del(k);
|
||||||
if (success) {
|
if (success) {
|
||||||
deletedCount++;
|
deletedCount++;
|
||||||
// logger.debug(`Deleted key from Redis: ${k}`);
|
logger.debug(`Deleted key from Redis: ${k}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,7 +161,7 @@ class AdaptiveCache {
|
|||||||
const success = localCache.del(k);
|
const success = localCache.del(k);
|
||||||
if (success > 0) {
|
if (success > 0) {
|
||||||
deletedCount++;
|
deletedCount++;
|
||||||
// logger.debug(`Deleted key from local cache: ${k}`);
|
logger.debug(`Deleted key from local cache: ${k}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,7 +229,7 @@ class AdaptiveCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
localCache.flushAll();
|
localCache.flushAll();
|
||||||
// logger.debug("Flushed local cache");
|
logger.debug("Flushed local cache");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -332,7 +332,7 @@ class RegionalAdaptiveCache {
|
|||||||
redisTtl
|
redisTtl
|
||||||
);
|
);
|
||||||
if (success) {
|
if (success) {
|
||||||
// logger.debug(`[regional] Set key in Redis: ${key}`);
|
logger.debug(`[regional] Set key in Redis: ${key}`);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -353,10 +353,10 @@ class RegionalAdaptiveCache {
|
|||||||
try {
|
try {
|
||||||
const value = await regionalRedisManager.get(key);
|
const value = await regionalRedisManager.get(key);
|
||||||
if (value !== null) {
|
if (value !== null) {
|
||||||
// logger.debug(`[regional] Cache hit in Redis: ${key}`);
|
logger.debug(`[regional] Cache hit in Redis: ${key}`);
|
||||||
return JSON.parse(value) as T;
|
return JSON.parse(value) as T;
|
||||||
}
|
}
|
||||||
// logger.debug(`[regional] Cache miss in Redis: ${key}`);
|
logger.debug(`[regional] Cache miss in Redis: ${key}`);
|
||||||
return undefined;
|
return undefined;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(
|
logger.error(
|
||||||
@@ -385,7 +385,7 @@ class RegionalAdaptiveCache {
|
|||||||
const success = await regionalRedisManager.del(k);
|
const success = await regionalRedisManager.del(k);
|
||||||
if (success) {
|
if (success) {
|
||||||
deletedCount++;
|
deletedCount++;
|
||||||
// logger.debug(`[regional] Deleted key from Redis: ${k}`);
|
logger.debug(`[regional] Deleted key from Redis: ${k}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (deletedCount === keys.length) return deletedCount;
|
if (deletedCount === keys.length) return deletedCount;
|
||||||
@@ -400,7 +400,7 @@ class RegionalAdaptiveCache {
|
|||||||
const count = regionalLocalCache.del(k);
|
const count = regionalLocalCache.del(k);
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
deletedCount++;
|
deletedCount++;
|
||||||
// logger.debug(`[regional] Deleted key from local cache: ${k}`);
|
logger.debug(`[regional] Deleted key from local cache: ${k}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return deletedCount;
|
return deletedCount;
|
||||||
|
|||||||
@@ -1,298 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of a proprietary work.
|
|
||||||
*
|
|
||||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
|
||||||
* All rights reserved.
|
|
||||||
*
|
|
||||||
* This file is licensed under the Fossorial Commercial License.
|
|
||||||
* You may not use this file except in compliance with the License.
|
|
||||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
|
||||||
*
|
|
||||||
* This file is not licensed under the AGPLv3.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import * as acme from "acme-client";
|
|
||||||
import * as fs from "fs";
|
|
||||||
import { eq } from "drizzle-orm/sql";
|
|
||||||
import { privateConfig as config } from "#private/lib/config";
|
|
||||||
import { DnsChallenge, db, dnsChallenge } from "@server/db";
|
|
||||||
import { withRetry } from "@server/lib/retry";
|
|
||||||
import logger from "@server/logger";
|
|
||||||
import { acmeRateLimiter } from "./acmeRateLimiter";
|
|
||||||
|
|
||||||
// acme-client's own retry/backoff logging (429 retries, 5xx retries, each
|
|
||||||
// status-poll tick in waitForValidStatus) is a no-op by default - it only
|
|
||||||
// activates via DEBUG=acme-client or this call, neither of which was wired
|
|
||||||
// up. Without it, a cert silently retrying a Let's Encrypt rate limit for
|
|
||||||
// several minutes is indistinguishable in our logs from one that's actually
|
|
||||||
// hung, since our own logging only wraps the call, not what happens inside
|
|
||||||
// it. Must run before any AcmeClient method is called.
|
|
||||||
acme.setLogger((msg: string) => logger.info(`[acme-client] ${msg}`));
|
|
||||||
|
|
||||||
// acme-client's axios retry wrapper treats any response-less request error
|
|
||||||
// (timeout, connection reset, DNS blip reaching the ACME server) as
|
|
||||||
// retryable, but once its internal retries are exhausted it falls through to
|
|
||||||
// `validateStatus(response)` with `response` still undefined, throwing this
|
|
||||||
// uninformative TypeError instead of the real network error.
|
|
||||||
// https://github.com/publishlab/node-acme-client/blob/master/src/axios.js
|
|
||||||
function isUnresponsiveAcmeError(error: unknown): boolean {
|
|
||||||
return (
|
|
||||||
error instanceof TypeError &&
|
|
||||||
error.message ===
|
|
||||||
"Cannot read properties of undefined (reading 'config')"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeAcmeError(error: unknown): Error {
|
|
||||||
if (isUnresponsiveAcmeError(error)) {
|
|
||||||
return new Error(
|
|
||||||
"ACME server did not respond after repeated attempts (network error reaching the ACME endpoint)",
|
|
||||||
{ cause: error }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return error instanceof Error ? error : new Error(String(error));
|
|
||||||
}
|
|
||||||
|
|
||||||
export class AcmeClientManager {
|
|
||||||
private client: acme.Client | null = null;
|
|
||||||
private accountKey: string | null = null;
|
|
||||||
|
|
||||||
async initialize() {
|
|
||||||
try {
|
|
||||||
this.accountKey = await this.loadAccountKey();
|
|
||||||
|
|
||||||
this.client = new acme.Client({
|
|
||||||
directoryUrl: config.getRawConfig().acme!.acme_directory_url,
|
|
||||||
accountKey: this.accountKey
|
|
||||||
});
|
|
||||||
|
|
||||||
// Try to create account or get existing one
|
|
||||||
await this.client.createAccount({
|
|
||||||
termsOfServiceAgreed: true,
|
|
||||||
contact: [`mailto:${config.getRawConfig().acme!.contact_email}`]
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info("ACME client initialized successfully");
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Failed to initialize ACME client:", error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async loadAccountKey(): Promise<string> {
|
|
||||||
const keyPath = config.getRawConfig().acme!.acme_account_key_path;
|
|
||||||
|
|
||||||
if (fs.existsSync(keyPath)) {
|
|
||||||
logger.info("Loading existing account key");
|
|
||||||
return fs.readFileSync(keyPath, "utf8");
|
|
||||||
} else {
|
|
||||||
logger.info("Generating new account key");
|
|
||||||
const privateKey = await acme.crypto.createPrivateKey();
|
|
||||||
const privateKeyString = privateKey.toString();
|
|
||||||
fs.writeFileSync(keyPath, privateKeyString);
|
|
||||||
return privateKeyString;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getClient(): acme.Client {
|
|
||||||
if (!this.client) {
|
|
||||||
throw new Error("ACME client not initialized");
|
|
||||||
}
|
|
||||||
return this.client;
|
|
||||||
}
|
|
||||||
|
|
||||||
async createOrder(domain: string, wildcard: boolean = false): Promise<any> {
|
|
||||||
const client = this.getClient();
|
|
||||||
|
|
||||||
const identifiers = wildcard
|
|
||||||
? [
|
|
||||||
{ type: "dns", value: domain },
|
|
||||||
{ type: "dns", value: `*.${domain}` }
|
|
||||||
]
|
|
||||||
: [{ type: "dns", value: domain }];
|
|
||||||
|
|
||||||
await acmeRateLimiter.acquire();
|
|
||||||
const order = await client.createOrder({
|
|
||||||
identifiers
|
|
||||||
});
|
|
||||||
|
|
||||||
if (wildcard) {
|
|
||||||
logger.info(`Created wildcard order for domain: ${domain}`);
|
|
||||||
} else {
|
|
||||||
logger.info(`Created order for domain: ${domain}`);
|
|
||||||
}
|
|
||||||
return order;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getAuthorizations(order: any): Promise<any[]> {
|
|
||||||
const client = this.getClient();
|
|
||||||
await acmeRateLimiter.acquire();
|
|
||||||
return client.getAuthorizations(order);
|
|
||||||
}
|
|
||||||
|
|
||||||
async handleDnsChallenge(
|
|
||||||
dnsChallenges: {
|
|
||||||
authz: any;
|
|
||||||
challenge: any;
|
|
||||||
}[]
|
|
||||||
): Promise<void> {
|
|
||||||
const client = this.getClient();
|
|
||||||
|
|
||||||
let challengeDomains: DnsChallenge[] = [];
|
|
||||||
|
|
||||||
for (const { authz, challenge } of dnsChallenges) {
|
|
||||||
const keyAuthorization =
|
|
||||||
await client.getChallengeKeyAuthorization(challenge);
|
|
||||||
|
|
||||||
// Extract the domain from authorization
|
|
||||||
const domain = authz.identifier.value;
|
|
||||||
|
|
||||||
// Store challenge in database for DNS server to pick up
|
|
||||||
challengeDomains = await withRetry(
|
|
||||||
() =>
|
|
||||||
db
|
|
||||||
.insert(dnsChallenge)
|
|
||||||
.values({
|
|
||||||
domain: domain,
|
|
||||||
token: challenge.token,
|
|
||||||
keyAuthorization,
|
|
||||||
createdAt: Math.floor(Date.now() / 1000),
|
|
||||||
expiresAt: Math.floor(
|
|
||||||
(Date.now() +
|
|
||||||
config.getRawConfig().acme!
|
|
||||||
.challenge_ttl_ms) /
|
|
||||||
1000
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.returning(),
|
|
||||||
{ label: `insert dnsChallenge for domain ${domain}` }
|
|
||||||
);
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`DNS challenge stored for domain: ${domain} as token ${challenge.token} and keyAuthorization`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
||||||
|
|
||||||
const failedDomains: string[] = [];
|
|
||||||
|
|
||||||
for (const { authz, challenge } of dnsChallenges) {
|
|
||||||
const domain = authz.identifier.value;
|
|
||||||
const challengeDomain = `_acme-challenge.${domain}`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// The ACME server occasionally has a transient network blip
|
|
||||||
// mid-sequence; retry the whole verify/complete/wait sequence
|
|
||||||
// rather than just the DNS challenge propagation wait, since
|
|
||||||
// these calls are safe to repeat against the ACME server.
|
|
||||||
await withRetry(
|
|
||||||
async () => {
|
|
||||||
// Verify challenge
|
|
||||||
await acmeRateLimiter.acquire();
|
|
||||||
await client.verifyChallenge(authz, challenge);
|
|
||||||
|
|
||||||
// Complete challenge
|
|
||||||
logger.info(
|
|
||||||
`Completing challenge for domain: ${challengeDomain}`
|
|
||||||
);
|
|
||||||
await acmeRateLimiter.acquire();
|
|
||||||
await client.completeChallenge(challenge);
|
|
||||||
|
|
||||||
// Wait for validation
|
|
||||||
logger.info(
|
|
||||||
`Waiting for challenge to be validated for domain: ${challengeDomain}...`
|
|
||||||
);
|
|
||||||
await acmeRateLimiter.acquire();
|
|
||||||
await client.waitForValidStatus(challenge);
|
|
||||||
},
|
|
||||||
{
|
|
||||||
retries: 2,
|
|
||||||
baseDelayMs: 5000,
|
|
||||||
label: `ACME challenge completion for domain ${domain}`,
|
|
||||||
// Only retry the known network-blip crash - a
|
|
||||||
// genuine validation failure (e.g. challenge marked
|
|
||||||
// "invalid" because the DNS record wasn't found) is
|
|
||||||
// permanent and should fail immediately instead of
|
|
||||||
// burning Let's Encrypt's per-hostname failed-
|
|
||||||
// validation rate limit on retries that can't help.
|
|
||||||
shouldRetry: isUnresponsiveAcmeError
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
logger.info(`Challenge completed for domain: ${domain}`);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(
|
|
||||||
`Failed to complete challenge for domain ${domain}:`,
|
|
||||||
normalizeAcmeError(error)
|
|
||||||
);
|
|
||||||
failedDomains.push(domain);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const challengeDomain of challengeDomains) {
|
|
||||||
await this.removeDnsChallenge(challengeDomain.dnsChallengeId);
|
|
||||||
logger.info(
|
|
||||||
`Removed DNS challenge for domain: ${challengeDomain.domain}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A failed dns-01 challenge leaves the order stuck in "pending" -
|
|
||||||
// finalizing it would just fail with a confusing ACME error, so
|
|
||||||
// stop here and let the caller mark the certificate as failed.
|
|
||||||
if (failedDomains.length > 0) {
|
|
||||||
throw new Error(
|
|
||||||
`DNS-01 challenge validation failed for domain(s): ${failedDomains.join(", ")}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async removeDnsChallenge(dnsChallengeId: number): Promise<void> {
|
|
||||||
try {
|
|
||||||
await withRetry(
|
|
||||||
() =>
|
|
||||||
db
|
|
||||||
.delete(dnsChallenge)
|
|
||||||
.where(eq(dnsChallenge.dnsChallengeId, dnsChallengeId)),
|
|
||||||
{ label: `delete dnsChallenge ${dnsChallengeId}` }
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(
|
|
||||||
`Failed to clean up DNS challenge for id ${dnsChallengeId}:`,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async finalizeCertificate(
|
|
||||||
order: any,
|
|
||||||
domain: string,
|
|
||||||
wildcard: boolean = false
|
|
||||||
): Promise<{ certificate: string; privateKey: string }> {
|
|
||||||
const client = this.getClient();
|
|
||||||
|
|
||||||
const altNames = wildcard ? [`*.${domain}`, domain] : [domain];
|
|
||||||
|
|
||||||
// Create CSR
|
|
||||||
const [privateKey, csr] = await acme.crypto.createCsr({
|
|
||||||
altNames
|
|
||||||
});
|
|
||||||
|
|
||||||
// Finalize order
|
|
||||||
await acmeRateLimiter.acquire();
|
|
||||||
const finalizedOrder = await client.finalizeOrder(order, csr);
|
|
||||||
|
|
||||||
// Get certificate
|
|
||||||
await acmeRateLimiter.acquire();
|
|
||||||
const certificate = await client.getCertificate(finalizedOrder);
|
|
||||||
|
|
||||||
logger.info(`Certificate obtained for domain: ${domain}`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
certificate: certificate.toString(),
|
|
||||||
privateKey: privateKey.toString()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const acmeClientManager = new AcmeClientManager();
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of a proprietary work.
|
|
||||||
*
|
|
||||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
|
||||||
* All rights reserved.
|
|
||||||
*
|
|
||||||
* This file is licensed under the Fossorial Commercial License.
|
|
||||||
* You may not use this file except in compliance with the License.
|
|
||||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
|
||||||
*
|
|
||||||
* This file is not licensed under the AGPLv3.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { privateConfig as config } from "#private/lib/config";
|
|
||||||
import logger from "@server/logger";
|
|
||||||
import { redis } from "../redis";
|
|
||||||
// Caps outgoing ACME API calls to a fixed budget per wall-clock second,
|
|
||||||
// shared across all pops workers via Redis (mirrors the lockManager pattern
|
|
||||||
// in @lib/lock) - a per-process limiter wouldn't be enough since multiple
|
|
||||||
// workers issue certificates against the same Let's Encrypt account.
|
|
||||||
const ACQUIRE_SCRIPT = `
|
|
||||||
local key = KEYS[1]
|
|
||||||
local limit = tonumber(ARGV[1])
|
|
||||||
local current = redis.call('INCR', key)
|
|
||||||
if current == 1 then
|
|
||||||
redis.call('PEXPIRE', key, 2000)
|
|
||||||
end
|
|
||||||
if current > limit then
|
|
||||||
return 0
|
|
||||||
else
|
|
||||||
return 1
|
|
||||||
end
|
|
||||||
`;
|
|
||||||
|
|
||||||
class AcmeRateLimiter {
|
|
||||||
async acquire(): Promise<void> {
|
|
||||||
const limit =
|
|
||||||
config.getRawConfig().acme?.acme_requests_per_second ?? 15;
|
|
||||||
|
|
||||||
for (;;) {
|
|
||||||
const bucket = Math.floor(Date.now() / 1000);
|
|
||||||
const key = `acme_rate_limit:${bucket}`;
|
|
||||||
|
|
||||||
let allowed: number;
|
|
||||||
try {
|
|
||||||
allowed = (await redis.eval(
|
|
||||||
ACQUIRE_SCRIPT,
|
|
||||||
1,
|
|
||||||
key,
|
|
||||||
limit.toString()
|
|
||||||
)) as number;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(
|
|
||||||
"ACME rate limiter check failed, proceeding without throttling:",
|
|
||||||
error
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (allowed === 1) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Budget for this second is spent - wait for the next window.
|
|
||||||
const waitMs = 1000 - (Date.now() % 1000) + 10;
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const acmeRateLimiter = new AcmeRateLimiter();
|
|
||||||
@@ -1,511 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of a proprietary work.
|
|
||||||
*
|
|
||||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
|
||||||
* All rights reserved.
|
|
||||||
*
|
|
||||||
* This file is licensed under the Fossorial Commercial License.
|
|
||||||
* You may not use this file except in compliance with the License.
|
|
||||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
|
||||||
*
|
|
||||||
* This file is not licensed under the AGPLv3.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { acmeClientManager } from "./acme-client";
|
|
||||||
import { dnsValidator } from "./dns-validator";
|
|
||||||
import { getTableColumns } from "drizzle-orm";
|
|
||||||
import { eq, and, or, isNull, lt, asc } from "drizzle-orm/sql";
|
|
||||||
import { config } from "@server/lib/config";
|
|
||||||
import { db, certificates, domains, Certificate } from "@server/db";
|
|
||||||
import { encrypt } from "@server/lib/crypto";
|
|
||||||
import { withTimeout, withRetry } from "@server/lib/retry";
|
|
||||||
import logger from "@server/logger";
|
|
||||||
import { lockManager } from "../lock";
|
|
||||||
import { pushCertUpdateToAffectedNewts } from "@server/lib/acmeCertSync";
|
|
||||||
import crypto from "crypto";
|
|
||||||
|
|
||||||
// Number of on-demand DNS validation attempts made right before a
|
|
||||||
// certificate is (re)issued, to avoid burning Let's Encrypt rate limits on
|
|
||||||
// domains whose DNS has drifted since they were last verified.
|
|
||||||
const PRE_CERT_DNS_VALIDATION_ATTEMPTS = 3;
|
|
||||||
|
|
||||||
// Hard ceiling on a single certificate's issuance/renewal flow. acme-client's
|
|
||||||
// axios instance never sets a request timeout, so a stalled connection to
|
|
||||||
// the ACME server hangs forever instead of erroring - and since
|
|
||||||
// processPendingCertificates/processRenewalCandidates gate the *next* batch
|
|
||||||
// on Promise.all(...) over the current one, one hung certificate would
|
|
||||||
// otherwise stall every other domain permanently. Sized generously above the
|
|
||||||
// legitimate worst case (acme-client's own bounded backoff is ~3.6min per
|
|
||||||
// status-polling loop, and a wildcard cert's two identifiers plus order
|
|
||||||
// finalization can chain a few of those) so this only fires on a genuine hang.
|
|
||||||
const CERTIFICATE_ISSUANCE_TIMEOUT_MS = 20 * 60 * 1000;
|
|
||||||
|
|
||||||
// "requested" is set the instant a cert starts processing and is never
|
|
||||||
// queried anywhere else - processPendingCertificates only selects "pending"
|
|
||||||
// and processRenewalCandidates only selects "valid". So if the *process*
|
|
||||||
// dies mid-flight (OOM, node eviction, a rolling deploy) rather than just
|
|
||||||
// hanging, the row is orphaned in "requested" permanently with nothing to
|
|
||||||
// ever pick it back up, no matter how good the in-process timeouts are.
|
|
||||||
// Threshold is set comfortably above CERTIFICATE_ISSUANCE_TIMEOUT_MS plus the
|
|
||||||
// scheduler's own outer backstop so this never reclaims a cert that's still
|
|
||||||
// genuinely being worked on.
|
|
||||||
const STUCK_CERTIFICATE_THRESHOLD_MS = 40 * 60 * 1000;
|
|
||||||
|
|
||||||
export class CertificateService {
|
|
||||||
// Runs at the top of every processPendingCertificates tick so an
|
|
||||||
// interrupted worker's leftovers always get put back in the queue
|
|
||||||
// instead of sitting invisible to every query forever.
|
|
||||||
private async reclaimStuckCertificates(): Promise<void> {
|
|
||||||
const staleBefore =
|
|
||||||
Math.floor(Date.now() / 1000) -
|
|
||||||
Math.floor(STUCK_CERTIFICATE_THRESHOLD_MS / 1000);
|
|
||||||
|
|
||||||
const reclaimed = await db
|
|
||||||
.update(certificates)
|
|
||||||
.set({
|
|
||||||
status: "pending",
|
|
||||||
errorMessage:
|
|
||||||
'Reclaimed after being stuck in "requested" state - the worker processing it likely restarted or crashed',
|
|
||||||
updatedAt: Math.floor(Date.now() / 1000)
|
|
||||||
})
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(certificates.status, "requested"),
|
|
||||||
lt(certificates.updatedAt, staleBefore)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.returning({ domain: certificates.domain });
|
|
||||||
|
|
||||||
if (reclaimed.length > 0) {
|
|
||||||
logger.warn(
|
|
||||||
`Reclaimed ${reclaimed.length} certificate(s) stuck in "requested" state: ${reclaimed
|
|
||||||
.map((c) => c.domain)
|
|
||||||
.join(", ")}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async processPendingCertificates(): Promise<void> {
|
|
||||||
logger.debug("Checking for pending certificates...");
|
|
||||||
|
|
||||||
await this.reclaimStuckCertificates();
|
|
||||||
|
|
||||||
const pendingCerts = await db
|
|
||||||
.select(getTableColumns(certificates))
|
|
||||||
.from(certificates)
|
|
||||||
.leftJoin(domains, eq(certificates.domainId, domains.domainId))
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(certificates.status, "pending"),
|
|
||||||
or(
|
|
||||||
// Certs with no linked domain row (e.g. legacy certs
|
|
||||||
// imported from acme.json) aren't gated on domain
|
|
||||||
// verification since there's nothing to check.
|
|
||||||
isNull(certificates.domainId),
|
|
||||||
and(
|
|
||||||
eq(domains.verified, true),
|
|
||||||
eq(domains.failed, false)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(10);
|
|
||||||
|
|
||||||
if (pendingCerts.length === 0) {
|
|
||||||
logger.debug("No pending certificates found");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Found ${pendingCerts.length} pending certificates`);
|
|
||||||
|
|
||||||
// Process the batch concurrently so one domain stuck retrying a slow
|
|
||||||
// DNS-01 challenge (the ACME client's waitForValidStatus can spend
|
|
||||||
// minutes on a bad domain) doesn't stall the rest of the batch.
|
|
||||||
// processSingleCertificate catches its own errors and each cert uses
|
|
||||||
// an independent per-domain lock, so this is safe to parallelize.
|
|
||||||
await Promise.all(
|
|
||||||
pendingCerts.map((cert) => this.processSingleCertificate(cert))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async processRenewalCandidates(): Promise<void> {
|
|
||||||
logger.debug("Checking for certificates needing renewal...");
|
|
||||||
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
|
|
||||||
const renewalCandidates = await db
|
|
||||||
.select(getTableColumns(certificates))
|
|
||||||
.from(certificates)
|
|
||||||
.leftJoin(domains, eq(certificates.domainId, domains.domainId))
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(certificates.status, "valid"),
|
|
||||||
lt(certificates.expiresAt, now + 15 * 24 * 60 * 60), // 15 days from now
|
|
||||||
or(
|
|
||||||
// Certs with no linked domain row (e.g. legacy certs
|
|
||||||
// imported from acme.json) aren't gated on domain
|
|
||||||
// verification since there's nothing to check.
|
|
||||||
isNull(certificates.domainId),
|
|
||||||
and(
|
|
||||||
eq(domains.verified, true),
|
|
||||||
eq(domains.failed, false)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
// Most urgent first, so already-expired certs aren't starved
|
|
||||||
// behind the limit by certs that still have weeks of runway.
|
|
||||||
.orderBy(asc(certificates.expiresAt))
|
|
||||||
.limit(50);
|
|
||||||
|
|
||||||
if (renewalCandidates.length === 0) {
|
|
||||||
logger.debug("No certificates need renewal");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`Found ${renewalCandidates.length} certificates needing renewal`
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const cert of renewalCandidates) {
|
|
||||||
if (cert.expiresAt !== null && cert.expiresAt < now) {
|
|
||||||
logger.warn(
|
|
||||||
`Certificate for ${cert.domain} is marked "valid" but already expired at ${new Date(cert.expiresAt * 1000).toISOString()} (bad state) - renewing immediately`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process the batch concurrently - see processPendingCertificates for why.
|
|
||||||
await Promise.all(
|
|
||||||
renewalCandidates.map((cert) => this.renewCertificate(cert))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async processSingleCertificate(cert: Certificate): Promise<void> {
|
|
||||||
const lockKey = `cert:${cert.domain}`;
|
|
||||||
|
|
||||||
const lockToken = await lockManager.acquireLock(lockKey);
|
|
||||||
if (!lockToken) {
|
|
||||||
logger.debug(
|
|
||||||
`Could not acquire lock for certificate: ${cert.domain}`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
logger.info(`Processing certificate for domain: ${cert.domain}`);
|
|
||||||
|
|
||||||
// Update status to processing
|
|
||||||
await db
|
|
||||||
.update(certificates)
|
|
||||||
.set({
|
|
||||||
status: "requested",
|
|
||||||
updatedAt: Math.floor(Date.now() / 1000)
|
|
||||||
})
|
|
||||||
.where(eq(certificates.certId, cert.certId));
|
|
||||||
//
|
|
||||||
|
|
||||||
await withTimeout(
|
|
||||||
this.obtainCertificate(cert),
|
|
||||||
CERTIFICATE_ISSUANCE_TIMEOUT_MS,
|
|
||||||
`certificate issuance for ${cert.domain}`
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(
|
|
||||||
`Failed to process certificate for ${cert.domain}:`,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
|
|
||||||
await db
|
|
||||||
.update(certificates)
|
|
||||||
.set({
|
|
||||||
status: "failed",
|
|
||||||
errorMessage:
|
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "Unknown error",
|
|
||||||
updatedAt: Math.floor(Date.now() / 1000)
|
|
||||||
})
|
|
||||||
.where(eq(certificates.certId, cert.certId));
|
|
||||||
} finally {
|
|
||||||
await lockManager.releaseLock(lockKey, lockToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async renewCertificate(cert: Certificate): Promise<void> {
|
|
||||||
const lockKey = `cert:${cert.domain}`;
|
|
||||||
|
|
||||||
const lockToken = await lockManager.acquireLock(lockKey);
|
|
||||||
if (!lockToken) {
|
|
||||||
logger.debug(
|
|
||||||
`Could not acquire lock for certificate renewal: ${cert.domain}`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
logger.info(`Renewing certificate for domain: ${cert.domain}`);
|
|
||||||
|
|
||||||
// Update last renewal attempt
|
|
||||||
await db
|
|
||||||
.update(certificates)
|
|
||||||
.set({
|
|
||||||
lastRenewalAttempt: Math.floor(Date.now() / 1000),
|
|
||||||
updatedAt: Math.floor(Date.now() / 1000)
|
|
||||||
})
|
|
||||||
.where(eq(certificates.certId, cert.certId));
|
|
||||||
|
|
||||||
await withTimeout(
|
|
||||||
this.obtainCertificate(cert),
|
|
||||||
CERTIFICATE_ISSUANCE_TIMEOUT_MS,
|
|
||||||
`certificate renewal for ${cert.domain}`
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(
|
|
||||||
`Failed to renew certificate for ${cert.domain}:`,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
|
|
||||||
await db
|
|
||||||
.update(certificates)
|
|
||||||
.set({
|
|
||||||
status: "failed",
|
|
||||||
errorMessage:
|
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "Unknown error",
|
|
||||||
lastRenewalAttempt: Math.floor(Date.now() / 1000),
|
|
||||||
updatedAt: Math.floor(Date.now() / 1000)
|
|
||||||
})
|
|
||||||
.where(eq(certificates.certId, cert.certId));
|
|
||||||
} finally {
|
|
||||||
await lockManager.releaseLock(lockKey, lockToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-checks the domain's DNS records right before we spend a Let's
|
|
||||||
// Encrypt order on it, so drift that happened after the domain was
|
|
||||||
// originally verified doesn't burn ACME rate limits. Certs with no
|
|
||||||
// linked domain row (e.g. legacy/manually-managed certs) skip this and
|
|
||||||
// proceed as before, since there are no tracked DNS records to check.
|
|
||||||
private async verifyDomainBeforeIssuance(cert: Certificate): Promise<void> {
|
|
||||||
if (!cert.domainId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [domain] = await db
|
|
||||||
.select()
|
|
||||||
.from(domains)
|
|
||||||
.where(eq(domains.domainId, cert.domainId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!domain) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (
|
|
||||||
let attempt = 1;
|
|
||||||
attempt <= PRE_CERT_DNS_VALIDATION_ATTEMPTS;
|
|
||||||
attempt++
|
|
||||||
) {
|
|
||||||
// Offset `tries` so each attempt round-robins to a different
|
|
||||||
// privateConfigured DNS resolver instead of re-querying the same one.
|
|
||||||
const probe = { ...domain, tries: domain.tries + attempt - 1 };
|
|
||||||
if (
|
|
||||||
await dnsValidator.validateDomain(probe, {
|
|
||||||
forceRecheck: true
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
await db
|
|
||||||
.update(domains)
|
|
||||||
.set({ verified: true, failed: false, errorMessage: null })
|
|
||||||
.where(eq(domains.domainId, domain.domainId));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.warn(
|
|
||||||
`Pre-certificate DNS check ${attempt}/${PRE_CERT_DNS_VALIDATION_ATTEMPTS} failed for domain ${domain.baseDomain} (cert: ${cert.domain})`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const errorMessage = `Domain failed DNS validation ${PRE_CERT_DNS_VALIDATION_ATTEMPTS} times before certificate issuance`;
|
|
||||||
await db
|
|
||||||
.update(domains)
|
|
||||||
.set({ verified: false, failed: true, errorMessage })
|
|
||||||
.where(eq(domains.domainId, domain.domainId));
|
|
||||||
|
|
||||||
throw new Error(errorMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async obtainCertificate(cert: Certificate): Promise<void> {
|
|
||||||
await this.verifyDomainBeforeIssuance(cert);
|
|
||||||
|
|
||||||
// Create order
|
|
||||||
const order = await acmeClientManager.createOrder(
|
|
||||||
cert.domain,
|
|
||||||
cert.wildcard || false
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update with order ID
|
|
||||||
await withRetry(
|
|
||||||
() =>
|
|
||||||
db
|
|
||||||
.update(certificates)
|
|
||||||
.set({
|
|
||||||
orderId: order.url,
|
|
||||||
updatedAt: Math.floor(Date.now() / 1000)
|
|
||||||
})
|
|
||||||
.where(eq(certificates.certId, cert.certId)),
|
|
||||||
{ label: `update orderId for certificate ${cert.domain}` }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Get authorizations
|
|
||||||
const authorizations = await acmeClientManager.getAuthorizations(order);
|
|
||||||
|
|
||||||
// Aggregate all DNS-01 challenges
|
|
||||||
const dnsChallenges = authorizations.map((authz: any) => {
|
|
||||||
const dnsChallenge = authz.challenges.find(
|
|
||||||
(c: any) => c.type === "dns-01"
|
|
||||||
);
|
|
||||||
if (!dnsChallenge) {
|
|
||||||
throw new Error(
|
|
||||||
`No DNS-01 challenge found for ${authz.identifier.value}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
authz,
|
|
||||||
challenge: dnsChallenge
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send all DNS-01 challenges in one request to handleDnsChallenge
|
|
||||||
await acmeClientManager.handleDnsChallenge(dnsChallenges);
|
|
||||||
|
|
||||||
// Finalize certificate
|
|
||||||
const { certificate, privateKey } =
|
|
||||||
await acmeClientManager.finalizeCertificate(
|
|
||||||
order,
|
|
||||||
cert.domain,
|
|
||||||
cert.wildcard || false
|
|
||||||
);
|
|
||||||
|
|
||||||
const encryptionKey = config.getRawConfig().server.secret;
|
|
||||||
if (!encryptionKey) {
|
|
||||||
throw new Error("Encryption key not provided");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Encrypt certificate and private key
|
|
||||||
const encryptedCert = encrypt(certificate, encryptionKey);
|
|
||||||
const encryptedKey = encrypt(privateKey, encryptionKey);
|
|
||||||
|
|
||||||
// Parse certificate to get expiration date
|
|
||||||
const expiresAt = this.extractExpirationDate(certificate);
|
|
||||||
|
|
||||||
// Update database record. This persists the certificate we just
|
|
||||||
// obtained from the ACME server, so it's retried aggressively -
|
|
||||||
// losing this write means re-issuing the cert from scratch.
|
|
||||||
await withRetry(
|
|
||||||
() =>
|
|
||||||
db
|
|
||||||
.update(certificates)
|
|
||||||
.set({
|
|
||||||
status: "valid",
|
|
||||||
expiresAt: Math.floor(expiresAt.getTime() / 1000),
|
|
||||||
renewalCount: (cert.renewalCount || 0) + 1,
|
|
||||||
errorMessage: null,
|
|
||||||
updatedAt: Math.floor(Date.now() / 1000),
|
|
||||||
certFile: encryptedCert,
|
|
||||||
keyFile: encryptedKey
|
|
||||||
})
|
|
||||||
.where(eq(certificates.certId, cert.certId)),
|
|
||||||
{
|
|
||||||
retries: 5,
|
|
||||||
label: `persist issued certificate for ${cert.domain}`
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`Certificate successfully obtained/renewed for domain: ${cert.domain}`
|
|
||||||
);
|
|
||||||
|
|
||||||
await pushCertUpdateToAffectedNewts(
|
|
||||||
cert.domain,
|
|
||||||
cert.domainId ?? null,
|
|
||||||
certificate,
|
|
||||||
privateKey
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private extractExpirationDate(certificate: string): Date {
|
|
||||||
try {
|
|
||||||
// Extract the certificate block
|
|
||||||
const pem = certificate
|
|
||||||
.replace(/-----BEGIN CERTIFICATE-----/g, "")
|
|
||||||
.replace(/-----END CERTIFICATE-----/g, "")
|
|
||||||
.replace(/\s+/g, "");
|
|
||||||
const der = Buffer.from(pem, "base64");
|
|
||||||
|
|
||||||
// Use Node.js crypto to parse the certificate
|
|
||||||
const x509 = new crypto.X509Certificate(der);
|
|
||||||
return new Date(x509.validTo);
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn(
|
|
||||||
"Failed to parse certificate expiration date, using default",
|
|
||||||
error
|
|
||||||
);
|
|
||||||
// Default to 90 days from now (Let's Encrypt default)
|
|
||||||
return new Date(Date.now() + 90 * 24 * 60 * 60 * 1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async addCertificateRequest(domain: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
await db.insert(certificates).values({
|
|
||||||
domain,
|
|
||||||
status: "pending",
|
|
||||||
createdAt: Math.floor(Date.now() / 1000),
|
|
||||||
updatedAt: Math.floor(Date.now() / 1000)
|
|
||||||
});
|
|
||||||
logger.info(`Certificate request added for domain: ${domain}`);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error && error.message.includes("unique")) {
|
|
||||||
logger.warn(
|
|
||||||
`Certificate request already exists for domain: ${domain}`
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async getCertificateStatus(domain: string) {
|
|
||||||
const cert = await db
|
|
||||||
.select()
|
|
||||||
.from(certificates)
|
|
||||||
.where(eq(certificates.domain, domain))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
return cert[0] || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async cleanupExpiredChallenges(): Promise<void> {
|
|
||||||
try {
|
|
||||||
const result = await db
|
|
||||||
.delete(certificates)
|
|
||||||
.where(
|
|
||||||
lt(certificates.expiresAt, Math.floor(Date.now() / 1000))
|
|
||||||
)
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
if (result.length > 0) {
|
|
||||||
logger.info(
|
|
||||||
`Cleaned up ${result.length} expired DNS challenges`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Failed to cleanup expired challenges:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const certificateService = new CertificateService();
|
|
||||||
@@ -1,334 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of a proprietary work.
|
|
||||||
*
|
|
||||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
|
||||||
* All rights reserved.
|
|
||||||
*
|
|
||||||
* This file is licensed under the Fossorial Commercial License.
|
|
||||||
* You may not use this file except in compliance with the License.
|
|
||||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
|
||||||
*
|
|
||||||
* This file is not licensed under the AGPLv3.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { eq, and, lt } from "drizzle-orm";
|
|
||||||
import * as dns from "dns/promises";
|
|
||||||
import { privateConfig as config } from "#private/lib/config";
|
|
||||||
import { db, domains, DnsRecord, dnsRecords, Domain } from "@server/db";
|
|
||||||
import logger from "@server/logger";
|
|
||||||
import { lockManager } from "../lock";
|
|
||||||
|
|
||||||
export const DNS_VALIDATOR_MAX_TRIES = 300;
|
|
||||||
|
|
||||||
export class DNSValidator {
|
|
||||||
private static readonly MAX_TRIES = DNS_VALIDATOR_MAX_TRIES;
|
|
||||||
|
|
||||||
constructor() {}
|
|
||||||
|
|
||||||
async validateAll(): Promise<void> {
|
|
||||||
// Get all domains that are not yet verified and haven't exceeded max tries
|
|
||||||
const unverifiedDomains: Domain[] = await db
|
|
||||||
.select()
|
|
||||||
.from(domains)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(domains.verified, false),
|
|
||||||
lt(domains.tries, DNSValidator.MAX_TRIES)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (unverifiedDomains.length === 0) {
|
|
||||||
logger.debug("No unverified domains found for DNS validation");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Validating ${unverifiedDomains.length} DNS records`);
|
|
||||||
|
|
||||||
for (const domain of unverifiedDomains) {
|
|
||||||
const lockKey = `dns:${domain.baseDomain}`;
|
|
||||||
const lockToken = await lockManager.acquireLock(lockKey);
|
|
||||||
if (!lockToken) {
|
|
||||||
logger.debug(
|
|
||||||
`Could not acquire lock for DNS validation: ${domain.baseDomain}`
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const isValid = await this.validateDomain(domain);
|
|
||||||
if (isValid) {
|
|
||||||
await db
|
|
||||||
.update(domains)
|
|
||||||
.set({
|
|
||||||
verified: true,
|
|
||||||
failed: false,
|
|
||||||
tries: 0,
|
|
||||||
errorMessage: null
|
|
||||||
})
|
|
||||||
.where(eq(domains.domainId, domain.domainId));
|
|
||||||
logger.info(
|
|
||||||
`Domain ${domain.baseDomain} validated successfully`
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const newTries = domain.tries + 1;
|
|
||||||
const shouldMarkAsFailed =
|
|
||||||
newTries >= DNSValidator.MAX_TRIES;
|
|
||||||
|
|
||||||
await db
|
|
||||||
.update(domains)
|
|
||||||
.set({
|
|
||||||
tries: newTries,
|
|
||||||
failed: shouldMarkAsFailed
|
|
||||||
})
|
|
||||||
.where(eq(domains.domainId, domain.domainId));
|
|
||||||
|
|
||||||
if (shouldMarkAsFailed) {
|
|
||||||
logger.warn(
|
|
||||||
`Domain ${domain.baseDomain} exceeded maximum tries (${DNSValidator.MAX_TRIES}), marking as failed`
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
logger.debug(
|
|
||||||
`Domain ${domain.baseDomain} did not validate (attempt ${newTries}/${DNSValidator.MAX_TRIES})`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn(
|
|
||||||
`Error validating domain ${domain.baseDomain}:`,
|
|
||||||
err
|
|
||||||
);
|
|
||||||
// Increment tries even on error
|
|
||||||
const newTries = domain.tries + 1;
|
|
||||||
const shouldMarkAsFailed = newTries >= DNSValidator.MAX_TRIES;
|
|
||||||
|
|
||||||
await db
|
|
||||||
.update(domains)
|
|
||||||
.set({
|
|
||||||
tries: newTries,
|
|
||||||
failed: shouldMarkAsFailed
|
|
||||||
})
|
|
||||||
.where(eq(domains.domainId, domain.domainId));
|
|
||||||
} finally {
|
|
||||||
await lockManager.releaseLock(lockKey, lockToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async validateDomain(
|
|
||||||
domain: Domain,
|
|
||||||
opts: { forceRecheck?: boolean } = {}
|
|
||||||
): Promise<boolean> {
|
|
||||||
const { forceRecheck = false } = opts;
|
|
||||||
const resolver = new dns.Resolver();
|
|
||||||
const servers = config.getRawConfig().acme?.dns_resolvers;
|
|
||||||
if (!servers || servers.length === 0) {
|
|
||||||
throw new Error("No DNS resolvers configured");
|
|
||||||
}
|
|
||||||
const dnsServer = servers[domain.tries % servers.length]!;
|
|
||||||
resolver.setServers([dnsServer]);
|
|
||||||
logger.debug(
|
|
||||||
`Using DNS server ${dnsServer} for domain ${domain.baseDomain} (try ${domain.tries})`
|
|
||||||
);
|
|
||||||
|
|
||||||
// Get all DNS records for this domain
|
|
||||||
const records: DnsRecord[] = await db
|
|
||||||
.select()
|
|
||||||
.from(dnsRecords)
|
|
||||||
.where(eq(dnsRecords.domainId, domain.domainId));
|
|
||||||
|
|
||||||
if (records.length === 0) {
|
|
||||||
logger.warn(`No DNS records found for domain ${domain.baseDomain}`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!forceRecheck && records.every((r) => r.verified)) {
|
|
||||||
logger.info(
|
|
||||||
`All DNS records already verified for domain ${domain.baseDomain}`
|
|
||||||
);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`Validating ${records.length} DNS records for domain ${domain.baseDomain}`
|
|
||||||
);
|
|
||||||
|
|
||||||
// Collect the full set of expected NS values for this domain so we can
|
|
||||||
// detect extra records that are present in DNS but not in our DB.
|
|
||||||
const expectedNsValues = new Set<string>(
|
|
||||||
records.filter((r) => r.recordType === "NS").map((r) => r.value)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Cache resolved NS records across iterations — there will be 3 NS
|
|
||||||
// records in the DB and we don't need to hit the upstream server 3 times.
|
|
||||||
let previousNs: string[] | null = null;
|
|
||||||
|
|
||||||
for (const record of records) {
|
|
||||||
// Skip already verified records, unless a live recheck was requested
|
|
||||||
if (record.verified && !forceRecheck) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let isValid = false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (record.recordType === "NS") {
|
|
||||||
let nsRecords: string[] | null = previousNs;
|
|
||||||
if (!nsRecords) {
|
|
||||||
nsRecords = await resolver.resolveNs(
|
|
||||||
record.baseDomain || domain.baseDomain
|
|
||||||
);
|
|
||||||
}
|
|
||||||
logger.info(
|
|
||||||
`NS records for ${
|
|
||||||
record.baseDomain || domain.baseDomain
|
|
||||||
}:`,
|
|
||||||
nsRecords
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check if this expected NS value is present in the live records.
|
|
||||||
// A stale/legacy expected value (e.g. left over from a
|
|
||||||
// nameserver rebrand) is also accepted as long as the live
|
|
||||||
// records resolve to some other known-valid nameserver —
|
|
||||||
// the specific literal hostname stored per-domain isn't
|
|
||||||
// meaningful once it's a recognized alias.
|
|
||||||
isValid = nsRecords.some((ns) => ns === record.value);
|
|
||||||
|
|
||||||
previousNs = nsRecords;
|
|
||||||
} else if (record.recordType === "CNAME") {
|
|
||||||
const cnameRecords = await resolver.resolveCname(
|
|
||||||
record.baseDomain || domain.baseDomain
|
|
||||||
);
|
|
||||||
logger.info(
|
|
||||||
`CNAME records for ${
|
|
||||||
record.baseDomain || domain.baseDomain
|
|
||||||
}:`,
|
|
||||||
cnameRecords
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check if the CNAME record matches the expected value
|
|
||||||
isValid =
|
|
||||||
cnameRecords.length === 1 &&
|
|
||||||
cnameRecords[0] === record.value;
|
|
||||||
} else if (record.recordType === "TXT") {
|
|
||||||
const txtRecords = await resolver.resolveTxt(
|
|
||||||
record.baseDomain || domain.baseDomain
|
|
||||||
);
|
|
||||||
logger.info(
|
|
||||||
`TXT records for ${
|
|
||||||
record.baseDomain || domain.baseDomain
|
|
||||||
}:`,
|
|
||||||
txtRecords
|
|
||||||
);
|
|
||||||
|
|
||||||
// TXT records come as an array of arrays, flatten and check
|
|
||||||
const flatTxtRecords = txtRecords.flat();
|
|
||||||
isValid = flatTxtRecords.includes(record.value);
|
|
||||||
} else if (record.recordType === "A") {
|
|
||||||
const aRecords = await resolver.resolve4(
|
|
||||||
record.baseDomain || domain.baseDomain
|
|
||||||
);
|
|
||||||
logger.info(
|
|
||||||
`A records for ${
|
|
||||||
record.baseDomain || domain.baseDomain
|
|
||||||
}:`,
|
|
||||||
aRecords
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check if the A record matches the expected value
|
|
||||||
isValid = aRecords.includes(record.value);
|
|
||||||
} else {
|
|
||||||
logger.warn(
|
|
||||||
`Unsupported record type: ${record.recordType}`
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
isValid = false;
|
|
||||||
logger.debug(
|
|
||||||
`Did not resolve ${record.recordType} record for ${
|
|
||||||
record.baseDomain || domain.baseDomain
|
|
||||||
}:`,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the individual record verification status. Runs for
|
|
||||||
// both a mismatched value and a failed/thrown DNS lookup, so a
|
|
||||||
// previously-verified record that stops resolving (e.g. NXDOMAIN
|
|
||||||
// after NS delegation is dropped) gets downgraded instead of
|
|
||||||
// leaving stale `verified: true` state behind.
|
|
||||||
if (isValid) {
|
|
||||||
await db
|
|
||||||
.update(dnsRecords)
|
|
||||||
.set({ verified: true })
|
|
||||||
.where(eq(dnsRecords.id, record.id));
|
|
||||||
logger.info(
|
|
||||||
`DNS record ${record.id} (${record.recordType}) for ${
|
|
||||||
record.baseDomain || domain.baseDomain
|
|
||||||
} verified successfully`
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
if (record.verified) {
|
|
||||||
await db
|
|
||||||
.update(dnsRecords)
|
|
||||||
.set({ verified: false })
|
|
||||||
.where(eq(dnsRecords.id, record.id));
|
|
||||||
}
|
|
||||||
logger.debug(
|
|
||||||
`DNS record ${record.id} (${record.recordType}) for ${
|
|
||||||
record.baseDomain || domain.baseDomain
|
|
||||||
} does not match expected value: ${record.value}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Extra NS record check ---
|
|
||||||
// If we resolved NS records during this pass, verify that the live DNS
|
|
||||||
// has no nameservers beyond the ones we expect. Individual records may
|
|
||||||
// already be marked verified above, but we must block full domain
|
|
||||||
// verification until the extra records are removed.
|
|
||||||
if (previousNs !== null && expectedNsValues.size > 0) {
|
|
||||||
const extraNsRecords = previousNs.filter(
|
|
||||||
(ns) => !expectedNsValues.has(ns)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (extraNsRecords.length > 0) {
|
|
||||||
const errorMessage = `Extra NS records found that are not expected: ${extraNsRecords.join(", ")}. Remove these nameservers to complete domain verification.`;
|
|
||||||
|
|
||||||
await db
|
|
||||||
.update(domains)
|
|
||||||
.set({ errorMessage })
|
|
||||||
.where(eq(domains.domainId, domain.domainId));
|
|
||||||
|
|
||||||
logger.warn(
|
|
||||||
`Domain ${domain.baseDomain} has extra NS records that prevent verification: ${extraNsRecords.join(", ")}`
|
|
||||||
);
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// No extras — clear any stale error that was previously written
|
|
||||||
await db
|
|
||||||
.update(domains)
|
|
||||||
.set({ errorMessage: null })
|
|
||||||
.where(eq(domains.domainId, domain.domainId));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if all records are now verified
|
|
||||||
const updatedRecords: DnsRecord[] = await db
|
|
||||||
.select()
|
|
||||||
.from(dnsRecords)
|
|
||||||
.where(eq(dnsRecords.domainId, domain.domainId));
|
|
||||||
|
|
||||||
const allRecordsVerified = updatedRecords.every((r) => r.verified);
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`Domain ${domain.baseDomain}: ${
|
|
||||||
updatedRecords.filter((r) => r.verified).length
|
|
||||||
}/${updatedRecords.length} records verified`
|
|
||||||
);
|
|
||||||
|
|
||||||
return allRecordsVerified;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const dnsValidator = new DNSValidator();
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of a proprietary work.
|
|
||||||
*
|
|
||||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
|
||||||
* All rights reserved.
|
|
||||||
*
|
|
||||||
* This file is licensed under the Fossorial Commercial License.
|
|
||||||
* You may not use this file except in compliance with the License.
|
|
||||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
|
||||||
*
|
|
||||||
* This file is not licensed under the AGPLv3.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { eq, and, or, isNull, lt } from "drizzle-orm";
|
|
||||||
import * as dns from "dns/promises";
|
|
||||||
import { DNS_VALIDATOR_MAX_TRIES } from "./dns-validator";
|
|
||||||
import { db, domains, DnsRecord, dnsRecords, Domain } from "@server/db";
|
|
||||||
import logger from "@server/logger";
|
|
||||||
import { lockManager } from "../lock";
|
|
||||||
import { privateConfig as config } from "#private/lib/config";
|
|
||||||
|
|
||||||
// Module-level counter so successive domains in a batch round-robin across servers.
|
|
||||||
let serverIndex = 0;
|
|
||||||
|
|
||||||
export class DomainReverifier {
|
|
||||||
async reverifyAll(): Promise<void> {
|
|
||||||
const certConfig = config.getRawConfig().acme;
|
|
||||||
if (!certConfig) {
|
|
||||||
logger.debug(
|
|
||||||
"No certificate config — skipping domain reverification"
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const windowMs = certConfig.domain_reverification_window_ms;
|
|
||||||
const batchSize = certConfig.domain_reverification_batch_size;
|
|
||||||
const windowSecs = Math.floor(windowMs / 1000);
|
|
||||||
const cutoff = Math.floor(Date.now() / 1000) - windowSecs;
|
|
||||||
|
|
||||||
const domainsToCheck: Domain[] = await db
|
|
||||||
.select()
|
|
||||||
.from(domains)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(domains.verified, true),
|
|
||||||
or(
|
|
||||||
isNull(domains.lastCheckedAt),
|
|
||||||
lt(domains.lastCheckedAt, cutoff)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(batchSize);
|
|
||||||
|
|
||||||
if (domainsToCheck.length === 0) {
|
|
||||||
logger.debug("No verified domains due for reverification");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Reverifying ${domainsToCheck.length} domains`);
|
|
||||||
|
|
||||||
for (const domain of domainsToCheck) {
|
|
||||||
const lockKey = `dns-reverify:${domain.baseDomain}`;
|
|
||||||
const lockToken = await lockManager.acquireLock(lockKey);
|
|
||||||
if (!lockToken) {
|
|
||||||
logger.debug(
|
|
||||||
`Could not acquire lock for domain reverification: ${domain.baseDomain}`
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.reverifyDomain(domain, certConfig.dns_resolvers);
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn(
|
|
||||||
`Unexpected error reverifying domain ${domain.baseDomain}:`,
|
|
||||||
err
|
|
||||||
);
|
|
||||||
// Still stamp lastCheckedAt so we don't hammer a broken domain every run.
|
|
||||||
await db
|
|
||||||
.update(domains)
|
|
||||||
.set({ lastCheckedAt: Math.floor(Date.now() / 1000) })
|
|
||||||
.where(eq(domains.domainId, domain.domainId));
|
|
||||||
} finally {
|
|
||||||
await lockManager.releaseLock(lockKey, lockToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async reverifyDomain(
|
|
||||||
domain: Domain,
|
|
||||||
servers: string[]
|
|
||||||
): Promise<void> {
|
|
||||||
if (!servers || servers.length === 0) {
|
|
||||||
throw new Error("No DNS resolvers configured");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Round-robin across servers; advance the global counter so the next
|
|
||||||
// domain in the same batch gets a different server.
|
|
||||||
const dnsServer = servers[serverIndex % servers.length]!;
|
|
||||||
serverIndex++;
|
|
||||||
|
|
||||||
const resolver = new dns.Resolver();
|
|
||||||
resolver.setServers([dnsServer]);
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
`Reverifying domain ${domain.baseDomain} using DNS server ${dnsServer}`
|
|
||||||
);
|
|
||||||
|
|
||||||
const records: DnsRecord[] = await db
|
|
||||||
.select()
|
|
||||||
.from(dnsRecords)
|
|
||||||
.where(eq(dnsRecords.domainId, domain.domainId));
|
|
||||||
|
|
||||||
if (records.length === 0) {
|
|
||||||
logger.warn(
|
|
||||||
`No DNS records found for domain ${domain.baseDomain} during reverification — marking failed`
|
|
||||||
);
|
|
||||||
await this.markFailed(
|
|
||||||
domain.domainId,
|
|
||||||
"No DNS records found during periodic reverification"
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const expectedNsValues = new Set<string>(
|
|
||||||
records.filter((r) => r.recordType === "NS").map((r) => r.value)
|
|
||||||
);
|
|
||||||
|
|
||||||
let allValid = true;
|
|
||||||
let errorMessage: string | null = null;
|
|
||||||
let resolvedNs: string[] | null = null;
|
|
||||||
|
|
||||||
for (const record of records) {
|
|
||||||
let isValid = false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (record.recordType === "NS") {
|
|
||||||
if (!resolvedNs) {
|
|
||||||
resolvedNs = await resolver.resolveNs(
|
|
||||||
record.baseDomain || domain.baseDomain
|
|
||||||
);
|
|
||||||
}
|
|
||||||
isValid = resolvedNs.some((ns) => ns === record.value);
|
|
||||||
} else if (record.recordType === "CNAME") {
|
|
||||||
const cnameRecords = await resolver.resolveCname(
|
|
||||||
record.baseDomain || domain.baseDomain
|
|
||||||
);
|
|
||||||
isValid =
|
|
||||||
cnameRecords.length === 1 &&
|
|
||||||
cnameRecords[0] === record.value;
|
|
||||||
} else if (record.recordType === "TXT") {
|
|
||||||
const txtRecords = await resolver.resolveTxt(
|
|
||||||
record.baseDomain || domain.baseDomain
|
|
||||||
);
|
|
||||||
isValid = txtRecords.flat().includes(record.value);
|
|
||||||
} else if (record.recordType === "A") {
|
|
||||||
const aRecords = await resolver.resolve4(
|
|
||||||
record.baseDomain || domain.baseDomain
|
|
||||||
);
|
|
||||||
isValid = aRecords.includes(record.value);
|
|
||||||
} else {
|
|
||||||
logger.warn(
|
|
||||||
`Unsupported record type ${record.recordType} during reverification of ${domain.baseDomain}`
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
logger.debug(
|
|
||||||
`DNS lookup failed for ${record.recordType} record on ${record.baseDomain || domain.baseDomain}:`,
|
|
||||||
err
|
|
||||||
);
|
|
||||||
isValid = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isValid) {
|
|
||||||
allValid = false;
|
|
||||||
errorMessage = `${record.recordType} record for ${record.baseDomain || domain.baseDomain} no longer resolves to expected value "${record.value}"`;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for extra NS records beyond what we expect.
|
|
||||||
if (allValid && resolvedNs !== null && expectedNsValues.size > 0) {
|
|
||||||
const extraNs = resolvedNs.filter(
|
|
||||||
(ns) => !expectedNsValues.has(ns)
|
|
||||||
);
|
|
||||||
if (extraNs.length > 0) {
|
|
||||||
allValid = false;
|
|
||||||
errorMessage = `Extra NS records found: ${extraNs.join(", ")}. Remove these nameservers.`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
|
|
||||||
if (allValid) {
|
|
||||||
await db
|
|
||||||
.update(domains)
|
|
||||||
.set({ lastCheckedAt: now, errorMessage: null })
|
|
||||||
.where(eq(domains.domainId, domain.domainId));
|
|
||||||
logger.debug(
|
|
||||||
`Domain ${domain.baseDomain} passed periodic reverification`
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
await this.markFailed(domain.domainId, errorMessage);
|
|
||||||
logger.warn(
|
|
||||||
`Domain ${domain.baseDomain} failed periodic reverification: ${errorMessage}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async markFailed(
|
|
||||||
domainId: string,
|
|
||||||
errorMessage: string | null
|
|
||||||
): Promise<void> {
|
|
||||||
await db
|
|
||||||
.update(domains)
|
|
||||||
.set({
|
|
||||||
verified: false,
|
|
||||||
failed: true,
|
|
||||||
// Three below MAX_TRIES: keeps the domain out of the DNS
|
|
||||||
// validator's immediate retry loop, while still leaving it
|
|
||||||
// eligible (tries < MAX_TRIES) for a few more validation
|
|
||||||
// passes instead of being excluded forever once tries hits
|
|
||||||
// MAX_TRIES.
|
|
||||||
tries: DNS_VALIDATOR_MAX_TRIES - 3,
|
|
||||||
lastCheckedAt: Math.floor(Date.now() / 1000),
|
|
||||||
errorMessage
|
|
||||||
})
|
|
||||||
.where(eq(domains.domainId, domainId));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const domainReverifier = new DomainReverifier();
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of a proprietary work.
|
|
||||||
*
|
|
||||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
|
||||||
* All rights reserved.
|
|
||||||
*
|
|
||||||
* This file is licensed under the Fossorial Commercial License.
|
|
||||||
* You may not use this file except in compliance with the License.
|
|
||||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
|
||||||
*
|
|
||||||
* This file is not licensed under the AGPLv3.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import logger from "@server/logger";
|
|
||||||
import { privateConfig } from "#private/lib/config";
|
|
||||||
import { acmeClientManager } from "./acme-client";
|
|
||||||
import { jobScheduler } from "./scheduler";
|
|
||||||
|
|
||||||
export async function startCertificateManager() {
|
|
||||||
const acmeConfig = privateConfig.getRawPrivateConfig().acme;
|
|
||||||
if (
|
|
||||||
acmeConfig &&
|
|
||||||
acmeConfig.cert_mode === "pangolin" &&
|
|
||||||
acmeConfig.enable_acme_client
|
|
||||||
) {
|
|
||||||
logger.info("Starting certificate management server...");
|
|
||||||
|
|
||||||
// Initialize ACME client
|
|
||||||
await acmeClientManager.initialize();
|
|
||||||
|
|
||||||
// Start certificate issuance/renewal jobs
|
|
||||||
await jobScheduler.start();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
|
|
||||||
// DNS record validation/reverification doesn't require certs, so it
|
|
||||||
// runs whenever Pangolin is acting as the authoritative DNS server,
|
|
||||||
// independent of the cert manager above.
|
|
||||||
await jobScheduler.startDnsJobs();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function stopCertificateManager() {
|
|
||||||
await jobScheduler.stop();
|
|
||||||
}
|
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of a proprietary work.
|
|
||||||
*
|
|
||||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
|
||||||
* All rights reserved.
|
|
||||||
*
|
|
||||||
* This file is licensed under the Fossorial Commercial License.
|
|
||||||
* You may not use this file except in compliance with the License.
|
|
||||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
|
||||||
*
|
|
||||||
* This file is not licensed under the AGPLv3.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { withTimeout } from "@server/lib/retry";
|
|
||||||
import logger from "@server/logger";
|
|
||||||
import { certificateService } from "./certificate-service";
|
|
||||||
import { privateConfig as config } from "#private/lib/config";
|
|
||||||
import { dnsValidator } from "./dns-validator";
|
|
||||||
import { domainReverifier } from "./domain-reverifier";
|
|
||||||
import license from "#private/license/license";
|
|
||||||
|
|
||||||
// Backstop for runExclusive: no single job's own internal timeouts (e.g.
|
|
||||||
// certificate-service's per-cert issuance timeout) are relied on here. This
|
|
||||||
// is the last line of defense - if *anything* inside a job hangs with no
|
|
||||||
// error (a stalled Redis/DB call, a future code path that forgets to bound
|
|
||||||
// itself, etc.), state.active must still reset so the next tick can run.
|
|
||||||
// Without it, one hung run permanently skips every future tick for that job,
|
|
||||||
// since runExclusive only clears state.active after the job promise settles.
|
|
||||||
const RUN_EXCLUSIVE_TIMEOUT_MS = 30 * 60 * 1000;
|
|
||||||
|
|
||||||
export class JobScheduler {
|
|
||||||
private certIntervals: NodeJS.Timeout[] = [];
|
|
||||||
private dnsIntervals: NodeJS.Timeout[] = [];
|
|
||||||
private certRunning = false;
|
|
||||||
private dnsRunning = false;
|
|
||||||
|
|
||||||
// Guards against a slow batch (e.g. 10 certs whose DNS challenges take a
|
|
||||||
// while) still being processed when the next interval tick fires -
|
|
||||||
// without this, overlapping ticks would each pull their own batch of up
|
|
||||||
// to 10 pending/renewal certs and process them concurrently instead of
|
|
||||||
// waiting for the prior batch to finish.
|
|
||||||
private runExclusive(
|
|
||||||
job: () => Promise<void>,
|
|
||||||
state: { active: boolean },
|
|
||||||
label: string
|
|
||||||
): () => Promise<void> {
|
|
||||||
return async () => {
|
|
||||||
if (!(await license.isUnlocked())) {
|
|
||||||
logger.debug(
|
|
||||||
`Skipping ${label} tick - license is not subscribed`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (state.active) {
|
|
||||||
logger.debug(
|
|
||||||
`Skipping ${label} tick - previous run still in progress`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
state.active = true;
|
|
||||||
try {
|
|
||||||
await withTimeout(job(), RUN_EXCLUSIVE_TIMEOUT_MS, label);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Error in ${label}:`, error);
|
|
||||||
} finally {
|
|
||||||
state.active = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Certificate issuance/renewal - requires an ACME client, so this is
|
|
||||||
// only started when Pangolin is actually managing certs.
|
|
||||||
async start(): Promise<void> {
|
|
||||||
if (this.certRunning) {
|
|
||||||
logger.warn("Certificate job scheduler is already running");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.certRunning = true;
|
|
||||||
logger.info("Starting certificate job scheduler");
|
|
||||||
|
|
||||||
const newCertState = { active: false };
|
|
||||||
const renewalState = { active: false };
|
|
||||||
|
|
||||||
const runNewCertCheck = this.runExclusive(
|
|
||||||
() => certificateService.processPendingCertificates(),
|
|
||||||
newCertState,
|
|
||||||
"processing pending certificates"
|
|
||||||
);
|
|
||||||
const runRenewalCheck = this.runExclusive(
|
|
||||||
() => certificateService.processRenewalCandidates(),
|
|
||||||
renewalState,
|
|
||||||
"processing renewal candidates"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Schedule new certificate processing
|
|
||||||
const newCertInterval = setInterval(
|
|
||||||
runNewCertCheck,
|
|
||||||
config.getRawConfig().acme!.new_cert_check_interval_ms
|
|
||||||
);
|
|
||||||
|
|
||||||
// Schedule renewal processing (every 24 hours)
|
|
||||||
const renewalInterval = setInterval(
|
|
||||||
runRenewalCheck,
|
|
||||||
config.getRawConfig().acme!.renewal_check_interval_ms
|
|
||||||
);
|
|
||||||
|
|
||||||
this.certIntervals.push(newCertInterval, renewalInterval);
|
|
||||||
|
|
||||||
// Run initial checks
|
|
||||||
setTimeout(async () => {
|
|
||||||
try {
|
|
||||||
await runNewCertCheck();
|
|
||||||
// await runRenewalCheck();
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Error in initial certificate processing:", error);
|
|
||||||
}
|
|
||||||
}, 1000); // Wait 1 second after startup
|
|
||||||
|
|
||||||
logger.info("Certificate job scheduler started successfully");
|
|
||||||
}
|
|
||||||
|
|
||||||
// DNS record validation/reverification - doesn't touch certs at all, so
|
|
||||||
// this runs independently whenever Pangolin is acting as the
|
|
||||||
// authoritative DNS server, regardless of cert_mode.
|
|
||||||
async startDnsJobs(): Promise<void> {
|
|
||||||
if (this.dnsRunning) {
|
|
||||||
logger.warn("DNS validation job scheduler is already running");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.dnsRunning = true;
|
|
||||||
logger.info("Starting DNS validation job scheduler");
|
|
||||||
|
|
||||||
const dnsValidationState = { active: false };
|
|
||||||
const reverifyState = { active: false };
|
|
||||||
|
|
||||||
const runDnsValidation = this.runExclusive(
|
|
||||||
() => dnsValidator.validateAll(),
|
|
||||||
dnsValidationState,
|
|
||||||
"validating DNS records"
|
|
||||||
);
|
|
||||||
const runReverify = this.runExclusive(
|
|
||||||
() => domainReverifier.reverifyAll(),
|
|
||||||
reverifyState,
|
|
||||||
"reverifying domains"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Schedule DNS validation
|
|
||||||
const dnsValidationInterval = setInterval(
|
|
||||||
runDnsValidation,
|
|
||||||
config.getRawConfig().acme?.dns_check_interval_ms ?? 60000
|
|
||||||
);
|
|
||||||
|
|
||||||
// Schedule periodic reverification of already-verified domains
|
|
||||||
const reverifyInterval = setInterval(
|
|
||||||
runReverify,
|
|
||||||
config.getRawConfig().acme?.domain_reverification_interval_ms ??
|
|
||||||
3600000
|
|
||||||
);
|
|
||||||
|
|
||||||
this.dnsIntervals.push(dnsValidationInterval, reverifyInterval);
|
|
||||||
|
|
||||||
// Run an initial validation pass shortly after startup
|
|
||||||
setTimeout(async () => {
|
|
||||||
try {
|
|
||||||
await runDnsValidation();
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Error in initial DNS validation:", error);
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
logger.info("DNS validation job scheduler started successfully");
|
|
||||||
}
|
|
||||||
|
|
||||||
async stop(): Promise<void> {
|
|
||||||
if (this.certRunning) {
|
|
||||||
logger.info("Stopping certificate job scheduler");
|
|
||||||
this.certRunning = false;
|
|
||||||
this.certIntervals.forEach((interval) => clearInterval(interval));
|
|
||||||
this.certIntervals = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.dnsRunning) {
|
|
||||||
logger.info("Stopping DNS validation job scheduler");
|
|
||||||
this.dnsRunning = false;
|
|
||||||
this.dnsIntervals.forEach((interval) => clearInterval(interval));
|
|
||||||
this.dnsIntervals = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
isRunning(): boolean {
|
|
||||||
return this.certRunning || this.dnsRunning;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const jobScheduler = new JobScheduler();
|
|
||||||
@@ -146,20 +146,12 @@ export class PrivateConfig {
|
|||||||
process.env.USE_PANGOLIN_DNS =
|
process.env.USE_PANGOLIN_DNS =
|
||||||
this.rawPrivateConfig.flags.use_pangolin_dns.toString();
|
this.rawPrivateConfig.flags.use_pangolin_dns.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.rawPrivateConfig.acme?.cert_mode) {
|
|
||||||
process.env.CERT_MODE = this.rawPrivateConfig.acme.cert_mode;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public getRawPrivateConfig() {
|
public getRawPrivateConfig() {
|
||||||
return this.rawPrivateConfig;
|
return this.rawPrivateConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getRawConfig() {
|
|
||||||
return this.getRawPrivateConfig();
|
|
||||||
}
|
|
||||||
|
|
||||||
// `flags.enable_acme_cert_sync`, `flags.disable_private_http_placeholder`,
|
// `flags.enable_acme_cert_sync`, `flags.disable_private_http_placeholder`,
|
||||||
// and `acme` used to live in the private config file. They now live in
|
// and `acme` used to live in the private config file. They now live in
|
||||||
// the public config file. If an operator still has them set in the
|
// the public config file. If an operator still has them set in the
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of a proprietary work.
|
|
||||||
*
|
|
||||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
|
||||||
* All rights reserved.
|
|
||||||
*
|
|
||||||
* This file is licensed under the Fossorial Commercial License.
|
|
||||||
* You may not use this file except in compliance with the License.
|
|
||||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
|
||||||
*
|
|
||||||
* This file is not licensed under the AGPLv3.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { build } from "@server/build";
|
|
||||||
import privateConfig from "#private/lib/config";
|
|
||||||
|
|
||||||
export function createCname(domainId: string, baseDomain: string) {
|
|
||||||
if (!privateConfig.getRawPrivateConfig().dns?.cname_extension) {
|
|
||||||
throw new Error("CNAME extension not configured");
|
|
||||||
}
|
|
||||||
|
|
||||||
let cnameRecords = [
|
|
||||||
{
|
|
||||||
value: `${domainId}.${privateConfig.getRawPrivateConfig().dns?.cname_extension}`,
|
|
||||||
baseDomain: baseDomain
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: `_acme-challenge.${domainId}.${privateConfig.getRawPrivateConfig().dns?.cname_extension}`,
|
|
||||||
baseDomain: `_acme-challenge.${baseDomain}`
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
return cnameRecords;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createNs() {
|
|
||||||
if (!privateConfig.getRawPrivateConfig().dns?.nameserver_name) {
|
|
||||||
throw new Error("Nameservers not configured");
|
|
||||||
}
|
|
||||||
|
|
||||||
const nsRecords = [
|
|
||||||
privateConfig.getRawPrivateConfig().dns?.nameserver_name,
|
|
||||||
...(privateConfig.getRawPrivateConfig().dns?.alternate_nameservers ||
|
|
||||||
[])
|
|
||||||
] as string[];
|
|
||||||
return nsRecords;
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of a proprietary work.
|
|
||||||
*
|
|
||||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
|
||||||
* All rights reserved.
|
|
||||||
*
|
|
||||||
* This file is licensed under the Fossorial Commercial License.
|
|
||||||
* You may not use this file except in compliance with the License.
|
|
||||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
|
||||||
*
|
|
||||||
* This file is not licensed under the AGPLv3.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export * from "./server";
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -18,7 +18,6 @@ import { eq } from "drizzle-orm";
|
|||||||
import { sendToClient } from "#private/routers/ws";
|
import { sendToClient } from "#private/routers/ws";
|
||||||
import privateConfig from "#private/lib/config";
|
import privateConfig from "#private/lib/config";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { hasExitNodeCheckedIn } from "@server/lib/exitNodes";
|
|
||||||
|
|
||||||
interface ExitNodeRequest {
|
interface ExitNodeRequest {
|
||||||
remoteType?: string;
|
remoteType?: string;
|
||||||
@@ -139,19 +138,13 @@ export async function sendToExitNode(
|
|||||||
|
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = axios.isAxiosError(error)
|
if (axios.isAxiosError(error)) {
|
||||||
? `Error making ${method} request (can Pangolin see Gerbil HTTP API?) for exit node at ${hostname} (status: ${error.response?.status}): ${error.message}`
|
logger.error(
|
||||||
: `Error making ${method} request for exit node at ${hostname}: ${error}`;
|
`Error making ${method} request (can Pangolin see Gerbil HTTP API?) for exit node at ${hostname} (status: ${error.response?.status}): ${error.message}`
|
||||||
|
);
|
||||||
// The exit node (gerbil) may still be starting up and not yet
|
|
||||||
// reachable. Until it has checked in at least once, log this at a
|
|
||||||
// lower level since it's expected; once it has checked in, a
|
|
||||||
// connection failure is a real problem.
|
|
||||||
if (hasExitNodeCheckedIn(exitNode.exitNodeId)) {
|
|
||||||
logger.error(message);
|
|
||||||
} else {
|
} else {
|
||||||
logger.warn(
|
logger.error(
|
||||||
`${message} (exit node has not checked in yet since startup, this is expected briefly)`
|
`Error making ${method} request for exit node at ${hostname}: ${error}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,70 +95,6 @@ export const privateConfigSchema = z
|
|||||||
.optional()
|
.optional()
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
dns: z
|
|
||||||
.object({
|
|
||||||
enabled: z.boolean().optional().default(false),
|
|
||||||
listen_port: z.number().int().positive().optional().default(53),
|
|
||||||
nameserver_name: z.string(),
|
|
||||||
cname_extension: z.string(),
|
|
||||||
site_extension: z.string().optional(),
|
|
||||||
cname_alternate_extensions: z
|
|
||||||
.array(z.string())
|
|
||||||
.optional()
|
|
||||||
.default([]),
|
|
||||||
alternate_nameservers: z
|
|
||||||
.array(z.string())
|
|
||||||
.optional()
|
|
||||||
.default([]),
|
|
||||||
rate_limit: z
|
|
||||||
.object({
|
|
||||||
enabled: z.boolean().optional().default(true),
|
|
||||||
window_ms: z
|
|
||||||
.number()
|
|
||||||
.int()
|
|
||||||
.min(1000)
|
|
||||||
.max(600000)
|
|
||||||
.optional()
|
|
||||||
.default(60000),
|
|
||||||
max_requests: z
|
|
||||||
.number()
|
|
||||||
.int()
|
|
||||||
.min(50)
|
|
||||||
.max(100000)
|
|
||||||
.optional()
|
|
||||||
.default(1200),
|
|
||||||
max_requests_per_query_type: z
|
|
||||||
.number()
|
|
||||||
.int()
|
|
||||||
.min(10)
|
|
||||||
.max(50000)
|
|
||||||
.optional()
|
|
||||||
.default(600)
|
|
||||||
})
|
|
||||||
.default({
|
|
||||||
enabled: true,
|
|
||||||
window_ms: 60000,
|
|
||||||
max_requests: 1200,
|
|
||||||
max_requests_per_query_type: 600
|
|
||||||
}),
|
|
||||||
static_records: z
|
|
||||||
.array(
|
|
||||||
z.object({
|
|
||||||
domain: z.string(),
|
|
||||||
type: z.enum(["TXT", "CNAME", "A", "NS"]),
|
|
||||||
value: z.string(),
|
|
||||||
ttl: z
|
|
||||||
.number()
|
|
||||||
.int()
|
|
||||||
.positive()
|
|
||||||
.optional()
|
|
||||||
.default(300)
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.optional()
|
|
||||||
.default([])
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
gerbil: z
|
gerbil: z
|
||||||
.object({
|
.object({
|
||||||
local_exit_node_reachable_at: z
|
local_exit_node_reachable_at: z
|
||||||
@@ -189,86 +125,15 @@ export const privateConfigSchema = z
|
|||||||
})
|
})
|
||||||
.optional()
|
.optional()
|
||||||
.prefault({}),
|
.prefault({}),
|
||||||
|
// @deprecated Moved to the public config file as `acme`
|
||||||
|
// (server/lib/readConfigFile.ts). Kept here only so existing private
|
||||||
|
// config files keep parsing; any value set here is migrated into the
|
||||||
|
// public config at startup by PrivateConfig (server/private/lib/config.ts).
|
||||||
acme: z
|
acme: z
|
||||||
.object({
|
.object({
|
||||||
cert_mode: z
|
|
||||||
.enum(["traefik", "pangolin"])
|
|
||||||
.optional()
|
|
||||||
.default("traefik"),
|
|
||||||
enable_acme_client: z.boolean().optional().default(false),
|
|
||||||
// @deprecated Moved to the public config file
|
|
||||||
// (server/lib/readConfigFile.ts). Kept here only so existing private
|
|
||||||
// config files keep parsing; any value set here is migrated into the
|
|
||||||
// public config at startup by PrivateConfig (server/private/lib/config.ts).
|
|
||||||
acme_json_path: z.string().optional(),
|
acme_json_path: z.string().optional(),
|
||||||
// @deprecated Moved to the public config file
|
|
||||||
// (server/lib/readConfigFile.ts). Kept here only so existing private
|
|
||||||
// config files keep parsing; any value set here is migrated into the
|
|
||||||
// public config at startup by PrivateConfig (server/private/lib/config.ts).
|
|
||||||
acme_http_endpoint: z.string().optional(),
|
acme_http_endpoint: z.string().optional(),
|
||||||
// @deprecated Moved to the public config file
|
sync_interval_ms: z.number().optional()
|
||||||
// (server/lib/readConfigFile.ts). Kept here only so existing private
|
|
||||||
// config files keep parsing; any value set here is migrated into the
|
|
||||||
// public config at startup by PrivateConfig (server/private/lib/config.ts).
|
|
||||||
sync_interval_ms: z.number().optional(),
|
|
||||||
acme_directory_url: z
|
|
||||||
.string()
|
|
||||||
.url()
|
|
||||||
.default("https://acme-v02.api.letsencrypt.org/directory"),
|
|
||||||
contact_email: z.string().email().optional(),
|
|
||||||
acme_account_key_path: z
|
|
||||||
.string()
|
|
||||||
.default("./config/account.key"),
|
|
||||||
challenge_ttl_ms: z.number().int().positive().default(300000),
|
|
||||||
renewal_check_interval_ms: z
|
|
||||||
.number()
|
|
||||||
.int()
|
|
||||||
.positive()
|
|
||||||
.default(3600000),
|
|
||||||
new_cert_check_interval_ms: z
|
|
||||||
.number()
|
|
||||||
.int()
|
|
||||||
.positive()
|
|
||||||
.default(5000),
|
|
||||||
// Kept safely under Let's Encrypt's ~20 req/s limit since this
|
|
||||||
// budget is shared across all pops workers and only covers the
|
|
||||||
// request-issuing calls we make directly (not every request
|
|
||||||
// acme-client makes internally, e.g. while polling for
|
|
||||||
// challenge/order status).
|
|
||||||
acme_requests_per_second: z
|
|
||||||
.number()
|
|
||||||
.int()
|
|
||||||
.positive()
|
|
||||||
.default(15),
|
|
||||||
dns_check_interval_ms: z
|
|
||||||
.number()
|
|
||||||
.int()
|
|
||||||
.positive()
|
|
||||||
.default(60000),
|
|
||||||
domain_reverification_interval_ms: z
|
|
||||||
.number()
|
|
||||||
.int()
|
|
||||||
.positive()
|
|
||||||
.default(3600000), // 1 hour — how often to run the reverification pass
|
|
||||||
domain_reverification_window_ms: z
|
|
||||||
.number()
|
|
||||||
.int()
|
|
||||||
.positive()
|
|
||||||
.default(259200000), // 72 hours — how old checkedAt must be before rechecking
|
|
||||||
domain_reverification_batch_size: z
|
|
||||||
.number()
|
|
||||||
.int()
|
|
||||||
.positive()
|
|
||||||
.default(20), // max domains to recheck per pass
|
|
||||||
dns_resolvers: z
|
|
||||||
.array(z.string())
|
|
||||||
.optional()
|
|
||||||
.default([
|
|
||||||
"8.8.8.8",
|
|
||||||
"1.1.1.1",
|
|
||||||
"9.9.9.9",
|
|
||||||
"208.67.222.222"
|
|
||||||
])
|
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
branding: z
|
branding: z
|
||||||
|
|||||||
@@ -396,7 +396,7 @@ export async function getTraefikConfig(
|
|||||||
);
|
);
|
||||||
|
|
||||||
let validCerts: CertificateResult[] = [];
|
let validCerts: CertificateResult[] = [];
|
||||||
if (privateConfig.getRawPrivateConfig().acme?.cert_mode == "pangolin") {
|
if (privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
|
||||||
// create a list of all domains to get certs for
|
// create a list of all domains to get certs for
|
||||||
const domains = new Set<string>();
|
const domains = new Set<string>();
|
||||||
for (const resource of resourcesMap.values()) {
|
for (const resource of resourcesMap.values()) {
|
||||||
@@ -522,10 +522,7 @@ export async function getTraefikConfig(
|
|||||||
);
|
);
|
||||||
|
|
||||||
let tls = {};
|
let tls = {};
|
||||||
if (
|
if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
|
||||||
privateConfig.getRawPrivateConfig().acme?.cert_mode !=
|
|
||||||
"pangolin"
|
|
||||||
) {
|
|
||||||
tls = buildWildcardTls({
|
tls = buildWildcardTls({
|
||||||
fullDomain,
|
fullDomain,
|
||||||
hasSubdomain: !!resource.subdomain,
|
hasSubdomain: !!resource.subdomain,
|
||||||
@@ -792,8 +789,7 @@ export async function getTraefikConfig(
|
|||||||
preferWildcardCert
|
preferWildcardCert
|
||||||
}) => {
|
}) => {
|
||||||
if (
|
if (
|
||||||
privateConfig.getRawPrivateConfig().acme?.cert_mode !=
|
!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns
|
||||||
"pangolin"
|
|
||||||
) {
|
) {
|
||||||
return buildWildcardTls({
|
return buildWildcardTls({
|
||||||
fullDomain,
|
fullDomain,
|
||||||
@@ -836,8 +832,7 @@ export async function getTraefikConfig(
|
|||||||
redirectHttpsMiddlewareName,
|
redirectHttpsMiddlewareName,
|
||||||
resolveTls: (fullDomain) => {
|
resolveTls: (fullDomain) => {
|
||||||
if (
|
if (
|
||||||
privateConfig.getRawPrivateConfig().acme?.cert_mode !=
|
!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns
|
||||||
"pangolin"
|
|
||||||
) {
|
) {
|
||||||
// siteResource aliases don't have a per-domain cert
|
// siteResource aliases don't have a per-domain cert
|
||||||
// resolver stored, so always fall back to the global
|
// resolver stored, so always fall back to the global
|
||||||
@@ -929,10 +924,7 @@ export async function getTraefikConfig(
|
|||||||
const rule = buildHostRule(fullDomain, ir.wildcard);
|
const rule = buildHostRule(fullDomain, ir.wildcard);
|
||||||
|
|
||||||
let tls: any = {};
|
let tls: any = {};
|
||||||
if (
|
if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
|
||||||
privateConfig.getRawPrivateConfig().acme?.cert_mode !=
|
|
||||||
"pangolin"
|
|
||||||
) {
|
|
||||||
tls = buildWildcardTls({
|
tls = buildWildcardTls({
|
||||||
fullDomain,
|
fullDomain,
|
||||||
hasSubdomain: !!ir.subdomain,
|
hasSubdomain: !!ir.subdomain,
|
||||||
@@ -1013,8 +1005,7 @@ export async function getTraefikConfig(
|
|||||||
|
|
||||||
let tls: any = {};
|
let tls: any = {};
|
||||||
if (
|
if (
|
||||||
privateConfig.getRawPrivateConfig().acme?.cert_mode !=
|
!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns
|
||||||
"pangolin"
|
|
||||||
) {
|
) {
|
||||||
// siteResource aliases don't have a per-domain cert
|
// siteResource aliases don't have a per-domain cert
|
||||||
// resolver stored, so always fall back to the global
|
// resolver stored, so always fall back to the global
|
||||||
@@ -1089,7 +1080,7 @@ export async function getTraefikConfig(
|
|||||||
.where(eq(exitNodes.exitNodeId, exitNodeId));
|
.where(eq(exitNodes.exitNodeId, exitNodeId));
|
||||||
|
|
||||||
let validCertsLoginPages: CertificateResult[] = [];
|
let validCertsLoginPages: CertificateResult[] = [];
|
||||||
if (privateConfig.getRawPrivateConfig().acme?.cert_mode == "pangolin") {
|
if (privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
|
||||||
// create a list of all domains to get certs for
|
// create a list of all domains to get certs for
|
||||||
const domains = new Set<string>();
|
const domains = new Set<string>();
|
||||||
for (const lp of exitNodeLoginPages) {
|
for (const lp of exitNodeLoginPages) {
|
||||||
@@ -1135,8 +1126,7 @@ export async function getTraefikConfig(
|
|||||||
|
|
||||||
const tls = {};
|
const tls = {};
|
||||||
if (
|
if (
|
||||||
privateConfig.getRawPrivateConfig().acme?.cert_mode !=
|
!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns
|
||||||
"pangolin"
|
|
||||||
) {
|
) {
|
||||||
// TODO: we need to add the wildcard logic here too
|
// TODO: we need to add the wildcard logic here too
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
import { getRandomItemInArray } from "@app/lib/getRandomItemInArray";
|
import { getRandomItemInArray } from "@app/lib/getRandomItemInArray";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { processTestAlerts } from "#private/lib/alerts/processTestAlerts";
|
import { processTestAlerts } from "@server/private/lib/alerts/processTestAlerts";
|
||||||
import { type AlertAction } from "@server/routers/alertRule/types";
|
import { type AlertAction } from "@server/routers/alertRule/types";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import { NextFunction, Request, Response } from "express";
|
import { NextFunction, Request, Response } from "express";
|
||||||
|
|||||||
@@ -33,11 +33,8 @@ import { OpenAPITags, registry } from "@server/openApi";
|
|||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { encrypt } from "@server/lib/crypto";
|
import { encrypt } from "@server/lib/crypto";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import {
|
import { HC_EVENT_TYPES, SITE_EVENT_TYPES, RESOURCE_EVENT_TYPES } from "./createAlertRule";
|
||||||
HC_EVENT_TYPES,
|
import { invalidateAllRemoteExitNodeSessions } from "@server/private/auth/sessions/remoteExitNode";
|
||||||
SITE_EVENT_TYPES,
|
|
||||||
RESOURCE_EVENT_TYPES
|
|
||||||
} from "./createAlertRule";
|
|
||||||
|
|
||||||
const paramsSchema = z
|
const paramsSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -88,57 +85,35 @@ const bodySchema = z
|
|||||||
const isHcEvent = (HC_EVENT_TYPES as readonly string[]).includes(
|
const isHcEvent = (HC_EVENT_TYPES as readonly string[]).includes(
|
||||||
val.eventType
|
val.eventType
|
||||||
);
|
);
|
||||||
const isResourceEvent = (
|
const isResourceEvent = (RESOURCE_EVENT_TYPES as readonly string[]).includes(
|
||||||
RESOURCE_EVENT_TYPES as readonly string[]
|
val.eventType
|
||||||
).includes(val.eventType);
|
);
|
||||||
|
|
||||||
if (
|
if (isSiteEvent && val.siteIds !== undefined && val.siteIds.length === 0 && !val.allSites) {
|
||||||
isSiteEvent &&
|
|
||||||
val.siteIds !== undefined &&
|
|
||||||
val.siteIds.length === 0 &&
|
|
||||||
!val.allSites
|
|
||||||
) {
|
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message:
|
message: "At least one siteId is required for site event types when allSites is false",
|
||||||
"At least one siteId is required for site event types when allSites is false",
|
|
||||||
path: ["siteIds"]
|
path: ["siteIds"]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (isHcEvent && val.healthCheckIds !== undefined && val.healthCheckIds.length === 0 && !val.allHealthChecks) {
|
||||||
isHcEvent &&
|
|
||||||
val.healthCheckIds !== undefined &&
|
|
||||||
val.healthCheckIds.length === 0 &&
|
|
||||||
!val.allHealthChecks
|
|
||||||
) {
|
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message:
|
message: "At least one healthCheckId is required for health check event types when allHealthChecks is false",
|
||||||
"At least one healthCheckId is required for health check event types when allHealthChecks is false",
|
|
||||||
path: ["healthCheckIds"]
|
path: ["healthCheckIds"]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (isResourceEvent && val.resourceIds !== undefined && val.resourceIds.length === 0 && !val.allResources) {
|
||||||
isResourceEvent &&
|
|
||||||
val.resourceIds !== undefined &&
|
|
||||||
val.resourceIds.length === 0 &&
|
|
||||||
!val.allResources
|
|
||||||
) {
|
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message:
|
message: "At least one resourceId is required for resource event types when allResources is false",
|
||||||
"At least one resourceId is required for resource event types when allResources is false",
|
|
||||||
path: ["resourceIds"]
|
path: ["resourceIds"]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (isSiteEvent && val.healthCheckIds !== undefined && val.healthCheckIds.length > 0) {
|
||||||
isSiteEvent &&
|
|
||||||
val.healthCheckIds !== undefined &&
|
|
||||||
val.healthCheckIds.length > 0
|
|
||||||
) {
|
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message: "healthCheckIds must not be set for site event types",
|
message: "healthCheckIds must not be set for site event types",
|
||||||
@@ -154,11 +129,7 @@ const bodySchema = z
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (isResourceEvent && val.siteIds !== undefined && val.siteIds.length > 0) {
|
||||||
isResourceEvent &&
|
|
||||||
val.siteIds !== undefined &&
|
|
||||||
val.siteIds.length > 0
|
|
||||||
) {
|
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message: "siteIds must not be set for resource event types",
|
message: "siteIds must not be set for resource event types",
|
||||||
@@ -166,15 +137,10 @@ const bodySchema = z
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (isResourceEvent && val.healthCheckIds !== undefined && val.healthCheckIds.length > 0) {
|
||||||
isResourceEvent &&
|
|
||||||
val.healthCheckIds !== undefined &&
|
|
||||||
val.healthCheckIds.length > 0
|
|
||||||
) {
|
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message:
|
message: "healthCheckIds must not be set for resource event types",
|
||||||
"healthCheckIds must not be set for resource event types",
|
|
||||||
path: ["healthCheckIds"]
|
path: ["healthCheckIds"]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -187,6 +153,7 @@ const UpdateAlertRuleResponseDataSchema = z.object({
|
|||||||
alertRuleId: z.number()
|
alertRuleId: z.number()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
registry.registerPath({
|
registry.registerPath({
|
||||||
method: "post",
|
method: "post",
|
||||||
path: "/org/{orgId}/alert-rule/{alertRuleId}",
|
path: "/org/{orgId}/alert-rule/{alertRuleId}",
|
||||||
@@ -207,9 +174,7 @@ registry.registerPath({
|
|||||||
description: "Successful response",
|
description: "Successful response",
|
||||||
content: {
|
content: {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
schema: createApiResponseSchema(
|
schema: createApiResponseSchema(UpdateAlertRuleResponseDataSchema)
|
||||||
UpdateAlertRuleResponseDataSchema
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -285,11 +250,9 @@ export async function updateAlertRule(
|
|||||||
if (name !== undefined) updateData.name = name;
|
if (name !== undefined) updateData.name = name;
|
||||||
if (eventType !== undefined) updateData.eventType = eventType;
|
if (eventType !== undefined) updateData.eventType = eventType;
|
||||||
if (enabled !== undefined) updateData.enabled = enabled;
|
if (enabled !== undefined) updateData.enabled = enabled;
|
||||||
if (cooldownSeconds !== undefined)
|
if (cooldownSeconds !== undefined) updateData.cooldownSeconds = cooldownSeconds;
|
||||||
updateData.cooldownSeconds = cooldownSeconds;
|
|
||||||
if (allSites !== undefined) updateData.allSites = allSites;
|
if (allSites !== undefined) updateData.allSites = allSites;
|
||||||
if (allHealthChecks !== undefined)
|
if (allHealthChecks !== undefined) updateData.allHealthChecks = allHealthChecks;
|
||||||
updateData.allHealthChecks = allHealthChecks;
|
|
||||||
if (allResources !== undefined) updateData.allResources = allResources;
|
if (allResources !== undefined) updateData.allResources = allResources;
|
||||||
|
|
||||||
await db
|
await db
|
||||||
@@ -310,11 +273,7 @@ export async function updateAlertRule(
|
|||||||
|
|
||||||
// Only insert junction rows when allSites is not true
|
// Only insert junction rows when allSites is not true
|
||||||
const effectiveAllSites = allSites ?? false;
|
const effectiveAllSites = allSites ?? false;
|
||||||
if (
|
if (!effectiveAllSites && siteIds !== undefined && siteIds.length > 0) {
|
||||||
!effectiveAllSites &&
|
|
||||||
siteIds !== undefined &&
|
|
||||||
siteIds.length > 0
|
|
||||||
) {
|
|
||||||
await db.insert(alertSites).values(
|
await db.insert(alertSites).values(
|
||||||
siteIds.map((siteId) => ({
|
siteIds.map((siteId) => ({
|
||||||
alertRuleId,
|
alertRuleId,
|
||||||
@@ -331,11 +290,7 @@ export async function updateAlertRule(
|
|||||||
.where(eq(alertHealthChecks.alertRuleId, alertRuleId));
|
.where(eq(alertHealthChecks.alertRuleId, alertRuleId));
|
||||||
|
|
||||||
const effectiveAllHealthChecks = allHealthChecks ?? false;
|
const effectiveAllHealthChecks = allHealthChecks ?? false;
|
||||||
if (
|
if (!effectiveAllHealthChecks && healthCheckIds !== undefined && healthCheckIds.length > 0) {
|
||||||
!effectiveAllHealthChecks &&
|
|
||||||
healthCheckIds !== undefined &&
|
|
||||||
healthCheckIds.length > 0
|
|
||||||
) {
|
|
||||||
await db.insert(alertHealthChecks).values(
|
await db.insert(alertHealthChecks).values(
|
||||||
healthCheckIds.map((healthCheckId) => ({
|
healthCheckIds.map((healthCheckId) => ({
|
||||||
alertRuleId,
|
alertRuleId,
|
||||||
@@ -352,11 +307,7 @@ export async function updateAlertRule(
|
|||||||
.where(eq(alertResources.alertRuleId, alertRuleId));
|
.where(eq(alertResources.alertRuleId, alertRuleId));
|
||||||
|
|
||||||
const effectiveAllResources = allResources ?? false;
|
const effectiveAllResources = allResources ?? false;
|
||||||
if (
|
if (!effectiveAllResources && resourceIds !== undefined && resourceIds.length > 0) {
|
||||||
!effectiveAllResources &&
|
|
||||||
resourceIds !== undefined &&
|
|
||||||
resourceIds.length > 0
|
|
||||||
) {
|
|
||||||
await db.insert(alertResources).values(
|
await db.insert(alertResources).values(
|
||||||
resourceIds.map((resourceId) => ({
|
resourceIds.map((resourceId) => ({
|
||||||
alertRuleId,
|
alertRuleId,
|
||||||
@@ -441,10 +392,7 @@ export async function updateAlertRule(
|
|||||||
webhookActions.map((wa) => ({
|
webhookActions.map((wa) => ({
|
||||||
alertRuleId,
|
alertRuleId,
|
||||||
webhookUrl: wa.webhookUrl,
|
webhookUrl: wa.webhookUrl,
|
||||||
config:
|
config: wa.config != null ? encrypt(wa.config, serverSecret) : null,
|
||||||
wa.config != null
|
|
||||||
? encrypt(wa.config, serverSecret)
|
|
||||||
: null,
|
|
||||||
enabled: wa.enabled
|
enabled: wa.enabled
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -31,9 +31,7 @@ export async function clearInstanceName(
|
|||||||
next: NextFunction
|
next: NextFunction
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const parsedParams = clearInstanceNameParamsSchema.safeParse(
|
const parsedParams = clearInstanceNameParamsSchema.safeParse(req.params);
|
||||||
req.params
|
|
||||||
);
|
|
||||||
if (!parsedParams.success) {
|
if (!parsedParams.success) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
@@ -65,8 +63,7 @@ export async function clearInstanceName(
|
|||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
data.status || HttpCode.BAD_REQUEST,
|
data.status || HttpCode.BAD_REQUEST,
|
||||||
data.message ||
|
data.message || "Failed to clear instance name from Fossorial API"
|
||||||
"Failed to clear server ID from Fossorial API"
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -75,7 +72,7 @@ export async function clearInstanceName(
|
|||||||
data: null,
|
data: null,
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
message: "Server ID cleared successfully",
|
message: "Instance name cleared successfully",
|
||||||
status: HttpCode.OK
|
status: HttpCode.OK
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -83,7 +80,7 @@ export async function clearInstanceName(
|
|||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
"An error occurred while clearing the server ID."
|
"An error occurred while clearing the instance name."
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,8 +12,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { db, ExitNode, exitNodes } from "@server/db";
|
import { db, ExitNode, exitNodes } from "@server/db";
|
||||||
|
import { getUniqueExitNodeEndpointName } from "@server/db/names";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import privateConfig from "#private/lib/config";
|
|
||||||
import { getNextAvailableSubnet } from "@server/lib/exitNodes";
|
import { getNextAvailableSubnet } from "@server/lib/exitNodes";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
@@ -45,8 +45,6 @@ export async function createExitNode(
|
|||||||
.values({
|
.values({
|
||||||
publicKey,
|
publicKey,
|
||||||
endpoint: config.getRawConfig().gerbil.base_endpoint,
|
endpoint: config.getRawConfig().gerbil.base_endpoint,
|
||||||
region:
|
|
||||||
privateConfig.getRawPrivateConfig().app.region || null,
|
|
||||||
address,
|
address,
|
||||||
listenPort,
|
listenPort,
|
||||||
online: true,
|
online: true,
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import {
|
|||||||
validateRemoteExitNodeSessionToken,
|
validateRemoteExitNodeSessionToken,
|
||||||
EXPIRES
|
EXPIRES
|
||||||
} from "#private/auth/sessions/remoteExitNode";
|
} from "#private/auth/sessions/remoteExitNode";
|
||||||
import { getOrCreateCachedToken } from "#private/lib/tokenCache";
|
import { getOrCreateCachedToken } from "@server/private/lib/tokenCache";
|
||||||
import { verifyPassword } from "@server/auth/password";
|
import { verifyPassword } from "@server/auth/password";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import { LimitId } from "@server/lib/billing";
|
|||||||
import { isSecondLevelDomain, isValidDomain } from "@server/lib/validators";
|
import { isSecondLevelDomain, isValidDomain } from "@server/lib/validators";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { createNs, createCname } from "#dynamic/lib/dns/generateDomains";
|
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({
|
const paramsSchema = z.strictObject({
|
||||||
orgId: z.string()
|
orgId: z.string()
|
||||||
@@ -284,7 +283,8 @@ export async function createOrgDomain(
|
|||||||
|
|
||||||
// TODO: This needs to be cross region and not hardcoded
|
// TODO: This needs to be cross region and not hardcoded
|
||||||
if (type === "ns") {
|
if (type === "ns") {
|
||||||
nsRecords = createNs();
|
nsRecords = config.getRawConfig().dns.nameservers as string[];
|
||||||
|
|
||||||
// Save NS records to database
|
// Save NS records to database
|
||||||
for (const nsValue of nsRecords) {
|
for (const nsValue of nsRecords) {
|
||||||
recordsToInsert.push({
|
recordsToInsert.push({
|
||||||
@@ -296,7 +296,16 @@ export async function createOrgDomain(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (type === "cname") {
|
} else if (type === "cname") {
|
||||||
cnameRecords = createCname(domainId, baseDomain);
|
cnameRecords = [
|
||||||
|
{
|
||||||
|
value: `${domainId}.${config.getRawConfig().dns.cname_extension}`,
|
||||||
|
baseDomain: baseDomain
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: `_acme-challenge.${domainId}.${config.getRawConfig().dns.cname_extension}`,
|
||||||
|
baseDomain: `_acme-challenge.${baseDomain}`
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
// Save CNAME records to database
|
// Save CNAME records to database
|
||||||
for (const cnameRecord of cnameRecords) {
|
for (const cnameRecord of cnameRecords) {
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import config from "@server/lib/config";
|
|||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { getAllowedIps } from "../target/helpers";
|
import { getAllowedIps } from "../target/helpers";
|
||||||
import { createExitNode } from "#dynamic/routers/gerbil/createExitNode";
|
import { createExitNode } from "#dynamic/routers/gerbil/createExitNode";
|
||||||
import { markExitNodeCheckedIn } from "@server/lib/exitNodes";
|
|
||||||
|
|
||||||
// Define Zod schema for request validation
|
// Define Zod schema for request validation
|
||||||
const getConfigSchema = z.object({
|
const getConfigSchema = z.object({
|
||||||
@@ -66,8 +65,6 @@ export async function getConfig(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
markExitNodeCheckedIn(exitNode.exitNodeId);
|
|
||||||
|
|
||||||
const configResponse = await generateGerbilConfig(exitNode);
|
const configResponse = await generateGerbilConfig(exitNode);
|
||||||
|
|
||||||
logger.debug("Sending config: ", configResponse);
|
logger.debug("Sending config: ", configResponse);
|
||||||
|
|||||||
@@ -1,3 +1,16 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of a proprietary work.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* This file is licensed under the Fossorial Commercial License.
|
||||||
|
* You may not use this file except in compliance with the License.
|
||||||
|
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
*
|
||||||
|
* This file is not licensed under the AGPLv3.
|
||||||
|
*/
|
||||||
|
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { MessageHandler } from "@server/routers/ws";
|
import { MessageHandler } from "@server/routers/ws";
|
||||||
import { sites, Newt, orgs, clients, clientSitesAssociationsCache, users } from "@server/db";
|
import { sites, Newt, orgs, clients, clientSitesAssociationsCache, users } from "@server/db";
|
||||||
|
|||||||
@@ -66,12 +66,6 @@ const migrations = [
|
|||||||
|
|
||||||
await run();
|
await run();
|
||||||
|
|
||||||
// The pg Pool is created with allowExitOnIdle: false (see poolConfig.ts) so
|
|
||||||
// its sockets keep the event loop alive even when idle. Without an explicit
|
|
||||||
// exit here, this one-shot script would hang until the pool's
|
|
||||||
// idleTimeoutMillis elapses before the process could terminate.
|
|
||||||
process.exit(0);
|
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
// run the migrations
|
// run the migrations
|
||||||
await runMigrations();
|
await runMigrations();
|
||||||
|
|||||||
@@ -78,16 +78,13 @@ export default function GenerateLicenseKeysTable({
|
|||||||
);
|
);
|
||||||
toast({
|
toast({
|
||||||
title: t("success"),
|
title: t("success"),
|
||||||
description: "Server ID cleared successfully"
|
description: "Instance name cleared successfully"
|
||||||
});
|
});
|
||||||
await refreshData();
|
await refreshData();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
title: t("error"),
|
title: t("error"),
|
||||||
description: formatAxiosError(
|
description: formatAxiosError(error, "Failed to clear instance name"),
|
||||||
error,
|
|
||||||
"Failed to clear server ID"
|
|
||||||
),
|
|
||||||
variant: "destructive"
|
variant: "destructive"
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
@@ -294,7 +291,7 @@ export default function GenerateLicenseKeysTable({
|
|||||||
clearInstanceName(key.licenseKey)
|
clearInstanceName(key.licenseKey)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{t("clearInstanceName")}
|
Clear Instance Name
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
SettingsSectionTitle
|
SettingsSectionTitle
|
||||||
} from "./Settings";
|
} from "./Settings";
|
||||||
import { CheckboxWithLabel } from "./ui/checkbox";
|
import { CheckboxWithLabel } from "./ui/checkbox";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
import { OptionSelect, type OptionSelectOption } from "./OptionSelect";
|
import { OptionSelect, type OptionSelectOption } from "./OptionSelect";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
@@ -18,11 +19,14 @@ import {
|
|||||||
FaLinux,
|
FaLinux,
|
||||||
FaWindows
|
FaWindows
|
||||||
} from "react-icons/fa";
|
} from "react-icons/fa";
|
||||||
import { ExternalLink } from "lucide-react";
|
import { Download, ExternalLink } from "lucide-react";
|
||||||
import { SiKubernetes, SiNixos } from "react-icons/si";
|
import { SiKubernetes, SiNixos } from "react-icons/si";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
|
||||||
export type CommandItem = string | { title: string; command: string };
|
export type CommandItem =
|
||||||
|
| string
|
||||||
|
| { title: string; command: string }
|
||||||
|
| { title: string; link: string };
|
||||||
|
|
||||||
const PLATFORMS = [
|
const PLATFORMS = [
|
||||||
"linux",
|
"linux",
|
||||||
@@ -41,14 +45,12 @@ export type NewtSiteInstallCommandsProps = {
|
|||||||
id: string;
|
id: string;
|
||||||
secret: string;
|
secret: string;
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
version?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function NewtSiteInstallCommands({
|
export function NewtSiteInstallCommands({
|
||||||
id,
|
id,
|
||||||
secret,
|
secret,
|
||||||
endpoint,
|
endpoint
|
||||||
version = "latest"
|
|
||||||
}: NewtSiteInstallCommandsProps) {
|
}: NewtSiteInstallCommandsProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
@@ -88,25 +90,39 @@ export function NewtSiteInstallCommands({
|
|||||||
Run: [
|
Run: [
|
||||||
{
|
{
|
||||||
title: t("install"),
|
title: t("install"),
|
||||||
command: `curl -fsSL https://static.pangolin.net/get-newt.sh | bash`
|
command: `curl -fsSL https://static.pangolin.net/get-cli.sh | bash`
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("run"),
|
title: t("run"),
|
||||||
command: `${runAsRootPrefix}newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}${disableSshFlag}`
|
command: `${runAsRootPrefix}pangolin up site --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}${disableSshFlag}`
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"Systemd Service": [
|
"Auto Systemd Service": [
|
||||||
{
|
{
|
||||||
title: t("install"),
|
title: t("install"),
|
||||||
command: `curl -fsSL https://static.pangolin.net/get-newt.sh | bash`
|
command: `curl -fsSL https://static.pangolin.net/get-cli.sh | bash`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("run"),
|
||||||
|
command: `sudo pangolin service install site --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}${disableSshFlag}`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("check"),
|
||||||
|
command: `sudo pangolin service status site`
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Manual Systemd Service": [
|
||||||
|
{
|
||||||
|
title: t("install"),
|
||||||
|
command: `curl -fsSL https://static.pangolin.net/get-cli.sh | bash`
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("envFile"),
|
title: t("envFile"),
|
||||||
command: `# Create the directory and environment file
|
command: `# Create the directory and environment file
|
||||||
sudo install -d -m 0755 /etc/newt
|
sudo install -d -m 0755 /etc/pangolin
|
||||||
sudo tee /etc/newt/newt.env > /dev/null << 'EOF'
|
sudo tee /etc/pangolin/pangolin-site.env > /dev/null << 'EOF'
|
||||||
NEWT_ID=${id}
|
SITE_ID=${id}
|
||||||
NEWT_SECRET=${secret}
|
SITE_SECRET=${secret}
|
||||||
PANGOLIN_ENDPOINT=${endpoint}${
|
PANGOLIN_ENDPOINT=${endpoint}${
|
||||||
!acceptClients
|
!acceptClients
|
||||||
? `
|
? `
|
||||||
@@ -119,11 +135,11 @@ DISABLE_SSH=true`
|
|||||||
: ""
|
: ""
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
sudo chmod 600 /etc/newt/newt.env`
|
sudo chmod 600 /etc/pangolin/pangolin-site.env`
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("serviceFile"),
|
title: t("serviceFile"),
|
||||||
command: `sudo tee /etc/systemd/system/newt.service > /dev/null << 'EOF'
|
command: `sudo tee /etc/systemd/system/pangolin-site.service > /dev/null << 'EOF'
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Newt
|
Description=Newt
|
||||||
Wants=network-online.target
|
Wants=network-online.target
|
||||||
@@ -133,8 +149,8 @@ After=network-online.target
|
|||||||
Type=simple
|
Type=simple
|
||||||
User=root
|
User=root
|
||||||
Group=root
|
Group=root
|
||||||
EnvironmentFile=/etc/newt/newt.env
|
EnvironmentFile=/etc/pangolin/pangolin-site.env
|
||||||
ExecStart=/usr/local/bin/newt
|
ExecStart=/home/owen/fossorial/cli/bin/pangolin up site
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=2
|
RestartSec=2
|
||||||
UMask=0077
|
UMask=0077
|
||||||
@@ -148,7 +164,7 @@ EOF`
|
|||||||
{
|
{
|
||||||
title: t("enableAndStart"),
|
title: t("enableAndStart"),
|
||||||
command: `sudo systemctl daemon-reload
|
command: `sudo systemctl daemon-reload
|
||||||
sudo systemctl enable --now newt`
|
sudo systemctl enable --now pangolin-site`
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -156,23 +172,51 @@ sudo systemctl enable --now newt`
|
|||||||
Run: [
|
Run: [
|
||||||
{
|
{
|
||||||
title: t("install"),
|
title: t("install"),
|
||||||
command: `curl -fsSL https://static.pangolin.net/get-newt.sh | bash`
|
command: `curl -fsSL https://static.pangolin.net/get-cli.sh | bash`
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("run"),
|
title: t("run"),
|
||||||
command: `newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
|
command: `pangolin up site --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
|
||||||
|
}
|
||||||
|
],
|
||||||
|
Service: [
|
||||||
|
{
|
||||||
|
title: t("install"),
|
||||||
|
command: `curl -fsSL https://static.pangolin.net/get-cli.sh | bash`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("run"),
|
||||||
|
command: `sudo pangolin service install site --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}${disableSshFlag}`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("check"),
|
||||||
|
command: `sudo pangolin service status site`
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
windows: {
|
windows: {
|
||||||
x64: [
|
Run: [
|
||||||
{
|
{
|
||||||
title: t("install"),
|
title: t("install"),
|
||||||
command: `curl -o newt.exe -L "https://github.com/fosrl/newt/releases/download/${version}/newt_windows_amd64.exe"`
|
link: `https://github.com/fosrl/cli/releases/latest/download/pangolin-cli_windows_installer.msi`
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("run"),
|
title: t("run"),
|
||||||
command: `newt.exe --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
|
command: `pangolin up site --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
|
||||||
|
}
|
||||||
|
],
|
||||||
|
Service: [
|
||||||
|
{
|
||||||
|
title: t("install"),
|
||||||
|
link: `https://github.com/fosrl/cli/releases/latest/download/pangolin-cli_windows_installer.msi`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("run"),
|
||||||
|
command: `pangolin service install site --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}${disableSshFlag}`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("check"),
|
||||||
|
command: `pangolin service status site`
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -180,19 +224,20 @@ sudo systemctl enable --now newt`
|
|||||||
"Docker Compose": [
|
"Docker Compose": [
|
||||||
`services:
|
`services:
|
||||||
newt:
|
newt:
|
||||||
image: fosrl/newt
|
image: fosrl/pangolin-cli
|
||||||
container_name: newt
|
container_name: newt
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- PANGOLIN_ENDPOINT=${endpoint}
|
- PANGOLIN_ENDPOINT=${endpoint}
|
||||||
- NEWT_ID=${id}
|
- SITE_ID=${id}
|
||||||
- NEWT_SECRET=${secret}${acceptClientsEnv}`
|
- SITE_SECRET=${secret}${acceptClientsEnv}`
|
||||||
],
|
],
|
||||||
"Docker Run": [
|
"Docker Run": [
|
||||||
`docker run -dit --network host fosrl/newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
|
`docker run -dit --network host fosrl/pangolin-cli up site --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
kubernetes: {
|
kubernetes: {
|
||||||
|
// we are leaving this using newt until we change it to use the cli
|
||||||
"Helm Chart": [
|
"Helm Chart": [
|
||||||
`helm repo add fossorial https://charts.fossorial.io`,
|
`helm repo add fossorial https://charts.fossorial.io`,
|
||||||
`helm repo update fossorial`,
|
`helm repo update fossorial`,
|
||||||
@@ -216,15 +261,15 @@ sudo systemctl enable --now newt`
|
|||||||
podman: {
|
podman: {
|
||||||
"Podman Quadlet": [
|
"Podman Quadlet": [
|
||||||
`[Unit]
|
`[Unit]
|
||||||
Description=Newt container
|
Description=Pangolin Site Container
|
||||||
|
|
||||||
[Container]
|
[Container]
|
||||||
ContainerName=newt
|
ContainerName=pangolin-site
|
||||||
Image=docker.io/fosrl/newt
|
Image=docker.io/fosrl/pangolin-cli
|
||||||
Environment=PANGOLIN_ENDPOINT=${endpoint}
|
Environment=PANGOLIN_ENDPOINT=${endpoint}
|
||||||
Environment=NEWT_ID=${id}
|
Environment=SITE_ID=${id}
|
||||||
Environment=NEWT_SECRET=${secret}${!acceptClients ? "\nEnvironment=DISABLE_CLIENTS=true" : ""}
|
Environment=SITE_SECRET=${secret}${!acceptClients ? "\nEnvironment=DISABLE_CLIENTS=true" : ""}
|
||||||
# Secret=newt-secret,type=env,target=NEWT_SECRET
|
# Secret=pangolin-secret,type=env,target=SITE_SECRET
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Restart=always
|
Restart=always
|
||||||
@@ -233,12 +278,12 @@ Restart=always
|
|||||||
WantedBy=default.target`
|
WantedBy=default.target`
|
||||||
],
|
],
|
||||||
"Podman Run": [
|
"Podman Run": [
|
||||||
`podman run -dit docker.io/fosrl/newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
|
`podman run -dit docker.io/fosrl/pangolin-cli up site --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
nixos: {
|
nixos: {
|
||||||
Flake: [
|
Flake: [
|
||||||
`${runAsRootPrefix}nix run 'nixpkgs#fosrl-newt' -- --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}${disableSshFlag}`
|
`${runAsRootPrefix}nix run 'nixpkgs#pangolin-cli' -- up site --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}${disableSshFlag}`
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -277,9 +322,7 @@ WantedBy=default.target`
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<OptionSelect<string>
|
<OptionSelect<string>
|
||||||
label={
|
label={t("method")}
|
||||||
platform === "windows" ? t("architecture") : t("method")
|
|
||||||
}
|
|
||||||
options={getArchitectures(platform).map((arch) => ({
|
options={getArchitectures(platform).map((arch) => ({
|
||||||
value: arch,
|
value: arch,
|
||||||
label: arch
|
label: arch
|
||||||
@@ -377,14 +420,23 @@ WantedBy=default.target`
|
|||||||
)}
|
)}
|
||||||
<div className="mt-2 space-y-3">
|
<div className="mt-2 space-y-3">
|
||||||
{commands.map((item, index) => {
|
{commands.map((item, index) => {
|
||||||
|
const isLink =
|
||||||
|
typeof item !== "string" && "link" in item;
|
||||||
const commandText =
|
const commandText =
|
||||||
typeof item === "string" ? item : item.command;
|
typeof item === "string"
|
||||||
|
? item
|
||||||
|
: isLink
|
||||||
|
? undefined
|
||||||
|
: item.command;
|
||||||
|
const linkHref = isLink
|
||||||
|
? (item as { link: string }).link
|
||||||
|
: undefined;
|
||||||
const title =
|
const title =
|
||||||
typeof item === "string"
|
typeof item === "string"
|
||||||
? undefined
|
? undefined
|
||||||
: item.title;
|
: item.title;
|
||||||
|
|
||||||
const key = `${title ?? ""}::${commandText}`;
|
const key = `${title ?? ""}::${commandText ?? linkHref}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={key}>
|
<div key={key}>
|
||||||
@@ -393,10 +445,23 @@ WantedBy=default.target`
|
|||||||
{title}
|
{title}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{isLink ? (
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<a href={linkHref}>
|
||||||
|
<Download className="h-4 w-4 mr-2" />
|
||||||
|
{t("downloadInstaller")}
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
<CopyTextBox
|
<CopyTextBox
|
||||||
text={commandText}
|
text={commandText!}
|
||||||
outline={true}
|
outline={true}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -456,11 +521,11 @@ function getPlatformName(platformName: Platform) {
|
|||||||
function getArchitectures(platform: Platform) {
|
function getArchitectures(platform: Platform) {
|
||||||
switch (platform) {
|
switch (platform) {
|
||||||
case "linux":
|
case "linux":
|
||||||
return ["Run", "Systemd Service"];
|
return ["Run", "Auto Systemd Service", "Manual Systemd Service"];
|
||||||
case "macos":
|
case "macos":
|
||||||
return ["Run"];
|
return ["Run", "Service"];
|
||||||
case "windows":
|
case "windows":
|
||||||
return ["x64"];
|
return ["Run", "Service"];
|
||||||
case "docker":
|
case "docker":
|
||||||
return ["Docker Compose", "Docker Run"];
|
return ["Docker Compose", "Docker Run"];
|
||||||
case "kubernetes":
|
case "kubernetes":
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Terminal } from "lucide-react";
|
import { Download, Terminal } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { FaDocker, FaWindows } from "react-icons/fa";
|
import { FaDocker, FaWindows } from "react-icons/fa";
|
||||||
@@ -10,9 +10,13 @@ import {
|
|||||||
SettingsSectionHeader,
|
SettingsSectionHeader,
|
||||||
SettingsSectionTitle
|
SettingsSectionTitle
|
||||||
} from "./Settings";
|
} from "./Settings";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
import { OptionSelect, type OptionSelectOption } from "./OptionSelect";
|
import { OptionSelect, type OptionSelectOption } from "./OptionSelect";
|
||||||
|
|
||||||
export type CommandItem = string | { title: string; command: string };
|
export type CommandItem =
|
||||||
|
| string
|
||||||
|
| { title: string; command: string }
|
||||||
|
| { title: string; link: string };
|
||||||
|
|
||||||
const PLATFORMS = ["unix", "docker", "windows"] as const;
|
const PLATFORMS = ["unix", "docker", "windows"] as const;
|
||||||
|
|
||||||
@@ -22,14 +26,12 @@ export type OlmInstallCommandsProps = {
|
|||||||
id: string;
|
id: string;
|
||||||
secret: string;
|
secret: string;
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
version?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function OlmInstallCommands({
|
export function OlmInstallCommands({
|
||||||
id,
|
id,
|
||||||
secret,
|
secret,
|
||||||
endpoint,
|
endpoint
|
||||||
version = "latest"
|
|
||||||
}: OlmInstallCommandsProps) {
|
}: OlmInstallCommandsProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
@@ -40,14 +42,28 @@ export function OlmInstallCommands({
|
|||||||
|
|
||||||
const commandList: Record<Platform, Record<string, CommandItem[]>> = {
|
const commandList: Record<Platform, Record<string, CommandItem[]>> = {
|
||||||
unix: {
|
unix: {
|
||||||
All: [
|
Run: [
|
||||||
{
|
{
|
||||||
title: t("install"),
|
title: t("install"),
|
||||||
command: `curl -fsSL https://static.pangolin.net/get-cli.sh | sudo bash`
|
command: `curl -fsSL https://static.pangolin.net/get-cli.sh | sudo bash`
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("run"),
|
title: t("run"),
|
||||||
command: `sudo pangolin up --id ${id} --secret ${secret} --endpoint ${endpoint} --attach`
|
command: `sudo pangolin up client --id ${id} --secret ${secret} --endpoint ${endpoint} --attach`
|
||||||
|
}
|
||||||
|
],
|
||||||
|
Service: [
|
||||||
|
{
|
||||||
|
title: t("install"),
|
||||||
|
command: `curl -fsSL https://static.pangolin.net/get-cli.sh | bash`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("run"),
|
||||||
|
command: `sudo pangolin service install client --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("check"),
|
||||||
|
command: `sudo pangolin service status client`
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -73,15 +89,28 @@ export function OlmInstallCommands({
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
windows: {
|
windows: {
|
||||||
x64: [
|
Run: [
|
||||||
{
|
{
|
||||||
title: t("install"),
|
title: t("install"),
|
||||||
command: `# Download and run the installer to install Olm first\n
|
link: `https://github.com/fosrl/cli/releases/latest/download/pangolin-cli_windows_installer.msi`
|
||||||
curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/olm_windows_installer.exe"`
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("run"),
|
title: t("run"),
|
||||||
command: `olm.exe --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
command: `pangolin up client --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||||
|
}
|
||||||
|
],
|
||||||
|
Service: [
|
||||||
|
{
|
||||||
|
title: t("install"),
|
||||||
|
link: `https://github.com/fosrl/cli/releases/latest/download/pangolin-cli_windows_installer.msi`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("run"),
|
||||||
|
command: `pangolin service install client --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("check"),
|
||||||
|
command: `pangolin service status client`
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -121,9 +150,7 @@ curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/ol
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<OptionSelect<string>
|
<OptionSelect<string>
|
||||||
label={
|
label={t("method")}
|
||||||
platform === "docker" ? t("method") : t("architecture")
|
|
||||||
}
|
|
||||||
options={getArchitectures(platform).map((arch) => ({
|
options={getArchitectures(platform).map((arch) => ({
|
||||||
value: arch,
|
value: arch,
|
||||||
label: arch
|
label: arch
|
||||||
@@ -138,8 +165,17 @@ curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/ol
|
|||||||
<p className="font-semibold mb-3">{t("commands")}</p>
|
<p className="font-semibold mb-3">{t("commands")}</p>
|
||||||
<div className="mt-2 space-y-3">
|
<div className="mt-2 space-y-3">
|
||||||
{commands.map((item, index) => {
|
{commands.map((item, index) => {
|
||||||
|
const isLink =
|
||||||
|
typeof item !== "string" && "link" in item;
|
||||||
const commandText =
|
const commandText =
|
||||||
typeof item === "string" ? item : item.command;
|
typeof item === "string"
|
||||||
|
? item
|
||||||
|
: isLink
|
||||||
|
? undefined
|
||||||
|
: item.command;
|
||||||
|
const linkHref = isLink
|
||||||
|
? (item as { link: string }).link
|
||||||
|
: undefined;
|
||||||
const title =
|
const title =
|
||||||
typeof item === "string"
|
typeof item === "string"
|
||||||
? undefined
|
? undefined
|
||||||
@@ -152,10 +188,23 @@ curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/ol
|
|||||||
{title}
|
{title}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{isLink ? (
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<a href={linkHref}>
|
||||||
|
<Download className="h-4 w-4 mr-2" />
|
||||||
|
{t("downloadInstaller")}
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
<CopyTextBox
|
<CopyTextBox
|
||||||
text={commandText}
|
text={commandText!}
|
||||||
outline={true}
|
outline={true}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -169,13 +218,13 @@ curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/ol
|
|||||||
function getArchitectures(platform: Platform) {
|
function getArchitectures(platform: Platform) {
|
||||||
switch (platform) {
|
switch (platform) {
|
||||||
case "unix":
|
case "unix":
|
||||||
return ["All"];
|
return ["Run", "Service"];
|
||||||
case "windows":
|
case "windows":
|
||||||
return ["x64"];
|
return ["Run", "Service"];
|
||||||
case "docker":
|
case "docker":
|
||||||
return ["Docker Compose", "Docker Run"];
|
return ["Docker Compose", "Docker Run"];
|
||||||
default:
|
default:
|
||||||
return ["x64"];
|
return ["Run"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -1,4 +1,3 @@
|
|||||||
import { build } from "@server/build";
|
|
||||||
import { Env } from "./types/env";
|
import { Env } from "./types/env";
|
||||||
|
|
||||||
export function pullEnv(): Env {
|
export function pullEnv(): Env {
|
||||||
@@ -35,7 +34,9 @@ export function pullEnv(): Env {
|
|||||||
: false
|
: false
|
||||||
},
|
},
|
||||||
identityProviderMode: process.env.IDENTITY_PROVIDER_MODE as
|
identityProviderMode: process.env.IDENTITY_PROVIDER_MODE as
|
||||||
"org" | "global" | undefined
|
| "org"
|
||||||
|
| "global"
|
||||||
|
| undefined
|
||||||
},
|
},
|
||||||
email: {
|
email: {
|
||||||
emailEnabled: process.env.EMAIL_ENABLED === "true" ? true : false
|
emailEnabled: process.env.EMAIL_ENABLED === "true" ? true : false
|
||||||
|
|||||||
Reference in New Issue
Block a user