mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 21:24:10 +00:00
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:
parent
281e2d3d57
commit
36114a2fcc
43 changed files with 3545 additions and 184 deletions
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
13
frontend/nodes.html
Normal 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>
|
||||||
|
|
@ -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') },
|
||||||
|
|
|
||||||
42
frontend/src/composables/useNodeList.js
Normal file
42
frontend/src/composables/useNodeList.js
Normal 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 };
|
||||||
|
}
|
||||||
17
frontend/src/entries/nodes.js
Normal file
17
frontend/src/entries/nodes.js
Normal 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');
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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 '';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 = [];
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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 id→node 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"
|
||||||
|
|
|
||||||
|
|
@ -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 = [];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
226
frontend/src/pages/nodes/NodeFormModal.vue
Normal file
226
frontend/src/pages/nodes/NodeFormModal.vue
Normal 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>
|
||||||
211
frontend/src/pages/nodes/NodeList.vue
Normal file
211
frontend/src/pages/nodes/NodeList.vue
Normal 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>
|
||||||
240
frontend/src/pages/nodes/NodesPage.vue
Normal file
240
frontend/src/pages/nodes/NodesPage.vue
Normal 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>
|
||||||
140
frontend/src/pages/nodes/useNodes.js
Normal file
140
frontend/src/pages/nodes/useNodes.js
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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": "",
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -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": "",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
184
web/controller/node.go
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
92
web/job/node_heartbeat_job.go
Normal file
92
web/job/node_heartbeat_job.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
137
web/job/node_traffic_sync_job.go
Normal file
137
web/job/node_traffic_sync_job.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
137
web/runtime/local.go
Normal 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
145
web/runtime/manager.go
Normal 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
407
web/runtime/remote.go
Normal 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
66
web/runtime/runtime.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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(¢ral).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tagToCentral := make(map[string]*model.Inbound, len(central))
|
||||||
|
for i := range central {
|
||||||
|
tagToCentral[central[i].Tag] = ¢ral[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
273
web/service/node.go
Normal 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
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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": "ذخیره",
|
||||||
|
|
|
||||||
17
web/web.go
17
web/web.go
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue