diff --git a/server/lib/ip.ts b/server/lib/ip.ts index f9b3cb61..b2ff58d6 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 134cbc06..60384fcf 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -33,6 +33,8 @@ import { generateAliasConfig, generateRemoteSubnets, generateSubnetProxyTargets, + parseEndpoint, + formatEndpoint } from "@server/lib/ip"; import { addPeerData, @@ -541,6 +543,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 @@ -552,13 +561,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))