4 Commits
Author SHA1 Message Date
holdmysocks b31667d3f0 Recommend a fixed file name for the copy an autoloader starts 2026-10-06 18:38:48 -04:00
holdmysocks edbaaed240 Say which DNS servers were tried when falling back 2026-10-06 18:33:29 -04:00
holdmysocks 4c6d9223b5 Do without a DNS payload; name the release file with its version
- Name lookups no longer depend on a DNS payload at 127.0.0.1:53. The
  daemon probes that address, the default gateway and three public
  resolvers, uses the first that answers, and looks again every five
  minutes or when the network changes.
- The release payload is now tailscale-<version>.elf. The updater looks
  for that name and still understands the old one. Releases up to 0.6.0
  only know the old name and have to be updated by hand once.
- The status page warns, next to Install and before installing, that the
  copy a payload manager or autoloader starts is not the one being
  replaced, unless it is named in the settings.
- Screenshot updated.
2026-10-06 18:25:31 -04:00
holdmysocks 070e838135 Update the status page screenshot for 0.6.0 2026-10-05 09:15:21 -04:00
12 changed files with 426 additions and 35 deletions

No files matched your search

+46 -13
View File
@@ -51,10 +51,14 @@ Tested on firmware 13.42 with elfldr 0.26.
## Install
There is one file, `tailscale.elf`, and it is an ordinary payload: running
it starts Tailscale.
There is one file, `tailscale-<version>.elf` (for example
`tailscale-0.6.1.elf`), and it is an ordinary payload: running it starts
Tailscale. The examples below call it `tailscale.elf`; use the name of the
file you downloaded, or rename it.
1. Download `tailscale.elf` from the [latest release](../../releases/latest).
1. Download `tailscale-<version>.elf` from the
[latest release](../../releases/latest). The `.sig` file next to it is
for the status page's Install button; you do not need it.
2. Send it to the console's ELF loader. Any payload sender works:
```bash
@@ -76,11 +80,20 @@ run also adds a **Tailscale** icon to the home screen (media section) that
opens the status page.
Tailscale runs until the console restarts. After a restart and jailbreak,
send `tailscale.elf` again; the login and settings are kept. If you use a
payload manager or autoloader, add the file there like any other payload.
send the file again; the login and settings are kept. If you use a payload
manager or autoloader, add the file there like any other payload.
To update, use the new `tailscale.elf` in place of the old one. Sending it
while Tailscale is running replaces the running copy.
To update, use the new file in place of the old one. Sending it while
Tailscale is running replaces the running copy. The file name changes with
every release, so **check your autoload settings after updating**: a payload
manager or autoloader that still points at the old file starts the old
version with the console, or nothing if you deleted it.
The easy way to avoid that: keep the copy your payload manager or autoloader
starts under a fixed name without a version, such as `tailscale.elf`. When
you update, save the new release over it under that same name, and the
autoload entry never needs to change. The status page always shows which
version is really running.
## Using it
@@ -150,11 +163,20 @@ 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.
sending the payload by hand does.
Installing from the page replaces the copy that is running, not the file
your payload manager or autoloader starts with the console. **Check your
autoload settings after updating**, or the old version is back after the
next restart. To have that file replaced as well, put its path under
**Payload file to keep up to date** in the settings, for example
`/data/pldmgr/payloads/Tailscale/tailscale.elf`. The file keeps the name it
has there, whatever version is in it, so your autoload entry keeps working.
That is one more reason to give that copy a fixed name such as
`tailscale.elf` and not the versioned name it was downloaded under.
Releases up to 0.6.0 cannot install later ones from the page, because the
release files were renamed after 0.6.0; update those by hand once.
**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
@@ -284,7 +306,7 @@ optional.
| `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. |
| `payloadPath` | The copy of the payload that is started with the console, if there is one; best kept under a fixed name such as `tailscale.elf`. An update installed from the status page replaces its contents and leaves its name alone. 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. |
@@ -331,6 +353,13 @@ Left to do by hand:
`127.0.0.1` (or `127.0.0.1:<port>` for a host on another port), and the
Sunshine host must be listed on the status page with the port its Sunshine
uses.
- **It stays on "Starting", or the update check never finds anything.** The
daemon looks names up itself: at a DNS payload on the console
(`127.0.0.1:53`) if one is running, otherwise at your router, otherwise at
a public resolver (1.1.1.1, 8.8.8.8, 9.9.9.9). The log says which one it
uses in a line starting with `DNS:`. If none answers, the console has no
working internet connection for payloads. The DNS server set in the PS5's
network settings plays no part.
- **"Not logged in" after logging in.** Press **Log in again** for a fresh
link.
- **Forgot the status page password.** Delete the `passwordHash` line from
@@ -354,6 +383,10 @@ 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.
- Name lookups by the daemon go to the console's DNS payload if there is
one, otherwise to your router or a public resolver. They are only the
daemon's own lookups (Tailscale's servers, GitHub, what you send through
the HTTP proxy), not the console's.
- 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
+3 -1
View File
@@ -65,7 +65,9 @@ the launcher.
```
builds the payload and puts three files in `out\release-1.2.3`:
`tailscale.elf`, its signature `tailscale.elf.sig`, and `SHA256SUMS.txt`.
`tailscale-1.2.3.elf`, its signature `tailscale-1.2.3.elf.sig`, and
`SHA256SUMS.txt`. The version in the file name is what the status page looks
for (`payloadAssetName` in `tsd\selfupdate.go`).
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.
+7 -2
View File
@@ -66,7 +66,7 @@ way is a failure in the SDK's crt, which runs before any of this.
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
release's `tailscale-<version>.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
@@ -163,7 +163,12 @@ apply `SOCK_NONBLOCK`/`SOCK_CLOEXEC` with `fcntl`.
blocks and hands the converted entries out across calls.
- Unix domain sockets cannot be bound on `/data`.
- There is no `/etc/resolv.conf` and no CA bundle. Go's resolver falls back
to `127.0.0.1:53`; the daemon imports `x509roots/fallback` for TLS roots.
to `127.0.0.1:53`, which only answers if a DNS payload runs on the
console. The daemon therefore installs its own resolver (`dns.go`): it
probes `127.0.0.1:53`, the default gateway and three public resolvers,
uses the first that answers, and checks again every five minutes or when
the network changes. The daemon imports `x509roots/fallback` for TLS
roots.
- A payload's stdin, stdout and stderr are the ELF loader's TCP connection.
Go kills a process whose write to fd 1 or 2 fails with `EPIPE`, so the
daemon moves that connection to another descriptor and points 1 and 2 at
Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 108 KiB

