mirror of
https://github.com/fosrl/pangolin.git
synced 2026-03-17 12:22:42 +00:00
Compare commits
13 Commits
1.16.2-s.8
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c2be50b19 | ||
|
|
e2db4c6246 | ||
|
|
c4839fee08 | ||
|
|
965b7026f0 | ||
|
|
e14e15fcbb | ||
|
|
4ca5acf158 | ||
|
|
ea41fcc566 | ||
|
|
5736c1d8ce | ||
|
|
d142366dd9 | ||
|
|
bab09dff95 | ||
|
|
23d3345ab9 | ||
|
|
09a64815d4 | ||
|
|
6d5f969798 |
@@ -515,6 +515,6 @@ authenticated.post(
|
||||
verifyOrgAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.signSshKey),
|
||||
logActionAudit(ActionsEnum.signSshKey),
|
||||
// logActionAudit(ActionsEnum.signSshKey), // it is handled inside of the function below so we can include more metadata
|
||||
ssh.signSshKey
|
||||
);
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
actionAuditLog,
|
||||
db,
|
||||
logsDb,
|
||||
newts,
|
||||
roles,
|
||||
roundTripMessageTracker,
|
||||
@@ -34,6 +36,7 @@ import { canUserAccessSiteResource } from "@server/auth/canUserAccessSiteResourc
|
||||
import { signPublicKey, getOrgCAKeys } from "@server/lib/sshCA";
|
||||
import config from "@server/lib/config";
|
||||
import { sendToClient } from "#private/routers/ws";
|
||||
import { ActionsEnum } from "@server/auth/actions";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
orgId: z.string().nonempty()
|
||||
@@ -446,6 +449,20 @@ export async function signSshKey(
|
||||
sshHost = resource.destination;
|
||||
}
|
||||
|
||||
await logsDb.insert(actionAuditLog).values({
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
orgId: orgId,
|
||||
actorType: "user",
|
||||
actor: req.user?.username ?? "",
|
||||
actorId: req.user?.userId ?? "",
|
||||
action: ActionsEnum.signSshKey,
|
||||
metadata: JSON.stringify({
|
||||
resourceId: resource.siteResourceId,
|
||||
resource: resource.name,
|
||||
siteId: resource.siteId,
|
||||
})
|
||||
});
|
||||
|
||||
return response<SignSshKeyResponse>(res, {
|
||||
data: {
|
||||
certificate: cert.certificate,
|
||||
|
||||
@@ -197,6 +197,12 @@ const connectedClients: Map<string, AuthenticatedWebSocket[]> = new Map();
|
||||
// Config version tracking map (local to this node, resets on server restart)
|
||||
const clientConfigVersions: Map<string, number> = new Map();
|
||||
|
||||
// Tracks the last Unix timestamp (seconds) at which a ping was flushed to the
|
||||
// DB for a given siteId. Resets on server restart which is fine – the first
|
||||
// ping after startup will always write, re-establishing the online state.
|
||||
const lastPingDbWrite: Map<number, number> = new Map();
|
||||
const PING_DB_WRITE_INTERVAL = 45; // seconds
|
||||
|
||||
// Recovery tracking
|
||||
let isRedisRecoveryInProgress = false;
|
||||
|
||||
@@ -855,12 +861,16 @@ const setupConnection = async (
|
||||
const newtClient = client as Newt;
|
||||
ws.on("ping", async () => {
|
||||
if (!newtClient.siteId) return;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const lastWrite = lastPingDbWrite.get(newtClient.siteId) ?? 0;
|
||||
if (now - lastWrite < PING_DB_WRITE_INTERVAL) return;
|
||||
lastPingDbWrite.set(newtClient.siteId, now);
|
||||
try {
|
||||
await db
|
||||
.update(sites)
|
||||
.set({
|
||||
online: true,
|
||||
lastPing: Math.floor(Date.now() / 1000)
|
||||
lastPing: now
|
||||
})
|
||||
.where(eq(sites.siteId, newtClient.siteId));
|
||||
} catch (error) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
247
server/routers/siteResource/batchAddClientToSiteResources.ts
Normal file
247
server/routers/siteResource/batchAddClientToSiteResources.ts
Normal 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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -15,4 +15,5 @@ export * from "./addUserToSiteResource";
|
||||
export * from "./removeUserFromSiteResource";
|
||||
export * from "./setSiteResourceClients";
|
||||
export * from "./addClientToSiteResource";
|
||||
export * from "./batchAddClientToSiteResources";
|
||||
export * from "./removeClientFromSiteResource";
|
||||
|
||||
Reference in New Issue
Block a user