20 Commits
Author SHA1 Message Date
Muse 3a3c7c0dc9 install-agent: RELEASE_BASE -> V2.3.0 (sh + ps1)
CI / go-test (push) Waiting to run
CI / postgres-durable-first (push) Waiting to run
CI / codegen (push) Waiting to run
CI / govulncheck (push) Waiting to run
CI / race (push) Waiting to run
CI / fuzz-smoke (push) Waiting to run
CI / golangci (push) Waiting to run
CI / frontend (push) Waiting to run
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (amd64) (push) Waiting to run
Release 3X-UI / Publish rolling dev release (push) Blocked by required conditions
CodeQL Advanced / Analyze (go) (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
2026-10-23 17:26:25 +00:00
Muse 388e26c292 geoip: pin default weekly-update URL to Gitea geoip-country asset (V2.3.0) 2026-10-23 17:26:15 +00:00
Muse 1d1258a10d merge fix/no-probeonly-flag into main (V2.3.0) 2026-10-23 17:25:59 +00:00
Muse 81299db6ff merge feat/geoip-flag into main (V2.3.0) 2026-10-23 17:25:55 +00:00
Muse 89b65f1606 merge feat/agent-split into main (V2.3.0) 2026-10-23 17:25:50 +00:00
Muse d16b888146 geoip: tests — known-address calibration, source order, pin precedence, updater download/validate/replace, contract frame 2026-10-23 16:42:28 +00:00
Muse c94136c8a1 geoip: resolve node countries locally for the public views (Q7)
country/country_auto in buildProbeNodeView now come from a local
MMDB lookup of the node's public address (held v4, held v6, seen
v4, seen v6) instead of the pin alone / a hardcoded empty string.
Layered data: local file in the data folder wins, embedded factory
snapshot is the fallback, no database means the pre-flag empty
shape. A weekly job conditionally downloads a fresher copy,
validates it, atomically replaces the local file and hot-swaps the
reader; no URL configured (geoipCountryDefaultURL / the
geoip_country_url setting) means the updater stays silent. No
address ever leaves the master.
2026-10-23 16:42:28 +00:00
Muse e7f4bfed24 geoip: embed DB-IP Lite country database snapshot (CC BY 4.0, provenance in NOTICE) 2026-10-23 16:42:28 +00:00
Muse 6b9832cdcc install-agent: download the standalone agent artifact
XUI_URL default switches from x-ui-linux-<arch> (panel binary) to
x-ui-agent-linux-<arch>; RELEASE_BASE mechanism unchanged. The new
asset exists from the first split release on — bumping RELEASE_BASE
stays a release-step action.
2026-10-23 16:40:35 +00:00
Muse e78cb8ccc5 feat(agent): standalone Linux agent binary + agent assets in update chain
New entry cmd/xui-agent links only the agent dependency closure
(internal/agent + config/logger/selfupdate/xray/util leaves), a
drop-in replacement for the dual-mode binary's agent subcommand:
same invocation (xui-agent agent -config ...), same agent.json and
state files, same wire protocol.

selfupdate gains AgentAssetName (x-ui-agent-linux-<arch>); the
agent's one-click update now downloads the agent asset instead of
the panel binary. Pre-split agents keep fetching the panel asset —
the two forms are interchangeable at the install path.
2026-10-23 16:40:35 +00:00
Muse ef662c9fdb panel: install commands no longer append --probe-only
Probe-only is governed solely by the master-side flag so unchecking it
in the panel takes effect on its own; an agent.json preset would pin
it via the dual-source OR gate. The install-agent.sh/ps1 --probe-only
flag stays for manual installs. tsc --noEmit clean.
2026-10-24 00:22:24 +08:00
Muse a81eff8c0d install-agent: RELEASE_BASE -> V2.2.1 (sh + ps1)
CI / go-test (push) Canceled after 0s
CI / postgres-durable-first (push) Canceled after 0s
CI / codegen (push) Canceled after 0s
CI / govulncheck (push) Canceled after 0s
CI / race (push) Canceled after 0s
CI / fuzz-smoke (push) Canceled after 0s
CI / golangci (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
Release 3X-UI / build (386) (push) Canceled after 0s
Release 3X-UI / build (amd64) (push) Canceled after 0s
Release 3X-UI / build (arm64) (push) Canceled after 0s
Release 3X-UI / build (armv5) (push) Canceled after 0s
Release 3X-UI / build (armv6) (push) Canceled after 0s
Release 3X-UI / build (armv7) (push) Canceled after 0s
Release 3X-UI / build (s390x) (push) Canceled after 0s
Release 3X-UI / Build for Windows (amd64) (push) Canceled after 0s
Release 3X-UI / Publish rolling dev release (push) Canceled after 0s
CodeQL Advanced / Analyze (go) (push) Canceled after 0s
CodeQL Advanced / Analyze (actions) (push) Canceled after 0s
CodeQL Advanced / Analyze (javascript-typescript) (push) Canceled after 0s
2026-10-23 15:25:09 +00:00
Muse 1d7eb16784 merge feat/v221: windows probe degradation + agent heartbeat probe + update-check fallback + procd/OpenRC script fixes + probe traffic GB unification + public remark name (V2.2.1) 2026-10-23 15:24:55 +00:00
Muse a13179ed9b v221 ⑦ public views: node name prefers the public remark
buildProbeNodeView now sources its 'name' from probeDisplayName:
the node's public remark (probe_meta.public_remark) when non-empty
after trimming, else the node name as before. Contract field name
unchanged, theme packages untouched, both audiences (anonymous and
admin frames share the builder); the panel's internal node list is
a different surface and keeps the plain node name.

Tests: display-name unit cases (remark / empty / whitespace) and
view-level assertions for both audiences; the public contract
test's seeded expectation updated to the remark (its fixture sets
public_remark='shown publicly' on node 'PubNode').
2026-10-23 15:02:12 +00:00
Muse 93c1620c15 v221 ⑥ probe traffic: binary GB convention + one-shot calibration
Unit unification: GB entries converted at 1e9 while every byte
formatter is binary (1024^3), so a 3000 GB limit rendered 2.73 TB
instead of 2.93 TB. calBytes and the form's limit conversion now
use 1024^3. Stored traffic_limits (bytes, typed x1e9) are rescaled
x1.073741824 exactly once at store open, guarded by the
probe_setting mark gb_1024_migrated_v1; calibration values are
stored in GB (no rescale — only their conversion changed) and
price/sort/reset-day are unitless.

One-shot calibration: the form's cal fields are deltas to apply
now. probe.MergeCalDelta folds a non-zero submitted value into the
stored standing correction and keeps the standing value on a
zero/absent submission; putMeta merges against the stored row
(SaveMeta/UpsertMeta keep plain replace — the notify sweep relies
on it). GET no longer echoes the standing correction into the form,
and the frontend keeps the cal inputs as local state that starts
empty and clears on successful save, so a later unrelated save
resubmits nothing.

Tests: calBytes(3000)=3000x2^30; migration 3.0e12 -> 3000x2^30 with
no second rescale on reopen; one-shot flow (save delta, save
unrelated with zero cal -> calibrated traffic unmoved, second delta
accumulates once). Existing TestTrafficCalibrationAppliedAndFloored
expectation updated to the binary figure. No vitest covers
ProbeMetaSection (disclosed in the report).
2026-10-23 15:02:11 +00:00
Muse ba57fb3c00 v221 ⑤ install-agent: OpenRC respawn_period=0 rejected by supervise-daemon
The item-23 template wrote respawn_period=0 assuming it meant 'no
window' like respawn_max=0; supervise-daemon validates the value at
service start and refuses: Invalid respawn-period value '0'
(Alpine real-machine report). Template now writes 1800, the value
the machine's other supervise-daemon services use; with
respawn_max=0 (unlimited) the period is only the counting window.

Why existing Alpine nodes (Leg-US) never hit it: the respawn lines
exist only since 27a5097 (2026-10-22, item 23) — before that the
template used command_background with no respawn parameters at all,
and panel one-click agent updates swap only the binary, never
regenerate /etc/init.d/x-ui-agent. Only fresh installs (or script
reruns) on OpenRC with the post-item-23 template were exposed.
2026-10-23 14:27:23 +00:00
Muse 151b07b73f v221 ④ install-agent: OpenWrt/procd support + honest nohup fallback
detect_init_system gains a procd tier ([ -f /etc/rc.common ] &&
command -v procd, after systemd/OpenRC; XUI_INIT_SYSTEM=procd also
accepted). start_via_procd writes /etc/init.d/xui-agent byte-equal
to the unit hand-verified on AX6600 (paths via AGENT_BIN/CONF_DIR),
stops any previous instance before overwriting, enables + starts,
and verifies the process is alive (pgrep, ps fallback). The nohup
fallback now errors out immediately and clearly when nohup itself
is missing (BusyBox-trimmed systems) instead of pretending to start
and failing at the end.
2026-10-23 14:26:31 +00:00
Muse cec5905483 v221 ③ update check: fall back to raw version when display is unparseable
displayUpdateFields returned avail=false whenever the running
build's display version could not be compared (the VV2.1.0
double-letter builds), so the panel swallowed a real update prompt
and the native updater refused to run. displayUpdateFieldsFor now
takes the current display/raw versions as inputs and, when the
display comparison cannot judge, compares the latest tag against
the raw base version with the same tolerant display parser —
unparseable raw base yields false. (Not isNewerVersion: on this
fork's uppercase-V tags it degrades to string inequality, which
would phantom-report an update on form difference alone.)
Parseable display versions keep the segment rule untouched.
Regression tests cover the VV scenario in both raw forms.
2026-10-23 14:26:31 +00:00
Muse 1df78dd874 v221 ② node probe: agent nodes answer from heartbeat, not a dead dial
NodeService.Probe now checks for a bound agent first: agent-managed
nodes synthesize the result from agent_nodes.last_seen (MAX over
bound agents, ms) — fresh within the same 180s window the heartbeat
job expires by = online with the heartbeat age as latency; stale or
never-seen = offline with LatencyMs -1. No dial, and LastError is
never written for agent nodes (the stored dial error from the
placeholder address https://agent:2053 is also healed on next ⚡,
since the synthesized patch carries an empty error through
UpdateHeartbeat). Traditional-node dial path unchanged; verdict is a
pure function with unit tests.

