cmd/tailscale,ipn: implement lock log command

This commit implements `tailscale lock log [--limit N]`, which displays an ordered list
of changes to network-lock state in a manner familiar to `git log`.

Signed-off-by: Tom DNetto <tom@tailscale.com>
This commit is contained in:
Tom DNetto 2022-11-14 15:04:10 -08:00 committed by Tom
parent ed1fae6c73
commit 6708f9a93f
7 changed files with 198 additions and 2 deletions

View File

@ -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 {

View File

@ -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
}

View File

@ -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

4
go.mod
View File

@ -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

View File

@ -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 {

View File

@ -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.

View File

@ -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)