mirror of
https://github.com/holdmysocks/ps5-tailscale.git
synced 2026-10-06 07:00:22 +02:00
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.
This commit is contained in:
1 parent
3e0e872884
commit
bc19d3c251
21 files changed
+786
-47
No files matched your search
@@ -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
|
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
|
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
|
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
|
**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
|
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
|
every device except the console itself. If you forget it, delete the
|
||||||
`passwordHash` line from `/data/tailscale/config.json`.
|
`passwordHash` line from `/data/tailscale/config.json`.
|
||||||
|
|
||||||
**Settings.** The name on the tailnet, the password, which UDP ports are
|
**Settings.** The name on the tailnet, the password, who on the tailnet may
|
||||||
reachable and which TCP ports are not, extra forwards, the HTTP proxy, the
|
connect, which UDP ports are reachable and which TCP ports are not, extra
|
||||||
priority, and update checks. Most take effect when saved; the page says which
|
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.
|
ones need Tailscale to be started again.
|
||||||
|
|
||||||
### Game streaming (Moonlight to Sunshine)
|
### Game streaming (Moonlight to Sunshine)
|
||||||
@@ -229,6 +236,7 @@ optional.
|
|||||||
],
|
],
|
||||||
"udpPorts": [9295, 9296, 9297, 9302],
|
"udpPorts": [9295, 9296, 9297, 9302],
|
||||||
"blockedPorts": [],
|
"blockedPorts": [],
|
||||||
|
"allowFrom": "",
|
||||||
"priority": "",
|
"priority": "",
|
||||||
"checkUpdates": true,
|
"checkUpdates": true,
|
||||||
"verbose": false
|
"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. |
|
| `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. |
|
| `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. |
|
| `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. |
|
| `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. |
|
| `verbose` | Put Tailscale's own log in the main log as well. |
|
||||||
|
|
||||||
Files on the console:
|
Files on the console:
|
||||||
@@ -259,6 +268,7 @@ Files on the console:
|
|||||||
| `/data/tailscale/state/` | Tailscale's state, including the login. |
|
| `/data/tailscale/state/` | Tailscale's state, including the login. |
|
||||||
| `/data/tailscale/tailscale.log` | The daemon's log, rotated at 2 MB. |
|
| `/data/tailscale/tailscale.log` | The daemon's log, rotated at 2 MB. |
|
||||||
| `/data/tailscale/tailscale-debug.log` | Tailscale's detailed log, up to 4 MB plus one older file. |
|
| `/data/tailscale/tailscale-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-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. |
|
| `/data/tailscale/icon-helper.elf` | The small payload that adds and removes the icon. |
|
||||||
| `/user/app/TSCL00001/` | The home screen icon. |
|
| `/user/app/TSCL00001/` | The home screen icon. |
|
||||||
@@ -277,6 +287,14 @@ Left to do by hand:
|
|||||||
|
|
||||||
## Troubleshooting
|
## 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:<console>:9021 < tailscale.elf`, or
|
||||||
|
`.\tools\ps5send.ps1 -File tailscale.elf -PS5Host <console>` 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.**
|
- **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".
|
Check that the PS5's proxy server setting is "Do Not Use".
|
||||||
- **The Moonlight client cannot find the host.** The host to add is
|
- **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.
|
tailnet Tailscale encrypts it.
|
||||||
- All listening TCP ports on the console, and the UDP ports in `udpPorts`,
|
- All listening TCP ports on the console, and the UDP ports in `udpPorts`,
|
||||||
become reachable from your tailnet. That includes the payload loader, which
|
become reachable from your tailnet. That includes the payload loader, which
|
||||||
runs anything sent to it. Use Tailscale ACLs if other people share your
|
runs anything sent to it. If other people use your tailnet or share
|
||||||
tailnet, or list ports under "TCP ports never exposed".
|
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
|
- The local forwards and the proxy are for the console's own apps and are
|
||||||
not exposed to the tailnet.
|
not exposed to the tailnet.
|
||||||
- With update checks on, the console contacts `api.github.com` twice a day.
|
- 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
|
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
|
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,
|
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).
|
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
|
Remote Play through the tailnet address works with Chiaki and with Asobi on
|
||||||
|
|||||||
+19
-1
@@ -22,6 +22,14 @@ specification.
|
|||||||
to a fresh 1 MB stack and jumps to the Go entry point. The embedded copy
|
to a fresh 1 MB stack and jumps to the Go entry point. The embedded copy
|
||||||
is then released with `madvise(MADV_FREE)`.
|
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.
|
**The daemon** (`tsd/`, Go): a `tsnet` server.
|
||||||
|
|
||||||
- Inbound: tsnet's fallback TCP handler pipes each tailnet connection to
|
- 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
|
the priority, before any Go code runs, so the daemon leaves it in
|
||||||
`/data/tailscale/priority` for the next start.
|
`/data/tailscale/priority` for the next start.
|
||||||
- Update notice (`update.go`): the latest release tag from the GitHub API,
|
- 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
|
- 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
|
network was reconfigured), the daemon asks Tailscale to rebind and re-STUN
|
||||||
instead of waiting for its interface polling. See [Rest mode](#rest-mode)
|
instead of waiting for its interface polling. See [Rest mode](#rest-mode)
|
||||||
|
|||||||
+18
-8
@@ -7,6 +7,7 @@
|
|||||||
* FreeBSD kernel would: %rdi pointing at argc/argv/envp/auxv. */
|
* FreeBSD kernel would: %rdi pointing at argc/argv/envp/auxv. */
|
||||||
|
|
||||||
#include <elf.h>
|
#include <elf.h>
|
||||||
|
#include <errno.h>
|
||||||
#include <stdint.h>
|
#include <stdint.h>
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
@@ -17,6 +18,7 @@
|
|||||||
#include <ps5/kernel.h>
|
#include <ps5/kernel.h>
|
||||||
|
|
||||||
#include "goload.h"
|
#include "goload.h"
|
||||||
|
#include "report.h"
|
||||||
|
|
||||||
#define PS5_PAGE_SIZE 0x4000ul
|
#define PS5_PAGE_SIZE 0x4000ul
|
||||||
#define PAGE_TRUNC(x) ((x) & ~(PS5_PAGE_SIZE - 1))
|
#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;
|
int envc = 0;
|
||||||
|
|
||||||
if (image_check(image, size)) {
|
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;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,7 +145,7 @@ goload_run(const uint8_t *image, size_t size, char *const argv[], char *const en
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (phdr[i].p_offset + phdr[i].p_filesz > size) {
|
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;
|
return -1;
|
||||||
}
|
}
|
||||||
if (phdr[i].p_vaddr < min_vaddr) {
|
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) {
|
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;
|
return -1;
|
||||||
}
|
}
|
||||||
min_vaddr = PAGE_TRUNC(min_vaddr);
|
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);
|
base = mmap(0, max_vaddr - min_vaddr, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
|
||||||
if (base == MAP_FAILED) {
|
if (base == MAP_FAILED) {
|
||||||
perror("goload: mmap image");
|
report_fail("goload: no memory for the program (mmap: %s)", strerror(errno));
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
bias = (uintptr_t)base - min_vaddr;
|
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++) {
|
for (size_t i = 0; rela && i < relasz / sizeof(*rela); i++) {
|
||||||
if (ELF64_R_TYPE(rela[i].r_info) != R_X86_64_RELATIVE) {
|
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;
|
return -1;
|
||||||
}
|
}
|
||||||
*(uintptr_t *)(bias + rela[i].r_offset) = bias + rela[i].r_addend;
|
*(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
|
* that contains the address, so first let a regular mprotect split the
|
||||||
* text range off into an entry of its own. */
|
* text range off into an entry of its own. */
|
||||||
if (mprotect((void *)start, end - start, PROT_READ)) {
|
if (mprotect((void *)start, end - start, PROT_READ)) {
|
||||||
perror("goload: mprotect");
|
report_fail("goload: mprotect: %s", strerror(errno));
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
if (kernel_mprotect(-1, start, end - start, PROT_READ | PROT_EXEC)) {
|
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;
|
return -1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stack = mmap(0, GO_STACK_SIZE, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
|
stack = mmap(0, GO_STACK_SIZE, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
|
||||||
if (stack == MAP_FAILED) {
|
if (stack == MAP_FAILED) {
|
||||||
perror("goload: mmap stack");
|
report_fail("goload: no memory for the stack (mmap: %s)", strerror(errno));
|
||||||
return -1;
|
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,
|
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);
|
base + (max_vaddr - min_vaddr), (unsigned long)(bias + ehdr->e_entry), stack, stack + GO_STACK_SIZE, argc);
|
||||||
dbg_install((uintptr_t)base, min_vaddr);
|
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
|
#endif
|
||||||
|
|
||||||
fflush(stdout);
|
fflush(stdout);
|
||||||
|
|||||||
+5
-2
@@ -9,6 +9,8 @@
|
|||||||
|
|
||||||
#ifdef ICON_HELPER
|
#ifdef ICON_HELPER
|
||||||
|
|
||||||
|
#include "report.h"
|
||||||
|
|
||||||
#include <fcntl.h>
|
#include <fcntl.h>
|
||||||
#include <stdint.h>
|
#include <stdint.h>
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
@@ -103,6 +105,7 @@ home_icon_install_once(void) {
|
|||||||
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||||
if (connect(fd, (struct sockaddr *)&addr, sizeof(addr))) {
|
if (connect(fd, (struct sockaddr *)&addr, sizeof(addr))) {
|
||||||
/* No ELF loader on the usual port: go without an icon this time. */
|
/* 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);
|
close(fd);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -139,9 +142,9 @@ home_icon_install_once(void) {
|
|||||||
write(fd, ICON_VERSION, sizeof(ICON_VERSION) - 1);
|
write(fd, ICON_VERSION, sizeof(ICON_VERSION) - 1);
|
||||||
close(fd);
|
close(fd);
|
||||||
}
|
}
|
||||||
fprintf(stderr, "launcher: home screen icon installed\n");
|
report_log("launcher: home screen icon installed");
|
||||||
} else {
|
} 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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -12,6 +12,7 @@
|
|||||||
|
|
||||||
#include "goload.h"
|
#include "goload.h"
|
||||||
#include "homeicon.h"
|
#include "homeicon.h"
|
||||||
|
#include "report.h"
|
||||||
|
|
||||||
#ifndef GO_IMAGE
|
#ifndef GO_IMAGE
|
||||||
#error "GO_IMAGE must name the Go binary to embed"
|
#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_rgid(pid, 0);
|
||||||
kernel_set_ucred_svgid(pid, 0);
|
kernel_set_ucred_svgid(pid, 0);
|
||||||
|
|
||||||
|
report_begin();
|
||||||
home_icon_install_once();
|
home_icon_install_once();
|
||||||
|
|
||||||
if (leave_realtime_class()) {
|
if (leave_realtime_class()) {
|
||||||
/* Without this a runaway goroutine could hang the console, so do not
|
/* Without this a runaway goroutine could hang the console, so do not
|
||||||
* take the chance. */
|
* 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;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
/* The launcher's log and its way of telling the user that a start failed. */
|
||||||
|
|
||||||
|
#include <fcntl.h>
|
||||||
|
#include <stdarg.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <time.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
#include <sys/stat.h>
|
||||||
|
|
||||||
|
#include <ps5/kernel.h>
|
||||||
|
|
||||||
|
#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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -54,7 +54,7 @@ if ($HomeIcon) {
|
|||||||
$ccArgs += "-DICON_HELPER=`"$($helper -replace '\\', '/')`""
|
$ccArgs += "-DICON_HELPER=`"$($helper -replace '\\', '/')`""
|
||||||
}
|
}
|
||||||
$ccArgs += @('-o', $elf, (Join-Path $DevRoot 'launcher\main.c'), (Join-Path $DevRoot 'launcher\goload.c'),
|
$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
|
Invoke-PS5CC @ccArgs
|
||||||
|
|
||||||
Write-Host ("built {0} ({1:N1} MB)" -f $elf, ((Get-Item $elf).Length / 1MB))
|
Write-Host ("built {0} ({1:N1} MB)" -f $elf, ((Get-Item $elf).Length / 1MB))
|
||||||
|
|||||||
+142
@@ -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())
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,6 +42,10 @@ type config struct {
|
|||||||
UDPPorts []uint16 `json:"udpPorts"`
|
UDPPorts []uint16 `json:"udpPorts"`
|
||||||
// BlockedPorts lists local TCP ports that are never exposed to the tailnet.
|
// BlockedPorts lists local TCP ports that are never exposed to the tailnet.
|
||||||
BlockedPorts []uint16 `json:"blockedPorts,omitempty"`
|
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)
|
// 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
|
// never takes time from a game, "high" shares the CPU with games on
|
||||||
// equal terms, which can make Remote Play smoother. Applied at start.
|
// equal terms, which can make Remote Play smoother. Applied at start.
|
||||||
@@ -96,6 +100,9 @@ func (cfg *config) normalize() {
|
|||||||
}
|
}
|
||||||
cfg.SunshineHost = ""
|
cfg.SunshineHost = ""
|
||||||
}
|
}
|
||||||
|
if cfg.AllowFrom != accessOwn {
|
||||||
|
cfg.AllowFrom = ""
|
||||||
|
}
|
||||||
if cfg.Priority != priorityHigh {
|
if cfg.Priority != priorityHigh {
|
||||||
cfg.Priority = ""
|
cfg.Priority = ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
// open here must not end up at the console's service.
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
if !d.allowedFrom(src.Addr()) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
port := dst.Port()
|
port := dst.Port()
|
||||||
if port == d.webPort {
|
if port == d.webPort {
|
||||||
// The status page is served on the tailnet connection itself rather
|
// The status page is served on the tailnet connection itself rather
|
||||||
|
|||||||
+4
-1
@@ -31,6 +31,8 @@ type udpExposer struct {
|
|||||||
logf func(format string, args ...any)
|
logf func(format string, args ...any)
|
||||||
// targetHost is where the console's services are reached.
|
// targetHost is where the console's services are reached.
|
||||||
targetHost string
|
targetHost string
|
||||||
|
// allow, if set, decides which senders are served.
|
||||||
|
allow func(from net.Addr) bool
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
addrs []netip.Addr
|
addrs []netip.Addr
|
||||||
@@ -71,7 +73,8 @@ func (e *udpExposer) update(addrs []netip.Addr, ports []uint16) {
|
|||||||
var d net.Dialer
|
var d net.Dialer
|
||||||
return d.DialContext(ctx, "udp", target)
|
return d.DialContext(ctx, "udp", target)
|
||||||
},
|
},
|
||||||
logf: e.logf,
|
allow: e.allow,
|
||||||
|
logf: e.logf,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
e.logf("udp %s: %v", listenAddr, err)
|
e.logf("udp %s: %v", listenAddr, err)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
+27
-13
@@ -12,10 +12,7 @@ import (
|
|||||||
// startEcho runs a TCP and a UDP echo server on the same port and returns it.
|
// startEcho runs a TCP and a UDP echo server on the same port and returns it.
|
||||||
func startEcho(t *testing.T) string {
|
func startEcho(t *testing.T) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
ln, pc := listenBoth(t)
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { ln.Close() })
|
t.Cleanup(func() { ln.Close() })
|
||||||
go func() {
|
go func() {
|
||||||
for {
|
for {
|
||||||
@@ -26,10 +23,6 @@ func startEcho(t *testing.T) string {
|
|||||||
go func() { io.Copy(c, c); c.Close() }()
|
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() })
|
t.Cleanup(func() { pc.Close() })
|
||||||
go func() {
|
go func() {
|
||||||
buf := make([]byte, 65535)
|
buf := make([]byte, 65535)
|
||||||
@@ -44,14 +37,35 @@ func startEcho(t *testing.T) string {
|
|||||||
return ln.Addr().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 {
|
func freePort(t *testing.T) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
ln, pc := listenBoth(t)
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer ln.Close()
|
defer ln.Close()
|
||||||
|
defer pc.Close()
|
||||||
return ln.Addr().String()
|
return ln.Addr().String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -124,6 +124,7 @@ type daemon struct {
|
|||||||
latest releaseInfo // newest release known, see update.go
|
latest releaseInfo // newest release known, see update.go
|
||||||
|
|
||||||
sessions sessions // browsers that have entered the password
|
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
|
tailnetWeb *tailnetListener // status page connections arriving over the tailnet
|
||||||
|
|
||||||
quit chan struct{}
|
quit chan struct{}
|
||||||
@@ -186,11 +187,12 @@ func (d *daemon) run() error {
|
|||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
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.watch(ctx)
|
||||||
go d.recoverLogin(ctx)
|
go d.recoverLogin(ctx)
|
||||||
go d.exposeUDP(ctx)
|
go d.exposeUDP(ctx)
|
||||||
go d.watchForUpdates(ctx)
|
go d.watchForUpdates(ctx)
|
||||||
|
go d.watchKeyExpiry(ctx)
|
||||||
|
|
||||||
sigc := make(chan os.Signal, 1)
|
sigc := make(chan os.Signal, 1)
|
||||||
signal.Notify(sigc, syscall.SIGTERM, syscall.SIGINT)
|
signal.Notify(sigc, syscall.SIGTERM, syscall.SIGINT)
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ type settings struct {
|
|||||||
UDPPorts []uint16 `json:"udpPorts"`
|
UDPPorts []uint16 `json:"udpPorts"`
|
||||||
BlockedPorts []uint16 `json:"blockedPorts"`
|
BlockedPorts []uint16 `json:"blockedPorts"`
|
||||||
Priority string `json:"priority"`
|
Priority string `json:"priority"`
|
||||||
|
AllowFrom string `json:"allowFrom"`
|
||||||
CheckUpdates bool `json:"checkUpdates"`
|
CheckUpdates bool `json:"checkUpdates"`
|
||||||
Verbose bool `json:"verbose"`
|
Verbose bool `json:"verbose"`
|
||||||
|
|
||||||
@@ -60,6 +61,7 @@ func settingsFromConfig(cfg config) settings {
|
|||||||
UDPPorts: append([]uint16{}, cfg.UDPPorts...),
|
UDPPorts: append([]uint16{}, cfg.UDPPorts...),
|
||||||
BlockedPorts: append([]uint16{}, cfg.BlockedPorts...),
|
BlockedPorts: append([]uint16{}, cfg.BlockedPorts...),
|
||||||
Priority: priorityLow,
|
Priority: priorityLow,
|
||||||
|
AllowFrom: accessAll,
|
||||||
CheckUpdates: cfg.CheckUpdates,
|
CheckUpdates: cfg.CheckUpdates,
|
||||||
Verbose: cfg.Verbose,
|
Verbose: cfg.Verbose,
|
||||||
PasswordSet: cfg.PasswordHash != "",
|
PasswordSet: cfg.PasswordHash != "",
|
||||||
@@ -67,6 +69,9 @@ func settingsFromConfig(cfg config) settings {
|
|||||||
if cfg.Priority == priorityHigh {
|
if cfg.Priority == priorityHigh {
|
||||||
s.Priority = priorityHigh
|
s.Priority = priorityHigh
|
||||||
}
|
}
|
||||||
|
if cfg.AllowFrom == accessOwn {
|
||||||
|
s.AllowFrom = accessOwn
|
||||||
|
}
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,6 +108,12 @@ func (s *settings) validate() error {
|
|||||||
if slices.Contains(s.UDPPorts, 0) || slices.Contains(s.BlockedPorts, 0) {
|
if slices.Contains(s.UDPPorts, 0) || slices.Contains(s.BlockedPorts, 0) {
|
||||||
return fmt.Errorf("0 is not a port")
|
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 {
|
if s.Priority != priorityLow && s.Priority != priorityHigh {
|
||||||
return fmt.Errorf("the priority must be low or high")
|
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 {
|
if s.Priority == priorityHigh {
|
||||||
cfg.Priority = priorityHigh
|
cfg.Priority = priorityHigh
|
||||||
}
|
}
|
||||||
|
cfg.AllowFrom = ""
|
||||||
|
if s.AllowFrom == accessOwn {
|
||||||
|
cfg.AllowFrom = accessOwn
|
||||||
|
}
|
||||||
cfg.CheckUpdates = s.CheckUpdates
|
cfg.CheckUpdates = s.CheckUpdates
|
||||||
cfg.Verbose = s.Verbose
|
cfg.Verbose = s.Verbose
|
||||||
if newHash != nil {
|
if newHash != nil {
|
||||||
@@ -238,6 +253,7 @@ func (d *daemon) handleSetConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
d.writePriorityFile()
|
d.writePriorityFile()
|
||||||
|
d.access.clear()
|
||||||
// UDP ports and blocked ports are read from the config where they are used.
|
// UDP ports and blocked ports are read from the config where they are used.
|
||||||
|
|
||||||
restart := []string{}
|
restart := []string{}
|
||||||
|
|||||||
+89
-10
@@ -45,6 +45,17 @@
|
|||||||
th { text-align: left; color: var(--muted); font-weight: 500; padding: 4px 8px 8px 0; }
|
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 { padding: 7px 8px 7px 0; border-top: 1px solid var(--line); overflow-wrap: anywhere; }
|
||||||
td.off { color: var(--muted); }
|
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; }
|
.actions { display: flex; flex-wrap: wrap; gap: 10px; align-items: flex-end; }
|
||||||
button {
|
button {
|
||||||
font: inherit; padding: 9px 16px; border-radius: 8px; border: 1px solid var(--line);
|
font: inherit; padding: 9px 16px; border-radius: 8px; border: 1px solid var(--line);
|
||||||
@@ -107,6 +118,7 @@
|
|||||||
<div class="state"><span class="dot" id="dot"></span><span id="state">Loading…</span></div>
|
<div class="state"><span class="dot" id="dot"></span><span id="state">Loading…</span></div>
|
||||||
<dl id="facts"></dl>
|
<dl id="facts"></dl>
|
||||||
<ul class="health hidden" id="health"></ul>
|
<ul class="health hidden" id="health"></ul>
|
||||||
|
<p class="health hidden" id="keywarn" style="padding-left: 0"></p>
|
||||||
<p class="msg hidden" id="error"></p>
|
<p class="msg hidden" id="error"></p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -162,6 +174,14 @@
|
|||||||
<code>passwordHash</code> from <code>/data/tailscale/config.json</code>.</span>
|
<code>passwordHash</code> from <code>/data/tailscale/config.json</code>.</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="check hidden" id="set-pwremove-row"><input type="checkbox" id="set-pwremove"> Remove the password</label>
|
<label class="check hidden" id="set-pwremove-row"><input type="checkbox" id="set-pwremove"> Remove the password</label>
|
||||||
|
<label class="field">Who on the tailnet may connect to this console
|
||||||
|
<select id="set-allowfrom">
|
||||||
|
<option value="all">Every device my tailnet's access rules allow (default)</option>
|
||||||
|
<option value="own">Only devices logged in as the same user as this console</option>
|
||||||
|
</select>
|
||||||
|
<span class="hint">The second choice turns away other users' devices and devices shared into your
|
||||||
|
tailnet, for every port and this page. The page stays reachable on your home network.</span>
|
||||||
|
</label>
|
||||||
<label class="field">UDP ports reachable from the tailnet
|
<label class="field">UDP ports reachable from the tailnet
|
||||||
<input type="text" id="set-udp" autocomplete="off">
|
<input type="text" id="set-udp" autocomplete="off">
|
||||||
<span class="hint">Comma separated. 9295, 9296, 9297, 9302 are Remote Play's. Empty turns inbound UDP off.</span>
|
<span class="hint">Comma separated. 9295, 9296, 9297, 9302 are Remote Play's. Empty turns inbound UDP off.</span>
|
||||||
@@ -254,14 +274,50 @@ function show(id, text) {
|
|||||||
el.classList.toggle('hidden', !text);
|
el.classList.toggle('hidden', !text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The page is served over plain HTTP, where browsers do not offer the
|
||||||
|
// clipboard API, so fall back to the old way of copying a selection.
|
||||||
|
async function copyText(text) {
|
||||||
|
try { await navigator.clipboard.writeText(text); return true; } catch (e) {}
|
||||||
|
const t = document.createElement('textarea');
|
||||||
|
t.value = text; t.setAttribute('readonly', ''); t.style.position = 'fixed'; t.style.opacity = '0';
|
||||||
|
document.body.append(t); t.select();
|
||||||
|
let ok = false;
|
||||||
|
try { ok = document.execCommand('copy'); } catch (e) {}
|
||||||
|
t.remove();
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyable returns a piece of monospace text that is copied when clicked.
|
||||||
|
function copyable(text) {
|
||||||
|
const c = document.createElement('code');
|
||||||
|
c.className = 'copy'; c.textContent = text; c.title = 'Copy'; c.tabIndex = 0;
|
||||||
|
c.setAttribute('role', 'button'); c.setAttribute('aria-label', 'Copy ' + text);
|
||||||
|
const go = async () => {
|
||||||
|
if (c.classList.contains('done')) return;
|
||||||
|
const ok = await copyText(text);
|
||||||
|
c.textContent = ok ? 'Copied' : 'Copy failed'; c.classList.add('done');
|
||||||
|
setTimeout(() => { c.textContent = text; c.classList.remove('done'); }, 1200);
|
||||||
|
};
|
||||||
|
c.onclick = go;
|
||||||
|
c.onkeydown = ev => { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); go(); } };
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
// row adds a fact. mono shows the value in monospace; an array of values is
|
||||||
|
// shown as separately copyable pieces.
|
||||||
function row(dl, name, value, mono) {
|
function row(dl, name, value, mono) {
|
||||||
const dt = document.createElement('dt'); dt.textContent = name;
|
const dt = document.createElement('dt'); dt.textContent = name;
|
||||||
const dd = document.createElement('dd');
|
const dd = document.createElement('dd');
|
||||||
if (mono) { const c = document.createElement('code'); c.textContent = value; dd.append(c); }
|
if (Array.isArray(value)) value.forEach((v, i) => { if (i) dd.append(', '); dd.append(copyable(v)); });
|
||||||
|
else if (mono) { const c = document.createElement('code'); c.textContent = value; dd.append(c); }
|
||||||
else dd.textContent = value;
|
else dd.textContent = value;
|
||||||
dl.append(dt, dd);
|
dl.append(dt, dd);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Facts are rebuilt on every refresh; skip that while nothing changed so a
|
||||||
|
// "Copied" confirmation is not wiped out mid-way.
|
||||||
|
let shownFacts = '';
|
||||||
|
|
||||||
function hostRow(host, port) {
|
function hostRow(host, port) {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.className = 'hostrow';
|
div.className = 'hostrow';
|
||||||
@@ -305,7 +361,7 @@ function peerRow(p) {
|
|||||||
for (const [v, mono, note] of [[p.name, false, notes.join(' · ')], [p.ip, true], [p.os], [p.online ? 'online' : 'offline']]) {
|
for (const [v, mono, note] of [[p.name, false, notes.join(' · ')], [p.ip, true], [p.os], [p.online ? 'online' : 'offline']]) {
|
||||||
const td = document.createElement('td');
|
const td = document.createElement('td');
|
||||||
if (!p.online) td.className = 'off';
|
if (!p.online) td.className = 'off';
|
||||||
if (mono) { const c = document.createElement('code'); c.textContent = v; td.append(c); } else td.textContent = v;
|
if (mono) td.append(v ? copyable(v) : ''); else td.textContent = v;
|
||||||
if (note) { const n = document.createElement('span'); n.className = 'tag'; n.textContent = note; td.append(n); }
|
if (note) { const n = document.createElement('span'); n.className = 'tag'; n.textContent = note; td.append(n); }
|
||||||
tr.append(td);
|
tr.append(td);
|
||||||
}
|
}
|
||||||
@@ -359,14 +415,35 @@ async function refresh() {
|
|||||||
$('updatelink').href = s.updateURL;
|
$('updatelink').href = s.updateURL;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dl = $('facts');
|
// Key expiry, in whole days.
|
||||||
dl.replaceChildren();
|
let keyText = '', keyWarn = '';
|
||||||
row(dl, 'Name', s.dnsName || s.hostname);
|
if (s.keyExpiry) {
|
||||||
if (s.ips.length) row(dl, 'Tailnet address', s.ips.join(', '), true);
|
const when = new Date(s.keyExpiry), days = Math.floor((when - Date.now()) / 86400000);
|
||||||
if (s.tailnet) row(dl, 'Tailnet', s.tailnet);
|
const date = when.toLocaleDateString(undefined, {year: 'numeric', month: 'short', day: 'numeric'});
|
||||||
if (s.ips.length) row(dl, 'Reachable from tailnet', 'every open TCP port' + (s.udpPorts.length ? '; UDP ' + s.udpPorts.join(', ') + ' (Remote Play)' : ''));
|
const left = days < 0 ? 'expired' : days === 0 ? 'in less than a day' : 'in ' + days + (days === 1 ? ' day' : ' days');
|
||||||
if (s.proxy) row(dl, 'HTTP proxy', s.proxy, true);
|
keyText = date + ' (' + left + ')';
|
||||||
if (s.priority === 'high') row(dl, 'Priority', 'High');
|
if (days < 14 && s.state === 'Running') {
|
||||||
|
keyWarn = 'This console\'s Tailscale key expires ' + left + '. After that it drops off your tailnet until you ' +
|
||||||
|
'use "Log in again" below. To stop it expiring, open the Tailscale admin console, find this device and ' +
|
||||||
|
'choose "Disable key expiry".';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
show('keywarn', keyWarn);
|
||||||
|
|
||||||
|
const factsNow = JSON.stringify([s.dnsName, s.hostname, s.ips, s.tailnet, s.udpPorts, s.proxy, s.priority, s.allowFrom, keyText]);
|
||||||
|
if (factsNow !== shownFacts) {
|
||||||
|
shownFacts = factsNow;
|
||||||
|
const dl = $('facts');
|
||||||
|
dl.replaceChildren();
|
||||||
|
row(dl, 'Name', s.dnsName || s.hostname);
|
||||||
|
if (s.ips.length) row(dl, 'Tailnet address', s.ips);
|
||||||
|
if (s.tailnet) row(dl, 'Tailnet', s.tailnet);
|
||||||
|
if (s.ips.length) row(dl, 'Reachable from tailnet', 'every open TCP port' + (s.udpPorts.length ? '; UDP ' + s.udpPorts.join(', ') + ' (Remote Play)' : '')
|
||||||
|
+ (s.allowFrom === 'own' ? '; only from your own devices' : ''));
|
||||||
|
if (keyText) row(dl, 'Key expires', keyText);
|
||||||
|
if (s.proxy) row(dl, 'HTTP proxy', s.proxy, true);
|
||||||
|
if (s.priority === 'high') row(dl, 'Priority', 'High');
|
||||||
|
}
|
||||||
|
|
||||||
const health = $('health');
|
const health = $('health');
|
||||||
health.replaceChildren(...(s.health || []).map(h => { const li = document.createElement('li'); li.textContent = h; return li; }));
|
health.replaceChildren(...(s.health || []).map(h => { const li = document.createElement('li'); li.textContent = h; return li; }));
|
||||||
@@ -477,6 +554,7 @@ async function loadSettings() {
|
|||||||
$('set-forwards').value = c.forwards.map(f => f.proto + ' ' + f.listen + ' ' + f.target).join('\n');
|
$('set-forwards').value = c.forwards.map(f => f.proto + ' ' + f.listen + ' ' + f.target).join('\n');
|
||||||
$('set-proxy').value = c.httpProxyAddr;
|
$('set-proxy').value = c.httpProxyAddr;
|
||||||
$('set-priority').value = c.priority;
|
$('set-priority').value = c.priority;
|
||||||
|
$('set-allowfrom').value = c.allowFrom;
|
||||||
$('set-webaddr').value = c.webAddr;
|
$('set-webaddr').value = c.webAddr;
|
||||||
$('set-updates').checked = c.checkUpdates;
|
$('set-updates').checked = c.checkUpdates;
|
||||||
$('set-verbose').checked = c.verbose;
|
$('set-verbose').checked = c.verbose;
|
||||||
@@ -502,6 +580,7 @@ $('settingsform').onsubmit = async ev => {
|
|||||||
udpPorts: udp,
|
udpPorts: udp,
|
||||||
blockedPorts: blocked,
|
blockedPorts: blocked,
|
||||||
priority: $('set-priority').value,
|
priority: $('set-priority').value,
|
||||||
|
allowFrom: $('set-allowfrom').value,
|
||||||
checkUpdates: $('set-updates').checked,
|
checkUpdates: $('set-updates').checked,
|
||||||
verbose: $('set-verbose').checked,
|
verbose: $('set-verbose').checked,
|
||||||
};
|
};
|
||||||
|
|||||||
+15
-1
@@ -27,7 +27,10 @@ type udpRelayConfig struct {
|
|||||||
listen func() (net.PacketConn, error)
|
listen func() (net.PacketConn, error)
|
||||||
// dial opens the connection to the target for one client.
|
// dial opens the connection to the target for one client.
|
||||||
dial func(ctx context.Context) (net.Conn, error)
|
dial func(ctx context.Context) (net.Conn, error)
|
||||||
logf func(format string, args ...any)
|
// allow, if set, is asked once per client address whether to serve it.
|
||||||
|
// Datagrams from a client it turns down are dropped.
|
||||||
|
allow func(from net.Addr) bool
|
||||||
|
logf func(format string, args ...any)
|
||||||
}
|
}
|
||||||
|
|
||||||
// udpFlow is the relay state for one client address.
|
// udpFlow is the relay state for one client address.
|
||||||
@@ -118,6 +121,17 @@ func (r *udpRelay) readLoop() {
|
|||||||
key := from.String()
|
key := from.String()
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
fl := r.flows[key]
|
fl := r.flows[key]
|
||||||
|
if fl == nil && r.cfg.allow != nil {
|
||||||
|
// Ask without holding the lock; the answer may take a moment.
|
||||||
|
r.mu.Unlock()
|
||||||
|
ok := r.cfg.allow(from)
|
||||||
|
r.mu.Lock()
|
||||||
|
if !ok {
|
||||||
|
r.mu.Unlock()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fl = r.flows[key]
|
||||||
|
}
|
||||||
if fl == nil {
|
if fl == nil {
|
||||||
fl = &udpFlow{out: make(chan []byte, 256)}
|
fl = &udpFlow{out: make(chan []byte, 256)}
|
||||||
r.flows[key] = fl
|
r.flows[key] = fl
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -122,6 +124,20 @@ func (d *daemon) watchForUpdates(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
if rel.Version != known && newerVersion(version, rel.Version) {
|
if rel.Version != known && newerVersion(version, rel.Version) {
|
||||||
d.logf("a newer release is available: %s (running %s)", rel.Version, version)
|
d.logf("a newer release is available: %s (running %s)", rel.Version, version)
|
||||||
|
d.notifyUpdate(rel.Version)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// updateNotifiedFile remembers the release the user has been told about on
|
||||||
|
// screen, so that each release is announced once and not after every start.
|
||||||
|
const updateNotifiedFile = "update-notified"
|
||||||
|
|
||||||
|
func (d *daemon) notifyUpdate(latest string) {
|
||||||
|
path := filepath.Join(dataDir, updateNotifiedFile)
|
||||||
|
if b, err := os.ReadFile(path); err == nil && strings.TrimSpace(string(b)) == latest {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
notify("Tailscale for PS5 %s is available (this is %s).\nSee %s", latest, version, d.webURL())
|
||||||
|
os.WriteFile(path, []byte(latest+"\n"), 0o644)
|
||||||
|
}
|
||||||
@@ -59,6 +59,10 @@ type statusInfo struct {
|
|||||||
Priority string `json:"priority"`
|
Priority string `json:"priority"`
|
||||||
// PasswordSet says whether the page is password protected.
|
// PasswordSet says whether the page is password protected.
|
||||||
PasswordSet bool `json:"passwordSet"`
|
PasswordSet bool `json:"passwordSet"`
|
||||||
|
// AllowFrom is "all" or "own": which tailnet devices may connect.
|
||||||
|
AllowFrom string `json:"allowFrom"`
|
||||||
|
// KeyExpiry is when this console's Tailscale key expires, if it does.
|
||||||
|
KeyExpiry *time.Time `json:"keyExpiry,omitempty"`
|
||||||
// LatestVersion and UpdateURL are set when a newer release exists.
|
// LatestVersion and UpdateURL are set when a newer release exists.
|
||||||
LatestVersion string `json:"latestVersion,omitempty"`
|
LatestVersion string `json:"latestVersion,omitempty"`
|
||||||
UpdateURL string `json:"updateURL,omitempty"`
|
UpdateURL string `json:"updateURL,omitempty"`
|
||||||
@@ -176,6 +180,7 @@ func (d *daemon) handleStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
Hostname: d.cfg.Hostname,
|
Hostname: d.cfg.Hostname,
|
||||||
Proxy: d.cfg.HTTPProxyAddr,
|
Proxy: d.cfg.HTTPProxyAddr,
|
||||||
Priority: priorityLow,
|
Priority: priorityLow,
|
||||||
|
AllowFrom: accessAll,
|
||||||
PasswordSet: d.cfg.PasswordHash != "",
|
PasswordSet: d.cfg.PasswordHash != "",
|
||||||
Uptime: int64(time.Since(d.started).Seconds()),
|
Uptime: int64(time.Since(d.started).Seconds()),
|
||||||
IPs: []string{},
|
IPs: []string{},
|
||||||
@@ -185,6 +190,9 @@ func (d *daemon) handleStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
if d.cfg.Priority == priorityHigh {
|
if d.cfg.Priority == priorityHigh {
|
||||||
info.Priority = priorityHigh
|
info.Priority = priorityHigh
|
||||||
}
|
}
|
||||||
|
if d.cfg.AllowFrom == accessOwn {
|
||||||
|
info.AllowFrom = accessOwn
|
||||||
|
}
|
||||||
for _, h := range d.cfg.SunshineHosts {
|
for _, h := range d.cfg.SunshineHosts {
|
||||||
info.SunshineHosts = append(info.SunshineHosts, sunshineInfo{Host: h.Host, Port: h.basePort(), Address: h.clientAddress()})
|
info.SunshineHosts = append(info.SunshineHosts, sunshineInfo{Host: h.Host, Port: h.basePort(), Address: h.clientAddress()})
|
||||||
}
|
}
|
||||||
@@ -219,6 +227,7 @@ func (d *daemon) handleStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
for _, ip := range st.Self.TailscaleIPs {
|
for _, ip := range st.Self.TailscaleIPs {
|
||||||
info.IPs = append(info.IPs, ip.String())
|
info.IPs = append(info.IPs, ip.String())
|
||||||
}
|
}
|
||||||
|
info.KeyExpiry = st.Self.KeyExpiry
|
||||||
}
|
}
|
||||||
info.Peers, info.VPNServers = peersFromStatus(st, r.URL.Query().Get("vpn") == "1")
|
info.Peers, info.VPNServers = peersFromStatus(st, r.URL.Query().Get("vpn") == "1")
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user