/* * This file is part of a proprietary work. * * Copyright (c) 2025 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. * You may not use this file except in compliance with the License. * Unauthorized use, copying, modification, or distribution is strictly prohibited. * * This file is not licensed under the AGPLv3. */ import { NextFunction, Request, Response } from "express"; import { account, db, domainNamespaces, domains, exitNodes, newts, newtSessions, orgs, passwordResetTokens, Resource, resourcePassword, resourcePincode, resources, resourceWhitelist, roleResources, roles, roleSites, sites, targetHealthCheck, targets, userResources, userSites } from "@server/db"; import HttpCode from "@server/types/HttpCode"; import { z } from "zod"; import { users } from "@server/db"; import { fromError } from "zod-validation-error"; import createHttpError from "http-errors"; import response from "@server/lib/response"; import { SqliteError } from "better-sqlite3"; import { eq, and, sql } from "drizzle-orm"; import moment from "moment"; import { generateId } from "@server/auth/sessions/app"; import config from "@server/lib/config"; import logger from "@server/logger"; import { hashPassword } from "@server/auth/password"; import { UserType } from "@server/types/UserTypes"; import { createUserAccountOrg } from "@server/lib/createUserAccountOrg"; import { sendEmail } from "@server/emails"; import WelcomeQuickStart from "@server/emails/templates/WelcomeQuickStart"; import { alphabet, generateRandomString } from "oslo/crypto"; import { createDate, TimeSpan } from "oslo"; import { getUniqueResourceName, getUniqueSiteName } from "@server/db/names"; import { pickPort } from "@server/routers/target/helpers"; import { addTargets } from "@server/routers/newt/targets"; import { isTargetValid } from "@server/lib/validators"; import { listExitNodes } from "#private/lib/exitNodes"; const bodySchema = z.object({ email: z.email().toLowerCase(), ip: z.string().refine(isTargetValid), method: z.enum(["http", "https"]), port: z.int().min(1).max(65535), pincode: z .string() .regex(/^\d{6}$/) .optional(), password: z.string().min(4).max(100).optional(), enableWhitelist: z.boolean().optional().default(true), animalId: z.string() // This is actually the secret key for the backend }); export type QuickStartBody = z.infer; export type QuickStartResponse = { newtId: string; newtSecret: string; resourceUrl: string; completeSignUpLink: string; }; const DEMO_UBO_KEY = "b460293f-347c-4b30-837d-4e06a04d5a22"; export async function quickStart( req: Request, res: Response, next: NextFunction ): Promise { const parsedBody = bodySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { email, ip, method, port, pincode, password, enableWhitelist, animalId } = parsedBody.data; try { const tokenValidation = validateTokenOnApi(animalId); if (!tokenValidation.isValid) { logger.warn( `Quick start failed for ${email} token ${animalId}: ${tokenValidation.message}` ); return next( createHttpError( HttpCode.BAD_REQUEST, "Invalid or expired token" ) ); } if (animalId === DEMO_UBO_KEY) { if (email !== "mehrdad@getubo.com") { return next( createHttpError( HttpCode.BAD_REQUEST, "Invalid email for demo Ubo key" ) ); } const [existing] = await db .select() .from(users) .where( and( eq(users.email, email), eq(users.type, UserType.Internal) ) ); if (existing) { // delete the user if it already exists await db.delete(users).where(eq(users.userId, existing.userId)); const orgId = `org_${existing.userId}`; await db.delete(orgs).where(eq(orgs.orgId, orgId)); } } const tempPassword = generateId(15); const passwordHash = await hashPassword(tempPassword); const userId = generateId(15); // TODO: see if that user already exists? // Create the sandbox user const existing = await db .select() .from(users) .where( and(eq(users.email, email), eq(users.type, UserType.Internal)) ); if (existing && existing.length > 0) { return next( createHttpError( HttpCode.BAD_REQUEST, "A user with that email address already exists" ) ); } let newtId: string; let secret: string; let fullDomain: string; let resource: Resource; let completeSignUpLink: string; await db.transaction(async (trx) => { await trx.insert(users).values({ userId: userId, type: UserType.Internal, username: email, email: email, passwordHash, dateCreated: moment().toISOString() }); // create user"s account await trx.insert(account).values({ userId }); }); const { success, error, org } = await createUserAccountOrg( userId, email ); if (!success) { if (error) { throw new Error(error); } throw new Error("Failed to create user account and organization"); } if (!org) { throw new Error("Failed to create user account and organization"); } const orgId = org.orgId; await db.transaction(async (trx) => { const token = generateRandomString( 8, alphabet("0-9", "A-Z", "a-z") ); await trx .delete(passwordResetTokens) .where(eq(passwordResetTokens.userId, userId)); const tokenHash = await hashPassword(token); await trx.insert(passwordResetTokens).values({ userId: userId, email: email, tokenHash, expiresAt: createDate(new TimeSpan(7, "d")).getTime() }); // // Create the sandbox newt // const newClientAddress = await getNextAvailableClientSubnet(orgId); // if (!newClientAddress) { // throw new Error("No available subnet found"); // } // const clientAddress = newClientAddress.split("/")[0]; newtId = generateId(15); secret = generateId(48); // Create the sandbox site const siteNiceId = await getUniqueSiteName(orgId); const siteName = `First Site`; // pick a random exit node const exitNodesList = await listExitNodes(orgId); // select a random exit node const randomExitNode = exitNodesList[Math.floor(Math.random() * exitNodesList.length)]; if (!randomExitNode) { throw new Error("No exit nodes available"); } const [newSite] = await trx .insert(sites) .values({ orgId, exitNodeId: randomExitNode.exitNodeId, name: siteName, niceId: siteNiceId, // address: clientAddress, type: "newt", dockerSocketEnabled: true }) .returning(); const siteId = newSite.siteId; const adminRole = await trx .select() .from(roles) .where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId))) .limit(1); if (adminRole.length === 0) { throw new Error("Admin role not found"); } await trx.insert(roleSites).values({ roleId: adminRole[0].roleId, siteId: newSite.siteId }); if (req.user && req.userOrgRoleId != adminRole[0].roleId) { // make sure the user can access the site await trx.insert(userSites).values({ userId: req.user?.userId!, siteId: newSite.siteId }); } // add the peer to the exit node const secretHash = await hashPassword(secret!); await trx.insert(newts).values({ newtId: newtId!, secretHash, siteId: newSite.siteId, dateCreated: moment().toISOString() }); const [randomNamespace] = await trx .select() .from(domainNamespaces) .orderBy(sql`RANDOM()`) .limit(1); if (!randomNamespace) { throw new Error("No domain namespace available"); } const [randomNamespaceDomain] = await trx .select() .from(domains) .where(eq(domains.domainId, randomNamespace.domainId)) .limit(1); if (!randomNamespaceDomain) { throw new Error("No domain found for the namespace"); } const resourceNiceId = await getUniqueResourceName(orgId); // Create sandbox resource const subdomain = `${resourceNiceId}-${generateId(5)}`; fullDomain = `${subdomain}.${randomNamespaceDomain.baseDomain}`; const resourceName = `First Resource`; const newResource = await trx .insert(resources) .values({ niceId: resourceNiceId, fullDomain, domainId: randomNamespaceDomain.domainId, orgId, name: resourceName, subdomain, http: true, protocol: "tcp", ssl: true, sso: false, emailWhitelistEnabled: enableWhitelist }) .returning(); await trx.insert(roleResources).values({ roleId: adminRole[0].roleId, resourceId: newResource[0].resourceId }); if (req.user && req.userOrgRoleId != adminRole[0].roleId) { // make sure the user can access the resource await trx.insert(userResources).values({ userId: req.user?.userId!, resourceId: newResource[0].resourceId }); } resource = newResource[0]; // Create the sandbox target const { internalPort, targetIps } = await pickPort(siteId!, trx); if (!internalPort) { throw new Error("No available internal port"); } const newTarget = await trx .insert(targets) .values({ resourceId: resource.resourceId, siteId: siteId!, internalPort, ip, method, port, enabled: true }) .returning(); const newHealthcheck = await trx .insert(targetHealthCheck) .values({ targetId: newTarget[0].targetId, hcEnabled: false }) .returning(); // add the new target to the targetIps array targetIps.push(`${ip}/32`); const [newt] = await trx .select() .from(newts) .where(eq(newts.siteId, siteId!)) .limit(1); await addTargets( newt.newtId, newTarget, newHealthcheck, resource.protocol ); // Set resource pincode if provided if (pincode) { await trx .delete(resourcePincode) .where( eq(resourcePincode.resourceId, resource!.resourceId) ); const pincodeHash = await hashPassword(pincode); await trx.insert(resourcePincode).values({ resourceId: resource!.resourceId, pincodeHash, digitLength: 6 }); } // Set resource password if provided if (password) { await trx .delete(resourcePassword) .where( eq(resourcePassword.resourceId, resource!.resourceId) ); const passwordHash = await hashPassword(password); await trx.insert(resourcePassword).values({ resourceId: resource!.resourceId, passwordHash }); } // Set resource OTP if whitelist is enabled if (enableWhitelist) { await trx.insert(resourceWhitelist).values({ email, resourceId: resource!.resourceId }); } completeSignUpLink = `${config.getRawConfig().app.dashboard_url}/auth/reset-password?quickstart=true&email=${email}&token=${token}`; // Store token for email outside transaction await sendEmail( WelcomeQuickStart({ username: email, link: completeSignUpLink, fallbackLink: `${config.getRawConfig().app.dashboard_url}/auth/reset-password?quickstart=true&email=${email}`, resourceMethod: method, resourceHostname: ip, resourcePort: port, resourceUrl: `https://${fullDomain}`, cliCommand: `newt --id ${newtId} --secret ${secret}` }), { to: email, from: config.getNoReplyEmail(), subject: `Access your Pangolin dashboard and resources` } ); }); return response(res, { data: { newtId: newtId!, newtSecret: secret!, resourceUrl: `https://${fullDomain!}`, completeSignUpLink: completeSignUpLink! }, success: true, error: false, message: "Quick start completed successfully", status: HttpCode.OK }); } catch (e) { if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_UNIQUE") { if (config.getRawConfig().app.log_failed_attempts) { logger.info( `Account already exists with that email. Email: ${email}. IP: ${req.ip}.` ); } return next( createHttpError( HttpCode.BAD_REQUEST, "A user with that email address already exists" ) ); } else { logger.error(e); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to do quick start" ) ); } } } const BACKEND_SECRET_KEY = "4f9b6000-5d1a-11f0-9de7-ff2cc032f501"; /** * Validates a token received from the frontend. * @param {string} token The validation token from the request. * @returns {{ isValid: boolean; message: string }} An object indicating if the token is valid. */ const validateTokenOnApi = ( token: string ): { isValid: boolean; message: string } => { if (token === DEMO_UBO_KEY) { // Special case for demo UBO key return { isValid: true, message: "Demo UBO key is valid." }; } if (!token) { return { isValid: false, message: "Error: No token provided." }; } try { // 1. Decode the base64 string const decodedB64 = atob(token); // 2. Reverse the character code manipulation const deobfuscated = decodedB64 .split("") .map((char) => String.fromCharCode(char.charCodeAt(0) - 5)) // Reverse the shift .join(""); // 3. Split the data to get the original secret and timestamp const parts = deobfuscated.split("|"); if (parts.length !== 2) { throw new Error("Invalid token format."); } const receivedKey = parts[0]; const tokenTimestamp = parseInt(parts[1], 10); // 4. Check if the secret key matches if (receivedKey !== BACKEND_SECRET_KEY) { return { isValid: false, message: "Invalid token: Key mismatch." }; } // 5. Check if the timestamp is recent (e.g., within 30 seconds) to prevent replay attacks const now = Date.now(); const timeDifference = now - tokenTimestamp; if (timeDifference > 30000) { // 30 seconds return { isValid: false, message: "Invalid token: Expired." }; } if (timeDifference < 0) { // Timestamp is in the future return { isValid: false, message: "Invalid token: Timestamp is in the future." }; } // If all checks pass, the token is valid return { isValid: true, message: "Token is valid!" }; } catch (error) { // This will catch errors from atob (if not valid base64) or other issues. return { isValid: false, message: `Error: ${(error as Error).message}` }; } };