mirror of
https://github.com/holdmysocks/ps5-tailscale.git
synced 2026-10-09 01:00:20 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4986b4d52 | ||
|
|
001837e9c0 | ||
|
|
f3f31e59b9 | ||
|
|
c41c46e9cd | ||
|
|
b31667d3f0 | ||
|
|
edbaaed240 | ||
|
|
4c6d9223b5 | ||
|
|
070e838135 |
No files matched your search
@@ -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 ./...
|
||||
@@ -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.
|
||||
@@ -51,10 +52,14 @@ Tested on firmware 13.42 with elfldr 0.26.
|
||||
|
||||
## Install
|
||||
|
||||
There is one file, `tailscale.elf`, and it is an ordinary payload: running
|
||||
it starts Tailscale.
|
||||
There is one file, `tailscale-<version>.elf` (for example
|
||||
`tailscale-0.6.1.elf`), and it is an ordinary payload: running it starts
|
||||
Tailscale. The examples below call it `tailscale.elf`; use the name of the
|
||||
file you downloaded, or rename it.
|
||||
|
||||
1. Download `tailscale.elf` from the [latest release](../../releases/latest).
|
||||
1. Download `tailscale-<version>.elf` from the
|
||||
[latest release](../../releases/latest). The `.sig` file next to it is
|
||||
for the status page's Install button; you do not need it.
|
||||
2. Send it to the console's ELF loader. Any payload sender works:
|
||||
|
||||
```bash
|
||||
@@ -76,11 +81,20 @@ run also adds a **Tailscale** icon to the home screen (media section) that
|
||||
opens the status page.
|
||||
|
||||
Tailscale runs until the console restarts. After a restart and jailbreak,
|
||||
send `tailscale.elf` again; the login and settings are kept. If you use a
|
||||
payload manager or autoloader, add the file there like any other payload.
|
||||
send the file again; the login and settings are kept. If you use a payload
|
||||
manager or autoloader, add the file there like any other payload.
|
||||
|
||||
To update, use the new `tailscale.elf` in place of the old one. Sending it
|
||||
while Tailscale is running replaces the running copy.
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
@@ -150,11 +164,21 @@ shows a notification once. **Install** downloads the release, checks that it
|
||||
is signed with this project's release key and is the version it claims to be,
|
||||
and starts it; the login and settings are kept. Nothing is ever installed
|
||||
without that button being pressed. It needs an ELF loader on port 9021, like
|
||||
sending the payload by hand does. If you keep `tailscale.elf` in a payload
|
||||
manager or autoloader so that it starts with the console, put that file's
|
||||
path under **Payload file to keep up to date** in the settings, for example
|
||||
`/data/pldmgr/payloads/Tailscale/tailscale.elf`; the update then replaces
|
||||
that copy as well. Otherwise the old version is back after the next restart.
|
||||
sending the payload by hand does.
|
||||
|
||||
Installing from the page replaces the copy that is running, not the file
|
||||
your payload manager or autoloader starts with the console. **Check your
|
||||
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, 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.
|
||||
|
||||
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
|
||||
@@ -163,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
|
||||
@@ -224,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
|
||||
@@ -279,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. |
|
||||
@@ -331,14 +400,30 @@ Left to do by hand:
|
||||
`127.0.0.1` (or `127.0.0.1:<port>` for a host on another port), and the
|
||||
Sunshine host must be listed on the status page with the port its Sunshine
|
||||
uses.
|
||||
- **It stays on "Starting", or the update check never finds anything.** The
|
||||
daemon looks names up itself: at a DNS payload on the console
|
||||
(`127.0.0.1:53`) if one is running, otherwise at your router, otherwise at
|
||||
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. 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
|
||||
|
||||
@@ -354,6 +439,10 @@ Left to do by hand:
|
||||
- The local forwards and the proxy are for the console's own apps and are
|
||||
not exposed to the tailnet.
|
||||
- With update checks on, the console contacts `api.github.com` twice a day.
|
||||
- Name lookups by the daemon go to the console's DNS payload if there is
|
||||
one, otherwise to your router or a public resolver. They are only the
|
||||
daemon's own lookups (Tailscale's servers, GitHub, what you send through
|
||||
the HTTP proxy), not the console's.
|
||||
- An update is only installed when **Install** is pressed, and only if it
|
||||
carries a valid signature made with the project's release key, which is
|
||||
not kept on GitHub. A release put up by someone who got into the GitHub
|
||||
@@ -378,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.
|
||||
|
||||
+5
-3
@@ -65,7 +65,9 @@ the launcher.
|
||||
```
|
||||
|
||||
builds the payload and puts three files in `out\release-1.2.3`:
|
||||
`tailscale.elf`, its signature `tailscale.elf.sig`, and `SHA256SUMS.txt`.
|
||||
`tailscale-1.2.3.elf`, its signature `tailscale-1.2.3.elf.sig`, and
|
||||
`SHA256SUMS.txt`. The version in the file name is what the status page looks
|
||||
for (`payloadAssetName` in `tsd\selfupdate.go`).
|
||||
Attach all three to the GitHub release, and tag it `v1.2.3`. The status
|
||||
page's **Install** button only offers a release that has the first two, and
|
||||
only installs it if the signature is good and is for that very version.
|
||||
@@ -77,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
|
||||
|
||||
+17
-4
@@ -66,7 +66,7 @@ way is a failure in the SDK's crt, which runs before any of this.
|
||||
on the page and announced once on the console; the announced version is
|
||||
kept in `/data/tailscale/update-notified`.
|
||||
- Installing an update (`selfupdate.go`, `relsig/`): on request only. The
|
||||
release's `tailscale.elf.sig` names a version and a SHA-256 and carries an
|
||||
release's `tailscale-<version>.elf.sig` names a version and a SHA-256 and carries an
|
||||
Ed25519 signature over both. The daemon checks the signature against the
|
||||
public key built into it, that the version is the release's and newer than
|
||||
its own, downloads the payload to `/data/tailscale/update/`, compares the
|
||||
@@ -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
|
||||
@@ -163,7 +169,12 @@ apply `SOCK_NONBLOCK`/`SOCK_CLOEXEC` with `fcntl`.
|
||||
blocks and hands the converted entries out across calls.
|
||||
- Unix domain sockets cannot be bound on `/data`.
|
||||
- There is no `/etc/resolv.conf` and no CA bundle. Go's resolver falls back
|
||||
to `127.0.0.1:53`; the daemon imports `x509roots/fallback` for TLS roots.
|
||||
to `127.0.0.1:53`, which only answers if a DNS payload runs on the
|
||||
console. The daemon therefore installs its own resolver (`dns.go`): it
|
||||
probes `127.0.0.1:53`, the default gateway and three public resolvers,
|
||||
uses the first that answers, and checks again every five minutes or when
|
||||
the network changes. The daemon imports `x509roots/fallback` for TLS
|
||||
roots.
|
||||
- A payload's stdin, stdout and stderr are the ELF loader's TCP connection.
|
||||
Go kills a process whose write to fd 1 or 2 fails with `EPIPE`, so the
|
||||
daemon moves that connection to another descriptor and points 1 and 2 at
|
||||
@@ -210,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
|
||||
@@ -226,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: 78 KiB After Width: | Height: | Size: 159 KiB |
+17
-5
@@ -1,13 +1,17 @@
|
||||
# Build the files of a release into out\release-<version>:
|
||||
# tailscale.elf the payload
|
||||
# tailscale.elf.sig its signature, which the status page's "Install" checks
|
||||
# tailscale-<version>.elf the payload
|
||||
# tailscale-<version>.elf.sig its signature, which the status page's "Install" checks
|
||||
# SHA256SUMS.txt
|
||||
#
|
||||
# .\tools\make-release.ps1 -Version 1.2.3
|
||||
#
|
||||
# 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'
|
||||
@@ -19,7 +23,8 @@ if ($Version -notmatch '^\d+\.\d+\.\d+$') { throw "version must look like 1.2.3,
|
||||
|
||||
$rel = Join-Path $DevRoot "out\release-$Version"
|
||||
New-Item -ItemType Directory -Force $rel | Out-Null
|
||||
$elf = Join-Path $rel 'tailscale.elf'
|
||||
$name = "tailscale-$Version.elf" # the name the status page's Install looks for
|
||||
$elf = Join-Path $rel $name
|
||||
Copy-Item (Join-Path $DevRoot 'out\tailscale.elf') $elf -Force
|
||||
|
||||
Push-Location (Join-Path $DevRoot 'tsd')
|
||||
@@ -37,7 +42,14 @@ try {
|
||||
} finally { Pop-Location }
|
||||
|
||||
$hash = (Get-FileHash $elf -Algorithm SHA256).Hash.ToLower()
|
||||
[IO.File]::WriteAllText((Join-Path $rel 'SHA256SUMS.txt'), "$hash tailscale.elf`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,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) }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"net"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"tailscale.com/net/netmon"
|
||||
)
|
||||
|
||||
// Name lookups by the daemon itself: Tailscale's servers, GitHub for the
|
||||
// update check, whatever goes through the HTTP proxy.
|
||||
//
|
||||
// The PS5 has no resolv.conf, so Go asks 127.0.0.1:53. That only works on a
|
||||
// console that runs a DNS payload, which most jailbreak setups do and some do
|
||||
// not. Rather than depend on it, the daemon uses the first of these that
|
||||
// answers: the local DNS payload, the router, a public resolver.
|
||||
|
||||
// dnsLocal is where a DNS payload on the console listens. A variable so that
|
||||
// a test build can pretend there is none.
|
||||
var dnsLocal = "127.0.0.1:53"
|
||||
|
||||
// dnsPublic are used when neither the console nor the router answers.
|
||||
var dnsPublic = []string{"1.1.1.1:53", "8.8.8.8:53", "9.9.9.9:53"}
|
||||
|
||||
const (
|
||||
dnsRecheckGood = 5 * time.Minute // how long a working server is kept
|
||||
dnsRecheckBad = 20 * time.Second // how soon to look again when none works
|
||||
dnsProbeWait = 1200 * time.Millisecond
|
||||
)
|
||||
|
||||
type dnsPicker struct {
|
||||
logf func(format string, args ...any)
|
||||
// candidates lists the servers to try, in order of preference.
|
||||
candidates func() []string
|
||||
// probe reports whether a server answers queries.
|
||||
probe func(ctx context.Context, server string) bool
|
||||
|
||||
mu sync.Mutex
|
||||
server string
|
||||
working bool
|
||||
checked time.Time
|
||||
}
|
||||
|
||||
func newDNSPicker(logf func(format string, args ...any)) *dnsPicker {
|
||||
return &dnsPicker{logf: logf, candidates: dnsCandidates, probe: dnsAnswers}
|
||||
}
|
||||
|
||||
// dnsCandidates returns the local DNS payload, the router and the public
|
||||
// resolvers, in that order.
|
||||
func dnsCandidates() []string {
|
||||
list := []string{dnsLocal}
|
||||
if gw, _, ok := netmon.LikelyHomeRouterIP(); ok && gw.IsValid() {
|
||||
list = append(list, net.JoinHostPort(gw.String(), "53"))
|
||||
}
|
||||
return append(list, dnsPublic...)
|
||||
}
|
||||
|
||||
// pick returns the server to ask. The choice is kept for a while, so the
|
||||
// candidates are not probed for every lookup.
|
||||
func (p *dnsPicker) pick(ctx context.Context) string {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
keep := dnsRecheckBad
|
||||
if p.working {
|
||||
keep = dnsRecheckGood
|
||||
}
|
||||
if p.server != "" && time.Since(p.checked) < keep {
|
||||
return p.server
|
||||
}
|
||||
candidates := p.candidates()
|
||||
chosen, working := candidates[0], false
|
||||
for _, c := range candidates {
|
||||
if p.probe(ctx, c) {
|
||||
chosen, working = c, true
|
||||
break
|
||||
}
|
||||
}
|
||||
if chosen != p.server || working != p.working {
|
||||
switch {
|
||||
case !working:
|
||||
p.logf("DNS: no server answers (tried %v); name lookups will fail until one does", candidates)
|
||||
case chosen == candidates[0]:
|
||||
p.logf("DNS: using the console's own DNS at %s", chosen)
|
||||
default:
|
||||
p.logf("DNS: nothing answers at %s; using %s instead (in order of preference: %v)", candidates[0], chosen, candidates)
|
||||
}
|
||||
}
|
||||
p.server, p.working, p.checked = chosen, working, time.Now()
|
||||
return chosen
|
||||
}
|
||||
|
||||
// reset makes the next lookup choose again, for when the network changed.
|
||||
func (p *dnsPicker) reset() {
|
||||
p.mu.Lock()
|
||||
p.checked = time.Time{}
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
// dial is the resolver's Dial: whatever address Go wants to ask, the chosen
|
||||
// server is asked instead.
|
||||
func (p *dnsPicker) dial(ctx context.Context, network, _ string) (net.Conn, error) {
|
||||
var d net.Dialer
|
||||
return d.DialContext(ctx, network, p.pick(ctx))
|
||||
}
|
||||
|
||||
// install makes every name lookup in the process go through the picker.
|
||||
func (p *dnsPicker) install() {
|
||||
net.DefaultResolver = &net.Resolver{PreferGo: true, Dial: p.dial}
|
||||
}
|
||||
|
||||
// dnsAnswers asks server for the address of a name that certainly exists
|
||||
// and reports whether a proper answer came back.
|
||||
func dnsAnswers(ctx context.Context, server string) bool {
|
||||
ctx, cancel := context.WithTimeout(ctx, dnsProbeWait)
|
||||
defer cancel()
|
||||
var d net.Dialer
|
||||
c, err := d.DialContext(ctx, "udp", server)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer c.Close()
|
||||
c.SetDeadline(time.Now().Add(dnsProbeWait))
|
||||
query := dnsQuery(uint16(time.Now().UnixNano()), "github.com")
|
||||
if _, err := c.Write(query); err != nil {
|
||||
return false
|
||||
}
|
||||
buf := make([]byte, 1500)
|
||||
n, err := c.Read(buf)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return dnsReplyOK(query, buf[:n])
|
||||
}
|
||||
|
||||
// dnsQuery builds a query for the IPv4 address of name.
|
||||
func dnsQuery(id uint16, name string) []byte {
|
||||
q := make([]byte, 12, 64)
|
||||
binary.BigEndian.PutUint16(q[0:], id)
|
||||
q[2] = 0x01 // recursion desired
|
||||
binary.BigEndian.PutUint16(q[4:], 1)
|
||||
start := 0
|
||||
for i := 0; i <= len(name); i++ {
|
||||
if i == len(name) || name[i] == '.' {
|
||||
q = append(q, byte(i-start))
|
||||
q = append(q, name[start:i]...)
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
return append(q, 0, 0, 1, 0, 1) // root label, type A, class IN
|
||||
}
|
||||
|
||||
// dnsReplyOK reports whether reply is a successful answer to query.
|
||||
func dnsReplyOK(query, reply []byte) bool {
|
||||
if len(reply) < 12 || reply[0] != query[0] || reply[1] != query[1] {
|
||||
return false
|
||||
}
|
||||
isResponse := reply[2]&0x80 != 0
|
||||
rcode := reply[3] & 0x0f
|
||||
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)"
|
||||
}
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func testPicker(t *testing.T, candidates []string, answering map[string]bool) (*dnsPicker, *[]string, *[]string) {
|
||||
var logs, probed []string
|
||||
p := &dnsPicker{
|
||||
logf: func(format string, args ...any) { logs = append(logs, format) },
|
||||
candidates: func() []string { return candidates },
|
||||
probe: func(ctx context.Context, server string) bool {
|
||||
probed = append(probed, server)
|
||||
return answering[server]
|
||||
},
|
||||
}
|
||||
return p, &logs, &probed
|
||||
}
|
||||
|
||||
func TestDNSPicker(t *testing.T) {
|
||||
candidates := []string{"127.0.0.1:53", "192.168.1.1:53", "1.1.1.1:53"}
|
||||
answering := map[string]bool{"127.0.0.1:53": true, "192.168.1.1:53": true, "1.1.1.1:53": true}
|
||||
p, logs, probed := testPicker(t, candidates, answering)
|
||||
ctx := context.Background()
|
||||
|
||||
// The console's own DNS is preferred, and the choice is kept.
|
||||
if got := p.pick(ctx); got != "127.0.0.1:53" {
|
||||
t.Fatalf("picked %s", got)
|
||||
}
|
||||
p.pick(ctx)
|
||||
if len(*probed) != 1 {
|
||||
t.Errorf("probed %v; the choice should have been kept", *probed)
|
||||
}
|
||||
|
||||
// No DNS payload: the router is used, and that is logged.
|
||||
answering["127.0.0.1:53"] = false
|
||||
p.reset()
|
||||
if got := p.pick(ctx); got != "192.168.1.1:53" {
|
||||
t.Errorf("without a local DNS: picked %s", got)
|
||||
}
|
||||
if last := (*logs)[len(*logs)-1]; !strings.Contains(last, "instead") {
|
||||
t.Errorf("log: %q", last)
|
||||
}
|
||||
|
||||
// Nor the router: a public resolver.
|
||||
answering["192.168.1.1:53"] = false
|
||||
p.reset()
|
||||
if got := p.pick(ctx); got != "1.1.1.1:53" {
|
||||
t.Errorf("without local DNS or router: picked %s", got)
|
||||
}
|
||||
|
||||
// Nothing at all: stay with the first, and look again soon.
|
||||
answering["1.1.1.1:53"] = false
|
||||
p.reset()
|
||||
if got := p.pick(ctx); got != "127.0.0.1:53" {
|
||||
t.Errorf("with nothing answering: picked %s", got)
|
||||
}
|
||||
if p.working {
|
||||
t.Error("the picker thinks it has a working server")
|
||||
}
|
||||
before := len(*probed)
|
||||
p.checked = time.Now().Add(-dnsRecheckBad - time.Second)
|
||||
answering["127.0.0.1:53"] = true
|
||||
if got := p.pick(ctx); got != "127.0.0.1:53" || len(*probed) == before || !p.working {
|
||||
t.Errorf("after the short wait: picked %s, working %v", got, p.working)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSQueryAndReply(t *testing.T) {
|
||||
q := dnsQuery(0x1234, "github.com")
|
||||
want := []byte{0x12, 0x34, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 6, 'g', 'i', 't', 'h', 'u', 'b', 3, 'c', 'o', 'm', 0, 0, 1, 0, 1}
|
||||
if string(q) != string(want) {
|
||||
t.Fatalf("query = % x", q)
|
||||
}
|
||||
reply := func(id0, id1, flags2, flags3 byte, answers byte) []byte {
|
||||
return []byte{id0, id1, flags2, flags3, 0, 1, 0, answers, 0, 0, 0, 0}
|
||||
}
|
||||
for name, tt := range map[string]struct {
|
||||
r []byte
|
||||
want bool
|
||||
}{
|
||||
"good answer": {reply(0x12, 0x34, 0x81, 0x80, 2), true},
|
||||
"another query's id": {reply(0x12, 0x35, 0x81, 0x80, 2), false},
|
||||
"server failure": {reply(0x12, 0x34, 0x81, 0x82, 0), false},
|
||||
"no such name": {reply(0x12, 0x34, 0x81, 0x83, 0), false},
|
||||
"no answers": {reply(0x12, 0x34, 0x81, 0x80, 0), false},
|
||||
"not a response": {reply(0x12, 0x34, 0x01, 0x00, 1), false},
|
||||
"too short": {[]byte{0x12, 0x34}, false},
|
||||
} {
|
||||
if got := dnsReplyOK(q, tt.r); got != tt.want {
|
||||
t.Errorf("%s: got %v", name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A real exchange over UDP with a stand-in server, and a port where nothing
|
||||
// answers.
|
||||
func TestDNSAnswers(t *testing.T) {
|
||||
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pc.Close()
|
||||
go func() {
|
||||
buf := make([]byte, 1500)
|
||||
for {
|
||||
n, from, err := pc.ReadFrom(buf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
r := append([]byte(nil), buf[:n]...)
|
||||
r[2], r[3], r[7] = 0x81, 0x80, 1 // response, no error, one answer
|
||||
pc.WriteTo(r, from)
|
||||
}
|
||||
}()
|
||||
if !dnsAnswers(context.Background(), pc.LocalAddr().String()) {
|
||||
t.Error("a server that answers was reported as silent")
|
||||
}
|
||||
|
||||
silent, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer silent.Close()
|
||||
start := time.Now()
|
||||
if dnsAnswers(context.Background(), silent.LocalAddr().String()) {
|
||||
t.Error("a silent server was reported as answering")
|
||||
}
|
||||
if time.Since(start) > 3*time.Second {
|
||||
t.Errorf("the probe took %v", time.Since(start))
|
||||
}
|
||||
}
|
||||
+14
-1
@@ -15,6 +15,7 @@ import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -64,6 +65,14 @@ func main() {
|
||||
}
|
||||
logf("ps5-tailscale %s starting, hostname %q, web UI on %s", version, cfg.Hostname, cfg.WebAddr)
|
||||
|
||||
// On the console, name lookups go to whichever DNS server answers; see
|
||||
// dns.go. Elsewhere the system's resolver is left alone.
|
||||
var dns *dnsPicker
|
||||
if runtime.GOOS == "freebsd" {
|
||||
dns = newDNSPicker(logf)
|
||||
dns.install()
|
||||
}
|
||||
|
||||
// A payload that is sent again replaces the running instance, which is
|
||||
// how an upgrade or a restart is done.
|
||||
if stopRunningInstance(cfg.WebAddr) {
|
||||
@@ -92,7 +101,7 @@ func main() {
|
||||
// the very copy that is running now, and it is not needed again.
|
||||
os.RemoveAll(filepath.Join(dataDir, updateDirName))
|
||||
|
||||
d := &daemon{cfg: cfg, cfgPath: filepath.Join(dataDir, "config.json"), logf: logf, debug: debug, console: console, started: time.Now()}
|
||||
d := &daemon{dns: dns, cfg: cfg, cfgPath: filepath.Join(dataDir, "config.json"), logf: logf, debug: debug, console: console, started: time.Now()}
|
||||
if err := d.run(); err != nil {
|
||||
logf("fatal: %v", err)
|
||||
notify("Tailscale failed to start:\n%v", err)
|
||||
@@ -111,6 +120,7 @@ type daemon struct {
|
||||
srv *tsnet.Server
|
||||
lc *local.Client
|
||||
fwd *forwarder
|
||||
dns *dnsPicker // nil when the system's resolver is used
|
||||
udp *udpExposer
|
||||
|
||||
mu sync.Mutex
|
||||
@@ -256,6 +266,9 @@ func (d *daemon) networkChanged() {
|
||||
d.lastNetChange = time.Now()
|
||||
lc := d.lc
|
||||
d.mu.Unlock()
|
||||
if d.dns != nil {
|
||||
d.dns.reset()
|
||||
}
|
||||
if recent || lc == nil {
|
||||
return
|
||||
}
|
||||
|
||||
+75
-2
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+25
-5
@@ -33,7 +33,6 @@ import (
|
||||
var updatePublicKey = "HUcHCXGe3vNEeyt4frmoKZUqYDamdA1dzsr+Hsybda0="
|
||||
|
||||
const (
|
||||
payloadAsset = "tailscale.elf"
|
||||
maxPayload = 128 << 20
|
||||
loaderAddr = "127.0.0.1:9021"
|
||||
updateDirName = "update"
|
||||
@@ -57,7 +56,24 @@ func (d *daemon) setUpdate(p updateProgress) {
|
||||
|
||||
// canInstall reports whether rel can be installed from the status page.
|
||||
func canInstall(rel releaseInfo) bool {
|
||||
return newerVersion(version, rel.Version) && rel.Assets[payloadAsset] != "" && rel.Assets[payloadAsset+relsig.FileSuffix] != ""
|
||||
_, _, _, ok := releaseAssets(rel)
|
||||
return ok && newerVersion(version, rel.Version)
|
||||
}
|
||||
|
||||
// payloadAssetName is what the payload of a release is called: the version is
|
||||
// part of the name, so that a downloaded file says what it is.
|
||||
func payloadAssetName(ver string) string { return "tailscale-" + ver + ".elf" }
|
||||
|
||||
// releaseAssets finds a release's payload and its signature. Releases up to
|
||||
// 0.6.0 called the payload plain "tailscale.elf"; that name is still
|
||||
// understood.
|
||||
func releaseAssets(rel releaseInfo) (name, payloadURL, sigURL string, ok bool) {
|
||||
for _, name := range []string{payloadAssetName(rel.Version), "tailscale.elf"} {
|
||||
if p, s := rel.Assets[name], rel.Assets[name+relsig.FileSuffix]; p != "" && s != "" {
|
||||
return name, p, s, true
|
||||
}
|
||||
}
|
||||
return "", "", "", false
|
||||
}
|
||||
|
||||
// startUpdate begins installing the newest known release. It returns at
|
||||
@@ -96,7 +112,11 @@ func (d *daemon) runUpdate(rel releaseInfo) error {
|
||||
defer cancel()
|
||||
|
||||
// The signature first: it is small, and says what the payload must be.
|
||||
sigBytes, err := httpGetSmall(ctx, rel.Assets[payloadAsset+relsig.FileSuffix])
|
||||
name, payloadURL, sigURL, ok := releaseAssets(rel)
|
||||
if !ok {
|
||||
return errors.New("that release has no signed payload")
|
||||
}
|
||||
sigBytes, err := httpGetSmall(ctx, sigURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("downloading the signature: %w", err)
|
||||
}
|
||||
@@ -109,8 +129,8 @@ func (d *daemon) runUpdate(rel releaseInfo) error {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
file := filepath.Join(dir, payloadAsset)
|
||||
sum, err := d.download(ctx, rel.Assets[payloadAsset], file, rel.Version)
|
||||
file := filepath.Join(dir, name)
|
||||
sum, err := d.download(ctx, payloadURL, file, rel.Version)
|
||||
if err != nil {
|
||||
os.Remove(file)
|
||||
return fmt.Errorf("downloading the payload: %w", err)
|
||||
|
||||
@@ -66,14 +66,18 @@ func TestBuiltInKeyIsValid(t *testing.T) {
|
||||
|
||||
func TestCanInstall(t *testing.T) {
|
||||
withVersion(t, "1.0.0")
|
||||
both := map[string]string{"tailscale.elf": "u1", "tailscale.elf.sig": "u2"}
|
||||
both := map[string]string{"tailscale-1.1.0.elf": "u1", "tailscale-1.1.0.elf.sig": "u2"}
|
||||
oldNames := map[string]string{"tailscale.elf": "u1", "tailscale.elf.sig": "u2"}
|
||||
otherVersion := map[string]string{"tailscale-1.0.9.elf": "u1", "tailscale-1.0.9.elf.sig": "u2"}
|
||||
for _, tt := range []struct {
|
||||
rel releaseInfo
|
||||
want bool
|
||||
}{
|
||||
{releaseInfo{Version: "1.1.0", Assets: both}, true},
|
||||
{releaseInfo{Version: "1.0.0", Assets: both}, false},
|
||||
{releaseInfo{Version: "1.1.0", Assets: map[string]string{"tailscale.elf": "u1"}}, false},
|
||||
{releaseInfo{Version: "1.1.0", Assets: oldNames}, true},
|
||||
{releaseInfo{Version: "1.1.0", Assets: otherVersion}, false},
|
||||
{releaseInfo{Version: "1.1.0", Assets: map[string]string{"tailscale-1.1.0.elf": "u1"}}, false},
|
||||
{releaseInfo{Version: "1.1.0"}, false},
|
||||
} {
|
||||
if got := canInstall(tt.rel); got != tt.want {
|
||||
|
||||
+31
-12
@@ -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 = ""
|
||||
|
||||
+314
-22
@@ -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>
|
||||
@@ -104,6 +118,7 @@
|
||||
<div class="actions" style="margin-top: 12px">
|
||||
<button id="btn-update" class="hidden">Install it</button>
|
||||
</div>
|
||||
<p class="health hidden" id="updatewarn" style="padding-left: 0"></p>
|
||||
<p class="note hidden" id="updatestate" style="margin: 12px 0 0"></p>
|
||||
<p class="msg hidden" id="updatemsg"></p>
|
||||
</section>
|
||||
@@ -127,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>
|
||||
@@ -162,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
|
||||
@@ -178,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>
|
||||
@@ -207,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>
|
||||
@@ -226,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">
|
||||
@@ -249,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>
|
||||
@@ -344,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';
|
||||
@@ -357,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;
|
||||
}
|
||||
|
||||
@@ -377,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 = [];
|
||||
@@ -384,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;
|
||||
@@ -400,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);
|
||||
@@ -418,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 {
|
||||
@@ -444,6 +625,12 @@ async function refresh() {
|
||||
}
|
||||
$('btn-update').classList.toggle('hidden', !s.canUpdate || busy);
|
||||
$('btn-update').textContent = 'Install ' + (s.latestVersion || 'it');
|
||||
// Installing replaces the running copy. What starts with the console is
|
||||
// a file somewhere else, which the user has to keep an eye on.
|
||||
autoloadWarning = s.payloadPath
|
||||
? 'Installing also replaces the copy at ' + s.payloadPath + '. Afterwards, check your autoload settings: your payload manager or autoloader must still start that file.'
|
||||
: 'After installing, check your autoload settings. If a payload manager or autoloader starts an older Tailscale file with the console, the old version comes back after a restart: replace that file with the new release, or name it under Settings ("Payload file to keep up to date") before installing.';
|
||||
show('updatewarn', s.canUpdate && !busy ? autoloadWarning : '');
|
||||
show('updatestate', {
|
||||
downloading: 'Downloading version ' + upd.version + (upd.percent ? ' (' + upd.percent + '%)' : '') + '…',
|
||||
verifying: 'Checking the signature of version ' + upd.version + '…',
|
||||
@@ -466,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');
|
||||
@@ -477,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');
|
||||
}
|
||||
@@ -506,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;
|
||||
@@ -515,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();
|
||||
@@ -575,9 +781,10 @@ async function refreshFiles() {
|
||||
}
|
||||
|
||||
let updateRefused = ''; // why the daemon would not start an update
|
||||
let autoloadWarning = '';
|
||||
$('btn-update').onclick = async () => {
|
||||
const v = $('btn-update').textContent.replace('Install ', '');
|
||||
if (!confirm('Download and install version ' + v + '?\n\nTailscale on this PS5 restarts, which interrupts a stream or Remote Play session that runs through it.')) return;
|
||||
if (!confirm('Download and install version ' + v + '?\n\nTailscale on this PS5 restarts, which interrupts a stream or Remote Play session that runs through it.\n\n' + autoloadWarning)) return;
|
||||
updateRefused = '';
|
||||
try {
|
||||
const r = await api('/api/update', {method: 'POST'});
|
||||
@@ -611,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 () => {
|
||||
@@ -636,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;
|
||||
@@ -653,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,
|
||||
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
+164
-10
@@ -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"`
|
||||
@@ -68,9 +78,11 @@ type statusInfo struct {
|
||||
UpdateURL string `json:"updateURL,omitempty"`
|
||||
// CanUpdate says that the newer release can be installed from the page;
|
||||
// Update reports on an installation in progress.
|
||||
CanUpdate bool `json:"canUpdate"`
|
||||
Update updateProgress `json:"update"`
|
||||
Uptime int64 `json:"uptimeSeconds"`
|
||||
CanUpdate bool `json:"canUpdate"`
|
||||
// PayloadPath is the copy an update replaces as well, if one is set.
|
||||
PayloadPath string `json:"payloadPath,omitempty"`
|
||||
Update updateProgress `json:"update"`
|
||||
Uptime int64 `json:"uptimeSeconds"`
|
||||
}
|
||||
|
||||
// webHandler builds the status page and its API.
|
||||
@@ -105,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)))
|
||||
}
|
||||
@@ -200,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()})
|
||||
}
|
||||
@@ -208,6 +228,10 @@ 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{}
|
||||
if d.udp != nil {
|
||||
@@ -436,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
|
||||
}
|
||||
Reference in new issue
Block a user