Merge branch 'dev' into alerting-rules

This commit is contained in:
Owen
2026-04-16 15:53:32 -07:00
11 changed files with 326 additions and 57 deletions

View File

@@ -19,6 +19,7 @@ export class TraefikConfigManager {
private timeoutId: NodeJS.Timeout | null = null; private timeoutId: NodeJS.Timeout | null = null;
private lastCertificateFetch: Date | null = null; private lastCertificateFetch: Date | null = null;
private lastKnownDomains = new Set<string>(); private lastKnownDomains = new Set<string>();
private pendingDeletion = new Map<string, number>(); // domain -> cycles remaining before delete
private lastLocalCertificateState = new Map< private lastLocalCertificateState = new Map<
string, string,
{ {
@@ -1004,33 +1005,62 @@ export class TraefikConfigManager {
const dirName = dirent.name; const dirName = dirent.name;
// Only delete if NO current domain is exactly the same or ends with `.${dirName}` // 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) =>
domain === dirName || domain.endsWith(`.${dirName}`) domain === dirName || domain.endsWith(`.${dirName}`)
); );
if (shouldDelete) { if (!isUnused) {
const domainDir = path.join(certsPath, dirName); // Domain is still active — remove from pending deletion if it was queued
logger.info( if (this.pendingDeletion.has(dirName)) {
`Cleaning up unused certificate directory: ${dirName}` logger.info(
); `Certificate ${dirName} is active again, cancelling pending deletion`
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) { this.pendingDeletion.delete(dirName);
configChanged = true; }
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;
}
} }
} }
} }

View File

