Skip to content

Commit 17728a2

Browse files
author
cgossiaux
committed
fix(tailscale): better handling of auth URL
1 parent 536ed25 commit 17728a2

1 file changed

Lines changed: 112 additions & 15 deletions

File tree

tailscale/Main.qml

Lines changed: 112 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ Item {
5656
property string tailscaleIp: ""
5757
property string tailscaleStatus: ""
5858
property bool needsLogin: false
59+
property string authUrl: ""
5960
property int peerCount: 0
6061
property bool isRefreshing: false
6162
property string lastToggleAction: ""
@@ -162,9 +163,27 @@ Item {
162163
root.peerCount = 0;
163164
root._realPeerList = [];
164165
root.exitNodeStatus = null;
166+
// Capture the pending authentication URL exposed by the daemon.
167+
var newAuthUrl = data.AuthURL || "";
168+
root.authUrl = newAuthUrl;
169+
// If a login attempt is in progress and the daemon has surfaced a
170+
// URL that differs from the one cached at click-time, it means the
171+
// URL was regenerated (fresh) — open it immediately.
172+
if (root._loginInProgress
173+
&& !root._loginUrlOpened
174+
&& newAuthUrl.length > 0
175+
&& newAuthUrl !== root._preLoginAuthUrl) {
176+
root._openAuthUrl(newAuthUrl);
177+
}
165178
} else if (root.tailscaleRunning && data.Self && data.Self.TailscaleIPs && data.Self.TailscaleIPs.length > 0) {
166179
root.tailscaleIp = filterIPv4(data.Self.TailscaleIPs)[0] || data.Self.TailscaleIPs[0];
167180
root.tailscaleStatus = "Connected";
181+
root.authUrl = "";
182+
// Login flow settled — reset flags so the next attempt starts clean
183+
root._loginInProgress = false;
184+
root._loginUrlOpened = false;
185+
root._preLoginAuthUrl = "";
186+
loginTimeoutTimer.stop();
168187

169188
var peers = [];
170189
if (data.Peer) {
@@ -202,13 +221,15 @@ Item {
202221
root.peerCount = 0;
203222
root._realPeerList = [];
204223
root.exitNodeStatus = null;
224+
root.authUrl = "";
205225
}
206226
} catch (e) {
207227
Logger.e("Tailscale", "Failed to parse status: " + e);
208228
root.tailscaleRunning = false;
209229
root.needsLogin = false;
210230
root.tailscaleStatus = "Error";
211231
root._realPeerList = [];
232+
root.authUrl = "";
212233
}
213234
} else {
214235
root.tailscaleRunning = false;
@@ -217,6 +238,7 @@ Item {
217238
root.tailscaleIp = "";
218239
root.peerCount = 0;
219240
root._realPeerList = [];
241+
root.authUrl = "";
220242
}
221243
}
222244
}
@@ -246,7 +268,61 @@ Item {
246268

247269
property string lastExitNodeAction: ""
248270

271+
// ─── Login flow state ────────────────────────────────────────────────────
272+
// A login attempt is tracked across two asynchronous sources that may deliver
273+
// a fresh AuthURL: (1) stdout/stderr of `tailscale up` and (2) the periodic
274+
// `tailscale status --json` poll. Whichever arrives first wins; the other is
275+
// deduped via `_loginUrlOpened`.
249276
property bool _loginUrlOpened: false
277+
property bool _loginInProgress: false
278+
// Snapshot of authUrl taken at click-time. Used to detect when the daemon
279+
// has regenerated the URL so we never open a stale/expired one.
280+
property string _preLoginAuthUrl: ""
281+
282+
/**
283+
* Open an authentication URL in the default browser, with anti-double-open
284+
* protection. Resets login-flow state.
285+
*
286+
* @param {string} url Authentication URL to open
287+
*/
288+
function _openAuthUrl(url) {
289+
if (root._loginUrlOpened) return;
290+
if (!url || url.length === 0) return;
291+
root._loginUrlOpened = true;
292+
root._loginInProgress = false;
293+
loginTimeoutTimer.stop();
294+
Logger.d("Tailscale", "Opening auth URL: " + url);
295+
Qt.openUrlExternally(url);
296+
ToastService.showNotice(
297+
pluginApi?.tr("toast.title"),
298+
pluginApi?.tr("toast.login-browser-opened"),
299+
"external-link"
300+
);
301+
}
302+
303+
// Safety net: if no fresh AuthURL surfaces within 10s after a login click,
304+
// either open whatever is cached (best effort) or show an error toast.
305+
Timer {
306+
id: loginTimeoutTimer
307+
interval: 10000
308+
repeat: false
309+
onTriggered: {
310+
if (!root._loginInProgress || root._loginUrlOpened) return;
311+
root._loginInProgress = false;
312+
Logger.w("Tailscale", "Login timeout: no fresh AuthURL received within 10s");
313+
if (root.authUrl && root.authUrl.length > 0) {
314+
// Last resort: open the cached URL (may be the stale one)
315+
Logger.w("Tailscale", "Falling back to cached (possibly stale) AuthURL");
316+
root._openAuthUrl(root.authUrl);
317+
} else {
318+
ToastService.showError(
319+
pluginApi?.tr("toast.title"),
320+
pluginApi?.tr("toast.login-failed"),
321+
"alert-circle"
322+
);
323+
}
324+
}
325+
}
250326

251327
Process {
252328
id: loginProcess
@@ -257,13 +333,7 @@ Item {
257333
Logger.d("Tailscale", "Login output: " + line);
258334
var urlMatch = line.match(/https?:\/\/\S+/);
259335
if (urlMatch) {
260-
root._loginUrlOpened = true;
261-
Qt.openUrlExternally(urlMatch[0]);
262-
ToastService.showNotice(
263-
pluginApi?.tr("toast.title"),
264-
pluginApi?.tr("toast.login-browser-opened"),
265-
"external-link"
266-
);
336+
root._openAuthUrl(urlMatch[0]);
267337
}
268338
}
269339

@@ -278,14 +348,11 @@ Item {
278348
onExited: function (exitCode, exitStatus) {
279349
Logger.d("Tailscale", "Login exited (code " + exitCode + "), urlOpened=" + root._loginUrlOpened);
280350
if (exitCode !== 0 && !root._loginUrlOpened) {
281-
ToastService.showError(
282-
pluginApi?.tr("toast.title"),
283-
pluginApi?.tr("toast.login-failed"),
284-
"alert-circle"
285-
);
286-
Logger.e("Tailscale", "Login failed (exit " + exitCode + ")");
351+
Logger.e("Tailscale", "tailscale up failed (exit " + exitCode + "), waiting on status poll for fresh AuthURL");
287352
}
288-
root._loginUrlOpened = false;
353+
// Do NOT reset _loginUrlOpened here — the status poll may still deliver
354+
// the fresh URL after process exit. The flag is reset by _openAuthUrl
355+
// and by the login-state transitions in statusProcess.
289356
statusDelayTimer.start();
290357
}
291358
}
@@ -535,16 +602,46 @@ Item {
535602
toggleProcess.running = true;
536603
}
537604

605+
/**
606+
* Trigger the Tailscale authentication flow.
607+
*
608+
* @description
609+
* Always runs `tailscale up --force-reauth` to force the daemon to generate
610+
* a fresh AuthURL (any cached one may be expired). The fresh URL is then
611+
* opened from whichever channel delivers it first:
612+
* - stdout/stderr of `tailscale up` (parsed by loginProcess)
613+
* - the next `tailscale status --json` poll (via statusProcess)
614+
* Deduped through `_openAuthUrl` / `_loginUrlOpened`.
615+
*
616+
* Guard: no-op when not in NeedsLogin state to avoid disconnecting an
617+
* already-authenticated session.
618+
*/
538619
function loginTailscale() {
539620
if (!root.tailscaleInstalled)
540621
return;
622+
if (!root.needsLogin) {
623+
Logger.w("Tailscale", "Login requested but backend is not in NeedsLogin state — ignoring");
624+
return;
625+
}
626+
627+
// Start a fresh login attempt
628+
root._loginInProgress = true;
629+
root._loginUrlOpened = false;
630+
root._preLoginAuthUrl = root.authUrl;
631+
632+
// --force-reauth forces the daemon to regenerate the AuthURL even if a
633+
// (possibly expired) one is still cached in its session state.
541634
var loginServer = pluginApi?.pluginSettings?.loginServer || "";
542-
var cmd = ["tailscale", "up", "--accept-routes"];
635+
var cmd = ["tailscale", "up", "--accept-routes", "--force-reauth"];
543636
if (loginServer.trim() !== "") {
544637
cmd.push("--login-server=" + loginServer.trim());
545638
}
546639
loginProcess.command = cmd;
547640
loginProcess.running = true;
641+
642+
// Arm the safety-net timeout and kick an early status refresh
643+
loginTimeoutTimer.restart();
644+
statusDelayTimer.start();
548645
}
549646

550647
Timer {

0 commit comments

Comments
 (0)