Compare commits

..

45 Commits

Author SHA1 Message Date
Owen Schwartz
7d13ed79b2 Merge pull request #2862 from fosrl/dev
1.17.3
2026-04-14 20:33:04 -07:00
Owen Schwartz
c1f65c802c Merge pull request #2861 from fosrl/crowdin_dev
New Crowdin updates
2026-04-14 20:32:03 -07:00
Owen Schwartz
bcc429221e New translations en-us.json (Spanish) 2026-04-14 20:30:58 -07:00
Owen Schwartz
bd73609b9e New translations en-us.json (Norwegian Bokmal) 2026-04-14 20:30:56 -07:00
Owen Schwartz
2dbb21a7f2 New translations en-us.json (Chinese Simplified) 2026-04-14 20:30:55 -07:00
Owen Schwartz
fe68533ff2 New translations en-us.json (Turkish) 2026-04-14 20:30:53 -07:00
Owen Schwartz
01a40daf38 New translations en-us.json (Russian) 2026-04-14 20:30:51 -07:00
Owen Schwartz
097744275f New translations en-us.json (Portuguese) 2026-04-14 20:30:49 -07:00
Owen Schwartz
e481a4d847 New translations en-us.json (Polish) 2026-04-14 20:30:48 -07:00
Owen Schwartz
95c6bb4de6 New translations en-us.json (Dutch) 2026-04-14 20:30:46 -07:00
Owen Schwartz
18e194e152 New translations en-us.json (Korean) 2026-04-14 20:30:44 -07:00
Owen Schwartz
b2f391307b New translations en-us.json (Italian) 2026-04-14 20:30:43 -07:00
Owen Schwartz
a4da3c7ba2 New translations en-us.json (German) 2026-04-14 20:30:41 -07:00
Owen Schwartz
af3abef3bf New translations en-us.json (Czech) 2026-04-14 20:30:39 -07:00
Owen Schwartz
f7633a43ce New translations en-us.json (Bulgarian) 2026-04-14 20:30:38 -07:00
Owen Schwartz
ffd345f044 New translations en-us.json (French) 2026-04-14 20:30:36 -07:00
Owen
ae36d3228f Remove journal 2026-04-14 20:23:56 -07:00
Owen
1c78a6b483 Adjust self serve 2026-04-14 20:21:34 -07:00
Owen Schwartz
b6c6590aad New translations en-us.json (Norwegian Bokmal) 2026-04-14 19:48:12 -07:00
Owen Schwartz
5a792e9913 New translations en-us.json (Chinese Simplified) 2026-04-14 19:48:11 -07:00
Owen Schwartz
a2f822889d New translations en-us.json (Turkish) 2026-04-14 19:48:09 -07:00
Owen Schwartz
83ba463a34 New translations en-us.json (Russian) 2026-04-14 19:48:07 -07:00
Owen Schwartz
a909c5cbe0 New translations en-us.json (Portuguese) 2026-04-14 19:48:06 -07:00
Owen Schwartz
d615f34f94 New translations en-us.json (Polish) 2026-04-14 19:48:04 -07:00
Owen Schwartz
37378895cf New translations en-us.json (Dutch) 2026-04-14 19:48:02 -07:00
Owen Schwartz
19ef055296 New translations en-us.json (Korean) 2026-04-14 19:48:00 -07:00
Owen Schwartz
599fa5eb30 New translations en-us.json (Italian) 2026-04-14 19:47:59 -07:00
Owen Schwartz
4d82b37cab New translations en-us.json (German) 2026-04-14 19:47:57 -07:00
Owen Schwartz
77d01d50db New translations en-us.json (Czech) 2026-04-14 19:47:55 -07:00
Owen Schwartz
013c1ab92c New translations en-us.json (Bulgarian) 2026-04-14 19:47:53 -07:00
Owen Schwartz
d4fc60f2f4 New translations en-us.json (Spanish) 2026-04-14 19:47:52 -07:00
Owen Schwartz
cd25cde47f New translations en-us.json (French) 2026-04-14 19:47:50 -07:00
Owen
af709331fb Add missing DnsRecords type 2026-04-14 19:46:25 -07:00
Owen
e20a21bacd Contact support 2026-04-14 19:46:19 -07:00
Owen
74b3b283f7 Fix #2848 2026-04-13 21:30:19 -07:00
Owen Schwartz
9fe4f78269 Merge pull request #2857 from fosrl/dev
Proxy targets returns an array
2026-04-13 20:57:15 -07:00
Owen
03d95874e6 Proxy targets returns an array 2026-04-13 20:44:35 -07:00
Milo Schwartz
bd3d6994c1 Merge pull request #2856 from fosrl/update-readme
fix image
2026-04-13 20:29:36 -07:00
miloschwartz
5fd78817a8 fix image 2026-04-13 20:28:05 -07:00
Owen Schwartz
72bc125f84 Merge pull request #2854 from fosrl/dev
Rename script
2026-04-13 16:23:39 -07:00
Owen
5d51af4330 Rename script 2026-04-13 16:22:53 -07:00
Owen Schwartz
b18ea66def Merge pull request #2853 from fosrl/dev
1.17.1-s.1
2026-04-13 12:28:08 -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
36 changed files with 200 additions and 264 deletions

