mirror of
https://github.com/holdmysocks/ps5-tailscale.git
synced 2026-10-06 09:00:19 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
726b9b99c4 | ||
|
|
558994fc76 | ||
|
|
ed67b35b52 | ||
|
|
0d7d8ad4b0 | ||
|
|
01b6381444 |
No files matched your search
@@ -5,6 +5,9 @@ Puts a jailbroken PS5 on your [Tailscale](https://tailscale.com) network.
|
||||
- **Reach the console from anywhere.** FTP, the payload loader, web tools:
|
||||
whatever listens on the console is available at its tailnet address from
|
||||
your other Tailscale devices.
|
||||
- **Remote Play over Tailscale.** Play the PS5 from anywhere with a Remote
|
||||
Play client, at the console's tailnet address, with no port forwarding on
|
||||
your router.
|
||||
- **Stream games to the console over Tailscale.** A Moonlight client on the
|
||||
PS5 (such as ProsperoLight) can connect to a Sunshine host on your tailnet.
|
||||
- **Home screen icon** that opens its status page.
|
||||
@@ -28,7 +31,14 @@ VPN here. It runs inside one process:
|
||||
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.
|
||||
- No subnet routing, Tailscale SSH, Taildrop or Funnel.
|
||||
- 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
|
||||
|
||||
@@ -40,38 +50,36 @@ Tested on firmware 13.42 with elfldr 0.26.
|
||||
|
||||
## Install
|
||||
|
||||
1. Download `tailscale-installer.elf` from the
|
||||
[latest release](../../releases/latest).
|
||||
2. Send it to the console's ELF loader, once. Any payload sender works:
|
||||
There is one file, `tailscale.elf`, and it is an ordinary payload: running
|
||||
it starts Tailscale.
|
||||
|
||||
1. Download `tailscale.elf` from the [latest release](../../releases/latest).
|
||||
2. Send it to the console's ELF loader. Any payload sender works:
|
||||
|
||||
```bash
|
||||
# Linux / macOS
|
||||
socat -t 60 - TCP:<console-ip>:9021 < tailscale-installer.elf
|
||||
socat -t 60 - TCP:<console-ip>:9021 < tailscale.elf
|
||||
```
|
||||
|
||||
```powershell
|
||||
# Windows (script from this repository)
|
||||
.\tools\ps5send.ps1 -File tailscale-installer.elf -PS5Host <console-ip> -Seconds 70
|
||||
.\tools\ps5send.ps1 -File tailscale.elf -PS5Host <console-ip>
|
||||
```
|
||||
|
||||
The installer prints what it does. It:
|
||||
- stores the daemon payload as `/data/tailscale/tailscale.elf`,
|
||||
- adds a **Tailscale** icon to the home screen (media section),
|
||||
- starts Tailscale.
|
||||
|
||||
3. Open `http://<console-ip>:8090` on a phone or PC. Scan the QR code or
|
||||
follow the link and log in to Tailscale. If your tailnet uses device
|
||||
approval, approve the console in the admin console.
|
||||
|
||||
The console now has a tailnet address, shown on the status page.
|
||||
|
||||
Sending the installer again upgrades and restarts Tailscale. The login is
|
||||
kept.
|
||||
The console now has a tailnet address, shown on the status page. The first
|
||||
run also adds a **Tailscale** icon to the home screen (media section) that
|
||||
opens the status page.
|
||||
|
||||
Tailscale runs until the console restarts. After a restart and jailbreak,
|
||||
send `/data/tailscale/tailscale.elf` (or the installer) to the ELF loader
|
||||
again. The installer does not change any payload autoloader; if you use one,
|
||||
you can add that file to it yourself.
|
||||
send `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.
|
||||
|
||||
To update, use the new `tailscale.elf` in place of the old one. Sending it
|
||||
while Tailscale is running replaces the running copy.
|
||||
|
||||
## Using it
|
||||
|
||||
@@ -82,19 +90,65 @@ 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 is not forwarded in this direction.
|
||||
- To keep a port off the tailnet, add it to `blockedPorts` in the
|
||||
[configuration](#configuration).
|
||||
- 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
|
||||
|
||||
The console's own Remote Play service is reachable at its tailnet address, so
|
||||
a Remote Play client on any of your Tailscale devices can connect from
|
||||
anywhere. Any client that lets you enter the console's address works.
|
||||
|
||||
1. On the console, enable Remote Play (Settings > System > Remote Play).
|
||||
2. Register your Remote Play client with the console as usual. This is
|
||||
easiest at home on the same network; see the client's documentation.
|
||||
3. In the client, add the console manually with its **tailnet address**
|
||||
(shown on the status page).
|
||||
4. Connect.
|
||||
|
||||
Tested and working with Chiaki, and with Asobi on iOS and Android.
|
||||
|
||||
How it works: Remote Play uses TCP 9295 and UDP 9295, 9296, 9297 and 9302.
|
||||
The TCP port is forwarded like any other; the daemon listens on the UDP
|
||||
ports on the console's tailnet addresses and relays them to the service.
|
||||
|
||||
Notes:
|
||||
|
||||
- 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.
|
||||
|
||||
### 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 also says when a
|
||||
newer release is available.
|
||||
|
||||
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.
|
||||
**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**.
|
||||
|
||||
**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, which UDP ports are
|
||||
reachable and which TCP ports are not, extra forwards, the HTTP proxy, 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)
|
||||
|
||||
@@ -103,29 +157,35 @@ 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;
|
||||
@@ -134,32 +194,43 @@ Notes:
|
||||
|
||||
### 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.
|
||||
"Extra forwards" in the settings relay any localhost port to a tailnet host
|
||||
in the same way, TCP or UDP. One per line, for example
|
||||
`tcp 127.0.0.1:8096 my-nas:8096`.
|
||||
|
||||
The 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": [],
|
||||
"priority": "",
|
||||
"checkUpdates": true,
|
||||
"verbose": false
|
||||
}
|
||||
```
|
||||
@@ -167,13 +238,17 @@ 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. |
|
||||
| `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` | Extra local forwards: `proto` is `tcp` or `udp`, `listen` a localhost address, `target` a tailnet host 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. |
|
||||
| `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. Nothing is downloaded. |
|
||||
| `verbose` | Put Tailscale's own log in the main log as well. |
|
||||
|
||||
Files on the console:
|
||||
@@ -184,56 +259,79 @@ Files on the console:
|
||||
| `/data/tailscale/state/` | Tailscale's state, including the login. |
|
||||
| `/data/tailscale/tailscale.log` | The daemon's log, rotated at 2 MB. |
|
||||
| `/data/tailscale/tailscale-debug.log` | Tailscale's detailed log, up to 4 MB plus one older file. |
|
||||
| `/data/tailscale/tailscale.elf` | The daemon payload. |
|
||||
| `/data/tailscale/icon-installed` | Marks that the home screen icon was added. Delete it to have the icon added again on the next start. |
|
||||
| `/data/tailscale/icon-helper.elf` | The small payload that adds and removes the icon. |
|
||||
| `/user/app/TSCL00001/` | The home screen icon. |
|
||||
|
||||
## Uninstall
|
||||
|
||||
Press **Uninstall** on the status page. It deletes the daemon payload and
|
||||
stops Tailscale. It asks whether to also log out and delete the saved login.
|
||||
Press **Uninstall** on the status page. It removes the home screen icon, logs
|
||||
the console out of your tailnet, deletes `/data/tailscale` (login, settings,
|
||||
logs) and stops Tailscale.
|
||||
|
||||
Two things are left to do by hand:
|
||||
Left to do by hand:
|
||||
|
||||
- Delete the home screen icon (Options button, then Delete).
|
||||
- Remove the device in the Tailscale admin console.
|
||||
- If you added the payload to an autoloader yourself, remove it there.
|
||||
- If you added `tailscale.elf` to a payload manager or autoloader, remove it
|
||||
there, or it starts again on the next boot.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **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.
|
||||
- **"Not logged in" after logging in.** Press **Log in again** for a fresh
|
||||
link.
|
||||
- **Forgot the status page password.** Delete the `passwordHash` line from
|
||||
`/data/tailscale/config.json` and start Tailscale again, or use the page on
|
||||
the console itself, where no password is asked.
|
||||
- **Something else.** `http://<console>:8090/api/logs?full=1` is the daemon's
|
||||
log and `/api/logs?debug=1` is Tailscale's detailed log. Please attach them
|
||||
to bug reports, after checking them for anything you consider private.
|
||||
|
||||
## Security
|
||||
|
||||
- The status page and its controls are unauthenticated.
|
||||
- All listening TCP ports on the console become reachable from your tailnet,
|
||||
including 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.
|
||||
- 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, 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.
|
||||
|
||||
## 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, a short stay in rest mode (about a minute: the same process
|
||||
carried on and was back on the tailnet within a second of waking).
|
||||
|
||||
Not tested: rest mode, Uninstall on a console, other firmware versions,
|
||||
coordination servers other than Tailscale's.
|
||||
Remote Play through the tailnet address works with Chiaki and with Asobi on
|
||||
iOS and Android.
|
||||
|
||||
Not tested: hours in rest mode, switching between Wi-Fi and Ethernet while
|
||||
running, the complete Uninstall
|
||||
on a console (its parts were tested separately), whether High priority
|
||||
improves Remote Play, a real Sunshine host on a non-default port, other
|
||||
firmware versions, coordination servers other than Tailscale's.
|
||||
|
||||
## Building
|
||||
|
||||
|
||||
File renamed without changes.
+190
@@ -0,0 +1,190 @@
|
||||
/* Home screen icon helper for Tailscale on PS5.
|
||||
*
|
||||
* A tiny payload that puts a "Tailscale" icon on the home screen: a media app
|
||||
* whose only content is a link to the status page, which the console opens in
|
||||
* its browser. The daemon's launcher carries this payload and hands it to the
|
||||
* ELF loader the first time Tailscale runs. It is a separate payload so that
|
||||
* the system libraries it needs are never loaded into the daemon's process.
|
||||
*
|
||||
* The same payload removes the icon again when its mode byte says so (see
|
||||
* icon_mode below); the daemon uses that for Uninstall.
|
||||
*
|
||||
* Prints "icon: ok" or "icon: removed" on success; the launcher and the
|
||||
* daemon look for that.
|
||||
*
|
||||
* Build with -DASSET_DIR="path/to/appicon" and link, in this order,
|
||||
* -lSceIpmi -lSceAppInstUtil -lSceUserService -lSceSystemService (with
|
||||
* libSceAppInstUtil alone the payload is never started). */
|
||||
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <sys/stat.h>
|
||||
|
||||
#include <ps5/kernel.h>
|
||||
|
||||
#ifndef ASSET_DIR
|
||||
#error "ASSET_DIR must name the folder with param.json and icon0.png"
|
||||
#endif
|
||||
|
||||
#define TITLE_ID "TSCL00001"
|
||||
#define APP_DIR "/user/app/" TITLE_ID
|
||||
|
||||
#define INCASSET(name, file) \
|
||||
__asm__(".section .rodata\n" \
|
||||
".balign 16\n" \
|
||||
".global " #name "\n" #name ":\n" \
|
||||
".incbin \"" file "\"\n" \
|
||||
".global " #name "_end\n" #name "_end:\n" \
|
||||
".text\n"); \
|
||||
extern const uint8_t name[]; \
|
||||
extern const uint8_t name##_end[];
|
||||
|
||||
INCASSET(param_json, ASSET_DIR "/param.json")
|
||||
INCASSET(icon_png, ASSET_DIR "/icon0.png")
|
||||
|
||||
int sceAppInstUtilInitialize(void);
|
||||
int sceAppInstUtilTerminate(void);
|
||||
int sceAppInstUtilAppInstallAll(void *);
|
||||
int sceAppInstUtilAppUnInstall(const char *);
|
||||
|
||||
/* What to do. A payload sent to the ELF loader gets no arguments, so the
|
||||
* mode is a byte in the file itself: the daemon changes the character after
|
||||
* the '=' to 'R' before sending the helper when it wants the icon removed
|
||||
* (see tsd/homeicon.go). It is volatile so that the compiler reads it at run
|
||||
* time instead of baking the install branch in. */
|
||||
volatile char icon_mode[] = "TSICON-MODE=I";
|
||||
|
||||
static int
|
||||
remove_icon(void) {
|
||||
int err;
|
||||
|
||||
if ((err = sceAppInstUtilInitialize())) {
|
||||
printf("icon: sceAppInstUtilInitialize failed: 0x%08x\n", err);
|
||||
return 1;
|
||||
}
|
||||
err = sceAppInstUtilAppUnInstall(TITLE_ID);
|
||||
sceAppInstUtilTerminate();
|
||||
/* Whatever the system left behind of the folder goes too. */
|
||||
unlink(APP_DIR "/sce_sys/param.json");
|
||||
unlink(APP_DIR "/sce_sys/icon0.png");
|
||||
rmdir(APP_DIR "/sce_sys");
|
||||
rmdir(APP_DIR);
|
||||
if (err) {
|
||||
printf("icon: removing the app failed: 0x%08x\n", err);
|
||||
return 1;
|
||||
}
|
||||
printf("icon: removed\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int
|
||||
write_file(const char *path, const uint8_t *data, size_t size) {
|
||||
int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0644);
|
||||
|
||||
if (fd < 0) {
|
||||
return -1;
|
||||
}
|
||||
while (size > 0) {
|
||||
ssize_t n = write(fd, data, size);
|
||||
if (n < 0) {
|
||||
if (errno == EINTR) {
|
||||
continue;
|
||||
}
|
||||
close(fd);
|
||||
return -1;
|
||||
}
|
||||
data += n;
|
||||
size -= n;
|
||||
}
|
||||
return close(fd);
|
||||
}
|
||||
|
||||
/* Report whether the file at path already has exactly these contents. */
|
||||
static int
|
||||
file_matches(const char *path, const uint8_t *data, size_t size) {
|
||||
struct stat st;
|
||||
uint8_t *buf;
|
||||
int same = 0;
|
||||
FILE *f;
|
||||
|
||||
if (stat(path, &st) || (size_t)st.st_size != size || !(f = fopen(path, "rb"))) {
|
||||
return 0;
|
||||
}
|
||||
if ((buf = malloc(size))) {
|
||||
same = fread(buf, 1, size, f) == size && !memcmp(buf, data, size);
|
||||
free(buf);
|
||||
}
|
||||
fclose(f);
|
||||
return same;
|
||||
}
|
||||
|
||||
int
|
||||
main(void) {
|
||||
int (*install_title_dir)(const char *, const char *, void *) = 0;
|
||||
size_t param_size = param_json_end - param_json;
|
||||
size_t icon_size = icon_png_end - icon_png;
|
||||
pid_t pid = getpid();
|
||||
intptr_t rootvnode;
|
||||
uint32_t handle;
|
||||
int err;
|
||||
|
||||
setvbuf(stdout, 0, _IONBF, 0);
|
||||
|
||||
/* /user/app is only writable as root outside the sandbox. */
|
||||
if ((rootvnode = kernel_get_root_vnode())) {
|
||||
kernel_set_proc_rootdir(pid, rootvnode);
|
||||
kernel_set_proc_jaildir(pid, 0);
|
||||
}
|
||||
kernel_set_ucred_uid(pid, 0);
|
||||
kernel_set_ucred_ruid(pid, 0);
|
||||
kernel_set_ucred_svuid(pid, 0);
|
||||
kernel_set_ucred_rgid(pid, 0);
|
||||
kernel_set_ucred_svgid(pid, 0);
|
||||
|
||||
if (icon_mode[sizeof(icon_mode) - 2] == 'R') {
|
||||
return remove_icon();
|
||||
}
|
||||
|
||||
if (file_matches(APP_DIR "/sce_sys/param.json", param_json, param_size) &&
|
||||
file_matches(APP_DIR "/sce_sys/icon0.png", icon_png, icon_size)) {
|
||||
printf("icon: ok (already installed)\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ((err = sceAppInstUtilInitialize())) {
|
||||
printf("icon: sceAppInstUtilInitialize failed: 0x%08x\n", err);
|
||||
return 1;
|
||||
}
|
||||
mkdir(APP_DIR, 0755);
|
||||
mkdir(APP_DIR "/sce_sys", 0755);
|
||||
if (write_file(APP_DIR "/sce_sys/param.json", param_json, param_size) ||
|
||||
write_file(APP_DIR "/sce_sys/icon0.png", icon_png, icon_size)) {
|
||||
printf("icon: could not write to %s: %s\n", APP_DIR, strerror(errno));
|
||||
sceAppInstUtilTerminate();
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* Register just this title where the firmware supports it; otherwise ask
|
||||
* for a rescan of everything under /user/app. */
|
||||
if (!kernel_dynlib_handle(-1, "libSceAppInstUtil.sprx", &handle)) {
|
||||
install_title_dir = (void *)kernel_dynlib_resolve(-1, handle, "Wudg3Xe3heE");
|
||||
}
|
||||
if (install_title_dir) {
|
||||
err = install_title_dir(TITLE_ID, "/user/app/", 0);
|
||||
} else {
|
||||
err = sceAppInstUtilAppInstallAll(0);
|
||||
}
|
||||
sceAppInstUtilTerminate();
|
||||
if (err) {
|
||||
printf("icon: registering the app failed: 0x%08x\n", err);
|
||||
return 1;
|
||||
}
|
||||
printf("icon: ok (installed %s)\n", TITLE_ID);
|
||||
return 0;
|
||||
}
|
||||
File renamed without changes.
+6
-6
@@ -51,18 +51,18 @@ These folders are not in the repository.
|
||||
## Building the payloads
|
||||
|
||||
```powershell
|
||||
# daemon payload: C launcher + Go program -> out\tailscale.elf
|
||||
.\tools\build-payload.ps1 -GoDir tsd -Name tailscale -Version 0.3.0
|
||||
|
||||
# installer -> out\tailscale-installer.elf (embeds out\tailscale.elf)
|
||||
.\tools\build-installer.ps1
|
||||
# C launcher + Go program + home screen icon helper -> out\tailscale.elf
|
||||
.\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.
|
||||
|
||||
## Sending to the console
|
||||
|
||||
```powershell
|
||||
$env:PS5_HOST = '192.168.1.50' # your console
|
||||
.\tools\ps5send.ps1 -File out\tailscale-installer.elf -Seconds 70
|
||||
.\tools\ps5send.ps1 -File out\tailscale.elf
|
||||
```
|
||||
|
||||
`ps5send.ps1` prints whatever the payload writes back.
|
||||
|
||||
+76
-11
@@ -27,21 +27,59 @@ specification.
|
||||
- Inbound: tsnet's fallback TCP handler pipes each tailnet connection to
|
||||
`127.0.0.1:<same port>`. It dials the local port before accepting, so
|
||||
ports with no listener are refused properly.
|
||||
- Inbound UDP (`inboundudp.go`): tsnet has no catch-all for UDP, so the ports
|
||||
in `udpPorts` are listened on with `tsnet.Server.ListenPacket` on the
|
||||
node's tailnet addresses and relayed to `127.0.0.1`. The default list is
|
||||
PS5 Remote Play's (9295, 9296, 9297, 9302); its service answers clients
|
||||
that arrive from loopback. Both UDP directions share `udprelay.go`: one
|
||||
connection to the target per client address, dropped after two idle
|
||||
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.
|
||||
- When a listener reports that it had to reopen its socket (the PS5's
|
||||
network was reconfigured), the daemon asks Tailscale to rebind and re-STUN
|
||||
instead of waiting for its interface polling. See [Rest mode](#rest-mode)
|
||||
for the one time this has been seen.
|
||||
- A payload that is sent again stops the running instance (through the
|
||||
status page, or failing that by the pid it recorded) and takes over.
|
||||
|
||||
**The installer** (`installer/`, C) embeds `tailscale.elf`. It writes it to
|
||||
`/data/tailscale/tailscale.elf`, registers a home screen app whose
|
||||
`param.json` has a `deeplinkUri` to the status page, and starts the daemon by
|
||||
sending it to the ELF loader on `127.0.0.1:9021`. It does not modify any
|
||||
payload autoloader.
|
||||
**The icon helper** (`appicon/`, C) is a second, tiny payload embedded in the
|
||||
launcher. The first time `tailscale.elf` runs, the launcher sends it to the
|
||||
ELF loader on `127.0.0.1:9021`, where it runs as a process of its own,
|
||||
registers a home screen app whose `param.json` has a `deeplinkUri` to the
|
||||
status page, reports the result and exits. The launcher then writes
|
||||
`/data/tailscale/icon-installed` and never does it again. It is a separate
|
||||
payload so that the system libraries it needs are never loaded into the
|
||||
long-running daemon process, where their threads could receive signals meant
|
||||
for the Go runtime.
|
||||
|
||||
The same helper removes the icon (`sceAppInstUtilAppUnInstall`). A payload
|
||||
sent to the ELF loader gets no arguments, so the mode is a byte in the file
|
||||
after the marker `TSICON-MODE=`. The launcher leaves a copy of the helper in
|
||||
`/data/tailscale/icon-helper.elf`; for Uninstall the daemon flips that byte
|
||||
and sends it to the loader (`tsd/homeicon.go`).
|
||||
|
||||
There is no installer. Nothing is copied anywhere and no payload autoloader
|
||||
is touched: the payload is run from wherever the user keeps it.
|
||||
|
||||
## The PS5 as a Go target
|
||||
|
||||
@@ -126,6 +164,32 @@ 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 once, 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 hours has not been tried, nor one on Wi-Fi.
|
||||
|
||||
## Home screen icon
|
||||
|
||||
`/user/app/TSCL00001/sce_sys/param.json` with `applicationCategoryType` 65536
|
||||
@@ -148,5 +212,6 @@ that order, as in the SDK's `install_app` sample.
|
||||
request was answered with "auth path not found". The daemon now requests a
|
||||
new link when it sees that error; the recovery path has not been observed
|
||||
in practice.
|
||||
- Whether Tailscale's own UDP sockets recover after a network
|
||||
reconfiguration has not been examined.
|
||||
- Tailscale's sockets recovered after rest mode took the network down and
|
||||
brought it back. A change of interface (Wi-Fi to Ethernet or back) while
|
||||
the daemon runs has not been observed since the rebind request was added.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 67 KiB |
@@ -1,310 +0,0 @@
|
||||
/* Tailscale installer payload for jailbroken PS5s.
|
||||
*
|
||||
* Stores the Tailscale daemon payload on the console, adds a home screen
|
||||
* icon that opens the status page, and starts the daemon through the ELF
|
||||
* loader on this console. It does not touch any payload autoloader. Build
|
||||
* with tools\build-installer.ps1, which sets DAEMON_ELF and
|
||||
* ASSET_DIR and links the system libraries the app installer needs. */
|
||||
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <stdarg.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <netinet/in.h>
|
||||
#include <sys/select.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/time.h>
|
||||
|
||||
#include <ps5/kernel.h>
|
||||
|
||||
#ifndef DAEMON_ELF
|
||||
#error "DAEMON_ELF must name the daemon payload to embed"
|
||||
#endif
|
||||
|
||||
#define DATA_DIR "/data/tailscale"
|
||||
#define DAEMON_NAME "tailscale.elf"
|
||||
#define LOADER_PORT 9021
|
||||
|
||||
extern const uint8_t daemon_elf[];
|
||||
extern const uint8_t daemon_elf_end[];
|
||||
|
||||
__asm__(".section .rodata\n"
|
||||
".balign 16\n"
|
||||
".global daemon_elf\n"
|
||||
"daemon_elf:\n"
|
||||
".incbin \"" DAEMON_ELF "\"\n"
|
||||
".global daemon_elf_end\n"
|
||||
"daemon_elf_end:\n"
|
||||
".text\n");
|
||||
|
||||
/* The home screen launcher: a media app whose only content is a link, which
|
||||
* the console opens in its browser. ASSET_DIR is set by the build. */
|
||||
#ifndef ASSET_DIR
|
||||
#error "ASSET_DIR must name the folder with param.json and icon0.png"
|
||||
#endif
|
||||
#define LAUNCHER_TITLE_ID "TSCL00001"
|
||||
#define LAUNCHER_DIR "/user/app/" LAUNCHER_TITLE_ID
|
||||
|
||||
#define INCASSET(name, file) \
|
||||
__asm__(".section .rodata\n" \
|
||||
".balign 16\n" \
|
||||
".global " #name "\n" #name ":\n" \
|
||||
".incbin \"" file "\"\n" \
|
||||
".global " #name "_end\n" #name "_end:\n" \
|
||||
".text\n"); \
|
||||
extern const uint8_t name[]; \
|
||||
extern const uint8_t name##_end[];
|
||||
|
||||
INCASSET(launcher_param_json, ASSET_DIR "/param.json")
|
||||
INCASSET(launcher_icon_png, ASSET_DIR "/icon0.png")
|
||||
|
||||
int sceAppInstUtilInitialize(void);
|
||||
int sceAppInstUtilTerminate(void);
|
||||
int sceAppInstUtilAppInstallAll(void *);
|
||||
|
||||
typedef struct notify_request {
|
||||
char useless1[45];
|
||||
char message[3075];
|
||||
} notify_request_t;
|
||||
|
||||
int sceKernelSendNotificationRequest(int, notify_request_t *, size_t, int);
|
||||
|
||||
static void
|
||||
notify(const char *fmt, ...) {
|
||||
notify_request_t req;
|
||||
va_list args;
|
||||
|
||||
memset(&req, 0, sizeof(req));
|
||||
va_start(args, fmt);
|
||||
vsnprintf(req.message, sizeof(req.message), fmt, args);
|
||||
va_end(args);
|
||||
sceKernelSendNotificationRequest(0, &req, sizeof(req), 0);
|
||||
}
|
||||
|
||||
static int
|
||||
write_all(int fd, const uint8_t *data, size_t size) {
|
||||
while (size > 0) {
|
||||
ssize_t n = write(fd, data, size);
|
||||
if (n < 0) {
|
||||
if (errno == EINTR) {
|
||||
continue;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
data += n;
|
||||
size -= n;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Write a file through a temporary name so a failed write never leaves a
|
||||
* truncated payload behind. */
|
||||
static int
|
||||
write_file(const char *path, const uint8_t *data, size_t size) {
|
||||
char tmp[512];
|
||||
int fd;
|
||||
|
||||
snprintf(tmp, sizeof(tmp), "%s.tmp", path);
|
||||
if ((fd = open(tmp, O_WRONLY | O_CREAT | O_TRUNC, 0755)) < 0) {
|
||||
return -1;
|
||||
}
|
||||
if (write_all(fd, data, size)) {
|
||||
close(fd);
|
||||
unlink(tmp);
|
||||
return -1;
|
||||
}
|
||||
close(fd);
|
||||
if (rename(tmp, path)) {
|
||||
unlink(tmp);
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int
|
||||
install_daemon(const char *dir) {
|
||||
char path[512];
|
||||
|
||||
mkdir(dir, 0755);
|
||||
snprintf(path, sizeof(path), "%s/%s", dir, DAEMON_NAME);
|
||||
if (write_file(path, daemon_elf, daemon_elf_end - daemon_elf)) {
|
||||
printf(" could not write %s: %s\n", path, strerror(errno));
|
||||
return -1;
|
||||
}
|
||||
printf(" wrote %s (%.1f MB)\n", path, (daemon_elf_end - daemon_elf) / 1048576.0);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Report whether the file at path already has exactly these contents. */
|
||||
static int
|
||||
file_matches(const char *path, const uint8_t *data, size_t size) {
|
||||
struct stat st;
|
||||
uint8_t *buf;
|
||||
int same = 0;
|
||||
FILE *f;
|
||||
|
||||
if (stat(path, &st) || (size_t)st.st_size != size || !(f = fopen(path, "rb"))) {
|
||||
return 0;
|
||||
}
|
||||
if ((buf = malloc(size))) {
|
||||
same = fread(buf, 1, size, f) == size && !memcmp(buf, data, size);
|
||||
free(buf);
|
||||
}
|
||||
fclose(f);
|
||||
return same;
|
||||
}
|
||||
|
||||
/* Put a "Tailscale" icon on the home screen that opens the status page in
|
||||
* the console's browser. Does nothing if it is already there and current.
|
||||
* Returns 0 on success. */
|
||||
static int
|
||||
install_launcher_app(void) {
|
||||
int (*install_title_dir)(const char *, const char *, void *) = 0;
|
||||
size_t param_size = launcher_param_json_end - launcher_param_json;
|
||||
size_t icon_size = launcher_icon_png_end - launcher_icon_png;
|
||||
uint32_t handle;
|
||||
int err;
|
||||
|
||||
if (file_matches(LAUNCHER_DIR "/sce_sys/param.json", launcher_param_json, param_size) &&
|
||||
file_matches(LAUNCHER_DIR "/sce_sys/icon0.png", launcher_icon_png, icon_size)) {
|
||||
printf(" already installed\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ((err = sceAppInstUtilInitialize())) {
|
||||
printf(" sceAppInstUtilInitialize failed: 0x%08x\n", err);
|
||||
return -1;
|
||||
}
|
||||
mkdir(LAUNCHER_DIR, 0755);
|
||||
mkdir(LAUNCHER_DIR "/sce_sys", 0755);
|
||||
if (write_file(LAUNCHER_DIR "/sce_sys/param.json", launcher_param_json, param_size) ||
|
||||
write_file(LAUNCHER_DIR "/sce_sys/icon0.png", launcher_icon_png, icon_size)) {
|
||||
printf(" could not write to %s: %s\n", LAUNCHER_DIR, strerror(errno));
|
||||
sceAppInstUtilTerminate();
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Register just this title where the firmware supports it; otherwise ask
|
||||
* for a rescan of everything under /user/app. */
|
||||
if (!kernel_dynlib_handle(-1, "libSceAppInstUtil.sprx", &handle)) {
|
||||
install_title_dir = (void *)kernel_dynlib_resolve(-1, handle, "Wudg3Xe3heE");
|
||||
}
|
||||
if (install_title_dir) {
|
||||
err = install_title_dir(LAUNCHER_TITLE_ID, "/user/app/", 0);
|
||||
} else {
|
||||
err = sceAppInstUtilAppInstallAll(0);
|
||||
}
|
||||
sceAppInstUtilTerminate();
|
||||
if (err) {
|
||||
printf(" registering the app failed: 0x%08x\n", err);
|
||||
return -1;
|
||||
}
|
||||
printf(" installed (%s), opens http://127.0.0.1:8090/\n", LAUNCHER_TITLE_ID);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Hand the daemon to the ELF loader on this console and relay what it prints
|
||||
* for a while, so that the login link reaches whoever sent the installer. */
|
||||
static int
|
||||
start_daemon(int relay_seconds) {
|
||||
struct sockaddr_in addr = {0};
|
||||
struct timeval start, now;
|
||||
char buf[4096];
|
||||
int fd;
|
||||
|
||||
if ((fd = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
|
||||
return -1;
|
||||
}
|
||||
addr.sin_family = AF_INET;
|
||||
addr.sin_port = htons(LOADER_PORT);
|
||||
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||
if (connect(fd, (struct sockaddr *)&addr, sizeof(addr))) {
|
||||
close(fd);
|
||||
return -1;
|
||||
}
|
||||
if (write_all(fd, daemon_elf, daemon_elf_end - daemon_elf)) {
|
||||
close(fd);
|
||||
return -1;
|
||||
}
|
||||
|
||||
gettimeofday(&start, 0);
|
||||
for (;;) {
|
||||
struct timeval tv = {1, 0};
|
||||
fd_set rfds;
|
||||
|
||||
gettimeofday(&now, 0);
|
||||
if (now.tv_sec - start.tv_sec >= relay_seconds) {
|
||||
break;
|
||||
}
|
||||
FD_ZERO(&rfds);
|
||||
FD_SET(fd, &rfds);
|
||||
if (select(fd + 1, &rfds, 0, 0, &tv) <= 0) {
|
||||
continue;
|
||||
}
|
||||
ssize_t n = read(fd, buf, sizeof(buf));
|
||||
if (n <= 0) {
|
||||
break;
|
||||
}
|
||||
if (write_all(STDOUT_FILENO, (uint8_t *)buf, n)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
close(fd);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int
|
||||
main(void) {
|
||||
pid_t pid = getpid();
|
||||
intptr_t rootvnode;
|
||||
|
||||
setvbuf(stdout, 0, _IONBF, 0);
|
||||
|
||||
/* Run as root outside the sandbox so /data and USB drives are writable. */
|
||||
if ((rootvnode = kernel_get_root_vnode())) {
|
||||
kernel_set_proc_rootdir(pid, rootvnode);
|
||||
kernel_set_proc_jaildir(pid, 0);
|
||||
}
|
||||
kernel_set_ucred_uid(pid, 0);
|
||||
kernel_set_ucred_ruid(pid, 0);
|
||||
kernel_set_ucred_svuid(pid, 0);
|
||||
kernel_set_ucred_rgid(pid, 0);
|
||||
kernel_set_ucred_svgid(pid, 0);
|
||||
|
||||
#ifdef LAUNCHER_ONLY
|
||||
/* Test build: only (re)install the home screen icon. */
|
||||
printf("Home screen icon:\n");
|
||||
return install_launcher_app() ? 1 : 0;
|
||||
#endif
|
||||
|
||||
printf("Tailscale for PS5 installer\n\n");
|
||||
|
||||
printf("Daemon payload:\n");
|
||||
if (install_daemon(DATA_DIR)) {
|
||||
notify("Tailscale install failed:\ncould not write to %s", DATA_DIR);
|
||||
return 1;
|
||||
}
|
||||
|
||||
printf("Home screen icon:\n");
|
||||
install_launcher_app();
|
||||
|
||||
printf("\nStarting Tailscale...\n");
|
||||
if (start_daemon(45)) {
|
||||
printf("Could not reach the ELF loader on port %d: %s\n", LOADER_PORT, strerror(errno));
|
||||
notify("Tailscale is installed but could not be started:\nno ELF loader on port %d.", LOADER_PORT);
|
||||
return 1;
|
||||
}
|
||||
|
||||
printf("\nDone. The status page is on port 8090 of this console.\n"
|
||||
"Tailscale runs until the console restarts. To start it again, send\n"
|
||||
"%s/%s to the ELF loader.\n",
|
||||
DATA_DIR, DAEMON_NAME);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/* Installs the home screen icon the first time the payload runs.
|
||||
*
|
||||
* The icon is installed by a separate small payload (appicon/), embedded
|
||||
* here and handed to the ELF loader on this console, so that the system
|
||||
* libraries it needs never end up in this process. Build with
|
||||
* -DICON_HELPER="path/to/appicon.elf"; without it this file does nothing. */
|
||||
|
||||
#include "homeicon.h"
|
||||
|
||||
#ifdef ICON_HELPER
|
||||
|
||||
#include <fcntl.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <netinet/in.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/time.h>
|
||||
|
||||
#define DATA_DIR "/data/tailscale"
|
||||
/* Records that the icon has been installed, and which version of it. Once
|
||||
* it exists the icon is left alone, so an icon the user deletes from the
|
||||
* home screen stays deleted. Remove the file to get the icon back. */
|
||||
#define ICON_MARKER DATA_DIR "/icon-installed"
|
||||
#define ICON_VERSION "1\n"
|
||||
#define ICON_HELPER_FILE DATA_DIR "/icon-helper.elf"
|
||||
#define LOADER_PORT 9021
|
||||
|
||||
extern const uint8_t icon_helper[];
|
||||
extern const uint8_t icon_helper_end[];
|
||||
|
||||
__asm__(".section .rodata\n"
|
||||
".balign 16\n"
|
||||
".global icon_helper\n"
|
||||
"icon_helper:\n"
|
||||
".incbin \"" ICON_HELPER "\"\n"
|
||||
".global icon_helper_end\n"
|
||||
"icon_helper_end:\n"
|
||||
".text\n");
|
||||
|
||||
static int
|
||||
marker_is_current(void) {
|
||||
char buf[16] = {0};
|
||||
int fd = open(ICON_MARKER, O_RDONLY);
|
||||
|
||||
if (fd < 0) {
|
||||
return 0;
|
||||
}
|
||||
read(fd, buf, sizeof(buf) - 1);
|
||||
close(fd);
|
||||
return !strcmp(buf, ICON_VERSION);
|
||||
}
|
||||
|
||||
/* Leave a copy of the helper where the daemon can find it. Uninstall runs it
|
||||
* again, switched to removing the icon. */
|
||||
static void
|
||||
save_helper(void) {
|
||||
size_t size = icon_helper_end - icon_helper;
|
||||
struct stat st;
|
||||
int fd;
|
||||
|
||||
if (!stat(ICON_HELPER_FILE, &st) && (size_t)st.st_size == size) {
|
||||
return;
|
||||
}
|
||||
if ((fd = open(ICON_HELPER_FILE, O_WRONLY | O_CREAT | O_TRUNC, 0644)) < 0) {
|
||||
return;
|
||||
}
|
||||
for (size_t done = 0; done < size;) {
|
||||
ssize_t n = write(fd, icon_helper + done, size - done);
|
||||
if (n <= 0) {
|
||||
break;
|
||||
}
|
||||
done += n;
|
||||
}
|
||||
close(fd);
|
||||
}
|
||||
|
||||
void
|
||||
home_icon_install_once(void) {
|
||||
struct sockaddr_in addr = {0};
|
||||
struct timeval tv = {1, 0};
|
||||
const uint8_t *data = icon_helper;
|
||||
size_t left = icon_helper_end - icon_helper;
|
||||
char reply[512] = {0};
|
||||
size_t got = 0;
|
||||
int fd;
|
||||
|
||||
mkdir(DATA_DIR, 0755);
|
||||
save_helper();
|
||||
if (marker_is_current()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((fd = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
|
||||
return;
|
||||
}
|
||||
addr.sin_family = AF_INET;
|
||||
addr.sin_port = htons(LOADER_PORT);
|
||||
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||
if (connect(fd, (struct sockaddr *)&addr, sizeof(addr))) {
|
||||
/* No ELF loader on the usual port: go without an icon this time. */
|
||||
close(fd);
|
||||
return;
|
||||
}
|
||||
while (left > 0) {
|
||||
ssize_t n = write(fd, data, left);
|
||||
if (n <= 0) {
|
||||
close(fd);
|
||||
return;
|
||||
}
|
||||
data += n;
|
||||
left -= n;
|
||||
}
|
||||
|
||||
/* The helper reports "icon: ok" or what went wrong, then exits, which
|
||||
* closes the connection. Give it 20 seconds. */
|
||||
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
|
||||
for (int tries = 0; tries < 20 && got < sizeof(reply) - 1; tries++) {
|
||||
const char *line = strstr(reply, "icon: ");
|
||||
if (line && strchr(line, '\n')) {
|
||||
break;
|
||||
}
|
||||
ssize_t n = read(fd, reply + got, sizeof(reply) - 1 - got);
|
||||
if (n == 0) {
|
||||
break;
|
||||
}
|
||||
if (n > 0) {
|
||||
got += n;
|
||||
}
|
||||
}
|
||||
close(fd);
|
||||
|
||||
if (strstr(reply, "icon: ok")) {
|
||||
if ((fd = open(ICON_MARKER, O_WRONLY | O_CREAT | O_TRUNC, 0644)) >= 0) {
|
||||
write(fd, ICON_VERSION, sizeof(ICON_VERSION) - 1);
|
||||
close(fd);
|
||||
}
|
||||
fprintf(stderr, "launcher: home screen icon installed\n");
|
||||
} else {
|
||||
fprintf(stderr, "launcher: home screen icon not installed: %s\n", got ? reply : "no reply from the helper");
|
||||
}
|
||||
}
|
||||
|
||||
#else
|
||||
|
||||
void
|
||||
home_icon_install_once(void) {
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,5 @@
|
||||
#pragma once
|
||||
|
||||
/* Put the Tailscale icon on the home screen if that has not been done yet.
|
||||
* Never fails: without an icon everything else still works. */
|
||||
void home_icon_install_once(void);
|
||||
+28
-6
@@ -3,6 +3,7 @@
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <sys/mman.h>
|
||||
@@ -10,6 +11,7 @@
|
||||
#include <ps5/kernel.h>
|
||||
|
||||
#include "goload.h"
|
||||
#include "homeicon.h"
|
||||
|
||||
#ifndef GO_IMAGE
|
||||
#error "GO_IMAGE must name the Go binary to embed"
|
||||
@@ -62,30 +64,48 @@ raw_syscall3(long n, long a, long b, long c) {
|
||||
* priority, where nothing this process does can keep the system's own threads
|
||||
* off the CPU. Threads created later inherit the setting. The kernel ignores
|
||||
* priorities outside its own range without reporting an error, so the result
|
||||
* is read back. Round-robin at the lowest priority is the fallback. */
|
||||
* is read back. Round-robin at the lowest priority is the fallback.
|
||||
*
|
||||
* With the "high" priority setting the process instead becomes round-robin
|
||||
* at the default priority: it then competes with games on equal terms, but
|
||||
* equal-priority round-robin threads take turns, so even then a thread that
|
||||
* never blocks cannot shut the others out. */
|
||||
static int
|
||||
high_priority_requested(void) {
|
||||
char buf[16] = {0};
|
||||
FILE *f = fopen("/data/tailscale/priority", "r");
|
||||
|
||||
if (!f) {
|
||||
return 0;
|
||||
}
|
||||
fgets(buf, sizeof(buf), f);
|
||||
fclose(f);
|
||||
return !strncmp(buf, "high", 4);
|
||||
}
|
||||
|
||||
static int
|
||||
leave_realtime_class(void) {
|
||||
static const struct rtprio choices[] = {
|
||||
{RTP_PRIO_REALTIME, PS5_PRIO_DEFAULT}, /* only with the "high" setting */
|
||||
{RTP_PRIO_NORMAL, PS5_PRIO_LOWEST},
|
||||
{RTP_PRIO_REALTIME, PS5_PRIO_LOWEST},
|
||||
};
|
||||
struct rtprio before = {0}, after = {0};
|
||||
int ok = 0;
|
||||
|
||||
raw_syscall3(SYS_rtprio_thread, RTP_LOOKUP, 0, (long)&before);
|
||||
for (size_t i = 0; i < sizeof(choices) / sizeof(choices[0]); i++) {
|
||||
for (size_t i = high_priority_requested() ? 0 : 1; i < sizeof(choices) / sizeof(choices[0]) && !ok; i++) {
|
||||
struct rtprio want = choices[i];
|
||||
raw_syscall3(SYS_rtprio_thread, RTP_SET, 0, (long)&want);
|
||||
raw_syscall3(SYS_rtprio_thread, RTP_LOOKUP, 0, (long)&after);
|
||||
if (after.type == choices[i].type && after.prio == choices[i].prio) {
|
||||
break;
|
||||
}
|
||||
ok = after.type == choices[i].type && after.prio == choices[i].prio;
|
||||
}
|
||||
#ifdef GOLOAD_DEBUG
|
||||
fprintf(stderr, "launcher: scheduling class %u/%u -> %u/%u\n", before.type, before.prio, after.type, after.prio);
|
||||
#else
|
||||
(void)before;
|
||||
#endif
|
||||
return after.prio > PS5_PRIO_DEFAULT && after.type != before.type ? 0 : -1;
|
||||
return ok ? 0 : -1;
|
||||
}
|
||||
|
||||
#ifdef GOLOAD_WATCHDOG
|
||||
@@ -134,6 +154,8 @@ main(int argc, char **argv) {
|
||||
kernel_set_ucred_rgid(pid, 0);
|
||||
kernel_set_ucred_svgid(pid, 0);
|
||||
|
||||
home_icon_install_once();
|
||||
|
||||
if (leave_realtime_class()) {
|
||||
/* Without this a runaway goroutine could hang the console, so do not
|
||||
* take the chance. */
|
||||
|
||||
+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)) {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/* Ask the console's Remote Play service for its discovery reply over
|
||||
* loopback, to confirm it answers clients that arrive from 127.0.0.1 (which
|
||||
* is how the daemon's UDP relay reaches it). Read-only. */
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <netinet/in.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/time.h>
|
||||
|
||||
int
|
||||
main(void) {
|
||||
static const char req[] = "SRCH * HTTP/1.1\ndevice-discovery-protocol-version:00030010\n";
|
||||
struct sockaddr_in addr = {0};
|
||||
struct timeval tv = {3, 0};
|
||||
char buf[1024];
|
||||
int fd = socket(AF_INET, SOCK_DGRAM, 0);
|
||||
ssize_t n;
|
||||
|
||||
setvbuf(stdout, 0, _IONBF, 0);
|
||||
addr.sin_family = AF_INET;
|
||||
addr.sin_port = htons(9302);
|
||||
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
|
||||
sendto(fd, req, sizeof(req) - 1, 0, (struct sockaddr *)&addr, sizeof(addr));
|
||||
n = recv(fd, buf, sizeof(buf) - 1, 0);
|
||||
if (n <= 0) {
|
||||
printf("discovery via 127.0.0.1:9302: no reply\n");
|
||||
} else {
|
||||
buf[n] = 0;
|
||||
buf[strcspn(buf, "\r\n")] = 0;
|
||||
printf("discovery via 127.0.0.1:9302: %s\n", buf);
|
||||
}
|
||||
close(fd);
|
||||
return 0;
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
# Build the installer payload around an already built daemon payload.
|
||||
# .\tools\build-installer.ps1 [-Daemon out\tailscale.elf] [-Send]
|
||||
param(
|
||||
[string]$Daemon = 'out\tailscale.elf',
|
||||
[string]$Out = 'out\tailscale-installer.elf',
|
||||
[switch]$Send,
|
||||
[int]$Seconds = 70
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
. (Join-Path $PSScriptRoot 'env.ps1')
|
||||
|
||||
$daemonPath = (Resolve-Path (Join-Path $DevRoot $Daemon)).Path -replace '\\', '/'
|
||||
$assets = (Join-Path $DevRoot 'installer\assets') -replace '\\', '/'
|
||||
$outPath = Join-Path $DevRoot $Out
|
||||
|
||||
# The app installer library only loads when these come with it, in this
|
||||
# order (as in the SDK's install_app sample). With libSceAppInstUtil alone
|
||||
# the payload never starts: the loader leaves it stopped.
|
||||
Invoke-PS5CC -O2 -Wall "-DDAEMON_ELF=`"$daemonPath`"" "-DASSET_DIR=`"$assets`"" `
|
||||
-lSceIpmi -lSceAppInstUtil -lSceUserService -lSceSystemService `
|
||||
-o $outPath (Join-Path $DevRoot 'installer\main.c')
|
||||
|
||||
Write-Host ("built {0} ({1:N1} MB)" -f $outPath, ((Get-Item $outPath).Length / 1MB))
|
||||
if ($Send) {
|
||||
& (Join-Path $PSScriptRoot 'ps5send.ps1') -File $outPath -Seconds $Seconds
|
||||
}
|
||||
+16
-1
@@ -1,4 +1,5 @@
|
||||
# Build a Go program for the PS5 and wrap it in the launcher payload.
|
||||
# .\tools\build-payload.ps1 -GoDir tsd -Name tailscale -Version 0.5.2 -HomeIcon
|
||||
# .\tools\build-payload.ps1 -GoDir probe-go -Name probe [-DebugLoader] [-Watchdog 120] [-Send]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$GoDir,
|
||||
@@ -9,6 +10,7 @@ param(
|
||||
[string]$MaxProcs = '', # GOMAXPROCS for the Go program (launcher default: 4)
|
||||
[string]$GoDebug = '', # GODEBUG value baked into the launcher
|
||||
[int]$Watchdog = 0, # test builds: kill the process after this many seconds
|
||||
[switch]$HomeIcon, # embed the helper that adds the home screen icon on first run
|
||||
[switch]$DebugLoader, # print loader details and early crash registers
|
||||
[switch]$KeepSymbols,
|
||||
[switch]$Send,
|
||||
@@ -39,7 +41,20 @@ if ($DebugLoader) { $ccArgs += '-DGOLOAD_DEBUG' }
|
||||
if ($Watchdog -gt 0) { $ccArgs += "-DGOLOAD_WATCHDOG=$Watchdog" }
|
||||
if ($MaxProcs) { $ccArgs += "-DGO_MAXPROCS=`"$MaxProcs`"" }
|
||||
if ($GoDebug) { $ccArgs += "-DGO_DEBUG=`"$GoDebug`"" }
|
||||
$ccArgs += @('-o', $elf, (Join-Path $DevRoot 'launcher\main.c'), (Join-Path $DevRoot 'launcher\goload.c'))
|
||||
if ($HomeIcon) {
|
||||
# The icon helper is a payload of its own. The app installer library only
|
||||
# loads when these come with it, in this order (as in the SDK's
|
||||
# install_app sample); with libSceAppInstUtil alone the payload is never
|
||||
# started.
|
||||
$helper = Join-Path $out 'appicon.elf'
|
||||
$assets = (Join-Path $DevRoot 'appicon') -replace '\\', '/'
|
||||
Invoke-PS5CC -O2 -Wall "-DASSET_DIR=`"$assets`"" `
|
||||
-lSceIpmi -lSceAppInstUtil -lSceUserService -lSceSystemService `
|
||||
-o $helper (Join-Path $DevRoot 'appicon\main.c')
|
||||
$ccArgs += "-DICON_HELPER=`"$($helper -replace '\\', '/')`""
|
||||
}
|
||||
$ccArgs += @('-o', $elf, (Join-Path $DevRoot 'launcher\main.c'), (Join-Path $DevRoot 'launcher\goload.c'),
|
||||
(Join-Path $DevRoot 'launcher\homeicon.c'))
|
||||
Invoke-PS5CC @ccArgs
|
||||
|
||||
Write-Host ("built {0} ({1:N1} MB)" -f $elf, ((Get-Item $elf).Length / 1MB))
|
||||
|
||||
+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" }
|
||||
+46
-10
@@ -5,9 +5,11 @@ import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// 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"`
|
||||
@@ -15,30 +17,48 @@ 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.
|
||||
Forwards []forwardRule `json:"forwards,omitempty"`
|
||||
// UDPPorts lists the console's UDP ports that are reachable from the
|
||||
// tailnet. The default is what PS5 Remote Play uses. An empty list turns
|
||||
// inbound UDP off.
|
||||
UDPPorts []uint16 `json:"udpPorts"`
|
||||
// BlockedPorts lists local TCP ports that are never exposed to the tailnet.
|
||||
BlockedPorts []uint16 `json:"blockedPorts,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",
|
||||
Hostname: "ps5",
|
||||
WebAddr: ":8090",
|
||||
UDPPorts: slices.Clone(remotePlayUDPPorts),
|
||||
CheckUpdates: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,13 +76,29 @@ 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.Priority != priorityHigh {
|
||||
cfg.Priority = ""
|
||||
}
|
||||
}
|
||||
|
||||
func saveConfig(path string, cfg config) error {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 9.3 KiB |
+24
-6
@@ -20,8 +20,24 @@ 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
|
||||
}
|
||||
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 +56,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.
|
||||
|
||||
@@ -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]))
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strconv"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Inbound UDP: tailnet devices reaching UDP services on the console.
|
||||
//
|
||||
// TCP needs no configuration, because tsnet asks about every incoming
|
||||
// connection and it can be passed to localhost on the spot. UDP has no such
|
||||
// hook, so the ports have to be listed and listened on, on the console's
|
||||
// tailnet addresses. The default list is what PS5 Remote Play uses.
|
||||
|
||||
// remotePlayUDPPorts are the UDP ports of the console's Remote Play service:
|
||||
// registration (9295), the stream (9296), the connection test (9297) and
|
||||
// discovery (9302). Its session port, TCP 9295, is covered by the TCP
|
||||
// forwarding.
|
||||
var remotePlayUDPPorts = []uint16{9295, 9296, 9297, 9302}
|
||||
|
||||
// udpExposer keeps a set of the console's UDP ports reachable on its tailnet
|
||||
// addresses.
|
||||
type udpExposer struct {
|
||||
// listen opens a UDP socket on a tailnet address
|
||||
// (tsnet.Server.ListenPacket).
|
||||
listen func(network, addr string) (net.PacketConn, error)
|
||||
logf func(format string, args ...any)
|
||||
// targetHost is where the console's services are reached.
|
||||
targetHost string
|
||||
|
||||
mu sync.Mutex
|
||||
addrs []netip.Addr
|
||||
ports []uint16
|
||||
relays []*udpRelay
|
||||
active []uint16
|
||||
}
|
||||
|
||||
// update makes ports reachable on addrs, replacing whatever was exposed
|
||||
// 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()
|
||||
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 _, r := range e.relays {
|
||||
r.stop()
|
||||
}
|
||||
e.relays, e.active = nil, nil
|
||||
e.addrs, e.ports = slices.Clone(addrs), slices.Clone(ports)
|
||||
|
||||
for _, port := range ports {
|
||||
target := net.JoinHostPort(e.targetHost, strconv.Itoa(int(port)))
|
||||
ok := false
|
||||
for _, addr := range addrs {
|
||||
network := "udp4"
|
||||
if addr.Is6() {
|
||||
network = "udp6"
|
||||
}
|
||||
listenAddr := netip.AddrPortFrom(addr, port).String()
|
||||
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,
|
||||
})
|
||||
if err != nil {
|
||||
e.logf("udp %s: %v", listenAddr, err)
|
||||
continue
|
||||
}
|
||||
e.relays = append(e.relays, relay)
|
||||
ok = true
|
||||
}
|
||||
if ok {
|
||||
e.active = append(e.active, port)
|
||||
}
|
||||
}
|
||||
if len(e.active) > 0 {
|
||||
e.logf("UDP ports reachable from the tailnet: %v", e.active)
|
||||
}
|
||||
}
|
||||
|
||||
// activePorts returns the ports currently exposed.
|
||||
func (e *udpExposer) activePorts() []uint16 {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
return slices.Clone(e.active)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The exposer must relay datagrams arriving on a "tailnet" socket to the same
|
||||
// port on the target host and bring the replies back to the sender.
|
||||
func TestUDPExposer(t *testing.T) {
|
||||
// The console's service: echoes with a prefix.
|
||||
service, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer service.Close()
|
||||
go func() {
|
||||
buf := make([]byte, 2048)
|
||||
for {
|
||||
n, from, err := service.ReadFrom(buf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
service.WriteTo(append([]byte("ps5:"), buf[:n]...), from)
|
||||
}
|
||||
}()
|
||||
port := uint16(service.LocalAddr().(*net.UDPAddr).Port)
|
||||
|
||||
// Stand-in for tsnet: "listening on the tailnet address" is a loopback
|
||||
// socket on some other port, whose address the test then sends to.
|
||||
listening := make(chan net.Addr, 4)
|
||||
var asked []string
|
||||
e := &udpExposer{
|
||||
targetHost: "127.0.0.1",
|
||||
logf: t.Logf,
|
||||
listen: func(network, addr string) (net.PacketConn, error) {
|
||||
asked = append(asked, network+" "+addr)
|
||||
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||
if err == nil {
|
||||
listening <- pc.LocalAddr()
|
||||
}
|
||||
return pc, err
|
||||
},
|
||||
}
|
||||
tailnetIP := netip.MustParseAddr("100.64.0.5")
|
||||
e.update([]netip.Addr{tailnetIP}, []uint16{port})
|
||||
defer e.update(nil, nil)
|
||||
|
||||
want := "udp4 100.64.0.5:" + strconv.Itoa(int(port))
|
||||
if len(asked) != 1 || asked[0] != want {
|
||||
t.Fatalf("listened on %v, want [%s]", asked, want)
|
||||
}
|
||||
if got := e.activePorts(); len(got) != 1 || got[0] != port {
|
||||
t.Fatalf("activePorts = %v", got)
|
||||
}
|
||||
|
||||
client, err := net.Dial("udp", (<-listening).String())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer client.Close()
|
||||
for _, msg := range []string{"SRCH", "again"} {
|
||||
client.Write([]byte(msg))
|
||||
buf := make([]byte, 100)
|
||||
client.SetReadDeadline(time.Now().Add(5 * time.Second))
|
||||
n, err := client.Read(buf)
|
||||
if err != nil || string(buf[:n]) != "ps5:"+msg {
|
||||
t.Fatalf("%q: got %q, %v", msg, buf[:n], err)
|
||||
}
|
||||
}
|
||||
|
||||
// Unchanged input must not reopen anything.
|
||||
e.update([]netip.Addr{tailnetIP}, []uint16{port})
|
||||
if len(asked) != 1 {
|
||||
t.Errorf("update with the same addresses and ports listened again: %v", asked)
|
||||
}
|
||||
// An empty port list turns it off.
|
||||
e.update([]netip.Addr{tailnetIP}, nil)
|
||||
if got := e.activePorts(); len(got) != 0 {
|
||||
t.Errorf("activePorts after clearing = %v", got)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+78
-152
@@ -7,7 +7,6 @@ import (
|
||||
"net"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -31,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,144 +207,15 @@ func (f *forwarder) serveTCP(c net.Conn, r forwardRule) {
|
||||
pipe(c, up)
|
||||
}
|
||||
|
||||
// UDP flows that have been silent this long are forgotten.
|
||||
const udpIdleTimeout = 2 * time.Minute
|
||||
|
||||
// udpFlow is the relay state for one local client address.
|
||||
type udpFlow struct {
|
||||
out chan []byte // datagrams from the client waiting to go upstream
|
||||
lastSeen atomic.Int64
|
||||
}
|
||||
|
||||
func (fl *udpFlow) touch() { fl.lastSeen.Store(time.Now().UnixNano()) }
|
||||
|
||||
func (fl *udpFlow) idle() bool {
|
||||
return time.Since(time.Unix(0, fl.lastSeen.Load())) > udpIdleTimeout
|
||||
}
|
||||
|
||||
func (f *forwarder) startUDP(r forwardRule) (stop func(), err error) {
|
||||
pc, err := net.ListenPacket("udp", r.Listen)
|
||||
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
|
||||
}
|
||||
var (
|
||||
mu sync.Mutex
|
||||
current = pc
|
||||
closed bool
|
||||
flows = map[string]*udpFlow{}
|
||||
)
|
||||
socket := func() (net.PacketConn, bool) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return current, closed
|
||||
}
|
||||
|
||||
go func() {
|
||||
buf := make([]byte, 65535)
|
||||
for {
|
||||
sock, stopped := socket()
|
||||
if stopped {
|
||||
return
|
||||
}
|
||||
n, from, err := sock.ReadFrom(buf)
|
||||
if err != nil {
|
||||
if _, stopped := socket(); stopped {
|
||||
return
|
||||
}
|
||||
// Like TCP listeners, a UDP socket can die when the
|
||||
// PS5's network is reconfigured. Open a new one.
|
||||
f.logf("forward %v: %v; reopening", r, err)
|
||||
sock.Close()
|
||||
time.Sleep(time.Second)
|
||||
if reopened, err := net.ListenPacket("udp", r.Listen); err == nil {
|
||||
mu.Lock()
|
||||
if closed {
|
||||
reopened.Close()
|
||||
} else {
|
||||
current = reopened
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
key := from.String()
|
||||
mu.Lock()
|
||||
fl := flows[key]
|
||||
if fl == nil {
|
||||
fl = &udpFlow{out: make(chan []byte, 256)}
|
||||
flows[key] = fl
|
||||
go f.serveUDPFlow(r, fl, from, socket, func() {
|
||||
mu.Lock()
|
||||
if flows[key] == fl {
|
||||
delete(flows, key)
|
||||
}
|
||||
mu.Unlock()
|
||||
})
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
fl.touch()
|
||||
select {
|
||||
case fl.out <- append([]byte(nil), buf[:n]...):
|
||||
default: // upstream is not keeping up; UDP may drop
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return func() {
|
||||
mu.Lock()
|
||||
closed = true
|
||||
current.Close()
|
||||
mu.Unlock()
|
||||
}, nil
|
||||
}
|
||||
|
||||
// serveUDPFlow relays one client's datagrams to the target and the replies
|
||||
// back, until the flow goes quiet or the forward is stopped.
|
||||
func (f *forwarder) serveUDPFlow(r forwardRule, fl *udpFlow, client net.Addr, socket func() (net.PacketConn, bool), done func()) {
|
||||
defer done()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
up, err := f.dial(ctx, "udp", r.Target)
|
||||
cancel()
|
||||
if err != nil {
|
||||
f.logf("forward %v: %v", r, err)
|
||||
return
|
||||
}
|
||||
defer up.Close()
|
||||
|
||||
// Replies: target -> client.
|
||||
go func() {
|
||||
buf := make([]byte, 65535)
|
||||
for {
|
||||
up.SetReadDeadline(time.Now().Add(udpIdleTimeout))
|
||||
n, err := up.Read(buf)
|
||||
if err != nil {
|
||||
var ne net.Error
|
||||
if errors.As(err, &ne) && ne.Timeout() && !fl.idle() {
|
||||
continue
|
||||
}
|
||||
return
|
||||
}
|
||||
fl.touch()
|
||||
if pc, closed := socket(); !closed {
|
||||
pc.WriteTo(buf[:n], client)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
idle := time.NewTicker(udpIdleTimeout / 4)
|
||||
defer idle.Stop()
|
||||
for {
|
||||
select {
|
||||
case b := <-fl.out:
|
||||
if _, err := up.Write(b); err != nil {
|
||||
return
|
||||
}
|
||||
case <-idle.C:
|
||||
if _, closed := socket(); closed || fl.idle() {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
return relay.stop, nil
|
||||
}
|
||||
+62
-10
@@ -134,21 +134,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) {
|
||||
|
||||
+93
-13
@@ -11,9 +11,11 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
@@ -105,6 +107,7 @@ type daemon struct {
|
||||
srv *tsnet.Server
|
||||
lc *local.Client
|
||||
fwd *forwarder
|
||||
udp *udpExposer
|
||||
|
||||
mu sync.Mutex
|
||||
state string // ipn backend state, e.g. "NeedsLogin", "Running"
|
||||
@@ -112,13 +115,22 @@ 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
|
||||
|
||||
sessions sessions // browsers that have entered the password
|
||||
tailnetWeb *tailnetListener // status page connections arriving over the tailnet
|
||||
|
||||
quit chan struct{}
|
||||
quitOnce sync.Once
|
||||
// removeDataOnExit is set by Uninstall: delete the data directory once
|
||||
// everything that writes to it has shut down.
|
||||
removeDataOnExit bool
|
||||
}
|
||||
|
||||
func (d *daemon) run() error {
|
||||
@@ -148,8 +160,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)
|
||||
@@ -159,13 +176,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())
|
||||
@@ -174,8 +186,11 @@ 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"}
|
||||
go d.watch(ctx)
|
||||
go d.recoverLogin(ctx)
|
||||
go d.exposeUDP(ctx)
|
||||
go d.watchForUpdates(ctx)
|
||||
|
||||
sigc := make(chan os.Signal, 1)
|
||||
signal.Notify(sigc, syscall.SIGTERM, syscall.SIGINT)
|
||||
@@ -187,6 +202,7 @@ func (d *daemon) run() error {
|
||||
}
|
||||
cancel()
|
||||
webLn.Close()
|
||||
d.tailnetWeb.Close()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
@@ -199,6 +215,13 @@ func (d *daemon) run() error {
|
||||
d.logf("shutdown timed out")
|
||||
}
|
||||
d.logf("stopped")
|
||||
|
||||
d.mu.Lock()
|
||||
remove := d.removeDataOnExit
|
||||
d.mu.Unlock()
|
||||
if remove {
|
||||
os.RemoveAll(dataDir)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -211,7 +234,64 @@ 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 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
|
||||
// addresses. Those are only known once Tailscale is connected and can change,
|
||||
// so they are checked periodically.
|
||||
func (d *daemon) exposeUDP(ctx context.Context) {
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
d.mu.Lock()
|
||||
running := d.state == "Running"
|
||||
ports := slices.Clone(d.cfg.UDPPorts)
|
||||
d.mu.Unlock()
|
||||
if running {
|
||||
var addrs []netip.Addr
|
||||
v4, v6 := d.srv.TailscaleIPs()
|
||||
for _, a := range []netip.Addr{v4, v6} {
|
||||
if a.IsValid() {
|
||||
addrs = append(addrs, a)
|
||||
}
|
||||
}
|
||||
if len(addrs) > 0 {
|
||||
d.udp.update(addrs, ports)
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tsnetLogf receives tsnet's messages for the user. While it waits for a
|
||||
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
)
|
||||
|
||||
// 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"`
|
||||
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"
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+292
@@ -0,0 +1,292 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"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"`
|
||||
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,
|
||||
CheckUpdates: cfg.CheckUpdates,
|
||||
Verbose: cfg.Verbose,
|
||||
PasswordSet: cfg.PasswordHash != "",
|
||||
}
|
||||
if cfg.Priority == priorityHigh {
|
||||
s.Priority = priorityHigh
|
||||
}
|
||||
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
|
||||
}
|
||||
for _, f := range s.Forwards {
|
||||
if f.Proto != "tcp" && f.Proto != "udp" {
|
||||
return fmt.Errorf("forward %v: the protocol must be tcp or udp", f)
|
||||
}
|
||||
if err := validListenAddr(f.Listen); err != nil {
|
||||
return fmt.Errorf("forward %v: listen address: %w", f, err)
|
||||
}
|
||||
host, port, err := net.SplitHostPort(f.Target)
|
||||
if err != nil || host == "" || !validHostName(host) || !validPort(port) {
|
||||
return fmt.Errorf("forward %v: the target must be host:port", f)
|
||||
}
|
||||
}
|
||||
if slices.Contains(s.UDPPorts, 0) || slices.Contains(s.BlockedPorts, 0) {
|
||||
return fmt.Errorf("0 is not a port")
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
cfg.Forwards = s.Forwards
|
||||
cfg.UDPPorts = s.UDPPorts
|
||||
cfg.BlockedPorts = s.BlockedPorts
|
||||
cfg.Priority = ""
|
||||
if s.Priority == priorityHigh {
|
||||
cfg.Priority = priorityHigh
|
||||
}
|
||||
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()
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+363
-73
@@ -4,6 +4,7 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Tailscale on PS5</title>
|
||||
<link rel="icon" type="image/png" href="/favicon.png">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f4f5f7; --panel: #ffffff; --text: #1c1e21; --muted: #646a73;
|
||||
@@ -22,9 +23,11 @@
|
||||
}
|
||||
main { max-width: 720px; margin: 0 auto; }
|
||||
h1 { font-size: 22px; margin: 0 0 4px; }
|
||||
h2 { font-size: 15px; margin: 0 0 12px; color: var(--muted); font-weight: 600; text-transform: uppercase; letter-spacing: .04em; }
|
||||
h2, summary .heading { font-size: 15px; color: var(--muted); font-weight: 600; text-transform: uppercase; letter-spacing: .04em; }
|
||||
h2 { margin: 0 0 12px; }
|
||||
.sub { color: var(--muted); margin: 0 0 20px; font-size: 14px; }
|
||||
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 18px; margin-bottom: 16px; }
|
||||
.banner { border-color: var(--accent); }
|
||||
.state { display: flex; align-items: center; gap: 10px; font-size: 20px; font-weight: 600; }
|
||||
.dot { width: 12px; height: 12px; border-radius: 50%; background: var(--muted); flex: none; }
|
||||
.dot.ok { background: var(--ok); } .dot.warn { background: var(--warn); } .dot.bad { background: var(--bad); }
|
||||
@@ -36,28 +39,46 @@
|
||||
.login img { width: 240px; height: 240px; image-rendering: pixelated; background: #fff; padding: 8px; border-radius: 8px; }
|
||||
.login .url { display: block; margin: 12px 0 4px; font-size: 18px; overflow-wrap: anywhere; }
|
||||
.msg { color: var(--bad); margin: 12px 0 0; overflow-wrap: anywhere; }
|
||||
.okmsg { color: var(--ok); margin: 12px 0 0; overflow-wrap: anywhere; }
|
||||
.health { color: var(--warn); margin: 12px 0 0; padding-left: 18px; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 15px; }
|
||||
th { text-align: left; color: var(--muted); font-weight: 500; padding: 4px 8px 8px 0; }
|
||||
td { padding: 7px 8px 7px 0; border-top: 1px solid var(--line); overflow-wrap: anywhere; }
|
||||
td.off { color: var(--muted); }
|
||||
.actions { display: flex; flex-wrap: wrap; gap: 10px; }
|
||||
.actions { display: flex; flex-wrap: wrap; gap: 10px; align-items: flex-end; }
|
||||
button {
|
||||
font: inherit; padding: 9px 16px; border-radius: 8px; border: 1px solid var(--line);
|
||||
background: var(--panel); color: var(--text); cursor: pointer;
|
||||
}
|
||||
button:hover { border-color: var(--accent); }
|
||||
button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
button.danger { color: var(--bad); }
|
||||
button.small { padding: 6px 10px; font-size: 14px; }
|
||||
pre { margin: 0; max-height: 320px; overflow: auto; font: 12.5px/1.45 ui-monospace, Consolas, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
details summary { cursor: pointer; color: var(--muted); }
|
||||
summary .count { font-size: 14px; margin-left: 8px; }
|
||||
summary .count { white-space: nowrap; }
|
||||
details[open] > table, details[open] > form { margin-top: 12px; }
|
||||
.note { color: var(--muted); font-size: 14px; margin: 0 0 12px; }
|
||||
.field { display: flex; flex-direction: column; gap: 4px; font-size: 14px; color: var(--muted); }
|
||||
select {
|
||||
.hint { color: var(--muted); font-size: 13px; margin: 2px 0 0; }
|
||||
label.field { display: flex; flex-direction: column; gap: 4px; font-size: 14px; color: var(--muted); margin-bottom: 14px; }
|
||||
label.check { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
|
||||
input[type=text], input[type=password], input[type=number], select, textarea {
|
||||
font: inherit; padding: 8px 10px; border-radius: 8px; border: 1px solid var(--line);
|
||||
background: var(--panel); color: var(--text); min-width: 220px;
|
||||
background: var(--panel); color: var(--text); width: 100%;
|
||||
}
|
||||
.actions { align-items: flex-end; }
|
||||
textarea { font: 14px ui-monospace, Consolas, monospace; min-height: 70px; resize: vertical; }
|
||||
.filters { display: flex; flex-wrap: wrap; gap: 8px 16px; align-items: center; margin-top: 12px; }
|
||||
.filters input[type=search] {
|
||||
font: inherit; padding: 8px 10px; border-radius: 8px; border: 1px solid var(--line);
|
||||
background: var(--panel); color: var(--text); flex: 1 1 200px; min-width: 0;
|
||||
}
|
||||
.filters label.check { margin: 0; font-size: 14px; }
|
||||
tr.group td { color: var(--muted); font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; padding-top: 14px; border-top: 0; }
|
||||
td .tag { display: block; color: var(--muted); font-size: 13px; }
|
||||
.hostrow { display: flex; gap: 8px; margin-bottom: 8px; align-items: center; }
|
||||
.hostrow .host { flex: 3; min-width: 0; }
|
||||
.hostrow .port { flex: 1; min-width: 90px; }
|
||||
.hidden { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
@@ -66,6 +87,22 @@
|
||||
<h1>Tailscale on PS5</h1>
|
||||
<p class="sub" id="version"></p>
|
||||
|
||||
<section class="panel banner hidden" id="updatepanel">
|
||||
<strong id="updatetext"></strong>
|
||||
<a id="updatelink" target="_blank" rel="noopener">Release notes and download</a>
|
||||
</section>
|
||||
|
||||
<section class="panel hidden" id="lockpanel">
|
||||
<h2>Password</h2>
|
||||
<p class="note">This status page is password protected.</p>
|
||||
<form id="lockform" class="actions">
|
||||
<input type="password" id="lockpw" autocomplete="current-password" aria-label="Password" style="max-width: 280px">
|
||||
<button type="submit">Unlock</button>
|
||||
</form>
|
||||
<p class="msg hidden" id="lockmsg"></p>
|
||||
</section>
|
||||
|
||||
<div id="content" class="hidden">
|
||||
<section class="panel">
|
||||
<div class="state"><span class="dot" id="dot"></span><span id="state">Loading…</span></div>
|
||||
<dl id="facts"></dl>
|
||||
@@ -81,25 +118,82 @@
|
||||
</section>
|
||||
|
||||
<section class="panel hidden" id="peerpanel">
|
||||
<h2>Devices on your tailnet</h2>
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Address</th><th>OS</th><th>Status</th></tr></thead>
|
||||
<tbody id="peers"></tbody>
|
||||
</table>
|
||||
<details id="peerbox" open>
|
||||
<summary><span class="heading">Devices on your tailnet</span><span class="count" id="peercount"></span></summary>
|
||||
<div class="filters">
|
||||
<input type="search" id="peersearch" placeholder="Search name, address, OS, tag or place" aria-label="Search devices" autocomplete="off">
|
||||
<label class="check"><input type="checkbox" id="peeronline"> Online only</label>
|
||||
<label class="check hidden" id="peervpn-row"><input type="checkbox" id="peervpn"> <span id="peervpn-text"></span></label>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Address</th><th>OS</th><th>Status</th></tr></thead>
|
||||
<tbody id="peers"></tbody>
|
||||
</table>
|
||||
<p class="note hidden" id="peernone" style="margin: 12px 0 0">No device matches.</p>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section class="panel hidden" id="streampanel">
|
||||
<h2>Game streaming (Moonlight to Sunshine)</h2>
|
||||
<p class="note">Apps on the PS5 cannot reach tailnet addresses directly. Pick the device that runs Sunshine and its
|
||||
streaming ports are made available on this console. Then, in your Moonlight client on the PS5 (for example
|
||||
ProsperoLight), add the host <code>127.0.0.1</code>.</p>
|
||||
<p class="note">Apps on the PS5 cannot reach tailnet addresses directly. List the devices that run Sunshine and
|
||||
their streaming ports are made available on this console. In your Moonlight client on the PS5 (for example
|
||||
ProsperoLight), add the address shown for each host.</p>
|
||||
<div id="hostrows"></div>
|
||||
<datalist id="peernames"></datalist>
|
||||
<div class="actions">
|
||||
<label class="field">Sunshine host
|
||||
<select id="sunshine-select"></select>
|
||||
</label>
|
||||
<button id="btn-addhost" class="small">Add a host</button>
|
||||
<button id="btn-sunshine">Save</button>
|
||||
</div>
|
||||
<p class="note" id="sunshine-state"></p>
|
||||
<p class="hint">Port is Sunshine's own port setting; leave it empty for the default (47989). Two hosts need
|
||||
different ports.</p>
|
||||
<p class="note" id="sunshine-state" style="margin: 12px 0 0"></p>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<details id="settingsbox">
|
||||
<summary><span class="heading">Settings</span></summary>
|
||||
<form id="settingsform">
|
||||
<label class="field">Name on the tailnet
|
||||
<input type="text" id="set-hostname" maxlength="63" autocomplete="off">
|
||||
</label>
|
||||
<label class="field">Password for this page <span id="set-pwstate"></span>
|
||||
<input type="password" id="set-password" autocomplete="new-password" placeholder="Leave empty to keep it as it is">
|
||||
<span class="hint">Asked on every device except the console itself. If you forget it, delete
|
||||
<code>passwordHash</code> from <code>/data/tailscale/config.json</code>.</span>
|
||||
</label>
|
||||
<label class="check hidden" id="set-pwremove-row"><input type="checkbox" id="set-pwremove"> Remove the password</label>
|
||||
<label class="field">UDP ports reachable from the tailnet
|
||||
<input type="text" id="set-udp" autocomplete="off">
|
||||
<span class="hint">Comma separated. 9295, 9296, 9297, 9302 are Remote Play's. Empty turns inbound UDP off.</span>
|
||||
</label>
|
||||
<label class="field">TCP ports never exposed to the tailnet
|
||||
<input type="text" id="set-blocked" autocomplete="off">
|
||||
<span class="hint">Comma separated. Every other open TCP port on the console is reachable.</span>
|
||||
</label>
|
||||
<label class="field">Extra forwards from the console to tailnet hosts
|
||||
<textarea id="set-forwards" spellcheck="false"></textarea>
|
||||
<span class="hint">One per line: <code>tcp 127.0.0.1:8096 my-nas:8096</code> (protocol, local address, tailnet host and port).</span>
|
||||
</label>
|
||||
<label class="field">HTTP proxy address
|
||||
<input type="text" id="set-proxy" autocomplete="off" placeholder="Off">
|
||||
<span class="hint">Empty is off. Example: <code>127.0.0.1:8118</code>. Do not set it as the PS5's system proxy.</span>
|
||||
</label>
|
||||
<label class="field">Priority
|
||||
<select id="set-priority">
|
||||
<option value="low">Low: never takes time from a game (default)</option>
|
||||
<option value="high">High: shares the CPU with games; smoother Remote Play</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">Status page address
|
||||
<input type="text" id="set-webaddr" autocomplete="off">
|
||||
</label>
|
||||
<label class="check"><input type="checkbox" id="set-updates"> Check GitHub for new releases</label>
|
||||
<label class="check"><input type="checkbox" id="set-verbose"> Verbose log</label>
|
||||
<div class="actions"><button type="submit">Save settings</button></div>
|
||||
<p class="okmsg hidden" id="settingsok"></p>
|
||||
<p class="msg hidden" id="settingsmsg"></p>
|
||||
</form>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
@@ -109,16 +203,19 @@
|
||||
<button id="btn-logout" class="danger">Log out</button>
|
||||
<button id="btn-quit" class="danger">Stop Tailscale</button>
|
||||
<button id="btn-uninstall" class="danger">Uninstall</button>
|
||||
<button id="btn-lock" class="hidden">Lock this page</button>
|
||||
</div>
|
||||
<p class="okmsg hidden" id="actionok"></p>
|
||||
<p class="msg hidden" id="actionmsg"></p>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<details id="logbox">
|
||||
<summary>Recent log</summary>
|
||||
<summary><span class="heading">Recent log</span></summary>
|
||||
<pre id="logs"></pre>
|
||||
</details>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
@@ -132,6 +229,30 @@ const labels = {
|
||||
Running: ['Connected', 'ok'],
|
||||
};
|
||||
let shownQR = '';
|
||||
let hostsDirty = false; // the user is editing the Sunshine hosts
|
||||
let lastHosts = '';
|
||||
|
||||
// api performs a request to the daemon. A 401 means the page is locked.
|
||||
async function api(path, options) {
|
||||
options = options || {};
|
||||
options.cache = 'no-store';
|
||||
options.headers = Object.assign({'X-PS5-Tailscale': '1'}, options.headers || {});
|
||||
const r = await fetch(path, options);
|
||||
if (r.status === 401) { showLocked(true); throw new Error('locked'); }
|
||||
return r;
|
||||
}
|
||||
|
||||
function showLocked(locked) {
|
||||
$('lockpanel').classList.toggle('hidden', !locked);
|
||||
$('content').classList.toggle('hidden', locked);
|
||||
if (locked) $('updatepanel').classList.add('hidden');
|
||||
}
|
||||
|
||||
function show(id, text) {
|
||||
const el = $(id);
|
||||
el.textContent = text || '';
|
||||
el.classList.toggle('hidden', !text);
|
||||
}
|
||||
|
||||
function row(dl, name, value, mono) {
|
||||
const dt = document.createElement('dt'); dt.textContent = name;
|
||||
@@ -141,33 +262,116 @@ function row(dl, name, value, mono) {
|
||||
dl.append(dt, dd);
|
||||
}
|
||||
|
||||
function hostRow(host, port) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'hostrow';
|
||||
const h = document.createElement('input');
|
||||
h.type = 'text'; h.className = 'host'; h.placeholder = 'Device name or address'; h.value = host || '';
|
||||
h.setAttribute('list', 'peernames'); h.setAttribute('aria-label', 'Sunshine host'); h.autocomplete = 'off';
|
||||
const p = document.createElement('input');
|
||||
p.type = 'number'; p.className = 'port'; p.placeholder = '47989'; p.min = 1029; p.max = 65514;
|
||||
p.value = port && port !== 47989 ? port : ''; p.setAttribute('aria-label', 'Sunshine port');
|
||||
const x = document.createElement('button');
|
||||
x.type = 'button'; x.className = 'small'; x.textContent = 'Remove';
|
||||
x.onclick = () => { div.remove(); hostsDirty = true; };
|
||||
h.oninput = p.oninput = () => { hostsDirty = true; };
|
||||
div.append(h, p, x);
|
||||
return div;
|
||||
}
|
||||
|
||||
// The device list. Peers come sorted from the daemon (online first); the page
|
||||
// groups them by kind and applies the filters. Exit servers of a VPN add-on
|
||||
// can be hundreds, so they are only requested while their box is ticked.
|
||||
const kinds = [['own', 'This tailnet'], ['shared', 'Shared with you'], ['vpn', 'VPN exit servers']];
|
||||
let peers = [];
|
||||
let shownPeers = '';
|
||||
|
||||
function stored(key) { try { return localStorage.getItem(key); } catch (e) { return null; } }
|
||||
function store(key, value) { try { localStorage.setItem(key, value); } catch (e) {} }
|
||||
|
||||
function peerMatches(p, words) {
|
||||
const text = [p.name, p.ip, p.os, p.location || '', (p.tags || []).join(' '), p.exitNode ? 'exit node' : '',
|
||||
p.online ? 'online' : 'offline'].join(' ').toLowerCase();
|
||||
return words.every(w => text.includes(w));
|
||||
}
|
||||
|
||||
function peerRow(p) {
|
||||
const tr = document.createElement('tr');
|
||||
const notes = [];
|
||||
if (p.exitNode === 'used') notes.push('exit node in use');
|
||||
else if (p.exitNode) notes.push('exit node');
|
||||
if (p.location) notes.push(p.location);
|
||||
if (p.tags) notes.push(p.tags.join(', '));
|
||||
for (const [v, mono, note] of [[p.name, false, notes.join(' · ')], [p.ip, true], [p.os], [p.online ? 'online' : 'offline']]) {
|
||||
const td = document.createElement('td');
|
||||
if (!p.online) td.className = 'off';
|
||||
if (mono) { const c = document.createElement('code'); c.textContent = v; td.append(c); } else td.textContent = v;
|
||||
if (note) { const n = document.createElement('span'); n.className = 'tag'; n.textContent = note; td.append(n); }
|
||||
tr.append(td);
|
||||
}
|
||||
return tr;
|
||||
}
|
||||
|
||||
function renderPeers() {
|
||||
const words = $('peersearch').value.toLowerCase().split(/\s+/).filter(w => w);
|
||||
const onlineOnly = $('peeronline').checked;
|
||||
const shown = peers.filter(p => (!onlineOnly || p.online) && peerMatches(p, words));
|
||||
// Rebuilding hundreds of rows every few seconds is wasteful; only do it
|
||||
// when what is shown changed.
|
||||
const key = JSON.stringify(shown);
|
||||
if (key === shownPeers) return;
|
||||
shownPeers = key;
|
||||
const groups = kinds.map(([kind, title]) => [title, shown.filter(p => p.kind === kind)]).filter(g => g[1].length);
|
||||
const rows = [];
|
||||
for (const [title, list] of groups) {
|
||||
if (groups.length > 1) {
|
||||
const tr = document.createElement('tr'); tr.className = 'group';
|
||||
const td = document.createElement('td'); td.colSpan = 4;
|
||||
td.textContent = title + ' (' + list.length + ')';
|
||||
tr.append(td); rows.push(tr);
|
||||
}
|
||||
rows.push(...list.map(peerRow));
|
||||
}
|
||||
$('peers').replaceChildren(...rows);
|
||||
$('peernone').classList.toggle('hidden', shown.length > 0);
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
let s;
|
||||
try {
|
||||
s = await (await fetch('/api/status', {cache: 'no-store'})).json();
|
||||
s = await (await api('/api/status' + ($('peervpn').checked ? '?vpn=1' : ''))).json();
|
||||
} catch (e) {
|
||||
$('state').textContent = 'Not responding';
|
||||
$('dot').className = 'dot bad';
|
||||
if (e.message !== 'locked') {
|
||||
$('state').textContent = 'Not responding';
|
||||
$('dot').className = 'dot bad';
|
||||
}
|
||||
return;
|
||||
}
|
||||
showLocked(false);
|
||||
const [label, cls] = labels[s.state] || [s.state, 'warn'];
|
||||
$('state').textContent = label;
|
||||
$('dot').className = 'dot ' + cls;
|
||||
$('version').textContent = 'ps5-tailscale ' + s.version;
|
||||
|
||||
$('updatepanel').classList.toggle('hidden', !s.latestVersion);
|
||||
if (s.latestVersion) {
|
||||
$('updatetext').textContent = 'Version ' + s.latestVersion + ' is available. ';
|
||||
$('updatelink').href = s.updateURL;
|
||||
}
|
||||
|
||||
const dl = $('facts');
|
||||
dl.replaceChildren();
|
||||
row(dl, 'Name', s.dnsName || s.hostname);
|
||||
if (s.ips.length) row(dl, 'Tailnet address', s.ips.join(', '), true);
|
||||
if (s.tailnet) row(dl, 'Tailnet', s.tailnet);
|
||||
if (s.ips.length) row(dl, 'Reachable from tailnet', 'every open TCP port' + (s.udpPorts.length ? '; UDP ' + s.udpPorts.join(', ') + ' (Remote Play)' : ''));
|
||||
if (s.proxy) row(dl, 'HTTP proxy', s.proxy, true);
|
||||
if (s.priority === 'high') row(dl, 'Priority', 'High');
|
||||
|
||||
const health = $('health');
|
||||
health.replaceChildren(...(s.health || []).map(h => { const li = document.createElement('li'); li.textContent = h; return li; }));
|
||||
health.classList.toggle('hidden', !(s.health || []).length);
|
||||
|
||||
$('error').textContent = s.error || '';
|
||||
$('error').classList.toggle('hidden', !s.error);
|
||||
show('error', s.error);
|
||||
|
||||
const needLogin = !!s.authURL;
|
||||
$('login').classList.toggle('hidden', !needLogin);
|
||||
@@ -177,74 +381,160 @@ async function refresh() {
|
||||
if (shownQR !== s.authURL) { shownQR = s.authURL; $('qr').src = '/qr.png?' + Date.now(); }
|
||||
}
|
||||
|
||||
const tbody = $('peers');
|
||||
tbody.replaceChildren(...s.peers.map(p => {
|
||||
const tr = document.createElement('tr');
|
||||
for (const [v, mono] of [[p.name], [p.ip, true], [p.os], [p.online ? 'online' : 'offline']]) {
|
||||
const td = document.createElement('td');
|
||||
if (!p.online) td.className = 'off';
|
||||
if (mono) { const c = document.createElement('code'); c.textContent = v; td.append(c); } else td.textContent = v;
|
||||
tr.append(td);
|
||||
}
|
||||
return tr;
|
||||
}));
|
||||
$('peerpanel').classList.toggle('hidden', !s.peers.length);
|
||||
peers = s.peers;
|
||||
const devices = peers.filter(p => p.kind !== 'vpn');
|
||||
$('peerpanel').classList.toggle('hidden', !peers.length && !s.vpnServers.total);
|
||||
$('peercount').textContent = devices.filter(p => p.online).length + ' online of ' + devices.length;
|
||||
$('peervpn-row').classList.toggle('hidden', !s.vpnServers.total);
|
||||
$('peervpn-text').textContent = 'Show VPN exit servers (' + s.vpnServers.total + ')';
|
||||
renderPeers();
|
||||
|
||||
// Game streaming: offer the tailnet's devices, keep the user's selection
|
||||
// while they are choosing.
|
||||
$('streampanel').classList.toggle('hidden', s.state !== 'Running' && !s.sunshineHost);
|
||||
const sel = $('sunshine-select');
|
||||
const options = ['', ...s.peers.map(p => p.name)];
|
||||
if (s.sunshineHost && !options.includes(s.sunshineHost)) options.push(s.sunshineHost);
|
||||
const signature = options.join('|') + '#' + s.sunshineHost;
|
||||
if (sel.dataset.signature !== signature && document.activeElement !== sel) {
|
||||
sel.replaceChildren(...options.map(name => {
|
||||
const o = document.createElement('option');
|
||||
o.value = name;
|
||||
const peer = s.peers.find(p => p.name === name);
|
||||
o.textContent = name === '' ? 'None (off)' : name + (peer && !peer.online ? ' (offline)' : '');
|
||||
return o;
|
||||
}));
|
||||
sel.value = s.sunshineHost;
|
||||
sel.dataset.signature = signature;
|
||||
// Game streaming. The rows are only rebuilt from the daemon's state while
|
||||
// the user is not in the middle of editing them.
|
||||
$('streampanel').classList.toggle('hidden', s.state !== 'Running' && !s.sunshineHosts.length);
|
||||
$('peernames').replaceChildren(...devices.map(p => { const o = document.createElement('option'); o.value = p.name; return o; }));
|
||||
const hostsNow = JSON.stringify(s.sunshineHosts);
|
||||
if (!hostsDirty && hostsNow !== lastHosts) {
|
||||
lastHosts = hostsNow;
|
||||
$('hostrows').replaceChildren(...s.sunshineHosts.map(h => hostRow(h.host, h.port)));
|
||||
}
|
||||
$('sunshine-state').textContent = s.sunshineHost
|
||||
? 'Forwarding 127.0.0.1 to ' + s.sunshineHost + ' (' + s.forwards.length + ' ports).'
|
||||
: 'Off.';
|
||||
$('sunshine-state').textContent = s.sunshineHosts.length
|
||||
? s.sunshineHosts.map(h => h.host + ': add ' + h.address + ' in Moonlight').join('. ') + '.'
|
||||
: 'No Sunshine host is forwarded.';
|
||||
|
||||
$('btn-lock').classList.toggle('hidden', !s.passwordSet);
|
||||
|
||||
if ($('logbox').open) {
|
||||
try { $('logs').textContent = await (await fetch('/api/logs', {cache: 'no-store'})).text(); } catch (e) {}
|
||||
try { $('logs').textContent = await (await api('/api/logs')).text(); } catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
async function act(path, confirmText) {
|
||||
async function act(path, confirmText, body) {
|
||||
if (confirmText && !confirm(confirmText)) return;
|
||||
const msg = $('actionmsg');
|
||||
msg.classList.add('hidden');
|
||||
show('actionmsg', ''); show('actionok', '');
|
||||
try {
|
||||
const r = await fetch(path, {method: 'POST', headers: {'X-PS5-Tailscale': '1'}});
|
||||
if (!r.ok) throw new Error((await r.text()).trim() || r.statusText);
|
||||
const options = {method: 'POST'};
|
||||
if (body !== undefined) { options.body = JSON.stringify(body); options.headers = {'Content-Type': 'application/json'}; }
|
||||
const r = await api(path, options);
|
||||
const text = (await r.text()).trim();
|
||||
if (!r.ok) throw new Error(text || r.statusText);
|
||||
return text;
|
||||
} catch (e) {
|
||||
msg.textContent = e.message;
|
||||
msg.classList.remove('hidden');
|
||||
if (e.message !== 'locked') show('actionmsg', e.message);
|
||||
} finally {
|
||||
refresh();
|
||||
}
|
||||
refresh();
|
||||
}
|
||||
|
||||
$('btn-login').onclick = () => act('/api/login');
|
||||
$('btn-logout').onclick = () => act('/api/logout', 'Log this PS5 out of your tailnet?');
|
||||
$('btn-quit').onclick = () => act('/api/quit', 'Stop Tailscale on this PS5? Send the payload again to start it.');
|
||||
$('btn-uninstall').onclick = async () => {
|
||||
const text = await act('/api/uninstall',
|
||||
'Remove Tailscale from this PS5?\n\nThis removes the home screen icon, logs the console out of your tailnet, deletes its settings and logs, and stops Tailscale.');
|
||||
if (text) show('actionok', text);
|
||||
};
|
||||
$('btn-lock').onclick = async () => { await act('/api/lock'); showLocked(true); };
|
||||
|
||||
$('lockform').onsubmit = async ev => {
|
||||
ev.preventDefault();
|
||||
show('lockmsg', '');
|
||||
const r = await fetch('/api/auth', {
|
||||
method: 'POST',
|
||||
headers: {'X-PS5-Tailscale': '1', 'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({password: $('lockpw').value}),
|
||||
});
|
||||
if (!r.ok) { show('lockmsg', r.status === 403 ? 'Wrong password.' : (await r.text()).trim()); return; }
|
||||
$('lockpw').value = '';
|
||||
refresh();
|
||||
};
|
||||
|
||||
// Game streaming hosts.
|
||||
$('btn-addhost').onclick = () => { $('hostrows').append(hostRow('', 0)); hostsDirty = true; };
|
||||
$('btn-sunshine').onclick = async () => {
|
||||
await act('/api/sunshine?host=' + encodeURIComponent($('sunshine-select').value));
|
||||
$('sunshine-select').dataset.signature = '';
|
||||
const hosts = [];
|
||||
for (const div of $('hostrows').children) {
|
||||
const host = div.querySelector('.host').value.trim();
|
||||
const port = parseInt(div.querySelector('.port').value, 10) || 0;
|
||||
if (host) hosts.push({host: host, port: port});
|
||||
}
|
||||
hostsDirty = false; lastHosts = '';
|
||||
await act('/api/sunshine', '', hosts);
|
||||
};
|
||||
$('btn-uninstall').onclick = () => {
|
||||
if (!confirm('Remove Tailscale from this PS5? It stops now and its payload is deleted.')) return;
|
||||
const purge = confirm('Also log out and delete the saved login?\n\nOK: delete everything.\nCancel: keep the login, so reinstalling reconnects without logging in again.');
|
||||
act('/api/uninstall' + (purge ? '?purge=1' : ''));
|
||||
|
||||
// Settings.
|
||||
const ports = text => text.split(/[\s,]+/).filter(x => x).map(x => parseInt(x, 10));
|
||||
async function loadSettings() {
|
||||
let c;
|
||||
try { c = await (await api('/api/config')).json(); } catch (e) { return; }
|
||||
$('set-hostname').value = c.hostname;
|
||||
$('set-pwstate').textContent = c.passwordSet ? '(set)' : '(not set)';
|
||||
$('set-password').value = '';
|
||||
$('set-pwremove').checked = false;
|
||||
$('set-pwremove-row').classList.toggle('hidden', !c.passwordSet);
|
||||
$('set-udp').value = c.udpPorts.join(', ');
|
||||
$('set-blocked').value = c.blockedPorts.join(', ');
|
||||
$('set-forwards').value = c.forwards.map(f => f.proto + ' ' + f.listen + ' ' + f.target).join('\n');
|
||||
$('set-proxy').value = c.httpProxyAddr;
|
||||
$('set-priority').value = c.priority;
|
||||
$('set-webaddr').value = c.webAddr;
|
||||
$('set-updates').checked = c.checkUpdates;
|
||||
$('set-verbose').checked = c.verbose;
|
||||
}
|
||||
$('settingsbox').addEventListener('toggle', () => { if ($('settingsbox').open) loadSettings(); });
|
||||
if (location.hash === '#settings') $('settingsbox').open = true;
|
||||
$('settingsform').onsubmit = async ev => {
|
||||
ev.preventDefault();
|
||||
show('settingsmsg', ''); show('settingsok', '');
|
||||
const udp = ports($('set-udp').value), blocked = ports($('set-blocked').value);
|
||||
if (udp.concat(blocked).some(p => !(p >= 1 && p <= 65535))) { show('settingsmsg', 'Ports must be numbers from 1 to 65535.'); return; }
|
||||
const forwards = [];
|
||||
for (const line of $('set-forwards').value.split('\n').map(l => l.trim()).filter(l => l)) {
|
||||
const parts = line.split(/\s+/);
|
||||
if (parts.length !== 3) { show('settingsmsg', 'Each forward needs three parts: protocol, local address, target. Problem: ' + line); return; }
|
||||
forwards.push({proto: parts[0].toLowerCase(), listen: parts[1], target: parts[2]});
|
||||
}
|
||||
const c = {
|
||||
hostname: $('set-hostname').value.trim(),
|
||||
webAddr: $('set-webaddr').value.trim(),
|
||||
httpProxyAddr: $('set-proxy').value.trim(),
|
||||
forwards: forwards,
|
||||
udpPorts: udp,
|
||||
blockedPorts: blocked,
|
||||
priority: $('set-priority').value,
|
||||
checkUpdates: $('set-updates').checked,
|
||||
verbose: $('set-verbose').checked,
|
||||
};
|
||||
if ($('set-pwremove').checked) c.password = '';
|
||||
else if ($('set-password').value) c.password = $('set-password').value;
|
||||
try {
|
||||
const r = await api('/api/config', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(c)});
|
||||
if (!r.ok) throw new Error((await r.text()).trim() || r.statusText);
|
||||
const reply = await r.json();
|
||||
let text = 'Saved.';
|
||||
if (reply.restart && reply.restart.length) text += ' Takes effect when Tailscale is started again: ' + reply.restart.join(', ') + '.';
|
||||
show('settingsok', text);
|
||||
if (reply.problems && reply.problems.length) show('settingsmsg', reply.problems.join(' '));
|
||||
loadSettings();
|
||||
refresh();
|
||||
} catch (e) {
|
||||
if (e.message !== 'locked') show('settingsmsg', e.message);
|
||||
}
|
||||
};
|
||||
|
||||
$('logbox').addEventListener('toggle', refresh);
|
||||
|
||||
// The device list can be long. Remember whether it was left collapsed and
|
||||
// how it was filtered; the browser may not allow storage, in which case it
|
||||
// simply starts open and unfiltered.
|
||||
if (stored('peersCollapsed') === '1') $('peerbox').open = false;
|
||||
$('peerbox').addEventListener('toggle', () => store('peersCollapsed', $('peerbox').open ? '0' : '1'));
|
||||
$('peeronline').checked = stored('peersOnlineOnly') === '1';
|
||||
$('peervpn').checked = stored('peersShowVPN') === '1';
|
||||
$('peersearch').oninput = renderPeers;
|
||||
$('peeronline').onchange = () => { store('peersOnlineOnly', $('peeronline').checked ? '1' : '0'); renderPeers(); };
|
||||
$('peervpn').onchange = () => { store('peersShowVPN', $('peervpn').checked ? '1' : '0'); refresh(); };
|
||||
|
||||
refresh();
|
||||
setInterval(refresh, 3000);
|
||||
</script>
|
||||
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A UDP relay sits between a listening socket and a target. Every client
|
||||
// address that sends to the socket gets its own connection to the target, so
|
||||
// the target's replies find their way back to the right client. It is used in
|
||||
// both directions: console apps to a tailnet host (local forwards) and
|
||||
// tailnet devices to a service on the console (inbound UDP).
|
||||
|
||||
// UDP flows that have been silent this long are forgotten.
|
||||
const udpIdleTimeout = 2 * time.Minute
|
||||
|
||||
type udpRelayConfig struct {
|
||||
name string
|
||||
// listen opens the socket clients send to. It is called again if the
|
||||
// socket fails, which on the PS5 happens when the network is
|
||||
// reconfigured.
|
||||
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)
|
||||
}
|
||||
|
||||
// udpFlow is the relay state for one client address.
|
||||
type udpFlow struct {
|
||||
out chan []byte // datagrams from the client waiting to go to the target
|
||||
lastSeen atomic.Int64
|
||||
}
|
||||
|
||||
func (fl *udpFlow) touch() { fl.lastSeen.Store(time.Now().UnixNano()) }
|
||||
|
||||
func (fl *udpFlow) idle() bool {
|
||||
return time.Since(time.Unix(0, fl.lastSeen.Load())) > udpIdleTimeout
|
||||
}
|
||||
|
||||
type udpRelay struct {
|
||||
cfg udpRelayConfig
|
||||
|
||||
mu sync.Mutex
|
||||
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) (*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, 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()
|
||||
r.closed = true
|
||||
r.sock.Close()
|
||||
}
|
||||
|
||||
// socket returns the current listening socket and whether the relay has been
|
||||
// stopped.
|
||||
func (r *udpRelay) socket() (net.PacketConn, bool) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.sock, r.closed
|
||||
}
|
||||
|
||||
func (r *udpRelay) readLoop() {
|
||||
defer r.ended.Store(true)
|
||||
buf := make([]byte, 65535)
|
||||
for {
|
||||
sock, stopped := r.socket()
|
||||
if stopped {
|
||||
return
|
||||
}
|
||||
n, from, err := sock.ReadFrom(buf)
|
||||
if err != nil {
|
||||
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)
|
||||
if reopened, err := r.cfg.listen(); err == nil {
|
||||
r.mu.Lock()
|
||||
if r.closed {
|
||||
reopened.Close()
|
||||
} else {
|
||||
r.sock = reopened
|
||||
}
|
||||
r.mu.Unlock()
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
key := from.String()
|
||||
r.mu.Lock()
|
||||
fl := r.flows[key]
|
||||
if fl == nil {
|
||||
fl = &udpFlow{out: make(chan []byte, 256)}
|
||||
r.flows[key] = fl
|
||||
go r.serveFlow(key, fl, from)
|
||||
}
|
||||
r.mu.Unlock()
|
||||
|
||||
fl.touch()
|
||||
select {
|
||||
case fl.out <- append([]byte(nil), buf[:n]...):
|
||||
default: // the target is not keeping up; UDP may drop
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// serveFlow relays one client's datagrams to the target and the replies
|
||||
// back, until the flow goes quiet or the relay is stopped.
|
||||
func (r *udpRelay) serveFlow(key string, fl *udpFlow, client net.Addr) {
|
||||
defer func() {
|
||||
r.mu.Lock()
|
||||
if r.flows[key] == fl {
|
||||
delete(r.flows, key)
|
||||
}
|
||||
r.mu.Unlock()
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
up, err := r.cfg.dial(ctx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
r.cfg.logf("%s: %v", r.cfg.name, err)
|
||||
return
|
||||
}
|
||||
defer up.Close()
|
||||
|
||||
// Replies: target -> client.
|
||||
go func() {
|
||||
buf := make([]byte, 65535)
|
||||
for {
|
||||
up.SetReadDeadline(time.Now().Add(udpIdleTimeout))
|
||||
n, err := up.Read(buf)
|
||||
if err != nil {
|
||||
var ne net.Error
|
||||
if errors.As(err, &ne) && ne.Timeout() && !fl.idle() {
|
||||
continue
|
||||
}
|
||||
return
|
||||
}
|
||||
fl.touch()
|
||||
if sock, stopped := r.socket(); !stopped {
|
||||
sock.WriteTo(buf[:n], client)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
idle := time.NewTicker(udpIdleTimeout / 4)
|
||||
defer idle.Stop()
|
||||
for {
|
||||
select {
|
||||
case b := <-fl.out:
|
||||
if _, err := up.Write(b); err != nil {
|
||||
return
|
||||
}
|
||||
case <-idle.C:
|
||||
if _, stopped := r.socket(); stopped || fl.idle() {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// daemonFileName is the name the installer stores the daemon payload under,
|
||||
// in the data directory.
|
||||
const daemonFileName = "tailscale.elf"
|
||||
|
||||
// uninstall deletes the installed daemon payload. With purge it also deletes
|
||||
// the Tailscale state and config, which forgets the login. The running
|
||||
// process is not affected.
|
||||
func uninstall(purge bool) error {
|
||||
var errs []error
|
||||
if err := os.Remove(filepath.Join(dataDir, daemonFileName)); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
if purge {
|
||||
for _, name := range []string{"state", "config.json", ".cache"} {
|
||||
if err := os.RemoveAll(filepath.Join(dataDir, name)); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"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.
|
||||
|
||||
const releasesAPI = "https://api.github.com/repos/holdmysocks/ps5-tailscale/releases/latest"
|
||||
|
||||
type releaseInfo struct {
|
||||
Version string // without the leading "v"
|
||||
URL 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"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
|
||||
return releaseInfo{}, err
|
||||
}
|
||||
return releaseInfo{Version: strings.TrimPrefix(rel.TagName, "v"), URL: rel.HTMLURL}, 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+143
-88
@@ -8,7 +8,6 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -18,17 +17,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,49 +47,70 @@ 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"`
|
||||
Uptime int64 `json:"uptimeSeconds"`
|
||||
// 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"`
|
||||
// UDPPorts are the console's UDP ports reachable from the tailnet.
|
||||
UDPPorts []uint16 `json:"udpPorts"`
|
||||
Priority string `json:"priority"`
|
||||
// PasswordSet says whether the page is password protected.
|
||||
PasswordSet bool `json:"passwordSet"`
|
||||
// LatestVersion and UpdateURL are set when a newer release exists.
|
||||
LatestVersion string `json:"latestVersion,omitempty"`
|
||||
UpdateURL string `json:"updateURL,omitempty"`
|
||||
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))
|
||||
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,
|
||||
} {
|
||||
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)
|
||||
}
|
||||
@@ -124,21 +150,52 @@ 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,
|
||||
PasswordSet: d.cfg.PasswordHash != "",
|
||||
Uptime: int64(time.Since(d.started).Seconds()),
|
||||
IPs: []string{},
|
||||
Peers: []peerInfo{},
|
||||
SunshineHosts: []sunshineInfo{},
|
||||
}
|
||||
if d.cfg.Priority == priorityHigh {
|
||||
info.Priority = priorityHigh
|
||||
}
|
||||
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.SunshineHost = d.cfg.SunshineHost
|
||||
d.mu.Unlock()
|
||||
info.UDPPorts = []uint16{}
|
||||
if d.udp != nil {
|
||||
info.UDPPorts = append(info.UDPPorts, d.udp.activePorts()...)
|
||||
}
|
||||
info.Forwards = []string{}
|
||||
for _, r := range d.fwd.rules() {
|
||||
info.Forwards = append(info.Forwards, r.String())
|
||||
@@ -163,22 +220,7 @@ func (d *daemon) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
info.IPs = append(info.IPs, ip.String())
|
||||
}
|
||||
}
|
||||
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" {
|
||||
@@ -248,23 +290,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
|
||||
@@ -296,30 +347,34 @@ func (d *daemon) stop() {
|
||||
d.quitOnce.Do(func() { close(d.quit) })
|
||||
}
|
||||
|
||||
// handleUninstall removes the installed payload, then stops. With ?purge=1 it
|
||||
// first logs the console out of the tailnet and deletes the saved state as
|
||||
// well.
|
||||
// handleUninstall takes the home screen icon away, logs the console out of
|
||||
// the tailnet and stops the daemon, which deletes its data directory (login,
|
||||
// settings, logs) on the way out. The payload file itself is wherever the
|
||||
// user keeps it.
|
||||
func (d *daemon) handleUninstall(w http.ResponseWriter, r *http.Request) {
|
||||
purge := r.URL.Query().Get("purge") == "1"
|
||||
if purge && d.lc != nil {
|
||||
d.logf("uninstall requested from the status page")
|
||||
iconNote := "The home screen icon was removed."
|
||||
if err := removeHomeIcon(); err != nil {
|
||||
d.logf("uninstall: home screen icon: %v", err)
|
||||
iconNote = "The home screen icon could not be removed (" + err.Error() + "); delete it from the home screen."
|
||||
}
|
||||
if d.lc != nil {
|
||||
if err := d.lc.Logout(r.Context()); err != nil {
|
||||
d.logf("uninstall: logout: %v", err)
|
||||
}
|
||||
}
|
||||
if err := uninstall(purge); err != nil {
|
||||
d.logf("uninstall: %v", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
d.logf("uninstalled (purge=%v)", purge)
|
||||
d.mu.Lock()
|
||||
d.removeDataOnExit = true
|
||||
d.mu.Unlock()
|
||||
notify("Tailscale was removed from this PS5.")
|
||||
io.WriteString(w, "uninstalled\n")
|
||||
io.WriteString(w, "Tailscale was removed from this PS5. "+iconNote+"\n")
|
||||
d.stop()
|
||||
}
|
||||
|
||||
// stopRunningInstance asks an instance that is already serving the status
|
||||
// page to exit and waits for the port to become free. It reports whether
|
||||
// there was one.
|
||||
// there was one. The request comes from the console itself, so it needs no
|
||||
// password.
|
||||
func stopRunningInstance(webAddr string) bool {
|
||||
_, port, err := net.SplitHostPort(webAddr)
|
||||
if err != nil {
|
||||
|
||||
Reference in new issue
Block a user