fix(nodes): refresh adopted inbound replicas from the node - #197
Conversation
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>
|
Self-review turned up one gap in the first commit, fixed in b1438e9.
Verified on the same two-panel rig with a proxy in front of the node that rejects every
|
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 —
refreshNodeLinkskept regenerating the[node]subscription links from thestale
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
inboundsarray points at and unassigns them all.The comment at
service/inbounds.go:118already promised "reconciliationrefreshes the copy". It does now.
What changed
runReconcilere-pulls the adopted tags through the same two stock apiv2endpoints
AdoptInboundsuses — the list projection dropsout_json/addrs,so the full panel shape has to be fetched by id — and rewrites
type/options/out_json/addrswhen they moved. One extra GET perreconcile.
It runs before
expectedClientsandrefreshNodeLinks, or the links wouldbe 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
changesrow plus aLastUpdatebump thatrepaints every open panel. The compare is byte equality, sound because both
sides are
buildReplicaInbound'sMarshalIndentover a decoded map and Gosorts map keys — and unlike
jsonEqualit getsnil == nilright.Only the four columns above are written, not
Save:tls_idandnode_idarethe master's to keep (adoption drops the node's
tls_idon purpose, TLSterminates 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
ReconcileAllOnlinesweep. Polling every node's inbound list every5s 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:
expectedClientsfinds no node-local idand the del branch wipes the node's
@clusterclients, while the master keepsserving 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 apiv2shape against a real SQLite DB. It covers the rewrite, that
tls_id/node_idsurvive, that panel-only keys stay out of
Options, that a replica absent fromthe node is untouched, and that a second run writes nothing. With the DB write
short-circuited it fails with exactly this issue's symptom:
Plus an A/B on two real panels (a master and a node, same script both times):
listen_portserver_port…@127.0.0.1:14443…@127.0.0.1:14444changesrows after 3 reconcilesOn main the reconcile reports success —
last_syncadvances,dirtyclears —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