Compare commits

...

7 Commits

Author SHA1 Message Date
Owen 1f453dc04f Send out of address space errors to sites and clients 2026-09-17 09:24:15 -04:00
Owen 65ccd5a89d Enhance error handling for subscription lifecycle events in billing hooks 2026-09-16 12:11:25 -04:00
Owen 76a4f50ccf Update how billing is presented for license keys 2026-09-16 11:42:19 -04:00
miloschwartz 1dfe0124be add expanded resource laucher screenshot 2026-09-16 10:52:35 -04:00
miloschwartz aa52174f96 update resource launcher screenshot 2026-09-16 10:52:35 -04:00
Owen e23ae5707f Link direct to self hosted 2026-09-16 10:43:46 -04:00
Owen 17053ac2bd Fix agent display again 2026-09-16 10:38:52 -04:00
19 changed files with 251 additions and 51 deletions
+1
View File
@@ -142,6 +142,7 @@ Give users a landing page to quickly find and open the resources they can access
* Create reusable views for common access patterns * Create reusable views for common access patterns
<img src="public/screenshots/resource-launcher.png" alt="Resource Launcher" width="100%" /> <img src="public/screenshots/resource-launcher.png" alt="Resource Launcher" width="100%" />
<img src="public/screenshots/resource-launcher-expanded.png" alt="Resource Launcher Details Panel" width="100%" />
## Download Clients ## Download Clients
+3
View File
@@ -234,6 +234,9 @@
"clientResourceDescription": "Create and manage resources that are only accessible through a connected client", "clientResourceDescription": "Create and manage resources that are only accessible through a connected client",
"privateResourcesBannerTitle": "Zero-Trust Private Access", "privateResourcesBannerTitle": "Zero-Trust Private Access",
"privateResourcesBannerDescription": "Private resources use zero-trust security, ensuring users and machines can only access resources you explicitly grant. Connect user devices or machine clients to access these resources over a secure virtual private network.", "privateResourcesBannerDescription": "Private resources use zero-trust security, ensuring users and machines can only access resources you explicitly grant. Connect user devices or machine clients to access these resources over a secure virtual private network.",
"licenseBillingBannerTitle": "Manage License Billing",
"licenseBillingBannerDescription": "To manage billing for your license keys, including payment methods and invoices, visit the billing page.",
"licenseBillingBannerButton": "Go to Billing",
"resourcesSearch": "Search resources...", "resourcesSearch": "Search resources...",
"resourceAdd": "Add Resource", "resourceAdd": "Add Resource",
"resourceErrorDelte": "Error deleting resource", "resourceErrorDelte": "Error deleting resource",
Binary file not shown.

After

Width:  |  Height:  |  Size: 711 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 620 KiB

After

Width:  |  Height:  |  Size: 713 KiB

