ipn/{conffile,ipnlocal}: start booting tailscaled from a config file w/ auth key

Updates #1412

Change-Id: Icd880035a31df59797b8379f4af19da5c4c453e2
Co-authored-by: Maisem Ali <maisem@tailscale.com>
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
pull/9769/head
Brad Fitzpatrick 1 year ago committed by Brad Fitzpatrick
parent 6ca8650c7b
commit 1fc3573446

@ -67,6 +67,13 @@ jobs:
- goarch: amd64 - goarch: amd64
- goarch: amd64 - goarch: amd64
buildflags: "-race" buildflags: "-race"
shard: '1/3'
- goarch: amd64
buildflags: "-race"
shard: '2/3'
- goarch: amd64
buildflags: "-race"
shard: '3/3'
- goarch: "386" # thanks yaml - goarch: "386" # thanks yaml
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
@ -115,6 +122,7 @@ jobs:
run: PATH=$PWD/tool:$PATH /tmp/testwrapper ./... ${{matrix.buildflags}} run: PATH=$PWD/tool:$PATH /tmp/testwrapper ./... ${{matrix.buildflags}}
env: env:
GOARCH: ${{ matrix.goarch }} GOARCH: ${{ matrix.goarch }}
TS_TEST_SHARD: ${{ matrix.shard }}
- name: bench all - name: bench all
run: ./tool/go test ${{matrix.buildflags}} -bench=. -benchtime=1x -run=^$ $(for x in $(git grep -l "^func Benchmark" | xargs dirname | sort | uniq); do echo "./$x"; done) run: ./tool/go test ${{matrix.buildflags}} -bench=. -benchtime=1x -run=^$ $(for x in $(git grep -l "^func Benchmark" | xargs dirname | sort | uniq); do echo "./$x"; done)
env: env:

