Skip to content

Commit 37b63ef

Browse files
alexwlchannickkhyl
authored andcommitted
ipn/ipnlocal: use an in-memory TKA store if FS is unavailable
This requires making the internals of LocalBackend a bit more generic, and implementing the `tka.CompactableChonk` interface for `tka.Mem`. Signed-off-by: Alex Chan <alexc@tailscale.com> Updates tailscale/corp#33599 (cherry picked from commit 1723cb8)
1 parent 43ab8b4 commit 37b63ef

File tree

6 files changed

+174
-27
lines changed

6 files changed

+174
-27
lines changed

cmd/tailscale/cli/up.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -818,6 +818,7 @@ func upWorthyWarning(s string) bool {
818818
strings.Contains(s, healthmsg.WarnAcceptRoutesOff) ||
819819
strings.Contains(s, healthmsg.LockedOut) ||
820820
strings.Contains(s, healthmsg.WarnExitNodeUsage) ||
821+
strings.Contains(s, healthmsg.InMemoryTailnetLockState) ||
821822
strings.Contains(strings.ToLower(s), "update available: ")
822823
}
823824

health/healthmsg/healthmsg.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@
88
package healthmsg
99

1010
const (
11-
WarnAcceptRoutesOff = "Some peers are advertising routes but --accept-routes is false"
12-
TailscaleSSHOnBut = "Tailscale SSH enabled, but " // + ... something from caller
13-
LockedOut = "this node is locked out; it will not have connectivity until it is signed. For more info, see https://tailscale.com/s/locked-out"
14-
WarnExitNodeUsage = "The following issues on your machine will likely make usage of exit nodes impossible"
15-
DisableRPFilter = "Please set rp_filter=2 instead of rp_filter=1; see https://github.com/tailscale/tailscale/issues/3310"
11+
WarnAcceptRoutesOff = "Some peers are advertising routes but --accept-routes is false"
12+
TailscaleSSHOnBut = "Tailscale SSH enabled, but " // + ... something from caller
13+
LockedOut = "this node is locked out; it will not have connectivity until it is signed. For more info, see https://tailscale.com/s/locked-out"
14+
WarnExitNodeUsage = "The following issues on your machine will likely make usage of exit nodes impossible"
15+
DisableRPFilter = "Please set rp_filter=2 instead of rp_filter=1; see https://github.com/tailscale/tailscale/issues/3310"
16+
InMemoryTailnetLockState = "Tailnet Lock state is only being stored in-memory. Set --statedir to store state on disk, which is more secure. See https://tailscale.com/kb/1226/tailnet-lock#tailnet-lock-state"
1617
)

ipn/ipnlocal/network-lock.go

Lines changed: 33 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"slices"
2424
"time"
2525

