diff --git a/README.md b/README.md index 9b6638d..a8e1a6d 100644 --- a/README.md +++ b/README.md @@ -83,9 +83,10 @@ 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 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`. +- UDP ports have to be listed, under **Settings** on the status page. The + default list is Remote Play's. +- To keep a TCP port off the tailnet, list it under "TCP ports never + exposed" in the settings. ### Remote Play @@ -108,20 +109,32 @@ 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 video passes through the daemon, which by default runs at the lowest + priority so that it never takes time from a game. If the stream stutters + under a demanding game, set **Priority** to High in the settings and start + Tailscale again. +- Waking the console from rest mode does not work: nothing runs while it + sleeps, so it is not on the tailnet then. Tailscale carries on by itself + once the console is awake again. ### The status page `http://:8090`, on the LAN or over the tailnet, or the **Tailscale** icon on the home screen. It shows the connection state, the -login link, and your devices, and has controls for game streaming, logging -out, stopping and uninstalling. +login link and your devices, and has the game streaming hosts, the settings, +and buttons for logging out, stopping and uninstalling. It also says when a +newer release is available. -It has **no password**, like the console's other homebrew services. Anyone on -your LAN, or on your tailnet if your ACLs allow it, can use it. +**Password.** Out of the box the page has no password, like the console's +other homebrew services: anyone on your LAN, or on your tailnet if your ACLs +allow it, can use it. Set one under **Settings**. It is then asked for on +every device except the console itself. If you forget it, delete the +`passwordHash` line from `/data/tailscale/config.json`. + +**Settings.** The name on the tailnet, the password, which UDP ports are +reachable and which TCP ports are not, extra forwards, the HTTP proxy, the +priority, and update checks. Most take effect when saved; the page says which +ones need Tailscale to be started again. ### Game streaming (Moonlight to Sunshine) @@ -130,29 +143,35 @@ else, over Tailscale. On the PC: -1. Install [Sunshine](https://github.com/LizardByte/Sunshine) and leave it on - its default port (47989). +1. Install [Sunshine](https://github.com/LizardByte/Sunshine). 2. Install Tailscale and log in to the same tailnet as the console. On the console: 1. Open the status page and find **Game streaming**. -2. Choose the device that runs Sunshine and press **Save**. The page shows - "Forwarding 127.0.0.1 to *your-pc* (7 ports)". +2. Press **Add a host**, enter the device that runs Sunshine and press + **Save**. The page then shows what to enter in Moonlight. 3. In your Moonlight client on the PS5, add a host manually with the address **`127.0.0.1`**. Do not enter the PC's tailnet address: the client cannot reach it. 4. Pair as usual: the client shows a PIN, which you enter in Sunshine's web interface on the PC. -How it works: the daemon listens on `127.0.0.1` on Sunshine's ports (TCP -47984, 47989, 48010 and UDP 47998, 47999, 48000, 48002) and relays them to -the chosen host through Tailscale. To the Moonlight client the Sunshine host -appears to be the console itself. +How it works: the daemon listens on `127.0.0.1` on Sunshine's ports (by +default TCP 47984, 47989, 48010 and UDP 47998, 47999, 48000, 48002) and +relays them to the host through Tailscale. To the Moonlight client the +Sunshine host appears to be the console itself. + +More than one host, or a host that does not use the default port: + +- If a host's Sunshine is set to another port (Sunshine's "Port" setting), + enter that port next to the host. In Moonlight, add `127.0.0.1:`. +- Several hosts can be forwarded at once, but they all appear on + `127.0.0.1`, so each needs its own port: give every host a different port + in Sunshine, at least 30 apart (for example 47989 and 48989). Notes: -- One Sunshine host at a time. Change it on the status page at any time. - A wired connection on the console helps, as with any streaming. - **Do not change the console's network (Wi-Fi to Ethernet, connection settings) while a stream is running.** That froze the test console once; @@ -161,33 +180,43 @@ Notes: ### Other apps on the console -`forwards` in the [configuration](#configuration) relays any localhost port -to a tailnet host in the same way, TCP or UDP. +"Extra forwards" in the settings relay any localhost port to a tailnet host +in the same way, TCP or UDP. One per line, for example +`tcp 127.0.0.1:8096 my-nas:8096`. -The daemon also runs an HTTP proxy on `127.0.0.1:8118` that reaches tailnet -hosts, for apps that have their own proxy setting. **Do not set it as the -PS5's system proxy.** The system then sends everything through it, including -pages on `127.0.0.1`, and it is not running until Tailscale has been loaded. -On the test console that stopped another homebrew tool's page from opening. +The daemon can also run an HTTP proxy that reaches tailnet hosts, for apps +that have their own proxy setting. It is off unless you give it an address in +the settings (for example `127.0.0.1:8118`). **Do not set it as the PS5's +system proxy.** The system then sends everything through it, including pages +on `127.0.0.1`, and it is not running until Tailscale has been loaded. On the +test console that stopped another homebrew tool's page from opening. ## Configuration -`/data/tailscale/config.json` is created on first start. Every field is -optional. Restart Tailscale (send the payload again) to apply edits. +Use **Settings** on the status page. The settings are stored in +`/data/tailscale/config.json`, which can also be edited by hand; start +Tailscale again (send the payload) to apply hand edits. Every field is +optional. ```json { "hostname": "ps5", "authKey": "", "webAddr": ":8090", - "httpProxyAddr": "127.0.0.1:8118", + "passwordHash": "", + "httpProxyAddr": "", "controlURL": "", - "sunshineHost": "", + "sunshineHosts": [ + {"host": "gaming-pc"}, + {"host": "office-pc", "port": 48989} + ], "forwards": [ {"proto": "tcp", "listen": "127.0.0.1:8096", "target": "my-nas:8096"} ], "udpPorts": [9295, 9296, 9297, 9302], "blockedPorts": [], + "priority": "", + "checkUpdates": true, "verbose": false } ``` @@ -195,14 +224,17 @@ optional. Restart Tailscale (send the payload again) to apply edits. | Field | Meaning | | --- | --- | | `hostname` | The console's name on the tailnet. | -| `authKey` | A Tailscale auth key, to log in without the browser step. | +| `authKey` | A Tailscale auth key, to log in without the browser step. File only. | | `webAddr` | Where the status page listens. | -| `httpProxyAddr` | Where the HTTP proxy listens. Empty turns it off. | -| `controlURL` | A coordination server other than Tailscale's. | -| `sunshineHost` | The Sunshine host; set from the status page. | +| `passwordHash` | The status page's password, hashed. Set it on the status page; delete the field to remove a forgotten password. | +| `httpProxyAddr` | Where the HTTP proxy listens. Empty, the default, is off. | +| `controlURL` | A coordination server other than Tailscale's. File only. | +| `sunshineHosts` | The Sunshine hosts and, where it is not 47989, their port. | | `forwards` | Extra local forwards: `proto` is `tcp` or `udp`, `listen` a localhost address, `target` a tailnet host and port. | | `udpPorts` | The console's UDP ports reachable from the tailnet. Default `[9295, 9296, 9297, 9302]` (Remote Play). `[]` turns inbound UDP off. | | `blockedPorts` | Local TCP ports that are never exposed to the tailnet. | +| `priority` | `"high"` lets the daemon compete with games for CPU time; anything else is the default, low. Applied when Tailscale starts. | +| `checkUpdates` | Ask GitHub twice a day whether a newer release exists, to show it on the status page. Nothing is downloaded. | | `verbose` | Put Tailscale's own log in the main log as well. | Files on the console: @@ -214,17 +246,17 @@ Files on the console: | `/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/icon-installed` | Marks that the home screen icon was added. Delete it to have the icon added again on the next start. | +| `/data/tailscale/icon-helper.elf` | The small payload that adds and removes the icon. | | `/user/app/TSCL00001/` | The home screen icon. | ## Uninstall -Press **Uninstall** on the status page. It logs the console out of your -tailnet, deletes `/data/tailscale` (login, settings, logs) and stops -Tailscale. +Press **Uninstall** on the status page. It removes the home screen icon, logs +the console out of your tailnet, deletes `/data/tailscale` (login, settings, +logs) and stops Tailscale. Left to do by hand: -- Delete the home screen icon (Options button, then Delete). - Remove the device in the Tailscale admin console. - If you added `tailscale.elf` to a payload manager or autoloader, remove it there, or it starts again on the next boot. @@ -234,41 +266,58 @@ Left to do by hand: - **The status page does not open on the console, but does from a PC.** Check that the PS5's proxy server setting is "Do Not Use". - **The Moonlight client cannot find the host.** The host to add is - `127.0.0.1`, and a Sunshine host must be selected on the status page. - Sunshine must be on its default port. + `127.0.0.1` (or `127.0.0.1:` for a host on another port), and the + Sunshine host must be listed on the status page with the port its Sunshine + uses. - **"Not logged in" after logging in.** Press **Log in again** for a fresh link. +- **Forgot the status page password.** Delete the `passwordHash` line from + `/data/tailscale/config.json` and start Tailscale again, or use the page on + the console itself, where no password is asked. - **Something else.** `http://:8090/api/logs?full=1` is the daemon's log and `/api/logs?debug=1` is Tailscale's detailed log. Please attach them to bug reports, after checking them for anything you consider private. ## Security -- The status page and its controls are unauthenticated. +- The status page and its controls have no password until you set one. With + a password, only the console itself gets in without it. The page is served + over plain HTTP: on the LAN the password travels unencrypted, over the + tailnet Tailscale encrypts it. - All listening TCP ports on the console, and the UDP ports in `udpPorts`, become reachable from your tailnet. That includes the payload loader, which runs anything sent to it. Use Tailscale ACLs if other people share your - tailnet. -- The local forwards and the proxy listen on `127.0.0.1` only and are not - exposed to the tailnet. + tailnet, or list ports under "TCP ports never exposed". +- The local forwards and the proxy are for the console's own apps and are + not exposed to the tailnet. +- With update checks on, the console contacts `api.github.com` twice a day. ## Resource use -About 60 MB of memory and next to no CPU when idle. The daemon runs at the -lowest scheduling priority on at most 4 cores, so it gives way to games. +About 60 MB of memory and next to no CPU when idle. By default the daemon +runs at the lowest scheduling priority on at most 4 cores, so it gives way to +games. With the priority set to High it shares those cores with games on +equal terms. ## What has and has not been tested -Tested on the one console: install and upgrade, login with device approval, +Tested on the one console: first run and upgrade, login with device approval, starting again after a reboot with the saved login, reaching the console over the tailnet, a ProsperoLight stream from a Sunshine host through the forward, -the HTTP proxy, the home screen icon. +two forwarded hosts on different ports (with a stand-in for the second), the +HTTP proxy, adding and removing the home screen icon, the password from the +LAN and the tailnet, changing settings from the page, both priority settings, +the update check, a short stay in rest mode (about a minute: the same process +carried on and was back on the tailnet within a second of waking). 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. +Not tested: hours in rest mode, switching between Wi-Fi and Ethernet while +running, the complete Uninstall +on a console (its parts were tested separately), whether High priority +improves Remote Play, a real Sunshine host on a non-default port, other +firmware versions, coordination servers other than Tailscale's. ## Building diff --git a/appicon/main.c b/appicon/main.c index dc63c46..4baac0a 100644 --- a/appicon/main.c +++ b/appicon/main.c @@ -6,7 +6,11 @@ * 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. + * The same payload removes the icon again when its mode byte says so (see + * icon_mode below); the daemon uses that for Uninstall. + * + * Prints "icon: ok" or "icon: removed" on success; the launcher and the + * daemon look for that. * * Build with -DASSET_DIR="path/to/appicon" and link, in this order, * -lSceIpmi -lSceAppInstUtil -lSceUserService -lSceSystemService (with @@ -47,6 +51,37 @@ INCASSET(icon_png, ASSET_DIR "/icon0.png") int sceAppInstUtilInitialize(void); int sceAppInstUtilTerminate(void); int sceAppInstUtilAppInstallAll(void *); +int sceAppInstUtilAppUnInstall(const char *); + +/* What to do. A payload sent to the ELF loader gets no arguments, so the + * mode is a byte in the file itself: the daemon changes the character after + * the '=' to 'R' before sending the helper when it wants the icon removed + * (see tsd/homeicon.go). It is volatile so that the compiler reads it at run + * time instead of baking the install branch in. */ +volatile char icon_mode[] = "TSICON-MODE=I"; + +static int +remove_icon(void) { + int err; + + if ((err = sceAppInstUtilInitialize())) { + printf("icon: sceAppInstUtilInitialize failed: 0x%08x\n", err); + return 1; + } + err = sceAppInstUtilAppUnInstall(TITLE_ID); + sceAppInstUtilTerminate(); + /* Whatever the system left behind of the folder goes too. */ + unlink(APP_DIR "/sce_sys/param.json"); + unlink(APP_DIR "/sce_sys/icon0.png"); + rmdir(APP_DIR "/sce_sys"); + rmdir(APP_DIR); + if (err) { + printf("icon: removing the app failed: 0x%08x\n", err); + return 1; + } + printf("icon: removed\n"); + return 0; +} static int write_file(const char *path, const uint8_t *data, size_t size) { @@ -112,6 +147,10 @@ main(void) { kernel_set_ucred_rgid(pid, 0); kernel_set_ucred_svgid(pid, 0); + if (icon_mode[sizeof(icon_mode) - 2] == 'R') { + return remove_icon(); + } + if (file_matches(APP_DIR "/sce_sys/param.json", param_json, param_size) && file_matches(APP_DIR "/sce_sys/icon0.png", icon_png, icon_size)) { printf("icon: ok (already installed)\n"); diff --git a/docs/TECHNICAL.md b/docs/TECHNICAL.md index 67fff2c..92aa79f 100644 --- a/docs/TECHNICAL.md +++ b/docs/TECHNICAL.md @@ -36,11 +36,29 @@ specification. minutes. - Outbound: local forwards (`localforward.go`) listen on localhost and relay TCP and UDP to a tailnet host through `tsnet.Server.Dial`. UDP is relayed - per client address with an idle timeout. The Sunshine setting is a preset - of seven such forwards. -- A status page and small JSON API on port 8090, an HTTP proxy on - `127.0.0.1:8118`, PS5 notifications by writing a request to + per client address with an idle timeout. Each Sunshine host is a preset of + seven such forwards on the ports that host really uses, derived from + Sunshine's port setting (HTTPS -5, HTTP +0, RTSP +21, video +9, control + +10, audio +11, microphone +13). The ports cannot be remapped, because the + host tells the Moonlight client which ports to use; several hosts can only + coexist on 127.0.0.1 if their Sunshine ports differ. +- A status page and JSON API on port 8090 (`web.go`, `settings.go`), an + optional HTTP proxy, PS5 notifications by writing a request to `/dev/notification0`. +- Password (`auth.go`): PBKDF2-SHA256 hash in the config, session cookie, + one attempt per second. Requests from loopback are exempt. For that to be + safe, connections for the status page that arrive over the tailnet are not + piped to localhost like other ports but handed to the page's HTTP server + directly, so it sees the tailnet address. +- Settings are applied live where possible. The launcher needs one of them, + the priority, before any Go code runs, so the daemon leaves it in + `/data/tailscale/priority` for the next start. +- Update notice (`update.go`): the latest release tag from the GitHub API, + twice a day, compared with the running version. +- When a listener reports that it had to reopen its socket (the PS5's + network was reconfigured), the daemon asks Tailscale to rebind and re-STUN + instead of waiting for its interface polling. See [Rest mode](#rest-mode) + for the one time this has been seen. - A payload that is sent again stops the running instance (through the status page, or failing that by the pid it recorded) and takes over. @@ -54,6 +72,12 @@ payload so that the system libraries it needs are never loaded into the long-running daemon process, where their threads could receive signals meant for the Go runtime. +The same helper removes the icon (`sceAppInstUtilAppUnInstall`). A payload +sent to the ELF loader gets no arguments, so the mode is a byte in the file +after the marker `TSICON-MODE=`. The launcher leaves a copy of the helper in +`/data/tailscale/icon-helper.elf`; for Uninstall the daemon flips that byte +and sends it to the loader (`tsd/homeicon.go`). + There is no installer. Nothing is copied anywhere and no payload autoloader is touched: the payload is run from wherever the user keeps it. @@ -140,6 +164,32 @@ works across cores (4 spinning goroutines, 5 garbage collections in about 350 ms). Test builds can add a watchdog thread that kills the process after a fixed time (`build-payload.ps1 -Watchdog`). +The "high" priority setting uses class 2 (round-robin) at priority 700 +instead: equal to games and system threads, but equal-priority round-robin +threads take turns. With it, the same test passes (5 collections in about +300 ms) and the console stays responsive: the status page answered within +60 ms throughout while four goroutines spun. + +## Rest mode + +Observed once, for a rest of about a minute on Ethernet. The process is not +killed: it is frozen with the rest of the console and continues afterwards. + +- Going to sleep, the network is taken down first. Every socket fails with + errno 163 at the same moment: the status page listener, Tailscale's relay + connection and its connection to the coordination server. The listener + reopened at once, the daemon asked for a rebind, and Tailscale saw "all + links down" and paused. +- Nothing is logged while the console sleeps, and it is not reachable on the + tailnet. +- On waking, Tailscale's monitor noticed the jump in the clock, rebound its + sockets, reconnected to its relay and had its endpoints back within about + 300 ms. The status page, forwarded TCP ports and the Remote Play UDP ports + answered through the tailnet address afterwards without anything being + restarted. + +A rest of hours has not been tried, nor one on Wi-Fi. + ## Home screen icon `/user/app/TSCL00001/sce_sys/param.json` with `applicationCategoryType` 65536 @@ -151,9 +201,6 @@ Linking `libSceAppInstUtil` alone leaves the payload stopped before it runs. It needs `-lSceIpmi -lSceAppInstUtil -lSceUserService -lSceSystemService`, in that order, as in the SDK's `install_app` sample. -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 @@ -165,5 +212,6 @@ Delete, on the home screen). request was answered with "auth path not found". The daemon now requests a new link when it sees that error; the recovery path has not been observed in practice. -- Whether Tailscale's own UDP sockets recover after a network - reconfiguration has not been examined. +- Tailscale's sockets recovered after rest mode took the network down and + brought it back. A change of interface (Wi-Fi to Ethernet or back) while + the daemon runs has not been observed since the rebind request was added. diff --git a/docs/status-page.png b/docs/status-page.png index 20ac98d..6d12f56 100644 Binary files a/docs/status-page.png and b/docs/status-page.png differ diff --git a/launcher/homeicon.c b/launcher/homeicon.c index 307a296..7bab337 100644 --- a/launcher/homeicon.c +++ b/launcher/homeicon.c @@ -27,6 +27,7 @@ * home screen stays deleted. Remove the file to get the icon back. */ #define ICON_MARKER DATA_DIR "/icon-installed" #define ICON_VERSION "1\n" +#define ICON_HELPER_FILE DATA_DIR "/icon-helper.elf" #define LOADER_PORT 9021 extern const uint8_t icon_helper[]; @@ -54,6 +55,30 @@ marker_is_current(void) { return !strcmp(buf, ICON_VERSION); } +/* Leave a copy of the helper where the daemon can find it. Uninstall runs it + * again, switched to removing the icon. */ +static void +save_helper(void) { + size_t size = icon_helper_end - icon_helper; + struct stat st; + int fd; + + if (!stat(ICON_HELPER_FILE, &st) && (size_t)st.st_size == size) { + return; + } + if ((fd = open(ICON_HELPER_FILE, O_WRONLY | O_CREAT | O_TRUNC, 0644)) < 0) { + return; + } + for (size_t done = 0; done < size;) { + ssize_t n = write(fd, icon_helper + done, size - done); + if (n <= 0) { + break; + } + done += n; + } + close(fd); +} + void home_icon_install_once(void) { struct sockaddr_in addr = {0}; @@ -64,10 +89,11 @@ home_icon_install_once(void) { size_t got = 0; int fd; + mkdir(DATA_DIR, 0755); + save_helper(); if (marker_is_current()) { return; } - mkdir(DATA_DIR, 0755); if ((fd = socket(AF_INET, SOCK_STREAM, 0)) < 0) { return; diff --git a/launcher/main.c b/launcher/main.c index 7379aa3..03ace43 100644 --- a/launcher/main.c +++ b/launcher/main.c @@ -3,6 +3,7 @@ #include #include +#include #include #include @@ -63,30 +64,48 @@ raw_syscall3(long n, long a, long b, long c) { * priority, where nothing this process does can keep the system's own threads * off the CPU. Threads created later inherit the setting. The kernel ignores * priorities outside its own range without reporting an error, so the result - * is read back. Round-robin at the lowest priority is the fallback. */ + * is read back. Round-robin at the lowest priority is the fallback. + * + * With the "high" priority setting the process instead becomes round-robin + * at the default priority: it then competes with games on equal terms, but + * equal-priority round-robin threads take turns, so even then a thread that + * never blocks cannot shut the others out. */ +static int +high_priority_requested(void) { + char buf[16] = {0}; + FILE *f = fopen("/data/tailscale/priority", "r"); + + if (!f) { + return 0; + } + fgets(buf, sizeof(buf), f); + fclose(f); + return !strncmp(buf, "high", 4); +} + static int leave_realtime_class(void) { static const struct rtprio choices[] = { + {RTP_PRIO_REALTIME, PS5_PRIO_DEFAULT}, /* only with the "high" setting */ {RTP_PRIO_NORMAL, PS5_PRIO_LOWEST}, {RTP_PRIO_REALTIME, PS5_PRIO_LOWEST}, }; struct rtprio before = {0}, after = {0}; + int ok = 0; raw_syscall3(SYS_rtprio_thread, RTP_LOOKUP, 0, (long)&before); - for (size_t i = 0; i < sizeof(choices) / sizeof(choices[0]); i++) { + for (size_t i = high_priority_requested() ? 0 : 1; i < sizeof(choices) / sizeof(choices[0]) && !ok; i++) { struct rtprio want = choices[i]; raw_syscall3(SYS_rtprio_thread, RTP_SET, 0, (long)&want); raw_syscall3(SYS_rtprio_thread, RTP_LOOKUP, 0, (long)&after); - if (after.type == choices[i].type && after.prio == choices[i].prio) { - break; - } + ok = after.type == choices[i].type && after.prio == choices[i].prio; } #ifdef GOLOAD_DEBUG fprintf(stderr, "launcher: scheduling class %u/%u -> %u/%u\n", before.type, before.prio, after.type, after.prio); #else (void)before; #endif - return after.prio > PS5_PRIO_DEFAULT && after.type != before.type ? 0 : -1; + return ok ? 0 : -1; } #ifdef GOLOAD_WATCHDOG diff --git a/probe-c/fwdtest.c b/probe-c/fwdtest.c index 3732914..206eea6 100644 --- a/probe-c/fwdtest.c +++ b/probe-c/fwdtest.c @@ -14,6 +14,11 @@ #ifndef HTTP_PATH #define HTTP_PATH "/hello.txt" #endif +/* Sunshine's "port" setting on the host under test; its HTTP port is this + * and its video (UDP) port is this plus 9. */ +#ifndef BASE_PORT +#define BASE_PORT 47989 +#endif static struct sockaddr_in local(int port) { @@ -42,12 +47,12 @@ main(void) { setvbuf(stdout, 0, _IONBF, 0); /* TCP */ - addr = local(47989); + addr = local(BASE_PORT); fd = socket(AF_INET, SOCK_STREAM, 0); setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); double t0 = now(); if (connect(fd, (struct sockaddr *)&addr, sizeof(addr))) { - printf("tcp 127.0.0.1:47989: cannot connect (no forward listening)\n"); + printf("tcp 127.0.0.1:%d: cannot connect (no forward listening)\n", BASE_PORT); } else { const char *req = "GET " HTTP_PATH " HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n"; size_t total = 0; @@ -59,9 +64,9 @@ main(void) { char *body = strstr(buf, "\r\n\r\n"); char *eol = strstr(buf, "\r\n"); if (!total) { - printf("tcp 127.0.0.1:47989: connected but no response\n"); + printf("tcp 127.0.0.1:%d: connected but no response\n", BASE_PORT); } else { - printf("tcp 127.0.0.1:47989: %.*s (%.0f ms)\n", eol ? (int)(eol - buf) : 60, buf, (now() - t0) * 1000); + printf("tcp 127.0.0.1:%d: %.*s (%.0f ms)\n", BASE_PORT, eol ? (int)(eol - buf) : 60, buf, (now() - t0) * 1000); if (body) { printf(" body: %.400s\n", body + 4); } @@ -71,7 +76,7 @@ main(void) { #ifndef SKIP_UDP /* UDP */ - addr = local(47998); + addr = local(BASE_PORT + 9); fd = socket(AF_INET, SOCK_DGRAM, 0); setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); int ok = 0; @@ -90,7 +95,7 @@ main(void) { } } } - printf("udp 127.0.0.1:47998: %d/20 datagrams of 1300 bytes echoed, worst round trip %.1f ms\n", ok, worst); + printf("udp 127.0.0.1:%d: %d/20 datagrams of 1300 bytes echoed, worst round trip %.1f ms\n", BASE_PORT + 9, ok, worst); close(fd); #endif return 0; diff --git a/probe-c/pskill.c b/probe-c/pskill.c index f3b4aa4..e1ad4bb 100644 --- a/probe-c/pskill.c +++ b/probe-c/pskill.c @@ -48,9 +48,10 @@ main(void) { int stray = ki->ki_pid >= MIN_PID && ki->ki_pid != self && !strcmp(tdname, "payload.elf"); if (ki->ki_pid >= MIN_PID - 40 || stray) { #endif - printf("%6d %-20s %-20s rss=%ldMB threads=%d cpu=%.2fs stat=%d wait=%.8s%s\n", ki->ki_pid, ki->ki_comm, - tdname, (long)(ki->ki_rssize * 16384L >> 20), ki->ki_numthreads, ki->ki_runtime / 1e6, (int)ki->ki_stat, - ki->ki_wmesg, stray ? " <- killing" : ""); + printf("%6d %-20s %-20s rss=%ldMB threads=%d cpu=%.2fs stat=%d wait=%.8s sched=%d/%d%s\n", ki->ki_pid, + ki->ki_comm, tdname, (long)(ki->ki_rssize * 16384L >> 20), ki->ki_numthreads, ki->ki_runtime / 1e6, + (int)ki->ki_stat, ki->ki_wmesg, (int)ki->ki_pri.pri_class, (int)ki->ki_pri.pri_user, + stray ? " <- killing" : ""); } if (stray) { if (kill(ki->ki_pid, SIGKILL)) { diff --git a/tsd/auth.go b/tsd/auth.go new file mode 100644 index 0000000..b6593a2 --- /dev/null +++ b/tsd/auth.go @@ -0,0 +1,248 @@ +package main + +import ( + "crypto/pbkdf2" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "encoding/json" + "io" + "net" + "net/http" + "net/netip" + "strconv" + "strings" + "sync" + "time" +) + +// The status page can be given a password. Without one it trusts whoever can +// reach it, like the other services on a jailbroken console. With one, the +// page and its API ask for it, except from the console itself: someone at +// the console can do anything anyway, and typing a password with a +// controller is no fun. +// +// A browser that has entered the password gets a session cookie. + +const ( + sessionCookie = "ps5ts_session" + sessionLifetime = 30 * 24 * time.Hour + pbkdf2Rounds = 210_000 +) + +// hashPassword returns the stored form of a password: +// "pbkdf2-sha256$$$". +func hashPassword(password string) (string, error) { + salt := make([]byte, 16) + if _, err := rand.Read(salt); err != nil { + return "", err + } + key, err := pbkdf2.Key(sha256.New, password, salt, pbkdf2Rounds, 32) + if err != nil { + return "", err + } + return "pbkdf2-sha256$" + strconv.Itoa(pbkdf2Rounds) + "$" + hex.EncodeToString(salt) + "$" + hex.EncodeToString(key), nil +} + +// checkPassword reports whether password matches a stored hash. +func checkPassword(stored, password string) bool { + parts := strings.Split(stored, "$") + if len(parts) != 4 || parts[0] != "pbkdf2-sha256" { + return false + } + rounds, err := strconv.Atoi(parts[1]) + if err != nil || rounds < 1 || rounds > 10_000_000 { + return false + } + salt, err1 := hex.DecodeString(parts[2]) + want, err2 := hex.DecodeString(parts[3]) + if err1 != nil || err2 != nil || len(want) == 0 { + return false + } + got, err := pbkdf2.Key(sha256.New, password, salt, rounds, len(want)) + return err == nil && subtle.ConstantTimeCompare(got, want) == 1 +} + +// sessions are the browsers that have entered the password. +type sessions struct { + mu sync.Mutex + tokens map[string]time.Time // token -> expiry + attempt sync.Mutex // serializes password attempts +} + +func (s *sessions) create() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + token := hex.EncodeToString(b) + s.mu.Lock() + defer s.mu.Unlock() + if s.tokens == nil { + s.tokens = map[string]time.Time{} + } + now := time.Now() + for t, exp := range s.tokens { + if now.After(exp) { + delete(s.tokens, t) + } + } + s.tokens[token] = now.Add(sessionLifetime) + return token, nil +} + +func (s *sessions) valid(token string) bool { + s.mu.Lock() + defer s.mu.Unlock() + exp, ok := s.tokens[token] + return ok && time.Now().Before(exp) +} + +func (s *sessions) remove(token string) { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.tokens, token) +} + +// clear ends every session, for when the password changes. +func (s *sessions) clear() { + s.mu.Lock() + defer s.mu.Unlock() + s.tokens = nil +} + +// fromConsole reports whether the request was made on the console itself. +// Connections from the tailnet are served directly (see tailnetListener), so +// they arrive with their tailnet address, not as loopback. +func fromConsole(r *http.Request) bool { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return false + } + ip, err := netip.ParseAddr(host) + return err == nil && ip.Unmap().IsLoopback() +} + +// authorized reports whether the request may use the page: there is no +// password, it comes from the console itself, or it carries a session. +func (d *daemon) authorized(r *http.Request) bool { + d.mu.Lock() + hash := d.cfg.PasswordHash + d.mu.Unlock() + if hash == "" || fromConsole(r) { + return true + } + c, err := r.Cookie(sessionCookie) + return err == nil && d.sessions.valid(c.Value) +} + +// protect wraps a handler that needs the password, if one is set. +func (d *daemon) protect(h http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !d.authorized(r) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]any{"locked": true, "version": version}) + return + } + h(w, r) + } +} + +// handleAuth checks a password and starts a session. +func (d *daemon) handleAuth(w http.ResponseWriter, r *http.Request) { + var req struct { + Password string `json:"password"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&req); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + d.mu.Lock() + hash := d.cfg.PasswordHash + d.mu.Unlock() + + // One attempt at a time, and a wrong one costs a second: enough to make + // guessing over the network pointless. + d.sessions.attempt.Lock() + ok := hash == "" || checkPassword(hash, req.Password) + if !ok { + time.Sleep(time.Second) + } + d.sessions.attempt.Unlock() + if !ok { + d.logf("status page: wrong password from %s", r.RemoteAddr) + http.Error(w, "wrong password", http.StatusForbidden) + return + } + + token, err := d.sessions.create() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + http.SetCookie(w, &http.Cookie{ + Name: sessionCookie, + Value: token, + Path: "/", + MaxAge: int(sessionLifetime.Seconds()), + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + }) + io.WriteString(w, "ok\n") +} + +// handleLock ends the browser's session. +func (d *daemon) handleLock(w http.ResponseWriter, r *http.Request) { + if c, err := r.Cookie(sessionCookie); err == nil { + d.sessions.remove(c.Value) + } + http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteStrictMode}) + io.WriteString(w, "ok\n") +} + +// tailnetListener hands the status page's server the connections that arrive +// for it over the tailnet. They could be piped to the page's port on +// localhost like any other, but then every tailnet device would look like +// the console itself and get past the password. +type tailnetListener struct { + conns chan net.Conn + closed chan struct{} + once sync.Once +} + +func newTailnetListener() *tailnetListener { + return &tailnetListener{conns: make(chan net.Conn, 16), closed: make(chan struct{})} +} + +// deliver gives a tailnet connection to the server. +func (l *tailnetListener) deliver(c net.Conn) { + select { + case l.conns <- c: + case <-l.closed: + c.Close() + } +} + +func (l *tailnetListener) Accept() (net.Conn, error) { + select { + case c := <-l.conns: + return c, nil + case <-l.closed: + return nil, net.ErrClosed + } +} + +func (l *tailnetListener) Close() error { + l.once.Do(func() { close(l.closed) }) + return nil +} + +func (l *tailnetListener) Addr() net.Addr { return tailnetAddr{} } + +type tailnetAddr struct{} + +func (tailnetAddr) Network() string { return "tailnet" } +func (tailnetAddr) String() string { return "tailnet" } diff --git a/tsd/config.go b/tsd/config.go index 44037ea..1995b39 100644 --- a/tsd/config.go +++ b/tsd/config.go @@ -9,6 +9,7 @@ import ( ) // config is read from /data/tailscale/config.json. Every field is optional. +// Most of it can be edited on the status page. type config struct { // Hostname is the name this console gets on the tailnet. Hostname string `json:"hostname"` @@ -16,15 +17,21 @@ type config struct { AuthKey string `json:"authKey,omitempty"` // WebAddr is where the status page listens. WebAddr string `json:"webAddr"` - // HTTPProxyAddr is where the outbound HTTP proxy listens. Pointing the - // PS5's proxy setting at it lets the console reach tailnet hosts. Empty - // disables the proxy. + // PasswordHash protects the status page. Empty means no password. It is + // set from the status page; delete the field to remove a forgotten + // password. + PasswordHash string `json:"passwordHash,omitempty"` + // HTTPProxyAddr is where the outbound HTTP proxy listens. Empty, the + // default, turns the proxy off. HTTPProxyAddr string `json:"httpProxyAddr"` // ControlURL selects a coordination server other than Tailscale's. ControlURL string `json:"controlURL,omitempty"` - // SunshineHost is a tailnet device running Sunshine. When set, its - // streaming ports are forwarded from 127.0.0.1, so a Moonlight client on - // the console can use 127.0.0.1 as the host. + // SunshineHosts are tailnet devices running Sunshine. Their streaming + // ports are forwarded from 127.0.0.1, so a Moonlight client on the + // console can use 127.0.0.1 as the host. + SunshineHosts []sunshineHost `json:"sunshineHosts,omitempty"` + // SunshineHost is the single-host setting of earlier versions. It is + // folded into SunshineHosts when the config is loaded. SunshineHost string `json:"sunshineHost,omitempty"` // Forwards are extra local forwards: a localhost port on the console // relayed to a host on the tailnet. @@ -35,16 +42,23 @@ type config struct { UDPPorts []uint16 `json:"udpPorts"` // BlockedPorts lists local TCP ports that are never exposed to the tailnet. BlockedPorts []uint16 `json:"blockedPorts,omitempty"` + // Priority is how the daemon competes for CPU time: "low" (the default) + // never takes time from a game, "high" shares the CPU with games on + // equal terms, which can make Remote Play smoother. Applied at start. + Priority string `json:"priority,omitempty"` + // CheckUpdates makes the daemon ask GitHub now and then whether a newer + // release exists, to say so on the status page. + CheckUpdates bool `json:"checkUpdates"` // Verbose turns on Tailscale's own (very chatty) logging. Verbose bool `json:"verbose,omitempty"` } func defaultConfig() config { return config{ - Hostname: "ps5", - WebAddr: ":8090", - HTTPProxyAddr: "127.0.0.1:8118", - UDPPorts: slices.Clone(remotePlayUDPPorts), + Hostname: "ps5", + WebAddr: ":8090", + UDPPorts: slices.Clone(remotePlayUDPPorts), + CheckUpdates: true, } } @@ -62,13 +76,29 @@ func loadConfig(path string) (config, error) { if err := json.Unmarshal(b, &cfg); err != nil { return defaultConfig(), err } + cfg.normalize() + return cfg, nil +} + +// normalize fills in what must not be empty and brings settings from earlier +// versions into their current form. +func (cfg *config) normalize() { if cfg.Hostname == "" { cfg.Hostname = "ps5" } if cfg.WebAddr == "" { cfg.WebAddr = ":8090" } - return cfg, nil + if cfg.SunshineHost != "" { + known := slices.ContainsFunc(cfg.SunshineHosts, func(h sunshineHost) bool { return h.Host == cfg.SunshineHost }) + if !known { + cfg.SunshineHosts = append(cfg.SunshineHosts, sunshineHost{Host: cfg.SunshineHost}) + } + cfg.SunshineHost = "" + } + if cfg.Priority != priorityHigh { + cfg.Priority = "" + } } func saveConfig(path string, cfg config) error { diff --git a/tsd/favicon.png b/tsd/favicon.png new file mode 100644 index 0000000..26abd48 Binary files /dev/null and b/tsd/favicon.png differ diff --git a/tsd/forward.go b/tsd/forward.go index 428e470..e6c0f10 100644 --- a/tsd/forward.go +++ b/tsd/forward.go @@ -21,7 +21,15 @@ import ( // rather than a connection that opens and closes. func (d *daemon) forwardToLocalhost(src, dst netip.AddrPort) (handler func(net.Conn), intercept bool) { port := dst.Port() - if slices.Contains(d.cfg.BlockedPorts, port) || port == d.proxyPort || d.fwd.listensOnTCP(port) { + if port == d.webPort { + // The status page is served on the tailnet connection itself rather + // than through localhost, so that the page sees who is asking. + return d.tailnetWeb.deliver, true + } + d.mu.Lock() + blocked := slices.Contains(d.cfg.BlockedPorts, port) || port == d.proxyPort + d.mu.Unlock() + if blocked || d.fwd.listensOnTCP(port) { // The outbound proxy and the local forwards are for the console's // own apps. Exposing them would let any tailnet device use the // console as a relay. @@ -40,11 +48,7 @@ func (d *daemon) forwardToLocalhost(src, dst netip.AddrPort) (handler func(net.C if !abandoned.Stop() { return } - if port != d.webPort { - // The status page polls every few seconds; logging its own - // requests would bury everything else. - d.logf("forward %v -> localhost:%d", src, port) - } + d.logf("forward %v -> localhost:%d", src, port) pipe(c, local) }, true } diff --git a/tsd/homeicon.go b/tsd/homeicon.go new file mode 100644 index 0000000..84cc01d --- /dev/null +++ b/tsd/homeicon.go @@ -0,0 +1,86 @@ +package main + +import ( + "bytes" + "errors" + "fmt" + "net" + "os" + "path/filepath" + "strings" + "time" +) + +// The home screen icon is installed by a small helper payload that the +// launcher carries (see appicon/ and launcher/homeicon.c). The launcher also +// leaves a copy of the helper in the data directory, so that Uninstall can +// run it again to take the icon away: the system call for that lives in +// libraries this process must not load. +// +// The helper installs or removes depending on one byte in it, after a marker +// string; removing is a matter of flipping that byte before sending it to +// the ELF loader. + +const ( + iconHelperFile = "icon-helper.elf" + iconModeMarker = "TSICON-MODE=" + iconModeRemove = 'R' + elfLoaderAddr = "127.0.0.1:9021" + iconHelperTimeout = 20 * time.Second +) + +// removeHomeIcon takes the Tailscale icon off the home screen. +func removeHomeIcon() error { + helper, err := os.ReadFile(filepath.Join(dataDir, iconHelperFile)) + if err != nil { + return err + } + i := bytes.Index(helper, []byte(iconModeMarker)) + if i < 0 || i+len(iconModeMarker) >= len(helper) { + return errors.New("the icon helper is not one this version understands") + } + helper[i+len(iconModeMarker)] = iconModeRemove + + c, err := net.DialTimeout("tcp", elfLoaderAddr, 3*time.Second) + if err != nil { + return fmt.Errorf("no ELF loader to run the icon helper: %w", err) + } + defer c.Close() + c.SetDeadline(time.Now().Add(iconHelperTimeout)) + if _, err := c.Write(helper); err != nil { + return err + } + + // The helper prints "icon: removed" or what went wrong, and exits. + var reply []byte + buf := make([]byte, 512) + for len(reply) < 4096 { + n, err := c.Read(buf) + reply = append(reply, buf[:n]...) + if line := iconReplyLine(reply); line != "" { + if strings.HasPrefix(line, "icon: removed") { + return nil + } + return errors.New(line) + } + if err != nil { + break + } + } + return errors.New("no answer from the icon helper") +} + +// iconReplyLine returns the helper's complete "icon: ..." line, if it has +// arrived. +func iconReplyLine(reply []byte) string { + i := bytes.Index(reply, []byte("icon: ")) + if i < 0 { + return "" + } + rest := reply[i:] + j := bytes.IndexByte(rest, '\n') + if j < 0 { + return "" + } + return strings.TrimSpace(string(rest[:j])) +} diff --git a/tsd/inboundudp.go b/tsd/inboundudp.go index 5c8f942..7079912 100644 --- a/tsd/inboundudp.go +++ b/tsd/inboundudp.go @@ -35,22 +35,24 @@ type udpExposer struct { mu sync.Mutex addrs []netip.Addr ports []uint16 - stops []func() + relays []*udpRelay active []uint16 } // update makes ports reachable on addrs, replacing whatever was exposed -// before. It does nothing if neither has changed. +// before. It does nothing if neither has changed and every relay is still +// running. func (e *udpExposer) update(addrs []netip.Addr, ports []uint16) { e.mu.Lock() defer e.mu.Unlock() - if slices.Equal(addrs, e.addrs) && slices.Equal(ports, e.ports) { + healthy := !slices.ContainsFunc(e.relays, func(r *udpRelay) bool { return !r.running() }) + if healthy && slices.Equal(addrs, e.addrs) && slices.Equal(ports, e.ports) { return } - for _, stop := range e.stops { - stop() + for _, r := range e.relays { + r.stop() } - e.stops, e.active = nil, nil + e.relays, e.active = nil, nil e.addrs, e.ports = slices.Clone(addrs), slices.Clone(ports) for _, port := range ports { @@ -62,7 +64,7 @@ func (e *udpExposer) update(addrs []netip.Addr, ports []uint16) { network = "udp6" } listenAddr := netip.AddrPortFrom(addr, port).String() - stop, err := startUDPRelay(udpRelayConfig{ + relay, err := startUDPRelay(udpRelayConfig{ name: "udp " + listenAddr, listen: func() (net.PacketConn, error) { return e.listen(network, listenAddr) }, dial: func(ctx context.Context) (net.Conn, error) { @@ -75,7 +77,7 @@ func (e *udpExposer) update(addrs []netip.Addr, ports []uint16) { e.logf("udp %s: %v", listenAddr, err) continue } - e.stops = append(e.stops, stop) + e.relays = append(e.relays, relay) ok = true } if ok { diff --git a/tsd/listener.go b/tsd/listener.go index addea11..ce7b31e 100644 --- a/tsd/listener.go +++ b/tsd/listener.go @@ -18,6 +18,8 @@ type resilientListener struct { network string addr string logf func(format string, args ...any) + // onReopen, if set, is called after the socket had to be reopened. + onReopen func() mu sync.Mutex ln net.Listener @@ -80,6 +82,9 @@ func (l *resilientListener) reopen() bool { l.ln = ln l.mu.Unlock() l.logf("listener %s: reopened", l.addr) + if l.onReopen != nil { + l.onReopen() + } return true } } diff --git a/tsd/localforward.go b/tsd/localforward.go index 876c645..121b052 100644 --- a/tsd/localforward.go +++ b/tsd/localforward.go @@ -30,30 +30,86 @@ func (r forwardRule) String() string { return fmt.Sprintf("%s %s -> %s", r.Proto, r.Listen, r.Target) } -// Ports a Sunshine host uses with its default base port (47989). +// sunshineHost is a device on the tailnet that runs Sunshine. +type sunshineHost struct { + Host string `json:"host"` + // Port is Sunshine's "port" setting, which all its other ports are + // derived from. 0 means the default, 47989. + Port int `json:"port,omitempty"` +} + +const sunshineDefaultPort = 47989 + +func (h sunshineHost) basePort() int { + if h.Port == 0 { + return sunshineDefaultPort + } + return h.Port +} + +// Sunshine's ports as offsets from its "port" setting. var ( - sunshineTCPPorts = []int{47984, 47989, 48010} // HTTPS, HTTP, RTSP - sunshineUDPPorts = []int{47998, 47999, 48000, 48002} // video, control, audio, microphone + sunshineTCPOffsets = []int{-5, 0, 21} // HTTPS, HTTP, RTSP + sunshineUDPOffsets = []int{9, 10, 11, 13} // video, control, audio, microphone ) -// sunshineRules returns the forwards that make the Sunshine host on the -// tailnet appear on 127.0.0.1 to a Moonlight client on the console. -func sunshineRules(host string) []forwardRule { - if host == "" { - return nil - } +// rules returns the forwards that make this Sunshine host appear on +// 127.0.0.1, on the same ports it really uses. The ports have to match: the +// host tells the Moonlight client which ports to connect to. +func (h sunshineHost) rules() []forwardRule { var rules []forwardRule - for _, p := range sunshineTCPPorts { - port := strconv.Itoa(p) - rules = append(rules, forwardRule{"tcp", net.JoinHostPort("127.0.0.1", port), net.JoinHostPort(host, port)}) + add := func(proto string, offsets []int) { + for _, off := range offsets { + port := strconv.Itoa(h.basePort() + off) + rules = append(rules, forwardRule{proto, net.JoinHostPort("127.0.0.1", port), net.JoinHostPort(h.Host, port)}) + } } - for _, p := range sunshineUDPPorts { - port := strconv.Itoa(p) - rules = append(rules, forwardRule{"udp", net.JoinHostPort("127.0.0.1", port), net.JoinHostPort(host, port)}) + add("tcp", sunshineTCPOffsets) + add("udp", sunshineUDPOffsets) + return rules +} + +// clientAddress is what to enter as the host in a Moonlight client on the +// console to reach this Sunshine host. +func (h sunshineHost) clientAddress() string { + if h.basePort() == sunshineDefaultPort { + return "127.0.0.1" + } + return net.JoinHostPort("127.0.0.1", strconv.Itoa(h.basePort())) +} + +// sunshineRules returns the forwards for all hosts. +func sunshineRules(hosts []sunshineHost) []forwardRule { + var rules []forwardRule + for _, h := range hosts { + rules = append(rules, h.rules()...) } return rules } +// validateSunshineHosts checks that the hosts can be forwarded side by side. +// They all share 127.0.0.1, so each needs its own set of ports, which means +// each must use a different port setting in Sunshine. +func validateSunshineHosts(hosts []sunshineHost) error { + used := map[string]string{} + for _, h := range hosts { + if h.Host == "" || !validHostName(h.Host) { + return fmt.Errorf("%q does not look like a host name or address", h.Host) + } + if p := h.basePort(); p < 1024+5 || p > 65535-21 { + return fmt.Errorf("port %d for %s is out of range", p, h.Host) + } + for _, r := range h.rules() { + key := r.Proto + " " + r.Listen + if other, taken := used[key]; taken { + return fmt.Errorf("%s and %s use overlapping ports; give each Sunshine host its own port setting", other, h.Host) + } + used[key] = h.Host + } + } + return nil +} + type dialFunc func(ctx context.Context, network, addr string) (net.Conn, error) // forwarder runs a set of local forwards. @@ -152,10 +208,14 @@ func (f *forwarder) serveTCP(c net.Conn, r forwardRule) { } func (f *forwarder) startUDP(r forwardRule) (stop func(), err error) { - return startUDPRelay(udpRelayConfig{ + relay, err := startUDPRelay(udpRelayConfig{ name: "forward " + r.String(), listen: func() (net.PacketConn, error) { return net.ListenPacket("udp", r.Listen) }, dial: func(ctx context.Context) (net.Conn, error) { return f.dial(ctx, "udp", r.Target) }, logf: f.logf, }) + if err != nil { + return nil, err + } + return relay.stop, nil } diff --git a/tsd/localforward_test.go b/tsd/localforward_test.go index 1337b4b..f489fa0 100644 --- a/tsd/localforward_test.go +++ b/tsd/localforward_test.go @@ -134,21 +134,73 @@ func TestLocalForward(t *testing.T) { } func TestSunshineRules(t *testing.T) { - if got := sunshineRules(""); got != nil { - t.Errorf("no host: got %v", got) + if got := sunshineRules(nil); got != nil { + t.Errorf("no hosts: got %v", got) } - rules := sunshineRules("gaming-pc") - if len(rules) != 7 { - t.Fatalf("got %d rules, want 7", len(rules)) - } - if got, want := rules[0].String(), "tcp 127.0.0.1:47984 -> gaming-pc:47984"; got != want { - t.Errorf("first rule %q, want %q", got, want) - } - for _, r := range rules { + + // Default port: the well-known Sunshine ports. + def := sunshineHost{Host: "gaming-pc"} + var got []string + for _, r := range def.rules() { + got = append(got, r.String()) if !strings.HasPrefix(r.Listen, "127.0.0.1:") { t.Errorf("%v does not listen on localhost only", r) } } + want := []string{ + "tcp 127.0.0.1:47984 -> gaming-pc:47984", + "tcp 127.0.0.1:47989 -> gaming-pc:47989", + "tcp 127.0.0.1:48010 -> gaming-pc:48010", + "udp 127.0.0.1:47998 -> gaming-pc:47998", + "udp 127.0.0.1:47999 -> gaming-pc:47999", + "udp 127.0.0.1:48000 -> gaming-pc:48000", + "udp 127.0.0.1:48002 -> gaming-pc:48002", + } + if strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Errorf("default port rules:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) + } + if a := def.clientAddress(); a != "127.0.0.1" { + t.Errorf("client address %q", a) + } + + // A host on another port keeps its own port numbers, shifted as a set. + alt := sunshineHost{Host: "office-pc", Port: 48989} + if r := alt.rules(); r[0].String() != "tcp 127.0.0.1:48984 -> office-pc:48984" || r[6].String() != "udp 127.0.0.1:49002 -> office-pc:49002" { + t.Errorf("custom port rules: %v", r) + } + if a := alt.clientAddress(); a != "127.0.0.1:48989" { + t.Errorf("client address %q", a) + } + if n := len(sunshineRules([]sunshineHost{def, alt})); n != 14 { + t.Errorf("two hosts: %d rules, want 14", n) + } +} + +func TestValidateSunshineHosts(t *testing.T) { + ok := [][]sunshineHost{ + nil, + {{Host: "gaming-pc"}}, + {{Host: "gaming-pc"}, {Host: "office-pc", Port: 48989}}, + {{Host: "100.64.0.2", Port: 50000}}, + } + for _, hosts := range ok { + if err := validateSunshineHosts(hosts); err != nil { + t.Errorf("%v: unexpected error %v", hosts, err) + } + } + bad := [][]sunshineHost{ + {{Host: ""}}, + {{Host: "bad host"}}, + {{Host: "a"}, {Host: "b"}}, // same ports + {{Host: "a"}, {Host: "b", Port: 47989 + 5}}, // b's HTTPS port is a's HTTP port + {{Host: "a", Port: 80}}, // too low + {{Host: "a", Port: 65530}}, // derived ports past 65535 + } + for _, hosts := range bad { + if err := validateSunshineHosts(hosts); err == nil { + t.Errorf("%v: expected an error", hosts) + } + } } func TestIsLocalDestination(t *testing.T) { diff --git a/tsd/main.go b/tsd/main.go index e98f4ab..e0139fe 100644 --- a/tsd/main.go +++ b/tsd/main.go @@ -115,10 +115,16 @@ type daemon struct { lastErr string notified string // last state the user was notified about - lastTsnetMsg string - proxyPort uint16 // port of the outbound HTTP proxy, 0 if disabled - webPort uint16 // port of the status page - lastRelogin time.Time + lastTsnetMsg string + proxyLn *resilientListener // the outbound HTTP proxy, nil if disabled + proxyPort uint16 // its port, 0 if disabled + webPort uint16 // port of the status page + lastRelogin time.Time + lastNetChange time.Time + latest releaseInfo // newest release known, see update.go + + sessions sessions // browsers that have entered the password + tailnetWeb *tailnetListener // status page connections arriving over the tailnet quit chan struct{} quitOnce sync.Once @@ -154,8 +160,13 @@ func (d *daemon) run() error { if err != nil { return fmt.Errorf("web UI: %w", err) } + webLn.onReopen = d.networkChanged d.webPort = webLn.port() - go d.serveWeb(webLn) + d.tailnetWeb = newTailnetListener() + handler := d.webHandler() + go d.serveWeb(webLn, handler) + go d.serveWeb(d.tailnetWeb, handler) + d.writePriorityFile() if err := d.srv.Start(); err != nil { return fmt.Errorf("starting tailscale: %w", err) @@ -165,13 +176,8 @@ func (d *daemon) run() error { return fmt.Errorf("local client: %w", err) } - if d.cfg.HTTPProxyAddr != "" { - if ln, err := listenResilient("tcp", d.cfg.HTTPProxyAddr, d.logf); err != nil { - d.logf("http proxy: %v", err) - } else { - d.proxyPort = ln.port() - go d.serveProxy(ln) - } + if err := d.setProxy(d.cfg.HTTPProxyAddr); err != nil { + d.logf("http proxy: %v", err) } d.fwd.set(d.localForwardRules()) @@ -184,6 +190,7 @@ func (d *daemon) run() error { go d.watch(ctx) go d.recoverLogin(ctx) go d.exposeUDP(ctx) + go d.watchForUpdates(ctx) sigc := make(chan os.Signal, 1) signal.Notify(sigc, syscall.SIGTERM, syscall.SIGINT) @@ -195,6 +202,7 @@ func (d *daemon) run() error { } cancel() webLn.Close() + d.tailnetWeb.Close() done := make(chan struct{}) go func() { @@ -226,7 +234,33 @@ func (d *daemon) dialTailnet(ctx context.Context, network, addr string) (net.Con func (d *daemon) localForwardRules() []forwardRule { d.mu.Lock() defer d.mu.Unlock() - return append(sunshineRules(d.cfg.SunshineHost), d.cfg.Forwards...) + return append(sunshineRules(d.cfg.SunshineHosts), d.cfg.Forwards...) +} + +// networkChanged is called when a listening socket has died and been +// reopened, which on the PS5 means the network was reconfigured (connection +// settings changed, Wi-Fi to Ethernet, ...). Tailscale notices changes by +// polling the interfaces; this tells it straight away to open fresh sockets +// and work out its addresses again. +func (d *daemon) networkChanged() { + d.mu.Lock() + recent := time.Since(d.lastNetChange) < 10*time.Second + d.lastNetChange = time.Now() + lc := d.lc + d.mu.Unlock() + if recent || lc == nil { + return + } + d.logf("the console's network changed; asking Tailscale to rebind") + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + for _, action := range []string{"rebind", "restun"} { + if err := lc.DebugAction(ctx, action); err != nil { + d.logf("tailscale %s: %v", action, err) + } + } + }() } // exposeUDP keeps the configured UDP ports listening on the console's tailnet diff --git a/tsd/proxy.go b/tsd/proxy.go index 600c5a9..174dade 100644 --- a/tsd/proxy.go +++ b/tsd/proxy.go @@ -2,6 +2,7 @@ package main import ( "context" + "errors" "io" "net" "net/http" @@ -52,7 +53,9 @@ func (d *daemon) serveProxy(ln net.Listener) { io.Copy(w, resp.Body) }), } - if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed && !d.stopping() { + // Serve returns when the listener is closed, which is how the proxy is + // turned off or moved from the settings. + if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed && !errors.Is(err, net.ErrClosed) && !d.stopping() { d.logf("http proxy stopped: %v", err) } } diff --git a/tsd/settings.go b/tsd/settings.go new file mode 100644 index 0000000..bade88e --- /dev/null +++ b/tsd/settings.go @@ -0,0 +1,292 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "os" + "path/filepath" + "slices" + "strconv" + "strings" + "time" + + "tailscale.com/ipn" +) + +// Settings editing from the status page. Most settings take effect at once; +// the few that are only read when the payload starts are reported back so +// the page can say so. + +const ( + priorityLow = "low" + priorityHigh = "high" + // priorityFile tells the launcher which scheduling class to use. The + // launcher is C and runs before any of this, so it gets the one setting + // it needs in a file of its own rather than parsing the config. + priorityFile = "priority" +) + +// settings is the editable part of the config as the status page sees it. +type settings struct { + Hostname string `json:"hostname"` + WebAddr string `json:"webAddr"` + HTTPProxyAddr string `json:"httpProxyAddr"` + SunshineHosts []sunshineHost `json:"sunshineHosts"` + Forwards []forwardRule `json:"forwards"` + UDPPorts []uint16 `json:"udpPorts"` + BlockedPorts []uint16 `json:"blockedPorts"` + Priority string `json:"priority"` + CheckUpdates bool `json:"checkUpdates"` + Verbose bool `json:"verbose"` + + // PasswordSet says whether a password is in place. Password is only + // read: absent leaves the password alone, empty removes it, anything + // else sets it. + PasswordSet bool `json:"passwordSet"` + Password *string `json:"password,omitempty"` +} + +func settingsFromConfig(cfg config) settings { + s := settings{ + Hostname: cfg.Hostname, + WebAddr: cfg.WebAddr, + HTTPProxyAddr: cfg.HTTPProxyAddr, + SunshineHosts: append([]sunshineHost{}, cfg.SunshineHosts...), + Forwards: append([]forwardRule{}, cfg.Forwards...), + UDPPorts: append([]uint16{}, cfg.UDPPorts...), + BlockedPorts: append([]uint16{}, cfg.BlockedPorts...), + Priority: priorityLow, + CheckUpdates: cfg.CheckUpdates, + Verbose: cfg.Verbose, + PasswordSet: cfg.PasswordHash != "", + } + if cfg.Priority == priorityHigh { + s.Priority = priorityHigh + } + return s +} + +// validate checks the settings and tidies them. +func (s *settings) validate() error { + s.Hostname = strings.TrimSpace(s.Hostname) + if !validTailnetName(s.Hostname) { + return fmt.Errorf("the name may only contain letters, digits and hyphens (at most 63)") + } + if err := validListenAddr(s.WebAddr); err != nil { + return fmt.Errorf("status page address: %w", err) + } + s.HTTPProxyAddr = strings.TrimSpace(s.HTTPProxyAddr) + if s.HTTPProxyAddr != "" { + if err := validListenAddr(s.HTTPProxyAddr); err != nil { + return fmt.Errorf("HTTP proxy address: %w", err) + } + } + if err := validateSunshineHosts(s.SunshineHosts); err != nil { + return err + } + for _, f := range s.Forwards { + if f.Proto != "tcp" && f.Proto != "udp" { + return fmt.Errorf("forward %v: the protocol must be tcp or udp", f) + } + if err := validListenAddr(f.Listen); err != nil { + return fmt.Errorf("forward %v: listen address: %w", f, err) + } + host, port, err := net.SplitHostPort(f.Target) + if err != nil || host == "" || !validHostName(host) || !validPort(port) { + return fmt.Errorf("forward %v: the target must be host:port", f) + } + } + if slices.Contains(s.UDPPorts, 0) || slices.Contains(s.BlockedPorts, 0) { + return fmt.Errorf("0 is not a port") + } + if s.Priority != priorityLow && s.Priority != priorityHigh { + return fmt.Errorf("the priority must be low or high") + } + if s.Password != nil && len(*s.Password) > 0 && len(*s.Password) < 4 { + return fmt.Errorf("the password must be at least 4 characters") + } + return nil +} + +func validTailnetName(s string) bool { + if len(s) == 0 || len(s) > 63 || s[0] == '-' || s[len(s)-1] == '-' { + return false + } + for _, c := range s { + if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-') { + return false + } + } + return true +} + +func validPort(s string) bool { + n, err := strconv.Atoi(s) + return err == nil && n >= 1 && n <= 65535 +} + +// validListenAddr accepts "host:port" and ":port". +func validListenAddr(addr string) error { + host, port, err := net.SplitHostPort(addr) + if err != nil { + return fmt.Errorf("%q is not host:port", addr) + } + if !validHostName(host) || !validPort(port) { + return fmt.Errorf("%q is not a valid address", addr) + } + return nil +} + +func (d *daemon) handleGetConfig(w http.ResponseWriter, r *http.Request) { + d.mu.Lock() + s := settingsFromConfig(d.cfg) + d.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + json.NewEncoder(w).Encode(s) +} + +// handleSetConfig saves new settings and applies what can be applied without +// a restart. The reply lists the settings that need one. +func (d *daemon) handleSetConfig(w http.ResponseWriter, r *http.Request) { + var s settings + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&s); err != nil { + http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest) + return + } + if err := s.validate(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + var newHash *string + if s.Password != nil { + hash := "" + if *s.Password != "" { + var err error + if hash, err = hashPassword(*s.Password); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } + newHash = &hash + } + + d.mu.Lock() + old := d.cfg + cfg := d.cfg + cfg.Hostname = s.Hostname + cfg.WebAddr = s.WebAddr + cfg.HTTPProxyAddr = s.HTTPProxyAddr + if s.SunshineHosts != nil { + // The settings form leaves the Sunshine hosts out: they have a + // panel of their own. + cfg.SunshineHosts = s.SunshineHosts + } + cfg.Forwards = s.Forwards + cfg.UDPPorts = s.UDPPorts + cfg.BlockedPorts = s.BlockedPorts + cfg.Priority = "" + if s.Priority == priorityHigh { + cfg.Priority = priorityHigh + } + cfg.CheckUpdates = s.CheckUpdates + cfg.Verbose = s.Verbose + if newHash != nil { + cfg.PasswordHash = *newHash + } + d.cfg = cfg + d.mu.Unlock() + + if err := saveConfig(d.cfgPath, cfg); err != nil { + d.logf("saving config: %v", err) + http.Error(w, "the settings are in effect but could not be saved: "+err.Error(), http.StatusInternalServerError) + return + } + d.logf("settings changed from the status page") + + // Apply. + problems := []string{} + if cfg.Hostname != old.Hostname && d.lc != nil { + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + _, err := d.lc.EditPrefs(ctx, &ipn.MaskedPrefs{Prefs: ipn.Prefs{Hostname: cfg.Hostname}, HostnameSet: true}) + cancel() + if err != nil { + problems = append(problems, "name: "+err.Error()) + } + } + if err := d.fwd.set(d.localForwardRules()); err != nil { + problems = append(problems, err.Error()) + } + if cfg.HTTPProxyAddr != old.HTTPProxyAddr { + if err := d.setProxy(cfg.HTTPProxyAddr); err != nil { + problems = append(problems, "HTTP proxy: "+err.Error()) + } + } + if newHash != nil && cfg.PasswordHash != old.PasswordHash { + // A changed password ends every session but the one that changed it. + d.sessions.clear() + if cfg.PasswordHash != "" { + if token, err := d.sessions.create(); err == nil { + http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: token, Path: "/", + MaxAge: int(sessionLifetime.Seconds()), HttpOnly: true, SameSite: http.SameSiteStrictMode}) + } + } + } + d.writePriorityFile() + // UDP ports and blocked ports are read from the config where they are used. + + restart := []string{} + if cfg.WebAddr != old.WebAddr { + restart = append(restart, "status page address") + } + if cfg.Priority != old.Priority { + restart = append(restart, "priority") + } + if cfg.Verbose != old.Verbose { + restart = append(restart, "verbose log") + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{"restart": restart, "problems": problems}) +} + +// writePriorityFile leaves the launcher its instruction for the next start. +func (d *daemon) writePriorityFile() { + d.mu.Lock() + high := d.cfg.Priority == priorityHigh + d.mu.Unlock() + path := filepath.Join(dataDir, priorityFile) + if high { + os.WriteFile(path, []byte(priorityHigh+"\n"), 0o644) + } else { + os.Remove(path) + } +} + +// setProxy starts, stops or moves the outbound HTTP proxy. +func (d *daemon) setProxy(addr string) error { + d.mu.Lock() + old := d.proxyLn + d.proxyLn, d.proxyPort = nil, 0 + d.mu.Unlock() + if old != nil { + old.Close() + } + if addr == "" { + return nil + } + ln, err := listenResilient("tcp", addr, d.logf) + if err != nil { + return err + } + d.mu.Lock() + d.proxyLn, d.proxyPort = ln, ln.port() + d.mu.Unlock() + go d.serveProxy(ln) + return nil +} diff --git a/tsd/settings_test.go b/tsd/settings_test.go new file mode 100644 index 0000000..eb71271 --- /dev/null +++ b/tsd/settings_test.go @@ -0,0 +1,306 @@ +package main + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestPasswordHash(t *testing.T) { + hash, err := hashPassword("correct horse") + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(hash, "pbkdf2-sha256$") { + t.Errorf("unexpected hash format %q", hash) + } + if !checkPassword(hash, "correct horse") { + t.Error("the right password was rejected") + } + for _, wrong := range []string{"", "Correct horse", "correct horse "} { + if checkPassword(hash, wrong) { + t.Errorf("%q was accepted", wrong) + } + } + other, _ := hashPassword("correct horse") + if other == hash { + t.Error("two hashes of one password are identical; the salt is not random") + } + for _, bad := range []string{"", "plain", "pbkdf2-sha256$x$00$00", "pbkdf2-sha256$1000$zz$00", "md5$1$00$00"} { + if checkPassword(bad, "anything") { + t.Errorf("malformed hash %q accepted a password", bad) + } + } +} + +// newTestDaemon returns a daemon with just enough set up to serve the status +// page's API. +func newTestDaemon(t *testing.T) *daemon { + t.Helper() + dir := t.TempDir() + old := dataDir + dataDir = dir + t.Cleanup(func() { dataDir = old }) + d := &daemon{ + cfg: defaultConfig(), + cfgPath: filepath.Join(dir, "config.json"), + logf: t.Logf, + quit: make(chan struct{}), + } + d.fwd = newForwarder(nil, t.Logf) + d.tailnetWeb = newTailnetListener() + return d +} + +// request performs one API call. remote is the client address the server sees. +func request(t *testing.T, h http.Handler, method, path, remote string, body any, cookies []*http.Cookie) *httptest.ResponseRecorder { + t.Helper() + var buf bytes.Buffer + if body != nil { + json.NewEncoder(&buf).Encode(body) + } + r := httptest.NewRequest(method, path, &buf) + r.RemoteAddr = remote + r.Header.Set(apiHeader, "1") + for _, c := range cookies { + r.AddCookie(c) + } + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + return w +} + +func TestPasswordProtection(t *testing.T) { + d := newTestDaemon(t) + h := d.webHandler() + const lan, tailnet, console = "192.168.1.20:5000", "100.64.0.9:5000", "127.0.0.1:5000" + + // No password: everyone gets in. + if w := request(t, h, "GET", "/api/status", lan, nil, nil); w.Code != 200 { + t.Fatalf("no password, LAN status: %d", w.Code) + } + + // Set one from the LAN. + pw := "hunter22" + w := request(t, h, "POST", "/api/config", lan, func() settings { + s := settingsFromConfig(d.cfg) + s.Password = &pw + return s + }(), nil) + if w.Code != 200 { + t.Fatalf("setting the password: %d %s", w.Code, w.Body) + } + setter := w.Result().Cookies() + if d.cfg.PasswordHash == "" || strings.Contains(d.cfg.PasswordHash, pw) { + t.Fatalf("stored password hash: %q", d.cfg.PasswordHash) + } + saved, _ := os.ReadFile(d.cfgPath) + if !bytes.Contains(saved, []byte("passwordHash")) || bytes.Contains(saved, []byte(pw)) { + t.Errorf("config file should hold the hash and not the password:\n%s", saved) + } + + // Now locked for the LAN and the tailnet, open for the console itself + // and for the browser that set it. + for _, remote := range []string{lan, tailnet} { + for _, path := range []string{"/api/status", "/api/config", "/api/logs", "/qr.png"} { + if w := request(t, h, "GET", path, remote, nil, nil); w.Code != http.StatusUnauthorized { + t.Errorf("GET %s from %s: %d, want 401", path, remote, w.Code) + } + } + for _, path := range []string{"/api/logout", "/api/quit", "/api/uninstall", "/api/config", "/api/sunshine", "/api/login"} { + if w := request(t, h, "POST", path, remote, nil, nil); w.Code != http.StatusUnauthorized { + t.Errorf("POST %s from %s: %d, want 401", path, remote, w.Code) + } + } + } + if w := request(t, h, "GET", "/api/status", console, nil, nil); w.Code != 200 { + t.Errorf("console status: %d", w.Code) + } + if w := request(t, h, "GET", "/api/status", lan, nil, setter); w.Code != 200 { + t.Errorf("status with the session of the browser that set the password: %d", w.Code) + } + // The page shell and ping stay reachable so the unlock form can load. + for _, path := range []string{"/", "/api/ping", "/favicon.png"} { + if w := request(t, h, "GET", path, lan, nil, nil); w.Code != 200 { + t.Errorf("GET %s while locked: %d", path, w.Code) + } + } + + // Unlocking. + if w := request(t, h, "POST", "/api/auth", lan, map[string]string{"password": "nope"}, nil); w.Code != http.StatusForbidden { + t.Errorf("wrong password: %d", w.Code) + } + w = request(t, h, "POST", "/api/auth", tailnet, map[string]string{"password": pw}, nil) + if w.Code != 200 || len(w.Result().Cookies()) == 0 { + t.Fatalf("right password: %d, cookies %v", w.Code, w.Result().Cookies()) + } + session := w.Result().Cookies() + if !session[0].HttpOnly { + t.Error("the session cookie should be HttpOnly") + } + if w := request(t, h, "GET", "/api/status", tailnet, nil, session); w.Code != 200 { + t.Errorf("status with a session: %d", w.Code) + } + + // Locking again ends the session. + request(t, h, "POST", "/api/lock", tailnet, nil, session) + if w := request(t, h, "GET", "/api/status", tailnet, nil, session); w.Code != http.StatusUnauthorized { + t.Errorf("status after lock: %d", w.Code) + } + + // Removing the password opens the page again. + empty := "" + s := settingsFromConfig(d.cfg) + s.Password = &empty + if w := request(t, h, "POST", "/api/config", console, s, nil); w.Code != 200 { + t.Fatalf("removing the password: %d %s", w.Code, w.Body) + } + if w := request(t, h, "GET", "/api/status", lan, nil, nil); w.Code != 200 { + t.Errorf("status after removing the password: %d", w.Code) + } +} + +func TestStateChangesNeedHeader(t *testing.T) { + d := newTestDaemon(t) + h := d.webHandler() + r := httptest.NewRequest("POST", "/api/quit", nil) + r.RemoteAddr = "192.168.1.20:5000" + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != http.StatusForbidden { + t.Errorf("POST without the API header: %d, want 403", w.Code) + } + if d.stopping() { + t.Error("the daemon was told to stop by a request without the header") + } +} + +func TestSettingsRoundTrip(t *testing.T) { + d := newTestDaemon(t) + h := d.webHandler() + const console = "127.0.0.1:5000" + + var s settings + w := request(t, h, "GET", "/api/config", console, nil, nil) + if err := json.Unmarshal(w.Body.Bytes(), &s); err != nil { + t.Fatal(err) + } + if s.Hostname != "ps5" || s.Priority != priorityLow || !s.CheckUpdates || s.HTTPProxyAddr != "" || s.PasswordSet { + t.Errorf("defaults: %+v", s) + } + + s.Hostname = "living-room-ps5" + s.BlockedPorts = []uint16{9021} + s.UDPPorts = []uint16{9296} + s.Priority = priorityHigh + s.CheckUpdates = false + w = request(t, h, "POST", "/api/config", console, s, nil) + if w.Code != 200 { + t.Fatalf("saving: %d %s", w.Code, w.Body) + } + var reply struct{ Restart []string } + json.Unmarshal(w.Body.Bytes(), &reply) + if len(reply.Restart) != 1 || reply.Restart[0] != "priority" { + t.Errorf("settings needing a restart: %v, want [priority]", reply.Restart) + } + + cfg, err := loadConfig(d.cfgPath) + if err != nil { + t.Fatal(err) + } + if cfg.Hostname != "living-room-ps5" || cfg.Priority != priorityHigh || cfg.CheckUpdates || + len(cfg.BlockedPorts) != 1 || len(cfg.UDPPorts) != 1 { + t.Errorf("saved config: %+v", cfg) + } + if b, _ := os.ReadFile(filepath.Join(dataDir, priorityFile)); strings.TrimSpace(string(b)) != priorityHigh { + t.Errorf("priority file: %q", b) + } + + // Back to low removes the launcher's instruction. + s.Priority = priorityLow + request(t, h, "POST", "/api/config", console, s, nil) + if _, err := os.Stat(filepath.Join(dataDir, priorityFile)); !os.IsNotExist(err) { + t.Errorf("priority file should be gone: %v", err) + } + + // Invalid input is rejected and changes nothing. + for name, edit := range map[string]func(*settings){ + "name": func(s *settings) { s.Hostname = "bad name!" }, + "web": func(s *settings) { s.WebAddr = "8090" }, + "proxy": func(s *settings) { s.HTTPProxyAddr = "nonsense" }, + "priority": func(s *settings) { s.Priority = "turbo" }, + "forward": func(s *settings) { s.Forwards = []forwardRule{{"sctp", "127.0.0.1:1", "a:1"}} }, + "sunshine": func(s *settings) { s.SunshineHosts = []sunshineHost{{Host: "a"}, {Host: "b"}} }, + "password": func(s *settings) { p := "abc"; s.Password = &p }, + } { + bad := settingsFromConfig(d.cfg) + edit(&bad) + if w := request(t, h, "POST", "/api/config", console, bad, nil); w.Code != http.StatusBadRequest { + t.Errorf("invalid %s: %d, want 400", name, w.Code) + } + } + if d.cfg.Hostname != "living-room-ps5" { + t.Errorf("hostname changed by a rejected request: %q", d.cfg.Hostname) + } +} + +func TestConfigMigration(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + // A config as v0.4.1 wrote it. + os.WriteFile(path, []byte(`{"hostname":"ps5","webAddr":":8090","httpProxyAddr":"127.0.0.1:8118","sunshineHost":"gaming-pc","udpPorts":[9295,9296,9297,9302]}`), 0o600) + cfg, err := loadConfig(path) + if err != nil { + t.Fatal(err) + } + if len(cfg.SunshineHosts) != 1 || cfg.SunshineHosts[0].Host != "gaming-pc" || cfg.SunshineHost != "" { + t.Errorf("sunshine host not migrated: %+v", cfg) + } + if cfg.HTTPProxyAddr != "127.0.0.1:8118" { + t.Errorf("an explicitly configured proxy must stay on: %q", cfg.HTTPProxyAddr) + } + if !cfg.CheckUpdates { + t.Error("update checks should default to on for an existing config") + } +} + +func TestVersionCompare(t *testing.T) { + for _, tt := range []struct { + current, latest string + want bool + }{ + {"0.4.1", "0.5.0", true}, + {"0.4.1", "v0.4.2", true}, + {"0.4.1", "0.4.1", false}, + {"0.5.0", "0.4.9", false}, + {"0.4.2-dev", "0.4.1", false}, + {"0.4.2-dev", "0.4.2", false}, + {"0.4.2-dev", "0.4.3", true}, + {"0.9.0", "0.10.0", true}, + {"dev", "0.5.0", false}, + {"0.4.1", "", false}, + {"0.4.1", "nonsense", false}, + } { + if got := newerVersion(tt.current, tt.latest); got != tt.want { + t.Errorf("newerVersion(%q, %q) = %v, want %v", tt.current, tt.latest, got, tt.want) + } + } +} + +func TestIconReplyLine(t *testing.T) { + for in, want := range map[string]string{ + "": "", + "[SceLncUtil] something\n": "", + "icon: remov": "", + "[SceLncUtil] x\nicon: removed\n": "icon: removed", + "icon: registering failed: 0x1\r\n": "icon: registering failed: 0x1", + } { + if got := iconReplyLine([]byte(in)); got != want { + t.Errorf("iconReplyLine(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/tsd/status.html b/tsd/status.html index 572acf7..b49dbb8 100644 --- a/tsd/status.html +++ b/tsd/status.html @@ -4,6 +4,7 @@ Tailscale on PS5 + @@ -66,6 +78,22 @@

