Add editable system configuration flow
This commit is contained in:
@@ -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, '/');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user