diff --git a/app/api/inbounds/route.ts b/app/api/inbounds/route.ts index 9b34524..52e7c10 100644 --- a/app/api/inbounds/route.ts +++ b/app/api/inbounds/route.ts @@ -24,6 +24,7 @@ export async function POST(req: Request) { name, protocol, port, + server_address, remark, // OpenVPN fields ovpn_protocol, @@ -60,8 +61,8 @@ export async function POST(req: Request) { } = body; // Validate required fields - if (!name || !protocol || !port) { - return NextResponse.json({ error: 'Name, protocol, and port are required' }, { status: 400 }); + if (!name || !protocol || !port || !server_address) { + return NextResponse.json({ error: 'Name, protocol, port and server address are required' }, { status: 400 }); } // Validate protocol @@ -84,9 +85,9 @@ export async function POST(req: Request) { } // Build SQL based on protocol type - const columns = ['name', 'protocol', 'port', 'remark', 'status']; - const values: any[] = [name, protocol, parseInt(port, 10), remark || '', 'active']; - const placeholders = ['?', '?', '?', '?', '?']; + const columns = ['name', 'protocol', 'port', 'server_address', 'remark', 'status']; + const values: any[] = [name, protocol, parseInt(port, 10), server_address, remark || '', 'active']; + const placeholders = ['?', '?', '?', '?', '?', '?']; // Add protocol-specific fields switch (protocol) { diff --git a/app/api/tunnel-nodes/route.ts b/app/api/tunnel-nodes/route.ts new file mode 100644 index 0000000..7fa7285 --- /dev/null +++ b/app/api/tunnel-nodes/route.ts @@ -0,0 +1,110 @@ +import { NextResponse } from 'next/server'; +import { query } from '@/lib/db'; +import { z } from 'zod'; +import { handleApiError } from '@/lib/api-utils'; +import crypto from 'crypto'; + +export const dynamic = 'force-dynamic'; + +const TunnelNodeSchema = z.object({ + name: z.string().min(1).max(255), + location: z.string().min(1).max(255), + country_code: z.string().max(10).optional(), + flag_emoji: z.string().max(10).optional(), + remote_ip: z.string().min(1), + tunnel_port: z.number().int().min(1).max(65535).default(443), + tunnel_type: z.enum(['wss', 'grpc', 'quic', 'h2']).default('wss'), + local_forward_port: z.number().int().min(1).max(65535), + sni_host: z.string().default('www.google.com'), +}); + +// Generate a secure random secret for tunnel authentication +function generateTunnelSecret(): string { + return crypto.randomBytes(32).toString('base64url'); +} + +export async function GET() { + try { + const nodes = await query(` + SELECT * FROM tunnel_nodes + ORDER BY created_at DESC + `); + return NextResponse.json(nodes); + } catch (error) { + return handleApiError(error); + } +} + +export async function POST(req: Request) { + try { + const body = await req.json(); + const validated = TunnelNodeSchema.parse(body); + + const tunnel_secret = generateTunnelSecret(); + + const result: any = await query( + `INSERT INTO tunnel_nodes + (name, location, country_code, flag_emoji, remote_ip, tunnel_port, tunnel_type, tunnel_secret, local_forward_port, sni_host, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending')`, + [ + validated.name, + validated.location, + validated.country_code || null, + validated.flag_emoji || null, + validated.remote_ip, + validated.tunnel_port, + validated.tunnel_type, + tunnel_secret, + validated.local_forward_port, + validated.sni_host, + ] + ); + + // Return the created node with the generated secret + const newNode = await query('SELECT * FROM tunnel_nodes WHERE id = ?', [result.insertId]); + + return NextResponse.json({ + success: true, + node: (newNode as any[])[0], + tunnel_secret + }); + } catch (error) { + return handleApiError(error); + } +} + +export async function DELETE(req: Request) { + try { + const { searchParams } = new URL(req.url); + const id = searchParams.get('id'); + if (!id) throw new Error('ID required'); + + await query('DELETE FROM tunnel_nodes WHERE id = ?', [id]); + return NextResponse.json({ success: true }); + } catch (error) { + return handleApiError(error); + } +} + +export async function PATCH(req: Request) { + try { + const { searchParams } = new URL(req.url); + const id = searchParams.get('id'); + if (!id) throw new Error('ID required'); + + const body = await req.json(); + const { status, is_active } = body; + + if (status) { + await query('UPDATE tunnel_nodes SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [status, id]); + } + + if (typeof is_active === 'boolean') { + await query('UPDATE tunnel_nodes SET is_active = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [is_active ? 1 : 0, id]); + } + + return NextResponse.json({ success: true }); + } catch (error) { + return handleApiError(error); + } +} diff --git a/app/page.tsx b/app/page.tsx index 92ef728..2e65d1b 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -2,7 +2,7 @@ import React, { useState } from 'react'; import { motion, AnimatePresence } from 'motion/react'; -import { Shield, Users, Activity, Settings as SettingsIcon, LayoutDashboard, Menu, X, Network, Server, UserCheck } from 'lucide-react'; +import { Shield, Users, Activity, Settings as SettingsIcon, LayoutDashboard, Menu, X, Network, Server, UserCheck, Globe } from 'lucide-react'; import { ErrorBoundary } from '@/components/ui/error-boundary'; import DashboardView from '@/components/views/dashboard-view'; import { UsersView } from '@/components/views/users-view'; @@ -11,9 +11,10 @@ import SettingsView from '@/components/views/settings-view'; import InboundsView from '@/components/views/inbounds-view'; import { RepresentativesView } from '@/components/views/representatives-view'; import { NodesView } from '@/components/views/nodes-view'; +import { TunnelNodesView } from '@/components/views/tunnel-nodes-view'; import { Toaster } from 'sonner'; -type ViewType = 'dashboard' | 'users' | 'inbounds' | 'sessions' | 'settings' | 'representatives' | 'nodes'; +type ViewType = 'dashboard' | 'users' | 'inbounds' | 'sessions' | 'settings' | 'representatives' | 'nodes' | 'tunnel-nodes'; export default function Home() { const [activeView, setActiveView] = useState('dashboard'); @@ -23,6 +24,7 @@ export default function Home() { { id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard }, { id: 'representatives', label: 'Representatives', icon: UserCheck }, { id: 'nodes', label: 'Nodes', icon: Server }, + { id: 'tunnel-nodes', label: 'Tunnel Nodes', icon: Globe }, { id: 'users', label: 'Users', icon: Users }, { id: 'inbounds', label: 'Inbounds', icon: Network }, { id: 'sessions', label: 'Sessions', icon: Activity }, @@ -37,6 +39,8 @@ export default function Home() { return ; case 'nodes': return ; + case 'tunnel-nodes': + return ; case 'users': return ; case 'inbounds': diff --git a/components/views/inbounds-view.tsx b/components/views/inbounds-view.tsx index d584ac6..7eeb46b 100644 --- a/components/views/inbounds-view.tsx +++ b/components/views/inbounds-view.tsx @@ -33,6 +33,7 @@ interface Inbound { name: string; protocol: string; port: number; + server_address: string; remark: string; status: string; created_at: string; @@ -52,6 +53,7 @@ interface InboundFormData { name: string; protocol: string; port: string; + server_address: string; remark: string; // OpenVPN ovpn_protocol: string; @@ -91,6 +93,7 @@ const initialFormData: InboundFormData = { name: '', protocol: 'openvpn', port: '', + server_address: '', remark: '', // OpenVPN defaults ovpn_protocol: 'udp', @@ -231,8 +234,8 @@ export default function InboundsView() { const handleCreate = async (e: React.FormEvent) => { e.preventDefault(); - if (!formData.name || !formData.port) { - return toast.error('Name and port are required'); + if (!formData.name || !formData.port || !formData.server_address) { + return toast.error('Name, server address and port are required'); } try { @@ -728,7 +731,7 @@ export default function InboundsView() {
{/* Basic Info */} -
+
+
+ + updateFormField('server_address', e.target.value)} + className="w-full bg-slate-50 border border-slate-100 rounded-xl px-4 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none transition-all" + placeholder="e.g. 185.12.34.56 or vpn.example.com" + /> +
+
+
setMainServerIp(e.target.value)} + placeholder="185.x.x.x" + className="w-full bg-white/10 border border-white/20 rounded-xl px-4 py-2.5 text-white placeholder:text-slate-500 focus:ring-2 focus:ring-emerald-500 focus:outline-none" + /> +
+
+ + setMainServerPort(e.target.value)} + placeholder="8443" + className="w-full bg-white/10 border border-white/20 rounded-xl px-4 py-2.5 text-white placeholder:text-slate-500 focus:ring-2 focus:ring-emerald-500 focus:outline-none" + /> +
+
+
+ + {/* Nodes Table */} +
+ {loading ? ( +
+ +

Loading nodes...

+
+ ) : nodes.length === 0 ? ( +
+ +
+

No tunnel nodes configured

+

Add your first node to enable multi-location VPN

+
+
+ ) : ( +
+ + + + + + + + + + + + + {nodes.map((node) => ( + + + + + + + + + ))} + +
LocationRemote IPTunnel TypePortStatusActions
+
+
+ {node.flag_emoji || COUNTRY_FLAGS[node.country_code || ''] || '🌍'} +
+
+

{node.name}

+

+ + {node.location} +

+
+
+
+ {node.remote_ip} + + + {node.tunnel_type} + + + {node.tunnel_port} + + + {node.status === 'online' ? : } + {node.status} + + +
+ + +
+
+
+ )} +
+ + {/* Add Node Modal */} + setShowAddModal(false)} + onSuccess={() => { + fetchNodes(); + setShowAddModal(false); + }} + /> + + {/* Command Modal */} + setShowCommandModal(false)} + node={selectedNode} + mainServerIp={mainServerIp} + mainServerPort={parseInt(mainServerPort, 10) || 8443} + onCopy={copyToClipboard} + copiedId={copiedId} + /> + + ); +} + +// Add Node Modal Component +function AddTunnelNodeModal({ + isOpen, + onClose, + onSuccess +}: { + isOpen: boolean; + onClose: () => void; + onSuccess: () => void; +}) { + const [formData, setFormData] = useState({ + name: '', + location: '', + country_code: '', + remote_ip: '', + tunnel_port: '443', + tunnel_type: getRecommendedTunnelType(), + local_forward_port: '10000', + sni_host: 'www.google.com', + }); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!formData.name || !formData.location || !formData.remote_ip) { + toast.error('Please fill all required fields'); + return; + } + + setIsSubmitting(true); + try { + const res = await fetch('/api/tunnel-nodes', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ...formData, + tunnel_port: parseInt(formData.tunnel_port, 10), + local_forward_port: parseInt(formData.local_forward_port, 10), + flag_emoji: COUNTRY_FLAGS[formData.country_code] || null, + }), + }); + + if (!res.ok) throw new Error('Failed to create node'); + + toast.success('Tunnel node created successfully'); + onSuccess(); + setFormData({ + name: '', + location: '', + country_code: '', + remote_ip: '', + tunnel_port: '443', + tunnel_type: getRecommendedTunnelType(), + local_forward_port: '10000', + sni_host: 'www.google.com', + }); + } catch { + toast.error('Failed to create tunnel node'); + } finally { + setIsSubmitting(false); + } + }; + + if (!isOpen) return null; + + return ( +
+ + + +
+
+
+

