diff --git a/README.md b/README.md index a8e1a6d..ccceb3a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/tsd/peers.go b/tsd/peers.go new file mode 100644 index 0000000..bde6443 --- /dev/null +++ b/tsd/peers.go @@ -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 +} diff --git a/tsd/peers_test.go b/tsd/peers_test.go new file mode 100644 index 0000000..e1fc052 --- /dev/null +++ b/tsd/peers_test.go @@ -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) + } +} diff --git a/tsd/status.html b/tsd/status.html index b49dbb8..4ee4db4 100644 --- a/tsd/status.html +++ b/tsd/status.html @@ -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 @@ @@ -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); diff --git a/tsd/web.go b/tsd/web.go index 4d00104..8db9e9c 100644 --- a/tsd/web.go +++ b/tsd/web.go @@ -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" {