Skip to content

feat(tailscale): add Tailscale control center widget#1875

Open
Gdetrane wants to merge 1 commit intoAvengeMedia:masterfrom
Gdetrane:feat/tailscale-widget
Open

feat(tailscale): add Tailscale control center widget#1875
Gdetrane wants to merge 1 commit intoAvengeMedia:masterfrom
Gdetrane:feat/tailscale-widget

Conversation

@Gdetrane
Copy link
Contributor

@Gdetrane Gdetrane commented Mar 1, 2026

Summary

Native Tailscale integration as a builtin control center plugin — search, filter,
and copy IPs or FQDNs of any node in the tailnet without opening the web admin
panel or using the CLI.

Motivation

Tailscale is a rapidly growing mesh VPN with a vibrant community, now shipping by
default in distros like Bazzite and other Fedora variants. Existing desktop
integrations (e.g., GNOME extensions) are limited and unreliable. This widget
provides quick, native access to tailnet device information directly from the DMS
control center.

Changes

Go backend (core/internal/server/tailscale/)

  • Official Tailscale client — uses tailscale.com/client/local (v1.96.1) for
    automatic socket discovery and typed API responses, with 3s context-based poll
    timeout and 30s poll interval
  • Status conversion — converts ipnstate.Status into lean IPC types with
    peer sorting (online first, alphabetical), DNS-derived hostnames (unique per
    node), user ID resolution, and relative time formatting
  • IPC methods — tailscale.subscribe (streaming state updates),
    tailscale.getStatus (one-shot), tailscale.refresh (manual poll)
  • Daemon lifecycle handling — eager init with 30s background retry when
    tailscaled is not running at startup; polls keep running through daemon
    stop/restart cycles for automatic recovery
  • 17 unit tests covering status conversion, peer parsing, sorting, manager
    lifecycle, poll errors, handler routing, and state comparison

QML frontend

  • TailscaleService singleton with ref counting and subscription management
    following the CupsService pattern
  • TailscaleWidget PluginComponent with control center tile (device_hub icon,
    online peer count) and expandable detail view
  • Filter chips — My Online / Online / All with live device counts; filters
    compose with search (tagged devices correctly included in My Online)
  • Searchable device list — match on hostname, DNS name, IP, and OS; search
    applies within the active filter
  • Copy-to-clipboard buttons for Tailscale IPs and DNS names
  • Self device highlighted with accent tint and This device badge
  • Expandable cards showing DNS name, tags, owner, relay/direct info
  • All strings use I18n.tr() with translator context per CONTRIBUTING.md

Integration

  • Widget registered in WidgetModel.qml with DragDropGrid and DetailHost wiring
  • DMSService.qml signal added for tailscale state updates
  • DetailHost.qml height set to 350px (matching Bluetooth/VPN)

Dependencies

  • Add tailscale.com v1.96.1 (official local API client, ~22 transitive deps, pure Go)
  • Bump Go to 1.26.1 (required by tailscale.com)
  • Bump golangci-lint hook v2.9.0 → v2.11.3

Daemon lifecycle handling

  • Tailscale running at startup: widget shows immediately
  • Tailscale started after DMS: widget appears within 30s (retry loop)
  • Tailscale stopped while running: widget shows Disconnected within 30s
  • Tailscale restarted: widget recovers automatically within 30s
  • Tailscale not installed: widget shows Not available, no errors

Test plan

  • Unit tests pass (make test)
  • Widget appears when Tailscale is running
  • Widget shows Not available when Tailscale is not installed
  • Filter chips filter correctly (My Online includes tagged devices)
  • Search composes with active filter
  • Copy buttons copy correct values to clipboard
  • Self device shows with accent highlight and This device badge
  • Expanding a card reveals DNS name, tags, owner
  • Daemon stop: widget shows Disconnected
  • Daemon restart: widget recovers automatically
  • Nodes with duplicate OS hostnames show unique DNS-derived names
  • DMS started without Tailscale, Tailscale started later: widget appears

Tested on:

  • CachyOS (Arch), niri compositor, 57-node tailnet

@Purian23
Copy link
Collaborator

Purian23 commented Mar 1, 2026

