mirror of
https://github.com/holdmysocks/ps5-tailscale.git
synced 2026-10-06 09:00:19 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d7d8ad4b0 | ||
|
|
01b6381444 |
No files matched your search
@@ -5,6 +5,9 @@ Puts a jailbroken PS5 on your [Tailscale](https://tailscale.com) network.
|
||||
- **Reach the console from anywhere.** FTP, the payload loader, web tools:
|
||||
whatever listens on the console is available at its tailnet address from
|
||||
your other Tailscale devices.
|
||||
- **Remote Play over Tailscale.** Play the PS5 from anywhere with a Remote
|
||||
Play client, at the console's tailnet address, with no port forwarding on
|
||||
your router.
|
||||
- **Stream games to the console over Tailscale.** A Moonlight client on the
|
||||
PS5 (such as ProsperoLight) can connect to a Sunshine host on your tailnet.
|
||||
- **Home screen icon** that opens its status page.
|
||||
@@ -40,38 +43,36 @@ Tested on firmware 13.42 with elfldr 0.26.
|
||||
|
||||
## Install
|
||||
|
||||
1. Download `tailscale-installer.elf` from the
|
||||
[latest release](../../releases/latest).
|
||||
2. Send it to the console's ELF loader, once. Any payload sender works:
|
||||
There is one file, `tailscale.elf`, and it is an ordinary payload: running
|
||||
it starts Tailscale.
|
||||
|
||||
1. Download `tailscale.elf` from the [latest release](../../releases/latest).
|
||||
2. Send it to the console's ELF loader. Any payload sender works:
|
||||
|
||||
```bash
|
||||
# Linux / macOS
|
||||
socat -t 60 - TCP:<console-ip>:9021 < tailscale-installer.elf
|
||||
socat -t 60 - TCP:<console-ip>:9021 < tailscale.elf
|
||||
```
|
||||
|
||||
```powershell
|
||||
# Windows (script from this repository)
|
||||
.\tools\ps5send.ps1 -File tailscale-installer.elf -PS5Host <console-ip> -Seconds 70
|
||||
.\tools\ps5send.ps1 -File tailscale.elf -PS5Host <console-ip>
|
||||
```
|
||||
|
||||
The installer prints what it does. It:
|
||||
- stores the daemon payload as `/data/tailscale/tailscale.elf`,
|
||||
- adds a **Tailscale** icon to the home screen (media section),
|
||||
- starts Tailscale.
|
||||
|
||||
3. Open `http://<console-ip>:8090` on a phone or PC. Scan the QR code or
|
||||
follow the link and log in to Tailscale. If your tailnet uses device
|
||||
approval, approve the console in the admin console.
|
||||
|
||||
The console now has a tailnet address, shown on the status page.
|
||||
|
||||
Sending the installer again upgrades and restarts Tailscale. The login is
|
||||
kept.
|
||||
The console now has a tailnet address, shown on the status page. The first
|
||||
run also adds a **Tailscale** icon to the home screen (media section) that
|
||||
opens the status page.
|
||||
|
||||
Tailscale runs until the console restarts. After a restart and jailbreak,
|
||||
send `/data/tailscale/tailscale.elf` (or the installer) to the ELF loader
|
||||
again. The installer does not change any payload autoloader; if you use one,
|
||||
you can add that file to it yourself.
|
||||
send `tailscale.elf` again; the login and settings are kept. If you use a
|
||||
payload manager or autoloader, add the file there like any other payload.
|
||||
|
||||
To update, use the new `tailscale.elf` in place of the old one. Sending it
|
||||
while Tailscale is running replaces the running copy.
|
||||
|
||||
## Using it
|
||||
|
||||
@@ -82,9 +83,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
|
||||
|
||||
@@ -159,6 +186,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 +201,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,19 +213,21 @@ Files on the console:
|
||||
| `/data/tailscale/state/` | Tailscale's state, including the login. |
|
||||
| `/data/tailscale/tailscale.log` | The daemon's log, rotated at 2 MB. |
|
||||
| `/data/tailscale/tailscale-debug.log` | Tailscale's detailed log, up to 4 MB plus one older file. |
|
||||
| `/data/tailscale/tailscale.elf` | The daemon payload. |
|
||||
| `/data/tailscale/icon-installed` | Marks that the home screen icon was added. Delete it to have the icon added again on the next start. |
|
||||
| `/user/app/TSCL00001/` | The home screen icon. |
|
||||
|
||||
## Uninstall
|
||||
|
||||
Press **Uninstall** on the status page. It deletes the daemon payload and
|
||||
stops Tailscale. It asks whether to also log out and delete the saved login.
|
||||
Press **Uninstall** on the status page. It logs the console out of your
|
||||
tailnet, deletes `/data/tailscale` (login, settings, logs) and stops
|
||||
Tailscale.
|
||||
|
||||
Two things are left to do by hand:
|
||||
Left to do by hand:
|
||||
|
||||
- Delete the home screen icon (Options button, then Delete).
|
||||
- Remove the device in the Tailscale admin console.
|
||||
- If you added the payload to an autoloader yourself, remove it there.
|
||||
- If you added `tailscale.elf` to a payload manager or autoloader, remove it
|
||||
there, or it starts again on the next boot.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@@ -214,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.
|
||||
|
||||
@@ -232,6 +264,9 @@ 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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
File renamed without changes.
+151
@@ -0,0 +1,151 @@
|
||||
/* 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.
|
||||
*
|
||||
* Prints "icon: ok" on success; the launcher looks 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 *);
|
||||
|
||||
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 (file_matches(APP_DIR "/sce_sys/param.json", param_json, param_size) &&
|
||||
file_matches(APP_DIR "/sce_sys/icon0.png", icon_png, icon_size)) {
|
||||
printf("icon: ok (already installed)\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ((err = sceAppInstUtilInitialize())) {
|
||||
printf("icon: sceAppInstUtilInitialize failed: 0x%08x\n", err);
|
||||
return 1;
|
||||
}
|
||||
mkdir(APP_DIR, 0755);
|
||||
mkdir(APP_DIR "/sce_sys", 0755);
|
||||
if (write_file(APP_DIR "/sce_sys/param.json", param_json, param_size) ||
|
||||
write_file(APP_DIR "/sce_sys/icon0.png", icon_png, icon_size)) {
|
||||
printf("icon: could not write to %s: %s\n", APP_DIR, strerror(errno));
|
||||
sceAppInstUtilTerminate();
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* Register just this title where the firmware supports it; otherwise ask
|
||||
* for a rescan of everything under /user/app. */
|
||||
if (!kernel_dynlib_handle(-1, "libSceAppInstUtil.sprx", &handle)) {
|
||||
install_title_dir = (void *)kernel_dynlib_resolve(-1, handle, "Wudg3Xe3heE");
|
||||
}
|
||||
if (install_title_dir) {
|
||||
err = install_title_dir(TITLE_ID, "/user/app/", 0);
|
||||
} else {
|
||||
err = sceAppInstUtilAppInstallAll(0);
|
||||
}
|
||||
sceAppInstUtilTerminate();
|
||||
if (err) {
|
||||
printf("icon: registering the app failed: 0x%08x\n", err);
|
||||
return 1;
|
||||
}
|
||||
printf("icon: ok (installed %s)\n", TITLE_ID);
|
||||
return 0;
|
||||
}
|
||||
File renamed without changes.
+6
-6
@@ -51,18 +51,18 @@ These folders are not in the repository.
|
||||
## Building the payloads
|
||||
|
||||
```powershell
|
||||
# daemon payload: C launcher + Go program -> out\tailscale.elf
|
||||
.\tools\build-payload.ps1 -GoDir tsd -Name tailscale -Version 0.3.0
|
||||
|
||||
# installer -> out\tailscale-installer.elf (embeds out\tailscale.elf)
|
||||
.\tools\build-installer.ps1
|
||||
# C launcher + Go program + home screen icon helper -> out\tailscale.elf
|
||||
.\tools\build-payload.ps1 -GoDir tsd -Name tailscale -Version 0.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.
|
||||
|
||||
+22
-5
@@ -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,11 +44,18 @@ 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 writes it to
|
||||
`/data/tailscale/tailscale.elf`, registers a home screen app whose
|
||||
`param.json` has a `deeplinkUri` to the status page, and starts the daemon by
|
||||
sending it to the ELF loader on `127.0.0.1:9021`. It does not modify any
|
||||
payload autoloader.
|
||||
**The icon helper** (`appicon/`, C) is a second, tiny payload embedded in the
|
||||
launcher. The first time `tailscale.elf` runs, the launcher sends it to the
|
||||
ELF loader on `127.0.0.1:9021`, where it runs as a process of its own,
|
||||
registers a home screen app whose `param.json` has a `deeplinkUri` to the
|
||||
status page, reports the result and exits. The launcher then writes
|
||||
`/data/tailscale/icon-installed` and never does it again. It is a separate
|
||||
payload so that the system libraries it needs are never loaded into the
|
||||
long-running daemon process, where their threads could receive signals meant
|
||||
for the Go runtime.
|
||||
|
||||
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
|
||||
|
||||
@@ -137,6 +151,9 @@ Linking `libSceAppInstUtil` alone leaves the payload stopped before it runs.
|
||||
It needs `-lSceIpmi -lSceAppInstUtil -lSceUserService -lSceSystemService`, in
|
||||
that order, as in the SDK's `install_app` sample.
|
||||
|
||||
The daemon cannot remove the icon; that is left to the user (Options, then
|
||||
Delete, on the home screen).
|
||||
|
||||
## Known problems
|
||||
|
||||
- Once, switching the console from Wi-Fi to Ethernet during a Moonlight
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 60 KiB |
@@ -1,310 +0,0 @@
|
||||
/* Tailscale installer payload for jailbroken PS5s.
|
||||
*
|
||||
* Stores the Tailscale daemon payload on the console, adds a home screen
|
||||
* icon that opens the status page, and starts the daemon through the ELF
|
||||
* loader on this console. It does not touch any payload autoloader. Build
|
||||
* with tools\build-installer.ps1, which sets DAEMON_ELF and
|
||||
* ASSET_DIR and links the system libraries the app installer needs. */
|
||||
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <stdarg.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <netinet/in.h>
|
||||
#include <sys/select.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/time.h>
|
||||
|
||||
#include <ps5/kernel.h>
|
||||
|
||||
#ifndef DAEMON_ELF
|
||||
#error "DAEMON_ELF must name the daemon payload to embed"
|
||||
#endif
|
||||
|
||||
#define DATA_DIR "/data/tailscale"
|
||||
#define DAEMON_NAME "tailscale.elf"
|
||||
#define LOADER_PORT 9021
|
||||
|
||||
extern const uint8_t daemon_elf[];
|
||||
extern const uint8_t daemon_elf_end[];
|
||||
|
||||
__asm__(".section .rodata\n"
|
||||
".balign 16\n"
|
||||
".global daemon_elf\n"
|
||||
"daemon_elf:\n"
|
||||
".incbin \"" DAEMON_ELF "\"\n"
|
||||
".global daemon_elf_end\n"
|
||||
"daemon_elf_end:\n"
|
||||
".text\n");
|
||||
|
||||
/* The home screen launcher: a media app whose only content is a link, which
|
||||
* the console opens in its browser. ASSET_DIR is set by the build. */
|
||||
#ifndef ASSET_DIR
|
||||
#error "ASSET_DIR must name the folder with param.json and icon0.png"
|
||||
#endif
|
||||
#define LAUNCHER_TITLE_ID "TSCL00001"
|
||||
#define LAUNCHER_DIR "/user/app/" LAUNCHER_TITLE_ID
|
||||
|
||||
#define INCASSET(name, file) \
|
||||
__asm__(".section .rodata\n" \
|
||||
".balign 16\n" \
|
||||
".global " #name "\n" #name ":\n" \
|
||||
".incbin \"" file "\"\n" \
|
||||
".global " #name "_end\n" #name "_end:\n" \
|
||||
".text\n"); \
|
||||
extern const uint8_t name[]; \
|
||||
extern const uint8_t name##_end[];
|
||||
|
||||
INCASSET(launcher_param_json, ASSET_DIR "/param.json")
|
||||
INCASSET(launcher_icon_png, ASSET_DIR "/icon0.png")
|
||||
|
||||
int sceAppInstUtilInitialize(void);
|
||||
int sceAppInstUtilTerminate(void);
|
||||
int sceAppInstUtilAppInstallAll(void *);
|
||||
|
||||
typedef struct notify_request {
|
||||
char useless1[45];
|
||||
char message[3075];
|
||||
} notify_request_t;
|
||||
|
||||
int sceKernelSendNotificationRequest(int, notify_request_t *, size_t, int);
|
||||
|
||||
static void
|
||||
notify(const char *fmt, ...) {
|
||||
notify_request_t req;
|
||||
va_list args;
|
||||
|
||||
memset(&req, 0, sizeof(req));
|
||||
va_start(args, fmt);
|
||||
vsnprintf(req.message, sizeof(req.message), fmt, args);
|
||||
va_end(args);
|
||||
sceKernelSendNotificationRequest(0, &req, sizeof(req), 0);
|
||||
}
|
||||
|
||||
static int
|
||||
write_all(int fd, const uint8_t *data, size_t size) {
|
||||
while (size > 0) {
|
||||
ssize_t n = write(fd, data, size);
|
||||
if (n < 0) {
|
||||
if (errno == EINTR) {
|
||||
continue;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
data += n;
|
||||
size -= n;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Write a file through a temporary name so a failed write never leaves a
|
||||
* truncated payload behind. */
|
||||
static int
|
||||
write_file(const char *path, const uint8_t *data, size_t size) {
|
||||
char tmp[512];
|
||||
int fd;
|
||||
|
||||
snprintf(tmp, sizeof(tmp), "%s.tmp", path);
|
||||
if ((fd = open(tmp, O_WRONLY | O_CREAT | O_TRUNC, 0755)) < 0) {
|
||||
return -1;
|
||||
}
|
||||
if (write_all(fd, data, size)) {
|
||||
close(fd);
|
||||
unlink(tmp);
|
||||
return -1;
|
||||
}
|
||||
close(fd);
|
||||
if (rename(tmp, path)) {
|
||||
unlink(tmp);
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int
|
||||
install_daemon(const char *dir) {
|
||||
char path[512];
|
||||
|
||||
mkdir(dir, 0755);
|
||||
snprintf(path, sizeof(path), "%s/%s", dir, DAEMON_NAME);
|
||||
if (write_file(path, daemon_elf, daemon_elf_end - daemon_elf)) {
|
||||
printf(" could not write %s: %s\n", path, strerror(errno));
|
||||
return -1;
|
||||
}
|
||||
printf(" wrote %s (%.1f MB)\n", path, (daemon_elf_end - daemon_elf) / 1048576.0);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Report whether the file at path already has exactly these contents. */
|
||||
static int
|
||||
file_matches(const char *path, const uint8_t *data, size_t size) {
|
||||
struct stat st;
|
||||
uint8_t *buf;
|
||||
int same = 0;
|
||||
FILE *f;
|
||||
|
||||
if (stat(path, &st) || (size_t)st.st_size != size || !(f = fopen(path, "rb"))) {
|
||||
return 0;
|
||||
}
|
||||
if ((buf = malloc(size))) {
|
||||
same = fread(buf, 1, size, f) == size && !memcmp(buf, data, size);
|
||||
free(buf);
|
||||
}
|
||||
fclose(f);
|
||||
return same;
|
||||
}
|
||||
|
||||
/* Put a "Tailscale" icon on the home screen that opens the status page in
|
||||
* the console's browser. Does nothing if it is already there and current.
|
||||
* Returns 0 on success. */
|
||||
static int
|
||||
install_launcher_app(void) {
|
||||
int (*install_title_dir)(const char *, const char *, void *) = 0;
|
||||
size_t param_size = launcher_param_json_end - launcher_param_json;
|
||||
size_t icon_size = launcher_icon_png_end - launcher_icon_png;
|
||||
uint32_t handle;
|
||||
int err;
|
||||
|
||||
if (file_matches(LAUNCHER_DIR "/sce_sys/param.json", launcher_param_json, param_size) &&
|
||||
file_matches(LAUNCHER_DIR "/sce_sys/icon0.png", launcher_icon_png, icon_size)) {
|
||||
printf(" already installed\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ((err = sceAppInstUtilInitialize())) {
|
||||
printf(" sceAppInstUtilInitialize failed: 0x%08x\n", err);
|
||||
return -1;
|
||||
}
|
||||
mkdir(LAUNCHER_DIR, 0755);
|
||||
mkdir(LAUNCHER_DIR "/sce_sys", 0755);
|
||||
if (write_file(LAUNCHER_DIR "/sce_sys/param.json", launcher_param_json, param_size) ||
|
||||
write_file(LAUNCHER_DIR "/sce_sys/icon0.png", launcher_icon_png, icon_size)) {
|
||||
printf(" could not write to %s: %s\n", LAUNCHER_DIR, strerror(errno));
|
||||
sceAppInstUtilTerminate();
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Register just this title where the firmware supports it; otherwise ask
|
||||
* for a rescan of everything under /user/app. */
|
||||
if (!kernel_dynlib_handle(-1, "libSceAppInstUtil.sprx", &handle)) {
|
||||
install_title_dir = (void *)kernel_dynlib_resolve(-1, handle, "Wudg3Xe3heE");
|
||||
}
|
||||
if (install_title_dir) {
|
||||
err = install_title_dir(LAUNCHER_TITLE_ID, "/user/app/", 0);
|
||||
} else {
|
||||
err = sceAppInstUtilAppInstallAll(0);
|
||||
}
|
||||
sceAppInstUtilTerminate();
|
||||
if (err) {
|
||||
printf(" registering the app failed: 0x%08x\n", err);
|
||||
return -1;
|
||||
}
|
||||
printf(" installed (%s), opens http://127.0.0.1:8090/\n", LAUNCHER_TITLE_ID);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Hand the daemon to the ELF loader on this console and relay what it prints
|
||||
* for a while, so that the login link reaches whoever sent the installer. */
|
||||
static int
|
||||
start_daemon(int relay_seconds) {
|
||||
struct sockaddr_in addr = {0};
|
||||
struct timeval start, now;
|
||||
char buf[4096];
|
||||
int fd;
|
||||
|
||||
if ((fd = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
|
||||
return -1;
|
||||
}
|
||||
addr.sin_family = AF_INET;
|
||||
addr.sin_port = htons(LOADER_PORT);
|
||||
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||
if (connect(fd, (struct sockaddr *)&addr, sizeof(addr))) {
|
||||
close(fd);
|
||||
return -1;
|
||||
}
|
||||
if (write_all(fd, daemon_elf, daemon_elf_end - daemon_elf)) {
|
||||
close(fd);
|
||||
return -1;
|
||||
}
|
||||
|
||||
gettimeofday(&start, 0);
|
||||
for (;;) {
|
||||
struct timeval tv = {1, 0};
|
||||
fd_set rfds;
|
||||
|
||||
gettimeofday(&now, 0);
|
||||
if (now.tv_sec - start.tv_sec >= relay_seconds) {
|
||||
break;
|
||||
}
|
||||
FD_ZERO(&rfds);
|
||||
FD_SET(fd, &rfds);
|
||||
if (select(fd + 1, &rfds, 0, 0, &tv) <= 0) {
|
||||
continue;
|
||||
}
|
||||
ssize_t n = read(fd, buf, sizeof(buf));
|
||||
if (n <= 0) {
|
||||
break;
|
||||
}
|
||||
if (write_all(STDOUT_FILENO, (uint8_t *)buf, n)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
close(fd);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int
|
||||
main(void) {
|
||||
pid_t pid = getpid();
|
||||
intptr_t rootvnode;
|
||||
|
||||
setvbuf(stdout, 0, _IONBF, 0);
|
||||
|
||||
/* Run as root outside the sandbox so /data and USB drives are writable. */
|
||||
if ((rootvnode = kernel_get_root_vnode())) {
|
||||
kernel_set_proc_rootdir(pid, rootvnode);
|
||||
kernel_set_proc_jaildir(pid, 0);
|
||||
}
|
||||
kernel_set_ucred_uid(pid, 0);
|
||||
kernel_set_ucred_ruid(pid, 0);
|
||||
kernel_set_ucred_svuid(pid, 0);
|
||||
kernel_set_ucred_rgid(pid, 0);
|
||||
kernel_set_ucred_svgid(pid, 0);
|
||||
|
||||
#ifdef LAUNCHER_ONLY
|
||||
/* Test build: only (re)install the home screen icon. */
|
||||
printf("Home screen icon:\n");
|
||||
return install_launcher_app() ? 1 : 0;
|
||||
#endif
|
||||
|
||||
printf("Tailscale for PS5 installer\n\n");
|
||||
|
||||
printf("Daemon payload:\n");
|
||||
if (install_daemon(DATA_DIR)) {
|
||||
notify("Tailscale install failed:\ncould not write to %s", DATA_DIR);
|
||||
return 1;
|
||||
}
|
||||
|
||||
printf("Home screen icon:\n");
|
||||
install_launcher_app();
|
||||
|
||||
printf("\nStarting Tailscale...\n");
|
||||
if (start_daemon(45)) {
|
||||
printf("Could not reach the ELF loader on port %d: %s\n", LOADER_PORT, strerror(errno));
|
||||
notify("Tailscale is installed but could not be started:\nno ELF loader on port %d.", LOADER_PORT);
|
||||
return 1;
|
||||
}
|
||||
|
||||
printf("\nDone. The status page is on port 8090 of this console.\n"
|
||||
"Tailscale runs until the console restarts. To start it again, send\n"
|
||||
"%s/%s to the ELF loader.\n",
|
||||
DATA_DIR, DAEMON_NAME);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/* 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 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);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
if (marker_is_current()) {
|
||||
return;
|
||||
}
|
||||
mkdir(DATA_DIR, 0755);
|
||||
|
||||
if ((fd = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
|
||||
return;
|
||||
}
|
||||
addr.sin_family = AF_INET;
|
||||
addr.sin_port = htons(LOADER_PORT);
|
||||
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||
if (connect(fd, (struct sockaddr *)&addr, sizeof(addr))) {
|
||||
/* No ELF loader on the usual port: go without an icon this time. */
|
||||
close(fd);
|
||||
return;
|
||||
}
|
||||
while (left > 0) {
|
||||
ssize_t n = write(fd, data, left);
|
||||
if (n <= 0) {
|
||||
close(fd);
|
||||
return;
|
||||
}
|
||||
data += n;
|
||||
left -= n;
|
||||
}
|
||||
|
||||
/* The helper reports "icon: ok" or what went wrong, then exits, which
|
||||
* closes the connection. Give it 20 seconds. */
|
||||
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
|
||||
for (int tries = 0; tries < 20 && got < sizeof(reply) - 1; tries++) {
|
||||
const char *line = strstr(reply, "icon: ");
|
||||
if (line && strchr(line, '\n')) {
|
||||
break;
|
||||
}
|
||||
ssize_t n = read(fd, reply + got, sizeof(reply) - 1 - got);
|
||||
if (n == 0) {
|
||||
break;
|
||||
}
|
||||
if (n > 0) {
|
||||
got += n;
|
||||
}
|
||||
}
|
||||
close(fd);
|
||||
|
||||
if (strstr(reply, "icon: ok")) {
|
||||
if ((fd = open(ICON_MARKER, O_WRONLY | O_CREAT | O_TRUNC, 0644)) >= 0) {
|
||||
write(fd, ICON_VERSION, sizeof(ICON_VERSION) - 1);
|
||||
close(fd);
|
||||
}
|
||||
fprintf(stderr, "launcher: home screen icon installed\n");
|
||||
} else {
|
||||
fprintf(stderr, "launcher: home screen icon not installed: %s\n", got ? reply : "no reply from the helper");
|
||||
}
|
||||
}
|
||||
|
||||
#else
|
||||
|
||||
void
|
||||
home_icon_install_once(void) {
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,5 @@
|
||||
#pragma once
|
||||
|
||||
/* Put the Tailscale icon on the home screen if that has not been done yet.
|
||||
* Never fails: without an icon everything else still works. */
|
||||
void home_icon_install_once(void);
|
||||
@@ -10,6 +10,7 @@
|
||||
#include <ps5/kernel.h>
|
||||
|
||||
#include "goload.h"
|
||||
#include "homeicon.h"
|
||||
|
||||
#ifndef GO_IMAGE
|
||||
#error "GO_IMAGE must name the Go binary to embed"
|
||||
@@ -134,6 +135,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. */
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/* Ask the console's Remote Play service for its discovery reply over
|
||||
* loopback, to confirm it answers clients that arrive from 127.0.0.1 (which
|
||||
* is how the daemon's UDP relay reaches it). Read-only. */
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <netinet/in.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/time.h>
|
||||
|
||||
int
|
||||
main(void) {
|
||||
static const char req[] = "SRCH * HTTP/1.1\ndevice-discovery-protocol-version:00030010\n";
|
||||
struct sockaddr_in addr = {0};
|
||||
struct timeval tv = {3, 0};
|
||||
char buf[1024];
|
||||
int fd = socket(AF_INET, SOCK_DGRAM, 0);
|
||||
ssize_t n;
|
||||
|
||||
setvbuf(stdout, 0, _IONBF, 0);
|
||||
addr.sin_family = AF_INET;
|
||||
addr.sin_port = htons(9302);
|
||||
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
|
||||
sendto(fd, req, sizeof(req) - 1, 0, (struct sockaddr *)&addr, sizeof(addr));
|
||||
n = recv(fd, buf, sizeof(buf) - 1, 0);
|
||||
if (n <= 0) {
|
||||
printf("discovery via 127.0.0.1:9302: no reply\n");
|
||||
} else {
|
||||
buf[n] = 0;
|
||||
buf[strcspn(buf, "\r\n")] = 0;
|
||||
printf("discovery via 127.0.0.1:9302: %s\n", buf);
|
||||
}
|
||||
close(fd);
|
||||
return 0;
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
# Build the installer payload around an already built daemon payload.
|
||||
# .\tools\build-installer.ps1 [-Daemon out\tailscale.elf] [-Send]
|
||||
param(
|
||||
[string]$Daemon = 'out\tailscale.elf',
|
||||
[string]$Out = 'out\tailscale-installer.elf',
|
||||
[switch]$Send,
|
||||
[int]$Seconds = 70
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
. (Join-Path $PSScriptRoot 'env.ps1')
|
||||
|
||||
$daemonPath = (Resolve-Path (Join-Path $DevRoot $Daemon)).Path -replace '\\', '/'
|
||||
$assets = (Join-Path $DevRoot 'installer\assets') -replace '\\', '/'
|
||||
$outPath = Join-Path $DevRoot $Out
|
||||
|
||||
# The app installer library only loads when these come with it, in this
|
||||
# order (as in the SDK's install_app sample). With libSceAppInstUtil alone
|
||||
# the payload never starts: the loader leaves it stopped.
|
||||
Invoke-PS5CC -O2 -Wall "-DDAEMON_ELF=`"$daemonPath`"" "-DASSET_DIR=`"$assets`"" `
|
||||
-lSceIpmi -lSceAppInstUtil -lSceUserService -lSceSystemService `
|
||||
-o $outPath (Join-Path $DevRoot 'installer\main.c')
|
||||
|
||||
Write-Host ("built {0} ({1:N1} MB)" -f $outPath, ((Get-Item $outPath).Length / 1MB))
|
||||
if ($Send) {
|
||||
& (Join-Path $PSScriptRoot 'ps5send.ps1') -File $outPath -Seconds $Seconds
|
||||
}
|
||||
+16
-1
@@ -1,4 +1,5 @@
|
||||
# Build a Go program for the PS5 and wrap it in the launcher payload.
|
||||
# .\tools\build-payload.ps1 -GoDir tsd -Name tailscale -Version 0.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))
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
+46
@@ -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"
|
||||
@@ -119,6 +122,9 @@ type daemon struct {
|
||||
|
||||
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 {
|
||||
@@ -174,8 +180,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)
|
||||
@@ -199,6 +207,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
|
||||
}
|
||||
|
||||
@@ -214,6 +229,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.
|
||||
|
||||
+3
-5
@@ -161,6 +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);
|
||||
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; }));
|
||||
@@ -238,11 +239,8 @@ $('btn-sunshine').onclick = async () => {
|
||||
await act('/api/sunshine?host=' + encodeURIComponent($('sunshine-select').value));
|
||||
$('sunshine-select').dataset.signature = '';
|
||||
};
|
||||
$('btn-uninstall').onclick = () => {
|
||||
if (!confirm('Remove Tailscale from this PS5? It stops now and its payload is deleted.')) return;
|
||||
const purge = confirm('Also log out and delete the saved login?\n\nOK: delete everything.\nCancel: keep the login, so reinstalling reconnects without logging in again.');
|
||||
act('/api/uninstall' + (purge ? '?purge=1' : ''));
|
||||
};
|
||||
$('btn-uninstall').onclick = () => act('/api/uninstall',
|
||||
'Remove Tailscale from this PS5?\n\nThis logs the console out of your tailnet, deletes its settings and logs, and stops Tailscale.');
|
||||
$('logbox').addEventListener('toggle', refresh);
|
||||
|
||||
refresh();
|
||||
|
||||
+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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// daemonFileName is the name the installer stores the daemon payload under,
|
||||
// in the data directory.
|
||||
const daemonFileName = "tailscale.elf"
|
||||
|
||||
// uninstall deletes the installed daemon payload. With purge it also deletes
|
||||
// the Tailscale state and config, which forgets the login. The running
|
||||
// process is not affected.
|
||||
func uninstall(purge bool) error {
|
||||
var errs []error
|
||||
if err := os.Remove(filepath.Join(dataDir, daemonFileName)); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
if purge {
|
||||
for _, name := range []string{"state", "config.json", ".cache"} {
|
||||
if err := os.RemoveAll(filepath.Join(dataDir, name)); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
+16
-12
@@ -46,7 +46,9 @@ type statusInfo struct {
|
||||
// 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) {
|
||||
@@ -139,6 +141,10 @@ func (d *daemon) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
info.SunshineHost = d.cfg.SunshineHost
|
||||
d.mu.Unlock()
|
||||
info.UDPPorts = []uint16{}
|
||||
if d.udp != nil {
|
||||
info.UDPPorts = append(info.UDPPorts, d.udp.activePorts()...)
|
||||
}
|
||||
info.Forwards = []string{}
|
||||
for _, r := range d.fwd.rules() {
|
||||
info.Forwards = append(info.Forwards, r.String())
|
||||
@@ -296,22 +302,20 @@ func (d *daemon) stop() {
|
||||
d.quitOnce.Do(func() { close(d.quit) })
|
||||
}
|
||||
|
||||
// handleUninstall removes the installed payload, then stops. With ?purge=1 it
|
||||
// first logs the console out of the tailnet and deletes the saved state as
|
||||
// well.
|
||||
// handleUninstall logs the console out of the tailnet and stops the daemon,
|
||||
// which deletes its data directory (login, settings, logs) on the way out.
|
||||
// The payload file itself is wherever the user keeps it, and the home screen
|
||||
// icon can only be deleted from the home screen.
|
||||
func (d *daemon) handleUninstall(w http.ResponseWriter, r *http.Request) {
|
||||
purge := r.URL.Query().Get("purge") == "1"
|
||||
if purge && d.lc != nil {
|
||||
if d.lc != nil {
|
||||
if err := d.lc.Logout(r.Context()); err != nil {
|
||||
d.logf("uninstall: logout: %v", err)
|
||||
}
|
||||
}
|
||||
if err := uninstall(purge); err != nil {
|
||||
d.logf("uninstall: %v", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
d.logf("uninstalled (purge=%v)", purge)
|
||||
d.logf("uninstall requested from the status page")
|
||||
d.mu.Lock()
|
||||
d.removeDataOnExit = true
|
||||
d.mu.Unlock()
|
||||
notify("Tailscale was removed from this PS5.")
|
||||
io.WriteString(w, "uninstalled\n")
|
||||
d.stop()
|
||||
|
||||
Reference in new issue
Block a user