diff --git a/server/lib/traefik/TraefikConfigManager.ts b/server/lib/traefik/TraefikConfigManager.ts index de9249291..4aed80e45 100644 --- a/server/lib/traefik/TraefikConfigManager.ts +++ b/server/lib/traefik/TraefikConfigManager.ts @@ -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 diff --git a/server/routers/gerbil/receiveBandwidth.ts b/server/routers/gerbil/receiveBandwidth.ts index 098a1b558..b73ce986d 100644 --- a/server/routers/gerbil/receiveBandwidth.ts +++ b/server/routers/gerbil/receiveBandwidth.ts @@ -119,7 +119,7 @@ export async function flushSiteBandwidthToDb(): Promise { .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({ @@ -321,4 +321,4 @@ export const receiveBandwidth = async ( ) ); } -}; \ No newline at end of file +}; diff --git a/server/routers/integration.ts b/server/routers/integration.ts index a36a61e84..aea98bc63 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -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, diff --git a/server/routers/newt/buildConfiguration.ts b/server/routers/newt/buildConfiguration.ts index 579316336..c3a261f03 100644 --- a/server/routers/newt/buildConfiguration.ts +++ b/server/routers/newt/buildConfiguration.ts @@ -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") { diff --git a/server/routers/olm/handleOlmRegisterMessage.ts b/server/routers/olm/handleOlmRegisterMessage.ts index 065aeeaa6..5439245c4 100644 --- a/server/routers/olm/handleOlmRegisterMessage.ts +++ b/server/routers/olm/handleOlmRegisterMessage.ts @@ -227,7 +227,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { // Prepare an array to store site configurations logger.debug(`Found ${sitesCount} sites for client ${client.clientId}`); - let jitMode = true; + let jitMode = false; if (sitesCount > 250 && build == "saas") { // THIS IS THE MAX ON THE BUSINESS TIER // we have too many sites diff --git a/server/routers/siteResource/batchAddClientToSiteResources.ts b/server/routers/siteResource/batchAddClientToSiteResources.ts new file mode 100644 index 000000000..c3ad3859a --- /dev/null +++ b/server/routers/siteResource/batchAddClientToSiteResources.ts @@ -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 { + 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") + ); + } +} diff --git a/server/routers/siteResource/index.ts b/server/routers/siteResource/index.ts index 9494843bf..5c09d3883 100644 --- a/server/routers/siteResource/index.ts +++ b/server/routers/siteResource/index.ts @@ -15,4 +15,5 @@ export * from "./addUserToSiteResource"; export * from "./removeUserFromSiteResource"; export * from "./setSiteResourceClients"; export * from "./addClientToSiteResource"; +export * from "./batchAddClientToSiteResources"; export * from "./removeClientFromSiteResource"; diff --git a/server/routers/traefik/traefikConfigProvider.ts b/server/routers/traefik/traefikConfigProvider.ts index e8ac1621e..fa76190ff 100644 --- a/server/routers/traefik/traefikConfigProvider.ts +++ b/server/routers/traefik/traefikConfigProvider.ts @@ -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 diff --git a/src/components/MemberResourcesPortal.tsx b/src/components/MemberResourcesPortal.tsx index 93456b126..8ce721c88 100644 --- a/src/components/MemberResourcesPortal.tsx +++ b/src/components/MemberResourcesPortal.tsx @@ -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 = (
{/* Site Information */} @@ -828,6 +833,12 @@ export default function MemberResourcesPortal({
)} +
+ Destination: + + {siteResource.destination} + +
{siteResource.alias && (
Alias: @@ -836,14 +847,6 @@ export default function MemberResourcesPortal({
)} - {siteResource.aliasAddress && ( -
- Alias Address: - - {siteResource.aliasAddress} - -
- )}
Status: