mirror of
https://github.com/holdmysocks/ps5-tailscale.git
synced 2026-10-06 13:00:17 +02:00
Add a password, settings page and several streaming hosts
The status page can now be protected with a password and edits the settings itself, so the config file no longer has to be changed by hand. - Password for everything on the status page that shows or changes something. The console's own browser is exempt. Connections to the page from the tailnet are served directly so they are not taken for local. - Settings form: name, ports, forwards, proxy, priority, update checks. Most take effect at once; the page says which need a restart. - Game streaming: several Sunshine hosts, each with its own port. - The HTTP proxy is off by default. - The page says when a newer release exists. - Uninstall removes the home screen icon. - Priority setting for streams that stutter under a demanding game. - After the console's network is reconfigured, Tailscale is asked to rebind. Seen working across a short stay in rest mode. - Favicon, and the device list can be collapsed.
This commit is contained in:
1 parent
0d7d8ad4b0
commit
ed67b35b52
25 files changed
+2007
-264
No files matched your search
@@ -83,9 +83,10 @@ want, for example FTP on 2121 or the payload loader on 9021.
|
||||
|
||||
- Every TCP port that something on the console listens on is forwarded.
|
||||
Ports with no listener refuse the connection.
|
||||
- UDP ports have to be listed, in `udpPorts` in the
|
||||
[configuration](#configuration). The default list is Remote Play's.
|
||||
- To keep a TCP port off the tailnet, add it to `blockedPorts`.
|
||||
- UDP ports have to be listed, under **Settings** on the status page. The
|
||||
default list is Remote Play's.
|
||||
- To keep a TCP port off the tailnet, list it under "TCP ports never
|
||||
exposed" in the settings.
|
||||
|
||||
### Remote Play
|
||||
|
||||
@@ -108,20 +109,32 @@ ports on the console's tailnet addresses and relays them to the service.
|
||||
|
||||
Notes:
|
||||
|
||||
- The video passes through the daemon, which runs at the lowest priority so
|
||||
that it never takes time from a game. Under a demanding game that may show
|
||||
as stutter.
|
||||
- Waking the console from rest mode does not work: nothing is running then.
|
||||
- The video passes through the daemon, which by default runs at the lowest
|
||||
priority so that it never takes time from a game. If the stream stutters
|
||||
under a demanding game, set **Priority** to High in the settings and start
|
||||
Tailscale again.
|
||||
- Waking the console from rest mode does not work: nothing runs while it
|
||||
sleeps, so it is not on the tailnet then. Tailscale carries on by itself
|
||||
once the console is awake again.
|
||||
|
||||
### The status page
|
||||
|
||||
`http://<console address>:8090`, on the LAN or over the tailnet, or the
|
||||
**Tailscale** icon on the home screen. It shows the connection state, the
|
||||
login link, and your devices, and has controls for game streaming, logging
|
||||
out, stopping and uninstalling.
|
||||
login link and your devices, and has the game streaming hosts, the settings,
|
||||
and buttons for logging out, stopping and uninstalling. It also says when a
|
||||
newer release is available.
|
||||
|
||||
It has **no password**, like the console's other homebrew services. Anyone on
|
||||
your LAN, or on your tailnet if your ACLs allow it, can use it.
|
||||
**Password.** Out of the box the page has no password, like the console's
|
||||
other homebrew services: anyone on your LAN, or on your tailnet if your ACLs
|
||||
allow it, can use it. Set one under **Settings**. It is then asked for on
|
||||
every device except the console itself. If you forget it, delete the
|
||||
`passwordHash` line from `/data/tailscale/config.json`.
|
||||
|
||||
**Settings.** The name on the tailnet, the password, which UDP ports are
|
||||
reachable and which TCP ports are not, extra forwards, the HTTP proxy, the
|
||||
priority, and update checks. Most take effect when saved; the page says which
|
||||
ones need Tailscale to be started again.
|
||||
|
||||
### Game streaming (Moonlight to Sunshine)
|
||||
|
||||
@@ -130,29 +143,35 @@ else, over Tailscale.
|
||||
|
||||
On the PC:
|
||||
|
||||
1. Install [Sunshine](https://github.com/LizardByte/Sunshine) and leave it on
|
||||
its default port (47989).
|
||||
1. Install [Sunshine](https://github.com/LizardByte/Sunshine).
|
||||
2. Install Tailscale and log in to the same tailnet as the console.
|
||||
|
||||
On the console:
|
||||
|
||||
1. Open the status page and find **Game streaming**.
|
||||
2. Choose the device that runs Sunshine and press **Save**. The page shows
|
||||
"Forwarding 127.0.0.1 to *your-pc* (7 ports)".
|
||||
2. Press **Add a host**, enter the device that runs Sunshine and press
|
||||
**Save**. The page then shows what to enter in Moonlight.
|
||||
3. In your Moonlight client on the PS5, add a host manually with the address
|
||||
**`127.0.0.1`**. Do not enter the PC's tailnet address: the client cannot
|
||||
reach it.
|
||||
4. Pair as usual: the client shows a PIN, which you enter in Sunshine's web
|
||||
interface on the PC.
|
||||
|
||||
How it works: the daemon listens on `127.0.0.1` on Sunshine's ports (TCP
|
||||
47984, 47989, 48010 and UDP 47998, 47999, 48000, 48002) and relays them to
|
||||
the chosen host through Tailscale. To the Moonlight client the Sunshine host
|
||||
appears to be the console itself.
|
||||
How it works: the daemon listens on `127.0.0.1` on Sunshine's ports (by
|
||||
default TCP 47984, 47989, 48010 and UDP 47998, 47999, 48000, 48002) and
|
||||
relays them to the host through Tailscale. To the Moonlight client the
|
||||
Sunshine host appears to be the console itself.
|
||||
|
||||
More than one host, or a host that does not use the default port:
|
||||
|
||||
- If a host's Sunshine is set to another port (Sunshine's "Port" setting),
|
||||
enter that port next to the host. In Moonlight, add `127.0.0.1:<port>`.
|
||||
- Several hosts can be forwarded at once, but they all appear on
|
||||
`127.0.0.1`, so each needs its own port: give every host a different port
|
||||
in Sunshine, at least 30 apart (for example 47989 and 48989).
|
||||
|
||||
Notes:
|
||||
|
||||
- One Sunshine host at a time. Change it on the status page at any time.
|
||||
- A wired connection on the console helps, as with any streaming.
|
||||
- **Do not change the console's network (Wi-Fi to Ethernet, connection
|
||||
settings) while a stream is running.** That froze the test console once;
|
||||
@@ -161,33 +180,43 @@ Notes:
|
||||
|
||||
### Other apps on the console
|
||||
|
||||
`forwards` in the [configuration](#configuration) relays any localhost port
|
||||
to a tailnet host in the same way, TCP or UDP.
|
||||
"Extra forwards" in the settings relay any localhost port to a tailnet host
|
||||
in the same way, TCP or UDP. One per line, for example
|
||||
`tcp 127.0.0.1:8096 my-nas:8096`.
|
||||
|
||||
The daemon also runs an HTTP proxy on `127.0.0.1:8118` that reaches tailnet
|
||||
hosts, for apps that have their own proxy setting. **Do not set it as the
|
||||
PS5's system proxy.** The system then sends everything through it, including
|
||||
pages on `127.0.0.1`, and it is not running until Tailscale has been loaded.
|
||||
On the test console that stopped another homebrew tool's page from opening.
|
||||
The daemon can also run an HTTP proxy that reaches tailnet hosts, for apps
|
||||
that have their own proxy setting. It is off unless you give it an address in
|
||||
the settings (for example `127.0.0.1:8118`). **Do not set it as the PS5's
|
||||
system proxy.** The system then sends everything through it, including pages
|
||||
on `127.0.0.1`, and it is not running until Tailscale has been loaded. On the
|
||||
test console that stopped another homebrew tool's page from opening.
|
||||
|
||||
## Configuration
|
||||
|
||||
`/data/tailscale/config.json` is created on first start. Every field is
|
||||
optional. Restart Tailscale (send the payload again) to apply edits.
|
||||
Use **Settings** on the status page. The settings are stored in
|
||||
`/data/tailscale/config.json`, which can also be edited by hand; start
|
||||
Tailscale again (send the payload) to apply hand edits. Every field is
|
||||
optional.
|
||||
|
||||
```json
|
||||
{
|
||||
"hostname": "ps5",
|
||||
"authKey": "",
|
||||
"webAddr": ":8090",
|
||||
"httpProxyAddr": "127.0.0.1:8118",
|
||||
"passwordHash": "",
|
||||
"httpProxyAddr": "",
|
||||
"controlURL": "",
|
||||
"sunshineHost": "",
|
||||
"sunshineHosts": [
|
||||
{"host": "gaming-pc"},
|
||||
{"host": "office-pc", "port": 48989}
|
||||
],
|
||||
"forwards": [
|
||||
{"proto": "tcp", "listen": "127.0.0.1:8096", "target": "my-nas:8096"}
|
||||
],
|
||||
"udpPorts": [9295, 9296, 9297, 9302],
|
||||
"blockedPorts": [],
|
||||
"priority": "",
|
||||
"checkUpdates": true,
|
||||
"verbose": false
|
||||
}
|
||||
```
|
||||
@@ -195,14 +224,17 @@ optional. Restart Tailscale (send the payload again) to apply edits.
|
||||
| Field | Meaning |
|
||||
| --- | --- |
|
||||
| `hostname` | The console's name on the tailnet. |
|
||||
| `authKey` | A Tailscale auth key, to log in without the browser step. |
|
||||
| `authKey` | A Tailscale auth key, to log in without the browser step. File only. |
|
||||
| `webAddr` | Where the status page listens. |
|
||||
| `httpProxyAddr` | Where the HTTP proxy listens. Empty turns it off. |
|
||||
| `controlURL` | A coordination server other than Tailscale's. |
|
||||
| `sunshineHost` | The Sunshine host; set from the status page. |
|
||||
| `passwordHash` | The status page's password, hashed. Set it on the status page; delete the field to remove a forgotten password. |
|
||||
| `httpProxyAddr` | Where the HTTP proxy listens. Empty, the default, is off. |
|
||||
| `controlURL` | A coordination server other than Tailscale's. File only. |
|
||||
| `sunshineHosts` | The Sunshine hosts and, where it is not 47989, their port. |
|
||||
| `forwards` | Extra local forwards: `proto` is `tcp` or `udp`, `listen` a localhost address, `target` a tailnet host and port. |
|
||||
| `udpPorts` | The console's UDP ports reachable from the tailnet. Default `[9295, 9296, 9297, 9302]` (Remote Play). `[]` turns inbound UDP off. |
|
||||
| `blockedPorts` | Local TCP ports that are never exposed to the tailnet. |
|
||||
| `priority` | `"high"` lets the daemon compete with games for CPU time; anything else is the default, low. Applied when Tailscale starts. |
|
||||
| `checkUpdates` | Ask GitHub twice a day whether a newer release exists, to show it on the status page. Nothing is downloaded. |
|
||||
| `verbose` | Put Tailscale's own log in the main log as well. |
|
||||
|
||||
Files on the console:
|
||||
@@ -214,17 +246,17 @@ Files on the console:
|
||||
| `/data/tailscale/tailscale.log` | The daemon's log, rotated at 2 MB. |
|
||||
| `/data/tailscale/tailscale-debug.log` | Tailscale's detailed log, up to 4 MB plus one older file. |
|
||||
| `/data/tailscale/icon-installed` | Marks that the home screen icon was added. Delete it to have the icon added again on the next start. |
|
||||
| `/data/tailscale/icon-helper.elf` | The small payload that adds and removes the icon. |
|
||||
| `/user/app/TSCL00001/` | The home screen icon. |
|
||||
|
||||
## Uninstall
|
||||
|
||||
Press **Uninstall** on the status page. It logs the console out of your
|
||||
tailnet, deletes `/data/tailscale` (login, settings, logs) and stops
|
||||
Tailscale.
|
||||
Press **Uninstall** on the status page. It removes the home screen icon, logs
|
||||
the console out of your tailnet, deletes `/data/tailscale` (login, settings,
|
||||
logs) and stops Tailscale.
|
||||
|
||||
Left to do by hand:
|
||||
|
||||
- Delete the home screen icon (Options button, then Delete).
|
||||
- Remove the device in the Tailscale admin console.
|
||||
- If you added `tailscale.elf` to a payload manager or autoloader, remove it
|
||||
there, or it starts again on the next boot.
|
||||
@@ -234,41 +266,58 @@ Left to do by hand:
|
||||
- **The status page does not open on the console, but does from a PC.**
|
||||
Check that the PS5's proxy server setting is "Do Not Use".
|
||||
- **The Moonlight client cannot find the host.** The host to add is
|
||||
`127.0.0.1`, and a Sunshine host must be selected on the status page.
|
||||
Sunshine must be on its default port.
|
||||
`127.0.0.1` (or `127.0.0.1:<port>` for a host on another port), and the
|
||||
Sunshine host must be listed on the status page with the port its Sunshine
|
||||
uses.
|
||||
- **"Not logged in" after logging in.** Press **Log in again** for a fresh
|
||||
link.
|
||||
- **Forgot the status page password.** Delete the `passwordHash` line from
|
||||
`/data/tailscale/config.json` and start Tailscale again, or use the page on
|
||||
the console itself, where no password is asked.
|
||||
- **Something else.** `http://<console>:8090/api/logs?full=1` is the daemon's
|
||||
log and `/api/logs?debug=1` is Tailscale's detailed log. Please attach them
|
||||
to bug reports, after checking them for anything you consider private.
|
||||
|
||||
## Security
|
||||
|
||||
- The status page and its controls are unauthenticated.
|
||||
- The status page and its controls have no password until you set one. With
|
||||
a password, only the console itself gets in without it. The page is served
|
||||
over plain HTTP: on the LAN the password travels unencrypted, over the
|
||||
tailnet Tailscale encrypts it.
|
||||
- All listening TCP ports on the console, and the UDP ports in `udpPorts`,
|
||||
become reachable from your tailnet. That includes the payload loader, which
|
||||
runs anything sent to it. Use Tailscale ACLs if other people share your
|
||||
tailnet.
|
||||
- The local forwards and the proxy listen on `127.0.0.1` only and are not
|
||||
exposed to the tailnet.
|
||||
tailnet, or list ports under "TCP ports never exposed".
|
||||
- The local forwards and the proxy are for the console's own apps and are
|
||||
not exposed to the tailnet.
|
||||
- With update checks on, the console contacts `api.github.com` twice a day.
|
||||
|
||||
## Resource use
|
||||
|
||||
About 60 MB of memory and next to no CPU when idle. The daemon runs at the
|
||||
lowest scheduling priority on at most 4 cores, so it gives way to games.
|
||||
About 60 MB of memory and next to no CPU when idle. By default the daemon
|
||||
runs at the lowest scheduling priority on at most 4 cores, so it gives way to
|
||||
games. With the priority set to High it shares those cores with games on
|
||||
equal terms.
|
||||
|
||||
## What has and has not been tested
|
||||
|
||||
Tested on the one console: install and upgrade, login with device approval,
|
||||
Tested on the one console: first run and upgrade, login with device approval,
|
||||
starting again after a reboot with the saved login, reaching the console over
|
||||
the tailnet, a ProsperoLight stream from a Sunshine host through the forward,
|
||||
the HTTP proxy, the home screen icon.
|
||||
two forwarded hosts on different ports (with a stand-in for the second), the
|
||||
HTTP proxy, adding and removing the home screen icon, the password from the
|
||||
LAN and the tailnet, changing settings from the page, both priority settings,
|
||||
the update check, a short stay in rest mode (about a minute: the same process
|
||||
carried on and was back on the tailnet within a second of waking).
|
||||
|
||||
Remote Play through the tailnet address works with Chiaki and with Asobi on
|
||||
iOS and Android.
|
||||
|
||||
Not tested: rest mode, Uninstall on a console, other firmware versions,
|
||||
coordination servers other than Tailscale's.
|
||||
Not tested: hours in rest mode, switching between Wi-Fi and Ethernet while
|
||||
running, the complete Uninstall
|
||||
on a console (its parts were tested separately), whether High priority
|
||||
improves Remote Play, a real Sunshine host on a non-default port, other
|
||||
firmware versions, coordination servers other than Tailscale's.
|
||||
|
||||
## Building
|
||||
|
||||
|
||||
+40
-1
@@ -6,7 +6,11 @@
|
||||
* ELF loader the first time Tailscale runs. It is a separate payload so that
|
||||
* the system libraries it needs are never loaded into the daemon's process.
|
||||
*
|
||||
* Prints "icon: ok" on success; the launcher looks for that.
|
||||
* The same payload removes the icon again when its mode byte says so (see
|
||||
* icon_mode below); the daemon uses that for Uninstall.
|
||||
*
|
||||
* Prints "icon: ok" or "icon: removed" on success; the launcher and the
|
||||
* daemon look for that.
|
||||
*
|
||||
* Build with -DASSET_DIR="path/to/appicon" and link, in this order,
|
||||
* -lSceIpmi -lSceAppInstUtil -lSceUserService -lSceSystemService (with
|
||||
@@ -47,6 +51,37 @@ INCASSET(icon_png, ASSET_DIR "/icon0.png")
|
||||
int sceAppInstUtilInitialize(void);
|
||||
int sceAppInstUtilTerminate(void);
|
||||
int sceAppInstUtilAppInstallAll(void *);
|
||||
int sceAppInstUtilAppUnInstall(const char *);
|
||||
|
||||
/* What to do. A payload sent to the ELF loader gets no arguments, so the
|
||||
* mode is a byte in the file itself: the daemon changes the character after
|
||||
* the '=' to 'R' before sending the helper when it wants the icon removed
|
||||
* (see tsd/homeicon.go). It is volatile so that the compiler reads it at run
|
||||
* time instead of baking the install branch in. */
|
||||
volatile char icon_mode[] = "TSICON-MODE=I";
|
||||
|
||||
static int
|
||||
remove_icon(void) {
|
||||
int err;
|
||||
|
||||
if ((err = sceAppInstUtilInitialize())) {
|
||||
printf("icon: sceAppInstUtilInitialize failed: 0x%08x\n", err);
|
||||
return 1;
|
||||
}
|
||||
err = sceAppInstUtilAppUnInstall(TITLE_ID);
|
||||
sceAppInstUtilTerminate();
|
||||
/* Whatever the system left behind of the folder goes too. */
|
||||
unlink(APP_DIR "/sce_sys/param.json");
|
||||
unlink(APP_DIR "/sce_sys/icon0.png");
|
||||
rmdir(APP_DIR "/sce_sys");
|
||||
rmdir(APP_DIR);
|
||||
if (err) {
|
||||
printf("icon: removing the app failed: 0x%08x\n", err);
|
||||
return 1;
|
||||
}
|
||||
printf("icon: removed\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int
|
||||
write_file(const char *path, const uint8_t *data, size_t size) {
|
||||
@@ -112,6 +147,10 @@ main(void) {
|
||||
kernel_set_ucred_rgid(pid, 0);
|
||||
kernel_set_ucred_svgid(pid, 0);
|
||||
|
||||
if (icon_mode[sizeof(icon_mode) - 2] == 'R') {
|
||||
return remove_icon();
|
||||
}
|
||||
|
||||
if (file_matches(APP_DIR "/sce_sys/param.json", param_json, param_size) &&
|
||||
file_matches(APP_DIR "/sce_sys/icon0.png", icon_png, icon_size)) {
|
||||
printf("icon: ok (already installed)\n");
|
||||
|
||||
+57
-9
@@ -36,11 +36,29 @@ specification.
|
||||
minutes.
|
||||
- Outbound: local forwards (`localforward.go`) listen on localhost and relay
|
||||
TCP and UDP to a tailnet host through `tsnet.Server.Dial`. UDP is relayed
|
||||
per client address with an idle timeout. The Sunshine setting is a preset
|
||||
of seven such forwards.
|
||||
- A status page and small JSON API on port 8090, an HTTP proxy on
|
||||
`127.0.0.1:8118`, PS5 notifications by writing a request to
|
||||
per client address with an idle timeout. Each Sunshine host is a preset of
|
||||
seven such forwards on the ports that host really uses, derived from
|
||||
Sunshine's port setting (HTTPS -5, HTTP +0, RTSP +21, video +9, control
|
||||
+10, audio +11, microphone +13). The ports cannot be remapped, because the
|
||||
host tells the Moonlight client which ports to use; several hosts can only
|
||||
coexist on 127.0.0.1 if their Sunshine ports differ.
|
||||
- A status page and JSON API on port 8090 (`web.go`, `settings.go`), an
|
||||
optional HTTP proxy, PS5 notifications by writing a request to
|
||||
`/dev/notification0`.
|
||||
- Password (`auth.go`): PBKDF2-SHA256 hash in the config, session cookie,
|
||||
one attempt per second. Requests from loopback are exempt. For that to be
|
||||
safe, connections for the status page that arrive over the tailnet are not
|
||||
piped to localhost like other ports but handed to the page's HTTP server
|
||||
directly, so it sees the tailnet address.
|
||||
- Settings are applied live where possible. The launcher needs one of them,
|
||||
the priority, before any Go code runs, so the daemon leaves it in
|
||||
`/data/tailscale/priority` for the next start.
|
||||
- Update notice (`update.go`): the latest release tag from the GitHub API,
|
||||
twice a day, compared with the running version.
|
||||
- When a listener reports that it had to reopen its socket (the PS5's
|
||||
network was reconfigured), the daemon asks Tailscale to rebind and re-STUN
|
||||
instead of waiting for its interface polling. See [Rest mode](#rest-mode)
|
||||
for the one time this has been seen.
|
||||
- A payload that is sent again stops the running instance (through the
|
||||
status page, or failing that by the pid it recorded) and takes over.
|
||||
|
||||
@@ -54,6 +72,12 @@ payload so that the system libraries it needs are never loaded into the
|
||||
long-running daemon process, where their threads could receive signals meant
|
||||
for the Go runtime.
|
||||
|
||||
The same helper removes the icon (`sceAppInstUtilAppUnInstall`). A payload
|
||||
sent to the ELF loader gets no arguments, so the mode is a byte in the file
|
||||
after the marker `TSICON-MODE=`. The launcher leaves a copy of the helper in
|
||||
`/data/tailscale/icon-helper.elf`; for Uninstall the daemon flips that byte
|
||||
and sends it to the loader (`tsd/homeicon.go`).
|
||||
|
||||
There is no installer. Nothing is copied anywhere and no payload autoloader
|
||||
is touched: the payload is run from wherever the user keeps it.
|
||||
|
||||
@@ -140,6 +164,32 @@ works across cores (4 spinning goroutines, 5 garbage collections in about
|
||||
350 ms). Test builds can add a watchdog thread that kills the process after
|
||||
a fixed time (`build-payload.ps1 -Watchdog`).
|
||||
|
||||
The "high" priority setting uses class 2 (round-robin) at priority 700
|
||||
instead: equal to games and system threads, but equal-priority round-robin
|
||||
threads take turns. With it, the same test passes (5 collections in about
|
||||
300 ms) and the console stays responsive: the status page answered within
|
||||
60 ms throughout while four goroutines spun.
|
||||
|
||||
## Rest mode
|
||||
|
||||
Observed once, for a rest of about a minute on Ethernet. The process is not
|
||||
killed: it is frozen with the rest of the console and continues afterwards.
|
||||
|
||||
- Going to sleep, the network is taken down first. Every socket fails with
|
||||
errno 163 at the same moment: the status page listener, Tailscale's relay
|
||||
connection and its connection to the coordination server. The listener
|
||||
reopened at once, the daemon asked for a rebind, and Tailscale saw "all
|
||||
links down" and paused.
|
||||
- Nothing is logged while the console sleeps, and it is not reachable on the
|
||||
tailnet.
|
||||
- On waking, Tailscale's monitor noticed the jump in the clock, rebound its
|
||||
sockets, reconnected to its relay and had its endpoints back within about
|
||||
300 ms. The status page, forwarded TCP ports and the Remote Play UDP ports
|
||||
answered through the tailnet address afterwards without anything being
|
||||
restarted.
|
||||
|
||||
A rest of hours has not been tried, nor one on Wi-Fi.
|
||||
|
||||
## Home screen icon
|
||||
|
||||
`/user/app/TSCL00001/sce_sys/param.json` with `applicationCategoryType` 65536
|
||||
@@ -151,9 +201,6 @@ Linking `libSceAppInstUtil` alone leaves the payload stopped before it runs.
|
||||
It needs `-lSceIpmi -lSceAppInstUtil -lSceUserService -lSceSystemService`, in
|
||||
that order, as in the SDK's `install_app` sample.
|
||||
|
||||
The daemon cannot remove the icon; that is left to the user (Options, then
|
||||
Delete, on the home screen).
|
||||
|
||||
## Known problems
|
||||
|
||||
- Once, switching the console from Wi-Fi to Ethernet during a Moonlight
|
||||
@@ -165,5 +212,6 @@ Delete, on the home screen).
|
||||
request was answered with "auth path not found". The daemon now requests a
|
||||
new link when it sees that error; the recovery path has not been observed
|
||||
in practice.
|
||||
- Whether Tailscale's own UDP sockets recover after a network
|
||||
reconfiguration has not been examined.
|
||||
- Tailscale's sockets recovered after rest mode took the network down and
|
||||
brought it back. A change of interface (Wi-Fi to Ethernet or back) while
|
||||
the daemon runs has not been observed since the rebind request was added.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 67 KiB |
+27
-1
@@ -27,6 +27,7 @@
|
||||
* home screen stays deleted. Remove the file to get the icon back. */
|
||||
#define ICON_MARKER DATA_DIR "/icon-installed"
|
||||
#define ICON_VERSION "1\n"
|
||||
#define ICON_HELPER_FILE DATA_DIR "/icon-helper.elf"
|
||||
#define LOADER_PORT 9021
|
||||
|
||||
extern const uint8_t icon_helper[];
|
||||
@@ -54,6 +55,30 @@ marker_is_current(void) {
|
||||
return !strcmp(buf, ICON_VERSION);
|
||||
}
|
||||
|
||||
/* Leave a copy of the helper where the daemon can find it. Uninstall runs it
|
||||
* again, switched to removing the icon. */
|
||||
static void
|
||||
save_helper(void) {
|
||||
size_t size = icon_helper_end - icon_helper;
|
||||
struct stat st;
|
||||
int fd;
|
||||
|
||||
if (!stat(ICON_HELPER_FILE, &st) && (size_t)st.st_size == size) {
|
||||
return;
|
||||
}
|
||||
if ((fd = open(ICON_HELPER_FILE, O_WRONLY | O_CREAT | O_TRUNC, 0644)) < 0) {
|
||||
return;
|
||||
}
|
||||
for (size_t done = 0; done < size;) {
|
||||
ssize_t n = write(fd, icon_helper + done, size - done);
|
||||
if (n <= 0) {
|
||||
break;
|
||||
}
|
||||
done += n;
|
||||
}
|
||||
close(fd);
|
||||
}
|
||||
|
||||
void
|
||||
home_icon_install_once(void) {
|
||||
struct sockaddr_in addr = {0};
|
||||
@@ -64,10 +89,11 @@ home_icon_install_once(void) {
|
||||
size_t got = 0;
|
||||
int fd;
|
||||
|
||||
mkdir(DATA_DIR, 0755);
|
||||
save_helper();
|
||||
if (marker_is_current()) {
|
||||
return;
|
||||
}
|
||||
mkdir(DATA_DIR, 0755);
|
||||
|
||||
if ((fd = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
|
||||
return;
|
||||
|
||||
+25
-6
@@ -3,6 +3,7 @@
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <sys/mman.h>
|
||||
@@ -63,30 +64,48 @@ raw_syscall3(long n, long a, long b, long c) {
|
||||
* priority, where nothing this process does can keep the system's own threads
|
||||
* off the CPU. Threads created later inherit the setting. The kernel ignores
|
||||
* priorities outside its own range without reporting an error, so the result
|
||||
* is read back. Round-robin at the lowest priority is the fallback. */
|
||||
* is read back. Round-robin at the lowest priority is the fallback.
|
||||
*
|
||||
* With the "high" priority setting the process instead becomes round-robin
|
||||
* at the default priority: it then competes with games on equal terms, but
|
||||
* equal-priority round-robin threads take turns, so even then a thread that
|
||||
* never blocks cannot shut the others out. */
|
||||
static int
|
||||
high_priority_requested(void) {
|
||||
char buf[16] = {0};
|
||||
FILE *f = fopen("/data/tailscale/priority", "r");
|
||||
|
||||
if (!f) {
|
||||
return 0;
|
||||
}
|
||||
fgets(buf, sizeof(buf), f);
|
||||
fclose(f);
|
||||
return !strncmp(buf, "high", 4);
|
||||
}
|
||||
|
||||
static int
|
||||
leave_realtime_class(void) {
|
||||
static const struct rtprio choices[] = {
|
||||
{RTP_PRIO_REALTIME, PS5_PRIO_DEFAULT}, /* only with the "high" setting */
|
||||
{RTP_PRIO_NORMAL, PS5_PRIO_LOWEST},
|
||||
{RTP_PRIO_REALTIME, PS5_PRIO_LOWEST},
|
||||
};
|
||||
struct rtprio before = {0}, after = {0};
|
||||
int ok = 0;
|
||||
|
||||
raw_syscall3(SYS_rtprio_thread, RTP_LOOKUP, 0, (long)&before);
|
||||
for (size_t i = 0; i < sizeof(choices) / sizeof(choices[0]); i++) {
|
||||
for (size_t i = high_priority_requested() ? 0 : 1; i < sizeof(choices) / sizeof(choices[0]) && !ok; i++) {
|
||||
struct rtprio want = choices[i];
|
||||
raw_syscall3(SYS_rtprio_thread, RTP_SET, 0, (long)&want);
|
||||
raw_syscall3(SYS_rtprio_thread, RTP_LOOKUP, 0, (long)&after);
|
||||
if (after.type == choices[i].type && after.prio == choices[i].prio) {
|
||||
break;
|
||||
}
|
||||
ok = after.type == choices[i].type && after.prio == choices[i].prio;
|
||||
}
|
||||
#ifdef GOLOAD_DEBUG
|
||||
fprintf(stderr, "launcher: scheduling class %u/%u -> %u/%u\n", before.type, before.prio, after.type, after.prio);
|
||||
#else
|
||||
(void)before;
|
||||
#endif
|
||||
return after.prio > PS5_PRIO_DEFAULT && after.type != before.type ? 0 : -1;
|
||||
return ok ? 0 : -1;
|
||||
}
|
||||
|
||||
#ifdef GOLOAD_WATCHDOG
|
||||
|
||||
+11
-6
@@ -14,6 +14,11 @@
|
||||
#ifndef HTTP_PATH
|
||||
#define HTTP_PATH "/hello.txt"
|
||||
#endif
|
||||
/* Sunshine's "port" setting on the host under test; its HTTP port is this
|
||||
* and its video (UDP) port is this plus 9. */
|
||||
#ifndef BASE_PORT
|
||||
#define BASE_PORT 47989
|
||||
#endif
|
||||
|
||||
static struct sockaddr_in
|
||||
local(int port) {
|
||||
@@ -42,12 +47,12 @@ main(void) {
|
||||
setvbuf(stdout, 0, _IONBF, 0);
|
||||
|
||||
/* TCP */
|
||||
addr = local(47989);
|
||||
addr = local(BASE_PORT);
|
||||
fd = socket(AF_INET, SOCK_STREAM, 0);
|
||||
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
|
||||
double t0 = now();
|
||||
if (connect(fd, (struct sockaddr *)&addr, sizeof(addr))) {
|
||||
printf("tcp 127.0.0.1:47989: cannot connect (no forward listening)\n");
|
||||
printf("tcp 127.0.0.1:%d: cannot connect (no forward listening)\n", BASE_PORT);
|
||||
} else {
|
||||
const char *req = "GET " HTTP_PATH " HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n";
|
||||
size_t total = 0;
|
||||
@@ -59,9 +64,9 @@ main(void) {
|
||||
char *body = strstr(buf, "\r\n\r\n");
|
||||
char *eol = strstr(buf, "\r\n");
|
||||
if (!total) {
|
||||
printf("tcp 127.0.0.1:47989: connected but no response\n");
|
||||
printf("tcp 127.0.0.1:%d: connected but no response\n", BASE_PORT);
|
||||
} else {
|
||||
printf("tcp 127.0.0.1:47989: %.*s (%.0f ms)\n", eol ? (int)(eol - buf) : 60, buf, (now() - t0) * 1000);
|
||||
printf("tcp 127.0.0.1:%d: %.*s (%.0f ms)\n", BASE_PORT, eol ? (int)(eol - buf) : 60, buf, (now() - t0) * 1000);
|
||||
if (body) {
|
||||
printf(" body: %.400s\n", body + 4);
|
||||
}
|
||||
@@ -71,7 +76,7 @@ main(void) {
|
||||
|
||||
#ifndef SKIP_UDP
|
||||
/* UDP */
|
||||
addr = local(47998);
|
||||
addr = local(BASE_PORT + 9);
|
||||
fd = socket(AF_INET, SOCK_DGRAM, 0);
|
||||
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
|
||||
int ok = 0;
|
||||
@@ -90,7 +95,7 @@ main(void) {
|
||||
}
|
||||
}
|
||||
}
|
||||
printf("udp 127.0.0.1:47998: %d/20 datagrams of 1300 bytes echoed, worst round trip %.1f ms\n", ok, worst);
|
||||
printf("udp 127.0.0.1:%d: %d/20 datagrams of 1300 bytes echoed, worst round trip %.1f ms\n", BASE_PORT + 9, ok, worst);
|
||||
close(fd);
|
||||
#endif
|
||||
return 0;
|
||||
|
||||
+4
-3
@@ -48,9 +48,10 @@ main(void) {
|
||||
int stray = ki->ki_pid >= MIN_PID && ki->ki_pid != self && !strcmp(tdname, "payload.elf");
|
||||
if (ki->ki_pid >= MIN_PID - 40 || stray) {
|
||||
#endif
|
||||
printf("%6d %-20s %-20s rss=%ldMB threads=%d cpu=%.2fs stat=%d wait=%.8s%s\n", ki->ki_pid, ki->ki_comm,
|
||||
tdname, (long)(ki->ki_rssize * 16384L >> 20), ki->ki_numthreads, ki->ki_runtime / 1e6, (int)ki->ki_stat,
|
||||
ki->ki_wmesg, stray ? " <- killing" : "");
|
||||
printf("%6d %-20s %-20s rss=%ldMB threads=%d cpu=%.2fs stat=%d wait=%.8s sched=%d/%d%s\n", ki->ki_pid,
|
||||
ki->ki_comm, tdname, (long)(ki->ki_rssize * 16384L >> 20), ki->ki_numthreads, ki->ki_runtime / 1e6,
|
||||
(int)ki->ki_stat, ki->ki_wmesg, (int)ki->ki_pri.pri_class, (int)ki->ki_pri.pri_user,
|
||||
stray ? " <- killing" : "");
|
||||
}
|
||||
if (stray) {
|
||||
if (kill(ki->ki_pid, SIGKILL)) {
|
||||
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/pbkdf2"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The status page can be given a password. Without one it trusts whoever can
|
||||
// reach it, like the other services on a jailbroken console. With one, the
|
||||
// page and its API ask for it, except from the console itself: someone at
|
||||
// the console can do anything anyway, and typing a password with a
|
||||
// controller is no fun.
|
||||
//
|
||||
// A browser that has entered the password gets a session cookie.
|
||||
|
||||
const (
|
||||
sessionCookie = "ps5ts_session"
|
||||
sessionLifetime = 30 * 24 * time.Hour
|
||||
pbkdf2Rounds = 210_000
|
||||
)
|
||||
|
||||
// hashPassword returns the stored form of a password:
|
||||
// "pbkdf2-sha256$<rounds>$<salt hex>$<key hex>".
|
||||
func hashPassword(password string) (string, error) {
|
||||
salt := make([]byte, 16)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
key, err := pbkdf2.Key(sha256.New, password, salt, pbkdf2Rounds, 32)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "pbkdf2-sha256$" + strconv.Itoa(pbkdf2Rounds) + "$" + hex.EncodeToString(salt) + "$" + hex.EncodeToString(key), nil
|
||||
}
|
||||
|
||||
// checkPassword reports whether password matches a stored hash.
|
||||
func checkPassword(stored, password string) bool {
|
||||
parts := strings.Split(stored, "$")
|
||||
if len(parts) != 4 || parts[0] != "pbkdf2-sha256" {
|
||||
return false
|
||||
}
|
||||
rounds, err := strconv.Atoi(parts[1])
|
||||
if err != nil || rounds < 1 || rounds > 10_000_000 {
|
||||
return false
|
||||
}
|
||||
salt, err1 := hex.DecodeString(parts[2])
|
||||
want, err2 := hex.DecodeString(parts[3])
|
||||
if err1 != nil || err2 != nil || len(want) == 0 {
|
||||
return false
|
||||
}
|
||||
got, err := pbkdf2.Key(sha256.New, password, salt, rounds, len(want))
|
||||
return err == nil && subtle.ConstantTimeCompare(got, want) == 1
|
||||
}
|
||||
|
||||
// sessions are the browsers that have entered the password.
|
||||
type sessions struct {
|
||||
mu sync.Mutex
|
||||
tokens map[string]time.Time // token -> expiry
|
||||
attempt sync.Mutex // serializes password attempts
|
||||
}
|
||||
|
||||
func (s *sessions) create() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
token := hex.EncodeToString(b)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.tokens == nil {
|
||||
s.tokens = map[string]time.Time{}
|
||||
}
|
||||
now := time.Now()
|
||||
for t, exp := range s.tokens {
|
||||
if now.After(exp) {
|
||||
delete(s.tokens, t)
|
||||
}
|
||||
}
|
||||
s.tokens[token] = now.Add(sessionLifetime)
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (s *sessions) valid(token string) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
exp, ok := s.tokens[token]
|
||||
return ok && time.Now().Before(exp)
|
||||
}
|
||||
|
||||
func (s *sessions) remove(token string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
delete(s.tokens, token)
|
||||
}
|
||||
|
||||
// clear ends every session, for when the password changes.
|
||||
func (s *sessions) clear() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.tokens = nil
|
||||
}
|
||||
|
||||
// fromConsole reports whether the request was made on the console itself.
|
||||
// Connections from the tailnet are served directly (see tailnetListener), so
|
||||
// they arrive with their tailnet address, not as loopback.
|
||||
func fromConsole(r *http.Request) bool {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
ip, err := netip.ParseAddr(host)
|
||||
return err == nil && ip.Unmap().IsLoopback()
|
||||
}
|
||||
|
||||
// authorized reports whether the request may use the page: there is no
|
||||
// password, it comes from the console itself, or it carries a session.
|
||||
func (d *daemon) authorized(r *http.Request) bool {
|
||||
d.mu.Lock()
|
||||
hash := d.cfg.PasswordHash
|
||||
d.mu.Unlock()
|
||||
if hash == "" || fromConsole(r) {
|
||||
return true
|
||||
}
|
||||
c, err := r.Cookie(sessionCookie)
|
||||
return err == nil && d.sessions.valid(c.Value)
|
||||
}
|
||||
|
||||
// protect wraps a handler that needs the password, if one is set.
|
||||
func (d *daemon) protect(h http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !d.authorized(r) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
json.NewEncoder(w).Encode(map[string]any{"locked": true, "version": version})
|
||||
return
|
||||
}
|
||||
h(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// handleAuth checks a password and starts a session.
|
||||
func (d *daemon) handleAuth(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&req); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
d.mu.Lock()
|
||||
hash := d.cfg.PasswordHash
|
||||
d.mu.Unlock()
|
||||
|
||||
// One attempt at a time, and a wrong one costs a second: enough to make
|
||||
// guessing over the network pointless.
|
||||
d.sessions.attempt.Lock()
|
||||
ok := hash == "" || checkPassword(hash, req.Password)
|
||||
if !ok {
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
d.sessions.attempt.Unlock()
|
||||
if !ok {
|
||||
d.logf("status page: wrong password from %s", r.RemoteAddr)
|
||||
http.Error(w, "wrong password", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := d.sessions.create()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookie,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
MaxAge: int(sessionLifetime.Seconds()),
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
io.WriteString(w, "ok\n")
|
||||
}
|
||||
|
||||
// handleLock ends the browser's session.
|
||||
func (d *daemon) handleLock(w http.ResponseWriter, r *http.Request) {
|
||||
if c, err := r.Cookie(sessionCookie); err == nil {
|
||||
d.sessions.remove(c.Value)
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteStrictMode})
|
||||
io.WriteString(w, "ok\n")
|
||||
}
|
||||
|
||||
// tailnetListener hands the status page's server the connections that arrive
|
||||
// for it over the tailnet. They could be piped to the page's port on
|
||||
// localhost like any other, but then every tailnet device would look like
|
||||
// the console itself and get past the password.
|
||||
type tailnetListener struct {
|
||||
conns chan net.Conn
|
||||
closed chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func newTailnetListener() *tailnetListener {
|
||||
return &tailnetListener{conns: make(chan net.Conn, 16), closed: make(chan struct{})}
|
||||
}
|
||||
|
||||
// deliver gives a tailnet connection to the server.
|
||||
func (l *tailnetListener) deliver(c net.Conn) {
|
||||
select {
|
||||
case l.conns <- c:
|
||||
case <-l.closed:
|
||||
c.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (l *tailnetListener) Accept() (net.Conn, error) {
|
||||
select {
|
||||
case c := <-l.conns:
|
||||
return c, nil
|
||||
case <-l.closed:
|
||||
return nil, net.ErrClosed
|
||||
}
|
||||
}
|
||||
|
||||
func (l *tailnetListener) Close() error {
|
||||
l.once.Do(func() { close(l.closed) })
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *tailnetListener) Addr() net.Addr { return tailnetAddr{} }
|
||||
|
||||
type tailnetAddr struct{}
|
||||
|
||||
func (tailnetAddr) Network() string { return "tailnet" }
|
||||
func (tailnetAddr) String() string { return "tailnet" }
|
||||
+41
-11
@@ -9,6 +9,7 @@ import (
|
||||
)
|
||||
|
||||
// config is read from /data/tailscale/config.json. Every field is optional.
|
||||
// Most of it can be edited on the status page.
|
||||
type config struct {
|
||||
// Hostname is the name this console gets on the tailnet.
|
||||
Hostname string `json:"hostname"`
|
||||
@@ -16,15 +17,21 @@ type config struct {
|
||||
AuthKey string `json:"authKey,omitempty"`
|
||||
// WebAddr is where the status page listens.
|
||||
WebAddr string `json:"webAddr"`
|
||||
// HTTPProxyAddr is where the outbound HTTP proxy listens. Pointing the
|
||||
// PS5's proxy setting at it lets the console reach tailnet hosts. Empty
|
||||
// disables the proxy.
|
||||
// PasswordHash protects the status page. Empty means no password. It is
|
||||
// set from the status page; delete the field to remove a forgotten
|
||||
// password.
|
||||
PasswordHash string `json:"passwordHash,omitempty"`
|
||||
// HTTPProxyAddr is where the outbound HTTP proxy listens. Empty, the
|
||||
// default, turns the proxy off.
|
||||
HTTPProxyAddr string `json:"httpProxyAddr"`
|
||||
// ControlURL selects a coordination server other than Tailscale's.
|
||||
ControlURL string `json:"controlURL,omitempty"`
|
||||
// SunshineHost is a tailnet device running Sunshine. When set, its
|
||||
// streaming ports are forwarded from 127.0.0.1, so a Moonlight client on
|
||||
// the console can use 127.0.0.1 as the host.
|
||||
// SunshineHosts are tailnet devices running Sunshine. Their streaming
|
||||
// ports are forwarded from 127.0.0.1, so a Moonlight client on the
|
||||
// console can use 127.0.0.1 as the host.
|
||||
SunshineHosts []sunshineHost `json:"sunshineHosts,omitempty"`
|
||||
// SunshineHost is the single-host setting of earlier versions. It is
|
||||
// folded into SunshineHosts when the config is loaded.
|
||||
SunshineHost string `json:"sunshineHost,omitempty"`
|
||||
// Forwards are extra local forwards: a localhost port on the console
|
||||
// relayed to a host on the tailnet.
|
||||
@@ -35,16 +42,23 @@ type config struct {
|
||||
UDPPorts []uint16 `json:"udpPorts"`
|
||||
// BlockedPorts lists local TCP ports that are never exposed to the tailnet.
|
||||
BlockedPorts []uint16 `json:"blockedPorts,omitempty"`
|
||||
// Priority is how the daemon competes for CPU time: "low" (the default)
|
||||
// never takes time from a game, "high" shares the CPU with games on
|
||||
// equal terms, which can make Remote Play smoother. Applied at start.
|
||||
Priority string `json:"priority,omitempty"`
|
||||
// CheckUpdates makes the daemon ask GitHub now and then whether a newer
|
||||
// release exists, to say so on the status page.
|
||||
CheckUpdates bool `json:"checkUpdates"`
|
||||
// Verbose turns on Tailscale's own (very chatty) logging.
|
||||
Verbose bool `json:"verbose,omitempty"`
|
||||
}
|
||||
|
||||
func defaultConfig() config {
|
||||
return config{
|
||||
Hostname: "ps5",
|
||||
WebAddr: ":8090",
|
||||
HTTPProxyAddr: "127.0.0.1:8118",
|
||||
UDPPorts: slices.Clone(remotePlayUDPPorts),
|
||||
Hostname: "ps5",
|
||||
WebAddr: ":8090",
|
||||
UDPPorts: slices.Clone(remotePlayUDPPorts),
|
||||
CheckUpdates: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,13 +76,29 @@ func loadConfig(path string) (config, error) {
|
||||
if err := json.Unmarshal(b, &cfg); err != nil {
|
||||
return defaultConfig(), err
|
||||
}
|
||||
cfg.normalize()
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// normalize fills in what must not be empty and brings settings from earlier
|
||||
// versions into their current form.
|
||||
func (cfg *config) normalize() {
|
||||
if cfg.Hostname == "" {
|
||||
cfg.Hostname = "ps5"
|
||||
}
|
||||
if cfg.WebAddr == "" {
|
||||
cfg.WebAddr = ":8090"
|
||||
}
|
||||
return cfg, nil
|
||||
if cfg.SunshineHost != "" {
|
||||
known := slices.ContainsFunc(cfg.SunshineHosts, func(h sunshineHost) bool { return h.Host == cfg.SunshineHost })
|
||||
if !known {
|
||||
cfg.SunshineHosts = append(cfg.SunshineHosts, sunshineHost{Host: cfg.SunshineHost})
|
||||
}
|
||||
cfg.SunshineHost = ""
|
||||
}
|
||||
if cfg.Priority != priorityHigh {
|
||||
cfg.Priority = ""
|
||||
}
|
||||
}
|
||||
|
||||
func saveConfig(path string, cfg config) error {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 9.3 KiB |
+10
-6
@@ -21,7 +21,15 @@ import (
|
||||
// rather than a connection that opens and closes.
|
||||
func (d *daemon) forwardToLocalhost(src, dst netip.AddrPort) (handler func(net.Conn), intercept bool) {
|
||||
port := dst.Port()
|
||||
if slices.Contains(d.cfg.BlockedPorts, port) || port == d.proxyPort || d.fwd.listensOnTCP(port) {
|
||||
if port == d.webPort {
|
||||
// The status page is served on the tailnet connection itself rather
|
||||
// than through localhost, so that the page sees who is asking.
|
||||
return d.tailnetWeb.deliver, true
|
||||
}
|
||||
d.mu.Lock()
|
||||
blocked := slices.Contains(d.cfg.BlockedPorts, port) || port == d.proxyPort
|
||||
d.mu.Unlock()
|
||||
if blocked || d.fwd.listensOnTCP(port) {
|
||||
// The outbound proxy and the local forwards are for the console's
|
||||
// own apps. Exposing them would let any tailnet device use the
|
||||
// console as a relay.
|
||||
@@ -40,11 +48,7 @@ func (d *daemon) forwardToLocalhost(src, dst netip.AddrPort) (handler func(net.C
|
||||
if !abandoned.Stop() {
|
||||
return
|
||||
}
|
||||
if port != d.webPort {
|
||||
// The status page polls every few seconds; logging its own
|
||||
// requests would bury everything else.
|
||||
d.logf("forward %v -> localhost:%d", src, port)
|
||||
}
|
||||
d.logf("forward %v -> localhost:%d", src, port)
|
||||
pipe(c, local)
|
||||
}, true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The home screen icon is installed by a small helper payload that the
|
||||
// launcher carries (see appicon/ and launcher/homeicon.c). The launcher also
|
||||
// leaves a copy of the helper in the data directory, so that Uninstall can
|
||||
// run it again to take the icon away: the system call for that lives in
|
||||
// libraries this process must not load.
|
||||
//
|
||||
// The helper installs or removes depending on one byte in it, after a marker
|
||||
// string; removing is a matter of flipping that byte before sending it to
|
||||
// the ELF loader.
|
||||
|
||||
const (
|
||||
iconHelperFile = "icon-helper.elf"
|
||||
iconModeMarker = "TSICON-MODE="
|
||||
iconModeRemove = 'R'
|
||||
elfLoaderAddr = "127.0.0.1:9021"
|
||||
iconHelperTimeout = 20 * time.Second
|
||||
)
|
||||
|
||||
// removeHomeIcon takes the Tailscale icon off the home screen.
|
||||
func removeHomeIcon() error {
|
||||
helper, err := os.ReadFile(filepath.Join(dataDir, iconHelperFile))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
i := bytes.Index(helper, []byte(iconModeMarker))
|
||||
if i < 0 || i+len(iconModeMarker) >= len(helper) {
|
||||
return errors.New("the icon helper is not one this version understands")
|
||||
}
|
||||
helper[i+len(iconModeMarker)] = iconModeRemove
|
||||
|
||||
c, err := net.DialTimeout("tcp", elfLoaderAddr, 3*time.Second)
|
||||
if err != nil {
|
||||
return fmt.Errorf("no ELF loader to run the icon helper: %w", err)
|
||||
}
|
||||
defer c.Close()
|
||||
c.SetDeadline(time.Now().Add(iconHelperTimeout))
|
||||
if _, err := c.Write(helper); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The helper prints "icon: removed" or what went wrong, and exits.
|
||||
var reply []byte
|
||||
buf := make([]byte, 512)
|
||||
for len(reply) < 4096 {
|
||||
n, err := c.Read(buf)
|
||||
reply = append(reply, buf[:n]...)
|
||||
if line := iconReplyLine(reply); line != "" {
|
||||
if strings.HasPrefix(line, "icon: removed") {
|
||||
return nil
|
||||
}
|
||||
return errors.New(line)
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
return errors.New("no answer from the icon helper")
|
||||
}
|
||||
|
||||
// iconReplyLine returns the helper's complete "icon: ..." line, if it has
|
||||
// arrived.
|
||||
func iconReplyLine(reply []byte) string {
|
||||
i := bytes.Index(reply, []byte("icon: "))
|
||||
if i < 0 {
|
||||
return ""
|
||||
}
|
||||
rest := reply[i:]
|
||||
j := bytes.IndexByte(rest, '\n')
|
||||
if j < 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(rest[:j]))
|
||||
}
|
||||
+10
-8
@@ -35,22 +35,24 @@ type udpExposer struct {
|
||||
mu sync.Mutex
|
||||
addrs []netip.Addr
|
||||
ports []uint16
|
||||
stops []func()
|
||||
relays []*udpRelay
|
||||
active []uint16
|
||||
}
|
||||
|
||||
// update makes ports reachable on addrs, replacing whatever was exposed
|
||||
// before. It does nothing if neither has changed.
|
||||
// before. It does nothing if neither has changed and every relay is still
|
||||
// running.
|
||||
func (e *udpExposer) update(addrs []netip.Addr, ports []uint16) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if slices.Equal(addrs, e.addrs) && slices.Equal(ports, e.ports) {
|
||||
healthy := !slices.ContainsFunc(e.relays, func(r *udpRelay) bool { return !r.running() })
|
||||
if healthy && slices.Equal(addrs, e.addrs) && slices.Equal(ports, e.ports) {
|
||||
return
|
||||
}
|
||||
for _, stop := range e.stops {
|
||||
stop()
|
||||
for _, r := range e.relays {
|
||||
r.stop()
|
||||
}
|
||||
e.stops, e.active = nil, nil
|
||||
e.relays, e.active = nil, nil
|
||||
e.addrs, e.ports = slices.Clone(addrs), slices.Clone(ports)
|
||||
|
||||
for _, port := range ports {
|
||||
@@ -62,7 +64,7 @@ func (e *udpExposer) update(addrs []netip.Addr, ports []uint16) {
|
||||
network = "udp6"
|
||||
}
|
||||
listenAddr := netip.AddrPortFrom(addr, port).String()
|
||||
stop, err := startUDPRelay(udpRelayConfig{
|
||||
relay, err := startUDPRelay(udpRelayConfig{
|
||||
name: "udp " + listenAddr,
|
||||
listen: func() (net.PacketConn, error) { return e.listen(network, listenAddr) },
|
||||
dial: func(ctx context.Context) (net.Conn, error) {
|
||||
@@ -75,7 +77,7 @@ func (e *udpExposer) update(addrs []netip.Addr, ports []uint16) {
|
||||
e.logf("udp %s: %v", listenAddr, err)
|
||||
continue
|
||||
}
|
||||
e.stops = append(e.stops, stop)
|
||||
e.relays = append(e.relays, relay)
|
||||
ok = true
|
||||
}
|
||||
if ok {
|
||||
|
||||
@@ -18,6 +18,8 @@ type resilientListener struct {
|
||||
network string
|
||||
addr string
|
||||
logf func(format string, args ...any)
|
||||
// onReopen, if set, is called after the socket had to be reopened.
|
||||
onReopen func()
|
||||
|
||||
mu sync.Mutex
|
||||
ln net.Listener
|
||||
@@ -80,6 +82,9 @@ func (l *resilientListener) reopen() bool {
|
||||
l.ln = ln
|
||||
l.mu.Unlock()
|
||||
l.logf("listener %s: reopened", l.addr)
|
||||
if l.onReopen != nil {
|
||||
l.onReopen()
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
+76
-16
@@ -30,30 +30,86 @@ func (r forwardRule) String() string {
|
||||
return fmt.Sprintf("%s %s -> %s", r.Proto, r.Listen, r.Target)
|
||||
}
|
||||
|
||||
// Ports a Sunshine host uses with its default base port (47989).
|
||||
// sunshineHost is a device on the tailnet that runs Sunshine.
|
||||
type sunshineHost struct {
|
||||
Host string `json:"host"`
|
||||
// Port is Sunshine's "port" setting, which all its other ports are
|
||||
// derived from. 0 means the default, 47989.
|
||||
Port int `json:"port,omitempty"`
|
||||
}
|
||||
|
||||
const sunshineDefaultPort = 47989
|
||||
|
||||
func (h sunshineHost) basePort() int {
|
||||
if h.Port == 0 {
|
||||
return sunshineDefaultPort
|
||||
}
|
||||
return h.Port
|
||||
}
|
||||
|
||||
// Sunshine's ports as offsets from its "port" setting.
|
||||
var (
|
||||
sunshineTCPPorts = []int{47984, 47989, 48010} // HTTPS, HTTP, RTSP
|
||||
sunshineUDPPorts = []int{47998, 47999, 48000, 48002} // video, control, audio, microphone
|
||||
sunshineTCPOffsets = []int{-5, 0, 21} // HTTPS, HTTP, RTSP
|
||||
sunshineUDPOffsets = []int{9, 10, 11, 13} // video, control, audio, microphone
|
||||
)
|
||||
|
||||
// sunshineRules returns the forwards that make the Sunshine host on the
|
||||
// tailnet appear on 127.0.0.1 to a Moonlight client on the console.
|
||||
func sunshineRules(host string) []forwardRule {
|
||||
if host == "" {
|
||||
return nil
|
||||
}
|
||||
// rules returns the forwards that make this Sunshine host appear on
|
||||
// 127.0.0.1, on the same ports it really uses. The ports have to match: the
|
||||
// host tells the Moonlight client which ports to connect to.
|
||||
func (h sunshineHost) rules() []forwardRule {
|
||||
var rules []forwardRule
|
||||
for _, p := range sunshineTCPPorts {
|
||||
port := strconv.Itoa(p)
|
||||
rules = append(rules, forwardRule{"tcp", net.JoinHostPort("127.0.0.1", port), net.JoinHostPort(host, port)})
|
||||
add := func(proto string, offsets []int) {
|
||||
for _, off := range offsets {
|
||||
port := strconv.Itoa(h.basePort() + off)
|
||||
rules = append(rules, forwardRule{proto, net.JoinHostPort("127.0.0.1", port), net.JoinHostPort(h.Host, port)})
|
||||
}
|
||||
}
|
||||
for _, p := range sunshineUDPPorts {
|
||||
port := strconv.Itoa(p)
|
||||
rules = append(rules, forwardRule{"udp", net.JoinHostPort("127.0.0.1", port), net.JoinHostPort(host, port)})
|
||||
add("tcp", sunshineTCPOffsets)
|
||||
add("udp", sunshineUDPOffsets)
|
||||
return rules
|
||||
}
|
||||
|
||||
// clientAddress is what to enter as the host in a Moonlight client on the
|
||||
// console to reach this Sunshine host.
|
||||
func (h sunshineHost) clientAddress() string {
|
||||
if h.basePort() == sunshineDefaultPort {
|
||||
return "127.0.0.1"
|
||||
}
|
||||
return net.JoinHostPort("127.0.0.1", strconv.Itoa(h.basePort()))
|
||||
}
|
||||
|
||||
// sunshineRules returns the forwards for all hosts.
|
||||
func sunshineRules(hosts []sunshineHost) []forwardRule {
|
||||
var rules []forwardRule
|
||||
for _, h := range hosts {
|
||||
rules = append(rules, h.rules()...)
|
||||
}
|
||||
return rules
|
||||
}
|
||||
|
||||
// validateSunshineHosts checks that the hosts can be forwarded side by side.
|
||||
// They all share 127.0.0.1, so each needs its own set of ports, which means
|
||||
// each must use a different port setting in Sunshine.
|
||||
func validateSunshineHosts(hosts []sunshineHost) error {
|
||||
used := map[string]string{}
|
||||
for _, h := range hosts {
|
||||
if h.Host == "" || !validHostName(h.Host) {
|
||||
return fmt.Errorf("%q does not look like a host name or address", h.Host)
|
||||
}
|
||||
if p := h.basePort(); p < 1024+5 || p > 65535-21 {
|
||||
return fmt.Errorf("port %d for %s is out of range", p, h.Host)
|
||||
}
|
||||
for _, r := range h.rules() {
|
||||
key := r.Proto + " " + r.Listen
|
||||
if other, taken := used[key]; taken {
|
||||
return fmt.Errorf("%s and %s use overlapping ports; give each Sunshine host its own port setting", other, h.Host)
|
||||
}
|
||||
used[key] = h.Host
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type dialFunc func(ctx context.Context, network, addr string) (net.Conn, error)
|
||||
|
||||
// forwarder runs a set of local forwards.
|
||||
@@ -152,10 +208,14 @@ func (f *forwarder) serveTCP(c net.Conn, r forwardRule) {
|
||||
}
|
||||
|
||||
func (f *forwarder) startUDP(r forwardRule) (stop func(), err error) {
|
||||
return startUDPRelay(udpRelayConfig{
|
||||
relay, err := startUDPRelay(udpRelayConfig{
|
||||
name: "forward " + r.String(),
|
||||
listen: func() (net.PacketConn, error) { return net.ListenPacket("udp", r.Listen) },
|
||||
dial: func(ctx context.Context) (net.Conn, error) { return f.dial(ctx, "udp", r.Target) },
|
||||
logf: f.logf,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return relay.stop, nil
|
||||
}
|
||||
+62
-10
@@ -134,21 +134,73 @@ func TestLocalForward(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSunshineRules(t *testing.T) {
|
||||
if got := sunshineRules(""); got != nil {
|
||||
t.Errorf("no host: got %v", got)
|
||||
if got := sunshineRules(nil); got != nil {
|
||||
t.Errorf("no hosts: got %v", got)
|
||||
}
|
||||
rules := sunshineRules("gaming-pc")
|
||||
if len(rules) != 7 {
|
||||
t.Fatalf("got %d rules, want 7", len(rules))
|
||||
}
|
||||
if got, want := rules[0].String(), "tcp 127.0.0.1:47984 -> gaming-pc:47984"; got != want {
|
||||
t.Errorf("first rule %q, want %q", got, want)
|
||||
}
|
||||
for _, r := range rules {
|
||||
|
||||
// Default port: the well-known Sunshine ports.
|
||||
def := sunshineHost{Host: "gaming-pc"}
|
||||
var got []string
|
||||
for _, r := range def.rules() {
|
||||
got = append(got, r.String())
|
||||
if !strings.HasPrefix(r.Listen, "127.0.0.1:") {
|
||||
t.Errorf("%v does not listen on localhost only", r)
|
||||
}
|
||||
}
|
||||
want := []string{
|
||||
"tcp 127.0.0.1:47984 -> gaming-pc:47984",
|
||||
"tcp 127.0.0.1:47989 -> gaming-pc:47989",
|
||||
"tcp 127.0.0.1:48010 -> gaming-pc:48010",
|
||||
"udp 127.0.0.1:47998 -> gaming-pc:47998",
|
||||
"udp 127.0.0.1:47999 -> gaming-pc:47999",
|
||||
"udp 127.0.0.1:48000 -> gaming-pc:48000",
|
||||
"udp 127.0.0.1:48002 -> gaming-pc:48002",
|
||||
}
|
||||
if strings.Join(got, "\n") != strings.Join(want, "\n") {
|
||||
t.Errorf("default port rules:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n"))
|
||||
}
|
||||
if a := def.clientAddress(); a != "127.0.0.1" {
|
||||
t.Errorf("client address %q", a)
|
||||
}
|
||||
|
||||
// A host on another port keeps its own port numbers, shifted as a set.
|
||||
alt := sunshineHost{Host: "office-pc", Port: 48989}
|
||||
if r := alt.rules(); r[0].String() != "tcp 127.0.0.1:48984 -> office-pc:48984" || r[6].String() != "udp 127.0.0.1:49002 -> office-pc:49002" {
|
||||
t.Errorf("custom port rules: %v", r)
|
||||
}
|
||||
if a := alt.clientAddress(); a != "127.0.0.1:48989" {
|
||||
t.Errorf("client address %q", a)
|
||||
}
|
||||
if n := len(sunshineRules([]sunshineHost{def, alt})); n != 14 {
|
||||
t.Errorf("two hosts: %d rules, want 14", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSunshineHosts(t *testing.T) {
|
||||
ok := [][]sunshineHost{
|
||||
nil,
|
||||
{{Host: "gaming-pc"}},
|
||||
{{Host: "gaming-pc"}, {Host: "office-pc", Port: 48989}},
|
||||
{{Host: "100.64.0.2", Port: 50000}},
|
||||
}
|
||||
for _, hosts := range ok {
|
||||
if err := validateSunshineHosts(hosts); err != nil {
|
||||
t.Errorf("%v: unexpected error %v", hosts, err)
|
||||
}
|
||||
}
|
||||
bad := [][]sunshineHost{
|
||||
{{Host: ""}},
|
||||
{{Host: "bad host"}},
|
||||
{{Host: "a"}, {Host: "b"}}, // same ports
|
||||
{{Host: "a"}, {Host: "b", Port: 47989 + 5}}, // b's HTTPS port is a's HTTP port
|
||||
{{Host: "a", Port: 80}}, // too low
|
||||
{{Host: "a", Port: 65530}}, // derived ports past 65535
|
||||
}
|
||||
for _, hosts := range bad {
|
||||
if err := validateSunshineHosts(hosts); err == nil {
|
||||
t.Errorf("%v: expected an error", hosts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsLocalDestination(t *testing.T) {
|
||||
|
||||
+47
-13
@@ -115,10 +115,16 @@ type daemon struct {
|
||||
lastErr string
|
||||
notified string // last state the user was notified about
|
||||
|
||||
lastTsnetMsg string
|
||||
proxyPort uint16 // port of the outbound HTTP proxy, 0 if disabled
|
||||
webPort uint16 // port of the status page
|
||||
lastRelogin time.Time
|
||||
lastTsnetMsg string
|
||||
proxyLn *resilientListener // the outbound HTTP proxy, nil if disabled
|
||||
proxyPort uint16 // its port, 0 if disabled
|
||||
webPort uint16 // port of the status page
|
||||
lastRelogin time.Time
|
||||
lastNetChange time.Time
|
||||
latest releaseInfo // newest release known, see update.go
|
||||
|
||||
sessions sessions // browsers that have entered the password
|
||||
tailnetWeb *tailnetListener // status page connections arriving over the tailnet
|
||||
|
||||
quit chan struct{}
|
||||
quitOnce sync.Once
|
||||
@@ -154,8 +160,13 @@ func (d *daemon) run() error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("web UI: %w", err)
|
||||
}
|
||||
webLn.onReopen = d.networkChanged
|
||||
d.webPort = webLn.port()
|
||||
go d.serveWeb(webLn)
|
||||
d.tailnetWeb = newTailnetListener()
|
||||
handler := d.webHandler()
|
||||
go d.serveWeb(webLn, handler)
|
||||
go d.serveWeb(d.tailnetWeb, handler)
|
||||
d.writePriorityFile()
|
||||
|
||||
if err := d.srv.Start(); err != nil {
|
||||
return fmt.Errorf("starting tailscale: %w", err)
|
||||
@@ -165,13 +176,8 @@ func (d *daemon) run() error {
|
||||
return fmt.Errorf("local client: %w", err)
|
||||
}
|
||||
|
||||
if d.cfg.HTTPProxyAddr != "" {
|
||||
if ln, err := listenResilient("tcp", d.cfg.HTTPProxyAddr, d.logf); err != nil {
|
||||
d.logf("http proxy: %v", err)
|
||||
} else {
|
||||
d.proxyPort = ln.port()
|
||||
go d.serveProxy(ln)
|
||||
}
|
||||
if err := d.setProxy(d.cfg.HTTPProxyAddr); err != nil {
|
||||
d.logf("http proxy: %v", err)
|
||||
}
|
||||
|
||||
d.fwd.set(d.localForwardRules())
|
||||
@@ -184,6 +190,7 @@ func (d *daemon) run() error {
|
||||
go d.watch(ctx)
|
||||
go d.recoverLogin(ctx)
|
||||
go d.exposeUDP(ctx)
|
||||
go d.watchForUpdates(ctx)
|
||||
|
||||
sigc := make(chan os.Signal, 1)
|
||||
signal.Notify(sigc, syscall.SIGTERM, syscall.SIGINT)
|
||||
@@ -195,6 +202,7 @@ func (d *daemon) run() error {
|
||||
}
|
||||
cancel()
|
||||
webLn.Close()
|
||||
d.tailnetWeb.Close()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
@@ -226,7 +234,33 @@ func (d *daemon) dialTailnet(ctx context.Context, network, addr string) (net.Con
|
||||
func (d *daemon) localForwardRules() []forwardRule {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
return append(sunshineRules(d.cfg.SunshineHost), d.cfg.Forwards...)
|
||||
return append(sunshineRules(d.cfg.SunshineHosts), d.cfg.Forwards...)
|
||||
}
|
||||
|
||||
// networkChanged is called when a listening socket has died and been
|
||||
// reopened, which on the PS5 means the network was reconfigured (connection
|
||||
// settings changed, Wi-Fi to Ethernet, ...). Tailscale notices changes by
|
||||
// polling the interfaces; this tells it straight away to open fresh sockets
|
||||
// and work out its addresses again.
|
||||
func (d *daemon) networkChanged() {
|
||||
d.mu.Lock()
|
||||
recent := time.Since(d.lastNetChange) < 10*time.Second
|
||||
d.lastNetChange = time.Now()
|
||||
lc := d.lc
|
||||
d.mu.Unlock()
|
||||
if recent || lc == nil {
|
||||
return
|
||||
}
|
||||
d.logf("the console's network changed; asking Tailscale to rebind")
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
for _, action := range []string{"rebind", "restun"} {
|
||||
if err := lc.DebugAction(ctx, action); err != nil {
|
||||
d.logf("tailscale %s: %v", action, err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// exposeUDP keeps the configured UDP ports listening on the console's tailnet
|
||||
|
||||
+4
-1
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -52,7 +53,9 @@ func (d *daemon) serveProxy(ln net.Listener) {
|
||||
io.Copy(w, resp.Body)
|
||||
}),
|
||||
}
|
||||
if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed && !d.stopping() {
|
||||
// Serve returns when the listener is closed, which is how the proxy is
|
||||
// turned off or moved from the settings.
|
||||
if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed && !errors.Is(err, net.ErrClosed) && !d.stopping() {
|
||||
d.logf("http proxy stopped: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
+292
@@ -0,0 +1,292 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"tailscale.com/ipn"
|
||||
)
|
||||
|
||||
// Settings editing from the status page. Most settings take effect at once;
|
||||
// the few that are only read when the payload starts are reported back so
|
||||
// the page can say so.
|
||||
|
||||
const (
|
||||
priorityLow = "low"
|
||||
priorityHigh = "high"
|
||||
// priorityFile tells the launcher which scheduling class to use. The
|
||||
// launcher is C and runs before any of this, so it gets the one setting
|
||||
// it needs in a file of its own rather than parsing the config.
|
||||
priorityFile = "priority"
|
||||
)
|
||||
|
||||
// settings is the editable part of the config as the status page sees it.
|
||||
type settings struct {
|
||||
Hostname string `json:"hostname"`
|
||||
WebAddr string `json:"webAddr"`
|
||||
HTTPProxyAddr string `json:"httpProxyAddr"`
|
||||
SunshineHosts []sunshineHost `json:"sunshineHosts"`
|
||||
Forwards []forwardRule `json:"forwards"`
|
||||
UDPPorts []uint16 `json:"udpPorts"`
|
||||
BlockedPorts []uint16 `json:"blockedPorts"`
|
||||
Priority string `json:"priority"`
|
||||
CheckUpdates bool `json:"checkUpdates"`
|
||||
Verbose bool `json:"verbose"`
|
||||
|
||||
// PasswordSet says whether a password is in place. Password is only
|
||||
// read: absent leaves the password alone, empty removes it, anything
|
||||
// else sets it.
|
||||
PasswordSet bool `json:"passwordSet"`
|
||||
Password *string `json:"password,omitempty"`
|
||||
}
|
||||
|
||||
func settingsFromConfig(cfg config) settings {
|
||||
s := settings{
|
||||
Hostname: cfg.Hostname,
|
||||
WebAddr: cfg.WebAddr,
|
||||
HTTPProxyAddr: cfg.HTTPProxyAddr,
|
||||
SunshineHosts: append([]sunshineHost{}, cfg.SunshineHosts...),
|
||||
Forwards: append([]forwardRule{}, cfg.Forwards...),
|
||||
UDPPorts: append([]uint16{}, cfg.UDPPorts...),
|
||||
BlockedPorts: append([]uint16{}, cfg.BlockedPorts...),
|
||||
Priority: priorityLow,
|
||||
CheckUpdates: cfg.CheckUpdates,
|
||||
Verbose: cfg.Verbose,
|
||||
PasswordSet: cfg.PasswordHash != "",
|
||||
}
|
||||
if cfg.Priority == priorityHigh {
|
||||
s.Priority = priorityHigh
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// validate checks the settings and tidies them.
|
||||
func (s *settings) validate() error {
|
||||
s.Hostname = strings.TrimSpace(s.Hostname)
|
||||
if !validTailnetName(s.Hostname) {
|
||||
return fmt.Errorf("the name may only contain letters, digits and hyphens (at most 63)")
|
||||
}
|
||||
if err := validListenAddr(s.WebAddr); err != nil {
|
||||
return fmt.Errorf("status page address: %w", err)
|
||||
}
|
||||
s.HTTPProxyAddr = strings.TrimSpace(s.HTTPProxyAddr)
|
||||
if s.HTTPProxyAddr != "" {
|
||||
if err := validListenAddr(s.HTTPProxyAddr); err != nil {
|
||||
return fmt.Errorf("HTTP proxy address: %w", err)
|
||||
}
|
||||
}
|
||||
if err := validateSunshineHosts(s.SunshineHosts); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, f := range s.Forwards {
|
||||
if f.Proto != "tcp" && f.Proto != "udp" {
|
||||
return fmt.Errorf("forward %v: the protocol must be tcp or udp", f)
|
||||
}
|
||||
if err := validListenAddr(f.Listen); err != nil {
|
||||
return fmt.Errorf("forward %v: listen address: %w", f, err)
|
||||
}
|
||||
host, port, err := net.SplitHostPort(f.Target)
|
||||
if err != nil || host == "" || !validHostName(host) || !validPort(port) {
|
||||
return fmt.Errorf("forward %v: the target must be host:port", f)
|
||||
}
|
||||
}
|
||||
if slices.Contains(s.UDPPorts, 0) || slices.Contains(s.BlockedPorts, 0) {
|
||||
return fmt.Errorf("0 is not a port")
|
||||
}
|
||||
if s.Priority != priorityLow && s.Priority != priorityHigh {
|
||||
return fmt.Errorf("the priority must be low or high")
|
||||
}
|
||||
if s.Password != nil && len(*s.Password) > 0 && len(*s.Password) < 4 {
|
||||
return fmt.Errorf("the password must be at least 4 characters")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validTailnetName(s string) bool {
|
||||
if len(s) == 0 || len(s) > 63 || s[0] == '-' || s[len(s)-1] == '-' {
|
||||
return false
|
||||
}
|
||||
for _, c := range s {
|
||||
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validPort(s string) bool {
|
||||
n, err := strconv.Atoi(s)
|
||||
return err == nil && n >= 1 && n <= 65535
|
||||
}
|
||||
|
||||
// validListenAddr accepts "host:port" and ":port".
|
||||
func validListenAddr(addr string) error {
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%q is not host:port", addr)
|
||||
}
|
||||
if !validHostName(host) || !validPort(port) {
|
||||
return fmt.Errorf("%q is not a valid address", addr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *daemon) handleGetConfig(w http.ResponseWriter, r *http.Request) {
|
||||
d.mu.Lock()
|
||||
s := settingsFromConfig(d.cfg)
|
||||
d.mu.Unlock()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
json.NewEncoder(w).Encode(s)
|
||||
}
|
||||
|
||||
// handleSetConfig saves new settings and applies what can be applied without
|
||||
// a restart. The reply lists the settings that need one.
|
||||
func (d *daemon) handleSetConfig(w http.ResponseWriter, r *http.Request) {
|
||||
var s settings
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&s); err != nil {
|
||||
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := s.validate(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var newHash *string
|
||||
if s.Password != nil {
|
||||
hash := ""
|
||||
if *s.Password != "" {
|
||||
var err error
|
||||
if hash, err = hashPassword(*s.Password); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
newHash = &hash
|
||||
}
|
||||
|
||||
d.mu.Lock()
|
||||
old := d.cfg
|
||||
cfg := d.cfg
|
||||
cfg.Hostname = s.Hostname
|
||||
cfg.WebAddr = s.WebAddr
|
||||
cfg.HTTPProxyAddr = s.HTTPProxyAddr
|
||||
if s.SunshineHosts != nil {
|
||||
// The settings form leaves the Sunshine hosts out: they have a
|
||||
// panel of their own.
|
||||
cfg.SunshineHosts = s.SunshineHosts
|
||||
}
|
||||
cfg.Forwards = s.Forwards
|
||||
cfg.UDPPorts = s.UDPPorts
|
||||
cfg.BlockedPorts = s.BlockedPorts
|
||||
cfg.Priority = ""
|
||||
if s.Priority == priorityHigh {
|
||||
cfg.Priority = priorityHigh
|
||||
}
|
||||
cfg.CheckUpdates = s.CheckUpdates
|
||||
cfg.Verbose = s.Verbose
|
||||
if newHash != nil {
|
||||
cfg.PasswordHash = *newHash
|
||||
}
|
||||
d.cfg = cfg
|
||||
d.mu.Unlock()
|
||||
|
||||
if err := saveConfig(d.cfgPath, cfg); err != nil {
|
||||
d.logf("saving config: %v", err)
|
||||
http.Error(w, "the settings are in effect but could not be saved: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
d.logf("settings changed from the status page")
|
||||
|
||||
// Apply.
|
||||
problems := []string{}
|
||||
if cfg.Hostname != old.Hostname && d.lc != nil {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
_, err := d.lc.EditPrefs(ctx, &ipn.MaskedPrefs{Prefs: ipn.Prefs{Hostname: cfg.Hostname}, HostnameSet: true})
|
||||
cancel()
|
||||
if err != nil {
|
||||
problems = append(problems, "name: "+err.Error())
|
||||
}
|
||||
}
|
||||
if err := d.fwd.set(d.localForwardRules()); err != nil {
|
||||
problems = append(problems, err.Error())
|
||||
}
|
||||
if cfg.HTTPProxyAddr != old.HTTPProxyAddr {
|
||||
if err := d.setProxy(cfg.HTTPProxyAddr); err != nil {
|
||||
problems = append(problems, "HTTP proxy: "+err.Error())
|
||||
}
|
||||
}
|
||||
if newHash != nil && cfg.PasswordHash != old.PasswordHash {
|
||||
// A changed password ends every session but the one that changed it.
|
||||
d.sessions.clear()
|
||||
if cfg.PasswordHash != "" {
|
||||
if token, err := d.sessions.create(); err == nil {
|
||||
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: token, Path: "/",
|
||||
MaxAge: int(sessionLifetime.Seconds()), HttpOnly: true, SameSite: http.SameSiteStrictMode})
|
||||
}
|
||||
}
|
||||
}
|
||||
d.writePriorityFile()
|
||||
// UDP ports and blocked ports are read from the config where they are used.
|
||||
|
||||
restart := []string{}
|
||||
if cfg.WebAddr != old.WebAddr {
|
||||
restart = append(restart, "status page address")
|
||||
}
|
||||
if cfg.Priority != old.Priority {
|
||||
restart = append(restart, "priority")
|
||||
}
|
||||
if cfg.Verbose != old.Verbose {
|
||||
restart = append(restart, "verbose log")
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{"restart": restart, "problems": problems})
|
||||
}
|
||||
|
||||
// writePriorityFile leaves the launcher its instruction for the next start.
|
||||
func (d *daemon) writePriorityFile() {
|
||||
d.mu.Lock()
|
||||
high := d.cfg.Priority == priorityHigh
|
||||
d.mu.Unlock()
|
||||
path := filepath.Join(dataDir, priorityFile)
|
||||
if high {
|
||||
os.WriteFile(path, []byte(priorityHigh+"\n"), 0o644)
|
||||
} else {
|
||||
os.Remove(path)
|
||||
}
|
||||
}
|
||||
|
||||
// setProxy starts, stops or moves the outbound HTTP proxy.
|
||||
func (d *daemon) setProxy(addr string) error {
|
||||
d.mu.Lock()
|
||||
old := d.proxyLn
|
||||
d.proxyLn, d.proxyPort = nil, 0
|
||||
d.mu.Unlock()
|
||||
if old != nil {
|
||||
old.Close()
|
||||
}
|
||||
if addr == "" {
|
||||
return nil
|
||||
}
|
||||
ln, err := listenResilient("tcp", addr, d.logf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
d.mu.Lock()
|
||||
d.proxyLn, d.proxyPort = ln, ln.port()
|
||||
d.mu.Unlock()
|
||||
go d.serveProxy(ln)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPasswordHash(t *testing.T) {
|
||||
hash, err := hashPassword("correct horse")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(hash, "pbkdf2-sha256$") {
|
||||
t.Errorf("unexpected hash format %q", hash)
|
||||
}
|
||||
if !checkPassword(hash, "correct horse") {
|
||||
t.Error("the right password was rejected")
|
||||
}
|
||||
for _, wrong := range []string{"", "Correct horse", "correct horse "} {
|
||||
if checkPassword(hash, wrong) {
|
||||
t.Errorf("%q was accepted", wrong)
|
||||
}
|
||||
}
|
||||
other, _ := hashPassword("correct horse")
|
||||
if other == hash {
|
||||
t.Error("two hashes of one password are identical; the salt is not random")
|
||||
}
|
||||
for _, bad := range []string{"", "plain", "pbkdf2-sha256$x$00$00", "pbkdf2-sha256$1000$zz$00", "md5$1$00$00"} {
|
||||
if checkPassword(bad, "anything") {
|
||||
t.Errorf("malformed hash %q accepted a password", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// newTestDaemon returns a daemon with just enough set up to serve the status
|
||||
// page's API.
|
||||
func newTestDaemon(t *testing.T) *daemon {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
old := dataDir
|
||||
dataDir = dir
|
||||
t.Cleanup(func() { dataDir = old })
|
||||
d := &daemon{
|
||||
cfg: defaultConfig(),
|
||||
cfgPath: filepath.Join(dir, "config.json"),
|
||||
logf: t.Logf,
|
||||
quit: make(chan struct{}),
|
||||
}
|
||||
d.fwd = newForwarder(nil, t.Logf)
|
||||
d.tailnetWeb = newTailnetListener()
|
||||
return d
|
||||
}
|
||||
|
||||
// request performs one API call. remote is the client address the server sees.
|
||||
func request(t *testing.T, h http.Handler, method, path, remote string, body any, cookies []*http.Cookie) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
if body != nil {
|
||||
json.NewEncoder(&buf).Encode(body)
|
||||
}
|
||||
r := httptest.NewRequest(method, path, &buf)
|
||||
r.RemoteAddr = remote
|
||||
r.Header.Set(apiHeader, "1")
|
||||
for _, c := range cookies {
|
||||
r.AddCookie(c)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
|
||||
func TestPasswordProtection(t *testing.T) {
|
||||
d := newTestDaemon(t)
|
||||
h := d.webHandler()
|
||||
const lan, tailnet, console = "192.168.1.20:5000", "100.64.0.9:5000", "127.0.0.1:5000"
|
||||
|
||||
// No password: everyone gets in.
|
||||
if w := request(t, h, "GET", "/api/status", lan, nil, nil); w.Code != 200 {
|
||||
t.Fatalf("no password, LAN status: %d", w.Code)
|
||||
}
|
||||
|
||||
// Set one from the LAN.
|
||||
pw := "hunter22"
|
||||
w := request(t, h, "POST", "/api/config", lan, func() settings {
|
||||
s := settingsFromConfig(d.cfg)
|
||||
s.Password = &pw
|
||||
return s
|
||||
}(), nil)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("setting the password: %d %s", w.Code, w.Body)
|
||||
}
|
||||
setter := w.Result().Cookies()
|
||||
if d.cfg.PasswordHash == "" || strings.Contains(d.cfg.PasswordHash, pw) {
|
||||
t.Fatalf("stored password hash: %q", d.cfg.PasswordHash)
|
||||
}
|
||||
saved, _ := os.ReadFile(d.cfgPath)
|
||||
if !bytes.Contains(saved, []byte("passwordHash")) || bytes.Contains(saved, []byte(pw)) {
|
||||
t.Errorf("config file should hold the hash and not the password:\n%s", saved)
|
||||
}
|
||||
|
||||
// Now locked for the LAN and the tailnet, open for the console itself
|
||||
// and for the browser that set it.
|
||||
for _, remote := range []string{lan, tailnet} {
|
||||
for _, path := range []string{"/api/status", "/api/config", "/api/logs", "/qr.png"} {
|
||||
if w := request(t, h, "GET", path, remote, nil, nil); w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("GET %s from %s: %d, want 401", path, remote, w.Code)
|
||||
}
|
||||
}
|
||||
for _, path := range []string{"/api/logout", "/api/quit", "/api/uninstall", "/api/config", "/api/sunshine", "/api/login"} {
|
||||
if w := request(t, h, "POST", path, remote, nil, nil); w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("POST %s from %s: %d, want 401", path, remote, w.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
if w := request(t, h, "GET", "/api/status", console, nil, nil); w.Code != 200 {
|
||||
t.Errorf("console status: %d", w.Code)
|
||||
}
|
||||
if w := request(t, h, "GET", "/api/status", lan, nil, setter); w.Code != 200 {
|
||||
t.Errorf("status with the session of the browser that set the password: %d", w.Code)
|
||||
}
|
||||
// The page shell and ping stay reachable so the unlock form can load.
|
||||
for _, path := range []string{"/", "/api/ping", "/favicon.png"} {
|
||||
if w := request(t, h, "GET", path, lan, nil, nil); w.Code != 200 {
|
||||
t.Errorf("GET %s while locked: %d", path, w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Unlocking.
|
||||
if w := request(t, h, "POST", "/api/auth", lan, map[string]string{"password": "nope"}, nil); w.Code != http.StatusForbidden {
|
||||
t.Errorf("wrong password: %d", w.Code)
|
||||
}
|
||||
w = request(t, h, "POST", "/api/auth", tailnet, map[string]string{"password": pw}, nil)
|
||||
if w.Code != 200 || len(w.Result().Cookies()) == 0 {
|
||||
t.Fatalf("right password: %d, cookies %v", w.Code, w.Result().Cookies())
|
||||
}
|
||||
session := w.Result().Cookies()
|
||||
if !session[0].HttpOnly {
|
||||
t.Error("the session cookie should be HttpOnly")
|
||||
}
|
||||
if w := request(t, h, "GET", "/api/status", tailnet, nil, session); w.Code != 200 {
|
||||
t.Errorf("status with a session: %d", w.Code)
|
||||
}
|
||||
|
||||
// Locking again ends the session.
|
||||
request(t, h, "POST", "/api/lock", tailnet, nil, session)
|
||||
if w := request(t, h, "GET", "/api/status", tailnet, nil, session); w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status after lock: %d", w.Code)
|
||||
}
|
||||
|
||||
// Removing the password opens the page again.
|
||||
empty := ""
|
||||
s := settingsFromConfig(d.cfg)
|
||||
s.Password = &empty
|
||||
if w := request(t, h, "POST", "/api/config", console, s, nil); w.Code != 200 {
|
||||
t.Fatalf("removing the password: %d %s", w.Code, w.Body)
|
||||
}
|
||||
if w := request(t, h, "GET", "/api/status", lan, nil, nil); w.Code != 200 {
|
||||
t.Errorf("status after removing the password: %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStateChangesNeedHeader(t *testing.T) {
|
||||
d := newTestDaemon(t)
|
||||
h := d.webHandler()
|
||||
r := httptest.NewRequest("POST", "/api/quit", nil)
|
||||
r.RemoteAddr = "192.168.1.20:5000"
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("POST without the API header: %d, want 403", w.Code)
|
||||
}
|
||||
if d.stopping() {
|
||||
t.Error("the daemon was told to stop by a request without the header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsRoundTrip(t *testing.T) {
|
||||
d := newTestDaemon(t)
|
||||
h := d.webHandler()
|
||||
const console = "127.0.0.1:5000"
|
||||
|
||||
var s settings
|
||||
w := request(t, h, "GET", "/api/config", console, nil, nil)
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Hostname != "ps5" || s.Priority != priorityLow || !s.CheckUpdates || s.HTTPProxyAddr != "" || s.PasswordSet {
|
||||
t.Errorf("defaults: %+v", s)
|
||||
}
|
||||
|
||||
s.Hostname = "living-room-ps5"
|
||||
s.BlockedPorts = []uint16{9021}
|
||||
s.UDPPorts = []uint16{9296}
|
||||
s.Priority = priorityHigh
|
||||
s.CheckUpdates = false
|
||||
w = request(t, h, "POST", "/api/config", console, s, nil)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("saving: %d %s", w.Code, w.Body)
|
||||
}
|
||||
var reply struct{ Restart []string }
|
||||
json.Unmarshal(w.Body.Bytes(), &reply)
|
||||
if len(reply.Restart) != 1 || reply.Restart[0] != "priority" {
|
||||
t.Errorf("settings needing a restart: %v, want [priority]", reply.Restart)
|
||||
}
|
||||
|
||||
cfg, err := loadConfig(d.cfgPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Hostname != "living-room-ps5" || cfg.Priority != priorityHigh || cfg.CheckUpdates ||
|
||||
len(cfg.BlockedPorts) != 1 || len(cfg.UDPPorts) != 1 {
|
||||
t.Errorf("saved config: %+v", cfg)
|
||||
}
|
||||
if b, _ := os.ReadFile(filepath.Join(dataDir, priorityFile)); strings.TrimSpace(string(b)) != priorityHigh {
|
||||
t.Errorf("priority file: %q", b)
|
||||
}
|
||||
|
||||
// Back to low removes the launcher's instruction.
|
||||
s.Priority = priorityLow
|
||||
request(t, h, "POST", "/api/config", console, s, nil)
|
||||
if _, err := os.Stat(filepath.Join(dataDir, priorityFile)); !os.IsNotExist(err) {
|
||||
t.Errorf("priority file should be gone: %v", err)
|
||||
}
|
||||
|
||||
// Invalid input is rejected and changes nothing.
|
||||
for name, edit := range map[string]func(*settings){
|
||||
"name": func(s *settings) { s.Hostname = "bad name!" },
|
||||
"web": func(s *settings) { s.WebAddr = "8090" },
|
||||
"proxy": func(s *settings) { s.HTTPProxyAddr = "nonsense" },
|
||||
"priority": func(s *settings) { s.Priority = "turbo" },
|
||||
"forward": func(s *settings) { s.Forwards = []forwardRule{{"sctp", "127.0.0.1:1", "a:1"}} },
|
||||
"sunshine": func(s *settings) { s.SunshineHosts = []sunshineHost{{Host: "a"}, {Host: "b"}} },
|
||||
"password": func(s *settings) { p := "abc"; s.Password = &p },
|
||||
} {
|
||||
bad := settingsFromConfig(d.cfg)
|
||||
edit(&bad)
|
||||
if w := request(t, h, "POST", "/api/config", console, bad, nil); w.Code != http.StatusBadRequest {
|
||||
t.Errorf("invalid %s: %d, want 400", name, w.Code)
|
||||
}
|
||||
}
|
||||
if d.cfg.Hostname != "living-room-ps5" {
|
||||
t.Errorf("hostname changed by a rejected request: %q", d.cfg.Hostname)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigMigration(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
// A config as v0.4.1 wrote it.
|
||||
os.WriteFile(path, []byte(`{"hostname":"ps5","webAddr":":8090","httpProxyAddr":"127.0.0.1:8118","sunshineHost":"gaming-pc","udpPorts":[9295,9296,9297,9302]}`), 0o600)
|
||||
cfg, err := loadConfig(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cfg.SunshineHosts) != 1 || cfg.SunshineHosts[0].Host != "gaming-pc" || cfg.SunshineHost != "" {
|
||||
t.Errorf("sunshine host not migrated: %+v", cfg)
|
||||
}
|
||||
if cfg.HTTPProxyAddr != "127.0.0.1:8118" {
|
||||
t.Errorf("an explicitly configured proxy must stay on: %q", cfg.HTTPProxyAddr)
|
||||
}
|
||||
if !cfg.CheckUpdates {
|
||||
t.Error("update checks should default to on for an existing config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionCompare(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
current, latest string
|
||||
want bool
|
||||
}{
|
||||
{"0.4.1", "0.5.0", true},
|
||||
{"0.4.1", "v0.4.2", true},
|
||||
{"0.4.1", "0.4.1", false},
|
||||
{"0.5.0", "0.4.9", false},
|
||||
{"0.4.2-dev", "0.4.1", false},
|
||||
{"0.4.2-dev", "0.4.2", false},
|
||||
{"0.4.2-dev", "0.4.3", true},
|
||||
{"0.9.0", "0.10.0", true},
|
||||
{"dev", "0.5.0", false},
|
||||
{"0.4.1", "", false},
|
||||
{"0.4.1", "nonsense", false},
|
||||
} {
|
||||
if got := newerVersion(tt.current, tt.latest); got != tt.want {
|
||||
t.Errorf("newerVersion(%q, %q) = %v, want %v", tt.current, tt.latest, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIconReplyLine(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"": "",
|
||||
"[SceLncUtil] something\n": "",
|
||||
"icon: remov": "",
|
||||
"[SceLncUtil] x\nicon: removed\n": "icon: removed",
|
||||
"icon: registering failed: 0x1\r\n": "icon: registering failed: 0x1",
|
||||
} {
|
||||
if got := iconReplyLine([]byte(in)); got != want {
|
||||
t.Errorf("iconReplyLine(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+285
-61
@@ -4,6 +4,7 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Tailscale on PS5</title>
|
||||
<link rel="icon" type="image/png" href="/favicon.png">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f4f5f7; --panel: #ffffff; --text: #1c1e21; --muted: #646a73;
|
||||
@@ -22,9 +23,11 @@
|
||||
}
|
||||
main { max-width: 720px; margin: 0 auto; }
|
||||
h1 { font-size: 22px; margin: 0 0 4px; }
|
||||
h2 { font-size: 15px; margin: 0 0 12px; color: var(--muted); font-weight: 600; text-transform: uppercase; letter-spacing: .04em; }
|
||||
h2, summary .heading { font-size: 15px; color: var(--muted); font-weight: 600; text-transform: uppercase; letter-spacing: .04em; }
|
||||
h2 { margin: 0 0 12px; }
|
||||
.sub { color: var(--muted); margin: 0 0 20px; font-size: 14px; }
|
||||
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 18px; margin-bottom: 16px; }
|
||||
.banner { border-color: var(--accent); }
|
||||
.state { display: flex; align-items: center; gap: 10px; font-size: 20px; font-weight: 600; }
|
||||
.dot { width: 12px; height: 12px; border-radius: 50%; background: var(--muted); flex: none; }
|
||||
.dot.ok { background: var(--ok); } .dot.warn { background: var(--warn); } .dot.bad { background: var(--bad); }
|
||||
@@ -36,28 +39,37 @@
|
||||
.login img { width: 240px; height: 240px; image-rendering: pixelated; background: #fff; padding: 8px; border-radius: 8px; }
|
||||
.login .url { display: block; margin: 12px 0 4px; font-size: 18px; overflow-wrap: anywhere; }
|
||||
.msg { color: var(--bad); margin: 12px 0 0; overflow-wrap: anywhere; }
|
||||
.okmsg { color: var(--ok); margin: 12px 0 0; overflow-wrap: anywhere; }
|
||||
.health { color: var(--warn); margin: 12px 0 0; padding-left: 18px; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 15px; }
|
||||
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); }
|
||||
.actions { display: flex; flex-wrap: wrap; gap: 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);
|
||||
background: var(--panel); color: var(--text); cursor: pointer;
|
||||
}
|
||||
button:hover { border-color: var(--accent); }
|
||||
button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
button.danger { color: var(--bad); }
|
||||
button.small { padding: 6px 10px; font-size: 14px; }
|
||||
pre { margin: 0; max-height: 320px; overflow: auto; font: 12.5px/1.45 ui-monospace, Consolas, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
details summary { cursor: pointer; color: var(--muted); }
|
||||
summary .count { font-size: 14px; margin-left: 8px; }
|
||||
details[open] > table, details[open] > form { margin-top: 12px; }
|
||||
.note { color: var(--muted); font-size: 14px; margin: 0 0 12px; }
|
||||
.field { display: flex; flex-direction: column; gap: 4px; font-size: 14px; color: var(--muted); }
|
||||
select {
|
||||
.hint { color: var(--muted); font-size: 13px; margin: 2px 0 0; }
|
||||
label.field { display: flex; flex-direction: column; gap: 4px; font-size: 14px; color: var(--muted); margin-bottom: 14px; }
|
||||
label.check { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
|
||||
input[type=text], input[type=password], input[type=number], select, textarea {
|
||||
font: inherit; padding: 8px 10px; border-radius: 8px; border: 1px solid var(--line);
|
||||
background: var(--panel); color: var(--text); min-width: 220px;
|
||||
background: var(--panel); color: var(--text); width: 100%;
|
||||
}
|
||||
.actions { align-items: flex-end; }
|
||||
textarea { font: 14px ui-monospace, Consolas, monospace; min-height: 70px; resize: vertical; }
|
||||
.hostrow { display: flex; gap: 8px; margin-bottom: 8px; align-items: center; }
|
||||
.hostrow .host { flex: 3; min-width: 0; }
|
||||
.hostrow .port { flex: 1; min-width: 90px; }
|
||||
.hidden { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
@@ -66,6 +78,22 @@
|
||||
<h1>Tailscale on PS5</h1>
|
||||
<p class="sub" id="version"></p>
|
||||
|
||||
<section class="panel banner hidden" id="updatepanel">
|
||||
<strong id="updatetext"></strong>
|
||||
<a id="updatelink" target="_blank" rel="noopener">Release notes and download</a>
|
||||
</section>
|
||||
|
||||
<section class="panel hidden" id="lockpanel">
|
||||
<h2>Password</h2>
|
||||
<p class="note">This status page is password protected.</p>
|
||||
<form id="lockform" class="actions">
|
||||
<input type="password" id="lockpw" autocomplete="current-password" aria-label="Password" style="max-width: 280px">
|
||||
<button type="submit">Unlock</button>
|
||||
</form>
|
||||
<p class="msg hidden" id="lockmsg"></p>
|
||||
</section>
|
||||
|
||||
<div id="content" class="hidden">
|
||||
<section class="panel">
|
||||
<div class="state"><span class="dot" id="dot"></span><span id="state">Loading…</span></div>
|
||||
<dl id="facts"></dl>
|
||||
@@ -81,25 +109,76 @@
|
||||
</section>
|
||||
|
||||
<section class="panel hidden" id="peerpanel">
|
||||
<h2>Devices on your tailnet</h2>
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Address</th><th>OS</th><th>Status</th></tr></thead>
|
||||
<tbody id="peers"></tbody>
|
||||
</table>
|
||||
<details id="peerbox" open>
|
||||
<summary><span class="heading">Devices on your tailnet</span><span class="count" id="peercount"></span></summary>
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Address</th><th>OS</th><th>Status</th></tr></thead>
|
||||
<tbody id="peers"></tbody>
|
||||
</table>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section class="panel hidden" id="streampanel">
|
||||
<h2>Game streaming (Moonlight to Sunshine)</h2>
|
||||
<p class="note">Apps on the PS5 cannot reach tailnet addresses directly. Pick the device that runs Sunshine and its
|
||||
streaming ports are made available on this console. Then, in your Moonlight client on the PS5 (for example
|
||||
ProsperoLight), add the host <code>127.0.0.1</code>.</p>
|
||||
<p class="note">Apps on the PS5 cannot reach tailnet addresses directly. List the devices that run Sunshine and
|
||||
their streaming ports are made available on this console. In your Moonlight client on the PS5 (for example
|
||||
ProsperoLight), add the address shown for each host.</p>
|
||||
<div id="hostrows"></div>
|
||||
<datalist id="peernames"></datalist>
|
||||
<div class="actions">
|
||||
<label class="field">Sunshine host
|
||||
<select id="sunshine-select"></select>
|
||||
</label>
|
||||
<button id="btn-addhost" class="small">Add a host</button>
|
||||
<button id="btn-sunshine">Save</button>
|
||||
</div>
|
||||
<p class="note" id="sunshine-state"></p>
|
||||
<p class="hint">Port is Sunshine's own port setting; leave it empty for the default (47989). Two hosts need
|
||||
different ports.</p>
|
||||
<p class="note" id="sunshine-state" style="margin: 12px 0 0"></p>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<details id="settingsbox">
|
||||
<summary><span class="heading">Settings</span></summary>
|
||||
<form id="settingsform">
|
||||
<label class="field">Name on the tailnet
|
||||
<input type="text" id="set-hostname" maxlength="63" autocomplete="off">
|
||||
</label>
|
||||
<label class="field">Password for this page <span id="set-pwstate"></span>
|
||||
<input type="password" id="set-password" autocomplete="new-password" placeholder="Leave empty to keep it as it is">
|
||||
<span class="hint">Asked on every device except the console itself. If you forget it, delete
|
||||
<code>passwordHash</code> from <code>/data/tailscale/config.json</code>.</span>
|
||||
</label>
|
||||
<label class="check hidden" id="set-pwremove-row"><input type="checkbox" id="set-pwremove"> Remove the password</label>
|
||||
<label class="field">UDP ports reachable from the tailnet
|
||||
<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>
|
||||
</label>
|
||||
<label class="field">TCP ports never exposed to the tailnet
|
||||
<input type="text" id="set-blocked" autocomplete="off">
|
||||
<span class="hint">Comma separated. Every other open TCP port on the console is reachable.</span>
|
||||
</label>
|
||||
<label class="field">Extra forwards from the console to tailnet hosts
|
||||
<textarea id="set-forwards" spellcheck="false"></textarea>
|
||||
<span class="hint">One per line: <code>tcp 127.0.0.1:8096 my-nas:8096</code> (protocol, local address, tailnet host and port).</span>
|
||||
</label>
|
||||
<label class="field">HTTP proxy address
|
||||
<input type="text" id="set-proxy" autocomplete="off" placeholder="Off">
|
||||
<span class="hint">Empty is off. Example: <code>127.0.0.1:8118</code>. Do not set it as the PS5's system proxy.</span>
|
||||
</label>
|
||||
<label class="field">Priority
|
||||
<select id="set-priority">
|
||||
<option value="low">Low: never takes time from a game (default)</option>
|
||||
<option value="high">High: shares the CPU with games; smoother Remote Play</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">Status page address
|
||||
<input type="text" id="set-webaddr" autocomplete="off">
|
||||
</label>
|
||||
<label class="check"><input type="checkbox" id="set-updates"> Check GitHub for new releases</label>
|
||||
<label class="check"><input type="checkbox" id="set-verbose"> Verbose log</label>
|
||||
<div class="actions"><button type="submit">Save settings</button></div>
|
||||
<p class="okmsg hidden" id="settingsok"></p>
|
||||
<p class="msg hidden" id="settingsmsg"></p>
|
||||
</form>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
@@ -109,16 +188,19 @@
|
||||
<button id="btn-logout" class="danger">Log out</button>
|
||||
<button id="btn-quit" class="danger">Stop Tailscale</button>
|
||||
<button id="btn-uninstall" class="danger">Uninstall</button>
|
||||
<button id="btn-lock" class="hidden">Lock this page</button>
|
||||
</div>
|
||||
<p class="okmsg hidden" id="actionok"></p>
|
||||
<p class="msg hidden" id="actionmsg"></p>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<details id="logbox">
|
||||
<summary>Recent log</summary>
|
||||
<summary><span class="heading">Recent log</span></summary>
|
||||
<pre id="logs"></pre>
|
||||
</details>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
@@ -132,6 +214,30 @@ const labels = {
|
||||
Running: ['Connected', 'ok'],
|
||||
};
|
||||
let shownQR = '';
|
||||
let hostsDirty = false; // the user is editing the Sunshine hosts
|
||||
let lastHosts = '';
|
||||
|
||||
// api performs a request to the daemon. A 401 means the page is locked.
|
||||
async function api(path, options) {
|
||||
options = options || {};
|
||||
options.cache = 'no-store';
|
||||
options.headers = Object.assign({'X-PS5-Tailscale': '1'}, options.headers || {});
|
||||
const r = await fetch(path, options);
|
||||
if (r.status === 401) { showLocked(true); throw new Error('locked'); }
|
||||
return r;
|
||||
}
|
||||
|
||||
function showLocked(locked) {
|
||||
$('lockpanel').classList.toggle('hidden', !locked);
|
||||
$('content').classList.toggle('hidden', locked);
|
||||
if (locked) $('updatepanel').classList.add('hidden');
|
||||
}
|
||||
|
||||
function show(id, text) {
|
||||
const el = $(id);
|
||||
el.textContent = text || '';
|
||||
el.classList.toggle('hidden', !text);
|
||||
}
|
||||
|
||||
function row(dl, name, value, mono) {
|
||||
const dt = document.createElement('dt'); dt.textContent = name;
|
||||
@@ -141,34 +247,59 @@ function row(dl, name, value, mono) {
|
||||
dl.append(dt, dd);
|
||||
}
|
||||
|
||||
function hostRow(host, port) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'hostrow';
|
||||
const h = document.createElement('input');
|
||||
h.type = 'text'; h.className = 'host'; h.placeholder = 'Device name or address'; h.value = host || '';
|
||||
h.setAttribute('list', 'peernames'); h.setAttribute('aria-label', 'Sunshine host'); h.autocomplete = 'off';
|
||||
const p = document.createElement('input');
|
||||
p.type = 'number'; p.className = 'port'; p.placeholder = '47989'; p.min = 1029; p.max = 65514;
|
||||
p.value = port && port !== 47989 ? port : ''; p.setAttribute('aria-label', 'Sunshine port');
|
||||
const x = document.createElement('button');
|
||||
x.type = 'button'; x.className = 'small'; x.textContent = 'Remove';
|
||||
x.onclick = () => { div.remove(); hostsDirty = true; };
|
||||
h.oninput = p.oninput = () => { hostsDirty = true; };
|
||||
div.append(h, p, x);
|
||||
return div;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
let s;
|
||||
try {
|
||||
s = await (await fetch('/api/status', {cache: 'no-store'})).json();
|
||||
s = await (await api('/api/status')).json();
|
||||
} catch (e) {
|
||||
$('state').textContent = 'Not responding';
|
||||
$('dot').className = 'dot bad';
|
||||
if (e.message !== 'locked') {
|
||||
$('state').textContent = 'Not responding';
|
||||
$('dot').className = 'dot bad';
|
||||
}
|
||||
return;
|
||||
}
|
||||
showLocked(false);
|
||||
const [label, cls] = labels[s.state] || [s.state, 'warn'];
|
||||
$('state').textContent = label;
|
||||
$('dot').className = 'dot ' + cls;
|
||||
$('version').textContent = 'ps5-tailscale ' + s.version;
|
||||
|
||||
$('updatepanel').classList.toggle('hidden', !s.latestVersion);
|
||||
if (s.latestVersion) {
|
||||
$('updatetext').textContent = 'Version ' + s.latestVersion + ' is available. ';
|
||||
$('updatelink').href = s.updateURL;
|
||||
}
|
||||
|
||||
const dl = $('facts');
|
||||
dl.replaceChildren();
|
||||
row(dl, 'Name', s.dnsName || s.hostname);
|
||||
if (s.ips.length) row(dl, 'Tailnet address', s.ips.join(', '), true);
|
||||
if (s.tailnet) row(dl, 'Tailnet', s.tailnet);
|
||||
if (s.proxy) row(dl, 'HTTP proxy', s.proxy, true);
|
||||
if (s.ips.length) row(dl, 'Reachable from tailnet', 'every open TCP port' + (s.udpPorts.length ? '; UDP ' + s.udpPorts.join(', ') + ' (Remote Play)' : ''));
|
||||
if (s.proxy) row(dl, 'HTTP proxy', s.proxy, true);
|
||||
if (s.priority === 'high') row(dl, 'Priority', 'High');
|
||||
|
||||
const health = $('health');
|
||||
health.replaceChildren(...(s.health || []).map(h => { const li = document.createElement('li'); li.textContent = h; return li; }));
|
||||
health.classList.toggle('hidden', !(s.health || []).length);
|
||||
|
||||
$('error').textContent = s.error || '';
|
||||
$('error').classList.toggle('hidden', !s.error);
|
||||
show('error', s.error);
|
||||
|
||||
const needLogin = !!s.authURL;
|
||||
$('login').classList.toggle('hidden', !needLogin);
|
||||
@@ -190,59 +321,152 @@ async function refresh() {
|
||||
return tr;
|
||||
}));
|
||||
$('peerpanel').classList.toggle('hidden', !s.peers.length);
|
||||
$('peercount').textContent = s.peers.filter(p => p.online).length + ' online of ' + s.peers.length;
|
||||
|
||||
// Game streaming: offer the tailnet's devices, keep the user's selection
|
||||
// while they are choosing.
|
||||
$('streampanel').classList.toggle('hidden', s.state !== 'Running' && !s.sunshineHost);
|
||||
const sel = $('sunshine-select');
|
||||
const options = ['', ...s.peers.map(p => p.name)];
|
||||
if (s.sunshineHost && !options.includes(s.sunshineHost)) options.push(s.sunshineHost);
|
||||
const signature = options.join('|') + '#' + s.sunshineHost;
|
||||
if (sel.dataset.signature !== signature && document.activeElement !== sel) {
|
||||
sel.replaceChildren(...options.map(name => {
|
||||
const o = document.createElement('option');
|
||||
o.value = name;
|
||||
const peer = s.peers.find(p => p.name === name);
|
||||
o.textContent = name === '' ? 'None (off)' : name + (peer && !peer.online ? ' (offline)' : '');
|
||||
return o;
|
||||
}));
|
||||
sel.value = s.sunshineHost;
|
||||
sel.dataset.signature = signature;
|
||||
// Game streaming. The rows are only rebuilt from the daemon's state while
|
||||
// the user is not in the middle of editing them.
|
||||
$('streampanel').classList.toggle('hidden', s.state !== 'Running' && !s.sunshineHosts.length);
|
||||
$('peernames').replaceChildren(...s.peers.map(p => { const o = document.createElement('option'); o.value = p.name; return o; }));
|
||||
const hostsNow = JSON.stringify(s.sunshineHosts);
|
||||
if (!hostsDirty && hostsNow !== lastHosts) {
|
||||
lastHosts = hostsNow;
|
||||
$('hostrows').replaceChildren(...s.sunshineHosts.map(h => hostRow(h.host, h.port)));
|
||||
}
|
||||
$('sunshine-state').textContent = s.sunshineHost
|
||||
? 'Forwarding 127.0.0.1 to ' + s.sunshineHost + ' (' + s.forwards.length + ' ports).'
|
||||
: 'Off.';
|
||||
$('sunshine-state').textContent = s.sunshineHosts.length
|
||||
? s.sunshineHosts.map(h => h.host + ': add ' + h.address + ' in Moonlight').join('. ') + '.'
|
||||
: 'No Sunshine host is forwarded.';
|
||||
|
||||
$('btn-lock').classList.toggle('hidden', !s.passwordSet);
|
||||
|
||||
if ($('logbox').open) {
|
||||
try { $('logs').textContent = await (await fetch('/api/logs', {cache: 'no-store'})).text(); } catch (e) {}
|
||||
try { $('logs').textContent = await (await api('/api/logs')).text(); } catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
async function act(path, confirmText) {
|
||||
async function act(path, confirmText, body) {
|
||||
if (confirmText && !confirm(confirmText)) return;
|
||||
const msg = $('actionmsg');
|
||||
msg.classList.add('hidden');
|
||||
show('actionmsg', ''); show('actionok', '');
|
||||
try {
|
||||
const r = await fetch(path, {method: 'POST', headers: {'X-PS5-Tailscale': '1'}});
|
||||
if (!r.ok) throw new Error((await r.text()).trim() || r.statusText);
|
||||
const options = {method: 'POST'};
|
||||
if (body !== undefined) { options.body = JSON.stringify(body); options.headers = {'Content-Type': 'application/json'}; }
|
||||
const r = await api(path, options);
|
||||
const text = (await r.text()).trim();
|
||||
if (!r.ok) throw new Error(text || r.statusText);
|
||||
return text;
|
||||
} catch (e) {
|
||||
msg.textContent = e.message;
|
||||
msg.classList.remove('hidden');
|
||||
if (e.message !== 'locked') show('actionmsg', e.message);
|
||||
} finally {
|
||||
refresh();
|
||||
}
|
||||
refresh();
|
||||
}
|
||||
|
||||
$('btn-login').onclick = () => act('/api/login');
|
||||
$('btn-logout').onclick = () => act('/api/logout', 'Log this PS5 out of your tailnet?');
|
||||
$('btn-quit').onclick = () => act('/api/quit', 'Stop Tailscale on this PS5? Send the payload again to start it.');
|
||||
$('btn-sunshine').onclick = async () => {
|
||||
await act('/api/sunshine?host=' + encodeURIComponent($('sunshine-select').value));
|
||||
$('sunshine-select').dataset.signature = '';
|
||||
$('btn-uninstall').onclick = async () => {
|
||||
const text = await act('/api/uninstall',
|
||||
'Remove Tailscale from this PS5?\n\nThis removes the home screen icon, logs the console out of your tailnet, deletes its settings and logs, and stops Tailscale.');
|
||||
if (text) show('actionok', text);
|
||||
};
|
||||
$('btn-uninstall').onclick = () => act('/api/uninstall',
|
||||
'Remove Tailscale from this PS5?\n\nThis logs the console out of your tailnet, deletes its settings and logs, and stops Tailscale.');
|
||||
$('btn-lock').onclick = async () => { await act('/api/lock'); showLocked(true); };
|
||||
|
||||
$('lockform').onsubmit = async ev => {
|
||||
ev.preventDefault();
|
||||
show('lockmsg', '');
|
||||
const r = await fetch('/api/auth', {
|
||||
method: 'POST',
|
||||
headers: {'X-PS5-Tailscale': '1', 'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({password: $('lockpw').value}),
|
||||
});
|
||||
if (!r.ok) { show('lockmsg', r.status === 403 ? 'Wrong password.' : (await r.text()).trim()); return; }
|
||||
$('lockpw').value = '';
|
||||
refresh();
|
||||
};
|
||||
|
||||
// Game streaming hosts.
|
||||
$('btn-addhost').onclick = () => { $('hostrows').append(hostRow('', 0)); hostsDirty = true; };
|
||||
$('btn-sunshine').onclick = async () => {
|
||||
const hosts = [];
|
||||
for (const div of $('hostrows').children) {
|
||||
const host = div.querySelector('.host').value.trim();
|
||||
const port = parseInt(div.querySelector('.port').value, 10) || 0;
|
||||
if (host) hosts.push({host: host, port: port});
|
||||
}
|
||||
hostsDirty = false; lastHosts = '';
|
||||
await act('/api/sunshine', '', hosts);
|
||||
};
|
||||
|
||||
// Settings.
|
||||
const ports = text => text.split(/[\s,]+/).filter(x => x).map(x => parseInt(x, 10));
|
||||
async function loadSettings() {
|
||||
let c;
|
||||
try { c = await (await api('/api/config')).json(); } catch (e) { return; }
|
||||
$('set-hostname').value = c.hostname;
|
||||
$('set-pwstate').textContent = c.passwordSet ? '(set)' : '(not set)';
|
||||
$('set-password').value = '';
|
||||
$('set-pwremove').checked = false;
|
||||
$('set-pwremove-row').classList.toggle('hidden', !c.passwordSet);
|
||||
$('set-udp').value = c.udpPorts.join(', ');
|
||||
$('set-blocked').value = c.blockedPorts.join(', ');
|
||||
$('set-forwards').value = c.forwards.map(f => f.proto + ' ' + f.listen + ' ' + f.target).join('\n');
|
||||
$('set-proxy').value = c.httpProxyAddr;
|
||||
$('set-priority').value = c.priority;
|
||||
$('set-webaddr').value = c.webAddr;
|
||||
$('set-updates').checked = c.checkUpdates;
|
||||
$('set-verbose').checked = c.verbose;
|
||||
}
|
||||
$('settingsbox').addEventListener('toggle', () => { if ($('settingsbox').open) loadSettings(); });
|
||||
if (location.hash === '#settings') $('settingsbox').open = true;
|
||||
$('settingsform').onsubmit = async ev => {
|
||||
ev.preventDefault();
|
||||
show('settingsmsg', ''); show('settingsok', '');
|
||||
const udp = ports($('set-udp').value), blocked = ports($('set-blocked').value);
|
||||
if (udp.concat(blocked).some(p => !(p >= 1 && p <= 65535))) { show('settingsmsg', 'Ports must be numbers from 1 to 65535.'); return; }
|
||||
const forwards = [];
|
||||
for (const line of $('set-forwards').value.split('\n').map(l => l.trim()).filter(l => l)) {
|
||||
const parts = line.split(/\s+/);
|
||||
if (parts.length !== 3) { show('settingsmsg', 'Each forward needs three parts: protocol, local address, target. Problem: ' + line); return; }
|
||||
forwards.push({proto: parts[0].toLowerCase(), listen: parts[1], target: parts[2]});
|
||||
}
|
||||
const c = {
|
||||
hostname: $('set-hostname').value.trim(),
|
||||
webAddr: $('set-webaddr').value.trim(),
|
||||
httpProxyAddr: $('set-proxy').value.trim(),
|
||||
forwards: forwards,
|
||||
udpPorts: udp,
|
||||
blockedPorts: blocked,
|
||||
priority: $('set-priority').value,
|
||||
checkUpdates: $('set-updates').checked,
|
||||
verbose: $('set-verbose').checked,
|
||||
};
|
||||
if ($('set-pwremove').checked) c.password = '';
|
||||
else if ($('set-password').value) c.password = $('set-password').value;
|
||||
try {
|
||||
const r = await api('/api/config', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(c)});
|
||||
if (!r.ok) throw new Error((await r.text()).trim() || r.statusText);
|
||||
const reply = await r.json();
|
||||
let text = 'Saved.';
|
||||
if (reply.restart && reply.restart.length) text += ' Takes effect when Tailscale is started again: ' + reply.restart.join(', ') + '.';
|
||||
show('settingsok', text);
|
||||
if (reply.problems && reply.problems.length) show('settingsmsg', reply.problems.join(' '));
|
||||
loadSettings();
|
||||
refresh();
|
||||
} catch (e) {
|
||||
if (e.message !== 'locked') show('settingsmsg', e.message);
|
||||
}
|
||||
};
|
||||
|
||||
$('logbox').addEventListener('toggle', refresh);
|
||||
|
||||
// The device list can be long. Remember whether it was left collapsed; the
|
||||
// browser may not allow storage, in which case it simply starts open.
|
||||
try {
|
||||
if (localStorage.getItem('peersCollapsed') === '1') $('peerbox').open = false;
|
||||
} catch (e) {}
|
||||
$('peerbox').addEventListener('toggle', () => {
|
||||
try { localStorage.setItem('peersCollapsed', $('peerbox').open ? '0' : '1'); } catch (e) {}
|
||||
});
|
||||
|
||||
refresh();
|
||||
setInterval(refresh, 3000);
|
||||
</script>
|
||||
|
||||
+14
-2
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -48,19 +49,24 @@ type udpRelay struct {
|
||||
sock net.PacketConn
|
||||
closed bool
|
||||
flows map[string]*udpFlow
|
||||
|
||||
ended atomic.Bool // the read loop has returned
|
||||
}
|
||||
|
||||
// startUDPRelay opens the listening socket and relays until stop is called.
|
||||
func startUDPRelay(cfg udpRelayConfig) (stop func(), err error) {
|
||||
func startUDPRelay(cfg udpRelayConfig) (*udpRelay, error) {
|
||||
sock, err := cfg.listen()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r := &udpRelay{cfg: cfg, sock: sock, flows: map[string]*udpFlow{}}
|
||||
go r.readLoop()
|
||||
return r.stop, nil
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// running reports whether the relay is still reading from its socket.
|
||||
func (r *udpRelay) running() bool { return !r.ended.Load() }
|
||||
|
||||
func (r *udpRelay) stop() {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
@@ -77,6 +83,7 @@ func (r *udpRelay) socket() (net.PacketConn, bool) {
|
||||
}
|
||||
|
||||
func (r *udpRelay) readLoop() {
|
||||
defer r.ended.Store(true)
|
||||
buf := make([]byte, 65535)
|
||||
for {
|
||||
sock, stopped := r.socket()
|
||||
@@ -88,6 +95,11 @@ func (r *udpRelay) readLoop() {
|
||||
if _, stopped := r.socket(); stopped {
|
||||
return
|
||||
}
|
||||
if errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) {
|
||||
// Whatever provided the socket has shut down (Tailscale
|
||||
// stopping, for one). There is nothing to reopen.
|
||||
return
|
||||
}
|
||||
r.cfg.logf("%s: %v; reopening", r.cfg.name, err)
|
||||
sock.Close()
|
||||
time.Sleep(time.Second)
|
||||
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The daemon asks GitHub now and then whether a newer release exists, so that
|
||||
// the status page can say so. It never downloads or installs anything.
|
||||
|
||||
const releasesAPI = "https://api.github.com/repos/holdmysocks/ps5-tailscale/releases/latest"
|
||||
|
||||
type releaseInfo struct {
|
||||
Version string // without the leading "v"
|
||||
URL string
|
||||
}
|
||||
|
||||
// parseVersion reads "v1.2.3" or "1.2.3-dev" as its three numbers.
|
||||
func parseVersion(s string) (v [3]int, ok bool) {
|
||||
s = strings.TrimPrefix(strings.TrimSpace(s), "v")
|
||||
if i := strings.IndexAny(s, "-+ "); i >= 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
parts := strings.Split(s, ".")
|
||||
if len(parts) != 3 {
|
||||
return v, false
|
||||
}
|
||||
for i, p := range parts {
|
||||
n, err := strconv.Atoi(p)
|
||||
if err != nil || n < 0 {
|
||||
return v, false
|
||||
}
|
||||
v[i] = n
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
|
||||
// newerVersion reports whether latest is a later release than current.
|
||||
func newerVersion(current, latest string) bool {
|
||||
c, ok1 := parseVersion(current)
|
||||
l, ok2 := parseVersion(latest)
|
||||
if !ok1 || !ok2 {
|
||||
return false
|
||||
}
|
||||
for i := range c {
|
||||
if l[i] != c[i] {
|
||||
return l[i] > c[i]
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func fetchLatestRelease(ctx context.Context, url string) (releaseInfo, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return releaseInfo{}, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "ps5-tailscale/"+version)
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return releaseInfo{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return releaseInfo{}, &httpStatusError{resp.Status}
|
||||
}
|
||||
var rel struct {
|
||||
TagName string `json:"tag_name"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
|
||||
return releaseInfo{}, err
|
||||
}
|
||||
return releaseInfo{Version: strings.TrimPrefix(rel.TagName, "v"), URL: rel.HTMLURL}, nil
|
||||
}
|
||||
|
||||
type httpStatusError struct{ status string }
|
||||
|
||||
func (e *httpStatusError) Error() string { return "unexpected response: " + e.status }
|
||||
|
||||
// watchForUpdates checks shortly after start and then twice a day, for as
|
||||
// long as the setting is on.
|
||||
func (d *daemon) watchForUpdates(ctx context.Context) {
|
||||
timer := time.NewTimer(time.Minute)
|
||||
defer timer.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-timer.C:
|
||||
}
|
||||
timer.Reset(12 * time.Hour)
|
||||
|
||||
d.mu.Lock()
|
||||
enabled := d.cfg.CheckUpdates
|
||||
d.mu.Unlock()
|
||||
if !enabled {
|
||||
d.mu.Lock()
|
||||
d.latest = releaseInfo{}
|
||||
d.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
reqCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
rel, err := fetchLatestRelease(reqCtx, releasesAPI)
|
||||
cancel()
|
||||
if err != nil {
|
||||
d.logf("update check: %v", err)
|
||||
timer.Reset(time.Hour)
|
||||
continue
|
||||
}
|
||||
d.mu.Lock()
|
||||
known := d.latest.Version
|
||||
d.latest = rel
|
||||
d.mu.Unlock()
|
||||
if d.debug != nil {
|
||||
d.debug.Printf("update check: the latest release is %s", rel.Version)
|
||||
}
|
||||
if rel.Version != known && newerVersion(version, rel.Version) {
|
||||
d.logf("a newer release is available: %s (running %s)", rel.Version, version)
|
||||
}
|
||||
}
|
||||
}
|
||||
+130
-59
@@ -18,10 +18,15 @@ import (
|
||||
//go:embed status.html
|
||||
var statusHTML []byte
|
||||
|
||||
// The status page has no login: like the other services on a jailbroken
|
||||
// console it trusts the local network. State-changing requests must carry
|
||||
// this header, which a web page on another origin cannot send, so a stray
|
||||
// link or image tag cannot log the console out.
|
||||
// faviconPNG is the logo from the home screen icon (appicon/icon0.png)
|
||||
// without its text, 128x128, for the browser tab.
|
||||
//
|
||||
//go:embed favicon.png
|
||||
var faviconPNG []byte
|
||||
|
||||
// State-changing requests must carry this header, which a web page on
|
||||
// another origin cannot send, so a stray link or image tag cannot log the
|
||||
// console out. Who may use the page at all is decided in auth.go.
|
||||
const apiHeader = "X-PS5-Tailscale"
|
||||
|
||||
type peerInfo struct {
|
||||
@@ -31,6 +36,14 @@ type peerInfo struct {
|
||||
Online bool `json:"online"`
|
||||
}
|
||||
|
||||
// sunshineInfo is a forwarded Sunshine host as the status page shows it.
|
||||
type sunshineInfo struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
// Address is what to enter in a Moonlight client on the console.
|
||||
Address string `json:"address"`
|
||||
}
|
||||
|
||||
type statusInfo struct {
|
||||
Version string `json:"version"`
|
||||
State string `json:"state"`
|
||||
@@ -43,50 +56,66 @@ type statusInfo struct {
|
||||
Health []string `json:"health,omitempty"`
|
||||
Peers []peerInfo `json:"peers"`
|
||||
Proxy string `json:"proxy,omitempty"`
|
||||
// SunshineHost and Forwards describe the local forwards.
|
||||
SunshineHost string `json:"sunshineHost"`
|
||||
Forwards []string `json:"forwards"`
|
||||
// SunshineHosts and Forwards describe the local forwards.
|
||||
SunshineHosts []sunshineInfo `json:"sunshineHosts"`
|
||||
Forwards []string `json:"forwards"`
|
||||
// UDPPorts are the console's UDP ports reachable from the tailnet.
|
||||
UDPPorts []uint16 `json:"udpPorts"`
|
||||
Uptime int64 `json:"uptimeSeconds"`
|
||||
Priority string `json:"priority"`
|
||||
// PasswordSet says whether the page is password protected.
|
||||
PasswordSet bool `json:"passwordSet"`
|
||||
// LatestVersion and UpdateURL are set when a newer release exists.
|
||||
LatestVersion string `json:"latestVersion,omitempty"`
|
||||
UpdateURL string `json:"updateURL,omitempty"`
|
||||
Uptime int64 `json:"uptimeSeconds"`
|
||||
}
|
||||
|
||||
func (d *daemon) serveWeb(ln net.Listener) {
|
||||
// webHandler builds the status page and its API.
|
||||
func (d *daemon) webHandler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Open to everyone who can reach the page: the page itself (which shows
|
||||
// nothing until its API answers), the icon, and what a new instance
|
||||
// needs to recognise this one.
|
||||
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Write(statusHTML)
|
||||
})
|
||||
favicon := func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
w.Header().Set("Cache-Control", "max-age=86400")
|
||||
w.Write(faviconPNG)
|
||||
}
|
||||
mux.HandleFunc("GET /favicon.png", favicon)
|
||||
mux.HandleFunc("GET /favicon.ico", favicon) // what browsers ask for unprompted
|
||||
mux.HandleFunc("GET /api/ping", func(w http.ResponseWriter, r *http.Request) {
|
||||
io.WriteString(w, "ps5-tailscale "+version+"\n")
|
||||
})
|
||||
mux.HandleFunc("GET /api/status", d.handleStatus)
|
||||
mux.HandleFunc("GET /api/logs", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
switch {
|
||||
case r.URL.Query().Get("full") == "1":
|
||||
// The end of the log file itself.
|
||||
writeFileTail(w, filepath.Join(dataDir, "tailscale.log"), 512<<10)
|
||||
return
|
||||
case r.URL.Query().Get("debug") == "1":
|
||||
// The end of the debug log, which includes Tailscale's own messages.
|
||||
writeFileTail(w, filepath.Join(dataDir, "tailscale-debug.log"), 1<<20)
|
||||
return
|
||||
case r.URL.Query().Get("debug") == "old":
|
||||
writeFileTail(w, filepath.Join(dataDir, "tailscale-debug.log.old"), 1<<20)
|
||||
return
|
||||
}
|
||||
io.WriteString(w, strings.Join(recentLogs.snapshot(), "\n")+"\n")
|
||||
})
|
||||
mux.HandleFunc("GET /qr.png", d.handleQR)
|
||||
mux.HandleFunc("POST /api/login", d.guard(d.handleLogin))
|
||||
mux.HandleFunc("POST /api/logout", d.guard(d.handleLogout))
|
||||
mux.HandleFunc("POST /api/quit", d.guard(d.handleQuit))
|
||||
mux.HandleFunc("POST /api/uninstall", d.guard(d.handleUninstall))
|
||||
mux.HandleFunc("POST /api/sunshine", d.guard(d.handleSunshine))
|
||||
mux.HandleFunc("POST /api/auth", d.guard(d.handleAuth))
|
||||
mux.HandleFunc("POST /api/lock", d.guard(d.handleLock))
|
||||
|
||||
srv := &http.Server{Handler: mux, ReadHeaderTimeout: 10 * time.Second}
|
||||
// Everything else needs the password, if one is set.
|
||||
mux.HandleFunc("GET /api/status", d.protect(d.handleStatus))
|
||||
mux.HandleFunc("GET /api/logs", d.protect(d.handleLogs))
|
||||
mux.HandleFunc("GET /qr.png", d.protect(d.handleQR))
|
||||
mux.HandleFunc("GET /api/config", d.protect(d.handleGetConfig))
|
||||
for path, h := range map[string]http.HandlerFunc{
|
||||
"/api/config": d.handleSetConfig,
|
||||
"/api/login": d.handleLogin,
|
||||
"/api/logout": d.handleLogout,
|
||||
"/api/quit": d.handleQuit,
|
||||
"/api/uninstall": d.handleUninstall,
|
||||
"/api/sunshine": d.handleSunshine,
|
||||
} {
|
||||
mux.HandleFunc("POST "+path, d.protect(d.guard(h)))
|
||||
}
|
||||
return mux
|
||||
}
|
||||
|
||||
// serveWeb serves the status page on one listener.
|
||||
func (d *daemon) serveWeb(ln net.Listener, h http.Handler) {
|
||||
srv := &http.Server{Handler: h, ReadHeaderTimeout: 10 * time.Second}
|
||||
if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed && !d.stopping() {
|
||||
d.logf("web UI stopped: %v", err)
|
||||
}
|
||||
@@ -126,20 +155,47 @@ func (d *daemon) guard(h http.HandlerFunc) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func (d *daemon) handleLogs(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
switch {
|
||||
case r.URL.Query().Get("full") == "1":
|
||||
// The end of the log file itself.
|
||||
writeFileTail(w, filepath.Join(dataDir, "tailscale.log"), 512<<10)
|
||||
case r.URL.Query().Get("debug") == "1":
|
||||
// The end of the debug log, which includes Tailscale's own messages.
|
||||
writeFileTail(w, filepath.Join(dataDir, "tailscale-debug.log"), 1<<20)
|
||||
case r.URL.Query().Get("debug") == "old":
|
||||
writeFileTail(w, filepath.Join(dataDir, "tailscale-debug.log.old"), 1<<20)
|
||||
default:
|
||||
io.WriteString(w, strings.Join(recentLogs.snapshot(), "\n")+"\n")
|
||||
}
|
||||
}
|
||||
|
||||
func (d *daemon) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
d.mu.Lock()
|
||||
info := statusInfo{
|
||||
Version: version,
|
||||
State: d.state,
|
||||
AuthURL: d.authURL,
|
||||
Error: d.lastErr,
|
||||
Hostname: d.cfg.Hostname,
|
||||
Proxy: d.cfg.HTTPProxyAddr,
|
||||
Uptime: int64(time.Since(d.started).Seconds()),
|
||||
IPs: []string{},
|
||||
Peers: []peerInfo{},
|
||||
Version: version,
|
||||
State: d.state,
|
||||
AuthURL: d.authURL,
|
||||
Error: d.lastErr,
|
||||
Hostname: d.cfg.Hostname,
|
||||
Proxy: d.cfg.HTTPProxyAddr,
|
||||
Priority: priorityLow,
|
||||
PasswordSet: d.cfg.PasswordHash != "",
|
||||
Uptime: int64(time.Since(d.started).Seconds()),
|
||||
IPs: []string{},
|
||||
Peers: []peerInfo{},
|
||||
SunshineHosts: []sunshineInfo{},
|
||||
}
|
||||
if d.cfg.Priority == priorityHigh {
|
||||
info.Priority = priorityHigh
|
||||
}
|
||||
for _, h := range d.cfg.SunshineHosts {
|
||||
info.SunshineHosts = append(info.SunshineHosts, sunshineInfo{Host: h.Host, Port: h.basePort(), Address: h.clientAddress()})
|
||||
}
|
||||
if newerVersion(version, d.latest.Version) {
|
||||
info.LatestVersion, info.UpdateURL = d.latest.Version, d.latest.URL
|
||||
}
|
||||
info.SunshineHost = d.cfg.SunshineHost
|
||||
d.mu.Unlock()
|
||||
info.UDPPorts = []uint16{}
|
||||
if d.udp != nil {
|
||||
@@ -254,23 +310,32 @@ func (d *daemon) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
io.WriteString(w, "ok\n")
|
||||
}
|
||||
|
||||
// handleSunshine sets (or with an empty host, clears) the Sunshine host whose
|
||||
// streaming ports are forwarded from 127.0.0.1, saves the config and applies
|
||||
// it without a restart.
|
||||
// handleSunshine replaces the list of Sunshine hosts whose streaming ports are
|
||||
// forwarded from 127.0.0.1, saves the config and applies it at once.
|
||||
func (d *daemon) handleSunshine(w http.ResponseWriter, r *http.Request) {
|
||||
host := strings.TrimSpace(r.URL.Query().Get("host"))
|
||||
if !validHostName(host) {
|
||||
http.Error(w, "that does not look like a host name or address", http.StatusBadRequest)
|
||||
var hosts []sunshineHost
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&hosts); err != nil {
|
||||
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
for i := range hosts {
|
||||
hosts[i].Host = strings.TrimSpace(hosts[i].Host)
|
||||
if hosts[i].Port == sunshineDefaultPort {
|
||||
hosts[i].Port = 0
|
||||
}
|
||||
}
|
||||
if err := validateSunshineHosts(hosts); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
d.mu.Lock()
|
||||
d.cfg.SunshineHost = host
|
||||
d.cfg.SunshineHosts = hosts
|
||||
cfg := d.cfg
|
||||
d.mu.Unlock()
|
||||
if err := saveConfig(d.cfgPath, cfg); err != nil {
|
||||
d.logf("saving config: %v", err)
|
||||
}
|
||||
d.logf("sunshine host set to %q", host)
|
||||
d.logf("sunshine hosts set to %v", hosts)
|
||||
if err := d.fwd.set(d.localForwardRules()); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
@@ -302,28 +367,34 @@ func (d *daemon) stop() {
|
||||
d.quitOnce.Do(func() { close(d.quit) })
|
||||
}
|
||||
|
||||
// handleUninstall logs the console out of the tailnet and stops the daemon,
|
||||
// which deletes its data directory (login, settings, logs) on the way out.
|
||||
// The payload file itself is wherever the user keeps it, and the home screen
|
||||
// icon can only be deleted from the home screen.
|
||||
// handleUninstall takes the home screen icon away, logs the console out of
|
||||
// the tailnet and stops the daemon, which deletes its data directory (login,
|
||||
// settings, logs) on the way out. The payload file itself is wherever the
|
||||
// user keeps it.
|
||||
func (d *daemon) handleUninstall(w http.ResponseWriter, r *http.Request) {
|
||||
d.logf("uninstall requested from the status page")
|
||||
iconNote := "The home screen icon was removed."
|
||||
if err := removeHomeIcon(); err != nil {
|
||||
d.logf("uninstall: home screen icon: %v", err)
|
||||
iconNote = "The home screen icon could not be removed (" + err.Error() + "); delete it from the home screen."
|
||||
}
|
||||
if d.lc != nil {
|
||||
if err := d.lc.Logout(r.Context()); err != nil {
|
||||
d.logf("uninstall: logout: %v", err)
|
||||
}
|
||||
}
|
||||
d.logf("uninstall requested from the status page")
|
||||
d.mu.Lock()
|
||||
d.removeDataOnExit = true
|
||||
d.mu.Unlock()
|
||||
notify("Tailscale was removed from this PS5.")
|
||||
io.WriteString(w, "uninstalled\n")
|
||||
io.WriteString(w, "Tailscale was removed from this PS5. "+iconNote+"\n")
|
||||
d.stop()
|
||||
}
|
||||
|
||||
// stopRunningInstance asks an instance that is already serving the status
|
||||
// page to exit and waits for the port to become free. It reports whether
|
||||
// there was one.
|
||||
// there was one. The request comes from the console itself, so it needs no
|
||||
// password.
|
||||
func stopRunningInstance(webAddr string) bool {
|
||||
_, port, err := net.SplitHostPort(webAddr)
|
||||
if err != nil {
|
||||
|
||||
Reference in new issue
Block a user