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 <noreply@anthropic.com>
This commit is contained in:
MHSanaei 2026-05-09 15:25:29 +02:00
parent 281e2d3d57
commit 36114a2fcc
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
43 changed files with 3545 additions and 184 deletions

View file

@ -39,6 +39,7 @@ func initModels() error {
&xray.ClientTraffic{}, &xray.ClientTraffic{},
&model.HistoryOfSeeders{}, &model.HistoryOfSeeders{},
&model.CustomGeoResource{}, &model.CustomGeoResource{},
&model.Node{},
} }
for _, model := range models { for _, model := range models {
if err := db.AutoMigrate(model); err != nil { if err := db.AutoMigrate(model); err != nil {

View file

@ -66,6 +66,12 @@ type Inbound struct {
StreamSettings string `json:"streamSettings" form:"streamSettings"` StreamSettings string `json:"streamSettings" form:"streamSettings"`
Tag string `json:"tag" form:"tag" gorm:"unique"` Tag string `json:"tag" form:"tag" gorm:"unique"`
Sniffing string `json:"sniffing" form:"sniffing"` 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. // OutboundTraffics tracks traffic statistics for Xray outbound connections.
@ -117,6 +123,37 @@ type Setting struct {
Value string `json:"value" form:"value"` 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 { type CustomGeoResource struct {
Id int `json:"id" gorm:"primaryKey;autoIncrement"` Id int `json:"id" gorm:"primaryKey;autoIncrement"`
Type string `json:"type" gorm:"not null;uniqueIndex:idx_custom_geo_type_alias;column:geo_type"` Type string `json:"type" gorm:"not null;uniqueIndex:idx_custom_geo_type_alias;column:geo_type"`

13
frontend/nodes.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>3x-ui · Nodes</title>
</head>
<body>
<div id="message"></div>
<div id="app"></div>
<script type="module" src="/src/entries/nodes.js"></script>
</body>
</html>

View file

@ -6,6 +6,7 @@ import {
UserOutlined, UserOutlined,
SettingOutlined, SettingOutlined,
ToolOutlined, ToolOutlined,
ClusterOutlined,
LogoutOutlined, LogoutOutlined,
CloseOutlined, CloseOutlined,
MenuFoldOutlined, MenuFoldOutlined,
@ -35,6 +36,7 @@ const iconByName = {
user: UserOutlined, user: UserOutlined,
setting: SettingOutlined, setting: SettingOutlined,
tool: ToolOutlined, tool: ToolOutlined,
cluster: ClusterOutlined,
logout: LogoutOutlined, logout: LogoutOutlined,
}; };
@ -50,6 +52,7 @@ const prefix = props.basePath?.startsWith('/') ? props.basePath : `/${props.base
const tabs = computed(() => [ const tabs = computed(() => [
{ key: `${prefix}panel/`, icon: 'dashboard', title: t('menu.dashboard') }, { key: `${prefix}panel/`, icon: 'dashboard', title: t('menu.dashboard') },
{ key: `${prefix}panel/inbounds`, icon: 'user', title: t('menu.inbounds') }, { 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/settings`, icon: 'setting', title: t('menu.settings') },
{ key: `${prefix}panel/xray`, icon: 'tool', title: t('menu.xray') }, { key: `${prefix}panel/xray`, icon: 'tool', title: t('menu.xray') },
{ key: `${prefix}logout`, icon: 'logout', title: t('logout') }, { key: `${prefix}logout`, icon: 'logout', title: t('logout') },

View file

@ -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 };
}

View file

@ -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');

View file

@ -25,6 +25,9 @@ export class DBInbound {
this.tag = ""; this.tag = "";
this.sniffing = ""; this.sniffing = "";
this.clientStats = "" 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) { if (data == null) {
return; return;
} }
@ -173,8 +176,8 @@ export class DBInbound {
} }
} }
genInboundLinks(remarkModel) { genInboundLinks(remarkModel, hostOverride = '') {
const inbound = this.toInbound(); const inbound = this.toInbound();
return inbound.genInboundLinks(this.remark, remarkModel); return inbound.genInboundLinks(this.remark, remarkModel, hostOverride);
} }
} }

View file

