diff --git a/server/lib/traefik/TraefikConfigManager.ts b/server/lib/traefik/TraefikConfigManager.ts index 4aed80e45..6ef3c45b5 100644 --- a/server/lib/traefik/TraefikConfigManager.ts +++ b/server/lib/traefik/TraefikConfigManager.ts @@ -19,6 +19,7 @@ export class TraefikConfigManager { private timeoutId: NodeJS.Timeout | null = null; private lastCertificateFetch: Date | null = null; private lastKnownDomains = new Set(); + private pendingDeletion = new Map(); // 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; + } } } } diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index f10e526ed..153f1e839 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -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({ diff --git a/server/private/routers/generatedLicense/clearInstanceName.ts b/server/private/routers/generatedLicense/clearInstanceName.ts new file mode 100644 index 000000000..ed176a976 --- /dev/null +++ b/server/private/routers/generatedLicense/clearInstanceName.ts @@ -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 { + 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(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." + ) + ); + } +} \ No newline at end of file diff --git a/server/private/routers/generatedLicense/index.ts b/server/private/routers/generatedLicense/index.ts index e9212b47e..b527dc721 100644 --- a/server/private/routers/generatedLicense/index.ts +++ b/server/private/routers/generatedLicense/index.ts @@ -14,3 +14,4 @@ export * from "./listGeneratedLicenses"; export * from "./generateNewLicense"; export * from "./generateNewEnterpriseLicense"; +export * from "./clearInstanceName"; diff --git a/server/routers/client/getClient.ts b/server/routers/client/getClient.ts index 375c027a7..a05fdef42 100644 --- a/server/routers/client/getClient.ts +++ b/server/routers/client/getClient.ts @@ -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(), diff --git a/server/routers/site/createSite.ts b/server/routers/site/createSite.ts index d397b2784..f9b26799e 100644 --- a/server/routers/site/createSite.ts +++ b/server/routers/site/createSite.ts @@ -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; -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(res, { - data: newSite, + data: { + ...newSite, + newtId: type == "newt" ? updatedNewtId : undefined, + secret: type == "newt" ? updatedNewtSecret : undefined + }, success: true, error: false, message: "Site created successfully", diff --git a/server/routers/site/pickSiteDefaults.ts b/server/routers/site/pickSiteDefaults.ts index f5e95ca10..4e6e3bb17 100644 --- a/server/routers/site/pickSiteDefaults.ts +++ b/server/routers/site/pickSiteDefaults.ts @@ -124,7 +124,7 @@ export async function pickSiteDefaults( return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, - "No available subnet found" + "No available address" ) ); } diff --git a/src/app/[orgId]/settings/(private)/billing/page.tsx b/src/app/[orgId]/settings/(private)/billing/page.tsx index fdf0d252a..150725e32 100644 --- a/src/app/[orgId]/settings/(private)/billing/page.tsx +++ b/src/app/[orgId]/settings/(private)/billing/page.tsx @@ -477,7 +477,7 @@ export default function BillingPage() { }; const handleContactUs = () => { - window.open("https://pangolin.net/talk-to-us", "_blank"); + window.open("https://pangolin.net/contact", "_blank"); }; // Get current plan ID from tier @@ -558,6 +558,14 @@ export default function BillingPage() { // Get button label and action for each plan const getPlanAction = (plan: PlanOption) => { if (plan.id === "enterprise") { + if (plan.id === currentPlanId) { + return { + label: "Manage Current Plan", + action: handleModifySubscription, + variant: "default" as const, + disabled: false + }; + } return { label: "Contact Us", action: handleContactUs, diff --git a/src/app/[orgId]/settings/sites/create/page.tsx b/src/app/[orgId]/settings/sites/create/page.tsx index cb6365b79..b7cff202a 100644 --- a/src/app/[orgId]/settings/sites/create/page.tsx +++ b/src/app/[orgId]/settings/sites/create/page.tsx @@ -161,16 +161,13 @@ export default function Page() { description: t("siteNewtTunnelDescription"), disabled: true }, - ...(env.flags.disableBasicWireguardSites + ...(env.flags.disableBasicWireguardSites || build == "saas" ? [] : [ { id: "wireguard" as SiteType, title: t("siteWg"), - description: - build == "saas" - ? t("siteWgDescriptionSaas") - : t("siteWgDescription"), + description: t("siteWgDescription"), disabled: true } ]), @@ -426,9 +423,22 @@ export default function Page() { })); 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) { 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"); } } diff --git a/src/app/auth/layout.tsx b/src/app/auth/layout.tsx index 997ca3fb3..2cd8b4876 100644 --- a/src/app/auth/layout.tsx +++ b/src/app/auth/layout.tsx @@ -66,18 +66,23 @@ export default async function AuthLayout({ children }: AuthLayoutProps) { © {new Date().getFullYear()} Fossorial, Inc. - - - - {process.env.BRANDING_APP_NAME || "Pangolin"} - - + {build !== "saas" && ( + <> + + + + {process.env.BRANDING_APP_NAME || + "Pangolin"} + + + + )} {build === "oss" diff --git a/src/components/GenerateLicenseKeysTable.tsx b/src/components/GenerateLicenseKeysTable.tsx index 48eeb045e..e6961251b 100644 --- a/src/components/GenerateLicenseKeysTable.tsx +++ b/src/components/GenerateLicenseKeysTable.tsx @@ -4,7 +4,7 @@ import { useTranslations } from "next-intl"; import { ColumnDef } from "@tanstack/react-table"; import { ExtendedColumnDef } from "@app/components/ui/data-table"; import { Button } from "./ui/button"; -import { ArrowUpDown } from "lucide-react"; +import { ArrowUpDown, MoreHorizontal } from "lucide-react"; import CopyToClipboard from "./CopyToClipboard"; import { Badge } from "./ui/badge"; import moment from "moment"; @@ -16,6 +16,12 @@ import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import NewPricingLicenseForm from "./NewPricingLicenseForm"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from "@app/components/ui/dropdown-menu"; type GnerateLicenseKeysTableProps = { licenseKeys: GeneratedLicenseKey[]; @@ -44,6 +50,7 @@ export default function GenerateLicenseKeysTable({ const [isRefreshing, setIsRefreshing] = useState(false); const [showGenerateForm, setShowGenerateForm] = useState(false); + const [isClearingInstanceName, setIsClearingInstanceName] = useState(false); useEffect(() => { if (searchParams.get(GENERATE_QUERY) !== null) { @@ -63,6 +70,28 @@ export default function GenerateLicenseKeysTable({ 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 () => { console.log("Data refreshed"); setIsRefreshing(true); @@ -236,6 +265,39 @@ export default function GenerateLicenseKeysTable({ const termianteAt = row.original.expiresAt; return moment(termianteAt).format("lll"); } + }, + { + id: "actions", + enableHiding: false, + header: () => , + cell: ({ row }) => { + const key = row.original; + return ( +
+ + + + + + + clearInstanceName(key.licenseKey) + } + > + Clear Instance Name + + + +
+ ); + } } ]; @@ -254,6 +316,7 @@ export default function GenerateLicenseKeysTable({ onAdd={() => { setShowGenerateForm(true); }} + stickyRightColumn="actions" />