Compare commits

...

6 Commits

Author SHA1 Message Date
Owen
74b3b283f7 Fix #2848 2026-04-13 21:30:19 -07:00
Owen
03d95874e6 Proxy targets returns an array 2026-04-13 20:44:35 -07:00
Owen
5d51af4330 Rename script 2026-04-13 16:22:53 -07:00
Owen
93998f9fd5 Fix ts issue 2026-04-13 12:27:29 -07:00
Owen
c554e69514 Fill the width 2026-04-13 12:11:15 -07:00
Owen
a6e10e55cc Handle grandfather on the front end 2026-04-13 12:08:30 -07:00
13 changed files with 177 additions and 243 deletions

BIN
config/db/db.sqlite-journal Normal file

Binary file not shown.

View File

@@ -591,7 +591,7 @@ export function generateSubnetProxyTargetV2(
pubKey: string | null; pubKey: string | null;
subnet: string | null; subnet: string | null;
}[] }[]
): SubnetProxyTargetV2 | undefined { ): SubnetProxyTargetV2[] | undefined {
if (clients.length === 0) { if (clients.length === 0) {
logger.debug( logger.debug(
`No clients have access to site resource ${siteResource.siteResourceId}, skipping target generation.` `No clients have access to site resource ${siteResource.siteResourceId}, skipping target generation.`
@@ -599,7 +599,7 @@ export function generateSubnetProxyTargetV2(
return; return;
} }
let target: SubnetProxyTargetV2 | null = null; let targets: SubnetProxyTargetV2[] = [];
const portRange = [ const portRange = [
...parsePortRangeString(siteResource.tcpPortRangeString, "tcp"), ...parsePortRangeString(siteResource.tcpPortRangeString, "tcp"),
@@ -614,40 +614,41 @@ export function generateSubnetProxyTargetV2(
if (ipSchema.safeParse(destination).success) { if (ipSchema.safeParse(destination).success) {
destination = `${destination}/32`; destination = `${destination}/32`;
target = { targets.push({
sourcePrefixes: [], sourcePrefixes: [],
destPrefix: destination, destPrefix: destination,
portRange, portRange,
disableIcmp, disableIcmp,
resourceId: siteResource.siteResourceId, resourceId: siteResource.siteResourceId
}; });
} }
if (siteResource.alias && siteResource.aliasAddress) { if (siteResource.alias && siteResource.aliasAddress) {
// also push a match for the alias address // also push a match for the alias address
target = { targets.push({
sourcePrefixes: [], sourcePrefixes: [],
destPrefix: `${siteResource.aliasAddress}/32`, destPrefix: `${siteResource.aliasAddress}/32`,
rewriteTo: destination, rewriteTo: destination,
portRange, portRange,
disableIcmp, disableIcmp,
resourceId: siteResource.siteResourceId, resourceId: siteResource.siteResourceId
}; });
} }
} else if (siteResource.mode == "cidr") { } else if (siteResource.mode == "cidr") {
target = { targets.push({
sourcePrefixes: [], sourcePrefixes: [],
destPrefix: siteResource.destination, destPrefix: siteResource.destination,
portRange, portRange,
disableIcmp, disableIcmp,
resourceId: siteResource.siteResourceId, resourceId: siteResource.siteResourceId
}; });
} }
if (!target) { if (targets.length == 0) {
return; return;
} }
for (const target of targets) {
for (const clientSite of clients) { for (const clientSite of clients) {
if (!clientSite.subnet) { if (!clientSite.subnet) {
logger.debug( logger.debug(
@@ -661,16 +662,16 @@ export function generateSubnetProxyTargetV2(
// add client prefix to source prefixes // add client prefix to source prefixes
target.sourcePrefixes.push(clientPrefix); target.sourcePrefixes.push(clientPrefix);
} }
}
// print a nice representation of the targets // print a nice representation of the targets
// logger.debug( // logger.debug(
// `Generated subnet proxy targets for: ${JSON.stringify(targets, null, 2)}` // `Generated subnet proxy targets for: ${JSON.stringify(targets, null, 2)}`
// ); // );
return target; return targets;
} }
/** /**
* Converts a SubnetProxyTargetV2 to an array of SubnetProxyTarget (v1) * Converts a SubnetProxyTargetV2 to an array of SubnetProxyTarget (v1)
* by expanding each source prefix into its own target entry. * by expanding each source prefix into its own target entry.
@@ -697,7 +698,6 @@ export function generateSubnetProxyTargetV2(
); );
} }
// Custom schema for validating port range strings // Custom schema for validating port range strings
// Format: "80,443,8000-9000" or "*" for all ports, or empty string // Format: "80,443,8000-9000" or "*" for all ports, or empty string
export const portRangeStringSchema = z export const portRangeStringSchema = z

View File

@@ -661,16 +661,16 @@ async function handleSubnetProxyTargetUpdates(
); );
if (addedClients.length > 0) { if (addedClients.length > 0) {
const targetToAdd = generateSubnetProxyTargetV2( const targetsToAdd = generateSubnetProxyTargetV2(
siteResource, siteResource,
addedClients addedClients
); );
if (targetToAdd) { if (targetsToAdd) {
proxyJobs.push( proxyJobs.push(
addSubnetProxyTargets( addSubnetProxyTargets(
newt.newtId, newt.newtId,
[targetToAdd], targetsToAdd,
newt.version newt.version
) )
); );
@@ -698,16 +698,16 @@ async function handleSubnetProxyTargetUpdates(
); );
if (removedClients.length > 0) { if (removedClients.length > 0) {
const targetToRemove = generateSubnetProxyTargetV2( const targetsToRemove = generateSubnetProxyTargetV2(
siteResource, siteResource,
removedClients removedClients
); );
if (targetToRemove) { if (targetsToRemove) {
proxyJobs.push( proxyJobs.push(
removeSubnetProxyTargets( removeSubnetProxyTargets(
newt.newtId, newt.newtId,
[targetToRemove], targetsToRemove,
newt.version newt.version
) )
); );
@@ -1164,7 +1164,7 @@ async function handleMessagesForClientResources(
} }
for (const resource of resources) { for (const resource of resources) {
const target = generateSubnetProxyTargetV2(resource, [ const targets = generateSubnetProxyTargetV2(resource, [
{ {
clientId: client.clientId, clientId: client.clientId,
pubKey: client.pubKey, pubKey: client.pubKey,
@@ -1172,11 +1172,11 @@ async function handleMessagesForClientResources(
} }
]); ]);
if (target) { if (targets) {
proxyJobs.push( proxyJobs.push(
addSubnetProxyTargets( addSubnetProxyTargets(
newt.newtId, newt.newtId,
[target], targets,
newt.version newt.version
) )
); );
@@ -1241,7 +1241,7 @@ async function handleMessagesForClientResources(
} }
for (const resource of resources) { for (const resource of resources) {
const target = generateSubnetProxyTargetV2(resource, [ const targets = generateSubnetProxyTargetV2(resource, [
{ {
clientId: client.clientId, clientId: client.clientId,
pubKey: client.pubKey, pubKey: client.pubKey,
@@ -1249,11 +1249,11 @@ async function handleMessagesForClientResources(
} }
]); ]);
if (target) { if (targets) {
proxyJobs.push( proxyJobs.push(
removeSubnetProxyTargets( removeSubnetProxyTargets(
newt.newtId, newt.newtId,
[target], targets,
newt.version newt.version
) )
); );

View File

@@ -29,65 +29,9 @@ import {
} from "drizzle-orm"; } from "drizzle-orm";
import { NextFunction, Request, Response } from "express"; import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import NodeCache from "node-cache";
import semver from "semver";
import { z } from "zod"; import { z } from "zod";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
const olmVersionCache = new NodeCache({ stdTTL: 3600 });
async function getLatestOlmVersion(): Promise<string | null> {
try {
const cachedVersion = olmVersionCache.get<string>("latestOlmVersion");
if (cachedVersion) {
return cachedVersion;
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 1500);
const response = await fetch(
"https://api.github.com/repos/fosrl/olm/tags",
{
signal: controller.signal
}
);
clearTimeout(timeoutId);
if (!response.ok) {
logger.warn(
`Failed to fetch latest Olm version from GitHub: ${response.status} ${response.statusText}`
);
return null;
}
let tags = await response.json();
if (!Array.isArray(tags) || tags.length === 0) {
logger.warn("No tags found for Olm repository");
return null;
}
tags = tags.filter((version) => !version.name.includes("rc"));
const latestVersion = tags[0].name;
olmVersionCache.set("latestOlmVersion", latestVersion, 3600);
return latestVersion;
} catch (error: any) {
if (error.name === "AbortError") {
logger.warn("Request to fetch latest Olm version timed out (1.5s)");
} else if (error.cause?.code === "UND_ERR_CONNECT_TIMEOUT") {
logger.warn("Connection timeout while fetching latest Olm version");
} else {
logger.warn(
"Error fetching latest Olm version:",
error.message || error
);
}
return null;
}
}
const listClientsParamsSchema = z.strictObject({ const listClientsParamsSchema = z.strictObject({
orgId: z.string() orgId: z.string()
}); });
@@ -413,44 +357,45 @@ export async function listClients(
}; };
}); });
const latestOlVersionPromise = getLatestOlmVersion(); // REMOVING THIS BECAUSE WE HAVE DIFFERENT TYPES OF CLIENTS NOW
// const latestOlmVersionPromise = getLatestOlmVersion();
const olmsWithUpdates: OlmWithUpdateAvailable[] = clientsWithSites.map( // const olmsWithUpdates: OlmWithUpdateAvailable[] = clientsWithSites.map(
(client) => { // (client) => {
const OlmWithUpdate: OlmWithUpdateAvailable = { ...client }; // const OlmWithUpdate: OlmWithUpdateAvailable = { ...client };
// Initially set to false, will be updated if version check succeeds // // Initially set to false, will be updated if version check succeeds
OlmWithUpdate.olmUpdateAvailable = false; // OlmWithUpdate.olmUpdateAvailable = false;
return OlmWithUpdate; // return OlmWithUpdate;
} // }
); // );
// Try to get the latest version, but don't block if it fails // Try to get the latest version, but don't block if it fails
try { // try {
const latestOlVersion = await latestOlVersionPromise; // const latestOlmVersion = await latestOlVersionPromise;
if (latestOlVersion) { // if (latestOlVersion) {
olmsWithUpdates.forEach((client) => { // olmsWithUpdates.forEach((client) => {
try { // try {
client.olmUpdateAvailable = semver.lt( // client.olmUpdateAvailable = semver.lt(
client.olmVersion ? client.olmVersion : "", // client.olmVersion ? client.olmVersion : "",
latestOlVersion // latestOlVersion
); // );
} catch (error) { // } catch (error) {
client.olmUpdateAvailable = false; // client.olmUpdateAvailable = false;
} // }
}); // });
} // }
} catch (error) { // } catch (error) {
// Log the error but don't let it block the response // // Log the error but don't let it block the response
logger.warn( // logger.warn(
"Failed to check for OLM updates, continuing without update info:", // "Failed to check for OLM updates, continuing without update info:",
error // error
); // );
} // }
return response<ListClientsResponse>(res, { return response<ListClientsResponse>(res, {
data: { data: {
clients: olmsWithUpdates, clients: clientsWithSites,
pagination: { pagination: {
total: totalCount, total: totalCount,
page, page,

View File

@@ -30,65 +30,10 @@ import {
} from "drizzle-orm"; } from "drizzle-orm";
import { NextFunction, Request, Response } from "express"; import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import NodeCache from "node-cache";
import semver from "semver"; import semver from "semver";
import { z } from "zod"; import { z } from "zod";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
const olmVersionCache = new NodeCache({ stdTTL: 3600 });
async function getLatestOlmVersion(): Promise<string | null> {
try {
const cachedVersion = olmVersionCache.get<string>("latestOlmVersion");
if (cachedVersion) {
return cachedVersion;
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 1500);
const response = await fetch(
"https://api.github.com/repos/fosrl/olm/tags",
{
signal: controller.signal
}
);
clearTimeout(timeoutId);
if (!response.ok) {
logger.warn(
`Failed to fetch latest Olm version from GitHub: ${response.status} ${response.statusText}`
);
return null;
}
let tags = await response.json();
if (!Array.isArray(tags) || tags.length === 0) {
logger.warn("No tags found for Olm repository");
return null;
}
tags = tags.filter((version) => !version.name.includes("rc"));
const latestVersion = tags[0].name;
olmVersionCache.set("latestOlmVersion", latestVersion, 3600);
return latestVersion;
} catch (error: any) {
if (error.name === "AbortError") {
logger.warn("Request to fetch latest Olm version timed out (1.5s)");
} else if (error.cause?.code === "UND_ERR_CONNECT_TIMEOUT") {
logger.warn("Connection timeout while fetching latest Olm version");
} else {
logger.warn(
"Error fetching latest Olm version:",
error.message || error
);
}
return null;
}
}
const listUserDevicesParamsSchema = z.strictObject({ const listUserDevicesParamsSchema = z.strictObject({
orgId: z.string() orgId: z.string()
}); });
@@ -453,29 +398,30 @@ export async function listUserDevices(
} }
); );
// Try to get the latest version, but don't block if it fails // REMOVING THIS BECAUSE WE HAVE DIFFERENT TYPES OF CLIENTS NOW
try { // // Try to get the latest version, but don't block if it fails
const latestOlmVersion = await getLatestOlmVersion(); // try {
// const latestOlmVersion = await getLatestOlmVersion();
if (latestOlmVersion) { // if (latestOlmVersion) {
olmsWithUpdates.forEach((client) => { // olmsWithUpdates.forEach((client) => {
try { // try {
client.olmUpdateAvailable = semver.lt( // client.olmUpdateAvailable = semver.lt(
client.olmVersion ? client.olmVersion : "", // client.olmVersion ? client.olmVersion : "",
latestOlmVersion // latestOlmVersion
); // );
} catch (error) { // } catch (error) {
client.olmUpdateAvailable = false; // client.olmUpdateAvailable = false;
} // }
}); // });
} // }
} catch (error) { // } catch (error) {
// Log the error but don't let it block the response // // Log the error but don't let it block the response
logger.warn( // logger.warn(
"Failed to check for OLM updates, continuing without update info:", // "Failed to check for OLM updates, continuing without update info:",
error // error
); // );
} // }
return response<ListUserDevicesResponse>(res, { return response<ListUserDevicesResponse>(res, {
data: { data: {

View File

@@ -168,13 +168,13 @@ export async function buildClientConfigurationForNewtClient(
) )
); );
const resourceTarget = generateSubnetProxyTargetV2( const resourceTargets = generateSubnetProxyTargetV2(
resource, resource,
resourceClients resourceClients
); );
if (resourceTarget) { if (resourceTargets) {
targetsToSend.push(resourceTarget); targetsToSend.push(...resourceTargets);
} }
} }

View File

@@ -21,6 +21,11 @@ import semver from "semver";
import { z } from "zod"; import { z } from "zod";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
// Stale-while-revalidate: keeps the last successfully fetched version so that
// a transient network failure / timeout does not flip every site back to
// newtUpdateAvailable: false.
let staleNewtVersion: string | null = null;
async function getLatestNewtVersion(): Promise<string | null> { async function getLatestNewtVersion(): Promise<string | null> {
try { try {
const cachedVersion = await cache.get<string>("latestNewtVersion"); const cachedVersion = await cache.get<string>("latestNewtVersion");
@@ -29,7 +34,7 @@ async function getLatestNewtVersion(): Promise<string | null> {
} }
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 1500); // Reduced timeout to 1.5 seconds const timeoutId = setTimeout(() => controller.abort(), 1500);
const response = await fetch( const response = await fetch(
"https://api.github.com/repos/fosrl/newt/tags", "https://api.github.com/repos/fosrl/newt/tags",
@@ -44,18 +49,46 @@ async function getLatestNewtVersion(): Promise<string | null> {
logger.warn( logger.warn(
`Failed to fetch latest Newt version from GitHub: ${response.status} ${response.statusText}` `Failed to fetch latest Newt version from GitHub: ${response.status} ${response.statusText}`
); );
return null; return staleNewtVersion;
} }
let tags = await response.json(); let tags = await response.json();
if (!Array.isArray(tags) || tags.length === 0) { if (!Array.isArray(tags) || tags.length === 0) {
logger.warn("No tags found for Newt repository"); logger.warn("No tags found for Newt repository");
return null; return staleNewtVersion;
} }
tags = tags.filter((version) => !version.name.includes("rc"));
// Remove release-candidates, then sort descending by semver so that
// duplicate tags (e.g. "1.10.3" and "v1.10.3") and any ordering quirks
// from the GitHub API do not cause an older tag to be selected.
tags = tags.filter((tag: any) => !tag.name.includes("rc"));
tags.sort((a: any, b: any) => {
const va = semver.coerce(a.name);
const vb = semver.coerce(b.name);
if (!va && !vb) return 0;
if (!va) return 1;
if (!vb) return -1;
return semver.rcompare(va, vb);
});
// Deduplicate: keep only the first (highest) entry per normalised version
const seen = new Set<string>();
tags = tags.filter((tag: any) => {
const normalised = semver.coerce(tag.name)?.version;
if (!normalised || seen.has(normalised)) return false;
seen.add(normalised);
return true;
});
if (tags.length === 0) {
logger.warn("No valid semver tags found for Newt repository");
return staleNewtVersion;
}
const latestVersion = tags[0].name; const latestVersion = tags[0].name;
await cache.set("latestNewtVersion", latestVersion, 3600); staleNewtVersion = latestVersion;
await cache.set("cache:latestNewtVersion", latestVersion, 3600);
return latestVersion; return latestVersion;
} catch (error: any) { } catch (error: any) {
@@ -73,7 +106,7 @@ async function getLatestNewtVersion(): Promise<string | null> {
error.message || error error.message || error
); );
} }
return null; return staleNewtVersion;
} }
} }

View File

@@ -618,11 +618,11 @@ export async function handleMessagingForUpdatedSiteResource(
// Only update targets on newt if destination changed // Only update targets on newt if destination changed
if (destinationChanged || portRangesChanged) { if (destinationChanged || portRangesChanged) {
const oldTarget = generateSubnetProxyTargetV2( const oldTargets = generateSubnetProxyTargetV2(
existingSiteResource, existingSiteResource,
mergedAllClients mergedAllClients
); );
const newTarget = generateSubnetProxyTargetV2( const newTargets = generateSubnetProxyTargetV2(
updatedSiteResource, updatedSiteResource,
mergedAllClients mergedAllClients
); );
@@ -630,8 +630,8 @@ export async function handleMessagingForUpdatedSiteResource(
await updateTargets( await updateTargets(
newt.newtId, newt.newtId,
{ {
oldTargets: oldTarget ? [oldTarget] : [], oldTargets: oldTargets ? oldTargets : [],
newTargets: newTarget ? [newTarget] : [] newTargets: newTargets ? newTargets : []
}, },
newt.version newt.version
); );

View File

@@ -21,7 +21,8 @@ async function queryUser(userId: string) {
serverAdmin: users.serverAdmin, serverAdmin: users.serverAdmin,
idpName: idp.name, idpName: idp.name,
idpId: users.idpId, idpId: users.idpId,
locale: users.locale locale: users.locale,
dateCreated: users.dateCreated
}) })
.from(users) .from(users)
.leftJoin(idp, eq(users.idpId, idp.idpId)) .leftJoin(idp, eq(users.idpId, idp.idpId))

View File

@@ -64,7 +64,8 @@ export async function myDevice(
serverAdmin: users.serverAdmin, serverAdmin: users.serverAdmin,
idpName: idp.name, idpName: idp.name,
idpId: users.idpId, idpId: users.idpId,
locale: users.locale locale: users.locale,
dateCreated: users.dateCreated
}) })
.from(users) .from(users)
.leftJoin(idp, eq(users.idpId, idp.idpId)) .leftJoin(idp, eq(users.idpId, idp.idpId))

View File

@@ -491,6 +491,10 @@ export default function BillingPage() {
const currentPlanId = getCurrentPlanId(); const currentPlanId = getCurrentPlanId();
const visiblePlanOptions = planOptions.filter(
(plan) => plan.id !== "home" || currentPlanId === "home"
);
// Check if subscription is in a problematic state that requires attention // Check if subscription is in a problematic state that requires attention
const hasProblematicSubscription = (): boolean => { const hasProblematicSubscription = (): boolean => {
if (!tierSubscription?.subscription) return false; if (!tierSubscription?.subscription) return false;
@@ -803,8 +807,8 @@ export default function BillingPage() {
</SettingsSectionHeader> </SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
{/* Plan Cards Grid */} {/* Plan Cards Grid */}
<div className="grid grid-cols-1 md:grid-cols-5 gap-4"> <div className={cn("grid grid-cols-1 gap-4", visiblePlanOptions.length === 5 ? "md:grid-cols-5" : "md:grid-cols-4")}>
{planOptions.filter((plan) => plan.id !== "home" || currentPlanId === "home").map((plan) => { {visiblePlanOptions.map((plan) => {
const isCurrentPlan = plan.id === currentPlanId; const isCurrentPlan = plan.id === currentPlanId;
const planAction = getPlanAction(plan); const planAction = getPlanAction(plan);

View File

@@ -49,6 +49,7 @@ import { usePaidStatus } from "@/hooks/usePaidStatus";
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix"; import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
import { toUnicode } from "punycode"; import { toUnicode } from "punycode";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useUserContext } from "@app/hooks/useUserContext";
type AvailableOption = { type AvailableOption = {
domainNamespaceId: string; domainNamespaceId: string;
@@ -97,10 +98,16 @@ export default function DomainPicker({
warnOnProvidedDomain = false warnOnProvidedDomain = false
}: DomainPickerProps) { }: DomainPickerProps) {
const { env } = useEnvContext(); const { env } = useEnvContext();
const { user } = useUserContext();
const api = createApiClient({ env }); const api = createApiClient({ env });
const t = useTranslations(); const t = useTranslations();
const { hasSaasSubscription } = usePaidStatus(); const { hasSaasSubscription } = usePaidStatus();
const requiresPaywall =
build === "saas" &&
!hasSaasSubscription(tierMatrix[TierFeature.DomainNamespaces]) &&
new Date(user.dateCreated) > new Date("2026-04-13");
const { data = [], isLoading: loadingDomains } = useQuery( const { data = [], isLoading: loadingDomains } = useQuery(
orgQueries.domains({ orgId }) orgQueries.domains({ orgId })
); );
@@ -656,6 +663,7 @@ export default function DomainPicker({
}) })
} }
className="mx-2 rounded-md" className="mx-2 rounded-md"
disabled={requiresPaywall}
> >
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10 mr-3"> <div className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10 mr-3">
<Zap className="h-4 w-4 text-primary" /> <Zap className="h-4 w-4 text-primary" />
@@ -696,11 +704,7 @@ export default function DomainPicker({
</div> </div>
</div> </div>
{build === "saas" && {requiresPaywall && !hideFreeDomain && (
!hasSaasSubscription(
tierMatrix[TierFeature.DomainNamespaces]
) &&
!hideFreeDomain && (
<Card className="mt-3 border-black-500/30 bg-linear-to-br from-black-500/10 via-background to-background overflow-hidden"> <Card className="mt-3 border-black-500/30 bg-linear-to-br from-black-500/10 via-background to-background overflow-hidden">
<CardContent className="py-3 px-4"> <CardContent className="py-3 px-4">
<div className="flex items-center gap-2.5 text-sm text-muted-foreground"> <div className="flex items-center gap-2.5 text-sm text-muted-foreground">