diff --git a/client/tailscale/localclient.go b/client/tailscale/localclient.go index 2009eb055..71f97db01 100644 --- a/client/tailscale/localclient.go +++ b/client/tailscale/localclient.go @@ -829,6 +829,17 @@ type signRequest struct { return nil } +// NetworkLockLog returns up to maxEntries number of changes to network-lock state. +func (lc *LocalClient) NetworkLockLog(ctx context.Context, maxEntries int) ([]ipnstate.NetworkLockUpdate, error) { + v := url.Values{} + v.Set("limit", fmt.Sprint(maxEntries)) + body, err := lc.send(ctx, "GET", "/localapi/v0/tka/log?"+v.Encode(), 200, nil) + if err != nil { + return nil, fmt.Errorf("error %w: %s", err, body) + } + return decodeJSON[[]ipnstate.NetworkLockUpdate](body) +} + // 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 8888d86ff..a7c701761 100644 --- a/cmd/tailscale/cli/network-lock.go +++ b/cmd/tailscale/cli/network-lock.go @@ -7,12 +7,18 @@ import ( "context" "encoding/hex" + "encoding/json" "errors" + "flag" "fmt" + "os" "strconv" "strings" + "github.com/mattn/go-colorable" + "github.com/mattn/go-isatty" "github.com/peterbourgon/ff/v3/ffcli" + "tailscale.com/ipn/ipnstate" "tailscale.com/tka" "tailscale.com/types/key" ) @@ -29,6 +35,7 @@ nlSignCmd, nlDisableCmd, nlDisablementKDFCmd, + nlLogCmd, }, Exec: runNetworkLockStatus, } @@ -282,3 +289,100 @@ func runNetworkLockDisablementKDF(ctx context.Context, args []string) error { fmt.Printf("disablement:%x\n", tka.DisablementKDF(secret)) return nil } + +var nlLogArgs struct { + limit int +} + +var nlLogCmd = &ffcli.Command{ + Name: "log", + ShortUsage: "log [--limit N]", + ShortHelp: "List changes applied to network-lock", + Exec: runNetworkLockLog, + FlagSet: (func() *flag.FlagSet { + fs := newFlagSet("lock log") + fs.IntVar(&nlLogArgs.limit, "limit", 50, "max number of updates to list") + return fs + })(), +} + +func nlDescribeUpdate(update ipnstate.NetworkLockUpdate, color bool) (string, error) { + terminalYellow := "" + terminalClear := "" + if color { + terminalYellow = "\x1b[33m" + terminalClear = "\x1b[0m" + } + + var stanza strings.Builder + printKey := func(key *tka.Key, prefix string) { + fmt.Fprintf(&stanza, "%sType: %s\n", prefix, key.Kind.String()) + fmt.Fprintf(&stanza, "%sKeyID: %x\n", prefix, key.ID()) + fmt.Fprintf(&stanza, "%sVotes: %d\n", prefix, key.Votes) + if key.Meta != nil { + fmt.Fprintf(&stanza, "%sMetadata: %+v\n", prefix, key.Meta) + } + } + + var aum tka.AUM + if err := aum.Unserialize(update.Raw); err != nil { + return "", fmt.Errorf("decoding: %w", err) + } + + fmt.Fprintf(&stanza, "%supdate %x (%s)%s\n", terminalYellow, update.Hash, update.Change, terminalClear) + + switch update.Change { + case tka.AUMAddKey.String(): + printKey(aum.Key, "") + case tka.AUMRemoveKey.String(): + fmt.Fprintf(&stanza, "KeyID: %x\n", aum.KeyID) + + case tka.AUMUpdateKey.String(): + fmt.Fprintf(&stanza, "KeyID: %x\n", aum.KeyID) + if aum.Votes != nil { + fmt.Fprintf(&stanza, "Votes: %d\n", aum.Votes) + } + if aum.Meta != nil { + fmt.Fprintf(&stanza, "Metadata: %+v\n", aum.Meta) + } + + case tka.AUMCheckpoint.String(): + fmt.Fprintln(&stanza, "Disablement values:") + for _, v := range aum.State.DisablementSecrets { + fmt.Fprintf(&stanza, " - %x\n", v) + } + fmt.Fprintln(&stanza, "Keys:") + for _, k := range aum.State.Keys { + printKey(&k, " ") + } + + default: + // Print a JSON encoding of the AUM as a fallback. + e := json.NewEncoder(&stanza) + e.SetIndent("", "\t") + if err := e.Encode(aum); err != nil { + return "", err + } + stanza.WriteRune('\n') + } + + return stanza.String(), nil +} + +func runNetworkLockLog(ctx context.Context, args []string) error { + updates, err := localClient.NetworkLockLog(ctx, nlLogArgs.limit) + if err != nil { + return fixTailscaledConnectError(err) + } + useColor := isatty.IsTerminal(os.Stdout.Fd()) + + stdOut := colorable.NewColorableStdout() + for _, update := range updates { + stanza, err := nlDescribeUpdate(update, useColor) + if err != nil { + return err + } + fmt.Fprintln(stdOut, stanza) + } + return nil +} diff --git a/cmd/tailscale/depaware.txt b/cmd/tailscale/depaware.txt index 006d5ddd2..d2ec5da56 100644 --- a/cmd/tailscale/depaware.txt +++ b/cmd/tailscale/depaware.txt @@ -14,6 +14,8 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep L github.com/jsimonetti/rtnetlink/internal/unix from github.com/jsimonetti/rtnetlink github.com/kballard/go-shellquote from tailscale.com/cmd/tailscale/cli github.com/klauspost/compress/flate from nhooyr.io/websocket + 💣 github.com/mattn/go-colorable from tailscale.com/cmd/tailscale/cli + 💣 github.com/mattn/go-isatty from github.com/mattn/go-colorable+ L 💣 github.com/mdlayher/netlink from github.com/jsimonetti/rtnetlink+ L 💣 github.com/mdlayher/netlink/nlenc from github.com/jsimonetti/rtnetlink+ L 💣 github.com/mdlayher/socket from github.com/mdlayher/netlink diff --git a/go.mod b/go.mod index db9791393..ae0517619 100644 --- a/go.mod +++ b/go.mod @@ -36,6 +36,8 @@ require ( github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 github.com/klauspost/compress v1.15.4 github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a + github.com/mattn/go-colorable v0.1.12 + github.com/mattn/go-isatty v0.0.14 github.com/mdlayher/genetlink v1.2.0 github.com/mdlayher/netlink v1.6.0 github.com/mdlayher/sdnotify v1.0.0 @@ -199,8 +201,6 @@ require ( github.com/magiconair/properties v1.8.5 // indirect github.com/maratori/testpackage v1.0.1 // indirect github.com/matoous/godox v0.0.0-20210227103229-6504466cf951 // indirect - github.com/mattn/go-colorable v0.1.12 // indirect - github.com/mattn/go-isatty v0.0.14 // indirect github.com/mattn/go-runewidth v0.0.13 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect github.com/mbilski/exhaustivestruct v1.2.0 // indirect diff --git a/ipn/ipnlocal/network-lock.go b/ipn/ipnlocal/network-lock.go index afd328a98..e83656f43 100644 --- a/ipn/ipnlocal/network-lock.go +++ b/ipn/ipnlocal/network-lock.go @@ -647,6 +647,43 @@ func (b *LocalBackend) NetworkLockDisable(secret []byte) error { return err } +// NetworkLockLog returns the changelog of TKA state up to maxEntries in size. +func (b *LocalBackend) NetworkLockLog(maxEntries int) ([]ipnstate.NetworkLockUpdate, error) { + b.mu.Lock() + defer b.mu.Unlock() + + if b.tka == nil { + return nil, errNetworkLockNotActive + } + + var out []ipnstate.NetworkLockUpdate + cursor := b.tka.authority.Head() + for i := 0; i < maxEntries; i++ { + aum, err := b.tka.storage.AUM(cursor) + if err != nil { + if err == os.ErrNotExist { + break + } + return out, fmt.Errorf("reading AUM: %w", err) + } + + update := ipnstate.NetworkLockUpdate{ + Hash: cursor, + Change: aum.MessageKind.String(), + Raw: aum.Serialize(), + } + out = append(out, update) + + parent, hasParent := aum.Parent() + if !hasParent { + break + } + cursor = parent + } + + return out, nil +} + func signNodeKey(nodeInfo tailcfg.TKASignInfo, signer key.NLPrivate) (*tka.NodeKeySignature, error) { p, err := nodeInfo.NodePublic.MarshalBinary() if err != nil { diff --git a/ipn/ipnstate/ipnstate.go b/ipn/ipnstate/ipnstate.go index 6ba0e649b..7b691628b 100644 --- a/ipn/ipnstate/ipnstate.go +++ b/ipn/ipnstate/ipnstate.go @@ -101,6 +101,16 @@ type NetworkLockStatus struct { TrustedKeys []TKAKey } +// NetworkLockUpdate describes a change to network-lock state. +type NetworkLockUpdate struct { + Hash [32]byte + Change string // values of tka.AUMKind.String() + + // Raw contains the serialized AUM. The AUM is sent in serialized + // form to avoid transitive dependences bloating this package. + Raw []byte +} + // TailnetStatus is information about a Tailscale network ("tailnet"). type TailnetStatus struct { // Name is the name of the network that's currently in use. diff --git a/ipn/localapi/localapi.go b/ipn/localapi/localapi.go index f33f20719..3ef8b0e2d 100644 --- a/ipn/localapi/localapi.go +++ b/ipn/localapi/localapi.go @@ -82,6 +82,7 @@ "set-expiry-sooner": (*Handler).serveSetExpirySooner, "status": (*Handler).serveStatus, "tka/init": (*Handler).serveTKAInit, + "tka/log": (*Handler).serveTKALog, "tka/modify": (*Handler).serveTKAModify, "tka/sign": (*Handler).serveTKASign, "tka/status": (*Handler).serveTKAStatus, @@ -1164,6 +1165,37 @@ func (h *Handler) serveTKADisable(w http.ResponseWriter, r *http.Request) { 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) + return + } + + limit := 50 + if limitStr := r.FormValue("limit"); limitStr != "" { + l, err := strconv.Atoi(limitStr) + if err != nil { + http.Error(w, "parsing 'limit' parameter: "+err.Error(), http.StatusBadRequest) + return + } + limit = int(l) + } + + updates, err := h.b.NetworkLockLog(limit) + if err != nil { + http.Error(w, "reading log failed: "+err.Error(), http.StatusInternalServerError) + return + } + + j, err := json.MarshalIndent(updates, "", "\t") + if err != nil { + http.Error(w, "JSON encoding error", 500) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write(j) +} + // serveProfiles serves profile switching-related endpoints. Supported methods // and paths are: // - GET /profiles/: list all profiles (JSON-encoded array of ipn.LoginProfiles)