WIP: rebase for 2026-05-18 #7
+72
-117
@@ -35,8 +35,10 @@ import (
|
|||||||
"tailscale.com/net/netutil"
|
"tailscale.com/net/netutil"
|
||||||
"tailscale.com/net/tsaddr"
|
"tailscale.com/net/tsaddr"
|
||||||
"tailscale.com/tailcfg"
|
"tailscale.com/tailcfg"
|
||||||
|
"tailscale.com/tsweb"
|
||||||
"tailscale.com/types/logger"
|
"tailscale.com/types/logger"
|
||||||
"tailscale.com/types/views"
|
"tailscale.com/types/views"
|
||||||
|
"tailscale.com/util/ctxkey"
|
||||||
"tailscale.com/util/httpm"
|
"tailscale.com/util/httpm"
|
||||||
"tailscale.com/util/syspolicy/policyclient"
|
"tailscale.com/util/syspolicy/policyclient"
|
||||||
"tailscale.com/version"
|
"tailscale.com/version"
|
||||||
@@ -527,45 +529,40 @@ func (s *Server) serveLoginAPI(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type apiHandler[data any] struct {
|
// handleJSON manages decoding the request's body JSON as data and passing it
|
||||||
s *Server
|
// on to the provided handler function.
|
||||||
w http.ResponseWriter
|
func handleJSON[data any](h func(ctx context.Context, data data) error) http.HandlerFunc {
|
||||||
r *http.Request
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer r.Body.Close()
|
||||||
// permissionCheck allows for defining whether a requesting peer's
|
var body data
|
||||||
// capabilities grant them access to make the given data update.
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
// If permissionCheck reports false, the request fails as unauthorized.
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
permissionCheck func(data data, peer peerCapabilities) bool
|
return
|
||||||
}
|
}
|
||||||
|
if err := h(r.Context(), body); err != nil {
|
||||||
// newHandler constructs a new api handler which restricts the given request
|
if httpErr, ok := errors.AsType[tsweb.HTTPError](err); ok {
|
||||||
// to the specified permission check. If the permission check fails for
|
tsweb.WriteHTTPError(w, r, httpErr)
|
||||||
// the peer associated with the request, an unauthorized error is returned
|
} else {
|
||||||
// to the client.
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
func newHandler[data any](s *Server, w http.ResponseWriter, r *http.Request, permissionCheck func(data data, peer peerCapabilities) bool) *apiHandler[data] {
|
}
|
||||||
return &apiHandler[data]{
|
return
|
||||||
s: s,
|
}
|
||||||
w: w,
|
w.WriteHeader(http.StatusOK)
|
||||||
r: r,
|
|
||||||
permissionCheck: permissionCheck,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// alwaysAllowed can be passed as the permissionCheck argument to newHandler
|
var contextKeyPeer = ctxkey.New("peer-capabilities", peerCapabilities{})
|
||||||
// for requests that are always allowed to complete regardless of a peer's
|
|
||||||
// capabilities.
|
|
||||||
func alwaysAllowed[data any](_ data, _ peerCapabilities) bool { return true }
|
|
||||||
|
|
||||||
func (a *apiHandler[data]) getPeer() (peerCapabilities, error) {
|
func (s *Server) setPeer(r *http.Request) (*http.Request, error) {
|
||||||
// TODO(tailscale/corp#16695,sonia): We also call StatusWithoutPeers and
|
// TODO(tailscale/corp#16695,sonia): We also call StatusWithoutPeers and
|
||||||
// WhoIs when originally checking for a session from authorizeRequest.
|
// WhoIs when originally checking for a session from authorizeRequest.
|
||||||
// Would be nice if we could pipe those through to here so we don't end
|
// Would be nice if we could pipe those through to here so we don't end
|
||||||
// up having to re-call them to grab the peer capabilities.
|
// up having to re-call them to grab the peer capabilities.
|
||||||
status, err := a.s.lc.StatusWithoutPeers(a.r.Context())
|
status, err := s.lc.StatusWithoutPeers(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
whois, err := a.s.lc.WhoIs(a.r.Context(), a.r.RemoteAddr)
|
whois, err := s.lc.WhoIs(r.Context(), r.RemoteAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -573,56 +570,11 @@ func (a *apiHandler[data]) getPeer() (peerCapabilities, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return peer, nil
|
return r.WithContext(contextKeyPeer.WithValue(r.Context(), peer)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type noBodyData any // empty type, for use from serveAPI for endpoints with empty body
|
func (s *Server) getPeer(ctx context.Context) peerCapabilities {
|
||||||
|
return contextKeyPeer.Value(ctx)
|
||||||
// handle runs the given handler if the source peer satisfies the
|
|
||||||
// constraints for running this request.
|
|
||||||
//
|
|
||||||
// handle is expected for use when `data` type is empty, or set to
|
|
||||||
// `noBodyData` in practice. For requests that expect JSON body data
|
|
||||||
// to be attached, use handleJSON instead.
|
|
||||||
func (a *apiHandler[data]) handle(h http.HandlerFunc) {
|
|
||||||
peer, err := a.getPeer()
|
|
||||||
if err != nil {
|
|
||||||
http.Error(a.w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var body data // not used
|
|
||||||
if !a.permissionCheck(body, peer) {
|
|
||||||
http.Error(a.w, "not allowed", http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
h(a.w, a.r)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleJSON manages decoding the request's body JSON and passing
|
|
||||||
// it on to the provided function if the source peer satisfies the
|
|
||||||
// constraints for running this request.
|
|
||||||
func (a *apiHandler[data]) handleJSON(h func(ctx context.Context, data data) error) {
|
|
||||||
defer a.r.Body.Close()
|
|
||||||
var body data
|
|
||||||
if err := json.NewDecoder(a.r.Body).Decode(&body); err != nil {
|
|
||||||
http.Error(a.w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
peer, err := a.getPeer()
|
|
||||||
if err != nil {
|
|
||||||
http.Error(a.w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !a.permissionCheck(body, peer) {
|
|
||||||
http.Error(a.w, "not allowed", http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h(a.r.Context(), body); err != nil {
|
|
||||||
http.Error(a.w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
a.w.WriteHeader(http.StatusOK)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// serveAPI serves requests for the web client api.
|
// serveAPI serves requests for the web client api.
|
||||||
@@ -637,67 +589,44 @@ func (s *Server) serveAPI(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
r, err = s.setPeer(r)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
path := strings.TrimPrefix(r.URL.Path, "/api")
|
path := strings.TrimPrefix(r.URL.Path, "/api")
|
||||||
switch {
|
switch {
|
||||||
case path == "/data" && r.Method == httpm.GET:
|
case path == "/data" && r.Method == httpm.GET:
|
||||||
newHandler[noBodyData](s, w, r, alwaysAllowed).
|
s.serveGetNodeData(w, r)
|
||||||
handle(s.serveGetNodeData)
|
|
||||||
return
|
return
|
||||||
case path == "/exit-nodes" && r.Method == httpm.GET:
|
case path == "/exit-nodes" && r.Method == httpm.GET:
|
||||||
newHandler[noBodyData](s, w, r, alwaysAllowed).
|
s.serveGetExitNodes(w, r)
|
||||||
handle(s.serveGetExitNodes)
|
|
||||||
return
|
return
|
||||||
case path == "/routes" && r.Method == httpm.POST:
|
case path == "/routes" && r.Method == httpm.POST:
|
||||||
peerAllowed := func(d postRoutesRequest, p peerCapabilities) bool {
|
handleJSON[postRoutesRequest](s.servePostRoutes)(w, r)
|
||||||
if d.SetExitNode && !p.canEdit(capFeatureExitNodes) {
|
|
||||||
return false
|
|
||||||
} else if d.SetRoutes && !p.canEdit(capFeatureSubnets) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
newHandler[postRoutesRequest](s, w, r, peerAllowed).
|
|
||||||
handleJSON(s.servePostRoutes)
|
|
||||||
return
|
return
|
||||||
case path == "/device-details-click" && r.Method == httpm.POST:
|
case path == "/device-details-click" && r.Method == httpm.POST:
|
||||||
newHandler[noBodyData](s, w, r, alwaysAllowed).
|
s.serveDeviceDetailsClick(w, r)
|
||||||
handle(s.serveDeviceDetailsClick)
|
|
||||||
return
|
return
|
||||||
case path == "/local/v0/logout" && r.Method == httpm.POST:
|
case path == "/local/v0/logout" && r.Method == httpm.POST:
|
||||||
peerAllowed := func(_ noBodyData, peer peerCapabilities) bool {
|
s.proxyRequestToLocalAPI(w, r)
|
||||||
return peer.canEdit(capFeatureAccount)
|
|
||||||
}
|
|
||||||
newHandler[noBodyData](s, w, r, peerAllowed).
|
|
||||||
handle(s.proxyRequestToLocalAPI)
|
|
||||||
return
|
return
|
||||||
case path == "/local/v0/prefs" && r.Method == httpm.PATCH:
|
case path == "/local/v0/prefs" && r.Method == httpm.PATCH:
|
||||||
peerAllowed := func(data maskedPrefs, peer peerCapabilities) bool {
|
handleJSON[maskedPrefs](s.serveUpdatePrefs)(w, r)
|
||||||
if data.RunSSHSet && !peer.canEdit(capFeatureSSH) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
newHandler[maskedPrefs](s, w, r, peerAllowed).
|
|
||||||
handleJSON(s.serveUpdatePrefs)
|
|
||||||
return
|
return
|
||||||
case path == "/local/v0/update/check" && r.Method == httpm.GET:
|
case path == "/local/v0/update/check" && r.Method == httpm.GET:
|
||||||
newHandler[noBodyData](s, w, r, alwaysAllowed).
|
s.proxyRequestToLocalAPI(w, r)
|
||||||
handle(s.proxyRequestToLocalAPI)
|
|
||||||
return
|
return
|
||||||
case path == "/local/v0/update/check" && r.Method == httpm.POST:
|
case path == "/local/v0/update/check" && r.Method == httpm.POST:
|
||||||
peerAllowed := func(_ noBodyData, peer peerCapabilities) bool {
|
s.proxyRequestToLocalAPI(w, r)
|
||||||
return peer.canEdit(capFeatureAccount)
|
|
||||||
}
|
|
||||||
newHandler[noBodyData](s, w, r, peerAllowed).
|
|
||||||
handle(s.proxyRequestToLocalAPI)
|
|
||||||
return
|
return
|
||||||
case path == "/local/v0/update/progress" && r.Method == httpm.POST:
|
case path == "/local/v0/update/progress" && r.Method == httpm.POST:
|
||||||
newHandler[noBodyData](s, w, r, alwaysAllowed).
|
s.proxyRequestToLocalAPI(w, r)
|
||||||
handle(s.proxyRequestToLocalAPI)
|
|
||||||
return
|
return
|
||||||
case path == "/local/v0/upload-client-metrics" && r.Method == httpm.POST:
|
case path == "/local/v0/upload-client-metrics" && r.Method == httpm.POST:
|
||||||
newHandler[noBodyData](s, w, r, alwaysAllowed).
|
s.proxyRequestToLocalAPI(w, r)
|
||||||
handle(s.proxyRequestToLocalAPI)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.Error(w, "invalid endpoint", http.StatusNotFound)
|
http.Error(w, "invalid endpoint", http.StatusNotFound)
|
||||||
@@ -1122,6 +1051,11 @@ type maskedPrefs struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) serveUpdatePrefs(ctx context.Context, prefs maskedPrefs) error {
|
func (s *Server) serveUpdatePrefs(ctx context.Context, prefs maskedPrefs) error {
|
||||||
|
peer := s.getPeer(ctx)
|
||||||
|
if prefs.RunSSHSet && !peer.canEdit(capFeatureSSH) {
|
||||||
|
return tsweb.Error(http.StatusUnauthorized, "RunSSHSet not allowed", nil)
|
||||||
|
}
|
||||||
|
|
||||||
_, err := s.lc.EditPrefs(ctx, &ipn.MaskedPrefs{
|
_, err := s.lc.EditPrefs(ctx, &ipn.MaskedPrefs{
|
||||||
RunSSHSet: prefs.RunSSHSet,
|
RunSSHSet: prefs.RunSSHSet,
|
||||||
Prefs: ipn.Prefs{
|
Prefs: ipn.Prefs{
|
||||||
@@ -1141,8 +1075,16 @@ type postRoutesRequest struct {
|
|||||||
|
|
||||||
func (s *Server) servePostRoutes(ctx context.Context, data postRoutesRequest) error {
|
func (s *Server) servePostRoutes(ctx context.Context, data postRoutesRequest) error {
|
||||||
if !data.SetExitNode && !data.SetRoutes {
|
if !data.SetExitNode && !data.SetRoutes {
|
||||||
return errors.New("must specify SetExitNode or SetRoutes")
|
return tsweb.Error(http.StatusBadRequest, "must specify SetExitNode or SetRoutes", nil)
|
||||||
}
|
}
|
||||||
|
peer := s.getPeer(ctx)
|
||||||
|
if data.SetExitNode && !peer.canEdit(capFeatureExitNodes) {
|
||||||
|
return tsweb.Error(http.StatusUnauthorized, "SetExitNode not allowed", nil)
|
||||||
|
}
|
||||||
|
if data.SetRoutes && !peer.canEdit(capFeatureSubnets) {
|
||||||
|
return tsweb.Error(http.StatusUnauthorized, "SetRoutes not allowed", nil)
|
||||||
|
}
|
||||||
|
|
||||||
prefs, err := s.lc.GetPrefs(ctx)
|
prefs, err := s.lc.GetPrefs(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -1340,6 +1282,19 @@ func (s *Server) proxyRequestToLocalAPI(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
switch path {
|
||||||
|
case "/v0/logout":
|
||||||
|
if !s.getPeer(r.Context()).canEdit(capFeatureAccount) {
|
||||||
|
http.Error(w, "not allowed", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case "/v0/update/check":
|
||||||
|
if r.Method == httpm.POST && !s.getPeer(r.Context()).canEdit(capFeatureAccount) {
|
||||||
|
http.Error(w, "not allowed", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
localAPIURL := "http://" + apitype.LocalAPIHost + "/localapi" + path
|
localAPIURL := "http://" + apitype.LocalAPIHost + "/localapi" + path
|
||||||
req, err := http.NewRequestWithContext(r.Context(), r.Method, localAPIURL, r.Body)
|
req, err := http.NewRequestWithContext(r.Context(), r.Method, localAPIURL, r.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+27
-3
@@ -191,7 +191,7 @@ func TestServeAPI(t *testing.T) {
|
|||||||
reqBody: "{\"setExitNode\":true}",
|
reqBody: "{\"setExitNode\":true}",
|
||||||
tests: []requestTest{{
|
tests: []requestTest{{
|
||||||
remoteIP: remoteIPWithNoCapabilities,
|
remoteIP: remoteIPWithNoCapabilities,
|
||||||
wantResponse: "not allowed",
|
wantResponse: "SetExitNode not allowed",
|
||||||
wantStatus: http.StatusUnauthorized,
|
wantStatus: http.StatusUnauthorized,
|
||||||
}, {
|
}, {
|
||||||
remoteIP: remoteIPWithAllCapabilities,
|
remoteIP: remoteIPWithAllCapabilities,
|
||||||
@@ -204,7 +204,7 @@ func TestServeAPI(t *testing.T) {
|
|||||||
reqContentType: "application/json",
|
reqContentType: "application/json",
|
||||||
tests: []requestTest{{
|
tests: []requestTest{{
|
||||||
remoteIP: remoteIPWithNoCapabilities,
|
remoteIP: remoteIPWithNoCapabilities,
|
||||||
wantResponse: "not allowed",
|
wantResponse: "RunSSHSet not allowed",
|
||||||
wantStatus: http.StatusUnauthorized,
|
wantStatus: http.StatusUnauthorized,
|
||||||
}, {
|
}, {
|
||||||
remoteIP: remoteIPWithAllCapabilities,
|
remoteIP: remoteIPWithAllCapabilities,
|
||||||
@@ -1617,6 +1617,7 @@ func TestServePostRoutes(t *testing.T) {
|
|||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
data postRoutesRequest
|
data postRoutesRequest
|
||||||
|
peerCaps peerCapabilities
|
||||||
wantErr bool
|
wantErr bool
|
||||||
wantEditPrefs bool // whether EditPrefs (PATCH /prefs) should be called
|
wantEditPrefs bool // whether EditPrefs (PATCH /prefs) should be called
|
||||||
wantExitNodeID tailcfg.StableNodeID
|
wantExitNodeID tailcfg.StableNodeID
|
||||||
@@ -1625,6 +1626,7 @@ func TestServePostRoutes(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "empty-request",
|
name: "empty-request",
|
||||||
data: postRoutesRequest{},
|
data: postRoutesRequest{},
|
||||||
|
peerCaps: peerCapabilities{capFeatureExitNodes: true, capFeatureSubnets: true},
|
||||||
wantErr: true,
|
wantErr: true,
|
||||||
wantEditPrefs: false,
|
wantEditPrefs: false,
|
||||||
},
|
},
|
||||||
@@ -1634,20 +1636,40 @@ func TestServePostRoutes(t *testing.T) {
|
|||||||
SetExitNode: true,
|
SetExitNode: true,
|
||||||
UseExitNode: "new-exit-node",
|
UseExitNode: "new-exit-node",
|
||||||
},
|
},
|
||||||
|
peerCaps: peerCapabilities{capFeatureExitNodes: true, capFeatureSubnets: true},
|
||||||
wantEditPrefs: true,
|
wantEditPrefs: true,
|
||||||
wantExitNodeID: "new-exit-node",
|
wantExitNodeID: "new-exit-node",
|
||||||
wantRoutes: []netip.Prefix{existingRoute},
|
wantRoutes: []netip.Prefix{existingRoute},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "SetExitNode-not-allowed",
|
||||||
|
data: postRoutesRequest{
|
||||||
|
SetExitNode: true,
|
||||||
|
UseExitNode: "new-exit-node",
|
||||||
|
},
|
||||||
|
peerCaps: peerCapabilities{capFeatureSubnets: true},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "SetRoutes-only",
|
name: "SetRoutes-only",
|
||||||
data: postRoutesRequest{
|
data: postRoutesRequest{
|
||||||
SetRoutes: true,
|
SetRoutes: true,
|
||||||
AdvertiseRoutes: []string{"10.0.0.0/8"},
|
AdvertiseRoutes: []string{"10.0.0.0/8"},
|
||||||
},
|
},
|
||||||
|
peerCaps: peerCapabilities{capFeatureExitNodes: true, capFeatureSubnets: true},
|
||||||
wantEditPrefs: true,
|
wantEditPrefs: true,
|
||||||
wantExitNodeID: existingExitNodeID,
|
wantExitNodeID: existingExitNodeID,
|
||||||
wantRoutes: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")},
|
wantRoutes: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "SetRoutes-not-allowed",
|
||||||
|
data: postRoutesRequest{
|
||||||
|
SetRoutes: true,
|
||||||
|
AdvertiseRoutes: []string{"10.0.0.0/8"},
|
||||||
|
},
|
||||||
|
peerCaps: peerCapabilities{capFeatureExitNodes: true},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "SetExitNode-and-SetRoutes",
|
name: "SetExitNode-and-SetRoutes",
|
||||||
data: postRoutesRequest{
|
data: postRoutesRequest{
|
||||||
@@ -1656,6 +1678,7 @@ func TestServePostRoutes(t *testing.T) {
|
|||||||
UseExitNode: "new-exit-node",
|
UseExitNode: "new-exit-node",
|
||||||
AdvertiseRoutes: []string{"10.0.0.0/8"},
|
AdvertiseRoutes: []string{"10.0.0.0/8"},
|
||||||
},
|
},
|
||||||
|
peerCaps: peerCapabilities{capFeatureExitNodes: true, capFeatureSubnets: true},
|
||||||
wantEditPrefs: true,
|
wantEditPrefs: true,
|
||||||
wantExitNodeID: "new-exit-node",
|
wantExitNodeID: "new-exit-node",
|
||||||
wantRoutes: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")},
|
wantRoutes: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")},
|
||||||
@@ -1699,7 +1722,8 @@ func TestServePostRoutes(t *testing.T) {
|
|||||||
lc: &local.Client{Dial: lal.Dial},
|
lc: &local.Client{Dial: lal.Dial},
|
||||||
}
|
}
|
||||||
|
|
||||||
err := s.servePostRoutes(context.Background(), tt.data)
|
ctx := contextKeyPeer.WithValue(t.Context(), tt.peerCaps)
|
||||||
|
err := s.servePostRoutes(ctx, tt.data)
|
||||||
|
|
||||||
if tt.wantErr {
|
if tt.wantErr {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|||||||
@@ -910,7 +910,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
|||||||
tailscale.com/tstime from tailscale.com/cmd/k8s-operator+
|
tailscale.com/tstime from tailscale.com/cmd/k8s-operator+
|
||||||
tailscale.com/tstime/mono from tailscale.com/net/tstun+
|
tailscale.com/tstime/mono from tailscale.com/net/tstun+
|
||||||
tailscale.com/tstime/rate from tailscale.com/wgengine/filter
|
tailscale.com/tstime/rate from tailscale.com/wgengine/filter
|
||||||
tailscale.com/tsweb from tailscale.com/util/eventbus
|
tailscale.com/tsweb from tailscale.com/util/eventbus+
|
||||||
tailscale.com/tsweb/varz from tailscale.com/util/usermetric+
|
tailscale.com/tsweb/varz from tailscale.com/util/usermetric+
|
||||||
tailscale.com/types/appctype from tailscale.com/ipn/ipnlocal+
|
tailscale.com/types/appctype from tailscale.com/ipn/ipnlocal+
|
||||||
tailscale.com/types/bools from tailscale.com/tsnet+
|
tailscale.com/types/bools from tailscale.com/tsnet+
|
||||||
|
|||||||
@@ -239,7 +239,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
|
|||||||
tailscale.com/tstime from tailscale.com/control/controlhttp+
|
tailscale.com/tstime from tailscale.com/control/controlhttp+
|
||||||
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/cmd/tailscale/cli
|
tailscale.com/tstime/rate from tailscale.com/cmd/tailscale/cli
|
||||||
tailscale.com/tsweb from tailscale.com/util/eventbus
|
tailscale.com/tsweb from tailscale.com/util/eventbus+
|
||||||
tailscale.com/tsweb/varz from tailscale.com/util/usermetric+
|
tailscale.com/tsweb/varz from tailscale.com/util/usermetric+
|
||||||
tailscale.com/types/appctype from tailscale.com/client/local+
|
tailscale.com/types/appctype from tailscale.com/client/local+
|
||||||
tailscale.com/types/dnstype from tailscale.com/tailcfg+
|
tailscale.com/types/dnstype from tailscale.com/tailcfg+
|
||||||
|
|||||||
@@ -404,7 +404,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
|||||||
tailscale.com/tstime from tailscale.com/control/controlclient+
|
tailscale.com/tstime from tailscale.com/control/controlclient+
|
||||||
tailscale.com/tstime/mono from tailscale.com/net/tstun+
|
tailscale.com/tstime/mono from tailscale.com/net/tstun+
|
||||||
tailscale.com/tstime/rate from tailscale.com/wgengine/filter
|
tailscale.com/tstime/rate from tailscale.com/wgengine/filter
|
||||||
tailscale.com/tsweb from tailscale.com/util/eventbus
|
tailscale.com/tsweb from tailscale.com/util/eventbus+
|
||||||
tailscale.com/tsweb/varz from tailscale.com/cmd/tailscaled+
|
tailscale.com/tsweb/varz from tailscale.com/cmd/tailscaled+
|
||||||
tailscale.com/types/appctype from tailscale.com/ipn/ipnlocal+
|
tailscale.com/types/appctype from tailscale.com/ipn/ipnlocal+
|
||||||
tailscale.com/types/bools from tailscale.com/wgengine/netlog
|
tailscale.com/types/bools from tailscale.com/wgengine/netlog
|
||||||
|
|||||||
@@ -309,7 +309,7 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar
|
|||||||
tailscale.com/tstime from tailscale.com/control/controlclient+
|
tailscale.com/tstime from tailscale.com/control/controlclient+
|
||||||
tailscale.com/tstime/mono from tailscale.com/net/tstun+
|
tailscale.com/tstime/mono from tailscale.com/net/tstun+
|
||||||
tailscale.com/tstime/rate from tailscale.com/wgengine/filter
|
tailscale.com/tstime/rate from tailscale.com/wgengine/filter
|
||||||
tailscale.com/tsweb from tailscale.com/util/eventbus
|
tailscale.com/tsweb from tailscale.com/util/eventbus+
|
||||||
tailscale.com/tsweb/varz from tailscale.com/tsweb+
|
tailscale.com/tsweb/varz from tailscale.com/tsweb+
|
||||||
tailscale.com/types/appctype from tailscale.com/ipn/ipnlocal+
|
tailscale.com/types/appctype from tailscale.com/ipn/ipnlocal+
|
||||||
tailscale.com/types/bools from tailscale.com/tsnet+
|
tailscale.com/types/bools from tailscale.com/tsnet+
|
||||||
|
|||||||
+1
-1
@@ -304,7 +304,7 @@ tailscale.com/tsnet dependencies: (generated by github.com/tailscale/depaware)
|
|||||||
tailscale.com/tstime from tailscale.com/control/controlclient+
|
tailscale.com/tstime from tailscale.com/control/controlclient+
|
||||||
tailscale.com/tstime/mono from tailscale.com/net/tstun+
|
tailscale.com/tstime/mono from tailscale.com/net/tstun+
|
||||||
tailscale.com/tstime/rate from tailscale.com/wgengine/filter
|
tailscale.com/tstime/rate from tailscale.com/wgengine/filter
|
||||||
LDW tailscale.com/tsweb from tailscale.com/util/eventbus
|
LDW tailscale.com/tsweb from tailscale.com/util/eventbus+
|
||||||
tailscale.com/tsweb/varz from tailscale.com/tsweb+
|
tailscale.com/tsweb/varz from tailscale.com/tsweb+
|
||||||
tailscale.com/types/appctype from tailscale.com/ipn/ipnlocal+
|
tailscale.com/types/appctype from tailscale.com/ipn/ipnlocal+
|
||||||
tailscale.com/types/bools from tailscale.com/tsnet+
|
tailscale.com/types/bools from tailscale.com/tsnet+
|
||||||
|
|||||||
Reference in New Issue
Block a user