#16119

Tailscale widget hides offline exit node — no warning when all traffic is blocked

Opengtf-inetopened 1d ago

System details

Intel i7-1165G7, Intel Iris Xe, Omarchy 4.0.5, tailscale 1.104.1

What's wrong?

When the selected Tailscale exit node goes offline, the Tailscale bar widget gives no indication of it. The panel even claims the tailnet is empty, while all internet traffic on the machine is silently dropped.

Tailscale fails closed by design: with an exit node selected (RouteAll, plus the tailnet DNS pointing at that peer), the client loses all internet access when the exit node is unreachable. The fix on the user's side is a single command (tailscale set --exit-node=), but the widget hides every clue that points to it. In my case it looked exactly like a broken network or kernel after a system update, and it took a long while to trace back to Tailscale.

Root cause: parseStatus() in shell/plugins/panels/tailscale/Model.js only keeps online peers:

if (normalized.Online) {
  peers.push(normalized)
  if (normalized.ExitNodeOption) exitNodes.push(normalized)
}

As a result:

  • The active exit node disappears from the "Exit nodes" list as soon as it goes offline, so the user can't see it or click it to disconnect.
  • Offline machines are hidden entirely, so the panel shows "No machines found on this tailnet." even when the tailnet has machines.
  • ExitNodeStatus from tailscale status --json, which carries { ID, Online } for the selected exit node, is never read, so nothing in the bar or panel warns that traffic is blocked.

This code is unchanged on quattro.

Steps to reproduce

  1. Select another machine as the exit node from the widget.
  2. Take that machine offline: shut it down, or stop its tailscaled.
  3. Internet stops working on this machine. The bar icon looks normal, the exit node vanishes from the panel, and if no other peer is online the panel says "No machines found on this tailnet."

Expected

  • A warning badge on the bar icon while the selected exit node is offline.
  • A visible warning in the panel, with a one-click way to stop using the exit node.
  • Offline machines, and the offline active exit node, still listed, just marked as offline.

Proposed fix (tested locally against 4.0.5)

I applied the patch below to a cloned plugin (omarchy plugin clone omarchy.tailscale) and tested it against a real offline exit node. It:

  • reads ExitNodeStatus and exposes exitNodeOffline / exitNodeName from Model.parseStatus(), and marks the matching peer as ExitNode;
  • keeps offline peers, sorted online first and rendered dimmed with "· offline";
  • keeps the active exit node in the exit-node list while it is offline, shown in red, so clicking it disconnects;
  • reuses the icon's existing warning badge (needsLogin || exitNodeOffline), and adds a red, clickable panel notice that runs tailscale set --exit-node= (a new clearExitNode() in Service.qml).

The strings are hardcoded English and the patch is against 4.0.5, so it will need rebasing onto the i18n'd quattro code (tr). I'm happy to open a PR if that's preferred.

Screenshot of the patched panel with an offline exit node:

Related: #10508. Because the widget shows the OS hostname, the offline exit node appears as omarchy while the Tailscale machine is named aiomarchy, which makes the warning harder to recognize.

Patch (against Omarchy 4.0.5)
--- a/shell/plugins/panels/tailscale/Model.js
+++ b/shell/plugins/panels/tailscale/Model.js
@@ -232,18 +232,29 @@
     var peers = []
     var exitNodes = []
     var rawPeers = data.Peer || {}