@ -2296,8 +2296,20 @@ export class Inbound extends XrayCommonClass {
return url.toString(); return url.toString();
} }
genWireguardLinks(remark = '', remarkModel = '-ieo') { // resolveAddr picks the host that goes into share/sub links. Order:
const addr = !ObjectUtil.isEmpty(this.listen) && this.listen !== "0.0.0.0" ? this.listen : location.hostname; // 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); const separationChar = remarkModel.charAt(0);
let links = []; let links = [];
this.settings.peers.forEach((p, index) => { this.settings.peers.forEach((p, index) => {
@ -2306,8 +2318,8 @@ export class Inbound extends XrayCommonClass {
return links.join('\r\n'); return links.join('\r\n');
} }
genWireguardConfigs(remark = '', remarkModel = '-ieo') { genWireguardConfigs(remark = '', remarkModel = '-ieo', hostOverride = '') {
const addr = !ObjectUtil.isEmpty(this.listen) && this.listen !== "0.0.0.0" ? this.listen : location.hostname; const addr = this._resolveAddr(hostOverride);
const separationChar = remarkModel.charAt(0); const separationChar = remarkModel.charAt(0);
let links = []; let links = [];
this.settings.peers.forEach((p, index) => { 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 result = [];
let email = client ? client.email : ''; 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; let port = this.port;
const separationChar = remarkModel.charAt(0); const separationChar = remarkModel.charAt(0);
const orderChars = remarkModel.slice(1); const orderChars = remarkModel.slice(1);
@ -2363,12 +2375,12 @@ export class Inbound extends XrayCommonClass {
return result; return result;
} }
genInboundLinks(remark = '', remarkModel = '-ieo') { genInboundLinks(remark = '', remarkModel = '-ieo', hostOverride = '') {
let addr = !ObjectUtil.isEmpty(this.listen) && this.listen !== "0.0.0.0" ? this.listen : location.hostname; let addr = this._resolveAddr(hostOverride);
if (this.clients) { if (this.clients) {
let links = []; let links = [];
this.clients.forEach((client) => { this.clients.forEach((client) => {
this.genAllLinks(remark, remarkModel, client).forEach(l => { this.genAllLinks(remark, remarkModel, client, hostOverride).forEach(l => {
links.push(l.link); links.push(l.link);
}) })
}); });
@ -2376,7 +2388,7 @@ export class Inbound extends XrayCommonClass {
} else { } else {
if (this.protocol == Protocols.SHADOWSOCKS && !this.isSSMultiUser) return this.genSSLink(addr, this.port, 'same', remark); if (this.protocol == Protocols.SHADOWSOCKS && !this.isSSMultiUser) return this.genSSLink(addr, this.port, 'same', remark);
if (this.protocol == Protocols.WIREGUARD) { if (this.protocol == Protocols.WIREGUARD) {
return this.genWireguardConfigs(remark, remarkModel); return this.genWireguardConfigs(remark, remarkModel, hostOverride);
} }
return ''; return '';
} }

View file

@ -31,9 +31,17 @@ import {
import { DBInbound } from '@/models/dbinbound.js'; import { DBInbound } from '@/models/dbinbound.js';
import FinalMaskForm from '@/components/FinalMaskForm.vue'; import FinalMaskForm from '@/components/FinalMaskForm.vue';
import DateTimePicker from '@/components/DateTimePicker.vue'; import DateTimePicker from '@/components/DateTimePicker.vue';
import { useNodeList } from '@/composables/useNodeList.js';
const { t } = useI18n(); 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 // Phase 5f-iii-b: structured per-protocol/per-transport forms instead
// of raw JSON textareas. Edits a deeply-reactive Inbound + DBInbound // of raw JSON textareas. Edits a deeply-reactive Inbound + DBInbound
// pair so the existing model helpers (.toString(), .canEnableTls(), // pair so the existing model helpers (.toString(), .canEnableTls(),
@ -474,6 +482,14 @@ async function submit() {
streamSettings: streamSettings, streamSettings: streamSettings,
sniffing: sniffing, 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' const url = props.mode === 'edit'
? `/panel/api/inbounds/update/${props.dbInbound.id}` ? `/panel/api/inbounds/update/${props.dbInbound.id}`
@ -543,6 +559,24 @@ watch(
<a-form-item :label="t('pages.inbounds.remark')"> <a-form-item :label="t('pages.inbounds.remark')">
<a-input v-model:value="dbForm.remark" /> <a-input v-model:value="dbForm.remark" />
</a-form-item> </a-form-item>
<a-form-item :label="t('pages.inbounds.deployTo')">
<a-select
v-model:value="dbForm.nodeId"
:disabled="mode === 'edit'"
:placeholder="t('pages.inbounds.localPanel')"
allow-clear
>
<a-select-option :value="null">{{ t('pages.inbounds.localPanel') }}</a-select-option>
<a-select-option
v-for="n in selectableNodes"
:key="n.id"
:value="n.id"
:disabled="n.status === 'offline'"
>
{{ n.name }}{{ n.status === 'offline' ? ' (offline)' : '' }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item :label="t('pages.inbounds.protocol')"> <a-form-item :label="t('pages.inbounds.protocol')">
<a-select :value="protocol" :disabled="mode === 'edit'" @change="onProtocolChange"> <a-select :value="protocol" :disabled="mode === 'edit'" @change="onProtocolChange">
<a-select-option v-for="p in PROTOCOLS" :key="p" :value="p">{{ p }}</a-select-option> <a-select-option v-for="p in PROTOCOLS" :key="p" :value="p">{{ p }}</a-select-option>

View file

@ -45,6 +45,10 @@ const props = defineProps({
trafficDiff: { type: Number, default: 0 }, trafficDiff: { type: Number, default: 0 },
ipLimitEnable: { type: Boolean, default: false }, ipLimitEnable: { type: Boolean, default: false },
tgBotEnable: { 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: { subSettings: {
type: Object, type: Object,
default: () => ({ enable: false, subURI: '', subJsonURI: '', subJsonEnable: false }), 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 // Generate links per protocol WireGuard has its own .conf body
// path; everything else flows through genAllLinks. // path; everything else flows through genAllLinks.
if (inbound.value.protocol === Protocols.WIREGUARD) { if (inbound.value.protocol === Protocols.WIREGUARD) {
wireguardConfigs.value = inbound.value.genWireguardConfigs(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).split('\r\n'); wireguardLinks.value = inbound.value.genWireguardLinks(props.dbInbound.remark, '-ieo', props.nodeAddress).split('\r\n');
links.value = []; links.value = [];
} else { } else {
links.value = inbound.value.genAllLinks( links.value = inbound.value.genAllLinks(
props.dbInbound.remark, props.dbInbound.remark,
props.remarkModel, props.remarkModel,
clientSettings.value, clientSettings.value,
props.nodeAddress,
); );
wireguardConfigs.value = []; wireguardConfigs.value = [];
wireguardLinks.value = []; wireguardLinks.value = [];

View file

@ -48,6 +48,9 @@ const props = defineProps({
isMobile: { type: Boolean, default: false }, isMobile: { type: Boolean, default: false },
isDarkTheme: { type: Boolean, default: false }, isDarkTheme: { type: Boolean, default: false },
subEnable: { 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([ 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.operate'), key: 'action', align: 'center', width: 30 },
{ title: t('pages.inbounds.enable'), key: 'enable', align: 'center', width: 35 }, { 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.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.port'), dataIndex: 'port', key: 'port', align: 'center', width: 40 },
{ title: t('pages.inbounds.protocol'), key: 'protocol', align: 'left', width: 130 }, { title: t('pages.inbounds.protocol'), key: 'protocol', align: 'left', width: 130 },
{ title: t('clients'), key: 'clients', align: 'left', width: 50 }, { title: t('clients'), key: 'clients', align: 'left', width: 50 },
@ -390,6 +394,22 @@ function showQrCodeMenu(dbInbound) {
<a-switch :checked="record.enable" @change="(next) => onSwitchEnable(record, next)" /> <a-switch :checked="record.enable" @change="(next) => onSwitchEnable(record, next)" />
</template> </template>
<!-- ============== Node deployment tag ============== -->
<template v-else-if="column.key === 'node'">
<template v-if="record.nodeId == null">
<a-tag color="default">{{ t('pages.inbounds.localPanel') }}</a-tag>
</template>
<template v-else-if="nodesById.get(record.nodeId)">
<a-tag :color="nodesById.get(record.nodeId).status === 'online' ? 'blue' : 'red'">
{{ nodesById.get(record.nodeId).name }}
</a-tag>
</template>
<template v-else>
<!-- Node row was deleted but inbound still references it. -->
<a-tag color="orange">node #{{ record.nodeId }}</a-tag>
</template>
</template>
<!-- ============== Protocol tags ============== --> <!-- ============== Protocol tags ============== -->
<template v-else-if="column.key === 'protocol'"> <template v-else-if="column.key === 'protocol'">
<div class="protocol-tags"> <div class="protocol-tags">

View file

@ -1,5 +1,5 @@
<script setup> <script setup>
import { onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { Modal, message } from 'ant-design-vue'; import { Modal, message } from 'ant-design-vue';
import { import {
@ -16,6 +16,7 @@ import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js'
import { useMediaQuery } from '@/composables/useMediaQuery.js'; import { useMediaQuery } from '@/composables/useMediaQuery.js';
import AppSidebar from '@/components/AppSidebar.vue'; import AppSidebar from '@/components/AppSidebar.vue';
import CustomStatistic from '@/components/CustomStatistic.vue'; import CustomStatistic from '@/components/CustomStatistic.vue';
import { useNodeList } from '@/composables/useNodeList.js';
import InboundList from './InboundList.vue'; import InboundList from './InboundList.vue';
import InboundFormModal from './InboundFormModal.vue'; import InboundFormModal from './InboundFormModal.vue';
import ClientFormModal from './ClientFormModal.vue'; import ClientFormModal from './ClientFormModal.vue';
@ -47,6 +48,9 @@ const {
fetchDefaultSettings, fetchDefaultSettings,
} = useInbounds(); } = useInbounds();
const { isMobile } = useMediaQuery(); const { isMobile } = useMediaQuery();
// Node list lives on the central panel; the Inbounds page consumes
// the idnode map for the new "Node" column. Fetched once on mount.
const { byId: nodesById } = useNodeList();
const basePath = window.__X_UI_BASE_PATH__ || ''; const basePath = window.__X_UI_BASE_PATH__ || '';
const requestUri = window.location.pathname; const requestUri = window.location.pathname;
@ -79,6 +83,18 @@ const qrOpen = ref(false);
const qrDbInbound = ref(null); const qrDbInbound = ref(null);
const qrClient = ref(null); const qrClient = ref(null);
// hostOverrideFor returns the node's address for a node-managed inbound,
// or '' when the inbound runs locally. Wired into the QR / Info modals
// and into export-all-links functions so generated share links point at
// the node, not the central panel.
function hostOverrideFor(dbInbound) {
if (!dbInbound || dbInbound.nodeId == null) return '';
return nodesById.value.get(dbInbound.nodeId)?.address || '';
}
const infoNodeAddress = computed(() => hostOverrideFor(infoDbInbound.value));
const qrNodeAddress = computed(() => hostOverrideFor(qrDbInbound.value));
// === Shared text + prompt modal state ================================= // === Shared text + prompt modal state =================================
const textOpen = ref(false); const textOpen = ref(false);
const textTitle = ref(''); const textTitle = ref('');
@ -125,7 +141,7 @@ function exportInboundLinks(dbInbound) {
const projected = checkFallback(dbInbound); const projected = checkFallback(dbInbound);
openText({ openText({
title: 'Export inbound links', title: 'Export inbound links',
content: projected.genInboundLinks(remarkModel.value), content: projected.genInboundLinks(remarkModel.value, hostOverrideFor(dbInbound)),
fileName: projected.remark || 'inbound', fileName: projected.remark || 'inbound',
}); });
} }
@ -156,7 +172,7 @@ function exportInboundSubs(dbInbound) {
function exportAllLinks() { function exportAllLinks() {
const out = []; const out = [];
for (const ib of dbInbounds.value) { for (const ib of dbInbounds.value) {
out.push(ib.genInboundLinks(remarkModel.value)); out.push(ib.genInboundLinks(remarkModel.value, hostOverrideFor(ib)));
} }
openText({ openText({
title: 'Export all inbound links', title: 'Export all inbound links',
@ -578,7 +594,7 @@ function onRowAction({ key, dbInbound }) {
<InboundList :db-inbounds="dbInbounds" :client-count="clientCount" :online-clients="onlineClients" <InboundList :db-inbounds="dbInbounds" :client-count="clientCount" :online-clients="onlineClients"
:last-online-map="lastOnlineMap" :is-dark-theme="themeState.isDark" :refreshing="refreshing" :last-online-map="lastOnlineMap" :is-dark-theme="themeState.isDark" :refreshing="refreshing"
:expire-diff="expireDiff" :traffic-diff="trafficDiff" :page-size="pageSize" :is-mobile="isMobile" :expire-diff="expireDiff" :traffic-diff="trafficDiff" :page-size="pageSize" :is-mobile="isMobile"
:sub-enable="subSettings.enable" @refresh="refresh" @add-inbound="onAddInbound" :sub-enable="subSettings.enable" :nodes-by-id="nodesById" @refresh="refresh" @add-inbound="onAddInbound"
@general-action="onGeneralAction" @row-action="onRowAction" @edit-client="onEditClient" @general-action="onGeneralAction" @row-action="onRowAction" @edit-client="onEditClient"
@qrcode-client="onQrcodeClient" @info-client="onInfoClient" @qrcode-client="onQrcodeClient" @info-client="onInfoClient"
@reset-traffic-client="onResetTrafficClient" @delete-client="onDeleteClient" @reset-traffic-client="onResetTrafficClient" @delete-client="onDeleteClient"
@ -598,8 +614,9 @@ function onRowAction({ key, dbInbound }) {
<InboundInfoModal v-model:open="infoOpen" :db-inbound="infoDbInbound" :client-index="infoClientIndex" <InboundInfoModal v-model:open="infoOpen" :db-inbound="infoDbInbound" :client-index="infoClientIndex"
:remark-model="remarkModel" :expire-diff="expireDiff" :traffic-diff="trafficDiff" :remark-model="remarkModel" :expire-diff="expireDiff" :traffic-diff="trafficDiff"
:ip-limit-enable="ipLimitEnable" :tg-bot-enable="tgBotEnable" :sub-settings="subSettings" :ip-limit-enable="ipLimitEnable" :tg-bot-enable="tgBotEnable" :sub-settings="subSettings"
:last-online-map="lastOnlineMap" /> :last-online-map="lastOnlineMap" :node-address="infoNodeAddress" />
<QrCodeModal v-model:open="qrOpen" :db-inbound="qrDbInbound" :client="qrClient" :remark-model="remarkModel" /> <QrCodeModal v-model:open="qrOpen" :db-inbound="qrDbInbound" :client="qrClient" :remark-model="remarkModel"
:node-address="qrNodeAddress" />
<TextModal v-model:open="textOpen" :title="textTitle" :content="textContent" :file-name="textFileName" /> <TextModal v-model:open="textOpen" :title="textTitle" :content="textContent" :file-name="textFileName" />
<PromptModal v-model:open="promptOpen" :title="promptTitle" :ok-text="promptOkText" :type="promptType" <PromptModal v-model:open="promptOpen" :title="promptTitle" :ok-text="promptOkText" :type="promptType"

View file

@ -17,6 +17,10 @@ const props = defineProps({
dbInbound: { type: Object, default: null }, dbInbound: { type: Object, default: null },
client: { type: Object, default: null }, client: { type: Object, default: null },
remarkModel: { type: String, default: '-ieo' }, remarkModel: { type: String, default: '-ieo' },
// Address of the node hosting this inbound (empty string for local).
// When set, share/QR links use it as the host instead of the panel's
// origin node-managed inbounds proxy from the node, not the panel.
nodeAddress: { type: String, default: '' },
}); });
const emit = defineEmits(['update:open']); const emit = defineEmits(['update:open']);
@ -32,13 +36,13 @@ watch(() => props.open, (next) => {
const peerRemark = props.client?.email const peerRemark = props.client?.email
? `${props.dbInbound.remark}-${props.client.email}` ? `${props.dbInbound.remark}-${props.client.email}`
: props.dbInbound.remark; : props.dbInbound.remark;
wireguardConfigs.value = inbound.genWireguardConfigs(peerRemark).split('\r\n'); wireguardConfigs.value = inbound.genWireguardConfigs(peerRemark, '-ieo', props.nodeAddress).split('\r\n');
wireguardLinks.value = inbound.genWireguardLinks(peerRemark).split('\r\n'); wireguardLinks.value = inbound.genWireguardLinks(peerRemark, '-ieo', props.nodeAddress).split('\r\n');
links.value = []; links.value = [];
} else { } else {
// When a client is provided we generate per-client share links; // When a client is provided we generate per-client share links;
// otherwise (single-user SS) fall back to the inbound's settings. // otherwise (single-user SS) fall back to the inbound's settings.
links.value = inbound.genAllLinks(props.dbInbound.remark, props.remarkModel, props.client); links.value = inbound.genAllLinks(props.dbInbound.remark, props.remarkModel, props.client, props.nodeAddress);
wireguardConfigs.value = []; wireguardConfigs.value = [];
wireguardLinks.value = []; wireguardLinks.value = [];
} }

View file

@ -0,0 +1,226 @@
<script setup>
import { computed, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { message } from 'ant-design-vue';
const props = defineProps({
open: { type: Boolean, default: false },
mode: { type: String, default: 'add' }, // 'add' | 'edit'
node: { type: Object, default: null },
testConnection: { type: Function, required: true },
save: { type: Function, required: true }, // (payload) => Promise<msg>
});
const emit = defineEmits(['update:open']);
const { t } = useI18n();
// Default form shape used for "add" mode and to reset between
// edits. Sane defaults: HTTPS, port 2053, base path '/', enabled.
function defaultForm() {
return {
id: 0,
name: '',
remark: '',
scheme: 'https',
address: '',
port: 2053,
basePath: '/',
apiToken: '',
enable: true,
};
}
const form = reactive(defaultForm());
const submitting = ref(false);
const testing = ref(false);
const testResult = ref(null); // { status, latencyMs, xrayVersion, error }
const tokenVisible = ref(false);
// Reset the form whenever the modal is opened. In edit mode we copy
// the existing node into the form fields; in add mode we wipe back
// to defaults so a previous edit doesn't leak through.
watch(
() => props.open,
(open) => {
if (!open) return;
Object.assign(form, defaultForm());
testResult.value = null;
if (props.mode === 'edit' && props.node) {
Object.assign(form, props.node);
}
},
);
const title = computed(() =>
props.mode === 'edit' ? t('pages.nodes.editNode') : t('pages.nodes.addNode'),
);
function close() {
if (!submitting.value) emit('update:open', false);
}
function buildPayload() {
return {
id: form.id || 0,
name: form.name?.trim() || '',
remark: form.remark?.trim() || '',
scheme: form.scheme || 'https',
address: form.address?.trim() || '',
port: Number(form.port) || 0,
basePath: form.basePath?.trim() || '/',
apiToken: form.apiToken?.trim() || '',
enable: !!form.enable,
};
}
async function onTest() {
testing.value = true;
testResult.value = null;
try {
const payload = buildPayload();
if (!payload.address || !payload.port) {
message.error(t('pages.nodes.toasts.fillRequired'));
return;
}
const msg = await props.testConnection(payload);
if (msg?.success) {
testResult.value = msg.obj;
} else {
testResult.value = { status: 'offline', error: msg?.msg || 'unknown error' };
}
} finally {
testing.value = false;
}
}
async function onSave() {
const payload = buildPayload();
if (!payload.name || !payload.address || !payload.port) {
message.error(t('pages.nodes.toasts.fillRequired'));
return;
}
submitting.value = true;
try {
const msg = await props.save(payload);
if (msg?.success) {
emit('update:open', false);
}
} finally {
submitting.value = false;
}
}
</script>
<template>
<a-modal
:open="open"
:title="title"
:confirm-loading="submitting"
:ok-text="t('save')"
:cancel-text="t('cancel')"
:mask-closable="false"
width="640px"
@ok="onSave"
@cancel="close"
>
<a-form layout="vertical" :model="form">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item :label="t('pages.nodes.name')" required>
<a-input v-model:value="form.name" :placeholder="t('pages.nodes.namePlaceholder')" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item :label="t('pages.nodes.remark')">
<a-input v-model:value="form.remark" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="6">
<a-form-item :label="t('pages.nodes.scheme')">
<a-select v-model:value="form.scheme">
<a-select-option value="https">https</a-select-option>
<a-select-option value="http">http</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item :label="t('pages.nodes.address')" required>
<a-input v-model:value="form.address" :placeholder="t('pages.nodes.addressPlaceholder')" />
</a-form-item>
</a-col>
<a-col :span="6">
<a-form-item :label="t('pages.nodes.port')" required>
<a-input-number v-model:value="form.port" :min="1" :max="65535" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item :label="t('pages.nodes.basePath')">
<a-input v-model:value="form.basePath" placeholder="/" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item :label="t('pages.nodes.enable')">
<a-switch v-model:checked="form.enable" />
</a-form-item>
</a-col>
</a-row>
<a-form-item :label="t('pages.nodes.apiToken')" required>
<a-input-password
v-model:value="form.apiToken"
:visibility-toggle="{ visible: tokenVisible, 'onUpdate:visible': (v) => (tokenVisible = v) }"
:placeholder="t('pages.nodes.apiTokenPlaceholder')"
/>
<div class="hint">{{ t('pages.nodes.apiTokenHint') }}</div>
</a-form-item>
<div class="test-row">
<a-button :loading="testing" @click="onTest">
{{ t('pages.nodes.testConnection') }}
</a-button>
<div v-if="testResult" class="test-result">
<a-alert
v-if="testResult.status === 'online'"
type="success"
show-icon
:message="t('pages.nodes.connectionOk', { ms: testResult.latencyMs })"
:description="testResult.xrayVersion ? `Xray ${testResult.xrayVersion}` : undefined"
/>
<a-alert
v-else
type="error"
show-icon
:message="t('pages.nodes.connectionFailed')"
:description="testResult.error"
/>
</div>
</div>
</a-form>
</a-modal>
</template>
<style scoped>
.hint {
font-size: 12px;
opacity: 0.6;
margin-top: 4px;
}
.test-row {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 8px;
}
.test-result {
width: 100%;
}
</style>

View file

@ -0,0 +1,211 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import {
EditOutlined,
DeleteOutlined,
ReloadOutlined,
PlusOutlined,
ThunderboltOutlined,
ExclamationCircleOutlined,
} from '@ant-design/icons-vue';
const props = defineProps({
nodes: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
isMobile: { type: Boolean, default: false },
});
const emit = defineEmits([
'add',
'edit',
'delete',
'probe',
'toggle-enable',
'refresh',
]);
const { t } = useI18n();
// Render the address column as a clickable URL so admins can jump to
// the remote panel directly from the list.
const dataSource = computed(() =>
props.nodes.map((n) => ({
...n,
url: `${n.scheme}://${n.address}:${n.port}${n.basePath || '/'}`,
key: n.id,
})),
);
function statusColor(status) {
switch (status) {
case 'online': return 'green';
case 'offline': return 'red';
default: return 'default';
}
}
// Relative-time formatter keeps the column compact and avoids
// pulling dayjs just for this single use.
function relativeTime(unixSeconds) {
if (!unixSeconds) return t('pages.nodes.never');
const diffSec = Math.max(0, Math.floor(Date.now() / 1000 - unixSeconds));
if (diffSec < 5) return t('pages.nodes.justNow');
if (diffSec < 60) return `${diffSec}s`;
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m`;
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h`;
return `${Math.floor(diffSec / 86400)}d`;
}
function formatUptime(secs) {
if (!secs) return '-';
const days = Math.floor(secs / 86400);
const hours = Math.floor((secs % 86400) / 3600);
if (days > 0) return `${days}d ${hours}h`;
const mins = Math.floor((secs % 3600) / 60);
if (hours > 0) return `${hours}h ${mins}m`;
return `${mins}m`;
}
function formatPct(p) {
if (typeof p !== 'number' || isNaN(p)) return '-';
return `${p.toFixed(1)}%`;
}
</script>
<template>
<a-card size="small" hoverable>
<div class="toolbar">
<a-space>
<a-button type="primary" @click="emit('add')">
<template #icon><PlusOutlined /></template>
{{ t('pages.nodes.addNode') }}
</a-button>
<a-button :loading="loading" @click="emit('refresh')">
<template #icon><ReloadOutlined /></template>
{{ t('pages.nodes.refresh') }}
</a-button>
</a-space>
</div>
<a-table
:data-source="dataSource"
:pagination="false"
:loading="loading"
:scroll="{ x: 'max-content' }"
size="middle"
row-key="id"
>
<a-table-column :title="t('pages.nodes.name')" data-index="name" :ellipsis="true">
<template #default="{ record }">
<div class="name-cell">
<span class="name">{{ record.name }}</span>
<span v-if="record.remark" class="remark">{{ record.remark }}</span>
</div>
</template>
</a-table-column>
<a-table-column :title="t('pages.nodes.address')" data-index="url" :ellipsis="true">
<template #default="{ record }">
<a :href="record.url" target="_blank" rel="noopener noreferrer">{{ record.url }}</a>
</template>
</a-table-column>
<a-table-column :title="t('pages.nodes.status')" data-index="status" align="center">
<template #default="{ record }">
<a-space :size="4">
<a-badge :status="statusColor(record.status) === 'green' ? 'success' : (statusColor(record.status) === 'red' ? 'error' : 'default')" />
<span>{{ t(`pages.nodes.statusValues.${record.status || 'unknown'}`) }}</span>
<a-tooltip v-if="record.lastError" :title="record.lastError">
<ExclamationCircleOutlined style="color: #faad14" />
</a-tooltip>
</a-space>
</template>
</a-table-column>
<a-table-column :title="t('pages.nodes.cpu')" data-index="cpuPct" align="center" :width="90">
<template #default="{ record }">{{ formatPct(record.cpuPct) }}</template>
</a-table-column>
<a-table-column :title="t('pages.nodes.mem')" data-index="memPct" align="center" :width="90">
<template #default="{ record }">{{ formatPct(record.memPct) }}</template>
</a-table-column>
<a-table-column :title="t('pages.nodes.xrayVersion')" data-index="xrayVersion" align="center">
<template #default="{ record }">
{{ record.xrayVersion || '-' }}
</template>
</a-table-column>
<a-table-column :title="t('pages.nodes.uptime')" data-index="uptimeSecs" align="center">
<template #default="{ record }">{{ formatUptime(record.uptimeSecs) }}</template>
</a-table-column>
<a-table-column :title="t('pages.nodes.latency')" data-index="latencyMs" align="center" :width="100">
<template #default="{ record }">
<span v-if="record.latencyMs > 0">{{ record.latencyMs }} ms</span>
<span v-else>-</span>
</template>
</a-table-column>
<a-table-column :title="t('pages.nodes.lastHeartbeat')" data-index="lastHeartbeat" align="center" :width="120">
<template #default="{ record }">{{ relativeTime(record.lastHeartbeat) }}</template>
</a-table-column>
<a-table-column :title="t('pages.nodes.enable')" data-index="enable" align="center" :width="80">
<template #default="{ record }">
<a-switch
:checked="record.enable"
size="small"
@change="(v) => emit('toggle-enable', record, v)"
/>
</template>
</a-table-column>
<a-table-column :title="t('pages.nodes.actions')" align="center" :width="160" fixed="right">
<template #default="{ record }">
<a-space>
<a-tooltip :title="t('pages.nodes.probe')">
<a-button type="text" size="small" @click="emit('probe', record)">
<template #icon><ThunderboltOutlined /></template>
</a-button>
</a-tooltip>
<a-tooltip :title="t('edit')">
<a-button type="text" size="small" @click="emit('edit', record)">
<template #icon><EditOutlined /></template>
</a-button>
</a-tooltip>
<a-tooltip :title="t('delete')">
<a-button type="text" size="small" danger @click="emit('delete', record)">
<template #icon><DeleteOutlined /></template>
</a-button>
</a-tooltip>
</a-space>
</template>
</a-table-column>
</a-table>
</a-card>
</template>
<style scoped>
.toolbar {
margin-bottom: 12px;
display: flex;
justify-content: space-between;
align-items: center;
}
.name-cell {
display: flex;
flex-direction: column;
}
.name {
font-weight: 500;
}
.remark {
font-size: 12px;
opacity: 0.65;
}
</style>

View file

@ -0,0 +1,240 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { Modal, message } from 'ant-design-vue';
import {
CloudServerOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
ThunderboltOutlined,
} from '@ant-design/icons-vue';
import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js';
import { useMediaQuery } from '@/composables/useMediaQuery.js';
import AppSidebar from '@/components/AppSidebar.vue';
import CustomStatistic from '@/components/CustomStatistic.vue';
import NodeList from './NodeList.vue';
import NodeFormModal from './NodeFormModal.vue';
import { useNodes } from './useNodes.js';
const { t } = useI18n();
const {
nodes,
loading,
fetched,
totals,
refresh,
create,
update,
remove,
setEnable,
testConnection,
probe,
} = useNodes();
const { isMobile } = useMediaQuery();
const basePath = window.__X_UI_BASE_PATH__ || '';
const requestUri = window.location.pathname;
// === Form modal state =================================================
const formOpen = ref(false);
const formMode = ref('add');
const formNode = ref(null);
function onAdd() {
formMode.value = 'add';
formNode.value = null;
formOpen.value = true;
}
function onEdit(node) {
formMode.value = 'edit';
formNode.value = { ...node };
formOpen.value = true;
}
// Save callback the modal hands its payload to. We hide the create vs.
// update branching here so the modal stays mode-agnostic.
async function onSave(payload) {
if (formMode.value === 'edit' && formNode.value?.id) {
return update(formNode.value.id, payload);
}
return create(payload);
}
function onDelete(node) {
Modal.confirm({
title: t('pages.nodes.deleteConfirmTitle', { name: node.name }),
content: t('pages.nodes.deleteConfirmContent'),
okText: t('delete'),
okType: 'danger',
cancelText: t('cancel'),
onOk: async () => {
const msg = await remove(node.id);
if (msg?.success) message.success(t('pages.nodes.toasts.deleted'));
},
});
}
async function onProbe(node) {
const msg = await probe(node.id);
if (msg?.success && msg.obj) {
if (msg.obj.status === 'online') {
message.success(t('pages.nodes.connectionOk', { ms: msg.obj.latencyMs }));
} else {
message.error(msg.obj.error || t('pages.nodes.toasts.probeFailed'));
}
}
}
async function onToggleEnable(node, next) {
await setEnable(node.id, next);
}
</script>
<template>
<a-config-provider :theme="antdThemeConfig">
<a-layout
class="nodes-page"
:class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }"
>
<AppSidebar :base-path="basePath" :request-uri="requestUri" />
<a-layout class="content-shell">
<a-layout-content id="content-layout" class="content-area">
<a-spin :spinning="!fetched" :delay="200" tip="Loading…" size="large">
<div v-if="!fetched" class="loading-spacer" />
<a-row v-else :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]">
<!-- Summary statistics card -->
<a-col :span="24">
<a-card size="small" hoverable class="summary-card">
<a-row :gutter="[16, 12]">
<a-col :sm="12" :md="6">
<CustomStatistic
:title="t('pages.nodes.totalNodes')"
:value="String(totals.total)"
>
<template #prefix>
<CloudServerOutlined />
</template>
</CustomStatistic>
</a-col>
<a-col :sm="12" :md="6">
<CustomStatistic
:title="t('pages.nodes.onlineNodes')"
:value="String(totals.online)"
>
<template #prefix>
<CheckCircleOutlined style="color: #52c41a" />
</template>
</CustomStatistic>
</a-col>
<a-col :sm="12" :md="6">
<CustomStatistic
:title="t('pages.nodes.offlineNodes')"
:value="String(totals.offline)"
>
<template #prefix>
<CloseCircleOutlined style="color: #ff4d4f" />
</template>
</CustomStatistic>
</a-col>
<a-col :sm="12" :md="6">
<CustomStatistic
:title="t('pages.nodes.avgLatency')"
:value="totals.avgLatency > 0 ? `${totals.avgLatency} ms` : '-'"
>
<template #prefix>
<ThunderboltOutlined />
</template>
</CustomStatistic>
</a-col>
</a-row>
</a-card>
</a-col>
<!-- Node table -->
<a-col :span="24">
<NodeList
:nodes="nodes"
:loading="loading"
:is-mobile="isMobile"
@add="onAdd"
@edit="onEdit"
@delete="onDelete"
@probe="onProbe"
@toggle-enable="onToggleEnable"
@refresh="refresh"
/>
</a-col>
</a-row>
</a-spin>
</a-layout-content>
</a-layout>
<NodeFormModal
v-model:open="formOpen"
:mode="formMode"
:node="formNode"
:test-connection="testConnection"
:save="onSave"
/>
</a-layout>
</a-config-provider>
</template>
<style scoped>
.nodes-page {
--bg-page: #e6e8ec;
--bg-card: #ffffff;
min-height: 100vh;
background: var(--bg-page);
}
.nodes-page.is-dark {
--bg-page: #0a1222;
--bg-card: #151f31;
}
.nodes-page.is-dark.is-ultra {
--bg-page: #050505;
--bg-card: #0c0e12;
}
.nodes-page :deep(.ant-layout),
.nodes-page :deep(.ant-layout-content) {
background: transparent;
}
.content-shell {
background: transparent;
}
.content-area {
padding: 24px;
}
@media (max-width: 768px) {
.content-area {
padding: 8px;
}
}
.loading-spacer {
min-height: calc(100vh - 120px);
}
.summary-card {
padding: 16px;
}
@media (max-width: 768px) {
.summary-card {
padding: 8px;
}
}
</style>

View file

@ -0,0 +1,140 @@
// Loads the node list and runs CRUD/probe actions against the
// /panel/api/nodes/* endpoints. Polls every 5s while the page is
// visible so heartbeat status stays fresh without a WebSocket.
import { computed, onBeforeUnmount, onMounted, ref, shallowRef } from 'vue';
import { HttpUtil } from '@/utils';
const POLL_INTERVAL_MS = 5000;
export function useNodes() {
const nodes = shallowRef([]);
const loading = ref(false);
const fetched = ref(false);
let pollTimer = null;
let pageVisible = true;
async function refresh() {
loading.value = true;
try {
const msg = await HttpUtil.get('/panel/api/nodes/list');
if (msg?.success) {
nodes.value = Array.isArray(msg.obj) ? msg.obj : [];
}
fetched.value = true;
} finally {
loading.value = false;
}
}
async function create(payload) {
const msg = await HttpUtil.post('/panel/api/nodes/add', payload);
if (msg?.success) await refresh();
return msg;
}
async function update(id, payload) {
const msg = await HttpUtil.post(`/panel/api/nodes/update/${id}`, payload);
if (msg?.success) await refresh();
return msg;
}
async function remove(id) {
const msg = await HttpUtil.post(`/panel/api/nodes/del/${id}`);
if (msg?.success) await refresh();
return msg;
}
async function setEnable(id, enable) {
const msg = await HttpUtil.post(`/panel/api/nodes/setEnable/${id}`, { enable });
if (msg?.success) await refresh();
return msg;
}
// testConnection probes a transient (unsaved) node config so the form
// can validate before save. Returns the ProbeResultUI shape from Go.
async function testConnection(payload) {
const msg = await HttpUtil.post('/panel/api/nodes/test', payload);
return msg;
}
// probe forces an immediate heartbeat against an already-saved node.
async function probe(id) {
const msg = await HttpUtil.post(`/panel/api/nodes/probe/${id}`);
if (msg?.success) await refresh();
return msg;
}
// Aggregate cards on the dashboard. Computed off the live list so
// a refresh picks up new totals automatically.
const totals = computed(() => {
const list = nodes.value;
let online = 0;
let offline = 0;
let latencySum = 0;
let latencyCount = 0;
for (const n of list) {
if (!n.enable) continue;
if (n.status === 'online') {
online += 1;
if (n.latencyMs > 0) {
latencySum += n.latencyMs;
latencyCount += 1;
}
} else if (n.status === 'offline') {
offline += 1;
}
}
return {
total: list.length,
online,
offline,
avgLatency: latencyCount > 0 ? Math.round(latencySum / latencyCount) : 0,
};
});
function startPolling() {
if (pollTimer) return;
pollTimer = setInterval(() => {
if (pageVisible) refresh();
}, POLL_INTERVAL_MS);
}
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
function onVisibilityChange() {
pageVisible = !document.hidden;
if (pageVisible) refresh();
}
onMounted(() => {
refresh();
startPolling();
document.addEventListener('visibilitychange', onVisibilityChange);
});
onBeforeUnmount(() => {
stopPolling();
document.removeEventListener('visibilitychange', onVisibilityChange);
});
return {
nodes,
loading,
fetched,
totals,
refresh,
create,
update,
remove,
setEnable,
testConnection,
probe,
};
}

View file

@ -1,7 +1,7 @@
<script setup> <script setup>
import { reactive, ref } from 'vue'; import { onMounted, reactive, ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { message } from 'ant-design-vue'; import { Modal, message } from 'ant-design-vue';
import { HttpUtil, RandomUtil } from '@/utils'; import { HttpUtil, RandomUtil } from '@/utils';
import SettingListItem from '@/components/SettingListItem.vue'; import SettingListItem from '@/components/SettingListItem.vue';
@ -76,6 +76,65 @@ function updateUser() {
} }
} }
// === API Token =========================================================
// Surfaces the panel's API token so a remote central panel can register
// this instance as a node. Lazy-loaded on tab mount; rotation requires
// confirmation since it invalidates any cached value upstream.
const apiToken = ref('');
const apiTokenLoading = ref(false);
const apiTokenRotating = ref(false);
async function loadApiToken() {
apiTokenLoading.value = true;
try {
const msg = await HttpUtil.get('/panel/setting/getApiToken');
if (msg?.success) apiToken.value = msg.obj || '';
} finally {
apiTokenLoading.value = false;
}
}
async function copyApiToken() {
if (!apiToken.value) return;
try {
await navigator.clipboard.writeText(apiToken.value);
message.success(t('copySuccess'));
} catch (_e) {
// navigator.clipboard can be undefined on http:// fall back to
// a transient input + execCommand path.
const ta = document.createElement('textarea');
ta.value = apiToken.value;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
message.success(t('copySuccess'));
}
}
function regenerateApiToken() {
Modal.confirm({
title: t('pages.nodes.regenerateConfirm'),
okText: t('confirm'),
cancelText: t('cancel'),
okType: 'danger',
onOk: async () => {
apiTokenRotating.value = true;
try {
const msg = await HttpUtil.post('/panel/setting/regenerateApiToken');
if (msg?.success) {
apiToken.value = msg.obj || '';
message.success(t('success'));
}
} finally {
apiTokenRotating.value = false;
}
},
});
}
onMounted(loadApiToken);
function toggleTwoFactor() { function toggleTwoFactor() {
// Switch read-only the actual flip happens after the modal succeeds. // Switch read-only the actual flip happens after the modal succeeds.
if (!props.allSetting.twoFactorEnable) { if (!props.allSetting.twoFactorEnable) {
@ -156,6 +215,29 @@ function toggleTwoFactor() {
</template> </template>
</SettingListItem> </SettingListItem>
</a-collapse-panel> </a-collapse-panel>
<a-collapse-panel key="3" :header="t('pages.nodes.apiToken')">
<SettingListItem paddings="small">
<template #title>{{ t('pages.nodes.apiToken') }}</template>
<template #description>{{ t('pages.nodes.apiTokenHint') }}</template>
<template #control>
<a-input-password
:value="apiToken"
readonly
:loading="apiTokenLoading"
style="min-width: 240px"
/>
</template>
</SettingListItem>
<a-list-item>
<a-space direction="horizontal" :style="{ padding: '0 20px' }">
<a-button :disabled="!apiToken" @click="copyApiToken">{{ t('copy') }}</a-button>
<a-button danger :loading="apiTokenRotating" @click="regenerateApiToken">
{{ t('pages.nodes.regenerate') }}
</a-button>
</a-space>
</a-list-item>
</a-collapse-panel>
</a-collapse> </a-collapse>
<TwoFactorModal v-model:open="tfa.open" :title="tfa.title" :description="tfa.description" :token="tfa.token" <TwoFactorModal v-model:open="tfa.open" :title="tfa.title" :description="tfa.description" :token="tfa.token"

View file

@ -20,6 +20,8 @@ const MIGRATED_ROUTES = {
'/panel/inbounds/': '/inbounds.html', '/panel/inbounds/': '/inbounds.html',
'/panel/xray': '/xray.html', '/panel/xray': '/xray.html',
'/panel/xray/': '/xray.html', '/panel/xray/': '/xray.html',
'/panel/nodes': '/nodes.html',
'/panel/nodes/': '/nodes.html',
}; };
// Build a proxy config that suppresses ECONNREFUSED noise when the Go // Build a proxy config that suppresses ECONNREFUSED noise when the Go
@ -107,6 +109,7 @@ export default defineConfig({
settings: path.resolve(__dirname, 'settings.html'), settings: path.resolve(__dirname, 'settings.html'),
inbounds: path.resolve(__dirname, 'inbounds.html'), inbounds: path.resolve(__dirname, 'inbounds.html'),
xray: path.resolve(__dirname, 'xray.html'), xray: path.resolve(__dirname, 'xray.html'),
nodes: path.resolve(__dirname, 'nodes.html'),
subpage: path.resolve(__dirname, 'subpage.html'), subpage: path.resolve(__dirname, 'subpage.html'),
}, },
output: { output: {

View file

@ -29,6 +29,8 @@ func NewSubClashService(subService *SubService) *SubClashService {
} }
func (s *SubClashService) GetClash(subId string, host string) (string, string, error) { func (s *SubClashService) GetClash(subId string, host string) (string, string, error) {
// Set per-request state so resolveInboundAddress sees the node map.
s.SubService.PrepareForRequest(host)
inbounds, err := s.SubService.getInboundsBySubId(subId) inbounds, err := s.SubService.getInboundsBySubId(subId)
if err != nil || len(inbounds) == 0 { if err != nil || len(inbounds) == 0 {
return "", "", err return "", "", err
@ -118,11 +120,18 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
func (s *SubClashService) getProxies(inbound *model.Inbound, client model.Client, host string) []map[string]any { func (s *SubClashService) getProxies(inbound *model.Inbound, client model.Client, host string) []map[string]any {
stream := s.streamData(inbound.StreamSettings) stream := s.streamData(inbound.StreamSettings)
// For node-managed inbounds the Clash proxy "server" must be the
// node's address, not the request host. resolveInboundAddress handles
// the node→listen→request-host fallback chain.
defaultDest := s.SubService.resolveInboundAddress(inbound)
if defaultDest == "" {
defaultDest = host
}
externalProxies, ok := stream["externalProxy"].([]any) externalProxies, ok := stream["externalProxy"].([]any)
if !ok || len(externalProxies) == 0 { if !ok || len(externalProxies) == 0 {
externalProxies = []any{map[string]any{ externalProxies = []any{map[string]any{
"forceTls": "same", "forceTls": "same",
"dest": host, "dest": defaultDest,
"port": float64(inbound.Port), "port": float64(inbound.Port),
"remark": "", "remark": "",
}} }}

View file

@ -87,6 +87,9 @@ func NewSubJsonService(fragment string, noises string, mux string, rules string,
// GetJson generates a JSON subscription configuration for the given subscription ID and host. // GetJson generates a JSON subscription configuration for the given subscription ID and host.
func (s *SubJsonService) GetJson(subId string, host string) (string, string, error) { func (s *SubJsonService) GetJson(subId string, host string) (string, string, error) {
// Set per-request state on the shared SubService so any
// resolveInboundAddress call inside picks node-aware host values.
s.SubService.PrepareForRequest(host)
inbounds, err := s.SubService.getInboundsBySubId(subId) inbounds, err := s.SubService.getInboundsBySubId(subId)
if err != nil || len(inbounds) == 0 { if err != nil || len(inbounds) == 0 {
return "", "", err return "", "", err
@ -167,12 +170,22 @@ func (s *SubJsonService) getConfig(inbound *model.Inbound, client model.Client,
var newJsonArray []json_util.RawMessage var newJsonArray []json_util.RawMessage
stream := s.streamData(inbound.StreamSettings) stream := s.streamData(inbound.StreamSettings)
// When externalProxy is empty the JSON config falls back to a
// synthetic one whose `dest` is the host the client connects to.
// For node-managed inbounds we want the node's address — request
// host won't reach the right xray. resolveInboundAddress already
// implements the node→listen→request-host fallback chain.
defaultDest := s.SubService.resolveInboundAddress(inbound)
if defaultDest == "" {
defaultDest = host
}
externalProxies, ok := stream["externalProxy"].([]any) externalProxies, ok := stream["externalProxy"].([]any)
if !ok || len(externalProxies) == 0 { if !ok || len(externalProxies) == 0 {
externalProxies = []any{ externalProxies = []any{
map[string]any{ map[string]any{
"forceTls": "same", "forceTls": "same",
"dest": host, "dest": defaultDest,
"port": float64(inbound.Port), "port": float64(inbound.Port),
"remark": "", "remark": "",
}, },

View file

@ -30,6 +30,11 @@ type SubService struct {
datepicker string datepicker string
inboundService service.InboundService inboundService service.InboundService
settingService service.SettingService settingService service.SettingService
// nodesByID is populated per request from the Node table so
// resolveInboundAddress can return the node's address for any
// inbound whose NodeID is set. Keeps the per-link host derivation
// O(1) instead of O(N) DB hits.
nodesByID map[int]*model.Node
} }
// NewSubService creates a new subscription service with the given configuration. // NewSubService creates a new subscription service with the given configuration.
@ -40,9 +45,19 @@ func NewSubService(showInfo bool, remarkModel string) *SubService {
} }
} }
// PrepareForRequest sets per-request state (host + nodes map) on the
// shared SubService. Called by every entry point — GetSubs, GetJson,
// GetClash — so resolveInboundAddress sees the right host and the
// freshly-loaded node map regardless of which sub flavour the client
// hit.
func (s *SubService) PrepareForRequest(host string) {
s.address = host
s.loadNodes()
}
// GetSubs retrieves subscription links for a given subscription ID and host. // GetSubs retrieves subscription links for a given subscription ID and host.
func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.ClientTraffic, error) { func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.ClientTraffic, error) {
s.address = host s.PrepareForRequest(host)
var result []string var result []string
var traffic xray.ClientTraffic var traffic xray.ClientTraffic
var lastOnline int64 var lastOnline int64
@ -522,7 +537,39 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin
return url.String() return url.String()
} }
// loadNodes refreshes nodesByID from the DB. Called once per request so
// the per-inbound resolveInboundAddress lookups are pure map reads.
// We filter to address != '' so a half-configured node row doesn't
// accidentally produce a useless host like "https://:2053".
func (s *SubService) loadNodes() {
db := database.GetDB()
var nodes []*model.Node
if err := db.Model(&model.Node{}).Where("address != ''").Find(&nodes).Error; err != nil {
logger.Warning("subscription: load nodes failed:", err)
s.nodesByID = nil
return
}
m := make(map[int]*model.Node, len(nodes))
for _, n := range nodes {
m[n.Id] = n
}
s.nodesByID = m
}
// resolveInboundAddress picks the host an external client should
// connect to. Order:
// 1. If the inbound is node-managed and the node has an address, use
// the node's address — central panel's hostname doesn't speak xray
// for that inbound.
// 2. If the inbound binds to a non-wildcard listen address, use it.
// 3. Otherwise fall back to the request's host (whatever the client
// subscribed against).
func (s *SubService) resolveInboundAddress(inbound *model.Inbound) string { func (s *SubService) resolveInboundAddress(inbound *model.Inbound) string {
if inbound.NodeID != nil && s.nodesByID != nil {
if n, ok := s.nodesByID[*inbound.NodeID]; ok && n.Address != "" {
return n.Address
}
}
if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" { if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" {
return s.address return s.address
} }

View file

@ -2,6 +2,7 @@ package controller
import ( import (
"net/http" "net/http"
"strings"
"github.com/mhsanaei/3x-ui/v2/web/middleware" "github.com/mhsanaei/3x-ui/v2/web/middleware"
"github.com/mhsanaei/3x-ui/v2/web/service" "github.com/mhsanaei/3x-ui/v2/web/service"
@ -15,6 +16,9 @@ type APIController struct {
BaseController BaseController
inboundController *InboundController inboundController *InboundController
serverController *ServerController serverController *ServerController
nodeController *NodeController
settingService service.SettingService
userService service.UserService
Tgbot service.Tgbot Tgbot service.Tgbot
} }
@ -26,8 +30,32 @@ func NewAPIController(g *gin.RouterGroup, customGeo *service.CustomGeoService) *
} }
// checkAPIAuth is a middleware that returns 404 for unauthenticated API requests // checkAPIAuth is a middleware that returns 404 for unauthenticated API requests
// to hide the existence of API endpoints from unauthorized users // to hide the existence of API endpoints from unauthorized users.
//
// Two auth paths are accepted:
// 1. Authorization: Bearer <apiToken> — used by remote central panels
// polling this instance as a node. Matches via constant-time compare.
// Sets c.Set("api_authed", true) so CSRFMiddleware can short-circuit.
// 2. Existing session cookie — used by browsers logged into the panel UI.
//
// Anything else falls through to a 404 so the API endpoints remain hidden.
func (a *APIController) checkAPIAuth(c *gin.Context) { func (a *APIController) checkAPIAuth(c *gin.Context) {
auth := c.GetHeader("Authorization")
if strings.HasPrefix(auth, "Bearer ") {
tok := strings.TrimPrefix(auth, "Bearer ")
if a.settingService.MatchApiToken(tok) {
// Handlers like InboundController.addInbound assume a logged-in
// user (inbound.UserId = user.Id). Bearer callers have no
// session, so attach the first user as a fallback. Single-user
// panels are the norm here.
if u, err := a.userService.GetFirstUser(); err == nil {
session.SetAPIAuthUser(c, u)
}
c.Set("api_authed", true)
c.Next()
return
}
}
if !session.IsLogin(c) { if !session.IsLogin(c) {
c.AbortWithStatus(http.StatusNotFound) c.AbortWithStatus(http.StatusNotFound)
return return
@ -50,6 +78,10 @@ func (a *APIController) initRouter(g *gin.RouterGroup, customGeo *service.Custom
server := api.Group("/server") server := api.Group("/server")
a.serverController = NewServerController(server) a.serverController = NewServerController(server)
// Nodes API — multi-panel management
nodes := api.Group("/nodes")
a.nodeController = NewNodeController(nodes)
NewCustomGeoController(api.Group("/custom-geo"), customGeo) NewCustomGeoController(api.Group("/custom-geo"), customGeo)
// Extra routes // Extra routes

View file

@ -148,10 +148,23 @@ func (a *InboundController) addInbound(c *gin.Context) {
} }
user := session.GetLoginUser(c) user := session.GetLoginUser(c)
inbound.UserId = user.Id inbound.UserId = user.Id
if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" { // Treat NodeID=0 as "no node" — gin's *int form binding can land on
inbound.Tag = fmt.Sprintf("inbound-%v", inbound.Port) // 0 when the field is absent or empty, and 0 is never a valid Node
} else { // row id. Without this normalization the runtime layer would try to
inbound.Tag = fmt.Sprintf("inbound-%v:%v", inbound.Listen, inbound.Port) // load Node id=0 and surface "record not found".
if inbound.NodeID != nil && *inbound.NodeID == 0 {
inbound.NodeID = nil
}
// When the central panel deploys an inbound to a remote node, it sends
// the Tag pre-computed (so both DBs agree on the identifier). Local
// UI submits don't include a Tag — we compute one from listen+port
// using the original collision-avoiding scheme.
if inbound.Tag == "" {
if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" {
inbound.Tag = fmt.Sprintf("inbound-%v", inbound.Port)
} else {
inbound.Tag = fmt.Sprintf("inbound-%v:%v", inbound.Listen, inbound.Port)
}
} }
inbound, needRestart, err := a.inboundService.AddInbound(inbound) inbound, needRestart, err := a.inboundService.AddInbound(inbound)
@ -201,6 +214,13 @@ func (a *InboundController) updateInbound(c *gin.Context) {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err) jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
return return
} }
// Same NodeID=0 → nil normalisation as addInbound. UpdateInbound
// loads the existing row's NodeID from DB anyway (Phase 1 doesn't
// support migrating an inbound between nodes), but normalising here
// keeps the wire shape consistent.
if inbound.NodeID != nil && *inbound.NodeID == 0 {
inbound.NodeID = nil
}
inbound, needRestart, err := a.inboundService.UpdateInbound(inbound) inbound, needRestart, err := a.inboundService.UpdateInbound(inbound)
if err != nil { if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
@ -458,10 +478,12 @@ func (a *InboundController) importInbound(c *gin.Context) {
user := session.GetLoginUser(c) user := session.GetLoginUser(c)
inbound.Id = 0 inbound.Id = 0
inbound.UserId = user.Id inbound.UserId = user.Id
if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" { if inbound.Tag == "" {
inbound.Tag = fmt.Sprintf("inbound-%v", inbound.Port) if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" {
} else { inbound.Tag = fmt.Sprintf("inbound-%v", inbound.Port)
inbound.Tag = fmt.Sprintf("inbound-%v:%v", inbound.Listen, inbound.Port) } else {
inbound.Tag = fmt.Sprintf("inbound-%v:%v", inbound.Listen, inbound.Port)
}
} }
for index := range inbound.ClientStats { for index := range inbound.ClientStats {

184
web/controller/node.go Normal file
View file

@ -0,0 +1,184 @@
package controller
import (
"context"
"strconv"
"time"
"github.com/mhsanaei/3x-ui/v2/database/model"
"github.com/mhsanaei/3x-ui/v2/web/service"
"github.com/gin-gonic/gin"
)
// NodeController exposes CRUD + probe endpoints for managing remote
// 3x-ui panels registered as nodes. All routes mount under
// /panel/api/nodes/ via APIController.initRouter and inherit its
// session-or-bearer auth from checkAPIAuth.
type NodeController struct {
nodeService service.NodeService
}
// NewNodeController creates the controller and wires its routes onto g.
func NewNodeController(g *gin.RouterGroup) *NodeController {
a := &NodeController{}
a.initRouter(g)
return a
}
func (a *NodeController) initRouter(g *gin.RouterGroup) {
g.GET("/list", a.list)
g.GET("/get/:id", a.get)
g.POST("/add", a.add)
g.POST("/update/:id", a.update)
g.POST("/del/:id", a.del)
g.POST("/setEnable/:id", a.setEnable)
// /test takes a transient payload (no DB write) so the user can
// validate connectivity before saving the node.
g.POST("/test", a.test)
// /probe/:id triggers a synchronous probe of an already-saved node
// without waiting for the next 10s heartbeat tick.
g.POST("/probe/:id", a.probe)
}
func (a *NodeController) list(c *gin.Context) {
nodes, err := a.nodeService.GetAll()
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.list"), err)
return
}
jsonObj(c, nodes, nil)
}
func (a *NodeController) get(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "get"), err)
return
}
n, err := a.nodeService.GetById(id)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err)
return
}
jsonObj(c, n, nil)
}
func (a *NodeController) add(c *gin.Context) {
n := &model.Node{}
if err := c.ShouldBind(n); err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err)
return
}
if err := a.nodeService.Create(n); err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err)
return
}
jsonMsgObj(c, I18nWeb(c, "pages.nodes.toasts.add"), n, nil)
}
func (a *NodeController) update(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "get"), err)
return
}
n := &model.Node{}
if err := c.ShouldBind(n); err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
return
}
if err := a.nodeService.Update(id, n); err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), nil)
}
func (a *NodeController) del(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "get"), err)
return
}
if err := a.nodeService.Delete(id); err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.delete"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.delete"), nil)
}
// setEnable accepts a JSON body { "enable": bool } so the toggle
// switch can flip a node without sending the whole record back.
func (a *NodeController) setEnable(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "get"), err)
return
}
body := struct {
Enable bool `json:"enable" form:"enable"`
}{}
if err := c.ShouldBind(&body); err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
return
}
if err := a.nodeService.SetEnable(id, body.Enable); err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), nil)
}
// test runs Probe against a transient Node payload without writing to
// the DB. Used by the form modal to validate connectivity before save.
func (a *NodeController) test(c *gin.Context) {
n := &model.Node{}
if err := c.ShouldBind(n); err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.test"), err)
return
}
// Reuse normalize-style defaults so the form can leave scheme/basePath
// blank and still get a sensible probe URL. We do this by round-tripping
// through Create's validator without the DB write — a tiny duplication
// here vs. exposing normalize publicly.
if n.Scheme == "" {
n.Scheme = "https"
}
if n.BasePath == "" {
n.BasePath = "/"
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Second)
defer cancel()
patch, err := a.nodeService.Probe(ctx, n)
jsonObj(c, patch.ToUI(err == nil), nil)
}
// probe triggers a one-off probe against a saved node and persists
// the result so the dashboard updates immediately, without waiting
// for the next heartbeat tick.
func (a *NodeController) probe(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "get"), err)
return
}
n, err := a.nodeService.GetById(id)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err)
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Second)
defer cancel()
patch, probeErr := a.nodeService.Probe(ctx, n)
if probeErr != nil {
patch.Status = "offline"
} else {
patch.Status = "online"
}
_ = a.nodeService.UpdateHeartbeat(id, patch)
jsonObj(c, patch.ToUI(probeErr == nil), nil)
}

View file

@ -44,6 +44,8 @@ func (a *SettingController) initRouter(g *gin.RouterGroup) {
g.POST("/updateUser", a.updateUser) g.POST("/updateUser", a.updateUser)
g.POST("/restartPanel", a.restartPanel) g.POST("/restartPanel", a.restartPanel)
g.GET("/getDefaultJsonConfig", a.getDefaultXrayConfig) g.GET("/getDefaultJsonConfig", a.getDefaultXrayConfig)
g.GET("/getApiToken", a.getApiToken)
g.POST("/regenerateApiToken", a.regenerateApiToken)
} }
// getAllSetting retrieves all current settings. // getAllSetting retrieves all current settings.
@ -121,3 +123,27 @@ func (a *SettingController) getDefaultXrayConfig(c *gin.Context) {
} }
jsonObj(c, defaultJsonConfig, nil) jsonObj(c, defaultJsonConfig, nil)
} }
// getApiToken returns the panel's API token used by remote central
// panels to authenticate as Bearer tokens. The token is auto-generated
// on first read so existing installs upgrade transparently.
func (a *SettingController) getApiToken(c *gin.Context) {
tok, err := a.settingService.GetApiToken()
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getSettings"), err)
return
}
jsonObj(c, tok, nil)
}
// regenerateApiToken rotates the API token. Any central panel that had
// the old value cached will start failing heartbeats until it is updated
// with the new token — that's intentional, it's the whole point of rotation.
func (a *SettingController) regenerateApiToken(c *gin.Context) {
tok, err := a.settingService.RegenerateApiToken()
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
return
}
jsonObj(c, tok, nil)
}

View file

@ -33,6 +33,7 @@ func (a *XUIController) initRouter(g *gin.RouterGroup) {
g.GET("/", a.index) g.GET("/", a.index)
g.GET("/inbounds", a.inbounds) g.GET("/inbounds", a.inbounds)
g.GET("/nodes", a.nodes)
g.GET("/settings", a.settings) g.GET("/settings", a.settings)
g.GET("/xray", a.xraySettings) g.GET("/xray", a.xraySettings)
@ -60,6 +61,11 @@ func (a *XUIController) inbounds(c *gin.Context) {
serveDistPage(c, "inbounds.html") serveDistPage(c, "inbounds.html")
} }
// nodes renders the multi-panel nodes management page.
func (a *XUIController) nodes(c *gin.Context) {
serveDistPage(c, "nodes.html")
}
// settings renders the settings management page. // settings renders the settings management page.
func (a *XUIController) settings(c *gin.Context) { func (a *XUIController) settings(c *gin.Context) {
serveDistPage(c, "settings.html") serveDistPage(c, "settings.html")

View file

@ -0,0 +1,92 @@
package job
import (
"context"
"sync"
"time"
"github.com/mhsanaei/3x-ui/v2/database/model"
"github.com/mhsanaei/3x-ui/v2/logger"
"github.com/mhsanaei/3x-ui/v2/web/service"
)
// nodeHeartbeatConcurrency caps how many remote panels we probe at once.
// Plenty of headroom for typical deployments (tens of nodes) without
// letting a misconfigured run open thousands of sockets at once.
const nodeHeartbeatConcurrency = 32
// nodeHeartbeatRequestTimeout bounds a single probe. The cron is @every 10s,
// so this needs to stay well under that to avoid run pile-up.
const nodeHeartbeatRequestTimeout = 6 * time.Second
// NodeHeartbeatJob probes every enabled remote node once per cron tick
// and persists the result. Disabled nodes are skipped entirely so a
// long-broken node can be parked without burning sockets every 10s.
type NodeHeartbeatJob struct {
nodeService service.NodeService
// Coarse mutex prevents two ticks running concurrently if probes
// pile up under network failure. The next tick simply skips when
// the previous one is still draining.
running sync.Mutex
}
// NewNodeHeartbeatJob constructs a heartbeat job. The robfig/cron
// scheduler will hand the same instance to every tick, so the
// running mutex carries across runs as intended.
func NewNodeHeartbeatJob() *NodeHeartbeatJob {
return &NodeHeartbeatJob{}
}
func (j *NodeHeartbeatJob) Run() {
if !j.running.TryLock() {
// Previous tick still in flight — skip this one.
return
}
defer j.running.Unlock()
nodes, err := j.nodeService.GetAll()
if err != nil {
logger.Warning("node heartbeat: load nodes failed:", err)
return
}
if len(nodes) == 0 {
return
}
sem := make(chan struct{}, nodeHeartbeatConcurrency)
var wg sync.WaitGroup
for _, n := range nodes {
if !n.Enable {
continue
}
wg.Add(1)
sem <- struct{}{}
go func(n *model.Node) {
defer wg.Done()
defer func() { <-sem }()
j.probeOne(n)
}(n)
}
wg.Wait()
}
// probeOne runs a single probe and persists the result. We deliberately
// don't return errors — partial failures across the node set should not
// abort other probes, and the LastError column carries the message for
// the UI to surface.
func (j *NodeHeartbeatJob) probeOne(n *model.Node) {
ctx, cancel := context.WithTimeout(context.Background(), nodeHeartbeatRequestTimeout)
defer cancel()
patch, err := j.nodeService.Probe(ctx, n)
if err != nil {
patch.Status = "offline"
} else {
patch.Status = "online"
}
if updErr := j.nodeService.UpdateHeartbeat(n.Id, patch); updErr != nil {
// A row deleted mid-tick produces "rows affected = 0", which
// gorm reports as nil — so any error we get here is real.
logger.Warning("node heartbeat: update node", n.Id, "failed:", updErr)
}
}

View file

@ -0,0 +1,137 @@
package job
import (
"context"
"sync"
"time"
"github.com/mhsanaei/3x-ui/v2/database/model"
"github.com/mhsanaei/3x-ui/v2/logger"
"github.com/mhsanaei/3x-ui/v2/web/runtime"
"github.com/mhsanaei/3x-ui/v2/web/service"
"github.com/mhsanaei/3x-ui/v2/web/websocket"
)
// nodeTrafficSyncConcurrency caps how many nodes we sync simultaneously.
// Each sync does three HTTP calls in series, so the wall-clock budget
// per node is the request timeout below — keeping the cap modest avoids
// flooding the network while still getting through dozens of nodes
// inside a 10s tick.
const nodeTrafficSyncConcurrency = 8
// nodeTrafficSyncRequestTimeout bounds the per-node sync. Three probes
// in series at 8s each would blow past the cron interval, so the budget
// here covers the whole snapshot — FetchTrafficSnapshot internally caps
// each HTTP call at the runtime's own 10s ceiling but uses ctx for the
// outer total.
const nodeTrafficSyncRequestTimeout = 8 * time.Second
// NodeTrafficSyncJob pulls absolute traffic + online stats from every
// enabled, currently-online remote node and merges them into the central
// DB. Mirrors NodeHeartbeatJob's structure: TryLock to skip pile-ups,
// errgroup-style fan-out with a concurrency cap, per-node ctx timeout.
//
// Offline nodes are skipped entirely — the heartbeat job already owns
// status tracking, and we'd just waste sockets retrying a node we know
// is unreachable. As soon as heartbeat marks a node online again, the
// next traffic tick picks it up.
type NodeTrafficSyncJob struct {
nodeService service.NodeService
inboundService service.InboundService
// Coarse mutex prevents two ticks running concurrently if a single
// sync stalls past the 10s cron interval (rare but possible when
// many nodes are slow simultaneously).
running sync.Mutex
}
// NewNodeTrafficSyncJob builds a singleton sync job. Cron hands the same
// instance to every tick so the running mutex is preserved across runs.
func NewNodeTrafficSyncJob() *NodeTrafficSyncJob {
return &NodeTrafficSyncJob{}
}
func (j *NodeTrafficSyncJob) Run() {
if !j.running.TryLock() {
return
}
defer j.running.Unlock()
mgr := runtime.GetManager()
if mgr == nil {
// Server still booting — pre-Manager runs are normal during
// the first few seconds of startup.
return
}
nodes, err := j.nodeService.GetAll()
if err != nil {
logger.Warning("node traffic sync: load nodes failed:", err)
return
}
if len(nodes) == 0 {
return
}
sem := make(chan struct{}, nodeTrafficSyncConcurrency)
var wg sync.WaitGroup
for _, n := range nodes {
if !n.Enable || n.Status != "online" {
continue
}
wg.Add(1)
sem <- struct{}{}
go func(n *model.Node) {
defer wg.Done()
defer func() { <-sem }()
j.syncOne(mgr, n)
}(n)
}
wg.Wait()
// One broadcast per tick, batched across all nodes — frontend code
// is invariant to whether the rows came from local xray or a node,
// so we reuse the same WebSocket envelope XrayTrafficJob uses.
if websocket.HasClients() {
online := j.inboundService.GetOnlineClients()
if online == nil {
online = []string{}
}
lastOnline, err := j.inboundService.GetClientsLastOnline()
if err != nil {
logger.Warning("node traffic sync: get last-online failed:", err)
}
if lastOnline == nil {
lastOnline = map[string]int64{}
}
websocket.BroadcastTraffic(map[string]any{
"onlineClients": online,
"lastOnlineMap": lastOnline,
})
}
}
// syncOne fetches and merges one node's snapshot. Errors are logged
// per-node and don't propagate; one slow node shouldn't keep the rest
// from running.
func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node) {
ctx, cancel := context.WithTimeout(context.Background(), nodeTrafficSyncRequestTimeout)
defer cancel()
rt, err := mgr.RemoteFor(n)
if err != nil {
logger.Warning("node traffic sync: remote lookup failed for", n.Name, ":", err)
return
}
snap, err := rt.FetchTrafficSnapshot(ctx)
if err != nil {
logger.Warning("node traffic sync: fetch from", n.Name, "failed:", err)
// Drop node-online contribution so a hiccup doesn't leave the
// online filter showing stale clients indefinitely.
j.inboundService.ClearNodeOnlineClients(n.Id)
return
}
if err := j.inboundService.SetRemoteTraffic(n.Id, snap); err != nil {
logger.Warning("node traffic sync: merge for", n.Name, "failed:", err)
}
}

View file

@ -23,8 +23,15 @@ func SecurityHeadersMiddleware(directHTTPS bool) gin.HandlerFunc {
} }
// CSRFMiddleware rejects unsafe requests that do not include the session CSRF token. // CSRFMiddleware rejects unsafe requests that do not include the session CSRF token.
// Bearer-token-authenticated callers (api_authed flag set by APIController.checkAPIAuth)
// short-circuit the CSRF check — they are not browser sessions, so the
// cross-site request forgery threat model doesn't apply to them.
func CSRFMiddleware() gin.HandlerFunc { func CSRFMiddleware() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
if c.GetBool("api_authed") {
c.Next()
return
}
if isSafeMethod(c.Request.Method) { if isSafeMethod(c.Request.Method) {
c.Next() c.Next()
return return

137
web/runtime/local.go Normal file
View file

@ -0,0 +1,137 @@
package runtime
import (
"context"
"encoding/json"
"errors"
"sync"
"github.com/mhsanaei/3x-ui/v2/database/model"
"github.com/mhsanaei/3x-ui/v2/xray"
)
// LocalDeps wires the runtime to the panel's xray process and the
// service.XrayService restart trigger via callbacks. We use callbacks
// (not an interface to *service.XrayService) because the runtime
// package would otherwise cycle-import service.
type LocalDeps struct {
// APIPort returns the xray gRPC API port the local engine is
// currently listening on. Returns 0 when xray isn't running yet —
// callers should treat that as a transient error.
APIPort func() int
// SetNeedRestart trips the panel's "restart xray on next cron tick"
// flag. Mirrors how InboundController.addInbound calls
// xrayService.SetToNeedRestart() today.
SetNeedRestart func()
}
// Local implements Runtime against the panel's own xray process. Each
// call follows the existing inbound.go pattern: open a gRPC client,
// run one operation, close. Per-call init keeps the connection state
// scoped so a stuck call can't leak across operations.
type Local struct {
deps LocalDeps
// Serialise gRPC operations — xray's HandlerService isn't documented
// as concurrent-safe and the existing InboundService implicitly
// runs one op at a time per request. This matches that.
mu sync.Mutex
}
// NewLocal builds a Local runtime. deps.APIPort and deps.SetNeedRestart
// are required; callers that want a no-op restart can pass `func(){}`.
func NewLocal(deps LocalDeps) *Local {
return &Local{deps: deps}
}
func (l *Local) Name() string { return "local" }
// withAPI runs fn against a freshly-initialised XrayAPI client and
// guarantees Close() afterwards. Returns an error if the gRPC port
// isn't available yet (xray still starting / stopped).
func (l *Local) withAPI(fn func(api *xray.XrayAPI) error) error {
l.mu.Lock()
defer l.mu.Unlock()
port := l.deps.APIPort()
if port <= 0 {
return errors.New("local xray is not running")
}
var api xray.XrayAPI
if err := api.Init(port); err != nil {
return err
}
defer api.Close()
return fn(&api)
}
func (l *Local) AddInbound(_ context.Context, ib *model.Inbound) error {
body, err := json.MarshalIndent(ib.GenXrayInboundConfig(), "", " ")
if err != nil {
return err
}
return l.withAPI(func(api *xray.XrayAPI) error {
return api.AddInbound(body)
})
}
func (l *Local) DelInbound(_ context.Context, ib *model.Inbound) error {
return l.withAPI(func(api *xray.XrayAPI) error {
return api.DelInbound(ib.Tag)
})
}
func (l *Local) UpdateInbound(ctx context.Context, oldIb, newIb *model.Inbound) error {
// xray-core has no in-place inbound update — drop and re-add.
// Matches what InboundService.UpdateInbound did inline.
if err := l.DelInbound(ctx, oldIb); err != nil {
// Best-effort: continue to AddInbound so a transient remove
// failure (e.g. inbound already gone) doesn't strand us. The
// caller's needRestart fallback will reconcile from config.
_ = err
}
if !newIb.Enable {
// Disabled inbounds aren't pushed to xray; we already removed
// the old one above.
return nil
}
return l.AddInbound(ctx, newIb)
}
func (l *Local) AddUser(_ context.Context, ib *model.Inbound, userMap map[string]any) error {
return l.withAPI(func(api *xray.XrayAPI) error {
return api.AddUser(string(ib.Protocol), ib.Tag, userMap)
})
}
func (l *Local) RemoveUser(_ context.Context, ib *model.Inbound, email string) error {
return l.withAPI(func(api *xray.XrayAPI) error {
return api.RemoveUser(ib.Tag, email)
})
}
func (l *Local) RestartXray(_ context.Context) error {
if l.deps.SetNeedRestart != nil {
l.deps.SetNeedRestart()
}
return nil
}
// Reset methods are intentional no-ops for Local. The central DB UPDATE
// that runs in InboundService.Reset* before this call has already zeroed
// the counters that xray reads; on the next stats poll the gRPC service
// will pick up matching values. Pre-Phase-1 the panel never issued an
// xrayApi reset call here either — keeping the same shape avoids a
// behaviour change for single-panel users.
func (l *Local) ResetClientTraffic(_ context.Context, _ *model.Inbound, _ string) error {
return nil
}
func (l *Local) ResetInboundClientTraffics(_ context.Context, _ *model.Inbound) error {
return nil
}
func (l *Local) ResetAllTraffics(_ context.Context) error {
return nil
}

145
web/runtime/manager.go Normal file
View file

@ -0,0 +1,145 @@
package runtime
import (
"errors"
"sync"
"github.com/mhsanaei/3x-ui/v2/database"
"github.com/mhsanaei/3x-ui/v2/database/model"
)
// Manager is the entry point for service code that needs a Runtime.
// One singleton lives in the package-level `manager` var, set at
// server bootstrap (web.go calls SetManager once). InboundService and
// friends read it via GetManager().
//
// Local runs forever; Remotes are built lazily per nodeID and cached.
// Cache invalidation runs on node Update/Delete (NodeService hooks
// InvalidateNode) so a token rotation surfaces the next call.
type Manager struct {
local Runtime
mu sync.RWMutex
remotes map[int]*Remote
}
// NewManager wires the singleton with the deps Local needs. The runtime
// package can't import service so the caller (web.go) supplies the
// callbacks that bridge into XrayService.
func NewManager(localDeps LocalDeps) *Manager {
return &Manager{
local: NewLocal(localDeps),
remotes: make(map[int]*Remote),
}
}
// RuntimeFor picks the right adapter for an inbound based on NodeID.
// Returns local when nodeID is nil; otherwise looks up the node row
// (or returns the cached Remote for it). The caller does not need to
// know which kind they got — that's the point of the abstraction.
func (m *Manager) RuntimeFor(nodeID *int) (Runtime, error) {
if nodeID == nil {
return m.local, nil
}
m.mu.RLock()
if rt, ok := m.remotes[*nodeID]; ok {
m.mu.RUnlock()
return rt, nil
}
m.mu.RUnlock()
// Cache miss — load the node row and build a Remote. We re-check
// under the write lock to avoid duplicate construction under load.
m.mu.Lock()
defer m.mu.Unlock()
if rt, ok := m.remotes[*nodeID]; ok {
return rt, nil
}
n, err := loadNode(*nodeID)
if err != nil {
return nil, err
}
if !n.Enable {
return nil, errors.New("node " + n.Name + " is disabled")
}
rt := NewRemote(n)
m.remotes[*nodeID] = rt
return rt, nil
}
// Local returns the singleton local runtime. Used by code that needs
// to operate on the panel's own xray regardless of which inbound it
// came from (e.g. on-demand restart from the UI).
func (m *Manager) Local() Runtime { return m.local }
// RemoteFor returns the Remote adapter for an already-loaded node row.
// Differs from RuntimeFor in two ways: it skips the DB lookup (caller
// hands in the node), and it returns the concrete *Remote so callers
// like NodeTrafficSyncJob can reach FetchTrafficSnapshot, which the
// Runtime interface doesn't expose.
func (m *Manager) RemoteFor(node *model.Node) (*Remote, error) {
if node == nil {
return nil, errors.New("node is nil")
}
m.mu.RLock()
if rt, ok := m.remotes[node.Id]; ok {
m.mu.RUnlock()
return rt, nil
}
m.mu.RUnlock()
m.mu.Lock()
defer m.mu.Unlock()
if rt, ok := m.remotes[node.Id]; ok {
return rt, nil
}
rt := NewRemote(node)
m.remotes[node.Id] = rt
return rt, nil
}
// InvalidateNode drops the cached Remote for nodeID so the next
// RuntimeFor call rebuilds it from the (possibly updated) node row.
// Called from NodeService.Update / Delete.
func (m *Manager) InvalidateNode(nodeID int) {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.remotes, nodeID)
}
// loadNode reads a node row directly from the DB. Kept package-local
// to avoid pulling NodeService into the runtime — service depends on
// runtime, not the other way around.
func loadNode(id int) (*model.Node, error) {
db := database.GetDB()
n := &model.Node{}
if err := db.Model(model.Node{}).Where("id = ?", id).First(n).Error; err != nil {
return nil, err
}
return n, nil
}
// Singleton wiring -------------------------------------------------------
var (
managerMu sync.RWMutex
manager *Manager
)
// SetManager installs the process-wide Manager. web.go calls this once
// during NewServer. Tests can call it again with a stub.
func SetManager(m *Manager) {
managerMu.Lock()
defer managerMu.Unlock()
manager = m
}
// GetManager returns the installed Manager, or nil before SetManager
// has run. Callers should treat nil as "still booting" — the existing
// behaviour for code paths that only run on the local engine continues
// to work via a pre-wired fallback set up in init() below.
func GetManager() *Manager {
managerMu.RLock()
defer managerMu.RUnlock()
return manager
}

407
web/runtime/remote.go Normal file
View file

@ -0,0 +1,407 @@
package runtime
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"github.com/mhsanaei/3x-ui/v2/database/model"
"github.com/mhsanaei/3x-ui/v2/logger"
)
// remoteHTTPTimeout bounds a single remote API call. Generous enough for
// a slow node under load, short enough that a wedged remote doesn't
// block the central panel's UI thread for the user.
const remoteHTTPTimeout = 10 * time.Second
// remoteHTTPClient is shared so repeated calls to the same node reuse
// connections. Per-request timeouts are set via context.
var remoteHTTPClient = &http.Client{
Transport: &http.Transport{
MaxIdleConns: 64,
MaxIdleConnsPerHost: 4,
IdleConnTimeout: 60 * time.Second,
},
}
// envelope mirrors entity.Msg without depending on the entity package
// (avoids a cycle on the controller side that pulls in this runtime).
type envelope struct {
Success bool `json:"success"`
Msg string `json:"msg"`
Obj json.RawMessage `json:"obj"`
}
// Remote implements Runtime by calling the existing /panel/api/inbounds/*
// endpoints on a remote 3x-ui panel. The remote is authenticated as
// the central panel via its per-node Bearer token.
//
// remoteIDByTag caches the {tag → remote inbound id} mapping so the
// hot path (update/delete/addClient) avoids /list lookups. The cache
// is in-memory and rebuilt lazily on first miss after a process restart
// or InvalidateNode call.
type Remote struct {
node *model.Node
mu sync.RWMutex
remoteIDByTag map[string]int
}
// NewRemote constructs a Remote runtime for one node. The node pointer
// is cached; callers that mutate node config (via NodeService.Update)
// must drop the runtime through Manager.InvalidateNode so a fresh one
// picks up the new fields.
func NewRemote(n *model.Node) *Remote {
return &Remote{
node: n,
remoteIDByTag: make(map[string]int),
}
}
func (r *Remote) Name() string { return "node:" + r.node.Name }
// baseURL composes the panel root for r.node, e.g. https://1.2.3.4:2053/
// Always ends in '/' so callers can append "panel/api/...".
func (r *Remote) baseURL() string {
bp := r.node.BasePath
if bp == "" {
bp = "/"
}
if !strings.HasSuffix(bp, "/") {
bp += "/"
}
return fmt.Sprintf("%s://%s:%d%s", r.node.Scheme, r.node.Address, r.node.Port, bp)
}
// do issues an HTTP request against the remote panel and decodes the
// entity.Msg envelope. Returns an error for transport failures, non-2xx
// responses, or {success:false} bodies.
//
// body may be nil. For application/x-www-form-urlencoded calls (the
// existing controllers bind via c.ShouldBind which prefers form-encoded)
// pass url.Values; for JSON pass any other type and we'll marshal it.
func (r *Remote) do(ctx context.Context, method, path string, body any) (*envelope, error) {
if r.node.ApiToken == "" {
return nil, errors.New("node has no API token configured")
}
target := r.baseURL() + strings.TrimPrefix(path, "/")
var (
reqBody io.Reader
contentType string
)
switch b := body.(type) {
case nil:
// nothing
case url.Values:
reqBody = strings.NewReader(b.Encode())
contentType = "application/x-www-form-urlencoded"
default:
buf, err := json.Marshal(b)
if err != nil {
return nil, fmt.Errorf("marshal body: %w", err)
}
reqBody = bytes.NewReader(buf)
contentType = "application/json"
}
cctx, cancel := context.WithTimeout(ctx, remoteHTTPTimeout)
defer cancel()
req, err := http.NewRequestWithContext(cctx, method, target, reqBody)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+r.node.ApiToken)
req.Header.Set("Accept", "application/json")
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
resp, err := remoteHTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("%s %s: %w", method, path, err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read body: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%s %s: HTTP %d", method, path, resp.StatusCode)
}
var env envelope
if err := json.Unmarshal(raw, &env); err != nil {
return nil, fmt.Errorf("decode envelope: %w", err)
}
if !env.Success {
return &env, fmt.Errorf("remote: %s", env.Msg)
}
return &env, nil
}
// resolveRemoteID returns the remote panel's local inbound ID for the
// given tag. Cache-backed; on miss it hits /panel/api/inbounds/list and
// repopulates the whole map (one-shot list is cheaper than per-tag
// lookups when several inbounds need resolving in sequence).
func (r *Remote) resolveRemoteID(ctx context.Context, tag string) (int, error) {
if id, ok := r.cacheGet(tag); ok {
return id, nil
}
if err := r.refreshRemoteIDs(ctx); err != nil {
return 0, err
}
if id, ok := r.cacheGet(tag); ok {
return id, nil
}
return 0, fmt.Errorf("remote inbound with tag %q not found on node %s", tag, r.node.Name)
}
func (r *Remote) cacheGet(tag string) (int, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
id, ok := r.remoteIDByTag[tag]
return id, ok
}
func (r *Remote) cacheSet(tag string, id int) {
r.mu.Lock()
defer r.mu.Unlock()
r.remoteIDByTag[tag] = id
}
func (r *Remote) cacheDel(tag string) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.remoteIDByTag, tag)
}
// refreshRemoteIDs replaces the in-memory tag→id map with whatever the
// node currently has. Called on cache miss; also a useful recovery path
// when the remote panel is rebuilt or we get a "not found" on update.
func (r *Remote) refreshRemoteIDs(ctx context.Context) error {
env, err := r.do(ctx, http.MethodGet, "panel/api/inbounds/list", nil)
if err != nil {
return err
}
var list []struct {
Id int `json:"id"`
Tag string `json:"tag"`
}
if err := json.Unmarshal(env.Obj, &list); err != nil {
return fmt.Errorf("decode inbound list: %w", err)
}
next := make(map[string]int, len(list))
for _, ib := range list {
if ib.Tag == "" {
continue
}
next[ib.Tag] = ib.Id
}
r.mu.Lock()
r.remoteIDByTag = next
r.mu.Unlock()
return nil
}
func (r *Remote) AddInbound(ctx context.Context, ib *model.Inbound) error {
// Strip NodeID from the wire payload so the remote stores a "local"
// row from its own perspective. We also ship the full model.Inbound
// minus runtime metadata. Tag is preserved so central + remote agree
// on the identifier — relies on InboundController being patched to
// not overwrite a non-empty Tag.
payload := wireInbound(ib)
env, err := r.do(ctx, http.MethodPost, "panel/api/inbounds/add", payload)
if err != nil {
return err
}
// Response body contains the saved inbound (with the remote's Id).
var created struct {
Id int `json:"id"`
Tag string `json:"tag"`
}
if len(env.Obj) > 0 {
if err := json.Unmarshal(env.Obj, &created); err == nil && created.Id > 0 && created.Tag != "" {
r.cacheSet(created.Tag, created.Id)
}
}
return nil
}
func (r *Remote) DelInbound(ctx context.Context, ib *model.Inbound) error {
id, err := r.resolveRemoteID(ctx, ib.Tag)
if err != nil {
// Already gone on remote — treat as success so a sync after a
// remote panel reset doesn't strand the central panel.
logger.Warning("remote DelInbound: tag", ib.Tag, "not found on", r.node.Name, "— treating as success")
return nil
}
if _, err := r.do(ctx, http.MethodPost, "panel/api/inbounds/del/"+strconv.Itoa(id), nil); err != nil {
return err
}
r.cacheDel(ib.Tag)
return nil
}
func (r *Remote) UpdateInbound(ctx context.Context, oldIb, newIb *model.Inbound) error {
// The remote's old row is keyed by oldIb.Tag (tags can change on
// edit if listen/port changed). We update by remote-id so the row
// keeps its identity even when its tag flips.
id, err := r.resolveRemoteID(ctx, oldIb.Tag)
if err != nil {
// Remote lost the row — fall back to add. This can happen if
// the node panel was reset; we'd rather end up with the inbound
// existing than fail the user's update.
return r.AddInbound(ctx, newIb)
}
payload := wireInbound(newIb)
if _, err := r.do(ctx, http.MethodPost, "panel/api/inbounds/update/"+strconv.Itoa(id), payload); err != nil {
return err
}
// Tag may have changed — remap the cache.
if oldIb.Tag != newIb.Tag {
r.cacheDel(oldIb.Tag)
}
r.cacheSet(newIb.Tag, id)
return nil
}
// AddUser pushes a single client into the remote inbound's settings JSON.
// We can't reuse the central panel's xrayApi.AddUser shape directly
// because the remote's HTTP endpoint expects {id, settings} where
// settings is a JSON string with a "clients":[...] array. The central
// panel's InboundService has already updated its own settings JSON
// before calling us, so we just ship the new full settings to the
// remote via /update — simpler than reconstructing the partial AddUser
// payload remote-side.
//
// Caller passes the full updated *model.Inbound on the same code path
// AddUser is called from in InboundService. To avoid changing the
// Runtime interface for that, AddUser/RemoveUser delegate to UpdateInbound.
func (r *Remote) AddUser(ctx context.Context, ib *model.Inbound, _ map[string]any) error {
return r.UpdateInbound(ctx, ib, ib)
}
func (r *Remote) RemoveUser(ctx context.Context, ib *model.Inbound, _ string) error {
return r.UpdateInbound(ctx, ib, ib)
}
func (r *Remote) RestartXray(ctx context.Context) error {
_, err := r.do(ctx, http.MethodPost, "panel/api/server/restartXrayService", nil)
return err
}
func (r *Remote) ResetClientTraffic(ctx context.Context, ib *model.Inbound, email string) error {
id, err := r.resolveRemoteID(ctx, ib.Tag)
if err != nil {
// Already gone on remote — central reset is enough.
logger.Warning("remote ResetClientTraffic: tag", ib.Tag, "not found on", r.node.Name, "— treating as success")
return nil
}
_, err = r.do(ctx, http.MethodPost,
fmt.Sprintf("panel/api/inbounds/%d/resetClientTraffic/%s", id, url.PathEscape(email)),
nil)
return err
}
func (r *Remote) ResetInboundClientTraffics(ctx context.Context, ib *model.Inbound) error {
id, err := r.resolveRemoteID(ctx, ib.Tag)
if err != nil {
logger.Warning("remote ResetInboundClientTraffics: tag", ib.Tag, "not found on", r.node.Name, "— treating as success")
return nil
}
_, err = r.do(ctx, http.MethodPost,
fmt.Sprintf("panel/api/inbounds/resetAllClientTraffics/%d", id), nil)
return err
}
func (r *Remote) ResetAllTraffics(ctx context.Context) error {
_, err := r.do(ctx, http.MethodPost, "panel/api/inbounds/resetAllTraffics", nil)
return err
}
// TrafficSnapshot is what NodeTrafficSyncJob pulls from a remote node
// every cron tick. Inbounds carry absolute up/down/all_time + ClientStats
// (the same shape /panel/api/inbounds/list returns); the two map fields
// come from the dedicated /onlines and /lastOnline endpoints.
type TrafficSnapshot struct {
Inbounds []*model.Inbound
OnlineEmails []string
LastOnlineMap map[string]int64
}
// FetchTrafficSnapshot pulls the three pieces in series. Sequential is
// fine because the cron job already fans out across nodes — adding
// per-node parallelism on top would just thrash the remote.
//
// Not on the Runtime interface: only the sync job needs it, and Local
// has no equivalent (XrayTrafficJob already covers the local engine).
func (r *Remote) FetchTrafficSnapshot(ctx context.Context) (*TrafficSnapshot, error) {
snap := &TrafficSnapshot{LastOnlineMap: map[string]int64{}}
envList, err := r.do(ctx, http.MethodGet, "panel/api/inbounds/list", nil)
if err != nil {
return nil, err
}
if err := json.Unmarshal(envList.Obj, &snap.Inbounds); err != nil {
return nil, fmt.Errorf("decode inbound list: %w", err)
}
envOnlines, err := r.do(ctx, http.MethodPost, "panel/api/inbounds/onlines", nil)
if err != nil {
// Onlines/lastOnline are nice-to-have. A failure here shouldn't
// invalidate the inbound counter merge — log and continue with
// empty values, the next tick may succeed.
logger.Warning("remote", r.node.Name, "onlines fetch failed:", err)
} else if len(envOnlines.Obj) > 0 {
_ = json.Unmarshal(envOnlines.Obj, &snap.OnlineEmails)
}
envLastOnline, err := r.do(ctx, http.MethodPost, "panel/api/inbounds/lastOnline", nil)
if err != nil {
logger.Warning("remote", r.node.Name, "lastOnline fetch failed:", err)
} else if len(envLastOnline.Obj) > 0 {
_ = json.Unmarshal(envLastOnline.Obj, &snap.LastOnlineMap)
}
return snap, nil
}
// wireInbound builds the request body for /panel/api/inbounds/add and
// /update. Mirrors the form fields the existing InboundController
// expects via c.ShouldBind — we use form-encoded to match exactly.
//
// We deliberately omit Id (remote assigns its own), UserId (remote's
// fallback user takes over), NodeID (the remote sees itself as local),
// and ClientStats (those are joined-table data the remote rebuilds).
func wireInbound(ib *model.Inbound) url.Values {
v := url.Values{}
v.Set("up", strconv.FormatInt(ib.Up, 10))
v.Set("down", strconv.FormatInt(ib.Down, 10))
v.Set("total", strconv.FormatInt(ib.Total, 10))
v.Set("remark", ib.Remark)
v.Set("enable", strconv.FormatBool(ib.Enable))
v.Set("expiryTime", strconv.FormatInt(ib.ExpiryTime, 10))
v.Set("listen", ib.Listen)
v.Set("port", strconv.Itoa(ib.Port))
v.Set("protocol", string(ib.Protocol))
v.Set("settings", ib.Settings)
v.Set("streamSettings", ib.StreamSettings)
v.Set("tag", ib.Tag)
v.Set("sniffing", ib.Sniffing)
if ib.TrafficReset != "" {
v.Set("trafficReset", ib.TrafficReset)
}
return v
}

66
web/runtime/runtime.go Normal file
View file

@ -0,0 +1,66 @@
// Package runtime abstracts the live xray engine that an inbound's
// configuration is shipped to. Two implementations exist: Local talks
// to the panel's own xray via gRPC (the original behaviour); Remote
// talks to another 3x-ui panel's HTTP API as a managed Node.
//
// InboundService picks a Runtime per-inbound based on Inbound.NodeID.
// The point of the abstraction is to keep `if node != nil` checks out
// of the service code as Phase 2/3 features (traffic sync, subscription
// per-node) build on top.
package runtime
import (
"context"
"github.com/mhsanaei/3x-ui/v2/database/model"
)
// Runtime is the live-engine adapter for one inbound's worth of
// operations. Implementations must be safe for concurrent use — the
// service layer does not synchronise calls.
type Runtime interface {
// Name identifies the adapter in logs ("local", "node:<name>").
Name() string
// AddInbound deploys an inbound to the engine. The Tag field on ib
// is treated as the source of truth for identifying the inbound on
// the remote side; Local ignores it.
AddInbound(ctx context.Context, ib *model.Inbound) error
// DelInbound removes the inbound identified by ib.Tag.
DelInbound(ctx context.Context, ib *model.Inbound) error
// UpdateInbound replaces the existing inbound with newIb. oldIb
// carries the previous config so the adapter can compute a minimal
// diff (Local: drop+add by tag; Remote: HTTP update by remote-id).
UpdateInbound(ctx context.Context, oldIb, newIb *model.Inbound) error
// AddUser hot-adds a client to the inbound identified by ib.Tag.
// userMap matches the shape that xray.XrayAPI.AddUser already takes
// — keys: email, id, password, auth, security, flow, cipher.
AddUser(ctx context.Context, ib *model.Inbound, userMap map[string]any) error
// RemoveUser hot-removes the client by email from ib's inbound.
RemoveUser(ctx context.Context, ib *model.Inbound, email string) error
// RestartXray asks the engine to fully restart. For Local this just
// flips the SetToNeedRestart flag and lets the cron pick it up; for
// Remote it issues an HTTP POST to /panel/api/server/restartXrayService.
RestartXray(ctx context.Context) error
// ResetClientTraffic zeros the up/down counters for one client on the
// engine. Local: no-op — the central DB UPDATE that runs before this
// call is sufficient, and xray's gRPC stats counter resets on the next
// poll. Remote: HTTP POST so the next traffic sync doesn't pull the
// pre-reset absolute back from the node.
ResetClientTraffic(ctx context.Context, ib *model.Inbound, email string) error
// ResetInboundClientTraffics zeros every client of one inbound. Same
// Local/Remote split as ResetClientTraffic.
ResetInboundClientTraffics(ctx context.Context, ib *model.Inbound) error
// ResetAllTraffics zeros every inbound counter on the engine. Used by
// the panel-wide "reset all traffic" action; called once per affected
// node so that nodes with no inbounds for the current panel are skipped.
ResetAllTraffics(ctx context.Context) error
}

View file

@ -3,6 +3,7 @@
package service package service
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"sort" "sort"
@ -15,6 +16,7 @@ import (
"github.com/mhsanaei/3x-ui/v2/database/model" "github.com/mhsanaei/3x-ui/v2/database/model"
"github.com/mhsanaei/3x-ui/v2/logger" "github.com/mhsanaei/3x-ui/v2/logger"
"github.com/mhsanaei/3x-ui/v2/util/common" "github.com/mhsanaei/3x-ui/v2/util/common"
"github.com/mhsanaei/3x-ui/v2/web/runtime"
"github.com/mhsanaei/3x-ui/v2/xray" "github.com/mhsanaei/3x-ui/v2/xray"
"gorm.io/gorm" "gorm.io/gorm"
@ -25,9 +27,29 @@ import (
// It handles CRUD operations for inbounds, client management, traffic monitoring, // It handles CRUD operations for inbounds, client management, traffic monitoring,
// and integration with the Xray API for real-time updates. // and integration with the Xray API for real-time updates.
type InboundService struct { type InboundService struct {
// xrayApi is retained for backwards compatibility with bulk paths
// that still talk to the local engine directly (e.g. traffic-reset
// jobs that scope to NodeID IS NULL inbounds anyway). New code paths
// route through runtimeFor() instead so they can target remote nodes.
xrayApi xray.XrayAPI xrayApi xray.XrayAPI
} }
// runtimeFor returns the Runtime adapter for an inbound's destination
// engine. Returns the local runtime when the inbound has no NodeID
// (legacy/local inbounds); otherwise the cached Remote for that node.
//
// nil is returned only when the runtime Manager hasn't been wired yet
// (extremely early bootstrap). Callers treat nil as a transient error
// and either fall back to needRestart=true or surface "panel still
// starting" upstream.
func (s *InboundService) runtimeFor(ib *model.Inbound) (runtime.Runtime, error) {
mgr := runtime.GetManager()
if mgr == nil {
return nil, fmt.Errorf("runtime manager not initialised")
}
return mgr.RuntimeFor(ib.NodeID)
}
type CopyClientsResult struct { type CopyClientsResult struct {
Added []string `json:"added"` Added []string `json:"added"`
Skipped []string `json:"skipped"` Skipped []string `json:"skipped"`
@ -375,20 +397,28 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
needRestart := false needRestart := false
if inbound.Enable { if inbound.Enable {
s.xrayApi.Init(p.GetAPIPort()) rt, rterr := s.runtimeFor(inbound)
inboundJson, err1 := json.MarshalIndent(inbound.GenXrayInboundConfig(), "", " ") if rterr != nil {
if err1 != nil { // Fail-fast on remote routing errors. Assign to the named
logger.Debug("Unable to marshal inbound config:", err1) // `err` so the deferred tx handler rolls back the central
// DB row that tx.Save just inserted — otherwise we'd leave
// an orphan that the user sees succeed despite the toast.
err = rterr
return inbound, false, err
} }
if err1 := rt.AddInbound(context.Background(), inbound); err1 == nil {
err1 = s.xrayApi.AddInbound(inboundJson) logger.Debug("New inbound added on", rt.Name(), ":", inbound.Tag)
if err1 == nil {
logger.Debug("New inbound added by api:", inbound.Tag)
} else { } else {
logger.Debug("Unable to add inbound by api:", err1) logger.Debug("Unable to add inbound on", rt.Name(), ":", err1)
if inbound.NodeID != nil {
// Remote add failed — roll back so central + node stay
// in sync (no row on either side).
err = err1
return inbound, false, err
}
// Local: keep the existing fall-through-to-restart behaviour.
needRestart = true needRestart = true
} }
s.xrayApi.Close()
} }
return inbound, needRestart, err return inbound, needRestart, err
@ -400,21 +430,35 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
func (s *InboundService) DelInbound(id int) (bool, error) { func (s *InboundService) DelInbound(id int) (bool, error) {
db := database.GetDB() db := database.GetDB()
var tag string
needRestart := false needRestart := false
result := db.Model(model.Inbound{}).Select("tag").Where("id = ? and enable = ?", id, true).First(&tag) // Load the full inbound (not just the tag) so we know its NodeID and
if result.Error == nil { // can route the runtime call to the right engine. Skip-on-not-found
s.xrayApi.Init(p.GetAPIPort()) // preserves the old "no-op when DB row doesn't exist" behaviour.
err1 := s.xrayApi.DelInbound(tag) var ib model.Inbound
if err1 == nil { loadErr := db.Model(model.Inbound{}).Where("id = ? and enable = ?", id, true).First(&ib).Error
logger.Debug("Inbound deleted by api:", tag) if loadErr == nil {
// Delete is best-effort on the runtime side: the user's intent is
// to get rid of the inbound, so a missing node row, an offline
// node, or a remote-side "already gone" should NEVER block the
// central DB cleanup. Worst case the remote keeps an orphan that
// the user can clean up manually — far less painful than the row
// being stuck on central.
rt, rterr := s.runtimeFor(&ib)
if rterr != nil {
logger.Warning("DelInbound: runtime lookup failed, deleting central row anyway:", rterr)
if ib.NodeID == nil {
needRestart = true
}
} else if err1 := rt.DelInbound(context.Background(), &ib); err1 == nil {
logger.Debug("Inbound deleted on", rt.Name(), ":", ib.Tag)
} else { } else {
logger.Debug("Unable to delete inbound by api:", err1) logger.Warning("DelInbound on", rt.Name(), "failed, deleting central row anyway:", err1)
needRestart = true if ib.NodeID == nil {
needRestart = true
}
} }
s.xrayApi.Close()
} else { } else {
logger.Debug("No enabled inbound founded to removing by api", tag) logger.Debug("No enabled inbound found to remove by api, id:", id)
} }
// Delete client traffics of inbounds // Delete client traffics of inbounds
@ -487,36 +531,43 @@ func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) {
} }
inbound.Enable = enable inbound.Enable = enable
// Sync xray runtime: drop the live inbound, add it back if we're enabling. // Sync xray runtime via the Runtime adapter. For local inbounds we
// "User not found"-style errors from DelInbound mean the inbound was // also rebuild the runtime config (drops clients flagged as disabled
// already absent from the live config — that's fine. Any other error // in ClientTraffic) so the live xray sees the same filtered view it
// means the live config and DB diverged, so we ask the caller to // did pre-refactor. Remote runtimes ship the unfiltered inbound —
// schedule a restart. // the remote panel does its own filtering before pushing to its xray.
needRestart := false needRestart := false
s.xrayApi.Init(p.GetAPIPort()) rt, rterr := s.runtimeFor(inbound)
defer s.xrayApi.Close() if rterr != nil {
if inbound.NodeID != nil {
return false, rterr
}
return true, nil
}
if err := s.xrayApi.DelInbound(inbound.Tag); err != nil && if err := rt.DelInbound(context.Background(), inbound); err != nil &&
!strings.Contains(err.Error(), "not found") { !strings.Contains(err.Error(), "not found") {
logger.Debug("SetInboundEnable: DelInbound via api failed:", err) logger.Debug("SetInboundEnable: DelInbound on", rt.Name(), "failed:", err)
needRestart = true needRestart = true
} }
if !enable { if !enable {
return needRestart, nil return needRestart, nil
} }
runtimeInbound, err := s.buildRuntimeInboundForAPI(db, inbound) addTarget := inbound
if err != nil { if inbound.NodeID == nil {
logger.Debug("SetInboundEnable: build runtime config failed:", err) runtimeInbound, err := s.buildRuntimeInboundForAPI(db, inbound)
return true, nil if err != nil {
logger.Debug("SetInboundEnable: build runtime config failed:", err)
return true, nil
}
addTarget = runtimeInbound
} }
inboundJson, err := json.MarshalIndent(runtimeInbound.GenXrayInboundConfig(), "", " ") if err := rt.AddInbound(context.Background(), addTarget); err != nil {
if err != nil { logger.Debug("SetInboundEnable: AddInbound on", rt.Name(), "failed:", err)
logger.Debug("SetInboundEnable: marshal runtime config failed:", err) if inbound.NodeID != nil {
return true, nil return false, err
} }
if err := s.xrayApi.AddInbound(inboundJson); err != nil {
logger.Debug("SetInboundEnable: AddInbound via api failed:", err)
needRestart = true needRestart = true
} }
return needRestart, nil return needRestart, nil
@ -637,32 +688,52 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
} }
needRestart := false needRestart := false
s.xrayApi.Init(p.GetAPIPort()) rt, rterr := s.runtimeFor(oldInbound)
if s.xrayApi.DelInbound(tag) == nil { if rterr != nil {
logger.Debug("Old inbound deleted by api:", tag) if oldInbound.NodeID != nil {
} err = rterr
if inbound.Enable { return inbound, false, err
runtimeInbound, err2 := s.buildRuntimeInboundForAPI(tx, oldInbound) }
if err2 != nil { needRestart = true
logger.Debug("Unable to prepare runtime inbound config:", err2) } else {
needRestart = true // Use a snapshot of the OLD tag so the remote can resolve its
} else { // remote-id even when the new tag has changed (port/listen edit).
inboundJson, err2 := json.MarshalIndent(runtimeInbound.GenXrayInboundConfig(), "", " ") oldSnapshot := *oldInbound
if err2 != nil { oldSnapshot.Tag = tag
logger.Debug("Unable to marshal updated inbound config:", err2) if oldInbound.NodeID == nil {
needRestart = true // Local: keep the old del-then-add-filtered behaviour to
} else { // preserve runtime client filtering.
err2 = s.xrayApi.AddInbound(inboundJson) if err2 := rt.DelInbound(context.Background(), &oldSnapshot); err2 == nil {
if err2 == nil { logger.Debug("Old inbound deleted on", rt.Name(), ":", tag)
logger.Debug("Updated inbound added by api:", oldInbound.Tag) }
if inbound.Enable {
runtimeInbound, err2 := s.buildRuntimeInboundForAPI(tx, oldInbound)
if err2 != nil {
logger.Debug("Unable to prepare runtime inbound config:", err2)
needRestart = true
} else if err2 := rt.AddInbound(context.Background(), runtimeInbound); err2 == nil {
logger.Debug("Updated inbound added on", rt.Name(), ":", oldInbound.Tag)
} else { } else {
logger.Debug("Unable to update inbound by api:", err2) logger.Debug("Unable to update inbound on", rt.Name(), ":", err2)
needRestart = true needRestart = true
} }
} }
} else {
// Remote: a single UpdateInbound call (the Remote adapter
// resolves remote-id by old tag, then POSTs /update/{id}).
// Assign to the outer `err` on failure so the deferred tx
// handler rolls back the central DB write.
if !inbound.Enable {
if err2 := rt.DelInbound(context.Background(), &oldSnapshot); err2 != nil {
err = err2
return inbound, false, err
}
} else if err2 := rt.UpdateInbound(context.Background(), &oldSnapshot, oldInbound); err2 != nil {
err = err2
return inbound, false, err
}
} }
} }
s.xrayApi.Close()
return inbound, needRestart, tx.Save(oldInbound).Error return inbound, needRestart, tx.Save(oldInbound).Error
} }
@ -885,36 +956,62 @@ func (s *InboundService) AddInboundClient(data *model.Inbound) (bool, error) {
}() }()
needRestart := false needRestart := false
s.xrayApi.Init(p.GetAPIPort()) rt, rterr := s.runtimeFor(oldInbound)
for _, client := range clients { if rterr != nil {
if len(client.Email) > 0 { if oldInbound.NodeID != nil {
s.AddClientStat(tx, data.Id, &client) err = rterr
if client.Enable { return false, err
cipher := "" }
if oldInbound.Protocol == "shadowsocks" { needRestart = true
cipher = oldSettings["method"].(string) } else if oldInbound.NodeID == nil {
} // Local: per-client AddUser keeps existing connections alive
err1 := s.xrayApi.AddUser(string(oldInbound.Protocol), oldInbound.Tag, map[string]any{ // (incremental hot-add). Walk every new client; on any failure
"email": client.Email, // fall back to needRestart so cron rebuilds from scratch.
"id": client.ID, for _, client := range clients {
"auth": client.Auth, if len(client.Email) == 0 {
"security": client.Security, needRestart = true
"flow": client.Flow, continue
"password": client.Password,
"cipher": cipher,
})
if err1 == nil {
logger.Debug("Client added by api:", client.Email)
} else {
logger.Debug("Error in adding client by api:", err1)
needRestart = true
}
} }
} else { s.AddClientStat(tx, data.Id, &client)
needRestart = true if !client.Enable {
continue
}
cipher := ""
if oldInbound.Protocol == "shadowsocks" {
cipher = oldSettings["method"].(string)
}
err1 := rt.AddUser(context.Background(), oldInbound, map[string]any{
"email": client.Email,
"id": client.ID,
"auth": client.Auth,
"security": client.Security,
"flow": client.Flow,
"password": client.Password,
"cipher": cipher,
})
if err1 == nil {
logger.Debug("Client added on", rt.Name(), ":", client.Email)
} else {
logger.Debug("Error in adding client on", rt.Name(), ":", err1)
needRestart = true
}
}
} else {
// Remote: a single UpdateInbound ships the new clients in one
// HTTP round-trip rather than N. Settings are already mutated
// in-memory (oldInbound.Settings) so the remote sees the final
// state. Per-client ClientStat rows still need the central DB
// update so the loop runs that branch first.
for _, client := range clients {
if len(client.Email) > 0 {
s.AddClientStat(tx, data.Id, &client)
}
}
if err1 := rt.UpdateInbound(context.Background(), oldInbound, oldInbound); err1 != nil {
err = err1
return false, err
} }
} }
s.xrayApi.Close()
return needRestart, tx.Save(oldInbound).Error return needRestart, tx.Save(oldInbound).Error
} }
@ -1203,20 +1300,30 @@ func (s *InboundService) DelInboundClient(inboundId int, clientId string) (bool,
} }
} }
if needApiDel && notDepleted { if needApiDel && notDepleted {
s.xrayApi.Init(p.GetAPIPort()) rt, rterr := s.runtimeFor(oldInbound)
err1 := s.xrayApi.RemoveUser(oldInbound.Tag, email) if rterr != nil {
if err1 == nil { if oldInbound.NodeID != nil {
logger.Debug("Client deleted by api:", email) return false, rterr
needRestart = false }
} else { needRestart = true
if strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", email)) { } else if oldInbound.NodeID == nil {
err1 := rt.RemoveUser(context.Background(), oldInbound, email)
if err1 == nil {
logger.Debug("Client deleted on", rt.Name(), ":", email)
needRestart = false
} else if strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", email)) {
logger.Debug("User is already deleted. Nothing to do more...") logger.Debug("User is already deleted. Nothing to do more...")
} else { } else {
logger.Debug("Error in deleting client by api:", err1) logger.Debug("Error in deleting client on", rt.Name(), ":", err1)
needRestart = true needRestart = true
} }
} else {
// Remote: settings already mutated above; one UpdateInbound
// ships the post-deletion state to the node.
if err1 := rt.UpdateInbound(context.Background(), oldInbound, oldInbound); err1 != nil {
return false, err1
}
} }
s.xrayApi.Close()
} }
} }
return needRestart, db.Save(oldInbound).Error return needRestart, db.Save(oldInbound).Error
@ -1415,42 +1522,55 @@ func (s *InboundService) UpdateInboundClient(data *model.Inbound, clientId strin
} }
needRestart := false needRestart := false
if len(oldEmail) > 0 { if len(oldEmail) > 0 {
s.xrayApi.Init(p.GetAPIPort()) rt, rterr := s.runtimeFor(oldInbound)
if oldClients[clientIndex].Enable { if rterr != nil {
err1 := s.xrayApi.RemoveUser(oldInbound.Tag, oldEmail) if oldInbound.NodeID != nil {
if err1 == nil { err = rterr
logger.Debug("Old client deleted by api:", oldEmail) return false, err
} else { }
if strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", oldEmail)) { needRestart = true
} else if oldInbound.NodeID == nil {
// Local: paired Remove+Add on the live xray, keeping other
// clients online (full-restart fallback on partial failure).
if oldClients[clientIndex].Enable {
err1 := rt.RemoveUser(context.Background(), oldInbound, oldEmail)
if err1 == nil {
logger.Debug("Old client deleted on", rt.Name(), ":", oldEmail)
} else if strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", oldEmail)) {
logger.Debug("User is already deleted. Nothing to do more...") logger.Debug("User is already deleted. Nothing to do more...")
} else { } else {
logger.Debug("Error in deleting client by api:", err1) logger.Debug("Error in deleting client on", rt.Name(), ":", err1)
needRestart = true needRestart = true
} }
} }
} if clients[0].Enable {
if clients[0].Enable { cipher := ""
cipher := "" if oldInbound.Protocol == "shadowsocks" {
if oldInbound.Protocol == "shadowsocks" { cipher = oldSettings["method"].(string)
cipher = oldSettings["method"].(string) }
err1 := rt.AddUser(context.Background(), oldInbound, map[string]any{
"email": clients[0].Email,
"id": clients[0].ID,
"security": clients[0].Security,
"flow": clients[0].Flow,
"auth": clients[0].Auth,
"password": clients[0].Password,
"cipher": cipher,
})
if err1 == nil {
logger.Debug("Client edited on", rt.Name(), ":", clients[0].Email)
} else {
logger.Debug("Error in adding client on", rt.Name(), ":", err1)
needRestart = true
}
} }
err1 := s.xrayApi.AddUser(string(oldInbound.Protocol), oldInbound.Tag, map[string]any{ } else {
"email": clients[0].Email, // Remote: settings already mutated; one UpdateInbound suffices.
"id": clients[0].ID, if err1 := rt.UpdateInbound(context.Background(), oldInbound, oldInbound); err1 != nil {
"security": clients[0].Security, err = err1
"flow": clients[0].Flow, return false, err
"auth": clients[0].Auth,
"password": clients[0].Password,
"cipher": cipher,
})
if err1 == nil {
logger.Debug("Client edited by api:", clients[0].Email)
} else {
logger.Debug("Error in adding client by api:", err1)
needRestart = true
} }
} }
s.xrayApi.Close()
} else { } else {
logger.Debug("Client old email not found") logger.Debug("Client old email not found")
needRestart = true needRestart = true
@ -1458,6 +1578,140 @@ func (s *InboundService) UpdateInboundClient(data *model.Inbound, clientId strin
return needRestart, tx.Save(oldInbound).Error return needRestart, tx.Save(oldInbound).Error
} }
// resetGracePeriodMs is the window after a reset during which incoming
// traffic snapshots from the node are ignored if they would resurrect
// non-zero counters. Three sync ticks (10s each) is enough headroom for
// the central → node reset HTTP call to land before the next pull.
const resetGracePeriodMs int64 = 30000
// SetRemoteTraffic merges absolute counters from a remote node into the
// central DB. Unlike AddTraffic, which adds deltas pulled from the local
// xray gRPC stats endpoint, this SETs the values — the node already has
// the canonical absolute value and we just mirror it.
//
// Rows in the post-reset grace window are skipped if the snapshot would
// regress them, so a user-initiated reset survives until the propagation
// HTTP call has completed on the node. After the grace window expires
// the snapshot wins regardless (the node is authoritative for the
// inbounds it hosts).
func (s *InboundService) SetRemoteTraffic(nodeID int, snap *runtime.TrafficSnapshot) error {
if snap == nil || nodeID <= 0 {
return nil
}
db := database.GetDB()
now := time.Now().UnixMilli()
// Load central inbounds for this node so we can resolve tag→id and
// honour the per-inbound grace window. One query covers every row
// touched in this tick.
var central []model.Inbound
if err := db.Model(model.Inbound{}).
Where("node_id = ?", nodeID).
Find(&central).Error; err != nil {
return err
}
tagToCentral := make(map[string]*model.Inbound, len(central))
for i := range central {
tagToCentral[central[i].Tag] = &central[i]
}
tx := db.Begin()
committed := false
defer func() {
if !committed {
tx.Rollback()
}
}()
// Per-inbound counter merge. Skip rows whose central allTime is
// suspiciously lower than the snapshot AND we're inside the grace
// window — that's the "reset hit central but not the node yet"
// pattern we want to defer until next tick.
for _, snapIb := range snap.Inbounds {
if snapIb == nil {
continue
}
c, ok := tagToCentral[snapIb.Tag]
if !ok {
continue // node has an inbound the central doesn't know about — ignore
}
snapAllTime := snapIb.AllTime
if snapAllTime == 0 {
snapAllTime = snapIb.Up + snapIb.Down
}
inGrace := c.LastTrafficResetTime > 0 && now-c.LastTrafficResetTime < resetGracePeriodMs
if inGrace && snapAllTime > c.AllTime {
logger.Debug("SetRemoteTraffic: skipping inbound", c.Id, "in reset grace window")
continue
}
if err := tx.Model(model.Inbound{}).
Where("id = ?", c.Id).
Updates(map[string]any{
"up": snapIb.Up,
"down": snapIb.Down,
"all_time": snapAllTime,
}).Error; err != nil {
return err
}
}
// Per-client merge. The snapshot's ClientStats are nested under
// each Inbound, so flatten before walking. Each client_traffics row
// is keyed by (inbound_id, email) — we resolve inbound_id from the
// central inbound row matched above.
for _, snapIb := range snap.Inbounds {
if snapIb == nil {
continue
}
c, ok := tagToCentral[snapIb.Tag]
if !ok {
continue
}
// Honour the same grace window for client rows: if the parent
// inbound was just reset, leave its clients alone too.
inGrace := c.LastTrafficResetTime > 0 && now-c.LastTrafficResetTime < resetGracePeriodMs
for _, cs := range snapIb.ClientStats {
snapAllTime := cs.AllTime
if snapAllTime == 0 {
snapAllTime = cs.Up + cs.Down
}
if inGrace {
// Skip client rows whose snapshot would push counters
// back up; allow rows that are zero on the node side
// (those are normal — node was reset alongside central).
if snapAllTime > 0 {
continue
}
}
// MAX(last_online, ?) so a momentary clock skew on the node
// can't regress the central row's last-seen timestamp.
if err := tx.Exec(
`UPDATE client_traffics
SET up = ?, down = ?, all_time = ?, last_online = MAX(last_online, ?)
WHERE inbound_id = ? AND email = ?`,
cs.Up, cs.Down, snapAllTime, cs.LastOnline, c.Id, cs.Email,
).Error; err != nil {
return err
}
}
}
if err := tx.Commit().Error; err != nil {
return err
}
committed = true
// Push the node's online-clients contribution into xray.Process so
// GetOnlineClients returns the union of local + every node. Empty
// list still calls Set so a node that just had everyone disconnect
// updates promptly.
if p != nil {
p.SetNodeOnlineClients(nodeID, snap.OnlineEmails)
}
return nil
}
func (s *InboundService) AddTraffic(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) (bool, bool, error) { func (s *InboundService) AddTraffic(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) (bool, bool, error) {
var err error var err error
db := database.GetDB() db := database.GetDB()
@ -2524,7 +2778,14 @@ func (s *InboundService) ResetClientTraffic(id int, clientEmail string) (bool, e
} }
for _, client := range clients { for _, client := range clients {
if client.Email == clientEmail && client.Enable { if client.Email == clientEmail && client.Enable {
s.xrayApi.Init(p.GetAPIPort()) rt, rterr := s.runtimeFor(inbound)
if rterr != nil {
if inbound.NodeID != nil {
return false, rterr
}
needRestart = true
break
}
cipher := "" cipher := ""
if string(inbound.Protocol) == "shadowsocks" { if string(inbound.Protocol) == "shadowsocks" {
var oldSettings map[string]any var oldSettings map[string]any
@ -2534,7 +2795,7 @@ func (s *InboundService) ResetClientTraffic(id int, clientEmail string) (bool, e
} }
cipher = oldSettings["method"].(string) cipher = oldSettings["method"].(string)
} }
err1 := s.xrayApi.AddUser(string(inbound.Protocol), inbound.Tag, map[string]any{ err1 := rt.AddUser(context.Background(), inbound, map[string]any{
"email": client.Email, "email": client.Email,
"id": client.ID, "id": client.ID,
"auth": client.Auth, "auth": client.Auth,
@ -2544,12 +2805,11 @@ func (s *InboundService) ResetClientTraffic(id int, clientEmail string) (bool, e
"cipher": cipher, "cipher": cipher,
}) })
if err1 == nil { if err1 == nil {
logger.Debug("Client enabled due to reset traffic:", clientEmail) logger.Debug("Client enabled on", rt.Name(), "due to reset traffic:", clientEmail)
} else { } else {
logger.Debug("Error in enabling client by api:", err1) logger.Debug("Error in enabling client on", rt.Name(), ":", err1)
needRestart = true needRestart = true
} }
s.xrayApi.Close()
break break
} }
} }
@ -2565,6 +2825,29 @@ func (s *InboundService) ResetClientTraffic(id int, clientEmail string) (bool, e
return false, err return false, err
} }
// Stamp last_traffic_reset_time on the parent inbound so the next
// NodeTrafficSyncJob tick honours the grace window and doesn't pull
// the pre-reset absolute back from the node.
now := time.Now().UnixMilli()
_ = db.Model(model.Inbound{}).
Where("id = ?", id).
Update("last_traffic_reset_time", now).Error
// Propagate to the remote node if this inbound is node-managed.
// Best-effort: an offline node shouldn't block a user-driven reset
// — the central DB is already zeroed and the next successful sync
// (within the grace window) will re-pull whatever the node has.
inbound, err := s.GetInbound(id)
if err == nil && inbound != nil && inbound.NodeID != nil {
if rt, rterr := s.runtimeFor(inbound); rterr == nil {
if e := rt.ResetClientTraffic(context.Background(), inbound, clientEmail); e != nil {
logger.Warning("ResetClientTraffic: remote propagation to", rt.Name(), "failed:", e)
}
} else {
logger.Warning("ResetClientTraffic: runtime lookup failed:", rterr)
}
}
return needRestart, nil return needRestart, nil
} }
@ -2572,7 +2855,7 @@ func (s *InboundService) ResetAllClientTraffics(id int) error {
db := database.GetDB() db := database.GetDB()
now := time.Now().Unix() * 1000 now := time.Now().Unix() * 1000
return db.Transaction(func(tx *gorm.DB) error { if err := db.Transaction(func(tx *gorm.DB) error {
whereText := "inbound_id " whereText := "inbound_id "
if id == -1 { if id == -1 {
whereText += " > ?" whereText += " > ?"
@ -2602,18 +2885,77 @@ func (s *InboundService) ResetAllClientTraffics(id int) error {
Update("last_traffic_reset_time", now) Update("last_traffic_reset_time", now)
return result.Error return result.Error
}) }); err != nil {
return err
}
// Propagate to remote nodes after the central DB is settled. Single
// inbound: one rt.ResetInboundClientTraffics call. id == -1 (all
// inbounds across panel): walk every node-managed inbound and call
// the per-inbound endpoint — there's no panel-wide endpoint that
// only resets clients without zeroing inbound counters.
var inbounds []model.Inbound
q := db.Model(model.Inbound{}).Where("node_id IS NOT NULL")
if id != -1 {
q = q.Where("id = ?", id)
}
if err := q.Find(&inbounds).Error; err != nil {
// Failed to discover which inbounds to propagate to — central
// DB is already correct, log and move on.
logger.Warning("ResetAllClientTraffics: discover node inbounds failed:", err)
return nil
}
for i := range inbounds {
ib := &inbounds[i]
rt, rterr := s.runtimeFor(ib)
if rterr != nil {
logger.Warning("ResetAllClientTraffics: runtime lookup for inbound", ib.Id, "failed:", rterr)
continue
}
if e := rt.ResetInboundClientTraffics(context.Background(), ib); e != nil {
logger.Warning("ResetAllClientTraffics: remote propagation to", rt.Name(), "failed:", e)
}
}
return nil
} }
func (s *InboundService) ResetAllTraffics() error { func (s *InboundService) ResetAllTraffics() error {
db := database.GetDB() db := database.GetDB()
now := time.Now().UnixMilli()
result := db.Model(model.Inbound{}). if err := db.Model(model.Inbound{}).
Where("user_id > ?", 0). Where("user_id > ?", 0).
Updates(map[string]any{"up": 0, "down": 0}) Updates(map[string]any{
"up": 0,
"down": 0,
"last_traffic_reset_time": now,
}).Error; err != nil {
return err
}
err := result.Error // Propagate to every node that has at least one inbound on this
return err // panel. We can't blanket-call rt.ResetAllTraffics because that
// would also zero traffic for inbounds the node hosts but the
// central panel doesn't know about — instead reset per inbound.
var inbounds []model.Inbound
if err := db.Model(model.Inbound{}).
Where("node_id IS NOT NULL").
Find(&inbounds).Error; err != nil {
logger.Warning("ResetAllTraffics: discover node inbounds failed:", err)
return nil
}
for i := range inbounds {
ib := &inbounds[i]
rt, rterr := s.runtimeFor(ib)
if rterr != nil {
logger.Warning("ResetAllTraffics: runtime lookup for inbound", ib.Id, "failed:", rterr)
continue
}
if e := rt.ResetInboundClientTraffics(context.Background(), ib); e != nil {
logger.Warning("ResetAllTraffics: remote propagation to", rt.Name(), "failed:", e)
}
}
return nil
} }
func (s *InboundService) ResetInboundTraffic(id int) error { func (s *InboundService) ResetInboundTraffic(id int) error {
@ -3300,6 +3642,24 @@ func (s *InboundService) GetOnlineClients() []string {
return p.GetOnlineClients() return p.GetOnlineClients()
} }
// SetNodeOnlineClients records a remote node's online-clients list on
// the panel-wide xray.Process so GetOnlineClients returns the union of
// local + every node's contribution. Called by NodeTrafficSyncJob.
func (s *InboundService) SetNodeOnlineClients(nodeID int, emails []string) {
if p != nil {
p.SetNodeOnlineClients(nodeID, emails)
}
}
// ClearNodeOnlineClients drops one node's contribution to the online
// set. Used when the per-node sync probe fails so a downed node
// doesn't keep its clients listed as online forever.
func (s *InboundService) ClearNodeOnlineClients(nodeID int) {
if p != nil {
p.ClearNodeOnlineClients(nodeID)
}
}
func (s *InboundService) GetClientsLastOnline() (map[string]int64, error) { func (s *InboundService) GetClientsLastOnline() (map[string]int64, error) {
db := database.GetDB() db := database.GetDB()
var rows []xray.ClientTraffic var rows []xray.ClientTraffic
@ -3434,19 +3794,27 @@ func (s *InboundService) DelInboundClientByEmail(inboundId int, email string) (b
} }
if needApiDel { if needApiDel {
s.xrayApi.Init(p.GetAPIPort()) rt, rterr := s.runtimeFor(oldInbound)
if err1 := s.xrayApi.RemoveUser(oldInbound.Tag, email); err1 == nil { if rterr != nil {
logger.Debug("Client deleted by api:", email) if oldInbound.NodeID != nil {
needRestart = false return false, rterr
} else { }
if strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", email)) { needRestart = true
} else if oldInbound.NodeID == nil {
if err1 := rt.RemoveUser(context.Background(), oldInbound, email); err1 == nil {
logger.Debug("Client deleted on", rt.Name(), ":", email)
needRestart = false
} else if strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", email)) {
logger.Debug("User is already deleted. Nothing to do more...") logger.Debug("User is already deleted. Nothing to do more...")
} else { } else {
logger.Debug("Error in deleting client by api:", err1) logger.Debug("Error in deleting client on", rt.Name(), ":", err1)
needRestart = true needRestart = true
} }
} else {
if err1 := rt.UpdateInbound(context.Background(), oldInbound, oldInbound); err1 != nil {
return false, err1
}
} }
s.xrayApi.Close()
} }
} }

