Skip to content

Commit a0f11c2

Browse files
authored
Merge pull request #657 from SuperKumKum/feat/tailscale-login
feat(tailscale): add login/authentication support with custom login server
2 parents f0fc904 + 14ccc8a commit a0f11c2

12 files changed

Lines changed: 292 additions & 25 deletions

File tree

tailscale/BarWidget.qml

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,13 +89,20 @@ Item {
8989
id: contextMenu
9090

9191
model: [
92+
{
93+
"label": pluginApi?.tr("context.login"),
94+
"action": "login",
95+
"icon": "login",
96+
"visible": mainInstance?.needsLogin ?? false
97+
},
9298
{
9399
"label": (mainInstance?.tailscaleRunning ?? false)
94100
? pluginApi?.tr("context.disconnect")
95101
: pluginApi?.tr("context.connect"),
96102
"action": "toggle-tailscale",
97103
"icon": (mainInstance?.tailscaleRunning ?? false) ? "plug-x" : "plug",
98-
"enabled": mainInstance?.tailscaleInstalled ?? false
104+
"enabled": mainInstance?.tailscaleInstalled ?? false,
105+
"visible": !(mainInstance?.needsLogin ?? false)
99106
},
100107
{
101108
"label": pluginApi?.tr("actions.widget-settings"),
@@ -114,6 +121,10 @@ Item {
114121
if (mainInstance) {
115122
mainInstance.toggleTailscale()
116123
}
124+
} else if (action === "login") {
125+
if (mainInstance) {
126+
mainInstance.loginTailscale()
127+
}
117128
}
118129
}
119130
}

tailscale/Main.qml

Lines changed: 171 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ Item {
5555
property bool tailscaleRunning: false
5656
property string tailscaleIp: ""
5757
property string tailscaleStatus: ""
58+
property bool needsLogin: false
59+
property string authUrl: ""
5860
property int peerCount: 0
5961
property bool isRefreshing: false
6062
property string lastToggleAction: ""
@@ -160,10 +162,35 @@ Item {
160162
try {
161163
var data = JSON.parse(stdout);
162164
root.tailscaleRunning = data.BackendState === "Running";
165+
root.needsLogin = data.BackendState === "NeedsLogin";
163166

164-
if (root.tailscaleRunning && data.Self && data.Self.TailscaleIPs && data.Self.TailscaleIPs.length > 0) {
167+
if (root.needsLogin) {
168+
root.tailscaleIp = "";
169+
root.tailscaleStatus = "NeedsLogin";
170+
root.peerCount = 0;
171+
root._realPeerList = [];
172+
root.exitNodeStatus = null;
173+
// Capture the pending authentication URL exposed by the daemon.
174+
var newAuthUrl = data.AuthURL || "";
175+
root.authUrl = newAuthUrl;
176+
// If a login attempt is in progress and the daemon has surfaced a
177+
// URL that differs from the one cached at click-time, it means the
178+
// URL was regenerated (fresh) — open it immediately.
179+
if (root._loginInProgress
180+
&& !root._loginUrlOpened
181+
&& newAuthUrl.length > 0
182+
&& newAuthUrl !== root._preLoginAuthUrl) {
183+
root._openAuthUrl(newAuthUrl);
184+
}
185+
} else if (root.tailscaleRunning && data.Self && data.Self.TailscaleIPs && data.Self.TailscaleIPs.length > 0) {
165186
root.tailscaleIp = filterIPv4(data.Self.TailscaleIPs)[0] || data.Self.TailscaleIPs[0];
166187
root.tailscaleStatus = "Connected";
188+
root.authUrl = "";
189+
// Login flow settled — reset flags so the next attempt starts clean
190+
root._loginInProgress = false;
191+
root._loginUrlOpened = false;
192+
root._preLoginAuthUrl = "";
193+
loginTimeoutTimer.stop();
167194

168195
var peers = [];
169196
if (data.Peer) {
@@ -201,19 +228,24 @@ Item {
201228
root.peerCount = 0;
202229
root._realPeerList = [];
203230
root.exitNodeStatus = null;
231+
root.authUrl = "";
204232
}
205233
} catch (e) {
206234
Logger.e("Tailscale", "Failed to parse status: " + e);
207235
root.tailscaleRunning = false;
236+
root.needsLogin = false;
208237
root.tailscaleStatus = "Error";
209238
root._realPeerList = [];
239+
root.authUrl = "";
210240
}
211241
} else {
212242
root.tailscaleRunning = false;
243+
root.needsLogin = false;
213244
root.tailscaleStatus = "Disconnected";
214245
root.tailscaleIp = "";
215246
root.peerCount = 0;
216247
root._realPeerList = [];
248+
root.authUrl = "";
217249
}
218250
}
219251
}
@@ -243,6 +275,95 @@ Item {
243275

244276
property string lastExitNodeAction: ""
245277

278+
// ─── Login flow state ────────────────────────────────────────────────────
279+
// A login attempt is tracked across two asynchronous sources that may deliver
280+
// a fresh AuthURL: (1) stdout/stderr of `tailscale up` and (2) the periodic
281+
// `tailscale status --json` poll. Whichever arrives first wins; the other is
282+
// deduped via `_loginUrlOpened`.
283+
property bool _loginUrlOpened: false
284+
property bool _loginInProgress: false
285+
// Snapshot of authUrl taken at click-time. Used to detect when the daemon
286+
// has regenerated the URL so we never open a stale/expired one.
287+
property string _preLoginAuthUrl: ""
288+
289+
/**
290+
* Open an authentication URL in the default browser, with anti-double-open
291+
* protection. Resets login-flow state.
292+
*
293+
* @param {string} url Authentication URL to open
294+
*/
295+
function _openAuthUrl(url) {
296+
if (root._loginUrlOpened) return;
297+
if (!url || url.length === 0) return;
298+
root._loginUrlOpened = true;
299+
root._loginInProgress = false;
300+
loginTimeoutTimer.stop();
301+
Logger.d("Tailscale", "Opening auth URL: " + url);
302+
Qt.openUrlExternally(url);
303+
ToastService.showNotice(
304+
pluginApi?.tr("toast.title"),
305+
pluginApi?.tr("toast.login-browser-opened"),
306+
"external-link"
307+
);
308+
}
309+
310+
// Safety net: if no fresh AuthURL surfaces within 10s after a login click,
311+
// either open whatever is cached (best effort) or show an error toast.
312+
Timer {
313+
id: loginTimeoutTimer
314+
interval: 10000
315+
repeat: false
316+
onTriggered: {
317+
if (!root._loginInProgress || root._loginUrlOpened) return;
318+
root._loginInProgress = false;
319+
Logger.w("Tailscale", "Login timeout: no fresh AuthURL received within 10s");
320+
if (root.authUrl && root.authUrl.length > 0) {
321+
// Last resort: open the cached URL (may be the stale one)
322+
Logger.w("Tailscale", "Falling back to cached (possibly stale) AuthURL");
323+
root._openAuthUrl(root.authUrl);
324+
} else {
325+
ToastService.showError(
326+
pluginApi?.tr("toast.title"),
327+
pluginApi?.tr("toast.login-failed"),
328+
"alert-circle"
329+
);
330+
}
331+
}
332+
}
333+
334+
Process {
335+
id: loginProcess
336+
337+
function _handleLine(data) {
338+
if (root._loginUrlOpened) return;
339+
var line = data.trim();
340+
Logger.d("Tailscale", "Login output: " + line);
341+
var urlMatch = line.match(/https?:\/\/\S+/);
342+
if (urlMatch) {
343+
root._openAuthUrl(urlMatch[0]);
344+
}
345+
}
346+
347+
stdout: SplitParser {
348+
onRead: data => loginProcess._handleLine(data)
349+
}
350+
351+
stderr: SplitParser {
352+
onRead: data => loginProcess._handleLine(data)
353+
}
354+
355+
onExited: function (exitCode, exitStatus) {
356+
Logger.d("Tailscale", "Login exited (code " + exitCode + "), urlOpened=" + root._loginUrlOpened);
357+
if (exitCode !== 0 && !root._loginUrlOpened) {
358+
Logger.e("Tailscale", "tailscale up failed (exit " + exitCode + "), waiting on status poll for fresh AuthURL");
359+
}
360+
// Do NOT reset _loginUrlOpened here — the status poll may still deliver
361+
// the fresh URL after process exit. The flag is reset by _openAuthUrl
362+
// and by the login-state transitions in statusProcess.
363+
statusDelayTimer.start();
364+
}
365+
}
366+
246367
// ─── Taildrop state ──────────────────────────────────────────────────────
247368

248369
// Possible values: "idle", "receiving", "sending", "error"
@@ -462,6 +583,7 @@ Item {
462583
function updateTailscaleStatus() {
463584
if (!root.tailscaleInstalled) {
464585
root.tailscaleRunning = false;
586+
root.needsLogin = false;
465587
root.tailscaleIp = "";
466588
root.tailscaleStatus = "Not installed";
467589
root.peerCount = 0;
@@ -487,6 +609,48 @@ Item {
487609
toggleProcess.running = true;
488610
}
489611

612+
/**
613+
* Trigger the Tailscale authentication flow.
614+
*
615+
* @description
616+
* Always runs `tailscale up --force-reauth` to force the daemon to generate
617+
* a fresh AuthURL (any cached one may be expired). The fresh URL is then
618+
* opened from whichever channel delivers it first:
619+
* - stdout/stderr of `tailscale up` (parsed by loginProcess)
620+
* - the next `tailscale status --json` poll (via statusProcess)
621+
* Deduped through `_openAuthUrl` / `_loginUrlOpened`.
622+
*
623+
* Guard: no-op when not in NeedsLogin state to avoid disconnecting an
624+
* already-authenticated session.
625+
*/
626+
function loginTailscale() {
627+
if (!root.tailscaleInstalled)
628+
return;
629+
if (!root.needsLogin) {
630+
Logger.w("Tailscale", "Login requested but backend is not in NeedsLogin state — ignoring");
631+
return;
632+
}
633+
634+
// Start a fresh login attempt
635+
root._loginInProgress = true;
636+
root._loginUrlOpened = false;
637+
root._preLoginAuthUrl = root.authUrl;
638+
639+
// --force-reauth forces the daemon to regenerate the AuthURL even if a
640+
// (possibly expired) one is still cached in its session state.
641+
var loginServer = pluginApi?.pluginSettings?.loginServer || "";
642+
var cmd = ["tailscale", "up", "--accept-routes", "--force-reauth"];
643+
if (loginServer.trim() !== "") {
644+
cmd.push("--login-server=" + loginServer.trim());
645+
}
646+
loginProcess.command = cmd;
647+
loginProcess.running = true;
648+
649+
// Arm the safety-net timeout and kick an early status refresh
650+
loginTimeoutTimer.restart();
651+
statusDelayTimer.start();
652+
}
653+
490654
Timer {
491655
id: updateTimer
492656
interval: refreshInterval
@@ -526,14 +690,19 @@ Item {
526690
"running": root.tailscaleRunning,
527691
"ip": root.tailscaleIp,
528692
"status": root.tailscaleStatus,
529-
"peers": root.peerCount
693+
"peers": root.peerCount,
694+
"needsLogin": root.needsLogin
530695
};
531696
}
532697

533698
function refresh() {
534699
updateTailscaleStatus();
535700
}
536701

702+
function login() {
703+
loginTailscale();
704+
}
705+
537706
// Taildrop IPC: qs ipc call plugin:tailscale receive
538707
function receive() {
539708
startTaildropReceive();

tailscale/Panel.qml

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -355,7 +355,7 @@ Item {
355355
NText {
356356
text: mainInstance?.tailscaleRunning
357357
? (mainInstance?.peerList?.length || 0) + " " + (pluginApi?.tr("panel.peers"))
358-
: pluginApi?.tr("panel.not-connected")
358+
: (mainInstance?.needsLogin ? pluginApi?.tr("panel.not-authenticated") : pluginApi?.tr("panel.not-connected"))
359359
pointSize: Style.fontSizeS
360360
color: Color.mOnSurfaceVariant
361361
}
@@ -642,7 +642,23 @@ Item {
642642

643643
NButton {
644644
Layout.fillWidth: true
645-
text: mainInstance?.tailscaleRunning
645+
visible: mainInstance?.needsLogin ?? false
646+
text: pluginApi?.tr("context.login")
647+
icon: "login"
648+
backgroundColor: Color.mPrimary
649+
textColor: Color.mOnPrimary
650+
enabled: mainInstance?.tailscaleInstalled ?? false
651+
onClicked: {
652+
if (mainInstance) {
653+
mainInstance.loginTailscale()
654+
}
655+
}
656+
}
657+
658+
NButton {
659+
Layout.fillWidth: true
660+
visible: !(mainInstance?.needsLogin ?? false)
661+
text: mainInstance?.tailscaleRunning
646662
? pluginApi?.tr("context.disconnect")
647663
: pluginApi?.tr("context.connect")
648664
icon: mainInstance?.tailscaleRunning ? "plug-x" : "plug"

tailscale/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ Right-click any online peer in the panel and choose **Send File**. A file picker
7272
| `taildropEnabled` | `true` | Enable Taildrop send/receive. When false, hides the Receive button and Send File option. |
7373
| `taildropDownloadDir` | `"~/Downloads"` | Directory where received files are saved |
7474
| `taildropReceiveMode` | `"operator"` | Taildrop receive mode: `operator` or `pkexec` |
75+
| `loginServer` | `""` | Custom login server URL (e.g. Headscale). Leave empty for default Tailscale. |
7576

7677
## IPC Commands
7778

@@ -90,6 +91,7 @@ qs -c noctalia-shell ipc call plugin:tailscale <command>
9091
| `togglePanel` | Toggle Tailscale panel | `qs -c noctalia-shell ipc call plugin:tailscale togglePanel` |
9192
| `status` | Get current Tailscale status | `qs -c noctalia-shell ipc call plugin:tailscale status` |
9293
| `refresh` | Force refresh Tailscale status | `qs -c noctalia-shell ipc call plugin:tailscale refresh` |
94+
| `login` | Trigger Tailscale login (opens browser) | `qs -c noctalia-shell ipc call plugin:tailscale login` |
9395
| `receive` | Fetch any pending Taildrop files | `qs -c noctalia-shell ipc call plugin:tailscale receive` |
9496

9597
## Usage

tailscale/Settings.qml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,11 @@ ColumnLayout {
7575
pluginApi?.manifest?.metadata?.defaultSettings?.taildropReceiveMode ||
7676
"operator"
7777

78+
property string editLoginServer:
79+
pluginApi?.pluginSettings?.loginServer ||
80+
pluginApi?.manifest?.metadata?.defaultSettings?.loginServer ||
81+
""
82+
7883
spacing: Style.marginM
7984

8085
// Title section
@@ -185,6 +190,26 @@ ColumnLayout {
185190
onToggled: checked => root.editHideMullvadExitNodes = checked
186191
}
187192

193+
// Authentication section
194+
NDivider {
195+
Layout.fillWidth: true
196+
Layout.topMargin: Style.marginM
197+
Layout.bottomMargin: Style.marginM
198+
}
199+
200+
NLabel {
201+
label: pluginApi?.tr("settings.authentication")
202+
}
203+
204+
NTextInput {
205+
Layout.fillWidth: true
206+
label: pluginApi?.tr("settings.login-server")
207+
description: pluginApi?.tr("settings.login-server-desc")
208+
placeholderText: "https://login.tailscale.com"
209+
text: root.editLoginServer
210+
onTextChanged: root.editLoginServer = text
211+
}
212+
188213
// Terminal section
189214
NDivider {
190215
Layout.fillWidth: true
@@ -316,6 +341,7 @@ ColumnLayout {
316341
pluginApi.pluginSettings.taildropEnabled = root.editTaildropEnabled
317342
pluginApi.pluginSettings.taildropDownloadDir = root.editTaildropDownloadDir
318343
pluginApi.pluginSettings.taildropReceiveMode = root.editTaildropReceiveMode
344+
pluginApi.pluginSettings.loginServer = root.editLoginServer
319345

320346
pluginApi.saveSettings()
321347

0 commit comments

Comments
 (0)