Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4d259fa58 | ||
|
|
9caa4e0cf9 | ||
|
|
29de9761c8 |
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+137
-37
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"`;
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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, "/"))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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" {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -30,6 +30,15 @@ type Meta struct {
|
||||
CountryPin string
|
||||
IPv4Pin string
|
||||
IPv6Pin string
|
||||
// TrafficCalUpGB / TrafficCalDownGB are the operator's ledger
|
||||
// corrections in GB (V2.0.1), either sign: added to the counted
|
||||
// uplink (tx) / downlink (rx) figures wherever the ledger is
|
||||
// read — panel statistics, the public page and traffic alerts.
|
||||
TrafficCalUpGB float64
|
||||
TrafficCalDownGB float64
|
||||
// StatIface pins the one interface whose counters the agent
|
||||
// books (V2.0.1); "" keeps the automatic counted-set selection.
|
||||
StatIface string
|
||||
}
|
||||
|
||||
// DefaultMeta returns the metadata of an agent that has no row yet —
|
||||
@@ -48,7 +57,8 @@ func DefaultMeta(agentID int64) Meta {
|
||||
|
||||
const metaColumns = `agent_id, price, currency, billing_cycle, expires_at,
|
||||
traffic_limit, traffic_mode, traffic_reset_day, public, sort,
|
||||
group_name, public_remark, notify, country_pin, ipv4_pin, ipv6_pin`
|
||||
group_name, public_remark, notify, country_pin, ipv4_pin, ipv6_pin,
|
||||
traffic_cal_up_gb, traffic_cal_down_gb, stat_iface`
|
||||
|
||||
func scanMeta(sc interface{ Scan(...any) error }) (Meta, error) {
|
||||
var m Meta
|
||||
@@ -56,7 +66,8 @@ func scanMeta(sc interface{ Scan(...any) error }) (Meta, error) {
|
||||
var public, notify int64
|
||||
if err := sc.Scan(&m.AgentID, &m.Price, &m.Currency, &m.BillingCycle, &expires,
|
||||
&m.TrafficLimit, &m.TrafficMode, &m.TrafficResetDay, &public, &m.Sort,
|
||||
&m.GroupName, &m.PublicRemark, ¬ify, &m.CountryPin, &m.IPv4Pin, &m.IPv6Pin); err != nil {
|
||||
&m.GroupName, &m.PublicRemark, ¬ify, &m.CountryPin, &m.IPv4Pin, &m.IPv6Pin,
|
||||
&m.TrafficCalUpGB, &m.TrafficCalDownGB, &m.StatIface); err != nil {
|
||||
return Meta{}, err
|
||||
}
|
||||
if expires.Valid && strings.TrimSpace(expires.String) != "" {
|
||||
@@ -107,7 +118,7 @@ func (s *Store) UpsertMeta(m Meta) error {
|
||||
expires = *m.ExpiresAt
|
||||
}
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO probe_meta (`+metaColumns+`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
`INSERT INTO probe_meta (`+metaColumns+`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(agent_id) DO UPDATE SET
|
||||
price=excluded.price, currency=excluded.currency,
|
||||
billing_cycle=excluded.billing_cycle, expires_at=excluded.expires_at,
|
||||
@@ -116,10 +127,14 @@ func (s *Store) UpsertMeta(m Meta) error {
|
||||
sort=excluded.sort, group_name=excluded.group_name,
|
||||
public_remark=excluded.public_remark, notify=excluded.notify,
|
||||
country_pin=excluded.country_pin, ipv4_pin=excluded.ipv4_pin,
|
||||
ipv6_pin=excluded.ipv6_pin`,
|
||||
ipv6_pin=excluded.ipv6_pin,
|
||||
traffic_cal_up_gb=excluded.traffic_cal_up_gb,
|
||||
traffic_cal_down_gb=excluded.traffic_cal_down_gb,
|
||||
stat_iface=excluded.stat_iface`,
|
||||
m.AgentID, m.Price, m.Currency, m.BillingCycle, expires,
|
||||
m.TrafficLimit, m.TrafficMode, m.TrafficResetDay, boolToInt(m.Public), m.Sort,
|
||||
m.GroupName, m.PublicRemark, boolToInt(m.Notify), m.CountryPin, m.IPv4Pin, m.IPv6Pin)
|
||||
m.GroupName, m.PublicRemark, boolToInt(m.Notify), m.CountryPin, m.IPv4Pin, m.IPv6Pin,
|
||||
m.TrafficCalUpGB, m.TrafficCalDownGB, m.StatIface)
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,12 @@ CREATE TABLE IF NOT EXISTS probe_meta (
|
||||
notify INTEGER NOT NULL DEFAULT 0,
|
||||
country_pin TEXT NOT NULL DEFAULT '',
|
||||
ipv4_pin TEXT NOT NULL DEFAULT '',
|
||||
ipv6_pin TEXT NOT NULL DEFAULT ''
|
||||
ipv6_pin TEXT NOT NULL DEFAULT '',
|
||||
-- V2.0.1 probe batch: operator traffic calibration (GB, either
|
||||
-- sign) and the counted-interface pin ('' = automatic selection).
|
||||
traffic_cal_up_gb REAL NOT NULL DEFAULT 0,
|
||||
traffic_cal_down_gb REAL NOT NULL DEFAULT 0,
|
||||
stat_iface TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
-- Monotonic byte counters that survive agent reboots and master
|
||||
@@ -204,9 +209,55 @@ func Open(path string) (*Store, error) {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("probe schema: %w", err)
|
||||
}
|
||||
if err := migrateMetaColumns(db); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("probe schema migration: %w", err)
|
||||
}
|
||||
return &Store{db: db, nodes: make(map[int64]*nodeState)}, nil
|
||||
}
|
||||
|
||||
// metaColumnMigrations are the probe_meta columns added after the
|
||||
// table's first release; CREATE TABLE IF NOT EXISTS never retrofits
|
||||
// them onto an existing database, so they are added here, once, by
|
||||
// name check (SQLite has no ADD COLUMN IF NOT EXISTS).
|
||||
var metaColumnMigrations = []struct{ name, ddl string }{
|
||||
{"traffic_cal_up_gb", `ALTER TABLE probe_meta ADD COLUMN traffic_cal_up_gb REAL NOT NULL DEFAULT 0`},
|
||||
{"traffic_cal_down_gb", `ALTER TABLE probe_meta ADD COLUMN traffic_cal_down_gb REAL NOT NULL DEFAULT 0`},
|
||||
{"stat_iface", `ALTER TABLE probe_meta ADD COLUMN stat_iface TEXT NOT NULL DEFAULT ''`},
|
||||
}
|
||||
|
||||
func migrateMetaColumns(db *sql.DB) error {
|
||||
rows, err := db.Query(`PRAGMA table_info(probe_meta)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
have := map[string]bool{}
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name, typ string
|
||||
var notNull int
|
||||
var dflt sql.NullString
|
||||
var pk int
|
||||
if err := rows.Scan(&cid, &name, &typ, ¬Null, &dflt, &pk); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
have[name] = true
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, m := range metaColumnMigrations {
|
||||
if have[m.name] {
|
||||
continue
|
||||
}
|
||||
if _, err := db.Exec(m.ddl); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close releases the database handle.
|
||||
func (s *Store) Close() error {
|
||||
if s == nil || s.db == nil {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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": "تم إعادة تشغيل اللوحة بنجاح",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "پنل با موفقیت راهاندازی مجدد شد",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "パネルの再起動に成功しました",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Панель успешно перезапущена",
|
||||
|
||||
@@ -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ı.",
|
||||
|
||||
@@ -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": "Панель успішно перезапущено",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "面板已成功重启",
|
||||
|
||||
@@ -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": "面板已成功重新啟動",
|
||||
|
||||
Reference in New Issue
Block a user