Compare commits

...

13 Commits

Author SHA1 Message Date
Zoltán Papp
8294f416b8 [client] Deliver relay-borne packets when the relay comes up after ICE
When the relay connection becomes ready while ICE is already the active
path, the proxy was only stored for later: neither Work() nor RedirectAs()
ran, so nothing consumed the relayed connection. The remote peer's paths
can come up in the opposite order, in which case it is already sending
WireGuard traffic over the relay — those packets were never delivered. A
responder that had configured a nil endpoint then waited for a handshake
that could not arrive, until the 5s delayed-update fallback fired; on an
Android e2e run this stretched connected-to-first-ping to ~12s.

Redirect the standby proxy to the active ICE endpoint. RedirectAs now also
starts the proxy when it has not been started yet, with the attribution
set before the reader runs: starting through Work() first would hand
packets to WireGuard labelled with the relayed fake address, and WireGuard
would roam to it and send its replies there. The udp proxy starts its
writer worker together with the reader — Work() skips the isStarted block
on a later switch to relay, so the writer must already exist by then.

RedirectAs also gained the same nil-remoteConn guard Work() has: now that
it can start workers, calling it before AddTurnConn must stay a no-op.
2026-08-05 15:22:56 +02:00
Zoltán Papp
906fdf4bb5 Merge remote-tracking branch 'origin/main' into android/gui-integration
# Conflicts:
#	client/android/profile_state_test.go
2026-08-04 21:53:44 +02:00
Zoltán Papp
4263315527 [client] Read the extend flow's config and hint path in one lock
extendAuthSession took the config from stateSnapshot and the config path from a
second call, each acquiring the lock on its own. A profile switch landing
between the two swaps every field, which would authenticate with one profile's
config while reading the login hint from another profile's account file.

Replace configPathSnapshot with authSnapshot, which returns both from a single
critical section.
2026-07-30 21:00:44 +02:00
Zoltán Papp
56ff5237dd [client] Report the login's profile ID only when it is one
LoginResult.ProfileID was filled from the request's ProfileName, which is a
handle: a display name or an ID prefix resolve just as well. waitSSOLogin names
the state file after it, so a handle would have written the account email to a
file no reader looks for — the email silently lost, plus a stray file.

Fill it only on the branch where the daemon supplied the ID, and leave it empty
otherwise; waitSSOLogin then falls back to the active profile, as it did before
the field existed.
2026-07-30 20:51:40 +02:00
Zoltán Papp
3a17d0381c [client] Clear the removed profile's email by its resolved ID
RemoveProfile takes a handle — a display name or an ID prefix resolve just as
well as a full ID — but the state file holding the account email is named after
the ID. Passing the request handle straight through therefore named a
different file, or none, leaving the email behind for a recreated profile to
inherit.

The daemon already echoes back the ID it resolved for exactly this purpose;
use it.
2026-07-30 20:46:32 +02:00
Zoltán Papp
6155c94b05 [client] Reuse the profile's account for Android SSO logins
The Android binding never recorded which account a profile belongs to, so
every interactive login and every session extend went to the IdP with no
login_hint. With nothing to go on the IdP picks an account itself, which on a
session extend means re-authenticating an account the profile is already
signed in with.

Store the email the PKCE flow already parses out of the ID token, and pass it
back as the hint on later flows. An empty hint stays meaningful: a fresh
profile, or one that was logged out, deliberately leaves the choice to the
IdP, which is how a profile changes accounts. Logout clears the stored email
for that reason — while it is on disk it would steer the next login straight
back into the account just logged out of.

The email is keyed off the profile's config path rather than the active
profile: Auth.login runs in a goroutine, so the active profile can change
under a flow already in flight. It lands in <profile>.account.json, not the
<profile>.state.json desktop uses for the same data — there the email and the
engine's state manager sit in different directories, but on Android both
resolve under files/, and the state manager rewrites the whole file from its
own keys.
2026-07-30 20:34:08 +02:00
Zoltán Papp
09f7fb6510 [client] Drop the initial GetNetworkMap fetch on Android startup
Android startup opened a throwaway Sync stream to management before
creating the TUN device, only to learn the initial routes, DNS config
and the DNS feature flag. Server side this computed a full network map
and broadcast a false connect/disconnect pair to every peer in the
account on every Android start; client side it put a blocking network
round trip on the critical startup path and failed the whole engine
start when management was unreachable.

None of its outputs are needed upfront anymore: the TUN is created
empty and the first sync triggers a rebuild that pulls the fresh route
and search domain state, the permanent DNS server starts with an empty
config that the first sync populates, and the fake IP manager is
created lazily when the DNS feature flag turns on.

Remove readInitialSettings and its plumbing: the InitialRoutes and
DNSFeatureFlag manager config fields, the android construction-time
route setup, the initial-route bookkeeping in the notifiers and the
now-unused GetNetworkMap client method.
2026-07-30 20:19:41 +02:00
Zoltán Papp
4475819f38 [client] Pull fresh TUN settings on Android rebuild instead of pushing state
The Android TUN rebuild consumed state pushed through notifications and
a Java-side snapshot, and both sources were unreliable. The DNS
search-domain notifier fired OnNetworkChanged with an empty string,
which the rebuild handler treated as the new route list, so any search
domain change rebuilt the TUN with zero routes and cut all tunnel
traffic. The rebuild also reused the search domains cached at the last
establish, so search domain updates never reached the TUN at runtime.