1
config/db/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
*-journal

View File

@@ -2351,7 +2351,7 @@
}, },
"scale": { "scale": {
"title": "Скала", "title": "Скала",
"description": "Предприятие, 50 потребители, 50 сайта и приоритетна поддръжка." "description": "Функции за корпоративни клиенти, 50 потребители, 100 сайта и приоритетна поддръжка."
} }
}, },
"personalUseOnly": "Само за лична употреба (безплатен лиценз - без проверка)", "personalUseOnly": "Само за лична употреба (безплатен лиценз - без проверка)",

View File

@@ -2351,7 +2351,7 @@
}, },
"scale": { "scale": {
"title": "Měřítko", "title": "Měřítko",
"description": "Podnikové funkce, 50 uživatelů, 50 míst a prioritní podpory." "description": "Podnikové funkce, 50 uživatelů, 100 stránek a prioritní podpora."
} }
}, },
"personalUseOnly": "Pouze pro osobní použití (zdarma licence - bez ověření)", "personalUseOnly": "Pouze pro osobní použití (zdarma licence - bez ověření)",

View File

@@ -2351,7 +2351,7 @@
}, },
"scale": { "scale": {
"title": "Maßstab", "title": "Maßstab",
"description": "Enterprise Features, 50 Benutzer, 50 Sites und Prioritätsunterstützung." "description": "Unternehmensmerkmale, 50 Benutzer, 100 Standorte und prioritärer Support."
} }
}, },
"personalUseOnly": "Nur persönliche Nutzung (kostenlose Lizenz - kein Checkout)", "personalUseOnly": "Nur persönliche Nutzung (kostenlose Lizenz - kein Checkout)",

View File

