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.
ExitNodeStatusfromtailscale 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
- Select another machine as the exit node from the widget.
- Take that machine offline: shut it down, or stop its
tailscaled. - 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
ExitNodeStatusand exposesexitNodeOffline/exitNodeNamefromModel.parseStatus(), and marks the matching peer asExitNode; - 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
warningbadge (needsLogin || exitNodeOffline), and adds a red, clickable panel notice that runstailscale set --exit-node=(a newclearExitNode()inService.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.activeExitNodeActivity
0No activity yet.