control/controlclient,ipn/ipnlocal: wire tka enable/disable
Signed-off-by: Tom DNetto <tom@tailscale.com>pull/5713/head
parent
b9b0bf65a0
commit
e9b98dd2e1
|
@ -51,7 +51,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||||
tailscale.com/safesocket from tailscale.com/client/tailscale
|
tailscale.com/safesocket from tailscale.com/client/tailscale
|
||||||
tailscale.com/syncs from tailscale.com/cmd/derper+
|
tailscale.com/syncs from tailscale.com/cmd/derper+
|
||||||
tailscale.com/tailcfg from tailscale.com/client/tailscale+
|
tailscale.com/tailcfg from tailscale.com/client/tailscale+
|
||||||
tailscale.com/tka from tailscale.com/client/tailscale
|
tailscale.com/tka from tailscale.com/client/tailscale+
|
||||||
W tailscale.com/tsconst from tailscale.com/net/interfaces
|
W tailscale.com/tsconst from tailscale.com/net/interfaces
|
||||||
💣 tailscale.com/tstime/mono from tailscale.com/tstime/rate
|
💣 tailscale.com/tstime/mono from tailscale.com/tstime/rate
|
||||||
tailscale.com/tstime/rate from tailscale.com/wgengine/filter
|
tailscale.com/tstime/rate from tailscale.com/wgengine/filter
|
||||||
|
|
|
@ -106,6 +106,7 @@ type Options struct {
|
||||||
KeepAlive bool
|
KeepAlive bool
|
||||||
Logf logger.Logf
|
Logf logger.Logf
|
||||||
HTTPTestClient *http.Client // optional HTTP client to use (for tests only)
|
HTTPTestClient *http.Client // optional HTTP client to use (for tests only)
|
||||||
|
NoiseTestClient *http.Client // optional HTTP client to use for noise RPCs (tests only)
|
||||||
DebugFlags []string // debug settings to send to control
|
DebugFlags []string // debug settings to send to control
|
||||||
LinkMonitor *monitor.Mon // optional link monitor
|
LinkMonitor *monitor.Mon // optional link monitor
|
||||||
PopBrowserURL func(url string) // optional func to open browser
|
PopBrowserURL func(url string) // optional func to open browser
|
||||||
|
@ -226,6 +227,12 @@ func NewDirect(opts Options) (*Direct, error) {
|
||||||
c.SetNetInfo(ni)
|
c.SetNetInfo(ni)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if opts.NoiseTestClient != nil {
|
||||||
|
c.noiseClient = &noiseClient{
|
||||||
|
Client: opts.NoiseTestClient,
|
||||||
|
}
|
||||||
|
c.serverNoiseKey = key.NewMachine().Public() // prevent early error before hitting test client
|
||||||
|
}
|
||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -48,6 +48,7 @@ type mapSession struct {
|
||||||
lastHealth []string
|
lastHealth []string
|
||||||
lastPopBrowserURL string
|
lastPopBrowserURL string
|
||||||
stickyDebug tailcfg.Debug // accumulated opt.Bool values
|
stickyDebug tailcfg.Debug // accumulated opt.Bool values
|
||||||
|
lastTKAInfo *tailcfg.TKAInfo
|
||||||
|
|
||||||
// netMapBuilding is non-nil during a netmapForResponse call,
|
// netMapBuilding is non-nil during a netmapForResponse call,
|
||||||
// containing the value to be returned, once fully populated.
|
// containing the value to be returned, once fully populated.
|
||||||
|
@ -115,6 +116,9 @@ func (ms *mapSession) netmapForResponse(resp *tailcfg.MapResponse) *netmap.Netwo
|
||||||
if resp.Health != nil {
|
if resp.Health != nil {
|
||||||
ms.lastHealth = resp.Health
|
ms.lastHealth = resp.Health
|
||||||
}
|
}
|
||||||
|
if resp.TKAInfo != nil {
|
||||||
|
ms.lastTKAInfo = resp.TKAInfo
|
||||||
|
}
|
||||||
|
|
||||||
debug := resp.Debug
|
debug := resp.Debug
|
||||||
if debug != nil {
|
if debug != nil {
|
||||||
|
@ -152,9 +156,17 @@ func (ms *mapSession) netmapForResponse(resp *tailcfg.MapResponse) *netmap.Netwo
|
||||||
DERPMap: ms.lastDERPMap,
|
DERPMap: ms.lastDERPMap,
|
||||||
Debug: debug,
|
Debug: debug,
|
||||||
ControlHealth: ms.lastHealth,
|
ControlHealth: ms.lastHealth,
|
||||||
|
TKAEnabled: ms.lastTKAInfo != nil && !ms.lastTKAInfo.Disabled,
|
||||||
}
|
}
|
||||||
ms.netMapBuilding = nm
|
ms.netMapBuilding = nm
|
||||||
|
|
||||||
|
if ms.lastTKAInfo != nil && ms.lastTKAInfo.Head != "" {
|
||||||
|
if err := nm.TKAHead.UnmarshalText([]byte(ms.lastTKAInfo.Head)); err != nil {
|
||||||
|
ms.logf("error unmarshalling TKAHead: %v", err)
|
||||||
|
nm.TKAEnabled = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if resp.Node != nil {
|
if resp.Node != nil {
|
||||||
ms.lastNode = resp.Node
|
ms.lastNode = resp.Node
|
||||||
}
|
}
|
||||||
|
|
|
@ -684,6 +684,9 @@ func (b *LocalBackend) setClientStatus(st controlclient.Status) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if st.NetMap != nil {
|
if st.NetMap != nil {
|
||||||
|
if err := b.tkaSyncIfNeededLocked(st.NetMap); err != nil {
|
||||||
|
b.logf("[v1] TKA sync error: %v", err)
|
||||||
|
}
|
||||||
if b.findExitNodeIDLocked(st.NetMap) {
|
if b.findExitNodeIDLocked(st.NetMap) {
|
||||||
prefsChanged = true
|
prefsChanged = true
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
|
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||||
// Use of this source code is governed by a BSD-style
|
// Use of this source code is governed by a BSD-style
|
||||||
// license that can be found in the LICENSE file.
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
@ -12,6 +12,8 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"tailscale.com/envknob"
|
"tailscale.com/envknob"
|
||||||
|
@ -31,6 +33,118 @@ type tkaState struct {
|
||||||
storage *tka.FS
|
storage *tka.FS
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tkaSyncIfNeededLocked examines TKA info reported from the control plane,
|
||||||
|
// performing the steps necessary to synchronize local tka state.
|
||||||
|
//
|
||||||
|
// There are 4 scenarios handled here:
|
||||||
|
// - Enablement: nm.TKAEnabled but b.tka == nil
|
||||||
|
// ∴ reach out to /machine/tka/boostrap to get the genesis AUM, then
|
||||||
|
// initialize TKA.
|
||||||
|
// - Disablement: !nm.TKAEnabled but b.tka != nil
|
||||||
|
// ∴ reach out to /machine/tka/boostrap to read the disablement secret,
|
||||||
|
// then verify and clear tka local state.
|
||||||
|
// - Sync needed: b.tka.Head != nm.TKAHead
|
||||||
|
// ∴ complete multi-step synchronization flow.
|
||||||
|
// - Everything up to date: All other cases.
|
||||||
|
// ∴ no action necessary.
|
||||||
|
//
|
||||||
|
// b.mu must be held. b.mu will be stepped out of (and back in) during network
|
||||||
|
// RPCs.
|
||||||
|
func (b *LocalBackend) tkaSyncIfNeededLocked(nm *netmap.NetworkMap) error {
|
||||||
|
if !networkLockAvailable() {
|
||||||
|
// If the feature flag is not enabled, pretend we don't exist.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if nm.SelfNode == nil {
|
||||||
|
return errors.New("SelfNode missing")
|
||||||
|
}
|
||||||
|
|
||||||
|
isEnabled := b.tka != nil
|
||||||
|
wantEnabled := nm.TKAEnabled
|
||||||
|
if isEnabled != wantEnabled {
|
||||||
|
var ourHead tka.AUMHash
|
||||||
|
if b.tka != nil {
|
||||||
|
ourHead = b.tka.authority.Head()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regardless of whether we are moving to disabled or enabled, we
|
||||||
|
// need information from the tka bootstrap endpoint.
|
||||||
|
b.mu.Unlock()
|
||||||
|
bs, err := b.tkaFetchBootstrap(nm.SelfNode.ID, ourHead)
|
||||||
|
b.mu.Lock()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("fetching bootstrap: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if wantEnabled && !isEnabled {
|
||||||
|
if err := b.tkaBootstrapFromGenesisLocked(bs.GenesisAUM); err != nil {
|
||||||
|
return fmt.Errorf("bootstrap: %v", err)
|
||||||
|
}
|
||||||
|
isEnabled = true
|
||||||
|
} else if !wantEnabled && isEnabled {
|
||||||
|
if b.tka.authority.ValidDisablement(bs.DisablementSecret) {
|
||||||
|
b.tka = nil
|
||||||
|
isEnabled = false
|
||||||
|
|
||||||
|
if err := os.RemoveAll(b.chonkPath()); err != nil {
|
||||||
|
return fmt.Errorf("os.RemoveAll: %v", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
b.logf("Disablement secret did not verify, leaving TKA enabled.")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("[bug] unreachable invariant of wantEnabled /w isEnabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if isEnabled && b.tka.authority.Head() != nm.TKAHead {
|
||||||
|
// TODO(tom): Implement sync
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// chonkPath returns the absolute path to the directory in which TKA
|
||||||
|
// state (the 'tailchonk') is stored.
|
||||||
|
func (b *LocalBackend) chonkPath() string {
|
||||||
|
return filepath.Join(b.TailscaleVarRoot(), "tka")
|
||||||
|
}
|
||||||
|
|
||||||
|
// tkaBootstrapFromGenesisLocked initializes the local (on-disk) state of the
|
||||||
|
// tailnet key authority, based on the given genesis AUM.
|
||||||
|
//
|
||||||
|
// b.mu must be held.
|
||||||
|
func (b *LocalBackend) tkaBootstrapFromGenesisLocked(g tkatype.MarshaledAUM) error {
|
||||||
|
if !b.CanSupportNetworkLock() {
|
||||||
|
return errors.New("network lock not supported in this configuration")
|
||||||
|
}
|
||||||
|
|
||||||
|
var genesis tka.AUM
|
||||||
|
if err := genesis.Unserialize(g); err != nil {
|
||||||
|
return fmt.Errorf("reading genesis: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
chonkDir := b.chonkPath()
|
||||||
|
if err := os.Mkdir(chonkDir, 0755); err != nil && !os.IsExist(err) {
|
||||||
|
return fmt.Errorf("mkdir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
chonk, err := tka.ChonkDir(chonkDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("chonk: %v", err)
|
||||||
|
}
|
||||||
|
authority, err := tka.Bootstrap(chonk, genesis)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("tka bootstrap: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
b.tka = &tkaState{
|
||||||
|
authority: authority,
|
||||||
|
storage: chonk,
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// CanSupportNetworkLock returns true if tailscaled is able to operate
|
// CanSupportNetworkLock returns true if tailscaled is able to operate
|
||||||
// a local tailnet key authority (and hence enforce network lock).
|
// a local tailnet key authority (and hence enforce network lock).
|
||||||
func (b *LocalBackend) CanSupportNetworkLock() bool {
|
func (b *LocalBackend) CanSupportNetworkLock() bool {
|
||||||
|
@ -237,3 +351,50 @@ func (b *LocalBackend) tkaInitFinish(nm *netmap.NetworkMap, nks map[tailcfg.Node
|
||||||
return a, nil
|
return a, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tkaFetchBootstrap sends a /machine/tka/bootstrap RPC to the control plane
|
||||||
|
// over noise. This is used to get values necessary to enable or disable TKA.
|
||||||
|
func (b *LocalBackend) tkaFetchBootstrap(nodeID tailcfg.NodeID, head tka.AUMHash) (*tailcfg.TKABootstrapResponse, error) {
|
||||||
|
bootstrapReq := tailcfg.TKABootstrapRequest{
|
||||||
|
NodeID: nodeID,
|
||||||
|
}
|
||||||
|
if !head.IsZero() {
|
||||||
|
head, err := head.MarshalText()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("head.MarshalText failed: %v", err)
|
||||||
|
}
|
||||||
|
bootstrapReq.Head = string(head)
|
||||||
|
}
|
||||||
|
|
||||||
|
var req bytes.Buffer
|
||||||
|
if err := json.NewEncoder(&req).Encode(bootstrapReq); err != nil {
|
||||||
|
return nil, fmt.Errorf("encoding request: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("ctx: %w", err)
|
||||||
|
}
|
||||||
|
req2, err := http.NewRequestWithContext(ctx, "GET", "https://unused/machine/tka/bootstrap", &req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("req: %w", err)
|
||||||
|
}
|
||||||
|
res, err := b.DoNoiseRequest(req2)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("resp: %w", err)
|
||||||
|
}
|
||||||
|
if res.StatusCode != 200 {
|
||||||
|
body, _ := io.ReadAll(res.Body)
|
||||||
|
res.Body.Close()
|
||||||
|
return nil, fmt.Errorf("request returned (%d): %s", res.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
a := new(tailcfg.TKABootstrapResponse)
|
||||||
|
err = json.NewDecoder(res.Body).Decode(a)
|
||||||
|
res.Body.Close()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decoding JSON: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,243 @@
|
||||||
|
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package ipnlocal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"tailscale.com/control/controlclient"
|
||||||
|
"tailscale.com/hostinfo"
|
||||||
|
"tailscale.com/tailcfg"
|
||||||
|
"tailscale.com/tka"
|
||||||
|
"tailscale.com/types/key"
|
||||||
|
"tailscale.com/types/netmap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func fakeControlClient(t *testing.T, c *http.Client) *controlclient.Auto {
|
||||||
|
hi := hostinfo.New()
|
||||||
|
ni := tailcfg.NetInfo{LinkType: "wired"}
|
||||||
|
hi.NetInfo = &ni
|
||||||
|
|
||||||
|
k := key.NewMachine()
|
||||||
|
opts := controlclient.Options{
|
||||||
|
ServerURL: "https://example.com",
|
||||||
|
Hostinfo: hi,
|
||||||
|
GetMachinePrivateKey: func() (key.MachinePrivate, error) {
|
||||||
|
return k, nil
|
||||||
|
},
|
||||||
|
HTTPTestClient: c,
|
||||||
|
NoiseTestClient: c,
|
||||||
|
Status: func(controlclient.Status) {},
|
||||||
|
}
|
||||||
|
|
||||||
|
cc, err := controlclient.NewNoStart(opts)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return cc
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: URLs must have a https scheme and example.com domain to work with the underlying
|
||||||
|
// httptest plumbing, despite the domain being unused in the actual noise request transport.
|
||||||
|
func fakeNoiseServer(t *testing.T, handler http.HandlerFunc) (*httptest.Server, *http.Client) {
|
||||||
|
ts := httptest.NewUnstartedServer(handler)
|
||||||
|
ts.StartTLS()
|
||||||
|
client := ts.Client()
|
||||||
|
client.Transport.(*http.Transport).TLSClientConfig.InsecureSkipVerify = true
|
||||||
|
client.Transport.(*http.Transport).DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
|
return (&net.Dialer{}).DialContext(ctx, network, ts.Listener.Addr().String())
|
||||||
|
}
|
||||||
|
return ts, client
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTKAEnablementFlow(t *testing.T) {
|
||||||
|
networkLockAvailable = func() bool { return true } // Enable the feature flag
|
||||||
|
|
||||||
|
// Make a fake TKA authority, getting a usable genesis AUM which
|
||||||
|
// our mock server can communicate.
|
||||||
|
nlPriv := key.NewNLPrivate()
|
||||||
|
key := tka.Key{Kind: tka.Key25519, Public: nlPriv.Public().Verifier(), Votes: 2}
|
||||||
|
a1, genesisAUM, err := tka.Create(&tka.Mem{}, tka.State{
|
||||||
|
Keys: []tka.Key{key},
|
||||||
|
DisablementSecrets: [][]byte{bytes.Repeat([]byte{0xa5}, 32)},
|
||||||
|
}, 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.NodeID != 420 {
|
||||||
|
t.Errorf("bootstrap nodeID=%v, want 420", body.NodeID)
|
||||||
|
}
|
||||||
|
if body.Head != "" {
|
||||||
|
t.Errorf("bootstrap head=%s, want empty hash", body.Head)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(200)
|
||||||
|
out := tailcfg.TKABootstrapResponse{
|
||||||
|
GenesisAUM: genesisAUM.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()
|
||||||
|
temp := t.TempDir()
|
||||||
|
|
||||||
|
cc := fakeControlClient(t, client)
|
||||||
|
b := LocalBackend{
|
||||||
|
varRoot: temp,
|
||||||
|
cc: cc,
|
||||||
|
ccAuto: cc,
|
||||||
|
logf: t.Logf,
|
||||||
|
}
|
||||||
|
|
||||||
|
b.mu.Lock()
|
||||||
|
err = b.tkaSyncIfNeededLocked(&netmap.NetworkMap{
|
||||||
|
SelfNode: &tailcfg.Node{ID: 420},
|
||||||
|
TKAEnabled: true,
|
||||||
|
TKAHead: tka.AUMHash{},
|
||||||
|
})
|
||||||
|
b.mu.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("tkaSyncIfNeededLocked() failed: %v", err)
|
||||||
|
}
|
||||||
|
if b.tka == nil {
|
||||||
|
t.Fatal("tka was not initialized")
|
||||||
|
}
|
||||||
|
if b.tka.authority.Head() != a1.Head() {
|
||||||
|
t.Errorf("authority.Head() = %x, want %x", b.tka.authority.Head(), a1.Head())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTKADisablementFlow(t *testing.T) {
|
||||||
|
networkLockAvailable = func() bool { return true } // Enable the feature flag
|
||||||
|
temp := t.TempDir()
|
||||||
|
os.Mkdir(filepath.Join(temp, "tka"), 0755)
|
||||||
|
|
||||||
|
// 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}
|
||||||
|
chonk, err := tka.ChonkDir(filepath.Join(temp, "tka"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
authority, _, 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)
|
||||||
|
}
|
||||||
|
var disablement []byte
|
||||||
|
switch body.NodeID {
|
||||||
|
case 42:
|
||||||
|
disablement = bytes.Repeat([]byte{0x42}, 32) // wrong secret
|
||||||
|
case 420:
|
||||||
|
disablement = disablementSecret
|
||||||
|
default:
|
||||||
|
t.Errorf("bootstrap nodeID=%v, wanted 42 or 420", body.NodeID)
|
||||||
|
}
|
||||||
|
var head tka.AUMHash
|
||||||
|
if err := head.UnmarshalText([]byte(body.Head)); err != nil {
|
||||||
|
t.Fatalf("failed unmarshal of body.Head: %v", err)
|
||||||
|
}
|
||||||
|
if head != authority.Head() {
|
||||||
|
t.Errorf("reported head = %x, want %x", head, authority.Head())
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(200)
|
||||||
|
out := tailcfg.TKABootstrapResponse{
|
||||||
|
DisablementSecret: disablement,
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test that the wrong disablement secret does not shut down the authority.
|
||||||
|
// NodeID == 42 indicates this scenario to our mock server.
|
||||||
|
b.mu.Lock()
|
||||||
|
err = b.tkaSyncIfNeededLocked(&netmap.NetworkMap{
|
||||||
|
SelfNode: &tailcfg.Node{ID: 42},
|
||||||
|
TKAEnabled: false,
|
||||||
|
TKAHead: authority.Head(),
|
||||||
|
})
|
||||||
|
b.mu.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("tkaSyncIfNeededLocked() failed: %v", err)
|
||||||
|
}
|
||||||
|
if b.tka == nil {
|
||||||
|
t.Error("TKA was disabled despite incorrect disablement secret")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test the correct disablement secret shuts down the authority.
|
||||||
|
// NodeID == 420 indicates this scenario to our mock server.
|
||||||
|
b.mu.Lock()
|
||||||
|
err = b.tkaSyncIfNeededLocked(&netmap.NetworkMap{
|
||||||
|
SelfNode: &tailcfg.Node{ID: 420},
|
||||||
|
TKAEnabled: false,
|
||||||
|
TKAHead: authority.Head(),
|
||||||
|
})
|
||||||
|
b.mu.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("tkaSyncIfNeededLocked() failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if b.tka != nil {
|
||||||
|
t.Fatal("tka was not shut down")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(b.chonkPath()); err == nil || !os.IsNotExist(err) {
|
||||||
|
t.Errorf("os.Stat(chonkDir) = %v, want ErrNotExist", err)
|
||||||
|
}
|
||||||
|
}
|
|
@ -772,7 +772,7 @@ func New(logf logger.Logf, logid string, store ipn.StateStore, eng wgengine.Engi
|
||||||
})
|
})
|
||||||
|
|
||||||
if root := b.TailscaleVarRoot(); root != "" {
|
if root := b.TailscaleVarRoot(); root != "" {
|
||||||
chonkDir := filepath.Join(root, "chonk")
|
chonkDir := filepath.Join(root, "tka")
|
||||||
if _, err := os.Stat(chonkDir); err == nil {
|
if _, err := os.Stat(chonkDir); err == nil {
|
||||||
// The directory exists, which means network-lock has been initialized.
|
// The directory exists, which means network-lock has been initialized.
|
||||||
storage, err := tka.ChonkDir(chonkDir)
|
storage, err := tka.ChonkDir(chonkDir)
|
||||||
|
|
|
@ -101,6 +101,8 @@ type TKAInfo struct {
|
||||||
// TKABootstrapRequest is sent by a node to get information necessary for
|
// TKABootstrapRequest is sent by a node to get information necessary for
|
||||||
// enabling or disabling the tailnet key authority.
|
// enabling or disabling the tailnet key authority.
|
||||||
type TKABootstrapRequest struct {
|
type TKABootstrapRequest struct {
|
||||||
|
// NodeID is the node ID of the initiating client.
|
||||||
|
NodeID NodeID
|
||||||
// Head represents the node's head AUMHash (tka.Authority.Head), if
|
// Head represents the node's head AUMHash (tka.Authority.Head), if
|
||||||
// network lock is enabled.
|
// network lock is enabled.
|
||||||
Head string
|
Head string
|
||||||
|
@ -120,6 +122,8 @@ type TKABootstrapResponse struct {
|
||||||
// state (TKA). Values of type tka.AUMHash are encoded as strings in their
|
// state (TKA). Values of type tka.AUMHash are encoded as strings in their
|
||||||
// MarshalText form.
|
// MarshalText form.
|
||||||
type TKASyncOfferRequest struct {
|
type TKASyncOfferRequest struct {
|
||||||
|
// NodeID is the node ID of the initiating client.
|
||||||
|
NodeID NodeID
|
||||||
// Head represents the node's head AUMHash (tka.Authority.Head). This
|
// Head represents the node's head AUMHash (tka.Authority.Head). This
|
||||||
// corresponds to tka.SyncOffer.Head.
|
// corresponds to tka.SyncOffer.Head.
|
||||||
Head string
|
Head string
|
||||||
|
@ -147,6 +151,8 @@ type TKASyncOfferResponse struct {
|
||||||
// TKASyncSendRequest encodes AUMs that a node believes the control plane
|
// TKASyncSendRequest encodes AUMs that a node believes the control plane
|
||||||
// is missing.
|
// is missing.
|
||||||
type TKASyncSendRequest struct {
|
type TKASyncSendRequest struct {
|
||||||
|
// NodeID is the node ID of the initiating client.
|
||||||
|
NodeID NodeID
|
||||||
// MissingAUMs encodes AUMs that the node believes the control plane
|
// MissingAUMs encodes AUMs that the node believes the control plane
|
||||||
// is missing.
|
// is missing.
|
||||||
MissingAUMs []tkatype.MarshaledAUM
|
MissingAUMs []tkatype.MarshaledAUM
|
||||||
|
|
|
@ -45,6 +45,12 @@ func (h AUMHash) MarshalText() ([]byte, error) {
|
||||||
return b, nil
|
return b, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsZero returns true if the hash is the empty value.
|
||||||
|
func (h AUMHash) IsZero() bool {
|
||||||
|
return h == (AUMHash{})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// AUMKind describes valid AUM types.
|
// AUMKind describes valid AUM types.
|
||||||
type AUMKind uint8
|
type AUMKind uint8
|
||||||
|
|
||||||
|
|
|
@ -31,7 +31,7 @@ func TestAuthorityBuilderAddKey(t *testing.T) {
|
||||||
storage := &Mem{}
|
storage := &Mem{}
|
||||||
a, _, err := Create(storage, State{
|
a, _, err := Create(storage, State{
|
||||||
Keys: []Key{key},
|
Keys: []Key{key},
|
||||||
DisablementSecrets: [][]byte{disablementKDF([]byte{1, 2, 3})},
|
DisablementSecrets: [][]byte{DisablementKDF([]byte{1, 2, 3})},
|
||||||
}, signer25519(priv))
|
}, signer25519(priv))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Create() failed: %v", err)
|
t.Fatalf("Create() failed: %v", err)
|
||||||
|
@ -68,7 +68,7 @@ func TestAuthorityBuilderRemoveKey(t *testing.T) {
|
||||||
storage := &Mem{}
|
storage := &Mem{}
|
||||||
a, _, err := Create(storage, State{
|
a, _, err := Create(storage, State{
|
||||||
Keys: []Key{key, key2},
|
Keys: []Key{key, key2},
|
||||||
DisablementSecrets: [][]byte{disablementKDF([]byte{1, 2, 3})},
|
DisablementSecrets: [][]byte{DisablementKDF([]byte{1, 2, 3})},
|
||||||
}, signer25519(priv))
|
}, signer25519(priv))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Create() failed: %v", err)
|
t.Fatalf("Create() failed: %v", err)
|
||||||
|
@ -100,7 +100,7 @@ func TestAuthorityBuilderSetKeyVote(t *testing.T) {
|
||||||
storage := &Mem{}
|
storage := &Mem{}
|
||||||
a, _, err := Create(storage, State{
|
a, _, err := Create(storage, State{
|
||||||
Keys: []Key{key},
|
Keys: []Key{key},
|
||||||
DisablementSecrets: [][]byte{disablementKDF([]byte{1, 2, 3})},
|
DisablementSecrets: [][]byte{DisablementKDF([]byte{1, 2, 3})},
|
||||||
}, signer25519(priv))
|
}, signer25519(priv))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Create() failed: %v", err)
|
t.Fatalf("Create() failed: %v", err)
|
||||||
|
@ -136,7 +136,7 @@ func TestAuthorityBuilderSetKeyMeta(t *testing.T) {
|
||||||
storage := &Mem{}
|
storage := &Mem{}
|
||||||
a, _, err := Create(storage, State{
|
a, _, err := Create(storage, State{
|
||||||
Keys: []Key{key},
|
Keys: []Key{key},
|
||||||
DisablementSecrets: [][]byte{disablementKDF([]byte{1, 2, 3})},
|
DisablementSecrets: [][]byte{DisablementKDF([]byte{1, 2, 3})},
|
||||||
}, signer25519(priv))
|
}, signer25519(priv))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Create() failed: %v", err)
|
t.Fatalf("Create() failed: %v", err)
|
||||||
|
@ -172,7 +172,7 @@ func TestAuthorityBuilderMultiple(t *testing.T) {
|
||||||
storage := &Mem{}
|
storage := &Mem{}
|
||||||
a, _, err := Create(storage, State{
|
a, _, err := Create(storage, State{
|
||||||
Keys: []Key{key},
|
Keys: []Key{key},
|
||||||
DisablementSecrets: [][]byte{disablementKDF([]byte{1, 2, 3})},
|
DisablementSecrets: [][]byte{DisablementKDF([]byte{1, 2, 3})},
|
||||||
}, signer25519(priv))
|
}, signer25519(priv))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Create() failed: %v", err)
|
t.Fatalf("Create() failed: %v", err)
|
||||||
|
|
|
@ -169,7 +169,7 @@ func testScenario(t *testing.T, sharedChain string, sharedOptions ...testchainOp
|
||||||
sharedOptions = append(sharedOptions,
|
sharedOptions = append(sharedOptions,
|
||||||
optTemplate("genesis", AUM{MessageKind: AUMCheckpoint, State: &State{
|
optTemplate("genesis", AUM{MessageKind: AUMCheckpoint, State: &State{
|
||||||
Keys: []Key{key},
|
Keys: []Key{key},
|
||||||
DisablementSecrets: [][]byte{disablementKDF([]byte{1, 2, 3})},
|
DisablementSecrets: [][]byte{DisablementKDF([]byte{1, 2, 3})},
|
||||||
}}),
|
}}),
|
||||||
optKey("key", key, priv),
|
optKey("key", key, priv),
|
||||||
optSignAllUsing("key"))
|
optSignAllUsing("key"))
|
||||||
|
|
|
@ -226,7 +226,7 @@ func TestSigCredential(t *testing.T) {
|
||||||
a, _ := Open(newTestchain(t, "G1\nG1.template = genesis",
|
a, _ := Open(newTestchain(t, "G1\nG1.template = genesis",
|
||||||
optTemplate("genesis", AUM{MessageKind: AUMCheckpoint, State: &State{
|
optTemplate("genesis", AUM{MessageKind: AUMCheckpoint, State: &State{
|
||||||
Keys: []Key{k},
|
Keys: []Key{k},
|
||||||
DisablementSecrets: [][]byte{disablementKDF([]byte{1, 2, 3})},
|
DisablementSecrets: [][]byte{DisablementKDF([]byte{1, 2, 3})},
|
||||||
}})).Chonk())
|
}})).Chonk())
|
||||||
if err := a.NodeKeyAuthorized(node.Public(), nestedSig.Serialize()); err == nil {
|
if err := a.NodeKeyAuthorized(node.Public(), nestedSig.Serialize()); err == nil {
|
||||||
t.Error("NodeKeyAuthorized(SigCredential, node) did not fail")
|
t.Error("NodeKeyAuthorized(SigCredential, node) did not fail")
|
||||||
|
|
10
tka/state.go
10
tka/state.go
|
@ -93,7 +93,13 @@ const disablementLength = 32
|
||||||
|
|
||||||
var disablementSalt = []byte("tailscale network-lock disablement salt")
|
var disablementSalt = []byte("tailscale network-lock disablement salt")
|
||||||
|
|
||||||
func disablementKDF(secret []byte) []byte {
|
// DisablementKDF computes a public value which can be stored in a
|
||||||
|
// key authority, but cannot be reversed to find the input secret.
|
||||||
|
//
|
||||||
|
// When the output of this function is stored in tka state (i.e. in
|
||||||
|
// tka.State.DisablementSecrets) a call to Authority.ValidDisablement()
|
||||||
|
// with the input of this function as the argument will return true.
|
||||||
|
func DisablementKDF(secret []byte) []byte {
|
||||||
// time = 4 (3 recommended, booped to 4 to compensate for less memory)
|
// time = 4 (3 recommended, booped to 4 to compensate for less memory)
|
||||||
// memory = 16 (32 recommended)
|
// memory = 16 (32 recommended)
|
||||||
// threads = 4
|
// threads = 4
|
||||||
|
@ -103,7 +109,7 @@ func disablementKDF(secret []byte) []byte {
|
||||||
|
|
||||||
// checkDisablement returns true for a valid disablement secret.
|
// checkDisablement returns true for a valid disablement secret.
|
||||||
func (s State) checkDisablement(secret []byte) bool {
|
func (s State) checkDisablement(secret []byte) bool {
|
||||||
derived := disablementKDF(secret)
|
derived := DisablementKDF(secret)
|
||||||
for _, candidate := range s.DisablementSecrets {
|
for _, candidate := range s.DisablementSecrets {
|
||||||
if bytes.Equal(derived, candidate) {
|
if bytes.Equal(derived, candidate) {
|
||||||
return true
|
return true
|
||||||
|
|
|
@ -342,7 +342,7 @@ func TestSyncSimpleE2E(t *testing.T) {
|
||||||
`,
|
`,
|
||||||
optTemplate("genesis", AUM{MessageKind: AUMCheckpoint, State: &State{
|
optTemplate("genesis", AUM{MessageKind: AUMCheckpoint, State: &State{
|
||||||
Keys: []Key{key},
|
Keys: []Key{key},
|
||||||
DisablementSecrets: [][]byte{disablementKDF([]byte{1, 2, 3})},
|
DisablementSecrets: [][]byte{DisablementKDF([]byte{1, 2, 3})},
|
||||||
}}),
|
}}),
|
||||||
optKey("key", key, priv),
|
optKey("key", key, priv),
|
||||||
optSignAllUsing("key"))
|
optSignAllUsing("key"))
|
||||||
|
|
|
@ -305,7 +305,7 @@ func TestAuthorityValidDisablement(t *testing.T) {
|
||||||
`,
|
`,
|
||||||
optTemplate("genesis", AUM{MessageKind: AUMCheckpoint, State: &State{
|
optTemplate("genesis", AUM{MessageKind: AUMCheckpoint, State: &State{
|
||||||
Keys: []Key{key},
|
Keys: []Key{key},
|
||||||
DisablementSecrets: [][]byte{disablementKDF([]byte{1, 2, 3})},
|
DisablementSecrets: [][]byte{DisablementKDF([]byte{1, 2, 3})},
|
||||||
}}),
|
}}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -321,7 +321,7 @@ func TestCreateBootstrapAuthority(t *testing.T) {
|
||||||
|
|
||||||
a1, genesisAUM, err := Create(&Mem{}, State{
|
a1, genesisAUM, err := Create(&Mem{}, State{
|
||||||
Keys: []Key{key},
|
Keys: []Key{key},
|
||||||
DisablementSecrets: [][]byte{disablementKDF([]byte{1, 2, 3})},
|
DisablementSecrets: [][]byte{DisablementKDF([]byte{1, 2, 3})},
|
||||||
}, signer25519(priv))
|
}, signer25519(priv))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Create() failed: %v", err)
|
t.Fatalf("Create() failed: %v", err)
|
||||||
|
@ -361,7 +361,7 @@ func TestAuthorityInformNonLinear(t *testing.T) {
|
||||||
`,
|
`,
|
||||||
optTemplate("genesis", AUM{MessageKind: AUMCheckpoint, State: &State{
|
optTemplate("genesis", AUM{MessageKind: AUMCheckpoint, State: &State{
|
||||||
Keys: []Key{key},
|
Keys: []Key{key},
|
||||||
DisablementSecrets: [][]byte{disablementKDF([]byte{1, 2, 3})},
|
DisablementSecrets: [][]byte{DisablementKDF([]byte{1, 2, 3})},
|
||||||
}}),
|
}}),
|
||||||
optKey("key", key, priv),
|
optKey("key", key, priv),
|
||||||
optSignAllUsing("key"))
|
optSignAllUsing("key"))
|
||||||
|
@ -406,7 +406,7 @@ func TestAuthorityInformLinear(t *testing.T) {
|
||||||
`,
|
`,
|
||||||
optTemplate("genesis", AUM{MessageKind: AUMCheckpoint, State: &State{
|
optTemplate("genesis", AUM{MessageKind: AUMCheckpoint, State: &State{
|
||||||
Keys: []Key{key},
|
Keys: []Key{key},
|
||||||
DisablementSecrets: [][]byte{disablementKDF([]byte{1, 2, 3})},
|
DisablementSecrets: [][]byte{DisablementKDF([]byte{1, 2, 3})},
|
||||||
}}),
|
}}),
|
||||||
optKey("key", key, priv),
|
optKey("key", key, priv),
|
||||||
optSignAllUsing("key"))
|
optSignAllUsing("key"))
|
||||||
|
|
|
@ -14,6 +14,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"tailscale.com/tailcfg"
|
"tailscale.com/tailcfg"
|
||||||
|
"tailscale.com/tka"
|
||||||
"tailscale.com/types/key"
|
"tailscale.com/types/key"
|
||||||
"tailscale.com/wgengine/filter"
|
"tailscale.com/wgengine/filter"
|
||||||
)
|
)
|
||||||
|
@ -61,6 +62,13 @@ type NetworkMap struct {
|
||||||
// check problems.
|
// check problems.
|
||||||
ControlHealth []string
|
ControlHealth []string
|
||||||
|
|
||||||
|
// TKAEnabled indicates whether the tailnet key authority should be
|
||||||
|
// enabled, from the perspective of the control plane.
|
||||||
|
TKAEnabled bool
|
||||||
|
// TKAHead indicates the control plane's understanding of 'head' (the
|
||||||
|
// hash of the latest update message to tick through TKA).
|
||||||
|
TKAHead tka.AUMHash
|
||||||
|
|
||||||
// ACLs
|
// ACLs
|
||||||
|
|
||||||
User tailcfg.UserID
|
User tailcfg.UserID
|
||||||
|
|
Loading…
Reference in New Issue