2 Commits
Author SHA1 Message Date
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
17 changed files with 571 additions and 430 deletions

No files matched your search

+57 -24
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,11 +38,8 @@ 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
@@ -59,8 +58,7 @@ Tested on firmware 13.42 with elfldr 0.26 and Payload Manager 0.5.2.
```
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,
- stores the daemon payload as `/data/tailscale/tailscale.elf`,
- adds a **Tailscale** icon to the home screen (media section),
- starts Tailscale.
@@ -73,6 +71,11 @@ The console now has a tailnet address, shown on 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 `/data/tailscale/tailscale.elf` (or the installer) to the ELF loader
again. The installer does not change any payload autoloader; if you use one,
you can add that file to it yourself.
## Using it
### Reaching the console
@@ -82,9 +85,35 @@ 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, in `udpPorts` in the
[configuration](#configuration). The default list is Remote Play's.
- To keep a TCP port off the tailnet, add it to `blockedPorts`.
### 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 runs at the lowest priority so
that it never takes time from a game. Under a demanding game that may show
as stutter.
- Waking the console from rest mode does not work: nothing is running then.
### The status page
@@ -141,7 +170,7 @@ 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.
On the test console that stopped another homebrew tool's page from opening.
## Configuration
@@ -159,6 +188,7 @@ optional. Restart Tailscale (send the payload again) to apply edits.
"forwards": [
{"proto": "tcp", "listen": "127.0.0.1:8096", "target": "my-nas:8096"}
],
"udpPorts": [9295, 9296, 9297, 9302],
"blockedPorts": [],
"verbose": false
}
@@ -173,6 +203,7 @@ optional. Restart Tailscale (send the payload again) to apply edits.
| `controlURL` | A coordination server other than Tailscale's. |
| `sunshineHost` | The Sunshine host; set from the status page. |
| `forwards` | Extra local forwards: `proto` is `tcp` or `udp`, `listen` a localhost address, `target` a tailnet host and port. |
| `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. |
| `verbose` | Put Tailscale's own log in the main log as well. |
@@ -184,20 +215,19 @@ 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/tailscale.elf` | The daemon payload. |
| `/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 deletes the daemon payload and
stops Tailscale. It asks whether to also log out and delete the saved login.
Two things are left to do by hand:
- Delete the home screen icon (Options button, then Delete).
- Remove the device in the Tailscale admin console.
- If you added the payload to an autoloader yourself, remove it there.
## Troubleshooting
@@ -215,9 +245,10 @@ Two things are left to do by hand:
## 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.
- All listening TCP ports on the console, and the UDP ports in `udpPorts`,
become reachable from your tailnet. That includes the payload loader, which
runs anything sent to it. Use Tailscale ACLs if other people share your
tailnet.
- The local forwards and the proxy listen on `127.0.0.1` only and are not
exposed to the tailnet.
@@ -229,13 +260,15 @@ lowest scheduling priority on at most 4 cores, so it gives way to games.
## 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
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.
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: rest mode, Uninstall on a console, other firmware versions,
coordination servers other than Tailscale's.
## Building
+1 -1
View File
@@ -52,7 +52,7 @@ These folders are not in the repository.
```powershell
# daemon payload: C launcher + Go program -> out\tailscale.elf
.\tools\build-payload.ps1 -GoDir tsd -Name tailscale -Version 0.2.1
.\tools\build-payload.ps1 -GoDir tsd -Name tailscale -Version 0.4.0
# installer -> out\tailscale-installer.elf (embeds out\tailscale.elf)
.\tools\build-installer.ps1
+12 -15
View File
@@ -27,6 +27,13 @@ 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
@@ -37,10 +44,11 @@ specification.
- 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 installer** (`installer/`, C) embeds `tailscale.elf`. It writes it to
`/data/tailscale/tailscale.elf`, registers a home screen app whose
`param.json` has a `deeplinkUri` to the status page, and starts the daemon by
sending it to the ELF loader on `127.0.0.1:9021`. It does not modify any
payload autoloader.
## The PS5 as a Go target
@@ -136,17 +144,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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 60 KiB

+13 -105
View File
@@ -1,9 +1,9 @@
/* 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
* Stores the Tailscale daemon payload on the console, adds a home screen
* icon that opens the status page, and starts the daemon through the ELF
* loader on this console. It does not touch any payload autoloader. Build
* with tools\build-installer.ps1, which sets DAEMON_ELF and
* ASSET_DIR and links the system libraries the app installer needs. */
#include <errno.h>
@@ -69,23 +69,6 @@ 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];
@@ -122,7 +105,7 @@ write_all(int fd, const uint8_t *data, size_t size) {
}
/* Write a file through a temporary name so a failed write never leaves a
* truncated payload where the autoloader would pick it up. */
* truncated payload behind. */
static int
write_file(const char *path, const uint8_t *data, size_t size) {
char tmp[512];
@@ -159,71 +142,6 @@ install_daemon(const char *dir) {
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) {
@@ -346,7 +264,6 @@ int
main(void) {
pid_t pid = getpid();
intptr_t rootvnode;
int registered = 0;
setvbuf(stdout, 0, _IONBF, 0);
@@ -368,23 +285,11 @@ main(void) {
#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("Daemon payload:\n");
if (install_daemon(DATA_DIR)) {
notify("Tailscale install failed:\ncould not write to %s", DATA_DIR);
return 1;
}
printf("Home screen icon:\n");
@@ -397,6 +302,9 @@ main(void) {
return 1;
}
printf("\nDone. The status page is on port 8090 of this console.\n");
printf("\nDone. The status page is on port 8090 of this console.\n"
"Tailscale runs until the console restarts. To start it again, send\n"
"%s/%s to the ELF loader.\n",
DATA_DIR, DAEMON_NAME);
return 0;
}
+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;
}
-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...)
}
+6
View File
@@ -5,6 +5,7 @@ import (
"errors"
"io/fs"
"os"
"slices"
)
// config is read from /data/tailscale/config.json. Every field is optional.
@@ -28,6 +29,10 @@ type config struct {
// 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"`
// Verbose turns on Tailscale's own (very chatty) logging.
@@ -39,6 +44,7 @@ func defaultConfig() config {
Hostname: "ps5",
WebAddr: ":8090",
HTTPProxyAddr: "127.0.0.1:8118",
UDPPorts: slices.Clone(remotePlayUDPPorts),
}
}
@@ -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.
+95
View File
@@ -0,0 +1,95 @@
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
stops []func()
active []uint16
}
// update makes ports reachable on addrs, replacing whatever was exposed
// before. It does nothing if neither has changed.
func (e *udpExposer) update(addrs []netip.Addr, ports []uint16) {
e.mu.Lock()
defer e.mu.Unlock()
if slices.Equal(addrs, e.addrs) && slices.Equal(ports, e.ports) {
return
}
for _, stop := range e.stops {
stop()
}
e.stops, 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()
stop, 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.stops = append(e.stops, stop)
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)
}
}
+6 -140
View File
@@ -7,7 +7,6 @@ import (
"net"
"strconv"
"sync"
"sync/atomic"
"time"
)
@@ -152,144 +151,11 @@ 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)
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 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,
})
}
+36
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"
@@ -174,8 +177,10 @@ 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)
sigc := make(chan os.Signal, 1)
signal.Notify(sigc, syscall.SIGTERM, syscall.SIGINT)
@@ -214,6 +219,37 @@ func (d *daemon) localForwardRules() []forwardRule {
return append(sunshineRules(d.cfg.SunshineHost), d.cfg.Forwards...)
}
// 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
// login it repeats the same line every few seconds, which would drown the
// log, so a message is only logged again when it changes.
+2 -2
View File
@@ -161,7 +161,7 @@ async function refresh() {
if (s.ips.length) row(dl, 'Tailnet address', s.ips.join(', '), true);
if (s.tailnet) row(dl, 'Tailnet', s.tailnet);
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.ips.length) row(dl, 'Reachable from tailnet', 'every open TCP port' + (s.udpPorts.length ? '; UDP ' + s.udpPorts.join(', ') + ' (Remote Play)' : ''));
const health = $('health');
health.replaceChildren(...(s.health || []).map(h => { const li = document.createElement('li'); li.textContent = h; return li; }));
@@ -240,7 +240,7 @@ $('btn-sunshine').onclick = async () => {
$('sunshine-select').dataset.signature = '';
};
$('btn-uninstall').onclick = () => {
if (!confirm('Remove Tailscale from this PS5? It will stop now and no longer start with the console.')) return;
if (!confirm('Remove Tailscale from this PS5? It stops now and its payload is deleted.')) return;
const purge = confirm('Also log out and delete the saved login?\n\nOK: delete everything.\nCancel: keep the login, so reinstalling reconnects without logging in again.');
act('/api/uninstall' + (purge ? '?purge=1' : ''));
};
+178
View File
@@ -0,0 +1,178 @@
package main
import (
"context"
"errors"
"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
}
// startUDPRelay opens the listening socket and relays until stop is called.
func startUDPRelay(cfg udpRelayConfig) (stop func(), err error) {
sock, err := cfg.listen()
if err != nil {
return nil, err
}
r := &udpRelay{cfg: cfg, sock: sock, flows: map[string]*udpFlow{}}
go r.readLoop()
return r.stop, nil
}
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() {
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
}
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
}
}
}
}
+30
View File
@@ -0,0 +1,30 @@
package main
import (
"errors"
"io/fs"
"os"
"path/filepath"
)
// daemonFileName is the name the installer stores the daemon payload under,
// in the data directory.
const daemonFileName = "tailscale.elf"
// uninstall deletes the installed daemon payload. With purge it also deletes
// the Tailscale state and config, which forgets the login. The running
// process is not affected.
func uninstall(purge bool) error {
var errs []error
if err := os.Remove(filepath.Join(dataDir, daemonFileName)); err != nil && !errors.Is(err, fs.ErrNotExist) {
errs = append(errs, err)
}
if purge {
for _, name := range []string{"state", "config.json", ".cache"} {
if err := os.RemoveAll(filepath.Join(dataDir, name)); err != nil {
errs = append(errs, err)
}
}
}
return errors.Join(errs...)
}
+11 -8
View File
@@ -43,12 +43,12 @@ 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"`
// UDPPorts are the console's UDP ports reachable from the tailnet.
UDPPorts []uint16 `json:"udpPorts"`
Uptime int64 `json:"uptimeSeconds"`
}
func (d *daemon) serveWeb(ln net.Listener) {
@@ -141,7 +141,10 @@ func (d *daemon) handleStatus(w http.ResponseWriter, r *http.Request) {
}
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())
@@ -299,9 +302,9 @@ 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 removes the installed payload, then stops. With ?purge=1 it
// first logs the console out of the tailnet and deletes the saved state as
// well.
func (d *daemon) handleUninstall(w http.ResponseWriter, r *http.Request) {
purge := r.URL.Query().Get("purge") == "1"
if purge && d.lc != nil {
@@ -309,7 +312,7 @@ func (d *daemon) handleUninstall(w http.ResponseWriter, r *http.Request) {
d.logf("uninstall: logout: %v", err)
}
}
if err := uninstall(purge, d.logf); err != nil {
if err := uninstall(purge); err != nil {
d.logf("uninstall: %v", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return