mirror of
https://github.com/holdmysocks/ps5-tailscale.git
synced 2026-10-06 12:00:20 +02:00
A tailnet with a VPN add-on has hundreds of exit servers among its peers, which buried the real devices on the status page. - Devices are grouped: this tailnet, shared with you, VPN exit servers. - VPN exit servers are counted but only listed, and only sent to the page, when asked for. - Search by name, address, OS, tag or place, and an online-only toggle. - Devices that offer exit-node service are marked. - The count and the streaming host suggestions leave exit servers out.
411 lines
12 KiB
Go
411 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
_ "embed"
|
|
"encoding/json"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
qrcode "github.com/skip2/go-qrcode"
|
|
)
|
|
|
|
//go:embed status.html
|
|
var statusHTML []byte
|
|
|
|
// faviconPNG is the logo from the home screen icon (appicon/icon0.png)
|
|
// without its text, 128x128, for the browser tab.
|
|
//
|
|
//go:embed favicon.png
|
|
var faviconPNG []byte
|
|
|
|
// State-changing requests must carry this header, which a web page on
|
|
// another origin cannot send, so a stray link or image tag cannot log the
|
|
// console out. Who may use the page at all is decided in auth.go.
|
|
const apiHeader = "X-PS5-Tailscale"
|
|
|
|
// sunshineInfo is a forwarded Sunshine host as the status page shows it.
|
|
type sunshineInfo struct {
|
|
Host string `json:"host"`
|
|
Port int `json:"port"`
|
|
// Address is what to enter in a Moonlight client on the console.
|
|
Address string `json:"address"`
|
|
}
|
|
|
|
type statusInfo struct {
|
|
Version string `json:"version"`
|
|
State string `json:"state"`
|
|
AuthURL string `json:"authURL,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
Hostname string `json:"hostname"`
|
|
DNSName string `json:"dnsName,omitempty"`
|
|
IPs []string `json:"ips"`
|
|
Tailnet string `json:"tailnet,omitempty"`
|
|
Health []string `json:"health,omitempty"`
|
|
Peers []peerInfo `json:"peers"`
|
|
// VPNServers counts the exit servers of a VPN add-on. They are only in
|
|
// Peers when the page asks for them (?vpn=1).
|
|
VPNServers peerCount `json:"vpnServers"`
|
|
Proxy string `json:"proxy,omitempty"`
|
|
// SunshineHosts and Forwards describe the local forwards.
|
|
SunshineHosts []sunshineInfo `json:"sunshineHosts"`
|
|
Forwards []string `json:"forwards"`
|
|
// UDPPorts are the console's UDP ports reachable from the tailnet.
|
|
UDPPorts []uint16 `json:"udpPorts"`
|
|
Priority string `json:"priority"`
|
|
// PasswordSet says whether the page is password protected.
|
|
PasswordSet bool `json:"passwordSet"`
|
|
// LatestVersion and UpdateURL are set when a newer release exists.
|
|
LatestVersion string `json:"latestVersion,omitempty"`
|
|
UpdateURL string `json:"updateURL,omitempty"`
|
|
Uptime int64 `json:"uptimeSeconds"`
|
|
}
|
|
|
|
// webHandler builds the status page and its API.
|
|
func (d *daemon) webHandler() http.Handler {
|
|
mux := http.NewServeMux()
|
|
|
|
// Open to everyone who can reach the page: the page itself (which shows
|
|
// nothing until its API answers), the icon, and what a new instance
|
|
// needs to recognise this one.
|
|
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
w.Write(statusHTML)
|
|
})
|
|
favicon := func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "image/png")
|
|
w.Header().Set("Cache-Control", "max-age=86400")
|
|
w.Write(faviconPNG)
|
|
}
|
|
mux.HandleFunc("GET /favicon.png", favicon)
|
|
mux.HandleFunc("GET /favicon.ico", favicon) // what browsers ask for unprompted
|
|
mux.HandleFunc("GET /api/ping", func(w http.ResponseWriter, r *http.Request) {
|
|
io.WriteString(w, "ps5-tailscale "+version+"\n")
|
|
})
|
|
mux.HandleFunc("POST /api/auth", d.guard(d.handleAuth))
|
|
mux.HandleFunc("POST /api/lock", d.guard(d.handleLock))
|
|
|
|
// Everything else needs the password, if one is set.
|
|
mux.HandleFunc("GET /api/status", d.protect(d.handleStatus))
|
|
mux.HandleFunc("GET /api/logs", d.protect(d.handleLogs))
|
|
mux.HandleFunc("GET /qr.png", d.protect(d.handleQR))
|
|
mux.HandleFunc("GET /api/config", d.protect(d.handleGetConfig))
|
|
for path, h := range map[string]http.HandlerFunc{
|
|
"/api/config": d.handleSetConfig,
|
|
"/api/login": d.handleLogin,
|
|
"/api/logout": d.handleLogout,
|
|
"/api/quit": d.handleQuit,
|
|
"/api/uninstall": d.handleUninstall,
|
|
"/api/sunshine": d.handleSunshine,
|
|
} {
|
|
mux.HandleFunc("POST "+path, d.protect(d.guard(h)))
|
|
}
|
|
return mux
|
|
}
|
|
|
|
// serveWeb serves the status page on one listener.
|
|
func (d *daemon) serveWeb(ln net.Listener, h http.Handler) {
|
|
srv := &http.Server{Handler: h, ReadHeaderTimeout: 10 * time.Second}
|
|
if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed && !d.stopping() {
|
|
d.logf("web UI stopped: %v", err)
|
|
}
|
|
}
|
|
|
|
// stopping reports whether a shutdown has been requested.
|
|
func (d *daemon) stopping() bool {
|
|
select {
|
|
case <-d.quit:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// writeFileTail copies the last max bytes of a file to w.
|
|
func writeFileTail(w io.Writer, path string, max int64) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
io.WriteString(w, err.Error()+"\n")
|
|
return
|
|
}
|
|
defer f.Close()
|
|
if fi, err := f.Stat(); err == nil && fi.Size() > max {
|
|
f.Seek(fi.Size()-max, io.SeekStart)
|
|
}
|
|
io.Copy(w, f)
|
|
}
|
|
|
|
func (d *daemon) guard(h http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Header.Get(apiHeader) == "" {
|
|
http.Error(w, "missing "+apiHeader+" header", http.StatusForbidden)
|
|
return
|
|
}
|
|
h(w, r)
|
|
}
|
|
}
|
|
|
|
func (d *daemon) handleLogs(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
switch {
|
|
case r.URL.Query().Get("full") == "1":
|
|
// The end of the log file itself.
|
|
writeFileTail(w, filepath.Join(dataDir, "tailscale.log"), 512<<10)
|
|
case r.URL.Query().Get("debug") == "1":
|
|
// The end of the debug log, which includes Tailscale's own messages.
|
|
writeFileTail(w, filepath.Join(dataDir, "tailscale-debug.log"), 1<<20)
|
|
case r.URL.Query().Get("debug") == "old":
|
|
writeFileTail(w, filepath.Join(dataDir, "tailscale-debug.log.old"), 1<<20)
|
|
default:
|
|
io.WriteString(w, strings.Join(recentLogs.snapshot(), "\n")+"\n")
|
|
}
|
|
}
|
|
|
|
func (d *daemon) handleStatus(w http.ResponseWriter, r *http.Request) {
|
|
d.mu.Lock()
|
|
info := statusInfo{
|
|
Version: version,
|
|
State: d.state,
|
|
AuthURL: d.authURL,
|
|
Error: d.lastErr,
|
|
Hostname: d.cfg.Hostname,
|
|
Proxy: d.cfg.HTTPProxyAddr,
|
|
Priority: priorityLow,
|
|
PasswordSet: d.cfg.PasswordHash != "",
|
|
Uptime: int64(time.Since(d.started).Seconds()),
|
|
IPs: []string{},
|
|
Peers: []peerInfo{},
|
|
SunshineHosts: []sunshineInfo{},
|
|
}
|
|
if d.cfg.Priority == priorityHigh {
|
|
info.Priority = priorityHigh
|
|
}
|
|
for _, h := range d.cfg.SunshineHosts {
|
|
info.SunshineHosts = append(info.SunshineHosts, sunshineInfo{Host: h.Host, Port: h.basePort(), Address: h.clientAddress()})
|
|
}
|
|
if newerVersion(version, d.latest.Version) {
|
|
info.LatestVersion, info.UpdateURL = d.latest.Version, d.latest.URL
|
|
}
|
|
d.mu.Unlock()
|
|
info.UDPPorts = []uint16{}
|
|
if d.udp != nil {
|
|
info.UDPPorts = append(info.UDPPorts, d.udp.activePorts()...)
|
|
}
|
|
info.Forwards = []string{}
|
|
for _, r := range d.fwd.rules() {
|
|
info.Forwards = append(info.Forwards, r.String())
|
|
}
|
|
if info.State == "" {
|
|
info.State = "Starting"
|
|
}
|
|
|
|
if d.lc != nil {
|
|
if st, err := d.status(r.Context()); err == nil {
|
|
info.State = st.BackendState
|
|
info.Health = st.Health
|
|
if info.AuthURL == "" {
|
|
info.AuthURL = st.AuthURL
|
|
}
|
|
if st.CurrentTailnet != nil {
|
|
info.Tailnet = st.CurrentTailnet.Name
|
|
}
|
|
if st.Self != nil {
|
|
info.DNSName = strings.TrimSuffix(st.Self.DNSName, ".")
|
|
for _, ip := range st.Self.TailscaleIPs {
|
|
info.IPs = append(info.IPs, ip.String())
|
|
}
|
|
}
|
|
info.Peers, info.VPNServers = peersFromStatus(st, r.URL.Query().Get("vpn") == "1")
|
|
}
|
|
}
|
|
if info.State == "Running" {
|
|
info.AuthURL = ""
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
json.NewEncoder(w).Encode(info)
|
|
}
|
|
|
|
// handleQR renders the current login URL as a QR code. It only ever encodes
|
|
// the URL the daemon holds, never one supplied by the request.
|
|
func (d *daemon) handleQR(w http.ResponseWriter, r *http.Request) {
|
|
d.mu.Lock()
|
|
u := d.authURL
|
|
d.mu.Unlock()
|
|
if u == "" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
png, err := qrcode.Encode(u, qrcode.Medium, 320)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "image/png")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
w.Write(png)
|
|
}
|
|
|
|
func (d *daemon) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|
if d.lc == nil {
|
|
http.Error(w, "tailscale is still starting", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
d.mu.Lock()
|
|
loggedOut := d.state == "NeedsLogin"
|
|
d.mu.Unlock()
|
|
var err error
|
|
if loggedOut {
|
|
// The pending link may be used up or expired; get a new one.
|
|
err = d.freshLogin(r.Context())
|
|
} else {
|
|
err = d.lc.StartLoginInteractive(r.Context())
|
|
}
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
io.WriteString(w, "ok\n")
|
|
}
|
|
|
|
func (d *daemon) handleLogout(w http.ResponseWriter, r *http.Request) {
|
|
if d.lc == nil {
|
|
http.Error(w, "tailscale is still starting", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
if err := d.lc.Logout(r.Context()); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
d.mu.Lock()
|
|
d.notified = ""
|
|
d.mu.Unlock()
|
|
d.logf("logged out via the status page")
|
|
io.WriteString(w, "ok\n")
|
|
}
|
|
|
|
// handleSunshine replaces the list of Sunshine hosts whose streaming ports are
|
|
// forwarded from 127.0.0.1, saves the config and applies it at once.
|
|
func (d *daemon) handleSunshine(w http.ResponseWriter, r *http.Request) {
|
|
var hosts []sunshineHost
|
|
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&hosts); err != nil {
|
|
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
for i := range hosts {
|
|
hosts[i].Host = strings.TrimSpace(hosts[i].Host)
|
|
if hosts[i].Port == sunshineDefaultPort {
|
|
hosts[i].Port = 0
|
|
}
|
|
}
|
|
if err := validateSunshineHosts(hosts); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
d.mu.Lock()
|
|
d.cfg.SunshineHosts = hosts
|
|
cfg := d.cfg
|
|
d.mu.Unlock()
|
|
if err := saveConfig(d.cfgPath, cfg); err != nil {
|
|
d.logf("saving config: %v", err)
|
|
}
|
|
d.logf("sunshine hosts set to %v", hosts)
|
|
if err := d.fwd.set(d.localForwardRules()); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
io.WriteString(w, "ok\n")
|
|
}
|
|
|
|
// validHostName accepts an empty string, a DNS name or an IP address.
|
|
func validHostName(s string) bool {
|
|
if len(s) > 253 {
|
|
return false
|
|
}
|
|
for _, c := range s {
|
|
switch {
|
|
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '.', c == '-', c == ':':
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (d *daemon) handleQuit(w http.ResponseWriter, r *http.Request) {
|
|
io.WriteString(w, "stopping\n")
|
|
d.stop()
|
|
}
|
|
|
|
func (d *daemon) stop() {
|
|
d.quitOnce.Do(func() { close(d.quit) })
|
|
}
|
|
|
|
// handleUninstall takes the home screen icon away, logs the console out of
|
|
// the tailnet and stops the daemon, which deletes its data directory (login,
|
|
// settings, logs) on the way out. The payload file itself is wherever the
|
|
// user keeps it.
|
|
func (d *daemon) handleUninstall(w http.ResponseWriter, r *http.Request) {
|
|
d.logf("uninstall requested from the status page")
|
|
iconNote := "The home screen icon was removed."
|
|
if err := removeHomeIcon(); err != nil {
|
|
d.logf("uninstall: home screen icon: %v", err)
|
|
iconNote = "The home screen icon could not be removed (" + err.Error() + "); delete it from the home screen."
|
|
}
|
|
if d.lc != nil {
|
|
if err := d.lc.Logout(r.Context()); err != nil {
|
|
d.logf("uninstall: logout: %v", err)
|
|
}
|
|
}
|
|
d.mu.Lock()
|
|
d.removeDataOnExit = true
|
|
d.mu.Unlock()
|
|
notify("Tailscale was removed from this PS5.")
|
|
io.WriteString(w, "Tailscale was removed from this PS5. "+iconNote+"\n")
|
|
d.stop()
|
|
}
|
|
|
|
// stopRunningInstance asks an instance that is already serving the status
|
|
// page to exit and waits for the port to become free. It reports whether
|
|
// there was one. The request comes from the console itself, so it needs no
|
|
// password.
|
|
func stopRunningInstance(webAddr string) bool {
|
|
_, port, err := net.SplitHostPort(webAddr)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
base := "http://" + net.JoinHostPort("127.0.0.1", port)
|
|
client := &http.Client{Timeout: 3 * time.Second}
|
|
|
|
resp, err := client.Get(base + "/api/ping")
|
|
if err != nil {
|
|
return false
|
|
}
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 100))
|
|
resp.Body.Close()
|
|
if !strings.HasPrefix(string(body), "ps5-tailscale") {
|
|
return false
|
|
}
|
|
|
|
req, _ := http.NewRequest("POST", base+"/api/quit", nil)
|
|
req.Header.Set(apiHeader, "1")
|
|
if resp, err := client.Do(req); err == nil {
|
|
resp.Body.Close()
|
|
}
|
|
for i := 0; i < 40; i++ {
|
|
c, err := net.DialTimeout("tcp", net.JoinHostPort("127.0.0.1", port), time.Second)
|
|
if err != nil {
|
|
return true
|
|
}
|
|
c.Close()
|
|
time.Sleep(250 * time.Millisecond)
|
|
}
|
|
return true
|
|
}
|