mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-22 08:45:24 +00:00
@@ -763,6 +763,7 @@
|
|||||||
"newtEndpoint": "Endpoint",
|
"newtEndpoint": "Endpoint",
|
||||||
"newtId": "ID",
|
"newtId": "ID",
|
||||||
"newtSecretKey": "Secret",
|
"newtSecretKey": "Secret",
|
||||||
|
"newtVersion": "Version",
|
||||||
"architecture": "Architecture",
|
"architecture": "Architecture",
|
||||||
"sites": "Sites",
|
"sites": "Sites",
|
||||||
"siteWgAnyClients": "Use any WireGuard client to connect. You will have to address internal resources using the peer IP.",
|
"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.",
|
"domainPickerWildcardSubdomainNotAllowed": "Wildcard subdomains are not allowed.",
|
||||||
"domainPickerWildcardCertWarning": "Wildcard resources may require additional configuration to work properly.",
|
"domainPickerWildcardCertWarning": "Wildcard resources may require additional configuration to work properly.",
|
||||||
"domainPickerWildcardCertWarningLink": "Learn more",
|
"domainPickerWildcardCertWarningLink": "Learn more",
|
||||||
"health": "Health"
|
"health": "Health",
|
||||||
|
"domainPendingErrorTitle": "Verification Issue"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import {
|
import {
|
||||||
certificates,
|
certificates,
|
||||||
@@ -274,12 +275,244 @@ function detectWildcard(
|
|||||||
return { wildcard: false, wildcardSan: null };
|
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<void> {
|
||||||
|
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<void> {
|
async function syncAcmeCerts(acmeJsonPath: string): Promise<void> {
|
||||||
let raw: string;
|
let raw: string;
|
||||||
try {
|
try {
|
||||||
raw = fs.readFileSync(acmeJsonPath, "utf8");
|
raw = fs.readFileSync(acmeJsonPath, "utf8");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.debug(`acmeCertSync: could not read ${acmeJsonPath}: ${err}`);
|
logger.warn(`acmeCertSync: could not read "${acmeJsonPath}": ${err}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,7 +520,9 @@ async function syncAcmeCerts(acmeJsonPath: string): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
acmeJson = JSON.parse(raw);
|
acmeJson = JSON.parse(raw);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.debug(`acmeCertSync: could not parse acme.json: ${err}`);
|
logger.warn(
|
||||||
|
`acmeCertSync: could not parse "${acmeJsonPath}" as JSON: ${err}`
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,11 +624,7 @@ async function syncAcmeCerts(acmeJsonPath: string): Promise<void> {
|
|||||||
const existing = await db
|
const existing = await db
|
||||||
.select()
|
.select()
|
||||||
.from(certificates)
|
.from(certificates)
|
||||||
.where(
|
.where(and(eq(certificates.domain, domain)))
|
||||||
and(
|
|
||||||
eq(certificates.domain, domain)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
let oldCertPem: string | null = null;
|
let oldCertPem: string | null = null;
|
||||||
@@ -408,7 +639,7 @@ async function syncAcmeCerts(acmeJsonPath: string): Promise<void> {
|
|||||||
const wildcardUnchanged = existing[0].wildcard === wildcard;
|
const wildcardUnchanged = existing[0].wildcard === wildcard;
|
||||||
if (storedCertPem === certPem && wildcardUnchanged) {
|
if (storedCertPem === certPem && wildcardUnchanged) {
|
||||||
// logger.debug(
|
// logger.debug(
|
||||||
// `acmeCertSync: cert for ${domain} is unchanged, skipping`
|
// `acmeCertSync: cert for ${domain} is unchanged, skipping`
|
||||||
// );
|
// );
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -547,19 +778,62 @@ export function initAcmeCertSync(): void {
|
|||||||
privateConfigData.acme?.acme_json_path ??
|
privateConfigData.acme?.acme_json_path ??
|
||||||
"config/letsencrypt/acme.json";
|
"config/letsencrypt/acme.json";
|
||||||
const intervalMs = privateConfigData.acme?.sync_interval_ms ?? 5000;
|
const intervalMs = privateConfigData.acme?.sync_interval_ms ?? 5000;
|
||||||
|
const httpEndpoint = privateConfigData.acme?.acme_http_endpoint;
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`acmeCertSync: starting ACME cert sync from "${acmeJsonPath}" across all resolvers every ${intervalMs}ms`
|
`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
|
// Run immediately on init, then on the configured interval
|
||||||
syncAcmeCerts(acmeJsonPath).catch((err) => {
|
runSync();
|
||||||
logger.error(`acmeCertSync: error during initial sync: ${err}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
setInterval(() => {
|
setInterval(runSync, intervalMs);
|
||||||
syncAcmeCerts(acmeJsonPath).catch((err) => {
|
|
||||||
logger.error(`acmeCertSync: error during sync: ${err}`);
|
|
||||||
});
|
|
||||||
}, intervalMs);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,173 +21,172 @@ import { getEnvOrYaml } from "@server/lib/getEnvOrYaml";
|
|||||||
|
|
||||||
const portSchema = z.number().positive().gt(0).lte(65535);
|
const portSchema = z.number().positive().gt(0).lte(65535);
|
||||||
|
|
||||||
export const privateConfigSchema = z.object({
|
export const privateConfigSchema = z
|
||||||
app: z
|
.object({
|
||||||
.object({
|
app: z
|
||||||
region: z.string().optional().default("default"),
|
.object({
|
||||||
base_domain: z.string().optional(),
|
region: z.string().optional().default("default"),
|
||||||
identity_provider_mode: z.enum(["global", "org"]).optional()
|
base_domain: z.string().optional(),
|
||||||
})
|
identity_provider_mode: z.enum(["global", "org"]).optional()
|
||||||
.optional()
|
})
|
||||||
.default({
|
.optional()
|
||||||
region: "default"
|
.default({
|
||||||
}),
|
region: "default"
|
||||||
server: z
|
}),
|
||||||
.object({
|
server: z
|
||||||
reo_client_id: z
|
.object({
|
||||||
.string()
|
reo_client_id: z
|
||||||
.optional()
|
.string()
|
||||||
.transform(getEnvOrYaml("REO_CLIENT_ID")),
|
.optional()
|
||||||
fossorial_api: z
|
.transform(getEnvOrYaml("REO_CLIENT_ID")),
|
||||||
.string()
|
fossorial_api: z
|
||||||
.optional()
|
.string()
|
||||||
.default("https://api.fossorial.io"),
|
.optional()
|
||||||
fossorial_api_key: z
|
.default("https://api.fossorial.io"),
|
||||||
.string()
|
fossorial_api_key: z
|
||||||
.optional()
|
.string()
|
||||||
.transform(getEnvOrYaml("FOSSORIAL_API_KEY"))
|
.optional()
|
||||||
})
|
.transform(getEnvOrYaml("FOSSORIAL_API_KEY"))
|
||||||
.optional()
|
})
|
||||||
.prefault({}),
|
.optional()
|
||||||
redis: z
|
.prefault({}),
|
||||||
.object({
|
redis: z
|
||||||
host: z.string(),
|
.object({
|
||||||
port: portSchema,
|
host: z.string(),
|
||||||
password: z
|
port: portSchema,
|
||||||
.string()
|
password: z
|
||||||
.optional()
|
.string()
|
||||||
.transform(getEnvOrYaml("REDIS_PASSWORD")),
|
.optional()
|
||||||
db: z.int().nonnegative().optional().default(0),
|
.transform(getEnvOrYaml("REDIS_PASSWORD")),
|
||||||
replicas: z
|
db: z.int().nonnegative().optional().default(0),
|
||||||
.array(
|
replicas: z
|
||||||
z.object({
|
.array(
|
||||||
host: z.string(),
|
z.object({
|
||||||
port: portSchema,
|
host: z.string(),
|
||||||
password: z.string().optional(),
|
port: portSchema,
|
||||||
db: z.int().nonnegative().optional().default(0)
|
password: z.string().optional(),
|
||||||
|
db: z.int().nonnegative().optional().default(0)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
tls: z
|
||||||
|
.object({
|
||||||
|
rejectUnauthorized: z.boolean().optional().default(true)
|
||||||
})
|
})
|
||||||
)
|
.optional()
|
||||||
.optional(),
|
})
|
||||||
tls: z
|
.optional(),
|
||||||
.object({
|
gerbil: z
|
||||||
rejectUnauthorized: z
|
.object({
|
||||||
.boolean()
|
local_exit_node_reachable_at: z
|
||||||
.optional()
|
.string()
|
||||||
.default(true)
|
.optional()
|
||||||
})
|
.default("http://gerbil:3004")
|
||||||
.optional()
|
})
|
||||||
})
|
.optional()
|
||||||
.optional(),
|
.prefault({}),
|
||||||
gerbil: z
|
flags: z
|
||||||
.object({
|
.object({
|
||||||
local_exit_node_reachable_at: z
|
enable_redis: z.boolean().optional().default(false),
|
||||||
.string()
|
use_pangolin_dns: z.boolean().optional().default(false),
|
||||||
.optional()
|
use_org_only_idp: z.boolean().optional(),
|
||||||
.default("http://gerbil:3004")
|
enable_acme_cert_sync: z.boolean().optional().default(true)
|
||||||
})
|
})
|
||||||
.optional()
|
.optional()
|
||||||
.prefault({}),
|
.prefault({}),
|
||||||
flags: z
|
acme: z
|
||||||
.object({
|
.object({
|
||||||
enable_redis: z.boolean().optional().default(false),
|
acme_json_path: z
|
||||||
use_pangolin_dns: z.boolean().optional().default(false),
|
.string()
|
||||||
use_org_only_idp: z.boolean().optional(),
|
.optional()
|
||||||
enable_acme_cert_sync: z.boolean().optional().default(true)
|
.default("config/letsencrypt/acme.json"),
|
||||||
})
|
acme_http_endpoint: z.string().optional(),
|
||||||
.optional()
|
sync_interval_ms: z.number().optional().default(5000)
|
||||||
.prefault({}),
|
})
|
||||||
acme: z
|
.optional(),
|
||||||
.object({
|
branding: z
|
||||||
acme_json_path: z
|
.object({
|
||||||
.string()
|
app_name: z.string().optional(),
|
||||||
.optional()
|
background_image_path: z.string().optional(),
|
||||||
.default("config/letsencrypt/acme.json"),
|
colors: z
|
||||||
sync_interval_ms: z.number().optional().default(5000)
|
.object({
|
||||||
})
|
light: colorsSchema.optional(),
|
||||||
.optional(),
|
dark: colorsSchema.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(),
|
logo: z
|
||||||
hide_auth_layout_footer: z.boolean().optional().default(false),
|
.object({
|
||||||
login_page: z
|
light_path: z.string().optional(),
|
||||||
.object({
|
dark_path: z.string().optional(),
|
||||||
subtitle_text: z.string().optional()
|
auth_page: z
|
||||||
})
|
.object({
|
||||||
.optional(),
|
width: z.number().optional(),
|
||||||
signup_page: z
|
height: z.number().optional()
|
||||||
.object({
|
})
|
||||||
subtitle_text: z.string().optional()
|
.optional(),
|
||||||
})
|
navbar: z
|
||||||
.optional(),
|
.object({
|
||||||
resource_auth_page: z
|
width: z.number().optional(),
|
||||||
.object({
|
height: z.number().optional()
|
||||||
show_logo: z.boolean().optional(),
|
})
|
||||||
hide_powered_by: z.boolean().optional(),
|
.optional()
|
||||||
title_text: z.string().optional(),
|
})
|
||||||
subtitle_text: z.string().optional()
|
.optional(),
|
||||||
})
|
footer: z
|
||||||
.optional(),
|
.array(
|
||||||
emails: z
|
z.object({
|
||||||
.object({
|
text: z.string(),
|
||||||
signature: z.string().optional(),
|
href: z.string().optional()
|
||||||
colors: z
|
|
||||||
.object({
|
|
||||||
primary: z.string().optional()
|
|
||||||
})
|
})
|
||||||
.optional()
|
)
|
||||||
})
|
.optional(),
|
||||||
.optional()
|
hide_auth_layout_footer: z.boolean().optional().default(false),
|
||||||
})
|
login_page: z
|
||||||
.optional(),
|
.object({
|
||||||
stripe: z
|
subtitle_text: z.string().optional()
|
||||||
.object({
|
})
|
||||||
secret_key: z
|
.optional(),
|
||||||
.string()
|
signup_page: z
|
||||||
.optional()
|
.object({
|
||||||
.transform(getEnvOrYaml("STRIPE_SECRET_KEY")),
|
subtitle_text: z.string().optional()
|
||||||
webhook_secret: z
|
})
|
||||||
.string()
|
.optional(),
|
||||||
.optional()
|
resource_auth_page: z
|
||||||
.transform(getEnvOrYaml("STRIPE_WEBHOOK_SECRET")),
|
.object({
|
||||||
// s3Bucket: z.string(),
|
show_logo: z.boolean().optional(),
|
||||||
// s3Region: z.string().default("us-east-1"),
|
hide_powered_by: z.boolean().optional(),
|
||||||
// localFilePath: z.string().optional()
|
title_text: z.string().optional(),
|
||||||
})
|
subtitle_text: z.string().optional()
|
||||||
.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) => {
|
.transform((data) => {
|
||||||
// this to maintain backwards compatibility with the old config file
|
// this to maintain backwards compatibility with the old config file
|
||||||
const identityProviderMode = data.app?.identity_provider_mode;
|
const identityProviderMode = data.app?.identity_provider_mode;
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export async function createCertificate(
|
|||||||
|
|
||||||
let domainToWrite = domain;
|
let domainToWrite = domain;
|
||||||
if (
|
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 &&
|
domainRecord.preferWildcardCert &&
|
||||||
!domain.startsWith("*.")
|
!domain.startsWith("*.")
|
||||||
) {
|
) {
|
||||||
@@ -89,6 +89,16 @@ export async function createCertificate(
|
|||||||
domainToWrite = parts.slice(1).join(".");
|
domainToWrite = parts.slice(1).join(".");
|
||||||
domainToWrite = `*.${domainToWrite}`;
|
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
|
// No cert found, create a new one in pending state
|
||||||
|
|||||||
@@ -42,9 +42,12 @@ async function query(siteId?: number, niceId?: string, orgId?: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GetSiteResponse = NonNullable<
|
type SiteQueryRow = NonNullable<Awaited<ReturnType<typeof query>>>;
|
||||||
Awaited<ReturnType<typeof query>>
|
|
||||||
>["sites"] & { newtId: string | null };
|
export type GetSiteResponse = SiteQueryRow["sites"] & {
|
||||||
|
newtId: string | null;
|
||||||
|
newtVersion: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
registry.registerPath({
|
registry.registerPath({
|
||||||
method: "get",
|
method: "get",
|
||||||
@@ -100,7 +103,8 @@ export async function getSite(
|
|||||||
|
|
||||||
const data: GetSiteResponse = {
|
const data: GetSiteResponse = {
|
||||||
...site.sites,
|
...site.sites,
|
||||||
newtId: site.newt ? site.newt.newtId : null
|
newtId: site.newt ? site.newt.newtId : null,
|
||||||
|
newtVersion: site.newt?.version ?? null
|
||||||
};
|
};
|
||||||
|
|
||||||
return response<GetSiteResponse>(res, {
|
return response<GetSiteResponse>(res, {
|
||||||
|
|||||||
@@ -81,10 +81,10 @@ export default function ProductUpdates({
|
|||||||
|
|
||||||
const showNewVersionPopup = Boolean(
|
const showNewVersionPopup = Boolean(
|
||||||
latestVersion &&
|
latestVersion &&
|
||||||
valid(latestVersion) &&
|
valid(latestVersion) &&
|
||||||
valid(currentVersion) &&
|
valid(currentVersion) &&
|
||||||
ignoredVersionUpdate !== latestVersion &&
|
ignoredVersionUpdate !== latestVersion &&
|
||||||
gt(latestVersion, currentVersion)
|
gt(latestVersion, currentVersion)
|
||||||
);
|
);
|
||||||
|
|
||||||
const filteredUpdates = data.updates.filter(
|
const filteredUpdates = data.updates.filter(
|
||||||
@@ -103,40 +103,51 @@ export default function ProductUpdates({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{filteredUpdates.length > 1 && (
|
{filteredUpdates.length > 0 && (
|
||||||
<small
|
<div className="mt-3 flex flex-col gap-2">
|
||||||
className={cn(
|
{filteredUpdates.length > 1 && (
|
||||||
"text-xs text-muted-foreground flex items-center gap-1 mt-2",
|
<small
|
||||||
showMoreUpdatesText
|
className={cn(
|
||||||
? "animate-in fade-in duration-300"
|
"text-xs text-muted-foreground flex items-center gap-1",
|
||||||
: "opacity-0"
|
showMoreUpdatesText
|
||||||
|
? "animate-in fade-in duration-300"
|
||||||
|
: "opacity-0"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<BellIcon className="flex-none size-3" />
|
||||||
|
<span>
|
||||||
|
{showNewVersionPopup
|
||||||
|
? t("productUpdateMoreInfo", {
|
||||||
|
noOfUpdates:
|
||||||
|
filteredUpdates.length
|
||||||
|
})
|
||||||
|
: t("productUpdateInfo", {
|
||||||
|
noOfUpdates:
|
||||||
|
filteredUpdates.length
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</small>
|
||||||
)}
|
)}
|
||||||
>
|
<ProductUpdatesListPopup
|
||||||
<BellIcon className="flex-none size-3" />
|
updates={filteredUpdates}
|
||||||
<span>
|
show={filteredUpdates.length > 0}
|
||||||
{showNewVersionPopup
|
onDimissAll={() =>
|
||||||
? t("productUpdateMoreInfo", {
|
setProductUpdatesRead([
|
||||||
noOfUpdates: filteredUpdates.length
|
...productUpdatesRead,
|
||||||
})
|
...filteredUpdates.map(
|
||||||
: t("productUpdateInfo", {
|
(update) => update.id
|
||||||
noOfUpdates: filteredUpdates.length
|
)
|
||||||
})}
|
])
|
||||||
</span>
|
}
|
||||||
</small>
|
onDimiss={(id) =>
|
||||||
|
setProductUpdatesRead([
|
||||||
|
...productUpdatesRead,
|
||||||
|
id
|
||||||
|
])
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<ProductUpdatesListPopup
|
|
||||||
updates={filteredUpdates}
|
|
||||||
show={filteredUpdates.length > 0}
|
|
||||||
onDimissAll={() =>
|
|
||||||
setProductUpdatesRead([
|
|
||||||
...productUpdatesRead,
|
|
||||||
...filteredUpdates.map((update) => update.id)
|
|
||||||
])
|
|
||||||
}
|
|
||||||
onDimiss={(id) =>
|
|
||||||
setProductUpdatesRead([...productUpdatesRead, id])
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<NewVersionAvailable
|
<NewVersionAvailable
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { useSiteContext } from "@app/hooks/useSiteContext";
|
import { useSiteContext } from "@app/hooks/useSiteContext";
|
||||||
import {
|
import {
|
||||||
InfoSection,
|
InfoSection,
|
||||||
@@ -9,77 +9,137 @@ import {
|
|||||||
InfoSectionTitle
|
InfoSectionTitle
|
||||||
} from "@app/components/InfoSection";
|
} from "@app/components/InfoSection";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
|
||||||
|
|
||||||
type SiteInfoCardProps = {};
|
type SiteInfoCardProps = {};
|
||||||
|
|
||||||
export default function SiteInfoCard({}: SiteInfoCardProps) {
|
function formatPublicEndpoint(endpoint: string) {
|
||||||
const { site, updateSite } = useSiteContext();
|
return endpoint.includes(":")
|
||||||
const t = useTranslations();
|
? endpoint.substring(0, endpoint.lastIndexOf(":"))
|
||||||
const { env } = useEnvContext();
|
: endpoint;
|
||||||
|
}
|
||||||
|
|
||||||
const getConnectionTypeString = (type: string) => {
|
export default function SiteInfoCard({}: SiteInfoCardProps) {
|
||||||
if (type === "newt") {
|
const { site } = useSiteContext();
|
||||||
return "Newt";
|
const t = useTranslations();
|
||||||
} else if (type === "wireguard") {
|
|
||||||
return "WireGuard";
|
const identifierSection = (
|
||||||
} else if (type === "local") {
|
<InfoSection>
|
||||||
return t("local");
|
<InfoSectionTitle>{t("identifier")}</InfoSectionTitle>
|
||||||
} else {
|
<InfoSectionContent>{site.niceId}</InfoSectionContent>
|
||||||
return t("unknown");
|
</InfoSection>
|
||||||
}
|
);
|
||||||
};
|
|
||||||
|
const statusSection = (
|
||||||
|
<InfoSection>
|
||||||
|
<InfoSectionTitle>{t("status")}</InfoSectionTitle>
|
||||||
|
<InfoSectionContent>
|
||||||
|
{site.online ? (
|
||||||
|
<div className="text-green-500 flex items-center space-x-2">
|
||||||
|
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||||
|
<span>{t("online")}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-neutral-500 flex items-center space-x-2">
|
||||||
|
<div className="w-2 h-2 bg-neutral-500 rounded-full"></div>
|
||||||
|
<span>{t("offline")}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</InfoSectionContent>
|
||||||
|
</InfoSection>
|
||||||
|
);
|
||||||
|
|
||||||
|
const endpointSection = site.endpoint ? (
|
||||||
|
<InfoSection>
|
||||||
|
<InfoSectionTitle>{t("publicIpEndpoint")}</InfoSectionTitle>
|
||||||
|
<InfoSectionContent>
|
||||||
|
{formatPublicEndpoint(site.endpoint)}
|
||||||
|
</InfoSectionContent>
|
||||||
|
</InfoSection>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
if (site.type === "newt") {
|
||||||
|
return (
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>
|
||||||
|
<InfoSections cols={site.endpoint ? 5 : 4}>
|
||||||
|
{identifierSection}
|
||||||
|
{statusSection}
|
||||||
|
<InfoSection>
|
||||||
|
<InfoSectionTitle>
|
||||||
|
{t("connectionType")}
|
||||||
|
</InfoSectionTitle>
|
||||||
|
<InfoSectionContent>Newt</InfoSectionContent>
|
||||||
|
</InfoSection>
|
||||||
|
<InfoSection>
|
||||||
|
<InfoSectionTitle>
|
||||||
|
{t("newtVersion")}
|
||||||
|
</InfoSectionTitle>
|
||||||
|
<InfoSectionContent>
|
||||||
|
{site.newtVersion
|
||||||
|
? `v${site.newtVersion}`
|
||||||
|
: "-"}
|
||||||
|
</InfoSectionContent>
|
||||||
|
</InfoSection>
|
||||||
|
{endpointSection}
|
||||||
|
</InfoSections>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (site.type === "wireguard") {
|
||||||
|
return (
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>
|
||||||
|
<InfoSections cols={site.endpoint ? 4 : 3}>
|
||||||
|
{identifierSection}
|
||||||
|
{statusSection}
|
||||||
|
<InfoSection>
|
||||||
|
<InfoSectionTitle>
|
||||||
|
{t("connectionType")}
|
||||||
|
</InfoSectionTitle>
|
||||||
|
<InfoSectionContent>WireGuard</InfoSectionContent>
|
||||||
|
</InfoSection>
|
||||||
|
{endpointSection}
|
||||||
|
</InfoSections>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (site.type === "local") {
|
||||||
|
return (
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>
|
||||||
|
<InfoSections cols={site.endpoint ? 3 : 2}>
|
||||||
|
{identifierSection}
|
||||||
|
<InfoSection>
|
||||||
|
<InfoSectionTitle>
|
||||||
|
{t("connectionType")}
|
||||||
|
</InfoSectionTitle>
|
||||||
|
<InfoSectionContent>
|
||||||
|
{t("local")}
|
||||||
|
</InfoSectionContent>
|
||||||
|
</InfoSection>
|
||||||
|
{endpointSection}
|
||||||
|
</InfoSections>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Alert>
|
<Alert>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
<InfoSections cols={site.endpoint ? 4 : 3}>
|
<InfoSections cols={site.endpoint ? 3 : 2}>
|
||||||
<InfoSection>
|
{identifierSection}
|
||||||
<InfoSectionTitle>{t("identifier")}</InfoSectionTitle>
|
|
||||||
<InfoSectionContent>{site.niceId}</InfoSectionContent>
|
|
||||||
</InfoSection>
|
|
||||||
{(site.type == "newt" || site.type == "wireguard") && (
|
|
||||||
<>
|
|
||||||
<InfoSection>
|
|
||||||
<InfoSectionTitle>
|
|
||||||
{t("status")}
|
|
||||||
</InfoSectionTitle>
|
|
||||||
<InfoSectionContent>
|
|
||||||
{site.online ? (
|
|
||||||
<div className="text-green-500 flex items-center space-x-2">
|
|
||||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
|
||||||
<span>{t("online")}</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-neutral-500 flex items-center space-x-2">
|
|
||||||
<div className="w-2 h-2 bg-neutral-500 rounded-full"></div>
|
|
||||||
<span>{t("offline")}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</InfoSectionContent>
|
|
||||||
</InfoSection>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<InfoSection>
|
<InfoSection>
|
||||||
<InfoSectionTitle>
|
<InfoSectionTitle>
|
||||||
{t("connectionType")}
|
{t("connectionType")}
|
||||||
</InfoSectionTitle>
|
</InfoSectionTitle>
|
||||||
<InfoSectionContent>
|
<InfoSectionContent>{t("unknown")}</InfoSectionContent>
|
||||||
{getConnectionTypeString(site.type)}
|
|
||||||
</InfoSectionContent>
|
|
||||||
</InfoSection>
|
</InfoSection>
|
||||||
{site.endpoint && (
|
{endpointSection}
|
||||||
<InfoSection>
|
|
||||||
<InfoSectionTitle>
|
|
||||||
{t("publicIpEndpoint")}
|
|
||||||
</InfoSectionTitle>
|
|
||||||
<InfoSectionContent>
|
|
||||||
{site.endpoint.includes(":")
|
|
||||||
? site.endpoint.substring(0, site.endpoint.lastIndexOf(":"))
|
|
||||||
: site.endpoint}
|
|
||||||
</InfoSectionContent>
|
|
||||||
</InfoSection>
|
|
||||||
)}
|
|
||||||
</InfoSections>
|
</InfoSections>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|||||||
@@ -113,10 +113,10 @@ export function ResourceTargetAddressItem({
|
|||||||
? selectedSite?.name
|
? selectedSite?.name
|
||||||
: t("siteSelect")}
|
: t("siteSelect")}
|
||||||
</span>
|
</span>
|
||||||
<CaretSortIcon className="ml-2h-4 w-4 shrink-0 opacity-50" />
|
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="p-0 w-45">
|
<PopoverContent className="p-0">
|
||||||
<SitesSelector
|
<SitesSelector
|
||||||
orgId={orgId}
|
orgId={orgId}
|
||||||
selectedSite={selectedSite}
|
selectedSite={selectedSite}
|
||||||
@@ -225,7 +225,6 @@ export function ResourceTargetAddressItem({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user