Add Tunnel Node

+

Configure a new location for DPI-resistant connection

+
+ +
+
+ + +
+
+ + setFormData(prev => ({ ...prev, name: e.target.value }))} + placeholder="e.g. Paris-01" + className="w-full bg-slate-50 border border-slate-100 rounded-xl px-4 py-2.5 text-sm focus:ring-2 focus:ring-emerald-500 focus:outline-none" + /> +
+
+ + setFormData(prev => ({ ...prev, location: e.target.value }))} + placeholder="e.g. Paris, France" + className="w-full bg-slate-50 border border-slate-100 rounded-xl px-4 py-2.5 text-sm focus:ring-2 focus:ring-emerald-500 focus:outline-none" + /> +
+
+ +
+
+ + setFormData(prev => ({ ...prev, remote_ip: e.target.value }))} + placeholder="185.x.x.x" + className="w-full bg-slate-50 border border-slate-100 rounded-xl px-4 py-2.5 text-sm font-mono focus:ring-2 focus:ring-emerald-500 focus:outline-none" + /> +
+
+ + +
+
+ +
+
+ + +

+ {getTunnelTypeDescription(formData.tunnel_type)} +

+
+
+ + setFormData(prev => ({ ...prev, tunnel_port: e.target.value }))} + placeholder="443" + className="w-full bg-slate-50 border border-slate-100 rounded-xl px-4 py-2.5 text-sm focus:ring-2 focus:ring-emerald-500 focus:outline-none" + /> +

