mirror of
https://github.com/fosrl/pangolin.git
synced 2026-06-22 23:31:49 +00:00
Compare commits
1 Commits
dev
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d03d3522a3 |
@@ -1,4 +1,4 @@
|
||||
FROM node:24-alpine
|
||||
FROM node:26-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -2967,7 +2967,6 @@
|
||||
"orgOrDomainIdMissing": "Organization or Domain ID is missing",
|
||||
"loadingDNSRecords": "Loading DNS records...",
|
||||
"olmUpdateAvailableInfo": "An updated version of Olm is available. Please update to the latest version for the best experience.",
|
||||
"updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.",
|
||||
"client": "Client",
|
||||
"proxyProtocol": "Proxy Protocol Settings",
|
||||
"proxyProtocolDescription": "Configure Proxy Protocol to preserve client IP addresses for TCP services.",
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
primaryKey,
|
||||
uniqueIndex
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { InferSelectModel, sql } from "drizzle-orm";
|
||||
import { InferSelectModel } from "drizzle-orm";
|
||||
import {
|
||||
domains,
|
||||
orgs,
|
||||
@@ -207,28 +207,17 @@ export const remoteExitNodeSessions = pgTable("remoteExitNodeSession", {
|
||||
expiresAt: bigint("expiresAt", { mode: "number" }).notNull()
|
||||
});
|
||||
|
||||
export const loginPage = pgTable(
|
||||
"loginPage",
|
||||
{
|
||||
loginPageId: serial("loginPageId").primaryKey(),
|
||||
subdomain: varchar("subdomain"),
|
||||
fullDomain: varchar("fullDomain"),
|
||||
exitNodeId: integer("exitNodeId").references(
|
||||
() => exitNodes.exitNodeId,
|
||||
{
|
||||
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 loginPage = pgTable("loginPage", {
|
||||
loginPageId: serial("loginPageId").primaryKey(),
|
||||
subdomain: varchar("subdomain"),
|
||||
fullDomain: varchar("fullDomain"),
|
||||
exitNodeId: integer("exitNodeId").references(() => exitNodes.exitNodeId, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
domainId: varchar("domainId").references(() => domains.domainId, {
|
||||
onDelete: "set null"
|
||||
})
|
||||
});
|
||||
|
||||
export const loginPageOrg = pgTable("loginPageOrg", {
|
||||
loginPageId: integer("loginPageId")
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { InferSelectModel, sql } from "drizzle-orm";
|
||||
import { InferSelectModel } from "drizzle-orm";
|
||||
import {
|
||||
bigint,
|
||||
boolean,
|
||||
@@ -82,130 +82,107 @@ export const orgDomains = pgTable("orgDomains", {
|
||||
.references(() => domains.domainId, { onDelete: "cascade" })
|
||||
});
|
||||
|
||||
export const sites = pgTable(
|
||||
"sites",
|
||||
{
|
||||
siteId: serial("siteId").primaryKey(),
|
||||
orgId: varchar("orgId")
|
||||
.references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
niceId: varchar("niceId").notNull(),
|
||||
exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
name: varchar("name").notNull(),
|
||||
pubKey: varchar("pubKey"),
|
||||
subnet: varchar("subnet"),
|
||||
megabytesIn: real("bytesIn").default(0),
|
||||
megabytesOut: real("bytesOut").default(0),
|
||||
lastBandwidthUpdate: varchar("lastBandwidthUpdate"),
|
||||
type: varchar("type").notNull(), // "newt" or "wireguard"
|
||||
online: boolean("online").notNull().default(false),
|
||||
lastPing: integer("lastPing"),
|
||||
address: varchar("address"),
|
||||
endpoint: varchar("endpoint"),
|
||||
publicKey: varchar("publicKey"),
|
||||
lastHolePunch: bigint("lastHolePunch", { mode: "number" }),
|
||||
listenPort: integer("listenPort"),
|
||||
dockerSocketEnabled: boolean("dockerSocketEnabled")
|
||||
.notNull()
|
||||
.default(true),
|
||||
autoUpdateEnabled: boolean("autoUpdateEnabled")
|
||||
.notNull()
|
||||
.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 sites = pgTable("sites", {
|
||||
siteId: serial("siteId").primaryKey(),
|
||||
orgId: varchar("orgId")
|
||||
.references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
niceId: varchar("niceId").notNull(),
|
||||
exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
name: varchar("name").notNull(),
|
||||
pubKey: varchar("pubKey"),
|
||||
subnet: varchar("subnet"),
|
||||
megabytesIn: real("bytesIn").default(0),
|
||||
megabytesOut: real("bytesOut").default(0),
|
||||
lastBandwidthUpdate: varchar("lastBandwidthUpdate"),
|
||||
type: varchar("type").notNull(), // "newt" or "wireguard"
|
||||
online: boolean("online").notNull().default(false),
|
||||
lastPing: integer("lastPing"),
|
||||
address: varchar("address"),
|
||||
endpoint: varchar("endpoint"),
|
||||
publicKey: varchar("publicKey"),
|
||||
lastHolePunch: bigint("lastHolePunch", { mode: "number" }),
|
||||
listenPort: integer("listenPort"),
|
||||
dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true),
|
||||
autoUpdateEnabled: boolean("autoUpdateEnabled").notNull().default(false),
|
||||
autoUpdateOverrideOrg: boolean("autoUpdateOverrideOrg")
|
||||
.notNull()
|
||||
.default(false),
|
||||
status: varchar("status")
|
||||
.$type<"pending" | "approved">()
|
||||
.default("approved")
|
||||
});
|
||||
|
||||
export const resources = pgTable(
|
||||
"resources",
|
||||
{
|
||||
resourceId: serial("resourceId").primaryKey(),
|
||||
resourcePolicyId: integer("resourcePolicyId").references(
|
||||
() => resourcePolicies.resourcePolicyId,
|
||||
{ onDelete: "set null" }
|
||||
),
|
||||
defaultResourcePolicyId: integer("defaultResourcePolicyId").references(
|
||||
() => resourcePolicies.resourcePolicyId,
|
||||
{
|
||||
onDelete: "restrict"
|
||||
}
|
||||
),
|
||||
resourceGuid: varchar("resourceGuid", { length: 36 })
|
||||
.unique()
|
||||
.notNull()
|
||||
.$defaultFn(() => randomUUID()),
|
||||
orgId: varchar("orgId")
|
||||
.references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
niceId: text("niceId").notNull(),
|
||||
name: varchar("name").notNull(),
|
||||
subdomain: varchar("subdomain"),
|
||||
fullDomain: varchar("fullDomain"),
|
||||
domainId: varchar("domainId").references(() => domains.domainId, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
ssl: boolean("ssl").notNull().default(false),
|
||||
blockAccess: boolean("blockAccess").notNull().default(false),
|
||||
proxyPort: integer("proxyPort"),
|
||||
sso: boolean("sso"),
|
||||
emailWhitelistEnabled: boolean("emailWhitelistEnabled"),
|
||||
applyRules: boolean("applyRules"),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
stickySession: boolean("stickySession").notNull().default(false),
|
||||
tlsServerName: varchar("tlsServerName"),
|
||||
setHostHeader: varchar("setHostHeader"),
|
||||
enableProxy: boolean("enableProxy").default(true),
|
||||
skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
headers: text("headers"), // comma-separated list of headers to add to the request
|
||||
proxyProtocol: boolean("proxyProtocol").notNull().default(false),
|
||||
proxyProtocolVersion: integer("proxyProtocolVersion").default(1),
|
||||
maintenanceModeEnabled: boolean("maintenanceModeEnabled")
|
||||
.notNull()
|
||||
.default(false),
|
||||
maintenanceModeType: text("maintenanceModeType", {
|
||||
enum: ["forced", "automatic"]
|
||||
}).default("forced"), // "forced" = always show, "automatic" = only when down
|
||||
maintenanceTitle: text("maintenanceTitle"),
|
||||
maintenanceMessage: text("maintenanceMessage"),
|
||||
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
|
||||
postAuthPath: text("postAuthPath"),
|
||||
health: varchar("health").default("unknown"), // "healthy", "unhealthy", "unknown"
|
||||
wildcard: boolean("wildcard").notNull().default(false),
|
||||
mode: text("mode").default("http").notNull(), // rdp, ssh, http, vnc
|
||||
pamMode: varchar("pamMode", { length: 32 })
|
||||
.$type<"passthrough" | "push">()
|
||||
.default("passthrough"),
|
||||
authDaemonMode: varchar("authDaemonMode", { length: 32 })
|
||||
.$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 resources = pgTable("resources", {
|
||||
resourceId: serial("resourceId").primaryKey(),
|
||||
resourcePolicyId: integer("resourcePolicyId").references(
|
||||
() => resourcePolicies.resourcePolicyId,
|
||||
{ onDelete: "set null" }
|
||||
),
|
||||
defaultResourcePolicyId: integer("defaultResourcePolicyId").references(
|
||||
() => resourcePolicies.resourcePolicyId,
|
||||
{
|
||||
onDelete: "restrict"
|
||||
}
|
||||
),
|
||||
resourceGuid: varchar("resourceGuid", { length: 36 })
|
||||
.unique()
|
||||
.notNull()
|
||||
.$defaultFn(() => randomUUID()),
|
||||
orgId: varchar("orgId")
|
||||
.references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
niceId: text("niceId").notNull(),
|
||||
name: varchar("name").notNull(),
|
||||
subdomain: varchar("subdomain"),
|
||||
fullDomain: varchar("fullDomain"),
|
||||
domainId: varchar("domainId").references(() => domains.domainId, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
ssl: boolean("ssl").notNull().default(false),
|
||||
blockAccess: boolean("blockAccess").notNull().default(false),
|
||||
proxyPort: integer("proxyPort"),
|
||||
sso: boolean("sso"),
|
||||
emailWhitelistEnabled: boolean("emailWhitelistEnabled"),
|
||||
applyRules: boolean("applyRules"),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
stickySession: boolean("stickySession").notNull().default(false),
|
||||
tlsServerName: varchar("tlsServerName"),
|
||||
setHostHeader: varchar("setHostHeader"),
|
||||
enableProxy: boolean("enableProxy").default(true),
|
||||
skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
headers: text("headers"), // comma-separated list of headers to add to the request
|
||||
proxyProtocol: boolean("proxyProtocol").notNull().default(false),
|
||||
proxyProtocolVersion: integer("proxyProtocolVersion").default(1),
|
||||
maintenanceModeEnabled: boolean("maintenanceModeEnabled")
|
||||
.notNull()
|
||||
.default(false),
|
||||
maintenanceModeType: text("maintenanceModeType", {
|
||||
enum: ["forced", "automatic"]
|
||||
}).default("forced"), // "forced" = always show, "automatic" = only when down
|
||||
maintenanceTitle: text("maintenanceTitle"),
|
||||
maintenanceMessage: text("maintenanceMessage"),
|
||||
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
|
||||
postAuthPath: text("postAuthPath"),
|
||||
health: varchar("health").default("unknown"), // "healthy", "unhealthy", "unknown"
|
||||
wildcard: boolean("wildcard").notNull().default(false),
|
||||
mode: text("mode").default("http").notNull(), // rdp, ssh, http, vnc
|
||||
pamMode: varchar("pamMode", { length: 32 })
|
||||
.$type<"passthrough" | "push">()
|
||||
.default("passthrough"),
|
||||
authDaemonMode: varchar("authDaemonMode", { length: 32 })
|
||||
.$type<"site" | "remote" | "native">()
|
||||
.default("site"),
|
||||
authDaemonPort: integer("authDaemonPort").default(22123)
|
||||
});
|
||||
|
||||
export const labels = pgTable("labels", {
|
||||
labelId: serial("labelId").primaryKey(),
|
||||
@@ -290,84 +267,71 @@ export const clientLabels = pgTable(
|
||||
(t) => [unique("client_label_uniq").on(t.clientId, t.labelId)]
|
||||
);
|
||||
|
||||
export const targets = pgTable(
|
||||
"targets",
|
||||
{
|
||||
targetId: serial("targetId").primaryKey(),
|
||||
resourceId: integer("resourceId")
|
||||
.references(() => resources.resourceId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
siteId: integer("siteId")
|
||||
.references(() => sites.siteId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
ip: varchar("ip").notNull(),
|
||||
method: varchar("method"),
|
||||
port: integer("port").notNull(),
|
||||
internalPort: integer("internalPort"),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
path: text("path"),
|
||||
pathMatchType: text("pathMatchType"), // exact, prefix, regex
|
||||
rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target
|
||||
rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix
|
||||
priority: integer("priority").notNull().default(100),
|
||||
mode: varchar("mode")
|
||||
.$type<"http" | "tcp" | "udp" | "ssh" | "rdp" | "vnc">()
|
||||
.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",
|
||||
{
|
||||
targetHealthCheckId: serial("targetHealthCheckId").primaryKey(),
|
||||
targetId: integer("targetId").references(() => targets.targetId, {
|
||||
export const targets = pgTable("targets", {
|
||||
targetId: serial("targetId").primaryKey(),
|
||||
resourceId: integer("resourceId")
|
||||
.references(() => resources.resourceId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
orgId: varchar("orgId")
|
||||
.references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
siteId: integer("siteId")
|
||||
.references(() => sites.siteId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
name: varchar("name"),
|
||||
hcEnabled: boolean("hcEnabled").notNull().default(false),
|
||||
hcPath: varchar("hcPath"),
|
||||
hcScheme: varchar("hcScheme"),
|
||||
hcMode: varchar("hcMode").default("http"),
|
||||
hcHostname: varchar("hcHostname"),
|
||||
hcPort: integer("hcPort"),
|
||||
hcInterval: integer("hcInterval").default(30), // in seconds
|
||||
hcUnhealthyInterval: integer("hcUnhealthyInterval").default(30), // in seconds
|
||||
hcTimeout: integer("hcTimeout").default(5), // in seconds
|
||||
hcHeaders: varchar("hcHeaders"),
|
||||
hcFollowRedirects: boolean("hcFollowRedirects").default(true),
|
||||
hcMethod: varchar("hcMethod").default("GET"),
|
||||
hcStatus: integer("hcStatus"), // http code
|
||||
hcHealth: text("hcHealth")
|
||||
.$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)]
|
||||
);
|
||||
})
|
||||
.notNull(),
|
||||
siteId: integer("siteId")
|
||||
.references(() => sites.siteId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
ip: varchar("ip").notNull(),
|
||||
method: varchar("method"),
|
||||
port: integer("port").notNull(),
|
||||
internalPort: integer("internalPort"),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
path: text("path"),
|
||||
pathMatchType: text("pathMatchType"), // exact, prefix, regex
|
||||
rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target
|
||||
rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix
|
||||
priority: integer("priority").notNull().default(100),
|
||||
mode: varchar("mode")
|
||||
.$type<"http" | "tcp" | "udp" | "ssh" | "rdp" | "vnc">()
|
||||
.notNull()
|
||||
.default("http"),
|
||||
authToken: varchar("authToken")
|
||||
});
|
||||
|
||||
export const targetHealthCheck = pgTable("targetHealthCheck", {
|
||||
targetHealthCheckId: serial("targetHealthCheckId").primaryKey(),
|
||||
targetId: integer("targetId").references(() => targets.targetId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
orgId: varchar("orgId")
|
||||
.references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
siteId: integer("siteId")
|
||||
.references(() => sites.siteId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
name: varchar("name"),
|
||||
hcEnabled: boolean("hcEnabled").notNull().default(false),
|
||||
hcPath: varchar("hcPath"),
|
||||
hcScheme: varchar("hcScheme"),
|
||||
hcMode: varchar("hcMode").default("http"),
|
||||
hcHostname: varchar("hcHostname"),
|
||||
hcPort: integer("hcPort"),
|
||||
hcInterval: integer("hcInterval").default(30), // in seconds
|
||||
hcUnhealthyInterval: integer("hcUnhealthyInterval").default(30), // in seconds
|
||||
hcTimeout: integer("hcTimeout").default(5), // in seconds
|
||||
hcHeaders: varchar("hcHeaders"),
|
||||
hcFollowRedirects: boolean("hcFollowRedirects").default(true),
|
||||
hcMethod: varchar("hcMethod").default("GET"),
|
||||
hcStatus: integer("hcStatus"), // http code
|
||||
hcHealth: text("hcHealth")
|
||||
.$type<"unknown" | "healthy" | "unhealthy">()
|
||||
.default("unknown"), // "unknown", "healthy", "unhealthy"
|
||||
hcTlsServerName: text("hcTlsServerName"),
|
||||
hcHealthyThreshold: integer("hcHealthyThreshold").default(1),
|
||||
hcUnhealthyThreshold: integer("hcUnhealthyThreshold").default(1)
|
||||
});
|
||||
|
||||
export const exitNodes = pgTable("exitNodes", {
|
||||
exitNodeId: serial("exitNodeId").primaryKey(),
|
||||
@@ -442,74 +406,43 @@ export const networks = pgTable("networks", {
|
||||
.notNull()
|
||||
});
|
||||
|
||||
export const siteNetworks = pgTable(
|
||||
"siteNetworks",
|
||||
{
|
||||
siteId: integer("siteId")
|
||||
.notNull()
|
||||
.references(() => sites.siteId, {
|
||||
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 siteNetworks = pgTable("siteNetworks", {
|
||||
siteId: integer("siteId")
|
||||
.notNull()
|
||||
.references(() => sites.siteId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
networkId: integer("networkId")
|
||||
.notNull()
|
||||
.references(() => networks.networkId, { onDelete: "cascade" })
|
||||
});
|
||||
|
||||
export const clientSiteResources = pgTable(
|
||||
"clientSiteResources",
|
||||
{
|
||||
clientId: integer("clientId")
|
||||
.notNull()
|
||||
.references(() => clients.clientId, { 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 clientSiteResources = pgTable("clientSiteResources", {
|
||||
clientId: integer("clientId")
|
||||
.notNull()
|
||||
.references(() => clients.clientId, { onDelete: "cascade" }),
|
||||
siteResourceId: integer("siteResourceId")
|
||||
.notNull()
|
||||
.references(() => siteResources.siteResourceId, { onDelete: "cascade" })
|
||||
});
|
||||
|
||||
export const roleSiteResources = pgTable(
|
||||
"roleSiteResources",
|
||||
{
|
||||
roleId: integer("roleId")
|
||||
.notNull()
|
||||
.references(() => roles.roleId, { onDelete: "cascade" }),
|
||||
siteResourceId: integer("siteResourceId")
|
||||
.notNull()
|
||||
.references(() => siteResources.siteResourceId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
},
|
||||
(t) => [index("idx_rolesiteresources_siteresourceid").on(t.siteResourceId)]
|
||||
);
|
||||
export const roleSiteResources = pgTable("roleSiteResources", {
|
||||
roleId: integer("roleId")
|
||||
.notNull()
|
||||
.references(() => roles.roleId, { onDelete: "cascade" }),
|
||||
siteResourceId: integer("siteResourceId")
|
||||
.notNull()
|
||||
.references(() => siteResources.siteResourceId, { onDelete: "cascade" })
|
||||
});
|
||||
|
||||
export const userSiteResources = pgTable(
|
||||
"userSiteResources",
|
||||
{
|
||||
userId: varchar("userId")
|
||||
.notNull()
|
||||
.references(() => users.userId, { 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 userSiteResources = pgTable("userSiteResources", {
|
||||
userId: varchar("userId")
|
||||
.notNull()
|
||||
.references(() => users.userId, { onDelete: "cascade" }),
|
||||
siteResourceId: integer("siteResourceId")
|
||||
.notNull()
|
||||
.references(() => siteResources.siteResourceId, { onDelete: "cascade" })
|
||||
});
|
||||
|
||||
export const users = pgTable("user", {
|
||||
userId: varchar("id").primaryKey(),
|
||||
@@ -534,19 +467,15 @@ export const users = pgTable("user", {
|
||||
locale: varchar("locale")
|
||||
});
|
||||
|
||||
export const newts = pgTable(
|
||||
"newt",
|
||||
{
|
||||
newtId: varchar("id").primaryKey(),
|
||||
secretHash: varchar("secretHash").notNull(),
|
||||
dateCreated: varchar("dateCreated").notNull(),
|
||||
version: varchar("version"),
|
||||
siteId: integer("siteId").references(() => sites.siteId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
},
|
||||
(t) => [index("idx_newt_siteid").on(t.siteId)]
|
||||
);
|
||||
export const newts = pgTable("newt", {
|
||||
newtId: varchar("id").primaryKey(),
|
||||
secretHash: varchar("secretHash").notNull(),
|
||||
dateCreated: varchar("dateCreated").notNull(),
|
||||
version: varchar("version"),
|
||||
siteId: integer("siteId").references(() => sites.siteId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
});
|
||||
|
||||
export const twoFactorBackupCodes = pgTable("twoFactorBackupCodes", {
|
||||
codeId: serial("id").primaryKey(),
|
||||
@@ -647,49 +576,29 @@ export const userOrgRoles = pgTable(
|
||||
(t) => [unique().on(t.userId, t.orgId, t.roleId)]
|
||||
);
|
||||
|
||||
export const roleActions = pgTable(
|
||||
"roleActions",
|
||||
{
|
||||
roleId: integer("roleId")
|
||||
.notNull()
|
||||
.references(() => roles.roleId, { onDelete: "cascade" }),
|
||||
actionId: varchar("actionId")
|
||||
.notNull()
|
||||
.references(() => actions.actionId, { 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 roleActions = pgTable("roleActions", {
|
||||
roleId: integer("roleId")
|
||||
.notNull()
|
||||
.references(() => roles.roleId, { onDelete: "cascade" }),
|
||||
actionId: varchar("actionId")
|
||||
.notNull()
|
||||
.references(() => actions.actionId, { onDelete: "cascade" }),
|
||||
orgId: varchar("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" })
|
||||
});
|
||||
|
||||
export const userActions = pgTable(
|
||||
"userActions",
|
||||
{
|
||||
userId: varchar("userId")
|
||||
.notNull()
|
||||
.references(() => users.userId, { onDelete: "cascade" }),
|
||||
actionId: varchar("actionId")
|
||||
.notNull()
|
||||
.references(() => actions.actionId, { 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 userActions = pgTable("userActions", {
|
||||
userId: varchar("userId")
|
||||
.notNull()
|
||||
.references(() => users.userId, { onDelete: "cascade" }),
|
||||
actionId: varchar("actionId")
|
||||
.notNull()
|
||||
.references(() => actions.actionId, { onDelete: "cascade" }),
|
||||
orgId: varchar("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" })
|
||||
});
|
||||
|
||||
export const roleSites = pgTable("roleSites", {
|
||||
roleId: integer("roleId")
|
||||
@@ -1095,44 +1004,40 @@ export const idpOrg = pgTable("idpOrg", {
|
||||
orgMapping: varchar("orgMapping")
|
||||
});
|
||||
|
||||
export const clients = pgTable(
|
||||
"clients",
|
||||
{
|
||||
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
|
||||
export const clients = pgTable("clients", {
|
||||
clientId: serial("clientId").primaryKey(),
|
||||
orgId: varchar("orgId")
|
||||
.references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
niceId: varchar("niceId").notNull(),
|
||||
olmId: text("olmId"), // to lock it to a specific olm optionally
|
||||
name: varchar("name").notNull(),
|
||||
pubKey: varchar("pubKey"),
|
||||
subnet: varchar("subnet").notNull(),
|
||||
megabytesIn: real("bytesIn"),
|
||||
megabytesOut: real("bytesOut"),
|
||||
lastBandwidthUpdate: varchar("lastBandwidthUpdate"),
|
||||
lastPing: integer("lastPing"),
|
||||
type: varchar("type").notNull(), // "olm"
|
||||
online: boolean("online").notNull().default(false),
|
||||
// endpoint: varchar("endpoint"),
|
||||
lastHolePunch: integer("lastHolePunch"),
|
||||
maxConnections: integer("maxConnections"),
|
||||
archived: boolean("archived").notNull().default(false),
|
||||
blocked: boolean("blocked").notNull().default(false),
|
||||
approvalState: varchar("approvalState").$type<
|
||||
"pending" | "approved" | "denied"
|
||||
>()
|
||||
},
|
||||
(t) => [index("idx_clients_userid").on(t.userId)]
|
||||
);
|
||||
})
|
||||
.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"
|
||||
}),
|
||||
niceId: varchar("niceId").notNull(),
|
||||
olmId: text("olmId"), // to lock it to a specific olm optionally
|
||||
name: varchar("name").notNull(),
|
||||
pubKey: varchar("pubKey"),
|
||||
subnet: varchar("subnet").notNull(),
|
||||
megabytesIn: real("bytesIn"),
|
||||
megabytesOut: real("bytesOut"),
|
||||
lastBandwidthUpdate: varchar("lastBandwidthUpdate"),
|
||||
lastPing: integer("lastPing"),
|
||||
type: varchar("type").notNull(), // "olm"
|
||||
online: boolean("online").notNull().default(false),
|
||||
// endpoint: varchar("endpoint"),
|
||||
lastHolePunch: integer("lastHolePunch"),
|
||||
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(
|
||||
"clientSitesAssociationsCache",
|
||||
@@ -1144,11 +1049,7 @@ export const clientSitesAssociationsCache = pgTable(
|
||||
isJitMode: boolean("isJitMode").notNull().default(false),
|
||||
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
|
||||
},
|
||||
(t) => [
|
||||
primaryKey({ columns: [t.clientId, t.siteId] }),
|
||||
index("idx_clientsitesassociationscache_siteid").on(t.siteId)
|
||||
]
|
||||
}
|
||||
);
|
||||
|
||||
export const clientSiteResourcesAssociationsCache = pgTable(
|
||||
@@ -1157,14 +1058,7 @@ 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
|
||||
.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", {
|
||||
@@ -1177,27 +1071,23 @@ export const clientPostureSnapshots = pgTable("clientPostureSnapshots", {
|
||||
collectedAt: integer("collectedAt").notNull()
|
||||
});
|
||||
|
||||
export const olms = pgTable(
|
||||
"olms",
|
||||
{
|
||||
olmId: varchar("id").primaryKey(),
|
||||
secretHash: varchar("secretHash").notNull(),
|
||||
dateCreated: varchar("dateCreated").notNull(),
|
||||
version: text("version"),
|
||||
agent: text("agent"),
|
||||
name: varchar("name"),
|
||||
clientId: integer("clientId").references(() => clients.clientId, {
|
||||
// we will switch this depending on the current org it wants to connect to
|
||||
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"
|
||||
}),
|
||||
archived: boolean("archived").notNull().default(false)
|
||||
},
|
||||
(t) => [index("idx_olms_clientid").on(t.clientId)]
|
||||
);
|
||||
export const olms = pgTable("olms", {
|
||||
olmId: varchar("id").primaryKey(),
|
||||
secretHash: varchar("secretHash").notNull(),
|
||||
dateCreated: varchar("dateCreated").notNull(),
|
||||
version: text("version"),
|
||||
agent: text("agent"),
|
||||
name: varchar("name"),
|
||||
clientId: integer("clientId").references(() => clients.clientId, {
|
||||
// we will switch this depending on the current org it wants to connect to
|
||||
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"
|
||||
}),
|
||||
archived: boolean("archived").notNull().default(false)
|
||||
});
|
||||
|
||||
export const currentFingerprint = pgTable("currentFingerprint", {
|
||||
fingerprintId: serial("id").primaryKey(),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { drizzle as DrizzleSqlite } from "drizzle-orm/better-sqlite3";
|
||||
import Database from "better-sqlite3";
|
||||
import type BetterSqlite3 from "better-sqlite3";
|
||||
import * as schema from "./schema/schema";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
@@ -11,31 +12,68 @@ export const exists = checkFileExists(location);
|
||||
|
||||
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() {
|
||||
const sqlite = new Database(location);
|
||||
|
||||
if (process.env.ENABLE_SQLITE_WAL_MODE == "true") {
|
||||
// Enable WAL mode — allows concurrent readers + single writer, preventing
|
||||
// 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");
|
||||
// NORMAL sync mode: safe with WAL, reduces write lock hold time.
|
||||
sqlite.pragma("synchronous = NORMAL");
|
||||
}
|
||||
|
||||
// No busy_timeout pragma: better-sqlite3 already arms
|
||||
// sqlite3_busy_timeout(db, 5000) via its default `timeout` option
|
||||
// (lib/database.js), so an explicit pragma is redundant.
|
||||
// Wait up to 5s on SQLITE_BUSY instead of failing — prevents audit log
|
||||
// retry loops that accumulate memory.
|
||||
sqlite.pragma("busy_timeout = 5000");
|
||||
|
||||
// Intentionally NOT setting cache_size or mmap_size: a large page cache plus
|
||||
// a multi-hundred-MB mmap region inflate RSS and cause page-cache thrashing
|
||||
// on small (~1 GB) instances. Leave SQLite on its conservative defaults.
|
||||
// 64 MB page cache (default 2 MB) — reduces I/O round-trips on large
|
||||
// TraefikConfigManager JOINs that block the event loop.
|
||||
sqlite.pragma("cache_size = -65536");
|
||||
|
||||
// Intentionally NOT wrapping prepare()/statements: better-sqlite3 finalizes
|
||||
// sqlite3_stmt in the Statement destructor at GC, and drizzle-orm prepares a
|
||||
// fresh statement per query (no statement cache), so statements cannot
|
||||
// accumulate. better-sqlite3 11.x exposes no Statement.finalize() at all.
|
||||
// 256 MB memory-mapped I/O — OS serves reads from page cache directly,
|
||||
// reducing event-loop blocking.
|
||||
sqlite.pragma("mmap_size = 268435456");
|
||||
|
||||
// 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, {
|
||||
schema
|
||||
|
||||
@@ -24,7 +24,6 @@ import license from "#dynamic/license/license";
|
||||
import { initLogCleanupInterval } from "@server/lib/cleanupLogs";
|
||||
import { initAcmeCertSync } from "#dynamic/lib/acmeCertSync";
|
||||
import { fetchServerIp } from "@server/lib/serverIpService";
|
||||
import { startRebuildQueueProcessor } from "@server/lib/rebuildClientAssociations";
|
||||
|
||||
async function startServers() {
|
||||
await setHostMeta();
|
||||
@@ -42,7 +41,6 @@ async function startServers() {
|
||||
|
||||
initLogCleanupInterval();
|
||||
initAcmeCertSync();
|
||||
startRebuildQueueProcessor();
|
||||
|
||||
// Start all servers
|
||||
const apiServer = createApiServer();
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { FeatureId, getFeatureMeterId } from "./features";
|
||||
import logger from "@server/logger";
|
||||
import { build } from "@server/build";
|
||||
import { regionalCache as cache } from "#dynamic/lib/cache";
|
||||
import cache from "#dynamic/lib/cache";
|
||||
|
||||
export function noop() {
|
||||
if (build !== "saas") {
|
||||
@@ -22,6 +22,7 @@ export function noop() {
|
||||
}
|
||||
|
||||
export class UsageService {
|
||||
|
||||
constructor() {
|
||||
if (noop()) {
|
||||
return;
|
||||
@@ -56,10 +57,7 @@ export class UsageService {
|
||||
try {
|
||||
let usage;
|
||||
if (transaction) {
|
||||
const orgIdToUse = await this.getBillingOrg(
|
||||
orgId,
|
||||
transaction
|
||||
);
|
||||
const orgIdToUse = await this.getBillingOrg(orgId, transaction);
|
||||
usage = await this.internalAddUsage(
|
||||
orgIdToUse,
|
||||
featureId,
|
||||
|
||||
@@ -48,18 +48,18 @@ export async function applyBlueprint({
|
||||
name,
|
||||
source = "API"
|
||||
}: 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 blueprintMessage = "";
|
||||
let blueprintMessage: string;
|
||||
let error: any | null = null;
|
||||
|
||||
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 clientResourcesResults: ClientResourcesResults = [];
|
||||
await db.transaction(async (trx) => {
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
const MAX_RECURSION_DEPTH = 100;
|
||||
|
||||
const segmentRegexCache = new Map<string, RegExp>();
|
||||
|
||||
function getSegmentRegex(patternPart: string): RegExp {
|
||||
let regex = segmentRegexCache.get(patternPart);
|
||||
if (!regex) {
|
||||
const regexPattern = patternPart
|
||||
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
||||
.replace(/\*/g, ".*")
|
||||
.replace(/\?/g, ".");
|
||||
regex = new RegExp(`^${regexPattern}$`);
|
||||
segmentRegexCache.set(patternPart, regex);
|
||||
}
|
||||
return regex;
|
||||
}
|
||||
|
||||
export function isPathAllowed(pattern: string, path: string): boolean {
|
||||
const normalize = (p: string) => p.split("/").filter(Boolean);
|
||||
const patternParts = normalize(pattern);
|
||||
const pathParts = normalize(path);
|
||||
|
||||
function matchSegments(
|
||||
patternIndex: number,
|
||||
pathIndex: number,
|
||||
depth: number = 0
|
||||
): boolean {
|
||||
if (depth > MAX_RECURSION_DEPTH) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentPatternPart = patternParts[patternIndex];
|
||||
const currentPathPart = pathParts[pathIndex];
|
||||
|
||||
if (patternIndex >= patternParts.length) {
|
||||
return pathIndex >= pathParts.length;
|
||||
}
|
||||
|
||||
if (pathIndex >= pathParts.length) {
|
||||
return patternParts.slice(patternIndex).every((p) => p === "*");
|
||||
}
|
||||
|
||||
if (currentPatternPart === "*") {
|
||||
if (matchSegments(patternIndex + 1, pathIndex, depth + 1)) {
|
||||
return true;
|
||||
}
|
||||
if (matchSegments(patternIndex, pathIndex + 1, depth + 1)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (currentPatternPart.includes("*")) {
|
||||
const regex = getSegmentRegex(currentPatternPart);
|
||||
|
||||
if (regex.test(currentPathPart)) {
|
||||
return matchSegments(
|
||||
patternIndex + 1,
|
||||
pathIndex + 1,
|
||||
depth + 1
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (currentPatternPart !== currentPathPart) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return matchSegments(patternIndex + 1, pathIndex + 1, depth + 1);
|
||||
}
|
||||
|
||||
return matchSegments(0, 0, 0);
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
exitNodes,
|
||||
newts,
|
||||
olms,
|
||||
primaryDb,
|
||||
roleSiteResources,
|
||||
Site,
|
||||
SiteResource,
|
||||
@@ -21,10 +20,10 @@ import {
|
||||
} from "@server/db";
|
||||
import { and, count, eq, inArray, ne } from "drizzle-orm";
|
||||
|
||||
import { deletePeersBatch as newtDeletePeersBatch } from "@server/routers/newt/peers";
|
||||
import { deletePeer as newtDeletePeer } from "@server/routers/newt/peers";
|
||||
import {
|
||||
initPeerAddHandshakeBatch,
|
||||
deletePeersBatch as olmDeletePeersBatch
|
||||
initPeerAddHandshake,
|
||||
deletePeer as olmDeletePeer
|
||||
} from "@server/routers/olm/peers";
|
||||
import { sendToExitNode } from "#dynamic/lib/exitNodes";
|
||||
import logger from "@server/logger";
|
||||
@@ -35,13 +34,12 @@ import {
|
||||
parseEndpoint
|
||||
} from "@server/lib/ip";
|
||||
import {
|
||||
addPeerDataBatch,
|
||||
addTargetsBatch as addSubnetProxyTargetsBatch,
|
||||
removePeerDataBatch,
|
||||
removeTargetsBatch as removeSubnetProxyTargetsBatch
|
||||
addPeerData,
|
||||
addTargets as addSubnetProxyTargets,
|
||||
removePeerData,
|
||||
removeTargets as removeSubnetProxyTargets
|
||||
} from "@server/routers/client/targets";
|
||||
import { lockManager } from "#dynamic/lib/lock";
|
||||
import { rebuildQueue } from "#dynamic/lib/rebuildQueue";
|
||||
|
||||
// TTL for rebuild-association locks. These functions can fan out into many
|
||||
// peer/proxy updates, so give them a generous window.
|
||||
@@ -162,33 +160,18 @@ export async function getClientSiteResourceAccess(
|
||||
export async function rebuildClientAssociationsFromSiteResource(
|
||||
siteResource: SiteResource,
|
||||
trx: Transaction | typeof db = db
|
||||
) {
|
||||
try {
|
||||
return await lockManager.withLock(
|
||||
`rebuild-client-associations:site-resource:${siteResource.siteResourceId}`,
|
||||
() =>
|
||||
rebuildClientAssociationsFromSiteResourceImpl(
|
||||
siteResource,
|
||||
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 site resource ${siteResource.siteResourceId}, queuing for deferred processing`
|
||||
);
|
||||
await rebuildQueue.enqueue({
|
||||
type: "site-resource",
|
||||
id: siteResource.siteResourceId
|
||||
});
|
||||
return { mergedAllClients: [] };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
): Promise<{
|
||||
mergedAllClients: {
|
||||
clientId: number;
|
||||
pubKey: string | null;
|
||||
subnet: string | null;
|
||||
}[];
|
||||
}> {
|
||||
return await lockManager.withLock(
|
||||
`rebuild-client-associations:site-resource:${siteResource.siteResourceId}`,
|
||||
() => rebuildClientAssociationsFromSiteResourceImpl(siteResource, trx),
|
||||
REBUILD_ASSOCIATIONS_LOCK_TTL_MS
|
||||
);
|
||||
}
|
||||
|
||||
async function rebuildClientAssociationsFromSiteResourceImpl(
|
||||
@@ -553,28 +536,6 @@ async function handleMessagesForSiteClients(
|
||||
const newtJobs: Promise<any>[] = [];
|
||||
const olmJobs: 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)
|
||||
const clientsToProcess = new Map<
|
||||
@@ -623,21 +584,6 @@ 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()) {
|
||||
// UPDATE THE NEWT
|
||||
if (!client.subnet || !client.pubKey) {
|
||||
@@ -654,8 +600,14 @@ async function handleMessagesForSiteClients(
|
||||
continue;
|
||||
}
|
||||
|
||||
const olmId = olmByClientId.get(client.clientId);
|
||||
if (!olmId) {
|
||||
const [olm] = await trx
|
||||
.select({
|
||||
olmId: olms.olmId
|
||||
})
|
||||
.from(olms)
|
||||
.where(eq(olms.clientId, client.clientId))
|
||||
.limit(1);
|
||||
if (!olm) {
|
||||
logger.warn(
|
||||
`Olm not found for client ${client.clientId} so cannot add/delete peers`
|
||||
);
|
||||
@@ -663,17 +615,15 @@ async function handleMessagesForSiteClients(
|
||||
}
|
||||
|
||||
if (isDelete) {
|
||||
newtPeerDeletes.push({
|
||||
siteId,
|
||||
publicKey: client.pubKey,
|
||||
newtId: newt.newtId
|
||||
});
|
||||
olmPeerDeletes.push({
|
||||
clientId: client.clientId,
|
||||
siteId,
|
||||
publicKey: site.publicKey,
|
||||
olmId
|
||||
});
|
||||
newtJobs.push(newtDeletePeer(siteId, client.pubKey, newt.newtId));
|
||||
olmJobs.push(
|
||||
olmDeletePeer(
|
||||
client.clientId,
|
||||
siteId,
|
||||
site.publicKey,
|
||||
olm.olmId
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (isAdd) {
|
||||
@@ -685,34 +635,23 @@ async function handleMessagesForSiteClients(
|
||||
continue;
|
||||
}
|
||||
|
||||
olmPeerAddHandshakes.push({
|
||||
clientId: client.clientId,
|
||||
peer: {
|
||||
await initPeerAddHandshake(
|
||||
// this will kick off the add peer process for the client
|
||||
client.clientId,
|
||||
{
|
||||
siteId,
|
||||
exitNode: {
|
||||
publicKey: exitNode.publicKey,
|
||||
endpoint: exitNode.endpoint
|
||||
}
|
||||
},
|
||||
olmId
|
||||
});
|
||||
olm.olmId
|
||||
);
|
||||
}
|
||||
|
||||
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) => {
|
||||
logger.error(
|
||||
`rebuildClientAssociations: Error updating client site destinations for site ${site.siteId}:`,
|
||||
@@ -873,20 +812,6 @@ async function handleSubnetProxyTargetUpdates(
|
||||
): Promise<void> {
|
||||
const proxyJobs: 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) {
|
||||
const siteId = siteData.siteId;
|
||||
@@ -918,25 +843,25 @@ async function handleSubnetProxyTargetUpdates(
|
||||
);
|
||||
|
||||
if (targetsToAdd) {
|
||||
targetsToAddBatch.push({
|
||||
newtId: newt.newtId,
|
||||
targets: targetsToAdd,
|
||||
version: newt.version
|
||||
});
|
||||
proxyJobs.push(
|
||||
addSubnetProxyTargets(
|
||||
newt.newtId,
|
||||
targetsToAdd,
|
||||
newt.version
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
olmJobs.push(
|
||||
addPeerDataBatch(
|
||||
addedClients.map((client) => ({
|
||||
clientId: client.clientId,
|
||||
for (const client of addedClients) {
|
||||
olmJobs.push(
|
||||
addPeerData(
|
||||
client.clientId,
|
||||
siteId,
|
||||
remoteSubnets: generateRemoteSubnets([
|
||||
siteResource
|
||||
]),
|
||||
aliases: generateAliasConfig([siteResource])
|
||||
}))
|
||||
)
|
||||
);
|
||||
generateRemoteSubnets([siteResource]),
|
||||
generateAliasConfig([siteResource])
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -955,20 +880,15 @@ async function handleSubnetProxyTargetUpdates(
|
||||
);
|
||||
|
||||
if (targetsToRemove) {
|
||||
targetsToRemoveBatch.push({
|
||||
newtId: newt.newtId,
|
||||
targets: targetsToRemove,
|
||||
version: newt.version
|
||||
});
|
||||
proxyJobs.push(
|
||||
removeSubnetProxyTargets(
|
||||
newt.newtId,
|
||||
targetsToRemove,
|
||||
newt.version
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const peerDataRemovals: {
|
||||
clientId: number;
|
||||
siteId: number;
|
||||
remoteSubnets: string[];
|
||||
aliases: ReturnType<typeof generateAliasConfig>;
|
||||
}[] = [];
|
||||
|
||||
for (const client of removedClients) {
|
||||
if (!siteResource.destination) {
|
||||
continue;
|
||||
@@ -1016,58 +936,31 @@ async function handleSubnetProxyTargetUpdates(
|
||||
? []
|
||||
: generateRemoteSubnets([siteResource]);
|
||||
|
||||
peerDataRemovals.push({
|
||||
clientId: client.clientId,
|
||||
siteId,
|
||||
remoteSubnets: remoteSubnetsToRemove,
|
||||
aliases: generateAliasConfig([siteResource])
|
||||
});
|
||||
}
|
||||
|
||||
if (peerDataRemovals.length > 0) {
|
||||
olmJobs.push(removePeerDataBatch(peerDataRemovals));
|
||||
olmJobs.push(
|
||||
removePeerData(
|
||||
client.clientId,
|
||||
siteId,
|
||||
remoteSubnetsToRemove,
|
||||
generateAliasConfig([siteResource])
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (targetsToAddBatch.length > 0) {
|
||||
proxyJobs.push(addSubnetProxyTargetsBatch(targetsToAddBatch));
|
||||
}
|
||||
|
||||
if (targetsToRemoveBatch.length > 0) {
|
||||
proxyJobs.push(removeSubnetProxyTargetsBatch(targetsToRemoveBatch));
|
||||
}
|
||||
|
||||
await Promise.all([...proxyJobs, ...olmJobs]);
|
||||
await Promise.all(proxyJobs);
|
||||
}
|
||||
|
||||
export async function rebuildClientAssociationsFromClient(
|
||||
client: Client,
|
||||
trx: Transaction | typeof db = db
|
||||
): Promise<void> {
|
||||
try {
|
||||
return await lockManager.withLock(
|
||||
`rebuild-client-associations:client:${client.clientId}`,
|
||||
() => 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;
|
||||
}
|
||||
return await lockManager.withLock(
|
||||
`rebuild-client-associations:client:${client.clientId}`,
|
||||
() => rebuildClientAssociationsFromClientImpl(client, trx),
|
||||
REBUILD_ASSOCIATIONS_LOCK_TTL_MS
|
||||
);
|
||||
}
|
||||
|
||||
async function rebuildClientAssociationsFromClientImpl(
|
||||
@@ -1344,28 +1237,6 @@ async function handleMessagesForClientSites(
|
||||
const newtJobs: Promise<any>[] = [];
|
||||
const olmJobs: 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
|
||||
.select({ count: count(clientSitesAssociationsCache.siteId) })
|
||||
@@ -1397,19 +1268,19 @@ async function handleMessagesForClientSites(
|
||||
|
||||
if (isRemove) {
|
||||
// Remove peer from newt
|
||||
newtPeerDeletes.push({
|
||||
siteId: site.siteId,
|
||||
publicKey: client.pubKey,
|
||||
newtId: newt.newtId
|
||||
});
|
||||
newtJobs.push(
|
||||
newtDeletePeer(site.siteId, client.pubKey, newt.newtId)
|
||||
);
|
||||
try {
|
||||
// Remove peer from olm
|
||||
olmPeerDeletes.push({
|
||||
clientId: client.clientId,
|
||||
siteId: site.siteId,
|
||||
publicKey: site.publicKey,
|
||||
olmId
|
||||
});
|
||||
olmJobs.push(
|
||||
olmDeletePeer(
|
||||
client.clientId,
|
||||
site.siteId,
|
||||
site.publicKey,
|
||||
olmId
|
||||
)
|
||||
);
|
||||
} 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 (
|
||||
@@ -1441,9 +1312,10 @@ async function handleMessagesForClientSites(
|
||||
continue;
|
||||
}
|
||||
|
||||
olmPeerAddHandshakes.push({
|
||||
clientId: client.clientId,
|
||||
peer: {
|
||||
await initPeerAddHandshake(
|
||||
// this will kick off the add peer process for the client
|
||||
client.clientId,
|
||||
{
|
||||
siteId: site.siteId,
|
||||
exitNode: {
|
||||
publicKey: exitNode.publicKey,
|
||||
@@ -1451,7 +1323,7 @@ async function handleMessagesForClientSites(
|
||||
}
|
||||
},
|
||||
olmId
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
// Update exit node destinations
|
||||
@@ -1467,18 +1339,6 @@ 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) => {
|
||||
logger.error(
|
||||
`rebuildClientAssociations: Error updating client site destinations for client ${client.clientId}:`,
|
||||
@@ -1577,20 +1437,6 @@ async function handleMessagesForClientResources(
|
||||
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) {
|
||||
const targets = await generateSubnetProxyTargetV2(resource, [
|
||||
{
|
||||
@@ -1601,21 +1447,25 @@ async function handleMessagesForClientResources(
|
||||
]);
|
||||
|
||||
if (targets) {
|
||||
targetsToAddBatch.push({
|
||||
newtId: newt.newtId,
|
||||
targets,
|
||||
version: newt.version
|
||||
});
|
||||
proxyJobs.push(
|
||||
addSubnetProxyTargets(
|
||||
newt.newtId,
|
||||
targets,
|
||||
newt.version
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Add peer data to olm
|
||||
peerDataAdds.push({
|
||||
clientId: client.clientId,
|
||||
siteId,
|
||||
remoteSubnets: generateRemoteSubnets([resource]),
|
||||
aliases: generateAliasConfig([resource])
|
||||
});
|
||||
olmJobs.push(
|
||||
addPeerData(
|
||||
client.clientId,
|
||||
siteId,
|
||||
generateRemoteSubnets([resource]),
|
||||
generateAliasConfig([resource])
|
||||
)
|
||||
);
|
||||
} 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 (
|
||||
@@ -1630,14 +1480,6 @@ async function handleMessagesForClientResources(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (targetsToAddBatch.length > 0) {
|
||||
proxyJobs.push(addSubnetProxyTargetsBatch(targetsToAddBatch));
|
||||
}
|
||||
|
||||
if (peerDataAdds.length > 0) {
|
||||
olmJobs.push(addPeerDataBatch(peerDataAdds));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1704,20 +1546,6 @@ async function handleMessagesForClientResources(
|
||||
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) {
|
||||
const targets = await generateSubnetProxyTargetV2(resource, [
|
||||
{
|
||||
@@ -1728,11 +1556,13 @@ async function handleMessagesForClientResources(
|
||||
]);
|
||||
|
||||
if (targets) {
|
||||
targetsToRemoveBatch.push({
|
||||
newtId: newt.newtId,
|
||||
targets,
|
||||
version: newt.version
|
||||
});
|
||||
proxyJobs.push(
|
||||
removeSubnetProxyTargets(
|
||||
newt.newtId,
|
||||
targets,
|
||||
newt.version
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -1783,12 +1613,14 @@ async function handleMessagesForClientResources(
|
||||
: generateRemoteSubnets([resource]);
|
||||
|
||||
// Remove peer data from olm
|
||||
peerDataRemovals.push({
|
||||
clientId: client.clientId,
|
||||
siteId,
|
||||
remoteSubnets: remoteSubnetsToRemove,
|
||||
aliases: generateAliasConfig([resource])
|
||||
});
|
||||
olmJobs.push(
|
||||
removePeerData(
|
||||
client.clientId,
|
||||
siteId,
|
||||
remoteSubnetsToRemove,
|
||||
generateAliasConfig([resource])
|
||||
)
|
||||
);
|
||||
} 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 (
|
||||
@@ -1803,16 +1635,6 @@ async function handleMessagesForClientResources(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (targetsToRemoveBatch.length > 0) {
|
||||
proxyJobs.push(
|
||||
removeSubnetProxyTargetsBatch(targetsToRemoveBatch)
|
||||
);
|
||||
}
|
||||
|
||||
if (peerDataRemovals.length > 0) {
|
||||
olmJobs.push(removePeerDataBatch(peerDataRemovals));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2062,20 +1884,11 @@ export async function cleanupSiteAssociations(
|
||||
|
||||
// 7. Fire all removal messages in parallel.
|
||||
const jobs: Promise<any>[] = [];
|
||||
const olmPeerDeletes: {
|
||||
clientId: number;
|
||||
siteId: number;
|
||||
publicKey: string;
|
||||
}[] = [];
|
||||
|
||||
for (const client of allClients) {
|
||||
// Tell each olm to drop the site's WireGuard peer.
|
||||
if (site.publicKey) {
|
||||
olmPeerDeletes.push({
|
||||
clientId: client.clientId,
|
||||
siteId,
|
||||
publicKey: site.publicKey
|
||||
});
|
||||
jobs.push(olmDeletePeer(client.clientId, siteId, site.publicKey));
|
||||
}
|
||||
|
||||
// Recompute and push updated relay destinations (now excluding this site).
|
||||
@@ -2084,10 +1897,6 @@ export async function cleanupSiteAssociations(
|
||||
}
|
||||
}
|
||||
|
||||
if (olmPeerDeletes.length > 0) {
|
||||
jobs.push(olmDeletePeersBatch(olmPeerDeletes));
|
||||
}
|
||||
|
||||
await Promise.all(jobs).catch((error) => {
|
||||
logger.error(
|
||||
`cleanupSiteAssociations: error sending cleanup messages for siteId=${siteId}:`,
|
||||
@@ -2097,47 +1906,3 @@ export async function cleanupSiteAssociations(
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
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();
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
getResourceRuleValueValidationError,
|
||||
isValidUrlGlobPattern
|
||||
} from "./validators";
|
||||
import { isValidUrlGlobPattern } from "./validators";
|
||||
import { assertEquals } from "@test/assert";
|
||||
|
||||
function runTests() {
|
||||
@@ -239,43 +236,6 @@ function runTests() {
|
||||
"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!");
|
||||
}
|
||||
|
||||
|
||||
@@ -100,10 +100,7 @@ export function getResourceRuleValueValidationError(
|
||||
? null
|
||||
: "Invalid country code provided";
|
||||
case "ASN":
|
||||
const normalizedValue = value.trim().toUpperCase();
|
||||
return /^AS\d+$/.test(normalizedValue) ||
|
||||
normalizedValue === "ALL" ||
|
||||
normalizedValue === "AS0"
|
||||
return /^AS\d+$/i.test(value.trim())
|
||||
? null
|
||||
: "Invalid ASN provided";
|
||||
default:
|
||||
|
||||
@@ -17,7 +17,7 @@ import { certificates, db } from "@server/db";
|
||||
import { and, eq, isNotNull, or, inArray, sql } from "drizzle-orm";
|
||||
import { decrypt } from "@server/lib/crypto";
|
||||
import logger from "@server/logger";
|
||||
import { regionalCache as cache } from "#private/lib/cache";
|
||||
import cache from "#private/lib/cache";
|
||||
import { build } from "@server/build";
|
||||
|
||||
// Define the return type for clarity and type safety
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
/*
|
||||
* 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();
|
||||
@@ -22,7 +22,7 @@ import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { ListRemoteExitNodesResponse } from "@server/routers/remoteExitNode/types";
|
||||
import { regionalCache as cache } from "#private/lib/cache";
|
||||
import cache from "#private/lib/cache";
|
||||
import semver from "semver";
|
||||
|
||||
let stalePangolinNodeVersion: string | null = null;
|
||||
|
||||
@@ -38,7 +38,6 @@ import { messageHandlers } from "@server/routers/ws/messageHandlers";
|
||||
import { messageHandlers as privateMessageHandlers } from "#private/routers/ws/messageHandlers";
|
||||
import {
|
||||
AuthenticatedWebSocket,
|
||||
BatchSendMessage,
|
||||
ClientType,
|
||||
WSMessage,
|
||||
TokenPayload,
|
||||
@@ -188,8 +187,6 @@ const wss: WebSocketServer = new WebSocketServer({ noServer: true });
|
||||
// Generate unique node ID for this instance
|
||||
const NODE_ID = uuidv4();
|
||||
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)
|
||||
const connectedClients: Map<string, AuthenticatedWebSocket[]> = new Map();
|
||||
@@ -200,15 +197,6 @@ const clientConfigVersions: Map<string, number> = new Map();
|
||||
// Recovery tracking
|
||||
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
|
||||
const getClientMapKey = (clientId: string) => clientId;
|
||||
|
||||
@@ -219,78 +207,6 @@ const getNodeConnectionsKey = (nodeId: string, clientId: string) =>
|
||||
const getConfigVersionKey = (clientId: string) =>
|
||||
`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
|
||||
const initializeRedisSubscription = async (): Promise<void> => {
|
||||
if (!redisManager.isRedisEnabled()) return;
|
||||
@@ -311,16 +227,7 @@ const initializeRedisSubscription = async (): Promise<void> => {
|
||||
// Send to specific client on this node
|
||||
await sendToClientLocal(
|
||||
redisMessage.targetClientId,
|
||||
redisMessage.message,
|
||||
{},
|
||||
redisMessage.message.configVersion
|
||||
);
|
||||
} else if (
|
||||
redisMessage.type === "direct-batch" &&
|
||||
redisMessage.messages
|
||||
) {
|
||||
await sendRedisDirectBatchToLocalClients(
|
||||
redisMessage.messages
|
||||
redisMessage.message
|
||||
);
|
||||
} else if (redisMessage.type === "broadcast") {
|
||||
// Broadcast to all clients on this node except excluded
|
||||
@@ -596,8 +503,7 @@ const incrementClientConfigVersion = async (
|
||||
const sendToClientLocal = async (
|
||||
clientId: string,
|
||||
message: WSMessage,
|
||||
options: SendMessageOptions = {},
|
||||
preResolvedConfigVersion?: number
|
||||
options: SendMessageOptions = {}
|
||||
): Promise<boolean> => {
|
||||
const mapKey = getClientMapKey(clientId);
|
||||
const clients = connectedClients.get(mapKey);
|
||||
@@ -606,8 +512,7 @@ const sendToClientLocal = async (
|
||||
}
|
||||
|
||||
// Handle config version
|
||||
const configVersion =
|
||||
preResolvedConfigVersion ?? (await getClientConfigVersion(clientId));
|
||||
const configVersion = await getClientConfigVersion(clientId);
|
||||
|
||||
// Add config version to message
|
||||
const messageWithVersion = {
|
||||
@@ -640,71 +545,43 @@ const sendToClientLocal = async (
|
||||
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 (
|
||||
message: WSMessage,
|
||||
excludeClientId?: string,
|
||||
options: SendMessageOptions = {}
|
||||
): Promise<void> => {
|
||||
const sendPlans = await Promise.all(
|
||||
Array.from(connectedClients.entries()).map(
|
||||
async ([mapKey, clients]) => {
|
||||
const clientId = mapKey; // mapKey is the clientId
|
||||
if (excludeClientId && clientId === excludeClientId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let configVersion = await getClientConfigVersion(clientId);
|
||||
if (options.incrementConfigVersion) {
|
||||
configVersion =
|
||||
await incrementClientConfigVersion(clientId);
|
||||
}
|
||||
|
||||
return {
|
||||
clients,
|
||||
messageWithVersion: {
|
||||
...message,
|
||||
configVersion
|
||||
}
|
||||
};
|
||||
for (const [mapKey, clients] of connectedClients.entries()) {
|
||||
const [type, id] = mapKey.split(":");
|
||||
const clientId = mapKey; // mapKey is the clientId
|
||||
if (!(excludeClientId && clientId === excludeClientId)) {
|
||||
// Handle config version per client
|
||||
let configVersion = await getClientConfigVersion(clientId);
|
||||
if (options.incrementConfigVersion) {
|
||||
configVersion = await incrementClientConfigVersion(clientId);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
for (const plan of sendPlans) {
|
||||
if (!plan) {
|
||||
continue;
|
||||
}
|
||||
// Add config version to message
|
||||
const messageWithVersion = {
|
||||
...message,
|
||||
configVersion
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
if (options.compress) {
|
||||
const compressed = zlib.gzipSync(
|
||||
Buffer.from(JSON.stringify(messageWithVersion), "utf8")
|
||||
);
|
||||
clients.forEach((client) => {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(compressed);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
clients.forEach((client) => {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(JSON.stringify(messageWithVersion));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -725,23 +602,28 @@ const sendToClient = async (
|
||||
);
|
||||
|
||||
// Try to send locally first
|
||||
const localSent = await sendToClientLocal(
|
||||
clientId,
|
||||
message,
|
||||
options,
|
||||
configVersion
|
||||
);
|
||||
const localSent = await sendToClientLocal(clientId, message, options);
|
||||
|
||||
// Only send via Redis if the client is not connected locally and Redis is enabled
|
||||
if (!localSent && redisManager.isRedisEnabled()) {
|
||||
try {
|
||||
await enqueueRedisDirectMessage(clientId, {
|
||||
...message,
|
||||
configVersion
|
||||
});
|
||||
const redisMessage: RedisMessage = {
|
||||
type: "direct",
|
||||
targetClientId: clientId,
|
||||
message: {
|
||||
...message,
|
||||
configVersion
|
||||
},
|
||||
fromNodeId: NODE_ID
|
||||
};
|
||||
|
||||
await redisManager.publish(
|
||||
REDIS_CHANNEL,
|
||||
JSON.stringify(redisMessage)
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
"Failed to queue batched direct message for Redis delivery, message may be lost:",
|
||||
"Failed to send message via Redis, message may be lost:",
|
||||
error
|
||||
);
|
||||
// Continue execution - local delivery already attempted
|
||||
@@ -756,95 +638,6 @@ const sendToClient = async (
|
||||
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 (
|
||||
message: WSMessage,
|
||||
excludeClientId?: string,
|
||||
@@ -1316,8 +1109,6 @@ const disconnectClient = async (clientId: string): Promise<boolean> => {
|
||||
// Cleanup function for graceful shutdown
|
||||
const cleanup = async (): Promise<void> => {
|
||||
try {
|
||||
await flushPendingRedisDirectMessages();
|
||||
|
||||
// Close all WebSocket connections
|
||||
connectedClients.forEach((clients) => {
|
||||
clients.forEach((client) => {
|
||||
@@ -1348,7 +1139,6 @@ export {
|
||||
router,
|
||||
handleWSUpgrade,
|
||||
sendToClient,
|
||||
sendToClientsBatch,
|
||||
broadcastToAllExcept,
|
||||
connectedClients,
|
||||
hasActiveConnections,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { assertEquals } from "@test/assert";
|
||||
import { REGIONS } from "@server/db/regions";
|
||||
import { isPathAllowed } from "@server/lib/pathMatch";
|
||||
|
||||
function isIpInRegion(
|
||||
ipCountryCode: string | undefined,
|
||||
@@ -34,6 +33,76 @@ function isIpInRegion(
|
||||
return false;
|
||||
}
|
||||
|
||||
function isPathAllowed(pattern: string, path: string): boolean {
|
||||
// Normalize and split paths into segments
|
||||
const normalize = (p: string) => p.split("/").filter(Boolean);
|
||||
const patternParts = normalize(pattern);
|
||||
const pathParts = normalize(path);
|
||||
|
||||
// Recursive function to try different wildcard matches
|
||||
function matchSegments(patternIndex: number, pathIndex: number): boolean {
|
||||
const indent = " ".repeat(pathIndex); // Indent based on recursion depth
|
||||
const currentPatternPart = patternParts[patternIndex];
|
||||
const currentPathPart = pathParts[pathIndex];
|
||||
|
||||
// If we've consumed all pattern parts, we should have consumed all path parts
|
||||
if (patternIndex >= patternParts.length) {
|
||||
const result = pathIndex >= pathParts.length;
|
||||
return result;
|
||||
}
|
||||
|
||||
// If we've consumed all path parts but still have pattern parts
|
||||
if (pathIndex >= pathParts.length) {
|
||||
// The only way this can match is if all remaining pattern parts are wildcards
|
||||
const remainingPattern = patternParts.slice(patternIndex);
|
||||
const result = remainingPattern.every((p) => p === "*");
|
||||
return result;
|
||||
}
|
||||
|
||||
// For full segment wildcards, try consuming different numbers of path segments
|
||||
if (currentPatternPart === "*") {
|
||||
// Try consuming 0 segments (skip the wildcard)
|
||||
if (matchSegments(patternIndex + 1, pathIndex)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Try consuming current segment and recursively try rest
|
||||
if (matchSegments(patternIndex, pathIndex + 1)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for in-segment wildcard (e.g., "prefix*" or "prefix*suffix")
|
||||
if (currentPatternPart.includes("*")) {
|
||||
// Convert the pattern segment to a regex pattern
|
||||
const regexPattern = currentPatternPart
|
||||
.replace(/\*/g, ".*") // Replace * with .* for regex wildcard
|
||||
.replace(/\?/g, "."); // Replace ? with . for single character wildcard if needed
|
||||
|
||||
const regex = new RegExp(`^${regexPattern}$`);
|
||||
|
||||
if (regex.test(currentPathPart)) {
|
||||
return matchSegments(patternIndex + 1, pathIndex + 1);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// For regular segments, they must match exactly
|
||||
if (currentPatternPart !== currentPathPart) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Move to next segments in both pattern and path
|
||||
return matchSegments(patternIndex + 1, pathIndex + 1);
|
||||
}
|
||||
|
||||
const result = matchSegments(0, 0);
|
||||
return result;
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log("Running path matching tests...");
|
||||
|
||||
@@ -239,121 +308,6 @@ function runTests() {
|
||||
console.log("All path matching tests passed!");
|
||||
}
|
||||
|
||||
function runSpecialCharacterTests() {
|
||||
console.log("\nRunning special character tests...");
|
||||
|
||||
let threw = false;
|
||||
try {
|
||||
isPathAllowed("(api*", "anything");
|
||||
isPathAllowed("a(b*", "a(bc");
|
||||
isPathAllowed("c[d*", "c[de");
|
||||
isPathAllowed("x{2}*", "x{2}y");
|
||||
isPathAllowed("a|b*", "a|bc");
|
||||
isPathAllowed("back\\slash*", "back\\slashed");
|
||||
} catch (e) {
|
||||
threw = true;
|
||||
console.error(
|
||||
"Patterns accepted by isValidUrlGlobPattern crashed the matcher:",
|
||||
e instanceof Error ? e.message : e
|
||||
);
|
||||
}
|
||||
assertEquals(
|
||||
threw,
|
||||
false,
|
||||
"Patterns with regex metacharacters must not throw"
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
isPathAllowed("(api*", "(api-v1"),
|
||||
true,
|
||||
"Parenthesis should be treated as a literal character"
|
||||
);
|
||||
assertEquals(
|
||||
isPathAllowed("(api*", "xapi-v1"),
|
||||
false,
|
||||
"Parenthesis should not match other characters"
|
||||
);
|
||||
assertEquals(
|
||||
isPathAllowed("a(b)*", "a(b)c"),
|
||||
true,
|
||||
"Parentheses pair should be treated as literal characters"
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
isPathAllowed("*.png", "image.png"),
|
||||
true,
|
||||
"Dot should match a literal dot"
|
||||
);
|
||||
assertEquals(
|
||||
isPathAllowed("*.png", "imageXpng"),
|
||||
false,
|
||||
"Dot should not act as a regex wildcard"
|
||||
);
|
||||
assertEquals(
|
||||
isPathAllowed("v1.0*", "v1.0.1"),
|
||||
true,
|
||||
"Version-like literal should match itself"
|
||||
);
|
||||
assertEquals(
|
||||
isPathAllowed("v1.0*", "v1x0-beta"),
|
||||
false,
|
||||
"Version-like literal should not match arbitrary characters"
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
isPathAllowed("a+b*", "a+bc"),
|
||||
true,
|
||||
"Plus should be treated as a literal character"
|
||||
);
|
||||
assertEquals(
|
||||
isPathAllowed("a+b*", "aaabc"),
|
||||
false,
|
||||
"Plus should not act as a regex quantifier"
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
isPathAllowed("$ref*", "$refs"),
|
||||
true,
|
||||
"Dollar sign should be treated as a literal character"
|
||||
);
|
||||
assertEquals(
|
||||
isPathAllowed("price$*", "price$100"),
|
||||
true,
|
||||
"Dollar sign mid-pattern should be treated as a literal character"
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
isPathAllowed("^start*", "^started"),
|
||||
true,
|
||||
"Caret should be treated as a literal character"
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
isPathAllowed("a|b*", "a|bc"),
|
||||
true,
|
||||
"Pipe should be treated as a literal character"
|
||||
);
|
||||
assertEquals(
|
||||
isPathAllowed("a|b*", "a"),
|
||||
false,
|
||||
"Pipe should not act as regex alternation"
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
isPathAllowed("file?*", "fileX"),
|
||||
true,
|
||||
"Question mark should still act as a single-character wildcard"
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
isPathAllowed("api/*", "api/" + "x/".repeat(50)),
|
||||
true,
|
||||
"Deeply nested paths should still match"
|
||||
);
|
||||
|
||||
console.log("All special character tests passed!");
|
||||
}
|
||||
|
||||
function runRegionTests() {
|
||||
console.log("\nRunning isIpInRegion tests...");
|
||||
|
||||
@@ -413,7 +367,6 @@ function runRegionTests() {
|
||||
// Run all tests
|
||||
try {
|
||||
runTests();
|
||||
runSpecialCharacterTests();
|
||||
runRegionTests();
|
||||
console.log("\n✅ All tests passed!");
|
||||
} catch (error) {
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
} from "@server/db";
|
||||
import config from "@server/lib/config";
|
||||
import { isIpInCidr, stripPortFromHost } from "@server/lib/ip";
|
||||
import { isPathAllowed } from "@server/lib/pathMatch";
|
||||
import { response } from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
@@ -1091,7 +1090,143 @@ async function checkRules(
|
||||
return;
|
||||
}
|
||||
|
||||
export { isPathAllowed };
|
||||
export function isPathAllowed(pattern: string, path: string): boolean {
|
||||
logger.debug(`\nMatching path "${path}" against pattern "${pattern}"`);
|
||||
|
||||
// Normalize and split paths into segments
|
||||
const normalize = (p: string) => p.split("/").filter(Boolean);
|
||||
const patternParts = normalize(pattern);
|
||||
const pathParts = normalize(path);
|
||||
|
||||
logger.debug(`Normalized pattern parts: [${patternParts.join(", ")}]`);
|
||||
logger.debug(`Normalized path parts: [${pathParts.join(", ")}]`);
|
||||
|
||||
// Maximum recursion depth to prevent stack overflow and memory issues
|
||||
const MAX_RECURSION_DEPTH = 100;
|
||||
|
||||
// Recursive function to try different wildcard matches
|
||||
function matchSegments(
|
||||
patternIndex: number,
|
||||
pathIndex: number,
|
||||
depth: number = 0
|
||||
): boolean {
|
||||
// Check recursion depth limit
|
||||
if (depth > MAX_RECURSION_DEPTH) {
|
||||
logger.warn(
|
||||
`Path matching exceeded maximum recursion depth (${MAX_RECURSION_DEPTH}) for pattern "${pattern}" and path "${path}"`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const indent = " ".repeat(depth); // Indent based on recursion depth
|
||||
const currentPatternPart = patternParts[patternIndex];
|
||||
const currentPathPart = pathParts[pathIndex];
|
||||
|
||||
logger.debug(
|
||||
`${indent}Checking patternIndex=${patternIndex} (${currentPatternPart || "END"}) vs pathIndex=${pathIndex} (${currentPathPart || "END"}) [depth=${depth}]`
|
||||
);
|
||||
|
||||
// If we've consumed all pattern parts, we should have consumed all path parts
|
||||
if (patternIndex >= patternParts.length) {
|
||||
const result = pathIndex >= pathParts.length;
|
||||
logger.debug(
|
||||
`${indent}Reached end of pattern, remaining path: ${pathParts.slice(pathIndex).join("/")} -> ${result}`
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
// If we've consumed all path parts but still have pattern parts
|
||||
if (pathIndex >= pathParts.length) {
|
||||
// The only way this can match is if all remaining pattern parts are wildcards
|
||||
const remainingPattern = patternParts.slice(patternIndex);
|
||||
const result = remainingPattern.every((p) => p === "*");
|
||||
logger.debug(
|
||||
`${indent}Reached end of path, remaining pattern: ${remainingPattern.join("/")} -> ${result}`
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
// For full segment wildcards, try consuming different numbers of path segments
|
||||
if (currentPatternPart === "*") {
|
||||
logger.debug(
|
||||
`${indent}Found wildcard at pattern index ${patternIndex}`
|
||||
);
|
||||
|
||||
// Try consuming 0 segments (skip the wildcard)
|
||||
logger.debug(
|
||||
`${indent}Trying to skip wildcard (consume 0 segments)`
|
||||
);
|
||||
if (matchSegments(patternIndex + 1, pathIndex, depth + 1)) {
|
||||
logger.debug(
|
||||
`${indent}Successfully matched by skipping wildcard`
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Try consuming current segment and recursively try rest
|
||||
logger.debug(
|
||||
`${indent}Trying to consume segment "${currentPathPart}" for wildcard`
|
||||
);
|
||||
if (matchSegments(patternIndex, pathIndex + 1, depth + 1)) {
|
||||
logger.debug(
|
||||
`${indent}Successfully matched by consuming segment for wildcard`
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.debug(`${indent}Failed to match wildcard`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for in-segment wildcard (e.g., "prefix*" or "prefix*suffix")
|
||||
if (currentPatternPart.includes("*")) {
|
||||
logger.debug(
|
||||
`${indent}Found in-segment wildcard in "${currentPatternPart}"`
|
||||
);
|
||||
|
||||
// Convert the pattern segment to a regex pattern
|
||||
const regexPattern = currentPatternPart
|
||||
.replace(/\*/g, ".*") // Replace * with .* for regex wildcard
|
||||
.replace(/\?/g, "."); // Replace ? with . for single character wildcard if needed
|
||||
|
||||
const regex = new RegExp(`^${regexPattern}$`);
|
||||
|
||||
if (regex.test(currentPathPart)) {
|
||||
logger.debug(
|
||||
`${indent}Segment with wildcard matches: "${currentPatternPart}" matches "${currentPathPart}"`
|
||||
);
|
||||
return matchSegments(
|
||||
patternIndex + 1,
|
||||
pathIndex + 1,
|
||||
depth + 1
|
||||
);
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`${indent}Segment with wildcard mismatch: "${currentPatternPart}" doesn't match "${currentPathPart}"`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// For regular segments, they must match exactly
|
||||
if (currentPatternPart !== currentPathPart) {
|
||||
logger.debug(
|
||||
`${indent}Segment mismatch: "${currentPatternPart}" != "${currentPathPart}"`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`${indent}Segments match: "${currentPatternPart}" = "${currentPathPart}"`
|
||||
);
|
||||
// Move to next segments in both pattern and path
|
||||
return matchSegments(patternIndex + 1, pathIndex + 1, depth + 1);
|
||||
}
|
||||
|
||||
const result = matchSegments(0, 0, 0);
|
||||
logger.debug(`Final result: ${result}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function isIpInGeoIP(
|
||||
ipCountryCode: string | undefined,
|
||||
|
||||
@@ -420,6 +420,31 @@ export async function listUserDevices(
|
||||
}
|
||||
);
|
||||
|
||||
// REMOVING THIS BECAUSE WE HAVE DIFFERENT TYPES OF CLIENTS NOW
|
||||
// // Try to get the latest version, but don't block if it fails
|
||||
// try {
|
||||
// const latestOlmVersion = await getLatestOlmVersion();
|
||||
|
||||
// if (latestOlmVersion) {
|
||||
// olmsWithUpdates.forEach((client) => {
|
||||
// try {
|
||||
// client.olmUpdateAvailable = semver.lt(
|
||||
// client.olmVersion ? client.olmVersion : "",
|
||||
// latestOlmVersion
|
||||
// );
|
||||
// } catch (error) {
|
||||
// client.olmUpdateAvailable = false;
|
||||
// }
|
||||
// });
|
||||
// }
|
||||
// } catch (error) {
|
||||
// // Log the error but don't let it block the response
|
||||
// logger.warn(
|
||||
// "Failed to check for OLM updates, continuing without update info:",
|
||||
// error
|
||||
// );
|
||||
// }
|
||||
|
||||
return response<ListUserDevicesResponse>(res, {
|
||||
data: {
|
||||
devices: olmsWithUpdates,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { sendToClient, sendToClientsBatch } from "#dynamic/routers/ws";
|
||||
import { sendToClient } from "#dynamic/routers/ws";
|
||||
import { db, newts, olms } from "@server/db";
|
||||
import {
|
||||
Alias,
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "@server/lib/ip";
|
||||
import { canCompress } from "@server/lib/clientVersionChecks";
|
||||
import logger from "@server/logger";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import { eq } from "drizzle-orm";
|
||||
import semver from "semver";
|
||||
|
||||
const NEWT_V2_TARGETS_VERSION = ">=1.10.3";
|
||||
@@ -59,42 +59,6 @@ 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(
|
||||
newtId: string,
|
||||
targets: SubnetProxyTarget[] | SubnetProxyTargetV2[],
|
||||
@@ -112,42 +76,6 @@ 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(
|
||||
newtId: string,
|
||||
targets: {
|
||||
@@ -273,171 +201,6 @@ 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(
|
||||
clientId: number,
|
||||
siteId: number,
|
||||
|
||||
@@ -10,7 +10,7 @@ import { verifyPassword } from "@server/auth/password";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import logger from "@server/logger";
|
||||
import { regionalCache as cache } from "#dynamic/lib/cache";
|
||||
import cache from "#dynamic/lib/cache";
|
||||
import config from "@server/lib/config";
|
||||
|
||||
// Stale-while-revalidate in-memory fallback for the releases API.
|
||||
|
||||
@@ -2,7 +2,7 @@ import { MessageHandler } from "@server/routers/ws";
|
||||
import logger from "@server/logger";
|
||||
import { Newt } from "@server/db";
|
||||
import { applyNewtDockerBlueprint } from "@server/lib/blueprints/applyNewtDockerBlueprint";
|
||||
import cache from "#dynamic/lib/cache"; // not using regional here because we dont know where the site is
|
||||
import cache from "#dynamic/lib/cache";
|
||||
|
||||
export const handleDockerStatusMessage: MessageHandler = async (context) => {
|
||||
const { message, client, sendToClient } = context;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { db, Site } from "@server/db";
|
||||
import { newts, sites } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { sendToClient, sendToClientsBatch } from "#dynamic/routers/ws";
|
||||
import { sendToClient } from "#dynamic/routers/ws";
|
||||
import logger from "@server/logger";
|
||||
|
||||
export async function addPeer(
|
||||
@@ -36,14 +36,10 @@ export async function addPeer(
|
||||
newtId = newt.newtId;
|
||||
}
|
||||
|
||||
await sendToClient(
|
||||
newtId,
|
||||
{
|
||||
type: "newt/wg/peer/add",
|
||||
data: peer
|
||||
},
|
||||
{ incrementConfigVersion: true }
|
||||
).catch((error) => {
|
||||
await sendToClient(newtId, {
|
||||
type: "newt/wg/peer/add",
|
||||
data: peer
|
||||
}, { incrementConfigVersion: true }).catch((error) => {
|
||||
logger.warn(`Error sending message:`, error);
|
||||
});
|
||||
|
||||
@@ -80,16 +76,12 @@ export async function deletePeer(
|
||||
newtId = newt.newtId;
|
||||
}
|
||||
|
||||
await sendToClient(
|
||||
newtId,
|
||||
{
|
||||
type: "newt/wg/peer/remove",
|
||||
data: {
|
||||
publicKey
|
||||
}
|
||||
},
|
||||
{ incrementConfigVersion: true }
|
||||
).catch((error) => {
|
||||
await sendToClient(newtId, {
|
||||
type: "newt/wg/peer/remove",
|
||||
data: {
|
||||
publicKey
|
||||
}
|
||||
}, { incrementConfigVersion: true }).catch((error) => {
|
||||
logger.warn(`Error sending message:`, error);
|
||||
});
|
||||
|
||||
@@ -98,35 +90,6 @@ export async function deletePeer(
|
||||
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(
|
||||
siteId: number,
|
||||
publicKey: string,
|
||||
@@ -159,17 +122,13 @@ export async function updatePeer(
|
||||
newtId = newt.newtId;
|
||||
}
|
||||
|
||||
await sendToClient(
|
||||
newtId,
|
||||
{
|
||||
type: "newt/wg/peer/update",
|
||||
data: {
|
||||
publicKey,
|
||||
...peer
|
||||
}
|
||||
},
|
||||
{ incrementConfigVersion: true }
|
||||
).catch((error) => {
|
||||
await sendToClient(newtId, {
|
||||
type: "newt/wg/peer/update",
|
||||
data: {
|
||||
publicKey,
|
||||
...peer
|
||||
}
|
||||
}, { incrementConfigVersion: true }).catch((error) => {
|
||||
logger.warn(`Error sending message:`, error);
|
||||
});
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import { handleFingerprintInsertion } from "./fingerprintingUtils";
|
||||
import { build } from "@server/build";
|
||||
import { canCompress } from "@server/lib/clientVersionChecks";
|
||||
import config from "@server/lib/config";
|
||||
import cache from "#dynamic/lib/cache"; // not using regional here because we need this in the register message handler before we know where the client is
|
||||
import cache from "#dynamic/lib/cache";
|
||||
|
||||
const HOLEPUNCH_STALE_CHAIN_THRESHOLD = 18;
|
||||
const HOLEPUNCH_STALE_CHAIN_TTL_SECONDS = 1800;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { sendToClient, sendToClientsBatch } from "#dynamic/routers/ws";
|
||||
import { sendToClient } from "#dynamic/routers/ws";
|
||||
import { clientSitesAssociationsCache, db, olms } from "@server/db";
|
||||
import { canCompress } from "@server/lib/clientVersionChecks";
|
||||
import config from "@server/lib/config";
|
||||
import logger from "@server/logger";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { Alias } from "yaml";
|
||||
|
||||
export async function addPeer(
|
||||
@@ -205,150 +205,3 @@ export async function initPeerAddHandshake(
|
||||
`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)`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ import logger from "@server/logger";
|
||||
import { z } from "zod";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
import type { PaginatedResponse } from "@server/types/Pagination";
|
||||
import { regionalCache as cache } from "#dynamic/lib/cache";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { localCache } from "#dynamic/lib/cache";
|
||||
|
||||
const USER_RESOURCE_ALIASES_CACHE_TTL_SEC = 60;
|
||||
|
||||
@@ -152,7 +153,7 @@ export async function listUserResourceAliases(
|
||||
pageSize
|
||||
);
|
||||
const cachedData: ListUserResourceAliasesResponse | undefined =
|
||||
await cache.get(cacheKey);
|
||||
localCache.get(cacheKey);
|
||||
|
||||
if (cachedData) {
|
||||
return response<ListUserResourceAliasesResponse>(res, {
|
||||
@@ -210,11 +211,7 @@ export async function listUserResourceAliases(
|
||||
page
|
||||
}
|
||||
};
|
||||
await cache.set(
|
||||
cacheKey,
|
||||
data,
|
||||
USER_RESOURCE_ALIASES_CACHE_TTL_SEC
|
||||
);
|
||||
localCache.set(cacheKey, data, USER_RESOURCE_ALIASES_CACHE_TTL_SEC);
|
||||
return response<ListUserResourceAliasesResponse>(res, {
|
||||
data,
|
||||
success: true,
|
||||
@@ -259,7 +256,7 @@ export async function listUserResourceAliases(
|
||||
page
|
||||
}
|
||||
};
|
||||
await cache.set(cacheKey, data, USER_RESOURCE_ALIASES_CACHE_TTL_SEC);
|
||||
localCache.set(cacheKey, data, USER_RESOURCE_ALIASES_CACHE_TTL_SEC);
|
||||
|
||||
return response<ListUserResourceAliasesResponse>(res, {
|
||||
data,
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
import {
|
||||
db,
|
||||
exitNodes,
|
||||
labels,
|
||||
newts,
|
||||
orgs,
|
||||
remoteExitNodes,
|
||||
roleSites,
|
||||
siteLabels,
|
||||
siteNetworks,
|
||||
siteResources,
|
||||
sites,
|
||||
targets,
|
||||
sites,
|
||||
userSites,
|
||||
labels,
|
||||
siteLabels,
|
||||
type Label
|
||||
} from "@server/db";
|
||||
import { regionalCache as cache } from "#dynamic/lib/cache";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import cache from "#dynamic/lib/cache";
|
||||
import response from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
@@ -24,9 +23,102 @@ import type { PaginatedResponse } from "@server/types/Pagination";
|
||||
import { and, asc, desc, eq, inArray, like, or, sql } from "drizzle-orm";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import semver from "semver";
|
||||
import { z } from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
|
||||
// Stale-while-revalidate: keeps the last successfully fetched version so that
|
||||
// a transient network failure / timeout does not flip every site back to
|
||||
// newtUpdateAvailable: false.
|
||||
let staleNewtVersion: string | null = null;
|
||||
|
||||
async function getLatestNewtVersion(): Promise<string | null> {
|
||||
try {
|
||||
const cachedVersion = await cache.get<string>(
|
||||
"cache:latestNewtVersion"
|
||||
);
|
||||
if (cachedVersion) {
|
||||
return cachedVersion;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 1500);
|
||||
|
||||
const response = await fetch(
|
||||
"https://api.github.com/repos/fosrl/newt/tags",
|
||||
{
|
||||
signal: controller.signal
|
||||
}
|
||||
);
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn(
|
||||
`Failed to fetch latest Newt version from GitHub: ${response.status} ${response.statusText}`
|
||||
);
|
||||
return staleNewtVersion;
|
||||
}
|
||||
|
||||
let tags = await response.json();
|
||||
if (!Array.isArray(tags) || tags.length === 0) {
|
||||
logger.warn("No tags found for Newt repository");
|
||||
return staleNewtVersion;
|
||||
}
|
||||
|
||||
// Remove release-candidates, then sort descending by semver so that
|
||||
// duplicate tags (e.g. "1.10.3" and "v1.10.3") and any ordering quirks
|
||||
// from the GitHub API do not cause an older tag to be selected.
|
||||
tags = tags.filter((tag: any) => !tag.name.includes("rc"));
|
||||
tags.sort((a: any, b: any) => {
|
||||
const va = semver.coerce(a.name);
|
||||
const vb = semver.coerce(b.name);
|
||||
if (!va && !vb) return 0;
|
||||
if (!va) return 1;
|
||||
if (!vb) return -1;
|
||||
return semver.rcompare(va, vb);
|
||||
});
|
||||
|
||||
// Deduplicate: keep only the first (highest) entry per normalised version
|
||||
const seen = new Set<string>();
|
||||
tags = tags.filter((tag: any) => {
|
||||
const normalised = semver.coerce(tag.name)?.version;
|
||||
if (!normalised || seen.has(normalised)) return false;
|
||||
seen.add(normalised);
|
||||
return true;
|
||||
});
|
||||
|
||||
if (tags.length === 0) {
|
||||
logger.warn("No valid semver tags found for Newt repository");
|
||||
return staleNewtVersion;
|
||||
}
|
||||
|
||||
const latestVersion = tags[0].name;
|
||||
|
||||
staleNewtVersion = latestVersion;
|
||||
await cache.set("cache:latestNewtVersion", latestVersion, 3600);
|
||||
|
||||
return latestVersion;
|
||||
} catch (error: any) {
|
||||
if (error.name === "AbortError") {
|
||||
logger.warn(
|
||||
"Request to fetch latest Newt version timed out (1.5s)"
|
||||
);
|
||||
} else if (error.cause?.code === "UND_ERR_CONNECT_TIMEOUT") {
|
||||
logger.warn(
|
||||
"Connection timeout while fetching latest Newt version"
|
||||
);
|
||||
} else {
|
||||
logger.warn(
|
||||
"Error fetching latest Newt version:",
|
||||
error.message || error
|
||||
);
|
||||
}
|
||||
return staleNewtVersion;
|
||||
}
|
||||
}
|
||||
|
||||
const listSitesParamsSchema = z.strictObject({
|
||||
orgId: z.string()
|
||||
@@ -357,6 +449,9 @@ export async function listSites(
|
||||
|
||||
const totalCount = Number(countRows[0]?.count ?? 0);
|
||||
|
||||
// Get latest version asynchronously without blocking the response
|
||||
const latestNewtVersionPromise = getLatestNewtVersion();
|
||||
|
||||
const siteIds = rows.map((site) => site.siteId);
|
||||
|
||||
let labelsForSites: Array<{
|
||||
@@ -399,6 +494,36 @@ export async function listSites(
|
||||
return { ...siteWithUpdate, labels: labelsForSite };
|
||||
});
|
||||
|
||||
// Try to get the latest version, but don't block if it fails
|
||||
try {
|
||||
const latestNewtVersion = await latestNewtVersionPromise;
|
||||
|
||||
if (latestNewtVersion) {
|
||||
sitesWithUpdates.forEach((site) => {
|
||||
if (
|
||||
site.type === "newt" &&
|
||||
site.newtVersion &&
|
||||
latestNewtVersion
|
||||
) {
|
||||
try {
|
||||
site.newtUpdateAvailable = semver.lt(
|
||||
site.newtVersion,
|
||||
latestNewtVersion
|
||||
);
|
||||
} catch (error) {
|
||||
site.newtUpdateAvailable = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Log the error but don't let it block the response
|
||||
logger.warn(
|
||||
"Failed to check for Newt updates, continuing without update info:",
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
const sitesPayload = sitesWithUpdates.map((site) =>
|
||||
site.type === "local" ? { ...site, online: undefined } : site
|
||||
);
|
||||
|
||||
@@ -28,10 +28,7 @@ import {
|
||||
isIpInCidr,
|
||||
portRangeStringSchema
|
||||
} from "@server/lib/ip";
|
||||
import {
|
||||
getClientSiteResourceAccess,
|
||||
rebuildClientAssociationsFromSiteResource
|
||||
} from "@server/lib/rebuildClientAssociations";
|
||||
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
|
||||
import logger from "@server/logger";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
@@ -849,14 +846,9 @@ export async function handleMessagingForUpdatedSiteResource(
|
||||
updatedSiteResource
|
||||
);
|
||||
|
||||
await rebuildClientAssociationsFromSiteResource(
|
||||
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,
|
||||
const { mergedAllClients } =
|
||||
await rebuildClientAssociationsFromSiteResource(
|
||||
existingSiteResource || updatedSiteResource, // we want to rebuild based on the existing resource then we will apply the change to the destination below
|
||||
trx
|
||||
);
|
||||
|
||||
|
||||
@@ -76,32 +76,12 @@ export interface SendMessageOptions {
|
||||
compress?: boolean;
|
||||
}
|
||||
|
||||
export interface BatchSendMessage {
|
||||
clientId: string;
|
||||
// Redis message type for cross-node communication
|
||||
export interface RedisMessage {
|
||||
type: "direct" | "broadcast";
|
||||
targetClientId?: string;
|
||||
excludeClientId?: string;
|
||||
message: WSMessage;
|
||||
fromNodeId: string;
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -26,8 +26,7 @@ import {
|
||||
WebSocketRequest,
|
||||
WSMessage,
|
||||
AuthenticatedWebSocket,
|
||||
SendMessageOptions,
|
||||
BatchSendMessage
|
||||
SendMessageOptions
|
||||
} from "./types";
|
||||
import { validateSessionToken } from "@server/auth/sessions/app";
|
||||
|
||||
@@ -213,20 +212,6 @@ const sendToClient = async (
|
||||
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 (
|
||||
message: WSMessage,
|
||||
excludeClientId?: string,
|
||||
@@ -567,7 +552,6 @@ export {
|
||||
router,
|
||||
handleWSUpgrade,
|
||||
sendToClient,
|
||||
sendToClientsBatch,
|
||||
broadcastToAllExcept,
|
||||
connectedClients,
|
||||
hasActiveConnections,
|
||||
|
||||
@@ -41,13 +41,6 @@ import { useParams } from "next/navigation";
|
||||
import { FaApple, FaWindows, FaLinux } from "react-icons/fa";
|
||||
import { SiAndroid } from "react-icons/si";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import {
|
||||
productUpdatesQueries,
|
||||
type LatestVersionResponse
|
||||
} from "@app/lib/queries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import semver from "semver";
|
||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||
|
||||
function formatTimestamp(timestamp: number | null | undefined): string {
|
||||
if (!timestamp) return "-";
|
||||
@@ -173,34 +166,6 @@ export default function GeneralPage() {
|
||||
}>(null);
|
||||
const [isCheckingCache, setIsCheckingCache] = useState(false);
|
||||
const [isRebuildingCache, setIsRebuildingCache] = useState(false);
|
||||
const data = useQuery(productUpdatesQueries.latestVersion(true));
|
||||
const latestPlatformVersions = data.data?.data;
|
||||
|
||||
const agentVersionMap: Record<string, string> = {
|
||||
"Pangolin Windows": "windows",
|
||||
"Pangolin Android": "android",
|
||||
"Pangolin iOS": "ios",
|
||||
"Pangolin iPadOS": "ios",
|
||||
"Pangolin macOS": "mac",
|
||||
"Pangolin CLI": "cli",
|
||||
"Olm CLI": "olm"
|
||||
};
|
||||
|
||||
let updateAvailable = false;
|
||||
if (client.agent && client.olmVersion && latestPlatformVersions) {
|
||||
const agent = agentVersionMap[
|
||||
client.agent
|
||||
] as keyof LatestVersionResponse;
|
||||
|
||||
if (agent in latestPlatformVersions) {
|
||||
const agentVersion = latestPlatformVersions[agent];
|
||||
|
||||
updateAvailable = semver.lt(
|
||||
client.olmVersion,
|
||||
agentVersion.latestVersion
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// get "imp" from local storage to determine if we should show the verify button (imp = "1" means show)
|
||||
const showVerifyButton =
|
||||
@@ -486,21 +451,11 @@ export default function GeneralPage() {
|
||||
{t("agent")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<div className="flex items-center">
|
||||
<Badge variant="secondary">
|
||||
{client.agent +
|
||||
" v" +
|
||||
client.olmVersion}
|
||||
</Badge>
|
||||
|
||||
{updateAvailable && (
|
||||
<InfoPopup
|
||||
info={t(
|
||||
"updateAvailableInfo"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Badge variant="secondary">
|
||||
{client.agent +
|
||||
" v" +
|
||||
client.olmVersion}
|
||||
</Badge>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
</div>
|
||||
|
||||
@@ -18,6 +18,7 @@ import Link from "next/link";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
||||
import { build } from "@server/build";
|
||||
import type { QueryRequestAuditLogResponse } from "@server/routers/auditLogs/types";
|
||||
import { ColumnFilterButton } from "@app/components/ColumnFilterButton";
|
||||
|
||||
@@ -121,7 +122,8 @@ export default function GeneralPage() {
|
||||
...logQueries.requests({
|
||||
orgId: orgId as string,
|
||||
filters: queryFilters
|
||||
})
|
||||
}),
|
||||
enabled: build !== "oss"
|
||||
});
|
||||
|
||||
const rows = isLoading ? generateSampleRequestLogs() : (data?.log ?? []);
|
||||
|
||||
@@ -11,10 +11,10 @@ import {
|
||||
} from "@app/components/ui/dropdown-menu";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
||||
import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import type { PaginationState } from "@tanstack/react-table";
|
||||
@@ -31,18 +31,15 @@ import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { startTransition, useMemo, useState, useTransition } from "react";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import z from "zod";
|
||||
import { ColumnFilterButton } from "./ColumnFilterButton";
|
||||
import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
|
||||
import { type SelectedLabel } from "./labels-selector";
|
||||
import { LabelsTableCell } from "./LabelsTableCell";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { ControlledDataTable } from "./ui/controlled-data-table";
|
||||
import {
|
||||
productUpdatesQueries,
|
||||
type LatestVersionResponse
|
||||
} from "@app/lib/queries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import semver from "semver";
|
||||
import { InfoPopup } from "./ui/info-popup";
|
||||
import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
|
||||
import { useLocalLabels } from "@app/hooks/useLocalLabels";
|
||||
import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
|
||||
|
||||
export type ClientRow = {
|
||||
id: number;
|
||||
@@ -104,9 +101,6 @@ export default function MachineClientsTable({
|
||||
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
const isLabelFeatureEnabled = isPaidUser(tierMatrix.labels);
|
||||
const data = useQuery(productUpdatesQueries.latestVersion(true));
|
||||
|
||||
const latestPlatformVersions = data.data?.data;
|
||||
|
||||
const defaultMachineColumnVisibility = {
|
||||
subnet: false,
|
||||
@@ -381,37 +375,6 @@ export default function MachineClientsTable({
|
||||
cell: ({ row }) => {
|
||||
const originalRow = row.original;
|
||||
|
||||
const agentVersionMap: Record<string, string> = {
|
||||
"Pangolin Windows": "windows",
|
||||
"Pangolin Android": "android",
|
||||
"Pangolin iOS": "ios",
|
||||
"Pangolin iPadOS": "ios",
|
||||
"Pangolin macOS": "mac",
|
||||
"Pangolin CLI": "cli",
|
||||
"Olm CLI": "olm"
|
||||
};
|
||||
|
||||
let updateAvailable = false;
|
||||
|
||||
if (
|
||||
originalRow.olmVersion &&
|
||||
originalRow.agent &&
|
||||
latestPlatformVersions
|
||||
) {
|
||||
const agent = agentVersionMap[
|
||||
originalRow.agent
|
||||
] as keyof LatestVersionResponse;
|
||||
|
||||
if (agent in latestPlatformVersions) {
|
||||
const agentVersion = latestPlatformVersions[agent];
|
||||
|
||||
updateAvailable = semver.lt(
|
||||
originalRow.olmVersion,
|
||||
agentVersion.latestVersion
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-1">
|
||||
{originalRow.agent && originalRow.olmVersion ? (
|
||||
@@ -423,9 +386,9 @@ export default function MachineClientsTable({
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
{updateAvailable && (
|
||||
<InfoPopup info={t("updateAvailableInfo")} />
|
||||
)}
|
||||
{/*originalRow.olmUpdateAvailable && (
|
||||
<InfoPopup info={t("olmUpdateAvailableInfo")} />
|
||||
)*/}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -411,9 +411,9 @@ export function PrivateResourceForm({
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
const clientsQuery = useQuery(
|
||||
orgQueries.machineClients({ orgId, perPage: 1 })
|
||||
);
|
||||
const rolesQuery = useQuery(orgQueries.roles({ orgId }));
|
||||
const usersQuery = useQuery(orgQueries.users({ orgId }));
|
||||
const clientsQuery = useQuery(orgQueries.machineClients({ orgId }));
|
||||
const resourceRolesQuery = useQuery({
|
||||
...resourceQueries.siteResourceRoles({
|
||||
siteResourceId: siteResourceId ?? 0
|
||||
@@ -433,6 +433,13 @@ export function PrivateResourceForm({
|
||||
enabled: siteResourceId != null
|
||||
});
|
||||
|
||||
const allRoles = (rolesQuery.data ?? [])
|
||||
.map((r) => ({ id: r.roleId.toString(), text: r.name }))
|
||||
.filter((r) => r.text !== "Admin");
|
||||
const allUsers = (usersQuery.data ?? []).map((u) => ({
|
||||
id: u.id.toString(),
|
||||
text: `${getUserDisplayName({ email: u.email, username: u.username })}${u.type !== UserType.Internal ? ` (${u.idpName})` : ""}`
|
||||
}));
|
||||
const allClients = (clientsQuery.data ?? [])
|
||||
.filter((c) => !c.userId)
|
||||
.map((c) => ({ id: c.clientId.toString(), text: c.name }));
|
||||
@@ -471,6 +478,8 @@ export function PrivateResourceForm({
|
||||
}
|
||||
|
||||
const loadingRolesUsers =
|
||||
rolesQuery.isLoading ||
|
||||
usersQuery.isLoading ||
|
||||
clientsQuery.isLoading ||
|
||||
(siteResourceId != null &&
|
||||
(resourceRolesQuery.isLoading ||
|
||||
@@ -479,6 +488,16 @@ export function PrivateResourceForm({
|
||||
|
||||
const hasMachineClients = allClients.length > 0;
|
||||
|
||||
const [activeRolesTagIndex, setActiveRolesTagIndex] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [activeUsersTagIndex, setActiveUsersTagIndex] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [activeClientsTagIndex, setActiveClientsTagIndex] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
|
||||
const [sshServerMode, setSshServerMode] = useState<"standard" | "native">(
|
||||
() => {
|
||||
if (variant === "edit" && resource) {
|
||||
|
||||
@@ -55,9 +55,6 @@ import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
|
||||
import { LabelsTableCell } from "./LabelsTableCell";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { productUpdatesQueries } from "@app/lib/queries";
|
||||
import semver from "semver";
|
||||
|
||||
export type SiteRow = {
|
||||
id: number;
|
||||
@@ -116,11 +113,12 @@ export default function SitesTable({
|
||||
const api = createApiClient(useEnvContext());
|
||||
const t = useTranslations();
|
||||
|
||||
const { data: latestVersions } = useQuery(
|
||||
productUpdatesQueries.latestVersion(true)
|
||||
);
|
||||
|
||||
const latestNewtVersion = latestVersions?.data?.newt?.latestVersion;
|
||||
// useEffect(() => {
|
||||
// const interval = setInterval(() => {
|
||||
// router.refresh();
|
||||
// }, 30_000);
|
||||
// return () => clearInterval(interval);
|
||||
// }, []);
|
||||
|
||||
const booleanSearchFilterSchema = z
|
||||
.enum(["true", "false"])
|
||||
@@ -335,11 +333,6 @@ export default function SitesTable({
|
||||
cell: ({ row }) => {
|
||||
const originalRow = row.original;
|
||||
|
||||
let updateAvailable =
|
||||
latestNewtVersion &&
|
||||
originalRow.newtVersion &&
|
||||
semver.lt(originalRow.newtVersion, latestNewtVersion);
|
||||
|
||||
if (originalRow.type === "newt") {
|
||||
return (
|
||||
<div className="flex items-center space-x-1">
|
||||
@@ -353,7 +346,7 @@ export default function SitesTable({
|
||||
)}
|
||||
</div>
|
||||
</Badge>
|
||||
{updateAvailable && (
|
||||
{originalRow.newtUpdateAvailable && (
|
||||
<InfoPopup
|
||||
info={t("newtUpdateAvailableInfo")}
|
||||
/>
|
||||
@@ -568,7 +561,7 @@ export default function SitesTable({
|
||||
}
|
||||
|
||||
return cols;
|
||||
}, [isLabelFeatureEnabled, orgId, t, searchParams, latestNewtVersion]);
|
||||
}, [isLabelFeatureEnabled, orgId, t, searchParams]);
|
||||
|
||||
function toggleSort(column: string) {
|
||||
const newSearch = getNextSortOrder(column, searchParams);
|
||||
|
||||
@@ -38,12 +38,6 @@ import { ColumnFilterButton } from "./ColumnFilterButton";
|
||||
import IdpTypeBadge from "./IdpTypeBadge";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { ControlledDataTable } from "./ui/controlled-data-table";
|
||||
import {
|
||||
productUpdatesQueries,
|
||||
type LatestVersionResponse
|
||||
} from "@app/lib/queries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import semver from "semver";
|
||||
|
||||
export type ClientRow = {
|
||||
id: number;
|
||||
@@ -106,9 +100,6 @@ export default function UserDevicesTable({
|
||||
searchParams
|
||||
} = useNavigationContext();
|
||||
const [isRefreshing, startTransition] = useTransition();
|
||||
const data = useQuery(productUpdatesQueries.latestVersion(true));
|
||||
|
||||
const latestPlatformVersions = data.data?.data;
|
||||
|
||||
const defaultUserColumnVisibility = {
|
||||
subnet: false,
|
||||
@@ -564,37 +555,6 @@ export default function UserDevicesTable({
|
||||
cell: ({ row }) => {
|
||||
const originalRow = row.original;
|
||||
|
||||
const agentVersionMap: Record<string, string> = {
|
||||
"Pangolin Windows": "windows",
|
||||
"Pangolin Android": "android",
|
||||
"Pangolin iOS": "ios",
|
||||
"Pangolin iPadOS": "ios",
|
||||
"Pangolin macOS": "mac",
|
||||
"Pangolin CLI": "cli",
|
||||
"Olm CLI": "olm"
|
||||
};
|
||||
|
||||
let updateAvailable = false;
|
||||
|
||||
if (
|
||||
originalRow.olmVersion &&
|
||||
originalRow.agent &&
|
||||
latestPlatformVersions
|
||||
) {
|
||||
const agent = agentVersionMap[
|
||||
originalRow.agent
|
||||
] as keyof LatestVersionResponse;
|
||||
|
||||
if (agent in latestPlatformVersions) {
|
||||
const agentVersion = latestPlatformVersions[agent];
|
||||
|
||||
updateAvailable = semver.lt(
|
||||
originalRow.olmVersion,
|
||||
agentVersion.latestVersion
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-1">
|
||||
{originalRow.agent && originalRow.olmVersion ? (
|
||||
@@ -607,9 +567,9 @@ export default function UserDevicesTable({
|
||||
"-"
|
||||
)}
|
||||
|
||||
{updateAvailable && (
|
||||
<InfoPopup info={t("updateAvailableInfo")} />
|
||||
)}
|
||||
{/*originalRow.olmUpdateAvailable && (
|
||||
<InfoPopup info={t("olmUpdateAvailableInfo")} />
|
||||
)*/}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -754,7 +714,7 @@ export default function UserDevicesTable({
|
||||
}
|
||||
|
||||
return allOptions;
|
||||
}, [t, latestPlatformVersions]);
|
||||
}, [t]);
|
||||
|
||||
function handleFilterChange(
|
||||
column: string,
|
||||
|
||||
@@ -139,6 +139,7 @@ Restart=always
|
||||
RestartSec=2
|
||||
UMask=0077
|
||||
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
|
||||
[Install]
|
||||
|
||||
@@ -83,19 +83,9 @@ export function createPolicyRuleValueSchema(t: TranslateFn, match: string) {
|
||||
{ message: t("rulesErrorInvalidCountryDescription") }
|
||||
);
|
||||
case "ASN":
|
||||
return required.refine(
|
||||
(value) => {
|
||||
const normalizedValue = value.trim().toUpperCase();
|
||||
return (
|
||||
/^AS\d+$/.test(normalizedValue) ||
|
||||
normalizedValue === "ALL" ||
|
||||
normalizedValue === "AS0"
|
||||
);
|
||||
},
|
||||
{
|
||||
message: t("rulesErrorInvalidAsnDescription")
|
||||
}
|
||||
);
|
||||
return required.refine((value) => /^AS\d+$/i.test(value.trim()), {
|
||||
message: t("rulesErrorInvalidAsnDescription")
|
||||
});
|
||||
default:
|
||||
return required;
|
||||
}
|
||||
|
||||
@@ -63,34 +63,6 @@ export type LatestVersionResponse = {
|
||||
latestVersion: string;
|
||||
releaseNotes: string;
|
||||
};
|
||||
newt: {
|
||||
latestVersion: string;
|
||||
releaseNotes: string;
|
||||
};
|
||||
cli: {
|
||||
latestVersion: string;
|
||||
releaseNotes: string;
|
||||
};
|
||||
"panglin-node": {
|
||||
latestVersion: string;
|
||||
releaseNotes: string;
|
||||
};
|
||||
windows: {
|
||||
latestVersion: string;
|
||||
releaseNotes: string;
|
||||
};
|
||||
android: {
|
||||
latestVersion: string;
|
||||
releaseNotes: string;
|
||||
};
|
||||
mac: {
|
||||
latestVersion: string;
|
||||
releaseNotes: string;
|
||||
};
|
||||
ios: {
|
||||
latestVersion: string;
|
||||
releaseNotes: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const productUpdatesQueries = {
|
||||
|
||||
Reference in New Issue
Block a user