refractor code to allow more tests
This commit is contained in:
203
src/lib/caddy-utils.ts
Normal file
203
src/lib/caddy-utils.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Pure utility functions extracted from caddy.ts.
|
||||
* No DB, network, or filesystem dependencies — safe to unit-test directly.
|
||||
*/
|
||||
import { isIP } from "node:net";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private range expansion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const PRIVATE_RANGES_CIDRS = [
|
||||
"10.0.0.0/8",
|
||||
"172.16.0.0/12",
|
||||
"192.168.0.0/16",
|
||||
"127.0.0.0/8",
|
||||
"fd00::/8",
|
||||
"::1/128",
|
||||
];
|
||||
|
||||
export function expandPrivateRanges(proxies: string[]): string[] {
|
||||
if (!proxies.includes("private_ranges")) return proxies;
|
||||
return proxies.flatMap((p) => (p === "private_ranges" ? PRIVATE_RANGES_CIDRS : [p]));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Type helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Deep merge (prototype-pollution safe)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function mergeDeep(
|
||||
target: Record<string, unknown>,
|
||||
source: Record<string, unknown>
|
||||
) {
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
if (key === "__proto__" || key === "constructor" || key === "prototype") {
|
||||
continue;
|
||||
}
|
||||
const existing = target[key];
|
||||
if (isPlainObject(existing) && isPlainObject(value)) {
|
||||
mergeDeep(existing, value);
|
||||
} else {
|
||||
target[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function parseJson<T>(value: string | null, fallback: T): T {
|
||||
if (!value) return fallback;
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch (error) {
|
||||
console.warn("Failed to parse JSON value", value, error);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseOptionalJson(value: string | null | undefined) {
|
||||
if (!value) return null;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (error) {
|
||||
console.warn("Failed to parse custom JSON", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseCustomHandlers(
|
||||
value: string | null | undefined
|
||||
): Record<string, unknown>[] {
|
||||
const parsed = parseOptionalJson(value);
|
||||
if (!parsed) return [];
|
||||
const list = Array.isArray(parsed) ? parsed : [parsed];
|
||||
const handlers: Record<string, unknown>[] = [];
|
||||
for (const item of list) {
|
||||
if (isPlainObject(item)) {
|
||||
handlers.push(item);
|
||||
} else {
|
||||
console.warn("Ignoring custom handler entry that is not an object", item);
|
||||
}
|
||||
}
|
||||
return handlers;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Address / upstream parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function formatDialAddress(host: string, port: string) {
|
||||
return isIP(host) === 6 ? `[${host}]:${port}` : `${host}:${port}`;
|
||||
}
|
||||
|
||||
export function parseHostPort(
|
||||
value: string
|
||||
): { host: string; port: string } | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
if (trimmed.startsWith("[")) {
|
||||
const closeIndex = trimmed.indexOf("]");
|
||||
if (closeIndex <= 1) return null;
|
||||
const host = trimmed.slice(1, closeIndex);
|
||||
const remainder = trimmed.slice(closeIndex + 1);
|
||||
if (!remainder.startsWith(":")) return null;
|
||||
const port = remainder.slice(1).trim();
|
||||
if (!port) return null;
|
||||
return { host, port };
|
||||
}
|
||||
|
||||
const firstColon = trimmed.indexOf(":");
|
||||
const lastColon = trimmed.lastIndexOf(":");
|
||||
if (firstColon === -1 || firstColon !== lastColon) return null;
|
||||
|
||||
const host = trimmed.slice(0, lastColon).trim();
|
||||
const port = trimmed.slice(lastColon + 1).trim();
|
||||
if (!host || !port) return null;
|
||||
|
||||
return { host, port };
|
||||
}
|
||||
|
||||
export type ParsedUpstreamTarget = {
|
||||
original: string;
|
||||
dial: string;
|
||||
scheme: "http" | "https" | null;
|
||||
host: string | null;
|
||||
port: string | null;
|
||||
};
|
||||
|
||||
export function parseUpstreamTarget(upstream: string): ParsedUpstreamTarget {
|
||||
const trimmed = upstream.trim();
|
||||
if (!trimmed) {
|
||||
return { original: upstream, dial: upstream, scheme: null, host: null, port: null };
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
if (url.protocol === "http:" || url.protocol === "https:") {
|
||||
const scheme = url.protocol === "https:" ? "https" : "http";
|
||||
const port = url.port || (scheme === "https" ? "443" : "80");
|
||||
const host = url.hostname;
|
||||
return { original: trimmed, dial: formatDialAddress(host, port), scheme, host, port };
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
|
||||
const parsed = parseHostPort(trimmed);
|
||||
if (!parsed) {
|
||||
return { original: trimmed, dial: trimmed, scheme: null, host: null, port: null };
|
||||
}
|
||||
|
||||
return {
|
||||
original: trimmed,
|
||||
dial: formatDialAddress(parsed.host, parsed.port),
|
||||
scheme: null,
|
||||
host: parsed.host,
|
||||
port: parsed.port,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Duration parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function toDurationMs(value: string | null | undefined): number | null {
|
||||
if (!value) return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const regex = /(\d+(?:\.\d+)?)(ms|s|m|h)/g;
|
||||
let total = 0;
|
||||
let matched = false;
|
||||
let consumed = 0;
|
||||
|
||||
while (true) {
|
||||
const match = regex.exec(trimmed);
|
||||
if (!match) break;
|
||||
matched = true;
|
||||
consumed += match[0].length;
|
||||
const valueNum = Number.parseFloat(match[1]);
|
||||
if (!Number.isFinite(valueNum)) return null;
|
||||
const unit = match[2];
|
||||
if (unit === "ms") total += valueNum;
|
||||
else if (unit === "s") total += valueNum * 1000;
|
||||
else if (unit === "m") total += valueNum * 60_000;
|
||||
else if (unit === "h") total += valueNum * 3_600_000;
|
||||
}
|
||||
|
||||
if (!matched || consumed !== trimmed.length) return null;
|
||||
|
||||
const rounded = Math.round(total);
|
||||
return rounded > 0 ? rounded : null;
|
||||
}
|
||||
240
src/lib/caddy.ts
240
src/lib/caddy.ts
@@ -3,6 +3,20 @@ import { Resolver } from "node:dns/promises";
|
||||
import { join } from "node:path";
|
||||
import { isIP } from "node:net";
|
||||
import crypto from "node:crypto";
|
||||
import {
|
||||
PRIVATE_RANGES_CIDRS,
|
||||
expandPrivateRanges,
|
||||
isPlainObject,
|
||||
mergeDeep,
|
||||
parseJson,
|
||||
parseOptionalJson,
|
||||
parseCustomHandlers,
|
||||
formatDialAddress,
|
||||
parseHostPort,
|
||||
parseUpstreamTarget,
|
||||
toDurationMs,
|
||||
type ParsedUpstreamTarget,
|
||||
} from "./caddy-utils";
|
||||
import http from "node:http";
|
||||
import https from "node:https";
|
||||
import db, { nowIso } from "./db";
|
||||
@@ -54,21 +68,6 @@ const DEFAULT_AUTHENTIK_HEADERS = [
|
||||
|
||||
const DEFAULT_AUTHENTIK_TRUSTED_PROXIES = ["private_ranges"];
|
||||
|
||||
// The caddy-blocker-plugin accepts only literal IP/CIDR strings, not Caddy's
|
||||
// "private_ranges" shorthand. Expand it before building the blocker config.
|
||||
const PRIVATE_RANGES_CIDRS = [
|
||||
"10.0.0.0/8",
|
||||
"172.16.0.0/12",
|
||||
"192.168.0.0/16",
|
||||
"127.0.0.0/8",
|
||||
"fd00::/8",
|
||||
"::1/128"
|
||||
];
|
||||
|
||||
function expandPrivateRanges(proxies: string[]): string[] {
|
||||
if (!proxies.includes("private_ranges")) return proxies;
|
||||
return proxies.flatMap((p) => (p === "private_ranges" ? PRIVATE_RANGES_CIDRS : [p]));
|
||||
}
|
||||
|
||||
type ProxyHostRow = {
|
||||
id: number;
|
||||
@@ -215,80 +214,8 @@ type CertificateUsage = {
|
||||
domains: Set<string>;
|
||||
};
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function parseJson<T>(value: string | null, fallback: T): T {
|
||||
if (!value) {
|
||||
return fallback;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch (error) {
|
||||
console.warn("Failed to parse JSON value", value, error);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function parseOptionalJson(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (error) {
|
||||
console.warn("Failed to parse custom JSON", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function mergeDeep(target: Record<string, unknown>, source: Record<string, unknown>) {
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
// Block prototype-polluting keys
|
||||
if (
|
||||
key === "__proto__" ||
|
||||
key === "constructor" ||
|
||||
key === "prototype"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const existing = target[key];
|
||||
if (isPlainObject(existing) && isPlainObject(value)) {
|
||||
mergeDeep(existing, value);
|
||||
} else {
|
||||
target[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseCustomHandlers(value: string | null | undefined): Record<string, unknown>[] {
|
||||
const parsed = parseOptionalJson(value);
|
||||
if (!parsed) {
|
||||
return [];
|
||||
}
|
||||
const list = Array.isArray(parsed) ? parsed : [parsed];
|
||||
const handlers: Record<string, unknown>[] = [];
|
||||
for (const item of list) {
|
||||
if (isPlainObject(item)) {
|
||||
handlers.push(item);
|
||||
} else {
|
||||
console.warn("Ignoring custom handler entry that is not an object", item);
|
||||
}
|
||||
}
|
||||
return handlers;
|
||||
}
|
||||
|
||||
const VALID_UPSTREAM_DNS_FAMILIES: UpstreamDnsAddressFamily[] = ["ipv6", "ipv4", "both"];
|
||||
|
||||
type ParsedUpstreamTarget = {
|
||||
original: string;
|
||||
dial: string;
|
||||
scheme: "http" | "https" | null;
|
||||
host: string | null;
|
||||
port: string | null;
|
||||
};
|
||||
|
||||
type UpstreamDnsResolutionRouteConfig = {
|
||||
enabled: boolean | null;
|
||||
family: UpstreamDnsAddressFamily | null;
|
||||
@@ -299,145 +226,6 @@ type EffectiveUpstreamDnsResolution = {
|
||||
family: UpstreamDnsAddressFamily;
|
||||
};
|
||||
|
||||
function formatDialAddress(host: string, port: string) {
|
||||
return isIP(host) === 6 ? `[${host}]:${port}` : `${host}:${port}`;
|
||||
}
|
||||
|
||||
function parseHostPort(value: string): { host: string; port: string } | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("[")) {
|
||||
const closeIndex = trimmed.indexOf("]");
|
||||
if (closeIndex <= 1) {
|
||||
return null;
|
||||
}
|
||||
const host = trimmed.slice(1, closeIndex);
|
||||
const remainder = trimmed.slice(closeIndex + 1);
|
||||
if (!remainder.startsWith(":")) {
|
||||
return null;
|
||||
}
|
||||
const port = remainder.slice(1).trim();
|
||||
if (!port) {
|
||||
return null;
|
||||
}
|
||||
return { host, port };
|
||||
}
|
||||
|
||||
const firstColon = trimmed.indexOf(":");
|
||||
const lastColon = trimmed.lastIndexOf(":");
|
||||
if (firstColon === -1 || firstColon !== lastColon) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const host = trimmed.slice(0, lastColon).trim();
|
||||
const port = trimmed.slice(lastColon + 1).trim();
|
||||
if (!host || !port) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { host, port };
|
||||
}
|
||||
|
||||
function parseUpstreamTarget(upstream: string): ParsedUpstreamTarget {
|
||||
const trimmed = upstream.trim();
|
||||
if (!trimmed) {
|
||||
return {
|
||||
original: upstream,
|
||||
dial: upstream,
|
||||
scheme: null,
|
||||
host: null,
|
||||
port: null
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
if (url.protocol === "http:" || url.protocol === "https:") {
|
||||
const scheme = url.protocol === "https:" ? "https" : "http";
|
||||
const port = url.port || (scheme === "https" ? "443" : "80");
|
||||
const host = url.hostname;
|
||||
return {
|
||||
original: trimmed,
|
||||
dial: formatDialAddress(host, port),
|
||||
scheme,
|
||||
host,
|
||||
port
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Ignore and parse as host:port below.
|
||||
}
|
||||
|
||||
const parsed = parseHostPort(trimmed);
|
||||
if (!parsed) {
|
||||
return {
|
||||
original: trimmed,
|
||||
dial: trimmed,
|
||||
scheme: null,
|
||||
host: null,
|
||||
port: null
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
original: trimmed,
|
||||
dial: formatDialAddress(parsed.host, parsed.port),
|
||||
scheme: null,
|
||||
host: parsed.host,
|
||||
port: parsed.port
|
||||
};
|
||||
}
|
||||
|
||||
function toDurationMs(value: string | null | undefined): number | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const regex = /(\d+(?:\.\d+)?)(ms|s|m|h)/g;
|
||||
let total = 0;
|
||||
let matched = false;
|
||||
let consumed = 0;
|
||||
|
||||
while (true) {
|
||||
const match = regex.exec(trimmed);
|
||||
if (!match) {
|
||||
break;
|
||||
}
|
||||
|
||||
matched = true;
|
||||
consumed += match[0].length;
|
||||
const valueNum = Number.parseFloat(match[1]);
|
||||
if (!Number.isFinite(valueNum)) {
|
||||
return null;
|
||||
}
|
||||
const unit = match[2];
|
||||
if (unit === "ms") {
|
||||
total += valueNum;
|
||||
} else if (unit === "s") {
|
||||
total += valueNum * 1000;
|
||||
} else if (unit === "m") {
|
||||
total += valueNum * 60_000;
|
||||
} else if (unit === "h") {
|
||||
total += valueNum * 3_600_000;
|
||||
}
|
||||
}
|
||||
|
||||
if (!matched || consumed !== trimmed.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rounded = Math.round(total);
|
||||
return rounded > 0 ? rounded : null;
|
||||
}
|
||||
|
||||
function parseUpstreamDnsResolutionConfig(
|
||||
meta: UpstreamDnsResolutionMeta | undefined | null
|
||||
): UpstreamDnsResolutionRouteConfig | null {
|
||||
|
||||
61
src/lib/form-parse.ts
Normal file
61
src/lib/form-parse.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Pure FormData parsing utilities extracted from proxy-hosts/actions.ts.
|
||||
* No DB or network dependencies — safe to unit-test directly.
|
||||
*/
|
||||
|
||||
export function parseCsv(value: FormDataEntryValue | null): string[] {
|
||||
if (!value || typeof value !== "string") return [];
|
||||
return value
|
||||
.replace(/\n/g, ",")
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/** Parse upstreams by newline only (URLs may contain commas in query strings). */
|
||||
export function parseUpstreams(value: FormDataEntryValue | null): string[] {
|
||||
if (!value || typeof value !== "string") return [];
|
||||
return value
|
||||
.split("\n")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function parseCheckbox(value: FormDataEntryValue | null): boolean {
|
||||
return value === "on" || value === "true" || value === "1";
|
||||
}
|
||||
|
||||
export function parseOptionalText(value: FormDataEntryValue | null): string | null {
|
||||
if (!value || typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
export function parseCertificateId(value: FormDataEntryValue | null): number | null {
|
||||
if (!value || value === "") return null;
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === "" || trimmed === "null" || trimmed === "undefined") return null;
|
||||
const num = Number(trimmed);
|
||||
if (!Number.isFinite(num) || !Number.isInteger(num) || num <= 0) return null;
|
||||
return num;
|
||||
}
|
||||
|
||||
export function parseAccessListId(value: FormDataEntryValue | null): number | null {
|
||||
if (!value || value === "") return null;
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === "" || trimmed === "null" || trimmed === "undefined") return null;
|
||||
const num = Number(trimmed);
|
||||
if (!Number.isFinite(num) || !Number.isInteger(num) || num <= 0) return null;
|
||||
return num;
|
||||
}
|
||||
|
||||
export function parseOptionalNumber(value: FormDataEntryValue | null): number | null {
|
||||
if (!value || typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === "") return null;
|
||||
const num = Number(trimmed);
|
||||
if (!Number.isFinite(num)) return null;
|
||||
return num;
|
||||
}
|
||||
Reference in New Issue
Block a user