mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-30 12:42:22 +00:00
Merge branch 'dev' into alerting-rules
This commit is contained in:
@@ -19,6 +19,7 @@ export class TraefikConfigManager {
|
||||
private timeoutId: NodeJS.Timeout | null = null;
|
||||
private lastCertificateFetch: Date | null = null;
|
||||
private lastKnownDomains = new Set<string>();
|
||||
private pendingDeletion = new Map<string, number>(); // domain -> cycles remaining before delete
|
||||
private lastLocalCertificateState = new Map<
|
||||
string,
|
||||
{
|
||||
@@ -1004,33 +1005,62 @@ export class TraefikConfigManager {
|
||||
|
||||
const dirName = dirent.name;
|
||||
// Only delete if NO current domain is exactly the same or ends with `.${dirName}`
|
||||
const shouldDelete = !Array.from(currentActiveDomains).some(
|
||||
const isUnused = !Array.from(currentActiveDomains).some(
|
||||
(domain) =>
|
||||
domain === dirName || domain.endsWith(`.${dirName}`)
|
||||
);
|
||||
|
||||
if (shouldDelete) {
|
||||
const domainDir = path.join(certsPath, dirName);
|
||||
logger.info(
|
||||
`Cleaning up unused certificate directory: ${dirName}`
|
||||
);
|
||||
fs.rmSync(domainDir, { recursive: true, force: true });
|
||||
|
||||
// Remove from local state tracking
|
||||
this.lastLocalCertificateState.delete(dirName);
|
||||
|
||||
// Remove from dynamic config
|
||||
const certFilePath = path.join(domainDir, "cert.pem");
|
||||
const keyFilePath = path.join(domainDir, "key.pem");
|
||||
const before = dynamicConfig.tls.certificates.length;
|
||||
dynamicConfig.tls.certificates =
|
||||
dynamicConfig.tls.certificates.filter(
|
||||
(entry: any) =>
|
||||
entry.certFile !== certFilePath &&
|
||||
entry.keyFile !== keyFilePath
|
||||
if (!isUnused) {
|
||||
// Domain is still active — remove from pending deletion if it was queued
|
||||
if (this.pendingDeletion.has(dirName)) {
|
||||
logger.info(
|
||||
`Certificate ${dirName} is active again, cancelling pending deletion`
|
||||
);
|
||||
if (dynamicConfig.tls.certificates.length !== before) {
|
||||
configChanged = true;
|
||||
this.pendingDeletion.delete(dirName);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Domain is unused — add to pending deletion or decrement its counter
|
||||
if (!this.pendingDeletion.has(dirName)) {
|
||||
const graceCycles = 3;
|
||||
logger.info(
|
||||
`Certificate ${dirName} is no longer in use. Will delete after ${graceCycles} more cycles.`
|
||||
);
|
||||
this.pendingDeletion.set(dirName, graceCycles);
|
||||
} else {
|
||||
const remaining = this.pendingDeletion.get(dirName)! - 1;
|
||||
if (remaining > 0) {
|
||||
logger.info(
|
||||
`Certificate ${dirName} pending deletion: ${remaining} cycle(s) remaining`
|
||||
);
|
||||
this.pendingDeletion.set(dirName, remaining);
|
||||
} else {
|
||||
// Grace period expired — actually delete now
|
||||
this.pendingDeletion.delete(dirName);
|
||||
|
||||
const domainDir = path.join(certsPath, dirName);
|
||||
logger.info(
|
||||
`Cleaning up unused certificate directory: ${dirName}`
|
||||
);
|
||||
fs.rmSync(domainDir, { recursive: true, force: true });
|
||||
|
||||
// Remove from local state tracking
|
||||
this.lastLocalCertificateState.delete(dirName);
|
||||
|
||||
// Remove from dynamic config
|
||||
const certFilePath = path.join(domainDir, "cert.pem");
|
||||
const keyFilePath = path.join(domainDir, "key.pem");
|
||||
const before = dynamicConfig.tls.certificates.length;
|
||||
dynamicConfig.tls.certificates =
|
||||
dynamicConfig.tls.certificates.filter(
|
||||
(entry: any) =>
|
||||
entry.certFile !== certFilePath &&
|
||||
entry.keyFile !== keyFilePath
|
||||
);
|
||||
if (dynamicConfig.tls.certificates.length !== before) {
|
||||
configChanged = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,6 +216,13 @@ if (build === "saas") {
|
||||
generateLicense.generateNewEnterpriseLicense
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/org/:orgId/license/:licenseKey/clear-instance-name",
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.billing),
|
||||
generateLicense.clearInstanceName
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/send-support-request",
|
||||
rateLimit({
|
||||
|
||||
87
server/private/routers/generatedLicense/clearInstanceName.ts
Normal file
87
server/private/routers/generatedLicense/clearInstanceName.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { response as sendResponse } from "@server/lib/response";
|
||||
import privateConfig from "#private/lib/config";
|
||||
import z from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
|
||||
const clearInstanceNameParamsSchema = z.object({
|
||||
orgId: z.string(),
|
||||
licenseKey: z.string()
|
||||
});
|
||||
|
||||
export async function clearInstanceName(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = clearInstanceNameParamsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { licenseKey } = parsedParams.data;
|
||||
|
||||
const apiResponse = await fetch(
|
||||
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/clear-instance-name`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"api-key":
|
||||
privateConfig.getRawPrivateConfig().server
|
||||
.fossorial_api_key!,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({ licenseKey })
|
||||
}
|
||||
);
|
||||
|
||||
const data = await apiResponse.json();
|
||||
|
||||
if (!data.success || data.error) {
|
||||
return next(
|
||||
createHttpError(
|
||||
data.status || HttpCode.BAD_REQUEST,
|
||||
data.message || "Failed to clear instance name from Fossorial API"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return sendResponse<null>(res, {
|
||||
data: null,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Instance name cleared successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"An error occurred while clearing the instance name."
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,3 +14,4 @@
|
||||
export * from "./listGeneratedLicenses";
|
||||
export * from "./generateNewLicense";
|
||||
export * from "./generateNewEnterpriseLicense";
|
||||
export * from "./clearInstanceName";
|
||||
|
||||
@@ -243,7 +243,7 @@ registry.registerPath({
|
||||
path: "/org/{orgId}/client/{niceId}",
|
||||
description:
|
||||
"Get a client by orgId and niceId. NiceId is a readable ID for the site and unique on a per org basis.",
|
||||
tags: [OpenAPITags.Site],
|
||||
tags: [OpenAPITags.Client],
|
||||
request: {
|
||||
params: z.object({
|
||||
orgId: z.string(),
|
||||
|
||||
@@ -15,11 +15,12 @@ import moment from "moment";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { hashPassword } from "@server/auth/password";
|
||||
import { isValidIP } from "@server/lib/validators";
|
||||
import { isIpInCidr } from "@server/lib/ip";
|
||||
import { getNextAvailableClientSubnet, isIpInCidr } from "@server/lib/ip";
|
||||
import { verifyExitNodeOrgAccess } from "#dynamic/lib/exitNodes";
|
||||
import { build } from "@server/build";
|
||||
import { usageService } from "@server/lib/billing/usageService";
|
||||
import { FeatureId } from "@server/lib/billing";
|
||||
import { generateId } from "@server/auth/sessions/app";
|
||||
|
||||
const createSiteParamsSchema = z.strictObject({
|
||||
orgId: z.string()
|
||||
@@ -28,6 +29,7 @@ const createSiteParamsSchema = z.strictObject({
|
||||
const createSiteSchema = z.strictObject({
|
||||
name: z.string().min(1).max(255),
|
||||
exitNodeId: z.int().positive().optional(),
|
||||
niceId: z.string().min(1).max(255).optional(),
|
||||
// subdomain: z
|
||||
// .string()
|
||||
// .min(1)
|
||||
@@ -52,7 +54,10 @@ const createSiteSchema = z.strictObject({
|
||||
|
||||
export type CreateSiteBody = z.infer<typeof createSiteSchema>;
|
||||
|
||||
export type CreateSiteResponse = Site;
|
||||
export type CreateSiteResponse = Site & {
|
||||
newtId?: string;
|
||||
secret?: string;
|
||||
};
|
||||
|
||||
registry.registerPath({
|
||||
method: "put",
|
||||
@@ -64,7 +69,11 @@ registry.registerPath({
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: createSiteSchema
|
||||
schema: createSiteSchema,
|
||||
example: {
|
||||
name: "My Site",
|
||||
type: "newt"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -96,9 +105,13 @@ export async function createSite(
|
||||
subnet,
|
||||
newtId,
|
||||
secret,
|
||||
address
|
||||
address,
|
||||
niceId
|
||||
} = parsedBody.data;
|
||||
|
||||
const updatedNewtSecret = secret || generateId(48);
|
||||
const updatedNewtId = newtId || generateId(15);
|
||||
|
||||
const parsedParams = createSiteParamsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
@@ -111,7 +124,10 @@ export async function createSite(
|
||||
|
||||
const { orgId } = parsedParams.data;
|
||||
|
||||
if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) {
|
||||
if (
|
||||
req.user &&
|
||||
(!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)
|
||||
) {
|
||||
return next(
|
||||
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
|
||||
);
|
||||
@@ -227,6 +243,18 @@ export async function createSite(
|
||||
)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const newClientAddress = await getNextAvailableClientSubnet(orgId);
|
||||
if (!newClientAddress) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"No available address found"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
updatedAddress = newClientAddress.split("/")[0];
|
||||
}
|
||||
|
||||
if (subnet && exitNodeId) {
|
||||
@@ -285,7 +313,31 @@ export async function createSite(
|
||||
}
|
||||
}
|
||||
|
||||
const niceId = await getUniqueSiteName(orgId);
|
||||
let updatedNiceId = niceId;
|
||||
if (!niceId) {
|
||||
updatedNiceId = await getUniqueSiteName(orgId);
|
||||
} else {
|
||||
// make sure the niceId is unique
|
||||
const existingSite = await db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(
|
||||
and(
|
||||
eq(sites.niceId, niceId),
|
||||
eq(sites.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existingSite.length > 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.CONFLICT,
|
||||
`Nice ID ${niceId} already exists. Please choose a different one.`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let newSite: Site | undefined;
|
||||
await db.transaction(async (trx) => {
|
||||
@@ -295,7 +347,7 @@ export async function createSite(
|
||||
.values({ // NOTE: NO SUBNET OR EXIT NODE ID PASSED IN HERE BECAUSE ITS NOW CHOSEN ON CONNECT
|
||||
orgId,
|
||||
name,
|
||||
niceId,
|
||||
niceId: updatedNiceId!,
|
||||
address: updatedAddress || null,
|
||||
type,
|
||||
dockerSocketEnabled: true,
|
||||
@@ -353,7 +405,7 @@ export async function createSite(
|
||||
orgId,
|
||||
exitNodeId,
|
||||
name,
|
||||
niceId,
|
||||
niceId: updatedNiceId!,
|
||||
subnet,
|
||||
type,
|
||||
pubKey: pubKey || null,
|
||||
@@ -367,8 +419,7 @@ export async function createSite(
|
||||
exitNodeId: exitNodeId || null,
|
||||
orgId,
|
||||
name,
|
||||
niceId,
|
||||
address: updatedAddress || null,
|
||||
niceId: updatedNiceId!,
|
||||
type,
|
||||
dockerSocketEnabled: false,
|
||||
online: true,
|
||||
@@ -402,7 +453,10 @@ export async function createSite(
|
||||
siteId: newSite.siteId
|
||||
});
|
||||
|
||||
if (req.user && !req.userOrgRoleIds?.includes(adminRole[0].roleId)) {
|
||||
if (
|
||||
req.user &&
|
||||
!req.userOrgRoleIds?.includes(adminRole[0].roleId)
|
||||
) {
|
||||
// make sure the user can access the site
|
||||
trx.insert(userSites).values({
|
||||
userId: req.user?.userId!,
|
||||
@@ -412,10 +466,10 @@ export async function createSite(
|
||||
|
||||
// add the peer to the exit node
|
||||
if (type == "newt") {
|
||||
const secretHash = await hashPassword(secret!);
|
||||
const secretHash = await hashPassword(updatedNewtSecret);
|
||||
|
||||
await trx.insert(newts).values({
|
||||
newtId: newtId!,
|
||||
newtId: updatedNewtId,
|
||||
secretHash,
|
||||
siteId: newSite.siteId,
|
||||
dateCreated: moment().toISOString()
|
||||
@@ -458,7 +512,11 @@ export async function createSite(
|
||||
}
|
||||
|
||||
return response<CreateSiteResponse>(res, {
|
||||
data: newSite,
|
||||
data: {
|
||||
...newSite,
|
||||
newtId: type == "newt" ? updatedNewtId : undefined,
|
||||
secret: type == "newt" ? updatedNewtSecret : undefined
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Site created successfully",
|
||||
|
||||
@@ -124,7 +124,7 @@ export async function pickSiteDefaults(
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"No available subnet found"
|
||||
"No available address"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user