Compare commits

...

17 Commits

Author SHA1 Message Date
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
Owen Schwartz
03288d2a60 Merge pull request #2667 from LaurenceJJones/feature/newt-ipv6-format-endpoint
fix(newt): Format ipv6 targets for go
2026-03-18 15:34:36 -07:00
miloschwartz
1169b68619 fix more info content on member page 2026-03-18 12:18:18 -07:00
Laurence
d3bfd67738 fix(newt): Format ipv6 targets for go
We added support https://github.com/fosrl/newt/releases/tag/1.10.3 for ipv6 targets from newt -> application, but we need to ensure that we handle if user provides a none bracketed ipv6 string
2026-03-18 13:26:38 +00:00
miloschwartz
d44292cf33 pass access token params to badger 2026-03-17 16:57:31 -07:00
miloschwartz
2c2be50b19 change route name 2026-03-16 20:02:57 -07:00
Owen Schwartz
e2db4c6246 Merge pull request #2662 from fosrl/batch-add-client-to-resources
batch add client to resources
2026-03-16 19:53:47 -07:00
miloschwartz
c4839fee08 Merge branch 'dev' into batch-add-client-to-resources 2026-03-16 17:58:37 -07:00
miloschwartz
965b7026f0 add batch endpoint 2026-03-16 17:58:20 -07:00
Owen
e14e15fcbb Revert: Also update lastPing for legacy 2026-03-16 17:47:06 -07:00
Owen Schwartz
4ca5acf158 Merge pull request #2660 from fosrl/dev
Also update lastPing for legacy
2026-03-16 17:13:10 -07:00
Owen
ea41fcc566 Also update lastPing for legacy 2026-03-16 17:12:37 -07:00
16 changed files with 748 additions and 257 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -312,7 +312,8 @@ export const ClientResourceSchema = z
.object({
name: z.string().min(1).max(255),
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(),
// proxyPort: z.int().positive().optional(),
// destinationPort: z.int().positive().optional(),

View File

@@ -286,14 +286,12 @@ export class TraefikConfigManager {
// Check non-wildcard certs for expiry (within 45 days to match
// the server-side renewal window in certificate-service)
for (const domain of domainsNeedingCerts) {
const localState =
this.lastLocalCertificateState.get(domain);
const localState = this.lastLocalCertificateState.get(domain);
if (localState?.expiresAt) {
const nowInSeconds = Math.floor(Date.now() / 1000);
const secondsUntilExpiry =
localState.expiresAt - nowInSeconds;
const daysUntilExpiry =
secondsUntilExpiry / (60 * 60 * 24);
const daysUntilExpiry = secondsUntilExpiry / (60 * 60 * 24);
if (daysUntilExpiry < 45) {
logger.info(
`Fetching certificates due to upcoming expiry for ${domain} (${Math.round(daysUntilExpiry)} days remaining)`
@@ -306,18 +304,11 @@ export class TraefikConfigManager {
// Also check wildcard certificates for expiry. These are not
// included in domainsNeedingCerts since their subdomains are
// filtered out, so we must check them separately.
for (const [certDomain, state] of this
.lastLocalCertificateState) {
if (
state.exists &&
state.wildcard &&
state.expiresAt
) {
for (const [certDomain, state] of this.lastLocalCertificateState) {
if (state.exists && state.wildcard && state.expiresAt) {
const nowInSeconds = Math.floor(Date.now() / 1000);
const secondsUntilExpiry =
state.expiresAt - nowInSeconds;
const daysUntilExpiry =
secondsUntilExpiry / (60 * 60 * 24);
const secondsUntilExpiry = state.expiresAt - nowInSeconds;
const daysUntilExpiry = secondsUntilExpiry / (60 * 60 * 24);
if (daysUntilExpiry < 45) {
logger.info(
`Fetching certificates due to upcoming expiry for wildcard cert ${certDomain} (${Math.round(daysUntilExpiry)} days remaining)`
@@ -405,14 +396,8 @@ export class TraefikConfigManager {
// their subdomains were filtered out above.
for (const [certDomain, state] of this
.lastLocalCertificateState) {
if (
state.exists &&
state.wildcard &&
state.expiresAt
) {
const nowInSeconds = Math.floor(
Date.now() / 1000
);
if (state.exists && state.wildcard && state.expiresAt) {
const nowInSeconds = Math.floor(Date.now() / 1000);
const secondsUntilExpiry =
state.expiresAt - nowInSeconds;
const daysUntilExpiry =
@@ -572,11 +557,18 @@ export class TraefikConfigManager {
config.getRawConfig().server
.session_cookie_name,
// deprecated
accessTokenQueryParam:
config.getRawConfig().server
.resource_access_token_param,
accessTokenIdHeader:
config.getRawConfig().server
.resource_access_token_headers.id,
accessTokenHeader:
config.getRawConfig().server
.resource_access_token_headers.token,
resourceSessionRequestParam:
config.getRawConfig().server
.resource_session_request_param

View File

@@ -119,7 +119,7 @@ export async function flushSiteBandwidthToDb(): Promise<void> {
.set({
megabytesOut: sql`COALESCE(${sites.megabytesOut}, 0) + ${bytesIn}`,
megabytesIn: sql`COALESCE(${sites.megabytesIn}, 0) + ${bytesOut}`,
lastBandwidthUpdate: currentTime
lastBandwidthUpdate: currentTime,
})
.where(eq(sites.pubKey, publicKey))
.returning({

View File

@@ -309,6 +309,14 @@ authenticated.post(
siteResource.removeClientFromSiteResource
);
authenticated.post(
"/client/:clientId/site-resources",
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.setResourceUsers),
logActionAudit(ActionsEnum.setResourceUsers),
siteResource.batchAddClientToSiteResources
);
authenticated.put(
"/org/:orgId/resource",
verifyApiKeyOrgAccess,

View File

@@ -14,7 +14,11 @@ import logger from "@server/logger";
import { initPeerAddHandshake, updatePeer } from "../olm/peers";
import { eq, and } from "drizzle-orm";
import config from "@server/lib/config";
import { generateSubnetProxyTargets, SubnetProxyTarget } from "@server/lib/ip";
import {
formatEndpoint,
generateSubnetProxyTargets,
SubnetProxyTarget
} from "@server/lib/ip";
export async function buildClientConfigurationForNewtClient(
site: Site,
@@ -219,8 +223,8 @@ export async function buildTargetConfigurationForNewtClient(siteId: number) {
return acc;
}
// Format target into string
const formattedTarget = `${target.internalPort}:${target.ip}:${target.port}`;
// Format target into string (handles IPv6 bracketing)
const formattedTarget = `${target.internalPort}:${formatEndpoint(target.ip, target.port)}`;
// Add to the appropriate protocol array
if (target.protocol === "tcp") {

View File

@@ -0,0 +1,247 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import {
db,
clients,
clientSiteResources,
siteResources,
apiKeyOrg
} from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { eq, and, inArray } from "drizzle-orm";
import { OpenAPITags, registry } from "@server/openApi";
import {
rebuildClientAssociationsFromClient,
rebuildClientAssociationsFromSiteResource
} from "@server/lib/rebuildClientAssociations";
const batchAddClientToSiteResourcesParamsSchema = z
.object({
clientId: z.string().transform(Number).pipe(z.number().int().positive())
})
.strict();
const batchAddClientToSiteResourcesBodySchema = z
.object({
siteResourceIds: z
.array(z.number().int().positive())
.min(1, "At least one siteResourceId is required")
})
.strict();
registry.registerPath({
method: "post",
path: "/client/{clientId}/site-resources",
description: "Add a machine client to multiple site resources at once.",
tags: [OpenAPITags.Client],
request: {
params: batchAddClientToSiteResourcesParamsSchema,
body: {
content: {
"application/json": {
schema: batchAddClientToSiteResourcesBodySchema
}
}
}
},
responses: {}
});
export async function batchAddClientToSiteResources(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const apiKey = req.apiKey;
if (!apiKey) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated")
);
}
const parsedParams =
batchAddClientToSiteResourcesParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedBody = batchAddClientToSiteResourcesBodySchema.safeParse(
req.body
);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { clientId } = parsedParams.data;
const { siteResourceIds } = parsedBody.data;
const uniqueSiteResourceIds = [...new Set(siteResourceIds)];
const batchSiteResources = await db
.select()
.from(siteResources)
.where(
inArray(siteResources.siteResourceId, uniqueSiteResourceIds)
);
if (batchSiteResources.length !== uniqueSiteResourceIds.length) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"One or more site resources not found"
)
);
}
if (!apiKey.isRoot) {
const orgIds = [
...new Set(batchSiteResources.map((sr) => sr.orgId))
];
if (orgIds.length > 1) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"All site resources must belong to the same organization"
)
);
}
const orgId = orgIds[0];
const [apiKeyOrgRow] = await db
.select()
.from(apiKeyOrg)
.where(
and(
eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId),
eq(apiKeyOrg.orgId, orgId)
)
)
.limit(1);
if (!apiKeyOrgRow) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Key does not have access to the organization of the specified site resources"
)
);
}
const [clientInOrg] = await db
.select()
.from(clients)
.where(
and(
eq(clients.clientId, clientId),
eq(clients.orgId, orgId)
)
)
.limit(1);
if (!clientInOrg) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Key does not have access to the specified client"
)
);
}
}
const [client] = await db
.select()
.from(clients)
.where(eq(clients.clientId, clientId))
.limit(1);
if (!client) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Client not found")
);
}
if (client.userId !== null) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"This endpoint only supports machine (non-user) clients; the specified client is associated with a user"
)
);
}
const existingEntries = await db
.select({
siteResourceId: clientSiteResources.siteResourceId
})
.from(clientSiteResources)
.where(
and(
eq(clientSiteResources.clientId, clientId),
inArray(
clientSiteResources.siteResourceId,
batchSiteResources.map((sr) => sr.siteResourceId)
)
)
);
const existingSiteResourceIds = new Set(
existingEntries.map((e) => e.siteResourceId)
);
const siteResourcesToAdd = batchSiteResources.filter(
(sr) => !existingSiteResourceIds.has(sr.siteResourceId)
);
if (siteResourcesToAdd.length === 0) {
return next(
createHttpError(
HttpCode.CONFLICT,
"Client is already assigned to all specified site resources"
)
);
}
await db.transaction(async (trx) => {
for (const siteResource of siteResourcesToAdd) {
await trx.insert(clientSiteResources).values({
clientId,
siteResourceId: siteResource.siteResourceId
});
}
await rebuildClientAssociationsFromClient(client, trx);
});
return response(res, {
data: {
addedCount: siteResourcesToAdd.length,
skippedCount:
batchSiteResources.length - siteResourcesToAdd.length,
siteResourceIds: siteResourcesToAdd.map(
(sr) => sr.siteResourceId
)
},
success: true,
error: false,
message: `Client added to ${siteResourcesToAdd.length} site resource(s) successfully`,
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

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

View File

@@ -15,4 +15,5 @@ export * from "./addUserToSiteResource";
export * from "./removeUserFromSiteResource";
export * from "./setSiteResourceClients";
export * from "./addClientToSiteResource";
export * from "./batchAddClientToSiteResources";
export * from "./removeClientFromSiteResource";

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

View File

@@ -8,7 +8,9 @@ import {
orgs,
roles,
roleSiteResources,
siteNetworks,
sites,
networks,
Transaction,
userSiteResources
} from "@server/db";
@@ -16,7 +18,7 @@ import { siteResources, SiteResource } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import { eq, and, ne } from "drizzle-orm";
import { eq, and, ne, inArray } from "drizzle-orm";
import { fromError } from "zod-validation-error";
import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
@@ -42,7 +44,7 @@ const updateSiteResourceParamsSchema = z.strictObject({
const updateSiteResourceSchema = z
.strictObject({
name: z.string().min(1).max(255).optional(),
siteId: z.int(),
siteIds: z.array(z.int()),
// niceId: z.string().min(1).max(255).regex(/^[a-zA-Z0-9-]+$/, "niceId can only contain letters, numbers, and dashes").optional(),
// mode: z.enum(["host", "cidr", "port"]).optional(),
mode: z.enum(["host", "cidr"]).optional(),
@@ -166,7 +168,7 @@ export async function updateSiteResource(
const { siteResourceId } = parsedParams.data;
const {
name,
siteId, // because it can change
siteIds, // because it can change
mode,
destination,
alias,
@@ -181,16 +183,6 @@ export async function updateSiteResource(
authDaemonMode
} = parsedBody.data;
const [site] = await db
.select()
.from(sites)
.where(eq(sites.siteId, siteId))
.limit(1);
if (!site) {
return next(createHttpError(HttpCode.NOT_FOUND, "Site not found"));
}
// Check if site resource exists
const [existingSiteResource] = await db
.select()
@@ -230,6 +222,24 @@ export async function updateSiteResource(
);
}
// Verify the site exists and belongs to the org
const sitesToAssign = await db
.select()
.from(sites)
.where(
and(
inArray(sites.siteId, siteIds),
eq(sites.orgId, existingSiteResource.orgId)
)
)
.limit(1);
if (sitesToAssign.length !== siteIds.length) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Some site not found")
);
}
// Only check if destination is an IP address
const isIp = z
.union([z.ipv4(), z.ipv6()])
@@ -247,25 +257,24 @@ export async function updateSiteResource(
);
}
let existingSite = site;
let siteChanged = false;
if (existingSiteResource.siteId !== siteId) {
siteChanged = true;
// get the existing site
[existingSite] = await db
.select()
.from(sites)
.where(eq(sites.siteId, existingSiteResource.siteId))
.limit(1);
let sitesChanged = false;
const existingSiteIds = existingSiteResource.networkId
? await db
.select()
.from(siteNetworks)
.where(
eq(siteNetworks.networkId, existingSiteResource.networkId)
)
: [];
if (!existingSite) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Existing site not found"
)
);
}
const existingSiteIdSet = new Set(existingSiteIds.map((s) => s.siteId));
const newSiteIdSet = new Set(siteIds);
if (
existingSiteIdSet.size !== newSiteIdSet.size ||
![...existingSiteIdSet].every((id) => newSiteIdSet.has(id))
) {
sitesChanged = true;
}
// make sure the alias is unique within the org if provided
@@ -295,7 +304,7 @@ export async function updateSiteResource(
let updatedSiteResource: SiteResource | undefined;
await db.transaction(async (trx) => {
// if the site is changed we need to delete and recreate the resource to avoid complications with the rebuild function otherwise we can just update in place
if (siteChanged) {
if (sitesChanged) {
// delete the existing site resource
await trx
.delete(siteResources)
@@ -321,7 +330,8 @@ export async function updateSiteResource(
const sshPamSet =
isLicensedSshPam &&
(authDaemonPort !== undefined || authDaemonMode !== undefined)
(authDaemonPort !== undefined ||
authDaemonMode !== undefined)
? {
...(authDaemonPort !== undefined && {
authDaemonPort
@@ -335,7 +345,6 @@ export async function updateSiteResource(
.update(siteResources)
.set({
name: name,
siteId: siteId,
mode: mode,
destination: destination,
enabled: enabled,
@@ -423,7 +432,8 @@ export async function updateSiteResource(
// Update the site resource
const sshPamSet =
isLicensedSshPam &&
(authDaemonPort !== undefined || authDaemonMode !== undefined)
(authDaemonPort !== undefined ||
authDaemonMode !== undefined)
? {
...(authDaemonPort !== undefined && {
authDaemonPort
@@ -437,7 +447,6 @@ export async function updateSiteResource(
.update(siteResources)
.set({
name: name,
siteId: siteId,
mode: mode,
destination: destination,
enabled: enabled,
@@ -454,6 +463,22 @@ export async function updateSiteResource(
//////////////////// update the associations ////////////////////
// delete the site - site resources associations
await trx
.delete(siteNetworks)
.where(
eq(siteNetworks.networkId, updatedSiteResource.networkId!)
// TODO: HERE WE FORCE THE NETWORK TO BE DEFINED BUT THE NETWORK CAN GET DELETED and we need to handle that
);
for (const siteId of siteIds) {
await trx.insert(siteNetworks).values({
siteId: siteId,
networkId: updatedSiteResource.networkId!
// TODO: HERE WE FORCE THE NETWORK TO BE DEFINED BUT THE NETWORK CAN GET DELETED and we need to handle that
});
}
await trx
.delete(clientSiteResources)
.where(
@@ -524,13 +549,16 @@ export async function updateSiteResource(
}
logger.info(
`Updated site resource ${siteResourceId} for site ${siteId}`
`Updated site resource ${siteResourceId}`
);
await handleMessagingForUpdatedSiteResource(
existingSiteResource,
updatedSiteResource,
{ siteId: site.siteId, orgId: site.orgId },
siteIds.map((siteId) => ({
siteId,
orgId: existingSiteResource.orgId
})),
trx
);
}
@@ -557,7 +585,7 @@ export async function updateSiteResource(
export async function handleMessagingForUpdatedSiteResource(
existingSiteResource: SiteResource | undefined,
updatedSiteResource: SiteResource,
site: { siteId: number; orgId: string },
sites: { siteId: number; orgId: string }[],
trx: Transaction
) {
logger.debug(
@@ -594,101 +622,117 @@ export async function handleMessagingForUpdatedSiteResource(
// if the existingSiteResource is undefined (new resource) we don't need to do anything here, the rebuild above handled it all
if (destinationChanged || aliasChanged || portRangesChanged) {
const [newt] = await trx
.select()
.from(newts)
.where(eq(newts.siteId, site.siteId))
.limit(1);
if (!newt) {
throw new Error(
"Newt not found for site during site resource update"
);
}
// Only update targets on newt if destination changed
if (destinationChanged || portRangesChanged) {
const oldTargets = generateSubnetProxyTargets(
existingSiteResource,
mergedAllClients
);
const newTargets = generateSubnetProxyTargets(
updatedSiteResource,
mergedAllClients
);
await updateTargets(newt.newtId, {
oldTargets: oldTargets,
newTargets: newTargets
}, newt.version);
}
const olmJobs: Promise<void>[] = [];
for (const client of mergedAllClients) {
// does this client have access to another resource on this site that has the same destination still? if so we dont want to remove it from their olm yet
// todo: optimize this query if needed
const oldDestinationStillInUseSites = await trx
for (const site of sites) {
const [newt] = await trx
.select()
.from(siteResources)
.innerJoin(
clientSiteResourcesAssociationsCache,
eq(
clientSiteResourcesAssociationsCache.siteResourceId,
siteResources.siteResourceId
)
)
.where(
and(
eq(
clientSiteResourcesAssociationsCache.clientId,
client.clientId
),
eq(siteResources.siteId, site.siteId),
eq(
siteResources.destination,
existingSiteResource.destination
),
ne(
siteResources.siteResourceId,
existingSiteResource.siteResourceId
)
)
.from(newts)
.where(eq(newts.siteId, site.siteId))
.limit(1);
if (!newt) {
throw new Error(
"Newt not found for site during site resource update"
);
}
// Only update targets on newt if destination changed
if (destinationChanged || portRangesChanged) {
const oldTargets = generateSubnetProxyTargets(
existingSiteResource,
mergedAllClients
);
const newTargets = generateSubnetProxyTargets(
updatedSiteResource,
mergedAllClients
);
const oldDestinationStillInUseByASite =
oldDestinationStillInUseSites.length > 0;
await updateTargets(
newt.newtId,
{
oldTargets: oldTargets,
newTargets: newTargets
},
newt.version
);
}
// we also need to update the remote subnets on the olms for each client that has access to this site
olmJobs.push(
updatePeerData(
client.clientId,
updatedSiteResource.siteId,
destinationChanged
? {
oldRemoteSubnets: !oldDestinationStillInUseByASite
? generateRemoteSubnets([
existingSiteResource
])
: [],
newRemoteSubnets: generateRemoteSubnets([
updatedSiteResource
])
}
: undefined,
aliasChanged
? {
oldAliases: generateAliasConfig([
existingSiteResource
]),
newAliases: generateAliasConfig([
updatedSiteResource
])
}
: undefined
)
);
const olmJobs: Promise<void>[] = [];
for (const client of mergedAllClients) {
// does this client have access to another resource on this site that has the same destination still? if so we dont want to remove it from their olm yet
// todo: optimize this query if needed
const oldDestinationStillInUseSites = await trx
.select()
.from(siteResources)
.innerJoin(
clientSiteResourcesAssociationsCache,
eq(
clientSiteResourcesAssociationsCache.siteResourceId,
siteResources.siteResourceId
)
)
.innerJoin(
siteNetworks,
eq(
siteNetworks.networkId,
siteResources.networkId
// TODO: HERE WE FORCE THE NETWORK TO BE DEFINED BUT THE NETWORK CAN GET DELETED and we need to handle that
)
)
.where(
and(
eq(
clientSiteResourcesAssociationsCache.clientId,
client.clientId
),
eq(siteNetworks.siteId, site.siteId),
eq(
siteResources.destination,
existingSiteResource.destination
),
ne(
siteResources.siteResourceId,
existingSiteResource.siteResourceId
)
)
);
const oldDestinationStillInUseByASite =
oldDestinationStillInUseSites.length > 0;
// we also need to update the remote subnets on the olms for each client that has access to this site
olmJobs.push(
updatePeerData(
client.clientId,
site.siteId,
destinationChanged
? {
oldRemoteSubnets:
!oldDestinationStillInUseByASite
? generateRemoteSubnets([
existingSiteResource
])
: [],
newRemoteSubnets: generateRemoteSubnets([
updatedSiteResource
])
}
: undefined,
aliasChanged
? {
oldAliases: generateAliasConfig([
existingSiteResource
]),
newAliases: generateAliasConfig([
updatedSiteResource
])
}
: undefined
)
);
}
await Promise.all(olmJobs);
}
await Promise.all(olmJobs);
}
}

View File

@@ -39,11 +39,18 @@ export async function traefikConfigProvider(
userSessionCookieName:
config.getRawConfig().server.session_cookie_name,
// deprecated
accessTokenQueryParam:
config.getRawConfig().server
.resource_access_token_param,
accessTokenIdHeader:
config.getRawConfig().server
.resource_access_token_headers.id,
accessTokenHeader:
config.getRawConfig().server
.resource_access_token_headers.token,
resourceSessionRequestParam:
config.getRawConfig().server
.resource_session_request_param

View File

@@ -129,6 +129,11 @@ const ResourceInfo = ({ resource }: { resource: Resource }) => {
resource.pincode ||
resource.whitelist;
const hasAnyInfo =
Boolean(resource.siteName) || Boolean(hasAuthMethods) || !resource.enabled;
if (!hasAnyInfo) return null;
const infoContent = (
<div className="flex flex-col gap-3">
{/* Site Information */}
@@ -828,6 +833,12 @@ export default function MemberResourcesPortal({
</span>
</div>
)}
<div>
<span className="font-medium">Destination:</span>
<span className="ml-2 text-muted-foreground">
{siteResource.destination}
</span>
</div>
{siteResource.alias && (
<div>
<span className="font-medium">Alias:</span>
@@ -836,14 +847,6 @@ export default function MemberResourcesPortal({
</span>
</div>
)}
{siteResource.aliasAddress && (
<div>
<span className="font-medium">Alias Address:</span>
<span className="ml-2 text-muted-foreground">
{siteResource.aliasAddress}
</span>
</div>
)}
<div>
<span className="font-medium">Status:</span>
<span className={`ml-2 ${siteResource.enabled ? 'text-green-600' : 'text-red-600'}`}>