Compare commits

...

31 Commits

Author SHA1 Message Date
Owen
71497a7887 Merge branch 'dev' into private-site-ha 2026-04-12 17:54:07 -07:00
Owen
2f386f8e47 Grandfather in old users 2026-04-11 16:59:43 -07:00
Owen
f4ea572f6b Fix #2828 2026-04-11 16:50:28 -07:00
Owen Schwartz
825df7da63 Merge pull request #2806 from jbelke/fix-invite-email-encoding
Fix invite email encoding
2026-04-11 16:37:49 -07:00
Owen Schwartz
cd34f0a7b0 Merge pull request #2799 from LaurenceJJones/fix/proxy-target-deletion
fix: use targetId as row identifier
2026-04-11 16:35:09 -07:00
Owen Schwartz
b1b22c439a Merge pull request #2825 from AdnanSilajdzic/fix/worldmap-hover-stuck-public
fix(analytics): prevent countries from getting stuck highlighted on world map
2026-04-11 16:32:32 -07:00
Owen
eac747849b Restrict namespaces to paid plans due to abuse 2026-04-11 14:22:00 -07:00
Adnan Silajdzic
1aedf9da0a fix(worldmap): avoid stuck country hover state 2026-04-10 14:37:48 +00:00
miloschwartz
840684aeba dont show wildcard in domain picker 2026-04-09 17:54:25 -04:00
Owen
96b9123306 Merge branch 'dev' into private-site-ha 2026-04-09 17:39:45 -04:00
miloschwartz
f57012eb90 dont show international domain warning when capital letter present 2026-04-09 17:06:04 -04:00
miloschwartz
34387d9859 simplify wildcard domain on non pangolin-dns 2026-04-09 17:04:28 -04:00
miloschwartz
80f5914fdd add pluto 2026-04-09 16:15:19 -04:00
miloschwartz
eaa70da4dd add pluto 2026-04-09 16:14:46 -04:00
Joshua Belke
028df8bf27 fix: remove encodeURIComponent from invite link email parameter
The @ symbol in email addresses was being encoded as %40 when
constructing invite URLs, causing broken or garbled links when
copied/shared by users.

- Remove encodeURIComponent(email) from server-side invite link
  construction in inviteUser.ts (both new invite and regenerate paths)
- Remove encodeURIComponent(email) from client-side redirect URLs in
  InviteStatusCard.tsx (login, signup, and useEffect redirect paths)
- Valid Zod-validated email addresses do not contain characters that
  require URL encoding for safe query parameter use (@ is permitted
  in query strings per RFC 3986 §3.4)
2026-04-07 14:58:27 -04:00
Laurence
7d3d5b2b22 use targetid also on proxy create as that also has same issue 2026-04-06 14:17:04 +01:00
Laurence
81eba50c9a fix: use targetId as row identifier
fix: 2797
2026-04-06 14:03:33 +01:00
Owen
02033f611f First pass at HA 2026-03-23 11:44:02 -07:00
Owen
1366901e24 Adjust build functions 2026-03-22 14:40:57 -07:00
Owen
c4f48f5748 WIP - more conversion 2026-03-22 14:29:47 -07:00
Owen
c48bc71443 Update crud endpoints and ui 2026-03-22 14:18:34 -07:00
Owen
d85496453f Change SSH WIP 2026-03-21 10:40:12 -07:00
Owen
21b91374a3 Merge branch 'private-site-ha' of github.com:fosrl/pangolin into private-site-ha 2026-03-20 17:24:27 -07:00
Owen
a1ce7f54a0 Continue to rebase 2026-03-20 09:17:10 -07:00
Owen
87524fe8ae Remove siteSiteResources 2026-03-19 21:53:52 -07:00
Owen
2093bb5357 Remove siteSiteResources 2026-03-19 21:44:59 -07:00
Owen
6f2e37948c Its many to one now 2026-03-19 21:30:00 -07:00
Owen
b7421e47cc Switch to using networks 2026-03-19 21:22:04 -07:00
Owen
7cbe3d42a1 Working on refactoring 2026-03-19 12:10:04 -07:00
Owen
d8b511b198 Adjust create and update to be many to one 2026-03-18 20:54:49 -07:00
Owen
102a235407 Adjust schema for many to one site resources 2026-03-18 20:54:38 -07:00
37 changed files with 1373 additions and 774 deletions

View File

@@ -2113,9 +2113,11 @@
"addDomainToEnableCustomAuthPages": "Users will be able to access the organization's login page and complete resource authentication using this domain.", "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", "selectDomainForOrgAuthPage": "Select a domain for the organization's authentication page",
"domainPickerProvidedDomain": "Provided Domain", "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", "domainPickerVerified": "Verified",
"domainPickerUnverified": "Unverified", "domainPickerUnverified": "Unverified",
"domainPickerManual": "Manual",
"domainPickerInvalidSubdomainStructure": "This subdomain contains invalid characters or structure. It will be sanitized automatically when you save.", "domainPickerInvalidSubdomainStructure": "This subdomain contains invalid characters or structure. It will be sanitized automatically when you save.",
"domainPickerError": "Error", "domainPickerError": "Error",
"domainPickerErrorLoadDomains": "Failed to load organization domains", "domainPickerErrorLoadDomains": "Failed to load organization domains",

View File

@@ -222,12 +222,18 @@ export const exitNodes = pgTable("exitNodes", {
export const siteResources = pgTable("siteResources", { export const siteResources = pgTable("siteResources", {
// this is for the clients // this is for the clients
siteResourceId: serial("siteResourceId").primaryKey(), siteResourceId: serial("siteResourceId").primaryKey(),
siteId: integer("siteId")
.notNull()
.references(() => sites.siteId, { onDelete: "cascade" }),
orgId: varchar("orgId") orgId: varchar("orgId")
.notNull() .notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }), .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(), niceId: varchar("niceId").notNull(),
name: varchar("name").notNull(), name: varchar("name").notNull(),
mode: varchar("mode").$type<"host" | "cidr">().notNull(), // "host" | "cidr" | "port" mode: varchar("mode").$type<"host" | "cidr">().notNull(), // "host" | "cidr" | "port"
@@ -247,6 +253,32 @@ export const siteResources = pgTable("siteResources", {
.default("site") .default("site")
}); });
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", { export const clientSiteResources = pgTable("clientSiteResources", {
clientId: integer("clientId") clientId: integer("clientId")
.notNull() .notNull()
@@ -1106,3 +1138,4 @@ export type RequestAuditLog = InferSelectModel<typeof requestAuditLog>;
export type RoundTripMessageTracker = InferSelectModel< export type RoundTripMessageTracker = InferSelectModel<
typeof roundTripMessageTracker typeof roundTripMessageTracker
>; >;
export type Network = InferSelectModel<typeof networks>;

View File

@@ -92,6 +92,9 @@ export const sites = sqliteTable("sites", {
exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, { exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, {
onDelete: "set null" onDelete: "set null"
}), }),
networkId: integer("networkId").references(() => networks.networkId, {
onDelete: "set null"
}),
name: text("name").notNull(), name: text("name").notNull(),
pubKey: text("pubKey"), pubKey: text("pubKey"),
subnet: text("subnet"), subnet: text("subnet"),
@@ -250,12 +253,16 @@ export const siteResources = sqliteTable("siteResources", {
siteResourceId: integer("siteResourceId").primaryKey({ siteResourceId: integer("siteResourceId").primaryKey({
autoIncrement: true autoIncrement: true
}), }),
siteId: integer("siteId")
.notNull()
.references(() => sites.siteId, { onDelete: "cascade" }),
orgId: text("orgId") orgId: text("orgId")
.notNull() .notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }), .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(), niceId: text("niceId").notNull(),
name: text("name").notNull(), name: text("name").notNull(),
mode: text("mode").$type<"host" | "cidr">().notNull(), // "host" | "cidr" | "port" mode: text("mode").$type<"host" | "cidr">().notNull(), // "host" | "cidr" | "port"
@@ -277,6 +284,30 @@ export const siteResources = sqliteTable("siteResources", {
.default("site") .default("site")
}); });
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", { export const clientSiteResources = sqliteTable("clientSiteResources", {
clientId: integer("clientId") clientId: integer("clientId")
.notNull() .notNull()
@@ -1195,6 +1226,7 @@ export type ApiKey = InferSelectModel<typeof apiKeys>;
export type ApiKeyAction = InferSelectModel<typeof apiKeyActions>; export type ApiKeyAction = InferSelectModel<typeof apiKeyActions>;
export type ApiKeyOrg = InferSelectModel<typeof apiKeyOrg>; export type ApiKeyOrg = InferSelectModel<typeof apiKeyOrg>;
export type SiteResource = InferSelectModel<typeof siteResources>; export type SiteResource = InferSelectModel<typeof siteResources>;
export type Network = InferSelectModel<typeof networks>;
export type OrgDomains = InferSelectModel<typeof orgDomains>; export type OrgDomains = InferSelectModel<typeof orgDomains>;
export type SetupToken = InferSelectModel<typeof setupTokens>; export type SetupToken = InferSelectModel<typeof setupTokens>;
export type HostMeta = InferSelectModel<typeof hostMeta>; export type HostMeta = InferSelectModel<typeof hostMeta>;

View File

@@ -19,7 +19,8 @@ export enum TierFeature {
SshPam = "sshPam", SshPam = "sshPam",
FullRbac = "fullRbac", FullRbac = "fullRbac",
SiteProvisioningKeys = "siteProvisioningKeys", // handle downgrade by revoking keys if needed SiteProvisioningKeys = "siteProvisioningKeys", // handle downgrade by revoking keys if needed
SIEM = "siem" // handle downgrade by disabling SIEM integrations SIEM = "siem", // handle downgrade by disabling SIEM integrations
DomainNamespaces = "domainNamespaces" // handle downgrade by removing custom domain namespaces
} }
export const tierMatrix: Record<TierFeature, Tier[]> = { export const tierMatrix: Record<TierFeature, Tier[]> = {
@@ -56,5 +57,6 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
[TierFeature.SshPam]: ["tier1", "tier3", "enterprise"], [TierFeature.SshPam]: ["tier1", "tier3", "enterprise"],
[TierFeature.FullRbac]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.FullRbac]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.SiteProvisioningKeys]: ["tier3", "enterprise"], [TierFeature.SiteProvisioningKeys]: ["tier3", "enterprise"],
[TierFeature.SIEM]: ["enterprise"] [TierFeature.SIEM]: ["enterprise"],
[TierFeature.DomainNamespaces]: ["tier1", "tier2", "tier3", "enterprise"]
}; };

View File

