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

13
.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
.git
.gitignore
.codex
node_modules
dist
server-dist
docs
*.md
coverage
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*

1
.gitignore vendored
View File

@@ -9,6 +9,7 @@ lerna-debug.log*
node_modules node_modules
dist dist
server-dist
dist-ssr dist-ssr
*.local *.local

48
Dockerfile Normal file
View File

@@ -0,0 +1,48 @@
FROM node:22-bookworm-slim AS app-build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install
COPY tsconfig.json tsconfig.app.json tsconfig.node.json tsconfig.server.json vite.config.ts index.html ./
COPY public ./public
COPY src ./src
COPY server ./server
RUN npm run build
FROM debian:bookworm-slim AS proxy-build
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential ca-certificates git \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /tmp
RUN git clone --depth=1 https://github.com/3proxy/3proxy.git
WORKDIR /tmp/3proxy
RUN ln -s Makefile.Linux Makefile && make
FROM node:22-bookworm-slim
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV AUTO_START_3PROXY=true
ENV THREEPROXY_BINARY=/usr/local/bin/3proxy
ENV RUNTIME_DIR=/app/runtime
COPY --from=app-build /app/dist ./dist
COPY --from=app-build /app/server-dist ./server-dist
COPY --from=proxy-build /tmp/3proxy/bin/3proxy /usr/local/bin/3proxy
RUN mkdir -p /app/runtime/generated /app/runtime/state/reports /app/runtime/logs
EXPOSE 3000 1080 2080 3128 8081
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:3000/api/health').then((r)=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"
CMD ["node", "server-dist/index.cjs"]

View File

@@ -4,29 +4,49 @@ Control panel and runtime bundle for 3proxy in Docker.
## Current focus ## Current focus
The first delivered slice is the UI foundation: The project now includes both the UI and the first backend/runtime slice:
- login screen with hardcoded panel credentials - Express-based control plane API
- dashboard, users, and system views - generated `3proxy.cfg` from persisted panel state
- domain utilities and tests for quota, status, and proxy-link behavior - runtime manager for start/restart/reload
- project workflow docs for autonomous delivery - Docker image that builds the panel and compiles 3proxy in-container
- panel views for dashboard, users, and system
- edge-case-focused frontend and backend tests
## Local run ## Local run
```bash ```bash
npm install npm install
npm run dev npm run dev
npm run dev:server
``` ```
Default panel credentials for the current UI prototype: Default panel credentials:
- login: `admin` - login: `admin`
- password: `proxy-ui-demo` - password: `proxy-ui-demo`
## Docker run
```bash
docker compose up --build
```
Published ports:
- panel: `3000`
- socks main: `1080`
- socks lab: `2080`
- http proxy: `3128`
- 3proxy admin: `8081`
Runtime state is persisted in the Docker volume `3proxyui_3proxy-runtime`.
## Scripts ## Scripts
```bash ```bash
npm run dev npm run dev
npm run dev:server
npm run build npm run build
npm run test npm run test
npm run test:run npm run test:run

23
compose.yaml Normal file
View File

@@ -0,0 +1,23 @@
services:
3proxy-ui:
build:
context: .
dockerfile: Dockerfile
container_name: 3proxy-ui
ports:
- "3000:3000"
- "1080:1080"
- "2080:2080"
- "3128:3128"
- "8081:8081"
environment:
PORT: "3000"
AUTO_START_3PROXY: "true"
THREEPROXY_BINARY: "/usr/local/bin/3proxy"
RUNTIME_DIR: "/app/runtime"
volumes:
- 3proxy-runtime:/app/runtime
restart: unless-stopped
volumes:
3proxy-runtime:

View File

@@ -4,13 +4,13 @@ Updated: 2026-04-01
## Active ## Active
1. Present the UI-first slice for approval, then replace mocks with runtime-backed 3proxy control flows. 1. Harden the new backend/runtime layer, expand system configuration flows, and keep wiring the UI to real panel state instead of fallbacks.
## Next ## Next
1. Replace mocks with a backend control plane for config generation, process management, counters, and health checks. 1. Extend the backend to support system-tab editing for services, ports, and runtime configuration.
2. Add Docker runtime with 3proxy, panel server, health checks, and reload/start/restart operations. 2. Add stronger validation and tests for unsafe credentials, conflicting ports, and invalid service assignment.
3. Extend tests to cover config rendering, unsafe input handling, and runtime failure scenarios. 3. Refine Docker/runtime behavior and document real host-access expectations and deployment constraints.
## Done ## Done
@@ -25,3 +25,6 @@ Updated: 2026-04-01
9. Stabilized the Users table copy action so the column no longer shifts when the button label changes to `Copied`. 9. Stabilized the Users table copy action so the column no longer shifts when the button label changes to `Copied`.
10. Added operator actions in the Users table for pause/resume and delete with confirmation modal coverage. 10. Added operator actions in the Users table for pause/resume and delete with confirmation modal coverage.
11. Added a root quick-start prompt file so a new agent can resume implementation or fixes with minimal onboarding. 11. Added a root quick-start prompt file so a new agent can resume implementation or fixes with minimal onboarding.
12. Added a backend control plane with persisted state, 3proxy config generation, runtime actions, and API-backed frontend wiring.
13. Added Docker build/compose runtime that compiles 3proxy in-container and starts the panel with a managed 3proxy process.
14. Added backend tests for config rendering and user-management API edge cases.

