Panel-side POST /api/reality/scan performs real TLS handshakes
against operator input (domains / IPs / CIDRs, port suffixes) or the
built-in pool (form presets plus microsoft/google/nvidia/samsung/
cloudflare and friends) and reports per target: usable or not, TLS
version, ALPN, key exchange, first cert SAN, latency, and a short
failure reason. Key-exchange support is proven by restricted-group
handshakes (X25519MLKEM768 first, then X25519, then a combined offer)
since the negotiated group is not exposed to Go clients. Bounds: 32
targets, 8 concurrent, 4s per probe. Node form gains a 查找目标
button beside the Reality preset; the scanner is a stacked overlay
so form state survives, and 使用 backfills SNI/dest/port.
New read-only GET /api/nodes/:id/reality-keys returns the
panel-hosted keypair for the node's server from keyStore (display
only — it never mints a pair; a missing entry reports nulls). The
node edit form shows public and private key rows with copy buttons
for Reality protocols.
Assign/unassign, customer-form alignment, suspend/resume and
credential rotation no longer rebuild (and restart the node's
sing-box) per change. They mark the node rebuild-pending; a 1s
background flusher applies exactly one rebuild with the final state
when the FIXED window — 10s from the first pending change, never
extended by later changes — elapses. Manual deploy, repair, node
edits and server reconciles stay immediate and consume the mark.
Removal clears it, and the flusher discards marks on deleted /
removing / draft nodes, so a pending apply can never resurrect a
node. The mark is persisted in the data file, so a panel restart
inside the window cannot strand a node: the first post-boot pass
flushes whatever is due.
Item 20: drop the zero-client deploy gate in ValidateNodeDeploy for
all protocols, stop RebuildNode from auto-removing a node whose
bindings reach zero, and let server/heartbeat reconciliation rebuild
zero-client nodes like any other. Nodes are built and deployed first;
bindings can be added and removed freely.
Item 21: socks5-auth nodes carry one node-level fixed account
(node.socksUser/socksPassword, minted at creation or by the store-load
migration MigrateSocksNodeCredentials, idempotent). The sing-box
inbound's only user is that pair regardless of bindings; subscription
links for every bound customer carry the same pair. The generated
username avoids the ng: prefix so the agent never attributes the
account's traffic to a customer — it stays on the node's inbound
counters. Edit form shows both fields (copyable) for socks nodes;
NormalizeNode validates operator-supplied usernames.
Inbounds were pinned to the server's registered public address
(ipv4: PublicAddress, ipv6: PublicAddressV6), so addresses not
registered as 'the' public address - intranet NICs used for relay
landings, additional v6 addresses - had nothing listening and
intranet relays were unreachable (field-verified: ss showed the
landing port bound to the public IP only). 3x-ui/xray bind the
wildcard and serve these paths fine.
- listenAddress: explicit bindAddress still wins; ipv4 -> 0.0.0.0,
ipv6 -> ::, dual -> :: (ipv6 wildcard per user decision
2026-10-21: multi-v6 machines must serve all their v6 addresses).
- ResolveBindAddress: no longer resolves the registered address
into a bind pin (that resolution was the production path of the
pinning via resourceForNode/resourceForNodeExit); returns
("", nil) for all stacks, as it already did for dual/absent.
- serverAddress (client connection address / subscription host)
is unchanged.
- Golden fixtures updated for the three listen fields that
intentionally change; new listen_hotfix_test.go covers the
listenAddress cases and the resourceForNode/exit deploy chains.
Item 10: remove the disabled decorative topbar search input (each page
has its own working search); CSS rule and media-query reference removed.
Item 11: menu/title renames — 服务器 -> 节点管理, 客户与订阅 -> 用户订阅,
转发与节点 -> 入站&出站, 设置与运维 -> 设置 (nav, TITLES/breadcrumb and
cross-references synced); the chains page add button is now 添加入站 and
its count reads N 个入站; the 安装与命令 page is merged into 设置
(commandsHTML block between the settings grid and the jobs table) and
the page itself is removed from nav, PAGES, TITLES, load() and the view
map — an old #commands hash now lands on the SPA's standard overview
fallback.
Item 12: remove the system page's 最近活动 panel (rendering and its
audit label map plus orphaned CSS); backend audit records untouched.
Item 13: the settings jobs table gets the w-jobs treatment — ID, status
and time cells never wrap, compact row padding, and the error column is
capped at 22% width so an empty error column no longer balloons.
Also fixes a class collision from the item-8 batch: the exit-mode
segmented control is renamed .seg -> .segctl so it no longer draws a
border box around the system page info-bar segments.
Golden re-captured: the only diff is the served index HTML inside
errors.badSubscription, audited character by character as exactly these
renames and removals.
A forward node's exit target is now a choice: a managed panel server
(existing behavior) or a manual landing endpoint — address + port +
protocol (VLESS / SS2022 / SOCKS5) + operator-supplied credentials,
including third-party nodes the panel does not run. The panel only
builds the entry-side sing-box outbound and route rule; there is no
exit resource, no exit job, and the node is exit-ready by definition.
- model: Node.ExitMode/ExitAddress; manual exits force fixed port mode
and skip the exit-server port table entirely
- validation: address/port required; per-protocol credential presence;
SS2022 passwords must be base64 key material of the method's key
length (one PSK segment or serverKey:userKey) — the exact form
sing-box accepts, rejected up front instead of failing at deploy
- orchestration: resource building, deploy validation and rebuilds all
branch on manualExit; switching managed->manual queues removal of the
old exit resource, manual->managed mints a fresh exit resource id
(deploy validation previously dereferenced the nil exit server —
regression test locks the fix)
- UI: exit target segmented control, link paste box that parses
vless:// / ss:// / socks5:// links into the form, per-protocol
credential fields (blank keeps the stored value on edit)
- golden: new manualCreate/manualAssign/manualDeploy/manualJobs/
manualNodes sections — exactly one entry-side job whose outbound
targets the manual address; all prior sections unchanged
- the generated combined sing-box config passes the real sing-box
1.13.21 check
One credential set per customer (UUID + 32-byte root relay key + shortId)
lives on the customer record; node bindings no longer store credentials.
SS2022 user keys derive deterministically from the root key by method
length (16/24/32-byte prefix), so a single password serves every protocol,
matching 3x-ui's per-client credential model. Reality public keys stay a
node property and are shown per binding in the credentials pane.
- store: load-time convergence — first binding's set wins; a converged
password that is not a 32-byte root key is regenerated (uuid/shortId
preserved); bindings are stripped; idempotent, covered by tests
- deploy: the orchestrator stamps the customer's set onto a build copy;
subscriptions resolve the same set; the derivation applies in both the
inbound builder and the URI builder (verified live: Alice's N1 link is
byte-identical to the pre-change seed expectation, her N2 user key is
exactly the root key's 16-byte prefix)
- API: GET /api/customers/:id/credentials returns {credentials, bindings};
regenerate is now POST /api/customers/:id/credentials/regenerate and
rotates the whole set, rebuilding every deployed node of the customer
- UI: credentials pane shows the single shared set plus per-binding
node/protocol/Reality-public-key rows
- golden subscriptions fixture regenerated under one shared set (RF and
SS entries byte-identical to before; five entries audited by hand)
- golden baseline re-captured for the new shapes; difftest normalizer now
treats a base64 root key's prefix-truncation encodings and the Surge
'='-mangled variants as the same secret
agent-go: engineReport gains singBoxVersion. The probe runs
`sing-box version` once, parses the version token from its first line
('unknown' from unstamped builds reports empty), and caches it keyed by
the binary's mtime+size — re-probed only when an engine install/update
replaces the file. Steady-state heartbeats pay one stat, no subprocess.
Agent version stays 0.6.8.
panel-go: EngineState.singBoxVersion persisted from heartbeats (old
agents without the field keep showing '-'), servers table core-version
column now renders it. Golden gains heartbeatWithEngine +
serversAfterEngineHeartbeat sections locking the full chain, plus a
listen-address (<hostip>) normalization for the sandbox bind address.
singboxversion_test.go covers parsing, cache hits, and re-probe on
binary replacement.
GET /api/customers gains a summary object (total/online/depleted/
depletingSoon/disabled/enabled) computed server-side; the customers page
renders it as a six-cell stats strip above the toolbar.
Predicates aligned verbatim with 3x-ui client_paging.go:
- depleted = (limit>0 && used>=limit) || (expiry set && expiry<=now),
counted regardless of status;
- depletingSoon = active && !depleted && (expiry within expireDiff days
|| remaining traffic < trafficDiff GB), thresholds from new settings
expireDiff/trafficDiff with 3x-ui's factory default 0 (nobody counts
until configured);
- enabled = status active; disabled = manual suspensions only.
Online reuses the existing 3-minute presence projection.
Golden diff confined to the additive summary keys; summary_test.go
covers every bucket with thresholds off (soon=0) and on (soon=2).
NormalizeNode re-selected ports whenever the request merely CONTAINED
port/serverId keys; the browser form always submits them (port=null in
random mode), so every save re-rolled both entry and exit ports. Now an
assigned port only moves on an explicit addressing change: mode switch to
random, a different fixed port value, or a server move where the port is
taken (fixed mode surfaces the 409). random->fixed pins the current port.
Regression: portlock_test.go (UI-shape payload saved twice, rename-only,
mode switches, fixed change/conflict, server move free/taken).
Golden re-captured: diff confined to nodePatchUiStyle/nodePatchJobs/
nodesAfterPatch/backup port leaves returning to the seed ports.
Single-source Go from this round on: golden.mjs replays a fixed API script
against a Go panel seeded from the difftest seed DB and diffs every response
(learning-normalized) against golden.json. 25 sections: lists, credentials,
all subscription formats, deploy job payloads, usage/presence, UI-style node
PATCH, /api/system shape, backup, error cases.
- trafficResetDay/Period/At on the shared customer record (omitempty,
inert for the Node panel, which preserves unknown fields)
- housekeeping applies due resets before quota evaluation: period-key
idempotency, short-month clamping (31st fires Feb 28), missed-period
catch-up after panel downtime, traffic suspensions auto-resume
- first sight of a schedule only arms the period marker, never zeroes;
API create/PATCH accept trafficResetDay (0=off) and re-arm on change
- UI: reset-day input + last-reset display in the customer form
- tests: calendar math, apply pass, API lifecycle, persistence;
full Go/Node suites green, difftest formal 50/50
Pure-stdlib Go implementation of the whole control plane: same
nexusgate.json schema (unknown fields preserved verbatim, explicit
null/false tri-states modeled), same HTTP API and error strings,
embedded frontend (NG_PUBLIC_DIR override), self-implemented scrypt
auth, X25519 Reality keys, panel-hosted keyStore and heartbeat
reconciliation included. Version stays 0.6.8.
Verified: Go tests 33/33 (scrypt + Reality vectors generated from
the Node implementation, subscription goldens byte-identical),
Node suite 52/52 untouched, differential E2E against the Node
panel 50/50 including two-way DB swap. See panel-go/PARITY.md
and panel-go/INSTALL-GO.md.