@ -13,10 +13,11 @@ import (
// ConfigVAlpha is the config file format for the "alpha0" version. // ConfigVAlpha is the config file format for the "alpha0" version.
type ConfigVAlpha struct { type ConfigVAlpha struct {
Version string // "alpha0" for now
Locked opt.Bool `json:",omitempty"` // whether the config is locked from being changed by 'tailscale set'; it defaults to true 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 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) AuthKey *string `json:",omitempty"` // as needed if NeedsLogin. either key or path to a file (if prefixed with "file:")
Enabled opt.Bool `json:",omitempty"` // wantRunning; empty string defaults to true 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 OperatorUser *string `json:",omitempty"` // local user name who is allowed to operate tailscaled without being root or using sudo
@ -48,13 +49,15 @@ func (c *ConfigVAlpha) ToPrefs() (MaskedPrefs, error) {
if c == nil { if c == nil {
return mp, nil return mp, nil
} }
mp.WantRunning = !c.Enabled.EqualBool(false)
mp.WantRunningSet = mp.WantRunning || c.Enabled != ""
if c.ServerURL != nil { if c.ServerURL != nil {
mp.ControlURL = *c.ServerURL mp.ControlURL = *c.ServerURL
mp.ControlURLSet = true mp.ControlURLSet = true
} }
if c.Enabled != "" { if c.AuthKey != nil && *c.AuthKey != "" {
mp.WantRunning = c.Enabled.EqualBool(true) mp.LoggedOut = false
mp.WantRunningSet = true mp.LoggedOutSet = true
} }
if c.OperatorUser != nil { if c.OperatorUser != nil {
mp.OperatorUser = *c.OperatorUser mp.OperatorUser = *c.OperatorUser

@ -6,6 +6,7 @@
package conffile package conffile
import ( import (
"bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
@ -14,7 +15,7 @@ import (
"tailscale.com/ipn" "tailscale.com/ipn"
) )
// Config describes a config file // Config describes a config file.
type Config struct { type Config struct {
Path string // disk path of HuJSON Path string // disk path of HuJSON
Raw []byte // raw bytes from disk, in HuJSON form Raw []byte // raw bytes from disk, in HuJSON form
@ -29,6 +30,11 @@ type Config struct {
Parsed ipn.ConfigVAlpha Parsed ipn.ConfigVAlpha
} }
// WantRunning reports whether c is non-nil and it's configured to be running.
func (c *Config) WantRunning() bool {
return c != nil && !c.Parsed.Enabled.EqualBool(false)
}
// Load reads and parses the config file at the provided path on disk. // Load reads and parses the config file at the provided path on disk.
func Load(path string) (*Config, error) { func Load(path string) (*Config, error) {
var c Config var c Config
@ -58,9 +64,14 @@ func Load(path string) (*Config, error) {
} }
c.Version = ver.Version c.Version = ver.Version
err = json.Unmarshal(c.Std, &c.Parsed) jd := json.NewDecoder(bytes.NewReader(c.Std))
jd.DisallowUnknownFields()
err = jd.Decode(&c.Parsed)
if err != nil { if err != nil {
return nil, fmt.Errorf("error parsing config file %s: %w", path, err) return nil, fmt.Errorf("error parsing config file %s: %w", path, err)
} }
if jd.More() {
return nil, fmt.Errorf("error parsing config file %s: trailing data after JSON object", path)
}
return &c, nil return &c, nil
} }

@ -322,8 +322,18 @@ func NewLocalBackend(logf logger.Logf, logID logid.PublicID, sys *tsd.System, lo
sds.SetDialer(dialer.SystemDial) sds.SetDialer(dialer.SystemDial)
} }
hi := hostinfo.New() if sys.InitialConfig != nil {
logf.JSON(1, "Hostinfo", hi) p := pm.CurrentPrefs().AsStruct()
mp, err := sys.InitialConfig.Parsed.ToPrefs()
if err != nil {
return nil, err
}
p.ApplyEdits(&mp)
if err := pm.SetPrefs(p.View(), ""); err != nil {
return nil, err
}
}
envknob.LogCurrent(logf) envknob.LogCurrent(logf)
if dialer == nil { if dialer == nil {
dialer = &tsdial.Dialer{Logf: logf} dialer = &tsdial.Dialer{Logf: logf}
@ -1473,6 +1483,17 @@ func (b *LocalBackend) Start(opts ipn.Options) error {
} }
} }
profileID := b.pm.CurrentProfile().ID profileID := b.pm.CurrentProfile().ID
if b.state != ipn.Running && b.conf != nil && b.conf.Parsed.AuthKey != nil && opts.AuthKey == "" {
v := *b.conf.Parsed.AuthKey
if filename, ok := strings.CutPrefix(v, "file:"); ok {
b, err := os.ReadFile(filename)
if err != nil {
return fmt.Errorf("error reading config file authKey: %w", err)
}
v = strings.TrimSpace(string(b))
}
opts.AuthKey = v
}
// The iOS client sends a "Start" whenever its UI screen comes // The iOS client sends a "Start" whenever its UI screen comes
// up, just because it wants a netmap. That should be fixed, // up, just because it wants a netmap. That should be fixed,
@ -1495,10 +1516,12 @@ func (b *LocalBackend) Start(opts ipn.Options) error {
} }
hostinfo := hostinfo.New() hostinfo := hostinfo.New()
applyConfigToHostinfo(hostinfo, b.conf)
hostinfo.BackendLogID = b.backendLogID.String() hostinfo.BackendLogID = b.backendLogID.String()
hostinfo.FrontendLogID = opts.FrontendLogID hostinfo.FrontendLogID = opts.FrontendLogID
hostinfo.Userspace.Set(b.sys.IsNetstack()) hostinfo.Userspace.Set(b.sys.IsNetstack())
hostinfo.UserspaceRouter.Set(b.sys.IsNetstackRouter()) hostinfo.UserspaceRouter.Set(b.sys.IsNetstackRouter())
b.logf.JSON(1, "Hostinfo", hostinfo)
// TODO(apenwarr): avoid the need to reinit controlclient. // TODO(apenwarr): avoid the need to reinit controlclient.
// This will trigger a full relogin/reconfigure cycle every // This will trigger a full relogin/reconfigure cycle every
@ -1648,6 +1671,7 @@ func (b *LocalBackend) Start(opts ipn.Options) error {
} }
tkaHead = string(head) tkaHead = string(head)
} }
confWantRunning := b.conf != nil && wantRunning
b.mu.Unlock() b.mu.Unlock()
if endpoints != nil { if endpoints != nil {
@ -1662,7 +1686,7 @@ func (b *LocalBackend) Start(opts ipn.Options) error {
b.send(ipn.Notify{BackendLogID: &blid}) b.send(ipn.Notify{BackendLogID: &blid})
b.send(ipn.Notify{Prefs: &prefs}) b.send(ipn.Notify{Prefs: &prefs})
if !loggedOut && b.hasNodeKey() { if !loggedOut && (b.hasNodeKey() || confWantRunning) {
// Even if !WantRunning, we should verify our key, if there // Even if !WantRunning, we should verify our key, if there
// is one. If you want tailscaled to be completely idle, // is one. If you want tailscaled to be completely idle,
// use logout instead. // use logout instead.
@ -2013,9 +2037,12 @@ func (b *LocalBackend) readPoller() {
// ResendHostinfoIfNeeded is called to recompute the Hostinfo and send // ResendHostinfoIfNeeded is called to recompute the Hostinfo and send
// the new version to the control server. // the new version to the control server.
func (b *LocalBackend) ResendHostinfoIfNeeded() { func (b *LocalBackend) ResendHostinfoIfNeeded() {
// TODO(maisem,bradfitz): this is all wrong. hostinfo has been modified
// a dozen ways elsewhere that this omits. This method should be rethought.
hi := hostinfo.New() hi := hostinfo.New()
b.mu.Lock() b.mu.Lock()
applyConfigToHostinfo(hi, b.conf)
if b.hostinfo != nil { if b.hostinfo != nil {
hi.Services = b.hostinfo.Services hi.Services = b.hostinfo.Services
} }
@ -2025,6 +2052,15 @@ func (b *LocalBackend) ResendHostinfoIfNeeded() {
b.doSetHostinfoFilterServices(hi) b.doSetHostinfoFilterServices(hi)
} }
func applyConfigToHostinfo(hi *tailcfg.Hostinfo, c *conffile.Config) {
if c == nil {
return
}
if c.Parsed.Hostname != nil {
hi.Hostname = *c.Parsed.Hostname
}
}
// WatchNotifications subscribes to the ipn.Notify message bus notification // WatchNotifications subscribes to the ipn.Notify message bus notification
// messages. // messages.
// //
@ -2732,6 +2768,12 @@ func (b *LocalBackend) CheckPrefs(p *ipn.Prefs) error {
} }
func (b *LocalBackend) checkPrefsLocked(p *ipn.Prefs) error { func (b *LocalBackend) checkPrefsLocked(p *ipn.Prefs) error {
if b.conf != nil && !b.conf.Parsed.Locked.EqualBool(false) {
// TODO(bradfitz,maisem): make this more fine-grained, permit changing
// some things if they're not explicitly set in the config. But for now
// (2023-10-16), just blanket disable everything.
return errors.New("can't reconfigure tailscaled when using a config file; config file is locked")
}
var errs []error var errs []error
if p.Hostname == "badhostname.tailscale." { if p.Hostname == "badhostname.tailscale." {
// Keep this one just for testing. // Keep this one just for testing.

@ -41,6 +41,8 @@ import (
"tailscale.com/tstest/integration/testcontrol" "tailscale.com/tstest/integration/testcontrol"
"tailscale.com/types/key" "tailscale.com/types/key"
"tailscale.com/types/logger" "tailscale.com/types/logger"
"tailscale.com/types/ptr"
"tailscale.com/util/must"
"tailscale.com/util/rands" "tailscale.com/util/rands"
"tailscale.com/version" "tailscale.com/version"
) )
@ -312,6 +314,33 @@ func TestOneNodeUpAuth(t *testing.T) {
d1.MustCleanShutdown(t) d1.MustCleanShutdown(t)
} }
func TestConfigFileAuthKey(t *testing.T) {
tstest.SkipOnUnshardedCI(t)
tstest.Shard(t)
t.Parallel()
const authKey = "opensesame"
env := newTestEnv(t, configureControl(func(control *testcontrol.Server) {
control.RequireAuthKey = authKey
}))
n1 := newTestNode(t, env)
n1.configFile = filepath.Join(n1.dir, "config.json")
authKeyFile := filepath.Join(n1.dir, "my-auth-key")
must.Do(os.WriteFile(authKeyFile, fmt.Appendf(nil, "%s\n", authKey), 0666))
must.Do(os.WriteFile(n1.configFile, must.Get(json.Marshal(ipn.ConfigVAlpha{
Version: "alpha0",
AuthKey: ptr.To("file:" + authKeyFile),
ServerURL: ptr.To(n1.env.ControlServer.URL),
})), 0644))
d1 := n1.StartDaemon()
n1.AwaitListening()
t.Logf("Got IP: %v", n1.AwaitIP4())
n1.AwaitRunning()
d1.MustCleanShutdown(t)
}
func TestTwoNodes(t *testing.T) { func TestTwoNodes(t *testing.T) {
tstest.Shard(t) tstest.Shard(t)
flakytest.Mark(t, "https://github.com/tailscale/tailscale/issues/3598") flakytest.Mark(t, "https://github.com/tailscale/tailscale/issues/3598")
@ -920,6 +949,7 @@ type testNode struct {
env *testEnv env *testEnv
dir string // temp dir for sock & state dir string // temp dir for sock & state
configFile string // or empty for none
sockFile string sockFile string
stateFile string stateFile string
upFlagGOOS string // if non-empty, sets TS_DEBUG_UP_FLAG_GOOS for cmd/tailscale CLI upFlagGOOS string // if non-empty, sets TS_DEBUG_UP_FLAG_GOOS for cmd/tailscale CLI
@ -1113,6 +1143,9 @@ func (n *testNode) StartDaemonAsIPNGOOS(ipnGOOS string) *Daemon {
"--tun=userspace-networking", "--tun=userspace-networking",
) )
} }
if n.configFile != "" {
cmd.Args = append(cmd.Args, "--config="+n.configFile)
}
cmd.Env = append(os.Environ(), cmd.Env = append(os.Environ(),
"TS_DEBUG_PERMIT_HTTP_C2N=1", "TS_DEBUG_PERMIT_HTTP_C2N=1",
"TS_LOG_TARGET="+n.env.LogCatcherServer.URL, "TS_LOG_TARGET="+n.env.LogCatcherServer.URL,

@ -33,6 +33,7 @@ import (
"tailscale.com/types/key" "tailscale.com/types/key"
"tailscale.com/types/logger" "tailscale.com/types/logger"
"tailscale.com/types/ptr" "tailscale.com/types/ptr"
"tailscale.com/util/must"
"tailscale.com/util/rands" "tailscale.com/util/rands"
"tailscale.com/util/set" "tailscale.com/util/set"
) )
@ -45,6 +46,7 @@ type Server struct {
Logf logger.Logf // nil means to use the log package Logf logger.Logf // nil means to use the log package
DERPMap *tailcfg.DERPMap // nil means to use prod DERP map DERPMap *tailcfg.DERPMap // nil means to use prod DERP map
RequireAuth bool RequireAuth bool
RequireAuthKey string // required authkey for all nodes
Verbose bool Verbose bool
DNSConfig *tailcfg.DNSConfig // nil means no DNS config DNSConfig *tailcfg.DNSConfig // nil means no DNS config
MagicDNSDomain string MagicDNSDomain string
@ -538,6 +540,14 @@ func (s *Server) serveRegister(w http.ResponseWriter, r *http.Request, mkey key.
j, _ := json.MarshalIndent(req, "", "\t") j, _ := json.MarshalIndent(req, "", "\t")
log.Printf("Got %T: %s", req, j) log.Printf("Got %T: %s", req, j)
} }
if s.RequireAuthKey != "" && req.Auth.AuthKey != s.RequireAuthKey {
res := must.Get(s.encode(mkey, false, tailcfg.RegisterResponse{
Error: "invalid authkey",
}))
w.WriteHeader(200)
w.Write(res)
return
}
// If this is a followup request, wait until interactive followup URL visit complete. // If this is a followup request, wait until interactive followup URL visit complete.
if req.Followup != "" { if req.Followup != "" {

@ -16,6 +16,7 @@ import (
"tailscale.com/envknob" "tailscale.com/envknob"
"tailscale.com/logtail/backoff" "tailscale.com/logtail/backoff"
"tailscale.com/types/logger" "tailscale.com/types/logger"
"tailscale.com/util/cibuild"
) )
// Replace replaces the value of target with val. // Replace replaces the value of target with val.
@ -76,6 +77,14 @@ func Shard(t testing.TB) {
} }
} }
// SkipOnUnshardedCI skips t if we're in CI and the TS_TEST_SHARD
// environment variable isn't set.
func SkipOnUnshardedCI(t testing.TB) {
if cibuild.On() && os.Getenv("TS_TEST_SHARD") == "" {
t.Skip("skipping on CI without TS_TEST_SHARD")
}
}
var serializeParallel = envknob.RegisterBool("TS_SERIAL_TESTS") var serializeParallel = envknob.RegisterBool("TS_SERIAL_TESTS")
// Parallel calls t.Parallel, unless TS_SERIAL_TESTS is set true. // Parallel calls t.Parallel, unless TS_SERIAL_TESTS is set true.

Loading…
Cancel
Save