From 29de9761c8b3afdef533d57087960af57f577a6a Mon Sep 17 00:00:00 2001 From: Rogue Date: Fri, 23 Oct 2026 07:53:11 +0000 Subject: [PATCH] V2.0.1: listen override fix, sing-box core fetch, probe settings page + fast report, spx removal, core mem env - 0: sing-box gen overrides fragment listen/port from the inbound row (empty listen -> "::"), Update/Add recompute the stored fragment; non-loopback E2E against the real core binary. - 1: agent fetches the sing-box core from the Gitea release when missing, self-checks it, and only then latches the switch. - 2: node list shows the sing-box core version (heartbeat field, persisted on the node row), xray version as fallback. - 3: public-page fallback returns 404 to anonymous visitors when the page is disabled. - 4: settings restart hint bar removed, dead i18n keys cleaned. - 5: two PUT calls missing JSON headers fixed (audit: only those two), batch probe-task creation endpoint + UI. - 6: cf-probe retirement card and coverage pipeline removed. - 7: generated vless links no longer carry spx (panel export + sub); parser still accepts spx; golden fixtures updated. - probe batch: dedicated probe settings tab (public-page settings, tasks, per-node meta), retention_days editable, per-node traffic calibration + stat-interface pinning, runtime metrics split into a lightweight fast report (default 3s, 1-60s adjustable). - 8: sing-box core processes start with GOMEMLIMIT=32MiB, GOGC=50. - 9: dead /getTwoFactorEnable docs entries removed (frontend call and contract entry already gone at base; verified by grep). --- .../docs/en/reference/api/authentication.mdx | 2 +- .../docs/fa/reference/api/authentication.mdx | 2 +- .../docs/ru/reference/api/authentication.mdx | 2 +- .../docs/zh/reference/api/authentication.mdx | 2 +- frontend/src/layouts/AppSidebar.tsx | 6 + frontend/src/lib/xray/inbound-link.ts | 22 +- frontend/src/lib/xray/spider-x.ts | 10 - frontend/src/pages/nodes/NodeFormModal.tsx | 14 +- frontend/src/pages/nodes/NodeList.tsx | 17 +- frontend/src/pages/nodes/NodesPage.tsx | 12 +- frontend/src/pages/nodes/ProbeMetaSection.tsx | 40 +++ .../src/pages/settings/ProbeSettingsTab.tsx | 264 ++++++++++++++++++ .../ProbeTasksPanel.tsx} | 174 +++++++++--- frontend/src/pages/settings/PublicPageTab.tsx | 254 +---------------- frontend/src/pages/settings/SettingsPage.tsx | 6 +- frontend/src/schemas/node.ts | 3 + .../__snapshots__/inbound-link.test.ts.snap | 4 +- frontend/src/test/spider-x.test.ts | 27 -- internal/agent/agent.go | 114 +++++++- internal/agent/fast_report_test.go | 151 ++++++++++ internal/agent/probe.go | 37 ++- internal/agent/singbox.go | 23 ++ internal/agent/singbox_env_test.go | 93 ++++++ internal/agent/singbox_fetch.go | 162 +++++++++++ internal/agent/singbox_fetch_test.go | 130 +++++++++ internal/agent/singbox_gate_test.go | 59 ++++ internal/database/model/agent_node.go | 21 +- .../vless-tcp-finalmask/expected-link.txt | 2 +- .../expected-link.txt | 2 +- .../vless-tcp-reality/expected-link.txt | 2 +- internal/sub/service.go | 36 +-- internal/sub/service_sharelink_test.go | 81 ++---- internal/web/controller/agent.go | 69 ++++- .../web/controller/agent_fast_report_test.go | 257 +++++++++++++++++ internal/web/controller/api.go | 22 +- .../controller/probe_fallback_gate_test.go | 53 ++++ internal/web/controller/probe_meta.go | 81 ++++-- internal/web/controller/probe_notify.go | 18 +- internal/web/controller/probe_public.go | 10 + internal/web/controller/probe_tasks.go | 109 ++++++++ internal/web/service/inbound.go | 12 + .../web/service/node_agent_telemetry_test.go | 5 +- internal/web/service/node_contract.go | 64 +++-- internal/web/service/probe/meta.go | 25 +- internal/web/service/probe/meta_v201_test.go | 108 +++++++ internal/web/service/probe/store.go | 53 +++- internal/web/service/probe/traffic.go | 47 +++- internal/web/service/probe_coverage.go | 57 ---- internal/web/service/probe_public.go | 20 ++ internal/web/service/probe_theme_store.go | 43 ++- internal/web/service/singbox.go | 35 +++ internal/web/service/singbox_fragment.go | 133 +++++++++ .../web/service/singbox_listen_e2e_test.go | 205 ++++++++++++++ internal/web/service/singbox_listen_test.go | 206 ++++++++++++++ internal/web/translation/ar-EG.json | 39 ++- internal/web/translation/en-US.json | 39 ++- internal/web/translation/es-ES.json | 39 ++- internal/web/translation/fa-IR.json | 39 ++- internal/web/translation/id-ID.json | 39 ++- internal/web/translation/ja-JP.json | 39 ++- internal/web/translation/pt-BR.json | 39 ++- internal/web/translation/ru-RU.json | 39 ++- internal/web/translation/tr-TR.json | 39 ++- internal/web/translation/uk-UA.json | 39 ++- internal/web/translation/vi-VN.json | 39 ++- internal/web/translation/zh-CN.json | 39 ++- internal/web/translation/zh-TW.json | 39 ++- 67 files changed, 3081 insertions(+), 832 deletions(-) delete mode 100644 frontend/src/lib/xray/spider-x.ts create mode 100644 frontend/src/pages/settings/ProbeSettingsTab.tsx rename frontend/src/pages/{nodes/ProbeTasksModal.tsx => settings/ProbeTasksPanel.tsx} (66%) delete mode 100644 frontend/src/test/spider-x.test.ts create mode 100644 internal/agent/fast_report_test.go create mode 100644 internal/agent/singbox_env_test.go create mode 100644 internal/agent/singbox_fetch.go create mode 100644 internal/agent/singbox_fetch_test.go create mode 100644 internal/agent/singbox_gate_test.go create mode 100644 internal/web/controller/agent_fast_report_test.go create mode 100644 internal/web/controller/probe_fallback_gate_test.go create mode 100644 internal/web/service/probe/meta_v201_test.go delete mode 100644 internal/web/service/probe_coverage.go create mode 100644 internal/web/service/singbox_fragment.go create mode 100644 internal/web/service/singbox_listen_e2e_test.go create mode 100644 internal/web/service/singbox_listen_test.go diff --git a/docs/content/docs/en/reference/api/authentication.mdx b/docs/content/docs/en/reference/api/authentication.mdx index c5adc43..ccd5f30 100644 --- a/docs/content/docs/en/reference/api/authentication.mdx +++ b/docs/content/docs/en/reference/api/authentication.mdx @@ -54,7 +54,7 @@ export default function Layout(props) { return ( <> {props.children} - + ); } \ No newline at end of file diff --git a/docs/content/docs/fa/reference/api/authentication.mdx b/docs/content/docs/fa/reference/api/authentication.mdx index 55b7b17..0e35a61 100644 --- a/docs/content/docs/fa/reference/api/authentication.mdx +++ b/docs/content/docs/fa/reference/api/authentication.mdx @@ -69,7 +69,7 @@ export default function Layout(props) { return ( <> {props.children} - + ); } \ No newline at end of file diff --git a/docs/content/docs/ru/reference/api/authentication.mdx b/docs/content/docs/ru/reference/api/authentication.mdx index 3e7d23e..5aa74e5 100644 --- a/docs/content/docs/ru/reference/api/authentication.mdx +++ b/docs/content/docs/ru/reference/api/authentication.mdx @@ -69,7 +69,7 @@ export default function Layout(props) { return ( <> {props.children} - + ); } \ No newline at end of file diff --git a/docs/content/docs/zh/reference/api/authentication.mdx b/docs/content/docs/zh/reference/api/authentication.mdx index 579f9a3..4540f4c 100644 --- a/docs/content/docs/zh/reference/api/authentication.mdx +++ b/docs/content/docs/zh/reference/api/authentication.mdx @@ -66,7 +66,7 @@ export default function Layout(props) { return ( <> {props.children} - + ); } \ No newline at end of file diff --git a/frontend/src/layouts/AppSidebar.tsx b/frontend/src/layouts/AppSidebar.tsx index 3911608..402d041 100644 --- a/frontend/src/layouts/AppSidebar.tsx +++ b/frontend/src/layouts/AppSidebar.tsx @@ -22,6 +22,7 @@ import { MoonOutlined, PushpinFilled, PushpinOutlined, + RadarChartOutlined, ReadOutlined, SafetyOutlined, SettingOutlined, @@ -249,6 +250,11 @@ export default function AppSidebar() { icon: , label: t('pages.settings.probeThemes.title'), }, + { + key: '/settings#probe', + icon: , + label: t('pages.settings.probe.title'), + }, ]; return children; }, [t]); diff --git a/frontend/src/lib/xray/inbound-link.ts b/frontend/src/lib/xray/inbound-link.ts index 16677c1..a3c65da 100644 --- a/frontend/src/lib/xray/inbound-link.ts +++ b/frontend/src/lib/xray/inbound-link.ts @@ -14,7 +14,6 @@ import type { XHttpStreamSettings } from '@/schemas/protocols/stream/xhttp'; import { getHeaderValue } from './headers'; import { canEnableTlsFlow } from './protocol-capabilities'; -import { deriveSpiderX } from './spider-x'; // Share-link generators. Each per-protocol fn takes a typed inbound plus // client overrides and returns a URL (or '' when the protocol doesn't @@ -353,7 +352,6 @@ export interface GenVlessLinkInput { forceTls?: ForceTls; remark?: string; clientId: string; - clientKey?: string; flow?: VlessClient['flow']; externalProxy?: ExternalProxyEntry | null; } @@ -374,7 +372,7 @@ export function applyVlessRoute(id: string, route: string | undefined): string { // VLESS share link: vless://@:?#. The // query carries network type, encryption, network-specific knobs, and // security-specific knobs (TLS fingerprint/alpn/sni or Reality -// pbk/sid/spx). Returns '' if the inbound isn't vless. +// pbk/sid). Returns '' if the inbound isn't vless. export function genVlessLink(input: GenVlessLinkInput): string { const { inbound, @@ -383,7 +381,6 @@ export function genVlessLink(input: GenVlessLinkInput): string { forceTls = 'same', remark = '', clientId, - clientKey = '', flow = '', externalProxy = null, } = input; @@ -462,8 +459,6 @@ export function genVlessLink(input: GenVlessLinkInput): string { if (sni && sni.length > 0) params.set('sni', sni); if (reality.shortIds.length > 0) params.set('sid', reality.shortIds[0]); - const spx = deriveSpiderX(reality.settings.spiderX, clientKey); - if (spx.length > 0) params.set('spx', spx); if (reality.settings.mldsa65Verify.length > 0) params.set('pqv', reality.settings.mldsa65Verify); } @@ -560,7 +555,6 @@ function writeTlsParams( function writeRealityParams( stream: NonNullable, params: URLSearchParams, - clientKey: string, ): void { if (stream.security !== 'reality') return; const reality = stream.realitySettings; @@ -573,8 +567,6 @@ function writeRealityParams( if (sni && sni.length > 0) params.set('sni', sni); if (reality.shortIds.length > 0) params.set('sid', reality.shortIds[0]); - const spx = deriveSpiderX(reality.settings.spiderX, clientKey); - if (spx.length > 0) params.set('spx', spx); if (reality.settings.mldsa65Verify.length > 0) params.set('pqv', reality.settings.mldsa65Verify); } @@ -585,7 +577,6 @@ export interface GenTrojanLinkInput { forceTls?: ForceTls; remark?: string; clientPassword: string; - clientKey?: string; externalProxy?: ExternalProxyEntry | null; } @@ -600,7 +591,6 @@ export function genTrojanLink(input: GenTrojanLinkInput): string { forceTls = 'same', remark = '', clientPassword, - clientKey = '', externalProxy = null, } = input; @@ -621,7 +611,7 @@ export function genTrojanLink(input: GenTrojanLinkInput): string { applyExternalProxyTLSParams(externalProxy, params, security); } else if (security === 'reality') { params.set('security', 'reality'); - writeRealityParams(stream, params, clientKey); + writeRealityParams(stream, params); } else { params.set('security', 'none'); } @@ -1321,12 +1311,6 @@ type ClientShape = { subId?: string; }; -// Mirror of the Go subKey: the stable per-client identity spx derivation -// keys on — subscription id first, unique email as the fallback. -function clientSubKey(client: ClientShape): string { - return client.subId || client.email || ''; -} - export function getInboundClients(inbound: Inbound): ClientShape[] | null { switch (inbound.protocol) { case 'vmess': @@ -1392,7 +1376,6 @@ export function genLink(input: GenLinkInput): string { forceTls, remark, clientId: client.id ?? '', - clientKey: clientSubKey(client), flow: client.flow, externalProxy, }); @@ -1416,7 +1399,6 @@ export function genLink(input: GenLinkInput): string { forceTls, remark, clientPassword: client.password ?? '', - clientKey: clientSubKey(client), externalProxy, }); case 'hysteria': diff --git a/frontend/src/lib/xray/spider-x.ts b/frontend/src/lib/xray/spider-x.ts deleted file mode 100644 index b70003a..0000000 --- a/frontend/src/lib/xray/spider-x.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { sha256 } from '@noble/hashes/sha2.js'; -import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js'; - -// Mirrors deriveSpiderX in internal/sub/service.go byte-for-byte so panel -// links and subscription links agree; returns '' when there is no seed and -// no client key (the caller then omits spx, as the legacy builder did). -export function deriveSpiderX(seed: string, clientKey: string): string { - if (!seed && !clientKey) return ''; - return `/${bytesToHex(sha256(utf8ToBytes(`${seed}|${clientKey}`))).slice(0, 15)}`; -} diff --git a/frontend/src/pages/nodes/NodeFormModal.tsx b/frontend/src/pages/nodes/NodeFormModal.tsx index 973919c..ec38ca0 100644 --- a/frontend/src/pages/nodes/NodeFormModal.tsx +++ b/frontend/src/pages/nodes/NodeFormModal.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router'; import { Alert, Button, @@ -21,7 +22,7 @@ import { NodeFormSchema, type NodeFormValues, type ProbeResult } from '@/schemas import { FormField, rhfZodValidate } from '@/components/form/rhf'; import { useOutboundTagGroups } from '@/api/queries/useOutboundTags'; import { currentPanelMasterUrl } from './agentMasterUrl'; -import ProbeMetaSection from './ProbeMetaSection'; + import './NodeFormModal.css'; type Mode = 'add' | 'edit'; @@ -70,6 +71,7 @@ export default function NodeFormModal({ onOpenChange, }: NodeFormModalProps) { const { t } = useTranslation(); + const navigate = useNavigate(); const methods = useForm({ defaultValues: defaultValues() }); const [messageApi, messageContextHolder] = message.useMessage(); @@ -474,7 +476,15 @@ export default function NodeFormModal({ )} {mode === 'edit' && isAgent && node?.id ? ( - +
+
+ {t('pages.nodes.probe.title')} +
+

{t('pages.nodes.probe.movedHint')}