26+
"tailscale.com/health"
2627
"tailscale.com/health/healthmsg"
2728
"tailscale.com/ipn"
2829
"tailscale.com/ipn/ipnstate"
@@ -54,7 +55,7 @@ var (
5455
type tkaState struct {
5556
profile ipn.ProfileID
5657
authority *tka.Authority
57-
storage *tka.FS
58+
storage tka.CompactableChonk
5859
filtered []ipnstate.TKAPeer
5960
}
6061

@@ -75,7 +76,7 @@ func (b *LocalBackend) initTKALocked() error {
7576
root := b.TailscaleVarRoot()
7677
if root == "" {
7778
b.tka = nil
78-
b.logf("network-lock unavailable; no state directory")
79+
b.logf("cannot fetch existing TKA state; no state directory for network-lock")
7980
return nil
8081
}
8182

@@ -90,6 +91,7 @@ func (b *LocalBackend) initTKALocked() error {
9091
if err != nil {
9192
return fmt.Errorf("initializing tka: %v", err)
9293
}
94+
9395
if err := authority.Compact(storage, tkaCompactionDefaults); err != nil {
9496
b.logf("tka compaction failed: %v", err)
9597
}
@@ -105,6 +107,16 @@ func (b *LocalBackend) initTKALocked() error {
105107
return nil
106108
}
107109

110+
// noNetworkLockStateDirWarnable is a Warnable to warn the user that Tailnet Lock data
111+
// (in particular, the list of AUMs in the TKA state) is being stored in memory and will
112+
// be lost when tailscaled restarts.
113+
var noNetworkLockStateDirWarnable = health.Register(&health.Warnable{
114+
Code: "no-tailnet-lock-state-dir",
115+
Title: "No statedir for Tailnet Lock",
116+
Severity: health.SeverityMedium,
117+
Text: health.StaticMessage(healthmsg.InMemoryTailnetLockState),
118+
})
119+
108120
// tkaFilterNetmapLocked checks the signatures on each node key, dropping
109121
// nodes from the netmap whose signature does not verify.
110122
//
@@ -442,7 +454,7 @@ func (b *LocalBackend) tkaSyncLocked(ourNodeKey key.NodePublic) error {
442454
// b.mu must be held & TKA must be initialized.
443455
func (b *LocalBackend) tkaApplyDisablementLocked(secret []byte) error {
444456
if b.tka.authority.ValidDisablement(secret) {
445-
if err := os.RemoveAll(b.chonkPathLocked()); err != nil {
457+
if err := b.tka.storage.RemoveAll(); err != nil {
446458
return err
447459
}
448460
b.tka = nil
@@ -486,27 +498,29 @@ func (b *LocalBackend) tkaBootstrapFromGenesisLocked(g tkatype.MarshaledAUM, per
486498
}
487499
}
488500

489-
chonkDir := b.chonkPathLocked()
490-
if err := os.Mkdir(filepath.Dir(chonkDir), 0755); err != nil && !os.IsExist(err) {
491-
return fmt.Errorf("creating chonk root dir: %v", err)
492-
}
493-
if err := os.Mkdir(chonkDir, 0755); err != nil && !os.IsExist(err) {
494-
return fmt.Errorf("mkdir: %v", err)
495-
}
496-
497-
chonk, err := tka.ChonkDir(chonkDir)
498-
if err != nil {
499-
return fmt.Errorf("chonk: %v", err)
501+
root := b.TailscaleVarRoot()
502+
var storage tka.CompactableChonk
503+
if root == "" {
504+
b.health.SetUnhealthy(noNetworkLockStateDirWarnable, nil)
505+
b.logf("network-lock using in-memory storage; no state directory")
506+
storage = &tka.Mem{}
507+
} else {
508+
chonkDir := b.chonkPathLocked()
509+
chonk, err := tka.ChonkDir(chonkDir)
510+
if err != nil {
511+
return fmt.Errorf("chonk: %v", err)
512+
}
513+
storage = chonk
500514
}
501-
authority, err := tka.Bootstrap(chonk, genesis)
515+
authority, err := tka.Bootstrap(storage, genesis)
502516
if err != nil {
503517
return fmt.Errorf("tka bootstrap: %v", err)
504518
}
505519

506520
b.tka = &tkaState{
507521
profile: b.pm.CurrentProfile().ID(),
508522
authority: authority,
509-
storage: chonk,
523+
storage: storage,
510524
}
511525
return nil
512526
}
@@ -519,10 +533,6 @@ func (b *LocalBackend) CanSupportNetworkLock() error {
519533
return nil
520534
}
521535

522-
if b.TailscaleVarRoot() == "" {
523-
return errors.New("network-lock is not supported in this configuration, try setting --statedir")
524-
}
525-
526536
// There's a var root (aka --statedir), so if network lock gets
527537
// initialized we have somewhere to store our AUMs. That's all
528538
// we need.
@@ -642,6 +652,7 @@ func tkaStateFromPeer(p tailcfg.NodeView) ipnstate.TKAPeer {
642652
// needing signatures is returned as a response.
643653
// The Finish RPC submits signatures for all these nodes, at which point
644654
// Control has everything it needs to atomically enable network lock.
655+
// TODO(alexc): Only with persistent backend
645656
func (b *LocalBackend) NetworkLockInit(keys []tka.Key, disablementValues [][]byte, supportDisablement []byte) error {
646657
if err := b.CanSupportNetworkLock(); err != nil {
647658
return err
@@ -762,7 +773,7 @@ func (b *LocalBackend) NetworkLockForceLocalDisable() error {
762773
return fmt.Errorf("saving prefs: %w", err)
763774
}
764775

765-
if err := os.RemoveAll(b.chonkPathLocked()); err != nil {
776+
if err := b.tka.storage.RemoveAll(); err != nil {
766777
return fmt.Errorf("deleting TKA state: %w", err)
767778
}
768779
b.tka = nil
@@ -771,6 +782,7 @@ func (b *LocalBackend) NetworkLockForceLocalDisable() error {
771782

772783
// NetworkLockSign signs the given node-key and submits it to the control plane.
773784
// rotationPublic, if specified, must be an ed25519 public key.
785+
// TODO(alexc): in-memory only
774786
func (b *LocalBackend) NetworkLockSign(nodeKey key.NodePublic, rotationPublic []byte) error {
775787
ourNodeKey, sig, err := func(nodeKey key.NodePublic, rotationPublic []byte) (key.NodePublic, tka.NodeKeySignature, error) {
776788
b.mu.Lock()

tka/tailchonk.go

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"errors"
1111
"fmt"
1212
"log"
13+
"maps"
1314
"os"
1415
"path/filepath"
1516
"slices"
@@ -57,6 +58,10 @@ type Chonk interface {
5758
// as a hint to pick the correct chain in the event that the Chonk stores
5859
// multiple distinct chains.
5960
LastActiveAncestor() (*AUMHash, error)
61+
62+
// RemoveAll permanently and completely clears the TKA state. This should
63+
// be called when the user disables Tailnet Lock.
64+
RemoveAll() error
6065
}
6166

6267
// CompactableChonk implementation are extensions of Chonk, which are
@@ -78,12 +83,21 @@ type CompactableChonk interface {
7883
}
7984

8085
// Mem implements in-memory storage of TKA state, suitable for
81-
// tests.
86+
// tests or cases where filesystem storage is unavailable.
8287
//
8388
// Mem implements the Chonk interface.
89+
//
90+
// Mem is thread-safe.
8491
type Mem struct {
8592
mu sync.RWMutex
8693
aums map[AUMHash]AUM
94+
commitTimes map[AUMHash]time.Time
95+
96+
// parentIndex is a map of AUMs to the AUMs for which they are
97+
// the parent.
98+
//
99+
// For example, if parent index is {1 -> {2, 3, 4}}, that means
100+
// that AUMs 2, 3, 4 all have aum.PrevAUMHash = 1.
87101
parentIndex map[AUMHash][]AUMHash
88102

89103
lastActiveAncestor *AUMHash
@@ -152,12 +166,14 @@ func (c *Mem) CommitVerifiedAUMs(updates []AUM) error {
152166
if c.aums == nil {
153167
c.parentIndex = make(map[AUMHash][]AUMHash, 64)
154168
c.aums = make(map[AUMHash]AUM, 64)
169+
c.commitTimes = make(map[AUMHash]time.Time, 64)
155170
}
156171

157172
updateLoop:
158173
for _, aum := range updates {
159174
aumHash := aum.Hash()
160175
c.aums[aumHash] = aum
176+
c.commitTimes[aumHash] = time.Now()
161177

162178
parent, ok := aum.Parent()
163179
if ok {
@@ -173,6 +189,71 @@ updateLoop:
173189
return nil
174190
}
175191

192+
// RemoveAll permanently and completely clears the TKA state.
193+
func (c *Mem) RemoveAll() error {
194+
c.mu.Lock()
195+
defer c.mu.Unlock()
196+
c.aums = nil
197+
c.commitTimes = nil
198+
c.parentIndex = nil
199+
c.lastActiveAncestor = nil
200+
return nil
201+
}
202+
203+
// AllAUMs returns all AUMs stored in the chonk.
204+
func (c *Mem) AllAUMs() ([]AUMHash, error) {
205+
c.mu.RLock()
206+
defer c.mu.RUnlock()
207+
208+
return slices.Collect(maps.Keys(c.aums)), nil
209+
}
210+
211+
// CommitTime returns the time at which the AUM was committed.
212+
//
213+
// If the AUM does not exist, then os.ErrNotExist is returned.
214+
func (c *Mem) CommitTime(h AUMHash) (time.Time, error) {
215+
c.mu.RLock()
216+
defer c.mu.RUnlock()
217+
218+
t, ok := c.commitTimes[h]
219+
if ok {
220+
return t, nil
221+
} else {
222+
return time.Time{}, os.ErrNotExist
223+
}
224+
}
225+
226+
// PurgeAUMs marks the specified AUMs for deletion from storage.
227+
func (c *Mem) PurgeAUMs(hashes []AUMHash) error {
228+
c.mu.Lock()
229+
defer c.mu.Unlock()
230+
231+
for _, h := range hashes {
232+
// Remove the deleted AUM from the list of its parents' children.
233+
//
234+
// However, we leave the list of this AUM's children in parentIndex,
235+
// so we can find them later in ChildAUMs().
236+
if aum, ok := c.aums[h]; ok {
237+
parent, hasParent := aum.Parent()
238+
if hasParent {
239+
c.parentIndex[parent] = slices.DeleteFunc(
240+
c.parentIndex[parent],
241+
func(other AUMHash) bool { return bytes.Equal(h[:], other[:]) },
242+
)
243+
if len(c.parentIndex[parent]) == 0 {
244+
delete(c.parentIndex, parent)
245+
}
246+
}
247+
}
248+
249+
// Delete this AUM from the list of AUMs and commit times.
250+
delete(c.aums, h)
251+
delete(c.commitTimes, h)
252+
}
253+
254+
return nil
255+
}
256+
176257
// FS implements filesystem storage of TKA state.
177258
//
178259
// FS implements the Chonk interface.
@@ -184,6 +265,10 @@ type FS struct {
184265
// ChonkDir returns an implementation of Chonk which uses the
185266
// given directory to store TKA state.
186267
func ChonkDir(dir string) (*FS, error) {
268+
if err := os.MkdirAll(dir, 0755); err != nil && !os.IsExist(err) {
269+
return nil, fmt.Errorf("creating chonk root dir: %v", err)
270+
}
271+
187272
stat, err := os.Stat(dir)
188273
if err != nil {
189274
return nil, err
@@ -376,6 +461,11 @@ func (c *FS) Heads() ([]AUM, error) {
376461
return out, nil
377462
}
378463

464+
// RemoveAll permanently and completely clears the TKA state.
465+
func (c *FS) RemoveAll() error {
466+
return os.RemoveAll(c.base)
467+
}
468+
379469
// AllAUMs returns all AUMs stored in the chonk.
380470
func (c *FS) AllAUMs() ([]AUMHash, error) {
381471
c.mu.RLock()

tka/tailchonk_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,43 @@ func TestTailchonkFS_IgnoreTempFile(t *testing.T) {
127127
}
128128
}
129129

130+
// If we use a non-existent directory with filesystem Chonk storage,
131+
// it's automatically created.
132+
func TestTailchonkFS_CreateChonkDir(t *testing.T) {
133+
base := filepath.Join(t.TempDir(), "a", "b", "c")
134+
135+
chonk, err := ChonkDir(base)
136+
if err != nil {
137+
t.Fatalf("ChonkDir: %v", err)
138+
}
139+
140+
aum := AUM{MessageKind: AUMNoOp}
141+
must.Do(chonk.CommitVerifiedAUMs([]AUM{aum}))
142+
143+
got, err := chonk.AUM(aum.Hash())
144+
if err != nil {
145+
t.Errorf("Chonk.AUM: %v", err)
146+
}
147+
if diff := cmp.Diff(got, aum); diff != "" {
148+
t.Errorf("wrong AUM; (-got+want):%v", diff)
149+
}
150+
151+
if _, err := os.Stat(base); err != nil {
152+
t.Errorf("os.Stat: %v", err)
153+
}
154+
}
155+
156+
// You can't use a file as the root of your filesystem Chonk storage.
157+
func TestTailchonkFS_CannotUseFile(t *testing.T) {
158+
base := filepath.Join(t.TempDir(), "tka_storage.txt")
159+
must.Do(os.WriteFile(base, []byte("this won't work"), 0644))
160+
161+
_, err := ChonkDir(base)
162+
if err == nil {
163+
t.Fatal("ChonkDir succeeded; expected an error")
164+
}
165+
}
166+
130167
func TestMarkActiveChain(t *testing.T) {
131168
type aumTemplate struct {
132169
AUM AUM

tstest/chonktest/tailchonk_test.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@ func TestImplementsCompactableChonk(t *testing.T) {
3939
name string
4040
newChonk func(t *testing.T) tka.CompactableChonk
4141
}{
42+
{
43+
name: "Mem",
44+
newChonk: func(t *testing.T) tka.CompactableChonk {
45+
return &tka.Mem{}
46+
},
47+
},
4248
{
4349
name: "FS",
4450
newChonk: func(t *testing.T) tka.CompactableChonk {

0 commit comments

Comments
 (0)