mirror of
https://github.com/fosrl/pangolin.git
synced 2026-01-28 22:00:51 +00:00
586 lines
18 KiB
TypeScript
586 lines
18 KiB
TypeScript
/*
|
|
* 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<typeof bodySchema>;
|
|
|
|
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<any> {
|
|
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<QuickStartResponse>(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}`
|
|
};
|
|
}
|
|
};
|