View File

@@ -5,9 +5,13 @@ Updated: 2026-04-01
## Root ## Root
- `000_START_HERE.md`: copy-ready continuation prompt for the next agent session - `000_START_HERE.md`: copy-ready continuation prompt for the next agent session
- `.dockerignore`: trims Docker build context to runtime-relevant files only
- `AGENTS.md`: repository workflow rules for autonomous contributors - `AGENTS.md`: repository workflow rules for autonomous contributors
- `compose.yaml`: Docker Compose entrypoint for the bundled panel + 3proxy runtime
- `Dockerfile`: multi-stage image that builds the panel and compiles 3proxy
- `README.md`: quick start and current project scope - `README.md`: quick start and current project scope
- `package.json`: frontend scripts and dependencies - `package.json`: frontend scripts and dependencies
- `tsconfig.server.json`: server type-check configuration
- `vite.config.ts`: Vite + Vitest configuration - `vite.config.ts`: Vite + Vitest configuration
## Documentation ## Documentation
@@ -19,14 +23,25 @@ Updated: 2026-04-01
## Frontend ## Frontend
- `src/main.tsx`: application bootstrap - `src/main.tsx`: application bootstrap
- `src/App.tsx`: authenticated panel shell rebuilt around a topbar/tab layout, plus modal user creation, pause/resume, and delete-confirm flows - `src/App.tsx`: authenticated panel shell wired to backend APIs with local fallback behavior
- `src/App.test.tsx`: login-gate, modal interaction, pause/resume, and delete-confirm tests - `src/App.test.tsx`: login-gate, modal interaction, pause/resume, and delete-confirm tests
- `src/app.css`: full panel styling - `src/app.css`: full panel styling
- `src/data/mockDashboard.ts`: realistic mock state shaped for future API responses, including service-bound users - `src/data/mockDashboard.ts`: default panel state and frontend fallback snapshot
- `src/lib/3proxy.ts`: formatting and status helpers - `src/lib/3proxy.ts`: formatting and status helpers
- `src/lib/3proxy.test.ts`: paranoia-oriented tests for core domain rules - `src/lib/3proxy.test.ts`: paranoia-oriented tests for core domain rules
- `src/shared/contracts.ts`: shared panel, service, user, and API data contracts
- `src/test/setup.ts`: Testing Library matchers - `src/test/setup.ts`: Testing Library matchers
## Server
- `server/index.ts`: backend entrypoint and runtime bootstrap
- `server/app.ts`: Express app with panel state and runtime routes
- `server/app.test.ts`: API tests for user management edge cases
- `server/lib/config.ts`: 3proxy config renderer, validation, and dashboard derivation
- `server/lib/config.test.ts`: config-generation regression tests
- `server/lib/runtime.ts`: managed 3proxy process controller
- `server/lib/store.ts`: JSON-backed persistent state store
## Static ## Static
- `public/favicon.svg`: Vite default icon placeholder, to replace later - `public/favicon.svg`: Vite default icon placeholder, to replace later

1731
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,10 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "dev:server": "tsx server/index.ts",
"build:client": "vite build",
"build:server": "tsc -p tsconfig.server.json --noEmit && esbuild server/index.ts --bundle --platform=node --format=cjs --outfile=server-dist/index.cjs",
"build": "npm run build:client && npm run build:server",
"preview": "vite preview", "preview": "vite preview",
"test": "vitest", "test": "vitest",
"test:run": "vitest run" "test:run": "vitest run"
@@ -14,15 +17,21 @@
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"@types/express": "^5.0.6",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/supertest": "^7.2.0",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
"esbuild": "^0.27.4",
"jsdom": "^29.0.1", "jsdom": "^29.0.1",
"supertest": "^7.2.2",
"tsx": "^4.21.0",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"vite": "^8.0.1", "vite": "^8.0.1",
"vitest": "^4.1.2" "vitest": "^4.1.2"
}, },
"dependencies": { "dependencies": {
"express": "^5.2.1",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4" "react-dom": "^19.2.4"
} }

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

View File

