diff --git a/.github/workflows/restart-runners.yml b/.github/workflows/restart-runners.yml new file mode 100644 index 00000000..14bbcefb --- /dev/null +++ b/.github/workflows/restart-runners.yml @@ -0,0 +1,39 @@ +name: Restart Runners + +on: + schedule: + - cron: '0 0 */7 * *' + +permissions: + id-token: write + contents: read + +jobs: + ec2-maintenance-prod: + runs-on: ubuntu-latest + permissions: write-all + steps: + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v2 + with: + role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }} + role-duration-seconds: 3600 + aws-region: ${{ secrets.AWS_REGION }} + + - name: Verify AWS identity + run: aws sts get-caller-identity + + - name: Start EC2 instance + run: | + aws ec2 start-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_ARM_RUNNER }} + aws ec2 start-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_AMD_RUNNER }} + echo "EC2 instances started" + + - name: Wait + run: sleep 600 + + - name: Stop EC2 instance + run: | + aws ec2 stop-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_ARM_RUNNER }} + aws ec2 stop-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_AMD_RUNNER }} + echo "EC2 instances stopped" diff --git a/messages/en-US.json b/messages/en-US.json index 7d5deded..b023ac75 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2272,5 +2272,8 @@ "remoteExitNodeRegenerateAndDisconnectWarning": "This will regenerate the credentials and immediately disconnect the remote exit node. The remote exit node will need to be restarted with the new credentials.", "remoteExitNodeRegenerateCredentialsConfirmation": "Are you sure you want to regenerate the credentials for this remote exit node?", "remoteExitNodeRegenerateCredentialsWarning": "This will regenerate the credentials. The remote exit node will stay connected until you manually restart it and use the new credentials.", - "agent": "Agent" + "agent": "Agent", + "personalUseOnly": "Personal Use Only", + "loginPageLicenseWatermark": "This instance is licensed for personal use only.", + "instanceIsUnlicensed": "This instance is unlicensed." } diff --git a/package-lock.json b/package-lock.json index c6e60cef..b3a18c31 100644 --- a/package-lock.json +++ b/package-lock.json @@ -90,7 +90,7 @@ "qrcode.react": "4.2.0", "react": "19.2.3", "react-day-picker": "9.12.0", - "react-dom": "19.2.1", + "react-dom": "19.2.3", "react-easy-sort": "1.8.0", "react-hook-form": "7.68.0", "react-icons": "5.5.0", @@ -19768,16 +19768,16 @@ } }, "node_modules/react-dom": { - "version": "19.2.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz", - "integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==", + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", "peer": true, "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.1" + "react": "^19.2.3" } }, "node_modules/react-easy-sort": { diff --git a/package.json b/package.json index 5609b688..2aebc439 100644 --- a/package.json +++ b/package.json @@ -114,7 +114,7 @@ "qrcode.react": "4.2.0", "react": "19.2.3", "react-day-picker": "9.12.0", - "react-dom": "19.2.1", + "react-dom": "19.2.3", "react-easy-sort": "1.8.0", "react-hook-form": "7.68.0", "react-icons": "5.5.0", diff --git a/server/lib/consts.ts b/server/lib/consts.ts index d93cf224..d1f66a9e 100644 --- a/server/lib/consts.ts +++ b/server/lib/consts.ts @@ -2,7 +2,7 @@ import path from "path"; import { fileURLToPath } from "url"; // This is a placeholder value replaced by the build process -export const APP_VERSION = "1.13.0"; +export const APP_VERSION = "1.13.1"; export const __FILENAME = fileURLToPath(import.meta.url); export const __DIRNAME = path.dirname(__FILENAME); diff --git a/server/lib/ip.ts b/server/lib/ip.ts index 36065df3..9c412801 100644 --- a/server/lib/ip.ts +++ b/server/lib/ip.ts @@ -120,11 +120,13 @@ function bigIntToIp(num: bigint, version: IPVersion): string { * Parses an endpoint string (ip:port) handling both IPv4 and IPv6 addresses. * IPv6 addresses may be bracketed like [::1]:8080 or unbracketed like ::1:8080. * For unbracketed IPv6, the last colon-separated segment is treated as the port. - * + * * @param endpoint The endpoint string to parse (e.g., "192.168.1.1:8080" or "[::1]:8080" or "2607:fea8::1:8080") * @returns An object with ip and port, or null if parsing fails */ -export function parseEndpoint(endpoint: string): { ip: string; port: number } | null { +export function parseEndpoint( + endpoint: string +): { ip: string; port: number } | null { if (!endpoint) return null; // Check for bracketed IPv6 format: [ip]:port @@ -138,7 +140,7 @@ export function parseEndpoint(endpoint: string): { ip: string; port: number } | // Check if this looks like IPv6 (contains multiple colons) const colonCount = (endpoint.match(/:/g) || []).length; - + if (colonCount > 1) { // This is IPv6 - the port is after the last colon const lastColonIndex = endpoint.lastIndexOf(":"); @@ -163,7 +165,7 @@ export function parseEndpoint(endpoint: string): { ip: string; port: number } | /** * Formats an IP and port into a consistent endpoint string. * IPv6 addresses are wrapped in brackets for proper parsing. - * + * * @param ip The IP address (IPv4 or IPv6) * @param port The port number * @returns Formatted endpoint string @@ -430,7 +432,12 @@ export function generateRemoteSubnets( ): string[] { const remoteSubnets = allSiteResources .filter((sr) => { - if (sr.mode === "cidr") return true; + if (sr.mode === "cidr") { + // check if its a valid CIDR using zod + const cidrSchema = z.union([z.cidrv4(), z.cidrv6()]); + const parseResult = cidrSchema.safeParse(sr.destination); + return parseResult.success; + } if (sr.mode === "host") { // check if its a valid IP using zod const ipSchema = z.union([z.ipv4(), z.ipv6()]); @@ -454,13 +461,12 @@ export function generateRemoteSubnets( export type Alias = { alias: string | null; aliasAddress: string | null }; export function generateAliasConfig(allSiteResources: SiteResource[]): Alias[] { - let aliasConfigs = allSiteResources + return allSiteResources .filter((sr) => sr.alias && sr.aliasAddress && sr.mode == "host") .map((sr) => ({ alias: sr.alias, aliasAddress: sr.aliasAddress })); - return aliasConfigs; } export type SubnetProxyTarget = { diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index e0867dc5..625e5793 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -955,28 +955,8 @@ export async function rebuildClientAssociationsFromClient( /////////// Send messages /////////// - // Get the olm for this client - const [olm] = await trx - .select({ olmId: olms.olmId }) - .from(olms) - .where(eq(olms.clientId, client.clientId)) - .limit(1); - - if (!olm) { - logger.warn( - `Olm not found for client ${client.clientId}, skipping peer updates` - ); - return; - } - // Handle messages for sites being added - await handleMessagesForClientSites( - client, - olm.olmId, - sitesToAdd, - sitesToRemove, - trx - ); + await handleMessagesForClientSites(client, sitesToAdd, sitesToRemove, trx); // Handle subnet proxy target updates for resources await handleMessagesForClientResources( @@ -996,11 +976,26 @@ async function handleMessagesForClientSites( userId: string | null; orgId: string; }, - olmId: string, sitesToAdd: number[], sitesToRemove: number[], trx: Transaction | typeof db = db ): Promise { + // Get the olm for this client + const [olm] = await trx + .select({ olmId: olms.olmId }) + .from(olms) + .where(eq(olms.clientId, client.clientId)) + .limit(1); + + if (!olm) { + logger.warn( + `Olm not found for client ${client.clientId}, skipping peer updates` + ); + return; + } + + const olmId = olm.olmId; + if (!client.subnet || !client.pubKey) { logger.warn( `Client ${client.clientId} missing subnet or pubKey, skipping peer updates` @@ -1021,9 +1016,9 @@ async function handleMessagesForClientSites( .leftJoin(newts, eq(sites.siteId, newts.siteId)) .where(inArray(sites.siteId, allSiteIds)); - let newtJobs: Promise[] = []; - let olmJobs: Promise[] = []; - let exitNodeJobs: Promise[] = []; + const newtJobs: Promise[] = []; + const olmJobs: Promise[] = []; + const exitNodeJobs: Promise[] = []; for (const siteData of sitesData) { const site = siteData.sites; @@ -1130,18 +1125,8 @@ async function handleMessagesForClientResources( resourcesToRemove: number[], trx: Transaction | typeof db = db ): Promise { - // Group resources by site - const resourcesBySite = new Map(); - - for (const resource of allNewResources) { - if (!resourcesBySite.has(resource.siteId)) { - resourcesBySite.set(resource.siteId, []); - } - resourcesBySite.get(resource.siteId)!.push(resource); - } - - let proxyJobs: Promise[] = []; - let olmJobs: Promise[] = []; + const proxyJobs: Promise[] = []; + const olmJobs: Promise[] = []; // Handle additions if (resourcesToAdd.length > 0) { diff --git a/server/private/license/license.ts b/server/private/license/license.ts index db3db509..f8f774c6 100644 --- a/server/private/license/license.ts +++ b/server/private/license/license.ts @@ -84,14 +84,11 @@ LQIDAQAB -----END PUBLIC KEY-----`; constructor(private hostMeta: HostMeta) { - setInterval( - async () => { - this.doRecheck = true; - await this.check(); - this.doRecheck = false; - }, - 1000 * this.phoneHomeInterval - ); + setInterval(async () => { + this.doRecheck = true; + await this.check(); + this.doRecheck = false; + }, 1000 * this.phoneHomeInterval); } public listKeys(): LicenseKeyCache[] { @@ -242,7 +239,9 @@ LQIDAQAB // First failure: fail silently logger.error("Error communicating with license server:"); logger.error(e); - logger.error(`Allowing failure. Will retry one more time at next run interval.`); + logger.error( + `Allowing failure. Will retry one more time at next run interval.` + ); // return last known good status return this.statusCache.get( this.statusKey diff --git a/server/routers/badger/logRequestAudit.ts b/server/routers/badger/logRequestAudit.ts index 1cf97f98..1343bdaa 100644 --- a/server/routers/badger/logRequestAudit.ts +++ b/server/routers/badger/logRequestAudit.ts @@ -148,7 +148,7 @@ export async function cleanUpOldLogs(orgId: string, retentionDays: number) { } } -export function logRequestAudit( +export async function logRequestAudit( data: { action: boolean; reason: number; @@ -174,14 +174,13 @@ export function logRequestAudit( } ) { try { - // Quick synchronous check - if org has 0 retention, skip immediately + // Check retention before buffering any logs if (data.orgId) { - const cached = cache.get(`org_${data.orgId}_retentionDays`); - if (cached === 0) { + const retentionDays = await getRetentionDays(data.orgId); + if (retentionDays === 0) { // do not log return; } - // If not cached or > 0, we'll log it (async retention check happens in background) } let actorType: string | undefined; @@ -261,16 +260,6 @@ export function logRequestAudit( } else { scheduleFlush(); } - - // Async retention check in background (don't await) - if ( - data.orgId && - cache.get(`org_${data.orgId}_retentionDays`) === undefined - ) { - getRetentionDays(data.orgId).catch((err) => - logger.error("Error checking retention days:", err) - ); - } } catch (error) { logger.error(error); } diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/ExitNodesTable.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/ExitNodesTable.tsx index a38f3b86..e5250bea 100644 --- a/src/app/[orgId]/settings/(private)/remote-exit-nodes/ExitNodesTable.tsx +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/ExitNodesTable.tsx @@ -304,7 +304,7 @@ export default function ExitNodesTable({ setSelectedNode(null); }} dialog={ -
+

{t("remoteExitNodeQuestionRemove")}

{t("remoteExitNodeMessageRemove")}

diff --git a/src/app/[orgId]/settings/general/page.tsx b/src/app/[orgId]/settings/general/page.tsx index e391922f..97dd4a03 100644 --- a/src/app/[orgId]/settings/general/page.tsx +++ b/src/app/[orgId]/settings/general/page.tsx @@ -289,7 +289,7 @@ export default function GeneralPage() { setIsDeleteModalOpen(val); }} dialog={ -
+

{t("orgQuestionRemove")}

{t("orgMessageRemove")}

@@ -303,7 +303,7 @@ export default function GeneralPage() { open={isSecurityPolicyConfirmOpen} setOpen={setIsSecurityPolicyConfirmOpen} dialog={ -
+

{t("securityPolicyChangeDescription")}

} diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/rules/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/rules/page.tsx index 39badea4..78a1b896 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/rules/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/rules/page.tsx @@ -449,15 +449,16 @@ export default function ResourceRules(props: { type="number" onClick={(e) => e.currentTarget.focus()} onBlur={(e) => { - const parsed = z + const parsed = z.coerce + .number() .int() .optional() .safeParse(e.target.value); - if (!parsed.data) { + if (!parsed.success) { toast({ variant: "destructive", - title: t("rulesErrorInvalidIpAddress"), // correct priority or IP? + title: t("rulesErrorInvalidPriority"), // correct priority or IP? description: t( "rulesErrorInvalidPriorityDescription" ) diff --git a/src/app/admin/license/page.tsx b/src/app/admin/license/page.tsx index ac6d3e67..4e0586bd 100644 --- a/src/app/admin/license/page.tsx +++ b/src/app/admin/license/page.tsx @@ -315,7 +315,7 @@ export default function LicensePage() { setSelectedLicenseKey(null); }} dialog={ -
+

{t("licenseQuestionRemove")}

{t("licenseMessageRemove")} @@ -360,7 +360,8 @@ export default function LicensePage() {

- {t("licensed")} + {t("licensed") + + `${licenseStatus?.tier === "personal" ? ` (${t("personalUseOnly")})` : ""}`}
) : ( diff --git a/src/app/admin/users/AdminUsersTable.tsx b/src/app/admin/users/AdminUsersTable.tsx index efcf9484..1c7d1b7f 100644 --- a/src/app/admin/users/AdminUsersTable.tsx +++ b/src/app/admin/users/AdminUsersTable.tsx @@ -243,7 +243,7 @@ export default function UsersTable({ users }: Props) { setSelected(null); }} dialog={ -
+

{t("userQuestionRemove")}

{t("userMessageRemove")}

diff --git a/src/app/auth/layout.tsx b/src/app/auth/layout.tsx index 70439824..6a72006b 100644 --- a/src/app/auth/layout.tsx +++ b/src/app/auth/layout.tsx @@ -23,6 +23,7 @@ export default async function AuthLayout({ children }: AuthLayoutProps) { const t = await getTranslations(); let hideFooter = false; + let licenseStatus: GetLicenseStatusResponse | null = null; if (build == "enterprise") { const licenseStatusRes = await cache( async () => @@ -30,10 +31,12 @@ export default async function AuthLayout({ children }: AuthLayoutProps) { "/license/status" ) )(); + licenseStatus = licenseStatusRes.data.data; if ( env.branding.hideAuthLayoutFooter && licenseStatusRes.data.data.isHostLicensed && - licenseStatusRes.data.data.isLicenseValid + licenseStatusRes.data.data.isLicenseValid && + licenseStatusRes.data.data.tier !== "personal" ) { hideFooter = true; } @@ -83,6 +86,23 @@ export default async function AuthLayout({ children }: AuthLayoutProps) { ? t("enterpriseEdition") : t("pangolinCloud")} + {build === "enterprise" && + licenseStatus?.isHostLicensed && + licenseStatus?.isLicenseValid && + licenseStatus?.tier === "personal" ? ( + <> + + {t("personalUseOnly")} + + ) : null} + {build === "enterprise" && + (!licenseStatus?.isHostLicensed || + !licenseStatus?.isLicenseValid) ? ( + <> + + {t("unlicensed")} + + ) : null} {build === "saas" && ( <> diff --git a/src/components/AdminIdpTable.tsx b/src/components/AdminIdpTable.tsx index 76a0fdd7..75a7c545 100644 --- a/src/components/AdminIdpTable.tsx +++ b/src/components/AdminIdpTable.tsx @@ -196,7 +196,7 @@ export default function IdpTable({ idps }: Props) { setSelectedIdp(null); }} dialog={ -
+

{t("idpQuestionRemove", { name: selectedIdp.name diff --git a/src/components/AdminUsersTable.tsx b/src/components/AdminUsersTable.tsx index 9c741cee..327d4752 100644 --- a/src/components/AdminUsersTable.tsx +++ b/src/components/AdminUsersTable.tsx @@ -313,7 +313,7 @@ export default function UsersTable({ users }: Props) { setSelected(null); }} dialog={ -

+

{t("userQuestionRemove", { selectedUser: diff --git a/src/components/ApiKeysTable.tsx b/src/components/ApiKeysTable.tsx index c3202277..8987fa2c 100644 --- a/src/components/ApiKeysTable.tsx +++ b/src/components/ApiKeysTable.tsx @@ -182,7 +182,7 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) { setSelected(null); }} dialog={ -

+

{t("apiKeysQuestionRemove")}

{t("apiKeysMessageRemove")}

diff --git a/src/components/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx index a5e257c7..3f65c762 100644 --- a/src/components/ClientResourcesTable.tsx +++ b/src/components/ClientResourcesTable.tsx @@ -284,7 +284,7 @@ export default function ClientResourcesTable({ setSelectedInternalResource(null); }} dialog={ -
+

{t("resourceQuestionRemove")}

{t("resourceMessageRemove")}

diff --git a/src/components/Credenza.tsx b/src/components/Credenza.tsx index 9d468e60..6a48fc54 100644 --- a/src/components/Credenza.tsx +++ b/src/components/Credenza.tsx @@ -177,7 +177,13 @@ const CredenzaFooter = ({ className, children, ...props }: CredenzaProps) => { const CredenzaFooter = isDesktop ? DialogFooter : SheetFooter; return ( - + {children} ); diff --git a/src/components/DataTablePagination.tsx b/src/components/DataTablePagination.tsx index be12ca47..4abcf1c5 100644 --- a/src/components/DataTablePagination.tsx +++ b/src/components/DataTablePagination.tsx @@ -24,6 +24,8 @@ interface DataTablePaginationProps { isServerPagination?: boolean; isLoading?: boolean; disabled?: boolean; + pageSize?: number; + pageIndex?: number; } export function DataTablePagination({ @@ -33,10 +35,26 @@ export function DataTablePagination({ totalCount, isServerPagination = false, isLoading = false, - disabled = false + disabled = false, + pageSize: controlledPageSize, + pageIndex: controlledPageIndex }: DataTablePaginationProps) { const t = useTranslations(); + // Use controlled values if provided, otherwise fall back to table state + const pageSize = controlledPageSize ?? table.getState().pagination.pageSize; + const pageIndex = + controlledPageIndex ?? table.getState().pagination.pageIndex; + + // Calculate page boundaries based on controlled state + // For server-side pagination, use totalCount if available for accurate page count + const pageCount = + isServerPagination && totalCount !== undefined + ? Math.ceil(totalCount / pageSize) + : table.getPageCount(); + const canNextPage = pageIndex < pageCount - 1; + const canPreviousPage = pageIndex > 0; + const handlePageSizeChange = (value: string) => { const newPageSize = Number(value); table.setPageSize(newPageSize); @@ -51,7 +69,7 @@ export function DataTablePagination({ action: "first" | "previous" | "next" | "last" ) => { if (isServerPagination && onPageChange) { - const currentPage = table.getState().pagination.pageIndex; + const currentPage = pageIndex; const pageCount = table.getPageCount(); let newPage: number; @@ -77,18 +95,24 @@ export function DataTablePagination({ } } else { // Use table's built-in navigation for client-side pagination + // But add bounds checking to prevent going beyond page boundaries + const pageCount = table.getPageCount(); switch (action) { case "first": table.setPageIndex(0); break; case "previous": - table.previousPage(); + if (pageIndex > 0) { + table.previousPage(); + } break; case "next": - table.nextPage(); + if (pageIndex < pageCount - 1) { + table.nextPage(); + } break; case "last": - table.setPageIndex(table.getPageCount() - 1); + table.setPageIndex(Math.max(0, pageCount - 1)); break; } } @@ -98,14 +122,12 @@ export function DataTablePagination({