From 57cb13b4b5ddc4631415742cd3b1aba9c6e6fece Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 4 May 2026 10:48:51 +0000 Subject: [PATCH] fix: align database layer and API routes with SQLite backend The control plane uses better-sqlite3 but several modules still emitted MySQL-only SQL or referenced columns that were never created, breaking the production build and core flows (PKI, backups, audit logging). - Restore the build by exporting `getPkiService` from `lib/pki-service.ts` (the new download route imported it) and rewriting the helper to use SQLite-compatible upserts (`ON CONFLICT(...) DO UPDATE SET ... = excluded.x`). - Add idempotent column migrations in `lib/db.ts` so existing panel.sqlite files gain the missing `client_cert`, `client_key`, and `wg_privkey` columns required by the OpenVPN/WireGuard flows. - Replace MySQL `NOW()` with `CURRENT_TIMESTAMP` in the audit logger and the user-create route, and drop the redundant `created_at` column from the user INSERT (the table already has a default). - Detect duplicate-username errors via SQLite's `SQLITE_CONSTRAINT_UNIQUE` and the `UNIQUE constraint failed` message in `app/api/users/route.ts`, while still honouring the legacy MySQL `ER_DUP_ENTRY` code. - Convert `app/api/backup/import/route.ts` to SQLite upserts and remove references to columns that don't exist (`traffic_up`, `traffic_down`, `connected_clients`, `last_check`); mirror the cleanup in the export. - Replace the insecure `Math.random()` WireGuard private-key generator in `app/api/client/download/route.ts` with `crypto.randomBytes(32)`, and persist the generated key per-user so subsequent downloads are stable instead of rotating on every request. - Reduce `app/api/migrate/route.ts` to a thin trigger for the new in-process migrator; the previous body used MySQL-only `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` syntax that always failed under SQLite. - Normalise the user `status` enum across `lib/schemas.ts`, `lib/db-types.ts`, and both user routes so values produced by the UI ('disabled') validate consistently. - Tidy lint warnings: drop unused imports in the tunnel-nodes view, the add-node modal, the subscription route, and `lib/tunnel-commands.ts`. https://claude.ai/code/session_01BF6aKfH4Q7CjxF3ycVkRoT --- app/api/backup/export/route.ts | 4 +- app/api/backup/import/route.ts | 114 +++++++++++++++---------- app/api/client/download/route.ts | 42 ++++----- app/api/migrate/route.ts | 42 ++------- app/api/subscription/[token]/route.ts | 5 +- app/api/users/[id]/route.ts | 2 +- app/api/users/route.ts | 19 +++-- components/nodes/add-node-modal.tsx | 9 -- components/views/tunnel-nodes-view.tsx | 13 ++- lib/audit-logger.ts | 2 +- lib/db-types.ts | 2 +- lib/db.ts | 26 +++++- lib/pki-service.ts | 48 ++++++++--- lib/schemas.ts | 2 +- lib/tunnel-commands.ts | 3 +- panel.sqlite | Bin 86016 -> 86016 bytes schema.sql | 3 + 17 files changed, 196 insertions(+), 140 deletions(-) diff --git a/app/api/backup/export/route.ts b/app/api/backup/export/route.ts index bc0cd8b..700d370 100644 --- a/app/api/backup/export/route.ts +++ b/app/api/backup/export/route.ts @@ -5,8 +5,8 @@ export const dynamic = 'force-dynamic'; export async function GET() { try { - const users: any[] = await query('SELECT id, username, role, parent_id, status, traffic_limit_gb, traffic_total, traffic_up, traffic_down, max_connections, port, main_protocol, expires_at, created_at, last_connected FROM vpn_users'); - const servers: any[] = await query('SELECT id, name, ip_address, domain, protocol, status, is_active, load_score, connected_clients, bandwidth_ingress, bandwidth_egress, latency_ms, last_check, ports, supports_openvpn, supports_cisco, supports_l2tp, supports_wireguard, supports_xray FROM vpn_servers'); + const users: any[] = await query('SELECT id, username, role, parent_id, status, traffic_limit_gb, traffic_total, max_connections, port, main_protocol, expires_at, created_at, last_connected FROM vpn_users'); + const servers: any[] = await query('SELECT id, name, ip_address, domain, protocol, status, is_active, load_score, bandwidth_ingress, bandwidth_egress, latency_ms, ports, supports_openvpn, supports_cisco, supports_l2tp, supports_wireguard, supports_xray FROM vpn_servers'); const settings: any[] = await query('SELECT `key`, `value` FROM settings'); const resellers: any[] = await query('SELECT id, reseller_id, max_users, allocated_traffic_gb FROM reseller_limits'); diff --git a/app/api/backup/import/route.ts b/app/api/backup/import/route.ts index ae61157..055e7b9 100644 --- a/app/api/backup/import/route.ts +++ b/app/api/backup/import/route.ts @@ -17,76 +17,104 @@ export async function POST(req: Request) { try { const body = await req.json(); const validated = BackupSchema.safeParse(body); - + if (!validated.success) { return NextResponse.json({ error: 'Invalid backup format', details: validated.error.format() }, { status: 400 }); } const backup = validated.data; const connection = await pool.getConnection(); - + try { await connection.beginTransaction(); if (backup.settings && backup.settings.length > 0) { for (const row of backup.settings) { - await connection.query('INSERT INTO settings (`key`, `value`) VALUES (?, ?) ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)', [row.key, row.value]); + await connection.query( + 'INSERT INTO settings (`key`, `value`) VALUES (?, ?) ON CONFLICT(`key`) DO UPDATE SET `value` = excluded.`value`', + [row.key, row.value] + ); } } if (backup.users && backup.users.length > 0) { for (const user of backup.users) { - // Update only specific safe fields to avoid breaking sensitive data if it exists - await connection.query(` - INSERT INTO vpn_users - (id, username, role, parent_id, status, traffic_limit_gb, traffic_total, traffic_up, traffic_down, max_connections, port, main_protocol, expires_at, created_at, last_connected) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON DUPLICATE KEY UPDATE - username = VALUES(username), role = VALUES(role), parent_id = VALUES(parent_id), status = VALUES(status), - traffic_limit_gb = VALUES(traffic_limit_gb), traffic_total = VALUES(traffic_total), traffic_up = VALUES(traffic_up), - traffic_down = VALUES(traffic_down), max_connections = VALUES(max_connections), port = VALUES(port), - main_protocol = VALUES(main_protocol), expires_at = VALUES(expires_at) - `, [ - user.id, user.username, user.role, user.parent_id, - user.status, user.traffic_limit_gb, user.traffic_total, user.traffic_up, user.traffic_down, user.max_connections, - user.port, user.main_protocol, user.expires_at, user.created_at, user.last_connected - ]); + await connection.query( + `INSERT INTO vpn_users + (id, username, role, parent_id, status, traffic_limit_gb, traffic_total, max_connections, port, main_protocol, expires_at, created_at, last_connected) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + username = excluded.username, + role = excluded.role, + parent_id = excluded.parent_id, + status = excluded.status, + traffic_limit_gb = excluded.traffic_limit_gb, + traffic_total = excluded.traffic_total, + max_connections = excluded.max_connections, + port = excluded.port, + main_protocol = excluded.main_protocol, + expires_at = excluded.expires_at, + last_connected = excluded.last_connected`, + [ + user.id, user.username, user.role, user.parent_id ?? null, + user.status, user.traffic_limit_gb ?? 0, user.traffic_total ?? 0, + user.max_connections ?? 1, user.port ?? null, user.main_protocol ?? null, + user.expires_at ?? null, user.created_at ?? null, user.last_connected ?? null + ] + ); } } if (backup.servers && backup.servers.length > 0) { for (const server of backup.servers) { - await connection.query(` - INSERT INTO vpn_servers - (id, name, ip_address, domain, protocol, status, is_active, load_score, connected_clients, bandwidth_ingress, bandwidth_egress, latency_ms, last_check, ports, supports_openvpn, supports_cisco, supports_l2tp, supports_wireguard, supports_xray) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON DUPLICATE KEY UPDATE - name = VALUES(name), ip_address = VALUES(ip_address), domain = VALUES(domain), protocol = VALUES(protocol), - status = VALUES(status), is_active = VALUES(is_active), load_score = VALUES(load_score), - connected_clients = VALUES(connected_clients), bandwidth_ingress = VALUES(bandwidth_ingress), - bandwidth_egress = VALUES(bandwidth_egress), latency_ms = VALUES(latency_ms), last_check = VALUES(last_check), - ports = VALUES(ports), supports_openvpn = VALUES(supports_openvpn), supports_cisco = VALUES(supports_cisco), - supports_l2tp = VALUES(supports_l2tp), supports_wireguard = VALUES(supports_wireguard), supports_xray = VALUES(supports_xray) - `, [ - server.id, server.name, server.ip_address, server.domain || null, server.protocol, server.status, server.is_active, - server.load_score, server.connected_clients, server.bandwidth_ingress, server.bandwidth_egress, server.latency_ms, server.last_check, JSON.stringify(server.ports || []), - server.supports_openvpn, server.supports_cisco, server.supports_l2tp, server.supports_wireguard, server.supports_xray - ]); + await connection.query( + `INSERT INTO vpn_servers + (id, name, ip_address, domain, protocol, status, is_active, load_score, bandwidth_ingress, bandwidth_egress, latency_ms, ports, supports_openvpn, supports_cisco, supports_l2tp, supports_wireguard, supports_xray) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + ip_address = excluded.ip_address, + domain = excluded.domain, + protocol = excluded.protocol, + status = excluded.status, + is_active = excluded.is_active, + load_score = excluded.load_score, + bandwidth_ingress = excluded.bandwidth_ingress, + bandwidth_egress = excluded.bandwidth_egress, + latency_ms = excluded.latency_ms, + ports = excluded.ports, + supports_openvpn = excluded.supports_openvpn, + supports_cisco = excluded.supports_cisco, + supports_l2tp = excluded.supports_l2tp, + supports_wireguard = excluded.supports_wireguard, + supports_xray = excluded.supports_xray`, + [ + server.id, server.name, server.ip_address, server.domain || null, + server.protocol, server.status, server.is_active ? 1 : 0, server.load_score ?? 0, + server.bandwidth_ingress ?? 0, server.bandwidth_egress ?? 0, server.latency_ms ?? 0, + JSON.stringify(server.ports || []), + server.supports_openvpn ? 1 : 0, server.supports_cisco ? 1 : 0, + server.supports_l2tp ? 1 : 0, server.supports_wireguard ? 1 : 0, + server.supports_xray ? 1 : 0 + ] + ); } } - + if (backup.resellers && backup.resellers.length > 0) { for (const res of backup.resellers) { - await connection.query(` - INSERT INTO reseller_limits - (id, reseller_id, max_users, allocated_traffic_gb) - VALUES (?, ?, ?, ?) - ON DUPLICATE KEY UPDATE - max_users = VALUES(max_users), allocated_traffic_gb = VALUES(allocated_traffic_gb) - `, [res.id, res.reseller_id, res.max_users, res.allocated_traffic_gb]); + await connection.query( + `INSERT INTO reseller_limits + (id, reseller_id, max_users, allocated_traffic_gb) + VALUES (?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + max_users = excluded.max_users, + allocated_traffic_gb = excluded.allocated_traffic_gb`, + [res.id, res.reseller_id, res.max_users, res.allocated_traffic_gb] + ); } } - + await connection.commit(); logger.info('Backup imported successfully'); } catch (err) { diff --git a/app/api/client/download/route.ts b/app/api/client/download/route.ts index e235e1e..7d8b6c4 100644 --- a/app/api/client/download/route.ts +++ b/app/api/client/download/route.ts @@ -1,12 +1,13 @@ import { NextResponse } from 'next/server'; import { query } from '@/lib/db'; -import { +import { generateClientConfig, InboundConfig, ServerInfo, - UserCredentials + UserCredentials } from '@/lib/config-generators'; import { getPkiService } from '@/lib/pki-service'; +import crypto from 'crypto'; export async function GET(req: Request) { const { searchParams } = new URL(req.url); @@ -37,7 +38,7 @@ export async function GET(req: Request) { if (inboundId) { const inbounds = await query( 'SELECT * FROM vpn_inbounds WHERE id = ? LIMIT 1', - [parseInt(inboundId)] + [parseInt(inboundId, 10)] ); inbound = inbounds[0] || null; } else if (protocol) { @@ -56,7 +57,7 @@ export async function GET(req: Request) { const servers = await query( 'SELECT * FROM vpn_servers WHERE is_active = 1 ORDER BY load_score ASC LIMIT 1' ); - + const server: ServerInfo = servers[0] || { ip_address: '127.0.0.1', domain: null, @@ -73,16 +74,22 @@ export async function GET(req: Request) { // Generate config based on protocol let config: any; - + if (inbound.protocol === 'openvpn') { - // Get PKI certs for OpenVPN const pkiService = getPkiService(); const pki = await pkiService.generateClientCertificate(user.username); config = generateClientConfig(inbound.protocol, server, inbound, userCreds, pki); } else if (inbound.protocol === 'wireguard') { - // For WireGuard, we need to generate a client private key - // In production, this would be stored per-user - const clientPrivateKey = user.wg_privkey || generateWireGuardPrivateKey(); + // Persist a per-user WireGuard private key on first use so subsequent + // downloads return a stable config instead of rotating the key. + let clientPrivateKey: string | undefined = user.wg_privkey; + if (!clientPrivateKey) { + clientPrivateKey = generateWireGuardPrivateKey(); + await query( + 'UPDATE vpn_users SET wg_privkey = ? WHERE id = ?', + [clientPrivateKey, user.id] + ); + } config = generateClientConfig(inbound.protocol, server, inbound, userCreds, undefined, clientPrivateKey); } else { config = generateClientConfig(inbound.protocol, server, inbound, userCreds); @@ -98,7 +105,6 @@ export async function GET(req: Request) { }); } - // For URL-based configs (Xray protocols) if (config.type === 'url' && config.url) { return NextResponse.json({ success: true, @@ -109,7 +115,6 @@ export async function GET(req: Request) { }); } - // For instruction-based configs (Cisco, L2TP) if (config.type === 'instructions') { return NextResponse.json({ success: true, @@ -131,14 +136,11 @@ export async function GET(req: Request) { } } -// Helper function to generate WireGuard private key +// WireGuard private keys are 32 random bytes encoded as base64. +// Note: this is not a Curve25519-clamped key — for production use, derive the +// key with a proper WireGuard library and persist the matching public key on +// the server. For Power VPN's single-tenant flow we only need a deterministic, +// cryptographically-random base64 string of the right shape. function generateWireGuardPrivateKey(): string { - // In production, use proper crypto - // This is a placeholder that generates a base64-like string - const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; - let key = ''; - for (let i = 0; i < 43; i++) { - key += chars.charAt(Math.floor(Math.random() * chars.length)); - } - return key + '='; + return crypto.randomBytes(32).toString('base64'); } diff --git a/app/api/migrate/route.ts b/app/api/migrate/route.ts index 2a6a4f3..70829c0 100644 --- a/app/api/migrate/route.ts +++ b/app/api/migrate/route.ts @@ -1,41 +1,15 @@ import { NextResponse } from 'next/server'; -import pool from '@/lib/db'; +import { validateConnection } from '@/lib/db'; +/** + * Schema migrations for the SQLite-backed control plane are now applied + * automatically inside `validateConnection()` (see `lib/db.ts`). Hitting this + * endpoint simply triggers the connection bootstrap so any pending column + * additions land before the next request. + */ export async function GET() { try { - const migrations = [ - // 28: Add user_id to logs and foreign key - `ALTER TABLE logs - ADD COLUMN IF NOT EXISTS user_id INT, - ADD FOREIGN KEY IF NOT EXISTS (user_id) REFERENCES vpn_users(id) ON DELETE SET NULL;`, - - // 29: Unify traffic naming - `ALTER TABLE vpn_users - CHANGE COLUMN IF EXISTS traffic_up traffic_uploaded_bytes BIGINT DEFAULT 0, - CHANGE COLUMN IF EXISTS traffic_down traffic_downloaded_bytes BIGINT DEFAULT 0;`, - - // 30: JSON indexes (using generated columns for portability) - `ALTER TABLE vpn_users - ADD COLUMN IF NOT EXISTS config_server_name VARCHAR(255) GENERATED ALWAYS AS (custom_config->>'$.server_name') VIRTUAL, - ADD INDEX IF NOT EXISTS idx_config_server_name (config_server_name);`, - - // 31: Settings table improvements - `ALTER TABLE settings - ADD COLUMN IF NOT EXISTS value_type ENUM('string', 'number', 'boolean', 'json') DEFAULT 'string', - ADD COLUMN IF NOT EXISTS is_encrypted BOOLEAN DEFAULT FALSE;`, - - // 32: Parent ID index - `ALTER TABLE vpn_users ADD INDEX IF NOT EXISTS idx_parent_id (parent_id);` - ]; - - for (const sql of migrations) { - try { - await pool.execute(sql); - } catch (err: any) { - console.warn('Migration step failed (might already exist):', err.message); - } - } - + await validateConnection(); return NextResponse.json({ message: 'Migrations completed' }); } catch (error: any) { return NextResponse.json({ error: error.message }, { status: 500 }); diff --git a/app/api/subscription/[token]/route.ts b/app/api/subscription/[token]/route.ts index df90b16..308dfe9 100644 --- a/app/api/subscription/[token]/route.ts +++ b/app/api/subscription/[token]/route.ts @@ -1,8 +1,7 @@ import { NextResponse } from 'next/server'; import { query } from '@/lib/db'; -import { - generateClientConfig, - generateSubscriptionContent, +import { + generateClientConfig, InboundConfig, ServerInfo, UserCredentials diff --git a/app/api/users/[id]/route.ts b/app/api/users/[id]/route.ts index 2aae7eb..ebf8849 100644 --- a/app/api/users/[id]/route.ts +++ b/app/api/users/[id]/route.ts @@ -5,7 +5,7 @@ import { auditLog } from '@/lib/audit-logger'; const UpdateUserSchema = z.object({ role: z.enum(['admin', 'user', 'reseller']).optional().nullable(), - status: z.enum(['active', 'disabled', 'suspended', 'inactive']).optional().nullable(), + status: z.enum(['active', 'inactive', 'disabled', 'suspended', 'revoked']).optional().nullable(), traffic_limit_gb: z.number().optional().nullable(), max_connections: z.number().optional().nullable(), expires_at: z.string().optional().nullable(), diff --git a/app/api/users/route.ts b/app/api/users/route.ts index baad77d..062061a 100644 --- a/app/api/users/route.ts +++ b/app/api/users/route.ts @@ -13,7 +13,7 @@ const CreateUserSchema = z.object({ username: z.string().min(3), password: z.string().min(6).optional().nullable(), role: z.enum(['admin', 'user', 'reseller']).default('user'), - status: z.enum(['active', 'inactive', 'suspended']).default('active'), + status: z.enum(['active', 'inactive', 'disabled', 'suspended', 'revoked']).default('active'), traffic_limit_gb: z.number().min(0).default(10), max_connections: z.number().min(1).default(1), expires_at: z.string().optional().nullable(), @@ -106,9 +106,9 @@ export async function POST(request: Request) { // In a real app, hash password here const [result]: any = await pool.execute( - `INSERT INTO vpn_users - (username, password_hash, role, status, traffic_limit_gb, max_connections, expires_at, created_at, cisco_password, l2tp_password, wg_pubkey, xray_uuid, port, main_protocol) - VALUES (?, ?, ?, ?, ?, ?, ?, NOW(), ?, ?, ?, ?, ?, ?)`, + `INSERT INTO vpn_users + (username, password_hash, role, status, traffic_limit_gb, max_connections, expires_at, cisco_password, l2tp_password, wg_pubkey, xray_uuid, port, main_protocol) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [username, password || null, role, status, traffic_limit_gb, max_connections, finalExpiresAt, cisco_password || null, l2tp_password || null, wg_pubkey || null, xray_uuid || null, port || null, main_protocol || null] ); @@ -133,7 +133,16 @@ export async function POST(request: Request) { } }, { status: 201 }); } catch (error: any) { - if (error.code === 'ER_DUP_ENTRY') { + // SQLite reports unique-constraint violations via SQLITE_CONSTRAINT_UNIQUE. + // Keep the legacy MySQL ER_DUP_ENTRY check so the same code paths work + // if the deployment is later swapped back to MySQL. + const isDuplicate = + error.code === 'SQLITE_CONSTRAINT_UNIQUE' || + error.code === 'SQLITE_CONSTRAINT' || + error.code === 'ER_DUP_ENTRY' || + /UNIQUE constraint failed/i.test(error.message || ''); + + if (isDuplicate) { return NextResponse.json({ error: { code: 'DUPLICATE_USER', diff --git a/components/nodes/add-node-modal.tsx b/components/nodes/add-node-modal.tsx index 0f65eff..c684f00 100644 --- a/components/nodes/add-node-modal.tsx +++ b/components/nodes/add-node-modal.tsx @@ -21,7 +21,6 @@ export function AddNodeModal({ onSuccess }: Props) { register, handleSubmit, reset, - setValue, watch, formState: { errors } } = useForm({ @@ -37,14 +36,6 @@ export function AddNodeModal({ onSuccess }: Props) { } }); - const protocolSupport = { - supports_openvpn: watch('supports_openvpn'), - supports_cisco: watch('supports_cisco'), - supports_l2tp: watch('supports_l2tp'), - supports_wireguard: watch('supports_wireguard'), - supports_xray: watch('supports_xray'), - }; - const onSubmit = async (data: ServerFormData) => { setIsSubmitting(true); // Convert comma string ports to array of numbers for the API diff --git a/components/views/tunnel-nodes-view.tsx b/components/views/tunnel-nodes-view.tsx index dd1225f..b9fa95d 100644 --- a/components/views/tunnel-nodes-view.tsx +++ b/components/views/tunnel-nodes-view.tsx @@ -1,11 +1,11 @@ 'use client'; import React, { useState, useEffect } from 'react'; -import { motion, AnimatePresence } from 'motion/react'; -import { - Globe, Activity, Plus, X, Copy, Check, Server, - Wifi, WifiOff, Terminal, Shield, Zap, MapPin, - ChevronDown, Trash2, RefreshCw, Eye, EyeOff +import { motion } from 'motion/react'; +import { + Globe, Activity, Plus, X, Copy, Check, Server, + Wifi, WifiOff, Terminal, Shield, MapPin, + Trash2, RefreshCw, Eye, EyeOff } from 'lucide-react'; import { toast } from 'sonner'; import { @@ -563,8 +563,7 @@ function CommandModal({ const mainCommand = generateMainServerCommand( { ip: mainServerIp || 'YOUR_MAIN_SERVER_IP', port: mainServerPort }, node.tunnel_type, - node.tunnel_secret, - node.local_forward_port + node.tunnel_secret ); return ( diff --git a/lib/audit-logger.ts b/lib/audit-logger.ts index 4909a47..6268714 100644 --- a/lib/audit-logger.ts +++ b/lib/audit-logger.ts @@ -39,7 +39,7 @@ export async function auditLog( try { await query.execute( - 'INSERT INTO logs (level, message, context, created_at) VALUES (?, ?, ?, NOW())', + 'INSERT INTO logs (level, message, context, created_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP)', [ action.includes('error') || action.includes('failed') ? 'error' : 'info', `${action} - ${details}`, diff --git a/lib/db-types.ts b/lib/db-types.ts index 02ed085..8d80e5b 100644 --- a/lib/db-types.ts +++ b/lib/db-types.ts @@ -1,5 +1,5 @@ export type UserRole = 'admin' | 'reseller' | 'user'; -export type UserStatus = 'active' | 'inactive' | 'suspended' | 'revoked'; +export type UserStatus = 'active' | 'inactive' | 'disabled' | 'suspended' | 'revoked'; export type ServerStatus = 'online' | 'offline'; export type Protocol = 'udp' | 'tcp'; diff --git a/lib/db.ts b/lib/db.ts index 686cd56..360d757 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -18,9 +18,31 @@ async function acquireLock() { return release!; } +// Idempotent column migrations for already-initialized databases. +// CREATE TABLE IF NOT EXISTS will not add new columns, so we apply these manually. +const COLUMN_MIGRATIONS: Array<{ table: string; column: string; definition: string }> = [ + { table: 'vpn_users', column: 'client_cert', definition: 'TEXT' }, + { table: 'vpn_users', column: 'client_key', definition: 'TEXT' }, + { table: 'vpn_users', column: 'wg_privkey', definition: 'TEXT' }, +]; + +function applyColumnMigrations(database: Database.Database) { + for (const m of COLUMN_MIGRATIONS) { + try { + const existing = database + .prepare(`PRAGMA table_info(${m.table})`) + .all() as Array<{ name: string }>; + if (existing.some(c => c.name === m.column)) continue; + database.exec(`ALTER TABLE ${m.table} ADD COLUMN ${m.column} ${m.definition}`); + } catch { + // Table missing or column already added concurrently — safe to ignore. + } + } +} + export async function validateConnection() { if (db) return; - + const dbPath = path.join(process.cwd(), 'panel.sqlite'); db = new Database(dbPath); @@ -30,6 +52,8 @@ export async function validateConnection() { const schemaSql = fs.readFileSync(schemaPath, 'utf8'); db.exec(schemaSql); } + + applyColumnMigrations(db); } // Ensure connection is validated on startup diff --git a/lib/pki-service.ts b/lib/pki-service.ts index 66ad7cf..734a21e 100644 --- a/lib/pki-service.ts +++ b/lib/pki-service.ts @@ -1,27 +1,37 @@ import { query, queryOne } from './db'; import { generateCA, generateClientCert, generateTlsAuthKey } from './pki'; +const UPSERT_SETTING = 'INSERT INTO settings (`key`, `value`) VALUES (?, ?) ON CONFLICT(`key`) DO UPDATE SET `value` = excluded.`value`'; + export async function getOrGeneratePki() { - const settingsRows: any[] = await query('SELECT `key`, `value` FROM settings WHERE `key` IN ("caCert", "caPrivateKey", "tlsAuthKey")'); + const settingsRows: any[] = await query( + 'SELECT `key`, `value` FROM settings WHERE `key` IN (?, ?, ?)', + ['caCert', 'caPrivateKey', 'tlsAuthKey'] + ); const settingsMap = new Map(settingsRows.map((r: any) => [r.key, r.value])); let caCertPem = ''; let caKeyPem = ''; let tlsAuthKey = ''; - if (settingsMap.has('caCert') && settingsMap.get('caCert') !== 'PENDING_CA_GENERATION') { - caCertPem = settingsMap.get('caCert')!; + const existingCa = settingsMap.get('caCert'); + if (existingCa && existingCa !== 'PENDING_CA_GENERATION') { + caCertPem = existingCa; caKeyPem = settingsMap.get('caPrivateKey') || ''; tlsAuthKey = settingsMap.get('tlsAuthKey') || ''; - } else { + } + + if (!caCertPem || !caKeyPem) { const newCa = generateCA(); caCertPem = newCa.cert; caKeyPem = newCa.privateKey; + await query(UPSERT_SETTING, ['caCert', caCertPem]); + await query(UPSERT_SETTING, ['caPrivateKey', caKeyPem]); + } + + if (!tlsAuthKey) { tlsAuthKey = generateTlsAuthKey(); - - await query('INSERT INTO settings (`key`, `value`) VALUES (?, ?) ON DUPLICATE KEY UPDATE `value` = ?', ['caCert', caCertPem, caCertPem]); - await query('INSERT INTO settings (`key`, `value`) VALUES (?, ?) ON DUPLICATE KEY UPDATE `value` = ?', ['caPrivateKey', caKeyPem, caKeyPem]); - await query('INSERT INTO settings (`key`, `value`) VALUES (?, ?) ON DUPLICATE KEY UPDATE `value` = ?', ['tlsAuthKey', tlsAuthKey, tlsAuthKey]); + await query(UPSERT_SETTING, ['tlsAuthKey', tlsAuthKey]); } return { caCertPem, caKeyPem, tlsAuthKey }; @@ -29,7 +39,7 @@ export async function getOrGeneratePki() { export async function getOrGenerateClientCert(username: string, caCertPem: string, caKeyPem: string) { const user = await queryOne('SELECT client_cert, client_key FROM vpn_users WHERE username = ?', [username]); - if (!user) throw new Error("User not found"); + if (!user) throw new Error('User not found'); let clientCertPem = user.client_cert; let clientKeyPem = user.client_key; @@ -38,8 +48,26 @@ export async function getOrGenerateClientCert(username: string, caCertPem: strin const clientPair = generateClientCert(username, caCertPem, caKeyPem); clientCertPem = clientPair.cert; clientKeyPem = clientPair.privateKey; - await query('UPDATE vpn_users SET client_cert = ?, client_key = ? WHERE username = ?', [clientCertPem, clientKeyPem, username]); + await query( + 'UPDATE vpn_users SET client_cert = ?, client_key = ? WHERE username = ?', + [clientCertPem, clientKeyPem, username] + ); } return { clientCertPem, clientKeyPem }; } + +/** + * Convenience facade so callers can grab a single object and request a + * fully-materialised PKI bundle for a given user (CA, TLS auth key, and + * client cert/key) without juggling two helpers. + */ +export function getPkiService() { + return { + async generateClientCertificate(username: string) { + const { caCertPem, caKeyPem, tlsAuthKey } = await getOrGeneratePki(); + const { clientCertPem, clientKeyPem } = await getOrGenerateClientCert(username, caCertPem, caKeyPem); + return { caCertPem, tlsAuthKey, clientCertPem, clientKeyPem }; + }, + }; +} diff --git a/lib/schemas.ts b/lib/schemas.ts index 10f26ea..2e8ab53 100644 --- a/lib/schemas.ts +++ b/lib/schemas.ts @@ -4,7 +4,7 @@ export const UserSchema = z.object({ username: z.string().min(3, 'Username must be at least 3 characters'), password: z.string().min(6, 'Password must be at least 6 characters').optional().nullable(), role: z.enum(['admin', 'user', 'reseller']).default('user'), - status: z.enum(['active', 'inactive', 'suspended']).default('active'), + status: z.enum(['active', 'inactive', 'disabled', 'suspended', 'revoked']).default('active'), traffic_limit_gb: z.number().min(0).default(10), max_connections: z.number().min(0).default(1), expires_at: z.string().optional().nullable(), diff --git a/lib/tunnel-commands.ts b/lib/tunnel-commands.ts index 62cfb81..ae958e9 100644 --- a/lib/tunnel-commands.ts +++ b/lib/tunnel-commands.ts @@ -38,8 +38,7 @@ export interface MainServerConfig { export function generateMainServerCommand( mainServer: MainServerConfig, tunnelType: string, - tunnelSecret: string, - localForwardPort: number + tunnelSecret: string ): string { const authHeader = Buffer.from(`admin:${tunnelSecret}`).toString('base64'); diff --git a/panel.sqlite b/panel.sqlite index 9ce367c052c474ccca3e1d27c9210609212066b7..bda949624bc8d7dce4c767f18829b7ffa9f3c009 100644 GIT binary patch delta 136 zcmZozz}m2Yb%L~@2m=FyC=kPd;6xo`Mv;vP3-9xCePG~ZzQw@znQtNUt<8K7&M