@@ -216,6 +216,13 @@ if (build === "saas") {
generateLicense.generateNewEnterpriseLicense generateLicense.generateNewEnterpriseLicense
); );
authenticated.post(
"/org/:orgId/license/:licenseKey/clear-instance-name",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.billing),
generateLicense.clearInstanceName
);
authenticated.post( authenticated.post(
"/send-support-request", "/send-support-request",
rateLimit({ rateLimit({

View 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."
)
);
}
}

View File

@@ -14,3 +14,4 @@
export * from "./listGeneratedLicenses"; export * from "./listGeneratedLicenses";
export * from "./generateNewLicense"; export * from "./generateNewLicense";
export * from "./generateNewEnterpriseLicense"; export * from "./generateNewEnterpriseLicense";
export * from "./clearInstanceName";

View File

@@ -243,7 +243,7 @@ registry.registerPath({
path: "/org/{orgId}/client/{niceId}", path: "/org/{orgId}/client/{niceId}",
description: description:
"Get a client by orgId and niceId. NiceId is a readable ID for the site and unique on a per org basis.", "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: { request: {
params: z.object({ params: z.object({
orgId: z.string(), orgId: z.string(),

View File

@@ -15,11 +15,12 @@ import moment from "moment";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { hashPassword } from "@server/auth/password"; import { hashPassword } from "@server/auth/password";
import { isValidIP } from "@server/lib/validators"; 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 { verifyExitNodeOrgAccess } from "#dynamic/lib/exitNodes";
import { build } from "@server/build"; import { build } from "@server/build";
import { usageService } from "@server/lib/billing/usageService"; import { usageService } from "@server/lib/billing/usageService";
import { FeatureId } from "@server/lib/billing"; import { FeatureId } from "@server/lib/billing";
import { generateId } from "@server/auth/sessions/app";
const createSiteParamsSchema = z.strictObject({ const createSiteParamsSchema = z.strictObject({
orgId: z.string() orgId: z.string()
@@ -28,6 +29,7 @@ const createSiteParamsSchema = z.strictObject({
const createSiteSchema = z.strictObject({ const createSiteSchema = z.strictObject({
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
exitNodeId: z.int().positive().optional(), exitNodeId: z.int().positive().optional(),
niceId: z.string().min(1).max(255).optional(),
// subdomain: z // subdomain: z
// .string() // .string()
// .min(1) // .min(1)
@@ -52,7 +54,10 @@ const createSiteSchema = z.strictObject({
export type CreateSiteBody = z.infer<typeof createSiteSchema>; export type CreateSiteBody = z.infer<typeof createSiteSchema>;
export type CreateSiteResponse = Site; export type CreateSiteResponse = Site & {
newtId?: string;
secret?: string;
};
registry.registerPath({ registry.registerPath({
method: "put", method: "put",
@@ -64,7 +69,11 @@ registry.registerPath({
body: { body: {
content: { content: {
"application/json": { "application/json": {
schema: createSiteSchema schema: createSiteSchema,
example: {
name: "My Site",
type: "newt"
}
} }
} }
} }
@@ -96,9 +105,13 @@ export async function createSite(
subnet, subnet,
newtId, newtId,
secret, secret,
address address,
niceId
} = parsedBody.data; } = parsedBody.data;
const updatedNewtSecret = secret || generateId(48);
const updatedNewtId = newtId || generateId(15);
const parsedParams = createSiteParamsSchema.safeParse(req.params); const parsedParams = createSiteParamsSchema.safeParse(req.params);
if (!parsedParams.success) { if (!parsedParams.success) {
return next( return next(
@@ -111,7 +124,10 @@ export async function createSite(
const { orgId } = parsedParams.data; const { orgId } = parsedParams.data;
if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) { if (
req.user &&
(!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)
) {
return next( return next(
createHttpError(HttpCode.FORBIDDEN, "User does not have a role") 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) { 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; let newSite: Site | undefined;
await db.transaction(async (trx) => { 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 .values({ // NOTE: NO SUBNET OR EXIT NODE ID PASSED IN HERE BECAUSE ITS NOW CHOSEN ON CONNECT
orgId, orgId,
name, name,
niceId, niceId: updatedNiceId!,
address: updatedAddress || null, address: updatedAddress || null,
type, type,
dockerSocketEnabled: true, dockerSocketEnabled: true,
@@ -353,7 +405,7 @@ export async function createSite(
orgId, orgId,
exitNodeId, exitNodeId,
name, name,
niceId, niceId: updatedNiceId!,
subnet, subnet,
type, type,
pubKey: pubKey || null, pubKey: pubKey || null,
@@ -367,8 +419,7 @@ export async function createSite(
exitNodeId: exitNodeId || null, exitNodeId: exitNodeId || null,
orgId, orgId,
name, name,
niceId, niceId: updatedNiceId!,
address: updatedAddress || null,
type, type,
dockerSocketEnabled: false, dockerSocketEnabled: false,
online: true, online: true,
@@ -402,7 +453,10 @@ export async function createSite(
siteId: newSite.siteId 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 // make sure the user can access the site
trx.insert(userSites).values({ trx.insert(userSites).values({
userId: req.user?.userId!, userId: req.user?.userId!,
@@ -412,10 +466,10 @@ export async function createSite(
// add the peer to the exit node // add the peer to the exit node
if (type == "newt") { if (type == "newt") {
const secretHash = await hashPassword(secret!); const secretHash = await hashPassword(updatedNewtSecret);
await trx.insert(newts).values({ await trx.insert(newts).values({
newtId: newtId!, newtId: updatedNewtId,
secretHash, secretHash,
siteId: newSite.siteId, siteId: newSite.siteId,
dateCreated: moment().toISOString() dateCreated: moment().toISOString()
@@ -458,7 +512,11 @@ export async function createSite(
} }
return response<CreateSiteResponse>(res, { return response<CreateSiteResponse>(res, {
data: newSite, data: {
...newSite,
newtId: type == "newt" ? updatedNewtId : undefined,
secret: type == "newt" ? updatedNewtSecret : undefined
},
success: true, success: true,
error: false, error: false,
message: "Site created successfully", message: "Site created successfully",

View File

@@ -124,7 +124,7 @@ export async function pickSiteDefaults(
return next( return next(
createHttpError( createHttpError(
HttpCode.INTERNAL_SERVER_ERROR, HttpCode.INTERNAL_SERVER_ERROR,
"No available subnet found" "No available address"
) )
); );
} }

View File

@@ -477,7 +477,7 @@ export default function BillingPage() {
}; };
const handleContactUs = () => { const handleContactUs = () => {
window.open("https://pangolin.net/talk-to-us", "_blank"); window.open("https://pangolin.net/contact", "_blank");
}; };
// Get current plan ID from tier // Get current plan ID from tier
@@ -558,6 +558,14 @@ export default function BillingPage() {
// Get button label and action for each plan // Get button label and action for each plan
const getPlanAction = (plan: PlanOption) => { const getPlanAction = (plan: PlanOption) => {
if (plan.id === "enterprise") { if (plan.id === "enterprise") {
if (plan.id === currentPlanId) {
return {
label: "Manage Current Plan",
action: handleModifySubscription,
variant: "default" as const,
disabled: false
};
}
return { return {
label: "Contact Us", label: "Contact Us",
action: handleContactUs, action: handleContactUs,

View File

@@ -161,16 +161,13 @@ export default function Page() {
description: t("siteNewtTunnelDescription"), description: t("siteNewtTunnelDescription"),
disabled: true disabled: true
}, },
...(env.flags.disableBasicWireguardSites ...(env.flags.disableBasicWireguardSites || build == "saas"
? [] ? []
: [ : [
{ {
id: "wireguard" as SiteType, id: "wireguard" as SiteType,
title: t("siteWg"), title: t("siteWg"),
description: description: t("siteWgDescription"),
build == "saas"
? t("siteWgDescriptionSaas")
: t("siteWgDescription"),
disabled: true disabled: true
} }
]), ]),
@@ -426,9 +423,22 @@ export default function Page() {
})); }));
setRemoteExitNodeOptions(exitNodeOptions); setRemoteExitNodeOptions(exitNodeOptions);
if (exitNodeOptions.length === 0) {
// No remote exit nodes available — remove local option and default to newt
setTunnelTypes((prev: any) =>
prev.filter((item: any) => item.id !== "local")
);
form.setValue("method", "newt");
}
} }
} catch (error) { } catch (error) {
console.error("Failed to fetch remote exit nodes:", error); console.error("Failed to fetch remote exit nodes:", error);
// If fetch fails, no remote exit nodes available — remove local option and default to newt
setTunnelTypes((prev: any) =>
prev.filter((item: any) => item.id !== "local")
);
form.setValue("method", "newt");
} }
} }

View File

@@ -66,18 +66,23 @@ export default async function AuthLayout({ children }: AuthLayoutProps) {
© {new Date().getFullYear()} Fossorial, Inc. © {new Date().getFullYear()} Fossorial, Inc.
</span> </span>
</a> </a>
<Separator orientation="vertical" /> {build !== "saas" && (
<a <>
href="https://pangolin.net" <Separator orientation="vertical" />
target="_blank" <a
rel="noopener noreferrer" href="https://pangolin.net"
aria-label="Built by Fossorial" target="_blank"
className="flex items-center space-x-2 whitespace-nowrap" rel="noopener noreferrer"
> aria-label="Built by Fossorial"
<span> className="flex items-center space-x-2 whitespace-nowrap"
{process.env.BRANDING_APP_NAME || "Pangolin"} >
</span> <span>
</a> {process.env.BRANDING_APP_NAME ||
"Pangolin"}
</span>
</a>
</>
)}
<Separator orientation="vertical" /> <Separator orientation="vertical" />
<span> <span>
{build === "oss" {build === "oss"

View File

@@ -4,7 +4,7 @@ import { useTranslations } from "next-intl";
import { ColumnDef } from "@tanstack/react-table"; import { ColumnDef } from "@tanstack/react-table";
import { ExtendedColumnDef } from "@app/components/ui/data-table"; import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { ArrowUpDown } from "lucide-react"; import { ArrowUpDown, MoreHorizontal } from "lucide-react";
import CopyToClipboard from "./CopyToClipboard"; import CopyToClipboard from "./CopyToClipboard";
import { Badge } from "./ui/badge"; import { Badge } from "./ui/badge";
import moment from "moment"; import moment from "moment";
@@ -16,6 +16,12 @@ import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api"; import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import NewPricingLicenseForm from "./NewPricingLicenseForm"; import NewPricingLicenseForm from "./NewPricingLicenseForm";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
type GnerateLicenseKeysTableProps = { type GnerateLicenseKeysTableProps = {
licenseKeys: GeneratedLicenseKey[]; licenseKeys: GeneratedLicenseKey[];
@@ -44,6 +50,7 @@ export default function GenerateLicenseKeysTable({
const [isRefreshing, setIsRefreshing] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false);
const [showGenerateForm, setShowGenerateForm] = useState(false); const [showGenerateForm, setShowGenerateForm] = useState(false);
const [isClearingInstanceName, setIsClearingInstanceName] = useState(false);
useEffect(() => { useEffect(() => {
if (searchParams.get(GENERATE_QUERY) !== null) { if (searchParams.get(GENERATE_QUERY) !== null) {
@@ -63,6 +70,28 @@ export default function GenerateLicenseKeysTable({
refreshData(); refreshData();
}; };
const clearInstanceName = async (licenseKey: string) => {
setIsClearingInstanceName(true);
try {
await api.post(
`/org/${orgId}/license/${encodeURIComponent(licenseKey)}/clear-instance-name`
);
toast({
title: t("success"),
description: "Instance name cleared successfully"
});
await refreshData();
} catch (error) {
toast({
title: t("error"),
description: formatAxiosError(error, "Failed to clear instance name"),
variant: "destructive"
});
} finally {
setIsClearingInstanceName(false);
}
};
const refreshData = async () => { const refreshData = async () => {
console.log("Data refreshed"); console.log("Data refreshed");
setIsRefreshing(true); setIsRefreshing(true);
@@ -236,6 +265,39 @@ export default function GenerateLicenseKeysTable({
const termianteAt = row.original.expiresAt; const termianteAt = row.original.expiresAt;
return moment(termianteAt).format("lll"); return moment(termianteAt).format("lll");
} }
},
{
id: "actions",
enableHiding: false,
header: () => <span className="p-3"></span>,
cell: ({ row }) => {
const key = row.original;
return (
<div className="flex items-center gap-2 justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
disabled={
!key.instanceName ||
isClearingInstanceName
}
onClick={() =>
clearInstanceName(key.licenseKey)
}
>
Clear Instance Name
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
} }
]; ];
@@ -254,6 +316,7 @@ export default function GenerateLicenseKeysTable({
onAdd={() => { onAdd={() => {
setShowGenerateForm(true); setShowGenerateForm(true);
}} }}
stickyRightColumn="actions"
/> />
<NewPricingLicenseForm <NewPricingLicenseForm