diff --git a/server/routers/external.ts b/server/routers/external.ts index 45ab58bba..c32a7a8e9 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -102,6 +102,13 @@ authenticated.put( logActionAudit(ActionsEnum.createSite), site.createSite ); + +authenticated.put( + "/org/:orgId/newt/provisioning-key", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.createSite), + newt.createNewtProvisioningKey +); authenticated.get( "/org/:orgId/sites", verifyOrgAccess, @@ -1202,6 +1209,22 @@ authRouter.post( }), newt.getNewtToken ); + +authRouter.post( + "/newt/register", + rateLimit({ + windowMs: 15 * 60 * 1000, + max: 30, + keyGenerator: (req) => + `newtRegister:${req.body.provisioningKey?.split(".")[0] || ipKeyGenerator(req.ip || "")}`, + handler: (req, res, next) => { + const message = `You can only register a newt ${30} times every ${15} minutes. Please try again later.`; + return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); + }, + store: createStore() + }), + newt.registerNewt +); authRouter.post( "/olm/get-token", rateLimit({ diff --git a/server/routers/newt/createNewtProvisioningKey.ts b/server/routers/newt/createNewtProvisioningKey.ts new file mode 100644 index 000000000..2af4d166d --- /dev/null +++ b/server/routers/newt/createNewtProvisioningKey.ts @@ -0,0 +1,108 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { newtProvisioningKeys, orgs } 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 { eq } from "drizzle-orm"; +import { fromError } from "zod-validation-error"; +import { + generateId, + generateIdFromEntropySize +} from "@server/auth/sessions/app"; +import { hashPassword } from "@server/auth/password"; +import moment from "moment"; + +const paramsSchema = z.object({ + orgId: z.string().nonempty() +}); + +const bodySchema = z.object({ + expiresAt: z.number().int().positive().optional() // optional Unix timestamp (ms) +}); + +export type CreateNewtProvisioningKeyBody = z.infer; + +export type CreateNewtProvisioningKeyResponse = { + provisioningKeyId: string; + provisioningKey: string; // returned only once: "id.secret" + lastChars: string; + createdAt: string; + expiresAt: number | null; +}; + +export async function createNewtProvisioningKey( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + const { expiresAt } = parsedBody.data; + + // Verify org exists + const [org] = await db.select().from(orgs).where(eq(orgs.orgId, orgId)); + if (!org) { + return next( + createHttpError(HttpCode.NOT_FOUND, `Organization with ID ${orgId} not found`) + ); + } + + const provisioningKeyId = generateId(15); + const secret = generateIdFromEntropySize(25); + const keyHash = await hashPassword(secret); + const lastChars = secret.slice(-4); + const createdAt = moment().toISOString(); + const provisioningKey = `${provisioningKeyId}.${secret}`; + + await db.insert(newtProvisioningKeys).values({ + provisioningKeyId, + orgId, + keyHash, + lastChars, + createdAt, + expiresAt: expiresAt ?? null + }); + + return response(res, { + data: { + provisioningKeyId, + provisioningKey, + lastChars, + createdAt, + expiresAt: expiresAt ?? null + }, + success: true, + error: false, + message: "Provisioning key created successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/newt/index.ts b/server/routers/newt/index.ts index 63d1e1068..76207e8d4 100644 --- a/server/routers/newt/index.ts +++ b/server/routers/newt/index.ts @@ -9,3 +9,5 @@ export * from "./handleApplyBlueprintMessage"; export * from "./handleNewtPingMessage"; export * from "./handleNewtDisconnectingMessage"; export * from "./handleConnectionLogMessage"; +export * from "./registerNewt"; +export * from "./createNewtProvisioningKey"; diff --git a/server/routers/newt/registerNewt.ts b/server/routers/newt/registerNewt.ts new file mode 100644 index 000000000..f301b452a --- /dev/null +++ b/server/routers/newt/registerNewt.ts @@ -0,0 +1,240 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { + newtProvisioningKeys, + newts, + orgs, + roles, + roleSites, + sites +} 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 { eq, and } from "drizzle-orm"; +import { fromError } from "zod-validation-error"; +import { verifyPassword, hashPassword } from "@server/auth/password"; +import { + generateId, + generateIdFromEntropySize +} from "@server/auth/sessions/app"; +import { getUniqueSiteName } from "@server/db/names"; +import moment from "moment"; +import { build } from "@server/build"; +import { usageService } from "@server/lib/billing/usageService"; +import { FeatureId } from "@server/lib/billing"; + +const bodySchema = z.object({ + provisioningKey: z.string().nonempty() +}); + +export type RegisterNewtBody = z.infer; + +export type RegisterNewtResponse = { + newtId: string; + secret: string; +}; + +export async function registerNewt( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { provisioningKey } = parsedBody.data; + + // Keys are in the format "id.secret" + const dotIndex = provisioningKey.indexOf("."); + if (dotIndex === -1) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid provisioning key format" + ) + ); + } + + const provisioningKeyId = provisioningKey.substring(0, dotIndex); + const provisioningKeySecret = provisioningKey.substring(dotIndex + 1); + + // Look up the provisioning key by ID + const [keyRecord] = await db + .select() + .from(newtProvisioningKeys) + .where( + eq(newtProvisioningKeys.provisioningKeyId, provisioningKeyId) + ) + .limit(1); + + if (!keyRecord) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Invalid provisioning key") + ); + } + + // Verify the secret + const validSecret = await verifyPassword( + provisioningKeySecret, + keyRecord.keyHash + ); + if (!validSecret) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Invalid provisioning key") + ); + } + + // Check if key has already been used + if (keyRecord.siteId !== null) { + return next( + createHttpError( + HttpCode.CONFLICT, + "Provisioning key has already been used" + ) + ); + } + + // Check expiry + if (keyRecord.expiresAt !== null && keyRecord.expiresAt < Date.now()) { + return next( + createHttpError(HttpCode.GONE, "Provisioning key has expired") + ); + } + + const { orgId } = keyRecord; + + // Verify the org exists + const [org] = await db + .select() + .from(orgs) + .where(eq(orgs.orgId, orgId)); + if (!org) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Organization not found") + ); + } + + // SaaS billing check + if (build == "saas") { + const usage = await usageService.getUsage(orgId, FeatureId.SITES); + if (!usage) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "No usage data found for this organization" + ) + ); + } + const rejectSites = await usageService.checkLimitSet( + orgId, + FeatureId.SITES, + { + ...usage, + instantaneousValue: (usage.instantaneousValue || 0) + 1 + } + ); + if (rejectSites) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Site limit exceeded. Please upgrade your plan." + ) + ); + } + } + + const niceId = await getUniqueSiteName(orgId); + const newtId = generateId(15); + const newtSecret = generateIdFromEntropySize(25); + const secretHash = await hashPassword(newtSecret); + + let newSiteId: number | undefined; + + await db.transaction(async (trx) => { + // Create the site (type "newt", name = niceId) + const [newSite] = await trx + .insert(sites) + .values({ + orgId, + name: niceId, + niceId, + type: "newt", + dockerSocketEnabled: true + }) + .returning(); + + newSiteId = newSite.siteId; + + // Grant admin role access to the new site + const [adminRole] = await trx + .select() + .from(roles) + .where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId))) + .limit(1); + + if (!adminRole) { + throw new Error(`Admin role not found for org ${orgId}`); + } + + await trx.insert(roleSites).values({ + roleId: adminRole.roleId, + siteId: newSite.siteId + }); + + // Create the newt for this site + await trx.insert(newts).values({ + newtId, + secretHash, + siteId: newSite.siteId, + dateCreated: moment().toISOString() + }); + + // Mark the provisioning key as used + await trx + .update(newtProvisioningKeys) + .set({ siteId: newSite.siteId }) + .where( + eq( + newtProvisioningKeys.provisioningKeyId, + provisioningKeyId + ) + ); + + await usageService.add(orgId, FeatureId.SITES, 1, trx); + }); + + logger.info( + `Provisioned new site (ID: ${newSiteId}) and newt (ID: ${newtId}) for org ${orgId} via provisioning key ${provisioningKeyId}` + ); + + return response(res, { + data: { + newtId, + secret: newtSecret + }, + success: true, + error: false, + message: "Newt registered successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "An error occurred" + ) + ); + } +} \ No newline at end of file