4 Commits
Author SHA1 Message Date
holdmysocks ed67b35b52 Add a password, settings page and several streaming hosts
The status page can now be protected with a password and edits the
settings itself, so the config file no longer has to be changed by hand.

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

Uninstall on the status page now logs out, stops the daemon and deletes its
data directory.
2026-10-02 12:26:10 -04:00
holdmysocks 01b6381444 Forward UDP from the tailnet for Remote Play
The console's Remote Play service uses UDP 9295, 9296, 9297 and 9302 next to
TCP 9295. tsnet has no catch-all for UDP, so the daemon now listens on the
ports in the new udpPorts setting (those four by default) on its tailnet
addresses and relays them to localhost. The UDP relay is shared with the
local forwards.
2026-10-02 11:44:32 -04:00
holdmysocks 0156e4e210 Stop registering with payload autoloaders
The installer no longer adds the daemon to Payload Manager's or
ps5_autoloader's load order. It writes /data/tailscale/tailscale.elf, adds
the home screen icon and starts the daemon; starting it after a reboot is
left to the user. The daemon's status page and Uninstall no longer look at
autoloaders either.
2026-10-02 10:39:34 -04:00
36 changed files with 2837 additions and 1003 deletions

No files matched your search

+156 -74
View File
@@ -5,10 +5,12 @@ 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.
- **Starts by itself** after each jailbreak if you use a payload autoloader,
and adds a home screen icon for its status page.
- **Home screen icon** that opens its status page.
It runs the real Tailscale client code (v1.104.0) as a single payload.
@@ -36,42 +38,41 @@ VPN here. It runs inside one process:
- A jailbroken PS5 with an ELF loader listening on port 9021
(for example [elfldr](https://github.com/ps5-payload-dev/elfldr)).
- A Tailscale account.
- Optional: a payload autoloader, so Tailscale starts after every jailbreak.
[Payload Manager](https://github.com/itsPLK/ps5-payload-manager) and
`ps5_autoloader` folders are detected.
Tested on firmware 13.42 with elfldr 0.26 and Payload Manager 0.5.2.
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 (`tailscale.elf`),
- adds it to the end of your autoloader's load order, if it finds one,
- 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.
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.
Sending the installer again upgrades and restarts Tailscale. The login is
kept.
Tailscale runs until the console restarts. After a restart and jailbreak,
send `tailscale.elf` again; the login and settings are kept. If you use a
payload manager or autoloader, add the file there like any other payload.
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 +83,58 @@ 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.
**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 +143,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 +180,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 Payload Manager'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 +224,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,58 +245,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/pldmgr/payloads/Tailscale/tailscale.elf` | The daemon, when Payload Manager is used. |
| `/data/tailscale/tailscale.elf` | The daemon, when no autoloader was found. |
| `/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 removes the autoloader entry and
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 `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,
autostart after a reboot through Payload Manager, reaching the console over
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, the `ps5_autoloader` and USB autoloader paths,
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
View File
@@ -0,0 +1,190 @@
/* Home screen icon helper for Tailscale on PS5.
*
* A tiny payload that puts a "Tailscale" icon on the home screen: a media app
* whose only content is a link to the status page, which the console opens in
* its browser. The daemon's launcher carries this payload and hands it to the
* ELF loader the first time Tailscale runs. It is a separate payload so that
* the system libraries it needs are never loaded into the daemon's process.
*
* The same payload removes the icon again when its mode byte says so (see
* icon_mode below); the daemon uses that for Uninstall.
*
* Prints "icon: ok" or "icon: removed" on success; the launcher and the
* daemon look for that.
*
* Build with -DASSET_DIR="path/to/appicon" and link, in this order,
* -lSceIpmi -lSceAppInstUtil -lSceUserService -lSceSystemService (with
* libSceAppInstUtil alone the payload is never started). */
#include <errno.h>
#include <fcntl.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/stat.h>
#include <ps5/kernel.h>
#ifndef ASSET_DIR
#error "ASSET_DIR must name the folder with param.json and icon0.png"
#endif
#define TITLE_ID "TSCL00001"
#define APP_DIR "/user/app/" TITLE_ID
#define INCASSET(name, file) \
__asm__(".section .rodata\n" \
".balign 16\n" \
".global " #name "\n" #name ":\n" \
".incbin \"" file "\"\n" \
".global " #name "_end\n" #name "_end:\n" \
".text\n"); \
extern const uint8_t name[]; \
extern const uint8_t name##_end[];
INCASSET(param_json, ASSET_DIR "/param.json")
INCASSET(icon_png, ASSET_DIR "/icon0.png")
int sceAppInstUtilInitialize(void);
int sceAppInstUtilTerminate(void);
int sceAppInstUtilAppInstallAll(void *);
int sceAppInstUtilAppUnInstall(const char *);
/* What to do. A payload sent to the ELF loader gets no arguments, so the
* mode is a byte in the file itself: the daemon changes the character after
* the '=' to 'R' before sending the helper when it wants the icon removed
* (see tsd/homeicon.go). It is volatile so that the compiler reads it at run
* time instead of baking the install branch in. */
volatile char icon_mode[] = "TSICON-MODE=I";
static int
remove_icon(void) {
int err;
if ((err = sceAppInstUtilInitialize())) {
printf("icon: sceAppInstUtilInitialize failed: 0x%08x\n", err);
return 1;
}
err = sceAppInstUtilAppUnInstall(TITLE_ID);
sceAppInstUtilTerminate();
/* Whatever the system left behind of the folder goes too. */
unlink(APP_DIR "/sce_sys/param.json");
unlink(APP_DIR "/sce_sys/icon0.png");
rmdir(APP_DIR "/sce_sys");
rmdir(APP_DIR);
if (err) {
printf("icon: removing the app failed: 0x%08x\n", err);
return 1;
}
printf("icon: removed\n");
return 0;
}
static int
write_file(const char *path, const uint8_t *data, size_t size) {
int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd < 0) {
return -1;
}
while (size > 0) {
ssize_t n = write(fd, data, size);
if (n < 0) {
if (errno == EINTR) {
continue;
}
close(fd);
return -1;
}
data += n;
size -= n;
}
return close(fd);
}
/* Report whether the file at path already has exactly these contents. */
static int
file_matches(const char *path, const uint8_t *data, size_t size) {
struct stat st;
uint8_t *buf;
int same = 0;
FILE *f;
if (stat(path, &st) || (size_t)st.st_size != size || !(f = fopen(path, "rb"))) {
return 0;
}
if ((buf = malloc(size))) {
same = fread(buf, 1, size, f) == size && !memcmp(buf, data, size);
free(buf);
}
fclose(f);
return same;
}
int
main(void) {
int (*install_title_dir)(const char *, const char *, void *) = 0;
size_t param_size = param_json_end - param_json;
size_t icon_size = icon_png_end - icon_png;
pid_t pid = getpid();
intptr_t rootvnode;
uint32_t handle;
int err;
setvbuf(stdout, 0, _IONBF, 0);
/* /user/app is only writable as root outside the sandbox. */
if ((rootvnode = kernel_get_root_vnode())) {
kernel_set_proc_rootdir(pid, rootvnode);
kernel_set_proc_jaildir(pid, 0);
}
kernel_set_ucred_uid(pid, 0);
kernel_set_ucred_ruid(pid, 0);
kernel_set_ucred_svuid(pid, 0);
kernel_set_ucred_rgid(pid, 0);
kernel_set_ucred_svgid(pid, 0);
if (icon_mode[sizeof(icon_mode) - 2] == 'R') {
return remove_icon();
}
if (file_matches(APP_DIR "/sce_sys/param.json", param_json, param_size) &&
file_matches(APP_DIR "/sce_sys/icon0.png", icon_png, icon_size)) {
printf("icon: ok (already installed)\n");
return 0;
}
if ((err = sceAppInstUtilInitialize())) {
printf("icon: sceAppInstUtilInitialize failed: 0x%08x\n", err);
return 1;
}
mkdir(APP_DIR, 0755);
mkdir(APP_DIR "/sce_sys", 0755);
if (write_file(APP_DIR "/sce_sys/param.json", param_json, param_size) ||
write_file(APP_DIR "/sce_sys/icon0.png", icon_png, icon_size)) {
printf("icon: could not write to %s: %s\n", APP_DIR, strerror(errno));
sceAppInstUtilTerminate();
return 1;
}
/* Register just this title where the firmware supports it; otherwise ask
* for a rescan of everything under /user/app. */
if (!kernel_dynlib_handle(-1, "libSceAppInstUtil.sprx", &handle)) {
install_title_dir = (void *)kernel_dynlib_resolve(-1, handle, "Wudg3Xe3heE");
}
if (install_title_dir) {
err = install_title_dir(TITLE_ID, "/user/app/", 0);
} else {
err = sceAppInstUtilAppInstallAll(0);
}
sceAppInstUtilTerminate();
if (err) {
printf("icon: registering the app failed: 0x%08x\n", err);
return 1;
}
printf("icon: ok (installed %s)\n", TITLE_ID);
return 0;
}
File renamed without changes.
+6 -6
View File
@@ -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.2.1
# 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.4.1 -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 -21
View File
@@ -27,20 +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 stores it
where the console's autoloader looks, appends it to the load order, 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`.
**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
@@ -125,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
@@ -136,17 +201,6 @@ Linking `libSceAppInstUtil` alone leaves the payload stopped before it runs.
It needs `-lSceIpmi -lSceAppInstUtil -lSceUserService -lSceSystemService`, in
that order, as in the SDK's `install_app` sample.
## Autoloaders
- Payload Manager: load order in `/data/pldmgr/autoload.txt`, one file name
per line, `!N` for a delay in milliseconds. It finds a named file anywhere
below `/data/pldmgr` and launches it by streaming it to `127.0.0.1:9021`.
- `ps5_autoloader`: `autoload.txt` in `/data/ps5_autoloader` or the same
folder on a USB drive, with the payloads next to it.
The table of autoloaders exists twice, in `installer/main.c` and
`tsd/autostart.go`.
## Known problems
- Once, switching the console from Wi-Fi to Ethernet during a Moonlight
@@ -158,5 +212,6 @@ The table of autoloaders exists twice, in `installer/main.c` and
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: 58 KiB

After

Width:  |  Height:  |  Size: 67 KiB

-402
View File
@@ -1,402 +0,0 @@
/* Tailscale installer payload for jailbroken PS5s.
*
* Stores the Tailscale daemon payload on the console, registers it with the
* payload autoloader if one is set up, adds a home screen icon that opens
* the status page, and starts the daemon through the ELF loader on this
* console. 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 *);
/* Where payload autoloaders keep their load order, and where the daemon has
* to be stored for each. Keep in sync with tsd/autostart.go. */
static const struct autoloader {
const char *name;
const char *list; /* load order: one file name per line */
const char *payload_dir; /* where the payload goes */
} autoloaders[] = {
/* Payload Manager finds entries by file name anywhere below /data/pldmgr
* and keeps each payload in a folder of its own. */
{"Payload Manager", "/data/pldmgr/autoload.txt", "/data/pldmgr/payloads/Tailscale"},
{"PS5 autoloader", "/data/ps5_autoloader/autoload.txt", "/data/ps5_autoloader"},
{"PS5 autoloader (usb0)", "/mnt/usb0/ps5_autoloader/autoload.txt", "/mnt/usb0/ps5_autoloader"},
{"PS5 autoloader (usb1)", "/mnt/usb1/ps5_autoloader/autoload.txt", "/mnt/usb1/ps5_autoloader"},
{"PS5 autoloader (usb2)", "/mnt/usb2/ps5_autoloader/autoload.txt", "/mnt/usb2/ps5_autoloader"},
{"PS5 autoloader (usb3)", "/mnt/usb3/ps5_autoloader/autoload.txt", "/mnt/usb3/ps5_autoloader"},
};
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 where the autoloader would pick it up. */
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 load order already lists the daemon on a line of its own. */
static int
autoload_lists_daemon(const char *text) {
size_t namelen = strlen(DAEMON_NAME);
for (const char *line = text; line && *line;) {
const char *end = strchr(line, '\n');
size_t len = end ? (size_t)(end - line) : strlen(line);
while (len > 0 && (line[len - 1] == '\r' || line[len - 1] == ' ' || line[len - 1] == '\t')) {
len--;
}
if (len == namelen && !strncmp(line, DAEMON_NAME, namelen)) {
return 1;
}
line = end ? end + 1 : 0;
}
return 0;
}
/* If this autoloader is set up on the console, store the daemon for it and
* add it to the end of the load order. Returns 1 if registered, 0 if the
* autoloader is not present, -1 on error. */
static int
register_autoload(const struct autoloader *al) {
struct stat st;
char *text;
size_t got;
FILE *f;
if (stat(al->list, &st)) {
return 0;
}
printf("%s:\n", al->name);
if (install_daemon(al->payload_dir)) {
return -1;
}
if (!(f = fopen(al->list, "rb"))) {
printf(" could not read %s: %s\n", al->list, strerror(errno));
return -1;
}
text = calloc(1, st.st_size + 1);
got = fread(text, 1, st.st_size, f);
fclose(f);
text[got] = 0;
if (autoload_lists_daemon(text)) {
printf(" %s already lists %s\n", al->list, DAEMON_NAME);
} else {
if (!(f = fopen(al->list, "ab"))) {
printf(" could not update %s: %s\n", al->list, strerror(errno));
free(text);
return -1;
}
if (got > 0 && text[got - 1] != '\n') {
fputc('\n', f);
}
fputs(DAEMON_NAME "\n", f);
fclose(f);
printf(" added %s to the end of %s\n", DAEMON_NAME, al->list);
}
free(text);
return 1;
}
/* 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;
int registered = 0;
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");
mkdir(DATA_DIR, 0755);
for (size_t i = 0; i < sizeof(autoloaders) / sizeof(autoloaders[0]); i++) {
if (register_autoload(&autoloaders[i]) > 0) {
registered++;
}
}
if (!registered) {
/* No autoloader: keep the payload where the user can find it. */
printf("No payload autoloader found on this console.\n");
if (install_daemon(DATA_DIR)) {
notify("Tailscale install failed:\ncould not write to %s", DATA_DIR);
return 1;
}
printf(" Tailscale will not start by itself after a reboot.\n"
" Send %s/%s to the ELF loader to start it.\n",
DATA_DIR, DAEMON_NAME);
}
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");
return 0;
}
+154
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
#pragma once
/* Put the Tailscale icon on the home screen if that has not been done yet.
* Never fails: without an icon everything else still works. */
void home_icon_install_once(void);
+28 -6
View File
@@ -3,6 +3,7 @@
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/mman.h>
@@ -10,6 +11,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
View File
@@ -14,6 +14,11 @@
#ifndef HTTP_PATH
#define HTTP_PATH "/hello.txt"
#endif
/* Sunshine's "port" setting on the host under test; its HTTP port is this
* and its video (UDP) port is this plus 9. */
#ifndef BASE_PORT
#define BASE_PORT 47989
#endif
static struct sockaddr_in
local(int port) {
@@ -42,12 +47,12 @@ main(void) {
setvbuf(stdout, 0, _IONBF, 0);
/* TCP */
addr = local(47989);
addr = local(BASE_PORT);
fd = socket(AF_INET, SOCK_STREAM, 0);
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
double t0 = now();
if (connect(fd, (struct sockaddr *)&addr, sizeof(addr))) {
printf("tcp 127.0.0.1:47989: cannot connect (no forward listening)\n");
printf("tcp 127.0.0.1:%d: cannot connect (no forward listening)\n", BASE_PORT);
} else {
const char *req = "GET " HTTP_PATH " HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n";
size_t total = 0;
@@ -59,9 +64,9 @@ main(void) {
char *body = strstr(buf, "\r\n\r\n");
char *eol = strstr(buf, "\r\n");
if (!total) {
printf("tcp 127.0.0.1:47989: connected but no response\n");
printf("tcp 127.0.0.1:%d: connected but no response\n", BASE_PORT);
} else {
printf("tcp 127.0.0.1:47989: %.*s (%.0f ms)\n", eol ? (int)(eol - buf) : 60, buf, (now() - t0) * 1000);
printf("tcp 127.0.0.1:%d: %.*s (%.0f ms)\n", BASE_PORT, eol ? (int)(eol - buf) : 60, buf, (now() - t0) * 1000);
if (body) {
printf(" body: %.400s\n", body + 4);
}
@@ -71,7 +76,7 @@ main(void) {
#ifndef SKIP_UDP
/* UDP */
addr = local(47998);
addr = local(BASE_PORT + 9);
fd = socket(AF_INET, SOCK_DGRAM, 0);
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
int ok = 0;
@@ -90,7 +95,7 @@ main(void) {
}
}
}
printf("udp 127.0.0.1:47998: %d/20 datagrams of 1300 bytes echoed, worst round trip %.1f ms\n", ok, worst);
printf("udp 127.0.0.1:%d: %d/20 datagrams of 1300 bytes echoed, worst round trip %.1f ms\n", BASE_PORT + 9, ok, worst);
close(fd);
#endif
return 0;
+4 -3
View File
@@ -48,9 +48,10 @@ main(void) {
int stray = ki->ki_pid >= MIN_PID && ki->ki_pid != self && !strcmp(tdname, "payload.elf");
if (ki->ki_pid >= MIN_PID - 40 || stray) {
#endif
printf("%6d %-20s %-20s rss=%ldMB threads=%d cpu=%.2fs stat=%d wait=%.8s%s\n", ki->ki_pid, ki->ki_comm,
tdname, (long)(ki->ki_rssize * 16384L >> 20), ki->ki_numthreads, ki->ki_runtime / 1e6, (int)ki->ki_stat,
ki->ki_wmesg, stray ? " <- killing" : "");
printf("%6d %-20s %-20s rss=%ldMB threads=%d cpu=%.2fs stat=%d wait=%.8s sched=%d/%d%s\n", ki->ki_pid,
ki->ki_comm, tdname, (long)(ki->ki_rssize * 16384L >> 20), ki->ki_numthreads, ki->ki_runtime / 1e6,
(int)ki->ki_stat, ki->ki_wmesg, (int)ki->ki_pri.pri_class, (int)ki->ki_pri.pri_user,
stray ? " <- killing" : "");
}
if (stray) {
if (kill(ki->ki_pid, SIGKILL)) {
+39
View File
@@ -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;
}
-27
View File
@@ -1,27 +0,0 @@
# Build the installer payload around an already built daemon payload.
# .\tools\build-installer.ps1 [-Daemon out\tailscale.elf] [-Send]
param(
[string]$Daemon = 'out\tailscale.elf',
[string]$Out = 'out\tailscale-installer.elf',
[switch]$Send,
[int]$Seconds = 70
)
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'env.ps1')
$daemonPath = (Resolve-Path (Join-Path $DevRoot $Daemon)).Path -replace '\\', '/'
$assets = (Join-Path $DevRoot 'installer\assets') -replace '\\', '/'
$outPath = Join-Path $DevRoot $Out
# The app installer library only loads when these come with it, in this
# order (as in the SDK's install_app sample). With libSceAppInstUtil alone
# the payload never starts: the loader leaves it stopped.
Invoke-PS5CC -O2 -Wall "-DDAEMON_ELF=`"$daemonPath`"" "-DASSET_DIR=`"$assets`"" `
-lSceIpmi -lSceAppInstUtil -lSceUserService -lSceSystemService `
-o $outPath (Join-Path $DevRoot 'installer\main.c')
Write-Host ("built {0} ({1:N1} MB)" -f $outPath, ((Get-Item $outPath).Length / 1MB))
if ($Send) {
& (Join-Path $PSScriptRoot 'ps5send.ps1') -File $outPath -Seconds $Seconds
}
+16 -1
View File
@@ -1,4 +1,5 @@
# Build a Go program for the PS5 and wrap it in the launcher payload.
# .\tools\build-payload.ps1 -GoDir tsd -Name tailscale -Version 0.4.1 -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
View File
@@ -0,0 +1,248 @@
package main
import (
"crypto/pbkdf2"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"io"
"net"
"net/http"
"net/netip"
"strconv"
"strings"
"sync"
"time"
)
// The status page can be given a password. Without one it trusts whoever can
// reach it, like the other services on a jailbroken console. With one, the
// page and its API ask for it, except from the console itself: someone at
// the console can do anything anyway, and typing a password with a
// controller is no fun.
//
// A browser that has entered the password gets a session cookie.
const (
sessionCookie = "ps5ts_session"
sessionLifetime = 30 * 24 * time.Hour
pbkdf2Rounds = 210_000
)
// hashPassword returns the stored form of a password:
// "pbkdf2-sha256$<rounds>$<salt hex>$<key hex>".
func hashPassword(password string) (string, error) {
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return "", err
}
key, err := pbkdf2.Key(sha256.New, password, salt, pbkdf2Rounds, 32)
if err != nil {
return "", err
}
return "pbkdf2-sha256$" + strconv.Itoa(pbkdf2Rounds) + "$" + hex.EncodeToString(salt) + "$" + hex.EncodeToString(key), nil
}
// checkPassword reports whether password matches a stored hash.
func checkPassword(stored, password string) bool {
parts := strings.Split(stored, "$")
if len(parts) != 4 || parts[0] != "pbkdf2-sha256" {
return false
}
rounds, err := strconv.Atoi(parts[1])
if err != nil || rounds < 1 || rounds > 10_000_000 {
return false
}
salt, err1 := hex.DecodeString(parts[2])
want, err2 := hex.DecodeString(parts[3])
if err1 != nil || err2 != nil || len(want) == 0 {
return false
}
got, err := pbkdf2.Key(sha256.New, password, salt, rounds, len(want))
return err == nil && subtle.ConstantTimeCompare(got, want) == 1
}
// sessions are the browsers that have entered the password.
type sessions struct {
mu sync.Mutex
tokens map[string]time.Time // token -> expiry
attempt sync.Mutex // serializes password attempts
}
func (s *sessions) create() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
token := hex.EncodeToString(b)
s.mu.Lock()
defer s.mu.Unlock()
if s.tokens == nil {
s.tokens = map[string]time.Time{}
}
now := time.Now()
for t, exp := range s.tokens {
if now.After(exp) {
delete(s.tokens, t)
}
}
s.tokens[token] = now.Add(sessionLifetime)
return token, nil
}
func (s *sessions) valid(token string) bool {
s.mu.Lock()
defer s.mu.Unlock()
exp, ok := s.tokens[token]
return ok && time.Now().Before(exp)
}
func (s *sessions) remove(token string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.tokens, token)
}
// clear ends every session, for when the password changes.
func (s *sessions) clear() {
s.mu.Lock()
defer s.mu.Unlock()
s.tokens = nil
}
// fromConsole reports whether the request was made on the console itself.
// Connections from the tailnet are served directly (see tailnetListener), so
// they arrive with their tailnet address, not as loopback.
func fromConsole(r *http.Request) bool {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return false
}
ip, err := netip.ParseAddr(host)
return err == nil && ip.Unmap().IsLoopback()
}
// authorized reports whether the request may use the page: there is no
// password, it comes from the console itself, or it carries a session.
func (d *daemon) authorized(r *http.Request) bool {
d.mu.Lock()
hash := d.cfg.PasswordHash
d.mu.Unlock()
if hash == "" || fromConsole(r) {
return true
}
c, err := r.Cookie(sessionCookie)
return err == nil && d.sessions.valid(c.Value)
}
// protect wraps a handler that needs the password, if one is set.
func (d *daemon) protect(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !d.authorized(r) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]any{"locked": true, "version": version})
return
}
h(w, r)
}
}
// handleAuth checks a password and starts a session.
func (d *daemon) handleAuth(w http.ResponseWriter, r *http.Request) {
var req struct {
Password string `json:"password"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&req); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
d.mu.Lock()
hash := d.cfg.PasswordHash
d.mu.Unlock()
// One attempt at a time, and a wrong one costs a second: enough to make
// guessing over the network pointless.
d.sessions.attempt.Lock()
ok := hash == "" || checkPassword(hash, req.Password)
if !ok {
time.Sleep(time.Second)
}
d.sessions.attempt.Unlock()
if !ok {
d.logf("status page: wrong password from %s", r.RemoteAddr)
http.Error(w, "wrong password", http.StatusForbidden)
return
}
token, err := d.sessions.create()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: token,
Path: "/",
MaxAge: int(sessionLifetime.Seconds()),
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
})
io.WriteString(w, "ok\n")
}
// handleLock ends the browser's session.
func (d *daemon) handleLock(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(sessionCookie); err == nil {
d.sessions.remove(c.Value)
}
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteStrictMode})
io.WriteString(w, "ok\n")
}
// tailnetListener hands the status page's server the connections that arrive
// for it over the tailnet. They could be piped to the page's port on
// localhost like any other, but then every tailnet device would look like
// the console itself and get past the password.
type tailnetListener struct {
conns chan net.Conn
closed chan struct{}
once sync.Once
}
func newTailnetListener() *tailnetListener {
return &tailnetListener{conns: make(chan net.Conn, 16), closed: make(chan struct{})}
}
// deliver gives a tailnet connection to the server.
func (l *tailnetListener) deliver(c net.Conn) {
select {
case l.conns <- c:
case <-l.closed:
c.Close()
}
}
func (l *tailnetListener) Accept() (net.Conn, error) {
select {
case c := <-l.conns:
return c, nil
case <-l.closed:
return nil, net.ErrClosed
}
}
func (l *tailnetListener) Close() error {
l.once.Do(func() { close(l.closed) })
return nil
}
func (l *tailnetListener) Addr() net.Addr { return tailnetAddr{} }
type tailnetAddr struct{}
func (tailnetAddr) Network() string { return "tailnet" }
func (tailnetAddr) String() string { return "tailnet" }
-114
View File
@@ -1,114 +0,0 @@
package main
import (
"errors"
"io/fs"
"os"
"path/filepath"
"strings"
)
// The installer registers the daemon with whichever payload autoloader the
// console uses: it copies the payload where the autoloader looks for files
// and adds its file name to the autoloader's load order (one payload per
// line, "!N" lines are delays). This file is the daemon's side of that:
// reporting the registration and undoing it. Keep the table in sync with
// installer/main.c.
const daemonFileName = "tailscale.elf"
// An autoloader is one place a payload autoloader keeps its load order.
type autoloader struct {
name string // for messages
list string // the load order file
payloadDir string // where the daemon payload is stored for it
}
var autoloaders = func() []autoloader {
al := []autoloader{
// Payload Manager finds entries by file name anywhere below
// /data/pldmgr and keeps each payload in a folder of its own.
{"Payload Manager", "/data/pldmgr/autoload.txt", "/data/pldmgr/payloads/Tailscale"},
{"PS5 autoloader", "/data/ps5_autoloader/autoload.txt", "/data/ps5_autoloader"},
}
for _, usb := range []string{"usb0", "usb1", "usb2", "usb3"} {
dir := "/mnt/" + usb + "/ps5_autoloader"
al = append(al, autoloader{"PS5 autoloader (" + usb + ")", dir + "/autoload.txt", dir})
}
return al
}()
// autostartNames returns the autoloaders whose load order includes the daemon.
func autostartNames() []string {
names := []string{}
for _, al := range autoloaders {
b, err := os.ReadFile(al.list)
if err != nil {
continue
}
if _, found := removeAutoloadEntry(string(b), daemonFileName); found {
names = append(names, al.name)
}
}
return names
}
// removeAutoloadEntry returns text without the lines that name the payload,
// and whether there were any. Everything else, including line endings, is
// kept as it was.
func removeAutoloadEntry(text, name string) (string, bool) {
var out strings.Builder
found := false
for len(text) > 0 {
line := text
if i := strings.IndexByte(text, '\n'); i >= 0 {
line = text[:i+1]
}
text = text[len(line):]
if strings.TrimSpace(line) == name {
found = true
continue
}
out.WriteString(line)
}
return out.String(), found
}
// uninstall removes the daemon from every autoloader and deletes the
// installed 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, logf func(string, ...any)) error {
var errs []error
remove := func(path string) {
if err := os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) {
errs = append(errs, err)
}
}
for _, al := range autoloaders {
b, err := os.ReadFile(al.list)
if err != nil {
continue
}
if text, found := removeAutoloadEntry(string(b), daemonFileName); found {
if err := os.WriteFile(al.list, []byte(text), 0o644); err != nil {
errs = append(errs, err)
continue
}
logf("removed %s from %s", daemonFileName, al.list)
}
remove(filepath.Join(al.payloadDir, daemonFileName))
if filepath.Base(al.payloadDir) == "Tailscale" {
// A folder the installer created just for this payload.
os.Remove(al.payloadDir)
}
}
remove(filepath.Join(dataDir, daemonFileName))
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...)
}
+46 -10
View File
@@ -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 {
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

+10 -6
View File
@@ -21,7 +21,15 @@ import (
// rather than a connection that opens and closes.
func (d *daemon) forwardToLocalhost(src, dst netip.AddrPort) (handler func(net.Conn), intercept bool) {
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,11 +48,7 @@ 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
}
@@ -6,27 +6,6 @@ import (
"testing"
)
func TestRemoveAutoloadEntry(t *testing.T) {
tests := []struct {
name, in, want string
found bool
}{
{"absent", "kstuff.elf\n!500\nftpsrv.elf\n", "kstuff.elf\n!500\nftpsrv.elf\n", false},
{"last line", "kstuff.elf\ntailscale.elf\n", "kstuff.elf\n", true},
{"no trailing newline", "kstuff.elf\ntailscale.elf", "kstuff.elf\n", true},
{"middle, CRLF kept", "a.elf\r\ntailscale.elf\r\nb.elf\r\n", "a.elf\r\nb.elf\r\n", true},
{"listed twice", "tailscale.elf\na.elf\n tailscale.elf \n", "a.elf\n", true},
{"similar names stay", "# tailscale.elf\nmy-tailscale.elf\ntailscale.elf.bak\n", "# tailscale.elf\nmy-tailscale.elf\ntailscale.elf.bak\n", false},
{"empty", "", "", false},
}
for _, tt := range tests {
got, found := removeAutoloadEntry(tt.in, daemonFileName)
if got != tt.want || found != tt.found {
t.Errorf("%s: got %q, %v; want %q, %v", tt.name, got, found, tt.want, tt.found)
}
}
}
// 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.
+86
View File
@@ -0,0 +1,86 @@
package main
import (
"bytes"
"errors"
"fmt"
"net"
"os"
"path/filepath"
"strings"
"time"
)
// The home screen icon is installed by a small helper payload that the
// launcher carries (see appicon/ and launcher/homeicon.c). The launcher also
// leaves a copy of the helper in the data directory, so that Uninstall can
// run it again to take the icon away: the system call for that lives in
// libraries this process must not load.
//
// The helper installs or removes depending on one byte in it, after a marker
// string; removing is a matter of flipping that byte before sending it to
// the ELF loader.
const (
iconHelperFile = "icon-helper.elf"
iconModeMarker = "TSICON-MODE="
iconModeRemove = 'R'
elfLoaderAddr = "127.0.0.1:9021"
iconHelperTimeout = 20 * time.Second
)
// removeHomeIcon takes the Tailscale icon off the home screen.
func removeHomeIcon() error {
helper, err := os.ReadFile(filepath.Join(dataDir, iconHelperFile))
if err != nil {
return err
}
i := bytes.Index(helper, []byte(iconModeMarker))
if i < 0 || i+len(iconModeMarker) >= len(helper) {
return errors.New("the icon helper is not one this version understands")
}
helper[i+len(iconModeMarker)] = iconModeRemove
c, err := net.DialTimeout("tcp", elfLoaderAddr, 3*time.Second)
if err != nil {
return fmt.Errorf("no ELF loader to run the icon helper: %w", err)
}
defer c.Close()
c.SetDeadline(time.Now().Add(iconHelperTimeout))
if _, err := c.Write(helper); err != nil {
return err
}
// The helper prints "icon: removed" or what went wrong, and exits.
var reply []byte
buf := make([]byte, 512)
for len(reply) < 4096 {
n, err := c.Read(buf)
reply = append(reply, buf[:n]...)
if line := iconReplyLine(reply); line != "" {
if strings.HasPrefix(line, "icon: removed") {
return nil
}
return errors.New(line)
}
if err != nil {
break
}
}
return errors.New("no answer from the icon helper")
}
// iconReplyLine returns the helper's complete "icon: ..." line, if it has
// arrived.
func iconReplyLine(reply []byte) string {
i := bytes.Index(reply, []byte("icon: "))
if i < 0 {
return ""
}
rest := reply[i:]
j := bytes.IndexByte(rest, '\n')
if j < 0 {
return ""
}
return strings.TrimSpace(string(rest[:j]))
}
+97
View File
@@ -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)
}
+85
View File
@@ -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)
}
}
+5
View File
@@ -18,6 +18,8 @@ type resilientListener struct {
network string
addr string
logf func(format string, args ...any)
// onReopen, if set, is called after the socket had to be reopened.
onReopen func()
mu sync.Mutex
ln net.Listener
@@ -80,6 +82,9 @@ func (l *resilientListener) reopen() bool {
l.ln = ln
l.mu.Unlock()
l.logf("listener %s: reopened", l.addr)
if l.onReopen != nil {
l.onReopen()
}
return true
}
}
+78 -152
View File
@@ -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
View File
@@ -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
View File
@@ -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
+4 -1
View File
@@ -2,6 +2,7 @@ package main
import (
"context"
"errors"
"io"
"net"
"net/http"
@@ -52,7 +53,9 @@ func (d *daemon) serveProxy(ln net.Listener) {
io.Copy(w, resp.Body)
}),
}
if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed && !d.stopping() {
// Serve returns when the listener is closed, which is how the proxy is
// turned off or moved from the settings.
if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed && !errors.Is(err, net.ErrClosed) && !d.stopping() {
d.logf("http proxy stopped: %v", err)
}
}
+292
View File
@@ -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
}
+306
View File
@@ -0,0 +1,306 @@
package main
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func TestPasswordHash(t *testing.T) {
hash, err := hashPassword("correct horse")
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(hash, "pbkdf2-sha256$") {
t.Errorf("unexpected hash format %q", hash)
}
if !checkPassword(hash, "correct horse") {
t.Error("the right password was rejected")
}
for _, wrong := range []string{"", "Correct horse", "correct horse "} {
if checkPassword(hash, wrong) {
t.Errorf("%q was accepted", wrong)
}
}
other, _ := hashPassword("correct horse")
if other == hash {
t.Error("two hashes of one password are identical; the salt is not random")
}
for _, bad := range []string{"", "plain", "pbkdf2-sha256$x$00$00", "pbkdf2-sha256$1000$zz$00", "md5$1$00$00"} {
if checkPassword(bad, "anything") {
t.Errorf("malformed hash %q accepted a password", bad)
}
}
}
// newTestDaemon returns a daemon with just enough set up to serve the status
// page's API.
func newTestDaemon(t *testing.T) *daemon {
t.Helper()
dir := t.TempDir()
old := dataDir
dataDir = dir
t.Cleanup(func() { dataDir = old })
d := &daemon{
cfg: defaultConfig(),
cfgPath: filepath.Join(dir, "config.json"),
logf: t.Logf,
quit: make(chan struct{}),
}
d.fwd = newForwarder(nil, t.Logf)
d.tailnetWeb = newTailnetListener()
return d
}
// request performs one API call. remote is the client address the server sees.
func request(t *testing.T, h http.Handler, method, path, remote string, body any, cookies []*http.Cookie) *httptest.ResponseRecorder {
t.Helper()
var buf bytes.Buffer
if body != nil {
json.NewEncoder(&buf).Encode(body)
}
r := httptest.NewRequest(method, path, &buf)
r.RemoteAddr = remote
r.Header.Set(apiHeader, "1")
for _, c := range cookies {
r.AddCookie(c)
}
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w
}
func TestPasswordProtection(t *testing.T) {
d := newTestDaemon(t)
h := d.webHandler()
const lan, tailnet, console = "192.168.1.20:5000", "100.64.0.9:5000", "127.0.0.1:5000"
// No password: everyone gets in.
if w := request(t, h, "GET", "/api/status", lan, nil, nil); w.Code != 200 {
t.Fatalf("no password, LAN status: %d", w.Code)
}
// Set one from the LAN.
pw := "hunter22"
w := request(t, h, "POST", "/api/config", lan, func() settings {
s := settingsFromConfig(d.cfg)
s.Password = &pw
return s
}(), nil)
if w.Code != 200 {
t.Fatalf("setting the password: %d %s", w.Code, w.Body)
}
setter := w.Result().Cookies()
if d.cfg.PasswordHash == "" || strings.Contains(d.cfg.PasswordHash, pw) {
t.Fatalf("stored password hash: %q", d.cfg.PasswordHash)
}
saved, _ := os.ReadFile(d.cfgPath)
if !bytes.Contains(saved, []byte("passwordHash")) || bytes.Contains(saved, []byte(pw)) {
t.Errorf("config file should hold the hash and not the password:\n%s", saved)
}
// Now locked for the LAN and the tailnet, open for the console itself
// and for the browser that set it.
for _, remote := range []string{lan, tailnet} {
for _, path := range []string{"/api/status", "/api/config", "/api/logs", "/qr.png"} {
if w := request(t, h, "GET", path, remote, nil, nil); w.Code != http.StatusUnauthorized {
t.Errorf("GET %s from %s: %d, want 401", path, remote, w.Code)
}
}
for _, path := range []string{"/api/logout", "/api/quit", "/api/uninstall", "/api/config", "/api/sunshine", "/api/login"} {
if w := request(t, h, "POST", path, remote, nil, nil); w.Code != http.StatusUnauthorized {
t.Errorf("POST %s from %s: %d, want 401", path, remote, w.Code)
}
}
}
if w := request(t, h, "GET", "/api/status", console, nil, nil); w.Code != 200 {
t.Errorf("console status: %d", w.Code)
}
if w := request(t, h, "GET", "/api/status", lan, nil, setter); w.Code != 200 {
t.Errorf("status with the session of the browser that set the password: %d", w.Code)
}
// The page shell and ping stay reachable so the unlock form can load.
for _, path := range []string{"/", "/api/ping", "/favicon.png"} {
if w := request(t, h, "GET", path, lan, nil, nil); w.Code != 200 {
t.Errorf("GET %s while locked: %d", path, w.Code)
}
}
// Unlocking.
if w := request(t, h, "POST", "/api/auth", lan, map[string]string{"password": "nope"}, nil); w.Code != http.StatusForbidden {
t.Errorf("wrong password: %d", w.Code)
}
w = request(t, h, "POST", "/api/auth", tailnet, map[string]string{"password": pw}, nil)
if w.Code != 200 || len(w.Result().Cookies()) == 0 {
t.Fatalf("right password: %d, cookies %v", w.Code, w.Result().Cookies())
}
session := w.Result().Cookies()
if !session[0].HttpOnly {
t.Error("the session cookie should be HttpOnly")
}
if w := request(t, h, "GET", "/api/status", tailnet, nil, session); w.Code != 200 {
t.Errorf("status with a session: %d", w.Code)
}
// Locking again ends the session.
request(t, h, "POST", "/api/lock", tailnet, nil, session)
if w := request(t, h, "GET", "/api/status", tailnet, nil, session); w.Code != http.StatusUnauthorized {
t.Errorf("status after lock: %d", w.Code)
}
// Removing the password opens the page again.
empty := ""
s := settingsFromConfig(d.cfg)
s.Password = &empty
if w := request(t, h, "POST", "/api/config", console, s, nil); w.Code != 200 {
t.Fatalf("removing the password: %d %s", w.Code, w.Body)
}
if w := request(t, h, "GET", "/api/status", lan, nil, nil); w.Code != 200 {
t.Errorf("status after removing the password: %d", w.Code)
}
}
func TestStateChangesNeedHeader(t *testing.T) {
d := newTestDaemon(t)
h := d.webHandler()
r := httptest.NewRequest("POST", "/api/quit", nil)
r.RemoteAddr = "192.168.1.20:5000"
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusForbidden {
t.Errorf("POST without the API header: %d, want 403", w.Code)
}
if d.stopping() {
t.Error("the daemon was told to stop by a request without the header")
}
}
func TestSettingsRoundTrip(t *testing.T) {
d := newTestDaemon(t)
h := d.webHandler()
const console = "127.0.0.1:5000"
var s settings
w := request(t, h, "GET", "/api/config", console, nil, nil)
if err := json.Unmarshal(w.Body.Bytes(), &s); err != nil {
t.Fatal(err)
}
if s.Hostname != "ps5" || s.Priority != priorityLow || !s.CheckUpdates || s.HTTPProxyAddr != "" || s.PasswordSet {
t.Errorf("defaults: %+v", s)
}
s.Hostname = "living-room-ps5"
s.BlockedPorts = []uint16{9021}
s.UDPPorts = []uint16{9296}
s.Priority = priorityHigh
s.CheckUpdates = false
w = request(t, h, "POST", "/api/config", console, s, nil)
if w.Code != 200 {
t.Fatalf("saving: %d %s", w.Code, w.Body)
}
var reply struct{ Restart []string }
json.Unmarshal(w.Body.Bytes(), &reply)
if len(reply.Restart) != 1 || reply.Restart[0] != "priority" {
t.Errorf("settings needing a restart: %v, want [priority]", reply.Restart)
}
cfg, err := loadConfig(d.cfgPath)
if err != nil {
t.Fatal(err)
}
if cfg.Hostname != "living-room-ps5" || cfg.Priority != priorityHigh || cfg.CheckUpdates ||
len(cfg.BlockedPorts) != 1 || len(cfg.UDPPorts) != 1 {
t.Errorf("saved config: %+v", cfg)
}
if b, _ := os.ReadFile(filepath.Join(dataDir, priorityFile)); strings.TrimSpace(string(b)) != priorityHigh {
t.Errorf("priority file: %q", b)
}
// Back to low removes the launcher's instruction.
s.Priority = priorityLow
request(t, h, "POST", "/api/config", console, s, nil)
if _, err := os.Stat(filepath.Join(dataDir, priorityFile)); !os.IsNotExist(err) {
t.Errorf("priority file should be gone: %v", err)
}
// Invalid input is rejected and changes nothing.
for name, edit := range map[string]func(*settings){
"name": func(s *settings) { s.Hostname = "bad name!" },
"web": func(s *settings) { s.WebAddr = "8090" },
"proxy": func(s *settings) { s.HTTPProxyAddr = "nonsense" },
"priority": func(s *settings) { s.Priority = "turbo" },
"forward": func(s *settings) { s.Forwards = []forwardRule{{"sctp", "127.0.0.1:1", "a:1"}} },
"sunshine": func(s *settings) { s.SunshineHosts = []sunshineHost{{Host: "a"}, {Host: "b"}} },
"password": func(s *settings) { p := "abc"; s.Password = &p },
} {
bad := settingsFromConfig(d.cfg)
edit(&bad)
if w := request(t, h, "POST", "/api/config", console, bad, nil); w.Code != http.StatusBadRequest {
t.Errorf("invalid %s: %d, want 400", name, w.Code)
}
}
if d.cfg.Hostname != "living-room-ps5" {
t.Errorf("hostname changed by a rejected request: %q", d.cfg.Hostname)
}
}
func TestConfigMigration(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
// A config as v0.4.1 wrote it.
os.WriteFile(path, []byte(`{"hostname":"ps5","webAddr":":8090","httpProxyAddr":"127.0.0.1:8118","sunshineHost":"gaming-pc","udpPorts":[9295,9296,9297,9302]}`), 0o600)
cfg, err := loadConfig(path)
if err != nil {
t.Fatal(err)
}
if len(cfg.SunshineHosts) != 1 || cfg.SunshineHosts[0].Host != "gaming-pc" || cfg.SunshineHost != "" {
t.Errorf("sunshine host not migrated: %+v", cfg)
}
if cfg.HTTPProxyAddr != "127.0.0.1:8118" {
t.Errorf("an explicitly configured proxy must stay on: %q", cfg.HTTPProxyAddr)
}
if !cfg.CheckUpdates {
t.Error("update checks should default to on for an existing config")
}
}
func TestVersionCompare(t *testing.T) {
for _, tt := range []struct {
current, latest string
want bool
}{
{"0.4.1", "0.5.0", true},
{"0.4.1", "v0.4.2", true},
{"0.4.1", "0.4.1", false},
{"0.5.0", "0.4.9", false},
{"0.4.2-dev", "0.4.1", false},
{"0.4.2-dev", "0.4.2", false},
{"0.4.2-dev", "0.4.3", true},
{"0.9.0", "0.10.0", true},
{"dev", "0.5.0", false},
{"0.4.1", "", false},
{"0.4.1", "nonsense", false},
} {
if got := newerVersion(tt.current, tt.latest); got != tt.want {
t.Errorf("newerVersion(%q, %q) = %v, want %v", tt.current, tt.latest, got, tt.want)
}
}
}
func TestIconReplyLine(t *testing.T) {
for in, want := range map[string]string{
"": "",
"[SceLncUtil] something\n": "",
"icon: remov": "",
"[SceLncUtil] x\nicon: removed\n": "icon: removed",
"icon: registering failed: 0x1\r\n": "icon: registering failed: 0x1",
} {
if got := iconReplyLine([]byte(in)); got != want {
t.Errorf("iconReplyLine(%q) = %q, want %q", in, got, want)
}
}
}
+283 -62
View File
@@ -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,37 @@
.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; }
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; }
.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 +78,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 +109,76 @@
</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>
<table>
<thead><tr><th>Name</th><th>Address</th><th>OS</th><th>Status</th></tr></thead>
<tbody id="peers"></tbody>
</table>
</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 +188,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 +214,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,34 +247,59 @@ 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;
}
async function refresh() {
let s;
try {
s = await (await fetch('/api/status', {cache: 'no-store'})).json();
s = await (await api('/api/status')).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);
row(dl, 'Starts with the console', s.autostart.length ? 'Yes, via ' + s.autostart.join(', ') : 'No');
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);
@@ -190,62 +321,152 @@ async function refresh() {
return tr;
}));
$('peerpanel').classList.toggle('hidden', !s.peers.length);
$('peercount').textContent = s.peers.filter(p => p.online).length + ' online of ' + s.peers.length;
// 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(...s.peers.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 will stop now and no longer start with the console.')) 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; the
// browser may not allow storage, in which case it simply starts open.
try {
if (localStorage.getItem('peersCollapsed') === '1') $('peerbox').open = false;
} catch (e) {}
$('peerbox').addEventListener('toggle', () => {
try { localStorage.setItem('peersCollapsed', $('peerbox').open ? '0' : '1'); } catch (e) {}
});
refresh();
setInterval(refresh, 3000);
</script>
+190
View File
@@ -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
}
}
}
}
+127
View File
@@ -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)
}
}
}
+140 -68
View File
@@ -18,10 +18,15 @@ 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 {
@@ -31,6 +36,14 @@ type peerInfo struct {
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 {
Version string `json:"version"`
State string `json:"state"`
@@ -43,50 +56,66 @@ type statusInfo struct {
Health []string `json:"health,omitempty"`
Peers []peerInfo `json:"peers"`
Proxy string `json:"proxy,omitempty"`
// Autostart lists the autoloaders that start the daemon.
Autostart []string `json:"autostart"`
// SunshineHost and Forwards describe the local forwards.
SunshineHost string `json:"sunshineHost"`
Forwards []string `json:"forwards"`
Uptime int64 `json:"uptimeSeconds"`
// 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)
}
@@ -126,22 +155,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.Autostart = autostartNames()
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())
@@ -251,23 +310,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
@@ -299,30 +367,34 @@ func (d *daemon) stop() {
d.quitOnce.Do(func() { close(d.quit) })
}
// handleUninstall removes the autostart entry and 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, d.logf); 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 {