Make the notification a pure trigger and let the Java side pull a fresh
snapshot instead. Expose GetTunSettings on the Android SDK client: it
returns the current TUN route ranges, derived on demand by the route
manager from the client routes, the exit-node selection and the fake IP
blocks, together with the DNS search domains. The route notifier keeps
only its last-announced baseline to suppress triggers for unchanged
syncs; the TUN route state is owned by the route manager. SearchDomains
now locks the DNS server mutex since the pull arrives from a Java
thread.

Requires the matching android-client change that switches recreateTUN
to the pull API.
2026-07-30 20:19:41 +02:00
Zoltán Papp
c8adaa45da [client] Serialize Android tunnel reconfiguration callbacks
The Android route notifier and the DNS search-domain notifier both
delivered OnNetworkChanged from a fire-and-forget goroutine per update.
Two updates in quick succession could reach the Java side reordered:
the TUN rebuild handler applies them in arrival order and compares
against the last applied parameters, so a stale route set delivered
last won as the final TUN state. This is the same reordering hazard
fixed for iOS in #6454.

Wrap the Android network change listener into the shared tunnelnotifier
FIFO introduced in #6870, the same way RunOniOS does, and deliver both
notifiers synchronously into it. Enqueueing is non-blocking, a single
delivery goroutine preserves order, and calls into Java never overlap.

Also stop hasRouteDiff from sorting the notifier's shared route slices
in place; compare sorted copies instead.
2026-07-30 20:19:41 +02:00
Zoltán Papp
e970daaf5f [client] Create the Android fake IP manager lazily on DNS flag enable
The fake IP manager was only created at route manager construction,
from the DNS feature flag fetched by the initial GetNetworkMap call.
When the flag flipped to true mid-session, UpdateRoutes set
useNewDNSRoute but never created the manager, so domain routes added
after the flip got a DNS interceptor with a nil fake IP manager.

internalDnatFw only checked for a firewall and GOOS, so the interceptor
took the DNAT path and called GetFakeIP/AllocateFakeIP on the nil
*fakeip.Manager. These methods lock m.mu first, which is a nil pointer
dereference: the first DNS answer for such a route panicked and crashed
the VPN service. The fake IP blocks (240.0.0.0/8 and its v6 pair) also
never reached the TUN, since only the constructor registered them.

Create the manager and its TUN routes from UpdateRoutes when the flag
turns on, notify so the fake IP blocks get into the TUN without a
client route change, and treat a nil manager as no internal DNAT.

This is groundwork for removing the initial GetNetworkMap fetch, after
which every startup goes through the flag-off-to-on transition.
2026-07-30 20:19:41 +02:00
Zoltán Papp
5ae323a555 [client] Delete the account email when a profile is removed
Removing a profile left its state file behind: the daemon deletes what it
owns, but the file holding the account email is user-owned and out of reach
for a root daemon, which is why Connection.Logout already clears it from the
UI side.

Beyond the stray file, legacy profiles are keyed by name rather than by a
generated ID, so recreating a profile under a removed one's name inherited
its email — shown as the account in the profile list and sent as the
login_hint on the next login.
2026-07-30 20:19:41 +02:00
Zoltán Papp
19337dc056 [client] File the account email against the profile the login ran for
SetActiveProfileState resolves the target itself, so it writes to whichever
profile is active when it is called. A GUI SSO login spans seconds of user
interaction in the browser, and the tray stays clickable throughout: switching
profiles in that window left the email filed under the profile that happened
to be active when the flow returned. The wrong profile then advertised an
account it does not own, and offered it as the login_hint next time.

Add SetProfileState(id, state), the write-side counterpart of the existing
GetProfileState(id), and keep SetActiveProfileState as a wrapper for callers
with no particular profile in mind. Login now reports the profile it resolved
so the frontend can hand it back with the SSO wait, which closes the window.
2026-07-30 20:19:41 +02:00
Zoltán Papp
fd06d9a3d5 [client] Store the account email after a GUI SSO login
The daemon returns the authenticated user's email from WaitSSOLogin but
cannot persist it: it runs as root while the per-profile state file is
user-owned. The CLI's handleSSOLogin writes it after its own WaitSSOLogin;
the GUI path read the value and dropped it.

The profile was therefore left with no email, so Profiles.List showed no
account for it, and later logins and session extends went out with no
login_hint — leaving the IdP to pick an account instead of reusing the one
the profile belongs to. Mirror the CLI and store it, next to the Logout
path that already clears the same file for the same reason.
2026-07-30 20:19:41 +02:00
5 changed files with 52 additions and 3 deletions

View File

