diff --git a/.github/workflows/saas.yml b/.github/workflows/saas.yml index 5db7aa2f..93e5d198 100644 --- a/.github/workflows/saas.yml +++ b/.github/workflows/saas.yml @@ -56,6 +56,41 @@ jobs: - name: Checkout code uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - name: Download MaxMind GeoLite2 databases + env: + MAXMIND_LICENSE_KEY: ${{ secrets.MAXMIND_LICENSE_KEY }} + run: | + echo "Downloading MaxMind GeoLite2 databases..." + + # Download GeoLite2-Country + curl -L "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-Country&license_key=${MAXMIND_LICENSE_KEY}&suffix=tar.gz" \ + -o GeoLite2-Country.tar.gz + + # Download GeoLite2-ASN + curl -L "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-ASN&license_key=${MAXMIND_LICENSE_KEY}&suffix=tar.gz" \ + -o GeoLite2-ASN.tar.gz + + # Extract the .mmdb files + tar -xzf GeoLite2-Country.tar.gz --strip-components=1 --wildcards '*.mmdb' + tar -xzf GeoLite2-ASN.tar.gz --strip-components=1 --wildcards '*.mmdb' + + # Verify files exist + if [ ! -f "GeoLite2-Country.mmdb" ]; then + echo "ERROR: Failed to download GeoLite2-Country.mmdb" + exit 1 + fi + + if [ ! -f "GeoLite2-ASN.mmdb" ]; then + echo "ERROR: Failed to download GeoLite2-ASN.mmdb" + exit 1 + fi + + # Clean up tar files + rm -f GeoLite2-Country.tar.gz GeoLite2-ASN.tar.gz + + echo "MaxMind databases downloaded successfully" + ls -lh GeoLite2-*.mmdb + - name: Monitor storage space run: | THRESHOLD=75 diff --git a/Dockerfile b/Dockerfile index 4830067e..12c519b7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -49,6 +49,14 @@ COPY server/db/ios_models.json ./dist/ios_models.json COPY server/db/mac_models.json ./dist/mac_models.json COPY public ./public +# Copy MaxMind databases for SaaS builds +ARG BUILD=oss +RUN mkdir -p ./maxmind + +# This is only for saas +COPY --from=builder-dev /app/GeoLite2-Country.mmdb ./maxmind/GeoLite2-Country.mmdb +COPY --from=builder-dev /app/GeoLite2-ASN.mmdb ./maxmind/GeoLite2-ASN.mmdb + # OCI Image Labels - Build Args for dynamic values ARG VERSION="dev" ARG REVISION="" diff --git a/messages/en-US.json b/messages/en-US.json index f12e2210..d872d8e3 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1572,6 +1572,16 @@ "billingFeatureLossWarning": "Feature Availability Notice", "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.", "billingUsageExceedsLimit": "Current usage ({current}) exceeds limit ({limit})", + "billingPastDueTitle": "Payment Past Due", + "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.", + "billingUnpaidTitle": "Subscription Unpaid", + "billingUnpaidDescription": "Your subscription is unpaid and you have been reverted to the free tier. Please update your payment method to restore your subscription.", + "billingIncompleteTitle": "Payment Incomplete", + "billingIncompleteDescription": "Your payment is incomplete. Please complete the payment process to activate your subscription.", + "billingIncompleteExpiredTitle": "Payment Expired", + "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.", + "billingManageSubscription": "Manage your subscription", + "billingResolvePaymentIssue": "Please resolve your payment issue before upgrading or downgrading", "signUpTerms": { "IAgreeToThe": "I agree to the", "termsOfService": "terms of service", diff --git a/server/lib/billing/usageService.ts b/server/lib/billing/usageService.ts index a7786c76..d7299284 100644 --- a/server/lib/billing/usageService.ts +++ b/server/lib/billing/usageService.ts @@ -46,8 +46,6 @@ export class UsageService { return null; } - let orgIdToUse = await this.getBillingOrg(orgId, transaction); - // Truncate value to 11 decimal places value = this.truncateValue(value); @@ -59,6 +57,7 @@ export class UsageService { try { let usage; if (transaction) { + const orgIdToUse = await this.getBillingOrg(orgId, transaction); usage = await this.internalAddUsage( orgIdToUse, featureId, @@ -67,6 +66,7 @@ export class UsageService { ); } else { await db.transaction(async (trx) => { + const orgIdToUse = await this.getBillingOrg(orgId, trx); usage = await this.internalAddUsage( orgIdToUse, featureId, @@ -92,7 +92,7 @@ export class UsageService { const delay = baseDelay + jitter; logger.warn( - `Deadlock detected for ${orgIdToUse}/${featureId}, retrying attempt ${attempt}/${maxRetries} after ${delay.toFixed(0)}ms` + `Deadlock detected for ${orgId}/${featureId}, retrying attempt ${attempt}/${maxRetries} after ${delay.toFixed(0)}ms` ); await new Promise((resolve) => setTimeout(resolve, delay)); @@ -100,7 +100,7 @@ export class UsageService { } logger.error( - `Failed to add usage for ${orgIdToUse}/${featureId} after ${attempt} attempts:`, + `Failed to add usage for ${orgId}/${featureId} after ${attempt} attempts:`, error ); break; @@ -169,7 +169,7 @@ export class UsageService { return; } - let orgIdToUse = await this.getBillingOrg(orgId); + const orgIdToUse = await this.getBillingOrg(orgId); try { // Truncate value to 11 decimal places if provided @@ -227,7 +227,7 @@ export class UsageService { orgId: string, featureId: FeatureId ): Promise { - let orgIdToUse = await this.getBillingOrg(orgId); + const orgIdToUse = await this.getBillingOrg(orgId); const cacheKey = `customer_${orgIdToUse}_${featureId}`; const cached = cache.get(cacheKey); @@ -274,7 +274,7 @@ export class UsageService { return null; } - let orgIdToUse = await this.getBillingOrg(orgId, trx); + const orgIdToUse = await this.getBillingOrg(orgId, trx); const usageId = `${orgIdToUse}-${featureId}`; @@ -382,7 +382,7 @@ export class UsageService { return false; } - let orgIdToUse = await this.getBillingOrg(orgId, trx); + const orgIdToUse = await this.getBillingOrg(orgId, trx); // This method should check the current usage against the limits set for the organization // and kick out all of the sites on the org diff --git a/server/private/lib/billing/getOrgTierData.ts b/server/private/lib/billing/getOrgTierData.ts index d87f2c38..9972dcfc 100644 --- a/server/private/lib/billing/getOrgTierData.ts +++ b/server/private/lib/billing/getOrgTierData.ts @@ -78,7 +78,8 @@ export async function getOrgTierData( if ( subscription.type === "tier1" || subscription.type === "tier2" || - subscription.type === "tier3" + subscription.type === "tier3" || + subscription.type === "enterprise" ) { tier = subscription.type; active = true; diff --git a/server/private/lib/lock.ts b/server/private/lib/lock.ts index 7e68565e..4462a454 100644 --- a/server/private/lib/lock.ts +++ b/server/private/lib/lock.ts @@ -14,6 +14,9 @@ import { config } from "@server/lib/config"; import logger from "@server/logger"; import { redis } from "#private/lib/redis"; +import { v4 as uuidv4 } from "uuid"; + +const instanceId = uuidv4(); export class LockManager { /** @@ -33,7 +36,7 @@ export class LockManager { } const lockValue = `${ - config.getRawConfig().gerbil.exit_node_name + instanceId }:${Date.now()}`; const redisKey = `lock:${lockKey}`; @@ -52,7 +55,7 @@ export class LockManager { if (result === "OK") { logger.debug( `Lock acquired: ${lockKey} by ${ - config.getRawConfig().gerbil.exit_node_name + instanceId }` ); return true; @@ -63,14 +66,14 @@ export class LockManager { if ( existingValue && existingValue.startsWith( - `${config.getRawConfig().gerbil.exit_node_name}:` + `${instanceId}:` ) ) { // Extend the lock TTL since it's the same worker await redis.pexpire(redisKey, ttlMs); logger.debug( `Lock extended: ${lockKey} by ${ - config.getRawConfig().gerbil.exit_node_name + instanceId }` ); return true; @@ -116,7 +119,7 @@ export class LockManager { local key = KEYS[1] local worker_prefix = ARGV[1] local current_value = redis.call('GET', key) - + if current_value and string.find(current_value, worker_prefix, 1, true) == 1 then return redis.call('DEL', key) else @@ -129,19 +132,19 @@ export class LockManager { luaScript, 1, redisKey, - `${config.getRawConfig().gerbil.exit_node_name}:` + `${instanceId}:` )) as number; if (result === 1) { logger.debug( `Lock released: ${lockKey} by ${ - config.getRawConfig().gerbil.exit_node_name + instanceId }` ); } else { logger.warn( `Lock not released - not owned by worker: ${lockKey} by ${ - config.getRawConfig().gerbil.exit_node_name + instanceId }` ); } @@ -198,7 +201,7 @@ export class LockManager { const ownedByMe = exists && value!.startsWith( - `${config.getRawConfig().gerbil.exit_node_name}:` + `${instanceId}:` ); const owner = exists ? value!.split(":")[0] : undefined; @@ -233,7 +236,7 @@ export class LockManager { local worker_prefix = ARGV[1] local ttl = tonumber(ARGV[2]) local current_value = redis.call('GET', key) - + if current_value and string.find(current_value, worker_prefix, 1, true) == 1 then return redis.call('PEXPIRE', key, ttl) else @@ -246,14 +249,14 @@ export class LockManager { luaScript, 1, redisKey, - `${config.getRawConfig().gerbil.exit_node_name}:`, + `${instanceId}:`, ttlMs.toString() )) as number; if (result === 1) { logger.debug( `Lock extended: ${lockKey} by ${ - config.getRawConfig().gerbil.exit_node_name + instanceId } for ${ttlMs}ms` ); return true; @@ -356,7 +359,7 @@ export class LockManager { (value) => value && value.startsWith( - `${config.getRawConfig().gerbil.exit_node_name}:` + `${instanceId}:` ) ).length; } diff --git a/server/private/lib/readConfigFile.ts b/server/private/lib/readConfigFile.ts index e5efa498..a9de84e8 100644 --- a/server/private/lib/readConfigFile.ts +++ b/server/private/lib/readConfigFile.ts @@ -72,15 +72,15 @@ export const privateConfigSchema = z.object({ db: z.int().nonnegative().optional().default(0) }) ) + .optional(), + tls: z + .object({ + rejectUnauthorized: z + .boolean() + .optional() + .default(true) + }) .optional() - // tls: z - // .object({ - // reject_unauthorized: z - // .boolean() - // .optional() - // .default(true) - // }) - // .optional() }) .optional(), gerbil: z diff --git a/server/private/lib/redis.ts b/server/private/lib/redis.ts index 49cd4c61..69f563b4 100644 --- a/server/private/lib/redis.ts +++ b/server/private/lib/redis.ts @@ -108,11 +108,15 @@ class RedisManager { port: redisConfig.port!, password: redisConfig.password, db: redisConfig.db - // tls: { - // rejectUnauthorized: - // redisConfig.tls?.reject_unauthorized || false - // } }; + + // Enable TLS if configured (required for AWS ElastiCache in-transit encryption) + if (redisConfig.tls) { + opts.tls = { + rejectUnauthorized: redisConfig.tls.rejectUnauthorized ?? true + }; + } + return opts; } @@ -130,11 +134,15 @@ class RedisManager { port: replica.port!, password: replica.password, db: replica.db || redisConfig.db - // tls: { - // rejectUnauthorized: - // replica.tls?.reject_unauthorized || false - // } }; + + // Enable TLS if configured (required for AWS ElastiCache in-transit encryption) + if (redisConfig.tls) { + opts.tls = { + rejectUnauthorized: redisConfig.tls.rejectUnauthorized ?? true + }; + } + return opts; } diff --git a/server/routers/gerbil/receiveBandwidth.ts b/server/routers/gerbil/receiveBandwidth.ts index 937fa271..dbd687a1 100644 --- a/server/routers/gerbil/receiveBandwidth.ts +++ b/server/routers/gerbil/receiveBandwidth.ts @@ -197,7 +197,6 @@ export async function updateSiteBandwidth( usageService .checkLimitSet( orgId, - FeatureId.EGRESS_DATA_MB, bandwidthUsage ) diff --git a/server/setup/copyInConfig.ts b/server/setup/copyInConfig.ts index aa2e040d..be376817 100644 --- a/server/setup/copyInConfig.ts +++ b/server/setup/copyInConfig.ts @@ -2,9 +2,13 @@ import { db, dnsRecords } from "@server/db"; import { domains, exitNodes, orgDomains, orgs, resources } from "@server/db"; import config from "@server/lib/config"; import { eq, ne } from "drizzle-orm"; -import logger from "@server/logger"; +import { build } from "@server/build"; export async function copyInConfig() { + if (build == "saas") { + return; + } + const endpoint = config.getRawConfig().gerbil.base_endpoint; const listenPort = config.getRawConfig().gerbil.start_port; diff --git a/server/setup/migrationsPg.ts b/server/setup/migrationsPg.ts index fd28644c..8d27435a 100644 --- a/server/setup/migrationsPg.ts +++ b/server/setup/migrationsPg.ts @@ -19,6 +19,7 @@ import m11 from "./scriptsPg/1.14.0"; import m12 from "./scriptsPg/1.15.0"; import m13 from "./scriptsPg/1.15.3"; import m14 from "./scriptsPg/1.15.4"; +import { build } from "@server/build"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -53,6 +54,10 @@ async function run() { } export async function runMigrations() { + if (build == "saas") { + console.log("Running in SaaS mode, skipping migrations..."); + return; + } if (process.env.DISABLE_MIGRATIONS) { console.log("Migrations are disabled. Skipping..."); return; diff --git a/server/setup/migrationsSqlite.ts b/server/setup/migrationsSqlite.ts index 39c133bf..17bc7f19 100644 --- a/server/setup/migrationsSqlite.ts +++ b/server/setup/migrationsSqlite.ts @@ -37,6 +37,7 @@ import m32 from "./scriptsSqlite/1.14.0"; import m33 from "./scriptsSqlite/1.15.0"; import m34 from "./scriptsSqlite/1.15.3"; import m35 from "./scriptsSqlite/1.15.4"; +import { build } from "@server/build"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -105,6 +106,10 @@ function backupDb() { } export async function runMigrations() { + if (build == "saas") { + console.log("Running in SaaS mode, skipping migrations..."); + return; + } if (process.env.DISABLE_MIGRATIONS) { console.log("Migrations are disabled. Skipping..."); return; diff --git a/src/app/[orgId]/settings/(private)/billing/page.tsx b/src/app/[orgId]/settings/(private)/billing/page.tsx index bad8bda2..24fa7480 100644 --- a/src/app/[orgId]/settings/(private)/billing/page.tsx +++ b/src/app/[orgId]/settings/(private)/billing/page.tsx @@ -445,6 +445,54 @@ export default function BillingPage() { const currentPlanId = getCurrentPlanId(); + // Check if subscription is in a problematic state that requires attention + const hasProblematicSubscription = (): boolean => { + if (!tierSubscription?.subscription) return false; + const status = tierSubscription.subscription.status; + return ( + status === "past_due" || + status === "unpaid" || + status === "incomplete" || + status === "incomplete_expired" + ); + }; + + const isProblematicState = hasProblematicSubscription(); + + // Get user-friendly subscription status message + const getSubscriptionStatusMessage = (): { title: string; description: string } | null => { + if (!tierSubscription?.subscription || !isProblematicState) return null; + + const status = tierSubscription.subscription.status; + + switch (status) { + case "past_due": + return { + title: t("billingPastDueTitle") || "Payment Past Due", + 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." + }; + case "unpaid": + return { + title: t("billingUnpaidTitle") || "Subscription Unpaid", + 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." + }; + case "incomplete": + return { + title: t("billingIncompleteTitle") || "Payment Incomplete", + description: t("billingIncompleteDescription") || "Your payment is incomplete. Please complete the payment process to activate your subscription." + }; + case "incomplete_expired": + return { + title: 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." + }; + default: + return null; + } + }; + + const statusMessage = getSubscriptionStatusMessage(); + // Get button label and action for each plan const getPlanAction = (plan: PlanOption) => { if (plan.id === "enterprise") { @@ -458,7 +506,7 @@ export default function BillingPage() { if (plan.id === currentPlanId) { // If it's the basic plan (basic with no subscription), show as current but disabled - if (plan.id === "basic" && !hasSubscription) { + if (plan.id === "basic" && !hasSubscription && !isProblematicState) { return { label: "Current Plan", action: () => {}, @@ -466,8 +514,17 @@ export default function BillingPage() { disabled: true }; } + // If on free tier but has a problematic subscription, allow them to manage it + if (plan.id === "basic" && isProblematicState) { + return { + label: "Manage Subscription", + action: handleModifySubscription, + variant: "default" as const, + disabled: false + }; + } return { - label: "Modify Current Plan", + label: "Manage Current Plan", action: handleModifySubscription, variant: "default" as const, disabled: false @@ -503,7 +560,7 @@ export default function BillingPage() { } }, variant: "outline" as const, - disabled: false + disabled: isProblematicState }; } @@ -522,7 +579,7 @@ export default function BillingPage() { } }, variant: "outline" as const, - disabled: false + disabled: isProblematicState }; }; @@ -648,6 +705,26 @@ export default function BillingPage() { return ( + {/* Subscription Status Alert */} + {isProblematicState && statusMessage && ( + + + + {statusMessage.title} + + + {statusMessage.description} + {" "} + + + + )} + {/* Your Plan Section */} @@ -692,22 +769,50 @@ export default function BillingPage() {
- + {isProblematicState && planAction.disabled && !isCurrentPlan && plan.id !== "enterprise" ? ( + + +
+ +
+
+ +

{t("billingResolvePaymentIssue") || "Please resolve your payment issue before upgrading or downgrading"}

+
+
+ ) : ( + + )}
); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index d614012f..0db1b49b 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -84,13 +84,13 @@ export default async function RootLayout({ - {build === "saas" && ( + {/* build === "saas" && (