Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions app/api/inbounds/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export async function POST(req: Request) {
name,
protocol,
port,
server_address,
remark,
// OpenVPN fields
ovpn_protocol,
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand Down
110 changes: 110 additions & 0 deletions app/api/tunnel-nodes/route.ts
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +29 to +30
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Exclude tunnel secrets from node list responses

The list endpoint selects and returns all columns from tunnel_nodes, which includes tunnel_secret. This secret is the relay authentication credential used in generated commands, so exposing it in a broad list response allows anyone with API access to impersonate a tunnel node. The listing should omit tunnel_secret and only return it through a narrowly scoped privileged flow.

Useful? React with 👍 / 👎.

`);
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);
}
}
8 changes: 6 additions & 2 deletions app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<ViewType>('dashboard');
Expand All @@ -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 },
Expand All @@ -37,6 +39,8 @@ export default function Home() {
return <RepresentativesView />;
case 'nodes':
return <NodesView />;
case 'tunnel-nodes':
return <TunnelNodesView />;
case 'users':
return <UsersView />;
case 'inbounds':
Expand Down
25 changes: 22 additions & 3 deletions components/views/inbounds-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ interface Inbound {
name: string;
protocol: string;
port: number;
server_address: string;
remark: string;
status: string;
created_at: string;
Expand All @@ -52,6 +53,7 @@ interface InboundFormData {
name: string;
protocol: string;
port: string;
server_address: string;
remark: string;
// OpenVPN
ovpn_protocol: string;
Expand Down Expand Up @@ -91,6 +93,7 @@ const initialFormData: InboundFormData = {
name: '',
protocol: 'openvpn',
port: '',
server_address: '',
remark: '',
// OpenVPN defaults
ovpn_protocol: 'udp',
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -728,7 +731,7 @@ export default function InboundsView() {
</h3>
<form onSubmit={handleCreate} className="space-y-6">
{/* Basic Info */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-[10px] font-bold text-slate-400 uppercase mb-1">Inbound Name</label>
<input
Expand All @@ -739,6 +742,18 @@ export default function InboundsView() {
placeholder="e.g. EU-OpenVPN-Main"
/>
</div>
<div>
<label className="block text-[10px] font-bold text-slate-400 uppercase mb-1">Server Address (IP or Domain)</label>
<input
type="text"
value={formData.server_address}
onChange={(e) => 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"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-[10px] font-bold text-slate-400 uppercase mb-1">Protocol</label>
<select
Expand Down Expand Up @@ -819,6 +834,7 @@ export default function InboundsView() {
<thead>
<tr className="bg-slate-50/50 border-b border-slate-100 text-[10px] text-slate-500 font-bold uppercase tracking-widest">
<th className="px-6 py-4">Gateway Name</th>
<th className="px-6 py-4">Server Address</th>
<th className="px-6 py-4">Protocol</th>
<th className="px-6 py-4">Port</th>
<th className="px-6 py-4">Status</th>
Expand All @@ -845,6 +861,9 @@ export default function InboundsView() {
<span className="font-bold text-slate-900 tracking-tight">{inbound.name}</span>
</div>
</td>
<td className="px-6 py-4">
<span className="font-mono text-xs text-slate-600">{inbound.server_address}</span>
</td>
<td className="px-6 py-4">
<span className={`inline-flex items-center px-2 py-1 rounded-lg text-[10px] font-bold uppercase tracking-widest ${getProtocolColor(inbound.protocol)}`}>
{inbound.protocol}
Expand Down
Loading
Loading