Add editable system configuration flow

This commit is contained in:
2026-04-02 00:25:14 +03:00
parent 25f6beedd8
commit 1f73a29137
11 changed files with 756 additions and 152 deletions

View File

@@ -1,16 +1,16 @@
# Plan
Updated: 2026-04-01
Updated: 2026-04-02
## 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
1. Extend the backend to support system-tab editing for services, ports, and runtime configuration.
2. Add stronger validation and tests for unsafe credentials, conflicting ports, and invalid service assignment.
3. Refine Docker/runtime behavior and document real host-access expectations and deployment constraints.
1. Wire real counter/log ingestion into dashboard traffic and user status instead of seeded snapshot values.
2. 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
@@ -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.
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.
15. Added editable System settings with shared validation, a system update API, service/port conflict protection, and UI coverage for local save flows.

View File

@@ -1,6 +1,6 @@
# Project Index
Updated: 2026-04-01
Updated: 2026-04-02
## Root
@@ -23,20 +23,22 @@ Updated: 2026-04-01
## Frontend
- `src/main.tsx`: application bootstrap
- `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.tsx`: authenticated panel shell with API-backed dashboard/user flows and validated local fallback mutations
- `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/data/mockDashboard.ts`: default panel state and frontend fallback snapshot
- `src/lib/3proxy.ts`: formatting and status helpers
- `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/validation.ts`: shared validation for user creation, system edits, protocol mapping, and quota conversion
- `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/app.ts`: Express app with panel state, runtime routes, and writable system configuration API
- `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.test.ts`: config-generation regression tests
- `server/lib/runtime.ts`: managed 3proxy process controller

View File

