mirror of
https://github.com/fosrl/pangolin.git
synced 2026-03-13 06:06:39 +00:00
Compare commits
4 Commits
jit
...
delete-dom
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f29c77b640 | ||
|
|
4692cdbc72 | ||
|
|
549c5d517d | ||
|
|
9021030862 |
@@ -2343,8 +2343,8 @@
|
|||||||
"logRetentionEndOfFollowingYear": "End of following year",
|
"logRetentionEndOfFollowingYear": "End of following year",
|
||||||
"actionLogsDescription": "View a history of actions performed in this organization",
|
"actionLogsDescription": "View a history of actions performed in this organization",
|
||||||
"accessLogsDescription": "View access auth requests for resources in this organization",
|
"accessLogsDescription": "View access auth requests for resources in this organization",
|
||||||
"licenseRequiredToUse": "An <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> license or <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> is required to use this feature. <bookADemoLink>Book a demo or POC trial</bookADemoLink>.",
|
"licenseRequiredToUse": "An <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> license or <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> is required to use this feature.",
|
||||||
"ossEnterpriseEditionRequired": "The <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is required to use this feature. This feature is also available in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Book a demo or POC trial</bookADemoLink>.",
|
"ossEnterpriseEditionRequired": "The <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is required to use this feature. This feature is also available in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
||||||
"certResolver": "Certificate Resolver",
|
"certResolver": "Certificate Resolver",
|
||||||
"certResolverDescription": "Select the certificate resolver to use for this resource.",
|
"certResolverDescription": "Select the certificate resolver to use for this resource.",
|
||||||
"selectCertResolver": "Select Certificate Resolver",
|
"selectCertResolver": "Select Certificate Resolver",
|
||||||
@@ -2681,6 +2681,5 @@
|
|||||||
"approvalsEmptyStateStep2Title": "Enable Device Approvals",
|
"approvalsEmptyStateStep2Title": "Enable Device Approvals",
|
||||||
"approvalsEmptyStateStep2Description": "Edit a role and enable the 'Require Device Approvals' option. Users with this role will need admin approval for new devices.",
|
"approvalsEmptyStateStep2Description": "Edit a role and enable the 'Require Device Approvals' option. Users with this role will need admin approval for new devices.",
|
||||||
"approvalsEmptyStatePreviewDescription": "Preview: When enabled, pending device requests will appear here for review",
|
"approvalsEmptyStatePreviewDescription": "Preview: When enabled, pending device requests will appear here for review",
|
||||||
"approvalsEmptyStateButtonText": "Manage Roles",
|
"approvalsEmptyStateButtonText": "Manage Roles"
|
||||||
"domainErrorTitle": "We are having trouble verifying your domain"
|
|
||||||
}
|
}
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -16773,8 +16773,7 @@
|
|||||||
"node_modules/postal-mime": {
|
"node_modules/postal-mime": {
|
||||||
"version": "2.7.3",
|
"version": "2.7.3",
|
||||||
"resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.3.tgz",
|
"resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.3.tgz",
|
||||||
"integrity": "sha512-MjhXadAJaWgYzevi46+3kLak8y6gbg0ku14O1gO/LNOuay8dO+1PtcSGvAdgDR0DoIsSaiIA8y/Ddw6MnrO0Tw==",
|
"integrity": "sha512-MjhXadAJaWgYzevi46+3kLak8y6gbg0ku14O1gO/LNOuay8dO+1PtcSGvAdgDR0DoIsSaiIA8y/Ddw6MnrO0Tw=="
|
||||||
"license": "MIT-0"
|
|
||||||
},
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.6",
|
"version": "8.5.6",
|
||||||
@@ -17815,7 +17814,6 @@
|
|||||||
"version": "6.9.2",
|
"version": "6.9.2",
|
||||||
"resolved": "https://registry.npmjs.org/resend/-/resend-6.9.2.tgz",
|
"resolved": "https://registry.npmjs.org/resend/-/resend-6.9.2.tgz",
|
||||||
"integrity": "sha512-uIM6CQ08tS+hTCRuKBFbOBvHIGaEhqZe8s4FOgqsVXSbQLAhmNWpmUhG3UAtRnmcwTWFUqnHa/+Vux8YGPyDBA==",
|
"integrity": "sha512-uIM6CQ08tS+hTCRuKBFbOBvHIGaEhqZe8s4FOgqsVXSbQLAhmNWpmUhG3UAtRnmcwTWFUqnHa/+Vux8YGPyDBA==",
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"postal-mime": "2.7.3",
|
"postal-mime": "2.7.3",
|
||||||
"svix": "1.84.1"
|
"svix": "1.84.1"
|
||||||
|
|||||||
@@ -328,14 +328,6 @@ export const approvals = pgTable("approvals", {
|
|||||||
.notNull()
|
.notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
export const bannedEmails = pgTable("bannedEmails", {
|
|
||||||
email: varchar("email", { length: 255 }).primaryKey(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const bannedIps = pgTable("bannedIps", {
|
|
||||||
ip: varchar("ip", { length: 255 }).primaryKey(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type Approval = InferSelectModel<typeof approvals>;
|
export type Approval = InferSelectModel<typeof approvals>;
|
||||||
export type Limit = InferSelectModel<typeof limits>;
|
export type Limit = InferSelectModel<typeof limits>;
|
||||||
export type Account = InferSelectModel<typeof account>;
|
export type Account = InferSelectModel<typeof account>;
|
||||||
|
|||||||
@@ -22,8 +22,7 @@ export const domains = pgTable("domains", {
|
|||||||
tries: integer("tries").notNull().default(0),
|
tries: integer("tries").notNull().default(0),
|
||||||
certResolver: varchar("certResolver"),
|
certResolver: varchar("certResolver"),
|
||||||
customCertResolver: varchar("customCertResolver"),
|
customCertResolver: varchar("customCertResolver"),
|
||||||
preferWildcardCert: boolean("preferWildcardCert"),
|
preferWildcardCert: boolean("preferWildcardCert")
|
||||||
errorMessage: text("errorMessage")
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const dnsRecords = pgTable("dnsRecords", {
|
export const dnsRecords = pgTable("dnsRecords", {
|
||||||
@@ -721,7 +720,6 @@ export const clientSitesAssociationsCache = pgTable(
|
|||||||
.notNull(),
|
.notNull(),
|
||||||
siteId: integer("siteId").notNull(),
|
siteId: integer("siteId").notNull(),
|
||||||
isRelayed: boolean("isRelayed").notNull().default(false),
|
isRelayed: boolean("isRelayed").notNull().default(false),
|
||||||
isJitMode: boolean("isJitMode").notNull().default(false),
|
|
||||||
endpoint: varchar("endpoint"),
|
endpoint: varchar("endpoint"),
|
||||||
publicKey: varchar("publicKey") // this will act as the session's public key for hole punching so we can track when it changes
|
publicKey: varchar("publicKey") // this will act as the session's public key for hole punching so we can track when it changes
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -318,15 +318,6 @@ export const approvals = sqliteTable("approvals", {
|
|||||||
.notNull()
|
.notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
export const bannedEmails = sqliteTable("bannedEmails", {
|
|
||||||
email: text("email").primaryKey()
|
|
||||||
});
|
|
||||||
|
|
||||||
export const bannedIps = sqliteTable("bannedIps", {
|
|
||||||
ip: text("ip").primaryKey()
|
|
||||||
});
|
|
||||||
|
|
||||||
export type Approval = InferSelectModel<typeof approvals>;
|
export type Approval = InferSelectModel<typeof approvals>;
|
||||||
export type Limit = InferSelectModel<typeof limits>;
|
export type Limit = InferSelectModel<typeof limits>;
|
||||||
export type Account = InferSelectModel<typeof account>;
|
export type Account = InferSelectModel<typeof account>;
|
||||||
|
|||||||
@@ -13,8 +13,7 @@ export const domains = sqliteTable("domains", {
|
|||||||
failed: integer("failed", { mode: "boolean" }).notNull().default(false),
|
failed: integer("failed", { mode: "boolean" }).notNull().default(false),
|
||||||
tries: integer("tries").notNull().default(0),
|
tries: integer("tries").notNull().default(0),
|
||||||
certResolver: text("certResolver"),
|
certResolver: text("certResolver"),
|
||||||
preferWildcardCert: integer("preferWildcardCert", { mode: "boolean" }),
|
preferWildcardCert: integer("preferWildcardCert", { mode: "boolean" })
|
||||||
errorMessage: text("errorMessage")
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const dnsRecords = sqliteTable("dnsRecords", {
|
export const dnsRecords = sqliteTable("dnsRecords", {
|
||||||
@@ -410,9 +409,6 @@ export const clientSitesAssociationsCache = sqliteTable(
|
|||||||
isRelayed: integer("isRelayed", { mode: "boolean" })
|
isRelayed: integer("isRelayed", { mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(false),
|
.default(false),
|
||||||
isJitMode: integer("isJitMode", { mode: "boolean" })
|
|
||||||
.notNull()
|
|
||||||
.default(false),
|
|
||||||
endpoint: text("endpoint"),
|
endpoint: text("endpoint"),
|
||||||
publicKey: text("publicKey") // this will act as the session's public key for hole punching so we can track when it changes
|
publicKey: text("publicKey") // this will act as the session's public key for hole punching so we can track when it changes
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -477,7 +477,6 @@ async function handleMessagesForSiteClients(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isAdd) {
|
if (isAdd) {
|
||||||
// TODO: if we are in jit mode here should we really be sending this?
|
|
||||||
await initPeerAddHandshake(
|
await initPeerAddHandshake(
|
||||||
// this will kick off the add peer process for the client
|
// this will kick off the add peer process for the client
|
||||||
client.clientId,
|
client.clientId,
|
||||||
@@ -572,7 +571,7 @@ export async function updateClientSiteDestinations(
|
|||||||
destinations: [
|
destinations: [
|
||||||
{
|
{
|
||||||
destinationIP: site.sites.subnet.split("/")[0],
|
destinationIP: site.sites.subnet.split("/")[0],
|
||||||
destinationPort: site.sites.listenPort || 1 // this satisfies gerbil for now but should be reevaluated
|
destinationPort: site.sites.listenPort || 0
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
@@ -580,7 +579,7 @@ export async function updateClientSiteDestinations(
|
|||||||
// add to the existing destinations
|
// add to the existing destinations
|
||||||
destinations.destinations.push({
|
destinations.destinations.push({
|
||||||
destinationIP: site.sites.subnet.split("/")[0],
|
destinationIP: site.sites.subnet.split("/")[0],
|
||||||
destinationPort: site.sites.listenPort || 1 // this satisfies gerbil for now but should be reevaluated
|
destinationPort: site.sites.listenPort || 0
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1081,7 +1080,6 @@ async function handleMessagesForClientSites(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: if we are in jit mode here should we really be sending this?
|
|
||||||
await initPeerAddHandshake(
|
await initPeerAddHandshake(
|
||||||
// this will kick off the add peer process for the client
|
// this will kick off the add peer process for the client
|
||||||
client.clientId,
|
client.clientId,
|
||||||
|
|||||||
16
server/lib/resend.ts
Normal file
16
server/lib/resend.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export enum AudienceIds {
|
||||||
|
SignUps = "",
|
||||||
|
Subscribed = "",
|
||||||
|
Churned = "",
|
||||||
|
Newsletter = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
let resend;
|
||||||
|
export default resend;
|
||||||
|
|
||||||
|
export async function moveEmailToAudience(
|
||||||
|
email: string,
|
||||||
|
audienceId: AudienceIds
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
@@ -218,11 +218,10 @@ export class TraefikConfigManager {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch if it's been more than 24 hours (for renewals)
|
||||||
const dayInMs = 24 * 60 * 60 * 1000;
|
const dayInMs = 24 * 60 * 60 * 1000;
|
||||||
const timeSinceLastFetch =
|
const timeSinceLastFetch =
|
||||||
Date.now() - this.lastCertificateFetch.getTime();
|
Date.now() - this.lastCertificateFetch.getTime();
|
||||||
|
|
||||||
// Fetch if it's been more than 24 hours (daily routine check)
|
|
||||||
if (timeSinceLastFetch > dayInMs) {
|
if (timeSinceLastFetch > dayInMs) {
|
||||||
logger.info("Fetching certificates due to 24-hour renewal check");
|
logger.info("Fetching certificates due to 24-hour renewal check");
|
||||||
return true;
|
return true;
|
||||||
@@ -266,7 +265,7 @@ export class TraefikConfigManager {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if any local certificates are missing (needs immediate fetch)
|
// Check if any local certificates are missing or appear to be outdated
|
||||||
for (const domain of domainsNeedingCerts) {
|
for (const domain of domainsNeedingCerts) {
|
||||||
const localState = this.lastLocalCertificateState.get(domain);
|
const localState = this.lastLocalCertificateState.get(domain);
|
||||||
if (!localState || !localState.exists) {
|
if (!localState || !localState.exists) {
|
||||||
@@ -275,55 +274,17 @@ export class TraefikConfigManager {
|
|||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// For expiry checks, throttle to every 6 hours to avoid querying the
|
// Check if certificate is expiring soon (within 30 days)
|
||||||
// API/DB on every monitor loop. The certificate-service renews certs
|
if (localState.expiresAt) {
|
||||||
// 45 days before expiry, so checking every 6 hours is plenty frequent
|
const nowInSeconds = Math.floor(Date.now() / 1000);
|
||||||
// to pick up renewed certs promptly.
|
const secondsUntilExpiry = localState.expiresAt - nowInSeconds;
|
||||||
const renewalCheckIntervalMs = 6 * 60 * 60 * 1000; // 6 hours
|
const daysUntilExpiry = secondsUntilExpiry / (60 * 60 * 24);
|
||||||
if (timeSinceLastFetch > renewalCheckIntervalMs) {
|
if (daysUntilExpiry < 30) {
|
||||||
// Check non-wildcard certs for expiry (within 45 days to match
|
logger.info(
|
||||||
// the server-side renewal window in certificate-service)
|
`Fetching certificates due to upcoming expiry for ${domain} (${Math.round(daysUntilExpiry)} days remaining)`
|
||||||
for (const domain of domainsNeedingCerts) {
|
);
|
||||||
const localState =
|
return true;
|
||||||
this.lastLocalCertificateState.get(domain);
|
|
||||||
if (localState?.expiresAt) {
|
|
||||||
const nowInSeconds = Math.floor(Date.now() / 1000);
|
|
||||||
const secondsUntilExpiry =
|
|
||||||
localState.expiresAt - nowInSeconds;
|
|
||||||
const daysUntilExpiry =
|
|
||||||
secondsUntilExpiry / (60 * 60 * 24);
|
|
||||||
if (daysUntilExpiry < 45) {
|
|
||||||
logger.info(
|
|
||||||
`Fetching certificates due to upcoming expiry for ${domain} (${Math.round(daysUntilExpiry)} days remaining)`
|
|
||||||
);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also check wildcard certificates for expiry. These are not
|
|
||||||
// included in domainsNeedingCerts since their subdomains are
|
|
||||||
// filtered out, so we must check them separately.
|
|
||||||
for (const [certDomain, state] of this
|
|
||||||
.lastLocalCertificateState) {
|
|
||||||
if (
|
|
||||||
state.exists &&
|
|
||||||
state.wildcard &&
|
|
||||||
state.expiresAt
|
|
||||||
) {
|
|
||||||
const nowInSeconds = Math.floor(Date.now() / 1000);
|
|
||||||
const secondsUntilExpiry =
|
|
||||||
state.expiresAt - nowInSeconds;
|
|
||||||
const daysUntilExpiry =
|
|
||||||
secondsUntilExpiry / (60 * 60 * 24);
|
|
||||||
if (daysUntilExpiry < 45) {
|
|
||||||
logger.info(
|
|
||||||
`Fetching certificates due to upcoming expiry for wildcard cert ${certDomain} (${Math.round(daysUntilExpiry)} days remaining)`
|
|
||||||
);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -400,32 +361,6 @@ export class TraefikConfigManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also include wildcard cert base domains that are
|
|
||||||
// expiring or expired so they get re-fetched even though
|
|
||||||
// their subdomains were filtered out above.
|
|
||||||
for (const [certDomain, state] of this
|
|
||||||
.lastLocalCertificateState) {
|
|
||||||
if (
|
|
||||||
state.exists &&
|
|
||||||
state.wildcard &&
|
|
||||||
state.expiresAt
|
|
||||||
) {
|
|
||||||
const nowInSeconds = Math.floor(
|
|
||||||
Date.now() / 1000
|
|
||||||
);
|
|
||||||
const secondsUntilExpiry =
|
|
||||||
state.expiresAt - nowInSeconds;
|
|
||||||
const daysUntilExpiry =
|
|
||||||
secondsUntilExpiry / (60 * 60 * 24);
|
|
||||||
if (daysUntilExpiry < 45) {
|
|
||||||
domainsToFetch.add(certDomain);
|
|
||||||
logger.info(
|
|
||||||
`Including expiring wildcard cert domain ${certDomain} in fetch (${Math.round(daysUntilExpiry)} days remaining)`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (domainsToFetch.size > 0) {
|
if (domainsToFetch.size > 0) {
|
||||||
// Get valid certificates for domains not covered by wildcards
|
// Get valid certificates for domains not covered by wildcards
|
||||||
validCertificates =
|
validCertificates =
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import logger from "@server/logger";
|
|||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { resources, sites, Target, targets } from "@server/db";
|
import { resources, sites, Target, targets } from "@server/db";
|
||||||
import createPathRewriteMiddleware from "./middleware";
|
import createPathRewriteMiddleware from "./middleware";
|
||||||
import { sanitize, encodePath, validatePathRewriteConfig } from "./utils";
|
import { sanitize, validatePathRewriteConfig } from "./utils";
|
||||||
|
|
||||||
const redirectHttpsMiddlewareName = "redirect-to-https";
|
const redirectHttpsMiddlewareName = "redirect-to-https";
|
||||||
const badgerMiddlewareName = "badger";
|
const badgerMiddlewareName = "badger";
|
||||||
@@ -44,7 +44,7 @@ export async function getTraefikConfig(
|
|||||||
filterOutNamespaceDomains = false, // UNUSED BUT USED IN PRIVATE
|
filterOutNamespaceDomains = false, // UNUSED BUT USED IN PRIVATE
|
||||||
generateLoginPageRouters = false, // UNUSED BUT USED IN PRIVATE
|
generateLoginPageRouters = false, // UNUSED BUT USED IN PRIVATE
|
||||||
allowRawResources = true,
|
allowRawResources = true,
|
||||||
allowMaintenancePage = true // UNUSED BUT USED IN PRIVATE
|
allowMaintenancePage = true, // UNUSED BUT USED IN PRIVATE
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
// Get resources with their targets and sites in a single optimized query
|
// Get resources with their targets and sites in a single optimized query
|
||||||
// Start from sites on this exit node, then join to targets and resources
|
// Start from sites on this exit node, then join to targets and resources
|
||||||
@@ -127,7 +127,7 @@ export async function getTraefikConfig(
|
|||||||
resourcesWithTargetsAndSites.forEach((row) => {
|
resourcesWithTargetsAndSites.forEach((row) => {
|
||||||
const resourceId = row.resourceId;
|
const resourceId = row.resourceId;
|
||||||
const resourceName = sanitize(row.resourceName) || "";
|
const resourceName = sanitize(row.resourceName) || "";
|
||||||
const targetPath = encodePath(row.path); // Use encodePath to avoid collisions (e.g. "/a/b" vs "/a-b")
|
const targetPath = sanitize(row.path) || ""; // Handle null/undefined paths
|
||||||
const pathMatchType = row.pathMatchType || "";
|
const pathMatchType = row.pathMatchType || "";
|
||||||
const rewritePath = row.rewritePath || "";
|
const rewritePath = row.rewritePath || "";
|
||||||
const rewritePathType = row.rewritePathType || "";
|
const rewritePathType = row.rewritePathType || "";
|
||||||
@@ -145,7 +145,7 @@ export async function getTraefikConfig(
|
|||||||
const mapKey = [resourceId, pathKey].filter(Boolean).join("-");
|
const mapKey = [resourceId, pathKey].filter(Boolean).join("-");
|
||||||
const key = sanitize(mapKey);
|
const key = sanitize(mapKey);
|
||||||
|
|
||||||
if (!resourcesMap.has(mapKey)) {
|
if (!resourcesMap.has(key)) {
|
||||||
const validation = validatePathRewriteConfig(
|
const validation = validatePathRewriteConfig(
|
||||||
row.path,
|
row.path,
|
||||||
row.pathMatchType,
|
row.pathMatchType,
|
||||||
@@ -160,10 +160,9 @@ export async function getTraefikConfig(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
resourcesMap.set(mapKey, {
|
resourcesMap.set(key, {
|
||||||
resourceId: row.resourceId,
|
resourceId: row.resourceId,
|
||||||
name: resourceName,
|
name: resourceName,
|
||||||
key: key,
|
|
||||||
fullDomain: row.fullDomain,
|
fullDomain: row.fullDomain,
|
||||||
ssl: row.ssl,
|
ssl: row.ssl,
|
||||||
http: row.http,
|
http: row.http,
|
||||||
@@ -191,7 +190,7 @@ export async function getTraefikConfig(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
resourcesMap.get(mapKey).targets.push({
|
resourcesMap.get(key).targets.push({
|
||||||
resourceId: row.resourceId,
|
resourceId: row.resourceId,
|
||||||
targetId: row.targetId,
|
targetId: row.targetId,
|
||||||
ip: row.ip,
|
ip: row.ip,
|
||||||
@@ -228,9 +227,8 @@ export async function getTraefikConfig(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// get the key and the resource
|
// get the key and the resource
|
||||||
for (const [, resource] of resourcesMap.entries()) {
|
for (const [key, resource] of resourcesMap.entries()) {
|
||||||
const targets = resource.targets as TargetWithSite[];
|
const targets = resource.targets as TargetWithSite[];
|
||||||
const key = resource.key;
|
|
||||||
|
|
||||||
const routerName = `${key}-${resource.name}-router`;
|
const routerName = `${key}-${resource.name}-router`;
|
||||||
const serviceName = `${key}-${resource.name}-service`;
|
const serviceName = `${key}-${resource.name}-service`;
|
||||||
|
|||||||
@@ -1,323 +0,0 @@
|
|||||||
import { assertEquals } from "../../../test/assert";
|
|
||||||
|
|
||||||
// ── Pure function copies (inlined to avoid pulling in server dependencies) ──
|
|
||||||
|
|
||||||
function sanitize(input: string | null | undefined): string | undefined {
|
|
||||||
if (!input) return undefined;
|
|
||||||
if (input.length > 50) {
|
|
||||||
input = input.substring(0, 50);
|
|
||||||
}
|
|
||||||
return input
|
|
||||||
.replace(/[^a-zA-Z0-9-]/g, "-")
|
|
||||||
.replace(/-+/g, "-")
|
|
||||||
.replace(/^-|-$/g, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
function encodePath(path: string | null | undefined): string {
|
|
||||||
if (!path) return "";
|
|
||||||
return path.replace(/[^a-zA-Z0-9]/g, (ch) => {
|
|
||||||
return ch.charCodeAt(0).toString(16);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Exact replica of the OLD key computation from upstream main.
|
|
||||||
* Uses sanitize() for paths — this is what had the collision bug.
|
|
||||||
*/
|
|
||||||
function oldKeyComputation(
|
|
||||||
resourceId: number,
|
|
||||||
path: string | null,
|
|
||||||
pathMatchType: string | null,
|
|
||||||
rewritePath: string | null,
|
|
||||||
rewritePathType: string | null
|
|
||||||
): string {
|
|
||||||
const targetPath = sanitize(path) || "";
|
|
||||||
const pmt = pathMatchType || "";
|
|
||||||
const rp = rewritePath || "";
|
|
||||||
const rpt = rewritePathType || "";
|
|
||||||
const pathKey = [targetPath, pmt, rp, rpt].filter(Boolean).join("-");
|
|
||||||
const mapKey = [resourceId, pathKey].filter(Boolean).join("-");
|
|
||||||
return sanitize(mapKey) || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Replica of the NEW key computation from our fix.
|
|
||||||
* Uses encodePath() for paths — collision-free.
|
|
||||||
*/
|
|
||||||
function newKeyComputation(
|
|
||||||
resourceId: number,
|
|
||||||
path: string | null,
|
|
||||||
pathMatchType: string | null,
|
|
||||||
rewritePath: string | null,
|
|
||||||
rewritePathType: string | null
|
|
||||||
): string {
|
|
||||||
const targetPath = encodePath(path);
|
|
||||||
const pmt = pathMatchType || "";
|
|
||||||
const rp = rewritePath || "";
|
|
||||||
const rpt = rewritePathType || "";
|
|
||||||
const pathKey = [targetPath, pmt, rp, rpt].filter(Boolean).join("-");
|
|
||||||
const mapKey = [resourceId, pathKey].filter(Boolean).join("-");
|
|
||||||
return sanitize(mapKey) || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Tests ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function runTests() {
|
|
||||||
console.log("Running path encoding tests...\n");
|
|
||||||
|
|
||||||
let passed = 0;
|
|
||||||
|
|
||||||
// ── encodePath unit tests ────────────────────────────────────────
|
|
||||||
|
|
||||||
// Test 1: null/undefined/empty
|
|
||||||
{
|
|
||||||
assertEquals(encodePath(null), "", "null should return empty");
|
|
||||||
assertEquals(
|
|
||||||
encodePath(undefined),
|
|
||||||
"",
|
|
||||||
"undefined should return empty"
|
|
||||||
);
|
|
||||||
assertEquals(encodePath(""), "", "empty string should return empty");
|
|
||||||
console.log(" PASS: encodePath handles null/undefined/empty");
|
|
||||||
passed++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test 2: root path
|
|
||||||
{
|
|
||||||
assertEquals(encodePath("/"), "2f", "/ should encode to 2f");
|
|
||||||
console.log(" PASS: encodePath encodes root path");
|
|
||||||
passed++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test 3: alphanumeric passthrough
|
|
||||||
{
|
|
||||||
assertEquals(encodePath("/api"), "2fapi", "/api encodes slash only");
|
|
||||||
assertEquals(encodePath("/v1"), "2fv1", "/v1 encodes slash only");
|
|
||||||
assertEquals(encodePath("abc"), "abc", "plain alpha passes through");
|
|
||||||
console.log(" PASS: encodePath preserves alphanumeric chars");
|
|
||||||
passed++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test 4: all special chars produce unique hex
|
|
||||||
{
|
|
||||||
const paths = ["/a/b", "/a-b", "/a.b", "/a_b", "/a b"];
|
|
||||||
const results = paths.map((p) => encodePath(p));
|
|
||||||
const unique = new Set(results);
|
|
||||||
assertEquals(
|
|
||||||
unique.size,
|
|
||||||
paths.length,
|
|
||||||
"all special-char paths must produce unique encodings"
|
|
||||||
);
|
|
||||||
console.log(
|
|
||||||
" PASS: encodePath produces unique output for different special chars"
|
|
||||||
);
|
|
||||||
passed++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test 5: output is always alphanumeric (safe for Traefik names)
|
|
||||||
{
|
|
||||||
const paths = [
|
|
||||||
"/",
|
|
||||||
"/api",
|
|
||||||
"/a/b",
|
|
||||||
"/a-b",
|
|
||||||
"/a.b",
|
|
||||||
"/complex/path/here"
|
|
||||||
];
|
|
||||||
for (const p of paths) {
|
|
||||||
const e = encodePath(p);
|
|
||||||
assertEquals(
|
|
||||||
/^[a-zA-Z0-9]+$/.test(e),
|
|
||||||
true,
|
|
||||||
`encodePath("${p}") = "${e}" must be alphanumeric`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
console.log(" PASS: encodePath output is always alphanumeric");
|
|
||||||
passed++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test 6: deterministic
|
|
||||||
{
|
|
||||||
assertEquals(
|
|
||||||
encodePath("/api"),
|
|
||||||
encodePath("/api"),
|
|
||||||
"same input same output"
|
|
||||||
);
|
|
||||||
assertEquals(
|
|
||||||
encodePath("/a/b/c"),
|
|
||||||
encodePath("/a/b/c"),
|
|
||||||
"same input same output"
|
|
||||||
);
|
|
||||||
console.log(" PASS: encodePath is deterministic");
|
|
||||||
passed++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test 7: many distinct paths never collide
|
|
||||||
{
|
|
||||||
const paths = [
|
|
||||||
"/",
|
|
||||||
"/api",
|
|
||||||
"/api/v1",
|
|
||||||
"/api/v2",
|
|
||||||
"/a/b",
|
|
||||||
"/a-b",
|
|
||||||
"/a.b",
|
|
||||||
"/a_b",
|
|
||||||
"/health",
|
|
||||||
"/health/check",
|
|
||||||
"/admin",
|
|
||||||
"/admin/users",
|
|
||||||
"/api/v1/users",
|
|
||||||
"/api/v1/posts",
|
|
||||||
"/app",
|
|
||||||
"/app/dashboard"
|
|
||||||
];
|
|
||||||
const encoded = new Set(paths.map((p) => encodePath(p)));
|
|
||||||
assertEquals(
|
|
||||||
encoded.size,
|
|
||||||
paths.length,
|
|
||||||
`expected ${paths.length} unique encodings, got ${encoded.size}`
|
|
||||||
);
|
|
||||||
console.log(" PASS: 16 realistic paths all produce unique encodings");
|
|
||||||
passed++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Collision fix: the actual bug we're fixing ───────────────────
|
|
||||||
|
|
||||||
// Test 8: /a/b and /a-b now have different keys (THE BUG FIX)
|
|
||||||
{
|
|
||||||
const keyAB = newKeyComputation(1, "/a/b", "prefix", null, null);
|
|
||||||
const keyDash = newKeyComputation(1, "/a-b", "prefix", null, null);
|
|
||||||
assertEquals(
|
|
||||||
keyAB !== keyDash,
|
|
||||||
true,
|
|
||||||
"/a/b and /a-b MUST have different keys"
|
|
||||||
);
|
|
||||||
console.log(" PASS: collision fix — /a/b vs /a-b have different keys");
|
|
||||||
passed++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test 9: demonstrate the old bug — old code maps /a/b and /a-b to same key
|
|
||||||
{
|
|
||||||
const oldKeyAB = oldKeyComputation(1, "/a/b", "prefix", null, null);
|
|
||||||
const oldKeyDash = oldKeyComputation(1, "/a-b", "prefix", null, null);
|
|
||||||
assertEquals(
|
|
||||||
oldKeyAB,
|
|
||||||
oldKeyDash,
|
|
||||||
"old code MUST have this collision (confirms the bug exists)"
|
|
||||||
);
|
|
||||||
console.log(" PASS: confirmed old code bug — /a/b and /a-b collided");
|
|
||||||
passed++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test 10: /api/v1 and /api-v1 — old code collision, new code fixes it
|
|
||||||
{
|
|
||||||
const oldKey1 = oldKeyComputation(1, "/api/v1", "prefix", null, null);
|
|
||||||
const oldKey2 = oldKeyComputation(1, "/api-v1", "prefix", null, null);
|
|
||||||
assertEquals(
|
|
||||||
oldKey1,
|
|
||||||
oldKey2,
|
|
||||||
"old code collision for /api/v1 vs /api-v1"
|
|
||||||
);
|
|
||||||
|
|
||||||
const newKey1 = newKeyComputation(1, "/api/v1", "prefix", null, null);
|
|
||||||
const newKey2 = newKeyComputation(1, "/api-v1", "prefix", null, null);
|
|
||||||
assertEquals(
|
|
||||||
newKey1 !== newKey2,
|
|
||||||
true,
|
|
||||||
"new code must separate /api/v1 and /api-v1"
|
|
||||||
);
|
|
||||||
console.log(" PASS: collision fix — /api/v1 vs /api-v1");
|
|
||||||
passed++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test 11: /app.v2 and /app/v2 and /app-v2 — three-way collision fixed
|
|
||||||
{
|
|
||||||
const a = newKeyComputation(1, "/app.v2", "prefix", null, null);
|
|
||||||
const b = newKeyComputation(1, "/app/v2", "prefix", null, null);
|
|
||||||
const c = newKeyComputation(1, "/app-v2", "prefix", null, null);
|
|
||||||
const keys = new Set([a, b, c]);
|
|
||||||
assertEquals(
|
|
||||||
keys.size,
|
|
||||||
3,
|
|
||||||
"three paths must produce three unique keys"
|
|
||||||
);
|
|
||||||
console.log(
|
|
||||||
" PASS: collision fix — three-way /app.v2, /app/v2, /app-v2"
|
|
||||||
);
|
|
||||||
passed++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Edge cases ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
// Test 12: same path in different resources — always separate
|
|
||||||
{
|
|
||||||
const key1 = newKeyComputation(1, "/api", "prefix", null, null);
|
|
||||||
const key2 = newKeyComputation(2, "/api", "prefix", null, null);
|
|
||||||
assertEquals(
|
|
||||||
key1 !== key2,
|
|
||||||
true,
|
|
||||||
"different resources with same path must have different keys"
|
|
||||||
);
|
|
||||||
console.log(" PASS: edge case — same path, different resources");
|
|
||||||
passed++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test 13: same resource, different pathMatchType — separate keys
|
|
||||||
{
|
|
||||||
const exact = newKeyComputation(1, "/api", "exact", null, null);
|
|
||||||
const prefix = newKeyComputation(1, "/api", "prefix", null, null);
|
|
||||||
assertEquals(
|
|
||||||
exact !== prefix,
|
|
||||||
true,
|
|
||||||
"exact vs prefix must have different keys"
|
|
||||||
);
|
|
||||||
console.log(" PASS: edge case — same path, different match types");
|
|
||||||
passed++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test 14: same resource and path, different rewrite config — separate keys
|
|
||||||
{
|
|
||||||
const noRewrite = newKeyComputation(1, "/api", "prefix", null, null);
|
|
||||||
const withRewrite = newKeyComputation(
|
|
||||||
1,
|
|
||||||
"/api",
|
|
||||||
"prefix",
|
|
||||||
"/backend",
|
|
||||||
"prefix"
|
|
||||||
);
|
|
||||||
assertEquals(
|
|
||||||
noRewrite !== withRewrite,
|
|
||||||
true,
|
|
||||||
"with vs without rewrite must have different keys"
|
|
||||||
);
|
|
||||||
console.log(" PASS: edge case — same path, different rewrite config");
|
|
||||||
passed++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test 15: paths with special URL characters
|
|
||||||
{
|
|
||||||
const paths = ["/api?foo", "/api#bar", "/api%20baz", "/api+qux"];
|
|
||||||
const keys = new Set(
|
|
||||||
paths.map((p) => newKeyComputation(1, p, "prefix", null, null))
|
|
||||||
);
|
|
||||||
assertEquals(
|
|
||||||
keys.size,
|
|
||||||
paths.length,
|
|
||||||
"special URL chars must produce unique keys"
|
|
||||||
);
|
|
||||||
console.log(" PASS: edge case — special URL characters in paths");
|
|
||||||
passed++;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`\nAll ${passed} tests passed!`);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
runTests();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Test failed:", error);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
@@ -13,26 +13,6 @@ export function sanitize(input: string | null | undefined): string | undefined {
|
|||||||
.replace(/^-|-$/g, "");
|
.replace(/^-|-$/g, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Encode a URL path into a collision-free alphanumeric string suitable for use
|
|
||||||
* in Traefik map keys.
|
|
||||||
*
|
|
||||||
* Unlike sanitize(), this preserves uniqueness by encoding each non-alphanumeric
|
|
||||||
* character as its hex code. Different paths always produce different outputs.
|
|
||||||
*
|
|
||||||
* encodePath("/api") => "2fapi"
|
|
||||||
* encodePath("/a/b") => "2fa2fb"
|
|
||||||
* encodePath("/a-b") => "2fa2db" (different from /a/b)
|
|
||||||
* encodePath("/") => "2f"
|
|
||||||
* encodePath(null) => ""
|
|
||||||
*/
|
|
||||||
export function encodePath(path: string | null | undefined): string {
|
|
||||||
if (!path) return "";
|
|
||||||
return path.replace(/[^a-zA-Z0-9]/g, (ch) => {
|
|
||||||
return ch.charCodeAt(0).toString(16);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function validatePathRewriteConfig(
|
export function validatePathRewriteConfig(
|
||||||
path: string | null,
|
path: string | null,
|
||||||
pathMatchType: string | null,
|
pathMatchType: string | null,
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ export const privateConfigSchema = z.object({
|
|||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.transform(getEnvOrYaml("SERVER_ENCRYPTION_KEY")),
|
.transform(getEnvOrYaml("SERVER_ENCRYPTION_KEY")),
|
||||||
|
resend_api_key: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform(getEnvOrYaml("RESEND_API_KEY")),
|
||||||
reo_client_id: z
|
reo_client_id: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
|
|||||||
127
server/private/lib/resend.ts
Normal file
127
server/private/lib/resend.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of a proprietary work.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025 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 { Resend } from "resend";
|
||||||
|
import privateConfig from "#private/lib/config";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
|
||||||
|
export enum AudienceIds {
|
||||||
|
SignUps = "6c4e77b2-0851-4bd6-bac8-f51f91360f1a",
|
||||||
|
Subscribed = "870b43fd-387f-44de-8fc1-707335f30b20",
|
||||||
|
Churned = "f3ae92bd-2fdb-4d77-8746-2118afd62549",
|
||||||
|
Newsletter = "5500c431-191c-42f0-a5d4-8b6d445b4ea0"
|
||||||
|
}
|
||||||
|
|
||||||
|
const resend = new Resend(
|
||||||
|
privateConfig.getRawPrivateConfig().server.resend_api_key || "missing"
|
||||||
|
);
|
||||||
|
|
||||||
|
export default resend;
|
||||||
|
|
||||||
|
export async function moveEmailToAudience(
|
||||||
|
email: string,
|
||||||
|
audienceId: AudienceIds
|
||||||
|
) {
|
||||||
|
if (process.env.ENVIRONMENT !== "prod") {
|
||||||
|
logger.debug(
|
||||||
|
`Skipping moving email ${email} to audience ${audienceId} in non-prod environment`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { error, data } = await retryWithBackoff(async () => {
|
||||||
|
const { data, error } = await resend.contacts.create({
|
||||||
|
email,
|
||||||
|
unsubscribed: false,
|
||||||
|
audienceId
|
||||||
|
});
|
||||||
|
if (error) {
|
||||||
|
throw new Error(
|
||||||
|
`Error adding email ${email} to audience ${audienceId}: ${error}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { error, data };
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
logger.error(
|
||||||
|
`Error adding email ${email} to audience ${audienceId}: ${error}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
logger.debug(
|
||||||
|
`Added email ${email} to audience ${audienceId} with contact ID ${data.id}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const otherAudiences = Object.values(AudienceIds).filter(
|
||||||
|
(id) => id !== audienceId
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const otherAudienceId of otherAudiences) {
|
||||||
|
const { error, data } = await retryWithBackoff(async () => {
|
||||||
|
const { data, error } = await resend.contacts.remove({
|
||||||
|
email,
|
||||||
|
audienceId: otherAudienceId
|
||||||
|
});
|
||||||
|
if (error) {
|
||||||
|
throw new Error(
|
||||||
|
`Error removing email ${email} from audience ${otherAudienceId}: ${error}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { error, data };
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
logger.error(
|
||||||
|
`Error removing email ${email} from audience ${otherAudienceId}: ${error}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
logger.info(
|
||||||
|
`Removed email ${email} from audience ${otherAudienceId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type RetryOptions = {
|
||||||
|
retries?: number;
|
||||||
|
initialDelayMs?: number;
|
||||||
|
factor?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function retryWithBackoff<T>(
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
options: RetryOptions = {}
|
||||||
|
): Promise<T> {
|
||||||
|
const { retries = 5, initialDelayMs = 500, factor = 2 } = options;
|
||||||
|
|
||||||
|
let attempt = 0;
|
||||||
|
let delay = initialDelayMs;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} catch (err) {
|
||||||
|
attempt++;
|
||||||
|
|
||||||
|
if (attempt > retries) throw err;
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
|
delay *= factor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,11 +34,7 @@ import {
|
|||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { orgs, resources, sites, Target, targets } from "@server/db";
|
import { orgs, resources, sites, Target, targets } from "@server/db";
|
||||||
import {
|
import { sanitize, validatePathRewriteConfig } from "@server/lib/traefik/utils";
|
||||||
sanitize,
|
|
||||||
encodePath,
|
|
||||||
validatePathRewriteConfig
|
|
||||||
} from "@server/lib/traefik/utils";
|
|
||||||
import privateConfig from "#private/lib/config";
|
import privateConfig from "#private/lib/config";
|
||||||
import createPathRewriteMiddleware from "@server/lib/traefik/middleware";
|
import createPathRewriteMiddleware from "@server/lib/traefik/middleware";
|
||||||
import {
|
import {
|
||||||
@@ -174,7 +170,7 @@ export async function getTraefikConfig(
|
|||||||
resourcesWithTargetsAndSites.forEach((row) => {
|
resourcesWithTargetsAndSites.forEach((row) => {
|
||||||
const resourceId = row.resourceId;
|
const resourceId = row.resourceId;
|
||||||
const resourceName = sanitize(row.resourceName) || "";
|
const resourceName = sanitize(row.resourceName) || "";
|
||||||
const targetPath = encodePath(row.path); // Use encodePath to avoid collisions (e.g. "/a/b" vs "/a-b")
|
const targetPath = sanitize(row.path) || ""; // Handle null/undefined paths
|
||||||
const pathMatchType = row.pathMatchType || "";
|
const pathMatchType = row.pathMatchType || "";
|
||||||
const rewritePath = row.rewritePath || "";
|
const rewritePath = row.rewritePath || "";
|
||||||
const rewritePathType = row.rewritePathType || "";
|
const rewritePathType = row.rewritePathType || "";
|
||||||
@@ -196,7 +192,7 @@ export async function getTraefikConfig(
|
|||||||
const mapKey = [resourceId, pathKey].filter(Boolean).join("-");
|
const mapKey = [resourceId, pathKey].filter(Boolean).join("-");
|
||||||
const key = sanitize(mapKey);
|
const key = sanitize(mapKey);
|
||||||
|
|
||||||
if (!resourcesMap.has(mapKey)) {
|
if (!resourcesMap.has(key)) {
|
||||||
const validation = validatePathRewriteConfig(
|
const validation = validatePathRewriteConfig(
|
||||||
row.path,
|
row.path,
|
||||||
row.pathMatchType,
|
row.pathMatchType,
|
||||||
@@ -211,10 +207,9 @@ export async function getTraefikConfig(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
resourcesMap.set(mapKey, {
|
resourcesMap.set(key, {
|
||||||
resourceId: row.resourceId,
|
resourceId: row.resourceId,
|
||||||
name: resourceName,
|
name: resourceName,
|
||||||
key: key,
|
|
||||||
fullDomain: row.fullDomain,
|
fullDomain: row.fullDomain,
|
||||||
ssl: row.ssl,
|
ssl: row.ssl,
|
||||||
http: row.http,
|
http: row.http,
|
||||||
@@ -248,7 +243,7 @@ export async function getTraefikConfig(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add target with its associated site data
|
// Add target with its associated site data
|
||||||
resourcesMap.get(mapKey).targets.push({
|
resourcesMap.get(key).targets.push({
|
||||||
resourceId: row.resourceId,
|
resourceId: row.resourceId,
|
||||||
targetId: row.targetId,
|
targetId: row.targetId,
|
||||||
ip: row.ip,
|
ip: row.ip,
|
||||||
@@ -301,9 +296,8 @@ export async function getTraefikConfig(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// get the key and the resource
|
// get the key and the resource
|
||||||
for (const [, resource] of resourcesMap.entries()) {
|
for (const [key, resource] of resourcesMap.entries()) {
|
||||||
const targets = resource.targets as TargetWithSite[];
|
const targets = resource.targets as TargetWithSite[];
|
||||||
const key = resource.key;
|
|
||||||
|
|
||||||
const routerName = `${key}-${resource.name}-router`;
|
const routerName = `${key}-${resource.name}-router`;
|
||||||
const serviceName = `${key}-${resource.name}-service`;
|
const serviceName = `${key}-${resource.name}-service`;
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { eq, and } from "drizzle-orm";
|
|||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import stripe from "#private/lib/stripe";
|
import stripe from "#private/lib/stripe";
|
||||||
import { handleSubscriptionLifesycle } from "../subscriptionLifecycle";
|
import { handleSubscriptionLifesycle } from "../subscriptionLifecycle";
|
||||||
|
import { AudienceIds, moveEmailToAudience } from "#private/lib/resend";
|
||||||
import { getSubType } from "./getSubType";
|
import { getSubType } from "./getSubType";
|
||||||
import privateConfig from "#private/lib/config";
|
import privateConfig from "#private/lib/config";
|
||||||
import { getLicensePriceSet, LicenseId } from "@server/lib/billing/licenses";
|
import { getLicensePriceSet, LicenseId } from "@server/lib/billing/licenses";
|
||||||
@@ -171,7 +172,7 @@ export async function handleSubscriptionCreated(
|
|||||||
const email = orgUserRes.user.email;
|
const email = orgUserRes.user.email;
|
||||||
|
|
||||||
if (email) {
|
if (email) {
|
||||||
// TODO: update user in Sendy
|
moveEmailToAudience(email, AudienceIds.Subscribed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (type === "license") {
|
} else if (type === "license") {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { handleSubscriptionLifesycle } from "../subscriptionLifecycle";
|
import { handleSubscriptionLifesycle } from "../subscriptionLifecycle";
|
||||||
|
import { AudienceIds, moveEmailToAudience } from "#private/lib/resend";
|
||||||
import { getSubType } from "./getSubType";
|
import { getSubType } from "./getSubType";
|
||||||
import stripe from "#private/lib/stripe";
|
import stripe from "#private/lib/stripe";
|
||||||
import privateConfig from "#private/lib/config";
|
import privateConfig from "#private/lib/config";
|
||||||
@@ -108,7 +109,7 @@ export async function handleSubscriptionDeleted(
|
|||||||
const email = orgUserRes.user.email;
|
const email = orgUserRes.user.email;
|
||||||
|
|
||||||
if (email) {
|
if (email) {
|
||||||
// TODO: update user in Sendy
|
moveEmailToAudience(email, AudienceIds.Churned);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (type === "license") {
|
} else if (type === "license") {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import HttpCode from "@server/types/HttpCode";
|
|||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
import { eq, or, and } from "drizzle-orm";
|
import { eq, or, and } from "drizzle-orm";
|
||||||
import { canUserAccessSiteResource } from "@server/auth/canUserAccessSiteResource";
|
import { canUserAccessSiteResource } from "@server/auth/canUserAccessSiteResource";
|
||||||
import { signPublicKey, getOrgCAKeys } from "@server/lib/sshCA";
|
import { signPublicKey, getOrgCAKeys } from "@server/lib/sshCA";
|
||||||
@@ -63,7 +64,6 @@ export type SignSshKeyResponse = {
|
|||||||
sshUsername: string;
|
sshUsername: string;
|
||||||
sshHost: string;
|
sshHost: string;
|
||||||
resourceId: number;
|
resourceId: number;
|
||||||
siteId: number;
|
|
||||||
keyId: string;
|
keyId: string;
|
||||||
validPrincipals: string[];
|
validPrincipals: string[];
|
||||||
validAfter: string;
|
validAfter: string;
|
||||||
@@ -453,7 +453,6 @@ export async function signSshKey(
|
|||||||
sshUsername: usernameToUse,
|
sshUsername: usernameToUse,
|
||||||
sshHost: sshHost,
|
sshHost: sshHost,
|
||||||
resourceId: resource.siteResourceId,
|
resourceId: resource.siteResourceId,
|
||||||
siteId: resource.siteId,
|
|
||||||
keyId: cert.keyId,
|
keyId: cert.keyId,
|
||||||
validPrincipals: cert.validPrincipals,
|
validPrincipals: cert.validPrincipals,
|
||||||
validAfter: cert.validAfter.toISOString(),
|
validAfter: cert.validAfter.toISOString(),
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ const processMessage = async (
|
|||||||
clientId,
|
clientId,
|
||||||
message.type, // Pass message type for granular limiting
|
message.type, // Pass message type for granular limiting
|
||||||
100, // max requests per window
|
100, // max requests per window
|
||||||
100, // max requests per message type per window
|
20, // max requests per message type per window
|
||||||
60 * 1000 // window in milliseconds
|
60 * 1000 // window in milliseconds
|
||||||
);
|
);
|
||||||
if (rateLimitResult.isLimited) {
|
if (rateLimitResult.isLimited) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextFunction, Request, Response } from "express";
|
import { NextFunction, Request, Response } from "express";
|
||||||
import { bannedEmails, bannedIps, db, users } from "@server/db";
|
import { db, users } from "@server/db";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import { email, z } from "zod";
|
import { email, z } from "zod";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
@@ -22,6 +22,7 @@ import { checkValidInvite } from "@server/auth/checkValidInvite";
|
|||||||
import { passwordSchema } from "@server/auth/passwordSchema";
|
import { passwordSchema } from "@server/auth/passwordSchema";
|
||||||
import { UserType } from "@server/types/UserTypes";
|
import { UserType } from "@server/types/UserTypes";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
|
import resend, { AudienceIds, moveEmailToAudience } from "#dynamic/lib/resend";
|
||||||
|
|
||||||
export const signupBodySchema = z.object({
|
export const signupBodySchema = z.object({
|
||||||
email: z.email().toLowerCase(),
|
email: z.email().toLowerCase(),
|
||||||
@@ -65,30 +66,6 @@ export async function signup(
|
|||||||
skipVerificationEmail
|
skipVerificationEmail
|
||||||
} = parsedBody.data;
|
} = parsedBody.data;
|
||||||
|
|
||||||
const [bannedEmail] = await db
|
|
||||||
.select()
|
|
||||||
.from(bannedEmails)
|
|
||||||
.where(eq(bannedEmails.email, email))
|
|
||||||
.limit(1);
|
|
||||||
if (bannedEmail) {
|
|
||||||
return next(
|
|
||||||
createHttpError(HttpCode.FORBIDDEN, "Signup blocked. Do not attempt to continue to use this service.")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.ip) {
|
|
||||||
const [bannedIp] = await db
|
|
||||||
.select()
|
|
||||||
.from(bannedIps)
|
|
||||||
.where(eq(bannedIps.ip, req.ip))
|
|
||||||
.limit(1);
|
|
||||||
if (bannedIp) {
|
|
||||||
return next(
|
|
||||||
createHttpError(HttpCode.FORBIDDEN, "Signup blocked. Do not attempt to continue to use this service.")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const passwordHash = await hashPassword(password);
|
const passwordHash = await hashPassword(password);
|
||||||
const userId = generateId(15);
|
const userId = generateId(15);
|
||||||
|
|
||||||
@@ -236,7 +213,7 @@ export async function signup(
|
|||||||
logger.debug(
|
logger.debug(
|
||||||
`User ${email} opted in to marketing emails during signup.`
|
`User ${email} opted in to marketing emails during signup.`
|
||||||
);
|
);
|
||||||
// TODO: update user in Sendy
|
moveEmailToAudience(email, AudienceIds.SignUps);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.getRawConfig().flags?.require_email_verification) {
|
if (config.getRawConfig().flags?.require_email_verification) {
|
||||||
|
|||||||
@@ -40,8 +40,7 @@ async function queryDomains(orgId: string, limit: number, offset: number) {
|
|||||||
tries: domains.tries,
|
tries: domains.tries,
|
||||||
configManaged: domains.configManaged,
|
configManaged: domains.configManaged,
|
||||||
certResolver: domains.certResolver,
|
certResolver: domains.certResolver,
|
||||||
preferWildcardCert: domains.preferWildcardCert,
|
preferWildcardCert: domains.preferWildcardCert
|
||||||
errorMessage: domains.errorMessage
|
|
||||||
})
|
})
|
||||||
.from(orgDomains)
|
.from(orgDomains)
|
||||||
.where(eq(orgDomains.orgId, orgId))
|
.where(eq(orgDomains.orgId, orgId))
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ export async function generateRelayMappings(exitNode: ExitNode) {
|
|||||||
// Add site as a destination for this client
|
// Add site as a destination for this client
|
||||||
const destination: PeerDestination = {
|
const destination: PeerDestination = {
|
||||||
destinationIP: site.subnet.split("/")[0],
|
destinationIP: site.subnet.split("/")[0],
|
||||||
destinationPort: site.listenPort || 1 // this satisfies gerbil for now but should be reevaluated
|
destinationPort: site.listenPort
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check if this destination is already in the array to avoid duplicates
|
// Check if this destination is already in the array to avoid duplicates
|
||||||
@@ -165,7 +165,7 @@ export async function generateRelayMappings(exitNode: ExitNode) {
|
|||||||
|
|
||||||
const destination: PeerDestination = {
|
const destination: PeerDestination = {
|
||||||
destinationIP: peer.subnet.split("/")[0],
|
destinationIP: peer.subnet.split("/")[0],
|
||||||
destinationPort: peer.listenPort || 1 // this satisfies gerbil for now but should be reevaluated
|
destinationPort: peer.listenPort
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check for duplicates
|
// Check for duplicates
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ export async function updateHolePunch(
|
|||||||
destinations: destinations
|
destinations: destinations
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(error);
|
// logger.error(error); // FIX THIS
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
@@ -262,7 +262,7 @@ export async function updateAndGenerateEndpointDestinations(
|
|||||||
if (site.subnet && site.listenPort) {
|
if (site.subnet && site.listenPort) {
|
||||||
destinations.push({
|
destinations.push({
|
||||||
destinationIP: site.subnet.split("/")[0],
|
destinationIP: site.subnet.split("/")[0],
|
||||||
destinationPort: site.listenPort || 1 // this satisfies gerbil for now but should be reevaluated
|
destinationPort: site.listenPort
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -339,10 +339,10 @@ export async function updateAndGenerateEndpointDestinations(
|
|||||||
handleSiteEndpointChange(newt.siteId, updatedSite.endpoint!);
|
handleSiteEndpointChange(newt.siteId, updatedSite.endpoint!);
|
||||||
}
|
}
|
||||||
|
|
||||||
// if (!updatedSite || !updatedSite.subnet) {
|
if (!updatedSite || !updatedSite.subnet) {
|
||||||
// logger.warn(`Site not found: ${newt.siteId}`);
|
logger.warn(`Site not found: ${newt.siteId}`);
|
||||||
// throw new Error("Site not found");
|
throw new Error("Site not found");
|
||||||
// }
|
}
|
||||||
|
|
||||||
// Find all clients that connect to this site
|
// Find all clients that connect to this site
|
||||||
// const sitesClientPairs = await db
|
// const sitesClientPairs = await db
|
||||||
|
|||||||
@@ -1,15 +1,4 @@
|
|||||||
import {
|
import { clients, clientSiteResourcesAssociationsCache, clientSitesAssociationsCache, db, ExitNode, resources, Site, siteResources, targetHealthCheck, targets } from "@server/db";
|
||||||
clients,
|
|
||||||
clientSiteResourcesAssociationsCache,
|
|
||||||
clientSitesAssociationsCache,
|
|
||||||
db,
|
|
||||||
ExitNode,
|
|
||||||
resources,
|
|
||||||
Site,
|
|
||||||
siteResources,
|
|
||||||
targetHealthCheck,
|
|
||||||
targets
|
|
||||||
} from "@server/db";
|
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { initPeerAddHandshake, updatePeer } from "../olm/peers";
|
import { initPeerAddHandshake, updatePeer } from "../olm/peers";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
@@ -80,42 +69,40 @@ export async function buildClientConfigurationForNewtClient(
|
|||||||
// )
|
// )
|
||||||
// );
|
// );
|
||||||
|
|
||||||
if (!client.clientSitesAssociationsCache.isJitMode) { // if we are adding sites through jit then dont add the site to the olm
|
// update the peer info on the olm
|
||||||
// update the peer info on the olm
|
// if the peer has not been added yet this will be a no-op
|
||||||
// if the peer has not been added yet this will be a no-op
|
await updatePeer(client.clients.clientId, {
|
||||||
await updatePeer(client.clients.clientId, {
|
siteId: site.siteId,
|
||||||
siteId: site.siteId,
|
endpoint: site.endpoint!,
|
||||||
endpoint: site.endpoint!,
|
relayEndpoint: `${exitNode.endpoint}:${config.getRawConfig().gerbil.clients_start_port}`,
|
||||||
relayEndpoint: `${exitNode.endpoint}:${config.getRawConfig().gerbil.clients_start_port}`,
|
publicKey: site.publicKey!,
|
||||||
publicKey: site.publicKey!,
|
serverIP: site.address,
|
||||||
serverIP: site.address,
|
serverPort: site.listenPort
|
||||||
serverPort: site.listenPort
|
// remoteSubnets: generateRemoteSubnets(
|
||||||
// remoteSubnets: generateRemoteSubnets(
|
// allSiteResources.map(
|
||||||
// allSiteResources.map(
|
// ({ siteResources }) => siteResources
|
||||||
// ({ siteResources }) => siteResources
|
// )
|
||||||
// )
|
// ),
|
||||||
// ),
|
// aliases: generateAliasConfig(
|
||||||
// aliases: generateAliasConfig(
|
// allSiteResources.map(
|
||||||
// allSiteResources.map(
|
// ({ siteResources }) => siteResources
|
||||||
// ({ siteResources }) => siteResources
|
// )
|
||||||
// )
|
// )
|
||||||
// )
|
});
|
||||||
});
|
|
||||||
|
|
||||||
// also trigger the peer add handshake in case the peer was not already added to the olm and we need to hole punch
|
// also trigger the peer add handshake in case the peer was not already added to the olm and we need to hole punch
|
||||||
// if it has already been added this will be a no-op
|
// if it has already been added this will be a no-op
|
||||||
await initPeerAddHandshake(
|
await initPeerAddHandshake(
|
||||||
// this will kick off the add peer process for the client
|
// this will kick off the add peer process for the client
|
||||||
client.clients.clientId,
|
client.clients.clientId,
|
||||||
{
|
{
|
||||||
siteId,
|
siteId,
|
||||||
exitNode: {
|
exitNode: {
|
||||||
publicKey: exitNode.publicKey,
|
publicKey: exitNode.publicKey,
|
||||||
endpoint: exitNode.endpoint
|
endpoint: exitNode.endpoint
|
||||||
}
|
|
||||||
}
|
}
|
||||||
);
|
}
|
||||||
}
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
publicKey: client.clients.pubKey!,
|
publicKey: client.clients.pubKey!,
|
||||||
@@ -201,8 +188,7 @@ export async function buildTargetConfigurationForNewtClient(siteId: number) {
|
|||||||
hcTimeout: targetHealthCheck.hcTimeout,
|
hcTimeout: targetHealthCheck.hcTimeout,
|
||||||
hcHeaders: targetHealthCheck.hcHeaders,
|
hcHeaders: targetHealthCheck.hcHeaders,
|
||||||
hcMethod: targetHealthCheck.hcMethod,
|
hcMethod: targetHealthCheck.hcMethod,
|
||||||
hcTlsServerName: targetHealthCheck.hcTlsServerName,
|
hcTlsServerName: targetHealthCheck.hcTlsServerName
|
||||||
hcStatus: targetHealthCheck.hcStatus
|
|
||||||
})
|
})
|
||||||
.from(targets)
|
.from(targets)
|
||||||
.innerJoin(resources, eq(targets.resourceId, resources.resourceId))
|
.innerJoin(resources, eq(targets.resourceId, resources.resourceId))
|
||||||
@@ -275,8 +261,7 @@ export async function buildTargetConfigurationForNewtClient(siteId: number) {
|
|||||||
hcTimeout: target.hcTimeout, // in seconds
|
hcTimeout: target.hcTimeout, // in seconds
|
||||||
hcHeaders: hcHeadersSend,
|
hcHeaders: hcHeadersSend,
|
||||||
hcMethod: target.hcMethod,
|
hcMethod: target.hcMethod,
|
||||||
hcTlsServerName: target.hcTlsServerName,
|
hcTlsServerName: target.hcTlsServerName
|
||||||
hcStatus: target.hcStatus
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -104,11 +104,11 @@ export const handleGetConfigMessage: MessageHandler = async (context) => {
|
|||||||
const payload = {
|
const payload = {
|
||||||
oldDestination: {
|
oldDestination: {
|
||||||
destinationIP: existingSite.subnet?.split("/")[0],
|
destinationIP: existingSite.subnet?.split("/")[0],
|
||||||
destinationPort: existingSite.listenPort || 1 // this satisfies gerbil for now but should be reevaluated
|
destinationPort: existingSite.listenPort
|
||||||
},
|
},
|
||||||
newDestination: {
|
newDestination: {
|
||||||
destinationIP: site.subnet?.split("/")[0],
|
destinationIP: site.subnet?.split("/")[0],
|
||||||
destinationPort: site.listenPort || 1 // this satisfies gerbil for now but should be reevaluated
|
destinationPort: site.listenPort
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,5 @@
|
|||||||
import {
|
import { Client, clientSiteResourcesAssociationsCache, clientSitesAssociationsCache, db, exitNodes, siteResources, sites } from "@server/db";
|
||||||
Client,
|
import { generateAliasConfig, generateRemoteSubnets } from "@server/lib/ip";
|
||||||
clientSiteResourcesAssociationsCache,
|
|
||||||
clientSitesAssociationsCache,
|
|
||||||
db,
|
|
||||||
exitNodes,
|
|
||||||
siteResources,
|
|
||||||
sites
|
|
||||||
} from "@server/db";
|
|
||||||
import {
|
|
||||||
Alias,
|
|
||||||
generateAliasConfig,
|
|
||||||
generateRemoteSubnets
|
|
||||||
} from "@server/lib/ip";
|
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { addPeer, deletePeer } from "../newt/peers";
|
import { addPeer, deletePeer } from "../newt/peers";
|
||||||
@@ -20,19 +8,9 @@ import config from "@server/lib/config";
|
|||||||
export async function buildSiteConfigurationForOlmClient(
|
export async function buildSiteConfigurationForOlmClient(
|
||||||
client: Client,
|
client: Client,
|
||||||
publicKey: string | null,
|
publicKey: string | null,
|
||||||
relay: boolean,
|
relay: boolean
|
||||||
jitMode: boolean = false
|
|
||||||
) {
|
) {
|
||||||
const siteConfigurations: {
|
const siteConfigurations = [];
|
||||||
siteId: number;
|
|
||||||
name?: string
|
|
||||||
endpoint?: string
|
|
||||||
publicKey?: string
|
|
||||||
serverIP?: string | null
|
|
||||||
serverPort?: number | null
|
|
||||||
remoteSubnets?: string[];
|
|
||||||
aliases: Alias[];
|
|
||||||
}[] = [];
|
|
||||||
|
|
||||||
// Get all sites data
|
// Get all sites data
|
||||||
const sitesData = await db
|
const sitesData = await db
|
||||||
@@ -49,40 +27,6 @@ export async function buildSiteConfigurationForOlmClient(
|
|||||||
sites: site,
|
sites: site,
|
||||||
clientSitesAssociationsCache: association
|
clientSitesAssociationsCache: association
|
||||||
} of sitesData) {
|
} of sitesData) {
|
||||||
const allSiteResources = await db // only get the site resources that this client has access to
|
|
||||||
.select()
|
|
||||||
.from(siteResources)
|
|
||||||
.innerJoin(
|
|
||||||
clientSiteResourcesAssociationsCache,
|
|
||||||
eq(
|
|
||||||
siteResources.siteResourceId,
|
|
||||||
clientSiteResourcesAssociationsCache.siteResourceId
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(siteResources.siteId, site.siteId),
|
|
||||||
eq(
|
|
||||||
clientSiteResourcesAssociationsCache.clientId,
|
|
||||||
client.clientId
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (jitMode) {
|
|
||||||
// Add site configuration to the array
|
|
||||||
siteConfigurations.push({
|
|
||||||
siteId: site.siteId,
|
|
||||||
// remoteSubnets: generateRemoteSubnets(
|
|
||||||
// allSiteResources.map(({ siteResources }) => siteResources)
|
|
||||||
// ),
|
|
||||||
aliases: generateAliasConfig(
|
|
||||||
allSiteResources.map(({ siteResources }) => siteResources)
|
|
||||||
)
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!site.exitNodeId) {
|
if (!site.exitNodeId) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`Site ${site.siteId} does not have exit node, skipping`
|
`Site ${site.siteId} does not have exit node, skipping`
|
||||||
@@ -98,13 +42,6 @@ export async function buildSiteConfigurationForOlmClient(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!site.publicKey || site.publicKey == "") { // the site is not ready to accept new peers
|
|
||||||
logger.warn(
|
|
||||||
`Site ${site.siteId} has no public key, skipping`
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// if (site.lastHolePunch && now - site.lastHolePunch > 6 && relay) {
|
// if (site.lastHolePunch && now - site.lastHolePunch > 6 && relay) {
|
||||||
// logger.warn(
|
// logger.warn(
|
||||||
// `Site ${site.siteId} last hole punch is too old, skipping`
|
// `Site ${site.siteId} last hole punch is too old, skipping`
|
||||||
@@ -166,6 +103,26 @@ export async function buildSiteConfigurationForOlmClient(
|
|||||||
relayEndpoint = `${exitNode.endpoint}:${config.getRawConfig().gerbil.clients_start_port}`;
|
relayEndpoint = `${exitNode.endpoint}:${config.getRawConfig().gerbil.clients_start_port}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const allSiteResources = await db // only get the site resources that this client has access to
|
||||||
|
.select()
|
||||||
|
.from(siteResources)
|
||||||
|
.innerJoin(
|
||||||
|
clientSiteResourcesAssociationsCache,
|
||||||
|
eq(
|
||||||
|
siteResources.siteResourceId,
|
||||||
|
clientSiteResourcesAssociationsCache.siteResourceId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(siteResources.siteId, site.siteId),
|
||||||
|
eq(
|
||||||
|
clientSiteResourcesAssociationsCache.clientId,
|
||||||
|
client.clientId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
// Add site configuration to the array
|
// Add site configuration to the array
|
||||||
siteConfigurations.push({
|
siteConfigurations.push({
|
||||||
siteId: site.siteId,
|
siteId: site.siteId,
|
||||||
|
|||||||
@@ -17,8 +17,6 @@ import { getUserDeviceName } from "@server/db/names";
|
|||||||
import { buildSiteConfigurationForOlmClient } from "./buildConfiguration";
|
import { buildSiteConfigurationForOlmClient } from "./buildConfiguration";
|
||||||
import { OlmErrorCodes, sendOlmError } from "./error";
|
import { OlmErrorCodes, sendOlmError } from "./error";
|
||||||
import { handleFingerprintInsertion } from "./fingerprintingUtils";
|
import { handleFingerprintInsertion } from "./fingerprintingUtils";
|
||||||
import { Alias } from "@server/lib/ip";
|
|
||||||
import { build } from "@server/build";
|
|
||||||
|
|
||||||
export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
||||||
logger.info("Handling register olm message!");
|
logger.info("Handling register olm message!");
|
||||||
@@ -209,32 +207,6 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all sites data
|
|
||||||
const sitesCountResult = await db
|
|
||||||
.select({ count: count() })
|
|
||||||
.from(sites)
|
|
||||||
.innerJoin(
|
|
||||||
clientSitesAssociationsCache,
|
|
||||||
eq(sites.siteId, clientSitesAssociationsCache.siteId)
|
|
||||||
)
|
|
||||||
.where(eq(clientSitesAssociationsCache.clientId, client.clientId));
|
|
||||||
|
|
||||||
// Extract the count value from the result array
|
|
||||||
const sitesCount =
|
|
||||||
sitesCountResult.length > 0 ? sitesCountResult[0].count : 0;
|
|
||||||
|
|
||||||
// Prepare an array to store site configurations
|
|
||||||
logger.debug(`Found ${sitesCount} sites for client ${client.clientId}`);
|
|
||||||
|
|
||||||
let jitMode = true;
|
|
||||||
if (sitesCount > 250 && build == "saas") {
|
|
||||||
// THIS IS THE MAX ON THE BUSINESS TIER
|
|
||||||
// we have too many sites
|
|
||||||
// If we have too many sites we need to drop into fully JIT mode by not sending any of the sites
|
|
||||||
logger.info("Too many sites (%d), dropping into JIT mode", sitesCount);
|
|
||||||
jitMode = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Olm client ID: ${client.clientId}, Public Key: ${publicKey}, Relay: ${relay}`
|
`Olm client ID: ${client.clientId}, Public Key: ${publicKey}, Relay: ${relay}`
|
||||||
);
|
);
|
||||||
@@ -261,12 +233,28 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
|||||||
await db
|
await db
|
||||||
.update(clientSitesAssociationsCache)
|
.update(clientSitesAssociationsCache)
|
||||||
.set({
|
.set({
|
||||||
isRelayed: relay == true,
|
isRelayed: relay == true
|
||||||
isJitMode: jitMode
|
|
||||||
})
|
})
|
||||||
.where(eq(clientSitesAssociationsCache.clientId, client.clientId));
|
.where(eq(clientSitesAssociationsCache.clientId, client.clientId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get all sites data
|
||||||
|
const sitesCountResult = await db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(sites)
|
||||||
|
.innerJoin(
|
||||||
|
clientSitesAssociationsCache,
|
||||||
|
eq(sites.siteId, clientSitesAssociationsCache.siteId)
|
||||||
|
)
|
||||||
|
.where(eq(clientSitesAssociationsCache.clientId, client.clientId));
|
||||||
|
|
||||||
|
// Extract the count value from the result array
|
||||||
|
const sitesCount =
|
||||||
|
sitesCountResult.length > 0 ? sitesCountResult[0].count : 0;
|
||||||
|
|
||||||
|
// Prepare an array to store site configurations
|
||||||
|
logger.debug(`Found ${sitesCount} sites for client ${client.clientId}`);
|
||||||
|
|
||||||
// this prevents us from accepting a register from an olm that has not hole punched yet.
|
// this prevents us from accepting a register from an olm that has not hole punched yet.
|
||||||
// the olm will pump the register so we can keep checking
|
// the olm will pump the register so we can keep checking
|
||||||
// TODO: I still think there is a better way to do this rather than locking it out here but ???
|
// TODO: I still think there is a better way to do this rather than locking it out here but ???
|
||||||
@@ -277,14 +265,19 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOTE: its important that the client here is the old client and the public key is the new key
|
// NOTE: its important that the client here is the old client and the public key is the new key
|
||||||
const siteConfigurations = await buildSiteConfigurationForOlmClient(
|
const siteConfigurations = await buildSiteConfigurationForOlmClient(
|
||||||
client,
|
client,
|
||||||
publicKey,
|
publicKey,
|
||||||
relay,
|
relay
|
||||||
jitMode
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// REMOVED THIS SO IT CREATES THE INTERFACE AND JUST WAITS FOR THE SITES
|
||||||
|
// if (siteConfigurations.length === 0) {
|
||||||
|
// logger.warn("No valid site configurations found");
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
|
||||||
// Return connect message with all site configurations
|
// Return connect message with all site configurations
|
||||||
return {
|
return {
|
||||||
message: {
|
message: {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export const handleOlmRelayMessage: MessageHandler = async (context) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!olm.clientId) {
|
if (!olm.clientId) {
|
||||||
logger.warn("Olm has no client!");
|
logger.warn("Olm has no site!"); // TODO: Maybe we create the site here?
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ export const handleOlmRelayMessage: MessageHandler = async (context) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { siteId, chainId } = message.data;
|
const { siteId } = message.data;
|
||||||
|
|
||||||
// Get the site
|
// Get the site
|
||||||
const [site] = await db
|
const [site] = await db
|
||||||
@@ -90,8 +90,7 @@ export const handleOlmRelayMessage: MessageHandler = async (context) => {
|
|||||||
data: {
|
data: {
|
||||||
siteId: siteId,
|
siteId: siteId,
|
||||||
relayEndpoint: exitNode.endpoint,
|
relayEndpoint: exitNode.endpoint,
|
||||||
relayPort: config.getRawConfig().gerbil.clients_start_port,
|
relayPort: config.getRawConfig().gerbil.clients_start_port
|
||||||
chainId
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
broadcast: false,
|
broadcast: false,
|
||||||
|
|||||||
@@ -1,241 +0,0 @@
|
|||||||
import {
|
|
||||||
clientSiteResourcesAssociationsCache,
|
|
||||||
clientSitesAssociationsCache,
|
|
||||||
db,
|
|
||||||
exitNodes,
|
|
||||||
Site,
|
|
||||||
siteResources
|
|
||||||
} from "@server/db";
|
|
||||||
import { MessageHandler } from "@server/routers/ws";
|
|
||||||
import { clients, Olm, sites } from "@server/db";
|
|
||||||
import { and, eq, or } from "drizzle-orm";
|
|
||||||
import logger from "@server/logger";
|
|
||||||
import { initPeerAddHandshake } from "./peers";
|
|
||||||
|
|
||||||
export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
|
||||||
context
|
|
||||||
) => {
|
|
||||||
logger.info("Handling register olm message!");
|
|
||||||
const { message, client: c, sendToClient } = context;
|
|
||||||
const olm = c as Olm;
|
|
||||||
|
|
||||||
if (!olm) {
|
|
||||||
logger.warn("Olm not found");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!olm.clientId) {
|
|
||||||
logger.warn("Olm has no client!"); // TODO: Maybe we create the site here?
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const clientId = olm.clientId;
|
|
||||||
|
|
||||||
const [client] = await db
|
|
||||||
.select()
|
|
||||||
.from(clients)
|
|
||||||
.where(eq(clients.clientId, clientId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!client) {
|
|
||||||
logger.warn("Client not found");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { siteId, resourceId, chainId } = message.data;
|
|
||||||
|
|
||||||
let site: Site | null = null;
|
|
||||||
if (siteId) {
|
|
||||||
// get the site
|
|
||||||
const [siteRes] = await db
|
|
||||||
.select()
|
|
||||||
.from(sites)
|
|
||||||
.where(eq(sites.siteId, siteId))
|
|
||||||
.limit(1);
|
|
||||||
if (siteRes) {
|
|
||||||
site = siteRes;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resourceId && !site) {
|
|
||||||
const resources = await db
|
|
||||||
.select()
|
|
||||||
.from(siteResources)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
or(
|
|
||||||
eq(siteResources.niceId, resourceId),
|
|
||||||
eq(siteResources.alias, resourceId)
|
|
||||||
),
|
|
||||||
eq(siteResources.orgId, client.orgId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!resources || resources.length === 0) {
|
|
||||||
logger.error(`handleOlmServerPeerAddMessage: Resource not found`);
|
|
||||||
// cancel the request from the olm side to not keep doing this
|
|
||||||
await sendToClient(
|
|
||||||
olm.olmId,
|
|
||||||
{
|
|
||||||
type: "olm/wg/peer/chain/cancel",
|
|
||||||
data: {
|
|
||||||
chainId
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ incrementConfigVersion: false }
|
|
||||||
).catch((error) => {
|
|
||||||
logger.warn(`Error sending message:`, error);
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resources.length > 1) {
|
|
||||||
// error but this should not happen because the nice id cant contain a dot and the alias has to have a dot and both have to be unique within the org so there should never be multiple matches
|
|
||||||
logger.error(
|
|
||||||
`handleOlmServerPeerAddMessage: Multiple resources found matching the criteria`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const resource = resources[0];
|
|
||||||
|
|
||||||
const currentResourceAssociationCaches = await db
|
|
||||||
.select()
|
|
||||||
.from(clientSiteResourcesAssociationsCache)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(
|
|
||||||
clientSiteResourcesAssociationsCache.siteResourceId,
|
|
||||||
resource.siteResourceId
|
|
||||||
),
|
|
||||||
eq(
|
|
||||||
clientSiteResourcesAssociationsCache.clientId,
|
|
||||||
client.clientId
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (currentResourceAssociationCaches.length === 0) {
|
|
||||||
logger.error(
|
|
||||||
`handleOlmServerPeerAddMessage: Client ${client.clientId} does not have access to resource ${resource.siteResourceId}`
|
|
||||||
);
|
|
||||||
// cancel the request from the olm side to not keep doing this
|
|
||||||
await sendToClient(
|
|
||||||
olm.olmId,
|
|
||||||
{
|
|
||||||
type: "olm/wg/peer/chain/cancel",
|
|
||||||
data: {
|
|
||||||
chainId
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ incrementConfigVersion: false }
|
|
||||||
).catch((error) => {
|
|
||||||
logger.warn(`Error sending message:`, error);
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const siteIdFromResource = resource.siteId;
|
|
||||||
|
|
||||||
// get the site
|
|
||||||
const [siteRes] = await db
|
|
||||||
.select()
|
|
||||||
.from(sites)
|
|
||||||
.where(eq(sites.siteId, siteIdFromResource));
|
|
||||||
if (!siteRes) {
|
|
||||||
logger.error(
|
|
||||||
`handleOlmServerPeerAddMessage: Site with ID ${site} not found`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
site = siteRes;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!site) {
|
|
||||||
logger.error(`handleOlmServerPeerAddMessage: Site not found`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// check if the client can access this site using the cache
|
|
||||||
const currentSiteAssociationCaches = await db
|
|
||||||
.select()
|
|
||||||
.from(clientSitesAssociationsCache)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(clientSitesAssociationsCache.clientId, client.clientId),
|
|
||||||
eq(clientSitesAssociationsCache.siteId, site.siteId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (currentSiteAssociationCaches.length === 0) {
|
|
||||||
logger.error(
|
|
||||||
`handleOlmServerPeerAddMessage: Client ${client.clientId} does not have access to site ${site.siteId}`
|
|
||||||
);
|
|
||||||
// cancel the request from the olm side to not keep doing this
|
|
||||||
await sendToClient(
|
|
||||||
olm.olmId,
|
|
||||||
{
|
|
||||||
type: "olm/wg/peer/chain/cancel",
|
|
||||||
data: {
|
|
||||||
chainId
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ incrementConfigVersion: false }
|
|
||||||
).catch((error) => {
|
|
||||||
logger.warn(`Error sending message:`, error);
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!site.exitNodeId) {
|
|
||||||
logger.error(
|
|
||||||
`handleOlmServerPeerAddMessage: Site with ID ${site.siteId} has no exit node`
|
|
||||||
);
|
|
||||||
// cancel the request from the olm side to not keep doing this
|
|
||||||
await sendToClient(
|
|
||||||
olm.olmId,
|
|
||||||
{
|
|
||||||
type: "olm/wg/peer/chain/cancel",
|
|
||||||
data: {
|
|
||||||
chainId
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ incrementConfigVersion: false }
|
|
||||||
).catch((error) => {
|
|
||||||
logger.warn(`Error sending message:`, error);
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// get the exit node from the side
|
|
||||||
const [exitNode] = await db
|
|
||||||
.select()
|
|
||||||
.from(exitNodes)
|
|
||||||
.where(eq(exitNodes.exitNodeId, site.exitNodeId));
|
|
||||||
|
|
||||||
if (!exitNode) {
|
|
||||||
logger.error(
|
|
||||||
`handleOlmServerPeerAddMessage: Site with ID ${site.siteId} has no exit node`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// also trigger the peer add handshake in case the peer was not already added to the olm and we need to hole punch
|
|
||||||
// if it has already been added this will be a no-op
|
|
||||||
await initPeerAddHandshake(
|
|
||||||
// this will kick off the add peer process for the client
|
|
||||||
client.clientId,
|
|
||||||
{
|
|
||||||
siteId: site.siteId,
|
|
||||||
exitNode: {
|
|
||||||
publicKey: exitNode.publicKey,
|
|
||||||
endpoint: exitNode.endpoint
|
|
||||||
}
|
|
||||||
},
|
|
||||||
olm.olmId,
|
|
||||||
chainId
|
|
||||||
);
|
|
||||||
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
@@ -54,7 +54,7 @@ export const handleOlmServerPeerAddMessage: MessageHandler = async (
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { siteId, chainId } = message.data;
|
const { siteId } = message.data;
|
||||||
|
|
||||||
// get the site
|
// get the site
|
||||||
const [site] = await db
|
const [site] = await db
|
||||||
@@ -179,8 +179,7 @@ export const handleOlmServerPeerAddMessage: MessageHandler = async (
|
|||||||
),
|
),
|
||||||
aliases: generateAliasConfig(
|
aliases: generateAliasConfig(
|
||||||
allSiteResources.map(({ siteResources }) => siteResources)
|
allSiteResources.map(({ siteResources }) => siteResources)
|
||||||
),
|
)
|
||||||
chainId: chainId,
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
broadcast: false,
|
broadcast: false,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export const handleOlmUnRelayMessage: MessageHandler = async (context) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!olm.clientId) {
|
if (!olm.clientId) {
|
||||||
logger.warn("Olm has no client!");
|
logger.warn("Olm has no site!"); // TODO: Maybe we create the site here?
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ export const handleOlmUnRelayMessage: MessageHandler = async (context) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { siteId, chainId } = message.data;
|
const { siteId } = message.data;
|
||||||
|
|
||||||
// Get the site
|
// Get the site
|
||||||
const [site] = await db
|
const [site] = await db
|
||||||
@@ -87,8 +87,7 @@ export const handleOlmUnRelayMessage: MessageHandler = async (context) => {
|
|||||||
type: "olm/wg/peer/unrelay",
|
type: "olm/wg/peer/unrelay",
|
||||||
data: {
|
data: {
|
||||||
siteId: siteId,
|
siteId: siteId,
|
||||||
endpoint: site.endpoint,
|
endpoint: site.endpoint
|
||||||
chainId
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
broadcast: false,
|
broadcast: false,
|
||||||
|
|||||||
@@ -11,4 +11,3 @@ export * from "./handleOlmServerPeerAddMessage";
|
|||||||
export * from "./handleOlmUnRelayMessage";
|
export * from "./handleOlmUnRelayMessage";
|
||||||
export * from "./recoverOlmWithFingerprint";
|
export * from "./recoverOlmWithFingerprint";
|
||||||
export * from "./handleOlmDisconnectingMessage";
|
export * from "./handleOlmDisconnectingMessage";
|
||||||
export * from "./handleOlmServerInitAddPeerHandshake";
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { sendToClient } from "#dynamic/routers/ws";
|
import { sendToClient } from "#dynamic/routers/ws";
|
||||||
import { clientSitesAssociationsCache, db, olms } from "@server/db";
|
import { db, olms } from "@server/db";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { Alias } from "yaml";
|
import { Alias } from "yaml";
|
||||||
|
|
||||||
export async function addPeer(
|
export async function addPeer(
|
||||||
@@ -149,8 +149,7 @@ export async function initPeerAddHandshake(
|
|||||||
endpoint: string;
|
endpoint: string;
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
olmId?: string,
|
olmId?: string
|
||||||
chainId?: string
|
|
||||||
) {
|
) {
|
||||||
if (!olmId) {
|
if (!olmId) {
|
||||||
const [olm] = await db
|
const [olm] = await db
|
||||||
@@ -174,8 +173,7 @@ export async function initPeerAddHandshake(
|
|||||||
publicKey: peer.exitNode.publicKey,
|
publicKey: peer.exitNode.publicKey,
|
||||||
relayPort: config.getRawConfig().gerbil.clients_start_port,
|
relayPort: config.getRawConfig().gerbil.clients_start_port,
|
||||||
endpoint: peer.exitNode.endpoint
|
endpoint: peer.exitNode.endpoint
|
||||||
},
|
}
|
||||||
chainId
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ incrementConfigVersion: true }
|
{ incrementConfigVersion: true }
|
||||||
@@ -183,17 +181,6 @@ export async function initPeerAddHandshake(
|
|||||||
logger.warn(`Error sending message:`, error);
|
logger.warn(`Error sending message:`, error);
|
||||||
});
|
});
|
||||||
|
|
||||||
// update the clientSiteAssociationsCache to make the isJitMode flag false so that JIT mode is disabled for this site if it restarts or something after the connection
|
|
||||||
await db
|
|
||||||
.update(clientSitesAssociationsCache)
|
|
||||||
.set({ isJitMode: false })
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(clientSitesAssociationsCache.clientId, clientId),
|
|
||||||
eq(clientSitesAssociationsCache.siteId, peer.siteId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`Initiated peer add handshake for site ${peer.siteId} to olm ${olmId}`
|
`Initiated peer add handshake for site ${peer.siteId} to olm ${olmId}`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,8 +15,7 @@ import {
|
|||||||
startOlmOfflineChecker,
|
startOlmOfflineChecker,
|
||||||
handleOlmServerPeerAddMessage,
|
handleOlmServerPeerAddMessage,
|
||||||
handleOlmUnRelayMessage,
|
handleOlmUnRelayMessage,
|
||||||
handleOlmDisconnecingMessage,
|
handleOlmDisconnecingMessage
|
||||||
handleOlmServerInitAddPeerHandshake
|
|
||||||
} from "../olm";
|
} from "../olm";
|
||||||
import { handleHealthcheckStatusMessage } from "../target";
|
import { handleHealthcheckStatusMessage } from "../target";
|
||||||
import { handleRoundTripMessage } from "./handleRoundTripMessage";
|
import { handleRoundTripMessage } from "./handleRoundTripMessage";
|
||||||
@@ -24,7 +23,6 @@ import { MessageHandler } from "./types";
|
|||||||
|
|
||||||
export const messageHandlers: Record<string, MessageHandler> = {
|
export const messageHandlers: Record<string, MessageHandler> = {
|
||||||
"olm/wg/server/peer/add": handleOlmServerPeerAddMessage,
|
"olm/wg/server/peer/add": handleOlmServerPeerAddMessage,
|
||||||
"olm/wg/server/peer/init": handleOlmServerInitAddPeerHandshake,
|
|
||||||
"olm/wg/register": handleOlmRegisterMessage,
|
"olm/wg/register": handleOlmRegisterMessage,
|
||||||
"olm/wg/relay": handleOlmRelayMessage,
|
"olm/wg/relay": handleOlmRelayMessage,
|
||||||
"olm/wg/unrelay": handleOlmUnRelayMessage,
|
"olm/wg/unrelay": handleOlmUnRelayMessage,
|
||||||
|
|||||||
@@ -35,7 +35,11 @@ import {
|
|||||||
} from "@app/components/Credenza";
|
} from "@app/components/Credenza";
|
||||||
import { cn } from "@app/lib/cn";
|
import { cn } from "@app/lib/cn";
|
||||||
import { CreditCard, ExternalLink, Check, AlertTriangle } from "lucide-react";
|
import { CreditCard, ExternalLink, Check, AlertTriangle } from "lucide-react";
|
||||||
import { Alert, AlertTitle, AlertDescription } from "@app/components/ui/alert";
|
import {
|
||||||
|
Alert,
|
||||||
|
AlertTitle,
|
||||||
|
AlertDescription
|
||||||
|
} from "@app/components/ui/alert";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
@@ -65,7 +69,6 @@ type PlanOption = {
|
|||||||
price: string;
|
price: string;
|
||||||
priceDetail?: string;
|
priceDetail?: string;
|
||||||
tierType: Tier | null;
|
tierType: Tier | null;
|
||||||
features: string[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const planOptions: PlanOption[] = [
|
const planOptions: PlanOption[] = [
|
||||||
@@ -73,87 +76,41 @@ const planOptions: PlanOption[] = [
|
|||||||
id: "basic",
|
id: "basic",
|
||||||
name: "Basic",
|
name: "Basic",
|
||||||
price: "Free",
|
price: "Free",
|
||||||
tierType: null,
|
tierType: null
|
||||||
features: [
|
|
||||||
"Basic Pangolin features",
|
|
||||||
"Free provided domains",
|
|
||||||
"Web-based proxy resources",
|
|
||||||
"Private resources and clients",
|
|
||||||
"Peer-to-peer connections"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "home",
|
id: "home",
|
||||||
name: "Home",
|
name: "Home",
|
||||||
price: "$12.50",
|
price: "$12.50",
|
||||||
priceDetail: "/ month",
|
priceDetail: "/ month",
|
||||||
tierType: "tier1",
|
tierType: "tier1"
|
||||||
features: [
|
|
||||||
"Everything in Basic",
|
|
||||||
"OAuth2/OIDC, Google, & Azure SSO",
|
|
||||||
"Bring your own identity provider",
|
|
||||||
"Pangolin SSH",
|
|
||||||
"Custom branding",
|
|
||||||
"Device admin approvals"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "team",
|
id: "team",
|
||||||
name: "Team",
|
name: "Team",
|
||||||
price: "$4",
|
price: "$4",
|
||||||
priceDetail: "per user / month",
|
priceDetail: "per user / month",
|
||||||
tierType: "tier2",
|
tierType: "tier2"
|
||||||
features: [
|
|
||||||
"Everything in Basic",
|
|
||||||
"Custom domains",
|
|
||||||
"OAuth2/OIDC, Google, & Azure SSO",
|
|
||||||
"Access and action audit logs",
|
|
||||||
"Device posture information"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "business",
|
id: "business",
|
||||||
name: "Business",
|
name: "Business",
|
||||||
price: "$9",
|
price: "$9",
|
||||||
priceDetail: "per user / month",
|
priceDetail: "per user / month",
|
||||||
tierType: "tier3",
|
tierType: "tier3"
|
||||||
features: [
|
|
||||||
"Everything in Team",
|
|
||||||
"Multiple organizations (multi-tenancy)",
|
|
||||||
"Auto-provisioning via IdP",
|
|
||||||
"Pangolin SSH",
|
|
||||||
"Device approvals",
|
|
||||||
"Custom branding",
|
|
||||||
"Business support"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "enterprise",
|
id: "enterprise",
|
||||||
name: "Enterprise",
|
name: "Enterprise",
|
||||||
price: "Custom",
|
price: "Custom",
|
||||||
tierType: null,
|
tierType: null
|
||||||
features: [
|
|
||||||
"Everything in Business",
|
|
||||||
"Custom limits",
|
|
||||||
"Priority support and SLA",
|
|
||||||
"Log push and export",
|
|
||||||
"Private and Gov-Cloud deployment options",
|
|
||||||
"Dedicated, premium relay/exit nodes",
|
|
||||||
"Pay by invoice "
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
// Tier limits mapping derived from limit sets
|
// Tier limits mapping derived from limit sets
|
||||||
const tierLimits: Record<
|
const tierLimits: Record<
|
||||||
Tier | "basic",
|
Tier | "basic",
|
||||||
{
|
{ users: number; sites: number; domains: number; remoteNodes: number; organizations: number }
|
||||||
users: number;
|
|
||||||
sites: number;
|
|
||||||
domains: number;
|
|
||||||
remoteNodes: number;
|
|
||||||
organizations: number;
|
|
||||||
}
|
|
||||||
> = {
|
> = {
|
||||||
basic: {
|
basic: {
|
||||||
users: freeLimitSet[FeatureId.USERS]?.value ?? 0,
|
users: freeLimitSet[FeatureId.USERS]?.value ?? 0,
|
||||||
@@ -506,43 +463,31 @@ export default function BillingPage() {
|
|||||||
const isProblematicState = hasProblematicSubscription();
|
const isProblematicState = hasProblematicSubscription();
|
||||||
|
|
||||||
// Get user-friendly subscription status message
|
// Get user-friendly subscription status message
|
||||||
const getSubscriptionStatusMessage = (): {
|
const getSubscriptionStatusMessage = (): { title: string; description: string } | null => {
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
} | null => {
|
|
||||||
if (!tierSubscription?.subscription || !isProblematicState) return null;
|
if (!tierSubscription?.subscription || !isProblematicState) return null;
|
||||||
|
|
||||||
const status = tierSubscription.subscription.status;
|
const status = tierSubscription.subscription.status;
|
||||||
|
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "past_due":
|
case "past_due":
|
||||||
return {
|
return {
|
||||||
title: t("billingPastDueTitle") || "Payment Past Due",
|
title: t("billingPastDueTitle") || "Payment Past Due",
|
||||||
description:
|
description: t("billingPastDueDescription") || "Your payment is past due. Please update your payment method to continue using your current plan features. If not resolved, your subscription will be canceled and you'll be reverted to the free tier."
|
||||||
t("billingPastDueDescription") ||
|
|
||||||
"Your payment is past due. Please update your payment method to continue using your current plan features. If not resolved, your subscription will be canceled and you'll be reverted to the free tier."
|
|
||||||
};
|
};
|
||||||
case "unpaid":
|
case "unpaid":
|
||||||
return {
|
return {
|
||||||
title: t("billingUnpaidTitle") || "Subscription Unpaid",
|
title: t("billingUnpaidTitle") || "Subscription Unpaid",
|
||||||
description:
|
description: t("billingUnpaidDescription") || "Your subscription is unpaid and you have been reverted to the free tier. Please update your payment method to restore your subscription."
|
||||||
t("billingUnpaidDescription") ||
|
|
||||||
"Your subscription is unpaid and you have been reverted to the free tier. Please update your payment method to restore your subscription."
|
|
||||||
};
|
};
|
||||||
case "incomplete":
|
case "incomplete":
|
||||||
return {
|
return {
|
||||||
title: t("billingIncompleteTitle") || "Payment Incomplete",
|
title: t("billingIncompleteTitle") || "Payment Incomplete",
|
||||||
description:
|
description: t("billingIncompleteDescription") || "Your payment is incomplete. Please complete the payment process to activate your subscription."
|
||||||
t("billingIncompleteDescription") ||
|
|
||||||
"Your payment is incomplete. Please complete the payment process to activate your subscription."
|
|
||||||
};
|
};
|
||||||
case "incomplete_expired":
|
case "incomplete_expired":
|
||||||
return {
|
return {
|
||||||
title:
|
title: t("billingIncompleteExpiredTitle") || "Payment Expired",
|
||||||
t("billingIncompleteExpiredTitle") || "Payment Expired",
|
description: t("billingIncompleteExpiredDescription") || "Your payment was never completed and has expired. You have been reverted to the free tier. Please subscribe again to restore access to paid features."
|
||||||
description:
|
|
||||||
t("billingIncompleteExpiredDescription") ||
|
|
||||||
"Your payment was never completed and has expired. You have been reverted to the free tier. Please subscribe again to restore access to paid features."
|
|
||||||
};
|
};
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
@@ -564,11 +509,7 @@ export default function BillingPage() {
|
|||||||
|
|
||||||
if (plan.id === currentPlanId) {
|
if (plan.id === currentPlanId) {
|
||||||
// If it's the basic plan (basic with no subscription), show as current but disabled
|
// If it's the basic plan (basic with no subscription), show as current but disabled
|
||||||
if (
|
if (plan.id === "basic" && !hasSubscription && !isProblematicState) {
|
||||||
plan.id === "basic" &&
|
|
||||||
!hasSubscription &&
|
|
||||||
!isProblematicState
|
|
||||||
) {
|
|
||||||
return {
|
return {
|
||||||
label: "Current Plan",
|
label: "Current Plan",
|
||||||
action: () => {},
|
action: () => {},
|
||||||
@@ -691,9 +632,7 @@ export default function BillingPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 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"): Array<{
|
||||||
targetTier: Tier | "basic"
|
|
||||||
): Array<{
|
|
||||||
feature: string;
|
feature: string;
|
||||||
currentUsage: number;
|
currentUsage: number;
|
||||||
newLimit: number;
|
newLimit: number;
|
||||||
@@ -748,10 +687,7 @@ export default function BillingPage() {
|
|||||||
|
|
||||||
// Check organizations
|
// Check organizations
|
||||||
const organizationsUsage = getUsageValue(ORGINIZATIONS);
|
const organizationsUsage = getUsageValue(ORGINIZATIONS);
|
||||||
if (
|
if (limits.organizations > 0 && organizationsUsage > limits.organizations) {
|
||||||
limits.organizations > 0 &&
|
|
||||||
organizationsUsage > limits.organizations
|
|
||||||
) {
|
|
||||||
violations.push({
|
violations.push({
|
||||||
feature: "Organizations",
|
feature: "Organizations",
|
||||||
currentUsage: organizationsUsage,
|
currentUsage: organizationsUsage,
|
||||||
@@ -776,15 +712,17 @@ export default function BillingPage() {
|
|||||||
{isProblematicState && statusMessage && (
|
{isProblematicState && statusMessage && (
|
||||||
<Alert variant="destructive" className="mb-6">
|
<Alert variant="destructive" className="mb-6">
|
||||||
<AlertTriangle className="h-4 w-4" />
|
<AlertTriangle className="h-4 w-4" />
|
||||||
<AlertTitle>{statusMessage.title}</AlertTitle>
|
<AlertTitle>
|
||||||
|
{statusMessage.title}
|
||||||
|
</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
{statusMessage.description}{" "}
|
{statusMessage.description}
|
||||||
|
{" "}
|
||||||
<button
|
<button
|
||||||
onClick={handleModifySubscription}
|
onClick={handleModifySubscription}
|
||||||
className="underline font-semibold hover:no-underline"
|
className="underline font-semibold hover:no-underline"
|
||||||
>
|
>
|
||||||
{t("billingManageSubscription") ||
|
{t("billingManageSubscription") || "Manage your subscription"}
|
||||||
"Manage your subscription"}
|
|
||||||
</button>
|
</button>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
@@ -834,10 +772,7 @@ export default function BillingPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
{isProblematicState &&
|
{isProblematicState && planAction.disabled && !isCurrentPlan && plan.id !== "enterprise" ? (
|
||||||
planAction.disabled &&
|
|
||||||
!isCurrentPlan &&
|
|
||||||
plan.id !== "enterprise" ? (
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div>
|
<div>
|
||||||
@@ -849,29 +784,18 @@ export default function BillingPage() {
|
|||||||
}
|
}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={
|
onClick={planAction.action}
|
||||||
planAction.action
|
|
||||||
}
|
|
||||||
disabled={
|
disabled={
|
||||||
isLoading ||
|
isLoading || planAction.disabled
|
||||||
planAction.disabled
|
|
||||||
}
|
|
||||||
loading={
|
|
||||||
isLoading &&
|
|
||||||
isCurrentPlan
|
|
||||||
}
|
}
|
||||||
|
loading={isLoading && isCurrentPlan}
|
||||||
>
|
>
|
||||||
{planAction.label}
|
{planAction.label}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>
|
<p>{t("billingResolvePaymentIssue") || "Please resolve your payment issue before upgrading or downgrading"}</p>
|
||||||
{t(
|
|
||||||
"billingResolvePaymentIssue"
|
|
||||||
) ||
|
|
||||||
"Please resolve your payment issue before upgrading or downgrading"}
|
|
||||||
</p>
|
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : (
|
) : (
|
||||||
@@ -885,12 +809,9 @@ export default function BillingPage() {
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={planAction.action}
|
onClick={planAction.action}
|
||||||
disabled={
|
disabled={
|
||||||
isLoading ||
|
isLoading || planAction.disabled
|
||||||
planAction.disabled
|
|
||||||
}
|
|
||||||
loading={
|
|
||||||
isLoading && isCurrentPlan
|
|
||||||
}
|
}
|
||||||
|
loading={isLoading && isCurrentPlan}
|
||||||
>
|
>
|
||||||
{planAction.label}
|
{planAction.label}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -965,38 +886,18 @@ export default function BillingPage() {
|
|||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger className="flex items-center gap-1">
|
<TooltipTrigger className="flex items-center gap-1">
|
||||||
<AlertTriangle className="h-3 w-3 text-orange-400" />
|
<AlertTriangle className="h-3 w-3 text-orange-400" />
|
||||||
<span
|
<span className={cn(
|
||||||
className={cn(
|
"text-orange-600 dark:text-orange-400 font-medium"
|
||||||
"text-orange-600 dark:text-orange-400 font-medium"
|
)}>
|
||||||
)}
|
|
||||||
>
|
|
||||||
{getLimitValue(USERS) ??
|
{getLimitValue(USERS) ??
|
||||||
t(
|
t("billingUnlimited") ??
|
||||||
"billingUnlimited"
|
|
||||||
) ??
|
|
||||||
"∞"}{" "}
|
"∞"}{" "}
|
||||||
{getLimitValue(
|
{getLimitValue(USERS) !== null &&
|
||||||
USERS
|
"users"}
|
||||||
) !== null && "users"}
|
|
||||||
</span>
|
</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>
|
<p>{t("billingUsageExceedsLimit", { current: getUsageValue(USERS), limit: getLimitValue(USERS) ?? 0 }) || `Current usage (${getUsageValue(USERS)}) exceeds limit (${getLimitValue(USERS)})`}</p>
|
||||||
{t(
|
|
||||||
"billingUsageExceedsLimit",
|
|
||||||
{
|
|
||||||
current:
|
|
||||||
getUsageValue(
|
|
||||||
USERS
|
|
||||||
),
|
|
||||||
limit:
|
|
||||||
getLimitValue(
|
|
||||||
USERS
|
|
||||||
) ?? 0
|
|
||||||
}
|
|
||||||
) ||
|
|
||||||
`Current usage (${getUsageValue(USERS)}) exceeds limit (${getLimitValue(USERS)})`}
|
|
||||||
</p>
|
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : (
|
) : (
|
||||||
@@ -1004,8 +905,8 @@ export default function BillingPage() {
|
|||||||
{getLimitValue(USERS) ??
|
{getLimitValue(USERS) ??
|
||||||
t("billingUnlimited") ??
|
t("billingUnlimited") ??
|
||||||
"∞"}{" "}
|
"∞"}{" "}
|
||||||
{getLimitValue(USERS) !==
|
{getLimitValue(USERS) !== null &&
|
||||||
null && "users"}
|
"users"}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
@@ -1019,38 +920,18 @@ export default function BillingPage() {
|
|||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger className="flex items-center gap-1">
|
<TooltipTrigger className="flex items-center gap-1">
|
||||||
<AlertTriangle className="h-3 w-3 text-orange-400" />
|
<AlertTriangle className="h-3 w-3 text-orange-400" />
|
||||||
<span
|
<span className={cn(
|
||||||
className={cn(
|
"text-orange-600 dark:text-orange-400 font-medium"
|
||||||
"text-orange-600 dark:text-orange-400 font-medium"
|
)}>
|
||||||
)}
|
|
||||||
>
|
|
||||||
{getLimitValue(SITES) ??
|
{getLimitValue(SITES) ??
|
||||||
t(
|
t("billingUnlimited") ??
|
||||||
"billingUnlimited"
|
|
||||||
) ??
|
|
||||||
"∞"}{" "}
|
"∞"}{" "}
|
||||||
{getLimitValue(
|
{getLimitValue(SITES) !== null &&
|
||||||
SITES
|
"sites"}
|
||||||
) !== null && "sites"}
|
|
||||||
</span>
|
</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>
|
<p>{t("billingUsageExceedsLimit", { current: getUsageValue(SITES), limit: getLimitValue(SITES) ?? 0 }) || `Current usage (${getUsageValue(SITES)}) exceeds limit (${getLimitValue(SITES)})`}</p>
|
||||||
{t(
|
|
||||||
"billingUsageExceedsLimit",
|
|
||||||
{
|
|
||||||
current:
|
|
||||||
getUsageValue(
|
|
||||||
SITES
|
|
||||||
),
|
|
||||||
limit:
|
|
||||||
getLimitValue(
|
|
||||||
SITES
|
|
||||||
) ?? 0
|
|
||||||
}
|
|
||||||
) ||
|
|
||||||
`Current usage (${getUsageValue(SITES)}) exceeds limit (${getLimitValue(SITES)})`}
|
|
||||||
</p>
|
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : (
|
) : (
|
||||||
@@ -1058,8 +939,8 @@ export default function BillingPage() {
|
|||||||
{getLimitValue(SITES) ??
|
{getLimitValue(SITES) ??
|
||||||
t("billingUnlimited") ??
|
t("billingUnlimited") ??
|
||||||
"∞"}{" "}
|
"∞"}{" "}
|
||||||
{getLimitValue(SITES) !==
|
{getLimitValue(SITES) !== null &&
|
||||||
null && "sites"}
|
"sites"}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
@@ -1073,40 +954,18 @@ export default function BillingPage() {
|
|||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger className="flex items-center gap-1">
|
<TooltipTrigger className="flex items-center gap-1">
|
||||||
<AlertTriangle className="h-3 w-3 text-orange-400" />
|
<AlertTriangle className="h-3 w-3 text-orange-400" />
|
||||||
<span
|
<span className={cn(
|
||||||
className={cn(
|
"text-orange-600 dark:text-orange-400 font-medium"
|
||||||
"text-orange-600 dark:text-orange-400 font-medium"
|
)}>
|
||||||
)}
|
{getLimitValue(DOMAINS) ??
|
||||||
>
|
t("billingUnlimited") ??
|
||||||
{getLimitValue(
|
|
||||||
DOMAINS
|
|
||||||
) ??
|
|
||||||
t(
|
|
||||||
"billingUnlimited"
|
|
||||||
) ??
|
|
||||||
"∞"}{" "}
|
"∞"}{" "}
|
||||||
{getLimitValue(
|
{getLimitValue(DOMAINS) !== null &&
|
||||||
DOMAINS
|
"domains"}
|
||||||
) !== null && "domains"}
|
|
||||||
</span>
|
</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>
|
<p>{t("billingUsageExceedsLimit", { current: getUsageValue(DOMAINS), limit: getLimitValue(DOMAINS) ?? 0 }) || `Current usage (${getUsageValue(DOMAINS)}) exceeds limit (${getLimitValue(DOMAINS)})`}</p>
|
||||||
{t(
|
|
||||||
"billingUsageExceedsLimit",
|
|
||||||
{
|
|
||||||
current:
|
|
||||||
getUsageValue(
|
|
||||||
DOMAINS
|
|
||||||
),
|
|
||||||
limit:
|
|
||||||
getLimitValue(
|
|
||||||
DOMAINS
|
|
||||||
) ?? 0
|
|
||||||
}
|
|
||||||
) ||
|
|
||||||
`Current usage (${getUsageValue(DOMAINS)}) exceeds limit (${getLimitValue(DOMAINS)})`}
|
|
||||||
</p>
|
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : (
|
) : (
|
||||||
@@ -1114,8 +973,8 @@ export default function BillingPage() {
|
|||||||
{getLimitValue(DOMAINS) ??
|
{getLimitValue(DOMAINS) ??
|
||||||
t("billingUnlimited") ??
|
t("billingUnlimited") ??
|
||||||
"∞"}{" "}
|
"∞"}{" "}
|
||||||
{getLimitValue(DOMAINS) !==
|
{getLimitValue(DOMAINS) !== null &&
|
||||||
null && "domains"}
|
"domains"}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
@@ -1130,40 +989,18 @@ export default function BillingPage() {
|
|||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger className="flex items-center gap-1">
|
<TooltipTrigger className="flex items-center gap-1">
|
||||||
<AlertTriangle className="h-3 w-3 text-orange-400" />
|
<AlertTriangle className="h-3 w-3 text-orange-400" />
|
||||||
<span
|
<span className={cn(
|
||||||
className={cn(
|
"text-orange-600 dark:text-orange-400 font-medium"
|
||||||
"text-orange-600 dark:text-orange-400 font-medium"
|
)}>
|
||||||
)}
|
{getLimitValue(ORGINIZATIONS) ??
|
||||||
>
|
t("billingUnlimited") ??
|
||||||
{getLimitValue(
|
|
||||||
ORGINIZATIONS
|
|
||||||
) ??
|
|
||||||
t(
|
|
||||||
"billingUnlimited"
|
|
||||||
) ??
|
|
||||||
"∞"}{" "}
|
"∞"}{" "}
|
||||||
{getLimitValue(
|
{getLimitValue(ORGINIZATIONS) !==
|
||||||
ORGINIZATIONS
|
null && "orgs"}
|
||||||
) !== null && "orgs"}
|
|
||||||
</span>
|
</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>
|
<p>{t("billingUsageExceedsLimit", { current: getUsageValue(ORGINIZATIONS), limit: getLimitValue(ORGINIZATIONS) ?? 0 }) || `Current usage (${getUsageValue(ORGINIZATIONS)}) exceeds limit (${getLimitValue(ORGINIZATIONS)})`}</p>
|
||||||
{t(
|
|
||||||
"billingUsageExceedsLimit",
|
|
||||||
{
|
|
||||||
current:
|
|
||||||
getUsageValue(
|
|
||||||
ORGINIZATIONS
|
|
||||||
),
|
|
||||||
limit:
|
|
||||||
getLimitValue(
|
|
||||||
ORGINIZATIONS
|
|
||||||
) ?? 0
|
|
||||||
}
|
|
||||||
) ||
|
|
||||||
`Current usage (${getUsageValue(ORGINIZATIONS)}) exceeds limit (${getLimitValue(ORGINIZATIONS)})`}
|
|
||||||
</p>
|
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : (
|
) : (
|
||||||
@@ -1171,9 +1008,8 @@ export default function BillingPage() {
|
|||||||
{getLimitValue(ORGINIZATIONS) ??
|
{getLimitValue(ORGINIZATIONS) ??
|
||||||
t("billingUnlimited") ??
|
t("billingUnlimited") ??
|
||||||
"∞"}{" "}
|
"∞"}{" "}
|
||||||
{getLimitValue(
|
{getLimitValue(ORGINIZATIONS) !==
|
||||||
ORGINIZATIONS
|
null && "orgs"}
|
||||||
) !== null && "orgs"}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
@@ -1188,52 +1024,27 @@ export default function BillingPage() {
|
|||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger className="flex items-center gap-1">
|
<TooltipTrigger className="flex items-center gap-1">
|
||||||
<AlertTriangle className="h-3 w-3 text-orange-400" />
|
<AlertTriangle className="h-3 w-3 text-orange-400" />
|
||||||
<span
|
<span className={cn(
|
||||||
className={cn(
|
"text-orange-600 dark:text-orange-400 font-medium"
|
||||||
"text-orange-600 dark:text-orange-400 font-medium"
|
)}>
|
||||||
)}
|
{getLimitValue(REMOTE_EXIT_NODES) ??
|
||||||
>
|
t("billingUnlimited") ??
|
||||||
{getLimitValue(
|
|
||||||
REMOTE_EXIT_NODES
|
|
||||||
) ??
|
|
||||||
t(
|
|
||||||
"billingUnlimited"
|
|
||||||
) ??
|
|
||||||
"∞"}{" "}
|
"∞"}{" "}
|
||||||
{getLimitValue(
|
{getLimitValue(REMOTE_EXIT_NODES) !==
|
||||||
REMOTE_EXIT_NODES
|
null && "nodes"}
|
||||||
) !== null && "nodes"}
|
|
||||||
</span>
|
</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>
|
<p>{t("billingUsageExceedsLimit", { current: getUsageValue(REMOTE_EXIT_NODES), limit: getLimitValue(REMOTE_EXIT_NODES) ?? 0 }) || `Current usage (${getUsageValue(REMOTE_EXIT_NODES)}) exceeds limit (${getLimitValue(REMOTE_EXIT_NODES)})`}</p>
|
||||||
{t(
|
|
||||||
"billingUsageExceedsLimit",
|
|
||||||
{
|
|
||||||
current:
|
|
||||||
getUsageValue(
|
|
||||||
REMOTE_EXIT_NODES
|
|
||||||
),
|
|
||||||
limit:
|
|
||||||
getLimitValue(
|
|
||||||
REMOTE_EXIT_NODES
|
|
||||||
) ?? 0
|
|
||||||
}
|
|
||||||
) ||
|
|
||||||
`Current usage (${getUsageValue(REMOTE_EXIT_NODES)}) exceeds limit (${getLimitValue(REMOTE_EXIT_NODES)})`}
|
|
||||||
</p>
|
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{getLimitValue(
|
{getLimitValue(REMOTE_EXIT_NODES) ??
|
||||||
REMOTE_EXIT_NODES
|
|
||||||
) ??
|
|
||||||
t("billingUnlimited") ??
|
t("billingUnlimited") ??
|
||||||
"∞"}{" "}
|
"∞"}{" "}
|
||||||
{getLimitValue(
|
{getLimitValue(REMOTE_EXIT_NODES) !==
|
||||||
REMOTE_EXIT_NODES
|
null && "nodes"}
|
||||||
) !== null && "nodes"}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
@@ -1261,8 +1072,7 @@ export default function BillingPage() {
|
|||||||
<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>
|
||||||
<div className="text-sm text-muted-foreground mb-1">
|
<div className="text-sm text-muted-foreground mb-1">
|
||||||
{t("billingCurrentKeys") ||
|
{t("billingCurrentKeys") || "Current Keys"}
|
||||||
"Current Keys"}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline gap-2">
|
<div className="flex items-baseline gap-2">
|
||||||
<span className="text-3xl font-bold">
|
<span className="text-3xl font-bold">
|
||||||
@@ -1327,101 +1137,61 @@ export default function BillingPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Features with check marks */}
|
|
||||||
{(() => {
|
|
||||||
const plan = planOptions.find(
|
|
||||||
(p) =>
|
|
||||||
p.tierType === pendingTier.tier ||
|
|
||||||
(pendingTier.tier === "basic" &&
|
|
||||||
p.id === "basic")
|
|
||||||
);
|
|
||||||
return plan?.features?.length ? (
|
|
||||||
<div>
|
|
||||||
<h4 className="font-semibold mb-3">
|
|
||||||
{"What's included:"}
|
|
||||||
</h4>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{plan.features.map(
|
|
||||||
(feature, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Check className="h-4 w-4 text-green-600 shrink-0" />
|
|
||||||
<span>
|
|
||||||
{feature}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null;
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{/* Limits without check marks */}
|
|
||||||
{tierLimits[pendingTier.tier] && (
|
{tierLimits[pendingTier.tier] && (
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-semibold mb-3">
|
<h4 className="font-semibold mb-3">
|
||||||
{"Up to:"}
|
{t("billingPlanIncludes") ||
|
||||||
|
"Plan Includes:"}
|
||||||
</h4>
|
</h4>
|
||||||
<div className="space-y-2 text-sm text-muted-foreground">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/50 shrink-0" />
|
<Check className="h-4 w-4 text-green-600" />
|
||||||
<span>
|
<span>
|
||||||
{
|
{
|
||||||
tierLimits[
|
tierLimits[pendingTier.tier]
|
||||||
pendingTier.tier
|
.users
|
||||||
].users
|
|
||||||
}{" "}
|
}{" "}
|
||||||
{t("billingUsers") ||
|
{t("billingUsers") || "Users"}
|
||||||
"Users"}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/50 shrink-0" />
|
<Check className="h-4 w-4 text-green-600" />
|
||||||
<span>
|
<span>
|
||||||
{
|
{
|
||||||
tierLimits[
|
tierLimits[pendingTier.tier]
|
||||||
pendingTier.tier
|
.sites
|
||||||
].sites
|
|
||||||
}{" "}
|
}{" "}
|
||||||
{t("billingSites") ||
|
{t("billingSites") || "Sites"}
|
||||||
"Sites"}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/50 shrink-0" />
|
<Check className="h-4 w-4 text-green-600" />
|
||||||
<span>
|
<span>
|
||||||
{
|
{
|
||||||
tierLimits[
|
tierLimits[pendingTier.tier]
|
||||||
pendingTier.tier
|
.domains
|
||||||
].domains
|
|
||||||
}{" "}
|
}{" "}
|
||||||
{t("billingDomains") ||
|
{t("billingDomains") ||
|
||||||
"Domains"}
|
"Domains"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/50 shrink-0" />
|
<Check className="h-4 w-4 text-green-600" />
|
||||||
<span>
|
<span>
|
||||||
{
|
{
|
||||||
tierLimits[
|
tierLimits[pendingTier.tier]
|
||||||
pendingTier.tier
|
.organizations
|
||||||
].organizations
|
|
||||||
}{" "}
|
}{" "}
|
||||||
{t(
|
{t("billingOrganizations") ||
|
||||||
"billingOrganizations"
|
"Organizations"}
|
||||||
) || "Organizations"}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/50 shrink-0" />
|
<Check className="h-4 w-4 text-green-600" />
|
||||||
<span>
|
<span>
|
||||||
{
|
{
|
||||||
tierLimits[
|
tierLimits[pendingTier.tier]
|
||||||
pendingTier.tier
|
.remoteNodes
|
||||||
].remoteNodes
|
|
||||||
}{" "}
|
}{" "}
|
||||||
{t("billingRemoteNodes") ||
|
{t("billingRemoteNodes") ||
|
||||||
"Remote Nodes"}
|
"Remote Nodes"}
|
||||||
@@ -1432,84 +1202,43 @@ export default function BillingPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Warning for limit violations when downgrading */}
|
{/* Warning for limit violations when downgrading */}
|
||||||
{pendingTier.action === "downgrade" &&
|
{pendingTier.action === "downgrade" && (() => {
|
||||||
(() => {
|
const violations = checkLimitViolations(pendingTier.tier);
|
||||||
const violations = checkLimitViolations(
|
if (violations.length > 0) {
|
||||||
pendingTier.tier
|
return (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
<AlertTitle>
|
||||||
|
{t("billingLimitViolationWarning") || "Usage Exceeds New Plan Limits"}
|
||||||
|
</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
<p className="mb-3">
|
||||||
|
{t("billingLimitViolationDescription") || "Your current usage exceeds the limits of this plan. The following features will be disabled until you reduce usage:"}
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{violations.map((violation, index) => (
|
||||||
|
<li key={index} className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">{violation.feature}:</span>
|
||||||
|
<span>Currently using {violation.currentUsage}, new limit is {violation.newLimit}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
);
|
);
|
||||||
if (violations.length > 0) {
|
}
|
||||||
return (
|
return null;
|
||||||
<Alert variant="destructive">
|
})()}
|
||||||
<AlertTriangle className="h-4 w-4" />
|
|
||||||
<AlertTitle>
|
|
||||||
{t(
|
|
||||||
"billingLimitViolationWarning"
|
|
||||||
) ||
|
|
||||||
"Usage Exceeds New Plan Limits"}
|
|
||||||
</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
<p className="mb-3">
|
|
||||||
{t(
|
|
||||||
"billingLimitViolationDescription"
|
|
||||||
) ||
|
|
||||||
"Your current usage exceeds the limits of this plan. The following features will be disabled until you reduce usage:"}
|
|
||||||
</p>
|
|
||||||
<ul className="space-y-2">
|
|
||||||
{violations.map(
|
|
||||||
(
|
|
||||||
violation,
|
|
||||||
index
|
|
||||||
) => (
|
|
||||||
<li
|
|
||||||
key={
|
|
||||||
index
|
|
||||||
}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<span className="font-medium">
|
|
||||||
{
|
|
||||||
violation.feature
|
|
||||||
}
|
|
||||||
:
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
Currently
|
|
||||||
using{" "}
|
|
||||||
{
|
|
||||||
violation.currentUsage
|
|
||||||
}
|
|
||||||
,
|
|
||||||
new
|
|
||||||
limit
|
|
||||||
is{" "}
|
|
||||||
{
|
|
||||||
violation.newLimit
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{/* Warning for feature loss when downgrading */}
|
{/* Warning for feature loss when downgrading */}
|
||||||
{pendingTier.action === "downgrade" && (
|
{pendingTier.action === "downgrade" && (
|
||||||
<Alert variant="warning">
|
<Alert variant="warning">
|
||||||
<AlertTriangle className="h-4 w-4" />
|
<AlertTriangle className="h-4 w-4" />
|
||||||
<AlertTitle>
|
<AlertTitle>
|
||||||
{t("billingFeatureLossWarning") ||
|
{t("billingFeatureLossWarning") || "Feature Availability Notice"}
|
||||||
"Feature Availability Notice"}
|
|
||||||
</AlertTitle>
|
</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
{t(
|
{t("billingFeatureLossDescription") || "By downgrading, features not available in the new plan will be automatically disabled. Some settings and configurations may be lost. Please review the pricing matrix to understand which features will no longer be available."}
|
||||||
"billingFeatureLossDescription"
|
|
||||||
) ||
|
|
||||||
"By downgrading, features not available in the new plan will be automatically disabled. Some settings and configurations may be lost. Please review the pricing matrix to understand which features will no longer be available."}
|
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -69,7 +69,6 @@ export default async function DomainSettingsPage({
|
|||||||
failed={domain.failed}
|
failed={domain.failed}
|
||||||
verified={domain.verified}
|
verified={domain.verified}
|
||||||
type={domain.type}
|
type={domain.type}
|
||||||
errorMessage={domain.errorMessage}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DNSRecordsTable records={dnsRecords} type={domain.type} />
|
<DNSRecordsTable records={dnsRecords} type={domain.type} />
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ import {
|
|||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger
|
TooltipTrigger
|
||||||
} from "@app/components/ui/tooltip";
|
} from "@app/components/ui/tooltip";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
@@ -66,7 +65,6 @@ import { build } from "@server/build";
|
|||||||
import { Resource } from "@server/db";
|
import { Resource } from "@server/db";
|
||||||
import { isTargetValid } from "@server/lib/validators";
|
import { isTargetValid } from "@server/lib/validators";
|
||||||
import { ListTargetsResponse } from "@server/routers/target";
|
import { ListTargetsResponse } from "@server/routers/target";
|
||||||
import { ListRemoteExitNodesResponse } from "@server/routers/remoteExitNode/types";
|
|
||||||
import { ArrayElement } from "@server/types/ArrayElement";
|
import { ArrayElement } from "@server/types/ArrayElement";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
@@ -83,7 +81,6 @@ import {
|
|||||||
CircleCheck,
|
CircleCheck,
|
||||||
CircleX,
|
CircleX,
|
||||||
Info,
|
Info,
|
||||||
InfoIcon,
|
|
||||||
Plus,
|
Plus,
|
||||||
Settings,
|
Settings,
|
||||||
SquareArrowOutUpRight
|
SquareArrowOutUpRight
|
||||||
@@ -213,13 +210,6 @@ export default function Page() {
|
|||||||
orgQueries.sites({ orgId: orgId as string })
|
orgQueries.sites({ orgId: orgId as string })
|
||||||
);
|
);
|
||||||
|
|
||||||
const [remoteExitNodes, setRemoteExitNodes] = useState<
|
|
||||||
ListRemoteExitNodesResponse["remoteExitNodes"]
|
|
||||||
>([]);
|
|
||||||
const [loadingExitNodes, setLoadingExitNodes] = useState(
|
|
||||||
build === "saas"
|
|
||||||
);
|
|
||||||
|
|
||||||
const [createLoading, setCreateLoading] = useState(false);
|
const [createLoading, setCreateLoading] = useState(false);
|
||||||
const [showSnippets, setShowSnippets] = useState(false);
|
const [showSnippets, setShowSnippets] = useState(false);
|
||||||
const [niceId, setNiceId] = useState<string>("");
|
const [niceId, setNiceId] = useState<string>("");
|
||||||
@@ -234,27 +224,6 @@ export default function Page() {
|
|||||||
useState<LocalTarget | null>(null);
|
useState<LocalTarget | null>(null);
|
||||||
const [healthCheckDialogOpen, setHealthCheckDialogOpen] = useState(false);
|
const [healthCheckDialogOpen, setHealthCheckDialogOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (build !== "saas") return;
|
|
||||||
|
|
||||||
const fetchExitNodes = async () => {
|
|
||||||
try {
|
|
||||||
const res = await api.get<
|
|
||||||
AxiosResponse<ListRemoteExitNodesResponse>
|
|
||||||
>(`/org/${orgId}/remote-exit-nodes`);
|
|
||||||
if (res && res.status === 200) {
|
|
||||||
setRemoteExitNodes(res.data.data.remoteExitNodes);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to fetch remote exit nodes:", e);
|
|
||||||
} finally {
|
|
||||||
setLoadingExitNodes(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchExitNodes();
|
|
||||||
}, [orgId]);
|
|
||||||
|
|
||||||
const [isAdvancedMode, setIsAdvancedMode] = useState(() => {
|
const [isAdvancedMode, setIsAdvancedMode] = useState(() => {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
const saved = localStorage.getItem("create-advanced-mode");
|
const saved = localStorage.getItem("create-advanced-mode");
|
||||||
@@ -320,25 +289,15 @@ export default function Page() {
|
|||||||
},
|
},
|
||||||
...(!env.flags.allowRawResources
|
...(!env.flags.allowRawResources
|
||||||
? []
|
? []
|
||||||
: build === "saas" && remoteExitNodes.length === 0
|
: [
|
||||||
? []
|
{
|
||||||
: [
|
id: "raw" as ResourceType,
|
||||||
{
|
title: t("resourceRaw"),
|
||||||
id: "raw" as ResourceType,
|
description: build == "saas" ? t("resourceRawDescriptionCloud") : t("resourceRawDescription")
|
||||||
title: t("resourceRaw"),
|
}
|
||||||
description:
|
])
|
||||||
build == "saas"
|
|
||||||
? t("resourceRawDescriptionCloud")
|
|
||||||
: t("resourceRawDescription")
|
|
||||||
}
|
|
||||||
])
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// In saas mode with no exit nodes, force HTTP
|
|
||||||
const showTypeSelector =
|
|
||||||
build !== "saas" ||
|
|
||||||
(!loadingExitNodes && remoteExitNodes.length > 0);
|
|
||||||
|
|
||||||
const baseForm = useForm({
|
const baseForm = useForm({
|
||||||
resolver: zodResolver(baseResourceFormSchema),
|
resolver: zodResolver(baseResourceFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -1025,35 +984,34 @@ export default function Page() {
|
|||||||
</SettingsSectionTitle>
|
</SettingsSectionTitle>
|
||||||
</SettingsSectionHeader>
|
</SettingsSectionHeader>
|
||||||
<SettingsSectionBody>
|
<SettingsSectionBody>
|
||||||
{showTypeSelector &&
|
{resourceTypes.length > 1 && (
|
||||||
resourceTypes.length > 1 && (
|
<>
|
||||||
<>
|
<div className="mb-2">
|
||||||
<div className="mb-2">
|
<span className="text-sm font-medium">
|
||||||
<span className="text-sm font-medium">
|
{t("type")}
|
||||||
{t("type")}
|
</span>
|
||||||
</span>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<StrategySelect
|
<StrategySelect
|
||||||
options={resourceTypes}
|
options={resourceTypes}
|
||||||
defaultValue="http"
|
defaultValue="http"
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
baseForm.setValue(
|
baseForm.setValue(
|
||||||
"http",
|
"http",
|
||||||
value === "http"
|
value === "http"
|
||||||
);
|
);
|
||||||
// Update method default when switching resource type
|
// Update method default when switching resource type
|
||||||
addTargetForm.setValue(
|
addTargetForm.setValue(
|
||||||
"method",
|
"method",
|
||||||
value === "http"
|
value === "http"
|
||||||
? "http"
|
? "http"
|
||||||
: null
|
: null
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
cols={2}
|
cols={2}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<SettingsSectionForm>
|
<SettingsSectionForm>
|
||||||
<Form {...baseForm}>
|
<Form {...baseForm}>
|
||||||
|
|||||||
@@ -10,20 +10,17 @@ import {
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Badge } from "./ui/badge";
|
import { Badge } from "./ui/badge";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { AlertTriangle } from "lucide-react";
|
|
||||||
|
|
||||||
type DomainInfoCardProps = {
|
type DomainInfoCardProps = {
|
||||||
failed: boolean;
|
failed: boolean;
|
||||||
verified: boolean;
|
verified: boolean;
|
||||||
type: string | null;
|
type: string | null;
|
||||||
errorMessage?: string | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function DomainInfoCard({
|
export default function DomainInfoCard({
|
||||||
failed,
|
failed,
|
||||||
verified,
|
verified,
|
||||||
type,
|
type
|
||||||
errorMessage
|
|
||||||
}: DomainInfoCardProps) {
|
}: DomainInfoCardProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const env = useEnvContext();
|
const env = useEnvContext();
|
||||||
@@ -42,7 +39,6 @@ export default function DomainInfoCard({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
|
||||||
<Alert>
|
<Alert>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
<InfoSections cols={3}>
|
<InfoSections cols={3}>
|
||||||
@@ -83,19 +79,5 @@ export default function DomainInfoCard({
|
|||||||
</InfoSections>
|
</InfoSections>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
{errorMessage && (failed || !verified) && (
|
|
||||||
<Alert variant={failed ? "destructive" : "warning"}>
|
|
||||||
<AlertTriangle className="h-4 w-4" />
|
|
||||||
<AlertTitle>
|
|
||||||
{failed
|
|
||||||
? t("domainErrorTitle", { fallback: "Domain Error" })
|
|
||||||
: t("domainPendingErrorTitle", { fallback: "Verification Issue" })}
|
|
||||||
</AlertTitle>
|
|
||||||
<AlertDescription className="font-mono text-xs break-all">
|
|
||||||
{errorMessage}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,12 +27,6 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger
|
DropdownMenuTrigger
|
||||||
} from "./ui/dropdown-menu";
|
} from "./ui/dropdown-menu";
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger
|
|
||||||
} from "./ui/tooltip";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
export type DomainRow = {
|
export type DomainRow = {
|
||||||
@@ -45,7 +39,6 @@ export type DomainRow = {
|
|||||||
configManaged: boolean;
|
configManaged: boolean;
|
||||||
certResolver: string;
|
certResolver: string;
|
||||||
preferWildcardCert: boolean;
|
preferWildcardCert: boolean;
|
||||||
errorMessage?: string | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -182,7 +175,7 @@ export default function DomainsTable({ domains, orgId }: Props) {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const { verified, failed, type, errorMessage } = row.original;
|
const { verified, failed, type } = row.original;
|
||||||
if (verified) {
|
if (verified) {
|
||||||
return type == "wildcard" ? (
|
return type == "wildcard" ? (
|
||||||
<Badge variant="outlinePrimary">{t("manual")}</Badge>
|
<Badge variant="outlinePrimary">{t("manual")}</Badge>
|
||||||
@@ -190,44 +183,12 @@ export default function DomainsTable({ domains, orgId }: Props) {
|
|||||||
<Badge variant="green">{t("verified")}</Badge>
|
<Badge variant="green">{t("verified")}</Badge>
|
||||||
);
|
);
|
||||||
} else if (failed) {
|
} else if (failed) {
|
||||||
if (errorMessage) {
|
|
||||||
return (
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Badge variant="red" className="cursor-help">
|
|
||||||
{t("failed", { fallback: "Failed" })}
|
|
||||||
</Badge>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent className="max-w-xs">
|
|
||||||
<p className="break-words">{errorMessage}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<Badge variant="red">
|
<Badge variant="red">
|
||||||
{t("failed", { fallback: "Failed" })}
|
{t("failed", { fallback: "Failed" })}
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
if (errorMessage) {
|
|
||||||
return (
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Badge variant="yellow" className="cursor-help">
|
|
||||||
{t("pending")}
|
|
||||||
</Badge>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent className="max-w-xs">
|
|
||||||
<p className="break-words">{errorMessage}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return <Badge variant="yellow">{t("pending")}</Badge>;
|
return <Badge variant="yellow">{t("pending")}</Badge>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ const docsLinkClassName =
|
|||||||
const PANGOLIN_CLOUD_SIGNUP_URL = "https://app.pangolin.net/auth/signup/";
|
const PANGOLIN_CLOUD_SIGNUP_URL = "https://app.pangolin.net/auth/signup/";
|
||||||
const ENTERPRISE_DOCS_URL =
|
const ENTERPRISE_DOCS_URL =
|
||||||
"https://docs.pangolin.net/self-host/enterprise-edition";
|
"https://docs.pangolin.net/self-host/enterprise-edition";
|
||||||
const BOOK_A_DEMO_URL = "https://click.fossorial.io/ep922";
|
|
||||||
|
|
||||||
function getTierLinkRenderer(billingHref: string) {
|
function getTierLinkRenderer(billingHref: string) {
|
||||||
return function tierLinkRenderer(chunks: React.ReactNode) {
|
return function tierLinkRenderer(chunks: React.ReactNode) {
|
||||||
@@ -79,22 +78,6 @@ function getPangolinCloudLinkRenderer() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBookADemoLinkRenderer() {
|
|
||||||
return function bookADemoLinkRenderer(chunks: React.ReactNode) {
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
href={BOOK_A_DEMO_URL}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className={docsLinkClassName}
|
|
||||||
>
|
|
||||||
{chunks}
|
|
||||||
<ExternalLink className="size-3.5 shrink-0" />
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDocsLinkRenderer(href: string) {
|
function getDocsLinkRenderer(href: string) {
|
||||||
return function docsLinkRenderer(chunks: React.ReactNode) {
|
return function docsLinkRenderer(chunks: React.ReactNode) {
|
||||||
return (
|
return (
|
||||||
@@ -133,7 +116,6 @@ export function PaidFeaturesAlert({ tiers }: Props) {
|
|||||||
const tierLinkRenderer = getTierLinkRenderer(billingHref);
|
const tierLinkRenderer = getTierLinkRenderer(billingHref);
|
||||||
const pangolinCloudLinkRenderer = getPangolinCloudLinkRenderer();
|
const pangolinCloudLinkRenderer = getPangolinCloudLinkRenderer();
|
||||||
const enterpriseDocsLinkRenderer = getDocsLinkRenderer(ENTERPRISE_DOCS_URL);
|
const enterpriseDocsLinkRenderer = getDocsLinkRenderer(ENTERPRISE_DOCS_URL);
|
||||||
const bookADemoLinkRenderer = getBookADemoLinkRenderer();
|
|
||||||
|
|
||||||
if (env.flags.disableEnterpriseFeatures) {
|
if (env.flags.disableEnterpriseFeatures) {
|
||||||
return null;
|
return null;
|
||||||
@@ -175,8 +157,7 @@ export function PaidFeaturesAlert({ tiers }: Props) {
|
|||||||
{t.rich("licenseRequiredToUse", {
|
{t.rich("licenseRequiredToUse", {
|
||||||
enterpriseLicenseLink:
|
enterpriseLicenseLink:
|
||||||
enterpriseDocsLinkRenderer,
|
enterpriseDocsLinkRenderer,
|
||||||
pangolinCloudLink: pangolinCloudLinkRenderer,
|
pangolinCloudLink: pangolinCloudLinkRenderer
|
||||||
bookADemoLink: bookADemoLinkRenderer
|
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -193,8 +174,7 @@ export function PaidFeaturesAlert({ tiers }: Props) {
|
|||||||
{t.rich("ossEnterpriseEditionRequired", {
|
{t.rich("ossEnterpriseEditionRequired", {
|
||||||
enterpriseEditionLink:
|
enterpriseEditionLink:
|
||||||
enterpriseDocsLinkRenderer,
|
enterpriseDocsLinkRenderer,
|
||||||
pangolinCloudLink: pangolinCloudLinkRenderer,
|
pangolinCloudLink: pangolinCloudLinkRenderer
|
||||||
bookADemoLink: bookADemoLinkRenderer
|
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user