From 00b9e8d8ce37c7ef8f2811deaa125e179f47ea27 Mon Sep 17 00:00:00 2001 From: Simon Law Date: Thu, 18 Jun 2026 10:27:16 -0700 Subject: [PATCH] ipn: add fmt.Stringer support to NotifyWatchOpt (#20072) This patch adds support for the fmt.Stringer interface to the ipn.NotifyWatchOpt enum. This is useful when debugging these bitmasks. For example: fmt.Printf("%s", ipn.NotifyPeerChanges | ipn.NotifyNoNetMap) // Output: (ipn.NotifyPeerChanges | ipn.NotifyNoNetMap) Fixes #20066 Signed-off-by: Simon Law --- client/local/local.go | 6 +- ipn/backend.go | 76 +++++++++++++++++++++ ipn/backend_test.go | 139 +++++++++++++++++++++++++++++++++++++++ ipn/localapi/localapi.go | 4 +- 4 files changed, 221 insertions(+), 4 deletions(-) diff --git a/client/local/local.go b/client/local/local.go index 50050eb1b..51817f63b 100644 --- a/client/local/local.go +++ b/client/local/local.go @@ -1365,8 +1365,12 @@ func (lc *Client) StreamDebugCapture(ctx context.Context) (io.ReadCloser, error) // // A default set of ipn.Notify messages are returned but the set can be modified by mask. func (lc *Client) WatchIPNBus(ctx context.Context, mask ipn.NotifyWatchOpt) (*IPNBusWatcher, error) { + m, err := mask.MarshalText() + if err != nil { + return nil, err + } req, err := http.NewRequestWithContext(ctx, "GET", - "http://"+apitype.LocalAPIHost+"/localapi/v0/watch-ipn-bus?mask="+fmt.Sprint(mask), + "http://"+apitype.LocalAPIHost+"/localapi/v0/watch-ipn-bus?mask="+string(m), nil) if err != nil { return nil, err diff --git a/ipn/backend.go b/ipn/backend.go index b38c47099..4cfbe7eb1 100644 --- a/ipn/backend.go +++ b/ipn/backend.go @@ -6,6 +6,7 @@ import ( "fmt" "slices" + "strconv" "strings" "time" @@ -180,6 +181,81 @@ type EngineStatus struct { NotifyPeerWireGuardState NotifyWatchOpt = 1 << 18 ) +// String implements the [fmt.Stringer] interface. +// Returns the string representation of all the bits joined by the bitwise-or "|" operator. +func (o NotifyWatchOpt) String() string { + if o == NotifyWatchOpt(0) { + return fmt.Sprintf("%T(%#x)", o, uint64(o)) + } + + pkg, _, found := strings.Cut(fmt.Sprintf("%T", o), ".") + + var bits []string + var mask NotifyWatchOpt + try := func(bit NotifyWatchOpt, s string) { + if o&bit == 0 { + return + } + if found { + bits = append(bits, pkg+"."+s) + } else { + bits = append(bits, s) + } + mask |= bit + } + try(NotifyWatchEngineUpdates, "NotifyWatchEngineUpdates") + try(NotifyInitialState, "NotifyInitialState") + try(NotifyInitialPrefs, "NotifyInitialPrefs") + try(NotifyInitialNetMap, "NotifyInitialNetMap") + try(NotifyNoPrivateKeys, "NotifyNoPrivateKeys") + try(NotifyInitialDriveShares, "NotifyInitialDriveShares") + try(NotifyInitialOutgoingFiles, "NotifyInitialOutgoingFiles") + try(NotifyInitialHealthState, "NotifyInitialHealthState") + try(NotifyRateLimit, "NotifyRateLimit") + try(NotifyHealthActions, "NotifyHealthActions") + try(NotifyInitialSuggestedExitNode, "NotifyInitialSuggestedExitNode") + try(NotifyInitialClientVersion, "NotifyInitialClientVersion") + try(NotifyPeerChanges, "NotifyPeerChanges") + try(NotifyNoNetMap, "NotifyNoNetMap") + try(NotifyInitialStatus, "NotifyInitialStatus") + try(NotifyPeerPatches, "NotifyPeerPatches") + try(NotifyInProcessNoDisconnect, "NotifyInProcessNoDisconnect") + try(NotifyPeerWireGuardState, "NotifyPeerWireGuardState") + + if mask != o { + bits = append(bits, fmt.Sprintf("%T(%#x)", o, uint64(o^mask))) // unknown + } + + if len(bits) == 1 { + return bits[0] + } + // Multiple values, so we need to wrap with parentheses. + return "(" + strings.Join(bits, " | ") + ")" +} + +// AppendText implements the [encoding.TextAppender] interface +// by encoding its textual representation. +func (o NotifyWatchOpt) AppendText(b []byte) ([]byte, error) { + return strconv.AppendUint(b, uint64(o), 10), nil +} + +// MarshalText implements the [encoding.TextMarshaler] interface +// by encoding its textual representation. +func (o NotifyWatchOpt) MarshalText() (text []byte, err error) { + return o.AppendText(nil) +} + +// UnmarshalText implements the [encoding.TextUnmarshaler] interface +// by decoding its textual representation. +func (o *NotifyWatchOpt) UnmarshalText(text []byte) error { + v, err := strconv.ParseUint(string(text), 10, 64) + if err != nil { + return err + } + *o = NotifyWatchOpt(v) + return nil +} + // NotifyRateLimitIncompatibleBits is the set of new-style IPN bus // subscription bits that cannot be combined with [NotifyRateLimit]. // diff --git a/ipn/backend_test.go b/ipn/backend_test.go index f6ad29011..199901fca 100644 --- a/ipn/backend_test.go +++ b/ipn/backend_test.go @@ -5,10 +5,19 @@ import ( "encoding/json" + "go/types" + "maps" + "math/bits" + "slices" + "strconv" + "strings" "testing" + "golang.org/x/tools/go/packages" + "tailscale.com/health" "tailscale.com/types/empty" + "tailscale.com/util/mak" ) func TestNotifyString(t *testing.T) { @@ -126,3 +135,133 @@ func TestValidateNotifyWatchOpt(t *testing.T) { }) } } + +func TestNotifyWatchOptString(t *testing.T) { + consts := findNotifyWatchOptConstants(t) + t.Logf("consts = %#v", consts) + + t.Run("zero", func(t *testing.T) { + var zero NotifyWatchOpt + want := "ipn.NotifyWatchOpt(0x0)" + if got := zero.String(); got != want { + t.Errorf("NotifyWatchOpt(%#v).String() = %q, want %q", zero, got, want) + } + }) + + t.Run("unknown", func(t *testing.T) { + msb := NotifyWatchOpt(1 << 63) + want := "ipn.NotifyWatchOpt(0x8000000000000000)" + if got := msb.String(); got != want { + t.Errorf("NotifyWatchOpt(%#v).String() = %q, want %q", msb, got, want) + } + }) + + t.Run("simple", func(t *testing.T) { + for _, c := range slices.Sorted(maps.Keys(consts)) { + if bits.OnesCount64(uint64(c)) > 1 { + continue // multiple bits comes later + } + want := "ipn." + consts[c] + if got := c.String(); got != want { + t.Errorf("NotifyWatchOpt(%#v).String() = %q, want %q", c, got, want) + } + } + }) + + t.Run("composite", func(t *testing.T) { + for _, tc := range []struct { + name string + value NotifyWatchOpt + want string + }{ + { + name: "single", + value: NotifyWatchEngineUpdates, + want: "ipn.NotifyWatchEngineUpdates", + }, + { + name: "double", + value: NotifyWatchEngineUpdates | NotifyInitialState, + want: "(ipn.NotifyWatchEngineUpdates | ipn.NotifyInitialState)", + }, + { + name: "triple", + value: NotifyWatchEngineUpdates | NotifyInitialState | NotifyInitialPrefs, + want: "(ipn.NotifyWatchEngineUpdates | ipn.NotifyInitialState | ipn.NotifyInitialPrefs)", + }, + { + name: "unknown", + value: NotifyWatchEngineUpdates | NotifyWatchOpt(1<<63), + want: "(ipn.NotifyWatchEngineUpdates | ipn.NotifyWatchOpt(0x8000000000000000))", + }, + } { + t.Run(tc.name, func(t *testing.T) { + if got := tc.value.String(); got != tc.want { + t.Errorf("NotifyWatchOpt(%#v).String() = %q, want %q", tc.value, got, tc.want) + } + }) + } + }) + + // Check that every named NotifyWatchOpt value is mapped inside [NotifyWatchOpt.String]. + t.Run("all", func(t *testing.T) { + var all NotifyWatchOpt + var names []string // names are sorted and only contain simple consts + for _, c := range slices.Sorted(maps.Keys(consts)) { + all |= c + if bits.OnesCount64(uint64(c)) == 1 { + names = append(names, "ipn."+consts[c]) + } + } + want := "(" + strings.Join(names, " | ") + ")" + if got := all.String(); got != want { + t.Errorf("all.String() = %q, want %q", got, want) + } + }) +} + +func findNotifyWatchOptConstants(t *testing.T) map[NotifyWatchOpt]string { + t.Helper() + + // Load the current package. + cfg := &packages.Config{ + Mode: packages.NeedTypes, + } + pkgs, err := packages.Load(cfg, ".") + if err != nil { + t.Fatalf("failed to load packages: %v", err) + } + + // Find all the [NotifyWatchOpt] constants that represent this enum. + var found map[NotifyWatchOpt]string + for _, pkg := range pkgs { + if len(pkg.Errors) > 0 { + t.Fatalf("package %s has errors: %v", pkg.Name, pkg.Errors) + } + + wantType := pkg.Types.Path() + ".NotifyWatchOpt" + scope := pkg.Types.Scope() + for _, name := range scope.Names() { + obj := scope.Lookup(name) + if obj == nil || obj.Type().String() != wantType { + continue + } + c, ok := obj.(*types.Const) + if !ok { + continue + } + s := c.Val().ExactString() + val, err := strconv.ParseUint(s, 10, 64) + if err != nil { + t.Fatalf("cannot parse %q: %v", s, err) + } + mak.Set(&found, NotifyWatchOpt(val), name) + } + } + + if len(found) == 0 { + t.Fatal("could not find NotifyWatchOpt constants") + } + + return found +} diff --git a/ipn/localapi/localapi.go b/ipn/localapi/localapi.go index ebbe3506d..4ef4b33e4 100644 --- a/ipn/localapi/localapi.go +++ b/ipn/localapi/localapi.go @@ -896,12 +896,10 @@ func (h *Handler) serveWatchIPNBus(w http.ResponseWriter, r *http.Request) { var mask ipn.NotifyWatchOpt if s := r.FormValue("mask"); s != "" { - v, err := strconv.ParseUint(s, 10, 64) - if err != nil { + if err := mask.UnmarshalText([]byte(s)); err != nil { http.Error(w, "bad mask", http.StatusBadRequest) return } - mask = ipn.NotifyWatchOpt(v) } if mask&ipn.NotifyInProcessNoDisconnect != 0 { http.Error(w, "NotifyInProcessNoDisconnect is only valid for in-process IPN bus subscribers", http.StatusBadRequest)