Add editable system configuration flow
This commit is contained in:
11
docs/PLAN.md
11
docs/PLAN.md
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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, '/');
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
158
src/App.tsx
158
src/App.tsx
@@ -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(
|
||||
() =>
|
||||
const validated = validateCreateUserInput(input, snapshot.system.services);
|
||||
|
||||
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(input),
|
||||
body: JSON.stringify(validated),
|
||||
}),
|
||||
(current) => {
|
||||
);
|
||||
|
||||
if (payload) {
|
||||
setSnapshot(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextUser: ProxyUserRecord = {
|
||||
id: `u-${Math.random().toString(36).slice(2, 10)}`,
|
||||
username: input.username.trim(),
|
||||
password: input.password.trim(),
|
||||
serviceId: input.serviceId,
|
||||
username: validated.username,
|
||||
password: validated.password,
|
||||
serviceId: validated.serviceId,
|
||||
status: 'idle',
|
||||
paused: false,
|
||||
usedBytes: 0,
|
||||
quotaBytes: input.quotaMb === null ? null : input.quotaMb * 1024 * 1024,
|
||||
quotaBytes: quotaMbToBytes(validated.quotaMb),
|
||||
};
|
||||
|
||||
return withDerivedSnapshot({
|
||||
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
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,
|
||||
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) {
|
||||
|
||||
@@ -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
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