@@ -114,6 +114,10 @@ func (p *ProxyBind) Pause() {
}
func (p *ProxyBind) RedirectAs(endpoint *net.UDPAddr) {
if p.remoteConn == nil {
return
}
ep, err := addrToEndpoint(endpoint)
if err != nil {
log.Errorf("failed to start package redirection: %v", err)
@@ -125,6 +129,12 @@ func (p *ProxyBind) RedirectAs(endpoint *net.UDPAddr) {
p.wgCurrentUsed = ep
// start here (not only in Work) so the first packet already carries the redirected address
if !p.isStarted {
p.isStarted = true
go p.proxyToLocal(p.ctx)
}
p.pausedCond.Signal()
p.pausedCond.L.Unlock()
}

View File

@@ -188,6 +188,10 @@ func (p *ProxyWrapper) Pause() {
}
func (p *ProxyWrapper) RedirectAs(endpoint *net.UDPAddr) {
if p.remoteConn == nil {
return
}
if endpoint == nil || endpoint.IP == nil {
log.Errorf("failed to start package redirection, endpoint is nil")
return
@@ -215,6 +219,12 @@ func (p *ProxyWrapper) RedirectAs(endpoint *net.UDPAddr) {
p.headerCurrentUsed = header
p.rawConn = p.selectRawConn(header)
// start here (not only in Work) so the first packet already carries the rewritten headers
if !p.isStarted {
p.isStarted = true
go p.proxyToLocal(p.ctx)
}
p.pausedCond.Signal()
p.pausedCond.L.Unlock()
}

View File

@@ -12,9 +12,10 @@ type Proxy interface {
Work() // Work start or resume the proxy
Pause() // Pause to forward the packages from remote connection to WireGuard. The opposite way still works.
//RedirectAs resume the forwarding the packages from relayed connection to WireGuard interface if it was paused
//and rewrite the src address to the endpoint address.
//With this logic can avoid the package loss from relayed connections.
//RedirectAs forwards the packages from the relayed connection to the WireGuard interface
//with the src address rewritten to the endpoint address, starting the proxy if needed and
//resuming it if it was paused. Never delivers a packet with the relayed fake address —
//WireGuard would roam to it.
RedirectAs(endpoint *net.UDPAddr)
CloseConn() error
SetDisconnectListener(disconnected func())

View File

@@ -123,6 +123,10 @@ func (p *WGUDPProxy) Pause() {
// RedirectAs start to use the fake sourced raw socket as package sender
func (p *WGUDPProxy) RedirectAs(endpoint *net.UDPAddr) {
if p.remoteConn == nil {
return
}
p.pausedCond.L.Lock()
defer func() {
p.pausedCond.Signal()
@@ -145,6 +149,13 @@ func (p *WGUDPProxy) RedirectAs(endpoint *net.UDPAddr) {
}
p.srcFakerConn = srcFakerConn
p.sendPkg = p.srcFakerConn.SendPkg
// start here (not only in Work) so the first packet already carries the faked source
if !p.isStarted {
p.isStarted = true
go p.proxyToRemote(p.ctx)
go p.proxyToLocal(p.ctx)
}
}
// InjectPacket writes b to the remote peer over the underlying transport.

View File

@@ -134,6 +134,9 @@ type Conn struct {
wgProxyRelay wgproxy.Proxy
handshaker *Handshaker
// endpoint of the active ICE path, for redirecting a later relay conn. Guarded by mu.
iceWgEndpoint *net.UDPAddr
guard *guard.Guard
wg sync.WaitGroup
@@ -470,6 +473,7 @@ func (conn *Conn) onICEConnectionIsReady(priority conntype.ConnPriority, iceConn
conn.handleConfigurationFailure(err, wgProxy)
return
}
conn.iceWgEndpoint = ep
wgConfigWorkaround()
if conn.wgProxyRelay != nil {
@@ -495,6 +499,8 @@ func (conn *Conn) onICEStateDisconnected(sessionChanged bool) {
conn.Log.Tracef("ICE connection state changed to disconnected")
conn.iceWgEndpoint = nil
if conn.wgProxyICE != nil {
if err := conn.wgProxyICE.CloseConn(); err != nil {
conn.Log.Warnf("failed to close deprecated wg proxy conn: %v", err)
@@ -577,6 +583,17 @@ func (conn *Conn) onRelayConnectionIsReady(rci RelayConnInfo) {
if conn.isICEActive() {
conn.Log.Debugf("do not switch to relay because current priority is: %s", conn.currentConnPriority.String())
conn.setRelayedProxy(wgProxy)
// The remote may already be sending WireGuard traffic over the relay;
// keep consuming it, attributed to the ICE endpoint. Work() would
// attribute it to the relayed fake address and WireGuard would roam there.
if ep := conn.iceWgEndpoint; ep != nil {
conn.Log.Debugf("redirect packets from relayed conn to WireGuard as %s", ep)
wgProxy.RedirectAs(ep)
} else {
conn.Log.Warnf("ICE is active but its wg endpoint is unknown, leaving relayed proxy parked")
}
conn.statusRelay.SetConnected()
conn.updateRelayStatus(rci.relayedConn.RemoteAddr().String(), rci.rosenpassPubKey, time.Now())
return