3 Commits
Author SHA1 Message Date
Muse d4d259fa58 probe ws: frame cadence follows probe_fast_interval (V2.0.2)
CI / go-test (push) Waiting to run
CI / postgres-durable-first (push) Waiting to run
CI / codegen (push) Waiting to run
CI / govulncheck (push) Waiting to run
CI / race (push) Waiting to run
CI / fuzz-smoke (push) Waiting to run
CI / golangci (push) Waiting to run
CI / frontend (push) Waiting to run
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (amd64) (push) Waiting to run
Release 3X-UI / Publish rolling dev release (push) Blocked by required conditions
CodeQL Advanced / Analyze (go) (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
2026-10-23 18:15:05 +08:00
Muse 9caa4e0cf9 install-agent: RELEASE_BASE -> V2.0.1
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (amd64) (push) Waiting to run
Release 3X-UI / Publish rolling dev release (push) Blocked by required conditions
2026-10-23 16:13:25 +08:00
Rogue 29de9761c8 V2.0.1: listen override fix, sing-box core fetch, probe settings page + fast report, spx removal, core mem env
CI / go-test (push) Waiting to run
CI / postgres-durable-first (push) Waiting to run
CI / codegen (push) Waiting to run
CI / govulncheck (push) Waiting to run
CI / race (push) Waiting to run
CI / fuzz-smoke (push) Waiting to run
CI / golangci (push) Waiting to run
CI / frontend (push) Waiting to run
Docs CI / build (push) Waiting to run
Docs Deploy (GitHub Pages) / build (push) Waiting to run
Docs Deploy (GitHub Pages) / deploy (push) Blocked by required conditions
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (amd64) (push) Waiting to run
Release 3X-UI / Publish rolling dev release (push) Blocked by required conditions
CodeQL Advanced / Analyze (go) (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
- 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).
2026-10-23 07:53:11 +00:00
69 changed files with 3157 additions and 842 deletions
@@ -54,7 +54,7 @@ export default function Layout(props) {
return (
<>
{props.children}
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/login","method":"post"},{"path":"/logout","method":"post"},{"path":"/csrf-token","method":"get"},{"path":"/getTwoFactorEnable","method":"post"}]} showTitle />
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/login","method":"post"},{"path":"/logout","method":"post"},{"path":"/csrf-token","method":"get"}]} showTitle />
</>
);
}
@@ -69,7 +69,7 @@ export default function Layout(props) {
return (
<>
{props.children}
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/login","method":"post"},{"path":"/logout","method":"post"},{"path":"/csrf-token","method":"get"},{"path":"/getTwoFactorEnable","method":"post"}]} showTitle />
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/login","method":"post"},{"path":"/logout","method":"post"},{"path":"/csrf-token","method":"get"}]} showTitle />
</>
);
}
@@ -69,7 +69,7 @@ export default function Layout(props) {
return (
<>
{props.children}
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/login","method":"post"},{"path":"/logout","method":"post"},{"path":"/csrf-token","method":"get"},{"path":"/getTwoFactorEnable","method":"post"}]} showTitle />
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/login","method":"post"},{"path":"/logout","method":"post"},{"path":"/csrf-token","method":"get"}]} showTitle />
</>
);
}
@@ -66,7 +66,7 @@ export default function Layout(props) {
return (
<>
{props.children}
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/login","method":"post"},{"path":"/logout","method":"post"},{"path":"/csrf-token","method":"get"},{"path":"/getTwoFactorEnable","method":"post"}]} showTitle />
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/login","method":"post"},{"path":"/logout","method":"post"},{"path":"/csrf-token","method":"get"}]} showTitle />
</>
);
}
+6
View File
@@ -22,6 +22,7 @@ import {
MoonOutlined,
PushpinFilled,
PushpinOutlined,
RadarChartOutlined,
ReadOutlined,
SafetyOutlined,
SettingOutlined,
@@ -249,6 +250,11 @@ export default function AppSidebar() {
icon: <GlobalOutlined />,
label: t('pages.settings.probeThemes.title'),
},
{
key: '/settings#probe',
icon: <RadarChartOutlined />,
label: t('pages.settings.probe.title'),
},
];
return children;
}, [t]);
+2 -20
View File
@@ -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://<uuid>@<host>:<port>?<query>#<remark>. 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<Inbound['streamSettings']>,
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':
-10
View File
@@ -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)}`;
}
+12 -2
View File
@@ -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<NodeFormValues>({ defaultValues: defaultValues() });
const [messageApi, messageContextHolder] = message.useMessage();
@@ -474,7 +476,15 @@ export default function NodeFormModal({
)}
{mode === 'edit' && isAgent && node?.id ? (
<ProbeMetaSection nodeId={node.id} />
<div style={{ marginBottom: 16 }}>
<div style={{ fontWeight: 600, marginBottom: 4 }}>
{t('pages.nodes.probe.title')}
</div>
<p style={{ marginBottom: 8 }}>{t('pages.nodes.probe.movedHint')}</p>
<Button onClick={() => navigate('/settings#probe')}>
{t('pages.nodes.probe.goSettings')}
</Button>
</div>
) : null}
{!isAgent && (
+13 -4
View File
@@ -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({
<Tag>{formatPct(statsNode.memPct)}</Tag>
</div>
<div className="stat-row">
<span className="stat-label">{t('pages.nodes.xrayVersion')}</span>
<Tag>{statsNode.xrayVersion || '-'}</Tag>
<span className="stat-label">{t('pages.nodes.coreVersion')}</span>
<Tag>
{statsNode.nodeType === 'agent'
? statsNode.singboxVersion || statsNode.xrayVersion || '-'
: statsNode.xrayVersion || '-'}
</Tag>
</div>
<div className="stat-row">
<span className="stat-label">
+3 -9
View File
@@ -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<NodeRecord | null>(null);
const [selectedIds, setSelectedIds] = useState<number[]>([]);
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() {
</Layout.Content>
</Layout>
<ProbeTasksModal
open={probeTasksOpen}
onClose={() => setProbeTasksOpen(false)}
nodes={nodes}
/>
<NodeFormModal
open={formOpen}
mode={formMode}
@@ -23,8 +23,15 @@ interface ProbeMetaWire {
countryPin: string;
ipv4Pin: string;
ipv6Pin: string;
// V2.0.1: ledger corrections in GB (either sign) and the counted
// interface pin ('' = automatic selection on the agent).
trafficCalUpGb: number;
trafficCalDownGb: number;
statIface: string;
}
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } };
const NAMED_CYCLES = [
'once',
'monthly',
@@ -113,6 +120,7 @@ export default function ProbeMetaSection({ nodeId }: { nodeId: number }) {
};
const msg = await HttpUtil.put(`/panel/api/agent/probe-meta/${nodeId}`, payload, {
silent: true,
...JSON_HEADERS,
});
if (msg?.success) {
setLoaded({ nodeId, meta: payload });
@@ -237,6 +245,38 @@ export default function ProbeMetaSection({ nodeId }: { nodeId: number }) {
</Field>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} md={8}>
<Field label={t('pages.nodes.probe.calUp')}>
<InputNumber
value={meta.trafficCalUpGb}
step={0.1}
onChange={(v) => set('trafficCalUpGb', v ?? 0)}
style={{ width: '100%' }}
/>
</Field>
</Col>
<Col xs={24} md={8}>
<Field label={t('pages.nodes.probe.calDown')}>
<InputNumber
value={meta.trafficCalDownGb}
step={0.1}
onChange={(v) => set('trafficCalDownGb', v ?? 0)}
style={{ width: '100%' }}
/>
</Field>
</Col>
<Col xs={24} md={8}>
<Field label={t('pages.nodes.probe.statIface')}>
<Input
value={meta.statIface}
placeholder="eth0"
onChange={(e) => set('statIface', e.target.value)}
/>
</Field>
</Col>
</Row>
<p style={{ marginTop: -4, marginBottom: 12 }}>{t('pages.nodes.probe.calHint')}</p>
<Row gutter={16}>
<Col xs={24} md={8}>
<Field label={t('pages.nodes.probe.countryPin')}>
@@ -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<string> {
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<string> {
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<Blob | null>((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<ProbeSettingsWire | null>(null);
const [saving, setSaving] = useState(false);
const [metaNodeId, setMetaNodeId] = useState<number | undefined>(undefined);
const faviconRef = useRef<HTMLInputElement>(null);
const touchRef = useRef<HTMLInputElement>(null);
const { nodes } = useNodesQuery();
const agentNodes = nodes.filter((n) => n.nodeType === 'agent');
useEffect(() => {
let cancelled = false;
HttpUtil.get<ProbeSettingsWire>('/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 = <K extends keyof ProbeSettingsWire>(key: K, value: ProbeSettingsWire[K]) =>
setSettings((s) => (s ? { ...s, [key]: value } : s));
return (
<div>
{messageContextHolder}
<Typography.Title level={5}>{t('pages.settings.probeThemes.settingsTitle')}</Typography.Title>
{settings ? (
<>
<Row gutter={[16, 12]}>
<Col xs={24} md={12}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.publicPage')}</div>
<Switch
checked={settings.publicPage}
onChange={(v) => set('publicPage', v)}
/>
<Typography.Text type="secondary" style={{ marginLeft: 8 }}>
{t('pages.settings.probeThemes.publicPageHint')}
</Typography.Text>
</Col>
<Col xs={24} md={12}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.siteName')}</div>
<Input
value={settings.siteName}
onChange={(e) => set('siteName', e.target.value)}
/>
</Col>
<Col xs={24} md={12}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.site')}</div>
<Input
value={settings.site}
placeholder="https://"
onChange={(e) => set('site', e.target.value)}
/>
</Col>
<Col xs={24} md={12}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.githubProxy')}</div>
<Input
value={settings.githubProxy}
placeholder="https://"
onChange={(e) => set('githubProxy', e.target.value)}
/>
<Typography.Text type="secondary">
{t('pages.settings.probeThemes.githubProxyHint')}
</Typography.Text>
</Col>
<Col xs={24} md={12}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probe.retentionDays')}</div>
<InputNumber
min={1}
max={365}
value={settings.retentionDays}
onChange={(v) => set('retentionDays', v ?? 7)}
style={{ width: '100%' }}
/>
<Typography.Text type="secondary">
{t('pages.settings.probe.retentionHint')}
</Typography.Text>
</Col>
<Col xs={24} md={12}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probe.fastInterval')}</div>
<InputNumber
min={1}
max={60}
value={settings.fastIntervalSec}
onChange={(v) => set('fastIntervalSec', v ?? 3)}
addonAfter="s"
style={{ width: '100%' }}
/>
<Typography.Text type="secondary">
{t('pages.settings.probe.fastIntervalHint')}
</Typography.Text>
</Col>
<Col xs={24} md={12}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.favicon')}</div>
<Space>
{settings.favicon ? (
<img src={settings.favicon} alt="" style={{ width: 24, height: 24 }} />
) : null}
<Button size="small" onClick={() => faviconRef.current?.click()}>
{t('pages.settings.probeThemes.chooseImage')}
</Button>
{settings.favicon ? (
<Button size="small" onClick={() => set('favicon', '')}>
{t('clear')}
</Button>
) : null}
</Space>
<input
ref={faviconRef}
type="file"
accept="image/*"
hidden
onChange={(e) => pickIcon('favicon', e.target.files?.[0])}
/>
</Col>
<Col xs={24} md={12}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.touchIcon')}</div>
<Space>
{settings.touchIcon ? (
<img src={settings.touchIcon} alt="" style={{ width: 24, height: 24 }} />
) : null}
<Button size="small" onClick={() => touchRef.current?.click()}>
{t('pages.settings.probeThemes.chooseImage')}
</Button>
{settings.touchIcon ? (
<Button size="small" onClick={() => set('touchIcon', '')}>
{t('clear')}
</Button>
) : null}
</Space>
<input
ref={touchRef}
type="file"
accept="image/*"
hidden
onChange={(e) => pickIcon('touchIcon', e.target.files?.[0])}
/>
<div>
<Typography.Text type="secondary">
{t('pages.settings.probeThemes.iconHint')}
</Typography.Text>
</div>
</Col>
</Row>
<div style={{ marginTop: 12 }}>
<Button type="primary" loading={saving} onClick={saveSettings}>
{t('save')}
</Button>
</div>
</>
) : null}
<Typography.Title level={5} style={{ marginTop: 28 }}>
{t('pages.nodes.probeTasks.title')}
</Typography.Title>
<ProbeTasksPanel nodes={nodes} />
<Typography.Title level={5} style={{ marginTop: 28 }}>
{t('pages.settings.probe.metaTitle')}
</Typography.Title>
<Select
placeholder={t('pages.settings.probe.metaNodePlaceholder')}
value={metaNodeId}
onChange={setMetaNodeId}
options={agentNodes.map((n) => ({ value: n.id, label: n.name }))}
style={{ minWidth: 260, marginBottom: 16 }}
/>
{metaNodeId ? <ProbeMetaSection key={metaNodeId} nodeId={metaNodeId} /> : null}
</div>
);
}
@@ -1,10 +1,9 @@
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Button,
Input,
InputNumber,
Modal,
Popconfirm,
Select,
Space,
@@ -19,12 +18,14 @@ import type { ColumnsType } from 'antd/es/table';
import { HttpUtil } from '@/utils';
import type { NodeRecord } from '@/api/queries/useNodesQuery';
// Item 7 B4: probe (latency) task management. The operator maintains
// the target set here — grouped by the free-form region label, no
// preset list (user ruling Q4). Like ProbeMetaSection the surface is
// self-contained: it loads and saves on its own through the dedicated
// endpoints, effective immediately (the master pushes the set on each
// agent's next config poll; nothing restarts).
// Probe (latency) task management — the panel form of what used to
// be the nodes-page modal (moved to the probe settings page in
// V2.0.1; the target set lives in exactly one place now). The
// operator maintains the target set here — grouped by the free-form
// region label, no preset list (user ruling Q4). The surface is
// self-contained: it loads and saves on its own through the
// dedicated endpoints, effective immediately (the master pushes the
// set on each agent's next config poll; nothing restarts).
interface ProbeTaskWire {
id: number;
name: string;
@@ -48,22 +49,38 @@ interface TaskDraft {
}
const TASKS_URL = '/panel/api/agent/probe-tasks';
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } };
export default function ProbeTasksModal({
open,
onClose,
nodes,
}: {
open: boolean;
onClose: () => void;
nodes: NodeRecord[];
}) {
function parseTargets(text: string): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const raw of text.split(/[\n,,]+/)) {
const v = raw.trim();
if (v && !seen.has(v)) {
seen.add(v);
out.push(v);
}
}
return out;
}
export default function ProbeTasksPanel({ nodes }: { nodes: NodeRecord[] }) {
const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
const [tasks, setTasks] = useState<ProbeTaskWire[] | null>(null);
const [draft, setDraft] = useState<TaskDraft | null>(null);
const [saving, setSaving] = useState(false);
// Batch-add state (V2.0.1 ⑤): many targets pasted at once sharing
// every other field.
const [batchText, setBatchText] = useState('');
const [batchName, setBatchName] = useState('');
const [batchInterval, setBatchInterval] = useState(60);
const [batchAutoJoin, setBatchAutoJoin] = useState(false);
const [batchRegion, setBatchRegion] = useState('');
const [batchNodeIds, setBatchNodeIds] = useState<number[]>([]);
const [batchSaving, setBatchSaving] = useState(false);
const agentNodes = useMemo(() => nodes.filter((n) => n.nodeType === 'agent'), [nodes]);
const nodeName = useCallback(
(id: number) => nodes.find((n) => n.id === id)?.name ?? `#${id}`,
@@ -79,15 +96,9 @@ export default function ProbeTasksModal({
}
}, [messageApi, t]);
// Load on open (afterOpenChange is an event callback, so resetting
// the previous session's state here is not an effect write).
const onAfterOpenChange = (opened: boolean) => {
if (opened) {
setTasks(null);
setDraft(null);
void load();
}
};
useEffect(() => {
void load();
}, [load]);
// Group by region: region labels ascending, the ungrouped bucket
// last; inside a group the panel order (sort) rules.
@@ -147,7 +158,10 @@ export default function ProbeTasksModal({
nodeIds: draft.nodeIds,
};
if (draft.baseNodeIds) payload.baseNodeIds = draft.baseNodeIds;
const msg = await HttpUtil.put<{ id: number }>(TASKS_URL, payload, { silent: true });
const msg = await HttpUtil.put<{ id: number }>(TASKS_URL, payload, {
silent: true,
...JSON_HEADERS,
});
if (msg?.success) {
messageApi.success(t('pages.nodes.probeTasks.saved'));
setDraft(null);
@@ -160,6 +174,40 @@ export default function ProbeTasksModal({
}
}
async function onBatchAdd() {
const targets = parseTargets(batchText);
if (targets.length === 0) {
messageApi.error(t('pages.nodes.probeTasks.batchEmpty'));
return;
}
setBatchSaving(true);
try {
const msg = await HttpUtil.post<{ ids: number[] }>(
`${TASKS_URL}/batch`,
{
targets,
name: batchName,
interval: batchInterval,
autoJoin: batchAutoJoin,
region: batchRegion,
nodeIds: batchNodeIds,
},
{ silent: true, ...JSON_HEADERS },
);
if (msg?.success) {
messageApi.success(
t('pages.nodes.probeTasks.batchDone', { count: msg.obj?.ids.length ?? targets.length }),
);
setBatchText('');
await load();
} else {
messageApi.error(msg?.msg || t('pages.nodes.probeTasks.saveFailed'));
}
} finally {
setBatchSaving(false);
}
}
async function onDelete(task: ProbeTaskWire) {
const msg = await HttpUtil.delete(`${TASKS_URL}/${task.id}`, { silent: true });
if (msg?.success) {
@@ -226,20 +274,72 @@ export default function ProbeTasksModal({
setDraft((d) => (d ? { ...d, [key]: value } : d));
return (
<Modal
open={open}
title={t('pages.nodes.probeTasks.title')}
onCancel={onClose}
afterOpenChange={onAfterOpenChange}
footer={null}
width={780}
destroyOnHidden
>
<div>
{messageContextHolder}
<Typography.Paragraph type="secondary" style={{ marginTop: 0 }}>
{t('pages.nodes.probeTasks.intro')}
</Typography.Paragraph>
<div
style={{
border: '1px solid var(--ant-color-border-secondary, #f0f0f0)',
borderRadius: 8,
padding: 12,
marginBottom: 16,
}}
>
<Typography.Text strong>{t('pages.nodes.probeTasks.batchTitle')}</Typography.Text>
<Input.TextArea
rows={3}
value={batchText}
placeholder={t('pages.nodes.probeTasks.batchPlaceholder')}
onChange={(e) => setBatchText(e.target.value)}
style={{ marginTop: 8, fontFamily: 'monospace' }}
/>
<Space wrap style={{ marginTop: 8 }}>
<Input
placeholder={t('pages.nodes.probeTasks.name')}
value={batchName}
onChange={(e) => setBatchName(e.target.value)}
style={{ width: 180 }}
/>
<Input
placeholder={t('pages.nodes.probeTasks.region')}
value={batchRegion}
onChange={(e) => setBatchRegion(e.target.value)}
style={{ width: 130 }}
/>
<InputNumber
min={5}
max={3600}
value={batchInterval}
onChange={(v) => setBatchInterval(v ?? 60)}
addonAfter="s"
style={{ width: 120 }}
/>
<span>
{t('pages.nodes.probeTasks.autoJoin')}{' '}
<Switch checked={batchAutoJoin} onChange={setBatchAutoJoin} />
</span>
<Select
mode="multiple"
placeholder={t('pages.nodes.probeTasks.nodesPlaceholder')}
value={batchNodeIds}
onChange={setBatchNodeIds}
options={agentNodes.map((n) => ({ value: n.id, label: n.name }))}
style={{ minWidth: 260 }}
/>
<Button type="primary" loading={batchSaving} onClick={() => void onBatchAdd()}>
{t('pages.nodes.probeTasks.batchSubmit')}
</Button>
</Space>
<div>
<Typography.Text type="secondary">
{t('pages.nodes.probeTasks.batchHint')}
</Typography.Text>
</div>
</div>
{tasks === null ? (
<Typography.Text type="secondary">{t('pages.nodes.probeTasks.loading')}</Typography.Text>
) : tasks.length === 0 && !draft ? (
@@ -326,6 +426,6 @@ export default function ProbeTasksModal({
{t('pages.nodes.probeTasks.add')}
</Button>
)}
</Modal>
</div>
);
}
+8 -246
View File
@@ -20,11 +20,13 @@ import { DeleteOutlined, SettingOutlined, UploadOutlined } from '@ant-design/ico
import { HttpUtil } from '@/utils';
import { onNumber } from '@/utils/onNumber';
// Item 7 B5: the public status page's settings + theme store. Like
// the probe-meta section this surface is self-contained — it loads
// and saves through its own endpoints and every change takes effect
// immediately (no panel save, no restart). Theme packages are static
// files served by the panel; installing one never runs its code here.
// Item 7 B5: the public status page's theme store + notify block.
// (The public-page settings themselves moved to the probe settings
// page in V2.0.1.) Like the probe-meta section this surface is
// self-contained — it loads and saves through its own endpoints and
// every change takes effect immediately (no panel save, no
// restart). Theme packages are static files served by the panel;
// installing one never runs its code here.
interface ThemeConfigField {
key: string;
type: string;
@@ -47,18 +49,8 @@ interface ThemeWire {
preview: boolean;
}
interface ProbeSettingsWire {
publicPage: boolean;
siteName: string;
site: string;
githubProxy: string;
}
interface ThemesPayload {
themes: ThemeWire[];
settings: ProbeSettingsWire;
favicon: string;
touchIcon: string;
}
// Notify settings wire shape (B6) — mirrors probenotify.SettingsView.
@@ -78,83 +70,27 @@ interface NotifySettingsWire {
notifyWebhookHeadersSet: boolean;
}
interface CoverageRow {
name: string;
online: boolean;
hasProbe: boolean;
probeAt: number;
}
interface NotifyPayload {
settings: NotifySettingsWire;
coverage: CoverageRow[];
}
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } };
const UPLOAD_CHUNK = 1 << 20;
const MAX_ICON_BYTES = 20 * 1024;
function basePathPrefix(): string {
const bp = window.X_UI_BASE_PATH;
return typeof bp === 'string' && bp !== '/' ? bp.replace(/\/$/, '') : '';
}
function readAsDataURL(blob: Blob): Promise<string> {
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: small files pass through untouched (the server judges
// the bytes, not the declared type); 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<string> {
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<Blob | null>((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 PublicPageTab() {
const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
const [themes, setThemes] = useState<ThemeWire[]>([]);
const [settings, setSettings] = useState<ProbeSettingsWire>({
publicPage: false,
siteName: 'Monitor',
site: '',
githubProxy: '',
});
const [favicon, setFavicon] = useState('');
const [touchIcon, setTouchIcon] = useState('');
const [saving, setSaving] = useState(false);
const [installURL, setInstallURL] = useState('');
const [installing, setInstalling] = useState(false);
const [uploadPct, setUploadPct] = useState<number | null>(null);
const [busyShort, setBusyShort] = useState('');
const [notify, setNotify] = useState<NotifySettingsWire | null>(null);
const [coverage, setCoverage] = useState<CoverageRow[]>([]);
const [webhookUrl, setWebhookUrl] = useState('');
const [webhookHeaders, setWebhookHeaders] = useState('');
const [urlTouched, setUrlTouched] = useState(false);
@@ -162,14 +98,9 @@ export default function PublicPageTab() {
const [notifySaving, setNotifySaving] = useState(false);
const [notifyTesting, setNotifyTesting] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const faviconRef = useRef<HTMLInputElement>(null);
const touchRef = useRef<HTMLInputElement>(null);
const applyPayload = useCallback((payload: ThemesPayload) => {
setThemes(payload.themes ?? []);
setSettings(payload.settings);
setFavicon(payload.favicon ?? '');
setTouchIcon(payload.touchIcon ?? '');
}, []);
const reload = useCallback(async () => {
@@ -185,7 +116,6 @@ export default function PublicPageTab() {
HttpUtil.get<NotifyPayload>('/panel/api/probe/notify', { silent: true }).then((msg) => {
if (!cancelled && msg?.success && msg.obj) {
setNotify(msg.obj.settings);
setCoverage(msg.obj.coverage ?? []);
}
});
return () => {
@@ -215,7 +145,6 @@ export default function PublicPageTab() {
});
if (msg?.success && msg.obj) {
setNotify(msg.obj.settings);
setCoverage(msg.obj.coverage ?? []);
setWebhookUrl('');
setWebhookHeaders('');
setUrlTouched(false);
@@ -247,32 +176,6 @@ export default function PublicPageTab() {
}
}
async function saveSettings() {
setSaving(true);
try {
const msg = await HttpUtil.put(
'/panel/api/probe/settings',
{ ...settings, favicon, touchIcon },
{ 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) return;
try {
const url = await fileToIconDataURL(file);
if (which === 'favicon') setFavicon(url);
else setTouchIcon(url);
} catch {
messageApi.error(t('pages.settings.probeThemes.iconTooLarge'));
}
}
async function uploadTheme(file: File | undefined) {
if (!file) return;
if (file.size === 0) {
@@ -371,101 +274,7 @@ export default function PublicPageTab() {
return (
<div>
{messageContextHolder}
<Typography.Title level={5}>{t('pages.settings.probeThemes.settingsTitle')}</Typography.Title>
<Row gutter={[16, 12]}>
<Col xs={24} md={12}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.publicPage')}</div>
<Switch
checked={settings.publicPage}
onChange={(v) => setSettings({ ...settings, publicPage: v })}
/>
<Typography.Text type="secondary" style={{ marginLeft: 8 }}>
{t('pages.settings.probeThemes.publicPageHint')}
</Typography.Text>
</Col>
<Col xs={24} md={12}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.siteName')}</div>
<Input
value={settings.siteName}
onChange={(e) => setSettings({ ...settings, siteName: e.target.value })}
/>
</Col>
<Col xs={24} md={12}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.site')}</div>
<Input
value={settings.site}
placeholder="https://"
onChange={(e) => setSettings({ ...settings, site: e.target.value })}
/>
</Col>
<Col xs={24} md={12}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.githubProxy')}</div>
<Input
value={settings.githubProxy}
placeholder="https://"
onChange={(e) => setSettings({ ...settings, githubProxy: e.target.value })}
/>
<Typography.Text type="secondary">
{t('pages.settings.probeThemes.githubProxyHint')}
</Typography.Text>
</Col>
<Col xs={24} md={12}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.favicon')}</div>
<Space>
{favicon ? <img src={favicon} alt="" style={{ width: 24, height: 24 }} /> : null}
<Button size="small" onClick={() => faviconRef.current?.click()}>
{t('pages.settings.probeThemes.chooseImage')}
</Button>
{favicon ? (
<Button size="small" onClick={() => setFavicon('')}>
{t('clear')}
</Button>
) : null}
</Space>
<input
ref={faviconRef}
type="file"
accept="image/*"
hidden
onChange={(e) => pickIcon('favicon', e.target.files?.[0])}
/>
</Col>
<Col xs={24} md={12}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.touchIcon')}</div>
<Space>
{touchIcon ? <img src={touchIcon} alt="" style={{ width: 24, height: 24 }} /> : null}
<Button size="small" onClick={() => touchRef.current?.click()}>
{t('pages.settings.probeThemes.chooseImage')}
</Button>
{touchIcon ? (
<Button size="small" onClick={() => setTouchIcon('')}>
{t('clear')}
</Button>
) : null}
</Space>
<input
ref={touchRef}
type="file"
accept="image/*"
hidden
onChange={(e) => pickIcon('touchIcon', e.target.files?.[0])}
/>
<div>
<Typography.Text type="secondary">
{t('pages.settings.probeThemes.iconHint')}
</Typography.Text>
</div>
</Col>
</Row>
<div style={{ marginTop: 12 }}>
<Button type="primary" loading={saving} onClick={saveSettings}>
{t('save')}
</Button>
</div>
<Typography.Title level={5} style={{ marginTop: 28 }}>
{t('pages.settings.probeThemes.title')}
</Typography.Title>
<Typography.Title level={5}>{t('pages.settings.probeThemes.title')}</Typography.Title>
<Space wrap style={{ marginBottom: 16 }}>
<Button icon={<UploadOutlined />} onClick={() => fileRef.current?.click()}>
{uploadPct === null
@@ -745,53 +554,6 @@ export default function PublicPageTab() {
</>
) : null}
<Typography.Title level={5} style={{ marginTop: 28 }}>
{t('pages.settings.probeNotify.retireTitle')}
</Typography.Title>
<Typography.Paragraph type="secondary">
{t('pages.settings.probeNotify.retireHint')}
</Typography.Paragraph>
<Space direction="vertical">
<span>
{t('pages.settings.probeNotify.retirePublicPage')}:
{settings.publicPage ? (
<Tag color="green">{t('pages.settings.probeNotify.configured')}</Tag>
) : (
<Tag color="orange">{t('pages.settings.probeNotify.retirePublicPageOff')}</Tag>
)}
</span>
{coverage.map((row) => (
<span key={row.name}>
{row.name}:
{row.hasProbe ? (
<Tag color="green">{t('pages.settings.probeNotify.retireHasProbe')}</Tag>
) : (
<Tag color="red">{t('pages.settings.probeNotify.retireNoProbe')}</Tag>
)}
{row.online ? (
<Tag color="green">{t('pages.settings.probeNotify.retireOnline')}</Tag>
) : (
<Tag>{t('pages.settings.probeNotify.retireOffline')}</Tag>
)}
{row.probeAt > 0 ? (
<Typography.Text type="secondary">
{t('pages.settings.probeNotify.retireProbeAt', {
time: new Date(row.probeAt).toLocaleString(),
})}
</Typography.Text>
) : null}
</span>
))}
{coverage.length > 0 && coverage.every((row) => row.hasProbe) ? (
<Typography.Text type="success">
{t('pages.settings.probeNotify.retireAllReady')}
</Typography.Text>
) : (
<Typography.Text type="warning">
{t('pages.settings.probeNotify.retireNotReady')}
</Typography.Text>
)}
</Space>
</div>
);
}
+4 -2
View File
@@ -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 <SubscriptionGeneralTab allSetting={allSetting} updateSetting={updateSetting} />;
case 'public-page':
return <PublicPageTab />;
case 'probe':
return <ProbeSettingsTab />;
default:
return <GeneralTab allSetting={allSetting} updateSetting={updateSetting} />;
}
@@ -247,7 +250,6 @@ export default function SettingsPage() {
</Col>
<Col xs={24} sm={14} className="header-info">
<FloatButton.BackTop target={scrollTarget} visibilityHeight={200} />
<Alert type="warning" showIcon title={t('pages.settings.infoDesc')} />
</Col>
</Row>
</Card>
+3
View File
@@ -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(),
@@ -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"`;
-27
View File
@@ -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}$/);
});
});
+1 -1
View File
@@ -76,7 +76,7 @@ esac
# sing-box 核心在 NexusGate 仓的专用发行里(见下方 SINGBOX_BASE)。
# 注意:本脚本是 V2 版——只装 sing-box 运行时;主控未升 V2 前不要用它
# 重装节点(节点升级走面板一键更新,不需要本脚本)。
RELEASE_BASE="https://git.881218.xyz/rogue/3x-ui-agent/releases/download/V2.0.0"
RELEASE_BASE="https://git.881218.xyz/rogue/3x-ui-agent/releases/download/V2.0.1"
XUI_URL="${XUI_URL:-${RELEASE_BASE}/x-ui-linux-${BIN_ARCH}}"
ROOT="${XUI_ROOT:-}"
+111 -3
View File
@@ -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 {
+151
View File
@@ -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)
}
}
+36 -1
View File
@@ -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 {
+23
View File
@@ -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 {
+93
View File
@@ -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)
}
}
+162
View File
@@ -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
}
+130
View File
@@ -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)
}
}
+59
View File
@@ -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")
}
}
+13 -8
View File
@@ -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"`
@@ -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
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
@@ -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
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
@@ -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
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
+9 -27
View File
@@ -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)
+17 -64
View File
@@ -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)
}
}
}
+61 -8
View File
@@ -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).
@@ -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")
}
}
+11 -11
View File
@@ -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
@@ -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 := "<!doctype html><title>theme</title>"
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())
}
}
+50 -31
View File
@@ -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"
+7 -11
View File
@@ -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)
}
+22 -9
View File
@@ -211,10 +211,12 @@ func (pc *PublicProbeController) nodeMetrics(c *gin.Context) {
c.JSON(http.StatusOK, resp)
}
// liveWS is the hub's browser stream: a snapshot frame every 2 s,
// text by default, binary gzip frames for the anonymous audience when
// opened with ?gzip. Audience eligibility is re-checked every tick —
// a socket outlives both the session and the public-page switch.
// liveWS is the hub's browser stream: a snapshot frame on the
// operator's fast-report cadence (V2.0.2; the hub's fixed 2 s tick
// made a 1 s agent cadence invisible up front), text by default,
// binary gzip frames for the anonymous audience when opened with
// ?gzip. Audience eligibility is re-checked every tick — a socket
// outlives both the session and the public-page switch.
func (pc *PublicProbeController) liveWS(c *gin.Context) {
authed := probeAuthed(c) && !fromOtherOrigin(c)
if !authed && !service.ProbePublicPageEnabled() {
@@ -280,18 +282,19 @@ func (pc *PublicProbeController) liveWS(c *gin.Context) {
return conn.WriteMessage(frameType, frame) == nil
}
// The first frame goes out immediately (hub's interval ticks at
// once), then one per 2 s tick.
// The first frame goes out immediately; later frames follow the
// fast-report cadence, re-read after every frame so a settings
// change lands on a live socket without a reconnect.
if !send() {
return
}
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
timer := time.NewTimer(time.Duration(service.ProbeFastIntervalSec()) * time.Second)
select {
case <-done:
timer.Stop()
return
case <-ticker.C:
case <-timer.C:
if !send() {
return
}
@@ -378,5 +381,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, "/"))
}
+109
View File
@@ -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
// ("<name> · <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 {
@@ -0,0 +1,63 @@
package controller
import (
"net/http/httptest"
"strings"
"testing"
"time"
ws "github.com/gorilla/websocket"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
)
// V2.0.2: /api/ws frame cadence follows the operator's fast-report
// interval (probe_fast_interval) instead of the hub's fixed 2 s tick.
// A mid-stream settings change re-paces a live socket: the timer
// already in flight still fires on the old cadence, the next one is
// armed from the re-read setting.
func TestProbeWebSocketCadenceFollowsFastInterval(t *testing.T) {
rig := newProbeTestRig(t)
_, _ = rig.seedAgent(t, "CadenceNode", true)
rig.setSetting(t, "public_page", "on")
rig.setSetting(t, "probe_fast_interval", "1")
service.InvalidateProbeSnapshot()
srv := httptest.NewServer(rig.engine)
defer srv.Close()
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/api/ws"
conn, _, err := ws.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("dial: %v", err)
}
defer conn.Close()
readAt := func() time.Time {
t.Helper()
_ = conn.SetReadDeadline(time.Now().Add(10 * time.Second))
if _, _, err := conn.ReadMessage(); err != nil {
t.Fatalf("ws read: %v", err)
}
return time.Now()
}
_ = readAt() // first frame: immediate
prev := readAt()
for i := 0; i < 2; i++ {
now := readAt()
if gap := now.Sub(prev); gap < 500*time.Millisecond || gap > 2500*time.Millisecond {
t.Fatalf("frame gap at 1 s cadence: %v", gap)
}
prev = now
}
// Change to 3 s mid-stream: one in-flight 1 s frame, then the
// stream settles onto the new cadence without a reconnect.
rig.setSetting(t, "probe_fast_interval", "3")
prev = readAt()
now := readAt()
if gap := now.Sub(prev); gap < 2200*time.Millisecond || gap > 6*time.Second {
t.Fatalf("frame gap after change to 3 s: %v", gap)
}
}
+12
View File
@@ -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
}
@@ -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" {
+36 -28
View File
@@ -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
+20 -5
View File
@@ -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, &notify, &m.CountryPin, &m.IPv4Pin, &m.IPv6Pin); err != nil {
&m.GroupName, &m.PublicRemark, &notify, &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
}
@@ -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)
}
}
+52 -1
View File
@@ -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, &notNull, &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 {
+43 -4
View File
@@ -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()
}
-57
View File
@@ -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
}
+20
View File
@@ -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
+33 -10
View File
@@ -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)
}
+35
View File
@@ -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,
+133
View File
@@ -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)
}
}
@@ -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 ""
}
+206
View File
@@ -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
}
+24 -15
View File
@@ -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": "تم إعادة تشغيل اللوحة بنجاح",
+24 -15
View File
@@ -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.",
+24 -15
View File
@@ -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",
+24 -15
View File
@@ -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": "پنل با موفقیت راه‌اندازی مجدد شد",
+24 -15
View File
@@ -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",
+24 -15
View File
@@ -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": "パネルの再起動に成功しました",
+24 -15
View File
@@ -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",
+24 -15
View File
@@ -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": "Панель успешно перезапущена",
+24 -15
View File
@@ -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ı.",
+24 -15
View File
@@ -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": "Панель успішно перезапущено",
+24 -15
View File
@@ -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",
+24 -15
View File
@@ -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": "面板已成功重启",
+24 -15
View File
@@ -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": "面板已成功重新啟動",