Tailscale on PS5

+ + + + + +
- Recent log + Recent log

     
+ diff --git a/tsd/udprelay.go b/tsd/udprelay.go index 85ae667..c9724af 100644 --- a/tsd/udprelay.go +++ b/tsd/udprelay.go @@ -3,6 +3,7 @@ package main import ( "context" "errors" + "io" "net" "sync" "sync/atomic" @@ -48,19 +49,24 @@ type udpRelay struct { sock net.PacketConn closed bool flows map[string]*udpFlow + + ended atomic.Bool // the read loop has returned } // startUDPRelay opens the listening socket and relays until stop is called. -func startUDPRelay(cfg udpRelayConfig) (stop func(), err error) { +func startUDPRelay(cfg udpRelayConfig) (*udpRelay, error) { sock, err := cfg.listen() if err != nil { return nil, err } r := &udpRelay{cfg: cfg, sock: sock, flows: map[string]*udpFlow{}} go r.readLoop() - return r.stop, nil + return r, nil } +// running reports whether the relay is still reading from its socket. +func (r *udpRelay) running() bool { return !r.ended.Load() } + func (r *udpRelay) stop() { r.mu.Lock() defer r.mu.Unlock() @@ -77,6 +83,7 @@ func (r *udpRelay) socket() (net.PacketConn, bool) { } func (r *udpRelay) readLoop() { + defer r.ended.Store(true) buf := make([]byte, 65535) for { sock, stopped := r.socket() @@ -88,6 +95,11 @@ func (r *udpRelay) readLoop() { if _, stopped := r.socket(); stopped { return } + if errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) { + // Whatever provided the socket has shut down (Tailscale + // stopping, for one). There is nothing to reopen. + return + } r.cfg.logf("%s: %v; reopening", r.cfg.name, err) sock.Close() time.Sleep(time.Second) diff --git a/tsd/update.go b/tsd/update.go new file mode 100644 index 0000000..b607da9 --- /dev/null +++ b/tsd/update.go @@ -0,0 +1,127 @@ +package main + +import ( + "context" + "encoding/json" + "net/http" + "strconv" + "strings" + "time" +) + +// The daemon asks GitHub now and then whether a newer release exists, so that +// the status page can say so. It never downloads or installs anything. + +const releasesAPI = "https://api.github.com/repos/holdmysocks/ps5-tailscale/releases/latest" + +type releaseInfo struct { + Version string // without the leading "v" + URL string +} + +// parseVersion reads "v1.2.3" or "1.2.3-dev" as its three numbers. +func parseVersion(s string) (v [3]int, ok bool) { + s = strings.TrimPrefix(strings.TrimSpace(s), "v") + if i := strings.IndexAny(s, "-+ "); i >= 0 { + s = s[:i] + } + parts := strings.Split(s, ".") + if len(parts) != 3 { + return v, false + } + for i, p := range parts { + n, err := strconv.Atoi(p) + if err != nil || n < 0 { + return v, false + } + v[i] = n + } + return v, true +} + +// newerVersion reports whether latest is a later release than current. +func newerVersion(current, latest string) bool { + c, ok1 := parseVersion(current) + l, ok2 := parseVersion(latest) + if !ok1 || !ok2 { + return false + } + for i := range c { + if l[i] != c[i] { + return l[i] > c[i] + } + } + return false +} + +func fetchLatestRelease(ctx context.Context, url string) (releaseInfo, error) { + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return releaseInfo{}, err + } + req.Header.Set("User-Agent", "ps5-tailscale/"+version) + req.Header.Set("Accept", "application/vnd.github+json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return releaseInfo{}, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return releaseInfo{}, &httpStatusError{resp.Status} + } + var rel struct { + TagName string `json:"tag_name"` + HTMLURL string `json:"html_url"` + } + if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil { + return releaseInfo{}, err + } + return releaseInfo{Version: strings.TrimPrefix(rel.TagName, "v"), URL: rel.HTMLURL}, nil +} + +type httpStatusError struct{ status string } + +func (e *httpStatusError) Error() string { return "unexpected response: " + e.status } + +// watchForUpdates checks shortly after start and then twice a day, for as +// long as the setting is on. +func (d *daemon) watchForUpdates(ctx context.Context) { + timer := time.NewTimer(time.Minute) + defer timer.Stop() + for { + select { + case <-ctx.Done(): + return + case <-timer.C: + } + timer.Reset(12 * time.Hour) + + d.mu.Lock() + enabled := d.cfg.CheckUpdates + d.mu.Unlock() + if !enabled { + d.mu.Lock() + d.latest = releaseInfo{} + d.mu.Unlock() + continue + } + reqCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + rel, err := fetchLatestRelease(reqCtx, releasesAPI) + cancel() + if err != nil { + d.logf("update check: %v", err) + timer.Reset(time.Hour) + continue + } + d.mu.Lock() + known := d.latest.Version + d.latest = rel + d.mu.Unlock() + if d.debug != nil { + d.debug.Printf("update check: the latest release is %s", rel.Version) + } + if rel.Version != known && newerVersion(version, rel.Version) { + d.logf("a newer release is available: %s (running %s)", rel.Version, version) + } + } +} diff --git a/tsd/web.go b/tsd/web.go index 8aaa204..4d00104 100644 --- a/tsd/web.go +++ b/tsd/web.go @@ -18,10 +18,15 @@ import ( //go:embed status.html var statusHTML []byte -// The status page has no login: like the other services on a jailbroken -// console it trusts the local network. State-changing requests must carry -// this header, which a web page on another origin cannot send, so a stray -// link or image tag cannot log the console out. +// faviconPNG is the logo from the home screen icon (appicon/icon0.png) +// without its text, 128x128, for the browser tab. +// +//go:embed favicon.png +var faviconPNG []byte + +// State-changing requests must carry this header, which a web page on +// another origin cannot send, so a stray link or image tag cannot log the +// console out. Who may use the page at all is decided in auth.go. const apiHeader = "X-PS5-Tailscale" type peerInfo struct { @@ -31,6 +36,14 @@ type peerInfo struct { Online bool `json:"online"` } +// sunshineInfo is a forwarded Sunshine host as the status page shows it. +type sunshineInfo struct { + Host string `json:"host"` + Port int `json:"port"` + // Address is what to enter in a Moonlight client on the console. + Address string `json:"address"` +} + type statusInfo struct { Version string `json:"version"` State string `json:"state"` @@ -43,50 +56,66 @@ type statusInfo struct { Health []string `json:"health,omitempty"` Peers []peerInfo `json:"peers"` Proxy string `json:"proxy,omitempty"` - // SunshineHost and Forwards describe the local forwards. - SunshineHost string `json:"sunshineHost"` - Forwards []string `json:"forwards"` + // SunshineHosts and Forwards describe the local forwards. + SunshineHosts []sunshineInfo `json:"sunshineHosts"` + Forwards []string `json:"forwards"` // UDPPorts are the console's UDP ports reachable from the tailnet. UDPPorts []uint16 `json:"udpPorts"` - Uptime int64 `json:"uptimeSeconds"` + Priority string `json:"priority"` + // PasswordSet says whether the page is password protected. + PasswordSet bool `json:"passwordSet"` + // LatestVersion and UpdateURL are set when a newer release exists. + LatestVersion string `json:"latestVersion,omitempty"` + UpdateURL string `json:"updateURL,omitempty"` + Uptime int64 `json:"uptimeSeconds"` } -func (d *daemon) serveWeb(ln net.Listener) { +// webHandler builds the status page and its API. +func (d *daemon) webHandler() http.Handler { mux := http.NewServeMux() + + // Open to everyone who can reach the page: the page itself (which shows + // nothing until its API answers), the icon, and what a new instance + // needs to recognise this one. mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-store") w.Write(statusHTML) }) + favicon := func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "image/png") + w.Header().Set("Cache-Control", "max-age=86400") + w.Write(faviconPNG) + } + mux.HandleFunc("GET /favicon.png", favicon) + mux.HandleFunc("GET /favicon.ico", favicon) // what browsers ask for unprompted mux.HandleFunc("GET /api/ping", func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "ps5-tailscale "+version+"\n") }) - mux.HandleFunc("GET /api/status", d.handleStatus) - mux.HandleFunc("GET /api/logs", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/plain; charset=utf-8") - switch { - case r.URL.Query().Get("full") == "1": - // The end of the log file itself. - writeFileTail(w, filepath.Join(dataDir, "tailscale.log"), 512<<10) - return - case r.URL.Query().Get("debug") == "1": - // The end of the debug log, which includes Tailscale's own messages. - writeFileTail(w, filepath.Join(dataDir, "tailscale-debug.log"), 1<<20) - return - case r.URL.Query().Get("debug") == "old": - writeFileTail(w, filepath.Join(dataDir, "tailscale-debug.log.old"), 1<<20) - return - } - io.WriteString(w, strings.Join(recentLogs.snapshot(), "\n")+"\n") - }) - mux.HandleFunc("GET /qr.png", d.handleQR) - mux.HandleFunc("POST /api/login", d.guard(d.handleLogin)) - mux.HandleFunc("POST /api/logout", d.guard(d.handleLogout)) - mux.HandleFunc("POST /api/quit", d.guard(d.handleQuit)) - mux.HandleFunc("POST /api/uninstall", d.guard(d.handleUninstall)) - mux.HandleFunc("POST /api/sunshine", d.guard(d.handleSunshine)) + mux.HandleFunc("POST /api/auth", d.guard(d.handleAuth)) + mux.HandleFunc("POST /api/lock", d.guard(d.handleLock)) - srv := &http.Server{Handler: mux, ReadHeaderTimeout: 10 * time.Second} + // Everything else needs the password, if one is set. + mux.HandleFunc("GET /api/status", d.protect(d.handleStatus)) + mux.HandleFunc("GET /api/logs", d.protect(d.handleLogs)) + mux.HandleFunc("GET /qr.png", d.protect(d.handleQR)) + mux.HandleFunc("GET /api/config", d.protect(d.handleGetConfig)) + for path, h := range map[string]http.HandlerFunc{ + "/api/config": d.handleSetConfig, + "/api/login": d.handleLogin, + "/api/logout": d.handleLogout, + "/api/quit": d.handleQuit, + "/api/uninstall": d.handleUninstall, + "/api/sunshine": d.handleSunshine, + } { + mux.HandleFunc("POST "+path, d.protect(d.guard(h))) + } + return mux +} + +// serveWeb serves the status page on one listener. +func (d *daemon) serveWeb(ln net.Listener, h http.Handler) { + srv := &http.Server{Handler: h, ReadHeaderTimeout: 10 * time.Second} if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed && !d.stopping() { d.logf("web UI stopped: %v", err) } @@ -126,20 +155,47 @@ func (d *daemon) guard(h http.HandlerFunc) http.HandlerFunc { } } +func (d *daemon) handleLogs(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + switch { + case r.URL.Query().Get("full") == "1": + // The end of the log file itself. + writeFileTail(w, filepath.Join(dataDir, "tailscale.log"), 512<<10) + case r.URL.Query().Get("debug") == "1": + // The end of the debug log, which includes Tailscale's own messages. + writeFileTail(w, filepath.Join(dataDir, "tailscale-debug.log"), 1<<20) + case r.URL.Query().Get("debug") == "old": + writeFileTail(w, filepath.Join(dataDir, "tailscale-debug.log.old"), 1<<20) + default: + io.WriteString(w, strings.Join(recentLogs.snapshot(), "\n")+"\n") + } +} + func (d *daemon) handleStatus(w http.ResponseWriter, r *http.Request) { d.mu.Lock() info := statusInfo{ - Version: version, - State: d.state, - AuthURL: d.authURL, - Error: d.lastErr, - Hostname: d.cfg.Hostname, - Proxy: d.cfg.HTTPProxyAddr, - Uptime: int64(time.Since(d.started).Seconds()), - IPs: []string{}, - Peers: []peerInfo{}, + Version: version, + State: d.state, + AuthURL: d.authURL, + Error: d.lastErr, + Hostname: d.cfg.Hostname, + Proxy: d.cfg.HTTPProxyAddr, + Priority: priorityLow, + PasswordSet: d.cfg.PasswordHash != "", + Uptime: int64(time.Since(d.started).Seconds()), + IPs: []string{}, + Peers: []peerInfo{}, + SunshineHosts: []sunshineInfo{}, + } + if d.cfg.Priority == priorityHigh { + info.Priority = priorityHigh + } + for _, h := range d.cfg.SunshineHosts { + info.SunshineHosts = append(info.SunshineHosts, sunshineInfo{Host: h.Host, Port: h.basePort(), Address: h.clientAddress()}) + } + if newerVersion(version, d.latest.Version) { + info.LatestVersion, info.UpdateURL = d.latest.Version, d.latest.URL } - info.SunshineHost = d.cfg.SunshineHost d.mu.Unlock() info.UDPPorts = []uint16{} if d.udp != nil { @@ -254,23 +310,32 @@ func (d *daemon) handleLogout(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "ok\n") } -// handleSunshine sets (or with an empty host, clears) the Sunshine host whose -// streaming ports are forwarded from 127.0.0.1, saves the config and applies -// it without a restart. +// handleSunshine replaces the list of Sunshine hosts whose streaming ports are +// forwarded from 127.0.0.1, saves the config and applies it at once. func (d *daemon) handleSunshine(w http.ResponseWriter, r *http.Request) { - host := strings.TrimSpace(r.URL.Query().Get("host")) - if !validHostName(host) { - http.Error(w, "that does not look like a host name or address", http.StatusBadRequest) + var hosts []sunshineHost + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&hosts); err != nil { + http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest) + return + } + for i := range hosts { + hosts[i].Host = strings.TrimSpace(hosts[i].Host) + if hosts[i].Port == sunshineDefaultPort { + hosts[i].Port = 0 + } + } + if err := validateSunshineHosts(hosts); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) return } d.mu.Lock() - d.cfg.SunshineHost = host + d.cfg.SunshineHosts = hosts cfg := d.cfg d.mu.Unlock() if err := saveConfig(d.cfgPath, cfg); err != nil { d.logf("saving config: %v", err) } - d.logf("sunshine host set to %q", host) + d.logf("sunshine hosts set to %v", hosts) if err := d.fwd.set(d.localForwardRules()); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -302,28 +367,34 @@ func (d *daemon) stop() { d.quitOnce.Do(func() { close(d.quit) }) } -// 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. +// handleUninstall takes the home screen icon away, logs the console out of +// the tailnet and stops the daemon, which deletes its data directory (login, +// settings, logs) on the way out. The payload file itself is wherever the +// user keeps it. func (d *daemon) handleUninstall(w http.ResponseWriter, r *http.Request) { + d.logf("uninstall requested from the status page") + iconNote := "The home screen icon was removed." + if err := removeHomeIcon(); err != nil { + d.logf("uninstall: home screen icon: %v", err) + iconNote = "The home screen icon could not be removed (" + err.Error() + "); delete it from the home screen." + } if d.lc != nil { if err := d.lc.Logout(r.Context()); err != nil { d.logf("uninstall: logout: %v", err) } } - 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") + io.WriteString(w, "Tailscale was removed from this PS5. "+iconNote+"\n") d.stop() } // stopRunningInstance asks an instance that is already serving the status // page to exit and waits for the port to become free. It reports whether -// there was one. +// there was one. The request comes from the console itself, so it needs no +// password. func stopRunningInstance(webAddr string) bool { _, port, err := net.SplitHostPort(webAddr) if err != nil {