From 18bd98d35bb620bf6f19024707e4864742b1e9eb Mon Sep 17 00:00:00 2001 From: Brad Fitzpatrick Date: Wed, 11 Oct 2023 13:55:57 -0700 Subject: [PATCH] cmd/tailscaled,*: add start of configuration file support Updates #1412 Co-authored-by: Maisem Ali Change-Id: I38d559c1784d09fc804f521986c9b4b548718f7d Signed-off-by: Brad Fitzpatrick --- client/tailscale/apitype/apitype.go | 9 ++ client/tailscale/localclient.go | 19 +++ cmd/tailscale/cli/debug.go | 20 +++ cmd/tailscaled/depaware.txt | 2 + cmd/tailscaled/tailscaled.go | 14 ++ ipn/conf.go | 122 ++++++++++++++++++ ipn/conffile/conffile.go | 66 ++++++++++ ipn/ipnlocal/local.go | 24 +++- ipn/localapi/localapi.go | 21 +++ tsd/tsd.go | 7 + .../tailscaled_deps_test_darwin.go | 1 + .../tailscaled_deps_test_freebsd.go | 1 + .../integration/tailscaled_deps_test_linux.go | 1 + .../tailscaled_deps_test_openbsd.go | 1 + .../tailscaled_deps_test_windows.go | 1 + types/preftype/netfiltermode.go | 15 +++ 16 files changed, 323 insertions(+), 1 deletion(-) create mode 100644 ipn/conf.go create mode 100644 ipn/conffile/conffile.go diff --git a/client/tailscale/apitype/apitype.go b/client/tailscale/apitype/apitype.go index b63abf69c..6458f510b 100644 --- a/client/tailscale/apitype/apitype.go +++ b/client/tailscale/apitype/apitype.go @@ -40,3 +40,12 @@ type SetPushDeviceTokenRequest struct { // PushDeviceToken is the iOS/macOS APNs device token (and any future Android equivalent). PushDeviceToken string } + +// ReloadConfigResponse is the response to a LocalAPI reload-config request. +// +// There are three possible outcomes: (false, "") if no config mode in use, +// (true, "") on success, or (false, "error message") on failure. +type ReloadConfigResponse struct { + Reloaded bool // whether the config was reloaded + Err string // any error message +} diff --git a/client/tailscale/localclient.go b/client/tailscale/localclient.go index 32cb1041b..3b1a70f68 100644 --- a/client/tailscale/localclient.go +++ b/client/tailscale/localclient.go @@ -1244,6 +1244,25 @@ func (lc *LocalClient) ProfileStatus(ctx context.Context) (current ipn.LoginProf return current, all, err } +// ReloadConfig reloads the config file, if possible. +func (lc *LocalClient) ReloadConfig(ctx context.Context) (ok bool, err error) { + body, err := lc.send(ctx, "POST", "/localapi/v0/reload-config", 200, nil) + if err != nil { + return + } + res, err := decodeJSON[apitype.ReloadConfigResponse](body) + if err != nil { + return + } + if err != nil { + return false, err + } + if res.Err != "" { + return false, errors.New(res.Err) + } + return res.Reloaded, nil +} + // SwitchToEmptyProfile creates and switches to a new unnamed profile. The new // profile is not assigned an ID until it is persisted after a successful login. // In order to login to the new profile, the user must call LoginInteractive. diff --git a/cmd/tailscale/cli/debug.go b/cmd/tailscale/cli/debug.go index 3a429a82a..e7988cbd4 100644 --- a/cmd/tailscale/cli/debug.go +++ b/cmd/tailscale/cli/debug.go @@ -149,6 +149,12 @@ var debugCmd = &ffcli.Command{ Exec: localAPIAction("force-netmap-update"), ShortHelp: "force a full no-op netmap update (for load testing)", }, + { + // TODO(bradfitz,maisem): eventually promote this out of debug + Name: "reload-config", + Exec: reloadConfig, + ShortHelp: "reload config", + }, { Name: "control-knobs", Exec: debugControlKnobs, @@ -451,6 +457,20 @@ func localAPIAction(action string) func(context.Context, []string) error { } } +func reloadConfig(ctx context.Context, args []string) error { + ok, err := localClient.ReloadConfig(ctx) + if err != nil { + return err + } + if ok { + printf("config reloaded\n") + return nil + } + printf("config mode not in use\n") + os.Exit(1) + panic("unreachable") +} + func runEnv(ctx context.Context, args []string) error { for _, e := range os.Environ() { outln(e) diff --git a/cmd/tailscaled/depaware.txt b/cmd/tailscaled/depaware.txt index 3895b4ff4..bd3fb065d 100644 --- a/cmd/tailscaled/depaware.txt +++ b/cmd/tailscaled/depaware.txt @@ -148,6 +148,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de github.com/tailscale/goupnp/scpd from github.com/tailscale/goupnp github.com/tailscale/goupnp/soap from github.com/tailscale/goupnp+ github.com/tailscale/goupnp/ssdp from github.com/tailscale/goupnp + github.com/tailscale/hujson from tailscale.com/ipn/conffile L 💣 github.com/tailscale/netlink from tailscale.com/wgengine/router+ 💣 github.com/tailscale/wireguard-go/conn from github.com/tailscale/wireguard-go/device+ W 💣 github.com/tailscale/wireguard-go/conn/winrio from github.com/tailscale/wireguard-go/conn @@ -239,6 +240,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de tailscale.com/health/healthmsg from tailscale.com/ipn/ipnlocal tailscale.com/hostinfo from tailscale.com/control/controlclient+ tailscale.com/ipn from tailscale.com/ipn/ipnlocal+ + tailscale.com/ipn/conffile from tailscale.com/cmd/tailscaled+ 💣 tailscale.com/ipn/ipnauth from tailscale.com/ipn/ipnlocal+ tailscale.com/ipn/ipnlocal from tailscale.com/ssh/tailssh+ tailscale.com/ipn/ipnserver from tailscale.com/cmd/tailscaled diff --git a/cmd/tailscaled/tailscaled.go b/cmd/tailscaled/tailscaled.go index 0c5a2f0ba..29daf7113 100644 --- a/cmd/tailscaled/tailscaled.go +++ b/cmd/tailscaled/tailscaled.go @@ -32,6 +32,7 @@ import ( "tailscale.com/cmd/tailscaled/childproc" "tailscale.com/control/controlclient" "tailscale.com/envknob" + "tailscale.com/ipn/conffile" "tailscale.com/ipn/ipnlocal" "tailscale.com/ipn/ipnserver" "tailscale.com/ipn/store" @@ -127,6 +128,7 @@ var args struct { tunname string cleanup bool + confFile string debug string port uint16 statepath string @@ -172,6 +174,7 @@ func main() { flag.StringVar(&args.birdSocketPath, "bird-socket", "", "path of the bird unix socket") flag.BoolVar(&printVersion, "version", false, "print version information and exit") flag.BoolVar(&args.disableLogs, "no-logs-no-support", false, "disable log uploads; this also disables any technical support") + flag.StringVar(&args.confFile, "config", "", "path to config file") if len(os.Args) > 0 && filepath.Base(os.Args[0]) == "tailscale" && beCLI != nil { beCLI() @@ -339,6 +342,17 @@ func run() error { sys := new(tsd.System) + // Parse config, if specified, to fail early if it's invalid. + var conf *conffile.Config + if args.confFile != "" { + var err error + conf, err = conffile.Load(args.confFile) + if err != nil { + return fmt.Errorf("error reading config file: %w", err) + } + sys.InitialConfig = conf + } + netMon, err := netmon.New(func(format string, args ...any) { logf(format, args...) }) diff --git a/ipn/conf.go b/ipn/conf.go new file mode 100644 index 000000000..e9eb98d37 --- /dev/null +++ b/ipn/conf.go @@ -0,0 +1,122 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package ipn + +import ( + "net/netip" + + "tailscale.com/tailcfg" + "tailscale.com/types/opt" + "tailscale.com/types/preftype" +) + +// ConfigVAlpha is the config file format for the "alpha0" version. +type ConfigVAlpha struct { + Locked opt.Bool `json:",omitempty"` // whether the config is locked from being changed by 'tailscale set'; it defaults to true + + ServerURL *string `json:",omitempty"` // defaults to https://controlplane.tailscale.com + AuthKey *string `json:",omitempty"` // as needed if NeedsLogin. either key or path to a file (if it contains a slash) + Enabled opt.Bool `json:",omitempty"` // wantRunning; empty string defaults to true + + OperatorUser *string `json:",omitempty"` // local user name who is allowed to operate tailscaled without being root or using sudo + Hostname *string `json:",omitempty"` + + AcceptDNS opt.Bool `json:"acceptDNS,omitempty"` // --accept-dns + AcceptRoutes opt.Bool `json:"acceptRoutes,omitempty"` + + ExitNode *string `json:"exitNode,omitempty"` // IP, StableID, or MagicDNS base name + AllowLANWhileUsingExitNode opt.Bool `json:"allowLANWhileUsingExitNode,omitempty"` + + AdvertiseRoutes []netip.Prefix `json:",omitempty"` + DisableSNAT opt.Bool `json:",omitempty"` + + NetfilterMode *string `json:",omitempty"` // "on", "off", "nodivert" + + PostureChecking opt.Bool `json:",omitempty"` + RunSSHServer opt.Bool `json:",omitempty"` // Tailscale SSH + ShieldsUp opt.Bool `json:",omitempty"` + AutoUpdate *AutoUpdatePrefs `json:",omitempty"` + ServeConfigTemp *ServeConfig `json:",omitempty"` // TODO(bradfitz,maisem): make separate stable type for this + + // TODO(bradfitz,maisem): future something like: + // Profile map[string]*Config // keyed by alice@gmail.com, corp.com (TailnetSID) +} + +func (c *ConfigVAlpha) ToPrefs() (MaskedPrefs, error) { + var mp MaskedPrefs + if c == nil { + return mp, nil + } + if c.ServerURL != nil { + mp.ControlURL = *c.ServerURL + mp.ControlURLSet = true + } + if c.Enabled != "" { + mp.WantRunning = c.Enabled.EqualBool(true) + mp.WantRunningSet = true + } + if c.OperatorUser != nil { + mp.OperatorUser = *c.OperatorUser + mp.OperatorUserSet = true + } + if c.Hostname != nil { + mp.Hostname = *c.Hostname + mp.HostnameSet = true + } + if c.AcceptDNS != "" { + mp.CorpDNS = c.AcceptDNS.EqualBool(true) + mp.CorpDNSSet = true + } + if c.AcceptRoutes != "" { + mp.RouteAll = c.AcceptRoutes.EqualBool(true) + mp.RouteAllSet = true + } + if c.ExitNode != nil { + ip, err := netip.ParseAddr(*c.ExitNode) + if err == nil { + mp.ExitNodeIP = ip + mp.ExitNodeIPSet = true + } else { + mp.ExitNodeID = tailcfg.StableNodeID(*c.ExitNode) + mp.ExitNodeIDSet = true + } + } + if c.AllowLANWhileUsingExitNode != "" { + mp.ExitNodeAllowLANAccess = c.AllowLANWhileUsingExitNode.EqualBool(true) + mp.ExitNodeAllowLANAccessSet = true + } + if c.AdvertiseRoutes != nil { + mp.AdvertiseRoutes = c.AdvertiseRoutes + mp.AdvertiseRoutesSet = true + } + if c.DisableSNAT != "" { + mp.NoSNAT = c.DisableSNAT.EqualBool(true) + mp.NoSNAT = true + } + if c.NetfilterMode != nil { + m, err := preftype.ParseNetfilterMode(*c.NetfilterMode) + if err != nil { + return mp, err + } + mp.NetfilterMode = m + mp.NetfilterModeSet = true + } + if c.PostureChecking != "" { + mp.PostureChecking = c.PostureChecking.EqualBool(true) + mp.PostureCheckingSet = true + } + if c.RunSSHServer != "" { + mp.RunSSH = c.RunSSHServer.EqualBool(true) + mp.RunSSHSet = true + } + if c.ShieldsUp != "" { + mp.ShieldsUp = c.ShieldsUp.EqualBool(true) + mp.ShieldsUpSet = true + } + if c.AutoUpdate != nil { + mp.AutoUpdate = *c.AutoUpdate + mp.AutoUpdateSet = true + } + return mp, nil +} diff --git a/ipn/conffile/conffile.go b/ipn/conffile/conffile.go new file mode 100644 index 000000000..8905d151f --- /dev/null +++ b/ipn/conffile/conffile.go @@ -0,0 +1,66 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +// Package conffile contains code to load, manipulate, and access config file +// settings. +package conffile + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/tailscale/hujson" + "tailscale.com/ipn" +) + +// Config describes a config file +type Config struct { + Path string // disk path of HuJSON + Raw []byte // raw bytes from disk, in HuJSON form + Std []byte // standardized JSON form + Version string // "alpha0" for now + + // Parsed is the parsed config, converted from its on-disk version to the + // latest known format. + // + // As of 2023-10-15 there is exactly one format ("alpha0") so this is both + // the on-disk format and the in-memory upgraded format. + Parsed ipn.ConfigVAlpha +} + +// Load reads and parses the config file at the provided path on disk. +func Load(path string) (*Config, error) { + var c Config + c.Path = path + + var err error + c.Raw, err = os.ReadFile(path) + if err != nil { + return nil, err + } + c.Std, err = hujson.Standardize(c.Raw) + if err != nil { + return nil, fmt.Errorf("error parsing config file %s HuJSON/JSON: %w", path, err) + } + var ver struct { + Version string `json:"version"` + } + if err := json.Unmarshal(c.Std, &ver); err != nil { + return nil, fmt.Errorf("error parsing config file %s: %w", path, err) + } + switch ver.Version { + case "": + return nil, fmt.Errorf("error parsing config file %s: no \"version\" field defined", path) + case "alpha0": + default: + return nil, fmt.Errorf("error parsing config file %s: unsupported \"version\" value %q; want \"alpha0\" for now", path, ver.Version) + } + c.Version = ver.Version + + err = json.Unmarshal(c.Std, &c.Parsed) + if err != nil { + return nil, fmt.Errorf("error parsing config file %s: %w", path, err) + } + return &c, nil +} diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index 5d29a206d..e91109084 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -44,6 +44,7 @@ import ( "tailscale.com/health/healthmsg" "tailscale.com/hostinfo" "tailscale.com/ipn" + "tailscale.com/ipn/conffile" "tailscale.com/ipn/ipnauth" "tailscale.com/ipn/ipnstate" "tailscale.com/ipn/policy" @@ -198,7 +199,8 @@ type LocalBackend struct { // The mutex protects the following elements. mu sync.Mutex - pm *profileManager // mu guards access + conf *conffile.Config // latest parsed config, or nil if not in declarative mode + pm *profileManager // mu guards access filterHash deephash.Sum httpTestClient *http.Client // for controlclient. nil by default, used by tests. ccGen clientGen // function for producing controlclient; lazily populated @@ -340,6 +342,7 @@ func NewLocalBackend(logf logger.Logf, logID logid.PublicID, sys *tsd.System, lo keyLogf: logger.LogOnChange(logf, 5*time.Minute, clock.Now), statsLogf: logger.LogOnChange(logf, 5*time.Minute, clock.Now), sys: sys, + conf: sys.InitialConfig, e: e, dialer: dialer, store: store, @@ -518,6 +521,25 @@ func (b *LocalBackend) SetDirectFileDoFinalRename(v bool) { b.directFileDoFinalRename = v } +// ReloadCOnfig reloads the backend's config from disk. +// +// It returns (false, nil) if not running in declarative mode, (true, nil) on +// success, or (false, error) on failure. +func (b *LocalBackend) ReloadConfig() (ok bool, err error) { + b.mu.Lock() + defer b.mu.Unlock() + if b.conf == nil { + return false, nil + } + conf, err := conffile.Load(b.conf.Path) + if err != nil { + return false, err + } + b.conf = conf + // TODO(bradfitz): apply things + return true, nil +} + // pauseOrResumeControlClientLocked pauses b.cc if there is no network available // or if the LocalBackend is in Stopped state with a valid NetMap. In all other // cases, it unpauses it. It is a no-op if b.cc is nil. diff --git a/ipn/localapi/localapi.go b/ipn/localapi/localapi.go index a60734234..1b4df8488 100644 --- a/ipn/localapi/localapi.go +++ b/ipn/localapi/localapi.go @@ -96,6 +96,7 @@ var handler = map[string]localAPIHandler{ "ping": (*Handler).servePing, "prefs": (*Handler).servePrefs, "pprof": (*Handler).servePprof, + "reload-config": (*Handler).reloadConfig, "reset-auth": (*Handler).serveResetAuth, "serve-config": (*Handler).serveServeConfig, "set-dns": (*Handler).serveSetDNS, @@ -838,6 +839,26 @@ func (h *Handler) servePprof(w http.ResponseWriter, r *http.Request) { servePprofFunc(w, r) } +func (h *Handler) reloadConfig(w http.ResponseWriter, r *http.Request) { + if !h.PermitWrite { + http.Error(w, "access denied", http.StatusForbidden) + return + } + if r.Method != httpm.POST { + http.Error(w, "use POST", http.StatusMethodNotAllowed) + return + } + ok, err := h.b.ReloadConfig() + var res apitype.ReloadConfigResponse + res.Reloaded = ok + if err != nil { + res.Err = err.Error() + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(&res) +} + func (h *Handler) serveResetAuth(w http.ResponseWriter, r *http.Request) { if !h.PermitWrite { http.Error(w, "reset-auth modify access denied", http.StatusForbidden) diff --git a/tsd/tsd.go b/tsd/tsd.go index ff475b6be..5debb2aff 100644 --- a/tsd/tsd.go +++ b/tsd/tsd.go @@ -23,6 +23,7 @@ import ( "tailscale.com/control/controlknobs" "tailscale.com/ipn" + "tailscale.com/ipn/conffile" "tailscale.com/net/dns" "tailscale.com/net/netmon" "tailscale.com/net/tsdial" @@ -47,6 +48,12 @@ type System struct { StateStore SubSystem[ipn.StateStore] Netstack SubSystem[NetstackImpl] // actually a *netstack.Impl + // InitialConfig is initial server config, if any. + // It is nil if the node is not in declarative mode. + // This value is never updated after startup. + // LocalBackend tracks the current config after any reloads. + InitialConfig *conffile.Config + // onlyNetstack is whether the Tun value is a fake TUN device // and we're using netstack for everything. onlyNetstack bool diff --git a/tstest/integration/tailscaled_deps_test_darwin.go b/tstest/integration/tailscaled_deps_test_darwin.go index 1c8e928a1..fb39930f2 100644 --- a/tstest/integration/tailscaled_deps_test_darwin.go +++ b/tstest/integration/tailscaled_deps_test_darwin.go @@ -16,6 +16,7 @@ import ( _ "tailscale.com/derp/derphttp" _ "tailscale.com/envknob" _ "tailscale.com/ipn" + _ "tailscale.com/ipn/conffile" _ "tailscale.com/ipn/ipnlocal" _ "tailscale.com/ipn/ipnserver" _ "tailscale.com/ipn/store" diff --git a/tstest/integration/tailscaled_deps_test_freebsd.go b/tstest/integration/tailscaled_deps_test_freebsd.go index 1c8e928a1..fb39930f2 100644 --- a/tstest/integration/tailscaled_deps_test_freebsd.go +++ b/tstest/integration/tailscaled_deps_test_freebsd.go @@ -16,6 +16,7 @@ import ( _ "tailscale.com/derp/derphttp" _ "tailscale.com/envknob" _ "tailscale.com/ipn" + _ "tailscale.com/ipn/conffile" _ "tailscale.com/ipn/ipnlocal" _ "tailscale.com/ipn/ipnserver" _ "tailscale.com/ipn/store" diff --git a/tstest/integration/tailscaled_deps_test_linux.go b/tstest/integration/tailscaled_deps_test_linux.go index 1c8e928a1..fb39930f2 100644 --- a/tstest/integration/tailscaled_deps_test_linux.go +++ b/tstest/integration/tailscaled_deps_test_linux.go @@ -16,6 +16,7 @@ import ( _ "tailscale.com/derp/derphttp" _ "tailscale.com/envknob" _ "tailscale.com/ipn" + _ "tailscale.com/ipn/conffile" _ "tailscale.com/ipn/ipnlocal" _ "tailscale.com/ipn/ipnserver" _ "tailscale.com/ipn/store" diff --git a/tstest/integration/tailscaled_deps_test_openbsd.go b/tstest/integration/tailscaled_deps_test_openbsd.go index 1c8e928a1..fb39930f2 100644 --- a/tstest/integration/tailscaled_deps_test_openbsd.go +++ b/tstest/integration/tailscaled_deps_test_openbsd.go @@ -16,6 +16,7 @@ import ( _ "tailscale.com/derp/derphttp" _ "tailscale.com/envknob" _ "tailscale.com/ipn" + _ "tailscale.com/ipn/conffile" _ "tailscale.com/ipn/ipnlocal" _ "tailscale.com/ipn/ipnserver" _ "tailscale.com/ipn/store" diff --git a/tstest/integration/tailscaled_deps_test_windows.go b/tstest/integration/tailscaled_deps_test_windows.go index cea8e5749..dd3bdcd31 100644 --- a/tstest/integration/tailscaled_deps_test_windows.go +++ b/tstest/integration/tailscaled_deps_test_windows.go @@ -23,6 +23,7 @@ import ( _ "tailscale.com/derp/derphttp" _ "tailscale.com/envknob" _ "tailscale.com/ipn" + _ "tailscale.com/ipn/conffile" _ "tailscale.com/ipn/ipnlocal" _ "tailscale.com/ipn/ipnserver" _ "tailscale.com/ipn/store" diff --git a/types/preftype/netfiltermode.go b/types/preftype/netfiltermode.go index b85d54004..273e17344 100644 --- a/types/preftype/netfiltermode.go +++ b/types/preftype/netfiltermode.go @@ -5,6 +5,8 @@ // preferences. package preftype +import "fmt" + // NetfilterMode is the firewall management mode to use when // programming the Linux network stack. type NetfilterMode int @@ -17,6 +19,19 @@ const ( NetfilterOn NetfilterMode = 2 // manage tailscale chains and call them from main chains ) +func ParseNetfilterMode(s string) (NetfilterMode, error) { + switch s { + case "off": + return NetfilterOff, nil + case "nodivert": + return NetfilterNoDivert, nil + case "on": + return NetfilterOn, nil + default: + return NetfilterOff, fmt.Errorf("unknown netfilter mode %q", s) + } +} + func (m NetfilterMode) String() string { switch m { case NetfilterOff: