Merge branch 'dev' of https://github.com/fosrl/pangolin into dev

This commit is contained in:
miloschwartz
2025-07-14 22:21:04 -07:00
234 changed files with 16088 additions and 7588 deletions

View File

@@ -17,10 +17,6 @@ export class Config {
isDev: boolean = process.env.ENVIRONMENT !== "prod";
constructor() {
this.load();
}
public load() {
const environment = readConfigFile();
const {
@@ -90,17 +86,36 @@ export class Config {
? "true"
: "false";
process.env.DASHBOARD_URL = parsedConfig.app.dashboard_url;
process.env.FLAGS_DISABLE_LOCAL_SITES = parsedConfig.flags
?.disable_local_sites
? "true"
: "false";
process.env.FLAGS_DISABLE_BASIC_WIREGUARD_SITES = parsedConfig.flags
?.disable_basic_wireguard_sites
? "true"
: "false";
license.setServerSecret(parsedConfig.server.secret);
this.checkKeyStatus();
process.env.FLAGS_ENABLE_CLIENTS = parsedConfig.flags?.enable_clients
? "true"
: "false";
this.rawConfig = parsedConfig;
}
public async initServer() {
if (!this.rawConfig) {
throw new Error("Config not loaded. Call load() first.");
}
license.setServerSecret(this.rawConfig.server.secret);
await this.checkKeyStatus();
}
private async checkKeyStatus() {
const licenseStatus = await license.check();
if (!licenseStatus.isHostLicensed) {
if (
!licenseStatus.isHostLicensed
) {
this.checkSupporterKey();
}
}
@@ -116,6 +131,9 @@ export class Config {
}
public getDomain(domainId: string) {
if (!this.rawConfig.domains || !this.rawConfig.domains[domainId]) {
return null;
}
return this.rawConfig.domains[domainId];
}

View File

@@ -4,7 +4,14 @@ import { assertEquals } from "@test/assert";
// Test cases
function testFindNextAvailableCidr() {
console.log("Running findNextAvailableCidr tests...");
// Test 0: Basic IPv4 allocation with a subnet in the wrong range
{
const existing = ["100.90.130.1/30", "100.90.128.4/30"];
const result = findNextAvailableCidr(existing, 30, "100.90.130.1/24");
assertEquals(result, "100.90.130.4/30", "Basic IPv4 allocation failed");
}
// Test 1: Basic IPv4 allocation
{
const existing = ["10.0.0.0/16", "10.1.0.0/16"];
@@ -26,6 +33,12 @@ function testFindNextAvailableCidr() {
assertEquals(result, null, "No available space test failed");
}
// Test 4: Empty existing
{
const existing: string[] = [];
const result = findNextAvailableCidr(existing, 30, "10.0.0.0/8");
assertEquals(result, "10.0.0.0/30", "Empty existing test failed");
}
// // Test 4: IPv6 allocation
// {
// const existing = ["2001:db8::/32", "2001:db8:1::/32"];

View File

@@ -1,3 +1,8 @@
import { db } from "@server/db";
import { clients, orgs, sites } from "@server/db";
import { and, eq, isNotNull } from "drizzle-orm";
import config from "@server/lib/config";
interface IPRange {
start: bigint;
end: bigint;
@@ -9,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;
}
/**
@@ -19,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));
}
}
@@ -60,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
@@ -74,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) {
@@ -88,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(":");
}
}
@@ -101,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);
@@ -113,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;
@@ -132,28 +146,32 @@ export function findNextAvailableCidr(
blockSize: number,
startCidr?: string
): string | null {
if (!startCidr && existingCidrs.length === 0) {
return null;
}
// 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
@@ -161,14 +179,17 @@ export function findNextAvailableCidr(
const blockSizeBigInt = BigInt(1) << BigInt(maxPrefix - blockSize);
// Start from the beginning of the given CIDR
let current = cidrToRange(startCidr).start;
const maxIp = cidrToRange(startCidr).end;
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) {
@@ -176,12 +197,18 @@ export function findNextAvailableCidr(
}
// 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}`;
}
// Move current pointer to after the current range
current = nextRange.end + BigInt(1);
// 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;
@@ -195,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) {
@@ -207,3 +234,61 @@ export function isIpInCidr(ip: string, cidr: string): boolean {
const range = cidrToRange(cidr);
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));
const existingAddressesSites = await db
.select({
address: sites.address
})
.from(sites)
.where(and(isNotNull(sites.address), eq(sites.orgId, orgId)));
const existingAddressesClients = await db
.select({
address: clients.subnet
})
.from(clients)
.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`
)
].filter((address) => address !== null) as string[];
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");
}
return subnet;
}
export async function getNextAvailableOrgSubnet(): Promise<string> {
const existingAddresses = await db
.select({
subnet: orgs.subnet
})
.from(orgs)
.where(isNotNull(orgs.subnet));
const addresses = existingAddresses.map((org) => org.subnet);
let subnet = findNextAvailableCidr(
addresses,
config.getRawConfig().orgs.block_size,
config.getRawConfig().orgs.subnet_group
);
if (!subnet) {
throw new Error("No available subnets remaining in space");
}
return subnet;
}

View File

@@ -0,0 +1,16 @@
import { MemoryStore, Store } from "express-rate-limit";
import config from "./config";
import redisManager from "@server/db/redis";
import { RedisStore } from "rate-limit-redis";
export function createStore(): Store {
let rateLimitStore: Store = new MemoryStore();
if (config.getRawConfig().flags?.enable_redis) {
const client = redisManager.client!;
rateLimitStore = new RedisStore({
sendCommand: async (command: string, ...args: string[]) =>
(await client.call(command, args)) as any
});
}
return rateLimitStore;
}

View File

@@ -3,6 +3,7 @@ import yaml from "js-yaml";
import { configFilePath1, configFilePath2 } from "./consts";
import { z } from "zod";
import stoi from "./stoi";
import { build } from "@server/build";
const portSchema = z.number().positive().gt(0).lte(65535);
@@ -10,214 +11,279 @@ const getEnvOrYaml = (envVar: string) => (valFromYaml: any) => {
return process.env[envVar] ?? valFromYaml;
};
export const configSchema = z.object({
app: z.object({
dashboard_url: z
.string()
.url()
.optional()
.pipe(z.string().url())
.transform((url) => url.toLowerCase()),
log_level: z
.enum(["debug", "info", "warn", "error"])
.optional()
.default("info"),
save_logs: z.boolean().optional().default(false),
log_failed_attempts: z.boolean().optional().default(false)
}),
domains: z
.record(
z.string(),
z.object({
base_domain: z
.string()
.nonempty("base_domain must not be empty")
.transform((url) => url.toLowerCase()),
cert_resolver: z.string().optional().default("letsencrypt"),
prefer_wildcard_cert: z.boolean().optional().default(false)
})
)
.refine(
(domains) => {
const keys = Object.keys(domains);
if (keys.length === 0) {
return false;
}
return true;
},
{
message: "At least one domain must be defined"
}
),
server: z.object({
integration_port: portSchema
.optional()
.default(3003)
.transform(stoi)
.pipe(portSchema.optional()),
external_port: portSchema
.optional()
.default(3000)
.transform(stoi)
.pipe(portSchema),
internal_port: portSchema
.optional()
.default(3001)
.transform(stoi)
.pipe(portSchema),
next_port: portSchema
.optional()
.default(3002)
.transform(stoi)
.pipe(portSchema),
internal_hostname: z
.string()
.optional()
.default("pangolin")
.transform((url) => url.toLowerCase()),
session_cookie_name: z.string().optional().default("p_session_token"),
resource_access_token_param: z.string().optional().default("p_token"),
resource_access_token_headers: z
export const configSchema = z
.object({
app: z.object({
dashboard_url: z
.string()
.url()
.optional()
.pipe(z.string().url())
.transform((url) => url.toLowerCase()),
log_level: z
.enum(["debug", "info", "warn", "error"])
.optional()
.default("info"),
save_logs: z.boolean().optional().default(false),
log_failed_attempts: z.boolean().optional().default(false)
}),
domains: z
.record(
z.string(),
z.object({
base_domain: z
.string()
.nonempty("base_domain must not be empty")
.transform((url) => url.toLowerCase()),
cert_resolver: z.string().optional().default("letsencrypt"),
prefer_wildcard_cert: z.boolean().optional().default(false)
})
)
.optional(),
server: z.object({
integration_port: portSchema
.optional()
.default(3003)
.transform(stoi)
.pipe(portSchema.optional()),
external_port: portSchema
.optional()
.default(3000)
.transform(stoi)
.pipe(portSchema),
internal_port: portSchema
.optional()
.default(3001)
.transform(stoi)
.pipe(portSchema),
next_port: portSchema
.optional()
.default(3002)
.transform(stoi)
.pipe(portSchema),
internal_hostname: z
.string()
.optional()
.default("pangolin")
.transform((url) => url.toLowerCase()),
session_cookie_name: z
.string()
.optional()
.default("p_session_token"),
resource_access_token_param: z
.string()
.optional()
.default("p_token"),
resource_access_token_headers: z
.object({
id: z.string().optional().default("P-Access-Token-Id"),
token: z.string().optional().default("P-Access-Token")
})
.optional()
.default({}),
resource_session_request_param: z
.string()
.optional()
.default("resource_session_request_param"),
dashboard_session_length_hours: z
.number()
.positive()
.gt(0)
.optional()
.default(720),
resource_session_length_hours: z
.number()
.positive()
.gt(0)
.optional()
.default(720),
cors: z
.object({
origins: z.array(z.string()).optional(),
methods: z.array(z.string()).optional(),
allowed_headers: z.array(z.string()).optional(),
credentials: z.boolean().optional()
})
.optional(),
trust_proxy: z.number().int().gte(0).optional().default(1),
secret: z
.string()
.optional()
.transform(getEnvOrYaml("SERVER_SECRET"))
.pipe(z.string().min(8))
}),
postgres: z
.object({
id: z.string().optional().default("P-Access-Token-Id"),
token: z.string().optional().default("P-Access-Token")
connection_string: z.string(),
replicas: z
.array(
z.object({
connection_string: z.string()
})
)
.optional()
})
.optional(),
redis: z
.object({
host: z.string(),
port: portSchema,
password: z.string().optional(),
db: z.number().int().nonnegative().optional().default(0),
tls: z
.object({
reject_unauthorized: z
.boolean()
.optional()
.default(true)
})
.optional()
})
.optional(),
traefik: z
.object({
http_entrypoint: z.string().optional().default("web"),
https_entrypoint: z.string().optional().default("websecure"),
additional_middlewares: z.array(z.string()).optional()
})
.optional()
.default({}),
resource_session_request_param: z
.string()
.optional()
.default("resource_session_request_param"),
dashboard_session_length_hours: z
.number()
.positive()
.gt(0)
.optional()
.default(720),
resource_session_length_hours: z
.number()
.positive()
.gt(0)
.optional()
.default(720),
cors: z
gerbil: z
.object({
origins: z.array(z.string()).optional(),
methods: z.array(z.string()).optional(),
allowed_headers: z.array(z.string()).optional(),
credentials: z.boolean().optional()
exit_node_name: z.string().optional(),
start_port: portSchema
.optional()
.default(51820)
.transform(stoi)
.pipe(portSchema),
base_endpoint: z
.string()
.optional()
.pipe(z.string())
.transform((url) => url.toLowerCase()),
use_subdomain: z.boolean().optional().default(false),
subnet_group: z.string().optional().default("100.89.137.0/20"),
block_size: z.number().positive().gt(0).optional().default(24),
site_block_size: z
.number()
.positive()
.gt(0)
.optional()
.default(30)
})
.optional()
.default({}),
orgs: z.object({
block_size: z.number().positive().gt(0),
subnet_group: z.string()
}),
rate_limits: z
.object({
global: z
.object({
window_minutes: z
.number()
.positive()
.gt(0)
.optional()
.default(1),
max_requests: z
.number()
.positive()
.gt(0)
.optional()
.default(500)
})
.optional()
.default({}),
auth: z
.object({
window_minutes: z
.number()
.positive()
.gt(0)
.optional()
.default(1),
max_requests: z
.number()
.positive()
.gt(0)
.optional()
.default(500)
})
.optional()
.default({})
})
.optional()
.default({}),
email: z
.object({
smtp_host: z.string().optional(),
smtp_port: portSchema.optional(),
smtp_user: z.string().optional(),
smtp_pass: z.string().optional(),
smtp_secure: z.boolean().optional(),
smtp_tls_reject_unauthorized: z.boolean().optional(),
no_reply: z.string().email().optional()
})
.optional(),
trust_proxy: z.number().int().gte(0).optional().default(1),
secret: z
.string()
flags: z
.object({
require_email_verification: z.boolean().optional(),
disable_signup_without_invite: z.boolean().optional(),
disable_user_create_org: z.boolean().optional(),
allow_raw_resources: z.boolean().optional(),
allow_base_domain_resources: z.boolean().optional(),
enable_integration_api: z.boolean().optional(),
enable_redis: z.boolean().optional(),
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()
})
.optional()
.transform(getEnvOrYaml("SERVER_SECRET"))
.pipe(z.string().min(8))
}),
postgres: z
.object({
connection_string: z.string(),
replicas: z
.array(
z.object({
connection_string: z.string()
})
)
.optional()
})
.optional(),
traefik: z
.object({
http_entrypoint: z.string().optional().default("web"),
https_entrypoint: z.string().optional().default("websecure"),
additional_middlewares: z.array(z.string()).optional()
})
.optional()
.default({}),
gerbil: z
.object({
start_port: portSchema
.optional()
.default(51820)
.transform(stoi)
.pipe(portSchema),
base_endpoint: z
.string()
.optional()
.pipe(z.string())
.transform((url) => url.toLowerCase()),
use_subdomain: z.boolean().optional().default(false),
subnet_group: z.string().optional().default("100.89.137.0/20"),
block_size: z.number().positive().gt(0).optional().default(24),
site_block_size: z.number().positive().gt(0).optional().default(30)
})
.optional()
.default({}),
rate_limits: z
.object({
global: z
.object({
window_minutes: z
.number()
.positive()
.gt(0)
.optional()
.default(1),
max_requests: z
.number()
.positive()
.gt(0)
.optional()
.default(500)
})
.optional()
.default({}),
auth: z
.object({
window_minutes: z
.number()
.positive()
.gt(0)
.optional()
.default(1),
max_requests: z
.number()
.positive()
.gt(0)
.optional()
.default(500)
})
.optional()
.default({}),
})
.optional()
.default({}),
email: z
.object({
smtp_host: z.string().optional(),
smtp_port: portSchema.optional(),
smtp_user: z.string().optional(),
smtp_pass: z.string().optional(),
smtp_secure: z.boolean().optional(),
smtp_tls_reject_unauthorized: z.boolean().optional(),
no_reply: z.string().email().optional()
})
.optional(),
flags: z
.object({
require_email_verification: z.boolean().optional(),
disable_signup_without_invite: z.boolean().optional(),
disable_user_create_org: z.boolean().optional(),
allow_raw_resources: z.boolean().optional(),
allow_base_domain_resources: z.boolean().optional(),
allow_local_sites: z.boolean().optional(),
enable_integration_api: z.boolean().optional()
})
.optional()
});
})
.refine(
(data) => {
if (data.flags?.enable_redis) {
return data?.redis !== undefined;
}
return true;
},
{
message:
"If Redis is enabled, configuration details must be provided"
}
)
.refine(
(data) => {
const keys = Object.keys(data.domains || {});
if (data.flags?.disable_config_managed_domains) {
return true;
}
if (keys.length === 0) {
return false;
}
return true;
},
{
message: "At least one domain must be defined"
}
)
.refine(
(data) => {
if (build == "oss" && data.redis) {
return false;
}
if (build == "oss" && data.flags?.enable_redis) {
return false;
}
return true;
},
{
message: "Redis"
}
);
export function readConfigFile() {
const loadConfig = (configPath: string) => {

File diff suppressed because it is too large Load Diff