1 Commits
Author SHA1 Message Date
holdmysocks 558994fc76 Group, search and filter the device list
A tailnet with a VPN add-on has hundreds of exit servers among its peers,
which buried the real devices on the status page.

- Devices are grouped: this tailnet, shared with you, VPN exit servers.
- VPN exit servers are counted but only listed, and only sent to the page,
  when asked for.
- Search by name, address, OS, tag or place, and an online-only toggle.
- Devices that offer exit-node service are marked.
- The count and the streaming host suggestions leave exit servers out.
2026-10-04 10:22:53 -04:00
5 changed files with 328 additions and 48 deletions

No files matched your search

+7
View File
@@ -125,6 +125,13 @@ login link and your devices, and has the game streaming hosts, the settings,
and buttons for logging out, stopping and uninstalling. It also says when a
newer release is available.
**Devices.** The list is grouped into your tailnet's devices and devices
shared with you, and marks the ones that can be used as an exit node. It can
be searched (name, address, OS, tag, place) and limited to devices that are
online. If your tailnet has a VPN add-on such as Mullvad, its exit servers
are counted but kept out of the list until you tick **Show VPN exit
servers**.
**Password.** Out of the box the page has no password, like the console's
other homebrew services: anyone on your LAN, or on your tailnet if your ACLs
allow it, can use it. Set one under **Settings**. It is then asked for on
+120
View File
@@ -0,0 +1,120 @@
package main
import (
"sort"
"strings"
"tailscale.com/ipn/ipnstate"
)
// The device list of the status page. A tailnet with a VPN add-on has
// hundreds of exit servers among its peers, so peers are sorted into kinds
// and the exit servers are only sent to the page when it asks for them.
const (
peerOwn = "own" // a device of this tailnet
peerShared = "shared" // a device of another tailnet, shared with this one
peerVPN = "vpn" // an exit server of a VPN add-on
)
// vpnDomains are the DNS suffixes of the exit servers that VPN add-ons put
// in a tailnet. Tailscale's own "status" command hides them the same way.
var vpnDomains = []string{"mullvad.ts.net"}
type peerInfo struct {
Name string `json:"name"`
IP string `json:"ip"`
OS string `json:"os"`
Online bool `json:"online"`
Kind string `json:"kind"`
// ExitNode is "offered" for a device that can be used as an exit node
// and "used" for the one this console uses.
ExitNode string `json:"exitNode,omitempty"`
// Location is where an exit server says it is ("Vienna, Austria").
Location string `json:"location,omitempty"`
Tags []string `json:"tags,omitempty"`
}
// peerCount counts the peers of one kind.
type peerCount struct {
Total int `json:"total"`
Online int `json:"online"`
}
func hasDNSSuffix(name, suffix string) bool {
name = strings.ToLower(strings.TrimSuffix(name, "."))
suffix = strings.ToLower(strings.Trim(suffix, "."))
return suffix != "" && (name == suffix || strings.HasSuffix(name, "."+suffix))
}
// peerKind sorts a peer into one of the kinds. suffix is this tailnet's
// MagicDNS suffix.
func peerKind(p *ipnstate.PeerStatus, suffix string) string {
if p.ExitNodeOption || p.ExitNode {
for _, d := range vpnDomains {
if hasDNSSuffix(p.DNSName, d) {
return peerVPN
}
}
}
if p.DNSName != "" && suffix != "" && !hasDNSSuffix(p.DNSName, suffix) {
return peerShared
}
return peerOwn
}
func newPeerInfo(p *ipnstate.PeerStatus, suffix string) peerInfo {
pi := peerInfo{Name: p.HostName, OS: p.OS, Online: p.Online, Kind: peerKind(p, suffix)}
if p.DNSName != "" {
pi.Name = strings.SplitN(p.DNSName, ".", 2)[0]
}
if len(p.TailscaleIPs) > 0 {
pi.IP = p.TailscaleIPs[0].String()
}
switch {
case p.ExitNode:
pi.ExitNode = "used"
case p.ExitNodeOption:
pi.ExitNode = "offered"
}
if l := p.Location; l != nil {
parts := []string{}
for _, s := range []string{l.City, l.Country} {
if s != "" {
parts = append(parts, s)
}
}
pi.Location = strings.Join(parts, ", ")
}
if p.Tags != nil {
pi.Tags = p.Tags.AsSlice()
}
return pi
}
// peersFromStatus lists the peers for the status page, online ones first.
// VPN exit servers are counted, and listed only if withVPN is set; the one
// in use is always listed.
func peersFromStatus(st *ipnstate.Status, withVPN bool) (peers []peerInfo, vpn peerCount) {
peers = []peerInfo{}
for _, p := range st.Peer {
pi := newPeerInfo(p, st.MagicDNSSuffix)
if pi.Kind == peerVPN {
vpn.Total++
if pi.Online {
vpn.Online++
}
if !withVPN && pi.ExitNode != "used" {
continue
}
}
peers = append(peers, pi)
}
sort.Slice(peers, func(i, j int) bool {
if peers[i].Online != peers[j].Online {
return peers[i].Online
}
return peers[i].Name < peers[j].Name
})
return peers, vpn
}
+105
View File
@@ -0,0 +1,105 @@
package main
import (
"net/netip"
"testing"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
"tailscale.com/types/views"
)
func testStatus() *ipnstate.Status {
tags := views.SliceOf([]string{"tag:server"})
peers := []*ipnstate.PeerStatus{
{HostName: "Desk PC", DNSName: "desk.tail1234.ts.net.", OS: "windows", Online: true,
TailscaleIPs: []netip.Addr{netip.MustParseAddr("100.64.0.2")}},
{HostName: "nas", DNSName: "nas.tail1234.ts.net.", OS: "linux", ExitNodeOption: true, Tags: &tags},
{HostName: "friend", DNSName: "laptop.tail9999.ts.net.", OS: "macOS", Online: true},
{HostName: "at-vie-wg-001", DNSName: "at-vie-wg-001.mullvad.ts.net.", Online: true, ExitNodeOption: true,
Location: &tailcfg.Location{Country: "Austria", City: "Vienna"}},
{HostName: "se-sto-wg-001", DNSName: "se-sto-wg-001.mullvad.ts.net.", ExitNodeOption: true},
}
st := &ipnstate.Status{MagicDNSSuffix: "tail1234.ts.net", Peer: map[key.NodePublic]*ipnstate.PeerStatus{}}
for _, p := range peers {
st.Peer[key.NewNode().Public()] = p
}
return st
}
func TestPeersFromStatus(t *testing.T) {
peers, vpn := peersFromStatus(testStatus(), false)
if vpn != (peerCount{Total: 2, Online: 1}) {
t.Errorf("vpn count = %+v", vpn)
}
// Online first, then by name; no VPN servers.
want := []peerInfo{
{Name: "desk", IP: "100.64.0.2", OS: "windows", Online: true, Kind: peerOwn},
{Name: "laptop", OS: "macOS", Online: true, Kind: peerShared},
{Name: "nas", OS: "linux", Kind: peerOwn, ExitNode: "offered", Tags: []string{"tag:server"}},
}
if len(peers) != len(want) {
t.Fatalf("got %d peers, want %d: %+v", len(peers), len(want), peers)
}
for i := range want {
g, w := peers[i], want[i]
if g.Name != w.Name || g.IP != w.IP || g.OS != w.OS || g.Online != w.Online || g.Kind != w.Kind ||
g.ExitNode != w.ExitNode || len(g.Tags) != len(w.Tags) {
t.Errorf("peer %d = %+v, want %+v", i, g, w)
}
}
peers, _ = peersFromStatus(testStatus(), true)
if len(peers) != 5 {
t.Fatalf("with VPN servers: got %d peers, want 5", len(peers))
}
for _, p := range peers {
if p.Name == "at-vie-wg-001" && (p.Kind != peerVPN || p.Location != "Vienna, Austria" || p.ExitNode != "offered") {
t.Errorf("VPN server = %+v", p)
}
}
}
func TestExitServerInUseIsAlwaysListed(t *testing.T) {
st := testStatus()
for _, p := range st.Peer {
if p.HostName == "se-sto-wg-001" {
p.ExitNode = true
}
}
peers, _ := peersFromStatus(st, false)
found := false
for _, p := range peers {
if p.Name == "se-sto-wg-001" {
found = p.Kind == peerVPN && p.ExitNode == "used"
}
}
if !found {
t.Errorf("the exit server in use is missing: %+v", peers)
}
}
func TestPeerKind(t *testing.T) {
for _, tt := range []struct {
name string
p ipnstate.PeerStatus
want string
}{
{"own", ipnstate.PeerStatus{DNSName: "a.tail1234.ts.net."}, peerOwn},
{"own exit node", ipnstate.PeerStatus{DNSName: "a.tail1234.ts.net.", ExitNodeOption: true}, peerOwn},
{"no DNS name", ipnstate.PeerStatus{HostName: "a"}, peerOwn},
{"shared", ipnstate.PeerStatus{DNSName: "a.other.ts.net."}, peerShared},
{"suffix must match a whole label", ipnstate.PeerStatus{DNSName: "a.xtail1234.ts.net."}, peerShared},
{"vpn", ipnstate.PeerStatus{DNSName: "x.mullvad.ts.net.", ExitNodeOption: true}, peerVPN},
{"vpn domain but no exit node", ipnstate.PeerStatus{DNSName: "x.mullvad.ts.net."}, peerShared},
} {
if got := peerKind(&tt.p, "tail1234.ts.net"); got != tt.want {
t.Errorf("%s: got %s, want %s", tt.name, got, tt.want)
}
}
// Without a suffix (not logged in yet) nothing is taken for shared.
if got := peerKind(&ipnstate.PeerStatus{DNSName: "a.other.ts.net."}, ""); got != peerOwn {
t.Errorf("no suffix: got %s", got)
}
}
+91 -23
View File
@@ -57,6 +57,7 @@
pre { margin: 0; max-height: 320px; overflow: auto; font: 12.5px/1.45 ui-monospace, Consolas, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
details summary { cursor: pointer; color: var(--muted); }
summary .count { font-size: 14px; margin-left: 8px; }
summary .count { white-space: nowrap; }
details[open] > table, details[open] > form { margin-top: 12px; }
.note { color: var(--muted); font-size: 14px; margin: 0 0 12px; }
.hint { color: var(--muted); font-size: 13px; margin: 2px 0 0; }
@@ -67,6 +68,14 @@
background: var(--panel); color: var(--text); width: 100%;
}
textarea { font: 14px ui-monospace, Consolas, monospace; min-height: 70px; resize: vertical; }
.filters { display: flex; flex-wrap: wrap; gap: 8px 16px; align-items: center; margin-top: 12px; }
.filters input[type=search] {
font: inherit; padding: 8px 10px; border-radius: 8px; border: 1px solid var(--line);
background: var(--panel); color: var(--text); flex: 1 1 200px; min-width: 0;
}
.filters label.check { margin: 0; font-size: 14px; }
tr.group td { color: var(--muted); font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; padding-top: 14px; border-top: 0; }
td .tag { display: block; color: var(--muted); font-size: 13px; }
.hostrow { display: flex; gap: 8px; margin-bottom: 8px; align-items: center; }
.hostrow .host { flex: 3; min-width: 0; }
.hostrow .port { flex: 1; min-width: 90px; }
@@ -111,10 +120,16 @@
<section class="panel hidden" id="peerpanel">
<details id="peerbox" open>
<summary><span class="heading">Devices on your tailnet</span><span class="count" id="peercount"></span></summary>
<div class="filters">
<input type="search" id="peersearch" placeholder="Search name, address, OS, tag or place" aria-label="Search devices" autocomplete="off">
<label class="check"><input type="checkbox" id="peeronline"> Online only</label>
<label class="check hidden" id="peervpn-row"><input type="checkbox" id="peervpn"> <span id="peervpn-text"></span></label>
</div>
<table>
<thead><tr><th>Name</th><th>Address</th><th>OS</th><th>Status</th></tr></thead>
<tbody id="peers"></tbody>
</table>
<p class="note hidden" id="peernone" style="margin: 12px 0 0">No device matches.</p>
</details>
</section>
@@ -264,10 +279,67 @@ function hostRow(host, port) {
return div;
}
// The device list. Peers come sorted from the daemon (online first); the page
// groups them by kind and applies the filters. Exit servers of a VPN add-on
// can be hundreds, so they are only requested while their box is ticked.
const kinds = [['own', 'This tailnet'], ['shared', 'Shared with you'], ['vpn', 'VPN exit servers']];
let peers = [];
let shownPeers = '';
function stored(key) { try { return localStorage.getItem(key); } catch (e) { return null; } }
function store(key, value) { try { localStorage.setItem(key, value); } catch (e) {} }
function peerMatches(p, words) {
const text = [p.name, p.ip, p.os, p.location || '', (p.tags || []).join(' '), p.exitNode ? 'exit node' : '',
p.online ? 'online' : 'offline'].join(' ').toLowerCase();
return words.every(w => text.includes(w));
}
function peerRow(p) {
const tr = document.createElement('tr');
const notes = [];
if (p.exitNode === 'used') notes.push('exit node in use');
else if (p.exitNode) notes.push('exit node');
if (p.location) notes.push(p.location);
if (p.tags) notes.push(p.tags.join(', '));
for (const [v, mono, note] of [[p.name, false, notes.join(' · ')], [p.ip, true], [p.os], [p.online ? 'online' : 'offline']]) {
const td = document.createElement('td');
if (!p.online) td.className = 'off';
if (mono) { const c = document.createElement('code'); c.textContent = v; td.append(c); } else td.textContent = v;
if (note) { const n = document.createElement('span'); n.className = 'tag'; n.textContent = note; td.append(n); }
tr.append(td);
}
return tr;
}
function renderPeers() {
const words = $('peersearch').value.toLowerCase().split(/\s+/).filter(w => w);
const onlineOnly = $('peeronline').checked;
const shown = peers.filter(p => (!onlineOnly || p.online) && peerMatches(p, words));
// Rebuilding hundreds of rows every few seconds is wasteful; only do it
// when what is shown changed.
const key = JSON.stringify(shown);
if (key === shownPeers) return;
shownPeers = key;
const groups = kinds.map(([kind, title]) => [title, shown.filter(p => p.kind === kind)]).filter(g => g[1].length);
const rows = [];
for (const [title, list] of groups) {
if (groups.length > 1) {
const tr = document.createElement('tr'); tr.className = 'group';
const td = document.createElement('td'); td.colSpan = 4;
td.textContent = title + ' (' + list.length + ')';
tr.append(td); rows.push(tr);
}
rows.push(...list.map(peerRow));
}
$('peers').replaceChildren(...rows);
$('peernone').classList.toggle('hidden', shown.length > 0);
}
async function refresh() {
let s;
try {
s = await (await api('/api/status')).json();
s = await (await api('/api/status' + ($('peervpn').checked ? '?vpn=1' : ''))).json();
} catch (e) {
if (e.message !== 'locked') {
$('state').textContent = 'Not responding';
@@ -309,24 +381,18 @@ async function refresh() {
if (shownQR !== s.authURL) { shownQR = s.authURL; $('qr').src = '/qr.png?' + Date.now(); }
}
const tbody = $('peers');
tbody.replaceChildren(...s.peers.map(p => {
const tr = document.createElement('tr');
for (const [v, mono] of [[p.name], [p.ip, true], [p.os], [p.online ? 'online' : 'offline']]) {
const td = document.createElement('td');
if (!p.online) td.className = 'off';
if (mono) { const c = document.createElement('code'); c.textContent = v; td.append(c); } else td.textContent = v;
tr.append(td);
}
return tr;
}));
$('peerpanel').classList.toggle('hidden', !s.peers.length);
$('peercount').textContent = s.peers.filter(p => p.online).length + ' online of ' + s.peers.length;
peers = s.peers;
const devices = peers.filter(p => p.kind !== 'vpn');
$('peerpanel').classList.toggle('hidden', !peers.length && !s.vpnServers.total);
$('peercount').textContent = devices.filter(p => p.online).length + ' online of ' + devices.length;
$('peervpn-row').classList.toggle('hidden', !s.vpnServers.total);
$('peervpn-text').textContent = 'Show VPN exit servers (' + s.vpnServers.total + ')';
renderPeers();
// Game streaming. The rows are only rebuilt from the daemon's state while
// the user is not in the middle of editing them.
$('streampanel').classList.toggle('hidden', s.state !== 'Running' && !s.sunshineHosts.length);
$('peernames').replaceChildren(...s.peers.map(p => { const o = document.createElement('option'); o.value = p.name; return o; }));
$('peernames').replaceChildren(...devices.map(p => { const o = document.createElement('option'); o.value = p.name; return o; }));
const hostsNow = JSON.stringify(s.sunshineHosts);
if (!hostsDirty && hostsNow !== lastHosts) {
lastHosts = hostsNow;
@@ -458,14 +524,16 @@ $('settingsform').onsubmit = async ev => {
$('logbox').addEventListener('toggle', refresh);
// The device list can be long. Remember whether it was left collapsed; the
// browser may not allow storage, in which case it simply starts open.
try {
if (localStorage.getItem('peersCollapsed') === '1') $('peerbox').open = false;
} catch (e) {}
$('peerbox').addEventListener('toggle', () => {
try { localStorage.setItem('peersCollapsed', $('peerbox').open ? '0' : '1'); } catch (e) {}
});
// The device list can be long. Remember whether it was left collapsed and
// how it was filtered; the browser may not allow storage, in which case it
// simply starts open and unfiltered.
if (stored('peersCollapsed') === '1') $('peerbox').open = false;
$('peerbox').addEventListener('toggle', () => store('peersCollapsed', $('peerbox').open ? '0' : '1'));
$('peeronline').checked = stored('peersOnlineOnly') === '1';
$('peervpn').checked = stored('peersShowVPN') === '1';
$('peersearch').oninput = renderPeers;
$('peeronline').onchange = () => { store('peersOnlineOnly', $('peeronline').checked ? '1' : '0'); renderPeers(); };
$('peervpn').onchange = () => { store('peersShowVPN', $('peervpn').checked ? '1' : '0'); refresh(); };
refresh();
setInterval(refresh, 3000);
+5 -25
View File
@@ -8,7 +8,6 @@ import (
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"time"
@@ -29,13 +28,6 @@ var faviconPNG []byte
// console out. Who may use the page at all is decided in auth.go.
const apiHeader = "X-PS5-Tailscale"
type peerInfo struct {
Name string `json:"name"`
IP string `json:"ip"`
OS string `json:"os"`
Online bool `json:"online"`
}
// sunshineInfo is a forwarded Sunshine host as the status page shows it.
type sunshineInfo struct {
Host string `json:"host"`
@@ -55,7 +47,10 @@ type statusInfo struct {
Tailnet string `json:"tailnet,omitempty"`
Health []string `json:"health,omitempty"`
Peers []peerInfo `json:"peers"`
Proxy string `json:"proxy,omitempty"`
// VPNServers counts the exit servers of a VPN add-on. They are only in
// Peers when the page asks for them (?vpn=1).
VPNServers peerCount `json:"vpnServers"`
Proxy string `json:"proxy,omitempty"`
// SunshineHosts and Forwards describe the local forwards.
SunshineHosts []sunshineInfo `json:"sunshineHosts"`
Forwards []string `json:"forwards"`
@@ -225,22 +220,7 @@ func (d *daemon) handleStatus(w http.ResponseWriter, r *http.Request) {
info.IPs = append(info.IPs, ip.String())
}
}
for _, p := range st.Peer {
pi := peerInfo{Name: p.HostName, OS: p.OS, Online: p.Online}
if p.DNSName != "" {
pi.Name = strings.SplitN(p.DNSName, ".", 2)[0]
}
if len(p.TailscaleIPs) > 0 {
pi.IP = p.TailscaleIPs[0].String()
}
info.Peers = append(info.Peers, pi)
}
sort.Slice(info.Peers, func(i, j int) bool {
if info.Peers[i].Online != info.Peers[j].Online {
return info.Peers[i].Online
}
return info.Peers[i].Name < info.Peers[j].Name
})
info.Peers, info.VPNServers = peersFromStatus(st, r.URL.Query().Get("vpn") == "1")
}
}
if info.State == "Running" {