@@ -2351,7 +2351,7 @@
}, },
"scale": { "scale": {
"title": "Scale", "title": "Scale",
"description": "Enterprise features, 50 users, 50 sites, and priority support." "description": "Enterprise features, 50 users, 100 sites, and priority support."
} }
}, },
"personalUseOnly": "Personal use only (free license - no checkout)", "personalUseOnly": "Personal use only (free license - no checkout)",
@@ -2824,9 +2824,9 @@
"streamingHttpWebhookTitle": "HTTP Webhook", "streamingHttpWebhookTitle": "HTTP Webhook",
"streamingHttpWebhookDescription": "Send events to any HTTP endpoint with flexible authentication and templating.", "streamingHttpWebhookDescription": "Send events to any HTTP endpoint with flexible authentication and templating.",
"streamingS3Title": "Amazon S3", "streamingS3Title": "Amazon S3",
"streamingS3Description": "Stream events to an S3-compatible object storage bucket. Coming soon.", "streamingS3Description": "Stream events to an S3-compatible object storage bucket. Contact support to enable this destination.",
"streamingDatadogTitle": "Datadog", "streamingDatadogTitle": "Datadog",
"streamingDatadogDescription": "Forward events directly to your Datadog account. Coming soon.", "streamingDatadogDescription": "Forward events directly to your Datadog account. Contact support to enable this destination.",
"streamingTypePickerDescription": "Choose a destination type to get started.", "streamingTypePickerDescription": "Choose a destination type to get started.",
"streamingFailedToLoad": "Failed to load destinations", "streamingFailedToLoad": "Failed to load destinations",
"streamingUnexpectedError": "An unexpected error occurred.", "streamingUnexpectedError": "An unexpected error occurred.",
@@ -2849,7 +2849,7 @@
"httpDestNamePlaceholder": "My HTTP destination", "httpDestNamePlaceholder": "My HTTP destination",
"httpDestUrlLabel": "Destination URL", "httpDestUrlLabel": "Destination URL",
"httpDestUrlErrorHttpRequired": "URL must use http or https", "httpDestUrlErrorHttpRequired": "URL must use http or https",
"httpDestUrlErrorHttpsRequired": "HTTPS is required on cloud deployments", "httpDestUrlErrorHttpsRequired": "HTTPS is required",
"httpDestUrlErrorInvalid": "Enter a valid URL (e.g. https://example.com/webhook)", "httpDestUrlErrorInvalid": "Enter a valid URL (e.g. https://example.com/webhook)",
"httpDestAuthTitle": "Authentication", "httpDestAuthTitle": "Authentication",
"httpDestAuthDescription": "Choose how requests to your endpoint are authenticated.", "httpDestAuthDescription": "Choose how requests to your endpoint are authenticated.",

View File

@@ -2351,7 +2351,7 @@
}, },
"scale": { "scale": {
"title": "Escala", "title": "Escala",
"description": "Características de la empresa, 50 usuarios, 50 sitios y soporte prioritario." "description": "Funcionalidades empresariales, 50 usuarios, 100 sitios y soporte prioritario."
} }
}, },
"personalUseOnly": "Solo uso personal (licencia gratuita - sin salida)", "personalUseOnly": "Solo uso personal (licencia gratuita - sin salida)",

View File

@@ -2351,7 +2351,7 @@
}, },
"scale": { "scale": {
"title": "Échelle", "title": "Échelle",
"description": "Fonctionnalités d'entreprise, 50 utilisateurs, 50 sites et une prise en charge prioritaire." "description": "Fonctionnalités d'entreprise, 50 utilisateurs, 100 sites et support prioritaire."
} }
}, },
"personalUseOnly": "Usage personnel uniquement (licence gratuite - pas de validation)", "personalUseOnly": "Usage personnel uniquement (licence gratuite - pas de validation)",

View File

@@ -2351,7 +2351,7 @@
}, },
"scale": { "scale": {
"title": "Scala", "title": "Scala",
"description": "Funzionalità aziendali, 50 utenti, 50 siti e supporto prioritario." "description": "Funzionalità aziendali, 50 utenti, 100 siti e supporto prioritario."
} }
}, },
"personalUseOnly": "Uso personale esclusivo (licenza gratuita - nessun pagamento)", "personalUseOnly": "Uso personale esclusivo (licenza gratuita - nessun pagamento)",

View File

@@ -2351,7 +2351,7 @@
}, },
"scale": { "scale": {
"title": "스케일", "title": "스케일",
"description": "기업 기능, 50명의 사용자, 50개의 사이트, 우선 지원." "description": "기업 기능, 50명의 사용자, 100개의 사이트, 그리고 우선 지원."
} }
}, },
"personalUseOnly": "개인용으로만 사용 (무료 라이선스 - 결제 없음)", "personalUseOnly": "개인용으로만 사용 (무료 라이선스 - 결제 없음)",

View File

@@ -2351,7 +2351,7 @@
}, },
"scale": { "scale": {
"title": "Skala", "title": "Skala",
"description": "Enterprise features, 50 brukere, 50 nettsteder og prioritetsstøtte." "description": "Funksjoner for bedrifter, 50 brukere, 100 nettsteder og prioritert support."
} }
}, },
"personalUseOnly": "Kun personlig bruk (gratis lisens - ingen kasse)", "personalUseOnly": "Kun personlig bruk (gratis lisens - ingen kasse)",

View File