+5 -4
View File
@@ -1,6 +1,6 @@
# 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
# tailscale-<version>.elf the payload
# tailscale-<version>.elf.sig its signature, which the status page's "Install" checks
# SHA256SUMS.txt
#
# .\tools\make-release.ps1 -Version 1.2.3
@@ -19,7 +19,8 @@ if ($Version -notmatch '^\d+\.\d+\.\d+$') { throw "version must look like 1.2.3,
$rel = Join-Path $DevRoot "out\release-$Version"
New-Item -ItemType Directory -Force $rel | Out-Null
$elf = Join-Path $rel 'tailscale.elf'
$name = "tailscale-$Version.elf" # the name the status page's Install looks for
$elf = Join-Path $rel $name
Copy-Item (Join-Path $DevRoot 'out\tailscale.elf') $elf -Force
Push-Location (Join-Path $DevRoot 'tsd')
@@ -37,7 +38,7 @@ try {
} finally { Pop-Location }
$hash = (Get-FileHash $elf -Algorithm SHA256).Hash.ToLower()
[IO.File]::WriteAllText((Join-Path $rel 'SHA256SUMS.txt'), "$hash tailscale.elf`n")
[IO.File]::WriteAllText((Join-Path $rel 'SHA256SUMS.txt'), "$hash $name`n")
Get-ChildItem $rel | Select-Object Name, Length | Format-Table -AutoSize
Write-Host "release files are in $rel"
+164
View File
@@ -0,0 +1,164 @@
package main
import (
"context"
"encoding/binary"
"net"
"sync"
"time"
"tailscale.com/net/netmon"
)
// Name lookups by the daemon itself: Tailscale's servers, GitHub for the
// update check, whatever goes through the HTTP proxy.
//
// The PS5 has no resolv.conf, so Go asks 127.0.0.1:53. That only works on a
// console that runs a DNS payload, which most jailbreak setups do and some do
// not. Rather than depend on it, the daemon uses the first of these that
// answers: the local DNS payload, the router, a public resolver.
// dnsLocal is where a DNS payload on the console listens. A variable so that
// a test build can pretend there is none.
var dnsLocal = "127.0.0.1:53"
// dnsPublic are used when neither the console nor the router answers.
var dnsPublic = []string{"1.1.1.1:53", "8.8.8.8:53", "9.9.9.9:53"}
const (
dnsRecheckGood = 5 * time.Minute // how long a working server is kept
dnsRecheckBad = 20 * time.Second // how soon to look again when none works
dnsProbeWait = 1200 * time.Millisecond
)
type dnsPicker struct {
logf func(format string, args ...any)
// candidates lists the servers to try, in order of preference.
candidates func() []string
// probe reports whether a server answers queries.
probe func(ctx context.Context, server string) bool
mu sync.Mutex
server string
working bool
checked time.Time
}
func newDNSPicker(logf func(format string, args ...any)) *dnsPicker {
return &dnsPicker{logf: logf, candidates: dnsCandidates, probe: dnsAnswers}
}
// dnsCandidates returns the local DNS payload, the router and the public
// resolvers, in that order.
func dnsCandidates() []string {
list := []string{dnsLocal}
if gw, _, ok := netmon.LikelyHomeRouterIP(); ok && gw.IsValid() {
list = append(list, net.JoinHostPort(gw.String(), "53"))
}
return append(list, dnsPublic...)
}
// pick returns the server to ask. The choice is kept for a while, so the
// candidates are not probed for every lookup.
func (p *dnsPicker) pick(ctx context.Context) string {
p.mu.Lock()
defer p.mu.Unlock()
keep := dnsRecheckBad
if p.working {
keep = dnsRecheckGood
}
if p.server != "" && time.Since(p.checked) < keep {
return p.server
}
candidates := p.candidates()
chosen, working := candidates[0], false
for _, c := range candidates {
if p.probe(ctx, c) {
chosen, working = c, true
break
}
}
if chosen != p.server || working != p.working {
switch {
case !working:
p.logf("DNS: no server answers (tried %v); name lookups will fail until one does", candidates)
case chosen == candidates[0]:
p.logf("DNS: using the console's own DNS at %s", chosen)
default:
p.logf("DNS: nothing answers at %s; using %s instead (in order of preference: %v)", candidates[0], chosen, candidates)
}
}
p.server, p.working, p.checked = chosen, working, time.Now()
return chosen
}
// reset makes the next lookup choose again, for when the network changed.
func (p *dnsPicker) reset() {
p.mu.Lock()
p.checked = time.Time{}
p.mu.Unlock()
}
// dial is the resolver's Dial: whatever address Go wants to ask, the chosen
// server is asked instead.
func (p *dnsPicker) dial(ctx context.Context, network, _ string) (net.Conn, error) {
var d net.Dialer
return d.DialContext(ctx, network, p.pick(ctx))
}
// install makes every name lookup in the process go through the picker.
func (p *dnsPicker) install() {
net.DefaultResolver = &net.Resolver{PreferGo: true, Dial: p.dial}
}
// dnsAnswers asks server for the address of a name that certainly exists
// and reports whether a proper answer came back.
func dnsAnswers(ctx context.Context, server string) bool {
ctx, cancel := context.WithTimeout(ctx, dnsProbeWait)
defer cancel()
var d net.Dialer
c, err := d.DialContext(ctx, "udp", server)
if err != nil {
return false
}
defer c.Close()
c.SetDeadline(time.Now().Add(dnsProbeWait))
query := dnsQuery(uint16(time.Now().UnixNano()), "github.com")
if _, err := c.Write(query); err != nil {
return false
}
buf := make([]byte, 1500)
n, err := c.Read(buf)
if err != nil {
return false
}
return dnsReplyOK(query, buf[:n])
}
// dnsQuery builds a query for the IPv4 address of name.
func dnsQuery(id uint16, name string) []byte {
q := make([]byte, 12, 64)
binary.BigEndian.PutUint16(q[0:], id)
q[2] = 0x01 // recursion desired
binary.BigEndian.PutUint16(q[4:], 1)
start := 0
for i := 0; i <= len(name); i++ {
if i == len(name) || name[i] == '.' {
q = append(q, byte(i-start))
q = append(q, name[start:i]...)
start = i + 1
}
}
return append(q, 0, 0, 1, 0, 1) // root label, type A, class IN
}
// dnsReplyOK reports whether reply is a successful answer to query.
func dnsReplyOK(query, reply []byte) bool {
if len(reply) < 12 || reply[0] != query[0] || reply[1] != query[1] {
return false
}
isResponse := reply[2]&0x80 != 0
rcode := reply[3] & 0x0f
answers := binary.BigEndian.Uint16(reply[6:])
return isResponse && rcode == 0 && answers > 0
}
+136
View File
@@ -0,0 +1,136 @@
package main
import (
"context"
"net"
"strings"
"testing"
"time"
)
func testPicker(t *testing.T, candidates []string, answering map[string]bool) (*dnsPicker, *[]string, *[]string) {
var logs, probed []string
p := &dnsPicker{
logf: func(format string, args ...any) { logs = append(logs, format) },
candidates: func() []string { return candidates },
probe: func(ctx context.Context, server string) bool {
probed = append(probed, server)
return answering[server]
},
}
return p, &logs, &probed
}
func TestDNSPicker(t *testing.T) {
candidates := []string{"127.0.0.1:53", "192.168.1.1:53", "1.1.1.1:53"}
answering := map[string]bool{"127.0.0.1:53": true, "192.168.1.1:53": true, "1.1.1.1:53": true}
p, logs, probed := testPicker(t, candidates, answering)
ctx := context.Background()
// The console's own DNS is preferred, and the choice is kept.
if got := p.pick(ctx); got != "127.0.0.1:53" {
t.Fatalf("picked %s", got)
}
p.pick(ctx)
if len(*probed) != 1 {
t.Errorf("probed %v; the choice should have been kept", *probed)
}
// No DNS payload: the router is used, and that is logged.
answering["127.0.0.1:53"] = false
p.reset()
if got := p.pick(ctx); got != "192.168.1.1:53" {
t.Errorf("without a local DNS: picked %s", got)
}
if last := (*logs)[len(*logs)-1]; !strings.Contains(last, "instead") {
t.Errorf("log: %q", last)
}
// Nor the router: a public resolver.
answering["192.168.1.1:53"] = false
p.reset()
if got := p.pick(ctx); got != "1.1.1.1:53" {
t.Errorf("without local DNS or router: picked %s", got)
}
// Nothing at all: stay with the first, and look again soon.
answering["1.1.1.1:53"] = false
p.reset()
if got := p.pick(ctx); got != "127.0.0.1:53" {
t.Errorf("with nothing answering: picked %s", got)
}
if p.working {
t.Error("the picker thinks it has a working server")
}
before := len(*probed)
p.checked = time.Now().Add(-dnsRecheckBad - time.Second)
answering["127.0.0.1:53"] = true
if got := p.pick(ctx); got != "127.0.0.1:53" || len(*probed) == before || !p.working {
t.Errorf("after the short wait: picked %s, working %v", got, p.working)
}
}
func TestDNSQueryAndReply(t *testing.T) {
q := dnsQuery(0x1234, "github.com")
want := []byte{0x12, 0x34, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 6, 'g', 'i', 't', 'h', 'u', 'b', 3, 'c', 'o', 'm', 0, 0, 1, 0, 1}
if string(q) != string(want) {
t.Fatalf("query = % x", q)
}
reply := func(id0, id1, flags2, flags3 byte, answers byte) []byte {
return []byte{id0, id1, flags2, flags3, 0, 1, 0, answers, 0, 0, 0, 0}
}
for name, tt := range map[string]struct {
r []byte
want bool
}{
"good answer": {reply(0x12, 0x34, 0x81, 0x80, 2), true},
"another query's id": {reply(0x12, 0x35, 0x81, 0x80, 2), false},
"server failure": {reply(0x12, 0x34, 0x81, 0x82, 0), false},
"no such name": {reply(0x12, 0x34, 0x81, 0x83, 0), false},
"no answers": {reply(0x12, 0x34, 0x81, 0x80, 0), false},
"not a response": {reply(0x12, 0x34, 0x01, 0x00, 1), false},
"too short": {[]byte{0x12, 0x34}, false},
} {
if got := dnsReplyOK(q, tt.r); got != tt.want {
t.Errorf("%s: got %v", name, got)
}
}
}
// A real exchange over UDP with a stand-in server, and a port where nothing
// answers.
func TestDNSAnswers(t *testing.T) {
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer pc.Close()
go func() {
buf := make([]byte, 1500)
for {
n, from, err := pc.ReadFrom(buf)
if err != nil {
return
}
r := append([]byte(nil), buf[:n]...)
r[2], r[3], r[7] = 0x81, 0x80, 1 // response, no error, one answer
pc.WriteTo(r, from)
}
}()
if !dnsAnswers(context.Background(), pc.LocalAddr().String()) {
t.Error("a server that answers was reported as silent")
}
silent, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer silent.Close()
start := time.Now()
if dnsAnswers(context.Background(), silent.LocalAddr().String()) {
t.Error("a silent server was reported as answering")
}
if time.Since(start) > 3*time.Second {
t.Errorf("the probe took %v", time.Since(start))
}
}
+14 -1
View File
@@ -15,6 +15,7 @@ import (
"os"
"os/signal"
"path/filepath"
"runtime"
"slices"
"strings"
"sync"
@@ -64,6 +65,14 @@ func main() {
}
logf("ps5-tailscale %s starting, hostname %q, web UI on %s", version, cfg.Hostname, cfg.WebAddr)
// On the console, name lookups go to whichever DNS server answers; see
// dns.go. Elsewhere the system's resolver is left alone.
var dns *dnsPicker
if runtime.GOOS == "freebsd" {
dns = newDNSPicker(logf)
dns.install()
}
// A payload that is sent again replaces the running instance, which is
// how an upgrade or a restart is done.
if stopRunningInstance(cfg.WebAddr) {
@@ -92,7 +101,7 @@ func main() {
// 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()}
d := &daemon{dns: dns, 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)
notify("Tailscale failed to start:\n%v", err)
@@ -111,6 +120,7 @@ type daemon struct {
srv *tsnet.Server
lc *local.Client
fwd *forwarder
dns *dnsPicker // nil when the system's resolver is used
udp *udpExposer
mu sync.Mutex
@@ -256,6 +266,9 @@ func (d *daemon) networkChanged() {
d.lastNetChange = time.Now()
lc := d.lc
d.mu.Unlock()
if d.dns != nil {
d.dns.reset()
}
if recent || lc == nil {
return
}
+25 -5
View File
@@ -33,7 +33,6 @@ import (
var updatePublicKey = "HUcHCXGe3vNEeyt4frmoKZUqYDamdA1dzsr+Hsybda0="
const (
payloadAsset = "tailscale.elf"
maxPayload = 128 << 20
loaderAddr = "127.0.0.1:9021"
updateDirName = "update"
@@ -57,7 +56,24 @@ func (d *daemon) setUpdate(p updateProgress) {
// 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] != ""
_, _, _, ok := releaseAssets(rel)
return ok && newerVersion(version, rel.Version)
}
// payloadAssetName is what the payload of a release is called: the version is
// part of the name, so that a downloaded file says what it is.
func payloadAssetName(ver string) string { return "tailscale-" + ver + ".elf" }
// releaseAssets finds a release's payload and its signature. Releases up to
// 0.6.0 called the payload plain "tailscale.elf"; that name is still
// understood.
func releaseAssets(rel releaseInfo) (name, payloadURL, sigURL string, ok bool) {
for _, name := range []string{payloadAssetName(rel.Version), "tailscale.elf"} {
if p, s := rel.Assets[name], rel.Assets[name+relsig.FileSuffix]; p != "" && s != "" {
return name, p, s, true
}
}
return "", "", "", false
}
// startUpdate begins installing the newest known release. It returns at
@@ -96,7 +112,11 @@ func (d *daemon) runUpdate(rel releaseInfo) error {
defer cancel()
// The signature first: it is small, and says what the payload must be.
sigBytes, err := httpGetSmall(ctx, rel.Assets[payloadAsset+relsig.FileSuffix])
name, payloadURL, sigURL, ok := releaseAssets(rel)
if !ok {
return errors.New("that release has no signed payload")
}
sigBytes, err := httpGetSmall(ctx, sigURL)
if err != nil {
return fmt.Errorf("downloading the signature: %w", err)
}
@@ -109,8 +129,8 @@ func (d *daemon) runUpdate(rel releaseInfo) error {
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)
file := filepath.Join(dir, name)
sum, err := d.download(ctx, payloadURL, file, rel.Version)
if err != nil {
os.Remove(file)
return fmt.Errorf("downloading the payload: %w", err)
+6 -2
View File
@@ -66,14 +66,18 @@ func TestBuiltInKeyIsValid(t *testing.T) {
func TestCanInstall(t *testing.T) {
withVersion(t, "1.0.0")
both := map[string]string{"tailscale.elf": "u1", "tailscale.elf.sig": "u2"}
both := map[string]string{"tailscale-1.1.0.elf": "u1", "tailscale-1.1.0.elf.sig": "u2"}
oldNames := map[string]string{"tailscale.elf": "u1", "tailscale.elf.sig": "u2"}
otherVersion := map[string]string{"tailscale-1.0.9.elf": "u1", "tailscale-1.0.9.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", Assets: oldNames}, true},
{releaseInfo{Version: "1.1.0", Assets: otherVersion}, false},
{releaseInfo{Version: "1.1.0", Assets: map[string]string{"tailscale-1.1.0.elf": "u1"}}, false},
{releaseInfo{Version: "1.1.0"}, false},
} {
if got := canInstall(tt.rel); got != tt.want {
+14 -4
View File
@@ -104,6 +104,7 @@
<div class="actions" style="margin-top: 12px">
<button id="btn-update" class="hidden">Install it</button>
</div>
<p class="health hidden" id="updatewarn" style="padding-left: 0"></p>
<p class="note hidden" id="updatestate" style="margin: 12px 0 0"></p>
<p class="msg hidden" id="updatemsg"></p>
</section>
@@ -226,9 +227,11 @@
</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>
<span class="hint">Where the copy 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; it keeps its name, and your autoload entry keeps working. Give that copy a
fixed name such as <code>tailscale.elf</code>, without a version in it, so the name stays true.
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">
@@ -444,6 +447,12 @@ async function refresh() {
}
$('btn-update').classList.toggle('hidden', !s.canUpdate || busy);
$('btn-update').textContent = 'Install ' + (s.latestVersion || 'it');
// Installing replaces the running copy. What starts with the console is
// a file somewhere else, which the user has to keep an eye on.
autoloadWarning = s.payloadPath
? 'Installing also replaces the copy at ' + s.payloadPath + '. Afterwards, check your autoload settings: your payload manager or autoloader must still start that file.'
: 'After installing, check your autoload settings. If a payload manager or autoloader starts an older Tailscale file with the console, the old version comes back after a restart: replace that file with the new release, or name it under Settings ("Payload file to keep up to date") before installing.';
show('updatewarn', s.canUpdate && !busy ? autoloadWarning : '');
show('updatestate', {
downloading: 'Downloading version ' + upd.version + (upd.percent ? ' (' + upd.percent + '%)' : '') + '…',
verifying: 'Checking the signature of version ' + upd.version + '…',
@@ -575,9 +584,10 @@ async function refreshFiles() {
}
let updateRefused = ''; // why the daemon would not start an update
let autoloadWarning = '';
$('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;
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.\n\n' + autoloadWarning)) return;
updateRefused = '';
try {
const r = await api('/api/update', {method: 'POST'});
+6 -3
View File
@@ -68,9 +68,11 @@ type statusInfo struct {
UpdateURL string `json:"updateURL,omitempty"`
// 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"`
CanUpdate bool `json:"canUpdate"`
// PayloadPath is the copy an update replaces as well, if one is set.
PayloadPath string `json:"payloadPath,omitempty"`
Update updateProgress `json:"update"`
Uptime int64 `json:"uptimeSeconds"`
}
// webHandler builds the status page and its API.
@@ -208,6 +210,7 @@ func (d *daemon) handleStatus(w http.ResponseWriter, r *http.Request) {
info.CanUpdate = canInstall(d.latest)
}
info.Update = d.update
info.PayloadPath = d.cfg.PayloadPath
d.mu.Unlock()
info.UDPPorts = []uint16{}
if d.udp != nil {