Merge branch 'dev' into multi-domain

This commit is contained in:
miloschwartz 2025-02-26 21:26:20 -05:00
commit e82df67063
No known key found for this signature in database
30 changed files with 1508 additions and 230 deletions

View file

@ -11,7 +11,7 @@ import {
users
} from "@server/db/schema";
import db from "@server/db";
import { eq } from "drizzle-orm";
import { eq, inArray } from "drizzle-orm";
import config from "@server/lib/config";
import type { RandomReader } from "@oslojs/crypto/random";
import { generateRandomString } from "@oslojs/crypto/random";
@ -95,12 +95,36 @@ export async function validateSessionToken(
}
export async function invalidateSession(sessionId: string): Promise<void> {
await db.delete(resourceSessions).where(eq(resourceSessions.userSessionId, sessionId));
await db.delete(sessions).where(eq(sessions.sessionId, sessionId));
try {
await db.transaction(async (trx) => {
await trx
.delete(resourceSessions)
.where(eq(resourceSessions.userSessionId, sessionId));
await trx.delete(sessions).where(eq(sessions.sessionId, sessionId));
});
} catch (e) {
logger.error("Failed to invalidate session", e);
}
}
export async function invalidateAllSessions(userId: string): Promise<void> {
await db.delete(sessions).where(eq(sessions.userId, userId));
try {
await db.transaction(async (trx) => {
const userSessions = await trx
.select()
.from(sessions)
.where(eq(sessions.userId, userId));
await trx.delete(resourceSessions).where(
inArray(
resourceSessions.userSessionId,
userSessions.map((s) => s.sessionId)
)
);
await trx.delete(sessions).where(eq(sessions.userId, userId));
});
} catch (e) {
logger.error("Failed to all invalidate user sessions", e);
}
}
export function serializeSessionCookie(

183
server/lib/ip.test.ts Normal file
View file

@ -0,0 +1,183 @@
import { cidrToRange, findNextAvailableCidr } from "./ip";
/**
* Compares two objects for deep equality
* @param actual The actual value to test
* @param expected The expected value to compare against
* @param message The message to display if assertion fails
* @throws Error if objects are not equal
*/
export function assertEqualsObj<T>(actual: T, expected: T, message: string): void {
const actualStr = JSON.stringify(actual);
const expectedStr = JSON.stringify(expected);
if (actualStr !== expectedStr) {
throw new Error(`${message}\nExpected: ${expectedStr}\nActual: ${actualStr}`);
}
}
/**
* Compares two primitive values for equality
* @param actual The actual value to test
* @param expected The expected value to compare against
* @param message The message to display if assertion fails
* @throws Error if values are not equal
*/
export function assertEquals<T>(actual: T, expected: T, message: string): void {
if (actual !== expected) {
throw new Error(`${message}\nExpected: ${expected}\nActual: ${actual}`);
}
}
/**
* Tests if a function throws an expected error
* @param fn The function to test
* @param expectedError The expected error message or part of it
* @param message The message to display if assertion fails
* @throws Error if function doesn't throw or throws unexpected error
*/
export function assertThrows(
fn: () => void,
expectedError: string,
message: string
): void {
try {
fn();
throw new Error(`${message}: Expected to throw "${expectedError}"`);
} catch (error) {
if (!(error instanceof Error)) {
throw new Error(`${message}\nUnexpected error type: ${typeof error}`);
}
if (!error.message.includes(expectedError)) {
throw new Error(
`${message}\nExpected error: ${expectedError}\nActual error: ${error.message}`
);
}
}
}
// Test cases
function testFindNextAvailableCidr() {
console.log("Running findNextAvailableCidr tests...");
// Test 1: Basic IPv4 allocation
{
const existing = ["10.0.0.0/16", "10.1.0.0/16"];
const result = findNextAvailableCidr(existing, 16, "10.0.0.0/8");
assertEquals(result, "10.2.0.0/16", "Basic IPv4 allocation failed");
}
// Test 2: Finding gap between allocations
{
const existing = ["10.0.0.0/16", "10.2.0.0/16"];
const result = findNextAvailableCidr(existing, 16, "10.0.0.0/8");
assertEquals(result, "10.1.0.0/16", "Finding gap between allocations failed");
}
// Test 3: No available space
{
const existing = ["10.0.0.0/8"];
const result = findNextAvailableCidr(existing, 8, "10.0.0.0/8");
assertEquals(result, null, "No available space test failed");
}
// // Test 4: IPv6 allocation
// {
// const existing = ["2001:db8::/32", "2001:db8:1::/32"];
// const result = findNextAvailableCidr(existing, 32, "2001:db8::/16");
// assertEquals(result, "2001:db8:2::/32", "Basic IPv6 allocation failed");
// }
// // Test 5: Mixed IP versions
// {
// const existing = ["10.0.0.0/16", "2001:db8::/32"];
// assertThrows(
// () => findNextAvailableCidr(existing, 16),
// "All CIDRs must be of the same IP version",
// "Mixed IP versions test failed"
// );
// }
// Test 6: Empty input
{
const existing: string[] = [];
const result = findNextAvailableCidr(existing, 16);
assertEquals(result, null, "Empty input test failed");
}
// Test 7: Block size alignment
{
const existing = ["10.0.0.0/24"];
const result = findNextAvailableCidr(existing, 24, "10.0.0.0/16");
assertEquals(result, "10.0.1.0/24", "Block size alignment test failed");
}
// Test 8: Block size alignment
{
const existing: string[] = [];
const result = findNextAvailableCidr(existing, 24, "10.0.0.0/16");
assertEquals(result, "10.0.0.0/24", "Block size alignment test failed");
}
// Test 9: Large block size request
{
const existing = ["10.0.0.0/24", "10.0.1.0/24"];
const result = findNextAvailableCidr(existing, 16, "10.0.0.0/16");
assertEquals(result, null, "Large block size request test failed");
}
console.log("All findNextAvailableCidr tests passed!");
}
// function testCidrToRange() {
// console.log("Running cidrToRange tests...");
// // Test 1: Basic IPv4 conversion
// {
// const result = cidrToRange("192.168.0.0/24");
// assertEqualsObj(result, {
// start: BigInt("3232235520"),
// end: BigInt("3232235775")
// }, "Basic IPv4 conversion failed");
// }
// // Test 2: IPv6 conversion
// {
// const result = cidrToRange("2001:db8::/32");
// assertEqualsObj(result, {
// start: BigInt("42540766411282592856903984951653826560"),
// end: BigInt("42540766411282592875350729025363378175")
// }, "IPv6 conversion failed");
// }
// // Test 3: Invalid prefix length
// {
// assertThrows(
// () => cidrToRange("192.168.0.0/33"),
// "Invalid prefix length for IPv4",
// "Invalid IPv4 prefix test failed"
// );
// }
// // Test 4: Invalid IPv6 prefix
// {
// assertThrows(
// () => cidrToRange("2001:db8::/129"),
// "Invalid prefix length for IPv6",
// "Invalid IPv6 prefix test failed"
// );
// }
// console.log("All cidrToRange tests passed!");
// }
// Run all tests
try {
// testCidrToRange();
testFindNextAvailableCidr();
console.log("All tests passed successfully!");
} catch (error) {
console.error("Test failed:", error);
process.exit(1);
}

View file

@ -3,58 +3,162 @@ interface IPRange {
end: bigint;
}
type IPVersion = 4 | 6;
/**
* Converts IP address string to BigInt for numerical operations
* Detects IP version from address string
*/
function detectIpVersion(ip: string): IPVersion {
return ip.includes(':') ? 6 : 4;
}
/**
* Converts IPv4 or IPv6 address string to BigInt for numerical operations
*/
function ipToBigInt(ip: string): bigint {
return ip.split('.')
.reduce((acc, octet) => BigInt.asUintN(64, (acc << BigInt(8)) + BigInt(parseInt(octet))), BigInt(0));
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));
} 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(':');
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));
}
}
/**
* Converts BigInt to IP address string
*/
function bigIntToIp(num: bigint): string {
const octets: number[] = [];
for (let i = 0; i < 4; i++) {
octets.unshift(Number(num & BigInt(255)));
num = num >> BigInt(8);
function bigIntToIp(num: bigint, version: IPVersion): string {
if (version === 4) {
const octets: number[] = [];
for (let i = 0; i < 4; i++) {
octets.unshift(Number(num & BigInt(255)));
num = num >> BigInt(8);
}
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'));
num = num >> BigInt(16);
}
// Compress zero sequences
let maxZeroStart = -1;
let maxZeroLength = 0;
let currentZeroStart = -1;
let currentZeroLength = 0;
for (let i = 0; i < hextets.length; i++) {
if (hextets[i] === '0000') {
if (currentZeroStart === -1) currentZeroStart = i;
currentZeroLength++;
if (currentZeroLength > maxZeroLength) {
maxZeroLength = currentZeroLength;
maxZeroStart = currentZeroStart;
}
} else {
currentZeroStart = -1;
currentZeroLength = 0;
}
}
if (maxZeroLength > 1) {
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 octets.join('.');
}
/**
* Converts CIDR to IP range
*/
function cidrToRange(cidr: string): IPRange {
export function cidrToRange(cidr: string): IPRange {
const [ip, prefix] = cidr.split('/');
const version = detectIpVersion(ip);
const prefixBits = parseInt(prefix);
const ipBigInt = ipToBigInt(ip);
const mask = BigInt.asUintN(64, (BigInt(1) << BigInt(32 - prefixBits)) - BigInt(1));
// Validate prefix length
const maxPrefix = version === 4 ? 32 : 128;
if (prefixBits < 0 || prefixBits > maxPrefix) {
throw new Error(`Invalid prefix length for IPv${version}: ${prefix}`);
}
const shiftBits = BigInt(maxPrefix - prefixBits);
const mask = BigInt.asUintN(version === 4 ? 64 : 128, (BigInt(1) << shiftBits) - BigInt(1));
const start = ipBigInt & ~mask;
const end = start | mask;
return { start, end };
}
/**
* Finds the next available CIDR block given existing allocations
* @param existingCidrs Array of existing CIDR blocks
* @param blockSize Desired prefix length for the new block (e.g., 24 for /24)
* @param startCidr Optional CIDR to start searching from (default: "0.0.0.0/0")
* @param blockSize Desired prefix length for the new block
* @param startCidr Optional CIDR to start searching from
* @returns Next available CIDR block or null if none found
*/
export function findNextAvailableCidr(
existingCidrs: string[],
blockSize: number,
startCidr: string = "0.0.0.0/0"
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
// 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');
}
// Convert existing CIDRs to ranges and sort them
const existingRanges = existingCidrs
.map(cidr => cidrToRange(cidr))
.sort((a, b) => (a.start < b.start ? -1 : 1));
// Calculate block size
const blockSizeBigInt = BigInt(1) << BigInt(32 - blockSize);
const maxPrefix = version === 4 ? 32 : 128;
const blockSizeBigInt = BigInt(1) << BigInt(maxPrefix - blockSize);
// Start from the beginning of the given CIDR
let current = cidrToRange(startCidr).start;
@ -63,7 +167,6 @@ export function findNextAvailableCidr(
// 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);
@ -74,7 +177,7 @@ export function findNextAvailableCidr(
// If we're at the end of existing ranges or found a gap
if (!nextRange || alignedCurrent + blockSizeBigInt - BigInt(1) < nextRange.start) {
return `${bigIntToIp(alignedCurrent)}/${blockSize}`;
return `${bigIntToIp(alignedCurrent, version)}/${blockSize}`;
}
// Move current pointer to after the current range
@ -85,12 +188,19 @@ export function findNextAvailableCidr(
}
/**
* Checks if a given IP address is within a CIDR range
* @param ip IP address to check
* @param cidr CIDR range to check against
* @returns boolean indicating if IP is within the CIDR range
*/
* Checks if a given IP address is within a CIDR range
* @param ip IP address to check
* @param cidr CIDR range to check against
* @returns boolean indicating if IP is within the CIDR range
*/
export function isIpInCidr(ip: string, cidr: string): boolean {
const ipVersion = detectIpVersion(ip);
const cidrVersion = detectIpVersion(cidr.split('/')[0]);
if (ipVersion !== cidrVersion) {
throw new Error('IP address and CIDR must be of the same version');
}
const ipBigInt = ipToBigInt(ip);
const range = cidrToRange(cidr);
return ipBigInt >= range.start && ipBigInt <= range.end;

View file

@ -24,7 +24,7 @@ export function isValidUrlGlobPattern(pattern: string): boolean {
for (let i = 0; i < segments.length; i++) {
const segment = segments[i];
// Empty segments are not allowed (double slashes)
// Empty segments are not allowed (double slashes), except at the end
if (!segment && i !== segments.length - 1) {
return false;
}
@ -34,11 +34,63 @@ export function isValidUrlGlobPattern(pattern: string): boolean {
return false;
}
// Check for invalid characters
if (!/^[a-zA-Z0-9_*-]*$/.test(segment)) {
return false;
// Check each character in the segment
for (let j = 0; j < segment.length; j++) {
const char = segment[j];
// Check for percent-encoded sequences
if (char === "%" && j + 2 < segment.length) {
const hex1 = segment[j + 1];
const hex2 = segment[j + 2];
if (
!/^[0-9A-Fa-f]$/.test(hex1) ||
!/^[0-9A-Fa-f]$/.test(hex2)
) {
return false;
}
j += 2; // Skip the next two characters
continue;
}
// Allow:
// - unreserved (A-Z a-z 0-9 - . _ ~)
// - sub-delims (! $ & ' ( ) * + , ; =)
// - @ : for compatibility with some systems
if (!/^[A-Za-z0-9\-._~!$&'()*+,;=@:]$/.test(char)) {
return false;
}
}
}
return true;
}
export function isUrlValid(url: string | undefined) {
if (!url) return true; // the link is optional in the schema so if it's empty it's valid
var pattern = new RegExp(
"^(https?:\\/\\/)?" + // protocol
"((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|" + // domain name
"((\\d{1,3}\\.){3}\\d{1,3}))" + // OR ip (v4) address
"(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*" + // port and path
"(\\?[;&a-z\\d%_.~+=-]*)?" + // query string
"(\\#[-a-z\\d_]*)?$",
"i"
);
return !!pattern.test(url);
}
export function isTargetValid(value: string | undefined) {
if (!value) return true;
const DOMAIN_REGEX =
/^[a-zA-Z0-9_](?:[a-zA-Z0-9-_]{0,61}[a-zA-Z0-9_])?(?:\.[a-zA-Z0-9_](?:[a-zA-Z0-9-_]{0,61}[a-zA-Z0-9_])?)*$/;
const IPV4_REGEX =
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
const IPV6_REGEX = /^(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}$/i;
if (IPV4_REGEX.test(value) || IPV6_REGEX.test(value)) {
return true;
}
return DOMAIN_REGEX.test(value);
}

View file

@ -149,8 +149,6 @@ export async function resetPassword(
const passwordHash = await hashPassword(newPassword);
await invalidateAllSessions(resetRequest[0].userId);
await db.transaction(async (trx) => {
await trx
.update(users)
@ -162,11 +160,21 @@ export async function resetPassword(
.where(eq(passwordResetTokens.email, email));
});
await sendEmail(ConfirmPasswordReset({ email }), {
from: config.getNoReplyEmail(),
to: email,
subject: "Password Reset Confirmation"
});
try {
await invalidateAllSessions(resetRequest[0].userId);
} catch (e) {
logger.error("Failed to invalidate user sessions", e);
}
try {
await sendEmail(ConfirmPasswordReset({ email }), {
from: config.getNoReplyEmail(),
to: email,
subject: "Password Reset Confirmation"
});
} catch (e) {
logger.error("Failed to send password reset confirmation email", e);
}
return response<ResetPasswordResponse>(res, {
data: null,

View file

@ -90,7 +90,15 @@ export async function verifyResourceSession(
const clientIp = requestIp?.split(":")[0];
const resourceCacheKey = `resource:${host}`;
let cleanHost = host;
// if the host ends with :443 or :80 remove it
if (cleanHost.endsWith(":443")) {
cleanHost = cleanHost.slice(0, -4);
} else if (cleanHost.endsWith(":80")) {
cleanHost = cleanHost.slice(0, -3);
}
const resourceCacheKey = `resource:${cleanHost}`;
let resourceData:
| {
resource: Resource | null;
@ -111,11 +119,11 @@ export async function verifyResourceSession(
resourcePassword,
eq(resourcePassword.resourceId, resources.resourceId)
)
.where(eq(resources.fullDomain, host))
.where(eq(resources.fullDomain, cleanHost))
.limit(1);
if (!result) {
logger.debug("Resource not found", host);
logger.debug("Resource not found", cleanHost);
return notAllowed(res);
}
@ -131,7 +139,7 @@ export async function verifyResourceSession(
const { resource, pincode, password } = resourceData;
if (!resource) {
logger.debug("Resource not found", host);
logger.debug("Resource not found", cleanHost);
return notAllowed(res);
}

View file

@ -12,34 +12,7 @@ import { fromError } from "zod-validation-error";
import { addTargets } from "../newt/targets";
import { eq } from "drizzle-orm";
import { pickPort } from "./helpers";
// Regular expressions for validation
const DOMAIN_REGEX =
/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
const IPV4_REGEX =
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
const IPV6_REGEX = /^(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}$/i;
// Schema for domain names and IP addresses
const domainSchema = z
.string()
.min(1, "Domain cannot be empty")
.max(255, "Domain name too long")
.refine(
(value) => {
// Check if it's a valid IP address (v4 or v6)
if (IPV4_REGEX.test(value) || IPV6_REGEX.test(value)) {
return true;
}
// Check if it's a valid domain name
return DOMAIN_REGEX.test(value);
},
{
message: "Invalid domain name or IP address format",
path: ["domain"]
}
);
import { isTargetValid } from "@server/lib/validators";
const createTargetParamsSchema = z
.object({
@ -52,7 +25,7 @@ const createTargetParamsSchema = z
const createTargetSchema = z
.object({
ip: domainSchema,
ip: z.string().refine(isTargetValid),
method: z.string().optional().nullable(),
port: z.number().int().min(1).max(65535),
enabled: z.boolean().default(true)

View file

@ -11,34 +11,7 @@ import { fromError } from "zod-validation-error";
import { addPeer } from "../gerbil/peers";
import { addTargets } from "../newt/targets";
import { pickPort } from "./helpers";
// Regular expressions for validation
const DOMAIN_REGEX =
/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
const IPV4_REGEX =
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
const IPV6_REGEX = /^(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}$/i;
// Schema for domain names and IP addresses
const domainSchema = z
.string()
.min(1, "Domain cannot be empty")
.max(255, "Domain name too long")
.refine(
(value) => {
// Check if it's a valid IP address (v4 or v6)
if (IPV4_REGEX.test(value) || IPV6_REGEX.test(value)) {
return true;
}
// Check if it's a valid domain name
return DOMAIN_REGEX.test(value);
},
{
message: "Invalid domain name or IP address format",
path: ["domain"]
}
);
import { isTargetValid } from "@server/lib/validators";
const updateTargetParamsSchema = z
.object({
@ -48,7 +21,7 @@ const updateTargetParamsSchema = z
const updateTargetBodySchema = z
.object({
ip: domainSchema.optional(),
ip: z.string().refine(isTargetValid),
method: z.string().min(1).max(10).optional().nullable(),
port: z.number().int().min(1).max(65535).optional(),
enabled: z.boolean().optional()