Add editable system configuration flow
This commit is contained in:
11
docs/PLAN.md
11
docs/PLAN.md
@@ -1,16 +1,16 @@
|
|||||||
# Plan
|
# Plan
|
||||||
|
|
||||||
Updated: 2026-04-01
|
Updated: 2026-04-02
|
||||||
|
|
||||||
## Active
|
## Active
|
||||||
|
|
||||||
1. Harden the new backend/runtime layer, expand system configuration flows, and keep wiring the UI to real panel state instead of fallbacks.
|
1. Harden the backend/runtime layer, keep replacing fallback UI behavior with runtime-backed signals, and prepare the next slice of real traffic/counter ingestion.
|
||||||
|
|
||||||
## Next
|
## Next
|
||||||
|
|
||||||
1. Extend the backend to support system-tab editing for services, ports, and runtime configuration.
|
1. Wire real counter/log ingestion into dashboard traffic and user status instead of seeded snapshot values.
|
||||||
2. Add stronger validation and tests for unsafe credentials, conflicting ports, and invalid service assignment.
|
2. Refine Docker/runtime behavior and document real host-access expectations and deployment constraints.
|
||||||
3. Refine Docker/runtime behavior and document real host-access expectations and deployment constraints.
|
3. Expand validation and tests around service mutations, credential safety, and runtime failure reporting.
|
||||||
|
|
||||||
## Done
|
## Done
|
||||||
|
|
||||||
@@ -28,3 +28,4 @@ Updated: 2026-04-01
|
|||||||
12. Added a backend control plane with persisted state, 3proxy config generation, runtime actions, and API-backed frontend wiring.
|
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.
|
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.
|
14. Added backend tests for config rendering and user-management API edge cases.
|
||||||
|
15. Added editable System settings with shared validation, a system update API, service/port conflict protection, and UI coverage for local save flows.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Project Index
|
# Project Index
|
||||||
|
|
||||||
Updated: 2026-04-01
|
Updated: 2026-04-02
|
||||||
|
|
||||||
## Root
|
## Root
|
||||||
|
|
||||||
@@ -23,20 +23,22 @@ Updated: 2026-04-01
|
|||||||
## Frontend
|
## Frontend
|
||||||
|
|
||||||
- `src/main.tsx`: application bootstrap
|
- `src/main.tsx`: application bootstrap
|
||||||
- `src/App.tsx`: authenticated panel shell wired to backend APIs with local fallback behavior
|
- `src/App.tsx`: authenticated panel shell with API-backed dashboard/user flows and validated local fallback mutations
|
||||||
- `src/App.test.tsx`: login-gate, modal interaction, pause/resume, and delete-confirm tests
|
- `src/SystemTab.tsx`: editable runtime/system form for host, modes, and managed services
|
||||||
|
- `src/App.test.tsx`: login-gate, modal interaction, pause/resume, delete-confirm, and system-save UI tests
|
||||||
- `src/app.css`: full panel styling
|
- `src/app.css`: full panel styling
|
||||||
- `src/data/mockDashboard.ts`: default panel state and frontend fallback snapshot
|
- `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/shared/contracts.ts`: shared panel, service, user, and API data contracts
|
||||||
|
- `src/shared/validation.ts`: shared validation for user creation, system edits, protocol mapping, and quota conversion
|
||||||
- `src/test/setup.ts`: Testing Library matchers
|
- `src/test/setup.ts`: Testing Library matchers
|
||||||
|
|
||||||
## Server
|
## Server
|
||||||
|
|
||||||
- `server/index.ts`: backend entrypoint and runtime bootstrap
|
- `server/index.ts`: backend entrypoint and runtime bootstrap
|
||||||
- `server/app.ts`: Express app with panel state and runtime routes
|
- `server/app.ts`: Express app with panel state, runtime routes, and writable system configuration API
|
||||||
- `server/app.test.ts`: API tests for user management edge cases
|
- `server/app.test.ts`: API tests for user management plus system-update safety edge cases
|
||||||
- `server/lib/config.ts`: 3proxy config renderer, validation, and dashboard derivation
|
- `server/lib/config.ts`: 3proxy config renderer, validation, and dashboard derivation
|
||||||
- `server/lib/config.test.ts`: config-generation regression tests
|
- `server/lib/config.test.ts`: config-generation regression tests
|
||||||
- `server/lib/runtime.ts`: managed 3proxy process controller
|
- `server/lib/runtime.ts`: managed 3proxy process controller
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import os from 'node:os';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
import { afterEach, describe, expect, it } from 'vitest';
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
import type { UpdateSystemInput } from '../src/shared/contracts';
|
||||||
import { createApp } from './app';
|
import { createApp } from './app';
|
||||||
import type { RuntimeSnapshot } from './lib/config';
|
import type { RuntimeSnapshot } from './lib/config';
|
||||||
import type { RuntimeController } from './lib/runtime';
|
import type { RuntimeController } from './lib/runtime';
|
||||||
@@ -75,6 +76,56 @@ describe('panel api', () => {
|
|||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects system updates when two services reuse the same port', async () => {
|
||||||
|
const app = await createTestApp();
|
||||||
|
const initial = await request(app).get('/api/state');
|
||||||
|
const system = createSystemPayload(initial.body);
|
||||||
|
|
||||||
|
system.services[1].port = system.services[0].port;
|
||||||
|
|
||||||
|
const response = await request(app).put('/api/system').send(system);
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.error).toMatch(/cannot share port/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects system updates that strand existing users on a disabled service', async () => {
|
||||||
|
const app = await createTestApp();
|
||||||
|
const initial = await request(app).get('/api/state');
|
||||||
|
const system = createSystemPayload(initial.body);
|
||||||
|
|
||||||
|
system.services = system.services.map((service) =>
|
||||||
|
service.id === 'socks-main' ? { ...service, enabled: false } : service,
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await request(app).put('/api/system').send(system);
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.error).toMatch(/enabled assignable service/i);
|
||||||
|
expect(response.body.error).toMatch(/night-shift/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates system settings and regenerates the rendered config', async () => {
|
||||||
|
const app = await createTestApp();
|
||||||
|
const initial = await request(app).get('/api/state');
|
||||||
|
const system = createSystemPayload(initial.body);
|
||||||
|
|
||||||
|
system.publicHost = 'ops-gateway.example.net';
|
||||||
|
system.services = system.services.map((service) =>
|
||||||
|
service.id === 'socks-main' ? { ...service, port: 1180 } : service,
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await request(app).put('/api/system').send(system);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body.system.publicHost).toBe('ops-gateway.example.net');
|
||||||
|
expect(response.body.system.services.find((service: { id: string }) => service.id === 'socks-main').port).toBe(
|
||||||
|
1180,
|
||||||
|
);
|
||||||
|
expect(response.body.system.previewConfig).toContain('socks -p1180 -u2');
|
||||||
|
expect(response.body.service.lastEvent).toBe('System configuration updated from panel');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
async function createTestApp() {
|
async function createTestApp() {
|
||||||
@@ -90,3 +141,8 @@ async function createTestApp() {
|
|||||||
runtimeRootDir: dir,
|
runtimeRootDir: dir,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createSystemPayload(body: { system: Record<string, unknown> }): UpdateSystemInput {
|
||||||
|
const { previewConfig: _previewConfig, ...system } = body.system;
|
||||||
|
return structuredClone(system) as unknown as UpdateSystemInput;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import express, { type Request, type Response } from 'express';
|
import express, { type Request, type Response } from 'express';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import type { ControlPlaneState, CreateUserInput } from '../src/shared/contracts';
|
import type { ControlPlaneState, CreateUserInput, UpdateSystemInput } from '../src/shared/contracts';
|
||||||
|
import { validateCreateUserInput, validateSystemInput } from '../src/shared/validation';
|
||||||
import {
|
import {
|
||||||
buildRuntimePaths,
|
buildRuntimePaths,
|
||||||
createUserRecord,
|
createUserRecord,
|
||||||
deriveDashboardSnapshot,
|
deriveDashboardSnapshot,
|
||||||
render3proxyConfig,
|
render3proxyConfig,
|
||||||
validateCreateUserInput,
|
|
||||||
type RuntimePaths,
|
type RuntimePaths,
|
||||||
} from './lib/config';
|
} from './lib/config';
|
||||||
import type { RuntimeController } from './lib/runtime';
|
import type { RuntimeController } from './lib/runtime';
|
||||||
@@ -85,6 +85,18 @@ export function createApp({ store, runtime, runtimeRootDir }: AppServices) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.put('/api/system', async (request, response, next) => {
|
||||||
|
try {
|
||||||
|
const state = await store.read();
|
||||||
|
state.system = validateSystemInput(request.body as Partial<UpdateSystemInput>, state.userRecords);
|
||||||
|
state.service.lastEvent = 'System configuration updated from panel';
|
||||||
|
await persistRuntimeMutation(store, runtime, state, runtimePaths);
|
||||||
|
response.json(await getSnapshot(store, runtime, runtimePaths));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.post('/api/users/:id/pause', async (request, response, next) => {
|
app.post('/api/users/:id/pause', async (request, response, next) => {
|
||||||
try {
|
try {
|
||||||
const state = await store.read();
|
const state = await store.read();
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
ProxyServiceRecord,
|
ProxyServiceRecord,
|
||||||
ProxyUserRecord,
|
ProxyUserRecord,
|
||||||
} from '../../src/shared/contracts';
|
} from '../../src/shared/contracts';
|
||||||
|
import { quotaMbToBytes } from '../../src/shared/validation';
|
||||||
import type { ServiceState } from '../../src/lib/3proxy';
|
import type { ServiceState } from '../../src/lib/3proxy';
|
||||||
|
|
||||||
const MB = 1024 * 1024;
|
const MB = 1024 * 1024;
|
||||||
@@ -42,39 +43,6 @@ export function buildRuntimePaths(rootDir: string): RuntimePaths {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
export function createUserRecord(state: ControlPlaneState, input: CreateUserInput): ProxyUserRecord {
|
||||||
if (state.userRecords.some((user) => user.username === input.username)) {
|
if (state.userRecords.some((user) => user.username === input.username)) {
|
||||||
throw new Error('Username already exists.');
|
throw new Error('Username already exists.');
|
||||||
@@ -88,7 +56,7 @@ export function createUserRecord(state: ControlPlaneState, input: CreateUserInpu
|
|||||||
status: 'idle',
|
status: 'idle',
|
||||||
paused: false,
|
paused: false,
|
||||||
usedBytes: 0,
|
usedBytes: 0,
|
||||||
quotaBytes: input.quotaMb === null ? null : input.quotaMb * MB,
|
quotaBytes: quotaMbToBytes(input.quotaMb),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,16 +209,6 @@ function renderServiceCommand(service: ProxyServiceRecord): string {
|
|||||||
return `proxy -p${service.port}`;
|
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 {
|
function normalizePath(value: string): string {
|
||||||
return value.replace(/\\/g, '/');
|
return value.replace(/\\/g, '/');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,4 +88,27 @@ describe('App login gate', () => {
|
|||||||
expect(screen.queryByText(/night-shift/i)).not.toBeInTheDocument();
|
expect(screen.queryByText(/night-shift/i)).not.toBeInTheDocument();
|
||||||
expect(screen.queryByRole('dialog', { name: /delete user/i })).not.toBeInTheDocument();
|
expect(screen.queryByRole('dialog', { name: /delete user/i })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('saves system settings from the system tab and applies them to the local fallback state', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
await loginIntoPanel(user);
|
||||||
|
await user.click(screen.getByRole('button', { name: /system/i }));
|
||||||
|
|
||||||
|
await user.clear(screen.getByLabelText(/public host/i));
|
||||||
|
await user.type(screen.getByLabelText(/public host/i), 'ops-gateway.example.net');
|
||||||
|
|
||||||
|
const firstPortInput = screen.getAllByLabelText(/port/i)[0];
|
||||||
|
await user.clear(firstPortInput);
|
||||||
|
await user.type(firstPortInput, '1180');
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /save system/i }));
|
||||||
|
|
||||||
|
expect(screen.getByText(/ops-gateway\.example\.net/i)).toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /users/i }));
|
||||||
|
|
||||||
|
expect(screen.getAllByText(/ops-gateway\.example\.net:1180/i).length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
190
src/App.tsx
190
src/App.tsx
@@ -14,7 +14,10 @@ import type {
|
|||||||
DashboardSnapshot,
|
DashboardSnapshot,
|
||||||
ProxyServiceRecord,
|
ProxyServiceRecord,
|
||||||
ProxyUserRecord,
|
ProxyUserRecord,
|
||||||
|
UpdateSystemInput,
|
||||||
} from './shared/contracts';
|
} from './shared/contracts';
|
||||||
|
import SystemTab from './SystemTab';
|
||||||
|
import { quotaMbToBytes, validateCreateUserInput } from './shared/validation';
|
||||||
|
|
||||||
type TabId = 'dashboard' | 'users' | 'system';
|
type TabId = 'dashboard' | 'users' | 'system';
|
||||||
|
|
||||||
@@ -385,11 +388,14 @@ function UsersTab({
|
|||||||
<span>{snapshot.users.nearQuota} near quota</span>
|
<span>{snapshot.users.nearQuota} near quota</span>
|
||||||
<span>{snapshot.users.exceeded} exceeded</span>
|
<span>{snapshot.users.exceeded} exceeded</span>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" onClick={() => setIsModalOpen(true)}>
|
<button type="button" onClick={() => setIsModalOpen(true)} disabled={assignableServices.length === 0}>
|
||||||
New user
|
New user
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{assignableServices.length === 0 ? (
|
||||||
|
<p className="table-note">Enable an assignable service in System before creating new users.</p>
|
||||||
|
) : null}
|
||||||
<div className="table-wrap">
|
<div className="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -503,61 +509,6 @@ function UsersTab({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SystemTab({ snapshot }: { snapshot: DashboardSnapshot }) {
|
|
||||||
return (
|
|
||||||
<section className="page-grid system-grid">
|
|
||||||
<article className="panel-card">
|
|
||||||
<div className="card-header">
|
|
||||||
<h2>Runtime</h2>
|
|
||||||
</div>
|
|
||||||
<dl className="kv-list">
|
|
||||||
<div>
|
|
||||||
<dt>Config mode</dt>
|
|
||||||
<dd>{snapshot.system.configMode}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Reload</dt>
|
|
||||||
<dd>{snapshot.system.reloadMode}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Storage</dt>
|
|
||||||
<dd>{snapshot.system.storageMode}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article className="panel-card">
|
|
||||||
<div className="card-header">
|
|
||||||
<h2>Services</h2>
|
|
||||||
</div>
|
|
||||||
<div className="service-list">
|
|
||||||
{snapshot.system.services.map((service) => (
|
|
||||||
<div key={service.name} className="service-row">
|
|
||||||
<div>
|
|
||||||
<strong>{service.name}</strong>
|
|
||||||
<p>{service.description}</p>
|
|
||||||
</div>
|
|
||||||
<div className="service-meta">
|
|
||||||
<span>{service.port}</span>
|
|
||||||
<span className={`status-pill ${service.enabled ? 'live' : 'idle'}`}>
|
|
||||||
{service.enabled ? 'enabled' : 'disabled'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article className="panel-card wide-card">
|
|
||||||
<div className="card-header">
|
|
||||||
<h2>Generated config</h2>
|
|
||||||
</div>
|
|
||||||
<pre>{snapshot.system.previewConfig}</pre>
|
|
||||||
</article>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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');
|
||||||
@@ -620,34 +571,45 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateUser = async (input: CreateUserInput) => {
|
const handleCreateUser = async (input: CreateUserInput) => {
|
||||||
await mutateSnapshot(
|
const validated = validateCreateUserInput(input, snapshot.system.services);
|
||||||
() =>
|
|
||||||
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({
|
if (snapshot.userRecords.some((user) => user.username === validated.username)) {
|
||||||
...current,
|
throw new Error('Username already exists.');
|
||||||
service: {
|
}
|
||||||
...current.service,
|
|
||||||
lastEvent: `User ${nextUser.username} created from panel`,
|
const payload = await requestSnapshot(() =>
|
||||||
},
|
fetch('/api/users', {
|
||||||
userRecords: [...current.userRecords, nextUser],
|
method: 'POST',
|
||||||
});
|
headers: { 'Content-Type': 'application/json' },
|
||||||
},
|
body: JSON.stringify(validated),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (payload) {
|
||||||
|
setSnapshot(payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextUser: ProxyUserRecord = {
|
||||||
|
id: `u-${Math.random().toString(36).slice(2, 10)}`,
|
||||||
|
username: validated.username,
|
||||||
|
password: validated.password,
|
||||||
|
serviceId: validated.serviceId,
|
||||||
|
status: 'idle',
|
||||||
|
paused: false,
|
||||||
|
usedBytes: 0,
|
||||||
|
quotaBytes: quotaMbToBytes(validated.quotaMb),
|
||||||
|
};
|
||||||
|
|
||||||
|
setSnapshot((current) =>
|
||||||
|
withDerivedSnapshot({
|
||||||
|
...current,
|
||||||
|
service: {
|
||||||
|
...current.service,
|
||||||
|
lastEvent: `User ${nextUser.username} created from panel`,
|
||||||
|
},
|
||||||
|
userRecords: [...current.userRecords, nextUser],
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -675,6 +637,35 @@ export default function App() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSaveSystem = async (input: UpdateSystemInput) => {
|
||||||
|
const payload = await requestSnapshot(() =>
|
||||||
|
fetch('/api/system', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (payload) {
|
||||||
|
setSnapshot(payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSnapshot((current) =>
|
||||||
|
withDerivedSnapshot({
|
||||||
|
...current,
|
||||||
|
service: {
|
||||||
|
...current.service,
|
||||||
|
lastEvent: 'System configuration updated from panel',
|
||||||
|
},
|
||||||
|
system: {
|
||||||
|
...input,
|
||||||
|
previewConfig: current.system.previewConfig,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="shell">
|
<main className="shell">
|
||||||
<header className="shell-header">
|
<header className="shell-header">
|
||||||
@@ -720,7 +711,7 @@ export default function App() {
|
|||||||
onDeleteUser={handleDeleteUser}
|
onDeleteUser={handleDeleteUser}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{activeTab === 'system' ? <SystemTab snapshot={snapshot} /> : null}
|
{activeTab === 'system' ? <SystemTab snapshot={snapshot} onSaveSystem={handleSaveSystem} /> : null}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -752,3 +743,34 @@ function withDerivedSnapshot(snapshot: DashboardSnapshot): DashboardSnapshot {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function requestSnapshot(request: () => Promise<Response>): Promise<DashboardSnapshot | null> {
|
||||||
|
try {
|
||||||
|
const response = await request();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await readApiError(response));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await response.json()) as DashboardSnapshot;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof TypeError) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readApiError(response: Response): Promise<string> {
|
||||||
|
try {
|
||||||
|
const payload = (await response.json()) as { error?: string };
|
||||||
|
if (payload.error) {
|
||||||
|
return payload.error;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore invalid JSON and fall through to status-based message.
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Request failed with status ${response.status}.`;
|
||||||
|
}
|
||||||
|
|||||||
273
src/SystemTab.tsx
Normal file
273
src/SystemTab.tsx
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
import { FormEvent, useEffect, useState } from 'react';
|
||||||
|
import type { DashboardSnapshot, ProxyServiceRecord, SystemSettings, UpdateSystemInput } from './shared/contracts';
|
||||||
|
import { getProtocolForCommand, validateSystemInput } from './shared/validation';
|
||||||
|
|
||||||
|
interface SystemTabProps {
|
||||||
|
snapshot: DashboardSnapshot;
|
||||||
|
onSaveSystem: (input: UpdateSystemInput) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SystemTab({ snapshot, onSaveSystem }: SystemTabProps) {
|
||||||
|
const [draft, setDraft] = useState<UpdateSystemInput>(() => cloneSystemSettings(snapshot.system));
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDraft(cloneSystemSettings(snapshot.system));
|
||||||
|
setError('');
|
||||||
|
}, [snapshot.system]);
|
||||||
|
|
||||||
|
const updateService = (serviceId: string, updater: (service: ProxyServiceRecord) => ProxyServiceRecord) => {
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
services: current.services.map((service) => (service.id === serviceId ? updater(service) : service)),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setIsSaving(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const validated = validateSystemInput(draft, snapshot.userRecords);
|
||||||
|
await onSaveSystem(validated);
|
||||||
|
} catch (submitError) {
|
||||||
|
setError(submitError instanceof Error ? submitError.message : 'Unable to save system settings.');
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="system-editor" onSubmit={handleSubmit}>
|
||||||
|
<section className="page-grid system-grid">
|
||||||
|
<article className="panel-card">
|
||||||
|
<div className="card-header">
|
||||||
|
<h2>Runtime</h2>
|
||||||
|
<span className="status-pill idle">editable</span>
|
||||||
|
</div>
|
||||||
|
<div className="system-fields">
|
||||||
|
<label className="field-group">
|
||||||
|
Public host
|
||||||
|
<input
|
||||||
|
value={draft.publicHost}
|
||||||
|
onChange={(event) => setDraft((current) => ({ ...current, publicHost: event.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field-group">
|
||||||
|
Config mode
|
||||||
|
<input
|
||||||
|
value={draft.configMode}
|
||||||
|
onChange={(event) => setDraft((current) => ({ ...current, configMode: event.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field-group">
|
||||||
|
Reload mode
|
||||||
|
<input
|
||||||
|
value={draft.reloadMode}
|
||||||
|
onChange={(event) => setDraft((current) => ({ ...current, reloadMode: event.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field-group">
|
||||||
|
Storage mode
|
||||||
|
<input
|
||||||
|
value={draft.storageMode}
|
||||||
|
onChange={(event) => setDraft((current) => ({ ...current, storageMode: event.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p className="system-hint">
|
||||||
|
Saving writes a new generated config and keeps existing user assignments on enabled assignable
|
||||||
|
services only.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="panel-card">
|
||||||
|
<div className="card-header">
|
||||||
|
<h2>Services</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button-secondary"
|
||||||
|
onClick={() =>
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
services: [...current.services, createServiceDraft(current.services)],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Add service
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="service-editor-list">
|
||||||
|
{draft.services.map((service, index) => (
|
||||||
|
<section key={service.id} className="service-editor-row">
|
||||||
|
<div className="service-editor-header">
|
||||||
|
<div>
|
||||||
|
<strong>Service {index + 1}</strong>
|
||||||
|
<p>{service.id}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button-secondary button-small"
|
||||||
|
onClick={() =>
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
services: current.services.filter((entry) => entry.id !== service.id),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="service-editor-grid">
|
||||||
|
<label className="field-group">
|
||||||
|
Name
|
||||||
|
<input
|
||||||
|
value={service.name}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateService(service.id, (current) => ({ ...current, name: event.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field-group">
|
||||||
|
Port
|
||||||
|
<input
|
||||||
|
inputMode="numeric"
|
||||||
|
value={String(service.port)}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateService(service.id, (current) => ({
|
||||||
|
...current,
|
||||||
|
port: Number(event.target.value),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field-group">
|
||||||
|
Command
|
||||||
|
<select
|
||||||
|
value={service.command}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateService(service.id, (current) => {
|
||||||
|
const command = event.target.value as ProxyServiceRecord['command'];
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
command,
|
||||||
|
protocol: getProtocolForCommand(command),
|
||||||
|
assignable: command === 'admin' ? false : current.assignable,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="socks">socks</option>
|
||||||
|
<option value="proxy">proxy</option>
|
||||||
|
<option value="admin">admin</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="field-group">
|
||||||
|
Protocol
|
||||||
|
<input value={service.protocol} readOnly />
|
||||||
|
</label>
|
||||||
|
<label className="field-group field-span-2">
|
||||||
|
Description
|
||||||
|
<input
|
||||||
|
value={service.description}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateService(service.id, (current) => ({
|
||||||
|
...current,
|
||||||
|
description: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="toggle-row">
|
||||||
|
<label className="toggle-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={service.enabled}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateService(service.id, (current) => ({
|
||||||
|
...current,
|
||||||
|
enabled: event.target.checked,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
Enabled
|
||||||
|
</label>
|
||||||
|
<label className="toggle-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={service.assignable}
|
||||||
|
disabled={service.command === 'admin'}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateService(service.id, (current) => ({
|
||||||
|
...current,
|
||||||
|
assignable: current.command === 'admin' ? false : event.target.checked,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
Assignable to users
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{error ? <p className="form-error">{error}</p> : null}
|
||||||
|
<div className="system-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button-secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setDraft(cloneSystemSettings(snapshot.system));
|
||||||
|
setError('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
<button type="submit" disabled={isSaving}>
|
||||||
|
{isSaving ? 'Saving...' : 'Save system'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="panel-card wide-card">
|
||||||
|
<div className="card-header">
|
||||||
|
<h2>Generated config</h2>
|
||||||
|
</div>
|
||||||
|
<pre>{snapshot.system.previewConfig}</pre>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneSystemSettings(system: SystemSettings): UpdateSystemInput {
|
||||||
|
return {
|
||||||
|
publicHost: system.publicHost,
|
||||||
|
configMode: system.configMode,
|
||||||
|
reloadMode: system.reloadMode,
|
||||||
|
storageMode: system.storageMode,
|
||||||
|
services: system.services.map((service) => ({ ...service })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createServiceDraft(existingServices: ProxyServiceRecord[]): ProxyServiceRecord {
|
||||||
|
const usedPorts = new Set(existingServices.map((service) => service.port));
|
||||||
|
let port = 1080;
|
||||||
|
|
||||||
|
while (usedPorts.has(port) && port < 65535) {
|
||||||
|
port += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `service-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
name: `Service ${existingServices.length + 1}`,
|
||||||
|
command: 'socks',
|
||||||
|
protocol: 'socks5',
|
||||||
|
description: 'Additional SOCKS5 entrypoint managed from the panel.',
|
||||||
|
port,
|
||||||
|
enabled: true,
|
||||||
|
assignable: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
92
src/app.css
92
src/app.css
@@ -43,7 +43,8 @@ body {
|
|||||||
|
|
||||||
button,
|
button,
|
||||||
input,
|
input,
|
||||||
select {
|
select,
|
||||||
|
textarea {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,6 +52,11 @@ button {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
.login-shell {
|
.login-shell {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -106,7 +112,8 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.login-form label,
|
.login-form label,
|
||||||
.modal-form label {
|
.modal-form label,
|
||||||
|
.field-group {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -114,7 +121,10 @@ button {
|
|||||||
|
|
||||||
.login-form input,
|
.login-form input,
|
||||||
.modal-form input,
|
.modal-form input,
|
||||||
.modal-form select {
|
.modal-form select,
|
||||||
|
.field-group input,
|
||||||
|
.field-group select,
|
||||||
|
.field-group textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
@@ -126,7 +136,10 @@ button {
|
|||||||
|
|
||||||
.login-form input:focus,
|
.login-form input:focus,
|
||||||
.modal-form input:focus,
|
.modal-form input:focus,
|
||||||
.modal-form select:focus {
|
.modal-form select:focus,
|
||||||
|
.field-group input:focus,
|
||||||
|
.field-group select:focus,
|
||||||
|
.field-group textarea:focus {
|
||||||
outline: 2px solid rgba(37, 99, 235, 0.12);
|
outline: 2px solid rgba(37, 99, 235, 0.12);
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
@@ -413,6 +426,13 @@ button,
|
|||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.table-note,
|
||||||
|
.system-hint,
|
||||||
|
.service-editor-header p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
.toolbar-actions {
|
.toolbar-actions {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
@@ -522,6 +542,62 @@ tbody tr:last-child td {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.system-editor {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-fields,
|
||||||
|
.service-editor-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-editor-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-editor-row {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--surface-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-editor-header,
|
||||||
|
.system-actions,
|
||||||
|
.toggle-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-row {
|
||||||
|
justify-content: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-check {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-check input {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-span-2 {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
pre {
|
pre {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
@@ -611,13 +687,19 @@ pre {
|
|||||||
|
|
||||||
.page-grid,
|
.page-grid,
|
||||||
.stats-strip,
|
.stats-strip,
|
||||||
.modal-form {
|
.modal-form,
|
||||||
|
.system-fields,
|
||||||
|
.service-editor-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-actions {
|
.modal-actions {
|
||||||
justify-content: stretch;
|
justify-content: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.system-actions {
|
||||||
|
justify-content: stretch;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
|
|||||||
@@ -31,6 +31,14 @@ export interface ProxyUserRecord {
|
|||||||
paused?: boolean;
|
paused?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SystemSettings {
|
||||||
|
publicHost: string;
|
||||||
|
configMode: string;
|
||||||
|
reloadMode: string;
|
||||||
|
storageMode: string;
|
||||||
|
services: ProxyServiceRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface ControlPlaneState {
|
export interface ControlPlaneState {
|
||||||
service: {
|
service: {
|
||||||
versionLabel: string;
|
versionLabel: string;
|
||||||
@@ -44,13 +52,7 @@ export interface ControlPlaneState {
|
|||||||
daily: DailyTrafficBucket[];
|
daily: DailyTrafficBucket[];
|
||||||
};
|
};
|
||||||
userRecords: ProxyUserRecord[];
|
userRecords: ProxyUserRecord[];
|
||||||
system: {
|
system: SystemSettings;
|
||||||
publicHost: string;
|
|
||||||
configMode: string;
|
|
||||||
reloadMode: string;
|
|
||||||
storageMode: string;
|
|
||||||
services: ProxyServiceRecord[];
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DashboardSnapshot {
|
export interface DashboardSnapshot {
|
||||||
@@ -85,3 +87,5 @@ export interface CreateUserInput {
|
|||||||
serviceId: string;
|
serviceId: string;
|
||||||
quotaMb: number | null;
|
quotaMb: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type UpdateSystemInput = SystemSettings;
|
||||||
|
|||||||
171
src/shared/validation.ts
Normal file
171
src/shared/validation.ts
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import type {
|
||||||
|
CreateUserInput,
|
||||||
|
ProxyServiceRecord,
|
||||||
|
ProxyUserRecord,
|
||||||
|
ServiceCommand,
|
||||||
|
ServiceProtocol,
|
||||||
|
UpdateSystemInput,
|
||||||
|
} from './contracts';
|
||||||
|
|
||||||
|
const TOKEN_PATTERN = /^[A-Za-z0-9._~!@#$%^&*+=:/-]+$/;
|
||||||
|
const SERVICE_ID_PATTERN = /^[A-Za-z0-9._-]+$/;
|
||||||
|
const HOST_PATTERN = /^[A-Za-z0-9.-]+$/;
|
||||||
|
|
||||||
|
const COMMANDS: ServiceCommand[] = ['socks', 'proxy', 'admin'];
|
||||||
|
|
||||||
|
const MB = 1024 * 1024;
|
||||||
|
|
||||||
|
export function getProtocolForCommand(command: ServiceCommand): ServiceProtocol {
|
||||||
|
return command === 'socks' ? 'socks5' : 'http';
|
||||||
|
}
|
||||||
|
|
||||||
|
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 validateSystemInput(
|
||||||
|
input: Partial<UpdateSystemInput>,
|
||||||
|
existingUsers: ProxyUserRecord[],
|
||||||
|
): UpdateSystemInput {
|
||||||
|
const publicHost = input.publicHost?.trim() ?? '';
|
||||||
|
const configMode = input.configMode?.trim() ?? '';
|
||||||
|
const reloadMode = input.reloadMode?.trim() ?? '';
|
||||||
|
const storageMode = input.storageMode?.trim() ?? '';
|
||||||
|
const rawServices = Array.isArray(input.services) ? input.services : [];
|
||||||
|
|
||||||
|
if (!publicHost || !HOST_PATTERN.test(publicHost)) {
|
||||||
|
throw new Error('Public host must contain only letters, digits, dots, and hyphens.');
|
||||||
|
}
|
||||||
|
|
||||||
|
assertPresent(configMode, 'Config mode');
|
||||||
|
assertPresent(reloadMode, 'Reload mode');
|
||||||
|
assertPresent(storageMode, 'Storage mode');
|
||||||
|
|
||||||
|
if (rawServices.length === 0) {
|
||||||
|
throw new Error('At least one service is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const seenIds = new Set<string>();
|
||||||
|
const seenPorts = new Map<number, string>();
|
||||||
|
|
||||||
|
const services = rawServices.map((service, index) => {
|
||||||
|
const id = service.id?.trim() ?? '';
|
||||||
|
const name = service.name?.trim() ?? '';
|
||||||
|
const description = service.description?.trim() ?? '';
|
||||||
|
const command = service.command;
|
||||||
|
const port = Number(service.port);
|
||||||
|
const enabled = Boolean(service.enabled);
|
||||||
|
const assignable = Boolean(service.assignable);
|
||||||
|
const label = name || `service ${index + 1}`;
|
||||||
|
|
||||||
|
if (!id || !SERVICE_ID_PATTERN.test(id)) {
|
||||||
|
throw new Error(`Service ${index + 1} must have a safe id.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seenIds.has(id)) {
|
||||||
|
throw new Error(`Service id ${id} is duplicated.`);
|
||||||
|
}
|
||||||
|
seenIds.add(id);
|
||||||
|
|
||||||
|
assertPresent(name, `Service ${index + 1} name`);
|
||||||
|
assertPresent(description, `Service ${index + 1} description`);
|
||||||
|
|
||||||
|
if (!COMMANDS.includes(command)) {
|
||||||
|
throw new Error(`Service ${label} uses an unsupported command.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||||
|
throw new Error(`Service ${label} must use a port between 1 and 65535.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const conflict = seenPorts.get(port);
|
||||||
|
if (conflict) {
|
||||||
|
throw new Error(`Services ${conflict} and ${label} cannot share port ${port}.`);
|
||||||
|
}
|
||||||
|
seenPorts.set(port, label);
|
||||||
|
|
||||||
|
if (command === 'admin' && assignable) {
|
||||||
|
throw new Error('Admin services cannot be assignable.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
command,
|
||||||
|
protocol: getProtocolForCommand(command),
|
||||||
|
port,
|
||||||
|
enabled,
|
||||||
|
assignable: command === 'admin' ? false : assignable,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const servicesById = new Map(services.map((service) => [service.id, service] as const));
|
||||||
|
const invalidUser = existingUsers.find((user) => {
|
||||||
|
const service = servicesById.get(user.serviceId);
|
||||||
|
return !service || !service.enabled || !service.assignable;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (invalidUser) {
|
||||||
|
throw new Error(
|
||||||
|
`User ${invalidUser.username} must stay attached to an enabled assignable service before saving system changes.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
publicHost,
|
||||||
|
configMode,
|
||||||
|
reloadMode,
|
||||||
|
storageMode,
|
||||||
|
services,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function quotaMbToBytes(value: number | null): number | null {
|
||||||
|
return value === null ? null : value * MB;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertPresent(value: string, label: string): void {
|
||||||
|
if (!value) {
|
||||||
|
throw new Error(`${label} is required.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSafeToken(value: string, label: string): void {
|
||||||
|
if (!value) {
|
||||||
|
throw new Error(`${label} is required.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TOKEN_PATTERN.test(value)) {
|
||||||
|
throw new Error(`${label} contains unsupported characters.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user