mirror of
https://github.com/holdmysocks/ps5-tailscale.git
synced 2026-10-07 00:00:20 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01b6381444 | ||
|
|
0156e4e210 |
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/* Ask the console's Remote Play service for its discovery reply over
|
||||
* loopback, to confirm it answers clients that arrive from 127.0.0.1 (which
|
||||
* is how the daemon's UDP relay reaches it). Read-only. */
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <netinet/in.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/time.h>
|
||||
|
||||
int
|
||||
main(void) {
|
||||
static const char req[] = "SRCH * HTTP/1.1\ndevice-discovery-protocol-version:00030010\n";
|
||||
struct sockaddr_in addr = {0};
|
||||
struct timeval tv = {3, 0};
|
||||
char buf[1024];
|
||||
int fd = socket(AF_INET, SOCK_DGRAM, 0);
|
||||
ssize_t n;
|
||||
|
||||
setvbuf(stdout, 0, _IONBF, 0);
|
||||
addr.sin_family = AF_INET;
|
||||
addr.sin_port = htons(9302);
|
||||
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
|
||||
sendto(fd, req, sizeof(req) - 1, 0, (struct sockaddr *)&addr, sizeof(addr));
|
||||
n = recv(fd, buf, sizeof(buf) - 1, 0);
|
||||
if (n <= 0) {
|
||||
printf("discovery via 127.0.0.1:9302: no reply\n");
|
||||
} else {
|
||||
buf[n] = 0;
|
||||
buf[strcspn(buf, "\r\n")] = 0;
|
||||
printf("discovery via 127.0.0.1:9302: %s\n", buf);
|
||||
}
|
||||
close(fd);
|
||||
return 0;
|
||||
}
|
||||
@@ -1,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...)
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user