Skip to content

fix(nodes): refresh adopted inbound replicas from the node - #197

Merged
shenaba merged 2 commits into
mainfrom
fix/refresh-node-replicas
Sep 16, 2026
Merged

shenaba merged 2 commits into
mainfrom
fix/refresh-node-replicas

Conversation

@shenaba

@shenaba shenaba commented Sep 16, 2026

Copy link
Copy Markdown
Owner

Adopting a node inbound copies it into a local replica row once, and nothing
ever refreshed that copy. An edit made on the node itself was therefore
invisible on the master for good: the inbounds list kept showing the port the
inbound had on the day it was adopted, and — the half users actually feel —
refreshNodeLinks kept regenerating the [node] subscription links from the
stale out_json, so the master went on handing out a dead port.

The only way out was to remove the replica and re-adopt, which drops the row
every client's inbounds array points at and unassigns them all.

The comment at service/inbounds.go:118 already promised "reconciliation
refreshes the copy". It does now.

What changed

runReconcile re-pulls the adopted tags through the same two stock apiv2
endpoints AdoptInbounds uses — the list projection drops out_json/addrs,
so the full panel shape has to be fetched by id — and rewrites
type/options/out_json/addrs when they moved. One extra GET per
reconcile.

It runs before expectedClients and refreshNodeLinks, or the links would
be rebuilt from the snapshot it just replaced.

Nothing is written when nothing changed: this runs on the 5s heartbeat, and
every write costs an unpruned changes row plus a LastUpdate bump that
repaints every open panel. The compare is byte equality, sound because both
sides are buildReplicaInbound's MarshalIndent over a decoded map and Go
sorts map keys — and unlike jsonEqual it gets nil == nil right.

Only the four columns above are written, not Save: tls_id and node_id are
the master's to keep (adoption drops the node's tls_id on purpose, TLS
terminates there).

A replica whose tag the node no longer lists is left alone rather than deleted.
A rename there is indistinguishable from a delete from here, and that row is
what every client points at.

Convergence

A node-side edit cannot mark the master dirty — it has no way to know — so the
refresh lands on the manual sync button, on any dirty reconcile, or at worst on
the hourly ReconcileAllOnline sweep. Polling every node's inbound list every
5s to do better is not worth it.

Not fixed here

A tag renamed on the node still falls through the gap. The replica is keyed
by tag, so a rename loses its identity: expectedClients finds no node-local id
and the del branch wipes the node's @cluster clients, while the master keeps
serving links for a tag that no longer exists. Repairing that needs the
node-side inbound id stored at adoption; it is its own change.

Verification

TestRefreshReplicasPullsNodeSideEdits — httptest node speaking the real apiv2
shape against a real SQLite DB. It covers the rewrite, that tls_id/node_id
survive, that panel-only keys stay out of Options, that a replica absent from
the node is untouched, and that a second run writes nothing. With the DB write
short-circuited it fails with exactly this issue's symptom:

regenerated links = [vless://uuid-1@node.example:443], want one naming port 8443

Plus an A/B on two real panels (a master and a node, same script both times):

main this branch
replica listen_port 14443 (stale) 14444
replica server_port 14443 (stale) 14444
subscription link …@127.0.0.1:14443 …@127.0.0.1:14444
changes rows after 3 reconciles 1 3

On main the reconcile reports success — last_sync advances, dirty clears —
it just never looks at the inbound. The 3-vs-3 on the right column is the
idempotence check: three reconciles, one refresh write.

Fixes #196

shenaba and others added 2 commits September 16, 2026 10:40
Adoption copied a node's inbound into a local replica row once and nothing
ever refreshed it, so an edit made on the node itself was invisible here for
good: the master's inbounds list kept showing the old listen_port, and -- the
half users actually feel -- refreshNodeLinks kept regenerating the "[node] "
subscription links from the stale out_json, handing out a dead port. The only
way out was to remove the replica and re-adopt, which drops the row every
client's inbounds array points at and unassigns them all.

The comment at service/inbounds.go:118 already promised "reconciliation
refreshes the copy". It does now.

runReconcile re-pulls the adopted tags through the same two stock apiv2
endpoints AdoptInbounds uses (the list projection drops out_json/addrs, so the
full shape has to be fetched by id) and rewrites type/options/out_json/addrs
when they moved. It runs before expectedClients and refreshNodeLinks, or the
links would be rebuilt from the snapshot it just replaced. Costs one extra GET
per reconcile; converges on the manual sync button, on any dirty reconcile, and
at worst on the hourly sweep.

Nothing is written when nothing changed: this runs on the 5s heartbeat and
every write costs an unpruned changes row plus a LastUpdate bump that repaints
every open panel. The compare is byte equality, sound because both sides are
buildReplicaInbound's MarshalIndent over a decoded map and Go sorts map keys --
and unlike jsonEqual it gets nil == nil right.

A replica whose tag the node no longer lists is left alone rather than deleted.
A rename there is indistinguishable from a delete from here, and that row is
what every client points at. Repairing a rename needs the node-side id stored
at adoption; that is its own change.

Fixes #196

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
refreshReplicas commits the new snapshot, and refreshNodeLinks sits past five
`return err` paths. A node that keeps rejecting a save -- one duplicate client
name fails trojan's whole UpdateUsers -- therefore left the panel showing the
new port while the subscription kept serving the old one, with nothing to
recover it: every retry found the row already written, skipped, failed the push
again, and never reached the links. That is the same stale-link symptom #196
set out to fix, now with the UI actively contradicting it.

refreshReplicas reports whether it wrote anything, and the caller regenerates
the links right there when it did. The call on the way out then finds them
already correct and writes nothing, so the cost is one clients scan on the rare
round where a replica actually moved.

Verified on two panels with a proxy in front of the node that rejects every
POST /save. Identical scenario, every push refused, node stuck dirty:

  before:  inbounds list 14444, subscription link never regenerated
  after:   inbounds list 14444, subscription link 127.0.0.1:14444

Also drops a wrong justification from the comment above the call: expectedClients
reads only Id and Tag, neither of which the refresh touches, so refreshNodeLinks
is the only thing the ordering is for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@shenaba

shenaba commented Sep 16, 2026

Copy link
Copy Markdown
Owner Author

Self-review turned up one gap in the first commit, fixed in b1438e9.

refreshReplicas commits the new snapshot, but refreshNodeLinks sits past five return err paths (the client push loop). A node that keeps rejecting a save — one duplicate client name fails trojan's whole UpdateUsers — left the panel showing the new port while the subscription kept serving the old one, unrecoverably: every retry found the row already written, skipped, failed the push again, and never reached the links. Same stale-link symptom this PR set out to fix, with the UI now contradicting it.

refreshReplicas now reports whether it wrote anything and the caller regenerates the links right there. The call on the way out finds them already correct and writes nothing, so the cost is one clients scan on the rare round that moved.

Verified on the same two-panel rig with a proxy in front of the node that rejects every POST /save. Identical scenario, every push refused, node stuck dirty:

before b1438e9 after
inbounds list 14444 14444
subscription link never regenerated 127.0.0.1:14444

@shenaba
shenaba merged commit ae7671c into main Sep 16, 2026
2 checks passed
@shenaba
shenaba deleted the fix/refresh-node-replicas branch September 16, 2026 03:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

多接点同步存在问题

1 participant