mirror of
https://github.com/holdmysocks/ps5-tailscale.git
synced 2026-10-06 09:00:19 +02:00
- The launcher keeps a log (/data/tailscale/launcher.log) and shows a notification when it cannot start. A payload manager does not show what a payload prints, so a failed start used to leave no trace. The Go runtime's stderr goes to the same file until the daemon opens its log. - New setting "who on the tailnet may connect": every device the tailnet's access rules allow (default), or only devices of the same user as the console. Applies to every forwarded TCP port, the UDP ports and the status page over the tailnet. - The status page shows when the console's key expires and warns from two weeks before; the console shows a notification at 14, 3 and 1 days. - A newer release is announced once on the console, not only on the page. - Status page: click an address to copy it; OS, status and address columns no longer break mid-word; the facts stack on narrow screens.
309 lines
8.8 KiB
Go
309 lines
8.8 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"tailscale.com/ipn"
|
|
)
|
|
|
|
// Settings editing from the status page. Most settings take effect at once;
|
|
// the few that are only read when the payload starts are reported back so
|
|
// the page can say so.
|
|
|
|
const (
|
|
priorityLow = "low"
|
|
priorityHigh = "high"
|
|
// priorityFile tells the launcher which scheduling class to use. The
|
|
// launcher is C and runs before any of this, so it gets the one setting
|
|
// it needs in a file of its own rather than parsing the config.
|
|
priorityFile = "priority"
|
|
)
|
|
|
|
// settings is the editable part of the config as the status page sees it.
|
|
type settings struct {
|
|
Hostname string `json:"hostname"`
|
|
WebAddr string `json:"webAddr"`
|
|
HTTPProxyAddr string `json:"httpProxyAddr"`
|
|
SunshineHosts []sunshineHost `json:"sunshineHosts"`
|
|
Forwards []forwardRule `json:"forwards"`
|
|
UDPPorts []uint16 `json:"udpPorts"`
|
|
BlockedPorts []uint16 `json:"blockedPorts"`
|
|
Priority string `json:"priority"`
|
|
AllowFrom string `json:"allowFrom"`
|
|
CheckUpdates bool `json:"checkUpdates"`
|
|
Verbose bool `json:"verbose"`
|
|
|
|
// PasswordSet says whether a password is in place. Password is only
|
|
// read: absent leaves the password alone, empty removes it, anything
|
|
// else sets it.
|
|
PasswordSet bool `json:"passwordSet"`
|
|
Password *string `json:"password,omitempty"`
|
|
}
|
|
|
|
func settingsFromConfig(cfg config) settings {
|
|
s := settings{
|
|
Hostname: cfg.Hostname,
|
|
WebAddr: cfg.WebAddr,
|
|
HTTPProxyAddr: cfg.HTTPProxyAddr,
|
|
SunshineHosts: append([]sunshineHost{}, cfg.SunshineHosts...),
|
|
Forwards: append([]forwardRule{}, cfg.Forwards...),
|
|
UDPPorts: append([]uint16{}, cfg.UDPPorts...),
|
|
BlockedPorts: append([]uint16{}, cfg.BlockedPorts...),
|
|
Priority: priorityLow,
|
|
AllowFrom: accessAll,
|
|
CheckUpdates: cfg.CheckUpdates,
|
|
Verbose: cfg.Verbose,
|
|
PasswordSet: cfg.PasswordHash != "",
|
|
}
|
|
if cfg.Priority == priorityHigh {
|
|
s.Priority = priorityHigh
|
|
}
|
|
if cfg.AllowFrom == accessOwn {
|
|
s.AllowFrom = accessOwn
|
|
}
|
|
return s
|
|
}
|
|
|
|
// validate checks the settings and tidies them.
|
|
func (s *settings) validate() error {
|
|
s.Hostname = strings.TrimSpace(s.Hostname)
|
|
if !validTailnetName(s.Hostname) {
|
|
return fmt.Errorf("the name may only contain letters, digits and hyphens (at most 63)")
|
|
}
|
|
if err := validListenAddr(s.WebAddr); err != nil {
|
|
return fmt.Errorf("status page address: %w", err)
|
|
}
|
|
s.HTTPProxyAddr = strings.TrimSpace(s.HTTPProxyAddr)
|
|
if s.HTTPProxyAddr != "" {
|
|
if err := validListenAddr(s.HTTPProxyAddr); err != nil {
|
|
return fmt.Errorf("HTTP proxy address: %w", err)
|
|
}
|
|
}
|
|
if err := validateSunshineHosts(s.SunshineHosts); err != nil {
|
|
return err
|
|
}
|
|
for _, f := range s.Forwards {
|
|
if f.Proto != "tcp" && f.Proto != "udp" {
|
|
return fmt.Errorf("forward %v: the protocol must be tcp or udp", f)
|
|
}
|
|
if err := validListenAddr(f.Listen); err != nil {
|
|
return fmt.Errorf("forward %v: listen address: %w", f, err)
|
|
}
|
|
host, port, err := net.SplitHostPort(f.Target)
|
|
if err != nil || host == "" || !validHostName(host) || !validPort(port) {
|
|
return fmt.Errorf("forward %v: the target must be host:port", f)
|
|
}
|
|
}
|
|
if slices.Contains(s.UDPPorts, 0) || slices.Contains(s.BlockedPorts, 0) {
|
|
return fmt.Errorf("0 is not a port")
|
|
}
|
|
if s.AllowFrom == "" {
|
|
s.AllowFrom = accessAll
|
|
}
|
|
if s.AllowFrom != accessAll && s.AllowFrom != accessOwn {
|
|
return fmt.Errorf("who may connect must be all or own")
|
|
}
|
|
if s.Priority != priorityLow && s.Priority != priorityHigh {
|
|
return fmt.Errorf("the priority must be low or high")
|
|
}
|
|
if s.Password != nil && len(*s.Password) > 0 && len(*s.Password) < 4 {
|
|
return fmt.Errorf("the password must be at least 4 characters")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validTailnetName(s string) bool {
|
|
if len(s) == 0 || len(s) > 63 || s[0] == '-' || s[len(s)-1] == '-' {
|
|
return false
|
|
}
|
|
for _, c := range s {
|
|
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-') {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func validPort(s string) bool {
|
|
n, err := strconv.Atoi(s)
|
|
return err == nil && n >= 1 && n <= 65535
|
|
}
|
|
|
|
// validListenAddr accepts "host:port" and ":port".
|
|
func validListenAddr(addr string) error {
|
|
host, port, err := net.SplitHostPort(addr)
|
|
if err != nil {
|
|
return fmt.Errorf("%q is not host:port", addr)
|
|
}
|
|
if !validHostName(host) || !validPort(port) {
|
|
return fmt.Errorf("%q is not a valid address", addr)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (d *daemon) handleGetConfig(w http.ResponseWriter, r *http.Request) {
|
|
d.mu.Lock()
|
|
s := settingsFromConfig(d.cfg)
|
|
d.mu.Unlock()
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
json.NewEncoder(w).Encode(s)
|
|
}
|
|
|
|
// handleSetConfig saves new settings and applies what can be applied without
|
|
// a restart. The reply lists the settings that need one.
|
|
func (d *daemon) handleSetConfig(w http.ResponseWriter, r *http.Request) {
|
|
var s settings
|
|
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&s); err != nil {
|
|
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
if err := s.validate(); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var newHash *string
|
|
if s.Password != nil {
|
|
hash := ""
|
|
if *s.Password != "" {
|
|
var err error
|
|
if hash, err = hashPassword(*s.Password); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
newHash = &hash
|
|
}
|
|
|
|
d.mu.Lock()
|
|
old := d.cfg
|
|
cfg := d.cfg
|
|
cfg.Hostname = s.Hostname
|
|
cfg.WebAddr = s.WebAddr
|
|
cfg.HTTPProxyAddr = s.HTTPProxyAddr
|
|
if s.SunshineHosts != nil {
|
|
// The settings form leaves the Sunshine hosts out: they have a
|
|
// panel of their own.
|
|
cfg.SunshineHosts = s.SunshineHosts
|
|
}
|
|
cfg.Forwards = s.Forwards
|
|
cfg.UDPPorts = s.UDPPorts
|
|
cfg.BlockedPorts = s.BlockedPorts
|
|
cfg.Priority = ""
|
|
if s.Priority == priorityHigh {
|
|
cfg.Priority = priorityHigh
|
|
}
|
|
cfg.AllowFrom = ""
|
|
if s.AllowFrom == accessOwn {
|
|
cfg.AllowFrom = accessOwn
|
|
}
|
|
cfg.CheckUpdates = s.CheckUpdates
|
|
cfg.Verbose = s.Verbose
|
|
if newHash != nil {
|
|
cfg.PasswordHash = *newHash
|
|
}
|
|
d.cfg = cfg
|
|
d.mu.Unlock()
|
|
|
|
if err := saveConfig(d.cfgPath, cfg); err != nil {
|
|
d.logf("saving config: %v", err)
|
|
http.Error(w, "the settings are in effect but could not be saved: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
d.logf("settings changed from the status page")
|
|
|
|
// Apply.
|
|
problems := []string{}
|
|
if cfg.Hostname != old.Hostname && d.lc != nil {
|
|
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
|
_, err := d.lc.EditPrefs(ctx, &ipn.MaskedPrefs{Prefs: ipn.Prefs{Hostname: cfg.Hostname}, HostnameSet: true})
|
|
cancel()
|
|
if err != nil {
|
|
problems = append(problems, "name: "+err.Error())
|
|
}
|
|
}
|
|
if err := d.fwd.set(d.localForwardRules()); err != nil {
|
|
problems = append(problems, err.Error())
|
|
}
|
|
if cfg.HTTPProxyAddr != old.HTTPProxyAddr {
|
|
if err := d.setProxy(cfg.HTTPProxyAddr); err != nil {
|
|
problems = append(problems, "HTTP proxy: "+err.Error())
|
|
}
|
|
}
|
|
if newHash != nil && cfg.PasswordHash != old.PasswordHash {
|
|
// A changed password ends every session but the one that changed it.
|
|
d.sessions.clear()
|
|
if cfg.PasswordHash != "" {
|
|
if token, err := d.sessions.create(); err == nil {
|
|
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: token, Path: "/",
|
|
MaxAge: int(sessionLifetime.Seconds()), HttpOnly: true, SameSite: http.SameSiteStrictMode})
|
|
}
|
|
}
|
|
}
|
|
d.writePriorityFile()
|
|
d.access.clear()
|
|
// UDP ports and blocked ports are read from the config where they are used.
|
|
|
|
restart := []string{}
|
|
if cfg.WebAddr != old.WebAddr {
|
|
restart = append(restart, "status page address")
|
|
}
|
|
if cfg.Priority != old.Priority {
|
|
restart = append(restart, "priority")
|
|
}
|
|
if cfg.Verbose != old.Verbose {
|
|
restart = append(restart, "verbose log")
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]any{"restart": restart, "problems": problems})
|
|
}
|
|
|
|
// writePriorityFile leaves the launcher its instruction for the next start.
|
|
func (d *daemon) writePriorityFile() {
|
|
d.mu.Lock()
|
|
high := d.cfg.Priority == priorityHigh
|
|
d.mu.Unlock()
|
|
path := filepath.Join(dataDir, priorityFile)
|
|
if high {
|
|
os.WriteFile(path, []byte(priorityHigh+"\n"), 0o644)
|
|
} else {
|
|
os.Remove(path)
|
|
}
|
|
}
|
|
|
|
// setProxy starts, stops or moves the outbound HTTP proxy.
|
|
func (d *daemon) setProxy(addr string) error {
|
|
d.mu.Lock()
|
|
old := d.proxyLn
|
|
d.proxyLn, d.proxyPort = nil, 0
|
|
d.mu.Unlock()
|
|
if old != nil {
|
|
old.Close()
|
|
}
|
|
if addr == "" {
|
|
return nil
|
|
}
|
|
ln, err := listenResilient("tcp", addr, d.logf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
d.mu.Lock()
|
|
d.proxyLn, d.proxyPort = ln, ln.port()
|
|
d.mu.Unlock()
|
|
go d.serveProxy(ln)
|
|
return nil
|
|
}
|