5 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
holdmysocks 001837e9c0 Add a panel for reaching tailnet devices from the PS5; show how devices are connected
- "Reach a device from this PS5" on the status page sets up the local
  forwards that used to be a text box in the settings: pick a device and a
  port, and the page shows the address to use on the console. The port on
  the console is chosen automatically, and one that is already in use (the
  status page's own, game streaming, another service) is refused.
- The device list says whether each device is reached directly or through
  a relay, and a "test" link measures the connection.
- The page shows which DNS server the daemon uses.
- Tests run on GitHub for every push.
- make-release.ps1 -PlainName also writes the payload as tailscale.elf, the
  name 0.6.0's Install button looks for.
- Screenshot updated.
2026-10-06 19:29:29 -04:00
holdmysocks f3f31e59b9 Keep the signing key in the home directory; rest mode for hours is tested
The configuration directory is AppData on Windows, which a packaged app sees a private copy of: a key created from inside one was invisible to every other program. The key now lives in .ps5-tailscale in the home directory.
2026-10-06 19:03:21 -04:00
holdmysocks c41c46e9cd Update the status page screenshot for 0.6.2 2026-10-06 18:41:16 -04:00
holdmysocks b31667d3f0 Recommend a fixed file name for the copy an autoloader starts 2026-10-06 18:38:48 -04:00
17 changed files with 1509 additions and 76 deletions

No files matched your search

+33
View File
@@ -0,0 +1,33 @@
name: tests
# The daemon's unit tests. They run on an ordinary machine; nothing here
# builds the PS5 payload, which needs the patched Go tree (docs/BUILDING.md).
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
test:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
defaults:
run:
working-directory: tsd
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: tsd/go.mod
cache-dependency-path: tsd/go.sum
- name: gofmt
if: runner.os == 'Linux'
run: test -z "$(gofmt -l .)" || (gofmt -l . && exit 1)
- run: go vet ./...
- run: go test ./...
+89 -24
View File
@@ -27,10 +27,11 @@ The PS5 kernel has no tunnel device, so Tailscale cannot become a system-wide
VPN here. It runs inside one process:
- Games and PSN traffic do **not** go through Tailscale.
- Other apps on the console cannot open connections to tailnet addresses
directly. They can through a *local forward* (see
[game streaming](#game-streaming-moonlight-to-sunshine) and
[configuration](#configuration)).
- Apps and the browser on the console cannot open tailnet addresses, and the
console's own IP address does not change. They can reach a device on your
tailnet through an address on the console itself: see
[reaching a device from the PS5](#reaching-a-device-from-the-ps5) and
[game streaming](#game-streaming-moonlight-to-sunshine).
- No subnet routing, Tailscale SSH or Funnel. Taildrop works for receiving
files, not for sending them.
- The console cannot use an exit node, and it does not offer itself as one.
@@ -87,8 +88,13 @@ To update, use the new file in place of the old one. Sending it while
Tailscale is running replaces the running copy. The file name changes with
every release, so **check your autoload settings after updating**: a payload
manager or autoloader that still points at the old file starts the old
version with the console, or nothing if you deleted it. Renaming the new file
to the name your autoload entry uses avoids touching the entry.
version with the console, or nothing if you deleted it.
The easy way to avoid that: keep the copy your payload manager or autoloader
starts under a fixed name without a version, such as `tailscale.elf`. When
you update, save the new release over it under that same name, and the
autoload entry never needs to change. The status page always shows which
version is really running.
## Using it
@@ -166,10 +172,13 @@ autoload settings after updating**, or the old version is back after the
next restart. To have that file replaced as well, put its path under
**Payload file to keep up to date** in the settings, for example
`/data/pldmgr/payloads/Tailscale/tailscale.elf`. The file keeps the name it
has there, whatever version is in it.
has there, whatever version is in it, so your autoload entry keeps working.
That is one more reason to give that copy a fixed name such as
`tailscale.elf` and not the versioned name it was downloaded under.
Releases up to 0.6.0 cannot install later ones from the page, because the
release files were renamed after 0.6.0; update those by hand once.
0.6.0 looks for a file named plain `tailscale.elf`, which 0.6.1 and 0.6.2 do
not have; 0.7.0 carries it as well, so 0.6.0 can install 0.7.0 from the
page.
**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
@@ -178,6 +187,12 @@ 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**. Click an address to copy it.
Under a device's name the page says how the console currently reaches it:
**direct**, or **relayed** through one of Tailscale's relay servers, which is
slower and worth knowing when a stream stutters. Devices with no recent
traffic show nothing. **test** measures the connection there and then and
shows the round-trip time; it also wakes a connection that was idle.
**Key expiry.** The page shows when the console's Tailscale key expires. By
default that is 180 days after logging in, and an expired key takes the
console off your tailnet until someone presses **Log in again**. From two
@@ -239,11 +254,49 @@ Notes:
the cause was not established.
- Tested with ProsperoLight.
### Other apps on the console
### Reaching a device from the PS5
"Extra forwards" in the settings relay any localhost port to a tailnet host
in the same way, TCP or UDP. One per line, for example
`tcp 127.0.0.1:8096 my-nas:8096`.
The PS5's browser and apps cannot open a tailnet address such as
`100.64.0.4` or `my-nas`. To reach a service on one of your devices from the
console:
1. On the status page, under **Reach a device from this PS5**, press **Add a
device**. Enter the device (its tailnet name or address) and the port the
service uses, and press **Save**.
2. The page shows the address to use on the PS5, for example
`127.0.0.1:8096`. Open that in the PS5's browser, or enter it in the app.
Each line covers one port of one device, TCP or UDP. The port on the console
is the same as on the device where that is possible; a port below 1024 gets
8000 added (80 becomes 8080). Pages that redirect to their own name will not
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
that have their own proxy setting. It is off unless you give it an address in
@@ -294,12 +347,13 @@ optional.
| `httpProxyAddr` | Where the HTTP proxy listens. Empty, the default, is off. |
| `controlURL` | A coordination server other than Tailscale's. File only. |
| `sunshineHosts` | The Sunshine hosts and, where it is not 47989, their port. |
| `forwards` | Extra local forwards: `proto` is `tcp` or `udp`, `listen` a localhost address, `target` a tailnet host and port. |
| `forwards` | What "Reach a device from this PS5" sets up: `proto` is `tcp` or `udp`, `listen` the address on the console, `target` a tailnet device and port. |
| `udpPorts` | The console's UDP ports reachable from the tailnet. Default `[9295, 9296, 9297, 9302]` (Remote Play). `[]` turns inbound UDP off. |
| `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. |
| `payloadPath` | The copy of `tailscale.elf` that is started with the console, if there is one. An update installed from the status page replaces it. Empty: none. |
| `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. |
| `verbose` | Put Tailscale's own log in the main log as well. |
@@ -352,15 +406,24 @@ Left to do by hand:
a public resolver (1.1.1.1, 8.8.8.8, 9.9.9.9). The log says which one it
uses in a line starting with `DNS:`. If none answers, the console has no
working internet connection for payloads. The DNS server set in the PS5's
network settings plays no part.
network settings plays no part. The status page shows the one in use under
"Name lookups".
- **A stream or Remote Play stutters.** Look at the device in the list on the
status page and press **test**. "Relayed" means the two devices could not
connect directly and the traffic takes a detour; that is usually a router
or firewall on one side blocking UDP.
- **"Not logged in" after logging in.** Press **Log in again** for a fresh
link.
- **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
@@ -404,16 +467,18 @@ HTTP proxy, adding and removing the home screen icon, the password from the
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, limiting connections to your own devices (with the
with Taildrop, reaching a device through a forward set up on the page, 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
short stay in rest mode (about a minute: the same process
carried on and was back on the tailnet within a second of waking).
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.
Remote Play through the tailnet address works with Chiaki and with Asobi on
iOS and Android.
Not tested: hours in rest mode, switching between Wi-Fi and Ethernet while
running, 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.
+2 -2
View File
@@ -79,8 +79,8 @@ Releases are signed with an Ed25519 key. Its public half is
that stays out of the repository:
```
%APPDATA%\ps5-tailscale\release-signing.key (Windows)
~/.config/ps5-tailscale/release-signing.key (Linux)
%USERPROFILE%\.ps5-tailscale\release-signing.key (Windows)
~/.ps5-tailscale/release-signing.key (Linux, macOS)
```
`PS5TS_SIGNING_KEY` names another location. The file is not encrypted, so
+10 -2
View File
@@ -81,6 +81,12 @@ way is a failure in the SDK's crt, which runs before any of this.
`state/files/<login>-uid-<n>/`. The daemon long-polls for them, copies each
to `receiveDir` under a name that does not exist yet, and deletes it from
the holding area.
- The device list says how each peer is reached, from the peer's status:
a current direct address means direct, otherwise the relay region. The
"test" link runs a disco ping (`LocalClient.Ping`), which measures the path
WireGuard would use without sending IP traffic, and reports the latency.
- Tests run on GitHub for every push (`.github/workflows/test.yml`), on
Linux and Windows. They do not build the payload.
- Who may connect (`access.go`): with `allowFrom` set to `own`, the TCP
handler and the UDP relays ask Tailscale who the sender is (WhoIs) and
serve only nodes of the same user as the console, from the console's own
@@ -215,7 +221,7 @@ threads take turns. With it, the same test passes (5 collections in about
## Rest mode
Observed once, for a rest of about a minute on Ethernet. The process is not
Observed first for a rest of about a minute on Ethernet. The process is not
killed: it is frozen with the rest of the console and continues afterwards.
- Going to sleep, the network is taken down first. Every socket fails with
@@ -231,7 +237,9 @@ killed: it is frozen with the rest of the console and continues afterwards.
answered through the tailnet address afterwards without anything being
restarted.
A rest of hours has not been tried, nor one on Wi-Fi.
A rest of nine and a half hours went the same way: the monitor reported the
time jump on waking and the same process carried on. Rest mode on Wi-Fi has
not been tried.
## Home screen icon
Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 159 KiB

+13 -2
View File
@@ -7,7 +7,11 @@
#
# Needs the release signing key on this machine (see docs/BUILDING.md).
param(
[Parameter(Mandatory = $true)][string]$Version
[Parameter(Mandatory = $true)][string]$Version,
# Also write the payload and its signature under the plain names
# tailscale.elf and tailscale.elf.sig, which is what the Install button of
# 0.6.0 looks for. Attach them as well to let 0.6.0 install this release.
[switch]$PlainName
)
$ErrorActionPreference = 'Stop'
@@ -38,7 +42,14 @@ try {
} finally { Pop-Location }
$hash = (Get-FileHash $elf -Algorithm SHA256).Hash.ToLower()
[IO.File]::WriteAllText((Join-Path $rel 'SHA256SUMS.txt'), "$hash $name`n")
$sums = "$hash $name`n"
if ($PlainName) {
# The same bytes, so the same signature is valid for both.
Copy-Item $elf (Join-Path $rel 'tailscale.elf') -Force
Copy-Item "$elf.sig" (Join-Path $rel 'tailscale.elf.sig') -Force
$sums += "$hash tailscale.elf`n"
}
[IO.File]::WriteAllText((Join-Path $rel 'SHA256SUMS.txt'), $sums)
Get-ChildItem $rel | Select-Object Name, Length | Format-Table -AutoSize
Write-Host "release files are in $rel"
+7 -4
View File
@@ -7,8 +7,8 @@
// go run ./cmd/signrelease backup -out FILE passphrase-protected copy
// go run ./cmd/signrelease restore -in FILE bring a backup onto this machine
//
// The key lives outside the repository, by default in the user's
// configuration directory; PS5TS_SIGNING_KEY names another file. It is kept
// The key lives outside the repository, by default in .ps5-tailscale in the
// user's home directory; PS5TS_SIGNING_KEY names another file. It is kept
// unencrypted there so that releases can be made without typing anything.
// A backup is encrypted with a passphrase and is meant for somewhere else: a
// NAS, a USB stick, a password manager.
@@ -79,11 +79,14 @@ func keyPath() (string, error) {
if p := os.Getenv("PS5TS_SIGNING_KEY"); p != "" {
return p, nil
}
dir, err := os.UserConfigDir()
// The home directory itself, not the configuration directory: on Windows
// that is AppData, which packaged apps see a private copy of, so a key
// created from inside one would be invisible everywhere else.
dir, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(dir, "ps5-tailscale", "release-signing.key"), nil
return filepath.Join(dir, ".ps5-tailscale", "release-signing.key"), nil
}
func b64(b []byte) string { return base64.StdEncoding.EncodeToString(b) }
+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))
}
+27
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/binary"
"net"
"slices"
"sync"
"time"
@@ -162,3 +163,29 @@ func dnsReplyOK(query, reply []byte) bool {
answers := binary.BigEndian.Uint16(reply[6:])
return isResponse && rcode == 0 && answers > 0
}
// describe says in words which server is in use, for the status page. It
// does not probe.
func (p *dnsPicker) describe() string {
p.mu.Lock()
server, working := p.server, p.working
p.mu.Unlock()
if server == "" {
return ""
}
host, _, err := net.SplitHostPort(server)
if err != nil {
host = server
}
if !working {
return "no server answers"
}
switch {
case server == dnsLocal:
return host + " (DNS payload on this console)"
case slices.Contains(dnsPublic, server):
return host + " (public resolver)"
default:
return host + " (router)"
}
}
+75 -2
View File
@@ -1,10 +1,17 @@
package main
import (
"context"
"encoding/json"
"net/http"
"net/netip"
"sort"
"strings"
"time"
"tailscale.com/ipn/ipnstate"
"tailscale.com/net/tsaddr"
"tailscale.com/tailcfg"
)
// The device list of the status page. A tailnet with a VPN add-on has
@@ -31,8 +38,13 @@ type peerInfo struct {
// 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"`
Location string `json:"location,omitempty"`
// Conn says how traffic to the device travels right now: "direct",
// "relay" with Via naming the relay, or empty when there has been no
// traffic to it lately.
Conn string `json:"conn,omitempty"`
Via string `json:"via,omitempty"`
Tags []string `json:"tags,omitempty"`
}
// peerCount counts the peers of one kind.
@@ -77,6 +89,7 @@ func newPeerInfo(p *ipnstate.PeerStatus, suffix string) peerInfo {
case p.ExitNodeOption:
pi.ExitNode = "offered"
}
pi.Conn, pi.Via = peerConn(p)
if l := p.Location; l != nil {
parts := []string{}
for _, s := range []string{l.City, l.Country} {
@@ -118,3 +131,63 @@ func peersFromStatus(st *ipnstate.Status, withVPN bool) (peers []peerInfo, vpn p
})
return peers, vpn
}
// peerConn says how the console currently reaches a peer. A direct
// connection goes straight between the two devices; a relayed one goes
// through one of Tailscale's relay servers, or through a peer acting as one,
// which is slower and is the first thing to look at when a stream stutters.
func peerConn(p *ipnstate.PeerStatus) (conn, via string) {
switch {
case !p.Online || !p.Active:
return "", ""
case p.CurAddr != "":
return "direct", ""
case p.PeerRelay != "":
return "relay", "a peer relay"
case p.Relay != "":
return "relay", p.Relay
}
return "", ""
}
// pingResult is the answer of a connection test from the status page.
type pingResult struct {
Conn string `json:"conn"`
Via string `json:"via,omitempty"`
LatencyMS float64 `json:"latencyMs"`
}
// handlePingPeer measures the connection to one device of the tailnet.
func (d *daemon) handlePingPeer(w http.ResponseWriter, r *http.Request) {
ip, err := netip.ParseAddr(r.URL.Query().Get("ip"))
if err != nil || !tsaddr.IsTailscaleIP(ip) {
http.Error(w, "not a tailnet address", http.StatusBadRequest)
return
}
if 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()
res, err := d.lc.Ping(ctx, ip, tailcfg.PingDisco)
if err != nil {
http.Error(w, "no answer: "+err.Error(), http.StatusGatewayTimeout)
return
}
if res.Err != "" {
http.Error(w, "no answer: "+res.Err, http.StatusGatewayTimeout)
return
}
out := pingResult{LatencyMS: res.LatencySeconds * 1000}
switch {
case res.Endpoint != "":
out.Conn = "direct"
case res.PeerRelay != "":
out.Conn, out.Via = "relay", "a peer relay"
default:
out.Conn, out.Via = "relay", res.DERPRegionCode
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(out)
}
+203
View File
@@ -0,0 +1,203 @@
package main
import (
"bytes"
"encoding/json"
"net"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"tailscale.com/ipn/ipnstate"
)
func TestValidateForwards(t *testing.T) {
ok := []forwardRule{
{"tcp", "127.0.0.1:8096", "my-nas:8096"},
{"udp", "127.0.0.1:8096", "my-nas:8096"}, // same port, other protocol
{"tcp", "127.0.0.1:8080", "100.64.0.4:80"},
{"tcp", "127.0.0.1:8443", "[fd7a:115c:a1e0::4]:443"},
}
if err := validateForwards(ok); err != nil {
t.Errorf("good rules refused: %v", err)
}
for name, rules := range map[string][]forwardRule{
"unknown protocol": {{"icmp", "127.0.0.1:1", "a:1"}},
"no port on the device": {{"tcp", "127.0.0.1:8096", "my-nas"}},
"no device": {{"tcp", "127.0.0.1:8096", ":8096"}},
"bad local address": {{"tcp", "8096", "my-nas:8096"}},
"port 0": {{"tcp", "127.0.0.1:8096", "my-nas:0"}},
"same local port twice": {{"tcp", "127.0.0.1:8096", "a:1"}, {"tcp", "127.0.0.1:8096", "b:2"}},
"odd characters": {{"tcp", "127.0.0.1:8096", "my nas;rm:80"}},
} {
if err := validateForwards(rules); err == nil {
t.Errorf("%s: accepted", name)
}
}
}
func TestHandleForwards(t *testing.T) {
dir := t.TempDir()
d := &daemon{cfg: defaultConfig(), cfgPath: filepath.Join(dir, "config.json"), logf: t.Logf}
d.fwd = newForwarder(nil, t.Logf)
free := freePort(t)
post := func(body string) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
d.handleForwards(rec, httptest.NewRequest("POST", "/api/forwards", strings.NewReader(body)))
return rec
}
body, _ := json.Marshal([]forwardRule{{" TCP ", free, " my-nas:8096 "}})
if rec := post(string(body)); rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body)
}
if len(d.cfg.Forwards) != 1 || d.cfg.Forwards[0] != (forwardRule{"tcp", free, "my-nas:8096"}) {
t.Errorf("config = %+v", d.cfg.Forwards)
}
if got := d.fwd.rules(); len(got) != 1 {
t.Errorf("active forwards = %v", got)
}
saved, err := loadConfig(d.cfgPath)
if err != nil || len(saved.Forwards) != 1 {
t.Errorf("saved config: %+v, %v", saved.Forwards, err)
}
if rec := post(`[{"proto":"tcp","listen":"x","target":"y"}]`); rec.Code != http.StatusBadRequest {
t.Errorf("a bad rule: status %d", rec.Code)
}
if len(d.cfg.Forwards) != 1 {
t.Error("a refused request changed the config")
}
// An empty list removes them all.
if rec := post(`[]`); rec.Code != http.StatusOK || len(d.cfg.Forwards) != 0 || len(d.fwd.rules()) != 0 {
t.Errorf("clearing: status %d, %v", rec.Code, d.cfg.Forwards)
}
d.fwd.set(nil)
}
// The settings form no longer carries the forwards; saving it must not
// wipe them.
func TestSettingsLeaveForwardsAlone(t *testing.T) {
dir := t.TempDir()
cfg := defaultConfig()
cfg.Forwards = []forwardRule{{"tcp", freePort(t), "my-nas:8096"}}
d := &daemon{cfg: cfg, cfgPath: filepath.Join(dir, "config.json"), logf: t.Logf}
d.fwd = newForwarder(nil, t.Logf)
defer d.fwd.set(nil)
s := settingsFromConfig(cfg)
s.Forwards = nil
s.SunshineHosts = nil
body, _ := json.Marshal(s)
rec := httptest.NewRecorder()
d.handleSetConfig(rec, httptest.NewRequest("POST", "/api/config", bytes.NewReader(body)))
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body)
}
if len(d.cfg.Forwards) != 1 {
t.Errorf("the forwards were lost: %+v", d.cfg.Forwards)
}
}
func TestPeerConn(t *testing.T) {
for name, tt := range map[string]struct {
p ipnstate.PeerStatus
conn, via string
}{
"direct": {ipnstate.PeerStatus{Online: true, Active: true, CurAddr: "192.168.1.5:41641", Relay: "nyc"}, "direct", ""},
"relayed": {ipnstate.PeerStatus{Online: true, Active: true, Relay: "nyc"}, "relay", "nyc"},
"peer relay": {ipnstate.PeerStatus{Online: true, Active: true, PeerRelay: "1.2.3.4:5:6", Relay: "nyc"}, "relay", "a peer relay"},
"no traffic": {ipnstate.PeerStatus{Online: true, Relay: "nyc"}, "", ""},
"offline": {ipnstate.PeerStatus{Active: true, CurAddr: "192.168.1.5:41641"}, "", ""},
"active, no route": {ipnstate.PeerStatus{Online: true, Active: true}, "", ""},
} {
if conn, via := peerConn(&tt.p); conn != tt.conn || via != tt.via {
t.Errorf("%s: got %q %q", name, conn, via)
}
}
}
func TestPingPeerRefusesOtherAddresses(t *testing.T) {
d := &daemon{logf: t.Logf}
for _, ip := range []string{"", "8.8.8.8", "192.168.1.1", "not-an-ip", "127.0.0.1"} {
rec := httptest.NewRecorder()
d.handlePingPeer(rec, httptest.NewRequest("POST", "/api/pingpeer?ip="+ip, nil))
if rec.Code != http.StatusBadRequest {
t.Errorf("%q: status %d", ip, rec.Code)
}
}
}
func TestDNSDescribe(t *testing.T) {
p := &dnsPicker{}
if got := p.describe(); got != "" {
t.Errorf("before any lookup: %q", got)
}
for server, want := range map[string]string{
dnsLocal: "DNS payload",
"192.168.1.1:53": "router",
"1.1.1.1:53": "public",
} {
p.server, p.working = server, true
if got := p.describe(); !strings.Contains(got, want) {
t.Errorf("%s: %q", server, got)
}
}
p.working = false
if got := p.describe(); !strings.Contains(got, "no server") {
t.Errorf("not working: %q", got)
}
}
func TestCheckForwardPorts(t *testing.T) {
// Something else listening on the console.
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
busy := ln.Addr().String()
free := freePort(t)
cfg := defaultConfig()
cfg.SunshineHosts = []sunshineHost{{Host: "gaming-pc"}}
d := &daemon{cfg: cfg, logf: t.Logf, webPort: 8090, proxyPort: 8118}
d.fwd = newForwarder(nil, t.Logf)
defer d.fwd.set(nil)
for name, tt := range map[string]struct {
rule forwardRule
ok bool
}{
"a free port": {forwardRule{"tcp", free, "nas:80"}, true},
"the status page's port": {forwardRule{"tcp", "127.0.0.1:8090", "nas:80"}, false},
"the proxy's port": {forwardRule{"tcp", "127.0.0.1:8118", "nas:80"}, false},
"a game streaming port": {forwardRule{"tcp", "127.0.0.1:47989", "nas:80"}, false},
"a game streaming UDP port": {forwardRule{"udp", "127.0.0.1:47998", "nas:80"}, false},
"UDP on the page's port": {forwardRule{"udp", "127.0.0.1:8090", "nas:80"}, true},
"a port something else has": {forwardRule{"tcp", busy, "nas:80"}, false},
} {
rule, err := d.checkForwardPorts([]forwardRule{tt.rule})
if (err == nil) != tt.ok {
t.Errorf("%s: %v", name, err)
}
if err != nil && rule != tt.rule {
t.Errorf("%s: blamed %v", name, rule)
}
}
// A forward that is already running may stay, and may be pointed
// somewhere else, although its port is of course in use: by us.
running := forwardRule{"tcp", free, "nas:80"}
if err := d.fwd.set([]forwardRule{running}); err != nil {
t.Fatal(err)
}
if _, err := d.checkForwardPorts([]forwardRule{running}); err != nil {
t.Errorf("a running forward was refused: %v", err)
}
if _, err := d.checkForwardPorts([]forwardRule{{"tcp", free, "laptop:8080"}}); err != nil {
t.Errorf("retargeting a running forward was refused: %v", err)
}
}
+31 -12
View File
@@ -98,17 +98,8 @@ func (s *settings) validate() error {
if err := validateSunshineHosts(s.SunshineHosts); err != nil {
return err
}
for _, f := range s.Forwards {
if f.Proto != "tcp" && f.Proto != "udp" {
return fmt.Errorf("forward %v: the protocol must be tcp or udp", f)
}
if err := validListenAddr(f.Listen); err != nil {
return fmt.Errorf("forward %v: listen address: %w", f, err)
}
host, port, err := net.SplitHostPort(f.Target)
if err != nil || host == "" || !validHostName(host) || !validPort(port) {
return fmt.Errorf("forward %v: the target must be host:port", f)
}
if err := validateForwards(s.Forwards); err != nil {
return err
}
if slices.Contains(s.UDPPorts, 0) || slices.Contains(s.BlockedPorts, 0) {
return fmt.Errorf("0 is not a port")
@@ -139,6 +130,30 @@ func (s *settings) validate() error {
return nil
}
// validateForwards checks a list of local forwards.
func validateForwards(rules []forwardRule) error {
seen := map[string]bool{}
for _, f := range rules {
if f.Proto != "tcp" && f.Proto != "udp" {
return fmt.Errorf("forward %v: the protocol must be tcp or udp", f)
}
if err := validListenAddr(f.Listen); err != nil {
return fmt.Errorf("forward %v: listen address: %w", f, err)
}
host, port, err := net.SplitHostPort(f.Target)
if err != nil || host == "" || !validHostName(host) || !validPort(port) {
return fmt.Errorf("forward %v: the target must be a device and a port", f)
}
_, lport, _ := net.SplitHostPort(f.Listen)
if key := f.Proto + " " + lport; seen[key] {
return fmt.Errorf("two forwards use %s port %s on the console", f.Proto, lport)
} else {
seen[key] = true
}
}
return nil
}
func validTailnetName(s string) bool {
if len(s) == 0 || len(s) > 63 || s[0] == '-' || s[len(s)-1] == '-' {
return false
@@ -214,7 +229,11 @@ func (d *daemon) handleSetConfig(w http.ResponseWriter, r *http.Request) {
// panel of their own.
cfg.SunshineHosts = s.SunshineHosts
}
cfg.Forwards = s.Forwards
if s.Forwards != nil {
// Like the Sunshine hosts, the forwards have a panel of their own
// and are left alone when the settings form does not send them.
cfg.Forwards = s.Forwards
}
cfg.UDPPorts = s.UDPPorts
cfg.BlockedPorts = s.BlockedPorts
cfg.Priority = ""
+305 -21
View File
@@ -55,6 +55,10 @@
/* Label above value, so that names and addresses get the full width. */
dl { grid-template-columns: 1fr; gap: 0; }
dt { font-size: 13px; margin-top: 10px; }
/* 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 {
@@ -90,6 +94,16 @@
.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; }
.hostrow select { flex: 0 0 auto; width: auto; }
.fwd { margin-bottom: 12px; }
.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>
@@ -128,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>
@@ -163,6 +192,22 @@
</details>
</section>
<section class="panel hidden" id="reachpanel">
<h2>Reach a device from this PS5</h2>
<p class="note">Apps and the browser on the PS5 cannot open tailnet addresses. Add the device and the port you
need, and on the PS5 use the address shown for it instead.</p>
<div id="fwdrows"></div>
<div class="actions">
<button id="btn-addfwd" class="small">Add a device</button>
<button id="btn-fwds">Save</button>
</div>
<p class="hint">Example: a media server on port 8096 of <i>my-nas</i> becomes <code>127.0.0.1:8096</code> on the
PS5. Pages that insist on their own name, and HTTPS sites, may not work this way. Game streaming has its own
panel below.</p>
<p class="okmsg hidden" id="fwdok"></p>
<p class="msg hidden" id="fwdmsg"></p>
</section>
<section class="panel hidden" id="streampanel">
<h2>Game streaming (Moonlight to Sunshine)</h2>
<p class="note">Apps on the PS5 cannot reach tailnet addresses directly. List the devices that run Sunshine and
@@ -179,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>
@@ -208,10 +269,6 @@
<input type="text" id="set-blocked" autocomplete="off">
<span class="hint">Comma separated. Every other open TCP port on the console is reachable.</span>
</label>
<label class="field">Extra forwards from the console to tailnet hosts
<textarea id="set-forwards" spellcheck="false"></textarea>
<span class="hint">One per line: <code>tcp 127.0.0.1:8096 my-nas:8096</code> (protocol, local address, tailnet host and port).</span>
</label>
<label class="field">HTTP proxy address
<input type="text" id="set-proxy" autocomplete="off" placeholder="Off">
<span class="hint">Empty is off. Example: <code>127.0.0.1:8118</code>. Do not set it as the PS5's system proxy.</span>
@@ -227,9 +284,11 @@
</label>
<label class="field">Payload file to keep up to date
<input type="text" id="set-payloadpath" autocomplete="off" placeholder="None">
<span class="hint">Where the copy of <code>tailscale.elf</code> that starts with the console is kept, for
example <code>/data/pldmgr/payloads/Tailscale/tailscale.elf</code>. Installing an update from this
page then replaces that file too. Empty: only the running copy is updated, until the next restart.</span>
<span class="hint">Where the copy that starts with the console is kept, for example
<code>/data/pldmgr/payloads/Tailscale/tailscale.elf</code>. Installing an update from this page then
replaces that file too; it keeps its name, and your autoload entry keeps working. Give that copy a
fixed name such as <code>tailscale.elf</code>, without a version in it, so the name stays true.
Empty: only the running copy is updated, until the next restart.</span>
</label>
<label class="field">Status page address
<input type="text" id="set-webaddr" autocomplete="off">
@@ -250,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>
@@ -345,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';
@@ -358,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;
}
@@ -378,6 +474,28 @@ function peerMatches(p, words) {
return words.every(w => text.includes(w));
}
// How the console reaches a device: straight, or through a relay server,
// which is slower. Measured on request, since measuring wakes the connection.
const pings = {}; // tailnet address -> last test result, as text
function connText(conn, via) {
return conn === 'direct' ? 'direct' : conn === 'relay' ? 'relayed' + (via ? ' via ' + via : '') : '';
}
async function testPeer(ip) {
pings[ip] = 'testing…'; shownPeers = ''; renderPeers();
try {
const r = await api('/api/pingpeer?ip=' + encodeURIComponent(ip), {method: 'POST'});
if (!r.ok) throw new Error((await r.text()).trim() || r.statusText);
const p = await r.json();
pings[ip] = connText(p.conn, p.via) + ', ' + (p.latencyMs < 10 ? p.latencyMs.toFixed(1) : Math.round(p.latencyMs)) + ' ms';
} catch (e) {
pings[ip] = e.message === 'locked' ? '' : 'no answer';
}
shownPeers = ''; renderPeers();
// A measurement says nothing about a minute from now.
const shown = pings[ip];
setTimeout(() => { if (pings[ip] === shown) { delete pings[ip]; shownPeers = ''; renderPeers(); } }, 60000);
}
function peerRow(p) {
const tr = document.createElement('tr');
const notes = [];
@@ -385,11 +503,23 @@ function peerRow(p) {
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']]) {
if (pings[p.ip]) notes.push(pings[p.ip]);
else if (p.conn) notes.push(connText(p.conn, p.via));
for (const [v, mono, first] of [[p.name, false, true], [p.ip, true], [p.os], [p.online ? 'online' : 'offline']]) {
const td = document.createElement('td');
if (!p.online) td.className = 'off';
if (mono) td.append(v ? copyable(v) : ''); else td.textContent = v;
if (note) { const n = document.createElement('span'); n.className = 'tag'; n.textContent = note; td.append(n); }
if (first && (notes.length || (p.online && p.ip))) {
const n = document.createElement('span'); n.className = 'tag'; n.textContent = notes.join(' · ');
if (p.online && p.ip && pings[p.ip] !== 'testing…') {
if (notes.length) n.append(' · ');
const b = document.createElement('button');
b.type = 'button'; b.className = 'link'; b.textContent = 'test'; b.title = 'Measure the connection to this device';
b.onclick = () => testPeer(p.ip);
n.append(b);
}
td.append(n);
}
tr.append(td);
}
return tr;
@@ -401,7 +531,7 @@ function renderPeers() {
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);
const key = JSON.stringify([shown, pings]);
if (key === shownPeers) return;
shownPeers = key;
const groups = kinds.map(([kind, title]) => [title, shown.filter(p => p.kind === kind)]).filter(g => g[1].length);
@@ -419,6 +549,56 @@ function renderPeers() {
$('peernone').classList.toggle('hidden', shown.length > 0);
}
// "Reach a device from this PS5": forwards from a port on the console to a
// device on the tailnet. A row keeps the local address it was saved with; a
// new row gets one when it is saved.
let fwdsDirty = false;
let lastFwds = '';
function fwdRow(rule) {
const wrap = document.createElement('div');
wrap.className = 'fwd';
wrap.dataset.listen = rule ? rule.listen : '';
const i = rule ? rule.target.lastIndexOf(':') : -1;
const div = document.createElement('div');
div.className = 'hostrow';
const h = document.createElement('input');
h.type = 'text'; h.className = 'host'; h.placeholder = 'Device name or address'; h.value = rule ? rule.target.slice(0, i).replace(/^\[|\]$/g, '') : '';
h.setAttribute('list', 'peernames'); h.setAttribute('aria-label', 'Device'); h.autocomplete = 'off';
const p = document.createElement('input');
p.type = 'number'; p.className = 'port'; p.placeholder = 'Port'; p.min = 1; p.max = 65535;
p.value = rule ? rule.target.slice(i + 1) : ''; p.setAttribute('aria-label', 'Port on the device');
const proto = document.createElement('select');
proto.setAttribute('aria-label', 'Protocol');
for (const v of ['tcp', 'udp']) { const o = document.createElement('option'); o.value = v; o.textContent = v.toUpperCase(); proto.append(o); }
proto.value = rule ? rule.proto : 'tcp';
const x = document.createElement('button');
x.type = 'button'; x.className = 'small'; x.textContent = 'Remove';
x.onclick = () => { wrap.remove(); fwdsDirty = true; };
const use = document.createElement('div');
use.className = 'use';
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; };
p.oninput = proto.onchange = () => { fwdsDirty = true; wrap.dataset.listen = ''; use.textContent = 'The address to use on the PS5 appears here after saving.'; };
div.append(h, p, proto, x);
wrap.append(div, use);
return wrap;
}
// localPortFor picks the port on the console for a new forward: the same
// number as on the device where that is possible, so it is easy to remember.
function localPortFor(port, proto, taken) {
let local = port < 1024 ? port + 8000 : port;
while (taken.has(proto + local) || local === 8090) local++;
taken.add(proto + local);
return local;
}
async function refresh() {
let s;
try {
@@ -473,7 +653,7 @@ async function refresh() {
}
show('keywarn', keyWarn);
const factsNow = JSON.stringify([s.dnsName, s.hostname, s.ips, s.tailnet, s.udpPorts, s.proxy, s.priority, s.allowFrom, keyText]);
const factsNow = JSON.stringify([s.dnsName, s.hostname, s.ips, s.tailnet, s.udpPorts, s.proxy, s.priority, s.allowFrom, keyText, s.dns]);
if (factsNow !== shownFacts) {
shownFacts = factsNow;
const dl = $('facts');
@@ -484,6 +664,7 @@ async function refresh() {
if (s.ips.length) row(dl, 'Reachable from tailnet', 'every open TCP port' + (s.udpPorts.length ? '; UDP ' + s.udpPorts.join(', ') + ' (Remote Play)' : '')
+ (s.allowFrom === 'own' ? '; only from your own devices' : ''));
if (keyText) row(dl, 'Key expires', keyText);
if (s.dns) row(dl, 'Name lookups', s.dns);
if (s.proxy) row(dl, 'HTTP proxy', s.proxy, true);
if (s.priority === 'high') row(dl, 'Priority', 'High');
}
@@ -513,6 +694,12 @@ async function refresh() {
// the user is not in the middle of editing them.
$('streampanel').classList.toggle('hidden', s.state !== 'Running' && !s.sunshineHosts.length);
$('peernames').replaceChildren(...devices.map(p => { const o = document.createElement('option'); o.value = p.name; return o; }));
$('reachpanel').classList.toggle('hidden', s.state !== 'Running' && !s.userForwards.length);
const fwdsNow = JSON.stringify(s.userForwards);
if (!fwdsDirty && fwdsNow !== lastFwds) {
lastFwds = fwdsNow;
$('fwdrows').replaceChildren(...s.userForwards.map(fwdRow));
}
const hostsNow = JSON.stringify(s.sunshineHosts);
if (!hostsDirty && hostsNow !== lastHosts) {
lastHosts = hostsNow;
@@ -522,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();
@@ -619,6 +818,99 @@ $('lockform').onsubmit = async ev => {
refresh();
};
// Forwards to tailnet devices.
$('btn-addfwd').onclick = () => { $('fwdrows').append(fwdRow(null)); fwdsDirty = true; };
$('btn-fwds').onclick = async () => {
show('fwdmsg', ''); show('fwdok', '');
const rows = [...$('fwdrows').children].map(w => ({
host: w.querySelector('.host').value.trim(), port: parseInt(w.querySelector('.port').value, 10) || 0,
proto: w.querySelector('select').value, listen: w.dataset.listen,
})).filter(r => r.host || r.port);
for (const r of rows) {
if (!r.host || !(r.port >= 1 && r.port <= 65535)) { show('fwdmsg', 'Each line needs a device and a port from 1 to 65535.'); return; }
}
// Ports on the console that are spoken for: rows that keep their address.
const taken = new Set(rows.filter(r => r.listen).map(r => r.proto + r.listen.slice(r.listen.lastIndexOf(':') + 1)));
const rules = rows.map(r => ({
proto: r.proto,
listen: r.listen || '127.0.0.1:' + localPortFor(r.port, r.proto, taken),
target: (r.host.includes(':') ? '[' + r.host + ']' : r.host) + ':' + r.port,
}));
try {
for (let attempt = 0; ; attempt++) {
const resp = await api('/api/forwards', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(rules)});
const text = (await resp.text()).trim();
if (resp.ok) break;
// A port on the console that something else uses: if it was picked
// here, pick the next one and try again.
const takenPort = resp.headers.get('X-Port-Taken');
const n = rules.findIndex((r, k) => !rows[k].listen && r.proto + ' ' + r.listen === takenPort);
if (resp.status !== 409 || n < 0 || attempt >= 30) throw new Error(text || resp.statusText);
rules[n].listen = '127.0.0.1:' + localPortFor(parseInt(rules[n].listen.split(':').pop(), 10) + 1, rules[n].proto, taken);
}
show('fwdok', rules.length ? 'Saved. Use the addresses shown on the PS5.' : 'Saved.');
} catch (e) {
if (e.message !== 'locked') show('fwdmsg', e.message);
}
fwdsDirty = false; lastFwds = '';
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 () => {
@@ -644,7 +936,6 @@ async function loadSettings() {
$('set-pwremove-row').classList.toggle('hidden', !c.passwordSet);
$('set-udp').value = c.udpPorts.join(', ');
$('set-blocked').value = c.blockedPorts.join(', ');
$('set-forwards').value = c.forwards.map(f => f.proto + ' ' + f.listen + ' ' + f.target).join('\n');
$('set-proxy').value = c.httpProxyAddr;
$('set-priority').value = c.priority;
$('set-allowfrom').value = c.allowFrom;
@@ -661,17 +952,10 @@ $('settingsform').onsubmit = async ev => {
show('settingsmsg', ''); show('settingsok', '');
const udp = ports($('set-udp').value), blocked = ports($('set-blocked').value);
if (udp.concat(blocked).some(p => !(p >= 1 && p <= 65535))) { show('settingsmsg', 'Ports must be numbers from 1 to 65535.'); return; }
const forwards = [];
for (const line of $('set-forwards').value.split('\n').map(l => l.trim()).filter(l => l)) {
const parts = line.split(/\s+/);
if (parts.length !== 3) { show('settingsmsg', 'Each forward needs three parts: protocol, local address, target. Problem: ' + line); return; }
forwards.push({proto: parts[0].toLowerCase(), listen: parts[1], target: parts[2]});
}
const c = {
hostname: $('set-hostname').value.trim(),
webAddr: $('set-webaddr').value.trim(),
httpProxyAddr: $('set-proxy').value.trim(),
forwards: forwards,
udpPorts: udp,
blockedPorts: blocked,
priority: $('set-priority').value,
+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")
}
+158 -7
View File
@@ -1,13 +1,16 @@
package main
import (
"context"
_ "embed"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
@@ -54,6 +57,13 @@ type statusInfo struct {
// SunshineHosts and Forwards describe the local forwards.
SunshineHosts []sunshineInfo `json:"sunshineHosts"`
Forwards []string `json:"forwards"`
// 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.
UDPPorts []uint16 `json:"udpPorts"`
Priority string `json:"priority"`
@@ -107,14 +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/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)))
}
@@ -202,6 +218,8 @@ func (d *daemon) handleStatus(w http.ResponseWriter, r *http.Request) {
if d.cfg.AllowFrom == accessOwn {
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()})
}
@@ -210,6 +228,9 @@ func (d *daemon) handleStatus(w http.ResponseWriter, r *http.Request) {
info.CanUpdate = canInstall(d.latest)
}
info.Update = d.update
if d.dns != nil {
info.DNS = d.dns.describe()
}
info.PayloadPath = d.cfg.PayloadPath
d.mu.Unlock()
info.UDPPorts = []uint16{}
@@ -439,3 +460,133 @@ func (d *daemon) handleUpdate(w http.ResponseWriter, r *http.Request) {
d.logf("update requested from the status page")
io.WriteString(w, "The update has started.\n")
}
// handleForwards replaces the user's local forwards: localhost ports on the
// console that lead to a device on the tailnet.
func (d *daemon) handleForwards(w http.ResponseWriter, r *http.Request) {
var rules []forwardRule
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&rules); err != nil {
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
return
}
for i := range rules {
rules[i].Proto = strings.ToLower(strings.TrimSpace(rules[i].Proto))
rules[i].Listen = strings.TrimSpace(rules[i].Listen)
rules[i].Target = strings.TrimSpace(rules[i].Target)
}
if err := validateForwards(rules); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if rule, err := d.checkForwardPorts(rules); err != nil {
// The page picks the port on the console by itself, so tell it
// which one to pick again.
w.Header().Set("X-Port-Taken", rule.Proto+" "+rule.Listen)
http.Error(w, err.Error(), http.StatusConflict)
return
}
d.mu.Lock()
d.cfg.Forwards = rules
cfg := d.cfg
d.mu.Unlock()
if err := saveConfig(d.cfgPath, cfg); err != nil {
d.logf("saving config: %v", err)
}
d.logf("forwards set to %v", rules)
if err := d.fwd.set(d.localForwardRules()); err != nil {
// Typically a port on the console that is already in use.
http.Error(w, "saved, but not everything could be started: "+err.Error(), http.StatusConflict)
return
}
io.WriteString(w, "ok\n")
}
// checkForwardPorts refuses forwards whose port on the console already
// belongs to something else. On the PS5 a listener on 127.0.0.1 can be opened
// next to one on every address, and would then take the local connections
// away from it: a forward on the status page's port would cut the console's
// own browser off from the page.
func (d *daemon) checkForwardPorts(rules []forwardRule) (forwardRule, error) {
d.mu.Lock()
webPort, proxyPort := d.webPort, d.proxyPort
sunshine := sunshineRules(d.cfg.SunshineHosts)
d.mu.Unlock()
running := map[forwardRule]bool{}
for _, r := range d.fwd.rules() {
running[r] = true
}
for _, r := range rules {
_, portStr, _ := net.SplitHostPort(r.Listen)
port, _ := strconv.Atoi(portStr)
if r.Proto == "tcp" && (uint16(port) == webPort || (proxyPort != 0 && uint16(port) == proxyPort)) {
return r, fmt.Errorf("port %d on the console is used by this page or the HTTP proxy", port)
}
for _, s := range sunshine {
_, sp, _ := net.SplitHostPort(s.Listen)
if s.Proto == r.Proto && sp == portStr {
return r, fmt.Errorf("%s port %d on the console is used by game streaming", r.Proto, port)
}
}
if r.Proto != "tcp" || running[r] {
continue // one of ours already, or UDP, which cannot be probed
}
taken := false
for other := range running {
if other.Proto == "tcp" && other.Listen == r.Listen {
taken = true // the same local port, pointed somewhere else: ours to reuse
}
}
if taken {
continue
}
if c, err := net.DialTimeout("tcp", net.JoinHostPort("127.0.0.1", portStr), 500*time.Millisecond); err == nil {
c.Close()
return r, fmt.Errorf("port %d on the console is already in use by something else", port)
}
}
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
}