diff --git a/README.md b/README.md index 5f252fb..211f6b8 100644 --- a/README.md +++ b/README.md @@ -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 :`. 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://: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). diff --git a/docs/BUILDING.md b/docs/BUILDING.md index 5edc909..a380c4b 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -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=', 'main.releasesAPI=http://127.0.0.1:18099/latest.json' +``` + ## Sending to the console ```powershell diff --git a/docs/TECHNICAL.md b/docs/TECHNICAL.md index ad44791..13009eb 100644 --- a/docs/TECHNICAL.md +++ b/docs/TECHNICAL.md @@ -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/-uid-/`. 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 diff --git a/tools/build-payload.ps1 b/tools/build-payload.ps1 index de1a50e..9db00a8 100644 --- a/tools/build-payload.ps1 +++ b/tools/build-payload.ps1 @@ -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 diff --git a/tools/make-release.ps1 b/tools/make-release.ps1 new file mode 100644 index 0000000..74f2124 --- /dev/null +++ b/tools/make-release.ps1 @@ -0,0 +1,43 @@ +# Build the files of a release into out\release-: +# 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" diff --git a/tsd/cmd/signrelease/main.go b/tsd/cmd/signrelease/main.go new file mode 100644 index 0000000..3f450a4 --- /dev/null +++ b/tsd/cmd/signrelease/main.go @@ -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 ") + 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 \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 +} diff --git a/tsd/cmd/signrelease/main_test.go b/tsd/cmd/signrelease/main_test.go new file mode 100644 index 0000000..e01c2c8 --- /dev/null +++ b/tsd/cmd/signrelease/main_test.go @@ -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") + } +} diff --git a/tsd/config.go b/tsd/config.go index 4e4994f..d6fd26c 100644 --- a/tsd/config.go +++ b/tsd/config.go @@ -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 = "" } diff --git a/tsd/go.mod b/tsd/go.mod index 379c8f6..1a07202 100644 --- a/tsd/go.mod +++ b/tsd/go.mod @@ -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 diff --git a/tsd/main.go b/tsd/main.go index 1255238..1c81b2e 100644 --- a/tsd/main.go +++ b/tsd/main.go @@ -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) diff --git a/tsd/relsig/relsig.go b/tsd/relsig/relsig.go new file mode 100644 index 0000000..2524e00 --- /dev/null +++ b/tsd/relsig/relsig.go @@ -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 +} diff --git a/tsd/relsig/relsig_test.go b/tsd/relsig/relsig_test.go new file mode 100644 index 0000000..9e6261a --- /dev/null +++ b/tsd/relsig/relsig_test.go @@ -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") + } +} diff --git a/tsd/selfupdate.go b/tsd/selfupdate.go new file mode 100644 index 0000000..40daa40 --- /dev/null +++ b/tsd/selfupdate.go @@ -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 +} diff --git a/tsd/selfupdate_test.go b/tsd/selfupdate_test.go new file mode 100644 index 0000000..8d4189b --- /dev/null +++ b/tsd/selfupdate_test.go @@ -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("not found"), "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) + } + } +} diff --git a/tsd/settings.go b/tsd/settings.go index 348f80e..67eb112 100644 --- a/tsd/settings.go +++ b/tsd/settings.go @@ -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 diff --git a/tsd/status.html b/tsd/status.html index c87acf2..90d88dc 100644 --- a/tsd/status.html +++ b/tsd/status.html @@ -101,6 +101,11 @@ + +