Files
3proxyUI/server/lib/config.ts

275 lines
7.4 KiB
TypeScript

import path from 'node:path';
import { dashboardSnapshot } from '../../src/data/mockDashboard';
import type {
ControlPlaneState,
CreateUserInput,
DashboardSnapshot,
ProxyServiceRecord,
ProxyUserRecord,
} from '../../src/shared/contracts';
import type { ServiceState } from '../../src/lib/3proxy';
const MB = 1024 * 1024;
export interface RuntimeSnapshot {
status: Exclude<ServiceState, 'warn' | 'paused'>;
pid: number | null;
startedAt: string | null;
lastError: string | null;
}
export interface RuntimePaths {
rootDir: string;
configPath: string;
counterPath: string;
reportDir: string;
logPath: string;
pidPath: string;
}
export function createDefaultState(): ControlPlaneState {
return structuredClone(dashboardSnapshot);
}
export function buildRuntimePaths(rootDir: string): RuntimePaths {
return {
rootDir,
configPath: path.join(rootDir, 'generated', '3proxy.cfg'),
counterPath: path.join(rootDir, 'state', 'counters.3cf'),
reportDir: path.join(rootDir, 'state', 'reports'),
logPath: path.join(rootDir, 'logs', '3proxy.log'),
pidPath: path.join(rootDir, '3proxy.pid'),
};
}
export function validateCreateUserInput(
input: Partial<CreateUserInput>,
services: ProxyServiceRecord[],
): CreateUserInput {
const username = input.username?.trim() ?? '';
const password = input.password?.trim() ?? '';
const serviceId = input.serviceId?.trim() ?? '';
const quotaMb = input.quotaMb ?? null;
assertSafeToken(username, 'Username');
assertSafeToken(password, 'Password');
if (!serviceId) {
throw new Error('Service is required.');
}
const service = services.find((entry) => entry.id === serviceId);
if (!service || !service.enabled || !service.assignable) {
throw new Error('Service must reference an enabled assignable entry.');
}
if (quotaMb !== null && (!Number.isFinite(quotaMb) || quotaMb <= 0 || !Number.isInteger(quotaMb))) {
throw new Error('Quota must be a positive integer number of megabytes.');
}
return {
username,
password,
serviceId,
quotaMb,
};
}
export function createUserRecord(state: ControlPlaneState, input: CreateUserInput): ProxyUserRecord {
if (state.userRecords.some((user) => user.username === input.username)) {
throw new Error('Username already exists.');
}
return {
id: `u-${Math.random().toString(36).slice(2, 10)}`,
username: input.username,
password: input.password,
serviceId: input.serviceId,
status: 'idle',
paused: false,
usedBytes: 0,
quotaBytes: input.quotaMb === null ? null : input.quotaMb * MB,
};
}
export function render3proxyConfig(state: ControlPlaneState, paths: RuntimePaths): string {
const lines = [
`pidfile ${normalizePath(paths.pidPath)}`,
`monitor ${normalizePath(paths.configPath)}`,
`log ${normalizePath(paths.logPath)} D`,
'auth strong',
];
const activeUsers = state.userRecords.filter((user) => !user.paused);
if (activeUsers.length > 0) {
lines.push(`users ${activeUsers.map(renderUserCredential).join(' ')}`);
}
const quotaUsers = activeUsers.filter((user) => user.quotaBytes !== null);
if (quotaUsers.length > 0) {
lines.push(
`counter ${normalizePath(paths.counterPath)} D ${normalizePath(
path.join(paths.reportDir, '%Y-%m-%d.txt'),
)}`,
);
quotaUsers.forEach((user, index) => {
lines.push(
`countall ${index + 1} D ${Math.ceil((user.quotaBytes ?? 0) / MB)} ${user.username} * * * * * *`,
);
});
}
state.system.services
.filter((service) => service.enabled)
.forEach((service) => {
lines.push('', 'flush');
if (service.assignable) {
const usernames = activeUsers
.filter((user) => user.serviceId === service.id)
.map((user) => user.username);
lines.push(usernames.length > 0 ? `allow ${usernames.join(',')}` : 'deny *');
} else {
lines.push('allow *');
}
lines.push(renderServiceCommand(service));
});
return `${lines.join('\n')}\n`;
}
export function deriveDashboardSnapshot(
state: ControlPlaneState,
runtime: RuntimeSnapshot,
previewConfig: string,
): DashboardSnapshot {
const liveUsers = state.userRecords.filter((user) => !user.paused && user.status === 'live').length;
const exceededUsers = state.userRecords.filter(
(user) => user.quotaBytes !== null && user.usedBytes >= user.quotaBytes,
).length;
const nearQuotaUsers = state.userRecords.filter((user) => {
if (user.paused || user.quotaBytes === null || user.usedBytes >= user.quotaBytes) {
return false;
}
return user.usedBytes / user.quotaBytes >= 0.8;
}).length;
const attention = [];
if (exceededUsers > 0) {
attention.push({
level: 'fail' as const,
title: 'Quota exceeded',
message: `${exceededUsers} user profiles crossed their configured quota.`,
});
}
if (nearQuotaUsers > 0) {
attention.push({
level: 'warn' as const,
title: 'Quota pressure detected',
message: `${nearQuotaUsers} user profiles are above 80% of their quota.`,
});
}
if (runtime.status === 'live') {
attention.push({
level: 'live' as const,
title: '3proxy runtime online',
message: 'Backend control plane is currently attached to a live 3proxy process.',
});
} else if (runtime.lastError) {
attention.push({
level: 'fail' as const,
title: 'Runtime issue detected',
message: runtime.lastError,
});
}
if (attention.length === 0) {
attention.push({
level: 'live' as const,
title: 'State loaded',
message: 'No critical runtime or quota issues are currently detected.',
});
}
return {
service: {
status: runtime.status,
pidLabel: runtime.pid ? `pid ${runtime.pid}` : 'pid -',
versionLabel: state.service.versionLabel,
uptimeLabel: formatUptime(runtime.startedAt),
lastEvent: state.service.lastEvent,
},
traffic: {
...state.traffic,
activeUsers: state.userRecords.filter((user) => !user.paused).length,
},
users: {
total: state.userRecords.length,
live: liveUsers,
nearQuota: nearQuotaUsers,
exceeded: exceededUsers,
},
attention,
userRecords: state.userRecords,
system: {
...state.system,
previewConfig,
},
};
}
function renderUserCredential(user: ProxyUserRecord): string {
return `${user.username}:CL:${user.password}`;
}
function renderServiceCommand(service: ProxyServiceRecord): string {
if (service.command === 'admin') {
return `admin -p${service.port} -s`;
}
if (service.command === 'socks') {
return `socks -p${service.port} -u2`;
}
return `proxy -p${service.port}`;
}
function assertSafeToken(value: string, label: string): void {
if (!value) {
throw new Error(`${label} is required.`);
}
if (!/^[A-Za-z0-9._~!@#$%^&*+=:/-]+$/.test(value)) {
throw new Error(`${label} contains unsupported characters.`);
}
}
function normalizePath(value: string): string {
return value.replace(/\\/g, '/');
}
function formatUptime(startedAt: string | null): string {
if (!startedAt) {
return 'uptime -';
}
const started = new Date(startedAt).getTime();
const diffMs = Date.now() - started;
if (!Number.isFinite(diffMs) || diffMs <= 0) {
return 'uptime 0m';
}
const hours = Math.floor(diffMs / (1000 * 60 * 60));
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
return `uptime ${hours}h ${minutes}m`;
}