273
web/service/node.go Normal file
View file

@ -0,0 +1,273 @@
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/mhsanaei/3x-ui/v2/database"
"github.com/mhsanaei/3x-ui/v2/database/model"
"github.com/mhsanaei/3x-ui/v2/util/common"
"github.com/mhsanaei/3x-ui/v2/web/runtime"
)
// HeartbeatPatch is the slice of fields a single Probe() result writes
// back to a Node row. We pass it as a struct (not a *model.Node) so the
// heartbeat path can't accidentally clobber configuration columns the
// user just edited.
type HeartbeatPatch struct {
Status string
LastHeartbeat int64
LatencyMs int
XrayVersion string
CpuPct float64
MemPct float64
UptimeSecs uint64
LastError string
}
// NodeService manages remote 3x-ui nodes registered with this panel.
// It owns CRUD for the Node model and the HTTP probe used by both the
// heartbeat job and the on-demand "test connection" UI action.
type NodeService struct{}
// httpClient is shared so repeated probes reuse TCP/TLS connections.
// Timeout is per-request, set on each Do() via context.
var nodeHTTPClient = &http.Client{
Transport: &http.Transport{
MaxIdleConns: 64,
MaxIdleConnsPerHost: 4,
IdleConnTimeout: 60 * time.Second,
},
}
func (s *NodeService) GetAll() ([]*model.Node, error) {
db := database.GetDB()
var nodes []*model.Node
err := db.Model(model.Node{}).Order("id asc").Find(&nodes).Error
return nodes, err
}
func (s *NodeService) GetById(id int) (*model.Node, error) {
db := database.GetDB()
n := &model.Node{}
if err := db.Model(model.Node{}).Where("id = ?", id).First(n).Error; err != nil {
return nil, err
}
return n, nil
}
// normalize fills in defaults and trims accidental whitespace before save.
// Pulled out so Create and Update share the same rules.
func (s *NodeService) normalize(n *model.Node) error {
n.Name = strings.TrimSpace(n.Name)
n.Address = strings.TrimSpace(n.Address)
n.ApiToken = strings.TrimSpace(n.ApiToken)
if n.Name == "" {
return common.NewError("node name is required")
}
if n.Address == "" {
return common.NewError("node address is required")
}
if n.Port <= 0 || n.Port > 65535 {
return common.NewError("node port must be 1-65535")
}
if n.Scheme != "http" && n.Scheme != "https" {
n.Scheme = "https"
}
if n.BasePath == "" {
n.BasePath = "/"
}
if !strings.HasPrefix(n.BasePath, "/") {
n.BasePath = "/" + n.BasePath
}
if !strings.HasSuffix(n.BasePath, "/") {
n.BasePath = n.BasePath + "/"
}
return nil
}
func (s *NodeService) Create(n *model.Node) error {
if err := s.normalize(n); err != nil {
return err
}
db := database.GetDB()
return db.Create(n).Error
}
func (s *NodeService) Update(id int, in *model.Node) error {
if err := s.normalize(in); err != nil {
return err
}
db := database.GetDB()
existing := &model.Node{}
if err := db.Where("id = ?", id).First(existing).Error; err != nil {
return err
}
// Only persist user-controlled columns. Heartbeat fields stay where
// the heartbeat job last wrote them so a no-op edit doesn't blank
// the dashboard out for ten seconds.
updates := map[string]any{
"name": in.Name,
"remark": in.Remark,
"scheme": in.Scheme,
"address": in.Address,
"port": in.Port,
"base_path": in.BasePath,
"api_token": in.ApiToken,
"enable": in.Enable,
}
if err := db.Model(model.Node{}).Where("id = ?", id).Updates(updates).Error; err != nil {
return err
}
// Drop any cached Remote so the next inbound op picks up the fresh
// address/token. Cheap to do unconditionally — the next miss rebuilds.
if mgr := runtime.GetManager(); mgr != nil {
mgr.InvalidateNode(id)
}
return nil
}
func (s *NodeService) Delete(id int) error {
db := database.GetDB()
if err := db.Where("id = ?", id).Delete(model.Node{}).Error; err != nil {
return err
}
if mgr := runtime.GetManager(); mgr != nil {
mgr.InvalidateNode(id)
}
return nil
}
func (s *NodeService) SetEnable(id int, enable bool) error {
db := database.GetDB()
return db.Model(model.Node{}).Where("id = ?", id).Update("enable", enable).Error
}
// UpdateHeartbeat persists the slice of fields written by a probe. We
// don't touch updated_at via gorm autoUpdateTime here — that field is
// reserved for user-driven config edits.
func (s *NodeService) UpdateHeartbeat(id int, p HeartbeatPatch) error {
db := database.GetDB()
updates := map[string]any{
"status": p.Status,
"last_heartbeat": p.LastHeartbeat,
"latency_ms": p.LatencyMs,
"xray_version": p.XrayVersion,
"cpu_pct": p.CpuPct,
"mem_pct": p.MemPct,
"uptime_secs": p.UptimeSecs,
"last_error": p.LastError,
}
return db.Model(model.Node{}).Where("id = ?", id).Updates(updates).Error
}
// Probe issues a single GET to the node's /panel/api/server/status and
// returns a HeartbeatPatch. On error the patch is zero-valued except
// for LastError; the caller is responsible for setting Status="offline".
//
// The remote endpoint requires authentication: we send the per-node
// ApiToken as a Bearer token, which the remote APIController.checkAPIAuth
// validates. Calls without a token would just get a 404, which masks
// the existence of the API entirely.
func (s *NodeService) Probe(ctx context.Context, n *model.Node) (HeartbeatPatch, error) {
patch := HeartbeatPatch{LastHeartbeat: time.Now().Unix()}
url := fmt.Sprintf("%s://%s:%d%spanel/api/server/status",
n.Scheme, n.Address, n.Port, n.BasePath)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
patch.LastError = err.Error()
return patch, err
}
if n.ApiToken != "" {
req.Header.Set("Authorization", "Bearer "+n.ApiToken)
}
req.Header.Set("Accept", "application/json")
start := time.Now()
resp, err := nodeHTTPClient.Do(req)
if err != nil {
patch.LastError = err.Error()
return patch, err
}
defer resp.Body.Close()
patch.LatencyMs = int(time.Since(start) / time.Millisecond)
if resp.StatusCode != http.StatusOK {
patch.LastError = fmt.Sprintf("HTTP %d from remote panel", resp.StatusCode)
return patch, errors.New(patch.LastError)
}
// The remote wraps Status in entity.Msg. We decode into a typed
// envelope rather than map[string]any so a schema change on the
// remote shows up as a Go error instead of a silent zero-fill.
var envelope struct {
Success bool `json:"success"`
Msg string `json:"msg"`
Obj *struct {
Cpu uint64 `json:"-"`
// Status fields we care about. Decode CPU/Mem nested
// structs minimally — anything else gets discarded.
CpuPct float64 `json:"cpu"`
Mem struct {
Current uint64 `json:"current"`
Total uint64 `json:"total"`
} `json:"mem"`
Xray struct {
Version string `json:"version"`
} `json:"xray"`
Uptime uint64 `json:"uptime"`
} `json:"obj"`
}
if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil {
patch.LastError = "decode response: " + err.Error()
return patch, err
}
if !envelope.Success || envelope.Obj == nil {
patch.LastError = "remote returned success=false: " + envelope.Msg
return patch, errors.New(patch.LastError)
}
o := envelope.Obj
patch.CpuPct = o.CpuPct
if o.Mem.Total > 0 {
patch.MemPct = float64(o.Mem.Current) * 100.0 / float64(o.Mem.Total)
}
patch.XrayVersion = o.Xray.Version
patch.UptimeSecs = o.Uptime
return patch, nil
}
// EnvelopeForUI is the shape a frontend test-connection action expects.
// Pulling it out keeps the controller dumb.
type ProbeResultUI struct {
Status string `json:"status"`
LatencyMs int `json:"latencyMs"`
XrayVersion string `json:"xrayVersion"`
CpuPct float64 `json:"cpuPct"`
MemPct float64 `json:"memPct"`
UptimeSecs uint64 `json:"uptimeSecs"`
Error string `json:"error"`
}
func (p HeartbeatPatch) ToUI(ok bool) ProbeResultUI {
r := ProbeResultUI{
LatencyMs: p.LatencyMs,
XrayVersion: p.XrayVersion,
CpuPct: p.CpuPct,
MemPct: p.MemPct,
UptimeSecs: p.UptimeSecs,
Error: p.LastError,
}
if ok {
r.Status = "online"
} else {
r.Status = "offline"
}
return r
}

