'use strict'; const state = { session: null, page: 'overview', overview: null, servers: [], customers: [], nodes: [], nodeClients: [], jobs: [], protocols: [], realityPresets: [], search: '', version: '0.6.8' }; const $ = (selector, root = document) => root.querySelector(selector); const $$ = (selector, root = document) => [...root.querySelectorAll(selector)]; const esc = (value) => String(value == null ? '' : value).replace(/[&<>'"]/g, (char) => ({ '&':'&', '<':'<', '>':'>', "'":''', '"':'"' })[char]); const selected = (value, current) => String(value) === String(current) ? ' selected' : ''; const checked = (value, values) => (values || []).includes(value) ? ' checked' : ''; const fmtDate = (value) => value ? new Intl.DateTimeFormat('zh-CN', { dateStyle: 'short', timeStyle: 'short' }).format(new Date(value)) : '—'; const fmtBytes = (value) => { let size = Number(value || 0); const units = ['B','KB','MB','GB','TB','PB']; let index = 0; while (size >= 1024 && index < units.length - 1) { size /= 1024; index += 1; } return `${size >= 10 || index === 0 ? size.toFixed(0) : size.toFixed(1)} ${units[index]}`; }; const oldUsageAgent = (version) => { const parts = String(version || '').match(/^(\d+)\.(\d+)\.(\d+)/); return parts && Number(parts[1]) === 0 && (Number(parts[2]) < 6 || (Number(parts[2]) === 6 && Number(parts[3]) < 3)); }; async function copyText(text) { text = String(text ?? ''); if (window.isSecureContext && navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(text); return; } // HTTP 下 clipboard API 不可用,降级用 execCommand。 // 注意:弹窗是 模态的,此时 document.body 处于 inert 状态, // textarea 必须插进弹窗内部,否则 select/execCommand 会复制走弹窗内的当前选区。 const ta = document.createElement('textarea'); ta.value = text; ta.setAttribute('readonly', ''); ta.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;opacity:0;pointer-events:none;'; const dialog = $('#modal'); const host = (dialog && dialog.open) ? dialog : document.body; host.appendChild(ta); ta.focus(); ta.select(); try { ta.setSelectionRange(0, ta.value.length); } catch (e) {} let ok = false; try { ok = document.execCommand('copy'); } catch (e) { ok = false; } ta.remove(); if (!ok) throw new Error('复制失败,请手动长按复制'); } const splitList = (value) => String(value || '').split(',').map((item) => item.trim()).filter(Boolean); const statusText = { online:'在线', offline:'离线', pending:'待注册', active:'运行中', draft:'草稿', deploying:'部署中', queued:'排队中', running:'执行中', completed:'已完成', failed:'失败', degraded:'异常', suspended:'已暂停', removing:'停用中', deleted:'已删除' }; const roleText = { relay:'入口 / 转发', exit:'出口 / 落地', hybrid:'综合节点' }; const topologyText = { forward:'转发线路', direct:'单机直连' }; const auditText = { create_server:'添加设备', update_server:'编辑设备', delete_server:'删除设备', create_enrollment:'生成注册令牌', enroll_agent:'Agent 注册', reconcile_server:'设备配置对账', set_tls_domain:'节点证书同步', create_customer:'添加客户', update_customer:'编辑客户', reset_customer_usage:'重置客户流量', delete_customer:'删除客户', create_node:'创建节点', update_node:'编辑节点', deploy_node:'部署节点', repair_node:'修复节点', remove_node:'停用节点', delete_node:'删除节点', assign_customer:'分配客户', unassign_customer:'移除分配', suspend_customer_resources:'暂停客户资源', resume_customer_resources:'恢复客户资源', retry_cleanup:'重试遗留清理', force_forget_server:'强制遗忘离线设备', update_account:'修改管理员账号', suspend_ip_limit:'IP 超限停用', rotate_subscription:'重置订阅链接' }; function setTheme(theme) { const next = theme === 'dark' ? 'dark' : 'light'; document.documentElement.dataset.theme = next; try { localStorage.setItem('nexusgate-theme', next); } catch { /* storage may be disabled */ } document.querySelector('meta[name="theme-color"]').content = next === 'dark' ? '#0e1421' : '#f5f7fc'; document.querySelector('meta[name="color-scheme"]').content = next; $$('[data-theme-toggle]').forEach((button) => { button.textContent = next === 'dark' ? '☾ 深色' : '☀ 浅色'; button.title = next === 'dark' ? '切换为浅色主题' : '切换为深色主题'; button.setAttribute('aria-pressed', String(next === 'dark')); }); } function toast(message, error = false) { const item = document.createElement('div'); item.className = `toast${error ? ' error' : ''}`; item.textContent = message; $('#toasts').append(item); setTimeout(() => item.remove(), 3800); } async function api(path, options = {}) { const headers = { ...(options.body ? { 'content-type': 'application/json' } : {}), ...(options.headers || {}) }; if (state.session && !['GET','HEAD'].includes(options.method || 'GET')) headers['x-csrf-token'] = state.session.csrf; const response = await fetch(path, { ...options, headers }); const type = response.headers.get('content-type') || ''; const data = type.includes('json') ? await response.json() : await response.text(); if (!response.ok) { if (response.status === 401 && path !== '/api/auth/login') showLogin(); throw new Error(data.message || `请求失败 (${response.status})`); } return data; } function showLogin() { state.session = null; $('#app').classList.add('hidden'); $('#login').classList.remove('hidden'); } function showApp() { $('#login').classList.add('hidden'); $('#app').classList.remove('hidden'); $('#user-chip').textContent = `${state.session.user.username} · 管理员`; } async function load(page = state.page) { try { if (page === 'overview') { const [overview, servers, customers, routes] = await Promise.all([api('/api/overview'), api('/api/servers'), api('/api/customers'), api('/api/nodes')]); state.overview = overview; state.version = overview.version || state.version; state.servers = servers.servers; state.customers = customers.customers; state.nodes = routes.nodes; state.nodeClients = routes.nodeClients; } else if (page === 'servers') state.servers = (await api('/api/servers')).servers; else if (page === 'customers') { const [customers, routes] = await Promise.all([api('/api/customers'), api('/api/nodes')]); state.customers = customers.customers; state.nodes = routes.nodes; state.nodeClients = routes.nodeClients; } else if (page === 'chains') { const [servers, customers, routes, protocols] = await Promise.all([api('/api/servers'), api('/api/customers'), api('/api/nodes'), api('/api/protocols')]); state.servers = servers.servers; state.customers = customers.customers; state.nodes = routes.nodes; state.nodeClients = routes.nodeClients; state.protocols = protocols.profiles; state.realityPresets = protocols.realityPresets || []; } else if (page === 'commands') { /* Command reference needs no API request. */ } else if (page === 'operations') { const [jobs, servers, overview] = await Promise.all([api('/api/jobs'), api('/api/servers'), api('/api/overview')]); state.jobs = jobs.jobs; state.servers = servers.servers; state.overview = overview; state.version = overview.version || state.version; } render(); } catch (error) { toast(error.message, true); } } function setPage(page) { state.page = page; state.search = ''; closeSidebar(); $$('#nav button').forEach((button) => button.classList.toggle('active', button.dataset.page === page)); const titles = { overview:'概览', servers:'服务器', customers:'客户与订阅', chains:'转发与节点', commands:'安装与命令', operations:'设置与运维' }; $('#page-title').textContent = titles[page]; $('#breadcrumb').textContent = `NEXUSGATE / ${titles[page]}`; load(page); } function status(value) { return `${esc(statusText[value] || value)}`; } function tags(values) { return (values || []).map((value) => `${esc(value)}`).join('') || '—'; } function empty(title, note) { return `
${esc(title)}${esc(note)}
`; } function names(ids, collection) { return (ids || []).map((key) => (collection.find((item) => item.id === key) || {}).name || '已删除').join('、'); } function protocolName(id, role) { return (state.protocols.find((item) => item.id === id && (!role || item.role === role)) || {}).name || id || '—'; } function renderOverview() { const counts = (state.overview && state.overview.counts) || {}; const metrics = [ ['受管设备', counts.servers || 0, `${counts.online || 0} 台在线`, '#38bd91'], ['客户账户', counts.customers || 0, '额度与到期统一管理', '#5877f4'], ['共享节点', counts.nodes || 0, '多客户共用入口', '#9667e8'], ['运行中节点', counts.activeNodes || 0, '实际运行资源', '#e8a63c'], ['待办任务', counts.queuedJobs || 0, 'Agent 自动领取', '#e5667c'] ]; const serverRows = state.servers.slice(0, 7).map((server) => `
${esc(server.name)}${esc(roleText[server.role] || server.role)} · ${esc(server.region || server.publicAddress)}
${status(server.status)}
`).join(''); const events = ((state.overview && state.overview.activity) || []).map((event) => `
${esc(auditText[event.action] || event.action)}${esc(event.actor)} · ${esc(event.target)}
`).join(''); const failures = ((state.overview && state.overview.degraded) || []).length; return `
${metrics.map(([label,value,note,color]) => `
${label}${value}${note}
`).join('')}
${failures ? `
检测到 ${failures} 个异常节点。请到“转发与节点”查看错误后使用“修复”。更新 Agent 后无需重建节点。
` : ''}

设备健康

超过三分钟未上报将标记离线

${serverRows || empty('还没有设备','先添加入口、出口或综合节点')}

最近活动

重要操作审计记录

${events || empty('暂无活动','操作记录会显示在这里')}
`; } function renderServers() { const q = state.search.toLowerCase(); const resources = (system) => { if (!system || !Number.isFinite(system.memoryTotal)) return ''; const memory = Number(system.memoryAvailable ?? system.memoryFree); const disk = Number(system.diskAvailable); const warning = memory < 200 * 1024 * 1024 || (Number.isFinite(disk) && disk < 1024 ** 3); const text = `内存可用 ${fmtBytes(memory)} / ${fmtBytes(system.memoryTotal)} · 负载 ${esc(system.load1 ?? '—')} / ${esc(system.cpuCount ?? '—')} 核` + (Number.isFinite(disk) ? ` · 系统盘可用 ${fmtBytes(disk)} / ${fmtBytes(system.diskTotal)}` : ''); return `${text}${warning ? ' · 资源余量偏低' : ''}`; }; const rows = state.servers.filter((item) => [item.name,item.region,item.publicAddress,item.publicAddressV6,...(item.labels || [])].join(' ').toLowerCase().includes(q)).map((server) => ` ${esc(server.name)}${esc(server.publicAddress)}${server.publicAddressV6 ? ` · ${esc(server.publicAddressV6)}` : ''}${esc(roleText[server.role] || server.role)}${esc(server.region || '未分组')} ${tags(server.labels)}${status(server.status)}${server.lastSeenAt ? `最后上报 ${fmtDate(server.lastSeenAt)}` : '等待 Agent 注册'} · Agent ${esc(server.agentVersion || '未上报版本')}${resources(server.system)}${server.tlsDomain ? `节点证书 ${esc(server.tlsDomain)} · ${server.engine?.certificates?.includes(server.tlsDomain) ? '已确认' : '待确认'}` : ''}${server.role !== 'exit' && server.engine ? `AnyTLS 引擎 · ${server.engine.singBoxInstalled ? '已安装' : '按需安装(ng-agent engine install)'}` : ''}${oldUsageAgent(server.agentVersion) ? '旧版流量采集有问题,请在该机运行 ng-agent-update' : ''}${server.usage ? `流量统计 ${fmtDate(server.usage.lastReportAt)} · ${server.usage.sampleCount} 项` : '尚无流量统计上报'}${server.usage?.error ? `统计异常:${esc(server.usage.error)}` : ''}${server.engine && server.engine.status === 'error' ? `引擎故障:${esc(server.engine.detail)}` : ''}${server.pendingCleanup ? `待清理 ${server.pendingCleanup} 项` : ''} ${rowActions(server.id, ``, [ ['agent-uninstall','SSH 卸载'], ...(server.pendingCleanup ? [['retry-cleanup','重试清理']] : []), server.pendingCleanup && server.status !== 'online' ? ['forget-server','遗忘离线设备'] : ['delete-server','删除设备'] ])}`).join(''); return `

统一管理入口转发机、出口落地机与单机节点。Agent 主动连接控制面,不保存设备 SSH 密码;重装 Agent 会自动对账并恢复已有资源。

${state.servers.length} 台
${rows || ``}
设备用途地区标签状态操作
${empty('没有匹配设备','添加设备后生成一次性注册命令')}
`; } function renderCustomers() { const q = state.search.toLowerCase(); const rows = state.customers.filter((item) => [item.name,item.group,...(item.tags || [])].join(' ').toLowerCase().includes(q)).map((item) => { const percent = item.trafficLimitBytes ? Math.min(100, Math.round(item.usedBytes / item.trafficLimitBytes * 100)) : 0; return `${esc(item.name)}${esc(item.group || '未分组')}${status(item.status)}${item.status === 'suspended' ? `${esc(({ traffic_limit:'额度耗尽', expired:'已到期', ip_limit:'节点 IP 超限', manual:'手动停用' })[item.suspendReason] || '待恢复')}` : ''} ${fmtBytes(item.usedBytes)} / ${item.trafficLimitBytes ? fmtBytes(item.trafficLimitBytes) : '不限'}上行 ${fmtBytes(item.usedUplinkBytes)} · 下行 ${fmtBytes(item.usedDownlinkBytes)}
${item.lastUsageAt ? `最后计量 ${fmtDate(item.lastUsageAt)}` : '尚未收到入口流量统计'} ${item.expiresAt ? fmtDate(item.expiresAt) : '不限期'}节点 IP ${item.observedIpCount || 0} / ${item.ipLimit || '不限'}订阅客户端约 ${item.subscriptionClientCount || 0} / ${item.deviceLimit || '不限'}${tags(item.tags)} ${rowActions(item.id, ``, [['assign-nodes','分配节点'],['reset-usage','流量清零'],['toggle-customer',item.status === 'active' ? '停用' : '启用'],['delete-customer','删除客户']])}`; }).join(''); return `

流量按入口单向累计,Agent 约每分钟上报,按客户跨节点汇总。额度、到期或节点 IP 触发暂停后,调整限制会自动恢复;待 Agent 完成恢复任务,订阅才可更新。

${state.customers.length} 位
${rows || ``}
客户状态流量到期使用限制标签操作
${empty('还没有客户','先创建客户,再到“转发与节点”分配')}
`; } function nodeActions(node) { const edit = ``; const clients = ``; const remove = ``; if (node.status === 'draft') return `${edit}${clients}${remove}`; if (['failed', 'degraded'].includes(node.status)) return `${edit}${clients}${remove}`; if (['active', 'deploying'].includes(node.status)) return `${edit}${clients}${remove}`; return `${edit}${clients}${remove}`; } function rowActions(id, primary, more) { return `
${primary}
`; } function compactNodeActions(node) { const actions = nodeActions(node).match(/]*>[^<]*<\/button>/g) || []; const primary = actions.slice(0, 2).join(''); const more = actions.slice(2).map((html) => [(html.match(/data-action="([^"]+)"/) || [,''])[1], html.replace(/<[^>]+>/g, '')]); return more.length ? rowActions(node.id, primary, more) : `
${primary}
`; } function nodeClientCount(nodeId) { return state.nodeClients.filter((nc) => nc.nodeId === nodeId && nc.status === 'active').length; } function renderNodes() { const rows = state.nodes.map((node) => { const direct = (node.topology || 'forward') === 'direct'; const path = direct ? protocolName(node.protocol, 'relay-ingress') : `${protocolName(node.protocol, 'relay-ingress')} → ${protocolName(node.exitProtocol, 'exit-transport')}`; const server = state.servers.find((item) => item.id === node.serverId); const exit = direct ? '本机直出' : ((state.servers.find((item) => item.id === node.exitServerId) || {}).name || '已删除'); const traffic = node.meteredTraffic ? `上行 ${fmtBytes(node.meteredTraffic.uplink)} · 下行 ${fmtBytes(node.meteredTraffic.downlink)}` : '等待入口计量上报'; return `${esc(node.name)}${esc(topologyText[node.topology || 'forward'])} · ${esc(path)} ${esc(server ? server.name : '已删除')}${esc(node.protocol)} :${node.port} · ${esc(node.networkMode || 'ipv4').toUpperCase()}${esc(exit)} ${nodeClientCount(node.id)} 位${traffic}${node.lastUsageAt ? `最后上报 ${fmtDate(node.lastUsageAt)}` : ''} ${status(node.status)}${node.error ? `${esc(node.error)}` : ''}${compactNodeActions(node)}`; }).join(''); return `

一个节点是一个共享入口(对标 3x-ui 的 inbound):多位客户共用同一端口,各自使用独立凭据。分配 / 移除客户后节点自动重建下发;编辑运行中的节点会立即重建应用。

${rows || ``}
节点服务器 / 入口出口客户端流量状态操作
${empty('还没有节点','准备好设备和客户后创建第一个共享节点')}
`; } function renderNodeLinks() { // 按节点分组:一个节点一行,客户收纳为可展开列表 const byNode = new Map(); for (const nc of state.nodeClients) { if (!byNode.has(nc.nodeId)) byNode.set(nc.nodeId, []); byNode.get(nc.nodeId).push(nc); } const rows = [...byNode].map(([nodeId, ncs]) => { const node = state.nodes.find((item) => item.id === nodeId); const server = node && state.servers.find((item) => item.id === node.serverId); const nodeActive = node && node.status === 'active'; const customerRows = ncs.map((nc) => { const customer = state.customers.find((item) => item.id === nc.customerId); const canCopy = nc.status === 'active' && nodeActive && nc.clientUri && customer && customer.status === 'active'; const name = esc(customer ? customer.name : '已删除客户'); const st = status(nc.status); const link = canCopy ? `` : '未运行或已停用'; return ``; }).join(''); const names = ncs.map((nc) => { const c = state.customers.find((item) => item.id === nc.customerId); return esc(c ? c.name : '?'); }).join('、'); return `${esc(node ? node.name : '已删除节点')}${esc(server ? server.name : '')}${node ? ` :${node.port}` : ''}
${names}(${ncs.length})
${node ? status(node.status) : status('draft')} ${ncs.length} 个分配`; }).join(''); return `

每个客户在每个已分配节点上拥有独立凭据。客户端链接只在节点运行中且客户正常时生成;日常分发请使用客户页的订阅链接。

${rows || ``}
节点客户状态链接
${empty('暂无分配','先在“转发与节点”给客户分配节点')}
`; } function renderCommands() { const groups = [ ['面板机', [ ['更新控制面', 'ng update', '只在面板机运行;更新后刷新浏览器。'], ['检查面板 HTTPS', 'ng cert', '只检查面板的 Caddy 证书,不会申请入口节点证书。'], ['备份面板', 'ng backup', '客户凭据会包含在备份中,请妥善保管。'] ]], ['入口机 / 中转机', [ ['更新 Agent', 'ng-agent update', '更新后自动恢复心跳;已有线路无需重建。'], ['申请或续签节点证书', 'ng-agent cert', '按提示输入已解析到本机的域名。公网 80/TCP 可用时选择 HTTP;否则选择 Cloudflare DNS 并输入 Zone DNS Edit API Token。已有有效证书不必重复申请。'], ['安装 sing-box 引擎', 'ng-agent engine install', '所有入口/出口机都需要。首次构建可能耗时,临时 Go 缓存完成后会清理。'], ['检查证书、引擎与双向计数', 'ng-agent doctor', '核对本机证书、sing-box 与入口统计。'] ]], ['出口机 / 落地机', [ ['更新 Agent', 'ng-agent update', '入口/出口机通用;出口机无需申请入口证书。'], ['检查运行状态', 'ng-agent doctor', '核对心跳和 sing-box 配置。'] ]] ]; const sections = groups.map(([title, commands]) => `

${esc(title)}

${commands.map(([label, command, hint]) => `
${esc(label)}${esc(hint)}${esc(command)}
`).join('')}
`).join(''); return `

在对应服务器的 SSH 终端以 root 运行。首次注册请在“服务器”选择目标设备并生成一次性注册命令;证书域名需要先解析到入口机,AnyTLS 须单独安装 sing-box。

${sections}
`; } function renderOperations() { const rows = state.jobs.slice(0, 100).map((job) => `${esc(job.action === 'apply_resource' ? '应用资源' : '移除资源')}${esc(job.id)}${esc((state.servers.find((item) => item.id === job.serverId) || {}).name || job.serverId)}${status(job.status)}${job.attempts}${fmtDate(job.updatedAt)}${esc(job.error || '')}`).join(''); return `

账号安全

修改后台登录账号或密码

备份与恢复

浏览器 JSON 与整机迁移包

备份含客户凭据,请加密保管。整机迁移推荐 SSH 运行 ng backup,新机安装后把压缩包放进 /root,再运行 ng restore。

版本与证书

轻量单进程控制面

NexusGate当前版本
v${esc(state.version)}
Caddy HTTPS证书自动申请与续签
${status('active')}
ng update\nng domain new.example.com\nng cert

任务队列

任务租约 5 分钟,超时自动重试,最多 3 次

${rows || ``}
任务设备状态尝试更新时间错误
${empty('任务队列为空','部署或停用线路后会产生任务')}
`; } function render() { const views = { overview: renderOverview, servers: renderServers, customers: renderCustomers, chains: renderNodes, commands: renderCommands, operations: renderOperations }; $('#content').innerHTML = views[state.page](); } function modal(kicker, title, body) { $('#modal-kicker').textContent = kicker; $('#modal-title').textContent = title; $('#modal-body').innerHTML = body; if (!$('#modal').open) $('#modal').showModal(); } function serverForm(item = null) { const server = item || { role:'hybrid', portRangeStart:20000, portRangeEnd:50000, labels:[] }; modal(item ? 'EDIT DEVICE' : 'NEW DEVICE', item ? '编辑设备' : '添加设备', `
`); } function localDateParts(value) { if (!value) return { date:'', time:'23:59' }; const date = new Date(value); const pad = (number) => String(number).padStart(2, '0'); return { date:`${date.getFullYear()}-${pad(date.getMonth()+1)}-${pad(date.getDate())}`, time:`${pad(date.getHours())}:${pad(date.getMinutes())}` }; } function timeOptions(current = '23:59') { const values = ['00:00','06:00','12:00','18:00','23:59']; if (current && !values.includes(current)) values.push(current); return values.sort().map((value) => ``).join(''); } function customerForm(item = null) { const customer = item || { trafficLimitBytes:0, ipLimit:2, deviceLimit:3, tags:[] }; const expiry = localDateParts(customer.expiresAt); modal(item ? 'EDIT CUSTOMER' : 'NEW CUSTOMER', item ? '编辑客户' : '添加客户', `
`); } function protocolOptions(role, current) { return state.protocols.filter((item) => item.role === role && item.deployable).map((item) => ``).join(''); } function multiPicker(name, items, values, subtitle, emptyLabel) { return `
已选择 ${(values || []).length} 项▾
${items.map((item) => ``).join('') || `没有可用${esc(emptyLabel)}`}
支持搜索后连续多选,已选项在折叠后保留。
`; } function subscriptionModal(customer) { if (!customer || !customer.subscriptionToken) return toast('客户订阅令牌尚未生成,请刷新页面', true); const formats = [ ['auto','自动识别客户端'], ['v2ray','V2Ray / v2rayNG'], ['shadowrocket','Shadowrocket 小火箭'], ['base64','通用 Base64'], ['clash','Mihomo / Clash 简洁版'], ['clash-smart','Mihomo / Clash 智能分流'], ['singbox','sing-box JSON'], ['surge','Surge(仅兼容节点)'], ['raw','原始节点链接'] ]; // 该用户的各节点入站链接 const infoUrl = `${location.origin}/s/${customer.subscriptionToken}/info`; const nodeLinks = state.nodeClients.filter((nc) => nc.customerId === customer.id && nc.status === 'active').map((nc) => { const node = state.nodes.find((n) => n.id === nc.nodeId); if (!node || node.status !== 'active' || !nc.clientUri) return ''; const server = state.servers.find((s) => s.id === node.serverId); const label = `${node.name} (${server ? server.name : '?'}) :${node.port}`; return `
${esc(label)}${esc(nc.clientUri.slice(0, 60))}...
`; }).join(''); modal('CLIENT SUBSCRIPTION', `${customer.name} · 订阅链接`, `
仅展示已成功部署的入口节点。客户停用、到期或流量用尽时链接停止分发。重置令牌会立即使旧订阅地址失效;已复制的节点凭据仍需停用或重建线路才能撤销。
📱 订阅信息页${esc(infoUrl)}
${formats.map(([format,label]) => { const url = `${location.origin}/s/${customer.subscriptionToken}/${format}`; return `
${esc(label)}${esc(url)}
`; }).join('')} ${nodeLinks ? `

各节点入站链接

${nodeLinks}` : ''}
二维码在当前浏览器本地生成,不向第三方图片服务发送订阅令牌。Surge 只包含它原生支持的节点协议。
订阅客户端数按客户端标识或 User-Agent 估算,不能证明物理设备数量;共享节点链接可复制,订阅请求被拒后已导入节点仍可连接。节点 IP 超限需要等待 Agent 上报和清理任务。
`); } async function customerAccessModal(customer) { const data = await api(`/api/customers/${customer.id}/access`); const ips = data.observedIps.map((item) => `${esc(item.ip)}${fmtDate(item.firstSeenAt)}${fmtDate(item.lastSeenAt)}`).join(''); const logs = data.events.map((item) => `${fmtDate(item.at)}${item.status === 200 ? '已返回' : `${item.status} ${esc(item.reason)}`}${esc(item.ip)}${esc(item.userAgent || '未提供 User-Agent')}${esc(item.format)}${fmtBytes(item.bytes)}`).join(''); modal('ACCESS ACTIVITY', `${customer.name} · 访问记录`, `
节点 IP:${data.observedIps.length} / ${data.limits.ip || '不限'}(滚动观察窗口);订阅客户端约:${data.subscriptionClients} / ${data.limits.subscriptionClients || '不限'}(最近 24 小时)。订阅统计不能识别物理设备;同一 User-Agent 可能来自多台设备,客户端升级也可能改变 User-Agent。
节点观测 IP
${ips || ''}
IP首次最后
当前窗口没有 Agent 上报
最近订阅请求(最多 100 条)
${logs || ''}
时间结果来源 / 客户端格式返回
暂无访问记录
`); } function nodeForm(item = null) { const node = item || { topology:'forward', protocol:'vless-reality-vision', exitProtocol:'shadowsocks-2022-aes128', portMode:'random', exitPortMode:'random', networkMode:'ipv4', realityServerName:'www.tesla.com', realityDestPort:443 }; const preset = state.realityPresets.find((entry) => entry.serverName === node.realityServerName) || { id:'custom' }; modal(item ? 'EDIT NODE' : 'NEW NODE', item ? '编辑节点' : '新建节点', `
出口与转发传输
保存运行中的节点会立即重建下发(sing-box 重启一次)。VLESS WS TLS、Hysteria 2 和 AnyTLS 需要入口证书;Hysteria 2 使用 UDP,其余使用 TCP。全部由 sing-box 承载。
`); syncNodeForm(); } function syncNodeForm() { const form = $('#node-form'); if (!form) return; const direct = form.elements.topology.value === 'direct'; $$('[data-forward-only]', form).forEach((element) => element.classList.toggle('hidden', direct)); form.elements.exitServerId.required = !direct; form.elements.exitProtocol.required = !direct; const reality = ['vless-reality-vision','vless-reality'].includes(form.elements.protocol.value); $$('[data-reality-only]', form).forEach((element) => element.classList.toggle('hidden', !reality)); form.elements.realityServerName.required = reality; const protocol = state.protocols.find((item) => item.id === form.elements.protocol.value && item.role === 'relay-ingress'); $('[data-protocol-note]', form).textContent = protocol ? `入口:${protocol.description} ${reality ? 'Reality 默认使用 Tesla SNI,可从下拉菜单切换。' : '此协议不需要 Reality SNI。'}` : '请选择可部署的入口协议。'; for (const [mode, port] of [['portMode','port'],['exitPortMode','exitPort']]) { const fixed = form.elements[mode].value === 'fixed' && (mode === 'portMode' || !direct); form.elements[port].disabled = !fixed; form.elements[port].required = fixed; } } function nodeClientsModal(node) { const assigned = state.nodeClients.filter((nc) => nc.nodeId === node.id); const available = state.customers.filter((customer) => customer.status === 'active' && !assigned.some((nc) => nc.customerId === customer.id)); const rows = assigned.map((nc) => { const customer = state.customers.find((item) => item.id === nc.customerId); return `${esc(customer ? customer.name : '已删除客户')}${esc(customer ? (customer.group || '未分组') : nc.customerId)} ${status(nc.status)}
${nc.clientUri ? `` : ''}
`; }).join(''); modal('NODE CLIENTS', `${node.name} · 客户端分配`, `
多位客户共用同一入口端口,各自使用独立凭据。分配 / 移除后节点自动重建下发(sing-box 重启一次)。
${rows || ``}
客户状态操作
${empty('暂无客户端','从下方选择客户分配')}
${available.length ? `
` : '没有可分配的活跃客户。'}
`); } function assignNodesModal(customer) { const assigned = new Set(state.nodeClients.filter((nc) => nc.customerId === customer.id).map((nc) => nc.nodeId)); const rows = state.nodes.map((node) => { const server = state.servers.find((item) => item.id === node.serverId); return ``; }).join(''); modal('ASSIGN NODES', `${customer.name} · 分配节点`, `
勾选即分配,取消即移除。分配到运行中的节点会立即重建下发。
${rows || '还没有节点,先去“转发与节点”创建'}
`); } function expiryIso(formData) { const date = formData.get('expiryDate'); if (!date) return null; const time = formData.get('expiryTime') || '23:59'; const result = new Date(`${date}T${time}:00`); if (!Number.isFinite(result.getTime())) throw new Error('请选择有效的到期日期'); return result.toISOString(); } document.addEventListener('submit', async (event) => { event.preventDefault(); const form = event.target; const data = new FormData(form); try { if (form.id === 'login-form') { $('#login-error').textContent = ''; state.session = await api('/api/auth/login', { method:'POST', body:JSON.stringify({ username:data.get('username'), password:data.get('password') }) }); showApp(); setPage('overview'); return; } if (form.id === 'server-form') { const resourceId = data.get('resourceId'); const payload = { name:data.get('name'), role:data.get('role'), region:data.get('region'), publicAddress:data.get('publicAddress'), publicAddressV6:data.get('publicAddressV6'), tlsDomain:data.get('tlsDomain'), portRangeStart:Number(data.get('portRangeStart')), portRangeEnd:Number(data.get('portRangeEnd')), labels:splitList(data.get('labels')) }; await api(resourceId ? `/api/servers/${resourceId}` : '/api/servers', { method:resourceId ? 'PATCH' : 'POST', body:JSON.stringify(payload) }); $('#modal').close(); toast(resourceId ? '设备信息已更新' : '设备已添加'); await load('servers'); } else if (form.id === 'customer-form') { const resourceId = data.get('resourceId'); const payload = { name:data.get('name'), group:data.get('group'), trafficLimitBytes:Number(data.get('trafficGb') || 0) * 1024 ** 3, expiresAt:expiryIso(data), ipLimit:Number(data.get('ipLimit') || 0), deviceLimit:Number(data.get('deviceLimit') || 0), tags:splitList(data.get('tags')), notes:data.get('notes') }; await api(resourceId ? `/api/customers/${resourceId}` : '/api/customers', { method:resourceId ? 'PATCH' : 'POST', body:JSON.stringify(payload) }); $('#modal').close(); toast(resourceId ? '客户信息已更新' : '客户已创建'); await load('customers'); } else if (form.id === 'node-form') { const resourceId = data.get('resourceId'); const payload = { name:data.get('name'), serverId:data.get('serverId'), protocol:data.get('protocol'), portMode:data.get('portMode'), port:data.get('port') || null, networkMode:data.get('networkMode'), topology:data.get('topology'), exitServerId:data.get('exitServerId') || null, exitProtocol:data.get('exitProtocol') || null, exitPortMode:data.get('exitPortMode') || null, exitPort:data.get('exitPort') || null, realityServerName:data.get('realityServerName'), realityDestPort:Number(data.get('realityDestPort') || 443) }; const result = await api(resourceId ? `/api/nodes/${resourceId}` : '/api/nodes', { method:resourceId ? 'PATCH' : 'POST', body:JSON.stringify(payload) }); $('#modal').close(); toast(resourceId ? '节点已更新,运行中节点已重建下发' : '节点已创建,请分配客户后部署'); await load('chains'); } else if (form.id === 'assign-form') { await api(`/api/nodes/${data.get('nodeId')}/assign`, { method:'POST', body:JSON.stringify({ customerId:data.get('customerId') }) }); toast('客户已分配'); const node = state.nodes.find((item) => item.id === data.get('nodeId')); await load('chains'); if (node) nodeClientsModal(state.nodes.find((item) => item.id === node.id)); } else if (form.id === 'assign-nodes-form') { const customerId = data.get('customerId'); const wanted = new Set(data.getAll('nodeIds')); const current = state.nodeClients.filter((nc) => nc.customerId === customerId).map((nc) => nc.nodeId); for (const nodeId of current.filter((id) => !wanted.has(id))) await api(`/api/nodes/${nodeId}/unassign`, { method:'POST', body:JSON.stringify({ customerId }) }); for (const nodeId of [...wanted].filter((id) => !current.includes(id))) await api(`/api/nodes/${nodeId}/assign`, { method:'POST', body:JSON.stringify({ customerId }) }); $('#modal').close(); toast('节点分配已更新'); await load('customers'); } else if (form.id === 'account-form') { if (data.get('newPassword') !== data.get('confirmPassword')) throw new Error('两次输入的新密码不一致'); await api('/api/account', { method:'PATCH', body:JSON.stringify({ username:data.get('username'), currentPassword:data.get('currentPassword'), newPassword:data.get('newPassword') }) }); toast('账号已更新,请重新登录'); $('#login-form [name="username"]').value = data.get('username'); showLogin(); } } catch (error) { if (form.id === 'login-form') $('#login-error').textContent = error.message; else toast(error.message, true); } }); document.addEventListener('click', async (event) => { const theme = event.target.closest('[data-theme-toggle]'); if (theme) { setTheme(document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'); return; } const nav = event.target.closest('[data-page]'); if (nav) { setPage(nav.dataset.page); return; } const jump = event.target.closest('[data-page-jump]'); if (jump) { setPage(jump.dataset.pageJump); return; } if (event.target.closest('[data-close]')) { $('#modal').close(); return; } const button = event.target.closest('[data-action]'); if (!button) return; const action = button.dataset.action; const itemId = button.dataset.id; try { if (action === 'add-server') serverForm(); else if (action === 'edit-server') serverForm(state.servers.find((item) => item.id === itemId)); else if (action === 'add-customer') customerForm(); else if (action === 'edit-customer') customerForm(state.customers.find((item) => item.id === itemId)); else if (action === 'customer-subscription') subscriptionModal(state.customers.find((item) => item.id === itemId)); else if (action === 'customer-access') await customerAccessModal(state.customers.find((item) => item.id === itemId)); else if (action === 'reset-access' && confirm('清空此客户的订阅访问日志和最近 24 小时的客户端估计数?节点 IP 观察记录不受影响。')) { await api(`/api/customers/${itemId}/access`, { method:'DELETE' }); toast('订阅访问窗口已清空'); await load('customers'); await customerAccessModal(state.customers.find((item) => item.id === itemId)); } else if (action === 'rotate-subscription' && confirm('旧订阅地址会立刻失效。已导入的节点凭据不会随订阅令牌重置,确认继续?')) { const result = await api(`/api/customers/${itemId}/rotate-subscription`, { method:'POST', body:'{}' }); state.customers = state.customers.map((item) => item.id === itemId ? result.customer : item); subscriptionModal(result.customer); toast('订阅地址已重置'); } else if (action === 'add-node') nodeForm(); else if (action === 'edit-node') { if (!state.protocols.length) { const result = await api('/api/protocols'); state.protocols = result.profiles; state.realityPresets = result.realityPresets || []; } nodeForm(state.nodes.find((item) => item.id === itemId)); } else if (action === 'node-clients') nodeClientsModal(state.nodes.find((item) => item.id === itemId)); else if (action === 'assign-nodes') assignNodesModal(state.customers.find((item) => item.id === itemId)); else if (action === 'unassign-customer' && confirm('移除该客户在此节点的分配?节点会自动重建。')) { await api(`/api/nodes/${itemId}/unassign`, { method:'POST', body:JSON.stringify({ customerId:button.dataset.customer }) }); toast('分配已移除'); const node = state.nodes.find((item) => item.id === itemId); await load('chains'); nodeClientsModal(state.nodes.find((item) => item.id === node.id)); } else if (action === 'enroll-server') { const result = await api(`/api/servers/${itemId}/enrollment-token`, { method:'POST', body:'{}' }); const command = `curl -fsSL https://raw.githubusercontent.com/imamoxilin/NexusGate/main/scripts/agent-install.sh | bash -s -- --server ${location.origin} --token ${result.token}`; modal('ONE-TIME ENROLLMENT', 'Agent 注册 / 重装命令', `
令牌 30 分钟内有效且只能使用一次。重装会保留本机资源文件,并在 Agent 重启后自动与控制面对账。
${esc(command)}
`); } else if (action === 'copy-uri') { await copyText(button.dataset.value); toast('已复制到剪贴板'); } else if (action === 'qr-uri') { const target = $('.subscription-qr', button.closest('.subscription-row')); if (!target.hidden) { target.hidden = true; target.replaceChildren(); return; } const qr = qrcode(0, 'M'); qr.addData(button.dataset.value, 'Byte'); qr.make(); target.innerHTML = qr.createSvgTag(4, 4); target.hidden = false; } else if (action === 'agent-uninstall') { const command = 'ng-agent uninstall'; const fallback = 'curl -fsSL https://raw.githubusercontent.com/imamoxilin/NexusGate/main/scripts/agent-uninstall.sh | bash'; modal('AGENT MAINTENANCE', '在目标服务器 SSH 卸载 Agent', `
先停用并删除关联节点,让在线 Agent 完成资源清理。卸载会停止 NexusGate 专属服务并移除密钥与节点配置。然后在控制台删除设备登记;离线且有待清理项时使用“遗忘离线设备”。遗忘不会远程卸载机器。
${esc(command)}
旧版 Agent 没有 ng-agent 命令时,使用下列兼容命令:
${esc(fallback)}
`); } else if (action === 'retry-cleanup') { const result = await api(`/api/servers/${itemId}/cleanup`, { method:'POST', body:'{}' }); toast(result.queued ? `已重新排队 ${result.queued} 个清理任务` : '清理任务已在队列中,请等待设备上线'); await load(); } else if (action === 'forget-server') { const item = state.servers.find((entry) => entry.id === itemId); if (!item) return; const name = prompt(`仅在服务器永久下线、销毁,或已手工清除其代理配置时使用。强制遗忘会放弃 ${item.pendingCleanup} 项远程清理;旧节点若仍运行,配置可能继续监听。\n\n请输入设备名称「${item.name}」确认:`); if (name !== item.name) return; await api(`/api/servers/${itemId}/forget`, { method:'POST', body:JSON.stringify({ confirm:'FORGET', name }) }); toast('已强制遗忘设备;请确保旧机器已销毁或手工清理'); await load(); } else if (action === 'delete-server' && confirm('删除控制台登记并吊销 Agent 密钥?如需要彻底卸载,请先在目标机 SSH 执行 ng-agent uninstall。')) { await api(`/api/servers/${itemId}`, { method:'DELETE' }); toast('设备登记已删除'); await load(); } else if (action === 'delete-customer' && confirm('删除客户及订阅地址?须先移除全部节点分配。')) { await api(`/api/customers/${itemId}`, { method:'DELETE' }); toast('客户已删除'); await load(); } else if (action === 'reset-usage' && confirm('确认把该客户已用流量清零?')) { await api(`/api/customers/${itemId}/reset-usage`, { method:'POST', body:'{}' }); toast('客户流量已清零'); await load(); } else if (action === 'toggle-customer') { const item = state.customers.find((entry) => entry.id === itemId); await api(`/api/customers/${itemId}`, { method:'PATCH', body:JSON.stringify({ status:item.status === 'active' ? 'suspended' : 'active' }) }); toast('客户状态已更新'); await load(); } else if (action === 'deploy-node' && confirm('现在向所选设备下发这个节点?')) { await api(`/api/nodes/${itemId}/deploy`, { method:'POST', body:'{}' }); toast('部署任务已进入队列'); await load(); } else if (action === 'repair-node' && confirm('重新下发该节点配置?')) { await api(`/api/nodes/${itemId}/repair`, { method:'POST', body:'{}' }); toast('修复任务已进入队列'); await load(); } else if (action === 'remove-node' && confirm('停用会从相关设备移除配置,节点保留为草稿,确认继续?')) { await api(`/api/nodes/${itemId}/remove`, { method:'POST', body:'{}' }); toast('停用任务已进入队列'); await load(); } else if (action === 'delete-node' && confirm('确定删除这个节点?会移除全部分配并从设备清理资源。')) { await api(`/api/nodes/${itemId}`, { method:'DELETE' }); toast('节点已删除,设备资源待 Agent 确认清理'); await load(); } else if (action === 'download-backup') location.href = '/api/backup'; else if (action === 'restore-backup') { const input = document.createElement('input'); input.type = 'file'; input.accept = '.json,application/json'; input.onchange = async () => { try { if (!input.files[0] || !confirm('恢复会覆盖当前全部控制面数据,确认继续?')) return; const backupData = JSON.parse(await input.files[0].text()); await api('/api/restore', { method:'POST', body:JSON.stringify({ confirm:'RESTORE', data:backupData }) }); location.reload(); } catch (error) { toast(error.message, true); } }; input.click(); } } catch (error) { toast(error.message, true); } }); document.addEventListener('change', (event) => { if (event.target.matches('[data-row-actions]') && event.target.value) { const button = document.createElement('button'); button.dataset.action = event.target.value; button.dataset.id = event.target.dataset.id; event.target.value = ''; button.hidden = true; document.body.append(button); button.click(); button.remove(); return; } if (event.target.closest('[data-picker-option]')) { const picker = event.target.closest('[data-picker]'); $('[data-picker-count]', picker).textContent = `已选择 ${$$('input[type="checkbox"]:checked', picker).length} 项`; } if (event.target.matches('[data-node-sync]')) syncNodeForm(); if (event.target.matches('[data-reality-preset]')) { const option = event.target.selectedOptions[0]; const form = event.target.form; if (option && option.value !== 'custom') { form.elements.realityServerName.value = option.dataset.sni; form.elements.realityDestPort.value = option.dataset.port; } } if (event.target.matches('[data-expiry-quick]') && event.target.value) { const target = new Date(); target.setDate(target.getDate() + Number(event.target.value)); const pad = (number) => String(number).padStart(2, '0'); event.target.form.elements.expiryDate.value = `${target.getFullYear()}-${pad(target.getMonth()+1)}-${pad(target.getDate())}`; event.target.form.elements.expiryTime.value = '23:59'; } }); document.addEventListener('input', (event) => { if (event.target.matches('[data-picker-search]')) { const picker = event.target.closest('[data-picker]'); const query = event.target.value.trim().toLowerCase(); $$('[data-picker-option]', picker).forEach((option) => { option.hidden = !option.dataset.query.includes(query); }); return; } if (event.target.matches('[data-search]')) { state.search = event.target.value; render(); const next = $('[data-search]'); next.focus(); next.setSelectionRange(next.value.length,next.value.length); } }); function closeSidebar() { $('#app').classList.remove('sidebar-open'); } $('#menu-toggle').addEventListener('click', () => $('#app').classList.add('sidebar-open')); $('#sidebar-scrim').addEventListener('click', closeSidebar); $('#refresh').addEventListener('click', () => load()); $('#logout').addEventListener('click', async () => { try { await api('/api/auth/logout', { method:'POST', body:'{}' }); } finally { showLogin(); } }); (async function boot() { let preferred = 'light'; try { preferred = localStorage.getItem('nexusgate-theme') || 'light'; } catch { /* storage may be disabled */ } setTheme(preferred); try { const session = await api('/api/session'); if (!session.authenticated) return showLogin(); state.session = session; showApp(); await load('overview'); } catch { showLogin(); } setInterval(() => { if (state.session && document.visibilityState === 'visible' && !$('#modal').open) load(); }, 45000); })();