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

92
server/app.test.ts Normal file
View File

@@ -0,0 +1,92 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import request from 'supertest';
import { afterEach, describe, expect, it } from 'vitest';
import { createApp } from './app';
import type { RuntimeSnapshot } from './lib/config';
import type { RuntimeController } from './lib/runtime';
import { StateStore } from './lib/store';
class FakeRuntime implements RuntimeController {
private status: RuntimeSnapshot = {
status: 'idle' as const,
pid: null,
startedAt: null,
lastError: null,
};
getSnapshot() {
return { ...this.status };
}
async start() {
this.status = {
status: 'live',
pid: 999,
startedAt: new Date('2026-04-01T00:00:00.000Z').toISOString(),
lastError: null,
};
return this.getSnapshot();
}
async restart() {
return this.start();
}
async reload() {
return this.getSnapshot();
}
}
const cleanupDirs: string[] = [];
afterEach(async () => {
await Promise.all(cleanupDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
});
describe('panel api', () => {
it('rejects user creation against a non-assignable service', async () => {
const app = await createTestApp();
const response = await request(app).post('/api/users').send({
username: 'bad-admin-user',
password: 'secret123',
serviceId: 'admin',
quotaMb: 100,
});
expect(response.status).toBe(400);
expect(response.body.error).toMatch(/enabled assignable/i);
});
it('pauses and deletes a user through the api', async () => {
const app = await createTestApp();
const initial = await request(app).get('/api/state');
const userId = initial.body.userRecords[0].id;
const username = initial.body.userRecords[0].username;
const paused = await request(app).post(`/api/users/${userId}/pause`);
expect(paused.status).toBe(200);
expect(paused.body.userRecords.find((entry: { id: string }) => entry.id === userId).paused).toBe(true);
const removed = await request(app).delete(`/api/users/${userId}`);
expect(removed.status).toBe(200);
expect(removed.body.userRecords.some((entry: { username: string }) => entry.username === username)).toBe(
false,
);
});
});
async function createTestApp() {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), '3proxy-ui-'));
cleanupDirs.push(dir);
const runtime = new FakeRuntime();
const store = new StateStore(path.join(dir, 'state', 'panel-state.json'));
return createApp({
store,
runtime,
runtimeRootDir: dir,
});
}

179
server/app.ts Normal file
View File

