Files
tailscale-custom/cmd/tailscale/cli/jsonoutput/network-lock-log.go
T
Alex Chan 378ee20b9a cmd/tailscale/cli: stabilise the output of tailscale lock status --json
This patch stabilises the JSON output, and improves it in the following
ways:

* The AUM hash in Head uses the base32-encoded form of an AUM hash,
  consistent with how it's presented elsewhere
* TrustedKeys are the same format as the keys as `tailnet lock log --json`
* SigKind, Pubkey and KeyID are all presented consistently with other
  JSON output in NodeKeySignature
* FilteredPeers don't have a NodeKeySignature, because it will always
  be empty

For reference, here's the JSON output from the CLI prior to this change:

```json
{
  "Enabled": true,
  "Head": [
    196,
    69,
    63,
    243,
    213,
    133,
    123,
    46,
    183,
    203,
    143,
    34,
    184,
    85,
    80,
    1,
    221,
    92,
    49,
    213,
    93,
    106,
    5,
    206,
    176,
    250,
    58,
    165,
    155,
    136,
    11,
    13
  ],
  "PublicKey": "nlpub:0f99af5c02216193963ce9304bb4ca418846eddebe237f37a6de1c59097ed0b8",
  "NodeKey": "nodekey:8abfe98b38151748919f6e346ad16436201c3ecd453b01e9d6d3a38e1826000d",
  "NodeKeySigned": true,
  "NodeKeySignature": {
    "SigKind": 1,
    "Pubkey": "bnCKv+mLOBUXSJGfbjRq0WQ2IBw+zUU7AenW06OOGCYADQ==",
    "KeyID": "D5mvXAIhYZOWPOkwS7TKQYhG7d6+I383pt4cWQl+0Lg=",
    "Signature": "4DPW4v6MyLLwQ8AMDm27BVDGABjeC9gg1EfqRdKgzVXi/mJDwY9PTAoX0+0WTRs5SUksWjY0u1CLxq5xgjFGBA==",
    "Nested": null,
    "WrappingPubkey": "D5mvXAIhYZOWPOkwS7TKQYhG7d6+I383pt4cWQl+0Lg="
  },
  "TrustedKeys": [
    {
      "Key": "nlpub:0f99af5c02216193963ce9304bb4ca418846eddebe237f37a6de1c59097ed0b8",
      "Metadata": null,
      "Votes": 1
    },
    {
      "Key": "nlpub:de2254c040e728140d92bc967d51284e9daea103a28a97a215694c5bda2128b8",
      "Metadata": null,
      "Votes": 1
    }
  ],
  "VisiblePeers": [
    {
      "Name": "signing2.taila62b.unknown.c.ts.net.",
      "ID": 7525920332164264,
      "StableID": "nRX6TbAWm121DEVEL",
      "TailscaleIPs": [
        "100.110.67.20",
        "fd7a:115c:a1e0::9c01:4314"
      ],
      "NodeKey": "nodekey:10bf4a5c168051d700a29123cd81568377849da458abef4b328794ca9cae4313",
      "NodeKeySignature": {
        "SigKind": 1,
        "Pubkey": "bnAQv0pcFoBR1wCikSPNgVaDd4SdpFir70syh5TKnK5DEw==",
        "KeyID": "D5mvXAIhYZOWPOkwS7TKQYhG7d6+I383pt4cWQl+0Lg=",
        "Signature": "h9fhwHiNdkTqOGVQNdW6AVFoio6MFaFobPiK9ydywgmtYxcExJ38b76Tabdc56aNLxf8IfCaRw2VYPcQG2J/AA==",
        "Nested": null,
        "WrappingPubkey": "3iJUwEDnKBQNkryWfVEoTp2uoQOiipeiFWlMW9ohKLg="
      }
    }
  ],
  "FilteredPeers": [
    {
      "Name": "node3.taila62b.unknown.c.ts.net.",
      "ID": 5200614049042386,
      "StableID": "n3jAr7KNch11DEVEL",
      "TailscaleIPs": [
        "100.95.29.124",
        "fd7a:115c:a1e0::f901:1d7c"
      ],
      "NodeKey": "nodekey:454d2c8602c10574c5ec3a6790f159714802012b7b8bb8d2ab47d637f9df1d7b",
      "NodeKeySignature": {
        "SigKind": 0,
        "Pubkey": null,
        "KeyID": null,
        "Signature": null,
        "Nested": null,
        "WrappingPubkey": null
      }
    }
  ],
  "StateID": 16885615198276932820
}
```

Updates https://github.com/tailscale/corp/issues/22355
Updates https://github.com/tailscale/tailscale/issues/17619

Signed-off-by: Alex Chan <alexc@tailscale.com>

Change-Id: I65b58ff4520033e6b70fc3b1ba7fc91c1f70a960
2025-12-09 09:40:06 +00:00

206 lines
6.1 KiB
Go

// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_tailnetlock
package jsonoutput
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tka"
)
// PrintNetworkLockLogJSONV1 prints the stored TKA state as a JSON object to the CLI,
// in a stable "v1" format.
//
// This format includes:
//
// - the AUM hash as a base32-encoded string
// - the raw AUM as base64-encoded bytes
// - the expanded AUM, which prints named fields for consumption by other tools
func PrintNetworkLockLogJSONV1(out io.Writer, updates []ipnstate.NetworkLockUpdate) error {
messages := make([]logMessageV1, len(updates))
for i, update := range updates {
var aum tka.AUM
if err := aum.Unserialize(update.Raw); err != nil {
return fmt.Errorf("decoding: %w", err)
}
h := aum.Hash()
if !bytes.Equal(h[:], update.Hash[:]) {
return fmt.Errorf("incorrect AUM hash: got %v, want %v", h, update)
}
messages[i] = toLogMessageV1(aum, update)
}
result := struct {
ResponseEnvelope
Messages []logMessageV1
}{
ResponseEnvelope: ResponseEnvelope{
SchemaVersion: "1",
},
Messages: messages,
}
enc := json.NewEncoder(out)
enc.SetIndent("", " ")
return enc.Encode(result)
}
// toLogMessageV1 converts a [tka.AUM] and [ipnstate.NetworkLockUpdate] to the
// JSON output returned by the CLI.
func toLogMessageV1(aum tka.AUM, update ipnstate.NetworkLockUpdate) logMessageV1 {
expandedAUM := expandedAUMV1{}
expandedAUM.MessageKind = aum.MessageKind.String()
if len(aum.PrevAUMHash) > 0 {
expandedAUM.PrevAUMHash = aum.PrevAUMHash.String()
}
if key := aum.Key; key != nil {
expandedAUM.Key = toTKAKeyV1(key)
}
if keyID := aum.KeyID; keyID != nil {
expandedAUM.KeyID = fmt.Sprintf("tlpub:%x", keyID)
}
if state := aum.State; state != nil {
expandedState := expandedStateV1{}
if h := state.LastAUMHash; h != nil {
expandedState.LastAUMHash = h.String()
}
for _, secret := range state.DisablementSecrets {
expandedState.DisablementSecrets = append(expandedState.DisablementSecrets, fmt.Sprintf("%x", secret))
}
for _, key := range state.Keys {
expandedState.Keys = append(expandedState.Keys, toTKAKeyV1(&key))
}
expandedState.StateID1 = state.StateID1
expandedState.StateID2 = state.StateID2
expandedAUM.State = expandedState
}
if votes := aum.Votes; votes != nil {
expandedAUM.Votes = *votes
}
expandedAUM.Meta = aum.Meta
for _, signature := range aum.Signatures {
expandedAUM.Signatures = append(expandedAUM.Signatures, expandedSignatureV1{
KeyID: fmt.Sprintf("tlpub:%x", signature.KeyID),
Signature: base64.URLEncoding.EncodeToString(signature.Signature),
})
}
return logMessageV1{
Hash: aum.Hash().String(),
AUM: expandedAUM,
Raw: base64.URLEncoding.EncodeToString(update.Raw),
}
}
// toTKAKeyV1 converts a [tka.Key] to the JSON output returned
// by the CLI.
func toTKAKeyV1(key *tka.Key) tkaKeyV1 {
return tkaKeyV1{
Kind: key.Kind.String(),
Votes: key.Votes,
Public: fmt.Sprintf("tlpub:%x", key.Public),
Meta: key.Meta,
}
}
// logMessageV1 is the JSON representation of an AUM as both raw bytes and
// in its expanded form, and the CLI output is a list of these entries.
type logMessageV1 struct {
// The BLAKE2s digest of the CBOR-encoded AUM. This is printed as a
// base32-encoded string, e.g. KCE…XZQ
Hash string
// The expanded form of the AUM, which presents the fields in a more
// accessible format than doing a CBOR decoding.
AUM expandedAUMV1
// The raw bytes of the CBOR-encoded AUM, encoded as base64.
// This is useful for verifying the AUM hash.
Raw string
}
// expandedAUMV1 is the expanded version of a [tka.AUM], designed so external tools
// can read the AUM without knowing our CBOR definitions.
type expandedAUMV1 struct {
MessageKind string
PrevAUMHash string `json:"PrevAUMHash,omitzero"`
// Key encodes a public key to be added to the key authority.
// This field is used for AddKey AUMs.
Key tkaKeyV1 `json:"Key,omitzero"`
// KeyID references a public key which is part of the key authority.
// This field is used for RemoveKey and UpdateKey AUMs.
KeyID string `json:"KeyID,omitzero"`
// State describes the full state of the key authority.
// This field is used for Checkpoint AUMs.
State expandedStateV1 `json:"State,omitzero"`
// Votes and Meta describe properties of a key in the key authority.
// These fields are used for UpdateKey AUMs.
Votes uint `json:"Votes,omitzero"`
Meta map[string]string `json:"Meta,omitzero"`
// Signatures lists the signatures over this AUM.
Signatures []expandedSignatureV1 `json:"Signatures,omitzero"`
}
// tkaKeyV1 is the expanded version of a [tka.Key], which describes
// the public components of a key known to network-lock.
type tkaKeyV1 struct {
Kind string `json:"Kind,omitzero"`
// Votes describes the weight applied to signatures using this key.
Votes uint
// Public encodes the public key of the key as a hex string.
Public string
// Meta describes arbitrary metadata about the key. This could be
// used to store the name of the key, for instance.
Meta map[string]string `json:"Meta,omitzero"`
}
// expandedStateV1 is the expanded version of a [tka.State], which describes
// Tailnet Key Authority state at an instant in time.
type expandedStateV1 struct {
// LastAUMHash is the blake2s digest of the last-applied AUM.
LastAUMHash string `json:"LastAUMHash,omitzero"`
// DisablementSecrets are KDF-derived values which can be used
// to turn off the TKA in the event of a consensus-breaking bug.
DisablementSecrets []string
// Keys are the public keys of either:
//
// 1. The signing nodes currently trusted by the TKA.
// 2. Ephemeral keys that were used to generate pre-signed auth keys.
Keys []tkaKeyV1
// StateID's are nonce's, generated on enablement and fixed for
// the lifetime of the Tailnet Key Authority.
StateID1 uint64
StateID2 uint64
}
// expandedSignatureV1 is the expanded form of a [tka.Signature], which
// describes a signature over an AUM. This signature can be verified
// using the key referenced by KeyID.
type expandedSignatureV1 struct {
KeyID string
Signature string
}