pull/18009/merge
Alex Chan 1 day ago committed by GitHub
commit 2303961608
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -1,6 +1,8 @@
// Copyright (c) Tailscale Inc & AUTHORS // Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause // SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_tailnetlock
package jsonoutput package jsonoutput
import ( import (
@ -14,7 +16,7 @@ import (
"tailscale.com/tka" "tailscale.com/tka"
) )
// PrintNetworkLockJSONV1 prints the stored TKA state as a JSON object to the CLI, // PrintNetworkLockLogJSONV1 prints the stored TKA state as a JSON object to the CLI,
// in a stable "v1" format. // in a stable "v1" format.
// //
// This format includes: // This format includes:
@ -22,7 +24,7 @@ import (
// - the AUM hash as a base32-encoded string // - the AUM hash as a base32-encoded string
// - the raw AUM as base64-encoded bytes // - the raw AUM as base64-encoded bytes
// - the expanded AUM, which prints named fields for consumption by other tools // - the expanded AUM, which prints named fields for consumption by other tools
func PrintNetworkLockJSONV1(out io.Writer, updates []ipnstate.NetworkLockUpdate) error { func PrintNetworkLockLogJSONV1(out io.Writer, updates []ipnstate.NetworkLockUpdate) error {
messages := make([]logMessageV1, len(updates)) messages := make([]logMessageV1, len(updates))
for i, update := range updates { for i, update := range updates {
@ -64,7 +66,7 @@ func toLogMessageV1(aum tka.AUM, update ipnstate.NetworkLockUpdate) logMessageV1
expandedAUM.PrevAUMHash = aum.PrevAUMHash.String() expandedAUM.PrevAUMHash = aum.PrevAUMHash.String()
} }
if key := aum.Key; key != nil { if key := aum.Key; key != nil {
expandedAUM.Key = toExpandedKeyV1(key) expandedAUM.Key = toTKAKeyV1(key)
} }
if keyID := aum.KeyID; keyID != nil { if keyID := aum.KeyID; keyID != nil {
expandedAUM.KeyID = fmt.Sprintf("tlpub:%x", keyID) expandedAUM.KeyID = fmt.Sprintf("tlpub:%x", keyID)
@ -78,7 +80,7 @@ func toLogMessageV1(aum tka.AUM, update ipnstate.NetworkLockUpdate) logMessageV1
expandedState.DisablementSecrets = append(expandedState.DisablementSecrets, fmt.Sprintf("%x", secret)) expandedState.DisablementSecrets = append(expandedState.DisablementSecrets, fmt.Sprintf("%x", secret))
} }
for _, key := range state.Keys { for _, key := range state.Keys {
expandedState.Keys = append(expandedState.Keys, toExpandedKeyV1(&key)) expandedState.Keys = append(expandedState.Keys, toTKAKeyV1(&key))
} }
expandedState.StateID1 = state.StateID1 expandedState.StateID1 = state.StateID1
expandedState.StateID2 = state.StateID2 expandedState.StateID2 = state.StateID2
@ -102,10 +104,10 @@ func toLogMessageV1(aum tka.AUM, update ipnstate.NetworkLockUpdate) logMessageV1
} }
} }
// toExpandedKeyV1 converts a [tka.Key] to the JSON output returned // toTKAKeyV1 converts a [tka.Key] to the JSON output returned
// by the CLI. // by the CLI.
func toExpandedKeyV1(key *tka.Key) expandedKeyV1 { func toTKAKeyV1(key *tka.Key) tkaKeyV1 {
return expandedKeyV1{ return tkaKeyV1{
Kind: key.Kind.String(), Kind: key.Kind.String(),
Votes: key.Votes, Votes: key.Votes,
Public: fmt.Sprintf("tlpub:%x", key.Public), Public: fmt.Sprintf("tlpub:%x", key.Public),
@ -137,7 +139,7 @@ type expandedAUMV1 struct {
// Key encodes a public key to be added to the key authority. // Key encodes a public key to be added to the key authority.
// This field is used for AddKey AUMs. // This field is used for AddKey AUMs.
Key expandedKeyV1 `json:"Key,omitzero"` Key tkaKeyV1 `json:"Key,omitzero"`
// KeyID references a public key which is part of the key authority. // KeyID references a public key which is part of the key authority.
// This field is used for RemoveKey and UpdateKey AUMs. // This field is used for RemoveKey and UpdateKey AUMs.
@ -156,10 +158,10 @@ type expandedAUMV1 struct {
Signatures []expandedSignatureV1 `json:"Signatures,omitzero"` Signatures []expandedSignatureV1 `json:"Signatures,omitzero"`
} }
// expandedAUMV1 is the expanded version of a [tka.Key], which describes // tkaKeyV1 is the expanded version of a [tka.Key], which describes
// the public components of a key known to network-lock. // the public components of a key known to network-lock.
type expandedKeyV1 struct { type tkaKeyV1 struct {
Kind string Kind string `json:"Kind,omitzero"`
// Votes describes the weight applied to signatures using this key. // Votes describes the weight applied to signatures using this key.
Votes uint Votes uint
@ -186,7 +188,7 @@ type expandedStateV1 struct {
// //
// 1. The signing nodes currently trusted by the TKA. // 1. The signing nodes currently trusted by the TKA.
// 2. Ephemeral keys that were used to generate pre-signed auth keys. // 2. Ephemeral keys that were used to generate pre-signed auth keys.
Keys []expandedKeyV1 Keys []tkaKeyV1
// StateID's are nonce's, generated on enablement and fixed for // StateID's are nonce's, generated on enablement and fixed for
// the lifetime of the Tailnet Key Authority. // the lifetime of the Tailnet Key Authority.

@ -0,0 +1,249 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_tailnetlock
package jsonoutput
import (
"encoding/base64"
jsonv1 "encoding/json"
"fmt"
"io"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tka"
)
// PrintNetworkLockStatusJSONV1 prints the current Tailnet Lock status
// as a JSON object to the CLI, in a stable "v1" format.
func PrintNetworkLockStatusJSONV1(out io.Writer, status *ipnstate.NetworkLockStatus) error {
responseEnvelope := ResponseEnvelope{
SchemaVersion: "1",
}
var result any
if status.Enabled {
result = struct {
ResponseEnvelope
tailnetLockEnabledStatusV1
}{
ResponseEnvelope: responseEnvelope,
tailnetLockEnabledStatusV1: toTailnetLockEnabledStatusV1(status),
}
} else {
result = struct {
ResponseEnvelope
tailnetLockDisabledStatusV1
}{
ResponseEnvelope: responseEnvelope,
tailnetLockDisabledStatusV1: toTailnetLockDisabledStatusV1(status),
}
}
enc := jsonv1.NewEncoder(out)
enc.SetIndent("", " ")
return enc.Encode(result)
}
func toTailnetLockDisabledStatusV1(status *ipnstate.NetworkLockStatus) tailnetLockDisabledStatusV1 {
out := tailnetLockDisabledStatusV1{
Enabled: status.Enabled,
}
if !status.PublicKey.IsZero() {
out.PublicKey = status.PublicKey.CLIString()
}
if nk := status.NodeKey; nk != nil {
out.NodeKey = nk.String()
}
return out
}
func toTailnetLockEnabledStatusV1(status *ipnstate.NetworkLockStatus) tailnetLockEnabledStatusV1 {
out := tailnetLockEnabledStatusV1{
Enabled: status.Enabled,
}
if status.Head != nil {
var head tka.AUMHash
h := status.Head
copy(head[:], h[:])
out.Head = head.String()
}
if !status.PublicKey.IsZero() {
out.PublicKey = status.PublicKey.CLIString()
}
if nk := status.NodeKey; nk != nil {
out.NodeKey = nk.String()
}
out.NodeKeySigned = status.NodeKeySigned
if sig := status.NodeKeySignature; sig != nil {
out.NodeKeySignature = toTKANodeKeySignatureV1(sig)
}
for _, key := range status.TrustedKeys {
out.TrustedKeys = append(out.TrustedKeys, ipnTKAKeytoTKAKeyV1(&key))
}
for _, vp := range status.VisiblePeers {
out.VisiblePeers = append(out.VisiblePeers, tkaTrustedPeerV1{
tkaPeerV1: toTKAPeerV1(vp),
NodeKeySignature: toTKANodeKeySignatureV1(&vp.NodeKeySignature),
})
}
for _, fp := range status.FilteredPeers {
out.FilteredPeers = append(out.FilteredPeers, toTKAPeerV1(fp))
}
out.StateID = status.StateID
return out
}
// toTKAKeyV1 converts an [ipnstate.TKAKey] to the JSON output returned
// by the CLI.
func ipnTKAKeytoTKAKeyV1(key *ipnstate.TKAKey) tkaKeyV1 {
return tkaKeyV1{
Kind: key.Kind,
Votes: key.Votes,
Public: key.Key.CLIString(),
Meta: key.Metadata,
}
}
// tailnetLockDisabledStatusV1 is the JSON representation of the Tailnet Lock status
// when Tailnet Lock is disabled.
type tailnetLockDisabledStatusV1 struct {
// Enabled is true if Tailnet Lock is enabled.
Enabled bool
// PublicKey describes the node's network-lock public key.
PublicKey string `json:"PublicKey,omitzero"`
// NodeKey describes the node's current node-key. This field is not
// populated if the node is not operating (i.e. waiting for a login).
NodeKey string `json:"NodeKey,omitzero"`
}
// tailnetLockEnabledStatusV1 is the JSON representation of the Tailnet Lock status.
type tailnetLockEnabledStatusV1 struct {
// Enabled is true if Tailnet Lock is enabled.
Enabled bool
// Head describes the AUM hash of the leaf AUM.
Head string `json:"Head,omitzero"`
// PublicKey describes the node's network-lock public key.
PublicKey string `json:"PublicKey,omitzero"`
// NodeKey describes the node's current node-key. This field is not
// populated if the node is not operating (i.e. waiting for a login).
NodeKey string `json:"NodeKey,omitzero"`
// NodeKeySigned is true if our node is authorized by Tailnet Lock.
NodeKeySigned bool
// NodeKeySignature is the current signature of this node's key.
NodeKeySignature *tkaNodeKeySignatureV1
// TrustedKeys describes the keys currently trusted to make changes
// to network-lock.
TrustedKeys []tkaKeyV1
// VisiblePeers describes peers which are visible in the netmap that
// have valid Tailnet Lock signatures signatures.
VisiblePeers []tkaTrustedPeerV1
// FilteredPeers describes peers which were removed from the netmap
// (i.e. no connectivity) because they failed Tailnet Lock
// checks.
FilteredPeers []tkaPeerV1
// StateID is a nonce associated with the Tailnet Lock authority,
// generated upon enablement. This field is empty if Tailnet Lock
// is disabled.
StateID uint64 `json:"State,omitzero"`
}
// tkaPeerV1 is the JSON representation of an [ipnstate.TKAPeer], which describes
// a peer and its Tailnet Lock details.
type tkaPeerV1 struct {
// Stable ID, i.e. [tailcfg.StableNodeID]
ID string
// DNS name
DNSName string
// Tailscale IP(s) assigned to this node
TailscaleIPs []string
// The node's public key
NodeKey string
}
// tkaPeerV1 is the JSON representation of a trusted [ipnstate.TKAPeer], which
// has a node key signature.
type tkaTrustedPeerV1 struct {
tkaPeerV1
// The node's key signature
NodeKeySignature *tkaNodeKeySignatureV1 `json:"NodeKeySignature,omitzero"`
}
func toTKAPeerV1(peer *ipnstate.TKAPeer) tkaPeerV1 {
out := tkaPeerV1{
DNSName: peer.Name,
ID: string(peer.StableID),
}
for _, ip := range peer.TailscaleIPs {
out.TailscaleIPs = append(out.TailscaleIPs, ip.String())
}
out.NodeKey = peer.NodeKey.String()
return out
}
// tkaNodeKeySignatureV1 is the JSON representation of a [tka.NodeKeySignature],
// which describes a signature that authorizes a specific node key.
type tkaNodeKeySignatureV1 struct {
// SigKind identifies the variety of signature.
SigKind string
// PublicKey identifies the key.NodePublic which is being authorized.
// SigCredential signatures do not use this field.
PublicKey string `json:"PublicKey,omitzero"`
// KeyID identifies which key in the tailnet key authority should
// be used to verify this signature. Only set for SigDirect and
// SigCredential signature kinds.
KeyID string `json:"KeyID,omitzero"`
// Signature is the packed (R, S) ed25519 signature over all other
// fields of the structure.
Signature string
// Nested describes a NodeKeySignature which authorizes the node-key
// used as Pubkey. Only used for SigRotation signatures.
Nested *tkaNodeKeySignatureV1 `json:"Nested,omitzero"`
// WrappingPubkey specifies the ed25519 public key which must be used
// to sign a Signature which embeds this one.
WrappingPublicKey string `json:"WrappingPublicKey,omitzero"`
}
func toTKANodeKeySignatureV1(sig *tka.NodeKeySignature) *tkaNodeKeySignatureV1 {
out := tkaNodeKeySignatureV1{
SigKind: sig.SigKind.String(),
}
if len(sig.Pubkey) > 0 {
out.PublicKey = fmt.Sprintf("tlpub:%x", sig.Pubkey)
}
if len(sig.KeyID) > 0 {
out.KeyID = fmt.Sprintf("tlpub:%x", sig.KeyID)
}
out.Signature = base64.URLEncoding.EncodeToString(sig.Signature)
if sig.Nested != nil {
out.Nested = toTKANodeKeySignatureV1(sig.Nested)
}
if len(sig.WrappingPubkey) > 0 {
out.WrappingPublicKey = fmt.Sprintf("tlpub:%x", out.WrappingPublicKey)
}
return &out
}

@ -195,7 +195,7 @@ func runNetworkLockInit(ctx context.Context, args []string) error {
} }
var nlStatusArgs struct { var nlStatusArgs struct {
json bool json jsonoutput.JSONSchemaVersion
} }
var nlStatusCmd = &ffcli.Command{ var nlStatusCmd = &ffcli.Command{
@ -205,7 +205,7 @@ var nlStatusCmd = &ffcli.Command{
Exec: runNetworkLockStatus, Exec: runNetworkLockStatus,
FlagSet: (func() *flag.FlagSet { FlagSet: (func() *flag.FlagSet {
fs := newFlagSet("lock status") fs := newFlagSet("lock status")
fs.BoolVar(&nlStatusArgs.json, "json", false, "output in JSON format (WARNING: format subject to change)") fs.Var(&nlStatusArgs.json, "json", "output in JSON format")
return fs return fs
})(), })(),
} }
@ -220,10 +220,12 @@ func runNetworkLockStatus(ctx context.Context, args []string) error {
return fixTailscaledConnectError(err) return fixTailscaledConnectError(err)
} }
if nlStatusArgs.json { if nlStatusArgs.json.IsSet {
enc := jsonv1.NewEncoder(os.Stdout) if nlStatusArgs.json.Value == 1 {
enc.SetIndent("", " ") return jsonoutput.PrintNetworkLockStatusJSONV1(os.Stdout, st)
return enc.Encode(st) } else {
return fmt.Errorf("unrecognised version: %q", nlStatusArgs.json.Value)
}
} }
if st.Enabled { if st.Enabled {
@ -713,7 +715,7 @@ func runNetworkLockLog(ctx context.Context, args []string) error {
func printNetworkLockLog(updates []ipnstate.NetworkLockUpdate, out io.Writer, jsonSchema jsonoutput.JSONSchemaVersion, useColor bool) error { func printNetworkLockLog(updates []ipnstate.NetworkLockUpdate, out io.Writer, jsonSchema jsonoutput.JSONSchemaVersion, useColor bool) error {
if jsonSchema.IsSet { if jsonSchema.IsSet {
if jsonSchema.Value == 1 { if jsonSchema.Value == 1 {
return jsonoutput.PrintNetworkLockJSONV1(out, updates) return jsonoutput.PrintNetworkLockLogJSONV1(out, updates)
} else { } else {
return fmt.Errorf("unrecognised version: %q", jsonSchema.Value) return fmt.Errorf("unrecognised version: %q", jsonSchema.Value)
} }

@ -5,12 +5,16 @@ package cli
import ( import (
"bytes" "bytes"
"net/netip"
"testing" "testing"
"github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp"
"go4.org/mem"
"tailscale.com/cmd/tailscale/cli/jsonoutput" "tailscale.com/cmd/tailscale/cli/jsonoutput"
"tailscale.com/ipn/ipnstate" "tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
"tailscale.com/tka" "tailscale.com/tka"
"tailscale.com/types/key"
"tailscale.com/types/tkatype" "tailscale.com/types/tkatype"
) )
@ -183,7 +187,6 @@ KeyID: tlpub:0202
t.Run("json-1", func(t *testing.T) { t.Run("json-1", func(t *testing.T) {
t.Parallel() t.Parallel()
t.Logf("BOOM")
var outBuf bytes.Buffer var outBuf bytes.Buffer
json := jsonoutput.JSONSchemaVersion{ json := jsonoutput.JSONSchemaVersion{
@ -195,10 +198,172 @@ KeyID: tlpub:0202
printNetworkLockLog(updates, &outBuf, json, useColor) printNetworkLockLog(updates, &outBuf, json, useColor)
want := jsonV1 want := jsonV1
t.Logf("%s", outBuf.String())
if diff := cmp.Diff(outBuf.String(), want); diff != "" { if diff := cmp.Diff(outBuf.String(), want); diff != "" {
t.Fatalf("wrong output (-got, +want):\n%s", diff) t.Fatalf("wrong output (-got, +want):\n%s", diff)
} }
}) })
} }
func TestNetworkLockStatusOutput(t *testing.T) {
aum := tka.AUM{
MessageKind: tka.AUMNoOp,
}
h := aum.Hash()
head := [32]byte(h[:])
nodeKey1 := key.NodePublicFromRaw32(mem.B(bytes.Repeat([]byte{1}, 32)))
nodeKey2 := key.NodePublicFromRaw32(mem.B(bytes.Repeat([]byte{2}, 32)))
nodeKey3 := key.NodePublicFromRaw32(mem.B(bytes.Repeat([]byte{3}, 32)))
nlPub := key.NLPublicFromEd25519Unsafe(bytes.Repeat([]byte{4}, 32))
trustedNlPub := key.NLPublicFromEd25519Unsafe(bytes.Repeat([]byte{5}, 32))
tailnetIPv4_A, tailnetIPv6_A := netip.MustParseAddr("100.99.99.99"), netip.MustParseAddr("fd7a:115c:a1e0::701:b62a")
tailnetIPv4_B, tailnetIPv6_B := netip.MustParseAddr("100.88.88.88"), netip.MustParseAddr("fd7a:115c:a1e0::4101:512f")
t.Run("json-1", func(t *testing.T) {
for _, tt := range []struct {
Name string
Status ipnstate.NetworkLockStatus
Want string
}{
{
Name: "tailnet-lock-disabled",
Status: ipnstate.NetworkLockStatus{Enabled: false},
Want: `{
"SchemaVersion": "1",
"Enabled": false
}
`,
},
{
Name: "tailnet-lock-disabled-with-keys",
Status: ipnstate.NetworkLockStatus{
Enabled: false,
NodeKey: &nodeKey1,
PublicKey: trustedNlPub,
},
Want: `{
"SchemaVersion": "1",
"Enabled": false,
"PublicKey": "tlpub:0505050505050505050505050505050505050505050505050505050505050505",
"NodeKey": "nodekey:0101010101010101010101010101010101010101010101010101010101010101"
}
`,
},
{
Name: "tailnet-lock-enabled",
Status: ipnstate.NetworkLockStatus{
Enabled: true,
Head: &head,
PublicKey: nlPub,
NodeKey: &nodeKey1,
NodeKeySigned: false,
NodeKeySignature: nil,
TrustedKeys: []ipnstate.TKAKey{
{
Kind: tka.Key25519.String(),
Votes: 1,
Key: trustedNlPub,
Metadata: map[string]string{"en": "one", "de": "eins", "es": "uno"},
},
},
VisiblePeers: []*ipnstate.TKAPeer{
{
Name: "authentic-associate",
ID: tailcfg.NodeID(1234),
StableID: tailcfg.StableNodeID("1234_AAAA_TEST"),
TailscaleIPs: []netip.Addr{tailnetIPv4_A, tailnetIPv6_A},
NodeKey: nodeKey2,
NodeKeySignature: tka.NodeKeySignature{
SigKind: tka.SigDirect,
Pubkey: []byte("22222222222222222222222222222222"),
KeyID: []byte("44444444444444444444444444444444"),
Signature: []byte("1234567890"),
WrappingPubkey: []byte("0987654321"),
},
},
},
FilteredPeers: []*ipnstate.TKAPeer{
{
Name: "bogus-bandit",
ID: tailcfg.NodeID(5678),
StableID: tailcfg.StableNodeID("5678_BBBB_TEST"),
TailscaleIPs: []netip.Addr{tailnetIPv4_B, tailnetIPv6_B},
NodeKey: nodeKey3,
},
},
StateID: 98989898,
},
Want: `{
"SchemaVersion": "1",
"Enabled": true,
"Head": "WYIVHDR7JUIXBWAJT5UPSCAILEXB7OMINDFEFEPOPNTUCNXMY2KA",
"PublicKey": "tlpub:0404040404040404040404040404040404040404040404040404040404040404",
"NodeKey": "nodekey:0101010101010101010101010101010101010101010101010101010101010101",
"NodeKeySigned": false,
"NodeKeySignature": null,
"TrustedKeys": [
{
"Kind": "25519",
"Votes": 1,
"Public": "tlpub:0505050505050505050505050505050505050505050505050505050505050505",
"Meta": {
"de": "eins",
"en": "one",
"es": "uno"
}
}
],
"VisiblePeers": [
{
"ID": "1234_AAAA_TEST",
"DNSName": "authentic-associate",
"TailscaleIPs": [
"100.99.99.99",
"fd7a:115c:a1e0::701:b62a"
],
"NodeKey": "nodekey:0202020202020202020202020202020202020202020202020202020202020202",
"NodeKeySignature": {
"SigKind": "direct",
"PublicKey": "tlpub:3232323232323232323232323232323232323232323232323232323232323232",
"KeyID": "tlpub:3434343434343434343434343434343434343434343434343434343434343434",
"Signature": "MTIzNDU2Nzg5MA==",
"WrappingPublicKey": "tlpub:"
}
}
],
"FilteredPeers": [
{
"ID": "5678_BBBB_TEST",
"DNSName": "bogus-bandit",
"TailscaleIPs": [
"100.88.88.88",
"fd7a:115c:a1e0::4101:512f"
],
"NodeKey": "nodekey:0303030303030303030303030303030303030303030303030303030303030303"
}
],
"State": 98989898
}
`,
},
} {
t.Run(tt.Name, func(t *testing.T) {
t.Parallel()
var outBuf bytes.Buffer
err := jsonoutput.PrintNetworkLockStatusJSONV1(&outBuf, &tt.Status)
if err != nil {
t.Fatalf("PrintNetworkLockStatusJSONV1: %v", err)
}
if diff := cmp.Diff(outBuf.String(), tt.Want); diff != "" {
t.Fatalf("wrong output (-got, +want):\n%s", diff)
}
})
}
})
}

@ -563,6 +563,7 @@ func (b *LocalBackend) NetworkLockStatus() *ipnstate.NetworkLockStatus {
outKeys := make([]ipnstate.TKAKey, len(keys)) outKeys := make([]ipnstate.TKAKey, len(keys))
for i, k := range keys { for i, k := range keys {
outKeys[i] = ipnstate.TKAKey{ outKeys[i] = ipnstate.TKAKey{
Kind: k.Kind.String(),
Key: key.NLPublicFromEd25519Unsafe(k.Public), Key: key.NLPublicFromEd25519Unsafe(k.Public),
Metadata: k.Meta, Metadata: k.Meta,
Votes: k.Votes, Votes: k.Votes,

@ -89,6 +89,7 @@ type Status struct {
// TKAKey describes a key trusted by network lock. // TKAKey describes a key trusted by network lock.
type TKAKey struct { type TKAKey struct {
Kind string
Key key.NLPublic Key key.NLPublic
Metadata map[string]string Metadata map[string]string
Votes uint Votes uint

Loading…
Cancel
Save