diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c7dec1ff8..643b3e1a4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -67,6 +67,13 @@ jobs: - goarch: amd64 - goarch: amd64 buildflags: "-race" + shard: '1/3' + - goarch: amd64 + buildflags: "-race" + shard: '2/3' + - goarch: amd64 + buildflags: "-race" + shard: '3/3' - goarch: "386" # thanks yaml runs-on: ubuntu-22.04 steps: @@ -115,6 +122,7 @@ jobs: run: PATH=$PWD/tool:$PATH /tmp/testwrapper ./... ${{matrix.buildflags}} env: GOARCH: ${{ matrix.goarch }} + TS_TEST_SHARD: ${{ matrix.shard }} - 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) env: diff --git a/ipn/conf.go b/ipn/conf.go index e9eb98d37..a4ab2deb9 100644 --- a/ipn/conf.go +++ b/ipn/conf.go @@ -13,10 +13,11 @@ import ( // 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 + 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 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 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 { return mp, nil } + mp.WantRunning = !c.Enabled.EqualBool(false) + mp.WantRunningSet = mp.WantRunning || c.Enabled != "" 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.AuthKey != nil && *c.AuthKey != "" { + mp.LoggedOut = false + mp.LoggedOutSet = true } if c.OperatorUser != nil { mp.OperatorUser = *c.OperatorUser diff --git a/ipn/conffile/conffile.go b/ipn/conffile/conffile.go index 8905d151f..837094639 100644 --- a/ipn/conffile/conffile.go +++ b/ipn/conffile/conffile.go @@ -6,6 +6,7 @@ package conffile import ( + "bytes" "encoding/json" "fmt" "os" @@ -14,7 +15,7 @@ import ( "tailscale.com/ipn" ) -// Config describes a config file +// Config describes a config file. type Config struct { Path string // disk path of HuJSON Raw []byte // raw bytes from disk, in HuJSON form @@ -29,6 +30,11 @@ type Config struct { 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. func Load(path string) (*Config, error) { var c Config @@ -58,9 +64,14 @@ func Load(path string) (*Config, error) { } 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 { 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 } diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index f6188f000..5f4b75364 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -322,8 +322,18 @@ func NewLocalBackend(logf logger.Logf, logID logid.PublicID, sys *tsd.System, lo sds.SetDialer(dialer.SystemDial) } - hi := hostinfo.New() - logf.JSON(1, "Hostinfo", hi) + if sys.InitialConfig != nil { + 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) if dialer == nil { dialer = &tsdial.Dialer{Logf: logf} @@ -1473,6 +1483,17 @@ func (b *LocalBackend) Start(opts ipn.Options) error { } } 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 // 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() + applyConfigToHostinfo(hostinfo, b.conf) hostinfo.BackendLogID = b.backendLogID.String() hostinfo.FrontendLogID = opts.FrontendLogID hostinfo.Userspace.Set(b.sys.IsNetstack()) hostinfo.UserspaceRouter.Set(b.sys.IsNetstackRouter()) + b.logf.JSON(1, "Hostinfo", hostinfo) // TODO(apenwarr): avoid the need to reinit controlclient. // This will trigger a full relogin/reconfigure cycle every @@ -1648,6 +1671,7 @@ func (b *LocalBackend) Start(opts ipn.Options) error { } tkaHead = string(head) } + confWantRunning := b.conf != nil && wantRunning b.mu.Unlock() 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{Prefs: &prefs}) - if !loggedOut && b.hasNodeKey() { + if !loggedOut && (b.hasNodeKey() || confWantRunning) { // Even if !WantRunning, we should verify our key, if there // is one. If you want tailscaled to be completely idle, // use logout instead. @@ -2013,9 +2037,12 @@ func (b *LocalBackend) readPoller() { // ResendHostinfoIfNeeded is called to recompute the Hostinfo and send // the new version to the control server. 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() b.mu.Lock() + applyConfigToHostinfo(hi, b.conf) if b.hostinfo != nil { hi.Services = b.hostinfo.Services } @@ -2025,6 +2052,15 @@ func (b *LocalBackend) ResendHostinfoIfNeeded() { 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 // messages. // @@ -2732,6 +2768,12 @@ func (b *LocalBackend) CheckPrefs(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 if p.Hostname == "badhostname.tailscale." { // Keep this one just for testing. diff --git a/tstest/integration/integration_test.go b/tstest/integration/integration_test.go index d04c27f7d..23cd1059b 100644 --- a/tstest/integration/integration_test.go +++ b/tstest/integration/integration_test.go @@ -41,6 +41,8 @@ import ( "tailscale.com/tstest/integration/testcontrol" "tailscale.com/types/key" "tailscale.com/types/logger" + "tailscale.com/types/ptr" + "tailscale.com/util/must" "tailscale.com/util/rands" "tailscale.com/version" ) @@ -312,6 +314,33 @@ func TestOneNodeUpAuth(t *testing.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) { tstest.Shard(t) flakytest.Mark(t, "https://github.com/tailscale/tailscale/issues/3598") @@ -920,6 +949,7 @@ type testNode struct { env *testEnv dir string // temp dir for sock & state + configFile string // or empty for none sockFile string stateFile string 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", ) } + if n.configFile != "" { + cmd.Args = append(cmd.Args, "--config="+n.configFile) + } cmd.Env = append(os.Environ(), "TS_DEBUG_PERMIT_HTTP_C2N=1", "TS_LOG_TARGET="+n.env.LogCatcherServer.URL, diff --git a/tstest/integration/testcontrol/testcontrol.go b/tstest/integration/testcontrol/testcontrol.go index 981c5f8c2..6d55fc6e7 100644 --- a/tstest/integration/testcontrol/testcontrol.go +++ b/tstest/integration/testcontrol/testcontrol.go @@ -33,6 +33,7 @@ import ( "tailscale.com/types/key" "tailscale.com/types/logger" "tailscale.com/types/ptr" + "tailscale.com/util/must" "tailscale.com/util/rands" "tailscale.com/util/set" ) @@ -45,6 +46,7 @@ type Server struct { Logf logger.Logf // nil means to use the log package DERPMap *tailcfg.DERPMap // nil means to use prod DERP map RequireAuth bool + RequireAuthKey string // required authkey for all nodes Verbose bool DNSConfig *tailcfg.DNSConfig // nil means no DNS config MagicDNSDomain string @@ -538,6 +540,14 @@ func (s *Server) serveRegister(w http.ResponseWriter, r *http.Request, mkey key. j, _ := json.MarshalIndent(req, "", "\t") 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 req.Followup != "" { diff --git a/tstest/tstest.go b/tstest/tstest.go index 144ed315b..2d0d1351e 100644 --- a/tstest/tstest.go +++ b/tstest/tstest.go @@ -16,6 +16,7 @@ import ( "tailscale.com/envknob" "tailscale.com/logtail/backoff" "tailscale.com/types/logger" + "tailscale.com/util/cibuild" ) // 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") // Parallel calls t.Parallel, unless TS_SERIAL_TESTS is set true.