mirror of
https://github.com/fosrl/pangolin.git
synced 2026-09-11 05:26:32 +02:00
Compare commits
12 Commits
1.14.1
...
18579c0647
| Author | SHA1 | Date | |
|---|---|---|---|
| 18579c0647 | |||
| 2bb94e24eb | |||
| 0d37e08638 | |||
| a21f49cb02 | |||
| ef697c4864 | |||
| 2652dea09a | |||
| efa9312fca | |||
| 074ee70025 | |||
| 77117e48e3 | |||
| da112d3417 | |||
| 75b9703793 | |||
| 322f3bfb1d |
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -6,3 +6,4 @@ export * from "./handleGetConfigMessage";
|
||||
export * from "./handleSocketMessages";
|
||||
export * from "./handleNewtPingRequestMessage";
|
||||
export * from "./handleApplyBlueprintMessage";
|
||||
export * from "./handleNewtPingMessage";
|
||||
|
||||
@@ -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 } =
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
@@ -33,6 +33,7 @@ export type Env = {
|
||||
disableBasicWireguardSites: boolean;
|
||||
hideSupporterKey: boolean;
|
||||
usePangolinDns: boolean;
|
||||
disableProductHelpBanners: boolean;
|
||||
};
|
||||
branding: {
|
||||
appName?: string;
|
||||
|
||||
Reference in New Issue
Block a user