From 985b80e2c62d04a24da81d739cfa59d87ede2c5b Mon Sep 17 00:00:00 2001 From: mattv8 Date: Thu, 5 Feb 2026 16:43:20 -0700 Subject: [PATCH 01/10] DNS authority management and features --- messages/en-US.json | 41 ++ server/db/pg/schema/schema.ts | 13 +- server/db/sqlite/schema/schema.ts | 17 +- server/internalServer.ts | 11 +- server/lib/jwtKeypair.ts | 131 ++++ server/openApi.ts | 3 +- server/routers/auth/authProxy.ts | 275 ++++++++ server/routers/auth/index.ts | 4 +- server/routers/auth/validateSession.ts | 132 ++++ server/routers/dns/dnsAuthority.ts | 616 ++++++++++++++++++ server/routers/external.ts | 3 + server/routers/internal.ts | 9 + server/routers/newt/getNewtToken.ts | 72 +- server/routers/newt/handleDnsStatusMessage.ts | 43 ++ .../routers/newt/handleNewtRegisterMessage.ts | 39 ++ server/routers/newt/index.ts | 1 + server/routers/olm/sync.ts | 2 +- server/routers/resource/updateResource.ts | 62 +- server/routers/site/getSite.ts | 6 +- server/routers/site/listSites.ts | 5 +- server/routers/site/updateSite.ts | 49 +- server/routers/target/createTarget.ts | 15 + server/routers/target/deleteTarget.ts | 12 + .../target/handleHealthcheckStatusMessage.ts | 12 + server/routers/target/updateTarget.ts | 16 + server/routers/ws/messageHandlers.ts | 3 + server/setup/index.ts | 2 + server/setup/migrationsPg.ts | 4 +- server/setup/migrationsSqlite.ts | 4 +- server/setup/scriptsPg/1.16.1.ts | 104 +++ server/setup/scriptsSqlite/1.16.1.ts | 95 +++ .../resources/proxy/[niceId]/proxy/page.tsx | 8 + .../settings/sites/[niceId]/general/page.tsx | 144 +++- src/app/[orgId]/settings/sites/page.tsx | 3 +- src/components/DNSAuthorityForm.tsx | 330 ++++++++++ src/components/SiteInfoCard.tsx | 39 +- src/components/SitesTable.tsx | 56 ++ 37 files changed, 2357 insertions(+), 24 deletions(-) create mode 100644 server/lib/jwtKeypair.ts create mode 100644 server/routers/auth/authProxy.ts create mode 100644 server/routers/auth/validateSession.ts create mode 100644 server/routers/dns/dnsAuthority.ts create mode 100644 server/routers/newt/handleDnsStatusMessage.ts create mode 100644 server/setup/scriptsPg/1.16.1.ts create mode 100644 server/setup/scriptsSqlite/1.16.1.ts create mode 100644 src/components/DNSAuthorityForm.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 7642419c6..759f0e096 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -97,6 +97,18 @@ "siteUpdated": "Site updated", "siteUpdatedDescription": "The site has been updated.", "siteGeneralDescription": "Configure the general settings for this site", + "sitePublicIp": "Public IP Address", + "sitePublicIpDescription": "The public IP address of this site. Used for DNS authority responses when this site serves as an authoritative DNS server.", + "sitePublicIpPlaceholder": "e.g., 203.0.113.1", + "siteDnsAuthorityEnable": "Enable DNS Authority", + "siteDnsAuthorityDescription": "When enabled, this site can bind to port 53 and serve authoritative DNS responses for resources with DNS Authority enabled.", + "siteDnsAuthorityLocalOverrideWarning": "Important: If this site's Newt agent was started with the --disable-dns-authority flag, it will ignore this setting and the DNS server will not start. Check your Newt service configuration if the site stays offline for DNS.", + "siteDnsAuthorityStatus": "DNS Authority Real-time Status", + "siteDnsAuthorityStatusRunning": "Running", + "siteDnsAuthorityStatusWarning": "Warning", + "siteDnsAuthorityStatusError": "Error", + "siteDnsAuthorityStatusDisabled": "Disabled", + "siteDnsAuthorityStatusUnknown": "Unknown", "siteSettingDescription": "Configure the settings on the site", "siteSetting": "{siteName} Settings", "siteNewtTunnel": "Newt Site (Recommended)", @@ -612,6 +624,35 @@ "targetsDescription": "Set up targets to route traffic to backend services", "targetStickySessions": "Enable Sticky Sessions", "targetStickySessionsDescription": "Keep connections on the same backend target for their entire session.", + "dnsAuthority": "Intelligent DNS Routing", + "dnsAuthorityShort": "DNS Authority", + "dnsAuthorityDescription": "Enable authoritative DNS responses for this resource with health-based failover", + "dnsAuthorityEnable": "Enable DNS Authority", + "dnsAuthorityEnableDescription": "When enabled, your sites can serve as authoritative DNS servers for this resource, routing based on target health", + "dnsAuthorityUpdated": "DNS Authority Updated", + "dnsAuthorityUpdatedDescription": "DNS authority settings have been saved", + "dnsAuthorityUpdateError": "Failed to update DNS authority settings", + "dnsAuthorityLocalOverrideNote": "Sites participating in DNS Authority must not have been installed with the --disable-dns-authority flag. If it was, the Newt agent must be reinstalled without it.", + "dnsAuthorityRequirements": "To use DNS Authority, ensure the following:", + "dnsAuthorityRequirement1": "Each target site has a public IP configured and DNS Authority enabled", + "dnsAuthorityRequirement2": "Port 53 (UDP/TCP) is open on your site's firewall", + "dnsAuthorityRequirement3": "NS records are configured at your domain registrar pointing to your sites", + "dnsAuthoritySsoNote": "SSO Protection Enabled", + "dnsAuthoritySsoDescription": "This resource has SSO enabled. When DNS Authority routes traffic directly to sites, authentication will still be enforced by a distributed auth proxy running on each site. Users will be redirected to Pangolin for login.", + "dnsAuthorityRoutingPolicy": "Routing Policy", + "dnsAuthorityRoutingPolicyTooltip": "Determines how DNS responses are selected based on target health status", + "dnsAuthorityPolicyFailover": "Failover", + "dnsAuthorityPolicyRoundRobin": "Round Robin", + "dnsAuthorityPolicyPriority": "All Healthy", + "dnsAuthorityPolicyFailoverDescription": "Returns only the highest-priority healthy target. Falls back to the next priority if unhealthy.", + "dnsAuthorityPolicyRoundRobinDescription": "Rotates through all healthy targets for load distribution.", + "dnsAuthorityPolicyPriorityDescription": "Returns all healthy targets, letting the client choose.", + "dnsAuthorityPolicyNoHealthChecks": "Failover and All Healthy require health checks to be configured on your targets. Enable health checks on at least one target to unlock these options.", + "dnsAuthorityTtl": "DNS TTL (seconds)", + "dnsAuthorityTtlDescription": "Time-to-live for DNS responses. Lower values enable faster failover but increase DNS queries.", + "dnsAuthorityNsRecords": "Required DNS Records", + "dnsAuthorityNsRecordsDescription": "Add these records at your domain registrar to delegate DNS to your sites:", + "dnsAuthorityNsRecordsNote": "Replace [Your Site Public IP] with the public IP of each site that has DNS Authority enabled.", "methodSelect": "Select method", "targetSubmit": "Add Target", "targetNoOne": "This resource doesn't have any targets. Add a target to configure where to send requests to the backend.", diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index bde3e9aec..3c5351c55 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -101,6 +101,11 @@ export const sites = pgTable("sites", { lastHolePunch: bigint("lastHolePunch", { mode: "number" }), listenPort: integer("listenPort"), dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true), + // DNS Authority fields - for sites that can serve as authoritative DNS + publicIp: varchar("publicIp"), // Public IP address for DNS authority responses + dnsAuthorityEnabled: boolean("dnsAuthorityEnabled").notNull().default(false), // Whether this site serves as DNS authority + dnsStatus: varchar("dnsStatus"), // "running", "warning", "error", "disabled" + dnsError: text("dnsError"), // Error message if status is error/warning status: varchar("status").$type<"pending" | "approved">().default("approved") }); @@ -153,7 +158,13 @@ export const resources = pgTable("resources", { maintenanceTitle: text("maintenanceTitle"), maintenanceMessage: text("maintenanceMessage"), maintenanceEstimatedTime: text("maintenanceEstimatedTime"), - postAuthPath: text("postAuthPath") + postAuthPath: text("postAuthPath"), + // DNS Authority fields - for intelligent DNS routing based on health checks + dnsAuthorityEnabled: boolean("dnsAuthorityEnabled").notNull().default(false), // Enable DNS authority for this resource + dnsAuthorityTtl: integer("dnsAuthorityTtl").default(60), // TTL for DNS responses in seconds + dnsAuthorityRoutingPolicy: text("dnsAuthorityRoutingPolicy", { + enum: ["failover", "roundrobin", "priority"] + }).default("failover") // Routing policy based on health checks }); export const targets = pgTable("targets", { diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 1fb04ef14..5157dc3f7 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -111,6 +111,13 @@ export const sites = sqliteTable("sites", { dockerSocketEnabled: integer("dockerSocketEnabled", { mode: "boolean" }) .notNull() .default(true), + // DNS Authority fields - for sites that can serve as authoritative DNS + publicIp: text("publicIp"), // Public IP address for DNS authority responses + dnsAuthorityEnabled: integer("dnsAuthorityEnabled", { mode: "boolean" }) + .notNull() + .default(false), // Whether this site serves as DNS authority + dnsStatus: text("dnsStatus"), // "running", "warning", "error", "disabled" + dnsError: text("dnsError"), // Error message if status is error/warning status: text("status").$type<"pending" | "approved">().default("approved") }); @@ -173,7 +180,15 @@ export const resources = sqliteTable("resources", { maintenanceTitle: text("maintenanceTitle"), maintenanceMessage: text("maintenanceMessage"), maintenanceEstimatedTime: text("maintenanceEstimatedTime"), - postAuthPath: text("postAuthPath") + postAuthPath: text("postAuthPath"), + // DNS Authority fields - for intelligent DNS routing based on health checks + dnsAuthorityEnabled: integer("dnsAuthorityEnabled", { mode: "boolean" }) + .notNull() + .default(false), // Enable DNS authority for this resource + dnsAuthorityTtl: integer("dnsAuthorityTtl").default(60), // TTL for DNS responses in seconds + dnsAuthorityRoutingPolicy: text("dnsAuthorityRoutingPolicy", { + enum: ["failover", "roundrobin", "priority"] + }).default("failover") // Routing policy based on health checks }); export const targets = sqliteTable("targets", { diff --git a/server/internalServer.ts b/server/internalServer.ts index 7ba046e4b..4928adb83 100644 --- a/server/internalServer.ts +++ b/server/internalServer.ts @@ -10,6 +10,7 @@ import { } from "@server/middlewares"; import { internalRouter } from "#dynamic/routers/internal"; import { stripDuplicateSesions } from "./middlewares/stripDuplicateSessions"; +import { router as wsRouter, handleWSUpgrade } from "#dynamic/routers/ws"; const internalPort = config.getRawConfig().server.internal_port; @@ -30,15 +31,21 @@ export function createInternalServer() { const prefix = `/api/v1`; internalServer.use(prefix, internalRouter); + // WebSocket routes + internalServer.use(prefix, wsRouter); + internalServer.use(notFoundMiddleware); internalServer.use(errorHandlerMiddleware); - internalServer.listen(internalPort, (err?: any) => { + const httpServer = internalServer.listen(internalPort, (err?: any) => { if (err) throw err; logger.info( `Internal server is running on http://localhost:${internalPort}` ); }); - return internalServer; + // Handle WebSocket upgrades + handleWSUpgrade(httpServer); + + return httpServer; } diff --git a/server/lib/jwtKeypair.ts b/server/lib/jwtKeypair.ts new file mode 100644 index 000000000..d58300efb --- /dev/null +++ b/server/lib/jwtKeypair.ts @@ -0,0 +1,131 @@ +import crypto from "crypto"; +import fs from "fs"; +import path from "path"; +import { APP_PATH } from "@server/lib/consts"; +import logger from "@server/logger"; + +const AUTH_DIR = path.join(APP_PATH, "auth"); +const PRIVATE_KEY_PATH = path.join(AUTH_DIR, "jwt_private.pem"); +const PUBLIC_KEY_PATH = path.join(AUTH_DIR, "jwt_public.pem"); + +let cachedPrivateKey: crypto.KeyObject | null = null; +let cachedPublicKeyPem: string | null = null; + +/** + * Generate a new RSA keypair for JWT signing and save to disk. + * Private key is stored with restricted permissions (0o600). + * Public key is readable (0o644) and sent to Newt for local JWT verification. + */ +export function generateJwtKeypair(): { + privateKey: crypto.KeyObject; + publicKeyPem: string; +} { + logger.info("Generating new RSA keypair for JWT signing..."); + + const { publicKey, privateKey } = crypto.generateKeyPairSync("rsa", { + modulusLength: 2048 + }); + + const privateKeyPem = privateKey + .export({ type: "pkcs8", format: "pem" }) + .toString(); + const publicKeyPem = publicKey + .export({ type: "spki", format: "pem" }) + .toString(); + + // Ensure auth directory exists + if (!fs.existsSync(AUTH_DIR)) { + fs.mkdirSync(AUTH_DIR, { recursive: true }); + } + + // Write keys with appropriate permissions + fs.writeFileSync(PRIVATE_KEY_PATH, privateKeyPem, { mode: 0o600 }); + fs.writeFileSync(PUBLIC_KEY_PATH, publicKeyPem, { mode: 0o644 }); + + logger.info("JWT keypair generated and saved to %s", AUTH_DIR); + + cachedPrivateKey = privateKey; + cachedPublicKeyPem = publicKeyPem; + + return { privateKey, publicKeyPem }; +} + +/** + * Load the JWT keypair from disk. Returns null if files don't exist. + */ +export function loadJwtKeypair(): { + privateKey: crypto.KeyObject; + publicKeyPem: string; +} | null { + if (!fs.existsSync(PRIVATE_KEY_PATH) || !fs.existsSync(PUBLIC_KEY_PATH)) { + return null; + } + + try { + const privateKeyPem = fs.readFileSync(PRIVATE_KEY_PATH, "utf-8"); + const publicKeyPem = fs.readFileSync(PUBLIC_KEY_PATH, "utf-8"); + + const privateKey = crypto.createPrivateKey(privateKeyPem); + + cachedPrivateKey = privateKey; + cachedPublicKeyPem = publicKeyPem; + + return { privateKey, publicKeyPem }; + } catch (err) { + logger.error("Failed to load JWT keypair: %s", err); + return null; + } +} + +/** + * Ensure the JWT keypair exists. Load from disk or generate if missing. + */ +export function ensureJwtKeypair(): { + privateKey: crypto.KeyObject; + publicKeyPem: string; +} { + const existing = loadJwtKeypair(); + if (existing) { + logger.debug("JWT keypair loaded from %s", AUTH_DIR); + return existing; + } + return generateJwtKeypair(); +} + +/** + * Get the cached public key PEM string for sending to Newt. + * Call ensureJwtKeypair() during startup before using this. + */ +export function getJwtPublicKeyPem(): string { + if (cachedPublicKeyPem) { + return cachedPublicKeyPem; + } + + // Try loading from disk as fallback + const loaded = loadJwtKeypair(); + if (loaded) { + return loaded.publicKeyPem; + } + + logger.warn("JWT public key not available — keypair not yet generated"); + return ""; +} + +/** + * Get the cached private key for signing JWTs. + * Call ensureJwtKeypair() during startup before using this. + */ +export function getJwtPrivateKey(): crypto.KeyObject | null { + if (cachedPrivateKey) { + return cachedPrivateKey; + } + + // Try loading from disk as fallback + const loaded = loadJwtKeypair(); + if (loaded) { + return loaded.privateKey; + } + + logger.warn("JWT private key not available — keypair not yet generated"); + return null; +} diff --git a/server/openApi.ts b/server/openApi.ts index 26c9e2f2e..0820cb6dd 100644 --- a/server/openApi.ts +++ b/server/openApi.ts @@ -20,5 +20,6 @@ export enum OpenAPITags { Domain = "Domain", Blueprint = "Blueprint", Ssh = "SSH", - Logs = "Logs" + Logs = "Logs", + Auth = "Auth" } diff --git a/server/routers/auth/authProxy.ts b/server/routers/auth/authProxy.ts new file mode 100644 index 000000000..d5cac4a59 --- /dev/null +++ b/server/routers/auth/authProxy.ts @@ -0,0 +1,275 @@ +import { sendToClient } from "#dynamic/routers/ws"; +import { db } from "@server/db"; +import { + sites, + resources, + targets, + orgs, + resourceWhitelist, + newts +} from "@server/db"; +import logger from "@server/logger"; +import { eq, and, inArray } from "drizzle-orm"; +import config from "@server/lib/config"; +import { getJwtPublicKeyPem } from "@server/lib/jwtKeypair"; + +// AuthConfig holds the global authentication configuration for a site +interface AuthConfig { + enabled: boolean; + pangolinUrl: string; + jwtPublicKey: string; + cookieName: string; + cookieDomain: string; + sessionValidationUrl: string; +} + +// ResourceAuthConfig holds auth configuration for a specific resource +interface ResourceAuthConfig { + resourceId: number; + domain: string; + sso: boolean; + blockAccess: boolean; + emailWhitelistEnabled: boolean; + allowedEmails: string[]; + targetUrl: string; + ssl: boolean; +} + +// AuthProxyConfigMessage represents the message to send to Newt +interface AuthProxyConfigMessage { + action: "update" | "remove" | "start" | "stop"; + auth: AuthConfig; + resources: ResourceAuthConfig[]; +} + +/** + * Build auth proxy configuration for resources on a site + */ +export async function buildAuthProxyConfig( + siteId: number +): Promise { + // Get the site + const [site] = await db + .select() + .from(sites) + .where(eq(sites.siteId, siteId)) + .limit(1); + + if (!site) { + return null; + } + + // Get the org for this site + const [org] = await db + .select() + .from(orgs) + .where(eq(orgs.orgId, site.orgId)) + .limit(1); + + if (!org) { + return null; + } + + // Get all resources that have targets on this site with SSO or access control enabled + const siteTargets = await db + .select({ + resourceId: targets.resourceId, + siteId: targets.siteId, + targetIp: targets.ip, + targetPort: targets.port, + targetMethod: targets.method, + resourceName: resources.name, + fullDomain: resources.fullDomain, + sso: resources.sso, + blockAccess: resources.blockAccess, + emailWhitelistEnabled: resources.emailWhitelistEnabled, + ssl: resources.ssl, + http: resources.http, + dnsAuthorityEnabled: resources.dnsAuthorityEnabled + }) + .from(targets) + .innerJoin(resources, eq(targets.resourceId, resources.resourceId)) + .where( + and( + eq(targets.siteId, siteId), + eq(targets.enabled, true) + ) + ); + + // Filter to only resources with DNS authority and SSO/protection enabled + const protectedResources = siteTargets.filter( + (t: typeof siteTargets[0]) => t.dnsAuthorityEnabled && (t.sso || t.blockAccess || t.emailWhitelistEnabled) + ); + + if (protectedResources.length === 0) { + return null; + } + + // Build the auth config + const dashboardUrl = config.getRawConfig().app.dashboard_url; + if (!dashboardUrl) { + return null; + } + + const resolvedDashboardUrl: string = dashboardUrl; + + const authConfig: AuthConfig = { + enabled: true, + pangolinUrl: resolvedDashboardUrl, + jwtPublicKey: getJwtPublicKeyPem(), + cookieName: "p_session", + cookieDomain: extractBaseDomain(resolvedDashboardUrl), + sessionValidationUrl: `${resolvedDashboardUrl}/api/v1/auth/session/validate` + }; + + // Build resource configs + const resourceConfigs: ResourceAuthConfig[] = []; + + for (const target of protectedResources) { + if (!target.fullDomain) continue; + + // Get email whitelist for this resource + let allowedEmails: string[] = []; + if (target.emailWhitelistEnabled) { + const whitelist = await db + .select() + .from(resourceWhitelist) + .where(eq(resourceWhitelist.resourceId, target.resourceId)); + + allowedEmails = whitelist.map((w: typeof whitelist[0]) => w.email); + } + + // Build target URL + const scheme = target.ssl ? "https" : "http"; + const targetUrl = `${scheme}://${target.targetIp}:${target.targetPort}`; + + resourceConfigs.push({ + resourceId: target.resourceId, + domain: target.fullDomain, + sso: target.sso || false, + blockAccess: target.blockAccess || false, + emailWhitelistEnabled: target.emailWhitelistEnabled || false, + allowedEmails, + targetUrl, + ssl: target.ssl || false + }); + } + + return { + action: "update", + auth: authConfig, + resources: resourceConfigs + }; +} + +function buildEmptyAuthProxyConfigMessage(): AuthProxyConfigMessage { + const dashboardUrl = config.getRawConfig().app.dashboard_url || ""; + + return { + action: "update", + auth: { + enabled: true, + pangolinUrl: dashboardUrl, + jwtPublicKey: getJwtPublicKeyPem(), + cookieName: "p_session", + cookieDomain: extractBaseDomain(dashboardUrl), + sessionValidationUrl: `${dashboardUrl}/api/v1/auth/session/validate` + }, + resources: [] + }; +} + +/** + * Send auth proxy configuration to a Newt instance + */ +export async function sendAuthProxyConfigToNewt( + newtId: string, + config: AuthProxyConfigMessage +) { + try { + await sendToClient(newtId, { + type: "newt/auth/proxy/config", + data: config + }); + logger.debug(`Sent auth proxy config to Newt ${newtId}`); + } catch (error) { + logger.error(`Failed to send auth proxy config to Newt ${newtId}:`, error); + } +} + +/** + * Update auth proxy config for all Newts serving a resource + */ +export async function updateAuthProxyForResource(resourceId: number) { + // Get all sites that have targets for this resource + const resourceTargets = await db + .select({ + siteId: targets.siteId, + newtId: newts.newtId + }) + .from(targets) + .innerJoin(sites, eq(targets.siteId, sites.siteId)) + .innerJoin(newts, eq(sites.siteId, newts.siteId)) + .where( + eq(targets.resourceId, resourceId) + ); + + // Deduplicate by site + const siteIds = [...new Set(resourceTargets.map((t: typeof resourceTargets[0]) => t.siteId))]; + + for (const siteId of siteIds) { + const target = resourceTargets.find((t: typeof resourceTargets[0]) => t.siteId === siteId); + if (!target?.newtId) { + continue; + } + + const config = await buildAuthProxyConfig(siteId as number); + if (config) { + await sendAuthProxyConfigToNewt(target.newtId, config); + } else { + await sendAuthProxyConfigToNewt(target.newtId, buildEmptyAuthProxyConfigMessage()); + } + } +} + +/** + * Update auth proxy config for a site when its settings change + */ +export async function updateAuthProxyForSite(siteId: number) { + const [site] = await db + .select({ + newtId: newts.newtId + }) + .from(sites) + .innerJoin(newts, eq(sites.siteId, newts.siteId)) + .where(eq(sites.siteId, siteId)) + .limit(1); + + if (!site?.newtId) { + return; + } + + const config = await buildAuthProxyConfig(siteId); + if (config) { + await sendAuthProxyConfigToNewt(site.newtId, config); + } else { + await sendAuthProxyConfigToNewt(site.newtId, buildEmptyAuthProxyConfigMessage()); + } +} + +/** + * Extract base domain from URL for cookie domain + */ +function extractBaseDomain(url: string): string { + try { + const parsed = new URL(url); + const parts = parsed.hostname.split("."); + // Return last two parts (e.g., example.com from sub.example.com) + if (parts.length >= 2) { + return "." + parts.slice(-2).join("."); + } + return parsed.hostname; + } catch { + return ""; + } +} diff --git a/server/routers/auth/index.ts b/server/routers/auth/index.ts index 7a469aa13..6119ef28d 100644 --- a/server/routers/auth/index.ts +++ b/server/routers/auth/index.ts @@ -18,4 +18,6 @@ export * from "./startDeviceWebAuth"; export * from "./verifyDeviceWebAuth"; export * from "./pollDeviceWebAuth"; export * from "./lookupUser"; -export * from "./deleteMyAccount"; \ No newline at end of file +export * from "./deleteMyAccount"; +export * from "./validateSession"; +export * from "./authProxy"; diff --git a/server/routers/auth/validateSession.ts b/server/routers/auth/validateSession.ts new file mode 100644 index 000000000..d478d6421 --- /dev/null +++ b/server/routers/auth/validateSession.ts @@ -0,0 +1,132 @@ +import { Request, Response, NextFunction } from "express"; +import { db, sessions, users } from "@server/db"; +import { eq, and, gt } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import logger from "@server/logger"; +import { registry } from "@server/openApi"; +import { OpenAPITags } from "@server/openApi"; +import { z } from "zod"; +import { sha256 } from "@oslojs/crypto/sha2"; +import { encodeHexLowerCase } from "@oslojs/encoding"; + +export interface SessionValidationResponse { + valid: boolean; + userId?: string; + email?: string; + orgId?: string; + expiresAt?: string; +} + +registry.registerPath({ + method: "get", + path: "/auth/session/validate", + description: + "Validate a session token. Used by Newt auth proxy to verify user sessions.", + tags: [OpenAPITags.Auth], + responses: {} +}); + +/** + * Validate a session from cookie or Authorization header + * This endpoint is called by Newt to validate user sessions for SSO protection + */ +export async function validateSession( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + // Get session token from cookie or header + const sessionToken = + req.cookies?.p_session || + req.headers.authorization?.replace("Bearer ", ""); + + if (!sessionToken) { + return response(res, { + data: { valid: false }, + success: true, + error: false, + message: "No session token provided", + status: HttpCode.OK + }); + } + + // Look up the session in the database + // Session ID is the SHA256 hash of the session token + const sessionId = encodeHexLowerCase( + sha256(new TextEncoder().encode(sessionToken)) + ); + + const now = new Date(); + const [session] = await db + .select({ + sessionId: sessions.sessionId, + userId: sessions.userId, + expiresAt: sessions.expiresAt + }) + .from(sessions) + .where( + and( + eq(sessions.sessionId, sessionId), + gt(sessions.expiresAt, now.getTime()) + ) + ) + .limit(1); + + if (!session) { + return response(res, { + data: { valid: false }, + success: true, + error: false, + message: "Invalid or expired session", + status: HttpCode.OK + }); + } + + // Get user info + const [user] = await db + .select({ + email: users.email, + userId: users.userId + }) + .from(users) + .where(eq(users.userId, session.userId)) + .limit(1); + + if (!user) { + return response(res, { + data: { valid: false }, + success: true, + error: false, + message: "User not found", + status: HttpCode.OK + }); + } + + // Get user's primary org (for now, just return the session is valid) + // TODO: Include org membership and resource access info + + return response(res, { + data: { + valid: true, + userId: user.userId, + email: user.email || undefined, + expiresAt: new Date(session.expiresAt).toISOString() + }, + success: true, + error: false, + message: "Session valid", + status: HttpCode.OK + }); + } catch (error) { + logger.error("Session validation error:", error); + return response(res, { + data: { valid: false }, + success: false, + error: true, + message: "Session validation failed", + status: HttpCode.INTERNAL_SERVER_ERROR + }); + } +} diff --git a/server/routers/dns/dnsAuthority.ts b/server/routers/dns/dnsAuthority.ts new file mode 100644 index 000000000..9c6b0f974 --- /dev/null +++ b/server/routers/dns/dnsAuthority.ts @@ -0,0 +1,616 @@ +import { sendToClient } from "#dynamic/routers/ws"; +import { db } from "@server/db"; +import { sites, resources, targets, targetHealthCheck, newts, domains } from "@server/db"; +import logger from "@server/logger"; +import { eq, and, inArray } from "drizzle-orm"; + +// DNSAuthorityTarget represents a target IP with health status +interface DNSAuthorityTarget { + ip: string; + priority: number; + healthy: boolean; + siteId: number; + siteName: string; +} + +// DNSAuthorityConfig holds configuration for a DNS authority zone +interface DNSAuthorityConfig { + enabled: boolean; + domain: string; + ttl: number; + routingPolicy: string; + targets: DNSAuthorityTarget[]; +} + +// DNSAuthorityConfigMessage represents the message to send to Newt +interface DNSAuthorityConfigMessage { + action: "update" | "remove" | "start" | "stop"; + zones: DNSAuthorityConfig[]; +} + +/** + * Build DNS authority configuration for a resource based on its targets + */ +export async function buildDNSAuthorityConfig( + resourceId: number +): Promise { + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!resource) { + return null; + } + + // Check if DNS authority is enabled for this resource + if (!resource.dnsAuthorityEnabled) { + return null; + } + + // Get the domain for this resource + const domain = resource.fullDomain; + if (!domain) { + logger.warn( + `Resource ${resourceId} has DNS authority enabled but no domain configured` + ); + return null; + } + + // Get all targets for this resource with their health check status + const resourceTargets = await db + .select({ + targetId: targets.targetId, + siteId: targets.siteId, + ip: targets.ip, + port: targets.port, + enabled: targets.enabled, + priority: targets.priority, + siteName: sites.name, + sitePublicIp: sites.publicIp, + siteDnsAuthorityEnabled: sites.dnsAuthorityEnabled, + hcEnabled: targetHealthCheck.hcEnabled, + hcHealth: targetHealthCheck.hcHealth + }) + .from(targets) + .innerJoin(sites, eq(targets.siteId, sites.siteId)) + .leftJoin( + targetHealthCheck, + eq(targets.targetId, targetHealthCheck.targetId) + ) + .where(eq(targets.resourceId, resourceId)); + + // Filter to only enabled targets that have sites with DNS authority enabled and public IPs + const validTargets = resourceTargets.filter( + (t: typeof resourceTargets[number]) => + t.enabled && + t.sitePublicIp && + t.siteDnsAuthorityEnabled + ); + + if (validTargets.length === 0) { + logger.debug( + `Resource ${resourceId} has no valid DNS authority targets (no sites with public IPs or DNS authority enabled)` + ); + return null; + } + + const dnsTargets: DNSAuthorityTarget[] = validTargets.map((t: typeof resourceTargets[number]) => ({ + ip: t.sitePublicIp!, // Public IP of the site, not the internal target IP + priority: t.priority || 100, + healthy: t.hcEnabled ? t.hcHealth === "healthy" : true, // If no health check, assume healthy + siteId: t.siteId, + siteName: t.siteName || `Site ${t.siteId}` + })); + + return { + enabled: true, + domain: domain, + ttl: resource.dnsAuthorityTtl || 60, + routingPolicy: resource.dnsAuthorityRoutingPolicy || "failover", + targets: dnsTargets + }; +} + +/** + * Get all NEWT IDs that should serve DNS authority for a resource + */ +export async function getDNSAuthoritySiteNewtIds( + resourceId: number +): Promise<{ newtId: string; siteId: number }[]> { + const resourceTargets = await db + .select({ + siteId: targets.siteId, + newtId: newts.newtId, + sitePublicIp: sites.publicIp, + siteDnsAuthorityEnabled: sites.dnsAuthorityEnabled + }) + .from(targets) + .innerJoin(sites, eq(targets.siteId, sites.siteId)) + .innerJoin(newts, eq(sites.siteId, newts.siteId)) + .where( + and( + eq(targets.resourceId, resourceId), + eq(targets.enabled, true) + ) + ); + + // Filter to sites with DNS authority enabled + return resourceTargets + .filter((t: typeof resourceTargets[number]) => t.sitePublicIp && t.siteDnsAuthorityEnabled) + .map((t: typeof resourceTargets[number]) => ({ + newtId: t.newtId || "", + siteId: t.siteId + })) + .filter((t: { newtId: string; siteId: number }) => t.newtId); +} + +/** + * Send DNS authority configuration update to a NEWT instance + */ +export async function sendDNSAuthorityConfigToNewt( + newtId: string, + config: DNSAuthorityConfigMessage +) { + try { + await sendToClient(newtId, { + type: "newt/dns/authority/config", + data: config + }); + logger.debug( + `Sent DNS authority config to NEWT ${newtId}: ${config.action} with ${config.zones.length} zones` + ); + } catch (error) { + logger.warn(`Error sending DNS authority config to NEWT ${newtId}:`, error); + } +} + +/** + * Update DNS authority configuration for all affected NEWT instances + * when a resource is updated. + */ +export async function updateDNSAuthorityForResource(resourceId: number) { + const config = await buildDNSAuthorityConfig(resourceId); + + // Get all NEWT instances that should serve this resource + const newtSites = await getDNSAuthoritySiteNewtIds(resourceId); + + for (const { newtId } of newtSites) { + if (config) { + await sendDNSAuthorityConfigToNewt(newtId, { + action: "update", + zones: [config] + }); + } else { + // DNS authority disabled for this resource, remove the zone + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (resource?.fullDomain) { + await sendDNSAuthorityConfigToNewt(newtId, { + action: "remove", + zones: [{ domain: resource.fullDomain } as DNSAuthorityConfig] + }); + } + } + } +} + +/** + * Update DNS authority health status when a target's health changes + * This is called from handleHealthcheckStatusMessage + */ +export async function updateDNSAuthorityHealthForTarget( + targetId: number, + newHealthStatus: string +) { + // Get the target and its resource + const [targetInfo] = await db + .select({ + resourceId: targets.resourceId, + siteId: targets.siteId, + fullDomain: resources.fullDomain, + dnsAuthorityEnabled: resources.dnsAuthorityEnabled + }) + .from(targets) + .innerJoin(resources, eq(targets.resourceId, resources.resourceId)) + .where(eq(targets.targetId, targetId)) + .limit(1); + + if (!targetInfo || !targetInfo.dnsAuthorityEnabled) { + return; + } + + // Rebuild and send updated config to all affected sites + await updateDNSAuthorityForResource(targetInfo.resourceId); +} + +/** + * Handle health check updates for multiple targets + * This is called from handleHealthcheckStatusMessage after target health statuses are updated + */ +export async function onHealthCheckUpdate(targetIds: number[]) { + // Deduplicate by resource ID to avoid sending multiple updates for the same resource + const resourceIds = new Set(); + const domainIds = new Set(); + + for (const targetId of targetIds) { + const [targetInfo] = await db + .select({ + resourceId: targets.resourceId, + dnsAuthorityEnabled: resources.dnsAuthorityEnabled, + domainId: resources.domainId + }) + .from(targets) + .innerJoin(resources, eq(targets.resourceId, resources.resourceId)) + .where(eq(targets.targetId, targetId)) + .limit(1); + + if (targetInfo?.dnsAuthorityEnabled) { + resourceIds.add(targetInfo.resourceId); + } + + // Always check domain-level zones regardless of per-resource setting + if (targetInfo?.domainId) { + domainIds.add(targetInfo.domainId); + } + } + + // Update per-resource DNS authority configs + for (const resourceId of resourceIds) { + await updateDNSAuthorityForResource(resourceId); + } + + // Update domain-level wildcard DNS authority configs + for (const domainId of domainIds) { + // Only update wildcard domains + const [domain] = await db + .select() + .from(domains) + .where(and(eq(domains.domainId, domainId), eq(domains.type, "wildcard"))) + .limit(1); + + if (domain) { + await updateDNSAuthorityForDomain(domainId); + } + } +} + +// ============================================================================ +// Domain-level DNS Authority +// ============================================================================ +// When a site enables DNS Authority, ALL wildcard domains that have resources +// with targets on that site automatically get a wildcard zone pushed to every +// DNS Authority Newt. This provides domain-wide failover — if one site goes +// down, the Newt on the surviving site stops returning the dead IP. + +/** + * Build a domain-level wildcard DNS authority config. + * + * For a given wildcard domain (e.g. "docker.visnovsky.us"), this finds every + * site that: + * 1. Has dnsAuthorityEnabled = true + * 2. Has a publicIp set + * 3. Has at least one enabled target for a resource on this domain + * + * Returns a wildcard zone config ("*.docker.visnovsky.us") with those sites + * as targets. Health is determined by aggregating target health per site — + * a site is "healthy" if ANY of its targets for this domain are healthy. + */ +export async function buildDomainDNSAuthorityConfig( + domainId: string +): Promise { + // Get the domain + const [domain] = await db + .select() + .from(domains) + .where(eq(domains.domainId, domainId)) + .limit(1); + + if (!domain || domain.type !== "wildcard") { + return null; + } + + // Find all resources on this domain + const domainResources = await db + .select({ resourceId: resources.resourceId }) + .from(resources) + .where(eq(resources.domainId, domainId)); + + if (domainResources.length === 0) { + return null; + } + + const resourceIds = domainResources.map((r) => r.resourceId); + + // Find all targets for these resources, joined with site info and health + const allTargets = await db + .select({ + targetId: targets.targetId, + siteId: targets.siteId, + enabled: targets.enabled, + priority: targets.priority, + siteName: sites.name, + sitePublicIp: sites.publicIp, + siteDnsAuthorityEnabled: sites.dnsAuthorityEnabled, + hcEnabled: targetHealthCheck.hcEnabled, + hcHealth: targetHealthCheck.hcHealth + }) + .from(targets) + .innerJoin(sites, eq(targets.siteId, sites.siteId)) + .leftJoin( + targetHealthCheck, + eq(targets.targetId, targetHealthCheck.targetId) + ) + .where(inArray(targets.resourceId, resourceIds)); + + // Filter to enabled targets on DNS Authority sites with public IPs + const validTargets = allTargets.filter( + (t) => t.enabled && t.sitePublicIp && t.siteDnsAuthorityEnabled + ); + + if (validTargets.length === 0) { + logger.debug( + `Domain ${domain.baseDomain} has no valid DNS authority targets` + ); + return null; + } + + // Aggregate by site — a site is healthy if ANY of its targets are healthy + const siteMap = new Map< + number, + { + ip: string; + name: string; + healthy: boolean; + minPriority: number; + } + >(); + + for (const t of validTargets) { + const targetHealthy = t.hcEnabled ? t.hcHealth === "healthy" : true; + const existing = siteMap.get(t.siteId); + if (existing) { + // Site is healthy if ANY target is healthy + existing.healthy = existing.healthy || targetHealthy; + existing.minPriority = Math.min( + existing.minPriority, + t.priority || 100 + ); + } else { + siteMap.set(t.siteId, { + ip: t.sitePublicIp!, + name: t.siteName || `Site ${t.siteId}`, + healthy: targetHealthy, + minPriority: t.priority || 100 + }); + } + } + + const dnsTargets: DNSAuthorityTarget[] = Array.from( + siteMap.entries() + ).map(([siteId, info]) => ({ + ip: info.ip, + priority: info.minPriority, + healthy: info.healthy, + siteId, + siteName: info.name + })); + + return { + enabled: true, + domain: `*.${domain.baseDomain}`, + ttl: 60, + routingPolicy: "failover", + targets: dnsTargets + }; +} + +/** + * Get all Newt IDs on DNS Authority-enabled sites for a given domain. + * These are the Newts that should serve the wildcard zone. + */ +async function getDomainDNSAuthorityNewtIds( + domainId: string +): Promise { + const domainResources = await db + .select({ resourceId: resources.resourceId }) + .from(resources) + .where(eq(resources.domainId, domainId)); + + if (domainResources.length === 0) return []; + + const resourceIds = domainResources.map((r) => r.resourceId); + + const newtRows = await db + .select({ + newtId: newts.newtId, + sitePublicIp: sites.publicIp, + siteDnsAuthorityEnabled: sites.dnsAuthorityEnabled + }) + .from(targets) + .innerJoin(sites, eq(targets.siteId, sites.siteId)) + .innerJoin(newts, eq(sites.siteId, newts.siteId)) + .where( + and( + inArray(targets.resourceId, resourceIds), + eq(targets.enabled, true) + ) + ); + + // Deduplicate and filter to DNS Authority sites + const newtIds = new Set(); + for (const row of newtRows) { + if (row.newtId && row.sitePublicIp && row.siteDnsAuthorityEnabled) { + newtIds.add(row.newtId); + } + } + return Array.from(newtIds); +} + +/** + * Push domain-level wildcard DNS authority config to all relevant Newts. + */ +export async function updateDNSAuthorityForDomain(domainId: string) { + const config = await buildDomainDNSAuthorityConfig(domainId); + const newtIds = await getDomainDNSAuthorityNewtIds(domainId); + + if (newtIds.length === 0) { + logger.debug(`No DNS authority Newts for domain ${domainId}`); + return; + } + + for (const newtId of newtIds) { + if (config) { + await sendDNSAuthorityConfigToNewt(newtId, { + action: "update", + zones: [config] + }); + } else { + // No valid config — remove the wildcard zone + const [domain] = await db + .select() + .from(domains) + .where(eq(domains.domainId, domainId)) + .limit(1); + + if (domain) { + await sendDNSAuthorityConfigToNewt(newtId, { + action: "remove", + zones: [ + { + domain: `*.${domain.baseDomain}` + } as DNSAuthorityConfig + ] + }); + } + } + } +} + +/** + * When a site toggles DNS Authority, update wildcard zones for ALL domains + * that have resources with targets on this site. + */ +export async function updateDNSAuthorityForSite(siteId: number) { + // Find all unique domains that have resources with targets on this site + const siteTargets = await db + .select({ + domainId: resources.domainId + }) + .from(targets) + .innerJoin(resources, eq(targets.resourceId, resources.resourceId)) + .innerJoin(domains, eq(resources.domainId, domains.domainId)) + .where( + and(eq(targets.siteId, siteId), eq(domains.type, "wildcard")) + ); + + const domainIds = new Set(); + for (const t of siteTargets) { + if (t.domainId) { + domainIds.add(t.domainId); + } + } + + logger.info( + `DNS Authority: Site ${siteId} toggled — updating ${domainIds.size} wildcard domain(s)` + ); + + for (const domainId of domainIds) { + await updateDNSAuthorityForDomain(domainId); + } + + // Also update any per-resource DNS authority configs + const resourceTargets = await db + .select({ + resourceId: targets.resourceId, + dnsAuthorityEnabled: resources.dnsAuthorityEnabled + }) + .from(targets) + .innerJoin(resources, eq(targets.resourceId, resources.resourceId)) + .where(eq(targets.siteId, siteId)); + + for (const t of resourceTargets) { + if (t.dnsAuthorityEnabled) { + await updateDNSAuthorityForResource(t.resourceId); + } + } +} + +/** + * Push ALL DNS authority zone configs (domain-level + resource-level) to a + * specific Newt. Called when a Newt connects/registers so it gets the full + * picture immediately instead of waiting for a health check or resource edit. + */ +export async function sendAllDNSAuthorityConfigsToNewt( + newtId: string, + siteId: number +) { + // Check if this site has DNS authority enabled + const [site] = await db + .select() + .from(sites) + .where(eq(sites.siteId, siteId)) + .limit(1); + + if (!site?.dnsAuthorityEnabled || !site?.publicIp) { + return; + } + + const allZones: DNSAuthorityConfig[] = []; + + // 1. Domain-level wildcard zones + const siteTargets = await db + .select({ + domainId: resources.domainId + }) + .from(targets) + .innerJoin(resources, eq(targets.resourceId, resources.resourceId)) + .innerJoin(domains, eq(resources.domainId, domains.domainId)) + .where( + and(eq(targets.siteId, siteId), eq(domains.type, "wildcard")) + ); + + const domainIds = new Set(); + for (const t of siteTargets) { + if (t.domainId) domainIds.add(t.domainId); + } + + for (const domainId of domainIds) { + const config = await buildDomainDNSAuthorityConfig(domainId); + if (config) allZones.push(config); + } + + // 2. Per-resource zones + const resourceTargets = await db + .select({ + resourceId: targets.resourceId, + dnsAuthorityEnabled: resources.dnsAuthorityEnabled + }) + .from(targets) + .innerJoin(resources, eq(targets.resourceId, resources.resourceId)) + .where( + and(eq(targets.siteId, siteId), eq(targets.enabled, true)) + ); + + for (const t of resourceTargets) { + if (t.dnsAuthorityEnabled) { + const config = await buildDNSAuthorityConfig(t.resourceId); + if (config) allZones.push(config); + } + } + + if (allZones.length > 0) { + await sendDNSAuthorityConfigToNewt(newtId, { + action: "update", + zones: allZones + }); + logger.info( + `DNS Authority: Sent ${allZones.length} zone(s) to Newt ${newtId} on connect` + ); + } +} diff --git a/server/routers/external.ts b/server/routers/external.ts index 8914a0251..451d71616 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -1424,6 +1424,9 @@ authRouter.put("/set-server-admin", auth.setServerAdmin); authRouter.get("/initial-setup-complete", auth.initialSetupComplete); authRouter.post("/validate-setup-token", auth.validateSetupToken); +// Session validation for Newt auth proxy +authRouter.get("/session/validate", auth.validateSession); + // Security Key routes authRouter.post( "/security-key/register/start", diff --git a/server/routers/internal.ts b/server/routers/internal.ts index 2fa5239cc..f245f0a9e 100644 --- a/server/routers/internal.ts +++ b/server/routers/internal.ts @@ -6,6 +6,8 @@ import * as badger from "./badger"; import * as auth from "@server/routers/auth"; import * as supporterKey from "@server/routers/supporterKey"; import * as idp from "@server/routers/idp"; +import * as newt from "./newt"; +import * as olm from "./olm"; import HttpCode from "@server/types/HttpCode"; import { verifyResourceAccess, @@ -42,6 +44,13 @@ internalRouter.get("/idp", idp.listIdps); internalRouter.get("/idp/:idpId", idp.getIdp); +// Auth endpoints for Newt/OLM to get tokens (needed for WebSocket connection) +const authRouter = Router(); +internalRouter.use("/auth", authRouter); + +authRouter.post("/newt/get-token", newt.getNewtToken); +authRouter.post("/olm/get-token", olm.getOlmToken); + // Gerbil routes const gerbilRouter = Router(); internalRouter.use("/gerbil", gerbilRouter); diff --git a/server/routers/newt/getNewtToken.ts b/server/routers/newt/getNewtToken.ts index c5abb9968..ad700c8e3 100644 --- a/server/routers/newt/getNewtToken.ts +++ b/server/routers/newt/getNewtToken.ts @@ -1,8 +1,8 @@ import { generateSessionToken } from "@server/auth/sessions/app"; import { db, newtSessions } from "@server/db"; -import { newts } from "@server/db"; import { getOrCreateCachedToken } from "#dynamic/lib/tokenCache"; import { EXPIRES } from "@server/auth/sessions/newt"; +import { newts, sites } from "@server/db"; import HttpCode from "@server/types/HttpCode"; import response from "@server/lib/response"; import { eq } from "drizzle-orm"; @@ -18,6 +18,7 @@ import { verifyPassword } from "@server/auth/password"; import logger from "@server/logger"; import config from "@server/lib/config"; import { APP_VERSION } from "@server/lib/consts"; +import { isIP } from "node:net"; export const newtGetTokenBodySchema = z.object({ newtId: z.string(), @@ -27,6 +28,51 @@ export const newtGetTokenBodySchema = z.object({ export type NewtGetTokenBody = z.infer; +function normalizeIp(ip?: string | null): string | null { + if (!ip) return null; + let normalized = ip.trim(); + if (normalized.startsWith("::ffff:")) { + normalized = normalized.slice(7); + } + return normalized; +} + +function isPrivateOrReservedIPv4(ip: string): boolean { + const octets = ip.split(".").map(Number); + if (octets.length !== 4 || octets.some((part) => Number.isNaN(part) || part < 0 || part > 255)) { + return true; + } + + const [a, b] = octets; + + return ( + a === 0 || + a === 10 || + a === 127 || + a >= 224 || + (a === 100 && b >= 64 && b <= 127) || + (a === 169 && b === 254) || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 0) || + (a === 192 && b === 168) || + (a === 198 && (b === 18 || b === 19)) + ); +} + +function getPublicIPv4Candidate(req: Request): string | null { + const socketIp = normalizeIp(req.socket?.remoteAddress); + if (socketIp && isIP(socketIp) === 4 && !isPrivateOrReservedIPv4(socketIp)) { + return socketIp; + } + + const reqIp = normalizeIp(req.ip); + if (reqIp && isIP(reqIp) === 4 && !isPrivateOrReservedIPv4(reqIp)) { + return reqIp; + } + + return null; +} + export async function getNewtToken( req: Request, res: Response, @@ -108,6 +154,30 @@ export async function getNewtToken( } ); + // Auto-detect and update site's publicIp if not already set + if (existingNewt.siteId) { + const clientIp = getPublicIPv4Candidate(req); + + if (clientIp) { + // Only update if the site's publicIp is null/empty + const [site] = await db + .select({ publicIp: sites.publicIp }) + .from(sites) + .where(eq(sites.siteId, existingNewt.siteId)) + .limit(1); + + if (site && !site.publicIp) { + await db + .update(sites) + .set({ publicIp: clientIp }) + .where(eq(sites.siteId, existingNewt.siteId)); + logger.debug(`Auto-detected site publicIp: ${clientIp} for site ${existingNewt.siteId}`); + } + } else { + logger.debug(`Skipped site publicIp auto-detection for site ${existingNewt.siteId}: no public IPv4 candidate available`); + } + } + return response<{ token: string; serverVersion: string }>(res, { data: { token: resToken, diff --git a/server/routers/newt/handleDnsStatusMessage.ts b/server/routers/newt/handleDnsStatusMessage.ts new file mode 100644 index 000000000..b5cdeec24 --- /dev/null +++ b/server/routers/newt/handleDnsStatusMessage.ts @@ -0,0 +1,43 @@ +import { db, sites } from "@server/db"; +import { MessageHandler } from "@server/routers/ws"; +import { Newt } from "@server/db"; +import { eq } from "drizzle-orm"; +import logger from "@server/logger"; + +export const handleDnsStatusMessage: MessageHandler = async (context) => { + const { message, client } = context; + const newt = client as Newt; + + if (!newt) { + logger.warn("DNS status message: Newt not found"); + return; + } + + if (!newt.siteId) { + logger.warn("DNS status message: Newt has no site ID"); + return; + } + + const { status, error, address } = message.data; + + logger.info( + `Updating DNS status for site ${newt.siteId}: status=${status}, address=${address}, error=${error}` + ); + + try { + await db + .update(sites) + .set({ + dnsStatus: status, + dnsError: error || null + }) + .where(eq(sites.siteId, newt.siteId)); + } catch (err) { + logger.error( + `Failed to update DNS status for site ${newt.siteId}:`, + err + ); + } + + return; +}; diff --git a/server/routers/newt/handleNewtRegisterMessage.ts b/server/routers/newt/handleNewtRegisterMessage.ts index fce42caa3..9fd0b50d4 100644 --- a/server/routers/newt/handleNewtRegisterMessage.ts +++ b/server/routers/newt/handleNewtRegisterMessage.ts @@ -14,6 +14,7 @@ import { fetchContainers } from "./dockerSocket"; import { lockManager } from "#dynamic/lib/lock"; import { buildTargetConfigurationForNewtClient } from "./buildConfiguration"; import { canCompress } from "@server/lib/clientVersionChecks"; +import { sendAllDNSAuthorityConfigsToNewt } from "@server/routers/dns/dnsAuthority"; export type ExitNodePingResult = { exitNodeId: number; @@ -54,6 +55,29 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { logger.debug( "Backwards compatible mode detecting - not sending connect message and waiting for ping response." ); + + // Mark the site as online since the Newt is connected via WebSocket + // await db + // .update(sites) + // .set({ + // online: true, + // pubKey: publicKey + // }) + // .where(eq(sites.siteId, siteId)); + + // Even in backwards-compatible mode, push DNS authority configs + // since DNS authority works independently of WireGuard + setTimeout(async () => { + try { + await sendAllDNSAuthorityConfigsToNewt(newt.newtId, siteId); + } catch (error) { + logger.error( + `Failed to send DNS authority configs to Newt ${newt.newtId}:`, + error + ); + } + }, 3000); + return; } @@ -140,6 +164,21 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { .returning(); } + // Push DNS authority zone configs to the Newt after a short delay + // to allow the WG tunnel to establish first (if applicable). + // This must be before exit node checks since DNS authority works + // independently of WireGuard connectivity. + setTimeout(async () => { + try { + await sendAllDNSAuthorityConfigsToNewt(newt.newtId, siteId); + } catch (error) { + logger.error( + `Failed to send DNS authority configs to Newt ${newt.newtId}:`, + error + ); + } + }, 3000); + if (!exitNodeIdToQuery) { logger.warn("No exit node ID to query"); return; diff --git a/server/routers/newt/index.ts b/server/routers/newt/index.ts index 33b5caf7c..0a4a58451 100644 --- a/server/routers/newt/index.ts +++ b/server/routers/newt/index.ts @@ -10,3 +10,4 @@ export * from "./handleNewtPingMessage"; export * from "./handleNewtDisconnectingMessage"; export * from "./handleConnectionLogMessage"; export * from "./registerNewt"; +export * from "./handleDnsStatusMessage"; diff --git a/server/routers/olm/sync.ts b/server/routers/olm/sync.ts index c994b2c73..dc64fdc07 100644 --- a/server/routers/olm/sync.ts +++ b/server/routers/olm/sync.ts @@ -9,7 +9,7 @@ import { import { buildSiteConfigurationForOlmClient } from "./buildConfiguration"; import { sendToClient } from "#dynamic/routers/ws"; import logger from "@server/logger"; -import { eq, inArray } from "drizzle-orm"; +import { eq, inArray, and } from "drizzle-orm"; import config from "@server/lib/config"; import { canCompress } from "@server/lib/clientVersionChecks"; diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index 01f3e79ff..fb9f53c6f 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -25,6 +25,8 @@ import { validateAndConstructDomain } from "@server/lib/domainUtils"; import { build } from "@server/build"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { updateDNSAuthorityForResource, updateDNSAuthorityForDomain } from "@server/routers/dns/dnsAuthority"; +import { updateAuthProxyForResource } from "@server/routers/auth/authProxy"; const updateResourceParamsSchema = z.strictObject({ resourceId: z.string().transform(Number).pipe(z.int().positive()) @@ -64,7 +66,11 @@ const updateHttpResourceBodySchema = z maintenanceTitle: z.string().max(255).nullable().optional(), maintenanceMessage: z.string().max(2000).nullable().optional(), maintenanceEstimatedTime: z.string().max(100).nullable().optional(), - postAuthPath: z.string().nullable().optional() + postAuthPath: z.string().nullable().optional(), + // DNS Authority fields + dnsAuthorityEnabled: z.boolean().optional(), + dnsAuthorityTtl: z.int().min(1).max(86400).optional(), + dnsAuthorityRoutingPolicy: z.enum(["failover", "roundrobin", "priority"]).optional() }) .refine((data) => Object.keys(data).length > 0, { error: "At least one field must be provided for update" @@ -156,7 +162,11 @@ const updateRawResourceBodySchema = z stickySession: z.boolean().optional(), enabled: z.boolean().optional(), proxyProtocol: z.boolean().optional(), - proxyProtocolVersion: z.int().min(1).optional() + proxyProtocolVersion: z.int().min(1).optional(), + // DNS Authority fields + dnsAuthorityEnabled: z.boolean().optional(), + dnsAuthorityTtl: z.int().min(1).max(86400).optional(), + dnsAuthorityRoutingPolicy: z.enum(["failover", "roundrobin", "priority"]).optional() }) .refine((data) => Object.keys(data).length > 0, { error: "At least one field must be provided for update" @@ -434,6 +444,37 @@ async function updateHttpResource( ); } + // Update DNS authority config if DNS authority settings changed + if ( + updateData.dnsAuthorityEnabled !== undefined || + updateData.dnsAuthorityTtl !== undefined || + updateData.dnsAuthorityRoutingPolicy !== undefined + ) { + try { + await updateDNSAuthorityForResource(resource.resourceId); + // Also update domain-level wildcard zone since this resource participates in it + if (resource.domainId) { + await updateDNSAuthorityForDomain(resource.domainId); + } + } catch (error) { + logger.error("Failed to update DNS authority config:", error); + } + } + + // Update auth proxy config if SSO or DNS authority settings changed + if ( + updateData.dnsAuthorityEnabled !== undefined || + updateData.sso !== undefined || + updateData.blockAccess !== undefined || + updateData.emailWhitelistEnabled !== undefined + ) { + try { + await updateAuthProxyForResource(resource.resourceId); + } catch (error) { + logger.error("Failed to update auth proxy config:", error); + } + } + return response(res, { data: updatedResource[0], success: true, @@ -508,6 +549,23 @@ async function updateRawResource( ); } + // Update DNS authority config if DNS authority settings changed + if ( + updateData.dnsAuthorityEnabled !== undefined || + updateData.dnsAuthorityTtl !== undefined || + updateData.dnsAuthorityRoutingPolicy !== undefined + ) { + try { + await updateDNSAuthorityForResource(resource.resourceId); + // Also update domain-level wildcard zone since this resource participates in it + if (resource.domainId) { + await updateDNSAuthorityForDomain(resource.domainId); + } + } catch (error) { + logger.error("Failed to update DNS authority config:", error); + } + } + return response(res, { data: updatedResource[0], success: true, diff --git a/server/routers/site/getSite.ts b/server/routers/site/getSite.ts index 45d49abe6..2cd45beaa 100644 --- a/server/routers/site/getSite.ts +++ b/server/routers/site/getSite.ts @@ -10,6 +10,7 @@ import logger from "@server/logger"; import stoi from "@server/lib/stoi"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; +import { getServerIp } from "@server/lib/serverIpService"; const getSiteSchema = z.strictObject({ siteId: z @@ -44,7 +45,7 @@ async function query(siteId?: number, niceId?: string, orgId?: string) { export type GetSiteResponse = NonNullable< Awaited> ->["sites"] & { newtId: string | null }; +>["sites"] & { newtId: string | null; serverPublicIp: string | null }; registry.registerPath({ method: "get", @@ -100,7 +101,8 @@ export async function getSite( const data: GetSiteResponse = { ...site.sites, - newtId: site.newt ? site.newt.newtId : null + newtId: site.newt ? site.newt.newtId : null, + serverPublicIp: getServerIp() }; return response(res, { diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index 6f085d74d..b9c9ccc9e 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -166,7 +166,10 @@ function querySitesBase() { exitNodeName: exitNodes.name, exitNodeEndpoint: exitNodes.endpoint, remoteExitNodeId: remoteExitNodes.remoteExitNodeId, - status: sites.status + publicIp: sites.publicIp, + dnsAuthorityEnabled: sites.dnsAuthorityEnabled, + dnsStatus: sites.dnsStatus, + status: sites.status }) .from(sites) .leftJoin(orgs, eq(sites.orgId, orgs.orgId)) diff --git a/server/routers/site/updateSite.ts b/server/routers/site/updateSite.ts index 34d1341d7..cba777598 100644 --- a/server/routers/site/updateSite.ts +++ b/server/routers/site/updateSite.ts @@ -10,6 +10,9 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { isValidCIDR } from "@server/lib/validators"; +import { getServerIp } from "@server/lib/serverIpService"; +import { updateDNSAuthorityForSite } from "@server/routers/dns/dnsAuthority"; +import { updateAuthProxyForSite } from "@server/routers/auth/authProxy"; const updateSiteParamsSchema = z.strictObject({ siteId: z.string().transform(Number).pipe(z.int().positive()) @@ -21,7 +24,9 @@ const updateSiteBodySchema = z niceId: z.string().min(1).max(255).optional(), dockerSocketEnabled: z.boolean().optional(), status: z.enum(["pending", "approved"]).optional(), - // remoteSubnets: z.string().optional() + remoteSubnets: z.string().optional(), + publicIp: z.ipv4().nullable().optional(), + dnsAuthorityEnabled: z.boolean().optional() // subdomain: z // .string() // .min(1) @@ -35,7 +40,7 @@ const updateSiteBodySchema = z // megabytesOut: z.number().int().nonnegative().optional(), }) .refine((data) => Object.keys(data).length > 0, { - error: "At least one field must be provided for update" + message: "At least one field must be provided for update" }); registry.registerPath({ @@ -126,6 +131,30 @@ export async function updateSite( // } // } +// if enabling DNS Authority, ensure a publicIp is set (either in update, existing, or server-detected) + if (updateData.dnsAuthorityEnabled === true && !updateData.publicIp) { + const existingSite = await db + .select({ publicIp: sites.publicIp }) + .from(sites) + .where(eq(sites.siteId, siteId)) + .limit(1); + + if (!existingSite.length || !existingSite[0].publicIp) { + // Fall back to server's detected public IP + const serverIp = getServerIp(); + if (serverIp) { + updateData.publicIp = serverIp; + } else { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Public IP is required when enabling DNS Authority. Could not auto-detect server IP." + ) + ); + } + } + } + const updatedSite = await db .update(sites) .set(updateData) @@ -141,6 +170,22 @@ export async function updateSite( ); } + // If DNS Authority settings changed, update all affected domain zones + if ( + updateData.dnsAuthorityEnabled !== undefined || + updateData.publicIp !== undefined + ) { + try { + await updateDNSAuthorityForSite(siteId); + await updateAuthProxyForSite(siteId); + } catch (error) { + logger.error( + "Failed to update DNS authority/auth proxy config for site:", + error + ); + } + } + return response(res, { data: updatedSite[0], success: true, diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index ba52d85a1..feebdf639 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -14,6 +14,8 @@ import { eq } from "drizzle-orm"; import { pickPort } from "./helpers"; import { isTargetValid } from "@server/lib/validators"; import { OpenAPITags, registry } from "@server/openApi"; +import { updateDNSAuthorityForResource } from "../dns/dnsAuthority"; +import { updateAuthProxyForResource } from "../auth/authProxy"; const createTargetParamsSchema = z.strictObject({ resourceId: z.string().transform(Number).pipe(z.int().positive()) @@ -269,6 +271,19 @@ export async function createTarget( } } + // Trigger DNS Authority update if needed + if (resource.dnsAuthorityEnabled) { + await updateDNSAuthorityForResource(resourceId); + } + + // Trigger auth proxy update for protected DNS-authority resources + if ( + resource.dnsAuthorityEnabled && + (resource.sso || resource.blockAccess || resource.emailWhitelistEnabled) + ) { + await updateAuthProxyForResource(resourceId); + } + return response(res, { data: { ...newTarget[0], diff --git a/server/routers/target/deleteTarget.ts b/server/routers/target/deleteTarget.ts index 606d86351..95e55db2d 100644 --- a/server/routers/target/deleteTarget.ts +++ b/server/routers/target/deleteTarget.ts @@ -12,6 +12,8 @@ import { fromError } from "zod-validation-error"; import { removeTargets } from "../newt/targets"; import { getAllowedIps } from "./helpers"; import { OpenAPITags, registry } from "@server/openApi"; +import { updateDNSAuthorityForResource } from "../dns/dnsAuthority"; +import { updateAuthProxyForSite } from "../auth/authProxy"; const deleteTargetSchema = z.strictObject({ targetId: z.string().transform(Number).pipe(z.int().positive()) @@ -107,6 +109,16 @@ export async function deleteTarget( // } // } + // Trigger DNS Authority update if needed + if (resource.dnsAuthorityEnabled) { + await updateDNSAuthorityForResource(resource.resourceId); + } + + // Refresh auth proxy config on the affected site + if (deletedTarget.siteId) { + await updateAuthProxyForSite(deletedTarget.siteId); + } + return response(res, { data: null, success: true, diff --git a/server/routers/target/handleHealthcheckStatusMessage.ts b/server/routers/target/handleHealthcheckStatusMessage.ts index 7ea1730ce..d4544ea38 100644 --- a/server/routers/target/handleHealthcheckStatusMessage.ts +++ b/server/routers/target/handleHealthcheckStatusMessage.ts @@ -4,6 +4,7 @@ import { Newt } from "@server/db"; import { eq, and } from "drizzle-orm"; import logger from "@server/logger"; import { unknown } from "zod"; +import { onHealthCheckUpdate } from "@server/routers/dns/dnsAuthority"; interface TargetHealthStatus { status: string; @@ -58,6 +59,7 @@ export const handleHealthcheckStatusMessage: MessageHandler = async ( try { let successCount = 0; let errorCount = 0; + const updatedTargetIds: number[] = []; // Process each target status update for (const [targetId, healthStatus] of Object.entries(data.targets)) { @@ -127,11 +129,21 @@ export const handleHealthcheckStatusMessage: MessageHandler = async ( `Updated health status for target ${targetId} to ${healthStatus.status}` ); successCount++; + updatedTargetIds.push(targetIdNum); } logger.debug( `Health status update complete: ${successCount} successful, ${errorCount} errors out of ${Object.keys(data.targets).length} targets` ); + + // Notify DNS authority module about health check updates + if (updatedTargetIds.length > 0) { + try { + await onHealthCheckUpdate(updatedTargetIds); + } catch (error) { + logger.error("Error updating DNS authority config:", error); + } + } } catch (error) { logger.error("Error processing healthcheck status message:", error); } diff --git a/server/routers/target/updateTarget.ts b/server/routers/target/updateTarget.ts index 1f9eff716..58438dc56 100644 --- a/server/routers/target/updateTarget.ts +++ b/server/routers/target/updateTarget.ts @@ -14,6 +14,8 @@ import { pickPort } from "./helpers"; import { isTargetValid } from "@server/lib/validators"; import { OpenAPITags, registry } from "@server/openApi"; import { vs } from "@react-email/components"; +import { updateDNSAuthorityForResource } from "../dns/dnsAuthority"; +import { updateAuthProxyForResource } from "../auth/authProxy"; const updateTargetParamsSchema = z.strictObject({ targetId: z.string().transform(Number).pipe(z.int().positive()) @@ -268,6 +270,20 @@ export async function updateTarget( ); } } + + // Trigger DNS Authority update if needed + if (resource.dnsAuthorityEnabled) { + await updateDNSAuthorityForResource(resource.resourceId); + } + + // Trigger auth proxy update for protected DNS-authority resources + if ( + resource.dnsAuthorityEnabled && + (resource.sso || resource.blockAccess || resource.emailWhitelistEnabled) + ) { + await updateAuthProxyForResource(resource.resourceId); + } + return response(res, { data: { ...updatedTarget, diff --git a/server/routers/ws/messageHandlers.ts b/server/routers/ws/messageHandlers.ts index 143e4d516..6cd6d01e6 100644 --- a/server/routers/ws/messageHandlers.ts +++ b/server/routers/ws/messageHandlers.ts @@ -10,6 +10,8 @@ import { handleNewtPingMessage, startNewtOfflineChecker, handleNewtDisconnectingMessage + handleNewtDisconnectingMessage, + handleDnsStatusMessage } from "../newt"; import { startPingAccumulator } from "../newt/pingAccumulator"; import { @@ -41,6 +43,7 @@ export const messageHandlers: Record = { "newt/receive-bandwidth": handleReceiveBandwidthMessage, "newt/socket/status": handleDockerStatusMessage, "newt/socket/containers": handleDockerContainersMessage, + "newt/dns/status": handleDnsStatusMessage, "newt/ping/request": handleNewtPingRequestMessage, "newt/blueprint/apply": handleApplyBlueprintMessage, "newt/healthcheck/status": handleHealthcheckStatusMessage, diff --git a/server/setup/index.ts b/server/setup/index.ts index 2dfb633e5..506794486 100644 --- a/server/setup/index.ts +++ b/server/setup/index.ts @@ -2,10 +2,12 @@ import { ensureActions } from "./ensureActions"; import { copyInConfig } from "./copyInConfig"; import { clearStaleData } from "./clearStaleData"; import { ensureSetupToken } from "./ensureSetupToken"; +import { ensureJwtKeypair } from "@server/lib/jwtKeypair"; export async function runSetupFunctions() { await copyInConfig(); // copy in the config to the db as needed await ensureActions(); // make sure all of the actions are in the db and the roles await clearStaleData(); await ensureSetupToken(); // ensure setup token exists for initial setup + ensureJwtKeypair(); // generate JWT keypair for auth proxy if not present } diff --git a/server/setup/migrationsPg.ts b/server/setup/migrationsPg.ts index 9ba0b9767..9bee28b21 100644 --- a/server/setup/migrationsPg.ts +++ b/server/setup/migrationsPg.ts @@ -22,6 +22,7 @@ import m13 from "./scriptsPg/1.15.3"; import m14 from "./scriptsPg/1.15.4"; import m15 from "./scriptsPg/1.16.0"; import m16 from "./scriptsPg/1.17.0"; +import m17 from "./scriptsPg/1.16.1"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -43,7 +44,8 @@ const migrations = [ { version: "1.15.3", run: m13 }, { version: "1.15.4", run: m14 }, { version: "1.16.0", run: m15 }, - { version: "1.17.0", run: m16 } + { version: "1.17.0", run: m16 }, + { version: "1.16.1", run: m17 } // Add new migrations here as they are created ] as { version: string; diff --git a/server/setup/migrationsSqlite.ts b/server/setup/migrationsSqlite.ts index 45a29ec29..55dbf20da 100644 --- a/server/setup/migrationsSqlite.ts +++ b/server/setup/migrationsSqlite.ts @@ -40,6 +40,7 @@ import m34 from "./scriptsSqlite/1.15.3"; import m35 from "./scriptsSqlite/1.15.4"; import m36 from "./scriptsSqlite/1.16.0"; import m37 from "./scriptsSqlite/1.17.0"; +import m38 from "./scriptsSqlite/1.16.1"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -77,7 +78,8 @@ const migrations = [ { version: "1.15.3", run: m34 }, { version: "1.15.4", run: m35 }, { version: "1.16.0", run: m36 }, - { version: "1.17.0", run: m37 } + { version: "1.17.0", run: m37 }, + { version: "1.16.1", run: m38 } // Add new migrations here as they are created ] as const; diff --git a/server/setup/scriptsPg/1.16.1.ts b/server/setup/scriptsPg/1.16.1.ts new file mode 100644 index 000000000..8038fccd4 --- /dev/null +++ b/server/setup/scriptsPg/1.16.1.ts @@ -0,0 +1,104 @@ +import { db } from "@server/db/pg/driver"; +import { sql } from "drizzle-orm"; + +const version = "1.16.1"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + try { + // Helper to check if a column exists + const columnExists = async ( + table: string, + column: string + ): Promise => { + const result = await db.execute(sql` + SELECT 1 FROM information_schema.columns + WHERE table_name = ${table} AND column_name = ${column} + LIMIT 1 + `); + return (result as any).rows?.length > 0 || (result as any).length > 0; + }; + + await db.execute(sql`BEGIN`); + + // Add DNS authority columns to sites table + if (!(await columnExists("sites", "publicIp"))) { + await db.execute( + sql`ALTER TABLE "sites" ADD COLUMN "publicIp" varchar;` + ); + } + + if (!(await columnExists("sites", "dnsAuthorityEnabled"))) { + await db.execute( + sql`ALTER TABLE "sites" ADD COLUMN "dnsAuthorityEnabled" boolean DEFAULT false NOT NULL;` + ); + } + + if (!(await columnExists("sites", "dnsStatus"))) { + await db.execute( + sql`ALTER TABLE "sites" ADD COLUMN "dnsStatus" varchar;` + ); + } + + if (!(await columnExists("sites", "dnsError"))) { + await db.execute( + sql`ALTER TABLE "sites" ADD COLUMN "dnsError" text;` + ); + } + + // Add DNS authority columns to resources table + if (!(await columnExists("resources", "dnsAuthorityEnabled"))) { + await db.execute( + sql`ALTER TABLE "resources" ADD COLUMN "dnsAuthorityEnabled" boolean DEFAULT false NOT NULL;` + ); + } + + if (!(await columnExists("resources", "dnsAuthorityTtl"))) { + await db.execute( + sql`ALTER TABLE "resources" ADD COLUMN "dnsAuthorityTtl" integer DEFAULT 60;` + ); + } + + if (!(await columnExists("resources", "dnsAuthorityRoutingPolicy"))) { + await db.execute( + sql`ALTER TABLE "resources" ADD COLUMN "dnsAuthorityRoutingPolicy" text DEFAULT 'failover';` + ); + } + + // Add health check latency column used by intelligent DNS scoring + if (!(await columnExists("targetHealthCheck", "hcLatencyMs"))) { + await db.execute( + sql`ALTER TABLE "targetHealthCheck" ADD COLUMN "hcLatencyMs" integer;` + ); + } + + await db.execute(sql` + UPDATE "resources" + SET "dnsAuthorityRoutingPolicy" = 'failover' + WHERE "dnsAuthorityRoutingPolicy" IS NULL + OR "dnsAuthorityRoutingPolicy" NOT IN ('failover', 'roundrobin', 'priority', 'intelligent') + `); + + await db.execute(sql` + ALTER TABLE "resources" + DROP CONSTRAINT IF EXISTS "resources_dns_authority_routing_policy_check" + `); + + await db.execute(sql` + ALTER TABLE "resources" + ADD CONSTRAINT "resources_dns_authority_routing_policy_check" + CHECK ("dnsAuthorityRoutingPolicy" IN ('failover', 'roundrobin', 'priority', 'intelligent')) + `); + + await db.execute(sql`COMMIT`); + console.log("Migrated database"); + } catch (e) { + await db.execute(sql`ROLLBACK`); + console.log("Unable to migrate database"); + console.log(e); + throw e; + } + + console.log(`${version} migration complete`); +} diff --git a/server/setup/scriptsSqlite/1.16.1.ts b/server/setup/scriptsSqlite/1.16.1.ts new file mode 100644 index 000000000..1c8979fca --- /dev/null +++ b/server/setup/scriptsSqlite/1.16.1.ts @@ -0,0 +1,95 @@ +import { APP_PATH } from "@server/lib/consts"; +import Database from "better-sqlite3"; +import path from "path"; + +const version = "1.16.1"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + const location = path.join(APP_PATH, "db", "db.sqlite"); + const db = new Database(location); + + try { + db.pragma("foreign_keys = OFF"); + + // Helper to check if a column exists in a table + const columnExists = (table: string, column: string): boolean => { + const cols = db + .prepare(`PRAGMA table_info('${table}')`) + .all() as { name: string }[]; + return cols.some((c) => c.name === column); + }; + + db.transaction(() => { + // Add DNS authority columns to sites table + if (!columnExists("sites", "publicIp")) { + db.prepare( + `ALTER TABLE 'sites' ADD 'publicIp' text;` + ).run(); + } + + if (!columnExists("sites", "dnsAuthorityEnabled")) { + db.prepare( + `ALTER TABLE 'sites' ADD 'dnsAuthorityEnabled' integer DEFAULT false NOT NULL;` + ).run(); + } + + if (!columnExists("sites", "dnsStatus")) { + db.prepare( + `ALTER TABLE 'sites' ADD 'dnsStatus' text;` + ).run(); + } + + if (!columnExists("sites", "dnsError")) { + db.prepare( + `ALTER TABLE 'sites' ADD 'dnsError' text;` + ).run(); + } + + // Add DNS authority columns to resources table + if (!columnExists("resources", "dnsAuthorityEnabled")) { + db.prepare( + `ALTER TABLE 'resources' ADD 'dnsAuthorityEnabled' integer DEFAULT false NOT NULL;` + ).run(); + } + + if (!columnExists("resources", "dnsAuthorityTtl")) { + db.prepare( + `ALTER TABLE 'resources' ADD 'dnsAuthorityTtl' integer DEFAULT 60;` + ).run(); + } + + if (!columnExists("resources", "dnsAuthorityRoutingPolicy")) { + db.prepare( + `ALTER TABLE 'resources' ADD 'dnsAuthorityRoutingPolicy' text DEFAULT 'failover';` + ).run(); + } + + // Add health check latency column used by intelligent DNS scoring + if (!columnExists("targetHealthCheck", "hcLatencyMs")) { + db.prepare( + `ALTER TABLE 'targetHealthCheck' ADD 'hcLatencyMs' integer;` + ).run(); + } + + db.prepare( + ` + UPDATE "resources" + SET "dnsAuthorityRoutingPolicy" = 'failover' + WHERE "dnsAuthorityRoutingPolicy" IS NULL + OR "dnsAuthorityRoutingPolicy" NOT IN ('failover', 'roundrobin', 'priority', 'intelligent'); + ` + ).run(); + })(); + + db.pragma("foreign_keys = ON"); + + console.log(`Migrated database`); + } catch (e) { + console.log("Failed to migrate db:", e); + throw e; + } + + console.log(`${version} migration complete`); +} diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx index 3d6e6186b..7e6a6752f 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx @@ -11,6 +11,7 @@ import { SelectValue } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; +import { DNSAuthorityForm } from "@app/components/DNSAuthorityForm"; import { HeadersInput } from "@app/components/HeadersInput"; import { PathMatchDisplay, @@ -150,6 +151,13 @@ export default function ReverseProxyTargetsPage(props: { updateResource={updateResource} /> )} + + ); } diff --git a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx index 71dc32e70..50eada541 100644 --- a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx @@ -34,12 +34,32 @@ import { useState } from "react"; import { SwitchInput } from "@app/components/SwitchInput"; import { ExternalLink } from "lucide-react"; import { useTranslations } from "next-intl"; +import Link from "next/link"; +import { Alert, AlertDescription } from "@app/components/ui/alert"; +import { Info, ShieldAlert, ShieldCheck, ShieldX } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Switch } from "@/components/ui/switch"; +import { Label } from "@/components/ui/label"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger +} from "@/components/ui/tooltip"; const GeneralFormSchema = z.object({ name: z.string().nonempty("Name is required"), niceId: z.string().min(1).max(255).optional(), - dockerSocketEnabled: z.boolean().optional() -}); + dockerSocketEnabled: z.boolean().optional(), + publicIp: z.ipv4().nullable().optional().or(z.literal("")), + dnsAuthorityEnabled: z.boolean().optional() +}).refine( + (data) => !data.dnsAuthorityEnabled || (data.publicIp && data.publicIp !== ""), + { + message: "Public IP is required when DNS Authority is enabled", + path: ["publicIp"] + } +); type GeneralFormValues = z.infer; @@ -62,7 +82,9 @@ export default function GeneralPage() { defaultValues: { name: site?.name, niceId: site?.niceId || "", - dockerSocketEnabled: site?.dockerSocketEnabled ?? false + dockerSocketEnabled: site?.dockerSocketEnabled ?? false, + publicIp: site?.publicIp || "", + dnsAuthorityEnabled: site?.dnsAuthorityEnabled ?? false }, mode: "onChange" }); @@ -74,13 +96,17 @@ export default function GeneralPage() { await api.post(`/site/${site?.siteId}`, { name: data.name, niceId: data.niceId, - dockerSocketEnabled: data.dockerSocketEnabled + dockerSocketEnabled: data.dockerSocketEnabled, + publicIp: data.publicIp || null, + dnsAuthorityEnabled: data.dnsAuthorityEnabled }); updateSite({ name: data.name, niceId: data.niceId, - dockerSocketEnabled: data.dockerSocketEnabled + dockerSocketEnabled: data.dockerSocketEnabled, + publicIp: data.publicIp || null, + dnsAuthorityEnabled: data.dnsAuthorityEnabled }); if (data.niceId && data.niceId !== site?.niceId) { @@ -208,6 +234,114 @@ export default function GeneralPage() { )} /> )} + + ( + +
+
+ + { + field.onChange(checked); + // Auto-populate publicIp from server's detected IP when enabling + if (checked && !form.getValues("publicIp")) { + const defaultIp = site?.publicIp || site?.serverPublicIp || ""; + if (defaultIp) { + form.setValue("publicIp", defaultIp); + } + } + }} + /> + + + + + + + + + {t("dnsAuthorityLocalOverrideNote")} + + + + {field.value && !site?.dnsStatus && ( + + + + + + + {t("siteDnsAuthorityLocalOverrideWarning")} + + + + )} +
+
+ + {t( + "siteDnsAuthorityDescription" + )}{" "} + + {t("learnMore")} + + + {site?.dnsError && + site?.dnsStatus !== + "running" && ( + + + + {site?.dnsError} + + + )} + +
+ )} + /> + + {form.watch("dnsAuthorityEnabled") && ( + ( + + + {t("sitePublicIp")} + + + + + + {t("sitePublicIpDescription")} + + + + )} + /> + )} diff --git a/src/app/[orgId]/settings/sites/page.tsx b/src/app/[orgId]/settings/sites/page.tsx index 38083325b..8985e16ac 100644 --- a/src/app/[orgId]/settings/sites/page.tsx +++ b/src/app/[orgId]/settings/sites/page.tsx @@ -66,7 +66,8 @@ export default async function SitesPage(props: SitesPageProps) { newtUpdateAvailable: site.newtUpdateAvailable || false, exitNodeName: site.exitNodeName || undefined, exitNodeEndpoint: site.exitNodeEndpoint || undefined, - remoteExitNodeId: (site as any).remoteExitNodeId || undefined + remoteExitNodeId: (site as any).remoteExitNodeId || undefined, + dnsStatus: site.dnsStatus || null }; }); diff --git a/src/components/DNSAuthorityForm.tsx b/src/components/DNSAuthorityForm.tsx new file mode 100644 index 000000000..4bed76891 --- /dev/null +++ b/src/components/DNSAuthorityForm.tsx @@ -0,0 +1,330 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@/components/ui/select"; +import { Input } from "@/components/ui/input"; +import { + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger +} from "@app/components/ui/tooltip"; +import { Alert, AlertDescription } from "@app/components/ui/alert"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient } from "@app/lib/api"; +import { formatAxiosError } from "@app/lib/api/formatAxiosError"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { type GetResourceResponse } from "@server/routers/resource"; +import { type ListTargetsResponse } from "@server/routers/target/listTargets"; +import { type ListSitesResponse } from "@server/routers/site/listSites"; +import { useTranslations } from "next-intl"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { Info, Globe, Server, AlertTriangle, ExternalLink, Copy } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import Link from "next/link"; + +interface DNSAuthorityFormProps { + resource: GetResourceResponse; + updateResource: (data: Partial) => void; + targets: ListTargetsResponse["targets"]; + sites: ListSitesResponse["sites"]; +} + +const dnsAuthoritySchema = z.object({ + dnsAuthorityEnabled: z.boolean(), + dnsAuthorityTtl: z.number().min(10).max(86400), + dnsAuthorityRoutingPolicy: z.enum(["failover", "roundrobin", "priority"]) +}); + +type DNSAuthorityFormData = z.infer; + +export function DNSAuthorityForm({ resource, updateResource, targets, sites }: DNSAuthorityFormProps) { + const t = useTranslations(); + const api = createApiClient(useEnvContext()); + const [isSubmitting, setIsSubmitting] = useState(false); + + const relevantSites = useMemo(() => { + if (!targets || !sites) return []; + const targetSiteIds = new Set(targets.map((t) => t.siteId)); + return sites.filter((site) => targetSiteIds.has(site.siteId)); + }, [targets, sites]); + + const hasHealthChecks = useMemo(() => { + if (!targets || targets.length === 0) return false; + return targets.some((t) => t.hcEnabled); + }, [targets]); + + const canEnableDNSAuthority = useMemo(() => { + if (!relevantSites || relevantSites.length < 1) return false; + + // Check if at least one relevant site has DNS Authority enabled with a Public IP + const sitesWithAuth = relevantSites.filter(s => s.dnsAuthorityEnabled && s.publicIp); + + // We need at least 1 site with DNS Authority enabled to allow turning this on. + // A second site can be added later without any downtime or reconfiguration. + return sitesWithAuth.length >= 1; + }, [relevantSites]); + + const defaultPolicy = (() => { + const saved = resource.dnsAuthorityRoutingPolicy as "failover" | "roundrobin" | "priority" | undefined; + if (!hasHealthChecks && (saved === "failover" || saved === "priority" || !saved)) { + return "roundrobin"; + } + return saved ?? "failover"; + })(); + + const form = useForm({ + resolver: zodResolver(dnsAuthoritySchema), + defaultValues: { + dnsAuthorityEnabled: resource.dnsAuthorityEnabled ?? false, + dnsAuthorityTtl: resource.dnsAuthorityTtl ?? 60, + dnsAuthorityRoutingPolicy: defaultPolicy + } + }); + + const dnsAuthorityEnabled = form.watch("dnsAuthorityEnabled"); + + // Auto-switch to roundrobin when health-dependent policies are selected but no healthchecks exist + useEffect(() => { + const currentPolicy = form.getValues("dnsAuthorityRoutingPolicy"); + if (!hasHealthChecks && (currentPolicy === "failover" || currentPolicy === "priority")) { + form.setValue("dnsAuthorityRoutingPolicy", "roundrobin"); + } + }, [hasHealthChecks, form]); + + const onSubmit = async (data: DNSAuthorityFormData) => { + setIsSubmitting(true); + try { + const res = await api.post(`/resource/${resource.resourceId}`, data); + if (res.status === 200) { + updateResource(data); + toast({ + title: t("dnsAuthorityUpdated"), + description: t("dnsAuthorityUpdatedDescription"), + }); + } + } catch (e) { + toast({ + title: t("error"), + description: formatAxiosError(e, t("dnsAuthorityUpdateError")), + variant: "destructive" + }); + } finally { + setIsSubmitting(false); + } + }; + + return ( + + + +
+
+ + {t("dnsAuthority")} +
+ {dnsAuthorityEnabled && ( + + + + )} +
+
+ + {t("dnsAuthorityDescription")} + +
+ + +
+ + + ( + +
+ + {t("dnsAuthorityEnable")} + + + {t("dnsAuthorityEnableDescription")} + +
+ + + +
+ )} + /> + + {!canEnableDNSAuthority && ( + + + + To enable Intelligent DNS Routing, at least one target's site must have DNS Authority enabled with a Public IP configured. + + + )} + + {dnsAuthorityEnabled && ( + <> + ( + + +
+ {t("dnsAuthorityRoutingPolicy")} + + + + + + +

{t("dnsAuthorityRoutingPolicyTooltip")}

+ {!hasHealthChecks && ( +

+ {t("dnsAuthorityPolicyNoHealthChecks")} +

+ )} +
+
+
+
+
+ + + {field.value === "failover" && t("dnsAuthorityPolicyFailoverDescription")} + {field.value === "roundrobin" && t("dnsAuthorityPolicyRoundRobinDescription")} + {field.value === "priority" && t("dnsAuthorityPolicyPriorityDescription")} + + +
+ )} + /> + + ( + + + {t("dnsAuthorityTtl")} + + + field.onChange(parseInt(e.target.value, 10))} + /> + + + {t("dnsAuthorityTtlDescription")} + + + + )} + /> + +
+
+ + {t("dnsAuthorityNsRecords")} +
+

