mirror of
https://github.com/holdmysocks/ps5-tailscale.git
synced 2026-10-07 18:00:20 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4986b4d52 |
No files matched your search
@@ -273,6 +273,29 @@ follow, and HTTPS sites complain about the certificate, because the browser
|
||||
sees `127.0.0.1`; plain HTTP services work best, and the tailnet encrypts
|
||||
the connection anyway.
|
||||
|
||||
**test** next to a line tries the connection the way the PS5 would make it
|
||||
and says what happened: the device answers, the device answered but nothing
|
||||
listens on that port, there is no answer (off, asleep or firewalled), or
|
||||
there is no device with that name. The game streaming hosts have the same
|
||||
link, which checks whether Sunshine answers.
|
||||
|
||||
### Waking a PC at home
|
||||
|
||||
A sleeping PC cannot be reached over Tailscale, because nothing on it is
|
||||
running. The console is on the same home network, though, and can send it a
|
||||
Wake-on-LAN packet:
|
||||
|
||||
1. Under **Wake a device at home** on the status page, press **Add a
|
||||
device**, give it a name and its network card's MAC address (on Windows,
|
||||
the "Physical address" in `ipconfig /all`), and press **Save**.
|
||||
2. From wherever you are, open the status page over Tailscale and press
|
||||
**Wake**. Give the PC half a minute, then connect to it.
|
||||
|
||||
The PC needs Wake-on-LAN turned on, in its firmware setup and in the network
|
||||
card's settings, and it works most reliably over a cable. The console sends
|
||||
the packet to every device on its network; there is no reply, so the page
|
||||
cannot tell whether the PC heard it. Only devices in the list can be woken.
|
||||
|
||||
### Other apps on the console
|
||||
|
||||
The daemon can also run an HTTP proxy that reaches tailnet hosts, for apps
|
||||
@@ -329,6 +352,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. |
|
||||
| `wake` | Devices the status page can wake with Wake-on-LAN: a `name` and the network card's `mac` each. |
|
||||
| `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. |
|
||||
@@ -393,9 +417,13 @@ Left to do by hand:
|
||||
- **Forgot the status page password.** Delete the `passwordHash` line from
|
||||
`/data/tailscale/config.json` and start Tailscale again, or use the page on
|
||||
the console itself, where no password is asked.
|
||||
- **Something else.** `http://<console>:8090/api/logs?full=1` is the daemon's
|
||||
log and `/api/logs?debug=1` is Tailscale's detailed log. Please attach them
|
||||
to bug reports, after checking them for anything you consider private.
|
||||
- **Something else.** Press **Download diagnostics** on the status page and
|
||||
attach the file to your bug report. It holds the logs, the version, the
|
||||
firmware and the settings. E-mail addresses, your tailnet's name, public IP
|
||||
addresses, keys and the password are removed from it; device names and
|
||||
tailnet addresses are not, so read it before posting. If the status page
|
||||
never comes up, there is nothing to press: fetch
|
||||
`/data/tailscale/launcher.log` over FTP instead.
|
||||
|
||||
## Security
|
||||
|
||||
@@ -440,7 +468,7 @@ LAN and the tailnet, changing settings from the page, both priority settings,
|
||||
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, reaching a device through a forward set up on the page, the
|
||||
connection test, limiting connections to your own devices (with the
|
||||
connection tests, the diagnostics file, limiting connections to your own devices (with the
|
||||
console's owner's devices only; a refusal has not been seen for real), a
|
||||
stay in rest mode, both a minute and nine and a half hours: the same process
|
||||
carried on and was back on the tailnet after waking.
|
||||
@@ -448,8 +476,9 @@ carried on and was back on the tailnet after waking.
|
||||
Remote Play through the tailnet address works with Chiaki and with Asobi on
|
||||
iOS and Android.
|
||||
|
||||
Not tested: switching between Wi-Fi and Ethernet while running, rest mode on
|
||||
Wi-Fi, the complete Uninstall
|
||||
Not tested: whether a PC actually wakes from the Wake button (the console
|
||||
reports sending the packets; no sleeping PC was at hand), switching between
|
||||
Wi-Fi and Ethernet while running, rest mode on Wi-Fi, the complete Uninstall
|
||||
on a console (its parts were tested separately), whether High priority
|
||||
improves Remote Play, a real Sunshine host on a non-default port, other
|
||||
firmware versions, coordination servers other than Tailscale's.
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 159 KiB |
@@ -53,6 +53,9 @@ type config struct {
|
||||
PayloadPath string `json:"payloadPath,omitempty"`
|
||||
// ReceiveDir is where files sent to the console with Taildrop end up.
|
||||
ReceiveDir string `json:"receiveDir"`
|
||||
// Wake lists devices on the console's home network that the status page
|
||||
// can wake with a Wake-on-LAN packet.
|
||||
Wake []wakeTarget `json:"wake,omitempty"`
|
||||
// Priority is how the daemon competes for CPU time: "low" (the default)
|
||||
// never takes time from a game, "high" shares the CPU with games on
|
||||
// equal terms, which can make Remote Play smoother. Applied at start.
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The diagnostics file: everything someone helping with a problem needs, in
|
||||
// one download, so that a bug report does not depend on getting files off
|
||||
// the console by hand.
|
||||
//
|
||||
// It goes on the internet when it is attached to a report, so what can be
|
||||
// left out without making it useless is left out or blanked: the password
|
||||
// hash, auth keys, login links, e-mail addresses, the tailnet's name and
|
||||
// public IP addresses. Device names and tailnet addresses stay; without them
|
||||
// the logs cannot be followed.
|
||||
|
||||
const (
|
||||
diagLauncherTail = 64 << 10
|
||||
diagMainTail = 256 << 10
|
||||
diagDebugTail = 512 << 10
|
||||
)
|
||||
|
||||
var (
|
||||
reEmail = regexp.MustCompile(`[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}`)
|
||||
reTailnet = regexp.MustCompile(`\b([A-Za-z0-9-]+)\.[A-Za-z0-9-]+\.ts\.net\b`)
|
||||
// The tailnet's name on its own, as it appears in DNS settings.
|
||||
reTailnetBare = regexp.MustCompile(`\b[A-Za-z0-9-]+\.ts\.net\b`)
|
||||
reLoginURL = regexp.MustCompile(`https://login\.tailscale\.com/a/[A-Za-z0-9]+`)
|
||||
reAuthKey = regexp.MustCompile(`tskey-[A-Za-z0-9-]+`)
|
||||
reIPv4 = regexp.MustCompile(`\b(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\b`)
|
||||
reFirmware = regexp.MustCompile(`firmware (\d+\.\d+)`)
|
||||
)
|
||||
|
||||
// scrub blanks what should not be published. It keeps the shape of the
|
||||
// text, so the logs still read as logs.
|
||||
func scrub(b []byte) []byte {
|
||||
b = reLoginURL.ReplaceAll(b, []byte("https://login.tailscale.com/a/<removed>"))
|
||||
b = reAuthKey.ReplaceAll(b, []byte("tskey-<removed>"))
|
||||
b = reEmail.ReplaceAll(b, []byte("<email>"))
|
||||
b = reTailnet.ReplaceAll(b, []byte("$1.<tailnet>.ts.net"))
|
||||
b = reTailnetBare.ReplaceAll(b, []byte("<tailnet>.ts.net"))
|
||||
return reIPv4.ReplaceAllFunc(b, func(ip []byte) []byte {
|
||||
if publicIPv4(string(ip)) {
|
||||
return []byte("<public-ip>")
|
||||
}
|
||||
return ip
|
||||
})
|
||||
}
|
||||
|
||||
// publicIPv4 reports whether s is an address on the internet, as opposed to
|
||||
// a private, tailnet, loopback or otherwise special one. Text that only
|
||||
// looks like an address (a version number, say) is left alone.
|
||||
func publicIPv4(s string) bool {
|
||||
var a, b, c, d int
|
||||
if n, _ := fmt.Sscanf(s, "%d.%d.%d.%d", &a, &b, &c, &d); n != 4 || a > 255 || b > 255 || c > 255 || d > 255 {
|
||||
return false
|
||||
}
|
||||
switch {
|
||||
case a == 0, a == 10, a == 127, a >= 224:
|
||||
return false
|
||||
case a == 100 && b >= 64 && b <= 127: // tailnet addresses
|
||||
return false
|
||||
case a == 169 && b == 254:
|
||||
return false
|
||||
case a == 172 && b >= 16 && b <= 31:
|
||||
return false
|
||||
case a == 192 && b == 168:
|
||||
return false
|
||||
case a == 192 && b == 0 && c == 2:
|
||||
return false
|
||||
}
|
||||
// A well-known public resolver says nothing about the user.
|
||||
switch s {
|
||||
case "1.1.1.1", "8.8.8.8", "9.9.9.9":
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func fileTail(path string, max int64) []byte {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return []byte("(" + err.Error() + ")\n")
|
||||
}
|
||||
defer f.Close()
|
||||
if fi, err := f.Stat(); err == nil && fi.Size() > max {
|
||||
f.Seek(fi.Size()-max, io.SeekStart)
|
||||
}
|
||||
b, _ := io.ReadAll(onlyReader{f})
|
||||
return b
|
||||
}
|
||||
|
||||
// diagnostics assembles the file.
|
||||
func (d *daemon) diagnostics(r *http.Request) []byte {
|
||||
var out bytes.Buffer
|
||||
section := func(title string) { fmt.Fprintf(&out, "\n===== %s =====\n", title) }
|
||||
|
||||
launcher := fileTail(filepath.Join(dataDir, "launcher.log"), diagLauncherTail)
|
||||
firmware := "unknown"
|
||||
if m := reFirmware.FindAllSubmatch(launcher, -1); len(m) > 0 {
|
||||
firmware = string(m[len(m)-1][1])
|
||||
}
|
||||
|
||||
d.mu.Lock()
|
||||
cfg := d.cfg
|
||||
state, lastErr := d.state, d.lastErr
|
||||
latest := d.latest.Version
|
||||
d.mu.Unlock()
|
||||
|
||||
fmt.Fprintf(&out, "ps5-tailscale diagnostics\n")
|
||||
fmt.Fprintf(&out, "Please read this file before posting it. E-mail addresses, the tailnet's name, public IP\n")
|
||||
fmt.Fprintf(&out, "addresses, keys and the password have been removed; device names and tailnet addresses have not.\n\n")
|
||||
fmt.Fprintf(&out, "version: %s (%s/%s)\n", version, runtime.GOOS, runtime.GOARCH)
|
||||
fmt.Fprintf(&out, "firmware: %s\n", firmware)
|
||||
fmt.Fprintf(&out, "created: %s\n", time.Now().UTC().Format("2006-01-02 15:04:05 UTC"))
|
||||
fmt.Fprintf(&out, "running for: %s\n", time.Since(d.started).Round(time.Second))
|
||||
fmt.Fprintf(&out, "state: %s\n", state)
|
||||
if lastErr != "" {
|
||||
fmt.Fprintf(&out, "last error: %s\n", lastErr)
|
||||
}
|
||||
if latest != "" {
|
||||
fmt.Fprintf(&out, "latest known: %s\n", latest)
|
||||
}
|
||||
if d.dns != nil {
|
||||
fmt.Fprintf(&out, "name lookups: %s\n", d.dns.describe())
|
||||
}
|
||||
if d.udp != nil {
|
||||
fmt.Fprintf(&out, "UDP ports: %v\n", d.udp.activePorts())
|
||||
}
|
||||
if d.fwd != nil {
|
||||
fmt.Fprintf(&out, "forwards: %d active\n", len(d.fwd.rules()))
|
||||
}
|
||||
|
||||
if d.lc != nil {
|
||||
if st, err := d.status(r.Context()); err == nil {
|
||||
section("tailscale")
|
||||
fmt.Fprintf(&out, "backend state: %s\n", st.BackendState)
|
||||
for _, h := range st.Health {
|
||||
fmt.Fprintf(&out, "health: %s\n", h)
|
||||
}
|
||||
if st.Self != nil {
|
||||
fmt.Fprintf(&out, "self: %s, addresses %v, relay %q, key expiry %v\n", st.Self.DNSName, st.Self.TailscaleIPs, st.Self.Relay, st.Self.KeyExpiry)
|
||||
}
|
||||
peers, vpn := peersFromStatus(st, false)
|
||||
fmt.Fprintf(&out, "peers: %d, VPN exit servers: %d\n", len(peers), vpn.Total)
|
||||
for _, p := range peers {
|
||||
fmt.Fprintf(&out, " %-24s %-16s %-8s online=%-5v %s %s\n", p.Name, p.IP, p.OS, p.Online, p.Kind, strings.TrimSpace(p.Conn+" "+p.Via))
|
||||
}
|
||||
} else {
|
||||
section("tailscale")
|
||||
fmt.Fprintf(&out, "status: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
section("settings (password and auth key removed)")
|
||||
if cfg.PasswordHash != "" {
|
||||
cfg.PasswordHash = "(set)"
|
||||
}
|
||||
if cfg.AuthKey != "" {
|
||||
cfg.AuthKey = "(set)"
|
||||
}
|
||||
// cfg is a copy, but its slices are the daemon's own: copy before
|
||||
// blanking.
|
||||
cfg.Wake = slices.Clone(cfg.Wake)
|
||||
for i := range cfg.Wake {
|
||||
cfg.Wake[i].MAC = "(set)"
|
||||
}
|
||||
if b, err := json.MarshalIndent(cfg, "", " "); err == nil {
|
||||
out.Write(b)
|
||||
out.WriteByte('\n')
|
||||
}
|
||||
|
||||
section("launcher.log")
|
||||
out.Write(launcher)
|
||||
section("tailscale.log (end)")
|
||||
out.Write(fileTail(filepath.Join(dataDir, "tailscale.log"), diagMainTail))
|
||||
section("tailscale-debug.log (end)")
|
||||
out.Write(fileTail(filepath.Join(dataDir, "tailscale-debug.log"), diagDebugTail))
|
||||
return scrub(out.Bytes())
|
||||
}
|
||||
|
||||
func (d *daemon) handleDiagnostics(w http.ResponseWriter, r *http.Request) {
|
||||
name := fmt.Sprintf("ps5-tailscale-diagnostics-%s-%s.txt", version, time.Now().UTC().Format("20060102-150405"))
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Write(d.diagnostics(r))
|
||||
}
|
||||
+148
-4
@@ -58,6 +58,7 @@
|
||||
/* The device gets a line of its own; port, protocol and Remove share the next. */
|
||||
.fwd .hostrow { flex-wrap: wrap; }
|
||||
.fwd .hostrow .host { flex: 1 1 100%; }
|
||||
#wakerows .host, #wakerows .mac { flex: 1 1 100%; }
|
||||
}
|
||||
.actions { display: flex; flex-wrap: wrap; gap: 10px; align-items: flex-end; }
|
||||
button {
|
||||
@@ -98,6 +99,11 @@
|
||||
.fwd .hostrow { margin-bottom: 4px; }
|
||||
.fwd .use { font-size: 14px; color: var(--muted); }
|
||||
button.link { border: 0; padding: 0; background: none; color: var(--accent); font-size: inherit; text-decoration: underline; cursor: pointer; }
|
||||
ul.about { margin: 12px 0 0; padding-left: 20px; }
|
||||
ul.about li { margin-bottom: 8px; }
|
||||
.hostrow .mac { flex: 2; min-width: 0; font-family: ui-monospace, Consolas, monospace; font-size: 14px; }
|
||||
.result { font-size: 14px; color: var(--muted); }
|
||||
.result.good { color: var(--ok); } .result.bad { color: var(--bad); }
|
||||
.hidden { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
@@ -136,6 +142,21 @@
|
||||
<p class="msg hidden" id="error"></p>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<details id="aboutbox">
|
||||
<summary><span class="heading">What this does, and what it does not</span></summary>
|
||||
<ul class="about">
|
||||
<li><strong>Your other devices can reach this PS5</strong> at its Tailscale address, from anywhere: FTP, the
|
||||
payload loader, Remote Play, anything that listens on the console.</li>
|
||||
<li><strong>The PS5 can reach a device on your tailnet</strong> through an address on the console itself. Set
|
||||
that up under "Reach a device from this PS5"; game streaming has its own panel.</li>
|
||||
<li><strong>It is not a full VPN.</strong> Games, PSN and the PS5's browser keep using your normal internet
|
||||
connection, the console's public IP address does not change, and the console cannot use an exit node. The
|
||||
PS5 has no way to route its own traffic through Tailscale.</li>
|
||||
</ul>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section class="panel login hidden" id="login">
|
||||
<h2>Log in</h2>
|
||||
<p>Scan this with your phone, or open the link on any device, to add this PS5 to your tailnet.</p>
|
||||
@@ -203,6 +224,22 @@
|
||||
<p class="note" id="sunshine-state" style="margin: 12px 0 0"></p>
|
||||
</section>
|
||||
|
||||
<section class="panel hidden" id="wakepanel">
|
||||
<h2>Wake a device at home</h2>
|
||||
<p class="note">A sleeping PC cannot be reached over Tailscale, but this console is on the same home network and
|
||||
can send it a Wake-on-LAN packet. Open this page from wherever you are, press Wake, wait half a minute, then
|
||||
connect. The device needs Wake-on-LAN turned on, and a wired connection works best.</p>
|
||||
<div id="wakerows"></div>
|
||||
<div class="actions">
|
||||
<button id="btn-addwake" class="small">Add a device</button>
|
||||
<button id="btn-wakes">Save</button>
|
||||
</div>
|
||||
<p class="hint">The address is the network card's MAC address, such as <code>00:11:22:AA:BB:CC</code>. On Windows
|
||||
it is the "Physical address" shown by <code>ipconfig /all</code>.</p>
|
||||
<p class="okmsg hidden" id="wakeok"></p>
|
||||
<p class="msg hidden" id="wakemsg"></p>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<details id="settingsbox">
|
||||
<summary><span class="heading">Settings</span></summary>
|
||||
@@ -272,8 +309,12 @@
|
||||
<button id="btn-logout" class="danger">Log out</button>
|
||||
<button id="btn-quit" class="danger">Stop Tailscale</button>
|
||||
<button id="btn-uninstall" class="danger">Uninstall</button>
|
||||
<button id="btn-diag">Download diagnostics</button>
|
||||
<button id="btn-lock" class="hidden">Lock this page</button>
|
||||
</div>
|
||||
<p class="hint" style="margin-top: 10px">Diagnostics is one text file with the logs, version and settings, for
|
||||
attaching to a bug report. E-mail addresses, your tailnet's name, public IP addresses and the password are
|
||||
removed; device names are not. Read it before posting it.</p>
|
||||
<p class="okmsg hidden" id="actionok"></p>
|
||||
<p class="msg hidden" id="actionmsg"></p>
|
||||
</section>
|
||||
@@ -367,9 +408,35 @@ function row(dl, name, value, mono) {
|
||||
// "Copied" confirmation is not wiped out mid-way.
|
||||
let shownFacts = '';
|
||||
|
||||
// testTarget asks the daemon whether a device answers on a port, the way a
|
||||
// forward would reach it, and writes the outcome into el.
|
||||
async function testTarget(target, el) {
|
||||
el.className = 'result'; el.textContent = 'testing…';
|
||||
try {
|
||||
const r = await api('/api/testtarget?target=' + encodeURIComponent(target), {method: 'POST'});
|
||||
if (!r.ok) throw new Error((await r.text()).trim() || r.statusText);
|
||||
const t = await r.json();
|
||||
el.className = 'result ' + (t.ok ? 'good' : 'bad');
|
||||
el.textContent = t.ok ? 'answers (' + Math.round(t.ms) + ' ms)' : t.error;
|
||||
} catch (e) {
|
||||
el.className = 'result bad'; el.textContent = e.message === 'locked' ? '' : e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// testLink is a small "test" link with room for its result next to it.
|
||||
function testLink(target, title) {
|
||||
const span = document.createElement('span');
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button'; b.className = 'link'; b.textContent = 'test'; b.title = title;
|
||||
const res = document.createElement('span');
|
||||
b.onclick = () => testTarget(target(), res);
|
||||
span.append(b, ' ', res);
|
||||
return span;
|
||||
}
|
||||
|
||||
function hostRow(host, port) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'hostrow';
|
||||
div.className = 'hostrow'; div.style.flexWrap = 'wrap';
|
||||
const h = document.createElement('input');
|
||||
h.type = 'text'; h.className = 'host'; h.placeholder = 'Device name or address'; h.value = host || '';
|
||||
h.setAttribute('list', 'peernames'); h.setAttribute('aria-label', 'Sunshine host'); h.autocomplete = 'off';
|
||||
@@ -380,7 +447,14 @@ function hostRow(host, port) {
|
||||
x.type = 'button'; x.className = 'small'; x.textContent = 'Remove';
|
||||
x.onclick = () => { div.remove(); hostsDirty = true; };
|
||||
h.oninput = p.oninput = () => { hostsDirty = true; };
|
||||
div.append(h, p, x);
|
||||
// Whether Sunshine on that device answers: its web port is the one the
|
||||
// Moonlight client talks to first.
|
||||
const t = testLink(() => {
|
||||
const name = h.value.trim();
|
||||
return (name.includes(':') ? '[' + name + ']' : name) + ':' + (parseInt(p.value, 10) || 47989);
|
||||
}, 'Check whether Sunshine on this device answers');
|
||||
t.style.flex = '1 1 100%'; t.style.fontSize = '14px';
|
||||
div.append(h, p, x, t);
|
||||
return div;
|
||||
}
|
||||
|
||||
@@ -502,8 +576,11 @@ function fwdRow(rule) {
|
||||
x.onclick = () => { wrap.remove(); fwdsDirty = true; };
|
||||
const use = document.createElement('div');
|
||||
use.className = 'use';
|
||||
if (rule) { use.append('On the PS5 use '); use.append(copyable(rule.listen)); }
|
||||
else use.textContent = 'The address to use on the PS5 appears here after saving.';
|
||||
if (rule) {
|
||||
use.append('On the PS5 use ', copyable(rule.listen));
|
||||
// Only TCP can be tested: UDP has no "it answered".
|
||||
if (rule.proto === 'tcp') use.append(' · ', testLink(() => rule.target, 'Check whether the device answers on that port'));
|
||||
} else use.textContent = 'The address to use on the PS5 appears here after saving.';
|
||||
// Changing what a row points at keeps its address; changing the port or
|
||||
// protocol gives it a new one, so it stays easy to recognise.
|
||||
h.oninput = () => { fwdsDirty = true; };
|
||||
@@ -632,6 +709,18 @@ async function refresh() {
|
||||
? s.sunshineHosts.map(h => h.host + ': add ' + h.address + ' in Moonlight').join('. ') + '.'
|
||||
: 'No Sunshine host is forwarded.';
|
||||
|
||||
// Devices to wake.
|
||||
$('wakepanel').classList.toggle('hidden', s.state !== 'Running' && !s.wake.length);
|
||||
const wakeNow = JSON.stringify(s.wake);
|
||||
if (!wakeDirty && wakeNow !== lastWake) {
|
||||
lastWake = wakeNow;
|
||||
$('wakerows').replaceChildren(...s.wake.map(wakeRow));
|
||||
}
|
||||
|
||||
// Before the first login, say what to expect; afterwards the note stays
|
||||
// folded away unless it was opened.
|
||||
if (needLogin && !aboutShown) { aboutShown = true; $('aboutbox').open = true; }
|
||||
|
||||
$('btn-lock').classList.toggle('hidden', !s.passwordSet);
|
||||
|
||||
refreshFiles();
|
||||
@@ -767,6 +856,61 @@ $('btn-fwds').onclick = async () => {
|
||||
refresh();
|
||||
};
|
||||
|
||||
// Devices to wake. A row can be woken once it has been saved.
|
||||
let wakeDirty = false;
|
||||
let lastWake = '';
|
||||
let aboutShown = false;
|
||||
function wakeRow(t) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'hostrow'; div.style.flexWrap = 'wrap';
|
||||
const n = document.createElement('input');
|
||||
n.type = 'text'; n.className = 'host'; n.placeholder = 'Name, for example gaming-pc'; n.value = t ? t.name : '';
|
||||
n.setAttribute('list', 'peernames'); n.setAttribute('aria-label', 'Name'); n.autocomplete = 'off'; n.maxLength = 64;
|
||||
const m = document.createElement('input');
|
||||
m.type = 'text'; m.className = 'mac'; m.placeholder = '00:11:22:AA:BB:CC'; m.value = t ? t.mac : '';
|
||||
m.setAttribute('aria-label', 'MAC address'); m.autocomplete = 'off'; m.spellcheck = false;
|
||||
const w = document.createElement('button');
|
||||
w.type = 'button'; w.textContent = 'Wake'; w.disabled = !t;
|
||||
w.title = t ? 'Send the wake-up packet' : 'Save first';
|
||||
w.onclick = async () => {
|
||||
show('wakemsg', ''); show('wakeok', '');
|
||||
try {
|
||||
const r = await api('/api/wake?mac=' + encodeURIComponent(t.mac), {method: 'POST'});
|
||||
const text = (await r.text()).trim();
|
||||
if (!r.ok) throw new Error(text || r.statusText);
|
||||
show('wakeok', text);
|
||||
} catch (e) {
|
||||
if (e.message !== 'locked') show('wakemsg', e.message);
|
||||
}
|
||||
};
|
||||
const x = document.createElement('button');
|
||||
x.type = 'button'; x.className = 'small'; x.textContent = 'Remove';
|
||||
x.onclick = () => { div.remove(); wakeDirty = true; };
|
||||
n.oninput = () => { wakeDirty = true; };
|
||||
m.oninput = () => { wakeDirty = true; w.disabled = true; w.title = 'Save first'; };
|
||||
div.append(n, m, w, x);
|
||||
return div;
|
||||
}
|
||||
$('btn-addwake').onclick = () => { $('wakerows').append(wakeRow(null)); wakeDirty = true; };
|
||||
$('btn-wakes').onclick = async () => {
|
||||
show('wakemsg', ''); show('wakeok', '');
|
||||
const list = [...$('wakerows').children].map(d => ({name: d.querySelector('.host').value.trim(), mac: d.querySelector('.mac').value.trim()}))
|
||||
.filter(t => t.name || t.mac);
|
||||
try {
|
||||
const r = await api('/api/wakelist', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(list)});
|
||||
const text = (await r.text()).trim();
|
||||
if (!r.ok) throw new Error(text || r.statusText);
|
||||
show('wakeok', 'Saved.');
|
||||
wakeDirty = false; lastWake = '';
|
||||
} catch (e) {
|
||||
if (e.message !== 'locked') show('wakemsg', e.message);
|
||||
}
|
||||
refresh();
|
||||
};
|
||||
|
||||
// The diagnostics file is a plain download; the session cookie covers it.
|
||||
$('btn-diag').onclick = () => { location.href = '/api/diagnostics'; };
|
||||
|
||||
// Game streaming hosts.
|
||||
$('btn-addhost').onclick = () => { $('hostrows').append(hostRow('', 0)); hostsDirty = true; };
|
||||
$('btn-sunshine').onclick = async () => {
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestScrub(t *testing.T) {
|
||||
in := `2026-10-06 login URL: https://login.tailscale.com/a/1a2b3c4d5e6f
|
||||
self: ps5.tail-scale-fish.ts.net. user someone@example.com authkey tskey-auth-kABCDEF123-xyzXYZ
|
||||
endpoints 203.0.113.7:49866 (portmap), 192.168.1.50:49866 (local), 100.64.0.5, 10.0.0.5, 172.20.1.1
|
||||
derp 198.51.100.9:443, resolver 1.1.1.1:53, version 1.104.0, loopback 127.0.0.1:8090
|
||||
peer gaming-pc.tail-scale-fish.ts.net
|
||||
dns: Set: {Routes:{ts.net.:[199.247.155.53] tail-scale-fish.ts.net.:[]} SearchDomains:[tail-scale-fish.ts.net.]}`
|
||||
out := string(scrub([]byte(in)))
|
||||
for _, gone := range []string{"1a2b3c4d5e6f", "someone@example.com", "kABCDEF123", "tail-scale-fish", "203.0.113.7", "198.51.100.9"} {
|
||||
if strings.Contains(out, gone) {
|
||||
t.Errorf("%q was not removed:\n%s", gone, out)
|
||||
}
|
||||
}
|
||||
for _, kept := range []string{"192.168.1.50:49866", "100.64.0.5", "10.0.0.5", "172.20.1.1", "1.1.1.1:53", "1.104.0", "127.0.0.1:8090",
|
||||
"ps5.<tailnet>.ts.net", "gaming-pc.<tailnet>.ts.net", "<email>", "<public-ip>:49866", "2026-10-06"} {
|
||||
if !strings.Contains(out, kept) {
|
||||
t.Errorf("%q is missing:\n%s", kept, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnostics(t *testing.T) {
|
||||
old := dataDir
|
||||
dataDir = t.TempDir()
|
||||
t.Cleanup(func() { dataDir = old })
|
||||
os.WriteFile(filepath.Join(dataDir, "launcher.log"), []byte("2026-10-05 launcher: starting, firmware 9.60, pid 5\n2026-10-06 launcher: starting, firmware 13.42, pid 115\n"), 0o644)
|
||||
os.WriteFile(filepath.Join(dataDir, "tailscale.log"), []byte("main log line from someone@example.com\n"), 0o644)
|
||||
|
||||
cfg := defaultConfig()
|
||||
cfg.PasswordHash = "pbkdf2-sha256$210000$c2FsdA$a2V5"
|
||||
cfg.AuthKey = "tskey-auth-secret"
|
||||
cfg.Wake = []wakeTarget{{Name: "gaming-pc", MAC: "00:11:22:aa:bb:cc"}}
|
||||
d := &daemon{cfg: cfg, logf: t.Logf, started: time.Now(), state: "Running"}
|
||||
d.fwd = newForwarder(nil, t.Logf)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
d.handleDiagnostics(rec, httptest.NewRequest("GET", "/api/diagnostics", nil))
|
||||
body := rec.Body.String()
|
||||
if cd := rec.Header().Get("Content-Disposition"); !strings.HasPrefix(cd, "attachment") || !strings.Contains(cd, ".txt") {
|
||||
t.Errorf("Content-Disposition = %q", cd)
|
||||
}
|
||||
for _, want := range []string{"firmware: 13.42", "state: Running", "main log line", `"passwordHash": "(set)"`, "===== launcher.log =====", "tailscale-debug.log"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("missing %q", want)
|
||||
}
|
||||
}
|
||||
for _, secret := range []string{"c2FsdA", "tskey-auth-secret", "someone@example.com", "00:11:22:aa:bb:cc"} {
|
||||
if strings.Contains(body, secret) {
|
||||
t.Errorf("%q is in the diagnostics", secret)
|
||||
}
|
||||
}
|
||||
// The daemon's own copy of the settings must not have been touched.
|
||||
if d.cfg.PasswordHash != cfg.PasswordHash || d.cfg.Wake[0].MAC != "00:11:22:aa:bb:cc" {
|
||||
t.Error("building the diagnostics changed the settings")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMAC(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"00:11:22:AA:BB:CC": "00:11:22:aa:bb:cc",
|
||||
"00-11-22-aa-bb-cc": "00:11:22:aa:bb:cc",
|
||||
"001122AABBCC": "00:11:22:aa:bb:cc",
|
||||
"0011.22aa.bbcc": "00:11:22:aa:bb:cc",
|
||||
" 00:11:22:aa:bb:cc ": "00:11:22:aa:bb:cc",
|
||||
} {
|
||||
mac, err := parseMAC(in)
|
||||
if err != nil || mac.String() != want {
|
||||
t.Errorf("parseMAC(%q) = %v, %v", in, mac, err)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"", "gaming-pc", "00:11:22:aa:bb", "00:11:22:aa:bb:cc:dd:ee", "zz:11:22:aa:bb:cc", "192.168.1.5"} {
|
||||
if _, err := parseMAC(bad); err == nil {
|
||||
t.Errorf("parseMAC(%q) accepted", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMagicPacket(t *testing.T) {
|
||||
mac, _ := parseMAC("00:11:22:aa:bb:cc")
|
||||
p := magicPacket(mac)
|
||||
if len(p) != 102 {
|
||||
t.Fatalf("length %d", len(p))
|
||||
}
|
||||
if !bytes.Equal(p[:6], bytes.Repeat([]byte{0xff}, 6)) {
|
||||
t.Error("the packet does not start with six 0xff")
|
||||
}
|
||||
for i := 0; i < 16; i++ {
|
||||
if !bytes.Equal(p[6+i*6:12+i*6], mac) {
|
||||
t.Fatalf("repetition %d is wrong", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcastAddrs(t *testing.T) {
|
||||
list := broadcastAddrs()
|
||||
if len(list) == 0 || !list[0].Equal(net.IPv4bcast) {
|
||||
t.Fatalf("got %v", list)
|
||||
}
|
||||
for _, ip := range list {
|
||||
if ip.To4() == nil || ip.IsLoopback() {
|
||||
t.Errorf("unexpected address %v", ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWakeHandlers(t *testing.T) {
|
||||
d := &daemon{cfg: defaultConfig(), cfgPath: filepath.Join(t.TempDir(), "config.json"), logf: t.Logf}
|
||||
post := func(h http.HandlerFunc, url, body string) *httptest.ResponseRecorder {
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, httptest.NewRequest("POST", url, strings.NewReader(body)))
|
||||
return rec
|
||||
}
|
||||
if rec := post(d.handleWakeList, "/api/wakelist", `[{"name":" gaming-pc ","mac":"00-11-22-AA-BB-CC"},{"name":"","mac":"001122aabbdd"}]`); rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
want := []wakeTarget{{"gaming-pc", "00:11:22:aa:bb:cc"}, {"00:11:22:aa:bb:dd", "00:11:22:aa:bb:dd"}}
|
||||
if len(d.cfg.Wake) != 2 || d.cfg.Wake[0] != want[0] || d.cfg.Wake[1] != want[1] {
|
||||
t.Errorf("list = %+v", d.cfg.Wake)
|
||||
}
|
||||
if saved, err := loadConfig(d.cfgPath); err != nil || len(saved.Wake) != 2 {
|
||||
t.Errorf("saved: %+v, %v", saved.Wake, err)
|
||||
}
|
||||
if rec := post(d.handleWakeList, "/api/wakelist", `[{"name":"x","mac":"not a mac"}]`); rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("a bad address: status %d", rec.Code)
|
||||
}
|
||||
// Only listed devices can be woken.
|
||||
if rec := post(d.handleWake, "/api/wake?mac=de:ad:be:ef:00:01", ""); rec.Code != http.StatusNotFound {
|
||||
t.Errorf("an unlisted device: status %d", rec.Code)
|
||||
}
|
||||
if rec := post(d.handleWake, "/api/wake?mac=nonsense", ""); rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("nonsense: status %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribeDialError(t *testing.T) {
|
||||
for msg, want := range map[string]string{
|
||||
"dial tcp 100.64.0.4:80: connect: connection refused": "nothing listens",
|
||||
"context deadline exceeded": "no answer",
|
||||
"dial tcp: i/o timeout": "no answer",
|
||||
"lookup nosuch: no such host": "no device with that name",
|
||||
"something else entirely": "something else entirely",
|
||||
} {
|
||||
if got := describeDialError(errors.New(msg)); !strings.Contains(got, want) {
|
||||
t.Errorf("%q -> %q", msg, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestTargetRefusesBadTargets(t *testing.T) {
|
||||
d := &daemon{logf: t.Logf}
|
||||
for _, target := range []string{"", "nas", "nas:0", ":80", "na s:80", "nas:99999"} {
|
||||
rec := httptest.NewRecorder()
|
||||
d.handleTestTarget(rec, httptest.NewRequest("POST", "/api/testtarget?target="+strings.ReplaceAll(target, " ", "%20"), nil))
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("%q: status %d", target, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Waking a device on the console's home network.
|
||||
//
|
||||
// A sleeping PC cannot be reached over Tailscale: nothing on it is running.
|
||||
// But the console sits on the same home network, and a Wake-on-LAN packet
|
||||
// only has to come from there. So the status page, which can be opened from
|
||||
// anywhere over the tailnet, gets a button that makes the console send one.
|
||||
|
||||
// wakeTarget is a device that can be woken.
|
||||
type wakeTarget struct {
|
||||
Name string `json:"name"`
|
||||
MAC string `json:"mac"`
|
||||
}
|
||||
|
||||
// parseMAC accepts the usual ways of writing a hardware address and returns
|
||||
// it in the form aa:bb:cc:dd:ee:ff.
|
||||
func parseMAC(s string) (net.HardwareAddr, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) == 12 && !strings.ContainsAny(s, ":-.") {
|
||||
s = s[0:2] + ":" + s[2:4] + ":" + s[4:6] + ":" + s[6:8] + ":" + s[8:10] + ":" + s[10:12]
|
||||
}
|
||||
mac, err := net.ParseMAC(s)
|
||||
if err != nil || len(mac) != 6 {
|
||||
return nil, fmt.Errorf("%q is not a network card address (it looks like 00:11:22:AA:BB:CC)", s)
|
||||
}
|
||||
return mac, nil
|
||||
}
|
||||
|
||||
func validateWake(list []wakeTarget) error {
|
||||
for i := range list {
|
||||
list[i].Name = strings.TrimSpace(list[i].Name)
|
||||
mac, err := parseMAC(list[i].MAC)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
list[i].MAC = mac.String()
|
||||
if list[i].Name == "" {
|
||||
list[i].Name = list[i].MAC
|
||||
}
|
||||
if len(list[i].Name) > 64 {
|
||||
return errors.New("a name is too long")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// magicPacket is the Wake-on-LAN payload: six bytes of 0xff and the address
|
||||
// sixteen times.
|
||||
func magicPacket(mac net.HardwareAddr) []byte {
|
||||
p := make([]byte, 0, 6+16*6)
|
||||
for i := 0; i < 6; i++ {
|
||||
p = append(p, 0xff)
|
||||
}
|
||||
for i := 0; i < 16; i++ {
|
||||
p = append(p, mac...)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// broadcastAddrs returns where to send a packet so that every device on the
|
||||
// console's networks sees it: each network's own broadcast address, and the
|
||||
// general one.
|
||||
func broadcastAddrs() []net.IP {
|
||||
list := []net.IP{net.IPv4bcast}
|
||||
addrs, err := net.InterfaceAddrs()
|
||||
if err != nil {
|
||||
return list
|
||||
}
|
||||
for _, a := range addrs {
|
||||
ipnet, ok := a.(*net.IPNet)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
ip := ipnet.IP.To4()
|
||||
if ip == nil || ip.IsLoopback() || len(ipnet.Mask) != 4 {
|
||||
continue
|
||||
}
|
||||
if ones, _ := ipnet.Mask.Size(); ones >= 31 {
|
||||
continue
|
||||
}
|
||||
b := make(net.IP, 4)
|
||||
for i := range b {
|
||||
b[i] = ip[i] | ^ipnet.Mask[i]
|
||||
}
|
||||
list = append(list, b)
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
// sendWake broadcasts the magic packet. It reports how many sends went out;
|
||||
// there is no way to know whether the device heard it.
|
||||
func sendWake(mac net.HardwareAddr) (sent int, err error) {
|
||||
packet := magicPacket(mac)
|
||||
var lastErr error
|
||||
for _, ip := range broadcastAddrs() {
|
||||
// Port 9 is the customary one; some network cards listen on 7.
|
||||
for _, port := range []int{9, 7} {
|
||||
c, err := net.DialUDP("udp4", nil, &net.UDPAddr{IP: ip, Port: port})
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
if _, err := c.Write(packet); err != nil {
|
||||
lastErr = err
|
||||
} else {
|
||||
sent++
|
||||
}
|
||||
c.Close()
|
||||
}
|
||||
}
|
||||
if sent == 0 {
|
||||
if lastErr == nil {
|
||||
lastErr = errors.New("no network to send on")
|
||||
}
|
||||
return 0, lastErr
|
||||
}
|
||||
return sent, nil
|
||||
}
|
||||
|
||||
// handleWakeList replaces the list of devices that can be woken.
|
||||
func (d *daemon) handleWakeList(w http.ResponseWriter, r *http.Request) {
|
||||
var list []wakeTarget
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&list); err != nil {
|
||||
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := validateWake(list); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
d.mu.Lock()
|
||||
d.cfg.Wake = list
|
||||
cfg := d.cfg
|
||||
d.mu.Unlock()
|
||||
if err := saveConfig(d.cfgPath, cfg); err != nil {
|
||||
d.logf("saving config: %v", err)
|
||||
}
|
||||
io.WriteString(w, "ok\n")
|
||||
}
|
||||
|
||||
// handleWake sends the wake-up packet to one of the listed devices. Only
|
||||
// listed devices: the page is not a tool for poking arbitrary addresses.
|
||||
func (d *daemon) handleWake(w http.ResponseWriter, r *http.Request) {
|
||||
mac, err := parseMAC(r.URL.Query().Get("mac"))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
d.mu.Lock()
|
||||
name := ""
|
||||
for _, t := range d.cfg.Wake {
|
||||
if t.MAC == mac.String() {
|
||||
name = t.Name
|
||||
}
|
||||
}
|
||||
d.mu.Unlock()
|
||||
if name == "" {
|
||||
http.Error(w, "that device is not in the list; save it first", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
sent, err := sendWake(mac)
|
||||
if err != nil {
|
||||
d.logf("wake %s: %v", name, err)
|
||||
http.Error(w, "could not send the wake-up packet: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
d.logf("wake-up packet sent to %s (%d sends)", name, sent)
|
||||
io.WriteString(w, "Wake-up packet sent to "+name+". Give it half a minute.\n")
|
||||
}
|
||||
+61
-9
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -59,6 +60,8 @@ type statusInfo struct {
|
||||
// UserForwards are the forwards the user set up, as opposed to the ones
|
||||
// that belong to a Sunshine host.
|
||||
UserForwards []forwardRule `json:"userForwards"`
|
||||
// Wake lists the devices the page can wake.
|
||||
Wake []wakeTarget `json:"wake"`
|
||||
// DNS says where the daemon looks names up.
|
||||
DNS string `json:"dns,omitempty"`
|
||||
// UDPPorts are the console's UDP ports reachable from the tailnet.
|
||||
@@ -114,16 +117,20 @@ func (d *daemon) webHandler() http.Handler {
|
||||
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))
|
||||
mux.HandleFunc("GET /api/diagnostics", d.protect(d.handleDiagnostics))
|
||||
for path, h := range map[string]http.HandlerFunc{
|
||||
"/api/config": d.handleSetConfig,
|
||||
"/api/login": d.handleLogin,
|
||||
"/api/logout": d.handleLogout,
|
||||
"/api/quit": d.handleQuit,
|
||||
"/api/uninstall": d.handleUninstall,
|
||||
"/api/sunshine": d.handleSunshine,
|
||||
"/api/forwards": d.handleForwards,
|
||||
"/api/pingpeer": d.handlePingPeer,
|
||||
"/api/update": d.handleUpdate,
|
||||
"/api/config": d.handleSetConfig,
|
||||
"/api/login": d.handleLogin,
|
||||
"/api/logout": d.handleLogout,
|
||||
"/api/quit": d.handleQuit,
|
||||
"/api/uninstall": d.handleUninstall,
|
||||
"/api/sunshine": d.handleSunshine,
|
||||
"/api/forwards": d.handleForwards,
|
||||
"/api/pingpeer": d.handlePingPeer,
|
||||
"/api/testtarget": d.handleTestTarget,
|
||||
"/api/wakelist": d.handleWakeList,
|
||||
"/api/wake": d.handleWake,
|
||||
"/api/update": d.handleUpdate,
|
||||
} {
|
||||
mux.HandleFunc("POST "+path, d.protect(d.guard(h)))
|
||||
}
|
||||
@@ -212,6 +219,7 @@ func (d *daemon) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
info.AllowFrom = accessOwn
|
||||
}
|
||||
info.UserForwards = append([]forwardRule{}, d.cfg.Forwards...)
|
||||
info.Wake = append([]wakeTarget{}, d.cfg.Wake...)
|
||||
for _, h := range d.cfg.SunshineHosts {
|
||||
info.SunshineHosts = append(info.SunshineHosts, sunshineInfo{Host: h.Host, Port: h.basePort(), Address: h.clientAddress()})
|
||||
}
|
||||
@@ -538,3 +546,47 @@ func (d *daemon) checkForwardPorts(rules []forwardRule) (forwardRule, error) {
|
||||
}
|
||||
return forwardRule{}, nil
|
||||
}
|
||||
|
||||
// handleTestTarget tries to open a TCP connection to a device and port on
|
||||
// the tailnet, the way a forward would, and says whether it answered. It
|
||||
// tells "the device is not reachable" apart from "nothing listens there".
|
||||
func (d *daemon) handleTestTarget(w http.ResponseWriter, r *http.Request) {
|
||||
target := strings.TrimSpace(r.URL.Query().Get("target"))
|
||||
host, port, err := net.SplitHostPort(target)
|
||||
if err != nil || host == "" || !validHostName(host) || !validPort(port) {
|
||||
http.Error(w, "the target must be a device and a port", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if d.srv == nil || d.lc == nil {
|
||||
http.Error(w, "Tailscale is not running yet", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
|
||||
defer cancel()
|
||||
start := time.Now()
|
||||
c, err := d.dialTailnet(ctx, "tcp", target)
|
||||
took := time.Since(start)
|
||||
out := map[string]any{"ok": err == nil, "ms": float64(took.Microseconds()) / 1000}
|
||||
if err != nil {
|
||||
out["error"] = describeDialError(err)
|
||||
} else {
|
||||
c.Close()
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(out)
|
||||
}
|
||||
|
||||
// describeDialError puts a failed connection attempt into words a user can
|
||||
// act on.
|
||||
func describeDialError(err error) string {
|
||||
msg := err.Error()
|
||||
switch {
|
||||
case strings.Contains(msg, "connection refused"), strings.Contains(msg, "connection was refused"):
|
||||
return "the device answered, but nothing listens on that port"
|
||||
case strings.Contains(msg, "deadline exceeded"), strings.Contains(msg, "timeout"), strings.Contains(msg, "timed out"):
|
||||
return "no answer: the device is off, asleep, or a firewall on it blocks the port"
|
||||
case strings.Contains(msg, "no such host"), strings.Contains(msg, "lookup"), strings.Contains(msg, "not found"):
|
||||
return "there is no device with that name on your tailnet"
|
||||
}
|
||||
return msg
|
||||
}
|
||||
Reference in new issue
Block a user