@@ -93,9 +93,8 @@ export const EnterpriseEditionKeyGenerated = ({
</EmailSection> </EmailSection>
<EmailText> <EmailText>
If you need to purchase additional license keys or For any questions or concerns regarding your license
modify your existing license, please reach out to or billing, please reach out to support at{" "}
our support team at{" "}
<a <a
href="mailto:support@pangolin.net" href="mailto:support@pangolin.net"
className="text-primary font-medium" className="text-primary font-medium"
@@ -243,6 +243,14 @@ export async function handleSubscriptionCreated(
`License type determined: ${numUsers} users, ${numSites} sites for subscription ${subscription.id}` `License type determined: ${numUsers} users, ${numSites} sites for subscription ${subscription.id}`
); );
// Grace period of 5 days added on top of the current billing
// period end (usually ~1 year out) before the license expires
const currentPeriodEnd =
fullSubscription.items.data[0]?.current_period_end;
const expiresAt =
(currentPeriodEnd ?? subscription.created) +
5 * 24 * 60 * 60;
const response = await fetch( const response = await fetch(
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/paid-for`, `${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/paid-for`,
{ {
@@ -258,7 +266,8 @@ export async function handleSubscriptionCreated(
paidFor: true, paidFor: true,
users: numUsers, users: numUsers,
sites: numSites, sites: numSites,
tier: tier tier: tier,
expiresAt: expiresAt
}) })
} }
); );
@@ -267,6 +276,13 @@ export async function handleSubscriptionCreated(
logger.debug(`Fossorial API response: ${JSON.stringify(data)}`); logger.debug(`Fossorial API response: ${JSON.stringify(data)}`);
if (!response.ok || !data.success) {
logger.error(
`Fossorial API returned ${response.status} when setting paid-for for orgId ${customer.orgId} and subscription ID ${subscription.id}: ${JSON.stringify(data)}`
);
return;
}
if (customer.email) { if (customer.email) {
logger.debug( logger.debug(
`Sending license key email to ${customer.email} for subscription ${subscription.id}` `Sending license key email to ${customer.email} for subscription ${subscription.id}`
@@ -125,9 +125,7 @@ export async function handleSubscriptionDeleted(
`Handling license subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}` `Handling license subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}`
); );
try { try {
// WARNING: const invalidateResponse = await fetch(
// this invalidates ALL OF THE ENTERPRISE LICENSES for this orgId
await fetch(
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/invalidate`, `${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/invalidate`,
{ {
method: "POST", method: "POST",
@@ -139,9 +137,18 @@ export async function handleSubscriptionDeleted(
}, },
body: JSON.stringify({ body: JSON.stringify({
orgId: customer.orgId, orgId: customer.orgId,
licenseKeyId: parseInt(
subscription.metadata.licenseKeyId
)
}) })
} }
); );
if (!invalidateResponse.ok) {
logger.error(
`Fossorial API returned ${invalidateResponse.status} when invalidating license for orgId ${customer.orgId} and subscription ID ${subscription.id}: ${await invalidateResponse.text()}`
);
}
} catch (error) { } catch (error) {
logger.error( logger.error(
`Error notifying Fossorial API of license subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}:`, `Error notifying Fossorial API of license subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}:`,
@@ -23,7 +23,10 @@ import {
} from "@server/db"; } from "@server/db";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";
import { getFeatureIdByMetricId, getFeatureIdByPriceId } from "@server/lib/billing/features"; import {
getFeatureIdByMetricId,
getFeatureIdByPriceId
} from "@server/lib/billing/features";
import stripe from "#private/lib/stripe"; import stripe from "#private/lib/stripe";
import { handleSubscriptionLifesycle } from "../subscriptionLifecycle"; import { handleSubscriptionLifesycle } from "../subscriptionLifecycle";
import { getSubType, SubscriptionType } from "./getSubType"; import { getSubType, SubscriptionType } from "./getSubType";
@@ -66,7 +69,8 @@ export async function handleSubscriptionUpdated(
.limit(1); .limit(1);
const type = getSubType(fullSubscription); const type = getSubType(fullSubscription);
const previousType = existingSubscription.type as SubscriptionType | null; const previousType =
existingSubscription.type as SubscriptionType | null;
// If the subscription has been manually overridden, we lock the // If the subscription has been manually overridden, we lock the
// status down so Stripe webhooks can no longer change it. // status down so Stripe webhooks can no longer change it.
@@ -100,7 +104,11 @@ export async function handleSubscriptionUpdated(
logger.info( logger.info(
`Tier change detected for org ${customer.orgId}: ${previousType} -> ${type}` `Tier change detected for org ${customer.orgId}: ${previousType} -> ${type}`
); );
await handleTierChange(customer.orgId, type, previousType ?? undefined); await handleTierChange(
customer.orgId,
type,
previousType ?? undefined
);
} }
// Upsert subscription items // Upsert subscription items
@@ -113,7 +121,8 @@ export async function handleSubscriptionUpdated(
const itemsToUpsert = fullSubscription.items.data.map((item) => { const itemsToUpsert = fullSubscription.items.data.map((item) => {
// Try to get featureId from price // Try to get featureId from price
let featureId: string | null = getFeatureIdByPriceId(item.price.id) || null; let featureId: string | null =
getFeatureIdByPriceId(item.price.id) || null;
// If no match, try to preserve existing featureId // If no match, try to preserve existing featureId
if (!featureId) { if (!featureId) {
@@ -302,14 +311,20 @@ export async function handleSubscriptionUpdated(
logger.info( logger.info(
`Subscription ${subscription.id} for org ${customer.orgId} is ${effectiveStatus}, disabling paid features` `Subscription ${subscription.id} for org ${customer.orgId} is ${effectiveStatus}, disabling paid features`
); );
await handleTierChange(customer.orgId, null, previousType ?? undefined); await handleTierChange(
customer.orgId,
null,
previousType ?? undefined
);
} }
} else if (type === "license") { } else if (type === "license") {
if (effectiveStatus === "canceled" || effectiveStatus == "unpaid" || effectiveStatus == "incomplete_expired") { if (
effectiveStatus === "canceled" ||
effectiveStatus == "unpaid" ||
effectiveStatus == "incomplete_expired"
) {
try { try {
// WARNING: const invalidateResponse = await fetch(
// this invalidates ALL OF THE ENTERPRISE LICENSES for this orgId
await fetch(
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/invalidate`, `${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/invalidate`,
{ {
method: "POST", method: "POST",
@@ -320,16 +335,95 @@ export async function handleSubscriptionUpdated(
"Content-Type": "application/json" "Content-Type": "application/json"
}, },
body: JSON.stringify({ body: JSON.stringify({
orgId: customer.orgId orgId: customer.orgId,
licenseKeyId: parseInt(
subscription.metadata.licenseKeyId
)
}) })
} }
); );
if (!invalidateResponse.ok) {
logger.error(
`Fossorial API returned ${invalidateResponse.status} when invalidating license for orgId ${customer.orgId} and subscription ID ${subscription.id}: ${await invalidateResponse.text()}`
);
}
} catch (error) { } catch (error) {
logger.error( logger.error(
`Error notifying Fossorial API of license subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}:`, `Error notifying Fossorial API of license subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}:`,
error error
); );
} }
} else if (effectiveStatus === "active" && previousAttributes) {
// Detect a successful renewal: the billing period rolled
// forward (the invoice was paid and the new period began
// right where the previous one ended).
const currentItem = fullSubscription.items.data[0];
const prevItems = previousAttributes.items?.data;
const prevItem = Array.isArray(prevItems)
? prevItems.find(
(pi: any) => pi.id === currentItem?.id
)
: undefined;
const renewed =
currentItem &&
prevItem?.current_period_end &&
currentItem.current_period_start ===
prevItem.current_period_end &&
currentItem.current_period_start >
prevItem.current_period_start;
if (renewed) {
const licenseKeyId =
subscription.metadata.licenseKeyId;
if (!licenseKeyId) {
logger.error(
`No licenseKeyId in metadata for subscription ${subscription.id}, cannot extend license.`
);
} else {
// Grace period of 5 days added on top of the new
// billing period end (usually ~1 year out)
const expiresAt =
currentItem.current_period_end +
5 * 24 * 60 * 60;
try {
const extendResponse = await fetch(
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/extend`,
{
method: "POST",
headers: {
"api-key":
privateConfig.getRawPrivateConfig()
.server.fossorial_api_key!,
"Content-Type": "application/json"
},
body: JSON.stringify({
licenseId: parseInt(licenseKeyId),
expiresAt: expiresAt
})
}
);
if (!extendResponse.ok) {
logger.error(
`Fossorial API returned ${extendResponse.status} when extending license ${licenseKeyId} for subscription ${subscription.id}: ${await extendResponse.text()}`
);
} else {
logger.info(
`Extended license ${licenseKeyId} for subscription ${subscription.id} to expire at ${expiresAt}.`
);
}
} catch (error) {
logger.error(
`Error notifying Fossorial API of license renewal for subscription ${subscription.id}:`,
error
);
}
}
}
} }
} }
} }
@@ -96,6 +96,8 @@ export async function generateNewEnterpriseLicense(
); );
} }
const licenseKeyValue = apiResponse?.data?.licenseKey?.licenseKey;
// check if we already have a customer for this org // check if we already have a customer for this org
const [customer] = await db const [customer] = await db
.select() .select()
@@ -129,6 +131,16 @@ export async function generateNewEnterpriseLicense(
], // Start with the standard feature set that matches the free limits ], // Start with the standard feature set that matches the free limits
customer: customer.customerId, customer: customer.customerId,
mode: "subscription", mode: "subscription",
subscription_data: {
description: licenseKeyValue
? `License ${licenseKeyValue}`
: `License key ID ${keyId}`,
metadata: {
licenseKeyId: keyId.toString(),
licenseKey: licenseKeyValue ?? "",
tier: licenseData.tier
}
},
allow_promotion_codes: true, allow_promotion_codes: true,
success_url: `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/license?success=true&session_id={CHECKOUT_SESSION_ID}`, success_url: `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/license?success=true&session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/license?canceled=true` cancel_url: `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/license?canceled=true`
+24
View File
@@ -0,0 +1,24 @@
import { sendToClient } from "#dynamic/routers/ws";
// Error codes for registration failures
export const NewtErrorCodes = {
NO_AVAILABLE_SUBNET: {
code: "NO_AVAILABLE_SUBNET",
message:
"No available subnet could be assigned to this site on its exit node. Please contact your administrator to increase the available address space for this exit node's subnet."
}
} as const;
// Helper function to send registration error
export async function sendNewtError(
error: (typeof NewtErrorCodes)[keyof typeof NewtErrorCodes],
newtId: string
) {
sendToClient(newtId, {
type: "newt/error",
data: {
code: error.code,
message: error.message
}
});
}
@@ -14,6 +14,7 @@ import { getUniqueSubnetForExitNode } from "@server/lib/exitNodes";
import { fetchContainers } from "./dockerSocket"; import { fetchContainers } from "./dockerSocket";
import { buildTargetConfigurationForNewtClient } from "./buildConfiguration"; import { buildTargetConfigurationForNewtClient } from "./buildConfiguration";
import { canCompress } from "@server/lib/clientVersionChecks"; import { canCompress } from "@server/lib/clientVersionChecks";
import { NewtErrorCodes, sendNewtError } from "./error";
export const handleNewtRegisterMessage: MessageHandler = async (context) => { export const handleNewtRegisterMessage: MessageHandler = async (context) => {
const { message, client, sendToClient } = context; const { message, client, sendToClient } = context;
@@ -116,6 +117,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
logger.error( logger.error(
`No available subnets found for the new exit node id ${exitNodeId} and site id ${siteId}` `No available subnets found for the new exit node id ${exitNodeId} and site id ${siteId}`
); );
sendNewtError(NewtErrorCodes.NO_AVAILABLE_SUBNET, newt.newtId);
return; return;
} }
+5
View File
@@ -94,6 +94,11 @@ export const OlmErrorCodes = {
HOLEPUNCH_MISSING: { HOLEPUNCH_MISSING: {
code: "HOLEPUNCH_MISSING", code: "HOLEPUNCH_MISSING",
message: `Unable to coordinate client P2P connection. Please ensure your client can reach the server on UDP port ${udpPort} and try registering again.` message: `Unable to coordinate client P2P connection. Please ensure your client can reach the server on UDP port ${udpPort} and try registering again.`
},
NO_AVAILABLE_SUBNET: {
code: "NO_AVAILABLE_SUBNET",
message:
"No available subnet could be assigned to this client on the selected exit node. Please contact your administrator to increase the available address space for this exit node's subnet."
} }
} as const; } as const;
@@ -347,6 +347,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
`[handleOlmRegisterMessage] No available subnets found for exit node id ${exitNodeId} and client id ${client.clientId}`, `[handleOlmRegisterMessage] No available subnets found for exit node id ${exitNodeId} and client id ${client.clientId}`,
{ orgId: client.orgId, clientId: client.clientId } { orgId: client.orgId, clientId: client.clientId }
); );
sendOlmError(OlmErrorCodes.NO_AVAILABLE_SUBNET, olm.olmId);
return; return;
} }
@@ -749,12 +749,6 @@ export default function BillingPage() {
return 0; return 0;
}; };
// Get license key count
const getLicenseKeyCount = (): number => {
if (!licenseSubscription?.items) return 0;
return licenseSubscription.items.length;
};
// Check if downgrading to a tier would violate current usage limits // Check if downgrading to a tier would violate current usage limits
const checkLimitViolations = ( const checkLimitViolations = (
targetTier: Tier | "basic" targetTier: Tier | "basic"
@@ -1545,7 +1539,7 @@ export default function BillingPage() {
</SettingsSection> </SettingsSection>
{/* Paid License Keys Section */} {/* Paid License Keys Section */}
{(licenseSubscription || getLicenseKeyCount() > 0) && ( {licenseSubscription && (
<SettingsSection> <SettingsSection>
<SettingsSectionHeader> <SettingsSectionHeader>
<SettingsSectionTitle> <SettingsSectionTitle>
@@ -1561,22 +1555,6 @@ export default function BillingPage() {
<SettingsFormGrid> <SettingsFormGrid>
<SettingsFormCell span="full"> <SettingsFormCell span="full">
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4 border rounded-lg p-4"> <div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4 border rounded-lg p-4">
<div>
<div className="text-sm text-muted-foreground mb-1">
{t("billingCurrentKeys") ||
"Current Keys"}
</div>
<div className="flex items-baseline gap-2">
<span className="text-3xl font-semibold">
{getLicenseKeyCount()}
</span>
<span className="text-lg">
{getLicenseKeyCount() === 1
? "key"
: "keys"}
</span>
</div>
</div>
<Button <Button
variant="outline" variant="outline"
onClick={handleModifySubscription} onClick={handleModifySubscription}
@@ -1,4 +1,5 @@
import GenerateLicenseKeysTable from "@app/components/GenerateLicenseKeysTable"; import GenerateLicenseKeysTable from "@app/components/GenerateLicenseKeysTable";
import LicenseBillingBanner from "@app/components/LicenseBillingBanner";
import { internal } from "@app/lib/api"; import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies"; import { authCookieHeader } from "@app/lib/api/cookies";
import { ListGeneratedLicenseKeysResponse } from "@server/routers/generatedLicense/types"; import { ListGeneratedLicenseKeysResponse } from "@server/routers/generatedLicense/types";
@@ -26,5 +27,16 @@ export default async function Page({ params }: Props) {
licenseKeys = data.data.data; licenseKeys = data.data.data;
} catch {} } catch {}
return <GenerateLicenseKeysTable licenseKeys={licenseKeys} orgId={orgId} />; const hasNonPersonalLicenseKey = licenseKeys.some(
(key) => key.tier !== "personal"
);
return (
<>
{hasNonPersonalLicenseKey && (
<LicenseBillingBanner orgId={orgId} />
)}
<GenerateLicenseKeysTable licenseKeys={licenseKeys} orgId={orgId} />
</>
);
} }
+39
View File
@@ -0,0 +1,39 @@
"use client";
import { Globe, CreditCard, ArrowRight } from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { Button } from "@app/components/ui/button";
import DismissableBanner from "./DismissableBanner";
type LicenseBillingBannerProps = {
orgId: string;
};
export const LicenseBillingBanner = ({ orgId }: LicenseBillingBannerProps) => {
const t = useTranslations();
return (
<DismissableBanner
storageKey="license-billing-banner-dismissed"
version={1}
title={t("licenseBillingBannerTitle")}
titleIcon={<Globe className="w-5 h-5 text-primary" />}
description={t("licenseBillingBannerDescription")}
>
<Link href={`/${orgId}/settings/billing`}>
<Button
variant="outline"
size="sm"
className="gap-2 hover:bg-primary/10 hover:border-primary/50 transition-colors"
>
<CreditCard className="w-4 h-4" />
{t("licenseBillingBannerButton")}
<ArrowRight className="w-4 h-4" />
</Button>
</Link>
</DismissableBanner>
);
};
export default LicenseBillingBanner;
+1 -1
View File
@@ -330,7 +330,7 @@ export default function NewPricingLicenseForm({
cols={2} cols={2}
/> />
<a <a
href="https://pangolin.net/pricing" href="https://pangolin.net/pricing#Self-Hosted"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-sm text-primary hover:underline" className="text-sm text-primary hover:underline"
+12 -7
View File
@@ -63,6 +63,12 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
) : null; ) : null;
if (site.type === "newt") { if (site.type === "newt") {
// agent and agentVersion were added after newtVersion, so a
// site still running an older Newt reports only newtVersion.
// Without these fallbacks the badge renders with no label and
// no version at all.
const agentLabel = site.agent == "cli" ? "Pangolin CLI" : "Newt";
const agentVersion = site.agentVersion ?? site.newtVersion;
return ( return (
<Alert> <Alert>
<AlertDescription> <AlertDescription>
@@ -79,13 +85,12 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
<InfoSection> <InfoSection>
<InfoSectionTitle>{t("agent")}</InfoSectionTitle> <InfoSectionTitle>{t("agent")}</InfoSectionTitle>
<InfoSectionContent> <InfoSectionContent>
{site.agent == "newt" ? "Newt" : null} <div className="flex items-center space-x-1">
{site.agent == "cli" <span>{agentLabel}</span>
? "Pangolin CLI" {agentVersion && (
: null}{" "} <span>v{agentVersion}</span>
{site.agentVersion )}
? `v${site.agentVersion}` </div>
: "-"}
</InfoSectionContent> </InfoSectionContent>
</InfoSection> </InfoSection>
{endpointSection} {endpointSection}
+4 -2
View File
@@ -373,7 +373,7 @@ export default function SitesTable({
accessorKey: "type", accessorKey: "type",
friendlyName: t("agent"), friendlyName: t("agent"),
header: () => { header: () => {
return <span className="p-3">{t("type")}</span>; return <span className="p-3">{t("agent")}</span>;
}, },
cell: ({ row }) => { cell: ({ row }) => {
const originalRow = row.original; const originalRow = row.original;
@@ -395,7 +395,9 @@ export default function SitesTable({
// Without these fallbacks the badge renders with no label and // Without these fallbacks the badge renders with no label and
// no version at all. // no version at all.
const agentLabel = const agentLabel =
originalRow.agent == "cli" ? "Pangolin CLI" : "Newt"; originalRow.agent == "cli"
? "Pangolin CLI"
: "Newt";
const agentVersion = const agentVersion =
originalRow.agentVersion ?? originalRow.newtVersion; originalRow.agentVersion ?? originalRow.newtVersion;
return ( return (