+ {t("dnsAuthorityNsRecordsDescription")} +

+
+ {relevantSites.filter(s => s.dnsAuthorityEnabled && s.publicIp).map((site, index) => ( +
+
{resource.fullDomain} NS ns{index + 1}.{resource.fullDomain}
+
ns{index + 1}.{resource.fullDomain} A {site.publicIp}
+
+ ))} + {(!relevantSites.some(s => s.dnsAuthorityEnabled && s.publicIp)) && ( +
No DNS Authority sites configured
+ )} +
+

+ {t("dnsAuthorityNsRecordsNote")} +

+
+ + )} +
+ + +
+ +
+
+ ); +} diff --git a/src/components/SiteInfoCard.tsx b/src/components/SiteInfoCard.tsx index c0a9b36b1..5993adcb1 100644 --- a/src/components/SiteInfoCard.tsx +++ b/src/components/SiteInfoCard.tsx @@ -33,7 +33,7 @@ export default function SiteInfoCard({}: SiteInfoCardProps) { return ( - + {t("identifier")} {site.niceId} @@ -60,6 +60,34 @@ export default function SiteInfoCard({}: SiteInfoCardProps) { )} + {site?.dnsAuthorityEnabled && ( + + {t("dnsAuthorityShort")} + + {site?.dnsStatus === "running" ? ( +
+
+ {t("siteDnsAuthorityStatusRunning")} +
+ ) : site?.dnsStatus === "warning" ? ( +
+
+ {t("siteDnsAuthorityStatusWarning")} +
+ ) : site?.dnsStatus === "error" ? ( +
+
+ {t("siteDnsAuthorityStatusError")} +
+ ) : ( +
+
+ {t("siteDnsAuthorityStatusDisabled")} +
+ )} +
+
+ )} {t("connectionType")} @@ -68,6 +96,15 @@ export default function SiteInfoCard({}: SiteInfoCardProps) { {getConnectionTypeString(site.type)} + + {site.type == "newt" && ( + + Address + + {site.address?.split("/")[0]} + + + )}
diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index cc02e5d37..e2436e2f0 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -22,6 +22,7 @@ import { ArrowDown01Icon, ArrowRight, ArrowUp10Icon, + ArrowUpDown, ArrowUpRight, ChevronsUpDownIcon, MoreHorizontal @@ -53,6 +54,7 @@ export type SiteRow = { exitNodeName?: string; exitNodeEndpoint?: string; remoteExitNodeId?: string; + dnsStatus?: string | null; }; type SitesTableProps = { @@ -222,6 +224,60 @@ export default function SitesTable({ } } }, + { + id: "dnsAuthority", + accessorKey: "dnsStatus", + friendlyName: t("dnsAuthorityShort"), + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const status = row.original.dnsStatus; + if (!status || status === "disabled") { + return ( + +
+ {t("siteDnsAuthorityStatusDisabled")} +
+ ); + } + if (status === "running") { + return ( + +
+ {t("siteDnsAuthorityStatusRunning")} +
+ ); + } + if (status === "warning") { + return ( + +
+ {t("siteDnsAuthorityStatusWarning")} +
+ ); + } + if (status === "error") { + return ( + +
+ {t("error")} +
+ ); + } + return -; + } + }, { accessorKey: "mbIn", friendlyName: t("dataIn"), From 789309774759d844f8d008df2497d6e5d9d03306 Mon Sep 17 00:00:00 2001 From: mattv8 Date: Wed, 18 Feb 2026 04:37:00 +0000 Subject: [PATCH 02/10] Add TLS certificate handling to auth proxy configuration --- server/lib/readConfigFile.ts | 1 + server/lib/traefik/TraefikConfigManager.ts | 223 ++++++++++++++++++ server/routers/auth/authProxy.ts | 60 ++++- .../newt/handleAuthProxyStatusMessage.ts | 41 ++++ .../routers/newt/handleNewtRegisterMessage.ts | 19 ++ server/routers/newt/index.ts | 1 + server/routers/ws/messageHandlers.ts | 5 +- 7 files changed, 342 insertions(+), 8 deletions(-) create mode 100644 server/routers/newt/handleAuthProxyStatusMessage.ts diff --git a/server/lib/readConfigFile.ts b/server/lib/readConfigFile.ts index c3e796fc1..0a1bdd77d 100644 --- a/server/lib/readConfigFile.ts +++ b/server/lib/readConfigFile.ts @@ -238,6 +238,7 @@ export const configSchema = z cert_resolver: z.string().optional().default("letsencrypt"), prefer_wildcard_cert: z.boolean().optional().default(false), certificates_path: z.string().default("/var/certificates"), + acme_path: z.string().optional(), // path to Traefik's acme.json for cert extraction monitor_interval: z.number().default(5000), dynamic_cert_config_path: z .string() diff --git a/server/lib/traefik/TraefikConfigManager.ts b/server/lib/traefik/TraefikConfigManager.ts index 4aed80e45..5544735cd 100644 --- a/server/lib/traefik/TraefikConfigManager.ts +++ b/server/lib/traefik/TraefikConfigManager.ts @@ -1164,3 +1164,226 @@ export function isDomainCoveredByWildcard( } return false; } + +/** + * Certificate PEM data for a domain, read from the local Traefik certificate store. + */ +export interface CertificatePEM { + domain: string; // The domain this cert covers (prefixed with *. for wildcards) + certPem: string; // PEM-encoded certificate chain + keyPem: string; // PEM-encoded private key + expiresAt: number; // Unix timestamp (seconds) when the cert expires, or 0 if unknown + wildcard: boolean; // Whether this is a wildcard certificate +} + +/** + * Read TLS certificate PEM data from the local Traefik certificate store for the + * given list of domains. Uses the same directory layout as TraefikConfigManager: + * {certificates_path}/{domain}/cert.pem + * {certificates_path}/{domain}/key.pem + * {certificates_path}/{domain}/.expires_at (optional, unix timestamp) + * {certificates_path}/{domain}/.wildcard (optional, "true" if wildcard) + * + * For each domain, tries an exact-match directory first, then falls back to + * wildcard cert coverage (e.g. for "sub.example.com", checks "example.com" + * with a .wildcard marker). + * + * Returns one CertificatePEM per unique cert directory loaded (deduplicates + * when multiple domains share the same wildcard cert). + */ +export function readCertificatePEMsForDomains( + domains: string[] +): CertificatePEM[] { + const results: CertificatePEM[] = []; + const coveredDomains = new Set(); + + // 1. Try the certificates directory (written by the cert monitor) + const certsPath = config.getRawConfig().traefik?.certificates_path; + if (certsPath) { + const loadedDirs = new Set(); + for (const domain of domains) { + // Try exact match first + const exactDir = path.join(certsPath, domain); + if ( + !loadedDirs.has(exactDir) && + loadCertFromDir(exactDir, domain, false, results) + ) { + loadedDirs.add(exactDir); + coveredDomains.add(domain); + continue; + } + + // Try wildcard cert: for "sub.example.com", look at "example.com" + const parts = domain.split("."); + if (parts.length >= 2) { + const baseDomain = parts.slice(1).join("."); + const wildcardDir = path.join(certsPath, baseDomain); + if (!loadedDirs.has(wildcardDir)) { + const wildcardMarker = path.join(wildcardDir, ".wildcard"); + if ( + fs.existsSync(wildcardMarker) && + loadCertFromDir(wildcardDir, baseDomain, true, results) + ) { + loadedDirs.add(wildcardDir); + coveredDomains.add(domain); + } + } + } + } + } + + // 2. Fall back to Traefik's acme.json for any domains not yet covered + const acmePath = config.getRawConfig().traefik?.acme_path; + if (acmePath) { + const remaining = domains.filter((d) => !coveredDomains.has(d)); + if (remaining.length > 0) { + const acmeCerts = readCertsFromAcmeJson(acmePath, remaining); + results.push(...acmeCerts); + } + } + + return results; +} + +/** + * Read TLS certificates from Traefik's acme.json storage for the given domains. + * Certificates in acme.json are base64-encoded PEM. Tries exact domain match + * first, then falls back to wildcard (*.example.com covering sub.example.com). + * @internal — used by readCertificatePEMsForDomains + */ +function readCertsFromAcmeJson( + acmePath: string, + domains: string[] +): CertificatePEM[] { + try { + if (!fs.existsSync(acmePath)) { + logger.warn( + `Auth Proxy: acme.json not found at ${acmePath}` + ); + return []; + } + + type AcmeEntry = { + domain: { main: string; sans?: string[] }; + certificate: string; + key: string; + }; + type AcmeResolver = { Certificates?: AcmeEntry[] }; + + const raw: Record = JSON.parse( + fs.readFileSync(acmePath, "utf8") + ); + + const results: CertificatePEM[] = []; + // Track which acme cert (by main domain) we've already loaded to avoid duplicates + const loadedCertMains = new Set(); + + for (const resolver of Object.values(raw)) { + for (const entry of resolver.Certificates ?? []) { + const main = entry.domain?.main ?? ""; + const sans: string[] = entry.domain?.sans ?? []; + const allCertNames = [main, ...sans]; + + if (loadedCertMains.has(main)) continue; + + // Check if this cert covers any of the requested domains + const covers = domains.some((d) => + allCertNames.some((name) => acmeDomainCovers(name, d)) + ); + if (!covers) continue; + + try { + const certPem = Buffer.from( + entry.certificate, + "base64" + ).toString("utf8"); + const keyPem = Buffer.from( + entry.key, + "base64" + ).toString("utf8"); + const wildcard = main.startsWith("*."); + + results.push({ + domain: main, + certPem, + keyPem, + expiresAt: 0, // expiry is embedded in the cert itself + wildcard + }); + loadedCertMains.add(main); + logger.debug( + `Auth Proxy: Loaded cert for ${ + main + } from acme.json` + ); + } catch (e) { + logger.warn( + `Auth Proxy: Failed to decode acme.json cert for ${main}: ${e}` + ); + } + } + } + + return results; + } catch (e) { + logger.warn(`Auth Proxy: Failed to read acme.json at ${acmePath}: ${e}`); + return []; + } +} + +/** + * Returns true if a cert's SAN/CN entry `certName` covers `domain`. + * Handles wildcard certs (*.example.com) and exact matches. + * @internal + */ +function acmeDomainCovers(certName: string, domain: string): boolean { + const cn = certName.toLowerCase(); + const d = domain.toLowerCase(); + if (cn.startsWith("*.")) { + const base = cn.slice(2); + return d === base || d.endsWith("." + base); + } + return cn === d; +} + +/** + * Attempt to read cert.pem + key.pem from a directory. Returns true on success. + * @internal — used by readCertificatePEMsForDomains + */ +function loadCertFromDir( + dir: string, + domain: string, + wildcard: boolean, + out: CertificatePEM[] +): boolean { + const certPath = path.join(dir, "cert.pem"); + const keyPath = path.join(dir, "key.pem"); + + try { + if (!fs.existsSync(certPath) || !fs.existsSync(keyPath)) { + return false; + } + + const certPem = fs.readFileSync(certPath, "utf8"); + const keyPem = fs.readFileSync(keyPath, "utf8"); + + let expiresAt = 0; + const expiresAtPath = path.join(dir, ".expires_at"); + if (fs.existsSync(expiresAtPath)) { + const raw = fs.readFileSync(expiresAtPath, "utf8").trim(); + expiresAt = parseInt(raw, 10) || 0; + } + + out.push({ + domain: wildcard ? `*.${domain}` : domain, + certPem, + keyPem, + expiresAt, + wildcard + }); + return true; + } catch (err) { + logger.warn(`Failed to read TLS certificate from ${dir}: ${err}`); + return false; + } +} diff --git a/server/routers/auth/authProxy.ts b/server/routers/auth/authProxy.ts index d5cac4a59..9d6943102 100644 --- a/server/routers/auth/authProxy.ts +++ b/server/routers/auth/authProxy.ts @@ -12,6 +12,7 @@ import logger from "@server/logger"; import { eq, and, inArray } from "drizzle-orm"; import config from "@server/lib/config"; import { getJwtPublicKeyPem } from "@server/lib/jwtKeypair"; +import { readCertificatePEMsForDomains } from "@server/lib/traefik/TraefikConfigManager"; // AuthConfig holds the global authentication configuration for a site interface AuthConfig { @@ -35,11 +36,21 @@ interface ResourceAuthConfig { ssl: boolean; } +// TLSCertificateConfig holds a TLS certificate to push to Newt +interface TLSCertificateConfig { + domain: string; + certPem: string; + keyPem: string; + expiresAt: number; + wildcard: boolean; +} + // AuthProxyConfigMessage represents the message to send to Newt interface AuthProxyConfigMessage { action: "update" | "remove" | "start" | "stop"; auth: AuthConfig; resources: ResourceAuthConfig[]; + tlsCertificates?: TLSCertificateConfig[]; } /** @@ -96,12 +107,15 @@ export async function buildAuthProxyConfig( ) ); - // Filter to only resources with DNS authority and SSO/protection enabled - const protectedResources = siteTargets.filter( - (t: typeof siteTargets[0]) => t.dnsAuthorityEnabled && (t.sso || t.blockAccess || t.emailWhitelistEnabled) + // Get all DNS authority-enabled resources on this site. + // We need ALL of them (not just SSO-protected ones) because: + // - Protected resources need auth proxy + TLS termination + // - Unprotected resources still need TLS termination for HTTPS to work + const dnsAuthorityResources = siteTargets.filter( + (t: typeof siteTargets[0]) => t.dnsAuthorityEnabled ); - if (protectedResources.length === 0) { + if (dnsAuthorityResources.length === 0) { return null; } @@ -124,10 +138,13 @@ export async function buildAuthProxyConfig( // Build resource configs const resourceConfigs: ResourceAuthConfig[] = []; + const allDomains: string[] = []; - for (const target of protectedResources) { + for (const target of dnsAuthorityResources) { if (!target.fullDomain) continue; + allDomains.push(target.fullDomain); + // Get email whitelist for this resource let allowedEmails: string[] = []; if (target.emailWhitelistEnabled) { @@ -155,10 +172,21 @@ export async function buildAuthProxyConfig( }); } + // Read TLS certificates for all DNS authority domains from Traefik's cert store + const certPEMs = readCertificatePEMsForDomains(allDomains); + const tlsCertificates: TLSCertificateConfig[] = certPEMs.map((c) => ({ + domain: c.domain, + certPem: c.certPem, + keyPem: c.keyPem, + expiresAt: c.expiresAt, + wildcard: c.wildcard + })); + return { action: "update", auth: authConfig, - resources: resourceConfigs + resources: resourceConfigs, + tlsCertificates: tlsCertificates.length > 0 ? tlsCertificates : undefined }; } @@ -257,6 +285,26 @@ export async function updateAuthProxyForSite(siteId: number) { } } +/** + * Send auth proxy configuration (including TLS certs) to a Newt on connect/register. + * Called from handleNewtRegisterMessage to ensure Newt gets the full config immediately. + */ +export async function sendAllAuthProxyConfigsToNewt( + newtId: string, + siteId: number +) { + const authConfig = await buildAuthProxyConfig(siteId); + if (authConfig) { + await sendAuthProxyConfigToNewt(newtId, authConfig); + logger.info( + `Auth Proxy: Sent config with ${authConfig.resources.length} resource(s) and ${authConfig.tlsCertificates?.length || 0} cert(s) to Newt ${newtId} on connect` + ); + } else { + // Send empty config so Newt knows to clear any stale state + await sendAuthProxyConfigToNewt(newtId, buildEmptyAuthProxyConfigMessage()); + } +} + /** * Extract base domain from URL for cookie domain */ diff --git a/server/routers/newt/handleAuthProxyStatusMessage.ts b/server/routers/newt/handleAuthProxyStatusMessage.ts new file mode 100644 index 000000000..977b8c3e2 --- /dev/null +++ b/server/routers/newt/handleAuthProxyStatusMessage.ts @@ -0,0 +1,41 @@ +import { MessageHandler } from "@server/routers/ws"; +import { Newt } from "@server/db"; +import logger from "@server/logger"; + +export const handleAuthProxyStatusMessage: MessageHandler = async (context) => { + const { message, client } = context; + const newt = client as Newt; + + if (!newt) { + logger.warn("Auth proxy status message: Newt not found"); + return; + } + + if (!newt.siteId) { + logger.warn("Auth proxy status message: Newt has no site ID"); + return; + } + + const { + httpListening, + httpsListening, + httpSkipped, + httpsSkipped, + certCount, + resourceCount, + warning + } = message.data; + + if (warning) { + logger.warn( + `Auth proxy status for site ${newt.siteId} (newt ${newt.newtId}): ${warning}` + ); + } + + logger.info( + `Auth proxy status for site ${newt.siteId}: ` + + `HTTP=${httpListening ? "listening" : httpSkipped ? "skipped (port in use)" : "off"}, ` + + `HTTPS=${httpsListening ? "listening" : httpsSkipped ? "skipped (port in use)" : "off"}, ` + + `certs=${certCount}, resources=${resourceCount}` + ); +}; diff --git a/server/routers/newt/handleNewtRegisterMessage.ts b/server/routers/newt/handleNewtRegisterMessage.ts index 9fd0b50d4..294e55b20 100644 --- a/server/routers/newt/handleNewtRegisterMessage.ts +++ b/server/routers/newt/handleNewtRegisterMessage.ts @@ -15,6 +15,7 @@ import { lockManager } from "#dynamic/lib/lock"; import { buildTargetConfigurationForNewtClient } from "./buildConfiguration"; import { canCompress } from "@server/lib/clientVersionChecks"; import { sendAllDNSAuthorityConfigsToNewt } from "@server/routers/dns/dnsAuthority"; +import { sendAllAuthProxyConfigsToNewt } from "@server/routers/auth/authProxy"; export type ExitNodePingResult = { exitNodeId: number; @@ -76,6 +77,15 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { error ); } + // Also push auth proxy config (with TLS certs) for DNS authority + try { + await sendAllAuthProxyConfigsToNewt(newt.newtId, siteId); + } catch (error) { + logger.error( + `Failed to send auth proxy configs to Newt ${newt.newtId}:`, + error + ); + } }, 3000); return; @@ -177,6 +187,15 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { error ); } + // Also push auth proxy config (with TLS certs) for DNS authority + try { + await sendAllAuthProxyConfigsToNewt(newt.newtId, siteId); + } catch (error) { + logger.error( + `Failed to send auth proxy configs to Newt ${newt.newtId}:`, + error + ); + } }, 3000); if (!exitNodeIdToQuery) { diff --git a/server/routers/newt/index.ts b/server/routers/newt/index.ts index 0a4a58451..d9a7b52ca 100644 --- a/server/routers/newt/index.ts +++ b/server/routers/newt/index.ts @@ -11,3 +11,4 @@ export * from "./handleNewtDisconnectingMessage"; export * from "./handleConnectionLogMessage"; export * from "./registerNewt"; export * from "./handleDnsStatusMessage"; +export * from "./handleAuthProxyStatusMessage"; diff --git a/server/routers/ws/messageHandlers.ts b/server/routers/ws/messageHandlers.ts index 6cd6d01e6..98a32e6a8 100644 --- a/server/routers/ws/messageHandlers.ts +++ b/server/routers/ws/messageHandlers.ts @@ -9,9 +9,9 @@ import { handleApplyBlueprintMessage, handleNewtPingMessage, startNewtOfflineChecker, - handleNewtDisconnectingMessage handleNewtDisconnectingMessage, - handleDnsStatusMessage + handleDnsStatusMessage, + handleAuthProxyStatusMessage } from "../newt"; import { startPingAccumulator } from "../newt/pingAccumulator"; import { @@ -44,6 +44,7 @@ export const messageHandlers: Record = { "newt/socket/status": handleDockerStatusMessage, "newt/socket/containers": handleDockerContainersMessage, "newt/dns/status": handleDnsStatusMessage, + "newt/auth/proxy/status": handleAuthProxyStatusMessage, "newt/ping/request": handleNewtPingRequestMessage, "newt/blueprint/apply": handleApplyBlueprintMessage, "newt/healthcheck/status": handleHealthcheckStatusMessage, From 05c2b760208e46fc8fcdc8b58e406453a287839b Mon Sep 17 00:00:00 2001 From: mattv8 Date: Wed, 18 Feb 2026 18:46:33 -0700 Subject: [PATCH 03/10] Update target URL scheme to use method from target configuration --- server/routers/auth/authProxy.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/server/routers/auth/authProxy.ts b/server/routers/auth/authProxy.ts index 9d6943102..daeb5e075 100644 --- a/server/routers/auth/authProxy.ts +++ b/server/routers/auth/authProxy.ts @@ -156,8 +156,9 @@ export async function buildAuthProxyConfig( allowedEmails = whitelist.map((w: typeof whitelist[0]) => w.email); } - // Build target URL - const scheme = target.ssl ? "https" : "http"; + // Build target URL — use the target's method (http/https) for the backend + // connection, NOT the resource's ssl flag (which controls public-facing TLS) + const scheme = target.targetMethod || "http"; const targetUrl = `${scheme}://${target.targetIp}:${target.targetPort}`; resourceConfigs.push({ From 25c5e56b5fc59b48580c60f5b8ff8e278b9097f4 Mon Sep 17 00:00:00 2001 From: mattv8 Date: Thu, 19 Feb 2026 02:22:11 +0000 Subject: [PATCH 04/10] Add multi-target, path routing, and proxy settings to auth config --- server/routers/auth/authProxy.ts | 128 ++++++++++++++++++++++++++----- 1 file changed, 108 insertions(+), 20 deletions(-) diff --git a/server/routers/auth/authProxy.ts b/server/routers/auth/authProxy.ts index daeb5e075..0cd630dfc 100644 --- a/server/routers/auth/authProxy.ts +++ b/server/routers/auth/authProxy.ts @@ -32,8 +32,24 @@ interface ResourceAuthConfig { blockAccess: boolean; emailWhitelistEnabled: boolean; allowedEmails: string[]; - targetUrl: string; ssl: boolean; + // Proxy settings + targets: TargetConfig[]; + stickySession: boolean; + tlsServerName?: string; + setHostHeader?: string; + headers?: Record; + postAuthPath?: string; +} + +// TargetConfig holds a single backend target +interface TargetConfig { + targetUrl: string; + path?: string; + pathMatchType?: string; // exact, prefix, regex + rewritePath?: string; + rewritePathType?: string; // exact, prefix, regex, stripPrefix + priority?: number; } // TLSCertificateConfig holds a TLS certificate to push to Newt @@ -81,7 +97,7 @@ export async function buildAuthProxyConfig( return null; } - // Get all resources that have targets on this site with SSO or access control enabled + // Get all resources that have targets on this site const siteTargets = await db .select({ resourceId: targets.resourceId, @@ -89,6 +105,11 @@ export async function buildAuthProxyConfig( targetIp: targets.ip, targetPort: targets.port, targetMethod: targets.method, + targetPath: targets.path, + targetPathMatchType: targets.pathMatchType, + targetRewritePath: targets.rewritePath, + targetRewritePathType: targets.rewritePathType, + targetPriority: targets.priority, resourceName: resources.name, fullDomain: resources.fullDomain, sso: resources.sso, @@ -96,7 +117,12 @@ export async function buildAuthProxyConfig( emailWhitelistEnabled: resources.emailWhitelistEnabled, ssl: resources.ssl, http: resources.http, - dnsAuthorityEnabled: resources.dnsAuthorityEnabled + dnsAuthorityEnabled: resources.dnsAuthorityEnabled, + stickySession: resources.stickySession, + tlsServerName: resources.tlsServerName, + setHostHeader: resources.setHostHeader, + headers: resources.headers, + postAuthPath: resources.postAuthPath }) .from(targets) .innerJoin(resources, eq(targets.resourceId, resources.resourceId)) @@ -136,40 +162,102 @@ export async function buildAuthProxyConfig( sessionValidationUrl: `${resolvedDashboardUrl}/api/v1/auth/session/validate` }; + // Group targets by resourceId since multiple targets can exist per resource + const resourceMap = new Map< + number, + { + row: (typeof dnsAuthorityResources)[0]; + targets: TargetConfig[]; + } + >(); + + for (const t of dnsAuthorityResources) { + if (!t.fullDomain) continue; + + const scheme = t.targetMethod || "http"; + const targetUrl = `${scheme}://${t.targetIp}:${t.targetPort}`; + + const targetConfig: TargetConfig = { + targetUrl, + path: t.targetPath || undefined, + pathMatchType: t.targetPathMatchType || undefined, + rewritePath: t.targetRewritePath || undefined, + rewritePathType: t.targetRewritePathType || undefined, + priority: t.targetPriority ?? undefined + }; + + const existing = resourceMap.get(t.resourceId); + if (existing) { + existing.targets.push(targetConfig); + } else { + resourceMap.set(t.resourceId, { + row: t, + targets: [targetConfig] + }); + } + } + // Build resource configs const resourceConfigs: ResourceAuthConfig[] = []; const allDomains: string[] = []; - for (const target of dnsAuthorityResources) { - if (!target.fullDomain) continue; - - allDomains.push(target.fullDomain); + for (const [, { row, targets: tgts }] of resourceMap) { + allDomains.push(row.fullDomain!); // Get email whitelist for this resource let allowedEmails: string[] = []; - if (target.emailWhitelistEnabled) { + if (row.emailWhitelistEnabled) { const whitelist = await db .select() .from(resourceWhitelist) - .where(eq(resourceWhitelist.resourceId, target.resourceId)); + .where(eq(resourceWhitelist.resourceId, row.resourceId)); allowedEmails = whitelist.map((w: typeof whitelist[0]) => w.email); } - // Build target URL — use the target's method (http/https) for the backend - // connection, NOT the resource's ssl flag (which controls public-facing TLS) - const scheme = target.targetMethod || "http"; - const targetUrl = `${scheme}://${target.targetIp}:${target.targetPort}`; + // Parse custom headers JSON if present + // DB stores as [{name, value}, ...] array, convert to {name: value} map + let parsedHeaders: + | Record + | undefined; + if (row.headers) { + try { + const raw = + typeof row.headers === "string" + ? JSON.parse(row.headers) + : row.headers; + if (Array.isArray(raw)) { + parsedHeaders = {}; + for (const h of raw) { + if (h.name) parsedHeaders[h.name] = h.value || ""; + } + } else if (typeof raw === "object" && raw !== null) { + parsedHeaders = raw as Record; + } + } catch { + parsedHeaders = undefined; + } + } + + // Sort targets by priority (lower = higher priority) + tgts.sort( + (a, b) => (a.priority ?? 999) - (b.priority ?? 999) + ); resourceConfigs.push({ - resourceId: target.resourceId, - domain: target.fullDomain, - sso: target.sso || false, - blockAccess: target.blockAccess || false, - emailWhitelistEnabled: target.emailWhitelistEnabled || false, + resourceId: row.resourceId, + domain: row.fullDomain!, + sso: row.sso || false, + blockAccess: row.blockAccess || false, + emailWhitelistEnabled: row.emailWhitelistEnabled || false, allowedEmails, - targetUrl, - ssl: target.ssl || false + targets: tgts, + ssl: row.ssl || false, + stickySession: row.stickySession || false, + tlsServerName: row.tlsServerName || undefined, + setHostHeader: row.setHostHeader || undefined, + headers: parsedHeaders, + postAuthPath: row.postAuthPath || undefined }); } From e08931fa67f0129a90eb03c08eb5da97e5a2ec52 Mon Sep 17 00:00:00 2001 From: mattv8 Date: Wed, 18 Feb 2026 19:17:36 -0700 Subject: [PATCH 05/10] Add targetUrl for backward compatibility in ResourceAuthConfig --- server/routers/auth/authProxy.ts | 2 ++ server/routers/newt/handleDnsStatusMessage.ts | 16 +++++++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/server/routers/auth/authProxy.ts b/server/routers/auth/authProxy.ts index 0cd630dfc..951c62edc 100644 --- a/server/routers/auth/authProxy.ts +++ b/server/routers/auth/authProxy.ts @@ -34,6 +34,7 @@ interface ResourceAuthConfig { allowedEmails: string[]; ssl: boolean; // Proxy settings + targetUrl: string; // backward compat: first target URL for older Newt builds targets: TargetConfig[]; stickySession: boolean; tlsServerName?: string; @@ -251,6 +252,7 @@ export async function buildAuthProxyConfig( blockAccess: row.blockAccess || false, emailWhitelistEnabled: row.emailWhitelistEnabled || false, allowedEmails, + targetUrl: tgts.length > 0 ? tgts[0].targetUrl : "", // backward compat targets: tgts, ssl: row.ssl || false, stickySession: row.stickySession || false, diff --git a/server/routers/newt/handleDnsStatusMessage.ts b/server/routers/newt/handleDnsStatusMessage.ts index b5cdeec24..7aac20027 100644 --- a/server/routers/newt/handleDnsStatusMessage.ts +++ b/server/routers/newt/handleDnsStatusMessage.ts @@ -4,6 +4,10 @@ import { Newt } from "@server/db"; import { eq } from "drizzle-orm"; import logger from "@server/logger"; +// In-memory cache to avoid redundant DB writes and log spam +// when Newt sends identical consecutive status messages. +const lastDnsStatus = new Map(); + export const handleDnsStatusMessage: MessageHandler = async (context) => { const { message, client } = context; const newt = client as Newt; @@ -20,8 +24,18 @@ export const handleDnsStatusMessage: MessageHandler = async (context) => { const { status, error, address } = message.data; + // Deduplicate: skip if status + error unchanged since last message + const cacheKey = `${status}|${error || ""}`; + if (lastDnsStatus.get(newt.siteId) === cacheKey) { + logger.debug( + `DNS status unchanged for site ${newt.siteId}: status=${status} (skipped)` + ); + return; + } + lastDnsStatus.set(newt.siteId, cacheKey); + logger.info( - `Updating DNS status for site ${newt.siteId}: status=${status}, address=${address}, error=${error}` + `DNS status changed for site ${newt.siteId}: status=${status}, address=${address}, error=${error || "none"}` ); try { From 622f16b4b991430146852087636b7bad9c362aa5 Mon Sep 17 00:00:00 2001 From: mattv8 Date: Wed, 18 Feb 2026 20:31:42 -0700 Subject: [PATCH 06/10] feat(dns): include sticky metadata in authority config --- messages/en-US.json | 11 +-- server/db/pg/schema/schema.ts | 2 +- server/db/sqlite/schema/schema.ts | 2 +- server/routers/dns/dnsAuthority.ts | 94 ++++++++++++++++++++--- server/routers/resource/updateResource.ts | 55 ++++++++++++- server/setup/scriptsPg/1.16.0.ts | 18 +++++ src/components/DNSAuthorityForm.tsx | 12 ++- 7 files changed, 170 insertions(+), 24 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 759f0e096..53c007f39 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -641,13 +641,14 @@ "dnsAuthoritySsoDescription": "This resource has SSO enabled. When DNS Authority routes traffic directly to sites, authentication will still be enforced by a distributed auth proxy running on each site. Users will be redirected to Pangolin for login.", "dnsAuthorityRoutingPolicy": "Routing Policy", "dnsAuthorityRoutingPolicyTooltip": "Determines how DNS responses are selected based on target health status", - "dnsAuthorityPolicyFailover": "Failover", + "dnsAuthorityPolicyFailover": "Failover (One Healthy)", "dnsAuthorityPolicyRoundRobin": "Round Robin", - "dnsAuthorityPolicyPriority": "All Healthy", - "dnsAuthorityPolicyFailoverDescription": "Returns only the highest-priority healthy target. Falls back to the next priority if unhealthy.", + "dnsAuthorityPolicyPriority": "All Healthy (Client Chooses)", + "dnsAuthorityPolicyFailoverDescription": "Returns a single healthy target (highest priority first). If it is unhealthy, DNS fails over to the next healthy target.", "dnsAuthorityPolicyRoundRobinDescription": "Rotates through all healthy targets for load distribution.", - "dnsAuthorityPolicyPriorityDescription": "Returns all healthy targets, letting the client choose.", - "dnsAuthorityPolicyNoHealthChecks": "Failover and All Healthy require health checks to be configured on your targets. Enable health checks on at least one target to unlock these options.", + "dnsAuthorityPolicyPriorityDescription": "Returns all healthy targets in the DNS answer. Client resolver/application chooses which one to use.", + "dnsAuthorityPolicyIntelligentDescription": "Measures latency from each Newt and serves the lowest-latency healthy target. Falls back to failover if no fresh latency sample is available.", + "dnsAuthorityPolicyNoHealthChecks": "Failover, All Healthy, and Intelligent require health checks on your targets. Enable health checks on at least one target to unlock these options.", "dnsAuthorityTtl": "DNS TTL (seconds)", "dnsAuthorityTtlDescription": "Time-to-live for DNS responses. Lower values enable faster failover but increase DNS queries.", "dnsAuthorityNsRecords": "Required DNS Records", diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 3c5351c55..269009947 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -163,7 +163,7 @@ export const resources = pgTable("resources", { dnsAuthorityEnabled: boolean("dnsAuthorityEnabled").notNull().default(false), // Enable DNS authority for this resource dnsAuthorityTtl: integer("dnsAuthorityTtl").default(60), // TTL for DNS responses in seconds dnsAuthorityRoutingPolicy: text("dnsAuthorityRoutingPolicy", { - enum: ["failover", "roundrobin", "priority"] + enum: ["failover", "roundrobin", "priority", "intelligent"] }).default("failover") // Routing policy based on health checks }); diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 5157dc3f7..d53b87c26 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -187,7 +187,7 @@ export const resources = sqliteTable("resources", { .default(false), // Enable DNS authority for this resource dnsAuthorityTtl: integer("dnsAuthorityTtl").default(60), // TTL for DNS responses in seconds dnsAuthorityRoutingPolicy: text("dnsAuthorityRoutingPolicy", { - enum: ["failover", "roundrobin", "priority"] + enum: ["failover", "roundrobin", "priority", "intelligent"] }).default("failover") // Routing policy based on health checks }); diff --git a/server/routers/dns/dnsAuthority.ts b/server/routers/dns/dnsAuthority.ts index 9c6b0f974..fcb2f1e62 100644 --- a/server/routers/dns/dnsAuthority.ts +++ b/server/routers/dns/dnsAuthority.ts @@ -19,9 +19,65 @@ interface DNSAuthorityConfig { domain: string; ttl: number; routingPolicy: string; + stickySession?: boolean; + servingSiteId?: number; targets: DNSAuthorityTarget[]; } +function withServingSiteId( + config: DNSAuthorityConfig, + siteId: number +): DNSAuthorityConfig { + return { + ...config, + servingSiteId: siteId + }; +} + +export const healthDependentDNSRoutingPolicies = [ + "failover", + "priority", + "intelligent" +] as const; + +export function isHealthDependentDNSRoutingPolicy(policy: string): boolean { + return (healthDependentDNSRoutingPolicies as readonly string[]).includes( + policy + ); +} + +export function normalizeDNSRoutingPolicyForHealthChecks( + policy: string, + hasHealthChecks: boolean +): string { + if (!hasHealthChecks && isHealthDependentDNSRoutingPolicy(policy)) { + return "roundrobin"; + } + + return policy; +} + +export async function resourceHasEnabledHealthChecks( + resourceId: number +): Promise { + const [row] = await db + .select({ targetId: targets.targetId }) + .from(targets) + .innerJoin( + targetHealthCheck, + eq(targets.targetId, targetHealthCheck.targetId) + ) + .where( + and( + eq(targets.resourceId, resourceId), + eq(targetHealthCheck.hcEnabled, true) + ) + ) + .limit(1); + + return !!row; +} + // DNSAuthorityConfigMessage represents the message to send to Newt interface DNSAuthorityConfigMessage { action: "update" | "remove" | "start" | "stop"; @@ -104,11 +160,25 @@ export async function buildDNSAuthorityConfig( siteName: t.siteName || `Site ${t.siteId}` })); + const hasHealthChecks = validTargets.some((t) => t.hcEnabled); + const requestedPolicy = resource.dnsAuthorityRoutingPolicy || "failover"; + const routingPolicy = normalizeDNSRoutingPolicyForHealthChecks( + requestedPolicy, + hasHealthChecks + ); + + if (routingPolicy !== requestedPolicy) { + logger.debug( + `Resource ${resourceId} requested DNS policy ${requestedPolicy} without health checks; using roundrobin` + ); + } + return { enabled: true, domain: domain, ttl: resource.dnsAuthorityTtl || 60, - routingPolicy: resource.dnsAuthorityRoutingPolicy || "failover", + routingPolicy, + stickySession: resource.stickySession || false, targets: dnsTargets }; } @@ -176,11 +246,11 @@ export async function updateDNSAuthorityForResource(resourceId: number) { // Get all NEWT instances that should serve this resource const newtSites = await getDNSAuthoritySiteNewtIds(resourceId); - for (const { newtId } of newtSites) { + for (const { newtId, siteId } of newtSites) { if (config) { await sendDNSAuthorityConfigToNewt(newtId, { action: "update", - zones: [config] + zones: [withServingSiteId(config, siteId)] }); } else { // DNS authority disabled for this resource, remove the zone @@ -416,7 +486,7 @@ export async function buildDomainDNSAuthorityConfig( */ async function getDomainDNSAuthorityNewtIds( domainId: string -): Promise { +): Promise<{ newtId: string; siteId: number }[]> { const domainResources = await db .select({ resourceId: resources.resourceId }) .from(resources) @@ -429,6 +499,7 @@ async function getDomainDNSAuthorityNewtIds( const newtRows = await db .select({ newtId: newts.newtId, + siteId: sites.siteId, sitePublicIp: sites.publicIp, siteDnsAuthorityEnabled: sites.dnsAuthorityEnabled }) @@ -443,13 +514,16 @@ async function getDomainDNSAuthorityNewtIds( ); // Deduplicate and filter to DNS Authority sites - const newtIds = new Set(); + const newtBySite = new Map(); for (const row of newtRows) { if (row.newtId && row.sitePublicIp && row.siteDnsAuthorityEnabled) { - newtIds.add(row.newtId); + newtBySite.set(row.siteId, row.newtId); } } - return Array.from(newtIds); + return Array.from(newtBySite.entries()).map(([siteId, newtId]) => ({ + siteId, + newtId + })); } /** @@ -464,11 +538,11 @@ export async function updateDNSAuthorityForDomain(domainId: string) { return; } - for (const newtId of newtIds) { + for (const { newtId, siteId } of newtIds) { if (config) { await sendDNSAuthorityConfigToNewt(newtId, { action: "update", - zones: [config] + zones: [withServingSiteId(config, siteId)] }); } else { // No valid config — remove the wildcard zone @@ -607,7 +681,7 @@ export async function sendAllDNSAuthorityConfigsToNewt( if (allZones.length > 0) { await sendDNSAuthorityConfigToNewt(newtId, { action: "update", - zones: allZones + zones: allZones.map((zone) => withServingSiteId(zone, siteId)) }); logger.info( `DNS Authority: Sent ${allZones.length} zone(s) to Newt ${newtId} on connect` diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index fb9f53c6f..472a27bbf 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -25,7 +25,12 @@ import { validateAndConstructDomain } from "@server/lib/domainUtils"; import { build } from "@server/build"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; -import { updateDNSAuthorityForResource, updateDNSAuthorityForDomain } from "@server/routers/dns/dnsAuthority"; +import { + isHealthDependentDNSRoutingPolicy, + resourceHasEnabledHealthChecks, + updateDNSAuthorityForResource, + updateDNSAuthorityForDomain +} from "@server/routers/dns/dnsAuthority"; import { updateAuthProxyForResource } from "@server/routers/auth/authProxy"; const updateResourceParamsSchema = z.strictObject({ @@ -70,7 +75,7 @@ const updateHttpResourceBodySchema = z // DNS Authority fields dnsAuthorityEnabled: z.boolean().optional(), dnsAuthorityTtl: z.int().min(1).max(86400).optional(), - dnsAuthorityRoutingPolicy: z.enum(["failover", "roundrobin", "priority"]).optional() + dnsAuthorityRoutingPolicy: z.enum(["failover", "roundrobin", "priority", "intelligent"]).optional() }) .refine((data) => Object.keys(data).length > 0, { error: "At least one field must be provided for update" @@ -166,7 +171,7 @@ const updateRawResourceBodySchema = z // DNS Authority fields dnsAuthorityEnabled: z.boolean().optional(), dnsAuthorityTtl: z.int().min(1).max(86400).optional(), - dnsAuthorityRoutingPolicy: z.enum(["failover", "roundrobin", "priority"]).optional() + dnsAuthorityRoutingPolicy: z.enum(["failover", "roundrobin", "priority", "intelligent"]).optional() }) .refine((data) => Object.keys(data).length > 0, { error: "At least one field must be provided for update" @@ -302,6 +307,28 @@ async function updateHttpResource( const updateData = parsedBody.data; + if (updateData.dnsAuthorityEnabled || updateData.dnsAuthorityRoutingPolicy) { + const effectivePolicy = + updateData.dnsAuthorityRoutingPolicy || + resource.dnsAuthorityRoutingPolicy || + "failover"; + + if (isHealthDependentDNSRoutingPolicy(effectivePolicy)) { + const hasHealthChecks = await resourceHasEnabledHealthChecks( + resource.resourceId + ); + + if (!hasHealthChecks) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `dnsAuthorityRoutingPolicy '${effectivePolicy}' requires at least one enabled target health check for this resource. Use 'roundrobin' or enable health checks.` + ) + ); + } + } + } + if (updateData.niceId) { const [existingResource] = await db .select() @@ -510,6 +537,28 @@ async function updateRawResource( const updateData = parsedBody.data; + if (updateData.dnsAuthorityEnabled || updateData.dnsAuthorityRoutingPolicy) { + const effectivePolicy = + updateData.dnsAuthorityRoutingPolicy || + resource.dnsAuthorityRoutingPolicy || + "failover"; + + if (isHealthDependentDNSRoutingPolicy(effectivePolicy)) { + const hasHealthChecks = await resourceHasEnabledHealthChecks( + resource.resourceId + ); + + if (!hasHealthChecks) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `dnsAuthorityRoutingPolicy '${effectivePolicy}' requires at least one enabled target health check for this resource. Use 'roundrobin' or enable health checks.` + ) + ); + } + } + } + if (updateData.niceId) { const [existingResource] = await db .select() diff --git a/server/setup/scriptsPg/1.16.0.ts b/server/setup/scriptsPg/1.16.0.ts index 0bcfdc4a5..2137a7000 100644 --- a/server/setup/scriptsPg/1.16.0.ts +++ b/server/setup/scriptsPg/1.16.0.ts @@ -116,6 +116,24 @@ export default async function migration() { sql`UPDATE "roles" SET "sshSudoMode" = 'full' WHERE "isAdmin" = true;` ); + await db.execute(sql` + UPDATE "resources" + SET "dnsAuthorityRoutingPolicy" = 'failover' + WHERE "dnsAuthorityRoutingPolicy" IS NULL + OR "dnsAuthorityRoutingPolicy" NOT IN ('failover', 'roundrobin', 'priority', 'intelligent') + `); + + await db.execute(sql` + ALTER TABLE "resources" + DROP CONSTRAINT IF EXISTS "resources_dns_authority_routing_policy_check" + `); + + await db.execute(sql` + ALTER TABLE "resources" + ADD CONSTRAINT "resources_dns_authority_routing_policy_check" + CHECK ("dnsAuthorityRoutingPolicy" IN ('failover', 'roundrobin', 'priority', 'intelligent')) + `); + await db.execute(sql`COMMIT`); console.log("Migrated database"); } catch (e) { diff --git a/src/components/DNSAuthorityForm.tsx b/src/components/DNSAuthorityForm.tsx index 4bed76891..d171ea4e7 100644 --- a/src/components/DNSAuthorityForm.tsx +++ b/src/components/DNSAuthorityForm.tsx @@ -59,7 +59,7 @@ interface DNSAuthorityFormProps { const dnsAuthoritySchema = z.object({ dnsAuthorityEnabled: z.boolean(), dnsAuthorityTtl: z.number().min(10).max(86400), - dnsAuthorityRoutingPolicy: z.enum(["failover", "roundrobin", "priority"]) + dnsAuthorityRoutingPolicy: z.enum(["failover", "roundrobin", "priority", "intelligent"]) }); type DNSAuthorityFormData = z.infer; @@ -92,8 +92,8 @@ export function DNSAuthorityForm({ resource, updateResource, targets, sites }: D }, [relevantSites]); const defaultPolicy = (() => { - const saved = resource.dnsAuthorityRoutingPolicy as "failover" | "roundrobin" | "priority" | undefined; - if (!hasHealthChecks && (saved === "failover" || saved === "priority" || !saved)) { + const saved = resource.dnsAuthorityRoutingPolicy as "failover" | "roundrobin" | "priority" | "intelligent" | undefined; + if (!hasHealthChecks && (saved === "failover" || saved === "priority" || saved === "intelligent" || !saved)) { return "roundrobin"; } return saved ?? "failover"; @@ -113,7 +113,7 @@ export function DNSAuthorityForm({ resource, updateResource, targets, sites }: D // Auto-switch to roundrobin when health-dependent policies are selected but no healthchecks exist useEffect(() => { const currentPolicy = form.getValues("dnsAuthorityRoutingPolicy"); - if (!hasHealthChecks && (currentPolicy === "failover" || currentPolicy === "priority")) { + if (!hasHealthChecks && (currentPolicy === "failover" || currentPolicy === "priority" || currentPolicy === "intelligent")) { form.setValue("dnsAuthorityRoutingPolicy", "roundrobin"); } }, [hasHealthChecks, form]); @@ -251,12 +251,16 @@ export function DNSAuthorityForm({ resource, updateResource, targets, sites }: D {t("dnsAuthorityPolicyPriority")} + + Intelligent (Lowest Latency + Healthy) + {field.value === "failover" && t("dnsAuthorityPolicyFailoverDescription")} {field.value === "roundrobin" && t("dnsAuthorityPolicyRoundRobinDescription")} {field.value === "priority" && t("dnsAuthorityPolicyPriorityDescription")} + {field.value === "intelligent" && t("dnsAuthorityPolicyIntelligentDescription")} From 2c301326b567b063a5e0f14249f0d85df5b103ca Mon Sep 17 00:00:00 2001 From: mattv8 Date: Wed, 18 Feb 2026 20:24:41 -0700 Subject: [PATCH 07/10] feat(dns): persist target latency and fold migration into 1.16.0 --- server/db/pg/schema/schema.ts | 1 + server/db/sqlite/schema/schema.ts | 1 + server/routers/dns/dnsAuthority.ts | 33 ++++++++++++++++--- .../target/handleHealthcheckStatusMessage.ts | 6 ++++ server/setup/scriptsPg/1.16.0.ts | 7 ++++ 5 files changed, 43 insertions(+), 5 deletions(-) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 269009947..b9437f834 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -209,6 +209,7 @@ export const targetHealthCheck = pgTable("targetHealthCheck", { hcFollowRedirects: boolean("hcFollowRedirects").default(true), hcMethod: varchar("hcMethod").default("GET"), hcStatus: integer("hcStatus"), // http code + hcLatencyMs: integer("hcLatencyMs"), // most recent healthcheck latency in ms hcHealth: text("hcHealth") .$type<"unknown" | "healthy" | "unhealthy">() .default("unknown"), // "unknown", "healthy", "unhealthy" diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index d53b87c26..41e9c20ca 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -239,6 +239,7 @@ export const targetHealthCheck = sqliteTable("targetHealthCheck", { }).default(true), hcMethod: text("hcMethod").default("GET"), hcStatus: integer("hcStatus"), // http code + hcLatencyMs: integer("hcLatencyMs"), // most recent healthcheck latency in ms hcHealth: text("hcHealth") .$type<"unknown" | "healthy" | "unhealthy">() .default("unknown"), // "unknown", "healthy", "unhealthy" diff --git a/server/routers/dns/dnsAuthority.ts b/server/routers/dns/dnsAuthority.ts index fcb2f1e62..123b8a852 100644 --- a/server/routers/dns/dnsAuthority.ts +++ b/server/routers/dns/dnsAuthority.ts @@ -11,6 +11,7 @@ interface DNSAuthorityTarget { healthy: boolean; siteId: number; siteName: string; + backendLatencyMs?: number; } // DNSAuthorityConfig holds configuration for a DNS authority zone @@ -127,7 +128,8 @@ export async function buildDNSAuthorityConfig( sitePublicIp: sites.publicIp, siteDnsAuthorityEnabled: sites.dnsAuthorityEnabled, hcEnabled: targetHealthCheck.hcEnabled, - hcHealth: targetHealthCheck.hcHealth + hcHealth: targetHealthCheck.hcHealth, + hcLatencyMs: targetHealthCheck.hcLatencyMs }) .from(targets) .innerJoin(sites, eq(targets.siteId, sites.siteId)) @@ -157,7 +159,11 @@ export async function buildDNSAuthorityConfig( priority: t.priority || 100, healthy: t.hcEnabled ? t.hcHealth === "healthy" : true, // If no health check, assume healthy siteId: t.siteId, - siteName: t.siteName || `Site ${t.siteId}` + siteName: t.siteName || `Site ${t.siteId}`, + backendLatencyMs: + t.hcEnabled && typeof t.hcLatencyMs === "number" + ? t.hcLatencyMs + : undefined })); const hasHealthChecks = validTargets.some((t) => t.hcEnabled); @@ -408,7 +414,8 @@ export async function buildDomainDNSAuthorityConfig( sitePublicIp: sites.publicIp, siteDnsAuthorityEnabled: sites.dnsAuthorityEnabled, hcEnabled: targetHealthCheck.hcEnabled, - hcHealth: targetHealthCheck.hcHealth + hcHealth: targetHealthCheck.hcHealth, + hcLatencyMs: targetHealthCheck.hcLatencyMs }) .from(targets) .innerJoin(sites, eq(targets.siteId, sites.siteId)) @@ -438,6 +445,7 @@ export async function buildDomainDNSAuthorityConfig( name: string; healthy: boolean; minPriority: number; + minLatencyMs?: number; } >(); @@ -451,12 +459,26 @@ export async function buildDomainDNSAuthorityConfig( existing.minPriority, t.priority || 100 ); + if ( + typeof t.hcLatencyMs === "number" && + Number.isFinite(t.hcLatencyMs) + ) { + existing.minLatencyMs = + typeof existing.minLatencyMs === "number" + ? Math.min(existing.minLatencyMs, t.hcLatencyMs) + : t.hcLatencyMs; + } } else { siteMap.set(t.siteId, { ip: t.sitePublicIp!, name: t.siteName || `Site ${t.siteId}`, healthy: targetHealthy, - minPriority: t.priority || 100 + minPriority: t.priority || 100, + minLatencyMs: + typeof t.hcLatencyMs === "number" && + Number.isFinite(t.hcLatencyMs) + ? t.hcLatencyMs + : undefined }); } } @@ -468,7 +490,8 @@ export async function buildDomainDNSAuthorityConfig( priority: info.minPriority, healthy: info.healthy, siteId, - siteName: info.name + siteName: info.name, + backendLatencyMs: info.minLatencyMs })); return { diff --git a/server/routers/target/handleHealthcheckStatusMessage.ts b/server/routers/target/handleHealthcheckStatusMessage.ts index d4544ea38..5efe084a5 100644 --- a/server/routers/target/handleHealthcheckStatusMessage.ts +++ b/server/routers/target/handleHealthcheckStatusMessage.ts @@ -10,6 +10,7 @@ interface TargetHealthStatus { status: string; lastCheck: string; checkCount: number; + latencyMs?: number; lastError?: string; config: { id: string; @@ -117,6 +118,11 @@ export const handleHealthcheckStatusMessage: MessageHandler = async ( await db .update(targetHealthCheck) .set({ + hcLatencyMs: + typeof healthStatus.latencyMs === "number" && + Number.isFinite(healthStatus.latencyMs) + ? Math.max(0, Math.round(healthStatus.latencyMs)) + : null, hcHealth: healthStatus.status as | "unknown" | "healthy" diff --git a/server/setup/scriptsPg/1.16.0.ts b/server/setup/scriptsPg/1.16.0.ts index 2137a7000..a2ce62f25 100644 --- a/server/setup/scriptsPg/1.16.0.ts +++ b/server/setup/scriptsPg/1.16.0.ts @@ -116,6 +116,13 @@ export default async function migration() { sql`UPDATE "roles" SET "sshSudoMode" = 'full' WHERE "isAdmin" = true;` ); + // Add health check latency column used by intelligent DNS scoring + if (!(await columnExists("targetHealthCheck", "hcLatencyMs"))) { + await db.execute( + sql`ALTER TABLE "targetHealthCheck" ADD COLUMN "hcLatencyMs" integer;` + ); + } + await db.execute(sql` UPDATE "resources" SET "dnsAuthorityRoutingPolicy" = 'failover' From 33a5fd0b2ccc0bc8775f3e56dbac52d131128f31 Mon Sep 17 00:00:00 2001 From: mattv8 Date: Wed, 4 Mar 2026 20:32:41 -0700 Subject: [PATCH 08/10] chore(migrations): keep pg 1.16.0 upstream and move dns changes to 1.16.1 --- server/setup/scriptsPg/1.16.0.ts | 25 ------------------------- 1 file changed, 25 deletions(-) diff --git a/server/setup/scriptsPg/1.16.0.ts b/server/setup/scriptsPg/1.16.0.ts index a2ce62f25..0bcfdc4a5 100644 --- a/server/setup/scriptsPg/1.16.0.ts +++ b/server/setup/scriptsPg/1.16.0.ts @@ -116,31 +116,6 @@ export default async function migration() { sql`UPDATE "roles" SET "sshSudoMode" = 'full' WHERE "isAdmin" = true;` ); - // Add health check latency column used by intelligent DNS scoring - if (!(await columnExists("targetHealthCheck", "hcLatencyMs"))) { - await db.execute( - sql`ALTER TABLE "targetHealthCheck" ADD COLUMN "hcLatencyMs" integer;` - ); - } - - await db.execute(sql` - UPDATE "resources" - SET "dnsAuthorityRoutingPolicy" = 'failover' - WHERE "dnsAuthorityRoutingPolicy" IS NULL - OR "dnsAuthorityRoutingPolicy" NOT IN ('failover', 'roundrobin', 'priority', 'intelligent') - `); - - await db.execute(sql` - ALTER TABLE "resources" - DROP CONSTRAINT IF EXISTS "resources_dns_authority_routing_policy_check" - `); - - await db.execute(sql` - ALTER TABLE "resources" - ADD CONSTRAINT "resources_dns_authority_routing_policy_check" - CHECK ("dnsAuthorityRoutingPolicy" IN ('failover', 'roundrobin', 'priority', 'intelligent')) - `); - await db.execute(sql`COMMIT`); console.log("Migrated database"); } catch (e) { From 733b249defb7a991e272f9fe4c82c742dcc8e13d Mon Sep 17 00:00:00 2001 From: mattv8 Date: Wed, 8 Apr 2026 13:29:32 -0600 Subject: [PATCH 09/10] chore(migrations): bump dns migration to 1.17.1 --- server/setup/migrationsPg.ts | 4 ++-- server/setup/migrationsSqlite.ts | 4 ++-- server/setup/scriptsPg/{1.16.1.ts => 1.17.1.ts} | 2 +- server/setup/scriptsSqlite/{1.16.1.ts => 1.17.1.ts} | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) rename server/setup/scriptsPg/{1.16.1.ts => 1.17.1.ts} (99%) rename server/setup/scriptsSqlite/{1.16.1.ts => 1.17.1.ts} (99%) diff --git a/server/setup/migrationsPg.ts b/server/setup/migrationsPg.ts index 9bee28b21..bd822bb79 100644 --- a/server/setup/migrationsPg.ts +++ b/server/setup/migrationsPg.ts @@ -22,7 +22,7 @@ import m13 from "./scriptsPg/1.15.3"; import m14 from "./scriptsPg/1.15.4"; import m15 from "./scriptsPg/1.16.0"; import m16 from "./scriptsPg/1.17.0"; -import m17 from "./scriptsPg/1.16.1"; +import m17 from "./scriptsPg/1.17.1"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -45,7 +45,7 @@ const migrations = [ { version: "1.15.4", run: m14 }, { version: "1.16.0", run: m15 }, { version: "1.17.0", run: m16 }, - { version: "1.16.1", run: m17 } + { version: "1.17.1", run: m17 } // Add new migrations here as they are created ] as { version: string; diff --git a/server/setup/migrationsSqlite.ts b/server/setup/migrationsSqlite.ts index 55dbf20da..b42d8851d 100644 --- a/server/setup/migrationsSqlite.ts +++ b/server/setup/migrationsSqlite.ts @@ -40,7 +40,7 @@ import m34 from "./scriptsSqlite/1.15.3"; import m35 from "./scriptsSqlite/1.15.4"; import m36 from "./scriptsSqlite/1.16.0"; import m37 from "./scriptsSqlite/1.17.0"; -import m38 from "./scriptsSqlite/1.16.1"; +import m38 from "./scriptsSqlite/1.17.1"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -79,7 +79,7 @@ const migrations = [ { version: "1.15.4", run: m35 }, { version: "1.16.0", run: m36 }, { version: "1.17.0", run: m37 }, - { version: "1.16.1", run: m38 } + { version: "1.17.1", run: m38 } // Add new migrations here as they are created ] as const; diff --git a/server/setup/scriptsPg/1.16.1.ts b/server/setup/scriptsPg/1.17.1.ts similarity index 99% rename from server/setup/scriptsPg/1.16.1.ts rename to server/setup/scriptsPg/1.17.1.ts index 8038fccd4..0f783a912 100644 --- a/server/setup/scriptsPg/1.16.1.ts +++ b/server/setup/scriptsPg/1.17.1.ts @@ -1,7 +1,7 @@ import { db } from "@server/db/pg/driver"; import { sql } from "drizzle-orm"; -const version = "1.16.1"; +const version = "1.17.1"; export default async function migration() { console.log(`Running setup script ${version}...`); diff --git a/server/setup/scriptsSqlite/1.16.1.ts b/server/setup/scriptsSqlite/1.17.1.ts similarity index 99% rename from server/setup/scriptsSqlite/1.16.1.ts rename to server/setup/scriptsSqlite/1.17.1.ts index 1c8979fca..79f309568 100644 --- a/server/setup/scriptsSqlite/1.16.1.ts +++ b/server/setup/scriptsSqlite/1.17.1.ts @@ -2,7 +2,7 @@ import { APP_PATH } from "@server/lib/consts"; import Database from "better-sqlite3"; import path from "path"; -const version = "1.16.1"; +const version = "1.17.1"; export default async function migration() { console.log(`Running setup script ${version}...`); From 007711e79a9f063d534ececa427c768cab7d382a Mon Sep 17 00:00:00 2001 From: mattv8 Date: Wed, 8 Apr 2026 14:34:56 -0600 Subject: [PATCH 10/10] fix(proxy): load org sites in parent for DNS authority form --- .../settings/resources/proxy/[niceId]/proxy/page.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx index 7e6a6752f..bf060e9de 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx @@ -120,6 +120,12 @@ export default function ReverseProxyTargetsPage(props: { const params = use(props.params); const { resource, updateResource } = useResourceContext(); + const { data: sites = [] } = useQuery( + orgQueries.sites({ + orgId: params.orgId + }) + ); + const { data: remoteTargets = [], isLoading: isLoadingTargets } = useQuery( resourceQueries.resourceTargets({ resourceId: resource.resourceId