summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorJames 'zofrex' Sanderson <jsanderson@tailscale.com>2026-04-20 16:00:03 +0100
committerGitHub <noreply@github.com>2026-04-20 16:00:03 +0100
commitffae275d4da31d1992a78791e8502e5dec275d31 (patch)
treef5597dba0fdb0f7b27e363a2bb9f6b43e29daef8
parentec86f0ff93f571478af1277712f74558db0325fd (diff)
downloadtailscale-ffae275d4da31d1992a78791e8502e5dec275d31.tar.xz
tailscale-ffae275d4da31d1992a78791e8502e5dec275d31.zip
ipn/ipnlocal,tailcfg: add /debug/tka c2n endpoint (#19198)
Updates tailscale/corp#35015 Signed-off-by: James Sanderson <jsanderson@tailscale.com>
-rw-r--r--cmd/tailscaled/depaware.txt2
-rw-r--r--feature/condregister/maybe_tailnetlock.go8
-rw-r--r--feature/tailnetlock/tailnetlock.go54
-rw-r--r--feature/tailnetlock/tailnetlock_test.go146
-rw-r--r--ipn/ipnlocal/c2n.go8
-rw-r--r--ipn/ipnlocal/network-lock.go32
-rw-r--r--tailcfg/tailcfg.go3
7 files changed, 252 insertions, 1 deletions
diff --git a/cmd/tailscaled/depaware.txt b/cmd/tailscaled/depaware.txt
index 35f93380f..7a72c950e 100644
--- a/cmd/tailscaled/depaware.txt
+++ b/cmd/tailscaled/depaware.txt
@@ -259,6 +259,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/client/web from tailscale.com/ipn/ipnlocal
tailscale.com/clientupdate from tailscale.com/feature/clientupdate
LW tailscale.com/clientupdate/distsign from tailscale.com/clientupdate
+ tailscale.com/cmd/tailscale/cli/jsonoutput from tailscale.com/feature/tailnetlock
tailscale.com/cmd/tailscaled/childproc from tailscale.com/cmd/tailscaled+
tailscale.com/cmd/tailscaled/tailscaledhooks from tailscale.com/cmd/tailscaled+
tailscale.com/control/controlbase from tailscale.com/control/controlhttp+
@@ -307,6 +308,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
LD tailscale.com/feature/ssh from tailscale.com/cmd/tailscaled
tailscale.com/feature/syspolicy from tailscale.com/feature/condregister+
tailscale.com/feature/taildrop from tailscale.com/feature/condregister
+ tailscale.com/feature/tailnetlock from tailscale.com/feature/condregister
L tailscale.com/feature/tap from tailscale.com/feature/condregister
tailscale.com/feature/tpm from tailscale.com/feature/condregister
L 💣 tailscale.com/feature/tundevstats from tailscale.com/feature/condregister
diff --git a/feature/condregister/maybe_tailnetlock.go b/feature/condregister/maybe_tailnetlock.go
new file mode 100644
index 000000000..80a3dffe3
--- /dev/null
+++ b/feature/condregister/maybe_tailnetlock.go
@@ -0,0 +1,8 @@
+// Copyright (c) Tailscale Inc & contributors
+// SPDX-License-Identifier: BSD-3-Clause
+
+//go:build !ts_omit_tailnetlock
+
+package condregister
+
+import _ "tailscale.com/feature/tailnetlock"
diff --git a/feature/tailnetlock/tailnetlock.go b/feature/tailnetlock/tailnetlock.go
new file mode 100644
index 000000000..325a13b08
--- /dev/null
+++ b/feature/tailnetlock/tailnetlock.go
@@ -0,0 +1,54 @@
+// Copyright (c) Tailscale Inc & contributors
+// SPDX-License-Identifier: BSD-3-Clause
+
+// package tailnetlock registers the tailnet lock debug C2N handler. In the
+// future, all tailnet lock code should move here.
+package tailnetlock
+
+import (
+ "fmt"
+ "net/http"
+ "strconv"
+
+ "tailscale.com/cmd/tailscale/cli/jsonoutput"
+ "tailscale.com/feature"
+ "tailscale.com/feature/buildfeatures"
+ "tailscale.com/ipn/ipnlocal"
+)
+
+func init() {
+ feature.Register("tailnetlock")
+ ipnlocal.RegisterC2N("/debug/tka/log", handleC2NDebugTKALog)
+}
+
+const defaultC2NLogLimit = 50
+const maxC2NLogLimit = 1000
+
+func handleC2NDebugTKALog(b *ipnlocal.LocalBackend, w http.ResponseWriter, r *http.Request) {
+ if !buildfeatures.HasDebug {
+ http.Error(w, feature.ErrUnavailable.Error(), http.StatusNotImplemented)
+ return
+ }
+
+ logf := b.Logger()
+ logf("c2n: %s %s received", r.Method, r.URL)
+
+ limit := defaultC2NLogLimit
+ limitStr := r.URL.Query().Get("limit")
+ if limitStr != "" {
+ if parsed, err := strconv.Atoi(limitStr); err == nil {
+ limit = min(parsed, maxC2NLogLimit)
+ }
+ }
+
+ updates, err := b.NetworkLockLog(limit)
+ if ipnlocal.IsNetworkLockNotActive(err) {
+ http.Error(w, "tailnet lock not active", http.StatusBadRequest)
+ return
+ } else if err != nil {
+ http.Error(w, fmt.Sprintf("failed to get tailnet lock log: %v", err), http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ jsonoutput.PrintNetworkLockLogJSONV1(w, updates)
+}
diff --git a/feature/tailnetlock/tailnetlock_test.go b/feature/tailnetlock/tailnetlock_test.go
new file mode 100644
index 000000000..771525d9d
--- /dev/null
+++ b/feature/tailnetlock/tailnetlock_test.go
@@ -0,0 +1,146 @@
+// Copyright (c) Tailscale Inc & contributors
+// SPDX-License-Identifier: BSD-3-Clause
+
+package tailnetlock
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "tailscale.com/ipn/ipnlocal"
+ "tailscale.com/tka"
+ "tailscale.com/types/key"
+ "tailscale.com/util/must"
+)
+
+func TestHandleC2NDebugTKA(t *testing.T) {
+ makeTKA := func(length int) (tka.CompactableChonk, *tka.Authority) {
+ if length == 0 {
+ return nil, nil
+ }
+
+ disablementSecret := bytes.Repeat([]byte{0xa5}, 32)
+ signerKey := key.NewNLPrivate()
+ key1 := tka.Key{Kind: tka.Key25519, Public: signerKey.Public().Verifier(), Votes: 2}
+
+ chonk := tka.ChonkMem()
+ authority, _, err := tka.Create(chonk, tka.State{
+ Keys: []tka.Key{key1},
+ DisablementValues: [][]byte{tka.DisablementKDF(disablementSecret)},
+ }, signerKey)
+ if err != nil {
+ t.Fatalf("tka.Create() failed: %v", err)
+ }
+
+ for range length - 1 {
+ updater := authority.NewUpdater(signerKey)
+ key2 := tka.Key{Kind: tka.Key25519, Public: key.NewNLPrivate().Public().Verifier(), Votes: 2}
+ updater.AddKey(key2)
+ aums := must.Get(updater.Finalize(chonk))
+ must.Do(authority.Inform(chonk, aums))
+ }
+
+ return chonk, authority
+ }
+
+ bodyHead := func(body *bytes.Buffer) string {
+ count := 0
+ var sb strings.Builder
+ for line := range strings.Lines(body.String()) {
+ if count == 10 {
+ sb.WriteString("...")
+ break
+ }
+ sb.WriteString(line)
+ count++
+ }
+ return sb.String()
+ }
+
+ // matches [jsonoutput.PrintNetworkLockLogJSONV1]
+ type response struct {
+ SchemaVersion string
+ Messages []any
+ }
+
+ t.Run("tailnet-lock-disabled", func(t *testing.T) {
+ b := ipnlocal.LocalBackendWithTKAForTest(nil, nil)
+
+ req := httptest.NewRequest("GET", "/debug/tka/log", nil)
+ rec := httptest.NewRecorder()
+ b.HandleC2NForTest(rec, req)
+
+ if rec.Code != 400 {
+ t.Fatalf("got status code: %v, want: 400\nBody: %s", rec.Code, rec.Body)
+ }
+ })
+
+ t.Run("tailnet-lock-enabled", func(t *testing.T) {
+ chonk, authority := makeTKA(2)
+ b := ipnlocal.LocalBackendWithTKAForTest(chonk, authority)
+
+ req := httptest.NewRequest("GET", "/debug/tka/log", nil)
+ rec := httptest.NewRecorder()
+ b.HandleC2NForTest(rec, req)
+
+ if rec.Code != 200 {
+ t.Fatalf("got status code: %v, want: 200\nBody: %s", rec.Code, bodyHead(rec.Body))
+ }
+
+ var got response
+ if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
+ t.Fatalf("couldn't parse JSON: %v\nbody: %s", err, bodyHead(rec.Body))
+ }
+
+ if len(got.Messages) != 2 {
+ t.Fatalf("got %d items, want 2", len(got.Messages))
+ }
+ })
+
+ t.Run("default-limit", func(t *testing.T) {
+ chonk, authority := makeTKA(60)
+ b := ipnlocal.LocalBackendWithTKAForTest(chonk, authority)
+
+ req := httptest.NewRequest("GET", "/debug/tka/log", nil)
+ rec := httptest.NewRecorder()
+ b.HandleC2NForTest(rec, req)
+
+ if rec.Code != 200 {
+ t.Fatalf("got status code: %v, want: 200\nBody: %s", rec.Code, bodyHead(rec.Body))
+ }
+
+ var got response
+ if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
+ t.Fatalf("couldn't parse JSON: %v\nbody: %s", err, bodyHead(rec.Body))
+ }
+
+ if len(got.Messages) != 50 {
+ t.Fatalf("got %d items, want 50", len(got.Messages))
+ }
+ })
+
+ t.Run("override-limit", func(t *testing.T) {
+ chonk, authority := makeTKA(65)
+ b := ipnlocal.LocalBackendWithTKAForTest(chonk, authority)
+
+ req := httptest.NewRequest("GET", "/debug/tka/log?limit=60", nil)
+ rec := httptest.NewRecorder()
+ b.HandleC2NForTest(rec, req)
+
+ if rec.Code != 200 {
+ t.Fatalf("got status code: %v, want: 200\nBody: %s", rec.Code, bodyHead(rec.Body))
+ }
+
+ var got response
+ if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
+ t.Fatalf("couldn't parse JSON: %v\nbody: %s", err, bodyHead(rec.Body))
+ }
+
+ if len(got.Messages) != 60 {
+ t.Fatalf("got %d items, want 60", len(got.Messages))
+ }
+ })
+}
diff --git a/ipn/ipnlocal/c2n.go b/ipn/ipnlocal/c2n.go
index 8284872b9..bf8cf2e03 100644
--- a/ipn/ipnlocal/c2n.go
+++ b/ipn/ipnlocal/c2n.go
@@ -27,6 +27,7 @@ import (
"tailscale.com/util/goroutines"
"tailscale.com/util/httpm"
"tailscale.com/util/set"
+ "tailscale.com/util/testenv"
"tailscale.com/version"
)
@@ -323,3 +324,10 @@ func handleC2NSetNetfilterKind(b *LocalBackend, w http.ResponseWriter, r *http.R
w.WriteHeader(http.StatusNoContent)
}
+
+// HandleC2NForTest calls [handleC2N], for use by feature/ packages that
+// register C2N handlers and want to test them.
+func (b *LocalBackend) HandleC2NForTest(w http.ResponseWriter, r *http.Request) {
+ testenv.AssertInTest()
+ b.handleC2N(w, r)
+}
diff --git a/ipn/ipnlocal/network-lock.go b/ipn/ipnlocal/network-lock.go
index 81d6e275a..12711b259 100644
--- a/ipn/ipnlocal/network-lock.go
+++ b/ipn/ipnlocal/network-lock.go
@@ -27,6 +27,7 @@ import (
"tailscale.com/health/healthmsg"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
+ "tailscale.com/ipn/store/mem"
"tailscale.com/net/tsaddr"
"tailscale.com/tailcfg"
"tailscale.com/tka"
@@ -38,6 +39,7 @@ import (
"tailscale.com/types/tkatype"
"tailscale.com/util/mak"
"tailscale.com/util/set"
+ "tailscale.com/util/testenv"
)
// TODO(tom): RPC retry/backoff was broken and has been removed. Fix?
@@ -47,6 +49,13 @@ var (
errNetworkLockNotActive = errors.New("tailnet-lock is not active")
)
+// IsNetworkLockNotActive reports whether the given error indicates that
+// network-lock is not active. Stop-gap for feature/tailnetlock to check this
+// until all of this is code is moved to the feature.
+func IsNetworkLockNotActive(err error) bool {
+ return errors.Is(err, errNetworkLockNotActive)
+}
+
type tkaState struct {
profile ipn.ProfileID
authority *tka.Authority
@@ -702,6 +711,7 @@ func (b *LocalBackend) NetworkLockAllowed() bool {
// Only use is in tests.
func (b *LocalBackend) NetworkLockVerifySignatureForTest(nks tkatype.MarshaledSignature, nodeKey key.NodePublic) error {
+ testenv.AssertInTest()
b.mu.Lock()
defer b.mu.Unlock()
if b.tka == nil {
@@ -712,6 +722,7 @@ func (b *LocalBackend) NetworkLockVerifySignatureForTest(nks tkatype.MarshaledSi
// Only use is in tests.
func (b *LocalBackend) NetworkLockKeyTrustedForTest(keyID tkatype.KeyID) bool {
+ testenv.AssertInTest()
b.mu.Lock()
defer b.mu.Unlock()
if b.tka == nil {
@@ -1481,3 +1492,24 @@ func (b *LocalBackend) tkaReadAffectedSigs(ourNodeKey key.NodePublic, key tkatyp
return a, nil
}
+
+// LocalBackendWithTKAForTest creates a LocalBackend with an initialized TKA
+// state for testing tailnet lock from the feature/tailnetlock package. Will be
+// removed when tailnet lock is fully moved to its own package. Do not use this
+// from any other package.
+func LocalBackendWithTKAForTest(chonk tka.CompactableChonk, tka *tka.Authority) *LocalBackend {
+ testenv.AssertInTest()
+
+ var state *tkaState
+ if tka != nil {
+ state = &tkaState{
+ authority: tka,
+ storage: chonk,
+ }
+ }
+ return &LocalBackend{
+ store: &mem.Store{},
+ logf: logger.Discard,
+ tka: state,
+ }
+}
diff --git a/tailcfg/tailcfg.go b/tailcfg/tailcfg.go
index 7405ec0e5..da218837a 100644
--- a/tailcfg/tailcfg.go
+++ b/tailcfg/tailcfg.go
@@ -184,7 +184,8 @@ type CapabilityVersion int
// - 135: 2026-03-30: Client understands [NodeAttrCacheNetworkMaps]
// - 136: 2026-04-09: Client understands [NodeAttrDisableLinuxCGNATDropRule]
// - 137: 2026-04-15: Client handles 429 responses to /machine/register.
-const CurrentCapabilityVersion CapabilityVersion = 137
+// - 138: 2026-03-31: can handle C2N /debug/tka.
+const CurrentCapabilityVersion CapabilityVersion = 138
// ID is an integer ID for a user, node, or login allocated by the
// control plane.