Port 443 recommended for HTTPS mimicry

+
+
+ +
+
+ + setFormData(prev => ({ ...prev, local_forward_port: e.target.value }))} + placeholder="10000" + className="w-full bg-slate-50 border border-slate-100 rounded-xl px-4 py-2.5 text-sm focus:ring-2 focus:ring-emerald-500 focus:outline-none" + /> +
+
+ + setFormData(prev => ({ ...prev, sni_host: e.target.value }))} + placeholder="www.google.com" + className="w-full bg-slate-50 border border-slate-100 rounded-xl px-4 py-2.5 text-sm focus:ring-2 focus:ring-emerald-500 focus:outline-none" + /> +

Fake SNI to bypass DPI detection

+
+
+ +
+ +
+ +
+
+ ); +} + +// Command Modal Component +function CommandModal({ + isOpen, + onClose, + node, + mainServerIp, + mainServerPort, + onCopy, + copiedId, +}: { + isOpen: boolean; + onClose: () => void; + node: TunnelNode | null; + mainServerIp: string; + mainServerPort: number; + onCopy: (text: string, id: string) => void; + copiedId: string | null; +}) { + const [showSecret, setShowSecret] = useState(false); + const [activeTab, setActiveTab] = useState<'remote' | 'main'>('remote'); + + if (!isOpen || !node) return null; + + const remoteCommand = generateRemoteNodeCommand(node, mainServerIp || 'YOUR_MAIN_SERVER_IP', mainServerPort); + const mainCommand = generateMainServerCommand( + { ip: mainServerIp || 'YOUR_MAIN_SERVER_IP', port: mainServerPort }, + node.tunnel_type, + node.tunnel_secret, + node.local_forward_port + ); + + return ( +
+ + + +
+
+
+
+ {node.flag_emoji || COUNTRY_FLAGS[node.country_code || ''] || '🌍'} +
+
+

