Files
holdmysocks--ps5-tailscale/tsd/settings.go
T
holdmysocks bc19d3c251 Report start-up failures, limit access to own devices, warn about key expiry
- 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.
2026-10-05 08:31:24 -04:00

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
}