Hi there, thanks for the PR. I think this is better suited as an optional plug-in rather a native DMS feature as it's best fit for a select number of users. I will refer to @bbedward for a final accessment.

@Purian23 Purian23 requested a review from bbedward March 1, 2026 18:56
@Gdetrane
Copy link
Contributor Author

Gdetrane commented Mar 1, 2026

image

Let me know if you want to see more screenshots

@Gdetrane
Copy link
Contributor Author

Gdetrane commented Mar 1, 2026

@Purian23 Hi, thanks for jumping in, that's fair. I thought this could be a nice built-in feature, given Tailscale's growth and it being included in some distros, but I totally see your point.

I'm still new to this repo, but I imagine this would require keeping the core Go logic and moving the QML logic to a plugin package, right?

Any feedback is welcome

@mecattaf
Copy link

mecattaf commented Mar 4, 2026

I would 100% use that!

@Gdetrane
Copy link
Contributor Author

Gdetrane commented Mar 4, 2026

Thanks @mecattaf I really appreciate that 😄
I moved everything to Tailscale, it's brilliant and genuinely love it. It's exploding in popularity, I work at Red Hat and I know that in the Fedora world it's starting to get a lot of traction, see https://universal-blue.org/
If I'm not mistaken it's pre-installed in all their variants. One day I'd be very happy to see an official DMS flavor of Fedora btw 😎

@mecattaf
Copy link

mecattaf commented Mar 4, 2026

Thanks @mecattaf I really appreciate that 😄
I moved everything to Tailscale, it's brilliant and genuinely love it. It's exploding in popularity, I work at Red Hat and I know that in the Fedora world it's starting to get a lot of traction, see https://universal-blue.org/

Been a ublue user for 5+ years! Specifically have my own bluebuild recipe

If I'm not mistaken it's pre-installed in all their variants. One day I'd be very happy to see an official DMS flavor of Fedora btw 😎

Actually DMS ships with the miracle window manager official distro but that's super niche. Otherwise check out Zirconium (dms + niri)

@Purian23
Copy link
Collaborator

Purian23 commented Mar 7, 2026

Thanks again for the forward thinking here! The main dev @bbedward is out this week but we'll review whether a plugin would work or by using it natively in a few days!

@Purian23
Copy link
Collaborator

Purian23 commented Mar 7, 2026

Just another quick note on the plugin option route I mentioned. One already exists for you to see how it works.
https://danklinux.com/plugins
Tailscale Manager
https://github.com/cglavin50/dms-tailscale

@Gdetrane
Copy link
Contributor Author

Gdetrane commented Mar 9, 2026

@Purian23 No worries at all, we all need time off 👍

Just a little note on the plugin route, I think this might be a bit too complex for a plugin if I understand this correctly, given there's some Go code to handle everything via Tailscale's Unix sockets API, which imho is cleaner than relying on the cli as a middleman.

I'm not sure obviously, I don't exactly have a ton of experience with implementing tools using Unix sockets from my day to day work, but my intuition was in general to use the APIs that are natively provided to integrate with whatever the underlying system is

Copy link
Collaborator

@bbedward bbedward left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks mostly good, and I think it's fine as a built-in, though its worth noting a plugin could interface with it directly since it is a unix socket API, which can be done in QML/JS

@Gdetrane
Copy link
Contributor Author

Hi @bbedward thank you for the review, I will happily address all the requested changes asap

@bbedward
Copy link
Collaborator

@Gdetrane Also consider using the tailscale GO client, it would probably simplify things quite a bit and help compatibility/maintenance long-term. https://github.com/tailscale/tailscale/blob/main/client/local/local.go

@Gdetrane
Copy link
Contributor Author

@bbedward I noticed the Tailscale go client requires a very recent go version, 1.26+, considering there might be CI involved with 1.25, nix packages I haven't verified availability for, I think I will simply assess your feedback in code without changing the core implementation, but in the future maybe the tailscale go client will be the way to go (pun intended).
I'll resume work on this ASAP, a bit busy at work this week, but the changes I believe will be minimal, based on your feedback

@bbedward
Copy link
Collaborator

Bumping GO version is no big deal, CI should be unaffected since it pulls version from go.mod

@Gdetrane
Copy link
Contributor Author

@bbedward Cool, I will dive deeper into the library then, it doesn't seem to have a ton of dependencies either, so win win

@Gdetrane Gdetrane force-pushed the feat/tailscale-widget branch 2 times, most recently from 1099a17 to b2da0ac Compare March 13, 2026 16:19
@Gdetrane
Copy link
Contributor Author

Gdetrane commented Mar 13, 2026

@bbedward moved to the Tailscale go client and refactored based on it, I made some extra modifications fixing a couple of lingering issues I've noticed in manual testing done on my desktop. I believe your points should be addressed now, but ptal :)
Rebased on upstream and updated the description to reflect the new changes while at it.
One thing I should've asked, I'm used to squashing into a single commit and amending, that's a convention from my team at work, but let me know if you would prefer seeing multiple commits in the future

Full-stack Tailscale integration for DMS control center:

Backend (Go):
- Tailscale manager using official tailscale.com/client/local library
- Typed conversion from ipnstate.Status to QML-friendly IPC types
- 3s context-based poll timeout, 30s poll interval
- Manager cleanup in cleanupManagers()
- Comprehensive test coverage (16 tests)

Frontend (QML):
- TailscaleService with WebSocket subscription
- TailscaleWidget with peer list, filter chips, search
- Copy-to-clipboard for IPs and DNS names
- Daemon lifecycle handling (offline/stopped states)

Dependencies:
- Add tailscale.com v1.96.1 (official local API client)
- Bump Go to 1.26.1 (required by tailscale.com)
- Bump golangci-lint hook v2.9.0 -> v2.11.3
@Gdetrane Gdetrane force-pushed the feat/tailscale-widget branch from b2da0ac to 4fd16c4 Compare March 13, 2026 22:24
@Gdetrane
Copy link
Contributor Author

Noticed new conflicting changes merged upstream, rebased again

// Eagerly try to initialize Tailscale manager; if it fails (socket not
// found), start a background retry loop so the widget recovers when
// tailscaled starts later.
if err := InitializeTailscaleManager(); err != nil {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like it will never be triggered, because tailscale.NewManager() never returns an error.

current := m.state
m.stateMutex.RUnlock()

if !reflect.DeepEqual(m.lastState, current) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lastState RW should also be protected by mutex, or we could probably be more cautious with a notifier in a single goroutine pattern that might be more thread-safe, like some other managers do.

return err
}

tailscaleManager = manager
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tailScaleManager probably needs mutex protection, seems like it can be written from multiple goroutines.

func HandleRequest(conn net.Conn, req models.Request, manager *Manager) {
switch req.Method {
case "tailscale.subscribe":
handleSubscribe(conn, req, manager)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can remove this case, since the pattern is to subscribe through the main server.go with what we want. Other managers may do it (but its not a pattern thats used)

)

const (
pollInterval = 30 * time.Second
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would really prefer to not do polling here, I think we can use watch-ipn-bus : https://github.com/tailscale/tailscale/blob/54606a0a89bc5e90b5295179e5b776f6a2df8cfa/ipn/localapi/localapi.go#L119 , that would be much nicer

)

const (
pollInterval = 30 * time.Second
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As a bonus without polling maybe we can avoid all of the reflect.DeepEqual usage. Which has a few risks:

  1. Very slow and inefficient
  2. May evaluate as not equal on fields you dont care about

var capabilitySubscribers syncmap.Map[string, chan ServerInfo]
var cupsSubscribers syncmap.Map[string, bool]
var cupsSubscriberCount atomic.Int32
var tailscaleSubscribers syncmap.Map[string, bool]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We increment these but I can't tell that they actually matter or are used for anything. Ideally we would stop the retry loop when its 0 at least

property string magicDnsSuffix: ""
property string tailnetName: ""
property var selfNode: null
property var peers: []
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The way this is computed probably triggered an inefficient binding chain, but it's not a major deal for now.

@Gdetrane
Copy link
Contributor Author

@bbedward taking some time to address all the issues, I'm a little swamped at work right now

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants