Receive files with Taildrop; install signed updates from the status page

Taildrop: files sent to the console from the user's other devices are moved
from Tailscale's holding area to /data/tailscale/received (a setting),
announced on screen and listed on the status page with download links.

Updates: the status page can install a newer release when asked to. A
release carries tailscale.elf.sig, an Ed25519 signature over its version
and SHA-256; the daemon installs only what verifies against the public key
built into it, is the version the release claims and is newer than itself.
The payload is handed to the ELF loader, and the copy named by the new
payloadPath setting is replaced as well.

- tsd/relsig, tsd/cmd/signrelease: signing, verifying, and keeping the key
  (keygen, passphrase-protected backup and restore).
- tools/make-release.ps1 builds, signs and checksums a release.
- Files are no longer copied to sockets with sendfile, which the PS5
  kernel refuses.
This commit is contained in:
holdmysocks committed 2026-10-05 08:52:31 -04:00
1 parent bc19d3c251
commit 02fc73a04d
20 files changed
+1774 -12

No files matched your search

+43 -5
View File
@@ -31,7 +31,8 @@ VPN here. It runs inside one process:
directly. They can through a *local forward* (see
[game streaming](#game-streaming-moonlight-to-sunshine) and
[configuration](#configuration)).
- No subnet routing, Tailscale SSH, Taildrop or Funnel.
- No subnet routing, Tailscale SSH or Funnel. Taildrop works for receiving
files, not for sending them.
- The console cannot use an exit node, and it does not offer itself as one.
Offering one is doable (it needs no tunnel device), but the PS5 would make
a terrible exit node: every packet would pass through this one low-priority
@@ -124,13 +125,36 @@ Notes:
sleeps, so it is not on the tailnet then. Tailscale carries on by itself
once the console is awake again.
### Sending files to the console (Taildrop)
Send a file to the console from any of your own devices with Tailscale's
Taildrop: the share menu on a phone, `Send with Tailscale` on a desktop, or
`tailscale file cp <file> <console name>:`. The file lands in
`/data/tailscale/received` on the console (changeable in the settings), a
notification says so, and the status page lists it under **Received files**
with a download link. A file with the same name as an earlier one gets a
number; nothing is overwritten.
Taildrop only works between devices logged in as the same user; that is
Tailscale's rule. Sending files from the console is not implemented.
### 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 the game streaming hosts, the settings,
and buttons for logging out, stopping and uninstalling. It also says when a
newer release is available.
and buttons for logging out, stopping and uninstalling.
**Updates.** When a newer release exists the page says so, and the console
shows a notification once. **Install** downloads the release, checks that it
is signed with this project's release key and is the version it claims to be,
and starts it; the login and settings are kept. Nothing is ever installed
without that button being pressed. It needs an ELF loader on port 9021, like
sending the payload by hand does. If you keep `tailscale.elf` in a payload
manager or autoloader so that it starts with the console, put that file's
path under **Payload file to keep up to date** in the settings, for example
`/data/pldmgr/payloads/Tailscale/tailscale.elf`; the update then replaces
that copy as well. Otherwise the old version is back after the next restart.
**Devices.** The list is grouped into your tailnet's devices and devices
shared with you, and marks the ones that can be used as an exit node. It can
@@ -154,7 +178,8 @@ every device except the console itself. If you forget it, delete the
**Settings.** The name on the tailnet, the password, who on the tailnet may
connect, which UDP ports are reachable and which TCP ports are not, extra
forwards, the HTTP proxy, the priority, and update checks. Most take effect when saved; the page says which
forwards, the HTTP proxy, the folder for received files, the payload file to
keep up to date, 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)
@@ -237,6 +262,8 @@ optional.
"udpPorts": [9295, 9296, 9297, 9302],
"blockedPorts": [],
"allowFrom": "",
"receiveDir": "/data/tailscale/received",
"payloadPath": "",
"priority": "",
"checkUpdates": true,
"verbose": false
@@ -256,6 +283,8 @@ optional.
| `udpPorts` | The console's UDP ports reachable from the tailnet. Default `[9295, 9296, 9297, 9302]` (Remote Play). `[]` turns inbound UDP off. |
| `blockedPorts` | Local TCP ports that are never exposed to the tailnet. |
| `allowFrom` | `"own"` lets only devices logged in as the same user as the console connect; other users' devices and devices shared into the tailnet are turned away. Anything else is the default: every device your tailnet's access rules allow. If the console is tagged, `"own"` means the devices of its own tailnet. |
| `receiveDir` | Where files sent to the console with Taildrop are put. |
| `payloadPath` | The copy of `tailscale.elf` that is started with the console, if there is one. An update installed from the status page replaces it. Empty: none. |
| `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 and announce it once on the console. Nothing is downloaded. |
| `verbose` | Put Tailscale's own log in the main log as well. |
@@ -268,6 +297,7 @@ Files on the console:
| `/data/tailscale/state/` | Tailscale's state, including the login. |
| `/data/tailscale/tailscale.log` | The daemon's log, rotated at 2 MB. |
| `/data/tailscale/tailscale-debug.log` | Tailscale's detailed log, up to 4 MB plus one older file. |
| `/data/tailscale/received/` | Files received with Taildrop. |
| `/data/tailscale/launcher.log` | What the payload did before Tailscale itself started. The place to look when nothing seems to happen. |
| `/data/tailscale/icon-installed` | Marks that the home screen icon was added. Delete it to have the icon added again on the next start. |
| `/data/tailscale/icon-helper.elf` | The small payload that adds and removes the icon. |
@@ -324,6 +354,12 @@ Left to do by hand:
- 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.
- An update is only installed when **Install** is pressed, and only if it
carries a valid signature made with the project's release key, which is
not kept on GitHub. A release put up by someone who got into the GitHub
account, or changed on the way, is refused.
- Files received with Taildrop come only from devices logged in as the same
user. The status page hands them out as downloads and never displays them.
## Resource use
@@ -340,7 +376,9 @@ the tailnet, a ProsperoLight stream from a Sunshine host through the forward,
two forwarded hosts on different ports (with a stand-in for the second), the
HTTP proxy, adding and removing the home screen icon, the password from the
LAN and the tailnet, changing settings from the page, both priority settings,
the update check, limiting connections to your own devices (with the
the update check, installing an update from the page (rehearsed with a test
release, including replacing a second copy of the payload), receiving files
with Taildrop, limiting connections to your own devices (with the
console's owner's devices only; a refusal has not been seen for real), a
short stay in rest mode (about a minute: the same process
carried on and was back on the tailnet within a second of waking).
+55
View File
@@ -58,6 +58,61 @@ These folders are not in the repository.
`-HomeIcon` also builds `appicon\` into `out\appicon.elf` and embeds it in
the launcher.
## Making a release
```powershell
.\tools\make-release.ps1 -Version 1.2.3
```
builds the payload and puts three files in `out\release-1.2.3`:
`tailscale.elf`, its signature `tailscale.elf.sig`, and `SHA256SUMS.txt`.
Attach all three to the GitHub release, and tag it `v1.2.3`. The status
page's **Install** button only offers a release that has the first two, and
only installs it if the signature is good and is for that very version.
### The signing key
Releases are signed with an Ed25519 key. Its public half is
`updatePublicKey` in `tsd\selfupdate.go`; the private half is a small file
that stays out of the repository:
```
%APPDATA%\ps5-tailscale\release-signing.key (Windows)
~/.config/ps5-tailscale/release-signing.key (Linux)
```
`PS5TS_SIGNING_KEY` names another location. The file is not encrypted, so
that a release can be made without typing anything; treat it like an SSH
key. The tool that manages it is `tsd\cmd\signrelease`, run from `tsd`:
```powershell
go run ./cmd/signrelease pubkey # show the public key
go run ./cmd/signrelease backup -out Z:\keys\ps5-tailscale-signing.backup
go run ./cmd/signrelease restore -in Z:\keys\ps5-tailscale-signing.backup
```
- **Back it up.** `backup` writes a copy encrypted with a passphrase you
type, meant for a NAS, a USB stick or a password manager. Without the
passphrase the copy is useless, to you as well, so keep the passphrase
somewhere other than next to the file.
- **Building on another PC.** Copy the backup there and run `restore`. It
refuses to overwrite a key that is already present.
- **If the key is lost,** consoles running releases made with it can no
longer install updates from the page: a release signed with a new key is
refused. Their owners have to send the new payload by hand once.
- **If the key leaks,** make a new one (`keygen`, after moving the old file
away), put its public half in `selfupdate.go` and release. The same
one-time manual update applies.
- **A fork** that publishes its own releases needs its own key and its own
`releasesAPI` in `tsd\update.go`.
Test builds can use a throwaway key and a local "release":
```powershell
.\tools\build-payload.ps1 -GoDir tsd -Name test -Version 0.0.1 -HomeIcon `
-Set 'main.updatePublicKey=<base64>', 'main.releasesAPI=http://127.0.0.1:18099/latest.json'
```
## Sending to the console
```powershell
+20
View File
@@ -65,6 +65,22 @@ way is a failure in the SDK's crt, which runs before any of this.
twice a day, compared with the running version. A newer release is shown
on the page and announced once on the console; the announced version is
kept in `/data/tailscale/update-notified`.
- Installing an update (`selfupdate.go`, `relsig/`): on request only. The
release's `tailscale.elf.sig` names a version and a SHA-256 and carries an
Ed25519 signature over both. The daemon checks the signature against the
public key built into it, that the version is the release's and newer than
its own, downloads the payload to `/data/tailscale/update/`, compares the
hash, optionally replaces the copy named by `payloadPath` (temporary file,
then rename), and writes the payload to the ELF loader on 127.0.0.1:9021.
The new instance stops the old one as with any payload sent again, and
removes the download when it starts. The connection to the loader is kept
open until the old process exits, because it is the new payload's standard
output.
- Taildrop (`taildrop.go`): tsnet does not link Taildrop in; importing
`tailscale.com/feature/taildrop` does. Received files wait in
`state/files/<login>-uid-<n>/`. The daemon long-polls for them, copies each
to `receiveDir` under a name that does not exist yet, and deletes it from
the holding area.
- Who may connect (`access.go`): with `allowFrom` set to `own`, the TCP
handler and the UDP relays ask Tailscale who the sender is (WhoIs) and
serve only nodes of the same user as the console, from the console's own
@@ -155,6 +171,10 @@ apply `SOCK_NONBLOCK`/`SOCK_CLOEXEC` with `fcntl`.
- The SDK's `kernel_mprotect()` rewrites the protection of the whole kernel
map entry that contains the address. The loader first splits the text range
off with an ordinary `mprotect()`.
- `sendfile` on a socket fails with "socket is not connected". Go uses it
whenever a file is copied straight to a TCP connection (`io.Copy(conn,
file)`, `http.ServeContent` with a file), so the daemon hides the file
behind a plain reader in those places.
- When the network is reconfigured (connection settings changed, Wi-Fi to
Ethernet), listening sockets fail with errno 163, a Sony-specific code, and
do not recover. The daemon's listeners reopen themselves
+2
View File
@@ -7,6 +7,7 @@ param(
[string]$Package = '.',
[string]$Tags = '',
[string]$Version = '', # sets main.version in the Go program
[string[]]$Set = @(), # extra Go variables, e.g. -Set main.releasesAPI=http://127.0.0.1:18099/latest.json
[string]$MaxProcs = '', # GOMAXPROCS for the Go program (launcher default: 4)
[string]$GoDebug = '', # GODEBUG value baked into the launcher
[int]$Watchdog = 0, # test builds: kill the process after this many seconds
@@ -28,6 +29,7 @@ $elf = Join-Path $out "$Name.elf"
$ldflags = @()
if (-not $KeepSymbols) { $ldflags += '-s', '-w' }
if ($Version) { $ldflags += "-X main.version=$Version" }
foreach ($s in $Set) { $ldflags += "-X $s" }
$goArgs = @('build', '-buildmode=pie', '-trimpath', "-ldflags=$($ldflags -join ' ')", '-o', $bin)
if ($Tags) { $goArgs += "-tags=$Tags" }
$goArgs += $Package
+43
View File
@@ -0,0 +1,43 @@
# Build the files of a release into out\release-<version>:
# tailscale.elf the payload
# tailscale.elf.sig its signature, which the status page's "Install" checks
# SHA256SUMS.txt
#
# .\tools\make-release.ps1 -Version 1.2.3
#
# Needs the release signing key on this machine (see docs/BUILDING.md).
param(
[Parameter(Mandatory = $true)][string]$Version
)
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'env.ps1')
if ($Version -notmatch '^\d+\.\d+\.\d+$') { throw "version must look like 1.2.3, not '$Version'" }
& (Join-Path $PSScriptRoot 'build-payload.ps1') -GoDir tsd -Name tailscale -Version $Version -HomeIcon
$rel = Join-Path $DevRoot "out\release-$Version"
New-Item -ItemType Directory -Force $rel | Out-Null
$elf = Join-Path $rel 'tailscale.elf'
Copy-Item (Join-Path $DevRoot 'out\tailscale.elf') $elf -Force
Push-Location (Join-Path $DevRoot 'tsd')
try {
go run ./cmd/signrelease sign -version $Version -file $elf
if ($LASTEXITCODE -ne 0) { throw 'signing failed' }
go run ./cmd/signrelease verify -file $elf
if ($LASTEXITCODE -ne 0) { throw 'the signature does not verify' }
# The payload must carry the public half of the key it was signed with,
# or consoles running it could never install the release after it.
$pub = go run ./cmd/signrelease pubkey
if (-not (Select-String -Path 'selfupdate.go' -SimpleMatch $pub -Quiet)) {
throw "tsd\selfupdate.go does not have this machine's public key ($pub) as updatePublicKey"
}
} finally { Pop-Location }
$hash = (Get-FileHash $elf -Algorithm SHA256).Hash.ToLower()
[IO.File]::WriteAllText((Join-Path $rel 'SHA256SUMS.txt'), "$hash tailscale.elf`n")
Get-ChildItem $rel | Select-Object Name, Length | Format-Table -AutoSize
Write-Host "release files are in $rel"
+382
View File
@@ -0,0 +1,382 @@
// Command signrelease manages the release signing key and signs payloads.
//
// go run ./cmd/signrelease keygen create the key (once)
// go run ./cmd/signrelease pubkey print the public key
// go run ./cmd/signrelease sign -version 1.2.3 -file tailscale.elf
// go run ./cmd/signrelease verify -file tailscale.elf
// go run ./cmd/signrelease backup -out FILE passphrase-protected copy
// go run ./cmd/signrelease restore -in FILE bring a backup onto this machine
//
// The key lives outside the repository, by default in the user's
// configuration directory; PS5TS_SIGNING_KEY names another file. It is kept
// unencrypted there so that releases can be made without typing anything.
// A backup is encrypted with a passphrase and is meant for somewhere else: a
// NAS, a USB stick, a password manager.
package main
import (
"crypto/aes"
"crypto/cipher"
"crypto/ed25519"
"crypto/pbkdf2"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"golang.org/x/term"
"ps5tailscale/relsig"
)
const (
keyHeading = "ps5-tailscale release signing key. Keep this file private."
backupHeading = "ps5-tailscale release signing key, encrypted backup."
kdfRounds = 600_000
)
func main() {
if len(os.Args) < 2 {
usage()
}
var err error
switch cmd, args := os.Args[1], os.Args[2:]; cmd {
case "keygen":
err = keygen(args)
case "pubkey":
err = pubkey(args)
case "sign":
err = sign(args)
case "verify":
err = verify(args)
case "backup":
err = backup(args)
case "restore":
err = restore(args)
default:
usage()
}
if err != nil {
fmt.Fprintln(os.Stderr, "signrelease:", err)
os.Exit(1)
}
}
func usage() {
fmt.Fprintln(os.Stderr, "usage: signrelease keygen | pubkey | sign -version V -file F | verify -file F | backup -out F | restore -in F")
os.Exit(2)
}
// keyPath is where the signing key is kept on this machine.
func keyPath() (string, error) {
if p := os.Getenv("PS5TS_SIGNING_KEY"); p != "" {
return p, nil
}
dir, err := os.UserConfigDir()
if err != nil {
return "", err
}
return filepath.Join(dir, "ps5-tailscale", "release-signing.key"), nil
}
func b64(b []byte) string { return base64.StdEncoding.EncodeToString(b) }
func writeKey(path string, key ed25519.PrivateKey) error {
if _, err := os.Stat(path); err == nil {
return fmt.Errorf("%s already exists; refusing to overwrite a signing key", path)
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
text := fmt.Sprintf("%s\nprivate: %s\npublic: %s\n", keyHeading, b64(key.Seed()), b64(key.Public().(ed25519.PublicKey)))
return os.WriteFile(path, []byte(text), 0o600)
}
func loadKey() (ed25519.PrivateKey, string, error) {
path, err := keyPath()
if err != nil {
return nil, "", err
}
b, err := os.ReadFile(path)
if err != nil {
return nil, path, fmt.Errorf("no signing key (%w); run keygen, or restore a backup", err)
}
fields, err := relsig.ParseFields(b)
if err != nil {
return nil, path, err
}
seed, err := base64.StdEncoding.DecodeString(fields["private"])
if err != nil || len(seed) != ed25519.SeedSize {
return nil, path, fmt.Errorf("%s is not a signing key", path)
}
return ed25519.NewKeyFromSeed(seed), path, nil
}
func keygen(args []string) error {
path, err := keyPath()
if err != nil {
return err
}
_, key, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return err
}
if err := writeKey(path, key); err != nil {
return err
}
fmt.Printf("signing key written to %s\npublic key: %s\n", path, b64(key.Public().(ed25519.PublicKey)))
fmt.Println("Make a backup now: signrelease backup -out <file>")
return nil
}
func pubkey(args []string) error {
key, _, err := loadKey()
if err != nil {
return err
}
fmt.Println(b64(key.Public().(ed25519.PublicKey)))
return nil
}
func fileSum(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
func sign(args []string) error {
fs := flag.NewFlagSet("sign", flag.ExitOnError)
version := fs.String("version", "", "the release's version, e.g. 1.2.3")
file := fs.String("file", "", "the payload to sign")
out := fs.String("out", "", "the signature file (default: the payload's name plus "+relsig.FileSuffix+")")
fs.Parse(args)
if *version == "" || *file == "" {
return errors.New("sign needs -version and -file")
}
key, _, err := loadKey()
if err != nil {
return err
}
sum, err := fileSum(*file)
if err != nil {
return err
}
sig, err := relsig.Sign(key, *version, sum)
if err != nil {
return err
}
if *out == "" {
*out = *file + relsig.FileSuffix
}
if err := os.WriteFile(*out, sig.Marshal(), 0o644); err != nil {
return err
}
fmt.Printf("signed %s as version %s -> %s\n", *file, sig.Version, *out)
return nil
}
func verify(args []string) error {
fs := flag.NewFlagSet("verify", flag.ExitOnError)
file := fs.String("file", "", "the payload")
sigFile := fs.String("sig", "", "the signature file (default: the payload's name plus "+relsig.FileSuffix+")")
pub := fs.String("pubkey", "", "the public key to check against (default: this machine's signing key)")
fs.Parse(args)
if *file == "" {
return errors.New("verify needs -file")
}
if *sigFile == "" {
*sigFile = *file + relsig.FileSuffix
}
var public ed25519.PublicKey
if *pub != "" {
p, err := relsig.ParsePublicKey(*pub)
if err != nil {
return err
}
public = p
} else {
key, _, err := loadKey()
if err != nil {
return err
}
public = key.Public().(ed25519.PublicKey)
}
b, err := os.ReadFile(*sigFile)
if err != nil {
return err
}
sig, err := relsig.Parse(b)
if err != nil {
return err
}
sum, err := fileSum(*file)
if err != nil {
return err
}
if sum != sig.SHA256 {
return errors.New("the payload does not match the signature file")
}
if !sig.Verify(public) {
return errors.New("the signature is not valid for this key")
}
fmt.Printf("ok: version %s, sha256 %s\n", sig.Version, sig.SHA256)
return nil
}
// readPassphrase asks for a passphrase without showing it.
func readPassphrase(prompt string) ([]byte, error) {
fmt.Fprint(os.Stderr, prompt)
fd := int(os.Stdin.Fd())
if !term.IsTerminal(fd) {
return nil, errors.New("a passphrase has to be typed at a terminal")
}
p, err := term.ReadPassword(fd)
fmt.Fprintln(os.Stderr)
return p, err
}
func sealer(passphrase, salt []byte) (cipher.AEAD, error) {
k, err := pbkdf2.Key(sha256.New, string(passphrase), salt, kdfRounds, 32)
if err != nil {
return nil, err
}
block, err := aes.NewCipher(k)
if err != nil {
return nil, err
}
return cipher.NewGCM(block)
}
func backup(args []string) error {
fs := flag.NewFlagSet("backup", flag.ExitOnError)
out := fs.String("out", "", "where to write the encrypted backup")
fs.Parse(args)
if *out == "" {
return errors.New("backup needs -out")
}
if _, err := os.Stat(*out); err == nil {
return fmt.Errorf("%s already exists", *out)
}
key, _, err := loadKey()
if err != nil {
return err
}
p1, err := readPassphrase("Passphrase for the backup: ")
if err != nil {
return err
}
if len(p1) < 8 {
return errors.New("use at least 8 characters")
}
p2, err := readPassphrase("Again: ")
if err != nil {
return err
}
if string(p1) != string(p2) {
return errors.New("the passphrases differ")
}
text, err := sealBackup(key, p1)
if err != nil {
return err
}
if err := os.WriteFile(*out, []byte(text), 0o600); err != nil {
return err
}
fmt.Printf("encrypted backup written to %s\nWithout the passphrase it cannot be restored; keep the passphrase somewhere else.\n", *out)
return nil
}
func restore(args []string) error {
fs := flag.NewFlagSet("restore", flag.ExitOnError)
in := fs.String("in", "", "the encrypted backup")
fs.Parse(args)
if *in == "" {
return errors.New("restore needs -in")
}
path, err := keyPath()
if err != nil {
return err
}
if _, err := os.Stat(path); err == nil {
return fmt.Errorf("%s already exists; refusing to overwrite a signing key", path)
}
b, err := os.ReadFile(*in)
if err != nil {
return err
}
pass, err := readPassphrase("Passphrase of the backup: ")
if err != nil {
return err
}
key, err := openBackup(b, pass)
if err != nil {
return err
}
public := key.Public().(ed25519.PublicKey)
if err := writeKey(path, key); err != nil {
return err
}
fmt.Printf("signing key restored to %s\npublic key: %s\n", path, b64(public))
return nil
}
// sealBackup encrypts the key with a passphrase.
func sealBackup(key ed25519.PrivateKey, passphrase []byte) (string, error) {
salt, nonce := make([]byte, 16), make([]byte, 12)
rand.Read(salt)
rand.Read(nonce)
aead, err := sealer(passphrase, salt)
if err != nil {
return "", err
}
public := key.Public().(ed25519.PublicKey)
// The public key is bound to the ciphertext, so a backup cannot be
// relabelled as another key's.
data := aead.Seal(nil, nonce, key.Seed(), public)
return fmt.Sprintf("%s\nRestore with: signrelease restore -in <this file>\nkdf: pbkdf2-sha256\nrounds: %d\nsalt: %s\nnonce: %s\ndata: %s\npublic: %s\n",
backupHeading, kdfRounds, b64(salt), b64(nonce), b64(data), b64(public)), nil
}
// openBackup decrypts a backup.
func openBackup(b, passphrase []byte) (ed25519.PrivateKey, error) {
fields, err := relsig.ParseFields(b)
if err != nil {
return nil, err
}
dec := func(name string) []byte {
v, _ := base64.StdEncoding.DecodeString(fields[name])
return v
}
rounds, _ := strconv.Atoi(fields["rounds"])
salt, nonce, data, public := dec("salt"), dec("nonce"), dec("data"), dec("public")
if fields["kdf"] != "pbkdf2-sha256" || rounds != kdfRounds || len(salt) == 0 || len(nonce) != 12 || len(public) != ed25519.PublicKeySize {
return nil, errors.New("not a backup this version understands")
}
aead, err := sealer(passphrase, salt)
if err != nil {
return nil, err
}
seed, err := aead.Open(nil, nonce, data, public)
if err != nil || len(seed) != ed25519.SeedSize {
return nil, errors.New("wrong passphrase, or the backup is damaged")
}
key := ed25519.NewKeyFromSeed(seed)
if !key.Public().(ed25519.PublicKey).Equal(ed25519.PublicKey(public)) {
return nil, errors.New("the backup is inconsistent")
}
return key, nil
}
+31
View File
@@ -0,0 +1,31 @@
package main
import (
"crypto/ed25519"
"strings"
"testing"
)
func TestBackupRoundTrip(t *testing.T) {
_, key, err := ed25519.GenerateKey(nil)
if err != nil {
t.Fatal(err)
}
text, err := sealBackup(key, []byte("correct horse"))
if err != nil {
t.Fatal(err)
}
if strings.Contains(text, b64(key.Seed())) {
t.Fatal("the backup contains the key in the clear")
}
got, err := openBackup([]byte(text), []byte("correct horse"))
if err != nil || !got.Equal(key) {
t.Fatalf("restore: %v", err)
}
if _, err := openBackup([]byte(text), []byte("wrong")); err == nil {
t.Error("a wrong passphrase was accepted")
}
if _, err := openBackup([]byte("nonsense"), []byte("x")); err == nil {
t.Error("nonsense was accepted as a backup")
}
}
+11
View File
@@ -46,6 +46,13 @@ type config struct {
// empty for every device the tailnet's access rules allow, "own" for only
// the devices of the user this console is logged in as.
AllowFrom string `json:"allowFrom,omitempty"`
// PayloadPath names the copy of the payload that is started at boot, for
// example in a payload manager's folder. An update installed from the
// status page replaces that file too. Empty means there is none to keep
// up to date.
PayloadPath string `json:"payloadPath,omitempty"`
// ReceiveDir is where files sent to the console with Taildrop end up.
ReceiveDir string `json:"receiveDir"`
// 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.
@@ -61,6 +68,7 @@ func defaultConfig() config {
return config{
Hostname: "ps5",
WebAddr: ":8090",
ReceiveDir: defaultReceiveDir,
UDPPorts: slices.Clone(remotePlayUDPPorts),
CheckUpdates: true,
}
@@ -100,6 +108,9 @@ func (cfg *config) normalize() {
}
cfg.SunshineHost = ""
}
if cfg.ReceiveDir == "" {
cfg.ReceiveDir = defaultReceiveDir
}
if cfg.AllowFrom != accessOwn {
cfg.AllowFrom = ""
}
+1 -1
View File
@@ -5,6 +5,7 @@ go 1.27.1
require (
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
golang.org/x/crypto/x509roots/fallback v0.0.0-20260929172509-b39ff6d641ec
golang.org/x/term v0.46.0
tailscale.com v1.104.0
)
@@ -46,7 +47,6 @@ require (
golang.org/x/oauth2 v0.37.0 // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/term v0.46.0 // indirect
golang.org/x/text v0.42.0 // indirect
golang.org/x/time v0.16.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
+7 -1
View File
@@ -88,6 +88,10 @@ func main() {
debug.Printf(format, args...)
}
// What an update installed from the status page downloaded; this may be
// the very copy that is running now, and it is not needed again.
os.RemoveAll(filepath.Join(dataDir, updateDirName))
d := &daemon{cfg: cfg, cfgPath: filepath.Join(dataDir, "config.json"), logf: logf, debug: debug, console: console, started: time.Now()}
if err := d.run(); err != nil {
logf("fatal: %v", err)
@@ -121,7 +125,8 @@ type daemon struct {
webPort uint16 // port of the status page
lastRelogin time.Time
lastNetChange time.Time
latest releaseInfo // newest release known, see update.go
latest releaseInfo // newest release known, see update.go
update updateProgress // an update being installed, see selfupdate.go
sessions sessions // browsers that have entered the password
access accessCache // recent decisions about who may connect
@@ -193,6 +198,7 @@ func (d *daemon) run() error {
go d.exposeUDP(ctx)
go d.watchForUpdates(ctx)
go d.watchKeyExpiry(ctx)
go d.collectFiles(ctx)
sigc := make(chan os.Signal, 1)
signal.Notify(sigc, syscall.SIGTERM, syscall.SIGINT)
+114
View File
@@ -0,0 +1,114 @@
// Package relsig signs and verifies releases.
//
// A release's payload comes with a small signature file. It names the
// version and the SHA-256 of the payload and carries an Ed25519 signature
// over both, made with a key that never leaves the maintainer's machines.
// The daemon has the public half built in and installs an update only if the
// signature checks out, so a tampered release, or one put up by someone who
// got into the hosting account, is turned down.
//
// The version is part of what is signed, so a signature cannot be reused to
// pass an older payload off as a newer release.
package relsig
import (
"bytes"
"crypto/ed25519"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"strings"
)
// FileSuffix is appended to a payload's name for its signature file.
const FileSuffix = ".sig"
const domain = "ps5-tailscale-release-v1"
// Signature is the content of a signature file.
type Signature struct {
Version string // without a leading "v"
SHA256 string // lower-case hex of the payload's SHA-256
Sig []byte
}
// message is what gets signed.
func message(version, sum string) []byte {
return []byte(domain + "\nversion=" + version + "\nsha256=" + sum + "\n")
}
func clean(version, sum string) (string, string, error) {
version = strings.TrimPrefix(strings.TrimSpace(version), "v")
sum = strings.ToLower(strings.TrimSpace(sum))
if version == "" || strings.ContainsAny(version, " \t\r\n=") {
return "", "", errors.New("invalid version")
}
if b, err := hex.DecodeString(sum); err != nil || len(b) != 32 {
return "", "", errors.New("invalid SHA-256")
}
return version, sum, nil
}
// Sign signs a payload's version and SHA-256.
func Sign(key ed25519.PrivateKey, version, sum string) (Signature, error) {
version, sum, err := clean(version, sum)
if err != nil {
return Signature{}, err
}
return Signature{Version: version, SHA256: sum, Sig: ed25519.Sign(key, message(version, sum))}, nil
}
// Verify reports whether the signature was made with the private half of pub.
func (s Signature) Verify(pub ed25519.PublicKey) bool {
return len(pub) == ed25519.PublicKeySize && ed25519.Verify(pub, message(s.Version, s.SHA256), s.Sig)
}
// Marshal renders the signature file.
func (s Signature) Marshal() []byte {
return []byte(fmt.Sprintf("version: %s\nsha256: %s\nsignature: %s\n",
s.Version, s.SHA256, base64.StdEncoding.EncodeToString(s.Sig)))
}
// Parse reads a signature file.
func Parse(b []byte) (Signature, error) {
fields, err := ParseFields(b)
if err != nil {
return Signature{}, err
}
version, sum, err := clean(fields["version"], fields["sha256"])
if err != nil {
return Signature{}, err
}
sig, err := base64.StdEncoding.DecodeString(fields["signature"])
if err != nil || len(sig) != ed25519.SignatureSize {
return Signature{}, errors.New("invalid signature field")
}
return Signature{Version: version, SHA256: sum, Sig: sig}, nil
}
// ParseFields reads "name: value" lines. Lines without a colon are ignored,
// which leaves room for a heading.
func ParseFields(b []byte) (map[string]string, error) {
if len(b) > 16<<10 {
return nil, errors.New("file too large")
}
fields := map[string]string{}
for _, line := range bytes.Split(b, []byte("\n")) {
name, value, ok := strings.Cut(string(line), ":")
if !ok {
continue
}
fields[strings.TrimSpace(name)] = strings.TrimSpace(value)
}
return fields, nil
}
// ParsePublicKey reads a base64 public key.
func ParsePublicKey(s string) (ed25519.PublicKey, error) {
b, err := base64.StdEncoding.DecodeString(strings.TrimSpace(s))
if err != nil || len(b) != ed25519.PublicKeySize {
return nil, errors.New("invalid public key")
}
return ed25519.PublicKey(b), nil
}
+76
View File
@@ -0,0 +1,76 @@
package relsig
import (
"crypto/ed25519"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"testing"
)
func TestSignVerify(t *testing.T) {
pub, priv, err := ed25519.GenerateKey(nil)
if err != nil {
t.Fatal(err)
}
sum := sha256.Sum256([]byte("payload"))
hexSum := hex.EncodeToString(sum[:])
sig, err := Sign(priv, "v1.2.3", hexSum)
if err != nil {
t.Fatal(err)
}
if sig.Version != "1.2.3" {
t.Errorf("version = %q", sig.Version)
}
parsed, err := Parse(sig.Marshal())
if err != nil {
t.Fatal(err)
}
if !parsed.Verify(pub) {
t.Fatal("a good signature did not verify")
}
// The same signature must not pass for another version or payload.
other := parsed
other.Version = "1.2.4"
if other.Verify(pub) {
t.Error("the signature passed for another version")
}
other = parsed
otherSum := sha256.Sum256([]byte("another payload"))
other.SHA256 = hex.EncodeToString(otherSum[:])
if other.Verify(pub) {
t.Error("the signature passed for another payload")
}
// Nor with another key.
pub2, _, _ := ed25519.GenerateKey(nil)
if parsed.Verify(pub2) {
t.Error("the signature passed with another key")
}
if parsed.Verify(nil) {
t.Error("the signature passed with no key")
}
if got, err := ParsePublicKey(base64.StdEncoding.EncodeToString(pub)); err != nil || !got.Equal(pub) {
t.Errorf("ParsePublicKey: %v", err)
}
}
func TestParseRejects(t *testing.T) {
for name, text := range map[string]string{
"empty": "",
"no signature": "version: 1.0.0\nsha256: " + hex.EncodeToString(make([]byte, 32)) + "\n",
"short sum": "version: 1.0.0\nsha256: abcd\nsignature: " + base64.StdEncoding.EncodeToString(make([]byte, 64)) + "\n",
"bad signature": "version: 1.0.0\nsha256: " + hex.EncodeToString(make([]byte, 32)) + "\nsignature: AAAA\n",
"no version": "sha256: " + hex.EncodeToString(make([]byte, 32)) + "\nsignature: " + base64.StdEncoding.EncodeToString(make([]byte, 64)) + "\n",
} {
if _, err := Parse([]byte(text)); err == nil {
t.Errorf("%s: accepted", name)
}
}
if _, err := Sign(nil, "1.0.0", "nothex"); err == nil {
t.Error("Sign accepted a bad sum")
}
}
+335
View File
@@ -0,0 +1,335 @@
package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net"
"net/http"
"os"
"path"
"path/filepath"
"strings"
"time"
"ps5tailscale/relsig"
)
// Installing a newer release from the status page.
//
// Nothing here runs by itself: an update is only ever installed when someone
// presses the button. The payload is downloaded from the release, checked
// against the release's signature with the key built in below, and handed to
// the ELF loader on this console, which is the same as sending the payload by
// hand: the new copy stops this one and takes over.
// updatePublicKey is the public half of the release signing key (base64
// Ed25519). Only releases signed with the private half can be installed from
// the status page. A fork that makes its own releases needs its own key
// (tsd/cmd/signrelease); test builds set another one with -ldflags -X.
var updatePublicKey = "HUcHCXGe3vNEeyt4frmoKZUqYDamdA1dzsr+Hsybda0="
const (
payloadAsset = "tailscale.elf"
maxPayload = 128 << 20
loaderAddr = "127.0.0.1:9021"
updateDirName = "update"
)
// updateProgress is what the status page shows about an update in progress.
type updateProgress struct {
// State is "" (nothing going on), "downloading", "verifying",
// "installing" or "failed".
State string `json:"state"`
Version string `json:"version,omitempty"`
Percent int `json:"percent,omitempty"`
Error string `json:"error,omitempty"`
}
func (d *daemon) setUpdate(p updateProgress) {
d.mu.Lock()
d.update = p
d.mu.Unlock()
}
// canInstall reports whether rel can be installed from the status page.
func canInstall(rel releaseInfo) bool {
return newerVersion(version, rel.Version) && rel.Assets[payloadAsset] != "" && rel.Assets[payloadAsset+relsig.FileSuffix] != ""
}
// startUpdate begins installing the newest known release. It returns at
// once; progress is reported through the status.
func (d *daemon) startUpdate() error {
d.mu.Lock()
defer d.mu.Unlock()
switch d.update.State {
case "downloading", "verifying", "installing":
return errors.New("an update is already in progress")
}
rel := d.latest
if !newerVersion(version, rel.Version) {
return errors.New("no newer release is known")
}
if !canInstall(rel) {
return errors.New("that release has no signed payload; install it by hand")
}
d.update = updateProgress{State: "downloading", Version: rel.Version}
go func() {
if err := d.runUpdate(rel); err != nil {
d.logf("update to %s failed: %v", rel.Version, err)
d.setUpdate(updateProgress{State: "failed", Version: rel.Version, Error: err.Error()})
}
}()
return nil
}
func (d *daemon) runUpdate(rel releaseInfo) error {
pub, err := relsig.ParsePublicKey(updatePublicKey)
if err != nil {
return errors.New("this build has no release key")
}
d.logf("update: downloading %s", rel.Version)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
defer cancel()
// The signature first: it is small, and says what the payload must be.
sigBytes, err := httpGetSmall(ctx, rel.Assets[payloadAsset+relsig.FileSuffix])
if err != nil {
return fmt.Errorf("downloading the signature: %w", err)
}
sig, err := checkSignature(sigBytes, pub, rel.Version)
if err != nil {
return err
}
dir := filepath.Join(dataDir, updateDirName)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
file := filepath.Join(dir, payloadAsset)
sum, err := d.download(ctx, rel.Assets[payloadAsset], file, rel.Version)
if err != nil {
os.Remove(file)
return fmt.Errorf("downloading the payload: %w", err)
}
d.setUpdate(updateProgress{State: "verifying", Version: rel.Version})
if sum != sig.SHA256 {
os.Remove(file)
return errors.New("the downloaded payload does not match the release's signature; not installing it")
}
d.logf("update: %s verified (sha256 %s)", rel.Version, sum)
d.setUpdate(updateProgress{State: "installing", Version: rel.Version})
d.mu.Lock()
keep := d.cfg.PayloadPath
d.mu.Unlock()
if keep != "" {
// The copy that is started at boot. A failure here is reported but
// does not stop the update of the running instance.
if err := replaceFile(file, keep); err != nil {
d.logf("update: could not replace %s: %v", keep, err)
notify("Tailscale: could not update the copy at\n%s", keep)
} else {
d.logf("update: replaced %s", keep)
}
}
if err := sendToLoader(file); err != nil {
return fmt.Errorf("the update is downloaded and verified (%s), but could not be started: %w", file, err)
}
d.logf("update: handed %s to the ELF loader", rel.Version)
// The new instance stops this one within seconds. If it does not, its
// start failed.
select {
case <-d.quit:
return nil
case <-time.After(90 * time.Second):
return errors.New("the new version did not start; see /data/tailscale/launcher.log")
}
}
// checkSignature parses a release's signature file and checks it against the
// release key and the version the release claims to be.
func checkSignature(b []byte, pub []byte, wantVersion string) (relsig.Signature, error) {
sig, err := relsig.Parse(b)
if err != nil {
return sig, fmt.Errorf("the release's signature file is not valid: %w", err)
}
if !sig.Verify(pub) {
return sig, errors.New("the release is not signed with this project's release key; not installing it")
}
if sig.Version != wantVersion {
return sig, fmt.Errorf("the signature is for version %s, not %s; not installing it", sig.Version, wantVersion)
}
if !newerVersion(version, sig.Version) {
return sig, fmt.Errorf("version %s is not newer than this one", sig.Version)
}
return sig, nil
}
func httpGet(ctx context.Context, url string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "ps5-tailscale/"+version)
req.Header.Set("Accept", "application/octet-stream")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, &httpStatusError{resp.Status}
}
return resp, nil
}
func httpGetSmall(ctx context.Context, url string) ([]byte, error) {
resp, err := httpGet(ctx, url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return io.ReadAll(io.LimitReader(resp.Body, 16<<10))
}
// download saves url to file and returns the SHA-256 of what was written.
func (d *daemon) download(ctx context.Context, url, file, ver string) (string, error) {
resp, err := httpGet(ctx, url)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.ContentLength > maxPayload {
return "", errors.New("the payload is implausibly large")
}
f, err := os.OpenFile(file, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
if err != nil {
return "", err
}
h := sha256.New()
buf := make([]byte, 256<<10)
var done int64
lastPercent := -1
for {
n, rerr := resp.Body.Read(buf)
if n > 0 {
if done += int64(n); done > maxPayload {
f.Close()
return "", errors.New("the payload is implausibly large")
}
h.Write(buf[:n])
if _, err := f.Write(buf[:n]); err != nil {
f.Close()
return "", err
}
if resp.ContentLength > 0 {
if p := int(done * 100 / resp.ContentLength); p != lastPercent {
lastPercent = p
d.setUpdate(updateProgress{State: "downloading", Version: ver, Percent: p})
}
}
}
if rerr == io.EOF {
break
}
if rerr != nil {
f.Close()
return "", rerr
}
}
if err := f.Close(); err != nil {
return "", err
}
if resp.ContentLength > 0 && done != resp.ContentLength {
return "", errors.New("the download was cut short")
}
return hex.EncodeToString(h.Sum(nil)), nil
}
// replaceFile puts a copy of src at dst, by way of a temporary file next to
// dst so that dst is never left half written.
func replaceFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
tmp := dst + ".new"
out, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
os.Remove(tmp)
return err
}
if err := out.Close(); err != nil {
os.Remove(tmp)
return err
}
if err := os.Rename(tmp, dst); err != nil {
os.Remove(tmp)
return err
}
return nil
}
// sendToLoader hands a payload to the ELF loader on this console.
func sendToLoader(file string) error {
f, err := os.Open(file)
if err != nil {
return err
}
defer f.Close()
c, err := net.DialTimeout("tcp", loaderAddr, 5*time.Second)
if err != nil {
return fmt.Errorf("no ELF loader on port 9021 (%w); send the file by hand", err)
}
c.SetWriteDeadline(time.Now().Add(2 * time.Minute))
// Not io.Copy(c, f) with the bare file: Go would use sendfile, which the
// PS5 kernel refuses for sockets ("socket is not connected").
if _, err := io.Copy(c, onlyReader{f}); err != nil {
c.Close()
return err
}
// The half-close tells the loader that the payload is complete.
if tc, ok := c.(*net.TCPConn); ok {
tc.CloseWrite()
}
// The connection is the new payload's standard output. Keep reading it
// for as long as this process lives, so that nothing the payload prints
// while starting hits a closed socket.
go func() {
io.Copy(io.Discard, c)
c.Close()
}()
return nil
}
// onlyReader hides everything about a reader but Read, so that copying from
// it takes the plain path.
type onlyReader struct{ io.Reader }
// validPayloadPath checks the setting that names the copy started at boot.
func validPayloadPath(p string) error {
if p == "" {
return nil
}
if !strings.HasPrefix(p, "/") {
return errors.New("it must be a full path, such as /data/pldmgr/payloads/Tailscale/tailscale.elf")
}
if path.Clean(p) != p {
return errors.New("it must not contain .. or doubled slashes")
}
if path.Ext(p) != ".elf" {
return errors.New("it must name an .elf file")
}
return nil
}
+189
View File
@@ -0,0 +1,189 @@
package main
import (
"bytes"
"context"
"crypto/ed25519"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"ps5tailscale/relsig"
)
func withVersion(t *testing.T, v string) {
old := version
version = v
t.Cleanup(func() { version = old })
}
func TestCheckSignature(t *testing.T) {
withVersion(t, "1.0.0")
pub, priv, _ := ed25519.GenerateKey(nil)
otherPub, otherPriv, _ := ed25519.GenerateKey(nil)
_ = otherPub
sum := hex.EncodeToString(make([]byte, 32))
sign := func(key ed25519.PrivateKey, v string) []byte {
s, err := relsig.Sign(key, v, sum)
if err != nil {
t.Fatal(err)
}
return s.Marshal()
}
if _, err := checkSignature(sign(priv, "1.1.0"), pub, "1.1.0"); err != nil {
t.Errorf("a good signature was refused: %v", err)
}
for name, tt := range map[string]struct {
sig []byte
want string
}{
"signed with another key": {sign(otherPriv, "1.1.0"), "1.1.0"},
"signature of another version": {sign(priv, "1.0.5"), "1.1.0"},
"a properly signed older build": {sign(priv, "0.9.0"), "0.9.0"},
"the version that is running": {sign(priv, "1.0.0"), "1.0.0"},
"not a signature file": {[]byte("<html>not found</html>"), "1.1.0"},
"tampered version, same payload": {bytes.Replace(sign(priv, "1.0.5"), []byte("1.0.5"), []byte("1.1.0"), 1), "1.1.0"},
} {
if _, err := checkSignature(tt.sig, pub, tt.want); err == nil {
t.Errorf("%s: accepted", name)
}
}
}
func TestBuiltInKeyIsValid(t *testing.T) {
if _, err := relsig.ParsePublicKey(updatePublicKey); err != nil {
t.Fatalf("updatePublicKey: %v", err)
}
}
func TestCanInstall(t *testing.T) {
withVersion(t, "1.0.0")
both := map[string]string{"tailscale.elf": "u1", "tailscale.elf.sig": "u2"}
for _, tt := range []struct {
rel releaseInfo
want bool
}{
{releaseInfo{Version: "1.1.0", Assets: both}, true},
{releaseInfo{Version: "1.0.0", Assets: both}, false},
{releaseInfo{Version: "1.1.0", Assets: map[string]string{"tailscale.elf": "u1"}}, false},
{releaseInfo{Version: "1.1.0"}, false},
} {
if got := canInstall(tt.rel); got != tt.want {
t.Errorf("%+v: got %v", tt.rel, got)
}
}
}
func TestFetchLatestReleaseAssets(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, `{"tag_name":"v1.2.3","html_url":"https://example.com/r","assets":[
{"name":"tailscale.elf","browser_download_url":"https://example.com/a"},
{"name":"tailscale.elf.sig","browser_download_url":"https://example.com/b"}]}`)
}))
defer srv.Close()
rel, err := fetchLatestRelease(context.Background(), srv.URL)
if err != nil {
t.Fatal(err)
}
if rel.Version != "1.2.3" || rel.Assets["tailscale.elf"] != "https://example.com/a" || rel.Assets["tailscale.elf.sig"] != "https://example.com/b" {
t.Errorf("got %+v", rel)
}
}
func TestDownloadAndReplace(t *testing.T) {
payload := bytes.Repeat([]byte("payload "), 100_000)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/ok":
w.Header().Set("Content-Length", fmt.Sprint(len(payload)))
w.Write(payload)
case "/short":
w.Header().Set("Content-Length", fmt.Sprint(len(payload)))
w.Write(payload[:1000])
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
d := &daemon{logf: t.Logf}
dir := t.TempDir()
file := filepath.Join(dir, "tailscale.elf")
sum, err := d.download(context.Background(), srv.URL+"/ok", file, "1.1.0")
if err != nil {
t.Fatal(err)
}
want := sha256.Sum256(payload)
if sum != hex.EncodeToString(want[:]) {
t.Errorf("sum = %s", sum)
}
if d.update.State != "downloading" || d.update.Percent != 100 {
t.Errorf("progress = %+v", d.update)
}
if _, err := d.download(context.Background(), srv.URL+"/short", filepath.Join(dir, "short"), "1.1.0"); err == nil {
t.Error("a download that was cut short was accepted")
}
if _, err := d.download(context.Background(), srv.URL+"/missing", filepath.Join(dir, "missing"), "1.1.0"); err == nil {
t.Error("a 404 was accepted")
}
// Replacing the copy started at boot.
dst := filepath.Join(dir, "boot", "tailscale.elf")
os.MkdirAll(filepath.Dir(dst), 0o755)
os.WriteFile(dst, []byte("old"), 0o644)
if err := replaceFile(file, dst); err != nil {
t.Fatal(err)
}
got, _ := os.ReadFile(dst)
if !bytes.Equal(got, payload) {
t.Error("the file was not replaced")
}
if _, err := os.Stat(dst + ".new"); err == nil {
t.Error("the temporary file was left behind")
}
// A folder that does not exist: the old file elsewhere stays untouched.
if err := replaceFile(file, filepath.Join(dir, "nowhere", "tailscale.elf")); err == nil {
t.Error("replacing into a missing folder succeeded")
}
}
func TestStartUpdateRefusals(t *testing.T) {
withVersion(t, "1.0.0")
d := &daemon{logf: t.Logf}
if err := d.startUpdate(); err == nil {
t.Error("started with no release known")
}
d.latest = releaseInfo{Version: "1.1.0", Assets: map[string]string{"tailscale.elf": "x"}}
if err := d.startUpdate(); err == nil || !strings.Contains(err.Error(), "signed") {
t.Errorf("an unsigned release: %v", err)
}
d.update = updateProgress{State: "downloading"}
if err := d.startUpdate(); err == nil {
t.Error("started a second update")
}
}
func TestValidPayloadPath(t *testing.T) {
for p, ok := range map[string]bool{
"": true,
"/data/pldmgr/payloads/Tailscale/tailscale.elf": true,
"/mnt/usb0/tailscale.elf": true,
"tailscale.elf": false,
"/data/../etc/tailscale.elf": false,
"/data//tailscale.elf": false,
"/data/pldmgr/autoload.txt": false,
"/data/tailscale/": false,
} {
if err := validPayloadPath(p); (err == nil) != ok {
t.Errorf("%q: %v", p, err)
}
}
}
+18
View File
@@ -8,6 +8,7 @@ import (
"net"
"net/http"
"os"
"path"
"path/filepath"
"slices"
"strconv"
@@ -41,6 +42,8 @@ type settings struct {
BlockedPorts []uint16 `json:"blockedPorts"`
Priority string `json:"priority"`
AllowFrom string `json:"allowFrom"`
PayloadPath string `json:"payloadPath"`
ReceiveDir string `json:"receiveDir"`
CheckUpdates bool `json:"checkUpdates"`
Verbose bool `json:"verbose"`
@@ -62,6 +65,8 @@ func settingsFromConfig(cfg config) settings {
BlockedPorts: append([]uint16{}, cfg.BlockedPorts...),
Priority: priorityLow,
AllowFrom: accessAll,
PayloadPath: cfg.PayloadPath,
ReceiveDir: cfg.ReceiveDir,
CheckUpdates: cfg.CheckUpdates,
Verbose: cfg.Verbose,
PasswordSet: cfg.PasswordHash != "",
@@ -108,6 +113,17 @@ func (s *settings) validate() error {
if slices.Contains(s.UDPPorts, 0) || slices.Contains(s.BlockedPorts, 0) {
return fmt.Errorf("0 is not a port")
}
s.PayloadPath = strings.TrimSpace(s.PayloadPath)
if err := validPayloadPath(s.PayloadPath); err != nil {
return fmt.Errorf("payload file: %w", err)
}
s.ReceiveDir = strings.TrimSpace(s.ReceiveDir)
if s.ReceiveDir == "" {
s.ReceiveDir = defaultReceiveDir
}
if !strings.HasPrefix(s.ReceiveDir, "/") || path.Clean(s.ReceiveDir) != s.ReceiveDir || s.ReceiveDir == "/" {
return fmt.Errorf("folder for received files: it must be a full path, such as %s", defaultReceiveDir)
}
if s.AllowFrom == "" {
s.AllowFrom = accessAll
}
@@ -205,6 +221,8 @@ func (d *daemon) handleSetConfig(w http.ResponseWriter, r *http.Request) {
if s.Priority == priorityHigh {
cfg.Priority = priorityHigh
}
cfg.PayloadPath = s.PayloadPath
cfg.ReceiveDir = s.ReceiveDir
cfg.AllowFrom = ""
if s.AllowFrom == accessOwn {
cfg.AllowFrom = accessOwn
+90 -1
View File
@@ -101,6 +101,11 @@
<section class="panel banner hidden" id="updatepanel">
<strong id="updatetext"></strong>
<a id="updatelink" target="_blank" rel="noopener">Release notes and download</a>
<div class="actions" style="margin-top: 12px">
<button id="btn-update" class="hidden">Install it</button>
</div>
<p class="note hidden" id="updatestate" style="margin: 12px 0 0"></p>
<p class="msg hidden" id="updatemsg"></p>
</section>
<section class="panel hidden" id="lockpanel">
@@ -145,6 +150,18 @@
</details>
</section>
<section class="panel hidden" id="filespanel">
<details id="filesbox">
<summary><span class="heading">Received files</span><span class="count" id="filescount"></span></summary>
<p class="note" style="margin-top: 12px">Files sent to this console with Taildrop, from your own devices, are kept in
<code id="filesdir"></code>.</p>
<table>
<thead><tr><th>Name</th><th>Size</th><th>Received</th></tr></thead>
<tbody id="files"></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. List the devices that run Sunshine and
@@ -204,6 +221,15 @@
<option value="high">High: shares the CPU with games; smoother Remote Play</option>
</select>
</label>
<label class="field">Folder for files received with Taildrop
<input type="text" id="set-receivedir" autocomplete="off">
</label>
<label class="field">Payload file to keep up to date
<input type="text" id="set-payloadpath" autocomplete="off" placeholder="None">
<span class="hint">Where the copy of <code>tailscale.elf</code> that starts with the console is kept, for
example <code>/data/pldmgr/payloads/Tailscale/tailscale.elf</code>. Installing an update from this
page then replaces that file too. Empty: only the running copy is updated, until the next restart.</span>
</label>
<label class="field">Status page address
<input type="text" id="set-webaddr" autocomplete="off">
</label>
@@ -409,11 +435,21 @@ async function refresh() {
$('dot').className = 'dot ' + cls;
$('version').textContent = 'ps5-tailscale ' + s.version;
$('updatepanel').classList.toggle('hidden', !s.latestVersion);
const upd = s.update || {};
const busy = ['downloading', 'verifying', 'installing'].includes(upd.state);
$('updatepanel').classList.toggle('hidden', !s.latestVersion && !upd.state);
if (s.latestVersion) {
$('updatetext').textContent = 'Version ' + s.latestVersion + ' is available. ';
$('updatelink').href = s.updateURL;
}
$('btn-update').classList.toggle('hidden', !s.canUpdate || busy);
$('btn-update').textContent = 'Install ' + (s.latestVersion || 'it');
show('updatestate', {
downloading: 'Downloading version ' + upd.version + (upd.percent ? ' (' + upd.percent + '%)' : '') + '…',
verifying: 'Checking the signature of version ' + upd.version + '…',
installing: 'Starting version ' + upd.version + '. This page reconnects by itself.',
}[upd.state] || '');
show('updatemsg', upd.state === 'failed' ? 'The update was not installed: ' + upd.error : updateRefused);
// Key expiry, in whole days.
let keyText = '', keyWarn = '';
@@ -481,6 +517,8 @@ async function refresh() {
$('btn-lock').classList.toggle('hidden', !s.passwordSet);
refreshFiles();
if ($('logbox').open) {
try { $('logs').textContent = await (await api('/api/logs')).text(); } catch (e) {}
}
@@ -503,6 +541,53 @@ async function act(path, confirmText, body) {
}
}
// Received files. Asked for separately from the status, and only redrawn
// when the list changed.
let shownFiles = '';
function fileSize(n) {
if (n < 1024) return n + ' B';
const units = ['KB', 'MB', 'GB', 'TB'];
let i = -1;
do { n /= 1024; i++; } while (n >= 1024 && i < units.length - 1);
return (n < 10 ? n.toFixed(1) : Math.round(n)) + ' ' + units[i];
}
async function refreshFiles() {
let f;
try { f = await (await api('/api/files')).json(); } catch (e) { return; }
const key = JSON.stringify(f);
if (key === shownFiles) return;
shownFiles = key;
$('filespanel').classList.toggle('hidden', !f.files.length);
$('filescount').textContent = f.files.length;
$('filesdir').textContent = f.dir;
$('files').replaceChildren(...f.files.map(file => {
const tr = document.createElement('tr');
const name = document.createElement('td');
const a = document.createElement('a');
a.href = '/api/files/get?name=' + encodeURIComponent(file.name); a.textContent = file.name; a.download = file.name;
name.append(a);
const size = document.createElement('td'); size.textContent = fileSize(file.size);
const when = document.createElement('td'); when.textContent = new Date(file.time).toLocaleString(undefined, {dateStyle: 'medium', timeStyle: 'short'});
size.style.whiteSpace = when.style.whiteSpace = 'nowrap';
tr.append(name, size, when);
return tr;
}));
}
let updateRefused = ''; // why the daemon would not start an update
$('btn-update').onclick = async () => {
const v = $('btn-update').textContent.replace('Install ', '');
if (!confirm('Download and install version ' + v + '?\n\nTailscale on this PS5 restarts, which interrupts a stream or Remote Play session that runs through it.')) return;
updateRefused = '';
try {
const r = await api('/api/update', {method: 'POST'});
if (!r.ok) throw new Error((await r.text()).trim() || r.statusText);
} catch (e) {
if (e.message !== 'locked') updateRefused = e.message;
}
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.');
@@ -555,6 +640,8 @@ async function loadSettings() {
$('set-proxy').value = c.httpProxyAddr;
$('set-priority').value = c.priority;
$('set-allowfrom').value = c.allowFrom;
$('set-receivedir').value = c.receiveDir;
$('set-payloadpath').value = c.payloadPath;
$('set-webaddr').value = c.webAddr;
$('set-updates').checked = c.checkUpdates;
$('set-verbose').checked = c.verbose;
@@ -581,6 +668,8 @@ $('settingsform').onsubmit = async ev => {
blockedPorts: blocked,
priority: $('set-priority').value,
allowFrom: $('set-allowfrom').value,
receiveDir: $('set-receivedir').value.trim(),
payloadPath: $('set-payloadpath').value.trim(),
checkUpdates: $('set-updates').checked,
verbose: $('set-verbose').checked,
};
+228
View File
@@ -0,0 +1,228 @@
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"time"
// tsnet leaves Taildrop out unless it is linked in.
_ "tailscale.com/feature/taildrop"
)
// Receiving files with Taildrop.
//
// A file sent to the console from another device of the same user arrives
// in a holding area inside Tailscale's state directory. The daemon moves
// each one to a folder that can be reached with FTP, says so on screen, and
// lists the folder on the status page.
const defaultReceiveDir = "/data/tailscale/received"
// receivedFile is one entry of the list on the status page.
type receivedFile struct {
Name string `json:"name"`
Size int64 `json:"size"`
Time time.Time `json:"time"`
}
func (d *daemon) receiveDir() string {
d.mu.Lock()
defer d.mu.Unlock()
if d.cfg.ReceiveDir == "" {
return defaultReceiveDir
}
return d.cfg.ReceiveDir
}
// collectFiles waits for files in the holding area and moves them out.
func (d *daemon) collectFiles(ctx context.Context) {
for ctx.Err() == nil {
// Returns as soon as there are files, or after the wait with none.
files, err := d.lc.AwaitWaitingFiles(ctx, time.Minute)
if ctx.Err() != nil {
return
}
if err != nil || len(files) == 0 {
if err != nil && !strings.Contains(err.Error(), "context deadline exceeded") {
// Typically: not logged in yet, or Taildrop is turned off
// for the tailnet. Try again later without filling the log.
select {
case <-ctx.Done():
return
case <-time.After(30 * time.Second):
}
}
continue
}
dir := d.receiveDir()
var got []string
for _, f := range files {
name, err := d.collectFile(ctx, dir, f.Name)
if err != nil {
d.logf("taildrop: %s: %v", f.Name, err)
continue
}
d.logf("taildrop: received %s (%d bytes) into %s", name, f.Size, dir)
got = append(got, name)
}
switch len(got) {
case 0:
// Nothing could be moved; do not spin on the same files.
select {
case <-ctx.Done():
return
case <-time.After(30 * time.Second):
}
case 1:
notify("Tailscale: received a file\n%s\nin %s", got[0], dir)
default:
notify("Tailscale: received %d files\nin %s", len(got), dir)
}
}
}
// collectFile copies one waiting file into dir and removes it from the
// holding area. It returns the name the file got.
func (d *daemon) collectFile(ctx context.Context, dir, name string) (string, error) {
rc, _, err := d.lc.GetWaitingFile(ctx, name)
if err != nil {
return "", err
}
defer rc.Close()
saved, err := saveReceived(dir, name, rc)
if err != nil {
return "", err
}
if err := d.lc.DeleteWaitingFile(ctx, name); err != nil {
d.logf("taildrop: %s is saved, but could not be removed from the holding area: %v", name, err)
}
return saved, nil
}
// safeFileName reduces a name from another device to a plain file name.
func safeFileName(name string) string {
name = strings.ReplaceAll(name, "\\", "/")
name = name[strings.LastIndex(name, "/")+1:]
name = strings.Map(func(r rune) rune {
if r < 0x20 || r == 0x7f {
return -1
}
return r
}, name)
name = strings.TrimSpace(name)
if name == "" || name == "." || name == ".." {
return "file"
}
return name
}
// saveReceived writes r to a new file in dir, named after name. A file that
// is already there is never overwritten: the new one gets a number instead.
func saveReceived(dir, name string, r io.Reader) (string, error) {
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
name = safeFileName(name)
ext := filepath.Ext(name)
base := strings.TrimSuffix(name, ext)
var f *os.File
var err error
final := name
for n := 1; ; n++ {
if n > 1 {
final = fmt.Sprintf("%s (%d)%s", base, n, ext)
}
f, err = os.OpenFile(filepath.Join(dir, final), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
if err == nil {
break
}
if !os.IsExist(err) || n > 10000 {
return "", err
}
}
if _, err := io.Copy(f, r); err != nil {
f.Close()
os.Remove(f.Name())
return "", err
}
if err := f.Close(); err != nil {
os.Remove(f.Name())
return "", err
}
return final, nil
}
// listReceived returns the newest files in dir, newest first.
func listReceived(dir string, max int) []receivedFile {
files := []receivedFile{}
entries, err := os.ReadDir(dir)
if err != nil {
return files
}
for _, e := range entries {
info, err := e.Info()
if err != nil || !info.Mode().IsRegular() {
continue
}
files = append(files, receivedFile{Name: e.Name(), Size: info.Size(), Time: info.ModTime()})
}
sort.Slice(files, func(i, j int) bool { return files[i].Time.After(files[j].Time) })
if len(files) > max {
files = files[:max]
}
return files
}
func (d *daemon) handleFiles(w http.ResponseWriter, r *http.Request) {
dir := d.receiveDir()
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
json.NewEncoder(w).Encode(map[string]any{"dir": dir, "files": listReceived(dir, 100)})
}
// handleFileGet sends one received file to the browser.
func (d *daemon) handleFileGet(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
if name == "" || name != safeFileName(name) {
http.Error(w, "no such file", http.StatusNotFound)
return
}
f, err := os.Open(filepath.Join(d.receiveDir(), name))
if err != nil {
http.Error(w, "no such file", http.StatusNotFound)
return
}
defer f.Close()
info, err := f.Stat()
if err != nil || !info.Mode().IsRegular() {
http.Error(w, "no such file", http.StatusNotFound)
return
}
// Always a download, never something the browser renders: the file came
// from elsewhere and this page's origin has the controls on it.
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename*=UTF-8''%s", urlPathEscape(name)))
w.Header().Set("X-Content-Type-Options", "nosniff")
// Wrapped so that Go does not use sendfile, which fails on the PS5.
http.ServeContent(w, r, "", info.ModTime(), struct{ io.ReadSeeker }{f})
}
func urlPathEscape(s string) string {
var b strings.Builder
for _, c := range []byte(s) {
switch {
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '-', c == '.', c == '_', c == '~':
b.WriteByte(c)
default:
fmt.Fprintf(&b, "%%%02X", c)
}
}
return b.String()
}
+94
View File
@@ -0,0 +1,94 @@
package main
import (
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
)
func TestSafeFileName(t *testing.T) {
for in, want := range map[string]string{
"photo.jpg": "photo.jpg",
"../../etc/passwd": "passwd",
`C:\Users\me\notes.txt`: "notes.txt",
"dir/sub/file.bin": "file.bin",
"..": "file",
"": "file",
"a\x00b\n.txt": "ab.txt",
" spaced name.pkg ": "spaced name.pkg",
"save (1).zip": "save (1).zip",
"/data/tailscale/x.json": "x.json",
} {
if got := safeFileName(in); got != want {
t.Errorf("safeFileName(%q) = %q, want %q", in, got, want)
}
}
}
func TestSaveReceivedNeverOverwrites(t *testing.T) {
dir := filepath.Join(t.TempDir(), "received")
var names []string
for _, content := range []string{"one", "two", "three"} {
name, err := saveReceived(dir, "../save.zip", strings.NewReader(content))
if err != nil {
t.Fatal(err)
}
names = append(names, name)
}
want := []string{"save.zip", "save (2).zip", "save (3).zip"}
for i := range want {
if names[i] != want[i] {
t.Errorf("file %d was named %q, want %q", i, names[i], want[i])
}
}
if b, _ := os.ReadFile(filepath.Join(dir, "save.zip")); string(b) != "one" {
t.Errorf("the first file was changed: %q", b)
}
// Nothing escaped the folder.
if _, err := os.Stat(filepath.Join(filepath.Dir(dir), "save.zip")); err == nil {
t.Error("a file was written outside the folder")
}
files := listReceived(dir, 2)
if len(files) != 2 {
t.Errorf("listReceived returned %d files, want 2", len(files))
}
if got := listReceived(filepath.Join(dir, "missing"), 10); len(got) != 0 {
t.Errorf("a missing folder listed %d files", len(got))
}
}
func TestFileDownload(t *testing.T) {
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "page.html"), []byte("<script>alert(1)</script>"), 0o644)
os.WriteFile(filepath.Join(filepath.Dir(dir), "secret.txt"), []byte("secret"), 0o644)
cfg := defaultConfig()
cfg.ReceiveDir = dir
d := &daemon{cfg: cfg, logf: t.Logf}
get := func(name string) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
d.handleFileGet(rec, httptest.NewRequest("GET", "/api/files/get?name="+url.QueryEscape(name), nil))
return rec
}
rec := get("page.html")
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "alert") {
t.Fatalf("download: %d", rec.Code)
}
// Served as a download, not as a page of this origin.
if ct := rec.Header().Get("Content-Type"); ct != "application/octet-stream" {
t.Errorf("Content-Type = %q", ct)
}
if cd := rec.Header().Get("Content-Disposition"); !strings.HasPrefix(cd, "attachment") {
t.Errorf("Content-Disposition = %q", cd)
}
for _, name := range []string{"../secret.txt", "..", "", "missing.txt", `..\secret.txt`, "sub/page.html"} {
if rec := get(name); rec.Code != http.StatusNotFound {
t.Errorf("%q: status %d", name, rec.Code)
}
}
}
+14 -2
View File
@@ -14,11 +14,15 @@ import (
// 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"
// A variable so that test builds can point it elsewhere (-ldflags -X).
var releasesAPI = "https://api.github.com/repos/holdmysocks/ps5-tailscale/releases/latest"
type releaseInfo struct {
Version string // without the leading "v"
URL string
// Assets maps the names of the release's files to where they are
// downloaded from.
Assets map[string]string
}
// parseVersion reads "v1.2.3" or "1.2.3-dev" as its three numbers.
@@ -74,11 +78,19 @@ func fetchLatestRelease(ctx context.Context, url string) (releaseInfo, error) {
var rel struct {
TagName string `json:"tag_name"`
HTMLURL string `json:"html_url"`
Assets []struct {
Name string `json:"name"`
URL string `json:"browser_download_url"`
} `json:"assets"`
}
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
info := releaseInfo{Version: strings.TrimPrefix(rel.TagName, "v"), URL: rel.HTMLURL, Assets: map[string]string{}}
for _, a := range rel.Assets {
info.Assets[a.Name] = a.URL
}
return info, nil
}
type httpStatusError struct{ status string }
+21 -2
View File
@@ -66,7 +66,11 @@ type statusInfo struct {
// LatestVersion and UpdateURL are set when a newer release exists.
LatestVersion string `json:"latestVersion,omitempty"`
UpdateURL string `json:"updateURL,omitempty"`
Uptime int64 `json:"uptimeSeconds"`
// CanUpdate says that the newer release can be installed from the page;
// Update reports on an installation in progress.
CanUpdate bool `json:"canUpdate"`
Update updateProgress `json:"update"`
Uptime int64 `json:"uptimeSeconds"`
}
// webHandler builds the status page and its API.
@@ -99,6 +103,8 @@ func (d *daemon) webHandler() http.Handler {
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))
mux.HandleFunc("GET /api/files", d.protect(d.handleFiles))
mux.HandleFunc("GET /api/files/get", d.protect(d.handleFileGet))
for path, h := range map[string]http.HandlerFunc{
"/api/config": d.handleSetConfig,
"/api/login": d.handleLogin,
@@ -106,6 +112,7 @@ func (d *daemon) webHandler() http.Handler {
"/api/quit": d.handleQuit,
"/api/uninstall": d.handleUninstall,
"/api/sunshine": d.handleSunshine,
"/api/update": d.handleUpdate,
} {
mux.HandleFunc("POST "+path, d.protect(d.guard(h)))
}
@@ -141,7 +148,7 @@ func writeFileTail(w io.Writer, path string, max int64) {
if fi, err := f.Stat(); err == nil && fi.Size() > max {
f.Seek(fi.Size()-max, io.SeekStart)
}
io.Copy(w, f)
io.Copy(w, onlyReader{f}) // no sendfile, see sendToLoader
}
func (d *daemon) guard(h http.HandlerFunc) http.HandlerFunc {
@@ -198,7 +205,9 @@ func (d *daemon) handleStatus(w http.ResponseWriter, r *http.Request) {
}
if newerVersion(version, d.latest.Version) {
info.LatestVersion, info.UpdateURL = d.latest.Version, d.latest.URL
info.CanUpdate = canInstall(d.latest)
}
info.Update = d.update
d.mu.Unlock()
info.UDPPorts = []uint16{}
if d.udp != nil {
@@ -417,3 +426,13 @@ func stopRunningInstance(webAddr string) bool {
}
return true
}
// handleUpdate starts installing the newest release.
func (d *daemon) handleUpdate(w http.ResponseWriter, r *http.Request) {
if err := d.startUpdate(); err != nil {
http.Error(w, err.Error(), http.StatusConflict)
return
}
d.logf("update requested from the status page")
io.WriteString(w, "The update has started.\n")
}