@@ -1,6 +1,6 @@
import { FormEvent, KeyboardEvent, useEffect, useState } from 'react'; import { FormEvent, KeyboardEvent, useEffect, useMemo, useState } from 'react';
import './app.css'; import './app.css';
import { dashboardSnapshot, panelAuth } from './data/mockDashboard'; import { fallbackDashboardSnapshot, panelAuth } from './data/mockDashboard';
import { import {
buildProxyLink, buildProxyLink,
formatBytes, formatBytes,
@@ -9,6 +9,12 @@ import {
getServiceTone, getServiceTone,
isQuotaExceeded, isQuotaExceeded,
} from './lib/3proxy'; } from './lib/3proxy';
import type {
CreateUserInput,
DashboardSnapshot,
ProxyServiceRecord,
ProxyUserRecord,
} from './shared/contracts';
type TabId = 'dashboard' | 'users' | 'system'; type TabId = 'dashboard' | 'users' | 'system';
@@ -18,18 +24,6 @@ const tabs: Array<{ id: TabId; label: string }> = [
{ id: 'system', label: 'System' }, { id: 'system', label: 'System' },
]; ];
const assignableServices = dashboardSnapshot.system.services.filter(
(service) => service.enabled && service.assignable,
);
const servicesById = new Map(
dashboardSnapshot.system.services.map((service) => [service.id, service] as const),
);
type UserRow = (typeof dashboardSnapshot.userRecords)[number] & {
paused?: boolean;
};
function LoginGate({ onUnlock }: { onUnlock: () => void }) { function LoginGate({ onUnlock }: { onUnlock: () => void }) {
const [login, setLogin] = useState(''); const [login, setLogin] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
@@ -82,8 +76,22 @@ function LoginGate({ onUnlock }: { onUnlock: () => void }) {
); );
} }
function AddUserModal({ onClose }: { onClose: () => void }) { function AddUserModal({
const [serviceId, setServiceId] = useState(assignableServices[0]?.id ?? ''); host,
services,
onClose,
onCreate,
}: {
host: string;
services: ProxyServiceRecord[];
onClose: () => void;
onCreate: (input: CreateUserInput) => Promise<void>;
}) {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [serviceId, setServiceId] = useState(services[0]?.id ?? '');
const [quotaMb, setQuotaMb] = useState('');
const [error, setError] = useState('');
useEffect(() => { useEffect(() => {
const handleKeyDown = (event: globalThis.KeyboardEvent) => { const handleKeyDown = (event: globalThis.KeyboardEvent) => {
@@ -101,7 +109,23 @@ function AddUserModal({ onClose }: { onClose: () => void }) {
event.stopPropagation(); event.stopPropagation();
}; };
const selectedService = servicesById.get(serviceId); const selectedService = services.find((service) => service.id === serviceId);
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
try {
await onCreate({
username,
password,
serviceId,
quotaMb: quotaMb.trim() ? Number(quotaMb) : null,
});
onClose();
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : 'Unable to create user.');
}
};
return ( return (
<div className="modal-backdrop" role="presentation" onClick={onClose}> <div className="modal-backdrop" role="presentation" onClick={onClose}>
@@ -118,19 +142,19 @@ function AddUserModal({ onClose }: { onClose: () => void }) {
Close Close
</button> </button>
</div> </div>
<form className="modal-form"> <form className="modal-form" onSubmit={handleSubmit}>
<label> <label>
Username Username
<input autoFocus placeholder="night-shift-01" /> <input autoFocus placeholder="night-shift-01" value={username} onChange={(event) => setUsername(event.target.value)} />
</label> </label>
<label> <label>
Password Password
<input placeholder="generated secret" /> <input placeholder="generated-secret" value={password} onChange={(event) => setPassword(event.target.value)} />
</label> </label>
<label> <label>
Service Service
<select value={serviceId} onChange={(event) => setServiceId(event.target.value)}> <select value={serviceId} onChange={(event) => setServiceId(event.target.value)}>
{assignableServices.map((service) => ( {services.map((service) => (
<option key={service.id} value={service.id}> <option key={service.id} value={service.id}>
{service.name} {service.name}
</option> </option>
@@ -139,25 +163,22 @@ function AddUserModal({ onClose }: { onClose: () => void }) {
</label> </label>
<label> <label>
Quota (MB) Quota (MB)
<input placeholder="Optional" /> <input placeholder="Optional" value={quotaMb} onChange={(event) => setQuotaMb(event.target.value)} />
</label> </label>
<div className="modal-preview"> <div className="modal-preview">
<span>Endpoint</span> <span>Endpoint</span>
<strong> <strong>{selectedService ? `${host}:${selectedService.port}` : 'Unavailable'}</strong>
{selectedService
? `${dashboardSnapshot.system.publicHost}:${selectedService.port}`
: 'Unavailable'}
</strong>
</div> </div>
<div className="modal-preview"> <div className="modal-preview">
<span>Protocol</span> <span>Protocol</span>
<strong>{selectedService ? selectedService.protocol : 'Unavailable'}</strong> <strong>{selectedService ? selectedService.protocol : 'Unavailable'}</strong>
</div> </div>
{error ? <p className="form-error modal-error">{error}</p> : null}
<div className="modal-actions"> <div className="modal-actions">
<button type="button" className="button-secondary" onClick={onClose}> <button type="button" className="button-secondary" onClick={onClose}>
Cancel Cancel
</button> </button>
<button type="button">Create user</button> <button type="submit">Create user</button>
</div> </div>
</form> </form>
</section> </section>
@@ -172,7 +193,7 @@ function ConfirmDeleteModal({
}: { }: {
username: string; username: string;
onClose: () => void; onClose: () => void;
onConfirm: () => void; onConfirm: () => Promise<void>;
}) { }) {
useEffect(() => { useEffect(() => {
const handleKeyDown = (event: globalThis.KeyboardEvent) => { const handleKeyDown = (event: globalThis.KeyboardEvent) => {
@@ -219,37 +240,45 @@ function ConfirmDeleteModal({
); );
} }
function DashboardTab() { function DashboardTab({
const serviceTone = getServiceTone(dashboardSnapshot.service.status); snapshot,
onRuntimeAction,
}: {
snapshot: DashboardSnapshot;
onRuntimeAction: (action: 'start' | 'restart') => Promise<void>;
}) {
const serviceTone = getServiceTone(snapshot.service.status);
return ( return (
<section className="page-grid"> <section className="page-grid">
<article className="panel-card"> <article className="panel-card">
<div className="card-header"> <div className="card-header">
<h2>Service</h2> <h2>Service</h2>
<span className={`status-pill ${serviceTone}`}>{dashboardSnapshot.service.status}</span> <span className={`status-pill ${serviceTone}`}>{snapshot.service.status}</span>
</div> </div>
<dl className="kv-list"> <dl className="kv-list">
<div> <div>
<dt>Process</dt> <dt>Process</dt>
<dd>{dashboardSnapshot.service.pidLabel}</dd> <dd>{snapshot.service.pidLabel}</dd>
</div> </div>
<div> <div>
<dt>Version</dt> <dt>Version</dt>
<dd>{dashboardSnapshot.service.versionLabel}</dd> <dd>{snapshot.service.versionLabel}</dd>
</div> </div>
<div> <div>
<dt>Uptime</dt> <dt>Uptime</dt>
<dd>{dashboardSnapshot.service.uptimeLabel}</dd> <dd>{snapshot.service.uptimeLabel}</dd>
</div> </div>
<div> <div>
<dt>Last event</dt> <dt>Last event</dt>
<dd>{dashboardSnapshot.service.lastEvent}</dd> <dd>{snapshot.service.lastEvent}</dd>
</div> </div>
</dl> </dl>
<div className="actions-row"> <div className="actions-row">
<button type="button">Start</button> <button type="button" onClick={() => onRuntimeAction('start')}>
<button type="button" className="button-secondary"> Start
</button>
<button type="button" className="button-secondary" onClick={() => onRuntimeAction('restart')}>
Restart Restart
</button> </button>
</div> </div>
@@ -262,15 +291,15 @@ function DashboardTab() {
<div className="stats-strip"> <div className="stats-strip">
<div> <div>
<span>Total</span> <span>Total</span>
<strong>{formatBytes(dashboardSnapshot.traffic.totalBytes)}</strong> <strong>{formatBytes(snapshot.traffic.totalBytes)}</strong>
</div> </div>
<div> <div>
<span>Connections</span> <span>Connections</span>
<strong>{dashboardSnapshot.traffic.liveConnections}</strong> <strong>{snapshot.traffic.liveConnections}</strong>
</div> </div>
<div> <div>
<span>Active users</span> <span>Active users</span>
<strong>{dashboardSnapshot.traffic.activeUsers}</strong> <strong>{snapshot.traffic.activeUsers}</strong>
</div> </div>
</div> </div>
</article> </article>
@@ -280,7 +309,7 @@ function DashboardTab() {
<h2>Daily usage</h2> <h2>Daily usage</h2>
</div> </div>
<div className="usage-list"> <div className="usage-list">
{dashboardSnapshot.traffic.daily.map((bucket) => ( {snapshot.traffic.daily.map((bucket) => (
<div key={bucket.day} className="usage-row"> <div key={bucket.day} className="usage-row">
<span>{bucket.day}</span> <span>{bucket.day}</span>
<div className="usage-bar"> <div className="usage-bar">
@@ -297,7 +326,7 @@ function DashboardTab() {
<h2>Attention</h2> <h2>Attention</h2>
</div> </div>
<div className="event-list"> <div className="event-list">
{dashboardSnapshot.attention.map((item) => ( {snapshot.attention.map((item) => (
<div key={item.title} className="event-row"> <div key={item.title} className="event-row">
<span className={`event-marker ${item.level}`} /> <span className={`event-marker ${item.level}`} />
<div> <div>
@@ -312,44 +341,35 @@ function DashboardTab() {
); );
} }
function UsersTab() { function UsersTab({
snapshot,
onCreateUser,
onTogglePause,
onDeleteUser,
}: {
snapshot: DashboardSnapshot;
onCreateUser: (input: CreateUserInput) => Promise<void>;
onTogglePause: (userId: string) => Promise<void>;
onDeleteUser: (userId: string) => Promise<void>;
}) {
const [copiedId, setCopiedId] = useState<string | null>(null); const [copiedId, setCopiedId] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [users, setUsers] = useState<UserRow[]>(() => dashboardSnapshot.userRecords);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null); const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
const servicesById = useMemo(
() => new Map(snapshot.system.services.map((service) => [service.id, service] as const)),
[snapshot.system.services],
);
const assignableServices = snapshot.system.services.filter((service) => service.enabled && service.assignable);
const deleteTarget = snapshot.userRecords.find((user) => user.id === deleteTargetId) ?? null;
const handleCopy = async (userId: string, proxyLink: string) => { const handleCopy = async (userId: string, proxyLink: string) => {
await navigator.clipboard.writeText(proxyLink); await navigator.clipboard.writeText(proxyLink);
setCopiedId(userId); setCopiedId(userId);
window.setTimeout(() => setCopiedId((value) => (value === userId ? null : value)), 1200); window.setTimeout(() => setCopiedId((value) => (value === userId ? null : value)), 1200);
}; };
const handleTogglePause = (userId: string) => {
setUsers((current) =>
current.map((user) => (user.id === userId ? { ...user, paused: !user.paused } : user)),
);
};
const handleDelete = () => {
if (!deleteTargetId) {
return;
}
setUsers((current) => current.filter((user) => user.id !== deleteTargetId));
setDeleteTargetId(null);
};
const deleteTarget = users.find((user) => user.id === deleteTargetId) ?? null;
const liveUsers = users.filter((user) => !user.paused && user.status === 'live').length;
const nearQuotaUsers = users.filter((user) => {
if (user.paused || user.quotaBytes === null || isQuotaExceeded(user.usedBytes, user.quotaBytes)) {
return false;
}
return user.usedBytes / user.quotaBytes >= 0.8;
}).length;
const exceededUsers = users.filter((user) => isQuotaExceeded(user.usedBytes, user.quotaBytes)).length;
return ( return (
<> <>
<section className="page-grid single-column"> <section className="page-grid single-column">
@@ -357,13 +377,13 @@ function UsersTab() {
<div className="table-toolbar"> <div className="table-toolbar">
<div className="toolbar-title"> <div className="toolbar-title">
<h2>Users</h2> <h2>Users</h2>
<p>{users.length} accounts in current profile</p> <p>{snapshot.userRecords.length} accounts in current profile</p>
</div> </div>
<div className="toolbar-actions"> <div className="toolbar-actions">
<div className="summary-pills"> <div className="summary-pills">
<span>{liveUsers} live</span> <span>{snapshot.users.live} live</span>
<span>{nearQuotaUsers} near quota</span> <span>{snapshot.users.nearQuota} near quota</span>
<span>{exceededUsers} exceeded</span> <span>{snapshot.users.exceeded} exceeded</span>
</div> </div>
<button type="button" onClick={() => setIsModalOpen(true)}> <button type="button" onClick={() => setIsModalOpen(true)}>
New user New user
@@ -385,16 +405,16 @@ function UsersTab() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{users.map((user) => { {snapshot.userRecords.map((user) => {
const service = servicesById.get(user.serviceId); const service = servicesById.get(user.serviceId);
const endpoint = service const endpoint = service
? `${dashboardSnapshot.system.publicHost}:${service.port}` ? `${snapshot.system.publicHost}:${service.port}`
: 'service missing'; : 'service missing';
const proxyLink = service const proxyLink = service
? buildProxyLink( ? buildProxyLink(
user.username, user.username,
user.password, user.password,
dashboardSnapshot.system.publicHost, snapshot.system.publicHost,
service.port, service.port,
service.protocol, service.protocol,
) )
@@ -422,7 +442,7 @@ function UsersTab() {
</td> </td>
<td>{formatBytes(user.usedBytes)}</td> <td>{formatBytes(user.usedBytes)}</td>
<td>{formatQuotaState(user.usedBytes, user.quotaBytes)}</td> <td>{formatQuotaState(user.usedBytes, user.quotaBytes)}</td>
<td>{formatTrafficShare(user.usedBytes, dashboardSnapshot.traffic.totalBytes)}</td> <td>{formatTrafficShare(user.usedBytes, snapshot.traffic.totalBytes)}</td>
<td> <td>
<button <button
type="button" type="button"
@@ -438,7 +458,7 @@ function UsersTab() {
<button <button
type="button" type="button"
className="button-secondary button-small" className="button-secondary button-small"
onClick={() => handleTogglePause(user.id)} onClick={() => onTogglePause(user.id)}
> >
{user.paused ? 'Resume' : 'Pause'} {user.paused ? 'Resume' : 'Pause'}
</button> </button>
@@ -460,19 +480,30 @@ function UsersTab() {
</article> </article>
</section> </section>
{isModalOpen ? <AddUserModal onClose={() => setIsModalOpen(false)} /> : null} {isModalOpen ? (
<AddUserModal
host={snapshot.system.publicHost}
services={assignableServices}
onClose={() => setIsModalOpen(false)}
onCreate={onCreateUser}
/>
) : null}
{deleteTarget ? ( {deleteTarget ? (
<ConfirmDeleteModal <ConfirmDeleteModal
username={deleteTarget.username} username={deleteTarget.username}
onClose={() => setDeleteTargetId(null)} onClose={() => setDeleteTargetId(null)}
onConfirm={handleDelete} onConfirm={async () => {
await onDeleteUser(deleteTarget.id);
setDeleteTargetId(null);
}}
/> />
) : null} ) : null}
</> </>
); );
} }
function SystemTab() { function SystemTab({ snapshot }: { snapshot: DashboardSnapshot }) {
return ( return (
<section className="page-grid system-grid"> <section className="page-grid system-grid">
<article className="panel-card"> <article className="panel-card">
@@ -482,15 +513,15 @@ function SystemTab() {
<dl className="kv-list"> <dl className="kv-list">
<div> <div>
<dt>Config mode</dt> <dt>Config mode</dt>
<dd>{dashboardSnapshot.system.configMode}</dd> <dd>{snapshot.system.configMode}</dd>
</div> </div>
<div> <div>
<dt>Reload</dt> <dt>Reload</dt>
<dd>{dashboardSnapshot.system.reloadMode}</dd> <dd>{snapshot.system.reloadMode}</dd>
</div> </div>
<div> <div>
<dt>Storage</dt> <dt>Storage</dt>
<dd>{dashboardSnapshot.system.storageMode}</dd> <dd>{snapshot.system.storageMode}</dd>
</div> </div>
</dl> </dl>
</article> </article>
@@ -500,7 +531,7 @@ function SystemTab() {
<h2>Services</h2> <h2>Services</h2>
</div> </div>
<div className="service-list"> <div className="service-list">
{dashboardSnapshot.system.services.map((service) => ( {snapshot.system.services.map((service) => (
<div key={service.name} className="service-row"> <div key={service.name} className="service-row">
<div> <div>
<strong>{service.name}</strong> <strong>{service.name}</strong>
@@ -521,7 +552,7 @@ function SystemTab() {
<div className="card-header"> <div className="card-header">
<h2>Generated config</h2> <h2>Generated config</h2>
</div> </div>
<pre>{dashboardSnapshot.system.previewConfig}</pre> <pre>{snapshot.system.previewConfig}</pre>
</article> </article>
</section> </section>
); );
@@ -530,30 +561,139 @@ function SystemTab() {
export default function App() { export default function App() {
const [isAuthed, setIsAuthed] = useState(false); const [isAuthed, setIsAuthed] = useState(false);
const [activeTab, setActiveTab] = useState<TabId>('dashboard'); const [activeTab, setActiveTab] = useState<TabId>('dashboard');
const [snapshot, setSnapshot] = useState<DashboardSnapshot>(fallbackDashboardSnapshot);
useEffect(() => {
let cancelled = false;
void fetch('/api/state')
.then((response) => (response.ok ? response.json() : Promise.reject(new Error('API unavailable'))))
.then((payload: DashboardSnapshot) => {
if (!cancelled) {
setSnapshot(payload);
}
})
.catch(() => {
// Keep fallback snapshot for local UI and tests when backend is not running.
});
return () => {
cancelled = true;
};
}, []);
if (!isAuthed) { if (!isAuthed) {
return <LoginGate onUnlock={() => setIsAuthed(true)} />; return <LoginGate onUnlock={() => setIsAuthed(true)} />;
} }
const mutateSnapshot = async (
request: () => Promise<Response>,
fallback: (current: DashboardSnapshot) => DashboardSnapshot,
) => {
try {
const response = await request();
if (response.ok) {
const payload = (await response.json()) as DashboardSnapshot;
setSnapshot(payload);
return;
}
} catch {
// Fall back to local optimistic state when the API is unavailable.
}
setSnapshot((current) => fallback(current));
};
const handleRuntimeAction = async (action: 'start' | 'restart') => {
await mutateSnapshot(
() => fetch(`/api/runtime/${action}`, { method: 'POST' }),
(current) =>
withDerivedSnapshot({
...current,
service: {
...current.service,
status: 'live',
lastEvent: action === 'restart' ? 'Runtime restarted from panel' : 'Runtime start requested from panel',
},
}),
);
};
const handleCreateUser = async (input: CreateUserInput) => {
await mutateSnapshot(
() =>
fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
}),
(current) => {
const nextUser: ProxyUserRecord = {
id: `u-${Math.random().toString(36).slice(2, 10)}`,
username: input.username.trim(),
password: input.password.trim(),
serviceId: input.serviceId,
status: 'idle',
paused: false,
usedBytes: 0,
quotaBytes: input.quotaMb === null ? null : input.quotaMb * 1024 * 1024,
};
return withDerivedSnapshot({
...current,
service: {
...current.service,
lastEvent: `User ${nextUser.username} created from panel`,
},
userRecords: [...current.userRecords, nextUser],
});
},
);
};
const handleTogglePause = async (userId: string) => {
await mutateSnapshot(
() => fetch(`/api/users/${userId}/pause`, { method: 'POST' }),
(current) =>
withDerivedSnapshot({
...current,
userRecords: current.userRecords.map((user) =>
user.id === userId ? { ...user, paused: !user.paused } : user,
),
}),
);
};
const handleDeleteUser = async (userId: string) => {
await mutateSnapshot(
() => fetch(`/api/users/${userId}`, { method: 'DELETE' }),
(current) =>
withDerivedSnapshot({
...current,
userRecords: current.userRecords.filter((user) => user.id !== userId),
}),
);
};
return ( return (
<main className="shell"> <main className="shell">
<header className="shell-header"> <header className="shell-header">
<div className="shell-title"> <div className="shell-title">
<h1>3proxy UI</h1> <h1>3proxy UI</h1>
<p>{dashboardSnapshot.system.publicHost}</p> <p>{snapshot.system.publicHost}</p>
</div> </div>
<div className="header-meta"> <div className="header-meta">
<div> <div>
<span>Status</span> <span>Status</span>
<strong>{dashboardSnapshot.service.status}</strong> <strong>{snapshot.service.status}</strong>
</div> </div>
<div> <div>
<span>Version</span> <span>Version</span>
<strong>{dashboardSnapshot.service.versionLabel}</strong> <strong>{snapshot.service.versionLabel}</strong>
</div> </div>
<div> <div>
<span>Users</span> <span>Users</span>
<strong>{dashboardSnapshot.users.total}</strong> <strong>{snapshot.users.total}</strong>
</div> </div>
</div> </div>
</header> </header>
@@ -571,9 +711,44 @@ export default function App() {
))} ))}
</nav> </nav>
{activeTab === 'dashboard' ? <DashboardTab /> : null} {activeTab === 'dashboard' ? <DashboardTab snapshot={snapshot} onRuntimeAction={handleRuntimeAction} /> : null}
{activeTab === 'users' ? <UsersTab /> : null} {activeTab === 'users' ? (
{activeTab === 'system' ? <SystemTab /> : null} <UsersTab
snapshot={snapshot}
onCreateUser={handleCreateUser}
onTogglePause={handleTogglePause}
onDeleteUser={handleDeleteUser}
/>
) : null}
{activeTab === 'system' ? <SystemTab snapshot={snapshot} /> : null}
</main> </main>
); );
} }
function withDerivedSnapshot(snapshot: DashboardSnapshot): DashboardSnapshot {
const live = snapshot.userRecords.filter((user) => !user.paused && user.status === 'live').length;
const exceeded = snapshot.userRecords.filter(
(user) => user.quotaBytes !== null && user.usedBytes >= user.quotaBytes,
).length;
const nearQuota = snapshot.userRecords.filter((user) => {
if (user.paused || user.quotaBytes === null || user.usedBytes >= user.quotaBytes) {
return false;
}
return user.usedBytes / user.quotaBytes >= 0.8;
}).length;
return {
...snapshot,
traffic: {
...snapshot.traffic,
activeUsers: snapshot.userRecords.filter((user) => !user.paused).length,
},
users: {
total: snapshot.userRecords.length,
live,
nearQuota,
exceeded,
},
};
}

View File

@@ -1,15 +1,15 @@
import type { ControlPlaneState, DashboardSnapshot } from '../shared/contracts';
export const panelAuth = { export const panelAuth = {
login: 'admin', login: 'admin',
password: 'proxy-ui-demo', password: 'proxy-ui-demo',
}; };
export const dashboardSnapshot = { export const dashboardSnapshot: ControlPlaneState = {
service: { service: {
status: 'live' as const,
pidLabel: 'pid 17',
versionLabel: '3proxy 0.9.x', versionLabel: '3proxy 0.9.x',
uptimeLabel: 'uptime 6h 14m',
lastEvent: 'Last graceful reload 2m ago', lastEvent: 'Last graceful reload 2m ago',
startedAt: '2026-04-01T15:45:00.000Z',
}, },
traffic: { traffic: {
totalBytes: 1_557_402_624, totalBytes: 1_557_402_624,
@@ -23,29 +23,6 @@ export const dashboardSnapshot = {
{ day: 'Fri', bytes: 547_037_696, share: 1 }, { day: 'Fri', bytes: 547_037_696, share: 1 },
], ],
}, },
users: {
total: 18,
live: 9,
nearQuota: 3,
exceeded: 1,
},
attention: [
{
level: 'warn' as const,
title: 'Quota pressure detected',
message: '3 users crossed 80% of their assigned transfer cap.',
},
{
level: 'live' as const,
title: 'Config watcher online',
message: 'The next runtime slice will prefer graceful reload over full restart.',
},
{
level: 'fail' as const,
title: 'Admin API not wired yet',
message: 'Buttons are UI-first placeholders until the backend control plane lands.',
},
],
userRecords: [ userRecords: [
{ {
id: 'u-1', id: 'u-1',
@@ -68,7 +45,7 @@ export const dashboardSnapshot = {
{ {
id: 'u-3', id: 'u-3',
username: 'lab-unlimited', username: 'lab-unlimited',
password: 'open lane', password: 'open-lane',
serviceId: 'socks-lab', serviceId: 'socks-lab',
status: 'idle' as const, status: 'idle' as const,
usedBytes: 42_844_160, usedBytes: 42_844_160,
@@ -91,59 +68,80 @@ export const dashboardSnapshot = {
storageMode: 'flat files for config and counters', storageMode: 'flat files for config and counters',
services: [ services: [
{ {
id: 'socks-main', id: 'socks-main',
name: 'SOCKS5 main', name: 'SOCKS5 main',
protocol: 'socks5', command: 'socks',
description: 'Primary SOCKS5 entrypoint with user auth.', protocol: 'socks5',
port: 1080, description: 'Primary SOCKS5 entrypoint with user auth.',
port: 1080,
enabled: true, enabled: true,
assignable: true, assignable: true,
}, },
{ {
id: 'socks-lab', id: 'socks-lab',
name: 'SOCKS5 lab', name: 'SOCKS5 lab',
protocol: 'socks5', command: 'socks',
description: 'Secondary SOCKS5 service for lab and overflow users.', protocol: 'socks5',
port: 2080, description: 'Secondary SOCKS5 service for lab and overflow users.',
port: 2080,
enabled: true, enabled: true,
assignable: true, assignable: true,
}, },
{ {
id: 'admin', id: 'admin',
name: 'Admin', name: 'Admin',
protocol: 'http', command: 'admin',
description: 'Restricted admin visibility endpoint.', protocol: 'http',
port: 8081, description: 'Restricted admin visibility endpoint.',
port: 8081,
enabled: true, enabled: true,
assignable: false, assignable: false,
}, },
{ {
id: 'proxy', id: 'proxy',
name: 'HTTP proxy', name: 'HTTP proxy',
protocol: 'http', command: 'proxy',
description: 'Optional HTTP/HTTPS proxy profile.', protocol: 'http',
port: 3128, description: 'Optional HTTP/HTTPS proxy profile.',
port: 3128,
enabled: false, enabled: false,
assignable: true, assignable: true,
}, },
], ],
previewConfig: `daemon },
pidfile /var/run/3proxy/3proxy.pid };
monitor /etc/3proxy/generated/3proxy.cfg
export const fallbackDashboardSnapshot: DashboardSnapshot = {
auth strong service: {
users night-shift:CL:kettle!23 ops-east:CL:east/line status: 'live',
pidLabel: 'pid 17',
counter /var/lib/3proxy/counters.3cf D /var/lib/3proxy/reports/%Y-%m-%d.txt versionLabel: dashboardSnapshot.service.versionLabel,
countall 1 D 1024 night-shift * * * * * * uptimeLabel: 'uptime 6h 14m',
countall 2 D 1024 ops-east * * * * * * lastEvent: dashboardSnapshot.service.lastEvent,
},
flush traffic: dashboardSnapshot.traffic,
allow night-shift,ops-east users: {
socks -p1080 -u2 total: dashboardSnapshot.userRecords.length,
live: dashboardSnapshot.userRecords.filter((user) => user.status === 'live').length,
flush nearQuota: 1,
allow * exceeded: 1,
admin -p8081 -s`, },
attention: [
{
level: 'warn',
title: 'Quota pressure detected',
message: 'Fallback snapshot is active until the backend API responds.',
},
],
userRecords: dashboardSnapshot.userRecords,
system: {
...dashboardSnapshot.system,
previewConfig: `pidfile /runtime/3proxy.pid
monitor /runtime/generated/3proxy.cfg
auth strong
users night-shift:CL:kettle!23 ops-east:CL:east/line
flush
allow night-shift,ops-east
socks -p1080 -u2`,
}, },
}; };

87
src/shared/contracts.ts Normal file
View File

@@ -0,0 +1,87 @@
import type { ServiceState } from '../lib/3proxy';
export type ServiceProtocol = 'socks5' | 'http';
export type ServiceCommand = 'socks' | 'proxy' | 'admin';
export interface DailyTrafficBucket {
day: string;
bytes: number;
share: number;
}
export interface ProxyServiceRecord {
id: string;
name: string;
command: ServiceCommand;
protocol: ServiceProtocol;
description: string;
port: number;
enabled: boolean;
assignable: boolean;
}
export interface ProxyUserRecord {
id: string;
username: string;
password: string;
serviceId: string;
status: Exclude<ServiceState, 'paused'>;
usedBytes: number;
quotaBytes: number | null;
paused?: boolean;
}
export interface ControlPlaneState {
service: {
versionLabel: string;
lastEvent: string;
startedAt: string | null;
};
traffic: {
totalBytes: number;
liveConnections: number;
activeUsers: number;
daily: DailyTrafficBucket[];
};
userRecords: ProxyUserRecord[];
system: {
publicHost: string;
configMode: string;
reloadMode: string;
storageMode: string;
services: ProxyServiceRecord[];
};
}
export interface DashboardSnapshot {
service: {
status: ServiceState;
pidLabel: string;
versionLabel: string;
uptimeLabel: string;
lastEvent: string;
};
traffic: ControlPlaneState['traffic'];
users: {
total: number;
live: number;
nearQuota: number;
exceeded: number;
};
attention: Array<{
level: Exclude<ServiceState, 'idle' | 'paused'>;
title: string;
message: string;
}>;
userRecords: ProxyUserRecord[];
system: ControlPlaneState['system'] & {
previewConfig: string;
};
}
export interface CreateUserInput {
username: string;
password: string;
serviceId: string;
quotaMb: number | null;
}

16
tsconfig.server.json Normal file
View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["server/**/*.ts", "src/shared/**/*.ts", "src/data/mockDashboard.ts"]
}