2 Commits
Author SHA1 Message Date
holdmysocks 726b9b99c4 Only pipe connections addressed to the console; 0.5.2
The fallback handler piped a tailnet connection to localhost by its port
alone. Only connections to the console's own addresses reach it today, so
nothing was exposed, but the handler now checks the destination address
itself instead of relying on that.

The README explains why the console does not offer itself as an exit node.
2026-10-04 10:40:23 -04:00
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
9 changed files with 374 additions and 51 deletions

No files matched your search

+15 -1
View File
@@ -31,7 +31,14 @@ VPN here. It runs inside one process:
directly. They can through a *local forward* (see directly. They can through a *local forward* (see
[game streaming](#game-streaming-moonlight-to-sunshine) and [game streaming](#game-streaming-moonlight-to-sunshine) and
[configuration](#configuration)). [configuration](#configuration)).
- No exit node, subnet routing, Tailscale SSH, Taildrop or Funnel. - No subnet routing, Tailscale SSH, Taildrop or Funnel.
- The console cannot use an exit node, and it does not offer itself as one.
Offering one is doable (it needs no tunnel device), but the PS5 would make
a terrible exit node: every packet would pass through this one low-priority
process, so it would be slow, and it would fall away whenever the console
goes into rest mode, reboots or loses its jailbreak, taking the internet of
every device using it with it. Use a PC, a server or a router on your
tailnet instead.
## Requirements ## Requirements
@@ -125,6 +132,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 and buttons for logging out, stopping and uninstalling. It also says when a
newer release is available. 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 **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 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 allow it, can use it. Set one under **Settings**. It is then asked for on
+1 -1
View File
@@ -52,7 +52,7 @@ These folders are not in the repository.
```powershell ```powershell
# C launcher + Go program + home screen icon helper -> out\tailscale.elf # C launcher + Go program + home screen icon helper -> out\tailscale.elf
.\tools\build-payload.ps1 -GoDir tsd -Name tailscale -Version 0.4.1 -HomeIcon .\tools\build-payload.ps1 -GoDir tsd -Name tailscale -Version 0.5.2 -HomeIcon
``` ```
`-HomeIcon` also builds `appicon\` into `out\appicon.elf` and embeds it in `-HomeIcon` also builds `appicon\` into `out\appicon.elf` and embeds it in
+1 -1
View File
@@ -1,5 +1,5 @@
# Build a Go program for the PS5 and wrap it in the launcher payload. # Build a Go program for the PS5 and wrap it in the launcher payload.
# .\tools\build-payload.ps1 -GoDir tsd -Name tailscale -Version 0.4.1 -HomeIcon # .\tools\build-payload.ps1 -GoDir tsd -Name tailscale -Version 0.5.2 -HomeIcon
# .\tools\build-payload.ps1 -GoDir probe-go -Name probe [-DebugLoader] [-Watchdog 120] [-Send] # .\tools\build-payload.ps1 -GoDir probe-go -Name probe [-DebugLoader] [-Watchdog 120] [-Send]
param( param(
[Parameter(Mandatory = $true)][string]$GoDir, [Parameter(Mandatory = $true)][string]$GoDir,
+14
View File
@@ -20,6 +20,14 @@ import (
// connection is declined, so the peer sees an ordinary "connection refused" // connection is declined, so the peer sees an ordinary "connection refused"
// rather than a connection that opens and closes. // rather than a connection that opens and closes.
func (d *daemon) forwardToLocalhost(src, dst netip.AddrPort) (handler func(net.Conn), intercept bool) { func (d *daemon) forwardToLocalhost(src, dst netip.AddrPort) (handler func(net.Conn), intercept bool) {
ip4, ip6 := d.srv.TailscaleIPs()
if !addressedTo(dst.Addr(), ip4, ip6) {
// Only connections to the console's own tailnet addresses are for
// its services. Nothing else arrives today, but if this node ever
// advertised routes, a connection to any address on a port that is
// open here must not end up at the console's service.
return nil, false
}
port := dst.Port() port := dst.Port()
if port == d.webPort { if port == d.webPort {
// The status page is served on the tailnet connection itself rather // The status page is served on the tailnet connection itself rather
@@ -53,6 +61,12 @@ func (d *daemon) forwardToLocalhost(src, dst netip.AddrPort) (handler func(net.C
}, true }, true
} }
// addressedTo reports whether dst is one of the node's own addresses.
func addressedTo(dst netip.Addr, own ...netip.Addr) bool {
dst = dst.Unmap()
return dst.IsValid() && slices.Contains(own, dst)
}
// pipe copies in both directions until both sides are done. // pipe copies in both directions until both sides are done.
func pipe(a, b net.Conn) { func pipe(a, b net.Conn) {
done := make(chan struct{}, 2) done := make(chan struct{}, 2)
+22
View File
@@ -3,9 +3,31 @@ package main
import ( import (
"io" "io"
"net" "net"
"net/netip"
"testing" "testing"
) )
func TestAddressedTo(t *testing.T) {
ip4, ip6 := netip.MustParseAddr("100.64.0.1"), netip.MustParseAddr("fd7a:115c:a1e0::1")
for addr, want := range map[string]bool{
"100.64.0.1": true,
"::ffff:100.64.0.1": true,
"fd7a:115c:a1e0::1": true,
"100.64.0.2": false,
"93.184.216.34": false,
"127.0.0.1": false,
"2606:4700:4700::64": false,
} {
if got := addressedTo(netip.MustParseAddr(addr), ip4, ip6); got != want {
t.Errorf("addressedTo(%s) = %v, want %v", addr, got, want)
}
}
// Before the node has its addresses nothing is for it.
if addressedTo(netip.Addr{}, netip.Addr{}, netip.Addr{}) {
t.Error("an invalid address matched")
}
}
// pipe must pass a half-close through: the ELF loader protocol and FTP data // pipe must pass a half-close through: the ELF loader protocol and FTP data
// connections both rely on the reader seeing EOF while the other direction // connections both rely on the reader seeing EOF while the other direction
// stays open. // stays open.
+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; } 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); } details summary { cursor: pointer; color: var(--muted); }
summary .count { font-size: 14px; margin-left: 8px; } summary .count { font-size: 14px; margin-left: 8px; }
summary .count { white-space: nowrap; }
details[open] > table, details[open] > form { margin-top: 12px; } details[open] > table, details[open] > form { margin-top: 12px; }
.note { color: var(--muted); font-size: 14px; margin: 0 0 12px; } .note { color: var(--muted); font-size: 14px; margin: 0 0 12px; }
.hint { color: var(--muted); font-size: 13px; margin: 2px 0 0; } .hint { color: var(--muted); font-size: 13px; margin: 2px 0 0; }
@@ -67,6 +68,14 @@
background: var(--panel); color: var(--text); width: 100%; background: var(--panel); color: var(--text); width: 100%;
} }
textarea { font: 14px ui-monospace, Consolas, monospace; min-height: 70px; resize: vertical; } 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 { display: flex; gap: 8px; margin-bottom: 8px; align-items: center; }
.hostrow .host { flex: 3; min-width: 0; } .hostrow .host { flex: 3; min-width: 0; }
.hostrow .port { flex: 1; min-width: 90px; } .hostrow .port { flex: 1; min-width: 90px; }
@@ -111,10 +120,16 @@
<section class="panel hidden" id="peerpanel"> <section class="panel hidden" id="peerpanel">
<details id="peerbox" open> <details id="peerbox" open>
<summary><span class="heading">Devices on your tailnet</span><span class="count" id="peercount"></span></summary> <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> <table>
<thead><tr><th>Name</th><th>Address</th><th>OS</th><th>Status</th></tr></thead> <thead><tr><th>Name</th><th>Address</th><th>OS</th><th>Status</th></tr></thead>
<tbody id="peers"></tbody> <tbody id="peers"></tbody>
</table> </table>
<p class="note hidden" id="peernone" style="margin: 12px 0 0">No device matches.</p>
</details> </details>
</section> </section>
@@ -264,10 +279,67 @@ function hostRow(host, port) {
return div; 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() { async function refresh() {
let s; let s;
try { try {
s = await (await api('/api/status')).json(); s = await (await api('/api/status' + ($('peervpn').checked ? '?vpn=1' : ''))).json();
} catch (e) { } catch (e) {
if (e.message !== 'locked') { if (e.message !== 'locked') {
$('state').textContent = 'Not responding'; $('state').textContent = 'Not responding';
@@ -309,24 +381,18 @@ async function refresh() {
if (shownQR !== s.authURL) { shownQR = s.authURL; $('qr').src = '/qr.png?' + Date.now(); } if (shownQR !== s.authURL) { shownQR = s.authURL; $('qr').src = '/qr.png?' + Date.now(); }
} }
const tbody = $('peers'); peers = s.peers;
tbody.replaceChildren(...s.peers.map(p => { const devices = peers.filter(p => p.kind !== 'vpn');
const tr = document.createElement('tr'); $('peerpanel').classList.toggle('hidden', !peers.length && !s.vpnServers.total);
for (const [v, mono] of [[p.name], [p.ip, true], [p.os], [p.online ? 'online' : 'offline']]) { $('peercount').textContent = devices.filter(p => p.online).length + ' online of ' + devices.length;
const td = document.createElement('td'); $('peervpn-row').classList.toggle('hidden', !s.vpnServers.total);
if (!p.online) td.className = 'off'; $('peervpn-text').textContent = 'Show VPN exit servers (' + s.vpnServers.total + ')';
if (mono) { const c = document.createElement('code'); c.textContent = v; td.append(c); } else td.textContent = v; renderPeers();
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;
// Game streaming. The rows are only rebuilt from the daemon's state while // Game streaming. The rows are only rebuilt from the daemon's state while
// the user is not in the middle of editing them. // the user is not in the middle of editing them.
$('streampanel').classList.toggle('hidden', s.state !== 'Running' && !s.sunshineHosts.length); $('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); const hostsNow = JSON.stringify(s.sunshineHosts);
if (!hostsDirty && hostsNow !== lastHosts) { if (!hostsDirty && hostsNow !== lastHosts) {
lastHosts = hostsNow; lastHosts = hostsNow;
@@ -458,14 +524,16 @@ $('settingsform').onsubmit = async ev => {
$('logbox').addEventListener('toggle', refresh); $('logbox').addEventListener('toggle', refresh);
// The device list can be long. Remember whether it was left collapsed; the // The device list can be long. Remember whether it was left collapsed and
// browser may not allow storage, in which case it simply starts open. // how it was filtered; the browser may not allow storage, in which case it
try { // simply starts open and unfiltered.
if (localStorage.getItem('peersCollapsed') === '1') $('peerbox').open = false; if (stored('peersCollapsed') === '1') $('peerbox').open = false;
} catch (e) {} $('peerbox').addEventListener('toggle', () => store('peersCollapsed', $('peerbox').open ? '0' : '1'));
$('peerbox').addEventListener('toggle', () => { $('peeronline').checked = stored('peersOnlineOnly') === '1';
try { localStorage.setItem('peersCollapsed', $('peerbox').open ? '0' : '1'); } catch (e) {} $('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(); refresh();
setInterval(refresh, 3000); setInterval(refresh, 3000);
+5 -25
View File
@@ -8,7 +8,6 @@ import (
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"time" "time"
@@ -29,13 +28,6 @@ var faviconPNG []byte
// console out. Who may use the page at all is decided in auth.go. // console out. Who may use the page at all is decided in auth.go.
const apiHeader = "X-PS5-Tailscale" 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. // sunshineInfo is a forwarded Sunshine host as the status page shows it.
type sunshineInfo struct { type sunshineInfo struct {
Host string `json:"host"` Host string `json:"host"`
@@ -55,7 +47,10 @@ type statusInfo struct {
Tailnet string `json:"tailnet,omitempty"` Tailnet string `json:"tailnet,omitempty"`
Health []string `json:"health,omitempty"` Health []string `json:"health,omitempty"`
Peers []peerInfo `json:"peers"` 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 and Forwards describe the local forwards.
SunshineHosts []sunshineInfo `json:"sunshineHosts"` SunshineHosts []sunshineInfo `json:"sunshineHosts"`
Forwards []string `json:"forwards"` 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()) info.IPs = append(info.IPs, ip.String())
} }
} }
for _, p := range st.Peer { info.Peers, info.VPNServers = peersFromStatus(st, r.URL.Query().Get("vpn") == "1")
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
})
} }
} }
if info.State == "Running" { if info.State == "Running" {