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 # 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.

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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();

View File

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

View File

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

View File

@@ -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
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, 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) {

View File

@@ -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
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.`);
}
}