mirror of
https://github.com/fosrl/pangolin.git
synced 2026-09-11 13:31:27 +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
|
?.disable_basic_wireguard_sites
|
||||||
? "true"
|
? "true"
|
||||||
: "false";
|
: "false";
|
||||||
|
process.env.FLAGS_DISABLE_PRODUCT_HELP_BANNERS = parsedConfig.flags
|
||||||
|
?.disable_product_help_banners
|
||||||
|
? "true"
|
||||||
|
: "false";
|
||||||
|
|
||||||
process.env.PRODUCT_UPDATES_NOTIFICATION_ENABLED = parsedConfig.app
|
process.env.PRODUCT_UPDATES_NOTIFICATION_ENABLED = parsedConfig.app
|
||||||
.notifications.product_updates
|
.notifications.product_updates
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { and, eq, isNotNull } from "drizzle-orm";
|
|||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
import semver from "semver";
|
||||||
|
|
||||||
interface IPRange {
|
interface IPRange {
|
||||||
start: bigint;
|
start: bigint;
|
||||||
@@ -683,3 +684,35 @@ export function parsePortRangeString(
|
|||||||
|
|
||||||
return result;
|
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(),
|
enable_integration_api: z.boolean().optional(),
|
||||||
disable_local_sites: z.boolean().optional(),
|
disable_local_sites: z.boolean().optional(),
|
||||||
disable_basic_wireguard_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(),
|
.optional(),
|
||||||
dns: z
|
dns: z
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import logger from "@server/logger";
|
|||||||
import { and, eq, lt } from "drizzle-orm";
|
import { and, eq, lt } from "drizzle-orm";
|
||||||
import cache from "@server/lib/cache";
|
import cache from "@server/lib/cache";
|
||||||
import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs";
|
import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs";
|
||||||
|
import { stripPortFromHost } from "@server/lib/ip";
|
||||||
|
|
||||||
async function getAccessDays(orgId: string): Promise<number> {
|
async function getAccessDays(orgId: string): Promise<number> {
|
||||||
// check cache first
|
// check cache first
|
||||||
@@ -116,19 +117,7 @@ export async function logAccessAudit(data: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const clientIp = data.requestIp
|
const clientIp = data.requestIp
|
||||||
? (() => {
|
? stripPortFromHost(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;
|
|
||||||
})()
|
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const countryCode = data.requestIp
|
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> {
|
public async sadd(key: string, member: string): Promise<boolean> {
|
||||||
if (!this.isRedisEnabled() || !this.writeClient) return false;
|
if (!this.isRedisEnabled() || !this.writeClient) return false;
|
||||||
|
|
||||||
|
|||||||
+110
-16
@@ -43,7 +43,8 @@ import {
|
|||||||
WSMessage,
|
WSMessage,
|
||||||
TokenPayload,
|
TokenPayload,
|
||||||
WebSocketRequest,
|
WebSocketRequest,
|
||||||
RedisMessage
|
RedisMessage,
|
||||||
|
SendMessageOptions
|
||||||
} from "@server/routers/ws";
|
} from "@server/routers/ws";
|
||||||
import { validateSessionToken } from "@server/auth/sessions/app";
|
import { validateSessionToken } from "@server/auth/sessions/app";
|
||||||
|
|
||||||
@@ -172,6 +173,9 @@ const REDIS_CHANNEL = "websocket_messages";
|
|||||||
// Client tracking map (local to this node)
|
// Client tracking map (local to this node)
|
||||||
const connectedClients: Map<string, AuthenticatedWebSocket[]> = new Map();
|
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
|
// Recovery tracking
|
||||||
let isRedisRecoveryInProgress = false;
|
let isRedisRecoveryInProgress = false;
|
||||||
|
|
||||||
@@ -182,6 +186,7 @@ const getClientMapKey = (clientId: string) => clientId;
|
|||||||
const getConnectionsKey = (clientId: string) => `ws:connections:${clientId}`;
|
const getConnectionsKey = (clientId: string) => `ws:connections:${clientId}`;
|
||||||
const getNodeConnectionsKey = (nodeId: string, clientId: string) =>
|
const getNodeConnectionsKey = (nodeId: string, clientId: string) =>
|
||||||
`ws:node:${nodeId}:${clientId}`;
|
`ws:node:${nodeId}:${clientId}`;
|
||||||
|
const getConfigVersionKey = (clientId: string) => `ws:configVersion:${clientId}`;
|
||||||
|
|
||||||
// Initialize Redis subscription for cross-node messaging
|
// Initialize Redis subscription for cross-node messaging
|
||||||
const initializeRedisSubscription = async (): Promise<void> => {
|
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)
|
// Local message sending (within this node)
|
||||||
const sendToClientLocal = async (
|
const sendToClientLocal = async (
|
||||||
clientId: string,
|
clientId: string,
|
||||||
message: WSMessage
|
message: WSMessage,
|
||||||
|
options: SendMessageOptions = {}
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
const mapKey = getClientMapKey(clientId);
|
const mapKey = getClientMapKey(clientId);
|
||||||
const clients = connectedClients.get(mapKey);
|
const clients = connectedClients.get(mapKey);
|
||||||
if (!clients || clients.length === 0) {
|
if (!clients || clients.length === 0) {
|
||||||
return false;
|
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) => {
|
clients.forEach((client) => {
|
||||||
if (client.readyState === WebSocket.OPEN) {
|
if (client.readyState === WebSocket.OPEN) {
|
||||||
client.send(messageString);
|
client.send(messageString);
|
||||||
@@ -395,43 +459,69 @@ const sendToClientLocal = async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`sendToClient: Message type ${message.type} sent to clientId ${clientId}`
|
`sendToClient: Message type ${message.type} sent to clientId ${clientId} (configVersion: ${configVersion})`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const broadcastToAllExceptLocal = async (
|
const broadcastToAllExceptLocal = async (
|
||||||
message: WSMessage,
|
message: WSMessage,
|
||||||
excludeClientId?: string
|
excludeClientId?: string,
|
||||||
|
options: SendMessageOptions = {}
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
connectedClients.forEach((clients, mapKey) => {
|
for (const [mapKey, clients] of connectedClients.entries()) {
|
||||||
const [type, id] = mapKey.split(":");
|
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) => {
|
clients.forEach((client) => {
|
||||||
if (client.readyState === WebSocket.OPEN) {
|
if (client.readyState === WebSocket.OPEN) {
|
||||||
client.send(JSON.stringify(message));
|
client.send(JSON.stringify(messageWithVersion));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Cross-node message sending (via Redis)
|
// Cross-node message sending (via Redis)
|
||||||
const sendToClient = async (
|
const sendToClient = async (
|
||||||
clientId: string,
|
clientId: string,
|
||||||
message: WSMessage
|
message: WSMessage,
|
||||||
|
options: SendMessageOptions = {}
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
// Try to send locally first
|
// 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
|
// Only send via Redis if the client is not connected locally and Redis is enabled
|
||||||
if (!localSent && redisManager.isRedisEnabled()) {
|
if (!localSent && redisManager.isRedisEnabled()) {
|
||||||
try {
|
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 = {
|
const redisMessage: RedisMessage = {
|
||||||
type: "direct",
|
type: "direct",
|
||||||
targetClientId: clientId,
|
targetClientId: clientId,
|
||||||
message,
|
message: {
|
||||||
|
...message,
|
||||||
|
configVersion
|
||||||
|
},
|
||||||
fromNodeId: NODE_ID
|
fromNodeId: NODE_ID
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -458,19 +548,22 @@ const sendToClient = async (
|
|||||||
|
|
||||||
const broadcastToAllExcept = async (
|
const broadcastToAllExcept = async (
|
||||||
message: WSMessage,
|
message: WSMessage,
|
||||||
excludeClientId?: string
|
excludeClientId?: string,
|
||||||
|
options: SendMessageOptions = {}
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
// Broadcast locally
|
// Broadcast locally
|
||||||
await broadcastToAllExceptLocal(message, excludeClientId);
|
await broadcastToAllExceptLocal(message, excludeClientId, options);
|
||||||
|
|
||||||
// If Redis is enabled, also broadcast via Redis pub/sub to other nodes
|
// 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()) {
|
if (redisManager.isRedisEnabled()) {
|
||||||
try {
|
try {
|
||||||
const redisMessage: RedisMessage = {
|
const redisMessage: RedisMessage = {
|
||||||
type: "broadcast",
|
type: "broadcast",
|
||||||
excludeClientId,
|
excludeClientId,
|
||||||
message,
|
message,
|
||||||
fromNodeId: NODE_ID
|
fromNodeId: NODE_ID,
|
||||||
|
options
|
||||||
};
|
};
|
||||||
|
|
||||||
await redisManager.publish(
|
await redisManager.publish(
|
||||||
@@ -936,5 +1029,6 @@ export {
|
|||||||
getActiveNodes,
|
getActiveNodes,
|
||||||
disconnectClient,
|
disconnectClient,
|
||||||
NODE_ID,
|
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 { createSession, generateSessionToken } from "@server/auth/sessions/app";
|
||||||
import { encodeHexLowerCase } from "@oslojs/encoding";
|
import { encodeHexLowerCase } from "@oslojs/encoding";
|
||||||
import { sha256 } from "@oslojs/crypto/sha2";
|
import { sha256 } from "@oslojs/crypto/sha2";
|
||||||
|
import { stripPortFromHost } from "@server/lib/ip";
|
||||||
|
|
||||||
const paramsSchema = z.object({
|
const paramsSchema = z.object({
|
||||||
code: z.string().min(1, "Code is required")
|
code: z.string().min(1, "Code is required")
|
||||||
@@ -27,30 +28,6 @@ export type PollDeviceWebAuthResponse = {
|
|||||||
token?: string;
|
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(
|
export async function pollDeviceWebAuth(
|
||||||
req: Request,
|
req: Request,
|
||||||
res: Response,
|
res: Response,
|
||||||
@@ -70,7 +47,7 @@ export async function pollDeviceWebAuth(
|
|||||||
try {
|
try {
|
||||||
const { code } = parsedParams.data;
|
const { code } = parsedParams.data;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const requestIp = extractIpFromRequest(req);
|
const requestIp = req.ip ? stripPortFromHost(req.ip) : undefined;
|
||||||
|
|
||||||
// Hash the code before querying
|
// Hash the code before querying
|
||||||
const hashedCode = hashDeviceCode(code);
|
const hashedCode = hashDeviceCode(code);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { TimeSpan } from "oslo";
|
|||||||
import { maxmindLookup } from "@server/db/maxmind";
|
import { maxmindLookup } from "@server/db/maxmind";
|
||||||
import { encodeHexLowerCase } from "@oslojs/encoding";
|
import { encodeHexLowerCase } from "@oslojs/encoding";
|
||||||
import { sha256 } from "@oslojs/crypto/sha2";
|
import { sha256 } from "@oslojs/crypto/sha2";
|
||||||
|
import { stripPortFromHost } from "@server/lib/ip";
|
||||||
|
|
||||||
const bodySchema = z
|
const bodySchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -39,30 +40,6 @@ function hashDeviceCode(code: string): string {
|
|||||||
return encodeHexLowerCase(sha256(new TextEncoder().encode(code)));
|
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)
|
// Helper function to get city from IP (if available)
|
||||||
async function getCityFromIp(ip: string): Promise<string | undefined> {
|
async function getCityFromIp(ip: string): Promise<string | undefined> {
|
||||||
try {
|
try {
|
||||||
@@ -112,7 +89,7 @@ export async function startDeviceWebAuth(
|
|||||||
const hashedCode = hashDeviceCode(code);
|
const hashedCode = hashDeviceCode(code);
|
||||||
|
|
||||||
// Extract IP from request
|
// Extract IP from request
|
||||||
const ip = extractIpFromRequest(req);
|
const ip = req.ip ? stripPortFromHost(req.ip) : undefined;
|
||||||
|
|
||||||
// Get city (optional, may return undefined)
|
// Get city (optional, may return undefined)
|
||||||
const city = ip ? await getCityFromIp(ip) : 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 { SESSION_COOKIE_EXPIRES as RESOURCE_SESSION_COOKIE_EXPIRES } from "@server/auth/sessions/resource";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { response } from "@server/lib/response";
|
import { response } from "@server/lib/response";
|
||||||
|
import { stripPortFromHost } from "@server/lib/ip";
|
||||||
|
|
||||||
const exchangeSessionBodySchema = z.object({
|
const exchangeSessionBodySchema = z.object({
|
||||||
requestToken: z.string(),
|
requestToken: z.string(),
|
||||||
@@ -62,26 +63,7 @@ export async function exchangeSession(
|
|||||||
cleanHost = cleanHost.slice(0, -1 * matched.length);
|
cleanHost = cleanHost.slice(0, -1 * matched.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
const clientIp = requestIp
|
const clientIp = requestIp ? stripPortFromHost(requestIp) : undefined;
|
||||||
? (() => {
|
|
||||||
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 [resource] = await db
|
const [resource] = await db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import logger from "@server/logger";
|
|||||||
import { and, eq, lt } from "drizzle-orm";
|
import { and, eq, lt } from "drizzle-orm";
|
||||||
import cache from "@server/lib/cache";
|
import cache from "@server/lib/cache";
|
||||||
import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs";
|
import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs";
|
||||||
|
import { stripPortFromHost } from "@server/lib/ip";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
||||||
@@ -208,26 +209,7 @@ export async function logRequestAudit(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const clientIp = body.requestIp
|
const clientIp = body.requestIp
|
||||||
? (() => {
|
? stripPortFromHost(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;
|
|
||||||
})()
|
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
// Add to buffer instead of writing directly to DB
|
// Add to buffer instead of writing directly to DB
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
resourceSessions
|
resourceSessions
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import config from "@server/lib/config";
|
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 { response } from "@server/lib/response";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
@@ -110,37 +110,7 @@ export async function verifyResourceSession(
|
|||||||
const clientHeaderAuth = extractBasicAuth(headers);
|
const clientHeaderAuth = extractBasicAuth(headers);
|
||||||
|
|
||||||
const clientIp = requestIp
|
const clientIp = requestIp
|
||||||
? (() => {
|
? stripPortFromHost(requestIp, badgerVersion)
|
||||||
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;
|
|
||||||
})()
|
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
logger.debug("Client IP:", { clientIp });
|
logger.debug("Client IP:", { clientIp });
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import {
|
|||||||
ExitNode,
|
ExitNode,
|
||||||
exitNodes,
|
exitNodes,
|
||||||
siteResources,
|
siteResources,
|
||||||
clientSiteResourcesAssociationsCache
|
clientSiteResourcesAssociationsCache,
|
||||||
|
Site
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { clients, clientSitesAssociationsCache, Newt, sites } from "@server/db";
|
import { clients, clientSitesAssociationsCache, Newt, sites } from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
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
|
// Get all clients connected to this site
|
||||||
const clientsRes = await db
|
const clientsRes = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -278,22 +311,8 @@ export const handleGetConfigMessage: MessageHandler = async (context) => {
|
|||||||
targetsToSend.push(...resourceTargets);
|
targetsToSend.push(...resourceTargets);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the configuration response
|
return {
|
||||||
const configResponse = {
|
|
||||||
ipAddress: site.address,
|
|
||||||
peers: validPeers,
|
peers: validPeers,
|
||||||
targets: targetsToSend
|
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));
|
.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
|
// Get all enabled targets with their resource protocol information
|
||||||
const allTargets = await db
|
const allTargets = await db
|
||||||
.select({
|
.select({
|
||||||
@@ -337,30 +366,12 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
|||||||
(target) => target !== null
|
(target) => target !== null
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
`Sending health check targets to newt ${newt.newtId}: ${JSON.stringify(validHealthCheckTargets)}`
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message: {
|
validHealthCheckTargets,
|
||||||
type: "newt/wg/connect",
|
tcpTargets,
|
||||||
data: {
|
udpTargets
|
||||||
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
|
|
||||||
};
|
};
|
||||||
};
|
}
|
||||||
|
|
||||||
async function getUniqueSubnetForSite(
|
async function getUniqueSubnetForSite(
|
||||||
exitNode: ExitNode,
|
exitNode: ExitNode,
|
||||||
|
|||||||
@@ -6,3 +6,4 @@ export * from "./handleGetConfigMessage";
|
|||||||
export * from "./handleSocketMessages";
|
export * from "./handleSocketMessages";
|
||||||
export * from "./handleNewtPingRequestMessage";
|
export * from "./handleNewtPingRequestMessage";
|
||||||
export * from "./handleApplyBlueprintMessage";
|
export * from "./handleApplyBlueprintMessage";
|
||||||
|
export * from "./handleNewtPingMessage";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { disconnectClient } from "#dynamic/routers/ws";
|
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 { clients, Olm } from "@server/db";
|
||||||
import { eq, lt, isNull, and, or } from "drizzle-orm";
|
import { eq, lt, isNull, and, or } from "drizzle-orm";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
@@ -108,6 +108,15 @@ export const handleOlmPingMessage: MessageHandler = async (context) => {
|
|||||||
return;
|
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) {
|
if (olm.userId) {
|
||||||
// we need to check a user token to make sure its still valid
|
// we need to check a user token to make sure its still valid
|
||||||
const { session: userSession, user } =
|
const { session: userSession, user } =
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
Client,
|
||||||
clientSiteResourcesAssociationsCache,
|
clientSiteResourcesAssociationsCache,
|
||||||
db,
|
db,
|
||||||
orgs,
|
orgs,
|
||||||
@@ -13,7 +14,7 @@ import {
|
|||||||
olms,
|
olms,
|
||||||
sites
|
sites
|
||||||
} from "@server/db";
|
} 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 { addPeer, deletePeer } from "../newt/peers";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { generateAliasConfig } from "@server/lib/ip";
|
import { generateAliasConfig } from "@server/lib/ip";
|
||||||
@@ -144,6 +145,64 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
|||||||
.where(eq(clientSitesAssociationsCache.clientId, client.clientId));
|
.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
|
// Get all sites data
|
||||||
const sitesData = await db
|
const sitesData = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -154,22 +213,6 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
|||||||
)
|
)
|
||||||
.where(eq(clientSitesAssociationsCache.clientId, client.clientId));
|
.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
|
// Process each site
|
||||||
for (const {
|
for (const {
|
||||||
sites: site,
|
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
|
return siteConfigurations;
|
||||||
// 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
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import {
|
|||||||
handleDockerStatusMessage,
|
handleDockerStatusMessage,
|
||||||
handleDockerContainersMessage,
|
handleDockerContainersMessage,
|
||||||
handleNewtPingRequestMessage,
|
handleNewtPingRequestMessage,
|
||||||
handleApplyBlueprintMessage
|
handleApplyBlueprintMessage,
|
||||||
|
handleNewtPingMessage
|
||||||
} from "../newt";
|
} from "../newt";
|
||||||
import {
|
import {
|
||||||
handleOlmRegisterMessage,
|
handleOlmRegisterMessage,
|
||||||
@@ -24,6 +25,7 @@ export const messageHandlers: Record<string, MessageHandler> = {
|
|||||||
"olm/wg/relay": handleOlmRelayMessage,
|
"olm/wg/relay": handleOlmRelayMessage,
|
||||||
"olm/wg/unrelay": handleOlmUnRelayMessage,
|
"olm/wg/unrelay": handleOlmUnRelayMessage,
|
||||||
"olm/ping": handleOlmPingMessage,
|
"olm/ping": handleOlmPingMessage,
|
||||||
|
"newt/ping": handleNewtPingMessage,
|
||||||
"newt/wg/register": handleNewtRegisterMessage,
|
"newt/wg/register": handleNewtRegisterMessage,
|
||||||
"newt/wg/get-config": handleGetConfigMessage,
|
"newt/wg/get-config": handleGetConfigMessage,
|
||||||
"newt/receive-bandwidth": handleReceiveBandwidthMessage,
|
"newt/receive-bandwidth": handleReceiveBandwidthMessage,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export interface AuthenticatedWebSocket extends WebSocket {
|
|||||||
connectionId?: string;
|
connectionId?: string;
|
||||||
isFullyConnected?: boolean;
|
isFullyConnected?: boolean;
|
||||||
pendingMessages?: Buffer[];
|
pendingMessages?: Buffer[];
|
||||||
|
configVersion?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TokenPayload {
|
export interface TokenPayload {
|
||||||
@@ -36,6 +37,7 @@ export interface TokenPayload {
|
|||||||
export interface WSMessage {
|
export interface WSMessage {
|
||||||
type: string;
|
type: string;
|
||||||
data: any;
|
data: any;
|
||||||
|
configVersion?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HandlerResponse {
|
export interface HandlerResponse {
|
||||||
@@ -50,10 +52,15 @@ export interface HandlerContext {
|
|||||||
senderWs: WebSocket;
|
senderWs: WebSocket;
|
||||||
client: Newt | Olm | RemoteExitNode | undefined;
|
client: Newt | Olm | RemoteExitNode | undefined;
|
||||||
clientType: ClientType;
|
clientType: ClientType;
|
||||||
sendToClient: (clientId: string, message: WSMessage) => Promise<boolean>;
|
sendToClient: (
|
||||||
|
clientId: string,
|
||||||
|
message: WSMessage,
|
||||||
|
options?: SendMessageOptions
|
||||||
|
) => Promise<boolean>;
|
||||||
broadcastToAllExcept: (
|
broadcastToAllExcept: (
|
||||||
message: WSMessage,
|
message: WSMessage,
|
||||||
excludeClientId?: string
|
excludeClientId?: string,
|
||||||
|
options?: SendMessageOptions
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
connectedClients: Map<string, WebSocket[]>;
|
connectedClients: Map<string, WebSocket[]>;
|
||||||
}
|
}
|
||||||
@@ -62,6 +69,11 @@ export type MessageHandler = (
|
|||||||
context: HandlerContext
|
context: HandlerContext
|
||||||
) => Promise<HandlerResponse | void>;
|
) => Promise<HandlerResponse | void>;
|
||||||
|
|
||||||
|
// Options for sending messages with config version tracking
|
||||||
|
export interface SendMessageOptions {
|
||||||
|
incrementConfigVersion?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
// Redis message type for cross-node communication
|
// Redis message type for cross-node communication
|
||||||
export interface RedisMessage {
|
export interface RedisMessage {
|
||||||
type: "direct" | "broadcast";
|
type: "direct" | "broadcast";
|
||||||
@@ -69,4 +81,5 @@ export interface RedisMessage {
|
|||||||
excludeClientId?: string;
|
excludeClientId?: string;
|
||||||
message: WSMessage;
|
message: WSMessage;
|
||||||
fromNodeId: string;
|
fromNodeId: string;
|
||||||
|
options?: SendMessageOptions;
|
||||||
}
|
}
|
||||||
|
|||||||
+59
-11
@@ -15,7 +15,8 @@ import {
|
|||||||
TokenPayload,
|
TokenPayload,
|
||||||
WebSocketRequest,
|
WebSocketRequest,
|
||||||
WSMessage,
|
WSMessage,
|
||||||
AuthenticatedWebSocket
|
AuthenticatedWebSocket,
|
||||||
|
SendMessageOptions
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { validateSessionToken } from "@server/auth/sessions/app";
|
import { validateSessionToken } from "@server/auth/sessions/app";
|
||||||
|
|
||||||
@@ -34,6 +35,8 @@ const NODE_ID = uuidv4();
|
|||||||
|
|
||||||
// Client tracking map (local to this node)
|
// Client tracking map (local to this node)
|
||||||
const connectedClients: Map<string, AuthenticatedWebSocket[]> = new Map();
|
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
|
// Helper to get map key
|
||||||
const getClientMapKey = (clientId: string) => clientId;
|
const getClientMapKey = (clientId: string) => clientId;
|
||||||
|
|
||||||
@@ -84,14 +87,34 @@ const removeClient = async (
|
|||||||
// Local message sending (within this node)
|
// Local message sending (within this node)
|
||||||
const sendToClientLocal = async (
|
const sendToClientLocal = async (
|
||||||
clientId: string,
|
clientId: string,
|
||||||
message: WSMessage
|
message: WSMessage,
|
||||||
|
options: SendMessageOptions = {}
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
const mapKey = getClientMapKey(clientId);
|
const mapKey = getClientMapKey(clientId);
|
||||||
const clients = connectedClients.get(mapKey);
|
const clients = connectedClients.get(mapKey);
|
||||||
if (!clients || clients.length === 0) {
|
if (!clients || clients.length === 0) {
|
||||||
return false;
|
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) => {
|
clients.forEach((client) => {
|
||||||
if (client.readyState === WebSocket.OPEN) {
|
if (client.readyState === WebSocket.OPEN) {
|
||||||
client.send(messageString);
|
client.send(messageString);
|
||||||
@@ -102,14 +125,31 @@ const sendToClientLocal = async (
|
|||||||
|
|
||||||
const broadcastToAllExceptLocal = async (
|
const broadcastToAllExceptLocal = async (
|
||||||
message: WSMessage,
|
message: WSMessage,
|
||||||
excludeClientId?: string
|
excludeClientId?: string,
|
||||||
|
options: SendMessageOptions = {}
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
connectedClients.forEach((clients, mapKey) => {
|
connectedClients.forEach((clients, mapKey) => {
|
||||||
const [type, id] = mapKey.split(":");
|
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) => {
|
clients.forEach((client) => {
|
||||||
if (client.readyState === WebSocket.OPEN) {
|
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
|
// Cross-node message sending
|
||||||
const sendToClient = async (
|
const sendToClient = async (
|
||||||
clientId: string,
|
clientId: string,
|
||||||
message: WSMessage
|
message: WSMessage,
|
||||||
|
options: SendMessageOptions = {}
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
// Try to send locally first
|
// Try to send locally first
|
||||||
const localSent = await sendToClientLocal(clientId, message);
|
const localSent = await sendToClientLocal(clientId, message, options);
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`sendToClient: Message type ${message.type} sent to clientId ${clientId}`
|
`sendToClient: Message type ${message.type} sent to clientId ${clientId}`
|
||||||
@@ -133,10 +174,11 @@ const sendToClient = async (
|
|||||||
|
|
||||||
const broadcastToAllExcept = async (
|
const broadcastToAllExcept = async (
|
||||||
message: WSMessage,
|
message: WSMessage,
|
||||||
excludeClientId?: string
|
excludeClientId?: string,
|
||||||
|
options: SendMessageOptions = {}
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
// Broadcast locally
|
// Broadcast locally
|
||||||
await broadcastToAllExceptLocal(message, excludeClientId);
|
await broadcastToAllExceptLocal(message, excludeClientId, options);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check if a client has active connections across all nodes
|
// 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);
|
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
|
// Get all active nodes for a client
|
||||||
const getActiveNodes = async (
|
const getActiveNodes = async (
|
||||||
clientType: ClientType,
|
clientType: ClientType,
|
||||||
@@ -434,5 +481,6 @@ export {
|
|||||||
getActiveNodes,
|
getActiveNodes,
|
||||||
disconnectClient,
|
disconnectClient,
|
||||||
NODE_ID,
|
NODE_ID,
|
||||||
cleanup
|
cleanup,
|
||||||
|
getClientConfigVersion
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -162,3 +162,20 @@ p {
|
|||||||
#nprogress .bar {
|
#nprogress .bar {
|
||||||
background: var(--color-primary) !important;
|
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";
|
"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 { Card, CardContent } from "@app/components/ui/card";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
|
||||||
type DismissableBannerProps = {
|
type DismissableBannerProps = {
|
||||||
storageKey: string;
|
storageKey: string;
|
||||||
@@ -25,6 +26,12 @@ export const DismissableBanner = ({
|
|||||||
const [isDismissed, setIsDismissed] = useState(true);
|
const [isDismissed, setIsDismissed] = useState(true);
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
|
const { env } = useEnvContext();
|
||||||
|
|
||||||
|
if (env.flags.disableProductHelpBanners) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const dismissedData = localStorage.getItem(storageKey);
|
const dismissedData = localStorage.getItem(storageKey);
|
||||||
if (dismissedData) {
|
if (dismissedData) {
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export async function Layout({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"container mx-auto max-w-12xl mb-12",
|
"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}
|
{children}
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export function LayoutMobileMenu({
|
|||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
return (
|
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="h-16 flex items-center px-2">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{showSidebar && (
|
{showSidebar && (
|
||||||
@@ -72,7 +72,7 @@ export function LayoutMobileMenu({
|
|||||||
<SheetDescription className="sr-only">
|
<SheetDescription className="sr-only">
|
||||||
{t("navbarDescription")}
|
{t("navbarDescription")}
|
||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto relative">
|
||||||
<div className="px-3">
|
<div className="px-3">
|
||||||
<OrgSelector
|
<OrgSelector
|
||||||
orgId={orgId}
|
orgId={orgId}
|
||||||
@@ -83,7 +83,7 @@ export function LayoutMobileMenu({
|
|||||||
<div className="px-3">
|
<div className="px-3">
|
||||||
{!isAdminPage &&
|
{!isAdminPage &&
|
||||||
user.serverAdmin && (
|
user.serverAdmin && (
|
||||||
<div className="pb-3">
|
<div className="py-2">
|
||||||
<Link
|
<Link
|
||||||
href="/admin"
|
href="/admin"
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -113,6 +113,7 @@ export function LayoutMobileMenu({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
<div className="px-3 pt-3 pb-3 space-y-4 border-t shrink-0">
|
<div className="px-3 pt-3 pb-3 space-y-4 border-t shrink-0">
|
||||||
<SupporterStatus />
|
<SupporterStatus />
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ export function IdpDataTable<TData, TValue>({
|
|||||||
searchColumn="name"
|
searchColumn="name"
|
||||||
addButtonText={t("idpAdd")}
|
addButtonText={t("idpAdd")}
|
||||||
onAdd={onAdd}
|
onAdd={onAdd}
|
||||||
|
enableColumnVisibility={true}
|
||||||
|
stickyRightColumn="actions"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ export default function IdpTable({ idps, orgId }: Props) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
|
enableHiding: false,
|
||||||
header: () => <span className="p-3">{t("actions")}</span>,
|
header: () => <span className="p-3">{t("actions")}</span>,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const siteRow = row.original;
|
const siteRow = row.original;
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { Slot } from "@radix-ui/react-slot";
|
|||||||
import { cva, type VariantProps } from "class-variance-authority";
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
import { cn } from "@app/lib/cn";
|
import { cn } from "@app/lib/cn";
|
||||||
import { Loader2 } from "lucide-react";
|
|
||||||
|
|
||||||
const buttonVariants = cva(
|
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",
|
"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 ? (
|
{asChild ? (
|
||||||
props.children
|
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 && (
|
{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>
|
</Comp>
|
||||||
);
|
);
|
||||||
|
|||||||
+5
-1
@@ -59,7 +59,11 @@ export function pullEnv(): Env {
|
|||||||
hideSupporterKey:
|
hideSupporterKey:
|
||||||
process.env.HIDE_SUPPORTER_KEY === "true" ? true : false,
|
process.env.HIDE_SUPPORTER_KEY === "true" ? true : false,
|
||||||
usePangolinDns:
|
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: {
|
branding: {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export type Env = {
|
|||||||
disableBasicWireguardSites: boolean;
|
disableBasicWireguardSites: boolean;
|
||||||
hideSupporterKey: boolean;
|
hideSupporterKey: boolean;
|
||||||
usePangolinDns: boolean;
|
usePangolinDns: boolean;
|
||||||
|
disableProductHelpBanners: boolean;
|
||||||
};
|
};
|
||||||
branding: {
|
branding: {
|
||||||
appName?: string;
|
appName?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user