15 Commits
Author SHA1 Message Date
holdmysocks e4986b4d52 Wake-on-LAN, a diagnostics download, connection tests and a note on what to expect
- "Wake a device at home": the status page, reachable from anywhere over
  the tailnet, can make the console broadcast a Wake-on-LAN packet for a
  device in a saved list.
- "Download diagnostics": one text file with the logs, version, firmware
  and settings for bug reports. E-mail addresses, the tailnet's name,
  public IP addresses, keys and the password are removed.
- "test" links for forwards and game streaming hosts open a TCP connection
  through the tailnet and say in plain words how it went.
- A short "what this does, and what it does not" note, opened before the
  first login.
- Screenshot updated.
2026-10-06 19:46:22 -04:00
holdmysocks 001837e9c0 Add a panel for reaching tailnet devices from the PS5; show how devices are connected
- "Reach a device from this PS5" on the status page sets up the local
  forwards that used to be a text box in the settings: pick a device and a
  port, and the page shows the address to use on the console. The port on
  the console is chosen automatically, and one that is already in use (the
  status page's own, game streaming, another service) is refused.
- The device list says whether each device is reached directly or through
  a relay, and a "test" link measures the connection.
- The page shows which DNS server the daemon uses.
- Tests run on GitHub for every push.
- make-release.ps1 -PlainName also writes the payload as tailscale.elf, the
  name 0.6.0's Install button looks for.
- Screenshot updated.
2026-10-06 19:29:29 -04:00
holdmysocks f3f31e59b9 Keep the signing key in the home directory; rest mode for hours is tested
The configuration directory is AppData on Windows, which a packaged app sees a private copy of: a key created from inside one was invisible to every other program. The key now lives in .ps5-tailscale in the home directory.
2026-10-06 19:03:21 -04:00
holdmysocks c41c46e9cd Update the status page screenshot for 0.6.2 2026-10-06 18:41:16 -04:00
holdmysocks b31667d3f0 Recommend a fixed file name for the copy an autoloader starts 2026-10-06 18:38:48 -04:00
holdmysocks edbaaed240 Say which DNS servers were tried when falling back 2026-10-06 18:33:29 -04:00
holdmysocks 4c6d9223b5 Do without a DNS payload; name the release file with its version
- Name lookups no longer depend on a DNS payload at 127.0.0.1:53. The
  daemon probes that address, the default gateway and three public
  resolvers, uses the first that answers, and looks again every five
  minutes or when the network changes.
- The release payload is now tailscale-<version>.elf. The updater looks
  for that name and still understands the old one. Releases up to 0.6.0
  only know the old name and have to be updated by hand once.
- The status page warns, next to Install and before installing, that the
  copy a payload manager or autoloader starts is not the one being
  replaced, unless it is named in the settings.
- Screenshot updated.
2026-10-06 18:25:31 -04:00
holdmysocks 070e838135 Update the status page screenshot for 0.6.0 2026-10-05 09:15:21 -04:00
holdmysocks 02fc73a04d Receive files with Taildrop; install signed updates from the status page
Taildrop: files sent to the console from the user's other devices are moved
from Tailscale's holding area to /data/tailscale/received (a setting),
announced on screen and listed on the status page with download links.

Updates: the status page can install a newer release when asked to. A
release carries tailscale.elf.sig, an Ed25519 signature over its version
and SHA-256; the daemon installs only what verifies against the public key
built into it, is the version the release claims and is newer than itself.
The payload is handed to the ELF loader, and the copy named by the new
payloadPath setting is replaced as well.

- tsd/relsig, tsd/cmd/signrelease: signing, verifying, and keeping the key
  (keygen, passphrase-protected backup and restore).
- tools/make-release.ps1 builds, signs and checksums a release.
- Files are no longer copied to sockets with sendfile, which the PS5
  kernel refuses.
2026-10-05 08:52:31 -04:00
holdmysocks bc19d3c251 Report start-up failures, limit access to own devices, warn about key expiry
- The launcher keeps a log (/data/tailscale/launcher.log) and shows a
  notification when it cannot start. A payload manager does not show what a
  payload prints, so a failed start used to leave no trace. The Go
  runtime's stderr goes to the same file until the daemon opens its log.
- New setting "who on the tailnet may connect": every device the tailnet's
  access rules allow (default), or only devices of the same user as the
  console. Applies to every forwarded TCP port, the UDP ports and the
  status page over the tailnet.
- The status page shows when the console's key expires and warns from two
  weeks before; the console shows a notification at 14, 3 and 1 days.
- A newer release is announced once on the console, not only on the page.
- Status page: click an address to copy it; OS, status and address columns
  no longer break mid-word; the facts stack on narrow screens.
2026-10-05 08:31:24 -04:00
holdmysocks 3e0e872884 Update the status page screenshot for the device filters 2026-10-04 19:37:21 -04:00
holdmysocks 726b9b99c4 Only pipe connections addressed to the console; 0.5.2
The fallback handler piped a tailnet connection to localhost by its port
alone. Only connections to the console's own addresses reach it today, so
nothing was exposed, but the handler now checks the destination address
itself instead of relying on that.

The README explains why the console does not offer itself as an exit node.
2026-10-04 10:40:23 -04:00
holdmysocks 558994fc76 Group, search and filter the device list
A tailnet with a VPN add-on has hundreds of exit servers among its peers,
which buried the real devices on the status page.

- Devices are grouped: this tailnet, shared with you, VPN exit servers.
- VPN exit servers are counted but only listed, and only sent to the page,
  when asked for.
- Search by name, address, OS, tag or place, and an online-only toggle.
- Devices that offer exit-node service are marked.
- The count and the streaming host suggestions leave exit servers out.
2026-10-04 10:22:53 -04:00
holdmysocks ed67b35b52 Add a password, settings page and several streaming hosts
The status page can now be protected with a password and edits the
settings itself, so the config file no longer has to be changed by hand.

- Password for everything on the status page that shows or changes
  something. The console's own browser is exempt. Connections to the page
  from the tailnet are served directly so they are not taken for local.
- Settings form: name, ports, forwards, proxy, priority, update checks.
  Most take effect at once; the page says which need a restart.
- Game streaming: several Sunshine hosts, each with its own port.
- The HTTP proxy is off by default.
- The page says when a newer release exists.
- Uninstall removes the home screen icon.
- Priority setting for streams that stutter under a demanding game.
- After the console's network is reconfigured, Tailscale is asked to
  rebind. Seen working across a short stay in rest mode.
- Favicon, and the device list can be collapsed.
2026-10-02 14:32:30 -04:00
holdmysocks 0d7d8ad4b0 Ship a single payload instead of an installer
tailscale.elf now adds the home screen icon itself, the first time it runs,
by handing a small embedded helper payload to the ELF loader. The separate
installer is gone: nothing is copied to /data/tailscale any more, and the
payload runs from wherever the user keeps it.

Uninstall on the status page now logs out, stops the daemon and deletes its
data directory.
2026-10-02 12:26:10 -04:00
59 changed files with 7076 additions and 744 deletions

No files matched your search

+33
View File
@@ -0,0 +1,33 @@
name: tests
# The daemon's unit tests. They run on an ordinary machine; nothing here
# builds the PS5 payload, which needs the patched Go tree (docs/BUILDING.md).
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
test:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
defaults:
run:
working-directory: tsd
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: tsd/go.mod
cache-dependency-path: tsd/go.sum
- name: gofmt
if: runner.os == 'Linux'
run: test -z "$(gofmt -l .)" || (gofmt -l . && exit 1)
- run: go vet ./...
- run: go test ./...
+294 -81
View File
@@ -27,11 +27,20 @@ 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)).
- No exit node, subnet routing, Tailscale SSH, Taildrop or Funnel.
- 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.
Offering one is doable (it needs no tunnel device), but the PS5 would make
a terrible exit node: every packet would pass through this one low-priority
process, so it would be slow, and it would fall away whenever the console
goes into rest mode, reboots or loses its jailbreak, taking the internet of
every device using it with it. Use a PC, a server or a router on your
tailnet instead.
## Requirements
@@ -43,38 +52,49 @@ Tested on firmware 13.42 with elfldr 0.26.
## Install
1. Download `tailscale-installer.elf` from the
[latest release](../../releases/latest).
2. Send it to the console's ELF loader, once. Any payload sender works:
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-<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
# Linux / macOS
socat -t 60 - TCP:<console-ip>:9021 < tailscale-installer.elf
socat -t 60 - TCP:<console-ip>:9021 < tailscale.elf
```
```powershell
# Windows (script from this repository)
.\tools\ps5send.ps1 -File tailscale-installer.elf -PS5Host <console-ip> -Seconds 70
.\tools\ps5send.ps1 -File tailscale.elf -PS5Host <console-ip>
```
The installer prints what it does. It:
- stores the daemon payload as `/data/tailscale/tailscale.elf`,
- adds a **Tailscale** icon to the home screen (media section),
- starts Tailscale.
3. Open `http://<console-ip>:8090` on a phone or PC. Scan the QR code or
follow the link and log in to Tailscale. If your tailnet uses device
approval, approve the console in the admin console.
The console now has a tailnet address, shown on the status page.
Sending the installer again upgrades and restarts Tailscale. The login is
kept.
The console now has a tailnet address, shown on the status page. The first
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 `/data/tailscale/tailscale.elf` (or the installer) to the ELF loader
again. The installer does not change any payload autoloader; if you use one,
you can add that file to it yourself.
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 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
@@ -85,9 +105,10 @@ want, for example FTP on 2121 or the payload loader on 9021.
- Every TCP port that something on the console listens on is forwarded.
Ports with no listener refuse the connection.
- UDP ports have to be listed, in `udpPorts` in the
[configuration](#configuration). The default list is Remote Play's.
- To keep a TCP port off the tailnet, add it to `blockedPorts`.
- UDP ports have to be listed, under **Settings** on the status page. The
default list is Remote Play's.
- To keep a TCP port off the tailnet, list it under "TCP ports never
exposed" in the settings.
### Remote Play
@@ -110,20 +131,86 @@ ports on the console's tailnet addresses and relays them to the service.
Notes:
- The video passes through the daemon, which runs at the lowest priority so
that it never takes time from a game. Under a demanding game that may show
as stutter.
- Waking the console from rest mode does not work: nothing is running then.
- The video passes through the daemon, which by default runs at the lowest
priority so that it never takes time from a game. If the stream stutters
under a demanding game, set **Priority** to High in the settings and start
Tailscale again.
- Waking the console from rest mode does not work: nothing runs while it
sleeps, so it is not on the tailnet then. Tailscale carries on by itself
once the console is awake again.
### Sending files to the console (Taildrop)
Send a file to the console from any of your own devices with Tailscale's
Taildrop: the share menu on a phone, `Send with Tailscale` on a desktop, or
`tailscale file cp <file> <console name>:`. The file lands in
`/data/tailscale/received` on the console (changeable in the settings), a
notification says so, and the status page lists it under **Received files**
with a download link. A file with the same name as an earlier one gets a
number; nothing is overwritten.
Taildrop only works between devices logged in as the same user; that is
Tailscale's rule. Sending files from the console is not implemented.
### The status page
`http://<console address>:8090`, on the LAN or over the tailnet, or the
**Tailscale** icon on the home screen. It shows the connection state, the
login link, and your devices, and has controls for game streaming, logging
out, stopping and uninstalling.
login link and your devices, and has the game streaming hosts, the settings,
and buttons for logging out, stopping and uninstalling.
It has **no password**, like the console's other homebrew services. Anyone on
your LAN, or on your tailnet if your ACLs allow it, can use it.
**Updates.** When a newer release exists the page says so, and the console
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.
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
be searched (name, address, OS, tag, place) and limited to devices that are
online. If your tailnet has a VPN add-on such as Mullvad, its exit servers
are counted but kept out of the list until you tick **Show VPN exit
servers**. 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
weeks before, the page and a notification on the console warn about it. To
avoid it altogether, open the Tailscale admin console, find the console in
the list of machines and choose **Disable key expiry**.
**Password.** Out of the box the page has no password, like the console's
other homebrew services: anyone on your LAN, or on your tailnet if your ACLs
allow it, can use it. Set one under **Settings**. It is then asked for on
every device except the console itself. If you forget it, delete the
`passwordHash` line from `/data/tailscale/config.json`.
**Settings.** The name on the tailnet, the password, who on the tailnet may
connect, which UDP ports are reachable and which TCP ports are not, extra
forwards, the HTTP proxy, the folder for received files, the payload file to
keep up to date, the priority, and update checks. Most take effect when saved; the page says which
ones need Tailscale to be started again.
### Game streaming (Moonlight to Sunshine)
@@ -132,64 +219,121 @@ else, over Tailscale.
On the PC:
1. Install [Sunshine](https://github.com/LizardByte/Sunshine) and leave it on
its default port (47989).
1. Install [Sunshine](https://github.com/LizardByte/Sunshine).
2. Install Tailscale and log in to the same tailnet as the console.
On the console:
1. Open the status page and find **Game streaming**.
2. Choose the device that runs Sunshine and press **Save**. The page shows
"Forwarding 127.0.0.1 to *your-pc* (7 ports)".
2. Press **Add a host**, enter the device that runs Sunshine and press
**Save**. The page then shows what to enter in Moonlight.
3. In your Moonlight client on the PS5, add a host manually with the address
**`127.0.0.1`**. Do not enter the PC's tailnet address: the client cannot
reach it.
4. Pair as usual: the client shows a PIN, which you enter in Sunshine's web
interface on the PC.
How it works: the daemon listens on `127.0.0.1` on Sunshine's ports (TCP
47984, 47989, 48010 and UDP 47998, 47999, 48000, 48002) and relays them to
the chosen host through Tailscale. To the Moonlight client the Sunshine host
appears to be the console itself.
How it works: the daemon listens on `127.0.0.1` on Sunshine's ports (by
default TCP 47984, 47989, 48010 and UDP 47998, 47999, 48000, 48002) and
relays them to the host through Tailscale. To the Moonlight client the
Sunshine host appears to be the console itself.
More than one host, or a host that does not use the default port:
- If a host's Sunshine is set to another port (Sunshine's "Port" setting),
enter that port next to the host. In Moonlight, add `127.0.0.1:<port>`.
- Several hosts can be forwarded at once, but they all appear on
`127.0.0.1`, so each needs its own port: give every host a different port
in Sunshine, at least 30 apart (for example 47989 and 48989).
Notes:
- One Sunshine host at a time. Change it on the status page at any time.
- A wired connection on the console helps, as with any streaming.
- **Do not change the console's network (Wi-Fi to Ethernet, connection
settings) while a stream is running.** That froze the test console once;
the cause was not established.
- Tested with ProsperoLight.
### Reaching a device from the PS5
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
`forwards` in the [configuration](#configuration) relays any localhost port
to a tailnet host in the same way, TCP or UDP.
The daemon also runs an HTTP proxy on `127.0.0.1:8118` that reaches tailnet
hosts, for apps that have their own proxy setting. **Do not set it as the
PS5's system proxy.** The system then sends everything through it, including
pages on `127.0.0.1`, and it is not running until Tailscale has been loaded.
On the test console that stopped another homebrew tool's page from opening.
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
the settings (for example `127.0.0.1:8118`). **Do not set it as the PS5's
system proxy.** The system then sends everything through it, including pages
on `127.0.0.1`, and it is not running until Tailscale has been loaded. On the
test console that stopped another homebrew tool's page from opening.
## Configuration
`/data/tailscale/config.json` is created on first start. Every field is
optional. Restart Tailscale (send the payload again) to apply edits.
Use **Settings** on the status page. The settings are stored in
`/data/tailscale/config.json`, which can also be edited by hand; start
Tailscale again (send the payload) to apply hand edits. Every field is
optional.
```json
{
"hostname": "ps5",
"authKey": "",
"webAddr": ":8090",
"httpProxyAddr": "127.0.0.1:8118",
"passwordHash": "",
"httpProxyAddr": "",
"controlURL": "",
"sunshineHost": "",
"sunshineHosts": [
{"host": "gaming-pc"},
{"host": "office-pc", "port": 48989}
],
"forwards": [
{"proto": "tcp", "listen": "127.0.0.1:8096", "target": "my-nas:8096"}
],
"udpPorts": [9295, 9296, 9297, 9302],
"blockedPorts": [],
"allowFrom": "",
"receiveDir": "/data/tailscale/received",
"payloadPath": "",
"priority": "",
"checkUpdates": true,
"verbose": false
}
```
@@ -197,14 +341,21 @@ optional. Restart Tailscale (send the payload again) to apply edits.
| Field | Meaning |
| --- | --- |
| `hostname` | The console's name on the tailnet. |
| `authKey` | A Tailscale auth key, to log in without the browser step. |
| `authKey` | A Tailscale auth key, to log in without the browser step. File only. |
| `webAddr` | Where the status page listens. |
| `httpProxyAddr` | Where the HTTP proxy listens. Empty turns it off. |
| `controlURL` | A coordination server other than Tailscale's. |
| `sunshineHost` | The Sunshine host; set from the status page. |
| `forwards` | Extra local forwards: `proto` is `tcp` or `udp`, `listen` a localhost address, `target` a tailnet host and port. |
| `passwordHash` | The status page's password, hashed. Set it on the status page; delete the field to remove a forgotten password. |
| `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` | 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. |
| `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. |
Files on the console:
@@ -215,60 +366,122 @@ Files on the console:
| `/data/tailscale/state/` | Tailscale's state, including the login. |
| `/data/tailscale/tailscale.log` | The daemon's log, rotated at 2 MB. |
| `/data/tailscale/tailscale-debug.log` | Tailscale's detailed log, up to 4 MB plus one older file. |
| `/data/tailscale/tailscale.elf` | The daemon payload. |
| `/data/tailscale/received/` | Files received with Taildrop. |
| `/data/tailscale/launcher.log` | What the payload did before Tailscale itself started. The place to look when nothing seems to happen. |
| `/data/tailscale/icon-installed` | Marks that the home screen icon was added. Delete it to have the icon added again on the next start. |
| `/data/tailscale/icon-helper.elf` | The small payload that adds and removes the icon. |
| `/user/app/TSCL00001/` | The home screen icon. |
## Uninstall
Press **Uninstall** on the status page. It deletes the daemon payload and
stops Tailscale. It asks whether to also log out and delete the saved login.
Press **Uninstall** on the status page. It removes the home screen icon, logs
the console out of your tailnet, deletes `/data/tailscale` (login, settings,
logs) and stops Tailscale.
Two things are left to do by hand:
Left to do by hand:
- Delete the home screen icon (Options button, then Delete).
- Remove the device in the Tailscale admin console.
- If you added the payload to an autoloader yourself, remove it there.
- If you added `tailscale.elf` to a payload manager or autoloader, remove it
there, or it starts again on the next boot.
## Troubleshooting
- **Nothing happens when the payload is sent.** If the payload cannot start,
it says why in a notification on the console and in
`/data/tailscale/launcher.log`; fetch that file over FTP. A payload manager
does not show what a payload prints, so sending it from a PC shows more:
`socat -t 30 - TCP:<console>:9021 < tailscale.elf`, or
`.\tools\ps5send.ps1 -File tailscale.elf -PS5Host <console>` on Windows.
If the log ends with "starting the Go program" and no status page appears,
whatever follows that line is the crash report to send.
- **The status page does not open on the console, but does from a PC.**
Check that the PS5's proxy server setting is "Do Not Use".
- **The Moonlight client cannot find the host.** The host to add is
`127.0.0.1`, and a Sunshine host must be selected on the status page.
Sunshine must be on its default port.
`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.
- **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.
- **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.** 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
- The status page and its controls are unauthenticated.
- The status page and its controls have no password until you set one. With
a password, only the console itself gets in without it. The page is served
over plain HTTP: on the LAN the password travels unencrypted, over the
tailnet Tailscale encrypts it.
- All listening TCP ports on the console, and the UDP ports in `udpPorts`,
become reachable from your tailnet. That includes the payload loader, which
runs anything sent to it. Use Tailscale ACLs if other people share your
tailnet.
- The local forwards and the proxy listen on `127.0.0.1` only and are not
exposed to the tailnet.
runs anything sent to it. If other people use your tailnet or share
devices into it, set **Who on the tailnet may connect** to your own devices
only, use Tailscale ACLs, or list ports under "TCP ports never exposed".
- 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
account, or changed on the way, is refused.
- Files received with Taildrop come only from devices logged in as the same
user. The status page hands them out as downloads and never displays them.
## Resource use
About 60 MB of memory and next to no CPU when idle. The daemon runs at the
lowest scheduling priority on at most 4 cores, so it gives way to games.
About 60 MB of memory and next to no CPU when idle. By default the daemon
runs at the lowest scheduling priority on at most 4 cores, so it gives way to
games. With the priority set to High it shares those cores with games on
equal terms.
## What has and has not been tested
Tested on the one console: install and upgrade, login with device approval,
Tested on the one console: first run and upgrade, login with device approval,
starting again after a reboot with the saved login, reaching the console over
the tailnet, a ProsperoLight stream from a Sunshine host through the forward,
the HTTP proxy, the home screen icon.
two forwarded hosts on different ports (with a stand-in for the second), the
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, 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
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: rest mode, Uninstall on a console, other firmware versions,
coordination servers other than Tailscale's.
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.
## Building
File renamed without changes.
+190
View File
@@ -0,0 +1,190 @@
/* Home screen icon helper for Tailscale on PS5.
*
* A tiny payload that puts a "Tailscale" icon on the home screen: a media app
* whose only content is a link to the status page, which the console opens in
* its browser. The daemon's launcher carries this payload and hands it to the
* ELF loader the first time Tailscale runs. It is a separate payload so that
* the system libraries it needs are never loaded into the daemon's process.
*
* The same payload removes the icon again when its mode byte says so (see
* icon_mode below); the daemon uses that for Uninstall.
*
* Prints "icon: ok" or "icon: removed" on success; the launcher and the
* daemon look for that.
*
* Build with -DASSET_DIR="path/to/appicon" and link, in this order,
* -lSceIpmi -lSceAppInstUtil -lSceUserService -lSceSystemService (with
* libSceAppInstUtil alone the payload is never started). */
#include <errno.h>
#include <fcntl.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/stat.h>
#include <ps5/kernel.h>
#ifndef ASSET_DIR
#error "ASSET_DIR must name the folder with param.json and icon0.png"
#endif
#define TITLE_ID "TSCL00001"
#define APP_DIR "/user/app/" TITLE_ID
#define INCASSET(name, file) \
__asm__(".section .rodata\n" \
".balign 16\n" \
".global " #name "\n" #name ":\n" \
".incbin \"" file "\"\n" \
".global " #name "_end\n" #name "_end:\n" \
".text\n"); \
extern const uint8_t name[]; \
extern const uint8_t name##_end[];
INCASSET(param_json, ASSET_DIR "/param.json")
INCASSET(icon_png, ASSET_DIR "/icon0.png")
int sceAppInstUtilInitialize(void);
int sceAppInstUtilTerminate(void);
int sceAppInstUtilAppInstallAll(void *);
int sceAppInstUtilAppUnInstall(const char *);
/* What to do. A payload sent to the ELF loader gets no arguments, so the
* mode is a byte in the file itself: the daemon changes the character after
* the '=' to 'R' before sending the helper when it wants the icon removed
* (see tsd/homeicon.go). It is volatile so that the compiler reads it at run
* time instead of baking the install branch in. */
volatile char icon_mode[] = "TSICON-MODE=I";
static int
remove_icon(void) {
int err;
if ((err = sceAppInstUtilInitialize())) {
printf("icon: sceAppInstUtilInitialize failed: 0x%08x\n", err);
return 1;
}
err = sceAppInstUtilAppUnInstall(TITLE_ID);
sceAppInstUtilTerminate();
/* Whatever the system left behind of the folder goes too. */
unlink(APP_DIR "/sce_sys/param.json");
unlink(APP_DIR "/sce_sys/icon0.png");
rmdir(APP_DIR "/sce_sys");
rmdir(APP_DIR);
if (err) {
printf("icon: removing the app failed: 0x%08x\n", err);
return 1;
}
printf("icon: removed\n");
return 0;
}
static int
write_file(const char *path, const uint8_t *data, size_t size) {
int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd < 0) {
return -1;
}
while (size > 0) {
ssize_t n = write(fd, data, size);
if (n < 0) {
if (errno == EINTR) {
continue;
}
close(fd);
return -1;
}
data += n;
size -= n;
}
return close(fd);
}
/* Report whether the file at path already has exactly these contents. */
static int
file_matches(const char *path, const uint8_t *data, size_t size) {
struct stat st;
uint8_t *buf;
int same = 0;
FILE *f;
if (stat(path, &st) || (size_t)st.st_size != size || !(f = fopen(path, "rb"))) {
return 0;
}
if ((buf = malloc(size))) {
same = fread(buf, 1, size, f) == size && !memcmp(buf, data, size);
free(buf);
}
fclose(f);
return same;
}
int
main(void) {
int (*install_title_dir)(const char *, const char *, void *) = 0;
size_t param_size = param_json_end - param_json;
size_t icon_size = icon_png_end - icon_png;
pid_t pid = getpid();
intptr_t rootvnode;
uint32_t handle;
int err;
setvbuf(stdout, 0, _IONBF, 0);
/* /user/app is only writable as root outside the sandbox. */
if ((rootvnode = kernel_get_root_vnode())) {
kernel_set_proc_rootdir(pid, rootvnode);
kernel_set_proc_jaildir(pid, 0);
}
kernel_set_ucred_uid(pid, 0);
kernel_set_ucred_ruid(pid, 0);
kernel_set_ucred_svuid(pid, 0);
kernel_set_ucred_rgid(pid, 0);
kernel_set_ucred_svgid(pid, 0);
if (icon_mode[sizeof(icon_mode) - 2] == 'R') {
return remove_icon();
}
if (file_matches(APP_DIR "/sce_sys/param.json", param_json, param_size) &&
file_matches(APP_DIR "/sce_sys/icon0.png", icon_png, icon_size)) {
printf("icon: ok (already installed)\n");
return 0;
}
if ((err = sceAppInstUtilInitialize())) {
printf("icon: sceAppInstUtilInitialize failed: 0x%08x\n", err);
return 1;
}
mkdir(APP_DIR, 0755);
mkdir(APP_DIR "/sce_sys", 0755);
if (write_file(APP_DIR "/sce_sys/param.json", param_json, param_size) ||
write_file(APP_DIR "/sce_sys/icon0.png", icon_png, icon_size)) {
printf("icon: could not write to %s: %s\n", APP_DIR, strerror(errno));
sceAppInstUtilTerminate();
return 1;
}
/* Register just this title where the firmware supports it; otherwise ask
* for a rescan of everything under /user/app. */
if (!kernel_dynlib_handle(-1, "libSceAppInstUtil.sprx", &handle)) {
install_title_dir = (void *)kernel_dynlib_resolve(-1, handle, "Wudg3Xe3heE");
}
if (install_title_dir) {
err = install_title_dir(TITLE_ID, "/user/app/", 0);
} else {
err = sceAppInstUtilAppInstallAll(0);
}
sceAppInstUtilTerminate();
if (err) {
printf("icon: registering the app failed: 0x%08x\n", err);
return 1;
}
printf("icon: ok (installed %s)\n", TITLE_ID);
return 0;
}
File renamed without changes.
+62 -5
View File
@@ -51,18 +51,75 @@ These folders are not in the repository.
## Building the payloads
```powershell
# daemon payload: C launcher + Go program -> out\tailscale.elf
.\tools\build-payload.ps1 -GoDir tsd -Name tailscale -Version 0.4.0
# C launcher + Go program + home screen icon helper -> out\tailscale.elf
.\tools\build-payload.ps1 -GoDir tsd -Name tailscale -Version 0.5.2 -HomeIcon
```
# installer -> out\tailscale-installer.elf (embeds out\tailscale.elf)
.\tools\build-installer.ps1
`-HomeIcon` also builds `appicon\` into `out\appicon.elf` and embeds it in
the launcher.
## Making a release
```powershell
.\tools\make-release.ps1 -Version 1.2.3
```
builds the payload and puts three files in `out\release-1.2.3`:
`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.
### The signing key
Releases are signed with an Ed25519 key. Its public half is
`updatePublicKey` in `tsd\selfupdate.go`; the private half is a small file
that stays out of the repository:
```
%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
that a release can be made without typing anything; treat it like an SSH
key. The tool that manages it is `tsd\cmd\signrelease`, run from `tsd`:
```powershell
go run ./cmd/signrelease pubkey # show the public key
go run ./cmd/signrelease backup -out Z:\keys\ps5-tailscale-signing.backup
go run ./cmd/signrelease restore -in Z:\keys\ps5-tailscale-signing.backup
```
- **Back it up.** `backup` writes a copy encrypted with a passphrase you
type, meant for a NAS, a USB stick or a password manager. Without the
passphrase the copy is useless, to you as well, so keep the passphrase
somewhere other than next to the file.
- **Building on another PC.** Copy the backup there and run `restore`. It
refuses to overwrite a key that is already present.
- **If the key is lost,** consoles running releases made with it can no
longer install updates from the page: a release signed with a new key is
refused. Their owners have to send the new payload by hand once.
- **If the key leaks,** make a new one (`keygen`, after moving the old file
away), put its public half in `selfupdate.go` and release. The same
one-time manual update applies.
- **A fork** that publishes its own releases needs its own key and its own
`releasesAPI` in `tsd\update.go`.
Test builds can use a throwaway key and a local "release":
```powershell
.\tools\build-payload.ps1 -GoDir tsd -Name test -Version 0.0.1 -HomeIcon `
-Set 'main.updatePublicKey=<base64>', 'main.releasesAPI=http://127.0.0.1:18099/latest.json'
```
## Sending to the console
```powershell
$env:PS5_HOST = '192.168.1.50' # your console
.\tools\ps5send.ps1 -File out\tailscale-installer.elf -Seconds 70
.\tools\ps5send.ps1 -File out\tailscale.elf
```
`ps5send.ps1` prints whatever the payload writes back.
+121 -12
View File
@@ -22,6 +22,14 @@ specification.
to a fresh 1 MB stack and jumps to the Go entry point. The embedded copy
is then released with `madvise(MADV_FREE)`.
The launcher keeps a log, `/data/tailscale/launcher.log` (`report.c`): the
firmware version, each step that fails, and a last line before it jumps into
the Go program. A failure is also shown as a notification, because a payload
manager does not show what a payload prints. Just before the jump, stderr is
pointed at that log, so that a Go runtime that dies before the daemon has
opened its own log leaves its message there. What cannot be reported this
way is a failure in the SDK's crt, which runs before any of this.
**The daemon** (`tsd/`, Go): a `tsnet` server.
- Inbound: tsnet's fallback TCP handler pipes each tailnet connection to
@@ -36,19 +44,82 @@ specification.
minutes.
- Outbound: local forwards (`localforward.go`) listen on localhost and relay
TCP and UDP to a tailnet host through `tsnet.Server.Dial`. UDP is relayed
per client address with an idle timeout. The Sunshine setting is a preset
of seven such forwards.
- A status page and small JSON API on port 8090, an HTTP proxy on
`127.0.0.1:8118`, PS5 notifications by writing a request to
per client address with an idle timeout. Each Sunshine host is a preset of
seven such forwards on the ports that host really uses, derived from
Sunshine's port setting (HTTPS -5, HTTP +0, RTSP +21, video +9, control
+10, audio +11, microphone +13). The ports cannot be remapped, because the
host tells the Moonlight client which ports to use; several hosts can only
coexist on 127.0.0.1 if their Sunshine ports differ.
- A status page and JSON API on port 8090 (`web.go`, `settings.go`), an
optional HTTP proxy, PS5 notifications by writing a request to
`/dev/notification0`.
- Password (`auth.go`): PBKDF2-SHA256 hash in the config, session cookie,
one attempt per second. Requests from loopback are exempt. For that to be
safe, connections for the status page that arrive over the tailnet are not
piped to localhost like other ports but handed to the page's HTTP server
directly, so it sees the tailnet address.
- Settings are applied live where possible. The launcher needs one of them,
the priority, before any Go code runs, so the daemon leaves it in
`/data/tailscale/priority` for the next start.
- Update notice (`update.go`): the latest release tag from the GitHub API,
twice a day, compared with the running version. A newer release is shown
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-<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
hash, optionally replaces the copy named by `payloadPath` (temporary file,
then rename), and writes the payload to the ELF loader on 127.0.0.1:9021.
The new instance stops the old one as with any payload sent again, and
removes the download when it starts. The connection to the loader is kept
open until the old process exits, because it is the new payload's standard
output.
- Taildrop (`taildrop.go`): tsnet does not link Taildrop in; importing
`tailscale.com/feature/taildrop` does. Received files wait in
`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
tailnet. Answers are kept for a minute per address. Anything that cannot
be established is refused.
- Key expiry (`keyexpiry.go`): the date comes from the node's own status.
The page warns from 14 days before, and the console shows a notification
at 14, 3 and 1 days.
- When a listener reports that it had to reopen its socket (the PS5's
network was reconfigured), the daemon asks Tailscale to rebind and re-STUN
instead of waiting for its interface polling. See [Rest mode](#rest-mode)
for the one time this has been seen.
- A payload that is sent again stops the running instance (through the
status page, or failing that by the pid it recorded) and takes over.
**The installer** (`installer/`, C) embeds `tailscale.elf`. It writes it to
`/data/tailscale/tailscale.elf`, registers a home screen app whose
`param.json` has a `deeplinkUri` to the status page, and starts the daemon by
sending it to the ELF loader on `127.0.0.1:9021`. It does not modify any
payload autoloader.
**The icon helper** (`appicon/`, C) is a second, tiny payload embedded in the
launcher. The first time `tailscale.elf` runs, the launcher sends it to the
ELF loader on `127.0.0.1:9021`, where it runs as a process of its own,
registers a home screen app whose `param.json` has a `deeplinkUri` to the
status page, reports the result and exits. The launcher then writes
`/data/tailscale/icon-installed` and never does it again. It is a separate
payload so that the system libraries it needs are never loaded into the
long-running daemon process, where their threads could receive signals meant
for the Go runtime.
The same helper removes the icon (`sceAppInstUtilAppUnInstall`). A payload
sent to the ELF loader gets no arguments, so the mode is a byte in the file
after the marker `TSICON-MODE=`. The launcher leaves a copy of the helper in
`/data/tailscale/icon-helper.elf`; for Uninstall the daemon flips that byte
and sends it to the loader (`tsd/homeicon.go`).
There is no installer. Nothing is copied anywhere and no payload autoloader
is touched: the payload is run from wherever the user keeps it.
## The PS5 as a Go target
@@ -98,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
@@ -106,6 +182,10 @@ apply `SOCK_NONBLOCK`/`SOCK_CLOEXEC` with `fcntl`.
- The SDK's `kernel_mprotect()` rewrites the protection of the whole kernel
map entry that contains the address. The loader first splits the text range
off with an ordinary `mprotect()`.
- `sendfile` on a socket fails with "socket is not connected". Go uses it
whenever a file is copied straight to a TCP connection (`io.Copy(conn,
file)`, `http.ServeContent` with a file), so the daemon hides the file
behind a plain reader in those places.
- When the network is reconfigured (connection settings changed, Wi-Fi to
Ethernet), listening sockets fail with errno 163, a Sony-specific code, and
do not recover. The daemon's listeners reopen themselves
@@ -133,6 +213,34 @@ works across cores (4 spinning goroutines, 5 garbage collections in about
350 ms). Test builds can add a watchdog thread that kills the process after
a fixed time (`build-payload.ps1 -Watchdog`).
The "high" priority setting uses class 2 (round-robin) at priority 700
instead: equal to games and system threads, but equal-priority round-robin
threads take turns. With it, the same test passes (5 collections in about
300 ms) and the console stays responsive: the status page answered within
60 ms throughout while four goroutines spun.
## Rest mode
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
errno 163 at the same moment: the status page listener, Tailscale's relay
connection and its connection to the coordination server. The listener
reopened at once, the daemon asked for a rebind, and Tailscale saw "all
links down" and paused.
- Nothing is logged while the console sleeps, and it is not reachable on the
tailnet.
- On waking, Tailscale's monitor noticed the jump in the clock, rebound its
sockets, reconnected to its relay and had its endpoints back within about
300 ms. The status page, forwarded TCP ports and the Remote Play UDP ports
answered through the tailnet address afterwards without anything being
restarted.
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
`/user/app/TSCL00001/sce_sys/param.json` with `applicationCategoryType` 65536
@@ -155,5 +263,6 @@ that order, as in the SDK's `install_app` sample.
request was answered with "auth path not found". The daemon now requests a
new link when it sees that error; the recovery path has not been observed
in practice.
- Whether Tailscale's own UDP sockets recover after a network
reconfiguration has not been examined.
- Tailscale's sockets recovered after rest mode took the network down and
brought it back. A change of interface (Wi-Fi to Ethernet or back) while
the daemon runs has not been observed since the rebind request was added.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 159 KiB

-310
View File
@@ -1,310 +0,0 @@
/* Tailscale installer payload for jailbroken PS5s.
*
* Stores the Tailscale daemon payload on the console, adds a home screen
* icon that opens the status page, and starts the daemon through the ELF
* loader on this console. It does not touch any payload autoloader. Build
* with tools\build-installer.ps1, which sets DAEMON_ELF and
* ASSET_DIR and links the system libraries the app installer needs. */
#include <errno.h>
#include <fcntl.h>
#include <stdarg.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/select.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <sys/time.h>
#include <ps5/kernel.h>
#ifndef DAEMON_ELF
#error "DAEMON_ELF must name the daemon payload to embed"
#endif
#define DATA_DIR "/data/tailscale"
#define DAEMON_NAME "tailscale.elf"
#define LOADER_PORT 9021
extern const uint8_t daemon_elf[];
extern const uint8_t daemon_elf_end[];
__asm__(".section .rodata\n"
".balign 16\n"
".global daemon_elf\n"
"daemon_elf:\n"
".incbin \"" DAEMON_ELF "\"\n"
".global daemon_elf_end\n"
"daemon_elf_end:\n"
".text\n");
/* The home screen launcher: a media app whose only content is a link, which
* the console opens in its browser. ASSET_DIR is set by the build. */
#ifndef ASSET_DIR
#error "ASSET_DIR must name the folder with param.json and icon0.png"
#endif
#define LAUNCHER_TITLE_ID "TSCL00001"
#define LAUNCHER_DIR "/user/app/" LAUNCHER_TITLE_ID
#define INCASSET(name, file) \
__asm__(".section .rodata\n" \
".balign 16\n" \
".global " #name "\n" #name ":\n" \
".incbin \"" file "\"\n" \
".global " #name "_end\n" #name "_end:\n" \
".text\n"); \
extern const uint8_t name[]; \
extern const uint8_t name##_end[];
INCASSET(launcher_param_json, ASSET_DIR "/param.json")
INCASSET(launcher_icon_png, ASSET_DIR "/icon0.png")
int sceAppInstUtilInitialize(void);
int sceAppInstUtilTerminate(void);
int sceAppInstUtilAppInstallAll(void *);
typedef struct notify_request {
char useless1[45];
char message[3075];
} notify_request_t;
int sceKernelSendNotificationRequest(int, notify_request_t *, size_t, int);
static void
notify(const char *fmt, ...) {
notify_request_t req;
va_list args;
memset(&req, 0, sizeof(req));
va_start(args, fmt);
vsnprintf(req.message, sizeof(req.message), fmt, args);
va_end(args);
sceKernelSendNotificationRequest(0, &req, sizeof(req), 0);
}
static int
write_all(int fd, const uint8_t *data, size_t size) {
while (size > 0) {
ssize_t n = write(fd, data, size);
if (n < 0) {
if (errno == EINTR) {
continue;
}
return -1;
}
data += n;
size -= n;
}
return 0;
}
/* Write a file through a temporary name so a failed write never leaves a
* truncated payload behind. */
static int
write_file(const char *path, const uint8_t *data, size_t size) {
char tmp[512];
int fd;
snprintf(tmp, sizeof(tmp), "%s.tmp", path);
if ((fd = open(tmp, O_WRONLY | O_CREAT | O_TRUNC, 0755)) < 0) {
return -1;
}
if (write_all(fd, data, size)) {
close(fd);
unlink(tmp);
return -1;
}
close(fd);
if (rename(tmp, path)) {
unlink(tmp);
return -1;
}
return 0;
}
static int
install_daemon(const char *dir) {
char path[512];
mkdir(dir, 0755);
snprintf(path, sizeof(path), "%s/%s", dir, DAEMON_NAME);
if (write_file(path, daemon_elf, daemon_elf_end - daemon_elf)) {
printf(" could not write %s: %s\n", path, strerror(errno));
return -1;
}
printf(" wrote %s (%.1f MB)\n", path, (daemon_elf_end - daemon_elf) / 1048576.0);
return 0;
}
/* Report whether the file at path already has exactly these contents. */
static int
file_matches(const char *path, const uint8_t *data, size_t size) {
struct stat st;
uint8_t *buf;
int same = 0;
FILE *f;
if (stat(path, &st) || (size_t)st.st_size != size || !(f = fopen(path, "rb"))) {
return 0;
}
if ((buf = malloc(size))) {
same = fread(buf, 1, size, f) == size && !memcmp(buf, data, size);
free(buf);
}
fclose(f);
return same;
}
/* Put a "Tailscale" icon on the home screen that opens the status page in
* the console's browser. Does nothing if it is already there and current.
* Returns 0 on success. */
static int
install_launcher_app(void) {
int (*install_title_dir)(const char *, const char *, void *) = 0;
size_t param_size = launcher_param_json_end - launcher_param_json;
size_t icon_size = launcher_icon_png_end - launcher_icon_png;
uint32_t handle;
int err;
if (file_matches(LAUNCHER_DIR "/sce_sys/param.json", launcher_param_json, param_size) &&
file_matches(LAUNCHER_DIR "/sce_sys/icon0.png", launcher_icon_png, icon_size)) {
printf(" already installed\n");
return 0;
}
if ((err = sceAppInstUtilInitialize())) {
printf(" sceAppInstUtilInitialize failed: 0x%08x\n", err);
return -1;
}
mkdir(LAUNCHER_DIR, 0755);
mkdir(LAUNCHER_DIR "/sce_sys", 0755);
if (write_file(LAUNCHER_DIR "/sce_sys/param.json", launcher_param_json, param_size) ||
write_file(LAUNCHER_DIR "/sce_sys/icon0.png", launcher_icon_png, icon_size)) {
printf(" could not write to %s: %s\n", LAUNCHER_DIR, strerror(errno));
sceAppInstUtilTerminate();
return -1;
}
/* Register just this title where the firmware supports it; otherwise ask
* for a rescan of everything under /user/app. */
if (!kernel_dynlib_handle(-1, "libSceAppInstUtil.sprx", &handle)) {
install_title_dir = (void *)kernel_dynlib_resolve(-1, handle, "Wudg3Xe3heE");
}
if (install_title_dir) {
err = install_title_dir(LAUNCHER_TITLE_ID, "/user/app/", 0);
} else {
err = sceAppInstUtilAppInstallAll(0);
}
sceAppInstUtilTerminate();
if (err) {
printf(" registering the app failed: 0x%08x\n", err);
return -1;
}
printf(" installed (%s), opens http://127.0.0.1:8090/\n", LAUNCHER_TITLE_ID);
return 0;
}
/* Hand the daemon to the ELF loader on this console and relay what it prints
* for a while, so that the login link reaches whoever sent the installer. */
static int
start_daemon(int relay_seconds) {
struct sockaddr_in addr = {0};
struct timeval start, now;
char buf[4096];
int fd;
if ((fd = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
return -1;
}
addr.sin_family = AF_INET;
addr.sin_port = htons(LOADER_PORT);
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
if (connect(fd, (struct sockaddr *)&addr, sizeof(addr))) {
close(fd);
return -1;
}
if (write_all(fd, daemon_elf, daemon_elf_end - daemon_elf)) {
close(fd);
return -1;
}
gettimeofday(&start, 0);
for (;;) {
struct timeval tv = {1, 0};
fd_set rfds;
gettimeofday(&now, 0);
if (now.tv_sec - start.tv_sec >= relay_seconds) {
break;
}
FD_ZERO(&rfds);
FD_SET(fd, &rfds);
if (select(fd + 1, &rfds, 0, 0, &tv) <= 0) {
continue;
}
ssize_t n = read(fd, buf, sizeof(buf));
if (n <= 0) {
break;
}
if (write_all(STDOUT_FILENO, (uint8_t *)buf, n)) {
break;
}
}
close(fd);
return 0;
}
int
main(void) {
pid_t pid = getpid();
intptr_t rootvnode;
setvbuf(stdout, 0, _IONBF, 0);
/* Run as root outside the sandbox so /data and USB drives are writable. */
if ((rootvnode = kernel_get_root_vnode())) {
kernel_set_proc_rootdir(pid, rootvnode);
kernel_set_proc_jaildir(pid, 0);
}
kernel_set_ucred_uid(pid, 0);
kernel_set_ucred_ruid(pid, 0);
kernel_set_ucred_svuid(pid, 0);
kernel_set_ucred_rgid(pid, 0);
kernel_set_ucred_svgid(pid, 0);
#ifdef LAUNCHER_ONLY
/* Test build: only (re)install the home screen icon. */
printf("Home screen icon:\n");
return install_launcher_app() ? 1 : 0;
#endif
printf("Tailscale for PS5 installer\n\n");
printf("Daemon payload:\n");
if (install_daemon(DATA_DIR)) {
notify("Tailscale install failed:\ncould not write to %s", DATA_DIR);
return 1;
}
printf("Home screen icon:\n");
install_launcher_app();
printf("\nStarting Tailscale...\n");
if (start_daemon(45)) {
printf("Could not reach the ELF loader on port %d: %s\n", LOADER_PORT, strerror(errno));
notify("Tailscale is installed but could not be started:\nno ELF loader on port %d.", LOADER_PORT);
return 1;
}
printf("\nDone. The status page is on port 8090 of this console.\n"
"Tailscale runs until the console restarts. To start it again, send\n"
"%s/%s to the ELF loader.\n",
DATA_DIR, DAEMON_NAME);
return 0;
}
+18 -8
View File
@@ -7,6 +7,7 @@
* FreeBSD kernel would: %rdi pointing at argc/argv/envp/auxv. */
#include <elf.h>
#include <errno.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
@@ -17,6 +18,7 @@
#include <ps5/kernel.h>
#include "goload.h"
#include "report.h"
#define PS5_PAGE_SIZE 0x4000ul
#define PAGE_TRUNC(x) ((x) & ~(PS5_PAGE_SIZE - 1))
@@ -134,7 +136,7 @@ goload_run(const uint8_t *image, size_t size, char *const argv[], char *const en
int envc = 0;
if (image_check(image, size)) {
fprintf(stderr, "goload: not a relocatable x86-64 ELF image\n");
report_fail("goload: the embedded program is not a relocatable x86-64 ELF image");
return -1;
}
@@ -143,7 +145,7 @@ goload_run(const uint8_t *image, size_t size, char *const argv[], char *const en
continue;
}
if (phdr[i].p_offset + phdr[i].p_filesz > size) {
fprintf(stderr, "goload: truncated image\n");
report_fail("goload: the embedded program is truncated");
return -1;
}
if (phdr[i].p_vaddr < min_vaddr) {
@@ -154,7 +156,7 @@ goload_run(const uint8_t *image, size_t size, char *const argv[], char *const en
}
}
if (min_vaddr >= max_vaddr) {
fprintf(stderr, "goload: image has no loadable segments\n");
report_fail("goload: the embedded program has no loadable segments");
return -1;
}
min_vaddr = PAGE_TRUNC(min_vaddr);
@@ -162,7 +164,7 @@ goload_run(const uint8_t *image, size_t size, char *const argv[], char *const en
base = mmap(0, max_vaddr - min_vaddr, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (base == MAP_FAILED) {
perror("goload: mmap image");
report_fail("goload: no memory for the program (mmap: %s)", strerror(errno));
return -1;
}
bias = (uintptr_t)base - min_vaddr;
@@ -185,7 +187,7 @@ goload_run(const uint8_t *image, size_t size, char *const argv[], char *const en
}
for (size_t i = 0; rela && i < relasz / sizeof(*rela); i++) {
if (ELF64_R_TYPE(rela[i].r_info) != R_X86_64_RELATIVE) {
fprintf(stderr, "goload: unsupported relocation type %u\n", (unsigned)ELF64_R_TYPE(rela[i].r_info));
report_fail("goload: unsupported relocation type %u", (unsigned)ELF64_R_TYPE(rela[i].r_info));
return -1;
}
*(uintptr_t *)(bias + rela[i].r_offset) = bias + rela[i].r_addend;
@@ -205,18 +207,19 @@ goload_run(const uint8_t *image, size_t size, char *const argv[], char *const en
* that contains the address, so first let a regular mprotect split the
* text range off into an entry of its own. */
if (mprotect((void *)start, end - start, PROT_READ)) {
perror("goload: mprotect");
report_fail("goload: mprotect: %s", strerror(errno));
return -1;
}
if (kernel_mprotect(-1, start, end - start, PROT_READ | PROT_EXEC)) {
fprintf(stderr, "goload: kernel_mprotect failed\n");
report_fail("goload: could not make the program executable (kernel_mprotect failed); "
"this firmware or jailbreak may not allow it");
return -1;
}
}
stack = mmap(0, GO_STACK_SIZE, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (stack == MAP_FAILED) {
perror("goload: mmap stack");
report_fail("goload: no memory for the stack (mmap: %s)", strerror(errno));
return -1;
}
@@ -251,6 +254,13 @@ goload_run(const uint8_t *image, size_t size, char *const argv[], char *const en
fprintf(stderr, "goload: image %p..%p entry %#lx stack %p..%p argc=%d\n", base,
base + (max_vaddr - min_vaddr), (unsigned long)(bias + ehdr->e_entry), stack, stack + GO_STACK_SIZE, argc);
dbg_install((uintptr_t)base, min_vaddr);
#else
/* From here on nothing is printed by the launcher. If the Go runtime dies
* before the daemon has opened its own log, its message lands in the
* launcher log instead of being lost with the sender's connection. */
report_log("launcher: starting the Go program");
fflush(stderr);
report_capture_stderr();
#endif
fflush(stdout);
+157
View File
@@ -0,0 +1,157 @@
/* Installs the home screen icon the first time the payload runs.
*
* The icon is installed by a separate small payload (appicon/), embedded
* here and handed to the ELF loader on this console, so that the system
* libraries it needs never end up in this process. Build with
* -DICON_HELPER="path/to/appicon.elf"; without it this file does nothing. */
#include "homeicon.h"
#ifdef ICON_HELPER
#include "report.h"
#include <fcntl.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <sys/time.h>
#define DATA_DIR "/data/tailscale"
/* Records that the icon has been installed, and which version of it. Once
* it exists the icon is left alone, so an icon the user deletes from the
* home screen stays deleted. Remove the file to get the icon back. */
#define ICON_MARKER DATA_DIR "/icon-installed"
#define ICON_VERSION "1\n"
#define ICON_HELPER_FILE DATA_DIR "/icon-helper.elf"
#define LOADER_PORT 9021
extern const uint8_t icon_helper[];
extern const uint8_t icon_helper_end[];
__asm__(".section .rodata\n"
".balign 16\n"
".global icon_helper\n"
"icon_helper:\n"
".incbin \"" ICON_HELPER "\"\n"
".global icon_helper_end\n"
"icon_helper_end:\n"
".text\n");
static int
marker_is_current(void) {
char buf[16] = {0};
int fd = open(ICON_MARKER, O_RDONLY);
if (fd < 0) {
return 0;
}
read(fd, buf, sizeof(buf) - 1);
close(fd);
return !strcmp(buf, ICON_VERSION);
}
/* Leave a copy of the helper where the daemon can find it. Uninstall runs it
* again, switched to removing the icon. */
static void
save_helper(void) {
size_t size = icon_helper_end - icon_helper;
struct stat st;
int fd;
if (!stat(ICON_HELPER_FILE, &st) && (size_t)st.st_size == size) {
return;
}
if ((fd = open(ICON_HELPER_FILE, O_WRONLY | O_CREAT | O_TRUNC, 0644)) < 0) {
return;
}
for (size_t done = 0; done < size;) {
ssize_t n = write(fd, icon_helper + done, size - done);
if (n <= 0) {
break;
}
done += n;
}
close(fd);
}
void
home_icon_install_once(void) {
struct sockaddr_in addr = {0};
struct timeval tv = {1, 0};
const uint8_t *data = icon_helper;
size_t left = icon_helper_end - icon_helper;
char reply[512] = {0};
size_t got = 0;
int fd;
mkdir(DATA_DIR, 0755);
save_helper();
if (marker_is_current()) {
return;
}
if ((fd = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
return;
}
addr.sin_family = AF_INET;
addr.sin_port = htons(LOADER_PORT);
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
if (connect(fd, (struct sockaddr *)&addr, sizeof(addr))) {
/* No ELF loader on the usual port: go without an icon this time. */
report_log("launcher: home screen icon not installed: no ELF loader on port %d", LOADER_PORT);
close(fd);
return;
}
while (left > 0) {
ssize_t n = write(fd, data, left);
if (n <= 0) {
close(fd);
return;
}
data += n;
left -= n;
}
/* The helper reports "icon: ok" or what went wrong, then exits, which
* closes the connection. Give it 20 seconds. */
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
for (int tries = 0; tries < 20 && got < sizeof(reply) - 1; tries++) {
const char *line = strstr(reply, "icon: ");
if (line && strchr(line, '\n')) {
break;
}
ssize_t n = read(fd, reply + got, sizeof(reply) - 1 - got);
if (n == 0) {
break;
}
if (n > 0) {
got += n;
}
}
close(fd);
if (strstr(reply, "icon: ok")) {
if ((fd = open(ICON_MARKER, O_WRONLY | O_CREAT | O_TRUNC, 0644)) >= 0) {
write(fd, ICON_VERSION, sizeof(ICON_VERSION) - 1);
close(fd);
}
report_log("launcher: home screen icon installed");
} else {
report_log("launcher: home screen icon not installed: %s", got ? reply : "no reply from the helper");
}
}
#else
void
home_icon_install_once(void) {
}
#endif
+5
View File
@@ -0,0 +1,5 @@
#pragma once
/* Put the Tailscale icon on the home screen if that has not been done yet.
* Never fails: without an icon everything else still works. */
void home_icon_install_once(void);
+32 -7
View File
@@ -3,6 +3,7 @@
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/mman.h>
@@ -10,6 +11,8 @@
#include <ps5/kernel.h>
#include "goload.h"
#include "homeicon.h"
#include "report.h"
#ifndef GO_IMAGE
#error "GO_IMAGE must name the Go binary to embed"
@@ -62,30 +65,48 @@ raw_syscall3(long n, long a, long b, long c) {
* priority, where nothing this process does can keep the system's own threads
* off the CPU. Threads created later inherit the setting. The kernel ignores
* priorities outside its own range without reporting an error, so the result
* is read back. Round-robin at the lowest priority is the fallback. */
* is read back. Round-robin at the lowest priority is the fallback.
*
* With the "high" priority setting the process instead becomes round-robin
* at the default priority: it then competes with games on equal terms, but
* equal-priority round-robin threads take turns, so even then a thread that
* never blocks cannot shut the others out. */
static int
high_priority_requested(void) {
char buf[16] = {0};
FILE *f = fopen("/data/tailscale/priority", "r");
if (!f) {
return 0;
}
fgets(buf, sizeof(buf), f);
fclose(f);
return !strncmp(buf, "high", 4);
}
static int
leave_realtime_class(void) {
static const struct rtprio choices[] = {
{RTP_PRIO_REALTIME, PS5_PRIO_DEFAULT}, /* only with the "high" setting */
{RTP_PRIO_NORMAL, PS5_PRIO_LOWEST},
{RTP_PRIO_REALTIME, PS5_PRIO_LOWEST},
};
struct rtprio before = {0}, after = {0};
int ok = 0;
raw_syscall3(SYS_rtprio_thread, RTP_LOOKUP, 0, (long)&before);
for (size_t i = 0; i < sizeof(choices) / sizeof(choices[0]); i++) {
for (size_t i = high_priority_requested() ? 0 : 1; i < sizeof(choices) / sizeof(choices[0]) && !ok; i++) {
struct rtprio want = choices[i];
raw_syscall3(SYS_rtprio_thread, RTP_SET, 0, (long)&want);
raw_syscall3(SYS_rtprio_thread, RTP_LOOKUP, 0, (long)&after);
if (after.type == choices[i].type && after.prio == choices[i].prio) {
break;
}
ok = after.type == choices[i].type && after.prio == choices[i].prio;
}
#ifdef GOLOAD_DEBUG
fprintf(stderr, "launcher: scheduling class %u/%u -> %u/%u\n", before.type, before.prio, after.type, after.prio);
#else
(void)before;
#endif
return after.prio > PS5_PRIO_DEFAULT && after.type != before.type ? 0 : -1;
return ok ? 0 : -1;
}
#ifdef GOLOAD_WATCHDOG
@@ -134,10 +155,14 @@ main(int argc, char **argv) {
kernel_set_ucred_rgid(pid, 0);
kernel_set_ucred_svgid(pid, 0);
report_begin();
home_icon_install_once();
if (leave_realtime_class()) {
/* Without this a runaway goroutine could hang the console, so do not
* take the chance. */
fprintf(stderr, "launcher: could not leave the real-time scheduling class; not starting\n");
report_fail("launcher: could not lower the scheduling priority; not starting, "
"because a busy daemon could then freeze the console");
return 1;
}
+107
View File
@@ -0,0 +1,107 @@
/* The launcher's log and its way of telling the user that a start failed. */
#include <fcntl.h>
#include <stdarg.h>
#include <stdio.h>
#include <string.h>
#include <time.h>
#include <unistd.h>
#include <sys/stat.h>
#include <ps5/kernel.h>
#include "report.h"
#define DATA_DIR "/data/tailscale"
/* The log is started afresh once it has grown past this. */
#define LOG_MAX_SIZE (64 * 1024)
typedef struct {
char unused[45];
char message[3075];
} notify_request_t;
int sceKernelSendNotificationRequest(int, notify_request_t *, size_t, int);
static int
log_open(void) {
struct stat st;
int flags = O_WRONLY | O_CREAT | O_APPEND;
mkdir(DATA_DIR, 0755);
if (!stat(LAUNCHER_LOG, &st) && st.st_size > LOG_MAX_SIZE) {
flags |= O_TRUNC;
}
return open(LAUNCHER_LOG, flags, 0644);
}
static void
log_line(const char *line) {
char stamp[32] = "";
time_t now = time(0);
struct tm tm;
int fd = log_open();
if (fd < 0) {
return;
}
if (gmtime_r(&now, &tm)) {
strftime(stamp, sizeof(stamp), "%Y-%m-%d %H:%M:%S UTC ", &tm);
}
write(fd, stamp, strlen(stamp));
write(fd, line, strlen(line));
write(fd, "\n", 1);
close(fd);
}
void
report_begin(void) {
char line[128];
unsigned fw = kernel_get_fw_version();
snprintf(line, sizeof(line), "launcher: starting, firmware %x.%02x, pid %d", fw >> 24, (fw >> 16) & 0xff,
(int)getpid());
log_line(line);
}
void
report_log(const char *fmt, ...) {
char line[512];
va_list ap;
va_start(ap, fmt);
vsnprintf(line, sizeof(line), fmt, ap);
va_end(ap);
fprintf(stderr, "%s\n", line);
log_line(line);
}
void
report_fail(const char *fmt, ...) {
static notify_request_t req;
char line[512];
va_list ap;
va_start(ap, fmt);
vsnprintf(line, sizeof(line), fmt, ap);
va_end(ap);
fprintf(stderr, "%s\n", line);
log_line(line);
memset(&req, 0, sizeof(req));
snprintf(req.message, sizeof(req.message), "Tailscale did not start:\n%s\nDetails: " LAUNCHER_LOG, line);
sceKernelSendNotificationRequest(0, &req, sizeof(req), 0);
}
void
report_capture_stderr(void) {
int fd = log_open();
if (fd < 0) {
return;
}
fflush(stderr);
dup2(fd, 2);
close(fd);
}
+20
View File
@@ -0,0 +1,20 @@
#pragma once
/* Where the launcher records what it did. A payload manager does not show
* what a payload prints, so this file is how a start that went wrong can be
* looked into afterwards. */
#define LAUNCHER_LOG "/data/tailscale/launcher.log"
/* Starts a new entry in the launcher log. */
void report_begin(void);
/* Writes a line to the launcher log and to whoever sent the payload. */
void report_log(const char *fmt, ...) __attribute__((format(printf, 1, 2)));
/* Like report_log, and also tells the user on screen that Tailscale did not
* start. */
void report_fail(const char *fmt, ...) __attribute__((format(printf, 1, 2)));
/* Points stderr at the launcher log, so that whatever the Go runtime prints
* if it dies before the daemon has opened its own log ends up there. */
void report_capture_stderr(void);
+11 -6
View File
@@ -14,6 +14,11 @@
#ifndef HTTP_PATH
#define HTTP_PATH "/hello.txt"
#endif
/* Sunshine's "port" setting on the host under test; its HTTP port is this
* and its video (UDP) port is this plus 9. */
#ifndef BASE_PORT
#define BASE_PORT 47989
#endif
static struct sockaddr_in
local(int port) {
@@ -42,12 +47,12 @@ main(void) {
setvbuf(stdout, 0, _IONBF, 0);
/* TCP */
addr = local(47989);
addr = local(BASE_PORT);
fd = socket(AF_INET, SOCK_STREAM, 0);
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
double t0 = now();
if (connect(fd, (struct sockaddr *)&addr, sizeof(addr))) {
printf("tcp 127.0.0.1:47989: cannot connect (no forward listening)\n");
printf("tcp 127.0.0.1:%d: cannot connect (no forward listening)\n", BASE_PORT);
} else {
const char *req = "GET " HTTP_PATH " HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n";
size_t total = 0;
@@ -59,9 +64,9 @@ main(void) {
char *body = strstr(buf, "\r\n\r\n");
char *eol = strstr(buf, "\r\n");
if (!total) {
printf("tcp 127.0.0.1:47989: connected but no response\n");
printf("tcp 127.0.0.1:%d: connected but no response\n", BASE_PORT);
} else {
printf("tcp 127.0.0.1:47989: %.*s (%.0f ms)\n", eol ? (int)(eol - buf) : 60, buf, (now() - t0) * 1000);
printf("tcp 127.0.0.1:%d: %.*s (%.0f ms)\n", BASE_PORT, eol ? (int)(eol - buf) : 60, buf, (now() - t0) * 1000);
if (body) {
printf(" body: %.400s\n", body + 4);
}
@@ -71,7 +76,7 @@ main(void) {
#ifndef SKIP_UDP
/* UDP */
addr = local(47998);
addr = local(BASE_PORT + 9);
fd = socket(AF_INET, SOCK_DGRAM, 0);
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
int ok = 0;
@@ -90,7 +95,7 @@ main(void) {
}
}
}
printf("udp 127.0.0.1:47998: %d/20 datagrams of 1300 bytes echoed, worst round trip %.1f ms\n", ok, worst);
printf("udp 127.0.0.1:%d: %d/20 datagrams of 1300 bytes echoed, worst round trip %.1f ms\n", BASE_PORT + 9, ok, worst);
close(fd);
#endif
return 0;
+4 -3
View File
@@ -48,9 +48,10 @@ main(void) {
int stray = ki->ki_pid >= MIN_PID && ki->ki_pid != self && !strcmp(tdname, "payload.elf");
if (ki->ki_pid >= MIN_PID - 40 || stray) {
#endif
printf("%6d %-20s %-20s rss=%ldMB threads=%d cpu=%.2fs stat=%d wait=%.8s%s\n", ki->ki_pid, ki->ki_comm,
tdname, (long)(ki->ki_rssize * 16384L >> 20), ki->ki_numthreads, ki->ki_runtime / 1e6, (int)ki->ki_stat,
ki->ki_wmesg, stray ? " <- killing" : "");
printf("%6d %-20s %-20s rss=%ldMB threads=%d cpu=%.2fs stat=%d wait=%.8s sched=%d/%d%s\n", ki->ki_pid,
ki->ki_comm, tdname, (long)(ki->ki_rssize * 16384L >> 20), ki->ki_numthreads, ki->ki_runtime / 1e6,
(int)ki->ki_stat, ki->ki_wmesg, (int)ki->ki_pri.pri_class, (int)ki->ki_pri.pri_user,
stray ? " <- killing" : "");
}
if (stray) {
if (kill(ki->ki_pid, SIGKILL)) {
-27
View File
@@ -1,27 +0,0 @@
# Build the installer payload around an already built daemon payload.
# .\tools\build-installer.ps1 [-Daemon out\tailscale.elf] [-Send]
param(
[string]$Daemon = 'out\tailscale.elf',
[string]$Out = 'out\tailscale-installer.elf',
[switch]$Send,
[int]$Seconds = 70
)
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'env.ps1')
$daemonPath = (Resolve-Path (Join-Path $DevRoot $Daemon)).Path -replace '\\', '/'
$assets = (Join-Path $DevRoot 'installer\assets') -replace '\\', '/'
$outPath = Join-Path $DevRoot $Out
# The app installer library only loads when these come with it, in this
# order (as in the SDK's install_app sample). With libSceAppInstUtil alone
# the payload never starts: the loader leaves it stopped.
Invoke-PS5CC -O2 -Wall "-DDAEMON_ELF=`"$daemonPath`"" "-DASSET_DIR=`"$assets`"" `
-lSceIpmi -lSceAppInstUtil -lSceUserService -lSceSystemService `
-o $outPath (Join-Path $DevRoot 'installer\main.c')
Write-Host ("built {0} ({1:N1} MB)" -f $outPath, ((Get-Item $outPath).Length / 1MB))
if ($Send) {
& (Join-Path $PSScriptRoot 'ps5send.ps1') -File $outPath -Seconds $Seconds
}
+18 -1
View File
@@ -1,4 +1,5 @@
# Build a Go program for the PS5 and wrap it in the launcher payload.
# .\tools\build-payload.ps1 -GoDir tsd -Name tailscale -Version 0.5.2 -HomeIcon
# .\tools\build-payload.ps1 -GoDir probe-go -Name probe [-DebugLoader] [-Watchdog 120] [-Send]
param(
[Parameter(Mandatory = $true)][string]$GoDir,
@@ -6,9 +7,11 @@ param(
[string]$Package = '.',
[string]$Tags = '',
[string]$Version = '', # sets main.version in the Go program
[string[]]$Set = @(), # extra Go variables, e.g. -Set main.releasesAPI=http://127.0.0.1:18099/latest.json
[string]$MaxProcs = '', # GOMAXPROCS for the Go program (launcher default: 4)
[string]$GoDebug = '', # GODEBUG value baked into the launcher
[int]$Watchdog = 0, # test builds: kill the process after this many seconds
[switch]$HomeIcon, # embed the helper that adds the home screen icon on first run
[switch]$DebugLoader, # print loader details and early crash registers
[switch]$KeepSymbols,
[switch]$Send,
@@ -26,6 +29,7 @@ $elf = Join-Path $out "$Name.elf"
$ldflags = @()
if (-not $KeepSymbols) { $ldflags += '-s', '-w' }
if ($Version) { $ldflags += "-X main.version=$Version" }
foreach ($s in $Set) { $ldflags += "-X $s" }
$goArgs = @('build', '-buildmode=pie', '-trimpath', "-ldflags=$($ldflags -join ' ')", '-o', $bin)
if ($Tags) { $goArgs += "-tags=$Tags" }
$goArgs += $Package
@@ -39,7 +43,20 @@ if ($DebugLoader) { $ccArgs += '-DGOLOAD_DEBUG' }
if ($Watchdog -gt 0) { $ccArgs += "-DGOLOAD_WATCHDOG=$Watchdog" }
if ($MaxProcs) { $ccArgs += "-DGO_MAXPROCS=`"$MaxProcs`"" }
if ($GoDebug) { $ccArgs += "-DGO_DEBUG=`"$GoDebug`"" }
$ccArgs += @('-o', $elf, (Join-Path $DevRoot 'launcher\main.c'), (Join-Path $DevRoot 'launcher\goload.c'))
if ($HomeIcon) {
# The icon helper is a payload of its own. The app installer library only
# loads when these come with it, in this order (as in the SDK's
# install_app sample); with libSceAppInstUtil alone the payload is never
# started.
$helper = Join-Path $out 'appicon.elf'
$assets = (Join-Path $DevRoot 'appicon') -replace '\\', '/'
Invoke-PS5CC -O2 -Wall "-DASSET_DIR=`"$assets`"" `
-lSceIpmi -lSceAppInstUtil -lSceUserService -lSceSystemService `
-o $helper (Join-Path $DevRoot 'appicon\main.c')
$ccArgs += "-DICON_HELPER=`"$($helper -replace '\\', '/')`""
}
$ccArgs += @('-o', $elf, (Join-Path $DevRoot 'launcher\main.c'), (Join-Path $DevRoot 'launcher\goload.c'),
(Join-Path $DevRoot 'launcher\homeicon.c'), (Join-Path $DevRoot 'launcher\report.c'))
Invoke-PS5CC @ccArgs
Write-Host ("built {0} ({1:N1} MB)" -f $elf, ((Get-Item $elf).Length / 1MB))
+55
View File
@@ -0,0 +1,55 @@
# Build the files of a release into out\release-<version>:
# 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,
# 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'
. (Join-Path $PSScriptRoot 'env.ps1')
if ($Version -notmatch '^\d+\.\d+\.\d+$') { throw "version must look like 1.2.3, not '$Version'" }
& (Join-Path $PSScriptRoot 'build-payload.ps1') -GoDir tsd -Name tailscale -Version $Version -HomeIcon
$rel = Join-Path $DevRoot "out\release-$Version"
New-Item -ItemType Directory -Force $rel | Out-Null
$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')
try {
go run ./cmd/signrelease sign -version $Version -file $elf
if ($LASTEXITCODE -ne 0) { throw 'signing failed' }
go run ./cmd/signrelease verify -file $elf
if ($LASTEXITCODE -ne 0) { throw 'the signature does not verify' }
# The payload must carry the public half of the key it was signed with,
# or consoles running it could never install the release after it.
$pub = go run ./cmd/signrelease pubkey
if (-not (Select-String -Path 'selfupdate.go' -SimpleMatch $pub -Quiet)) {
throw "tsd\selfupdate.go does not have this machine's public key ($pub) as updatePublicKey"
}
} finally { Pop-Location }
$hash = (Get-FileHash $elf -Algorithm SHA256).Hash.ToLower()
$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"
+142
View File
@@ -0,0 +1,142 @@
package main
import (
"context"
"net"
"net/netip"
"sync"
"time"
"tailscale.com/tailcfg"
)
// Who on the tailnet may reach the console's services.
//
// Tailscale's access rules decide which devices can send to this node at
// all. On top of that the console can be limited to its owner's devices,
// because what it exposes (the payload loader above all) is more than most
// tailnets' rules were written with in mind, and a device that someone else
// shared into the tailnet is governed by rules the owner may not have looked
// at since.
const (
accessAll = "all" // every device the tailnet's access rules allow
accessOwn = "own" // only devices of the user this console is logged in as
)
// identity is what the access check needs to know about a node.
type identity struct {
User tailcfg.UserID
Tagged bool
DNSName string
}
// ownDevice reports whether peer belongs to the same user as self. A tagged
// node has no user: if the console itself is tagged, every device of its own
// tailnet counts, and a tagged peer never counts otherwise. suffix is the
// tailnet's MagicDNS suffix; a device from another tailnet never counts.
func ownDevice(self, peer identity, suffix string) bool {
if peer.DNSName != "" && suffix != "" && !hasDNSSuffix(peer.DNSName, suffix) {
return false
}
if self.Tagged {
return true
}
return !peer.Tagged && peer.User != 0 && peer.User == self.User
}
// accessCache remembers recent decisions, so that a busy port does not ask
// Tailscale about the same device for every connection.
type accessCache struct {
mu sync.Mutex
entries map[netip.Addr]accessEntry
}
type accessEntry struct {
allowed bool
at time.Time
}
const accessCacheTime = time.Minute
func (c *accessCache) get(addr netip.Addr) (allowed, ok bool) {
c.mu.Lock()
defer c.mu.Unlock()
e, ok := c.entries[addr]
if !ok || time.Since(e.at) > accessCacheTime {
return false, false
}
return e.allowed, true
}
func (c *accessCache) put(addr netip.Addr, allowed bool) {
c.mu.Lock()
defer c.mu.Unlock()
if c.entries == nil || len(c.entries) > 1024 {
c.entries = map[netip.Addr]accessEntry{}
}
c.entries[addr] = accessEntry{allowed: allowed, at: time.Now()}
}
func (c *accessCache) clear() {
c.mu.Lock()
defer c.mu.Unlock()
c.entries = nil
}
// allowedFrom reports whether the tailnet device at src may use the
// console's services under the current setting.
func (d *daemon) allowedFrom(src netip.Addr) bool {
d.mu.Lock()
mode := d.cfg.AllowFrom
d.mu.Unlock()
if mode != accessOwn {
return true
}
src = src.Unmap()
if allowed, ok := d.access.get(src); ok {
return allowed
}
allowed, who := d.lookupOwnDevice(src)
d.access.put(src, allowed)
if !allowed {
d.logf("refused %s (%s): only this console's owner's devices may connect", src, who)
}
return allowed
}
// lookupOwnDevice asks Tailscale who src is. Anything that cannot be
// established counts as not allowed.
func (d *daemon) lookupOwnDevice(src netip.Addr) (allowed bool, who string) {
if d.lc == nil {
return false, "unknown"
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
st, err := d.lc.StatusWithoutPeers(ctx)
if err != nil || st.Self == nil {
return false, "unknown"
}
// WhoIs wants an address with a port; the port plays no part for a
// tailnet address.
res, err := d.lc.WhoIs(ctx, netip.AddrPortFrom(src, 1).String())
if err != nil || res.Node == nil {
return false, "unknown"
}
who = res.Node.Name
if res.UserProfile != nil && res.UserProfile.LoginName != "" {
who += ", " + res.UserProfile.LoginName
}
self := identity{User: st.Self.UserID, Tagged: st.Self.IsTagged()}
peer := identity{User: res.Node.User, Tagged: res.Node.IsTagged(), DNSName: res.Node.Name}
return ownDevice(self, peer, st.MagicDNSSuffix), who
}
// allowedFromAddr is allowedFrom for the address of a datagram.
func (d *daemon) allowedFromAddr(from net.Addr) bool {
ap, err := netip.ParseAddrPort(from.String())
if err != nil {
return false
}
return d.allowedFrom(ap.Addr())
}
+151
View File
@@ -0,0 +1,151 @@
package main
import (
"context"
"net"
"net/netip"
"testing"
"time"
)
func TestOwnDevice(t *testing.T) {
const suffix = "tail1234.ts.net"
me := identity{User: 7}
for _, tt := range []struct {
name string
self identity
peer identity
want bool
}{
{"same user", me, identity{User: 7, DNSName: "pc.tail1234.ts.net."}, true},
{"another user of the tailnet", me, identity{User: 8, DNSName: "pc.tail1234.ts.net."}, false},
{"tagged device of the tailnet", me, identity{User: 7, Tagged: true, DNSName: "srv.tail1234.ts.net."}, false},
{"shared in from another tailnet", me, identity{User: 9, DNSName: "pc.other.ts.net."}, false},
{"another tailnet claiming the same user", me, identity{User: 7, DNSName: "pc.other.ts.net."}, false},
{"unknown user", me, identity{DNSName: "pc.tail1234.ts.net."}, false},
{"tagged console, device of its tailnet", identity{Tagged: true}, identity{User: 8, DNSName: "pc.tail1234.ts.net."}, true},
{"tagged console, shared device", identity{Tagged: true}, identity{User: 8, DNSName: "pc.other.ts.net."}, false},
} {
if got := ownDevice(tt.self, tt.peer, suffix); got != tt.want {
t.Errorf("%s: got %v, want %v", tt.name, got, tt.want)
}
}
}
func TestAccessCache(t *testing.T) {
var c accessCache
a := netip.MustParseAddr("100.64.0.2")
if _, ok := c.get(a); ok {
t.Fatal("an empty cache had an answer")
}
c.put(a, true)
if allowed, ok := c.get(a); !ok || !allowed {
t.Fatalf("got %v, %v", allowed, ok)
}
c.clear()
if _, ok := c.get(a); ok {
t.Fatal("the cache kept its answer after clear")
}
}
// With the default setting nothing is asked and everything is allowed.
func TestAllowedFromDefault(t *testing.T) {
d := &daemon{cfg: defaultConfig(), logf: t.Logf}
if !d.allowedFrom(netip.MustParseAddr("100.64.0.9")) {
t.Error("the default setting turned a device away")
}
// Limited to own devices, a device that cannot be identified is refused.
d.cfg.AllowFrom = accessOwn
if d.allowedFrom(netip.MustParseAddr("100.64.0.9")) {
t.Error("an unidentified device was let in")
}
}
func TestKeyWarnStage(t *testing.T) {
now := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC)
day := 24 * time.Hour
for _, tt := range []struct {
left time.Duration
stage int
days int
}{
{90 * day, -1, 90},
{14*day + time.Hour, -1, 14},
{14 * day, 0, 14},
{5 * day, 0, 5},
{3 * day, 1, 3},
{36 * time.Hour, 1, 1},
{20 * time.Hour, 2, 0},
{-time.Hour, 2, -1},
} {
expiry := now.Add(tt.left)
if got := keyWarnStage(now, expiry); got != tt.stage {
t.Errorf("%v left: stage %d, want %d", tt.left, got, tt.stage)
}
if got := daysLeft(now, expiry); got != tt.days {
t.Errorf("%v left: %d days, want %d", tt.left, got, tt.days)
}
}
if plural(1, "day") != "1 day" || plural(3, "day") != "3 days" {
t.Error("plural")
}
}
// A relay with an allow function serves the clients it accepts and stays
// silent towards the ones it turns down.
func TestUDPRelayAllow(t *testing.T) {
echo, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer echo.Close()
go func() {
buf := make([]byte, 1500)
for {
n, from, err := echo.ReadFrom(buf)
if err != nil {
return
}
echo.WriteTo(buf[:n], from)
}
}()
for _, allow := range []bool{true, false} {
var relayAddr net.Addr
relay, err := startUDPRelay(udpRelayConfig{
name: "test",
listen: func() (net.PacketConn, error) {
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
if err == nil {
relayAddr = pc.LocalAddr()
}
return pc, err
},
dial: func(ctx context.Context) (net.Conn, error) {
var d net.Dialer
return d.DialContext(ctx, "udp", echo.LocalAddr().String())
},
allow: func(net.Addr) bool { return allow },
logf: t.Logf,
})
if err != nil {
t.Fatal(err)
}
c, err := net.Dial("udp", relayAddr.String())
if err != nil {
t.Fatal(err)
}
c.Write([]byte("ping"))
c.SetReadDeadline(time.Now().Add(700 * time.Millisecond))
buf := make([]byte, 16)
n, err := c.Read(buf)
if allow && (err != nil || string(buf[:n]) != "ping") {
t.Errorf("allowed client: got %q, %v", buf[:n], err)
}
if !allow && err == nil {
t.Errorf("refused client got a reply: %q", buf[:n])
}
c.Close()
relay.stop()
}
}
+248
View File
@@ -0,0 +1,248 @@
package main
import (
"crypto/pbkdf2"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"io"
"net"
"net/http"
"net/netip"
"strconv"
"strings"
"sync"
"time"
)
// The status page can be given a password. Without one it trusts whoever can
// reach it, like the other services on a jailbroken console. With one, the
// page and its API ask for it, except from the console itself: someone at
// the console can do anything anyway, and typing a password with a
// controller is no fun.
//
// A browser that has entered the password gets a session cookie.
const (
sessionCookie = "ps5ts_session"
sessionLifetime = 30 * 24 * time.Hour
pbkdf2Rounds = 210_000
)
// hashPassword returns the stored form of a password:
// "pbkdf2-sha256$<rounds>$<salt hex>$<key hex>".
func hashPassword(password string) (string, error) {
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return "", err
}
key, err := pbkdf2.Key(sha256.New, password, salt, pbkdf2Rounds, 32)
if err != nil {
return "", err
}
return "pbkdf2-sha256$" + strconv.Itoa(pbkdf2Rounds) + "$" + hex.EncodeToString(salt) + "$" + hex.EncodeToString(key), nil
}
// checkPassword reports whether password matches a stored hash.
func checkPassword(stored, password string) bool {
parts := strings.Split(stored, "$")
if len(parts) != 4 || parts[0] != "pbkdf2-sha256" {
return false
}
rounds, err := strconv.Atoi(parts[1])
if err != nil || rounds < 1 || rounds > 10_000_000 {
return false
}
salt, err1 := hex.DecodeString(parts[2])
want, err2 := hex.DecodeString(parts[3])
if err1 != nil || err2 != nil || len(want) == 0 {
return false
}
got, err := pbkdf2.Key(sha256.New, password, salt, rounds, len(want))
return err == nil && subtle.ConstantTimeCompare(got, want) == 1
}
// sessions are the browsers that have entered the password.
type sessions struct {
mu sync.Mutex
tokens map[string]time.Time // token -> expiry
attempt sync.Mutex // serializes password attempts
}
func (s *sessions) create() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
token := hex.EncodeToString(b)
s.mu.Lock()
defer s.mu.Unlock()
if s.tokens == nil {
s.tokens = map[string]time.Time{}
}
now := time.Now()
for t, exp := range s.tokens {
if now.After(exp) {
delete(s.tokens, t)
}
}
s.tokens[token] = now.Add(sessionLifetime)
return token, nil
}
func (s *sessions) valid(token string) bool {
s.mu.Lock()
defer s.mu.Unlock()
exp, ok := s.tokens[token]
return ok && time.Now().Before(exp)
}
func (s *sessions) remove(token string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.tokens, token)
}
// clear ends every session, for when the password changes.
func (s *sessions) clear() {
s.mu.Lock()
defer s.mu.Unlock()
s.tokens = nil
}
// fromConsole reports whether the request was made on the console itself.
// Connections from the tailnet are served directly (see tailnetListener), so
// they arrive with their tailnet address, not as loopback.
func fromConsole(r *http.Request) bool {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return false
}
ip, err := netip.ParseAddr(host)
return err == nil && ip.Unmap().IsLoopback()
}
// authorized reports whether the request may use the page: there is no
// password, it comes from the console itself, or it carries a session.
func (d *daemon) authorized(r *http.Request) bool {
d.mu.Lock()
hash := d.cfg.PasswordHash
d.mu.Unlock()
if hash == "" || fromConsole(r) {
return true
}
c, err := r.Cookie(sessionCookie)
return err == nil && d.sessions.valid(c.Value)
}
// protect wraps a handler that needs the password, if one is set.
func (d *daemon) protect(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !d.authorized(r) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]any{"locked": true, "version": version})
return
}
h(w, r)
}
}
// handleAuth checks a password and starts a session.
func (d *daemon) handleAuth(w http.ResponseWriter, r *http.Request) {
var req struct {
Password string `json:"password"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&req); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
d.mu.Lock()
hash := d.cfg.PasswordHash
d.mu.Unlock()
// One attempt at a time, and a wrong one costs a second: enough to make
// guessing over the network pointless.
d.sessions.attempt.Lock()
ok := hash == "" || checkPassword(hash, req.Password)
if !ok {
time.Sleep(time.Second)
}
d.sessions.attempt.Unlock()
if !ok {
d.logf("status page: wrong password from %s", r.RemoteAddr)
http.Error(w, "wrong password", http.StatusForbidden)
return
}
token, err := d.sessions.create()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: token,
Path: "/",
MaxAge: int(sessionLifetime.Seconds()),
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
})
io.WriteString(w, "ok\n")
}
// handleLock ends the browser's session.
func (d *daemon) handleLock(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(sessionCookie); err == nil {
d.sessions.remove(c.Value)
}
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteStrictMode})
io.WriteString(w, "ok\n")
}
// tailnetListener hands the status page's server the connections that arrive
// for it over the tailnet. They could be piped to the page's port on
// localhost like any other, but then every tailnet device would look like
// the console itself and get past the password.
type tailnetListener struct {
conns chan net.Conn
closed chan struct{}
once sync.Once
}
func newTailnetListener() *tailnetListener {
return &tailnetListener{conns: make(chan net.Conn, 16), closed: make(chan struct{})}
}
// deliver gives a tailnet connection to the server.
func (l *tailnetListener) deliver(c net.Conn) {
select {
case l.conns <- c:
case <-l.closed:
c.Close()
}
}
func (l *tailnetListener) Accept() (net.Conn, error) {
select {
case c := <-l.conns:
return c, nil
case <-l.closed:
return nil, net.ErrClosed
}
}
func (l *tailnetListener) Close() error {
l.once.Do(func() { close(l.closed) })
return nil
}
func (l *tailnetListener) Addr() net.Addr { return tailnetAddr{} }
type tailnetAddr struct{}
func (tailnetAddr) Network() string { return "tailnet" }
func (tailnetAddr) String() string { return "tailnet" }
+385
View File
@@ -0,0 +1,385 @@
// Command signrelease manages the release signing key and signs payloads.
//
// go run ./cmd/signrelease keygen create the key (once)
// go run ./cmd/signrelease pubkey print the public key
// go run ./cmd/signrelease sign -version 1.2.3 -file tailscale.elf
// go run ./cmd/signrelease verify -file tailscale.elf
// 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 .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.
package main
import (
"crypto/aes"
"crypto/cipher"
"crypto/ed25519"
"crypto/pbkdf2"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"golang.org/x/term"
"ps5tailscale/relsig"
)
const (
keyHeading = "ps5-tailscale release signing key. Keep this file private."
backupHeading = "ps5-tailscale release signing key, encrypted backup."
kdfRounds = 600_000
)
func main() {
if len(os.Args) < 2 {
usage()
}
var err error
switch cmd, args := os.Args[1], os.Args[2:]; cmd {
case "keygen":
err = keygen(args)
case "pubkey":
err = pubkey(args)
case "sign":
err = sign(args)
case "verify":
err = verify(args)
case "backup":
err = backup(args)
case "restore":
err = restore(args)
default:
usage()
}
if err != nil {
fmt.Fprintln(os.Stderr, "signrelease:", err)
os.Exit(1)
}
}
func usage() {
fmt.Fprintln(os.Stderr, "usage: signrelease keygen | pubkey | sign -version V -file F | verify -file F | backup -out F | restore -in F")
os.Exit(2)
}
// keyPath is where the signing key is kept on this machine.
func keyPath() (string, error) {
if p := os.Getenv("PS5TS_SIGNING_KEY"); p != "" {
return p, nil
}
// 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
}
func b64(b []byte) string { return base64.StdEncoding.EncodeToString(b) }
func writeKey(path string, key ed25519.PrivateKey) error {
if _, err := os.Stat(path); err == nil {
return fmt.Errorf("%s already exists; refusing to overwrite a signing key", path)
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
text := fmt.Sprintf("%s\nprivate: %s\npublic: %s\n", keyHeading, b64(key.Seed()), b64(key.Public().(ed25519.PublicKey)))
return os.WriteFile(path, []byte(text), 0o600)
}
func loadKey() (ed25519.PrivateKey, string, error) {
path, err := keyPath()
if err != nil {
return nil, "", err
}
b, err := os.ReadFile(path)
if err != nil {
return nil, path, fmt.Errorf("no signing key (%w); run keygen, or restore a backup", err)
}
fields, err := relsig.ParseFields(b)
if err != nil {
return nil, path, err
}
seed, err := base64.StdEncoding.DecodeString(fields["private"])
if err != nil || len(seed) != ed25519.SeedSize {
return nil, path, fmt.Errorf("%s is not a signing key", path)
}
return ed25519.NewKeyFromSeed(seed), path, nil
}
func keygen(args []string) error {
path, err := keyPath()
if err != nil {
return err
}
_, key, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return err
}
if err := writeKey(path, key); err != nil {
return err
}
fmt.Printf("signing key written to %s\npublic key: %s\n", path, b64(key.Public().(ed25519.PublicKey)))
fmt.Println("Make a backup now: signrelease backup -out <file>")
return nil
}
func pubkey(args []string) error {
key, _, err := loadKey()
if err != nil {
return err
}
fmt.Println(b64(key.Public().(ed25519.PublicKey)))
return nil
}
func fileSum(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
func sign(args []string) error {
fs := flag.NewFlagSet("sign", flag.ExitOnError)
version := fs.String("version", "", "the release's version, e.g. 1.2.3")
file := fs.String("file", "", "the payload to sign")
out := fs.String("out", "", "the signature file (default: the payload's name plus "+relsig.FileSuffix+")")
fs.Parse(args)
if *version == "" || *file == "" {
return errors.New("sign needs -version and -file")
}
key, _, err := loadKey()
if err != nil {
return err
}
sum, err := fileSum(*file)
if err != nil {
return err
}
sig, err := relsig.Sign(key, *version, sum)
if err != nil {
return err
}
if *out == "" {
*out = *file + relsig.FileSuffix
}
if err := os.WriteFile(*out, sig.Marshal(), 0o644); err != nil {
return err
}
fmt.Printf("signed %s as version %s -> %s\n", *file, sig.Version, *out)
return nil
}
func verify(args []string) error {
fs := flag.NewFlagSet("verify", flag.ExitOnError)
file := fs.String("file", "", "the payload")
sigFile := fs.String("sig", "", "the signature file (default: the payload's name plus "+relsig.FileSuffix+")")
pub := fs.String("pubkey", "", "the public key to check against (default: this machine's signing key)")
fs.Parse(args)
if *file == "" {
return errors.New("verify needs -file")
}
if *sigFile == "" {
*sigFile = *file + relsig.FileSuffix
}
var public ed25519.PublicKey
if *pub != "" {
p, err := relsig.ParsePublicKey(*pub)
if err != nil {
return err
}
public = p
} else {
key, _, err := loadKey()
if err != nil {
return err
}
public = key.Public().(ed25519.PublicKey)
}
b, err := os.ReadFile(*sigFile)
if err != nil {
return err
}
sig, err := relsig.Parse(b)
if err != nil {
return err
}
sum, err := fileSum(*file)
if err != nil {
return err
}
if sum != sig.SHA256 {
return errors.New("the payload does not match the signature file")
}
if !sig.Verify(public) {
return errors.New("the signature is not valid for this key")
}
fmt.Printf("ok: version %s, sha256 %s\n", sig.Version, sig.SHA256)
return nil
}
// readPassphrase asks for a passphrase without showing it.
func readPassphrase(prompt string) ([]byte, error) {
fmt.Fprint(os.Stderr, prompt)
fd := int(os.Stdin.Fd())
if !term.IsTerminal(fd) {
return nil, errors.New("a passphrase has to be typed at a terminal")
}
p, err := term.ReadPassword(fd)
fmt.Fprintln(os.Stderr)
return p, err
}
func sealer(passphrase, salt []byte) (cipher.AEAD, error) {
k, err := pbkdf2.Key(sha256.New, string(passphrase), salt, kdfRounds, 32)
if err != nil {
return nil, err
}
block, err := aes.NewCipher(k)
if err != nil {
return nil, err
}
return cipher.NewGCM(block)
}
func backup(args []string) error {
fs := flag.NewFlagSet("backup", flag.ExitOnError)
out := fs.String("out", "", "where to write the encrypted backup")
fs.Parse(args)
if *out == "" {
return errors.New("backup needs -out")
}
if _, err := os.Stat(*out); err == nil {
return fmt.Errorf("%s already exists", *out)
}
key, _, err := loadKey()
if err != nil {
return err
}
p1, err := readPassphrase("Passphrase for the backup: ")
if err != nil {
return err
}
if len(p1) < 8 {
return errors.New("use at least 8 characters")
}
p2, err := readPassphrase("Again: ")
if err != nil {
return err
}
if string(p1) != string(p2) {
return errors.New("the passphrases differ")
}
text, err := sealBackup(key, p1)
if err != nil {
return err
}
if err := os.WriteFile(*out, []byte(text), 0o600); err != nil {
return err
}
fmt.Printf("encrypted backup written to %s\nWithout the passphrase it cannot be restored; keep the passphrase somewhere else.\n", *out)
return nil
}
func restore(args []string) error {
fs := flag.NewFlagSet("restore", flag.ExitOnError)
in := fs.String("in", "", "the encrypted backup")
fs.Parse(args)
if *in == "" {
return errors.New("restore needs -in")
}
path, err := keyPath()
if err != nil {
return err
}
if _, err := os.Stat(path); err == nil {
return fmt.Errorf("%s already exists; refusing to overwrite a signing key", path)
}
b, err := os.ReadFile(*in)
if err != nil {
return err
}
pass, err := readPassphrase("Passphrase of the backup: ")
if err != nil {
return err
}
key, err := openBackup(b, pass)
if err != nil {
return err
}
public := key.Public().(ed25519.PublicKey)
if err := writeKey(path, key); err != nil {
return err
}
fmt.Printf("signing key restored to %s\npublic key: %s\n", path, b64(public))
return nil
}
// sealBackup encrypts the key with a passphrase.
func sealBackup(key ed25519.PrivateKey, passphrase []byte) (string, error) {
salt, nonce := make([]byte, 16), make([]byte, 12)
rand.Read(salt)
rand.Read(nonce)
aead, err := sealer(passphrase, salt)
if err != nil {
return "", err
}
public := key.Public().(ed25519.PublicKey)
// The public key is bound to the ciphertext, so a backup cannot be
// relabelled as another key's.
data := aead.Seal(nil, nonce, key.Seed(), public)
return fmt.Sprintf("%s\nRestore with: signrelease restore -in <this file>\nkdf: pbkdf2-sha256\nrounds: %d\nsalt: %s\nnonce: %s\ndata: %s\npublic: %s\n",
backupHeading, kdfRounds, b64(salt), b64(nonce), b64(data), b64(public)), nil
}
// openBackup decrypts a backup.
func openBackup(b, passphrase []byte) (ed25519.PrivateKey, error) {
fields, err := relsig.ParseFields(b)
if err != nil {
return nil, err
}
dec := func(name string) []byte {
v, _ := base64.StdEncoding.DecodeString(fields[name])
return v
}
rounds, _ := strconv.Atoi(fields["rounds"])
salt, nonce, data, public := dec("salt"), dec("nonce"), dec("data"), dec("public")
if fields["kdf"] != "pbkdf2-sha256" || rounds != kdfRounds || len(salt) == 0 || len(nonce) != 12 || len(public) != ed25519.PublicKeySize {
return nil, errors.New("not a backup this version understands")
}
aead, err := sealer(passphrase, salt)
if err != nil {
return nil, err
}
seed, err := aead.Open(nil, nonce, data, public)
if err != nil || len(seed) != ed25519.SeedSize {
return nil, errors.New("wrong passphrase, or the backup is damaged")
}
key := ed25519.NewKeyFromSeed(seed)
if !key.Public().(ed25519.PublicKey).Equal(ed25519.PublicKey(public)) {
return nil, errors.New("the backup is inconsistent")
}
return key, nil
}
+31
View File
@@ -0,0 +1,31 @@
package main
import (
"crypto/ed25519"
"strings"
"testing"
)
func TestBackupRoundTrip(t *testing.T) {
_, key, err := ed25519.GenerateKey(nil)
if err != nil {
t.Fatal(err)
}
text, err := sealBackup(key, []byte("correct horse"))
if err != nil {
t.Fatal(err)
}
if strings.Contains(text, b64(key.Seed())) {
t.Fatal("the backup contains the key in the clear")
}
got, err := openBackup([]byte(text), []byte("correct horse"))
if err != nil || !got.Equal(key) {
t.Fatalf("restore: %v", err)
}
if _, err := openBackup([]byte(text), []byte("wrong")); err == nil {
t.Error("a wrong passphrase was accepted")
}
if _, err := openBackup([]byte("nonsense"), []byte("x")); err == nil {
t.Error("nonsense was accepted as a backup")
}
}
+62 -11
View File
@@ -9,6 +9,7 @@ import (
)
// config is read from /data/tailscale/config.json. Every field is optional.
// Most of it can be edited on the status page.
type config struct {
// Hostname is the name this console gets on the tailnet.
Hostname string `json:"hostname"`
@@ -16,15 +17,21 @@ type config struct {
AuthKey string `json:"authKey,omitempty"`
// WebAddr is where the status page listens.
WebAddr string `json:"webAddr"`
// HTTPProxyAddr is where the outbound HTTP proxy listens. Pointing the
// PS5's proxy setting at it lets the console reach tailnet hosts. Empty
// disables the proxy.
// PasswordHash protects the status page. Empty means no password. It is
// set from the status page; delete the field to remove a forgotten
// password.
PasswordHash string `json:"passwordHash,omitempty"`
// HTTPProxyAddr is where the outbound HTTP proxy listens. Empty, the
// default, turns the proxy off.
HTTPProxyAddr string `json:"httpProxyAddr"`
// ControlURL selects a coordination server other than Tailscale's.
ControlURL string `json:"controlURL,omitempty"`
// SunshineHost is a tailnet device running Sunshine. When set, its
// streaming ports are forwarded from 127.0.0.1, so a Moonlight client on
// the console can use 127.0.0.1 as the host.
// SunshineHosts are tailnet devices running Sunshine. Their streaming
// ports are forwarded from 127.0.0.1, so a Moonlight client on the
// console can use 127.0.0.1 as the host.
SunshineHosts []sunshineHost `json:"sunshineHosts,omitempty"`
// SunshineHost is the single-host setting of earlier versions. It is
// folded into SunshineHosts when the config is loaded.
SunshineHost string `json:"sunshineHost,omitempty"`
// Forwards are extra local forwards: a localhost port on the console
// relayed to a host on the tailnet.
@@ -35,16 +42,38 @@ type config struct {
UDPPorts []uint16 `json:"udpPorts"`
// BlockedPorts lists local TCP ports that are never exposed to the tailnet.
BlockedPorts []uint16 `json:"blockedPorts,omitempty"`
// AllowFrom limits which tailnet devices may reach the console's services:
// empty for every device the tailnet's access rules allow, "own" for only
// the devices of the user this console is logged in as.
AllowFrom string `json:"allowFrom,omitempty"`
// PayloadPath names the copy of the payload that is started at boot, for
// example in a payload manager's folder. An update installed from the
// status page replaces that file too. Empty means there is none to keep
// up to date.
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.
Priority string `json:"priority,omitempty"`
// CheckUpdates makes the daemon ask GitHub now and then whether a newer
// release exists, to say so on the status page.
CheckUpdates bool `json:"checkUpdates"`
// Verbose turns on Tailscale's own (very chatty) logging.
Verbose bool `json:"verbose,omitempty"`
}
func defaultConfig() config {
return config{
Hostname: "ps5",
WebAddr: ":8090",
HTTPProxyAddr: "127.0.0.1:8118",
UDPPorts: slices.Clone(remotePlayUDPPorts),
Hostname: "ps5",
WebAddr: ":8090",
ReceiveDir: defaultReceiveDir,
UDPPorts: slices.Clone(remotePlayUDPPorts),
CheckUpdates: true,
}
}
@@ -62,13 +91,35 @@ func loadConfig(path string) (config, error) {
if err := json.Unmarshal(b, &cfg); err != nil {
return defaultConfig(), err
}
cfg.normalize()
return cfg, nil
}
// normalize fills in what must not be empty and brings settings from earlier
// versions into their current form.
func (cfg *config) normalize() {
if cfg.Hostname == "" {
cfg.Hostname = "ps5"
}
if cfg.WebAddr == "" {
cfg.WebAddr = ":8090"
}
return cfg, nil
if cfg.SunshineHost != "" {
known := slices.ContainsFunc(cfg.SunshineHosts, func(h sunshineHost) bool { return h.Host == cfg.SunshineHost })
if !known {
cfg.SunshineHosts = append(cfg.SunshineHosts, sunshineHost{Host: cfg.SunshineHost})
}
cfg.SunshineHost = ""
}
if cfg.ReceiveDir == "" {
cfg.ReceiveDir = defaultReceiveDir
}
if cfg.AllowFrom != accessOwn {
cfg.AllowFrom = ""
}
if cfg.Priority != priorityHigh {
cfg.Priority = ""
}
}
func saveConfig(path string, cfg config) error {
+200
View File
@@ -0,0 +1,200 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"regexp"
"runtime"
"slices"
"strings"
"time"
)
// The diagnostics file: everything someone helping with a problem needs, in
// one download, so that a bug report does not depend on getting files off
// the console by hand.
//
// It goes on the internet when it is attached to a report, so what can be
// left out without making it useless is left out or blanked: the password
// hash, auth keys, login links, e-mail addresses, the tailnet's name and
// public IP addresses. Device names and tailnet addresses stay; without them
// the logs cannot be followed.
const (
diagLauncherTail = 64 << 10
diagMainTail = 256 << 10
diagDebugTail = 512 << 10
)
var (
reEmail = regexp.MustCompile(`[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}`)
reTailnet = regexp.MustCompile(`\b([A-Za-z0-9-]+)\.[A-Za-z0-9-]+\.ts\.net\b`)
// The tailnet's name on its own, as it appears in DNS settings.
reTailnetBare = regexp.MustCompile(`\b[A-Za-z0-9-]+\.ts\.net\b`)
reLoginURL = regexp.MustCompile(`https://login\.tailscale\.com/a/[A-Za-z0-9]+`)
reAuthKey = regexp.MustCompile(`tskey-[A-Za-z0-9-]+`)
reIPv4 = regexp.MustCompile(`\b(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\b`)
reFirmware = regexp.MustCompile(`firmware (\d+\.\d+)`)
)
// scrub blanks what should not be published. It keeps the shape of the
// text, so the logs still read as logs.
func scrub(b []byte) []byte {
b = reLoginURL.ReplaceAll(b, []byte("https://login.tailscale.com/a/<removed>"))
b = reAuthKey.ReplaceAll(b, []byte("tskey-<removed>"))
b = reEmail.ReplaceAll(b, []byte("<email>"))
b = reTailnet.ReplaceAll(b, []byte("$1.<tailnet>.ts.net"))
b = reTailnetBare.ReplaceAll(b, []byte("<tailnet>.ts.net"))
return reIPv4.ReplaceAllFunc(b, func(ip []byte) []byte {
if publicIPv4(string(ip)) {
return []byte("<public-ip>")
}
return ip
})
}
// publicIPv4 reports whether s is an address on the internet, as opposed to
// a private, tailnet, loopback or otherwise special one. Text that only
// looks like an address (a version number, say) is left alone.
func publicIPv4(s string) bool {
var a, b, c, d int
if n, _ := fmt.Sscanf(s, "%d.%d.%d.%d", &a, &b, &c, &d); n != 4 || a > 255 || b > 255 || c > 255 || d > 255 {
return false
}
switch {
case a == 0, a == 10, a == 127, a >= 224:
return false
case a == 100 && b >= 64 && b <= 127: // tailnet addresses
return false
case a == 169 && b == 254:
return false
case a == 172 && b >= 16 && b <= 31:
return false
case a == 192 && b == 168:
return false
case a == 192 && b == 0 && c == 2:
return false
}
// A well-known public resolver says nothing about the user.
switch s {
case "1.1.1.1", "8.8.8.8", "9.9.9.9":
return false
}
return true
}
func fileTail(path string, max int64) []byte {
f, err := os.Open(path)
if err != nil {
return []byte("(" + err.Error() + ")\n")
}
defer f.Close()
if fi, err := f.Stat(); err == nil && fi.Size() > max {
f.Seek(fi.Size()-max, io.SeekStart)
}
b, _ := io.ReadAll(onlyReader{f})
return b
}
// diagnostics assembles the file.
func (d *daemon) diagnostics(r *http.Request) []byte {
var out bytes.Buffer
section := func(title string) { fmt.Fprintf(&out, "\n===== %s =====\n", title) }
launcher := fileTail(filepath.Join(dataDir, "launcher.log"), diagLauncherTail)
firmware := "unknown"
if m := reFirmware.FindAllSubmatch(launcher, -1); len(m) > 0 {
firmware = string(m[len(m)-1][1])
}
d.mu.Lock()
cfg := d.cfg
state, lastErr := d.state, d.lastErr
latest := d.latest.Version
d.mu.Unlock()
fmt.Fprintf(&out, "ps5-tailscale diagnostics\n")
fmt.Fprintf(&out, "Please read this file before posting it. E-mail addresses, the tailnet's name, public IP\n")
fmt.Fprintf(&out, "addresses, keys and the password have been removed; device names and tailnet addresses have not.\n\n")
fmt.Fprintf(&out, "version: %s (%s/%s)\n", version, runtime.GOOS, runtime.GOARCH)
fmt.Fprintf(&out, "firmware: %s\n", firmware)
fmt.Fprintf(&out, "created: %s\n", time.Now().UTC().Format("2006-01-02 15:04:05 UTC"))
fmt.Fprintf(&out, "running for: %s\n", time.Since(d.started).Round(time.Second))
fmt.Fprintf(&out, "state: %s\n", state)
if lastErr != "" {
fmt.Fprintf(&out, "last error: %s\n", lastErr)
}
if latest != "" {
fmt.Fprintf(&out, "latest known: %s\n", latest)
}
if d.dns != nil {
fmt.Fprintf(&out, "name lookups: %s\n", d.dns.describe())
}
if d.udp != nil {
fmt.Fprintf(&out, "UDP ports: %v\n", d.udp.activePorts())
}
if d.fwd != nil {
fmt.Fprintf(&out, "forwards: %d active\n", len(d.fwd.rules()))
}
if d.lc != nil {
if st, err := d.status(r.Context()); err == nil {
section("tailscale")
fmt.Fprintf(&out, "backend state: %s\n", st.BackendState)
for _, h := range st.Health {
fmt.Fprintf(&out, "health: %s\n", h)
}
if st.Self != nil {
fmt.Fprintf(&out, "self: %s, addresses %v, relay %q, key expiry %v\n", st.Self.DNSName, st.Self.TailscaleIPs, st.Self.Relay, st.Self.KeyExpiry)
}
peers, vpn := peersFromStatus(st, false)
fmt.Fprintf(&out, "peers: %d, VPN exit servers: %d\n", len(peers), vpn.Total)
for _, p := range peers {
fmt.Fprintf(&out, " %-24s %-16s %-8s online=%-5v %s %s\n", p.Name, p.IP, p.OS, p.Online, p.Kind, strings.TrimSpace(p.Conn+" "+p.Via))
}
} else {
section("tailscale")
fmt.Fprintf(&out, "status: %v\n", err)
}
}
section("settings (password and auth key removed)")
if cfg.PasswordHash != "" {
cfg.PasswordHash = "(set)"
}
if cfg.AuthKey != "" {
cfg.AuthKey = "(set)"
}
// cfg is a copy, but its slices are the daemon's own: copy before
// blanking.
cfg.Wake = slices.Clone(cfg.Wake)
for i := range cfg.Wake {
cfg.Wake[i].MAC = "(set)"
}
if b, err := json.MarshalIndent(cfg, "", " "); err == nil {
out.Write(b)
out.WriteByte('\n')
}
section("launcher.log")
out.Write(launcher)
section("tailscale.log (end)")
out.Write(fileTail(filepath.Join(dataDir, "tailscale.log"), diagMainTail))
section("tailscale-debug.log (end)")
out.Write(fileTail(filepath.Join(dataDir, "tailscale-debug.log"), diagDebugTail))
return scrub(out.Bytes())
}
func (d *daemon) handleDiagnostics(w http.ResponseWriter, r *http.Request) {
name := fmt.Sprintf("ps5-tailscale-diagnostics-%s-%s.txt", version, time.Now().UTC().Format("20060102-150405"))
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`)
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Write(d.diagnostics(r))
}
+191
View File
@@ -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
View File
@@ -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))
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

+27 -6
View File
@@ -20,8 +20,27 @@ import (
// connection is declined, so the peer sees an ordinary "connection refused"
// rather than a connection that opens and closes.
func (d *daemon) forwardToLocalhost(src, dst netip.AddrPort) (handler func(net.Conn), intercept bool) {
ip4, ip6 := d.srv.TailscaleIPs()
if !addressedTo(dst.Addr(), ip4, ip6) {
// Only connections to the console's own tailnet addresses are for
// its services. Nothing else arrives today, but if this node ever
// advertised routes, a connection to any address on a port that is
// open here must not end up at the console's service.
return nil, false
}
if !d.allowedFrom(src.Addr()) {
return nil, false
}
port := dst.Port()
if slices.Contains(d.cfg.BlockedPorts, port) || port == d.proxyPort || d.fwd.listensOnTCP(port) {
if port == d.webPort {
// The status page is served on the tailnet connection itself rather
// than through localhost, so that the page sees who is asking.
return d.tailnetWeb.deliver, true
}
d.mu.Lock()
blocked := slices.Contains(d.cfg.BlockedPorts, port) || port == d.proxyPort
d.mu.Unlock()
if blocked || d.fwd.listensOnTCP(port) {
// The outbound proxy and the local forwards are for the console's
// own apps. Exposing them would let any tailnet device use the
// console as a relay.
@@ -40,15 +59,17 @@ func (d *daemon) forwardToLocalhost(src, dst netip.AddrPort) (handler func(net.C
if !abandoned.Stop() {
return
}
if port != d.webPort {
// The status page polls every few seconds; logging its own
// requests would bury everything else.
d.logf("forward %v -> localhost:%d", src, port)
}
d.logf("forward %v -> localhost:%d", src, port)
pipe(c, local)
}, true
}
// addressedTo reports whether dst is one of the node's own addresses.
func addressedTo(dst netip.Addr, own ...netip.Addr) bool {
dst = dst.Unmap()
return dst.IsValid() && slices.Contains(own, dst)
}
// pipe copies in both directions until both sides are done.
func pipe(a, b net.Conn) {
done := make(chan struct{}, 2)
+22
View File
@@ -3,9 +3,31 @@ package main
import (
"io"
"net"
"net/netip"
"testing"
)
func TestAddressedTo(t *testing.T) {
ip4, ip6 := netip.MustParseAddr("100.64.0.1"), netip.MustParseAddr("fd7a:115c:a1e0::1")
for addr, want := range map[string]bool{
"100.64.0.1": true,
"::ffff:100.64.0.1": true,
"fd7a:115c:a1e0::1": true,
"100.64.0.2": false,
"93.184.216.34": false,
"127.0.0.1": false,
"2606:4700:4700::64": false,
} {
if got := addressedTo(netip.MustParseAddr(addr), ip4, ip6); got != want {
t.Errorf("addressedTo(%s) = %v, want %v", addr, got, want)
}
}
// Before the node has its addresses nothing is for it.
if addressedTo(netip.Addr{}, netip.Addr{}, netip.Addr{}) {
t.Error("an invalid address matched")
}
}
// pipe must pass a half-close through: the ELF loader protocol and FTP data
// connections both rely on the reader seeing EOF while the other direction
// stays open.
+1 -1
View File
@@ -5,6 +5,7 @@ go 1.27.1
require (
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
golang.org/x/crypto/x509roots/fallback v0.0.0-20260929172509-b39ff6d641ec
golang.org/x/term v0.46.0
tailscale.com v1.104.0
)
@@ -46,7 +47,6 @@ require (
golang.org/x/oauth2 v0.37.0 // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/term v0.46.0 // indirect
golang.org/x/text v0.42.0 // indirect
golang.org/x/time v0.16.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
+86
View File
@@ -0,0 +1,86 @@
package main
import (
"bytes"
"errors"
"fmt"
"net"
"os"
"path/filepath"
"strings"
"time"
)
// The home screen icon is installed by a small helper payload that the
// launcher carries (see appicon/ and launcher/homeicon.c). The launcher also
// leaves a copy of the helper in the data directory, so that Uninstall can
// run it again to take the icon away: the system call for that lives in
// libraries this process must not load.
//
// The helper installs or removes depending on one byte in it, after a marker
// string; removing is a matter of flipping that byte before sending it to
// the ELF loader.
const (
iconHelperFile = "icon-helper.elf"
iconModeMarker = "TSICON-MODE="
iconModeRemove = 'R'
elfLoaderAddr = "127.0.0.1:9021"
iconHelperTimeout = 20 * time.Second
)
// removeHomeIcon takes the Tailscale icon off the home screen.
func removeHomeIcon() error {
helper, err := os.ReadFile(filepath.Join(dataDir, iconHelperFile))
if err != nil {
return err
}
i := bytes.Index(helper, []byte(iconModeMarker))
if i < 0 || i+len(iconModeMarker) >= len(helper) {
return errors.New("the icon helper is not one this version understands")
}
helper[i+len(iconModeMarker)] = iconModeRemove
c, err := net.DialTimeout("tcp", elfLoaderAddr, 3*time.Second)
if err != nil {
return fmt.Errorf("no ELF loader to run the icon helper: %w", err)
}
defer c.Close()
c.SetDeadline(time.Now().Add(iconHelperTimeout))
if _, err := c.Write(helper); err != nil {
return err
}
// The helper prints "icon: removed" or what went wrong, and exits.
var reply []byte
buf := make([]byte, 512)
for len(reply) < 4096 {
n, err := c.Read(buf)
reply = append(reply, buf[:n]...)
if line := iconReplyLine(reply); line != "" {
if strings.HasPrefix(line, "icon: removed") {
return nil
}
return errors.New(line)
}
if err != nil {
break
}
}
return errors.New("no answer from the icon helper")
}
// iconReplyLine returns the helper's complete "icon: ..." line, if it has
// arrived.
func iconReplyLine(reply []byte) string {
i := bytes.Index(reply, []byte("icon: "))
if i < 0 {
return ""
}
rest := reply[i:]
j := bytes.IndexByte(rest, '\n')
if j < 0 {
return ""
}
return strings.TrimSpace(string(rest[:j]))
}
+14 -9
View File
@@ -31,26 +31,30 @@ type udpExposer struct {
logf func(format string, args ...any)
// targetHost is where the console's services are reached.
targetHost string
// allow, if set, decides which senders are served.
allow func(from net.Addr) bool
mu sync.Mutex
addrs []netip.Addr
ports []uint16
stops []func()
relays []*udpRelay
active []uint16
}
// update makes ports reachable on addrs, replacing whatever was exposed
// before. It does nothing if neither has changed.
// before. It does nothing if neither has changed and every relay is still
// running.
func (e *udpExposer) update(addrs []netip.Addr, ports []uint16) {
e.mu.Lock()
defer e.mu.Unlock()
if slices.Equal(addrs, e.addrs) && slices.Equal(ports, e.ports) {
healthy := !slices.ContainsFunc(e.relays, func(r *udpRelay) bool { return !r.running() })
if healthy && slices.Equal(addrs, e.addrs) && slices.Equal(ports, e.ports) {
return
}
for _, stop := range e.stops {
stop()
for _, r := range e.relays {
r.stop()
}
e.stops, e.active = nil, nil
e.relays, e.active = nil, nil
e.addrs, e.ports = slices.Clone(addrs), slices.Clone(ports)
for _, port := range ports {
@@ -62,20 +66,21 @@ func (e *udpExposer) update(addrs []netip.Addr, ports []uint16) {
network = "udp6"
}
listenAddr := netip.AddrPortFrom(addr, port).String()
stop, err := startUDPRelay(udpRelayConfig{
relay, err := startUDPRelay(udpRelayConfig{
name: "udp " + listenAddr,
listen: func() (net.PacketConn, error) { return e.listen(network, listenAddr) },
dial: func(ctx context.Context) (net.Conn, error) {
var d net.Dialer
return d.DialContext(ctx, "udp", target)
},
logf: e.logf,
allow: e.allow,
logf: e.logf,
})
if err != nil {
e.logf("udp %s: %v", listenAddr, err)
continue
}
e.stops = append(e.stops, stop)
e.relays = append(e.relays, relay)
ok = true
}
if ok {
+101
View File
@@ -0,0 +1,101 @@
package main
import (
"context"
"fmt"
"time"
)
// A device's Tailscale key expires after a while (180 days unless the
// tailnet says otherwise or expiry is turned off for the device). When that
// happens to a console nobody is looking at, it simply drops off the tailnet.
// The status page shows the date; this warns on screen as it gets close.
// keyWarnDays are the points, in days before the expiry, at which the user
// is told on screen. The status page warns from the first of them on.
var keyWarnDays = []int{14, 3, 1}
// daysLeft is the number of whole days from now until expiry, negative once
// it has passed.
func daysLeft(now, expiry time.Time) int {
d := expiry.Sub(now)
if d < 0 {
return -1
}
return int(d / (24 * time.Hour))
}
// keyWarnStage returns the index of the last warning point that has been
// reached, or -1 if the expiry is still further away than all of them.
func keyWarnStage(now, expiry time.Time) int {
left := expiry.Sub(now)
stage := -1
for i, days := range keyWarnDays {
if left <= time.Duration(days)*24*time.Hour {
stage = i
}
}
return stage
}
// keyExpiry returns when this console's key expires; the zero time if it
// does not expire or is not known.
func (d *daemon) keyExpiry(ctx context.Context) time.Time {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
st, err := d.lc.StatusWithoutPeers(ctx)
if err != nil || st.Self == nil || st.Self.KeyExpiry == nil {
return time.Time{}
}
return *st.Self.KeyExpiry
}
// watchKeyExpiry tells the user on screen when the key is about to expire:
// once for each warning point reached while this process runs.
func (d *daemon) watchKeyExpiry(ctx context.Context) {
ticker := time.NewTicker(time.Hour)
defer ticker.Stop()
warned := -1
first := time.After(2 * time.Minute)
for {
select {
case <-ctx.Done():
return
case <-first:
case <-ticker.C:
}
d.mu.Lock()
running := d.state == "Running"
d.mu.Unlock()
if !running {
continue
}
expiry := d.keyExpiry(ctx)
if expiry.IsZero() {
warned = -1
continue
}
now := time.Now()
stage := keyWarnStage(now, expiry)
if stage <= warned || !expiry.After(now) {
if stage < warned {
warned = stage // the key was renewed
}
continue
}
warned = stage
left := "in less than a day"
if n := daysLeft(now, expiry); n >= 1 {
left = "in " + plural(n, "day")
}
d.logf("this console's Tailscale key expires %s (%s)", left, expiry.Local().Format("2006-01-02"))
notify("Tailscale: this PS5's key expires %s.\nLog in again or turn off key expiry.\n%s", left, d.webURL())
}
}
func plural(n int, word string) string {
if n == 1 {
return "1 " + word
}
return fmt.Sprintf("%d %ss", n, word)
}
+5
View File
@@ -18,6 +18,8 @@ type resilientListener struct {
network string
addr string
logf func(format string, args ...any)
// onReopen, if set, is called after the socket had to be reopened.
onReopen func()
mu sync.Mutex
ln net.Listener
@@ -80,6 +82,9 @@ func (l *resilientListener) reopen() bool {
l.ln = ln
l.mu.Unlock()
l.logf("listener %s: reopened", l.addr)
if l.onReopen != nil {
l.onReopen()
}
return true
}
}
+76 -16
View File
@@ -30,30 +30,86 @@ func (r forwardRule) String() string {
return fmt.Sprintf("%s %s -> %s", r.Proto, r.Listen, r.Target)
}
// Ports a Sunshine host uses with its default base port (47989).
// sunshineHost is a device on the tailnet that runs Sunshine.
type sunshineHost struct {
Host string `json:"host"`
// Port is Sunshine's "port" setting, which all its other ports are
// derived from. 0 means the default, 47989.
Port int `json:"port,omitempty"`
}
const sunshineDefaultPort = 47989
func (h sunshineHost) basePort() int {
if h.Port == 0 {
return sunshineDefaultPort
}
return h.Port
}
// Sunshine's ports as offsets from its "port" setting.
var (
sunshineTCPPorts = []int{47984, 47989, 48010} // HTTPS, HTTP, RTSP
sunshineUDPPorts = []int{47998, 47999, 48000, 48002} // video, control, audio, microphone
sunshineTCPOffsets = []int{-5, 0, 21} // HTTPS, HTTP, RTSP
sunshineUDPOffsets = []int{9, 10, 11, 13} // video, control, audio, microphone
)
// sunshineRules returns the forwards that make the Sunshine host on the
// tailnet appear on 127.0.0.1 to a Moonlight client on the console.
func sunshineRules(host string) []forwardRule {
if host == "" {
return nil
}
// rules returns the forwards that make this Sunshine host appear on
// 127.0.0.1, on the same ports it really uses. The ports have to match: the
// host tells the Moonlight client which ports to connect to.
func (h sunshineHost) rules() []forwardRule {
var rules []forwardRule
for _, p := range sunshineTCPPorts {
port := strconv.Itoa(p)
rules = append(rules, forwardRule{"tcp", net.JoinHostPort("127.0.0.1", port), net.JoinHostPort(host, port)})
add := func(proto string, offsets []int) {
for _, off := range offsets {
port := strconv.Itoa(h.basePort() + off)
rules = append(rules, forwardRule{proto, net.JoinHostPort("127.0.0.1", port), net.JoinHostPort(h.Host, port)})
}
}
for _, p := range sunshineUDPPorts {
port := strconv.Itoa(p)
rules = append(rules, forwardRule{"udp", net.JoinHostPort("127.0.0.1", port), net.JoinHostPort(host, port)})
add("tcp", sunshineTCPOffsets)
add("udp", sunshineUDPOffsets)
return rules
}
// clientAddress is what to enter as the host in a Moonlight client on the
// console to reach this Sunshine host.
func (h sunshineHost) clientAddress() string {
if h.basePort() == sunshineDefaultPort {
return "127.0.0.1"
}
return net.JoinHostPort("127.0.0.1", strconv.Itoa(h.basePort()))
}
// sunshineRules returns the forwards for all hosts.
func sunshineRules(hosts []sunshineHost) []forwardRule {
var rules []forwardRule
for _, h := range hosts {
rules = append(rules, h.rules()...)
}
return rules
}
// validateSunshineHosts checks that the hosts can be forwarded side by side.
// They all share 127.0.0.1, so each needs its own set of ports, which means
// each must use a different port setting in Sunshine.
func validateSunshineHosts(hosts []sunshineHost) error {
used := map[string]string{}
for _, h := range hosts {
if h.Host == "" || !validHostName(h.Host) {
return fmt.Errorf("%q does not look like a host name or address", h.Host)
}
if p := h.basePort(); p < 1024+5 || p > 65535-21 {
return fmt.Errorf("port %d for %s is out of range", p, h.Host)
}
for _, r := range h.rules() {
key := r.Proto + " " + r.Listen
if other, taken := used[key]; taken {
return fmt.Errorf("%s and %s use overlapping ports; give each Sunshine host its own port setting", other, h.Host)
}
used[key] = h.Host
}
}
return nil
}
type dialFunc func(ctx context.Context, network, addr string) (net.Conn, error)
// forwarder runs a set of local forwards.
@@ -152,10 +208,14 @@ func (f *forwarder) serveTCP(c net.Conn, r forwardRule) {
}
func (f *forwarder) startUDP(r forwardRule) (stop func(), err error) {
return startUDPRelay(udpRelayConfig{
relay, err := startUDPRelay(udpRelayConfig{
name: "forward " + r.String(),
listen: func() (net.PacketConn, error) { return net.ListenPacket("udp", r.Listen) },
dial: func(ctx context.Context) (net.Conn, error) { return f.dial(ctx, "udp", r.Target) },
logf: f.logf,
})
if err != nil {
return nil, err
}
return relay.stop, nil
}
+89 -23
View File
@@ -12,10 +12,7 @@ import (
// startEcho runs a TCP and a UDP echo server on the same port and returns it.
func startEcho(t *testing.T) string {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
ln, pc := listenBoth(t)
t.Cleanup(func() { ln.Close() })
go func() {
for {
@@ -26,10 +23,6 @@ func startEcho(t *testing.T) string {
go func() { io.Copy(c, c); c.Close() }()
}
}()
pc, err := net.ListenPacket("udp", ln.Addr().String())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { pc.Close() })
go func() {
buf := make([]byte, 65535)
@@ -44,14 +37,35 @@ func startEcho(t *testing.T) string {
return ln.Addr().String()
}
// freePort returns a localhost address nothing listens on.
// listenBoth opens a TCP and a UDP socket on the same localhost port. A port
// the system hands out for TCP is not always available for UDP (Windows
// reserves ranges per protocol), so it tries until both work.
func listenBoth(t *testing.T) (net.Listener, net.PacketConn) {
t.Helper()
var lastErr error
for range 50 {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
pc, err := net.ListenPacket("udp", ln.Addr().String())
if err == nil {
return ln, pc
}
lastErr = err
ln.Close()
}
t.Fatal(lastErr)
return nil, nil
}
// freePort returns a localhost address nothing listens on, free for both
// TCP and UDP.
func freePort(t *testing.T) string {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
ln, pc := listenBoth(t)
defer ln.Close()
defer pc.Close()
return ln.Addr().String()
}
@@ -134,21 +148,73 @@ func TestLocalForward(t *testing.T) {
}
func TestSunshineRules(t *testing.T) {
if got := sunshineRules(""); got != nil {
t.Errorf("no host: got %v", got)
if got := sunshineRules(nil); got != nil {
t.Errorf("no hosts: got %v", got)
}
rules := sunshineRules("gaming-pc")
if len(rules) != 7 {
t.Fatalf("got %d rules, want 7", len(rules))
}
if got, want := rules[0].String(), "tcp 127.0.0.1:47984 -> gaming-pc:47984"; got != want {
t.Errorf("first rule %q, want %q", got, want)
}
for _, r := range rules {
// Default port: the well-known Sunshine ports.
def := sunshineHost{Host: "gaming-pc"}
var got []string
for _, r := range def.rules() {
got = append(got, r.String())
if !strings.HasPrefix(r.Listen, "127.0.0.1:") {
t.Errorf("%v does not listen on localhost only", r)
}
}
want := []string{
"tcp 127.0.0.1:47984 -> gaming-pc:47984",
"tcp 127.0.0.1:47989 -> gaming-pc:47989",
"tcp 127.0.0.1:48010 -> gaming-pc:48010",
"udp 127.0.0.1:47998 -> gaming-pc:47998",
"udp 127.0.0.1:47999 -> gaming-pc:47999",
"udp 127.0.0.1:48000 -> gaming-pc:48000",
"udp 127.0.0.1:48002 -> gaming-pc:48002",
}
if strings.Join(got, "\n") != strings.Join(want, "\n") {
t.Errorf("default port rules:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n"))
}
if a := def.clientAddress(); a != "127.0.0.1" {
t.Errorf("client address %q", a)
}
// A host on another port keeps its own port numbers, shifted as a set.
alt := sunshineHost{Host: "office-pc", Port: 48989}
if r := alt.rules(); r[0].String() != "tcp 127.0.0.1:48984 -> office-pc:48984" || r[6].String() != "udp 127.0.0.1:49002 -> office-pc:49002" {
t.Errorf("custom port rules: %v", r)
}
if a := alt.clientAddress(); a != "127.0.0.1:48989" {
t.Errorf("client address %q", a)
}
if n := len(sunshineRules([]sunshineHost{def, alt})); n != 14 {
t.Errorf("two hosts: %d rules, want 14", n)
}
}
func TestValidateSunshineHosts(t *testing.T) {
ok := [][]sunshineHost{
nil,
{{Host: "gaming-pc"}},
{{Host: "gaming-pc"}, {Host: "office-pc", Port: 48989}},
{{Host: "100.64.0.2", Port: 50000}},
}
for _, hosts := range ok {
if err := validateSunshineHosts(hosts); err != nil {
t.Errorf("%v: unexpected error %v", hosts, err)
}
}
bad := [][]sunshineHost{
{{Host: ""}},
{{Host: "bad host"}},
{{Host: "a"}, {Host: "b"}}, // same ports
{{Host: "a"}, {Host: "b", Port: 47989 + 5}}, // b's HTTPS port is a's HTTP port
{{Host: "a", Port: 80}}, // too low
{{Host: "a", Port: 65530}}, // derived ports past 65535
}
for _, hosts := range bad {
if err := validateSunshineHosts(hosts); err == nil {
t.Errorf("%v: expected an error", hosts)
}
}
}
func TestIsLocalDestination(t *testing.T) {
+80 -15
View File
@@ -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) {
@@ -88,7 +97,11 @@ func main() {
debug.Printf(format, args...)
}
d := &daemon{cfg: cfg, cfgPath: filepath.Join(dataDir, "config.json"), logf: logf, debug: debug, console: console, started: time.Now()}
// What an update installed from the status page downloaded; this may be
// the very copy that is running now, and it is not needed again.
os.RemoveAll(filepath.Join(dataDir, updateDirName))
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)
@@ -107,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
@@ -115,13 +129,24 @@ type daemon struct {
lastErr string
notified string // last state the user was notified about
lastTsnetMsg string
proxyPort uint16 // port of the outbound HTTP proxy, 0 if disabled
webPort uint16 // port of the status page
lastRelogin time.Time
lastTsnetMsg string
proxyLn *resilientListener // the outbound HTTP proxy, nil if disabled
proxyPort uint16 // its port, 0 if disabled
webPort uint16 // port of the status page
lastRelogin time.Time
lastNetChange time.Time
latest releaseInfo // newest release known, see update.go
update updateProgress // an update being installed, see selfupdate.go
sessions sessions // browsers that have entered the password
access accessCache // recent decisions about who may connect
tailnetWeb *tailnetListener // status page connections arriving over the tailnet
quit chan struct{}
quitOnce sync.Once
// removeDataOnExit is set by Uninstall: delete the data directory once
// everything that writes to it has shut down.
removeDataOnExit bool
}
func (d *daemon) run() error {
@@ -151,8 +176,13 @@ func (d *daemon) run() error {
if err != nil {
return fmt.Errorf("web UI: %w", err)
}
webLn.onReopen = d.networkChanged
d.webPort = webLn.port()
go d.serveWeb(webLn)
d.tailnetWeb = newTailnetListener()
handler := d.webHandler()
go d.serveWeb(webLn, handler)
go d.serveWeb(d.tailnetWeb, handler)
d.writePriorityFile()
if err := d.srv.Start(); err != nil {
return fmt.Errorf("starting tailscale: %w", err)
@@ -162,13 +192,8 @@ func (d *daemon) run() error {
return fmt.Errorf("local client: %w", err)
}
if d.cfg.HTTPProxyAddr != "" {
if ln, err := listenResilient("tcp", d.cfg.HTTPProxyAddr, d.logf); err != nil {
d.logf("http proxy: %v", err)
} else {
d.proxyPort = ln.port()
go d.serveProxy(ln)
}
if err := d.setProxy(d.cfg.HTTPProxyAddr); err != nil {
d.logf("http proxy: %v", err)
}
d.fwd.set(d.localForwardRules())
@@ -177,10 +202,13 @@ func (d *daemon) run() error {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
d.udp = &udpExposer{listen: d.srv.ListenPacket, logf: d.logf, targetHost: "127.0.0.1"}
d.udp = &udpExposer{listen: d.srv.ListenPacket, logf: d.logf, targetHost: "127.0.0.1", allow: d.allowedFromAddr}
go d.watch(ctx)
go d.recoverLogin(ctx)
go d.exposeUDP(ctx)
go d.watchForUpdates(ctx)
go d.watchKeyExpiry(ctx)
go d.collectFiles(ctx)
sigc := make(chan os.Signal, 1)
signal.Notify(sigc, syscall.SIGTERM, syscall.SIGINT)
@@ -192,6 +220,7 @@ func (d *daemon) run() error {
}
cancel()
webLn.Close()
d.tailnetWeb.Close()
done := make(chan struct{})
go func() {
@@ -204,6 +233,13 @@ func (d *daemon) run() error {
d.logf("shutdown timed out")
}
d.logf("stopped")
d.mu.Lock()
remove := d.removeDataOnExit
d.mu.Unlock()
if remove {
os.RemoveAll(dataDir)
}
return nil
}
@@ -216,7 +252,36 @@ func (d *daemon) dialTailnet(ctx context.Context, network, addr string) (net.Con
func (d *daemon) localForwardRules() []forwardRule {
d.mu.Lock()
defer d.mu.Unlock()
return append(sunshineRules(d.cfg.SunshineHost), d.cfg.Forwards...)
return append(sunshineRules(d.cfg.SunshineHosts), d.cfg.Forwards...)
}
// networkChanged is called when a listening socket has died and been
// reopened, which on the PS5 means the network was reconfigured (connection
// settings changed, Wi-Fi to Ethernet, ...). Tailscale notices changes by
// polling the interfaces; this tells it straight away to open fresh sockets
// and work out its addresses again.
func (d *daemon) networkChanged() {
d.mu.Lock()
recent := time.Since(d.lastNetChange) < 10*time.Second
d.lastNetChange = time.Now()
lc := d.lc
d.mu.Unlock()
if d.dns != nil {
d.dns.reset()
}
if recent || lc == nil {
return
}
d.logf("the console's network changed; asking Tailscale to rebind")
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
for _, action := range []string{"rebind", "restun"} {
if err := lc.DebugAction(ctx, action); err != nil {
d.logf("tailscale %s: %v", action, err)
}
}
}()
}
// exposeUDP keeps the configured UDP ports listening on the console's tailnet
+193
View File
@@ -0,0 +1,193 @@
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
// hundreds of exit servers among its peers, so peers are sorted into kinds
// and the exit servers are only sent to the page when it asks for them.
const (
peerOwn = "own" // a device of this tailnet
peerShared = "shared" // a device of another tailnet, shared with this one
peerVPN = "vpn" // an exit server of a VPN add-on
)
// vpnDomains are the DNS suffixes of the exit servers that VPN add-ons put
// in a tailnet. Tailscale's own "status" command hides them the same way.
var vpnDomains = []string{"mullvad.ts.net"}
type peerInfo struct {
Name string `json:"name"`
IP string `json:"ip"`
OS string `json:"os"`
Online bool `json:"online"`
Kind string `json:"kind"`
// ExitNode is "offered" for a device that can be used as an exit node
// and "used" for the one this console uses.
ExitNode string `json:"exitNode,omitempty"`
// Location is where an exit server says it is ("Vienna, Austria").
Location string `json:"location,omitempty"`
// 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.
type peerCount struct {
Total int `json:"total"`
Online int `json:"online"`
}
func hasDNSSuffix(name, suffix string) bool {
name = strings.ToLower(strings.TrimSuffix(name, "."))
suffix = strings.ToLower(strings.Trim(suffix, "."))
return suffix != "" && (name == suffix || strings.HasSuffix(name, "."+suffix))
}
// peerKind sorts a peer into one of the kinds. suffix is this tailnet's
// MagicDNS suffix.
func peerKind(p *ipnstate.PeerStatus, suffix string) string {
if p.ExitNodeOption || p.ExitNode {
for _, d := range vpnDomains {
if hasDNSSuffix(p.DNSName, d) {
return peerVPN
}
}
}
if p.DNSName != "" && suffix != "" && !hasDNSSuffix(p.DNSName, suffix) {
return peerShared
}
return peerOwn
}
func newPeerInfo(p *ipnstate.PeerStatus, suffix string) peerInfo {
pi := peerInfo{Name: p.HostName, OS: p.OS, Online: p.Online, Kind: peerKind(p, suffix)}
if p.DNSName != "" {
pi.Name = strings.SplitN(p.DNSName, ".", 2)[0]
}
if len(p.TailscaleIPs) > 0 {
pi.IP = p.TailscaleIPs[0].String()
}
switch {
case p.ExitNode:
pi.ExitNode = "used"
case p.ExitNodeOption:
pi.ExitNode = "offered"
}
pi.Conn, pi.Via = peerConn(p)
if l := p.Location; l != nil {
parts := []string{}
for _, s := range []string{l.City, l.Country} {
if s != "" {
parts = append(parts, s)
}
}
pi.Location = strings.Join(parts, ", ")
}
if p.Tags != nil {
pi.Tags = p.Tags.AsSlice()
}
return pi
}
// peersFromStatus lists the peers for the status page, online ones first.
// VPN exit servers are counted, and listed only if withVPN is set; the one
// in use is always listed.
func peersFromStatus(st *ipnstate.Status, withVPN bool) (peers []peerInfo, vpn peerCount) {
peers = []peerInfo{}
for _, p := range st.Peer {
pi := newPeerInfo(p, st.MagicDNSSuffix)
if pi.Kind == peerVPN {
vpn.Total++
if pi.Online {
vpn.Online++
}
if !withVPN && pi.ExitNode != "used" {
continue
}
}
peers = append(peers, pi)
}
sort.Slice(peers, func(i, j int) bool {
if peers[i].Online != peers[j].Online {
return peers[i].Online
}
return peers[i].Name < peers[j].Name
})
return peers, vpn
}
// 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)
}
+105
View File
@@ -0,0 +1,105 @@
package main
import (
"net/netip"
"testing"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
"tailscale.com/types/views"
)
func testStatus() *ipnstate.Status {
tags := views.SliceOf([]string{"tag:server"})
peers := []*ipnstate.PeerStatus{
{HostName: "Desk PC", DNSName: "desk.tail1234.ts.net.", OS: "windows", Online: true,
TailscaleIPs: []netip.Addr{netip.MustParseAddr("100.64.0.2")}},
{HostName: "nas", DNSName: "nas.tail1234.ts.net.", OS: "linux", ExitNodeOption: true, Tags: &tags},
{HostName: "friend", DNSName: "laptop.tail9999.ts.net.", OS: "macOS", Online: true},
{HostName: "at-vie-wg-001", DNSName: "at-vie-wg-001.mullvad.ts.net.", Online: true, ExitNodeOption: true,
Location: &tailcfg.Location{Country: "Austria", City: "Vienna"}},
{HostName: "se-sto-wg-001", DNSName: "se-sto-wg-001.mullvad.ts.net.", ExitNodeOption: true},
}
st := &ipnstate.Status{MagicDNSSuffix: "tail1234.ts.net", Peer: map[key.NodePublic]*ipnstate.PeerStatus{}}
for _, p := range peers {
st.Peer[key.NewNode().Public()] = p
}
return st
}
func TestPeersFromStatus(t *testing.T) {
peers, vpn := peersFromStatus(testStatus(), false)
if vpn != (peerCount{Total: 2, Online: 1}) {
t.Errorf("vpn count = %+v", vpn)
}
// Online first, then by name; no VPN servers.
want := []peerInfo{
{Name: "desk", IP: "100.64.0.2", OS: "windows", Online: true, Kind: peerOwn},
{Name: "laptop", OS: "macOS", Online: true, Kind: peerShared},
{Name: "nas", OS: "linux", Kind: peerOwn, ExitNode: "offered", Tags: []string{"tag:server"}},
}
if len(peers) != len(want) {
t.Fatalf("got %d peers, want %d: %+v", len(peers), len(want), peers)
}
for i := range want {
g, w := peers[i], want[i]
if g.Name != w.Name || g.IP != w.IP || g.OS != w.OS || g.Online != w.Online || g.Kind != w.Kind ||
g.ExitNode != w.ExitNode || len(g.Tags) != len(w.Tags) {
t.Errorf("peer %d = %+v, want %+v", i, g, w)
}
}
peers, _ = peersFromStatus(testStatus(), true)
if len(peers) != 5 {
t.Fatalf("with VPN servers: got %d peers, want 5", len(peers))
}
for _, p := range peers {
if p.Name == "at-vie-wg-001" && (p.Kind != peerVPN || p.Location != "Vienna, Austria" || p.ExitNode != "offered") {
t.Errorf("VPN server = %+v", p)
}
}
}
func TestExitServerInUseIsAlwaysListed(t *testing.T) {
st := testStatus()
for _, p := range st.Peer {
if p.HostName == "se-sto-wg-001" {
p.ExitNode = true
}
}
peers, _ := peersFromStatus(st, false)
found := false
for _, p := range peers {
if p.Name == "se-sto-wg-001" {
found = p.Kind == peerVPN && p.ExitNode == "used"
}
}
if !found {
t.Errorf("the exit server in use is missing: %+v", peers)
}
}
func TestPeerKind(t *testing.T) {
for _, tt := range []struct {
name string
p ipnstate.PeerStatus
want string
}{
{"own", ipnstate.PeerStatus{DNSName: "a.tail1234.ts.net."}, peerOwn},
{"own exit node", ipnstate.PeerStatus{DNSName: "a.tail1234.ts.net.", ExitNodeOption: true}, peerOwn},
{"no DNS name", ipnstate.PeerStatus{HostName: "a"}, peerOwn},
{"shared", ipnstate.PeerStatus{DNSName: "a.other.ts.net."}, peerShared},
{"suffix must match a whole label", ipnstate.PeerStatus{DNSName: "a.xtail1234.ts.net."}, peerShared},
{"vpn", ipnstate.PeerStatus{DNSName: "x.mullvad.ts.net.", ExitNodeOption: true}, peerVPN},
{"vpn domain but no exit node", ipnstate.PeerStatus{DNSName: "x.mullvad.ts.net."}, peerShared},
} {
if got := peerKind(&tt.p, "tail1234.ts.net"); got != tt.want {
t.Errorf("%s: got %s, want %s", tt.name, got, tt.want)
}
}
// Without a suffix (not logged in yet) nothing is taken for shared.
if got := peerKind(&ipnstate.PeerStatus{DNSName: "a.other.ts.net."}, ""); got != peerOwn {
t.Errorf("no suffix: got %s", got)
}
}
+4 -1
View File
@@ -2,6 +2,7 @@ package main
import (
"context"
"errors"
"io"
"net"
"net/http"
@@ -52,7 +53,9 @@ func (d *daemon) serveProxy(ln net.Listener) {
io.Copy(w, resp.Body)
}),
}
if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed && !d.stopping() {
// Serve returns when the listener is closed, which is how the proxy is
// turned off or moved from the settings.
if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed && !errors.Is(err, net.ErrClosed) && !d.stopping() {
d.logf("http proxy stopped: %v", err)
}
}
+203
View File
@@ -0,0 +1,203 @@
package main
import (
"bytes"
"encoding/json"
"net"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"tailscale.com/ipn/ipnstate"
)
func TestValidateForwards(t *testing.T) {
ok := []forwardRule{
{"tcp", "127.0.0.1:8096", "my-nas:8096"},
{"udp", "127.0.0.1:8096", "my-nas:8096"}, // same port, other protocol
{"tcp", "127.0.0.1:8080", "100.64.0.4:80"},
{"tcp", "127.0.0.1:8443", "[fd7a:115c:a1e0::4]:443"},
}
if err := validateForwards(ok); err != nil {
t.Errorf("good rules refused: %v", err)
}
for name, rules := range map[string][]forwardRule{
"unknown protocol": {{"icmp", "127.0.0.1:1", "a:1"}},
"no port on the device": {{"tcp", "127.0.0.1:8096", "my-nas"}},
"no device": {{"tcp", "127.0.0.1:8096", ":8096"}},
"bad local address": {{"tcp", "8096", "my-nas:8096"}},
"port 0": {{"tcp", "127.0.0.1:8096", "my-nas:0"}},
"same local port twice": {{"tcp", "127.0.0.1:8096", "a:1"}, {"tcp", "127.0.0.1:8096", "b:2"}},
"odd characters": {{"tcp", "127.0.0.1:8096", "my nas;rm:80"}},
} {
if err := validateForwards(rules); err == nil {
t.Errorf("%s: accepted", name)
}
}
}
func TestHandleForwards(t *testing.T) {
dir := t.TempDir()
d := &daemon{cfg: defaultConfig(), cfgPath: filepath.Join(dir, "config.json"), logf: t.Logf}
d.fwd = newForwarder(nil, t.Logf)
free := freePort(t)
post := func(body string) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
d.handleForwards(rec, httptest.NewRequest("POST", "/api/forwards", strings.NewReader(body)))
return rec
}
body, _ := json.Marshal([]forwardRule{{" TCP ", free, " my-nas:8096 "}})
if rec := post(string(body)); rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body)
}
if len(d.cfg.Forwards) != 1 || d.cfg.Forwards[0] != (forwardRule{"tcp", free, "my-nas:8096"}) {
t.Errorf("config = %+v", d.cfg.Forwards)
}
if got := d.fwd.rules(); len(got) != 1 {
t.Errorf("active forwards = %v", got)
}
saved, err := loadConfig(d.cfgPath)
if err != nil || len(saved.Forwards) != 1 {
t.Errorf("saved config: %+v, %v", saved.Forwards, err)
}
if rec := post(`[{"proto":"tcp","listen":"x","target":"y"}]`); rec.Code != http.StatusBadRequest {
t.Errorf("a bad rule: status %d", rec.Code)
}
if len(d.cfg.Forwards) != 1 {
t.Error("a refused request changed the config")
}
// An empty list removes them all.
if rec := post(`[]`); rec.Code != http.StatusOK || len(d.cfg.Forwards) != 0 || len(d.fwd.rules()) != 0 {
t.Errorf("clearing: status %d, %v", rec.Code, d.cfg.Forwards)
}
d.fwd.set(nil)
}
// The settings form no longer carries the forwards; saving it must not
// wipe them.
func TestSettingsLeaveForwardsAlone(t *testing.T) {
dir := t.TempDir()
cfg := defaultConfig()
cfg.Forwards = []forwardRule{{"tcp", freePort(t), "my-nas:8096"}}
d := &daemon{cfg: cfg, cfgPath: filepath.Join(dir, "config.json"), logf: t.Logf}
d.fwd = newForwarder(nil, t.Logf)
defer d.fwd.set(nil)
s := settingsFromConfig(cfg)
s.Forwards = nil
s.SunshineHosts = nil
body, _ := json.Marshal(s)
rec := httptest.NewRecorder()
d.handleSetConfig(rec, httptest.NewRequest("POST", "/api/config", bytes.NewReader(body)))
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body)
}
if len(d.cfg.Forwards) != 1 {
t.Errorf("the forwards were lost: %+v", d.cfg.Forwards)
}
}
func TestPeerConn(t *testing.T) {
for name, tt := range map[string]struct {
p ipnstate.PeerStatus
conn, via string
}{
"direct": {ipnstate.PeerStatus{Online: true, Active: true, CurAddr: "192.168.1.5:41641", Relay: "nyc"}, "direct", ""},
"relayed": {ipnstate.PeerStatus{Online: true, Active: true, Relay: "nyc"}, "relay", "nyc"},
"peer relay": {ipnstate.PeerStatus{Online: true, Active: true, PeerRelay: "1.2.3.4:5:6", Relay: "nyc"}, "relay", "a peer relay"},
"no traffic": {ipnstate.PeerStatus{Online: true, Relay: "nyc"}, "", ""},
"offline": {ipnstate.PeerStatus{Active: true, CurAddr: "192.168.1.5:41641"}, "", ""},
"active, no route": {ipnstate.PeerStatus{Online: true, Active: true}, "", ""},
} {
if conn, via := peerConn(&tt.p); conn != tt.conn || via != tt.via {
t.Errorf("%s: got %q %q", name, conn, via)
}
}
}
func TestPingPeerRefusesOtherAddresses(t *testing.T) {
d := &daemon{logf: t.Logf}
for _, ip := range []string{"", "8.8.8.8", "192.168.1.1", "not-an-ip", "127.0.0.1"} {
rec := httptest.NewRecorder()
d.handlePingPeer(rec, httptest.NewRequest("POST", "/api/pingpeer?ip="+ip, nil))
if rec.Code != http.StatusBadRequest {
t.Errorf("%q: status %d", ip, rec.Code)
}
}
}
func TestDNSDescribe(t *testing.T) {
p := &dnsPicker{}
if got := p.describe(); got != "" {
t.Errorf("before any lookup: %q", got)
}
for server, want := range map[string]string{
dnsLocal: "DNS payload",
"192.168.1.1:53": "router",
"1.1.1.1:53": "public",
} {
p.server, p.working = server, true
if got := p.describe(); !strings.Contains(got, want) {
t.Errorf("%s: %q", server, got)
}
}
p.working = false
if got := p.describe(); !strings.Contains(got, "no server") {
t.Errorf("not working: %q", got)
}
}
func TestCheckForwardPorts(t *testing.T) {
// Something else listening on the console.
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
busy := ln.Addr().String()
free := freePort(t)
cfg := defaultConfig()
cfg.SunshineHosts = []sunshineHost{{Host: "gaming-pc"}}
d := &daemon{cfg: cfg, logf: t.Logf, webPort: 8090, proxyPort: 8118}
d.fwd = newForwarder(nil, t.Logf)
defer d.fwd.set(nil)
for name, tt := range map[string]struct {
rule forwardRule
ok bool
}{
"a free port": {forwardRule{"tcp", free, "nas:80"}, true},
"the status page's port": {forwardRule{"tcp", "127.0.0.1:8090", "nas:80"}, false},
"the proxy's port": {forwardRule{"tcp", "127.0.0.1:8118", "nas:80"}, false},
"a game streaming port": {forwardRule{"tcp", "127.0.0.1:47989", "nas:80"}, false},
"a game streaming UDP port": {forwardRule{"udp", "127.0.0.1:47998", "nas:80"}, false},
"UDP on the page's port": {forwardRule{"udp", "127.0.0.1:8090", "nas:80"}, true},
"a port something else has": {forwardRule{"tcp", busy, "nas:80"}, false},
} {
rule, err := d.checkForwardPorts([]forwardRule{tt.rule})
if (err == nil) != tt.ok {
t.Errorf("%s: %v", name, err)
}
if err != nil && rule != tt.rule {
t.Errorf("%s: blamed %v", name, rule)
}
}
// A forward that is already running may stay, and may be pointed
// somewhere else, although its port is of course in use: by us.
running := forwardRule{"tcp", free, "nas:80"}
if err := d.fwd.set([]forwardRule{running}); err != nil {
t.Fatal(err)
}
if _, err := d.checkForwardPorts([]forwardRule{running}); err != nil {
t.Errorf("a running forward was refused: %v", err)
}
if _, err := d.checkForwardPorts([]forwardRule{{"tcp", free, "laptop:8080"}}); err != nil {
t.Errorf("retargeting a running forward was refused: %v", err)
}
}
+114
View File
@@ -0,0 +1,114 @@
// Package relsig signs and verifies releases.
//
// A release's payload comes with a small signature file. It names the
// version and the SHA-256 of the payload and carries an Ed25519 signature
// over both, made with a key that never leaves the maintainer's machines.
// The daemon has the public half built in and installs an update only if the
// signature checks out, so a tampered release, or one put up by someone who
// got into the hosting account, is turned down.
//
// The version is part of what is signed, so a signature cannot be reused to
// pass an older payload off as a newer release.
package relsig
import (
"bytes"
"crypto/ed25519"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"strings"
)
// FileSuffix is appended to a payload's name for its signature file.
const FileSuffix = ".sig"
const domain = "ps5-tailscale-release-v1"
// Signature is the content of a signature file.
type Signature struct {
Version string // without a leading "v"
SHA256 string // lower-case hex of the payload's SHA-256
Sig []byte
}
// message is what gets signed.
func message(version, sum string) []byte {
return []byte(domain + "\nversion=" + version + "\nsha256=" + sum + "\n")
}
func clean(version, sum string) (string, string, error) {
version = strings.TrimPrefix(strings.TrimSpace(version), "v")
sum = strings.ToLower(strings.TrimSpace(sum))
if version == "" || strings.ContainsAny(version, " \t\r\n=") {
return "", "", errors.New("invalid version")
}
if b, err := hex.DecodeString(sum); err != nil || len(b) != 32 {
return "", "", errors.New("invalid SHA-256")
}
return version, sum, nil
}
// Sign signs a payload's version and SHA-256.
func Sign(key ed25519.PrivateKey, version, sum string) (Signature, error) {
version, sum, err := clean(version, sum)
if err != nil {
return Signature{}, err
}
return Signature{Version: version, SHA256: sum, Sig: ed25519.Sign(key, message(version, sum))}, nil
}
// Verify reports whether the signature was made with the private half of pub.
func (s Signature) Verify(pub ed25519.PublicKey) bool {
return len(pub) == ed25519.PublicKeySize && ed25519.Verify(pub, message(s.Version, s.SHA256), s.Sig)
}
// Marshal renders the signature file.
func (s Signature) Marshal() []byte {
return []byte(fmt.Sprintf("version: %s\nsha256: %s\nsignature: %s\n",
s.Version, s.SHA256, base64.StdEncoding.EncodeToString(s.Sig)))
}
// Parse reads a signature file.
func Parse(b []byte) (Signature, error) {
fields, err := ParseFields(b)
if err != nil {
return Signature{}, err
}
version, sum, err := clean(fields["version"], fields["sha256"])
if err != nil {
return Signature{}, err
}
sig, err := base64.StdEncoding.DecodeString(fields["signature"])
if err != nil || len(sig) != ed25519.SignatureSize {
return Signature{}, errors.New("invalid signature field")
}
return Signature{Version: version, SHA256: sum, Sig: sig}, nil
}
// ParseFields reads "name: value" lines. Lines without a colon are ignored,
// which leaves room for a heading.
func ParseFields(b []byte) (map[string]string, error) {
if len(b) > 16<<10 {
return nil, errors.New("file too large")
}
fields := map[string]string{}
for _, line := range bytes.Split(b, []byte("\n")) {
name, value, ok := strings.Cut(string(line), ":")
if !ok {
continue
}
fields[strings.TrimSpace(name)] = strings.TrimSpace(value)
}
return fields, nil
}
// ParsePublicKey reads a base64 public key.
func ParsePublicKey(s string) (ed25519.PublicKey, error) {
b, err := base64.StdEncoding.DecodeString(strings.TrimSpace(s))
if err != nil || len(b) != ed25519.PublicKeySize {
return nil, errors.New("invalid public key")
}
return ed25519.PublicKey(b), nil
}
+76
View File
@@ -0,0 +1,76 @@
package relsig
import (
"crypto/ed25519"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"testing"
)
func TestSignVerify(t *testing.T) {
pub, priv, err := ed25519.GenerateKey(nil)
if err != nil {
t.Fatal(err)
}
sum := sha256.Sum256([]byte("payload"))
hexSum := hex.EncodeToString(sum[:])
sig, err := Sign(priv, "v1.2.3", hexSum)
if err != nil {
t.Fatal(err)
}
if sig.Version != "1.2.3" {
t.Errorf("version = %q", sig.Version)
}
parsed, err := Parse(sig.Marshal())
if err != nil {
t.Fatal(err)
}
if !parsed.Verify(pub) {
t.Fatal("a good signature did not verify")
}
// The same signature must not pass for another version or payload.
other := parsed
other.Version = "1.2.4"
if other.Verify(pub) {
t.Error("the signature passed for another version")
}
other = parsed
otherSum := sha256.Sum256([]byte("another payload"))
other.SHA256 = hex.EncodeToString(otherSum[:])
if other.Verify(pub) {
t.Error("the signature passed for another payload")
}
// Nor with another key.
pub2, _, _ := ed25519.GenerateKey(nil)
if parsed.Verify(pub2) {
t.Error("the signature passed with another key")
}
if parsed.Verify(nil) {
t.Error("the signature passed with no key")
}
if got, err := ParsePublicKey(base64.StdEncoding.EncodeToString(pub)); err != nil || !got.Equal(pub) {
t.Errorf("ParsePublicKey: %v", err)
}
}
func TestParseRejects(t *testing.T) {
for name, text := range map[string]string{
"empty": "",
"no signature": "version: 1.0.0\nsha256: " + hex.EncodeToString(make([]byte, 32)) + "\n",
"short sum": "version: 1.0.0\nsha256: abcd\nsignature: " + base64.StdEncoding.EncodeToString(make([]byte, 64)) + "\n",
"bad signature": "version: 1.0.0\nsha256: " + hex.EncodeToString(make([]byte, 32)) + "\nsignature: AAAA\n",
"no version": "sha256: " + hex.EncodeToString(make([]byte, 32)) + "\nsignature: " + base64.StdEncoding.EncodeToString(make([]byte, 64)) + "\n",
} {
if _, err := Parse([]byte(text)); err == nil {
t.Errorf("%s: accepted", name)
}
}
if _, err := Sign(nil, "1.0.0", "nothex"); err == nil {
t.Error("Sign accepted a bad sum")
}
}
+355
View File
@@ -0,0 +1,355 @@
package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net"
"net/http"
"os"
"path"
"path/filepath"
"strings"
"time"
"ps5tailscale/relsig"
)
// Installing a newer release from the status page.
//
// Nothing here runs by itself: an update is only ever installed when someone
// presses the button. The payload is downloaded from the release, checked
// against the release's signature with the key built in below, and handed to
// the ELF loader on this console, which is the same as sending the payload by
// hand: the new copy stops this one and takes over.
// updatePublicKey is the public half of the release signing key (base64
// Ed25519). Only releases signed with the private half can be installed from
// the status page. A fork that makes its own releases needs its own key
// (tsd/cmd/signrelease); test builds set another one with -ldflags -X.
var updatePublicKey = "HUcHCXGe3vNEeyt4frmoKZUqYDamdA1dzsr+Hsybda0="
const (
maxPayload = 128 << 20
loaderAddr = "127.0.0.1:9021"
updateDirName = "update"
)
// updateProgress is what the status page shows about an update in progress.
type updateProgress struct {
// State is "" (nothing going on), "downloading", "verifying",
// "installing" or "failed".
State string `json:"state"`
Version string `json:"version,omitempty"`
Percent int `json:"percent,omitempty"`
Error string `json:"error,omitempty"`
}
func (d *daemon) setUpdate(p updateProgress) {
d.mu.Lock()
d.update = p
d.mu.Unlock()
}
// canInstall reports whether rel can be installed from the status page.
func canInstall(rel releaseInfo) bool {
_, _, _, 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
// once; progress is reported through the status.
func (d *daemon) startUpdate() error {
d.mu.Lock()
defer d.mu.Unlock()
switch d.update.State {
case "downloading", "verifying", "installing":
return errors.New("an update is already in progress")
}
rel := d.latest
if !newerVersion(version, rel.Version) {
return errors.New("no newer release is known")
}
if !canInstall(rel) {
return errors.New("that release has no signed payload; install it by hand")
}
d.update = updateProgress{State: "downloading", Version: rel.Version}
go func() {
if err := d.runUpdate(rel); err != nil {
d.logf("update to %s failed: %v", rel.Version, err)
d.setUpdate(updateProgress{State: "failed", Version: rel.Version, Error: err.Error()})
}
}()
return nil
}
func (d *daemon) runUpdate(rel releaseInfo) error {
pub, err := relsig.ParsePublicKey(updatePublicKey)
if err != nil {
return errors.New("this build has no release key")
}
d.logf("update: downloading %s", rel.Version)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
defer cancel()
// The signature first: it is small, and says what the payload must be.
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)
}
sig, err := checkSignature(sigBytes, pub, rel.Version)
if err != nil {
return err
}
dir := filepath.Join(dataDir, updateDirName)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
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)
}
d.setUpdate(updateProgress{State: "verifying", Version: rel.Version})
if sum != sig.SHA256 {
os.Remove(file)
return errors.New("the downloaded payload does not match the release's signature; not installing it")
}
d.logf("update: %s verified (sha256 %s)", rel.Version, sum)
d.setUpdate(updateProgress{State: "installing", Version: rel.Version})
d.mu.Lock()
keep := d.cfg.PayloadPath
d.mu.Unlock()
if keep != "" {
// The copy that is started at boot. A failure here is reported but
// does not stop the update of the running instance.
if err := replaceFile(file, keep); err != nil {
d.logf("update: could not replace %s: %v", keep, err)
notify("Tailscale: could not update the copy at\n%s", keep)
} else {
d.logf("update: replaced %s", keep)
}
}
if err := sendToLoader(file); err != nil {
return fmt.Errorf("the update is downloaded and verified (%s), but could not be started: %w", file, err)
}
d.logf("update: handed %s to the ELF loader", rel.Version)
// The new instance stops this one within seconds. If it does not, its
// start failed.
select {
case <-d.quit:
return nil
case <-time.After(90 * time.Second):
return errors.New("the new version did not start; see /data/tailscale/launcher.log")
}
}
// checkSignature parses a release's signature file and checks it against the
// release key and the version the release claims to be.
func checkSignature(b []byte, pub []byte, wantVersion string) (relsig.Signature, error) {
sig, err := relsig.Parse(b)
if err != nil {
return sig, fmt.Errorf("the release's signature file is not valid: %w", err)
}
if !sig.Verify(pub) {
return sig, errors.New("the release is not signed with this project's release key; not installing it")
}
if sig.Version != wantVersion {
return sig, fmt.Errorf("the signature is for version %s, not %s; not installing it", sig.Version, wantVersion)
}
if !newerVersion(version, sig.Version) {
return sig, fmt.Errorf("version %s is not newer than this one", sig.Version)
}
return sig, nil
}
func httpGet(ctx context.Context, url string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "ps5-tailscale/"+version)
req.Header.Set("Accept", "application/octet-stream")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, &httpStatusError{resp.Status}
}
return resp, nil
}
func httpGetSmall(ctx context.Context, url string) ([]byte, error) {
resp, err := httpGet(ctx, url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return io.ReadAll(io.LimitReader(resp.Body, 16<<10))
}
// download saves url to file and returns the SHA-256 of what was written.
func (d *daemon) download(ctx context.Context, url, file, ver string) (string, error) {
resp, err := httpGet(ctx, url)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.ContentLength > maxPayload {
return "", errors.New("the payload is implausibly large")
}
f, err := os.OpenFile(file, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
if err != nil {
return "", err
}
h := sha256.New()
buf := make([]byte, 256<<10)
var done int64
lastPercent := -1
for {
n, rerr := resp.Body.Read(buf)
if n > 0 {
if done += int64(n); done > maxPayload {
f.Close()
return "", errors.New("the payload is implausibly large")
}
h.Write(buf[:n])
if _, err := f.Write(buf[:n]); err != nil {
f.Close()
return "", err
}
if resp.ContentLength > 0 {
if p := int(done * 100 / resp.ContentLength); p != lastPercent {
lastPercent = p
d.setUpdate(updateProgress{State: "downloading", Version: ver, Percent: p})
}
}
}
if rerr == io.EOF {
break
}
if rerr != nil {
f.Close()
return "", rerr
}
}
if err := f.Close(); err != nil {
return "", err
}
if resp.ContentLength > 0 && done != resp.ContentLength {
return "", errors.New("the download was cut short")
}
return hex.EncodeToString(h.Sum(nil)), nil
}
// replaceFile puts a copy of src at dst, by way of a temporary file next to
// dst so that dst is never left half written.
func replaceFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
tmp := dst + ".new"
out, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
os.Remove(tmp)
return err
}
if err := out.Close(); err != nil {
os.Remove(tmp)
return err
}
if err := os.Rename(tmp, dst); err != nil {
os.Remove(tmp)
return err
}
return nil
}
// sendToLoader hands a payload to the ELF loader on this console.
func sendToLoader(file string) error {
f, err := os.Open(file)
if err != nil {
return err
}
defer f.Close()
c, err := net.DialTimeout("tcp", loaderAddr, 5*time.Second)
if err != nil {
return fmt.Errorf("no ELF loader on port 9021 (%w); send the file by hand", err)
}
c.SetWriteDeadline(time.Now().Add(2 * time.Minute))
// Not io.Copy(c, f) with the bare file: Go would use sendfile, which the
// PS5 kernel refuses for sockets ("socket is not connected").
if _, err := io.Copy(c, onlyReader{f}); err != nil {
c.Close()
return err
}
// The half-close tells the loader that the payload is complete.
if tc, ok := c.(*net.TCPConn); ok {
tc.CloseWrite()
}
// The connection is the new payload's standard output. Keep reading it
// for as long as this process lives, so that nothing the payload prints
// while starting hits a closed socket.
go func() {
io.Copy(io.Discard, c)
c.Close()
}()
return nil
}
// onlyReader hides everything about a reader but Read, so that copying from
// it takes the plain path.
type onlyReader struct{ io.Reader }
// validPayloadPath checks the setting that names the copy started at boot.
func validPayloadPath(p string) error {
if p == "" {
return nil
}
if !strings.HasPrefix(p, "/") {
return errors.New("it must be a full path, such as /data/pldmgr/payloads/Tailscale/tailscale.elf")
}
if path.Clean(p) != p {
return errors.New("it must not contain .. or doubled slashes")
}
if path.Ext(p) != ".elf" {
return errors.New("it must name an .elf file")
}
return nil
}
+193
View File
@@ -0,0 +1,193 @@
package main
import (
"bytes"
"context"
"crypto/ed25519"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"ps5tailscale/relsig"
)
func withVersion(t *testing.T, v string) {
old := version
version = v
t.Cleanup(func() { version = old })
}
func TestCheckSignature(t *testing.T) {
withVersion(t, "1.0.0")
pub, priv, _ := ed25519.GenerateKey(nil)
otherPub, otherPriv, _ := ed25519.GenerateKey(nil)
_ = otherPub
sum := hex.EncodeToString(make([]byte, 32))
sign := func(key ed25519.PrivateKey, v string) []byte {
s, err := relsig.Sign(key, v, sum)
if err != nil {
t.Fatal(err)
}
return s.Marshal()
}
if _, err := checkSignature(sign(priv, "1.1.0"), pub, "1.1.0"); err != nil {
t.Errorf("a good signature was refused: %v", err)
}
for name, tt := range map[string]struct {
sig []byte
want string
}{
"signed with another key": {sign(otherPriv, "1.1.0"), "1.1.0"},
"signature of another version": {sign(priv, "1.0.5"), "1.1.0"},
"a properly signed older build": {sign(priv, "0.9.0"), "0.9.0"},
"the version that is running": {sign(priv, "1.0.0"), "1.0.0"},
"not a signature file": {[]byte("<html>not found</html>"), "1.1.0"},
"tampered version, same payload": {bytes.Replace(sign(priv, "1.0.5"), []byte("1.0.5"), []byte("1.1.0"), 1), "1.1.0"},
} {
if _, err := checkSignature(tt.sig, pub, tt.want); err == nil {
t.Errorf("%s: accepted", name)
}
}
}
func TestBuiltInKeyIsValid(t *testing.T) {
if _, err := relsig.ParsePublicKey(updatePublicKey); err != nil {
t.Fatalf("updatePublicKey: %v", err)
}
}
func TestCanInstall(t *testing.T) {
withVersion(t, "1.0.0")
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: 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 {
t.Errorf("%+v: got %v", tt.rel, got)
}
}
}
func TestFetchLatestReleaseAssets(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, `{"tag_name":"v1.2.3","html_url":"https://example.com/r","assets":[
{"name":"tailscale.elf","browser_download_url":"https://example.com/a"},
{"name":"tailscale.elf.sig","browser_download_url":"https://example.com/b"}]}`)
}))
defer srv.Close()
rel, err := fetchLatestRelease(context.Background(), srv.URL)
if err != nil {
t.Fatal(err)
}
if rel.Version != "1.2.3" || rel.Assets["tailscale.elf"] != "https://example.com/a" || rel.Assets["tailscale.elf.sig"] != "https://example.com/b" {
t.Errorf("got %+v", rel)
}
}
func TestDownloadAndReplace(t *testing.T) {
payload := bytes.Repeat([]byte("payload "), 100_000)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/ok":
w.Header().Set("Content-Length", fmt.Sprint(len(payload)))
w.Write(payload)
case "/short":
w.Header().Set("Content-Length", fmt.Sprint(len(payload)))
w.Write(payload[:1000])
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
d := &daemon{logf: t.Logf}
dir := t.TempDir()
file := filepath.Join(dir, "tailscale.elf")
sum, err := d.download(context.Background(), srv.URL+"/ok", file, "1.1.0")
if err != nil {
t.Fatal(err)
}
want := sha256.Sum256(payload)
if sum != hex.EncodeToString(want[:]) {
t.Errorf("sum = %s", sum)
}
if d.update.State != "downloading" || d.update.Percent != 100 {
t.Errorf("progress = %+v", d.update)
}
if _, err := d.download(context.Background(), srv.URL+"/short", filepath.Join(dir, "short"), "1.1.0"); err == nil {
t.Error("a download that was cut short was accepted")
}
if _, err := d.download(context.Background(), srv.URL+"/missing", filepath.Join(dir, "missing"), "1.1.0"); err == nil {
t.Error("a 404 was accepted")
}
// Replacing the copy started at boot.
dst := filepath.Join(dir, "boot", "tailscale.elf")
os.MkdirAll(filepath.Dir(dst), 0o755)
os.WriteFile(dst, []byte("old"), 0o644)
if err := replaceFile(file, dst); err != nil {
t.Fatal(err)
}
got, _ := os.ReadFile(dst)
if !bytes.Equal(got, payload) {
t.Error("the file was not replaced")
}
if _, err := os.Stat(dst + ".new"); err == nil {
t.Error("the temporary file was left behind")
}
// A folder that does not exist: the old file elsewhere stays untouched.
if err := replaceFile(file, filepath.Join(dir, "nowhere", "tailscale.elf")); err == nil {
t.Error("replacing into a missing folder succeeded")
}
}
func TestStartUpdateRefusals(t *testing.T) {
withVersion(t, "1.0.0")
d := &daemon{logf: t.Logf}
if err := d.startUpdate(); err == nil {
t.Error("started with no release known")
}
d.latest = releaseInfo{Version: "1.1.0", Assets: map[string]string{"tailscale.elf": "x"}}
if err := d.startUpdate(); err == nil || !strings.Contains(err.Error(), "signed") {
t.Errorf("an unsigned release: %v", err)
}
d.update = updateProgress{State: "downloading"}
if err := d.startUpdate(); err == nil {
t.Error("started a second update")
}
}
func TestValidPayloadPath(t *testing.T) {
for p, ok := range map[string]bool{
"": true,
"/data/pldmgr/payloads/Tailscale/tailscale.elf": true,
"/mnt/usb0/tailscale.elf": true,
"tailscale.elf": false,
"/data/../etc/tailscale.elf": false,
"/data//tailscale.elf": false,
"/data/pldmgr/autoload.txt": false,
"/data/tailscale/": false,
} {
if err := validPayloadPath(p); (err == nil) != ok {
t.Errorf("%q: %v", p, err)
}
}
}
+345
View File
@@ -0,0 +1,345 @@
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"os"
"path"
"path/filepath"
"slices"
"strconv"
"strings"
"time"
"tailscale.com/ipn"
)
// Settings editing from the status page. Most settings take effect at once;
// the few that are only read when the payload starts are reported back so
// the page can say so.
const (
priorityLow = "low"
priorityHigh = "high"
// priorityFile tells the launcher which scheduling class to use. The
// launcher is C and runs before any of this, so it gets the one setting
// it needs in a file of its own rather than parsing the config.
priorityFile = "priority"
)
// settings is the editable part of the config as the status page sees it.
type settings struct {
Hostname string `json:"hostname"`
WebAddr string `json:"webAddr"`
HTTPProxyAddr string `json:"httpProxyAddr"`
SunshineHosts []sunshineHost `json:"sunshineHosts"`
Forwards []forwardRule `json:"forwards"`
UDPPorts []uint16 `json:"udpPorts"`
BlockedPorts []uint16 `json:"blockedPorts"`
Priority string `json:"priority"`
AllowFrom string `json:"allowFrom"`
PayloadPath string `json:"payloadPath"`
ReceiveDir string `json:"receiveDir"`
CheckUpdates bool `json:"checkUpdates"`
Verbose bool `json:"verbose"`
// PasswordSet says whether a password is in place. Password is only
// read: absent leaves the password alone, empty removes it, anything
// else sets it.
PasswordSet bool `json:"passwordSet"`
Password *string `json:"password,omitempty"`
}
func settingsFromConfig(cfg config) settings {
s := settings{
Hostname: cfg.Hostname,
WebAddr: cfg.WebAddr,
HTTPProxyAddr: cfg.HTTPProxyAddr,
SunshineHosts: append([]sunshineHost{}, cfg.SunshineHosts...),
Forwards: append([]forwardRule{}, cfg.Forwards...),
UDPPorts: append([]uint16{}, cfg.UDPPorts...),
BlockedPorts: append([]uint16{}, cfg.BlockedPorts...),
Priority: priorityLow,
AllowFrom: accessAll,
PayloadPath: cfg.PayloadPath,
ReceiveDir: cfg.ReceiveDir,
CheckUpdates: cfg.CheckUpdates,
Verbose: cfg.Verbose,
PasswordSet: cfg.PasswordHash != "",
}
if cfg.Priority == priorityHigh {
s.Priority = priorityHigh
}
if cfg.AllowFrom == accessOwn {
s.AllowFrom = accessOwn
}
return s
}
// validate checks the settings and tidies them.
func (s *settings) validate() error {
s.Hostname = strings.TrimSpace(s.Hostname)
if !validTailnetName(s.Hostname) {
return fmt.Errorf("the name may only contain letters, digits and hyphens (at most 63)")
}
if err := validListenAddr(s.WebAddr); err != nil {
return fmt.Errorf("status page address: %w", err)
}
s.HTTPProxyAddr = strings.TrimSpace(s.HTTPProxyAddr)
if s.HTTPProxyAddr != "" {
if err := validListenAddr(s.HTTPProxyAddr); err != nil {
return fmt.Errorf("HTTP proxy address: %w", err)
}
}
if err := validateSunshineHosts(s.SunshineHosts); err != nil {
return err
}
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")
}
s.PayloadPath = strings.TrimSpace(s.PayloadPath)
if err := validPayloadPath(s.PayloadPath); err != nil {
return fmt.Errorf("payload file: %w", err)
}
s.ReceiveDir = strings.TrimSpace(s.ReceiveDir)
if s.ReceiveDir == "" {
s.ReceiveDir = defaultReceiveDir
}
if !strings.HasPrefix(s.ReceiveDir, "/") || path.Clean(s.ReceiveDir) != s.ReceiveDir || s.ReceiveDir == "/" {
return fmt.Errorf("folder for received files: it must be a full path, such as %s", defaultReceiveDir)
}
if s.AllowFrom == "" {
s.AllowFrom = accessAll
}
if s.AllowFrom != accessAll && s.AllowFrom != accessOwn {
return fmt.Errorf("who may connect must be all or own")
}
if s.Priority != priorityLow && s.Priority != priorityHigh {
return fmt.Errorf("the priority must be low or high")
}
if s.Password != nil && len(*s.Password) > 0 && len(*s.Password) < 4 {
return fmt.Errorf("the password must be at least 4 characters")
}
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
}
for _, c := range s {
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-') {
return false
}
}
return true
}
func validPort(s string) bool {
n, err := strconv.Atoi(s)
return err == nil && n >= 1 && n <= 65535
}
// validListenAddr accepts "host:port" and ":port".
func validListenAddr(addr string) error {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return fmt.Errorf("%q is not host:port", addr)
}
if !validHostName(host) || !validPort(port) {
return fmt.Errorf("%q is not a valid address", addr)
}
return nil
}
func (d *daemon) handleGetConfig(w http.ResponseWriter, r *http.Request) {
d.mu.Lock()
s := settingsFromConfig(d.cfg)
d.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
json.NewEncoder(w).Encode(s)
}
// handleSetConfig saves new settings and applies what can be applied without
// a restart. The reply lists the settings that need one.
func (d *daemon) handleSetConfig(w http.ResponseWriter, r *http.Request) {
var s settings
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&s); err != nil {
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
return
}
if err := s.validate(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
var newHash *string
if s.Password != nil {
hash := ""
if *s.Password != "" {
var err error
if hash, err = hashPassword(*s.Password); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
newHash = &hash
}
d.mu.Lock()
old := d.cfg
cfg := d.cfg
cfg.Hostname = s.Hostname
cfg.WebAddr = s.WebAddr
cfg.HTTPProxyAddr = s.HTTPProxyAddr
if s.SunshineHosts != nil {
// The settings form leaves the Sunshine hosts out: they have a
// panel of their own.
cfg.SunshineHosts = s.SunshineHosts
}
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 = ""
if s.Priority == priorityHigh {
cfg.Priority = priorityHigh
}
cfg.PayloadPath = s.PayloadPath
cfg.ReceiveDir = s.ReceiveDir
cfg.AllowFrom = ""
if s.AllowFrom == accessOwn {
cfg.AllowFrom = accessOwn
}
cfg.CheckUpdates = s.CheckUpdates
cfg.Verbose = s.Verbose
if newHash != nil {
cfg.PasswordHash = *newHash
}
d.cfg = cfg
d.mu.Unlock()
if err := saveConfig(d.cfgPath, cfg); err != nil {
d.logf("saving config: %v", err)
http.Error(w, "the settings are in effect but could not be saved: "+err.Error(), http.StatusInternalServerError)
return
}
d.logf("settings changed from the status page")
// Apply.
problems := []string{}
if cfg.Hostname != old.Hostname && d.lc != nil {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
_, err := d.lc.EditPrefs(ctx, &ipn.MaskedPrefs{Prefs: ipn.Prefs{Hostname: cfg.Hostname}, HostnameSet: true})
cancel()
if err != nil {
problems = append(problems, "name: "+err.Error())
}
}
if err := d.fwd.set(d.localForwardRules()); err != nil {
problems = append(problems, err.Error())
}
if cfg.HTTPProxyAddr != old.HTTPProxyAddr {
if err := d.setProxy(cfg.HTTPProxyAddr); err != nil {
problems = append(problems, "HTTP proxy: "+err.Error())
}
}
if newHash != nil && cfg.PasswordHash != old.PasswordHash {
// A changed password ends every session but the one that changed it.
d.sessions.clear()
if cfg.PasswordHash != "" {
if token, err := d.sessions.create(); err == nil {
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: token, Path: "/",
MaxAge: int(sessionLifetime.Seconds()), HttpOnly: true, SameSite: http.SameSiteStrictMode})
}
}
}
d.writePriorityFile()
d.access.clear()
// UDP ports and blocked ports are read from the config where they are used.
restart := []string{}
if cfg.WebAddr != old.WebAddr {
restart = append(restart, "status page address")
}
if cfg.Priority != old.Priority {
restart = append(restart, "priority")
}
if cfg.Verbose != old.Verbose {
restart = append(restart, "verbose log")
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"restart": restart, "problems": problems})
}
// writePriorityFile leaves the launcher its instruction for the next start.
func (d *daemon) writePriorityFile() {
d.mu.Lock()
high := d.cfg.Priority == priorityHigh
d.mu.Unlock()
path := filepath.Join(dataDir, priorityFile)
if high {
os.WriteFile(path, []byte(priorityHigh+"\n"), 0o644)
} else {
os.Remove(path)
}
}
// setProxy starts, stops or moves the outbound HTTP proxy.
func (d *daemon) setProxy(addr string) error {
d.mu.Lock()
old := d.proxyLn
d.proxyLn, d.proxyPort = nil, 0
d.mu.Unlock()
if old != nil {
old.Close()
}
if addr == "" {
return nil
}
ln, err := listenResilient("tcp", addr, d.logf)
if err != nil {
return err
}
d.mu.Lock()
d.proxyLn, d.proxyPort = ln, ln.port()
d.mu.Unlock()
go d.serveProxy(ln)
return nil
}
+306
View File
@@ -0,0 +1,306 @@
package main
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func TestPasswordHash(t *testing.T) {
hash, err := hashPassword("correct horse")
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(hash, "pbkdf2-sha256$") {
t.Errorf("unexpected hash format %q", hash)
}
if !checkPassword(hash, "correct horse") {
t.Error("the right password was rejected")
}
for _, wrong := range []string{"", "Correct horse", "correct horse "} {
if checkPassword(hash, wrong) {
t.Errorf("%q was accepted", wrong)
}
}
other, _ := hashPassword("correct horse")
if other == hash {
t.Error("two hashes of one password are identical; the salt is not random")
}
for _, bad := range []string{"", "plain", "pbkdf2-sha256$x$00$00", "pbkdf2-sha256$1000$zz$00", "md5$1$00$00"} {
if checkPassword(bad, "anything") {
t.Errorf("malformed hash %q accepted a password", bad)
}
}
}
// newTestDaemon returns a daemon with just enough set up to serve the status
// page's API.
func newTestDaemon(t *testing.T) *daemon {
t.Helper()
dir := t.TempDir()
old := dataDir
dataDir = dir
t.Cleanup(func() { dataDir = old })
d := &daemon{
cfg: defaultConfig(),
cfgPath: filepath.Join(dir, "config.json"),
logf: t.Logf,
quit: make(chan struct{}),
}
d.fwd = newForwarder(nil, t.Logf)
d.tailnetWeb = newTailnetListener()
return d
}
// request performs one API call. remote is the client address the server sees.
func request(t *testing.T, h http.Handler, method, path, remote string, body any, cookies []*http.Cookie) *httptest.ResponseRecorder {
t.Helper()
var buf bytes.Buffer
if body != nil {
json.NewEncoder(&buf).Encode(body)
}
r := httptest.NewRequest(method, path, &buf)
r.RemoteAddr = remote
r.Header.Set(apiHeader, "1")
for _, c := range cookies {
r.AddCookie(c)
}
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w
}
func TestPasswordProtection(t *testing.T) {
d := newTestDaemon(t)
h := d.webHandler()
const lan, tailnet, console = "192.168.1.20:5000", "100.64.0.9:5000", "127.0.0.1:5000"
// No password: everyone gets in.
if w := request(t, h, "GET", "/api/status", lan, nil, nil); w.Code != 200 {
t.Fatalf("no password, LAN status: %d", w.Code)
}
// Set one from the LAN.
pw := "hunter22"
w := request(t, h, "POST", "/api/config", lan, func() settings {
s := settingsFromConfig(d.cfg)
s.Password = &pw
return s
}(), nil)
if w.Code != 200 {
t.Fatalf("setting the password: %d %s", w.Code, w.Body)
}
setter := w.Result().Cookies()
if d.cfg.PasswordHash == "" || strings.Contains(d.cfg.PasswordHash, pw) {
t.Fatalf("stored password hash: %q", d.cfg.PasswordHash)
}
saved, _ := os.ReadFile(d.cfgPath)
if !bytes.Contains(saved, []byte("passwordHash")) || bytes.Contains(saved, []byte(pw)) {
t.Errorf("config file should hold the hash and not the password:\n%s", saved)
}
// Now locked for the LAN and the tailnet, open for the console itself
// and for the browser that set it.
for _, remote := range []string{lan, tailnet} {
for _, path := range []string{"/api/status", "/api/config", "/api/logs", "/qr.png"} {
if w := request(t, h, "GET", path, remote, nil, nil); w.Code != http.StatusUnauthorized {
t.Errorf("GET %s from %s: %d, want 401", path, remote, w.Code)
}
}
for _, path := range []string{"/api/logout", "/api/quit", "/api/uninstall", "/api/config", "/api/sunshine", "/api/login"} {
if w := request(t, h, "POST", path, remote, nil, nil); w.Code != http.StatusUnauthorized {
t.Errorf("POST %s from %s: %d, want 401", path, remote, w.Code)
}
}
}
if w := request(t, h, "GET", "/api/status", console, nil, nil); w.Code != 200 {
t.Errorf("console status: %d", w.Code)
}
if w := request(t, h, "GET", "/api/status", lan, nil, setter); w.Code != 200 {
t.Errorf("status with the session of the browser that set the password: %d", w.Code)
}
// The page shell and ping stay reachable so the unlock form can load.
for _, path := range []string{"/", "/api/ping", "/favicon.png"} {
if w := request(t, h, "GET", path, lan, nil, nil); w.Code != 200 {
t.Errorf("GET %s while locked: %d", path, w.Code)
}
}
// Unlocking.
if w := request(t, h, "POST", "/api/auth", lan, map[string]string{"password": "nope"}, nil); w.Code != http.StatusForbidden {
t.Errorf("wrong password: %d", w.Code)
}
w = request(t, h, "POST", "/api/auth", tailnet, map[string]string{"password": pw}, nil)
if w.Code != 200 || len(w.Result().Cookies()) == 0 {
t.Fatalf("right password: %d, cookies %v", w.Code, w.Result().Cookies())
}
session := w.Result().Cookies()
if !session[0].HttpOnly {
t.Error("the session cookie should be HttpOnly")
}
if w := request(t, h, "GET", "/api/status", tailnet, nil, session); w.Code != 200 {
t.Errorf("status with a session: %d", w.Code)
}
// Locking again ends the session.
request(t, h, "POST", "/api/lock", tailnet, nil, session)
if w := request(t, h, "GET", "/api/status", tailnet, nil, session); w.Code != http.StatusUnauthorized {
t.Errorf("status after lock: %d", w.Code)
}
// Removing the password opens the page again.
empty := ""
s := settingsFromConfig(d.cfg)
s.Password = &empty
if w := request(t, h, "POST", "/api/config", console, s, nil); w.Code != 200 {
t.Fatalf("removing the password: %d %s", w.Code, w.Body)
}
if w := request(t, h, "GET", "/api/status", lan, nil, nil); w.Code != 200 {
t.Errorf("status after removing the password: %d", w.Code)
}
}
func TestStateChangesNeedHeader(t *testing.T) {
d := newTestDaemon(t)
h := d.webHandler()
r := httptest.NewRequest("POST", "/api/quit", nil)
r.RemoteAddr = "192.168.1.20:5000"
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusForbidden {
t.Errorf("POST without the API header: %d, want 403", w.Code)
}
if d.stopping() {
t.Error("the daemon was told to stop by a request without the header")
}
}
func TestSettingsRoundTrip(t *testing.T) {
d := newTestDaemon(t)
h := d.webHandler()
const console = "127.0.0.1:5000"
var s settings
w := request(t, h, "GET", "/api/config", console, nil, nil)
if err := json.Unmarshal(w.Body.Bytes(), &s); err != nil {
t.Fatal(err)
}
if s.Hostname != "ps5" || s.Priority != priorityLow || !s.CheckUpdates || s.HTTPProxyAddr != "" || s.PasswordSet {
t.Errorf("defaults: %+v", s)
}
s.Hostname = "living-room-ps5"
s.BlockedPorts = []uint16{9021}
s.UDPPorts = []uint16{9296}
s.Priority = priorityHigh
s.CheckUpdates = false
w = request(t, h, "POST", "/api/config", console, s, nil)
if w.Code != 200 {
t.Fatalf("saving: %d %s", w.Code, w.Body)
}
var reply struct{ Restart []string }
json.Unmarshal(w.Body.Bytes(), &reply)
if len(reply.Restart) != 1 || reply.Restart[0] != "priority" {
t.Errorf("settings needing a restart: %v, want [priority]", reply.Restart)
}
cfg, err := loadConfig(d.cfgPath)
if err != nil {
t.Fatal(err)
}
if cfg.Hostname != "living-room-ps5" || cfg.Priority != priorityHigh || cfg.CheckUpdates ||
len(cfg.BlockedPorts) != 1 || len(cfg.UDPPorts) != 1 {
t.Errorf("saved config: %+v", cfg)
}
if b, _ := os.ReadFile(filepath.Join(dataDir, priorityFile)); strings.TrimSpace(string(b)) != priorityHigh {
t.Errorf("priority file: %q", b)
}
// Back to low removes the launcher's instruction.
s.Priority = priorityLow
request(t, h, "POST", "/api/config", console, s, nil)
if _, err := os.Stat(filepath.Join(dataDir, priorityFile)); !os.IsNotExist(err) {
t.Errorf("priority file should be gone: %v", err)
}
// Invalid input is rejected and changes nothing.
for name, edit := range map[string]func(*settings){
"name": func(s *settings) { s.Hostname = "bad name!" },
"web": func(s *settings) { s.WebAddr = "8090" },
"proxy": func(s *settings) { s.HTTPProxyAddr = "nonsense" },
"priority": func(s *settings) { s.Priority = "turbo" },
"forward": func(s *settings) { s.Forwards = []forwardRule{{"sctp", "127.0.0.1:1", "a:1"}} },
"sunshine": func(s *settings) { s.SunshineHosts = []sunshineHost{{Host: "a"}, {Host: "b"}} },
"password": func(s *settings) { p := "abc"; s.Password = &p },
} {
bad := settingsFromConfig(d.cfg)
edit(&bad)
if w := request(t, h, "POST", "/api/config", console, bad, nil); w.Code != http.StatusBadRequest {
t.Errorf("invalid %s: %d, want 400", name, w.Code)
}
}
if d.cfg.Hostname != "living-room-ps5" {
t.Errorf("hostname changed by a rejected request: %q", d.cfg.Hostname)
}
}
func TestConfigMigration(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
// A config as v0.4.1 wrote it.
os.WriteFile(path, []byte(`{"hostname":"ps5","webAddr":":8090","httpProxyAddr":"127.0.0.1:8118","sunshineHost":"gaming-pc","udpPorts":[9295,9296,9297,9302]}`), 0o600)
cfg, err := loadConfig(path)
if err != nil {
t.Fatal(err)
}
if len(cfg.SunshineHosts) != 1 || cfg.SunshineHosts[0].Host != "gaming-pc" || cfg.SunshineHost != "" {
t.Errorf("sunshine host not migrated: %+v", cfg)
}
if cfg.HTTPProxyAddr != "127.0.0.1:8118" {
t.Errorf("an explicitly configured proxy must stay on: %q", cfg.HTTPProxyAddr)
}
if !cfg.CheckUpdates {
t.Error("update checks should default to on for an existing config")
}
}
func TestVersionCompare(t *testing.T) {
for _, tt := range []struct {
current, latest string
want bool
}{
{"0.4.1", "0.5.0", true},
{"0.4.1", "v0.4.2", true},
{"0.4.1", "0.4.1", false},
{"0.5.0", "0.4.9", false},
{"0.4.2-dev", "0.4.1", false},
{"0.4.2-dev", "0.4.2", false},
{"0.4.2-dev", "0.4.3", true},
{"0.9.0", "0.10.0", true},
{"dev", "0.5.0", false},
{"0.4.1", "", false},
{"0.4.1", "nonsense", false},
} {
if got := newerVersion(tt.current, tt.latest); got != tt.want {
t.Errorf("newerVersion(%q, %q) = %v, want %v", tt.current, tt.latest, got, tt.want)
}
}
}
func TestIconReplyLine(t *testing.T) {
for in, want := range map[string]string{
"": "",
"[SceLncUtil] something\n": "",
"icon: remov": "",
"[SceLncUtil] x\nicon: removed\n": "icon: removed",
"icon: registering failed: 0x1\r\n": "icon: registering failed: 0x1",
} {
if got := iconReplyLine([]byte(in)); got != want {
t.Errorf("iconReplyLine(%q) = %q, want %q", in, got, want)
}
}
}
+829 -80
View File
File diff suppressed because it is too large. Load diff
+173
View File
@@ -0,0 +1,173 @@
package main
import (
"bytes"
"errors"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestScrub(t *testing.T) {
in := `2026-10-06 login URL: https://login.tailscale.com/a/1a2b3c4d5e6f
self: ps5.tail-scale-fish.ts.net. user someone@example.com authkey tskey-auth-kABCDEF123-xyzXYZ
endpoints 203.0.113.7:49866 (portmap), 192.168.1.50:49866 (local), 100.64.0.5, 10.0.0.5, 172.20.1.1
derp 198.51.100.9:443, resolver 1.1.1.1:53, version 1.104.0, loopback 127.0.0.1:8090
peer gaming-pc.tail-scale-fish.ts.net
dns: Set: {Routes:{ts.net.:[199.247.155.53] tail-scale-fish.ts.net.:[]} SearchDomains:[tail-scale-fish.ts.net.]}`
out := string(scrub([]byte(in)))
for _, gone := range []string{"1a2b3c4d5e6f", "someone@example.com", "kABCDEF123", "tail-scale-fish", "203.0.113.7", "198.51.100.9"} {
if strings.Contains(out, gone) {
t.Errorf("%q was not removed:\n%s", gone, out)
}
}
for _, kept := range []string{"192.168.1.50:49866", "100.64.0.5", "10.0.0.5", "172.20.1.1", "1.1.1.1:53", "1.104.0", "127.0.0.1:8090",
"ps5.<tailnet>.ts.net", "gaming-pc.<tailnet>.ts.net", "<email>", "<public-ip>:49866", "2026-10-06"} {
if !strings.Contains(out, kept) {
t.Errorf("%q is missing:\n%s", kept, out)
}
}
}
func TestDiagnostics(t *testing.T) {
old := dataDir
dataDir = t.TempDir()
t.Cleanup(func() { dataDir = old })
os.WriteFile(filepath.Join(dataDir, "launcher.log"), []byte("2026-10-05 launcher: starting, firmware 9.60, pid 5\n2026-10-06 launcher: starting, firmware 13.42, pid 115\n"), 0o644)
os.WriteFile(filepath.Join(dataDir, "tailscale.log"), []byte("main log line from someone@example.com\n"), 0o644)
cfg := defaultConfig()
cfg.PasswordHash = "pbkdf2-sha256$210000$c2FsdA$a2V5"
cfg.AuthKey = "tskey-auth-secret"
cfg.Wake = []wakeTarget{{Name: "gaming-pc", MAC: "00:11:22:aa:bb:cc"}}
d := &daemon{cfg: cfg, logf: t.Logf, started: time.Now(), state: "Running"}
d.fwd = newForwarder(nil, t.Logf)
rec := httptest.NewRecorder()
d.handleDiagnostics(rec, httptest.NewRequest("GET", "/api/diagnostics", nil))
body := rec.Body.String()
if cd := rec.Header().Get("Content-Disposition"); !strings.HasPrefix(cd, "attachment") || !strings.Contains(cd, ".txt") {
t.Errorf("Content-Disposition = %q", cd)
}
for _, want := range []string{"firmware: 13.42", "state: Running", "main log line", `"passwordHash": "(set)"`, "===== launcher.log =====", "tailscale-debug.log"} {
if !strings.Contains(body, want) {
t.Errorf("missing %q", want)
}
}
for _, secret := range []string{"c2FsdA", "tskey-auth-secret", "someone@example.com", "00:11:22:aa:bb:cc"} {
if strings.Contains(body, secret) {
t.Errorf("%q is in the diagnostics", secret)
}
}
// The daemon's own copy of the settings must not have been touched.
if d.cfg.PasswordHash != cfg.PasswordHash || d.cfg.Wake[0].MAC != "00:11:22:aa:bb:cc" {
t.Error("building the diagnostics changed the settings")
}
}
func TestParseMAC(t *testing.T) {
for in, want := range map[string]string{
"00:11:22:AA:BB:CC": "00:11:22:aa:bb:cc",
"00-11-22-aa-bb-cc": "00:11:22:aa:bb:cc",
"001122AABBCC": "00:11:22:aa:bb:cc",
"0011.22aa.bbcc": "00:11:22:aa:bb:cc",
" 00:11:22:aa:bb:cc ": "00:11:22:aa:bb:cc",
} {
mac, err := parseMAC(in)
if err != nil || mac.String() != want {
t.Errorf("parseMAC(%q) = %v, %v", in, mac, err)
}
}
for _, bad := range []string{"", "gaming-pc", "00:11:22:aa:bb", "00:11:22:aa:bb:cc:dd:ee", "zz:11:22:aa:bb:cc", "192.168.1.5"} {
if _, err := parseMAC(bad); err == nil {
t.Errorf("parseMAC(%q) accepted", bad)
}
}
}
func TestMagicPacket(t *testing.T) {
mac, _ := parseMAC("00:11:22:aa:bb:cc")
p := magicPacket(mac)
if len(p) != 102 {
t.Fatalf("length %d", len(p))
}
if !bytes.Equal(p[:6], bytes.Repeat([]byte{0xff}, 6)) {
t.Error("the packet does not start with six 0xff")
}
for i := 0; i < 16; i++ {
if !bytes.Equal(p[6+i*6:12+i*6], mac) {
t.Fatalf("repetition %d is wrong", i)
}
}
}
func TestBroadcastAddrs(t *testing.T) {
list := broadcastAddrs()
if len(list) == 0 || !list[0].Equal(net.IPv4bcast) {
t.Fatalf("got %v", list)
}
for _, ip := range list {
if ip.To4() == nil || ip.IsLoopback() {
t.Errorf("unexpected address %v", ip)
}
}
}
func TestWakeHandlers(t *testing.T) {
d := &daemon{cfg: defaultConfig(), cfgPath: filepath.Join(t.TempDir(), "config.json"), logf: t.Logf}
post := func(h http.HandlerFunc, url, body string) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
h(rec, httptest.NewRequest("POST", url, strings.NewReader(body)))
return rec
}
if rec := post(d.handleWakeList, "/api/wakelist", `[{"name":" gaming-pc ","mac":"00-11-22-AA-BB-CC"},{"name":"","mac":"001122aabbdd"}]`); rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body)
}
want := []wakeTarget{{"gaming-pc", "00:11:22:aa:bb:cc"}, {"00:11:22:aa:bb:dd", "00:11:22:aa:bb:dd"}}
if len(d.cfg.Wake) != 2 || d.cfg.Wake[0] != want[0] || d.cfg.Wake[1] != want[1] {
t.Errorf("list = %+v", d.cfg.Wake)
}
if saved, err := loadConfig(d.cfgPath); err != nil || len(saved.Wake) != 2 {
t.Errorf("saved: %+v, %v", saved.Wake, err)
}
if rec := post(d.handleWakeList, "/api/wakelist", `[{"name":"x","mac":"not a mac"}]`); rec.Code != http.StatusBadRequest {
t.Errorf("a bad address: status %d", rec.Code)
}
// Only listed devices can be woken.
if rec := post(d.handleWake, "/api/wake?mac=de:ad:be:ef:00:01", ""); rec.Code != http.StatusNotFound {
t.Errorf("an unlisted device: status %d", rec.Code)
}
if rec := post(d.handleWake, "/api/wake?mac=nonsense", ""); rec.Code != http.StatusBadRequest {
t.Errorf("nonsense: status %d", rec.Code)
}
}
func TestDescribeDialError(t *testing.T) {
for msg, want := range map[string]string{
"dial tcp 100.64.0.4:80: connect: connection refused": "nothing listens",
"context deadline exceeded": "no answer",
"dial tcp: i/o timeout": "no answer",
"lookup nosuch: no such host": "no device with that name",
"something else entirely": "something else entirely",
} {
if got := describeDialError(errors.New(msg)); !strings.Contains(got, want) {
t.Errorf("%q -> %q", msg, got)
}
}
}
func TestTestTargetRefusesBadTargets(t *testing.T) {
d := &daemon{logf: t.Logf}
for _, target := range []string{"", "nas", "nas:0", ":80", "na s:80", "nas:99999"} {
rec := httptest.NewRecorder()
d.handleTestTarget(rec, httptest.NewRequest("POST", "/api/testtarget?target="+strings.ReplaceAll(target, " ", "%20"), nil))
if rec.Code != http.StatusBadRequest {
t.Errorf("%q: status %d", target, rec.Code)
}
}
}
+228
View File
@@ -0,0 +1,228 @@
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"time"
// tsnet leaves Taildrop out unless it is linked in.
_ "tailscale.com/feature/taildrop"
)
// Receiving files with Taildrop.
//
// A file sent to the console from another device of the same user arrives
// in a holding area inside Tailscale's state directory. The daemon moves
// each one to a folder that can be reached with FTP, says so on screen, and
// lists the folder on the status page.
const defaultReceiveDir = "/data/tailscale/received"
// receivedFile is one entry of the list on the status page.
type receivedFile struct {
Name string `json:"name"`
Size int64 `json:"size"`
Time time.Time `json:"time"`
}
func (d *daemon) receiveDir() string {
d.mu.Lock()
defer d.mu.Unlock()
if d.cfg.ReceiveDir == "" {
return defaultReceiveDir
}
return d.cfg.ReceiveDir
}
// collectFiles waits for files in the holding area and moves them out.
func (d *daemon) collectFiles(ctx context.Context) {
for ctx.Err() == nil {
// Returns as soon as there are files, or after the wait with none.
files, err := d.lc.AwaitWaitingFiles(ctx, time.Minute)
if ctx.Err() != nil {
return
}
if err != nil || len(files) == 0 {
if err != nil && !strings.Contains(err.Error(), "context deadline exceeded") {
// Typically: not logged in yet, or Taildrop is turned off
// for the tailnet. Try again later without filling the log.
select {
case <-ctx.Done():
return
case <-time.After(30 * time.Second):
}
}
continue
}
dir := d.receiveDir()
var got []string
for _, f := range files {
name, err := d.collectFile(ctx, dir, f.Name)
if err != nil {
d.logf("taildrop: %s: %v", f.Name, err)
continue
}
d.logf("taildrop: received %s (%d bytes) into %s", name, f.Size, dir)
got = append(got, name)
}
switch len(got) {
case 0:
// Nothing could be moved; do not spin on the same files.
select {
case <-ctx.Done():
return
case <-time.After(30 * time.Second):
}
case 1:
notify("Tailscale: received a file\n%s\nin %s", got[0], dir)
default:
notify("Tailscale: received %d files\nin %s", len(got), dir)
}
}
}
// collectFile copies one waiting file into dir and removes it from the
// holding area. It returns the name the file got.
func (d *daemon) collectFile(ctx context.Context, dir, name string) (string, error) {
rc, _, err := d.lc.GetWaitingFile(ctx, name)
if err != nil {
return "", err
}
defer rc.Close()
saved, err := saveReceived(dir, name, rc)
if err != nil {
return "", err
}
if err := d.lc.DeleteWaitingFile(ctx, name); err != nil {
d.logf("taildrop: %s is saved, but could not be removed from the holding area: %v", name, err)
}
return saved, nil
}
// safeFileName reduces a name from another device to a plain file name.
func safeFileName(name string) string {
name = strings.ReplaceAll(name, "\\", "/")
name = name[strings.LastIndex(name, "/")+1:]
name = strings.Map(func(r rune) rune {
if r < 0x20 || r == 0x7f {
return -1
}
return r
}, name)
name = strings.TrimSpace(name)
if name == "" || name == "." || name == ".." {
return "file"
}
return name
}
// saveReceived writes r to a new file in dir, named after name. A file that
// is already there is never overwritten: the new one gets a number instead.
func saveReceived(dir, name string, r io.Reader) (string, error) {
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
name = safeFileName(name)
ext := filepath.Ext(name)
base := strings.TrimSuffix(name, ext)
var f *os.File
var err error
final := name
for n := 1; ; n++ {
if n > 1 {
final = fmt.Sprintf("%s (%d)%s", base, n, ext)
}
f, err = os.OpenFile(filepath.Join(dir, final), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
if err == nil {
break
}
if !os.IsExist(err) || n > 10000 {
return "", err
}
}
if _, err := io.Copy(f, r); err != nil {
f.Close()
os.Remove(f.Name())
return "", err
}
if err := f.Close(); err != nil {
os.Remove(f.Name())
return "", err
}
return final, nil
}
// listReceived returns the newest files in dir, newest first.
func listReceived(dir string, max int) []receivedFile {
files := []receivedFile{}
entries, err := os.ReadDir(dir)
if err != nil {
return files
}
for _, e := range entries {
info, err := e.Info()
if err != nil || !info.Mode().IsRegular() {
continue
}
files = append(files, receivedFile{Name: e.Name(), Size: info.Size(), Time: info.ModTime()})
}
sort.Slice(files, func(i, j int) bool { return files[i].Time.After(files[j].Time) })
if len(files) > max {
files = files[:max]
}
return files
}
func (d *daemon) handleFiles(w http.ResponseWriter, r *http.Request) {
dir := d.receiveDir()
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
json.NewEncoder(w).Encode(map[string]any{"dir": dir, "files": listReceived(dir, 100)})
}
// handleFileGet sends one received file to the browser.
func (d *daemon) handleFileGet(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
if name == "" || name != safeFileName(name) {
http.Error(w, "no such file", http.StatusNotFound)
return
}
f, err := os.Open(filepath.Join(d.receiveDir(), name))
if err != nil {
http.Error(w, "no such file", http.StatusNotFound)
return
}
defer f.Close()
info, err := f.Stat()
if err != nil || !info.Mode().IsRegular() {
http.Error(w, "no such file", http.StatusNotFound)
return
}
// Always a download, never something the browser renders: the file came
// from elsewhere and this page's origin has the controls on it.
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename*=UTF-8''%s", urlPathEscape(name)))
w.Header().Set("X-Content-Type-Options", "nosniff")
// Wrapped so that Go does not use sendfile, which fails on the PS5.
http.ServeContent(w, r, "", info.ModTime(), struct{ io.ReadSeeker }{f})
}
func urlPathEscape(s string) string {
var b strings.Builder
for _, c := range []byte(s) {
switch {
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '-', c == '.', c == '_', c == '~':
b.WriteByte(c)
default:
fmt.Fprintf(&b, "%%%02X", c)
}
}
return b.String()
}
+94
View File
@@ -0,0 +1,94 @@
package main
import (
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
)
func TestSafeFileName(t *testing.T) {
for in, want := range map[string]string{
"photo.jpg": "photo.jpg",
"../../etc/passwd": "passwd",
`C:\Users\me\notes.txt`: "notes.txt",
"dir/sub/file.bin": "file.bin",
"..": "file",
"": "file",
"a\x00b\n.txt": "ab.txt",
" spaced name.pkg ": "spaced name.pkg",
"save (1).zip": "save (1).zip",
"/data/tailscale/x.json": "x.json",
} {
if got := safeFileName(in); got != want {
t.Errorf("safeFileName(%q) = %q, want %q", in, got, want)
}
}
}
func TestSaveReceivedNeverOverwrites(t *testing.T) {
dir := filepath.Join(t.TempDir(), "received")
var names []string
for _, content := range []string{"one", "two", "three"} {
name, err := saveReceived(dir, "../save.zip", strings.NewReader(content))
if err != nil {
t.Fatal(err)
}
names = append(names, name)
}
want := []string{"save.zip", "save (2).zip", "save (3).zip"}
for i := range want {
if names[i] != want[i] {
t.Errorf("file %d was named %q, want %q", i, names[i], want[i])
}
}
if b, _ := os.ReadFile(filepath.Join(dir, "save.zip")); string(b) != "one" {
t.Errorf("the first file was changed: %q", b)
}
// Nothing escaped the folder.
if _, err := os.Stat(filepath.Join(filepath.Dir(dir), "save.zip")); err == nil {
t.Error("a file was written outside the folder")
}
files := listReceived(dir, 2)
if len(files) != 2 {
t.Errorf("listReceived returned %d files, want 2", len(files))
}
if got := listReceived(filepath.Join(dir, "missing"), 10); len(got) != 0 {
t.Errorf("a missing folder listed %d files", len(got))
}
}
func TestFileDownload(t *testing.T) {
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "page.html"), []byte("<script>alert(1)</script>"), 0o644)
os.WriteFile(filepath.Join(filepath.Dir(dir), "secret.txt"), []byte("secret"), 0o644)
cfg := defaultConfig()
cfg.ReceiveDir = dir
d := &daemon{cfg: cfg, logf: t.Logf}
get := func(name string) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
d.handleFileGet(rec, httptest.NewRequest("GET", "/api/files/get?name="+url.QueryEscape(name), nil))
return rec
}
rec := get("page.html")
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "alert") {
t.Fatalf("download: %d", rec.Code)
}
// Served as a download, not as a page of this origin.
if ct := rec.Header().Get("Content-Type"); ct != "application/octet-stream" {
t.Errorf("Content-Type = %q", ct)
}
if cd := rec.Header().Get("Content-Disposition"); !strings.HasPrefix(cd, "attachment") {
t.Errorf("Content-Disposition = %q", cd)
}
for _, name := range []string{"../secret.txt", "..", "", "missing.txt", `..\secret.txt`, "sub/page.html"} {
if rec := get(name); rec.Code != http.StatusNotFound {
t.Errorf("%q: status %d", name, rec.Code)
}
}
}
+29 -3
View File
@@ -3,6 +3,7 @@ package main
import (
"context"
"errors"
"io"
"net"
"sync"
"sync/atomic"
@@ -26,7 +27,10 @@ type udpRelayConfig struct {
listen func() (net.PacketConn, error)
// dial opens the connection to the target for one client.
dial func(ctx context.Context) (net.Conn, error)
logf func(format string, args ...any)
// allow, if set, is asked once per client address whether to serve it.
// Datagrams from a client it turns down are dropped.
allow func(from net.Addr) bool
logf func(format string, args ...any)
}
// udpFlow is the relay state for one client address.
@@ -48,19 +52,24 @@ type udpRelay struct {
sock net.PacketConn
closed bool
flows map[string]*udpFlow
ended atomic.Bool // the read loop has returned
}
// startUDPRelay opens the listening socket and relays until stop is called.
func startUDPRelay(cfg udpRelayConfig) (stop func(), err error) {
func startUDPRelay(cfg udpRelayConfig) (*udpRelay, error) {
sock, err := cfg.listen()
if err != nil {
return nil, err
}
r := &udpRelay{cfg: cfg, sock: sock, flows: map[string]*udpFlow{}}
go r.readLoop()
return r.stop, nil
return r, nil
}
// running reports whether the relay is still reading from its socket.
func (r *udpRelay) running() bool { return !r.ended.Load() }
func (r *udpRelay) stop() {
r.mu.Lock()
defer r.mu.Unlock()
@@ -77,6 +86,7 @@ func (r *udpRelay) socket() (net.PacketConn, bool) {
}
func (r *udpRelay) readLoop() {
defer r.ended.Store(true)
buf := make([]byte, 65535)
for {
sock, stopped := r.socket()
@@ -88,6 +98,11 @@ func (r *udpRelay) readLoop() {
if _, stopped := r.socket(); stopped {
return
}
if errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) {
// Whatever provided the socket has shut down (Tailscale
// stopping, for one). There is nothing to reopen.
return
}
r.cfg.logf("%s: %v; reopening", r.cfg.name, err)
sock.Close()
time.Sleep(time.Second)
@@ -106,6 +121,17 @@ func (r *udpRelay) readLoop() {
key := from.String()
r.mu.Lock()
fl := r.flows[key]
if fl == nil && r.cfg.allow != nil {
// Ask without holding the lock; the answer may take a moment.
r.mu.Unlock()
ok := r.cfg.allow(from)
r.mu.Lock()
if !ok {
r.mu.Unlock()
continue
}
fl = r.flows[key]
}
if fl == nil {
fl = &udpFlow{out: make(chan []byte, 256)}
r.flows[key] = fl
-30
View File
@@ -1,30 +0,0 @@
package main
import (
"errors"
"io/fs"
"os"
"path/filepath"
)
// daemonFileName is the name the installer stores the daemon payload under,
// in the data directory.
const daemonFileName = "tailscale.elf"
// uninstall deletes the installed daemon payload. With purge it also deletes
// the Tailscale state and config, which forgets the login. The running
// process is not affected.
func uninstall(purge bool) error {
var errs []error
if err := os.Remove(filepath.Join(dataDir, daemonFileName)); err != nil && !errors.Is(err, fs.ErrNotExist) {
errs = append(errs, err)
}
if purge {
for _, name := range []string{"state", "config.json", ".cache"} {
if err := os.RemoveAll(filepath.Join(dataDir, name)); err != nil {
errs = append(errs, err)
}
}
}
return errors.Join(errs...)
}
+155
View File
@@ -0,0 +1,155 @@
package main
import (
"context"
"encoding/json"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
// The daemon asks GitHub now and then whether a newer release exists, so that
// the status page can say so. It never downloads or installs anything.
// A variable so that test builds can point it elsewhere (-ldflags -X).
var releasesAPI = "https://api.github.com/repos/holdmysocks/ps5-tailscale/releases/latest"
type releaseInfo struct {
Version string // without the leading "v"
URL string
// Assets maps the names of the release's files to where they are
// downloaded from.
Assets map[string]string
}
// parseVersion reads "v1.2.3" or "1.2.3-dev" as its three numbers.
func parseVersion(s string) (v [3]int, ok bool) {
s = strings.TrimPrefix(strings.TrimSpace(s), "v")
if i := strings.IndexAny(s, "-+ "); i >= 0 {
s = s[:i]
}
parts := strings.Split(s, ".")
if len(parts) != 3 {
return v, false
}
for i, p := range parts {
n, err := strconv.Atoi(p)
if err != nil || n < 0 {
return v, false
}
v[i] = n
}
return v, true
}
// newerVersion reports whether latest is a later release than current.
func newerVersion(current, latest string) bool {
c, ok1 := parseVersion(current)
l, ok2 := parseVersion(latest)
if !ok1 || !ok2 {
return false
}
for i := range c {
if l[i] != c[i] {
return l[i] > c[i]
}
}
return false
}
func fetchLatestRelease(ctx context.Context, url string) (releaseInfo, error) {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return releaseInfo{}, err
}
req.Header.Set("User-Agent", "ps5-tailscale/"+version)
req.Header.Set("Accept", "application/vnd.github+json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return releaseInfo{}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return releaseInfo{}, &httpStatusError{resp.Status}
}
var rel struct {
TagName string `json:"tag_name"`
HTMLURL string `json:"html_url"`
Assets []struct {
Name string `json:"name"`
URL string `json:"browser_download_url"`
} `json:"assets"`
}
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
return releaseInfo{}, err
}
info := releaseInfo{Version: strings.TrimPrefix(rel.TagName, "v"), URL: rel.HTMLURL, Assets: map[string]string{}}
for _, a := range rel.Assets {
info.Assets[a.Name] = a.URL
}
return info, nil
}
type httpStatusError struct{ status string }
func (e *httpStatusError) Error() string { return "unexpected response: " + e.status }
// watchForUpdates checks shortly after start and then twice a day, for as
// long as the setting is on.
func (d *daemon) watchForUpdates(ctx context.Context) {
timer := time.NewTimer(time.Minute)
defer timer.Stop()
for {
select {
case <-ctx.Done():
return
case <-timer.C:
}
timer.Reset(12 * time.Hour)
d.mu.Lock()
enabled := d.cfg.CheckUpdates
d.mu.Unlock()
if !enabled {
d.mu.Lock()
d.latest = releaseInfo{}
d.mu.Unlock()
continue
}
reqCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
rel, err := fetchLatestRelease(reqCtx, releasesAPI)
cancel()
if err != nil {
d.logf("update check: %v", err)
timer.Reset(time.Hour)
continue
}
d.mu.Lock()
known := d.latest.Version
d.latest = rel
d.mu.Unlock()
if d.debug != nil {
d.debug.Printf("update check: the latest release is %s", rel.Version)
}
if rel.Version != known && newerVersion(version, rel.Version) {
d.logf("a newer release is available: %s (running %s)", rel.Version, version)
d.notifyUpdate(rel.Version)
}
}
}
// updateNotifiedFile remembers the release the user has been told about on
// screen, so that each release is announced once and not after every start.
const updateNotifiedFile = "update-notified"
func (d *daemon) notifyUpdate(latest string) {
path := filepath.Join(dataDir, updateNotifiedFile)
if b, err := os.ReadFile(path); err == nil && strings.TrimSpace(string(b)) == latest {
return
}
notify("Tailscale for PS5 %s is available (this is %s).\nSee %s", latest, version, d.webURL())
os.WriteFile(path, []byte(latest+"\n"), 0o644)
}
+180
View File
@@ -0,0 +1,180 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"strings"
)
// Waking a device on the console's home network.
//
// A sleeping PC cannot be reached over Tailscale: nothing on it is running.
// But the console sits on the same home network, and a Wake-on-LAN packet
// only has to come from there. So the status page, which can be opened from
// anywhere over the tailnet, gets a button that makes the console send one.
// wakeTarget is a device that can be woken.
type wakeTarget struct {
Name string `json:"name"`
MAC string `json:"mac"`
}
// parseMAC accepts the usual ways of writing a hardware address and returns
// it in the form aa:bb:cc:dd:ee:ff.
func parseMAC(s string) (net.HardwareAddr, error) {
s = strings.TrimSpace(s)
if len(s) == 12 && !strings.ContainsAny(s, ":-.") {
s = s[0:2] + ":" + s[2:4] + ":" + s[4:6] + ":" + s[6:8] + ":" + s[8:10] + ":" + s[10:12]
}
mac, err := net.ParseMAC(s)
if err != nil || len(mac) != 6 {
return nil, fmt.Errorf("%q is not a network card address (it looks like 00:11:22:AA:BB:CC)", s)
}
return mac, nil
}
func validateWake(list []wakeTarget) error {
for i := range list {
list[i].Name = strings.TrimSpace(list[i].Name)
mac, err := parseMAC(list[i].MAC)
if err != nil {
return err
}
list[i].MAC = mac.String()
if list[i].Name == "" {
list[i].Name = list[i].MAC
}
if len(list[i].Name) > 64 {
return errors.New("a name is too long")
}
}
return nil
}
// magicPacket is the Wake-on-LAN payload: six bytes of 0xff and the address
// sixteen times.
func magicPacket(mac net.HardwareAddr) []byte {
p := make([]byte, 0, 6+16*6)
for i := 0; i < 6; i++ {
p = append(p, 0xff)
}
for i := 0; i < 16; i++ {
p = append(p, mac...)
}
return p
}
// broadcastAddrs returns where to send a packet so that every device on the
// console's networks sees it: each network's own broadcast address, and the
// general one.
func broadcastAddrs() []net.IP {
list := []net.IP{net.IPv4bcast}
addrs, err := net.InterfaceAddrs()
if err != nil {
return list
}
for _, a := range addrs {
ipnet, ok := a.(*net.IPNet)
if !ok {
continue
}
ip := ipnet.IP.To4()
if ip == nil || ip.IsLoopback() || len(ipnet.Mask) != 4 {
continue
}
if ones, _ := ipnet.Mask.Size(); ones >= 31 {
continue
}
b := make(net.IP, 4)
for i := range b {
b[i] = ip[i] | ^ipnet.Mask[i]
}
list = append(list, b)
}
return list
}
// sendWake broadcasts the magic packet. It reports how many sends went out;
// there is no way to know whether the device heard it.
func sendWake(mac net.HardwareAddr) (sent int, err error) {
packet := magicPacket(mac)
var lastErr error
for _, ip := range broadcastAddrs() {
// Port 9 is the customary one; some network cards listen on 7.
for _, port := range []int{9, 7} {
c, err := net.DialUDP("udp4", nil, &net.UDPAddr{IP: ip, Port: port})
if err != nil {
lastErr = err
continue
}
if _, err := c.Write(packet); err != nil {
lastErr = err
} else {
sent++
}
c.Close()
}
}
if sent == 0 {
if lastErr == nil {
lastErr = errors.New("no network to send on")
}
return 0, lastErr
}
return sent, nil
}
// handleWakeList replaces the list of devices that can be woken.
func (d *daemon) handleWakeList(w http.ResponseWriter, r *http.Request) {
var list []wakeTarget
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&list); err != nil {
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
return
}
if err := validateWake(list); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
d.mu.Lock()
d.cfg.Wake = list
cfg := d.cfg
d.mu.Unlock()
if err := saveConfig(d.cfgPath, cfg); err != nil {
d.logf("saving config: %v", err)
}
io.WriteString(w, "ok\n")
}
// handleWake sends the wake-up packet to one of the listed devices. Only
// listed devices: the page is not a tool for poking arbitrary addresses.
func (d *daemon) handleWake(w http.ResponseWriter, r *http.Request) {
mac, err := parseMAC(r.URL.Query().Get("mac"))
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
d.mu.Lock()
name := ""
for _, t := range d.cfg.Wake {
if t.MAC == mac.String() {
name = t.Name
}
}
d.mu.Unlock()
if name == "" {
http.Error(w, "that device is not in the list; save it first", http.StatusNotFound)
return
}
sent, err := sendWake(mac)
if err != nil {
d.logf("wake %s: %v", name, err)
http.Error(w, "could not send the wake-up packet: "+err.Error(), http.StatusInternalServerError)
return
}
d.logf("wake-up packet sent to %s (%d sends)", name, sent)
io.WriteString(w, "Wake-up packet sent to "+name+". Give it half a minute.\n")
}
+320 -89
View File
@@ -1,14 +1,16 @@
package main
import (
"context"
_ "embed"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
@@ -18,17 +20,23 @@ import (
//go:embed status.html
var statusHTML []byte
// The status page has no login: like the other services on a jailbroken
// console it trusts the local network. State-changing requests must carry
// this header, which a web page on another origin cannot send, so a stray
// link or image tag cannot log the console out.
// faviconPNG is the logo from the home screen icon (appicon/icon0.png)
// without its text, 128x128, for the browser tab.
//
//go:embed favicon.png
var faviconPNG []byte
// State-changing requests must carry this header, which a web page on
// another origin cannot send, so a stray link or image tag cannot log the
// console out. Who may use the page at all is decided in auth.go.
const apiHeader = "X-PS5-Tailscale"
type peerInfo struct {
Name string `json:"name"`
IP string `json:"ip"`
OS string `json:"os"`
Online bool `json:"online"`
// sunshineInfo is a forwarded Sunshine host as the status page shows it.
type sunshineInfo struct {
Host string `json:"host"`
Port int `json:"port"`
// Address is what to enter in a Moonlight client on the console.
Address string `json:"address"`
}
type statusInfo struct {
@@ -42,51 +50,96 @@ type statusInfo struct {
Tailnet string `json:"tailnet,omitempty"`
Health []string `json:"health,omitempty"`
Peers []peerInfo `json:"peers"`
Proxy string `json:"proxy,omitempty"`
// SunshineHost and Forwards describe the local forwards.
SunshineHost string `json:"sunshineHost"`
Forwards []string `json:"forwards"`
// VPNServers counts the exit servers of a VPN add-on. They are only in
// Peers when the page asks for them (?vpn=1).
VPNServers peerCount `json:"vpnServers"`
Proxy string `json:"proxy,omitempty"`
// SunshineHosts and Forwards describe the local forwards.
SunshineHosts []sunshineInfo `json:"sunshineHosts"`
Forwards []string `json:"forwards"`
// 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"`
Uptime int64 `json:"uptimeSeconds"`
Priority string `json:"priority"`
// PasswordSet says whether the page is password protected.
PasswordSet bool `json:"passwordSet"`
// AllowFrom is "all" or "own": which tailnet devices may connect.
AllowFrom string `json:"allowFrom"`
// KeyExpiry is when this console's Tailscale key expires, if it does.
KeyExpiry *time.Time `json:"keyExpiry,omitempty"`
// LatestVersion and UpdateURL are set when a newer release exists.
LatestVersion string `json:"latestVersion,omitempty"`
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"`
// 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"`
}
func (d *daemon) serveWeb(ln net.Listener) {
// webHandler builds the status page and its API.
func (d *daemon) webHandler() http.Handler {
mux := http.NewServeMux()
// Open to everyone who can reach the page: the page itself (which shows
// nothing until its API answers), the icon, and what a new instance
// needs to recognise this one.
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.Write(statusHTML)
})
favicon := func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/png")
w.Header().Set("Cache-Control", "max-age=86400")
w.Write(faviconPNG)
}
mux.HandleFunc("GET /favicon.png", favicon)
mux.HandleFunc("GET /favicon.ico", favicon) // what browsers ask for unprompted
mux.HandleFunc("GET /api/ping", func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, "ps5-tailscale "+version+"\n")
})
mux.HandleFunc("GET /api/status", d.handleStatus)
mux.HandleFunc("GET /api/logs", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
switch {
case r.URL.Query().Get("full") == "1":
// The end of the log file itself.
writeFileTail(w, filepath.Join(dataDir, "tailscale.log"), 512<<10)
return
case r.URL.Query().Get("debug") == "1":
// The end of the debug log, which includes Tailscale's own messages.
writeFileTail(w, filepath.Join(dataDir, "tailscale-debug.log"), 1<<20)
return
case r.URL.Query().Get("debug") == "old":
writeFileTail(w, filepath.Join(dataDir, "tailscale-debug.log.old"), 1<<20)
return
}
io.WriteString(w, strings.Join(recentLogs.snapshot(), "\n")+"\n")
})
mux.HandleFunc("GET /qr.png", d.handleQR)
mux.HandleFunc("POST /api/login", d.guard(d.handleLogin))
mux.HandleFunc("POST /api/logout", d.guard(d.handleLogout))
mux.HandleFunc("POST /api/quit", d.guard(d.handleQuit))
mux.HandleFunc("POST /api/uninstall", d.guard(d.handleUninstall))
mux.HandleFunc("POST /api/sunshine", d.guard(d.handleSunshine))
mux.HandleFunc("POST /api/auth", d.guard(d.handleAuth))
mux.HandleFunc("POST /api/lock", d.guard(d.handleLock))
srv := &http.Server{Handler: mux, ReadHeaderTimeout: 10 * time.Second}
// Everything else needs the password, if one is set.
mux.HandleFunc("GET /api/status", d.protect(d.handleStatus))
mux.HandleFunc("GET /api/logs", d.protect(d.handleLogs))
mux.HandleFunc("GET /qr.png", d.protect(d.handleQR))
mux.HandleFunc("GET /api/config", d.protect(d.handleGetConfig))
mux.HandleFunc("GET /api/files", d.protect(d.handleFiles))
mux.HandleFunc("GET /api/files/get", d.protect(d.handleFileGet))
mux.HandleFunc("GET /api/diagnostics", d.protect(d.handleDiagnostics))
for path, h := range map[string]http.HandlerFunc{
"/api/config": d.handleSetConfig,
"/api/login": d.handleLogin,
"/api/logout": d.handleLogout,
"/api/quit": d.handleQuit,
"/api/uninstall": d.handleUninstall,
"/api/sunshine": d.handleSunshine,
"/api/forwards": d.handleForwards,
"/api/pingpeer": d.handlePingPeer,
"/api/testtarget": d.handleTestTarget,
"/api/wakelist": d.handleWakeList,
"/api/wake": d.handleWake,
"/api/update": d.handleUpdate,
} {
mux.HandleFunc("POST "+path, d.protect(d.guard(h)))
}
return mux
}
// serveWeb serves the status page on one listener.
func (d *daemon) serveWeb(ln net.Listener, h http.Handler) {
srv := &http.Server{Handler: h, ReadHeaderTimeout: 10 * time.Second}
if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed && !d.stopping() {
d.logf("web UI stopped: %v", err)
}
@@ -113,7 +166,7 @@ func writeFileTail(w io.Writer, path string, max int64) {
if fi, err := f.Stat(); err == nil && fi.Size() > max {
f.Seek(fi.Size()-max, io.SeekStart)
}
io.Copy(w, f)
io.Copy(w, onlyReader{f}) // no sendfile, see sendToLoader
}
func (d *daemon) guard(h http.HandlerFunc) http.HandlerFunc {
@@ -126,20 +179,59 @@ func (d *daemon) guard(h http.HandlerFunc) http.HandlerFunc {
}
}
func (d *daemon) handleLogs(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
switch {
case r.URL.Query().Get("full") == "1":
// The end of the log file itself.
writeFileTail(w, filepath.Join(dataDir, "tailscale.log"), 512<<10)
case r.URL.Query().Get("debug") == "1":
// The end of the debug log, which includes Tailscale's own messages.
writeFileTail(w, filepath.Join(dataDir, "tailscale-debug.log"), 1<<20)
case r.URL.Query().Get("debug") == "old":
writeFileTail(w, filepath.Join(dataDir, "tailscale-debug.log.old"), 1<<20)
default:
io.WriteString(w, strings.Join(recentLogs.snapshot(), "\n")+"\n")
}
}
func (d *daemon) handleStatus(w http.ResponseWriter, r *http.Request) {
d.mu.Lock()
info := statusInfo{
Version: version,
State: d.state,
AuthURL: d.authURL,
Error: d.lastErr,
Hostname: d.cfg.Hostname,
Proxy: d.cfg.HTTPProxyAddr,
Uptime: int64(time.Since(d.started).Seconds()),
IPs: []string{},
Peers: []peerInfo{},
Version: version,
State: d.state,
AuthURL: d.authURL,
Error: d.lastErr,
Hostname: d.cfg.Hostname,
Proxy: d.cfg.HTTPProxyAddr,
Priority: priorityLow,
AllowFrom: accessAll,
PasswordSet: d.cfg.PasswordHash != "",
Uptime: int64(time.Since(d.started).Seconds()),
IPs: []string{},
Peers: []peerInfo{},
SunshineHosts: []sunshineInfo{},
}
info.SunshineHost = d.cfg.SunshineHost
if d.cfg.Priority == priorityHigh {
info.Priority = priorityHigh
}
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()})
}
if newerVersion(version, d.latest.Version) {
info.LatestVersion, info.UpdateURL = d.latest.Version, d.latest.URL
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 {
@@ -168,23 +260,9 @@ func (d *daemon) handleStatus(w http.ResponseWriter, r *http.Request) {
for _, ip := range st.Self.TailscaleIPs {
info.IPs = append(info.IPs, ip.String())
}
info.KeyExpiry = st.Self.KeyExpiry
}
for _, p := range st.Peer {
pi := peerInfo{Name: p.HostName, OS: p.OS, Online: p.Online}
if p.DNSName != "" {
pi.Name = strings.SplitN(p.DNSName, ".", 2)[0]
}
if len(p.TailscaleIPs) > 0 {
pi.IP = p.TailscaleIPs[0].String()
}
info.Peers = append(info.Peers, pi)
}
sort.Slice(info.Peers, func(i, j int) bool {
if info.Peers[i].Online != info.Peers[j].Online {
return info.Peers[i].Online
}
return info.Peers[i].Name < info.Peers[j].Name
})
info.Peers, info.VPNServers = peersFromStatus(st, r.URL.Query().Get("vpn") == "1")
}
}
if info.State == "Running" {
@@ -254,23 +332,32 @@ func (d *daemon) handleLogout(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, "ok\n")
}
// handleSunshine sets (or with an empty host, clears) the Sunshine host whose
// streaming ports are forwarded from 127.0.0.1, saves the config and applies
// it without a restart.
// handleSunshine replaces the list of Sunshine hosts whose streaming ports are
// forwarded from 127.0.0.1, saves the config and applies it at once.
func (d *daemon) handleSunshine(w http.ResponseWriter, r *http.Request) {
host := strings.TrimSpace(r.URL.Query().Get("host"))
if !validHostName(host) {
http.Error(w, "that does not look like a host name or address", http.StatusBadRequest)
var hosts []sunshineHost
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&hosts); err != nil {
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
return
}
for i := range hosts {
hosts[i].Host = strings.TrimSpace(hosts[i].Host)
if hosts[i].Port == sunshineDefaultPort {
hosts[i].Port = 0
}
}
if err := validateSunshineHosts(hosts); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
d.mu.Lock()
d.cfg.SunshineHost = host
d.cfg.SunshineHosts = hosts
cfg := d.cfg
d.mu.Unlock()
if err := saveConfig(d.cfgPath, cfg); err != nil {
d.logf("saving config: %v", err)
}
d.logf("sunshine host set to %q", host)
d.logf("sunshine hosts set to %v", hosts)
if err := d.fwd.set(d.localForwardRules()); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@@ -302,30 +389,34 @@ func (d *daemon) stop() {
d.quitOnce.Do(func() { close(d.quit) })
}
// handleUninstall removes the installed payload, then stops. With ?purge=1 it
// first logs the console out of the tailnet and deletes the saved state as
// well.
// handleUninstall takes the home screen icon away, logs the console out of
// the tailnet and stops the daemon, which deletes its data directory (login,
// settings, logs) on the way out. The payload file itself is wherever the
// user keeps it.
func (d *daemon) handleUninstall(w http.ResponseWriter, r *http.Request) {
purge := r.URL.Query().Get("purge") == "1"
if purge && d.lc != nil {
d.logf("uninstall requested from the status page")
iconNote := "The home screen icon was removed."
if err := removeHomeIcon(); err != nil {
d.logf("uninstall: home screen icon: %v", err)
iconNote = "The home screen icon could not be removed (" + err.Error() + "); delete it from the home screen."
}
if d.lc != nil {
if err := d.lc.Logout(r.Context()); err != nil {
d.logf("uninstall: logout: %v", err)
}
}
if err := uninstall(purge); err != nil {
d.logf("uninstall: %v", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
d.logf("uninstalled (purge=%v)", purge)
d.mu.Lock()
d.removeDataOnExit = true
d.mu.Unlock()
notify("Tailscale was removed from this PS5.")
io.WriteString(w, "uninstalled\n")
io.WriteString(w, "Tailscale was removed from this PS5. "+iconNote+"\n")
d.stop()
}
// stopRunningInstance asks an instance that is already serving the status
// page to exit and waits for the port to become free. It reports whether
// there was one.
// there was one. The request comes from the console itself, so it needs no
// password.
func stopRunningInstance(webAddr string) bool {
_, port, err := net.SplitHostPort(webAddr)
if err != nil {
@@ -359,3 +450,143 @@ func stopRunningInstance(webAddr string) bool {
}
return true
}
// handleUpdate starts installing the newest release.
func (d *daemon) handleUpdate(w http.ResponseWriter, r *http.Request) {
if err := d.startUpdate(); err != nil {
http.Error(w, err.Error(), http.StatusConflict)
return
}
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
}