@@ -0,0 +1,179 @@
import express, { type Request, type Response } from 'express';
import fs from 'node:fs/promises';
import path from 'node:path';
import type { ControlPlaneState, CreateUserInput } from '../src/shared/contracts';
import {
buildRuntimePaths,
createUserRecord,
deriveDashboardSnapshot,
render3proxyConfig,
validateCreateUserInput,
type RuntimePaths,
} from './lib/config';
import type { RuntimeController } from './lib/runtime';
import { StateStore } from './lib/store';
export interface AppServices {
store: StateStore;
runtime: RuntimeController;
runtimeRootDir: string;
}
export function createApp({ store, runtime, runtimeRootDir }: AppServices) {
const app = express();
const runtimePaths = buildRuntimePaths(runtimeRootDir);
const distDir = path.resolve('dist');
app.use(express.json());
app.use(express.static(distDir));
app.get('/api/health', async (_request, response) => {
const state = await store.read();
const previewConfig = render3proxyConfig(state, runtimePaths);
response.json({
ok: true,
runtime: runtime.getSnapshot(),
users: state.userRecords.length,
configBytes: Buffer.byteLength(previewConfig),
});
});
app.get('/api/state', async (_request, response, next) => {
try {
const payload = await getSnapshot(store, runtime, runtimePaths);
response.json(payload);
} catch (error) {
next(error);
}
});
app.post('/api/runtime/:action', async (request, response, next) => {
try {
const state = await store.read();
const action = request.params.action;
const controller = action === 'restart' ? runtime.restart.bind(runtime) : runtime.start.bind(runtime);
if (!['start', 'restart'].includes(action)) {
response.status(404).json({ error: 'Unknown runtime action.' });
return;
}
const runtimeSnapshot = await controller();
state.service.lastEvent = action === 'restart' ? 'Runtime restarted from panel' : 'Runtime start requested from panel';
if (runtimeSnapshot.startedAt) {
state.service.startedAt = runtimeSnapshot.startedAt;
}
await writeConfigAndState(store, state, runtimePaths);
response.json(await getSnapshot(store, runtime, runtimePaths));
} catch (error) {
next(error);
}
});
app.post('/api/users', async (request, response, next) => {
try {
const state = await store.read();
const input = validateCreateUserInput(request.body as Partial<CreateUserInput>, state.system.services);
const record = createUserRecord(state, input);
state.userRecords.push(record);
state.service.lastEvent = `User ${record.username} created from panel`;
await persistRuntimeMutation(store, runtime, state, runtimePaths);
response.status(201).json(await getSnapshot(store, runtime, runtimePaths));
} catch (error) {
next(error);
}
});
app.post('/api/users/:id/pause', async (request, response, next) => {
try {
const state = await store.read();
const user = state.userRecords.find((entry) => entry.id === request.params.id);
if (!user) {
response.status(404).json({ error: 'User not found.' });
return;
}
user.paused = !user.paused;
state.service.lastEvent = user.paused
? `User ${user.username} paused from panel`
: `User ${user.username} resumed from panel`;
await persistRuntimeMutation(store, runtime, state, runtimePaths);
response.json(await getSnapshot(store, runtime, runtimePaths));
} catch (error) {
next(error);
}
});
app.delete('/api/users/:id', async (request, response, next) => {
try {
const state = await store.read();
const index = state.userRecords.findIndex((entry) => entry.id === request.params.id);
if (index === -1) {
response.status(404).json({ error: 'User not found.' });
return;
}
const [removed] = state.userRecords.splice(index, 1);
state.service.lastEvent = `User ${removed.username} deleted from panel`;
await persistRuntimeMutation(store, runtime, state, runtimePaths);
response.json(await getSnapshot(store, runtime, runtimePaths));
} catch (error) {
next(error);
}
});
app.use(async (_request, response) => {
const distPath = path.join(distDir, 'index.html');
try {
const html = await fs.readFile(distPath, 'utf8');
response.type('html').send(html);
} catch {
response.status(404).send('Frontend build not found.');
}
});
app.use((error: unknown, _request: Request, response: Response, _next: express.NextFunction) => {
response.status(400).json({
error: error instanceof Error ? error.message : 'Unknown server error.',
});
});
return app;
}
async function getSnapshot(
store: StateStore,
runtime: RuntimeController,
runtimePaths: RuntimePaths,
) {
const state = await store.read();
const previewConfig = render3proxyConfig(state, runtimePaths);
return deriveDashboardSnapshot(state, runtime.getSnapshot(), previewConfig);
}
async function persistRuntimeMutation(
store: StateStore,
runtime: RuntimeController,
state: ControlPlaneState,
runtimePaths: RuntimePaths,
) {
await writeConfigAndState(store, state, runtimePaths);
await runtime.reload();
}
async function writeConfigAndState(
store: StateStore,
state: ControlPlaneState,
runtimePaths: RuntimePaths,
) {
const config = render3proxyConfig(state, runtimePaths);
await fs.mkdir(path.dirname(runtimePaths.configPath), { recursive: true });
await fs.mkdir(path.dirname(runtimePaths.logPath), { recursive: true });
await fs.mkdir(runtimePaths.reportDir, { recursive: true });
await fs.writeFile(runtimePaths.configPath, config, 'utf8');
await store.write(state);
}

32
server/index.ts Normal file
View File

@@ -0,0 +1,32 @@
import path from 'node:path';
import fs from 'node:fs/promises';
import { createApp } from './app';
import { buildRuntimePaths, render3proxyConfig } from './lib/config';
import { ThreeProxyManager } from './lib/runtime';
import { StateStore } from './lib/store';
const port = Number(process.env.PORT ?? '3000');
const runtimeRootDir = path.resolve(process.env.RUNTIME_DIR ?? 'runtime');
const statePath = path.join(runtimeRootDir, 'state', 'panel-state.json');
const binaryPath = path.resolve(process.env.THREEPROXY_BINARY ?? '/usr/local/bin/3proxy');
const autoStart = process.env.AUTO_START_3PROXY === 'true';
const store = new StateStore(statePath);
const runtimePaths = buildRuntimePaths(runtimeRootDir);
const runtime = new ThreeProxyManager(binaryPath, runtimePaths.configPath, runtimePaths, autoStart);
async function main() {
const initialState = await store.read();
await fs.mkdir(path.dirname(runtimePaths.configPath), { recursive: true });
await fs.writeFile(runtimePaths.configPath, render3proxyConfig(initialState, runtimePaths), 'utf8');
await runtime.initialize();
const app = createApp({ store, runtime, runtimeRootDir });
app.listen(port, () => {
console.log(`Panel server listening on http://0.0.0.0:${port}`);
});
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});

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');
}
}