Deleting a client on the master propagated to nodes via the detach
endpoint, which removes the client from that one inbound's settings but
deliberately keeps the client record. The node ended up with an
orphaned record that kept showing in its Clients view; the master and
node could never converge on a delete.
Full-delete and detach intent now travel separately: the Runtime
interface gains DeleteClient, which on Remote hits the node's
panel/api/clients/del endpoint (record, attachments, traffic; repeat
calls for a client on several inbounds of the same node are swallowed
as idempotent "not found"). Delete/DeleteByEmail/BulkDelete use it for
node inbounds, while Detach/BulkDetach keep the inbound-scoped detach
RPC so removing a client from one inbound never wipes it node-wide
(the #5543 guarantee is preserved and covered by tests). Bulk deletes
above the fold threshold still converge membership via reconcile; their
leftover node records can be cleaned with the node's delete-orphans
action.
Closes#5797
A client save on the master always stamped a fresh updated_at, marked
the node dirty, and let the 5s sync push a full inbounds/update to the
node, where applying it removes and re-adds the Xray handler - killing
live traffic on every edit, including no-op saves (open the editor,
click Save). Nodes stayed online with Xray running while forwarding
nothing until a manual Xray restart.
- No-op client saves preserve the client's updated_at and return before
any DB write, runtime RPC, or node dirty mark when the effective
settings did not change.
- Successful per-client add/update/delete pushes advance the node's
reconcile-skip fingerprint only when the recorded fingerprint proves
the node held the exact pre-edit payload and every push in the edit
succeeded (Remote.AdvancePushedInbound). Anything unproven keeps the
stale fingerprint so the dirty reconcile still sends the full inbound.
Unconditional stamping would certify folded bulk changes (threshold,
flow change, offline edit) or partially failed batches as delivered:
a folded 41->6 bulk delete followed by one live edit left the node
permanently serving all 41 clients in end-to-end testing, with the
snapshot adoption then resurrecting the deleted clients on the master.
- DeleteUser treats only an envelope-level not-found as already deleted;
an HTTP 404 from an old node build without the detach endpoint
surfaces as an error instead of certifying an undelivered delete.
cacheDel drops the fingerprint alongside the id cache so DelInbound
and tag renames leave no stale skip entry.
- Adopting the node's own settings serialization into the master row now
also stamps the fingerprint (RecordAdoptedInbound). Without it the
serialization round-trip invalidated the fingerprint one sync tick
after every push, so each edit degraded back to a full teardown push.
- UpdateInboundClient applies the Shadowsocks method normalization
before the no-op comparison (real method changes bump updated_at, SS
no-op edits are detected) and syncs the generated subId into the
pushed client so the node cannot mint a different one.
Verified with a two-panel docker deployment: no-op saves produce zero
node requests, real edits send one lightweight clients/update RPC with
zero full inbound updates and zero handler teardowns, and folded bulk
deletes still converge.
Based on PR #5778 by @rqzbeh.
Closes#5764Closes#5771
Add .golangci.yml (v2): the standard linters plus bodyclose, errorlint, noctx, misspell, rowserrcheck, sqlclosecheck, unconvert, usestdlibvars, with gofumpt + goimports formatters. Enable the std-error-handling exclusion preset for idiomatic Close/Remove/Setenv ignores; scope-exclude SA1019 (parser.ParseDir in tools/openapigen) and ST1005 (intentional capitalized user-facing error copy that tests assert verbatim). No inline nolint directives were introduced.
Resolve all 217 findings behavior-preserving: gofumpt/goimports formatting, explicit blank assignment on intentionally ignored errors, errors.Is/errors.As and %w wrapping, context-aware stdlib calls (CommandContext/QueryContext/NewRequestWithContext/Dialer), staticcheck simplifications, removed redundant conversions, http.StatusOK and http.MethodGet, inlined the go:fix intPtr helper, and deferred sql rows Close. Add a golangci CI job mirroring the existing Go jobs.
Local hot paths:
- autoRenewClients: replace the O(clients x expired) inner scan with an
email->traffic map lookup (quadratic at scale).
- node traffic sync: scope the client_traffics email-membership query to the
snapshot's emails instead of plucking the whole table every poll.
- add a (expiry_time, reset) index for the per-tick auto-renew filter.
- SQLite: add cache_size/mmap_size/temp_store pragmas (env-tunable); keep the
single-file DELETE journal and synchronous=FULL defaults.
- scale benchmarks now run on SQLite too via XUI_SCALE_TEST=1 (shared
setupScaleDB/resetScaleTables helpers), not just Postgres.
Node paths:
- bulk add/delete/adjust on a node-attached inbound folded one HTTP RPC per
client; above nodeBulkPushThreshold (32) mark the node dirty and let one
ReconcileNode push converge it instead of O(M) sequential round-trips.
Small ops keep the live per-client path. Also hoist nodePushPlan out of the
per-email delete loop.
- ReconcileNode skips inbounds whose wire payload is unchanged (per-tag
fingerprint on Remote), guarded by node-side tag presence so a restarted
node is still re-seeded.
Tests: auto-renew multi-inbound correctness, node-path dispatch (large ops
fold to dirty, small ops push live) via a manager runtime override seam, and
reconcile delta-skip.