diff --git a/package-lock.json b/package-lock.json index 6b4ca5f8..b594dea0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -128,6 +128,7 @@ "@types/express": "5.0.6", "@types/express-session": "1.18.2", "@types/jmespath": "0.15.2", + "@types/js-yaml": "4.0.9", "@types/jsonwebtoken": "9.0.10", "@types/node": "24.10.2", "@types/nodemailer": "7.0.4", @@ -9292,6 +9293,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", diff --git a/package.json b/package.json index b9c52173..63af7f51 100644 --- a/package.json +++ b/package.json @@ -34,9 +34,9 @@ }, "dependencies": { "@asteasolutions/zod-to-openapi": "8.2.0", + "@aws-sdk/client-s3": "3.947.0", "@faker-js/faker": "10.1.0", "@headlessui/react": "2.2.9", - "@aws-sdk/client-s3": "3.947.0", "@hookform/resolvers": "5.2.2", "@monaco-editor/react": "4.7.0", "@node-rs/argon2": "2.0.2", @@ -125,9 +125,8 @@ "semver": "7.7.3", "stripe": "20.0.0", "swagger-ui-express": "5.0.1", - "topojson-client": "3.1.0", - "tailwind-merge": "3.4.0", + "topojson-client": "3.1.0", "tw-animate-css": "1.4.0", "uuid": "13.0.0", "vaul": "1.1.2", @@ -155,8 +154,8 @@ "@types/jmespath": "0.15.2", "@types/jsonwebtoken": "9.0.10", "@types/node": "24.10.2", - "@types/nprogress": "0.2.3", "@types/nodemailer": "7.0.4", + "@types/nprogress": "0.2.3", "@types/pg": "8.15.6", "@types/react": "19.2.7", "@types/react-dom": "19.2.3", @@ -164,18 +163,19 @@ "@types/swagger-ui-express": "4.1.8", "@types/topojson-client": "3.1.5", "@types/ws": "8.18.1", - "babel-plugin-react-compiler": "1.0.0", "@types/yargs": "17.0.35", + "@types/js-yaml": "4.0.9", + "babel-plugin-react-compiler": "1.0.0", "drizzle-kit": "0.31.8", "esbuild": "0.27.1", "esbuild-node-externals": "1.20.1", "postcss": "8.5.6", + "prettier": "3.7.4", "react-email": "5.0.6", "tailwindcss": "4.1.17", - "prettier": "3.7.4", "tsc-alias": "1.8.16", "tsx": "4.21.0", "typescript": "5.9.3", "typescript-eslint": "8.49.0" } -} +} \ No newline at end of file diff --git a/server/lib/ip.ts b/server/lib/ip.ts index f3dabf43..36065df3 100644 --- a/server/lib/ip.ts +++ b/server/lib/ip.ts @@ -116,6 +116,68 @@ 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 { + if (!endpoint) return null; + + // Check for bracketed IPv6 format: [ip]:port + const bracketedMatch = endpoint.match(/^\[([^\]]+)\]:(\d+)$/); + if (bracketedMatch) { + const ip = bracketedMatch[1]; + const port = parseInt(bracketedMatch[2], 10); + if (isNaN(port)) return null; + return { ip, port }; + } + + // 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(":"); + const ip = endpoint.substring(0, lastColonIndex); + const portStr = endpoint.substring(lastColonIndex + 1); + const port = parseInt(portStr, 10); + if (isNaN(port)) return null; + return { ip, port }; + } + + // IPv4 format: ip:port + if (colonCount === 1) { + const [ip, portStr] = endpoint.split(":"); + const port = parseInt(portStr, 10); + if (isNaN(port)) return null; + return { ip, port }; + } + + return null; +} + +/** + * 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 + */ +export function formatEndpoint(ip: string, port: number): string { + // Check if this is IPv6 (contains colons) + if (ip.includes(":")) { + // Remove brackets if already present + const cleanIp = ip.replace(/^\[|\]$/g, ""); + return `[${cleanIp}]:${port}`; + } + return `${ip}:${port}`; +} + /** * Converts CIDR to IP range */ diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index 8729edaf..6ac2d42e 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -32,7 +32,9 @@ import logger from "@server/logger"; import { generateAliasConfig, generateRemoteSubnets, - generateSubnetProxyTargets + generateSubnetProxyTargets, + parseEndpoint, + formatEndpoint } from "@server/lib/ip"; import { addPeerData, @@ -542,6 +544,13 @@ export async function updateClientSiteDestinations( continue; } + // Parse the endpoint properly for both IPv4 and IPv6 + const parsedEndpoint = parseEndpoint(site.clientSitesAssociationsCache.endpoint); + if (!parsedEndpoint) { + logger.warn(`Failed to parse endpoint ${site.clientSitesAssociationsCache.endpoint}, skipping`); + continue; + } + // find the destinations in the array let destinations = exitNodeDestinations.find( (d) => d.reachableAt === site.exitNodes?.reachableAt @@ -553,13 +562,8 @@ export async function updateClientSiteDestinations( exitNodeId: site.exitNodes?.exitNodeId || 0, type: site.exitNodes?.type || "", name: site.exitNodes?.name || "", - sourceIp: - site.clientSitesAssociationsCache.endpoint.split(":")[0] || - "", - sourcePort: - parseInt( - site.clientSitesAssociationsCache.endpoint.split(":")[1] - ) || 0, + sourceIp: parsedEndpoint.ip, + sourcePort: parsedEndpoint.port, destinations: [ { destinationIP: site.sites.subnet.split("/")[0], diff --git a/server/routers/gerbil/updateHolePunch.ts b/server/routers/gerbil/updateHolePunch.ts index e1fa7c4c..3f24430b 100644 --- a/server/routers/gerbil/updateHolePunch.ts +++ b/server/routers/gerbil/updateHolePunch.ts @@ -21,6 +21,7 @@ import { validateOlmSessionToken } from "@server/auth/sessions/olm"; import { checkExitNodeOrg } from "#dynamic/lib/exitNodes"; import { updatePeer as updateOlmPeer } from "../olm/peers"; import { updatePeer as updateNewtPeer } from "../newt/peers"; +import { formatEndpoint } from "@server/lib/ip"; // Define Zod schema for request validation const updateHolePunchSchema = z.object({ @@ -207,9 +208,12 @@ export async function updateAndGenerateEndpointDestinations( // `Updating site ${site.siteId} on exit node ${exitNode.exitNodeId}` // ); + // Format the endpoint properly for both IPv4 and IPv6 + const formattedEndpoint = formatEndpoint(ip, port); + // if the public key or endpoint has changed, update it otherwise continue if ( - site.endpoint === `${ip}:${port}` && + site.endpoint === formattedEndpoint && site.publicKey === publicKey ) { continue; @@ -218,7 +222,7 @@ export async function updateAndGenerateEndpointDestinations( const [updatedClientSitesAssociationsCache] = await db .update(clientSitesAssociationsCache) .set({ - endpoint: `${ip}:${port}`, + endpoint: formattedEndpoint, publicKey: publicKey }) .where( @@ -310,11 +314,14 @@ export async function updateAndGenerateEndpointDestinations( currentSiteId = newt.siteId; + // Format the endpoint properly for both IPv4 and IPv6 + const formattedSiteEndpoint = formatEndpoint(ip, port); + // Update the current site with the new endpoint const [updatedSite] = await db .update(sites) .set({ - endpoint: `${ip}:${port}`, + endpoint: formattedSiteEndpoint, lastHolePunch: timestamp }) .where(eq(sites.siteId, newt.siteId))