Frontend: the ⚡ tooltip/menu used t('pages.nodes.probe'), which
since the probe-metadata work resolves to the status-page SECTION
object, so i18next fell back to rendering the raw key. The existing
pages.nodes.testConnection key (present in all 13 locales) is the
right label — point the three usages at it. No new i18n keys.
2026-10-23 14:26:31 +00:00
Muse 92dfdc542c v221 ① windows probe: gate optional procs via Find + per-source recover guard
LazyProc.Call must-finds and panics on a missing export; the trimmed
Tianyi cloud-PC image has no FreeMibIfTable2 in iphlpapi.dll, which
killed the agent on its first collection after a successful register.
Optional procs (GetSystemTimes/GlobalMemoryStatusEx/GetIfTable2Ex+
FreeMibIfTable2 as one gated pair) now resolve through sourceGate
(Find once, missing = source disabled with one warning); the iface
pair is gated together so a missing free proc never leaks the
system table. collectMetrics additionally runs every source through
guardedCall so any residual panic zeroes that source alone.
Pure gate/guard logic lives in probe_guard.go (no build tag) with
Linux-runnable regression tests.
2026-10-23 14:26:22 +00:00
34 changed files with 1737 additions and 65 deletions
+113
View File
@@ -0,0 +1,113 @@
// Command xui-agent is the standalone Linux build of the 3x-ui node
// agent (agent/panel split, phase 1). Until the split, nodes ran the
// panel binary under the "agent" subcommand, shipping the whole web
// panel and the embedded frontend to every node. This entry links only
// the agent's own dependency closure (internal/agent plus config,
// logger, selfupdate, xray-api types and two util leaves), so the node
// artifact is a fraction of the panel binary while remaining a drop-in
// replacement for it:
//
// - invocation is unchanged: service units run
// "xui-agent agent [-config /etc/x-ui/agent.json]" (the bare
// "-config" form is also accepted for manual runs);
// - agent.json, the traffic baseline and the other state files keep
// their names, locations and formats;
// - the wire protocol with the master (register / heartbeat /
// config / traffic / probe-report / update intent) is byte-for-
// byte the internal/agent implementation the dual-mode binary ran.
//
// The agent lifecycle wiring below mirrors main.go's agent branch
// exactly — the same internal/agent package does the work either way,
// and cmd/xui-agent-windows is the Windows sibling of this entry.
package main
import (
"flag"
"fmt"
"log"
"os"
"os/signal"
"path/filepath"
"syscall"
"github.com/mhsanaei/3x-ui/v3/internal/agent"
"github.com/mhsanaei/3x-ui/v3/internal/config"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/op/go-logging"
)
func main() {
args := os.Args[1:]
// Version self-report: the agent's own display version (A-form),
// from the same build-time displayVersion injection the panel
// uses. Kept ahead of the subcommand so it works however the
// binary was invoked.
if len(args) > 0 {
switch args[0] {
case "-v", "--version", "version":
fmt.Println(config.GetAgentDisplayVersion())
return
}
}
// Service units (systemd / OpenRC / procd / nohup fallback in
// install-agent.sh) invoke the binary with the historical "agent"
// subcommand; consume it so those units keep working unchanged.
if len(args) > 0 && args[0] == "agent" {
args = args[1:]
} else if len(args) > 0 && args[0][0] != '-' {
fmt.Fprintf(os.Stderr, "xui-agent: unknown subcommand %q (this binary only runs the node agent)\n", args[0])
os.Exit(2)
}
runAgent(args)
}
// runAgent is main.go's agent branch, verbatim in behaviour: same
// flags, same defaults, same shutdown wiring.
func runAgent(args []string) {
agentCmd := flag.NewFlagSet("agent", flag.ExitOnError)
var configPath string
agentCmd.StringVar(&configPath, "config", "/etc/x-ui/agent.json", "Path to agent config file")
agentCmd.Parse(args)
// Agent mode always uses the panel's directory layout for the
// xray binary, geo files and config.json. Scoped to agent mode
// only so the panel's own relative "bin" default is untouched.
if os.Getenv("XUI_BIN_FOLDER") == "" {
_ = os.Setenv("XUI_BIN_FOLDER", agent.DefaultBinFolder)
}
cfg, err := agent.LoadConfig(configPath)
if err != nil {
log.Fatalf("Failed to load agent config from %s: %v", configPath, err)
}
// The acknowledged traffic baseline persists next to the agent
// config (its state home, e.g. /etc/x-ui), unless the config
// names a path.
if cfg.BaselinePath == "" {
cfg.BaselinePath = filepath.Join(filepath.Dir(configPath), "agent-traffic-baseline.json")
}
// Initialize logger
logger.InitLogger(logging.INFO)
log.Printf("Starting 3x-ui agent (node: %s)", cfg.NodeName)
ag := agent.New(cfg)
// Handle shutdown signals
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigCh
log.Println("Shutting down agent...")
ag.Stop()
}()
if err := ag.Run(); err != nil {
log.Fatalf("Agent error: %v", err)
}
}
+8 -10
View File
@@ -84,9 +84,6 @@ export default function NodeFormModal({
const [testResult, setTestResult] = useState<ProbeResult | null>(null);
const [agentToken, setAgentToken] = useState<string | null>(null);
const [agentNodeName, setAgentNodeName] = useState('');
// Whether the just-created agent node was flagged probe-only, so the
// install command below can carry the matching --probe-only flag.
const [agentProbeOnly, setAgentProbeOnly] = useState(false);
const [agentMasterUrl, setAgentMasterUrl] = useState('');
// Edit mode (item 19): the stored install material for an existing agent
// node, fetched from the master; null until loaded / for non-agent nodes.
@@ -104,7 +101,6 @@ export default function NodeFormModal({
const inboundSyncMode = useWatch({ control: methods.control, name: 'inboundSyncMode' }) ?? 'all';
const nodeType = useWatch({ control: methods.control, name: 'nodeType' }) ?? 'traditional';
const isAgent = nodeType === 'agent';
const probeOnly = useWatch({ control: methods.control, name: 'probeOnly' }) ?? false;
const { data: outboundGroups } = useOutboundTagGroups({ excludeBlackhole: true });
// Outbounds and balancers share one picker (like the panel-outbound selector);
@@ -186,10 +182,13 @@ export default function NodeFormModal({
const cmd = shownInstall.hasToken
? `${base} --token ${shownInstall.token} --name ${shownInstall.name}`
: `${base} --name ${shownInstall.name}`;
// The toggle is the source of truth for the re-displayed command:
// flipping it rewrites the command the operator would re-run.
return probeOnly ? `${cmd} --probe-only` : cmd;
}, [shownInstall, editMasterUrl, probeOnly]);
// The command never carries --probe-only: an agent.json preset would
// pin probe-only on the machine (dual-source OR gate) and the panel
// toggle alone could no longer turn it off. Probe-only is governed
// solely by the master-side flag; the script flag stays for manual
// installs that deliberately want the preset.
return cmd;
}, [shownInstall, editMasterUrl]);
const title = useMemo(
() => (mode === 'edit' ? t('pages.nodes.editNode') : t('pages.nodes.addNode')),
@@ -307,7 +306,6 @@ export default function NodeFormModal({
if (agentMode && mode === 'add' && obj?.agentToken) {
setAgentToken(obj.agentToken);
setAgentNodeName(result.data.name.trim());
setAgentProbeOnly(result.data.probeOnly ?? false);
// Default the master URL to the address this panel is being
// viewed through — origin AND base path. Dropping the base
// path made the agent's registration 404. User can edit it.
@@ -330,7 +328,7 @@ export default function NodeFormModal({
// one would produce a command that can never register, so withhold it
// (and say why) instead of letting the user copy a dead command.
const installCmd = agentMasterUrl
? `bash <(curl -fL https://git.881218.xyz/rogue/3x-ui-agent/raw/branch/main/install-agent.sh) --master ${agentMasterUrl} --token ${agentToken ?? ''} --name ${agentNodeName}${agentProbeOnly ? ' --probe-only' : ''}`
? `bash <(curl -fL https://git.881218.xyz/rogue/3x-ui-agent/raw/branch/main/install-agent.sh) --master ${agentMasterUrl} --token ${agentToken ?? ''} --name ${agentNodeName}`
: '';
return (
+3 -3
View File
@@ -307,13 +307,13 @@ export default function NodeList({
</Tooltip>
) : (
<Space>
<Tooltip title={t('pages.nodes.probe')}>
<Tooltip title={t('pages.nodes.testConnection')}>
<Button
type="text"
size="small"
style={{ fontSize: 16 }}
icon={<ThunderboltOutlined />}
aria-label={t('pages.nodes.probe')}
aria-label={t('pages.nodes.testConnection')}
onClick={() => onProbe(record)}
/>
</Tooltip>
@@ -716,7 +716,7 @@ export default function NodeList({
key: 'probe',
label: (
<>
<ThunderboltOutlined /> {t('pages.nodes.probe')}
<ThunderboltOutlined /> {t('pages.nodes.testConnection')}
</>
),
onClick: () => onProbe(record),
+30 -8
View File
@@ -24,12 +24,20 @@ interface ProbeMetaWire {
ipv4Pin: string;
ipv6Pin: string;
// V2.0.1: ledger corrections in GB (either sign) and the counted
// interface pin ('' = automatic selection on the agent).
// interface pin ('' = automatic selection on the agent). Since
// V2.2.1 the corrections are one-shot deltas: the inputs below are
// local state (never prefilled from the wire), a save submits them
// once, and they clear back to empty on success.
trafficCalUpGb: number;
trafficCalDownGb: number;
statIface: string;
}
// GB entry converts on the binary convention every byte formatter in
// the panel and on the public page uses (V2.2.1: was decimal 1e9,
// which made a 3000 GB limit render as 2.73 TB instead of 2.93 TB).
const BYTES_PER_GB = 1024 ** 3;
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } };
const NAMED_CYCLES = [
@@ -59,6 +67,9 @@ export default function ProbeMetaSection({ nodeId }: { nodeId: number }) {
// in NodeFormModal); visibility derives during render.
const [loaded, setLoaded] = useState<{ nodeId: number; meta: ProbeMetaWire } | null>(null);
const [trafficGb, setTrafficGb] = useState(0);
// One-shot calibration deltas (GB, either sign): null = empty box.
const [calUpGb, setCalUpGb] = useState<number | null>(null);
const [calDownGb, setCalDownGb] = useState<number | null>(null);
const [saving, setSaving] = useState(false);
useEffect(() => {
@@ -68,7 +79,7 @@ export default function ProbeMetaSection({ nodeId }: { nodeId: number }) {
}).then((msg) => {
if (!cancelled && msg?.success && msg.obj) {
setLoaded({ nodeId, meta: msg.obj });
setTrafficGb(msg.obj.trafficLimit / 1e9);
setTrafficGb(msg.obj.trafficLimit / BYTES_PER_GB);
}
});
return () => {
@@ -116,14 +127,23 @@ export default function ProbeMetaSection({ nodeId }: { nodeId: number }) {
try {
const payload: ProbeMetaWire = {
...meta,
trafficLimit: Math.round(trafficGb * 1e9),
trafficLimit: Math.round(trafficGb * BYTES_PER_GB),
trafficCalUpGb: calUpGb ?? 0,
trafficCalDownGb: calDownGb ?? 0,
};
const msg = await HttpUtil.put(`/panel/api/agent/probe-meta/${nodeId}`, payload, {
silent: true,
...JSON_HEADERS,
});
if (msg?.success) {
setLoaded({ nodeId, meta: payload });
// One-shot: the submitted corrections are folded in server-
// side; the boxes clear so a later save resubmits nothing.
setLoaded({
nodeId,
meta: { ...payload, trafficCalUpGb: 0, trafficCalDownGb: 0 },
});
setCalUpGb(null);
setCalDownGb(null);
messageApi.success(t('pages.nodes.probe.saved'));
} else {
messageApi.error(msg?.msg || t('pages.nodes.probe.saveFailed'));
@@ -249,9 +269,10 @@ export default function ProbeMetaSection({ nodeId }: { nodeId: number }) {
<Col xs={24} md={8}>
<Field label={t('pages.nodes.probe.calUp')}>
<InputNumber
value={meta.trafficCalUpGb}
value={calUpGb}
step={0.1}
onChange={(v) => set('trafficCalUpGb', v ?? 0)}
placeholder="0"
onChange={(v) => setCalUpGb(v)}
style={{ width: '100%' }}
/>
</Field>
@@ -259,9 +280,10 @@ export default function ProbeMetaSection({ nodeId }: { nodeId: number }) {
<Col xs={24} md={8}>
<Field label={t('pages.nodes.probe.calDown')}>
<InputNumber
value={meta.trafficCalDownGb}
value={calDownGb}
step={0.1}
onChange={(v) => set('trafficCalDownGb', v ?? 0)}
placeholder="0"
onChange={(v) => setCalDownGb(v)}
style={{ width: '100%' }}
/>
</Field>
+1
View File
@@ -68,6 +68,7 @@ require (
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/oschwald/maxminddb-golang v1.13.1 // indirect
github.com/pelletier/go-toml/v2 v2.4.3 // indirect
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
+2
View File
@@ -108,6 +108,8 @@ github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd
github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 h1:lDH9UUVJtmYCjyT0CI4q8xvlXPxeZ0gYCVvWbmPlp88=
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk=
github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE=
github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+1 -1
View File
@@ -29,7 +29,7 @@ $ErrorActionPreference = "Stop"
# Release download base, same shape as install-agent.sh's
# RELEASE_BASE (the version segment is bumped by the release flow).
$ReleaseBase = "https://git.881218.xyz/rogue/3x-ui-agent/releases/download/V2.2.0"
$ReleaseBase = "https://git.881218.xyz/rogue/3x-ui-agent/releases/download/V2.3.0"
if (-not $AgentUrl) {
$AgentUrl = "$ReleaseBase/x-ui-agent-windows-amd64.exe"
}
+68 -6
View File
@@ -11,7 +11,7 @@
#
# 测试用环境变量(一般无需设置):
# XUI_ROOT 安装路径前缀(假根目录验证用),默认空 = 真实路径
# XUI_INIT_SYSTEM 强制 init 系统: systemd | openrc | none,默认 auto 探测
# XUI_INIT_SYSTEM 强制 init 系统: systemd | openrc | procd | none,默认 auto 探测
# XUI_URL agent 二进制下载地址覆盖
#
# 二进制命名(P12 改名):agent 独立装为 /usr/local/x-ui/xui-agent,不再与主控共用
@@ -81,8 +81,13 @@ esac
# sing-box 核心在 NexusGate 仓的专用发行里(见下方 SINGBOX_BASE)。
# 注意:本脚本是 V2 版——只装 sing-box 运行时;主控未升 V2 前不要用它
# 重装节点(节点升级走面板一键更新,不需要本脚本)。
RELEASE_BASE="https://git.881218.xyz/rogue/3x-ui-agent/releases/download/V2.2.0"
XUI_URL="${XUI_URL:-${RELEASE_BASE}/x-ui-linux-${BIN_ARCH}}"
#
# agent/主控拆分(期一):下载的是 agent 专用产物 x-ui-agent-linux-<arch>
# (独立入口 cmd/xui-agent,不再是主控双模式二进制)。该资产自拆分版
# 发行起才有——RELEASE_BASE 的升版由发版步完成,指向拆分版即可用;
# 若需装拆分前的旧版节点,显式 XUI_URL 指回 x-ui-linux-<arch> 即可。
RELEASE_BASE="https://git.881218.xyz/rogue/3x-ui-agent/releases/download/V2.3.0"
XUI_URL="${XUI_URL:-${RELEASE_BASE}/x-ui-agent-linux-${BIN_ARCH}}"
ROOT="${XUI_ROOT:-}"
XUI_HOME="${ROOT}/usr/local/x-ui"
@@ -220,7 +225,7 @@ stop_legacy_agent() {
detect_init_system() {
case "${XUI_INIT_SYSTEM:-auto}" in
systemd|openrc|none) echo "${XUI_INIT_SYSTEM}"; return;;
systemd|openrc|procd|none) echo "${XUI_INIT_SYSTEM}"; return;;
esac
if [ -d /run/systemd/system ] && command -v systemctl >/dev/null 2>&1; then
echo systemd; return
@@ -228,6 +233,9 @@ detect_init_system() {
if command -v rc-update >/dev/null 2>&1 && command -v rc-service >/dev/null 2>&1; then
echo openrc; return
fi
if [ -f /etc/rc.common ] && command -v procd >/dev/null 2>&1; then
echo procd; return
fi
echo none
}
@@ -286,10 +294,14 @@ command_args="agent"
# supervise-daemon 守护(第 23 条):进程崩溃后按 respawn 参数自动拉起,
# 对齐 systemd 支的 Restart=on-failure + RestartSec=5;旧版 command_background
# 只负责后台化、崩溃不重启。respawn_max=0 = 不限次(同 systemd 的持续重试)。
# respawn_period 必须为正数:supervise-daemon 启动时校验该值,0 直接报
# "Invalid respawn-period value '0'" 拒绝拉起(V2.2.1 ⑤,Alpine 真机实证);
# 取 1800(真机上其他 supervise-daemon 服务同值)。max=0 不限次时 period
# 仅是计数窗口,不影响持续重试语义。
supervisor="supervise-daemon"
respawn_delay=5
respawn_max=0
respawn_period=0
respawn_period=1800
pidfile="/run/x-ui-agent.pid"
output_log="/var/log/x-ui-agent.log"
error_log="/var/log/x-ui-agent.log"
@@ -322,9 +334,58 @@ EOF
fi
}
# 以 procd 服务方式安装并启动(开机自启,OpenWrt)
start_via_procd() {
local init_dir="${ROOT}/etc/init.d"
mkdir -p "$init_dir"
# 服务脚本与 2026-10-23 AX6600 真机手工验证版逐字一致(V2.2.1 ④
# 收编):路径按脚本变量展开,XUI_ROOT 为空时即验证版的
# /usr/local/x-ui/xui-agent 与 /etc/x-ui/agent.json。procd 托管
# + respawn 守护,stdout/stderr 进 syslog(logread 查看)。
cat > "${init_dir}/xui-agent" <<EOF
#!/bin/sh /etc/rc.common
START=99
STOP=10
USE_PROCD=1
start_service() {
procd_open_instance
procd_set_param command ${AGENT_BIN} agent -config ${CONF_DIR}/agent.json
procd_set_param respawn 3600 5 5
procd_set_param stdout 1
procd_set_param stderr 1
procd_close_instance
}
EOF
chmod +x "${init_dir}/xui-agent"
info "已生成 procd 服务文件: ${init_dir}/xui-agent"
if [ -n "$ROOT" ]; then
info "(XUI_ROOT 模式:跳过 /etc/init.d 实际操作)"
return 0
fi
/etc/init.d/xui-agent stop 2>/dev/null || true
stop_legacy_agent
pkill -f "${AGENT_BIN} agent" 2>/dev/null || true; sleep 1
/etc/init.d/xui-agent enable
/etc/init.d/xui-agent start
sleep 2
if pgrep -f "${AGENT_BIN} agent" >/dev/null 2>&1 || ps w 2>/dev/null | grep "[x]ui-agent agent" >/dev/null 2>&1; then
info "agent 已由 procd 托管启动(开机自启)"
echo ""
echo " 主控: $MASTER"
echo " 节点名: $NAME"
echo " 日志: logread -f"
echo " agent 会在 30 秒内从主控拉取配置"
else
error "agent 服务启动失败,查看: logread | tail -n 50"
fi
}
# 无服务管理器时回落 nohup(无开机自启,明确警告)
start_via_nohup() {
warn "未检测到 systemd / OpenRC,退回 nohup 方式启动"
# BusyBox 精简系统可能连 nohup 都没有(AX6600 实证):先讲清再退,
# 不要假装启动、走到最后才报一个看不懂的失败。
command -v nohup >/dev/null 2>&1 || error "未检测到 systemd / OpenRC / procd,且本机无 nohup 命令,无法启动 agent"
warn "未检测到 systemd / OpenRC / procd,退回 nohup 方式启动"
warn "注意:nohup 方式没有开机自启,机器重启后 agent 不会自动恢复,需手动重跑本脚本!"
stop_legacy_agent
pkill -f "${AGENT_BIN} agent" 2>/dev/null || true; sleep 1
@@ -348,5 +409,6 @@ info "启动 agent..."
case "$(detect_init_system)" in
systemd) start_via_systemd;;
openrc) start_via_openrc;;
procd) start_via_procd;;
*) start_via_nohup;;
esac
+56
View File
@@ -0,0 +1,56 @@
package agent
import (
"sync"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
)
// This file holds the probe collector's degrade-don't-die plumbing:
// the per-source independence discipline as reusable mechanics,
// shared by every platform sampler but exercised today by the Windows
// sampler (the Linux sampler reads /proc files, whose failure mode is
// an error return, not a panic).
//
// Two layers:
//
// - source gates: an optional OS facility (a Win32 procedure, say)
// is probed once; when it is missing the source is skipped —
// zeroed — with a single warning, never re-probed, and never
// reached through a call path that panics on a missing export the
// way LazyProc.Call does when it must-finds a procedure;
// - guarded calls: a panicking source is recovered, zeroed and
// logged, so one bad source can never take the agent process (and
// every other source with it) down.
// sourceGate caches one availability probe. The zero value is ready.
type sourceGate struct {
once sync.Once
available bool
}
// enabled runs probe on first use and reports whether the source may
// be used. probe returns nil when the facility is present. A failed
// probe disables the source for the process lifetime and logs once.
func (g *sourceGate) enabled(name string, probe func() error) bool {
g.once.Do(func() {
if err := probe(); err != nil {
logger.Warningf("probe: %s unavailable, source disabled: %v", name, err)
return
}
g.available = true
})
return g.available
}
// guardedCall runs fn and converts a panic into the zero value plus a
// warning, so one failing metrics source degrades alone.
func guardedCall[T any](name string, fn func() T) (out T) {
defer func() {
if r := recover(); r != nil {
logger.Warningf("probe: %s panicked, source zeroed: %v", name, r)
out = *new(T)
}
}()
return fn()
}
+63
View File
@@ -0,0 +1,63 @@
package agent
import (
"errors"
"testing"
)
// The degrade-don't-die mechanics behind V2.2.1 ①: on the trimmed
// Tianyi cloud-PC image the Windows sampler died on its first
// collection because a missing iphlpapi export made LazyProc.Call
// panic. These tests pin the two layers that replace that failure
// mode — a failed availability probe disables the source (probed
// once, not per poll), and a panicking source is zeroed, never
// propagated.
func TestSourceGateDisablesFailedProbeOnce(t *testing.T) {
var g sourceGate
calls := 0
probe := func() error {
calls++
return errors.New("procedure not found")
}
for i := range 3 {
if g.enabled("test-source", probe) {
t.Fatalf("call %d: gate enabled despite failed probe", i)
}
}
if calls != 1 {
t.Fatalf("probe ran %d times, want exactly 1 (cached verdict)", calls)
}
}
func TestSourceGateEnablesSuccessfulProbeOnce(t *testing.T) {
var g sourceGate
calls := 0
probe := func() error {
calls++
return nil
}
for i := range 3 {
if !g.enabled("test-source", probe) {
t.Fatalf("call %d: gate disabled despite successful probe", i)
}
}
if calls != 1 {
t.Fatalf("probe ran %d times, want exactly 1 (cached verdict)", calls)
}
}
func TestGuardedCallRecoversPanicToZero(t *testing.T) {
got := guardedCall("test-source", func() uint64 {
panic("Failed to find FreeMibIfTable2 procedure in iphlpapi.dll")
})
if got != 0 {
t.Fatalf("guardedCall after panic = %d, want zero value", got)
}
}
func TestGuardedCallPassesValueThrough(t *testing.T) {
if got := guardedCall("test-source", func() string { return "ok" }); got != "ok" {
t.Fatalf("guardedCall = %q, want %q", got, "ok")
}
}
+82 -15
View File
@@ -35,6 +35,14 @@ package agent
// degradation discipline Load stays zero and TCP/UDP stay 0
// until a wrapper exists;
// - every call fails soft to zeros, like an unreadable /proc file.
// Optional procedures are resolved through source gates
// (probe_guard.go): LazyProc.Call must-finds and PANICS on a
// missing export, and trimmed images do ship DLLs without the
// full surface — the Tianyi cloud PC's iphlpapi.dll has no
// FreeMibIfTable2, which killed the V2.2.0 agent on its first
// collection (V2.2.1 ①). A missing procedure now disables just
// its source, and collectMetrics additionally guards each source
// so a panic anywhere degrades that source alone.
import (
"net"
@@ -50,15 +58,35 @@ import (
"golang.org/x/sys/windows/registry"
)
// kernel32 procs x/sys/windows v0.47.0 does not wrap.
// kernel32 procs x/sys/windows v0.47.0 does not wrap, plus the
// iphlpapi pair this file calls directly (gated below, so x/sys'
// own GetIfTable2Ex wrapper — whose Addr() panics the same way on a
// missing export — is deliberately not used).
var (
kernel32DLL = windows.NewLazySystemDLL("kernel32.dll")
procGetSystemTimes = kernel32DLL.NewProc("GetSystemTimes")
procGlobalMemoryStatus = kernel32DLL.NewProc("GlobalMemoryStatusEx")
iphlpapiDLL = windows.NewLazySystemDLL("iphlpapi.dll")
procGetIfTable2Ex = iphlpapiDLL.NewProc("GetIfTable2Ex")
procFreeMibIfTable2 = iphlpapiDLL.NewProc("FreeMibIfTable2")
)
// Availability gates for the optional procedures above. A LazyProc
// must never be Call()ed before its gate has proven the export
// exists: Call must-finds, so a missing procedure is a process-killing
// panic, not an error.
var (
gateSystemTimes sourceGate
gateMemoryStatus sourceGate
gateIfTable sourceGate
)
// procProbe adapts a LazyProc to a source-gate probe: Find resolves
// the export WITHOUT the mustFind panic baked into Call/Addr.
func procProbe(p *windows.LazyProc) func() error {
return func() error { return p.Find() }
}
func sampleFacts() *ProbeFacts {
memTotal, _, swapTotal, _ := winMemory()
diskTotal, _ := winDiskUsage()
@@ -80,12 +108,26 @@ func sampleFacts() *ProbeFacts {
}
}
// memSnapshot / diskSnapshot pack the multi-value samplers so each
// can run as one guarded source.
type memSnapshot struct{ total, used, swapTotal, swapUsed uint64 }
type diskSnapshot struct{ total, used uint64 }
func collectMetrics() *ProbeMetrics {
probeColl.mu.Lock()
defer probeColl.mu.Unlock()
memTotal, memUsed, swapTotal, swapUsed := winMemory()
diskTotal, diskUsed := winDiskUsage()
rows := winIfaceRows()
// Every source runs guarded: a panic in one (a Win32 surface the
// gates did not foresee) zeroes that source alone instead of
// killing the agent on its first collection.
mem := guardedCall("memory", func() memSnapshot {
total, used, swapTotal, swapUsed := winMemory()
return memSnapshot{total, used, swapTotal, swapUsed}
})
disk := guardedCall("disk", func() diskSnapshot {
total, used := winDiskUsage()
return diskSnapshot{total, used}
})
rows := guardedCall("interfaces", winIfaceRows)
all := make([]netCounters, 0, len(rows))
counted := make([]netCounters, 0, len(rows))
for _, r := range rows {
@@ -107,23 +149,25 @@ func collectMetrics() *ProbeMetrics {
}
rxRate, txRate := probeColl.netRate(counted, time.Now())
return &ProbeMetrics{
BootID: bootEpoch(winMachineGUID(), names),
Uptime: uint64(windows.DurationSinceBoot().Seconds()),
CPU: probeColl.cpuPercent(winCPUSample()),
BootID: bootEpoch(winMachineGUID(), names),
Uptime: guardedCall("uptime", func() uint64 {
return uint64(windows.DurationSinceBoot().Seconds())
}),
CPU: probeColl.cpuPercent(guardedCall("cpu", winCPUSample)),
Load: [3]float32{},
MemTotal: memTotal,
MemUsed: memUsed,
SwapTotal: swapTotal,
SwapUsed: swapUsed,
DiskTotal: diskTotal,
DiskUsed: diskUsed,
MemTotal: mem.total,
MemUsed: mem.used,
SwapTotal: mem.swapTotal,
SwapUsed: mem.swapUsed,
DiskTotal: disk.total,
DiskUsed: disk.used,
NetRxTotal: rxTotal,
NetTxTotal: txTotal,
NetRx: rxRate,
NetTx: txRate,
TCP: 0, // no exported x/sys wrapper (see header)
UDP: 0, // no exported x/sys wrapper (see header)
Procs: winProcCount(),
Procs: guardedCall("processes", winProcCount),
}
}
@@ -212,6 +256,9 @@ func filetime100ns(ft windows.Filetime) uint64 {
// winCPUSample reads the cumulative system times as one cpuSample:
// total = kernel + user (kernel already includes idle, like jiffies).
func winCPUSample() *cpuSample {
if !gateSystemTimes.enabled("GetSystemTimes", procProbe(procGetSystemTimes)) {
return nil
}
var idle, kernel, user windows.Filetime
r, _, _ := procGetSystemTimes.Call(
uintptr(unsafe.Pointer(&idle)),
@@ -248,6 +295,9 @@ func usedFromTotalAvail(total, avail uint64) uint64 {
}
func winMemory() (total, used, swapTotal, swapUsed uint64) {
if !gateMemoryStatus.enabled("GlobalMemoryStatusEx", procProbe(procGlobalMemoryStatus)) {
return 0, 0, 0, 0
}
var ms memoryStatusEx
ms.Length = uint32(unsafe.Sizeof(ms))
r, _, _ := procGlobalMemoryStatus.Call(uintptr(unsafe.Pointer(&ms)))
@@ -359,8 +409,25 @@ func winIfaceCounts(r winIfaceRow) bool {
// derives both the all-interfaces list (for the operator pin) and the
// counted set from it.
func winIfaceRows() []winIfaceRow {
// The get/free pair is gated as ONE source: calling GetIfTable2Ex
// without FreeMibIfTable2 would leak the system-allocated table
// on every poll, and either export missing must degrade the
// source to zero rows instead of panicking — the trimmed Tianyi
// cloud-PC image ships an iphlpapi.dll without FreeMibIfTable2,
// and the V2.2.0 sampler's unguarded LazyProc.Call died on it.
if !gateIfTable.enabled("GetIfTable2Ex/FreeMibIfTable2 (iphlpapi)", func() error {
if err := procGetIfTable2Ex.Find(); err != nil {
return err
}
return procFreeMibIfTable2.Find()
}) {
return nil
}
var table *windows.MibIfTable2
if err := windows.GetIfTable2Ex(windows.MibIfTableNormal, &table); err != nil || table == nil {
r, _, _ := procGetIfTable2Ex.Call(
uintptr(windows.MibIfTableNormal),
uintptr(unsafe.Pointer(&table)))
if r != 0 || table == nil {
return nil
}
defer procFreeMibIfTable2.Call(uintptr(unsafe.Pointer(table)))
+6 -1
View File
@@ -151,7 +151,12 @@ func (a *Agent) runUpdate(intent updateIntent) {
fail(fmt.Errorf("locate own binary: %w", err))
return
}
asset, err := selfupdate.AssetName(runtime.GOARCH)
// Agent/panel split (phase 1): the agent downloads the standalone
// agent asset, not the panel binary. An agent build predating the
// split still asks for the panel asset (AssetName) — the two are
// interchangeable at the same path, so each generation converges
// on the form its own build requests.
asset, err := selfupdate.AgentAssetName(runtime.GOARCH)
if err != nil {
fail(err)
return
+3 -1
View File
@@ -22,7 +22,9 @@ import (
func fakeReleaseServer(t *testing.T, payload []byte, goodSum bool, hits *atomic.Int32) *httptest.Server {
t.Helper()
asset, err := selfupdate.AssetName(runtime.GOARCH)
// The agent requests the standalone agent asset (agent/panel
// split); the fake release only serves that name.
asset, err := selfupdate.AgentAssetName(runtime.GOARCH)
if err != nil {
t.Fatal(err)
}
+25 -3
View File
@@ -20,8 +20,10 @@ import (
)
// DefaultBaseURL is the release download base of the fork's own Gitea.
// A release tag directory hangs directly below it:
// <base><tag>/x-ui-linux-<arch> plus a "<asset>.sha256" sidecar.
// A release tag directory hangs directly below it, holding the panel
// assets (<base><tag>/x-ui-linux-<arch>) and — since the agent/panel
// split — the standalone agent assets (x-ui-agent-linux-<arch>),
// each with a "<asset>.sha256" sidecar.
const DefaultBaseURL = "https://git.881218.xyz/rogue/3x-ui-agent/releases/download/"
// LatestReleaseAPI is the Gitea API endpoint for the newest release of the
@@ -41,7 +43,9 @@ func BaseURL() string {
return DefaultBaseURL
}
// AssetName maps a GOARCH to the release asset name published for it.
// AssetName maps a GOARCH to the release asset name published for it:
// the panel (dual-mode) binary. The panel's own native self-update
// downloads this asset; pre-split agents consumed it too.
func AssetName(goarch string) (string, error) {
switch goarch {
case "amd64":
@@ -53,6 +57,24 @@ func AssetName(goarch string) (string, error) {
}
}
// AgentAssetName maps a GOARCH to the standalone agent asset published
// since the agent/panel split (phase 1). A split agent's one-click
// update downloads this asset — never the panel binary — so a node
// keeps its slim form across updates. Releases predating the split do
// not carry these assets; an intent pointing at such a tag fails the
// download and the agent keeps running its old binary (the designed
// failure mode of runUpdate).
func AgentAssetName(goarch string) (string, error) {
switch goarch {
case "amd64":
return "x-ui-agent-linux-amd64", nil
case "arm64":
return "x-ui-agent-linux-arm64", nil
default:
return "", fmt.Errorf("no agent release asset for architecture %q", goarch)
}
}
// LatestReleaseURLEnv overrides the release-lookup endpoint as a whole
// (mirrors, tests), analogous to BaseURLEnv.
const LatestReleaseURLEnv = "XUI_RELEASE_LATEST_URL"
+12
View File
@@ -47,6 +47,18 @@ func TestAssetName(t *testing.T) {
}
}
func TestAgentAssetName(t *testing.T) {
if name, err := AgentAssetName("amd64"); err != nil || name != "x-ui-agent-linux-amd64" {
t.Fatalf("amd64: %q, %v", name, err)
}
if name, err := AgentAssetName("arm64"); err != nil || name != "x-ui-agent-linux-arm64" {
t.Fatalf("arm64: %q, %v", name, err)
}
if _, err := AgentAssetName("riscv64"); err == nil {
t.Fatal("unknown arch must error")
}
}
func TestSwapExecutableKeepsBackup(t *testing.T) {
dir := t.TempDir()
exe := filepath.Join(dir, "xui-agent")
+19 -4
View File
@@ -52,7 +52,10 @@ type probeMetaForm struct {
IPv4Pin string `json:"ipv4Pin"`
IPv6Pin string `json:"ipv6Pin"`
// V2.0.1: traffic calibration (GB, either sign) and the counted
// interface pin ("" = automatic).
// interface pin ("" = automatic). Since V2.2.1 ⑥b the calibration
// fields are ONE-SHOT deltas: a non-zero value is folded into the
// stored standing correction on save (see probe.MergeCalDelta);
// zero/absent applies nothing.
TrafficCalUpGB float64 `json:"trafficCalUpGb"`
TrafficCalDownGB float64 `json:"trafficCalDownGb"`
StatIface string `json:"statIface"`
@@ -75,8 +78,11 @@ func metaToForm(m probe.Meta) probeMetaForm {
CountryPin: m.CountryPin,
IPv4Pin: m.IPv4Pin,
IPv6Pin: m.IPv6Pin,
TrafficCalUpGB: m.TrafficCalUpGB,
TrafficCalDownGB: m.TrafficCalDownGB,
// One-shot delta inputs (V2.2.1 ⑥b): the form's calibration
// boxes always start empty; the standing correction folded
// into the stored row is never echoed back into them.
TrafficCalUpGB: 0,
TrafficCalDownGB: 0,
StatIface: m.StatIface,
}
}
@@ -150,7 +156,16 @@ func (pc *ProbeMetaController) putMeta(c *gin.Context) {
jsonMsg(c, msg, errRequest)
return
}
if err := probe.SaveMeta(form.toMeta(int64(agent.Id))); err != nil {
// Calibration is one-shot (V2.2.1 ⑥b): fold the submitted deltas
// into the stored standing correction; all other fields replace.
meta := form.toMeta(int64(agent.Id))
if existing, found, err := probe.MetaFor(int64(agent.Id)); err != nil {
jsonMsg(c, "save probe meta failed", err)
return
} else if found {
meta = probe.MergeCalDelta(existing, meta)
}
if err := probe.SaveMeta(meta); err != nil {
jsonMsg(c, "save probe meta failed", err)
return
}
+61 -1
View File
@@ -323,7 +323,9 @@ func TestProbePublicContract(t *testing.T) {
assertKeys(t, keySet(t, nodeBody), anonNodeKeys)
var node map[string]any
_ = json.Unmarshal(nodeBody, &node)
if node["id"] != float64(pubID) || node["name"] != "PubNode" || node["online"] != true ||
// V2.2.1 ⑦: the public view's name is the public remark when one
// is set (the seed sets "shown publicly"), not the node name.
if node["id"] != float64(pubID) || node["name"] != "shown publicly" || node["online"] != true ||
node["month_rx"] != float64(33) || node["month_used"] != float64(77) ||
node["total_tx"] != float64(222) || node["price"] != float64(10) {
t.Fatalf("anon node values wrong: %v", node)
@@ -599,3 +601,61 @@ func TestProbeWebSocket(t *testing.T) {
func itoa(v int64) string {
return strconv.FormatInt(v, 10)
}
// Q7: the resolved country reaches both frames. The seed's held
// address is a documentation range (unlisted in any country DB), so
// repoint it at a real public address whose country the embedded
// DB-IP snapshot knows — 8.8.8.8 is US there.
func TestProbePublicCountryFlag(t *testing.T) {
rig := newProbeTestRig(t)
pubID, _ := rig.seedAgent(t, "FlagNode", true)
db := database.GetDB()
var agent model.AgentNode
if err := db.First(&agent, pubID).Error; err != nil {
t.Fatalf("load seeded agent: %v", err)
}
agent.IPv4 = "8.8.8.8"
if err := db.Save(&agent).Error; err != nil {
t.Fatalf("repoint agent address: %v", err)
}
rig.setSetting(t, "public_page", "on")
service.InvalidateProbeSnapshot()
find := func(rec *httptest.ResponseRecorder) map[string]any {
t.Helper()
var frame struct {
Nodes []map[string]any `json:"nodes"`
Admin bool `json:"admin"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &frame); err != nil {
t.Fatalf("frame decode: %v", err)
}
for _, n := range frame.Nodes {
if n["id"] == float64(pubID) {
return n
}
}
t.Fatalf("seeded node missing from frame: %s", rec.Body.String())
return nil
}
rec := rig.do(http.MethodGet, "/api/nodes", nil, false)
if rec.Code != 200 {
t.Fatalf("nodes anon: %d", rec.Code)
}
if got := find(rec)["country"]; got != "US" {
t.Fatalf("anonymous country = %v, want US", got)
}
rec = rig.do(http.MethodGet, "/api/nodes", nil, true)
if rec.Code != 200 {
t.Fatalf("nodes admin: %d", rec.Code)
}
admin := find(rec)
if admin["country"] != "US" || admin["country_auto"] != "US" || admin["country_pin"] != "" {
t.Fatalf("admin country triple = (%v, %v, %v), want (US, US, empty)",
admin["country"], admin["country_auto"], admin["country_pin"])
}
}
+23
View File
@@ -0,0 +1,23 @@
package job
import (
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
)
// GeoIPCountryJob is the weekly refresh of the local country
// database behind the public pages' node flags (Q7: a local MMDB on
// the master, never an external per-node lookup). The service does
// the conditional download, validation and hot swap; with no update
// URL configured the pass is a no-op, so scheduling it is harmless
// on installs that never opted in.
type GeoIPCountryJob struct{}
// NewGeoIPCountryJob creates a geoip country job instance.
func NewGeoIPCountryJob() *GeoIPCountryJob {
return new(GeoIPCountryJob)
}
// Run executes one update pass.
func (j *GeoIPCountryJob) Run() {
service.UpdateGeoIPCountry()
}
+376
View File
@@ -0,0 +1,376 @@
package service
import (
_ "embed"
"fmt"
"io"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/oschwald/maxminddb-golang"
"github.com/mhsanaei/3x-ui/v3/internal/config"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
)
// Node country flags (item 7 Q7, resolved 2026-10-24): the public
// views carry an ISO country code per node, which the themes render
// as a flag. The upstream hub resolves it by calling ipinfo.io per
// node; this build never sends a node address to a third party — the
// lookup is a local MMDB read on the master, from the node's own
// reported addresses. The agent is untouched: it already reports its
// interface addresses in its facts, and the master already observes
// the connection's source addresses (SeenIPv4/SeenIPv6).
//
// The data is the DB-IP Lite country database (CC BY 4.0 by DB-IP,
// https://db-ip.com; attribution and snapshot provenance live in
// geoipdata/NOTICE), in the MMDB conversion published by the
// sapics/ip-location-db project. Three layers, in winning order:
//
// 1. a local file in the data folder (geoip-country.mmdb beside the
// panel database) — written by the weekly updater below, or by
// the operator by hand;
// 2. the factory snapshot embedded in the binary (geoipdata/), so a
// fresh install shows flags with no network at all;
// 3. no database: lookups answer "", the views render exactly the
// pre-flag shape.
//
// Reads are mmap-backed (maxminddb.Open) or run against the embedded
// bytes in place; either way a lookup is a pure in-memory tree walk
// on the snapshot path, and answers are cached per address for the
// life of the loaded database.
//go:embed geoipdata/country.mmdb
var geoipCountryEmbedded []byte
// geoipCountryFileName is the local copy's name inside the data
// folder (config.GetDBFolderPath, where x-ui.db and probe.db live).
const geoipCountryFileName = "geoip-country.mmdb"
// geoipCountryDefaultURL is the weekly updater's fallback source,
// used when the operator has not set the geoip_country_url setting.
//
// RELEASE-TIME (main session): point this at the self-hosted Gitea
// mirror asset for geoip-country.mmdb. Empty disables auto-update;
// the embedded snapshot and any hand-placed local file still apply.
// Set at V2.3.0 release time to the standing `geoip-country` release
// asset on the self-hosted Gitea (asset swapped in place, URL stable).
const geoipCountryDefaultURL = "https://git.881218.xyz/rogue/3x-ui-agent/releases/download/geoip-country/geoip-country.mmdb"
// Settings keys (main settings KV, alongside the other probe_* keys).
const (
geoipCountryURLSetting = "geoip_country_url"
geoipCountryETagSetting = "geoip_country_etag"
)
// geoipCountryMaxBytes caps one update download; the country
// database is ~8 MB today, the cap only stops a runaway response.
const geoipCountryMaxBytes = 64 << 20
// geoipCountryProbeIP / geoipCountryProbeIPv6 are the well-known
// addresses a candidate database must answer to be trusted (Google
// DNS v4, AliDNS v6 — both resolve in the DB-IP data).
const (
geoipCountryProbeIP = "8.8.8.8"
geoipCountryProbeIPv6 = "2400:3200::1"
)
// GeoIPCountryLocalPath is where the local country database lives.
func GeoIPCountryLocalPath() string {
return filepath.Join(config.GetDBFolderPath(), geoipCountryFileName)
}
// geoipCountryUpdateURL resolves the updater's source: the operator
// setting wins, else the compiled-in default (possibly empty).
func geoipCountryUpdateURL() string {
if v, ok := probeSetting(geoipCountryURLSetting); ok {
if v = strings.TrimSpace(v); v != "" {
return v
}
}
return geoipCountryDefaultURL
}
// ---- the loaded database ----
// countryRecord is the DB-IP Lite record shape: a flat country_code.
type countryRecord struct {
CountryCode string `maxminddb:"country_code"`
}
// geoipDB is one loaded database plus its per-address answer cache
// ("" answers are cached too — an unlisted address stays unlisted
// until the database is swapped).
type geoipDB struct {
reader *maxminddb.Reader
origin string // "local" | "embedded"
mu sync.Mutex
cache map[string]string
}
func (db *geoipDB) lookup(ipStr string) string {
db.mu.Lock()
if cc, ok := db.cache[ipStr]; ok {
db.mu.Unlock()
return cc
}
db.mu.Unlock()
cc := ""
var rec countryRecord
if err := db.reader.Lookup(net.ParseIP(ipStr), &rec); err == nil {
cc = rec.CountryCode
}
db.mu.Lock()
db.cache[ipStr] = cc
db.mu.Unlock()
return cc
}
var (
geoipMu sync.RWMutex
geoipCur *geoipDB
geoipOnce sync.Once
)
// currentGeoIP returns the live database, loading it on first use.
func currentGeoIP() *geoipDB {
geoipOnce.Do(func() {
if db := loadGeoIP(); db != nil {
swapGeoIP(db)
}
})
geoipMu.RLock()
defer geoipMu.RUnlock()
return geoipCur
}
// swapGeoIP makes db the live database. The replaced reader is
// deliberately NOT closed: it is mmap-backed (or backed by the
// binary's own image), a lookup racing the swap could still be
// walking it, and an mmap cannot be safely unmapped under a reader.
// The mapping is one file per week at most and is reclaimed when the
// process exits.
func swapGeoIP(db *geoipDB) {
geoipMu.Lock()
geoipCur = db
geoipMu.Unlock()
}
// loadGeoIP applies the layer order: a valid local file wins, else
// the embedded snapshot. A local file that will not open or will not
// answer falls through to the snapshot with one warning — a corrupt
// download must never take the flags (or the panel) down.
func loadGeoIP() *geoipDB {
if path := GeoIPCountryLocalPath(); path != "" {
if r, err := openGeoIPFile(path); err == nil {
return &geoipDB{reader: r, origin: "local", cache: map[string]string{}}
} else {
logger.Warningf("geoip: local country database %s unusable (%v); using the embedded snapshot", path, err)
}
}
if r, err := maxminddb.FromBytes(geoipCountryEmbedded); err == nil {
if err := probeGeoIPReader(r); err == nil {
return &geoipDB{reader: r, origin: "embedded", cache: map[string]string{}}
} else {
logger.Warningf("geoip: embedded country snapshot fails validation: %v", err)
}
} else {
logger.Warningf("geoip: embedded country snapshot will not open: %v", err)
}
return &geoipDB{origin: "none", cache: map[string]string{}}
}
// openGeoIPFile opens a database file and checks it answers.
func openGeoIPFile(path string) (*maxminddb.Reader, error) {
r, err := maxminddb.Open(path)
if err != nil {
return nil, err
}
if err := probeGeoIPReader(r); err != nil {
_ = r.Close()
return nil, err
}
return r, nil
}
// probeGeoIPReader is the functional validation: the well-known
// probe address must resolve to a country code, and a v6 probe must
// at least answer without an error. (maxminddb's Verify is NOT used:
// it also audits metadata niceties — the DB-IP/sapics files carry an
// empty description map and fail it despite being fully readable,
// verified against the factory snapshot on 2026-10-24. A candidate
// that opens and answers both probes is a working country DB; a
// truncated or foreign file fails to open or errors a lookup.)
func probeGeoIPReader(r *maxminddb.Reader) error {
var rec countryRecord
if err := r.Lookup(net.ParseIP(geoipCountryProbeIP), &rec); err != nil {
return err
}
if rec.CountryCode == "" {
return fmt.Errorf("probe address %s resolves to no country", geoipCountryProbeIP)
}
return r.Lookup(net.ParseIP(geoipCountryProbeIPv6), &rec)
}
// verifyGeoIPFile validates a fresh download in place before it may
// replace the live database.
func verifyGeoIPFile(path string) error {
r, err := maxminddb.Open(path)
if err != nil {
return err
}
defer func() { _ = r.Close() }()
return probeGeoIPReader(r)
}
// ---- lookups used by the public views ----
// countryCodeForIP resolves one public IP literal to its ISO country
// code, "" for anything non-public, unknown, or unanswerable. The
// public gate runs first so private/held addresses never reach the
// database (they have no country, exactly as on the hub).
func countryCodeForIP(ipStr string) string {
ipStr = strings.TrimSpace(ipStr)
if !IsPublicIPLiteral(ipStr) {
return ""
}
db := currentGeoIP()
if db == nil || db.reader == nil {
return ""
}
return db.lookup(ipStr)
}
// probeCountryAuto is a node's auto-detected country: the first
// public address in the hub's order — the held interface v4, then
// held v6, then the master-observed exit v4, then exit v6 — resolved
// locally. "" when the node shows no public address or the database
// has no answer for it.
func probeCountryAuto(a *model.AgentNode) string {
for _, s := range []string{a.IPv4, a.IPv6, a.SeenIPv4, a.SeenIPv6} {
if IsPublicIPLiteral(s) {
return countryCodeForIP(s)
}
}
return ""
}
// probeCountry resolves the pair the views publish (hub api.rs): the
// effective country is the operator's pin when one is set, else the
// auto-detected code; country_auto always carries the detected code.
func probeCountry(pin string, a *model.AgentNode) (country, auto string) {
auto = probeCountryAuto(a)
if pin != "" {
return pin, auto
}
return auto, auto
}
// ---- weekly auto-update ----
type geoipFetchResult int
const (
geoipFetchUnchanged geoipFetchResult = iota
geoipFetchUpdated
)
// downloadGeoIPCountry performs one conditional download of url into
// destPath. The candidate lands in a temp file in the destination
// directory, is fully validated there, and only then renames over
// destPath (atomic on the same filesystem); any failure leaves
// destPath and the live database untouched.
func downloadGeoIPCountry(client *http.Client, url, destPath, etag string, ifModifiedSince time.Time) (geoipFetchResult, string, error) {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return geoipFetchUnchanged, etag, err
}
if etag != "" {
req.Header.Set("If-None-Match", etag)
}
if !ifModifiedSince.IsZero() {
req.Header.Set("If-Modified-Since", ifModifiedSince.UTC().Format(http.TimeFormat))
}
resp, err := client.Do(req)
if err != nil {
return geoipFetchUnchanged, etag, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNotModified {
return geoipFetchUnchanged, etag, nil
}
if resp.StatusCode != http.StatusOK {
return geoipFetchUnchanged, etag, fmt.Errorf("update server answered %s", resp.Status)
}
dir := filepath.Dir(destPath)
tmp, err := os.CreateTemp(dir, ".geoip-country-*.tmp")
if err != nil {
return geoipFetchUnchanged, etag, err
}
tmpName := tmp.Name()
defer func() { _ = os.Remove(tmpName) }()
n, err := io.Copy(tmp, io.LimitReader(resp.Body, geoipCountryMaxBytes))
if err := tmp.Close(); err != nil {
return geoipFetchUnchanged, etag, err
}
if err != nil {
return geoipFetchUnchanged, etag, err
}
if n == 0 {
return geoipFetchUnchanged, etag, fmt.Errorf("update download was empty")
}
if err := verifyGeoIPFile(tmpName); err != nil {
return geoipFetchUnchanged, etag, fmt.Errorf("downloaded database rejected: %w", err)
}
if err := os.Rename(tmpName, destPath); err != nil {
return geoipFetchUnchanged, etag, err
}
return geoipFetchUpdated, resp.Header.Get("ETag"), nil
}
// UpdateGeoIPCountry runs one update pass: a conditional download
// from the configured URL, a validated atomic replace of the local
// copy, and a hot swap of the live reader. No configured URL or any
// failure keeps the current database; a failure logs a single
// warning (the hub discipline: a broken mirror must not spam).
func UpdateGeoIPCountry() {
url := geoipCountryUpdateURL()
if url == "" {
return
}
dest := GeoIPCountryLocalPath()
var lastMod time.Time
if fi, err := os.Stat(dest); err == nil {
lastMod = fi.ModTime()
}
etag, _ := probeSetting(geoipCountryETagSetting)
client := &http.Client{Timeout: 2 * time.Minute}
res, newETag, err := downloadGeoIPCountry(client, url, dest, etag, lastMod)
if err != nil {
logger.Warningf("geoip: country database update from %s failed: %v (keeping the current database)", url, err)
return
}
if res != geoipFetchUpdated {
return
}
if newETag != "" && newETag != etag {
_ = setProbeSetting(geoipCountryETagSetting, newETag)
}
r, err := openGeoIPFile(dest)
if err != nil {
logger.Warningf("geoip: updated country database %s will not reopen: %v", dest, err)
return
}
swapGeoIP(&geoipDB{reader: r, origin: "local", cache: map[string]string{}})
logger.Infof("geoip: country database updated from %s", url)
}
+259
View File
@@ -0,0 +1,259 @@
package service
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"github.com/oschwald/maxminddb-golang"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/web/service/probe"
)
// Q7 node country flags: local MMDB resolution on the master. The
// known-address expectations below are calibrated against the
// embedded factory snapshot (DB-IP Lite country database, retrieved
// 2026-10-24; provenance in geoipdata/NOTICE): these are long-lived
// anycast/ISP addresses whose country attribution is stable across
// editions — 8.8.8.8 (Google DNS) US, 223.5.5.5 (AliDNS) CN,
// 1.1.1.1 (Cloudflare/APNIC) AU, 2400:3200::1 (AliDNS v6) CN.
// useEmbeddedGeoIP swaps the embedded snapshot in as the live
// database for one test, restoring whatever was live before.
func useEmbeddedGeoIP(t *testing.T) {
t.Helper()
prev := currentGeoIP() // fire the lazy load so the Once is spent
r, err := maxminddb.FromBytes(geoipCountryEmbedded)
if err != nil {
t.Fatalf("embedded snapshot will not open: %v", err)
}
swapGeoIP(&geoipDB{reader: r, origin: "embedded", cache: map[string]string{}})
t.Cleanup(func() { swapGeoIP(prev) })
}
func TestCountryCodeForIPKnownAddresses(t *testing.T) {
useEmbeddedGeoIP(t)
for ip, want := range map[string]string{
"8.8.8.8": "US",
"223.5.5.5": "CN",
"1.1.1.1": "AU",
"2400:3200::1": "CN",
" 8.8.8.8 ": "US", // surrounding space is trimmed
} {
if got := countryCodeForIP(ip); got != want {
t.Errorf("countryCodeForIP(%q) = %q, want %q", ip, got, want)
}
}
// Non-public and unlisted addresses have no country.
for _, ip := range []string{
"192.168.0.1", // RFC1918
"10.0.0.5", // RFC1918
"127.0.0.1", // loopback
"100.64.0.1", // CGNAT shared range
"203.0.113.9", // public documentation range: unlisted in the DB
"", // empty
"example.com", // not a literal
"999.1.1.1", // not an address
} {
if got := countryCodeForIP(ip); got != "" {
t.Errorf("countryCodeForIP(%q) = %q, want empty", ip, got)
}
}
}
func TestProbeCountryAutoSourceOrder(t *testing.T) {
useEmbeddedGeoIP(t)
// Held v4 leads over held v6 (hub order).
a := &model.AgentNode{IPv4: "8.8.8.8", IPv6: "2400:3200::1", SeenIPv4: "223.5.5.5"}
if got := probeCountryAuto(a); got != "US" {
t.Errorf("held v4 first: got %q, want US", got)
}
// A private held v4 is skipped; the held public v6 beats the
// observed exit v4.
a = &model.AgentNode{IPv4: "10.0.0.5", IPv6: "2400:3200::1", SeenIPv4: "8.8.8.8"}
if got := probeCountryAuto(a); got != "CN" {
t.Errorf("held v6 over exit v4: got %q, want CN", got)
}
// Exit addresses are the fallback when the node holds none.
a = &model.AgentNode{SeenIPv4: "223.5.5.5"}
if got := probeCountryAuto(a); got != "CN" {
t.Errorf("exit v4 fallback: got %q, want CN", got)
}
a = &model.AgentNode{SeenIPv6: "2400:3200::1"}
if got := probeCountryAuto(a); got != "CN" {
t.Errorf("exit v6 fallback: got %q, want CN", got)
}
// No public address anywhere: no country.
a = &model.AgentNode{IPv4: "192.168.1.2", SeenIPv4: "10.1.1.1"}
if got := probeCountryAuto(a); got != "" {
t.Errorf("private only: got %q, want empty", got)
}
}
func TestProbeCountryPinWins(t *testing.T) {
useEmbeddedGeoIP(t)
a := &model.AgentNode{IPv4: "223.5.5.5"}
country, auto := probeCountry("JP", a)
if country != "JP" || auto != "CN" {
t.Errorf("pin set: got (%q, %q), want (JP, CN)", country, auto)
}
country, auto = probeCountry("", a)
if country != "CN" || auto != "CN" {
t.Errorf("no pin: got (%q, %q), want (CN, CN)", country, auto)
}
country, auto = probeCountry("JP", &model.AgentNode{})
if country != "JP" || auto != "" {
t.Errorf("pin without address: got (%q, %q), want (JP, empty)", country, auto)
}
}
func TestBuildProbeNodeViewCountry(t *testing.T) {
useEmbeddedGeoIP(t)
src := probeNodeSource{
name: "flagged",
agent: model.AgentNode{IPv4: "8.8.8.8"},
meta: probe.Meta{Public: true},
}
anon := buildProbeNodeView(src, false)
if anon["country"] != "US" {
t.Errorf("anonymous country = %v, want US", anon["country"])
}
admin := buildProbeNodeView(src, true)
if admin["country"] != "US" || admin["country_auto"] != "US" || admin["country_pin"] != "" {
t.Errorf("admin pair = (%v, %v, pin %v), want (US, US, empty)",
admin["country"], admin["country_auto"], admin["country_pin"])
}
src.meta.CountryPin = "DE"
if got := buildProbeNodeView(src, false)["country"]; got != "DE" {
t.Errorf("pinned country = %v, want DE", got)
}
if got := buildProbeNodeView(src, true)["country_auto"]; got != "US" {
t.Errorf("country_auto under a pin = %v, want US", got)
}
// A node with no public address keeps the pre-flag empty country.
src = probeNodeSource{name: "dark", agent: model.AgentNode{IPv4: "192.168.0.9"}, meta: probe.Meta{Public: true}}
if got := buildProbeNodeView(src, false)["country"]; got != "" {
t.Errorf("private node country = %v, want empty", got)
}
}
func TestOpenGeoIPFileValidation(t *testing.T) {
dir := t.TempDir()
bad := filepath.Join(dir, "bad.mmdb")
if err := os.WriteFile(bad, []byte("this is not an mmdb"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := openGeoIPFile(bad); err == nil {
t.Fatal("garbage file opened as a country database")
}
good := filepath.Join(dir, "good.mmdb")
if err := os.WriteFile(good, geoipCountryEmbedded, 0o644); err != nil {
t.Fatal(err)
}
r, err := openGeoIPFile(good)
if err != nil {
t.Fatalf("embedded bytes as a file rejected: %v", err)
}
defer func() { _ = r.Close() }()
db := &geoipDB{reader: r, cache: map[string]string{}}
if got := db.lookup("223.5.5.5"); got != "CN" {
t.Errorf("file-backed lookup = %q, want CN", got)
}
}
// ---- the weekly updater's download/validate/replace core ----
func TestDownloadGeoIPCountryUpdates(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("ETag", `"db-v1"`)
_, _ = w.Write(geoipCountryEmbedded)
}))
defer srv.Close()
dest := filepath.Join(t.TempDir(), "geoip-country.mmdb")
res, etag, err := downloadGeoIPCountry(srv.Client(), srv.URL, dest, "", time.Time{})
if err != nil {
t.Fatalf("download: %v", err)
}
if res != geoipFetchUpdated || etag != `"db-v1"` {
t.Fatalf("got (res %v, etag %q), want (updated, \"db-v1\")", res, etag)
}
fi, err := os.Stat(dest)
if err != nil || fi.Size() != int64(len(geoipCountryEmbedded)) {
t.Fatalf("dest stat = %v (err %v), want %d bytes", fi, err, len(geoipCountryEmbedded))
}
// The replaced file is a working database.
r, err := openGeoIPFile(dest)
if err != nil {
t.Fatalf("replaced file will not open: %v", err)
}
_ = r.Close()
}
func TestDownloadGeoIPCountryNotModified(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("If-None-Match") == `"db-v1"` || r.Header.Get("If-Modified-Since") != "" {
w.WriteHeader(http.StatusNotModified)
return
}
_, _ = w.Write(geoipCountryEmbedded)
}))
defer srv.Close()
dir := t.TempDir()
dest := filepath.Join(dir, "geoip-country.mmdb")
if err := os.WriteFile(dest, []byte("ORIGINAL"), 0o644); err != nil {
t.Fatal(err)
}
// ETag path.
res, _, err := downloadGeoIPCountry(srv.Client(), srv.URL, dest, `"db-v1"`, time.Time{})
if err != nil || res != geoipFetchUnchanged {
t.Fatalf("etag path: got (res %v, err %v), want unchanged", res, err)
}
// If-Modified-Since path.
res, _, err = downloadGeoIPCountry(srv.Client(), srv.URL, dest, "", time.Now())
if err != nil || res != geoipFetchUnchanged {
t.Fatalf("modified-since path: got (res %v, err %v), want unchanged", res, err)
}
body, _ := os.ReadFile(dest)
if string(body) != "ORIGINAL" {
t.Fatalf("dest rewritten on a 304: %q", body)
}
}
func TestDownloadGeoIPCountryRejectsBad(t *testing.T) {
dir := t.TempDir()
dest := filepath.Join(dir, "geoip-country.mmdb")
if err := os.WriteFile(dest, []byte("ORIGINAL"), 0o644); err != nil {
t.Fatal(err)
}
assertKept := func(name string) {
t.Helper()
body, _ := os.ReadFile(dest)
if string(body) != "ORIGINAL" {
t.Fatalf("%s: dest replaced despite the failure: %q", name, body)
}
}
garbage := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("definitely not an mmdb, just bytes"))
}))
defer garbage.Close()
if _, _, err := downloadGeoIPCountry(garbage.Client(), garbage.URL, dest, "", time.Time{}); err == nil {
t.Fatal("garbage download accepted")
}
assertKept("garbage")
broken := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer broken.Close()
if _, _, err := downloadGeoIPCountry(broken.Client(), broken.URL, dest, "", time.Time{}); err == nil {
t.Fatal("500 download accepted")
}
assertKept("500")
}
+26
View File
@@ -0,0 +1,26 @@
Country database attribution
============================
The embedded country.mmdb is the DB-IP Lite country database
(dbip-country, IPv4+IPv6 combined), converted to MMDB format by the
sapics/ip-location-db project:
https://github.com/sapics/ip-location-db
The underlying data is made available by DB-IP (https://db-ip.com)
under the Creative Commons Attribution 4.0 International License
(CC BY 4.0):
https://creativecommons.org/licenses/by/4.0/
Attribution: IP geolocation data by DB-IP, https://db-ip.com
Snapshot provenance (factory copy embedded in the binary):
source file : dbip-country.mmdb (sapics/ip-location-db "latest" release)
retrieved : 2026-10-24
size : 8300093 bytes
sha256 : fcecf33b565fcda088bbcb7a959b5b35c8e5195ed64fcd67daa3cd9384f6272b
Locally updated copies (downloaded by the master's weekly updater
into the data folder as geoip-country.mmdb) supersede this snapshot
at runtime; they are the same database from the same source.
Binary file not shown.
+67
View File
@@ -1234,7 +1234,74 @@ func (s *NodeService) AggregateNodeMetric(id int, metric string, bucketSeconds i
return nodeMetrics.aggregate(nodeMetricKey(id, metric), bucketSeconds, maxPoints)
}
// agentOnlineWindow is how fresh an agent heartbeat must be for its
// node to read online — the same 180s the heartbeat job expires agent
// nodes by (agentHeartbeatStaleAfter in internal/web/job: max(3
// heartbeat intervals, 120s), so two lost heartbeats don't flap).
const agentOnlineWindow = 180 * time.Second
var errAgentOffline = errors.New("agent node offline: no fresh heartbeat")
// agentHeartbeatVerdict is the pure decision behind the agent-node
// probe: a fresh stamp reads online with the heartbeat AGE as the
// latency (there is no dial to time); a stale or never-seen stamp
// reads offline with LatencyMs -1. LastError stays empty either way —
// the answer comes from the heartbeat, not from a dial failure.
func agentHeartbeatVerdict(lastSeenMs int64, now time.Time) (HeartbeatPatch, bool) {
patch := HeartbeatPatch{LatencyMs: -1}
if lastSeenMs <= 0 {
return patch, false
}
patch.LastHeartbeat = lastSeenMs / 1000
if age := now.Sub(time.UnixMilli(lastSeenMs)); age <= agentOnlineWindow {
patch.LastHeartbeat = now.Unix()
patch.LatencyMs = int(age / time.Millisecond)
return patch, true
}
return patch, false
}
// agentNodeProbe answers a probe for an agent-managed node from the
// bound agent's last_seen stamp (Unix ms, the unit the agent
// controller stamps). ok=false means the node has no bound agent — or
// the lookup failed — and the caller takes the dial path. The stored
// telemetry fields carry through unchanged (same as the heartbeat
// job's agent-expiry patch); only the error is dropped, so a ⚡ click
// also heals the bogus dial errors recorded before this change.
func (s *NodeService) agentNodeProbe(n *model.Node) (patch HeartbeatPatch, err error, ok bool) {
var lastSeen *int64 // MAX() over zero rows is NULL
qerr := database.GetDB().Model(&model.AgentNode{}).
Where("node_id = ?", n.Id).
Select("MAX(last_seen)").
Scan(&lastSeen).Error
if qerr != nil || lastSeen == nil {
return HeartbeatPatch{}, nil, false
}
patch, online := agentHeartbeatVerdict(*lastSeen, time.Now())
patch.XrayVersion = n.XrayVersion
patch.PanelVersion = n.PanelVersion
patch.Guid = n.Guid
patch.CpuPct = n.CpuPct
patch.MemPct = n.MemPct
patch.UptimeSecs = n.UptimeSecs
patch.NetUp = n.NetUp
patch.NetDown = n.NetDown
patch.XrayState = n.XrayState
patch.XrayError = n.XrayError
if !online {
return patch, errAgentOffline, true
}
return patch, nil, true
}
func (s *NodeService) Probe(ctx context.Context, n *model.Node) (HeartbeatPatch, error) {
// Agent-managed nodes have no panel to dial — their address row
// is a placeholder ("agent") — so a probe answers from the agent
// heartbeat instead of dialing a dead URL and persisting a
// LastError the agent heartbeat never clears (V2.2.1 ②).
if patch, err, ok := s.agentNodeProbe(n); ok {
return patch, err
}
proxyURL := ""
if n.OutboundTag != "" {
if mgr := runtime.GetManager(); mgr != nil {
@@ -0,0 +1,70 @@
package service
import (
"testing"
"time"
)
// V2.2.1 ②: the ⚡ probe for an agent-managed node is synthesized
// from the agent's heartbeat stamp instead of dialing the placeholder
// address. These tests pin the pure verdict: fresh stamp → online
// with the heartbeat age as latency; stale / never-seen → offline
// with LatencyMs -1; LastError empty in every case (a dial error must
// never be recorded for an agent node again).
func TestAgentHeartbeatVerdictFreshIsOnlineWithAgeLatency(t *testing.T) {
now := time.Unix(1_700_000_000, 0)
lastSeenMs := now.Add(-30 * time.Second).UnixMilli()
patch, online := agentHeartbeatVerdict(lastSeenMs, now)
if !online {
t.Fatal("30s-old heartbeat must read online")
}
if patch.LatencyMs != 30_000 {
t.Fatalf("LatencyMs = %d, want heartbeat age 30000", patch.LatencyMs)
}
if patch.LastHeartbeat != now.Unix() {
t.Fatalf("LastHeartbeat = %d, want now %d", patch.LastHeartbeat, now.Unix())
}
if patch.LastError != "" {
t.Fatalf("LastError = %q, want empty", patch.LastError)
}
}
func TestAgentHeartbeatVerdictWindowEdge(t *testing.T) {
now := time.Unix(1_700_000_000, 0)
if _, online := agentHeartbeatVerdict(now.Add(-agentOnlineWindow).UnixMilli(), now); !online {
t.Fatal("heartbeat exactly at the window edge must read online")
}
if _, online := agentHeartbeatVerdict(now.Add(-agentOnlineWindow-time.Second).UnixMilli(), now); online {
t.Fatal("heartbeat past the window must read offline")
}
}
func TestAgentHeartbeatVerdictStaleIsOffline(t *testing.T) {
now := time.Unix(1_700_000_000, 0)
lastSeenMs := now.Add(-10 * time.Minute).UnixMilli()
patch, online := agentHeartbeatVerdict(lastSeenMs, now)
if online {
t.Fatal("10min-old heartbeat must read offline")
}
if patch.LatencyMs != -1 {
t.Fatalf("LatencyMs = %d, want -1", patch.LatencyMs)
}
if patch.LastHeartbeat != lastSeenMs/1000 {
t.Fatalf("LastHeartbeat = %d, want the truthful last-seen %d", patch.LastHeartbeat, lastSeenMs/1000)
}
if patch.LastError != "" {
t.Fatalf("LastError = %q, want empty", patch.LastError)
}
}
func TestAgentHeartbeatVerdictNeverSeenIsOffline(t *testing.T) {
now := time.Unix(1_700_000_000, 0)
patch, online := agentHeartbeatVerdict(0, now)
if online {
t.Fatal("never-seen agent must read offline")
}
if patch.LatencyMs != -1 || patch.LastHeartbeat != 0 || patch.LastError != "" {
t.Fatalf("patch = %+v, want zero stamp, latency -1, no error", patch)
}
}
+27 -2
View File
@@ -164,13 +164,38 @@ func (s *PanelService) GetUpdateInfo() (*PanelUpdateInfo, error) {
// display version. An unparseable tag (not a V-series release) yields ""
// so the UI falls back to the raw fields.
func displayUpdateFields(latestTag string) (string, bool) {
return displayUpdateFieldsFor(latestTag, config.GetMasterDisplayVersion(), config.GetBaseVersion())
}
// displayUpdateFieldsFor is displayUpdateFields with the running
// build's versions as inputs. When the DISPLAY comparison cannot
// judge — the running build's display version is itself unparseable,
// the "VV2.1.0" double-letter builds — the verdict falls back to the
// raw version comparison instead of reporting "no update": the
// display path used to swallow a real update entirely (the panel
// showed no prompt while /releases/latest happily served the newer
// tag, and the native updater refused to run). A parseable display
// version keeps the segment rule exactly as before.
func displayUpdateFieldsFor(latestTag, currentDisplay, currentRaw string) (string, bool) {
parts, ok := config.ParseDisplayVersion(latestTag)
if !ok {
return "", false
}
disp := fmt.Sprintf("V%d.%d.%d", parts[0], parts[1], parts[2])
cmp, ok := config.CompareDisplayVersion(disp, config.GetMasterDisplayVersion())
return disp, ok && cmp > 0
cmp, ok := config.CompareDisplayVersion(disp, currentDisplay)
if !ok {
// Raw fallback: judge the latest tag against the running
// build's raw base version, parsed with the same tolerant
// display parser (bare and V/A forms both parse). An
// unparseable raw base yields no verdict → false. This is
// deliberately NOT isNewerVersion: on this fork's uppercase-V
// tags that helper degrades to string inequality (its
// normalizer strips only a lowercase "v"), which would report
// a phantom update whenever latest merely differs in form.
rawCmp, rawOk := config.CompareDisplayVersion(disp, currentRaw)
return disp, rawOk && rawCmp > 0
}
return disp, cmp > 0
}
// devChannelActive reports whether self-update should track the rolling dev
@@ -25,6 +25,42 @@ func TestDisplayUpdateFields(t *testing.T) {
}
}
// V2.2.1 ③: when the running build's display version is unparseable
// (the "VV2.1.0" double-letter builds), the display comparison cannot
// judge and the verdict must fall back to the raw version comparison
// instead of swallowing the update. A parseable display version keeps
// the segment rule — the raw string plays no part there.
func TestDisplayUpdateFieldsForFallback(t *testing.T) {
// Unparseable display "VV2.1.0", newer latest → available via raw.
if disp, avail := displayUpdateFieldsFor("V2.2.0", "VV2.1.0", "2.1.0"); disp != "V2.2.0" || !avail {
t.Fatalf("VV display, newer latest = %q, %v; want V2.2.0, true", disp, avail)
}
// Unparseable display, latest equal to the raw base → not
// available, in both raw forms (bare and V-prefixed).
if disp, avail := displayUpdateFieldsFor("V2.1.0", "VV2.1.0", "2.1.0"); disp != "V2.1.0" || avail {
t.Fatalf("VV display, same latest (bare raw) = %q, %v; want V2.1.0, false", disp, avail)
}
if _, avail := displayUpdateFieldsFor("V2.1.0", "VV2.1.0", "V2.1.0"); avail {
t.Fatal("VV display, same latest (V raw) must not report an update")
}
// Unparseable display AND unparseable raw base → no verdict, false.
if _, avail := displayUpdateFieldsFor("V2.2.0", "VV2.1.0", "3.7.0-agent.1"); avail {
t.Fatal("unparseable raw base must yield no update verdict")
}
// Parseable display: segment rule decides, raw base is ignored
// even when it disagrees.
if disp, avail := displayUpdateFieldsFor("V2.2.0", "V2.1.0", "9.9.9"); disp != "V2.2.0" || !avail {
t.Fatalf("parseable display, newer = %q, %v; want V2.2.0, true", disp, avail)
}
if _, avail := displayUpdateFieldsFor("V2.1.0", "V2.1.0", "1.0.0"); avail {
t.Fatal("parseable display, equal versions must not report an update (raw base must not leak in)")
}
// Unparseable latest tag still yields "" so the UI uses raw fields.
if disp, avail := displayUpdateFieldsFor("3.7.0-agent.1", "VV2.1.0", "2.1.0"); disp != "" || avail {
t.Fatalf("legacy tag = %q, %v; want empty, false", disp, avail)
}
}
func TestServiceUnitFromCgroup(t *testing.T) {
content := "0::/system.slice/x-ui.service\n"
if got := serviceUnitFromCgroup(content); got != "x-ui" {
+27
View File
@@ -34,6 +34,9 @@ type Meta struct {
// 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.
// The stored values are the STANDING accumulated correction;
// the edit form submits one-shot deltas that MergeCalDelta folds
// into them (V2.2.1 ⑥b).
TrafficCalUpGB float64
TrafficCalDownGB float64
// StatIface pins the one interface whose counters the agent
@@ -138,6 +141,30 @@ func (s *Store) UpsertMeta(m Meta) error {
return err
}
// MergeCalDelta folds a probe-meta form submission into the stored
// row under the one-shot calibration semantics (V2.2.1 ⑥b): the
// form's calibration fields are a correction TO APPLY NOW, not the
// new standing value. A non-zero submitted value adds onto the stored
// standing correction; a zero field (an absent field is the same
// zero — the wire type cannot distinguish them, and both mean
// "nothing to apply") keeps the stored value untouched. Every other
// field takes the submitted value, as UpsertMeta's full replace
// does. Paired with the form clearing its calibration inputs after a
// successful save, a later unrelated save can never re-apply an old
// correction.
func MergeCalDelta(existing, submitted Meta) Meta {
out := submitted
out.TrafficCalUpGB = existing.TrafficCalUpGB
if submitted.TrafficCalUpGB != 0 {
out.TrafficCalUpGB += submitted.TrafficCalUpGB
}
out.TrafficCalDownGB = existing.TrafficCalDownGB
if submitted.TrafficCalDownGB != 0 {
out.TrafficCalDownGB += submitted.TrafficCalDownGB
}
return out
}
func boolToInt(b bool) int64 {
if b {
return 1
@@ -0,0 +1,143 @@
package probe
import (
"path/filepath"
"testing"
"time"
)
// V2.2.1 ⑥: GB entries convert on the binary convention (1024^3)
// every formatter uses, stored decimal-era limits are rescaled once,
// and calibration submissions are one-shot deltas folded into the
// standing correction — never re-applied by a later unrelated save.
func TestCalBytesBinaryConvention(t *testing.T) {
if got := calBytes(3000); got != 3000*(1<<30) {
t.Fatalf("calBytes(3000) = %d, want %d (3000 GiB)", got, 3000*(1<<30))
}
if got := calBytes(0); got != 0 {
t.Fatalf("calBytes(0) = %d, want 0", got)
}
if got := calBytes(-1.5); got != -1610612736 {
t.Fatalf("calBytes(-1.5) = %d, want -1610612736", got)
}
}
func TestGBConventionMigrationRescalesLimitsOnce(t *testing.T) {
path := filepath.Join(t.TempDir(), "probe.db")
st, err := Open(path)
if err != nil {
t.Fatalf("open: %v", err)
}
// Seed a legacy row as a pre-V2.2.1 database holds it — 3000 GB
// typed at the decimal convention = 3.0e12 stored bytes — and
// strike the migration mark so the next open sees an unmigrated
// database.
if _, err := st.db.Exec(`INSERT INTO probe_meta (agent_id, traffic_limit) VALUES (9, 3000000000000)`); err != nil {
t.Fatalf("seed legacy limit: %v", err)
}
if _, err := st.db.Exec(`DELETE FROM probe_setting WHERE key = ?`, gbConventionKey); err != nil {
t.Fatalf("strike mark: %v", err)
}
if err := st.Close(); err != nil {
t.Fatalf("close: %v", err)
}
st2, err := Open(path)
if err != nil {
t.Fatalf("reopen (migration run): %v", err)
}
m, found, err := st2.Meta(9)
if err != nil || !found {
t.Fatalf("meta after migration: found=%v err=%v", found, err)
}
// 3.0e12 x 1024^3/1e9 = 3000 x 2^30 exactly (~3.221e12).
if want := int64(3000) * (1 << 30); m.TrafficLimit != want {
t.Fatalf("migrated limit = %d, want %d", m.TrafficLimit, want)
}
if err := st2.Close(); err != nil {
t.Fatalf("close 2: %v", err)
}
st3, err := Open(path)
if err != nil {
t.Fatalf("reopen 2: %v", err)
}
defer st3.Close()
m, found, err = st3.Meta(9)
if err != nil || !found {
t.Fatalf("meta after second open: found=%v err=%v", found, err)
}
if want := int64(3000) * (1 << 30); m.TrafficLimit != want {
t.Fatalf("limit after second open = %d, want %d (no double rescale)", m.TrafficLimit, want)
}
}
func TestOneShotCalibrationSaveFlow(t *testing.T) {
st := newTestStore(t)
now := time.Now()
book(t, st, 5, "boot-a", 1000, 1000, now, nil, 0) // baseline: books 0
book(t, st, 5, "boot-a", 3000, 2000, now, nil, 0) // books rx 2000, tx 1000
// First save carries a +1 GB uplink correction delta.
submitted := DefaultMeta(5)
submitted.TrafficCalUpGB = 1
merged := MergeCalDelta(DefaultMeta(5), submitted)
if merged.TrafficCalUpGB != 1 {
t.Fatalf("standing after first fold = %v, want 1", merged.TrafficCalUpGB)
}
if err := st.UpsertMeta(merged); err != nil {
t.Fatalf("upsert 1: %v", err)
}
after1, ok, err := st.TrafficFor(5)
if err != nil || !ok {
t.Fatalf("traffic after save 1: ok=%v err=%v", ok, err)
}
if want := int64(1000) + (1 << 30); after1.TotalTx != want {
t.Fatalf("TotalTx after save 1 = %d, want %d", after1.TotalTx, want)
}
if after1.TotalRx != 2000 {
t.Fatalf("TotalRx after save 1 = %d, want 2000 (uncalibrated)", after1.TotalRx)
}
// Second save changes another field; the (cleared) form submits
// zero calibration — the calibrated figure must not move again.
stored, found, err := st.Meta(5)
if err != nil || !found {
t.Fatalf("stored meta: found=%v err=%v", found, err)
}
sub2 := DefaultMeta(5)
sub2.GroupName = "g2"
merged2 := MergeCalDelta(stored, sub2)
if merged2.TrafficCalUpGB != 1 {
t.Fatalf("standing after zero-delta save = %v, want 1 (unchanged)", merged2.TrafficCalUpGB)
}
if err := st.UpsertMeta(merged2); err != nil {
t.Fatalf("upsert 2: %v", err)
}
after2, ok, err := st.TrafficFor(5)
if err != nil || !ok {
t.Fatalf("traffic after save 2: ok=%v err=%v", ok, err)
}
if after2.TotalTx != after1.TotalTx {
t.Fatalf("TotalTx moved on an unrelated save: %d -> %d (double-applied calibration)", after1.TotalTx, after2.TotalTx)
}
// A further non-zero delta accumulates exactly once more.
sub3 := DefaultMeta(5)
sub3.TrafficCalUpGB = 0.5
merged3 := MergeCalDelta(merged2, sub3)
if merged3.TrafficCalUpGB != 1.5 {
t.Fatalf("standing after second delta = %v, want 1.5", merged3.TrafficCalUpGB)
}
if err := st.UpsertMeta(merged3); err != nil {
t.Fatalf("upsert 3: %v", err)
}
after3, _, err := st.TrafficFor(5)
if err != nil {
t.Fatalf("traffic after save 3: %v", err)
}
if want := int64(1000) + calBytes(1.5); after3.TotalTx != want {
t.Fatalf("TotalTx after save 3 = %d, want %d", after3.TotalTx, want)
}
}
+4 -2
View File
@@ -91,8 +91,10 @@ func TestTrafficCalibrationAppliedAndFloored(t *testing.T) {
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)
// V2.2.1 ⑥: calibration converts on the binary convention —
// +0.5 GB = 0.5 x 2^30 = 536870912 B (was 5e8 at the old 1e9).
if tr.TotalRx != 3_536_870_912 || tr.MonthRx != 3_536_870_912 || tr.DayRx != 3_536_870_912 {
t.Errorf("rx figures after +0.5 GB calibration = %d/%d/%d, want 3536870912 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)
+43
View File
@@ -213,9 +213,52 @@ func Open(path string) (*Store, error) {
db.Close()
return nil, fmt.Errorf("probe schema migration: %w", err)
}
if err := migrateGBConvention(db); err != nil {
db.Close()
return nil, fmt.Errorf("probe gb migration: %w", err)
}
return &Store{db: db, nodes: make(map[int64]*nodeState)}, nil
}
// gbConventionKey is the probe_setting mark recording that the
// V2.2.1 ⑥ traffic-limit rescale has been applied to this database.
const gbConventionKey = "gb_1024_migrated_v1"
// migrateGBConvention rescales stored probe_meta traffic limits from
// the old decimal entry convention to the unified binary one, once.
// Limits are typed in GB and were stored as GB × 1e9 bytes, while
// every byte formatter (panel, public page) divides by 1024^3 — a
// 3000 GB limit displayed as 2.73 TB instead of 2.93 TB. The GB the
// operator typed is the intent, so stored bytes are scaled by
// 1024^3/1e9 = 1.073741824 and now read back correctly under the
// binary conversion. Only traffic_limit is byte-denominated:
// calibration values are stored in GB (their byte conversion,
// calBytes, carries the fix) and price/sort/reset-day are unitless —
// neither is touched here. The probe_setting mark makes the rescale
// run exactly once per database.
func migrateGBConvention(db *sql.DB) error {
var v string
err := db.QueryRow(`SELECT value FROM probe_setting WHERE key = ?`, gbConventionKey).Scan(&v)
if err == nil {
return nil // already applied
}
if err != sql.ErrNoRows {
return err
}
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(`UPDATE probe_meta SET traffic_limit = CAST(ROUND(traffic_limit * 1.073741824) AS INTEGER) WHERE traffic_limit > 0`); err != nil {
return err
}
if _, err := tx.Exec(`INSERT OR REPLACE INTO probe_setting (key, value) VALUES (?, '1')`, gbConventionKey); err != nil {
return err
}
return tx.Commit()
}
// 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
+6 -2
View File
@@ -332,9 +332,13 @@ func (s *Store) TrafficFor(agentID int64) (Traffic, bool, error) {
// 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).
// binary convention, 1 GB = 1024^3 B — the convention every byte
// formatter in the panel and on the public page uses, and the one
// the traffic limit's GB entry converts with (V2.2.1 ⑥; the decimal
// 1e9 stood here before, which made a 3000 GB limit read as 2.73 TB
// instead of 2.93 TB wherever bytes were formatted).
func calBytes(gb float64) int64 {
return int64(math.Round(gb * 1e9))
return int64(math.Round(gb * (1 << 30)))
}
// applyCalibration shifts a ledger row by the operator's per-node
+21 -3
View File
@@ -268,6 +268,19 @@ func expiresInDays(expires *string, now time.Time) any {
return days
}
// probeDisplayName is the name a node carries in the public views
// (V2.2.1 ⑦): the operator's public remark when one is set — it is
// the label written for public consumption — else the node name. The
// contract field stays "name"; themes and the public API keep their
// shape, only the value's source changes. The panel's internal node
// list is a different surface and keeps the plain node name.
func probeDisplayName(name, publicRemark string) string {
if r := strings.TrimSpace(publicRemark); r != "" {
return r
}
return name
}
// buildProbeNodeView renders one node for one audience. The public
// shape is built by whitelist (hub node_view): address, hostname and
// the private remark exist only in the admin (full) rendering.
@@ -275,6 +288,11 @@ func buildProbeNodeView(src probeNodeSource, full bool) map[string]any {
a := src.agent
m := src.meta
t := src.traffic
// The flag pair (hub api.rs): the effective country is the pin
// when the operator set one, else the locally resolved code for
// the node's public address (geoip_country.go, Q7 — no external
// lookup ever leaves the master).
country, countryAuto := probeCountry(m.CountryPin, &a)
var lastSeenAgo any
if src.lastSeen > 0 {
@@ -298,8 +316,8 @@ func buildProbeNodeView(src probeNodeSource, full bool) map[string]any {
view := map[string]any{
"id": a.Id,
"name": src.name,
"country": m.CountryPin,
"name": probeDisplayName(src.name, m.PublicRemark),
"country": country,
"group": m.GroupName,
"public_remark": m.PublicRemark,
"sort": m.Sort,
@@ -370,7 +388,7 @@ func buildProbeNodeView(src probeNodeSource, full bool) map[string]any {
view["ipv6_auto"] = probeAutoAddress(exits, held4, held6, m.IPv4Pin, "", true)
view["interval"] = interval
view["country_pin"] = m.CountryPin
view["country_auto"] = ""
view["country_auto"] = countryAuto
view["remark"] = src.remark
view["notify"] = m.Notify
return view
@@ -0,0 +1,40 @@
package service
import (
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/web/service/probe"
)
// V2.2.1 ⑦: the public views' node name is the operator's public
// remark when one is set, and the node name otherwise — the panel's
// own list (a different surface) is untouched.
func TestProbeDisplayNamePrefersPublicRemark(t *testing.T) {
if got := probeDisplayName("node-name", "东京 01"); got != "东京 01" {
t.Fatalf("with remark = %q, want the remark", got)
}
if got := probeDisplayName("node-name", ""); got != "node-name" {
t.Fatalf("empty remark = %q, want node name", got)
}
if got := probeDisplayName("node-name", " "); got != "node-name" {
t.Fatalf("whitespace-only remark = %q, want node name", got)
}
}
func TestBuildProbeNodeViewNameSource(t *testing.T) {
withRemark := probeNodeSource{
name: "internal-name",
meta: probe.Meta{PublicRemark: "公开名", Public: true},
}
if got := buildProbeNodeView(withRemark, false)["name"]; got != "公开名" {
t.Fatalf("anonymous view name = %v, want the public remark", got)
}
if got := buildProbeNodeView(withRemark, true)["name"]; got != "公开名" {
t.Fatalf("admin view name = %v, want the public remark", got)
}
without := probeNodeSource{name: "internal-name", meta: probe.Meta{Public: true}}
if got := buildProbeNodeView(without, false)["name"]; got != "internal-name" {
t.Fatalf("view name without remark = %v, want the node name", got)
}
}
+16 -3
View File
@@ -320,9 +320,10 @@ const (
cadenceXrayLogPrune = "@every 10m"
// cpu.Percent samples over a full minute (blocking), so a finer cadence just
// stacks overlapping samplers; subscribers rate-limit alerts to 1/min anyway.
cadenceCheckHash = "@every 2m"
cadenceCPUAlarm = "@every 1m"
cadenceMemoryAlarm = "@every 1m"
cadenceCheckHash = "@every 2m"
cadenceCPUAlarm = "@every 1m"
cadenceMemoryAlarm = "@every 1m"
cadenceGeoIPCountry = "@weekly"
)
// startTask schedules background jobs (Xray checks, traffic jobs, cron
@@ -378,6 +379,18 @@ func (s *Server) startTask(restartXray bool, loc *time.Location) {
job.NewProbeNotifyHousekeepingJob().Run()
}()
// Node country flags (Q7): refresh the local country database
// weekly. The pass is a conditional download — a no-op while no
// update URL is configured, cheap (304) while the mirror copy is
// current — and the same one-minute startup kick as the probe
// jobs above lets a long-parked install catch up without waiting
// out the week.
_, _ = s.cron.AddJob(cadenceGeoIPCountry, job.NewGeoIPCountryJob())
go func() {
time.Sleep(time.Minute)
job.NewGeoIPCountryJob().Run()
}()
// check client ips from log file every day
_, _ = s.cron.AddJob("@daily", job.NewClearLogsJob())
_, _ = s.cron.AddJob(cadenceXrayLogPrune, job.NewPruneXrayLogsJob())