@@ -121,8 +121,8 @@ export async function applyBlueprint({
for (const result of clientResourcesResults) { for (const result of clientResourcesResults) {
if ( if (
result.oldSiteResource && result.oldSiteResource &&
result.oldSiteResource.siteId != JSON.stringify(result.newSites?.sort()) !==
result.newSiteResource.siteId JSON.stringify(result.oldSites?.sort())
) { ) {
// query existing associations // query existing associations
const existingRoleIds = await trx const existingRoleIds = await trx
@@ -222,13 +222,15 @@ export async function applyBlueprint({
trx trx
); );
} else { } else {
const [newSite] = await trx let good = true;
for (const newSite of result.newSites) {
const [site] = await trx
.select() .select()
.from(sites) .from(sites)
.innerJoin(newts, eq(sites.siteId, newts.siteId)) .innerJoin(newts, eq(sites.siteId, newts.siteId))
.where( .where(
and( and(
eq(sites.siteId, result.newSiteResource.siteId), eq(sites.siteId, newSite.siteId),
eq(sites.orgId, orgId), eq(sites.orgId, orgId),
eq(sites.type, "newt"), eq(sites.type, "newt"),
isNotNull(sites.pubKey) isNotNull(sites.pubKey)
@@ -236,24 +238,30 @@ export async function applyBlueprint({
) )
.limit(1); .limit(1);
if (!newSite) { if (!site) {
logger.debug( logger.debug(
`No newt site found for client resource ${result.newSiteResource.siteResourceId}, skipping target update` `No newt sites found for client resource ${result.newSiteResource.siteResourceId}, skipping target update`
); );
continue; good = false;
break;
} }
logger.debug( logger.debug(
`Updating client resource ${result.newSiteResource.siteResourceId} on site ${newSite.sites.siteId}` `Updating client resource ${result.newSiteResource.siteResourceId} on site ${newSite.siteId}`
); );
}
if (!good) {
continue;
}
await handleMessagingForUpdatedSiteResource( await handleMessagingForUpdatedSiteResource(
result.oldSiteResource, result.oldSiteResource,
result.newSiteResource, result.newSiteResource,
{ result.newSites.map((site) => ({
siteId: newSite.sites.siteId, siteId: site.siteId,
orgId: newSite.sites.orgId orgId: result.newSiteResource.orgId
}, })),
trx trx
); );
} }

View File

@@ -3,12 +3,15 @@ import {
clientSiteResources, clientSiteResources,
roles, roles,
roleSiteResources, roleSiteResources,
Site,
SiteResource, SiteResource,
siteNetworks,
siteResources, siteResources,
Transaction, Transaction,
userOrgs, userOrgs,
users, users,
userSiteResources userSiteResources,
networks
} from "@server/db"; } from "@server/db";
import { sites } from "@server/db"; import { sites } from "@server/db";
import { eq, and, ne, inArray, or } from "drizzle-orm"; import { eq, and, ne, inArray, or } from "drizzle-orm";
@@ -19,6 +22,8 @@ import { getNextAvailableAliasAddress } from "../ip";
export type ClientResourcesResults = { export type ClientResourcesResults = {
newSiteResource: SiteResource; newSiteResource: SiteResource;
oldSiteResource?: SiteResource; oldSiteResource?: SiteResource;
newSites: { siteId: number }[];
oldSites: { siteId: number }[];
}[]; }[];
export async function updateClientResources( export async function updateClientResources(
@@ -43,12 +48,21 @@ export async function updateClientResources(
) )
.limit(1); .limit(1);
const existingSiteIds = existingResource?.networkId
? await trx
.select({ siteId: sites.siteId })
.from(siteNetworks)
.where(eq(siteNetworks.networkId, existingResource.networkId))
: [];
let allSites: { siteId: number }[] = [];
if (resourceData.site) {
let siteSingle;
const resourceSiteId = resourceData.site; const resourceSiteId = resourceData.site;
let site;
if (resourceSiteId) { if (resourceSiteId) {
// Look up site by niceId // Look up site by niceId
[site] = await trx [siteSingle] = await trx
.select({ siteId: sites.siteId }) .select({ siteId: sites.siteId })
.from(sites) .from(sites)
.where( .where(
@@ -60,20 +74,45 @@ export async function updateClientResources(
.limit(1); .limit(1);
} else if (siteId) { } else if (siteId) {
// Use the provided siteId directly, but verify it belongs to the org // Use the provided siteId directly, but verify it belongs to the org
[site] = await trx [siteSingle] = await trx
.select({ siteId: sites.siteId }) .select({ siteId: sites.siteId })
.from(sites) .from(sites)
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))) .where(
and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))
)
.limit(1); .limit(1);
} else { } else {
throw new Error(`Target site is required`); throw new Error(`Target site is required`);
} }
if (!site) { if (!siteSingle) {
throw new Error( throw new Error(
`Site not found: ${resourceSiteId} in org ${orgId}` `Site not found: ${resourceSiteId} in org ${orgId}`
); );
} }
allSites.push(siteSingle);
}
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) { if (existingResource) {
// Update existing resource // Update existing resource
@@ -81,7 +120,6 @@ export async function updateClientResources(
.update(siteResources) .update(siteResources)
.set({ .set({
name: resourceData.name || resourceNiceId, name: resourceData.name || resourceNiceId,
siteId: site.siteId,
mode: resourceData.mode, mode: resourceData.mode,
destination: resourceData.destination, destination: resourceData.destination,
enabled: true, // hardcoded for now enabled: true, // hardcoded for now
@@ -102,6 +140,21 @@ export async function updateClientResources(
const siteResourceId = existingResource.siteResourceId; const siteResourceId = existingResource.siteResourceId;
const orgId = existingResource.orgId; const orgId = existingResource.orgId;
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 await trx
.delete(clientSiteResources) .delete(clientSiteResources)
.where(eq(clientSiteResources.siteResourceId, siteResourceId)); .where(eq(clientSiteResources.siteResourceId, siteResourceId));
@@ -204,7 +257,9 @@ export async function updateClientResources(
results.push({ results.push({
newSiteResource: updatedResource, newSiteResource: updatedResource,
oldSiteResource: existingResource oldSiteResource: existingResource,
newSites: allSites,
oldSites: existingSiteIds
}); });
} else { } else {
let aliasAddress: string | null = null; let aliasAddress: string | null = null;
@@ -213,13 +268,22 @@ export async function updateClientResources(
aliasAddress = await getNextAvailableAliasAddress(orgId); aliasAddress = await getNextAvailableAliasAddress(orgId);
} }
const [network] = await trx
.insert(networks)
.values({
scope: "resource",
orgId: orgId
})
.returning();
// Create new resource // Create new resource
const [newResource] = await trx const [newResource] = await trx
.insert(siteResources) .insert(siteResources)
.values({ .values({
orgId: orgId, orgId: orgId,
siteId: site.siteId,
niceId: resourceNiceId, niceId: resourceNiceId,
networkId: network.networkId,
defaultNetworkId: network.networkId,
name: resourceData.name || resourceNiceId, name: resourceData.name || resourceNiceId,
mode: resourceData.mode, mode: resourceData.mode,
destination: resourceData.destination, destination: resourceData.destination,
@@ -235,6 +299,13 @@ export async function updateClientResources(
const siteResourceId = newResource.siteResourceId; const siteResourceId = newResource.siteResourceId;
for (const site of allSites) {
await trx.insert(siteNetworks).values({
siteId: site.siteId,
networkId: network.networkId
});
}
const [adminRole] = await trx const [adminRole] = await trx
.select() .select()
.from(roles) .from(roles)
@@ -324,7 +395,11 @@ export async function updateClientResources(
`Created new client resource ${newResource.name} (${newResource.siteResourceId}) for org ${orgId}` `Created new client resource ${newResource.name} (${newResource.siteResourceId}) for org ${orgId}`
); );
results.push({ newSiteResource: newResource }); results.push({
newSiteResource: newResource,
newSites: allSites,
oldSites: existingSiteIds
});
} }
} }

View File

@@ -326,7 +326,8 @@ export const ClientResourceSchema = z
.object({ .object({
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
mode: z.enum(["host", "cidr"]), mode: z.enum(["host", "cidr"]),
site: z.string(), site: z.string(), // DEPRECATED IN FAVOR OF sites
sites: z.array(z.string()).optional().default([]),
// protocol: z.enum(["tcp", "udp"]).optional(), // protocol: z.enum(["tcp", "udp"]).optional(),
// proxyPort: z.int().positive().optional(), // proxyPort: z.int().positive().optional(),
// destinationPort: z.int().positive().optional(), // destinationPort: z.int().positive().optional(),

View File

@@ -11,11 +11,11 @@ import {
roleSiteResources, roleSiteResources,
Site, Site,
SiteResource, SiteResource,
siteNetworks,
siteResources, siteResources,
sites, sites,
Transaction, Transaction,
userOrgRoles, userOrgRoles,
userOrgs,
userSiteResources userSiteResources
} from "@server/db"; } from "@server/db";
import { and, eq, inArray, ne } from "drizzle-orm"; import { and, eq, inArray, ne } from "drizzle-orm";
@@ -48,15 +48,23 @@ export async function getClientSiteResourceAccess(
siteResource: SiteResource, siteResource: SiteResource,
trx: Transaction | typeof db = db trx: Transaction | typeof db = db
) { ) {
// get the site // get all sites associated with this siteResource via its network
const [site] = await trx const sitesList = siteResource.networkId
? await trx
.select() .select()
.from(sites) .from(sites)
.where(eq(sites.siteId, siteResource.siteId)) .innerJoin(
.limit(1); siteNetworks,
eq(siteNetworks.siteId, sites.siteId)
)
.where(eq(siteNetworks.networkId, siteResource.networkId))
.then((rows) => rows.map((row) => row.sites))
: [];
if (!site) { if (sitesList.length === 0) {
throw new Error(`Site with ID ${siteResource.siteId} not found`); logger.warn(
`No sites found for siteResource ${siteResource.siteResourceId} with networkId ${siteResource.networkId}`
);
} }
const roleIds = await trx const roleIds = await trx
@@ -137,7 +145,7 @@ export async function getClientSiteResourceAccess(
const mergedAllClientIds = mergedAllClients.map((c) => c.clientId); const mergedAllClientIds = mergedAllClients.map((c) => c.clientId);
return { return {
site, sitesList,
mergedAllClients, mergedAllClients,
mergedAllClientIds mergedAllClientIds
}; };
@@ -153,17 +161,18 @@ export async function rebuildClientAssociationsFromSiteResource(
subnet: string | null; subnet: string | null;
}[]; }[];
}> { }> {
const siteId = siteResource.siteId; const { sitesList, mergedAllClients, mergedAllClientIds } =
const { site, mergedAllClients, mergedAllClientIds } =
await getClientSiteResourceAccess(siteResource, trx); await getClientSiteResourceAccess(siteResource, trx);
/////////// process the client-siteResource associations /////////// /////////// process the client-siteResource associations ///////////
// get all of the clients associated with other resources on this site // get all of the clients associated with other resources in the same network,
const allUpdatedClientsFromOtherResourcesOnThisSite = await trx // joined through siteNetworks so we know which siteId each client belongs to
const allUpdatedClientsFromOtherResourcesOnThisSite = siteResource.networkId
? await trx
.select({ .select({
clientId: clientSiteResourcesAssociationsCache.clientId clientId: clientSiteResourcesAssociationsCache.clientId,
siteId: siteNetworks.siteId
}) })
.from(clientSiteResourcesAssociationsCache) .from(clientSiteResourcesAssociationsCache)
.innerJoin( .innerJoin(
@@ -173,20 +182,30 @@ export async function rebuildClientAssociationsFromSiteResource(
siteResources.siteResourceId siteResources.siteResourceId
) )
) )
.innerJoin(
siteNetworks,
eq(siteNetworks.networkId, siteResources.networkId)
)
.where( .where(
and( and(
eq(siteResources.siteId, siteId), eq(siteResources.networkId, siteResource.networkId),
ne(siteResources.siteResourceId, siteResource.siteResourceId) ne(
siteResources.siteResourceId,
siteResource.siteResourceId
) )
); )
)
: [];
const allClientIdsFromOtherResourcesOnThisSite = Array.from( // Build a per-site map so the loop below can check by siteId rather than
new Set( // across the entire network.
allUpdatedClientsFromOtherResourcesOnThisSite.map( const clientsFromOtherResourcesBySite = new Map<number, Set<number>>();
(row) => row.clientId 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 const existingClientSiteResources = await trx
.select({ .select({
@@ -260,31 +279,39 @@ export async function rebuildClientAssociationsFromSiteResource(
/////////// process the client-site associations /////////// /////////// process the client-site associations ///////////
for (const site of sitesList) {
const siteId = site.siteId;
const existingClientSites = await trx const existingClientSites = await trx
.select({ .select({
clientId: clientSitesAssociationsCache.clientId clientId: clientSitesAssociationsCache.clientId
}) })
.from(clientSitesAssociationsCache) .from(clientSitesAssociationsCache)
.where(eq(clientSitesAssociationsCache.siteId, siteResource.siteId)); .where(eq(clientSitesAssociationsCache.siteId, siteId));
const existingClientSiteIds = existingClientSites.map( const existingClientSiteIds = existingClientSites.map(
(row) => row.clientId (row) => row.clientId
); );
// Get full client details for existing clients (needed for sending delete messages) // Get full client details for existing clients (needed for sending delete messages)
const existingClients = await trx const existingClients =
existingClientSiteIds.length > 0
? await trx
.select({ .select({
clientId: clients.clientId, clientId: clients.clientId,
pubKey: clients.pubKey, pubKey: clients.pubKey,
subnet: clients.subnet subnet: clients.subnet
}) })
.from(clients) .from(clients)
.where(inArray(clients.clientId, existingClientSiteIds)); .where(inArray(clients.clientId, existingClientSiteIds))
: [];
const otherResourceClientIds = clientsFromOtherResourcesBySite.get(siteId) ?? new Set<number>();
const clientSitesToAdd = mergedAllClientIds.filter( const clientSitesToAdd = mergedAllClientIds.filter(
(clientId) => (clientId) =>
!existingClientSiteIds.includes(clientId) && !existingClientSiteIds.includes(clientId) &&
!allClientIdsFromOtherResourcesOnThisSite.includes(clientId) // dont remove if there is still another connection for another site resource !otherResourceClientIds.has(clientId) // dont add if already connected via another site resource
); );
const clientSitesToInsert = clientSitesToAdd.map((clientId) => ({ const clientSitesToInsert = clientSitesToAdd.map((clientId) => ({
@@ -303,7 +330,7 @@ export async function rebuildClientAssociationsFromSiteResource(
const clientSitesToRemove = existingClientSiteIds.filter( const clientSitesToRemove = existingClientSiteIds.filter(
(clientId) => (clientId) =>
!mergedAllClientIds.includes(clientId) && !mergedAllClientIds.includes(clientId) &&
!allClientIdsFromOtherResourcesOnThisSite.includes(clientId) // dont remove if there is still another connection for another site resource !otherResourceClientIds.has(clientId) // dont remove if there is still another connection for another site resource
); );
if (clientSitesToRemove.length > 0) { if (clientSitesToRemove.length > 0) {
@@ -320,8 +347,6 @@ export async function rebuildClientAssociationsFromSiteResource(
); );
} }
/////////// send the messages ///////////
// Now handle the messages to add/remove peers on both the newt and olm sides // Now handle the messages to add/remove peers on both the newt and olm sides
await handleMessagesForSiteClients( await handleMessagesForSiteClients(
site, site,
@@ -332,10 +357,12 @@ export async function rebuildClientAssociationsFromSiteResource(
clientSitesToRemove, clientSitesToRemove,
trx trx
); );
}
// Handle subnet proxy target updates for the resource associations // Handle subnet proxy target updates for the resource associations
await handleSubnetProxyTargetUpdates( await handleSubnetProxyTargetUpdates(
siteResource, siteResource,
sitesList,
mergedAllClients, mergedAllClients,
existingResourceClients, existingResourceClients,
clientSiteResourcesToAdd, clientSiteResourcesToAdd,
@@ -624,6 +651,7 @@ export async function updateClientSiteDestinations(
async function handleSubnetProxyTargetUpdates( async function handleSubnetProxyTargetUpdates(
siteResource: SiteResource, siteResource: SiteResource,
sitesList: Site[],
allClients: { allClients: {
clientId: number; clientId: number;
pubKey: string | null; pubKey: string | null;
@@ -638,22 +666,26 @@ async function handleSubnetProxyTargetUpdates(
clientSiteResourcesToRemove: number[], clientSiteResourcesToRemove: number[],
trx: Transaction | typeof db = db trx: Transaction | typeof db = db
): Promise<void> { ): Promise<void> {
const proxyJobs: Promise<any>[] = [];
const olmJobs: Promise<any>[] = [];
for (const siteData of sitesList) {
const siteId = siteData.siteId;
// Get the newt for this site // Get the newt for this site
const [newt] = await trx const [newt] = await trx
.select() .select()
.from(newts) .from(newts)
.where(eq(newts.siteId, siteResource.siteId)) .where(eq(newts.siteId, siteId))
.limit(1); .limit(1);
if (!newt) { if (!newt) {
logger.warn( logger.warn(
`Newt not found for site ${siteResource.siteId}, skipping subnet proxy target updates` `Newt not found for site ${siteId}, skipping subnet proxy target updates`
); );
return; continue;
} }
const proxyJobs = [];
const olmJobs = [];
// Generate targets for added associations // Generate targets for added associations
if (clientSiteResourcesToAdd.length > 0) { if (clientSiteResourcesToAdd.length > 0) {
const addedClients = allClients.filter((client) => const addedClients = allClients.filter((client) =>
@@ -680,7 +712,7 @@ async function handleSubnetProxyTargetUpdates(
olmJobs.push( olmJobs.push(
addPeerData( addPeerData(
client.clientId, client.clientId,
siteResource.siteId, siteId,
generateRemoteSubnets([siteResource]), generateRemoteSubnets([siteResource]),
generateAliasConfig([siteResource]) generateAliasConfig([siteResource])
) )
@@ -714,7 +746,11 @@ async function handleSubnetProxyTargetUpdates(
} }
for (const client of removedClients) { for (const client of removedClients) {
// 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 const destinationStillInUse = await trx
.select() .select()
.from(siteResources) .from(siteResources)
@@ -725,13 +761,17 @@ async function handleSubnetProxyTargetUpdates(
siteResources.siteResourceId siteResources.siteResourceId
) )
) )
.innerJoin(
siteNetworks,
eq(siteNetworks.networkId, siteResources.networkId)
)
.where( .where(
and( and(
eq( eq(
clientSiteResourcesAssociationsCache.clientId, clientSiteResourcesAssociationsCache.clientId,
client.clientId client.clientId
), ),
eq(siteResources.siteId, siteResource.siteId), eq(siteNetworks.siteId, siteId),
eq( eq(
siteResources.destination, siteResources.destination,
siteResource.destination siteResource.destination
@@ -752,7 +792,7 @@ async function handleSubnetProxyTargetUpdates(
olmJobs.push( olmJobs.push(
removePeerData( removePeerData(
client.clientId, client.clientId,
siteResource.siteId, siteId,
remoteSubnetsToRemove, remoteSubnetsToRemove,
generateAliasConfig([siteResource]) generateAliasConfig([siteResource])
) )
@@ -760,6 +800,7 @@ async function handleSubnetProxyTargetUpdates(
} }
} }
} }
}
await Promise.all(proxyJobs); await Promise.all(proxyJobs);
} }
@@ -863,10 +904,25 @@ export async function rebuildClientAssociationsFromClient(
) )
: []; : [];
// Group by siteId for site-level associations // Group by siteId for site-level associations — look up via siteNetworks since
const newSiteIds = Array.from( // siteResources no longer carries a direct siteId column.
new Set(newSiteResources.map((sr) => sr.siteId)) 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 /////////// /////////// Process client-siteResource associations ///////////
@@ -1139,13 +1195,45 @@ async function handleMessagesForClientResources(
resourcesToAdd.includes(r.siteResourceId) 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<number, number[]>();
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 // Group by site for proxy updates
const addedBySite = new Map<number, SiteResource[]>(); const addedBySite = new Map<number, SiteResource[]>();
for (const resource of addedResources) { for (const resource of addedResources) {
if (!addedBySite.has(resource.siteId)) { const siteIds =
addedBySite.set(resource.siteId, []); 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 // Add subnet proxy targets for each site
@@ -1187,7 +1275,7 @@ async function handleMessagesForClientResources(
olmJobs.push( olmJobs.push(
addPeerData( addPeerData(
client.clientId, client.clientId,
resource.siteId, siteId,
generateRemoteSubnets([resource]), generateRemoteSubnets([resource]),
generateAliasConfig([resource]) generateAliasConfig([resource])
) )
@@ -1199,7 +1287,7 @@ async function handleMessagesForClientResources(
error.message.includes("not found") error.message.includes("not found")
) { ) {
logger.debug( 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 { } else {
throw error; throw error;
@@ -1216,13 +1304,45 @@ async function handleMessagesForClientResources(
.from(siteResources) .from(siteResources)
.where(inArray(siteResources.siteResourceId, resourcesToRemove)); .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<number, number[]>();
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 // Group by site for proxy updates
const removedBySite = new Map<number, SiteResource[]>(); const removedBySite = new Map<number, SiteResource[]>();
for (const resource of removedResources) { for (const resource of removedResources) {
if (!removedBySite.has(resource.siteId)) { const siteIds =
removedBySite.set(resource.siteId, []); 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 // Remove subnet proxy targets for each site
@@ -1260,7 +1380,11 @@ async function handleMessagesForClientResources(
} }
try { 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 const destinationStillInUse = await trx
.select() .select()
.from(siteResources) .from(siteResources)
@@ -1271,13 +1395,17 @@ async function handleMessagesForClientResources(
siteResources.siteResourceId siteResources.siteResourceId
) )
) )
.innerJoin(
siteNetworks,
eq(siteNetworks.networkId, siteResources.networkId)
)
.where( .where(
and( and(
eq( eq(
clientSiteResourcesAssociationsCache.clientId, clientSiteResourcesAssociationsCache.clientId,
client.clientId client.clientId
), ),
eq(siteResources.siteId, resource.siteId), eq(siteNetworks.siteId, siteId),
eq( eq(
siteResources.destination, siteResources.destination,
resource.destination resource.destination
@@ -1299,7 +1427,7 @@ async function handleMessagesForClientResources(
olmJobs.push( olmJobs.push(
removePeerData( removePeerData(
client.clientId, client.clientId,
resource.siteId, siteId,
remoteSubnetsToRemove, remoteSubnetsToRemove,
generateAliasConfig([resource]) generateAliasConfig([resource])
) )
@@ -1311,7 +1439,7 @@ async function handleMessagesForClientResources(
error.message.includes("not found") error.message.includes("not found")
) { ) {
logger.debug( 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 { } else {
throw error; throw error;

View File

@@ -22,11 +22,15 @@ import { OpenAPITags, registry } from "@server/openApi";
import { db, domainNamespaces, resources } from "@server/db"; import { db, domainNamespaces, resources } from "@server/db";
import { inArray } from "drizzle-orm"; import { inArray } from "drizzle-orm";
import { CheckDomainAvailabilityResponse } from "@server/routers/domain/types"; 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 paramsSchema = z.strictObject({});
const querySchema = 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({ registry.registerPath({
@@ -58,6 +62,23 @@ export async function checkDomainNamespaceAvailability(
} }
const { subdomain } = parsedQuery.data; const { subdomain } = parsedQuery.data;
// if (
// build == "saas" &&
// !isSubscribed(orgId!, tierMatrix.domainNamespaces)
// ) {
// // return not available
// return response<CheckDomainAvailabilityResponse>(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); const namespaces = await db.select().from(domainNamespaces);
let possibleDomains = namespaces.map((ns) => { let possibleDomains = namespaces.map((ns) => {
const desired = `${subdomain}.${ns.domainNamespaceId}`; const desired = `${subdomain}.${ns.domainNamespaceId}`;

View File

@@ -22,6 +22,9 @@ import { eq, sql } from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi"; 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({}); const paramsSchema = z.strictObject({});
@@ -37,7 +40,8 @@ const querySchema = z.strictObject({
.optional() .optional()
.default("0") .default("0")
.transform(Number) .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) { async function query(limit: number, offset: number) {
@@ -99,6 +103,26 @@ export async function listDomainNamespaces(
); );
} }
// if (
// build == "saas" &&
// !isSubscribed(orgId!, tierMatrix.domainNamespaces)
// ) {
// return response<ListDomainNamespacesResponse>(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 domainNamespacesList = await query(limit, offset);
const [{ count }] = await db const [{ count }] = await db

View File

@@ -21,7 +21,7 @@ import {
roles, roles,
roundTripMessageTracker, roundTripMessageTracker,
siteResources, siteResources,
sites, siteNetworks,
userOrgs userOrgs
} from "@server/db"; } from "@server/db";
import { logAccessAudit } from "#private/lib/logAccessAudit"; import { logAccessAudit } from "#private/lib/logAccessAudit";
@@ -63,10 +63,12 @@ const bodySchema = z
export type SignSshKeyResponse = { export type SignSshKeyResponse = {
certificate: string; certificate: string;
messageIds: number[];
messageId: number; messageId: number;
sshUsername: string; sshUsername: string;
sshHost: string; sshHost: string;
resourceId: number; resourceId: number;
siteIds: number[];
siteId: number; siteId: number;
keyId: string; keyId: string;
validPrincipals: string[]; validPrincipals: string[];
@@ -260,10 +262,7 @@ export async function signSshKey(
.update(userOrgs) .update(userOrgs)
.set({ pamUsername: usernameToUse }) .set({ pamUsername: usernameToUse })
.where( .where(
and( and(eq(userOrgs.orgId, orgId), eq(userOrgs.userId, userId))
eq(userOrgs.orgId, orgId),
eq(userOrgs.userId, userId)
)
); );
} else { } else {
usernameToUse = userOrg.pamUsername; usernameToUse = userOrg.pamUsername;
@@ -395,21 +394,12 @@ export async function signSshKey(
homedir = roleRows[0].sshCreateHomeDir ?? null; homedir = roleRows[0].sshCreateHomeDir ?? null;
} }
// get the site const sites = await db
const [newt] = await db .select({ siteId: siteNetworks.siteId })
.select() .from(siteNetworks)
.from(newts) .where(eq(siteNetworks.networkId, resource.networkId!));
.where(eq(newts.siteId, resource.siteId))
.limit(1);
if (!newt) { const siteIds = sites.map((site) => site.siteId);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Site associated with resource not found"
)
);
}
// Sign the public key // Sign the public key
const now = BigInt(Math.floor(Date.now() / 1000)); const now = BigInt(Math.floor(Date.now() / 1000));
@@ -423,6 +413,24 @@ export async function signSshKey(
validBefore: now + validFor validBefore: now + validFor
}); });
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 (!newt) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Site associated with resource not found"
)
);
}
const [message] = await db const [message] = await db
.insert(roundTripMessageTracker) .insert(roundTripMessageTracker)
.values({ .values({
@@ -441,6 +449,8 @@ export async function signSshKey(
); );
} }
messageIds.push(message.messageId);
await sendToClient(newt.newtId, { await sendToClient(newt.newtId, {
type: `newt/pam/connection`, type: `newt/pam/connection`,
data: { data: {
@@ -460,6 +470,7 @@ export async function signSshKey(
} }
} }
}); });
}
const expiresIn = Number(validFor); // seconds const expiresIn = Number(validFor); // seconds
@@ -480,7 +491,7 @@ export async function signSshKey(
metadata: JSON.stringify({ metadata: JSON.stringify({
resourceId: resource.siteResourceId, resourceId: resource.siteResourceId,
resource: resource.name, resource: resource.name,
siteId: resource.siteId, siteIds: siteIds
}) })
}); });
@@ -505,11 +516,13 @@ export async function signSshKey(
return response<SignSshKeyResponse>(res, { return response<SignSshKeyResponse>(res, {
data: { data: {
certificate: cert.certificate, certificate: cert.certificate,
messageId: message.messageId, messageIds: messageIds,
messageId: messageIds[0], // just pick the first one for backward compatibility
sshUsername: usernameToUse, sshUsername: usernameToUse,
sshHost: sshHost, sshHost: sshHost,
resourceId: resource.siteResourceId, resourceId: resource.siteResourceId,
siteId: resource.siteId, siteIds: siteIds,
siteId: siteIds[0], // just pick the first one for backward compatibility
keyId: cert.keyId, keyId: cert.keyId,
validPrincipals: cert.validPrincipals, validPrincipals: cert.validPrincipals,
validAfter: cert.validAfter.toISOString(), validAfter: cert.validAfter.toISOString(),

View File

@@ -4,8 +4,10 @@ import {
clientSitesAssociationsCache, clientSitesAssociationsCache,
db, db,
ExitNode, ExitNode,
networks,
resources, resources,
Site, Site,
siteNetworks,
siteResources, siteResources,
targetHealthCheck, targetHealthCheck,
targets targets
@@ -137,11 +139,14 @@ export async function buildClientConfigurationForNewtClient(
// Filter out any null values from peers that didn't have an olm // Filter out any null values from peers that didn't have an olm
const validPeers = peers.filter((peer) => peer !== null); 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 const allSiteResources = await db
.select() .select()
.from(siteResources) .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[] = []; const targetsToSend: SubnetProxyTargetV2[] = [];

View File

@@ -4,6 +4,8 @@ import {
clientSitesAssociationsCache, clientSitesAssociationsCache,
db, db,
exitNodes, exitNodes,
networks,
siteNetworks,
siteResources, siteResources,
sites sites
} from "@server/db"; } from "@server/db";
@@ -59,9 +61,17 @@ export async function buildSiteConfigurationForOlmClient(
clientSiteResourcesAssociationsCache.siteResourceId clientSiteResourcesAssociationsCache.siteResourceId
) )
) )
.innerJoin(
networks,
eq(siteResources.networkId, networks.networkId)
)
.innerJoin(
siteNetworks,
eq(networks.networkId, siteNetworks.networkId)
)
.where( .where(
and( and(
eq(siteResources.siteId, site.siteId), eq(siteNetworks.siteId, site.siteId),
eq( eq(
clientSiteResourcesAssociationsCache.clientId, clientSiteResourcesAssociationsCache.clientId,
client.clientId client.clientId
@@ -69,6 +79,7 @@ export async function buildSiteConfigurationForOlmClient(
) )
); );
if (jitMode) { if (jitMode) {
// Add site configuration to the array // Add site configuration to the array
siteConfigurations.push({ siteConfigurations.push({

View File

@@ -4,10 +4,12 @@ import {
db, db,
exitNodes, exitNodes,
Site, Site,
siteResources siteNetworks,
siteResources,
sites
} from "@server/db"; } from "@server/db";
import { MessageHandler } from "@server/routers/ws"; 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 { and, eq, or } from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";
import { initPeerAddHandshake } from "./peers"; import { initPeerAddHandshake } from "./peers";
@@ -44,20 +46,31 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
const { siteId, resourceId, chainId } = message.data; 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) { if (siteId) {
// get the site
const [siteRes] = await db const [siteRes] = await db
.select() .select()
.from(sites) .from(sites)
.where(eq(sites.siteId, siteId)) .where(eq(sites.siteId, siteId))
.limit(1); .limit(1);
if (siteRes) { if (siteRes) {
site = siteRes; sitesToProcess = [siteRes];
} }
} } else if (resourceId) {
if (resourceId && !site) {
const resources = await db const resources = await db
.select() .select()
.from(siteResources) .from(siteResources)
@@ -72,27 +85,17 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
); );
if (!resources || resources.length === 0) { if (!resources || resources.length === 0) {
logger.error(`handleOlmServerPeerAddMessage: Resource not found`); logger.error(
// cancel the request from the olm side to not keep doing this `handleOlmServerInitAddPeerHandshake: Resource not found`
await sendToClient( );
olm.olmId, await sendCancel();
{
type: "olm/wg/peer/chain/cancel",
data: {
chainId
}
},
{ incrementConfigVersion: false }
).catch((error) => {
logger.warn(`Error sending message:`, error);
});
return; return;
} }
if (resources.length > 1) { 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 // 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( logger.error(
`handleOlmServerPeerAddMessage: Multiple resources found matching the criteria` `handleOlmServerInitAddPeerHandshake: Multiple resources found matching the criteria`
); );
return; return;
} }
@@ -117,47 +120,61 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
if (currentResourceAssociationCaches.length === 0) { if (currentResourceAssociationCaches.length === 0) {
logger.error( 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 sendCancel();
await sendToClient(
olm.olmId,
{
type: "olm/wg/peer/chain/cancel",
data: {
chainId
}
},
{ incrementConfigVersion: false }
).catch((error) => {
logger.warn(`Error sending message:`, error);
});
return; return;
} }
const siteIdFromResource = resource.siteId; if (!resource.networkId) {
logger.error(
`handleOlmServerInitAddPeerHandshake: Resource ${resource.siteResourceId} has no network`
);
await sendCancel();
return;
}
// get the site // Get all sites associated with this resource's network via siteNetworks
const [siteRes] = await db 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() .select()
.from(sites) .from(sites)
.where(eq(sites.siteId, siteIdFromResource)); .where(eq(sites.siteId, sid))
if (!siteRes) { .limit(1);
logger.error( return s ?? null;
`handleOlmServerPeerAddMessage: Site with ID ${site} not found` })
); );
sitesToProcess = foundSites.filter((s): s is Site => s !== null);
}
if (sitesToProcess.length === 0) {
logger.error(
`handleOlmServerInitAddPeerHandshake: No sites to process`
);
await sendCancel();
return; return;
} }
site = siteRes; let handshakeInitiated = false;
}
if (!site) { for (const site of sitesToProcess) {
logger.error(`handleOlmServerPeerAddMessage: Site not found`); // Check if the client can access this site using the cache
return;
}
// check if the client can access this site using the cache
const currentSiteAssociationCaches = await db const currentSiteAssociationCaches = await db
.select() .select()
.from(clientSitesAssociationsCache) .from(clientSitesAssociationsCache)
@@ -169,46 +186,19 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
); );
if (currentSiteAssociationCaches.length === 0) { if (currentSiteAssociationCaches.length === 0) {
logger.error( logger.warn(
`handleOlmServerPeerAddMessage: Client ${client.clientId} does not have access to site ${site.siteId}` `handleOlmServerInitAddPeerHandshake: Client ${client.clientId} does not have access to site ${site.siteId}, skipping`
); );
// cancel the request from the olm side to not keep doing this continue;
await sendToClient(
olm.olmId,
{
type: "olm/wg/peer/chain/cancel",
data: {
chainId
}
},
{ incrementConfigVersion: false }
).catch((error) => {
logger.warn(`Error sending message:`, error);
});
return;
} }
if (!site.exitNodeId) { if (!site.exitNodeId) {
logger.error( logger.error(
`handleOlmServerPeerAddMessage: Site with ID ${site.siteId} has no exit node` `handleOlmServerInitAddPeerHandshake: Site ${site.siteId} has no exit node, skipping`
); );
// cancel the request from the olm side to not keep doing this continue;
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 const [exitNode] = await db
.select() .select()
.from(exitNodes) .from(exitNodes)
@@ -216,15 +206,13 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
if (!exitNode) { if (!exitNode) {
logger.error( logger.error(
`handleOlmServerPeerAddMessage: Site with ID ${site.siteId} has no exit node` `handleOlmServerInitAddPeerHandshake: Exit node not found for site ${site.siteId}, skipping`
); );
return; continue;
} }
// also trigger the peer add handshake in case the peer was not already added to the olm and we need to hole punch // Trigger the peer add handshake — if the peer was already added this will be a no-op
// if it has already been added this will be a no-op
await initPeerAddHandshake( await initPeerAddHandshake(
// this will kick off the add peer process for the client
client.clientId, client.clientId,
{ {
siteId: site.siteId, siteId: site.siteId,
@@ -237,5 +225,15 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
chainId chainId
); );
handshakeInitiated = true;
}
if (!handshakeInitiated) {
logger.error(
`handleOlmServerInitAddPeerHandshake: No accessible sites with valid exit nodes found, cancelling chain`
);
await sendCancel();
}
return; return;
}; };

View File

@@ -1,43 +1,25 @@
import { import {
Client,
clientSiteResourcesAssociationsCache, clientSiteResourcesAssociationsCache,
db, db,
ExitNode, networks,
Org, siteNetworks,
orgs,
roleClients,
roles,
siteResources, siteResources,
Transaction,
userClients,
userOrgs,
users
} from "@server/db"; } from "@server/db";
import { MessageHandler } from "@server/routers/ws"; import { MessageHandler } from "@server/routers/ws";
import { import {
clients, clients,
clientSitesAssociationsCache, clientSitesAssociationsCache,
exitNodes,
Olm, Olm,
olms,
sites sites
} from "@server/db"; } from "@server/db";
import { and, eq, inArray, isNotNull, isNull } from "drizzle-orm"; import { and, eq, inArray, isNotNull, isNull } from "drizzle-orm";
import { addPeer, deletePeer } from "../newt/peers";
import logger from "@server/logger"; import logger from "@server/logger";
import { listExitNodes } from "#dynamic/lib/exitNodes";
import { import {
generateAliasConfig, generateAliasConfig,
getNextAvailableClientSubnet
} from "@server/lib/ip"; } from "@server/lib/ip";
import { generateRemoteSubnets } 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 { import {
addPeer as newtAddPeer, addPeer as newtAddPeer,
deletePeer as newtDeletePeer
} from "@server/routers/newt/peers"; } from "@server/routers/newt/peers";
export const handleOlmServerPeerAddMessage: MessageHandler = async ( export const handleOlmServerPeerAddMessage: MessageHandler = async (
@@ -153,14 +135,22 @@ export const handleOlmServerPeerAddMessage: MessageHandler = async (
clientSiteResourcesAssociationsCache.siteResourceId clientSiteResourcesAssociationsCache.siteResourceId
) )
) )
.where( .innerJoin(
networks,
eq(siteResources.networkId, networks.networkId)
)
.innerJoin(
siteNetworks,
and( and(
eq(siteResources.siteId, site.siteId), eq(networks.networkId, siteNetworks.networkId),
eq(siteNetworks.siteId, site.siteId)
)
)
.where(
eq( eq(
clientSiteResourcesAssociationsCache.clientId, clientSiteResourcesAssociationsCache.clientId,
client.clientId client.clientId
) )
)
); );
// Return connect message with all site configurations // Return connect message with all site configurations

View File

@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db, loginPage } from "@server/db"; import { db, domainNamespaces, loginPage } from "@server/db";
import { import {
domains, domains,
orgDomains, orgDomains,
@@ -24,6 +24,8 @@ import { build } from "@server/build";
import { createCertificate } from "#dynamic/routers/certificates/createCertificate"; import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
import { getUniqueResourceName } from "@server/db/names"; import { getUniqueResourceName } from "@server/db/names";
import { validateAndConstructDomain } from "@server/lib/domainUtils"; import { validateAndConstructDomain } from "@server/lib/domainUtils";
import { isSubscribed } from "#dynamic/lib/isSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
const createResourceParamsSchema = z.strictObject({ const createResourceParamsSchema = z.strictObject({
orgId: z.string() orgId: z.string()
@@ -112,7 +114,10 @@ export async function createResource(
const { orgId } = parsedParams.data; const { orgId } = parsedParams.data;
if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) { if (
req.user &&
(!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)
) {
return next( return next(
createHttpError(HttpCode.FORBIDDEN, "User does not have a role") createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
); );
@@ -193,6 +198,29 @@ async function createHttpResource(
const subdomain = parsedBody.data.subdomain; const subdomain = parsedBody.data.subdomain;
const stickySession = parsedBody.data.stickySession; 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 // Validate domain and construct full domain
const domainResult = await validateAndConstructDomain( const domainResult = await validateAndConstructDomain(
domainId, domainId,

View File

@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db, loginPage } from "@server/db"; import { db, domainNamespaces, loginPage } from "@server/db";
import { import {
domains, domains,
Org, Org,
@@ -25,6 +25,7 @@ import { validateAndConstructDomain } from "@server/lib/domainUtils";
import { build } from "@server/build"; import { build } from "@server/build";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { isSubscribed } from "#dynamic/lib/isSubscribed";
const updateResourceParamsSchema = z.strictObject({ const updateResourceParamsSchema = z.strictObject({
resourceId: z.string().transform(Number).pipe(z.int().positive()) resourceId: z.string().transform(Number).pipe(z.int().positive())
@@ -120,7 +121,9 @@ const updateHttpResourceBodySchema = z
if (data.headers) { if (data.headers) {
// HTTP header values must be visible ASCII or horizontal whitespace, no control chars (RFC 7230) // HTTP header values must be visible ASCII or horizontal whitespace, no control chars (RFC 7230)
const validHeaderValue = /^[\t\x20-\x7E]*$/; 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; return true;
}, },
@@ -318,6 +321,34 @@ async function updateHttpResource(
if (updateData.domainId) { if (updateData.domainId) {
const domainId = 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 // Validate domain and construct full domain
const domainResult = await validateAndConstructDomain( const domainResult = await validateAndConstructDomain(
domainId, domainId,

View File

@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; 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 { newts, newtSessions, sites } from "@server/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import response from "@server/lib/response"; import response from "@server/lib/response";
@@ -71,19 +71,24 @@ export async function deleteSite(
await deletePeer(site.exitNodeId!, site.pubKey); await deletePeer(site.exitNodeId!, site.pubKey);
} }
} else if (site.type == "newt") { } else if (site.type == "newt") {
// delete all of the site resources on this site const networks = await trx
const siteResourcesOnSite = trx .select({ networkId: siteNetworks.networkId })
.delete(siteResources) .from(siteNetworks)
.where(eq(siteResources.siteId, siteId)) .where(eq(siteNetworks.siteId, siteId));
.returning();
// loop through them // loop through them
for (const removedSiteResource of await siteResourcesOnSite) { for (const network of await networks) {
const [siteResource] = await trx
.select()
.from(siteResources)
.where(eq(siteResources.networkId, network.networkId));
if (siteResource) {
await rebuildClientAssociationsFromSiteResource( await rebuildClientAssociationsFromSiteResource(
removedSiteResource, siteResource,
trx trx
); );
} }
}
// get the newt on the site by querying the newt table for siteId // get the newt on the site by querying the newt table for siteId
const [deletedNewt] = await trx const [deletedNewt] = await trx

View File

@@ -5,6 +5,8 @@ import {
orgs, orgs,
roles, roles,
roleSiteResources, roleSiteResources,
siteNetworks,
networks,
SiteResource, SiteResource,
siteResources, siteResources,
sites, sites,
@@ -23,7 +25,7 @@ import response from "@server/lib/response";
import logger from "@server/logger"; import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import HttpCode from "@server/types/HttpCode"; 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 { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import { z } from "zod"; import { z } from "zod";
@@ -37,7 +39,7 @@ const createSiteResourceSchema = z
.strictObject({ .strictObject({
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
mode: z.enum(["host", "cidr", "port"]), mode: z.enum(["host", "cidr", "port"]),
siteId: z.int(), siteIds: z.array(z.int()),
// protocol: z.enum(["tcp", "udp"]).optional(), // protocol: z.enum(["tcp", "udp"]).optional(),
// proxyPort: z.int().positive().optional(), // proxyPort: z.int().positive().optional(),
// destinationPort: z.int().positive().optional(), // destinationPort: z.int().positive().optional(),
@@ -159,7 +161,7 @@ export async function createSiteResource(
const { orgId } = parsedParams.data; const { orgId } = parsedParams.data;
const { const {
name, name,
siteId, siteIds,
mode, mode,
// protocol, // protocol,
// proxyPort, // proxyPort,
@@ -178,14 +180,16 @@ export async function createSiteResource(
} = parsedBody.data; } = parsedBody.data;
// Verify the site exists and belongs to the org // Verify the site exists and belongs to the org
const [site] = await db const sitesToAssign = await db
.select() .select()
.from(sites) .from(sites)
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))) .where(and(inArray(sites.siteId, siteIds), eq(sites.orgId, orgId)))
.limit(1); .limit(1);
if (!site) { if (sitesToAssign.length !== siteIds.length) {
return next(createHttpError(HttpCode.NOT_FOUND, "Site not found")); return next(
createHttpError(HttpCode.NOT_FOUND, "Some site not found")
);
} }
const [org] = await db const [org] = await db
@@ -287,12 +291,29 @@ export async function createSiteResource(
let newSiteResource: SiteResource | undefined; let newSiteResource: SiteResource | undefined;
await db.transaction(async (trx) => { 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 // Create the site resource
const insertValues: typeof siteResources.$inferInsert = { const insertValues: typeof siteResources.$inferInsert = {
siteId,
niceId, niceId,
orgId, orgId,
name, name,
networkId: network.networkId,
mode: mode as "host" | "cidr", mode: mode as "host" | "cidr",
destination, destination,
enabled, enabled,
@@ -317,6 +338,13 @@ export async function createSiteResource(
//////////////////// update the associations //////////////////// //////////////////// update the associations ////////////////////
for (const siteId of siteIds) {
await trx.insert(siteNetworks).values({
siteId: siteId,
networkId: network.networkId
});
}
const [adminRole] = await trx const [adminRole] = await trx
.select() .select()
.from(roles) .from(roles)
@@ -359,17 +387,22 @@ export async function createSiteResource(
); );
} }
for (const siteToAssign of sitesToAssign) {
const [newt] = await trx const [newt] = await trx
.select() .select()
.from(newts) .from(newts)
.where(eq(newts.siteId, site.siteId)) .where(eq(newts.siteId, siteToAssign.siteId))
.limit(1); .limit(1);
if (!newt) { if (!newt) {
return next( return next(
createHttpError(HttpCode.NOT_FOUND, "Newt not found") createHttpError(
HttpCode.NOT_FOUND,
`Newt not found for site ${siteToAssign.siteId}`
)
); );
} }
}
await rebuildClientAssociationsFromSiteResource( await rebuildClientAssociationsFromSiteResource(
newSiteResource, newSiteResource,
@@ -387,7 +420,7 @@ export async function createSiteResource(
} }
logger.info( logger.info(
`Created site resource ${newSiteResource.siteResourceId} for site ${siteId}` `Created site resource ${newSiteResource.siteResourceId} for org ${orgId}`
); );
return response(res, { return response(res, {

View File

@@ -70,17 +70,18 @@ export async function deleteSiteResource(
.where(and(eq(siteResources.siteResourceId, siteResourceId))) .where(and(eq(siteResources.siteResourceId, siteResourceId)))
.returning(); .returning();
const [newt] = await trx // not sure why this is here...
.select() // const [newt] = await trx
.from(newts) // .select()
.where(eq(newts.siteId, removedSiteResource.siteId)) // .from(newts)
.limit(1); // .where(eq(newts.siteId, removedSiteResource.siteId))
// .limit(1);
if (!newt) { // if (!newt) {
return next( // return next(
createHttpError(HttpCode.NOT_FOUND, "Newt not found") // createHttpError(HttpCode.NOT_FOUND, "Newt not found")
); // );
} // }
await rebuildClientAssociationsFromSiteResource( await rebuildClientAssociationsFromSiteResource(
removedSiteResource, removedSiteResource,

View File

@@ -17,38 +17,34 @@ const getSiteResourceParamsSchema = z.strictObject({
.transform((val) => (val ? Number(val) : undefined)) .transform((val) => (val ? Number(val) : undefined))
.pipe(z.int().positive().optional()) .pipe(z.int().positive().optional())
.optional(), .optional(),
siteId: z.string().transform(Number).pipe(z.int().positive()),
niceId: z.string().optional(), niceId: z.string().optional(),
orgId: z.string() orgId: z.string()
}); });
async function query( async function query(
siteResourceId?: number, siteResourceId?: number,
siteId?: number,
niceId?: string, niceId?: string,
orgId?: string orgId?: string
) { ) {
if (siteResourceId && siteId && orgId) { if (siteResourceId && orgId) {
const [siteResource] = await db const [siteResource] = await db
.select() .select()
.from(siteResources) .from(siteResources)
.where( .where(
and( and(
eq(siteResources.siteResourceId, siteResourceId), eq(siteResources.siteResourceId, siteResourceId),
eq(siteResources.siteId, siteId),
eq(siteResources.orgId, orgId) eq(siteResources.orgId, orgId)
) )
) )
.limit(1); .limit(1);
return siteResource; return siteResource;
} else if (niceId && siteId && orgId) { } else if (niceId && orgId) {
const [siteResource] = await db const [siteResource] = await db
.select() .select()
.from(siteResources) .from(siteResources)
.where( .where(
and( and(
eq(siteResources.niceId, niceId), eq(siteResources.niceId, niceId),
eq(siteResources.siteId, siteId),
eq(siteResources.orgId, orgId) eq(siteResources.orgId, orgId)
) )
) )
@@ -84,7 +80,6 @@ registry.registerPath({
request: { request: {
params: z.object({ params: z.object({
niceId: z.string(), niceId: z.string(),
siteId: z.number(),
orgId: z.string() 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 // Get the site resource
const siteResource = await query(siteResourceId, siteId, niceId, orgId); const siteResource = await query(siteResourceId, niceId, orgId);
if (!siteResource) { if (!siteResource) {
return next( return next(

View File

@@ -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 response from "@server/lib/response";
import logger from "@server/logger"; import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
@@ -73,9 +73,10 @@ const listAllSiteResourcesByOrgQuerySchema = z.object({
export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{ export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{
siteResources: (SiteResource & { siteResources: (SiteResource & {
siteName: string; siteIds: number[];
siteNiceId: string; siteNames: string[];
siteAddress: string | null; siteNiceIds: string[];
siteAddresses: (string | null)[];
})[]; })[];
}>; }>;
@@ -83,7 +84,6 @@ function querySiteResourcesBase() {
return db return db
.select({ .select({
siteResourceId: siteResources.siteResourceId, siteResourceId: siteResources.siteResourceId,
siteId: siteResources.siteId,
orgId: siteResources.orgId, orgId: siteResources.orgId,
niceId: siteResources.niceId, niceId: siteResources.niceId,
name: siteResources.name, name: siteResources.name,
@@ -100,14 +100,20 @@ function querySiteResourcesBase() {
disableIcmp: siteResources.disableIcmp, disableIcmp: siteResources.disableIcmp,
authDaemonMode: siteResources.authDaemonMode, authDaemonMode: siteResources.authDaemonMode,
authDaemonPort: siteResources.authDaemonPort, authDaemonPort: siteResources.authDaemonPort,
siteName: sites.name, networkId: siteResources.networkId,
siteNiceId: sites.niceId, defaultNetworkId: siteResources.defaultNetworkId,
siteAddress: sites.address siteNames: sql<string[]>`array_agg(${sites.name})`,
siteNiceIds: sql<string[]>`array_agg(${sites.niceId})`,
siteIds: sql<number[]>`array_agg(${sites.siteId})`,
siteAddresses: sql<(string | null)[]>`array_agg(${sites.address})`
}) })
.from(siteResources) .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({ registry.registerPath({
method: "get", method: "get",
path: "/org/{orgId}/site-resources", path: "/org/{orgId}/site-resources",

View File

@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db } from "@server/db"; import { db, networks, siteNetworks } from "@server/db";
import { siteResources, sites, SiteResource } from "@server/db"; import { siteResources, sites, SiteResource } from "@server/db";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
@@ -108,13 +108,21 @@ export async function listSiteResources(
return next(createHttpError(HttpCode.NOT_FOUND, "Site not found")); 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 const siteResourcesList = await db
.select() .select()
.from(siteResources) .from(siteNetworks)
.innerJoin(
networks,
eq(siteNetworks.networkId, networks.networkId)
)
.innerJoin(
siteResources,
eq(siteResources.networkId, networks.networkId)
)
.where( .where(
and( and(
eq(siteResources.siteId, siteId), eq(siteNetworks.siteId, siteId),
eq(siteResources.orgId, orgId) eq(siteResources.orgId, orgId)
) )
) )
@@ -128,6 +136,7 @@ export async function listSiteResources(
.limit(limit) .limit(limit)
.offset(offset); .offset(offset);
return response(res, { return response(res, {
data: { siteResources: siteResourcesList }, data: { siteResources: siteResourcesList },
success: true, success: true,

View File

@@ -7,12 +7,18 @@ import {
orgs, orgs,
roles, roles,
roleSiteResources, roleSiteResources,
siteNetworks,
SiteResource, SiteResource,
siteResources, siteResources,
sites, sites,
networks,
Transaction, Transaction,
userSiteResources userSiteResources
} from "@server/db"; } from "@server/db";
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 { tierMatrix } from "@server/lib/billing/tierMatrix"; import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { import {
generateAliasConfig, generateAliasConfig,
@@ -22,12 +28,8 @@ import {
portRangeStringSchema portRangeStringSchema
} from "@server/lib/ip"; } from "@server/lib/ip";
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
import response from "@server/lib/response";
import logger from "@server/logger"; 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 HttpCode from "@server/types/HttpCode";
import { and, eq, ne } from "drizzle-orm";
import { NextFunction, Request, Response } from "express"; import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import { z } from "zod"; import { z } from "zod";
@@ -40,7 +42,8 @@ const updateSiteResourceParamsSchema = z.strictObject({
const updateSiteResourceSchema = z const updateSiteResourceSchema = z
.strictObject({ .strictObject({
name: z.string().min(1).max(255).optional(), 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 niceId: z
.string() .string()
.min(1) .min(1)
@@ -172,7 +175,7 @@ export async function updateSiteResource(
const { siteResourceId } = parsedParams.data; const { siteResourceId } = parsedParams.data;
const { const {
name, name,
siteId, // because it can change siteIds, // because it can change
niceId, niceId,
mode, mode,
destination, destination,
@@ -188,16 +191,6 @@ export async function updateSiteResource(
authDaemonMode authDaemonMode
} = parsedBody.data; } = 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 // Check if site resource exists
const [existingSiteResource] = await db const [existingSiteResource] = await db
.select() .select()
@@ -237,6 +230,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 // Only check if destination is an IP address
const isIp = z const isIp = z
.union([z.ipv4(), z.ipv6()]) .union([z.ipv4(), z.ipv6()])
@@ -254,25 +265,24 @@ export async function updateSiteResource(
); );
} }
let existingSite = site; let sitesChanged = false;
let siteChanged = false; const existingSiteIds = existingSiteResource.networkId
if (existingSiteResource.siteId !== siteId) { ? await db
siteChanged = true;
// get the existing site
[existingSite] = await db
.select() .select()
.from(sites) .from(siteNetworks)
.where(eq(sites.siteId, existingSiteResource.siteId)) .where(
.limit(1); 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;
} }
// make sure the alias is unique within the org if provided // make sure the alias is unique within the org if provided
@@ -302,7 +312,7 @@ export async function updateSiteResource(
let updatedSiteResource: SiteResource | undefined; let updatedSiteResource: SiteResource | undefined;
await db.transaction(async (trx) => { 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 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 // delete the existing site resource
await trx await trx
.delete(siteResources) .delete(siteResources)
@@ -343,7 +353,6 @@ export async function updateSiteResource(
.update(siteResources) .update(siteResources)
.set({ .set({
name, name,
siteId,
niceId, niceId,
mode, mode,
destination, destination,
@@ -447,7 +456,6 @@ export async function updateSiteResource(
.update(siteResources) .update(siteResources)
.set({ .set({
name: name, name: name,
siteId: siteId,
mode: mode, mode: mode,
destination: destination, destination: destination,
enabled: enabled, enabled: enabled,
@@ -464,6 +472,23 @@ export async function updateSiteResource(
//////////////////// update the associations //////////////////// //////////////////// 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 await trx
.delete(clientSiteResources) .delete(clientSiteResources)
.where( .where(
@@ -533,14 +558,15 @@ export async function updateSiteResource(
); );
} }
logger.info( logger.info(`Updated site resource ${siteResourceId}`);
`Updated site resource ${siteResourceId} for site ${siteId}`
);
await handleMessagingForUpdatedSiteResource( await handleMessagingForUpdatedSiteResource(
existingSiteResource, existingSiteResource,
updatedSiteResource, updatedSiteResource,
{ siteId: site.siteId, orgId: site.orgId }, siteIds.map((siteId) => ({
siteId,
orgId: existingSiteResource.orgId
})),
trx trx
); );
} }
@@ -567,7 +593,7 @@ export async function updateSiteResource(
export async function handleMessagingForUpdatedSiteResource( export async function handleMessagingForUpdatedSiteResource(
existingSiteResource: SiteResource | undefined, existingSiteResource: SiteResource | undefined,
updatedSiteResource: SiteResource, updatedSiteResource: SiteResource,
site: { siteId: number; orgId: string }, sites: { siteId: number; orgId: string }[],
trx: Transaction trx: Transaction
) { ) {
logger.debug( logger.debug(
@@ -604,6 +630,7 @@ 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 the existingSiteResource is undefined (new resource) we don't need to do anything here, the rebuild above handled it all
if (destinationChanged || aliasChanged || portRangesChanged) { if (destinationChanged || aliasChanged || portRangesChanged) {
for (const site of sites) {
const [newt] = await trx const [newt] = await trx
.select() .select()
.from(newts) .from(newts)
@@ -651,13 +678,17 @@ export async function handleMessagingForUpdatedSiteResource(
siteResources.siteResourceId siteResources.siteResourceId
) )
) )
.innerJoin(
siteNetworks,
eq(siteNetworks.networkId, siteResources.networkId)
)
.where( .where(
and( and(
eq( eq(
clientSiteResourcesAssociationsCache.clientId, clientSiteResourcesAssociationsCache.clientId,
client.clientId client.clientId
), ),
eq(siteResources.siteId, site.siteId), eq(siteNetworks.siteId, site.siteId),
eq( eq(
siteResources.destination, siteResources.destination,
existingSiteResource.destination existingSiteResource.destination
@@ -676,10 +707,11 @@ export async function handleMessagingForUpdatedSiteResource(
olmJobs.push( olmJobs.push(
updatePeerData( updatePeerData(
client.clientId, client.clientId,
updatedSiteResource.siteId, site.siteId,
destinationChanged destinationChanged
? { ? {
oldRemoteSubnets: !oldDestinationStillInUseByASite oldRemoteSubnets:
!oldDestinationStillInUseByASite
? generateRemoteSubnets([ ? generateRemoteSubnets([
existingSiteResource existingSiteResource
]) ])
@@ -705,4 +737,5 @@ export async function handleMessagingForUpdatedSiteResource(
await Promise.all(olmJobs); await Promise.all(olmJobs);
} }
}
} }

View File

@@ -1,7 +1,14 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db } from "@server/db"; 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 { and, eq, inArray } from "drizzle-orm";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
@@ -37,8 +44,7 @@ const inviteUserBodySchema = z
regenerate: z.boolean().optional() regenerate: z.boolean().optional()
}) })
.refine( .refine(
(d) => (d) => (d.roleIds != null && d.roleIds.length > 0) || d.roleId != null,
(d.roleIds != null && d.roleIds.length > 0) || d.roleId != null,
{ message: "roleIds or roleId is required", path: ["roleIds"] } { message: "roleIds or roleId is required", path: ["roleIds"] }
) )
.transform((data) => ({ .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) { if (doEmail) {
await sendEmail( await sendEmail(
@@ -314,12 +320,12 @@ export async function inviteUser(
expiresAt, expiresAt,
tokenHash tokenHash
}); });
await trx.insert(userInviteRoles).values( await trx
uniqueRoleIds.map((roleId) => ({ inviteId, roleId })) .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) { if (doEmail) {
await sendEmail( await sendEmail(

View File

@@ -10,6 +10,7 @@ import { authCookieHeader } from "@app/lib/api/cookies";
import { GetDNSRecordsResponse } from "@server/routers/domain"; import { GetDNSRecordsResponse } from "@server/routers/domain";
import DNSRecordsTable from "@app/components/DNSRecordTable"; import DNSRecordsTable from "@app/components/DNSRecordTable";
import DomainCertForm from "@app/components/DomainCertForm"; import DomainCertForm from "@app/components/DomainCertForm";
import { build } from "@server/build";
interface DomainSettingsPageProps { interface DomainSettingsPageProps {
params: Promise<{ domainId: string; orgId: string }>; params: Promise<{ domainId: string; orgId: string }>;
@@ -65,12 +66,14 @@ export default async function DomainSettingsPage({
)} )}
</div> </div>
<div className="space-y-6"> <div className="space-y-6">
{build != "oss" && env.flags.usePangolinDns ? (
<DomainInfoCard <DomainInfoCard
failed={domain.failed} failed={domain.failed}
verified={domain.verified} verified={domain.verified}
type={domain.type} type={domain.type}
errorMessage={domain.errorMessage} errorMessage={domain.errorMessage}
/> />
) : null}
<DNSRecordsTable records={dnsRecords} type={domain.type} /> <DNSRecordsTable records={dnsRecords} type={domain.type} />

View File

@@ -60,17 +60,17 @@ export default async function ClientResourcesPage(
id: siteResource.siteResourceId, id: siteResource.siteResourceId,
name: siteResource.name, name: siteResource.name,
orgId: params.orgId, orgId: params.orgId,
siteName: siteResource.siteName, siteNames: siteResource.siteNames,
siteAddress: siteResource.siteAddress || null, siteAddresses: siteResource.siteAddresses || null,
mode: siteResource.mode || ("port" as any), mode: siteResource.mode || ("port" as any),
// protocol: siteResource.protocol, // protocol: siteResource.protocol,
// proxyPort: siteResource.proxyPort, // proxyPort: siteResource.proxyPort,
siteId: siteResource.siteId, siteIds: siteResource.siteIds,
destination: siteResource.destination, destination: siteResource.destination,
// destinationPort: siteResource.destinationPort, // destinationPort: siteResource.destinationPort,
alias: siteResource.alias || null, alias: siteResource.alias || null,
aliasAddress: siteResource.aliasAddress || null, aliasAddress: siteResource.aliasAddress || null,
siteNiceId: siteResource.siteNiceId, siteNiceIds: siteResource.siteNiceIds,
niceId: siteResource.niceId, niceId: siteResource.niceId,
tcpPortRangeString: siteResource.tcpPortRangeString || null, tcpPortRangeString: siteResource.tcpPortRangeString || null,
udpPortRangeString: siteResource.udpPortRangeString || null, udpPortRangeString: siteResource.udpPortRangeString || null,

View File

@@ -678,6 +678,7 @@ function ProxyResourceTargetsForm({
getPaginationRowModel: getPaginationRowModel(), getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(), getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(), getFilteredRowModel: getFilteredRowModel(),
getRowId: (row) => String(row.targetId),
state: { state: {
pagination: { pagination: {
pageIndex: 0, pageIndex: 0,

View File

@@ -999,6 +999,7 @@ export default function Page() {
getPaginationRowModel: getPaginationRowModel(), getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(), getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(), getFilteredRowModel: getFilteredRowModel(),
getRowId: (row) => String(row.targetId),
state: { state: {
pagination: { pagination: {
pageIndex: 0, pageIndex: 0,

View File

@@ -21,6 +21,7 @@ import {
ArrowUp10Icon, ArrowUp10Icon,
ArrowUpDown, ArrowUpDown,
ArrowUpRight, ArrowUpRight,
ChevronDown,
ChevronsUpDownIcon, ChevronsUpDownIcon,
MoreHorizontal MoreHorizontal
} from "lucide-react"; } from "lucide-react";
@@ -43,14 +44,14 @@ export type InternalResourceRow = {
id: number; id: number;
name: string; name: string;
orgId: string; orgId: string;
siteName: string; siteNames: string[];
siteAddress: string | null; siteAddresses: (string | null)[];
siteIds: number[];
siteNiceIds: string[];
// mode: "host" | "cidr" | "port"; // mode: "host" | "cidr" | "port";
mode: "host" | "cidr"; mode: "host" | "cidr";
// protocol: string | null; // protocol: string | null;
// proxyPort: number | null; // proxyPort: number | null;
siteId: number;
siteNiceId: string;
destination: string; destination: string;
// destinationPort: number | null; // destinationPort: number | null;
alias: string | null; alias: string | null;
@@ -136,6 +137,60 @@ export default function ClientResourcesTable({
} }
}; };
function SiteCell({ resourceRow }: { resourceRow: InternalResourceRow }) {
const { siteNames, siteNiceIds, orgId } = resourceRow;
if (!siteNames || siteNames.length === 0) {
return <span>-</span>;
}
if (siteNames.length === 1) {
return (
<Link
href={`/${orgId}/settings/sites/${siteNiceIds[0]}`}
>
<Button variant="outline">
{siteNames[0]}
<ArrowUpRight className="ml-2 h-4 w-4" />
</Button>
</Link>
);
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="flex items-center gap-2"
>
<span>
{siteNames.length} {t("sites")}
</span>
<ChevronDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{siteNames.map((siteName, idx) => (
<DropdownMenuItem
key={siteNiceIds[idx]}
asChild
>
<Link
href={`/${orgId}/settings/sites/${siteNiceIds[idx]}`}
className="flex items-center gap-2 cursor-pointer"
>
{siteName}
<ArrowUpRight className="h-3 w-3" />
</Link>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
const internalColumns: ExtendedColumnDef<InternalResourceRow>[] = [ const internalColumns: ExtendedColumnDef<InternalResourceRow>[] = [
{ {
accessorKey: "name", accessorKey: "name",
@@ -185,21 +240,11 @@ export default function ClientResourcesTable({
} }
}, },
{ {
accessorKey: "siteName", accessorKey: "siteNames",
friendlyName: t("site"), friendlyName: t("site"),
header: () => <span className="p-3">{t("site")}</span>, header: () => <span className="p-3">{t("site")}</span>,
cell: ({ row }) => { cell: ({ row }) => {
const resourceRow = row.original; return <SiteCell resourceRow={row.original} />;
return (
<Link
href={`/${resourceRow.orgId}/settings/sites/${resourceRow.siteNiceId}`}
>
<Button variant="outline">
{resourceRow.siteName}
<ArrowUpRight className="ml-2 h-4 w-4" />
</Button>
</Link>
);
} }
}, },
{ {
@@ -399,7 +444,7 @@ export default function ClientResourcesTable({
onConfirm={async () => onConfirm={async () =>
deleteInternalResource( deleteInternalResource(
selectedInternalResource!.id, selectedInternalResource!.id,
selectedInternalResource!.siteId selectedInternalResource!.siteIds[0]
) )
} }
string={selectedInternalResource.name} string={selectedInternalResource.name}
@@ -433,7 +478,11 @@ export default function ClientResourcesTable({
<EditInternalResourceDialog <EditInternalResourceDialog
open={isEditDialogOpen} open={isEditDialogOpen}
setOpen={setIsEditDialogOpen} setOpen={setIsEditDialogOpen}
resource={editingResource} resource={{
...editingResource,
siteName: editingResource.siteNames[0] ?? "",
siteId: editingResource.siteIds[0]
}}
orgId={orgId} orgId={orgId}
sites={sites} sites={sites}
onSuccess={() => { onSuccess={() => {

View File

@@ -154,7 +154,7 @@ export default function CreateDomainForm({
const punycodePreview = useMemo(() => { const punycodePreview = useMemo(() => {
if (!baseDomain) return ""; if (!baseDomain) return "";
const punycode = toPunycode(baseDomain); const punycode = toPunycode(baseDomain.toLowerCase());
return punycode !== baseDomain.toLowerCase() ? punycode : ""; return punycode !== baseDomain.toLowerCase() ? punycode : "";
}, [baseDomain]); }, [baseDomain]);
@@ -239,6 +239,7 @@ export default function CreateDomainForm({
className="space-y-4" className="space-y-4"
id="create-domain-form" id="create-domain-form"
> >
{build != "oss" && env.flags.usePangolinDns ? (
<FormField <FormField
control={form.control} control={form.control}
name="type" name="type"
@@ -254,6 +255,8 @@ export default function CreateDomainForm({
</FormItem> </FormItem>
)} )}
/> />
) : null}
<FormField <FormField
control={form.control} control={form.control}
name="baseDomain" name="baseDomain"

View File

@@ -319,6 +319,7 @@ export default function DeviceLoginForm({
<div className="flex justify-center"> <div className="flex justify-center">
<InputOTP <InputOTP
maxLength={9} maxLength={9}
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
{...field} {...field}
value={field.value value={field.value
.replace(/-/g, "") .replace(/-/g, "")

View File

@@ -2,6 +2,7 @@
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { import {
Command, Command,
CommandEmpty, CommandEmpty,
@@ -40,9 +41,12 @@ import {
Check, Check,
CheckCircle2, CheckCircle2,
ChevronsUpDown, ChevronsUpDown,
KeyRound,
Zap Zap
} from "lucide-react"; } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { usePaidStatus } from "@/hooks/usePaidStatus";
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
import { toUnicode } from "punycode"; import { toUnicode } from "punycode";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
@@ -95,6 +99,7 @@ export default function DomainPicker({
const { env } = useEnvContext(); const { env } = useEnvContext();
const api = createApiClient({ env }); const api = createApiClient({ env });
const t = useTranslations(); const t = useTranslations();
const { hasSaasSubscription } = usePaidStatus();
const { data = [], isLoading: loadingDomains } = useQuery( const { data = [], isLoading: loadingDomains } = useQuery(
orgQueries.domains({ orgId }) orgQueries.domains({ orgId })
@@ -509,7 +514,9 @@ export default function DomainPicker({
<span className="truncate"> <span className="truncate">
{selectedBaseDomain.domain} {selectedBaseDomain.domain}
</span> </span>
{selectedBaseDomain.verified && ( {selectedBaseDomain.verified &&
selectedBaseDomain.domainType !==
"wildcard" && (
<CheckCircle2 className="h-3 w-3 text-green-500 shrink-0" /> <CheckCircle2 className="h-3 w-3 text-green-500 shrink-0" />
)} )}
</div> </div>
@@ -574,6 +581,13 @@ export default function DomainPicker({
} }
</span> </span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{orgDomain.type ===
"wildcard"
? t(
"domainPickerManual"
)
: (
<>
{orgDomain.type.toUpperCase()}{" "} {orgDomain.type.toUpperCase()}{" "}
{" "} {" "}
{orgDomain.verified {orgDomain.verified
@@ -583,6 +597,8 @@ export default function DomainPicker({
: t( : t(
"domainPickerUnverified" "domainPickerUnverified"
)} )}
</>
)}
</span> </span>
</div> </div>
<Check <Check
@@ -680,6 +696,23 @@ export default function DomainPicker({
</div> </div>
</div> </div>
{build === "saas" &&
!hasSaasSubscription(
tierMatrix[TierFeature.DomainNamespaces]
) &&
!hideFreeDomain && (
<Card className="mt-3 border-black-500/30 bg-linear-to-br from-black-500/10 via-background to-background overflow-hidden">
<CardContent className="py-3 px-4">
<div className="flex items-center gap-2.5 text-sm text-muted-foreground">
<KeyRound className="size-4 shrink-0 text-black-500" />
<span>
{t("domainPickerFreeDomainsPaidFeature")}
</span>
</div>
</CardContent>
</Card>
)}
{/*showProvidedDomainSearch && build === "saas" && ( {/*showProvidedDomainSearch && build === "saas" && (
<Alert> <Alert>
<AlertCircle className="h-4 w-4" /> <AlertCircle className="h-4 w-4" />

View File

@@ -39,7 +39,11 @@ export default function InviteStatusCard({
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [type, setType] = 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"); >("rejected");
useEffect(() => { useEffect(() => {
@@ -90,12 +94,12 @@ export default function InviteStatusCard({
if (!user && type === "user_does_not_exist") { if (!user && type === "user_does_not_exist") {
const redirectUrl = email 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}`; : `/auth/signup?redirect=/invite?token=${tokenParam}`;
router.push(redirectUrl); router.push(redirectUrl);
} else if (!user && type === "not_logged_in") { } else if (!user && type === "not_logged_in") {
const redirectUrl = email 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}`; : `/auth/login?redirect=/invite?token=${tokenParam}`;
router.push(redirectUrl); router.push(redirectUrl);
} else { } else {
@@ -109,7 +113,7 @@ export default function InviteStatusCard({
async function goToLogin() { async function goToLogin() {
await api.post("/auth/logout", {}); await api.post("/auth/logout", {});
const redirectUrl = email 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}`; : `/auth/login?redirect=/invite?token=${tokenParam}`;
router.push(redirectUrl); router.push(redirectUrl);
} }
@@ -117,7 +121,7 @@ export default function InviteStatusCard({
async function goToSignup() { async function goToSignup() {
await api.post("/auth/logout", {}); await api.post("/auth/logout", {});
const redirectUrl = email 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}`; : `/auth/signup?redirect=/invite?token=${tokenParam}`;
router.push(redirectUrl); router.push(redirectUrl);
} }
@@ -157,7 +161,9 @@ export default function InviteStatusCard({
Cannot Accept Invite Cannot Accept Invite
</p> </p>
<p className="text-center text-sm"> <p className="text-center text-sm">
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.
</p> </p>
</div> </div>
); );

View File

@@ -333,7 +333,8 @@ export default function PendingSitesTable({
"jupiter", "jupiter",
"saturn", "saturn",
"uranus", "uranus",
"neptune" "neptune",
"pluto"
].includes(originalRow.exitNodeName.toLowerCase()); ].includes(originalRow.exitNodeName.toLowerCase());
if (isCloudNode) { if (isCloudNode) {

View File

@@ -342,7 +342,8 @@ export default function SitesTable({
"jupiter", "jupiter",
"saturn", "saturn",
"uranus", "uranus",
"neptune" "neptune",
"pluto"
].includes(originalRow.exitNodeName.toLowerCase()); ].includes(originalRow.exitNodeName.toLowerCase());
if (isCloudNode) { if (isCloudNode) {

View File

@@ -164,7 +164,7 @@ const countryClass = cn(
const highlightedCountryClass = cn( const highlightedCountryClass = cn(
sharedCountryClass, sharedCountryClass,
"stroke-2", "stroke-[3]",
"fill-[#f4f4f5]", "fill-[#f4f4f5]",
"stroke-[#f36117]", "stroke-[#f36117]",
"dark:fill-[#3f3f46]" "dark:fill-[#3f3f46]"
@@ -194,11 +194,20 @@ function drawInteractiveCountries(
const path = setupProjetionPath(); const path = setupProjetionPath();
const data = parseWorldTopoJsonToGeoJsonFeatures(); const data = parseWorldTopoJsonToGeoJsonFeatures();
const svg = d3.select(element); 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) .data(data)
.enter() .enter()
.append("path") .append("path")
.attr("data-country-path", "true")
.attr("class", countryClass) .attr("class", countryClass)
.attr("d", path as never) .attr("d", path as never)
@@ -209,9 +218,10 @@ function drawInteractiveCountries(
y, y,
hoveredCountryAlpha3Code: country.properties.a3 hoveredCountryAlpha3Code: country.properties.a3
}); });
// brings country to front hoverPath
this.parentNode?.appendChild(this); .datum(country)
d3.select(this).attr("class", highlightedCountryClass); .attr("d", path(country) as string)
.style("display", null);
}) })
.on("mousemove", function (event) { .on("mousemove", function (event) {
@@ -221,7 +231,7 @@ function drawInteractiveCountries(
.on("mouseout", function () { .on("mouseout", function () {
setTooltip({ x: 0, y: 0, hoveredCountryAlpha3Code: null }); setTooltip({ x: 0, y: 0, hoveredCountryAlpha3Code: null });
d3.select(this).attr("class", countryClass); hoverPath.style("display", "none");
}); });
return svg; return svg;
@@ -257,7 +267,7 @@ function colorInCountriesWithValues(
const svg = d3.select(element); const svg = d3.select(element);
return svg return svg
.selectAll("path") .selectAll('path[data-country-path="true"]')
.style("fill", (countryPath) => { .style("fill", (countryPath) => {
const country = getCountryByCountryPath(countryPath); const country = getCountryByCountryPath(countryPath);
if (!country?.count) { if (!country?.count) {