+    // With an exit node selected, Tailscale fails closed: if that node goes
+    // offline all traffic is dropped, so surface it instead of hiding it.
+    var exitStatus = data.ExitNodeStatus || null
+    var exitNodeId = exitStatus ? String(exitStatus.ID || "") : ""
+    var exitNodeOffline = exitStatus !== null && exitStatus.Online !== true
+    var exitNodeName = ""
 
     for (var id in rawPeers) {
       var peer = rawPeers[id] || {}
       var normalized = peerFromStatus(id, peer)
       if (normalized.Mullvad) continue
-      if (normalized.Online) {
-        peers.push(normalized)
-        if (normalized.ExitNodeOption) exitNodes.push(normalized)
+      if (exitNodeId !== "" && String(peer.ID || "") === exitNodeId) {
+        normalized.ExitNode = true
+        exitNodeName = normalized.DisplayName
       }
+      // Offline machines stay listed (dimmed); only the active exit node is
+      // kept among the offline exit node options, so it can be disconnected.
+      peers.push(normalized)
+      if (normalized.ExitNodeOption && (normalized.Online || normalized.ExitNode)) exitNodes.push(normalized)
     }
 
     peers.sort(function(a, b) {
+      if (a.Online !== b.Online) return a.Online ? -1 : 1
       return String(a.HostName).localeCompare(String(b.HostName))
     })
     exitNodes.sort(function(a, b) {
@@ -263,7 +274,9 @@
       selfUserId: String(self.UserID || ""),
       fileSharing: hasFileSharing(self),
       peers: peers,
-      exitNodes: exitNodes
+      exitNodes: exitNodes,
+      exitNodeOffline: exitNodeOffline,
+      exitNodeName: exitNodeName || (exitStatus && exitStatus.TailscaleIPs && exitStatus.TailscaleIPs.length > 0 ? String(exitStatus.TailscaleIPs[0]).split("/")[0] : "")
     }
   } catch (e) {
     return { ok: false, unavailable: true, message: "Status error", error: "Failed to parse tailscale status" }
--- a/shell/plugins/panels/tailscale/Service.qml
+++ b/shell/plugins/panels/tailscale/Service.qml
@@ -37,6 +37,8 @@
   property string selectedAccountLabel: ""
   property string switchingAccountId: ""
   property string settingExitNodeId: ""
+  property bool exitNodeOffline: false
+  property string exitNodeName: ""
   property bool accountsAccessDenied: false
   property string actionStatus: ""
   property string lastError: ""
@@ -219,6 +221,8 @@
     selectedAccountLabel = ""
     switchingAccountId = ""
     settingExitNodeId = ""
+    exitNodeOffline = false
+    exitNodeName = ""
     accountsAccessDenied = false
   }
 
@@ -250,6 +254,8 @@
     peers = parsed.running ? parsed.peers : []
     tailnetExitNodes = parsed.running ? parsed.exitNodes : []
     exitNodes = parsed.running ? tailnetExitNodes.concat(mullvadRegions) : []
+    exitNodeOffline = parsed.running && parsed.exitNodeOffline
+    exitNodeName = parsed.exitNodeName
 
     if (needsLogin) statusText = "Needs login"
     else if (running) {
@@ -333,6 +339,14 @@
     return peerAddress(peer)
   }
 
+  function clearExitNode() {
+    if (!installed || !running || exitNodeProcess.running) return
+    _exitNodeOutput = ""
+    _exitNodeError = ""
+    exitNodeProcess.command = ["tailscale", "set", "--exit-node="]
+    exitNodeProcess.running = true
+  }
+
   function setExitNode(peer) {
     if (!installed || !running || !peer || exitNodeProcess.running) return
     var active = peer.ExitNode === true
--- a/shell/plugins/panels/tailscale/Panel.qml
+++ b/shell/plugins/panels/tailscale/Panel.qml
@@ -390,7 +390,7 @@
           color: root.barIconColor
           badgeColor: root.urgent
           crossed: !tailscale.active && !tailscale.needsLogin
-          warning: tailscale.needsLogin
+          warning: tailscale.needsLogin || tailscale.exitNodeOffline
         }
       }
     }
@@ -470,7 +470,7 @@
                   color: root.iconColor
                   badgeColor: root.urgent
                   crossed: !tailscale.active && !tailscale.needsLogin
-                  warning: tailscale.needsLogin
+                  warning: tailscale.needsLogin || tailscale.exitNodeOffline
                 }
               }
 
@@ -498,6 +498,36 @@
             }
           }
 
+          CursorSurface {
+            visible: tailscale.exitNodeOffline
+            width: parent.width
+            implicitHeight: exitOfflineText.implicitHeight + Style.spacing.rowPaddingX
+            foreground: root.foreground
+            fill: root.hoverFill
+
+            Text {
+              id: exitOfflineText
+              textFormat: Text.PlainText
+              anchors.left: parent.left
+              anchors.right: parent.right
+              anchors.verticalCenter: parent.verticalCenter
+              anchors.leftMargin: Style.space(8)
+              anchors.rightMargin: Style.space(8)
+              text: "󰀦  Exit node " + (tailscale.exitNodeName || "") + " is offline — internet traffic is blocked. Click to stop using it."
+              color: root.urgent
+              font.family: root.fontFamily
+              font.pixelSize: Style.font.bodySmall
+              wrapMode: Text.WordWrap
+            }
+
+            MouseArea {
+              anchors.fill: parent
+              hoverEnabled: true
+              cursorShape: Qt.PointingHandCursor
+              onClicked: tailscale.clearExitNode()
+            }
+          }
+
           Text {
             textFormat: Text.PlainText
             visible: tailscale.actionStatus !== "" || tailscale.lastError !== ""
@@ -952,8 +982,8 @@
         Text {
           textFormat: Text.PlainText
           Layout.fillWidth: true
-          text: peerRow.peerName
-          color: root.foreground
+          text: peerRow.peerName + (peerRow.peer && peerRow.peer.Online === false ? "  · offline" : "")
+          color: peerRow.peer && peerRow.peer.Online === false ? root.dim : root.foreground
           font.family: root.fontFamily
           font.pixelSize: Style.font.body
           elide: Text.ElideRight
@@ -1163,8 +1193,8 @@
 
       Text {
         textFormat: Text.PlainText
-        text: exitNodeRow.peerName
-        color: root.foreground
+        text: exitNodeRow.peerName + (peer && peer.Online === false && !exitNodeRow.addMullvad ? "  · offline" : "")
+        color: peer && peer.Online === false && !exitNodeRow.addMullvad ? root.urgent : root.foreground
         font.family: root.fontFamily
         font.pixelSize: Style.font.body
         font.bold: exitNodeRow.activeExitNode

Activity

0

No activity yet.