feat: add dockerized 3proxy control plane backend

This commit is contained in:
2026-04-02 00:06:26 +03:00
parent ff2bc8711b
commit 25f6beedd8
20 changed files with 3076 additions and 174 deletions

39
server/lib/config.test.ts Normal file
View File

@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest';
import { createDefaultState, render3proxyConfig } from './config';
describe('render3proxyConfig', () => {
it('renders enabled services with their own ports and per-service ACLs', () => {
const state = createDefaultState();
const config = render3proxyConfig(state, {
rootDir: '/runtime',
configPath: '/runtime/generated/3proxy.cfg',
counterPath: '/runtime/state/counters.3cf',
reportDir: '/runtime/state/reports',
logPath: '/runtime/logs/3proxy.log',
pidPath: '/runtime/3proxy.pid',
});
expect(config).toContain('socks -p1080 -u2');
expect(config).toContain('socks -p2080 -u2');
expect(config).toContain('admin -p8081 -s');
expect(config).toContain('allow night-shift,ops-east');
expect(config).toContain('allow lab-unlimited,burst-user');
});
it('excludes paused users from credentials and ACLs', () => {
const state = createDefaultState();
state.userRecords[0].paused = true;
const config = render3proxyConfig(state, {
rootDir: '/runtime',
configPath: '/runtime/generated/3proxy.cfg',
counterPath: '/runtime/state/counters.3cf',
reportDir: '/runtime/state/reports',
logPath: '/runtime/logs/3proxy.log',
pidPath: '/runtime/3proxy.pid',
});
expect(config).not.toContain('night-shift:CL:kettle!23');
expect(config).not.toContain('allow night-shift,ops-east');
expect(config).toContain('allow ops-east');
});
});

274
server/lib/config.ts Normal file
View File

@@ -0,0 +1,274 @@
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`;
}

117
server/lib/runtime.ts Normal file
View File

@@ -0,0 +1,117 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { spawn, type ChildProcess } from 'node:child_process';
import type { RuntimePaths, RuntimeSnapshot } from './config';
export interface RuntimeController {
getSnapshot(): RuntimeSnapshot;
start(): Promise<RuntimeSnapshot>;
restart(): Promise<RuntimeSnapshot>;
reload(): Promise<RuntimeSnapshot>;
}
export class ThreeProxyManager implements RuntimeController {
private child: ChildProcess | null = null;
private snapshot: RuntimeSnapshot = {
status: 'idle',
pid: null,
startedAt: null,
lastError: null,
};
constructor(
private readonly binaryPath: string,
private readonly configPath: string,
private readonly paths: RuntimePaths,
private readonly autoStart: boolean,
) {}
async initialize(): Promise<void> {
await fs.mkdir(path.dirname(this.paths.configPath), { recursive: true });
await fs.mkdir(path.dirname(this.paths.counterPath), { recursive: true });
await fs.mkdir(this.paths.reportDir, { recursive: true });
await fs.mkdir(path.dirname(this.paths.logPath), { recursive: true });
if (this.autoStart) {
await this.start();
}
}
getSnapshot(): RuntimeSnapshot {
return { ...this.snapshot };
}
async start(): Promise<RuntimeSnapshot> {
if (this.child && this.child.exitCode === null) {
return this.getSnapshot();
}
await ensureBinaryExists(this.binaryPath);
const child = spawn(this.binaryPath, [this.configPath], {
stdio: ['ignore', 'pipe', 'pipe'],
});
this.child = child;
this.snapshot = {
status: 'live',
pid: child.pid ?? null,
startedAt: new Date().toISOString(),
lastError: null,
};
child.stdout.on('data', (chunk) => process.stdout.write(`[3proxy] ${chunk}`));
child.stderr.on('data', (chunk) => process.stderr.write(`[3proxy] ${chunk}`));
child.on('exit', (code, signal) => {
this.child = null;
this.snapshot = {
status: code === 0 || signal === 'SIGTERM' ? 'idle' : 'fail',
pid: null,
startedAt: null,
lastError:
code === 0 || signal === 'SIGTERM'
? null
: `3proxy exited unexpectedly (code=${code ?? 'null'}, signal=${signal ?? 'null'}).`,
};
});
return this.getSnapshot();
}
async restart(): Promise<RuntimeSnapshot> {
await this.stop();
return this.start();
}
async reload(): Promise<RuntimeSnapshot> {
if (!this.child || this.child.exitCode !== null || !this.child.pid) {
return this.start();
}
process.kill(this.child.pid, 'SIGUSR1');
return this.getSnapshot();
}
private async stop(): Promise<void> {
if (!this.child || this.child.exitCode !== null || !this.child.pid) {
this.child = null;
return;
}
const current = this.child;
const waitForExit = new Promise<void>((resolve) => {
current.once('exit', () => resolve());
});
current.kill('SIGTERM');
await waitForExit;
}
}
async function ensureBinaryExists(binaryPath: string): Promise<void> {
try {
await fs.access(binaryPath);
} catch {
throw new Error(`3proxy binary not found at ${binaryPath}.`);
}
}

30
server/lib/store.ts Normal file
View File

@@ -0,0 +1,30 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import type { ControlPlaneState } from '../../src/shared/contracts';
import { createDefaultState } from './config';
export class StateStore {
constructor(private readonly statePath: string) {}
async read(): Promise<ControlPlaneState> {
await fs.mkdir(path.dirname(this.statePath), { recursive: true });
try {
const raw = await fs.readFile(this.statePath, 'utf8');
return JSON.parse(raw) as ControlPlaneState;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error;
}
const fallback = createDefaultState();
await this.write(fallback);
return fallback;
}
}
async write(state: ControlPlaneState): Promise<void> {
await fs.mkdir(path.dirname(this.statePath), { recursive: true });
await fs.writeFile(this.statePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
}
}