229 lines
6.2 KiB
TypeScript
229 lines
6.2 KiB
TypeScript
import path from 'node:path';
|
|
import { dashboardSnapshot } from '../../src/data/mockDashboard';
|
|
import type {
|
|
ControlPlaneState,
|
|
CreateUserInput,
|
|
DashboardSnapshot,
|
|
ProxyServiceRecord,
|
|
ProxyUserRecord,
|
|
} from '../../src/shared/contracts';
|
|
import { quotaMbToBytes } from '../../src/shared/validation';
|
|
import type { ServiceState } from '../../src/lib/3proxy';
|
|
|
|
const MB = 1024 * 1024;
|
|
|
|
export interface RuntimeSnapshot {
|
|
status: Exclude<ServiceState, 'warn' | 'paused'>;
|
|
pid: number | null;
|
|
startedAt: string | null;
|
|
lastError: string | null;
|
|
}
|
|
|
|
export interface RuntimePaths {
|
|
rootDir: string;
|
|
configPath: string;
|
|
counterPath: string;
|
|
reportDir: string;
|
|
logPath: string;
|
|
pidPath: string;
|
|
}
|
|
|
|
export function createDefaultState(): ControlPlaneState {
|
|
return structuredClone(dashboardSnapshot);
|
|
}
|
|
|
|
export function buildRuntimePaths(rootDir: string): RuntimePaths {
|
|
return {
|
|
rootDir,
|
|
configPath: path.join(rootDir, 'generated', '3proxy.cfg'),
|
|
counterPath: path.join(rootDir, 'state', 'counters.3cf'),
|
|
reportDir: path.join(rootDir, 'state', 'reports'),
|
|
logPath: path.join(rootDir, 'logs', '3proxy.log'),
|
|
pidPath: path.join(rootDir, '3proxy.pid'),
|
|
};
|
|
}
|
|
|
|
export function createUserRecord(state: ControlPlaneState, input: CreateUserInput): ProxyUserRecord {
|
|
if (state.userRecords.some((user) => user.username === input.username)) {
|
|
throw new Error('Username already exists.');
|
|
}
|
|
|
|
return {
|
|
id: `u-${Math.random().toString(36).slice(2, 10)}`,
|
|
username: input.username,
|
|
password: input.password,
|
|
serviceId: input.serviceId,
|
|
status: 'idle',
|
|
paused: false,
|
|
usedBytes: 0,
|
|
quotaBytes: quotaMbToBytes(input.quotaMb),
|
|
};
|
|
}
|
|
|
|
export function render3proxyConfig(state: ControlPlaneState, paths: RuntimePaths): string {
|
|
const lines = [
|
|
`pidfile ${normalizePath(paths.pidPath)}`,
|
|
`monitor ${normalizePath(paths.configPath)}`,
|
|
`log ${normalizePath(paths.logPath)} D`,
|
|
'auth strong',
|
|
];
|
|
|
|
const activeUsers = state.userRecords.filter((user) => !user.paused);
|
|
if (activeUsers.length > 0) {
|
|
lines.push(`users ${activeUsers.map(renderUserCredential).join(' ')}`);
|
|
}
|
|
|
|
const quotaUsers = activeUsers.filter((user) => user.quotaBytes !== null);
|
|
if (quotaUsers.length > 0) {
|
|
lines.push(
|
|
`counter ${normalizePath(paths.counterPath)} D ${normalizePath(
|
|
path.join(paths.reportDir, '%Y-%m-%d.txt'),
|
|
)}`,
|
|
);
|
|
|
|
quotaUsers.forEach((user, index) => {
|
|
lines.push(
|
|
`countall ${index + 1} D ${Math.ceil((user.quotaBytes ?? 0) / MB)} ${user.username} * * * * * *`,
|
|
);
|
|
});
|
|
}
|
|
|
|
state.system.services
|
|
.filter((service) => service.enabled)
|
|
.forEach((service) => {
|
|
lines.push('', 'flush');
|
|
|
|
if (service.assignable) {
|
|
const usernames = activeUsers
|
|
.filter((user) => user.serviceId === service.id)
|
|
.map((user) => user.username);
|
|
|
|
lines.push(usernames.length > 0 ? `allow ${usernames.join(',')}` : 'deny *');
|
|
} else {
|
|
lines.push('allow *');
|
|
}
|
|
|
|
lines.push(renderServiceCommand(service));
|
|
});
|
|
|
|
return `${lines.join('\n')}\n`;
|
|
}
|
|
|
|
export function deriveDashboardSnapshot(
|
|
state: ControlPlaneState,
|
|
runtime: RuntimeSnapshot,
|
|
previewConfig: string,
|
|
): DashboardSnapshot {
|
|
const liveUsers = state.userRecords.filter((user) => !user.paused && user.status === 'live').length;
|
|
const exceededUsers = state.userRecords.filter(
|
|
(user) => user.quotaBytes !== null && user.usedBytes >= user.quotaBytes,
|
|
).length;
|
|
const nearQuotaUsers = state.userRecords.filter((user) => {
|
|
if (user.paused || user.quotaBytes === null || user.usedBytes >= user.quotaBytes) {
|
|
return false;
|
|
}
|
|
|
|
return user.usedBytes / user.quotaBytes >= 0.8;
|
|
}).length;
|
|
|
|
const attention = [];
|
|
|
|
if (exceededUsers > 0) {
|
|
attention.push({
|
|
level: 'fail' as const,
|
|
title: 'Quota exceeded',
|
|
message: `${exceededUsers} user profiles crossed their configured quota.`,
|
|
});
|
|
}
|
|
|
|
if (nearQuotaUsers > 0) {
|
|
attention.push({
|
|
level: 'warn' as const,
|
|
title: 'Quota pressure detected',
|
|
message: `${nearQuotaUsers} user profiles are above 80% of their quota.`,
|
|
});
|
|
}
|
|
|
|
if (runtime.status === 'live') {
|
|
attention.push({
|
|
level: 'live' as const,
|
|
title: '3proxy runtime online',
|
|
message: 'Backend control plane is currently attached to a live 3proxy process.',
|
|
});
|
|
} else if (runtime.lastError) {
|
|
attention.push({
|
|
level: 'fail' as const,
|
|
title: 'Runtime issue detected',
|
|
message: runtime.lastError,
|
|
});
|
|
}
|
|
|
|
if (attention.length === 0) {
|
|
attention.push({
|
|
level: 'live' as const,
|
|
title: 'State loaded',
|
|
message: 'No critical runtime or quota issues are currently detected.',
|
|
});
|
|
}
|
|
|
|
return {
|
|
service: {
|
|
status: runtime.status,
|
|
pidLabel: runtime.pid ? `pid ${runtime.pid}` : 'pid -',
|
|
versionLabel: state.service.versionLabel,
|
|
uptimeLabel: formatUptime(runtime.startedAt),
|
|
lastEvent: state.service.lastEvent,
|
|
},
|
|
traffic: {
|
|
...state.traffic,
|
|
activeUsers: state.userRecords.filter((user) => !user.paused).length,
|
|
},
|
|
users: {
|
|
total: state.userRecords.length,
|
|
live: liveUsers,
|
|
nearQuota: nearQuotaUsers,
|
|
exceeded: exceededUsers,
|
|
},
|
|
attention,
|
|
userRecords: state.userRecords,
|
|
system: {
|
|
...state.system,
|
|
previewConfig,
|
|
},
|
|
};
|
|
}
|
|
|
|
function renderUserCredential(user: ProxyUserRecord): string {
|
|
return `${user.username}:CL:${user.password}`;
|
|
}
|
|
|
|
function renderServiceCommand(service: ProxyServiceRecord): string {
|
|
if (service.command === 'socks') {
|
|
return `socks -p${service.port} -u2`;
|
|
}
|
|
|
|
return `proxy -p${service.port}`;
|
|
}
|
|
|
|
function normalizePath(value: string): string {
|
|
return value.replace(/\\/g, '/');
|
|
}
|
|
|
|
function formatUptime(startedAt: string | null): string {
|
|
if (!startedAt) {
|
|
return 'uptime -';
|
|
}
|
|
|
|
const started = new Date(startedAt).getTime();
|
|
const diffMs = Date.now() - started;
|
|
|
|
if (!Number.isFinite(diffMs) || diffMs <= 0) {
|
|
return 'uptime 0m';
|
|
}
|
|
|
|
const hours = Math.floor(diffMs / (1000 * 60 * 60));
|
|
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
|
|
|
|
return `uptime ${hours}h ${minutes}m`;
|
|
}
|