mirror of
https://github.com/fosrl/pangolin.git
synced 2026-07-31 17:50:47 +02:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 83df1cc57c | |||
| d3324f4f28 | |||
| 97a1bc3697 | |||
| 45623ce36b | |||
| d68f123e13 | |||
| 9469fea88f | |||
| 915e8d5b1f | |||
| 719baa201e | |||
| b0e274f5a9 |
@@ -99,6 +99,7 @@ export const sites = pgTable(
|
||||
name: varchar("name").notNull(),
|
||||
pubKey: varchar("pubKey"),
|
||||
subnet: varchar("subnet"),
|
||||
exitNodeSubnet: text("exitNodeSubnet"), // this is the subnet when connecting to an exit node
|
||||
megabytesIn: real("bytesIn").default(0),
|
||||
megabytesOut: real("bytesOut").default(0),
|
||||
lastBandwidthUpdate: varchar("lastBandwidthUpdate"),
|
||||
@@ -194,7 +195,10 @@ export const resources = pgTable(
|
||||
postAuthPath: text("postAuthPath"),
|
||||
health: varchar("health").default("unknown"), // "healthy", "unhealthy", "unknown"
|
||||
wildcard: boolean("wildcard").notNull().default(false),
|
||||
mode: text("mode").default("http").notNull(), // rdp, ssh, http, vnc
|
||||
mode: text("mode")
|
||||
.default("http")
|
||||
.$type<"rdp" | "ssh" | "http" | "vnc" | "inference">()
|
||||
.notNull(),
|
||||
pamMode: varchar("pamMode", { length: 32 })
|
||||
.$type<"passthrough" | "push">()
|
||||
.default("passthrough"),
|
||||
@@ -424,11 +428,14 @@ export const siteResources = pgTable(
|
||||
onDelete: "restrict"
|
||||
}
|
||||
),
|
||||
requiresExitNodeConnection: boolean("requiresExitNodeConnection")
|
||||
.notNull()
|
||||
.default(false),
|
||||
niceId: varchar("niceId").notNull(),
|
||||
name: varchar("name").notNull(),
|
||||
ssl: boolean("ssl").notNull().default(false),
|
||||
mode: varchar("mode")
|
||||
.$type<"host" | "cidr" | "http" | "ssh">()
|
||||
.$type<"host" | "cidr" | "http" | "ssh" | "inference">()
|
||||
.notNull(), // "host" | "cidr" | "http"
|
||||
scheme: varchar("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode
|
||||
proxyPort: integer("proxyPort"), // only for port mode
|
||||
@@ -1181,7 +1188,7 @@ export const clients = pgTable(
|
||||
olmId: text("olmId"), // to lock it to a specific olm optionally
|
||||
name: varchar("name").notNull(),
|
||||
pubKey: varchar("pubKey"),
|
||||
subnet: varchar("subnet").notNull(),
|
||||
exitNodeSubnet: varchar("exitNodeSubnet").notNull(),
|
||||
megabytesIn: real("bytesIn"),
|
||||
megabytesOut: real("bytesOut"),
|
||||
lastBandwidthUpdate: varchar("lastBandwidthUpdate"),
|
||||
|
||||
@@ -107,7 +107,7 @@ export const sites = sqliteTable("sites", {
|
||||
}),
|
||||
name: text("name").notNull(),
|
||||
pubKey: text("pubKey"),
|
||||
subnet: text("subnet"),
|
||||
exitNodeSubnet: text("exitNodeSubnet"),
|
||||
megabytesIn: integer("bytesIn").default(0),
|
||||
megabytesOut: integer("bytesOut").default(0),
|
||||
lastBandwidthUpdate: text("lastBandwidthUpdate"),
|
||||
@@ -203,7 +203,10 @@ export const resources = sqliteTable("resources", {
|
||||
postAuthPath: text("postAuthPath"),
|
||||
health: text("health").default("unknown"), // "healthy", "unhealthy", "unknown"
|
||||
wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false),
|
||||
mode: text("mode").default("http").notNull(), // rdp, ssh, http, vnc
|
||||
mode: text("mode")
|
||||
.default("http")
|
||||
.$type<"rdp" | "ssh" | "http" | "vnc" | "inference">()
|
||||
.notNull(), // rdp, ssh, http, vnc, inference
|
||||
pamMode: text("pamMode")
|
||||
.$type<"passthrough" | "push">()
|
||||
.default("passthrough"),
|
||||
@@ -422,10 +425,17 @@ export const siteResources = sqliteTable("siteResources", {
|
||||
() => networks.networkId,
|
||||
{ onDelete: "restrict" }
|
||||
),
|
||||
requiresExitNodeConnection: integer("requiresExitNodeConnection", {
|
||||
mode: "boolean"
|
||||
})
|
||||
.notNull()
|
||||
.default(false),
|
||||
niceId: text("niceId").notNull(),
|
||||
name: text("name").notNull(),
|
||||
ssl: integer("ssl", { mode: "boolean" }).notNull().default(false),
|
||||
mode: text("mode").$type<"host" | "cidr" | "http" | "ssh">().notNull(), // "host" | "cidr" | "http"
|
||||
mode: text("mode")
|
||||
.$type<"host" | "cidr" | "http" | "ssh" | "inference">()
|
||||
.notNull(), // "host" | "cidr" | "http"
|
||||
scheme: text("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode
|
||||
proxyPort: integer("proxyPort"), // only for port mode
|
||||
destinationPort: integer("destinationPort"), // only for port mode
|
||||
@@ -599,6 +609,7 @@ export const clients = sqliteTable("clients", {
|
||||
pubKey: text("pubKey"),
|
||||
olmId: text("olmId"), // to lock it to a specific olm optionally
|
||||
subnet: text("subnet").notNull(),
|
||||
exitNodeSubnet: text("exitNodeSubnet"), // this is the subnet when connecting to an exit node
|
||||
megabytesIn: integer("bytesIn"),
|
||||
megabytesOut: integer("bytesOut"),
|
||||
lastBandwidthUpdate: text("lastBandwidthUpdate"),
|
||||
|
||||
@@ -339,19 +339,6 @@ export async function calculateUserClientsForOrgs(
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get exit nodes for this org
|
||||
const exitNodesList = await getExitNodes(orgId);
|
||||
|
||||
if (exitNodesList.length === 0) {
|
||||
logger.warn(
|
||||
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): no exit nodes found`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const randomExitNode =
|
||||
exitNodesList[Math.floor(Math.random() * exitNodesList.length)];
|
||||
|
||||
// Get next available subnet
|
||||
const { value: newSubnet, release: releaseSubnetLock } =
|
||||
await getNextAvailableClientSubnet(orgId, trx);
|
||||
@@ -370,7 +357,6 @@ export async function calculateUserClientsForOrgs(
|
||||
const newClientData: InferInsertModel<typeof clients> = {
|
||||
userId,
|
||||
orgId: userOrg.orgId,
|
||||
exitNodeId: randomExitNode.exitNodeId,
|
||||
name: olm.name || "User Client",
|
||||
subnet: updatedSubnet,
|
||||
olmId: olm.olmId,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { db, sites, clients } from "@server/db";
|
||||
import { and, eq, count } from "drizzle-orm";
|
||||
|
||||
// (MAX_CONNECTIONS - current_connections) / MAX_CONNECTIONS)
|
||||
// higher = more desirable
|
||||
// like saying, this node has x% of its capacity left
|
||||
export async function calculateExitNodeWeight(
|
||||
exitNodeId: number,
|
||||
maxConnections: number | null | undefined
|
||||
): Promise<number | null> {
|
||||
if (maxConnections === null || maxConnections === undefined) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const [[siteConnections], [clientConnections]] = await Promise.all([
|
||||
db
|
||||
.select({ count: count() })
|
||||
.from(sites)
|
||||
.where(
|
||||
and(eq(sites.exitNodeId, exitNodeId), eq(sites.online, true))
|
||||
),
|
||||
db
|
||||
.select({ count: count() })
|
||||
.from(clients)
|
||||
.where(
|
||||
and(
|
||||
eq(clients.exitNodeId, exitNodeId),
|
||||
eq(clients.online, true)
|
||||
)
|
||||
)
|
||||
]);
|
||||
|
||||
const currentConnections = siteConnections.count + clientConnections.count;
|
||||
|
||||
if (currentConnections >= maxConnections) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (maxConnections - currentConnections) / maxConnections;
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { db, exitNodes, Transaction } from "@server/db";
|
||||
import logger from "@server/logger";
|
||||
import { ExitNodePingResult } from "@server/routers/newt";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export async function verifyExitNodeOrgAccess(
|
||||
@@ -52,6 +51,16 @@ export async function listExitNodes(
|
||||
return allExitNodes;
|
||||
}
|
||||
|
||||
export type ExitNodePingResult = {
|
||||
exitNodeId: number;
|
||||
latencyMs: number;
|
||||
weight: number;
|
||||
error?: string;
|
||||
exitNodeName: string;
|
||||
endpoint: string;
|
||||
wasPreviouslyConnected: boolean;
|
||||
};
|
||||
|
||||
export function selectBestExitNode(
|
||||
pingResults: ExitNodePingResult[]
|
||||
): ExitNodePingResult | null {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { db, ExitNode, Transaction, sites, clients } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import config from "@server/lib/config";
|
||||
import { findNextAvailableCidr } from "@server/lib/ip";
|
||||
import { lockManager } from "#dynamic/lib/lock";
|
||||
|
||||
export async function getUniqueSubnetForExitNode(
|
||||
exitNode: ExitNode,
|
||||
trx: Transaction | typeof db = db
|
||||
): Promise<string | null> {
|
||||
const lockKey = `subnet-allocation:${exitNode.exitNodeId}`;
|
||||
|
||||
return await lockManager.withLock(
|
||||
lockKey,
|
||||
async () => {
|
||||
const [sitesQuery, clientsQuery] = await Promise.all([
|
||||
trx
|
||||
.select({ subnet: sites.exitNodeSubnet })
|
||||
.from(sites)
|
||||
.where(eq(sites.exitNodeId, exitNode.exitNodeId)),
|
||||
trx
|
||||
.select({ subnet: clients.exitNodeSubnet })
|
||||
.from(clients)
|
||||
.where(eq(clients.exitNodeId, exitNode.exitNodeId))
|
||||
]);
|
||||
|
||||
const blockSize = config.getRawConfig().gerbil.site_block_size;
|
||||
const subnets = [...sitesQuery, ...clientsQuery]
|
||||
.map((row) => row.subnet)
|
||||
.filter(
|
||||
(subnet): subnet is string =>
|
||||
!!subnet &&
|
||||
/^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/.test(subnet)
|
||||
);
|
||||
subnets.push(exitNode.address.replace(/\/\d+$/, `/${blockSize}`));
|
||||
|
||||
return findNextAvailableCidr(subnets, blockSize, exitNode.address);
|
||||
},
|
||||
5000 // 5 second lock TTL - subnet allocation should be quick
|
||||
);
|
||||
}
|
||||
@@ -2,3 +2,5 @@ export * from "./exitNodes";
|
||||
export * from "./exitNodeComms";
|
||||
export * from "./subnet";
|
||||
export * from "./getCurrentExitNodeId";
|
||||
export * from "./calculateExitNodeWeight";
|
||||
export * from "./getUniqueSubnetForExitNode";
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
userOrgRoles,
|
||||
userSiteResources
|
||||
} from "@server/db";
|
||||
import { and, count, eq, inArray, ne } from "drizzle-orm";
|
||||
import { and, count, eq, inArray, isNotNull, ne } from "drizzle-orm";
|
||||
|
||||
import { deletePeersBatch as newtDeletePeersBatch } from "@server/routers/newt/peers";
|
||||
import {
|
||||
@@ -27,6 +27,9 @@ import {
|
||||
deletePeersBatch as olmDeletePeersBatch
|
||||
} from "@server/routers/olm/peers";
|
||||
import { sendToExitNode } from "#dynamic/lib/exitNodes";
|
||||
import { sendToClientsBatch } from "#dynamic/routers/ws";
|
||||
import { canCompress } from "@server/lib/clientVersionChecks";
|
||||
import config from "@server/lib/config";
|
||||
import logger from "@server/logger";
|
||||
import {
|
||||
generateAliasConfig,
|
||||
@@ -187,7 +190,12 @@ export async function getClientSiteResourceAccess(
|
||||
`rebuildClientAssociations: [getClientSiteResourceAccess] siteResourceId=${siteResource.siteResourceId} networkId=${siteResource.networkId} siteCount=${sitesList.length} siteIds=[${sitesList.map((s) => s.siteId).join(", ")}]`
|
||||
);
|
||||
|
||||
if (sitesList.length === 0) {
|
||||
if (sitesList.length === 0 && siteResource.networkId !== null) {
|
||||
// A site resource with a networkId is expected to have at least one
|
||||
// site attached via siteNetworks. Resources with no networkId (e.g.
|
||||
// inference-mode resources, which connect clients directly to the
|
||||
// exit node instead of any site) are expected to have no sites, so
|
||||
// don't warn for those.
|
||||
logger.warn(
|
||||
`No sites found for siteResource ${siteResource.siteResourceId} with networkId ${siteResource.networkId}`
|
||||
);
|
||||
@@ -687,6 +695,22 @@ async function rebuildClientAssociationsFromSiteResourceImpl(
|
||||
clientSiteResourcesToRemove,
|
||||
trx
|
||||
);
|
||||
|
||||
// If this resource requires clients to be connected to the exit node
|
||||
// (e.g. an inference resource), re-sync the connect/disconnect state for
|
||||
// every client whose access to it may have changed - both those who
|
||||
// currently have access and those who just lost it.
|
||||
if (siteResource.requiresExitNodeConnection) {
|
||||
await syncClientExitNodeConnections(
|
||||
Array.from(
|
||||
new Set([
|
||||
...mergedAllClientIds,
|
||||
...existingClientSiteResourceIds
|
||||
])
|
||||
),
|
||||
trx
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMessagesForSiteClients(
|
||||
@@ -966,7 +990,7 @@ export async function updateClientSiteDestinations(
|
||||
.where(eq(clientSitesAssociationsCache.clientId, client.clientId));
|
||||
|
||||
for (const site of sitesData) {
|
||||
if (!site.sites.subnet) {
|
||||
if (!site.sites.exitNodeSubnet) {
|
||||
logger.debug(`Site ${site.sites.siteId} has no subnet, skipping`);
|
||||
continue;
|
||||
}
|
||||
@@ -1002,7 +1026,7 @@ export async function updateClientSiteDestinations(
|
||||
sourcePort: parsedEndpoint.port,
|
||||
destinations: [
|
||||
{
|
||||
destinationIP: site.sites.subnet.split("/")[0],
|
||||
destinationIP: site.sites.exitNodeSubnet.split("/")[0],
|
||||
destinationPort: site.sites.listenPort || 1 // this satisfies gerbil for now but should be reevaluated
|
||||
}
|
||||
]
|
||||
@@ -1010,7 +1034,7 @@ export async function updateClientSiteDestinations(
|
||||
} else {
|
||||
// add to the existing destinations
|
||||
destinations.destinations.push({
|
||||
destinationIP: site.sites.subnet.split("/")[0],
|
||||
destinationIP: site.sites.exitNodeSubnet.split("/")[0],
|
||||
destinationPort: site.sites.listenPort || 1 // this satisfies gerbil for now but should be reevaluated
|
||||
});
|
||||
}
|
||||
@@ -1052,6 +1076,179 @@ export async function updateClientSiteDestinations(
|
||||
}
|
||||
}
|
||||
|
||||
// Determines, for each of the given clients, whether they currently have
|
||||
// access to any enabled site resource with requiresExitNodeConnection set
|
||||
// (e.g. an inference-mode resource) and tells the client's olm to connect to
|
||||
// or disconnect from its assigned exit node accordingly. Site resources with
|
||||
// requiresExitNodeConnection don't belong to any site/network, so this can't
|
||||
// be derived from the per-site peer logic above - it has to be recomputed
|
||||
// from the client's full current resource access every time that access
|
||||
// changes.
|
||||
async function syncClientExitNodeConnections(
|
||||
clientIds: number[],
|
||||
trx: Transaction | typeof db = db
|
||||
): Promise<void> {
|
||||
const uniqueClientIds = Array.from(new Set(clientIds));
|
||||
if (uniqueClientIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only clients with an exit node assigned can be told to connect/disconnect.
|
||||
const clientsData = await trx
|
||||
.select({
|
||||
clientId: clients.clientId,
|
||||
exitNodeId: clients.exitNodeId,
|
||||
exitNodeSubnet: clients.exitNodeSubnet
|
||||
})
|
||||
.from(clients)
|
||||
.where(
|
||||
and(
|
||||
inArray(clients.clientId, uniqueClientIds),
|
||||
isNotNull(clients.exitNodeId)
|
||||
)
|
||||
);
|
||||
|
||||
if (clientsData.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clientIdsWithExitNode = clientsData.map((c) => c.clientId);
|
||||
|
||||
const requiresExitNodeRows = await trx
|
||||
.select({
|
||||
clientId: clientSiteResourcesAssociationsCache.clientId
|
||||
})
|
||||
.from(clientSiteResourcesAssociationsCache)
|
||||
.innerJoin(
|
||||
siteResources,
|
||||
eq(
|
||||
clientSiteResourcesAssociationsCache.siteResourceId,
|
||||
siteResources.siteResourceId
|
||||
)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
inArray(
|
||||
clientSiteResourcesAssociationsCache.clientId,
|
||||
clientIdsWithExitNode
|
||||
),
|
||||
eq(siteResources.enabled, true),
|
||||
eq(siteResources.requiresExitNodeConnection, true)
|
||||
)
|
||||
);
|
||||
|
||||
const needsConnectSet = new Set(requiresExitNodeRows.map((r) => r.clientId));
|
||||
|
||||
const exitNodeIds = Array.from(
|
||||
new Set(
|
||||
clientsData
|
||||
.map((c) => c.exitNodeId)
|
||||
.filter((id): id is number => id !== null)
|
||||
)
|
||||
);
|
||||
|
||||
const exitNodeRows =
|
||||
exitNodeIds.length > 0
|
||||
? await trx
|
||||
.select()
|
||||
.from(exitNodes)
|
||||
.where(inArray(exitNodes.exitNodeId, exitNodeIds))
|
||||
: [];
|
||||
const exitNodeById = new Map(exitNodeRows.map((n) => [n.exitNodeId, n]));
|
||||
|
||||
const olmRows = await trx
|
||||
.select({
|
||||
clientId: olms.clientId,
|
||||
olmId: olms.olmId,
|
||||
version: olms.version
|
||||
})
|
||||
.from(olms)
|
||||
.where(inArray(olms.clientId, clientIdsWithExitNode));
|
||||
const olmByClientId = new Map(
|
||||
olmRows
|
||||
.filter((r) => r.clientId !== null)
|
||||
.map((r) => [r.clientId as number, r])
|
||||
);
|
||||
|
||||
const relayPort = config.getRawConfig().gerbil.clients_start_port;
|
||||
|
||||
const connectPayloads: {
|
||||
clientId: string;
|
||||
message: { type: string; data: any };
|
||||
options: { compress: boolean };
|
||||
}[] = [];
|
||||
const disconnectPayloads: {
|
||||
clientId: string;
|
||||
message: { type: string; data: any };
|
||||
options: { compress: boolean };
|
||||
}[] = [];
|
||||
|
||||
for (const client of clientsData) {
|
||||
const olm = olmByClientId.get(client.clientId);
|
||||
if (!olm) {
|
||||
// No olm registered for this client yet/anymore, nothing to send.
|
||||
continue;
|
||||
}
|
||||
|
||||
const needsConnect = needsConnectSet.has(client.clientId);
|
||||
|
||||
if (needsConnect) {
|
||||
const exitNode = client.exitNodeId
|
||||
? exitNodeById.get(client.exitNodeId)
|
||||
: undefined;
|
||||
if (!exitNode || !client.exitNodeSubnet) {
|
||||
logger.warn(
|
||||
`rebuildClientAssociations: [syncClientExitNodeConnections] client ${client.clientId} needs an exit node connection but has no exit node or subnet assigned`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
connectPayloads.push({
|
||||
clientId: olm.olmId,
|
||||
message: {
|
||||
type: "olm/wg/exitnode/connect",
|
||||
data: {
|
||||
connect: true,
|
||||
endpoint: `${exitNode.endpoint}:${exitNode.listenPort}`,
|
||||
relayPort,
|
||||
publicKey: exitNode.publicKey,
|
||||
serverIP: exitNode.address.split("/")[0],
|
||||
tunnelIP: client.exitNodeSubnet.split("/")[0]
|
||||
}
|
||||
},
|
||||
options: { compress: canCompress(olm.version, "olm") }
|
||||
});
|
||||
} else {
|
||||
disconnectPayloads.push({
|
||||
clientId: olm.olmId,
|
||||
message: {
|
||||
type: "olm/wg/exitnode/disconnect",
|
||||
data: {}
|
||||
},
|
||||
options: { compress: canCompress(olm.version, "olm") }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (connectPayloads.length > 0) {
|
||||
await sendToClientsBatch(connectPayloads).catch((error) => {
|
||||
logger.error(
|
||||
`rebuildClientAssociations: Error sending exit node connect messages:`,
|
||||
error
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (disconnectPayloads.length > 0) {
|
||||
await sendToClientsBatch(disconnectPayloads).catch((error) => {
|
||||
logger.error(
|
||||
`rebuildClientAssociations: Error sending exit node disconnect messages:`,
|
||||
error
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubnetProxyTargetUpdates(
|
||||
siteResource: SiteResource,
|
||||
sitesList: Site[],
|
||||
@@ -1709,6 +1906,20 @@ export async function handleMessagingForUpdatedSiteResource(
|
||||
);
|
||||
}
|
||||
|
||||
// If this resource requires (or required) clients to be connected to the
|
||||
// exit node (e.g. an inference resource), re-sync connect/disconnect
|
||||
// state for every client currently associated with it - covers toggling
|
||||
// requiresExitNodeConnection on update as well as enabling/disabling it.
|
||||
if (
|
||||
updatedSiteResource.requiresExitNodeConnection ||
|
||||
existingSiteResource?.requiresExitNodeConnection
|
||||
) {
|
||||
await syncClientExitNodeConnections(
|
||||
mergedAllClients.map((c) => c.clientId),
|
||||
trx
|
||||
);
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`handleMessagingForUpdatedSiteResource: DONE siteResourceId=${updatedSiteResource.siteResourceId}`
|
||||
);
|
||||
@@ -1990,6 +2201,10 @@ async function rebuildClientAssociationsFromClientImpl(
|
||||
resourcesToRemove,
|
||||
trx
|
||||
);
|
||||
|
||||
// Re-sync exit node connect/disconnect state based on this client's
|
||||
// current full set of resource access (e.g. inference resources).
|
||||
await syncClientExitNodeConnections([client.clientId], trx);
|
||||
}
|
||||
|
||||
async function handleMessagesForClientSites(
|
||||
|
||||
@@ -87,7 +87,7 @@ export async function getTraefikConfig(
|
||||
siteId: sites.siteId,
|
||||
siteType: sites.type,
|
||||
siteOnline: sites.online,
|
||||
subnet: sites.subnet,
|
||||
subnet: sites.exitNodeSubnet,
|
||||
exitNodeId: sites.exitNodeId,
|
||||
// Domain cert resolver fields
|
||||
domainCertResolver: domains.certResolver,
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
Transaction
|
||||
} from "@server/db";
|
||||
import logger from "@server/logger";
|
||||
import { ExitNodePingResult } from "@server/routers/newt";
|
||||
import { eq, and, or, ne, isNull, inArray } from "drizzle-orm";
|
||||
import axios from "axios";
|
||||
import config from "../config";
|
||||
@@ -330,6 +329,16 @@ export async function listExitNodes(
|
||||
return exitNodesList;
|
||||
}
|
||||
|
||||
export type ExitNodePingResult = {
|
||||
exitNodeId: number;
|
||||
latencyMs: number;
|
||||
weight: number;
|
||||
error?: string;
|
||||
exitNodeName: string;
|
||||
endpoint: string;
|
||||
wasPreviouslyConnected: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Selects the most suitable exit node from a list of ping results.
|
||||
*
|
||||
|
||||
@@ -134,7 +134,7 @@ export async function getTraefikConfig(
|
||||
siteId: sites.siteId,
|
||||
siteType: sites.type,
|
||||
siteOnline: sites.online,
|
||||
subnet: sites.subnet,
|
||||
subnet: sites.exitNodeSubnet,
|
||||
exitNodeId: sites.exitNodeId,
|
||||
// Namespace
|
||||
domainNamespaceId: domainNamespaces.domainNamespaceId,
|
||||
|
||||
@@ -178,7 +178,7 @@ export async function reGenerateSiteSecret(
|
||||
);
|
||||
}
|
||||
|
||||
if (site.exitNodeId && site.subnet) {
|
||||
if (site.exitNodeId && site.exitNodeSubnet) {
|
||||
await deletePeer(site.exitNodeId, site.pubKey!); // the old pubkey
|
||||
await addPeer(site.exitNodeId, {
|
||||
publicKey: pubKey,
|
||||
|
||||
@@ -255,20 +255,6 @@ export async function createClient(
|
||||
|
||||
let newClient: Client | null = null;
|
||||
await db.transaction(async (trx) => {
|
||||
// TODO: more intelligent way to pick the exit node
|
||||
const exitNodesList = await listExitNodes(orgId);
|
||||
const randomExitNode =
|
||||
exitNodesList[Math.floor(Math.random() * exitNodesList.length)];
|
||||
|
||||
if (!randomExitNode) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`No exit nodes available. ${build == "saas" ? "Please contact support." : "You need to install gerbil to use the clients."}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const [adminRole] = await trx
|
||||
.select()
|
||||
.from(roles)
|
||||
@@ -287,7 +273,6 @@ export async function createClient(
|
||||
.insert(clients)
|
||||
.values({
|
||||
niceId,
|
||||
exitNodeId: randomExitNode.exitNodeId,
|
||||
orgId,
|
||||
name,
|
||||
subnet: updatedSubnet,
|
||||
|
||||
@@ -222,11 +222,6 @@ export async function createUserClient(
|
||||
|
||||
let newClient: Client | null = null;
|
||||
await db.transaction(async (trx) => {
|
||||
// TODO: more intelligent way to pick the exit node
|
||||
const exitNodesList = await listExitNodes(orgId);
|
||||
const randomExitNode =
|
||||
exitNodesList[Math.floor(Math.random() * exitNodesList.length)];
|
||||
|
||||
const [adminRole] = await trx
|
||||
.select()
|
||||
.from(roles)
|
||||
@@ -244,7 +239,6 @@ export async function createUserClient(
|
||||
[newClient] = await trx
|
||||
.insert(clients)
|
||||
.values({
|
||||
exitNodeId: randomExitNode.exitNodeId,
|
||||
orgId,
|
||||
niceId,
|
||||
name,
|
||||
|
||||
@@ -100,7 +100,7 @@ export async function generateRelayMappings(exitNode: ExitNode) {
|
||||
// Filter to sites with the required fields up front so the rest of the
|
||||
// function can safely treat endpoint/subnet/listenPort as defined.
|
||||
const validSites = sitesRes.filter(
|
||||
(s) => s.endpoint && s.subnet && s.listenPort
|
||||
(s) => s.endpoint && s.exitNodeSubnet && s.listenPort
|
||||
);
|
||||
|
||||
if (validSites.length === 0) {
|
||||
@@ -136,7 +136,7 @@ export async function generateRelayMappings(exitNode: ExitNode) {
|
||||
if (
|
||||
peer.orgId == null ||
|
||||
!peer.endpoint ||
|
||||
!peer.subnet ||
|
||||
!peer.exitNodeSubnet ||
|
||||
!peer.listenPort
|
||||
) {
|
||||
continue;
|
||||
@@ -183,7 +183,7 @@ export async function generateRelayMappings(exitNode: ExitNode) {
|
||||
// Process each site using the pre-fetched data.
|
||||
for (const site of validSites) {
|
||||
const siteDestination: PeerDestination = {
|
||||
destinationIP: site.subnet!.split("/")[0],
|
||||
destinationIP: site.exitNodeSubnet!.split("/")[0],
|
||||
destinationPort: site.listenPort! || 1 // this satisfies gerbil for now but should be reevaluated
|
||||
};
|
||||
|
||||
@@ -207,7 +207,7 @@ export async function generateRelayMappings(exitNode: ExitNode) {
|
||||
continue;
|
||||
}
|
||||
addDestination(site.endpoint!, {
|
||||
destinationIP: peer.subnet!.split("/")[0],
|
||||
destinationIP: peer.exitNodeSubnet!.split("/")[0],
|
||||
destinationPort: peer.listenPort! || 1 // this satisfies gerbil for now but should be reevaluated
|
||||
});
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ export async function generateGerbilConfig(exitNode: ExitNode) {
|
||||
and(
|
||||
eq(sites.exitNodeId, exitNode.exitNodeId),
|
||||
isNotNull(sites.pubKey),
|
||||
isNotNull(sites.subnet)
|
||||
isNotNull(sites.exitNodeSubnet)
|
||||
)
|
||||
);
|
||||
|
||||
@@ -103,7 +103,7 @@ export async function generateGerbilConfig(exitNode: ExitNode) {
|
||||
} else if (site.type === "newt") {
|
||||
return {
|
||||
publicKey: site.pubKey,
|
||||
allowedIps: [site.subnet!]
|
||||
allowedIps: [site.exitNodeSubnet!]
|
||||
};
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -186,7 +186,7 @@ export async function updateAndGenerateEndpointDestinations(
|
||||
.select({
|
||||
siteId: sites.siteId,
|
||||
newtId: newts.newtId,
|
||||
subnet: sites.subnet,
|
||||
subnet: sites.exitNodeSubnet,
|
||||
listenPort: sites.listenPort,
|
||||
publicKey: sites.publicKey,
|
||||
endpoint: clientSitesAssociationsCache.endpoint,
|
||||
|
||||
+12
-28
@@ -2,14 +2,17 @@ import { db, sites } from "@server/db";
|
||||
import { MessageHandler } from "@server/routers/ws";
|
||||
import { exitNodes, Newt } from "@server/db";
|
||||
import logger from "@server/logger";
|
||||
import { ne, eq, or, and, count } from "drizzle-orm";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { listExitNodes } from "#dynamic/lib/exitNodes";
|
||||
import { calculateExitNodeWeight } from "@server/lib/exitNodes";
|
||||
|
||||
export const handleNewtPingRequestMessage: MessageHandler = async (context) => {
|
||||
export const handleNewtExitNodesRequestMessage: MessageHandler = async (
|
||||
context
|
||||
) => {
|
||||
const { message, client, sendToClient } = context;
|
||||
const newt = client as Newt;
|
||||
|
||||
logger.info("Handling ping request newt message!");
|
||||
logger.info("Handling exit nodes request newt message!");
|
||||
|
||||
if (!newt) {
|
||||
logger.warn("Newt not found");
|
||||
@@ -54,32 +57,13 @@ export const handleNewtPingRequestMessage: MessageHandler = async (context) => {
|
||||
|
||||
const exitNodesPayload = await Promise.all(
|
||||
exitNodesList.map(async (node) => {
|
||||
// (MAX_CONNECTIONS - current_connections) / MAX_CONNECTIONS)
|
||||
// higher = more desirable
|
||||
// like saying, this node has x% of its capacity left
|
||||
const weight = await calculateExitNodeWeight(
|
||||
node.exitNodeId,
|
||||
node.maxConnections
|
||||
);
|
||||
|
||||
let weight = 1;
|
||||
const maxConnections = node.maxConnections;
|
||||
if (maxConnections !== null && maxConnections !== undefined) {
|
||||
const [currentConnections] = await db
|
||||
.select({
|
||||
count: count()
|
||||
})
|
||||
.from(sites)
|
||||
.where(
|
||||
and(
|
||||
eq(sites.exitNodeId, node.exitNodeId),
|
||||
eq(sites.online, true)
|
||||
)
|
||||
);
|
||||
|
||||
if (currentConnections.count >= maxConnections) {
|
||||
return null;
|
||||
}
|
||||
|
||||
weight =
|
||||
(maxConnections - currentConnections.count) /
|
||||
maxConnections;
|
||||
if (weight === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -95,16 +95,16 @@ export const handleNewtGetConfigMessage: MessageHandler = async (context) => {
|
||||
.limit(1);
|
||||
if (
|
||||
exitNode.reachableAt &&
|
||||
existingSite.subnet &&
|
||||
existingSite.exitNodeSubnet &&
|
||||
existingSite.listenPort
|
||||
) {
|
||||
const payload = {
|
||||
oldDestination: {
|
||||
destinationIP: existingSite.subnet?.split("/")[0],
|
||||
destinationIP: existingSite.exitNodeSubnet?.split("/")[0],
|
||||
destinationPort: existingSite.listenPort || 1 // this satisfies gerbil for now but should be reevaluated
|
||||
},
|
||||
newDestination: {
|
||||
destinationIP: site.subnet?.split("/")[0],
|
||||
destinationIP: site.exitNodeSubnet?.split("/")[0],
|
||||
destinationPort: site.listenPort || 1 // this satisfies gerbil for now but should be reevaluated
|
||||
}
|
||||
};
|
||||
@@ -132,7 +132,10 @@ export const handleNewtGetConfigMessage: MessageHandler = async (context) => {
|
||||
({ targets: dedupedTargets, certs } = dedupeCertsForTargets(targets));
|
||||
}
|
||||
|
||||
const targetsToSend = await convertTargetsIfNecessary(newt.newtId, dedupedTargets); // for backward compatibility with old newt versions that don't support the new target format
|
||||
const targetsToSend = await convertTargetsIfNecessary(
|
||||
newt.newtId,
|
||||
dedupedTargets
|
||||
); // for backward compatibility with old newt versions that don't support the new target format
|
||||
|
||||
return {
|
||||
message: {
|
||||
|
||||
@@ -1,30 +1,20 @@
|
||||
import { db, ExitNode, newts, remoteExitNodes, Transaction } from "@server/db";
|
||||
import { db, newts, remoteExitNodes } from "@server/db";
|
||||
import { MessageHandler } from "@server/routers/ws";
|
||||
import { exitNodes, Newt, sites } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { addPeer, deletePeer } from "../gerbil/peers";
|
||||
import logger from "@server/logger";
|
||||
import config from "@server/lib/config";
|
||||
import { findNextAvailableCidr } from "@server/lib/ip";
|
||||
import {
|
||||
ExitNodePingResult,
|
||||
selectBestExitNode,
|
||||
verifyExitNodeOrgAccess
|
||||
} from "#dynamic/lib/exitNodes";
|
||||
import { getUniqueSubnetForExitNode } from "@server/lib/exitNodes";
|
||||
import { fetchContainers } from "./dockerSocket";
|
||||
import { lockManager } from "#dynamic/lib/lock";
|
||||
import { buildTargetConfigurationForNewtClient } from "./buildConfiguration";
|
||||
import { canCompress } from "@server/lib/clientVersionChecks";
|
||||
|
||||
export type ExitNodePingResult = {
|
||||
exitNodeId: number;
|
||||
latencyMs: number;
|
||||
weight: number;
|
||||
error?: string;
|
||||
exitNodeName: string;
|
||||
endpoint: string;
|
||||
wasPreviouslyConnected: boolean;
|
||||
};
|
||||
|
||||
export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
||||
const { message, client, sendToClient } = context;
|
||||
const newt = client as Newt;
|
||||
@@ -94,9 +84,12 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
||||
fetchContainers(newt.newtId);
|
||||
}
|
||||
|
||||
let siteSubnet = oldSite.subnet;
|
||||
let siteSubnet = oldSite.exitNodeSubnet;
|
||||
let exitNodeIdToQuery = oldSite.exitNodeId;
|
||||
if (exitNodeId && (oldSite.exitNodeId !== exitNodeId || !oldSite.subnet)) {
|
||||
if (
|
||||
exitNodeId &&
|
||||
(oldSite.exitNodeId !== exitNodeId || !oldSite.exitNodeSubnet)
|
||||
) {
|
||||
// This effectively moves the exit node to the new one
|
||||
exitNodeIdToQuery = exitNodeId; // Use the provided exitNodeId if it differs from the site's exitNodeId
|
||||
|
||||
@@ -115,7 +108,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const newSubnet = await getUniqueSubnetForSite(exitNode);
|
||||
const newSubnet = await getUniqueSubnetForExitNode(exitNode);
|
||||
|
||||
if (!newSubnet) {
|
||||
logger.error(
|
||||
@@ -131,7 +124,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
||||
.set({
|
||||
pubKey: publicKey,
|
||||
exitNodeId: exitNodeId,
|
||||
subnet: newSubnet
|
||||
exitNodeSubnet: newSubnet
|
||||
})
|
||||
.where(eq(sites.siteId, siteId))
|
||||
.returning();
|
||||
@@ -250,40 +243,3 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
||||
excludeSender: false // Include sender in broadcast
|
||||
};
|
||||
};
|
||||
|
||||
async function getUniqueSubnetForSite(
|
||||
exitNode: ExitNode,
|
||||
trx: Transaction | typeof db = db
|
||||
): Promise<string | null> {
|
||||
const lockKey = `subnet-allocation:${exitNode.exitNodeId}`;
|
||||
|
||||
return await lockManager.withLock(
|
||||
lockKey,
|
||||
async () => {
|
||||
const sitesQuery = await trx
|
||||
.select({
|
||||
subnet: sites.subnet
|
||||
})
|
||||
.from(sites)
|
||||
.where(eq(sites.exitNodeId, exitNode.exitNodeId));
|
||||
|
||||
const blockSize = config.getRawConfig().gerbil.site_block_size;
|
||||
const subnets = sitesQuery
|
||||
.map((site) => site.subnet)
|
||||
.filter(
|
||||
(subnet) =>
|
||||
subnet &&
|
||||
/^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/.test(subnet)
|
||||
)
|
||||
.filter((subnet) => subnet !== null);
|
||||
subnets.push(exitNode.address.replace(/\/\d+$/, `/${blockSize}`));
|
||||
const newSubnet = findNextAvailableCidr(
|
||||
subnets,
|
||||
blockSize,
|
||||
exitNode.address
|
||||
);
|
||||
return newSubnet;
|
||||
},
|
||||
5000 // 5 second lock TTL - subnet allocation should be quick
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ export * from "./handleNewtRegisterMessage";
|
||||
export * from "./handleReceiveBandwidthMessage";
|
||||
export * from "./handleNewtGetConfigMessage";
|
||||
export * from "./handleSocketMessages";
|
||||
export * from "./handleNewtPingRequestMessage";
|
||||
export * from "./handleNewtExitNodesRequestMessage";
|
||||
export * from "./handleApplyBlueprintMessage";
|
||||
export * from "./handleNewtPingMessage";
|
||||
export * from "./handleNewtDisconnectingMessage";
|
||||
|
||||
@@ -48,10 +48,6 @@ export async function buildSiteConfigurationForOlmClient(
|
||||
)
|
||||
.where(eq(clientSitesAssociationsCache.clientId, client.clientId));
|
||||
|
||||
if (sitesData.length === 0) {
|
||||
return siteConfigurations;
|
||||
}
|
||||
|
||||
// Batch-fetch every site resource this client has access to across ALL sites
|
||||
// in a single query, then group by siteId in memory. This avoids issuing one
|
||||
// query per site (which would be N round-trips for N sites).
|
||||
@@ -68,8 +64,8 @@ export async function buildSiteConfigurationForOlmClient(
|
||||
clientSiteResourcesAssociationsCache.siteResourceId
|
||||
)
|
||||
)
|
||||
.innerJoin(networks, eq(siteResources.networkId, networks.networkId))
|
||||
.innerJoin(siteNetworks, eq(networks.networkId, siteNetworks.networkId))
|
||||
.leftJoin(networks, eq(siteResources.networkId, networks.networkId))
|
||||
.leftJoin(siteNetworks, eq(networks.networkId, siteNetworks.networkId))
|
||||
.where(
|
||||
and(
|
||||
eq(
|
||||
@@ -80,8 +76,20 @@ export async function buildSiteConfigurationForOlmClient(
|
||||
)
|
||||
);
|
||||
|
||||
const haveInferenceResources = allClientSiteResources.some(
|
||||
(row) => row.siteResource.requiresExitNodeConnection === true
|
||||
);
|
||||
|
||||
if (sitesData.length === 0) {
|
||||
return { siteConfigurations, haveInferenceResources };
|
||||
}
|
||||
|
||||
const siteResourcesBySiteId = new Map<number, SiteResource[]>();
|
||||
for (const row of allClientSiteResources) {
|
||||
if (!row.siteId) {
|
||||
// because we are doing a leftJoin above to get the inference resources without a network / sites
|
||||
continue;
|
||||
}
|
||||
const arr = siteResourcesBySiteId.get(row.siteId);
|
||||
if (arr) {
|
||||
arr.push(row.siteResource);
|
||||
@@ -167,7 +175,7 @@ export async function buildSiteConfigurationForOlmClient(
|
||||
peerOps.push(deletePeer(site.siteId, client.pubKey!));
|
||||
}
|
||||
|
||||
if (!site.subnet) {
|
||||
if (!site.exitNodeSubnet) {
|
||||
logger.debug(`Site ${site.siteId} has no subnet, skipping`);
|
||||
continue;
|
||||
}
|
||||
@@ -226,5 +234,5 @@ export async function buildSiteConfigurationForOlmClient(
|
||||
});
|
||||
}
|
||||
|
||||
return siteConfigurations;
|
||||
return { siteConfigurations, haveInferenceResources };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { db, clients } from "@server/db";
|
||||
import { MessageHandler } from "@server/routers/ws";
|
||||
import { exitNodes, Olm } from "@server/db";
|
||||
import logger from "@server/logger";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { listExitNodes } from "#dynamic/lib/exitNodes";
|
||||
import { calculateExitNodeWeight } from "@server/lib/exitNodes";
|
||||
|
||||
export const handleOlmExitNodesRequestMessage: MessageHandler = async (
|
||||
context
|
||||
) => {
|
||||
const { message, client: olmClient, sendToClient } = context;
|
||||
const olm = olmClient as Olm;
|
||||
|
||||
logger.info("Handling exit nodes request olm message!");
|
||||
|
||||
if (!olm) {
|
||||
logger.warn("olm not found");
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the olm's orgId through the client relationship
|
||||
if (!olm.clientId) {
|
||||
logger.warn("olm clientId not found");
|
||||
return;
|
||||
}
|
||||
|
||||
const [client] = await db
|
||||
.select({ orgId: clients.orgId })
|
||||
.from(clients)
|
||||
.where(eq(clients.clientId, olm.clientId))
|
||||
.limit(1);
|
||||
|
||||
if (!client || !client.orgId) {
|
||||
logger.warn("client not found");
|
||||
return;
|
||||
}
|
||||
|
||||
const { noCloud, chainId } = message.data;
|
||||
|
||||
const exitNodesList = await listExitNodes(
|
||||
client.orgId,
|
||||
true,
|
||||
noCloud || false,
|
||||
olm.clientId
|
||||
); // filter for only the online ones
|
||||
|
||||
let lastExitNodeId = null;
|
||||
if (olm.clientId) {
|
||||
const [lastExitNode] = await db
|
||||
.select()
|
||||
.from(clients)
|
||||
.where(eq(clients.clientId, olm.clientId))
|
||||
.limit(1);
|
||||
lastExitNodeId = lastExitNode?.exitNodeId || null;
|
||||
}
|
||||
|
||||
const exitNodesPayload = await Promise.all(
|
||||
exitNodesList.map(async (node) => {
|
||||
const weight = await calculateExitNodeWeight(
|
||||
node.exitNodeId,
|
||||
node.maxConnections
|
||||
);
|
||||
|
||||
if (weight === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
exitNodeId: node.exitNodeId,
|
||||
exitNodeName: node.name,
|
||||
endpoint: node.endpoint,
|
||||
weight,
|
||||
wasPreviouslyConnected: node.exitNodeId === lastExitNodeId
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// filter out null values
|
||||
const filteredExitNodes = exitNodesPayload.filter((node) => node !== null);
|
||||
|
||||
return {
|
||||
message: {
|
||||
type: "olm/ping/exitNodes",
|
||||
data: {
|
||||
exitNodes: filteredExitNodes,
|
||||
chainId: chainId
|
||||
}
|
||||
},
|
||||
broadcast: false, // Send to all clients
|
||||
excludeSender: false // Include sender in broadcast
|
||||
};
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { db, orgs, primaryDb } from "@server/db";
|
||||
import { db, ExitNode, orgs, primaryDb } from "@server/db";
|
||||
import { MessageHandler } from "@server/routers/ws";
|
||||
import {
|
||||
clients,
|
||||
@@ -22,6 +22,12 @@ import { canCompress } from "@server/lib/clientVersionChecks";
|
||||
import config from "@server/lib/config";
|
||||
import cache from "#dynamic/lib/cache"; // not using regional here because we need this in the register message handler before we know where the client is
|
||||
import { waitForClientRebuildIdle } from "@server/lib/rebuildClientAssociations";
|
||||
import {
|
||||
ExitNodePingResult,
|
||||
selectBestExitNode,
|
||||
verifyExitNodeOrgAccess
|
||||
} from "#dynamic/lib/exitNodes";
|
||||
import { getUniqueSubnetForExitNode } from "@server/lib/exitNodes";
|
||||
|
||||
const HOLEPUNCH_STALE_CHAIN_THRESHOLD = 18;
|
||||
const HOLEPUNCH_STALE_CHAIN_TTL_SECONDS = 1800;
|
||||
@@ -49,11 +55,20 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
||||
olmAgent,
|
||||
orgId,
|
||||
userToken,
|
||||
pingResults,
|
||||
fingerprint,
|
||||
postures,
|
||||
backwardsCompatible,
|
||||
chainId
|
||||
} = message.data;
|
||||
|
||||
if (backwardsCompatible) {
|
||||
logger.debug(
|
||||
"[handleOlmRegisterMessage] Backwards compatible mode detected - not sending connect message and waiting for ping response."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!olm.clientId) {
|
||||
logger.warn("[handleOlmRegisterMessage] Olm client ID not found");
|
||||
sendOlmError(OlmErrorCodes.CLIENT_ID_NOT_FOUND, olm.olmId);
|
||||
@@ -284,7 +299,65 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (client.pubKey !== publicKey || client.archived) {
|
||||
let exitNodeId: number | undefined;
|
||||
if (pingResults) {
|
||||
const bestPingResult = selectBestExitNode(
|
||||
pingResults as ExitNodePingResult[]
|
||||
);
|
||||
if (!bestPingResult) {
|
||||
logger.warn("No suitable exit node found based on ping results");
|
||||
}
|
||||
exitNodeId = bestPingResult?.exitNodeId;
|
||||
}
|
||||
|
||||
let clientSubnet = client.exitNodeSubnet;
|
||||
let exitNode: ExitNode | null = null;
|
||||
if (
|
||||
exitNodeId &&
|
||||
(client.exitNodeId !== exitNodeId || !client.exitNodeSubnet)
|
||||
) {
|
||||
const { exitNode: exitNodeResult, hasAccess } =
|
||||
await verifyExitNodeOrgAccess(exitNodeId, client.orgId);
|
||||
|
||||
exitNode = exitNodeResult;
|
||||
|
||||
if (!exitNode) {
|
||||
logger.warn("[handleOlmRegisterMessage] Exit node not found", {
|
||||
orgId: client.orgId,
|
||||
clientId: client.clientId
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasAccess) {
|
||||
logger.warn(
|
||||
"[handleOlmRegisterMessage] Not authorized to use this exit node",
|
||||
{ orgId: client.orgId, clientId: client.clientId }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: IF WE DO NOT HAVE AN INFERENCE RESOURCE DO WE NEED TO BE HOLDING A SUBNET ON THE CLIENT?
|
||||
|
||||
const newSubnet = await getUniqueSubnetForExitNode(exitNode);
|
||||
|
||||
if (!newSubnet) {
|
||||
logger.error(
|
||||
`[handleOlmRegisterMessage] No available subnets found for exit node id ${exitNodeId} and client id ${client.clientId}`,
|
||||
{ orgId: client.orgId, clientId: client.clientId }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
clientSubnet = newSubnet;
|
||||
}
|
||||
|
||||
if (
|
||||
client.pubKey !== publicKey ||
|
||||
client.archived ||
|
||||
client.exitNodeId !== exitNodeId ||
|
||||
client.exitNodeSubnet !== clientSubnet
|
||||
) {
|
||||
logger.info(
|
||||
"[handleOlmRegisterMessage] Public key mismatch. Updating public key and clearing session info...",
|
||||
{ orgId: client.orgId, clientId: client.clientId }
|
||||
@@ -294,7 +367,9 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
||||
.update(clients)
|
||||
.set({
|
||||
pubKey: publicKey,
|
||||
archived: false
|
||||
archived: false,
|
||||
exitNodeId: exitNodeId, // this can be undefined if no exit node was selected, which is fine just means we cant talk to the node or connect to it
|
||||
exitNodeSubnet: clientSubnet
|
||||
})
|
||||
.where(eq(clients.clientId, client.clientId));
|
||||
|
||||
@@ -379,12 +454,13 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
||||
// NOTE: its important that the client here is the old client and the public key is the new key
|
||||
await waitForClientRebuildIdle(olm.clientId);
|
||||
|
||||
const siteConfigurations = await buildSiteConfigurationForOlmClient(
|
||||
client,
|
||||
publicKey,
|
||||
relay,
|
||||
jitMode
|
||||
);
|
||||
const { siteConfigurations, haveInferenceResources } =
|
||||
await buildSiteConfigurationForOlmClient(
|
||||
client,
|
||||
publicKey,
|
||||
relay,
|
||||
jitMode
|
||||
);
|
||||
|
||||
// Return connect message with all site configurations
|
||||
return {
|
||||
@@ -394,6 +470,19 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
||||
sites: siteConfigurations,
|
||||
tunnelIP: client.subnet,
|
||||
utilitySubnet: org.utilitySubnet,
|
||||
exitNode:
|
||||
exitNode && client.exitNodeSubnet
|
||||
? {
|
||||
connect: haveInferenceResources, // we do not need to connect to the exit node if we do not have inference resources
|
||||
endpoint: `${exitNode.endpoint}:${exitNode.listenPort}`,
|
||||
relayPort:
|
||||
config.getRawConfig().gerbil
|
||||
.clients_start_port,
|
||||
publicKey: exitNode.publicKey,
|
||||
serverIP: exitNode.address.split("/")[0],
|
||||
tunnelIP: client.exitNodeSubnet.split("/")[0]
|
||||
}
|
||||
: undefined,
|
||||
chainId: chainId
|
||||
}
|
||||
},
|
||||
|
||||
@@ -15,3 +15,4 @@ export * from "./handleOlmServerInitAddPeerHandshake";
|
||||
export * from "./offlineChecker";
|
||||
export * from "./handleOlmUnLocalMessage";
|
||||
export * from "./handleOlmLocalMessage";
|
||||
export * from "./handleOlmExitNodesRequestMessage";
|
||||
|
||||
@@ -311,13 +311,13 @@ export async function createSite(
|
||||
// lets also make sure there is no overlap with other sites on the exit node
|
||||
const sitesQuery = await db
|
||||
.select({
|
||||
subnet: sites.subnet
|
||||
subnet: sites.exitNodeSubnet
|
||||
})
|
||||
.from(sites)
|
||||
.where(
|
||||
and(
|
||||
eq(sites.exitNodeId, exitNodeId),
|
||||
eq(sites.subnet, subnet)
|
||||
eq(sites.exitNodeSubnet, subnet)
|
||||
)
|
||||
);
|
||||
|
||||
@@ -427,7 +427,7 @@ export async function createSite(
|
||||
exitNodeId,
|
||||
name,
|
||||
niceId: updatedNiceId!,
|
||||
subnet,
|
||||
exitNodeSubnet: subnet,
|
||||
type,
|
||||
pubKey: pubKey || null,
|
||||
status: "approved"
|
||||
@@ -444,7 +444,7 @@ export async function createSite(
|
||||
type,
|
||||
dockerSocketEnabled: false,
|
||||
online: true,
|
||||
subnet: "0.0.0.0/32",
|
||||
exitNodeSubnet: "0.0.0.0/32",
|
||||
status: "approved"
|
||||
})
|
||||
.returning();
|
||||
|
||||
@@ -125,7 +125,7 @@ function querySitesBase() {
|
||||
niceId: sites.niceId,
|
||||
name: sites.name,
|
||||
pubKey: sites.pubKey,
|
||||
subnet: sites.subnet,
|
||||
subnet: sites.exitNodeSubnet,
|
||||
megabytesIn: sites.megabytesIn,
|
||||
megabytesOut: sites.megabytesOut,
|
||||
orgName: orgs.name,
|
||||
|
||||
@@ -43,7 +43,6 @@ const PickSiteDefaultsResponseDataSchema = z.object({
|
||||
clientAddress: z.string().optional()
|
||||
});
|
||||
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/org/{orgId}/pick-site-defaults",
|
||||
@@ -60,7 +59,9 @@ registry.registerPath({
|
||||
description: "Successful response",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: createApiResponseSchema(PickSiteDefaultsResponseDataSchema)
|
||||
schema: createApiResponseSchema(
|
||||
PickSiteDefaultsResponseDataSchema
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -108,7 +109,7 @@ export async function pickSiteDefaults(
|
||||
// list all of the sites on that exit node
|
||||
const sitesQuery = await db
|
||||
.select({
|
||||
subnet: sites.subnet
|
||||
subnet: sites.exitNodeSubnet
|
||||
})
|
||||
.from(sites)
|
||||
.where(eq(sites.exitNodeId, randomExitNode.exitNodeId));
|
||||
|
||||
@@ -49,7 +49,7 @@ const createSiteResourceSchema = z
|
||||
name: z.string().min(1).max(255),
|
||||
niceId: z.string().optional(),
|
||||
// protocol: z.enum(["tcp", "udp"]).optional(),
|
||||
mode: z.enum(["host", "cidr", "http", "ssh"]),
|
||||
mode: z.enum(["host", "cidr", "http", "ssh", "inference"]),
|
||||
ssl: z.boolean().optional(), // only used for http mode
|
||||
scheme: z.enum(["http", "https"]).optional(),
|
||||
siteIds: z.array(z.int()).optional(),
|
||||
@@ -171,6 +171,9 @@ const createSiteResourceSchema = z
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.mode == "inference") {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
(data.siteIds !== undefined && data.siteIds.length > 0) ||
|
||||
data.siteId !== undefined
|
||||
@@ -533,21 +536,24 @@ export async function createSiteResource(
|
||||
let newSiteResource: SiteResource | undefined;
|
||||
try {
|
||||
await db.transaction(async (trx) => {
|
||||
const [network] = await trx
|
||||
.insert(networks)
|
||||
.values({
|
||||
scope: "resource",
|
||||
orgId: orgId
|
||||
})
|
||||
.returning();
|
||||
let network: typeof networks.$inferSelect | undefined;
|
||||
if (mode !== "inference") {
|
||||
[network] = await trx
|
||||
.insert(networks)
|
||||
.values({
|
||||
scope: "resource",
|
||||
orgId: orgId
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!network) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
`Failed to create network`
|
||||
)
|
||||
);
|
||||
if (!network) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
`Failed to create network`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let tcpPortRangeStringAdjusted = tcpPortRangeString;
|
||||
@@ -566,7 +572,7 @@ export async function createSiteResource(
|
||||
name,
|
||||
mode,
|
||||
ssl,
|
||||
networkId: network.networkId,
|
||||
networkId: network ? network.networkId : null,
|
||||
destination: destination, // the ssh can be null
|
||||
scheme,
|
||||
destinationPort,
|
||||
@@ -582,7 +588,8 @@ export async function createSiteResource(
|
||||
(mode == "http" || mode == "ssh" ? true : false), // default to true for http resources, otherwise false
|
||||
domainId,
|
||||
subdomain: finalSubdomain,
|
||||
fullDomain
|
||||
fullDomain,
|
||||
requiresExitNodeConnection: mode === "inference" // in the future we might want to have different modes that do this
|
||||
};
|
||||
if (isLicensedSshPam) {
|
||||
if (authDaemonPort !== undefined)
|
||||
@@ -600,11 +607,13 @@ export async function createSiteResource(
|
||||
|
||||
//////////////////// update the associations ////////////////////
|
||||
|
||||
for (const siteId of siteIds) {
|
||||
await trx.insert(siteNetworks).values({
|
||||
siteId: siteId,
|
||||
networkId: network.networkId
|
||||
});
|
||||
if (network) {
|
||||
for (const siteId of siteIds) {
|
||||
await trx.insert(siteNetworks).values({
|
||||
siteId: siteId,
|
||||
networkId: network.networkId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const [adminRole] = await trx
|
||||
|
||||
@@ -50,7 +50,7 @@ const updateSiteResourceSchema = z
|
||||
)
|
||||
.optional(),
|
||||
// mode: z.enum(["host", "cidr", "port"]).optional(),
|
||||
mode: z.enum(["host", "cidr", "http", "ssh"]).optional(),
|
||||
mode: z.enum(["host", "cidr", "http", "ssh", "inference"]).optional(),
|
||||
ssl: z.boolean().optional(),
|
||||
scheme: z.enum(["http", "https"]).nullish(),
|
||||
destinationPort: z.int().positive().nullish(),
|
||||
@@ -173,6 +173,9 @@ const updateSiteResourceSchema = z
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.mode == "inference") {
|
||||
return true;
|
||||
}
|
||||
// if neither is provided, the existing site associations are left unchanged
|
||||
if (data.siteIds === undefined && data.siteId === undefined) {
|
||||
return true;
|
||||
@@ -579,13 +582,15 @@ export async function updateSiteResource(
|
||||
disableIcmp:
|
||||
mode !== undefined
|
||||
? disableIcmp ||
|
||||
(mode == "http" || mode == "ssh"
|
||||
? true
|
||||
: false)
|
||||
(mode == "http" || mode == "ssh" ? true : false)
|
||||
: disableIcmp,
|
||||
domainId,
|
||||
subdomain: finalSubdomain,
|
||||
fullDomain,
|
||||
networkId:
|
||||
mode === "inference" ? null : undefined,
|
||||
requiresExitNodeConnection:
|
||||
mode !== undefined ? mode === "inference" : undefined,
|
||||
...sshPamSet
|
||||
})
|
||||
.where(and(eq(siteResources.siteResourceId, siteResourceId)))
|
||||
@@ -593,7 +598,20 @@ export async function updateSiteResource(
|
||||
|
||||
//////////////////// update the associations ////////////////////
|
||||
|
||||
if (siteIds !== undefined) {
|
||||
if (mode === "inference") {
|
||||
// inference resources are not attached to any site network
|
||||
if (existingSiteResource.networkId) {
|
||||
await trx
|
||||
.delete(siteNetworks)
|
||||
.where(
|
||||
eq(
|
||||
siteNetworks.networkId,
|
||||
existingSiteResource.networkId
|
||||
)
|
||||
);
|
||||
}
|
||||
updatedSiteIds = [];
|
||||
} else if (siteIds !== undefined) {
|
||||
// delete the site - site resources associations
|
||||
await trx
|
||||
.delete(siteNetworks)
|
||||
|
||||
@@ -263,7 +263,7 @@ export async function createTarget(
|
||||
// make sure the target is within the site subnet
|
||||
if (
|
||||
site.type == "wireguard" &&
|
||||
!isIpInCidr(targetData.ip, site.subnet!)
|
||||
!isIpInCidr(targetData.ip, site.exitNodeSubnet!)
|
||||
) {
|
||||
return next(
|
||||
createHttpError(
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
handleNewtGetConfigMessage,
|
||||
handleDockerStatusMessage,
|
||||
handleDockerContainersMessage,
|
||||
handleNewtPingRequestMessage,
|
||||
handleNewtExitNodesRequestMessage,
|
||||
handleApplyBlueprintMessage,
|
||||
handleNewtPingMessage,
|
||||
startNewtOfflineChecker,
|
||||
@@ -22,7 +22,8 @@ import {
|
||||
handleOlmDisconnectingMessage,
|
||||
handleOlmServerInitAddPeerHandshake,
|
||||
handleOlmLocalMessage,
|
||||
handleOlmUnLocalMessage
|
||||
handleOlmUnLocalMessage,
|
||||
handleOlmExitNodesRequestMessage
|
||||
} from "../olm";
|
||||
import { handleHealthcheckStatusMessage } from "../target";
|
||||
import { handleRoundTripMessage } from "./handleRoundTripMessage";
|
||||
@@ -37,6 +38,7 @@ export const messageHandlers: Record<string, MessageHandler> = {
|
||||
"olm/wg/local": handleOlmLocalMessage,
|
||||
"olm/wg/unlocal": handleOlmUnLocalMessage,
|
||||
"olm/ping": handleOlmPingMessage,
|
||||
"olm/ping/request": handleOlmExitNodesRequestMessage,
|
||||
"olm/disconnecting": handleOlmDisconnectingMessage,
|
||||
"newt/disconnecting": handleNewtDisconnectingMessage,
|
||||
"newt/ping": handleNewtPingMessage,
|
||||
@@ -45,7 +47,7 @@ export const messageHandlers: Record<string, MessageHandler> = {
|
||||
"newt/receive-bandwidth": handleReceiveBandwidthMessage,
|
||||
"newt/socket/status": handleDockerStatusMessage,
|
||||
"newt/socket/containers": handleDockerContainersMessage,
|
||||
"newt/ping/request": handleNewtPingRequestMessage,
|
||||
"newt/ping/request": handleNewtExitNodesRequestMessage,
|
||||
"newt/blueprint/apply": handleApplyBlueprintMessage,
|
||||
"newt/healthcheck/status": handleHealthcheckStatusMessage,
|
||||
"ws/round-trip/complete": handleRoundTripMessage
|
||||
|
||||
@@ -72,8 +72,7 @@ export default function CredentialsPage() {
|
||||
const { data: latestVersions } = useQuery(
|
||||
productUpdatesQueries.latestVersion(true)
|
||||
);
|
||||
const newtVersion =
|
||||
latestVersions?.data?.newt?.latestVersion ?? "latest";
|
||||
const newtVersion = latestVersions?.data?.newt?.latestVersion ?? "latest";
|
||||
|
||||
// Fetch site defaults for wireguard sites to show in obfuscated config
|
||||
useEffect(() => {
|
||||
@@ -354,7 +353,7 @@ export default function CredentialsPage() {
|
||||
text={generateObfuscatedWireGuardConfig(
|
||||
{
|
||||
subnet:
|
||||
site?.subnet ||
|
||||
site?.exitNodeSubnet ||
|
||||
siteDefaults?.subnet ||
|
||||
null,
|
||||
address:
|
||||
|
||||
@@ -134,7 +134,9 @@ export default function UptimeBar({
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
const allNoData = data.days.every((d) => d.status === "no_data");
|
||||
const allNoData = data.days.every(
|
||||
(d) => d.status === "no_data" || d.status === "unknown"
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-3", className)}>
|
||||
|
||||
@@ -124,7 +124,9 @@ export function UptimeMiniBar({
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
const allNoData = data.days.every((d) => d.status === "no_data");
|
||||
const allNoData = data.days.every(
|
||||
(d) => d.status === "no_data" || d.status === "unknown"
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
Reference in New Issue
Block a user