diff --git a/messages/en-US.json b/messages/en-US.json
index 675c37a13..51ba75db5 100644
--- a/messages/en-US.json
+++ b/messages/en-US.json
@@ -2127,7 +2127,8 @@
"addDomainToEnableCustomAuthPages": "Users will be able to access the organization's login page and complete resource authentication using this domain.",
"selectDomainForOrgAuthPage": "Select a domain for the organization's authentication page",
"domainPickerProvidedDomain": "Provided Domain",
- "domainPickerFreeProvidedDomain": "Free Provided Domain",
+ "domainPickerFreeProvidedDomain": "Provided Domain",
+ "domainPickerFreeDomainsPaidFeature": "Provided domains are a paid feature. Subscribe to get a domain included with your plan — no need to bring your own.",
"domainPickerVerified": "Verified",
"domainPickerUnverified": "Unverified",
"domainPickerManual": "Manual",
diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts
index 308a69fc7..5e04bf96a 100644
--- a/server/db/pg/schema/schema.ts
+++ b/server/db/pg/schema/schema.ts
@@ -226,12 +226,18 @@ export const exitNodes = pgTable("exitNodes", {
export const siteResources = pgTable("siteResources", {
// this is for the clients
siteResourceId: serial("siteResourceId").primaryKey(),
- siteId: integer("siteId")
- .notNull()
- .references(() => sites.siteId, { onDelete: "cascade" }),
orgId: varchar("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
+ networkId: integer("networkId").references(() => networks.networkId, {
+ onDelete: "set null"
+ }),
+ defaultNetworkId: integer("defaultNetworkId").references(
+ () => networks.networkId,
+ {
+ onDelete: "restrict"
+ }
+ ),
niceId: varchar("niceId").notNull(),
name: varchar("name").notNull(),
ssl: boolean("ssl").notNull().default(false),
@@ -257,6 +263,32 @@ export const siteResources = pgTable("siteResources", {
fullDomain: varchar("fullDomain")
});
+export const networks = pgTable("networks", {
+ networkId: serial("networkId").primaryKey(),
+ niceId: text("niceId"),
+ name: text("name"),
+ scope: varchar("scope")
+ .$type<"global" | "resource">()
+ .notNull()
+ .default("global"),
+ orgId: varchar("orgId")
+ .references(() => orgs.orgId, {
+ onDelete: "cascade"
+ })
+ .notNull()
+});
+
+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()
@@ -1117,3 +1149,4 @@ export type RequestAuditLog = InferSelectModel;
export type RoundTripMessageTracker = InferSelectModel<
typeof roundTripMessageTracker
>;
+export type Network = InferSelectModel;
diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts
index fe192014b..5c9d57e6d 100644
--- a/server/db/sqlite/schema/schema.ts
+++ b/server/db/sqlite/schema/schema.ts
@@ -94,6 +94,9 @@ export const sites = sqliteTable("sites", {
exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, {
onDelete: "set null"
}),
+ networkId: integer("networkId").references(() => networks.networkId, {
+ onDelete: "set null"
+ }),
name: text("name").notNull(),
pubKey: text("pubKey"),
subnet: text("subnet"),
@@ -252,12 +255,16 @@ export const siteResources = sqliteTable("siteResources", {
siteResourceId: integer("siteResourceId").primaryKey({
autoIncrement: true
}),
- siteId: integer("siteId")
- .notNull()
- .references(() => sites.siteId, { onDelete: "cascade" }),
orgId: text("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
+ networkId: integer("networkId").references(() => networks.networkId, {
+ onDelete: "set null"
+ }),
+ defaultNetworkId: integer("defaultNetworkId").references(
+ () => networks.networkId,
+ { onDelete: "restrict" }
+ ),
niceId: text("niceId").notNull(),
name: text("name").notNull(),
ssl: integer("ssl", { mode: "boolean" }).notNull().default(false),
@@ -285,6 +292,30 @@ export const siteResources = sqliteTable("siteResources", {
fullDomain: text("fullDomain"),
});
+export const networks = sqliteTable("networks", {
+ networkId: integer("networkId").primaryKey({ autoIncrement: true }),
+ niceId: text("niceId"),
+ name: text("name"),
+ scope: text("scope")
+ .$type<"global" | "resource">()
+ .notNull()
+ .default("global"),
+ orgId: text("orgId")
+ .notNull()
+ .references(() => orgs.orgId, { onDelete: "cascade" })
+});
+
+export const siteNetworks = sqliteTable("siteNetworks", {
+ siteId: integer("siteId")
+ .notNull()
+ .references(() => sites.siteId, {
+ onDelete: "cascade"
+ }),
+ networkId: integer("networkId")
+ .notNull()
+ .references(() => networks.networkId, { onDelete: "cascade" })
+});
+
export const clientSiteResources = sqliteTable("clientSiteResources", {
clientId: integer("clientId")
.notNull()
@@ -1204,6 +1235,7 @@ export type ApiKey = InferSelectModel;
export type ApiKeyAction = InferSelectModel;
export type ApiKeyOrg = InferSelectModel;
export type SiteResource = InferSelectModel;
+export type Network = InferSelectModel;
export type OrgDomains = InferSelectModel;
export type SetupToken = InferSelectModel;
export type HostMeta = InferSelectModel;
diff --git a/server/lib/billing/tierMatrix.ts b/server/lib/billing/tierMatrix.ts
index b83e56077..d64ed1b56 100644
--- a/server/lib/billing/tierMatrix.ts
+++ b/server/lib/billing/tierMatrix.ts
@@ -20,7 +20,8 @@ export enum TierFeature {
FullRbac = "fullRbac",
SiteProvisioningKeys = "siteProvisioningKeys", // handle downgrade by revoking keys if needed
SIEM = "siem", // handle downgrade by disabling SIEM integrations
- HTTPPrivateResources = "httpPrivateResources" // handle downgrade by disabling HTTP private resources
+ HTTPPrivateResources = "httpPrivateResources", // handle downgrade by disabling HTTP private resources
+ DomainNamespaces = "domainNamespaces" // handle downgrade by removing custom domain namespaces
}
export const tierMatrix: Record = {
@@ -58,5 +59,6 @@ export const tierMatrix: Record = {
[TierFeature.FullRbac]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.SiteProvisioningKeys]: ["tier3", "enterprise"],
[TierFeature.SIEM]: ["enterprise"],
- [TierFeature.HTTPPrivateResources]: ["tier3", "enterprise"]
+ [TierFeature.HTTPPrivateResources]: ["tier3", "enterprise"],
+ [TierFeature.DomainNamespaces]: ["tier1", "tier2", "tier3", "enterprise"]
};
diff --git a/server/lib/blueprints/applyBlueprint.ts b/server/lib/blueprints/applyBlueprint.ts
index a304bb392..fd189e6ca 100644
--- a/server/lib/blueprints/applyBlueprint.ts
+++ b/server/lib/blueprints/applyBlueprint.ts
@@ -121,8 +121,8 @@ export async function applyBlueprint({
for (const result of clientResourcesResults) {
if (
result.oldSiteResource &&
- result.oldSiteResource.siteId !=
- result.newSiteResource.siteId
+ JSON.stringify(result.newSites?.sort()) !==
+ JSON.stringify(result.oldSites?.sort())
) {
// query existing associations
const existingRoleIds = await trx
@@ -222,38 +222,46 @@ export async function applyBlueprint({
trx
);
} else {
- const [newSite] = await trx
- .select()
- .from(sites)
- .innerJoin(newts, eq(sites.siteId, newts.siteId))
- .where(
- and(
- eq(sites.siteId, result.newSiteResource.siteId),
- eq(sites.orgId, orgId),
- eq(sites.type, "newt"),
- isNotNull(sites.pubKey)
+ let good = true;
+ for (const newSite of result.newSites) {
+ const [site] = await trx
+ .select()
+ .from(sites)
+ .innerJoin(newts, eq(sites.siteId, newts.siteId))
+ .where(
+ and(
+ eq(sites.siteId, newSite.siteId),
+ eq(sites.orgId, orgId),
+ eq(sites.type, "newt"),
+ isNotNull(sites.pubKey)
+ )
)
- )
- .limit(1);
+ .limit(1);
+
+ if (!site) {
+ logger.debug(
+ `No newt sites found for client resource ${result.newSiteResource.siteResourceId}, skipping target update`
+ );
+ good = false;
+ break;
+ }
- if (!newSite) {
logger.debug(
- `No newt site found for client resource ${result.newSiteResource.siteResourceId}, skipping target update`
+ `Updating client resource ${result.newSiteResource.siteResourceId} on site ${newSite.siteId}`
);
- continue;
}
- logger.debug(
- `Updating client resource ${result.newSiteResource.siteResourceId} on site ${newSite.sites.siteId}`
- );
+ if (!good) {
+ continue;
+ }
await handleMessagingForUpdatedSiteResource(
result.oldSiteResource,
result.newSiteResource,
- {
- siteId: newSite.sites.siteId,
- orgId: newSite.sites.orgId
- },
+ result.newSites.map((site) => ({
+ siteId: site.siteId,
+ orgId: result.newSiteResource.orgId
+ })),
trx
);
}
diff --git a/server/lib/blueprints/clientResources.ts b/server/lib/blueprints/clientResources.ts
index 40c09dd10..df1fd0cfb 100644
--- a/server/lib/blueprints/clientResources.ts
+++ b/server/lib/blueprints/clientResources.ts
@@ -5,12 +5,15 @@ import {
orgDomains,
roles,
roleSiteResources,
+ Site,
SiteResource,
+ siteNetworks,
siteResources,
Transaction,
userOrgs,
users,
- userSiteResources
+ userSiteResources,
+ networks
} from "@server/db";
import { sites } from "@server/db";
import { eq, and, ne, inArray, or, isNotNull } from "drizzle-orm";
@@ -91,23 +94,11 @@ async function getDomainForSiteResource(
};
}
-function siteResourceModeForDb(mode: "host" | "cidr" | "http" | "https"): {
- mode: "host" | "cidr" | "http";
- ssl: boolean;
- scheme: "http" | "https" | null;
-} {
- if (mode === "https") {
- return { mode: "http", ssl: true, scheme: "https" };
- }
- if (mode === "http") {
- return { mode: "http", ssl: false, scheme: "http" };
- }
- return { mode, ssl: false, scheme: null };
-}
-
export type ClientResourcesResults = {
newSiteResource: SiteResource;
oldSiteResource?: SiteResource;
+ newSites: { siteId: number }[];
+ oldSites: { siteId: number }[];
}[];
export async function updateClientResources(
@@ -132,45 +123,77 @@ export async function updateClientResources(
)
.limit(1);
- const resourceSiteId = resourceData.site;
- let site;
+ const existingSiteIds = existingResource?.networkId
+ ? await trx
+ .select({ siteId: sites.siteId })
+ .from(siteNetworks)
+ .where(eq(siteNetworks.networkId, existingResource.networkId))
+ : [];
- if (resourceSiteId) {
- // Look up site by niceId
- [site] = await trx
- .select({ siteId: sites.siteId })
- .from(sites)
- .where(
- and(
- eq(sites.niceId, resourceSiteId),
- eq(sites.orgId, orgId)
+ let allSites: { siteId: number }[] = [];
+ if (resourceData.site) {
+ let siteSingle;
+ const resourceSiteId = resourceData.site;
+
+ if (resourceSiteId) {
+ // Look up site by niceId
+ [siteSingle] = await trx
+ .select({ siteId: sites.siteId })
+ .from(sites)
+ .where(
+ and(
+ eq(sites.niceId, resourceSiteId),
+ eq(sites.orgId, orgId)
+ )
)
- )
- .limit(1);
- } else if (siteId) {
- // Use the provided siteId directly, but verify it belongs to the org
- [site] = await trx
- .select({ siteId: sites.siteId })
- .from(sites)
- .where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
- .limit(1);
- } else {
- throw new Error(`Target site is required`);
+ .limit(1);
+ } else if (siteId) {
+ // Use the provided siteId directly, but verify it belongs to the org
+ [siteSingle] = await trx
+ .select({ siteId: sites.siteId })
+ .from(sites)
+ .where(
+ and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))
+ )
+ .limit(1);
+ } else {
+ throw new Error(`Target site is required`);
+ }
+
+ if (!siteSingle) {
+ throw new Error(
+ `Site not found: ${resourceSiteId} in org ${orgId}`
+ );
+ }
+ allSites.push(siteSingle);
}
- if (!site) {
- throw new Error(
- `Site not found: ${resourceSiteId} in org ${orgId}`
- );
+ if (resourceData.sites) {
+ for (const siteNiceId of resourceData.sites) {
+ const [site] = await trx
+ .select({ siteId: sites.siteId })
+ .from(sites)
+ .where(
+ and(
+ eq(sites.niceId, siteNiceId),
+ eq(sites.orgId, orgId)
+ )
+ )
+ .limit(1);
+ if (!site) {
+ throw new Error(
+ `Site not found: ${siteId} in org ${orgId}`
+ );
+ }
+ allSites.push(site);
+ }
}
if (existingResource) {
- const mappedMode = siteResourceModeForDb(resourceData.mode);
-
let domainInfo:
| { subdomain: string | null; domainId: string }
| undefined;
- if (resourceData["full-domain"] && mappedMode.mode === "http") {
+ if (resourceData["full-domain"] && resourceData.mode === "http") {
domainInfo = await getDomainForSiteResource(
existingResource.siteResourceId,
resourceData["full-domain"],
@@ -184,10 +207,9 @@ export async function updateClientResources(
.update(siteResources)
.set({
name: resourceData.name || resourceNiceId,
- siteId: site.siteId,
- mode: mappedMode.mode,
- ssl: mappedMode.ssl,
- scheme: mappedMode.scheme,
+ mode: resourceData.mode,
+ ssl: resourceData.ssl,
+ scheme: resourceData.scheme,
destination: resourceData.destination,
destinationPort: resourceData["destination-port"],
enabled: true, // hardcoded for now
@@ -210,6 +232,21 @@ export async function updateClientResources(
const siteResourceId = existingResource.siteResourceId;
+ if (updatedResource.networkId) {
+ await trx
+ .delete(siteNetworks)
+ .where(
+ eq(siteNetworks.networkId, updatedResource.networkId)
+ );
+
+ for (const site of allSites) {
+ await trx.insert(siteNetworks).values({
+ siteId: site.siteId,
+ networkId: updatedResource.networkId
+ });
+ }
+ }
+
await trx
.delete(clientSiteResources)
.where(eq(clientSiteResources.siteResourceId, siteResourceId));
@@ -312,19 +349,20 @@ export async function updateClientResources(
results.push({
newSiteResource: updatedResource,
- oldSiteResource: existingResource
+ oldSiteResource: existingResource,
+ newSites: allSites,
+ oldSites: existingSiteIds
});
} else {
- const mappedMode = siteResourceModeForDb(resourceData.mode);
let aliasAddress: string | null = null;
- if (mappedMode.mode === "host" || mappedMode.mode === "http") {
+ if (resourceData.mode === "host" || resourceData.mode === "http") {
aliasAddress = await getNextAvailableAliasAddress(orgId);
}
let domainInfo:
| { subdomain: string | null; domainId: string }
| undefined;
- if (resourceData["full-domain"] && mappedMode.mode === "http") {
+ if (resourceData["full-domain"] && resourceData.mode === "http") {
domainInfo = await getDomainForSiteResource(
undefined,
resourceData["full-domain"],
@@ -333,17 +371,26 @@ export async function updateClientResources(
);
}
+ const [network] = await trx
+ .insert(networks)
+ .values({
+ scope: "resource",
+ orgId: orgId
+ })
+ .returning();
+
// Create new resource
const [newResource] = await trx
.insert(siteResources)
.values({
orgId: orgId,
- siteId: site.siteId,
niceId: resourceNiceId,
+ networkId: network.networkId,
+ defaultNetworkId: network.networkId,
name: resourceData.name || resourceNiceId,
- mode: mappedMode.mode,
- ssl: mappedMode.ssl,
- scheme: mappedMode.scheme,
+ mode: resourceData.mode,
+ ssl: resourceData.ssl,
+ scheme: resourceData.scheme,
destination: resourceData.destination,
destinationPort: resourceData["destination-port"],
enabled: true, // hardcoded for now
@@ -361,6 +408,13 @@ export async function updateClientResources(
const siteResourceId = newResource.siteResourceId;
+ for (const site of allSites) {
+ await trx.insert(siteNetworks).values({
+ siteId: site.siteId,
+ networkId: network.networkId
+ });
+ }
+
const [adminRole] = await trx
.select()
.from(roles)
@@ -450,7 +504,11 @@ export async function updateClientResources(
`Created new client resource ${newResource.name} (${newResource.siteResourceId}) for org ${orgId}`
);
- results.push({ newSiteResource: newResource });
+ results.push({
+ newSiteResource: newResource,
+ newSites: allSites,
+ oldSites: existingSiteIds
+ });
}
}
diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts
index 7939e6e24..8269e7b65 100644
--- a/server/lib/blueprints/types.ts
+++ b/server/lib/blueprints/types.ts
@@ -164,6 +164,7 @@ export const ResourceSchema = z
name: z.string().optional(),
protocol: z.enum(["http", "tcp", "udp"]).optional(),
ssl: z.boolean().optional(),
+ scheme: z.enum(["http", "https"]).optional(),
"full-domain": z.string().optional(),
"proxy-port": z.int().min(1).max(65535).optional(),
enabled: z.boolean().optional(),
@@ -326,7 +327,8 @@ export const ClientResourceSchema = z
.object({
name: z.string().min(1).max(255),
mode: z.enum(["host", "cidr", "http"]),
- site: z.string(),
+ site: z.string(), // DEPRECATED IN FAVOR OF sites
+ sites: z.array(z.string()).optional().default([]),
// protocol: z.enum(["tcp", "udp"]).optional(),
// proxyPort: z.int().positive().optional(),
"destination-port": z.int().positive().optional(),
@@ -337,6 +339,7 @@ export const ClientResourceSchema = z
"disable-icmp": z.boolean().optional().default(false),
"full-domain": z.string().optional(),
ssl: z.boolean().optional(),
+ scheme: z.enum(["http", "https"]).optional().nullable(),
alias: z
.string()
.regex(
diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts
index 7c69ff71c..04b16beb8 100644
--- a/server/lib/rebuildClientAssociations.ts
+++ b/server/lib/rebuildClientAssociations.ts
@@ -11,11 +11,11 @@ import {
roleSiteResources,
Site,
SiteResource,
+ siteNetworks,
siteResources,
sites,
Transaction,
userOrgRoles,
- userOrgs,
userSiteResources
} from "@server/db";
import { and, eq, inArray, ne } from "drizzle-orm";
@@ -48,15 +48,23 @@ export async function getClientSiteResourceAccess(
siteResource: SiteResource,
trx: Transaction | typeof db = db
) {
- // get the site
- const [site] = await trx
- .select()
- .from(sites)
- .where(eq(sites.siteId, siteResource.siteId))
- .limit(1);
+ // get all sites associated with this siteResource via its network
+ const sitesList = siteResource.networkId
+ ? await trx
+ .select()
+ .from(sites)
+ .innerJoin(
+ siteNetworks,
+ eq(siteNetworks.siteId, sites.siteId)
+ )
+ .where(eq(siteNetworks.networkId, siteResource.networkId))
+ .then((rows) => rows.map((row) => row.sites))
+ : [];
- if (!site) {
- throw new Error(`Site with ID ${siteResource.siteId} not found`);
+ if (sitesList.length === 0) {
+ logger.warn(
+ `No sites found for siteResource ${siteResource.siteResourceId} with networkId ${siteResource.networkId}`
+ );
}
const roleIds = await trx
@@ -137,7 +145,7 @@ export async function getClientSiteResourceAccess(
const mergedAllClientIds = mergedAllClients.map((c) => c.clientId);
return {
- site,
+ sitesList,
mergedAllClients,
mergedAllClientIds
};
@@ -153,40 +161,51 @@ export async function rebuildClientAssociationsFromSiteResource(
subnet: string | null;
}[];
}> {
- const siteId = siteResource.siteId;
-
- const { site, mergedAllClients, mergedAllClientIds } =
+ const { sitesList, mergedAllClients, mergedAllClientIds } =
await getClientSiteResourceAccess(siteResource, trx);
/////////// process the client-siteResource associations ///////////
- // get all of the clients associated with other resources on this site
- const allUpdatedClientsFromOtherResourcesOnThisSite = await trx
- .select({
- clientId: clientSiteResourcesAssociationsCache.clientId
- })
- .from(clientSiteResourcesAssociationsCache)
- .innerJoin(
- siteResources,
- eq(
- clientSiteResourcesAssociationsCache.siteResourceId,
- siteResources.siteResourceId
- )
- )
- .where(
- and(
- eq(siteResources.siteId, siteId),
- ne(siteResources.siteResourceId, siteResource.siteResourceId)
- )
- );
+ // get all of the clients associated with other resources in the same network,
+ // joined through siteNetworks so we know which siteId each client belongs to
+ const allUpdatedClientsFromOtherResourcesOnThisSite = siteResource.networkId
+ ? await trx
+ .select({
+ clientId: clientSiteResourcesAssociationsCache.clientId,
+ siteId: siteNetworks.siteId
+ })
+ .from(clientSiteResourcesAssociationsCache)
+ .innerJoin(
+ siteResources,
+ eq(
+ clientSiteResourcesAssociationsCache.siteResourceId,
+ siteResources.siteResourceId
+ )
+ )
+ .innerJoin(
+ siteNetworks,
+ eq(siteNetworks.networkId, siteResources.networkId)
+ )
+ .where(
+ and(
+ eq(siteResources.networkId, siteResource.networkId),
+ ne(
+ siteResources.siteResourceId,
+ siteResource.siteResourceId
+ )
+ )
+ )
+ : [];
- const allClientIdsFromOtherResourcesOnThisSite = Array.from(
- new Set(
- allUpdatedClientsFromOtherResourcesOnThisSite.map(
- (row) => row.clientId
- )
- )
- );
+ // Build a per-site map so the loop below can check by siteId rather than
+ // across the entire network.
+ const clientsFromOtherResourcesBySite = new Map>();
+ for (const row of allUpdatedClientsFromOtherResourcesOnThisSite) {
+ if (!clientsFromOtherResourcesBySite.has(row.siteId)) {
+ clientsFromOtherResourcesBySite.set(row.siteId, new Set());
+ }
+ clientsFromOtherResourcesBySite.get(row.siteId)!.add(row.clientId);
+ }
const existingClientSiteResources = await trx
.select({
@@ -260,82 +279,90 @@ export async function rebuildClientAssociationsFromSiteResource(
/////////// process the client-site associations ///////////
- const existingClientSites = await trx
- .select({
- clientId: clientSitesAssociationsCache.clientId
- })
- .from(clientSitesAssociationsCache)
- .where(eq(clientSitesAssociationsCache.siteId, siteResource.siteId));
+ for (const site of sitesList) {
+ const siteId = site.siteId;
- const existingClientSiteIds = existingClientSites.map(
- (row) => row.clientId
- );
+ const existingClientSites = await trx
+ .select({
+ clientId: clientSitesAssociationsCache.clientId
+ })
+ .from(clientSitesAssociationsCache)
+ .where(eq(clientSitesAssociationsCache.siteId, siteId));
- // Get full client details for existing clients (needed for sending delete messages)
- const existingClients = await trx
- .select({
- clientId: clients.clientId,
- pubKey: clients.pubKey,
- subnet: clients.subnet
- })
- .from(clients)
- .where(inArray(clients.clientId, existingClientSiteIds));
+ const existingClientSiteIds = existingClientSites.map(
+ (row) => row.clientId
+ );
- const clientSitesToAdd = mergedAllClientIds.filter(
- (clientId) =>
- !existingClientSiteIds.includes(clientId) &&
- !allClientIdsFromOtherResourcesOnThisSite.includes(clientId) // dont remove if there is still another connection for another site resource
- );
+ // Get full client details for existing clients (needed for sending delete messages)
+ const existingClients =
+ existingClientSiteIds.length > 0
+ ? await trx
+ .select({
+ clientId: clients.clientId,
+ pubKey: clients.pubKey,
+ subnet: clients.subnet
+ })
+ .from(clients)
+ .where(inArray(clients.clientId, existingClientSiteIds))
+ : [];
- const clientSitesToInsert = clientSitesToAdd.map((clientId) => ({
- clientId,
- siteId
- }));
+ const otherResourceClientIds = clientsFromOtherResourcesBySite.get(siteId) ?? new Set();
- if (clientSitesToInsert.length > 0) {
- await trx
- .insert(clientSitesAssociationsCache)
- .values(clientSitesToInsert)
- .returning();
- }
+ const clientSitesToAdd = mergedAllClientIds.filter(
+ (clientId) =>
+ !existingClientSiteIds.includes(clientId) &&
+ !otherResourceClientIds.has(clientId) // dont add if already connected via another site resource
+ );
- // Now remove any client-site associations that should no longer exist
- const clientSitesToRemove = existingClientSiteIds.filter(
- (clientId) =>
- !mergedAllClientIds.includes(clientId) &&
- !allClientIdsFromOtherResourcesOnThisSite.includes(clientId) // dont remove if there is still another connection for another site resource
- );
+ const clientSitesToInsert = clientSitesToAdd.map((clientId) => ({
+ clientId,
+ siteId
+ }));
- if (clientSitesToRemove.length > 0) {
- await trx
- .delete(clientSitesAssociationsCache)
- .where(
- and(
- eq(clientSitesAssociationsCache.siteId, siteId),
- inArray(
- clientSitesAssociationsCache.clientId,
- clientSitesToRemove
+ if (clientSitesToInsert.length > 0) {
+ await trx
+ .insert(clientSitesAssociationsCache)
+ .values(clientSitesToInsert)
+ .returning();
+ }
+
+ // Now remove any client-site associations that should no longer exist
+ const clientSitesToRemove = existingClientSiteIds.filter(
+ (clientId) =>
+ !mergedAllClientIds.includes(clientId) &&
+ !otherResourceClientIds.has(clientId) // dont remove if there is still another connection for another site resource
+ );
+
+ if (clientSitesToRemove.length > 0) {
+ await trx
+ .delete(clientSitesAssociationsCache)
+ .where(
+ and(
+ eq(clientSitesAssociationsCache.siteId, siteId),
+ inArray(
+ clientSitesAssociationsCache.clientId,
+ clientSitesToRemove
+ )
)
- )
- );
+ );
+ }
+
+ // Now handle the messages to add/remove peers on both the newt and olm sides
+ await handleMessagesForSiteClients(
+ site,
+ siteId,
+ mergedAllClients,
+ existingClients,
+ clientSitesToAdd,
+ clientSitesToRemove,
+ trx
+ );
}
- /////////// send the messages ///////////
-
- // Now handle the messages to add/remove peers on both the newt and olm sides
- await handleMessagesForSiteClients(
- site,
- siteId,
- mergedAllClients,
- existingClients,
- clientSitesToAdd,
- clientSitesToRemove,
- trx
- );
-
// Handle subnet proxy target updates for the resource associations
await handleSubnetProxyTargetUpdates(
siteResource,
+ sitesList,
mergedAllClients,
existingResourceClients,
clientSiteResourcesToAdd,
@@ -624,6 +651,7 @@ export async function updateClientSiteDestinations(
async function handleSubnetProxyTargetUpdates(
siteResource: SiteResource,
+ sitesList: Site[],
allClients: {
clientId: number;
pubKey: string | null;
@@ -638,125 +666,138 @@ async function handleSubnetProxyTargetUpdates(
clientSiteResourcesToRemove: number[],
trx: Transaction | typeof db = db
): Promise {
- // Get the newt for this site
- const [newt] = await trx
- .select()
- .from(newts)
- .where(eq(newts.siteId, siteResource.siteId))
- .limit(1);
+ const proxyJobs: Promise[] = [];
+ const olmJobs: Promise[] = [];
- if (!newt) {
- logger.warn(
- `Newt not found for site ${siteResource.siteId}, skipping subnet proxy target updates`
- );
- return;
- }
+ for (const siteData of sitesList) {
+ const siteId = siteData.siteId;
- const proxyJobs = [];
- const olmJobs = [];
- // Generate targets for added associations
- if (clientSiteResourcesToAdd.length > 0) {
- const addedClients = allClients.filter((client) =>
- clientSiteResourcesToAdd.includes(client.clientId)
- );
+ // Get the newt for this site
+ const [newt] = await trx
+ .select()
+ .from(newts)
+ .where(eq(newts.siteId, siteId))
+ .limit(1);
- if (addedClients.length > 0) {
- const targetToAdd = await generateSubnetProxyTargetV2(
- siteResource,
- addedClients
+ if (!newt) {
+ logger.warn(
+ `Newt not found for site ${siteId}, skipping subnet proxy target updates`
);
-
- if (targetToAdd) {
- proxyJobs.push(
- addSubnetProxyTargets(
- newt.newtId,
- [targetToAdd],
- newt.version
- )
- );
- }
-
- for (const client of addedClients) {
- olmJobs.push(
- addPeerData(
- client.clientId,
- siteResource.siteId,
- generateRemoteSubnets([siteResource]),
- generateAliasConfig([siteResource])
- )
- );
- }
+ continue;
}
- }
- // here we use the existingSiteResource from BEFORE we updated the destination so we dont need to worry about updating destinations here
-
- // Generate targets for removed associations
- if (clientSiteResourcesToRemove.length > 0) {
- const removedClients = existingClients.filter((client) =>
- clientSiteResourcesToRemove.includes(client.clientId)
- );
-
- if (removedClients.length > 0) {
- const targetToRemove = await generateSubnetProxyTargetV2(
- siteResource,
- removedClients
+ // Generate targets for added associations
+ if (clientSiteResourcesToAdd.length > 0) {
+ const addedClients = allClients.filter((client) =>
+ clientSiteResourcesToAdd.includes(client.clientId)
);
- if (targetToRemove) {
- proxyJobs.push(
- removeSubnetProxyTargets(
- newt.newtId,
- [targetToRemove],
- newt.version
- )
+ if (addedClients.length > 0) {
+ const targetToAdd = await generateSubnetProxyTargetV2(
+ siteResource,
+ addedClients
);
- }
- for (const client of removedClients) {
- // Check if this client still has access to another resource on this site with the same destination
- const destinationStillInUse = await trx
- .select()
- .from(siteResources)
- .innerJoin(
- clientSiteResourcesAssociationsCache,
- eq(
- clientSiteResourcesAssociationsCache.siteResourceId,
- siteResources.siteResourceId
- )
- )
- .where(
- and(
- eq(
- clientSiteResourcesAssociationsCache.clientId,
- client.clientId
- ),
- eq(siteResources.siteId, siteResource.siteId),
- eq(
- siteResources.destination,
- siteResource.destination
- ),
- ne(
- siteResources.siteResourceId,
- siteResource.siteResourceId
- )
+ if (targetToAdd) {
+ proxyJobs.push(
+ addSubnetProxyTargets(
+ newt.newtId,
+ [targetToAdd],
+ newt.version
)
);
+ }
- // Only remove remote subnet if no other resource uses the same destination
- const remoteSubnetsToRemove =
- destinationStillInUse.length > 0
- ? []
- : generateRemoteSubnets([siteResource]);
+ for (const client of addedClients) {
+ olmJobs.push(
+ addPeerData(
+ client.clientId,
+ siteId,
+ generateRemoteSubnets([siteResource]),
+ generateAliasConfig([siteResource])
+ )
+ );
+ }
+ }
+ }
- olmJobs.push(
- removePeerData(
- client.clientId,
- siteResource.siteId,
- remoteSubnetsToRemove,
- generateAliasConfig([siteResource])
- )
+ // here we use the existingSiteResource from BEFORE we updated the destination so we dont need to worry about updating destinations here
+
+ // Generate targets for removed associations
+ if (clientSiteResourcesToRemove.length > 0) {
+ const removedClients = existingClients.filter((client) =>
+ clientSiteResourcesToRemove.includes(client.clientId)
+ );
+
+ if (removedClients.length > 0) {
+ const targetToRemove = await generateSubnetProxyTargetV2(
+ siteResource,
+ removedClients
);
+
+ if (targetToRemove) {
+ proxyJobs.push(
+ removeSubnetProxyTargets(
+ newt.newtId,
+ [targetToRemove],
+ newt.version
+ )
+ );
+ }
+
+ for (const client of removedClients) {
+ // Check if this client still has access to another resource
+ // on this specific site with the same destination. We scope
+ // by siteId (via siteNetworks) rather than networkId because
+ // removePeerData operates per-site — a resource on a different
+ // site sharing the same network should not block removal here.
+ const destinationStillInUse = await trx
+ .select()
+ .from(siteResources)
+ .innerJoin(
+ clientSiteResourcesAssociationsCache,
+ eq(
+ clientSiteResourcesAssociationsCache.siteResourceId,
+ siteResources.siteResourceId
+ )
+ )
+ .innerJoin(
+ siteNetworks,
+ eq(siteNetworks.networkId, siteResources.networkId)
+ )
+ .where(
+ and(
+ eq(
+ clientSiteResourcesAssociationsCache.clientId,
+ client.clientId
+ ),
+ eq(siteNetworks.siteId, siteId),
+ eq(
+ siteResources.destination,
+ siteResource.destination
+ ),
+ ne(
+ siteResources.siteResourceId,
+ siteResource.siteResourceId
+ )
+ )
+ );
+
+ // Only remove remote subnet if no other resource uses the same destination
+ const remoteSubnetsToRemove =
+ destinationStillInUse.length > 0
+ ? []
+ : generateRemoteSubnets([siteResource]);
+
+ olmJobs.push(
+ removePeerData(
+ client.clientId,
+ siteId,
+ remoteSubnetsToRemove,
+ generateAliasConfig([siteResource])
+ )
+ );
+ }
}
}
}
@@ -863,10 +904,25 @@ export async function rebuildClientAssociationsFromClient(
)
: [];
- // Group by siteId for site-level associations
- const newSiteIds = Array.from(
- new Set(newSiteResources.map((sr) => sr.siteId))
+ // Group by siteId for site-level associations — look up via siteNetworks since
+ // siteResources no longer carries a direct siteId column.
+ const networkIds = Array.from(
+ new Set(
+ newSiteResources
+ .map((sr) => sr.networkId)
+ .filter((id): id is number => id !== null)
+ )
);
+ const newSiteIds =
+ networkIds.length > 0
+ ? await trx
+ .select({ siteId: siteNetworks.siteId })
+ .from(siteNetworks)
+ .where(inArray(siteNetworks.networkId, networkIds))
+ .then((rows) =>
+ Array.from(new Set(rows.map((r) => r.siteId)))
+ )
+ : [];
/////////// Process client-siteResource associations ///////////
@@ -1139,13 +1195,45 @@ async function handleMessagesForClientResources(
resourcesToAdd.includes(r.siteResourceId)
);
+ // Build (resource, siteId) pairs by looking up siteNetworks for each resource's networkId
+ const addedNetworkIds = Array.from(
+ new Set(
+ addedResources
+ .map((r) => r.networkId)
+ .filter((id): id is number => id !== null)
+ )
+ );
+ const addedSiteNetworkRows =
+ addedNetworkIds.length > 0
+ ? await trx
+ .select({
+ networkId: siteNetworks.networkId,
+ siteId: siteNetworks.siteId
+ })
+ .from(siteNetworks)
+ .where(inArray(siteNetworks.networkId, addedNetworkIds))
+ : [];
+ const addedNetworkToSites = new Map();
+ for (const row of addedSiteNetworkRows) {
+ if (!addedNetworkToSites.has(row.networkId)) {
+ addedNetworkToSites.set(row.networkId, []);
+ }
+ addedNetworkToSites.get(row.networkId)!.push(row.siteId);
+ }
+
// Group by site for proxy updates
const addedBySite = new Map();
for (const resource of addedResources) {
- if (!addedBySite.has(resource.siteId)) {
- addedBySite.set(resource.siteId, []);
+ const siteIds =
+ resource.networkId != null
+ ? (addedNetworkToSites.get(resource.networkId) ?? [])
+ : [];
+ for (const siteId of siteIds) {
+ if (!addedBySite.has(siteId)) {
+ addedBySite.set(siteId, []);
+ }
+ addedBySite.get(siteId)!.push(resource);
}
- addedBySite.get(resource.siteId)!.push(resource);
}
// Add subnet proxy targets for each site
@@ -1187,7 +1275,7 @@ async function handleMessagesForClientResources(
olmJobs.push(
addPeerData(
client.clientId,
- resource.siteId,
+ siteId,
generateRemoteSubnets([resource]),
generateAliasConfig([resource])
)
@@ -1199,7 +1287,7 @@ async function handleMessagesForClientResources(
error.message.includes("not found")
) {
logger.debug(
- `Olm data not found for client ${client.clientId} and site ${resource.siteId}, skipping removal`
+ `Olm data not found for client ${client.clientId} and site ${siteId}, skipping addition`
);
} else {
throw error;
@@ -1216,13 +1304,45 @@ async function handleMessagesForClientResources(
.from(siteResources)
.where(inArray(siteResources.siteResourceId, resourcesToRemove));
+ // Build (resource, siteId) pairs via siteNetworks
+ const removedNetworkIds = Array.from(
+ new Set(
+ removedResources
+ .map((r) => r.networkId)
+ .filter((id): id is number => id !== null)
+ )
+ );
+ const removedSiteNetworkRows =
+ removedNetworkIds.length > 0
+ ? await trx
+ .select({
+ networkId: siteNetworks.networkId,
+ siteId: siteNetworks.siteId
+ })
+ .from(siteNetworks)
+ .where(inArray(siteNetworks.networkId, removedNetworkIds))
+ : [];
+ const removedNetworkToSites = new Map();
+ for (const row of removedSiteNetworkRows) {
+ if (!removedNetworkToSites.has(row.networkId)) {
+ removedNetworkToSites.set(row.networkId, []);
+ }
+ removedNetworkToSites.get(row.networkId)!.push(row.siteId);
+ }
+
// Group by site for proxy updates
const removedBySite = new Map();
for (const resource of removedResources) {
- if (!removedBySite.has(resource.siteId)) {
- removedBySite.set(resource.siteId, []);
+ const siteIds =
+ resource.networkId != null
+ ? (removedNetworkToSites.get(resource.networkId) ?? [])
+ : [];
+ for (const siteId of siteIds) {
+ if (!removedBySite.has(siteId)) {
+ removedBySite.set(siteId, []);
+ }
+ removedBySite.get(siteId)!.push(resource);
}
- removedBySite.get(resource.siteId)!.push(resource);
}
// Remove subnet proxy targets for each site
@@ -1260,7 +1380,11 @@ async function handleMessagesForClientResources(
}
try {
- // Check if this client still has access to another resource on this site with the same destination
+ // Check if this client still has access to another resource
+ // on this specific site with the same destination. We scope
+ // by siteId (via siteNetworks) rather than networkId because
+ // removePeerData operates per-site — a resource on a different
+ // site sharing the same network should not block removal here.
const destinationStillInUse = await trx
.select()
.from(siteResources)
@@ -1271,13 +1395,17 @@ async function handleMessagesForClientResources(
siteResources.siteResourceId
)
)
+ .innerJoin(
+ siteNetworks,
+ eq(siteNetworks.networkId, siteResources.networkId)
+ )
.where(
and(
eq(
clientSiteResourcesAssociationsCache.clientId,
client.clientId
),
- eq(siteResources.siteId, resource.siteId),
+ eq(siteNetworks.siteId, siteId),
eq(
siteResources.destination,
resource.destination
@@ -1299,7 +1427,7 @@ async function handleMessagesForClientResources(
olmJobs.push(
removePeerData(
client.clientId,
- resource.siteId,
+ siteId,
remoteSubnetsToRemove,
generateAliasConfig([resource])
)
@@ -1311,7 +1439,7 @@ async function handleMessagesForClientResources(
error.message.includes("not found")
) {
logger.debug(
- `Olm data not found for client ${client.clientId} and site ${resource.siteId}, skipping removal`
+ `Olm data not found for client ${client.clientId} and site ${siteId}, skipping removal`
);
} else {
throw error;
diff --git a/server/private/lib/acmeCertSync.ts b/server/private/lib/acmeCertSync.ts
index aff3efaec..9bedc6a3e 100644
--- a/server/private/lib/acmeCertSync.ts
+++ b/server/private/lib/acmeCertSync.ts
@@ -20,6 +20,7 @@ import {
db,
domains,
newts,
+ siteNetworks,
SiteResource,
siteResources
} from "@server/db";
@@ -91,16 +92,17 @@ async function pushCertUpdateToAffectedNewts(
for (const resource of affectedResources) {
try {
- // Get the newt for this site
- const [newt] = await db
- .select()
- .from(newts)
- .where(eq(newts.siteId, resource.siteId))
- .limit(1);
+ // Get all sites for this resource via siteNetworks
+ const resourceSiteRows = resource.networkId
+ ? await db
+ .select({ siteId: siteNetworks.siteId })
+ .from(siteNetworks)
+ .where(eq(siteNetworks.networkId, resource.networkId))
+ : [];
- if (!newt) {
+ if (resourceSiteRows.length === 0) {
logger.debug(
- `acmeCertSync: no newt found for site ${resource.siteId}, skipping resource ${resource.siteResourceId}`
+ `acmeCertSync: no sites for resource ${resource.siteResourceId}, skipping`
);
continue;
}
@@ -139,7 +141,7 @@ async function pushCertUpdateToAffectedNewts(
await cache.del(`cert:${resource.fullDomain}`);
}
- // Generate the new target (will read the freshly updated cert from DB)
+ // Generate target once — same cert applies to all sites for this resource
const newTarget = await generateSubnetProxyTargetV2(
resource,
resourceClients
@@ -161,15 +163,31 @@ async function pushCertUpdateToAffectedNewts(
tlsKey: oldKeyPem ?? undefined
};
- await updateTargets(
- newt.newtId,
- { oldTargets: [oldTarget], newTargets: [newTarget] },
- newt.version
- );
+ // Push update to each site's newt
+ for (const { siteId } of resourceSiteRows) {
+ const [newt] = await db
+ .select()
+ .from(newts)
+ .where(eq(newts.siteId, siteId))
+ .limit(1);
- logger.info(
- `acmeCertSync: pushed cert update to newt for site ${resource.siteId}, resource ${resource.siteResourceId}`
- );
+ if (!newt) {
+ logger.debug(
+ `acmeCertSync: no newt found for site ${siteId}, skipping resource ${resource.siteResourceId}`
+ );
+ continue;
+ }
+
+ await updateTargets(
+ newt.newtId,
+ { oldTargets: [oldTarget], newTargets: [newTarget] },
+ newt.version
+ );
+
+ logger.info(
+ `acmeCertSync: pushed cert update to newt for site ${siteId}, resource ${resource.siteResourceId}`
+ );
+ }
} catch (err) {
logger.error(
`acmeCertSync: error pushing cert update for resource ${resource?.siteResourceId}: ${err}`
diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts
index 5b9c2da8a..6be0636fa 100644
--- a/server/private/lib/traefik/getTraefikConfig.ts
+++ b/server/private/lib/traefik/getTraefikConfig.ts
@@ -33,7 +33,7 @@ import {
} from "drizzle-orm";
import logger from "@server/logger";
import config from "@server/lib/config";
-import { orgs, resources, sites, siteResources, Target, targets } from "@server/db";
+import { orgs, resources, sites, siteNetworks, siteResources, Target, targets } from "@server/db";
import {
sanitize,
encodePath,
@@ -275,7 +275,8 @@ export async function getTraefikConfig(
mode: siteResources.mode
})
.from(siteResources)
- .innerJoin(sites, eq(sites.siteId, siteResources.siteId))
+ .innerJoin(siteNetworks, eq(siteResources.networkId, siteNetworks.networkId))
+ .innerJoin(sites, eq(siteNetworks.siteId, sites.siteId))
.where(
and(
eq(siteResources.enabled, true),
diff --git a/server/private/routers/domain/checkDomainNamespaceAvailability.ts b/server/private/routers/domain/checkDomainNamespaceAvailability.ts
index db9a4b46a..0bb7f8704 100644
--- a/server/private/routers/domain/checkDomainNamespaceAvailability.ts
+++ b/server/private/routers/domain/checkDomainNamespaceAvailability.ts
@@ -22,11 +22,15 @@ import { OpenAPITags, registry } from "@server/openApi";
import { db, domainNamespaces, resources } from "@server/db";
import { inArray } from "drizzle-orm";
import { CheckDomainAvailabilityResponse } from "@server/routers/domain/types";
+import { build } from "@server/build";
+import { isSubscribed } from "#private/lib/isSubscribed";
+import { tierMatrix } from "@server/lib/billing/tierMatrix";
const paramsSchema = z.strictObject({});
const querySchema = z.strictObject({
- subdomain: z.string()
+ subdomain: z.string(),
+ // orgId: build === "saas" ? z.string() : z.string().optional() // Required for saas, optional otherwise
});
registry.registerPath({
@@ -58,6 +62,23 @@ export async function checkDomainNamespaceAvailability(
}
const { subdomain } = parsedQuery.data;
+ // if (
+ // build == "saas" &&
+ // !isSubscribed(orgId!, tierMatrix.domainNamespaces)
+ // ) {
+ // // return not available
+ // return response(res, {
+ // data: {
+ // available: false,
+ // options: []
+ // },
+ // success: true,
+ // error: false,
+ // message: "Your current subscription does not support custom domain namespaces. Please upgrade to access this feature.",
+ // status: HttpCode.OK
+ // });
+ // }
+
const namespaces = await db.select().from(domainNamespaces);
let possibleDomains = namespaces.map((ns) => {
const desired = `${subdomain}.${ns.domainNamespaceId}`;
diff --git a/server/private/routers/domain/listDomainNamespaces.ts b/server/private/routers/domain/listDomainNamespaces.ts
index 180613a85..5bbd25b1a 100644
--- a/server/private/routers/domain/listDomainNamespaces.ts
+++ b/server/private/routers/domain/listDomainNamespaces.ts
@@ -22,6 +22,9 @@ import { eq, sql } from "drizzle-orm";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
+import { isSubscribed } from "#private/lib/isSubscribed";
+import { build } from "@server/build";
+import { tierMatrix } from "@server/lib/billing/tierMatrix";
const paramsSchema = z.strictObject({});
@@ -37,7 +40,8 @@ const querySchema = z.strictObject({
.optional()
.default("0")
.transform(Number)
- .pipe(z.int().nonnegative())
+ .pipe(z.int().nonnegative()),
+ // orgId: build === "saas" ? z.string() : z.string().optional() // Required for saas, optional otherwise
});
async function query(limit: number, offset: number) {
@@ -99,6 +103,26 @@ export async function listDomainNamespaces(
);
}
+ // if (
+ // build == "saas" &&
+ // !isSubscribed(orgId!, tierMatrix.domainNamespaces)
+ // ) {
+ // return response(res, {
+ // data: {
+ // domainNamespaces: [],
+ // pagination: {
+ // total: 0,
+ // limit,
+ // offset
+ // }
+ // },
+ // success: true,
+ // error: false,
+ // message: "No namespaces found. Your current subscription does not support custom domain namespaces. Please upgrade to access this feature.",
+ // status: HttpCode.OK
+ // });
+ // }
+
const domainNamespacesList = await query(limit, offset);
const [{ count }] = await db
diff --git a/server/private/routers/ssh/signSshKey.ts b/server/private/routers/ssh/signSshKey.ts
index b02d2b23c..b5eea8f2d 100644
--- a/server/private/routers/ssh/signSshKey.ts
+++ b/server/private/routers/ssh/signSshKey.ts
@@ -21,7 +21,7 @@ import {
roles,
roundTripMessageTracker,
siteResources,
- sites,
+ siteNetworks,
userOrgs
} from "@server/db";
import { logAccessAudit } from "#private/lib/logAccessAudit";
@@ -63,10 +63,12 @@ const bodySchema = z
export type SignSshKeyResponse = {
certificate: string;
+ messageIds: number[];
messageId: number;
sshUsername: string;
sshHost: string;
resourceId: number;
+ siteIds: number[];
siteId: number;
keyId: string;
validPrincipals: string[];
@@ -260,10 +262,7 @@ export async function signSshKey(
.update(userOrgs)
.set({ pamUsername: usernameToUse })
.where(
- and(
- eq(userOrgs.orgId, orgId),
- eq(userOrgs.userId, userId)
- )
+ and(eq(userOrgs.orgId, orgId), eq(userOrgs.userId, userId))
);
} else {
usernameToUse = userOrg.pamUsername;
@@ -395,21 +394,12 @@ export async function signSshKey(
homedir = roleRows[0].sshCreateHomeDir ?? null;
}
- // get the site
- const [newt] = await db
- .select()
- .from(newts)
- .where(eq(newts.siteId, resource.siteId))
- .limit(1);
+ const sites = await db
+ .select({ siteId: siteNetworks.siteId })
+ .from(siteNetworks)
+ .where(eq(siteNetworks.networkId, resource.networkId!));
- if (!newt) {
- return next(
- createHttpError(
- HttpCode.INTERNAL_SERVER_ERROR,
- "Site associated with resource not found"
- )
- );
- }
+ const siteIds = sites.map((site) => site.siteId);
// Sign the public key
const now = BigInt(Math.floor(Date.now() / 1000));
@@ -423,43 +413,64 @@ export async function signSshKey(
validBefore: now + validFor
});
- const [message] = await db
- .insert(roundTripMessageTracker)
- .values({
- wsClientId: newt.newtId,
- messageType: `newt/pam/connection`,
- sentAt: Math.floor(Date.now() / 1000)
- })
- .returning();
+ const messageIds: number[] = [];
+ for (const siteId of siteIds) {
+ // get the site
+ const [newt] = await db
+ .select()
+ .from(newts)
+ .where(eq(newts.siteId, siteId))
+ .limit(1);
- if (!message) {
- return next(
- createHttpError(
- HttpCode.INTERNAL_SERVER_ERROR,
- "Failed to create message tracker entry"
- )
- );
- }
-
- await sendToClient(newt.newtId, {
- type: `newt/pam/connection`,
- data: {
- messageId: message.messageId,
- orgId: orgId,
- agentPort: resource.authDaemonPort ?? 22123,
- externalAuthDaemon: resource.authDaemonMode === "remote",
- agentHost: resource.destination,
- caCert: caKeys.publicKeyOpenSSH,
- username: usernameToUse,
- niceId: resource.niceId,
- metadata: {
- sudoMode: sudoMode,
- sudoCommands: parsedSudoCommands,
- homedir: homedir,
- groups: parsedGroups
- }
+ if (!newt) {
+ return next(
+ createHttpError(
+ HttpCode.INTERNAL_SERVER_ERROR,
+ "Site associated with resource not found"
+ )
+ );
}
- });
+
+ const [message] = await db
+ .insert(roundTripMessageTracker)
+ .values({
+ wsClientId: newt.newtId,
+ messageType: `newt/pam/connection`,
+ sentAt: Math.floor(Date.now() / 1000)
+ })
+ .returning();
+
+ if (!message) {
+ return next(
+ createHttpError(
+ HttpCode.INTERNAL_SERVER_ERROR,
+ "Failed to create message tracker entry"
+ )
+ );
+ }
+
+ messageIds.push(message.messageId);
+
+ await sendToClient(newt.newtId, {
+ type: `newt/pam/connection`,
+ data: {
+ messageId: message.messageId,
+ orgId: orgId,
+ agentPort: resource.authDaemonPort ?? 22123,
+ externalAuthDaemon: resource.authDaemonMode === "remote",
+ agentHost: resource.destination,
+ caCert: caKeys.publicKeyOpenSSH,
+ username: usernameToUse,
+ niceId: resource.niceId,
+ metadata: {
+ sudoMode: sudoMode,
+ sudoCommands: parsedSudoCommands,
+ homedir: homedir,
+ groups: parsedGroups
+ }
+ }
+ });
+ }
const expiresIn = Number(validFor); // seconds
@@ -480,7 +491,7 @@ export async function signSshKey(
metadata: JSON.stringify({
resourceId: resource.siteResourceId,
resource: resource.name,
- siteId: resource.siteId,
+ siteIds: siteIds
})
});
@@ -494,7 +505,7 @@ export async function signSshKey(
: undefined,
metadata: {
resourceName: resource.name,
- siteId: resource.siteId,
+ siteId: siteIds[0],
sshUsername: usernameToUse,
sshHost: sshHost
},
@@ -505,11 +516,13 @@ export async function signSshKey(
return response(res, {
data: {
certificate: cert.certificate,
- messageId: message.messageId,
+ messageIds: messageIds,
+ messageId: messageIds[0], // just pick the first one for backward compatibility
sshUsername: usernameToUse,
sshHost: sshHost,
resourceId: resource.siteResourceId,
- siteId: resource.siteId,
+ siteIds: siteIds,
+ siteId: siteIds[0], // just pick the first one for backward compatibility
keyId: cert.keyId,
validPrincipals: cert.validPrincipals,
validAfter: cert.validAfter.toISOString(),
diff --git a/server/routers/newt/buildConfiguration.ts b/server/routers/newt/buildConfiguration.ts
index 5e79804b7..ec0547d42 100644
--- a/server/routers/newt/buildConfiguration.ts
+++ b/server/routers/newt/buildConfiguration.ts
@@ -4,8 +4,10 @@ import {
clientSitesAssociationsCache,
db,
ExitNode,
+ networks,
resources,
Site,
+ siteNetworks,
siteResources,
targetHealthCheck,
targets
@@ -137,11 +139,14 @@ export async function buildClientConfigurationForNewtClient(
// Filter out any null values from peers that didn't have an olm
const validPeers = peers.filter((peer) => peer !== null);
- // Get all enabled site resources for this site
+ // Get all enabled site resources for this site by joining through siteNetworks and networks
const allSiteResources = await db
.select()
.from(siteResources)
- .where(eq(siteResources.siteId, siteId));
+ .innerJoin(networks, eq(siteResources.networkId, networks.networkId))
+ .innerJoin(siteNetworks, eq(networks.networkId, siteNetworks.networkId))
+ .where(eq(siteNetworks.siteId, siteId))
+ .then((rows) => rows.map((r) => r.siteResources));
const targetsToSend: SubnetProxyTargetV2[] = [];
diff --git a/server/routers/olm/buildConfiguration.ts b/server/routers/olm/buildConfiguration.ts
index bc2611b1c..4182725d3 100644
--- a/server/routers/olm/buildConfiguration.ts
+++ b/server/routers/olm/buildConfiguration.ts
@@ -4,6 +4,8 @@ import {
clientSitesAssociationsCache,
db,
exitNodes,
+ networks,
+ siteNetworks,
siteResources,
sites
} from "@server/db";
@@ -59,9 +61,17 @@ export async function buildSiteConfigurationForOlmClient(
clientSiteResourcesAssociationsCache.siteResourceId
)
)
+ .innerJoin(
+ networks,
+ eq(siteResources.networkId, networks.networkId)
+ )
+ .innerJoin(
+ siteNetworks,
+ eq(networks.networkId, siteNetworks.networkId)
+ )
.where(
and(
- eq(siteResources.siteId, site.siteId),
+ eq(siteNetworks.siteId, site.siteId),
eq(
clientSiteResourcesAssociationsCache.clientId,
client.clientId
@@ -69,6 +79,7 @@ export async function buildSiteConfigurationForOlmClient(
)
);
+
if (jitMode) {
// Add site configuration to the array
siteConfigurations.push({
diff --git a/server/routers/olm/handleOlmServerInitAddPeerHandshake.ts b/server/routers/olm/handleOlmServerInitAddPeerHandshake.ts
index 54badb2dc..0eda41e04 100644
--- a/server/routers/olm/handleOlmServerInitAddPeerHandshake.ts
+++ b/server/routers/olm/handleOlmServerInitAddPeerHandshake.ts
@@ -4,10 +4,12 @@ import {
db,
exitNodes,
Site,
- siteResources
+ siteNetworks,
+ siteResources,
+ sites
} from "@server/db";
import { MessageHandler } from "@server/routers/ws";
-import { clients, Olm, sites } from "@server/db";
+import { clients, Olm } from "@server/db";
import { and, eq, or } from "drizzle-orm";
import logger from "@server/logger";
import { initPeerAddHandshake } from "./peers";
@@ -44,20 +46,31 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
const { siteId, resourceId, chainId } = message.data;
- let site: Site | null = null;
+ const sendCancel = async () => {
+ await sendToClient(
+ olm.olmId,
+ {
+ type: "olm/wg/peer/chain/cancel",
+ data: { chainId }
+ },
+ { incrementConfigVersion: false }
+ ).catch((error) => {
+ logger.warn(`Error sending message:`, error);
+ });
+ };
+
+ let sitesToProcess: Site[] = [];
+
if (siteId) {
- // get the site
const [siteRes] = await db
.select()
.from(sites)
.where(eq(sites.siteId, siteId))
.limit(1);
if (siteRes) {
- site = siteRes;
+ sitesToProcess = [siteRes];
}
- }
-
- if (resourceId && !site) {
+ } else if (resourceId) {
const resources = await db
.select()
.from(siteResources)
@@ -72,27 +85,17 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
);
if (!resources || resources.length === 0) {
- logger.error(`handleOlmServerPeerAddMessage: Resource not found`);
- // cancel the request from the olm side to not keep doing this
- await sendToClient(
- olm.olmId,
- {
- type: "olm/wg/peer/chain/cancel",
- data: {
- chainId
- }
- },
- { incrementConfigVersion: false }
- ).catch((error) => {
- logger.warn(`Error sending message:`, error);
- });
+ logger.error(
+ `handleOlmServerInitAddPeerHandshake: Resource not found`
+ );
+ await sendCancel();
return;
}
if (resources.length > 1) {
// error but this should not happen because the nice id cant contain a dot and the alias has to have a dot and both have to be unique within the org so there should never be multiple matches
logger.error(
- `handleOlmServerPeerAddMessage: Multiple resources found matching the criteria`
+ `handleOlmServerInitAddPeerHandshake: Multiple resources found matching the criteria`
);
return;
}
@@ -117,125 +120,120 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
if (currentResourceAssociationCaches.length === 0) {
logger.error(
- `handleOlmServerPeerAddMessage: Client ${client.clientId} does not have access to resource ${resource.siteResourceId}`
+ `handleOlmServerInitAddPeerHandshake: Client ${client.clientId} does not have access to resource ${resource.siteResourceId}`
);
- // cancel the request from the olm side to not keep doing this
- await sendToClient(
- olm.olmId,
- {
- type: "olm/wg/peer/chain/cancel",
- data: {
- chainId
- }
- },
- { incrementConfigVersion: false }
- ).catch((error) => {
- logger.warn(`Error sending message:`, error);
- });
+ await sendCancel();
return;
}
- const siteIdFromResource = resource.siteId;
-
- // get the site
- const [siteRes] = await db
- .select()
- .from(sites)
- .where(eq(sites.siteId, siteIdFromResource));
- if (!siteRes) {
+ if (!resource.networkId) {
logger.error(
- `handleOlmServerPeerAddMessage: Site with ID ${site} not found`
+ `handleOlmServerInitAddPeerHandshake: Resource ${resource.siteResourceId} has no network`
);
+ await sendCancel();
return;
}
- site = siteRes;
+ // Get all sites associated with this resource's network via siteNetworks
+ const siteRows = await db
+ .select({ siteId: siteNetworks.siteId })
+ .from(siteNetworks)
+ .where(eq(siteNetworks.networkId, resource.networkId));
+
+ if (!siteRows || siteRows.length === 0) {
+ logger.error(
+ `handleOlmServerInitAddPeerHandshake: No sites found for resource ${resource.siteResourceId}`
+ );
+ await sendCancel();
+ return;
+ }
+
+ // Fetch full site objects for all network members
+ const foundSites = await Promise.all(
+ siteRows.map(async ({ siteId: sid }) => {
+ const [s] = await db
+ .select()
+ .from(sites)
+ .where(eq(sites.siteId, sid))
+ .limit(1);
+ return s ?? null;
+ })
+ );
+
+ sitesToProcess = foundSites.filter((s): s is Site => s !== null);
}
- if (!site) {
- logger.error(`handleOlmServerPeerAddMessage: Site not found`);
+ if (sitesToProcess.length === 0) {
+ logger.error(
+ `handleOlmServerInitAddPeerHandshake: No sites to process`
+ );
+ await sendCancel();
return;
}
- // check if the client can access this site using the cache
- const currentSiteAssociationCaches = await db
- .select()
- .from(clientSitesAssociationsCache)
- .where(
- and(
- eq(clientSitesAssociationsCache.clientId, client.clientId),
- eq(clientSitesAssociationsCache.siteId, site.siteId)
- )
- );
+ let handshakeInitiated = false;
- if (currentSiteAssociationCaches.length === 0) {
- logger.error(
- `handleOlmServerPeerAddMessage: Client ${client.clientId} does not have access to site ${site.siteId}`
- );
- // cancel the request from the olm side to not keep doing this
- await sendToClient(
- olm.olmId,
+ for (const site of sitesToProcess) {
+ // Check if the client can access this site using the cache
+ const currentSiteAssociationCaches = await db
+ .select()
+ .from(clientSitesAssociationsCache)
+ .where(
+ and(
+ eq(clientSitesAssociationsCache.clientId, client.clientId),
+ eq(clientSitesAssociationsCache.siteId, site.siteId)
+ )
+ );
+
+ if (currentSiteAssociationCaches.length === 0) {
+ logger.warn(
+ `handleOlmServerInitAddPeerHandshake: Client ${client.clientId} does not have access to site ${site.siteId}, skipping`
+ );
+ continue;
+ }
+
+ if (!site.exitNodeId) {
+ logger.error(
+ `handleOlmServerInitAddPeerHandshake: Site ${site.siteId} has no exit node, skipping`
+ );
+ continue;
+ }
+
+ const [exitNode] = await db
+ .select()
+ .from(exitNodes)
+ .where(eq(exitNodes.exitNodeId, site.exitNodeId));
+
+ if (!exitNode) {
+ logger.error(
+ `handleOlmServerInitAddPeerHandshake: Exit node not found for site ${site.siteId}, skipping`
+ );
+ continue;
+ }
+
+ // Trigger the peer add handshake — if the peer was already added this will be a no-op
+ await initPeerAddHandshake(
+ client.clientId,
{
- type: "olm/wg/peer/chain/cancel",
- data: {
- chainId
+ siteId: site.siteId,
+ exitNode: {
+ publicKey: exitNode.publicKey,
+ endpoint: exitNode.endpoint
}
},
- { incrementConfigVersion: false }
- ).catch((error) => {
- logger.warn(`Error sending message:`, error);
- });
- return;
- }
-
- if (!site.exitNodeId) {
- logger.error(
- `handleOlmServerPeerAddMessage: Site with ID ${site.siteId} has no exit node`
- );
- // cancel the request from the olm side to not keep doing this
- await sendToClient(
olm.olmId,
- {
- type: "olm/wg/peer/chain/cancel",
- data: {
- chainId
- }
- },
- { incrementConfigVersion: false }
- ).catch((error) => {
- logger.warn(`Error sending message:`, error);
- });
- return;
- }
-
- // get the exit node from the side
- const [exitNode] = await db
- .select()
- .from(exitNodes)
- .where(eq(exitNodes.exitNodeId, site.exitNodeId));
-
- if (!exitNode) {
- logger.error(
- `handleOlmServerPeerAddMessage: Site with ID ${site.siteId} has no exit node`
+ chainId
);
- return;
+
+ handshakeInitiated = true;
}
- // also trigger the peer add handshake in case the peer was not already added to the olm and we need to hole punch
- // if it has already been added this will be a no-op
- await initPeerAddHandshake(
- // this will kick off the add peer process for the client
- client.clientId,
- {
- siteId: site.siteId,
- exitNode: {
- publicKey: exitNode.publicKey,
- endpoint: exitNode.endpoint
- }
- },
- olm.olmId,
- chainId
- );
+ if (!handshakeInitiated) {
+ logger.error(
+ `handleOlmServerInitAddPeerHandshake: No accessible sites with valid exit nodes found, cancelling chain`
+ );
+ await sendCancel();
+ }
return;
-};
+};
\ No newline at end of file
diff --git a/server/routers/olm/handleOlmServerPeerAddMessage.ts b/server/routers/olm/handleOlmServerPeerAddMessage.ts
index 64284f493..5f46ea84c 100644
--- a/server/routers/olm/handleOlmServerPeerAddMessage.ts
+++ b/server/routers/olm/handleOlmServerPeerAddMessage.ts
@@ -1,43 +1,25 @@
import {
- Client,
clientSiteResourcesAssociationsCache,
db,
- ExitNode,
- Org,
- orgs,
- roleClients,
- roles,
+ networks,
+ siteNetworks,
siteResources,
- Transaction,
- userClients,
- userOrgs,
- users
} from "@server/db";
import { MessageHandler } from "@server/routers/ws";
import {
clients,
clientSitesAssociationsCache,
- exitNodes,
Olm,
- olms,
sites
} from "@server/db";
import { and, eq, inArray, isNotNull, isNull } from "drizzle-orm";
-import { addPeer, deletePeer } from "../newt/peers";
import logger from "@server/logger";
-import { listExitNodes } from "#dynamic/lib/exitNodes";
import {
generateAliasConfig,
- getNextAvailableClientSubnet
} from "@server/lib/ip";
import { generateRemoteSubnets } from "@server/lib/ip";
-import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
-import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
-import { validateSessionToken } from "@server/auth/sessions/app";
-import config from "@server/lib/config";
import {
addPeer as newtAddPeer,
- deletePeer as newtDeletePeer
} from "@server/routers/newt/peers";
export const handleOlmServerPeerAddMessage: MessageHandler = async (
@@ -153,13 +135,21 @@ export const handleOlmServerPeerAddMessage: MessageHandler = async (
clientSiteResourcesAssociationsCache.siteResourceId
)
)
- .where(
+ .innerJoin(
+ networks,
+ eq(siteResources.networkId, networks.networkId)
+ )
+ .innerJoin(
+ siteNetworks,
and(
- eq(siteResources.siteId, site.siteId),
- eq(
- clientSiteResourcesAssociationsCache.clientId,
- client.clientId
- )
+ eq(networks.networkId, siteNetworks.networkId),
+ eq(siteNetworks.siteId, site.siteId)
+ )
+ )
+ .where(
+ eq(
+ clientSiteResourcesAssociationsCache.clientId,
+ client.clientId
)
);
diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts
index 6cff4d23a..d8820de79 100644
--- a/server/routers/resource/createResource.ts
+++ b/server/routers/resource/createResource.ts
@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
-import { db, loginPage } from "@server/db";
+import { db, domainNamespaces, loginPage } from "@server/db";
import {
domains,
orgDomains,
@@ -24,6 +24,8 @@ import { build } from "@server/build";
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
import { getUniqueResourceName } from "@server/db/names";
import { validateAndConstructDomain } from "@server/lib/domainUtils";
+import { isSubscribed } from "#dynamic/lib/isSubscribed";
+import { tierMatrix } from "@server/lib/billing/tierMatrix";
const createResourceParamsSchema = z.strictObject({
orgId: z.string()
@@ -112,7 +114,10 @@ export async function createResource(
const { orgId } = parsedParams.data;
- if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) {
+ if (
+ req.user &&
+ (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)
+ ) {
return next(
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
);
@@ -193,6 +198,29 @@ async function createHttpResource(
const subdomain = parsedBody.data.subdomain;
const stickySession = parsedBody.data.stickySession;
+ if (build == "saas" && !isSubscribed(orgId!, tierMatrix.domainNamespaces)) {
+ // grandfather in existing users
+ const lastAllowedDate = new Date("2026-04-12");
+ const userCreatedDate = new Date(req.user?.dateCreated || new Date());
+ if (userCreatedDate > lastAllowedDate) {
+ // check if this domain id is a namespace domain and if so, reject
+ const domain = await db
+ .select()
+ .from(domainNamespaces)
+ .where(eq(domainNamespaces.domainId, domainId))
+ .limit(1);
+
+ if (domain.length > 0) {
+ return next(
+ createHttpError(
+ HttpCode.BAD_REQUEST,
+ "Your current subscription does not support custom domain namespaces. Please upgrade to access this feature."
+ )
+ );
+ }
+ }
+ }
+
// Validate domain and construct full domain
const domainResult = await validateAndConstructDomain(
domainId,
diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts
index 01f3e79ff..07e566194 100644
--- a/server/routers/resource/updateResource.ts
+++ b/server/routers/resource/updateResource.ts
@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
-import { db, loginPage } from "@server/db";
+import { db, domainNamespaces, loginPage } from "@server/db";
import {
domains,
Org,
@@ -25,6 +25,7 @@ import { validateAndConstructDomain } from "@server/lib/domainUtils";
import { build } from "@server/build";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
+import { isSubscribed } from "#dynamic/lib/isSubscribed";
const updateResourceParamsSchema = z.strictObject({
resourceId: z.string().transform(Number).pipe(z.int().positive())
@@ -120,7 +121,9 @@ const updateHttpResourceBodySchema = z
if (data.headers) {
// HTTP header values must be visible ASCII or horizontal whitespace, no control chars (RFC 7230)
const validHeaderValue = /^[\t\x20-\x7E]*$/;
- return data.headers.every((h) => validHeaderValue.test(h.value));
+ return data.headers.every((h) =>
+ validHeaderValue.test(h.value)
+ );
}
return true;
},
@@ -318,6 +321,34 @@ async function updateHttpResource(
if (updateData.domainId) {
const domainId = updateData.domainId;
+ if (
+ build == "saas" &&
+ !isSubscribed(resource.orgId, tierMatrix.domainNamespaces)
+ ) {
+ // grandfather in existing users
+ const lastAllowedDate = new Date("2026-04-12");
+ const userCreatedDate = new Date(
+ req.user?.dateCreated || new Date()
+ );
+ if (userCreatedDate > lastAllowedDate) {
+ // check if this domain id is a namespace domain and if so, reject
+ const domain = await db
+ .select()
+ .from(domainNamespaces)
+ .where(eq(domainNamespaces.domainId, domainId))
+ .limit(1);
+
+ if (domain.length > 0) {
+ return next(
+ createHttpError(
+ HttpCode.BAD_REQUEST,
+ "Your current subscription does not support custom domain namespaces. Please upgrade to access this feature."
+ )
+ );
+ }
+ }
+ }
+
// Validate domain and construct full domain
const domainResult = await validateAndConstructDomain(
domainId,
@@ -366,7 +397,7 @@ async function updateHttpResource(
);
}
}
-
+
if (build != "oss") {
const existingLoginPages = await db
.select()
diff --git a/server/routers/site/deleteSite.ts b/server/routers/site/deleteSite.ts
index 587572535..344f6b4e3 100644
--- a/server/routers/site/deleteSite.ts
+++ b/server/routers/site/deleteSite.ts
@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
-import { db, Site, siteResources } from "@server/db";
+import { db, Site, siteNetworks, siteResources } from "@server/db";
import { newts, newtSessions, sites } from "@server/db";
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
@@ -71,18 +71,23 @@ export async function deleteSite(
await deletePeer(site.exitNodeId!, site.pubKey);
}
} else if (site.type == "newt") {
- // delete all of the site resources on this site
- const siteResourcesOnSite = trx
- .delete(siteResources)
- .where(eq(siteResources.siteId, siteId))
- .returning();
+ const networks = await trx
+ .select({ networkId: siteNetworks.networkId })
+ .from(siteNetworks)
+ .where(eq(siteNetworks.siteId, siteId));
// loop through them
- for (const removedSiteResource of await siteResourcesOnSite) {
- await rebuildClientAssociationsFromSiteResource(
- removedSiteResource,
- trx
- );
+ for (const network of await networks) {
+ const [siteResource] = await trx
+ .select()
+ .from(siteResources)
+ .where(eq(siteResources.networkId, network.networkId));
+ if (siteResource) {
+ await rebuildClientAssociationsFromSiteResource(
+ siteResource,
+ trx
+ );
+ }
}
// get the newt on the site by querying the newt table for siteId
diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts
index b5cd64656..da5355c9e 100644
--- a/server/routers/siteResource/createSiteResource.ts
+++ b/server/routers/siteResource/createSiteResource.ts
@@ -5,6 +5,8 @@ import {
orgs,
roles,
roleSiteResources,
+ siteNetworks,
+ networks,
SiteResource,
siteResources,
sites,
@@ -23,7 +25,7 @@ import response from "@server/lib/response";
import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
import HttpCode from "@server/types/HttpCode";
-import { and, eq } from "drizzle-orm";
+import { and, eq, inArray } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
@@ -39,8 +41,8 @@ const createSiteResourceSchema = z
name: z.string().min(1).max(255),
mode: z.enum(["host", "cidr", "http"]),
ssl: z.boolean().optional(), // only used for http mode
- siteId: z.int(),
scheme: z.enum(["http", "https"]).optional(),
+ siteIds: z.array(z.int()),
// proxyPort: z.int().positive().optional(),
destinationPort: z.int().positive().optional(),
destination: z.string().min(1),
@@ -180,7 +182,7 @@ export async function createSiteResource(
const { orgId } = parsedParams.data;
const {
name,
- siteId,
+ siteIds,
mode,
scheme,
// proxyPort,
@@ -217,14 +219,16 @@ export async function createSiteResource(
}
// Verify the site exists and belongs to the org
- const [site] = await db
+ const sitesToAssign = await db
.select()
.from(sites)
- .where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
+ .where(and(inArray(sites.siteId, siteIds), eq(sites.orgId, orgId)))
.limit(1);
- if (!site) {
- return next(createHttpError(HttpCode.NOT_FOUND, "Site not found"));
+ if (sitesToAssign.length !== siteIds.length) {
+ return next(
+ createHttpError(HttpCode.NOT_FOUND, "Some site not found")
+ );
}
const [org] = await db
@@ -346,14 +350,31 @@ export async function createSiteResource(
let newSiteResource: SiteResource | undefined;
await db.transaction(async (trx) => {
+ const [network] = await trx
+ .insert(networks)
+ .values({
+ scope: "resource",
+ orgId: orgId
+ })
+ .returning();
+
+ if (!network) {
+ return next(
+ createHttpError(
+ HttpCode.INTERNAL_SERVER_ERROR,
+ `Failed to create network`
+ )
+ );
+ }
+
// Create the site resource
const insertValues: typeof siteResources.$inferInsert = {
- siteId,
niceId,
orgId,
name,
mode,
ssl,
+ networkId: network.networkId,
destination,
scheme,
destinationPort,
@@ -382,6 +403,13 @@ export async function createSiteResource(
//////////////////// update the associations ////////////////////
+ for (const siteId of siteIds) {
+ await trx.insert(siteNetworks).values({
+ siteId: siteId,
+ networkId: network.networkId
+ });
+ }
+
const [adminRole] = await trx
.select()
.from(roles)
@@ -424,16 +452,21 @@ export async function createSiteResource(
);
}
- const [newt] = await trx
- .select()
- .from(newts)
- .where(eq(newts.siteId, site.siteId))
- .limit(1);
+ for (const siteToAssign of sitesToAssign) {
+ const [newt] = await trx
+ .select()
+ .from(newts)
+ .where(eq(newts.siteId, siteToAssign.siteId))
+ .limit(1);
- if (!newt) {
- return next(
- createHttpError(HttpCode.NOT_FOUND, "Newt not found")
- );
+ if (!newt) {
+ return next(
+ createHttpError(
+ HttpCode.NOT_FOUND,
+ `Newt not found for site ${siteToAssign.siteId}`
+ )
+ );
+ }
}
await rebuildClientAssociationsFromSiteResource(
@@ -452,7 +485,7 @@ export async function createSiteResource(
}
logger.info(
- `Created site resource ${newSiteResource.siteResourceId} for site ${siteId}`
+ `Created site resource ${newSiteResource.siteResourceId} for org ${orgId}`
);
return response(res, {
diff --git a/server/routers/siteResource/deleteSiteResource.ts b/server/routers/siteResource/deleteSiteResource.ts
index 5b50b0ea3..8d08d545d 100644
--- a/server/routers/siteResource/deleteSiteResource.ts
+++ b/server/routers/siteResource/deleteSiteResource.ts
@@ -70,17 +70,18 @@ export async function deleteSiteResource(
.where(and(eq(siteResources.siteResourceId, siteResourceId)))
.returning();
- const [newt] = await trx
- .select()
- .from(newts)
- .where(eq(newts.siteId, removedSiteResource.siteId))
- .limit(1);
+ // not sure why this is here...
+ // const [newt] = await trx
+ // .select()
+ // .from(newts)
+ // .where(eq(newts.siteId, removedSiteResource.siteId))
+ // .limit(1);
- if (!newt) {
- return next(
- createHttpError(HttpCode.NOT_FOUND, "Newt not found")
- );
- }
+ // if (!newt) {
+ // return next(
+ // createHttpError(HttpCode.NOT_FOUND, "Newt not found")
+ // );
+ // }
await rebuildClientAssociationsFromSiteResource(
removedSiteResource,
diff --git a/server/routers/siteResource/getSiteResource.ts b/server/routers/siteResource/getSiteResource.ts
index be28d36e4..2e3dfe87b 100644
--- a/server/routers/siteResource/getSiteResource.ts
+++ b/server/routers/siteResource/getSiteResource.ts
@@ -17,38 +17,34 @@ const getSiteResourceParamsSchema = z.strictObject({
.transform((val) => (val ? Number(val) : undefined))
.pipe(z.int().positive().optional())
.optional(),
- siteId: z.string().transform(Number).pipe(z.int().positive()),
niceId: z.string().optional(),
orgId: z.string()
});
async function query(
siteResourceId?: number,
- siteId?: number,
niceId?: string,
orgId?: string
) {
- if (siteResourceId && siteId && orgId) {
+ if (siteResourceId && orgId) {
const [siteResource] = await db
.select()
.from(siteResources)
.where(
and(
eq(siteResources.siteResourceId, siteResourceId),
- eq(siteResources.siteId, siteId),
eq(siteResources.orgId, orgId)
)
)
.limit(1);
return siteResource;
- } else if (niceId && siteId && orgId) {
+ } else if (niceId && orgId) {
const [siteResource] = await db
.select()
.from(siteResources)
.where(
and(
eq(siteResources.niceId, niceId),
- eq(siteResources.siteId, siteId),
eq(siteResources.orgId, orgId)
)
)
@@ -84,7 +80,6 @@ registry.registerPath({
request: {
params: z.object({
niceId: z.string(),
- siteId: z.number(),
orgId: z.string()
})
},
@@ -107,10 +102,10 @@ export async function getSiteResource(
);
}
- const { siteResourceId, siteId, niceId, orgId } = parsedParams.data;
+ const { siteResourceId, niceId, orgId } = parsedParams.data;
// Get the site resource
- const siteResource = await query(siteResourceId, siteId, niceId, orgId);
+ const siteResource = await query(siteResourceId, niceId, orgId);
if (!siteResource) {
return next(
diff --git a/server/routers/siteResource/listAllSiteResourcesByOrg.ts b/server/routers/siteResource/listAllSiteResourcesByOrg.ts
index de9083c2c..aa1fe7043 100644
--- a/server/routers/siteResource/listAllSiteResourcesByOrg.ts
+++ b/server/routers/siteResource/listAllSiteResourcesByOrg.ts
@@ -1,4 +1,4 @@
-import { db, SiteResource, siteResources, sites } from "@server/db";
+import { db, SiteResource, siteNetworks, siteResources, sites } from "@server/db";
import response from "@server/lib/response";
import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
@@ -73,10 +73,11 @@ const listAllSiteResourcesByOrgQuerySchema = z.object({
export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{
siteResources: (SiteResource & {
- siteName: string;
- siteNiceId: string;
- siteAddress: string | null;
- siteOnline: boolean;
+ siteOnlines: boolean[];
+ siteIds: number[];
+ siteNames: string[];
+ siteNiceIds: string[];
+ siteAddresses: (string | null)[];
})[];
}>;
@@ -84,7 +85,6 @@ function querySiteResourcesBase() {
return db
.select({
siteResourceId: siteResources.siteResourceId,
- siteId: siteResources.siteId,
orgId: siteResources.orgId,
niceId: siteResources.niceId,
name: siteResources.name,
@@ -105,15 +105,21 @@ function querySiteResourcesBase() {
subdomain: siteResources.subdomain,
domainId: siteResources.domainId,
fullDomain: siteResources.fullDomain,
- siteName: sites.name,
- siteNiceId: sites.niceId,
- siteAddress: sites.address,
- siteOnline: sites.online
+ networkId: siteResources.networkId,
+ defaultNetworkId: siteResources.defaultNetworkId,
+ siteNames: sql`array_agg(${sites.name})`,
+ siteNiceIds: sql`array_agg(${sites.niceId})`,
+ siteIds: sql`array_agg(${sites.siteId})`,
+ siteAddresses: sql<(string | null)[]>`array_agg(${sites.address})`,
+ siteOnlines: sql`array_agg(${sites.online})`
})
.from(siteResources)
- .innerJoin(sites, eq(siteResources.siteId, sites.siteId));
+ .innerJoin(siteNetworks, eq(siteResources.networkId, siteNetworks.networkId))
+ .innerJoin(sites, eq(siteNetworks.siteId, sites.siteId))
+ .groupBy(siteResources.siteResourceId);
}
+
registry.registerPath({
method: "get",
path: "/org/{orgId}/site-resources",
diff --git a/server/routers/siteResource/listSiteResources.ts b/server/routers/siteResource/listSiteResources.ts
index 358aa0497..8a1469f76 100644
--- a/server/routers/siteResource/listSiteResources.ts
+++ b/server/routers/siteResource/listSiteResources.ts
@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
-import { db } from "@server/db";
+import { db, networks, siteNetworks } from "@server/db";
import { siteResources, sites, SiteResource } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
@@ -108,13 +108,21 @@ export async function listSiteResources(
return next(createHttpError(HttpCode.NOT_FOUND, "Site not found"));
}
- // Get site resources
+ // Get site resources by joining networks to siteResources via siteNetworks
const siteResourcesList = await db
.select()
- .from(siteResources)
+ .from(siteNetworks)
+ .innerJoin(
+ networks,
+ eq(siteNetworks.networkId, networks.networkId)
+ )
+ .innerJoin(
+ siteResources,
+ eq(siteResources.networkId, networks.networkId)
+ )
.where(
and(
- eq(siteResources.siteId, siteId),
+ eq(siteNetworks.siteId, siteId),
eq(siteResources.orgId, orgId)
)
)
@@ -128,6 +136,7 @@ export async function listSiteResources(
.limit(limit)
.offset(offset);
+
return response(res, {
data: { siteResources: siteResourcesList },
success: true,
diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts
index 980116cdb..24b9f45b2 100644
--- a/server/routers/siteResource/updateSiteResource.ts
+++ b/server/routers/siteResource/updateSiteResource.ts
@@ -6,15 +6,21 @@ import {
orgs,
roles,
roleSiteResources,
+ siteNetworks,
SiteResource,
siteResources,
sites,
+ networks,
Transaction,
userSiteResources
} from "@server/db";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
import { validateAndConstructDomain } from "@server/lib/domainUtils";
+import response from "@server/lib/response";
+import { eq, and, ne, inArray } from "drizzle-orm";
+import { OpenAPITags, registry } from "@server/openApi";
+import { updatePeerData, updateTargets } from "@server/routers/client/targets";
import {
generateAliasConfig,
generateRemoteSubnets,
@@ -23,12 +29,8 @@ import {
portRangeStringSchema
} from "@server/lib/ip";
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
-import response from "@server/lib/response";
import logger from "@server/logger";
-import { OpenAPITags, registry } from "@server/openApi";
-import { updatePeerData, updateTargets } from "@server/routers/client/targets";
import HttpCode from "@server/types/HttpCode";
-import { and, eq, ne } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
@@ -41,7 +43,8 @@ const updateSiteResourceParamsSchema = z.strictObject({
const updateSiteResourceSchema = z
.strictObject({
name: z.string().min(1).max(255).optional(),
- siteId: z.int(),
+ siteIds: z.array(z.int()),
+ // niceId: z.string().min(1).max(255).regex(/^[a-zA-Z0-9-]+$/, "niceId can only contain letters, numbers, and dashes").optional(),
niceId: z
.string()
.min(1)
@@ -193,7 +196,7 @@ export async function updateSiteResource(
const { siteResourceId } = parsedParams.data;
const {
name,
- siteId, // because it can change
+ siteIds, // because it can change
niceId,
mode,
scheme,
@@ -214,16 +217,6 @@ export async function updateSiteResource(
subdomain
} = parsedBody.data;
- const [site] = await db
- .select()
- .from(sites)
- .where(eq(sites.siteId, siteId))
- .limit(1);
-
- if (!site) {
- return next(createHttpError(HttpCode.NOT_FOUND, "Site not found"));
- }
-
// Check if site resource exists
const [existingSiteResource] = await db
.select()
@@ -278,6 +271,24 @@ export async function updateSiteResource(
);
}
+ // Verify the site exists and belongs to the org
+ const sitesToAssign = await db
+ .select()
+ .from(sites)
+ .where(
+ and(
+ inArray(sites.siteId, siteIds),
+ eq(sites.orgId, existingSiteResource.orgId)
+ )
+ )
+ .limit(1);
+
+ if (sitesToAssign.length !== siteIds.length) {
+ return next(
+ createHttpError(HttpCode.NOT_FOUND, "Some site not found")
+ );
+ }
+
// Only check if destination is an IP address
const isIp = z
.union([z.ipv4(), z.ipv6()])
@@ -295,25 +306,24 @@ export async function updateSiteResource(
);
}
- let existingSite = site;
- let siteChanged = false;
- if (existingSiteResource.siteId !== siteId) {
- siteChanged = true;
- // get the existing site
- [existingSite] = await db
- .select()
- .from(sites)
- .where(eq(sites.siteId, existingSiteResource.siteId))
- .limit(1);
+ let sitesChanged = false;
+ const existingSiteIds = existingSiteResource.networkId
+ ? await db
+ .select()
+ .from(siteNetworks)
+ .where(
+ eq(siteNetworks.networkId, existingSiteResource.networkId)
+ )
+ : [];
- if (!existingSite) {
- return next(
- createHttpError(
- HttpCode.NOT_FOUND,
- "Existing site not found"
- )
- );
- }
+ const existingSiteIdSet = new Set(existingSiteIds.map((s) => s.siteId));
+ const newSiteIdSet = new Set(siteIds);
+
+ if (
+ existingSiteIdSet.size !== newSiteIdSet.size ||
+ ![...existingSiteIdSet].every((id) => newSiteIdSet.has(id))
+ ) {
+ sitesChanged = true;
}
let fullDomain: string | null = null;
@@ -382,7 +392,7 @@ export async function updateSiteResource(
let updatedSiteResource: SiteResource | undefined;
await db.transaction(async (trx) => {
// if the site is changed we need to delete and recreate the resource to avoid complications with the rebuild function otherwise we can just update in place
- if (siteChanged) {
+ if (sitesChanged) {
// delete the existing site resource
await trx
.delete(siteResources)
@@ -423,7 +433,6 @@ export async function updateSiteResource(
.update(siteResources)
.set({
name,
- siteId,
niceId,
mode,
scheme,
@@ -533,7 +542,6 @@ export async function updateSiteResource(
.update(siteResources)
.set({
name: name,
- siteId: siteId,
niceId: niceId,
mode: mode,
scheme,
@@ -557,6 +565,23 @@ export async function updateSiteResource(
//////////////////// update the associations ////////////////////
+ // delete the site - site resources associations
+ await trx
+ .delete(siteNetworks)
+ .where(
+ eq(
+ siteNetworks.networkId,
+ updatedSiteResource.networkId!
+ )
+ );
+
+ for (const siteId of siteIds) {
+ await trx.insert(siteNetworks).values({
+ siteId: siteId,
+ networkId: updatedSiteResource.networkId!
+ });
+ }
+
await trx
.delete(clientSiteResources)
.where(
@@ -626,14 +651,15 @@ export async function updateSiteResource(
);
}
- logger.info(
- `Updated site resource ${siteResourceId} for site ${siteId}`
- );
+ logger.info(`Updated site resource ${siteResourceId}`);
await handleMessagingForUpdatedSiteResource(
existingSiteResource,
updatedSiteResource,
- { siteId: site.siteId, orgId: site.orgId },
+ siteIds.map((siteId) => ({
+ siteId,
+ orgId: existingSiteResource.orgId
+ })),
trx
);
}
@@ -660,7 +686,7 @@ export async function updateSiteResource(
export async function handleMessagingForUpdatedSiteResource(
existingSiteResource: SiteResource | undefined,
updatedSiteResource: SiteResource,
- site: { siteId: number; orgId: string },
+ sites: { siteId: number; orgId: string }[],
trx: Transaction
) {
logger.debug(
@@ -702,105 +728,112 @@ export async function handleMessagingForUpdatedSiteResource(
// if the existingSiteResource is undefined (new resource) we don't need to do anything here, the rebuild above handled it all
if (destinationChanged || aliasChanged || portRangesChanged || destinationPortChanged) {
- const [newt] = await trx
- .select()
- .from(newts)
- .where(eq(newts.siteId, site.siteId))
- .limit(1);
-
- if (!newt) {
- throw new Error(
- "Newt not found for site during site resource update"
- );
- }
-
- // Only update targets on newt if destination changed
- if (destinationChanged || portRangesChanged || destinationPortChanged) {
- const oldTarget = await generateSubnetProxyTargetV2(
- existingSiteResource,
- mergedAllClients
- );
- const newTarget = await generateSubnetProxyTargetV2(
- updatedSiteResource,
- mergedAllClients
- );
-
- await updateTargets(
- newt.newtId,
- {
- oldTargets: oldTarget ? [oldTarget] : [],
- newTargets: newTarget ? [newTarget] : []
- },
- newt.version
- );
- }
-
- const olmJobs: Promise[] = [];
- for (const client of mergedAllClients) {
- // does this client have access to another resource on this site that has the same destination still? if so we dont want to remove it from their olm yet
- // todo: optimize this query if needed
- const oldDestinationStillInUseSites = await trx
+ for (const site of sites) {
+ const [newt] = await trx
.select()
- .from(siteResources)
- .innerJoin(
- clientSiteResourcesAssociationsCache,
- eq(
- clientSiteResourcesAssociationsCache.siteResourceId,
- siteResources.siteResourceId
- )
- )
- .where(
- and(
- eq(
- clientSiteResourcesAssociationsCache.clientId,
- client.clientId
- ),
- eq(siteResources.siteId, site.siteId),
- eq(
- siteResources.destination,
- existingSiteResource.destination
- ),
- ne(
- siteResources.siteResourceId,
- existingSiteResource.siteResourceId
- )
- )
+ .from(newts)
+ .where(eq(newts.siteId, site.siteId))
+ .limit(1);
+
+ if (!newt) {
+ throw new Error(
+ "Newt not found for site during site resource update"
+ );
+ }
+
+ // Only update targets on newt if destination changed
+ if (destinationChanged || portRangesChanged || destinationPortChanged) {
+ const oldTarget = await generateSubnetProxyTargetV2(
+ existingSiteResource,
+ mergedAllClients
+ );
+ const newTarget = await generateSubnetProxyTargetV2(
+ updatedSiteResource,
+ mergedAllClients
);
- const oldDestinationStillInUseByASite =
- oldDestinationStillInUseSites.length > 0;
+ await updateTargets(
+ newt.newtId,
+ {
+ oldTargets: oldTarget ? [oldTarget] : [],
+ newTargets: newTarget ? [newTarget] : []
+ },
+ newt.version
+ );
+ }
- // we also need to update the remote subnets on the olms for each client that has access to this site
- olmJobs.push(
- updatePeerData(
- client.clientId,
- updatedSiteResource.siteId,
- destinationChanged
- ? {
- oldRemoteSubnets: !oldDestinationStillInUseByASite
- ? generateRemoteSubnets([
- existingSiteResource
- ])
- : [],
- newRemoteSubnets: generateRemoteSubnets([
- updatedSiteResource
- ])
- }
- : undefined,
- aliasChanged
- ? {
- oldAliases: generateAliasConfig([
- existingSiteResource
- ]),
- newAliases: generateAliasConfig([
- updatedSiteResource
- ])
- }
- : undefined
- )
- );
+ const olmJobs: Promise[] = [];
+ for (const client of mergedAllClients) {
+ // does this client have access to another resource on this site that has the same destination still? if so we dont want to remove it from their olm yet
+ // todo: optimize this query if needed
+ const oldDestinationStillInUseSites = await trx
+ .select()
+ .from(siteResources)
+ .innerJoin(
+ clientSiteResourcesAssociationsCache,
+ eq(
+ clientSiteResourcesAssociationsCache.siteResourceId,
+ siteResources.siteResourceId
+ )
+ )
+ .innerJoin(
+ siteNetworks,
+ eq(siteNetworks.networkId, siteResources.networkId)
+ )
+ .where(
+ and(
+ eq(
+ clientSiteResourcesAssociationsCache.clientId,
+ client.clientId
+ ),
+ eq(siteNetworks.siteId, site.siteId),
+ eq(
+ siteResources.destination,
+ existingSiteResource.destination
+ ),
+ ne(
+ siteResources.siteResourceId,
+ existingSiteResource.siteResourceId
+ )
+ )
+ );
+
+ const oldDestinationStillInUseByASite =
+ oldDestinationStillInUseSites.length > 0;
+
+ // we also need to update the remote subnets on the olms for each client that has access to this site
+ olmJobs.push(
+ updatePeerData(
+ client.clientId,
+ site.siteId,
+ destinationChanged
+ ? {
+ oldRemoteSubnets:
+ !oldDestinationStillInUseByASite
+ ? generateRemoteSubnets([
+ existingSiteResource
+ ])
+ : [],
+ newRemoteSubnets: generateRemoteSubnets([
+ updatedSiteResource
+ ])
+ }
+ : undefined,
+ aliasChanged
+ ? {
+ oldAliases: generateAliasConfig([
+ existingSiteResource
+ ]),
+ newAliases: generateAliasConfig([
+ updatedSiteResource
+ ])
+ }
+ : undefined
+ )
+ );
+ }
+
+ await Promise.all(olmJobs);
}
-
- await Promise.all(olmJobs);
}
}
diff --git a/server/routers/user/inviteUser.ts b/server/routers/user/inviteUser.ts
index 7ac1849b9..b11586e69 100644
--- a/server/routers/user/inviteUser.ts
+++ b/server/routers/user/inviteUser.ts
@@ -1,7 +1,14 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
-import { orgs, roles, userInviteRoles, userInvites, userOrgs, users } from "@server/db";
+import {
+ orgs,
+ roles,
+ userInviteRoles,
+ userInvites,
+ userOrgs,
+ users
+} from "@server/db";
import { and, eq, inArray } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
@@ -37,8 +44,7 @@ const inviteUserBodySchema = z
regenerate: z.boolean().optional()
})
.refine(
- (d) =>
- (d.roleIds != null && d.roleIds.length > 0) || d.roleId != null,
+ (d) => (d.roleIds != null && d.roleIds.length > 0) || d.roleId != null,
{ message: "roleIds or roleId is required", path: ["roleIds"] }
)
.transform((data) => ({
@@ -265,7 +271,7 @@ export async function inviteUser(
)
);
- const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}&email=${encodeURIComponent(email)}`;
+ const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}&email=${email}`;
if (doEmail) {
await sendEmail(
@@ -314,12 +320,12 @@ export async function inviteUser(
expiresAt,
tokenHash
});
- await trx.insert(userInviteRoles).values(
- uniqueRoleIds.map((roleId) => ({ inviteId, roleId }))
- );
+ await trx
+ .insert(userInviteRoles)
+ .values(uniqueRoleIds.map((roleId) => ({ inviteId, roleId })));
});
- const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}&email=${encodeURIComponent(email)}`;
+ const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}&email=${email}`;
if (doEmail) {
await sendEmail(
diff --git a/src/app/[orgId]/settings/domains/[domainId]/page.tsx b/src/app/[orgId]/settings/domains/[domainId]/page.tsx
index cf23e81be..23a79737d 100644
--- a/src/app/[orgId]/settings/domains/[domainId]/page.tsx
+++ b/src/app/[orgId]/settings/domains/[domainId]/page.tsx
@@ -10,6 +10,7 @@ import { authCookieHeader } from "@app/lib/api/cookies";
import { GetDNSRecordsResponse } from "@server/routers/domain";
import DNSRecordsTable from "@app/components/DNSRecordTable";
import DomainCertForm from "@app/components/DomainCertForm";
+import { build } from "@server/build";
interface DomainSettingsPageProps {
params: Promise<{ domainId: string; orgId: string }>;
@@ -65,12 +66,14 @@ export default async function DomainSettingsPage({
)}
-
+ {build != "oss" && env.flags.usePangolinDns ? (
+
+ ) : null}
diff --git a/src/app/[orgId]/settings/resources/client/page.tsx b/src/app/[orgId]/settings/resources/client/page.tsx
index f63563cc9..c15e3d429 100644
--- a/src/app/[orgId]/settings/resources/client/page.tsx
+++ b/src/app/[orgId]/settings/resources/client/page.tsx
@@ -56,42 +56,29 @@ export default async function ClientResourcesPage(
const internalResourceRows: InternalResourceRow[] = siteResources.map(
(siteResource) => {
- const rawMode = siteResource.mode as string | undefined;
- const normalizedMode =
- rawMode === "https"
- ? ("http" as const)
- : rawMode === "host" ||
- rawMode === "cidr" ||
- rawMode === "http"
- ? rawMode
- : ("host" as const);
return {
id: siteResource.siteResourceId,
name: siteResource.name,
orgId: params.orgId,
- sites: [
- {
- siteId: siteResource.siteId,
- siteName: siteResource.siteName,
- siteNiceId: siteResource.siteNiceId,
- online: siteResource.siteOnline
- }
- ],
- siteName: siteResource.siteName,
- siteAddress: siteResource.siteAddress || null,
- mode: normalizedMode,
- scheme:
- siteResource.scheme ??
- (rawMode === "https" ? ("https" as const) : null),
- ssl: siteResource.ssl === true || rawMode === "https",
+ sites: siteResource.siteIds.map((siteId, idx) => ({
+ siteId,
+ siteName: siteResource.siteNames[idx],
+ siteNiceId: siteResource.siteNiceIds[idx],
+ online: siteResource.siteOnlines[idx]
+ })),
+ mode: siteResource.mode,
+ scheme: siteResource.scheme,
+ ssl: siteResource.ssl,
+ siteNames: siteResource.siteNames,
+ siteAddresses: siteResource.siteAddresses || null,
// protocol: siteResource.protocol,
// proxyPort: siteResource.proxyPort,
- siteId: siteResource.siteId,
+ siteIds: siteResource.siteIds,
destination: siteResource.destination,
httpHttpsPort: siteResource.destinationPort ?? null,
alias: siteResource.alias || null,
aliasAddress: siteResource.aliasAddress || null,
- siteNiceId: siteResource.siteNiceId,
+ siteNiceIds: siteResource.siteNiceIds,
niceId: siteResource.niceId,
tcpPortRangeString: siteResource.tcpPortRangeString || null,
udpPortRangeString: siteResource.udpPortRangeString || null,
diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx
index 3d6e6186b..a9128b9d3 100644
--- a/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx
+++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx
@@ -678,6 +678,7 @@ function ProxyResourceTargetsForm({
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
+ getRowId: (row) => String(row.targetId),
state: {
pagination: {
pageIndex: 0,
diff --git a/src/app/[orgId]/settings/resources/proxy/create/page.tsx b/src/app/[orgId]/settings/resources/proxy/create/page.tsx
index f057c07c4..f5c20d8cc 100644
--- a/src/app/[orgId]/settings/resources/proxy/create/page.tsx
+++ b/src/app/[orgId]/settings/resources/proxy/create/page.tsx
@@ -999,6 +999,7 @@ export default function Page() {
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
+ getRowId: (row) => String(row.targetId),
state: {
pagination: {
pageIndex: 0,
diff --git a/src/components/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx
index fc1a6a6f3..4fd7f44fe 100644
--- a/src/components/ClientResourcesTable.tsx
+++ b/src/components/ClientResourcesTable.tsx
@@ -20,6 +20,7 @@ import {
ArrowDown01Icon,
ArrowUp10Icon,
ArrowUpDown,
+ ArrowUpRight,
ChevronDown,
ChevronsUpDownIcon,
MoreHorizontal
@@ -52,16 +53,16 @@ export type InternalResourceRow = {
name: string;
orgId: string;
sites: InternalResourceSiteRow[];
- siteName: string;
- siteAddress: string | null;
+ siteNames: string[];
+ siteAddresses: (string | null)[];
+ siteIds: number[];
+ siteNiceIds: string[];
// mode: "host" | "cidr" | "port";
mode: "host" | "cidr" | "http";
scheme: "http" | "https" | null;
ssl: boolean;
// protocol: string | null;
// proxyPort: number | null;
- siteId: number;
- siteNiceId: string;
destination: string;
httpHttpsPort: number | null;
alias: string | null;
@@ -284,6 +285,60 @@ export default function ClientResourcesTable({
}
};
+ function SiteCell({ resourceRow }: { resourceRow: InternalResourceRow }) {
+ const { siteNames, siteNiceIds, orgId } = resourceRow;
+
+ if (!siteNames || siteNames.length === 0) {
+ return
- ;
+ }
+
+ if (siteNames.length === 1) {
+ return (
+
+
+ {siteNames[0]}
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ {siteNames.length} {t("sites")}
+
+
+
+
+
+ {siteNames.map((siteName, idx) => (
+
+
+ {siteName}
+
+
+
+ ))}
+
+
+ );
+ }
+
const internalColumns: ExtendedColumnDef
[] = [
{
accessorKey: "name",
@@ -334,8 +389,7 @@ export default function ClientResourcesTable({
},
{
id: "sites",
- accessorFn: (row) =>
- row.sites.map((s) => s.siteName).join(", ") || row.siteName,
+ accessorFn: (row) => row.sites.map((s) => s.siteName).join(", "),
friendlyName: t("sites"),
header: () => {t("sites")} ,
cell: ({ row }) => {
@@ -565,7 +619,7 @@ export default function ClientResourcesTable({
onConfirm={async () =>
deleteInternalResource(
selectedInternalResource!.id,
- selectedInternalResource!.siteId
+ selectedInternalResource!.siteIds[0]
)
}
string={selectedInternalResource.name}
@@ -599,7 +653,11 @@ export default function ClientResourcesTable({
{
// Delay refresh to allow modal to close smoothly
diff --git a/src/components/CreateDomainForm.tsx b/src/components/CreateDomainForm.tsx
index 9a187f1ed..8840d2f93 100644
--- a/src/components/CreateDomainForm.tsx
+++ b/src/components/CreateDomainForm.tsx
@@ -154,7 +154,7 @@ export default function CreateDomainForm({
const punycodePreview = useMemo(() => {
if (!baseDomain) return "";
- const punycode = toPunycode(baseDomain);
+ const punycode = toPunycode(baseDomain.toLowerCase());
return punycode !== baseDomain.toLowerCase() ? punycode : "";
}, [baseDomain]);
@@ -239,21 +239,24 @@ export default function CreateDomainForm({
className="space-y-4"
id="create-domain-form"
>
- (
-
-
-
-
- )}
- />
+ {build != "oss" && env.flags.usePangolinDns ? (
+ (
+
+
+
+
+ )}
+ />
+ ) : null}
+
+ {build === "saas" &&
+ !hasSaasSubscription(
+ tierMatrix[TierFeature.DomainNamespaces]
+ ) &&
+ !hideFreeDomain && (
+
+
+
+
+
+ {t("domainPickerFreeDomainsPaidFeature")}
+
+
+
+
+ )}
+
{/*showProvidedDomainSearch && build === "saas" && (
diff --git a/src/components/InviteStatusCard.tsx b/src/components/InviteStatusCard.tsx
index 417fa9892..5de8f25fd 100644
--- a/src/components/InviteStatusCard.tsx
+++ b/src/components/InviteStatusCard.tsx
@@ -39,7 +39,11 @@ export default function InviteStatusCard({
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [type, setType] = useState<
- "rejected" | "wrong_user" | "user_does_not_exist" | "not_logged_in" | "user_limit_exceeded"
+ | "rejected"
+ | "wrong_user"
+ | "user_does_not_exist"
+ | "not_logged_in"
+ | "user_limit_exceeded"
>("rejected");
useEffect(() => {
@@ -90,12 +94,12 @@ export default function InviteStatusCard({
if (!user && type === "user_does_not_exist") {
const redirectUrl = email
- ? `/auth/signup?redirect=/invite?token=${tokenParam}&email=${encodeURIComponent(email)}`
+ ? `/auth/signup?redirect=/invite?token=${tokenParam}&email=${email}`
: `/auth/signup?redirect=/invite?token=${tokenParam}`;
router.push(redirectUrl);
} else if (!user && type === "not_logged_in") {
const redirectUrl = email
- ? `/auth/login?redirect=/invite?token=${tokenParam}&email=${encodeURIComponent(email)}`
+ ? `/auth/login?redirect=/invite?token=${tokenParam}&email=${email}`
: `/auth/login?redirect=/invite?token=${tokenParam}`;
router.push(redirectUrl);
} else {
@@ -109,7 +113,7 @@ export default function InviteStatusCard({
async function goToLogin() {
await api.post("/auth/logout", {});
const redirectUrl = email
- ? `/auth/login?redirect=/invite?token=${tokenParam}&email=${encodeURIComponent(email)}`
+ ? `/auth/login?redirect=/invite?token=${tokenParam}&email=${email}`
: `/auth/login?redirect=/invite?token=${tokenParam}`;
router.push(redirectUrl);
}
@@ -117,7 +121,7 @@ export default function InviteStatusCard({
async function goToSignup() {
await api.post("/auth/logout", {});
const redirectUrl = email
- ? `/auth/signup?redirect=/invite?token=${tokenParam}&email=${encodeURIComponent(email)}`
+ ? `/auth/signup?redirect=/invite?token=${tokenParam}&email=${email}`
: `/auth/signup?redirect=/invite?token=${tokenParam}`;
router.push(redirectUrl);
}
@@ -157,7 +161,9 @@ export default function InviteStatusCard({
Cannot Accept Invite
- This organization has reached its user limit. Please contact the organization administrator to upgrade their plan before accepting this invite.
+ This organization has reached its user limit. Please
+ contact the organization administrator to upgrade their
+ plan before accepting this invite.
);
diff --git a/src/components/PendingSitesTable.tsx b/src/components/PendingSitesTable.tsx
index f4156603e..a1ed6f354 100644
--- a/src/components/PendingSitesTable.tsx
+++ b/src/components/PendingSitesTable.tsx
@@ -333,7 +333,8 @@ export default function PendingSitesTable({
"jupiter",
"saturn",
"uranus",
- "neptune"
+ "neptune",
+ "pluto"
].includes(originalRow.exitNodeName.toLowerCase());
if (isCloudNode) {
diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx
index 4f459ffc1..606630a50 100644
--- a/src/components/SitesTable.tsx
+++ b/src/components/SitesTable.tsx
@@ -342,7 +342,8 @@ export default function SitesTable({
"jupiter",
"saturn",
"uranus",
- "neptune"
+ "neptune",
+ "pluto"
].includes(originalRow.exitNodeName.toLowerCase());
if (isCloudNode) {
diff --git a/src/components/WorldMap.tsx b/src/components/WorldMap.tsx
index c64c3f430..2b3e5e043 100644
--- a/src/components/WorldMap.tsx
+++ b/src/components/WorldMap.tsx
@@ -164,7 +164,7 @@ const countryClass = cn(
const highlightedCountryClass = cn(
sharedCountryClass,
- "stroke-2",
+ "stroke-[3]",
"fill-[#f4f4f5]",
"stroke-[#f36117]",
"dark:fill-[#3f3f46]"
@@ -194,11 +194,20 @@ function drawInteractiveCountries(
const path = setupProjetionPath();
const data = parseWorldTopoJsonToGeoJsonFeatures();
const svg = d3.select(element);
+ const countriesLayer = svg.append("g");
+ const hoverLayer = svg.append("g").style("pointer-events", "none");
+ const hoverPath = hoverLayer
+ .append("path")
+ .datum(null)
+ .attr("class", highlightedCountryClass)
+ .style("display", "none");
- svg.selectAll("path")
+ countriesLayer
+ .selectAll("path")
.data(data)
.enter()
.append("path")
+ .attr("data-country-path", "true")
.attr("class", countryClass)
.attr("d", path as never)
@@ -209,9 +218,10 @@ function drawInteractiveCountries(
y,
hoveredCountryAlpha3Code: country.properties.a3
});
- // brings country to front
- this.parentNode?.appendChild(this);
- d3.select(this).attr("class", highlightedCountryClass);
+ hoverPath
+ .datum(country)
+ .attr("d", path(country as any) as string)
+ .style("display", null);
})
.on("mousemove", function (event) {
@@ -221,7 +231,7 @@ function drawInteractiveCountries(
.on("mouseout", function () {
setTooltip({ x: 0, y: 0, hoveredCountryAlpha3Code: null });
- d3.select(this).attr("class", countryClass);
+ hoverPath.style("display", "none");
});
return svg;
@@ -257,7 +267,7 @@ function colorInCountriesWithValues(
const svg = d3.select(element);
return svg
- .selectAll("path")
+ .selectAll('path[data-country-path="true"]')
.style("fill", (countryPath) => {
const country = getCountryByCountryPath(countryPath);
if (!country?.count) {