diff --git a/client/tailscale/localclient.go b/client/tailscale/localclient.go index e705a9638..3042f7f25 100644 --- a/client/tailscale/localclient.go +++ b/client/tailscale/localclient.go @@ -853,6 +853,21 @@ func (lc *LocalClient) NetworkLockLog(ctx context.Context, maxEntries int) ([]ip return decodeJSON[[]ipnstate.NetworkLockUpdate](body) } +// NetworkLockForceLocalDisable forcibly shuts down network lock on this node. +func (lc *LocalClient) NetworkLockForceLocalDisable(ctx context.Context) error { + // This endpoint expects an empty JSON stanza as the payload. + var b bytes.Buffer + if err := json.NewEncoder(&b).Encode(struct{}{}); err != nil { + return err + } + + if _, err := lc.send(ctx, "POST", "/localapi/v0/tka/force-local-disable", 200, &b); err != nil { + return fmt.Errorf("error: %w", err) + } + return nil +} + + // SetServeConfig sets or replaces the serving settings. // If config is nil, settings are cleared and serving is disabled. func (lc *LocalClient) SetServeConfig(ctx context.Context, config *ipn.ServeConfig) error { diff --git a/cmd/tailscale/cli/network-lock.go b/cmd/tailscale/cli/network-lock.go index 26a5863fe..8cc6944aa 100644 --- a/cmd/tailscale/cli/network-lock.go +++ b/cmd/tailscale/cli/network-lock.go @@ -37,6 +37,7 @@ var netlockCmd = &ffcli.Command{ nlDisableCmd, nlDisablementKDFCmd, nlLogCmd, + nlLocalDisableCmd, }, Exec: runNetworkLockStatus, } @@ -348,6 +349,17 @@ func runNetworkLockDisable(ctx context.Context, args []string) error { return localClient.NetworkLockDisable(ctx, secrets[0]) } +var nlLocalDisableCmd = &ffcli.Command{ + Name: "local-disable", + ShortUsage: "local-disable", + ShortHelp: "Disables the currently-active tailnet lock for this node", + Exec: runNetworkLockLocalDisable, +} + +func runNetworkLockLocalDisable(ctx context.Context, args []string) error { + return localClient.NetworkLockForceLocalDisable(ctx) +} + var nlDisablementKDFCmd = &ffcli.Command{ Name: "disablement-kdf", ShortUsage: "disablement-kdf ", diff --git a/ipn/ipnlocal/network-lock.go b/ipn/ipnlocal/network-lock.go index 10c05b206..e46312a6e 100644 --- a/ipn/ipnlocal/network-lock.go +++ b/ipn/ipnlocal/network-lock.go @@ -25,6 +25,7 @@ import ( "tailscale.com/tka" "tailscale.com/types/key" "tailscale.com/types/netmap" + "tailscale.com/types/persist" "tailscale.com/types/tkatype" "tailscale.com/util/mak" ) @@ -134,7 +135,7 @@ func (b *LocalBackend) tkaSyncIfNeeded(nm *netmap.NetworkMap, prefs ipn.PrefsVie } if wantEnabled && !isEnabled { - if err := b.tkaBootstrapFromGenesisLocked(bs.GenesisAUM); err != nil { + if err := b.tkaBootstrapFromGenesisLocked(bs.GenesisAUM, prefs.Persist()); err != nil { return fmt.Errorf("bootstrap: %w", err) } isEnabled = true @@ -278,7 +279,7 @@ func (b *LocalBackend) chonkPathLocked() string { // tailnet key authority, based on the given genesis AUM. // // b.mu must be held. -func (b *LocalBackend) tkaBootstrapFromGenesisLocked(g tkatype.MarshaledAUM) error { +func (b *LocalBackend) tkaBootstrapFromGenesisLocked(g tkatype.MarshaledAUM, persist persist.PersistView) error { if err := b.CanSupportNetworkLock(); err != nil { return err } @@ -288,6 +289,20 @@ func (b *LocalBackend) tkaBootstrapFromGenesisLocked(g tkatype.MarshaledAUM) err return fmt.Errorf("reading genesis: %v", err) } + if persist.Valid() && persist.DisallowedTKAStateIDs().Len() > 0 { + if genesis.State == nil { + return errors.New("invalid genesis: missing State") + } + bootstrapStateID := fmt.Sprintf("%d:%d", genesis.State.StateID1, genesis.State.StateID2) + + for i := 0; i < persist.DisallowedTKAStateIDs().Len(); i++ { + stateID := persist.DisallowedTKAStateIDs().At(i) + if stateID == bootstrapStateID { + return fmt.Errorf("TKA with stateID of %q is disallowed on this node", stateID) + } + } + } + chonkDir := b.chonkPathLocked() if err := os.Mkdir(filepath.Dir(chonkDir), 0755); err != nil && !os.IsExist(err) { return fmt.Errorf("creating chonk root dir: %v", err) @@ -495,6 +510,31 @@ func (b *LocalBackend) NetworkLockKeyTrustedForTest(keyID tkatype.KeyID) bool { return b.tka.authority.KeyTrusted(keyID) } +// NetworkLockForceLocalDisable shuts down TKA locally, and denylists the current +// TKA from being initialized locally in future. +func (b *LocalBackend) NetworkLockForceLocalDisable() error { + b.mu.Lock() + defer b.mu.Unlock() + if b.tka == nil { + return errNetworkLockNotActive + } + + id1, id2 := b.tka.authority.StateIDs() + stateID := fmt.Sprintf("%d:%d", id1, id2) + + newPrefs := b.pm.CurrentPrefs().AsStruct().Clone() // .Persist should always be initialized here. + newPrefs.Persist.DisallowedTKAStateIDs = append(newPrefs.Persist.DisallowedTKAStateIDs, stateID) + if err := b.pm.SetPrefs(newPrefs.View()); err != nil { + return fmt.Errorf("saving prefs: %w", err) + } + + if err := os.RemoveAll(b.chonkPathLocked()); err != nil { + return fmt.Errorf("deleting TKA state: %w", err) + } + b.tka = nil + return nil +} + // NetworkLockSign signs the given node-key and submits it to the control plane. // rotationPublic, if specified, must be an ed25519 public key. func (b *LocalBackend) NetworkLockSign(nodeKey key.NodePublic, rotationPublic []byte) error { diff --git a/ipn/ipnlocal/network-lock_test.go b/ipn/ipnlocal/network-lock_test.go index 1044e2d6d..d508f8ee7 100644 --- a/ipn/ipnlocal/network-lock_test.go +++ b/ipn/ipnlocal/network-lock_test.go @@ -778,3 +778,103 @@ func TestTKASign(t *testing.T) { t.Errorf("NetworkLockSign() failed: %v", err) } } + +func TestTKAForceDisable(t *testing.T) { + envknob.Setenv("TAILSCALE_USE_WIP_CODE", "1") + defer envknob.Setenv("TAILSCALE_USE_WIP_CODE", "") + nodePriv := key.NewNode() + + // Make a fake TKA authority, to seed local state. + disablementSecret := bytes.Repeat([]byte{0xa5}, 32) + nlPriv := key.NewNLPrivate() + key := tka.Key{Kind: tka.Key25519, Public: nlPriv.Public().Verifier(), Votes: 2} + + pm := must.Get(newProfileManager(new(mem.Store), t.Logf, "")) + must.Do(pm.SetPrefs((&ipn.Prefs{ + Persist: &persist.Persist{ + PrivateNodeKey: nodePriv, + NetworkLockKey: nlPriv, + }, + }).View())) + + temp := t.TempDir() + tkaPath := filepath.Join(temp, "tka-profile", string(pm.CurrentProfile().ID)) + os.Mkdir(tkaPath, 0755) + chonk, err := tka.ChonkDir(tkaPath) + if err != nil { + t.Fatal(err) + } + authority, genesis, err := tka.Create(chonk, tka.State{ + Keys: []tka.Key{key}, + DisablementSecrets: [][]byte{tka.DisablementKDF(disablementSecret)}, + }, nlPriv) + if err != nil { + t.Fatalf("tka.Create() failed: %v", err) + } + + ts, client := fakeNoiseServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + switch r.URL.Path { + case "/machine/tka/bootstrap": + body := new(tailcfg.TKABootstrapRequest) + if err := json.NewDecoder(r.Body).Decode(body); err != nil { + t.Fatal(err) + } + if body.Version != tailcfg.CurrentCapabilityVersion { + t.Errorf("bootstrap CapVer = %v, want %v", body.Version, tailcfg.CurrentCapabilityVersion) + } + if body.NodeKey != nodePriv.Public() { + t.Errorf("nodeKey=%v, want %v", body.NodeKey, nodePriv.Public()) + } + + w.WriteHeader(200) + out := tailcfg.TKABootstrapResponse{ + GenesisAUM: genesis.Serialize(), + } + if err := json.NewEncoder(w).Encode(out); err != nil { + t.Fatal(err) + } + + default: + t.Errorf("unhandled endpoint path: %v", r.URL.Path) + w.WriteHeader(404) + } + })) + defer ts.Close() + + cc := fakeControlClient(t, client) + b := LocalBackend{ + varRoot: temp, + cc: cc, + ccAuto: cc, + logf: t.Logf, + tka: &tkaState{ + authority: authority, + storage: chonk, + }, + pm: pm, + store: pm.Store(), + } + + if err := b.NetworkLockForceLocalDisable(); err != nil { + t.Fatalf("NetworkLockForceLocalDisable() failed: %v", err) + } + if b.tka != nil { + t.Fatal("tka was not shut down") + } + if _, err := os.Stat(b.chonkPathLocked()); err == nil || !os.IsNotExist(err) { + t.Errorf("os.Stat(chonkDir) = %v, want ErrNotExist", err) + } + + err = b.tkaSyncIfNeeded(&netmap.NetworkMap{ + TKAEnabled: true, + TKAHead: authority.Head(), + }, pm.CurrentPrefs()) + if err != nil && err.Error() != "bootstrap: TKA with stateID of \"0:0\" is disallowed on this node" { + t.Errorf("tkaSyncIfNeededLocked() failed: %v", err) + } + + if b.tka != nil { + t.Fatal("tka was re-initalized") + } +} diff --git a/ipn/localapi/localapi.go b/ipn/localapi/localapi.go index ec87b25cc..f11217666 100644 --- a/ipn/localapi/localapi.go +++ b/ipn/localapi/localapi.go @@ -89,6 +89,7 @@ var handler = map[string]localAPIHandler{ "tka/sign": (*Handler).serveTKASign, "tka/status": (*Handler).serveTKAStatus, "tka/disable": (*Handler).serveTKADisable, + "tka/force-local-disable": (*Handler).serveTKALocalDisable, "upload-client-metrics": (*Handler).serveUploadClientMetrics, "watch-ipn-bus": (*Handler).serveWatchIPNBus, "whois": (*Handler).serveWhoIs, @@ -1243,6 +1244,30 @@ func (h *Handler) serveTKADisable(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) } +func (h *Handler) serveTKALocalDisable(w http.ResponseWriter, r *http.Request) { + if !h.PermitWrite { + http.Error(w, "network-lock modify access denied", http.StatusForbidden) + return + } + if r.Method != http.MethodPost { + http.Error(w, "use POST", http.StatusMethodNotAllowed) + return + } + + // Require a JSON stanza for the body as an additional CSRF protection. + var req struct{} + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid JSON body", 400) + return + } + + if err := h.b.NetworkLockForceLocalDisable(); err != nil { + http.Error(w, "network-lock local disable failed: "+err.Error(), http.StatusBadRequest) + return + } + w.WriteHeader(200) +} + func (h *Handler) serveTKALog(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "use GET", http.StatusMethodNotAllowed) diff --git a/tka/tka.go b/tka/tka.go index 338912872..2692930af 100644 --- a/tka/tka.go +++ b/tka/tka.go @@ -714,3 +714,10 @@ func (a *Authority) Keys() []Key { } return out } + +// StateIDs returns the stateIDs for this tailnet key authority. These +// are values that are fixed for the lifetime of the authority: see +// comments on the relevant fields in state.go. +func (a *Authority) StateIDs() (uint64, uint64) { + return a.state.StateID1, a.state.StateID2 +}