From bc19d3c251bb0e2ab64ee3169cc2a4aba6d9643d Mon Sep 17 00:00:00 2001 From: holdmysocks <1349908+holdmysocks@users.noreply.github.com> Date: Mon, 5 Oct 2026 08:31:24 -0400 Subject: [PATCH] Report start-up failures, limit access to own devices, warn about key expiry - The launcher keeps a log (/data/tailscale/launcher.log) and shows a notification when it cannot start. A payload manager does not show what a payload prints, so a failed start used to leave no trace. The Go runtime's stderr goes to the same file until the daemon opens its log. - New setting "who on the tailnet may connect": every device the tailnet's access rules allow (default), or only devices of the same user as the console. Applies to every forwarded TCP port, the UDP ports and the status page over the tailnet. - The status page shows when the console's key expires and warns from two weeks before; the console shows a notification at 14, 3 and 1 days. - A newer release is announced once on the console, not only on the page. - Status page: click an address to copy it; OS, status and address columns no longer break mid-word; the facts stack on narrow screens. --- README.md | 37 +++++++--- docs/TECHNICAL.md | 20 +++++- launcher/goload.c | 26 ++++--- launcher/homeicon.c | 7 +- launcher/main.c | 5 +- launcher/report.c | 107 +++++++++++++++++++++++++++ launcher/report.h | 20 ++++++ tools/build-payload.ps1 | 2 +- tsd/access.go | 142 ++++++++++++++++++++++++++++++++++++ tsd/access_test.go | 151 +++++++++++++++++++++++++++++++++++++++ tsd/config.go | 7 ++ tsd/forward.go | 3 + tsd/inboundudp.go | 5 +- tsd/keyexpiry.go | 101 ++++++++++++++++++++++++++ tsd/localforward_test.go | 40 +++++++---- tsd/main.go | 4 +- tsd/settings.go | 16 +++++ tsd/status.html | 99 ++++++++++++++++++++++--- tsd/udprelay.go | 16 ++++- tsd/update.go | 16 +++++ tsd/web.go | 9 +++ 21 files changed, 786 insertions(+), 47 deletions(-) create mode 100644 launcher/report.c create mode 100644 launcher/report.h create mode 100644 tsd/access.go create mode 100644 tsd/access_test.go create mode 100644 tsd/keyexpiry.go diff --git a/README.md b/README.md index 51bcaf3..5f252fb 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,14 @@ shared with you, and marks the ones that can be used as an exit node. It can be searched (name, address, OS, tag, place) and limited to devices that are online. If your tailnet has a VPN add-on such as Mullvad, its exit servers are counted but kept out of the list until you tick **Show VPN exit -servers**. +servers**. Click an address to copy it. + +**Key expiry.** The page shows when the console's Tailscale key expires. By +default that is 180 days after logging in, and an expired key takes the +console off your tailnet until someone presses **Log in again**. From two +weeks before, the page and a notification on the console warn about it. To +avoid it altogether, open the Tailscale admin console, find the console in +the list of machines and choose **Disable key expiry**. **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 @@ -145,9 +152,9 @@ 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 +**Settings.** The name on the tailnet, the password, who on the tailnet may +connect, 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) @@ -229,6 +236,7 @@ optional. ], "udpPorts": [9295, 9296, 9297, 9302], "blockedPorts": [], + "allowFrom": "", "priority": "", "checkUpdates": true, "verbose": false @@ -247,8 +255,9 @@ optional. | `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. | +| `allowFrom` | `"own"` lets only devices logged in as the same user as the console connect; other users' devices and devices shared into the tailnet are turned away. Anything else is the default: every device your tailnet's access rules allow. If the console is tagged, `"own"` means the devices of its own 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. | +| `checkUpdates` | Ask GitHub twice a day whether a newer release exists, to show it on the status page and announce it once on the console. Nothing is downloaded. | | `verbose` | Put Tailscale's own log in the main log as well. | Files on the console: @@ -259,6 +268,7 @@ 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/launcher.log` | What the payload did before Tailscale itself started. The place to look when nothing seems to happen. | | `/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. | @@ -277,6 +287,14 @@ Left to do by hand: ## Troubleshooting +- **Nothing happens when the payload is sent.** If the payload cannot start, + it says why in a notification on the console and in + `/data/tailscale/launcher.log`; fetch that file over FTP. A payload manager + does not show what a payload prints, so sending it from a PC shows more: + `socat -t 30 - TCP::9021 < tailscale.elf`, or + `.\tools\ps5send.ps1 -File tailscale.elf -PS5Host ` on Windows. + If the log ends with "starting the Go program" and no status page appears, + whatever follows that line is the crash report to send. - **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 @@ -300,8 +318,9 @@ Left to do by hand: tailnet Tailscale encrypts it. - All listening TCP ports on the console, and the UDP ports in `udpPorts`, become reachable from your tailnet. That includes the payload loader, which - runs anything sent to it. Use Tailscale ACLs if other people share your - tailnet, or list ports under "TCP ports never exposed". + runs anything sent to it. If other people use your tailnet or share + devices into it, set **Who on the tailnet may connect** to your own devices + only, use Tailscale ACLs, 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. @@ -321,7 +340,9 @@ the tailnet, a ProsperoLight stream from a Sunshine host through the forward, 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 +the update check, limiting connections to your own devices (with the +console's owner's devices only; a refusal has not been seen for real), 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 diff --git a/docs/TECHNICAL.md b/docs/TECHNICAL.md index 92aa79f..ad44791 100644 --- a/docs/TECHNICAL.md +++ b/docs/TECHNICAL.md @@ -22,6 +22,14 @@ specification. to a fresh 1 MB stack and jumps to the Go entry point. The embedded copy is then released with `madvise(MADV_FREE)`. +The launcher keeps a log, `/data/tailscale/launcher.log` (`report.c`): the +firmware version, each step that fails, and a last line before it jumps into +the Go program. A failure is also shown as a notification, because a payload +manager does not show what a payload prints. Just before the jump, stderr is +pointed at that log, so that a Go runtime that dies before the daemon has +opened its own log leaves its message there. What cannot be reported this +way is a failure in the SDK's crt, which runs before any of this. + **The daemon** (`tsd/`, Go): a `tsnet` server. - Inbound: tsnet's fallback TCP handler pipes each tailnet connection to @@ -54,7 +62,17 @@ specification. 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. + twice a day, compared with the running version. A newer release is shown + on the page and announced once on the console; the announced version is + kept in `/data/tailscale/update-notified`. +- Who may connect (`access.go`): with `allowFrom` set to `own`, the TCP + handler and the UDP relays ask Tailscale who the sender is (WhoIs) and + serve only nodes of the same user as the console, from the console's own + tailnet. Answers are kept for a minute per address. Anything that cannot + be established is refused. +- Key expiry (`keyexpiry.go`): the date comes from the node's own status. + The page warns from 14 days before, and the console shows a notification + at 14, 3 and 1 days. - 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) diff --git a/launcher/goload.c b/launcher/goload.c index ec312c8..a5666ae 100644 --- a/launcher/goload.c +++ b/launcher/goload.c @@ -7,6 +7,7 @@ * FreeBSD kernel would: %rdi pointing at argc/argv/envp/auxv. */ #include +#include #include #include #include @@ -17,6 +18,7 @@ #include #include "goload.h" +#include "report.h" #define PS5_PAGE_SIZE 0x4000ul #define PAGE_TRUNC(x) ((x) & ~(PS5_PAGE_SIZE - 1)) @@ -134,7 +136,7 @@ goload_run(const uint8_t *image, size_t size, char *const argv[], char *const en int envc = 0; if (image_check(image, size)) { - fprintf(stderr, "goload: not a relocatable x86-64 ELF image\n"); + report_fail("goload: the embedded program is not a relocatable x86-64 ELF image"); return -1; } @@ -143,7 +145,7 @@ goload_run(const uint8_t *image, size_t size, char *const argv[], char *const en continue; } if (phdr[i].p_offset + phdr[i].p_filesz > size) { - fprintf(stderr, "goload: truncated image\n"); + report_fail("goload: the embedded program is truncated"); return -1; } if (phdr[i].p_vaddr < min_vaddr) { @@ -154,7 +156,7 @@ goload_run(const uint8_t *image, size_t size, char *const argv[], char *const en } } if (min_vaddr >= max_vaddr) { - fprintf(stderr, "goload: image has no loadable segments\n"); + report_fail("goload: the embedded program has no loadable segments"); return -1; } min_vaddr = PAGE_TRUNC(min_vaddr); @@ -162,7 +164,7 @@ goload_run(const uint8_t *image, size_t size, char *const argv[], char *const en base = mmap(0, max_vaddr - min_vaddr, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); if (base == MAP_FAILED) { - perror("goload: mmap image"); + report_fail("goload: no memory for the program (mmap: %s)", strerror(errno)); return -1; } bias = (uintptr_t)base - min_vaddr; @@ -185,7 +187,7 @@ goload_run(const uint8_t *image, size_t size, char *const argv[], char *const en } for (size_t i = 0; rela && i < relasz / sizeof(*rela); i++) { if (ELF64_R_TYPE(rela[i].r_info) != R_X86_64_RELATIVE) { - fprintf(stderr, "goload: unsupported relocation type %u\n", (unsigned)ELF64_R_TYPE(rela[i].r_info)); + report_fail("goload: unsupported relocation type %u", (unsigned)ELF64_R_TYPE(rela[i].r_info)); return -1; } *(uintptr_t *)(bias + rela[i].r_offset) = bias + rela[i].r_addend; @@ -205,18 +207,19 @@ goload_run(const uint8_t *image, size_t size, char *const argv[], char *const en * that contains the address, so first let a regular mprotect split the * text range off into an entry of its own. */ if (mprotect((void *)start, end - start, PROT_READ)) { - perror("goload: mprotect"); + report_fail("goload: mprotect: %s", strerror(errno)); return -1; } if (kernel_mprotect(-1, start, end - start, PROT_READ | PROT_EXEC)) { - fprintf(stderr, "goload: kernel_mprotect failed\n"); + report_fail("goload: could not make the program executable (kernel_mprotect failed); " + "this firmware or jailbreak may not allow it"); return -1; } } stack = mmap(0, GO_STACK_SIZE, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); if (stack == MAP_FAILED) { - perror("goload: mmap stack"); + report_fail("goload: no memory for the stack (mmap: %s)", strerror(errno)); return -1; } @@ -251,6 +254,13 @@ goload_run(const uint8_t *image, size_t size, char *const argv[], char *const en fprintf(stderr, "goload: image %p..%p entry %#lx stack %p..%p argc=%d\n", base, base + (max_vaddr - min_vaddr), (unsigned long)(bias + ehdr->e_entry), stack, stack + GO_STACK_SIZE, argc); dbg_install((uintptr_t)base, min_vaddr); +#else + /* From here on nothing is printed by the launcher. If the Go runtime dies + * before the daemon has opened its own log, its message lands in the + * launcher log instead of being lost with the sender's connection. */ + report_log("launcher: starting the Go program"); + fflush(stderr); + report_capture_stderr(); #endif fflush(stdout); diff --git a/launcher/homeicon.c b/launcher/homeicon.c index 7bab337..4adb2ad 100644 --- a/launcher/homeicon.c +++ b/launcher/homeicon.c @@ -9,6 +9,8 @@ #ifdef ICON_HELPER +#include "report.h" + #include #include #include @@ -103,6 +105,7 @@ home_icon_install_once(void) { 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. */ + report_log("launcher: home screen icon not installed: no ELF loader on port %d", LOADER_PORT); close(fd); return; } @@ -139,9 +142,9 @@ home_icon_install_once(void) { write(fd, ICON_VERSION, sizeof(ICON_VERSION) - 1); close(fd); } - fprintf(stderr, "launcher: home screen icon installed\n"); + report_log("launcher: home screen icon installed"); } else { - fprintf(stderr, "launcher: home screen icon not installed: %s\n", got ? reply : "no reply from the helper"); + report_log("launcher: home screen icon not installed: %s", got ? reply : "no reply from the helper"); } } diff --git a/launcher/main.c b/launcher/main.c index 03ace43..985b1dd 100644 --- a/launcher/main.c +++ b/launcher/main.c @@ -12,6 +12,7 @@ #include "goload.h" #include "homeicon.h" +#include "report.h" #ifndef GO_IMAGE #error "GO_IMAGE must name the Go binary to embed" @@ -154,12 +155,14 @@ main(int argc, char **argv) { kernel_set_ucred_rgid(pid, 0); kernel_set_ucred_svgid(pid, 0); + report_begin(); home_icon_install_once(); if (leave_realtime_class()) { /* Without this a runaway goroutine could hang the console, so do not * take the chance. */ - fprintf(stderr, "launcher: could not leave the real-time scheduling class; not starting\n"); + report_fail("launcher: could not lower the scheduling priority; not starting, " + "because a busy daemon could then freeze the console"); return 1; } diff --git a/launcher/report.c b/launcher/report.c new file mode 100644 index 0000000..0c242dc --- /dev/null +++ b/launcher/report.c @@ -0,0 +1,107 @@ +/* The launcher's log and its way of telling the user that a start failed. */ + +#include +#include +#include +#include +#include +#include + +#include + +#include + +#include "report.h" + +#define DATA_DIR "/data/tailscale" +/* The log is started afresh once it has grown past this. */ +#define LOG_MAX_SIZE (64 * 1024) + +typedef struct { + char unused[45]; + char message[3075]; +} notify_request_t; + +int sceKernelSendNotificationRequest(int, notify_request_t *, size_t, int); + +static int +log_open(void) { + struct stat st; + int flags = O_WRONLY | O_CREAT | O_APPEND; + + mkdir(DATA_DIR, 0755); + if (!stat(LAUNCHER_LOG, &st) && st.st_size > LOG_MAX_SIZE) { + flags |= O_TRUNC; + } + return open(LAUNCHER_LOG, flags, 0644); +} + +static void +log_line(const char *line) { + char stamp[32] = ""; + time_t now = time(0); + struct tm tm; + int fd = log_open(); + + if (fd < 0) { + return; + } + if (gmtime_r(&now, &tm)) { + strftime(stamp, sizeof(stamp), "%Y-%m-%d %H:%M:%S UTC ", &tm); + } + write(fd, stamp, strlen(stamp)); + write(fd, line, strlen(line)); + write(fd, "\n", 1); + close(fd); +} + +void +report_begin(void) { + char line[128]; + unsigned fw = kernel_get_fw_version(); + + snprintf(line, sizeof(line), "launcher: starting, firmware %x.%02x, pid %d", fw >> 24, (fw >> 16) & 0xff, + (int)getpid()); + log_line(line); +} + +void +report_log(const char *fmt, ...) { + char line[512]; + va_list ap; + + va_start(ap, fmt); + vsnprintf(line, sizeof(line), fmt, ap); + va_end(ap); + fprintf(stderr, "%s\n", line); + log_line(line); +} + +void +report_fail(const char *fmt, ...) { + static notify_request_t req; + char line[512]; + va_list ap; + + va_start(ap, fmt); + vsnprintf(line, sizeof(line), fmt, ap); + va_end(ap); + fprintf(stderr, "%s\n", line); + log_line(line); + + memset(&req, 0, sizeof(req)); + snprintf(req.message, sizeof(req.message), "Tailscale did not start:\n%s\nDetails: " LAUNCHER_LOG, line); + sceKernelSendNotificationRequest(0, &req, sizeof(req), 0); +} + +void +report_capture_stderr(void) { + int fd = log_open(); + + if (fd < 0) { + return; + } + fflush(stderr); + dup2(fd, 2); + close(fd); +} diff --git a/launcher/report.h b/launcher/report.h new file mode 100644 index 0000000..cc4262b --- /dev/null +++ b/launcher/report.h @@ -0,0 +1,20 @@ +#pragma once + +/* Where the launcher records what it did. A payload manager does not show + * what a payload prints, so this file is how a start that went wrong can be + * looked into afterwards. */ +#define LAUNCHER_LOG "/data/tailscale/launcher.log" + +/* Starts a new entry in the launcher log. */ +void report_begin(void); + +/* Writes a line to the launcher log and to whoever sent the payload. */ +void report_log(const char *fmt, ...) __attribute__((format(printf, 1, 2))); + +/* Like report_log, and also tells the user on screen that Tailscale did not + * start. */ +void report_fail(const char *fmt, ...) __attribute__((format(printf, 1, 2))); + +/* Points stderr at the launcher log, so that whatever the Go runtime prints + * if it dies before the daemon has opened its own log ends up there. */ +void report_capture_stderr(void); diff --git a/tools/build-payload.ps1 b/tools/build-payload.ps1 index 51f8ed2..de1a50e 100644 --- a/tools/build-payload.ps1 +++ b/tools/build-payload.ps1 @@ -54,7 +54,7 @@ if ($HomeIcon) { $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')) + (Join-Path $DevRoot 'launcher\homeicon.c'), (Join-Path $DevRoot 'launcher\report.c')) Invoke-PS5CC @ccArgs Write-Host ("built {0} ({1:N1} MB)" -f $elf, ((Get-Item $elf).Length / 1MB)) diff --git a/tsd/access.go b/tsd/access.go new file mode 100644 index 0000000..7060e51 --- /dev/null +++ b/tsd/access.go @@ -0,0 +1,142 @@ +package main + +import ( + "context" + "net" + "net/netip" + "sync" + "time" + + "tailscale.com/tailcfg" +) + +// Who on the tailnet may reach the console's services. +// +// Tailscale's access rules decide which devices can send to this node at +// all. On top of that the console can be limited to its owner's devices, +// because what it exposes (the payload loader above all) is more than most +// tailnets' rules were written with in mind, and a device that someone else +// shared into the tailnet is governed by rules the owner may not have looked +// at since. + +const ( + accessAll = "all" // every device the tailnet's access rules allow + accessOwn = "own" // only devices of the user this console is logged in as +) + +// identity is what the access check needs to know about a node. +type identity struct { + User tailcfg.UserID + Tagged bool + DNSName string +} + +// ownDevice reports whether peer belongs to the same user as self. A tagged +// node has no user: if the console itself is tagged, every device of its own +// tailnet counts, and a tagged peer never counts otherwise. suffix is the +// tailnet's MagicDNS suffix; a device from another tailnet never counts. +func ownDevice(self, peer identity, suffix string) bool { + if peer.DNSName != "" && suffix != "" && !hasDNSSuffix(peer.DNSName, suffix) { + return false + } + if self.Tagged { + return true + } + return !peer.Tagged && peer.User != 0 && peer.User == self.User +} + +// accessCache remembers recent decisions, so that a busy port does not ask +// Tailscale about the same device for every connection. +type accessCache struct { + mu sync.Mutex + entries map[netip.Addr]accessEntry +} + +type accessEntry struct { + allowed bool + at time.Time +} + +const accessCacheTime = time.Minute + +func (c *accessCache) get(addr netip.Addr) (allowed, ok bool) { + c.mu.Lock() + defer c.mu.Unlock() + e, ok := c.entries[addr] + if !ok || time.Since(e.at) > accessCacheTime { + return false, false + } + return e.allowed, true +} + +func (c *accessCache) put(addr netip.Addr, allowed bool) { + c.mu.Lock() + defer c.mu.Unlock() + if c.entries == nil || len(c.entries) > 1024 { + c.entries = map[netip.Addr]accessEntry{} + } + c.entries[addr] = accessEntry{allowed: allowed, at: time.Now()} +} + +func (c *accessCache) clear() { + c.mu.Lock() + defer c.mu.Unlock() + c.entries = nil +} + +// allowedFrom reports whether the tailnet device at src may use the +// console's services under the current setting. +func (d *daemon) allowedFrom(src netip.Addr) bool { + d.mu.Lock() + mode := d.cfg.AllowFrom + d.mu.Unlock() + if mode != accessOwn { + return true + } + src = src.Unmap() + if allowed, ok := d.access.get(src); ok { + return allowed + } + allowed, who := d.lookupOwnDevice(src) + d.access.put(src, allowed) + if !allowed { + d.logf("refused %s (%s): only this console's owner's devices may connect", src, who) + } + return allowed +} + +// lookupOwnDevice asks Tailscale who src is. Anything that cannot be +// established counts as not allowed. +func (d *daemon) lookupOwnDevice(src netip.Addr) (allowed bool, who string) { + if d.lc == nil { + return false, "unknown" + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + st, err := d.lc.StatusWithoutPeers(ctx) + if err != nil || st.Self == nil { + return false, "unknown" + } + // WhoIs wants an address with a port; the port plays no part for a + // tailnet address. + res, err := d.lc.WhoIs(ctx, netip.AddrPortFrom(src, 1).String()) + if err != nil || res.Node == nil { + return false, "unknown" + } + who = res.Node.Name + if res.UserProfile != nil && res.UserProfile.LoginName != "" { + who += ", " + res.UserProfile.LoginName + } + self := identity{User: st.Self.UserID, Tagged: st.Self.IsTagged()} + peer := identity{User: res.Node.User, Tagged: res.Node.IsTagged(), DNSName: res.Node.Name} + return ownDevice(self, peer, st.MagicDNSSuffix), who +} + +// allowedFromAddr is allowedFrom for the address of a datagram. +func (d *daemon) allowedFromAddr(from net.Addr) bool { + ap, err := netip.ParseAddrPort(from.String()) + if err != nil { + return false + } + return d.allowedFrom(ap.Addr()) +} diff --git a/tsd/access_test.go b/tsd/access_test.go new file mode 100644 index 0000000..66f224f --- /dev/null +++ b/tsd/access_test.go @@ -0,0 +1,151 @@ +package main + +import ( + "context" + "net" + "net/netip" + "testing" + "time" +) + +func TestOwnDevice(t *testing.T) { + const suffix = "tail1234.ts.net" + me := identity{User: 7} + for _, tt := range []struct { + name string + self identity + peer identity + want bool + }{ + {"same user", me, identity{User: 7, DNSName: "pc.tail1234.ts.net."}, true}, + {"another user of the tailnet", me, identity{User: 8, DNSName: "pc.tail1234.ts.net."}, false}, + {"tagged device of the tailnet", me, identity{User: 7, Tagged: true, DNSName: "srv.tail1234.ts.net."}, false}, + {"shared in from another tailnet", me, identity{User: 9, DNSName: "pc.other.ts.net."}, false}, + {"another tailnet claiming the same user", me, identity{User: 7, DNSName: "pc.other.ts.net."}, false}, + {"unknown user", me, identity{DNSName: "pc.tail1234.ts.net."}, false}, + {"tagged console, device of its tailnet", identity{Tagged: true}, identity{User: 8, DNSName: "pc.tail1234.ts.net."}, true}, + {"tagged console, shared device", identity{Tagged: true}, identity{User: 8, DNSName: "pc.other.ts.net."}, false}, + } { + if got := ownDevice(tt.self, tt.peer, suffix); got != tt.want { + t.Errorf("%s: got %v, want %v", tt.name, got, tt.want) + } + } +} + +func TestAccessCache(t *testing.T) { + var c accessCache + a := netip.MustParseAddr("100.64.0.2") + if _, ok := c.get(a); ok { + t.Fatal("an empty cache had an answer") + } + c.put(a, true) + if allowed, ok := c.get(a); !ok || !allowed { + t.Fatalf("got %v, %v", allowed, ok) + } + c.clear() + if _, ok := c.get(a); ok { + t.Fatal("the cache kept its answer after clear") + } +} + +// With the default setting nothing is asked and everything is allowed. +func TestAllowedFromDefault(t *testing.T) { + d := &daemon{cfg: defaultConfig(), logf: t.Logf} + if !d.allowedFrom(netip.MustParseAddr("100.64.0.9")) { + t.Error("the default setting turned a device away") + } + // Limited to own devices, a device that cannot be identified is refused. + d.cfg.AllowFrom = accessOwn + if d.allowedFrom(netip.MustParseAddr("100.64.0.9")) { + t.Error("an unidentified device was let in") + } +} + +func TestKeyWarnStage(t *testing.T) { + now := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC) + day := 24 * time.Hour + for _, tt := range []struct { + left time.Duration + stage int + days int + }{ + {90 * day, -1, 90}, + {14*day + time.Hour, -1, 14}, + {14 * day, 0, 14}, + {5 * day, 0, 5}, + {3 * day, 1, 3}, + {36 * time.Hour, 1, 1}, + {20 * time.Hour, 2, 0}, + {-time.Hour, 2, -1}, + } { + expiry := now.Add(tt.left) + if got := keyWarnStage(now, expiry); got != tt.stage { + t.Errorf("%v left: stage %d, want %d", tt.left, got, tt.stage) + } + if got := daysLeft(now, expiry); got != tt.days { + t.Errorf("%v left: %d days, want %d", tt.left, got, tt.days) + } + } + if plural(1, "day") != "1 day" || plural(3, "day") != "3 days" { + t.Error("plural") + } +} + +// A relay with an allow function serves the clients it accepts and stays +// silent towards the ones it turns down. +func TestUDPRelayAllow(t *testing.T) { + echo, err := net.ListenPacket("udp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer echo.Close() + go func() { + buf := make([]byte, 1500) + for { + n, from, err := echo.ReadFrom(buf) + if err != nil { + return + } + echo.WriteTo(buf[:n], from) + } + }() + + for _, allow := range []bool{true, false} { + var relayAddr net.Addr + relay, err := startUDPRelay(udpRelayConfig{ + name: "test", + listen: func() (net.PacketConn, error) { + pc, err := net.ListenPacket("udp", "127.0.0.1:0") + if err == nil { + relayAddr = pc.LocalAddr() + } + return pc, err + }, + dial: func(ctx context.Context) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, "udp", echo.LocalAddr().String()) + }, + allow: func(net.Addr) bool { return allow }, + logf: t.Logf, + }) + if err != nil { + t.Fatal(err) + } + c, err := net.Dial("udp", relayAddr.String()) + if err != nil { + t.Fatal(err) + } + c.Write([]byte("ping")) + c.SetReadDeadline(time.Now().Add(700 * time.Millisecond)) + buf := make([]byte, 16) + n, err := c.Read(buf) + if allow && (err != nil || string(buf[:n]) != "ping") { + t.Errorf("allowed client: got %q, %v", buf[:n], err) + } + if !allow && err == nil { + t.Errorf("refused client got a reply: %q", buf[:n]) + } + c.Close() + relay.stop() + } +} diff --git a/tsd/config.go b/tsd/config.go index 1995b39..4e4994f 100644 --- a/tsd/config.go +++ b/tsd/config.go @@ -42,6 +42,10 @@ type config struct { UDPPorts []uint16 `json:"udpPorts"` // BlockedPorts lists local TCP ports that are never exposed to the tailnet. BlockedPorts []uint16 `json:"blockedPorts,omitempty"` + // AllowFrom limits which tailnet devices may reach the console's services: + // empty for every device the tailnet's access rules allow, "own" for only + // the devices of the user this console is logged in as. + AllowFrom string `json:"allowFrom,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. @@ -96,6 +100,9 @@ func (cfg *config) normalize() { } cfg.SunshineHost = "" } + if cfg.AllowFrom != accessOwn { + cfg.AllowFrom = "" + } if cfg.Priority != priorityHigh { cfg.Priority = "" } diff --git a/tsd/forward.go b/tsd/forward.go index b749520..0c77536 100644 --- a/tsd/forward.go +++ b/tsd/forward.go @@ -28,6 +28,9 @@ func (d *daemon) forwardToLocalhost(src, dst netip.AddrPort) (handler func(net.C // open here must not end up at the console's service. return nil, false } + if !d.allowedFrom(src.Addr()) { + return nil, false + } port := dst.Port() if port == d.webPort { // The status page is served on the tailnet connection itself rather diff --git a/tsd/inboundudp.go b/tsd/inboundudp.go index 7079912..c3e2670 100644 --- a/tsd/inboundudp.go +++ b/tsd/inboundudp.go @@ -31,6 +31,8 @@ type udpExposer struct { logf func(format string, args ...any) // targetHost is where the console's services are reached. targetHost string + // allow, if set, decides which senders are served. + allow func(from net.Addr) bool mu sync.Mutex addrs []netip.Addr @@ -71,7 +73,8 @@ func (e *udpExposer) update(addrs []netip.Addr, ports []uint16) { var d net.Dialer return d.DialContext(ctx, "udp", target) }, - logf: e.logf, + allow: e.allow, + logf: e.logf, }) if err != nil { e.logf("udp %s: %v", listenAddr, err) diff --git a/tsd/keyexpiry.go b/tsd/keyexpiry.go new file mode 100644 index 0000000..1a579d5 --- /dev/null +++ b/tsd/keyexpiry.go @@ -0,0 +1,101 @@ +package main + +import ( + "context" + "fmt" + "time" +) + +// A device's Tailscale key expires after a while (180 days unless the +// tailnet says otherwise or expiry is turned off for the device). When that +// happens to a console nobody is looking at, it simply drops off the tailnet. +// The status page shows the date; this warns on screen as it gets close. + +// keyWarnDays are the points, in days before the expiry, at which the user +// is told on screen. The status page warns from the first of them on. +var keyWarnDays = []int{14, 3, 1} + +// daysLeft is the number of whole days from now until expiry, negative once +// it has passed. +func daysLeft(now, expiry time.Time) int { + d := expiry.Sub(now) + if d < 0 { + return -1 + } + return int(d / (24 * time.Hour)) +} + +// keyWarnStage returns the index of the last warning point that has been +// reached, or -1 if the expiry is still further away than all of them. +func keyWarnStage(now, expiry time.Time) int { + left := expiry.Sub(now) + stage := -1 + for i, days := range keyWarnDays { + if left <= time.Duration(days)*24*time.Hour { + stage = i + } + } + return stage +} + +// keyExpiry returns when this console's key expires; the zero time if it +// does not expire or is not known. +func (d *daemon) keyExpiry(ctx context.Context) time.Time { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + st, err := d.lc.StatusWithoutPeers(ctx) + if err != nil || st.Self == nil || st.Self.KeyExpiry == nil { + return time.Time{} + } + return *st.Self.KeyExpiry +} + +// watchKeyExpiry tells the user on screen when the key is about to expire: +// once for each warning point reached while this process runs. +func (d *daemon) watchKeyExpiry(ctx context.Context) { + ticker := time.NewTicker(time.Hour) + defer ticker.Stop() + warned := -1 + first := time.After(2 * time.Minute) + for { + select { + case <-ctx.Done(): + return + case <-first: + case <-ticker.C: + } + d.mu.Lock() + running := d.state == "Running" + d.mu.Unlock() + if !running { + continue + } + expiry := d.keyExpiry(ctx) + if expiry.IsZero() { + warned = -1 + continue + } + now := time.Now() + stage := keyWarnStage(now, expiry) + if stage <= warned || !expiry.After(now) { + if stage < warned { + warned = stage // the key was renewed + } + continue + } + warned = stage + left := "in less than a day" + if n := daysLeft(now, expiry); n >= 1 { + left = "in " + plural(n, "day") + } + d.logf("this console's Tailscale key expires %s (%s)", left, expiry.Local().Format("2006-01-02")) + notify("Tailscale: this PS5's key expires %s.\nLog in again or turn off key expiry.\n%s", left, d.webURL()) + } +} + +func plural(n int, word string) string { + if n == 1 { + return "1 " + word + } + return fmt.Sprintf("%d %ss", n, word) +} diff --git a/tsd/localforward_test.go b/tsd/localforward_test.go index f489fa0..d49bdbb 100644 --- a/tsd/localforward_test.go +++ b/tsd/localforward_test.go @@ -12,10 +12,7 @@ import ( // startEcho runs a TCP and a UDP echo server on the same port and returns it. func startEcho(t *testing.T) string { t.Helper() - ln, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatal(err) - } + ln, pc := listenBoth(t) t.Cleanup(func() { ln.Close() }) go func() { for { @@ -26,10 +23,6 @@ func startEcho(t *testing.T) string { go func() { io.Copy(c, c); c.Close() }() } }() - pc, err := net.ListenPacket("udp", ln.Addr().String()) - if err != nil { - t.Fatal(err) - } t.Cleanup(func() { pc.Close() }) go func() { buf := make([]byte, 65535) @@ -44,14 +37,35 @@ func startEcho(t *testing.T) string { return ln.Addr().String() } -// freePort returns a localhost address nothing listens on. +// listenBoth opens a TCP and a UDP socket on the same localhost port. A port +// the system hands out for TCP is not always available for UDP (Windows +// reserves ranges per protocol), so it tries until both work. +func listenBoth(t *testing.T) (net.Listener, net.PacketConn) { + t.Helper() + var lastErr error + for range 50 { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + pc, err := net.ListenPacket("udp", ln.Addr().String()) + if err == nil { + return ln, pc + } + lastErr = err + ln.Close() + } + t.Fatal(lastErr) + return nil, nil +} + +// freePort returns a localhost address nothing listens on, free for both +// TCP and UDP. func freePort(t *testing.T) string { t.Helper() - ln, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatal(err) - } + ln, pc := listenBoth(t) defer ln.Close() + defer pc.Close() return ln.Addr().String() } diff --git a/tsd/main.go b/tsd/main.go index e0139fe..1255238 100644 --- a/tsd/main.go +++ b/tsd/main.go @@ -124,6 +124,7 @@ type daemon struct { latest releaseInfo // newest release known, see update.go sessions sessions // browsers that have entered the password + access accessCache // recent decisions about who may connect tailnetWeb *tailnetListener // status page connections arriving over the tailnet quit chan struct{} @@ -186,11 +187,12 @@ 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"} + d.udp = &udpExposer{listen: d.srv.ListenPacket, logf: d.logf, targetHost: "127.0.0.1", allow: d.allowedFromAddr} go d.watch(ctx) go d.recoverLogin(ctx) go d.exposeUDP(ctx) go d.watchForUpdates(ctx) + go d.watchKeyExpiry(ctx) sigc := make(chan os.Signal, 1) signal.Notify(sigc, syscall.SIGTERM, syscall.SIGINT) diff --git a/tsd/settings.go b/tsd/settings.go index bade88e..348f80e 100644 --- a/tsd/settings.go +++ b/tsd/settings.go @@ -40,6 +40,7 @@ type settings struct { UDPPorts []uint16 `json:"udpPorts"` BlockedPorts []uint16 `json:"blockedPorts"` Priority string `json:"priority"` + AllowFrom string `json:"allowFrom"` CheckUpdates bool `json:"checkUpdates"` Verbose bool `json:"verbose"` @@ -60,6 +61,7 @@ func settingsFromConfig(cfg config) settings { UDPPorts: append([]uint16{}, cfg.UDPPorts...), BlockedPorts: append([]uint16{}, cfg.BlockedPorts...), Priority: priorityLow, + AllowFrom: accessAll, CheckUpdates: cfg.CheckUpdates, Verbose: cfg.Verbose, PasswordSet: cfg.PasswordHash != "", @@ -67,6 +69,9 @@ func settingsFromConfig(cfg config) settings { if cfg.Priority == priorityHigh { s.Priority = priorityHigh } + if cfg.AllowFrom == accessOwn { + s.AllowFrom = accessOwn + } return s } @@ -103,6 +108,12 @@ func (s *settings) validate() error { if slices.Contains(s.UDPPorts, 0) || slices.Contains(s.BlockedPorts, 0) { return fmt.Errorf("0 is not a port") } + if s.AllowFrom == "" { + s.AllowFrom = accessAll + } + if s.AllowFrom != accessAll && s.AllowFrom != accessOwn { + return fmt.Errorf("who may connect must be all or own") + } if s.Priority != priorityLow && s.Priority != priorityHigh { return fmt.Errorf("the priority must be low or high") } @@ -194,6 +205,10 @@ func (d *daemon) handleSetConfig(w http.ResponseWriter, r *http.Request) { if s.Priority == priorityHigh { cfg.Priority = priorityHigh } + cfg.AllowFrom = "" + if s.AllowFrom == accessOwn { + cfg.AllowFrom = accessOwn + } cfg.CheckUpdates = s.CheckUpdates cfg.Verbose = s.Verbose if newHash != nil { @@ -238,6 +253,7 @@ func (d *daemon) handleSetConfig(w http.ResponseWriter, r *http.Request) { } } d.writePriorityFile() + d.access.clear() // UDP ports and blocked ports are read from the config where they are used. restart := []string{} diff --git a/tsd/status.html b/tsd/status.html index 4ee4db4..c87acf2 100644 --- a/tsd/status.html +++ b/tsd/status.html @@ -45,6 +45,17 @@ th { text-align: left; color: var(--muted); font-weight: 500; padding: 4px 8px 8px 0; } td { padding: 7px 8px 7px 0; border-top: 1px solid var(--line); overflow-wrap: anywhere; } td.off { color: var(--muted); } + /* Names may wrap anywhere; addresses, OS and status stay in one piece. */ + #peers td:nth-child(n+2), th { white-space: nowrap; overflow-wrap: normal; } + code.copy { cursor: pointer; border-bottom: 1px dotted var(--muted); } + code.copy:hover, code.copy:focus-visible { color: var(--accent); border-bottom-color: var(--accent); } + code.copy.done { color: var(--ok); border-bottom-color: transparent; } + @media (max-width: 480px) { + #peers code { font-size: 12.5px; } table { font-size: 14px; } + /* Label above value, so that names and addresses get the full width. */ + dl { grid-template-columns: 1fr; gap: 0; } + dt { font-size: 13px; margin-top: 10px; } + } .actions { display: flex; flex-wrap: wrap; gap: 10px; align-items: flex-end; } button { font: inherit; padding: 9px 16px; border-radius: 8px; border: 1px solid var(--line); @@ -107,6 +118,7 @@
Loading…
+ @@ -162,6 +174,14 @@ passwordHash from /data/tailscale/config.json. +