feat: add dockerized 3proxy control plane backend
This commit is contained in:
92
server/app.test.ts
Normal file
92
server/app.test.ts
Normal 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
179
server/app.ts
Normal 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
32
server/index.ts
Normal 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
39
server/lib/config.test.ts
Normal 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
274
server/lib/config.ts
Normal 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
117
server/lib/runtime.ts
Normal 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
30
server/lib/store.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user