Pull up downstream changes

This commit is contained in:
Owen 2025-07-13 21:57:24 -07:00
parent c679875273
commit 98a261e38c
No known key found for this signature in database
GPG key ID: 8271FDFFD9E0CCBD
108 changed files with 9799 additions and 2038 deletions

View file

@ -6,7 +6,8 @@ import logger from "@server/logger";
import {
errorHandlerMiddleware,
notFoundMiddleware,
rateLimitMiddleware
rateLimitMiddleware,
requestTimeoutMiddleware
} from "@server/middlewares";
import { authenticated, unauthenticated } from "@server/routers/external";
import { router as wsRouter, handleWSUpgrade } from "@server/routers/ws";
@ -19,6 +20,7 @@ const externalPort = config.getRawConfig().server.external_port;
export function createApiServer() {
const apiServer = express();
const prefix = `/api/v1`;
const trustProxy = config.getRawConfig().server.trust_proxy;
if (trustProxy) {
@ -54,6 +56,9 @@ export function createApiServer() {
apiServer.use(cookieParser());
apiServer.use(express.json());
// Add request timeout middleware
apiServer.use(requestTimeoutMiddleware(60000)); // 60 second timeout
if (!dev) {
apiServer.use(
rateLimitMiddleware({
@ -66,7 +71,6 @@ export function createApiServer() {
}
// API routes
const prefix = `/api/v1`;
apiServer.use(logIncomingMiddleware);
apiServer.use(prefix, unauthenticated);
apiServer.use(prefix, authenticated);

View file

@ -90,7 +90,10 @@ export enum ActionsEnum {
setApiKeyOrgs = "setApiKeyOrgs",
listApiKeyActions = "listApiKeyActions",
listApiKeys = "listApiKeys",
getApiKey = "getApiKey"
getApiKey = "getApiKey",
createOrgDomain = "createOrgDomain",
deleteOrgDomain = "deleteOrgDomain",
restartOrgDomain = "restartOrgDomain"
}
export async function checkUserActionPermission(

View file

@ -1,40 +0,0 @@
import { db } from '@server/db';
import { limitsTable } from '@server/db';
import { and, eq } from 'drizzle-orm';
import createHttpError from 'http-errors';
import HttpCode from '@server/types/HttpCode';
interface CheckLimitOptions {
orgId: string;
limitName: string;
currentValue: number;
increment?: number;
}
export async function checkOrgLimit({ orgId, limitName, currentValue, increment = 0 }: CheckLimitOptions): Promise<boolean> {
try {
const limit = await db.select()
.from(limitsTable)
.where(
and(
eq(limitsTable.orgId, orgId),
eq(limitsTable.name, limitName)
)
)
.limit(1);
if (limit.length === 0) {
throw createHttpError(HttpCode.NOT_FOUND, `Limit "${limitName}" not found for organization`);
}
const limitValue = limit[0].value;
// Check if the current value plus the increment is within the limit
return (currentValue + increment) <= limitValue;
} catch (error) {
if (error instanceof Error) {
throw createHttpError(HttpCode.INTERNAL_SERVER_ERROR, `Error checking limit: ${error.message}`);
}
throw createHttpError(HttpCode.INTERNAL_SERVER_ERROR, 'Unknown error occurred while checking limit');
}
}

View file

@ -59,7 +59,7 @@ export async function getUniqueExitNodeEndpointName(): Promise<string> {
export function generateName(): string {
return (
const name = (
names.descriptors[
Math.floor(Math.random() * names.descriptors.length)
] +
@ -68,4 +68,7 @@ export function generateName(): string {
)
.toLowerCase()
.replace(/\s/g, "-");
// clean out any non-alphanumeric characters except for dashes
return name.replace(/[^a-z0-9-]/g, "");
}

View file

@ -1,4 +1,5 @@
import { drizzle as DrizzlePostgres } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import { readConfigFile } from "@server/lib/readConfigFile";
import { withReplicas } from "drizzle-orm/pg-core";
@ -20,19 +21,31 @@ function createDb() {
);
}
const primary = DrizzlePostgres(connectionString);
// Create connection pools instead of individual connections
const primaryPool = new Pool({
connectionString,
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
const replicas = [];
if (!replicaConnections.length) {
replicas.push(primary);
replicas.push(DrizzlePostgres(primaryPool));
} else {
for (const conn of replicaConnections) {
const replica = DrizzlePostgres(conn.connection_string);
replicas.push(replica);
const replicaPool = new Pool({
connectionString: conn.connection_string,
max: 10,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
replicas.push(DrizzlePostgres(replicaPool));
}
}
return withReplicas(primary, replicas as any);
return withReplicas(DrizzlePostgres(primaryPool), replicas as any);
}
export const db = createDb();

View file

@ -1,2 +1,2 @@
export * from "./driver";
export * from "./schema";
export * from "./schema";

View file

@ -14,6 +14,9 @@ export const domains = pgTable("domains", {
baseDomain: varchar("baseDomain").notNull(),
configManaged: boolean("configManaged").notNull().default(false),
type: varchar("type"), // "ns", "cname", "a"
verified: boolean("verified").notNull().default(false),
failed: boolean("failed").notNull().default(false),
tries: integer("tries").notNull().default(0)
});
export const orgs = pgTable("orgs", {
@ -44,9 +47,9 @@ export const sites = pgTable("sites", {
}),
name: varchar("name").notNull(),
pubKey: varchar("pubKey"),
subnet: varchar("subnet").notNull(),
megabytesIn: real("bytesIn"),
megabytesOut: real("bytesOut"),
subnet: varchar("subnet"),
megabytesIn: real("bytesIn").default(0),
megabytesOut: real("bytesOut").default(0),
lastBandwidthUpdate: varchar("lastBandwidthUpdate"),
type: varchar("type").notNull(), // "newt" or "wireguard"
online: boolean("online").notNull().default(false),
@ -282,18 +285,6 @@ export const userResources = pgTable("userResources", {
.references(() => resources.resourceId, { onDelete: "cascade" })
});
export const limitsTable = pgTable("limits", {
limitId: serial("limitId").primaryKey(),
orgId: varchar("orgId")
.references(() => orgs.orgId, {
onDelete: "cascade"
})
.notNull(),
name: varchar("name").notNull(),
value: bigint("value", { mode: "number" }).notNull(),
description: varchar("description")
});
export const userInvites = pgTable("userInvites", {
inviteId: varchar("inviteId").primaryKey(),
orgId: varchar("orgId")
@ -520,7 +511,8 @@ export const clients = pgTable("clients", {
type: varchar("type").notNull(), // "olm"
online: boolean("online").notNull().default(false),
endpoint: varchar("endpoint"),
lastHolePunch: integer("lastHolePunch")
lastHolePunch: integer("lastHolePunch"),
maxConnections: integer("maxConnections")
});
export const clientSites = pgTable("clientSites", {
@ -590,7 +582,6 @@ export type RoleSite = InferSelectModel<typeof roleSites>;
export type UserSite = InferSelectModel<typeof userSites>;
export type RoleResource = InferSelectModel<typeof roleResources>;
export type UserResource = InferSelectModel<typeof userResources>;
export type Limit = InferSelectModel<typeof limitsTable>;
export type UserInvite = InferSelectModel<typeof userInvites>;
export type UserOrg = InferSelectModel<typeof userOrgs>;
export type ResourceSession = InferSelectModel<typeof resourceSessions>;
@ -613,3 +604,4 @@ export type Olm = InferSelectModel<typeof olms>;
export type OlmSession = InferSelectModel<typeof olmSessions>;
export type UserClient = InferSelectModel<typeof userClients>;
export type RoleClient = InferSelectModel<typeof roleClients>;
export type OrgDomains = InferSelectModel<typeof orgDomains>;

View file

@ -3,30 +3,25 @@ import logger from "@server/logger";
import config from "@server/lib/config";
class RedisManager {
private static instance: RedisManager;
public client: Redis | null = null;
private subscriber: Redis | null = null;
private publisher: Redis | null = null;
private isEnabled: boolean = false;
private isHealthy: boolean = true;
private lastHealthCheck: number = 0;
private healthCheckInterval: number = 30000; // 30 seconds
private subscribers: Map<
string,
Set<(channel: string, message: string) => void>
> = new Map();
private constructor() {
constructor() {
this.isEnabled = config.getRawConfig().flags?.enable_redis || false;
if (this.isEnabled) {
this.initializeClients();
}
}
public static getInstance(): RedisManager {
if (!RedisManager.instance) {
RedisManager.instance = new RedisManager();
}
return RedisManager.instance;
}
private getRedisConfig(): RedisOptions {
const redisConfig = config.getRawConfig().redis!;
const opts: RedisOptions = {
@ -34,38 +29,78 @@ class RedisManager {
port: redisConfig.port!,
password: redisConfig.password,
db: redisConfig.db,
tls: {
rejectUnauthorized:
redisConfig.tls?.reject_unauthorized || false
}
// tls: {
// rejectUnauthorized:
// redisConfig.tls?.reject_unauthorized || false
// }
};
return opts;
}
// Add reconnection logic in initializeClients
private initializeClients(): void {
const config = this.getRedisConfig();
try {
// Main client for general operations
this.client = new Redis(config);
this.client = new Redis({
...config,
enableReadyCheck: false,
maxRetriesPerRequest: 3,
keepAlive: 30000,
connectTimeout: 10000, // 10 seconds
commandTimeout: 5000, // 5 seconds
});
// Dedicated publisher client
this.publisher = new Redis(config);
this.publisher = new Redis({
...config,
enableReadyCheck: false,
maxRetriesPerRequest: 3,
keepAlive: 30000,
connectTimeout: 10000, // 10 seconds
commandTimeout: 5000, // 5 seconds
});
// Dedicated subscriber client
this.subscriber = new Redis(config);
this.subscriber = new Redis({
...config,
enableReadyCheck: false,
maxRetriesPerRequest: 3,
keepAlive: 30000,
connectTimeout: 10000, // 10 seconds
commandTimeout: 5000, // 5 seconds
});
// Set up error handlers
// Add reconnection handlers
this.client.on("error", (err) => {
logger.error("Redis client error:", err);
this.isHealthy = false;
});
this.client.on("reconnecting", () => {
logger.info("Redis client reconnecting...");
this.isHealthy = false;
});
this.client.on("ready", () => {
logger.info("Redis client ready");
this.isHealthy = true;
});
this.publisher.on("error", (err) => {
logger.error("Redis publisher error:", err);
this.isHealthy = false;
});
this.publisher.on("ready", () => {
logger.info("Redis publisher ready");
});
this.subscriber.on("error", (err) => {
logger.error("Redis subscriber error:", err);
this.isHealthy = false;
});
this.subscriber.on("ready", () => {
logger.info("Redis subscriber ready");
});
// Set up connection handlers
@ -102,18 +137,65 @@ class RedisManager {
);
logger.info("Redis clients initialized successfully");
// Start periodic health monitoring
this.startHealthMonitoring();
} catch (error) {
logger.error("Failed to initialize Redis clients:", error);
this.isEnabled = false;
}
}
public isRedisEnabled(): boolean {
return this.isEnabled && this.client !== null;
private startHealthMonitoring(): void {
if (!this.isEnabled) return;
// Check health every 30 seconds
setInterval(async () => {
try {
await this.checkRedisHealth();
} catch (error) {
logger.error("Error during Redis health monitoring:", error);
}
}, this.healthCheckInterval);
}
public getClient(): Redis | null {
return this.client;
public isRedisEnabled(): boolean {
return this.isEnabled && this.client !== null && this.isHealthy;
}
private async checkRedisHealth(): Promise<boolean> {
const now = Date.now();
// Only check health every 30 seconds
if (now - this.lastHealthCheck < this.healthCheckInterval) {
return this.isHealthy;
}
this.lastHealthCheck = now;
if (!this.client) {
this.isHealthy = false;
return false;
}
try {
await Promise.race([
this.client.ping(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Health check timeout')), 2000)
)
]);
this.isHealthy = true;
return true;
} catch (error) {
logger.error("Redis health check failed:", error);
this.isHealthy = false;
return false;
}
}
public getClient(): Redis {
return this.client!;
}
public async set(
@ -247,11 +329,25 @@ class RedisManager {
public async publish(channel: string, message: string): Promise<boolean> {
if (!this.isRedisEnabled() || !this.publisher) return false;
// Quick health check before attempting to publish
const isHealthy = await this.checkRedisHealth();
if (!isHealthy) {
logger.warn("Skipping Redis publish due to unhealthy connection");
return false;
}
try {
await this.publisher.publish(channel, message);
// Add timeout to prevent hanging
await Promise.race([
this.publisher.publish(channel, message),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Redis publish timeout')), 3000)
)
]);
return true;
} catch (error) {
logger.error("Redis PUBLISH error:", error);
this.isHealthy = false; // Mark as unhealthy on error
return false;
}
}
@ -267,13 +363,19 @@ class RedisManager {
if (!this.subscribers.has(channel)) {
this.subscribers.set(channel, new Set());
// Only subscribe to the channel if it's the first subscriber
await this.subscriber.subscribe(channel);
await Promise.race([
this.subscriber.subscribe(channel),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Redis subscribe timeout')), 5000)
)
]);
}
this.subscribers.get(channel)!.add(callback);
return true;
} catch (error) {
logger.error("Redis SUBSCRIBE error:", error);
this.isHealthy = false;
return false;
}
}
@ -330,5 +432,6 @@ class RedisManager {
}
}
export const redisManager = RedisManager.getInstance();
export const redisManager = new RedisManager();
export const redis = redisManager.getClient();
export default redisManager;

View file

@ -7,7 +7,7 @@ export const domains = sqliteTable("domains", {
configManaged: integer("configManaged", { mode: "boolean" })
.notNull()
.default(false),
type: text("type"), // "ns", "cname", "a"
type: text("type") // "ns", "cname", "a"
});
export const orgs = sqliteTable("orgs", {
@ -16,6 +16,15 @@ export const orgs = sqliteTable("orgs", {
subnet: text("subnet").notNull(),
});
export const userDomains = sqliteTable("userDomains", {
userId: text("userId")
.notNull()
.references(() => users.userId, { onDelete: "cascade" }),
domainId: text("domainId")
.notNull()
.references(() => domains.domainId, { onDelete: "cascade" })
});
export const orgDomains = sqliteTable("orgDomains", {
orgId: text("orgId")
.notNull()
@ -38,9 +47,9 @@ export const sites = sqliteTable("sites", {
}),
name: text("name").notNull(),
pubKey: text("pubKey"),
subnet: text("subnet").notNull(),
megabytesIn: integer("bytesIn"),
megabytesOut: integer("bytesOut"),
subnet: text("subnet"),
megabytesIn: integer("bytesIn").default(0),
megabytesOut: integer("bytesOut").default(0),
lastBandwidthUpdate: text("lastBandwidthUpdate"),
type: text("type").notNull(), // "newt" or "wireguard"
online: integer("online", { mode: "boolean" }).notNull().default(false),
@ -48,7 +57,7 @@ export const sites = sqliteTable("sites", {
// exit node stuff that is how to connect to the site when it has a wg server
address: text("address"), // this is the address of the wireguard interface in newt
endpoint: text("endpoint"), // this is how to reach gerbil externally - gets put into the wireguard config
publicKey: text("pubicKey"), // TODO: Fix typo in publicKey
publicKey: text("publicKey"), // TODO: Fix typo in publicKey
lastHolePunch: integer("lastHolePunch"),
listenPort: integer("listenPort"),
dockerSocketEnabled: integer("dockerSocketEnabled", { mode: "boolean" })
@ -626,13 +635,14 @@ export type ResourceAccessToken = InferSelectModel<typeof resourceAccessToken>;
export type ResourceWhitelist = InferSelectModel<typeof resourceWhitelist>;
export type VersionMigration = InferSelectModel<typeof versionMigrations>;
export type ResourceRule = InferSelectModel<typeof resourceRules>;
export type Domain = InferSelectModel<typeof domains>;
export type Client = InferSelectModel<typeof clients>;
export type ClientSite = InferSelectModel<typeof clientSites>;
export type RoleClient = InferSelectModel<typeof roleClients>;
export type UserClient = InferSelectModel<typeof userClients>;
export type Domain = InferSelectModel<typeof domains>;
export type SupporterKey = InferSelectModel<typeof supporterKey>;
export type Idp = InferSelectModel<typeof idp>;
export type ApiKey = InferSelectModel<typeof apiKeys>;
export type ApiKeyAction = InferSelectModel<typeof apiKeyActions>;
export type ApiKeyOrg = InferSelectModel<typeof apiKeyOrg>;
export type OrgDomains = InferSelectModel<typeof orgDomains>;

View file

@ -2,6 +2,7 @@ import { render } from "@react-email/render";
import { ReactElement } from "react";
import emailClient from "@server/emails";
import logger from "@server/logger";
import config from "@server/lib/config";
export async function sendEmail(
template: ReactElement,
@ -24,9 +25,11 @@ export async function sendEmail(
const emailHtml = await render(template);
const appName = "Fossorial - Pangolin";
await emailClient.sendMail({
from: {
name: opts.name || "Pangolin",
name: opts.name || appName,
address: opts.from,
},
to: opts.to,

View file

@ -1,11 +1,5 @@
import {
Body,
Head,
Html,
Preview,
Tailwind
} from "@react-email/components";
import * as React from "react";
import React from "react";
import { Body, Head, Html, Preview, Tailwind } from "@react-email/components";
import { themeColors } from "./lib/theme";
import {
EmailContainer,
@ -22,29 +16,29 @@ interface Props {
}
export const ConfirmPasswordReset = ({ email }: Props) => {
const previewText = `Your password has been reset`;
const previewText = `Your password has been successfully reset.`;
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind config={themeColors}>
<Body className="font-sans relative">
<Body className="font-sans bg-gray-50">
<EmailContainer>
<EmailLetterHead />
<EmailHeading>Password Reset Confirmation</EmailHeading>
{/* <EmailHeading>Password Successfully Reset</EmailHeading> */}
<EmailGreeting>Hi {email || "there"},</EmailGreeting>
<EmailGreeting>Hi there,</EmailGreeting>
<EmailText>
This email confirms that your password has just been
reset. If you made this change, no further action is
required.
Your password has been successfully reset. You can
now sign in to your account using your new password.
</EmailText>
<EmailText>
Thank you for keeping your account secure.
If you didn't make this change, please contact our
support team immediately to secure your account.
</EmailText>
<EmailFooter>

View file

@ -1,11 +1,5 @@
import {
Body,
Head,
Html,
Preview,
Tailwind
} from "@react-email/components";
import * as React from "react";
import React from "react";
import { Body, Head, Html, Preview, Tailwind } from "@react-email/components";
import { themeColors } from "./lib/theme";
import {
EmailContainer,
@ -18,6 +12,7 @@ import {
EmailText
} from "./components/Email";
import CopyCodeBox from "./components/CopyCodeBox";
import ButtonLink from "./components/ButtonLink";
interface Props {
email: string;
@ -26,37 +21,39 @@ interface Props {
}
export const ResetPasswordCode = ({ email, code, link }: Props) => {
const previewText = `Your password reset code is ${code}`;
const previewText = `Reset your password with code: ${code}`;
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind config={themeColors}>
<Body className="font-sans">
<Body className="font-sans bg-gray-50">
<EmailContainer>
<EmailLetterHead />
<EmailHeading>Password Reset Request</EmailHeading>
{/* <EmailHeading>Reset Your Password</EmailHeading> */}
<EmailGreeting>Hi {email || "there"},</EmailGreeting>
<EmailGreeting>Hi there,</EmailGreeting>
<EmailText>
Youve requested to reset your password. Please{" "}
<a href={link} className="text-primary">
click here
</a>{" "}
and follow the instructions to reset your password,
or manually enter the following code:
You've requested to reset your password. Click the
button below to reset your password, or use the
verification code provided if prompted.
</EmailText>
<EmailSection>
<ButtonLink href={link}>Reset Password</ButtonLink>
</EmailSection>
<EmailSection>
<CopyCodeBox text={code} />
</EmailSection>
<EmailText>
If you didnt request this, you can safely ignore
this email.
This reset code will expire in 2 hours. If you
didn't request a password reset, you can safely
ignore this email.
</EmailText>
<EmailFooter>

View file

@ -1,11 +1,5 @@
import {
Body,
Head,
Html,
Preview,
Tailwind
} from "@react-email/components";
import * as React from "react";
import React from "react";
import { Body, Head, Html, Preview, Tailwind } from "@react-email/components";
import {
EmailContainer,
EmailLetterHead,
@ -32,34 +26,40 @@ export const ResourceOTPCode = ({
orgName: organizationName,
otp
}: ResourceOTPCodeProps) => {
const previewText = `Your one-time password for ${resourceName} is ${otp}`;
const previewText = `Your access code for ${resourceName}: ${otp}`;
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind config={themeColors}>
<Body className="font-sans">
<Body className="font-sans bg-gray-50">
<EmailContainer>
<EmailLetterHead />
<EmailHeading>
Your One-Time Code for {resourceName}
</EmailHeading>
{/* <EmailHeading> */}
{/* Access Code for {resourceName} */}
{/* </EmailHeading> */}
<EmailGreeting>Hi {email || "there"},</EmailGreeting>
<EmailGreeting>Hi there,</EmailGreeting>
<EmailText>
Youve requested a one-time password to access{" "}
You've requested access to{" "}
<strong>{resourceName}</strong> in{" "}
<strong>{organizationName}</strong>. Use the code
below to complete your authentication:
<strong>{organizationName}</strong>. Use the
verification code below to complete your
authentication.
</EmailText>
<EmailSection>
<CopyCodeBox text={otp} />
</EmailSection>
<EmailText>
This code will expire in 15 minutes. If you didn't
request this code, please ignore this email.
</EmailText>
<EmailFooter>
<EmailSignature />
</EmailFooter>

View file

@ -1,11 +1,5 @@
import {
Body,
Head,
Html,
Preview,
Tailwind,
} from "@react-email/components";
import * as React from "react";
import React from "react";
import { Body, Head, Html, Preview, Tailwind } from "@react-email/components";
import { themeColors } from "./lib/theme";
import {
EmailContainer,
@ -41,35 +35,44 @@ export const SendInviteLink = ({
<Head />
<Preview>{previewText}</Preview>
<Tailwind config={themeColors}>
<Body className="font-sans">
<Body className="font-sans bg-gray-50">
<EmailContainer>
<EmailLetterHead />
<EmailHeading>Invited to Join {orgName}</EmailHeading>
{/* <EmailHeading> */}
{/* You're Invited to Join {orgName} */}
{/* </EmailHeading> */}
<EmailGreeting>Hi {email || "there"},</EmailGreeting>
<EmailGreeting>Hi there,</EmailGreeting>
<EmailText>
Youve been invited to join the organization{" "}
You've been invited to join{" "}
<strong>{orgName}</strong>
{inviterName ? ` by ${inviterName}.` : "."} Please
access the link below to accept the invite.
</EmailText>
<EmailText>
This invite will expire in{" "}
<strong>
{expiresInDays}{" "}
{expiresInDays === "1" ? "day" : "days"}.
</strong>
{inviterName ? ` by ${inviterName}` : ""}. Click the
button below to accept your invitation and get
started.
</EmailText>
<EmailSection>
<ButtonLink href={inviteLink}>
Accept Invite to {orgName}
Accept Invitation
</ButtonLink>
</EmailSection>
{/* <EmailText> */}
{/* If you're having trouble clicking the button, copy */}
{/* and paste the URL below into your web browser: */}
{/* <br /> */}
{/* <span className="break-all">{inviteLink}</span> */}
{/* </EmailText> */}
<EmailText>
This invite expires in {expiresInDays}{" "}
{expiresInDays === "1" ? "day" : "days"}. If the
link has expired, please contact the owner of the
organization to request a new invitation.
</EmailText>
<EmailFooter>
<EmailSignature />
</EmailFooter>

View file

@ -1,11 +1,5 @@
import {
Body,
Head,
Html,
Preview,
Tailwind
} from "@react-email/components";
import * as React from "react";
import React from "react";
import { Body, Head, Html, Preview, Tailwind } from "@react-email/components";
import { themeColors } from "./lib/theme";
import {
EmailContainer,
@ -23,44 +17,52 @@ interface Props {
}
export const TwoFactorAuthNotification = ({ email, enabled }: Props) => {
const previewText = `Two-Factor Authentication has been ${enabled ? "enabled" : "disabled"}`;
const previewText = `Two-Factor Authentication ${enabled ? "enabled" : "disabled"} for your account`;
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind config={themeColors}>
<Body className="font-sans">
<Body className="font-sans bg-gray-50">
<EmailContainer>
<EmailLetterHead />
<EmailHeading>
Two-Factor Authentication{" "}
{enabled ? "Enabled" : "Disabled"}
</EmailHeading>
{/* <EmailHeading> */}
{/* Security Update: 2FA{" "} */}
{/* {enabled ? "Enabled" : "Disabled"} */}
{/* </EmailHeading> */}
<EmailGreeting>Hi {email || "there"},</EmailGreeting>
<EmailGreeting>Hi there,</EmailGreeting>
<EmailText>
This email confirms that Two-Factor Authentication
has been successfully{" "}
{enabled ? "enabled" : "disabled"} on your account.
Two-factor authentication has been successfully{" "}
<strong>{enabled ? "enabled" : "disabled"}</strong>{" "}
on your account.
</EmailText>
{enabled ? (
<EmailText>
With Two-Factor Authentication enabled, your
account is now more secure. Please ensure you
keep your authentication method safe.
</EmailText>
<>
<EmailText>
Your account is now protected with an
additional layer of security. Keep your
authentication method safe and accessible.
</EmailText>
</>
) : (
<EmailText>
With Two-Factor Authentication disabled, your
account may be less secure. We recommend
enabling it to protect your account.
</EmailText>
<>
<EmailText>
We recommend re-enabling two-factor
authentication to keep your account secure.
</EmailText>
</>
)}
<EmailText>
If you didn't make this change, please contact our
support team immediately.
</EmailText>
<EmailFooter>
<EmailSignature />
</EmailFooter>

View file

@ -1,5 +1,5 @@
import React from "react";
import { Body, Head, Html, Preview, Tailwind } from "@react-email/components";
import * as React from "react";
import { themeColors } from "./lib/theme";
import {
EmailContainer,
@ -24,25 +24,24 @@ export const VerifyEmail = ({
verificationCode,
verifyLink
}: VerifyEmailProps) => {
const previewText = `Your verification code is ${verificationCode}`;
const previewText = `Verify your email with code: ${verificationCode}`;
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind config={themeColors}>
<Body className="font-sans">
<Body className="font-sans bg-gray-50">
<EmailContainer>
<EmailLetterHead />
<EmailHeading>Please Verify Your Email</EmailHeading>
{/* <EmailHeading>Verify Your Email Address</EmailHeading> */}
<EmailGreeting>Hi {username || "there"},</EmailGreeting>
<EmailGreeting>Hi there,</EmailGreeting>
<EmailText>
Youve requested to verify your email. Please use
the code below to complete the verification process
upon logging in.
Welcome! To complete your account setup, please
verify your email address using the code below.
</EmailText>
<EmailSection>
@ -50,7 +49,8 @@ export const VerifyEmail = ({
</EmailSection>
<EmailText>
If you didnt request this, you can safely ignore
This verification code will expire in 15 minutes. If
you didn't create an account, you can safely ignore
this email.
</EmailText>

View file

@ -0,0 +1,131 @@
import React from "react";
import { Body, Head, Html, Preview, Tailwind } from "@react-email/components";
import { themeColors } from "./lib/theme";
import {
EmailContainer,
EmailFooter,
EmailGreeting,
EmailHeading,
EmailLetterHead,
EmailSection,
EmailSignature,
EmailText,
EmailInfoSection
} from "./components/Email";
import ButtonLink from "./components/ButtonLink";
import CopyCodeBox from "./components/CopyCodeBox";
interface WelcomeQuickStartProps {
username?: string;
link: string;
fallbackLink: string;
resourceMethod: string;
resourceHostname: string;
resourcePort: string | number;
resourceUrl: string;
cliCommand: string;
}
export const WelcomeQuickStart = ({
username,
link,
fallbackLink,
resourceMethod,
resourceHostname,
resourcePort,
resourceUrl,
cliCommand
}: WelcomeQuickStartProps) => {
const previewText = "Welcome! Here's what to do next";
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind config={themeColors}>
<Body className="font-sans bg-gray-50">
<EmailContainer>
<EmailLetterHead />
<EmailGreeting>Hi there,</EmailGreeting>
<EmailText>
Thank you for trying out Pangolin! We're excited to
have you on board.
</EmailText>
<EmailText>
To continue to configure your site, resources, and
other features, complete your account setup to
access the full dashboard.
</EmailText>
<EmailSection>
<ButtonLink href={link}>
View Your Dashboard
</ButtonLink>
{/* <p className="text-sm text-gray-300 mt-2"> */}
{/* If the button above doesn't work, you can also */}
{/* use this{" "} */}
{/* <a href={fallbackLink} className="underline"> */}
{/* link */}
{/* </a> */}
{/* . */}
{/* </p> */}
</EmailSection>
<EmailSection>
<div className="mb-2 font-semibold text-gray-900 text-base text-left">
Connect your site using Newt
</div>
<div className="inline-block w-full">
<div className="bg-gray-50 border border-gray-200 rounded-lg px-6 py-4 mx-auto text-left">
<span className="text-sm font-mono text-gray-900 tracking-wider">
{cliCommand}
</span>
</div>
<p className="text-xs text-gray-500 mt-2">
To learn how to use Newt, including more
installation methods, visit the{" "}
<a
href="https://docs.fossorial.io"
className="underline"
>
docs
</a>
.
</p>
</div>
</EmailSection>
<EmailInfoSection
title="Your Demo Resource"
items={[
{ label: "Method", value: resourceMethod },
{ label: "Hostname", value: resourceHostname },
{ label: "Port", value: resourcePort },
{
label: "Resource URL",
value: (
<a
href={resourceUrl}
className="underline text-blue-600"
>
{resourceUrl}
</a>
)
}
]}
/>
<EmailFooter>
<EmailSignature />
</EmailFooter>
</EmailContainer>
</Body>
</Tailwind>
</Html>
);
};
export default WelcomeQuickStart;

View file

@ -12,7 +12,11 @@ export default function ButtonLink({
return (
<a
href={href}
className={`rounded-full bg-primary px-4 py-2 text-center font-semibold text-white text-xl no-underline inline-block ${className}`}
className={`inline-block bg-primary hover:bg-primary/90 text-white font-semibold px-8 py-3 rounded-lg text-center no-underline transition-colors ${className}`}
style={{
backgroundColor: "#F97316",
textDecoration: "none"
}}
>
{children}
</a>

View file

@ -2,10 +2,15 @@ import React from "react";
export default function CopyCodeBox({ text }: { text: string }) {
return (
<div className="text-center rounded-lg bg-neutral-100 p-2">
<span className="text-2xl font-mono text-neutral-600 tracking-wide">
{text}
</span>
<div className="inline-block">
<div className="bg-gray-50 border border-gray-200 rounded-lg px-6 py-4 mx-auto">
<span className="text-2xl font-mono text-gray-900 tracking-wider font-semibold">
{text}
</span>
</div>
<p className="text-xs text-gray-500 mt-2">
Copy and paste this code when prompted
</p>
</div>
);
}

View file

@ -1,47 +1,26 @@
import { Container } from "@react-email/components";
import React from "react";
import { Container, Img } from "@react-email/components";
// EmailContainer: Wraps the entire email layout
export function EmailContainer({ children }: { children: React.ReactNode }) {
return (
<Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg">
<Container className="bg-white border border-solid border-gray-200 max-w-lg mx-auto my-8 rounded-lg overflow-hidden shadow-sm">
{children}
</Container>
);
}
// EmailLetterHead: For branding or logo at the top
// EmailLetterHead: For branding with logo on dark background
export function EmailLetterHead() {
return (
<div className="mb-4">
<table
role="presentation"
width="100%"
style={{
marginBottom: "24px"
}}
>
<tr>
<td
style={{
fontSize: "14px",
fontWeight: "bold",
color: "#F97317"
}}
>
Pangolin
</td>
<td
style={{
fontSize: "14px",
textAlign: "right",
color: "#6B7280"
}}
>
{new Date().getFullYear()}
</td>
</tr>
</table>
<div className="px-6 pt-8 pb-2 text-center">
<Img
src="https://fossorial-public-assets.s3.us-east-1.amazonaws.com/word_mark_black.png"
alt="Fossorial"
width="120"
height="auto"
className="mx-auto"
/>
</div>
);
}
@ -49,14 +28,22 @@ export function EmailLetterHead() {
// EmailHeading: For the primary message or headline
export function EmailHeading({ children }: { children: React.ReactNode }) {
return (
<h1 className="text-2xl font-semibold text-gray-800 text-center">
{children}
</h1>
<div className="px-6 pt-4 pb-1">
<h1 className="text-2xl font-semibold text-gray-900 text-center leading-tight">
{children}
</h1>
</div>
);
}
export function EmailGreeting({ children }: { children: React.ReactNode }) {
return <p className="text-base text-gray-700 my-4">{children}</p>;
return (
<div className="px-6">
<p className="text-base text-gray-700 leading-relaxed">
{children}
</p>
</div>
);
}
// EmailText: For general text content
@ -68,9 +55,13 @@ export function EmailText({
className?: string;
}) {
return (
<p className={`my-2 text-base text-gray-700 ${className}`}>
{children}
</p>
<div className="px-6">
<p
className={`text-base text-gray-700 leading-relaxed ${className}`}
>
{children}
</p>
</div>
);
}
@ -82,20 +73,70 @@ export function EmailSection({
children: React.ReactNode;
className?: string;
}) {
return <div className={`text-center my-6 ${className}`}>{children}</div>;
return (
<div className={`px-6 py-6 text-center ${className}`}>{children}</div>
);
}
// EmailFooter: For closing or signature
export function EmailFooter({ children }: { children: React.ReactNode }) {
return <div className="text-sm text-gray-500 mt-6">{children}</div>;
return (
<div className="px-6 py-6 border-t border-gray-100 bg-gray-50">
{children}
<p className="text-xs text-gray-400 mt-4">
For any questions or support, please contact us at:
<br />
support@fossorial.io
</p>
<p className="text-xs text-gray-300 text-center mt-4">
&copy; {new Date().getFullYear()} Fossorial, Inc. All rights
reserved.
</p>
</div>
);
}
export function EmailSignature() {
return (
<p>
Best regards,
<br />
Fossorial
</p>
<div className="text-sm text-gray-600">
<p className="mb-2">
Best regards,
<br />
<strong>The Fossorial Team</strong>
</p>
</div>
);
}
// EmailInfoSection: For structured key-value info (like resource details)
export function EmailInfoSection({
title,
items
}: {
title?: string;
items: { label: string; value: React.ReactNode }[];
}) {
return (
<div className="px-6 py-4">
{title && (
<div className="mb-2 font-semibold text-gray-900 text-base">
{title}
</div>
)}
<table className="w-full text-sm text-left">
<tbody>
{items.map((item, idx) => (
<tr key={idx}>
<td className="pr-4 py-1 text-gray-600 align-top whitespace-nowrap">
{item.label}
</td>
<td className="py-1 text-gray-900 break-all">
{item.value}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View file

@ -1,3 +1,5 @@
import React from "react";
export const themeColors = {
theme: {
extend: {

View file

@ -113,7 +113,9 @@ export class Config {
private async checkKeyStatus() {
const licenseStatus = await license.check();
if (!licenseStatus.isHostLicensed) {
if (
!licenseStatus.isHostLicensed
) {
this.checkSupporterKey();
}
}

View file

@ -14,7 +14,7 @@ type IPVersion = 4 | 6;
* Detects IP version from address string
*/
function detectIpVersion(ip: string): IPVersion {
return ip.includes(':') ? 6 : 4;
return ip.includes(":") ? 6 : 4;
}
/**
@ -24,34 +24,34 @@ function ipToBigInt(ip: string): bigint {
const version = detectIpVersion(ip);
if (version === 4) {
return ip.split('.')
.reduce((acc, octet) => {
const num = parseInt(octet);
if (isNaN(num) || num < 0 || num > 255) {
throw new Error(`Invalid IPv4 octet: ${octet}`);
}
return BigInt.asUintN(64, (acc << BigInt(8)) + BigInt(num));
}, BigInt(0));
return ip.split(".").reduce((acc, octet) => {
const num = parseInt(octet);
if (isNaN(num) || num < 0 || num > 255) {
throw new Error(`Invalid IPv4 octet: ${octet}`);
}
return BigInt.asUintN(64, (acc << BigInt(8)) + BigInt(num));
}, BigInt(0));
} else {
// Handle IPv6
// Expand :: notation
let fullAddress = ip;
if (ip.includes('::')) {
const parts = ip.split('::');
if (parts.length > 2) throw new Error('Invalid IPv6 address: multiple :: found');
const missing = 8 - (parts[0].split(':').length + parts[1].split(':').length);
const padding = Array(missing).fill('0').join(':');
if (ip.includes("::")) {
const parts = ip.split("::");
if (parts.length > 2)
throw new Error("Invalid IPv6 address: multiple :: found");
const missing =
8 - (parts[0].split(":").length + parts[1].split(":").length);
const padding = Array(missing).fill("0").join(":");
fullAddress = `${parts[0]}:${padding}:${parts[1]}`;
}
return fullAddress.split(':')
.reduce((acc, hextet) => {
const num = parseInt(hextet || '0', 16);
if (isNaN(num) || num < 0 || num > 65535) {
throw new Error(`Invalid IPv6 hextet: ${hextet}`);
}
return BigInt.asUintN(128, (acc << BigInt(16)) + BigInt(num));
}, BigInt(0));
return fullAddress.split(":").reduce((acc, hextet) => {
const num = parseInt(hextet || "0", 16);
if (isNaN(num) || num < 0 || num > 65535) {
throw new Error(`Invalid IPv6 hextet: ${hextet}`);
}
return BigInt.asUintN(128, (acc << BigInt(16)) + BigInt(num));
}, BigInt(0));
}
}
@ -65,11 +65,15 @@ function bigIntToIp(num: bigint, version: IPVersion): string {
octets.unshift(Number(num & BigInt(255)));
num = num >> BigInt(8);
}
return octets.join('.');
return octets.join(".");
} else {
const hextets: string[] = [];
for (let i = 0; i < 8; i++) {
hextets.unshift(Number(num & BigInt(65535)).toString(16).padStart(4, '0'));
hextets.unshift(
Number(num & BigInt(65535))
.toString(16)
.padStart(4, "0")
);
num = num >> BigInt(16);
}
// Compress zero sequences
@ -79,7 +83,7 @@ function bigIntToIp(num: bigint, version: IPVersion): string {
let currentZeroLength = 0;
for (let i = 0; i < hextets.length; i++) {
if (hextets[i] === '0000') {
if (hextets[i] === "0000") {
if (currentZeroStart === -1) currentZeroStart = i;
currentZeroLength++;
if (currentZeroLength > maxZeroLength) {
@ -93,12 +97,14 @@ function bigIntToIp(num: bigint, version: IPVersion): string {
}
if (maxZeroLength > 1) {
hextets.splice(maxZeroStart, maxZeroLength, '');
if (maxZeroStart === 0) hextets.unshift('');
if (maxZeroStart + maxZeroLength === 8) hextets.push('');
hextets.splice(maxZeroStart, maxZeroLength, "");
if (maxZeroStart === 0) hextets.unshift("");
if (maxZeroStart + maxZeroLength === 8) hextets.push("");
}
return hextets.map(h => h === '0000' ? '0' : h.replace(/^0+/, '')).join(':');
return hextets
.map((h) => (h === "0000" ? "0" : h.replace(/^0+/, "")))
.join(":");
}
}
@ -106,7 +112,7 @@ function bigIntToIp(num: bigint, version: IPVersion): string {
* Converts CIDR to IP range
*/
export function cidrToRange(cidr: string): IPRange {
const [ip, prefix] = cidr.split('/');
const [ip, prefix] = cidr.split("/");
const version = detectIpVersion(ip);
const prefixBits = parseInt(prefix);
const ipBigInt = ipToBigInt(ip);
@ -118,7 +124,10 @@ export function cidrToRange(cidr: string): IPRange {
}
const shiftBits = BigInt(maxPrefix - prefixBits);
const mask = BigInt.asUintN(version === 4 ? 64 : 128, (BigInt(1) << shiftBits) - BigInt(1));
const mask = BigInt.asUintN(
version === 4 ? 64 : 128,
(BigInt(1) << shiftBits) - BigInt(1)
);
const start = ipBigInt & ~mask;
const end = start | mask;
@ -142,59 +151,66 @@ export function findNextAvailableCidr(
}
// If no existing CIDRs, use the IP version from startCidr
const version = startCidr
? detectIpVersion(startCidr.split('/')[0])
: 4; // Default to IPv4 if no startCidr provided
const version = startCidr ? detectIpVersion(startCidr.split("/")[0]) : 4; // Default to IPv4 if no startCidr provided
// Use appropriate default startCidr if none provided
startCidr = startCidr || (version === 4 ? "0.0.0.0/0" : "::/0");
// If there are existing CIDRs, ensure all are same version
if (existingCidrs.length > 0 &&
existingCidrs.some(cidr => detectIpVersion(cidr.split('/')[0]) !== version)) {
throw new Error('All CIDRs must be of the same IP version');
if (
existingCidrs.length > 0 &&
existingCidrs.some(
(cidr) => detectIpVersion(cidr.split("/")[0]) !== version
)
) {
throw new Error("All CIDRs must be of the same IP version");
}
// Extract the network part from startCidr to ensure we stay in the right subnet
const startCidrRange = cidrToRange(startCidr);
// Convert existing CIDRs to ranges and sort them
const existingRanges = existingCidrs
.map(cidr => cidrToRange(cidr))
.map((cidr) => cidrToRange(cidr))
.sort((a, b) => (a.start < b.start ? -1 : 1));
// Calculate block size
const maxPrefix = version === 4 ? 32 : 128;
const blockSizeBigInt = BigInt(1) << BigInt(maxPrefix - blockSize);
// Start from the beginning of the given CIDR
let current = startCidrRange.start;
const maxIp = startCidrRange.end;
// Iterate through existing ranges
for (let i = 0; i <= existingRanges.length; i++) {
const nextRange = existingRanges[i];
// Align current to block size
const alignedCurrent = current + ((blockSizeBigInt - (current % blockSizeBigInt)) % blockSizeBigInt);
const alignedCurrent =
current +
((blockSizeBigInt - (current % blockSizeBigInt)) % blockSizeBigInt);
// Check if we've gone beyond the maximum allowed IP
if (alignedCurrent + blockSizeBigInt - BigInt(1) > maxIp) {
return null;
}
// If we're at the end of existing ranges or found a gap
if (!nextRange || alignedCurrent + blockSizeBigInt - BigInt(1) < nextRange.start) {
if (
!nextRange ||
alignedCurrent + blockSizeBigInt - BigInt(1) < nextRange.start
) {
return `${bigIntToIp(alignedCurrent, version)}/${blockSize}`;
}
// If next range overlaps with our search space, move past it
if (nextRange.end >= startCidrRange.start && nextRange.start <= maxIp) {
// Move current pointer to after the current range
current = nextRange.end + BigInt(1);
}
}
return null;
}
@ -206,7 +222,7 @@ export function findNextAvailableCidr(
*/
export function isIpInCidr(ip: string, cidr: string): boolean {
const ipVersion = detectIpVersion(ip);
const cidrVersion = detectIpVersion(cidr.split('/')[0]);
const cidrVersion = detectIpVersion(cidr.split("/")[0]);
// If IP versions don't match, the IP cannot be in the CIDR range
if (ipVersion !== cidrVersion) {
@ -219,11 +235,10 @@ export function isIpInCidr(ip: string, cidr: string): boolean {
return ipBigInt >= range.start && ipBigInt <= range.end;
}
export async function getNextAvailableClientSubnet(orgId: string): Promise<string> {
const [org] = await db
.select()
.from(orgs)
.where(eq(orgs.orgId, orgId));
export async function getNextAvailableClientSubnet(
orgId: string
): Promise<string> {
const [org] = await db.select().from(orgs).where(eq(orgs.orgId, orgId));
const existingAddressesSites = await db
.select({
@ -240,15 +255,15 @@ export async function getNextAvailableClientSubnet(orgId: string): Promise<strin
.where(and(isNotNull(clients.subnet), eq(clients.orgId, orgId)));
const addresses = [
...existingAddressesSites.map((site) => `${site.address?.split("/")[0]}/32`), // we are overriding the 32 so that we pick individual addresses in the subnet of the org for the site and the client even though they are stored with the /block_size of the org
...existingAddressesClients.map((client) => `${client.address.split("/")}/32`)
...existingAddressesSites.map(
(site) => `${site.address?.split("/")[0]}/32`
), // we are overriding the 32 so that we pick individual addresses in the subnet of the org for the site and the client even though they are stored with the /block_size of the org
...existingAddressesClients.map(
(client) => `${client.address.split("/")}/32`
)
].filter((address) => address !== null) as string[];
let subnet = findNextAvailableCidr(
addresses,
32,
org.subnet
); // pick the sites address in the org
let subnet = findNextAvailableCidr(addresses, 32, org.subnet); // pick the sites address in the org
if (!subnet) {
throw new Error("No available subnets remaining in space");
}

View file

@ -133,7 +133,10 @@ export const configSchema = z
db: z.number().int().nonnegative().optional().default(0),
tls: z
.object({
reject_unauthorized: z.boolean().optional().default(true)
reject_unauthorized: z
.boolean()
.optional()
.default(true)
})
.optional()
})
@ -226,9 +229,9 @@ export const configSchema = z
disable_local_sites: z.boolean().optional(),
disable_basic_wireguard_sites: z.boolean().optional(),
disable_config_managed_domains: z.boolean().optional(),
enable_clients: z.boolean().optional()
enable_clients: z.boolean().optional(),
})
.optional()
.optional(),
})
.refine(
(data) => {

File diff suppressed because it is too large Load diff

View file

@ -14,10 +14,17 @@ export * from "./verifyAdmin";
export * from "./verifySetResourceUsers";
export * from "./verifyUserInRole";
export * from "./verifyAccessTokenAccess";
export * from "./requestTimeout";
export * from "./verifyClientAccess";
export * from "./verifyUserHasAction";
export * from "./verifyUserIsServerAdmin";
export * from "./verifyIsLoggedInUser";
export * from "./verifyIsLoggedInUser";
export * from "./verifyClientAccess";
export * from "./integration";
export * from "./verifyValidLicense";
export * from "./verifyUserHasAction";
export * from "./verifyApiKeyAccess";
export * from "./verifyDomainAccess";
export * from "./verifyClientsEnabled";
export * from "./verifyUserIsOrgOwner";

View file

@ -0,0 +1,35 @@
import { Request, Response, NextFunction } from 'express';
import logger from '@server/logger';
import createHttpError from 'http-errors';
import HttpCode from '@server/types/HttpCode';
export function requestTimeoutMiddleware(timeoutMs: number = 30000) {
return (req: Request, res: Response, next: NextFunction) => {
// Set a timeout for the request
const timeout = setTimeout(() => {
if (!res.headersSent) {
logger.error(`Request timeout: ${req.method} ${req.url} from ${req.ip}`);
return next(
createHttpError(
HttpCode.REQUEST_TIMEOUT,
'Request timeout - operation took too long to complete'
)
);
}
}, timeoutMs);
// Clear timeout when response finishes
res.on('finish', () => {
clearTimeout(timeout);
});
// Clear timeout when response closes
res.on('close', () => {
clearTimeout(timeout);
});
next();
};
}
export default requestTimeoutMiddleware;

View file

@ -0,0 +1,93 @@
import { Request, Response, NextFunction } from "express";
import { db, domains, orgDomains } from "@server/db";
import { userOrgs, apiKeyOrg } from "@server/db";
import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
export async function verifyDomainAccess(
req: Request,
res: Response,
next: NextFunction
) {
try {
const userId = req.user!.userId;
const domainId =
req.params.domainId || req.body.apiKeyId || req.query.apiKeyId;
const orgId = req.params.orgId;
if (!userId) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
);
}
if (!orgId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
);
}
if (!domainId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid domain ID")
);
}
const [domain] = await db
.select()
.from(domains)
.innerJoin(orgDomains, eq(orgDomains.domainId, domains.domainId))
.where(
and(
eq(orgDomains.domainId, domainId),
eq(orgDomains.orgId, orgId)
)
)
.limit(1);
if (!domain.orgDomains) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Domain with ID ${domainId} not found`
)
);
}
if (!req.userOrg) {
const userOrgRole = await db
.select()
.from(userOrgs)
.where(
and(
eq(userOrgs.userId, userId),
eq(userOrgs.orgId, apiKeyOrg.orgId)
)
)
.limit(1);
req.userOrg = userOrgRole[0];
}
if (!req.userOrg) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have access to this organization"
)
);
}
const userOrgRoleId = req.userOrg.roleId;
req.userOrgRoleId = userOrgRoleId;
return next();
} catch (error) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Error verifying domain access"
)
);
}
}

View file

@ -14,5 +14,6 @@ export enum OpenAPITags {
AccessToken = "Access Token",
Idp = "Identity Provider",
Client = "Client",
ApiKey = "API Key"
ApiKey = "API Key",
Domain = "Domain"
}

View file

@ -11,4 +11,4 @@ export * from "./requestPasswordReset";
export * from "./resetPassword";
export * from "./checkResourceSession";
export * from "./setServerAdmin";
export * from "./initialSetupComplete";
export * from "./initialSetupComplete";

View file

@ -12,6 +12,7 @@ import { createTOTPKeyURI } from "oslo/otp";
import logger from "@server/logger";
import { verifyPassword } from "@server/auth/password";
import { unauthorized } from "@server/auth/unauthorizedResponse";
import config from "@server/lib/config";
import { UserType } from "@server/types/UserTypes";
export const requestTotpSecretBody = z
@ -73,7 +74,11 @@ export async function requestTotpSecret(
const hex = crypto.getRandomValues(new Uint8Array(20));
const secret = encodeHex(hex);
const uri = createTOTPKeyURI("Pangolin", user.email!, hex);
const uri = createTOTPKeyURI(
"Pangolin",
user.email!,
hex
);
await db
.update(users)

View file

@ -57,8 +57,6 @@ export async function signup(
const { email, password, inviteToken, inviteId } = parsedBody.data;
logger.debug("signup", { email, password, inviteToken, inviteId });
const passwordHash = await hashPassword(password);
const userId = generateId(15);

View file

@ -4,7 +4,7 @@ import { z } from "zod";
import { fromError } from "zod-validation-error";
import HttpCode from "@server/types/HttpCode";
import { response } from "@server/lib";
import { db } from "@server/db";
import { db, userOrgs } from "@server/db";
import { User, emailVerificationCodes, users } from "@server/db";
import { eq } from "drizzle-orm";
import { isWithinExpirationDate } from "oslo";

View file

@ -94,7 +94,7 @@ export async function createClient(
const { orgId } = parsedParams.data;
if (!req.userOrgRoleId) {
if (req.user && !req.userOrgRoleId) {
return next(
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
);
@ -208,7 +208,7 @@ export async function createClient(
clientId: newClient.clientId
});
if (req.userOrgRoleId != adminRole[0].roleId) {
if (req.user && req.userOrgRoleId != adminRole[0].roleId) {
// make sure the user can access the site
trx.insert(userClients).values({
userId: req.user?.userId!,

View file

@ -126,7 +126,7 @@ export async function listClients(
}
const { orgId } = parsedParams.data;
if (orgId && orgId !== req.userOrgId) {
if (req.user && orgId && orgId !== req.userOrgId) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
@ -135,21 +135,29 @@ export async function listClients(
);
}
const accessibleClients = await db
.select({
clientId: sql<number>`COALESCE(${userClients.clientId}, ${roleClients.clientId})`
})
.from(userClients)
.fullJoin(
roleClients,
eq(userClients.clientId, roleClients.clientId)
)
.where(
or(
eq(userClients.userId, req.user!.userId),
eq(roleClients.roleId, req.userOrgRoleId!)
let accessibleClients;
if (req.user) {
accessibleClients = await db
.select({
clientId: sql<number>`COALESCE(${userClients.clientId}, ${roleClients.clientId})`
})
.from(userClients)
.fullJoin(
roleClients,
eq(userClients.clientId, roleClients.clientId)
)
);
.where(
or(
eq(userClients.userId, req.user!.userId),
eq(roleClients.roleId, req.userOrgRoleId!)
)
);
} else {
accessibleClients = await db
.select({ clientId: clients.clientId })
.from(clients)
.where(eq(clients.orgId, orgId));
}
const accessibleClientIds = accessibleClients.map(
(client) => client.clientId

View file

@ -7,6 +7,7 @@ import { generateId } from "@server/auth/sessions/app";
import { getNextAvailableClientSubnet } from "@server/lib/ip";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
export type PickClientDefaultsResponse = {
olmId: string;
@ -20,6 +21,17 @@ const pickClientDefaultsSchema = z
})
.strict();
registry.registerPath({
method: "get",
path: "/site/{siteId}/pick-client-defaults",
description: "Return pre-requisite data for creating a client.",
tags: [OpenAPITags.Client, OpenAPITags.Site],
request: {
params: pickClientDefaultsSchema
},
responses: {}
});
export async function pickClientDefaults(
req: Request,
res: Response,

View file

@ -0,0 +1,252 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, Domain, domains, OrgDomains, orgDomains } 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 { subdomainSchema } from "@server/lib/schemas";
import { generateId } from "@server/auth/sessions/app";
import { eq, and } from "drizzle-orm";
import { isValidDomain } from "@server/lib/validators";
const paramsSchema = z
.object({
orgId: z.string()
})
.strict();
const bodySchema = z
.object({
type: z.enum(["ns", "cname"]),
baseDomain: subdomainSchema
})
.strict();
export type CreateDomainResponse = {
domainId: string;
nsRecords?: string[];
cnameRecords?: { baseDomain: string; value: string }[];
txtRecords?: { baseDomain: string; value: string }[];
};
// Helper to check if a domain is a subdomain or equal to another domain
function isSubdomainOrEqual(a: string, b: string): boolean {
const aParts = a.toLowerCase().split(".");
const bParts = b.toLowerCase().split(".");
if (aParts.length < bParts.length) return false;
return aParts.slice(-bParts.length).join(".") === bParts.join(".");
}
export async function createOrgDomain(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId } = parsedParams.data;
const { type, baseDomain } = parsedBody.data;
// Validate organization exists
if (!isValidDomain(baseDomain)) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid domain format")
);
}
let numOrgDomains: OrgDomains[] | undefined;
let cnameRecords: CreateDomainResponse["cnameRecords"];
let txtRecords: CreateDomainResponse["txtRecords"];
let nsRecords: CreateDomainResponse["nsRecords"];
let returned: Domain | undefined;
await db.transaction(async (trx) => {
const [existing] = await trx
.select()
.from(domains)
.where(
and(
eq(domains.baseDomain, baseDomain),
eq(domains.type, type)
)
)
.leftJoin(
orgDomains,
eq(orgDomains.domainId, domains.domainId)
);
if (existing) {
const {
domains: existingDomain,
orgDomains: existingOrgDomain
} = existing;
// user alrady added domain to this account
// always reject
if (existingOrgDomain?.orgId === orgId) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Domain is already added to this org"
)
);
}
// domain already exists elsewhere
// check if it's already fully verified
if (existingDomain.verified) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Domain is already verified to an org"
)
);
}
}
// --- Domain overlap logic ---
// Only consider existing verified domains
const verifiedDomains = await trx
.select()
.from(domains)
.where(eq(domains.verified, true));
if (type === "cname") {
// Block if a verified CNAME exists at the same name
const cnameExists = verifiedDomains.some(
(d) => d.type === "cname" && d.baseDomain === baseDomain
);
if (cnameExists) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
`A CNAME record already exists for ${baseDomain}. Only one CNAME record is allowed per domain.`
)
);
}
// Block if a verified NS exists at or below (same or subdomain)
const nsAtOrBelow = verifiedDomains.some(
(d) =>
d.type === "ns" &&
(isSubdomainOrEqual(baseDomain, d.baseDomain) ||
baseDomain === d.baseDomain)
);
if (nsAtOrBelow) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
`A nameserver (NS) record exists at or below ${baseDomain}. You cannot create a CNAME record here.`
)
);
}
} else if (type === "ns") {
// Block if a verified NS exists at or below (same or subdomain)
const nsAtOrBelow = verifiedDomains.some(
(d) =>
d.type === "ns" &&
(isSubdomainOrEqual(baseDomain, d.baseDomain) ||
baseDomain === d.baseDomain)
);
if (nsAtOrBelow) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
`A nameserver (NS) record already exists at or below ${baseDomain}. You cannot create another NS record here.`
)
);
}
}
const domainId = generateId(15);
const [insertedDomain] = await trx
.insert(domains)
.values({
domainId,
baseDomain,
type
})
.returning();
returned = insertedDomain;
// add domain to account
await trx
.insert(orgDomains)
.values({
orgId,
domainId
})
.returning();
// TODO: This needs to be cross region and not hardcoded
if (type === "ns") {
nsRecords = ["ns-east.fossorial.io", "ns-west.fossorial.io"];
} else if (type === "cname") {
cnameRecords = [
{
value: `${domainId}.cname.fossorial.io`,
baseDomain: baseDomain
},
{
value: `_acme-challenge.${domainId}.cname.fossorial.io`,
baseDomain: `_acme-challenge.${baseDomain}`
}
];
}
numOrgDomains = await trx
.select()
.from(orgDomains)
.where(eq(orgDomains.orgId, orgId));
});
if (!returned) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to create domain"
)
);
}
return response<CreateDomainResponse>(res, {
data: {
domainId: returned.domainId,
cnameRecords,
txtRecords,
nsRecords
},
success: true,
error: false,
message: "Domain created successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View file

@ -0,0 +1,72 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, domains, OrgDomains, orgDomains } 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 { and, eq } from "drizzle-orm";
const paramsSchema = z
.object({
domainId: z.string(),
orgId: z.string()
})
.strict();
export type DeleteAccountDomainResponse = {
success: boolean;
};
export async function deleteAccountDomain(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsed = paramsSchema.safeParse(req.params);
if (!parsed.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsed.error).toString()
)
);
}
const { domainId, orgId } = parsed.data;
let numOrgDomains: OrgDomains[] | undefined;
await db.transaction(async (trx) => {
await trx
.delete(orgDomains)
.where(
and(
eq(orgDomains.orgId, orgId),
eq(orgDomains.domainId, domainId)
)
);
await trx.delete(domains).where(eq(domains.domainId, domainId));
numOrgDomains = await trx
.select()
.from(orgDomains)
.where(eq(orgDomains.orgId, orgId));
});
return response<DeleteAccountDomainResponse>(res, {
data: { success: true },
success: true,
error: false,
message: "Domain deleted from account successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View file

@ -1 +1,4 @@
export * from "./listDomains";
export * from "./createOrgDomain";
export * from "./deleteOrgDomain";
export * from "./restartOrgDomain";

View file

@ -37,7 +37,11 @@ async function queryDomains(orgId: string, limit: number, offset: number) {
const res = await db
.select({
domainId: domains.domainId,
baseDomain: domains.baseDomain
baseDomain: domains.baseDomain,
verified: domains.verified,
type: domains.type,
failed: domains.failed,
tries: domains.tries,
})
.from(orgDomains)
.where(eq(orgDomains.orgId, orgId))
@ -112,7 +116,7 @@ export async function listDomains(
},
success: true,
error: false,
message: "Users retrieved successfully",
message: "Domains retrieved successfully",
status: HttpCode.OK
});
} catch (error) {

View file

@ -0,0 +1,57 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, domains } 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 { and, eq } from "drizzle-orm";
const paramsSchema = z
.object({
domainId: z.string(),
orgId: z.string()
})
.strict();
export type RestartOrgDomainResponse = {
success: boolean;
};
export async function restartOrgDomain(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsed = paramsSchema.safeParse(req.params);
if (!parsed.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsed.error).toString()
)
);
}
const { domainId, orgId } = parsed.data;
await db
.update(domains)
.set({ failed: false, tries: 0 })
.where(and(eq(domains.domainId, domainId)));
return response<RestartOrgDomainResponse>(res, {
data: { success: true },
success: true,
error: false,
message: "Domain restarted successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View file

@ -33,15 +33,16 @@ import {
verifyClientAccess,
verifyApiKeyAccess,
createStore,
verifyDomainAccess,
verifyClientsEnabled,
verifyUserHasAction,
verifyUserIsOrgOwner
} from "@server/middlewares";
import { verifyUserHasAction } from "../middlewares/verifyUserHasAction";
import { ActionsEnum } from "@server/auth/actions";
import { verifyUserIsOrgOwner } from "../middlewares/verifyUserIsOrgOwner";
import { createNewt, getNewtToken } from "./newt";
import { getOlmToken } from "./olm";
import rateLimit from "express-rate-limit";
import createHttpError from "http-errors";
import { verifyClientsEnabled } from "@server/middlewares/verifyClintsEnabled";
// Root routes
export const unauthenticated = Router();
@ -54,10 +55,7 @@ unauthenticated.get("/", (_, res) => {
export const authenticated = Router();
authenticated.use(verifySessionUserMiddleware);
authenticated.get(
"/pick-org-defaults",
org.pickOrgDefaults
);
authenticated.get("/pick-org-defaults", org.pickOrgDefaults);
authenticated.get("/org/checkId", org.checkId);
authenticated.put("/org", getUserOrgs, org.createOrg);
@ -750,6 +748,29 @@ authenticated.get(
apiKeys.getApiKey
);
authenticated.put(
`/org/:orgId/domain`,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.createOrgDomain),
domain.createOrgDomain
);
authenticated.post(
`/org/:orgId/domain/:domainId/restart`,
verifyOrgAccess,
verifyDomainAccess,
verifyUserHasAction(ActionsEnum.restartOrgDomain),
domain.restartOrgDomain
);
authenticated.delete(
`/org/:orgId/domain/:domainId`,
verifyOrgAccess,
verifyDomainAccess,
verifyUserHasAction(ActionsEnum.deleteOrgDomain),
domain.deleteAccountDomain
);
// Auth routes
export const authRouter = Router();
unauthenticated.use("/auth", authRouter);

View file

@ -1,5 +1,5 @@
import { Request, Response, NextFunction } from "express";
import { eq, and, lt, inArray } from "drizzle-orm";
import { eq, and, lt, inArray, sql } from "drizzle-orm";
import { sites } from "@server/db";
import { db } from "@server/db";
import logger from "@server/logger";
@ -7,6 +7,9 @@ import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import response from "@server/lib/response";
// Track sites that are already offline to avoid unnecessary queries
const offlineSites = new Set<string>();
interface PeerBandwidth {
publicKey: string;
bytesIn: number;
@ -28,43 +31,62 @@ export const receiveBandwidth = async (
const currentTime = new Date();
const oneMinuteAgo = new Date(currentTime.getTime() - 60000); // 1 minute ago
logger.debug(`Received data: ${JSON.stringify(bandwidthData)}`);
await db.transaction(async (trx) => {
// First, handle sites that are actively reporting bandwidth
const activePeers = bandwidthData.filter(peer => peer.bytesIn > 0 || peer.bytesOut > 0);
const activePeers = bandwidthData.filter(peer => peer.bytesIn > 0); // Bytesout will have data as it tries to send keep alive messages
if (activePeers.length > 0) {
// Get all active sites in one query
const activeSites = await trx
.select()
.from(sites)
.where(inArray(sites.pubKey, activePeers.map(p => p.publicKey)));
// Remove any active peers from offline tracking since they're sending data
activePeers.forEach(peer => offlineSites.delete(peer.publicKey));
// Create a map for quick lookup
const siteMap = new Map();
activeSites.forEach(site => {
siteMap.set(site.pubKey, site);
});
// Aggregate usage data by organization
const orgUsageMap = new Map<string, number>();
const orgUptimeMap = new Map<string, number>();
// Update sites with actual bandwidth usage
// Update all active sites with bandwidth data and get the site data in one operation
const updatedSites = [];
for (const peer of activePeers) {
const site = siteMap.get(peer.publicKey);
if (!site) continue;
await trx
const updatedSite = await trx
.update(sites)
.set({
megabytesOut: (site.megabytesOut || 0) + peer.bytesIn,
megabytesIn: (site.megabytesIn || 0) + peer.bytesOut,
megabytesOut: sql`${sites.megabytesOut} + ${peer.bytesIn}`,
megabytesIn: sql`${sites.megabytesIn} + ${peer.bytesOut}`,
lastBandwidthUpdate: currentTime.toISOString(),
online: true
})
.where(eq(sites.siteId, site.siteId));
.where(eq(sites.pubKey, peer.publicKey))
.returning({
online: sites.online,
orgId: sites.orgId,
siteId: sites.siteId,
lastBandwidthUpdate: sites.lastBandwidthUpdate,
});
if (updatedSite.length > 0) {
updatedSites.push({ ...updatedSite[0], peer });
}
}
// Calculate org usage aggregations using the updated site data
for (const { peer, ...site } of updatedSites) {
// Aggregate bandwidth usage for the org
const totalBandwidth = peer.bytesIn + peer.bytesOut;
const currentOrgUsage = orgUsageMap.get(site.orgId) || 0;
orgUsageMap.set(site.orgId, currentOrgUsage + totalBandwidth);
// Add 10 seconds of uptime for each active site
const currentOrgUptime = orgUptimeMap.get(site.orgId) || 0;
orgUptimeMap.set(site.orgId, currentOrgUptime + 10 / 60); // Store in minutes and jut add 10 seconds
}
}
// Handle sites that reported zero bandwidth but need online status updated
const zeroBandwidthPeers = bandwidthData.filter(peer => peer.bytesIn === 0 && peer.bytesOut === 0);
const zeroBandwidthPeers = bandwidthData.filter(peer =>
peer.bytesIn === 0 && !offlineSites.has(peer.publicKey) // Bytesout will have data as it tries to send keep alive messages
);
if (zeroBandwidthPeers.length > 0) {
const zeroBandwidthSites = await trx
.select()
@ -91,18 +113,14 @@ export const receiveBandwidth = async (
await trx
.update(sites)
.set({
lastBandwidthUpdate: currentTime.toISOString(),
online: newOnlineStatus
})
.where(eq(sites.siteId, site.siteId));
} else {
// Just update the heartbeat timestamp
await trx
.update(sites)
.set({
lastBandwidthUpdate: currentTime.toISOString()
})
.where(eq(sites.siteId, site.siteId));
// If site went offline, add it to our tracking set
if (!newOnlineStatus && site.pubKey) {
offlineSites.add(site.pubKey);
}
}
}
}

View file

@ -11,6 +11,7 @@ import {
idpOidcConfig,
idpOrg,
orgs,
Role,
roles,
userOrgs,
users
@ -307,6 +308,8 @@ export async function validateOidcCallback(
let existingUserId = existingUser?.userId;
let orgUserCounts: { orgId: string; userCount: number }[] = [];
// sync the user with the orgs and roles
await db.transaction(async (trx) => {
let userId = existingUser?.userId;
@ -410,6 +413,19 @@ export async function validateOidcCallback(
}))
);
}
// Loop through all the orgs and get the total number of users from the userOrgs table
for (const org of currentUserOrgs) {
const userCount = await trx
.select()
.from(userOrgs)
.where(eq(userOrgs.orgId, org.orgId));
orgUserCounts.push({
orgId: org.orgId,
userCount: userCount.length
});
}
});
const token = generateSessionToken();

View file

@ -87,7 +87,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
let siteSubnet = oldSite.subnet;
let exitNodeIdToQuery = oldSite.exitNodeId;
if (exitNodeId && oldSite.exitNodeId !== exitNodeId) {
if (exitNodeId && (oldSite.exitNodeId !== exitNodeId || !oldSite.subnet)) {
// This effectively moves the exit node to the new one
exitNodeIdToQuery = exitNodeId; // Use the provided exitNodeId if it differs from the site's exitNodeId
@ -105,7 +105,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
.limit(1);
const blockSize = config.getRawConfig().gerbil.site_block_size;
const subnets = sitesQuery.map((site) => site.subnet);
const subnets = sitesQuery.map((site) => site.subnet).filter((subnet) => subnet !== null);
subnets.push(exitNode.address.replace(/\/\d+$/, `/${blockSize}`));
const newSubnet = findNextAvailableCidr(
subnets,

View file

@ -49,7 +49,7 @@ export async function createNewt(
const { newtId, secret } = parsedBody.data;
if (!req.userOrgRoleId) {
if (req.user && !req.userOrgRoleId) {
return next(
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
);

View file

@ -33,8 +33,6 @@ const createOrgSchema = z
})
.strict();
// const MAX_ORGS = 5;
registry.registerPath({
method: "put",
path: "/org",
@ -80,16 +78,6 @@ export async function createOrg(
);
}
// const userOrgIds = req.userOrgIds;
// if (userOrgIds && userOrgIds.length > MAX_ORGS) {
// return next(
// createHttpError(
// HttpCode.FORBIDDEN,
// `Maximum number of organizations reached.`
// )
// );
// }
const { orgId, name, subnet } = parsedBody.data;
if (!isValidCIDR(subnet)) {
@ -147,7 +135,7 @@ export async function createOrg(
.values({
orgId,
name,
subnet,
subnet
})
.returning();
@ -182,15 +170,13 @@ export async function createOrg(
const actionIds = await trx.select().from(actions).execute();
if (actionIds.length > 0) {
await trx
.insert(roleActions)
.values(
actionIds.map((action) => ({
roleId,
actionId: action.actionId,
orgId: newOrg[0].orgId
}))
);
await trx.insert(roleActions).values(
actionIds.map((action) => ({
roleId,
actionId: action.actionId,
orgId: newOrg[0].orgId
}))
);
}
if (allDomains.length) {
@ -227,7 +213,7 @@ export async function createOrg(
orgId: newOrg[0].orgId,
roleId: roleId,
isOwner: true
});
});
}
const memberRole = await trx

View file

@ -89,6 +89,8 @@ export async function deleteOrg(
.where(eq(sites.orgId, orgId))
.limit(1);
const deletedNewtIds: string[] = [];
await db.transaction(async (trx) => {
if (sites) {
for (const site of orgSites) {
@ -102,11 +104,7 @@ export async function deleteOrg(
.where(eq(newts.siteId, site.siteId))
.returning();
if (deletedNewt) {
const payload = {
type: `newt/terminate`,
data: {}
};
sendToClient(deletedNewt.newtId, payload);
deletedNewtIds.push(deletedNewt.newtId);
// delete all of the sessions for the newt
await trx
@ -131,6 +129,18 @@ export async function deleteOrg(
await trx.delete(orgs).where(eq(orgs.orgId, orgId));
});
// Send termination messages outside of transaction to prevent blocking
for (const newtId of deletedNewtIds) {
const payload = {
type: `newt/terminate`,
data: {}
};
// Don't await this to prevent blocking the response
sendToClient(newtId, payload).catch(error => {
logger.error("Failed to send termination message to newt:", error);
});
}
return response(res, {
data: null,
success: true,

View file

@ -69,7 +69,8 @@ function queryResources(
http: resources.http,
protocol: resources.protocol,
proxyPort: resources.proxyPort,
enabled: resources.enabled
enabled: resources.enabled,
domainId: resources.domainId
})
.from(resources)
.leftJoin(sites, eq(resources.siteId, sites.siteId))
@ -103,7 +104,8 @@ function queryResources(
http: resources.http,
protocol: resources.protocol,
proxyPort: resources.proxyPort,
enabled: resources.enabled
enabled: resources.enabled,
domainId: resources.domainId
})
.from(resources)
.leftJoin(sites, eq(resources.siteId, sites.siteId))

View file

@ -38,7 +38,7 @@ const createSiteSchema = z
subnet: z.string().optional(),
newtId: z.string().optional(),
secret: z.string().optional(),
address: z.string().optional(),
// address: z.string().optional(),
type: z.enum(["newt", "wireguard", "local"])
})
.strict()
@ -97,7 +97,7 @@ export async function createSite(
subnet,
newtId,
secret,
address
// address
} = parsedBody.data;
const parsedParams = createSiteParamsSchema.safeParse(req.params);
@ -129,58 +129,58 @@ export async function createSite(
);
}
let updatedAddress = null;
if (address) {
if (!isValidIP(address)) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid subnet format. Please provide a valid CIDR notation."
)
);
}
if (!isIpInCidr(address, org.subnet)) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"IP is not in the CIDR range of the subnet."
)
);
}
updatedAddress = `${address}/${org.subnet.split("/")[1]}`; // we want the block size of the whole org
// make sure the subnet is unique
const addressExistsSites = await db
.select()
.from(sites)
.where(eq(sites.address, updatedAddress))
.limit(1);
if (addressExistsSites.length > 0) {
return next(
createHttpError(
HttpCode.CONFLICT,
`Subnet ${subnet} already exists`
)
);
}
const addressExistsClients = await db
.select()
.from(sites)
.where(eq(sites.subnet, updatedAddress))
.limit(1);
if (addressExistsClients.length > 0) {
return next(
createHttpError(
HttpCode.CONFLICT,
`Subnet ${subnet} already exists`
)
);
}
}
// let updatedAddress = null;
// if (address) {
// if (!isValidIP(address)) {
// return next(
// createHttpError(
// HttpCode.BAD_REQUEST,
// "Invalid subnet format. Please provide a valid CIDR notation."
// )
// );
// }
//
// if (!isIpInCidr(address, org.subnet)) {
// return next(
// createHttpError(
// HttpCode.BAD_REQUEST,
// "IP is not in the CIDR range of the subnet."
// )
// );
// }
//
// updatedAddress = `${address}/${org.subnet.split("/")[1]}`; // we want the block size of the whole org
//
// // make sure the subnet is unique
// const addressExistsSites = await db
// .select()
// .from(sites)
// .where(eq(sites.address, updatedAddress))
// .limit(1);
//
// if (addressExistsSites.length > 0) {
// return next(
// createHttpError(
// HttpCode.CONFLICT,
// `Subnet ${subnet} already exists`
// )
// );
// }
//
// const addressExistsClients = await db
// .select()
// .from(sites)
// .where(eq(sites.subnet, updatedAddress))
// .limit(1);
// if (addressExistsClients.length > 0) {
// return next(
// createHttpError(
// HttpCode.CONFLICT,
// `Subnet ${subnet} already exists`
// )
// );
// }
// }
const niceId = await getUniqueSiteName(orgId);
@ -205,7 +205,7 @@ export async function createSite(
exitNodeId,
name,
niceId,
address: updatedAddress || null,
// address: updatedAddress || null,
subnet,
type,
dockerSocketEnabled: type == "newt",
@ -221,7 +221,7 @@ export async function createSite(
orgId,
name,
niceId,
address: updatedAddress || null,
// address: updatedAddress || null,
type,
dockerSocketEnabled: type == "newt",
subnet: "0.0.0.0/0"

View file

@ -62,6 +62,8 @@ export async function deleteSite(
);
}
let deletedNewtId: string | null = null;
await db.transaction(async (trx) => {
if (site.pubKey) {
if (site.type == "wireguard") {
@ -73,11 +75,7 @@ export async function deleteSite(
.where(eq(newts.siteId, siteId))
.returning();
if (deletedNewt) {
const payload = {
type: `newt/terminate`,
data: {}
};
sendToClient(deletedNewt.newtId, payload);
deletedNewtId = deletedNewt.newtId;
// delete all of the sessions for the newt
await trx
@ -90,6 +88,18 @@ export async function deleteSite(
await trx.delete(sites).where(eq(sites.siteId, siteId));
});
// Send termination message outside of transaction to prevent blocking
if (deletedNewtId) {
const payload = {
type: `newt/terminate`,
data: {}
};
// Don't await this to prevent blocking the response
sendToClient(deletedNewtId, payload).catch(error => {
logger.error("Failed to send termination message to newt:", error);
});
}
return response(res, {
data: null,
success: true,

View file

@ -20,10 +20,10 @@ export type PickSiteDefaultsResponse = {
name: string;
listenPort: number;
endpoint: string;
subnet: string;
subnet: string; // TODO: make optional?
newtId: string;
newtSecret: string;
clientAddress: string;
clientAddress?: string;
};
registry.registerPath({
@ -86,7 +86,7 @@ export async function pickSiteDefaults(
.where(eq(sites.exitNodeId, exitNode.exitNodeId));
// TODO: we need to lock this subnet for some time so someone else does not take it
let subnets = sitesQuery.map((site) => site.subnet);
let subnets = sitesQuery.map((site) => site.subnet).filter((subnet) => subnet !== null);
// exclude the exit node address by replacing after the / with a site block size
subnets.push(
exitNode.address.replace(
@ -108,17 +108,17 @@ export async function pickSiteDefaults(
);
}
const newClientAddress = await getNextAvailableClientSubnet(orgId);
if (!newClientAddress) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"No available subnet found"
)
);
}
// const newClientAddress = await getNextAvailableClientSubnet(orgId);
// if (!newClientAddress) {
// return next(
// createHttpError(
// HttpCode.INTERNAL_SERVER_ERROR,
// "No available subnet found"
// )
// );
// }
const clientAddress = newClientAddress.split("/")[0];
// const clientAddress = newClientAddress.split("/")[0];
const newtId = generateId(15);
const secret = generateId(48);
@ -133,7 +133,7 @@ export async function pickSiteDefaults(
endpoint: exitNode.endpoint,
// subnet: `${newSubnet.split("/")[0]}/${config.getRawConfig().gerbil.block_size}`, // we want the block size of the whole subnet
subnet: newSubnet,
clientAddress: clientAddress,
// clientAddress: clientAddress,
newtId,
newtSecret: secret
},

View file

@ -56,12 +56,8 @@ export async function traefikConfigProvider(
.select({
// Resource fields
resourceId: resources.resourceId,
subdomain: resources.subdomain,
fullDomain: resources.fullDomain,
ssl: resources.ssl,
blockAccess: resources.blockAccess,
sso: resources.sso,
emailWhitelistEnabled: resources.emailWhitelistEnabled,
http: resources.http,
proxyPort: resources.proxyPort,
protocol: resources.protocol,
@ -74,10 +70,6 @@ export async function traefikConfigProvider(
subnet: sites.subnet,
exitNodeId: sites.exitNodeId,
},
// Org fields
org: {
orgId: orgs.orgId
},
enabled: resources.enabled,
stickySession: resources.stickySession,
tlsServerName: resources.tlsServerName,
@ -85,7 +77,6 @@ export async function traefikConfigProvider(
})
.from(resources)
.innerJoin(sites, eq(sites.siteId, resources.siteId))
.innerJoin(orgs, eq(resources.orgId, orgs.orgId))
.where(eq(sites.exitNodeId, currentExitNodeId));
// Get all resource IDs from the first query
@ -179,7 +170,6 @@ export async function traefikConfigProvider(
for (const resource of allResources) {
const targets = resource.targets as Target[];
const site = resource.site;
const org = resource.org;
const routerName = `${resource.resourceId}-router`;
const serviceName = `${resource.resourceId}-service`;
@ -203,11 +193,6 @@ export async function traefikConfigProvider(
continue;
}
// HTTP configuration remains the same
if (!resource.subdomain && !resource.isBaseDomain) {
continue;
}
// add routers and services empty objects if they don't exist
if (!config_output.http.routers) {
config_output.http.routers = {};
@ -299,7 +284,7 @@ export async function traefikConfigProvider(
} else if (site.type === "newt") {
if (
!target.internalPort ||
!target.method
!target.method || !site.subnet
) {
return false;
}
@ -315,7 +300,7 @@ export async function traefikConfigProvider(
url: `${target.method}://${target.ip}:${target.port}`
};
} else if (site.type === "newt") {
const ip = site.subnet.split("/")[0];
const ip = site.subnet!.split("/")[0];
return {
url: `${target.method}://${ip}:${target.internalPort}`
};
@ -409,7 +394,7 @@ export async function traefikConfigProvider(
return false;
}
} else if (site.type === "newt") {
if (!target.internalPort) {
if (!target.internalPort || !site.subnet) {
return false;
}
}
@ -424,7 +409,7 @@ export async function traefikConfigProvider(
address: `${target.ip}:${target.port}`
};
} else if (site.type === "newt") {
const ip = site.subnet.split("/")[0];
const ip = site.subnet!.split("/")[0];
return {
address: `${ip}:${target.internalPort}`
};

View file

@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { db, UserOrg } from "@server/db";
import { roles, userInvites, userOrgs, users } from "@server/db";
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
@ -92,6 +92,7 @@ export async function acceptInvite(
}
let roleId: number;
let totalUsers: UserOrg[] | undefined;
// get the role to make sure it exists
const existingRole = await db
.select()
@ -122,6 +123,12 @@ export async function acceptInvite(
await trx
.delete(userInvites)
.where(eq(userInvites.inviteId, inviteId));
// Get the total number of users in the org now
totalUsers = await db
.select()
.from(userOrgs)
.where(eq(userOrgs.orgId, existingInvite.orgId));
});
return response<AcceptInviteResponse>(res, {

View file

@ -6,7 +6,7 @@ import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { db } from "@server/db";
import { db, UserOrg } from "@server/db";
import { and, eq } from "drizzle-orm";
import { idp, idpOidcConfig, roles, userOrgs, users } from "@server/db";
import { generateId } from "@server/auth/sessions/app";
@ -135,65 +135,76 @@ export async function createOrgUser(
);
}
const [existingUser] = await db
.select()
.from(users)
.where(eq(users.username, username));
let orgUsers: UserOrg[] | undefined;
if (existingUser) {
const [existingOrgUser] = await db
await db.transaction(async (trx) => {
const [existingUser] = await trx
.select()
.from(userOrgs)
.where(
and(
eq(userOrgs.orgId, orgId),
eq(userOrgs.userId, existingUser.userId)
)
);
.from(users)
.where(eq(users.username, username));
if (existingOrgUser) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"User already exists in this organization"
)
);
if (existingUser) {
const [existingOrgUser] = await trx
.select()
.from(userOrgs)
.where(
and(
eq(userOrgs.orgId, orgId),
eq(userOrgs.userId, existingUser.userId)
)
);
if (existingOrgUser) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"User already exists in this organization"
)
);
}
await trx
.insert(userOrgs)
.values({
orgId,
userId: existingUser.userId,
roleId: role.roleId
})
.returning();
} else {
const userId = generateId(15);
const [newUser] = await trx
.insert(users)
.values({
userId: userId,
email,
username,
name,
type: "oidc",
idpId,
dateCreated: new Date().toISOString(),
emailVerified: true
})
.returning();
await trx
.insert(userOrgs)
.values({
orgId,
userId: newUser.userId,
roleId: role.roleId
})
.returning();
}
await db
.insert(userOrgs)
.values({
orgId,
userId: existingUser.userId,
roleId: role.roleId
})
.returning();
} else {
const userId = generateId(15);
// List all of the users in the org
orgUsers = await trx
.select()
.from(userOrgs)
.where(eq(userOrgs.orgId, orgId));
});
const [newUser] = await db
.insert(users)
.values({
userId: userId,
email,
username,
name,
type: "oidc",
idpId,
dateCreated: new Date().toISOString(),
emailVerified: true
})
.returning();
await db
.insert(userOrgs)
.values({
orgId,
userId: newUser.userId,
roleId: role.roleId
})
.returning();
}
} else {
return next(
createHttpError(HttpCode.BAD_REQUEST, "User type is required")

View file

@ -99,6 +99,7 @@ export async function inviteUser(
regenerate
} = parsedBody.data;
// Check if the organization exists
const org = await db
.select()

View file

@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, resources, sites } from "@server/db";
import { db, resources, sites, UserOrg } from "@server/db";
import { userOrgs, userResources, users, userSites } from "@server/db";
import { and, eq, exists } from "drizzle-orm";
import response from "@server/lib/response";
@ -65,6 +65,8 @@ export async function removeUserOrg(
);
}
let userCount: UserOrg[] | undefined;
await db.transaction(async (trx) => {
await trx
.delete(userOrgs)
@ -108,6 +110,11 @@ export async function removeUserOrg(
)
)
);
userCount = await trx
.select()
.from(userOrgs)
.where(eq(userOrgs.orgId, orgId));
});
return response(res, {