From 9630b5196abf7eb2401b08b2e035fb71f8ee18ff Mon Sep 17 00:00:00 2001 From: Oli Strik Date: Mon, 4 May 2026 15:09:40 +0200 Subject: [PATCH] feat: add support for grant replacement Signed-off-by: Oli Strik --- .gitignore | 2 + README.md | 50 +++++++++++---- auth.go | 57 +++++++++++++++++ cel_test.go | 174 ++++++++++++++++++++++++++++++++++++++++++++++++++++ go.sum | 19 ------ 5 files changed, 272 insertions(+), 30 deletions(-) create mode 100644 .gitignore create mode 100644 cel_test.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3195517 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*.test +*.test.exe diff --git a/README.md b/README.md index 08d5fb1..5a8ebfe 100644 --- a/README.md +++ b/README.md @@ -329,25 +329,53 @@ The following fields are set on the Caddy user object: - `user.tailscale_name`: the display name of the Tailscale user - `user.tailscale_profile_picture`: the URL of the Tailscale user's profile picture - `user.tailscale_tailnet`: the name of the Tailscale network the user is a member of +- `user.tailscale_grants`: JSON string of the peer's granted [application capabilities](#application-capabilities), if any These values can be mapped to HTTP headers that are then passed to an application that supports proxy authentication such as [Gitea] or [Grafana]. -You might have something like the following in your Caddyfile: -```caddyfile -:80 { - bind tailscale/gitea - tailscale_auth - reverse_proxy http://localhost:3000 { - header_up X-Webauth-User {http.auth.user.tailscale_login} - header_up X-Webauth-Email {http.auth.user.tailscale_user} - header_up X-Webauth-Name {http.auth.user.tailscale_name} +When used with a Tailscale listener (described above), that Tailscale node is used to identify the remote user. +Otherwise, the authentication provider will attempt to connect to the Tailscale daemon running on the local machine. + +## Application Capabilities + +[Tailscale application capabilities](https://tailscale.com/kb/1537/grants-app-capabilities) (grants) allow you to pass +application-specific data defined in your Tailscale ACL policy file to your Caddy site. +These are exposed through the `{tailscale.grants}` [Caddy placeholder](https://caddyserver.com/docs/conventions#placeholders) +for use in [CEL expression matchers](https://caddyserver.com/docs/caddyfile/matchers#expression). + +For example, given the following grant in your Tailscale ACL policy: + +```json +{ + "src": "autogroup:admins", + "dst": "tag:caddy", + "app": { + "example.com/app": [ + { "admin": true } + ] } } ``` -When used with a Tailscale listener (described above), that Tailscale node is used to identify the remote user. -Otherwise, the authentication provider will attempt to connect to the Tailscale daemon running on the local machine. +You can route requests based on grant values in a Caddyfile: + +```caddyfile +handle /admin { + @admin expression `{tailscale.grants}["example.com/app"][0].admin == true` + respond @admin "Welcome admin" + respond "not authorized" 401 +} +``` + +Or forward the grants to a backend application using the user metadata: + +```caddyfile +reverse_proxy localhost:3000 { + header_up X-Tailscale-User {http.auth.user.tailscale_login} + header_up X-Tailscale-App-Caps {http.auth.user.tailscale_grants} +} +``` [tagged devices]: https://tailscale.com/kb/1068/acl-tags [Gitea]: https://docs.gitea.com/usage/authentication#reverse-proxy diff --git a/auth.go b/auth.go index c84ac09..d9f0afc 100644 --- a/auth.go +++ b/auth.go @@ -6,6 +6,7 @@ package tscaddy // auth.go contains the TailscaleAuth module and supporting logic. import ( + "encoding/json" "fmt" "net" "net/http" @@ -18,6 +19,7 @@ import ( "github.com/caddyserver/caddy/v2/modules/caddyhttp" "github.com/caddyserver/caddy/v2/modules/caddyhttp/caddyauth" "tailscale.com/client/local" + "tailscale.com/tailcfg" "tailscale.com/tsnet" ) @@ -132,6 +134,7 @@ type tsnetListener interface { // - tailscale_name: the user's display name // - tailscale_profile_picture: the user's profile picture URL // - tailscale_tailnet: the user's tailnet name (if the user is not connecting to a shared node) +// - tailscale_grants: JSON string of the peer's granted capabilities (if any) func (ta Auth) Authenticate(w http.ResponseWriter, r *http.Request) (caddyauth.User, bool, error) { user := caddyauth.User{} @@ -156,6 +159,31 @@ func (ta Auth) Authenticate(w http.ResponseWriter, r *http.Request) (caddyauth.U } } + // Extract grants from CapMap + grants := extractGrants(info.CapMap) + + // Register replacer mapping for {tailscale.grants} + repl := r.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer) + repl.Map(func(key string) (any, bool) { + if key == "tailscale.grants" { + if len(grants) == 0 { + // Return empty map so CEL operations like "in" work without "no such overload" + return map[string]any{}, true + } + return grants, true + } + return nil, false + }) + + // Marshal grants to JSON for metadata + var grantsJSON string + if len(grants) > 0 { + b, err := json.Marshal(grants) + if err == nil { + grantsJSON = string(b) + } + } + user.ID = info.UserProfile.LoginName user.Metadata = map[string]string{ "tailscale_login": strings.Split(info.UserProfile.LoginName, "@")[0], @@ -163,10 +191,39 @@ func (ta Auth) Authenticate(w http.ResponseWriter, r *http.Request) (caddyauth.U "tailscale_name": info.UserProfile.DisplayName, "tailscale_profile_picture": info.UserProfile.ProfilePicURL, "tailscale_tailnet": tailnet, + "tailscale_grants": grantsJSON, } return user, true, nil } +// extractGrants filters the CapMap based on acceptAppCaps and returns a map suitable for JSON marshaling. +// If acceptAppCaps is empty, all capabilities are included. +func extractGrants(capMap tailcfg.PeerCapMap) map[string]any { + if len(capMap) == 0 { + return nil + } + + grants := make(map[string]any) + + for cap, vals := range capMap { + var parsed []any + for _, v := range vals { + var obj any + if err := json.Unmarshal([]byte(v), &obj); err == nil { + parsed = append(parsed, obj) + } else { + parsed = append(parsed, string(v)) + } + } + grants[string(cap)] = parsed + } + + if len(grants) == 0 { + return nil + } + return grants +} + func parseAuthConfig(_ httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) { var ta Auth diff --git a/cel_test.go b/cel_test.go new file mode 100644 index 0000000..72b7738 --- /dev/null +++ b/cel_test.go @@ -0,0 +1,174 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: Apache-2.0 + +package tscaddy + +import ( + "context" + "net/http/httptest" + "testing" + + "github.com/caddyserver/caddy/v2" + "github.com/caddyserver/caddy/v2/modules/caddyhttp" +) + +// TestTailscaleGrantsCEL verifies that the tailscale.grants placeholder +// can be used in CEL expressions via the expression matcher. +func TestTailscaleGrantsCEL(t *testing.T) { + testGrants := map[string]any{ + "example.com/app": []any{ + map[string]any{ + "admin": true, + "role": "admin", + }, + }, + "example.com/cap/sql": []any{ + map[string]any{ + "access": "read", + }, + }, + } + + req := httptest.NewRequest("GET", "http://example.com", nil) + + // Create a replacer with the tailscale.grants placeholder handler, + // as Auth.Authenticate does during request processing. + repl := caddy.NewReplacer() + repl.Map(func(key string) (any, bool) { + if key == "tailscale.grants" { + return testGrants, true + } + return nil, false + }) + ctx := context.WithValue(req.Context(), caddy.ReplacerCtxKey, repl) + req = req.WithContext(ctx) + + caddyCtx, cancel := caddy.NewContext(caddy.Context{Context: context.Background()}) + defer cancel() + + t.Run("in_operator_key_exists", func(t *testing.T) { + expr := &caddyhttp.MatchExpression{ + Expr: `"example.com/app" in {tailscale.grants}`, + } + if err := expr.Provision(caddyCtx); err != nil { + t.Fatalf("Provision failed: %v", err) + } + matches, err := expr.MatchWithError(req) + if err != nil { + t.Fatalf("CEL evaluation failed: %v", err) + } + if !matches { + t.Error("expected 'example.com/app' in grants") + } + }) + + t.Run("map_index_with_list_size", func(t *testing.T) { + expr := &caddyhttp.MatchExpression{ + Expr: `{tailscale.grants}["example.com/app"].size() == 1`, + } + if err := expr.Provision(caddyCtx); err != nil { + t.Fatalf("Provision failed: %v", err) + } + matches, err := expr.MatchWithError(req) + if err != nil { + t.Fatalf("CEL evaluation failed: %v", err) + } + if !matches { + t.Error("expected grants map indexing to return list with 1 element") + } + }) + + t.Run("deep_field_access", func(t *testing.T) { + // Access a nested field: grants["example.com/app"][0].admin == true + expr := &caddyhttp.MatchExpression{ + Expr: `{tailscale.grants}["example.com/app"][0].admin == true`, + } + if err := expr.Provision(caddyCtx); err != nil { + t.Fatalf("Provision failed: %v", err) + } + matches, err := expr.MatchWithError(req) + if err != nil { + t.Fatalf("CEL evaluation failed: %v", err) + } + if !matches { + t.Error("expected admin to be true") + } + }) + + t.Run("in_operator_missing_key", func(t *testing.T) { + expr := &caddyhttp.MatchExpression{ + Expr: `"example.com/nonexistent" in {tailscale.grants}`, + } + if err := expr.Provision(caddyCtx); err != nil { + t.Fatalf("Provision failed: %v", err) + } + matches, err := expr.MatchWithError(req) + if err != nil { + t.Fatalf("CEL evaluation failed: %v", err) + } + if matches { + t.Error("expected nonexistent key not to be in grants") + } + }) + + t.Run("grants_empty_when_not_set", func(t *testing.T) { + // Request without grants in context should get nil from replacer + emptyReq := httptest.NewRequest("GET", "http://example.com", nil) + emptyRepl := caddy.NewReplacer() + emptyCtx := context.WithValue(emptyReq.Context(), caddy.ReplacerCtxKey, emptyRepl) + emptyReq = emptyReq.WithContext(emptyCtx) + + emptyRepl.Map(func(key string) (any, bool) { + if key == "tailscale.grants" { + // Return empty map, as Auth.Authenticate now does + return map[string]any{}, true + } + return nil, false + }) + + // Check that in operator returns false (not an error) when grants are nil + expr := &caddyhttp.MatchExpression{ + Expr: `"example.com/app" in {tailscale.grants}`, + } + if err := expr.Provision(caddyCtx); err != nil { + t.Fatalf("Provision failed: %v", err) + } + matches, err := expr.MatchWithError(emptyReq) + if err != nil { + t.Fatalf("CEL evaluation failed with 'no such overload': %v", err) + } + if matches { + t.Error("expected in operator to return false when grants are empty") + } + }) + + t.Run("null_check_empty_grants", func(t *testing.T) { + // Same setup: empty grants + emptyReq := httptest.NewRequest("GET", "http://example.com", nil) + emptyRepl := caddy.NewReplacer() + emptyCtx := context.WithValue(emptyReq.Context(), caddy.ReplacerCtxKey, emptyRepl) + emptyReq = emptyReq.WithContext(emptyCtx) + + emptyRepl.Map(func(key string) (any, bool) { + if key == "tailscale.grants" { + return map[string]any{}, true + } + return nil, false + }) + + // Empty map is not null, so == null should be false + expr := &caddyhttp.MatchExpression{ + Expr: `{tailscale.grants} == null`, + } + if err := expr.Provision(caddyCtx); err != nil { + t.Fatalf("Provision failed: %v", err) + } + matches, err := expr.MatchWithError(emptyReq) + if err != nil { + t.Fatalf("CEL evaluation failed: %v", err) + } + if matches { + t.Error("expected tailscale.grants to not be null when grants are empty") + } + }) +} diff --git a/go.sum b/go.sum index 783fd49..71c7dee 100644 --- a/go.sum +++ b/go.sum @@ -619,8 +619,6 @@ golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDf golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= -golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= -golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/crypto/x509roots/fallback v0.0.0-20250305170421-49bf5b80c810 h1:V5+zy0jmgNYmK1uW/sPpBw8ioFvalrhaUrYWmu1Fpe4= @@ -640,8 +638,6 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= -golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -660,8 +656,6 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= -golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -684,8 +678,6 @@ golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -713,8 +705,6 @@ golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= @@ -727,8 +717,6 @@ golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8= golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= -golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= -golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -742,8 +730,6 @@ golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -760,8 +746,6 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= -golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -821,14 +805,11 @@ honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWh honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.5.1 h1:4bH5o3b5ZULQ4UrBmP+63W9r7qIkqJClEA9ko5YKx+I= honnef.co/go/tools v0.5.1/go.mod h1:e9irvo83WDG9/irijV44wr3tbhcFeRnfpVlRqVwpzMs= -honnef.co/go/tools v0.7.0-0.dev.0.20251022135355-8273271481d0 h1:5SXjd4ET5dYijLaf0O3aOenC0Z4ZafIWSpjUzsQaNho= howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= sourcegraph.com/sourcegraph/go-diff v0.5.0/go.mod h1:kuch7UrkMzY0X+p9CRK03kfuPQ2zzQcaEFbx8wA8rck= sourcegraph.com/sqs/pbtypes v0.0.0-20180604144634-d3ebe8f20ae4/go.mod h1:ketZ/q3QxT9HOBeFhu6RdvsftgpsbFHBF5Cas6cDKZ0= -tailscale.com v1.90.6 h1:EhYPiZP/xcLeinikaLA0kn4CQT3+z9SZ13IB/kzWhd4= -tailscale.com v1.90.6/go.mod h1:+9EX6pOGCNa6pxCVRhhlJLy/qnkDzOplFYpeZyYlCT0= tailscale.com v1.90.9 h1:foPasfgXCey5TGEFNeJbm2YeoyCYcrsg0TEHFrPhckA= tailscale.com v1.90.9/go.mod h1:+9EX6pOGCNa6pxCVRhhlJLy/qnkDzOplFYpeZyYlCT0=