diff --git a/install/config/traefik/dynamic_config.yml b/install/config/traefik/dynamic_config.yml index 8fcf8e55..f795016b 100644 --- a/install/config/traefik/dynamic_config.yml +++ b/install/config/traefik/dynamic_config.yml @@ -51,3 +51,12 @@ http: loadBalancer: servers: - url: "http://pangolin:3000" # API/WebSocket server + +tcp: + serversTransports: + pp-transport-v1: + proxyProtocol: + version: 1 + pp-transport-v2: + proxyProtocol: + version: 2 \ No newline at end of file diff --git a/install/main.go b/install/main.go index 72ffbac0..a1b7d901 100644 --- a/install/main.go +++ b/install/main.go @@ -378,7 +378,7 @@ func collectUserInput(reader *bufio.Reader) Config { fmt.Println("\n=== Advanced Configuration ===") config.EnableIPv6 = readBool(reader, "Is your server IPv6 capable?", true) - config.EnableGeoblocking = readBool(reader, "Do you want to download the MaxMind GeoLite2 database for geoblocking functionality?", false) + config.EnableGeoblocking = readBool(reader, "Do you want to download the MaxMind GeoLite2 database for geoblocking functionality?", true) if config.DashboardDomain == "" { fmt.Println("Error: Dashboard Domain name is required") diff --git a/messages/en-US.json b/messages/en-US.json index 34ef3d7a..c2fd35cc 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1943,5 +1943,35 @@ "logRetentionForever": "Forever", "actionLogsDescription": "View a history of actions performed in this organization", "accessLogsDescription": "View access auth requests for resources in this organization", - "licenseRequiredToUse": "An Enterprise license is required to use this feature." -} \ No newline at end of file + "licenseRequiredToUse": "An Enterprise license is required to use this feature.", + "certResolver": "Certificate Resolver", + "certResolverDescription": "Select the certificate resolver to use for this resource.", + "selectCertResolver": "Select Certificate Resolver", + "enterCustomResolver": "Enter Custom Resolver", + "preferWildcardCert": "Prefer Wildcard Certificate", + "unverified": "Unverified", + "domainSetting": "Domain Settings", + "domainSettingDescription": "Configure settings for your domain", + "preferWildcardCertDescription": "Attempt to generate a wildcard certificate (require a properly configured certificate resolver).", + "recordName": "Record Name", + "auto": "Auto", + "TTL": "TTL", + "howToAddRecords": "How to Add Records", + "dnsRecord": "DNS Records", + "required": "Required", + "domainSettingsUpdated": "Domain settings updated successfully", + "orgOrDomainIdMissing": "Organization or Domain ID is missing", + "loadingDNSRecords": "Loading DNS records...", + "olmUpdateAvailableInfo": "An updated version of Olm is available. Please update to the latest version for the best experience.", + "client": "Client", + "proxyProtocol": "Proxy Protocol Settings", + "proxyProtocolDescription": "Configure Proxy Protocol to preserve client IP addresses for TCP/UDP services.", + "enableProxyProtocol": "Enable Proxy Protocol", + "proxyProtocolInfo": "Preserve client IP addresses for TCP/UDP backends", + "proxyProtocolVersion": "Proxy Protocol Version", + "version1": " Version 1 (Recommended)", + "version2": "Version 2", + "versionDescription": "Version 1 is text-based and widely supported. Version 2 is binary and more efficient but less compatible.", + "warning": "Warning", + "proxyProtocolWarning": "Your backend application must be configured to accept Proxy Protocol connections. If your backend doesn't support Proxy Protocol, enabling this will break all connections. Make sure to configure your backend to trust Proxy Protocol headers from Traefik." +} diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 1d22cff1..34278dfb 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -81,6 +81,9 @@ export enum ActionsEnum { listClients = "listClients", getClient = "getClient", listOrgDomains = "listOrgDomains", + getDomain = "getDomain", + updateOrgDomain = "updateOrgDomain", + getDNSRecords = "getDNSRecords", createNewt = "createNewt", createIdp = "createIdp", updateIdp = "updateIdp", diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 36e31804..b751517b 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -19,7 +19,22 @@ export const domains = pgTable("domains", { type: varchar("type"), // "ns", "cname", "wildcard" verified: boolean("verified").notNull().default(false), failed: boolean("failed").notNull().default(false), - tries: integer("tries").notNull().default(0) + tries: integer("tries").notNull().default(0), + certResolver: varchar("certResolver"), + customCertResolver: varchar("customCertResolver"), + preferWildcardCert: boolean("preferWildcardCert") +}); + + +export const dnsRecords = pgTable("dnsRecords", { + id: varchar("id").primaryKey(), + domainId: varchar("domainId") + .notNull() + .references(() => domains.domainId, { onDelete: "cascade" }), + recordType: varchar("recordType").notNull(), // "NS" | "CNAME" | "A" | "TXT" + baseDomain: varchar("baseDomain"), + value: varchar("value").notNull(), + verified: boolean("verified").notNull().default(false), }); export const orgs = pgTable("orgs", { @@ -111,7 +126,9 @@ export const resources = pgTable("resources", { skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, { onDelete: "cascade" }), - headers: text("headers") // comma-separated list of headers to add to the request + headers: text("headers"), // comma-separated list of headers to add to the request + proxyProtocol: boolean("proxyProtocol").notNull().default(false), + proxyProtocolVersion: integer("proxyProtocolVersion").default(1) }); export const targets = pgTable("targets", { diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 2d3a142c..a95d5d62 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -12,9 +12,24 @@ export const domains = sqliteTable("domains", { type: text("type"), // "ns", "cname", "wildcard" verified: integer("verified", { mode: "boolean" }).notNull().default(false), failed: integer("failed", { mode: "boolean" }).notNull().default(false), - tries: integer("tries").notNull().default(0) + tries: integer("tries").notNull().default(0), + certResolver: text("certResolver"), + preferWildcardCert: integer("preferWildcardCert", { mode: "boolean" }) }); +export const dnsRecords = sqliteTable("dnsRecords", { + id: text("id").primaryKey(), + domainId: text("domainId") + .notNull() + .references(() => domains.domainId, { onDelete: "cascade" }), + + recordType: text("recordType").notNull(), // "NS" | "CNAME" | "A" | "TXT" + baseDomain: text("baseDomain"), + value: text("value").notNull(), + verified: integer("verified", { mode: "boolean" }).notNull().default(false), +}); + + export const orgs = sqliteTable("orgs", { orgId: text("orgId").primaryKey(), name: text("name").notNull(), @@ -123,7 +138,10 @@ export const resources = sqliteTable("resources", { skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, { onDelete: "cascade" }), - headers: text("headers") // comma-separated list of headers to add to the request + headers: text("headers"), // comma-separated list of headers to add to the request + proxyProtocol: integer("proxyProtocol", { mode: "boolean" }).notNull().default(false), + proxyProtocolVersion: integer("proxyProtocolVersion").default(1) + }); export const targets = sqliteTable("targets", { @@ -797,6 +815,7 @@ export type ResourceWhitelist = InferSelectModel; export type VersionMigration = InferSelectModel; export type ResourceRule = InferSelectModel; export type Domain = InferSelectModel; +export type DnsRecord = InferSelectModel; export type Client = InferSelectModel; export type ClientSite = InferSelectModel; export type RoleClient = InferSelectModel; diff --git a/server/index.ts b/server/index.ts index b84728ef..68c7df4c 100644 --- a/server/index.ts +++ b/server/index.ts @@ -21,6 +21,7 @@ import { TraefikConfigManager } from "@server/lib/traefik/TraefikConfigManager"; import { initCleanup } from "#dynamic/cleanup"; import license from "#dynamic/license/license"; import { initLogCleanupInterval } from "@server/lib/cleanupLogs"; +import { fetchServerIp } from "@server/lib/serverIpService"; async function startServers() { await setHostMeta(); @@ -32,6 +33,8 @@ async function startServers() { await runSetupFunctions(); + await fetchServerIp(); + initTelemetryClient(); initLogCleanupInterval(); diff --git a/server/lib/blueprints/proxyResources.ts b/server/lib/blueprints/proxyResources.ts index a31cfb9d..37b69761 100644 --- a/server/lib/blueprints/proxyResources.ts +++ b/server/lib/blueprints/proxyResources.ts @@ -527,7 +527,7 @@ export async function updateProxyResources( if ( existingRule.action !== getRuleAction(rule.action) || existingRule.match !== rule.match.toUpperCase() || - existingRule.value !== rule.value + existingRule.value !== rule.value.toUpperCase() ) { validateRule(rule); await trx @@ -535,7 +535,7 @@ export async function updateProxyResources( .set({ action: getRuleAction(rule.action), match: rule.match.toUpperCase(), - value: rule.value + value: rule.value.toUpperCase(), }) .where( eq(resourceRules.ruleId, existingRule.ruleId) @@ -547,7 +547,7 @@ export async function updateProxyResources( resourceId: existingResource.resourceId, action: getRuleAction(rule.action), match: rule.match.toUpperCase(), - value: rule.value, + value: rule.value.toUpperCase(), priority: index + 1 // start priorities at 1 }); } @@ -705,7 +705,7 @@ export async function updateProxyResources( resourceId: newResource.resourceId, action: getRuleAction(rule.action), match: rule.match.toUpperCase(), - value: rule.value, + value: rule.value.toUpperCase(), priority: index + 1 // start priorities at 1 }); } diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts index 02f83f9d..de5c8a70 100644 --- a/server/lib/blueprints/types.ts +++ b/server/lib/blueprints/types.ts @@ -275,24 +275,26 @@ export const ConfigSchema = z } ) .refine( - // Enforce proxy-port uniqueness within proxy-resources + // Enforce proxy-port uniqueness within proxy-resources per protocol (config) => { - const proxyPortMap = new Map(); + const protocolPortMap = new Map(); Object.entries(config["proxy-resources"]).forEach( ([resourceKey, resource]) => { const proxyPort = resource["proxy-port"]; - if (proxyPort !== undefined) { - if (!proxyPortMap.has(proxyPort)) { - proxyPortMap.set(proxyPort, []); + const protocol = resource.protocol; + if (proxyPort !== undefined && protocol !== undefined) { + const key = `${protocol}:${proxyPort}`; + if (!protocolPortMap.has(key)) { + protocolPortMap.set(key, []); } - proxyPortMap.get(proxyPort)!.push(resourceKey); + protocolPortMap.get(key)!.push(resourceKey); } } ); // Find duplicates - const duplicates = Array.from(proxyPortMap.entries()).filter( + const duplicates = Array.from(protocolPortMap.entries()).filter( ([_, resourceKeys]) => resourceKeys.length > 1 ); @@ -300,25 +302,29 @@ export const ConfigSchema = z }, (config) => { // Extract duplicates for error message - const proxyPortMap = new Map(); + const protocolPortMap = new Map(); Object.entries(config["proxy-resources"]).forEach( ([resourceKey, resource]) => { const proxyPort = resource["proxy-port"]; - if (proxyPort !== undefined) { - if (!proxyPortMap.has(proxyPort)) { - proxyPortMap.set(proxyPort, []); + const protocol = resource.protocol; + if (proxyPort !== undefined && protocol !== undefined) { + const key = `${protocol}:${proxyPort}`; + if (!protocolPortMap.has(key)) { + protocolPortMap.set(key, []); } - proxyPortMap.get(proxyPort)!.push(resourceKey); + protocolPortMap.get(key)!.push(resourceKey); } } ); - const duplicates = Array.from(proxyPortMap.entries()) + const duplicates = Array.from(protocolPortMap.entries()) .filter(([_, resourceKeys]) => resourceKeys.length > 1) .map( - ([proxyPort, resourceKeys]) => - `port ${proxyPort} used by proxy-resources: ${resourceKeys.join(", ")}` + ([protocolPort, resourceKeys]) => { + const [protocol, port] = protocolPort.split(':'); + return `${protocol.toUpperCase()} port ${port} used by proxy-resources: ${resourceKeys.join(", ")}`; + } ) .join("; "); diff --git a/server/lib/serverIpService.ts b/server/lib/serverIpService.ts new file mode 100644 index 00000000..fb25c1c2 --- /dev/null +++ b/server/lib/serverIpService.ts @@ -0,0 +1,28 @@ +import axios from "axios"; + +let serverIp: string | null = null; + +const services = [ + "https://ifconfig.io/ip", + "https://api.ipify.org", + "https://checkip.amazonaws.com" +]; + +export async function fetchServerIp() { + for (const url of services) { + try { + const response = await axios.get(url, { timeout: 5000 }); + serverIp = response.data.trim(); + console.log("Detected public IP:", serverIp); + return; + } catch (err: any) { + console.warn(`Failed to fetch server IP from ${url}: ${err.message || err.code}`); + } + } + + console.error("All attempts to fetch server IP failed."); +} + +export function getServerIp() { + return serverIp; +} diff --git a/server/lib/traefik/TraefikConfigManager.ts b/server/lib/traefik/TraefikConfigManager.ts index ec4e25f4..56648559 100644 --- a/server/lib/traefik/TraefikConfigManager.ts +++ b/server/lib/traefik/TraefikConfigManager.ts @@ -309,10 +309,7 @@ export class TraefikConfigManager { this.lastActiveDomains = new Set(domains); } - if ( - process.env.USE_PANGOLIN_DNS === "true" && - build != "oss" - ) { + if (process.env.USE_PANGOLIN_DNS === "true" && build != "oss") { // Scan current local certificate state this.lastLocalCertificateState = await this.scanLocalCertificateState(); @@ -450,7 +447,8 @@ export class TraefikConfigManager { currentExitNode, config.getRawConfig().traefik.site_types, build == "oss", // filter out the namespace domains in open source - build != "oss" // generate the login pages on the cloud and hybrid + build != "oss", // generate the login pages on the cloud and hybrid, + build == "saas" ? false : config.getRawConfig().traefik.allow_raw_resources // dont allow raw resources on saas otherwise use config ); const domains = new Set(); @@ -502,6 +500,25 @@ export class TraefikConfigManager { }; } + // tcp: + // serversTransports: + // pp-transport-v1: + // proxyProtocol: + // version: 1 + // pp-transport-v2: + // proxyProtocol: + // version: 2 + + if (build != "saas") { + // add the serversTransports section if not present + if (traefikConfig.tcp && !traefikConfig.tcp.serversTransports) { + traefikConfig.tcp.serversTransports = { + "pp-transport-v1": { proxyProtocol: { version: 1 } }, + "pp-transport-v2": { proxyProtocol: { version: 2 } } + }; + } + } + return { domains, traefikConfig }; } catch (error) { // pull data out of the axios error to log diff --git a/server/lib/traefik/getTraefikConfig.ts b/server/lib/traefik/getTraefikConfig.ts index 5916d026..4352173b 100644 --- a/server/lib/traefik/getTraefikConfig.ts +++ b/server/lib/traefik/getTraefikConfig.ts @@ -1,4 +1,4 @@ -import { db, targetHealthCheck } from "@server/db"; +import { db, targetHealthCheck, domains } from "@server/db"; import { and, eq, @@ -23,7 +23,8 @@ export async function getTraefikConfig( exitNodeId: number, siteTypes: string[], filterOutNamespaceDomains = false, - generateLoginPageRouters = false + generateLoginPageRouters = false, + allowRawResources = true ): Promise { // Define extended target type with site information type TargetWithSite = Target & { @@ -56,6 +57,8 @@ export async function getTraefikConfig( setHostHeader: resources.setHostHeader, enableProxy: resources.enableProxy, headers: resources.headers, + proxyProtocol: resources.proxyProtocol, + proxyProtocolVersion: resources.proxyProtocolVersion, // Target fields targetId: targets.targetId, targetEnabled: targets.enabled, @@ -75,11 +78,14 @@ export async function getTraefikConfig( siteType: sites.type, siteOnline: sites.online, subnet: sites.subnet, - exitNodeId: sites.exitNodeId + exitNodeId: sites.exitNodeId, + // Domain cert resolver fields + domainCertResolver: domains.certResolver }) .from(sites) .innerJoin(targets, eq(targets.siteId, sites.siteId)) .innerJoin(resources, eq(resources.resourceId, targets.resourceId)) + .leftJoin(domains, eq(domains.domainId, resources.domainId)) .leftJoin( targetHealthCheck, eq(targetHealthCheck.targetId, targets.targetId) @@ -101,7 +107,7 @@ export async function getTraefikConfig( isNull(targetHealthCheck.hcHealth) // Include targets with no health check record ), inArray(sites.type, siteTypes), - config.getRawConfig().traefik.allow_raw_resources + allowRawResources ? isNotNull(resources.http) // ignore the http check if allow_raw_resources is true : eq(resources.http, true) ) @@ -164,11 +170,15 @@ export async function getTraefikConfig( enableProxy: row.enableProxy, targets: [], headers: row.headers, + proxyProtocol: row.proxyProtocol, + proxyProtocolVersion: row.proxyProtocolVersion ?? 1, path: row.path, // the targets will all have the same path pathMatchType: row.pathMatchType, // the targets will all have the same pathMatchType rewritePath: row.rewritePath, rewritePathType: row.rewritePathType, - priority: priority // may be null, we fallback later + priority: priority, + // Store domain cert resolver fields + domainCertResolver: row.domainCertResolver }); } @@ -247,30 +257,45 @@ export async function getTraefikConfig( wildCard = resource.fullDomain; } - const configDomain = config.getDomain(resource.domainId); + const globalDefaultResolver = + config.getRawConfig().traefik.cert_resolver; + const globalDefaultPreferWildcard = + config.getRawConfig().traefik.prefer_wildcard_cert; - let certResolver: string, preferWildcardCert: boolean; - if (!configDomain) { - certResolver = config.getRawConfig().traefik.cert_resolver; - preferWildcardCert = - config.getRawConfig().traefik.prefer_wildcard_cert; - } else { - certResolver = configDomain.cert_resolver; - preferWildcardCert = configDomain.prefer_wildcard_cert; - } + const domainCertResolver = resource.domainCertResolver; + const preferWildcardCert = resource.preferWildcardCert; - const tls = { - certResolver: certResolver, - ...(preferWildcardCert - ? { - domains: [ - { - main: wildCard - } - ] - } - : {}) - }; + let resolverName: string | undefined; + let preferWildcard: boolean | undefined; + // Handle both letsencrypt & custom cases + if (domainCertResolver) { + resolverName = domainCertResolver.trim(); + } else { + resolverName = globalDefaultResolver; + } + + if ( + preferWildcardCert !== undefined && + preferWildcardCert !== null + ) { + preferWildcard = preferWildcardCert; + } else { + preferWildcard = globalDefaultPreferWildcard; + } + + const tls = { + certResolver: resolverName, + ...(preferWildcard + ? { + domains: [ + { + main: wildCard + } + ] + } + : {}) + }; + const additionalMiddlewares = config.getRawConfig().traefik.additional_middlewares || []; @@ -509,14 +534,14 @@ export async function getTraefikConfig( })(), ...(resource.stickySession ? { - sticky: { - cookie: { - name: "p_sticky", // TODO: make this configurable via config.yml like other cookies - secure: resource.ssl, - httpOnly: true - } - } - } + sticky: { + cookie: { + name: "p_sticky", // TODO: make this configurable via config.yml like other cookies + secure: resource.ssl, + httpOnly: true + } + } + } : {}) } }; @@ -615,15 +640,20 @@ export async function getTraefikConfig( } }); })(), + ...(resource.proxyProtocol && protocol == "tcp" + ? { + serversTransport: `pp-transport-v${resource.proxyProtocolVersion || 1}` + } + : {}), ...(resource.stickySession ? { - sticky: { - ipStrategy: { - depth: 0, - sourcePort: true - } - } - } + sticky: { + ipStrategy: { + depth: 0, + sourcePort: true + } + } + } : {}) } }; diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index 881e4632..b8a2b32b 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -15,6 +15,7 @@ import { certificates, db, domainNamespaces, + domains, exitNodes, loginPage, targetHealthCheck @@ -50,7 +51,8 @@ export async function getTraefikConfig( exitNodeId: number, siteTypes: string[], filterOutNamespaceDomains = false, - generateLoginPageRouters = false + generateLoginPageRouters = false, + allowRawResources = true ): Promise { // Define extended target type with site information type TargetWithSite = Target & { @@ -104,11 +106,16 @@ export async function getTraefikConfig( subnet: sites.subnet, exitNodeId: sites.exitNodeId, // Namespace - domainNamespaceId: domainNamespaces.domainNamespaceId + domainNamespaceId: domainNamespaces.domainNamespaceId, + // Certificate + certificateStatus: certificates.status, + domainCertResolver: domains.certResolver, }) .from(sites) .innerJoin(targets, eq(targets.siteId, sites.siteId)) .innerJoin(resources, eq(resources.resourceId, targets.resourceId)) + .leftJoin(certificates, eq(certificates.domainId, resources.domainId)) + .leftJoin(domains, eq(domains.domainId, resources.domainId)) .leftJoin( targetHealthCheck, eq(targetHealthCheck.targetId, targets.targetId) @@ -135,7 +142,7 @@ export async function getTraefikConfig( isNull(targetHealthCheck.hcHealth) // Include targets with no health check record ), inArray(sites.type, siteTypes), - config.getRawConfig().traefik.allow_raw_resources + allowRawResources ? isNotNull(resources.http) // ignore the http check if allow_raw_resources is true : eq(resources.http, true) ) @@ -206,7 +213,8 @@ export async function getTraefikConfig( pathMatchType: row.pathMatchType, // the targets will all have the same pathMatchType rewritePath: row.rewritePath, rewritePathType: row.rewritePathType, - priority: priority // may be null, we fallback later + priority: priority, // may be null, we fallback later + domainCertResolver: row.domainCertResolver, }); } @@ -294,6 +302,20 @@ export async function getTraefikConfig( config_output.http.services = {}; } + const domainParts = fullDomain.split("."); + let wildCard; + if (domainParts.length <= 2) { + wildCard = `*.${domainParts.join(".")}`; + } else { + wildCard = `*.${domainParts.slice(1).join(".")}`; + } + + if (!resource.subdomain) { + wildCard = resource.fullDomain; + } + + const configDomain = config.getDomain(resource.domainId); + let tls = {}; if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) { const domainParts = fullDomain.split("."); @@ -324,13 +346,13 @@ export async function getTraefikConfig( certResolver: certResolver, ...(preferWildcardCert ? { - domains: [ - { - main: wildCard - } - ] - } - : {}) + domains: [ + { + main: wildCard, + }, + ], + } + : {}), }; } else { // find a cert that matches the full domain, if not continue @@ -582,14 +604,14 @@ export async function getTraefikConfig( })(), ...(resource.stickySession ? { - sticky: { - cookie: { - name: "p_sticky", // TODO: make this configurable via config.yml like other cookies - secure: resource.ssl, - httpOnly: true - } - } - } + sticky: { + cookie: { + name: "p_sticky", // TODO: make this configurable via config.yml like other cookies + secure: resource.ssl, + httpOnly: true + } + } + } : {}) } }; @@ -688,15 +710,20 @@ export async function getTraefikConfig( } }); })(), + ...(resource.proxyProtocol && protocol == "tcp" // proxy protocol only works for tcp + ? { + serversTransport: `pp-transport-v${resource.proxyProtocolVersion || 1}` + } + : {}), ...(resource.stickySession ? { - sticky: { - ipStrategy: { - depth: 0, - sourcePort: true - } - } - } + sticky: { + ipStrategy: { + depth: 0, + sourcePort: true + } + } + } : {}) } }; @@ -744,10 +771,9 @@ export async function getTraefikConfig( loadBalancer: { servers: [ { - url: `http://${ - config.getRawConfig().server + url: `http://${config.getRawConfig().server .internal_hostname - }:${config.getRawConfig().server.next_port}` + }:${config.getRawConfig().server.next_port}` } ] } diff --git a/server/private/routers/hybrid.ts b/server/private/routers/hybrid.ts index eedf4d2c..1fc5d512 100644 --- a/server/private/routers/hybrid.ts +++ b/server/private/routers/hybrid.ts @@ -272,7 +272,8 @@ hybridRouter.get( remoteExitNode.exitNodeId, ["newt", "local", "wireguard"], // Allow them to use all the site types true, // But don't allow domain namespace resources - false // Dont include login pages + false, // Dont include login pages, + true // allow raw resources ); return response(res, { diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index bd4a1a4b..af29dadb 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -911,9 +911,9 @@ async function checkRules( ) { return rule.action as any; } else if ( - ipCC && - rule.match == "GEOIP" && - (await isIpInGeoIP(ipCC, rule.value)) + clientIp && + rule.match == "COUNTRY" && + (await isIpInGeoIP(clientIp, rule.value)) ) { return rule.action as any; } diff --git a/server/routers/client/listClients.ts b/server/routers/client/listClients.ts index ff03b2e0..209b54b4 100644 --- a/server/routers/client/listClients.ts +++ b/server/routers/client/listClients.ts @@ -1,4 +1,4 @@ -import { db } from "@server/db"; +import { db, olms } from "@server/db"; import { clients, orgs, @@ -16,6 +16,67 @@ import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; +import NodeCache from "node-cache"; +import semver from "semver"; + +const olmVersionCache = new NodeCache({ stdTTL: 3600 }); + +async function getLatestOlmVersion(): Promise { + try { + const cachedVersion = olmVersionCache.get("latestOlmVersion"); + if (cachedVersion) { + return cachedVersion; + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 1500); + + const response = await fetch( + "https://api.github.com/repos/fosrl/olm/tags", + { + signal: controller.signal + } + ); + + clearTimeout(timeoutId); + + if (!response.ok) { + logger.warn( + `Failed to fetch latest Olm version from GitHub: ${response.status} ${response.statusText}` + ); + return null; + } + + const tags = await response.json(); + if (!Array.isArray(tags) || tags.length === 0) { + logger.warn("No tags found for Olm repository"); + return null; + } + + const latestVersion = tags[0].name; + + olmVersionCache.set("latestOlmVersion", latestVersion); + + return latestVersion; + } catch (error: any) { + if (error.name === "AbortError") { + logger.warn( + "Request to fetch latest Olm version timed out (1.5s)" + ); + } else if (error.cause?.code === "UND_ERR_CONNECT_TIMEOUT") { + logger.warn( + "Connection timeout while fetching latest Olm version" + ); + } else { + logger.warn( + "Error fetching latest Olm version:", + error.message || error + ); + } + return null; + } +} + const listClientsParamsSchema = z .object({ @@ -50,10 +111,12 @@ function queryClients(orgId: string, accessibleClientIds: number[]) { megabytesOut: clients.megabytesOut, orgName: orgs.name, type: clients.type, - online: clients.online + online: clients.online, + olmVersion: olms.version }) .from(clients) .leftJoin(orgs, eq(clients.orgId, orgs.orgId)) + .leftJoin(olms, eq(clients.clientId, olms.clientId)) .where( and( inArray(clients.clientId, accessibleClientIds), @@ -77,12 +140,20 @@ async function getSiteAssociations(clientIds: number[]) { .where(inArray(clientSites.clientId, clientIds)); } +type OlmWithUpdateAvailable = Awaited>[0] & { + olmUpdateAvailable?: boolean; +}; + + export type ListClientsResponse = { - clients: Array>[0] & { sites: Array<{ - siteId: number; - siteName: string | null; - siteNiceId: string | null; - }> }>; + clients: Array>[0] & { + sites: Array<{ + siteId: number; + siteName: string | null; + siteNiceId: string | null; + }> + olmUpdateAvailable?: boolean; + }>; pagination: { total: number; limit: number; offset: number }; }; @@ -206,6 +277,43 @@ export async function listClients( sites: sitesByClient[client.clientId] || [] })); + const latestOlVersionPromise = getLatestOlmVersion(); + + const olmsWithUpdates: OlmWithUpdateAvailable[] = clientsWithSites.map( + (client) => { + const OlmWithUpdate: OlmWithUpdateAvailable = { ...client }; + // Initially set to false, will be updated if version check succeeds + OlmWithUpdate.olmUpdateAvailable = false; + return OlmWithUpdate; + } + ); + + // Try to get the latest version, but don't block if it fails + try { + const latestOlVersion = await latestOlVersionPromise; + + if (latestOlVersion) { + olmsWithUpdates.forEach((client) => { + try { + client.olmUpdateAvailable = semver.lt( + client.olmVersion ? client.olmVersion : "", + latestOlVersion + ); + } catch (error) { + client.olmUpdateAvailable = false; + } + + }); + } + } catch (error) { + // Log the error but don't let it block the response + logger.warn( + "Failed to check for OLM updates, continuing without update info:", + error + ); + } + + return response(res, { data: { clients: clientsWithSites, diff --git a/server/routers/domain/createOrgDomain.ts b/server/routers/domain/createOrgDomain.ts index d0e8a72b..d40a0cb8 100644 --- a/server/routers/domain/createOrgDomain.ts +++ b/server/routers/domain/createOrgDomain.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db, Domain, domains, OrgDomains, orgDomains } from "@server/db"; +import { db, Domain, domains, OrgDomains, orgDomains, dnsRecords } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -24,16 +24,21 @@ const paramsSchema = z const bodySchema = z .object({ type: z.enum(["ns", "cname", "wildcard"]), - baseDomain: subdomainSchema + baseDomain: subdomainSchema, + certResolver: z.string().optional().nullable(), + preferWildcardCert: z.boolean().optional().nullable() // optional, only for wildcard }) .strict(); + export type CreateDomainResponse = { domainId: string; nsRecords?: string[]; cnameRecords?: { baseDomain: string; value: string }[]; aRecords?: { baseDomain: string; value: string }[]; txtRecords?: { baseDomain: string; value: string }[]; + certResolver?: string | null; + preferWildcardCert?: boolean | null; }; // Helper to check if a domain is a subdomain or equal to another domain @@ -71,7 +76,7 @@ export async function createOrgDomain( } const { orgId } = parsedParams.data; - const { type, baseDomain } = parsedBody.data; + const { type, baseDomain, certResolver, preferWildcardCert } = parsedBody.data; if (build == "oss") { if (type !== "wildcard") { @@ -254,7 +259,9 @@ export async function createOrgDomain( domainId, baseDomain, type, - verified: type === "wildcard" ? true : false + verified: type === "wildcard" ? true : false, + certResolver: certResolver || null, + preferWildcardCert: preferWildcardCert || false }) .returning(); @@ -269,9 +276,24 @@ export async function createOrgDomain( }) .returning(); + // Prepare DNS records to insert + const recordsToInsert = []; + // TODO: This needs to be cross region and not hardcoded if (type === "ns") { nsRecords = config.getRawConfig().dns.nameservers as string[]; + + // Save NS records to database + for (const nsValue of nsRecords) { + recordsToInsert.push({ + id: generateId(15), + domainId, + recordType: "NS", + baseDomain: baseDomain, + value: nsValue, + verified: false + }); + } } else if (type === "cname") { cnameRecords = [ { @@ -283,6 +305,18 @@ export async function createOrgDomain( baseDomain: `_acme-challenge.${baseDomain}` } ]; + + // Save CNAME records to database + for (const cnameRecord of cnameRecords) { + recordsToInsert.push({ + id: generateId(15), + domainId, + recordType: "CNAME", + baseDomain: cnameRecord.baseDomain, + value: cnameRecord.value, + verified: false + }); + } } else if (type === "wildcard") { aRecords = [ { @@ -294,6 +328,23 @@ export async function createOrgDomain( baseDomain: `${baseDomain}` } ]; + + // Save A records to database + for (const aRecord of aRecords) { + recordsToInsert.push({ + id: generateId(15), + domainId, + recordType: "A", + baseDomain: aRecord.baseDomain, + value: aRecord.value, + verified: true + }); + } + } + + // Insert all DNS records in batch + if (recordsToInsert.length > 0) { + await trx.insert(dnsRecords).values(recordsToInsert); } numOrgDomains = await trx @@ -325,7 +376,9 @@ export async function createOrgDomain( cnameRecords, txtRecords, nsRecords, - aRecords + aRecords, + certResolver: returned.certResolver, + preferWildcardCert: returned.preferWildcardCert }, success: true, error: false, diff --git a/server/routers/domain/getDNSRecords.ts b/server/routers/domain/getDNSRecords.ts new file mode 100644 index 00000000..c705b4fa --- /dev/null +++ b/server/routers/domain/getDNSRecords.ts @@ -0,0 +1,97 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, dnsRecords } from "@server/db"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { getServerIp } from "@server/lib/serverIpService"; // your in-memory IP module + +const getDNSRecordsSchema = z + .object({ + domainId: z.string(), + orgId: z.string() + }) + .strict(); + +async function query(domainId: string) { + const records = await db + .select() + .from(dnsRecords) + .where(eq(dnsRecords.domainId, domainId)); + + return records; +} + +export type GetDNSRecordsResponse = Awaited>; + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/domain/{domainId}/dns-records", + description: "Get all DNS records for a domain by domainId.", + tags: [OpenAPITags.Domain], + request: { + params: z.object({ + domainId: z.string(), + orgId: z.string() + }) + }, + responses: {} +}); + +export async function getDNSRecords( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = getDNSRecordsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { domainId } = parsedParams.data; + + const records = await query(domainId); + + if (!records || records.length === 0) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "No DNS records found for this domain" + ) + ); + } + + const serverIp = getServerIp(); + + // Override value for type A or wildcard records + const updatedRecords = records.map(record => { + if ((record.recordType === "A" || record.baseDomain === "*") && serverIp) { + return { ...record, value: serverIp }; + } + return record; + }); + + return response(res, { + data: updatedRecords, + success: true, + error: false, + message: "DNS records retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} \ No newline at end of file diff --git a/server/routers/domain/getDomain.ts b/server/routers/domain/getDomain.ts new file mode 100644 index 00000000..77bd18ae --- /dev/null +++ b/server/routers/domain/getDomain.ts @@ -0,0 +1,86 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, domains } from "@server/db"; +import { eq, and } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { domain } from "zod/v4/core/regexes"; + +const getDomainSchema = z + .object({ + domainId: z + .string() + .optional(), + orgId: z.string().optional() + }) + .strict(); + +async function query(domainId?: string, orgId?: string) { + if (domainId) { + const [res] = await db + .select() + .from(domains) + .where(eq(domains.domainId, domainId)) + .limit(1); + return res; + } +} + +export type GetDomainResponse = NonNullable>>; + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/domain/{domainId}", + description: "Get a domain by domainId.", + tags: [OpenAPITags.Domain], + request: { + params: z.object({ + domainId: z.string(), + orgId: z.string() + }) + }, + responses: {} +}); + +export async function getDomain( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = getDomainSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId, domainId } = parsedParams.data; + + const domain = await query(domainId, orgId); + + if (!domain) { + return next(createHttpError(HttpCode.NOT_FOUND, "Domain not found")); + } + + return response(res, { + data: domain, + success: true, + error: false, + message: "Domain retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/domain/index.ts b/server/routers/domain/index.ts index c0cafafe..e7e0b555 100644 --- a/server/routers/domain/index.ts +++ b/server/routers/domain/index.ts @@ -1,4 +1,7 @@ export * from "./listDomains"; export * from "./createOrgDomain"; export * from "./deleteOrgDomain"; -export * from "./restartOrgDomain"; \ No newline at end of file +export * from "./restartOrgDomain"; +export * from "./getDomain"; +export * from "./getDNSRecords"; +export * from "./updateDomain"; \ No newline at end of file diff --git a/server/routers/domain/listDomains.ts b/server/routers/domain/listDomains.ts index fe51cde6..55ea99cb 100644 --- a/server/routers/domain/listDomains.ts +++ b/server/routers/domain/listDomains.ts @@ -42,7 +42,9 @@ async function queryDomains(orgId: string, limit: number, offset: number) { type: domains.type, failed: domains.failed, tries: domains.tries, - configManaged: domains.configManaged + configManaged: domains.configManaged, + certResolver: domains.certResolver, + preferWildcardCert: domains.preferWildcardCert }) .from(orgDomains) .where(eq(orgDomains.orgId, orgId)) diff --git a/server/routers/domain/updateDomain.ts b/server/routers/domain/updateDomain.ts new file mode 100644 index 00000000..c684466e --- /dev/null +++ b/server/routers/domain/updateDomain.ts @@ -0,0 +1,161 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, domains, orgDomains } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { eq, and } from "drizzle-orm"; +import { OpenAPITags, registry } from "@server/openApi"; + +const paramsSchema = z + .object({ + orgId: z.string(), + domainId: z.string() + }) + .strict(); + +const bodySchema = z + .object({ + certResolver: z.string().optional().nullable(), + preferWildcardCert: z.boolean().optional().nullable() + }) + .strict(); + +export type UpdateDomainResponse = { + domainId: string; + certResolver: string | null; + preferWildcardCert: boolean | null; +}; + + +registry.registerPath({ + method: "patch", + path: "/org/{orgId}/domain/{domainId}", + description: "Update a domain by domainId.", + tags: [OpenAPITags.Domain], + request: { + params: z.object({ + domainId: z.string(), + orgId: z.string() + }) + }, + responses: {} +}); + +export async function updateOrgDomain( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { orgId, domainId } = parsedParams.data; + const { certResolver, preferWildcardCert } = parsedBody.data; + + const [orgDomain] = await db + .select() + .from(orgDomains) + .where( + and( + eq(orgDomains.orgId, orgId), + eq(orgDomains.domainId, domainId) + ) + ); + + if (!orgDomain) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Domain not found or does not belong to this organization" + ) + ); + } + + + const [existingDomain] = await db + .select() + .from(domains) + .where(eq(domains.domainId, domainId)); + + if (!existingDomain) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Domain not found") + ); + } + + if (existingDomain.type !== "wildcard") { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Domain settings can only be updated for wildcard domains" + ) + ); + } + + const updateData: Partial<{ + certResolver: string | null; + preferWildcardCert: boolean; + }> = {}; + + if (certResolver !== undefined) { + updateData.certResolver = certResolver; + } + + if (preferWildcardCert !== undefined && preferWildcardCert !== null) { + updateData.preferWildcardCert = preferWildcardCert; + } + + const [updatedDomain] = await db + .update(domains) + .set(updateData) + .where(eq(domains.domainId, domainId)) + .returning(); + + if (!updatedDomain) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to update domain" + ) + ); + } + + return response(res, { + data: { + domainId: updatedDomain.domainId, + certResolver: updatedDomain.certResolver, + preferWildcardCert: updatedDomain.preferWildcardCert + }, + success: true, + error: false, + message: "Domain updated successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} \ No newline at end of file diff --git a/server/routers/external.ts b/server/routers/external.ts index 335bc252..380746c4 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -318,6 +318,27 @@ authenticated.get( domain.listDomains ); +authenticated.get( + "/org/:orgId/domain/:domainId", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.getDomain), + domain.getDomain +); + +authenticated.patch( + "/org/:orgId/domain/:domainId", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.updateOrgDomain), + domain.updateOrgDomain +); + +authenticated.get( + "/org/:orgId/domain/:domainId/dns-records", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.getDNSRecords), + domain.getDNSRecords +); + authenticated.get( "/org/:orgId/invitations", verifyOrgAccess, diff --git a/server/routers/resource/createResourceRule.ts b/server/routers/resource/createResourceRule.ts index 7cb83d8b..1a5c07c2 100644 --- a/server/routers/resource/createResourceRule.ts +++ b/server/routers/resource/createResourceRule.ts @@ -18,7 +18,7 @@ import { OpenAPITags, registry } from "@server/openApi"; const createResourceRuleSchema = z .object({ action: z.enum(["ACCEPT", "DROP", "PASS"]), - match: z.enum(["CIDR", "IP", "PATH", "GEOIP"]), + match: z.enum(["CIDR", "IP", "PATH", "COUNTRY"]), value: z.string().min(1), priority: z.number().int(), enabled: z.boolean().optional() diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index a9c3b5de..13c5220d 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -99,8 +99,9 @@ const updateRawResourceBodySchema = z name: z.string().min(1).max(255).optional(), proxyPort: z.number().int().min(1).max(65535).optional(), stickySession: z.boolean().optional(), - enabled: z.boolean().optional() - // enableProxy: z.boolean().optional() // always true now + enabled: z.boolean().optional(), + proxyProtocol: z.boolean().optional(), + proxyProtocolVersion: z.number().int().min(1).optional() }) .strict() .refine((data) => Object.keys(data).length > 0, { diff --git a/server/routers/resource/updateResourceRule.ts b/server/routers/resource/updateResourceRule.ts index 06061da9..8df70c0f 100644 --- a/server/routers/resource/updateResourceRule.ts +++ b/server/routers/resource/updateResourceRule.ts @@ -30,7 +30,7 @@ const updateResourceRuleParamsSchema = z const updateResourceRuleSchema = z .object({ action: z.enum(["ACCEPT", "DROP", "PASS"]).optional(), - match: z.enum(["CIDR", "IP", "PATH", "GEOIP"]).optional(), + match: z.enum(["CIDR", "IP", "PATH", "COUNTRY"]).optional(), value: z.string().min(1).optional(), priority: z.number().int(), enabled: z.boolean().optional() diff --git a/server/routers/traefik/traefikConfigProvider.ts b/server/routers/traefik/traefikConfigProvider.ts index 6c9404e9..9b12ed8a 100644 --- a/server/routers/traefik/traefikConfigProvider.ts +++ b/server/routers/traefik/traefikConfigProvider.ts @@ -21,7 +21,8 @@ export async function traefikConfigProvider( currentExitNodeId, config.getRawConfig().traefik.site_types, build == "oss", // filter out the namespace domains in open source - build != "oss" // generate the login pages on the cloud and hybrid + build != "oss", // generate the login pages on the cloud and and enterprise, + config.getRawConfig().traefik.allow_raw_resources ); if (traefikConfig?.http?.middlewares) { diff --git a/server/setup/copyInConfig.ts b/server/setup/copyInConfig.ts index b8c00192..e003d089 100644 --- a/server/setup/copyInConfig.ts +++ b/server/setup/copyInConfig.ts @@ -37,7 +37,9 @@ async function copyInDomains() { const configDomains = Object.entries(rawDomains).map( ([key, value]) => ({ domainId: key, - baseDomain: value.base_domain.toLowerCase() + baseDomain: value.base_domain.toLowerCase(), + certResolver: value.cert_resolver || null, + preferWildcardCert: value.prefer_wildcard_cert || null, }) ); @@ -59,11 +61,11 @@ async function copyInDomains() { } } - for (const { domainId, baseDomain } of configDomains) { + for (const { domainId, baseDomain, certResolver, preferWildcardCert } of configDomains) { if (existingDomainKeys.has(domainId)) { await trx .update(domains) - .set({ baseDomain, verified: true, type: "wildcard" }) + .set({ baseDomain, verified: true, type: "wildcard", certResolver, preferWildcardCert }) .where(eq(domains.domainId, domainId)) .execute(); } else { @@ -74,7 +76,9 @@ async function copyInDomains() { baseDomain, configManaged: true, type: "wildcard", - verified: true + verified: true, + certResolver, + preferWildcardCert }) .execute(); } diff --git a/server/setup/migrationsPg.ts b/server/setup/migrationsPg.ts index c8e632e0..b6d20512 100644 --- a/server/setup/migrationsPg.ts +++ b/server/setup/migrationsPg.ts @@ -13,6 +13,7 @@ import m5 from "./scriptsPg/1.10.0"; import m6 from "./scriptsPg/1.10.2"; import m7 from "./scriptsPg/1.11.0"; import m8 from "./scriptsPg/1.11.1"; +import m9 from "./scriptsPg/1.11.2"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -26,7 +27,8 @@ const migrations = [ { version: "1.10.0", run: m5 }, { version: "1.10.2", run: m6 }, { version: "1.11.0", run: m7 }, - { version: "1.11.1", run: m8 } + { version: "1.11.1", run: m8 }, + { version: "1.11.2", run: m9 } // Add new migrations here as they are created ] as { version: string; diff --git a/server/setup/migrationsSqlite.ts b/server/setup/migrationsSqlite.ts index e65d7436..d60db7a0 100644 --- a/server/setup/migrationsSqlite.ts +++ b/server/setup/migrationsSqlite.ts @@ -31,6 +31,7 @@ import m26 from "./scriptsSqlite/1.10.1"; import m27 from "./scriptsSqlite/1.10.2"; import m28 from "./scriptsSqlite/1.11.0"; import m29 from "./scriptsSqlite/1.11.1"; +import m30 from "./scriptsSqlite/1.11.2"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -60,7 +61,8 @@ const migrations = [ { version: "1.10.1", run: m26 }, { version: "1.10.2", run: m27 }, { version: "1.11.0", run: m28 }, - { version: "1.11.1", run: m29 } + { version: "1.11.1", run: m29 }, + { version: "1.11.2", run: m30 } // Add new migrations here as they are created ] as const; diff --git a/server/setup/scriptsPg/1.11.2.ts b/server/setup/scriptsPg/1.11.2.ts new file mode 100644 index 00000000..6f61e727 --- /dev/null +++ b/server/setup/scriptsPg/1.11.2.ts @@ -0,0 +1,24 @@ +import { db } from "@server/db/pg/driver"; +import { sql } from "drizzle-orm"; + +const version = "1.11.2"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + try { + await db.execute(sql`BEGIN`); + + await db.execute(sql`UPDATE "resourceRules" SET "match" = "COUNTRY" WHERE "match" = "GEOIP"`); + + await db.execute(sql`COMMIT`); + console.log(`Updated resource rules match value from GEOIP to COUNTRY`); + } catch (e) { + await db.execute(sql`ROLLBACK`); + console.log("Unable to update resource rules match value"); + console.log(e); + throw e; + } + + console.log(`${version} migration complete`); +} diff --git a/server/setup/scriptsSqlite/1.11.2.ts b/server/setup/scriptsSqlite/1.11.2.ts new file mode 100644 index 00000000..dfc1b7ae --- /dev/null +++ b/server/setup/scriptsSqlite/1.11.2.ts @@ -0,0 +1,18 @@ +import { APP_PATH } from "@server/lib/consts"; +import Database from "better-sqlite3"; +import path from "path"; + +const version = "1.11.2"; + +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); + + db.transaction(() => { + db.prepare(`UPDATE resourceRules SET match = "COUNTRY" WHERE match = "GEOIP"`).run(); + })(); + + console.log(`${version} migration complete`); +} diff --git a/src/app/[orgId]/settings/clients/page.tsx b/src/app/[orgId]/settings/clients/page.tsx index 0813ad3c..fd73b736 100644 --- a/src/app/[orgId]/settings/clients/page.tsx +++ b/src/app/[orgId]/settings/clients/page.tsx @@ -44,7 +44,9 @@ export default async function ClientsPage(props: ClientsPageProps) { mbIn: formatSize(client.megabytesIn || 0), mbOut: formatSize(client.megabytesOut || 0), orgId: params.orgId, - online: client.online + online: client.online, + olmVersion: client.olmVersion || undefined, + olmUpdateAvailable: client.olmUpdateAvailable || false, }; }); diff --git a/src/app/[orgId]/settings/domains/[domainId]/layout.tsx b/src/app/[orgId]/settings/domains/[domainId]/layout.tsx new file mode 100644 index 00000000..d33d666a --- /dev/null +++ b/src/app/[orgId]/settings/domains/[domainId]/layout.tsx @@ -0,0 +1,32 @@ +import { redirect } from "next/navigation"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { internal } from "@app/lib/api"; +import { GetDomainResponse } from "@server/routers/domain/getDomain"; +import { AxiosResponse } from "axios"; +import DomainProvider from "@app/providers/DomainProvider"; + +interface SettingsLayoutProps { + children: React.ReactNode; + params: Promise<{ domainId: string; orgId: string }>; +} + +export default async function SettingsLayout({ children, params }: SettingsLayoutProps) { + const { domainId, orgId } = await params; + let domain = null; + + try { + const res = await internal.get>( + `/org/${orgId}/domain/${domainId}`, + await authCookieHeader() + ); + domain = res.data.data; + } catch { + redirect(`/${orgId}/settings/domains`); + } + + return ( + + {children} + + ); +} \ No newline at end of file diff --git a/src/app/[orgId]/settings/domains/[domainId]/page.tsx b/src/app/[orgId]/settings/domains/[domainId]/page.tsx new file mode 100644 index 00000000..c7e137f6 --- /dev/null +++ b/src/app/[orgId]/settings/domains/[domainId]/page.tsx @@ -0,0 +1,104 @@ +"use client"; +import { useState } from "react"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { useRouter } from "next/navigation"; +import { RefreshCw } from "lucide-react"; +import { Button } from "@app/components/ui/button"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import DomainInfoCard from "@app/components/DomainInfoCard"; +import { useDomain } from "@app/contexts/domainContext"; +import { useTranslations } from "next-intl"; + +export default function DomainSettingsPage() { + const { domain, orgId } = useDomain(); + const router = useRouter(); + const api = createApiClient(useEnvContext()); + const [isRefreshing, setIsRefreshing] = useState(false); + const [restartingDomains, setRestartingDomains] = useState>(new Set()); + const t = useTranslations(); + + const refreshData = async () => { + setIsRefreshing(true); + try { + await new Promise((resolve) => setTimeout(resolve, 200)); + router.refresh(); + } catch { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive", + }); + } finally { + setIsRefreshing(false); + } + }; + + const restartDomain = async (domainId: string) => { + setRestartingDomains((prev) => new Set(prev).add(domainId)); + try { + await api.post(`/org/${orgId}/domain/${domainId}/restart`); + toast({ + title: t("success"), + description: t("domainRestartedDescription", { + fallback: "Domain verification restarted successfully", + }), + }); + refreshData(); + } catch (e) { + toast({ + title: t("error"), + description: formatAxiosError(e), + variant: "destructive", + }); + } finally { + setRestartingDomains((prev) => { + const newSet = new Set(prev); + newSet.delete(domainId); + return newSet; + }); + } + }; + + if (!domain) { + return null; + } + + const isRestarting = restartingDomains.has(domain.domainId); + + return ( + <> +
+ + +
+
+ +
+ + ); +} \ No newline at end of file diff --git a/src/app/[orgId]/settings/domains/page.tsx b/src/app/[orgId]/settings/domains/page.tsx index cb587d92..2c667b3a 100644 --- a/src/app/[orgId]/settings/domains/page.tsx +++ b/src/app/[orgId]/settings/domains/page.tsx @@ -60,7 +60,7 @@ export default async function DomainsPage(props: Props) { title={t("domains")} description={t("domainsDescription")} /> - + ); diff --git a/src/app/[orgId]/settings/resources/[niceId]/proxy/page.tsx b/src/app/[orgId]/settings/resources/[niceId]/proxy/page.tsx index 9588e0c8..5ef2ccd5 100644 --- a/src/app/[orgId]/settings/resources/[niceId]/proxy/page.tsx +++ b/src/app/[orgId]/settings/resources/[niceId]/proxy/page.tsx @@ -77,7 +77,8 @@ import { MoveRight, ArrowUp, Info, - ArrowDown + ArrowDown, + AlertTriangle } from "lucide-react"; import { ContainersSelector } from "@app/components/ContainersSelector"; import { useTranslations } from "next-intl"; @@ -115,6 +116,7 @@ import { TooltipProvider, TooltipTrigger } from "@app/components/ui/tooltip"; +import { Alert, AlertDescription } from "@app/components/ui/alert"; const addTargetSchema = z .object({ @@ -288,7 +290,9 @@ export default function ReverseProxyTargets(props: { ), headers: z .array(z.object({ name: z.string(), value: z.string() })) - .nullable() + .nullable(), + proxyProtocol: z.boolean().optional(), + proxyProtocolVersion: z.number().int().min(1).max(2).optional() }); const tlsSettingsSchema = z.object({ @@ -325,7 +329,9 @@ export default function ReverseProxyTargets(props: { resolver: zodResolver(proxySettingsSchema), defaultValues: { setHostHeader: resource.setHostHeader || "", - headers: resource.headers + headers: resource.headers, + proxyProtocol: resource.proxyProtocol || false, + proxyProtocolVersion: resource.proxyProtocolVersion || 1 } }); @@ -549,11 +555,11 @@ export default function ReverseProxyTargets(props: { prev.map((t) => t.targetId === target.targetId ? { - ...t, - targetId: response.data.data.targetId, - new: false, - updated: false - } + ...t, + targetId: response.data.data.targetId, + new: false, + updated: false + } : t ) ); @@ -673,11 +679,11 @@ export default function ReverseProxyTargets(props: { targets.map((target) => target.targetId === targetId ? { - ...target, - ...data, - updated: true, - siteType: site ? site.type : target.siteType - } + ...target, + ...data, + updated: true, + siteType: site ? site.type : target.siteType + } : target ) ); @@ -688,10 +694,10 @@ export default function ReverseProxyTargets(props: { targets.map((target) => target.targetId === targetId ? { - ...target, - ...config, - updated: true - } + ...target, + ...config, + updated: true + } : target ) ); @@ -800,6 +806,22 @@ export default function ReverseProxyTargets(props: { setHostHeader: proxyData.setHostHeader || null, headers: proxyData.headers || null }); + } else { + // For TCP/UDP resources, save proxy protocol settings + const proxyData = proxySettingsForm.getValues(); + + const payload = { + proxyProtocol: proxyData.proxyProtocol || false, + proxyProtocolVersion: proxyData.proxyProtocolVersion || 1 + }; + + await api.post(`/resource/${resource.resourceId}`, payload); + + updateResource({ + ...resource, + proxyProtocol: proxyData.proxyProtocol || false, + proxyProtocolVersion: proxyData.proxyProtocolVersion || 1 + }); } toast({ @@ -1064,7 +1086,7 @@ export default function ReverseProxyTargets(props: { className={cn( "w-[180px] justify-between text-sm border-r pr-4 rounded-none h-8 hover:bg-transparent", !row.original.siteId && - "text-muted-foreground" + "text-muted-foreground" )} > @@ -1404,12 +1426,12 @@ export default function ReverseProxyTargets(props: { {header.isPlaceholder ? null : flexRender( - header - .column - .columnDef - .header, - header.getContext() - )} + header + .column + .columnDef + .header, + header.getContext() + )} ) )} @@ -1675,6 +1697,102 @@ export default function ReverseProxyTargets(props: { )} + {!resource.http && resource.protocol && ( + + + + {t("proxyProtocol")} + + + {t("proxyProtocolDescription")} + + + + +
+ + ( + + + { + field.onChange(val); + }} + /> + + + )} + /> + + {proxySettingsForm.watch("proxyProtocol") && ( + <> + ( + + {t("proxyProtocolVersion")} + + + + + {t("versionDescription")} + + + )} + /> + + + + + {t("warning")}: {t("proxyProtocolWarning")} + + + + )} + + +
+
+
+ )} +
+ ); + }, + cell: ({ row }) => { + const originalRow = row.original; + + return ( +
+ +
+ Olm + {originalRow.olmVersion && ( + + v{originalRow.olmVersion} + + )} +
+
+ {originalRow.olmUpdateAvailable && ( + + )} +
+ ); + } + }, { accessorKey: "subnet", header: ({ column }) => { @@ -282,7 +325,7 @@ export default function ClientsTable({ clients, orgId }: ClientTableProps) { {t("deleteClientQuestion")}

- {t("clientMessageRemove")} + {t("clientMessageRemove")}

} diff --git a/src/components/CreateDomainForm.tsx b/src/components/CreateDomainForm.tsx index 258aee49..7d614376 100644 --- a/src/components/CreateDomainForm.tsx +++ b/src/components/CreateDomainForm.tsx @@ -11,6 +11,7 @@ import { FormDescription } from "@app/components/ui/form"; import { Input } from "@app/components/ui/input"; +import { Checkbox, CheckboxWithLabel } from "@app/components/ui/checkbox"; import { useToast } from "@app/hooks/useToast"; import { zodResolver } from "@hookform/resolvers/zod"; import { useState, useMemo } from "react"; @@ -45,6 +46,8 @@ import { import { useOrgContext } from "@app/hooks/useOrgContext"; import { build } from "@server/build"; import { toASCII, toUnicode } from 'punycode'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; +import { useRouter } from "next/navigation"; // Helper functions for Unicode domain handling @@ -96,7 +99,9 @@ const formSchema = z.object({ .min(1, "Domain is required") .refine((val) => isValidDomainFormat(val), "Invalid domain format") .transform((val) => toPunycode(val)), - type: z.enum(["ns", "cname", "wildcard"]) + type: z.enum(["ns", "cname", "wildcard"]), + certResolver: z.string().nullable().optional(), + preferWildcardCert: z.boolean().optional() }); type FormValues = z.infer; @@ -107,6 +112,12 @@ type CreateDomainFormProps = { onCreated?: (domain: CreateDomainResponse) => void; }; +// Example cert resolver options (replace with real API/fetch if needed) +const certResolverOptions = [ + { id: "default", title: "Default" }, + { id: "custom", title: "Custom Resolver" } +]; + export default function CreateDomainForm({ open, setOpen, @@ -120,20 +131,32 @@ export default function CreateDomainForm({ const { toast } = useToast(); const { org } = useOrgContext(); const { env } = useEnvContext(); + const router = useRouter(); const form = useForm({ resolver: zodResolver(formSchema), defaultValues: { baseDomain: "", - type: build == "oss" || !env.flags.usePangolinDns ? "wildcard" : "ns" + type: build == "oss" || !env.flags.usePangolinDns ? "wildcard" : "ns", + certResolver: null, + preferWildcardCert: false } }); - function reset() { + const baseDomain = form.watch("baseDomain"); + const domainType = form.watch("type"); + + const punycodePreview = useMemo(() => { + if (!baseDomain) return ""; + const punycode = toPunycode(baseDomain); + return punycode !== baseDomain.toLowerCase() ? punycode : ""; + }, [baseDomain]); + + const reset = () => { form.reset(); setLoading(false); setCreatedDomain(null); - } + }; async function onSubmit(values: FormValues) { setLoading(true); @@ -149,6 +172,7 @@ export default function CreateDomainForm({ description: t("domainCreatedDescription") }); onCreated?.(domainData); + router.push(`/${org.org.orgId}/settings/domains/${domainData.domainId}`); } catch (e) { toast({ title: t("error"), @@ -158,17 +182,9 @@ export default function CreateDomainForm({ } finally { setLoading(false); } - } - - const baseDomain = form.watch("baseDomain"); - const domainInputValue = form.watch("baseDomain") || ""; - - const punycodePreview = useMemo(() => { - if (!domainInputValue) return ""; - const punycode = toPunycode(domainInputValue); - return punycode !== domainInputValue.toLowerCase() ? punycode : ""; - }, [domainInputValue]); + }; + // Domain type options let domainOptions: any = []; if (build != "oss" && env.flags.usePangolinDns) { domainOptions = [ @@ -209,7 +225,6 @@ export default function CreateDomainForm({ - {!createdDomain ? (
)} /> + {domainType === "wildcard" && ( + <> + ( + + {t("certResolver")} + + + + + + )} + /> + {form.watch("certResolver") !== null && + form.watch("certResolver") !== "default" && ( + ( + + + field.onChange(e.target.value)} + /> + + + + )} + /> + )} + + {form.watch("certResolver") !== null && + form.watch("certResolver") !== "default" && ( + ( + + + + + {/*
+ + {t("preferWildcardCert")} + +
*/} +
+ )} + /> + )} + + )} - ) : ( -
- - - - {t("createDomainAddDnsRecords")} - - - {t("createDomainAddDnsRecordsDescription")} - - -
- {createdDomain.nsRecords && - createdDomain.nsRecords.length > 0 && ( -
-

- {t("createDomainNsRecords")} -

- - - - {t("createDomainRecord")} - - -
-
- - {t( - "createDomainType" - )} - - - NS - -
-
- - {t( - "createDomainName" - )} - -
- - {fromPunycode(baseDomain)} - - {fromPunycode(baseDomain) !== baseDomain && ( - - ({baseDomain}) - - )} -
-
- - {t( - "createDomainValue" - )} - - {createdDomain.nsRecords.map( - ( - nsRecord, - index - ) => ( -
- -
- ) - )} -
-
-
-
-
- )} - - {createdDomain.cnameRecords && - createdDomain.cnameRecords.length > 0 && ( -
-

- {t("createDomainCnameRecords")} -

- - {createdDomain.cnameRecords.map( - (cnameRecord, index) => ( - - - {t( - "createDomainRecordNumber", - { - number: - index + - 1 - } - )} - - -
-
- - {t( - "createDomainType" - )} - - - CNAME - -
-
- - {t( - "createDomainName" - )} - -
- - {fromPunycode(cnameRecord.baseDomain)} - - {fromPunycode(cnameRecord.baseDomain) !== cnameRecord.baseDomain && ( - - ({cnameRecord.baseDomain}) - - )} -
-
-
- - {t( - "createDomainValue" - )} - - -
-
-
-
- ) - )} -
-
- )} - - {createdDomain.aRecords && - createdDomain.aRecords.length > 0 && ( -
-

- {t("createDomainARecords")} -

- - {createdDomain.aRecords.map( - (aRecord, index) => ( - - - {t( - "createDomainRecordNumber", - { - number: - index + - 1 - } - )} - - -
-
- - {t( - "createDomainType" - )} - - - A - -
-
- - {t( - "createDomainName" - )} - -
- - {fromPunycode(aRecord.baseDomain)} - - {fromPunycode(aRecord.baseDomain) !== aRecord.baseDomain && ( - - ({aRecord.baseDomain}) - - )} -
-
-
- - {t( - "createDomainValue" - )} - - - { - aRecord.value - } - -
-
-
-
- ) - )} -
-
- )} - {createdDomain.txtRecords && - createdDomain.txtRecords.length > 0 && ( -
-

- {t("createDomainTxtRecords")} -

- - {createdDomain.txtRecords.map( - (txtRecord, index) => ( - - - {t( - "createDomainRecordNumber", - { - number: - index + - 1 - } - )} - - -
-
- - {t( - "createDomainType" - )} - - - TXT - -
-
- - {t( - "createDomainName" - )} - -
- - {fromPunycode(txtRecord.baseDomain)} - - {fromPunycode(txtRecord.baseDomain) !== txtRecord.baseDomain && ( - - ({txtRecord.baseDomain}) - - )} -
-
-
- - {t( - "createDomainValue" - )} - - -
-
-
-
- ) - )} -
-
- )} -
- - {build != "oss" && env.flags.usePangolinDns && ( - - - - {t("createDomainSaveTheseRecords")} - - - {t( - "createDomainSaveTheseRecordsDescription" - )} - - - )} - - - - - {t("createDomainDnsPropagation")} - - - {t("createDomainDnsPropagationDescription")} - - -
- )}
diff --git a/src/components/DNSRecordTable.tsx b/src/components/DNSRecordTable.tsx new file mode 100644 index 00000000..8d8e4024 --- /dev/null +++ b/src/components/DNSRecordTable.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { useTranslations } from "next-intl"; +import { Badge } from "@app/components/ui/badge"; +import { DNSRecordsDataTable } from "./DNSRecordsDataTable"; + +export type DNSRecordRow = { + id: string; + domainId: string; + recordType: string; // "NS" | "CNAME" | "A" | "TXT" + baseDomain: string | null; + value: string; + verified?: boolean; +}; + +type Props = { + records: DNSRecordRow[]; + domainId: string; + isRefreshing?: boolean; +}; + +export default function DNSRecordsTable({ records, domainId, isRefreshing }: Props) { + const t = useTranslations(); + + const columns: ColumnDef[] = [ + { + accessorKey: "baseDomain", + header: ({ column }) => { + return ( +
+ {t("recordName", { fallback: "Record name" })} +
+ ); + }, + cell: ({ row }) => { + const baseDomain = row.original.baseDomain; + return ( +
+ {baseDomain || "-"} +
+ ); + } + }, + { + accessorKey: "recordType", + header: ({ column }) => { + return ( +
+ {t("type")} +
+ ); + }, + cell: ({ row }) => { + const type = row.original.recordType; + return ( +
+ {type} +
+ ); + } + }, + { + accessorKey: "ttl", + header: ({ column }) => { + return ( +
+ {t("TTL")} +
+ ); + }, + cell: ({ row }) => { + return ( +
+ {t("auto")} +
+ ); + } + }, + { + accessorKey: "value", + header: () => { + return
{t("value")}
; + }, + cell: ({ row }) => { + const value = row.original.value; + return ( +
+ {value} +
+ ); + } + }, + { + accessorKey: "verified", + header: ({ column }) => { + return ( +
+ {t("status")} +
+ ); + }, + cell: ({ row }) => { + const verified = row.original.verified; + return ( + verified ? ( + {t("verified")} + ) : ( + + {t("pending", { fallback: "Pending" })} + + ) + ); + } + } + ]; + + + return ( + + ); +} \ No newline at end of file diff --git a/src/components/DNSRecordsDataTable.tsx b/src/components/DNSRecordsDataTable.tsx new file mode 100644 index 00000000..5d179f30 --- /dev/null +++ b/src/components/DNSRecordsDataTable.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { + ColumnDef, + flexRender, + getCoreRowModel, + useReactTable, + getPaginationRowModel, + getSortedRowModel, + getFilteredRowModel +} from "@tanstack/react-table"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from "@/components/ui/table"; +import { Button } from "@app/components/ui/button"; +import { useMemo, useState } from "react"; +import { ExternalLink, Plus, RefreshCw } from "lucide-react"; +import { + Card, + CardContent, + CardHeader, + CardTitle +} from "@app/components/ui/card"; +import { Tabs, TabsList, TabsTrigger } from "@app/components/ui/tabs"; +import { useTranslations } from "next-intl"; +import { Badge } from "./ui/badge"; + + +type TabFilter = { + id: string; + label: string; + filterFn: (row: any) => boolean; +}; + +type DNSRecordsDataTableProps = { + columns: ColumnDef[]; + data: TData[]; + title?: string; + addButtonText?: string; + onAdd?: () => void; + onRefresh?: () => void; + isRefreshing?: boolean; + searchPlaceholder?: string; + searchColumn?: string; + defaultSort?: { + id: string; + desc: boolean; + }; + tabs?: TabFilter[]; + defaultTab?: string; + persistPageSize?: boolean | string; + defaultPageSize?: number; +}; + +export function DNSRecordsDataTable({ + columns, + data, + title, + addButtonText, + onAdd, + onRefresh, + isRefreshing, + defaultSort, + tabs, + defaultTab, + +}: DNSRecordsDataTableProps) { + const t = useTranslations(); + + const [activeTab, setActiveTab] = useState( + defaultTab || tabs?.[0]?.id || "" + ); + + // Apply tab filter to data + const filteredData = useMemo(() => { + if (!tabs || activeTab === "") { + return data; + } + + const activeTabFilter = tabs.find((tab) => tab.id === activeTab); + if (!activeTabFilter) { + return data; + } + + return data.filter(activeTabFilter.filterFn); + }, [data, tabs, activeTab]); + + const table = useReactTable({ + data: filteredData, + columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + }); + + + + + return ( +
+ + +
+
+

{t("dnsRecord")}

+ {t("required")} +
+ +
+
+ + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef + .header, + header.getContext() + )} + + ))} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + {t("noResults")} + + + )} + +
+
+
+
+ ); +} diff --git a/src/components/DomainInfoCard.tsx b/src/components/DomainInfoCard.tsx new file mode 100644 index 00000000..15fe5ce0 --- /dev/null +++ b/src/components/DomainInfoCard.tsx @@ -0,0 +1,397 @@ +"use client"; + +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { + InfoSection, + InfoSectionContent, + InfoSections, + InfoSectionTitle +} from "@app/components/InfoSection"; +import { useTranslations } from "next-intl"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useDomainContext } from "@app/hooks/useDomainContext"; +import { SettingsContainer, SettingsSection, SettingsSectionBody, SettingsSectionDescription, SettingsSectionFooter, SettingsSectionForm, SettingsSectionHeader, SettingsSectionTitle } from "./Settings"; +import { Button } from "./ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + FormDescription +} from "@app/components/ui/form"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; +import { Input } from "./ui/input"; +import { useForm } from "react-hook-form"; +import z from "zod"; +import { toASCII } from "punycode"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { build } from "@server/build"; +import { Switch } from "./ui/switch"; +import { useEffect, useState } from "react"; +import DNSRecordsTable, { DNSRecordRow } from "./DNSRecordTable"; +import { createApiClient } from "@app/lib/api"; +import { useToast } from "@app/hooks/useToast"; +import { formatAxiosError } from "@app/lib/api"; +import { Badge } from "./ui/badge"; + +type DomainInfoCardProps = { + orgId?: string; + domainId?: string; +}; + +// Helper functions for Unicode domain handling +function toPunycode(domain: string): string { + try { + const parts = toASCII(domain); + return parts; + } catch (error) { + return domain.toLowerCase(); + } +} + + +function isValidDomainFormat(domain: string): boolean { + const unicodeRegex = /^(?!:\/\/)([^\s.]+\.)*[^\s.]+$/; + + if (!unicodeRegex.test(domain)) { + return false; + } + + const parts = domain.split('.'); + for (const part of parts) { + if (part.length === 0 || part.startsWith('-') || part.endsWith('-')) { + return false; + } + if (part.length > 63) { + return false; + } + } + + if (domain.length > 253) { + return false; + } + + return true; +} + +const formSchema = z.object({ + baseDomain: z + .string() + .min(1, "Domain is required") + .refine((val) => isValidDomainFormat(val), "Invalid domain format") + .transform((val) => toPunycode(val)), + type: z.enum(["ns", "cname", "wildcard"]), + certResolver: z.string().nullable().optional(), + preferWildcardCert: z.boolean().optional() +}); + +type FormValues = z.infer; + +const certResolverOptions = [ + { id: "default", title: "Default" }, + { id: "custom", title: "Custom Resolver" } +]; + + +export default function DomainInfoCard({ orgId, domainId }: DomainInfoCardProps) { + const { domain, updateDomain } = useDomainContext(); + const t = useTranslations(); + const { env } = useEnvContext(); + const api = createApiClient(useEnvContext()); + const { toast } = useToast(); + + const [dnsRecords, setDnsRecords] = useState([]); + const [loadingRecords, setLoadingRecords] = useState(true); + const [isRefreshing, setIsRefreshing] = useState(false); + const [saveLoading, setSaveLoading] = useState(false); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + baseDomain: "", + type: build == "oss" || !env.flags.usePangolinDns ? "wildcard" : "ns", + certResolver: domain.certResolver ?? "", + preferWildcardCert: false + } + }); + + useEffect(() => { + if (domain.domainId) { + const certResolverValue = domain.certResolver && domain.certResolver.trim() !== "" + ? domain.certResolver + : null; + + form.reset({ + baseDomain: domain.baseDomain || "", + type: (domain.type as "ns" | "cname" | "wildcard") || "wildcard", + certResolver: certResolverValue, + preferWildcardCert: domain.preferWildcardCert || false + }); + } + }, [domain]); + + const fetchDNSRecords = async (showRefreshing = false) => { + if (showRefreshing) { + setIsRefreshing(true); + } else { + setLoadingRecords(true); + } + + try { + const response = await api.get<{ data: DNSRecordRow[] }>( + `/org/${orgId}/domain/${domainId}/dns-records` + ); + setDnsRecords(response.data.data); + } catch (error) { + // Only show error if records exist (not a 404) + const err = error as any; + if (err?.response?.status !== 404) { + toast({ + title: t("error"), + description: formatAxiosError(error), + variant: "destructive" + }); + } + } finally { + setLoadingRecords(false); + setIsRefreshing(false); + } + }; + + useEffect(() => { + if (domain.domainId) { + fetchDNSRecords(); + } + }, [domain.domainId]); + + const onSubmit = async (values: FormValues) => { + if (!orgId || !domainId) { + toast({ + title: t("error"), + description: t("orgOrDomainIdMissing", { fallback: "Organization or Domain ID is missing" }), + variant: "destructive" + }); + return; + } + + setSaveLoading(true); + + try { + const response = await api.patch( + `/org/${orgId}/domain/${domainId}`, + { + certResolver: values.certResolver, + preferWildcardCert: values.preferWildcardCert + } + ); + + updateDomain({ + ...domain, + certResolver: values.certResolver || null, + preferWildcardCert: values.preferWildcardCert || false + }); + + toast({ + title: t("success"), + description: t("domainSettingsUpdated", { fallback: "Domain settings updated successfully" }), + variant: "default" + }); + } catch (error) { + toast({ + title: t("error"), + description: formatAxiosError(error), + variant: "destructive" + }); + } finally { + setSaveLoading(false); + } + }; + + const getTypeDisplay = (type: string) => { + switch (type) { + case "ns": + return t("selectDomainTypeNsName"); + case "cname": + return t("selectDomainTypeCnameName"); + case "wildcard": + return t("selectDomainTypeWildcardName"); + default: + return type; + } + }; + + + + return ( + <> + + + + + + {t("type")} + + + + {getTypeDisplay(domain.type ? domain.type : "")} + + + + + + {t("status")} + + + {domain.verified ? ( + {t("verified")} + ) : ( + + {t("pending", { fallback: "Pending" })} + + )} + + + + + + + {loadingRecords ? ( +
+ {t("loadingDNSRecords", { fallback: "Loading DNS Records..." })} +
+ ) : ( + + ) + } + + {/* Domain Settings - Only show for wildcard domains */} + {domain.type === "wildcard" && ( + + + + + {t("domainSetting")} + + + + + +
+ + <> + ( + + {t("certResolver")} + + + + + + )} + /> + {form.watch("certResolver") !== null && + form.watch("certResolver") !== "default" && ( + ( + + + field.onChange(e.target.value)} + /> + + + + )} + /> + )} + + {form.watch("certResolver") !== null && + form.watch("certResolver") !== "default" && ( + ( + + +
+ + {t("preferWildcardCert")} +
+
+ + + {t("preferWildcardCertDescription")} + + +
+ )} + /> + )} + + + +
+
+ + + + +
+
+ )} + + ); +} \ No newline at end of file diff --git a/src/components/DomainsTable.tsx b/src/components/DomainsTable.tsx index ca8d2a7c..51aa951a 100644 --- a/src/components/DomainsTable.tsx +++ b/src/components/DomainsTable.tsx @@ -3,7 +3,7 @@ import { ColumnDef } from "@tanstack/react-table"; import { DomainsDataTable } from "@app/components/DomainsDataTable"; import { Button } from "@app/components/ui/button"; -import { ArrowUpDown } from "lucide-react"; +import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react"; import { useState } from "react"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import { formatAxiosError } from "@app/lib/api"; @@ -15,6 +15,8 @@ import { useTranslations } from "next-intl"; import CreateDomainForm from "@app/components/CreateDomainForm"; import { useToast } from "@app/hooks/useToast"; import { useOrgContext } from "@app/hooks/useOrgContext"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./ui/dropdown-menu"; +import Link from "next/link"; export type DomainRow = { domainId: string; @@ -24,13 +26,16 @@ export type DomainRow = { failed: boolean; tries: number; configManaged: boolean; + certResolver: string; + preferWildcardCert: boolean; }; type Props = { domains: DomainRow[]; + orgId: string; }; -export default function DomainsTable({ domains }: Props) { +export default function DomainsTable({ domains, orgId }: Props) { const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [selectedDomain, setSelectedDomain] = useState( @@ -205,12 +210,51 @@ export default function DomainsTable({ domains }: Props) { > {isRestarting ? t("restarting", { - fallback: "Restarting..." - }) + fallback: "Restarting..." + }) : t("restart", { fallback: "Restart" })} )} - + + + + + {t("viewSettings")} + + + { + setSelectedDomain(domain); + setIsDeleteModalOpen(true); + }} + > + + {t("delete")} + + + + + + + + + + {/* + */} ); } diff --git a/src/contexts/domainContext.ts b/src/contexts/domainContext.ts new file mode 100644 index 00000000..e38ddefb --- /dev/null +++ b/src/contexts/domainContext.ts @@ -0,0 +1,19 @@ +import { GetDomainResponse } from "@server/routers/domain/getDomain"; +import { createContext, useContext } from "react"; +interface DomainContextType { + domain: GetDomainResponse; + updateDomain: (updatedDomain: Partial) => void; + orgId: string; +} + +const DomainContext = createContext(undefined); + +export function useDomain() { + const context = useContext(DomainContext); + if (!context) { + throw new Error("useDomain must be used within DomainProvider"); + } + return context; +} + +export default DomainContext; \ No newline at end of file diff --git a/src/hooks/useDomainContext.ts b/src/hooks/useDomainContext.ts new file mode 100644 index 00000000..36d3840f --- /dev/null +++ b/src/hooks/useDomainContext.ts @@ -0,0 +1,10 @@ +import DomainContext from "@app/contexts/domainContext"; +import { useContext } from "react"; + +export function useDomainContext() { + const context = useContext(DomainContext); + if (context === undefined) { + throw new Error('useDomainContext must be used within a DomainProvider'); + } + return context; +} \ No newline at end of file diff --git a/src/providers/DomainProvider.tsx b/src/providers/DomainProvider.tsx new file mode 100644 index 00000000..845b369f --- /dev/null +++ b/src/providers/DomainProvider.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { useState } from "react"; +import { useTranslations } from "next-intl"; +import { GetDomainResponse } from "@server/routers/domain/getDomain"; +import DomainContext from "@app/contexts/domainContext"; + +interface DomainProviderProps { + children: React.ReactNode; + domain: GetDomainResponse; + orgId: string; +} + +export function DomainProvider({ + children, + domain: serverDomain, + orgId +}: DomainProviderProps) { + const [domain, setDomain] = useState(serverDomain); + + const t = useTranslations(); + + const updateDomain = (updatedDomain: Partial) => { + if (!domain) { + throw new Error(t('domainErrorNoUpdate')); + } + setDomain((prev) => { + if (!prev) { + return prev; + } + return { + ...prev, + ...updatedDomain + }; + }); + }; + + return ( + + {children} + + ); +} + +export default DomainProvider; \ No newline at end of file