1 Commits
Author SHA1 Message Date
holdmysocks e4986b4d52 Wake-on-LAN, a diagnostics download, connection tests and a note on what to expect
- "Wake a device at home": the status page, reachable from anywhere over
  the tailnet, can make the console broadcast a Wake-on-LAN packet for a
  device in a saved list.
- "Download diagnostics": one text file with the logs, version, firmware
  and settings for bug reports. E-mail addresses, the tailnet's name,
  public IP addresses, keys and the password are removed.
- "test" links for forwards and game streaming hosts open a TCP connection
  through the tailnet and say in plain words how it went.
- A short "what this does, and what it does not" note, opened before the
  first login.
- Screenshot updated.
2026-10-06 19:46:22 -04:00
8 changed files with 800 additions and 19 deletions

No files matched your search

+35 -6
View File
@@ -273,6 +273,29 @@ follow, and HTTPS sites complain about the certificate, because the browser
sees `127.0.0.1`; plain HTTP services work best, and the tailnet encrypts
the connection anyway.
**test** next to a line tries the connection the way the PS5 would make it
and says what happened: the device answers, the device answered but nothing
listens on that port, there is no answer (off, asleep or firewalled), or
there is no device with that name. The game streaming hosts have the same
link, which checks whether Sunshine answers.
### Waking a PC at home
A sleeping PC cannot be reached over Tailscale, because nothing on it is
running. The console is on the same home network, though, and can send it a
Wake-on-LAN packet:
1. Under **Wake a device at home** on the status page, press **Add a
device**, give it a name and its network card's MAC address (on Windows,
the "Physical address" in `ipconfig /all`), and press **Save**.
2. From wherever you are, open the status page over Tailscale and press
**Wake**. Give the PC half a minute, then connect to it.
The PC needs Wake-on-LAN turned on, in its firmware setup and in the network
card's settings, and it works most reliably over a cable. The console sends
the packet to every device on its network; there is no reply, so the page
cannot tell whether the PC heard it. Only devices in the list can be woken.
### Other apps on the console
The daemon can also run an HTTP proxy that reaches tailnet hosts, for apps
@@ -329,6 +352,7 @@ optional.
| `blockedPorts` | Local TCP ports that are never exposed to the tailnet. |
| `allowFrom` | `"own"` lets only devices logged in as the same user as the console connect; other users' devices and devices shared into the tailnet are turned away. Anything else is the default: every device your tailnet's access rules allow. If the console is tagged, `"own"` means the devices of its own tailnet. |
| `receiveDir` | Where files sent to the console with Taildrop are put. |
| `wake` | Devices the status page can wake with Wake-on-LAN: a `name` and the network card's `mac` each. |
| `payloadPath` | The copy of the payload that is started with the console, if there is one; best kept under a fixed name such as `tailscale.elf`. An update installed from the status page replaces its contents and leaves its name alone. Empty: none. |
| `priority` | `"high"` lets the daemon compete with games for CPU time; anything else is the default, low. Applied when Tailscale starts. |
| `checkUpdates` | Ask GitHub twice a day whether a newer release exists, to show it on the status page and announce it once on the console. Nothing is downloaded. |
@@ -393,9 +417,13 @@ Left to do by hand:
- **Forgot the status page password.** Delete the `passwordHash` line from
`/data/tailscale/config.json` and start Tailscale again, or use the page on
the console itself, where no password is asked.
- **Something else.** `http://<console>:8090/api/logs?full=1` is the daemon's
log and `/api/logs?debug=1` is Tailscale's detailed log. Please attach them
to bug reports, after checking them for anything you consider private.
- **Something else.** Press **Download diagnostics** on the status page and
attach the file to your bug report. It holds the logs, the version, the
firmware and the settings. E-mail addresses, your tailnet's name, public IP
addresses, keys and the password are removed from it; device names and
tailnet addresses are not, so read it before posting. If the status page
never comes up, there is nothing to press: fetch
`/data/tailscale/launcher.log` over FTP instead.
## Security
@@ -440,7 +468,7 @@ LAN and the tailnet, changing settings from the page, both priority settings,
the update check, installing an update from the page (rehearsed with a test
release, including replacing a second copy of the payload), receiving files
with Taildrop, reaching a device through a forward set up on the page, the
connection test, limiting connections to your own devices (with the
connection tests, the diagnostics file, limiting connections to your own devices (with the
console's owner's devices only; a refusal has not been seen for real), a
stay in rest mode, both a minute and nine and a half hours: the same process
carried on and was back on the tailnet after waking.
@@ -448,8 +476,9 @@ carried on and was back on the tailnet after waking.
Remote Play through the tailnet address works with Chiaki and with Asobi on
iOS and Android.
Not tested: switching between Wi-Fi and Ethernet while running, rest mode on
Wi-Fi, the complete Uninstall
Not tested: whether a PC actually wakes from the Wake button (the console
reports sending the packets; no sleeping PC was at hand), switching between
Wi-Fi and Ethernet while running, rest mode on Wi-Fi, the complete Uninstall
on a console (its parts were tested separately), whether High priority
improves Remote Play, a real Sunshine host on a non-default port, other
firmware versions, coordination servers other than Tailscale's.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 159 KiB

+3
View File
@@ -53,6 +53,9 @@ type config struct {
PayloadPath string `json:"payloadPath,omitempty"`
// ReceiveDir is where files sent to the console with Taildrop end up.
ReceiveDir string `json:"receiveDir"`
// Wake lists devices on the console's home network that the status page
// can wake with a Wake-on-LAN packet.
Wake []wakeTarget `json:"wake,omitempty"`
// Priority is how the daemon competes for CPU time: "low" (the default)
// never takes time from a game, "high" shares the CPU with games on
// equal terms, which can make Remote Play smoother. Applied at start.
+200
View File
@@ -0,0 +1,200 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"regexp"
"runtime"
"slices"
"strings"
"time"
)
// The diagnostics file: everything someone helping with a problem needs, in
// one download, so that a bug report does not depend on getting files off
// the console by hand.
//
// It goes on the internet when it is attached to a report, so what can be
// left out without making it useless is left out or blanked: the password
// hash, auth keys, login links, e-mail addresses, the tailnet's name and
// public IP addresses. Device names and tailnet addresses stay; without them
// the logs cannot be followed.
const (
diagLauncherTail = 64 << 10
diagMainTail = 256 << 10
diagDebugTail = 512 << 10
)
var (
reEmail = regexp.MustCompile(`[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}`)
reTailnet = regexp.MustCompile(`\b([A-Za-z0-9-]+)\.[A-Za-z0-9-]+\.ts\.net\b`)
// The tailnet's name on its own, as it appears in DNS settings.
reTailnetBare = regexp.MustCompile(`\b[A-Za-z0-9-]+\.ts\.net\b`)
reLoginURL = regexp.MustCompile(`https://login\.tailscale\.com/a/[A-Za-z0-9]+`)
reAuthKey = regexp.MustCompile(`tskey-[A-Za-z0-9-]+`)
reIPv4 = regexp.MustCompile(`\b(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\b`)
reFirmware = regexp.MustCompile(`firmware (\d+\.\d+)`)
)
// scrub blanks what should not be published. It keeps the shape of the
// text, so the logs still read as logs.
func scrub(b []byte) []byte {
b = reLoginURL.ReplaceAll(b, []byte("https://login.tailscale.com/a/<removed>"))
b = reAuthKey.ReplaceAll(b, []byte("tskey-<removed>"))
b = reEmail.ReplaceAll(b, []byte("<email>"))
b = reTailnet.ReplaceAll(b, []byte("$1.<tailnet>.ts.net"))
b = reTailnetBare.ReplaceAll(b, []byte("<tailnet>.ts.net"))
return reIPv4.ReplaceAllFunc(b, func(ip []byte) []byte {
if publicIPv4(string(ip)) {
return []byte("<public-ip>")
}
return ip
})
}
// publicIPv4 reports whether s is an address on the internet, as opposed to
// a private, tailnet, loopback or otherwise special one. Text that only
// looks like an address (a version number, say) is left alone.
func publicIPv4(s string) bool {
var a, b, c, d int
if n, _ := fmt.Sscanf(s, "%d.%d.%d.%d", &a, &b, &c, &d); n != 4 || a > 255 || b > 255 || c > 255 || d > 255 {
return false
}
switch {
case a == 0, a == 10, a == 127, a >= 224:
return false
case a == 100 && b >= 64 && b <= 127: // tailnet addresses
return false
case a == 169 && b == 254:
return false
case a == 172 && b >= 16 && b <= 31:
return false
case a == 192 && b == 168:
return false
case a == 192 && b == 0 && c == 2:
return false
}
// A well-known public resolver says nothing about the user.
switch s {
case "1.1.1.1", "8.8.8.8", "9.9.9.9":
return false
}
return true
}
func fileTail(path string, max int64) []byte {
f, err := os.Open(path)
if err != nil {
return []byte("(" + err.Error() + ")\n")
}
defer f.Close()
if fi, err := f.Stat(); err == nil && fi.Size() > max {
f.Seek(fi.Size()-max, io.SeekStart)
}
b, _ := io.ReadAll(onlyReader{f})
return b
}
// diagnostics assembles the file.
func (d *daemon) diagnostics(r *http.Request) []byte {
var out bytes.Buffer
section := func(title string) { fmt.Fprintf(&out, "\n===== %s =====\n", title) }
launcher := fileTail(filepath.Join(dataDir, "launcher.log"), diagLauncherTail)
firmware := "unknown"
if m := reFirmware.FindAllSubmatch(launcher, -1); len(m) > 0 {
firmware = string(m[len(m)-1][1])
}
d.mu.Lock()
cfg := d.cfg
state, lastErr := d.state, d.lastErr
latest := d.latest.Version
d.mu.Unlock()
fmt.Fprintf(&out, "ps5-tailscale diagnostics\n")
fmt.Fprintf(&out, "Please read this file before posting it. E-mail addresses, the tailnet's name, public IP\n")
fmt.Fprintf(&out, "addresses, keys and the password have been removed; device names and tailnet addresses have not.\n\n")
fmt.Fprintf(&out, "version: %s (%s/%s)\n", version, runtime.GOOS, runtime.GOARCH)
fmt.Fprintf(&out, "firmware: %s\n", firmware)
fmt.Fprintf(&out, "created: %s\n", time.Now().UTC().Format("2006-01-02 15:04:05 UTC"))
fmt.Fprintf(&out, "running for: %s\n", time.Since(d.started).Round(time.Second))
fmt.Fprintf(&out, "state: %s\n", state)
if lastErr != "" {
fmt.Fprintf(&out, "last error: %s\n", lastErr)
}
if latest != "" {
fmt.Fprintf(&out, "latest known: %s\n", latest)
}
if d.dns != nil {
fmt.Fprintf(&out, "name lookups: %s\n", d.dns.describe())
}
if d.udp != nil {
fmt.Fprintf(&out, "UDP ports: %v\n", d.udp.activePorts())
}
if d.fwd != nil {
fmt.Fprintf(&out, "forwards: %d active\n", len(d.fwd.rules()))
}
if d.lc != nil {
if st, err := d.status(r.Context()); err == nil {
section("tailscale")
fmt.Fprintf(&out, "backend state: %s\n", st.BackendState)
for _, h := range st.Health {
fmt.Fprintf(&out, "health: %s\n", h)
}
if st.Self != nil {
fmt.Fprintf(&out, "self: %s, addresses %v, relay %q, key expiry %v\n", st.Self.DNSName, st.Self.TailscaleIPs, st.Self.Relay, st.Self.KeyExpiry)
}
peers, vpn := peersFromStatus(st, false)
fmt.Fprintf(&out, "peers: %d, VPN exit servers: %d\n", len(peers), vpn.Total)
for _, p := range peers {
fmt.Fprintf(&out, " %-24s %-16s %-8s online=%-5v %s %s\n", p.Name, p.IP, p.OS, p.Online, p.Kind, strings.TrimSpace(p.Conn+" "+p.Via))
}
} else {
section("tailscale")
fmt.Fprintf(&out, "status: %v\n", err)
}
}
section("settings (password and auth key removed)")
if cfg.PasswordHash != "" {
cfg.PasswordHash = "(set)"
}
if cfg.AuthKey != "" {
cfg.AuthKey = "(set)"
}
// cfg is a copy, but its slices are the daemon's own: copy before
// blanking.
cfg.Wake = slices.Clone(cfg.Wake)
for i := range cfg.Wake {
cfg.Wake[i].MAC = "(set)"
}
if b, err := json.MarshalIndent(cfg, "", " "); err == nil {
out.Write(b)
out.WriteByte('\n')
}
section("launcher.log")
out.Write(launcher)
section("tailscale.log (end)")
out.Write(fileTail(filepath.Join(dataDir, "tailscale.log"), diagMainTail))
section("tailscale-debug.log (end)")
out.Write(fileTail(filepath.Join(dataDir, "tailscale-debug.log"), diagDebugTail))
return scrub(out.Bytes())
}
func (d *daemon) handleDiagnostics(w http.ResponseWriter, r *http.Request) {
name := fmt.Sprintf("ps5-tailscale-diagnostics-%s-%s.txt", version, time.Now().UTC().Format("20060102-150405"))
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`)
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Write(d.diagnostics(r))
}
+148 -4
View File
@@ -58,6 +58,7 @@
/* The device gets a line of its own; port, protocol and Remove share the next. */
.fwd .hostrow { flex-wrap: wrap; }
.fwd .hostrow .host { flex: 1 1 100%; }
#wakerows .host, #wakerows .mac { flex: 1 1 100%; }
}
.actions { display: flex; flex-wrap: wrap; gap: 10px; align-items: flex-end; }
button {
@@ -98,6 +99,11 @@
.fwd .hostrow { margin-bottom: 4px; }
.fwd .use { font-size: 14px; color: var(--muted); }
button.link { border: 0; padding: 0; background: none; color: var(--accent); font-size: inherit; text-decoration: underline; cursor: pointer; }
ul.about { margin: 12px 0 0; padding-left: 20px; }
ul.about li { margin-bottom: 8px; }
.hostrow .mac { flex: 2; min-width: 0; font-family: ui-monospace, Consolas, monospace; font-size: 14px; }
.result { font-size: 14px; color: var(--muted); }
.result.good { color: var(--ok); } .result.bad { color: var(--bad); }
.hidden { display: none; }
</style>
</head>
@@ -136,6 +142,21 @@
<p class="msg hidden" id="error"></p>
</section>
<section class="panel">
<details id="aboutbox">
<summary><span class="heading">What this does, and what it does not</span></summary>
<ul class="about">
<li><strong>Your other devices can reach this PS5</strong> at its Tailscale address, from anywhere: FTP, the
payload loader, Remote Play, anything that listens on the console.</li>
<li><strong>The PS5 can reach a device on your tailnet</strong> through an address on the console itself. Set
that up under "Reach a device from this PS5"; game streaming has its own panel.</li>
<li><strong>It is not a full VPN.</strong> Games, PSN and the PS5's browser keep using your normal internet
connection, the console's public IP address does not change, and the console cannot use an exit node. The
PS5 has no way to route its own traffic through Tailscale.</li>
</ul>
</details>
</section>
<section class="panel login hidden" id="login">
<h2>Log in</h2>
<p>Scan this with your phone, or open the link on any device, to add this PS5 to your tailnet.</p>
@@ -203,6 +224,22 @@
<p class="note" id="sunshine-state" style="margin: 12px 0 0"></p>
</section>
<section class="panel hidden" id="wakepanel">
<h2>Wake a device at home</h2>
<p class="note">A sleeping PC cannot be reached over Tailscale, but this console is on the same home network and
can send it a Wake-on-LAN packet. Open this page from wherever you are, press Wake, wait half a minute, then
connect. The device needs Wake-on-LAN turned on, and a wired connection works best.</p>
<div id="wakerows"></div>
<div class="actions">
<button id="btn-addwake" class="small">Add a device</button>
<button id="btn-wakes">Save</button>
</div>
<p class="hint">The address is the network card's MAC address, such as <code>00:11:22:AA:BB:CC</code>. On Windows
it is the "Physical address" shown by <code>ipconfig /all</code>.</p>
<p class="okmsg hidden" id="wakeok"></p>
<p class="msg hidden" id="wakemsg"></p>
</section>
<section class="panel">
<details id="settingsbox">
<summary><span class="heading">Settings</span></summary>
@@ -272,8 +309,12 @@
<button id="btn-logout" class="danger">Log out</button>
<button id="btn-quit" class="danger">Stop Tailscale</button>
<button id="btn-uninstall" class="danger">Uninstall</button>
<button id="btn-diag">Download diagnostics</button>
<button id="btn-lock" class="hidden">Lock this page</button>
</div>
<p class="hint" style="margin-top: 10px">Diagnostics is one text file with the logs, version and settings, for
attaching to a bug report. E-mail addresses, your tailnet's name, public IP addresses and the password are
removed; device names are not. Read it before posting it.</p>
<p class="okmsg hidden" id="actionok"></p>
<p class="msg hidden" id="actionmsg"></p>
</section>
@@ -367,9 +408,35 @@ function row(dl, name, value, mono) {
// "Copied" confirmation is not wiped out mid-way.
let shownFacts = '';
// testTarget asks the daemon whether a device answers on a port, the way a
// forward would reach it, and writes the outcome into el.
async function testTarget(target, el) {
el.className = 'result'; el.textContent = 'testing…';
try {
const r = await api('/api/testtarget?target=' + encodeURIComponent(target), {method: 'POST'});
if (!r.ok) throw new Error((await r.text()).trim() || r.statusText);
const t = await r.json();
el.className = 'result ' + (t.ok ? 'good' : 'bad');
el.textContent = t.ok ? 'answers (' + Math.round(t.ms) + ' ms)' : t.error;
} catch (e) {
el.className = 'result bad'; el.textContent = e.message === 'locked' ? '' : e.message;
}
}
// testLink is a small "test" link with room for its result next to it.
function testLink(target, title) {
const span = document.createElement('span');
const b = document.createElement('button');
b.type = 'button'; b.className = 'link'; b.textContent = 'test'; b.title = title;
const res = document.createElement('span');
b.onclick = () => testTarget(target(), res);
span.append(b, ' ', res);
return span;
}
function hostRow(host, port) {
const div = document.createElement('div');
div.className = 'hostrow';
div.className = 'hostrow'; div.style.flexWrap = 'wrap';
const h = document.createElement('input');
h.type = 'text'; h.className = 'host'; h.placeholder = 'Device name or address'; h.value = host || '';
h.setAttribute('list', 'peernames'); h.setAttribute('aria-label', 'Sunshine host'); h.autocomplete = 'off';
@@ -380,7 +447,14 @@ function hostRow(host, port) {
x.type = 'button'; x.className = 'small'; x.textContent = 'Remove';
x.onclick = () => { div.remove(); hostsDirty = true; };
h.oninput = p.oninput = () => { hostsDirty = true; };
div.append(h, p, x);
// Whether Sunshine on that device answers: its web port is the one the
// Moonlight client talks to first.
const t = testLink(() => {
const name = h.value.trim();
return (name.includes(':') ? '[' + name + ']' : name) + ':' + (parseInt(p.value, 10) || 47989);
}, 'Check whether Sunshine on this device answers');
t.style.flex = '1 1 100%'; t.style.fontSize = '14px';
div.append(h, p, x, t);
return div;
}
@@ -502,8 +576,11 @@ function fwdRow(rule) {
x.onclick = () => { wrap.remove(); fwdsDirty = true; };
const use = document.createElement('div');
use.className = 'use';
if (rule) { use.append('On the PS5 use '); use.append(copyable(rule.listen)); }
else use.textContent = 'The address to use on the PS5 appears here after saving.';
if (rule) {
use.append('On the PS5 use ', copyable(rule.listen));
// Only TCP can be tested: UDP has no "it answered".
if (rule.proto === 'tcp') use.append(' · ', testLink(() => rule.target, 'Check whether the device answers on that port'));
} else use.textContent = 'The address to use on the PS5 appears here after saving.';
// Changing what a row points at keeps its address; changing the port or
// protocol gives it a new one, so it stays easy to recognise.
h.oninput = () => { fwdsDirty = true; };
@@ -632,6 +709,18 @@ async function refresh() {
? s.sunshineHosts.map(h => h.host + ': add ' + h.address + ' in Moonlight').join('. ') + '.'
: 'No Sunshine host is forwarded.';
// Devices to wake.
$('wakepanel').classList.toggle('hidden', s.state !== 'Running' && !s.wake.length);
const wakeNow = JSON.stringify(s.wake);
if (!wakeDirty && wakeNow !== lastWake) {
lastWake = wakeNow;
$('wakerows').replaceChildren(...s.wake.map(wakeRow));
}
// Before the first login, say what to expect; afterwards the note stays
// folded away unless it was opened.
if (needLogin && !aboutShown) { aboutShown = true; $('aboutbox').open = true; }
$('btn-lock').classList.toggle('hidden', !s.passwordSet);
refreshFiles();
@@ -767,6 +856,61 @@ $('btn-fwds').onclick = async () => {
refresh();
};
// Devices to wake. A row can be woken once it has been saved.
let wakeDirty = false;
let lastWake = '';
let aboutShown = false;
function wakeRow(t) {
const div = document.createElement('div');
div.className = 'hostrow'; div.style.flexWrap = 'wrap';
const n = document.createElement('input');
n.type = 'text'; n.className = 'host'; n.placeholder = 'Name, for example gaming-pc'; n.value = t ? t.name : '';
n.setAttribute('list', 'peernames'); n.setAttribute('aria-label', 'Name'); n.autocomplete = 'off'; n.maxLength = 64;
const m = document.createElement('input');
m.type = 'text'; m.className = 'mac'; m.placeholder = '00:11:22:AA:BB:CC'; m.value = t ? t.mac : '';
m.setAttribute('aria-label', 'MAC address'); m.autocomplete = 'off'; m.spellcheck = false;
const w = document.createElement('button');
w.type = 'button'; w.textContent = 'Wake'; w.disabled = !t;
w.title = t ? 'Send the wake-up packet' : 'Save first';
w.onclick = async () => {
show('wakemsg', ''); show('wakeok', '');
try {
const r = await api('/api/wake?mac=' + encodeURIComponent(t.mac), {method: 'POST'});
const text = (await r.text()).trim();
if (!r.ok) throw new Error(text || r.statusText);
show('wakeok', text);
} catch (e) {
if (e.message !== 'locked') show('wakemsg', e.message);
}
};
const x = document.createElement('button');
x.type = 'button'; x.className = 'small'; x.textContent = 'Remove';
x.onclick = () => { div.remove(); wakeDirty = true; };
n.oninput = () => { wakeDirty = true; };
m.oninput = () => { wakeDirty = true; w.disabled = true; w.title = 'Save first'; };
div.append(n, m, w, x);
return div;
}
$('btn-addwake').onclick = () => { $('wakerows').append(wakeRow(null)); wakeDirty = true; };
$('btn-wakes').onclick = async () => {
show('wakemsg', ''); show('wakeok', '');
const list = [...$('wakerows').children].map(d => ({name: d.querySelector('.host').value.trim(), mac: d.querySelector('.mac').value.trim()}))
.filter(t => t.name || t.mac);
try {
const r = await api('/api/wakelist', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(list)});
const text = (await r.text()).trim();
if (!r.ok) throw new Error(text || r.statusText);
show('wakeok', 'Saved.');
wakeDirty = false; lastWake = '';
} catch (e) {
if (e.message !== 'locked') show('wakemsg', e.message);
}
refresh();
};
// The diagnostics file is a plain download; the session cookie covers it.
$('btn-diag').onclick = () => { location.href = '/api/diagnostics'; };
// Game streaming hosts.
$('btn-addhost').onclick = () => { $('hostrows').append(hostRow('', 0)); hostsDirty = true; };
$('btn-sunshine').onclick = async () => {
+173
View File
@@ -0,0 +1,173 @@
package main
import (
"bytes"
"errors"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestScrub(t *testing.T) {
in := `2026-10-06 login URL: https://login.tailscale.com/a/1a2b3c4d5e6f
self: ps5.tail-scale-fish.ts.net. user someone@example.com authkey tskey-auth-kABCDEF123-xyzXYZ
endpoints 203.0.113.7:49866 (portmap), 192.168.1.50:49866 (local), 100.64.0.5, 10.0.0.5, 172.20.1.1
derp 198.51.100.9:443, resolver 1.1.1.1:53, version 1.104.0, loopback 127.0.0.1:8090
peer gaming-pc.tail-scale-fish.ts.net
dns: Set: {Routes:{ts.net.:[199.247.155.53] tail-scale-fish.ts.net.:[]} SearchDomains:[tail-scale-fish.ts.net.]}`
out := string(scrub([]byte(in)))
for _, gone := range []string{"1a2b3c4d5e6f", "someone@example.com", "kABCDEF123", "tail-scale-fish", "203.0.113.7", "198.51.100.9"} {
if strings.Contains(out, gone) {
t.Errorf("%q was not removed:\n%s", gone, out)
}
}
for _, kept := range []string{"192.168.1.50:49866", "100.64.0.5", "10.0.0.5", "172.20.1.1", "1.1.1.1:53", "1.104.0", "127.0.0.1:8090",
"ps5.<tailnet>.ts.net", "gaming-pc.<tailnet>.ts.net", "<email>", "<public-ip>:49866", "2026-10-06"} {
if !strings.Contains(out, kept) {
t.Errorf("%q is missing:\n%s", kept, out)
}
}
}
func TestDiagnostics(t *testing.T) {
old := dataDir
dataDir = t.TempDir()
t.Cleanup(func() { dataDir = old })
os.WriteFile(filepath.Join(dataDir, "launcher.log"), []byte("2026-10-05 launcher: starting, firmware 9.60, pid 5\n2026-10-06 launcher: starting, firmware 13.42, pid 115\n"), 0o644)
os.WriteFile(filepath.Join(dataDir, "tailscale.log"), []byte("main log line from someone@example.com\n"), 0o644)
cfg := defaultConfig()
cfg.PasswordHash = "pbkdf2-sha256$210000$c2FsdA$a2V5"
cfg.AuthKey = "tskey-auth-secret"
cfg.Wake = []wakeTarget{{Name: "gaming-pc", MAC: "00:11:22:aa:bb:cc"}}
d := &daemon{cfg: cfg, logf: t.Logf, started: time.Now(), state: "Running"}
d.fwd = newForwarder(nil, t.Logf)
rec := httptest.NewRecorder()
d.handleDiagnostics(rec, httptest.NewRequest("GET", "/api/diagnostics", nil))
body := rec.Body.String()
if cd := rec.Header().Get("Content-Disposition"); !strings.HasPrefix(cd, "attachment") || !strings.Contains(cd, ".txt") {
t.Errorf("Content-Disposition = %q", cd)
}
for _, want := range []string{"firmware: 13.42", "state: Running", "main log line", `"passwordHash": "(set)"`, "===== launcher.log =====", "tailscale-debug.log"} {
if !strings.Contains(body, want) {
t.Errorf("missing %q", want)
}
}
for _, secret := range []string{"c2FsdA", "tskey-auth-secret", "someone@example.com", "00:11:22:aa:bb:cc"} {
if strings.Contains(body, secret) {
t.Errorf("%q is in the diagnostics", secret)
}
}
// The daemon's own copy of the settings must not have been touched.
if d.cfg.PasswordHash != cfg.PasswordHash || d.cfg.Wake[0].MAC != "00:11:22:aa:bb:cc" {
t.Error("building the diagnostics changed the settings")
}
}
func TestParseMAC(t *testing.T) {
for in, want := range map[string]string{
"00:11:22:AA:BB:CC": "00:11:22:aa:bb:cc",
"00-11-22-aa-bb-cc": "00:11:22:aa:bb:cc",
"001122AABBCC": "00:11:22:aa:bb:cc",
"0011.22aa.bbcc": "00:11:22:aa:bb:cc",
" 00:11:22:aa:bb:cc ": "00:11:22:aa:bb:cc",
} {
mac, err := parseMAC(in)
if err != nil || mac.String() != want {
t.Errorf("parseMAC(%q) = %v, %v", in, mac, err)
}
}
for _, bad := range []string{"", "gaming-pc", "00:11:22:aa:bb", "00:11:22:aa:bb:cc:dd:ee", "zz:11:22:aa:bb:cc", "192.168.1.5"} {
if _, err := parseMAC(bad); err == nil {
t.Errorf("parseMAC(%q) accepted", bad)
}
}
}
func TestMagicPacket(t *testing.T) {
mac, _ := parseMAC("00:11:22:aa:bb:cc")
p := magicPacket(mac)
if len(p) != 102 {
t.Fatalf("length %d", len(p))
}
if !bytes.Equal(p[:6], bytes.Repeat([]byte{0xff}, 6)) {
t.Error("the packet does not start with six 0xff")
}
for i := 0; i < 16; i++ {
if !bytes.Equal(p[6+i*6:12+i*6], mac) {
t.Fatalf("repetition %d is wrong", i)
}
}
}
func TestBroadcastAddrs(t *testing.T) {
list := broadcastAddrs()
if len(list) == 0 || !list[0].Equal(net.IPv4bcast) {
t.Fatalf("got %v", list)
}
for _, ip := range list {
if ip.To4() == nil || ip.IsLoopback() {
t.Errorf("unexpected address %v", ip)
}
}
}
func TestWakeHandlers(t *testing.T) {
d := &daemon{cfg: defaultConfig(), cfgPath: filepath.Join(t.TempDir(), "config.json"), logf: t.Logf}
post := func(h http.HandlerFunc, url, body string) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
h(rec, httptest.NewRequest("POST", url, strings.NewReader(body)))
return rec
}
if rec := post(d.handleWakeList, "/api/wakelist", `[{"name":" gaming-pc ","mac":"00-11-22-AA-BB-CC"},{"name":"","mac":"001122aabbdd"}]`); rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body)
}
want := []wakeTarget{{"gaming-pc", "00:11:22:aa:bb:cc"}, {"00:11:22:aa:bb:dd", "00:11:22:aa:bb:dd"}}
if len(d.cfg.Wake) != 2 || d.cfg.Wake[0] != want[0] || d.cfg.Wake[1] != want[1] {
t.Errorf("list = %+v", d.cfg.Wake)
}
if saved, err := loadConfig(d.cfgPath); err != nil || len(saved.Wake) != 2 {
t.Errorf("saved: %+v, %v", saved.Wake, err)
}
if rec := post(d.handleWakeList, "/api/wakelist", `[{"name":"x","mac":"not a mac"}]`); rec.Code != http.StatusBadRequest {
t.Errorf("a bad address: status %d", rec.Code)
}
// Only listed devices can be woken.
if rec := post(d.handleWake, "/api/wake?mac=de:ad:be:ef:00:01", ""); rec.Code != http.StatusNotFound {
t.Errorf("an unlisted device: status %d", rec.Code)
}
if rec := post(d.handleWake, "/api/wake?mac=nonsense", ""); rec.Code != http.StatusBadRequest {
t.Errorf("nonsense: status %d", rec.Code)
}
}
func TestDescribeDialError(t *testing.T) {
for msg, want := range map[string]string{
"dial tcp 100.64.0.4:80: connect: connection refused": "nothing listens",
"context deadline exceeded": "no answer",
"dial tcp: i/o timeout": "no answer",
"lookup nosuch: no such host": "no device with that name",
"something else entirely": "something else entirely",
} {
if got := describeDialError(errors.New(msg)); !strings.Contains(got, want) {
t.Errorf("%q -> %q", msg, got)
}
}
}
func TestTestTargetRefusesBadTargets(t *testing.T) {
d := &daemon{logf: t.Logf}
for _, target := range []string{"", "nas", "nas:0", ":80", "na s:80", "nas:99999"} {
rec := httptest.NewRecorder()
d.handleTestTarget(rec, httptest.NewRequest("POST", "/api/testtarget?target="+strings.ReplaceAll(target, " ", "%20"), nil))
if rec.Code != http.StatusBadRequest {
t.Errorf("%q: status %d", target, rec.Code)
}
}
}
+180
View File
@@ -0,0 +1,180 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"strings"
)
// Waking a device on the console's home network.
//
// A sleeping PC cannot be reached over Tailscale: nothing on it is running.
// But the console sits on the same home network, and a Wake-on-LAN packet
// only has to come from there. So the status page, which can be opened from
// anywhere over the tailnet, gets a button that makes the console send one.
// wakeTarget is a device that can be woken.
type wakeTarget struct {
Name string `json:"name"`
MAC string `json:"mac"`
}
// parseMAC accepts the usual ways of writing a hardware address and returns
// it in the form aa:bb:cc:dd:ee:ff.
func parseMAC(s string) (net.HardwareAddr, error) {
s = strings.TrimSpace(s)
if len(s) == 12 && !strings.ContainsAny(s, ":-.") {
s = s[0:2] + ":" + s[2:4] + ":" + s[4:6] + ":" + s[6:8] + ":" + s[8:10] + ":" + s[10:12]
}
mac, err := net.ParseMAC(s)
if err != nil || len(mac) != 6 {
return nil, fmt.Errorf("%q is not a network card address (it looks like 00:11:22:AA:BB:CC)", s)
}
return mac, nil
}
func validateWake(list []wakeTarget) error {
for i := range list {
list[i].Name = strings.TrimSpace(list[i].Name)
mac, err := parseMAC(list[i].MAC)
if err != nil {
return err
}
list[i].MAC = mac.String()
if list[i].Name == "" {
list[i].Name = list[i].MAC
}
if len(list[i].Name) > 64 {
return errors.New("a name is too long")
}
}
return nil
}
// magicPacket is the Wake-on-LAN payload: six bytes of 0xff and the address
// sixteen times.
func magicPacket(mac net.HardwareAddr) []byte {
p := make([]byte, 0, 6+16*6)
for i := 0; i < 6; i++ {
p = append(p, 0xff)
}
for i := 0; i < 16; i++ {
p = append(p, mac...)
}
return p
}
// broadcastAddrs returns where to send a packet so that every device on the
// console's networks sees it: each network's own broadcast address, and the
// general one.
func broadcastAddrs() []net.IP {
list := []net.IP{net.IPv4bcast}
addrs, err := net.InterfaceAddrs()
if err != nil {
return list
}
for _, a := range addrs {
ipnet, ok := a.(*net.IPNet)
if !ok {
continue
}
ip := ipnet.IP.To4()
if ip == nil || ip.IsLoopback() || len(ipnet.Mask) != 4 {
continue
}
if ones, _ := ipnet.Mask.Size(); ones >= 31 {
continue
}
b := make(net.IP, 4)
for i := range b {
b[i] = ip[i] | ^ipnet.Mask[i]
}
list = append(list, b)
}
return list
}
// sendWake broadcasts the magic packet. It reports how many sends went out;
// there is no way to know whether the device heard it.
func sendWake(mac net.HardwareAddr) (sent int, err error) {
packet := magicPacket(mac)
var lastErr error
for _, ip := range broadcastAddrs() {
// Port 9 is the customary one; some network cards listen on 7.
for _, port := range []int{9, 7} {
c, err := net.DialUDP("udp4", nil, &net.UDPAddr{IP: ip, Port: port})
if err != nil {
lastErr = err
continue
}
if _, err := c.Write(packet); err != nil {
lastErr = err
} else {
sent++
}
c.Close()
}
}
if sent == 0 {
if lastErr == nil {
lastErr = errors.New("no network to send on")
}
return 0, lastErr
}
return sent, nil
}
// handleWakeList replaces the list of devices that can be woken.
func (d *daemon) handleWakeList(w http.ResponseWriter, r *http.Request) {
var list []wakeTarget
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&list); err != nil {
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
return
}
if err := validateWake(list); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
d.mu.Lock()
d.cfg.Wake = list
cfg := d.cfg
d.mu.Unlock()
if err := saveConfig(d.cfgPath, cfg); err != nil {
d.logf("saving config: %v", err)
}
io.WriteString(w, "ok\n")
}
// handleWake sends the wake-up packet to one of the listed devices. Only
// listed devices: the page is not a tool for poking arbitrary addresses.
func (d *daemon) handleWake(w http.ResponseWriter, r *http.Request) {
mac, err := parseMAC(r.URL.Query().Get("mac"))
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
d.mu.Lock()
name := ""
for _, t := range d.cfg.Wake {
if t.MAC == mac.String() {
name = t.Name
}
}
d.mu.Unlock()
if name == "" {
http.Error(w, "that device is not in the list; save it first", http.StatusNotFound)
return
}
sent, err := sendWake(mac)
if err != nil {
d.logf("wake %s: %v", name, err)
http.Error(w, "could not send the wake-up packet: "+err.Error(), http.StatusInternalServerError)
return
}
d.logf("wake-up packet sent to %s (%d sends)", name, sent)
io.WriteString(w, "Wake-up packet sent to "+name+". Give it half a minute.\n")
}
+61 -9
View File
@@ -1,6 +1,7 @@
package main
import (
"context"
_ "embed"
"encoding/json"
"fmt"
@@ -59,6 +60,8 @@ type statusInfo struct {
// UserForwards are the forwards the user set up, as opposed to the ones
// that belong to a Sunshine host.
UserForwards []forwardRule `json:"userForwards"`
// Wake lists the devices the page can wake.
Wake []wakeTarget `json:"wake"`
// DNS says where the daemon looks names up.
DNS string `json:"dns,omitempty"`
// UDPPorts are the console's UDP ports reachable from the tailnet.
@@ -114,16 +117,20 @@ func (d *daemon) webHandler() http.Handler {
mux.HandleFunc("GET /api/config", d.protect(d.handleGetConfig))
mux.HandleFunc("GET /api/files", d.protect(d.handleFiles))
mux.HandleFunc("GET /api/files/get", d.protect(d.handleFileGet))
mux.HandleFunc("GET /api/diagnostics", d.protect(d.handleDiagnostics))
for path, h := range map[string]http.HandlerFunc{
"/api/config": d.handleSetConfig,
"/api/login": d.handleLogin,
"/api/logout": d.handleLogout,
"/api/quit": d.handleQuit,
"/api/uninstall": d.handleUninstall,
"/api/sunshine": d.handleSunshine,
"/api/forwards": d.handleForwards,
"/api/pingpeer": d.handlePingPeer,
"/api/update": d.handleUpdate,
"/api/config": d.handleSetConfig,
"/api/login": d.handleLogin,
"/api/logout": d.handleLogout,
"/api/quit": d.handleQuit,
"/api/uninstall": d.handleUninstall,
"/api/sunshine": d.handleSunshine,
"/api/forwards": d.handleForwards,
"/api/pingpeer": d.handlePingPeer,
"/api/testtarget": d.handleTestTarget,
"/api/wakelist": d.handleWakeList,
"/api/wake": d.handleWake,
"/api/update": d.handleUpdate,
} {
mux.HandleFunc("POST "+path, d.protect(d.guard(h)))
}
@@ -212,6 +219,7 @@ func (d *daemon) handleStatus(w http.ResponseWriter, r *http.Request) {
info.AllowFrom = accessOwn
}
info.UserForwards = append([]forwardRule{}, d.cfg.Forwards...)
info.Wake = append([]wakeTarget{}, d.cfg.Wake...)
for _, h := range d.cfg.SunshineHosts {
info.SunshineHosts = append(info.SunshineHosts, sunshineInfo{Host: h.Host, Port: h.basePort(), Address: h.clientAddress()})
}
@@ -538,3 +546,47 @@ func (d *daemon) checkForwardPorts(rules []forwardRule) (forwardRule, error) {
}
return forwardRule{}, nil
}
// handleTestTarget tries to open a TCP connection to a device and port on
// the tailnet, the way a forward would, and says whether it answered. It
// tells "the device is not reachable" apart from "nothing listens there".
func (d *daemon) handleTestTarget(w http.ResponseWriter, r *http.Request) {
target := strings.TrimSpace(r.URL.Query().Get("target"))
host, port, err := net.SplitHostPort(target)
if err != nil || host == "" || !validHostName(host) || !validPort(port) {
http.Error(w, "the target must be a device and a port", http.StatusBadRequest)
return
}
if d.srv == nil || d.lc == nil {
http.Error(w, "Tailscale is not running yet", http.StatusServiceUnavailable)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
defer cancel()
start := time.Now()
c, err := d.dialTailnet(ctx, "tcp", target)
took := time.Since(start)
out := map[string]any{"ok": err == nil, "ms": float64(took.Microseconds()) / 1000}
if err != nil {
out["error"] = describeDialError(err)
} else {
c.Close()
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(out)
}
// describeDialError puts a failed connection attempt into words a user can
// act on.
func describeDialError(err error) string {
msg := err.Error()
switch {
case strings.Contains(msg, "connection refused"), strings.Contains(msg, "connection was refused"):
return "the device answered, but nothing listens on that port"
case strings.Contains(msg, "deadline exceeded"), strings.Contains(msg, "timeout"), strings.Contains(msg, "timed out"):
return "no answer: the device is off, asleep, or a firewall on it blocks the port"
case strings.Contains(msg, "no such host"), strings.Contains(msg, "lookup"), strings.Contains(msg, "not found"):
return "there is no device with that name on your tailnet"
}
return msg
}