From 36114a2fcc7fa32d0090b75c861f047e663a25cf Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 9 May 2026 15:25:29 +0200 Subject: [PATCH] feat(nodes): multi-node panel orchestration (CRUD, deployment, traffic sync, sub per-node) - Node model + service + controller (/panel/api/nodes/*) with bearer-token apiToken auth - Heartbeat job @every 10s; status/latency/xrayVersion surfaced in Nodes UI - Runtime abstraction (Local + Remote) so inbound/client mutations target the inbound's owning node instead of always hitting the local xray - Inbounds gain optional NodeID; tag-based correlation with remote panel (no RemoteInboundID column needed) - NodeTrafficSyncJob @every 10s pulls absolute counters + online/lastOnline from each enabled+online node and writes them into central DB; 30s reset grace window prevents post-reset overwrite - Reset propagation to nodes (best-effort) on client/inbound/all reset paths - Subscription server uses node.Address for inbounds with NodeID, falling back to existing host resolution for local inbounds - Frontend: Nodes page, "Deploy to" select in inbound form, Node column on inbound list, hostOverride threaded through genAllLinks/QR/Info modals Co-Authored-By: Claude Opus 4.7 --- database/db.go | 1 + database/model/model.go | 37 + frontend/nodes.html | 13 + frontend/src/components/AppSidebar.vue | 3 + frontend/src/composables/useNodeList.js | 42 ++ frontend/src/entries/nodes.js | 17 + frontend/src/models/dbinbound.js | 7 +- frontend/src/models/inbound.js | 32 +- .../src/pages/inbounds/InboundFormModal.vue | 34 + .../src/pages/inbounds/InboundInfoModal.vue | 9 +- frontend/src/pages/inbounds/InboundList.vue | 20 + frontend/src/pages/inbounds/InboundsPage.vue | 29 +- frontend/src/pages/inbounds/QrCodeModal.vue | 10 +- frontend/src/pages/nodes/NodeFormModal.vue | 226 ++++++ frontend/src/pages/nodes/NodeList.vue | 211 ++++++ frontend/src/pages/nodes/NodesPage.vue | 240 +++++++ frontend/src/pages/nodes/useNodes.js | 140 ++++ frontend/src/pages/settings/SecurityTab.vue | 86 ++- frontend/vite.config.js | 3 + sub/subClashService.go | 11 +- sub/subJsonService.go | 15 +- sub/subService.go | 49 +- web/controller/api.go | 34 +- web/controller/inbound.go | 38 +- web/controller/node.go | 184 +++++ web/controller/setting.go | 26 + web/controller/xui.go | 6 + web/job/node_heartbeat_job.go | 92 +++ web/job/node_traffic_sync_job.go | 137 ++++ web/middleware/security.go | 7 + web/runtime/local.go | 137 ++++ web/runtime/manager.go | 145 ++++ web/runtime/remote.go | 407 +++++++++++ web/runtime/runtime.go | 66 ++ web/service/inbound.go | 656 ++++++++++++++---- web/service/node.go | 273 ++++++++ web/service/setting.go | 44 ++ web/service/xray.go | 12 + web/session/session.go | 24 + web/translation/en-US.json | 60 ++ web/translation/fa-IR.json | 60 ++ web/web.go | 17 + xray/process.go | 69 +- 43 files changed, 3545 insertions(+), 184 deletions(-) create mode 100644 frontend/nodes.html create mode 100644 frontend/src/composables/useNodeList.js create mode 100644 frontend/src/entries/nodes.js create mode 100644 frontend/src/pages/nodes/NodeFormModal.vue create mode 100644 frontend/src/pages/nodes/NodeList.vue create mode 100644 frontend/src/pages/nodes/NodesPage.vue create mode 100644 frontend/src/pages/nodes/useNodes.js create mode 100644 web/controller/node.go create mode 100644 web/job/node_heartbeat_job.go create mode 100644 web/job/node_traffic_sync_job.go create mode 100644 web/runtime/local.go create mode 100644 web/runtime/manager.go create mode 100644 web/runtime/remote.go create mode 100644 web/runtime/runtime.go create mode 100644 web/service/node.go diff --git a/database/db.go b/database/db.go index 2e468587..80f9f655 100644 --- a/database/db.go +++ b/database/db.go @@ -39,6 +39,7 @@ func initModels() error { &xray.ClientTraffic{}, &model.HistoryOfSeeders{}, &model.CustomGeoResource{}, + &model.Node{}, } for _, model := range models { if err := db.AutoMigrate(model); err != nil { diff --git a/database/model/model.go b/database/model/model.go index 047780e5..10036df4 100644 --- a/database/model/model.go +++ b/database/model/model.go @@ -66,6 +66,12 @@ type Inbound struct { StreamSettings string `json:"streamSettings" form:"streamSettings"` Tag string `json:"tag" form:"tag" gorm:"unique"` Sniffing string `json:"sniffing" form:"sniffing"` + + // NodeID points at the remote panel (Node) where this inbound's xray + // actually runs. NULL means the inbound runs on the local xray (the + // pre-multi-node behaviour). Existing rows migrate to NULL with no + // backfill. + NodeID *int `json:"nodeId,omitempty" form:"nodeId" gorm:"index"` } // OutboundTraffics tracks traffic statistics for Xray outbound connections. @@ -117,6 +123,37 @@ type Setting struct { Value string `json:"value" form:"value"` } +// Node represents a remote 3x-ui panel registered with the central panel. +// The central panel polls each node's existing /panel/api/server/status +// endpoint over HTTP using the per-node ApiToken to populate the runtime +// status fields below. +type Node struct { + Id int `json:"id" gorm:"primaryKey;autoIncrement"` + Name string `json:"name" gorm:"uniqueIndex"` + Remark string `json:"remark"` + Scheme string `json:"scheme"` // "https" | "http" + Address string `json:"address"` // host or IP + Port int `json:"port"` + BasePath string `json:"basePath"` // "/" or "/myprefix/" + ApiToken string `json:"apiToken"` // plaintext, matches existing tg/ldap pattern + Enable bool `json:"enable" gorm:"default:true"` + + // Heartbeat-updated fields. UpdatedAt advances on every probe even when + // the row is otherwise unchanged so the UI's "last seen" tooltip is + // truthful without us having to read LastHeartbeat separately. + Status string `json:"status" gorm:"default:unknown"` // online|offline|unknown + LastHeartbeat int64 `json:"lastHeartbeat"` // unix seconds, 0 = never + LatencyMs int `json:"latencyMs"` + XrayVersion string `json:"xrayVersion"` + CpuPct float64 `json:"cpuPct"` + MemPct float64 `json:"memPct"` + UptimeSecs uint64 `json:"uptimeSecs"` + LastError string `json:"lastError"` + + CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime"` + UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime"` +} + type CustomGeoResource struct { Id int `json:"id" gorm:"primaryKey;autoIncrement"` Type string `json:"type" gorm:"not null;uniqueIndex:idx_custom_geo_type_alias;column:geo_type"` diff --git a/frontend/nodes.html b/frontend/nodes.html new file mode 100644 index 00000000..fb607b19 --- /dev/null +++ b/frontend/nodes.html @@ -0,0 +1,13 @@ + + + + + + 3x-ui · Nodes + + +
+
+ + + diff --git a/frontend/src/components/AppSidebar.vue b/frontend/src/components/AppSidebar.vue index dc157272..b72da591 100644 --- a/frontend/src/components/AppSidebar.vue +++ b/frontend/src/components/AppSidebar.vue @@ -6,6 +6,7 @@ import { UserOutlined, SettingOutlined, ToolOutlined, + ClusterOutlined, LogoutOutlined, CloseOutlined, MenuFoldOutlined, @@ -35,6 +36,7 @@ const iconByName = { user: UserOutlined, setting: SettingOutlined, tool: ToolOutlined, + cluster: ClusterOutlined, logout: LogoutOutlined, }; @@ -50,6 +52,7 @@ const prefix = props.basePath?.startsWith('/') ? props.basePath : `/${props.base const tabs = computed(() => [ { key: `${prefix}panel/`, icon: 'dashboard', title: t('menu.dashboard') }, { key: `${prefix}panel/inbounds`, icon: 'user', title: t('menu.inbounds') }, + { key: `${prefix}panel/nodes`, icon: 'cluster', title: t('menu.nodes') }, { key: `${prefix}panel/settings`, icon: 'setting', title: t('menu.settings') }, { key: `${prefix}panel/xray`, icon: 'tool', title: t('menu.xray') }, { key: `${prefix}logout`, icon: 'logout', title: t('logout') }, diff --git a/frontend/src/composables/useNodeList.js b/frontend/src/composables/useNodeList.js new file mode 100644 index 00000000..ca4f416d --- /dev/null +++ b/frontend/src/composables/useNodeList.js @@ -0,0 +1,42 @@ +// Lightweight composable that fetches the node list once on mount and +// exposes id→name + id→online lookups. Used by the Inbounds page so it +// can render a Node selector and a Node column without pulling the +// full pages/nodes/useNodes.js (which polls and owns CRUD state). + +import { onMounted, ref, computed } from 'vue'; +import { HttpUtil } from '@/utils'; + +export function useNodeList() { + const nodes = ref([]); + const fetched = ref(false); + + async function refresh() { + const msg = await HttpUtil.get('/panel/api/nodes/list'); + if (msg?.success) { + nodes.value = Array.isArray(msg.obj) ? msg.obj : []; + } + fetched.value = true; + } + + // Indexed by id for O(1) UI lookups (Node column on N-row tables). + const byId = computed(() => { + const m = new Map(); + for (const n of nodes.value) m.set(n.id, n); + return m; + }); + + function nameFor(id) { + if (id == null) return null; + return byId.value.get(id)?.name || null; + } + + function isOnline(id) { + if (id == null) return true; + const n = byId.value.get(id); + return n != null && n.enable && n.status === 'online'; + } + + onMounted(refresh); + + return { nodes, fetched, refresh, byId, nameFor, isOnline }; +} diff --git a/frontend/src/entries/nodes.js b/frontend/src/entries/nodes.js new file mode 100644 index 00000000..512643be --- /dev/null +++ b/frontend/src/entries/nodes.js @@ -0,0 +1,17 @@ +import { createApp } from 'vue'; +import Antd, { message } from 'ant-design-vue'; +import 'ant-design-vue/dist/reset.css'; + +import { setupAxios } from '@/api/axios-init.js'; +import '@/composables/useTheme.js'; +import { i18n } from '@/i18n/index.js'; +import NodesPage from '@/pages/nodes/NodesPage.vue'; + +setupAxios(); + +const messageContainer = document.getElementById('message'); +if (messageContainer) { + message.config({ getContainer: () => messageContainer }); +} + +createApp(NodesPage).use(Antd).use(i18n).mount('#app'); diff --git a/frontend/src/models/dbinbound.js b/frontend/src/models/dbinbound.js index 61e08c8b..e2e0adfe 100644 --- a/frontend/src/models/dbinbound.js +++ b/frontend/src/models/dbinbound.js @@ -25,6 +25,9 @@ export class DBInbound { this.tag = ""; this.sniffing = ""; this.clientStats = "" + // Optional FK to web/runtime registered Node. null/undefined = + // local panel; otherwise the inbound lives on the named node. + this.nodeId = null; if (data == null) { return; } @@ -173,8 +176,8 @@ export class DBInbound { } } - genInboundLinks(remarkModel) { + genInboundLinks(remarkModel, hostOverride = '') { const inbound = this.toInbound(); - return inbound.genInboundLinks(this.remark, remarkModel); + return inbound.genInboundLinks(this.remark, remarkModel, hostOverride); } } \ No newline at end of file diff --git a/frontend/src/models/inbound.js b/frontend/src/models/inbound.js index c17c894e..cccd8d4d 100644 --- a/frontend/src/models/inbound.js +++ b/frontend/src/models/inbound.js @@ -2296,8 +2296,20 @@ export class Inbound extends XrayCommonClass { return url.toString(); } - genWireguardLinks(remark = '', remarkModel = '-ieo') { - const addr = !ObjectUtil.isEmpty(this.listen) && this.listen !== "0.0.0.0" ? this.listen : location.hostname; + // resolveAddr picks the host that goes into share/sub links. Order: + // 1. hostOverride (caller supplies node address for node-managed inbounds) + // 2. inbound's bind listen (when explicit, not 0.0.0.0) + // 3. browser's location.hostname (single-panel default) + // Centralised so genAllLinks/genInboundLinks/genWireguard* + // all share the same chain — pre-Phase 3 we had four duplicated lines. + _resolveAddr(hostOverride = '') { + if (hostOverride) return hostOverride; + if (!ObjectUtil.isEmpty(this.listen) && this.listen !== "0.0.0.0") return this.listen; + return location.hostname; + } + + genWireguardLinks(remark = '', remarkModel = '-ieo', hostOverride = '') { + const addr = this._resolveAddr(hostOverride); const separationChar = remarkModel.charAt(0); let links = []; this.settings.peers.forEach((p, index) => { @@ -2306,8 +2318,8 @@ export class Inbound extends XrayCommonClass { return links.join('\r\n'); } - genWireguardConfigs(remark = '', remarkModel = '-ieo') { - const addr = !ObjectUtil.isEmpty(this.listen) && this.listen !== "0.0.0.0" ? this.listen : location.hostname; + genWireguardConfigs(remark = '', remarkModel = '-ieo', hostOverride = '') { + const addr = this._resolveAddr(hostOverride); const separationChar = remarkModel.charAt(0); let links = []; this.settings.peers.forEach((p, index) => { @@ -2332,10 +2344,10 @@ export class Inbound extends XrayCommonClass { } } - genAllLinks(remark = '', remarkModel = '-ieo', client) { + genAllLinks(remark = '', remarkModel = '-ieo', client, hostOverride = '') { let result = []; let email = client ? client.email : ''; - let addr = !ObjectUtil.isEmpty(this.listen) && this.listen !== "0.0.0.0" ? this.listen : location.hostname; + let addr = this._resolveAddr(hostOverride); let port = this.port; const separationChar = remarkModel.charAt(0); const orderChars = remarkModel.slice(1); @@ -2363,12 +2375,12 @@ export class Inbound extends XrayCommonClass { return result; } - genInboundLinks(remark = '', remarkModel = '-ieo') { - let addr = !ObjectUtil.isEmpty(this.listen) && this.listen !== "0.0.0.0" ? this.listen : location.hostname; + genInboundLinks(remark = '', remarkModel = '-ieo', hostOverride = '') { + let addr = this._resolveAddr(hostOverride); if (this.clients) { let links = []; this.clients.forEach((client) => { - this.genAllLinks(remark, remarkModel, client).forEach(l => { + this.genAllLinks(remark, remarkModel, client, hostOverride).forEach(l => { links.push(l.link); }) }); @@ -2376,7 +2388,7 @@ export class Inbound extends XrayCommonClass { } else { if (this.protocol == Protocols.SHADOWSOCKS && !this.isSSMultiUser) return this.genSSLink(addr, this.port, 'same', remark); if (this.protocol == Protocols.WIREGUARD) { - return this.genWireguardConfigs(remark, remarkModel); + return this.genWireguardConfigs(remark, remarkModel, hostOverride); } return ''; } diff --git a/frontend/src/pages/inbounds/InboundFormModal.vue b/frontend/src/pages/inbounds/InboundFormModal.vue index 40f47bce..875d3c3e 100644 --- a/frontend/src/pages/inbounds/InboundFormModal.vue +++ b/frontend/src/pages/inbounds/InboundFormModal.vue @@ -31,9 +31,17 @@ import { import { DBInbound } from '@/models/dbinbound.js'; import FinalMaskForm from '@/components/FinalMaskForm.vue'; import DateTimePicker from '@/components/DateTimePicker.vue'; +import { useNodeList } from '@/composables/useNodeList.js'; const { t } = useI18n(); +// Node selector — Phase 1 multi-node deployment. Shows all enabled +// nodes regardless of online state so the form is usable while a node +// is briefly offline; the backend's fail-fast path will surface the +// real error when the user submits. +const { nodes: availableNodes } = useNodeList(); +const selectableNodes = computed(() => (availableNodes.value || []).filter((n) => n.enable)); + // Phase 5f-iii-b: structured per-protocol/per-transport forms instead // of raw JSON textareas. Edits a deeply-reactive Inbound + DBInbound // pair so the existing model helpers (.toString(), .canEnableTls(), @@ -474,6 +482,14 @@ async function submit() { streamSettings: streamSettings, sniffing: sniffing, }; + // Multi-node deployment: only include nodeId when the user picked a + // remote node. Sending nodeId=null over qs.stringify becomes an + // empty form value, which Go's form binding for *int parses as 0 + // — not nil — and we'd then try to look up node id 0 and fail with + // "record not found". Omitting the key entirely keeps NodeID nil. + if (dbForm.value.nodeId != null) { + payload.nodeId = dbForm.value.nodeId; + } const url = props.mode === 'edit' ? `/panel/api/inbounds/update/${props.dbInbound.id}` @@ -543,6 +559,24 @@ watch( + + + {{ t('pages.inbounds.localPanel') }} + + {{ n.name }}{{ n.status === 'offline' ? ' (offline)' : '' }} + + + {{ p }} diff --git a/frontend/src/pages/inbounds/InboundInfoModal.vue b/frontend/src/pages/inbounds/InboundInfoModal.vue index c30aaa96..05af6a13 100644 --- a/frontend/src/pages/inbounds/InboundInfoModal.vue +++ b/frontend/src/pages/inbounds/InboundInfoModal.vue @@ -45,6 +45,10 @@ const props = defineProps({ trafficDiff: { type: Number, default: 0 }, ipLimitEnable: { type: Boolean, default: false }, tgBotEnable: { type: Boolean, default: false }, + // Address of the node hosting this inbound; '' for local. Wired + // through to share/QR link generation so node-managed inbounds + // produce links that connect to the node, not the central panel. + nodeAddress: { type: String, default: '' }, subSettings: { type: Object, default: () => ({ enable: false, subURI: '', subJsonURI: '', subJsonEnable: false }), @@ -208,14 +212,15 @@ watch(() => props.open, (next) => { // Generate links per protocol — WireGuard has its own .conf body // path; everything else flows through genAllLinks. if (inbound.value.protocol === Protocols.WIREGUARD) { - wireguardConfigs.value = inbound.value.genWireguardConfigs(props.dbInbound.remark).split('\r\n'); - wireguardLinks.value = inbound.value.genWireguardLinks(props.dbInbound.remark).split('\r\n'); + wireguardConfigs.value = inbound.value.genWireguardConfigs(props.dbInbound.remark, '-ieo', props.nodeAddress).split('\r\n'); + wireguardLinks.value = inbound.value.genWireguardLinks(props.dbInbound.remark, '-ieo', props.nodeAddress).split('\r\n'); links.value = []; } else { links.value = inbound.value.genAllLinks( props.dbInbound.remark, props.remarkModel, clientSettings.value, + props.nodeAddress, ); wireguardConfigs.value = []; wireguardLinks.value = []; diff --git a/frontend/src/pages/inbounds/InboundList.vue b/frontend/src/pages/inbounds/InboundList.vue index e367a13f..f473dd33 100644 --- a/frontend/src/pages/inbounds/InboundList.vue +++ b/frontend/src/pages/inbounds/InboundList.vue @@ -48,6 +48,9 @@ const props = defineProps({ isMobile: { type: Boolean, default: false }, isDarkTheme: { type: Boolean, default: false }, subEnable: { type: Boolean, default: false }, + // Map node id -> node row, supplied by the parent page so each + // inbound row can render its node name without an extra fetch. + nodesById: { type: Map, default: () => new Map() }, }); const emit = defineEmits([ @@ -154,6 +157,7 @@ const desktopColumns = computed(() => [ { title: t('pages.inbounds.operate'), key: 'action', align: 'center', width: 30 }, { title: t('pages.inbounds.enable'), key: 'enable', align: 'center', width: 35 }, { title: t('pages.inbounds.remark'), dataIndex: 'remark', key: 'remark', align: 'center', width: 60 }, + { title: t('pages.inbounds.node'), key: 'node', align: 'center', width: 60 }, { title: t('pages.inbounds.port'), dataIndex: 'port', key: 'port', align: 'center', width: 40 }, { title: t('pages.inbounds.protocol'), key: 'protocol', align: 'left', width: 130 }, { title: t('clients'), key: 'clients', align: 'left', width: 50 }, @@ -390,6 +394,22 @@ function showQrCodeMenu(dbInbound) { + + +