@@ -2351,7 +2351,7 @@
}, },
"scale": { "scale": {
"title": "Schaal", "title": "Schaal",
"description": "Enterprise functies, 50 gebruikers, 50 sites en prioriteit ondersteuning." "description": "Enterprise-functies, 50 gebruikers, 100 sites en prioritaire ondersteuning."
} }
}, },
"personalUseOnly": "Alleen voor persoonlijk gebruik (gratis licentie - geen afrekening)", "personalUseOnly": "Alleen voor persoonlijk gebruik (gratis licentie - geen afrekening)",

View File

@@ -2351,7 +2351,7 @@
}, },
"scale": { "scale": {
"title": "Skala", "title": "Skala",
"description": "Cechy przedsiębiorstw, 50 użytkowników, 50 obiektów i wsparcie priorytetowe." "description": "Funkcje dla przedsiębiorstw, 50 użytkowników, 100 witryn i priorytetowe wsparcie."
} }
}, },
"personalUseOnly": "Tylko do użytku osobistego (darmowa licencja - bez płatności)", "personalUseOnly": "Tylko do użytku osobistego (darmowa licencja - bez płatności)",

View File

@@ -2351,7 +2351,7 @@
}, },
"scale": { "scale": {
"title": "Escala", "title": "Escala",
"description": "Recursos de empresa, 50 usuários, 50 sites e apoio prioritário." "description": "Recursos empresariais, 50 usuários, 100 sites, e suporte prioritário."
} }
}, },
"personalUseOnly": "Uso pessoal apenas (licença gratuita - sem checkout)", "personalUseOnly": "Uso pessoal apenas (licença gratuita - sem checkout)",

View File

@@ -2351,7 +2351,7 @@
}, },
"scale": { "scale": {
"title": "Масштаб", "title": "Масштаб",
"description": "Функции предприятия, 50 пользователей, 50 сайтов, а также приоритетная поддержка." "description": "Функции корпоративного уровня, 50 пользователей, 100 сайтов и приоритетная поддержка."
} }
}, },
"personalUseOnly": "Только для личного использования (бесплатная лицензия - без оформления на кассе)", "personalUseOnly": "Только для личного использования (бесплатная лицензия - без оформления на кассе)",

View File

@@ -2351,7 +2351,7 @@
}, },
"scale": { "scale": {
"title": "Ölçek", "title": "Ölçek",
"description": "Kurumsal özellikler, 50 kullanıcı, 50 site ve öncelikli destek." "description": "Kurumsal özellikler, 50 kullanıcı, 100 site ve öncelikli destek."
} }
}, },
"personalUseOnly": "Kişisel kullanım için (ücretsiz lisans - ödeme yok)", "personalUseOnly": "Kişisel kullanım için (ücretsiz lisans - ödeme yok)",

View File

@@ -2351,7 +2351,7 @@
}, },
"scale": { "scale": {
"title": "缩放比例", "title": "缩放比例",
"description": "企业特征、50个用户、50个站点优先支持。" "description": "企业功能,50个用户100个站点,以及优先支持。"
} }
}, },
"personalUseOnly": "仅限个人使用(免费许可 - 无需结账)", "personalUseOnly": "仅限个人使用(免费许可 - 无需结账)",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 765 KiB

After

Width:  |  Height:  |  Size: 588 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 742 KiB

After

Width:  |  Height:  |  Size: 569 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 765 KiB

After

Width:  |  Height:  |  Size: 588 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 MiB

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

After

Width:  |  Height:  |  Size: 274 KiB

View File

@@ -1080,6 +1080,7 @@ export type ResourceWhitelist = InferSelectModel<typeof resourceWhitelist>;
export type VersionMigration = InferSelectModel<typeof versionMigrations>; export type VersionMigration = InferSelectModel<typeof versionMigrations>;
export type ResourceRule = InferSelectModel<typeof resourceRules>; export type ResourceRule = InferSelectModel<typeof resourceRules>;
export type Domain = InferSelectModel<typeof domains>; export type Domain = InferSelectModel<typeof domains>;
export type DnsRecord = InferSelectModel<typeof dnsRecords>;
export type SupporterKey = InferSelectModel<typeof supporterKey>; export type SupporterKey = InferSelectModel<typeof supporterKey>;
export type Idp = InferSelectModel<typeof idp>; export type Idp = InferSelectModel<typeof idp>;
export type ApiKey = InferSelectModel<typeof apiKeys>; export type ApiKey = InferSelectModel<typeof apiKeys>;

