Compare commits

...

12 Commits

Author SHA1 Message Date
Owen 18579c0647 Merge branch 'dev' into msg-delivery 2025-12-23 16:57:17 -05:00
Owen 2bb94e24eb Merge branch 'main' into dev 2025-12-23 16:57:01 -05:00
Owen 0d37e08638 Merge branch 'dev' into msg-delivery 2025-12-23 16:56:50 -05:00
miloschwartz a21f49cb02 add sticky actions col to org idp table 2025-12-23 14:58:58 -05:00
miloschwartz ef697c4864 adjustments to mobile header css closes #1930 2025-12-23 13:57:44 -05:00
miloschwartz 2652dea09a fade mobile footer 2025-12-23 13:41:11 -05:00
miloschwartz efa9312fca fix server admin spacing on mobile sidebar 2025-12-23 13:37:48 -05:00
miloschwartz 074ee70025 add flag to disable product help banners 2025-12-23 13:33:24 -05:00
miloschwartz 77117e48e3 improved button loading animation 2025-12-23 12:51:38 -05:00
miloschwartz da112d3417 add stripPortFromHost and reuse everywhere 2025-12-23 12:35:03 -05:00
Owen 75b9703793 Seperate config gen into functions 2025-12-20 11:41:23 -05:00
Owen 322f3bfb1d Add version and send it down 2025-12-19 16:44:57 -05:00
29 changed files with 600 additions and 254 deletions
+4
View File
@@ -84,6 +84,10 @@ export class Config {
?.disable_basic_wireguard_sites
? "true"
: "false";
process.env.FLAGS_DISABLE_PRODUCT_HELP_BANNERS = parsedConfig.flags
?.disable_product_help_banners
? "true"
: "false";
process.env.PRODUCT_UPDATES_NOTIFICATION_ENABLED = parsedConfig.app
.notifications.product_updates
+33
View File
@@ -4,6 +4,7 @@ import { and, eq, isNotNull } from "drizzle-orm";
import config from "@server/lib/config";
import z from "zod";
import logger from "@server/logger";
import semver from "semver";
interface IPRange {
start: bigint;
@@ -683,3 +684,35 @@ export function parsePortRangeString(
return result;
}
export function stripPortFromHost(ip: string, badgerVersion?: string): string {
const isNewerBadger =
badgerVersion &&
semver.valid(badgerVersion) &&
semver.gte(badgerVersion, "1.3.1");
if (isNewerBadger) {
return ip;
}
if (ip.startsWith("[") && ip.includes("]")) {
// if brackets are found, extract the IPv6 address from between the brackets
const ipv6Match = ip.match(/\[(.*?)\]/);
if (ipv6Match) {
return ipv6Match[1];
}
}
// Check if it looks like IPv4 (contains dots and matches IPv4 pattern)
// IPv4 format: x.x.x.x where x is 0-255
const ipv4Pattern = /^(\d{1,3}\.){3}\d{1,3}/;
if (ipv4Pattern.test(ip)) {
const lastColonIndex = ip.lastIndexOf(":");
if (lastColonIndex !== -1) {
return ip.substring(0, lastColonIndex);
}
}
// Return as is
return ip;
}
+2 -1
View File
@@ -330,7 +330,8 @@ export const configSchema = z
enable_integration_api: z.boolean().optional(),
disable_local_sites: z.boolean().optional(),
disable_basic_wireguard_sites: z.boolean().optional(),
disable_config_managed_domains: z.boolean().optional()
disable_config_managed_domains: z.boolean().optional(),
disable_product_help_banners: z.boolean().optional()
})
.optional(),
dns: z
+2 -13
View File
@@ -17,6 +17,7 @@ import logger from "@server/logger";
import { and, eq, lt } from "drizzle-orm";
import cache from "@server/lib/cache";
import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs";
import { stripPortFromHost } from "@server/lib/ip";
async function getAccessDays(orgId: string): Promise<number> {
// check cache first
@@ -116,19 +117,7 @@ export async function logAccessAudit(data: {
}
const clientIp = data.requestIp
? (() => {
if (
data.requestIp.startsWith("[") &&
data.requestIp.includes("]")
) {
// if brackets are found, extract the IPv6 address from between the brackets
const ipv6Match = data.requestIp.match(/\[(.*?)\]/);
if (ipv6Match) {
return ipv6Match[1];
}
}
return data.requestIp;
})()
? stripPortFromHost(data.requestIp)
: undefined;
const countryCode = data.requestIp
+14
View File
@@ -573,6 +573,20 @@ class RedisManager {
}
}
public async incr(key: string): Promise<number> {
if (!this.isRedisEnabled() || !this.writeClient) return 0;
try {
return await this.executeWithRetry(
() => this.writeClient!.incr(key),
"Redis INCR"
);
} catch (error) {
logger.error("Redis INCR error:", error);
return 0;
}
}
public async sadd(key: string, member: string): Promise<boolean> {
if (!this.isRedisEnabled() || !this.writeClient) return false;
+110 -16
View File
@@ -43,7 +43,8 @@ import {
WSMessage,
TokenPayload,
WebSocketRequest,
RedisMessage
RedisMessage,
SendMessageOptions
} from "@server/routers/ws";
import { validateSessionToken } from "@server/auth/sessions/app";
@@ -172,6 +173,9 @@ const REDIS_CHANNEL = "websocket_messages";
// Client tracking map (local to this node)
const connectedClients: Map<string, AuthenticatedWebSocket[]> = new Map();
// Config version tracking map (local to this node, resets on server restart)
const clientConfigVersions: Map<string, number> = new Map();
// Recovery tracking
let isRedisRecoveryInProgress = false;
@@ -182,6 +186,7 @@ const getClientMapKey = (clientId: string) => clientId;
const getConnectionsKey = (clientId: string) => `ws:connections:${clientId}`;
const getNodeConnectionsKey = (nodeId: string, clientId: string) =>
`ws:node:${nodeId}:${clientId}`;
const getConfigVersionKey = (clientId: string) => `ws:configVersion:${clientId}`;
// Initialize Redis subscription for cross-node messaging
const initializeRedisSubscription = async (): Promise<void> => {
@@ -377,17 +382,76 @@ const removeClient = async (
}
};
// Helper to get the current config version for a client
const getClientConfigVersion = async (clientId: string): Promise<number> => {
// Try Redis first if available
if (redisManager.isRedisEnabled()) {
try {
const redisVersion = await redisManager.get(getConfigVersionKey(clientId));
if (redisVersion !== null) {
const version = parseInt(redisVersion, 10);
// Sync local cache with Redis
clientConfigVersions.set(clientId, version);
return version;
}
} catch (error) {
logger.error("Failed to get config version from Redis:", error);
}
}
// Fall back to local cache
return clientConfigVersions.get(clientId) || 0;
};
// Helper to increment and get the new config version for a client
const incrementClientConfigVersion = async (clientId: string): Promise<number> => {
let newVersion: number;
if (redisManager.isRedisEnabled()) {
try {
// Use Redis INCR for atomic increment across nodes
newVersion = await redisManager.incr(getConfigVersionKey(clientId));
// Sync local cache
clientConfigVersions.set(clientId, newVersion);
return newVersion;
} catch (error) {
logger.error("Failed to increment config version in Redis:", error);
// Fall through to local increment
}
}
// Local increment
const currentVersion = clientConfigVersions.get(clientId) || 0;
newVersion = currentVersion + 1;
clientConfigVersions.set(clientId, newVersion);
return newVersion;
};
// Local message sending (within this node)
const sendToClientLocal = async (
clientId: string,
message: WSMessage
message: WSMessage,
options: SendMessageOptions = {}
): Promise<boolean> => {
const mapKey = getClientMapKey(clientId);
const clients = connectedClients.get(mapKey);
if (!clients || clients.length === 0) {
return false;
}
const messageString = JSON.stringify(message);
// Handle config version
let configVersion = await getClientConfigVersion(clientId);
if (options.incrementConfigVersion) {
configVersion = await incrementClientConfigVersion(clientId);
}
// Add config version to message
const messageWithVersion = {
...message,
configVersion
};
const messageString = JSON.stringify(messageWithVersion);
clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(messageString);
@@ -395,43 +459,69 @@ const sendToClientLocal = async (
});
logger.debug(
`sendToClient: Message type ${message.type} sent to clientId ${clientId}`
`sendToClient: Message type ${message.type} sent to clientId ${clientId} (configVersion: ${configVersion})`
);
return true;
};
const broadcastToAllExceptLocal = async (
message: WSMessage,
excludeClientId?: string
excludeClientId?: string,
options: SendMessageOptions = {}
): Promise<void> => {
connectedClients.forEach((clients, mapKey) => {
for (const [mapKey, clients] of connectedClients.entries()) {
const [type, id] = mapKey.split(":");
if (!(excludeClientId && id === excludeClientId)) {
const clientId = mapKey; // mapKey is the clientId
if (!(excludeClientId && clientId === excludeClientId)) {
// Handle config version per client
let configVersion = await getClientConfigVersion(clientId);
if (options.incrementConfigVersion) {
configVersion = await incrementClientConfigVersion(clientId);
}
// Add config version to message
const messageWithVersion = {
...message,
configVersion
};
clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify(message));
client.send(JSON.stringify(messageWithVersion));
}
});
}
});
}
};
// Cross-node message sending (via Redis)
const sendToClient = async (
clientId: string,
message: WSMessage
message: WSMessage,
options: SendMessageOptions = {}
): Promise<boolean> => {
// Try to send locally first
const localSent = await sendToClientLocal(clientId, message);
const localSent = await sendToClientLocal(clientId, message, options);
// Only send via Redis if the client is not connected locally and Redis is enabled
if (!localSent && redisManager.isRedisEnabled()) {
try {
// If we need to increment config version, do it before sending via Redis
// so remote nodes send the correct version
let configVersion = await getClientConfigVersion(clientId);
if (options.incrementConfigVersion) {
configVersion = await incrementClientConfigVersion(clientId);
}
const redisMessage: RedisMessage = {
type: "direct",
targetClientId: clientId,
message,
message: {
...message,
configVersion
},
fromNodeId: NODE_ID
};
@@ -458,19 +548,22 @@ const sendToClient = async (
const broadcastToAllExcept = async (
message: WSMessage,
excludeClientId?: string
excludeClientId?: string,
options: SendMessageOptions = {}
): Promise<void> => {
// Broadcast locally
await broadcastToAllExceptLocal(message, excludeClientId);
await broadcastToAllExceptLocal(message, excludeClientId, options);
// If Redis is enabled, also broadcast via Redis pub/sub to other nodes
// Note: For broadcasts, we include the options so remote nodes can handle versioning
if (redisManager.isRedisEnabled()) {
try {
const redisMessage: RedisMessage = {
type: "broadcast",
excludeClientId,
message,
fromNodeId: NODE_ID
fromNodeId: NODE_ID,
options
};
await redisManager.publish(
@@ -936,5 +1029,6 @@ export {
getActiveNodes,
disconnectClient,
NODE_ID,
cleanup
cleanup,
getClientConfigVersion
};
+2 -25
View File
@@ -10,6 +10,7 @@ import { eq, and, gt } from "drizzle-orm";
import { createSession, generateSessionToken } from "@server/auth/sessions/app";
import { encodeHexLowerCase } from "@oslojs/encoding";
import { sha256 } from "@oslojs/crypto/sha2";
import { stripPortFromHost } from "@server/lib/ip";
const paramsSchema = z.object({
code: z.string().min(1, "Code is required")
@@ -27,30 +28,6 @@ export type PollDeviceWebAuthResponse = {
token?: string;
};
// Helper function to extract IP from request (same as in startDeviceWebAuth)
function extractIpFromRequest(req: Request): string | undefined {
const ip = req.ip || req.socket.remoteAddress;
if (!ip) {
return undefined;
}
// Handle IPv6 format [::1] or IPv4 format
if (ip.startsWith("[") && ip.includes("]")) {
const ipv6Match = ip.match(/\[(.*?)\]/);
if (ipv6Match) {
return ipv6Match[1];
}
}
// Handle IPv4 with port (split at last colon)
const lastColonIndex = ip.lastIndexOf(":");
if (lastColonIndex !== -1) {
return ip.substring(0, lastColonIndex);
}
return ip;
}
export async function pollDeviceWebAuth(
req: Request,
res: Response,
@@ -70,7 +47,7 @@ export async function pollDeviceWebAuth(
try {
const { code } = parsedParams.data;
const now = Date.now();
const requestIp = extractIpFromRequest(req);
const requestIp = req.ip ? stripPortFromHost(req.ip) : undefined;
// Hash the code before querying
const hashedCode = hashDeviceCode(code);
+2 -25
View File
@@ -12,6 +12,7 @@ import { TimeSpan } from "oslo";
import { maxmindLookup } from "@server/db/maxmind";
import { encodeHexLowerCase } from "@oslojs/encoding";
import { sha256 } from "@oslojs/crypto/sha2";
import { stripPortFromHost } from "@server/lib/ip";
const bodySchema = z
.object({
@@ -39,30 +40,6 @@ function hashDeviceCode(code: string): string {
return encodeHexLowerCase(sha256(new TextEncoder().encode(code)));
}
// Helper function to extract IP from request
function extractIpFromRequest(req: Request): string | undefined {
const ip = req.ip;
if (!ip) {
return undefined;
}
// Handle IPv6 format [::1] or IPv4 format
if (ip.startsWith("[") && ip.includes("]")) {
const ipv6Match = ip.match(/\[(.*?)\]/);
if (ipv6Match) {
return ipv6Match[1];
}
}
// Handle IPv4 with port (split at last colon)
const lastColonIndex = ip.lastIndexOf(":");
if (lastColonIndex !== -1) {
return ip.substring(0, lastColonIndex);
}
return ip;
}
// Helper function to get city from IP (if available)
async function getCityFromIp(ip: string): Promise<string | undefined> {
try {
@@ -112,7 +89,7 @@ export async function startDeviceWebAuth(
const hashedCode = hashDeviceCode(code);
// Extract IP from request
const ip = extractIpFromRequest(req);
const ip = req.ip ? stripPortFromHost(req.ip) : undefined;
// Get city (optional, may return undefined)
const city = ip ? await getCityFromIp(ip) : undefined;
+2 -20
View File
@@ -19,6 +19,7 @@ import {
import { SESSION_COOKIE_EXPIRES as RESOURCE_SESSION_COOKIE_EXPIRES } from "@server/auth/sessions/resource";
import config from "@server/lib/config";
import { response } from "@server/lib/response";
import { stripPortFromHost } from "@server/lib/ip";
const exchangeSessionBodySchema = z.object({
requestToken: z.string(),
@@ -62,26 +63,7 @@ export async function exchangeSession(
cleanHost = cleanHost.slice(0, -1 * matched.length);
}
const clientIp = requestIp
? (() => {
if (requestIp.startsWith("[") && requestIp.includes("]")) {
const ipv6Match = requestIp.match(/\[(.*?)\]/);
if (ipv6Match) {
return ipv6Match[1];
}
}
const ipv4Pattern = /^(\d{1,3}\.){3}\d{1,3}/;
if (ipv4Pattern.test(requestIp)) {
const lastColonIndex = requestIp.lastIndexOf(":");
if (lastColonIndex !== -1) {
return requestIp.substring(0, lastColonIndex);
}
}
return requestIp;
})()
: undefined;
const clientIp = requestIp ? stripPortFromHost(requestIp) : undefined;
const [resource] = await db
.select()
+2 -20
View File
@@ -3,6 +3,7 @@ import logger from "@server/logger";
import { and, eq, lt } from "drizzle-orm";
import cache from "@server/lib/cache";
import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs";
import { stripPortFromHost } from "@server/lib/ip";
/**
@@ -208,26 +209,7 @@ export async function logRequestAudit(
}
const clientIp = body.requestIp
? (() => {
if (
body.requestIp.startsWith("[") &&
body.requestIp.includes("]")
) {
// if brackets are found, extract the IPv6 address from between the brackets
const ipv6Match = body.requestIp.match(/\[(.*?)\]/);
if (ipv6Match) {
return ipv6Match[1];
}
}
// ivp4
// split at last colon
const lastColonIndex = body.requestIp.lastIndexOf(":");
if (lastColonIndex !== -1) {
return body.requestIp.substring(0, lastColonIndex);
}
return body.requestIp;
})()
? stripPortFromHost(body.requestIp)
: undefined;
// Add to buffer instead of writing directly to DB
+2 -32
View File
@@ -21,7 +21,7 @@ import {
resourceSessions
} from "@server/db";
import config from "@server/lib/config";
import { isIpInCidr } from "@server/lib/ip";
import { isIpInCidr, stripPortFromHost } from "@server/lib/ip";
import { response } from "@server/lib/response";
import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode";
@@ -110,37 +110,7 @@ export async function verifyResourceSession(
const clientHeaderAuth = extractBasicAuth(headers);
const clientIp = requestIp
? (() => {
const isNewerBadger =
badgerVersion &&
semver.valid(badgerVersion) &&
semver.gte(badgerVersion, "1.3.1");
if (isNewerBadger) {
return requestIp;
}
if (requestIp.startsWith("[") && requestIp.includes("]")) {
// if brackets are found, extract the IPv6 address from between the brackets
const ipv6Match = requestIp.match(/\[(.*?)\]/);
if (ipv6Match) {
return ipv6Match[1];
}
}
// Check if it looks like IPv4 (contains dots and matches IPv4 pattern)
// IPv4 format: x.x.x.x where x is 0-255
const ipv4Pattern = /^(\d{1,3}\.){3}\d{1,3}/;
if (ipv4Pattern.test(requestIp)) {
const lastColonIndex = requestIp.lastIndexOf(":");
if (lastColonIndex !== -1) {
return requestIp.substring(0, lastColonIndex);
}
}
// Return as is
return requestIp;
})()
? stripPortFromHost(requestIp, badgerVersion)
: undefined;
logger.debug("Client IP:", { clientIp });
+36 -17
View File
@@ -7,7 +7,8 @@ import {
ExitNode,
exitNodes,
siteResources,
clientSiteResourcesAssociationsCache
clientSiteResourcesAssociationsCache,
Site
} from "@server/db";
import { clients, clientSitesAssociationsCache, Newt, sites } from "@server/db";
import { eq } from "drizzle-orm";
@@ -130,6 +131,38 @@ export const handleGetConfigMessage: MessageHandler = async (context) => {
}
}
const { peers, targets } = await buildClientConfigurationForNewtClient(
site,
exitNode
);
// Build the configuration response
const configResponse = {
ipAddress: site.address,
peers,
targets
};
logger.debug("Sending config: ", configResponse);
return {
message: {
type: "newt/wg/receive-config",
data: {
...configResponse
}
},
broadcast: false,
excludeSender: false,
};
};
export async function buildClientConfigurationForNewtClient(
site: Site,
exitNode?: ExitNode
) {
const siteId = site.siteId;
// Get all clients connected to this site
const clientsRes = await db
.select()
@@ -278,22 +311,8 @@ export const handleGetConfigMessage: MessageHandler = async (context) => {
targetsToSend.push(...resourceTargets);
}
// Build the configuration response
const configResponse = {
ipAddress: site.address,
return {
peers: validPeers,
targets: targetsToSend
};
logger.debug("Sending config: ", configResponse);
return {
message: {
type: "newt/wg/receive-config",
data: {
...configResponse
}
},
broadcast: false,
excludeSender: false
};
};
}
@@ -0,0 +1,141 @@
import { db } from "@server/db";
import { disconnectClient } from "#dynamic/routers/ws";
import { getClientConfigVersion, MessageHandler } from "@server/routers/ws";
import { clients, Newt } from "@server/db";
import { eq, lt, isNull, and, or } from "drizzle-orm";
import logger from "@server/logger";
import { validateSessionToken } from "@server/auth/sessions/app";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { sendTerminateClient } from "../client/terminate";
import { encodeHexLowerCase } from "@oslojs/encoding";
import { sha256 } from "@oslojs/crypto/sha2";
// Track if the offline checker interval is running
// let offlineCheckerInterval: NodeJS.Timeout | null = null;
// const OFFLINE_CHECK_INTERVAL = 30 * 1000; // Check every 30 seconds
// const OFFLINE_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes
/**
* Starts the background interval that checks for clients that haven't pinged recently
* and marks them as offline
*/
// export const startNewtOfflineChecker = (): void => {
// if (offlineCheckerInterval) {
// return; // Already running
// }
// offlineCheckerInterval = setInterval(async () => {
// try {
// const twoMinutesAgo = Math.floor(
// (Date.now() - OFFLINE_THRESHOLD_MS) / 1000
// );
// // TODO: WE NEED TO MAKE SURE THIS WORKS WITH DISTRIBUTED NODES ALL DOING THE SAME THING
// // Find clients that haven't pinged in the last 2 minutes and mark them as offline
// const offlineClients = await db
// .update(clients)
// .set({ online: false })
// .where(
// and(
// eq(clients.online, true),
// or(
// lt(clients.lastPing, twoMinutesAgo),
// isNull(clients.lastPing)
// )
// )
// )
// .returning();
// for (const offlineClient of offlineClients) {
// logger.info(
// `Kicking offline newt client ${offlineClient.clientId} due to inactivity`
// );
// if (!offlineClient.newtId) {
// logger.warn(
// `Offline client ${offlineClient.clientId} has no newtId, cannot disconnect`
// );
// continue;
// }
// // Send a disconnect message to the client if connected
// try {
// await sendTerminateClient(
// offlineClient.clientId,
// offlineClient.newtId
// ); // terminate first
// // wait a moment to ensure the message is sent
// await new Promise((resolve) => setTimeout(resolve, 1000));
// await disconnectClient(offlineClient.newtId);
// } catch (error) {
// logger.error(
// `Error sending disconnect to offline newt ${offlineClient.clientId}`,
// { error }
// );
// }
// }
// } catch (error) {
// logger.error("Error in offline checker interval", { error });
// }
// }, OFFLINE_CHECK_INTERVAL);
// logger.debug("Started offline checker interval");
// };
/**
* Stops the background interval that checks for offline clients
*/
// export const stopNewtOfflineChecker = (): void => {
// if (offlineCheckerInterval) {
// clearInterval(offlineCheckerInterval);
// offlineCheckerInterval = null;
// logger.info("Stopped offline checker interval");
// }
// };
/**
* Handles ping messages from clients and responds with pong
*/
export const handleNewtPingMessage: MessageHandler = async (context) => {
const { message, client: c, sendToClient } = context;
const newt = c as Newt;
if (!newt) {
logger.warn("Newt not found");
return;
}
// get the version
const configVersion = await getClientConfigVersion(newt.newtId);
if (message.configVersion && configVersion != message.configVersion) {
logger.warn(`Newt ping with outdated config version: ${message.configVersion} (current: ${configVersion})`);
// TODO: sync the client
}
// try {
// // Update the client's last ping timestamp
// await db
// .update(clients)
// .set({
// lastPing: Math.floor(Date.now() / 1000),
// online: true
// })
// .where(eq(clients.clientId, newt.clientId));
// } catch (error) {
// logger.error("Error handling ping message", { error });
// }
return {
message: {
type: "pong",
data: {
timestamp: new Date().toISOString()
}
},
broadcast: false,
excludeSender: false
};
};
@@ -233,6 +233,35 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
.where(eq(newts.newtId, newt.newtId));
}
const { tcpTargets, udpTargets, validHealthCheckTargets } =
await buildTargetConfigurationForNewtClient(siteId);
logger.debug(
`Sending health check targets to newt ${newt.newtId}: ${JSON.stringify(validHealthCheckTargets)}`
);
return {
message: {
type: "newt/wg/connect",
data: {
endpoint: `${exitNode.endpoint}:${exitNode.listenPort}`,
relayPort: config.getRawConfig().gerbil.clients_start_port,
publicKey: exitNode.publicKey,
serverIP: exitNode.address.split("/")[0],
tunnelIP: siteSubnet.split("/")[0],
targets: {
udp: udpTargets,
tcp: tcpTargets
},
healthCheckTargets: validHealthCheckTargets
}
},
broadcast: false, // Send to all clients
excludeSender: false // Include sender in broadcast
};
};
export async function buildTargetConfigurationForNewtClient(siteId: number) {
// Get all enabled targets with their resource protocol information
const allTargets = await db
.select({
@@ -337,30 +366,12 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
(target) => target !== null
);
logger.debug(
`Sending health check targets to newt ${newt.newtId}: ${JSON.stringify(validHealthCheckTargets)}`
);
return {
message: {
type: "newt/wg/connect",
data: {
endpoint: `${exitNode.endpoint}:${exitNode.listenPort}`,
relayPort: config.getRawConfig().gerbil.clients_start_port,
publicKey: exitNode.publicKey,
serverIP: exitNode.address.split("/")[0],
tunnelIP: siteSubnet.split("/")[0],
targets: {
udp: udpTargets,
tcp: tcpTargets
},
healthCheckTargets: validHealthCheckTargets
}
},
broadcast: false, // Send to all clients
excludeSender: false // Include sender in broadcast
validHealthCheckTargets,
tcpTargets,
udpTargets
};
};
}
async function getUniqueSubnetForSite(
exitNode: ExitNode,
+1
View File
@@ -6,3 +6,4 @@ export * from "./handleGetConfigMessage";
export * from "./handleSocketMessages";
export * from "./handleNewtPingRequestMessage";
export * from "./handleApplyBlueprintMessage";
export * from "./handleNewtPingMessage";
+10 -1
View File
@@ -1,6 +1,6 @@
import { db } from "@server/db";
import { disconnectClient } from "#dynamic/routers/ws";
import { MessageHandler } from "@server/routers/ws";
import { getClientConfigVersion, MessageHandler } from "@server/routers/ws";
import { clients, Olm } from "@server/db";
import { eq, lt, isNull, and, or } from "drizzle-orm";
import logger from "@server/logger";
@@ -108,6 +108,15 @@ export const handleOlmPingMessage: MessageHandler = async (context) => {
return;
}
// get the version
const configVersion = await getClientConfigVersion(olm.olmId);
if (message.configVersion && configVersion != message.configVersion) {
logger.warn(`Olm ping with outdated config version: ${message.configVersion} (current: ${configVersion})`);
// TODO: sync the client
}
if (olm.userId) {
// we need to check a user token to make sure its still valid
const { session: userSession, user } =
+62 -37
View File
@@ -1,4 +1,5 @@
import {
Client,
clientSiteResourcesAssociationsCache,
db,
orgs,
@@ -13,7 +14,7 @@ import {
olms,
sites
} from "@server/db";
import { and, eq, inArray, isNull } from "drizzle-orm";
import { and, count, eq, inArray, isNull } from "drizzle-orm";
import { addPeer, deletePeer } from "../newt/peers";
import logger from "@server/logger";
import { generateAliasConfig } from "@server/lib/ip";
@@ -144,6 +145,64 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
.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.
// 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 ???
if (now - (client.lastHolePunch || 0) > 5 && sitesCount > 0) {
logger.warn(
"Client last hole punch is too old and we have sites to send; skipping this register"
);
return;
}
const siteConfigurations = await buildSiteConfigurationForOlmClient(client, publicKey, relay);
// 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 {
message: {
type: "olm/wg/connect",
data: {
sites: siteConfigurations,
tunnelIP: client.subnet,
utilitySubnet: org.utilitySubnet
}
},
broadcast: false,
excludeSender: false
};
};
export async function buildSiteConfigurationForOlmClient(
client: Client,
publicKey: string,
relay: boolean
) {
const siteConfigurations = [];
// Get all sites data
const sitesData = await db
.select()
@@ -154,22 +213,6 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
)
.where(eq(clientSitesAssociationsCache.clientId, client.clientId));
// Prepare an array to store site configurations
const siteConfigurations = [];
logger.debug(
`Found ${sitesData.length} sites for client ${client.clientId}`
);
// 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
// TODO: I still think there is a better way to do this rather than locking it out here but ???
if (now - (client.lastHolePunch || 0) > 5 && sitesData.length > 0) {
logger.warn(
"Client last hole punch is too old and we have sites to send; skipping this register"
);
return;
}
// Process each site
for (const {
sites: site,
@@ -289,23 +332,5 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
});
}
// 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 {
message: {
type: "olm/wg/connect",
data: {
sites: siteConfigurations,
tunnelIP: client.subnet,
utilitySubnet: org.utilitySubnet
}
},
broadcast: false,
excludeSender: false
};
};
return siteConfigurations;
}
+3 -1
View File
@@ -5,7 +5,8 @@ import {
handleDockerStatusMessage,
handleDockerContainersMessage,
handleNewtPingRequestMessage,
handleApplyBlueprintMessage
handleApplyBlueprintMessage,
handleNewtPingMessage
} from "../newt";
import {
handleOlmRegisterMessage,
@@ -24,6 +25,7 @@ export const messageHandlers: Record<string, MessageHandler> = {
"olm/wg/relay": handleOlmRelayMessage,
"olm/wg/unrelay": handleOlmUnRelayMessage,
"olm/ping": handleOlmPingMessage,
"newt/ping": handleNewtPingMessage,
"newt/wg/register": handleNewtRegisterMessage,
"newt/wg/get-config": handleGetConfigMessage,
"newt/receive-bandwidth": handleReceiveBandwidthMessage,
+15 -2
View File
@@ -25,6 +25,7 @@ export interface AuthenticatedWebSocket extends WebSocket {
connectionId?: string;
isFullyConnected?: boolean;
pendingMessages?: Buffer[];
configVersion?: number;
}
export interface TokenPayload {
@@ -36,6 +37,7 @@ export interface TokenPayload {
export interface WSMessage {
type: string;
data: any;
configVersion?: number;
}
export interface HandlerResponse {
@@ -50,10 +52,15 @@ export interface HandlerContext {
senderWs: WebSocket;
client: Newt | Olm | RemoteExitNode | undefined;
clientType: ClientType;
sendToClient: (clientId: string, message: WSMessage) => Promise<boolean>;
sendToClient: (
clientId: string,
message: WSMessage,
options?: SendMessageOptions
) => Promise<boolean>;
broadcastToAllExcept: (
message: WSMessage,
excludeClientId?: string
excludeClientId?: string,
options?: SendMessageOptions
) => Promise<void>;
connectedClients: Map<string, WebSocket[]>;
}
@@ -62,6 +69,11 @@ export type MessageHandler = (
context: HandlerContext
) => Promise<HandlerResponse | void>;
// Options for sending messages with config version tracking
export interface SendMessageOptions {
incrementConfigVersion?: boolean;
}
// Redis message type for cross-node communication
export interface RedisMessage {
type: "direct" | "broadcast";
@@ -69,4 +81,5 @@ export interface RedisMessage {
excludeClientId?: string;
message: WSMessage;
fromNodeId: string;
options?: SendMessageOptions;
}
+59 -11
View File
@@ -15,7 +15,8 @@ import {
TokenPayload,
WebSocketRequest,
WSMessage,
AuthenticatedWebSocket
AuthenticatedWebSocket,
SendMessageOptions
} from "./types";
import { validateSessionToken } from "@server/auth/sessions/app";
@@ -34,6 +35,8 @@ const NODE_ID = uuidv4();
// Client tracking map (local to this node)
const connectedClients: Map<string, AuthenticatedWebSocket[]> = new Map();
// Config version tracking map (clientId -> version)
const clientConfigVersions: Map<string, number> = new Map();
// Helper to get map key
const getClientMapKey = (clientId: string) => clientId;
@@ -84,14 +87,34 @@ const removeClient = async (
// Local message sending (within this node)
const sendToClientLocal = async (
clientId: string,
message: WSMessage
message: WSMessage,
options: SendMessageOptions = {}
): Promise<boolean> => {
const mapKey = getClientMapKey(clientId);
const clients = connectedClients.get(mapKey);
if (!clients || clients.length === 0) {
return false;
}
const messageString = JSON.stringify(message);
// Increment config version if requested
if (options.incrementConfigVersion) {
const currentVersion = clientConfigVersions.get(clientId) || 0;
const newVersion = currentVersion + 1;
clientConfigVersions.set(clientId, newVersion);
// Update version on all client connections
clients.forEach((client) => {
client.configVersion = newVersion;
});
}
// Include config version in message
const configVersion = clientConfigVersions.get(clientId) || 0;
const messageWithVersion = {
...message,
configVersion
};
const messageString = JSON.stringify(messageWithVersion);
clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(messageString);
@@ -102,14 +125,31 @@ const sendToClientLocal = async (
const broadcastToAllExceptLocal = async (
message: WSMessage,
excludeClientId?: string
excludeClientId?: string,
options: SendMessageOptions = {}
): Promise<void> => {
connectedClients.forEach((clients, mapKey) => {
const [type, id] = mapKey.split(":");
if (!(excludeClientId && id === excludeClientId)) {
const clientId = mapKey; // mapKey is the clientId
if (!(excludeClientId && clientId === excludeClientId)) {
// Handle config version per client
if (options.incrementConfigVersion) {
const currentVersion = clientConfigVersions.get(clientId) || 0;
const newVersion = currentVersion + 1;
clientConfigVersions.set(clientId, newVersion);
clients.forEach((client) => {
client.configVersion = newVersion;
});
}
// Include config version in message for this client
const configVersion = clientConfigVersions.get(clientId) || 0;
const messageWithVersion = {
...message,
configVersion
};
clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify(message));
client.send(JSON.stringify(messageWithVersion));
}
});
}
@@ -119,10 +159,11 @@ const broadcastToAllExceptLocal = async (
// Cross-node message sending
const sendToClient = async (
clientId: string,
message: WSMessage
message: WSMessage,
options: SendMessageOptions = {}
): Promise<boolean> => {
// Try to send locally first
const localSent = await sendToClientLocal(clientId, message);
const localSent = await sendToClientLocal(clientId, message, options);
logger.debug(
`sendToClient: Message type ${message.type} sent to clientId ${clientId}`
@@ -133,10 +174,11 @@ const sendToClient = async (
const broadcastToAllExcept = async (
message: WSMessage,
excludeClientId?: string
excludeClientId?: string,
options: SendMessageOptions = {}
): Promise<void> => {
// Broadcast locally
await broadcastToAllExceptLocal(message, excludeClientId);
await broadcastToAllExceptLocal(message, excludeClientId, options);
};
// Check if a client has active connections across all nodes
@@ -146,6 +188,11 @@ const hasActiveConnections = async (clientId: string): Promise<boolean> => {
return !!(clients && clients.length > 0);
};
// Get the current config version for a client
const getClientConfigVersion = async (clientId: string): Promise<number> => {
return clientConfigVersions.get(clientId) || 0;
};
// Get all active nodes for a client
const getActiveNodes = async (
clientType: ClientType,
@@ -434,5 +481,6 @@ export {
getActiveNodes,
disconnectClient,
NODE_ID,
cleanup
cleanup,
getClientConfigVersion
};
+17
View File
@@ -162,3 +162,20 @@ p {
#nprogress .bar {
background: var(--color-primary) !important;
}
@keyframes dot-pulse {
0%, 80%, 100% {
opacity: 0.3;
transform: scale(0.8);
}
40% {
opacity: 1;
transform: scale(1);
}
}
@layer utilities {
.animate-dot-pulse {
animation: dot-pulse 1.4s ease-in-out infinite;
}
}
+8 -1
View File
@@ -1,9 +1,10 @@
"use client";
import React, { useState, useEffect, type ReactNode } from "react";
import React, { useState, useEffect, type ReactNode, useEffectEvent } from "react";
import { Card, CardContent } from "@app/components/ui/card";
import { X } from "lucide-react";
import { useTranslations } from "next-intl";
import { useEnvContext } from "@app/hooks/useEnvContext";
type DismissableBannerProps = {
storageKey: string;
@@ -25,6 +26,12 @@ export const DismissableBanner = ({
const [isDismissed, setIsDismissed] = useState(true);
const t = useTranslations();
const { env } = useEnvContext();
if (env.flags.disableProductHelpBanners) {
return null;
}
useEffect(() => {
const dismissedData = localStorage.getItem(storageKey);
if (dismissedData) {
+1 -1
View File
@@ -75,7 +75,7 @@ export async function Layout({
<div
className={cn(
"container mx-auto max-w-12xl mb-12",
showHeader && "md:pt-16" // Add top padding only on desktop to account for fixed header
showHeader && "pt-16 md:pt-16" // Add top padding on mobile and desktop to account for fixed header
)}
>
{children}
+4 -3
View File
@@ -48,7 +48,7 @@ export function LayoutMobileMenu({
const t = useTranslations();
return (
<div className="shrink-0 md:hidden">
<div className="shrink-0 md:hidden fixed top-0 left-0 right-0 z-50 bg-card border-b border-border">
<div className="h-16 flex items-center px-2">
<div className="flex items-center gap-4">
{showSidebar && (
@@ -72,7 +72,7 @@ export function LayoutMobileMenu({
<SheetDescription className="sr-only">
{t("navbarDescription")}
</SheetDescription>
<div className="flex-1 overflow-y-auto">
<div className="flex-1 overflow-y-auto relative">
<div className="px-3">
<OrgSelector
orgId={orgId}
@@ -83,7 +83,7 @@ export function LayoutMobileMenu({
<div className="px-3">
{!isAdminPage &&
user.serverAdmin && (
<div className="pb-3">
<div className="py-2">
<Link
href="/admin"
className={cn(
@@ -113,6 +113,7 @@ export function LayoutMobileMenu({
}
/>
</div>
<div className="sticky bottom-0 left-0 right-0 h-8 pointer-events-none bg-gradient-to-t from-card to-transparent" />
</div>
<div className="px-3 pt-3 pb-3 space-y-4 border-t shrink-0">
<SupporterStatus />
@@ -27,6 +27,8 @@ export function IdpDataTable<TData, TValue>({
searchColumn="name"
addButtonText={t("idpAdd")}
onAdd={onAdd}
enableColumnVisibility={true}
stickyRightColumn="actions"
/>
);
}
+1
View File
@@ -118,6 +118,7 @@ export default function IdpTable({ idps, orgId }: Props) {
},
{
id: "actions",
enableHiding: false,
header: () => <span className="p-3">{t("actions")}</span>,
cell: ({ row }) => {
const siteRow = row.original;
+26 -5
View File
@@ -3,7 +3,6 @@ import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@app/lib/cn";
import { Loader2 } from "lucide-react";
const buttonVariants = cva(
"cursor-pointer inline-flex items-center justify-center whitespace-nowrap text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 disabled:pointer-events-none disabled:opacity-50",
@@ -75,12 +74,34 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
{asChild ? (
props.children
) : (
<>
<span className="relative inline-flex items-center justify-center">
<span
className={cn(
"inline-flex items-center justify-center",
loading && "opacity-0"
)}
>
{props.children}
</span>
{loading && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<span className="absolute inset-0 flex items-center justify-center">
<span className="flex items-center gap-1">
<span
className="h-1 w-1 bg-current animate-dot-pulse"
style={{ animationDelay: "0ms" }}
/>
<span
className="h-1 w-1 bg-current animate-dot-pulse"
style={{ animationDelay: "200ms" }}
/>
<span
className="h-1 w-1 bg-current animate-dot-pulse"
style={{ animationDelay: "400ms" }}
/>
</span>
</span>
)}
{props.children}
</>
</span>
)}
</Comp>
);
+5 -1
View File
@@ -59,7 +59,11 @@ export function pullEnv(): Env {
hideSupporterKey:
process.env.HIDE_SUPPORTER_KEY === "true" ? true : false,
usePangolinDns:
process.env.USE_PANGOLIN_DNS === "true" ? true : false
process.env.USE_PANGOLIN_DNS === "true" ? true : false,
disableProductHelpBanners:
process.env.FLAGS_DISABLE_PRODUCT_HELP_BANNERS === "true"
? true
: false
},
branding: {
+1
View File
@@ -33,6 +33,7 @@ export type Env = {
disableBasicWireguardSites: boolean;
hideSupporterKey: boolean;
usePangolinDns: boolean;
disableProductHelpBanners: boolean;
};
branding: {
appName?: string;