View file

@ -1,6 +1,7 @@
package service package service
import ( import (
"crypto/subtle"
_ "embed" _ "embed"
"encoding/json" "encoding/json"
"errors" "errors"
@ -32,6 +33,7 @@ var defaultValueMap = map[string]string{
"webCertFile": "", "webCertFile": "",
"webKeyFile": "", "webKeyFile": "",
"secret": random.Seq(32), "secret": random.Seq(32),
"apiToken": "",
"webBasePath": "/", "webBasePath": "/",
"sessionMaxAge": "360", "sessionMaxAge": "360",
"pageSize": "25", "pageSize": "25",
@ -430,6 +432,48 @@ func (s *SettingService) GetSecret() ([]byte, error) {
return []byte(secret), err return []byte(secret), err
} }
// GetApiToken returns the panel's API token, lazily generating one on
// first read so existing installs upgrade transparently. The token is
// stored plaintext to match how the existing tg/ldap secrets are kept.
func (s *SettingService) GetApiToken() (string, error) {
tok, err := s.getString("apiToken")
if err != nil {
return "", err
}
if tok == "" {
tok = random.Seq(48)
if saveErr := s.saveSetting("apiToken", tok); saveErr != nil {
logger.Warning("save apiToken failed:", saveErr)
return "", saveErr
}
}
return tok, nil
}
// RegenerateApiToken rotates the API token, invalidating any central
// panel that has the old value cached.
func (s *SettingService) RegenerateApiToken() (string, error) {
tok := random.Seq(48)
if err := s.saveSetting("apiToken", tok); err != nil {
return "", err
}
return tok, nil
}
// MatchApiToken returns true when the supplied bearer token matches the
// stored API token. Uses constant-time compare so a remote attacker
// can't time-attack the token byte-by-byte.
func (s *SettingService) MatchApiToken(presented string) bool {
if presented == "" {
return false
}
stored, err := s.getString("apiToken")
if err != nil || stored == "" {
return false
}
return subtle.ConstantTimeCompare([]byte(stored), []byte(presented)) == 1
}
func (s *SettingService) SetBasePath(basePath string) error { func (s *SettingService) SetBasePath(basePath string) error {
if !strings.HasPrefix(basePath, "/") { if !strings.HasPrefix(basePath, "/") {
basePath = "/" + basePath basePath = "/" + basePath

View file

@ -267,6 +267,18 @@ func (s *XrayService) SetToNeedRestart() {
isNeedXrayRestart.Store(true) isNeedXrayRestart.Store(true)
} }
// GetXrayAPIPort returns the port the local xray process is listening on
// for its gRPC HandlerService, or 0 when xray isn't currently running.
// Exposed for the runtime package's LocalRuntime adapter — runtime can't
// reach into the package-level `p` directly without a service-package
// import cycle.
func (s *XrayService) GetXrayAPIPort() int {
if p == nil || !p.IsRunning() {
return 0
}
return p.GetAPIPort()
}
// IsNeedRestartAndSetFalse checks if restart is needed and resets the flag to false. // IsNeedRestartAndSetFalse checks if restart is needed and resets the flag to false.
func (s *XrayService) IsNeedRestartAndSetFalse() bool { func (s *XrayService) IsNeedRestartAndSetFalse() bool {
return isNeedXrayRestart.CompareAndSwap(true, false) return isNeedXrayRestart.CompareAndSwap(true, false)

View file

@ -15,6 +15,14 @@ import (
const ( const (
loginUserKey = "LOGIN_USER" loginUserKey = "LOGIN_USER"
// apiAuthUserKey is the gin-context key under which checkAPIAuth
// stashes a fallback user for Bearer-token-authenticated callers.
// Bearer requests don't carry a session cookie, so handlers that
// scope writes by user.Id (e.g. InboundController.addInbound) would
// otherwise nil-deref. Keeping the override in the gin context
// (not the cookie session) means the fallback never leaks into a
// browser request.
apiAuthUserKey = "api_auth_user"
) )
func init() { func init() {
@ -33,9 +41,25 @@ func SetLoginUser(c *gin.Context, user *model.User) error {
return s.Save() return s.Save()
} }
// SetAPIAuthUser stashes a fallback user on the gin context for the
// lifetime of a single bearer-authed request. checkAPIAuth calls this
// after a successful token match so downstream handlers that read
// GetLoginUser don't see nil.
func SetAPIAuthUser(c *gin.Context, user *model.User) {
if user == nil {
return
}
c.Set(apiAuthUserKey, user)
}
// GetLoginUser retrieves the authenticated user from the session. // GetLoginUser retrieves the authenticated user from the session.
// Returns nil if no user is logged in or if the session data is invalid. // Returns nil if no user is logged in or if the session data is invalid.
func GetLoginUser(c *gin.Context) *model.User { func GetLoginUser(c *gin.Context) *model.User {
if v, ok := c.Get(apiAuthUserKey); ok {
if u, ok2 := v.(*model.User); ok2 {
return u
}
}
s := sessions.Default(c) s := sessions.Default(c)
obj := s.Get(loginUserKey) obj := s.Get(loginUserKey)
if obj == nil { if obj == nil {

View file

@ -94,6 +94,7 @@
"ultraDark": "Ultra Dark", "ultraDark": "Ultra Dark",
"dashboard": "Overview", "dashboard": "Overview",
"inbounds": "Inbounds", "inbounds": "Inbounds",
"nodes": "Nodes",
"settings": "Panel Settings", "settings": "Panel Settings",
"xray": "Xray Configs", "xray": "Xray Configs",
"logout": "Log Out", "logout": "Log Out",
@ -231,6 +232,9 @@
"operate": "Menu", "operate": "Menu",
"enable": "Enabled", "enable": "Enabled",
"remark": "Remark", "remark": "Remark",
"node": "Node",
"deployTo": "Deploy to",
"localPanel": "Local panel",
"protocol": "Protocol", "protocol": "Protocol",
"port": "Port", "port": "Port",
"portMap": "Port Mapping", "portMap": "Port Mapping",
@ -380,6 +384,62 @@
"renew": "Auto Renew", "renew": "Auto Renew",
"renewDesc": "Auto-renewal after expiration. (0 = disable)(unit: day)" "renewDesc": "Auto-renewal after expiration. (0 = disable)(unit: day)"
}, },
"nodes": {
"title": "Nodes",
"addNode": "Add Node",
"editNode": "Edit Node",
"totalNodes": "Total Nodes",
"onlineNodes": "Online",
"offlineNodes": "Offline",
"avgLatency": "Avg Latency",
"name": "Name",
"namePlaceholder": "e.g. de-frankfurt-1",
"addressPlaceholder": "panel.example.com or 1.2.3.4",
"remark": "Remark",
"scheme": "Scheme",
"address": "Address",
"port": "Port",
"basePath": "Base Path",
"apiToken": "API Token",
"apiTokenPlaceholder": "Token from the remote panel's Settings page",
"apiTokenHint": "The remote panel exposes its API token under Settings → API Token.",
"regenerate": "Regenerate Token",
"regenerateConfirm": "Regenerating invalidates the current token. Any central panel using it will lose access until updated. Continue?",
"enable": "Enabled",
"status": "Status",
"cpu": "CPU",
"mem": "Memory",
"uptime": "Uptime",
"latency": "Latency",
"lastHeartbeat": "Last Heartbeat",
"xrayVersion": "Xray Version",
"actions": "Actions",
"refresh": "Refresh",
"probe": "Probe Now",
"testConnection": "Test Connection",
"connectionOk": "Connection OK ({ms} ms)",
"connectionFailed": "Connection failed",
"never": "never",
"justNow": "just now",
"deleteConfirmTitle": "Delete node \"{name}\"?",
"deleteConfirmContent": "This stops monitoring the node. The remote panel itself is unaffected.",
"statusValues": {
"online": "Online",
"offline": "Offline",
"unknown": "Unknown"
},
"toasts": {
"list": "Failed to load nodes",
"obtain": "Failed to load node",
"add": "Add node",
"update": "Update node",
"delete": "Delete node",
"deleted": "Node deleted",
"test": "Test connection",
"fillRequired": "Name, address, port and API token are required",
"probeFailed": "Probe failed"
}
},
"settings": { "settings": {
"title": "Panel Settings", "title": "Panel Settings",
"save": "Save", "save": "Save",

View file

@ -94,6 +94,7 @@
"ultraDark": "فوق تیره", "ultraDark": "فوق تیره",
"dashboard": "نمای کلی", "dashboard": "نمای کلی",
"inbounds": "ورودی‌ها", "inbounds": "ورودی‌ها",
"nodes": "نودها",
"settings": "تنظیمات پنل", "settings": "تنظیمات پنل",
"xray": "پیکربندی ایکس‌ری", "xray": "پیکربندی ایکس‌ری",
"logout": "خروج", "logout": "خروج",
@ -222,6 +223,9 @@
"customGeoEmpty": "هنوز منبع geo سفارشی‌ای ثبت نشده — برای ایجاد روی «افزودن» کلیک کنید" "customGeoEmpty": "هنوز منبع geo سفارشی‌ای ثبت نشده — برای ایجاد روی «افزودن» کلیک کنید"
}, },
"inbounds": { "inbounds": {
"node": "نود",
"deployTo": "استقرار روی",
"localPanel": "پنل لوکال",
"allTimeTraffic": "کل ترافیک", "allTimeTraffic": "کل ترافیک",
"allTimeTrafficUsage": "کل استفاده در تمام مدت", "allTimeTrafficUsage": "کل استفاده در تمام مدت",
"title": "کاربران", "title": "کاربران",
@ -380,6 +384,62 @@
"renew": "تمدید خودکار", "renew": "تمدید خودکار",
"renewDesc": "تمدید خودکار پس‌از ‌انقضا. (0 = غیرفعال)(واحد: روز)" "renewDesc": "تمدید خودکار پس‌از ‌انقضا. (0 = غیرفعال)(واحد: روز)"
}, },
"nodes": {
"title": "نودها",
"addNode": "افزودن نود",
"editNode": "ویرایش نود",
"totalNodes": "کل نودها",
"onlineNodes": "آنلاین",
"offlineNodes": "آفلاین",
"avgLatency": "میانگین تاخیر",
"name": "نام",
"namePlaceholder": "مثلاً de-frankfurt-1",
"addressPlaceholder": "panel.example.com یا 1.2.3.4",
"remark": "توضیحات",
"scheme": "پروتکل",
"address": "آدرس",
"port": "پورت",
"basePath": "مسیر پایه",
"apiToken": "توکن API",
"apiTokenPlaceholder": "توکن از صفحه تنظیمات پنل ریموت",
"apiTokenHint": "پنل ریموت توکن API خودش را در بخش تنظیمات → توکن API نمایش می‌دهد.",
"regenerate": "تولید مجدد توکن",
"regenerateConfirm": "تولید مجدد، توکن فعلی را باطل می‌کند. هر پنل مرکزی‌ای که از این توکن استفاده می‌کند تا زمان به‌روزرسانی، دسترسی‌اش قطع می‌شود. ادامه می‌دهید؟",
"enable": "فعال",
"status": "وضعیت",
"cpu": "پردازنده",
"mem": "حافظه",
"uptime": "زمان کارکرد",
"latency": "تاخیر",
"lastHeartbeat": "آخرین ضربان",
"xrayVersion": "نسخه Xray",
"actions": "عملیات",
"refresh": "به‌روزرسانی",
"probe": "بررسی فوری",
"testConnection": "تست اتصال",
"connectionOk": "اتصال موفق ({ms} میلی‌ثانیه)",
"connectionFailed": "اتصال ناموفق",
"never": "هرگز",
"justNow": "هم‌اکنون",
"deleteConfirmTitle": "نود «{name}» حذف شود؟",
"deleteConfirmContent": "نظارت روی این نود متوقف می‌شود. خود پنل ریموت تغییری نمی‌کند.",
"statusValues": {
"online": "آنلاین",
"offline": "آفلاین",
"unknown": "نامشخص"
},
"toasts": {
"list": "بارگذاری نودها ناموفق",
"obtain": "بارگذاری نود ناموفق",
"add": "افزودن نود",
"update": "به‌روزرسانی نود",
"delete": "حذف نود",
"deleted": "نود حذف شد",
"test": "تست اتصال",
"fillRequired": "نام، آدرس، پورت و توکن API الزامی است",
"probeFailed": "بررسی ناموفق"
}
},
"settings": { "settings": {
"title": "تنظیمات پنل", "title": "تنظیمات پنل",
"save": "ذخیره", "save": "ذخیره",

View file

@ -23,6 +23,7 @@ import (
"github.com/mhsanaei/3x-ui/v2/web/locale" "github.com/mhsanaei/3x-ui/v2/web/locale"
"github.com/mhsanaei/3x-ui/v2/web/middleware" "github.com/mhsanaei/3x-ui/v2/web/middleware"
"github.com/mhsanaei/3x-ui/v2/web/network" "github.com/mhsanaei/3x-ui/v2/web/network"
"github.com/mhsanaei/3x-ui/v2/web/runtime"
"github.com/mhsanaei/3x-ui/v2/web/service" "github.com/mhsanaei/3x-ui/v2/web/service"
"github.com/mhsanaei/3x-ui/v2/web/websocket" "github.com/mhsanaei/3x-ui/v2/web/websocket"
@ -280,6 +281,13 @@ func (s *Server) startTask() {
// check client ips from log file every 10 sec // check client ips from log file every 10 sec
s.cron.AddJob("@every 10s", job.NewCheckClientIpJob()) s.cron.AddJob("@every 10s", job.NewCheckClientIpJob())
// Probe every enabled remote node every 10 sec
s.cron.AddJob("@every 10s", job.NewNodeHeartbeatJob())
// Pull traffic + online-clients from every online node every 10 sec
// and merge absolute counters into the central DB.
s.cron.AddJob("@every 10s", job.NewNodeTrafficSyncJob())
// check client ips from log file every day // check client ips from log file every day
s.cron.AddJob("@daily", job.NewClearLogsJob()) s.cron.AddJob("@daily", job.NewClearLogsJob())
@ -352,6 +360,15 @@ func (s *Server) Start() (err error) {
s.cron = cron.New(cron.WithLocation(loc), cron.WithSeconds()) s.cron = cron.New(cron.WithLocation(loc), cron.WithSeconds())
s.cron.Start() s.cron.Start()
// Wire the inbound-runtime manager once so InboundService can route
// add/update/delete to either the local xray or a remote node panel.
// The closures bridge into XrayService (which owns the running xray
// process state) without forcing the runtime package to import service.
runtime.SetManager(runtime.NewManager(runtime.LocalDeps{
APIPort: func() int { return s.xrayService.GetXrayAPIPort() },
SetNeedRestart: func() { s.xrayService.SetToNeedRestart() },
}))
s.customGeoService = service.NewCustomGeoService() s.customGeoService = service.NewCustomGeoService()
engine, err := s.initRouter() engine, err := s.initRouter()

View file

@ -10,6 +10,7 @@ import (
"os/exec" "os/exec"
"runtime" "runtime"
"strings" "strings"
"sync"
"syscall" "syscall"
"time" "time"
@ -126,6 +127,13 @@ type process struct {
apiPort int apiPort int
onlineClients []string onlineClients []string
// nodeOnlineClients holds the online-emails list reported by each
// remote node, keyed by node id. NodeTrafficSyncJob populates entries
// per cron tick and clears them when a node's probe fails. The mutex
// guards both this map and onlineClients above so GetOnlineClients
// can build the union without a torn read.
nodeOnlineClients map[int][]string
onlineMu sync.RWMutex
config *Config config *Config
configPath string // if set, use this path instead of GetConfigPath() and remove on Stop configPath string // if set, use this path instead of GetConfigPath() and remove on Stop
@ -190,14 +198,69 @@ func (p *Process) GetConfig() *Config {
return p.config return p.config
} }
// GetOnlineClients returns the list of online clients for the Xray process. // GetOnlineClients returns the union of locally-online clients and
// node-online clients from every registered remote panel. Dedupes by
// email so a client connected to both a local and a node-managed inbound
// surfaces once. Cheap allocation — typical online sets are small and
// the union is recomputed on demand.
func (p *Process) GetOnlineClients() []string { func (p *Process) GetOnlineClients() []string {
return p.onlineClients p.onlineMu.RLock()
defer p.onlineMu.RUnlock()
if len(p.nodeOnlineClients) == 0 {
// Hot path for single-panel deployments: avoid the map+dedupe
// work entirely and return the local slice as-is.
return p.onlineClients
}
seen := make(map[string]struct{}, len(p.onlineClients))
out := make([]string, 0, len(p.onlineClients))
for _, email := range p.onlineClients {
if _, dup := seen[email]; dup {
continue
}
seen[email] = struct{}{}
out = append(out, email)
}
for _, list := range p.nodeOnlineClients {
for _, email := range list {
if _, dup := seen[email]; dup {
continue
}
seen[email] = struct{}{}
out = append(out, email)
}
}
return out
} }
// SetOnlineClients sets the list of online clients for the Xray process. // SetOnlineClients sets the locally-online list. Called by the local
// XrayTrafficJob after each xray gRPC stats poll.
func (p *Process) SetOnlineClients(users []string) { func (p *Process) SetOnlineClients(users []string) {
p.onlineMu.Lock()
p.onlineClients = users p.onlineClients = users
p.onlineMu.Unlock()
}
// SetNodeOnlineClients records the online-emails set for one remote
// node. Replaces any previous entry for that node — NodeTrafficSyncJob
// always sends the full list per tick.
func (p *Process) SetNodeOnlineClients(nodeID int, emails []string) {
p.onlineMu.Lock()
defer p.onlineMu.Unlock()
if p.nodeOnlineClients == nil {
p.nodeOnlineClients = map[int][]string{}
}
p.nodeOnlineClients[nodeID] = emails
}
// ClearNodeOnlineClients drops a node's contribution to the online set.
// Called when a probe fails so a downed node doesn't keep its clients
// listed as "online" until the next successful probe.
func (p *Process) ClearNodeOnlineClients(nodeID int) {
p.onlineMu.Lock()
defer p.onlineMu.Unlock()
delete(p.nodeOnlineClients, nodeID)
} }
// GetUptime returns the uptime of the Xray process in seconds. // GetUptime returns the uptime of the Xray process in seconds.