@@ -3,6 +3,7 @@ import os from 'node:os';
import path from 'node:path';
import request from 'supertest';
import { afterEach, describe, expect, it } from 'vitest';
import type { UpdateSystemInput } from '../src/shared/contracts';
import { createApp } from './app';
import type { RuntimeSnapshot } from './lib/config';
import type { RuntimeController } from './lib/runtime';
@@ -75,6 +76,56 @@ describe('panel api', () => {
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() {
@@ -90,3 +141,8 @@ async function createTestApp() {
runtimeRootDir: dir,
});
}
function createSystemPayload(body: { system: Record<string, unknown> }): UpdateSystemInput {
const { previewConfig: _previewConfig, ...system } = body.system;
return structuredClone(system) as unknown as UpdateSystemInput;
}

View File

@@ -1,13 +1,13 @@
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 type { ControlPlaneState, CreateUserInput, UpdateSystemInput } from '../src/shared/contracts';
import { validateCreateUserInput, validateSystemInput } from '../src/shared/validation';
import {
buildRuntimePaths,
createUserRecord,
deriveDashboardSnapshot,
render3proxyConfig,
validateCreateUserInput,
type RuntimePaths,
} from './lib/config';
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) => {
try {
const state = await store.read();

View File

@@ -7,6 +7,7 @@ import type {
ProxyServiceRecord,
ProxyUserRecord,
} from '../../src/shared/contracts';
import { quotaMbToBytes } from '../../src/shared/validation';
import type { ServiceState } from '../../src/lib/3proxy';
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 {
if (state.userRecords.some((user) => user.username === input.username)) {
throw new Error('Username already exists.');
@@ -88,7 +56,7 @@ export function createUserRecord(state: ControlPlaneState, input: CreateUserInpu
status: 'idle',
paused: false,
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}`;
}
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, '/');
}

View File

@@ -88,4 +88,27 @@ describe('App login gate', () => {
expect(screen.queryByText(/night-shift/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);
});
});

View File

@@ -14,7 +14,10 @@ import type {
DashboardSnapshot,
ProxyServiceRecord,
ProxyUserRecord,
UpdateSystemInput,
} from './shared/contracts';
import SystemTab from './SystemTab';
import { quotaMbToBytes, validateCreateUserInput } from './shared/validation';
type TabId = 'dashboard' | 'users' | 'system';
@@ -385,11 +388,14 @@ function UsersTab({
<span>{snapshot.users.nearQuota} near quota</span>
<span>{snapshot.users.exceeded} exceeded</span>
</div>
<button type="button" onClick={() => setIsModalOpen(true)}>
<button type="button" onClick={() => setIsModalOpen(true)} disabled={assignableServices.length === 0}>
New user
</button>
</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">
<table>
<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() {
const [isAuthed, setIsAuthed] = useState(false);
const [activeTab, setActiveTab] = useState<TabId>('dashboard');
@@ -620,34 +571,45 @@ export default function App() {
};
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,
};
const validated = validateCreateUserInput(input, snapshot.system.services);
return withDerivedSnapshot({
...current,
service: {
...current.service,
lastEvent: `User ${nextUser.username} created from panel`,
},
userRecords: [...current.userRecords, nextUser],
});
},
if (snapshot.userRecords.some((user) => user.username === validated.username)) {
throw new Error('Username already exists.');
}
const payload = await requestSnapshot(() =>
fetch('/api/users', {
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 (
<main className="shell">
<header className="shell-header">
@@ -720,7 +711,7 @@ export default function App() {
onDeleteUser={handleDeleteUser}
/>
) : null}
{activeTab === 'system' ? <SystemTab snapshot={snapshot} /> : null}
{activeTab === 'system' ? <SystemTab snapshot={snapshot} onSaveSystem={handleSaveSystem} /> : null}
</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
View 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,
};
}

View File

@@ -43,7 +43,8 @@ body {
button,
input,
select {
select,
textarea {
font: inherit;
}
@@ -51,6 +52,11 @@ button {
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
opacity: 0.65;
}
.login-shell {
min-height: 100vh;
display: grid;
@@ -106,7 +112,8 @@ button {
}
.login-form label,
.modal-form label {
.modal-form label,
.field-group {
display: grid;
gap: 6px;
font-weight: 500;
@@ -114,7 +121,10 @@ button {
.login-form input,
.modal-form input,
.modal-form select {
.modal-form select,
.field-group input,
.field-group select,
.field-group textarea {
width: 100%;
height: 40px;
padding: 0 12px;
@@ -126,7 +136,10 @@ button {
.login-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);
border-color: var(--accent);
}
@@ -413,6 +426,13 @@ button,
line-height: 1.2;
}
.table-note,
.system-hint,
.service-editor-header p {
margin: 0;
color: var(--muted);
}
.toolbar-actions {
flex-wrap: wrap;
}
@@ -522,6 +542,62 @@ tbody tr:last-child td {
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 {
margin: 0;
padding: 14px;
@@ -611,13 +687,19 @@ pre {
.page-grid,
.stats-strip,
.modal-form {
.modal-form,
.system-fields,
.service-editor-grid {
grid-template-columns: 1fr;
}
.modal-actions {
justify-content: stretch;
}
.system-actions {
justify-content: stretch;
}
}
@media (max-width: 640px) {

View File

@@ -31,6 +31,14 @@ export interface ProxyUserRecord {
paused?: boolean;
}
export interface SystemSettings {
publicHost: string;
configMode: string;
reloadMode: string;
storageMode: string;
services: ProxyServiceRecord[];
}
export interface ControlPlaneState {
service: {
versionLabel: string;
@@ -44,13 +52,7 @@ export interface ControlPlaneState {
daily: DailyTrafficBucket[];
};
userRecords: ProxyUserRecord[];
system: {
publicHost: string;
configMode: string;
reloadMode: string;
storageMode: string;
services: ProxyServiceRecord[];
};
system: SystemSettings;
}
export interface DashboardSnapshot {
@@ -85,3 +87,5 @@ export interface CreateUserInput {
serviceId: string;
quotaMb: number | null;
}
export type UpdateSystemInput = SystemSettings;

171
src/shared/validation.ts Normal file
View 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.`);
}
}