View File

@@ -9,8 +9,8 @@ export type LicensePriceSet = {
export const licensePriceSet: LicensePriceSet = { export const licensePriceSet: LicensePriceSet = {
// Free license matches the freeLimitSet // Free license matches the freeLimitSet
[LicenseId.SMALL_LICENSE]: "price_1SxKHiD3Ee2Ir7WmvtEh17A8", [LicenseId.SMALL_LICENSE]: "price_1TMJzmD3Ee2Ir7Wm05NlGImT",
[LicenseId.BIG_LICENSE]: "price_1SxKHiD3Ee2Ir7WmMUiP0H6Y" [LicenseId.BIG_LICENSE]: "price_1TMJzzD3Ee2Ir7WmzJw9TerS"
}; };
export const licensePriceSetSandbox: LicensePriceSet = { export const licensePriceSetSandbox: LicensePriceSet = {

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,52 +614,54 @@ 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 clientSite of clients) { for (const target of targets) {
if (!clientSite.subnet) { for (const clientSite of clients) {
logger.debug( if (!clientSite.subnet) {
`Client ${clientSite.clientId} has no subnet, skipping for site resource ${siteResource.siteResourceId}.` logger.debug(
); `Client ${clientSite.clientId} has no subnet, skipping for site resource ${siteResource.siteResourceId}.`
continue; );
continue;
}
const clientPrefix = `${clientSite.subnet.split("/")[0]}/32`;
// add client prefix to source prefixes
target.sourcePrefixes.push(clientPrefix);
} }
const clientPrefix = `${clientSite.subnet.split("/")[0]}/32`;
// add client prefix to source prefixes
target.sourcePrefixes.push(clientPrefix);
} }
// print a nice representation of the targets // print a nice representation of the targets
@@ -667,36 +669,34 @@ export function generateSubnetProxyTargetV2(
// `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.
* @param targetV2 - The v2 target to convert * @param targetV2 - The v2 target to convert
* @returns Array of v1 SubnetProxyTarget objects * @returns Array of v1 SubnetProxyTarget objects
*/ */
export function convertSubnetProxyTargetsV2ToV1( export function convertSubnetProxyTargetsV2ToV1(
targetsV2: SubnetProxyTargetV2[] targetsV2: SubnetProxyTargetV2[]
): SubnetProxyTarget[] { ): SubnetProxyTarget[] {
return targetsV2.flatMap((targetV2) => return targetsV2.flatMap((targetV2) =>
targetV2.sourcePrefixes.map((sourcePrefix) => ({ targetV2.sourcePrefixes.map((sourcePrefix) => ({
sourcePrefix, sourcePrefix,
destPrefix: targetV2.destPrefix, destPrefix: targetV2.destPrefix,
...(targetV2.disableIcmp !== undefined && { ...(targetV2.disableIcmp !== undefined && {
disableIcmp: targetV2.disableIcmp disableIcmp: targetV2.disableIcmp
}), }),
...(targetV2.rewriteTo !== undefined && { ...(targetV2.rewriteTo !== undefined && {
rewriteTo: targetV2.rewriteTo rewriteTo: targetV2.rewriteTo
}), }),
...(targetV2.portRange !== undefined && { ...(targetV2.portRange !== undefined && {
portRange: targetV2.portRange portRange: targetV2.portRange
}) })
})) }))
); );
} }
// 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

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

@@ -217,7 +217,7 @@ export async function handleSubscriptionCreated(
subscriptionPriceId === priceSet[LicenseId.BIG_LICENSE] subscriptionPriceId === priceSet[LicenseId.BIG_LICENSE]
) { ) {
numUsers = 50; numUsers = 50;
numSites = 50; numSites = 100;
} else { } else {
logger.error( logger.error(
`Unknown price ID ${subscriptionPriceId} for subscription ${subscription.id}` `Unknown price ID ${subscriptionPriceId} for subscription ${subscription.id}`

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">