Compare commits

...

18 Commits

Author SHA1 Message Date
Owen
c3820a4e70 Add missing queuing 2026-06-22 16:47:52 -04:00
Owen
6b56c00782 Pull the listing out of the queue 2026-06-22 15:24:31 -04:00
Owen
60c1b572ba Add drizzle indexes to match db 2026-06-22 15:12:07 -04:00
Owen
604dee9aa5 Batch get olm ids 2026-06-22 15:12:07 -04:00
Owen
ee42846c90 Add batch messaging functions to rebuild function 2026-06-22 15:12:07 -04:00
copilot-swe-agent[bot]
22ac711dc6 refactor: tighten ws batch typing and queue cleanup logging 2026-06-22 15:12:07 -04:00
copilot-swe-agent[bot]
d09668b20b feat: batch redis ws direct messages and dedupe rebuild queue jobs 2026-06-22 15:12:07 -04:00
Owen
16abe98fd9 Add queue 2026-06-22 15:12:07 -04:00
copilot-swe-agent[bot]
d240201361 Initial plan 2026-06-22 15:12:07 -04:00
Josh Voyles
b7081aff11 fix: remove no-op autoFinalizeStatement wrapper and redundant busy_timeout (#2120)
better-sqlite3 11.x exposes no Statement.finalize() — the wrapper threw and
swallowed a TypeError on every query (verified: 'Statement.finalize exists:
undefined' in the runner image) while adding +122% per-statement overhead
(3.90 -> 8.66 us/op, 200k-op in-container microbench) and freeing nothing.
Statement lifecycle is GC-managed by the driver; drizzle-orm prepares fresh
per query, so nothing accumulates unbounded.

busy_timeout=5000 duplicates better-sqlite3's default timeout option, which
already arms sqlite3_busy_timeout(db, 5000) at open (lib/database.js).

With ENABLE_SQLITE_WAL_MODE unset the driver is now runtime-identical to
pre-1.18.3 (zero pragmas). The env-gated WAL block stays: journal_mode is
sticky in the DB file, so removing it would strand opted-in databases on
WAL+synchronous=FULL.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-22 15:11:51 -04:00
Josh Voyles
a55fb21e53 fix(sqlite): remove cache_size and mmap_size PRAGMAs (#2120)
A 64 MB page cache plus a 256 MB memory-mapped region inflate RSS and
cause page-cache thrashing on small (~1 GB) instances. The PRAGMAs were
added to reduce event-loop blocking on TraefikConfigManager JOINs but
the memory cost outweighs the I/O benefit on the deployment shapes that
hit #2120. Leave SQLite on its conservative defaults.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-22 15:11:51 -04:00
copilot-swe-agent[bot]
e5e7b79712 test: add normalized ASN validation coverage 2026-06-22 15:11:51 -04:00
copilot-swe-agent[bot]
de48a0529e refactor: normalize ASN validation value once 2026-06-22 15:11:51 -04:00
copilot-swe-agent[bot]
3f37408dae fix: allow ALL ASN values in policy rule validation 2026-06-22 15:11:51 -04:00
copilot-swe-agent[bot]
a2882857ff Initial plan 2026-06-22 15:11:51 -04:00
Owen
476d92b3ac Convert things to regional cache 2026-06-22 15:11:51 -04:00
Owen
bf604f25e9 Show the input validation in the error report 2026-06-22 15:11:50 -04:00
Owen
34a0d2a68b Remove NoNewPrivileges
Fixes https://github.com/fosrl/newt/issues/383
2026-06-22 15:11:50 -04:00
27 changed files with 1854 additions and 577 deletions

View File

@@ -11,7 +11,7 @@ import {
primaryKey, primaryKey,
uniqueIndex uniqueIndex
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
import { InferSelectModel } from "drizzle-orm"; import { InferSelectModel, sql } from "drizzle-orm";
import { import {
domains, domains,
orgs, orgs,
@@ -207,17 +207,28 @@ export const remoteExitNodeSessions = pgTable("remoteExitNodeSession", {
expiresAt: bigint("expiresAt", { mode: "number" }).notNull() expiresAt: bigint("expiresAt", { mode: "number" }).notNull()
}); });
export const loginPage = pgTable("loginPage", { export const loginPage = pgTable(
loginPageId: serial("loginPageId").primaryKey(), "loginPage",
subdomain: varchar("subdomain"), {
fullDomain: varchar("fullDomain"), loginPageId: serial("loginPageId").primaryKey(),
exitNodeId: integer("exitNodeId").references(() => exitNodes.exitNodeId, { subdomain: varchar("subdomain"),
onDelete: "set null" fullDomain: varchar("fullDomain"),
}), exitNodeId: integer("exitNodeId").references(
domainId: varchar("domainId").references(() => domains.domainId, { () => exitNodes.exitNodeId,
onDelete: "set null" {
}) onDelete: "set null"
}); }
),
domainId: varchar("domainId").references(() => domains.domainId, {
onDelete: "set null"
})
},
(t) => [
index("idx_loginpage_fulldomain")
.on(t.fullDomain)
.where(sql`${t.fullDomain} IS NOT NULL`)
]
);
export const loginPageOrg = pgTable("loginPageOrg", { export const loginPageOrg = pgTable("loginPageOrg", {
loginPageId: integer("loginPageId") loginPageId: integer("loginPageId")

View File

@@ -1,5 +1,5 @@
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
import { InferSelectModel } from "drizzle-orm"; import { InferSelectModel, sql } from "drizzle-orm";
import { import {
bigint, bigint,
boolean, boolean,
@@ -82,107 +82,130 @@ export const orgDomains = pgTable("orgDomains", {
.references(() => domains.domainId, { onDelete: "cascade" }) .references(() => domains.domainId, { onDelete: "cascade" })
}); });
export const sites = pgTable("sites", { export const sites = pgTable(
siteId: serial("siteId").primaryKey(), "sites",
orgId: varchar("orgId") {
.references(() => orgs.orgId, { siteId: serial("siteId").primaryKey(),
onDelete: "cascade" orgId: varchar("orgId")
}) .references(() => orgs.orgId, {
.notNull(), onDelete: "cascade"
niceId: varchar("niceId").notNull(), })
exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, { .notNull(),
onDelete: "set null" niceId: varchar("niceId").notNull(),
}), exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, {
name: varchar("name").notNull(), onDelete: "set null"
pubKey: varchar("pubKey"), }),
subnet: varchar("subnet"), name: varchar("name").notNull(),
megabytesIn: real("bytesIn").default(0), pubKey: varchar("pubKey"),
megabytesOut: real("bytesOut").default(0), subnet: varchar("subnet"),
lastBandwidthUpdate: varchar("lastBandwidthUpdate"), megabytesIn: real("bytesIn").default(0),
type: varchar("type").notNull(), // "newt" or "wireguard" megabytesOut: real("bytesOut").default(0),
online: boolean("online").notNull().default(false), lastBandwidthUpdate: varchar("lastBandwidthUpdate"),
lastPing: integer("lastPing"), type: varchar("type").notNull(), // "newt" or "wireguard"
address: varchar("address"), online: boolean("online").notNull().default(false),
endpoint: varchar("endpoint"), lastPing: integer("lastPing"),
publicKey: varchar("publicKey"), address: varchar("address"),
lastHolePunch: bigint("lastHolePunch", { mode: "number" }), endpoint: varchar("endpoint"),
listenPort: integer("listenPort"), publicKey: varchar("publicKey"),
dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true), lastHolePunch: bigint("lastHolePunch", { mode: "number" }),
autoUpdateEnabled: boolean("autoUpdateEnabled").notNull().default(false), listenPort: integer("listenPort"),
autoUpdateOverrideOrg: boolean("autoUpdateOverrideOrg") dockerSocketEnabled: boolean("dockerSocketEnabled")
.notNull() .notNull()
.default(false), .default(true),
status: varchar("status") autoUpdateEnabled: boolean("autoUpdateEnabled")
.$type<"pending" | "approved">() .notNull()
.default("approved") .default(false),
}); autoUpdateOverrideOrg: boolean("autoUpdateOverrideOrg")
.notNull()
.default(false),
status: varchar("status")
.$type<"pending" | "approved">()
.default("approved")
},
(t) => [
index("idx_sites_exitnodeid").on(t.exitNodeId),
index("idx_sites_exitnode_type_siteid").on(
t.exitNodeId,
t.type,
t.siteId
)
]
);
export const resources = pgTable("resources", { export const resources = pgTable(
resourceId: serial("resourceId").primaryKey(), "resources",
resourcePolicyId: integer("resourcePolicyId").references( {
() => resourcePolicies.resourcePolicyId, resourceId: serial("resourceId").primaryKey(),
{ onDelete: "set null" } resourcePolicyId: integer("resourcePolicyId").references(
), () => resourcePolicies.resourcePolicyId,
defaultResourcePolicyId: integer("defaultResourcePolicyId").references( { onDelete: "set null" }
() => resourcePolicies.resourcePolicyId, ),
{ defaultResourcePolicyId: integer("defaultResourcePolicyId").references(
onDelete: "restrict" () => resourcePolicies.resourcePolicyId,
} {
), onDelete: "restrict"
resourceGuid: varchar("resourceGuid", { length: 36 }) }
.unique() ),
.notNull() resourceGuid: varchar("resourceGuid", { length: 36 })
.$defaultFn(() => randomUUID()), .unique()
orgId: varchar("orgId") .notNull()
.references(() => orgs.orgId, { .$defaultFn(() => randomUUID()),
onDelete: "cascade" orgId: varchar("orgId")
}) .references(() => orgs.orgId, {
.notNull(), onDelete: "cascade"
niceId: text("niceId").notNull(), })
name: varchar("name").notNull(), .notNull(),
subdomain: varchar("subdomain"), niceId: text("niceId").notNull(),
fullDomain: varchar("fullDomain"), name: varchar("name").notNull(),
domainId: varchar("domainId").references(() => domains.domainId, { subdomain: varchar("subdomain"),
onDelete: "set null" fullDomain: varchar("fullDomain"),
}), domainId: varchar("domainId").references(() => domains.domainId, {
ssl: boolean("ssl").notNull().default(false), onDelete: "set null"
blockAccess: boolean("blockAccess").notNull().default(false), }),
proxyPort: integer("proxyPort"), ssl: boolean("ssl").notNull().default(false),
sso: boolean("sso"), blockAccess: boolean("blockAccess").notNull().default(false),
emailWhitelistEnabled: boolean("emailWhitelistEnabled"), proxyPort: integer("proxyPort"),
applyRules: boolean("applyRules"), sso: boolean("sso"),
enabled: boolean("enabled").notNull().default(true), emailWhitelistEnabled: boolean("emailWhitelistEnabled"),
stickySession: boolean("stickySession").notNull().default(false), applyRules: boolean("applyRules"),
tlsServerName: varchar("tlsServerName"), enabled: boolean("enabled").notNull().default(true),
setHostHeader: varchar("setHostHeader"), stickySession: boolean("stickySession").notNull().default(false),
enableProxy: boolean("enableProxy").default(true), tlsServerName: varchar("tlsServerName"),
skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, { setHostHeader: varchar("setHostHeader"),
onDelete: "set null" enableProxy: boolean("enableProxy").default(true),
}), skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, {
headers: text("headers"), // comma-separated list of headers to add to the request onDelete: "set null"
proxyProtocol: boolean("proxyProtocol").notNull().default(false), }),
proxyProtocolVersion: integer("proxyProtocolVersion").default(1), headers: text("headers"), // comma-separated list of headers to add to the request
maintenanceModeEnabled: boolean("maintenanceModeEnabled") proxyProtocol: boolean("proxyProtocol").notNull().default(false),
.notNull() proxyProtocolVersion: integer("proxyProtocolVersion").default(1),
.default(false), maintenanceModeEnabled: boolean("maintenanceModeEnabled")
maintenanceModeType: text("maintenanceModeType", { .notNull()
enum: ["forced", "automatic"] .default(false),
}).default("forced"), // "forced" = always show, "automatic" = only when down maintenanceModeType: text("maintenanceModeType", {
maintenanceTitle: text("maintenanceTitle"), enum: ["forced", "automatic"]
maintenanceMessage: text("maintenanceMessage"), }).default("forced"), // "forced" = always show, "automatic" = only when down
maintenanceEstimatedTime: text("maintenanceEstimatedTime"), maintenanceTitle: text("maintenanceTitle"),
postAuthPath: text("postAuthPath"), maintenanceMessage: text("maintenanceMessage"),
health: varchar("health").default("unknown"), // "healthy", "unhealthy", "unknown" maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
wildcard: boolean("wildcard").notNull().default(false), postAuthPath: text("postAuthPath"),
mode: text("mode").default("http").notNull(), // rdp, ssh, http, vnc health: varchar("health").default("unknown"), // "healthy", "unhealthy", "unknown"
pamMode: varchar("pamMode", { length: 32 }) wildcard: boolean("wildcard").notNull().default(false),
.$type<"passthrough" | "push">() mode: text("mode").default("http").notNull(), // rdp, ssh, http, vnc
.default("passthrough"), pamMode: varchar("pamMode", { length: 32 })
authDaemonMode: varchar("authDaemonMode", { length: 32 }) .$type<"passthrough" | "push">()
.$type<"site" | "remote" | "native">() .default("passthrough"),
.default("site"), authDaemonMode: varchar("authDaemonMode", { length: 32 })
authDaemonPort: integer("authDaemonPort").default(22123) .$type<"site" | "remote" | "native">()
}); .default("site"),
authDaemonPort: integer("authDaemonPort").default(22123)
},
(t) => [
index("idx_resources_fulldomain")
.on(t.fullDomain)
.where(sql`${t.fullDomain} IS NOT NULL`)
]
);
export const labels = pgTable("labels", { export const labels = pgTable("labels", {
labelId: serial("labelId").primaryKey(), labelId: serial("labelId").primaryKey(),
@@ -267,71 +290,84 @@ export const clientLabels = pgTable(
(t) => [unique("client_label_uniq").on(t.clientId, t.labelId)] (t) => [unique("client_label_uniq").on(t.clientId, t.labelId)]
); );
export const targets = pgTable("targets", { export const targets = pgTable(
targetId: serial("targetId").primaryKey(), "targets",
resourceId: integer("resourceId") {
.references(() => resources.resourceId, { targetId: serial("targetId").primaryKey(),
onDelete: "cascade" resourceId: integer("resourceId")
}) .references(() => resources.resourceId, {
.notNull(), onDelete: "cascade"
siteId: integer("siteId") })
.references(() => sites.siteId, { .notNull(),
onDelete: "cascade" siteId: integer("siteId")
}) .references(() => sites.siteId, {
.notNull(), onDelete: "cascade"
ip: varchar("ip").notNull(), })
method: varchar("method"), .notNull(),
port: integer("port").notNull(), ip: varchar("ip").notNull(),
internalPort: integer("internalPort"), method: varchar("method"),
enabled: boolean("enabled").notNull().default(true), port: integer("port").notNull(),
path: text("path"), internalPort: integer("internalPort"),
pathMatchType: text("pathMatchType"), // exact, prefix, regex enabled: boolean("enabled").notNull().default(true),
rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target path: text("path"),
rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix pathMatchType: text("pathMatchType"), // exact, prefix, regex
priority: integer("priority").notNull().default(100), rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target
mode: varchar("mode") rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix
.$type<"http" | "tcp" | "udp" | "ssh" | "rdp" | "vnc">() priority: integer("priority").notNull().default(100),
.notNull() mode: varchar("mode")
.default("http"), .$type<"http" | "tcp" | "udp" | "ssh" | "rdp" | "vnc">()
authToken: varchar("authToken") .notNull()
}); .default("http"),
authToken: varchar("authToken")
},
(t) => [
index("idx_targets_resourceid_siteid").on(t.resourceId, t.siteId),
index("idx_targets_site_enabled_priority_target_resource")
.on(t.siteId, t.priority.desc(), t.targetId, t.resourceId)
.where(sql`${t.enabled} = true`)
]
);
export const targetHealthCheck = pgTable("targetHealthCheck", { export const targetHealthCheck = pgTable(
targetHealthCheckId: serial("targetHealthCheckId").primaryKey(), "targetHealthCheck",
targetId: integer("targetId").references(() => targets.targetId, { {
onDelete: "cascade" targetHealthCheckId: serial("targetHealthCheckId").primaryKey(),
}), targetId: integer("targetId").references(() => targets.targetId, {
orgId: varchar("orgId")
.references(() => orgs.orgId, {
onDelete: "cascade" onDelete: "cascade"
}) }),
.notNull(), orgId: varchar("orgId")
siteId: integer("siteId") .references(() => orgs.orgId, {
.references(() => sites.siteId, { onDelete: "cascade"
onDelete: "cascade" })
}) .notNull(),
.notNull(), siteId: integer("siteId")
name: varchar("name"), .references(() => sites.siteId, {
hcEnabled: boolean("hcEnabled").notNull().default(false), onDelete: "cascade"
hcPath: varchar("hcPath"), })
hcScheme: varchar("hcScheme"), .notNull(),
hcMode: varchar("hcMode").default("http"), name: varchar("name"),
hcHostname: varchar("hcHostname"), hcEnabled: boolean("hcEnabled").notNull().default(false),
hcPort: integer("hcPort"), hcPath: varchar("hcPath"),
hcInterval: integer("hcInterval").default(30), // in seconds hcScheme: varchar("hcScheme"),
hcUnhealthyInterval: integer("hcUnhealthyInterval").default(30), // in seconds hcMode: varchar("hcMode").default("http"),
hcTimeout: integer("hcTimeout").default(5), // in seconds hcHostname: varchar("hcHostname"),
hcHeaders: varchar("hcHeaders"), hcPort: integer("hcPort"),
hcFollowRedirects: boolean("hcFollowRedirects").default(true), hcInterval: integer("hcInterval").default(30), // in seconds
hcMethod: varchar("hcMethod").default("GET"), hcUnhealthyInterval: integer("hcUnhealthyInterval").default(30), // in seconds
hcStatus: integer("hcStatus"), // http code hcTimeout: integer("hcTimeout").default(5), // in seconds
hcHealth: text("hcHealth") hcHeaders: varchar("hcHeaders"),
.$type<"unknown" | "healthy" | "unhealthy">() hcFollowRedirects: boolean("hcFollowRedirects").default(true),
.default("unknown"), // "unknown", "healthy", "unhealthy" hcMethod: varchar("hcMethod").default("GET"),
hcTlsServerName: text("hcTlsServerName"), hcStatus: integer("hcStatus"), // http code
hcHealthyThreshold: integer("hcHealthyThreshold").default(1), hcHealth: text("hcHealth")
hcUnhealthyThreshold: integer("hcUnhealthyThreshold").default(1) .$type<"unknown" | "healthy" | "unhealthy">()
}); .default("unknown"), // "unknown", "healthy", "unhealthy"
hcTlsServerName: text("hcTlsServerName"),
hcHealthyThreshold: integer("hcHealthyThreshold").default(1),
hcUnhealthyThreshold: integer("hcUnhealthyThreshold").default(1)
},
(t) => [index("idx_targethealthcheck_targetid").on(t.targetId)]
);
export const exitNodes = pgTable("exitNodes", { export const exitNodes = pgTable("exitNodes", {
exitNodeId: serial("exitNodeId").primaryKey(), exitNodeId: serial("exitNodeId").primaryKey(),
@@ -406,43 +442,74 @@ export const networks = pgTable("networks", {
.notNull() .notNull()
}); });
export const siteNetworks = pgTable("siteNetworks", { export const siteNetworks = pgTable(
siteId: integer("siteId") "siteNetworks",
.notNull() {
.references(() => sites.siteId, { siteId: integer("siteId")
onDelete: "cascade" .notNull()
}), .references(() => sites.siteId, {
networkId: integer("networkId") onDelete: "cascade"
.notNull() }),
.references(() => networks.networkId, { onDelete: "cascade" }) networkId: integer("networkId")
}); .notNull()
.references(() => networks.networkId, { onDelete: "cascade" })
},
(t) => [
index("idx_sitenetworks_siteid").on(t.siteId),
index("idx_sitenetworks_networkid").on(t.networkId)
]
);
export const clientSiteResources = pgTable("clientSiteResources", { export const clientSiteResources = pgTable(
clientId: integer("clientId") "clientSiteResources",
.notNull() {
.references(() => clients.clientId, { onDelete: "cascade" }), clientId: integer("clientId")
siteResourceId: integer("siteResourceId") .notNull()
.notNull() .references(() => clients.clientId, { onDelete: "cascade" }),
.references(() => siteResources.siteResourceId, { onDelete: "cascade" }) siteResourceId: integer("siteResourceId")
}); .notNull()
.references(() => siteResources.siteResourceId, {
onDelete: "cascade"
})
},
(t) => [
index("idx_clientsiteresources_clientid").on(t.clientId),
index("idx_clientsiteresources_siteresourceid").on(t.siteResourceId)
]
);
export const roleSiteResources = pgTable("roleSiteResources", { export const roleSiteResources = pgTable(
roleId: integer("roleId") "roleSiteResources",
.notNull() {
.references(() => roles.roleId, { onDelete: "cascade" }), roleId: integer("roleId")
siteResourceId: integer("siteResourceId") .notNull()
.notNull() .references(() => roles.roleId, { onDelete: "cascade" }),
.references(() => siteResources.siteResourceId, { onDelete: "cascade" }) siteResourceId: integer("siteResourceId")
}); .notNull()
.references(() => siteResources.siteResourceId, {
onDelete: "cascade"
})
},
(t) => [index("idx_rolesiteresources_siteresourceid").on(t.siteResourceId)]
);
export const userSiteResources = pgTable("userSiteResources", { export const userSiteResources = pgTable(
userId: varchar("userId") "userSiteResources",
.notNull() {
.references(() => users.userId, { onDelete: "cascade" }), userId: varchar("userId")
siteResourceId: integer("siteResourceId") .notNull()
.notNull() .references(() => users.userId, { onDelete: "cascade" }),
.references(() => siteResources.siteResourceId, { onDelete: "cascade" }) siteResourceId: integer("siteResourceId")
}); .notNull()
.references(() => siteResources.siteResourceId, {
onDelete: "cascade"
})
},
(t) => [
index("idx_usersiteresources_userid").on(t.userId),
index("idx_usersiteresources_siteresourceid").on(t.siteResourceId)
]
);
export const users = pgTable("user", { export const users = pgTable("user", {
userId: varchar("id").primaryKey(), userId: varchar("id").primaryKey(),
@@ -467,15 +534,19 @@ export const users = pgTable("user", {
locale: varchar("locale") locale: varchar("locale")
}); });
export const newts = pgTable("newt", { export const newts = pgTable(
newtId: varchar("id").primaryKey(), "newt",
secretHash: varchar("secretHash").notNull(), {
dateCreated: varchar("dateCreated").notNull(), newtId: varchar("id").primaryKey(),
version: varchar("version"), secretHash: varchar("secretHash").notNull(),
siteId: integer("siteId").references(() => sites.siteId, { dateCreated: varchar("dateCreated").notNull(),
onDelete: "cascade" version: varchar("version"),
}) siteId: integer("siteId").references(() => sites.siteId, {
}); onDelete: "cascade"
})
},
(t) => [index("idx_newt_siteid").on(t.siteId)]
);
export const twoFactorBackupCodes = pgTable("twoFactorBackupCodes", { export const twoFactorBackupCodes = pgTable("twoFactorBackupCodes", {
codeId: serial("id").primaryKey(), codeId: serial("id").primaryKey(),
@@ -576,29 +647,49 @@ export const userOrgRoles = pgTable(
(t) => [unique().on(t.userId, t.orgId, t.roleId)] (t) => [unique().on(t.userId, t.orgId, t.roleId)]
); );
export const roleActions = pgTable("roleActions", { export const roleActions = pgTable(
roleId: integer("roleId") "roleActions",
.notNull() {
.references(() => roles.roleId, { onDelete: "cascade" }), roleId: integer("roleId")
actionId: varchar("actionId") .notNull()
.notNull() .references(() => roles.roleId, { onDelete: "cascade" }),
.references(() => actions.actionId, { onDelete: "cascade" }), actionId: varchar("actionId")
orgId: varchar("orgId") .notNull()
.notNull() .references(() => actions.actionId, { onDelete: "cascade" }),
.references(() => orgs.orgId, { onDelete: "cascade" }) orgId: varchar("orgId")
}); .notNull()
.references(() => orgs.orgId, { onDelete: "cascade" })
},
(t) => [
index("idx_roleActions_roleId_orgId_actionId").on(
t.roleId,
t.orgId,
t.actionId
)
]
);
export const userActions = pgTable("userActions", { export const userActions = pgTable(
userId: varchar("userId") "userActions",
.notNull() {
.references(() => users.userId, { onDelete: "cascade" }), userId: varchar("userId")
actionId: varchar("actionId") .notNull()
.notNull() .references(() => users.userId, { onDelete: "cascade" }),
.references(() => actions.actionId, { onDelete: "cascade" }), actionId: varchar("actionId")
orgId: varchar("orgId") .notNull()
.notNull() .references(() => actions.actionId, { onDelete: "cascade" }),
.references(() => orgs.orgId, { onDelete: "cascade" }) orgId: varchar("orgId")
}); .notNull()
.references(() => orgs.orgId, { onDelete: "cascade" })
},
(t) => [
index("idx_userActions_userId_orgId_actionId").on(
t.userId,
t.orgId,
t.actionId
)
]
);
export const roleSites = pgTable("roleSites", { export const roleSites = pgTable("roleSites", {
roleId: integer("roleId") roleId: integer("roleId")
@@ -1004,40 +1095,44 @@ export const idpOrg = pgTable("idpOrg", {
orgMapping: varchar("orgMapping") orgMapping: varchar("orgMapping")
}); });
export const clients = pgTable("clients", { export const clients = pgTable(
clientId: serial("clientId").primaryKey(), "clients",
orgId: varchar("orgId") {
.references(() => orgs.orgId, { clientId: serial("clientId").primaryKey(),
orgId: varchar("orgId")
.references(() => orgs.orgId, {
onDelete: "cascade"
})
.notNull(),
exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, {
onDelete: "set null"
}),
userId: text("userId").references(() => users.userId, {
// optionally tied to a user and in this case delete when the user deletes
onDelete: "cascade" onDelete: "cascade"
}) }),
.notNull(), niceId: varchar("niceId").notNull(),
exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, { olmId: text("olmId"), // to lock it to a specific olm optionally
onDelete: "set null" name: varchar("name").notNull(),
}), pubKey: varchar("pubKey"),
userId: text("userId").references(() => users.userId, { subnet: varchar("subnet").notNull(),
// optionally tied to a user and in this case delete when the user deletes megabytesIn: real("bytesIn"),
onDelete: "cascade" megabytesOut: real("bytesOut"),
}), lastBandwidthUpdate: varchar("lastBandwidthUpdate"),
niceId: varchar("niceId").notNull(), lastPing: integer("lastPing"),
olmId: text("olmId"), // to lock it to a specific olm optionally type: varchar("type").notNull(), // "olm"
name: varchar("name").notNull(), online: boolean("online").notNull().default(false),
pubKey: varchar("pubKey"), // endpoint: varchar("endpoint"),
subnet: varchar("subnet").notNull(), lastHolePunch: integer("lastHolePunch"),
megabytesIn: real("bytesIn"), maxConnections: integer("maxConnections"),
megabytesOut: real("bytesOut"), archived: boolean("archived").notNull().default(false),
lastBandwidthUpdate: varchar("lastBandwidthUpdate"), blocked: boolean("blocked").notNull().default(false),
lastPing: integer("lastPing"), approvalState: varchar("approvalState").$type<
type: varchar("type").notNull(), // "olm" "pending" | "approved" | "denied"
online: boolean("online").notNull().default(false), >()
// endpoint: varchar("endpoint"), },
lastHolePunch: integer("lastHolePunch"), (t) => [index("idx_clients_userid").on(t.userId)]
maxConnections: integer("maxConnections"), );
archived: boolean("archived").notNull().default(false),
blocked: boolean("blocked").notNull().default(false),
approvalState: varchar("approvalState").$type<
"pending" | "approved" | "denied"
>()
});
export const clientSitesAssociationsCache = pgTable( export const clientSitesAssociationsCache = pgTable(
"clientSitesAssociationsCache", "clientSitesAssociationsCache",
@@ -1049,7 +1144,11 @@ export const clientSitesAssociationsCache = pgTable(
isJitMode: boolean("isJitMode").notNull().default(false), isJitMode: boolean("isJitMode").notNull().default(false),
endpoint: varchar("endpoint"), endpoint: varchar("endpoint"),
publicKey: varchar("publicKey") // this will act as the session's public key for hole punching so we can track when it changes publicKey: varchar("publicKey") // this will act as the session's public key for hole punching so we can track when it changes
} },
(t) => [
primaryKey({ columns: [t.clientId, t.siteId] }),
index("idx_clientsitesassociationscache_siteid").on(t.siteId)
]
); );
export const clientSiteResourcesAssociationsCache = pgTable( export const clientSiteResourcesAssociationsCache = pgTable(
@@ -1058,7 +1157,14 @@ export const clientSiteResourcesAssociationsCache = pgTable(
clientId: integer("clientId") // not a foreign key here so after its deleted the rebuild function can delete it and send the message clientId: integer("clientId") // not a foreign key here so after its deleted the rebuild function can delete it and send the message
.notNull(), .notNull(),
siteResourceId: integer("siteResourceId").notNull() siteResourceId: integer("siteResourceId").notNull()
} },
(t) => [
primaryKey({ columns: [t.clientId, t.siteResourceId] }),
index("idx_clientSiteResourcesAssociationsCache_siteResourceId").on(
t.siteResourceId,
t.clientId
)
]
); );
export const clientPostureSnapshots = pgTable("clientPostureSnapshots", { export const clientPostureSnapshots = pgTable("clientPostureSnapshots", {
@@ -1071,23 +1177,27 @@ export const clientPostureSnapshots = pgTable("clientPostureSnapshots", {
collectedAt: integer("collectedAt").notNull() collectedAt: integer("collectedAt").notNull()
}); });
export const olms = pgTable("olms", { export const olms = pgTable(
olmId: varchar("id").primaryKey(), "olms",
secretHash: varchar("secretHash").notNull(), {
dateCreated: varchar("dateCreated").notNull(), olmId: varchar("id").primaryKey(),
version: text("version"), secretHash: varchar("secretHash").notNull(),
agent: text("agent"), dateCreated: varchar("dateCreated").notNull(),
name: varchar("name"), version: text("version"),
clientId: integer("clientId").references(() => clients.clientId, { agent: text("agent"),
// we will switch this depending on the current org it wants to connect to name: varchar("name"),
onDelete: "set null" clientId: integer("clientId").references(() => clients.clientId, {
}), // we will switch this depending on the current org it wants to connect to
userId: text("userId").references(() => users.userId, { onDelete: "set null"
// optionally tied to a user and in this case delete when the user deletes }),
onDelete: "cascade" userId: text("userId").references(() => users.userId, {
}), // optionally tied to a user and in this case delete when the user deletes
archived: boolean("archived").notNull().default(false) onDelete: "cascade"
}); }),
archived: boolean("archived").notNull().default(false)
},
(t) => [index("idx_olms_clientid").on(t.clientId)]
);
export const currentFingerprint = pgTable("currentFingerprint", { export const currentFingerprint = pgTable("currentFingerprint", {
fingerprintId: serial("id").primaryKey(), fingerprintId: serial("id").primaryKey(),

View File

@@ -1,6 +1,5 @@
import { drizzle as DrizzleSqlite } from "drizzle-orm/better-sqlite3"; import { drizzle as DrizzleSqlite } from "drizzle-orm/better-sqlite3";
import Database from "better-sqlite3"; import Database from "better-sqlite3";
import type BetterSqlite3 from "better-sqlite3";
import * as schema from "./schema/schema"; import * as schema from "./schema/schema";
import path from "path"; import path from "path";
import fs from "fs"; import fs from "fs";
@@ -12,68 +11,31 @@ export const exists = checkFileExists(location);
bootstrapVolume(); bootstrapVolume();
/**
* Wraps better-sqlite3 Statement to call `finalize()` immediately after
* execution, freeing native sqlite3_stmt memory deterministically instead
* of waiting for GC. Fixes steady off-heap growth under load (#2120).
* WARNING: Finalizes after first execution — incompatible with drizzle's
* reusable .prepare() builders. No such usage exists in this codebase.
*/
function autoFinalizeStatement(
stmt: BetterSqlite3.Statement
): BetterSqlite3.Statement {
const wrapExec = <T extends (...args: any[]) => any>(fn: T): T => {
return function (this: any, ...args: any[]) {
try {
return fn.apply(this, args);
} finally {
try {
// finalize() exists on the native Statement at runtime but
// is missing from @types/better-sqlite3.
(stmt as any).finalize();
} catch {
// Already finalized — harmless
}
}
} as unknown as T;
};
stmt.run = wrapExec(stmt.run);
stmt.get = wrapExec(stmt.get);
stmt.all = wrapExec(stmt.all);
return stmt;
}
function createDb() { function createDb() {
const sqlite = new Database(location); const sqlite = new Database(location);
if (process.env.ENABLE_SQLITE_WAL_MODE == "true") { if (process.env.ENABLE_SQLITE_WAL_MODE == "true") {
// Enable WAL mode — allows concurrent readers + single writer, preventing // Enable WAL mode — allows concurrent readers + single writer, preventing
// contention across subsystems (verifySession, Traefik, audit, ping). // contention across subsystems (verifySession, Traefik, audit, ping).
// NOTE: journal_mode persists in the DB file once set; unsetting this
// env var does NOT revert an existing WAL database.
sqlite.pragma("journal_mode = WAL"); sqlite.pragma("journal_mode = WAL");
// NORMAL sync mode: safe with WAL, reduces write lock hold time. // NORMAL sync mode: safe with WAL, reduces write lock hold time.
sqlite.pragma("synchronous = NORMAL"); sqlite.pragma("synchronous = NORMAL");
} }
// Wait up to 5s on SQLITE_BUSY instead of failing — prevents audit log // No busy_timeout pragma: better-sqlite3 already arms
// retry loops that accumulate memory. // sqlite3_busy_timeout(db, 5000) via its default `timeout` option
sqlite.pragma("busy_timeout = 5000"); // (lib/database.js), so an explicit pragma is redundant.
// 64 MB page cache (default 2 MB) — reduces I/O round-trips on large // Intentionally NOT setting cache_size or mmap_size: a large page cache plus
// TraefikConfigManager JOINs that block the event loop. // a multi-hundred-MB mmap region inflate RSS and cause page-cache thrashing
sqlite.pragma("cache_size = -65536"); // on small (~1 GB) instances. Leave SQLite on its conservative defaults.
// 256 MB memory-mapped I/O — OS serves reads from page cache directly, // Intentionally NOT wrapping prepare()/statements: better-sqlite3 finalizes
// reducing event-loop blocking. // sqlite3_stmt in the Statement destructor at GC, and drizzle-orm prepares a
sqlite.pragma("mmap_size = 268435456"); // fresh statement per query (no statement cache), so statements cannot
// accumulate. better-sqlite3 11.x exposes no Statement.finalize() at all.
// Wrap prepare() so every drizzle-orm statement is auto-finalized after
// first use, preventing sqlite3_stmt accumulation between GC cycles.
const originalPrepare = sqlite.prepare.bind(sqlite);
(sqlite as any).prepare = function autoFinalizePrepare(source: string) {
return autoFinalizeStatement(originalPrepare(source));
};
return DrizzleSqlite(sqlite, { return DrizzleSqlite(sqlite, {
schema schema

View File

@@ -24,6 +24,7 @@ import license from "#dynamic/license/license";
import { initLogCleanupInterval } from "@server/lib/cleanupLogs"; import { initLogCleanupInterval } from "@server/lib/cleanupLogs";
import { initAcmeCertSync } from "#dynamic/lib/acmeCertSync"; import { initAcmeCertSync } from "#dynamic/lib/acmeCertSync";
import { fetchServerIp } from "@server/lib/serverIpService"; import { fetchServerIp } from "@server/lib/serverIpService";
import { startRebuildQueueProcessor } from "@server/lib/rebuildClientAssociations";
async function startServers() { async function startServers() {
await setHostMeta(); await setHostMeta();
@@ -41,6 +42,7 @@ async function startServers() {
initLogCleanupInterval(); initLogCleanupInterval();
initAcmeCertSync(); initAcmeCertSync();
startRebuildQueueProcessor();
// Start all servers // Start all servers
const apiServer = createApiServer(); const apiServer = createApiServer();

View File

@@ -12,7 +12,7 @@ import {
import { FeatureId, getFeatureMeterId } from "./features"; import { FeatureId, getFeatureMeterId } from "./features";
import logger from "@server/logger"; import logger from "@server/logger";
import { build } from "@server/build"; import { build } from "@server/build";
import cache from "#dynamic/lib/cache"; import { regionalCache as cache } from "#dynamic/lib/cache";
export function noop() { export function noop() {
if (build !== "saas") { if (build !== "saas") {
@@ -22,7 +22,6 @@ export function noop() {
} }
export class UsageService { export class UsageService {
constructor() { constructor() {
if (noop()) { if (noop()) {
return; return;
@@ -57,7 +56,10 @@ export class UsageService {
try { try {
let usage; let usage;
if (transaction) { if (transaction) {
const orgIdToUse = await this.getBillingOrg(orgId, transaction); const orgIdToUse = await this.getBillingOrg(
orgId,
transaction
);
usage = await this.internalAddUsage( usage = await this.internalAddUsage(
orgIdToUse, orgIdToUse,
featureId, featureId,

View File

@@ -48,18 +48,18 @@ export async function applyBlueprint({
name, name,
source = "API" source = "API"
}: ApplyBlueprintArgs): Promise<Blueprint> { }: ApplyBlueprintArgs): Promise<Blueprint> {
// Validate the input data
const validationResult = ConfigSchema.safeParse(configData);
if (!validationResult.success) {
throw new Error(fromError(validationResult.error).toString());
}
const config: Config = validationResult.data;
let blueprintSucceeded: boolean = false; let blueprintSucceeded: boolean = false;
let blueprintMessage: string; let blueprintMessage = "";
let error: any | null = null; let error: any | null = null;
try { try {
const validationResult = ConfigSchema.safeParse(configData);
if (!validationResult.success) {
throw new Error(fromError(validationResult.error).toString());
}
const config: Config = validationResult.data;
let proxyResourcesResults: PublicResourcesResults = []; let proxyResourcesResults: PublicResourcesResults = [];
let clientResourcesResults: ClientResourcesResults = []; let clientResourcesResults: ClientResourcesResults = [];
await db.transaction(async (trx) => { await db.transaction(async (trx) => {

View File

@@ -8,6 +8,7 @@ import {
exitNodes, exitNodes,
newts, newts,
olms, olms,
primaryDb,
roleSiteResources, roleSiteResources,
Site, Site,
SiteResource, SiteResource,
@@ -20,10 +21,10 @@ import {
} from "@server/db"; } from "@server/db";
import { and, count, eq, inArray, ne } from "drizzle-orm"; import { and, count, eq, inArray, ne } from "drizzle-orm";
import { deletePeer as newtDeletePeer } from "@server/routers/newt/peers"; import { deletePeersBatch as newtDeletePeersBatch } from "@server/routers/newt/peers";
import { import {
initPeerAddHandshake, initPeerAddHandshakeBatch,
deletePeer as olmDeletePeer deletePeersBatch as olmDeletePeersBatch
} from "@server/routers/olm/peers"; } from "@server/routers/olm/peers";
import { sendToExitNode } from "#dynamic/lib/exitNodes"; import { sendToExitNode } from "#dynamic/lib/exitNodes";
import logger from "@server/logger"; import logger from "@server/logger";
@@ -34,12 +35,13 @@ import {
parseEndpoint parseEndpoint
} from "@server/lib/ip"; } from "@server/lib/ip";
import { import {
addPeerData, addPeerDataBatch,
addTargets as addSubnetProxyTargets, addTargetsBatch as addSubnetProxyTargetsBatch,
removePeerData, removePeerDataBatch,
removeTargets as removeSubnetProxyTargets removeTargetsBatch as removeSubnetProxyTargetsBatch
} from "@server/routers/client/targets"; } from "@server/routers/client/targets";
import { lockManager } from "#dynamic/lib/lock"; import { lockManager } from "#dynamic/lib/lock";
import { rebuildQueue } from "#dynamic/lib/rebuildQueue";
// TTL for rebuild-association locks. These functions can fan out into many // TTL for rebuild-association locks. These functions can fan out into many
// peer/proxy updates, so give them a generous window. // peer/proxy updates, so give them a generous window.
@@ -160,18 +162,33 @@ export async function getClientSiteResourceAccess(
export async function rebuildClientAssociationsFromSiteResource( export async function rebuildClientAssociationsFromSiteResource(
siteResource: SiteResource, siteResource: SiteResource,
trx: Transaction | typeof db = db trx: Transaction | typeof db = db
): Promise<{ ) {
mergedAllClients: { try {
clientId: number; return await lockManager.withLock(
pubKey: string | null; `rebuild-client-associations:site-resource:${siteResource.siteResourceId}`,
subnet: string | null; () =>
}[]; rebuildClientAssociationsFromSiteResourceImpl(
}> { siteResource,
return await lockManager.withLock( trx
`rebuild-client-associations:site-resource:${siteResource.siteResourceId}`, ),
() => rebuildClientAssociationsFromSiteResourceImpl(siteResource, trx), REBUILD_ASSOCIATIONS_LOCK_TTL_MS
REBUILD_ASSOCIATIONS_LOCK_TTL_MS );
); } catch (err: any) {
if (
typeof err?.message === "string" &&
err.message.startsWith("Failed to acquire lock")
) {
logger.warn(
`rebuildClientAssociations: could not acquire lock for site resource ${siteResource.siteResourceId}, queuing for deferred processing`
);
await rebuildQueue.enqueue({
type: "site-resource",
id: siteResource.siteResourceId
});
return { mergedAllClients: [] };
}
throw err;
}
} }
async function rebuildClientAssociationsFromSiteResourceImpl( async function rebuildClientAssociationsFromSiteResourceImpl(
@@ -536,6 +553,28 @@ async function handleMessagesForSiteClients(
const newtJobs: Promise<any>[] = []; const newtJobs: Promise<any>[] = [];
const olmJobs: Promise<any>[] = []; const olmJobs: Promise<any>[] = [];
const exitNodeJobs: Promise<any>[] = []; const exitNodeJobs: Promise<any>[] = [];
const newtPeerDeletes: {
siteId: number;
publicKey: string;
newtId: string;
}[] = [];
const olmPeerDeletes: {
clientId: number;
siteId: number;
publicKey: string;
olmId: string;
}[] = [];
const olmPeerAddHandshakes: {
clientId: number;
peer: {
siteId: number;
exitNode: {
publicKey: string;
endpoint: string;
};
};
olmId: string;
}[] = [];
// Combine all clients that need processing (those being added or removed) // Combine all clients that need processing (those being added or removed)
const clientsToProcess = new Map< const clientsToProcess = new Map<
@@ -584,6 +623,21 @@ async function handleMessagesForSiteClients(
} }
} }
// Batch-fetch all olm IDs for the clients we need to process
const clientIdsToProcess = Array.from(clientsToProcess.keys());
const olmRows =
clientIdsToProcess.length > 0
? await trx
.select({ olmId: olms.olmId, clientId: olms.clientId })
.from(olms)
.where(inArray(olms.clientId, clientIdsToProcess))
: [];
const olmByClientId = new Map<number, string>(
olmRows
.filter((r) => r.clientId !== null)
.map((r) => [r.clientId as number, r.olmId])
);
for (const client of clientsToProcess.values()) { for (const client of clientsToProcess.values()) {
// UPDATE THE NEWT // UPDATE THE NEWT
if (!client.subnet || !client.pubKey) { if (!client.subnet || !client.pubKey) {
@@ -600,14 +654,8 @@ async function handleMessagesForSiteClients(
continue; continue;
} }
const [olm] = await trx const olmId = olmByClientId.get(client.clientId);
.select({ if (!olmId) {
olmId: olms.olmId
})
.from(olms)
.where(eq(olms.clientId, client.clientId))
.limit(1);
if (!olm) {
logger.warn( logger.warn(
`Olm not found for client ${client.clientId} so cannot add/delete peers` `Olm not found for client ${client.clientId} so cannot add/delete peers`
); );
@@ -615,15 +663,17 @@ async function handleMessagesForSiteClients(
} }
if (isDelete) { if (isDelete) {
newtJobs.push(newtDeletePeer(siteId, client.pubKey, newt.newtId)); newtPeerDeletes.push({
olmJobs.push( siteId,
olmDeletePeer( publicKey: client.pubKey,
client.clientId, newtId: newt.newtId
siteId, });
site.publicKey, olmPeerDeletes.push({
olm.olmId clientId: client.clientId,
) siteId,
); publicKey: site.publicKey,
olmId
});
} }
if (isAdd) { if (isAdd) {
@@ -635,23 +685,34 @@ async function handleMessagesForSiteClients(
continue; continue;
} }
await initPeerAddHandshake( olmPeerAddHandshakes.push({
// this will kick off the add peer process for the client clientId: client.clientId,
client.clientId, peer: {
{
siteId, siteId,
exitNode: { exitNode: {
publicKey: exitNode.publicKey, publicKey: exitNode.publicKey,
endpoint: exitNode.endpoint endpoint: exitNode.endpoint
} }
}, },
olm.olmId olmId
); });
} }
exitNodeJobs.push(updateClientSiteDestinations(client, trx)); exitNodeJobs.push(updateClientSiteDestinations(client, trx));
} }
if (newtPeerDeletes.length > 0) {
newtJobs.push(newtDeletePeersBatch(newtPeerDeletes));
}
if (olmPeerDeletes.length > 0) {
olmJobs.push(olmDeletePeersBatch(olmPeerDeletes));
}
if (olmPeerAddHandshakes.length > 0) {
olmJobs.push(initPeerAddHandshakeBatch(olmPeerAddHandshakes));
}
Promise.all(exitNodeJobs).catch((error) => { Promise.all(exitNodeJobs).catch((error) => {
logger.error( logger.error(
`rebuildClientAssociations: Error updating client site destinations for site ${site.siteId}:`, `rebuildClientAssociations: Error updating client site destinations for site ${site.siteId}:`,
@@ -812,6 +873,20 @@ async function handleSubnetProxyTargetUpdates(
): Promise<void> { ): Promise<void> {
const proxyJobs: Promise<any>[] = []; const proxyJobs: Promise<any>[] = [];
const olmJobs: Promise<any>[] = []; const olmJobs: Promise<any>[] = [];
const targetsToAddBatch: {
newtId: string;
targets: NonNullable<
Awaited<ReturnType<typeof generateSubnetProxyTargetV2>>
>;
version: string | null;
}[] = [];
const targetsToRemoveBatch: {
newtId: string;
targets: NonNullable<
Awaited<ReturnType<typeof generateSubnetProxyTargetV2>>
>;
version: string | null;
}[] = [];
for (const siteData of sitesList) { for (const siteData of sitesList) {
const siteId = siteData.siteId; const siteId = siteData.siteId;
@@ -843,25 +918,25 @@ async function handleSubnetProxyTargetUpdates(
); );
if (targetsToAdd) { if (targetsToAdd) {
proxyJobs.push( targetsToAddBatch.push({
addSubnetProxyTargets( newtId: newt.newtId,
newt.newtId, targets: targetsToAdd,
targetsToAdd, version: newt.version
newt.version });
)
);
} }
for (const client of addedClients) { olmJobs.push(
olmJobs.push( addPeerDataBatch(
addPeerData( addedClients.map((client) => ({
client.clientId, clientId: client.clientId,
siteId, siteId,
generateRemoteSubnets([siteResource]), remoteSubnets: generateRemoteSubnets([
generateAliasConfig([siteResource]) siteResource
) ]),
); aliases: generateAliasConfig([siteResource])
} }))
)
);
} }
} }
@@ -880,15 +955,20 @@ async function handleSubnetProxyTargetUpdates(
); );
if (targetsToRemove) { if (targetsToRemove) {
proxyJobs.push( targetsToRemoveBatch.push({
removeSubnetProxyTargets( newtId: newt.newtId,
newt.newtId, targets: targetsToRemove,
targetsToRemove, version: newt.version
newt.version });
)
);
} }
const peerDataRemovals: {
clientId: number;
siteId: number;
remoteSubnets: string[];
aliases: ReturnType<typeof generateAliasConfig>;
}[] = [];
for (const client of removedClients) { for (const client of removedClients) {
if (!siteResource.destination) { if (!siteResource.destination) {
continue; continue;
@@ -936,31 +1016,58 @@ async function handleSubnetProxyTargetUpdates(
? [] ? []
: generateRemoteSubnets([siteResource]); : generateRemoteSubnets([siteResource]);
olmJobs.push( peerDataRemovals.push({
removePeerData( clientId: client.clientId,
client.clientId, siteId,
siteId, remoteSubnets: remoteSubnetsToRemove,
remoteSubnetsToRemove, aliases: generateAliasConfig([siteResource])
generateAliasConfig([siteResource]) });
) }
);
if (peerDataRemovals.length > 0) {
olmJobs.push(removePeerDataBatch(peerDataRemovals));
} }
} }
} }
} }
await Promise.all(proxyJobs); if (targetsToAddBatch.length > 0) {
proxyJobs.push(addSubnetProxyTargetsBatch(targetsToAddBatch));
}
if (targetsToRemoveBatch.length > 0) {
proxyJobs.push(removeSubnetProxyTargetsBatch(targetsToRemoveBatch));
}
await Promise.all([...proxyJobs, ...olmJobs]);
} }
export async function rebuildClientAssociationsFromClient( export async function rebuildClientAssociationsFromClient(
client: Client, client: Client,
trx: Transaction | typeof db = db trx: Transaction | typeof db = db
): Promise<void> { ): Promise<void> {
return await lockManager.withLock( try {
`rebuild-client-associations:client:${client.clientId}`, return await lockManager.withLock(
() => rebuildClientAssociationsFromClientImpl(client, trx), `rebuild-client-associations:client:${client.clientId}`,
REBUILD_ASSOCIATIONS_LOCK_TTL_MS () => rebuildClientAssociationsFromClientImpl(client, trx),
); REBUILD_ASSOCIATIONS_LOCK_TTL_MS
);
} catch (err: any) {
if (
typeof err?.message === "string" &&
err.message.startsWith("Failed to acquire lock")
) {
logger.warn(
`rebuildClientAssociations: could not acquire lock for client ${client.clientId}, queuing for deferred processing`
);
await rebuildQueue.enqueue({
type: "client",
id: client.clientId
});
return;
}
throw err;
}
} }
async function rebuildClientAssociationsFromClientImpl( async function rebuildClientAssociationsFromClientImpl(
@@ -1237,6 +1344,28 @@ async function handleMessagesForClientSites(
const newtJobs: Promise<any>[] = []; const newtJobs: Promise<any>[] = [];
const olmJobs: Promise<any>[] = []; const olmJobs: Promise<any>[] = [];
const exitNodeJobs: Promise<any>[] = []; const exitNodeJobs: Promise<any>[] = [];
const newtPeerDeletes: {
siteId: number;
publicKey: string;
newtId: string;
}[] = [];
const olmPeerDeletes: {
clientId: number;
siteId: number;
publicKey: string;
olmId: string;
}[] = [];
const olmPeerAddHandshakes: {
clientId: number;
peer: {
siteId: number;
exitNode: {
publicKey: string;
endpoint: string;
};
};
olmId: string;
}[] = [];
const totalSitesOnClient = await trx const totalSitesOnClient = await trx
.select({ count: count(clientSitesAssociationsCache.siteId) }) .select({ count: count(clientSitesAssociationsCache.siteId) })
@@ -1268,19 +1397,19 @@ async function handleMessagesForClientSites(
if (isRemove) { if (isRemove) {
// Remove peer from newt // Remove peer from newt
newtJobs.push( newtPeerDeletes.push({
newtDeletePeer(site.siteId, client.pubKey, newt.newtId) siteId: site.siteId,
); publicKey: client.pubKey,
newtId: newt.newtId
});
try { try {
// Remove peer from olm // Remove peer from olm
olmJobs.push( olmPeerDeletes.push({
olmDeletePeer( clientId: client.clientId,
client.clientId, siteId: site.siteId,
site.siteId, publicKey: site.publicKey,
site.publicKey, olmId
olmId });
)
);
} catch (error) { } catch (error) {
// if the error includes not found then its just because the olm does not exist anymore or yet and its fine if we dont send // if the error includes not found then its just because the olm does not exist anymore or yet and its fine if we dont send
if ( if (
@@ -1312,10 +1441,9 @@ async function handleMessagesForClientSites(
continue; continue;
} }
await initPeerAddHandshake( olmPeerAddHandshakes.push({
// this will kick off the add peer process for the client clientId: client.clientId,
client.clientId, peer: {
{
siteId: site.siteId, siteId: site.siteId,
exitNode: { exitNode: {
publicKey: exitNode.publicKey, publicKey: exitNode.publicKey,
@@ -1323,7 +1451,7 @@ async function handleMessagesForClientSites(
} }
}, },
olmId olmId
); });
} }
// Update exit node destinations // Update exit node destinations
@@ -1339,6 +1467,18 @@ async function handleMessagesForClientSites(
); );
} }
if (newtPeerDeletes.length > 0) {
newtJobs.push(newtDeletePeersBatch(newtPeerDeletes));
}
if (olmPeerDeletes.length > 0) {
olmJobs.push(olmDeletePeersBatch(olmPeerDeletes));
}
if (olmPeerAddHandshakes.length > 0) {
olmJobs.push(initPeerAddHandshakeBatch(olmPeerAddHandshakes));
}
Promise.all(exitNodeJobs).catch((error) => { Promise.all(exitNodeJobs).catch((error) => {
logger.error( logger.error(
`rebuildClientAssociations: Error updating client site destinations for client ${client.clientId}:`, `rebuildClientAssociations: Error updating client site destinations for client ${client.clientId}:`,
@@ -1437,6 +1577,20 @@ async function handleMessagesForClientResources(
continue; continue;
} }
const targetsToAddBatch: {
newtId: string;
targets: NonNullable<
Awaited<ReturnType<typeof generateSubnetProxyTargetV2>>
>;
version: string | null;
}[] = [];
const peerDataAdds: {
clientId: number;
siteId: number;
remoteSubnets: string[];
aliases: ReturnType<typeof generateAliasConfig>;
}[] = [];
for (const resource of resources) { for (const resource of resources) {
const targets = await generateSubnetProxyTargetV2(resource, [ const targets = await generateSubnetProxyTargetV2(resource, [
{ {
@@ -1447,25 +1601,21 @@ async function handleMessagesForClientResources(
]); ]);
if (targets) { if (targets) {
proxyJobs.push( targetsToAddBatch.push({
addSubnetProxyTargets( newtId: newt.newtId,
newt.newtId, targets,
targets, version: newt.version
newt.version });
)
);
} }
try { try {
// Add peer data to olm // Add peer data to olm
olmJobs.push( peerDataAdds.push({
addPeerData( clientId: client.clientId,
client.clientId, siteId,
siteId, remoteSubnets: generateRemoteSubnets([resource]),
generateRemoteSubnets([resource]), aliases: generateAliasConfig([resource])
generateAliasConfig([resource]) });
)
);
} catch (error) { } catch (error) {
// if the error includes not found then its just because the olm does not exist anymore or yet and its fine if we dont send // if the error includes not found then its just because the olm does not exist anymore or yet and its fine if we dont send
if ( if (
@@ -1480,6 +1630,14 @@ async function handleMessagesForClientResources(
} }
} }
} }
if (targetsToAddBatch.length > 0) {
proxyJobs.push(addSubnetProxyTargetsBatch(targetsToAddBatch));
}
if (peerDataAdds.length > 0) {
olmJobs.push(addPeerDataBatch(peerDataAdds));
}
} }
} }
@@ -1546,6 +1704,20 @@ async function handleMessagesForClientResources(
continue; continue;
} }
const targetsToRemoveBatch: {
newtId: string;
targets: NonNullable<
Awaited<ReturnType<typeof generateSubnetProxyTargetV2>>
>;
version: string | null;
}[] = [];
const peerDataRemovals: {
clientId: number;
siteId: number;
remoteSubnets: string[];
aliases: ReturnType<typeof generateAliasConfig>;
}[] = [];
for (const resource of resources) { for (const resource of resources) {
const targets = await generateSubnetProxyTargetV2(resource, [ const targets = await generateSubnetProxyTargetV2(resource, [
{ {
@@ -1556,13 +1728,11 @@ async function handleMessagesForClientResources(
]); ]);
if (targets) { if (targets) {
proxyJobs.push( targetsToRemoveBatch.push({
removeSubnetProxyTargets( newtId: newt.newtId,
newt.newtId, targets,
targets, version: newt.version
newt.version });
)
);
} }
try { try {
@@ -1613,14 +1783,12 @@ async function handleMessagesForClientResources(
: generateRemoteSubnets([resource]); : generateRemoteSubnets([resource]);
// Remove peer data from olm // Remove peer data from olm
olmJobs.push( peerDataRemovals.push({
removePeerData( clientId: client.clientId,
client.clientId, siteId,
siteId, remoteSubnets: remoteSubnetsToRemove,
remoteSubnetsToRemove, aliases: generateAliasConfig([resource])
generateAliasConfig([resource]) });
)
);
} catch (error) { } catch (error) {
// if the error includes not found then its just because the olm does not exist anymore or yet and its fine if we dont send // if the error includes not found then its just because the olm does not exist anymore or yet and its fine if we dont send
if ( if (
@@ -1635,6 +1803,16 @@ async function handleMessagesForClientResources(
} }
} }
} }
if (targetsToRemoveBatch.length > 0) {
proxyJobs.push(
removeSubnetProxyTargetsBatch(targetsToRemoveBatch)
);
}
if (peerDataRemovals.length > 0) {
olmJobs.push(removePeerDataBatch(peerDataRemovals));
}
} }
} }
@@ -1884,11 +2062,20 @@ export async function cleanupSiteAssociations(
// 7. Fire all removal messages in parallel. // 7. Fire all removal messages in parallel.
const jobs: Promise<any>[] = []; const jobs: Promise<any>[] = [];
const olmPeerDeletes: {
clientId: number;
siteId: number;
publicKey: string;
}[] = [];
for (const client of allClients) { for (const client of allClients) {
// Tell each olm to drop the site's WireGuard peer. // Tell each olm to drop the site's WireGuard peer.
if (site.publicKey) { if (site.publicKey) {
jobs.push(olmDeletePeer(client.clientId, siteId, site.publicKey)); olmPeerDeletes.push({
clientId: client.clientId,
siteId,
publicKey: site.publicKey
});
} }
// Recompute and push updated relay destinations (now excluding this site). // Recompute and push updated relay destinations (now excluding this site).
@@ -1897,6 +2084,10 @@ export async function cleanupSiteAssociations(
} }
} }
if (olmPeerDeletes.length > 0) {
jobs.push(olmDeletePeersBatch(olmPeerDeletes));
}
await Promise.all(jobs).catch((error) => { await Promise.all(jobs).catch((error) => {
logger.error( logger.error(
`cleanupSiteAssociations: error sending cleanup messages for siteId=${siteId}:`, `cleanupSiteAssociations: error sending cleanup messages for siteId=${siteId}:`,
@@ -1906,3 +2097,47 @@ export async function cleanupSiteAssociations(
logger.debug(`cleanupSiteAssociations: DONE siteId=${siteId}`); logger.debug(`cleanupSiteAssociations: DONE siteId=${siteId}`);
} }
/**
* Start the background rebuild queue processor. This should be called once
* during server startup. Only one server instance at a time will actively
* consume the queue (enforced via a distributed Redis lock); all other
* instances will poll and wait until the lock becomes available.
*/
export function startRebuildQueueProcessor(): void {
rebuildQueue.startProcessing({
onSiteResource: async (siteResourceId: number) => {
const [siteResource] = await primaryDb
.select()
.from(siteResources)
.where(eq(siteResources.siteResourceId, siteResourceId));
if (!siteResource) {
logger.warn(
`Rebuild queue: site resource ${siteResourceId} not found, skipping`
);
return;
}
await rebuildClientAssociationsFromSiteResource(
siteResource,
primaryDb
);
},
onClient: async (clientId: number) => {
const [client] = await primaryDb
.select()
.from(clients)
.where(eq(clients.clientId, clientId));
if (!client) {
logger.warn(
`Rebuild queue: client ${clientId} not found, skipping`
);
return;
}
await rebuildClientAssociationsFromClient(client, primaryDb);
}
});
}

View File

@@ -0,0 +1,23 @@
export type RebuildJobType = "site-resource" | "client";
export interface RebuildJob {
type: RebuildJobType;
id: number;
}
export interface RebuildJobHandlers {
onSiteResource(siteResourceId: number): Promise<void>;
onClient(clientId: number): Promise<void>;
}
export interface RebuildQueueManager {
enqueue(job: RebuildJob): Promise<void>;
startProcessing(handlers: RebuildJobHandlers): void;
}
class NoopRebuildQueue implements RebuildQueueManager {
async enqueue(_job: RebuildJob): Promise<void> {}
startProcessing(_handlers: RebuildJobHandlers): void {}
}
export const rebuildQueue: RebuildQueueManager = new NoopRebuildQueue();

View File

@@ -1,4 +1,7 @@
import { isValidUrlGlobPattern } from "./validators"; import {
getResourceRuleValueValidationError,
isValidUrlGlobPattern
} from "./validators";
import { assertEquals } from "@test/assert"; import { assertEquals } from "@test/assert";
function runTests() { function runTests() {
@@ -236,6 +239,43 @@ function runTests() {
"Path with isolated percent sign should be invalid" "Path with isolated percent sign should be invalid"
); );
// ASN validation tests
assertEquals(
getResourceRuleValueValidationError("ASN", "AS15169"),
null,
"Standard ASN should be valid"
);
assertEquals(
getResourceRuleValueValidationError("ASN", " As15169 "),
null,
"Standard ASN should be valid with mixed case and whitespace"
);
assertEquals(
getResourceRuleValueValidationError("ASN", "ALL"),
null,
"ALL ASN selector should be valid"
);
assertEquals(
getResourceRuleValueValidationError("ASN", " all "),
null,
"ALL ASN selector should be valid with mixed case and whitespace"
);
assertEquals(
getResourceRuleValueValidationError("ASN", "AS0"),
null,
"AS0 alias should be valid"
);
assertEquals(
getResourceRuleValueValidationError("ASN", " as0 "),
null,
"AS0 alias should be valid with mixed case and whitespace"
);
assertEquals(
getResourceRuleValueValidationError("ASN", "not-an-asn"),
"Invalid ASN provided",
"Invalid ASN should return an error"
);
console.log("All tests passed!"); console.log("All tests passed!");
} }

View File

@@ -100,7 +100,10 @@ export function getResourceRuleValueValidationError(
? null ? null
: "Invalid country code provided"; : "Invalid country code provided";
case "ASN": case "ASN":
return /^AS\d+$/i.test(value.trim()) const normalizedValue = value.trim().toUpperCase();
return /^AS\d+$/.test(normalizedValue) ||
normalizedValue === "ALL" ||
normalizedValue === "AS0"
? null ? null
: "Invalid ASN provided"; : "Invalid ASN provided";
default: default:

View File

@@ -17,7 +17,7 @@ import { certificates, db } from "@server/db";
import { and, eq, isNotNull, or, inArray, sql } from "drizzle-orm"; import { and, eq, isNotNull, or, inArray, sql } from "drizzle-orm";
import { decrypt } from "@server/lib/crypto"; import { decrypt } from "@server/lib/crypto";
import logger from "@server/logger"; import logger from "@server/logger";
import cache from "#private/lib/cache"; import { regionalCache as cache } from "#private/lib/cache";
import { build } from "@server/build"; import { build } from "@server/build";
// Define the return type for clarity and type safety // Define the return type for clarity and type safety

View File

@@ -0,0 +1,198 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { redis } from "#private/lib/redis";
import { lockManager } from "#dynamic/lib/lock";
import logger from "@server/logger";
export type RebuildJobType = "site-resource" | "client";
export interface RebuildJob {
type: RebuildJobType;
id: number;
}
export interface RebuildJobHandlers {
onSiteResource(siteResourceId: number): Promise<void>;
onClient(clientId: number): Promise<void>;
}
// Redis list holding pending rebuild jobs (RPUSH to enqueue, LPOP to dequeue — FIFO order).
const QUEUE_KEY = "rebuild-client-associations:queue";
const QUEUED_SET_KEY = "rebuild-client-associations:queued";
// Distributed lock that serialises queue consumption to a single server instance
// at a time. TTL is generous enough to cover a full batch of expensive rebuilds.
const PROCESSOR_LOCK_KEY = "rebuild-client-associations:processor";
// Each rebuild can take up to REBUILD_ASSOCIATIONS_LOCK_TTL_MS (120 s) per
// resource. Allow BATCH_SIZE resources per processor-lock acquisition, plus a
// small buffer.
const BATCH_SIZE = 5;
const PROCESSOR_LOCK_TTL_MS = 120000 * BATCH_SIZE + 30000; // ~630 s
const POLL_INTERVAL_MS = 500;
class RedisRebuildQueue {
private processingStarted = false;
async enqueue(job: RebuildJob): Promise<void> {
if (!redis || redis.status !== "ready") {
logger.warn(
`Rebuild queue: Redis not available — rebuild for ${job.type}:${job.id} will not be retried`
);
return;
}
try {
const dedupeKey = `${job.type}:${job.id}`;
const added = await redis.sadd(QUEUED_SET_KEY, dedupeKey);
if (added === 0) {
logger.debug(
`Rebuild queue: skipped duplicate queued job ${job.type}:${job.id}`
);
return;
}
await redis.rpush(QUEUE_KEY, JSON.stringify(job));
logger.debug(
`Rebuild queue: enqueued ${job.type}:${job.id} (queue position: tail)`
);
} catch (err) {
await redis
.srem(QUEUED_SET_KEY, `${job.type}:${job.id}`)
.catch((cleanupErr) =>
logger.warn(
`Rebuild queue: failed to cleanup dedupe key for ${job.type}:${job.id} after enqueue failure:`,
cleanupErr
)
);
logger.error(
`Rebuild queue: failed to enqueue ${job.type}:${job.id}:`,
err
);
}
}
startProcessing(handlers: RebuildJobHandlers): void {
if (this.processingStarted) return;
this.processingStarted = true;
this.processLoop(handlers).catch((err) => {
logger.error("Rebuild queue processor loop crashed:", err);
});
logger.info("Rebuild queue processor started");
}
private async processLoop(handlers: RebuildJobHandlers): Promise<void> {
while (true) {
try {
await this.tryProcessBatch(handlers);
} catch (err) {
logger.error(
"Rebuild queue: unhandled error in process loop:",
err
);
}
await new Promise((resolve) =>
setTimeout(resolve, POLL_INTERVAL_MS)
);
}
}
private async tryProcessBatch(handlers: RebuildJobHandlers): Promise<void> {
if (!redis || redis.status !== "ready") return;
// Peek before acquiring the processor lock to avoid unnecessary Redis
// round-trips and lock contention when the queue is idle.
const queueLength = await redis.llen(QUEUE_KEY).catch(() => 0);
if (queueLength === 0) return;
try {
await lockManager.withLock(
PROCESSOR_LOCK_KEY,
async () => {
for (let i = 0; i < BATCH_SIZE; i++) {
if (!redis || redis.status !== "ready") break;
const payload = await redis.lpop(QUEUE_KEY);
if (payload === null) break; // queue drained
let job: RebuildJob;
try {
job = JSON.parse(payload) as RebuildJob;
} catch {
logger.error(
`Rebuild queue: could not parse job payload, discarding: ${payload}`
);
continue;
}
// Remove from dedupe set once dequeued so the same job
// can be re-queued while this one is in progress.
await redis
.srem(QUEUED_SET_KEY, `${job.type}:${job.id}`)
.catch((cleanupErr) =>
logger.warn(
`Rebuild queue: failed to remove dedupe key for ${job.type}:${job.id} on dequeue:`,
cleanupErr
)
);
logger.debug(
`Rebuild queue: processing ${job.type}:${job.id}`
);
try {
if (job.type === "site-resource") {
await handlers.onSiteResource(job.id);
} else if (job.type === "client") {
await handlers.onClient(job.id);
} else {
logger.warn(
`Rebuild queue: unknown job type "${(job as any).type}", discarding`
);
}
logger.debug(
`Rebuild queue: completed ${job.type}:${job.id}`
);
} catch (err) {
logger.error(
`Rebuild queue: job ${job.type}:${job.id} threw an error:`,
err
);
}
}
},
PROCESSOR_LOCK_TTL_MS
);
} catch (err: any) {
if (
typeof err?.message === "string" &&
err.message.startsWith("Failed to acquire lock")
) {
// Another server instance currently holds the processor lock and
// is consuming the queue — nothing to do this cycle.
logger.debug(
"Rebuild queue: processor lock held by another instance, skipping this cycle"
);
} else {
throw err;
}
}
}
}
export const rebuildQueue: RedisRebuildQueue = new RedisRebuildQueue();

View File

@@ -22,7 +22,7 @@ import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { ListRemoteExitNodesResponse } from "@server/routers/remoteExitNode/types"; import { ListRemoteExitNodesResponse } from "@server/routers/remoteExitNode/types";
import cache from "#private/lib/cache"; import { regionalCache as cache } from "#private/lib/cache";
import semver from "semver"; import semver from "semver";
let stalePangolinNodeVersion: string | null = null; let stalePangolinNodeVersion: string | null = null;

View File

@@ -38,6 +38,7 @@ import { messageHandlers } from "@server/routers/ws/messageHandlers";
import { messageHandlers as privateMessageHandlers } from "#private/routers/ws/messageHandlers"; import { messageHandlers as privateMessageHandlers } from "#private/routers/ws/messageHandlers";
import { import {
AuthenticatedWebSocket, AuthenticatedWebSocket,
BatchSendMessage,
ClientType, ClientType,
WSMessage, WSMessage,
TokenPayload, TokenPayload,
@@ -187,6 +188,8 @@ const wss: WebSocketServer = new WebSocketServer({ noServer: true });
// Generate unique node ID for this instance // Generate unique node ID for this instance
const NODE_ID = uuidv4(); const NODE_ID = uuidv4();
const REDIS_CHANNEL = "websocket_messages"; const REDIS_CHANNEL = "websocket_messages";
const REDIS_DIRECT_BATCH_SIZE = 250;
const REDIS_DIRECT_FLUSH_INTERVAL_MS = 10;
// 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();
@@ -197,6 +200,15 @@ const clientConfigVersions: Map<string, number> = new Map();
// Recovery tracking // Recovery tracking
let isRedisRecoveryInProgress = false; let isRedisRecoveryInProgress = false;
interface RedisDirectBatchEntry {
targetClientId: string;
message: WSMessage;
resolve: () => void;
}
let pendingRedisDirectMessages: RedisDirectBatchEntry[] = [];
let redisDirectFlushTimer: NodeJS.Timeout | null = null;
// Helper to get map key // Helper to get map key
const getClientMapKey = (clientId: string) => clientId; const getClientMapKey = (clientId: string) => clientId;
@@ -207,6 +219,78 @@ const getNodeConnectionsKey = (nodeId: string, clientId: string) =>
const getConfigVersionKey = (clientId: string) => const getConfigVersionKey = (clientId: string) =>
`ws:configVersion:${clientId}`; `ws:configVersion:${clientId}`;
const clearRedisDirectFlushTimer = (): void => {
if (redisDirectFlushTimer) {
clearTimeout(redisDirectFlushTimer);
redisDirectFlushTimer = null;
}
};
const publishDirectBatch = async (
entries: RedisDirectBatchEntry[]
): Promise<void> => {
const redisMessage: RedisMessage = {
type: "direct-batch",
messages: entries.map((entry) => ({
targetClientId: entry.targetClientId,
message: entry.message
})),
fromNodeId: NODE_ID
};
await redisManager.publish(REDIS_CHANNEL, JSON.stringify(redisMessage));
};
const flushPendingRedisDirectMessages = async (): Promise<void> => {
clearRedisDirectFlushTimer();
if (pendingRedisDirectMessages.length === 0) {
return;
}
const entries = pendingRedisDirectMessages;
pendingRedisDirectMessages = [];
if (!redisManager.isRedisEnabled()) {
entries.forEach((entry) => entry.resolve());
return;
}
for (let i = 0; i < entries.length; i += REDIS_DIRECT_BATCH_SIZE) {
const batch = entries.slice(i, i + REDIS_DIRECT_BATCH_SIZE);
try {
await publishDirectBatch(batch);
} catch (error) {
logger.error(
"Failed to send batched direct messages via Redis, messages may be lost:",
error
);
} finally {
batch.forEach((entry) => entry.resolve());
}
}
};
const enqueueRedisDirectMessage = async (
targetClientId: string,
message: WSMessage
): Promise<void> => {
await new Promise<void>((resolve) => {
pendingRedisDirectMessages.push({ targetClientId, message, resolve });
if (pendingRedisDirectMessages.length >= REDIS_DIRECT_BATCH_SIZE) {
void flushPendingRedisDirectMessages();
return;
}
if (!redisDirectFlushTimer) {
redisDirectFlushTimer = setTimeout(() => {
void flushPendingRedisDirectMessages();
}, REDIS_DIRECT_FLUSH_INTERVAL_MS);
}
});
};
// Initialize Redis subscription for cross-node messaging // Initialize Redis subscription for cross-node messaging
const initializeRedisSubscription = async (): Promise<void> => { const initializeRedisSubscription = async (): Promise<void> => {
if (!redisManager.isRedisEnabled()) return; if (!redisManager.isRedisEnabled()) return;
@@ -227,7 +311,16 @@ const initializeRedisSubscription = async (): Promise<void> => {
// Send to specific client on this node // Send to specific client on this node
await sendToClientLocal( await sendToClientLocal(
redisMessage.targetClientId, redisMessage.targetClientId,
redisMessage.message redisMessage.message,
{},
redisMessage.message.configVersion
);
} else if (
redisMessage.type === "direct-batch" &&
redisMessage.messages
) {
await sendRedisDirectBatchToLocalClients(
redisMessage.messages
); );
} else if (redisMessage.type === "broadcast") { } else if (redisMessage.type === "broadcast") {
// Broadcast to all clients on this node except excluded // Broadcast to all clients on this node except excluded
@@ -503,7 +596,8 @@ const incrementClientConfigVersion = async (
const sendToClientLocal = async ( const sendToClientLocal = async (
clientId: string, clientId: string,
message: WSMessage, message: WSMessage,
options: SendMessageOptions = {} options: SendMessageOptions = {},
preResolvedConfigVersion?: number
): Promise<boolean> => { ): Promise<boolean> => {
const mapKey = getClientMapKey(clientId); const mapKey = getClientMapKey(clientId);
const clients = connectedClients.get(mapKey); const clients = connectedClients.get(mapKey);
@@ -512,7 +606,8 @@ const sendToClientLocal = async (
} }
// Handle config version // Handle config version
const configVersion = await getClientConfigVersion(clientId); const configVersion =
preResolvedConfigVersion ?? (await getClientConfigVersion(clientId));
// Add config version to message // Add config version to message
const messageWithVersion = { const messageWithVersion = {
@@ -545,43 +640,71 @@ const sendToClientLocal = async (
return true; return true;
}; };
const sendRedisDirectBatchToLocalClients = async (
entries: { targetClientId: string; message: WSMessage }[]
): Promise<void> => {
const jobs = entries.map((entry) =>
sendToClientLocal(
entry.targetClientId,
entry.message,
{},
entry.message.configVersion
)
);
await Promise.all(jobs);
};
const broadcastToAllExceptLocal = async ( const broadcastToAllExceptLocal = async (
message: WSMessage, message: WSMessage,
excludeClientId?: string, excludeClientId?: string,
options: SendMessageOptions = {} options: SendMessageOptions = {}
): Promise<void> => { ): Promise<void> => {
for (const [mapKey, clients] of connectedClients.entries()) { const sendPlans = await Promise.all(
const [type, id] = mapKey.split(":"); Array.from(connectedClients.entries()).map(
const clientId = mapKey; // mapKey is the clientId async ([mapKey, clients]) => {
if (!(excludeClientId && clientId === excludeClientId)) { const clientId = mapKey; // mapKey is the clientId
// Handle config version per client if (excludeClientId && clientId === excludeClientId) {
let configVersion = await getClientConfigVersion(clientId); return null;
if (options.incrementConfigVersion) { }
configVersion = await incrementClientConfigVersion(clientId);
}
// Add config version to message let configVersion = await getClientConfigVersion(clientId);
const messageWithVersion = { if (options.incrementConfigVersion) {
...message, configVersion =
configVersion await incrementClientConfigVersion(clientId);
}; }
if (options.compress) { return {
const compressed = zlib.gzipSync( clients,
Buffer.from(JSON.stringify(messageWithVersion), "utf8") messageWithVersion: {
); ...message,
clients.forEach((client) => { configVersion
if (client.readyState === WebSocket.OPEN) {
client.send(compressed);
} }
}); };
} else {
clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify(messageWithVersion));
}
});
} }
)
);
for (const plan of sendPlans) {
if (!plan) {
continue;
}
if (options.compress) {
const compressed = zlib.gzipSync(
Buffer.from(JSON.stringify(plan.messageWithVersion), "utf8")
);
plan.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(compressed);
}
});
} else {
const messageString = JSON.stringify(plan.messageWithVersion);
plan.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(messageString);
}
});
} }
} }
}; };
@@ -602,28 +725,23 @@ const sendToClient = async (
); );
// Try to send locally first // Try to send locally first
const localSent = await sendToClientLocal(clientId, message, options); const localSent = await sendToClientLocal(
clientId,
message,
options,
configVersion
);
// 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 {
const redisMessage: RedisMessage = { await enqueueRedisDirectMessage(clientId, {
type: "direct", ...message,
targetClientId: clientId, configVersion
message: { });
...message,
configVersion
},
fromNodeId: NODE_ID
};
await redisManager.publish(
REDIS_CHANNEL,
JSON.stringify(redisMessage)
);
} catch (error) { } catch (error) {
logger.error( logger.error(
"Failed to send message via Redis, message may be lost:", "Failed to queue batched direct message for Redis delivery, message may be lost:",
error error
); );
// Continue execution - local delivery already attempted // Continue execution - local delivery already attempted
@@ -638,6 +756,95 @@ const sendToClient = async (
return localSent; return localSent;
}; };
const sendToClientsBatch = async (
entries: BatchSendMessage[]
): Promise<void> => {
if (entries.length === 0) {
return;
}
const remoteEntries: { targetClientId: string; message: WSMessage }[] = [];
const clientsWithIncrement = new Set(
entries
.filter((entry) => !!entry.options?.incrementConfigVersion)
.map((entry) => entry.clientId)
);
const nonIncrementOnlyClientIds = Array.from(
new Set(
entries
.map((entry) => entry.clientId)
.filter((clientId) => !clientsWithIncrement.has(clientId))
)
);
const stableConfigVersionByClient = new Map<string, number | undefined>(
await Promise.all(
nonIncrementOnlyClientIds.map(
async (clientId) =>
[clientId, await getClientConfigVersion(clientId)] as const
)
)
);
for (const entry of entries) {
const options = entry.options || {};
const { clientId, message } = entry;
const configVersion = options.incrementConfigVersion
? await incrementClientConfigVersion(clientId)
: stableConfigVersionByClient.get(clientId);
logger.debug(
`sendToClientsBatch: Message type ${message.type} queued for clientId ${clientId} (new configVersion: ${configVersion})`
);
const localSent = await sendToClientLocal(
clientId,
message,
options,
configVersion
);
if (!localSent && redisManager.isRedisEnabled()) {
remoteEntries.push({
targetClientId: clientId,
message: {
...message,
configVersion
}
});
} else if (!localSent && !redisManager.isRedisEnabled()) {
logger.debug(
`Could not deliver batch message to ${clientId} - not connected locally and Redis unavailable`
);
}
}
if (!redisManager.isRedisEnabled() || remoteEntries.length === 0) {
return;
}
for (let i = 0; i < remoteEntries.length; i += REDIS_DIRECT_BATCH_SIZE) {
const messages = remoteEntries.slice(i, i + REDIS_DIRECT_BATCH_SIZE);
try {
const redisMessage: RedisMessage = {
type: "direct-batch",
messages,
fromNodeId: NODE_ID
};
await redisManager.publish(
REDIS_CHANNEL,
JSON.stringify(redisMessage)
);
} catch (error) {
logger.error(
"Failed to send explicit direct batch via Redis, messages may be lost:",
error
);
}
}
};
const broadcastToAllExcept = async ( const broadcastToAllExcept = async (
message: WSMessage, message: WSMessage,
excludeClientId?: string, excludeClientId?: string,
@@ -1109,6 +1316,8 @@ const disconnectClient = async (clientId: string): Promise<boolean> => {
// Cleanup function for graceful shutdown // Cleanup function for graceful shutdown
const cleanup = async (): Promise<void> => { const cleanup = async (): Promise<void> => {
try { try {
await flushPendingRedisDirectMessages();
// Close all WebSocket connections // Close all WebSocket connections
connectedClients.forEach((clients) => { connectedClients.forEach((clients) => {
clients.forEach((client) => { clients.forEach((client) => {
@@ -1139,6 +1348,7 @@ export {
router, router,
handleWSUpgrade, handleWSUpgrade,
sendToClient, sendToClient,
sendToClientsBatch,
broadcastToAllExcept, broadcastToAllExcept,
connectedClients, connectedClients,
hasActiveConnections, hasActiveConnections,

View File

@@ -1,4 +1,4 @@
import { sendToClient } from "#dynamic/routers/ws"; import { sendToClient, sendToClientsBatch } from "#dynamic/routers/ws";
import { db, newts, olms } from "@server/db"; import { db, newts, olms } from "@server/db";
import { import {
Alias, Alias,
@@ -8,7 +8,7 @@ import {
} from "@server/lib/ip"; } from "@server/lib/ip";
import { canCompress } from "@server/lib/clientVersionChecks"; import { canCompress } from "@server/lib/clientVersionChecks";
import logger from "@server/logger"; import logger from "@server/logger";
import { eq } from "drizzle-orm"; import { eq, inArray } from "drizzle-orm";
import semver from "semver"; import semver from "semver";
const NEWT_V2_TARGETS_VERSION = ">=1.10.3"; const NEWT_V2_TARGETS_VERSION = ">=1.10.3";
@@ -59,6 +59,42 @@ export async function addTargets(
); );
} }
export async function addTargetsBatch(
entries: {
newtId: string;
targets: SubnetProxyTarget[] | SubnetProxyTargetV2[];
version?: string | null;
}[]
) {
if (entries.length === 0) {
return;
}
const resolved = await Promise.all(
entries.map(async (entry) => ({
...entry,
targets: await convertTargetsIfNecessary(
entry.newtId,
entry.targets
)
}))
);
await sendToClientsBatch(
resolved.map((entry) => ({
clientId: entry.newtId,
message: {
type: `newt/wg/targets/add`,
data: entry.targets
},
options: {
incrementConfigVersion: true,
compress: canCompress(entry.version, "newt")
}
}))
);
}
export async function removeTargets( export async function removeTargets(
newtId: string, newtId: string,
targets: SubnetProxyTarget[] | SubnetProxyTargetV2[], targets: SubnetProxyTarget[] | SubnetProxyTargetV2[],
@@ -76,6 +112,42 @@ export async function removeTargets(
); );
} }
export async function removeTargetsBatch(
entries: {
newtId: string;
targets: SubnetProxyTarget[] | SubnetProxyTargetV2[];
version?: string | null;
}[]
) {
if (entries.length === 0) {
return;
}
const resolved = await Promise.all(
entries.map(async (entry) => ({
...entry,
targets: await convertTargetsIfNecessary(
entry.newtId,
entry.targets
)
}))
);
await sendToClientsBatch(
resolved.map((entry) => ({
clientId: entry.newtId,
message: {
type: `newt/wg/targets/remove`,
data: entry.targets
},
options: {
incrementConfigVersion: true,
compress: canCompress(entry.version, "newt")
}
}))
);
}
export async function updateTargets( export async function updateTargets(
newtId: string, newtId: string,
targets: { targets: {
@@ -201,6 +273,171 @@ export async function removePeerData(
}); });
} }
const resolveOlmTargets = async (
entries: {
clientId: number;
olmId?: string;
version?: string | null;
}[]
) => {
const unresolvedClientIds = entries
.filter((entry) => !entry.olmId)
.map((entry) => entry.clientId);
const olmMap = new Map<number, { olmId: string; version: string | null }>();
if (unresolvedClientIds.length > 0) {
const olmRows = await db
.select({
clientId: olms.clientId,
olmId: olms.olmId,
version: olms.version
})
.from(olms)
.where(inArray(olms.clientId, unresolvedClientIds));
for (const row of olmRows) {
if (row.clientId !== null) {
olmMap.set(row.clientId, {
olmId: row.olmId,
version: row.version
});
}
}
}
return entries
.map((entry) => {
if (entry.olmId) {
return {
clientId: entry.clientId,
olmId: entry.olmId,
version: entry.version
};
}
const resolved = olmMap.get(entry.clientId);
if (!resolved) {
return null;
}
return {
clientId: entry.clientId,
olmId: resolved.olmId,
version: entry.version ?? resolved.version
};
})
.filter((entry) => entry !== null);
};
export async function addPeerDataBatch(
entries: {
clientId: number;
siteId: number;
remoteSubnets: string[];
aliases: Alias[];
olmId?: string;
version?: string | null;
}[]
) {
if (entries.length === 0) {
return;
}
const resolvedTargets = await resolveOlmTargets(entries);
if (resolvedTargets.length === 0) {
return;
}
const payloads = entries
.map((entry) => {
const resolved = resolvedTargets.find(
(target) => target.clientId === entry.clientId
);
if (!resolved) {
return null;
}
return {
clientId: resolved.olmId,
message: {
type: `olm/wg/peer/data/add`,
data: {
siteId: entry.siteId,
remoteSubnets: entry.remoteSubnets,
aliases: entry.aliases
}
},
options: {
incrementConfigVersion: true,
compress: canCompress(resolved.version, "olm")
}
};
})
.filter((entry) => entry !== null);
if (payloads.length === 0) {
return;
}
await sendToClientsBatch(payloads);
}
export async function removePeerDataBatch(
entries: {
clientId: number;
siteId: number;
remoteSubnets: string[];
aliases: Alias[];
olmId?: string;
version?: string | null;
}[]
) {
if (entries.length === 0) {
return;
}
const resolvedTargets = await resolveOlmTargets(entries);
if (resolvedTargets.length === 0) {
return;
}
const payloads = entries
.map((entry) => {
const resolved = resolvedTargets.find(
(target) => target.clientId === entry.clientId
);
if (!resolved) {
return null;
}
return {
clientId: resolved.olmId,
message: {
type: `olm/wg/peer/data/remove`,
data: {
siteId: entry.siteId,
remoteSubnets: entry.remoteSubnets,
aliases: entry.aliases
}
},
options: {
incrementConfigVersion: true,
compress: canCompress(resolved.version, "olm")
}
};
})
.filter((entry) => entry !== null);
if (payloads.length === 0) {
return;
}
await sendToClientsBatch(payloads);
}
export async function updatePeerData( export async function updatePeerData(
clientId: number, clientId: number,
siteId: number, siteId: number,

View File

@@ -10,7 +10,7 @@ import { verifyPassword } from "@server/auth/password";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import logger from "@server/logger"; import logger from "@server/logger";
import cache from "#dynamic/lib/cache"; import { regionalCache as cache } from "#dynamic/lib/cache";
import config from "@server/lib/config"; import config from "@server/lib/config";
// Stale-while-revalidate in-memory fallback for the releases API. // Stale-while-revalidate in-memory fallback for the releases API.

View File

@@ -2,7 +2,7 @@ import { MessageHandler } from "@server/routers/ws";
import logger from "@server/logger"; import logger from "@server/logger";
import { Newt } from "@server/db"; import { Newt } from "@server/db";
import { applyNewtDockerBlueprint } from "@server/lib/blueprints/applyNewtDockerBlueprint"; import { applyNewtDockerBlueprint } from "@server/lib/blueprints/applyNewtDockerBlueprint";
import cache from "#dynamic/lib/cache"; import cache from "#dynamic/lib/cache"; // not using regional here because we dont know where the site is
export const handleDockerStatusMessage: MessageHandler = async (context) => { export const handleDockerStatusMessage: MessageHandler = async (context) => {
const { message, client, sendToClient } = context; const { message, client, sendToClient } = context;

View File

@@ -1,7 +1,7 @@
import { db, Site } from "@server/db"; import { db, Site } from "@server/db";
import { newts, sites } from "@server/db"; import { newts, sites } from "@server/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { sendToClient } from "#dynamic/routers/ws"; import { sendToClient, sendToClientsBatch } from "#dynamic/routers/ws";
import logger from "@server/logger"; import logger from "@server/logger";
export async function addPeer( export async function addPeer(
@@ -36,10 +36,14 @@ export async function addPeer(
newtId = newt.newtId; newtId = newt.newtId;
} }
await sendToClient(newtId, { await sendToClient(
type: "newt/wg/peer/add", newtId,
data: peer {
}, { incrementConfigVersion: true }).catch((error) => { type: "newt/wg/peer/add",
data: peer
},
{ incrementConfigVersion: true }
).catch((error) => {
logger.warn(`Error sending message:`, error); logger.warn(`Error sending message:`, error);
}); });
@@ -76,12 +80,16 @@ export async function deletePeer(
newtId = newt.newtId; newtId = newt.newtId;
} }
await sendToClient(newtId, { await sendToClient(
type: "newt/wg/peer/remove", newtId,
data: { {
publicKey type: "newt/wg/peer/remove",
} data: {
}, { incrementConfigVersion: true }).catch((error) => { publicKey
}
},
{ incrementConfigVersion: true }
).catch((error) => {
logger.warn(`Error sending message:`, error); logger.warn(`Error sending message:`, error);
}); });
@@ -90,6 +98,35 @@ export async function deletePeer(
return site; return site;
} }
export async function deletePeersBatch(
peers: {
siteId: number;
publicKey: string;
newtId: string;
}[]
) {
if (peers.length === 0) {
return;
}
await sendToClientsBatch(
peers.map((peer) => ({
clientId: peer.newtId,
message: {
type: "newt/wg/peer/remove",
data: {
publicKey: peer.publicKey
}
},
options: { incrementConfigVersion: true }
}))
).catch((error) => {
logger.warn(`Error sending batched newt peer removals:`, error);
});
logger.info(`Deleted ${peers.length} peer(s) from newts (batch)`);
}
export async function updatePeer( export async function updatePeer(
siteId: number, siteId: number,
publicKey: string, publicKey: string,
@@ -122,13 +159,17 @@ export async function updatePeer(
newtId = newt.newtId; newtId = newt.newtId;
} }
await sendToClient(newtId, { await sendToClient(
type: "newt/wg/peer/update", newtId,
data: { {
publicKey, type: "newt/wg/peer/update",
...peer data: {
} publicKey,
}, { incrementConfigVersion: true }).catch((error) => { ...peer
}
},
{ incrementConfigVersion: true }
).catch((error) => {
logger.warn(`Error sending message:`, error); logger.warn(`Error sending message:`, error);
}); });

View File

@@ -20,7 +20,7 @@ import { handleFingerprintInsertion } from "./fingerprintingUtils";
import { build } from "@server/build"; import { build } from "@server/build";
import { canCompress } from "@server/lib/clientVersionChecks"; import { canCompress } from "@server/lib/clientVersionChecks";
import config from "@server/lib/config"; import config from "@server/lib/config";
import cache from "#dynamic/lib/cache"; 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
const HOLEPUNCH_STALE_CHAIN_THRESHOLD = 18; const HOLEPUNCH_STALE_CHAIN_THRESHOLD = 18;
const HOLEPUNCH_STALE_CHAIN_TTL_SECONDS = 1800; const HOLEPUNCH_STALE_CHAIN_TTL_SECONDS = 1800;

View File

@@ -1,9 +1,9 @@
import { sendToClient } from "#dynamic/routers/ws"; import { sendToClient, sendToClientsBatch } from "#dynamic/routers/ws";
import { clientSitesAssociationsCache, db, olms } from "@server/db"; import { clientSitesAssociationsCache, db, olms } from "@server/db";
import { canCompress } from "@server/lib/clientVersionChecks"; import { canCompress } from "@server/lib/clientVersionChecks";
import config from "@server/lib/config"; import config from "@server/lib/config";
import logger from "@server/logger"; import logger from "@server/logger";
import { and, eq } from "drizzle-orm"; import { and, eq, inArray } from "drizzle-orm";
import { Alias } from "yaml"; import { Alias } from "yaml";
export async function addPeer( export async function addPeer(
@@ -205,3 +205,150 @@ export async function initPeerAddHandshake(
`Initiated peer add handshake for site ${peer.siteId} to olm ${olmId}` `Initiated peer add handshake for site ${peer.siteId} to olm ${olmId}`
); );
} }
export async function deletePeersBatch(
peers: {
clientId: number;
siteId: number;
publicKey: string;
olmId?: string;
version?: string | null;
}[]
) {
if (peers.length === 0) {
return;
}
const unresolvedClientIds = peers
.filter((peer) => !peer.olmId)
.map((peer) => peer.clientId);
const olmByClientId = new Map<
number,
{ olmId: string; version: string | null }
>();
if (unresolvedClientIds.length > 0) {
const olmRows = await db
.select({
clientId: olms.clientId,
olmId: olms.olmId,
version: olms.version
})
.from(olms)
.where(inArray(olms.clientId, unresolvedClientIds));
for (const row of olmRows) {
if (row.clientId !== null) {
olmByClientId.set(row.clientId, {
olmId: row.olmId,
version: row.version
});
}
}
}
const batchPayloads = peers
.map((peer) => {
const resolved = peer.olmId
? { olmId: peer.olmId, version: peer.version ?? null }
: olmByClientId.get(peer.clientId);
if (!resolved) {
return null;
}
return {
clientId: resolved.olmId,
message: {
type: "olm/wg/peer/remove",
data: {
publicKey: peer.publicKey,
siteId: peer.siteId
}
},
options: {
incrementConfigVersion: true,
compress: canCompress(
peer.version ?? resolved.version,
"olm"
)
}
};
})
.filter((payload) => payload !== null);
if (batchPayloads.length === 0) {
return;
}
await sendToClientsBatch(batchPayloads).catch((error) => {
logger.warn(`Error sending batched olm peer removals:`, error);
});
logger.info(`Deleted ${batchPayloads.length} peer(s) from olms (batch)`);
}
export async function initPeerAddHandshakeBatch(
handshakes: {
clientId: number;
peer: {
siteId: number;
exitNode: {
publicKey: string;
endpoint: string;
};
};
olmId: string;
chainId?: string;
}[]
) {
if (handshakes.length === 0) {
return;
}
await sendToClientsBatch(
handshakes.map((item) => ({
clientId: item.olmId,
message: {
type: "olm/wg/peer/holepunch/site/add",
data: {
siteId: item.peer.siteId,
exitNode: {
publicKey: item.peer.exitNode.publicKey,
relayPort:
config.getRawConfig().gerbil.clients_start_port,
endpoint: item.peer.exitNode.endpoint
},
chainId: item.chainId
}
},
options: { incrementConfigVersion: true }
}))
).catch((error) => {
logger.warn(`Error sending batched olm handshakes:`, error);
});
await Promise.all(
handshakes.map((item) =>
db
.update(clientSitesAssociationsCache)
.set({ isJitMode: false })
.where(
and(
eq(
clientSitesAssociationsCache.clientId,
item.clientId
),
eq(
clientSitesAssociationsCache.siteId,
item.peer.siteId
)
)
)
)
);
logger.info(
`Initiated ${handshakes.length} peer add handshake(s) to olms (batch)`
);
}

View File

@@ -15,8 +15,7 @@ import logger from "@server/logger";
import { z } from "zod"; import { z } from "zod";
import { fromZodError } from "zod-validation-error"; import { fromZodError } from "zod-validation-error";
import type { PaginatedResponse } from "@server/types/Pagination"; import type { PaginatedResponse } from "@server/types/Pagination";
import { OpenAPITags, registry } from "@server/openApi"; import { regionalCache as cache } from "#dynamic/lib/cache";
import { localCache } from "#dynamic/lib/cache";
const USER_RESOURCE_ALIASES_CACHE_TTL_SEC = 60; const USER_RESOURCE_ALIASES_CACHE_TTL_SEC = 60;
@@ -153,7 +152,7 @@ export async function listUserResourceAliases(
pageSize pageSize
); );
const cachedData: ListUserResourceAliasesResponse | undefined = const cachedData: ListUserResourceAliasesResponse | undefined =
localCache.get(cacheKey); await cache.get(cacheKey);
if (cachedData) { if (cachedData) {
return response<ListUserResourceAliasesResponse>(res, { return response<ListUserResourceAliasesResponse>(res, {
@@ -211,7 +210,11 @@ export async function listUserResourceAliases(
page page
} }
}; };
localCache.set(cacheKey, data, USER_RESOURCE_ALIASES_CACHE_TTL_SEC); await cache.set(
cacheKey,
data,
USER_RESOURCE_ALIASES_CACHE_TTL_SEC
);
return response<ListUserResourceAliasesResponse>(res, { return response<ListUserResourceAliasesResponse>(res, {
data, data,
success: true, success: true,
@@ -256,7 +259,7 @@ export async function listUserResourceAliases(
page page
} }
}; };
localCache.set(cacheKey, data, USER_RESOURCE_ALIASES_CACHE_TTL_SEC); await cache.set(cacheKey, data, USER_RESOURCE_ALIASES_CACHE_TTL_SEC);
return response<ListUserResourceAliasesResponse>(res, { return response<ListUserResourceAliasesResponse>(res, {
data, data,

View File

@@ -14,7 +14,7 @@ import {
siteLabels, siteLabels,
type Label type Label
} from "@server/db"; } from "@server/db";
import cache from "#dynamic/lib/cache"; import { regionalCache as cache } from "#dynamic/lib/cache";
import response from "@server/lib/response"; import response from "@server/lib/response";
import logger from "@server/logger"; import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";

View File

@@ -28,7 +28,10 @@ import {
isIpInCidr, isIpInCidr,
portRangeStringSchema portRangeStringSchema
} from "@server/lib/ip"; } from "@server/lib/ip";
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; import {
getClientSiteResourceAccess,
rebuildClientAssociationsFromSiteResource
} from "@server/lib/rebuildClientAssociations";
import logger from "@server/logger"; import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { NextFunction, Request, Response } from "express"; import { NextFunction, Request, Response } from "express";
@@ -846,9 +849,14 @@ export async function handleMessagingForUpdatedSiteResource(
updatedSiteResource updatedSiteResource
); );
const { mergedAllClients } = await rebuildClientAssociationsFromSiteResource(
await rebuildClientAssociationsFromSiteResource( existingSiteResource || updatedSiteResource, // we want to rebuild based on the existing resource then we will apply the change to the destination below
existingSiteResource || updatedSiteResource, // we want to rebuild based on the existing resource then we will apply the change to the destination below trx
);
const { sitesList, mergedAllClients, mergedAllClientIds } =
await getClientSiteResourceAccess(
existingSiteResource || updatedSiteResource,
trx trx
); );

View File

@@ -76,12 +76,32 @@ export interface SendMessageOptions {
compress?: boolean; compress?: boolean;
} }
// Redis message type for cross-node communication export interface BatchSendMessage {
export interface RedisMessage { clientId: string;
type: "direct" | "broadcast";
targetClientId?: string;
excludeClientId?: string;
message: WSMessage; message: WSMessage;
fromNodeId: string;
options?: SendMessageOptions; options?: SendMessageOptions;
} }
// Redis message types for cross-node communication
export type RedisMessage =
| {
type: "direct";
targetClientId: string;
message: WSMessage;
fromNodeId: string;
}
| {
type: "direct-batch";
messages: {
targetClientId: string;
message: WSMessage;
}[];
fromNodeId: string;
}
| {
type: "broadcast";
excludeClientId?: string;
message: WSMessage;
fromNodeId: string;
options?: SendMessageOptions;
};

View File

@@ -26,7 +26,8 @@ import {
WebSocketRequest, WebSocketRequest,
WSMessage, WSMessage,
AuthenticatedWebSocket, AuthenticatedWebSocket,
SendMessageOptions SendMessageOptions,
BatchSendMessage
} from "./types"; } from "./types";
import { validateSessionToken } from "@server/auth/sessions/app"; import { validateSessionToken } from "@server/auth/sessions/app";
@@ -212,6 +213,20 @@ const sendToClient = async (
return localSent; return localSent;
}; };
const sendToClientsBatch = async (
entries: BatchSendMessage[]
): Promise<void> => {
if (entries.length === 0) {
return;
}
await Promise.all(
entries.map((entry) =>
sendToClient(entry.clientId, entry.message, entry.options)
)
);
};
const broadcastToAllExcept = async ( const broadcastToAllExcept = async (
message: WSMessage, message: WSMessage,
excludeClientId?: string, excludeClientId?: string,
@@ -552,6 +567,7 @@ export {
router, router,
handleWSUpgrade, handleWSUpgrade,
sendToClient, sendToClient,
sendToClientsBatch,
broadcastToAllExcept, broadcastToAllExcept,
connectedClients, connectedClients,
hasActiveConnections, hasActiveConnections,

View File

@@ -139,7 +139,6 @@ Restart=always
RestartSec=2 RestartSec=2
UMask=0077 UMask=0077
NoNewPrivileges=true
PrivateTmp=true PrivateTmp=true
[Install] [Install]

View File

@@ -83,9 +83,19 @@ export function createPolicyRuleValueSchema(t: TranslateFn, match: string) {
{ message: t("rulesErrorInvalidCountryDescription") } { message: t("rulesErrorInvalidCountryDescription") }
); );
case "ASN": case "ASN":
return required.refine((value) => /^AS\d+$/i.test(value.trim()), { return required.refine(
message: t("rulesErrorInvalidAsnDescription") (value) => {
}); const normalizedValue = value.trim().toUpperCase();
return (
/^AS\d+$/.test(normalizedValue) ||
normalizedValue === "ALL" ||
normalizedValue === "AS0"
);
},
{
message: t("rulesErrorInvalidAsnDescription")
}
);
default: default:
return required; return required;
} }