'use strict'; const state = { session: null, page: 'overview', overview: null, servers: [], customers: [], chains: [], deployments: [], 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)); }; 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:'已暂停', suspending:'暂停中', partially_suspended:'部分暂停', removing:'停用中', deleted:'已删除', redeploying:'重建中', redeploy_pending:'等待重建', changes_pending:'待重新部署' }; const roleText = { relay:'入口 / 转发', exit:'出口 / 落地', hybrid:'综合节点' }; const topologyText = { forward:'转发线路', direct:'单机直连' }; const deploymentRoleText = { relay:'客户端入口', exit:'出口传输', 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_chain:'创建线路', update_route:'编辑线路', deploy_route:'部署线路', redeploy_route:'重新部署线路', repair_route:'修复线路', remove_route:'停用线路', delete_chain:'删除线路', 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/chains')]); state.overview = overview; state.version = overview.version || state.version; state.servers = servers.servers; state.customers = customers.customers; state.chains = routes.chains; state.deployments = routes.deployments; } else if (page === 'servers') state.servers = (await api('/api/servers')).servers; else if (page === 'customers') state.customers = (await api('/api/customers')).customers; else if (page === 'chains') { const [servers, customers, routes, protocols] = await Promise.all([api('/api/servers'), api('/api/customers'), api('/api/chains'), api('/api/protocols')]); state.servers = servers.servers; state.customers = customers.customers; state.chains = routes.chains; state.deployments = routes.deployments; state.protocols = protocols.profiles; state.realityPresets = protocols.realityPresets || []; } else if (page === 'deployments') { const [servers, customers, routes] = await Promise.all([api('/api/servers'), api('/api/customers'), api('/api/chains')]); state.servers = servers.servers; state.customers = customers.customers; state.chains = routes.chains; state.deployments = routes.deployments; } 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:'转发与节点', deployments:'部署与链接', 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.chains || 0, '转发与单机节点', '#9667e8'], ['活动部署', counts.activeDeployments || 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, ``, [['reset-usage','流量清零'],['toggle-customer',item.status === 'active' ? '停用' : '启用'],['delete-customer','删除客户']])}${item.pendingCleanup ? `待清理 ${item.pendingCleanup} 项` : ''}`; }).join(''); return `

流量按入口上行+下行双向累计,Agent 约每分钟上报。额度、到期或节点 IP 触发暂停后,调整限制会自动恢复原节点;待 Agent 完成恢复任务,订阅才可更新。旧版已经变为草稿的线路可点“恢复原节点”。

${state.customers.length} 位
${rows || ``}
客户状态流量到期使用限制标签操作
${empty('还没有客户','先创建客户,再编排线路')}
`; } function chainActions(chain) { const edit = ``; const remove = ``; if (chain.status === 'draft') return `${edit}${chain.generation > 0 ? `` : ``}${remove}`; if (chain.status === 'suspended') return `${edit}${state.customers.some((item) => chain.customerIds.includes(item.id) && item.status === 'active') ? `` : ''}${remove}`; if (chain.status === 'changes_pending') return `${edit}`; if (chain.status === 'degraded') return `${edit}${remove}`; if (['active','deploying','suspending','partially_suspended'].includes(chain.status)) return `${edit}`; return `${edit}${remove}`; } function rowActions(id, primary, more) { return `
${primary}
`; } function compactChainActions(chain) { const actions = chainActions(chain).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(chain.id, primary, more) : `
${primary}
`; } function renderChains() { const rows = state.chains.map((chain) => { const direct = (chain.topology || 'forward') === 'direct'; const path = direct ? protocolName(chain.relayProtocol, 'relay-ingress') : `${protocolName(chain.relayProtocol, 'relay-ingress')} → ${protocolName(chain.exitProtocol, 'exit-transport')}`; const exit = direct ? '本机直出' : ((state.servers.find((item) => item.id === chain.exitServerId) || {}).name || '已删除'); const applied = state.deployments.filter((item) => item.chainId === chain.id && ['active','queued','deploying'].includes(item.status) && item.role !== 'exit'); const drift = applied.some((item) => item.protocol !== chain.relayProtocol); return `${esc(chain.name)}${esc(topologyText[chain.topology || 'forward'])} · ${esc(path)}${drift ? '编辑的协议尚未应用,当前节点见“部署与链接”' : ''} ${esc(names(chain.relayServerIds, state.servers))}${esc(chain.networkMode || 'ipv4').toUpperCase()}${esc(exit)} ${esc(names(chain.customerIds, state.customers))}${status(chain.status)}${chain.lastError ? `${esc(chain.lastError)}` : ''}${compactChainActions(chain)}`; }).join(''); return `

“转发线路”把多个客户端入口汇聚到一个出口;“单机直连”无需出口机,直接在任意设备创建节点。编辑运行中线路后,点击“应用修改”安全重建。

${rows || ``}
线路入口 / 节点设备出口客户状态操作
${empty('还没有线路','准备好设备和客户后创建第一条线路')}
`; } function renderDeployments() { const active = state.deployments.filter((item) => item.status !== 'deleted'); const rows = active.map((item) => { const server = state.servers.find((entry) => entry.id === item.serverId); const customer = state.customers.find((entry) => entry.id === item.customerId); const chain = state.chains.find((entry) => entry.id === item.chainId); const canCopy = item.status === 'active' && item.clientUri && customer?.status === 'active' && (!customer.expiresAt || Date.parse(customer.expiresAt) > Date.now()) && (!(customer.trafficLimitBytes > 0) || customer.usedBytes < customer.trafficLimitBytes) && (item.role === 'direct' || state.deployments.some((exit) => exit.id === item.exitDeploymentId && exit.status === 'active')); return `${esc(chain ? chain.name : '已删除线路')}${esc(deploymentRoleText[item.role] || item.role)}${esc(customer ? customer.name : '已删除')} ${esc(server ? server.name : '已删除')}${esc(server ? (server.publicAddressV6 && chain && chain.networkMode === 'ipv6' ? server.publicAddressV6 : server.publicAddress) : '')}:${item.port} ${esc(protocolName(item.protocol, item.role === 'exit' ? 'exit-transport' : 'relay-ingress'))}${chain && item.role !== 'exit' && item.protocol !== chain.relayProtocol ? '编辑中的协议尚未部署' : ''}${status(item.status)}${item.error ? `${esc(item.error)}` : ''}${item.role !== 'exit' ? `${item.meteredTraffic ? `本部署计量:上行 ${fmtBytes(item.meteredTraffic.uplink)} · 下行 ${fmtBytes(item.meteredTraffic.downlink)}` : '等待入口计量上报'}${item.lastUsageAt ? `最后上报 ${fmtDate(item.lastUsageAt)}` : ''}` : ''}
${canCopy ? `` : ''}${chain ? `` : ''}
`; }).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。已有有效证书不必重复申请。'], ['安装 AnyTLS 引擎', 'ng-agent engine install', '仅在承载 AnyTLS 的入口执行。首次构建可能耗时,临时 Go 缓存完成后会清理。HY2 与 VLESS WS TLS 无需该引擎。'], ['检查证书、引擎与双向计数', 'ng-agent doctor', '核对本机证书、Xray、sing-box 与入口统计。'] ]], ['出口机 / 落地机', [ ['更新轻量 Agent', 'ng-agent update', '只使用出口传输时无需安装 sing-box,也无需申请入口证书。'], ['检查运行状态', 'ng-agent doctor', '核对心跳和 Xray 配置。'] ]] ]; 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: renderChains, deployments: renderDeployments, 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','原始节点链接'] ]; modal('CLIENT SUBSCRIPTION', `${customer.name} · 订阅链接`, `
仅展示已成功部署的入口节点。客户停用、到期或流量用尽时链接停止分发。重置令牌会立即使旧订阅地址失效;已复制的节点凭据仍需停用或重建线路才能撤销。
${formats.map(([format,label]) => { const url = `${location.origin}/s/${customer.subscriptionToken}/${format}`; return `
${esc(label)}${esc(url)}
`; }).join('')}
二维码在当前浏览器本地生成,不向第三方图片服务发送订阅令牌。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 chainForm(item = null) { const chain = item || { topology:'forward', relayProtocol:'vless-reality-vision', exitProtocol:'shadowsocks-2022-aes128', relayPortMode:'random', exitPortMode:'random', networkMode:'ipv4', realityServerName:'www.tesla.com', realityDestPort:443, relayServerIds:[], customerIds:[] }; const preset = state.realityPresets.find((entry) => entry.serverName === chain.realityServerName) || { id:'custom' }; modal(item ? 'EDIT ROUTE' : 'NEW ROUTE', item ? '编辑线路' : '新建线路', `
入口与客户端节点
入口 / 节点设备${multiPicker('relayServerIds',state.servers,chain.relayServerIds,(server) => `${roleText[server.role]} · ${server.region || server.publicAddress} · ${server.publicAddress}`,'设备')}
出口与转发传输
客户分配
客户${multiPicker('customerIds',state.customers.filter((customer) => customer.status === 'active' || (chain.customerIds || []).includes(customer.id)),chain.customerIds,(customer) => `${customer.group || '未分组'}${customer.status !== 'active' ? ' · 已停用' : ''}`,'客户')}批量选择多个客户时请使用随机端口。
VLESS WS TLS、Hysteria 2 和 AnyTLS 需要入口证书。到入口机运行 ng-agent cert,按提示申请并同步域名;Hysteria 2 使用 UDP,其余使用 TCP。AnyTLS 由 sing-box 承载,缺少引擎时运行 ng-agent engine install。
${item && !['draft'].includes(item.status) ? '
保存运行中线路只会标记“待重新部署”,不会立即中断服务。确认后再点击“应用修改”。
' : ''}
`); syncChainForm(); } function syncChainForm() { const form = $('#chain-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.relayProtocol.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.relayProtocol.value && item.role === 'relay-ingress'); $('[data-protocol-note]', form).textContent = protocol ? `入口:${protocol.description} ${reality ? 'Reality 默认使用 Tesla SNI,可从下拉菜单切换。' : '此协议不需要 Reality SNI。'}` : '请选择可部署的入口协议。'; for (const [mode, port] of [['relayPortMode','relayPort'],['exitPortMode','exitPort']]) { const fixed = form.elements[mode].value === 'fixed' && (mode === 'relayPortMode' || !direct); form.elements[port].disabled = !fixed; form.elements[port].required = fixed; } } 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 === 'chain-form') { const resourceId = data.get('resourceId'); const payload = { name:data.get('name'), topology:data.get('topology'), networkMode:data.get('networkMode'), relayServerIds:data.getAll('relayServerIds'), exitServerId:data.get('exitServerId') || null, customerIds:data.getAll('customerIds'), relayProtocol:data.get('relayProtocol'), exitProtocol:data.get('exitProtocol') || null, relayPortMode:data.get('relayPortMode'), relayPort:data.get('relayPort') || 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/chains/${resourceId}` : '/api/chains', { method:resourceId ? 'PATCH' : 'POST', body:JSON.stringify(payload) }); $('#modal').close(); toast(result.requiresRedeploy ? '修改已保存;原节点仍按旧协议运行,请点击“应用修改”' : (resourceId ? '线路已更新' : '线路草稿已创建')); await load('chains'); } 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-chain') chainForm(); else if (action === 'edit-chain') { if (!state.protocols.length) { const result = await api('/api/protocols'); state.protocols = result.profiles; state.realityPresets = result.realityPresets || []; } chainForm(state.chains.find((item) => item.id === itemId)); } 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/a2899882/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 navigator.clipboard.writeText(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/a2899882/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-chain' && confirm('现在向所选设备下发这条线路?')) { await api(`/api/chains/${itemId}/deploy`, { method:'POST', body:'{}' }); toast('部署任务已进入队列'); await load(); } else if (action === 'restore-chain' && confirm('使用原端口和节点凭据恢复这条线路?如端口已被其他线路占用,请改用全新部署。')) { const result = await api(`/api/chains/${itemId}/restore`, { method:'POST', body:'{}' }); toast(`已排队恢复 ${result.jobs} 项原资源`); await load(); } else if (action === 'redeploy-chain' && confirm('重新部署会先移除旧资源,再自动创建新资源。确认继续?')) { await api(`/api/chains/${itemId}/redeploy`, { method:'POST', body:'{}' }); toast('线路已进入安全重建流程'); await load(); } else if (action === 'repair-chain' && confirm('仅重试当前失败的部署项?')) { await api(`/api/chains/${itemId}/repair`, { method:'POST', body:'{}' }); toast('修复任务已进入队列'); await load(); } else if (action === 'remove-chain' && confirm('停用会从相关设备移除配置,确认继续?')) { await api(`/api/chains/${itemId}/remove`, { method:'POST', body:'{}' }); toast('移除任务已进入队列'); await load(); } else if (action === 'delete-chain' && confirm('确定删除这条线路?未完成的部署会取消;系统会给相关设备排队清理遗留资源。运行中的资源须先停用。')) { const result = await api(`/api/chains/${itemId}`, { method:'DELETE' }); toast(result.cleanupPending ? `线路已移除,${result.cleanupPending} 个设备资源待 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-chain-sync]')) syncChainForm(); 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); })();