mirror of
https://github.com/holdmysocks/ps5-tailscale.git
synced 2026-10-07 23:00:22 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4986b4d52 | ||
|
|
001837e9c0 | ||
|
|
f3f31e59b9 | ||
|
|
c41c46e9cd | ||
|
|
b31667d3f0 | ||
|
|
edbaaed240 | ||
|
|
4c6d9223b5 | ||
|
|
070e838135 | ||
|
|
02fc73a04d | ||
|
|
bc19d3c251 | ||
|
|
3e0e872884 | ||
|
|
726b9b99c4 | ||
|
|
558994fc76 | ||
|
|
ed67b35b52 |
No files matched your search
@@ -0,0 +1,33 @@
|
||||
name: tests
|
||||
|
||||
# The daemon's unit tests. They run on an ordinary machine; nothing here
|
||||
# builds the PS5 payload, which needs the patched Go tree (docs/BUILDING.md).
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: tsd
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: tsd/go.mod
|
||||
cache-dependency-path: tsd/go.sum
|
||||
- name: gofmt
|
||||
if: runner.os == 'Linux'
|
||||
run: test -z "$(gofmt -l .)" || (gofmt -l . && exit 1)
|
||||
- run: go vet ./...
|
||||
- run: go test ./...
|
||||
@@ -27,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,10 +52,14 @@ Tested on firmware 13.42 with elfldr 0.26.
|
||||
|
||||
## Install
|
||||
|
||||
There is one file, `tailscale.elf`, and it is an ordinary payload: running
|
||||
it starts Tailscale.
|
||||
There is one file, `tailscale-<version>.elf` (for example
|
||||
`tailscale-0.6.1.elf`), and it is an ordinary payload: running it starts
|
||||
Tailscale. The examples below call it `tailscale.elf`; use the name of the
|
||||
file you downloaded, or rename it.
|
||||
|
||||
1. Download `tailscale.elf` from the [latest release](../../releases/latest).
|
||||
1. Download `tailscale-<version>.elf` from the
|
||||
[latest release](../../releases/latest). The `.sig` file next to it is
|
||||
for the status page's Install button; you do not need it.
|
||||
2. Send it to the console's ELF loader. Any payload sender works:
|
||||
|
||||
```bash
|
||||
@@ -68,11 +81,20 @@ run also adds a **Tailscale** icon to the home screen (media section) that
|
||||
opens the status page.
|
||||
|
||||
Tailscale runs until the console restarts. After a restart and jailbreak,
|
||||
send `tailscale.elf` again; the login and settings are kept. If you use a
|
||||
payload manager or autoloader, add the file there like any other payload.
|
||||
send the file again; the login and settings are kept. If you use a payload
|
||||
manager or autoloader, add the file there like any other payload.
|
||||
|
||||
To update, use the new `tailscale.elf` in place of the old one. Sending it
|
||||
while Tailscale is running replaces the running copy.
|
||||
To update, use the new file in place of the old one. Sending it while
|
||||
Tailscale is running replaces the running copy. The file name changes with
|
||||
every release, so **check your autoload settings after updating**: a payload
|
||||
manager or autoloader that still points at the old file starts the old
|
||||
version with the console, or nothing if you deleted it.
|
||||
|
||||
The easy way to avoid that: keep the copy your payload manager or autoloader
|
||||
starts under a fixed name without a version, such as `tailscale.elf`. When
|
||||
you update, save the new release over it under that same name, and the
|
||||
autoload entry never needs to change. The status page always shows which
|
||||
version is really running.
|
||||
|
||||
## Using it
|
||||
|
||||
@@ -83,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
|
||||
|
||||
@@ -108,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)
|
||||
|
||||
@@ -130,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
|
||||
}
|
||||
```
|
||||
@@ -195,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:
|
||||
@@ -213,62 +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/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 logs the console out of your
|
||||
tailnet, deletes `/data/tailscale` (login, settings, logs) and stops
|
||||
Tailscale.
|
||||
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.
|
||||
|
||||
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 `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
|
||||
|
||||
|
||||
+40
-1
@@ -6,7 +6,11 @@
|
||||
* 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.
|
||||
*
|
||||
* Prints "icon: ok" on success; the launcher looks for that.
|
||||
* 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
|
||||
@@ -47,6 +51,37 @@ 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) {
|
||||
@@ -112,6 +147,10 @@ main(void) {
|
||||
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");
|
||||
|
||||
+58
-1
@@ -52,12 +52,69 @@ These folders are not in the repository.
|
||||
|
||||
```powershell
|
||||
# C launcher + Go program + home screen icon helper -> out\tailscale.elf
|
||||
.\tools\build-payload.ps1 -GoDir tsd -Name tailscale -Version 0.4.1 -HomeIcon
|
||||
.\tools\build-payload.ps1 -GoDir tsd -Name tailscale -Version 0.5.2 -HomeIcon
|
||||
```
|
||||
|
||||
`-HomeIcon` also builds `appicon\` into `out\appicon.elf` and embeds it in
|
||||
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
|
||||
|
||||
+109
-10
@@ -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,11 +44,61 @@ 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.
|
||||
|
||||
@@ -54,6 +112,12 @@ 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.
|
||||
|
||||
@@ -105,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
|
||||
@@ -113,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
|
||||
@@ -140,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
|
||||
@@ -151,9 +252,6 @@ Linking `libSceAppInstUtil` alone leaves the payload stopped before it runs.
|
||||
It needs `-lSceIpmi -lSceAppInstUtil -lSceUserService -lSceSystemService`, in
|
||||
that order, as in the SDK's `install_app` sample.
|
||||
|
||||
The daemon cannot remove the icon; that is left to the user (Options, then
|
||||
Delete, on the home screen).
|
||||
|
||||
## Known problems
|
||||
|
||||
- Once, switching the console from Wi-Fi to Ethernet during a Moonlight
|
||||
@@ -165,5 +263,6 @@ Delete, on the home screen).
|
||||
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 |
+18
-8
@@ -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);
|
||||
|
||||
+32
-3
@@ -9,6 +9,8 @@
|
||||
|
||||
#ifdef ICON_HELPER
|
||||
|
||||
#include "report.h"
|
||||
|
||||
#include <fcntl.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
@@ -27,6 +29,7 @@
|
||||
* 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[];
|
||||
@@ -54,6 +57,30 @@ marker_is_current(void) {
|
||||
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};
|
||||
@@ -64,10 +91,11 @@ home_icon_install_once(void) {
|
||||
size_t got = 0;
|
||||
int fd;
|
||||
|
||||
mkdir(DATA_DIR, 0755);
|
||||
save_helper();
|
||||
if (marker_is_current()) {
|
||||
return;
|
||||
}
|
||||
mkdir(DATA_DIR, 0755);
|
||||
|
||||
if ((fd = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
|
||||
return;
|
||||
@@ -77,6 +105,7 @@ home_icon_install_once(void) {
|
||||
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;
|
||||
}
|
||||
@@ -113,9 +142,9 @@ home_icon_install_once(void) {
|
||||
write(fd, ICON_VERSION, sizeof(ICON_VERSION) - 1);
|
||||
close(fd);
|
||||
}
|
||||
fprintf(stderr, "launcher: home screen icon installed\n");
|
||||
report_log("launcher: home screen icon installed");
|
||||
} else {
|
||||
fprintf(stderr, "launcher: home screen icon not installed: %s\n", got ? reply : "no reply from the helper");
|
||||
report_log("launcher: home screen icon not installed: %s", got ? reply : "no reply from the helper");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+29
-7
@@ -3,6 +3,7 @@
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <sys/mman.h>
|
||||
@@ -11,6 +12,7 @@
|
||||
|
||||
#include "goload.h"
|
||||
#include "homeicon.h"
|
||||
#include "report.h"
|
||||
|
||||
#ifndef GO_IMAGE
|
||||
#error "GO_IMAGE must name the Go binary to embed"
|
||||
@@ -63,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
|
||||
@@ -135,12 +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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)) {
|
||||
|
||||
@@ -1,5 +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.4.1 -HomeIcon
|
||||
# .\tools\build-payload.ps1 -GoDir tsd -Name tailscale -Version 0.5.2 -HomeIcon
|
||||
# .\tools\build-payload.ps1 -GoDir probe-go -Name probe [-DebugLoader] [-Watchdog 120] [-Send]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$GoDir,
|
||||
@@ -7,6 +7,7 @@ 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
|
||||
@@ -28,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
|
||||
@@ -54,7 +56,7 @@ if ($HomeIcon) {
|
||||
$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\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))
|
||||
|
||||
@@ -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
@@ -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())
|
||||
}
|
||||
@@ -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
@@ -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" }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The diagnostics file: everything someone helping with a problem needs, in
|
||||
// one download, so that a bug report does not depend on getting files off
|
||||
// the console by hand.
|
||||
//
|
||||
// It goes on the internet when it is attached to a report, so what can be
|
||||
// left out without making it useless is left out or blanked: the password
|
||||
// hash, auth keys, login links, e-mail addresses, the tailnet's name and
|
||||
// public IP addresses. Device names and tailnet addresses stay; without them
|
||||
// the logs cannot be followed.
|
||||
|
||||
const (
|
||||
diagLauncherTail = 64 << 10
|
||||
diagMainTail = 256 << 10
|
||||
diagDebugTail = 512 << 10
|
||||
)
|
||||
|
||||
var (
|
||||
reEmail = regexp.MustCompile(`[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}`)
|
||||
reTailnet = regexp.MustCompile(`\b([A-Za-z0-9-]+)\.[A-Za-z0-9-]+\.ts\.net\b`)
|
||||
// The tailnet's name on its own, as it appears in DNS settings.
|
||||
reTailnetBare = regexp.MustCompile(`\b[A-Za-z0-9-]+\.ts\.net\b`)
|
||||
reLoginURL = regexp.MustCompile(`https://login\.tailscale\.com/a/[A-Za-z0-9]+`)
|
||||
reAuthKey = regexp.MustCompile(`tskey-[A-Za-z0-9-]+`)
|
||||
reIPv4 = regexp.MustCompile(`\b(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\b`)
|
||||
reFirmware = regexp.MustCompile(`firmware (\d+\.\d+)`)
|
||||
)
|
||||
|
||||
// scrub blanks what should not be published. It keeps the shape of the
|
||||
// text, so the logs still read as logs.
|
||||
func scrub(b []byte) []byte {
|
||||
b = reLoginURL.ReplaceAll(b, []byte("https://login.tailscale.com/a/<removed>"))
|
||||
b = reAuthKey.ReplaceAll(b, []byte("tskey-<removed>"))
|
||||
b = reEmail.ReplaceAll(b, []byte("<email>"))
|
||||
b = reTailnet.ReplaceAll(b, []byte("$1.<tailnet>.ts.net"))
|
||||
b = reTailnetBare.ReplaceAll(b, []byte("<tailnet>.ts.net"))
|
||||
return reIPv4.ReplaceAllFunc(b, func(ip []byte) []byte {
|
||||
if publicIPv4(string(ip)) {
|
||||
return []byte("<public-ip>")
|
||||
}
|
||||
return ip
|
||||
})
|
||||
}
|
||||
|
||||
// publicIPv4 reports whether s is an address on the internet, as opposed to
|
||||
// a private, tailnet, loopback or otherwise special one. Text that only
|
||||
// looks like an address (a version number, say) is left alone.
|
||||
func publicIPv4(s string) bool {
|
||||
var a, b, c, d int
|
||||
if n, _ := fmt.Sscanf(s, "%d.%d.%d.%d", &a, &b, &c, &d); n != 4 || a > 255 || b > 255 || c > 255 || d > 255 {
|
||||
return false
|
||||
}
|
||||
switch {
|
||||
case a == 0, a == 10, a == 127, a >= 224:
|
||||
return false
|
||||
case a == 100 && b >= 64 && b <= 127: // tailnet addresses
|
||||
return false
|
||||
case a == 169 && b == 254:
|
||||
return false
|
||||
case a == 172 && b >= 16 && b <= 31:
|
||||
return false
|
||||
case a == 192 && b == 168:
|
||||
return false
|
||||
case a == 192 && b == 0 && c == 2:
|
||||
return false
|
||||
}
|
||||
// A well-known public resolver says nothing about the user.
|
||||
switch s {
|
||||
case "1.1.1.1", "8.8.8.8", "9.9.9.9":
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func fileTail(path string, max int64) []byte {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return []byte("(" + err.Error() + ")\n")
|
||||
}
|
||||
defer f.Close()
|
||||
if fi, err := f.Stat(); err == nil && fi.Size() > max {
|
||||
f.Seek(fi.Size()-max, io.SeekStart)
|
||||
}
|
||||
b, _ := io.ReadAll(onlyReader{f})
|
||||
return b
|
||||
}
|
||||
|
||||
// diagnostics assembles the file.
|
||||
func (d *daemon) diagnostics(r *http.Request) []byte {
|
||||
var out bytes.Buffer
|
||||
section := func(title string) { fmt.Fprintf(&out, "\n===== %s =====\n", title) }
|
||||
|
||||
launcher := fileTail(filepath.Join(dataDir, "launcher.log"), diagLauncherTail)
|
||||
firmware := "unknown"
|
||||
if m := reFirmware.FindAllSubmatch(launcher, -1); len(m) > 0 {
|
||||
firmware = string(m[len(m)-1][1])
|
||||
}
|
||||
|
||||
d.mu.Lock()
|
||||
cfg := d.cfg
|
||||
state, lastErr := d.state, d.lastErr
|
||||
latest := d.latest.Version
|
||||
d.mu.Unlock()
|
||||
|
||||
fmt.Fprintf(&out, "ps5-tailscale diagnostics\n")
|
||||
fmt.Fprintf(&out, "Please read this file before posting it. E-mail addresses, the tailnet's name, public IP\n")
|
||||
fmt.Fprintf(&out, "addresses, keys and the password have been removed; device names and tailnet addresses have not.\n\n")
|
||||
fmt.Fprintf(&out, "version: %s (%s/%s)\n", version, runtime.GOOS, runtime.GOARCH)
|
||||
fmt.Fprintf(&out, "firmware: %s\n", firmware)
|
||||
fmt.Fprintf(&out, "created: %s\n", time.Now().UTC().Format("2006-01-02 15:04:05 UTC"))
|
||||
fmt.Fprintf(&out, "running for: %s\n", time.Since(d.started).Round(time.Second))
|
||||
fmt.Fprintf(&out, "state: %s\n", state)
|
||||
if lastErr != "" {
|
||||
fmt.Fprintf(&out, "last error: %s\n", lastErr)
|
||||
}
|
||||
if latest != "" {
|
||||
fmt.Fprintf(&out, "latest known: %s\n", latest)
|
||||
}
|
||||
if d.dns != nil {
|
||||
fmt.Fprintf(&out, "name lookups: %s\n", d.dns.describe())
|
||||
}
|
||||
if d.udp != nil {
|
||||
fmt.Fprintf(&out, "UDP ports: %v\n", d.udp.activePorts())
|
||||
}
|
||||
if d.fwd != nil {
|
||||
fmt.Fprintf(&out, "forwards: %d active\n", len(d.fwd.rules()))
|
||||
}
|
||||
|
||||
if d.lc != nil {
|
||||
if st, err := d.status(r.Context()); err == nil {
|
||||
section("tailscale")
|
||||
fmt.Fprintf(&out, "backend state: %s\n", st.BackendState)
|
||||
for _, h := range st.Health {
|
||||
fmt.Fprintf(&out, "health: %s\n", h)
|
||||
}
|
||||
if st.Self != nil {
|
||||
fmt.Fprintf(&out, "self: %s, addresses %v, relay %q, key expiry %v\n", st.Self.DNSName, st.Self.TailscaleIPs, st.Self.Relay, st.Self.KeyExpiry)
|
||||
}
|
||||
peers, vpn := peersFromStatus(st, false)
|
||||
fmt.Fprintf(&out, "peers: %d, VPN exit servers: %d\n", len(peers), vpn.Total)
|
||||
for _, p := range peers {
|
||||
fmt.Fprintf(&out, " %-24s %-16s %-8s online=%-5v %s %s\n", p.Name, p.IP, p.OS, p.Online, p.Kind, strings.TrimSpace(p.Conn+" "+p.Via))
|
||||
}
|
||||
} else {
|
||||
section("tailscale")
|
||||
fmt.Fprintf(&out, "status: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
section("settings (password and auth key removed)")
|
||||
if cfg.PasswordHash != "" {
|
||||
cfg.PasswordHash = "(set)"
|
||||
}
|
||||
if cfg.AuthKey != "" {
|
||||
cfg.AuthKey = "(set)"
|
||||
}
|
||||
// cfg is a copy, but its slices are the daemon's own: copy before
|
||||
// blanking.
|
||||
cfg.Wake = slices.Clone(cfg.Wake)
|
||||
for i := range cfg.Wake {
|
||||
cfg.Wake[i].MAC = "(set)"
|
||||
}
|
||||
if b, err := json.MarshalIndent(cfg, "", " "); err == nil {
|
||||
out.Write(b)
|
||||
out.WriteByte('\n')
|
||||
}
|
||||
|
||||
section("launcher.log")
|
||||
out.Write(launcher)
|
||||
section("tailscale.log (end)")
|
||||
out.Write(fileTail(filepath.Join(dataDir, "tailscale.log"), diagMainTail))
|
||||
section("tailscale-debug.log (end)")
|
||||
out.Write(fileTail(filepath.Join(dataDir, "tailscale-debug.log"), diagDebugTail))
|
||||
return scrub(out.Bytes())
|
||||
}
|
||||
|
||||
func (d *daemon) handleDiagnostics(w http.ResponseWriter, r *http.Request) {
|
||||
name := fmt.Sprintf("ps5-tailscale-diagnostics-%s-%s.txt", version, time.Now().UTC().Format("20060102-150405"))
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Write(d.diagnostics(r))
|
||||
}
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"net"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"tailscale.com/net/netmon"
|
||||
)
|
||||
|
||||
// Name lookups by the daemon itself: Tailscale's servers, GitHub for the
|
||||
// update check, whatever goes through the HTTP proxy.
|
||||
//
|
||||
// The PS5 has no resolv.conf, so Go asks 127.0.0.1:53. That only works on a
|
||||
// console that runs a DNS payload, which most jailbreak setups do and some do
|
||||
// not. Rather than depend on it, the daemon uses the first of these that
|
||||
// answers: the local DNS payload, the router, a public resolver.
|
||||
|
||||
// dnsLocal is where a DNS payload on the console listens. A variable so that
|
||||
// a test build can pretend there is none.
|
||||
var dnsLocal = "127.0.0.1:53"
|
||||
|
||||
// dnsPublic are used when neither the console nor the router answers.
|
||||
var dnsPublic = []string{"1.1.1.1:53", "8.8.8.8:53", "9.9.9.9:53"}
|
||||
|
||||
const (
|
||||
dnsRecheckGood = 5 * time.Minute // how long a working server is kept
|
||||
dnsRecheckBad = 20 * time.Second // how soon to look again when none works
|
||||
dnsProbeWait = 1200 * time.Millisecond
|
||||
)
|
||||
|
||||
type dnsPicker struct {
|
||||
logf func(format string, args ...any)
|
||||
// candidates lists the servers to try, in order of preference.
|
||||
candidates func() []string
|
||||
// probe reports whether a server answers queries.
|
||||
probe func(ctx context.Context, server string) bool
|
||||
|
||||
mu sync.Mutex
|
||||
server string
|
||||
working bool
|
||||
checked time.Time
|
||||
}
|
||||
|
||||
func newDNSPicker(logf func(format string, args ...any)) *dnsPicker {
|
||||
return &dnsPicker{logf: logf, candidates: dnsCandidates, probe: dnsAnswers}
|
||||
}
|
||||
|
||||
// dnsCandidates returns the local DNS payload, the router and the public
|
||||
// resolvers, in that order.
|
||||
func dnsCandidates() []string {
|
||||
list := []string{dnsLocal}
|
||||
if gw, _, ok := netmon.LikelyHomeRouterIP(); ok && gw.IsValid() {
|
||||
list = append(list, net.JoinHostPort(gw.String(), "53"))
|
||||
}
|
||||
return append(list, dnsPublic...)
|
||||
}
|
||||
|
||||
// pick returns the server to ask. The choice is kept for a while, so the
|
||||
// candidates are not probed for every lookup.
|
||||
func (p *dnsPicker) pick(ctx context.Context) string {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
keep := dnsRecheckBad
|
||||
if p.working {
|
||||
keep = dnsRecheckGood
|
||||
}
|
||||
if p.server != "" && time.Since(p.checked) < keep {
|
||||
return p.server
|
||||
}
|
||||
candidates := p.candidates()
|
||||
chosen, working := candidates[0], false
|
||||
for _, c := range candidates {
|
||||
if p.probe(ctx, c) {
|
||||
chosen, working = c, true
|
||||
break
|
||||
}
|
||||
}
|
||||
if chosen != p.server || working != p.working {
|
||||
switch {
|
||||
case !working:
|
||||
p.logf("DNS: no server answers (tried %v); name lookups will fail until one does", candidates)
|
||||
case chosen == candidates[0]:
|
||||
p.logf("DNS: using the console's own DNS at %s", chosen)
|
||||
default:
|
||||
p.logf("DNS: nothing answers at %s; using %s instead (in order of preference: %v)", candidates[0], chosen, candidates)
|
||||
}
|
||||
}
|
||||
p.server, p.working, p.checked = chosen, working, time.Now()
|
||||
return chosen
|
||||
}
|
||||
|
||||
// reset makes the next lookup choose again, for when the network changed.
|
||||
func (p *dnsPicker) reset() {
|
||||
p.mu.Lock()
|
||||
p.checked = time.Time{}
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
// dial is the resolver's Dial: whatever address Go wants to ask, the chosen
|
||||
// server is asked instead.
|
||||
func (p *dnsPicker) dial(ctx context.Context, network, _ string) (net.Conn, error) {
|
||||
var d net.Dialer
|
||||
return d.DialContext(ctx, network, p.pick(ctx))
|
||||
}
|
||||
|
||||
// install makes every name lookup in the process go through the picker.
|
||||
func (p *dnsPicker) install() {
|
||||
net.DefaultResolver = &net.Resolver{PreferGo: true, Dial: p.dial}
|
||||
}
|
||||
|
||||
// dnsAnswers asks server for the address of a name that certainly exists
|
||||
// and reports whether a proper answer came back.
|
||||
func dnsAnswers(ctx context.Context, server string) bool {
|
||||
ctx, cancel := context.WithTimeout(ctx, dnsProbeWait)
|
||||
defer cancel()
|
||||
var d net.Dialer
|
||||
c, err := d.DialContext(ctx, "udp", server)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer c.Close()
|
||||
c.SetDeadline(time.Now().Add(dnsProbeWait))
|
||||
query := dnsQuery(uint16(time.Now().UnixNano()), "github.com")
|
||||
if _, err := c.Write(query); err != nil {
|
||||
return false
|
||||
}
|
||||
buf := make([]byte, 1500)
|
||||
n, err := c.Read(buf)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return dnsReplyOK(query, buf[:n])
|
||||
}
|
||||
|
||||
// dnsQuery builds a query for the IPv4 address of name.
|
||||
func dnsQuery(id uint16, name string) []byte {
|
||||
q := make([]byte, 12, 64)
|
||||
binary.BigEndian.PutUint16(q[0:], id)
|
||||
q[2] = 0x01 // recursion desired
|
||||
binary.BigEndian.PutUint16(q[4:], 1)
|
||||
start := 0
|
||||
for i := 0; i <= len(name); i++ {
|
||||
if i == len(name) || name[i] == '.' {
|
||||
q = append(q, byte(i-start))
|
||||
q = append(q, name[start:i]...)
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
return append(q, 0, 0, 1, 0, 1) // root label, type A, class IN
|
||||
}
|
||||
|
||||
// dnsReplyOK reports whether reply is a successful answer to query.
|
||||
func dnsReplyOK(query, reply []byte) bool {
|
||||
if len(reply) < 12 || reply[0] != query[0] || reply[1] != query[1] {
|
||||
return false
|
||||
}
|
||||
isResponse := reply[2]&0x80 != 0
|
||||
rcode := reply[3] & 0x0f
|
||||
answers := binary.BigEndian.Uint16(reply[6:])
|
||||
return isResponse && rcode == 0 && answers > 0
|
||||
}
|
||||
|
||||
// describe says in words which server is in use, for the status page. It
|
||||
// does not probe.
|
||||
func (p *dnsPicker) describe() string {
|
||||
p.mu.Lock()
|
||||
server, working := p.server, p.working
|
||||
p.mu.Unlock()
|
||||
if server == "" {
|
||||
return ""
|
||||
}
|
||||
host, _, err := net.SplitHostPort(server)
|
||||
if err != nil {
|
||||
host = server
|
||||
}
|
||||
if !working {
|
||||
return "no server answers"
|
||||
}
|
||||
switch {
|
||||
case server == dnsLocal:
|
||||
return host + " (DNS payload on this console)"
|
||||
case slices.Contains(dnsPublic, server):
|
||||
return host + " (public resolver)"
|
||||
default:
|
||||
return host + " (router)"
|
||||
}
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func testPicker(t *testing.T, candidates []string, answering map[string]bool) (*dnsPicker, *[]string, *[]string) {
|
||||
var logs, probed []string
|
||||
p := &dnsPicker{
|
||||
logf: func(format string, args ...any) { logs = append(logs, format) },
|
||||
candidates: func() []string { return candidates },
|
||||
probe: func(ctx context.Context, server string) bool {
|
||||
probed = append(probed, server)
|
||||
return answering[server]
|
||||
},
|
||||
}
|
||||
return p, &logs, &probed
|
||||
}
|
||||
|
||||
func TestDNSPicker(t *testing.T) {
|
||||
candidates := []string{"127.0.0.1:53", "192.168.1.1:53", "1.1.1.1:53"}
|
||||
answering := map[string]bool{"127.0.0.1:53": true, "192.168.1.1:53": true, "1.1.1.1:53": true}
|
||||
p, logs, probed := testPicker(t, candidates, answering)
|
||||
ctx := context.Background()
|
||||
|
||||
// The console's own DNS is preferred, and the choice is kept.
|
||||
if got := p.pick(ctx); got != "127.0.0.1:53" {
|
||||
t.Fatalf("picked %s", got)
|
||||
}
|
||||
p.pick(ctx)
|
||||
if len(*probed) != 1 {
|
||||
t.Errorf("probed %v; the choice should have been kept", *probed)
|
||||
}
|
||||
|
||||
// No DNS payload: the router is used, and that is logged.
|
||||
answering["127.0.0.1:53"] = false
|
||||
p.reset()
|
||||
if got := p.pick(ctx); got != "192.168.1.1:53" {
|
||||
t.Errorf("without a local DNS: picked %s", got)
|
||||
}
|
||||
if last := (*logs)[len(*logs)-1]; !strings.Contains(last, "instead") {
|
||||
t.Errorf("log: %q", last)
|
||||
}
|
||||
|
||||
// Nor the router: a public resolver.
|
||||
answering["192.168.1.1:53"] = false
|
||||
p.reset()
|
||||
if got := p.pick(ctx); got != "1.1.1.1:53" {
|
||||
t.Errorf("without local DNS or router: picked %s", got)
|
||||
}
|
||||
|
||||
// Nothing at all: stay with the first, and look again soon.
|
||||
answering["1.1.1.1:53"] = false
|
||||
p.reset()
|
||||
if got := p.pick(ctx); got != "127.0.0.1:53" {
|
||||
t.Errorf("with nothing answering: picked %s", got)
|
||||
}
|
||||
if p.working {
|
||||
t.Error("the picker thinks it has a working server")
|
||||
}
|
||||
before := len(*probed)
|
||||
p.checked = time.Now().Add(-dnsRecheckBad - time.Second)
|
||||
answering["127.0.0.1:53"] = true
|
||||
if got := p.pick(ctx); got != "127.0.0.1:53" || len(*probed) == before || !p.working {
|
||||
t.Errorf("after the short wait: picked %s, working %v", got, p.working)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSQueryAndReply(t *testing.T) {
|
||||
q := dnsQuery(0x1234, "github.com")
|
||||
want := []byte{0x12, 0x34, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 6, 'g', 'i', 't', 'h', 'u', 'b', 3, 'c', 'o', 'm', 0, 0, 1, 0, 1}
|
||||
if string(q) != string(want) {
|
||||
t.Fatalf("query = % x", q)
|
||||
}
|
||||
reply := func(id0, id1, flags2, flags3 byte, answers byte) []byte {
|
||||
return []byte{id0, id1, flags2, flags3, 0, 1, 0, answers, 0, 0, 0, 0}
|
||||
}
|
||||
for name, tt := range map[string]struct {
|
||||
r []byte
|
||||
want bool
|
||||
}{
|
||||
"good answer": {reply(0x12, 0x34, 0x81, 0x80, 2), true},
|
||||
"another query's id": {reply(0x12, 0x35, 0x81, 0x80, 2), false},
|
||||
"server failure": {reply(0x12, 0x34, 0x81, 0x82, 0), false},
|
||||
"no such name": {reply(0x12, 0x34, 0x81, 0x83, 0), false},
|
||||
"no answers": {reply(0x12, 0x34, 0x81, 0x80, 0), false},
|
||||
"not a response": {reply(0x12, 0x34, 0x01, 0x00, 1), false},
|
||||
"too short": {[]byte{0x12, 0x34}, false},
|
||||
} {
|
||||
if got := dnsReplyOK(q, tt.r); got != tt.want {
|
||||
t.Errorf("%s: got %v", name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A real exchange over UDP with a stand-in server, and a port where nothing
|
||||
// answers.
|
||||
func TestDNSAnswers(t *testing.T) {
|
||||
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pc.Close()
|
||||
go func() {
|
||||
buf := make([]byte, 1500)
|
||||
for {
|
||||
n, from, err := pc.ReadFrom(buf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
r := append([]byte(nil), buf[:n]...)
|
||||
r[2], r[3], r[7] = 0x81, 0x80, 1 // response, no error, one answer
|
||||
pc.WriteTo(r, from)
|
||||
}
|
||||
}()
|
||||
if !dnsAnswers(context.Background(), pc.LocalAddr().String()) {
|
||||
t.Error("a server that answers was reported as silent")
|
||||
}
|
||||
|
||||
silent, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer silent.Close()
|
||||
start := time.Now()
|
||||
if dnsAnswers(context.Background(), silent.LocalAddr().String()) {
|
||||
t.Error("a silent server was reported as answering")
|
||||
}
|
||||
if time.Since(start) > 3*time.Second {
|
||||
t.Errorf("the probe took %v", time.Since(start))
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 9.3 KiB |
+27
-6
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
+70
-15
@@ -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,10 +129,18 @@ 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
|
||||
@@ -154,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)
|
||||
@@ -165,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())
|
||||
@@ -180,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)
|
||||
@@ -195,6 +220,7 @@ func (d *daemon) run() error {
|
||||
}
|
||||
cancel()
|
||||
webLn.Close()
|
||||
d.tailnetWeb.Close()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
@@ -226,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
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+831
-79
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
+155
@@ -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
@@ -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")
|
||||
}
|
||||
+316
-83
@@ -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,28 +389,34 @@ func (d *daemon) stop() {
|
||||
d.quitOnce.Do(func() { close(d.quit) })
|
||||
}
|
||||
|
||||
// handleUninstall 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, and the home screen
|
||||
// icon can only be deleted from the home screen.
|
||||
// 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) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
d.logf("uninstall requested from the status page")
|
||||
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 {
|
||||
@@ -357,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
|
||||
}
|
||||
Reference in new issue
Block a user