+ +
) : null} {!isAgent && ( diff --git a/frontend/src/pages/nodes/NodeList.tsx b/frontend/src/pages/nodes/NodeList.tsx index ffe595b..c53982b 100644 --- a/frontend/src/pages/nodes/NodeList.tsx +++ b/frontend/src/pages/nodes/NodeList.tsx @@ -493,10 +493,15 @@ export default function NodeList({ render: (_value, record) => formatPct(record.memPct), }, { - title: t('pages.nodes.xrayVersion'), + // Core version (V2.0.1 ②): agent rows run sing-box and show + // its version; traditional rows keep showing the xray one. + title: t('pages.nodes.coreVersion'), dataIndex: 'xrayVersion', align: 'center', - render: (_value, record) => record.xrayVersion || '-', + render: (_value, record) => + record.nodeType === 'agent' + ? record.singboxVersion || record.xrayVersion || '-' + : record.xrayVersion || '-', }, { title: t('pages.nodes.panelVersion') || 'Panel version', @@ -840,8 +845,12 @@ export default function NodeList({ {formatPct(statsNode.memPct)}
- {t('pages.nodes.xrayVersion')} - {statsNode.xrayVersion || '-'} + {t('pages.nodes.coreVersion')} + + {statsNode.nodeType === 'agent' + ? statsNode.singboxVersion || statsNode.xrayVersion || '-' + : statsNode.xrayVersion || '-'} +
diff --git a/frontend/src/pages/nodes/NodesPage.tsx b/frontend/src/pages/nodes/NodesPage.tsx index 87f06a6..3b42d03 100644 --- a/frontend/src/pages/nodes/NodesPage.tsx +++ b/frontend/src/pages/nodes/NodesPage.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router'; import { useQuery } from '@tanstack/react-query'; import { Alert, @@ -33,7 +34,6 @@ import { useNodeMutations } from '@/api/queries/useNodeMutations'; import AppSidebar from '@/layouts/AppSidebar'; import NodeList from './NodeList'; import NodeFormModal from './NodeFormModal'; -import ProbeTasksModal from './ProbeTasksModal'; import { setMessageInstance } from '@/utils/messageBus'; import { HttpUtil } from '@/utils'; import type { PanelUpdateInfo } from '../index/PanelUpdateModal'; @@ -70,6 +70,7 @@ function UpdateChannelChoice({ onChange }: { onChange: (dev: boolean) => void }) export default function NodesPage() { const { t } = useTranslation(); + const navigate = useNavigate(); const { isDark, isUltra, antdThemeConfig } = useTheme(); const { isMobile } = useMediaQuery(); const [modal, modalContextHolder] = Modal.useModal(); @@ -105,7 +106,6 @@ export default function NodesPage() { const [formNode, setFormNode] = useState(null); const [selectedIds, setSelectedIds] = useState([]); const [mtlsOpen, setMtlsOpen] = useState(false); - const [probeTasksOpen, setProbeTasksOpen] = useState(false); const [trustCa, setTrustCa] = useState(''); const [copyingCa, setCopyingCa] = useState(false); const [savingTrustCa, setSavingTrustCa] = useState(false); @@ -369,7 +369,7 @@ export default function NodesPage() { onSelectionChange={setSelectedIds} onAdd={onAdd} onMtls={() => setMtlsOpen(true)} - onProbeTasks={() => setProbeTasksOpen(true)} + onProbeTasks={() => navigate('/settings#probe')} onEdit={onEdit} onDelete={onDelete} onProbe={onProbe} @@ -384,12 +384,6 @@ export default function NodesPage() { - setProbeTasksOpen(false)} - nodes={nodes} - /> - + + + + set('trafficCalUpGb', v ?? 0)} + style={{ width: '100%' }} + /> + + + + + set('trafficCalDownGb', v ?? 0)} + style={{ width: '100%' }} + /> + + + + + set('statIface', e.target.value)} + /> + + + +

{t('pages.nodes.probe.calHint')}

diff --git a/frontend/src/pages/settings/ProbeSettingsTab.tsx b/frontend/src/pages/settings/ProbeSettingsTab.tsx new file mode 100644 index 0000000..22223ab --- /dev/null +++ b/frontend/src/pages/settings/ProbeSettingsTab.tsx @@ -0,0 +1,264 @@ +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, Col, Input, InputNumber, Row, Select, Space, Switch, Typography, message } from 'antd'; +import { HttpUtil } from '@/utils'; +import { useNodesQuery } from '@/api/queries/useNodesQuery'; +import ProbeMetaSection from '../nodes/ProbeMetaSection'; +import ProbeTasksPanel from './ProbeTasksPanel'; + +// V2.0.1 probe batch: the dedicated probe settings page. Everything +// probe-related that used to be scattered — the public-page block +// (public-page tab), the latency task manager (nodes-page modal) +// and the per-node probe metadata (node edit dialog) — is gathered +// here and lives nowhere else. Like the surfaces it replaces, each +// block loads and saves through its own endpoints and takes effect +// immediately (no panel save, no restart). +interface ProbeSettingsWire { + publicPage: boolean; + siteName: string; + site: string; + githubProxy: string; + retentionDays: number; + fastIntervalSec: number; + favicon: string; + touchIcon: string; +} + +const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } }; +const MAX_ICON_BYTES = 20 * 1024; + +function readAsDataURL(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result)); + reader.onerror = () => reject(reader.error); + reader.readAsDataURL(blob); + }); +} + +// fileToIconDataURL fits a chosen image into the hub's 20 KiB site +// icon budget (moved unchanged from the public-page tab): small +// files pass through; a large raster is scaled down until it fits; +// a large SVG cannot be scaled and is refused here. +async function fileToIconDataURL(file: File): Promise { + if (file.size <= MAX_ICON_BYTES) return readAsDataURL(file); + if (file.type === 'image/svg+xml' || file.type === 'image/x-icon' || file.type === 'image/gif') { + throw new Error('tooLarge'); + } + const bitmap = await createImageBitmap(file); + try { + for (const size of [256, 180, 128, 96, 64]) { + const scale = Math.min(1, size / Math.max(bitmap.width, bitmap.height)); + const canvas = document.createElement('canvas'); + canvas.width = Math.max(1, Math.round(bitmap.width * scale)); + canvas.height = Math.max(1, Math.round(bitmap.height * scale)); + const ctx = canvas.getContext('2d'); + if (!ctx) break; + ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height); + const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png')); + if (blob && blob.size <= MAX_ICON_BYTES) return readAsDataURL(blob); + } + } finally { + bitmap.close(); + } + throw new Error('tooLarge'); +} + +export default function ProbeSettingsTab() { + const { t } = useTranslation(); + const [messageApi, messageContextHolder] = message.useMessage(); + const [settings, setSettings] = useState(null); + const [saving, setSaving] = useState(false); + const [metaNodeId, setMetaNodeId] = useState(undefined); + const faviconRef = useRef(null); + const touchRef = useRef(null); + const { nodes } = useNodesQuery(); + const agentNodes = nodes.filter((n) => n.nodeType === 'agent'); + + useEffect(() => { + let cancelled = false; + HttpUtil.get('/panel/api/probe/settings', { silent: true }).then((msg) => { + if (!cancelled && msg?.success && msg.obj) setSettings(msg.obj); + }); + return () => { + cancelled = true; + }; + }, []); + + async function saveSettings() { + if (!settings) return; + setSaving(true); + try { + const msg = await HttpUtil.put('/panel/api/probe/settings', settings, { + silent: true, + ...JSON_HEADERS, + }); + if (msg?.success) messageApi.success(t('pages.settings.probeThemes.saved')); + else messageApi.error(msg?.msg || t('pages.settings.probeThemes.saveFailed')); + } finally { + setSaving(false); + } + } + + async function pickIcon(which: 'favicon' | 'touchIcon', file: File | undefined) { + if (!file || !settings) return; + try { + const url = await fileToIconDataURL(file); + setSettings({ ...settings, [which]: url }); + } catch { + messageApi.error(t('pages.settings.probeThemes.iconTooLarge')); + } + } + + const set = (key: K, value: ProbeSettingsWire[K]) => + setSettings((s) => (s ? { ...s, [key]: value } : s)); + + return ( +
+ {messageContextHolder} + {t('pages.settings.probeThemes.settingsTitle')} + {settings ? ( + <> + + +
{t('pages.settings.probeThemes.publicPage')}
+ set('publicPage', v)} + /> + + {t('pages.settings.probeThemes.publicPageHint')} + + + +
{t('pages.settings.probeThemes.siteName')}
+ set('siteName', e.target.value)} + /> + + +
{t('pages.settings.probeThemes.site')}
+ set('site', e.target.value)} + /> + + +
{t('pages.settings.probeThemes.githubProxy')}
+ set('githubProxy', e.target.value)} + /> + + {t('pages.settings.probeThemes.githubProxyHint')} + + + +
{t('pages.settings.probe.retentionDays')}
+ set('retentionDays', v ?? 7)} + style={{ width: '100%' }} + /> + + {t('pages.settings.probe.retentionHint')} + + + +
{t('pages.settings.probe.fastInterval')}
+ set('fastIntervalSec', v ?? 3)} + addonAfter="s" + style={{ width: '100%' }} + /> + + {t('pages.settings.probe.fastIntervalHint')} + + + +
{t('pages.settings.probeThemes.favicon')}
+ + {settings.favicon ? ( + + ) : null} + + {settings.favicon ? ( + + ) : null} + + pickIcon('favicon', e.target.files?.[0])} + /> + + +
{t('pages.settings.probeThemes.touchIcon')}
+ + {settings.touchIcon ? ( + + ) : null} + + {settings.touchIcon ? ( + + ) : null} + + pickIcon('touchIcon', e.target.files?.[0])} + /> +
+ + {t('pages.settings.probeThemes.iconHint')} + +
+ +
+
+ +
+ + ) : null} + + + {t('pages.nodes.probeTasks.title')} + + + + + {t('pages.settings.probe.metaTitle')} + + setBatchName(e.target.value)} + style={{ width: 180 }} + /> + setBatchRegion(e.target.value)} + style={{ width: 130 }} + /> + setBatchInterval(v ?? 60)} + addonAfter="s" + style={{ width: 120 }} + /> + + {t('pages.nodes.probeTasks.autoJoin')}{' '} + + + setSettings({ ...settings, siteName: e.target.value })} - /> - - -
{t('pages.settings.probeThemes.site')}
- setSettings({ ...settings, site: e.target.value })} - /> - - -
{t('pages.settings.probeThemes.githubProxy')}
- setSettings({ ...settings, githubProxy: e.target.value })} - /> - - {t('pages.settings.probeThemes.githubProxyHint')} - - - -
{t('pages.settings.probeThemes.favicon')}
- - {favicon ? : null} - - {favicon ? ( - - ) : null} - - pickIcon('favicon', e.target.files?.[0])} - /> - - -
{t('pages.settings.probeThemes.touchIcon')}
- - {touchIcon ? : null} - - {touchIcon ? ( - - ) : null} - - pickIcon('touchIcon', e.target.files?.[0])} - /> -
- - {t('pages.settings.probeThemes.iconHint')} - -
- - -
- -
- - - {t('pages.settings.probeThemes.title')} - + {t('pages.settings.probeThemes.title')}
); } diff --git a/frontend/src/pages/settings/SettingsPage.tsx b/frontend/src/pages/settings/SettingsPage.tsx index 2e9a13e..baa1f7d 100644 --- a/frontend/src/pages/settings/SettingsPage.tsx +++ b/frontend/src/pages/settings/SettingsPage.tsx @@ -28,13 +28,14 @@ import SecurityTab from './SecurityTab'; import SubscriptionGeneralTab from './SubscriptionGeneralTab'; import TelegramTab from './TelegramTab'; import PublicPageTab from './PublicPageTab'; +import ProbeSettingsTab from './ProbeSettingsTab'; import './SettingsPage.css'; interface ApiMsg { success?: boolean; } -const tabSlugs = ['general', 'security', 'telegram', 'subscription', 'public-page']; +const tabSlugs = ['general', 'security', 'telegram', 'subscription', 'public-page', 'probe']; function isIp(h: string): boolean { if (typeof h !== 'string') return false; @@ -182,6 +183,8 @@ export default function SettingsPage() { return ; case 'public-page': return ; + case 'probe': + return ; default: return ; } @@ -247,7 +250,6 @@ export default function SettingsPage() { -
diff --git a/frontend/src/schemas/node.ts b/frontend/src/schemas/node.ts index 96dd52a..eafba49 100644 --- a/frontend/src/schemas/node.ts +++ b/frontend/src/schemas/node.ts @@ -17,6 +17,9 @@ export const NodeRecordSchema = z cpuPct: z.number().optional(), memPct: z.number().optional(), xrayVersion: z.string().optional(), + // Agent nodes run sing-box since V2: their core version is the + // sing-box one the heartbeat reports (V2.0.1 ②). + singboxVersion: z.string().optional(), panelVersion: z.string().optional(), uptimeSecs: z.number().optional(), inboundCount: z.number().optional(), diff --git a/frontend/src/test/__snapshots__/inbound-link.test.ts.snap b/frontend/src/test/__snapshots__/inbound-link.test.ts.snap index e41bf18..a63341c 100644 --- a/frontend/src/test/__snapshots__/inbound-link.test.ts.snap +++ b/frontend/src/test/__snapshots__/inbound-link.test.ts.snap @@ -8,7 +8,7 @@ exports[`genInboundLinks orchestrator > shadowsocks-tcp-2022: byte-stable 1`] = exports[`genInboundLinks orchestrator > trojan-ws-tls: byte-stable 1`] = `"trojan://trojan-test-pw-XYZ@override.test:443?type=ws&path=%2Ftrojan&host=trojan.example.test&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=trojan.example.test#parity-test"`; -exports[`genInboundLinks orchestrator > vless-tcp-reality: byte-stable 1`] = `"vless://22222222-3333-4444-9555-666666666666@override.test:443?type=tcp&encryption=none&security=reality&pbk=Tx5yj1bRcOPHkdvT2pIAQ2zh0gQ8m4OPdnzqXJxxV3o&fp=chrome&sni=yahoo.com&sid=a3f1&spx=%2Fdafd018f50a389b&flow=xtls-rprx-vision#parity-test"`; +exports[`genInboundLinks orchestrator > vless-tcp-reality: byte-stable 1`] = `"vless://22222222-3333-4444-9555-666666666666@override.test:443?type=tcp&encryption=none&security=reality&pbk=Tx5yj1bRcOPHkdvT2pIAQ2zh0gQ8m4OPdnzqXJxxV3o&fp=chrome&sni=yahoo.com&sid=a3f1&flow=xtls-rprx-vision#parity-test"`; exports[`genInboundLinks orchestrator > vless-ws-tls: byte-stable 1`] = `"vless://8c14d6f7-2e3b-4a91-9d24-3f7a6b8c1e02@override.test:443?type=ws&encryption=none&path=%2Fws&host=cdn.example.test&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=cdn.example.test#parity-test"`; @@ -36,7 +36,7 @@ exports[`genShadowsocksLink > shadowsocks-tcp-2022: byte-stable 1`] = `"ss://202 exports[`genTrojanLink > trojan-ws-tls: byte-stable 1`] = `"trojan://trojan-test-pw-XYZ@example.test:443?type=ws&path=%2Ftrojan&host=trojan.example.test&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=trojan.example.test#parity-test"`; -exports[`genVlessLink > vless-tcp-reality: byte-stable 1`] = `"vless://22222222-3333-4444-9555-666666666666@example.test:443?type=tcp&encryption=none&security=reality&pbk=Tx5yj1bRcOPHkdvT2pIAQ2zh0gQ8m4OPdnzqXJxxV3o&fp=chrome&sni=yahoo.com&sid=a3f1&spx=%2Fd08ed99bd9afc60&flow=xtls-rprx-vision#parity-test"`; +exports[`genVlessLink > vless-tcp-reality: byte-stable 1`] = `"vless://22222222-3333-4444-9555-666666666666@example.test:443?type=tcp&encryption=none&security=reality&pbk=Tx5yj1bRcOPHkdvT2pIAQ2zh0gQ8m4OPdnzqXJxxV3o&fp=chrome&sni=yahoo.com&sid=a3f1&flow=xtls-rprx-vision#parity-test"`; exports[`genVlessLink > vless-ws-tls: byte-stable 1`] = `"vless://8c14d6f7-2e3b-4a91-9d24-3f7a6b8c1e02@example.test:443?type=ws&encryption=none&path=%2Fws&host=cdn.example.test&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=cdn.example.test#parity-test"`; diff --git a/frontend/src/test/spider-x.test.ts b/frontend/src/test/spider-x.test.ts deleted file mode 100644 index bf405ef..0000000 --- a/frontend/src/test/spider-x.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { deriveSpiderX } from '@/lib/xray/spider-x'; - -// Cross-language vectors shared with TestDeriveSpiderXMatchesFrontendVectors -// in internal/sub/service_sharelink_test.go: subscription links come from Go, -// panel links from this module, and the two must agree byte-for-byte. -describe('deriveSpiderX', () => { - it('matches the Go deriveSpiderX vectors', () => { - expect(deriveSpiderX('/seed', 'subAlice')).toBe('/c252fbc3ecd3e3c'); - expect(deriveSpiderX('/', '')).toBe('/d08ed99bd9afc60'); - }); - - it('is stable per client, distinct across clients, and rotates with the seed', () => { - expect(deriveSpiderX('/seed', 'subAlice')).toBe(deriveSpiderX('/seed', 'subAlice')); - expect(deriveSpiderX('/seed', 'subAlice')).not.toBe(deriveSpiderX('/seed', 'subBob')); - expect(deriveSpiderX('/seedA', 'subAlice')).not.toBe(deriveSpiderX('/seedB', 'subAlice')); - }); - - it('returns empty when there is nothing to derive from', () => { - expect(deriveSpiderX('', '')).toBe(''); - }); - - it('emits a /-prefixed 15-hex-char path', () => { - expect(deriveSpiderX('/some-seed', 'client@example.com')).toMatch(/^\/[0-9a-f]{15}$/); - }); -}); diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 59da62d..e97e88f 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -113,6 +113,17 @@ type Agent struct { sbVerFor string sbVerMod time.Time + // Core fetch backoff (V2.0.1 ①, see singbox_fetch.go). + sbFetchMu sync.Mutex + sbFetchFailedAt time.Time + + // Fast-report state (V2.0.1 probe batch): the cadence and + // counted-interface pin delivered on the config poll. A zero + // fastInterval means the master never offered one (pre-V2.0.1 + // master) and metrics keep riding the 60 s heartbeat. + fastIntervalSec atomic.Int64 + fastUnsupported atomic.Bool + // Latency probe runner (item 7 B4, see ping.go): task set pushed // on the config poll, samples returned on the heartbeat. probes *probeRunner @@ -278,10 +289,18 @@ func (a *Agent) Run() error { trafficTicker := time.NewTicker(defaultTrafficInterval) defer trafficTicker.Stop() + // Fast-report timer (V2.0.1): armed by the config poll once the + // master offers a cadence; disarmed while the master is older + // (no cadence offered) or answered 404 (endpoint unknown). + fastTimer := time.NewTimer(time.Hour) + fastTimer.Stop() + defer fastTimer.Stop() + // Initial config fetch if err := a.fetchAndApplyConfig(); err != nil { logger.Warningf("Initial config fetch failed: %v", err) } + a.armFastTimer(fastTimer) for { select { @@ -298,14 +317,69 @@ func (a *Agent) Run() error { if err := a.fetchAndApplyConfig(); err != nil { logger.Warningf("Config fetch failed: %v", err) } + a.armFastTimer(fastTimer) case <-trafficTicker.C: if err := a.collectAndReportTraffic(); err != nil { logger.Warningf("Traffic report failed: %v", err) } + case <-fastTimer.C: + a.sendFastReport() + a.armFastTimer(fastTimer) } } } +// armFastTimer (re)schedules the fast report from the current +// master-provided cadence. Not offered (0) or unsupported (404 +// seen) leaves the timer stopped: metrics then ride the heartbeat, +// the pre-V2.0.1 arrangement. +func (a *Agent) armFastTimer(t *time.Timer) { + sec := a.fastIntervalSec.Load() + if sec <= 0 || a.fastUnsupported.Load() { + t.Stop() + return + } + t.Reset(time.Duration(sec) * time.Second) +} + +// fastReportActive reports whether runtime metrics currently ride +// the fast channel (and so must stay off the heartbeat). +func (a *Agent) fastReportActive() bool { + return a.fastIntervalSec.Load() > 0 && !a.fastUnsupported.Load() +} + +// sendFastReport posts one metrics-only report (V2.0.1). Failures +// are quiet by design — the stream is high-cadence and the next +// tick retries; a 404 marks the endpoint unsupported (pre-V2.0.1 +// master) and metrics stay on the heartbeat from then on. +func (a *Agent) sendFastReport() { + m := collectMetrics() + if m == nil { + return + } + body, err := json.Marshal(map[string]any{"probeMetrics": m}) + if err != nil { + return + } + url := a.config.MasterURL + "/panel/api/agent/probe-report" + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return + } + req.Header.Set("Authorization", "Bearer "+a.config.Token) + req.Header.Set("Content-Type", "application/json") + resp, err := a.httpClient.Do(req) + if err != nil { + return + } + defer resp.Body.Close() + _, _ = io.Copy(io.Discard, resp.Body) + if resp.StatusCode == http.StatusNotFound { + a.fastUnsupported.Store(true) + logger.Warning("master does not serve the probe fast-report endpoint; metrics stay on the heartbeat") + } +} + // Stop stops the agent func (a *Agent) Stop() { if a.probes != nil { @@ -337,8 +411,16 @@ func (a *Agent) sendHeartbeat() error { // costs nothing and heals a master that missed the register-time // copy; metrics are sampled fresh each heartbeat. Old masters // ignore both fields. - "probeFacts": collectFacts(), - "probeMetrics": collectMetrics(), + "probeFacts": collectFacts(), + } + // V2.0.1: runtime metrics ride the fast report whenever the + // master offered a cadence and the endpoint answered; the + // heartbeat then carries only the heavy, slow-moving content + // (facts, telemetry, probe results, update status). Without a + // fast channel the metrics stay here — a pre-V2.0.1 master must + // keep receiving them exactly as before. + if !a.fastReportActive() { + payload["probeMetrics"] = collectMetrics() } if rep := a.currentUpdateReport(); rep != nil { payload["updateStatus"] = rep @@ -417,11 +499,31 @@ func (a *Agent) fetchAndApplyConfig() error { // current set, while a present-but-empty list means "run no // probes" and clears it. ProbeTasks *[]ProbeTask `json:"probe_tasks"` + // V2.0.1 probe batch: fast-report cadence + counted-interface + // pin. Pointers for the same reason — an old master omits + // both and the agent keeps its current behaviour (no fast + // report, automatic interface selection). + ProbeFastInterval *int64 `json:"probe_fast_interval"` + ProbeStatIface *string `json:"probe_stat_iface"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return err } + if result.ProbeFastInterval != nil { + sec := *result.ProbeFastInterval + if sec < 1 { + sec = 1 + } + if sec > 60 { + sec = 60 + } + a.fastIntervalSec.Store(sec) + } + if result.ProbeStatIface != nil { + SetStatIfacePin(*result.ProbeStatIface) + } + // One-click update intents ride this poll (item 20); handle them even // when the config itself is unchanged. a.maybeHandleUpdate(result.AgentUpdate) @@ -438,7 +540,13 @@ func (a *Agent) fetchAndApplyConfig() error { // core), and never touch the xray path again — a later poll that // somehow lacks the sing-box keys leaves both cores as they are // rather than resurrecting xray. - if result.SingboxConfigHash != "" { + // + // V2.0.1 ① gate: the switch needs the sing-box core on disk. A + // node whose core is missing gets one fetch attempt (throttled); + // while it stays missing the switch is NOT latched and the xray + // path below keeps the node current — nothing is retired for a + // runtime that cannot start yet. + if result.SingboxConfigHash != "" && a.singboxCoreReady() { a.sbSwitched = true if result.SingboxConfigHash != a.lastSingboxHash { if err := a.switchApplySingbox(result.SingboxConfig); err != nil { diff --git a/internal/agent/fast_report_test.go b/internal/agent/fast_report_test.go new file mode 100644 index 0000000..aa9d00d --- /dev/null +++ b/internal/agent/fast_report_test.go @@ -0,0 +1,151 @@ +package agent + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" +) + +// V2.0.1 probe batch, agent side: the config poll's cadence + pin +// are adopted; while the fast channel is active the heartbeat stops +// carrying metrics (they ride the fast report instead); a master +// without the endpoint (404) sends the agent back to heartbeat +// metrics permanently. + +type fastMaster struct { + mu sync.Mutex + heartbeat []byte + fastReport []byte + fastStatus int + configBody string + reportStatus int +} + +func (m *fastMaster) handler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + m.mu.Lock() + switch { + case strings.HasSuffix(r.URL.Path, "/config"): + m.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(m.configBody)) + case strings.HasSuffix(r.URL.Path, "/probe-report"): + m.fastReport = body + st := m.fastStatus + m.mu.Unlock() + if st != 0 && st != http.StatusOK { + w.WriteHeader(st) + return + } + _, _ = w.Write([]byte(`{"success":true}`)) + case strings.HasSuffix(r.URL.Path, "/heartbeat"): + m.heartbeat = body + m.mu.Unlock() + _, _ = w.Write([]byte(`{"success":true}`)) + default: + m.mu.Unlock() + http.NotFound(w, r) + } + }) +} + +func TestFastReportSplitAndFallback(t *testing.T) { + SetStatIfacePin("") + t.Cleanup(func() { SetStatIfacePin("") }) + + master := &fastMaster{configBody: `{"probe_fast_interval":5,"probe_stat_iface":"eth7"}`} + srv := httptest.NewServer(master.handler()) + defer srv.Close() + + a := &Agent{ + config: &Config{MasterURL: srv.URL, Token: "tok", NodeName: "n"}, + httpClient: http.DefaultClient, + probes: newProbeRunner(), + } + + if err := a.fetchAndApplyConfig(); err != nil { + t.Fatalf("fetchAndApplyConfig: %v", err) + } + if got := a.fastIntervalSec.Load(); got != 5 { + t.Fatalf("fast interval = %d, want 5 from the poll", got) + } + if got := statIfacePinValue(); got != "eth7" { + t.Fatalf("stat iface pin = %q, want eth7", got) + } + if !a.fastReportActive() { + t.Fatal("fast report should be active after the poll offered a cadence") + } + + // The fast report carries the metrics… + a.sendFastReport() + master.mu.Lock() + fast := master.fastReport + master.mu.Unlock() + var fastBody map[string]json.RawMessage + if err := json.Unmarshal(fast, &fastBody); err != nil || fastBody["probeMetrics"] == nil { + t.Fatalf("fast report = %s (err %v), want a probeMetrics block", fast, err) + } + + // …and the heartbeat no longer does. + if err := a.sendHeartbeat(); err != nil { + t.Fatalf("sendHeartbeat: %v", err) + } + master.mu.Lock() + hb := master.heartbeat + master.mu.Unlock() + var hbBody map[string]json.RawMessage + if err := json.Unmarshal(hb, &hbBody); err != nil { + t.Fatalf("heartbeat body: %v", err) + } + if hbBody["probeMetrics"] != nil { + t.Error("heartbeat still carries probeMetrics while the fast channel is active") + } + if hbBody["probeFacts"] == nil || hbBody["telemetry"] == nil { + t.Error("heartbeat lost its heavy content (facts/telemetry)") + } + + // A 404 from the fast endpoint (old master) disables the fast + // channel and restores heartbeat metrics. + master.mu.Lock() + master.fastStatus = http.StatusNotFound + master.mu.Unlock() + a.sendFastReport() + if a.fastReportActive() { + t.Fatal("fast report still active after a 404") + } + if err := a.sendHeartbeat(); err != nil { + t.Fatalf("sendHeartbeat after fallback: %v", err) + } + master.mu.Lock() + hb = master.heartbeat + master.mu.Unlock() + hbBody = map[string]json.RawMessage{} + if err := json.Unmarshal(hb, &hbBody); err != nil { + t.Fatalf("heartbeat body after fallback: %v", err) + } + if hbBody["probeMetrics"] == nil { + t.Error("heartbeat did not resume carrying probeMetrics after the 404 fallback") + } +} + +// The counted-interface pin: strict single-interface selection, and +// a pin naming a vanished interface counts nothing (so the epoch +// changes and the master re-baselines instead of booking garbage). +func TestPinnedInterfaces(t *testing.T) { + all := []netCounters{ + {name: "eth0", rx: 100, tx: 200}, + {name: "ens3", rx: 300, tx: 400}, + } + got := pinnedInterfaces(all, "ens3") + if len(got) != 1 || got[0].name != "ens3" || got[0].rx != 300 { + t.Errorf("pinnedInterfaces = %+v, want only ens3", got) + } + if got := pinnedInterfaces(all, "gone0"); len(got) != 0 { + t.Errorf("pin for a vanished interface = %+v, want empty", got) + } +} diff --git a/internal/agent/probe.go b/internal/agent/probe.go index 21482a8..e4179de 100644 --- a/internal/agent/probe.go +++ b/internal/agent/probe.go @@ -24,6 +24,7 @@ import ( "strconv" "strings" "sync" + "sync/atomic" "syscall" "time" @@ -115,6 +116,36 @@ func sampleFacts() *ProbeFacts { } } +// statIfacePin (V2.0.1) names the single interface to count when +// the operator pinned one on the master (its probe_meta.stat_iface, +// delivered on the config poll). "" keeps the automatic counted-set +// selection below. The pin bypasses the three-layer filter on +// purpose: the operator named this exact device, including when it +// is one the filter would skip (a tunnel the node actually bills +// on). A pinned name absent from /proc/net/dev counts nothing — +// the boot epoch changes with the counted set, so the master +// re-baselines instead of booking a phantom delta. +var statIfacePin atomic.Value // string + +// SetStatIfacePin records the master's counted-interface pin. +func SetStatIfacePin(name string) { statIfacePin.Store(name) } + +func statIfacePinValue() string { + if v := statIfacePin.Load(); v != nil { + return v.(string) + } + return "" +} + +func pinnedInterfaces(all []netCounters, pin string) []netCounters { + for _, c := range all { + if c.name == pin { + return []netCounters{c} + } + } + return nil +} + // collectMetrics takes one full sample. Individual sources fail soft. func collectMetrics() *ProbeMetrics { probeColl.mu.Lock() @@ -123,7 +154,11 @@ func collectMetrics() *ProbeMetrics { memTotal, memUsed := memUsedFree(mem) swapTotal, swapUsed := swapUsedFree(mem) diskTotal, diskUsed := diskUsage(realMountPoints(readProcFile("self/mounts"))) - counted := countedInterfaces(parseNetDev(readProcFile("net/dev")), sysNetRoot) + allIfaces := parseNetDev(readProcFile("net/dev")) + counted := countedInterfaces(allIfaces, sysNetRoot) + if pin := statIfacePinValue(); pin != "" { + counted = pinnedInterfaces(allIfaces, pin) + } var rxTotal, txTotal uint64 names := make([]string, 0, len(counted)) for _, c := range counted { diff --git a/internal/agent/singbox.go b/internal/agent/singbox.go index 8fffb81..4bc9052 100644 --- a/internal/agent/singbox.go +++ b/internal/agent/singbox.go @@ -193,6 +193,28 @@ func (p *singboxProcess) epoch() string { return fmt.Sprintf("sing-box:%d:%d", p.cmd.Process.Pid, p.startedAt.UnixNano()) } +// singboxCoreEnv is the environment the sing-box core process runs +// with (V2.0.1 ⑧): a 32 MiB Go memory limit and GOGC=50, so the +// core's heap is collected back instead of only growing — the +// untuned core on HW-SG idles around 30 MB RSS where the tuned +// NexusGate nodes sit under 10 MB. Every core launch goes through +// singboxProcess.start, so the first start and every re-apply / +// restart inherit this. Only the child is tuned: the agent's own +// runtime settings are untouched, and values the agent itself was +// started with are replaced, not duplicated (a duplicate key's +// effective value would depend on the reader). +func singboxCoreEnv() []string { + env := os.Environ() + out := make([]string, 0, len(env)+2) + for _, kv := range env { + if strings.HasPrefix(kv, "GOMEMLIMIT=") || strings.HasPrefix(kv, "GOGC=") { + continue + } + out = append(out, kv) + } + return append(out, "GOMEMLIMIT=32MiB", "GOGC=50") +} + // start launches sing-box with the given config file, appending its // output to the log file the online tracker tails. func (p *singboxProcess) start(binPath, configPath, logPath string) error { @@ -214,6 +236,7 @@ func (p *singboxProcess) start(binPath, configPath, logPath string) error { return fmt.Errorf("open sing-box log: %w", err) } cmd := exec.Command(binPath, "run", "-c", configPath) + cmd.Env = singboxCoreEnv() cmd.Stdout = logFile cmd.Stderr = logFile if err := cmd.Start(); err != nil { diff --git a/internal/agent/singbox_env_test.go b/internal/agent/singbox_env_test.go new file mode 100644 index 0000000..5e8299b --- /dev/null +++ b/internal/agent/singbox_env_test.go @@ -0,0 +1,93 @@ +package agent + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// V2.0.1 ⑧: the sing-box core process is launched with +// GOMEMLIMIT=32MiB and GOGC=50 in its environment — on the first +// start and on every re-apply, since all launches go through +// singboxProcess.start. The agent's own env is not modified, and +// values the agent was itself started with are replaced (never +// duplicated) for the child. + +func countEnv(env []string, want string) int { + n := 0 + for _, kv := range env { + if kv == want { + n++ + } + } + return n +} + +func TestSingboxCoreEnv(t *testing.T) { + t.Setenv("GOMEMLIMIT", "999MiB") + t.Setenv("GOGC", "off") + env := singboxCoreEnv() + if n := countEnv(env, "GOMEMLIMIT=32MiB"); n != 1 { + t.Errorf("GOMEMLIMIT=32MiB appears %d times, want exactly 1 (%v)", n, env) + } + if n := countEnv(env, "GOGC=50"); n != 1 { + t.Errorf("GOGC=50 appears %d times, want exactly 1", n) + } + for _, kv := range env { + if kv == "GOMEMLIMIT=999MiB" || kv == "GOGC=off" { + t.Errorf("agent's own %q leaked into the core env", kv) + } + } + // The agent process env itself is untouched. + if got := os.Getenv("GOMEMLIMIT"); got != "999MiB" { + t.Errorf("agent env GOMEMLIMIT = %q, want the untouched 999MiB", got) + } +} + +// TestSingboxProcessStartCarriesCoreEnv starts a stand-in core +// through the real launch path and reads back the environment the +// child actually received (the script dumps its own env on start). +func TestSingboxProcessStartCarriesCoreEnv(t *testing.T) { + dir := t.TempDir() + dump := filepath.Join(dir, "child-env.txt") + binPath := filepath.Join(dir, "sing-box") + script := "#!/bin/sh\nenv > '" + dump + "'\nexec sleep 3600\n" + if err := os.WriteFile(binPath, []byte(script), 0o755); err != nil { + t.Fatalf("write fake core: %v", err) + } + cfgPath := filepath.Join(dir, "cfg.json") + if err := os.WriteFile(cfgPath, []byte("{}"), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + + t.Setenv("GOMEMLIMIT", "999MiB") + p := &singboxProcess{} + if err := p.start(binPath, cfgPath, filepath.Join(dir, "sing-box.log")); err != nil { + t.Fatalf("start: %v", err) + } + defer p.stop() + + var childEnv []string + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if raw, err := os.ReadFile(dump); err == nil && len(raw) > 0 { + childEnv = strings.Split(strings.TrimSpace(string(raw)), "\n") + break + } + time.Sleep(50 * time.Millisecond) + } + if childEnv == nil { + t.Fatal("fake core never dumped its environment") + } + if n := countEnv(childEnv, "GOMEMLIMIT=32MiB"); n != 1 { + t.Errorf("child env GOMEMLIMIT=32MiB x%d, want 1 (%v)", n, childEnv) + } + if n := countEnv(childEnv, "GOGC=50"); n != 1 { + t.Errorf("child env GOGC=50 x%d, want 1", n) + } + if n := countEnv(childEnv, "GOMEMLIMIT=999MiB"); n != 0 { + t.Errorf("child inherited the agent's GOMEMLIMIT=999MiB (%v)", childEnv) + } +} diff --git a/internal/agent/singbox_fetch.go b/internal/agent/singbox_fetch.go new file mode 100644 index 0000000..23c3b1d --- /dev/null +++ b/internal/agent/singbox_fetch.go @@ -0,0 +1,162 @@ +package agent + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/mhsanaei/3x-ui/v3/internal/config" + "github.com/mhsanaei/3x-ui/v3/internal/logger" +) + +// ITEM V2.0.1 ①: agents upgraded through the panel's one-click +// update swap only their own binary — a node installed before the +// V2 switchover can hold no sing-box core at all, and applying the +// master's sing-box config there would strand it (the switch retires +// xray first). Before any switch is attempted, a missing core is +// fetched from the same Gitea release install-agent.sh installs +// from (the NexusGate repo's dedicated sing-box-v1.13.21 release), +// self-checked, and only then does the switch proceed. Any failure +// leaves the node exactly as it was: xray keeps running, the fetch +// retries on a later poll (throttled), and the failure is visible in +// the agent log and — through the still-empty singboxVersion — on +// the master's node list. + +// singboxCoreVersion is the pinned self-built core release, the +// same pin install-agent.sh carries (SINGBOX_VER). +const singboxCoreVersion = "1.13.21" + +// singboxCoreBaseURL is the release download base. A package +// variable so tests can point it at a local server. +var singboxCoreBaseURL = "https://git.881218.xyz/rogue/NexusGate/releases/download/sing-box-v" + singboxCoreVersion + +// singboxFetchBackoff suppresses immediate re-downloads after a +// failed fetch: a node whose route to the release host is broken +// must not hammer it every config poll. +const singboxFetchBackoff = 10 * time.Minute + +// singboxCoreAssetName is the release asset for this agent's +// architecture (the release publishes linux amd64/arm64 only, the +// same set install-agent.sh supports). +func singboxCoreAssetName() (string, error) { + switch runtime.GOARCH { + case "amd64", "arm64": + return "nexusgate-sing-box-linux-" + runtime.GOARCH, nil + } + return "", fmt.Errorf("no sing-box core asset for architecture %s", runtime.GOARCH) +} + +// singboxCoreReady reports whether the node can run sing-box: the +// core is on disk, or a fetch just installed it. Callers on the +// config path use it as the switch gate. +func (a *Agent) singboxCoreReady() bool { + if singboxBinaryPath() != "" { + return true + } + _ = a.fetchSingboxCoreThrottled() + return singboxBinaryPath() != "" +} + +// fetchSingboxCoreThrottled runs one fetch attempt unless the last +// failure is still inside the backoff window. A failed attempt is +// logged once (here, not per poll) and stays visible on the master +// through the agent's still-empty singboxVersion telemetry. +func (a *Agent) fetchSingboxCoreThrottled() error { + a.sbFetchMu.Lock() + defer a.sbFetchMu.Unlock() + if !a.sbFetchFailedAt.IsZero() && time.Since(a.sbFetchFailedAt) < singboxFetchBackoff { + return fmt.Errorf("sing-box core fetch in backoff after failure at %s", a.sbFetchFailedAt.Format(time.RFC3339)) + } + if err := a.fetchSingboxCore(); err != nil { + a.sbFetchFailedAt = time.Now() + logger.Warningf("sing-box core fetch failed, staying on the current runtime (retry after backoff): %v", err) + return err + } + a.sbFetchFailedAt = time.Time{} + return nil +} + +// fetchSingboxCore downloads the pinned core into the bin folder, +// self-checks it (a runnable `version`, mirroring the install +// script's closing self-check), and only then moves it into place +// and writes the install-time version record the telemetry reads. +func (a *Agent) fetchSingboxCore() error { + asset, err := singboxCoreAssetName() + if err != nil { + return err + } + binDir := config.GetBinFolderPath() + if err := os.MkdirAll(binDir, 0o755); err != nil { + return fmt.Errorf("create bin folder: %w", err) + } + dest := filepath.Join(binDir, singboxBinaryName()) + tmp := dest + ".download" + _ = os.Remove(tmp) + + url := strings.TrimSuffix(singboxCoreBaseURL, "/") + "/" + asset + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + var transport http.RoundTripper + if a.httpClient != nil { + transport = a.httpClient.Transport + } + client := &http.Client{Transport: transport, Timeout: 5 * time.Minute} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("download sing-box core: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("download sing-box core: status %d", resp.StatusCode) + } + f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755) + if err != nil { + return fmt.Errorf("create sing-box core download: %w", err) + } + if _, err := io.Copy(f, resp.Body); err != nil { + _ = f.Close() + _ = os.Remove(tmp) + return fmt.Errorf("download sing-box core: %w", err) + } + if err := f.Close(); err != nil { + _ = os.Remove(tmp) + return fmt.Errorf("download sing-box core: %w", err) + } + + // Self-check before anything is moved into place: the binary + // must actually run on this node (right architecture, not a + // truncated or error-page download). + vctx, vcancel := context.WithTimeout(context.Background(), 10*time.Second) + out, verr := exec.CommandContext(vctx, tmp, "version").CombinedOutput() + vcancel() + if verr != nil { + _ = os.Remove(tmp) + return fmt.Errorf("sing-box core self-check failed: %v (%s)", verr, strings.TrimSpace(string(out))) + } + if len(strings.TrimSpace(string(out))) == 0 { + _ = os.Remove(tmp) + return fmt.Errorf("sing-box core self-check failed: version produced no output") + } + + if err := os.Rename(tmp, dest); err != nil { + _ = os.Remove(tmp) + return fmt.Errorf("install sing-box core: %w", err) + } + // The version record is what telemetry reports for the self-built + // core (its `version` output carries no stamp) — same convention + // as install-agent.sh. + _ = os.WriteFile(filepath.Join(binDir, singboxVersionRecord), []byte(singboxCoreVersion+"\n"), 0o644) + logger.Infof("sing-box core %s fetched from the release and self-checked; ready to switch", singboxCoreVersion) + return nil +} diff --git a/internal/agent/singbox_fetch_test.go b/internal/agent/singbox_fetch_test.go new file mode 100644 index 0000000..09166a3 --- /dev/null +++ b/internal/agent/singbox_fetch_test.go @@ -0,0 +1,130 @@ +package agent + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/config" +) + +// V2.0.1 ①: a node missing its sing-box core fetches the pinned +// release build, self-checks it, and only then reports ready. A +// failed fetch installs nothing and backs off instead of hammering +// the release host on every config poll. + +// fakeFetchedCore mimics the self-built release binary: it runs, +// and its `version` carries no stamp ("unknown") — the shape that +// makes telemetry fall back to the install-time version record. +const fakeFetchedCore = "#!/bin/sh\necho 'sing-box version unknown'\n" + +func fakeCoreServer(t *testing.T, hits *atomic.Int32, status int) *httptest.Server { + t.Helper() + asset, err := singboxCoreAssetName() + if err != nil { + t.Skipf("no core asset for this arch: %v", err) + } + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + if status != http.StatusOK { + w.WriteHeader(status) + return + } + if strings.HasSuffix(r.URL.Path, asset) { + _, _ = w.Write([]byte(fakeFetchedCore)) + return + } + http.NotFound(w, r) + })) +} + +func useCoreBase(t *testing.T, url string) { + t.Helper() + orig := singboxCoreBaseURL + singboxCoreBaseURL = url + t.Cleanup(func() { singboxCoreBaseURL = orig }) +} + +func TestFetchSingboxCoreInstallsAndSelfChecks(t *testing.T) { + binDir := t.TempDir() + t.Setenv("XUI_BIN_FOLDER", binDir) + var hits atomic.Int32 + srv := fakeCoreServer(t, &hits, http.StatusOK) + defer srv.Close() + useCoreBase(t, srv.URL) + + a := &Agent{httpClient: http.DefaultClient} + if !a.singboxCoreReady() { + t.Fatal("core not ready after fetch") + } + dest := filepath.Join(binDir, singboxBinaryName()) + info, err := os.Stat(dest) + if err != nil { + t.Fatalf("core not installed: %v", err) + } + if info.Mode()&0o111 == 0 { + t.Errorf("installed core is not executable: %v", info.Mode()) + } + if got, ok := a.cachedSingboxVersion(); !ok || got != singboxCoreVersion { + t.Errorf("reported core version = %q (ok %v), want %q (from the version record)", got, ok, singboxCoreVersion) + } + if config.GetBinFolderPath() != binDir { + t.Fatalf("bin folder override not in effect") + } + // Second call: binary present, no further download. + if !a.singboxCoreReady() || hits.Load() != 1 { + t.Errorf("ready check re-downloaded: hits = %d", hits.Load()) + } +} + +func TestFetchSingboxCoreFailureKeepsNothingAndBacksOff(t *testing.T) { + binDir := t.TempDir() + t.Setenv("XUI_BIN_FOLDER", binDir) + var hits atomic.Int32 + srv := fakeCoreServer(t, &hits, http.StatusBadGateway) + defer srv.Close() + useCoreBase(t, srv.URL) + + a := &Agent{httpClient: http.DefaultClient} + if a.singboxCoreReady() { + t.Fatal("core reported ready after a failed fetch") + } + if _, err := os.Stat(filepath.Join(binDir, singboxBinaryName())); !os.IsNotExist(err) { + t.Errorf("failed fetch left a core binary behind: %v", err) + } + // Backoff: an immediate retry must not hit the server again. + if err := a.fetchSingboxCoreThrottled(); err == nil { + t.Fatal("throttled retry returned no error") + } + if hits.Load() != 1 { + t.Errorf("backoff violated: server hits = %d, want 1", hits.Load()) + } + // And the switch gate stays closed: ready remains false. + if a.singboxCoreReady() { + t.Error("core became ready during backoff") + } +} + +func TestFetchSingboxCoreRejectsBrokenDownload(t *testing.T) { + binDir := t.TempDir() + t.Setenv("XUI_BIN_FOLDER", binDir) + var hits atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + _, _ = w.Write([]byte("this is not a binary at all")) + })) + defer srv.Close() + useCoreBase(t, srv.URL) + + a := &Agent{httpClient: http.DefaultClient} + if err := a.fetchSingboxCore(); err == nil { + t.Fatal("broken download passed the self-check") + } + if _, err := os.Stat(filepath.Join(binDir, singboxBinaryName())); !os.IsNotExist(err) { + t.Errorf("broken download was installed: %v", err) + } +} diff --git a/internal/agent/singbox_gate_test.go b/internal/agent/singbox_gate_test.go new file mode 100644 index 0000000..61eeef2 --- /dev/null +++ b/internal/agent/singbox_gate_test.go @@ -0,0 +1,59 @@ +package agent + +import ( + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" +) + +// V2.0.1 ① integration: the master serves a sing-box config but the +// node has no core and the fetch fails — the switch must NOT latch +// (xray stays the runtime and the next poll retries), because the +// B5 switch retires xray the moment it engages. +func TestSwitchGateStaysOpenWhenCoreFetchFails(t *testing.T) { + binDir := t.TempDir() + t.Setenv("XUI_BIN_FOLDER", binDir) + + var hits atomic.Int32 + coreSrv := fakeCoreServer(t, &hits, http.StatusBadGateway) + defer coreSrv.Close() + useCoreBase(t, coreSrv.URL) + + master := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/config") { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"config_hash":"x1","singbox_config_hash":"sb1",` + + `"singbox_config":{"inbounds":[]}}`)) + return + } + http.NotFound(w, r) + })) + defer master.Close() + + a := &Agent{ + config: &Config{MasterURL: master.URL, Token: "tok", NodeName: "n"}, + httpClient: http.DefaultClient, + probes: newProbeRunner(), + } + if err := a.fetchAndApplyConfig(); err != nil { + t.Fatalf("fetchAndApplyConfig: %v", err) + } + if a.sbSwitched { + t.Fatal("switch latched although the sing-box core could not be fetched") + } + if hits.Load() != 1 { + t.Errorf("core fetch attempts = %d, want exactly 1", hits.Load()) + } + // A second poll inside the backoff window must not re-download. + if err := a.fetchAndApplyConfig(); err != nil { + t.Fatalf("second fetchAndApplyConfig: %v", err) + } + if hits.Load() != 1 { + t.Errorf("backoff violated on the config path: hits = %d", hits.Load()) + } + if a.sbSwitched { + t.Fatal("switch latched during backoff") + } +} diff --git a/internal/database/model/agent_node.go b/internal/database/model/agent_node.go index d26548a..31a3595 100644 --- a/internal/database/model/agent_node.go +++ b/internal/database/model/agent_node.go @@ -43,14 +43,19 @@ type AgentNode struct { // its 60s heartbeat and the panel overlays them onto the node list row. // All fields are best-effort: an agent that never reported telemetry has // TelemetryAt == 0 and the UI keeps showing "-" for its row. - AgentVersion string `json:"agentVersion"` - XrayVersion string `json:"xrayVersion"` - UptimeSecs uint64 `json:"uptimeSecs"` - LatencyMs int `json:"latencyMs"` - CpuPct float64 `json:"cpuPct"` - MemPct float64 `json:"memPct"` - IPv4 string `json:"ipv4"` - IPv6 string `json:"ipv6"` + AgentVersion string `json:"agentVersion"` + XrayVersion string `json:"xrayVersion"` + // SingboxVersion is the agent's sing-box core version (V2.0.1 ②): + // reported on the same telemetry since ITEM6 B3, persisted only + // from V2.0.1 on — the node list shows it in place of the xray + // version for agents that report one. + SingboxVersion string `json:"singboxVersion"` + UptimeSecs uint64 `json:"uptimeSecs"` + LatencyMs int `json:"latencyMs"` + CpuPct float64 `json:"cpuPct"` + MemPct float64 `json:"memPct"` + IPv4 string `json:"ipv4"` + IPv6 string `json:"ipv6"` // TelemetryAt is the unix-milli time of the last heartbeat that carried // a telemetry object. TelemetryAt int64 `json:"telemetryAt"` diff --git a/internal/singbox/testdata/golden/vless-tcp-finalmask/expected-link.txt b/internal/singbox/testdata/golden/vless-tcp-finalmask/expected-link.txt index 22bae4b..ff2e8e1 100644 --- a/internal/singbox/testdata/golden/vless-tcp-finalmask/expected-link.txt +++ b/internal/singbox/testdata/golden/vless-tcp-finalmask/expected-link.txt @@ -1 +1 @@ -vless://11111111-2222-4333-8444-555555555555@203.0.113.10:21443?encryption=none&flow=xtls-rprx-vision&fm=%7B%22tcp%22%3A%5B%7B%22settings%22%3A%7B%22interval%22%3A%2210-20%22%2C%22length%22%3A%22100-200%22%2C%22packets%22%3A%22tlshello%22%7D%2C%22type%22%3A%22fragment%22%7D%5D%7D&fp=chrome&pbk=B6N8vBQgk8i3VdwbEOhstCY3StFqqFPtC9%2FAsrhtHHw%3D&security=reality&sid=ab12cd34&sni=www.amd.com&spx=%2Fd0cf3c252cd6df6&type=tcp#fx-vless-finalmask-user%40example.com \ No newline at end of file +vless://11111111-2222-4333-8444-555555555555@203.0.113.10:21443?encryption=none&flow=xtls-rprx-vision&fm=%7B%22tcp%22%3A%5B%7B%22settings%22%3A%7B%22interval%22%3A%2210-20%22%2C%22length%22%3A%22100-200%22%2C%22packets%22%3A%22tlshello%22%7D%2C%22type%22%3A%22fragment%22%7D%5D%7D&fp=chrome&pbk=B6N8vBQgk8i3VdwbEOhstCY3StFqqFPtC9%2FAsrhtHHw%3D&security=reality&sid=ab12cd34&sni=www.amd.com&type=tcp#fx-vless-finalmask-user%40example.com \ No newline at end of file diff --git a/internal/singbox/testdata/golden/vless-tcp-reality-fallbacks/expected-link.txt b/internal/singbox/testdata/golden/vless-tcp-reality-fallbacks/expected-link.txt index 6596039..dd3b750 100644 --- a/internal/singbox/testdata/golden/vless-tcp-reality-fallbacks/expected-link.txt +++ b/internal/singbox/testdata/golden/vless-tcp-reality-fallbacks/expected-link.txt @@ -1 +1 @@ -vless://11111111-2222-4333-8444-555555555555@203.0.113.10:20443?encryption=none&flow=xtls-rprx-vision&fp=chrome&pbk=WGmv9FBUlzLLqu1eXfmzCm2jHLDldCutWtShp2jxpns%3D&security=reality&sid=77aa10f0&sni=www.microsoft.com&spx=%2Fd0cf3c252cd6df6&type=tcp#fx-vless-fallback-user%40example.com \ No newline at end of file +vless://11111111-2222-4333-8444-555555555555@203.0.113.10:20443?encryption=none&flow=xtls-rprx-vision&fp=chrome&pbk=WGmv9FBUlzLLqu1eXfmzCm2jHLDldCutWtShp2jxpns%3D&security=reality&sid=77aa10f0&sni=www.microsoft.com&type=tcp#fx-vless-fallback-user%40example.com \ No newline at end of file diff --git a/internal/singbox/testdata/golden/vless-tcp-reality/expected-link.txt b/internal/singbox/testdata/golden/vless-tcp-reality/expected-link.txt index 1082901..2dbb78d 100644 --- a/internal/singbox/testdata/golden/vless-tcp-reality/expected-link.txt +++ b/internal/singbox/testdata/golden/vless-tcp-reality/expected-link.txt @@ -1 +1 @@ -vless://11111111-2222-4333-8444-555555555555@203.0.113.10:443?encryption=none&flow=xtls-rprx-vision&fp=chrome&pbk=B6N8vBQgk8i3VdwbEOhstCY3StFqqFPtC9%2FAsrhtHHw%3D&security=reality&sid=ab12cd34&sni=www.amd.com&spx=%2Fd0cf3c252cd6df6&type=tcp#fx-vless-reality-user%40example.com \ No newline at end of file +vless://11111111-2222-4333-8444-555555555555@203.0.113.10:443?encryption=none&flow=xtls-rprx-vision&fp=chrome&pbk=B6N8vBQgk8i3VdwbEOhstCY3StFqqFPtC9%2FAsrhtHHw%3D&security=reality&sid=ab12cd34&sni=www.amd.com&type=tcp#fx-vless-reality-user%40example.com \ No newline at end of file diff --git a/internal/sub/service.go b/internal/sub/service.go index c81a20c..2c45a97 100644 --- a/internal/sub/service.go +++ b/internal/sub/service.go @@ -957,7 +957,7 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string { case "tls": applyShareTLSParams(stream, params) case "reality": - applyShareRealityParams(stream, params, subKey(client)) + applyShareRealityParams(stream, params) default: params["security"] = "none" } @@ -1010,7 +1010,7 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string case "tls": applyShareTLSParams(stream, params) case "reality": - applyShareRealityParams(stream, params, subKey(client)) + applyShareRealityParams(stream, params) if streamNetwork == "tcp" && len(client.Flow) > 0 && !inbound.DisableFlow { params["flow"] = client.Flow } @@ -1700,7 +1700,7 @@ func hysteriaPinHex(pin string) string { return pin } -func applyShareRealityParams(stream map[string]any, params map[string]string, clientKey string) { +func applyShareRealityParams(stream map[string]any, params map[string]string) { params["security"] = "reality" realitySetting, _ := stream["realitySettings"].(map[string]any) realitySettings, _ := searchKey(realitySetting, "settings") @@ -1728,33 +1728,15 @@ func applyShareRealityParams(stream map[string]any, params map[string]string, cl params["pqv"] = pqv } } - seed := "" - if spxValue, ok := searchKey(realitySettings, "spiderX"); ok { - seed, _ = spxValue.(string) - } - params["spx"] = deriveSpiderX(seed, clientKey) + // V2.0.1 ⑦: no spx parameter is emitted any more. The + // per-client spiderX derivation produced values some clients + // refuse to store (the operator's client errors out on the + // unknown parameter), and REALITY does not need it — the + // crawl path is a client-side nicety, not part of the + // handshake contract. } } -// subKey returns a stable per-client identity for deterministic derivations, -// preferring the subscription id and falling back to the (unique) email. -func subKey(c model.Client) string { - if c.SubID != "" { - return c.SubID - } - return c.Email -} - -// deriveSpiderX maps the inbound's spiderX seed plus a stable client key to a -// deterministic per-client "/path"; frontend/src/lib/xray/spider-x.ts mirrors it. -func deriveSpiderX(seed, clientKey string) string { - if seed == "" && clientKey == "" { - return "/" + random.Seq(15) - } - sum := sha256.Sum256([]byte(seed + "|" + clientKey)) - return "/" + hex.EncodeToString(sum[:])[:15] -} - func buildVmessLink(obj map[string]any) string { jsonStr, _ := json.MarshalIndent(obj, "", " ") return "vmess://" + base64.StdEncoding.EncodeToString(jsonStr) diff --git a/internal/sub/service_sharelink_test.go b/internal/sub/service_sharelink_test.go index 88cc80a..a2741cf 100644 --- a/internal/sub/service_sharelink_test.go +++ b/internal/sub/service_sharelink_test.go @@ -54,7 +54,7 @@ func TestGenVlessLink_TLSParamsMapped(t *testing.T) { } // Locks the reality field mapping of applyShareRealityParams; distinct pbk/sid -// catch a swap mutant. spx is now a per-client derived value (#5718 / follow-up). +// catch a swap mutant. spx must never be emitted (V2.0.1 ⑦). func TestGenVlessLink_RealityParamsMapped(t *testing.T) { stream := `{ "network":"tcp","security":"reality", @@ -74,13 +74,15 @@ func TestGenVlessLink_RealityParamsMapped(t *testing.T) { "pbk=PBKvalue", "sid=ab12cd", "fp=firefox", - "spx=%2F", } for _, w := range wants { if !strings.Contains(link, w) { t.Fatalf("reality link missing %q\n got: %s", w, link) } } + if strings.Contains(link, "spx") { + t.Fatalf("reality link must not carry spx (V2.0.1 ⑦): %s", link) + } // A pbk<->sid swap must not silently pass: pbk must not carry the shortId. if strings.Contains(link, "pbk=ab12cd") || strings.Contains(link, "sid=PBKvalue") { t.Fatalf("reality pbk/sid mapping crossed: %s", link) @@ -88,7 +90,7 @@ func TestGenVlessLink_RealityParamsMapped(t *testing.T) { } // realityTwoClientInbound builds a reality VLESS inbound carrying two clients -// with distinct subIds so the per-client spx derivation can be exercised. +// with distinct subIds. func realityTwoClientInbound() *model.Inbound { return &model.Inbound{ Listen: "203.0.113.1", @@ -111,68 +113,19 @@ func realityTwoClientInbound() *model.Inbound { } } -func spxParam(t *testing.T, link string) string { - t.Helper() - u, err := url.Parse(link) - if err != nil { - t.Fatalf("parse link %q: %v", link, err) - } - spx := u.Query().Get("spx") - if spx == "" || spx[0] != '/' { - t.Fatalf("spx missing or not /-prefixed in %q", link) - } - return spx -} - -// spx must be stable for a given client across repeated exports (the #5718 -// complaint) yet differ between clients so the value can't be fingerprinted. -func TestGenVlessLink_RealitySpiderXPerClientStable(t *testing.T) { +// V2.0.1 ⑦: generated reality links never carry spx, for any client, +// even when the inbound still stores a spiderX seed. +func TestGenVlessLink_RealityOmitsSpx(t *testing.T) { s := &SubService{} inbound := realityTwoClientInbound() - - aliceFirst := spxParam(t, s.genVlessLink(inbound, "alice")) - aliceSecond := spxParam(t, s.genVlessLink(inbound, "alice")) - bob := spxParam(t, s.genVlessLink(inbound, "bob")) - - if aliceFirst != aliceSecond { - t.Fatalf("spx not stable for the same client: %q vs %q", aliceFirst, aliceSecond) - } - if aliceFirst == bob { - t.Fatalf("spx identical across clients (fingerprintable): %q", aliceFirst) - } -} - -func TestDeriveSpiderX(t *testing.T) { - if got := deriveSpiderX("seed", "clientA"); got != deriveSpiderX("seed", "clientA") { - t.Fatalf("deriveSpiderX not deterministic: %q", got) - } - if deriveSpiderX("seed", "clientA") == deriveSpiderX("seed", "clientB") { - t.Fatal("deriveSpiderX must differ per client") - } - if deriveSpiderX("seedA", "clientA") == deriveSpiderX("seedB", "clientA") { - t.Fatal("rotating the seed must rotate a client's spx") - } - got := deriveSpiderX("seed", "clientA") - if len(got) != 16 || got[0] != '/' { - t.Fatalf("deriveSpiderX shape = %q, want /-prefixed 15-char path", got) - } - if fallback := deriveSpiderX("", ""); len(fallback) != 16 || fallback[0] != '/' { - t.Fatalf("empty-input fallback = %q, want /-prefixed path", fallback) - } -} - -// Cross-language vectors shared with frontend/src/test/spider-x.test.ts: the -// panel builds these links in TS, so both derivations must agree byte-for-byte. -func TestDeriveSpiderXMatchesFrontendVectors(t *testing.T) { - vectors := map[string]struct{ seed, clientKey, want string }{ - "seed and subId": {"/seed", "subAlice", "/c252fbc3ecd3e3c"}, - "seed only": {"/", "", "/d08ed99bd9afc60"}, - } - for name, v := range vectors { - t.Run(name, func(t *testing.T) { - if got := deriveSpiderX(v.seed, v.clientKey); got != v.want { - t.Fatalf("deriveSpiderX(%q, %q) = %q, want %q (must match frontend/src/lib/xray/spider-x.ts)", v.seed, v.clientKey, got, v.want) - } - }) + for _, email := range []string{"alice", "bob"} { + link := s.genVlessLink(inbound, email) + u, err := url.Parse(link) + if err != nil { + t.Fatalf("parse link %q: %v", link, err) + } + if _, ok := u.Query()["spx"]; ok { + t.Fatalf("link for %s carries spx: %s", email, link) + } } } diff --git a/internal/web/controller/agent.go b/internal/web/controller/agent.go index abf35e1..919651d 100644 --- a/internal/web/controller/agent.go +++ b/internal/web/controller/agent.go @@ -48,6 +48,10 @@ func (a *AgentController) initRouter(g *gin.RouterGroup) { g.POST("/heartbeat", a.heartbeat) g.GET("/config", a.getConfig) g.POST("/traffic", a.reportTraffic) + // V2.0.1 probe batch: the lightweight fast report (runtime + // metrics only) that replaces the metrics half of the heartbeat + // at the operator-configured cadence. + g.POST("/probe-report", a.probeReport) } // RegisterAdminRoutes mounts the panel-authenticated administration endpoints @@ -215,14 +219,15 @@ func (a *AgentController) register(c *gin.Context) { // "not reported this round" and must not overwrite the stored value. Older // agents send no telemetry object at all and are handled exactly as before. type heartbeatTelemetry struct { - AgentVersion *string `json:"agentVersion"` - XrayVersion *string `json:"xrayVersion"` - UptimeSeconds *uint64 `json:"uptimeSeconds"` - LatencyMs *int `json:"latencyMs"` - CpuPercent *float64 `json:"cpuPercent"` - MemPercent *float64 `json:"memPercent"` - IPv4 *string `json:"ipv4"` - IPv6 *string `json:"ipv6"` + AgentVersion *string `json:"agentVersion"` + XrayVersion *string `json:"xrayVersion"` + SingboxVersion *string `json:"singboxVersion"` + UptimeSeconds *uint64 `json:"uptimeSeconds"` + LatencyMs *int `json:"latencyMs"` + CpuPercent *float64 `json:"cpuPercent"` + MemPercent *float64 `json:"memPercent"` + IPv4 *string `json:"ipv4"` + IPv6 *string `json:"ipv6"` } // updateAgentTelemetry persists the reported telemetry fields onto the @@ -235,6 +240,9 @@ func (a *AgentController) updateAgentTelemetry(id uint, t *heartbeatTelemetry) { if t.XrayVersion != nil { updates["xray_version"] = *t.XrayVersion } + if t.SingboxVersion != nil { + updates["singbox_version"] = *t.SingboxVersion + } if t.UptimeSeconds != nil { updates["uptime_secs"] = *t.UptimeSeconds } @@ -605,6 +613,44 @@ func (a *AgentController) heartbeat(c *gin.Context) { // hard 500: the agent keeps its current runtime state and retries on // the next poll, and the failure stays visible in both logs instead // of silently freezing the node's config. +// probeStatIface reads the agent's counted-interface pin from its +// probe metadata ("" = automatic selection, and the answer for any +// read failure — a config poll must not fail over a display-side +// setting). +func probeStatIface(agentID int64) string { + meta, found, err := probe.MetaFor(agentID) + if err != nil || !found { + return "" + } + return meta.StatIface +} + +// probeReport ingests one fast report (V2.0.1): a probe metrics +// block and nothing else. It deliberately does NOT touch the +// agent's last-seen stamp — liveness stays the 60 s heartbeat's +// signal, and the traffic ledger's unseen-boot rule keeps measuring +// reboots against heartbeat sightings exactly as before. Agents on +// older masters never see this endpoint (404) and simply keep +// reporting metrics on the heartbeat; masters accept metrics on +// either channel. +func (a *AgentController) probeReport(c *gin.Context) { + agent, ok := a.findAgent(bearerToken(c)) + if !ok { + c.JSON(401, gin.H{"message": "unauthorized"}) + return + } + a.captureSeenIP(c, agent) + var req struct { + ProbeMetrics *heartbeatProbeMetrics `json:"probeMetrics"` + } + _ = c.ShouldBindJSON(&req) + if req.ProbeMetrics != nil { + a.updateAgentProbe(uint(agent.Id), nil, req.ProbeMetrics) + probe.Record(int64(agent.Id), probeSample(req.ProbeMetrics), agent.LastSeen) + } + jsonObj(c, gin.H{"message": "ok"}, nil) +} + func (a *AgentController) getConfig(c *gin.Context) { agent, ok := a.findAgent(bearerToken(c)) if !ok { @@ -638,6 +684,13 @@ func (a *AgentController) getConfig(c *gin.Context) { // an absent field (older master) leaves the agent's current // set untouched. "probe_tasks": probe.PingTasksFor(int64(agent.Id)), + // V2.0.1 probe batch: the fast-report cadence and this node's + // counted-interface pin ride the same poll. Older agents + // ignore both keys and keep the pre-V2.0.1 behaviour + // (metrics on the 60 s heartbeat, automatic interface + // selection). + "probe_fast_interval": service.ProbeFastIntervalSec(), + "probe_stat_iface": probeStatIface(int64(agent.Id)), } // Item 20: a pending one-click update intent rides along on the config // poll (no new channel — the agent already polls this endpoint). diff --git a/internal/web/controller/agent_fast_report_test.go b/internal/web/controller/agent_fast_report_test.go new file mode 100644 index 0000000..10f8689 --- /dev/null +++ b/internal/web/controller/agent_fast_report_test.go @@ -0,0 +1,257 @@ +package controller + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "github.com/gin-gonic/gin" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/web/service" + "github.com/mhsanaei/3x-ui/v3/internal/web/service/probe" +) + +// V2.0.1 probe batch, master side: the fast-report endpoint ingests +// metrics without touching liveness (last-seen stays the +// heartbeat's), and the config poll carries the cadence + the +// node's counted-interface pin. +func TestAgentFastReportAndConfigKeys(t *testing.T) { + gin.SetMode(gin.TestMode) + dbDir := t.TempDir() + t.Setenv("XUI_DB_FOLDER", dbDir) + if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil { + t.Fatalf("InitDB: %v", err) + } + t.Cleanup(func() { _ = database.CloseDB() }) + db := database.GetDB() + + const token = "fast-report-token" + node := &model.Node{Name: "fast-node", Address: "192.0.2.1", Port: 8443, Enable: true} + if err := db.Create(node).Error; err != nil { + t.Fatalf("seed node: %v", err) + } + agent := &model.AgentNode{TokenHash: hashAgentToken(token), TokenPrefix: "fast-", Name: "fast-node", NodeID: &node.Id} + if err := db.Create(agent).Error; err != nil { + t.Fatalf("seed agent: %v", err) + } + + engine := gin.New() + NewAgentController(engine.Group("/panel/api/agent")) + + call := func(method, path, body string) (int, []byte) { + req := httptest.NewRequest(method, path, bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, req) + return rec.Code, rec.Body.Bytes() + } + load := func() model.AgentNode { + var row model.AgentNode + if err := db.First(&row, agent.Id).Error; err != nil { + t.Fatalf("reload agent: %v", err) + } + return row + } + + // One heartbeat establishes last-seen. + if code, _ := call(http.MethodPost, "/panel/api/agent/heartbeat", `{"token":"`+token+`","timestamp":1}`); code != http.StatusOK { + t.Fatalf("heartbeat: %d", code) + } + seenAfterHeartbeat := load().LastSeen + if seenAfterHeartbeat == 0 { + t.Fatal("heartbeat did not stamp last-seen") + } + + metrics := `{"probeMetrics":{"bootId":"boot-f/0123456789abcdef","uptime":60,` + + `"memTotal":1000,"memUsed":400,"netRxTotal":10,"netTxTotal":20}}` + if code, body := call(http.MethodPost, "/panel/api/agent/probe-report", metrics); code != http.StatusOK { + t.Fatalf("probe-report: %d (%s)", code, body) + } + row := load() + if row.ProbeMemUsed != 400 || row.ProbeAt == 0 { + t.Errorf("fast report metrics not stored: %+v", row) + } + if row.LastSeen != seenAfterHeartbeat { + t.Errorf("fast report moved last-seen: %d -> %d (liveness is the heartbeat's signal)", seenAfterHeartbeat, row.LastSeen) + } + + // Unauthorized callers are refused. + req := httptest.NewRequest(http.MethodPost, "/panel/api/agent/probe-report", bytes.NewBufferString(metrics)) + req.Header.Set("Authorization", "Bearer wrong") + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Errorf("probe-report with bad token: %d, want 401", rec.Code) + } + + configObj := func() map[string]any { + code, body := call(http.MethodGet, "/panel/api/agent/config", "") + if code != http.StatusOK { + t.Fatalf("config poll: %d (%s)", code, body) + } + // getConfig answers with the bare payload (no envelope). + var obj map[string]any + if err := json.Unmarshal(body, &obj); err != nil { + t.Fatalf("config payload: %v", err) + } + return obj + } + obj := configObj() + if obj["probe_fast_interval"] != float64(3) { + t.Errorf("default probe_fast_interval = %v, want 3", obj["probe_fast_interval"]) + } + if obj["probe_stat_iface"] != "" { + t.Errorf("default probe_stat_iface = %v, want empty", obj["probe_stat_iface"]) + } + + // Operator changes: cadence via the probe settings block, pin + // via the node's probe meta — both must ride the next poll. + settings := service.GetProbePublicSettings() + settings.FastIntervalSec = 9 + if err := service.SetProbePublicSettings(settings); err != nil { + t.Fatalf("SetProbePublicSettings: %v", err) + } + st := probe.Default() + meta := probe.DefaultMeta(int64(agent.Id)) + meta.StatIface = "eth9" + if err := st.UpsertMeta(meta); err != nil { + t.Fatalf("UpsertMeta: %v", err) + } + obj = configObj() + if obj["probe_fast_interval"] != float64(9) { + t.Errorf("probe_fast_interval = %v, want 9", obj["probe_fast_interval"]) + } + if obj["probe_stat_iface"] != "eth9" { + t.Errorf("probe_stat_iface = %v, want eth9", obj["probe_stat_iface"]) + } + + // Settings validation clamps the ranges the page offers. + bad := service.GetProbePublicSettings() + bad.RetentionDays = 0 + if err := service.SetProbePublicSettings(bad); err == nil { + t.Error("retention_days = 0 accepted, want refusal") + } + bad = service.GetProbePublicSettings() + bad.FastIntervalSec = 61 + if err := service.SetProbePublicSettings(bad); err == nil { + t.Error("fast interval = 61 accepted, want refusal") + } +} + +// V2.0.1 ⑤: batch task creation — shared fields, auto names, +// duplicate collapse, all-or-nothing validation. +func TestProbeTasksBatch(t *testing.T) { + rig := newProbeTestRig(t) + cleanPingTasks(t) + const batchPath = "/panel/api/agent/probe-tasks/batch" + + rec := rig.do(http.MethodPost, batchPath, []byte( + `{"targets":["1.1.1.1:443","8.8.8.8:53"," 1.1.1.1:443 "],"autoJoin":true}`), true) + env := decodeEnvelope(t, rec.Body.Bytes()) + if !env.Success { + t.Fatalf("batch create refused: %s", env.Msg) + } + var obj struct { + IDs []int64 `json:"ids"` + } + if err := json.Unmarshal(env.Obj, &obj); err != nil || len(obj.IDs) != 2 { + t.Fatalf("batch ids = %s (err %v), want 2 ids (duplicate collapsed)", env.Obj, err) + } + tasks, err := probe.PingTasks() + if err != nil || len(tasks) != 2 { + t.Fatalf("tasks after batch = %d (err %v)", len(tasks), err) + } + for _, task := range tasks { + if task.Name != task.Target { + t.Errorf("auto name = %q, want the target %q", task.Name, task.Target) + } + if task.Interval != 60 { + t.Errorf("default interval = %d, want 60", task.Interval) + } + if !task.AutoJoin { + t.Errorf("autoJoin not carried into task %d", task.ID) + } + } + + cleanPingTasks(t) + rec = rig.do(http.MethodPost, batchPath, []byte( + `{"targets":["1.1.1.1:443","8.8.8.8:53"],"name":"线路","interval":30}`), true) + env = decodeEnvelope(t, rec.Body.Bytes()) + if !env.Success { + t.Fatalf("named batch refused: %s", env.Msg) + } + tasks, _ = probe.PingTasks() + if len(tasks) != 2 { + t.Fatalf("named batch tasks = %d", len(tasks)) + } + for _, task := range tasks { + if !strings.HasPrefix(task.Name, "线路 · ") || task.Interval != 30 { + t.Errorf("named batch task = %+v", task) + } + } + + // One bad line refuses the whole paste; nothing is created. + cleanPingTasks(t) + rec = rig.do(http.MethodPost, batchPath, []byte( + `{"targets":["1.1.1.1:443","not-a-target"]}`), true) + env = decodeEnvelope(t, rec.Body.Bytes()) + if env.Success { + t.Fatal("batch with a bad target succeeded") + } + if tasks, _ := probe.PingTasks(); len(tasks) != 0 { + t.Fatalf("refused batch leaked %d tasks", len(tasks)) + } +} + +// V2.0.1 probe batch: the meta editor's calibration + interface pin +// fields round-trip and validate. +func TestProbeMetaCalibrationFields(t *testing.T) { + rig := newProbeTestRig(t) + db := database.GetDB() + nodeID := 77 + node := &model.Node{Id: nodeID, Name: "meta-node", Address: "192.0.2.1", Port: 8443, Enable: true} + if err := db.Create(node).Error; err != nil { + t.Fatalf("create node: %v", err) + } + agent := &model.AgentNode{TokenHash: hashAgentToken("meta-token"), TokenPrefix: "meta-", Name: "meta-agent", NodeID: &nodeID} + if err := db.Create(agent).Error; err != nil { + t.Fatalf("create agent: %v", err) + } + path := "/panel/api/agent/probe-meta/77" + + put := func(body string) envelope { + rec := rig.do(http.MethodPut, path, []byte(body), true) + return decodeEnvelope(t, rec.Body.Bytes()) + } + base := `"price":0,"currency":"USD","billingCycle":"monthly",` + + `"expiresAt":null,"trafficLimit":0,"trafficMode":"sum","trafficResetDay":1,"public":true,"sort":0,` + + `"group":"","publicRemark":"","notify":false,"countryPin":"","ipv4Pin":"","ipv6Pin":""` + + env := put(`{` + base + `,"trafficCalUpGb":2.5,"trafficCalDownGb":-1,"statIface":" eth0 "}`) + if !env.Success { + t.Fatalf("meta save with calibration refused: %s", env.Msg) + } + meta, found, err := probe.MetaFor(int64(agent.Id)) + if err != nil || !found { + t.Fatalf("MetaFor: found=%v err=%v", found, err) + } + if meta.TrafficCalUpGB != 2.5 || meta.TrafficCalDownGB != -1 || meta.StatIface != "eth0" { + t.Errorf("stored meta = up %v down %v iface %q (pin must be trimmed)", meta.TrafficCalUpGB, meta.TrafficCalDownGB, meta.StatIface) + } + + env = put(`{` + base + `,"statIface":"eth 0"}`) + if env.Success { + t.Error("interface name with a space accepted") + } + env = put(`{` + base + `,"statIface":"eth/0"}`) + if env.Success { + t.Error("interface name with a slash accepted") + } +} diff --git a/internal/web/controller/api.go b/internal/web/controller/api.go index 8a1e79d..113bbb6 100644 --- a/internal/web/controller/api.go +++ b/internal/web/controller/api.go @@ -16,15 +16,15 @@ import ( // APIController handles the main API routes for the 3x-ui panel, including inbounds and server management. type APIController struct { BaseController - inboundController *InboundController - serverController *ServerController - nodeController *NodeController - agentController *AgentController - hostController *HostController - settingController *SettingController - userService panel.UserService - apiTokenService panel.ApiTokenService - Tgbot tgbot.Tgbot + inboundController *InboundController + serverController *ServerController + nodeController *NodeController + agentController *AgentController + hostController *HostController + settingController *SettingController + userService panel.UserService + apiTokenService panel.ApiTokenService + Tgbot tgbot.Tgbot } // NewAPIController creates a new APIController instance and initializes its routes. @@ -205,8 +205,8 @@ func (a *APIController) initRouter(g *gin.RouterGroup) { // group itself (/panel/api/probe/*). NewProbeThemesController(api) - // Item 7 B6: notification settings + test send + cf-probe - // retirement coverage, same group (/panel/api/probe/notify*). + // Item 7 B6: notification settings + test send, same group + // (/panel/api/probe/notify*). NewProbeNotifyController(api) // Hosts API — per-inbound override endpoints for subscription links diff --git a/internal/web/controller/probe_fallback_gate_test.go b/internal/web/controller/probe_fallback_gate_test.go new file mode 100644 index 0000000..2e31029 --- /dev/null +++ b/internal/web/controller/probe_fallback_gate_test.go @@ -0,0 +1,53 @@ +package controller + +import ( + "net/http" + "os" + "path/filepath" + "testing" +) + +// V2.0.1 ③: with the public page off, the root path and theme paths +// answer a plain 404 to anonymous visitors instead of the theme +// shell (whose "public page off" redirect chain dead-ends on +// /admin). A logged-in operator still reaches the theme, and with +// the page on the shell is public again. +func TestProbeFallbackPublicPageGate(t *testing.T) { + rig := newProbeTestRig(t) + + themeDir := filepath.Join(rig.dbDir, "themes", "testtheme", "dist") + if err := os.MkdirAll(themeDir, 0o755); err != nil { + t.Fatal(err) + } + manifest := `{"name":"Test Theme","short":"testtheme","description":"gate test fixture","version":"0.0.1","author":"tests","url":""}` + if err := os.WriteFile(filepath.Join(rig.dbDir, "themes", "testtheme", "theme.json"), []byte(manifest), 0o644); err != nil { + t.Fatal(err) + } + indexHTML := "theme" + if err := os.WriteFile(filepath.Join(themeDir, "index.html"), []byte(indexHTML), 0o644); err != nil { + t.Fatal(err) + } + rig.setSetting(t, "theme", "testtheme") + + // Public page off (the default): anonymous root + SPA paths 404. + if rec := rig.do(http.MethodGet, "/", nil, false); rec.Code != http.StatusNotFound { + t.Fatalf("anon root with public page off: %d, want 404", rec.Code) + } + if rec := rig.do(http.MethodGet, "/some/spa/route", nil, false); rec.Code != http.StatusNotFound { + t.Fatalf("anon spa path with public page off: %d, want 404", rec.Code) + } + // API 404 discipline is unchanged. + if rec := rig.do(http.MethodGet, "/api/nope", nil, false); rec.Code != http.StatusNotFound { + t.Fatalf("api 404 with public page off: %d", rec.Code) + } + // Logged-in operator still reaches the theme shell. + if rec := rig.do(http.MethodGet, "/", nil, true); rec.Code != 200 || rec.Body.String() != indexHTML { + t.Fatalf("authed root with public page off: %d %q", rec.Code, rec.Body.String()) + } + + // Public page on: anonymous shell access is back. + rig.setSetting(t, "public_page", "on") + if rec := rig.do(http.MethodGet, "/", nil, false); rec.Code != 200 || rec.Body.String() != indexHTML { + t.Fatalf("anon root with public page on: %d %q", rec.Code, rec.Body.String()) + } +} diff --git a/internal/web/controller/probe_meta.go b/internal/web/controller/probe_meta.go index 42406f4..02b1e8f 100644 --- a/internal/web/controller/probe_meta.go +++ b/internal/web/controller/probe_meta.go @@ -51,46 +51,57 @@ type probeMetaForm struct { CountryPin string `json:"countryPin"` IPv4Pin string `json:"ipv4Pin"` IPv6Pin string `json:"ipv6Pin"` + // V2.0.1: traffic calibration (GB, either sign) and the counted + // interface pin ("" = automatic). + TrafficCalUpGB float64 `json:"trafficCalUpGb"` + TrafficCalDownGB float64 `json:"trafficCalDownGb"` + StatIface string `json:"statIface"` } func metaToForm(m probe.Meta) probeMetaForm { return probeMetaForm{ - Price: m.Price, - Currency: m.Currency, - BillingCycle: m.BillingCycle, - ExpiresAt: m.ExpiresAt, - TrafficLimit: m.TrafficLimit, - TrafficMode: m.TrafficMode, - TrafficResetDay: m.TrafficResetDay, - Public: m.Public, - Sort: m.Sort, - Group: m.GroupName, - PublicRemark: m.PublicRemark, - Notify: m.Notify, - CountryPin: m.CountryPin, - IPv4Pin: m.IPv4Pin, - IPv6Pin: m.IPv6Pin, + Price: m.Price, + Currency: m.Currency, + BillingCycle: m.BillingCycle, + ExpiresAt: m.ExpiresAt, + TrafficLimit: m.TrafficLimit, + TrafficMode: m.TrafficMode, + TrafficResetDay: m.TrafficResetDay, + Public: m.Public, + Sort: m.Sort, + Group: m.GroupName, + PublicRemark: m.PublicRemark, + Notify: m.Notify, + CountryPin: m.CountryPin, + IPv4Pin: m.IPv4Pin, + IPv6Pin: m.IPv6Pin, + TrafficCalUpGB: m.TrafficCalUpGB, + TrafficCalDownGB: m.TrafficCalDownGB, + StatIface: m.StatIface, } } func (f probeMetaForm) toMeta(agentID int64) probe.Meta { return probe.Meta{ - AgentID: agentID, - Price: f.Price, - Currency: f.Currency, - BillingCycle: f.BillingCycle, - ExpiresAt: f.ExpiresAt, - TrafficLimit: f.TrafficLimit, - TrafficMode: f.TrafficMode, - TrafficResetDay: f.TrafficResetDay, - Public: f.Public, - Sort: f.Sort, - GroupName: f.Group, - PublicRemark: f.PublicRemark, - Notify: f.Notify, - CountryPin: f.CountryPin, - IPv4Pin: f.IPv4Pin, - IPv6Pin: f.IPv6Pin, + AgentID: agentID, + Price: f.Price, + Currency: f.Currency, + BillingCycle: f.BillingCycle, + ExpiresAt: f.ExpiresAt, + TrafficLimit: f.TrafficLimit, + TrafficMode: f.TrafficMode, + TrafficResetDay: f.TrafficResetDay, + Public: f.Public, + Sort: f.Sort, + GroupName: f.Group, + PublicRemark: f.PublicRemark, + Notify: f.Notify, + CountryPin: f.CountryPin, + IPv4Pin: f.IPv4Pin, + IPv6Pin: f.IPv6Pin, + TrafficCalUpGB: f.TrafficCalUpGB, + TrafficCalDownGB: f.TrafficCalDownGB, + StatIface: f.StatIface, } } @@ -202,6 +213,14 @@ func validateProbeMetaForm(f *probeMetaForm) string { if math.IsNaN(f.Price) || math.IsInf(f.Price, 0) || f.Price < 0 || f.TrafficLimit < 0 { return "价格和流量上限不能是负数" } + if math.IsNaN(f.TrafficCalUpGB) || math.IsInf(f.TrafficCalUpGB, 0) || + math.IsNaN(f.TrafficCalDownGB) || math.IsInf(f.TrafficCalDownGB, 0) { + return "流量校正要填有限的数字(GB,可正可负)" + } + f.StatIface = strings.TrimSpace(f.StatIface) + if len(f.StatIface) > 32 || hasControl(f.StatIface) || strings.ContainsAny(f.StatIface, " \t/") { + return "统计网卡填网卡名(如 eth0),留空则自动甄别" + } f.Currency = strings.ToUpper(strings.TrimSpace(f.Currency)) if len(f.Currency) != 3 || !isUpperASCII(f.Currency) { return "货币要填三个字母的代码,如 USD、CNY、HKD" diff --git a/internal/web/controller/probe_notify.go b/internal/web/controller/probe_notify.go index a1f25f7..19d88f8 100644 --- a/internal/web/controller/probe_notify.go +++ b/internal/web/controller/probe_notify.go @@ -5,7 +5,6 @@ import ( "net/http" "github.com/mhsanaei/3x-ui/v3/internal/web/entity" - "github.com/mhsanaei/3x-ui/v3/internal/web/service" "github.com/mhsanaei/3x-ui/v3/internal/web/service/probenotify" "github.com/gin-gonic/gin" @@ -13,12 +12,12 @@ import ( // Item 7 B6: the notification settings surface — the notify_* block // (offline grace, traffic step, expiry window, login alerts, the -// webhook channel and the two message templates), a test send, and -// the cf-probe retirement coverage the operator checks before -// standing the old probe fleet down. Like the rest of the probe -// surfaces these endpoints answer in the panel's Msg envelope and -// take effect immediately; validation refusals are operator-facing -// text, shown verbatim. +// webhook channel and the two message templates) and a test send. +// Like the rest of the probe surfaces these endpoints answer in the +// panel's Msg envelope and take effect immediately; validation +// refusals are operator-facing text, shown verbatim. (The cf-probe +// retirement coverage card that originally rode this payload was +// removed in V2.0.1 ⑥ — the checklist lives in the operator docs.) type ProbeNotifyController struct{} @@ -31,14 +30,12 @@ func NewProbeNotifyController(g *gin.RouterGroup) *ProbeNotifyController { } type probeNotifyPayload struct { - Settings probenotify.SettingsView `json:"settings"` - Coverage []service.ProbeCoverageRow `json:"coverage"` + Settings probenotify.SettingsView `json:"settings"` } func (pc *ProbeNotifyController) get(c *gin.Context) { jsonObj(c, probeNotifyPayload{ Settings: probenotify.GetSettings(), - Coverage: service.ProbeAgentCoverage(), }, nil) } @@ -59,7 +56,6 @@ func (pc *ProbeNotifyController) put(c *gin.Context) { // the channel status and secret-set flags from it. jsonObj(c, probeNotifyPayload{ Settings: probenotify.GetSettings(), - Coverage: service.ProbeAgentCoverage(), }, nil) } diff --git a/internal/web/controller/probe_public.go b/internal/web/controller/probe_public.go index b4d9f99..1064d15 100644 --- a/internal/web/controller/probe_public.go +++ b/internal/web/controller/probe_public.go @@ -378,5 +378,15 @@ func (pc *PublicProbeController) HandleFallback(c *gin.Context) bool { } else if strings.HasPrefix(path, "/panel") || strings.HasPrefix(path, "/assets") { return false } + // V2.0.1 ③: with the public page switched off, the theme shell + // is not public content — an anonymous visitor gets a plain 404 + // (the pre-item-7 behaviour for the root path), never the shell + // whose "public page off" redirect chain dead-ends on /admin. + // A logged-in operator still reaches the theme (preview, theme + // config edits), exactly like the data APIs' own gate. + if !service.ProbePublicPageEnabled() && !probeAuthed(c) { + answerText(c, http.StatusNotFound, "404 page not found") + return true + } return service.ServeProbeThemePath(c, strings.TrimPrefix(path, "/")) } diff --git a/internal/web/controller/probe_tasks.go b/internal/web/controller/probe_tasks.go index e89af45..e4e81ed 100644 --- a/internal/web/controller/probe_tasks.go +++ b/internal/web/controller/probe_tasks.go @@ -31,6 +31,7 @@ func NewProbeTasksController(g *gin.RouterGroup) *ProbeTasksController { pc := &ProbeTasksController{} g.GET("/probe-tasks", pc.listTasks) g.PUT("/probe-tasks", pc.saveTask) + g.POST("/probe-tasks/batch", pc.saveTaskBatch) g.DELETE("/probe-tasks/:id", pc.deleteTask) g.POST("/probe-tasks/reorder", pc.reorderTasks) g.PUT("/probe-tasks/by-node/:nodeId", pc.setNodeTasks) @@ -269,6 +270,114 @@ func (pc *ProbeTasksController) saveTask(c *gin.Context) { jsonObj(c, gin.H{"id": id}, nil) } +// probeTaskBatchForm is the batch-create shape (V2.0.1 ⑤): many +// targets at once — already split client-side from the pasted +// lines/commas — sharing every other field. Name is a shared label: +// with several targets each task is auto-named after its target +// unless a name is given, in which case it prefixes the target +// (" · "); with exactly one target the name is used +// verbatim (and may be empty, falling back to the target). +type probeTaskBatchForm struct { + Targets []string `json:"targets"` + Name string `json:"name"` + Interval int64 `json:"interval"` + AutoJoin bool `json:"autoJoin"` + Region string `json:"region"` + NodeIDs []int64 `json:"nodeIds"` +} + +// saveTaskBatch creates one task per target, all-or-nothing on +// validation: every form is validated before the first insert, so a +// bad line refuses the whole paste with the hub-style message +// instead of leaving a half-entered list behind. +func (pc *ProbeTasksController) saveTaskBatch(c *gin.Context) { + var form probeTaskBatchForm + if err := c.ShouldBindJSON(&form); err != nil { + jsonMsg(c, "Invalid request", err) + return + } + if form.NodeIDs == nil { + form.NodeIDs = []int64{} + } + // The batch form's interval is a shared convenience field: an + // omitted one means the standard 60 s (the single-task form + // keeps requiring an explicit value). + if form.Interval == 0 { + form.Interval = 60 + } + // Split remnants and duplicates in the paste collapse here; + // order is preserved (it becomes the tasks' sort order). + seen := map[string]bool{} + targets := make([]string, 0, len(form.Targets)) + for _, raw := range form.Targets { + tgt := strings.TrimSpace(raw) + if tgt == "" || seen[tgt] { + continue + } + seen[tgt] = true + targets = append(targets, tgt) + } + if len(targets) == 0 { + jsonMsg(c, "请填写目标", errRequest) + return + } + sharedName := strings.TrimSpace(form.Name) + forms := make([]probeTaskForm, 0, len(targets)) + for _, tgt := range targets { + name := sharedName + switch { + case len(targets) == 1: + if name == "" { + name = tgt + } + case name == "": + name = tgt + default: + name = name + " · " + tgt + } + f := probeTaskForm{ + Name: name, + Target: tgt, + Interval: form.Interval, + AutoJoin: form.AutoJoin, + Region: form.Region, + NodeIDs: form.NodeIDs, + } + if msg := validateProbeTaskForm(&f); msg != "" { + jsonMsg(c, msg, errRequest) + return + } + forms = append(forms, f) + } + bound, err := agentsByNode() + if err != nil { + jsonMsg(c, "save probe tasks failed", err) + return + } + agentIDs, refusal := resolveAgentIDs(form.NodeIDs, bound) + if refusal != "" { + jsonMsg(c, refusal, errRequest) + return + } + ids := make([]int64, 0, len(forms)) + for _, f := range forms { + id, err := probe.SavePingTask(probe.PingTask{ + Name: f.Name, + Target: f.Target, + Interval: f.Interval, + AutoJoin: f.AutoJoin, + Region: f.Region, + AgentIDs: agentIDs, + }, nil) + if err != nil { + probeTaskError(c, err) + return + } + ids = append(ids, id) + } + jsonObj(c, gin.H{"ids": ids}, nil) +} + func (pc *ProbeTasksController) deleteTask(c *gin.Context) { id, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go index dad0730..2590421 100644 --- a/internal/web/service/inbound.go +++ b/internal/web/service/inbound.go @@ -1303,6 +1303,13 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo postCommitApply() } + // V2.0.1 ⓪: a newly created inbound starts with no sing-box + // fragment at all (fragments were a migration-era snapshot); + // compute its first one from the saved row so an agent-bound + // inbound is servable from birth, under the same discipline as + // the UpdateInbound recompute. + recomputeSingboxFragmentLogged(inbound) + // A routed mtproto inbound is not an Xray inbound itself, so the runtime // push above only (re)starts the mtg sidecar. The egress SOCKS bridge lives // in the generated config, so force a regen to wire it in. @@ -1950,6 +1957,11 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, if postCommitApply != nil { postCommitApply() } + // V2.0.1 ⓪: refresh the stored sing-box fragment from the saved + // row so agent-bound inbounds serve the edit, not the + // migration-day snapshot. oldInbound carries the persisted values + // (resolved tag, target node, normalized settings). + recomputeSingboxFragmentLogged(oldInbound) return inbound, needRestart, nil } diff --git a/internal/web/service/node_agent_telemetry_test.go b/internal/web/service/node_agent_telemetry_test.go index 92e4533..d14a1cf 100644 --- a/internal/web/service/node_agent_telemetry_test.go +++ b/internal/web/service/node_agent_telemetry_test.go @@ -28,7 +28,8 @@ func TestAnnotateAgentTelemetry(t *testing.T) { TokenHash: "hash-reporting", Name: "agent-node", NodeID: &agentNode.Id, AgentVersion: "3.7.0-agent.1", XrayVersion: "26.6.27", - UptimeSecs: 90061, LatencyMs: 12, CpuPct: 3.5, MemPct: 41.2, + SingboxVersion: "1.13.21", + UptimeSecs: 90061, LatencyMs: 12, CpuPct: 3.5, MemPct: 41.2, IPv4: "203.0.113.7", IPv6: "2001:db8::7", TelemetryAt: 1700000000000, } silent := &model.AgentNode{TokenHash: "hash-silent", Name: "silent-agent-node", NodeID: &silentNode.Id} @@ -53,7 +54,7 @@ func TestAnnotateAgentTelemetry(t *testing.T) { if av.NodeType != "agent" { t.Fatalf("node 7401 type = %q, want agent", av.NodeType) } - if av.XrayVersion != "26.6.27" || av.PanelVersion != "agent 3.7.0-agent.1" || + if av.XrayVersion != "26.6.27" || av.SingboxVersion != "1.13.21" || av.PanelVersion != "agent 3.7.0-agent.1" || av.UptimeSecs != 90061 || av.LatencyMs != 12 || av.CpuPct != 3.5 || av.MemPct != 41.2 || av.Ipv4 != "203.0.113.7" || av.Ipv6 != "2001:db8::7" { diff --git a/internal/web/service/node_contract.go b/internal/web/service/node_contract.go index d6a7504..f0b3886 100644 --- a/internal/web/service/node_contract.go +++ b/internal/web/service/node_contract.go @@ -38,35 +38,40 @@ type NodeView struct { // The nodes page renders them as plain text on agent rows instead // of the panel-URL link used for traditional nodes. Empty for // traditional nodes and for agents with no known address yet. - Ipv4 string `json:"ipv4,omitempty" example:"203.0.113.7"` - Ipv6 string `json:"ipv6,omitempty" example:"2001:db8::7"` + Ipv4 string `json:"ipv4,omitempty" example:"203.0.113.7"` + Ipv6 string `json:"ipv6,omitempty" example:"2001:db8::7"` // Agent one-click update bookkeeping (item 20), agent nodes only: // the last intent's state/target and, on failure, its error. AgentUpdateState string `json:"agentUpdateState,omitempty" example:"failed"` AgentUpdateError string `json:"agentUpdateError,omitempty" example:""` AgentUpdateTarget string `json:"agentUpdateTarget,omitempty" example:"A1.0.1"` - Guid string `json:"guid" example:"node-guid"` - Status string `json:"status" example:"online"` - LastHeartbeat int64 `json:"lastHeartbeat" example:"1700000000"` - LatencyMs int `json:"latencyMs" example:"42"` - XrayVersion string `json:"xrayVersion" example:"25.10.31"` - PanelVersion string `json:"panelVersion" example:"v3.x.x"` - CpuPct float64 `json:"cpuPct" example:"12.5"` - MemPct float64 `json:"memPct" example:"45.2"` - UptimeSecs uint64 `json:"uptimeSecs" example:"86400"` - NetUp uint64 `json:"netUp" example:"2097152"` - NetDown uint64 `json:"netDown" example:"1048576"` - LastError string `json:"lastError" example:""` - XrayState string `json:"xrayState" example:"running"` - XrayError string `json:"xrayError" example:""` - ConfigDirty bool `json:"configDirty" example:"false"` - ConfigDirtyAt int64 `json:"configDirtyAt" example:"0"` - InboundCount int `json:"inboundCount" example:"3"` - ClientCount int `json:"clientCount" example:"25"` - OnlineCount int `json:"onlineCount" example:"5"` - ActiveCount int `json:"activeCount" example:"20"` - DisabledCount int `json:"disabledCount" example:"2"` - DepletedCount int `json:"depletedCount" example:"1"` + Guid string `json:"guid" example:"node-guid"` + Status string `json:"status" example:"online"` + LastHeartbeat int64 `json:"lastHeartbeat" example:"1700000000"` + LatencyMs int `json:"latencyMs" example:"42"` + XrayVersion string `json:"xrayVersion" example:"25.10.31"` + // SingboxVersion is the bound agent's sing-box core version + // (V2.0.1 ②), overlaid from agent telemetry like XrayVersion; + // empty for traditional nodes and agents that never reported + // one. The nodes page prefers it over XrayVersion when set. + SingboxVersion string `json:"singboxVersion" example:"1.13.21"` + PanelVersion string `json:"panelVersion" example:"v3.x.x"` + CpuPct float64 `json:"cpuPct" example:"12.5"` + MemPct float64 `json:"memPct" example:"45.2"` + UptimeSecs uint64 `json:"uptimeSecs" example:"86400"` + NetUp uint64 `json:"netUp" example:"2097152"` + NetDown uint64 `json:"netDown" example:"1048576"` + LastError string `json:"lastError" example:""` + XrayState string `json:"xrayState" example:"running"` + XrayError string `json:"xrayError" example:""` + ConfigDirty bool `json:"configDirty" example:"false"` + ConfigDirtyAt int64 `json:"configDirtyAt" example:"0"` + InboundCount int `json:"inboundCount" example:"3"` + ClientCount int `json:"clientCount" example:"25"` + OnlineCount int `json:"onlineCount" example:"5"` + ActiveCount int `json:"activeCount" example:"20"` + DisabledCount int `json:"disabledCount" example:"2"` + DepletedCount int `json:"depletedCount" example:"1"` // Per-category client email lists behind the counts above, from the // same attribution bucketing, so the nodes page category chips can // pop over the actual usernames. Empty when the category is empty. @@ -74,10 +79,10 @@ type NodeView struct { ActiveEmails []string `json:"activeEmails,omitempty" example:"[\"a@example.com\"]"` DisabledEmails []string `json:"disabledEmails,omitempty" example:"[\"b@example.com\"]"` DepletedEmails []string `json:"depletedEmails,omitempty" example:"[\"c@example.com\"]"` - ParentGuid string `json:"parentGuid,omitempty" example:""` - Transitive bool `json:"transitive,omitempty" example:"false"` - CreatedAt int64 `json:"createdAt" example:"1700000000"` - UpdatedAt int64 `json:"updatedAt" example:"1700003600"` + ParentGuid string `json:"parentGuid,omitempty" example:""` + Transitive bool `json:"transitive,omitempty" example:"false"` + CreatedAt int64 `json:"createdAt" example:"1700000000"` + UpdatedAt int64 `json:"updatedAt" example:"1700003600"` } func toNodeView(n *model.Node) *NodeView { @@ -226,6 +231,9 @@ func annotateAgentTelemetry(views []*NodeView) { if ag.XrayVersion != "" { v.XrayVersion = ag.XrayVersion } + if ag.SingboxVersion != "" { + v.SingboxVersion = ag.SingboxVersion + } if ag.AgentVersion != "" { // New agents report the A-series display version, shown // verbatim; older agents report the internal build version diff --git a/internal/web/service/probe/meta.go b/internal/web/service/probe/meta.go index e6c4066..fea3650 100644 --- a/internal/web/service/probe/meta.go +++ b/internal/web/service/probe/meta.go @@ -30,6 +30,15 @@ type Meta struct { CountryPin string IPv4Pin string IPv6Pin string + // TrafficCalUpGB / TrafficCalDownGB are the operator's ledger + // corrections in GB (V2.0.1), either sign: added to the counted + // uplink (tx) / downlink (rx) figures wherever the ledger is + // read — panel statistics, the public page and traffic alerts. + TrafficCalUpGB float64 + TrafficCalDownGB float64 + // StatIface pins the one interface whose counters the agent + // books (V2.0.1); "" keeps the automatic counted-set selection. + StatIface string } // DefaultMeta returns the metadata of an agent that has no row yet — @@ -48,7 +57,8 @@ func DefaultMeta(agentID int64) Meta { const metaColumns = `agent_id, price, currency, billing_cycle, expires_at, traffic_limit, traffic_mode, traffic_reset_day, public, sort, - group_name, public_remark, notify, country_pin, ipv4_pin, ipv6_pin` + group_name, public_remark, notify, country_pin, ipv4_pin, ipv6_pin, + traffic_cal_up_gb, traffic_cal_down_gb, stat_iface` func scanMeta(sc interface{ Scan(...any) error }) (Meta, error) { var m Meta @@ -56,7 +66,8 @@ func scanMeta(sc interface{ Scan(...any) error }) (Meta, error) { var public, notify int64 if err := sc.Scan(&m.AgentID, &m.Price, &m.Currency, &m.BillingCycle, &expires, &m.TrafficLimit, &m.TrafficMode, &m.TrafficResetDay, &public, &m.Sort, - &m.GroupName, &m.PublicRemark, ¬ify, &m.CountryPin, &m.IPv4Pin, &m.IPv6Pin); err != nil { + &m.GroupName, &m.PublicRemark, ¬ify, &m.CountryPin, &m.IPv4Pin, &m.IPv6Pin, + &m.TrafficCalUpGB, &m.TrafficCalDownGB, &m.StatIface); err != nil { return Meta{}, err } if expires.Valid && strings.TrimSpace(expires.String) != "" { @@ -107,7 +118,7 @@ func (s *Store) UpsertMeta(m Meta) error { expires = *m.ExpiresAt } _, err := s.db.Exec( - `INSERT INTO probe_meta (`+metaColumns+`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + `INSERT INTO probe_meta (`+metaColumns+`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(agent_id) DO UPDATE SET price=excluded.price, currency=excluded.currency, billing_cycle=excluded.billing_cycle, expires_at=excluded.expires_at, @@ -116,10 +127,14 @@ func (s *Store) UpsertMeta(m Meta) error { sort=excluded.sort, group_name=excluded.group_name, public_remark=excluded.public_remark, notify=excluded.notify, country_pin=excluded.country_pin, ipv4_pin=excluded.ipv4_pin, - ipv6_pin=excluded.ipv6_pin`, + ipv6_pin=excluded.ipv6_pin, + traffic_cal_up_gb=excluded.traffic_cal_up_gb, + traffic_cal_down_gb=excluded.traffic_cal_down_gb, + stat_iface=excluded.stat_iface`, m.AgentID, m.Price, m.Currency, m.BillingCycle, expires, m.TrafficLimit, m.TrafficMode, m.TrafficResetDay, boolToInt(m.Public), m.Sort, - m.GroupName, m.PublicRemark, boolToInt(m.Notify), m.CountryPin, m.IPv4Pin, m.IPv6Pin) + m.GroupName, m.PublicRemark, boolToInt(m.Notify), m.CountryPin, m.IPv4Pin, m.IPv6Pin, + m.TrafficCalUpGB, m.TrafficCalDownGB, m.StatIface) return err } diff --git a/internal/web/service/probe/meta_v201_test.go b/internal/web/service/probe/meta_v201_test.go new file mode 100644 index 0000000..9ceb4fe --- /dev/null +++ b/internal/web/service/probe/meta_v201_test.go @@ -0,0 +1,108 @@ +package probe + +import ( + "database/sql" + "path/filepath" + "testing" + "time" +) + +// V2.0.1 probe batch, storage side: the calibration columns and the +// counted-interface pin round-trip through probe_meta, pre-V2.0.1 +// databases gain the columns on open, and the traffic reads apply +// the calibration (floored at zero) on every figure. + +func TestMetaCalibrationAndIfaceRoundTrip(t *testing.T) { + st := newTestStore(t) + meta := DefaultMeta(7) + meta.TrafficCalUpGB = 2.5 + meta.TrafficCalDownGB = -1.25 + meta.StatIface = "eth0" + if err := st.UpsertMeta(meta); err != nil { + t.Fatalf("upsert: %v", err) + } + back, found, err := st.Meta(7) + if err != nil || !found { + t.Fatalf("meta read: found=%v err=%v", found, err) + } + if back.TrafficCalUpGB != 2.5 || back.TrafficCalDownGB != -1.25 || back.StatIface != "eth0" { + t.Errorf("round trip = up %v down %v iface %q", back.TrafficCalUpGB, back.TrafficCalDownGB, back.StatIface) + } +} + +func TestOpenMigratesLegacyMetaColumns(t *testing.T) { + path := filepath.Join(t.TempDir(), "probe.db") + db, err := sql.Open("sqlite3", path) + if err != nil { + t.Fatalf("open raw: %v", err) + } + // A pre-V2.0.1 probe_meta: the original 16 columns only (shape + // copied from the V2.0.0 schema in git history). + if _, err := db.Exec(`CREATE TABLE probe_meta ( + agent_id INTEGER PRIMARY KEY, price REAL NOT NULL DEFAULT 0, + currency TEXT NOT NULL DEFAULT 'USD', billing_cycle TEXT NOT NULL DEFAULT 'monthly', + expires_at TEXT, traffic_limit INTEGER NOT NULL DEFAULT 0, + traffic_mode TEXT NOT NULL DEFAULT 'sum', traffic_reset_day INTEGER NOT NULL DEFAULT 1, + public INTEGER NOT NULL DEFAULT 1, sort INTEGER NOT NULL DEFAULT 0, + group_name TEXT NOT NULL DEFAULT '', public_remark TEXT NOT NULL DEFAULT '', + notify INTEGER NOT NULL DEFAULT 0, country_pin TEXT NOT NULL DEFAULT '', + ipv4_pin TEXT NOT NULL DEFAULT '', ipv6_pin TEXT NOT NULL DEFAULT '')`); err != nil { + t.Fatalf("create legacy table: %v", err) + } + if _, err := db.Exec(`INSERT INTO probe_meta (agent_id, group_name) VALUES (3, 'legacy')`); err != nil { + t.Fatalf("seed legacy row: %v", err) + } + _ = db.Close() + + st, err := Open(path) + if err != nil { + t.Fatalf("open legacy db: %v", err) + } + defer st.Close() + back, found, err := st.Meta(3) + if err != nil || !found { + t.Fatalf("meta read: found=%v err=%v", found, err) + } + if back.GroupName != "legacy" || back.TrafficCalUpGB != 0 || back.TrafficCalDownGB != 0 || back.StatIface != "" { + t.Errorf("legacy row after migration = %+v", back) + } + back.StatIface = "ens3" + if err := st.UpsertMeta(back); err != nil { + t.Fatalf("upsert after migration: %v", err) + } +} + +func TestTrafficCalibrationAppliedAndFloored(t *testing.T) { + st := newTestStore(t) + now := time.Now() + // Book 3 GB down / 1 GB up of counted traffic (baseline, delta). + book(t, st, 5, "k1/fp", 0, 0, now, nil, 0) + book(t, st, 5, "k1/fp", 3_000_000_000, 1_000_000_000, now.Add(time.Minute), nil, now.Unix()) + + meta := DefaultMeta(5) + meta.TrafficCalUpGB = -2 // correction exceeds the counted upload + meta.TrafficCalDownGB = 0.5 // half a GB the counters missed + if err := st.UpsertMeta(meta); err != nil { + t.Fatalf("upsert: %v", err) + } + + all, err := st.AllTraffic() + if err != nil { + t.Fatalf("AllTraffic: %v", err) + } + tr := all[5] + if tr.TotalRx != 3_500_000_000 || tr.MonthRx != 3_500_000_000 || tr.DayRx != 3_500_000_000 { + t.Errorf("rx figures after +0.5 GB calibration = %d/%d/%d, want 3500000000 each", tr.TotalRx, tr.MonthRx, tr.DayRx) + } + if tr.TotalTx != 0 || tr.MonthTx != 0 || tr.DayTx != 0 { + t.Errorf("tx figures after -2 GB calibration = %d/%d/%d, want 0 (floored)", tr.TotalTx, tr.MonthTx, tr.DayTx) + } + + one, found, err := st.TrafficFor(5) + if err != nil || !found { + t.Fatalf("TrafficFor: found=%v err=%v", found, err) + } + if one.TotalRx != tr.TotalRx || one.TotalTx != tr.TotalTx { + t.Errorf("TrafficFor disagrees with AllTraffic: %+v vs %+v", one, tr) + } +} diff --git a/internal/web/service/probe/store.go b/internal/web/service/probe/store.go index 79a4b8b..3bbab8f 100644 --- a/internal/web/service/probe/store.go +++ b/internal/web/service/probe/store.go @@ -65,7 +65,12 @@ CREATE TABLE IF NOT EXISTS probe_meta ( notify INTEGER NOT NULL DEFAULT 0, country_pin TEXT NOT NULL DEFAULT '', ipv4_pin TEXT NOT NULL DEFAULT '', - ipv6_pin TEXT NOT NULL DEFAULT '' + ipv6_pin TEXT NOT NULL DEFAULT '', + -- V2.0.1 probe batch: operator traffic calibration (GB, either + -- sign) and the counted-interface pin ('' = automatic selection). + traffic_cal_up_gb REAL NOT NULL DEFAULT 0, + traffic_cal_down_gb REAL NOT NULL DEFAULT 0, + stat_iface TEXT NOT NULL DEFAULT '' ); -- Monotonic byte counters that survive agent reboots and master @@ -204,9 +209,55 @@ func Open(path string) (*Store, error) { db.Close() return nil, fmt.Errorf("probe schema: %w", err) } + if err := migrateMetaColumns(db); err != nil { + db.Close() + return nil, fmt.Errorf("probe schema migration: %w", err) + } return &Store{db: db, nodes: make(map[int64]*nodeState)}, nil } +// metaColumnMigrations are the probe_meta columns added after the +// table's first release; CREATE TABLE IF NOT EXISTS never retrofits +// them onto an existing database, so they are added here, once, by +// name check (SQLite has no ADD COLUMN IF NOT EXISTS). +var metaColumnMigrations = []struct{ name, ddl string }{ + {"traffic_cal_up_gb", `ALTER TABLE probe_meta ADD COLUMN traffic_cal_up_gb REAL NOT NULL DEFAULT 0`}, + {"traffic_cal_down_gb", `ALTER TABLE probe_meta ADD COLUMN traffic_cal_down_gb REAL NOT NULL DEFAULT 0`}, + {"stat_iface", `ALTER TABLE probe_meta ADD COLUMN stat_iface TEXT NOT NULL DEFAULT ''`}, +} + +func migrateMetaColumns(db *sql.DB) error { + rows, err := db.Query(`PRAGMA table_info(probe_meta)`) + if err != nil { + return err + } + have := map[string]bool{} + for rows.Next() { + var cid int + var name, typ string + var notNull int + var dflt sql.NullString + var pk int + if err := rows.Scan(&cid, &name, &typ, ¬Null, &dflt, &pk); err != nil { + rows.Close() + return err + } + have[name] = true + } + if err := rows.Close(); err != nil { + return err + } + for _, m := range metaColumnMigrations { + if have[m.name] { + continue + } + if _, err := db.Exec(m.ddl); err != nil { + return err + } + } + return nil +} + // Close releases the database handle. func (s *Store) Close() error { if s == nil || s.db == nil { diff --git a/internal/web/service/probe/traffic.go b/internal/web/service/probe/traffic.go index bed6172..365eaa1 100644 --- a/internal/web/service/probe/traffic.go +++ b/internal/web/service/probe/traffic.go @@ -320,15 +320,53 @@ func (s *Store) TrafficFor(agentID int64) (Traffic, bool, error) { if err != nil { return Traffic{}, false, err } - return gate(t, dayStart, resetDay, time.Now()), true, nil + gated := gate(t, dayStart, resetDay, time.Now()) + if meta, ok, err := s.Meta(agentID); err != nil { + return Traffic{}, false, err + } else if ok { + gated = applyCalibration(gated, meta.TrafficCalUpGB, meta.TrafficCalDownGB) + } + return gated, true, nil } // AllTraffic returns every agent's ledger in one query, gated as in // TrafficFor — the bulk read port for B3's node list. +// calBytes converts a GB calibration value to bytes on the panel's +// 1 GB = 1e9 B convention (the same one the traffic limit uses). +func calBytes(gb float64) int64 { + return int64(math.Round(gb * 1e9)) +} + +// applyCalibration shifts a ledger row by the operator's per-node +// corrections (V2.0.1): uplink correction on every tx figure, +// downlink on every rx figure. A correction may exceed the counted +// value (a node re-imaged mid-period); figures floor at zero rather +// than going negative. +func applyCalibration(t Traffic, calUpGB, calDownGB float64) Traffic { + up, down := calBytes(calUpGB), calBytes(calDownGB) + if up == 0 && down == 0 { + return t + } + floor := func(v, delta int64) int64 { + if v += delta; v < 0 { + return 0 + } + return v + } + t.TotalTx = floor(t.TotalTx, up) + t.MonthTx = floor(t.MonthTx, up) + t.DayTx = floor(t.DayTx, up) + t.TotalRx = floor(t.TotalRx, down) + t.MonthRx = floor(t.MonthRx, down) + t.DayRx = floor(t.DayRx, down) + return t +} + func (s *Store) AllTraffic() (map[int64]Traffic, error) { rows, err := s.db.Query(` SELECT t.agent_id, t.total_rx, t.total_tx, t.month_rx, t.month_tx, t.month_start, - t.day_rx, t.day_tx, t.day_start, COALESCE(m.traffic_reset_day, 1) + t.day_rx, t.day_tx, t.day_start, COALESCE(m.traffic_reset_day, 1), + COALESCE(m.traffic_cal_up_gb, 0), COALESCE(m.traffic_cal_down_gb, 0) FROM probe_traffic t LEFT JOIN probe_meta m ON m.agent_id = t.agent_id`) if err != nil { return nil, err @@ -340,11 +378,12 @@ func (s *Store) AllTraffic() (map[int64]Traffic, error) { var id, resetDay int64 var t Traffic var dayStart string + var calUpGB, calDownGB float64 if err := rows.Scan(&id, &t.TotalRx, &t.TotalTx, &t.MonthRx, &t.MonthTx, &t.MonthStart, - &t.DayRx, &t.DayTx, &dayStart, &resetDay); err != nil { + &t.DayRx, &t.DayTx, &dayStart, &resetDay, &calUpGB, &calDownGB); err != nil { return nil, err } - out[id] = gate(t, dayStart, resetDay, now) + out[id] = applyCalibration(gate(t, dayStart, resetDay, now), calUpGB, calDownGB) } return out, rows.Err() } diff --git a/internal/web/service/probe_coverage.go b/internal/web/service/probe_coverage.go deleted file mode 100644 index bdc55a7..0000000 --- a/internal/web/service/probe_coverage.go +++ /dev/null @@ -1,57 +0,0 @@ -package service - -import ( - "time" - - "github.com/mhsanaei/3x-ui/v3/internal/database" - "github.com/mhsanaei/3x-ui/v3/internal/database/model" -) - -// ProbeCoverageRow is one agent's cf-probe retirement readiness -// (item 7 B6): whether its heartbeats carry the probe block the -// public page is fed from. The retirement checklist in the settings -// tab renders from these rows — the operator verifies every node is -// covered by the in-panel probe before standing the old cf-probe -// fleet down, node by node. -type ProbeCoverageRow struct { - Name string `json:"name"` - Online bool `json:"online"` - HasProbe bool `json:"hasProbe"` - ProbeAt int64 `json:"probeAt"` // unix ms of the last probe-carrying heartbeat, 0 = never -} - -// ProbeAgentCoverage lists every agent with its probe coverage. The -// display name follows the public view's rule (the bound node's name -// first, the agent's own name otherwise); online is the public -// view's 150s freshness window. -func ProbeAgentCoverage() []ProbeCoverageRow { - db := database.GetDB() - var agents []model.AgentNode - if err := db.Find(&agents).Error; err != nil { - return []ProbeCoverageRow{} - } - nodeNames := map[int]string{} - var nodes []model.Node - if err := db.Find(&nodes).Error; err == nil { - for _, n := range nodes { - nodeNames[n.Id] = n.Name - } - } - nowMs := time.Now().UnixMilli() - out := make([]ProbeCoverageRow, 0, len(agents)) - for _, a := range agents { - name := a.Name - if a.NodeID != nil { - if bound, ok := nodeNames[*a.NodeID]; ok && bound != "" { - name = bound - } - } - out = append(out, ProbeCoverageRow{ - Name: name, - Online: a.LastSeen > 0 && nowMs-a.LastSeen <= 150_000, - HasProbe: a.ProbeAt > 0, - ProbeAt: a.ProbeAt, - }) - } - return out -} diff --git a/internal/web/service/probe_public.go b/internal/web/service/probe_public.go index 222eade..6b2cc1e 100644 --- a/internal/web/service/probe_public.go +++ b/internal/web/service/probe_public.go @@ -6,6 +6,7 @@ import ( "encoding/json" "net" "sort" + "strconv" "strings" "sync" "time" @@ -56,6 +57,25 @@ func setProbeSetting(key, value string) error { return db.Save(&row).Error } +// ProbeFastIntervalSec is the agent fast-report cadence in seconds +// (V2.0.1 probe batch): runtime metrics ride a lightweight report at +// this interval instead of the 60 s heartbeat. Default 3, operator +// range 1–60 (setting key probe_fast_interval). +func ProbeFastIntervalSec() int { + v, ok := probeSetting("probe_fast_interval") + if !ok { + return 3 + } + n, err := strconv.Atoi(strings.TrimSpace(v)) + if err != nil || n < 1 { + return 3 + } + if n > 60 { + return 60 + } + return n +} + // ProbePublicPageEnabled reports the public-page master switch. Per // the item-7 scope decision the page ships OFF until the operator // turns it on (setting public_page = "on"); the hub's own default is diff --git a/internal/web/service/probe_theme_store.go b/internal/web/service/probe_theme_store.go index cffd6fa..13ef141 100644 --- a/internal/web/service/probe_theme_store.go +++ b/internal/web/service/probe_theme_store.go @@ -17,9 +17,12 @@ import ( "path" "path/filepath" "sort" + "strconv" "strings" "syscall" "unicode/utf8" + + "github.com/mhsanaei/3x-ui/v3/internal/web/service/probe" ) // Item 7 B5: the theme store (hub frontend.rs + api.rs theme @@ -716,23 +719,31 @@ func SetProbeIconSetting(key, value string) error { return setProbeSetting(key, value) } -// ProbePublicSettings is the public-surface settings block the theme -// tab edits (settings KV keys, hub names). +// ProbePublicSettings is the public-surface settings block the probe +// settings page edits (settings KV keys, hub names). RetentionDays +// and FastIntervalSec joined in V2.0.1: the retention window already +// existed as the retention_days key (read by the probe store's +// housekeeping), it only lacked an editing surface; the fast-report +// interval drives the agents' lightweight metrics cadence. type ProbePublicSettings struct { - PublicPage bool `json:"publicPage"` - SiteName string `json:"siteName"` - Site string `json:"site"` - GitHubProxy string `json:"githubProxy"` + PublicPage bool `json:"publicPage"` + SiteName string `json:"siteName"` + Site string `json:"site"` + GitHubProxy string `json:"githubProxy"` + RetentionDays int64 `json:"retentionDays"` + FastIntervalSec int `json:"fastIntervalSec"` } // GetProbePublicSettings reads the settings block. func GetProbePublicSettings() ProbePublicSettings { proxy, _ := probeSetting("github_proxy") return ProbePublicSettings{ - PublicPage: ProbePublicPageEnabled(), - SiteName: ProbeSiteName(), - Site: ProbeSiteURL(), - GitHubProxy: proxy, + PublicPage: ProbePublicPageEnabled(), + SiteName: ProbeSiteName(), + Site: ProbeSiteURL(), + GitHubProxy: proxy, + RetentionDays: probe.RetentionDays(), + FastIntervalSec: ProbeFastIntervalSec(), } } @@ -742,6 +753,12 @@ func SetProbePublicSettings(s ProbePublicSettings) error { if s.GitHubProxy != "" && !strings.HasPrefix(s.GitHubProxy, "https://") { return themeRefuse("GitHub 代理要以 https:// 开头,或者留空") } + if s.RetentionDays < 1 || s.RetentionDays > 365 { + return themeRefuse("历史保留天数要在 1 到 365 之间") + } + if s.FastIntervalSec < 1 || s.FastIntervalSec > 60 { + return themeRefuse("快报间隔要在 1 到 60 秒之间") + } if err := SetProbePublicPageEnabled(s.PublicPage); err != nil { return err } @@ -751,6 +768,12 @@ func SetProbePublicSettings(s ProbePublicSettings) error { if err := setProbeSetting("site", s.Site); err != nil { return err } + if err := setProbeSetting("retention_days", strconv.FormatInt(s.RetentionDays, 10)); err != nil { + return err + } + if err := setProbeSetting("probe_fast_interval", strconv.Itoa(s.FastIntervalSec)); err != nil { + return err + } return setProbeSetting("github_proxy", s.GitHubProxy) } diff --git a/internal/web/service/singbox.go b/internal/web/service/singbox.go index 124815f..ede978b 100644 --- a/internal/web/service/singbox.go +++ b/internal/web/service/singbox.go @@ -106,6 +106,15 @@ func (s *SingboxService) GetSingboxConfigForAgent(nodeID int, singboxOutboundsJS if len(fragment) == 0 { continue } + // V2.0.1 ⓪: the row's listen/port are authoritative at + // generation time. The stored fragment is a snapshot (taken + // at migration or at the last save's recompute) and drifts + // whenever the row was edited through a path that did not + // recompute it — V2.0.0's converter also omitted "listen" + // entirely for an empty row listen, which sing-box reads as + // loopback-only, silently unbinding migrated inbounds from + // the network. The row always wins here. + applyRowListenOverride(inbound, fragment) preserved := singbox.Preserved{} if strings.TrimSpace(inbound.SingboxPreserved) != "" { if err := json.Unmarshal([]byte(inbound.SingboxPreserved), &preserved); err != nil { @@ -140,6 +149,32 @@ func (s *SingboxService) GetSingboxConfigForAgent(nodeID int, singboxOutboundsJS }), nil } +// applyRowListenOverride stamps the inbound row's listen address and +// port onto a sing-box fragment, replacing whatever the snapshot +// carried (V2.0.1 ⓪). An empty row listen means "all interfaces" in +// the panel's model, which sing-box spells "::" (dual-stack +// wildcard); an absent listen field would default to loopback-only, +// so the wildcard is always written explicitly. A specific row +// listen (one IPv6 address, an internal address, ...) is carried +// verbatim — sing-box then binds exactly that address. WireGuard +// endpoints take no listen address, only the port. +func applyRowListenOverride(inbound *model.Inbound, fragment map[string]any) { + if fragment["type"] == "wireguard" { + if inbound.Port > 0 { + fragment["listen_port"] = inbound.Port + } + return + } + if inbound.Listen != "" { + fragment["listen"] = inbound.Listen + } else { + fragment["listen"] = "::" + } + if inbound.Port > 0 { + fragment["listen_port"] = inbound.Port + } +} + // filterEnabledClients applies the xray generation path's client // gates, in the same order and with the same log line: a client whose // traffic row is disabled (expiration or traffic limit) is dropped, diff --git a/internal/web/service/singbox_fragment.go b/internal/web/service/singbox_fragment.go new file mode 100644 index 0000000..60e98da --- /dev/null +++ b/internal/web/service/singbox_fragment.go @@ -0,0 +1,133 @@ +package service + +import ( + "encoding/json" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/logger" + "github.com/mhsanaei/3x-ui/v3/internal/singbox" +) + +// RecomputeSingboxFragment re-runs the sing-box converter over an +// inbound row's current panel material and stores the fresh fragment +// + preserved sidecar (V2.0.1 ⓪). Before this, the fragment was a +// one-shot migration snapshot: panel edits after the migration (a new +// Reality target, a changed listen/port, ...) never reached it, so +// the agent kept serving the migration-day material while the panel +// showed the edit as saved. The identity layer is read back after the +// caller's own sync, so the fragment's embedded users match what +// generation would project anyway; the row listen/port override is +// stamped on top exactly as generation applies it. +func (s *SingboxService) RecomputeSingboxFragment(inbound *model.Inbound) error { + attached, err := s.inboundService.clientService.ListForInbound(nil, inbound.Id) + if err != nil { + return err + } + clients := toSingboxClients(attached) + fillConverterOnlyClientFields(inbound, attached, clients) + + fallbackMaps, err := s.inboundService.fallbackService.BuildFallbacksJSON(nil, inbound.Id) + if err != nil { + return err + } + fallbacks := make([]json.RawMessage, 0, len(fallbackMaps)) + for _, fm := range fallbackMaps { + raw, err := json.Marshal(fm) + if err != nil { + return err + } + fallbacks = append(fallbacks, raw) + } + + res, err := singbox.ConvertInbound(singbox.Inbound{ + Listen: inbound.Listen, + Port: inbound.Port, + Protocol: string(inbound.Protocol), + Tag: inbound.Tag, + DisableFlow: inbound.DisableFlow, + Settings: json.RawMessage(inbound.Settings), + StreamSettings: json.RawMessage(inbound.StreamSettings), + Sniffing: json.RawMessage(inbound.Sniffing), + Fallbacks: fallbacks, + Clients: clients, + }) + if err != nil { + return err + } + fragJSON, err := res.MarshalFragment() + if err != nil { + return err + } + if fragJSON != "" { + fragment := map[string]any{} + if err := json.Unmarshal([]byte(fragJSON), &fragment); err != nil { + return err + } + applyRowListenOverride(inbound, fragment) + raw, err := json.Marshal(fragment) + if err != nil { + return err + } + fragJSON = string(raw) + } + presJSON, err := res.MarshalPreserved() + if err != nil { + return err + } + return database.GetDB().Model(&model.Inbound{}).Where("id = ?", inbound.Id).Updates(map[string]any{ + "singbox_config": fragJSON, "singbox_preserved": presJSON, + }).Error +} + +// fillConverterOnlyClientFields restores the client fields the +// identity projection (toSingboxClients) does not carry but the +// converter reads: VMess alterId lives only in the settings.clients +// mirror (the identity layer has no column for it — the B1 migrator +// sourced it from the same mirror), and a VLESS client's reverse +// block reaches the identity projection as a parsed tag, re-rendered +// here in the converter's verbatim-JSON shape. +func fillConverterOnlyClientFields(inbound *model.Inbound, attached []model.Client, clients []singbox.Client) { + alterByEmail := map[string]int{} + var settings struct { + Clients []struct { + Email string `json:"email"` + AlterID int `json:"alterId"` + } `json:"clients"` + } + if err := json.Unmarshal([]byte(inbound.Settings), &settings); err == nil { + for _, c := range settings.Clients { + if c.AlterID != 0 { + alterByEmail[c.Email] = c.AlterID + } + } + } + reverseByEmail := map[string]string{} + for _, c := range attached { + if c.Reverse != nil && c.Reverse.Tag != "" { + reverseByEmail[c.Email] = c.Reverse.Tag + } + } + for i := range clients { + if alt, ok := alterByEmail[clients[i].Email]; ok { + clients[i].AlterID = alt + } + if tag, ok := reverseByEmail[clients[i].Email]; ok { + if raw, err := json.Marshal(map[string]string{"tag": tag}); err == nil { + clients[i].Reverse = raw + } + } + } +} + +// recomputeSingboxFragmentLogged is the save-path hook: a failed +// recompute must not fail the panel save itself (the row is already +// persisted and generation still overrides listen/port from it), but +// it is logged loudly — a silent skip here is exactly how the +// V2.0.0 fragment drift stayed invisible. +func recomputeSingboxFragmentLogged(inbound *model.Inbound) { + svc := &SingboxService{inboundService: InboundService{}} + if err := svc.RecomputeSingboxFragment(inbound); err != nil { + logger.Warningf("sing-box fragment recompute for inbound %d (%s) failed: %v", inbound.Id, inbound.Tag, err) + } +} diff --git a/internal/web/service/singbox_listen_e2e_test.go b/internal/web/service/singbox_listen_e2e_test.go new file mode 100644 index 0000000..4c160ad --- /dev/null +++ b/internal/web/service/singbox_listen_e2e_test.go @@ -0,0 +1,205 @@ +package service + +import ( + "encoding/json" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" +) + +// V2.0.1 ⓪ end-to-end regression for the loopback blind spot that +// let the V2 defect ship: a generated config is run under a real +// sing-box binary and connected to through the machine's +// NON-loopback address, not just 127.0.0.1 (the B5 drill connected +// loopback-only and never saw that an absent listen field binds +// loopback in sing-box). The negative control — the same config +// with the listen key removed, the pre-fix shape — must come up +// bound to loopback in the core's own start line, proving the test +// can actually catch the defect. +// +// Bind scope is asserted from the core's "tcp server started at" +// log line: this sandbox delivers connections to any local address +// (loopback-bound python http.server included) when dialing the +// machine's own IP, so a dial alone cannot discriminate bind scope +// here — on a real node it does, which is exactly how the defect +// presented in production (external clients timed out while ss +// showed the truth). The dials remain as reachability smoke for +// both addresses. +// +// Gated on SINGBOX_E2E_BIN pointing at a sing-box binary (the +// self-built v1.13.21 release build in production); skipped +// otherwise so the unit suite stays hermetic. +func TestSingboxListenNonLoopbackE2E(t *testing.T) { + bin := os.Getenv("SINGBOX_E2E_BIN") + if bin == "" { + t.Skip("SINGBOX_E2E_BIN not set") + } + lanIP := firstNonLoopbackIPv4(t) + + setupBulkDB(t) + nodeID := 43 + node := &model.Node{Id: nodeID, Name: "e2e-node", Address: "192.0.2.1", Port: 8443, Enable: true, Status: "offline"} + if err := database.GetDB().Create(node).Error; err != nil { + t.Fatalf("create node: %v", err) + } + clients := []model.Client{{Email: "e2e@x", ID: "11111111-1111-1111-1111-111111111111", SubID: "se", Enable: true}} + inbound := &model.Inbound{ + Remark: "listen-e2e", + Enable: true, + Protocol: model.VLESS, + Port: 29871, + Listen: "", + Tag: "inbound-29871", + NodeID: &nodeID, + Settings: clientsSettings(t, clients), + StreamSettings: `{"network":"tcp","security":"none"}`, + } + if err := database.GetDB().Create(inbound).Error; err != nil { + t.Fatalf("create inbound: %v", err) + } + if err := (&ClientService{}).SyncInbound(nil, inbound.Id, clients); err != nil { + t.Fatalf("seed client linkage: %v", err) + } + + // The V2.0.1 save path computes the fragment; generation then + // serves it. This is the exact chain a panel edit travels. + singboxSvc := &SingboxService{} + if err := singboxSvc.RecomputeSingboxFragment(inbound); err != nil { + t.Fatalf("RecomputeSingboxFragment: %v", err) + } + cfg, err := singboxSvc.GetSingboxConfigForAgent(nodeID, "", "") + if err != nil { + t.Fatalf("GetSingboxConfigForAgent: %v", err) + } + frag := firstInboundFrag(t, cfg) + if frag["listen"] != "::" { + t.Fatalf("generated listen = %v, want ::", frag["listen"]) + } + + dir := t.TempDir() + runAndDial := func(t *testing.T, name string, cfg map[string]any, port int) (loopOK, lanOK bool, startedAt string) { + t.Helper() + raw, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("marshal config: %v", err) + } + cfgPath := filepath.Join(dir, fmt.Sprintf("cfg-%s.json", name)) + if err := os.WriteFile(cfgPath, raw, 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + if out, err := exec.Command(bin, "check", "-c", cfgPath).CombinedOutput(); err != nil { + t.Fatalf("sing-box check: %v\n%s", err, out) + } + logPath := filepath.Join(dir, fmt.Sprintf("run-%s.log", name)) + logFile, _ := os.Create(logPath) + cmd := exec.Command(bin, "run", "-c", cfgPath) + cmd.Stdout, cmd.Stderr = logFile, logFile + if err := cmd.Start(); err != nil { + t.Fatalf("start sing-box: %v", err) + } + defer func() { + _ = cmd.Process.Kill() + _, _ = cmd.Process.Wait() + _ = logFile.Close() + }() + dial := func(addr string) bool { + conn, err := net.DialTimeout("tcp", addr, 500*time.Millisecond) + if err != nil { + return false + } + _ = conn.Close() + return true + } + loopAddr := fmt.Sprintf("127.0.0.1:%d", port) + lanAddr := fmt.Sprintf("%s:%d", lanIP, port) + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + loopOK = dial(loopAddr) + lanOK = dial(lanAddr) + if loopOK && lanOK { + break + } + time.Sleep(200 * time.Millisecond) + } + // The core's own start line is the bind-scope evidence. + time.Sleep(300 * time.Millisecond) + logBytes, _ := os.ReadFile(logPath) + for _, line := range strings.Split(string(logBytes), "\n") { + if strings.Contains(line, "tcp server started at") { + startedAt = strings.TrimSpace(line) + } + } + return loopOK, lanOK, startedAt + } + + // Positive: the generated config answers on both addresses and + // the core reports a wildcard bind. + loopOK, lanOK, startedAt := runAndDial(t, "gen", cfg, 29871) + if !loopOK { + t.Error("generated config does not answer on 127.0.0.1:29871") + } + if !lanOK { + t.Errorf("generated config does not answer on the non-loopback address %s:29871", lanIP) + } + if !strings.Contains(startedAt, "tcp server started at [::]:29871") { + t.Errorf("generated config bind line = %q, want the core started at [::]:29871", startedAt) + } + + // Negative control: strip the listen key (the pre-fix fragment + // shape). The core must report a loopback bind — the exact + // production defect — while the fixed config above does not. + raw, _ := json.Marshal(cfg) + var control map[string]any + if err := json.Unmarshal(raw, &control); err != nil { + t.Fatalf("clone config: %v", err) + } + delete(firstInboundFrag(t, control), "listen") + loopOK, _, startedAt = runAndDial(t, "ctl", control, 29871) + if !loopOK { + t.Error("control config lost loopback too — the negative control is not isolating the listen behaviour") + } + if !strings.Contains(startedAt, "tcp server started at 127.0.0.1:29871") { + t.Errorf("control bind line = %q, want the core started at 127.0.0.1:29871 (the pre-fix loopback default)", startedAt) + } +} + +func firstInboundFrag(t *testing.T, cfg map[string]any) map[string]any { + t.Helper() + inbounds, _ := cfg["inbounds"].([]any) + if len(inbounds) == 0 { + t.Fatalf("generated config has no inbounds: %v", cfg) + } + frag, _ := inbounds[0].(map[string]any) + if frag == nil { + t.Fatalf("generated inbound is not an object: %v", inbounds[0]) + } + return frag +} + +func firstNonLoopbackIPv4(t *testing.T) string { + t.Helper() + addrs, err := net.InterfaceAddrs() + if err != nil { + t.Fatalf("interface addrs: %v", err) + } + for _, a := range addrs { + ipNet, ok := a.(*net.IPNet) + if !ok { + continue + } + ip := ipNet.IP.To4() + if ip != nil && !ip.IsLoopback() { + return ip.String() + } + } + t.Skip("no non-loopback IPv4 on this machine") + return "" +} diff --git a/internal/web/service/singbox_listen_test.go b/internal/web/service/singbox_listen_test.go new file mode 100644 index 0000000..7d9e2be --- /dev/null +++ b/internal/web/service/singbox_listen_test.go @@ -0,0 +1,206 @@ +package service + +import ( + "encoding/json" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" +) + +// V2.0.1 ⓪: the row's listen/port rule the generated sing-box config. +// These tests pin the override function itself, the generation-time +// application over a stale stored fragment, and the save-time +// fragment recompute wired into UpdateInbound. + +func TestApplyRowListenOverride(t *testing.T) { + cases := []struct { + name string + row *model.Inbound + fragment map[string]any + wantListen any // nil = key must be absent + wantPort float64 + }{ + { + name: "empty row listen binds the dual-stack wildcard", + row: &model.Inbound{Listen: "", Port: 8443}, + fragment: map[string]any{"type": "vless", "tag": "in-1"}, + wantListen: "::", + wantPort: 8443, + }, + { + name: "specific IPv4 listen is kept verbatim", + row: &model.Inbound{Listen: "192.0.2.10", Port: 443}, + fragment: map[string]any{"type": "vless", "tag": "in-1", "listen": "0.0.0.0"}, + wantListen: "192.0.2.10", + wantPort: 443, + }, + { + name: "specific IPv6 listen is kept verbatim", + row: &model.Inbound{Listen: "2001:db8::5", Port: 443}, + fragment: map[string]any{"type": "shadowsocks", "tag": "in-1"}, + wantListen: "2001:db8::5", + wantPort: 443, + }, + { + name: "row port overrides a stale fragment port", + row: &model.Inbound{Listen: "", Port: 9999}, + fragment: map[string]any{"type": "vless", "tag": "in-1", "listen_port": float64(1111)}, + wantListen: "::", + wantPort: 9999, + }, + { + name: "wireguard endpoint gains no listen key, port only", + row: &model.Inbound{Listen: "192.0.2.10", Port: 51820}, + fragment: map[string]any{"type": "wireguard", "tag": "in-1"}, + wantListen: nil, + wantPort: 51820, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + applyRowListenOverride(tc.row, tc.fragment) + got, present := tc.fragment["listen"] + if tc.wantListen == nil { + if present { + t.Errorf("listen = %v, want the key absent", got) + } + } else if !present || got != tc.wantListen { + t.Errorf("listen = %v (present %v), want %v", got, present, tc.wantListen) + } + if p := numOf(tc.fragment["listen_port"]); p != tc.wantPort { + t.Errorf("listen_port = %v, want %v", tc.fragment["listen_port"], tc.wantPort) + } + }) + } +} + +// TestSingboxGenerationAppliesRowListenOverStaleFragment is the +// regression shape of the V2 defect itself: a stored fragment +// without a listen key (the migration-era snapshot) must reach the +// agent bound to the wildcard, on the row's current port — not +// sing-box's loopback default on a stale port. +func TestSingboxGenerationAppliesRowListenOverStaleFragment(t *testing.T) { + setupBulkDB(t) + nodeID := 41 + inbound := &model.Inbound{ + Remark: "listen-regression", + Enable: true, + Protocol: model.VLESS, + Port: 47158, + Tag: "inbound-47158", + NodeID: &nodeID, + // Migration-era snapshot: no listen key, stale port. + SingboxConfig: `{"type":"vless","tag":"inbound-47158","listen_port":1111,` + + `"users":[{"name":"a@example.com","uuid":"00000000-0000-0000-0000-000000000001"}]}`, + } + if err := database.GetDB().Create(inbound).Error; err != nil { + t.Fatalf("create inbound: %v", err) + } + + svc := &SingboxService{} + cfg, err := svc.GetSingboxConfigForAgent(nodeID, "", "") + if err != nil { + t.Fatalf("GetSingboxConfigForAgent: %v", err) + } + inbounds, _ := cfg["inbounds"].([]any) + if len(inbounds) != 1 { + t.Fatalf("generated inbounds = %d, want 1", len(inbounds)) + } + frag, _ := inbounds[0].(map[string]any) + if frag["listen"] != "::" { + t.Errorf("generated listen = %v, want \"::\" (an absent listen binds loopback only in sing-box)", frag["listen"]) + } + if numOf(frag["listen_port"]) != 47158 { + t.Errorf("generated listen_port = %v, want the row port 47158", frag["listen_port"]) + } +} + +// numOf reads a JSON-ish numeric (int / float64 / json.Number) +// without caring which shape the producer used. +func numOf(v any) float64 { + switch n := v.(type) { + case int: + return float64(n) + case int64: + return float64(n) + case float64: + return n + case json.Number: + f, _ := n.Float64() + return f + } + return -1 +} + +// TestRecomputeSingboxFragmentOnUpdate drives the save path: a row +// edited in the panel (listen + port changed) must have its stored +// fragment recomputed so the next agent poll serves the new binding. +func TestRecomputeSingboxFragmentOnUpdate(t *testing.T) { + setupBulkDB(t) + nodeID := 42 + node := &model.Node{Id: nodeID, Name: "listen-node", Address: "192.0.2.1", Port: 8443, Enable: true, Status: "offline"} + if err := database.GetDB().Create(node).Error; err != nil { + t.Fatalf("create node: %v", err) + } + clients := []model.Client{{Email: "u@x", ID: "11111111-1111-1111-1111-111111111111", SubID: "s1", Enable: true}} + inbound := &model.Inbound{ + Remark: "recompute", + Enable: true, + Protocol: model.VLESS, + Port: 23456, + Listen: "", + Tag: "inbound-23456", + NodeID: &nodeID, + Settings: clientsSettings(t, clients), + StreamSettings: `{"network":"tcp","security":"none"}`, + } + if err := database.GetDB().Create(inbound).Error; err != nil { + t.Fatalf("create inbound: %v", err) + } + // The identity layer the converter reads is the client linkage, + // seeded the way the client flows seed it. + if err := (&ClientService{}).SyncInbound(nil, inbound.Id, clients); err != nil { + t.Fatalf("seed client linkage: %v", err) + } + + singboxSvc := &SingboxService{} + if err := singboxSvc.RecomputeSingboxFragment(inbound); err != nil { + t.Fatalf("RecomputeSingboxFragment: %v", err) + } + frag := storedFragment(t, inbound.Id) + if frag["listen"] != "::" || numOf(frag["listen_port"]) != 23456 { + t.Fatalf("fragment after first recompute: listen=%v port=%v, want ::/23456", frag["listen"], frag["listen_port"]) + } + if users, _ := frag["users"].([]any); len(users) != 1 { + t.Fatalf("fragment users = %v, want the row's one client", frag["users"]) + } + + // Panel edit: bind a specific address and move the port. + edited := *inbound + edited.Listen = "192.0.2.10" + edited.Port = 23457 + if _, _, err := (&InboundService{}).UpdateInbound(&edited); err != nil { + t.Fatalf("UpdateInbound: %v", err) + } + frag = storedFragment(t, inbound.Id) + if frag["listen"] != "192.0.2.10" || numOf(frag["listen_port"]) != 23457 { + t.Errorf("fragment after update: listen=%v port=%v, want 192.0.2.10/23457", frag["listen"], frag["listen_port"]) + } +} + +func storedFragment(t *testing.T, inboundID int) map[string]any { + t.Helper() + var row model.Inbound + if err := database.GetDB().First(&row, inboundID).Error; err != nil { + t.Fatalf("reload inbound: %v", err) + } + if row.SingboxConfig == "" { + t.Fatalf("stored singbox_config is empty") + } + frag := map[string]any{} + if err := json.Unmarshal([]byte(row.SingboxConfig), &frag); err != nil { + t.Fatalf("unmarshal stored fragment: %v", err) + } + return frag +} diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index ac7a9b0..3e7e6da 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -856,7 +856,7 @@ "uptime": "مدة التشغيل", "latency": "الكمون", "lastHeartbeat": "آخر نبضة", - "xrayVersion": "إصدار Xray", + "coreVersion": "Core version", "panelVersion": "إصدار اللوحة", "actions": "العمليات", "probe": "فحص فوري", @@ -971,6 +971,12 @@ "countryPin": "Country pin (e.g. JP)", "ipv4Pin": "IPv4 pin", "ipv6Pin": "IPv6 pin", + "calUp": "Uplink correction (GB)", + "calDown": "Downlink correction (GB)", + "calHint": "Corrections are added to the counted traffic (negative values subtract) and apply to panel statistics, the public page and traffic alerts.", + "statIface": "Counted interface", + "movedHint": "Probe metadata (pricing, traffic limits, calibration…) is edited on the probe settings page.", + "goSettings": "Open probe settings", "save": "Save status page info", "saved": "Status page info saved", "saveFailed": "Saving status page info failed" @@ -1000,7 +1006,13 @@ "saved": "Probe task saved", "deleted": "Probe task deleted", "loadFailed": "Failed to load probe tasks", - "saveFailed": "Operation failed" + "saveFailed": "Operation failed", + "batchTitle": "Batch add", + "batchPlaceholder": "One target per line, or comma-separated — host:port, e.g. 1.1.1.1:443", + "batchSubmit": "Add all", + "batchHint": "The shared fields apply to every target; leave the name empty to name each task after its target.", + "batchEmpty": "Enter at least one target", + "batchDone": "Added {{count}} probe tasks" } }, "settings": { @@ -1034,6 +1046,15 @@ "alreadyLatest": "Already the latest version", "config": "Theme settings" }, + "probe": { + "title": "Probe settings", + "retentionDays": "History retention (days)", + "retentionHint": "How long probe history (metrics and latency) is kept.", + "fastInterval": "Fast report interval", + "fastIntervalHint": "How often agents report runtime metrics; everything else still rides the 60 s heartbeat.", + "metaTitle": "Per-node probe metadata", + "metaNodePlaceholder": "Select a node" + }, "probeNotify": { "notifyTitle": "Notifications", "notifyHint": "Offline, traffic and expiry alerts, delivered to the channels below (Telegram reuses the panel bot settings).", @@ -1057,23 +1078,11 @@ "clearSecret": "Clear", "templateHint": "Placeholders: {{title}} {{message}} {{node}} {{event}} {{time}} {{count}} {{site}}", "testSend": "Send a test alert", - "testSent": "Test alert sent: {{channels}}", - "retireTitle": "cf-probe retirement checklist", - "retireHint": "Only once every node is served by the panel may the old public page be switched off, in the order given in the deployment notes.", - "retirePublicPage": "Public page", - "retirePublicPageOff": "Off (enable it above)", - "retireHasProbe": "Probe reporting", - "retireNoProbe": "No probe data", - "retireOnline": "Online", - "retireOffline": "Offline", - "retireProbeAt": "Last report {{time}}", - "retireAllReady": "Every node is served by the panel; cf-probe can be retired in the documented order.", - "retireNotReady": "Some nodes have no probe data yet; do not take cf-probe down." + "testSent": "Test alert sent: {{channels}}" }, "defaultTag": "افتراضي", "title": "إعدادات البانل", "save": "حفظ", - "infoDesc": "كل تغيير هتعمله هنا لازم يتخزن. ياريت تعيد تشغيل البانل عشان التعديلات تتفعل.", "restartPanel": "إعادة تشغيل اللوحة", "restartPanelDesc": "متأكد إنك عايز تعيد تشغيل البانل؟ لو ماقدرتش تدخل بعد إعادة التشغيل، شوف سجل البانل على السيرفر.", "restartPanelSuccess": "تم إعادة تشغيل اللوحة بنجاح", diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index 2bf99bb..718e1a1 100644 --- a/internal/web/translation/en-US.json +++ b/internal/web/translation/en-US.json @@ -974,7 +974,7 @@ "uptime": "Uptime", "latency": "Latency", "lastHeartbeat": "Last Heartbeat", - "xrayVersion": "Xray Version", + "coreVersion": "Core version", "panelVersion": "Panel Version", "actions": "Actions", "probe": "Probe Now", @@ -1089,6 +1089,12 @@ "countryPin": "Country pin (e.g. JP)", "ipv4Pin": "IPv4 pin", "ipv6Pin": "IPv6 pin", + "calUp": "Uplink correction (GB)", + "calDown": "Downlink correction (GB)", + "calHint": "Corrections are added to the counted traffic (negative values subtract) and apply to panel statistics, the public page and traffic alerts.", + "statIface": "Counted interface", + "movedHint": "Probe metadata (pricing, traffic limits, calibration…) is edited on the probe settings page.", + "goSettings": "Open probe settings", "save": "Save status page info", "saved": "Status page info saved", "saveFailed": "Saving status page info failed" @@ -1118,7 +1124,13 @@ "saved": "Probe task saved", "deleted": "Probe task deleted", "loadFailed": "Failed to load probe tasks", - "saveFailed": "Operation failed" + "saveFailed": "Operation failed", + "batchTitle": "Batch add", + "batchPlaceholder": "One target per line, or comma-separated — host:port, e.g. 1.1.1.1:443", + "batchSubmit": "Add all", + "batchHint": "The shared fields apply to every target; leave the name empty to name each task after its target.", + "batchEmpty": "Enter at least one target", + "batchDone": "Added {{count}} probe tasks" } }, "settings": { @@ -1152,6 +1164,15 @@ "alreadyLatest": "Already the latest version", "config": "Theme settings" }, + "probe": { + "title": "Probe settings", + "retentionDays": "History retention (days)", + "retentionHint": "How long probe history (metrics and latency) is kept.", + "fastInterval": "Fast report interval", + "fastIntervalHint": "How often agents report runtime metrics; everything else still rides the 60 s heartbeat.", + "metaTitle": "Per-node probe metadata", + "metaNodePlaceholder": "Select a node" + }, "probeNotify": { "notifyTitle": "Notifications", "notifyHint": "Offline, traffic and expiry alerts, delivered to the channels below (Telegram reuses the panel bot settings).", @@ -1175,23 +1196,11 @@ "clearSecret": "Clear", "templateHint": "Placeholders: {{title}} {{message}} {{node}} {{event}} {{time}} {{count}} {{site}}", "testSend": "Send a test alert", - "testSent": "Test alert sent: {{channels}}", - "retireTitle": "cf-probe retirement checklist", - "retireHint": "Only once every node is served by the panel may the old public page be switched off, in the order given in the deployment notes.", - "retirePublicPage": "Public page", - "retirePublicPageOff": "Off (enable it above)", - "retireHasProbe": "Probe reporting", - "retireNoProbe": "No probe data", - "retireOnline": "Online", - "retireOffline": "Offline", - "retireProbeAt": "Last report {{time}}", - "retireAllReady": "Every node is served by the panel; cf-probe can be retired in the documented order.", - "retireNotReady": "Some nodes have no probe data yet; do not take cf-probe down." + "testSent": "Test alert sent: {{channels}}" }, "defaultTag": "Default", "title": "Panel Settings", "save": "Save", - "infoDesc": "Every change made here needs to be saved. Please restart the panel to apply changes.", "restartPanel": "Restart Panel", "restartPanelDesc": "Are you sure you want to restart the panel? If you cannot access the panel after restarting, please view the panel log info on the server.", "restartPanelSuccess": "The panel was successfully restarted.", diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index 756cc77..b2a0e0c 100644 --- a/internal/web/translation/es-ES.json +++ b/internal/web/translation/es-ES.json @@ -856,7 +856,7 @@ "uptime": "Tiempo activo", "latency": "Latencia", "lastHeartbeat": "Último latido", - "xrayVersion": "Versión de Xray", + "coreVersion": "Core version", "panelVersion": "Versión del panel", "actions": "Acciones", "probe": "Sondear ahora", @@ -971,6 +971,12 @@ "countryPin": "Country pin (e.g. JP)", "ipv4Pin": "IPv4 pin", "ipv6Pin": "IPv6 pin", + "calUp": "Uplink correction (GB)", + "calDown": "Downlink correction (GB)", + "calHint": "Corrections are added to the counted traffic (negative values subtract) and apply to panel statistics, the public page and traffic alerts.", + "statIface": "Counted interface", + "movedHint": "Probe metadata (pricing, traffic limits, calibration…) is edited on the probe settings page.", + "goSettings": "Open probe settings", "save": "Save status page info", "saved": "Status page info saved", "saveFailed": "Saving status page info failed" @@ -1000,7 +1006,13 @@ "saved": "Probe task saved", "deleted": "Probe task deleted", "loadFailed": "Failed to load probe tasks", - "saveFailed": "Operation failed" + "saveFailed": "Operation failed", + "batchTitle": "Batch add", + "batchPlaceholder": "One target per line, or comma-separated — host:port, e.g. 1.1.1.1:443", + "batchSubmit": "Add all", + "batchHint": "The shared fields apply to every target; leave the name empty to name each task after its target.", + "batchEmpty": "Enter at least one target", + "batchDone": "Added {{count}} probe tasks" } }, "settings": { @@ -1034,6 +1046,15 @@ "alreadyLatest": "Already the latest version", "config": "Theme settings" }, + "probe": { + "title": "Probe settings", + "retentionDays": "History retention (days)", + "retentionHint": "How long probe history (metrics and latency) is kept.", + "fastInterval": "Fast report interval", + "fastIntervalHint": "How often agents report runtime metrics; everything else still rides the 60 s heartbeat.", + "metaTitle": "Per-node probe metadata", + "metaNodePlaceholder": "Select a node" + }, "probeNotify": { "notifyTitle": "Notifications", "notifyHint": "Offline, traffic and expiry alerts, delivered to the channels below (Telegram reuses the panel bot settings).", @@ -1057,23 +1078,11 @@ "clearSecret": "Clear", "templateHint": "Placeholders: {{title}} {{message}} {{node}} {{event}} {{time}} {{count}} {{site}}", "testSend": "Send a test alert", - "testSent": "Test alert sent: {{channels}}", - "retireTitle": "cf-probe retirement checklist", - "retireHint": "Only once every node is served by the panel may the old public page be switched off, in the order given in the deployment notes.", - "retirePublicPage": "Public page", - "retirePublicPageOff": "Off (enable it above)", - "retireHasProbe": "Probe reporting", - "retireNoProbe": "No probe data", - "retireOnline": "Online", - "retireOffline": "Offline", - "retireProbeAt": "Last report {{time}}", - "retireAllReady": "Every node is served by the panel; cf-probe can be retired in the documented order.", - "retireNotReady": "Some nodes have no probe data yet; do not take cf-probe down." + "testSent": "Test alert sent: {{channels}}" }, "defaultTag": "Predeterminado", "title": "Configuraciones", "save": "Guardar", - "infoDesc": "Cada cambio realizado aquí debe ser guardado. Por favor, reinicie el panel para aplicar los cambios.", "restartPanel": "Reiniciar panel", "restartPanelDesc": "¿Está seguro de que desea reiniciar el panel? Haga clic en Aceptar para reiniciar después de 3 segundos. Si no puede acceder al panel después de reiniciar, por favor, consulte la información de registro del panel en el servidor.", "restartPanelSuccess": "El panel se reinició correctamente", diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index 776efe6..d4a7bd4 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -856,7 +856,7 @@ "uptime": "مدت فعالیت", "latency": "تاخیر", "lastHeartbeat": "آخرین ضربان", - "xrayVersion": "نسخه Xray", + "coreVersion": "Core version", "panelVersion": "نسخه پنل", "actions": "عملیات", "probe": "بررسی فوری", @@ -971,6 +971,12 @@ "countryPin": "Country pin (e.g. JP)", "ipv4Pin": "IPv4 pin", "ipv6Pin": "IPv6 pin", + "calUp": "Uplink correction (GB)", + "calDown": "Downlink correction (GB)", + "calHint": "Corrections are added to the counted traffic (negative values subtract) and apply to panel statistics, the public page and traffic alerts.", + "statIface": "Counted interface", + "movedHint": "Probe metadata (pricing, traffic limits, calibration…) is edited on the probe settings page.", + "goSettings": "Open probe settings", "save": "Save status page info", "saved": "Status page info saved", "saveFailed": "Saving status page info failed" @@ -1000,7 +1006,13 @@ "saved": "Probe task saved", "deleted": "Probe task deleted", "loadFailed": "Failed to load probe tasks", - "saveFailed": "Operation failed" + "saveFailed": "Operation failed", + "batchTitle": "Batch add", + "batchPlaceholder": "One target per line, or comma-separated — host:port, e.g. 1.1.1.1:443", + "batchSubmit": "Add all", + "batchHint": "The shared fields apply to every target; leave the name empty to name each task after its target.", + "batchEmpty": "Enter at least one target", + "batchDone": "Added {{count}} probe tasks" } }, "settings": { @@ -1034,6 +1046,15 @@ "alreadyLatest": "Already the latest version", "config": "Theme settings" }, + "probe": { + "title": "Probe settings", + "retentionDays": "History retention (days)", + "retentionHint": "How long probe history (metrics and latency) is kept.", + "fastInterval": "Fast report interval", + "fastIntervalHint": "How often agents report runtime metrics; everything else still rides the 60 s heartbeat.", + "metaTitle": "Per-node probe metadata", + "metaNodePlaceholder": "Select a node" + }, "probeNotify": { "notifyTitle": "Notifications", "notifyHint": "Offline, traffic and expiry alerts, delivered to the channels below (Telegram reuses the panel bot settings).", @@ -1057,23 +1078,11 @@ "clearSecret": "Clear", "templateHint": "Placeholders: {{title}} {{message}} {{node}} {{event}} {{time}} {{count}} {{site}}", "testSend": "Send a test alert", - "testSent": "Test alert sent: {{channels}}", - "retireTitle": "cf-probe retirement checklist", - "retireHint": "Only once every node is served by the panel may the old public page be switched off, in the order given in the deployment notes.", - "retirePublicPage": "Public page", - "retirePublicPageOff": "Off (enable it above)", - "retireHasProbe": "Probe reporting", - "retireNoProbe": "No probe data", - "retireOnline": "Online", - "retireOffline": "Offline", - "retireProbeAt": "Last report {{time}}", - "retireAllReady": "Every node is served by the panel; cf-probe can be retired in the documented order.", - "retireNotReady": "Some nodes have no probe data yet; do not take cf-probe down." + "testSent": "Test alert sent: {{channels}}" }, "defaultTag": "پیش‌فرض", "title": "تنظیمات پنل", "save": "ذخیره", - "infoDesc": "برای اعمال تغییرات در این بخش باید پس از ذخیره کردن، پنل را ریستارت کنید", "restartPanel": "راه‌اندازی مجدد پنل", "restartPanelDesc": "آیا مطمئن به ریستارت پنل هستید؟ اگر پس‌از ریستارت نمی‌توانید به پنل دسترسی پیدا کنید، لطفاً گزارش‌های موجود در اسکریپت پنل را بررسی کنید", "restartPanelSuccess": "پنل با موفقیت راه‌اندازی مجدد شد", diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index 04fe92e..0aaa28c 100644 --- a/internal/web/translation/id-ID.json +++ b/internal/web/translation/id-ID.json @@ -856,7 +856,7 @@ "uptime": "Uptime", "latency": "Latensi", "lastHeartbeat": "Heartbeat Terakhir", - "xrayVersion": "Versi Xray", + "coreVersion": "Core version", "panelVersion": "Versi panel", "actions": "Aksi", "probe": "Probe Sekarang", @@ -971,6 +971,12 @@ "countryPin": "Country pin (e.g. JP)", "ipv4Pin": "IPv4 pin", "ipv6Pin": "IPv6 pin", + "calUp": "Uplink correction (GB)", + "calDown": "Downlink correction (GB)", + "calHint": "Corrections are added to the counted traffic (negative values subtract) and apply to panel statistics, the public page and traffic alerts.", + "statIface": "Counted interface", + "movedHint": "Probe metadata (pricing, traffic limits, calibration…) is edited on the probe settings page.", + "goSettings": "Open probe settings", "save": "Save status page info", "saved": "Status page info saved", "saveFailed": "Saving status page info failed" @@ -1000,7 +1006,13 @@ "saved": "Probe task saved", "deleted": "Probe task deleted", "loadFailed": "Failed to load probe tasks", - "saveFailed": "Operation failed" + "saveFailed": "Operation failed", + "batchTitle": "Batch add", + "batchPlaceholder": "One target per line, or comma-separated — host:port, e.g. 1.1.1.1:443", + "batchSubmit": "Add all", + "batchHint": "The shared fields apply to every target; leave the name empty to name each task after its target.", + "batchEmpty": "Enter at least one target", + "batchDone": "Added {{count}} probe tasks" } }, "settings": { @@ -1034,6 +1046,15 @@ "alreadyLatest": "Already the latest version", "config": "Theme settings" }, + "probe": { + "title": "Probe settings", + "retentionDays": "History retention (days)", + "retentionHint": "How long probe history (metrics and latency) is kept.", + "fastInterval": "Fast report interval", + "fastIntervalHint": "How often agents report runtime metrics; everything else still rides the 60 s heartbeat.", + "metaTitle": "Per-node probe metadata", + "metaNodePlaceholder": "Select a node" + }, "probeNotify": { "notifyTitle": "Notifications", "notifyHint": "Offline, traffic and expiry alerts, delivered to the channels below (Telegram reuses the panel bot settings).", @@ -1057,23 +1078,11 @@ "clearSecret": "Clear", "templateHint": "Placeholders: {{title}} {{message}} {{node}} {{event}} {{time}} {{count}} {{site}}", "testSend": "Send a test alert", - "testSent": "Test alert sent: {{channels}}", - "retireTitle": "cf-probe retirement checklist", - "retireHint": "Only once every node is served by the panel may the old public page be switched off, in the order given in the deployment notes.", - "retirePublicPage": "Public page", - "retirePublicPageOff": "Off (enable it above)", - "retireHasProbe": "Probe reporting", - "retireNoProbe": "No probe data", - "retireOnline": "Online", - "retireOffline": "Offline", - "retireProbeAt": "Last report {{time}}", - "retireAllReady": "Every node is served by the panel; cf-probe can be retired in the documented order.", - "retireNotReady": "Some nodes have no probe data yet; do not take cf-probe down." + "testSent": "Test alert sent: {{channels}}" }, "defaultTag": "Bawaan", "title": "Pengaturan Panel", "save": "Simpan", - "infoDesc": "Setiap perubahan yang dibuat di sini perlu disimpan. Harap restart panel untuk menerapkan perubahan.", "restartPanel": "Mulai ulang panel", "restartPanelDesc": "Apakah Anda yakin ingin merestart panel? Jika Anda tidak dapat mengakses panel setelah merestart, lihat info log panel di server.", "restartPanelSuccess": "Panel berhasil dimulai ulang", diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index c56c56b..7bf62f9 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -856,7 +856,7 @@ "uptime": "稼働時間", "latency": "レイテンシ", "lastHeartbeat": "最後のハートビート", - "xrayVersion": "Xrayバージョン", + "coreVersion": "Core version", "panelVersion": "パネルのバージョン", "actions": "操作", "probe": "今すぐプローブ", @@ -971,6 +971,12 @@ "countryPin": "Country pin (e.g. JP)", "ipv4Pin": "IPv4 pin", "ipv6Pin": "IPv6 pin", + "calUp": "Uplink correction (GB)", + "calDown": "Downlink correction (GB)", + "calHint": "Corrections are added to the counted traffic (negative values subtract) and apply to panel statistics, the public page and traffic alerts.", + "statIface": "Counted interface", + "movedHint": "Probe metadata (pricing, traffic limits, calibration…) is edited on the probe settings page.", + "goSettings": "Open probe settings", "save": "Save status page info", "saved": "Status page info saved", "saveFailed": "Saving status page info failed" @@ -1000,7 +1006,13 @@ "saved": "Probe task saved", "deleted": "Probe task deleted", "loadFailed": "Failed to load probe tasks", - "saveFailed": "Operation failed" + "saveFailed": "Operation failed", + "batchTitle": "Batch add", + "batchPlaceholder": "One target per line, or comma-separated — host:port, e.g. 1.1.1.1:443", + "batchSubmit": "Add all", + "batchHint": "The shared fields apply to every target; leave the name empty to name each task after its target.", + "batchEmpty": "Enter at least one target", + "batchDone": "Added {{count}} probe tasks" } }, "settings": { @@ -1034,6 +1046,15 @@ "alreadyLatest": "Already the latest version", "config": "Theme settings" }, + "probe": { + "title": "Probe settings", + "retentionDays": "History retention (days)", + "retentionHint": "How long probe history (metrics and latency) is kept.", + "fastInterval": "Fast report interval", + "fastIntervalHint": "How often agents report runtime metrics; everything else still rides the 60 s heartbeat.", + "metaTitle": "Per-node probe metadata", + "metaNodePlaceholder": "Select a node" + }, "probeNotify": { "notifyTitle": "Notifications", "notifyHint": "Offline, traffic and expiry alerts, delivered to the channels below (Telegram reuses the panel bot settings).", @@ -1057,23 +1078,11 @@ "clearSecret": "Clear", "templateHint": "Placeholders: {{title}} {{message}} {{node}} {{event}} {{time}} {{count}} {{site}}", "testSend": "Send a test alert", - "testSent": "Test alert sent: {{channels}}", - "retireTitle": "cf-probe retirement checklist", - "retireHint": "Only once every node is served by the panel may the old public page be switched off, in the order given in the deployment notes.", - "retirePublicPage": "Public page", - "retirePublicPageOff": "Off (enable it above)", - "retireHasProbe": "Probe reporting", - "retireNoProbe": "No probe data", - "retireOnline": "Online", - "retireOffline": "Offline", - "retireProbeAt": "Last report {{time}}", - "retireAllReady": "Every node is served by the panel; cf-probe can be retired in the documented order.", - "retireNotReady": "Some nodes have no probe data yet; do not take cf-probe down." + "testSent": "Test alert sent: {{channels}}" }, "defaultTag": "デフォルト", "title": "パネル設定", "save": "保存", - "infoDesc": "ここでのすべての変更は、保存してパネルを再起動する必要があります", "restartPanel": "パネルを再起動", "restartPanelDesc": "パネルを再起動してもよろしいですか?再起動後にパネルにアクセスできない場合は、サーバーでパネルログを確認してください", "restartPanelSuccess": "パネルの再起動に成功しました", diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index 1999acb..f83921a 100644 --- a/internal/web/translation/pt-BR.json +++ b/internal/web/translation/pt-BR.json @@ -856,7 +856,7 @@ "uptime": "Tempo ativo", "latency": "Latência", "lastHeartbeat": "Último heartbeat", - "xrayVersion": "Versão do Xray", + "coreVersion": "Core version", "panelVersion": "Versão do painel", "actions": "Ações", "probe": "Sondar agora", @@ -971,6 +971,12 @@ "countryPin": "Country pin (e.g. JP)", "ipv4Pin": "IPv4 pin", "ipv6Pin": "IPv6 pin", + "calUp": "Uplink correction (GB)", + "calDown": "Downlink correction (GB)", + "calHint": "Corrections are added to the counted traffic (negative values subtract) and apply to panel statistics, the public page and traffic alerts.", + "statIface": "Counted interface", + "movedHint": "Probe metadata (pricing, traffic limits, calibration…) is edited on the probe settings page.", + "goSettings": "Open probe settings", "save": "Save status page info", "saved": "Status page info saved", "saveFailed": "Saving status page info failed" @@ -1000,7 +1006,13 @@ "saved": "Probe task saved", "deleted": "Probe task deleted", "loadFailed": "Failed to load probe tasks", - "saveFailed": "Operation failed" + "saveFailed": "Operation failed", + "batchTitle": "Batch add", + "batchPlaceholder": "One target per line, or comma-separated — host:port, e.g. 1.1.1.1:443", + "batchSubmit": "Add all", + "batchHint": "The shared fields apply to every target; leave the name empty to name each task after its target.", + "batchEmpty": "Enter at least one target", + "batchDone": "Added {{count}} probe tasks" } }, "settings": { @@ -1034,6 +1046,15 @@ "alreadyLatest": "Already the latest version", "config": "Theme settings" }, + "probe": { + "title": "Probe settings", + "retentionDays": "History retention (days)", + "retentionHint": "How long probe history (metrics and latency) is kept.", + "fastInterval": "Fast report interval", + "fastIntervalHint": "How often agents report runtime metrics; everything else still rides the 60 s heartbeat.", + "metaTitle": "Per-node probe metadata", + "metaNodePlaceholder": "Select a node" + }, "probeNotify": { "notifyTitle": "Notifications", "notifyHint": "Offline, traffic and expiry alerts, delivered to the channels below (Telegram reuses the panel bot settings).", @@ -1057,23 +1078,11 @@ "clearSecret": "Clear", "templateHint": "Placeholders: {{title}} {{message}} {{node}} {{event}} {{time}} {{count}} {{site}}", "testSend": "Send a test alert", - "testSent": "Test alert sent: {{channels}}", - "retireTitle": "cf-probe retirement checklist", - "retireHint": "Only once every node is served by the panel may the old public page be switched off, in the order given in the deployment notes.", - "retirePublicPage": "Public page", - "retirePublicPageOff": "Off (enable it above)", - "retireHasProbe": "Probe reporting", - "retireNoProbe": "No probe data", - "retireOnline": "Online", - "retireOffline": "Offline", - "retireProbeAt": "Last report {{time}}", - "retireAllReady": "Every node is served by the panel; cf-probe can be retired in the documented order.", - "retireNotReady": "Some nodes have no probe data yet; do not take cf-probe down." + "testSent": "Test alert sent: {{channels}}" }, "defaultTag": "Padrão", "title": "Configurações do Painel", "save": "Salvar", - "infoDesc": "Toda alteração feita aqui precisa ser salva. Reinicie o painel para aplicar as alterações.", "restartPanel": "Reiniciar painel", "restartPanelDesc": "Tem certeza de que deseja reiniciar o painel? Se não conseguir acessar o painel após reiniciar, consulte os logs do painel no servidor.", "restartPanelSuccess": "O painel foi reiniciado com sucesso", diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index 0947f92..ad2e6e0 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -856,7 +856,7 @@ "uptime": "Аптайм", "latency": "Задержка", "lastHeartbeat": "Последний пинг", - "xrayVersion": "Версия Xray", + "coreVersion": "Core version", "panelVersion": "Версия панели", "actions": "Действия", "probe": "Проверить сейчас", @@ -971,6 +971,12 @@ "countryPin": "Country pin (e.g. JP)", "ipv4Pin": "IPv4 pin", "ipv6Pin": "IPv6 pin", + "calUp": "Uplink correction (GB)", + "calDown": "Downlink correction (GB)", + "calHint": "Corrections are added to the counted traffic (negative values subtract) and apply to panel statistics, the public page and traffic alerts.", + "statIface": "Counted interface", + "movedHint": "Probe metadata (pricing, traffic limits, calibration…) is edited on the probe settings page.", + "goSettings": "Open probe settings", "save": "Save status page info", "saved": "Status page info saved", "saveFailed": "Saving status page info failed" @@ -1000,7 +1006,13 @@ "saved": "Probe task saved", "deleted": "Probe task deleted", "loadFailed": "Failed to load probe tasks", - "saveFailed": "Operation failed" + "saveFailed": "Operation failed", + "batchTitle": "Batch add", + "batchPlaceholder": "One target per line, or comma-separated — host:port, e.g. 1.1.1.1:443", + "batchSubmit": "Add all", + "batchHint": "The shared fields apply to every target; leave the name empty to name each task after its target.", + "batchEmpty": "Enter at least one target", + "batchDone": "Added {{count}} probe tasks" } }, "settings": { @@ -1034,6 +1046,15 @@ "alreadyLatest": "Already the latest version", "config": "Theme settings" }, + "probe": { + "title": "Probe settings", + "retentionDays": "History retention (days)", + "retentionHint": "How long probe history (metrics and latency) is kept.", + "fastInterval": "Fast report interval", + "fastIntervalHint": "How often agents report runtime metrics; everything else still rides the 60 s heartbeat.", + "metaTitle": "Per-node probe metadata", + "metaNodePlaceholder": "Select a node" + }, "probeNotify": { "notifyTitle": "Notifications", "notifyHint": "Offline, traffic and expiry alerts, delivered to the channels below (Telegram reuses the panel bot settings).", @@ -1057,23 +1078,11 @@ "clearSecret": "Clear", "templateHint": "Placeholders: {{title}} {{message}} {{node}} {{event}} {{time}} {{count}} {{site}}", "testSend": "Send a test alert", - "testSent": "Test alert sent: {{channels}}", - "retireTitle": "cf-probe retirement checklist", - "retireHint": "Only once every node is served by the panel may the old public page be switched off, in the order given in the deployment notes.", - "retirePublicPage": "Public page", - "retirePublicPageOff": "Off (enable it above)", - "retireHasProbe": "Probe reporting", - "retireNoProbe": "No probe data", - "retireOnline": "Online", - "retireOffline": "Offline", - "retireProbeAt": "Last report {{time}}", - "retireAllReady": "Every node is served by the panel; cf-probe can be retired in the documented order.", - "retireNotReady": "Some nodes have no probe data yet; do not take cf-probe down." + "testSent": "Test alert sent: {{channels}}" }, "defaultTag": "По умолчанию", "title": "Настройки", "save": "Сохранить", - "infoDesc": "Сохраните изменения и перезапустите панель для их применения.", "restartPanel": "Перезапустить панель", "restartPanelDesc": "Вы уверены, что хотите перезапустить панель? Подтвердите, и перезапуск произойдёт через 3 секунды. Если панель будет недоступна, проверьте лог сервера", "restartPanelSuccess": "Панель успешно перезапущена", diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index 407c9e1..efba14a 100644 --- a/internal/web/translation/tr-TR.json +++ b/internal/web/translation/tr-TR.json @@ -856,7 +856,7 @@ "uptime": "Çalışma Süresi", "latency": "Gecikme", "lastHeartbeat": "Son Sinyal", - "xrayVersion": "Xray Sürümü", + "coreVersion": "Core version", "panelVersion": "Panel Sürümü", "actions": "İşlemler", "probe": "Şimdi Test Et", @@ -971,6 +971,12 @@ "countryPin": "Country pin (e.g. JP)", "ipv4Pin": "IPv4 pin", "ipv6Pin": "IPv6 pin", + "calUp": "Uplink correction (GB)", + "calDown": "Downlink correction (GB)", + "calHint": "Corrections are added to the counted traffic (negative values subtract) and apply to panel statistics, the public page and traffic alerts.", + "statIface": "Counted interface", + "movedHint": "Probe metadata (pricing, traffic limits, calibration…) is edited on the probe settings page.", + "goSettings": "Open probe settings", "save": "Save status page info", "saved": "Status page info saved", "saveFailed": "Saving status page info failed" @@ -1000,7 +1006,13 @@ "saved": "Probe task saved", "deleted": "Probe task deleted", "loadFailed": "Failed to load probe tasks", - "saveFailed": "Operation failed" + "saveFailed": "Operation failed", + "batchTitle": "Batch add", + "batchPlaceholder": "One target per line, or comma-separated — host:port, e.g. 1.1.1.1:443", + "batchSubmit": "Add all", + "batchHint": "The shared fields apply to every target; leave the name empty to name each task after its target.", + "batchEmpty": "Enter at least one target", + "batchDone": "Added {{count}} probe tasks" } }, "settings": { @@ -1034,6 +1046,15 @@ "alreadyLatest": "Already the latest version", "config": "Theme settings" }, + "probe": { + "title": "Probe settings", + "retentionDays": "History retention (days)", + "retentionHint": "How long probe history (metrics and latency) is kept.", + "fastInterval": "Fast report interval", + "fastIntervalHint": "How often agents report runtime metrics; everything else still rides the 60 s heartbeat.", + "metaTitle": "Per-node probe metadata", + "metaNodePlaceholder": "Select a node" + }, "probeNotify": { "notifyTitle": "Notifications", "notifyHint": "Offline, traffic and expiry alerts, delivered to the channels below (Telegram reuses the panel bot settings).", @@ -1057,23 +1078,11 @@ "clearSecret": "Clear", "templateHint": "Placeholders: {{title}} {{message}} {{node}} {{event}} {{time}} {{count}} {{site}}", "testSend": "Send a test alert", - "testSent": "Test alert sent: {{channels}}", - "retireTitle": "cf-probe retirement checklist", - "retireHint": "Only once every node is served by the panel may the old public page be switched off, in the order given in the deployment notes.", - "retirePublicPage": "Public page", - "retirePublicPageOff": "Off (enable it above)", - "retireHasProbe": "Probe reporting", - "retireNoProbe": "No probe data", - "retireOnline": "Online", - "retireOffline": "Offline", - "retireProbeAt": "Last report {{time}}", - "retireAllReady": "Every node is served by the panel; cf-probe can be retired in the documented order.", - "retireNotReady": "Some nodes have no probe data yet; do not take cf-probe down." + "testSent": "Test alert sent: {{channels}}" }, "defaultTag": "Varsayılan", "title": "Panel Ayarları", "save": "Kaydet", - "infoDesc": "Burada yapılan her değişikliğin kaydedilmesi gerekir. Değişikliklerin uygulanması için paneli yeniden başlatın.", "restartPanel": "Paneli Yeniden Başlat", "restartPanelDesc": "Paneli yeniden başlatmak istediğinizden emin misiniz? Yeniden başlattıktan sonra panele erişemezseniz, sunucudaki panel loglarını kontrol edin.", "restartPanelSuccess": "Panel başarıyla yeniden başlatıldı.", diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index 5e70af7..46e0ea0 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -856,7 +856,7 @@ "uptime": "Час роботи", "latency": "Затримка", "lastHeartbeat": "Останній пінг", - "xrayVersion": "Версія Xray", + "coreVersion": "Core version", "panelVersion": "Версія панелі", "actions": "Дії", "probe": "Перевірити зараз", @@ -971,6 +971,12 @@ "countryPin": "Country pin (e.g. JP)", "ipv4Pin": "IPv4 pin", "ipv6Pin": "IPv6 pin", + "calUp": "Uplink correction (GB)", + "calDown": "Downlink correction (GB)", + "calHint": "Corrections are added to the counted traffic (negative values subtract) and apply to panel statistics, the public page and traffic alerts.", + "statIface": "Counted interface", + "movedHint": "Probe metadata (pricing, traffic limits, calibration…) is edited on the probe settings page.", + "goSettings": "Open probe settings", "save": "Save status page info", "saved": "Status page info saved", "saveFailed": "Saving status page info failed" @@ -1000,7 +1006,13 @@ "saved": "Probe task saved", "deleted": "Probe task deleted", "loadFailed": "Failed to load probe tasks", - "saveFailed": "Operation failed" + "saveFailed": "Operation failed", + "batchTitle": "Batch add", + "batchPlaceholder": "One target per line, or comma-separated — host:port, e.g. 1.1.1.1:443", + "batchSubmit": "Add all", + "batchHint": "The shared fields apply to every target; leave the name empty to name each task after its target.", + "batchEmpty": "Enter at least one target", + "batchDone": "Added {{count}} probe tasks" } }, "settings": { @@ -1034,6 +1046,15 @@ "alreadyLatest": "Already the latest version", "config": "Theme settings" }, + "probe": { + "title": "Probe settings", + "retentionDays": "History retention (days)", + "retentionHint": "How long probe history (metrics and latency) is kept.", + "fastInterval": "Fast report interval", + "fastIntervalHint": "How often agents report runtime metrics; everything else still rides the 60 s heartbeat.", + "metaTitle": "Per-node probe metadata", + "metaNodePlaceholder": "Select a node" + }, "probeNotify": { "notifyTitle": "Notifications", "notifyHint": "Offline, traffic and expiry alerts, delivered to the channels below (Telegram reuses the panel bot settings).", @@ -1057,23 +1078,11 @@ "clearSecret": "Clear", "templateHint": "Placeholders: {{title}} {{message}} {{node}} {{event}} {{time}} {{count}} {{site}}", "testSend": "Send a test alert", - "testSent": "Test alert sent: {{channels}}", - "retireTitle": "cf-probe retirement checklist", - "retireHint": "Only once every node is served by the panel may the old public page be switched off, in the order given in the deployment notes.", - "retirePublicPage": "Public page", - "retirePublicPageOff": "Off (enable it above)", - "retireHasProbe": "Probe reporting", - "retireNoProbe": "No probe data", - "retireOnline": "Online", - "retireOffline": "Offline", - "retireProbeAt": "Last report {{time}}", - "retireAllReady": "Every node is served by the panel; cf-probe can be retired in the documented order.", - "retireNotReady": "Some nodes have no probe data yet; do not take cf-probe down." + "testSent": "Test alert sent: {{channels}}" }, "defaultTag": "Типово", "title": "Параметри панелі", "save": "Зберегти", - "infoDesc": "Кожна внесена тут зміна повинна бути збережена. Перезапустіть панель, щоб застосувати зміни.", "restartPanel": "Перезапустити панель", "restartPanelDesc": "Ви впевнені, що бажаєте перезапустити панель? Якщо ви не можете отримати доступ до панелі після перезапуску, будь ласка, перегляньте інформацію журналу панелі на сервері.", "restartPanelSuccess": "Панель успішно перезапущено", diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index f9711b8..cc51e52 100644 --- a/internal/web/translation/vi-VN.json +++ b/internal/web/translation/vi-VN.json @@ -856,7 +856,7 @@ "uptime": "Thời gian hoạt động", "latency": "Độ trễ", "lastHeartbeat": "Heartbeat gần nhất", - "xrayVersion": "Phiên bản Xray", + "coreVersion": "Core version", "panelVersion": "Phiên bản panel", "actions": "Hành động", "probe": "Kiểm tra ngay", @@ -971,6 +971,12 @@ "countryPin": "Country pin (e.g. JP)", "ipv4Pin": "IPv4 pin", "ipv6Pin": "IPv6 pin", + "calUp": "Uplink correction (GB)", + "calDown": "Downlink correction (GB)", + "calHint": "Corrections are added to the counted traffic (negative values subtract) and apply to panel statistics, the public page and traffic alerts.", + "statIface": "Counted interface", + "movedHint": "Probe metadata (pricing, traffic limits, calibration…) is edited on the probe settings page.", + "goSettings": "Open probe settings", "save": "Save status page info", "saved": "Status page info saved", "saveFailed": "Saving status page info failed" @@ -1000,7 +1006,13 @@ "saved": "Probe task saved", "deleted": "Probe task deleted", "loadFailed": "Failed to load probe tasks", - "saveFailed": "Operation failed" + "saveFailed": "Operation failed", + "batchTitle": "Batch add", + "batchPlaceholder": "One target per line, or comma-separated — host:port, e.g. 1.1.1.1:443", + "batchSubmit": "Add all", + "batchHint": "The shared fields apply to every target; leave the name empty to name each task after its target.", + "batchEmpty": "Enter at least one target", + "batchDone": "Added {{count}} probe tasks" } }, "settings": { @@ -1034,6 +1046,15 @@ "alreadyLatest": "Already the latest version", "config": "Theme settings" }, + "probe": { + "title": "Probe settings", + "retentionDays": "History retention (days)", + "retentionHint": "How long probe history (metrics and latency) is kept.", + "fastInterval": "Fast report interval", + "fastIntervalHint": "How often agents report runtime metrics; everything else still rides the 60 s heartbeat.", + "metaTitle": "Per-node probe metadata", + "metaNodePlaceholder": "Select a node" + }, "probeNotify": { "notifyTitle": "Notifications", "notifyHint": "Offline, traffic and expiry alerts, delivered to the channels below (Telegram reuses the panel bot settings).", @@ -1057,23 +1078,11 @@ "clearSecret": "Clear", "templateHint": "Placeholders: {{title}} {{message}} {{node}} {{event}} {{time}} {{count}} {{site}}", "testSend": "Send a test alert", - "testSent": "Test alert sent: {{channels}}", - "retireTitle": "cf-probe retirement checklist", - "retireHint": "Only once every node is served by the panel may the old public page be switched off, in the order given in the deployment notes.", - "retirePublicPage": "Public page", - "retirePublicPageOff": "Off (enable it above)", - "retireHasProbe": "Probe reporting", - "retireNoProbe": "No probe data", - "retireOnline": "Online", - "retireOffline": "Offline", - "retireProbeAt": "Last report {{time}}", - "retireAllReady": "Every node is served by the panel; cf-probe can be retired in the documented order.", - "retireNotReady": "Some nodes have no probe data yet; do not take cf-probe down." + "testSent": "Test alert sent: {{channels}}" }, "defaultTag": "Mặc định", "title": "Cài đặt", "save": "Lưu", - "infoDesc": "Mọi thay đổi được thực hiện ở đây cần phải được lưu. Vui lòng khởi động lại bảng điều khiển để áp dụng các thay đổi.", "restartPanel": "Khởi động lại bảng điều khiển", "restartPanelDesc": "Bạn có chắc chắn muốn khởi động lại bảng điều khiển? Nhấn OK để khởi động lại sau 3 giây. Nếu bạn không thể truy cập bảng điều khiển sau khi khởi động lại, vui lòng xem thông tin nhật ký của bảng điều khiển trên máy chủ.", "restartPanelSuccess": "Đã khởi động lại bảng điều khiển thành công", diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index 81be13f..111c6e6 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -856,7 +856,7 @@ "uptime": "运行时长", "latency": "延迟", "lastHeartbeat": "上次心跳", - "xrayVersion": "Xray 版本", + "coreVersion": "核心版本", "panelVersion": "面板版本", "actions": "操作", "probe": "立即探测", @@ -971,6 +971,12 @@ "countryPin": "国家锁定(如 JP)", "ipv4Pin": "IPv4 锁定", "ipv6Pin": "IPv6 锁定", + "calUp": "上行校正(GB)", + "calDown": "下行校正(GB)", + "calHint": "校正值加到已统计流量上(可负数扣减),作用于面板统计、公开页与流量告警。", + "statIface": "统计网卡", + "movedHint": "探针元数据(价格、流量上限、校准等)已统一到「探针设置」页编辑。", + "goSettings": "前往探针设置", "save": "保存状态页信息", "saved": "状态页信息已保存", "saveFailed": "状态页信息保存失败" @@ -1000,7 +1006,13 @@ "saved": "探测任务已保存", "deleted": "探测任务已删除", "loadFailed": "探测任务加载失败", - "saveFailed": "操作失败" + "saveFailed": "操作失败", + "batchTitle": "批量添加", + "batchPlaceholder": "每行一个目标,或用逗号分隔——主机:端口,如 1.1.1.1:443", + "batchSubmit": "全部添加", + "batchHint": "共用字段对每个目标生效;名称留空时按目标自动命名。", + "batchEmpty": "至少填写一个目标", + "batchDone": "已添加 {{count}} 个探测任务" } }, "settings": { @@ -1034,6 +1046,15 @@ "alreadyLatest": "已是最新版本", "config": "主题设置" }, + "probe": { + "title": "探针设置", + "retentionDays": "历史保留(天)", + "retentionHint": "探针历史数据(指标/延迟)的保留时长。", + "fastInterval": "快报间隔", + "fastIntervalHint": "agent 上报运行时指标的频率;其余内容仍走 60 秒心跳。", + "metaTitle": "按节点探针元数据", + "metaNodePlaceholder": "选择节点" + }, "probeNotify": { "notifyTitle": "通知", "notifyHint": "节点掉线、流量超限与到期提醒,发送到下方配置的渠道(Telegram 复用面板机器人设置)。", @@ -1057,23 +1078,11 @@ "clearSecret": "清除", "templateHint": "可用占位符:{{title}} {{message}} {{node}} {{event}} {{time}} {{count}} {{site}}", "testSend": "发送测试通知", - "testSent": "测试通知已发送:{{channels}}", - "retireTitle": "cf-probe 退役前提", - "retireHint": "全部节点都由面板供数后,旧公开页才可按部署文档顺序切换/下线。", - "retirePublicPage": "公开页", - "retirePublicPageOff": "未开启(在上方开启)", - "retireHasProbe": "探针已上报", - "retireNoProbe": "探针未上报", - "retireOnline": "在线", - "retireOffline": "离线", - "retireProbeAt": "最近上报 {{time}}", - "retireAllReady": "全部节点已由主控供数,可按文档顺序退役 cf-probe。", - "retireNotReady": "仍有节点未供数,先不要下线 cf-probe。" + "testSent": "测试通知已发送:{{channels}}" }, "defaultTag": "默认", "title": "面板设置", "save": "保存", - "infoDesc": "此处的所有更改都需要保存并重启面板才能生效", "restartPanel": "重启面板", "restartPanelDesc": "确定要重启面板吗?若重启后无法访问面板,请前往服务器查看面板日志信息", "restartPanelSuccess": "面板已成功重启", diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index 4763a42..e854008 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -856,7 +856,7 @@ "uptime": "運行時間", "latency": "延遲", "lastHeartbeat": "上次心跳", - "xrayVersion": "Xray 版本", + "coreVersion": "核心版本", "panelVersion": "面板版本", "actions": "操作", "probe": "立即探測", @@ -971,6 +971,12 @@ "countryPin": "Country pin (e.g. JP)", "ipv4Pin": "IPv4 pin", "ipv6Pin": "IPv6 pin", + "calUp": "上行校正(GB)", + "calDown": "下行校正(GB)", + "calHint": "校正值加到已统计流量上(可负数扣减),作用于面板统计、公开页与流量告警。", + "statIface": "统计网卡", + "movedHint": "探针元数据(价格、流量上限、校准等)已统一到「探针设置」页编辑。", + "goSettings": "前往探针设置", "save": "Save status page info", "saved": "Status page info saved", "saveFailed": "Saving status page info failed" @@ -1000,7 +1006,13 @@ "saved": "Probe task saved", "deleted": "Probe task deleted", "loadFailed": "Failed to load probe tasks", - "saveFailed": "Operation failed" + "saveFailed": "Operation failed", + "batchTitle": "批量添加", + "batchPlaceholder": "每行一个目标,或用逗号分隔——主机:端口,如 1.1.1.1:443", + "batchSubmit": "全部添加", + "batchHint": "共用字段对每个目标生效;名称留空时按目标自动命名。", + "batchEmpty": "至少填写一个目标", + "batchDone": "已添加 {{count}} 个探测任务" } }, "settings": { @@ -1034,6 +1046,15 @@ "alreadyLatest": "Already the latest version", "config": "Theme settings" }, + "probe": { + "title": "探针设置", + "retentionDays": "历史保留(天)", + "retentionHint": "探针历史数据(指标/延迟)的保留时长。", + "fastInterval": "快报间隔", + "fastIntervalHint": "agent 上报运行时指标的频率;其余内容仍走 60 秒心跳。", + "metaTitle": "按节点探针元数据", + "metaNodePlaceholder": "选择节点" + }, "probeNotify": { "notifyTitle": "Notifications", "notifyHint": "Offline, traffic and expiry alerts, delivered to the channels below (Telegram reuses the panel bot settings).", @@ -1057,23 +1078,11 @@ "clearSecret": "Clear", "templateHint": "Placeholders: {{title}} {{message}} {{node}} {{event}} {{time}} {{count}} {{site}}", "testSend": "Send a test alert", - "testSent": "Test alert sent: {{channels}}", - "retireTitle": "cf-probe retirement checklist", - "retireHint": "Only once every node is served by the panel may the old public page be switched off, in the order given in the deployment notes.", - "retirePublicPage": "Public page", - "retirePublicPageOff": "Off (enable it above)", - "retireHasProbe": "Probe reporting", - "retireNoProbe": "No probe data", - "retireOnline": "Online", - "retireOffline": "Offline", - "retireProbeAt": "Last report {{time}}", - "retireAllReady": "Every node is served by the panel; cf-probe can be retired in the documented order.", - "retireNotReady": "Some nodes have no probe data yet; do not take cf-probe down." + "testSent": "Test alert sent: {{channels}}" }, "defaultTag": "預設", "title": "面板設定", "save": "儲存", - "infoDesc": "此處的所有更改都需要儲存並重啟面板才能生效", "restartPanel": "重新啟動面板", "restartPanelDesc": "確定要重啟面板嗎?若重啟後無法訪問面板,請前往伺服器檢視面板日誌資訊", "restartPanelSuccess": "面板已成功重新啟動",