diff --git a/messages/en-US.json b/messages/en-US.json index eb4d3ae3c..6e1947b3e 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -763,6 +763,7 @@ "newtEndpoint": "Endpoint", "newtId": "ID", "newtSecretKey": "Secret", + "newtVersion": "Version", "architecture": "Architecture", "sites": "Sites", "siteWgAnyClients": "Use any WireGuard client to connect. You will have to address internal resources using the peer IP.", @@ -3201,5 +3202,6 @@ "domainPickerWildcardSubdomainNotAllowed": "Wildcard subdomains are not allowed.", "domainPickerWildcardCertWarning": "Wildcard resources may require additional configuration to work properly.", "domainPickerWildcardCertWarningLink": "Learn more", - "health": "Health" + "health": "Health", + "domainPendingErrorTitle": "Verification Issue" } diff --git a/server/private/lib/acmeCertSync.ts b/server/private/lib/acmeCertSync.ts index 06b427955..adf87eed8 100644 --- a/server/private/lib/acmeCertSync.ts +++ b/server/private/lib/acmeCertSync.ts @@ -12,6 +12,7 @@ */ import fs from "fs"; +import path from "path"; import crypto from "crypto"; import { certificates, @@ -274,12 +275,244 @@ function detectWildcard( return { wildcard: false, wildcardSan: null }; } +interface HttpCert { + wildcard: boolean; + altName: string; + certName: string; + commonName: string; + certFile: string; + keyFile: string; +} + +async function syncAcmeCertsFromHttp(endpoint: string): Promise { + let response: Response; + try { + response = await fetch(endpoint); + } catch (err) { + logger.debug( + `acmeCertSync: could not reach HTTP endpoint ${endpoint}: ${err}` + ); + return; + } + + if (!response.ok) { + logger.debug( + `acmeCertSync: HTTP endpoint returned status ${response.status}` + ); + return; + } + + let httpCerts: HttpCert[]; + try { + httpCerts = await response.json(); + } catch (err) { + logger.debug( + `acmeCertSync: could not parse JSON from HTTP endpoint: ${err}` + ); + return; + } + + if (!Array.isArray(httpCerts) || httpCerts.length === 0) { + logger.debug( + `acmeCertSync: no certificates returned from HTTP endpoint` + ); + return; + } + + for (const cert of httpCerts) { + const domain = cert?.certName; + + if (!domain || typeof domain !== "string") { + logger.debug( + `acmeCertSync: skipping HTTP cert with missing certName` + ); + continue; + } + + const certPem = cert.certFile; + const keyPem = cert.keyFile; + + if (!certPem?.trim() || !keyPem?.trim()) { + logger.debug( + `acmeCertSync: skipping HTTP cert for ${domain} - empty certFile or keyFile` + ); + continue; + } + + const firstCertPemForValidation = extractFirstCert(certPem); + if (!firstCertPemForValidation) { + logger.debug( + `acmeCertSync: skipping HTTP cert for ${domain} - no PEM certificate block found` + ); + continue; + } + + let validatedX509: crypto.X509Certificate; + try { + validatedX509 = new crypto.X509Certificate( + firstCertPemForValidation + ); + } catch (err) { + logger.debug( + `acmeCertSync: skipping HTTP cert for ${domain} - invalid X.509 certificate: ${err}` + ); + continue; + } + + try { + crypto.createPrivateKey(keyPem); + } catch (err) { + logger.debug( + `acmeCertSync: skipping HTTP cert for ${domain} - invalid private key: ${err}` + ); + continue; + } + + const wildcard = cert.wildcard ?? false; + + const existing = await db + .select() + .from(certificates) + .where(eq(certificates.domain, domain)) + .limit(1); + + let oldCertPem: string | null = null; + let oldKeyPem: string | null = null; + + if (existing.length > 0 && existing[0].certFile) { + try { + const storedCertPem = decrypt( + existing[0].certFile, + config.getRawConfig().server.secret! + ); + const wildcardUnchanged = existing[0].wildcard === wildcard; + if (storedCertPem === certPem && wildcardUnchanged) { + continue; + } + oldCertPem = storedCertPem; + if (existing[0].keyFile) { + try { + oldKeyPem = decrypt( + existing[0].keyFile, + config.getRawConfig().server.secret! + ); + } catch (keyErr) { + logger.debug( + `acmeCertSync: could not decrypt stored key for ${domain}: ${keyErr}` + ); + } + } + } catch (err) { + logger.debug( + `acmeCertSync: could not decrypt stored cert for ${domain}, will update: ${err}` + ); + } + } + + let expiresAt: number | null = null; + try { + expiresAt = Math.floor( + new Date(validatedX509.validTo).getTime() / 1000 + ); + } catch (err) { + logger.debug( + `acmeCertSync: could not parse cert expiry for ${domain}: ${err}` + ); + } + + const encryptedCert = encrypt( + certPem, + config.getRawConfig().server.secret! + ); + const encryptedKey = encrypt( + keyPem, + config.getRawConfig().server.secret! + ); + const now = Math.floor(Date.now() / 1000); + + const domainId = await findDomainId(domain); + if (domainId) { + logger.debug( + `acmeCertSync: resolved domainId "${domainId}" for HTTP cert domain "${domain}"` + ); + } else { + logger.debug( + `acmeCertSync: no matching domain record found for HTTP cert domain "${domain}"` + ); + } + + if (existing.length > 0) { + logger.debug( + `acmeCertSync: updating existing certificate (HTTP) for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})` + ); + await db + .update(certificates) + .set({ + certFile: encryptedCert, + keyFile: encryptedKey, + status: "valid", + expiresAt, + updatedAt: now, + wildcard, + ...(domainId !== null && { domainId }) + }) + .where(eq(certificates.domain, domain)); + + await pushCertUpdateToAffectedNewts( + domain, + domainId, + oldCertPem, + oldKeyPem + ); + } else { + logger.debug( + `acmeCertSync: inserting new certificate (HTTP) for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})` + ); + await db.insert(certificates).values({ + domain, + domainId, + certFile: encryptedCert, + keyFile: encryptedKey, + status: "valid", + expiresAt, + createdAt: now, + updatedAt: now, + wildcard + }); + + await pushCertUpdateToAffectedNewts(domain, domainId, null, null); + } + } +} + +function findAcmeJsonFiles(dirPath: string): string[] { + const results: string[] = []; + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dirPath, { withFileTypes: true }); + } catch (err) { + logger.warn( + `acmeCertSync: could not read directory "${dirPath}": ${err}` + ); + return results; + } + for (const entry of entries) { + const fullPath = path.join(dirPath, entry.name); + if (entry.isDirectory()) { + results.push(...findAcmeJsonFiles(fullPath)); + } else if (entry.isFile() && entry.name === "acme.json") { + results.push(fullPath); + } + } + return results; +} + async function syncAcmeCerts(acmeJsonPath: string): Promise { let raw: string; try { raw = fs.readFileSync(acmeJsonPath, "utf8"); } catch (err) { - logger.debug(`acmeCertSync: could not read ${acmeJsonPath}: ${err}`); + logger.warn(`acmeCertSync: could not read "${acmeJsonPath}": ${err}`); return; } @@ -287,7 +520,9 @@ async function syncAcmeCerts(acmeJsonPath: string): Promise { try { acmeJson = JSON.parse(raw); } catch (err) { - logger.debug(`acmeCertSync: could not parse acme.json: ${err}`); + logger.warn( + `acmeCertSync: could not parse "${acmeJsonPath}" as JSON: ${err}` + ); return; } @@ -389,11 +624,7 @@ async function syncAcmeCerts(acmeJsonPath: string): Promise { const existing = await db .select() .from(certificates) - .where( - and( - eq(certificates.domain, domain) - ) - ) + .where(and(eq(certificates.domain, domain))) .limit(1); let oldCertPem: string | null = null; @@ -408,7 +639,7 @@ async function syncAcmeCerts(acmeJsonPath: string): Promise { const wildcardUnchanged = existing[0].wildcard === wildcard; if (storedCertPem === certPem && wildcardUnchanged) { // logger.debug( - // `acmeCertSync: cert for ${domain} is unchanged, skipping` + // `acmeCertSync: cert for ${domain} is unchanged, skipping` // ); continue; } @@ -547,19 +778,62 @@ export function initAcmeCertSync(): void { privateConfigData.acme?.acme_json_path ?? "config/letsencrypt/acme.json"; const intervalMs = privateConfigData.acme?.sync_interval_ms ?? 5000; + const httpEndpoint = privateConfigData.acme?.acme_http_endpoint; logger.debug( `acmeCertSync: starting ACME cert sync from "${acmeJsonPath}" across all resolvers every ${intervalMs}ms` ); + if (httpEndpoint) { + logger.debug( + `acmeCertSync: also syncing from HTTP endpoint "${httpEndpoint}" every ${intervalMs}ms` + ); + } + + const runSync = () => { + if (httpEndpoint) { + syncAcmeCertsFromHttp(httpEndpoint).catch((err) => { + logger.error(`acmeCertSync: error during HTTP sync: ${err}`); + }); + } else { + // only run the file-based sync if the HTTP endpoint is not configured, to avoid doubling up + let stat: fs.Stats | null = null; + try { + stat = fs.statSync(acmeJsonPath); + } catch (err) { + logger.warn( + `acmeCertSync: cannot stat path "${acmeJsonPath}": ${err}` + ); + return; + } + + if (stat.isDirectory()) { + const files = findAcmeJsonFiles(acmeJsonPath); + if (files.length === 0) { + logger.debug( + `acmeCertSync: no acme.json files found in directory "${acmeJsonPath}"` + ); + return; + } + logger.debug( + `acmeCertSync: found ${files.length} acme.json file(s) in directory "${acmeJsonPath}"` + ); + for (const file of files) { + syncAcmeCerts(file).catch((err) => { + logger.error( + `acmeCertSync: error during sync of "${file}": ${err}` + ); + }); + } + } else { + syncAcmeCerts(acmeJsonPath).catch((err) => { + logger.error(`acmeCertSync: error during sync: ${err}`); + }); + } + } + }; // Run immediately on init, then on the configured interval - syncAcmeCerts(acmeJsonPath).catch((err) => { - logger.error(`acmeCertSync: error during initial sync: ${err}`); - }); + runSync(); - setInterval(() => { - syncAcmeCerts(acmeJsonPath).catch((err) => { - logger.error(`acmeCertSync: error during sync: ${err}`); - }); - }, intervalMs); + setInterval(runSync, intervalMs); } diff --git a/server/private/lib/readConfigFile.ts b/server/private/lib/readConfigFile.ts index 056624159..63ca0b068 100644 --- a/server/private/lib/readConfigFile.ts +++ b/server/private/lib/readConfigFile.ts @@ -21,173 +21,172 @@ import { getEnvOrYaml } from "@server/lib/getEnvOrYaml"; const portSchema = z.number().positive().gt(0).lte(65535); -export const privateConfigSchema = z.object({ - app: z - .object({ - region: z.string().optional().default("default"), - base_domain: z.string().optional(), - identity_provider_mode: z.enum(["global", "org"]).optional() - }) - .optional() - .default({ - region: "default" - }), - server: z - .object({ - reo_client_id: z - .string() - .optional() - .transform(getEnvOrYaml("REO_CLIENT_ID")), - fossorial_api: z - .string() - .optional() - .default("https://api.fossorial.io"), - fossorial_api_key: z - .string() - .optional() - .transform(getEnvOrYaml("FOSSORIAL_API_KEY")) - }) - .optional() - .prefault({}), - redis: z - .object({ - host: z.string(), - port: portSchema, - password: z - .string() - .optional() - .transform(getEnvOrYaml("REDIS_PASSWORD")), - db: z.int().nonnegative().optional().default(0), - replicas: z - .array( - z.object({ - host: z.string(), - port: portSchema, - password: z.string().optional(), - db: z.int().nonnegative().optional().default(0) +export const privateConfigSchema = z + .object({ + app: z + .object({ + region: z.string().optional().default("default"), + base_domain: z.string().optional(), + identity_provider_mode: z.enum(["global", "org"]).optional() + }) + .optional() + .default({ + region: "default" + }), + server: z + .object({ + reo_client_id: z + .string() + .optional() + .transform(getEnvOrYaml("REO_CLIENT_ID")), + fossorial_api: z + .string() + .optional() + .default("https://api.fossorial.io"), + fossorial_api_key: z + .string() + .optional() + .transform(getEnvOrYaml("FOSSORIAL_API_KEY")) + }) + .optional() + .prefault({}), + redis: z + .object({ + host: z.string(), + port: portSchema, + password: z + .string() + .optional() + .transform(getEnvOrYaml("REDIS_PASSWORD")), + db: z.int().nonnegative().optional().default(0), + replicas: z + .array( + z.object({ + host: z.string(), + port: portSchema, + password: z.string().optional(), + db: z.int().nonnegative().optional().default(0) + }) + ) + .optional(), + tls: z + .object({ + rejectUnauthorized: z.boolean().optional().default(true) }) - ) - .optional(), - tls: z - .object({ - rejectUnauthorized: z - .boolean() - .optional() - .default(true) - }) - .optional() - }) - .optional(), - gerbil: z - .object({ - local_exit_node_reachable_at: z - .string() - .optional() - .default("http://gerbil:3004") - }) - .optional() - .prefault({}), - flags: z - .object({ - enable_redis: z.boolean().optional().default(false), - use_pangolin_dns: z.boolean().optional().default(false), - use_org_only_idp: z.boolean().optional(), - enable_acme_cert_sync: z.boolean().optional().default(true) - }) - .optional() - .prefault({}), - acme: z - .object({ - acme_json_path: z - .string() - .optional() - .default("config/letsencrypt/acme.json"), - sync_interval_ms: z.number().optional().default(5000) - }) - .optional(), - branding: z - .object({ - app_name: z.string().optional(), - background_image_path: z.string().optional(), - colors: z - .object({ - light: colorsSchema.optional(), - dark: colorsSchema.optional() - }) - .optional(), - logo: z - .object({ - light_path: z.string().optional(), - dark_path: z.string().optional(), - auth_page: z - .object({ - width: z.number().optional(), - height: z.number().optional() - }) - .optional(), - navbar: z - .object({ - width: z.number().optional(), - height: z.number().optional() - }) - .optional() - }) - .optional(), - footer: z - .array( - z.object({ - text: z.string(), - href: z.string().optional() + .optional() + }) + .optional(), + gerbil: z + .object({ + local_exit_node_reachable_at: z + .string() + .optional() + .default("http://gerbil:3004") + }) + .optional() + .prefault({}), + flags: z + .object({ + enable_redis: z.boolean().optional().default(false), + use_pangolin_dns: z.boolean().optional().default(false), + use_org_only_idp: z.boolean().optional(), + enable_acme_cert_sync: z.boolean().optional().default(true) + }) + .optional() + .prefault({}), + acme: z + .object({ + acme_json_path: z + .string() + .optional() + .default("config/letsencrypt/acme.json"), + acme_http_endpoint: z.string().optional(), + sync_interval_ms: z.number().optional().default(5000) + }) + .optional(), + branding: z + .object({ + app_name: z.string().optional(), + background_image_path: z.string().optional(), + colors: z + .object({ + light: colorsSchema.optional(), + dark: colorsSchema.optional() }) - ) - .optional(), - hide_auth_layout_footer: z.boolean().optional().default(false), - login_page: z - .object({ - subtitle_text: z.string().optional() - }) - .optional(), - signup_page: z - .object({ - subtitle_text: z.string().optional() - }) - .optional(), - resource_auth_page: z - .object({ - show_logo: z.boolean().optional(), - hide_powered_by: z.boolean().optional(), - title_text: z.string().optional(), - subtitle_text: z.string().optional() - }) - .optional(), - emails: z - .object({ - signature: z.string().optional(), - colors: z - .object({ - primary: z.string().optional() + .optional(), + logo: z + .object({ + light_path: z.string().optional(), + dark_path: z.string().optional(), + auth_page: z + .object({ + width: z.number().optional(), + height: z.number().optional() + }) + .optional(), + navbar: z + .object({ + width: z.number().optional(), + height: z.number().optional() + }) + .optional() + }) + .optional(), + footer: z + .array( + z.object({ + text: z.string(), + href: z.string().optional() }) - .optional() - }) - .optional() - }) - .optional(), - stripe: z - .object({ - secret_key: z - .string() - .optional() - .transform(getEnvOrYaml("STRIPE_SECRET_KEY")), - webhook_secret: z - .string() - .optional() - .transform(getEnvOrYaml("STRIPE_WEBHOOK_SECRET")), - // s3Bucket: z.string(), - // s3Region: z.string().default("us-east-1"), - // localFilePath: z.string().optional() - }) - .optional() -}) + ) + .optional(), + hide_auth_layout_footer: z.boolean().optional().default(false), + login_page: z + .object({ + subtitle_text: z.string().optional() + }) + .optional(), + signup_page: z + .object({ + subtitle_text: z.string().optional() + }) + .optional(), + resource_auth_page: z + .object({ + show_logo: z.boolean().optional(), + hide_powered_by: z.boolean().optional(), + title_text: z.string().optional(), + subtitle_text: z.string().optional() + }) + .optional(), + emails: z + .object({ + signature: z.string().optional(), + colors: z + .object({ + primary: z.string().optional() + }) + .optional() + }) + .optional() + }) + .optional(), + stripe: z + .object({ + secret_key: z + .string() + .optional() + .transform(getEnvOrYaml("STRIPE_SECRET_KEY")), + webhook_secret: z + .string() + .optional() + .transform(getEnvOrYaml("STRIPE_WEBHOOK_SECRET")) + // s3Bucket: z.string(), + // s3Region: z.string().default("us-east-1"), + // localFilePath: z.string().optional() + }) + .optional() + }) .transform((data) => { // this to maintain backwards compatibility with the old config file const identityProviderMode = data.app?.identity_provider_mode; diff --git a/server/private/routers/certificates/createCertificate.ts b/server/private/routers/certificates/createCertificate.ts index 60ca2072a..048b92352 100644 --- a/server/private/routers/certificates/createCertificate.ts +++ b/server/private/routers/certificates/createCertificate.ts @@ -79,7 +79,7 @@ export async function createCertificate( let domainToWrite = domain; if ( - domainRecord.type == "wildcard" && + domainRecord.type == "wildcard" && // this is to fix the wildcard certs for traefik in self hosted NOT ON THE CLOUD domainRecord.preferWildcardCert && !domain.startsWith("*.") ) { @@ -89,6 +89,16 @@ export async function createCertificate( domainToWrite = parts.slice(1).join("."); domainToWrite = `*.${domainToWrite}`; } + } else if (domainRecord.type == "ns") { + // first if we have a * in the domain for this case we dont want to include it because it will mess with the cert generator so remove it + if (domain.startsWith("*.")) { + domain = domain.slice(2); + } + + const parts = domain.split("."); + if (parts.length > 2) { + domainToWrite = parts.slice(1).join("."); + } } // No cert found, create a new one in pending state diff --git a/server/routers/site/getSite.ts b/server/routers/site/getSite.ts index 45d49abe6..a16547b8d 100644 --- a/server/routers/site/getSite.ts +++ b/server/routers/site/getSite.ts @@ -42,9 +42,12 @@ async function query(siteId?: number, niceId?: string, orgId?: string) { } } -export type GetSiteResponse = NonNullable< - Awaited> ->["sites"] & { newtId: string | null }; +type SiteQueryRow = NonNullable>>; + +export type GetSiteResponse = SiteQueryRow["sites"] & { + newtId: string | null; + newtVersion: string | null; +}; registry.registerPath({ method: "get", @@ -100,7 +103,8 @@ export async function getSite( const data: GetSiteResponse = { ...site.sites, - newtId: site.newt ? site.newt.newtId : null + newtId: site.newt ? site.newt.newtId : null, + newtVersion: site.newt?.version ?? null }; return response(res, { diff --git a/src/components/ProductUpdates.tsx b/src/components/ProductUpdates.tsx index b1da32a93..0d88853a7 100644 --- a/src/components/ProductUpdates.tsx +++ b/src/components/ProductUpdates.tsx @@ -81,10 +81,10 @@ export default function ProductUpdates({ const showNewVersionPopup = Boolean( latestVersion && - valid(latestVersion) && - valid(currentVersion) && - ignoredVersionUpdate !== latestVersion && - gt(latestVersion, currentVersion) + valid(latestVersion) && + valid(currentVersion) && + ignoredVersionUpdate !== latestVersion && + gt(latestVersion, currentVersion) ); const filteredUpdates = data.updates.filter( @@ -103,40 +103,51 @@ export default function ProductUpdates({ )} >
- {filteredUpdates.length > 1 && ( - 0 && ( +
+ {filteredUpdates.length > 1 && ( + + + + {showNewVersionPopup + ? t("productUpdateMoreInfo", { + noOfUpdates: + filteredUpdates.length + }) + : t("productUpdateInfo", { + noOfUpdates: + filteredUpdates.length + })} + + )} - > - - - {showNewVersionPopup - ? t("productUpdateMoreInfo", { - noOfUpdates: filteredUpdates.length - }) - : t("productUpdateInfo", { - noOfUpdates: filteredUpdates.length - })} - - + 0} + onDimissAll={() => + setProductUpdatesRead([ + ...productUpdatesRead, + ...filteredUpdates.map( + (update) => update.id + ) + ]) + } + onDimiss={(id) => + setProductUpdatesRead([ + ...productUpdatesRead, + id + ]) + } + /> +
)} - 0} - onDimissAll={() => - setProductUpdatesRead([ - ...productUpdatesRead, - ...filteredUpdates.map((update) => update.id) - ]) - } - onDimiss={(id) => - setProductUpdatesRead([...productUpdatesRead, id]) - } - />
{ - if (type === "newt") { - return "Newt"; - } else if (type === "wireguard") { - return "WireGuard"; - } else if (type === "local") { - return t("local"); - } else { - return t("unknown"); - } - }; +export default function SiteInfoCard({}: SiteInfoCardProps) { + const { site } = useSiteContext(); + const t = useTranslations(); + + const identifierSection = ( + + {t("identifier")} + {site.niceId} + + ); + + const statusSection = ( + + {t("status")} + + {site.online ? ( +
+
+ {t("online")} +
+ ) : ( +
+
+ {t("offline")} +
+ )} +
+
+ ); + + const endpointSection = site.endpoint ? ( + + {t("publicIpEndpoint")} + + {formatPublicEndpoint(site.endpoint)} + + + ) : null; + + if (site.type === "newt") { + return ( + + + + {identifierSection} + {statusSection} + + + {t("connectionType")} + + Newt + + + + {t("newtVersion")} + + + {site.newtVersion + ? `v${site.newtVersion}` + : "-"} + + + {endpointSection} + + + + ); + } + + if (site.type === "wireguard") { + return ( + + + + {identifierSection} + {statusSection} + + + {t("connectionType")} + + WireGuard + + {endpointSection} + + + + ); + } + + if (site.type === "local") { + return ( + + + + {identifierSection} + + + {t("connectionType")} + + + {t("local")} + + + {endpointSection} + + + + ); + } return ( - - - {t("identifier")} - {site.niceId} - - {(site.type == "newt" || site.type == "wireguard") && ( - <> - - - {t("status")} - - - {site.online ? ( -
-
- {t("online")} -
- ) : ( -
-
- {t("offline")} -
- )} -
-
- - )} + + {identifierSection} {t("connectionType")} - - {getConnectionTypeString(site.type)} - + {t("unknown")} - {site.endpoint && ( - - - {t("publicIpEndpoint")} - - - {site.endpoint.includes(":") - ? site.endpoint.substring(0, site.endpoint.lastIndexOf(":")) - : site.endpoint} - - - )} + {endpointSection}
diff --git a/src/components/resource-target-address-item.tsx b/src/components/resource-target-address-item.tsx index 08ce40dd3..abd4ed45b 100644 --- a/src/components/resource-target-address-item.tsx +++ b/src/components/resource-target-address-item.tsx @@ -113,10 +113,10 @@ export function ResourceTargetAddressItem({ ? selectedSite?.name : t("siteSelect")} - + - + - );