{node.name}

+

{node.location} - Setup Commands

+
+
+ +
+
+ +
+ {/* Warning if no main server IP */} + {!mainServerIp && ( +
+

+ Please enter your Main Server IP in the configuration section above to generate correct commands. +

+
+ )} + + {/* Tunnel Info */} +
+
+
+

Tunnel Type

+

{node.tunnel_type.toUpperCase()}

+
+
+

Remote IP

+

{node.remote_ip}

+
+
+

Port

+

{node.tunnel_port}

+
+
+
+
+
+

Tunnel Secret

+

+ {showSecret ? node.tunnel_secret : '••••••••••••••••••••'} +

+
+ +
+
+
+ + {/* Tabs */} +
+ + +
+ + {/* Command Display */} +
+
+
+                {activeTab === 'remote' ? remoteCommand : mainCommand}
+              
+
+ +
+ + {/* Instructions */} +
+

+ + Setup Instructions +

+
    +
  1. First, run the Main Server Command on your panel server to start the tunnel listener
  2. +
  3. Then, run the Remote Node Command on the remote server ({node.location})
  4. +
  5. The tunnel will establish automatically and traffic will flow through securely
  6. +
  7. Use the systemd service for auto-start on remote server reboot
  8. +
+
+
+
+
+ ); +} diff --git a/lib/tunnel-commands.ts b/lib/tunnel-commands.ts new file mode 100644 index 0000000..62cfb81 --- /dev/null +++ b/lib/tunnel-commands.ts @@ -0,0 +1,182 @@ +/** + * DPI-Resistant Tunnel Command Generator + * + * Generates commands for establishing secure tunnels between nodes + * Using Gost (Go Simple Tunnel) for maximum DPI bypass capability + * + * Tunnel Types: + * - WSS: WebSocket over TLS (looks like HTTPS traffic) + * - gRPC: HTTP/2 based (looks like Google services) + * - QUIC: UDP-based encrypted protocol + * - H2: HTTP/2 tunnel + */ + +export interface TunnelNode { + id: number; + name: string; + location: string; + country_code?: string; + flag_emoji?: string; + remote_ip: string; + tunnel_port: number; + tunnel_type: 'wss' | 'grpc' | 'quic' | 'h2'; + tunnel_secret: string; + local_forward_port: number; + sni_host: string; + status: string; +} + +export interface MainServerConfig { + ip: string; + port: number; +} + +/** + * Generate the command to run on the MAIN server (where panel is installed) + * This creates the listener that remote nodes will connect to + */ +export function generateMainServerCommand( + mainServer: MainServerConfig, + tunnelType: string, + tunnelSecret: string, + localForwardPort: number +): string { + const authHeader = Buffer.from(`admin:${tunnelSecret}`).toString('base64'); + + switch (tunnelType) { + case 'wss': + return `# Install Gost on Main Server +curl -L https://github.com/ginuerzh/gost/releases/download/v2.11.5/gost-linux-amd64-2.11.5.gz -o gost.gz && gunzip gost.gz && chmod +x gost && mv gost /usr/local/bin/ + +# Run Tunnel Listener (WSS - Looks like HTTPS) +gost -L "relay+wss://:${mainServer.port}?auth=${authHeader}&path=/ws&cert=/etc/ssl/certs/server.crt&key=/etc/ssl/private/server.key"`; + + case 'grpc': + return `# Install Gost on Main Server +curl -L https://github.com/ginuerzh/gost/releases/download/v2.11.5/gost-linux-amd64-2.11.5.gz -o gost.gz && gunzip gost.gz && chmod +x gost && mv gost /usr/local/bin/ + +# Run Tunnel Listener (gRPC - Looks like Google services) +gost -L "relay+grpc://:${mainServer.port}?auth=${authHeader}"`; + + case 'quic': + return `# Install Gost on Main Server +curl -L https://github.com/ginuerzh/gost/releases/download/v2.11.5/gost-linux-amd64-2.11.5.gz -o gost.gz && gunzip gost.gz && chmod +x gost && mv gost /usr/local/bin/ + +# Run Tunnel Listener (QUIC - UDP encrypted) +gost -L "relay+quic://:${mainServer.port}?auth=${authHeader}"`; + + case 'h2': + return `# Install Gost on Main Server +curl -L https://github.com/ginuerzh/gost/releases/download/v2.11.5/gost-linux-amd64-2.11.5.gz -o gost.gz && gunzip gost.gz && chmod +x gost && mv gost /usr/local/bin/ + +# Run Tunnel Listener (HTTP/2) +gost -L "relay+h2://:${mainServer.port}?auth=${authHeader}"`; + + default: + return `# Unknown tunnel type: ${tunnelType}`; + } +} + +/** + * Generate the command to run on the REMOTE node server + * This connects back to the main server and creates the tunnel + */ +export function generateRemoteNodeCommand( + node: TunnelNode, + mainServerIp: string, + mainServerListenPort: number +): string { + const authHeader = Buffer.from(`admin:${node.tunnel_secret}`).toString('base64'); + + const installCmd = `# ============================================ +# Tunnel Setup for: ${node.name} (${node.location}) +# ============================================ + +# Step 1: Install Gost +curl -L https://github.com/ginuerzh/gost/releases/download/v2.11.5/gost-linux-amd64-2.11.5.gz -o gost.gz +gunzip gost.gz +chmod +x gost +mv gost /usr/local/bin/ + +`; + + let tunnelCmd = ''; + + switch (node.tunnel_type) { + case 'wss': + tunnelCmd = `# Step 2: Start Tunnel (WSS - DPI Resistant) +# This will forward local port ${node.local_forward_port} through the tunnel +gost -L "tcp://:${node.local_forward_port}" -F "relay+wss://${mainServerIp}:${mainServerListenPort}?auth=${authHeader}&path=/ws&serverName=${node.sni_host}"`; + break; + + case 'grpc': + tunnelCmd = `# Step 2: Start Tunnel (gRPC - Looks like Google traffic) +gost -L "tcp://:${node.local_forward_port}" -F "relay+grpc://${mainServerIp}:${mainServerListenPort}?auth=${authHeader}"`; + break; + + case 'quic': + tunnelCmd = `# Step 2: Start Tunnel (QUIC - UDP based) +gost -L "tcp://:${node.local_forward_port}" -F "relay+quic://${mainServerIp}:${mainServerListenPort}?auth=${authHeader}"`; + break; + + case 'h2': + tunnelCmd = `# Step 2: Start Tunnel (HTTP/2) +gost -L "tcp://:${node.local_forward_port}" -F "relay+h2://${mainServerIp}:${mainServerListenPort}?auth=${authHeader}"`; + break; + } + + const systemdService = ` + +# ============================================ +# Optional: Create Systemd Service for Auto-Start +# ============================================ +cat > /etc/systemd/system/gost-tunnel.service << 'EOF' +[Unit] +Description=Gost Tunnel to Main Server +After=network.target + +[Service] +Type=simple +ExecStart=/usr/local/bin/gost -L "tcp://:${node.local_forward_port}" -F "relay+${node.tunnel_type}://${mainServerIp}:${mainServerListenPort}?auth=${authHeader}${node.tunnel_type === 'wss' ? `&path=/ws&serverName=${node.sni_host}` : ''}" +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +EOF + +systemctl daemon-reload +systemctl enable gost-tunnel +systemctl start gost-tunnel + +# Check status +systemctl status gost-tunnel`; + + return installCmd + tunnelCmd + systemdService; +} + +/** + * Get human-readable description of tunnel type + */ +export function getTunnelTypeDescription(type: string): string { + switch (type) { + case 'wss': + return 'WebSocket over TLS - Appears as normal HTTPS traffic, best for bypassing DPI'; + case 'grpc': + return 'gRPC over HTTP/2 - Mimics Google services traffic'; + case 'quic': + return 'QUIC Protocol - Fast UDP-based encrypted tunnel'; + case 'h2': + return 'HTTP/2 Tunnel - Standard HTTP/2 based connection'; + default: + return 'Unknown tunnel type'; + } +} + +/** + * Get recommended tunnel type based on conditions + */ +export function getRecommendedTunnelType(): 'wss' | 'grpc' | 'quic' | 'h2' { + // WSS is generally the most DPI-resistant for Iranian firewall + return 'wss'; +} diff --git a/panel.sqlite b/panel.sqlite index e45ab67..9ce367c 100644 Binary files a/panel.sqlite and b/panel.sqlite differ diff --git a/schema.sql b/schema.sql index 97db3e1..c392de4 100644 --- a/schema.sql +++ b/schema.sql @@ -3,6 +3,7 @@ CREATE TABLE IF NOT EXISTS vpn_inbounds ( name VARCHAR(255) NOT NULL, protocol VARCHAR(50) NOT NULL, port INT NOT NULL, + server_address VARCHAR(255) NOT NULL, remark TEXT, status VARCHAR(50) DEFAULT 'active', -- OpenVPN specific @@ -114,6 +115,30 @@ CREATE TABLE IF NOT EXISTS vpn_servers ( created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); +-- Tunnel Nodes for DPI-resistant connections +CREATE TABLE IF NOT EXISTS tunnel_nodes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name VARCHAR(255) NOT NULL, + location VARCHAR(255) NOT NULL, + country_code VARCHAR(10), + flag_emoji VARCHAR(10), + remote_ip VARCHAR(45) NOT NULL, + tunnel_port INT NOT NULL DEFAULT 443, + tunnel_type VARCHAR(50) DEFAULT 'wss', + tunnel_secret VARCHAR(255) NOT NULL, + local_forward_port INT NOT NULL, + sni_host VARCHAR(255) DEFAULT 'www.google.com', + status VARCHAR(50) DEFAULT 'pending', + is_active BOOLEAN DEFAULT TRUE, + last_heartbeat TIMESTAMP, + active_connections INT DEFAULT 0, + total_traffic_bytes BIGINT DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_tunnel_nodes_status ON tunnel_nodes (status, is_active); + CREATE TABLE IF NOT EXISTS server_status_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, server_id INT, diff --git a/scripts/migrate-inbounds-v2.sql b/scripts/migrate-inbounds-v2.sql index 44ec975..9761822 100644 --- a/scripts/migrate-inbounds-v2.sql +++ b/scripts/migrate-inbounds-v2.sql @@ -6,6 +6,9 @@ -- Note: SQLite doesn't support IF NOT EXISTS for ALTER TABLE, -- so these will error if columns already exist (which is fine) +-- Server address (IP or domain) +ALTER TABLE vpn_inbounds ADD COLUMN server_address VARCHAR(255); + -- OpenVPN specific columns ALTER TABLE vpn_inbounds ADD COLUMN status VARCHAR(50) DEFAULT 'active'; ALTER TABLE vpn_inbounds ADD COLUMN ovpn_protocol VARCHAR(10) DEFAULT 'udp'; diff --git a/scripts/migrate-tunnel-nodes.sql b/scripts/migrate-tunnel-nodes.sql new file mode 100644 index 0000000..2808a7e --- /dev/null +++ b/scripts/migrate-tunnel-nodes.sql @@ -0,0 +1,26 @@ +-- Migration: Add Tunnel Nodes Table for DPI-Resistant Connections +-- Version: 1.0 +-- Description: Creates table for managing multi-location tunnel nodes + +CREATE TABLE IF NOT EXISTS tunnel_nodes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name VARCHAR(255) NOT NULL, + location VARCHAR(255) NOT NULL, + country_code VARCHAR(10), + flag_emoji VARCHAR(10), + remote_ip VARCHAR(45) NOT NULL, + tunnel_port INT NOT NULL DEFAULT 443, + tunnel_type VARCHAR(50) DEFAULT 'wss', + tunnel_secret VARCHAR(255) NOT NULL, + local_forward_port INT NOT NULL, + sni_host VARCHAR(255) DEFAULT 'www.google.com', + status VARCHAR(50) DEFAULT 'pending', + is_active BOOLEAN DEFAULT TRUE, + last_heartbeat TIMESTAMP, + active_connections INT DEFAULT 0, + total_traffic_bytes BIGINT DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_tunnel_nodes_status ON tunnel_nodes (status, is_active);