Compare commits
34 Commits
icio/testw
...
brianp/con
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67d9def2d1 | ||
|
|
ce6ce81311 | ||
|
|
a567f56445 | ||
|
|
986daca5ee | ||
|
|
dc18091678 | ||
|
|
74d7d8a77b | ||
|
|
ef906763ee | ||
|
|
8c2717f96a | ||
|
|
2791b5d5cc | ||
|
|
7180812f47 | ||
|
|
90273a7f70 | ||
|
|
6df0aa58bb | ||
|
|
b85d18d14e | ||
|
|
3d28aa19cb | ||
|
|
f5522e62d1 | ||
|
|
ae303d41dd | ||
|
|
c174d3c795 | ||
|
|
820bdb870a | ||
|
|
d7508b24c6 | ||
|
|
83c104652d | ||
|
|
8d7033fe7f | ||
|
|
d1b0e1af06 | ||
|
|
781c1e9624 | ||
|
|
f5997b3c57 | ||
|
|
dcd7cd3c6a | ||
|
|
074372d6c5 | ||
|
|
2c3338c46b | ||
|
|
836c01258d | ||
|
|
cc923713f6 | ||
|
|
323747c3e0 | ||
|
|
09982e1918 | ||
|
|
1f1a26776b | ||
|
|
9c731b848b | ||
|
|
ec5f04b274 |
2
.github/workflows/golangci-lint.yml
vendored
2
.github/workflows/golangci-lint.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@2e788936b09dd82dc280e845628a40d2ba6b204c # v6.3.1
|
||||
with:
|
||||
version: v1.60
|
||||
version: v1.64
|
||||
|
||||
# Show only new issues if it's a pull request.
|
||||
only-new-issues: true
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
# $ docker exec tailscaled tailscale status
|
||||
|
||||
|
||||
FROM golang:1.23-alpine AS build-env
|
||||
FROM golang:1.24-alpine AS build-env
|
||||
|
||||
WORKDIR /go/src/tailscale
|
||||
|
||||
|
||||
@@ -289,9 +289,11 @@ func (e *AppConnector) updateDomains(domains []string) {
|
||||
toRemove = append(toRemove, netip.PrefixFrom(a, a.BitLen()))
|
||||
}
|
||||
}
|
||||
if err := e.routeAdvertiser.UnadvertiseRoute(toRemove...); err != nil {
|
||||
e.logf("failed to unadvertise routes on domain removal: %v: %v: %v", slicesx.MapKeys(oldDomains), toRemove, err)
|
||||
}
|
||||
e.queue.Add(func() {
|
||||
if err := e.routeAdvertiser.UnadvertiseRoute(toRemove...); err != nil {
|
||||
e.logf("failed to unadvertise routes on domain removal: %v: %v: %v", slicesx.MapKeys(oldDomains), toRemove, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
e.logf("handling domains: %v and wildcards: %v", slicesx.MapKeys(e.domains), e.wildcards)
|
||||
@@ -310,11 +312,6 @@ func (e *AppConnector) updateRoutes(routes []netip.Prefix) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := e.routeAdvertiser.AdvertiseRoute(routes...); err != nil {
|
||||
e.logf("failed to advertise routes: %v: %v", routes, err)
|
||||
return
|
||||
}
|
||||
|
||||
var toRemove []netip.Prefix
|
||||
|
||||
// If we're storing routes and know e.controlRoutes is a good
|
||||
@@ -338,9 +335,14 @@ nextRoute:
|
||||
}
|
||||
}
|
||||
|
||||
if err := e.routeAdvertiser.UnadvertiseRoute(toRemove...); err != nil {
|
||||
e.logf("failed to unadvertise routes: %v: %v", toRemove, err)
|
||||
}
|
||||
e.queue.Add(func() {
|
||||
if err := e.routeAdvertiser.AdvertiseRoute(routes...); err != nil {
|
||||
e.logf("failed to advertise routes: %v: %v", routes, err)
|
||||
}
|
||||
if err := e.routeAdvertiser.UnadvertiseRoute(toRemove...); err != nil {
|
||||
e.logf("failed to unadvertise routes: %v: %v", toRemove, err)
|
||||
}
|
||||
})
|
||||
|
||||
e.controlRoutes = routes
|
||||
if err := e.storeRoutesLocked(); err != nil {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net/netip"
|
||||
"reflect"
|
||||
"slices"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -86,6 +87,7 @@ func TestUpdateRoutes(t *testing.T) {
|
||||
|
||||
routes := []netip.Prefix{netip.MustParsePrefix("192.0.2.0/24"), netip.MustParsePrefix("192.0.0.1/32")}
|
||||
a.updateRoutes(routes)
|
||||
a.Wait(ctx)
|
||||
|
||||
slices.SortFunc(rc.Routes(), prefixCompare)
|
||||
rc.SetRoutes(slices.Compact(rc.Routes()))
|
||||
@@ -105,6 +107,7 @@ func TestUpdateRoutes(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdateRoutesUnadvertisesContainedRoutes(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
for _, shouldStore := range []bool{false, true} {
|
||||
rc := &appctest.RouteCollector{}
|
||||
var a *AppConnector
|
||||
@@ -117,6 +120,7 @@ func TestUpdateRoutesUnadvertisesContainedRoutes(t *testing.T) {
|
||||
rc.SetRoutes([]netip.Prefix{netip.MustParsePrefix("192.0.2.1/32")})
|
||||
routes := []netip.Prefix{netip.MustParsePrefix("192.0.2.0/24")}
|
||||
a.updateRoutes(routes)
|
||||
a.Wait(ctx)
|
||||
|
||||
if !slices.EqualFunc(routes, rc.Routes(), prefixEqual) {
|
||||
t.Fatalf("got %v, want %v", rc.Routes(), routes)
|
||||
@@ -636,3 +640,57 @@ func TestMetricBucketsAreSorted(t *testing.T) {
|
||||
t.Errorf("metricStoreRoutesNBuckets must be in order")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateRoutesDeadlock is a regression test for a deadlock in
|
||||
// LocalBackend<->AppConnector interaction. When using real LocalBackend as the
|
||||
// routeAdvertiser, calls to Advertise/UnadvertiseRoutes can end up calling
|
||||
// back into AppConnector via authReconfig. If everything is called
|
||||
// synchronously, this results in a deadlock on AppConnector.mu.
|
||||
func TestUpdateRoutesDeadlock(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
rc := &appctest.RouteCollector{}
|
||||
a := NewAppConnector(t.Logf, rc, &RouteInfo{}, fakeStoreRoutes)
|
||||
|
||||
advertiseCalled := new(atomic.Bool)
|
||||
unadvertiseCalled := new(atomic.Bool)
|
||||
rc.AdvertiseCallback = func() {
|
||||
// Call something that requires a.mu to be held.
|
||||
a.DomainRoutes()
|
||||
advertiseCalled.Store(true)
|
||||
}
|
||||
rc.UnadvertiseCallback = func() {
|
||||
// Call something that requires a.mu to be held.
|
||||
a.DomainRoutes()
|
||||
unadvertiseCalled.Store(true)
|
||||
}
|
||||
|
||||
a.updateDomains([]string{"example.com"})
|
||||
a.Wait(ctx)
|
||||
|
||||
// Trigger rc.AdveriseRoute.
|
||||
a.updateRoutes(
|
||||
[]netip.Prefix{
|
||||
netip.MustParsePrefix("127.0.0.1/32"),
|
||||
netip.MustParsePrefix("127.0.0.2/32"),
|
||||
},
|
||||
)
|
||||
a.Wait(ctx)
|
||||
// Trigger rc.UnadveriseRoute.
|
||||
a.updateRoutes(
|
||||
[]netip.Prefix{
|
||||
netip.MustParsePrefix("127.0.0.1/32"),
|
||||
},
|
||||
)
|
||||
a.Wait(ctx)
|
||||
|
||||
if !advertiseCalled.Load() {
|
||||
t.Error("AdvertiseRoute was not called")
|
||||
}
|
||||
if !unadvertiseCalled.Load() {
|
||||
t.Error("UnadvertiseRoute was not called")
|
||||
}
|
||||
|
||||
if want := []netip.Prefix{netip.MustParsePrefix("127.0.0.1/32")}; !slices.Equal(slices.Compact(rc.Routes()), want) {
|
||||
t.Fatalf("got %v, want %v", rc.Routes(), want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,12 +11,22 @@ import (
|
||||
|
||||
// RouteCollector is a test helper that collects the list of routes advertised
|
||||
type RouteCollector struct {
|
||||
// AdvertiseCallback (optional) is called synchronously from
|
||||
// AdvertiseRoute.
|
||||
AdvertiseCallback func()
|
||||
// UnadvertiseCallback (optional) is called synchronously from
|
||||
// UnadvertiseRoute.
|
||||
UnadvertiseCallback func()
|
||||
|
||||
routes []netip.Prefix
|
||||
removedRoutes []netip.Prefix
|
||||
}
|
||||
|
||||
func (rc *RouteCollector) AdvertiseRoute(pfx ...netip.Prefix) error {
|
||||
rc.routes = append(rc.routes, pfx...)
|
||||
if rc.AdvertiseCallback != nil {
|
||||
rc.AdvertiseCallback()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -30,6 +40,9 @@ func (rc *RouteCollector) UnadvertiseRoute(toRemove ...netip.Prefix) error {
|
||||
rc.removedRoutes = append(rc.removedRoutes, r)
|
||||
}
|
||||
}
|
||||
if rc.UnadvertiseCallback != nil {
|
||||
rc.UnadvertiseCallback()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,11 @@ type Menu struct {
|
||||
curProfile ipn.LoginProfile
|
||||
allProfiles []ipn.LoginProfile
|
||||
|
||||
// readonly is whether the systray app is running in read-only mode.
|
||||
// This is set if LocalAPI returns a permission error,
|
||||
// typically because the user needs to run `tailscale set --operator=$USER`.
|
||||
readonly bool
|
||||
|
||||
bgCtx context.Context // ctx for background tasks not involving menu item clicks
|
||||
bgCancel context.CancelFunc
|
||||
|
||||
@@ -153,6 +158,8 @@ func (menu *Menu) updateState() {
|
||||
defer menu.mu.Unlock()
|
||||
menu.init()
|
||||
|
||||
menu.readonly = false
|
||||
|
||||
var err error
|
||||
menu.status, err = menu.lc.Status(menu.bgCtx)
|
||||
if err != nil {
|
||||
@@ -160,6 +167,9 @@ func (menu *Menu) updateState() {
|
||||
}
|
||||
menu.curProfile, menu.allProfiles, err = menu.lc.ProfileStatus(menu.bgCtx)
|
||||
if err != nil {
|
||||
if local.IsAccessDeniedError(err) {
|
||||
menu.readonly = true
|
||||
}
|
||||
log.Print(err)
|
||||
}
|
||||
}
|
||||
@@ -182,6 +192,15 @@ func (menu *Menu) rebuild() {
|
||||
|
||||
systray.ResetMenu()
|
||||
|
||||
if menu.readonly {
|
||||
const readonlyMsg = "No permission to manage Tailscale.\nSee tailscale.com/s/cli-operator"
|
||||
m := systray.AddMenuItem(readonlyMsg, "")
|
||||
onClick(ctx, m, func(_ context.Context) {
|
||||
webbrowser.Open("https://tailscale.com/s/cli-operator")
|
||||
})
|
||||
systray.AddSeparator()
|
||||
}
|
||||
|
||||
menu.connect = systray.AddMenuItem("Connect", "")
|
||||
menu.disconnect = systray.AddMenuItem("Disconnect", "")
|
||||
menu.disconnect.Hide()
|
||||
@@ -222,28 +241,35 @@ func (menu *Menu) rebuild() {
|
||||
setAppIcon(disconnected)
|
||||
}
|
||||
|
||||
if menu.readonly {
|
||||
menu.connect.Disable()
|
||||
menu.disconnect.Disable()
|
||||
}
|
||||
|
||||
account := "Account"
|
||||
if pt := profileTitle(menu.curProfile); pt != "" {
|
||||
account = pt
|
||||
}
|
||||
accounts := systray.AddMenuItem(account, "")
|
||||
setRemoteIcon(accounts, menu.curProfile.UserProfile.ProfilePicURL)
|
||||
time.Sleep(newMenuDelay)
|
||||
for _, profile := range menu.allProfiles {
|
||||
title := profileTitle(profile)
|
||||
var item *systray.MenuItem
|
||||
if profile.ID == menu.curProfile.ID {
|
||||
item = accounts.AddSubMenuItemCheckbox(title, "", true)
|
||||
} else {
|
||||
item = accounts.AddSubMenuItem(title, "")
|
||||
}
|
||||
setRemoteIcon(item, profile.UserProfile.ProfilePicURL)
|
||||
onClick(ctx, item, func(ctx context.Context) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case menu.accountsCh <- profile.ID:
|
||||
if !menu.readonly {
|
||||
accounts := systray.AddMenuItem(account, "")
|
||||
setRemoteIcon(accounts, menu.curProfile.UserProfile.ProfilePicURL)
|
||||
time.Sleep(newMenuDelay)
|
||||
for _, profile := range menu.allProfiles {
|
||||
title := profileTitle(profile)
|
||||
var item *systray.MenuItem
|
||||
if profile.ID == menu.curProfile.ID {
|
||||
item = accounts.AddSubMenuItemCheckbox(title, "", true)
|
||||
} else {
|
||||
item = accounts.AddSubMenuItem(title, "")
|
||||
}
|
||||
})
|
||||
setRemoteIcon(item, profile.UserProfile.ProfilePicURL)
|
||||
onClick(ctx, item, func(ctx context.Context) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case menu.accountsCh <- profile.ID:
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if menu.status != nil && menu.status.Self != nil && len(menu.status.Self.TailscaleIPs) > 0 {
|
||||
@@ -255,7 +281,9 @@ func (menu *Menu) rebuild() {
|
||||
}
|
||||
systray.AddSeparator()
|
||||
|
||||
menu.rebuildExitNodeMenu(ctx)
|
||||
if !menu.readonly {
|
||||
menu.rebuildExitNodeMenu(ctx)
|
||||
}
|
||||
|
||||
if menu.status != nil {
|
||||
menu.more = systray.AddMenuItem("More settings", "")
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// ACLRow defines a rule that grants access by a set of users or groups to a set
|
||||
@@ -83,7 +84,7 @@ func (c *Client) ACL(ctx context.Context) (acl *ACL, err error) {
|
||||
}
|
||||
}()
|
||||
|
||||
path := fmt.Sprintf("%s/api/v2/tailnet/%s/acl", c.baseURL(), c.tailnet)
|
||||
path := c.BuildTailnetURL("acl")
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -97,7 +98,7 @@ func (c *Client) ACL(ctx context.Context) (acl *ACL, err error) {
|
||||
// If status code was not successful, return the error.
|
||||
// TODO: Change the check for the StatusCode to include other 2XX success codes.
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, handleErrorResponse(b, resp)
|
||||
return nil, HandleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
// Otherwise, try to decode the response.
|
||||
@@ -126,7 +127,7 @@ func (c *Client) ACLHuJSON(ctx context.Context) (acl *ACLHuJSON, err error) {
|
||||
}
|
||||
}()
|
||||
|
||||
path := fmt.Sprintf("%s/api/v2/tailnet/%s/acl?details=1", c.baseURL(), c.tailnet)
|
||||
path := c.BuildTailnetURL("acl", url.Values{"details": {"1"}})
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -138,7 +139,7 @@ func (c *Client) ACLHuJSON(ctx context.Context) (acl *ACLHuJSON, err error) {
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, handleErrorResponse(b, resp)
|
||||
return nil, HandleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
data := struct {
|
||||
@@ -146,7 +147,7 @@ func (c *Client) ACLHuJSON(ctx context.Context) (acl *ACLHuJSON, err error) {
|
||||
Warnings []string `json:"warnings"`
|
||||
}{}
|
||||
if err := json.Unmarshal(b, &data); err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("json.Unmarshal %q: %w", b, err)
|
||||
}
|
||||
|
||||
acl = &ACLHuJSON{
|
||||
@@ -184,7 +185,7 @@ func (e ACLTestError) Error() string {
|
||||
}
|
||||
|
||||
func (c *Client) aclPOSTRequest(ctx context.Context, body []byte, avoidCollisions bool, etag, acceptHeader string) ([]byte, string, error) {
|
||||
path := fmt.Sprintf("%s/api/v2/tailnet/%s/acl", c.baseURL(), c.tailnet)
|
||||
path := c.BuildTailnetURL("acl")
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewBuffer(body))
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
@@ -328,7 +329,7 @@ type ACLPreview struct {
|
||||
}
|
||||
|
||||
func (c *Client) previewACLPostRequest(ctx context.Context, body []byte, previewType string, previewFor string) (res *ACLPreviewResponse, err error) {
|
||||
path := fmt.Sprintf("%s/api/v2/tailnet/%s/acl/preview", c.baseURL(), c.tailnet)
|
||||
path := c.BuildTailnetURL("acl", "preview")
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewBuffer(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -350,7 +351,7 @@ func (c *Client) previewACLPostRequest(ctx context.Context, body []byte, preview
|
||||
// If status code was not successful, return the error.
|
||||
// TODO: Change the check for the StatusCode to include other 2XX success codes.
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, handleErrorResponse(b, resp)
|
||||
return nil, HandleErrorResponse(b, resp)
|
||||
}
|
||||
if err = json.Unmarshal(b, &res); err != nil {
|
||||
return nil, err
|
||||
@@ -488,7 +489,7 @@ func (c *Client) ValidateACLJSON(ctx context.Context, source, dest string) (test
|
||||
return nil, err
|
||||
}
|
||||
|
||||
path := fmt.Sprintf("%s/api/v2/tailnet/%s/acl/validate", c.baseURL(), c.tailnet)
|
||||
path := c.BuildTailnetURL("acl", "validate")
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewBuffer(postData))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -131,7 +131,7 @@ func (c *Client) Devices(ctx context.Context, fields *DeviceFieldsOpts) (deviceL
|
||||
}
|
||||
}()
|
||||
|
||||
path := fmt.Sprintf("%s/api/v2/tailnet/%s/devices", c.baseURL(), c.tailnet)
|
||||
path := c.BuildTailnetURL("devices")
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -149,7 +149,7 @@ func (c *Client) Devices(ctx context.Context, fields *DeviceFieldsOpts) (deviceL
|
||||
// If status code was not successful, return the error.
|
||||
// TODO: Change the check for the StatusCode to include other 2XX success codes.
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, handleErrorResponse(b, resp)
|
||||
return nil, HandleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
var devices GetDevicesResponse
|
||||
@@ -188,7 +188,7 @@ func (c *Client) Device(ctx context.Context, deviceID string, fields *DeviceFiel
|
||||
// If status code was not successful, return the error.
|
||||
// TODO: Change the check for the StatusCode to include other 2XX success codes.
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, handleErrorResponse(b, resp)
|
||||
return nil, HandleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
err = json.Unmarshal(b, &device)
|
||||
@@ -221,7 +221,7 @@ func (c *Client) DeleteDevice(ctx context.Context, deviceID string) (err error)
|
||||
// If status code was not successful, return the error.
|
||||
// TODO: Change the check for the StatusCode to include other 2XX success codes.
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return handleErrorResponse(b, resp)
|
||||
return HandleErrorResponse(b, resp)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -253,7 +253,7 @@ func (c *Client) SetAuthorized(ctx context.Context, deviceID string, authorized
|
||||
// If status code was not successful, return the error.
|
||||
// TODO: Change the check for the StatusCode to include other 2XX success codes.
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return handleErrorResponse(b, resp)
|
||||
return HandleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -281,7 +281,7 @@ func (c *Client) SetTags(ctx context.Context, deviceID string, tags []string) er
|
||||
// If status code was not successful, return the error.
|
||||
// TODO: Change the check for the StatusCode to include other 2XX success codes.
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return handleErrorResponse(b, resp)
|
||||
return HandleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -44,7 +44,7 @@ type DNSPreferences struct {
|
||||
}
|
||||
|
||||
func (c *Client) dnsGETRequest(ctx context.Context, endpoint string) ([]byte, error) {
|
||||
path := fmt.Sprintf("%s/api/v2/tailnet/%s/dns/%s", c.baseURL(), c.tailnet, endpoint)
|
||||
path := c.BuildTailnetURL("dns", endpoint)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -57,14 +57,14 @@ func (c *Client) dnsGETRequest(ctx context.Context, endpoint string) ([]byte, er
|
||||
// If status code was not successful, return the error.
|
||||
// TODO: Change the check for the StatusCode to include other 2XX success codes.
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, handleErrorResponse(b, resp)
|
||||
return nil, HandleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func (c *Client) dnsPOSTRequest(ctx context.Context, endpoint string, postData any) ([]byte, error) {
|
||||
path := fmt.Sprintf("%s/api/v2/tailnet/%s/dns/%s", c.baseURL(), c.tailnet, endpoint)
|
||||
path := c.BuildTailnetURL("dns", endpoint)
|
||||
data, err := json.Marshal(&postData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -84,7 +84,7 @@ func (c *Client) dnsPOSTRequest(ctx context.Context, endpoint string, postData a
|
||||
// If status code was not successful, return the error.
|
||||
// TODO: Change the check for the StatusCode to include other 2XX success codes.
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, handleErrorResponse(b, resp)
|
||||
return nil, HandleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
return b, nil
|
||||
|
||||
@@ -40,7 +40,7 @@ type KeyDeviceCreateCapabilities struct {
|
||||
|
||||
// Keys returns the list of keys for the current user.
|
||||
func (c *Client) Keys(ctx context.Context) ([]string, error) {
|
||||
path := fmt.Sprintf("%s/api/v2/tailnet/%s/keys", c.baseURL(), c.tailnet)
|
||||
path := c.BuildTailnetURL("keys")
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -51,7 +51,7 @@ func (c *Client) Keys(ctx context.Context) ([]string, error) {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, handleErrorResponse(b, resp)
|
||||
return nil, HandleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
var keys struct {
|
||||
@@ -99,7 +99,7 @@ func (c *Client) CreateKeyWithExpiry(ctx context.Context, caps KeyCapabilities,
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
path := fmt.Sprintf("%s/api/v2/tailnet/%s/keys", c.baseURL(), c.tailnet)
|
||||
path := c.BuildTailnetURL("keys")
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewReader(bs))
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
@@ -110,7 +110,7 @@ func (c *Client) CreateKeyWithExpiry(ctx context.Context, caps KeyCapabilities,
|
||||
return "", nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", nil, handleErrorResponse(b, resp)
|
||||
return "", nil, HandleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
var key struct {
|
||||
@@ -126,7 +126,7 @@ func (c *Client) CreateKeyWithExpiry(ctx context.Context, caps KeyCapabilities,
|
||||
// Key returns the metadata for the given key ID. Currently, capabilities are
|
||||
// only returned for auth keys, API keys only return general metadata.
|
||||
func (c *Client) Key(ctx context.Context, id string) (*Key, error) {
|
||||
path := fmt.Sprintf("%s/api/v2/tailnet/%s/keys/%s", c.baseURL(), c.tailnet, id)
|
||||
path := c.BuildTailnetURL("keys", id)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -137,7 +137,7 @@ func (c *Client) Key(ctx context.Context, id string) (*Key, error) {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, handleErrorResponse(b, resp)
|
||||
return nil, HandleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
var key Key
|
||||
@@ -149,7 +149,7 @@ func (c *Client) Key(ctx context.Context, id string) (*Key, error) {
|
||||
|
||||
// DeleteKey deletes the key with the given ID.
|
||||
func (c *Client) DeleteKey(ctx context.Context, id string) error {
|
||||
path := fmt.Sprintf("%s/api/v2/tailnet/%s/keys/%s", c.baseURL(), c.tailnet, id)
|
||||
path := c.BuildTailnetURL("keys", id)
|
||||
req, err := http.NewRequestWithContext(ctx, "DELETE", path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -160,7 +160,7 @@ func (c *Client) DeleteKey(ctx context.Context, id string) error {
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return handleErrorResponse(b, resp)
|
||||
return HandleErrorResponse(b, resp)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ func (c *Client) Routes(ctx context.Context, deviceID string) (routes *Routes, e
|
||||
// If status code was not successful, return the error.
|
||||
// TODO: Change the check for the StatusCode to include other 2XX success codes.
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, handleErrorResponse(b, resp)
|
||||
return nil, HandleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
var sr Routes
|
||||
@@ -84,7 +84,7 @@ func (c *Client) SetRoutes(ctx context.Context, deviceID string, subnets []netip
|
||||
// If status code was not successful, return the error.
|
||||
// TODO: Change the check for the StatusCode to include other 2XX success codes.
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, handleErrorResponse(b, resp)
|
||||
return nil, HandleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
var srr *Routes
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"tailscale.com/util/httpm"
|
||||
)
|
||||
@@ -22,7 +21,7 @@ func (c *Client) TailnetDeleteRequest(ctx context.Context, tailnetID string) (er
|
||||
}
|
||||
}()
|
||||
|
||||
path := fmt.Sprintf("%s/api/v2/tailnet/%s", c.baseURL(), url.PathEscape(string(tailnetID)))
|
||||
path := c.BuildTailnetURL("tailnet")
|
||||
req, err := http.NewRequestWithContext(ctx, httpm.DELETE, path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -35,7 +34,7 @@ func (c *Client) TailnetDeleteRequest(ctx context.Context, tailnetID string) (er
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return handleErrorResponse(b, resp)
|
||||
return HandleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -17,6 +17,8 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
)
|
||||
|
||||
// I_Acknowledge_This_API_Is_Unstable must be set true to use this package
|
||||
@@ -63,6 +65,46 @@ func (c *Client) httpClient() *http.Client {
|
||||
return http.DefaultClient
|
||||
}
|
||||
|
||||
// BuildURL builds a url to http(s)://<apiserver>/api/v2/<slash-separated-pathElements>
|
||||
// using the given pathElements. It url escapes each path element, so the
|
||||
// caller doesn't need to worry about that. The last item of pathElements can
|
||||
// be of type url.Values to add a query string to the URL.
|
||||
//
|
||||
// For example, BuildURL(devices, 5) with the default server URL would result in
|
||||
// https://api.tailscale.com/api/v2/devices/5.
|
||||
func (c *Client) BuildURL(pathElements ...any) string {
|
||||
elem := make([]string, 1, len(pathElements)+1)
|
||||
elem[0] = "/api/v2"
|
||||
var query string
|
||||
for i, pathElement := range pathElements {
|
||||
if uv, ok := pathElement.(url.Values); ok && i == len(pathElements)-1 {
|
||||
query = uv.Encode()
|
||||
} else {
|
||||
elem = append(elem, url.PathEscape(fmt.Sprint(pathElement)))
|
||||
}
|
||||
}
|
||||
url := c.baseURL() + path.Join(elem...)
|
||||
if query != "" {
|
||||
url += "?" + query
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
// BuildTailnetURL builds a url to http(s)://<apiserver>/api/v2/tailnet/<tailnet>/<slash-separated-pathElements>
|
||||
// using the given pathElements. It url escapes each path element, so the
|
||||
// caller doesn't need to worry about that. The last item of pathElements can
|
||||
// be of type url.Values to add a query string to the URL.
|
||||
//
|
||||
// For example, BuildTailnetURL(policy, validate) with the default server URL and a tailnet of "example.com"
|
||||
// would result in https://api.tailscale.com/api/v2/tailnet/example.com/policy/validate.
|
||||
func (c *Client) BuildTailnetURL(pathElements ...any) string {
|
||||
allElements := make([]any, 2, len(pathElements)+2)
|
||||
allElements[0] = "tailnet"
|
||||
allElements[1] = c.tailnet
|
||||
allElements = append(allElements, pathElements...)
|
||||
return c.BuildURL(allElements...)
|
||||
}
|
||||
|
||||
func (c *Client) baseURL() string {
|
||||
if c.BaseURL != "" {
|
||||
return c.BaseURL
|
||||
@@ -150,12 +192,14 @@ func (e ErrResponse) Error() string {
|
||||
return fmt.Sprintf("Status: %d, Message: %q", e.Status, e.Message)
|
||||
}
|
||||
|
||||
// handleErrorResponse decodes the error message from the server and returns
|
||||
// HandleErrorResponse decodes the error message from the server and returns
|
||||
// an ErrResponse from it.
|
||||
func handleErrorResponse(b []byte, resp *http.Response) error {
|
||||
//
|
||||
// Deprecated: use tailscale.com/client/tailscale/v2 instead.
|
||||
func HandleErrorResponse(b []byte, resp *http.Response) error {
|
||||
var errResp ErrResponse
|
||||
if err := json.Unmarshal(b, &errResp); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("json.Unmarshal %q: %w", b, err)
|
||||
}
|
||||
errResp.Status = resp.StatusCode
|
||||
return errResp
|
||||
|
||||
86
client/tailscale/tailscale_test.go
Normal file
86
client/tailscale/tailscale_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package tailscale
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClientBuildURL(t *testing.T) {
|
||||
c := Client{BaseURL: "http://127.0.0.1:1234"}
|
||||
for _, tt := range []struct {
|
||||
desc string
|
||||
elements []any
|
||||
want string
|
||||
}{
|
||||
{
|
||||
desc: "single-element",
|
||||
elements: []any{"devices"},
|
||||
want: "http://127.0.0.1:1234/api/v2/devices",
|
||||
},
|
||||
{
|
||||
desc: "multiple-elements",
|
||||
elements: []any{"tailnet", "example.com"},
|
||||
want: "http://127.0.0.1:1234/api/v2/tailnet/example.com",
|
||||
},
|
||||
{
|
||||
desc: "escape-element",
|
||||
elements: []any{"tailnet", "example dot com?foo=bar"},
|
||||
want: `http://127.0.0.1:1234/api/v2/tailnet/example%20dot%20com%3Ffoo=bar`,
|
||||
},
|
||||
{
|
||||
desc: "url.Values",
|
||||
elements: []any{"tailnet", "example.com", "acl", url.Values{"details": {"1"}}},
|
||||
want: `http://127.0.0.1:1234/api/v2/tailnet/example.com/acl?details=1`,
|
||||
},
|
||||
} {
|
||||
t.Run(tt.desc, func(t *testing.T) {
|
||||
got := c.BuildURL(tt.elements...)
|
||||
if got != tt.want {
|
||||
t.Errorf("got %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientBuildTailnetURL(t *testing.T) {
|
||||
c := Client{
|
||||
BaseURL: "http://127.0.0.1:1234",
|
||||
tailnet: "example.com",
|
||||
}
|
||||
for _, tt := range []struct {
|
||||
desc string
|
||||
elements []any
|
||||
want string
|
||||
}{
|
||||
{
|
||||
desc: "single-element",
|
||||
elements: []any{"devices"},
|
||||
want: "http://127.0.0.1:1234/api/v2/tailnet/example.com/devices",
|
||||
},
|
||||
{
|
||||
desc: "multiple-elements",
|
||||
elements: []any{"devices", 123},
|
||||
want: "http://127.0.0.1:1234/api/v2/tailnet/example.com/devices/123",
|
||||
},
|
||||
{
|
||||
desc: "escape-element",
|
||||
elements: []any{"foo bar?baz=qux"},
|
||||
want: `http://127.0.0.1:1234/api/v2/tailnet/example.com/foo%20bar%3Fbaz=qux`,
|
||||
},
|
||||
{
|
||||
desc: "url.Values",
|
||||
elements: []any{"acl", url.Values{"details": {"1"}}},
|
||||
want: `http://127.0.0.1:1234/api/v2/tailnet/example.com/acl?details=1`,
|
||||
},
|
||||
} {
|
||||
t.Run(tt.desc, func(t *testing.T) {
|
||||
got := c.BuildTailnetURL(tt.elements...)
|
||||
if got != tt.want {
|
||||
t.Errorf("got %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -203,35 +203,9 @@ func NewServer(opts ServerOpts) (s *Server, err error) {
|
||||
}
|
||||
s.assetsHandler, s.assetsCleanup = assetsHandler(s.devMode)
|
||||
|
||||
var metric string // clientmetric to report on startup
|
||||
|
||||
// Create handler for "/api" requests with CSRF protection.
|
||||
// We don't require secure cookies, since the web client is regularly used
|
||||
// on network appliances that are served on local non-https URLs.
|
||||
// The client is secured by limiting the interface it listens on,
|
||||
// or by authenticating requests before they reach the web client.
|
||||
csrfProtect := csrf.Protect(s.csrfKey(), csrf.Secure(false))
|
||||
|
||||
// signal to the CSRF middleware that the request is being served over
|
||||
// plaintext HTTP to skip TLS-only header checks.
|
||||
withSetPlaintext := func(h http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
r = csrf.PlaintextHTTPRequest(r)
|
||||
h.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
switch s.mode {
|
||||
case LoginServerMode:
|
||||
s.apiHandler = csrfProtect(withSetPlaintext(http.HandlerFunc(s.serveLoginAPI)))
|
||||
metric = "web_login_client_initialization"
|
||||
case ReadOnlyServerMode:
|
||||
s.apiHandler = csrfProtect(withSetPlaintext(http.HandlerFunc(s.serveLoginAPI)))
|
||||
metric = "web_readonly_client_initialization"
|
||||
case ManageServerMode:
|
||||
s.apiHandler = csrfProtect(withSetPlaintext(http.HandlerFunc(s.serveAPI)))
|
||||
metric = "web_client_initialization"
|
||||
}
|
||||
var metric string
|
||||
s.apiHandler, metric = s.modeAPIHandler(s.mode)
|
||||
s.apiHandler = s.withCSRF(s.apiHandler)
|
||||
|
||||
// Don't block startup on reporting metric.
|
||||
// Report in separate go routine with 5 second timeout.
|
||||
@@ -244,6 +218,39 @@ func NewServer(opts ServerOpts) (s *Server, err error) {
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Server) withCSRF(h http.Handler) http.Handler {
|
||||
csrfProtect := csrf.Protect(s.csrfKey(), csrf.Secure(false))
|
||||
|
||||
// ref https://github.com/tailscale/tailscale/pull/14822
|
||||
// signal to the CSRF middleware that the request is being served over
|
||||
// plaintext HTTP to skip TLS-only header checks.
|
||||
withSetPlaintext := func(h http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
r = csrf.PlaintextHTTPRequest(r)
|
||||
h.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// NB: the order of the withSetPlaintext and csrfProtect calls is important
|
||||
// to ensure that we signal to the CSRF middleware that the request is being
|
||||
// served over plaintext HTTP and not over TLS as it presumes by default.
|
||||
return withSetPlaintext(csrfProtect(h))
|
||||
}
|
||||
|
||||
func (s *Server) modeAPIHandler(mode ServerMode) (http.Handler, string) {
|
||||
switch mode {
|
||||
case LoginServerMode:
|
||||
return http.HandlerFunc(s.serveLoginAPI), "web_login_client_initialization"
|
||||
case ReadOnlyServerMode:
|
||||
return http.HandlerFunc(s.serveLoginAPI), "web_readonly_client_initialization"
|
||||
case ManageServerMode:
|
||||
return http.HandlerFunc(s.serveAPI), "web_client_initialization"
|
||||
default: // invalid mode
|
||||
log.Fatalf("invalid mode: %v", mode)
|
||||
}
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
func (s *Server) Shutdown() {
|
||||
s.logf("web.Server: shutting down")
|
||||
if s.assetsCleanup != nil {
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
@@ -20,6 +21,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/gorilla/csrf"
|
||||
"tailscale.com/client/local"
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
"tailscale.com/ipn"
|
||||
@@ -1477,3 +1479,83 @@ func mockWaitAuthURL(_ context.Context, id string, src tailcfg.NodeID) (*tailcfg
|
||||
return nil, errors.New("unknown id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRFProtect(t *testing.T) {
|
||||
s := &Server{}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /test/csrf-token", func(w http.ResponseWriter, r *http.Request) {
|
||||
token := csrf.Token(r)
|
||||
_, err := io.WriteString(w, token)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
mux.HandleFunc("POST /test/csrf-protected", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, err := io.WriteString(w, "ok")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
h := s.withCSRF(mux)
|
||||
ser := httptest.NewServer(h)
|
||||
defer ser.Close()
|
||||
|
||||
jar, err := cookiejar.New(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unable to construct cookie jar: %v", err)
|
||||
}
|
||||
|
||||
client := ser.Client()
|
||||
client.Jar = jar
|
||||
|
||||
// make GET request to populate cookie jar
|
||||
resp, err := client.Get(ser.URL + "/test/csrf-token")
|
||||
if err != nil {
|
||||
t.Fatalf("unable to make request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("unexpected status: %v", resp.Status)
|
||||
}
|
||||
tokenBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("unable to read body: %v", err)
|
||||
}
|
||||
|
||||
csrfToken := strings.TrimSpace(string(tokenBytes))
|
||||
if csrfToken == "" {
|
||||
t.Fatal("empty csrf token")
|
||||
}
|
||||
|
||||
// make a POST request without the CSRF header; ensure it fails
|
||||
resp, err = client.Post(ser.URL+"/test/csrf-protected", "text/plain", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unable to make request: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("unexpected status: %v", resp.Status)
|
||||
}
|
||||
|
||||
// make a POST request with the CSRF header; ensure it succeeds
|
||||
req, err := http.NewRequest("POST", ser.URL+"/test/csrf-protected", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("error building request: %v", err)
|
||||
}
|
||||
req.Header.Set("X-CSRF-Token", csrfToken)
|
||||
resp, err = client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("unable to make request: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("unexpected status: %v", resp.Status)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
out, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("unable to read body: %v", err)
|
||||
}
|
||||
if string(out) != "ok" {
|
||||
t.Fatalf("unexpected body: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,8 @@ func watchServeConfigChanges(ctx context.Context, path string, cdChanged <-chan
|
||||
var tickChan <-chan time.Time
|
||||
var eventChan <-chan fsnotify.Event
|
||||
if w, err := fsnotify.NewWatcher(); err != nil {
|
||||
// Creating a new fsnotify watcher would fail for example if inotify was not able to create a new file descriptor.
|
||||
// See https://github.com/tailscale/tailscale/issues/15081
|
||||
log.Printf("serve proxy: failed to create fsnotify watcher, timer-only mode: %v", err)
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
@@ -173,11 +173,14 @@ func tailscaleSet(ctx context.Context, cfg *settings) error {
|
||||
func watchTailscaledConfigChanges(ctx context.Context, path string, lc *local.Client, errCh chan<- error) {
|
||||
var (
|
||||
tickChan <-chan time.Time
|
||||
eventChan <-chan fsnotify.Event
|
||||
errChan <-chan error
|
||||
tailscaledCfgDir = filepath.Dir(path)
|
||||
prevTailscaledCfg []byte
|
||||
)
|
||||
w, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
if w, err := fsnotify.NewWatcher(); err != nil {
|
||||
// Creating a new fsnotify watcher would fail for example if inotify was not able to create a new file descriptor.
|
||||
// See https://github.com/tailscale/tailscale/issues/15081
|
||||
log.Printf("tailscaled config watch: failed to create fsnotify watcher, timer-only mode: %v", err)
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
@@ -188,6 +191,8 @@ func watchTailscaledConfigChanges(ctx context.Context, path string, lc *local.Cl
|
||||
errCh <- fmt.Errorf("failed to add fsnotify watch: %w", err)
|
||||
return
|
||||
}
|
||||
eventChan = w.Events
|
||||
errChan = w.Errors
|
||||
}
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
@@ -205,11 +210,11 @@ func watchTailscaledConfigChanges(ctx context.Context, path string, lc *local.Cl
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case err := <-w.Errors:
|
||||
case err := <-errChan:
|
||||
errCh <- fmt.Errorf("watcher error: %w", err)
|
||||
return
|
||||
case <-tickChan:
|
||||
case event := <-w.Events:
|
||||
case event := <-eventChan:
|
||||
if event.Name != toWatch {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -191,13 +191,11 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
golang.org/x/crypto/cryptobyte from crypto/ecdsa+
|
||||
golang.org/x/crypto/cryptobyte/asn1 from crypto/ecdsa+
|
||||
golang.org/x/crypto/curve25519 from golang.org/x/crypto/nacl/box+
|
||||
golang.org/x/crypto/hkdf from crypto/tls+
|
||||
golang.org/x/crypto/internal/alias from golang.org/x/crypto/chacha20+
|
||||
golang.org/x/crypto/internal/poly1305 from golang.org/x/crypto/chacha20poly1305+
|
||||
golang.org/x/crypto/nacl/box from tailscale.com/types/key
|
||||
golang.org/x/crypto/nacl/secretbox from golang.org/x/crypto/nacl/box
|
||||
golang.org/x/crypto/salsa20/salsa from golang.org/x/crypto/nacl/box+
|
||||
golang.org/x/crypto/sha3 from crypto/internal/mlkem768+
|
||||
W golang.org/x/exp/constraints from tailscale.com/util/winutil
|
||||
golang.org/x/exp/maps from tailscale.com/util/syspolicy/setting+
|
||||
L golang.org/x/net/bpf from github.com/mdlayher/netlink+
|
||||
@@ -230,7 +228,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
container/list from crypto/tls+
|
||||
context from crypto/tls+
|
||||
crypto from crypto/ecdh+
|
||||
crypto/aes from crypto/ecdsa+
|
||||
crypto/aes from crypto/internal/hpke+
|
||||
crypto/cipher from crypto/aes+
|
||||
crypto/des from crypto/tls+
|
||||
crypto/dsa from crypto/x509
|
||||
@@ -239,31 +237,58 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
crypto/ed25519 from crypto/tls+
|
||||
crypto/elliptic from crypto/ecdsa+
|
||||
crypto/hmac from crypto/tls+
|
||||
crypto/internal/alias from crypto/aes+
|
||||
crypto/internal/bigmod from crypto/ecdsa+
|
||||
crypto/internal/boring from crypto/aes+
|
||||
crypto/internal/boring/bbig from crypto/ecdsa+
|
||||
crypto/internal/boring/sig from crypto/internal/boring
|
||||
crypto/internal/edwards25519 from crypto/ed25519
|
||||
crypto/internal/edwards25519/field from crypto/ecdh+
|
||||
crypto/internal/entropy from crypto/internal/fips140/drbg
|
||||
crypto/internal/fips140 from crypto/internal/fips140/aes+
|
||||
crypto/internal/fips140/aes from crypto/aes+
|
||||
crypto/internal/fips140/aes/gcm from crypto/cipher+
|
||||
crypto/internal/fips140/alias from crypto/cipher+
|
||||
crypto/internal/fips140/bigmod from crypto/internal/fips140/ecdsa+
|
||||
crypto/internal/fips140/check from crypto/internal/fips140/aes+
|
||||
crypto/internal/fips140/drbg from crypto/internal/fips140/aes/gcm+
|
||||
crypto/internal/fips140/ecdh from crypto/ecdh
|
||||
crypto/internal/fips140/ecdsa from crypto/ecdsa
|
||||
crypto/internal/fips140/ed25519 from crypto/ed25519
|
||||
crypto/internal/fips140/edwards25519 from crypto/internal/fips140/ed25519
|
||||
crypto/internal/fips140/edwards25519/field from crypto/ecdh+
|
||||
crypto/internal/fips140/hkdf from crypto/internal/fips140/tls13+
|
||||
crypto/internal/fips140/hmac from crypto/hmac+
|
||||
crypto/internal/fips140/mlkem from crypto/tls
|
||||
crypto/internal/fips140/nistec from crypto/elliptic+
|
||||
crypto/internal/fips140/nistec/fiat from crypto/internal/fips140/nistec
|
||||
crypto/internal/fips140/rsa from crypto/rsa
|
||||
crypto/internal/fips140/sha256 from crypto/internal/fips140/check+
|
||||
crypto/internal/fips140/sha3 from crypto/internal/fips140/hmac+
|
||||
crypto/internal/fips140/sha512 from crypto/internal/fips140/ecdsa+
|
||||
crypto/internal/fips140/subtle from crypto/internal/fips140/aes+
|
||||
crypto/internal/fips140/tls12 from crypto/tls
|
||||
crypto/internal/fips140/tls13 from crypto/tls
|
||||
crypto/internal/fips140deps/byteorder from crypto/internal/fips140/aes+
|
||||
crypto/internal/fips140deps/cpu from crypto/internal/fips140/aes+
|
||||
crypto/internal/fips140deps/godebug from crypto/internal/fips140+
|
||||
crypto/internal/fips140hash from crypto/ecdsa+
|
||||
crypto/internal/fips140only from crypto/cipher+
|
||||
crypto/internal/hpke from crypto/tls
|
||||
crypto/internal/mlkem768 from crypto/tls
|
||||
crypto/internal/nistec from crypto/ecdh+
|
||||
crypto/internal/nistec/fiat from crypto/internal/nistec
|
||||
crypto/internal/impl from crypto/internal/fips140/aes+
|
||||
crypto/internal/randutil from crypto/dsa+
|
||||
crypto/internal/sysrand from crypto/internal/entropy+
|
||||
crypto/md5 from crypto/tls+
|
||||
crypto/rand from crypto/ed25519+
|
||||
crypto/rc4 from crypto/tls
|
||||
crypto/rsa from crypto/tls+
|
||||
crypto/sha1 from crypto/tls+
|
||||
crypto/sha256 from crypto/tls+
|
||||
crypto/sha3 from crypto/internal/fips140hash
|
||||
crypto/sha512 from crypto/ecdsa+
|
||||
crypto/subtle from crypto/aes+
|
||||
crypto/subtle from crypto/cipher+
|
||||
crypto/tls from golang.org/x/crypto/acme+
|
||||
crypto/tls/internal/fips140tls from crypto/tls
|
||||
crypto/x509 from crypto/tls+
|
||||
D crypto/x509/internal/macos from crypto/x509
|
||||
crypto/x509/pkix from crypto/x509+
|
||||
embed from crypto/internal/nistec+
|
||||
embed from google.golang.org/protobuf/internal/editiondefaults+
|
||||
encoding from encoding/json+
|
||||
encoding/asn1 from crypto/x509+
|
||||
encoding/base32 from github.com/fxamacker/cbor/v2+
|
||||
@@ -284,23 +309,22 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
html from net/http/pprof+
|
||||
html/template from tailscale.com/cmd/derper
|
||||
internal/abi from crypto/x509/internal/macos+
|
||||
internal/asan from syscall
|
||||
internal/asan from syscall+
|
||||
internal/bisect from internal/godebug
|
||||
internal/bytealg from bytes+
|
||||
internal/byteorder from crypto/aes+
|
||||
internal/byteorder from crypto/cipher+
|
||||
internal/chacha8rand from math/rand/v2+
|
||||
internal/concurrent from unique
|
||||
internal/coverage/rtcov from runtime
|
||||
internal/cpu from crypto/aes+
|
||||
internal/cpu from crypto/internal/fips140deps/cpu+
|
||||
internal/filepathlite from os+
|
||||
internal/fmtsort from fmt+
|
||||
internal/goarch from crypto/aes+
|
||||
internal/goarch from crypto/internal/fips140deps/cpu+
|
||||
internal/godebug from crypto/tls+
|
||||
internal/godebugs from internal/godebug+
|
||||
internal/goexperiment from runtime
|
||||
internal/goexperiment from runtime+
|
||||
internal/goos from crypto/x509+
|
||||
internal/itoa from internal/poll+
|
||||
internal/msan from syscall
|
||||
internal/msan from syscall+
|
||||
internal/nettrace from net+
|
||||
internal/oserror from io/fs+
|
||||
internal/poll from net+
|
||||
@@ -310,17 +334,20 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
internal/reflectlite from context+
|
||||
internal/runtime/atomic from internal/runtime/exithook+
|
||||
internal/runtime/exithook from runtime
|
||||
internal/runtime/maps from reflect+
|
||||
internal/runtime/math from internal/runtime/maps+
|
||||
internal/runtime/sys from crypto/subtle+
|
||||
L internal/runtime/syscall from runtime+
|
||||
internal/singleflight from net
|
||||
internal/stringslite from embed+
|
||||
internal/sync from sync+
|
||||
internal/syscall/execenv from os+
|
||||
LD internal/syscall/unix from crypto/rand+
|
||||
W internal/syscall/windows from crypto/rand+
|
||||
LD internal/syscall/unix from crypto/internal/sysrand+
|
||||
W internal/syscall/windows from crypto/internal/sysrand+
|
||||
W internal/syscall/windows/registry from mime+
|
||||
W internal/syscall/windows/sysdll from internal/syscall/windows+
|
||||
internal/testlog from os
|
||||
internal/unsafeheader from internal/reflectlite+
|
||||
internal/weak from unique
|
||||
io from bufio+
|
||||
io/fs from crypto/x509+
|
||||
L io/ioutil from github.com/mitchellh/go-ps+
|
||||
@@ -332,7 +359,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
math/big from crypto/dsa+
|
||||
math/bits from compress/flate+
|
||||
math/rand from github.com/mdlayher/netlink+
|
||||
math/rand/v2 from internal/concurrent+
|
||||
math/rand/v2 from crypto/ecdsa+
|
||||
mime from github.com/prometheus/common/expfmt+
|
||||
mime/multipart from net/http
|
||||
mime/quotedprintable from mime/multipart
|
||||
@@ -345,7 +372,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
net/netip from go4.org/netipx+
|
||||
net/textproto from golang.org/x/net/http/httpguts+
|
||||
net/url from crypto/x509+
|
||||
os from crypto/rand+
|
||||
os from crypto/internal/sysrand+
|
||||
os/exec from github.com/coreos/go-iptables/iptables+
|
||||
os/signal from tailscale.com/cmd/derper
|
||||
W os/user from tailscale.com/util/winutil+
|
||||
@@ -354,10 +381,8 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
reflect from crypto/x509+
|
||||
regexp from github.com/coreos/go-iptables/iptables+
|
||||
regexp/syntax from regexp
|
||||
runtime from crypto/internal/nistec+
|
||||
runtime from crypto/internal/fips140+
|
||||
runtime/debug from github.com/prometheus/client_golang/prometheus+
|
||||
runtime/internal/math from runtime
|
||||
runtime/internal/sys from runtime
|
||||
runtime/metrics from github.com/prometheus/client_golang/prometheus+
|
||||
runtime/pprof from net/http/pprof
|
||||
runtime/trace from net/http/pprof
|
||||
@@ -367,7 +392,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
strings from bufio+
|
||||
sync from compress/flate+
|
||||
sync/atomic from context+
|
||||
syscall from crypto/rand+
|
||||
syscall from crypto/internal/sysrand+
|
||||
text/tabwriter from runtime/pprof
|
||||
text/template from html/template
|
||||
text/template/parse from html/template+
|
||||
@@ -377,3 +402,4 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
unicode/utf8 from bufio+
|
||||
unique from net/netip
|
||||
unsafe from bytes+
|
||||
weak from unique
|
||||
|
||||
@@ -71,10 +71,13 @@ var (
|
||||
secretsCacheDir = flag.String("secrets-cache-dir", defaultSetecCacheDir(), "directory to cache setec secrets in (required if --secrets-url is set)")
|
||||
bootstrapDNS = flag.String("bootstrap-dns-names", "", "optional comma-separated list of hostnames to make available at /bootstrap-dns")
|
||||
unpublishedDNS = flag.String("unpublished-bootstrap-dns-names", "", "optional comma-separated list of hostnames to make available at /bootstrap-dns and not publish in the list. If an entry contains a slash, the second part names a DNS record to poll for its TXT record with a `0` to `100` value for rollout percentage.")
|
||||
|
||||
verifyClients = flag.Bool("verify-clients", false, "verify clients to this DERP server through a local tailscaled instance.")
|
||||
verifyClientURL = flag.String("verify-client-url", "", "if non-empty, an admission controller URL for permitting client connections; see tailcfg.DERPAdmitClientRequest")
|
||||
verifyFailOpen = flag.Bool("verify-client-url-fail-open", true, "whether we fail open if --verify-client-url is unreachable")
|
||||
|
||||
socket = flag.String("socket", "", "optional alternate path to tailscaled socket (only relevant when using --verify-clients)")
|
||||
|
||||
acceptConnLimit = flag.Float64("accept-connection-limit", math.Inf(+1), "rate limit for accepting new connection")
|
||||
acceptConnBurst = flag.Int("accept-connection-burst", math.MaxInt, "burst limit for accepting new connection")
|
||||
|
||||
@@ -192,6 +195,7 @@ func main() {
|
||||
|
||||
s := derp.NewServer(cfg.PrivateKey, log.Printf)
|
||||
s.SetVerifyClient(*verifyClients)
|
||||
s.SetTailscaledSocketPath(*socket)
|
||||
s.SetVerifyClientURL(*verifyClientURL)
|
||||
s.SetVerifyClientURLFailOpen(*verifyFailOpen)
|
||||
s.SetTCPWriteTimeout(*tcpWriteTimeout)
|
||||
@@ -324,6 +328,9 @@ func main() {
|
||||
Control: ktimeout.UserTimeout(*tcpUserTimeout),
|
||||
KeepAlive: *tcpKeepAlive,
|
||||
}
|
||||
// As of 2025-02-19, MPTCP does not support TCP_USER_TIMEOUT socket option
|
||||
// set in ktimeout.UserTimeout above.
|
||||
lc.SetMultipathTCP(false)
|
||||
|
||||
quietLogger := log.New(logger.HTTPServerLogFilter{Inner: log.Printf}, "", 0)
|
||||
httpsrv := &http.Server{
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -405,7 +406,8 @@ func getACLETag(ctx context.Context, client *http.Client, tailnet, apiKey string
|
||||
got := resp.StatusCode
|
||||
want := http.StatusOK
|
||||
if got != want {
|
||||
return "", fmt.Errorf("wanted HTTP status code %d but got %d", want, got)
|
||||
errorDetails, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("wanted HTTP status code %d but got %d: %#q", want, got, string(errorDetails))
|
||||
}
|
||||
|
||||
return Shuck(resp.Header.Get("ETag")), nil
|
||||
|
||||
@@ -997,14 +997,13 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
||||
golang.org/x/crypto/cryptobyte from crypto/ecdsa+
|
||||
golang.org/x/crypto/cryptobyte/asn1 from crypto/ecdsa+
|
||||
golang.org/x/crypto/curve25519 from golang.org/x/crypto/ssh+
|
||||
golang.org/x/crypto/hkdf from crypto/tls+
|
||||
golang.org/x/crypto/hkdf from tailscale.com/control/controlbase
|
||||
golang.org/x/crypto/internal/alias from golang.org/x/crypto/chacha20+
|
||||
golang.org/x/crypto/internal/poly1305 from golang.org/x/crypto/chacha20poly1305+
|
||||
golang.org/x/crypto/nacl/box from tailscale.com/types/key
|
||||
golang.org/x/crypto/nacl/secretbox from golang.org/x/crypto/nacl/box
|
||||
golang.org/x/crypto/poly1305 from github.com/tailscale/wireguard-go/device
|
||||
golang.org/x/crypto/salsa20/salsa from golang.org/x/crypto/nacl/box+
|
||||
golang.org/x/crypto/sha3 from crypto/internal/mlkem768+
|
||||
LD golang.org/x/crypto/ssh from tailscale.com/ipn/ipnlocal
|
||||
LD golang.org/x/crypto/ssh/internal/bcrypt_pbkdf from golang.org/x/crypto/ssh
|
||||
golang.org/x/exp/constraints from github.com/dblohm7/wingoes/pe+
|
||||
@@ -1055,7 +1054,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
||||
container/list from crypto/tls+
|
||||
context from crypto/tls+
|
||||
crypto from crypto/ecdh+
|
||||
crypto/aes from crypto/ecdsa+
|
||||
crypto/aes from crypto/internal/hpke+
|
||||
crypto/cipher from crypto/aes+
|
||||
crypto/des from crypto/tls+
|
||||
crypto/dsa from crypto/x509+
|
||||
@@ -1064,27 +1063,54 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
||||
crypto/ed25519 from crypto/tls+
|
||||
crypto/elliptic from crypto/ecdsa+
|
||||
crypto/hmac from crypto/tls+
|
||||
crypto/internal/alias from crypto/aes+
|
||||
crypto/internal/bigmod from crypto/ecdsa+
|
||||
crypto/internal/boring from crypto/aes+
|
||||
crypto/internal/boring/bbig from crypto/ecdsa+
|
||||
crypto/internal/boring/sig from crypto/internal/boring
|
||||
crypto/internal/edwards25519 from crypto/ed25519
|
||||
crypto/internal/edwards25519/field from crypto/ecdh+
|
||||
crypto/internal/entropy from crypto/internal/fips140/drbg
|
||||
crypto/internal/fips140 from crypto/internal/fips140/aes+
|
||||
crypto/internal/fips140/aes from crypto/aes+
|
||||
crypto/internal/fips140/aes/gcm from crypto/cipher+
|
||||
crypto/internal/fips140/alias from crypto/cipher+
|
||||
crypto/internal/fips140/bigmod from crypto/internal/fips140/ecdsa+
|
||||
crypto/internal/fips140/check from crypto/internal/fips140/aes+
|
||||
crypto/internal/fips140/drbg from crypto/internal/fips140/aes/gcm+
|
||||
crypto/internal/fips140/ecdh from crypto/ecdh
|
||||
crypto/internal/fips140/ecdsa from crypto/ecdsa
|
||||
crypto/internal/fips140/ed25519 from crypto/ed25519
|
||||
crypto/internal/fips140/edwards25519 from crypto/internal/fips140/ed25519
|
||||
crypto/internal/fips140/edwards25519/field from crypto/ecdh+
|
||||
crypto/internal/fips140/hkdf from crypto/internal/fips140/tls13+
|
||||
crypto/internal/fips140/hmac from crypto/hmac+
|
||||
crypto/internal/fips140/mlkem from crypto/tls
|
||||
crypto/internal/fips140/nistec from crypto/elliptic+
|
||||
crypto/internal/fips140/nistec/fiat from crypto/internal/fips140/nistec
|
||||
crypto/internal/fips140/rsa from crypto/rsa
|
||||
crypto/internal/fips140/sha256 from crypto/internal/fips140/check+
|
||||
crypto/internal/fips140/sha3 from crypto/internal/fips140/hmac+
|
||||
crypto/internal/fips140/sha512 from crypto/internal/fips140/ecdsa+
|
||||
crypto/internal/fips140/subtle from crypto/internal/fips140/aes+
|
||||
crypto/internal/fips140/tls12 from crypto/tls
|
||||
crypto/internal/fips140/tls13 from crypto/tls
|
||||
crypto/internal/fips140deps/byteorder from crypto/internal/fips140/aes+
|
||||
crypto/internal/fips140deps/cpu from crypto/internal/fips140/aes+
|
||||
crypto/internal/fips140deps/godebug from crypto/internal/fips140+
|
||||
crypto/internal/fips140hash from crypto/ecdsa+
|
||||
crypto/internal/fips140only from crypto/cipher+
|
||||
crypto/internal/hpke from crypto/tls
|
||||
crypto/internal/mlkem768 from crypto/tls
|
||||
crypto/internal/nistec from crypto/ecdh+
|
||||
crypto/internal/nistec/fiat from crypto/internal/nistec
|
||||
crypto/internal/impl from crypto/internal/fips140/aes+
|
||||
crypto/internal/randutil from crypto/dsa+
|
||||
crypto/internal/sysrand from crypto/internal/entropy+
|
||||
crypto/md5 from crypto/tls+
|
||||
crypto/rand from crypto/ed25519+
|
||||
crypto/rc4 from crypto/tls+
|
||||
crypto/rsa from crypto/tls+
|
||||
crypto/sha1 from crypto/tls+
|
||||
crypto/sha256 from crypto/tls+
|
||||
crypto/sha3 from crypto/internal/fips140hash
|
||||
crypto/sha512 from crypto/ecdsa+
|
||||
crypto/subtle from crypto/aes+
|
||||
crypto/subtle from crypto/cipher+
|
||||
crypto/tls from github.com/aws/aws-sdk-go-v2/aws/transport/http+
|
||||
crypto/tls/internal/fips140tls from crypto/tls
|
||||
crypto/x509 from crypto/tls+
|
||||
D crypto/x509/internal/macos from crypto/x509
|
||||
crypto/x509/pkix from crypto/x509+
|
||||
@@ -1092,7 +1118,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
||||
database/sql/driver from database/sql+
|
||||
W debug/dwarf from debug/pe
|
||||
W debug/pe from github.com/dblohm7/wingoes/pe
|
||||
embed from crypto/internal/nistec+
|
||||
embed from github.com/tailscale/web-client-prebuilt+
|
||||
encoding from encoding/gob+
|
||||
encoding/asn1 from crypto/x509+
|
||||
encoding/base32 from github.com/fxamacker/cbor/v2+
|
||||
@@ -1112,7 +1138,6 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
||||
go/build/constraint from go/parser
|
||||
go/doc from k8s.io/apimachinery/pkg/runtime
|
||||
go/doc/comment from go/doc
|
||||
go/internal/typeparams from go/parser
|
||||
go/parser from k8s.io/apimachinery/pkg/runtime
|
||||
go/scanner from go/ast+
|
||||
go/token from go/ast+
|
||||
@@ -1124,24 +1149,23 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
||||
html from html/template+
|
||||
html/template from github.com/gorilla/csrf
|
||||
internal/abi from crypto/x509/internal/macos+
|
||||
internal/asan from syscall
|
||||
internal/asan from syscall+
|
||||
internal/bisect from internal/godebug
|
||||
internal/bytealg from bytes+
|
||||
internal/byteorder from crypto/aes+
|
||||
internal/byteorder from crypto/cipher+
|
||||
internal/chacha8rand from math/rand/v2+
|
||||
internal/concurrent from unique
|
||||
internal/coverage/rtcov from runtime
|
||||
internal/cpu from crypto/aes+
|
||||
internal/cpu from crypto/internal/fips140deps/cpu+
|
||||
internal/filepathlite from os+
|
||||
internal/fmtsort from fmt+
|
||||
internal/goarch from crypto/aes+
|
||||
internal/goarch from crypto/internal/fips140deps/cpu+
|
||||
internal/godebug from archive/tar+
|
||||
internal/godebugs from internal/godebug+
|
||||
internal/goexperiment from runtime
|
||||
internal/goexperiment from runtime+
|
||||
internal/goos from crypto/x509+
|
||||
internal/itoa from internal/poll+
|
||||
internal/lazyregexp from go/doc
|
||||
internal/msan from syscall
|
||||
internal/msan from syscall+
|
||||
internal/nettrace from net+
|
||||
internal/oserror from io/fs+
|
||||
internal/poll from net+
|
||||
@@ -1151,18 +1175,21 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
||||
internal/reflectlite from context+
|
||||
internal/runtime/atomic from internal/runtime/exithook+
|
||||
internal/runtime/exithook from runtime
|
||||
internal/runtime/maps from reflect+
|
||||
internal/runtime/math from internal/runtime/maps+
|
||||
internal/runtime/sys from crypto/subtle+
|
||||
L internal/runtime/syscall from runtime+
|
||||
internal/saferio from debug/pe+
|
||||
internal/singleflight from net
|
||||
internal/stringslite from embed+
|
||||
internal/sync from sync+
|
||||
internal/syscall/execenv from os+
|
||||
LD internal/syscall/unix from crypto/rand+
|
||||
W internal/syscall/windows from crypto/rand+
|
||||
LD internal/syscall/unix from crypto/internal/sysrand+
|
||||
W internal/syscall/windows from crypto/internal/sysrand+
|
||||
W internal/syscall/windows/registry from mime+
|
||||
W internal/syscall/windows/sysdll from internal/syscall/windows+
|
||||
internal/testlog from os
|
||||
internal/unsafeheader from internal/reflectlite+
|
||||
internal/weak from unique
|
||||
io from archive/tar+
|
||||
io/fs from archive/tar+
|
||||
io/ioutil from github.com/aws/aws-sdk-go-v2/aws/protocol/query+
|
||||
@@ -1191,7 +1218,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
||||
net/netip from github.com/gaissmai/bart+
|
||||
net/textproto from github.com/aws/aws-sdk-go-v2/aws/signer/v4+
|
||||
net/url from crypto/x509+
|
||||
os from crypto/rand+
|
||||
os from crypto/internal/sysrand+
|
||||
os/exec from github.com/aws/aws-sdk-go-v2/credentials/processcreds+
|
||||
os/signal from sigs.k8s.io/controller-runtime/pkg/manager/signals
|
||||
os/user from archive/tar+
|
||||
@@ -1202,8 +1229,6 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
||||
regexp/syntax from regexp
|
||||
runtime from archive/tar+
|
||||
runtime/debug from github.com/aws/aws-sdk-go-v2/internal/sync/singleflight+
|
||||
runtime/internal/math from runtime
|
||||
runtime/internal/sys from runtime
|
||||
runtime/metrics from github.com/prometheus/client_golang/prometheus+
|
||||
runtime/pprof from net/http/pprof+
|
||||
runtime/trace from net/http/pprof
|
||||
@@ -1223,3 +1248,4 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
||||
unicode/utf8 from bufio+
|
||||
unique from net/netip
|
||||
unsafe from bytes+
|
||||
weak from unique
|
||||
|
||||
@@ -26,7 +26,7 @@ import (
|
||||
"k8s.io/client-go/tools/record"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/internal/client/tailscale"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
tsoperator "tailscale.com/k8s-operator"
|
||||
@@ -186,7 +186,7 @@ func (a *IngressPGReconciler) maybeProvision(ctx context.Context, hostname strin
|
||||
}
|
||||
dnsName := hostname + "." + tcd
|
||||
serviceName := tailcfg.ServiceName("svc:" + hostname)
|
||||
existingVIPSvc, err := a.tsClient.getVIPService(ctx, serviceName)
|
||||
existingVIPSvc, err := a.tsClient.GetVIPService(ctx, serviceName)
|
||||
// TODO(irbekrm): here and when creating the VIPService, verify if the error is not terminal (and therefore
|
||||
// should not be reconciled). For example, if the hostname is already a hostname of a Tailscale node, the GET
|
||||
// here will fail.
|
||||
@@ -269,7 +269,7 @@ func (a *IngressPGReconciler) maybeProvision(ctx context.Context, hostname strin
|
||||
vipPorts = append(vipPorts, "80")
|
||||
}
|
||||
|
||||
vipSvc := &VIPService{
|
||||
vipSvc := &tailscale.VIPService{
|
||||
Name: serviceName,
|
||||
Tags: tags,
|
||||
Ports: vipPorts,
|
||||
@@ -282,7 +282,7 @@ func (a *IngressPGReconciler) maybeProvision(ctx context.Context, hostname strin
|
||||
!reflect.DeepEqual(vipSvc.Tags, existingVIPSvc.Tags) ||
|
||||
!reflect.DeepEqual(vipSvc.Ports, existingVIPSvc.Ports) {
|
||||
logger.Infof("Ensuring VIPService %q exists and is up to date", hostname)
|
||||
if err := a.tsClient.createOrUpdateVIPService(ctx, vipSvc); err != nil {
|
||||
if err := a.tsClient.CreateOrUpdateVIPService(ctx, vipSvc); err != nil {
|
||||
logger.Infof("error creating VIPService: %v", err)
|
||||
return fmt.Errorf("error creating VIPService: %w", err)
|
||||
}
|
||||
@@ -361,7 +361,7 @@ func (a *IngressPGReconciler) maybeCleanupProxyGroup(ctx context.Context, proxyG
|
||||
}
|
||||
if isVIPServiceForAnyIngress(svc) {
|
||||
logger.Infof("cleaning up orphaned VIPService %q", vipServiceName)
|
||||
if err := a.tsClient.deleteVIPService(ctx, vipServiceName); err != nil {
|
||||
if err := a.tsClient.DeleteVIPService(ctx, vipServiceName); err != nil {
|
||||
errResp := &tailscale.ErrResponse{}
|
||||
if !errors.As(err, &errResp) || errResp.Status != http.StatusNotFound {
|
||||
return fmt.Errorf("deleting VIPService %q: %w", vipServiceName, err)
|
||||
@@ -509,8 +509,8 @@ func (a *IngressPGReconciler) shouldExpose(ing *networkingv1.Ingress) bool {
|
||||
return isTSIngress && pgAnnot != ""
|
||||
}
|
||||
|
||||
func (a *IngressPGReconciler) getVIPService(ctx context.Context, name tailcfg.ServiceName, logger *zap.SugaredLogger) (*VIPService, error) {
|
||||
svc, err := a.tsClient.getVIPService(ctx, name)
|
||||
func (a *IngressPGReconciler) getVIPService(ctx context.Context, name tailcfg.ServiceName, logger *zap.SugaredLogger) (*tailscale.VIPService, error) {
|
||||
svc, err := a.tsClient.GetVIPService(ctx, name)
|
||||
if err != nil {
|
||||
errResp := &tailscale.ErrResponse{}
|
||||
if ok := errors.As(err, errResp); ok && errResp.Status != http.StatusNotFound {
|
||||
@@ -521,14 +521,14 @@ func (a *IngressPGReconciler) getVIPService(ctx context.Context, name tailcfg.Se
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
func isVIPServiceForIngress(svc *VIPService, ing *networkingv1.Ingress) bool {
|
||||
func isVIPServiceForIngress(svc *tailscale.VIPService, ing *networkingv1.Ingress) bool {
|
||||
if svc == nil || ing == nil {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(svc.Comment, fmt.Sprintf(VIPSvcOwnerRef, ing.UID))
|
||||
}
|
||||
|
||||
func isVIPServiceForAnyIngress(svc *VIPService) bool {
|
||||
func isVIPServiceForAnyIngress(svc *tailscale.VIPService) bool {
|
||||
if svc == nil {
|
||||
return false
|
||||
}
|
||||
@@ -593,7 +593,7 @@ func (a *IngressPGReconciler) deleteVIPServiceIfExists(ctx context.Context, name
|
||||
}
|
||||
|
||||
logger.Infof("Deleting VIPService %q", name)
|
||||
if err = a.tsClient.deleteVIPService(ctx, name); err != nil {
|
||||
if err = a.tsClient.DeleteVIPService(ctx, name); err != nil {
|
||||
return fmt.Errorf("error deleting VIPService: %w", err)
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -70,7 +70,7 @@ func TestIngressPGReconciler(t *testing.T) {
|
||||
expectReconciled(t, ingPGR, "default", "test-ingress")
|
||||
|
||||
// Verify VIPService uses custom tags
|
||||
vipSvc, err := ft.getVIPService(context.Background(), "svc:my-svc")
|
||||
vipSvc, err := ft.GetVIPService(context.Background(), "svc:my-svc")
|
||||
if err != nil {
|
||||
t.Fatalf("getting VIPService: %v", err)
|
||||
}
|
||||
@@ -398,7 +398,7 @@ func TestIngressPGReconciler_HTTPEndpoint(t *testing.T) {
|
||||
|
||||
func verifyVIPService(t *testing.T, ft *fakeTSClient, serviceName string, wantPorts []string) {
|
||||
t.Helper()
|
||||
vipSvc, err := ft.getVIPService(context.Background(), tailcfg.ServiceName(serviceName))
|
||||
vipSvc, err := ft.GetVIPService(context.Background(), tailcfg.ServiceName(serviceName))
|
||||
if err != nil {
|
||||
t.Fatalf("getting VIPService %q: %v", serviceName, err)
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ import (
|
||||
"k8s.io/client-go/tools/record"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/internal/client/tailscale"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||
@@ -768,7 +768,7 @@ type fakeTSClient struct {
|
||||
sync.Mutex
|
||||
keyRequests []tailscale.KeyCapabilities
|
||||
deleted []string
|
||||
vipServices map[tailcfg.ServiceName]*VIPService
|
||||
vipServices map[tailcfg.ServiceName]*tailscale.VIPService
|
||||
}
|
||||
type fakeTSNetServer struct {
|
||||
certDomains []string
|
||||
@@ -875,7 +875,7 @@ func removeAuthKeyIfExistsModifier(t *testing.T) func(s *corev1.Secret) {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *fakeTSClient) getVIPService(ctx context.Context, name tailcfg.ServiceName) (*VIPService, error) {
|
||||
func (c *fakeTSClient) GetVIPService(ctx context.Context, name tailcfg.ServiceName) (*tailscale.VIPService, error) {
|
||||
c.Lock()
|
||||
defer c.Unlock()
|
||||
if c.vipServices == nil {
|
||||
@@ -888,17 +888,17 @@ func (c *fakeTSClient) getVIPService(ctx context.Context, name tailcfg.ServiceNa
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
func (c *fakeTSClient) createOrUpdateVIPService(ctx context.Context, svc *VIPService) error {
|
||||
func (c *fakeTSClient) CreateOrUpdateVIPService(ctx context.Context, svc *tailscale.VIPService) error {
|
||||
c.Lock()
|
||||
defer c.Unlock()
|
||||
if c.vipServices == nil {
|
||||
c.vipServices = make(map[tailcfg.ServiceName]*VIPService)
|
||||
c.vipServices = make(map[tailcfg.ServiceName]*tailscale.VIPService)
|
||||
}
|
||||
c.vipServices[svc.Name] = svc
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *fakeTSClient) deleteVIPService(ctx context.Context, name tailcfg.ServiceName) error {
|
||||
func (c *fakeTSClient) DeleteVIPService(ctx context.Context, name tailcfg.ServiceName) error {
|
||||
c.Lock()
|
||||
defer c.Unlock()
|
||||
if c.vipServices != nil {
|
||||
|
||||
@@ -6,19 +6,13 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
|
||||
"golang.org/x/oauth2/clientcredentials"
|
||||
"tailscale.com/internal/client/tailscale"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/util/httpm"
|
||||
)
|
||||
|
||||
// defaultTailnet is a value that can be used in Tailscale API calls instead of tailnet name to indicate that the API
|
||||
@@ -45,141 +39,14 @@ func newTSClient(ctx context.Context, clientIDPath, clientSecretPath string) (ts
|
||||
c := tailscale.NewClient(defaultTailnet, nil)
|
||||
c.UserAgent = "tailscale-k8s-operator"
|
||||
c.HTTPClient = credentials.Client(ctx)
|
||||
tsc := &tsClientImpl{
|
||||
Client: c,
|
||||
baseURL: defaultBaseURL,
|
||||
tailnet: defaultTailnet,
|
||||
}
|
||||
return tsc, nil
|
||||
return c, nil
|
||||
}
|
||||
|
||||
type tsClient interface {
|
||||
CreateKey(ctx context.Context, caps tailscale.KeyCapabilities) (string, *tailscale.Key, error)
|
||||
Device(ctx context.Context, deviceID string, fields *tailscale.DeviceFieldsOpts) (*tailscale.Device, error)
|
||||
DeleteDevice(ctx context.Context, nodeStableID string) error
|
||||
getVIPService(ctx context.Context, name tailcfg.ServiceName) (*VIPService, error)
|
||||
createOrUpdateVIPService(ctx context.Context, svc *VIPService) error
|
||||
deleteVIPService(ctx context.Context, name tailcfg.ServiceName) error
|
||||
}
|
||||
|
||||
type tsClientImpl struct {
|
||||
*tailscale.Client
|
||||
baseURL string
|
||||
tailnet string
|
||||
}
|
||||
|
||||
// VIPService is a Tailscale VIPService with Tailscale API JSON representation.
|
||||
type VIPService struct {
|
||||
// Name is a VIPService name in form svc:<leftmost-label-of-service-DNS-name>.
|
||||
Name tailcfg.ServiceName `json:"name,omitempty"`
|
||||
// Addrs are the IP addresses of the VIP Service. There are two addresses:
|
||||
// the first is IPv4 and the second is IPv6.
|
||||
// When creating a new VIP Service, the IP addresses are optional: if no
|
||||
// addresses are specified then they will be selected. If an IPv4 address is
|
||||
// specified at index 0, then that address will attempt to be used. An IPv6
|
||||
// address can not be specified upon creation.
|
||||
Addrs []string `json:"addrs,omitempty"`
|
||||
// Comment is an optional text string for display in the admin panel.
|
||||
Comment string `json:"comment,omitempty"`
|
||||
// Ports are the ports of a VIPService that will be configured via Tailscale serve config.
|
||||
// If set, any node wishing to advertise this VIPService must have this port configured via Tailscale serve.
|
||||
Ports []string `json:"ports,omitempty"`
|
||||
// Tags are optional ACL tags that will be applied to the VIPService.
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
}
|
||||
|
||||
// GetVIPServiceByName retrieves a VIPService by its name. It returns 404 if the VIPService is not found.
|
||||
func (c *tsClientImpl) getVIPService(ctx context.Context, name tailcfg.ServiceName) (*VIPService, error) {
|
||||
path := fmt.Sprintf("%s/api/v2/tailnet/%s/vip-services/%s", c.baseURL, c.tailnet, url.PathEscape(name.String()))
|
||||
req, err := http.NewRequestWithContext(ctx, httpm.GET, path, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error creating new HTTP request: %w", err)
|
||||
}
|
||||
b, resp, err := c.sendRequest(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error making Tailsale API request: %w", err)
|
||||
}
|
||||
// If status code was not successful, return the error.
|
||||
// TODO: Change the check for the StatusCode to include other 2XX success codes.
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, handleErrorResponse(b, resp)
|
||||
}
|
||||
svc := &VIPService{}
|
||||
if err := json.Unmarshal(b, svc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
// createOrUpdateVIPService creates or updates a VIPService by its name. Caller must ensure that, if the
|
||||
// VIPService already exists, the VIPService is fetched first to ensure that any auto-allocated IP addresses are not
|
||||
// lost during the update. If the VIPService was created without any IP addresses explicitly set (so that they were
|
||||
// auto-allocated by Tailscale) any subsequent request to this function that does not set any IP addresses will error.
|
||||
func (c *tsClientImpl) createOrUpdateVIPService(ctx context.Context, svc *VIPService) error {
|
||||
data, err := json.Marshal(svc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := fmt.Sprintf("%s/api/v2/tailnet/%s/vip-services/%s", c.baseURL, c.tailnet, url.PathEscape(svc.Name.String()))
|
||||
req, err := http.NewRequestWithContext(ctx, httpm.PUT, path, bytes.NewBuffer(data))
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating new HTTP request: %w", err)
|
||||
}
|
||||
b, resp, err := c.sendRequest(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error making Tailscale API request: %w", err)
|
||||
}
|
||||
// If status code was not successful, return the error.
|
||||
// TODO: Change the check for the StatusCode to include other 2XX success codes.
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return handleErrorResponse(b, resp)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteVIPServiceByName deletes a VIPService by its name. It returns an error if the VIPService
|
||||
// does not exist or if the deletion fails.
|
||||
func (c *tsClientImpl) deleteVIPService(ctx context.Context, name tailcfg.ServiceName) error {
|
||||
path := fmt.Sprintf("%s/api/v2/tailnet/%s/vip-services/%s", c.baseURL, c.tailnet, url.PathEscape(name.String()))
|
||||
req, err := http.NewRequestWithContext(ctx, httpm.DELETE, path, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating new HTTP request: %w", err)
|
||||
}
|
||||
b, resp, err := c.sendRequest(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error making Tailscale API request: %w", err)
|
||||
}
|
||||
// If status code was not successful, return the error.
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return handleErrorResponse(b, resp)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendRequest add the authentication key to the request and sends it. It
|
||||
// receives the response and reads up to 10MB of it.
|
||||
func (c *tsClientImpl) sendRequest(req *http.Request) ([]byte, *http.Response, error) {
|
||||
resp, err := c.Do(req)
|
||||
if err != nil {
|
||||
return nil, resp, fmt.Errorf("error actually doing request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read response
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("error reading response body: %v", err)
|
||||
}
|
||||
return b, resp, err
|
||||
}
|
||||
|
||||
// handleErrorResponse decodes the error message from the server and returns
|
||||
// an ErrResponse from it.
|
||||
func handleErrorResponse(b []byte, resp *http.Response) error {
|
||||
var errResp tailscale.ErrResponse
|
||||
if err := json.Unmarshal(b, &errResp); err != nil {
|
||||
return err
|
||||
}
|
||||
errResp.Status = resp.StatusCode
|
||||
return errResp
|
||||
GetVIPService(ctx context.Context, name tailcfg.ServiceName) (*tailscale.VIPService, error)
|
||||
CreateOrUpdateVIPService(ctx context.Context, svc *tailscale.VIPService) error
|
||||
DeleteVIPService(ctx context.Context, name tailcfg.ServiceName) error
|
||||
}
|
||||
|
||||
@@ -88,13 +88,11 @@ tailscale.com/cmd/stund dependencies: (generated by github.com/tailscale/depawar
|
||||
golang.org/x/crypto/cryptobyte from crypto/ecdsa+
|
||||
golang.org/x/crypto/cryptobyte/asn1 from crypto/ecdsa+
|
||||
golang.org/x/crypto/curve25519 from golang.org/x/crypto/nacl/box+
|
||||
golang.org/x/crypto/hkdf from crypto/tls+
|
||||
golang.org/x/crypto/internal/alias from golang.org/x/crypto/chacha20+
|
||||
golang.org/x/crypto/internal/poly1305 from golang.org/x/crypto/chacha20poly1305+
|
||||
golang.org/x/crypto/nacl/box from tailscale.com/types/key
|
||||
golang.org/x/crypto/nacl/secretbox from golang.org/x/crypto/nacl/box
|
||||
golang.org/x/crypto/salsa20/salsa from golang.org/x/crypto/nacl/box+
|
||||
golang.org/x/crypto/sha3 from crypto/internal/mlkem768+
|
||||
golang.org/x/net/dns/dnsmessage from net+
|
||||
golang.org/x/net/http/httpguts from net/http
|
||||
golang.org/x/net/http/httpproxy from net/http
|
||||
@@ -116,7 +114,7 @@ tailscale.com/cmd/stund dependencies: (generated by github.com/tailscale/depawar
|
||||
container/list from crypto/tls+
|
||||
context from crypto/tls+
|
||||
crypto from crypto/ecdh+
|
||||
crypto/aes from crypto/ecdsa+
|
||||
crypto/aes from crypto/internal/hpke+
|
||||
crypto/cipher from crypto/aes+
|
||||
crypto/des from crypto/tls+
|
||||
crypto/dsa from crypto/x509
|
||||
@@ -124,32 +122,59 @@ tailscale.com/cmd/stund dependencies: (generated by github.com/tailscale/depawar
|
||||
crypto/ecdsa from crypto/tls+
|
||||
crypto/ed25519 from crypto/tls+
|
||||
crypto/elliptic from crypto/ecdsa+
|
||||
crypto/hmac from crypto/tls+
|
||||
crypto/internal/alias from crypto/aes+
|
||||
crypto/internal/bigmod from crypto/ecdsa+
|
||||
crypto/hmac from crypto/tls
|
||||
crypto/internal/boring from crypto/aes+
|
||||
crypto/internal/boring/bbig from crypto/ecdsa+
|
||||
crypto/internal/boring/sig from crypto/internal/boring
|
||||
crypto/internal/edwards25519 from crypto/ed25519
|
||||
crypto/internal/edwards25519/field from crypto/ecdh+
|
||||
crypto/internal/entropy from crypto/internal/fips140/drbg
|
||||
crypto/internal/fips140 from crypto/internal/fips140/aes+
|
||||
crypto/internal/fips140/aes from crypto/aes+
|
||||
crypto/internal/fips140/aes/gcm from crypto/cipher+
|
||||
crypto/internal/fips140/alias from crypto/cipher+
|
||||
crypto/internal/fips140/bigmod from crypto/internal/fips140/ecdsa+
|
||||
crypto/internal/fips140/check from crypto/internal/fips140/aes+
|
||||
crypto/internal/fips140/drbg from crypto/internal/fips140/aes/gcm+
|
||||
crypto/internal/fips140/ecdh from crypto/ecdh
|
||||
crypto/internal/fips140/ecdsa from crypto/ecdsa
|
||||
crypto/internal/fips140/ed25519 from crypto/ed25519
|
||||
crypto/internal/fips140/edwards25519 from crypto/internal/fips140/ed25519
|
||||
crypto/internal/fips140/edwards25519/field from crypto/ecdh+
|
||||
crypto/internal/fips140/hkdf from crypto/internal/fips140/tls13+
|
||||
crypto/internal/fips140/hmac from crypto/hmac+
|
||||
crypto/internal/fips140/mlkem from crypto/tls
|
||||
crypto/internal/fips140/nistec from crypto/elliptic+
|
||||
crypto/internal/fips140/nistec/fiat from crypto/internal/fips140/nistec
|
||||
crypto/internal/fips140/rsa from crypto/rsa
|
||||
crypto/internal/fips140/sha256 from crypto/internal/fips140/check+
|
||||
crypto/internal/fips140/sha3 from crypto/internal/fips140/hmac+
|
||||
crypto/internal/fips140/sha512 from crypto/internal/fips140/ecdsa+
|
||||
crypto/internal/fips140/subtle from crypto/internal/fips140/aes+
|
||||
crypto/internal/fips140/tls12 from crypto/tls
|
||||
crypto/internal/fips140/tls13 from crypto/tls
|
||||
crypto/internal/fips140deps/byteorder from crypto/internal/fips140/aes+
|
||||
crypto/internal/fips140deps/cpu from crypto/internal/fips140/aes+
|
||||
crypto/internal/fips140deps/godebug from crypto/internal/fips140+
|
||||
crypto/internal/fips140hash from crypto/ecdsa+
|
||||
crypto/internal/fips140only from crypto/cipher+
|
||||
crypto/internal/hpke from crypto/tls
|
||||
crypto/internal/mlkem768 from crypto/tls
|
||||
crypto/internal/nistec from crypto/ecdh+
|
||||
crypto/internal/nistec/fiat from crypto/internal/nistec
|
||||
crypto/internal/impl from crypto/internal/fips140/aes+
|
||||
crypto/internal/randutil from crypto/dsa+
|
||||
crypto/internal/sysrand from crypto/internal/entropy+
|
||||
crypto/md5 from crypto/tls+
|
||||
crypto/rand from crypto/ed25519+
|
||||
crypto/rc4 from crypto/tls
|
||||
crypto/rsa from crypto/tls+
|
||||
crypto/sha1 from crypto/tls+
|
||||
crypto/sha256 from crypto/tls+
|
||||
crypto/sha3 from crypto/internal/fips140hash
|
||||
crypto/sha512 from crypto/ecdsa+
|
||||
crypto/subtle from crypto/aes+
|
||||
crypto/subtle from crypto/cipher+
|
||||
crypto/tls from net/http+
|
||||
crypto/tls/internal/fips140tls from crypto/tls
|
||||
crypto/x509 from crypto/tls
|
||||
D crypto/x509/internal/macos from crypto/x509
|
||||
crypto/x509/pkix from crypto/x509
|
||||
embed from crypto/internal/nistec+
|
||||
embed from google.golang.org/protobuf/internal/editiondefaults+
|
||||
encoding from encoding/json+
|
||||
encoding/asn1 from crypto/x509+
|
||||
encoding/base32 from github.com/go-json-experiment/json
|
||||
@@ -169,23 +194,22 @@ tailscale.com/cmd/stund dependencies: (generated by github.com/tailscale/depawar
|
||||
hash/maphash from go4.org/mem
|
||||
html from net/http/pprof+
|
||||
internal/abi from crypto/x509/internal/macos+
|
||||
internal/asan from syscall
|
||||
internal/asan from syscall+
|
||||
internal/bisect from internal/godebug
|
||||
internal/bytealg from bytes+
|
||||
internal/byteorder from crypto/aes+
|
||||
internal/byteorder from crypto/cipher+
|
||||
internal/chacha8rand from math/rand/v2+
|
||||
internal/concurrent from unique
|
||||
internal/coverage/rtcov from runtime
|
||||
internal/cpu from crypto/aes+
|
||||
internal/cpu from crypto/internal/fips140deps/cpu+
|
||||
internal/filepathlite from os+
|
||||
internal/fmtsort from fmt
|
||||
internal/goarch from crypto/aes+
|
||||
internal/goarch from crypto/internal/fips140deps/cpu+
|
||||
internal/godebug from crypto/tls+
|
||||
internal/godebugs from internal/godebug+
|
||||
internal/goexperiment from runtime
|
||||
internal/goexperiment from runtime+
|
||||
internal/goos from crypto/x509+
|
||||
internal/itoa from internal/poll+
|
||||
internal/msan from syscall
|
||||
internal/msan from syscall+
|
||||
internal/nettrace from net+
|
||||
internal/oserror from io/fs+
|
||||
internal/poll from net+
|
||||
@@ -195,17 +219,20 @@ tailscale.com/cmd/stund dependencies: (generated by github.com/tailscale/depawar
|
||||
internal/reflectlite from context+
|
||||
internal/runtime/atomic from internal/runtime/exithook+
|
||||
internal/runtime/exithook from runtime
|
||||
internal/runtime/maps from reflect+
|
||||
internal/runtime/math from internal/runtime/maps+
|
||||
internal/runtime/sys from crypto/subtle+
|
||||
L internal/runtime/syscall from runtime+
|
||||
internal/singleflight from net
|
||||
internal/stringslite from embed+
|
||||
internal/sync from sync+
|
||||
internal/syscall/execenv from os
|
||||
LD internal/syscall/unix from crypto/rand+
|
||||
W internal/syscall/windows from crypto/rand+
|
||||
LD internal/syscall/unix from crypto/internal/sysrand+
|
||||
W internal/syscall/windows from crypto/internal/sysrand+
|
||||
W internal/syscall/windows/registry from mime+
|
||||
W internal/syscall/windows/sysdll from internal/syscall/windows+
|
||||
internal/testlog from os
|
||||
internal/unsafeheader from internal/reflectlite+
|
||||
internal/weak from unique
|
||||
io from bufio+
|
||||
io/fs from crypto/x509+
|
||||
iter from maps+
|
||||
@@ -216,7 +243,7 @@ tailscale.com/cmd/stund dependencies: (generated by github.com/tailscale/depawar
|
||||
math/big from crypto/dsa+
|
||||
math/bits from compress/flate+
|
||||
math/rand from math/big+
|
||||
math/rand/v2 from internal/concurrent+
|
||||
math/rand/v2 from crypto/ecdsa+
|
||||
mime from github.com/prometheus/common/expfmt+
|
||||
mime/multipart from net/http
|
||||
mime/quotedprintable from mime/multipart
|
||||
@@ -229,17 +256,15 @@ tailscale.com/cmd/stund dependencies: (generated by github.com/tailscale/depawar
|
||||
net/netip from go4.org/netipx+
|
||||
net/textproto from golang.org/x/net/http/httpguts+
|
||||
net/url from crypto/x509+
|
||||
os from crypto/rand+
|
||||
os from crypto/internal/sysrand+
|
||||
os/signal from tailscale.com/cmd/stund
|
||||
path from github.com/prometheus/client_golang/prometheus/internal+
|
||||
path/filepath from crypto/x509+
|
||||
reflect from crypto/x509+
|
||||
regexp from github.com/prometheus/client_golang/prometheus/internal+
|
||||
regexp/syntax from regexp
|
||||
runtime from crypto/internal/nistec+
|
||||
runtime from crypto/internal/fips140+
|
||||
runtime/debug from github.com/prometheus/client_golang/prometheus+
|
||||
runtime/internal/math from runtime
|
||||
runtime/internal/sys from runtime
|
||||
runtime/metrics from github.com/prometheus/client_golang/prometheus+
|
||||
runtime/pprof from net/http/pprof
|
||||
runtime/trace from net/http/pprof
|
||||
@@ -249,7 +274,7 @@ tailscale.com/cmd/stund dependencies: (generated by github.com/tailscale/depawar
|
||||
strings from bufio+
|
||||
sync from compress/flate+
|
||||
sync/atomic from context+
|
||||
syscall from crypto/rand+
|
||||
syscall from crypto/internal/sysrand+
|
||||
text/tabwriter from runtime/pprof
|
||||
time from compress/gzip+
|
||||
unicode from bytes+
|
||||
@@ -257,3 +282,4 @@ tailscale.com/cmd/stund dependencies: (generated by github.com/tailscale/depawar
|
||||
unicode/utf8 from bufio+
|
||||
unique from net/netip
|
||||
unsafe from bytes+
|
||||
weak from unique
|
||||
|
||||
@@ -195,14 +195,13 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
|
||||
golang.org/x/crypto/cryptobyte from crypto/ecdsa+
|
||||
golang.org/x/crypto/cryptobyte/asn1 from crypto/ecdsa+
|
||||
golang.org/x/crypto/curve25519 from golang.org/x/crypto/nacl/box+
|
||||
golang.org/x/crypto/hkdf from crypto/tls+
|
||||
golang.org/x/crypto/hkdf from tailscale.com/control/controlbase
|
||||
golang.org/x/crypto/internal/alias from golang.org/x/crypto/chacha20+
|
||||
golang.org/x/crypto/internal/poly1305 from golang.org/x/crypto/chacha20poly1305+
|
||||
golang.org/x/crypto/nacl/box from tailscale.com/types/key
|
||||
golang.org/x/crypto/nacl/secretbox from golang.org/x/crypto/nacl/box
|
||||
golang.org/x/crypto/pbkdf2 from software.sslmate.com/src/go-pkcs12
|
||||
golang.org/x/crypto/salsa20/salsa from golang.org/x/crypto/nacl/box+
|
||||
golang.org/x/crypto/sha3 from crypto/internal/mlkem768+
|
||||
W golang.org/x/exp/constraints from github.com/dblohm7/wingoes/pe+
|
||||
golang.org/x/exp/maps from tailscale.com/util/syspolicy/internal/metrics+
|
||||
golang.org/x/net/bpf from github.com/mdlayher/netlink+
|
||||
@@ -246,7 +245,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
|
||||
container/list from crypto/tls+
|
||||
context from crypto/tls+
|
||||
crypto from crypto/ecdh+
|
||||
crypto/aes from crypto/ecdsa+
|
||||
crypto/aes from crypto/internal/hpke+
|
||||
crypto/cipher from crypto/aes+
|
||||
crypto/des from crypto/tls+
|
||||
crypto/dsa from crypto/x509
|
||||
@@ -255,34 +254,61 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
|
||||
crypto/ed25519 from crypto/tls+
|
||||
crypto/elliptic from crypto/ecdsa+
|
||||
crypto/hmac from crypto/tls+
|
||||
crypto/internal/alias from crypto/aes+
|
||||
crypto/internal/bigmod from crypto/ecdsa+
|
||||
crypto/internal/boring from crypto/aes+
|
||||
crypto/internal/boring/bbig from crypto/ecdsa+
|
||||
crypto/internal/boring/sig from crypto/internal/boring
|
||||
crypto/internal/edwards25519 from crypto/ed25519
|
||||
crypto/internal/edwards25519/field from crypto/ecdh+
|
||||
crypto/internal/entropy from crypto/internal/fips140/drbg
|
||||
crypto/internal/fips140 from crypto/internal/fips140/aes+
|
||||
crypto/internal/fips140/aes from crypto/aes+
|
||||
crypto/internal/fips140/aes/gcm from crypto/cipher+
|
||||
crypto/internal/fips140/alias from crypto/cipher+
|
||||
crypto/internal/fips140/bigmod from crypto/internal/fips140/ecdsa+
|
||||
crypto/internal/fips140/check from crypto/internal/fips140/aes+
|
||||
crypto/internal/fips140/drbg from crypto/internal/fips140/aes/gcm+
|
||||
crypto/internal/fips140/ecdh from crypto/ecdh
|
||||
crypto/internal/fips140/ecdsa from crypto/ecdsa
|
||||
crypto/internal/fips140/ed25519 from crypto/ed25519
|
||||
crypto/internal/fips140/edwards25519 from crypto/internal/fips140/ed25519
|
||||
crypto/internal/fips140/edwards25519/field from crypto/ecdh+
|
||||
crypto/internal/fips140/hkdf from crypto/internal/fips140/tls13+
|
||||
crypto/internal/fips140/hmac from crypto/hmac+
|
||||
crypto/internal/fips140/mlkem from crypto/tls
|
||||
crypto/internal/fips140/nistec from crypto/elliptic+
|
||||
crypto/internal/fips140/nistec/fiat from crypto/internal/fips140/nistec
|
||||
crypto/internal/fips140/rsa from crypto/rsa
|
||||
crypto/internal/fips140/sha256 from crypto/internal/fips140/check+
|
||||
crypto/internal/fips140/sha3 from crypto/internal/fips140/hmac+
|
||||
crypto/internal/fips140/sha512 from crypto/internal/fips140/ecdsa+
|
||||
crypto/internal/fips140/subtle from crypto/internal/fips140/aes+
|
||||
crypto/internal/fips140/tls12 from crypto/tls
|
||||
crypto/internal/fips140/tls13 from crypto/tls
|
||||
crypto/internal/fips140deps/byteorder from crypto/internal/fips140/aes+
|
||||
crypto/internal/fips140deps/cpu from crypto/internal/fips140/aes+
|
||||
crypto/internal/fips140deps/godebug from crypto/internal/fips140+
|
||||
crypto/internal/fips140hash from crypto/ecdsa+
|
||||
crypto/internal/fips140only from crypto/cipher+
|
||||
crypto/internal/hpke from crypto/tls
|
||||
crypto/internal/mlkem768 from crypto/tls
|
||||
crypto/internal/nistec from crypto/ecdh+
|
||||
crypto/internal/nistec/fiat from crypto/internal/nistec
|
||||
crypto/internal/impl from crypto/internal/fips140/aes+
|
||||
crypto/internal/randutil from crypto/dsa+
|
||||
crypto/internal/sysrand from crypto/internal/entropy+
|
||||
crypto/md5 from crypto/tls+
|
||||
crypto/rand from crypto/ed25519+
|
||||
crypto/rc4 from crypto/tls
|
||||
crypto/rsa from crypto/tls+
|
||||
crypto/sha1 from crypto/tls+
|
||||
crypto/sha256 from crypto/tls+
|
||||
crypto/sha3 from crypto/internal/fips140hash
|
||||
crypto/sha512 from crypto/ecdsa+
|
||||
crypto/subtle from crypto/aes+
|
||||
crypto/subtle from crypto/cipher+
|
||||
crypto/tls from github.com/miekg/dns+
|
||||
crypto/tls/internal/fips140tls from crypto/tls
|
||||
crypto/x509 from crypto/tls+
|
||||
D crypto/x509/internal/macos from crypto/x509
|
||||
crypto/x509/pkix from crypto/x509+
|
||||
DW database/sql/driver from github.com/google/uuid
|
||||
W debug/dwarf from debug/pe
|
||||
W debug/pe from github.com/dblohm7/wingoes/pe
|
||||
embed from crypto/internal/nistec+
|
||||
embed from github.com/peterbourgon/ff/v3+
|
||||
encoding from encoding/gob+
|
||||
encoding/asn1 from crypto/x509+
|
||||
encoding/base32 from github.com/fxamacker/cbor/v2+
|
||||
@@ -307,23 +333,22 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
|
||||
image/color from github.com/skip2/go-qrcode+
|
||||
image/png from github.com/skip2/go-qrcode
|
||||
internal/abi from crypto/x509/internal/macos+
|
||||
internal/asan from syscall
|
||||
internal/asan from syscall+
|
||||
internal/bisect from internal/godebug
|
||||
internal/bytealg from bytes+
|
||||
internal/byteorder from crypto/aes+
|
||||
internal/byteorder from crypto/cipher+
|
||||
internal/chacha8rand from math/rand/v2+
|
||||
internal/concurrent from unique
|
||||
internal/coverage/rtcov from runtime
|
||||
internal/cpu from crypto/aes+
|
||||
internal/cpu from crypto/internal/fips140deps/cpu+
|
||||
internal/filepathlite from os+
|
||||
internal/fmtsort from fmt+
|
||||
internal/goarch from crypto/aes+
|
||||
internal/goarch from crypto/internal/fips140deps/cpu+
|
||||
internal/godebug from archive/tar+
|
||||
internal/godebugs from internal/godebug+
|
||||
internal/goexperiment from runtime
|
||||
internal/goexperiment from runtime+
|
||||
internal/goos from crypto/x509+
|
||||
internal/itoa from internal/poll+
|
||||
internal/msan from syscall
|
||||
internal/msan from syscall+
|
||||
internal/nettrace from net+
|
||||
internal/oserror from io/fs+
|
||||
internal/poll from net+
|
||||
@@ -332,18 +357,21 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
|
||||
internal/reflectlite from context+
|
||||
internal/runtime/atomic from internal/runtime/exithook+
|
||||
internal/runtime/exithook from runtime
|
||||
internal/runtime/maps from reflect+
|
||||
internal/runtime/math from internal/runtime/maps+
|
||||
internal/runtime/sys from crypto/subtle+
|
||||
L internal/runtime/syscall from runtime+
|
||||
internal/saferio from debug/pe+
|
||||
internal/singleflight from net
|
||||
internal/stringslite from embed+
|
||||
internal/sync from sync+
|
||||
internal/syscall/execenv from os+
|
||||
LD internal/syscall/unix from crypto/rand+
|
||||
W internal/syscall/windows from crypto/rand+
|
||||
LD internal/syscall/unix from crypto/internal/sysrand+
|
||||
W internal/syscall/windows from crypto/internal/sysrand+
|
||||
W internal/syscall/windows/registry from mime+
|
||||
W internal/syscall/windows/sysdll from internal/syscall/windows+
|
||||
internal/testlog from os
|
||||
internal/unsafeheader from internal/reflectlite+
|
||||
internal/weak from unique
|
||||
io from archive/tar+
|
||||
io/fs from archive/tar+
|
||||
io/ioutil from github.com/mitchellh/go-ps+
|
||||
@@ -369,7 +397,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
|
||||
net/netip from go4.org/netipx+
|
||||
net/textproto from golang.org/x/net/http/httpguts+
|
||||
net/url from crypto/x509+
|
||||
os from crypto/rand+
|
||||
os from crypto/internal/sysrand+
|
||||
os/exec from github.com/coreos/go-iptables/iptables+
|
||||
os/signal from tailscale.com/cmd/tailscale/cli
|
||||
os/user from archive/tar+
|
||||
@@ -380,8 +408,6 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
|
||||
regexp/syntax from regexp
|
||||
runtime from archive/tar+
|
||||
runtime/debug from tailscale.com+
|
||||
runtime/internal/math from runtime
|
||||
runtime/internal/sys from runtime
|
||||
slices from tailscale.com/client/web+
|
||||
sort from compress/flate+
|
||||
strconv from archive/tar+
|
||||
@@ -398,3 +424,4 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
|
||||
unicode/utf8 from bufio+
|
||||
unique from net/netip
|
||||
unsafe from bytes+
|
||||
weak from unique
|
||||
|
||||
@@ -449,14 +449,13 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
golang.org/x/crypto/cryptobyte from crypto/ecdsa+
|
||||
golang.org/x/crypto/cryptobyte/asn1 from crypto/ecdsa+
|
||||
golang.org/x/crypto/curve25519 from golang.org/x/crypto/ssh+
|
||||
golang.org/x/crypto/hkdf from crypto/tls+
|
||||
golang.org/x/crypto/hkdf from tailscale.com/control/controlbase
|
||||
golang.org/x/crypto/internal/alias from golang.org/x/crypto/chacha20+
|
||||
golang.org/x/crypto/internal/poly1305 from golang.org/x/crypto/chacha20poly1305+
|
||||
golang.org/x/crypto/nacl/box from tailscale.com/types/key
|
||||
golang.org/x/crypto/nacl/secretbox from golang.org/x/crypto/nacl/box
|
||||
golang.org/x/crypto/poly1305 from github.com/tailscale/wireguard-go/device
|
||||
golang.org/x/crypto/salsa20/salsa from golang.org/x/crypto/nacl/box+
|
||||
golang.org/x/crypto/sha3 from crypto/internal/mlkem768+
|
||||
LD golang.org/x/crypto/ssh from github.com/pkg/sftp+
|
||||
LD golang.org/x/crypto/ssh/internal/bcrypt_pbkdf from golang.org/x/crypto/ssh
|
||||
golang.org/x/exp/constraints from github.com/dblohm7/wingoes/pe+
|
||||
@@ -504,7 +503,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
container/list from crypto/tls+
|
||||
context from crypto/tls+
|
||||
crypto from crypto/ecdh+
|
||||
crypto/aes from crypto/ecdsa+
|
||||
crypto/aes from crypto/internal/hpke+
|
||||
crypto/cipher from crypto/aes+
|
||||
crypto/des from crypto/tls+
|
||||
crypto/dsa from crypto/x509+
|
||||
@@ -513,34 +512,61 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
crypto/ed25519 from crypto/tls+
|
||||
crypto/elliptic from crypto/ecdsa+
|
||||
crypto/hmac from crypto/tls+
|
||||
crypto/internal/alias from crypto/aes+
|
||||
crypto/internal/bigmod from crypto/ecdsa+
|
||||
crypto/internal/boring from crypto/aes+
|
||||
crypto/internal/boring/bbig from crypto/ecdsa+
|
||||
crypto/internal/boring/sig from crypto/internal/boring
|
||||
crypto/internal/edwards25519 from crypto/ed25519
|
||||
crypto/internal/edwards25519/field from crypto/ecdh+
|
||||
crypto/internal/entropy from crypto/internal/fips140/drbg
|
||||
crypto/internal/fips140 from crypto/internal/fips140/aes+
|
||||
crypto/internal/fips140/aes from crypto/aes+
|
||||
crypto/internal/fips140/aes/gcm from crypto/cipher+
|
||||
crypto/internal/fips140/alias from crypto/cipher+
|
||||
crypto/internal/fips140/bigmod from crypto/internal/fips140/ecdsa+
|
||||
crypto/internal/fips140/check from crypto/internal/fips140/aes+
|
||||
crypto/internal/fips140/drbg from crypto/internal/fips140/aes/gcm+
|
||||
crypto/internal/fips140/ecdh from crypto/ecdh
|
||||
crypto/internal/fips140/ecdsa from crypto/ecdsa
|
||||
crypto/internal/fips140/ed25519 from crypto/ed25519
|
||||
crypto/internal/fips140/edwards25519 from crypto/internal/fips140/ed25519
|
||||
crypto/internal/fips140/edwards25519/field from crypto/ecdh+
|
||||
crypto/internal/fips140/hkdf from crypto/internal/fips140/tls13+
|
||||
crypto/internal/fips140/hmac from crypto/hmac+
|
||||
crypto/internal/fips140/mlkem from crypto/tls
|
||||
crypto/internal/fips140/nistec from crypto/elliptic+
|
||||
crypto/internal/fips140/nistec/fiat from crypto/internal/fips140/nistec
|
||||
crypto/internal/fips140/rsa from crypto/rsa
|
||||
crypto/internal/fips140/sha256 from crypto/internal/fips140/check+
|
||||
crypto/internal/fips140/sha3 from crypto/internal/fips140/hmac+
|
||||
crypto/internal/fips140/sha512 from crypto/internal/fips140/ecdsa+
|
||||
crypto/internal/fips140/subtle from crypto/internal/fips140/aes+
|
||||
crypto/internal/fips140/tls12 from crypto/tls
|
||||
crypto/internal/fips140/tls13 from crypto/tls
|
||||
crypto/internal/fips140deps/byteorder from crypto/internal/fips140/aes+
|
||||
crypto/internal/fips140deps/cpu from crypto/internal/fips140/aes+
|
||||
crypto/internal/fips140deps/godebug from crypto/internal/fips140+
|
||||
crypto/internal/fips140hash from crypto/ecdsa+
|
||||
crypto/internal/fips140only from crypto/cipher+
|
||||
crypto/internal/hpke from crypto/tls
|
||||
crypto/internal/mlkem768 from crypto/tls
|
||||
crypto/internal/nistec from crypto/ecdh+
|
||||
crypto/internal/nistec/fiat from crypto/internal/nistec
|
||||
crypto/internal/impl from crypto/internal/fips140/aes+
|
||||
crypto/internal/randutil from crypto/dsa+
|
||||
crypto/internal/sysrand from crypto/internal/entropy+
|
||||
crypto/md5 from crypto/tls+
|
||||
crypto/rand from crypto/ed25519+
|
||||
crypto/rc4 from crypto/tls+
|
||||
crypto/rsa from crypto/tls+
|
||||
crypto/sha1 from crypto/tls+
|
||||
crypto/sha256 from crypto/tls+
|
||||
crypto/sha3 from crypto/internal/fips140hash
|
||||
crypto/sha512 from crypto/ecdsa+
|
||||
crypto/subtle from crypto/aes+
|
||||
crypto/subtle from crypto/cipher+
|
||||
crypto/tls from github.com/aws/aws-sdk-go-v2/aws/transport/http+
|
||||
crypto/tls/internal/fips140tls from crypto/tls
|
||||
crypto/x509 from crypto/tls+
|
||||
D crypto/x509/internal/macos from crypto/x509
|
||||
crypto/x509/pkix from crypto/x509+
|
||||
DW database/sql/driver from github.com/google/uuid
|
||||
W debug/dwarf from debug/pe
|
||||
W debug/pe from github.com/dblohm7/wingoes/pe
|
||||
embed from crypto/internal/nistec+
|
||||
embed from github.com/tailscale/web-client-prebuilt+
|
||||
encoding from encoding/gob+
|
||||
encoding/asn1 from crypto/x509+
|
||||
encoding/base32 from github.com/fxamacker/cbor/v2+
|
||||
@@ -562,23 +588,22 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
html from html/template+
|
||||
html/template from github.com/gorilla/csrf
|
||||
internal/abi from crypto/x509/internal/macos+
|
||||
internal/asan from syscall
|
||||
internal/asan from syscall+
|
||||
internal/bisect from internal/godebug
|
||||
internal/bytealg from bytes+
|
||||
internal/byteorder from crypto/aes+
|
||||
internal/byteorder from crypto/cipher+
|
||||
internal/chacha8rand from math/rand/v2+
|
||||
internal/concurrent from unique
|
||||
internal/coverage/rtcov from runtime
|
||||
internal/cpu from crypto/aes+
|
||||
internal/cpu from crypto/internal/fips140deps/cpu+
|
||||
internal/filepathlite from os+
|
||||
internal/fmtsort from fmt+
|
||||
internal/goarch from crypto/aes+
|
||||
internal/goarch from crypto/internal/fips140deps/cpu+
|
||||
internal/godebug from archive/tar+
|
||||
internal/godebugs from internal/godebug+
|
||||
internal/goexperiment from runtime
|
||||
internal/goexperiment from runtime+
|
||||
internal/goos from crypto/x509+
|
||||
internal/itoa from internal/poll+
|
||||
internal/msan from syscall
|
||||
internal/msan from syscall+
|
||||
internal/nettrace from net+
|
||||
internal/oserror from io/fs+
|
||||
internal/poll from net+
|
||||
@@ -588,18 +613,21 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
internal/reflectlite from context+
|
||||
internal/runtime/atomic from internal/runtime/exithook+
|
||||
internal/runtime/exithook from runtime
|
||||
internal/runtime/maps from reflect+
|
||||
internal/runtime/math from internal/runtime/maps+
|
||||
internal/runtime/sys from crypto/subtle+
|
||||
L internal/runtime/syscall from runtime+
|
||||
internal/saferio from debug/pe+
|
||||
internal/singleflight from net
|
||||
internal/stringslite from embed+
|
||||
internal/sync from sync+
|
||||
internal/syscall/execenv from os+
|
||||
LD internal/syscall/unix from crypto/rand+
|
||||
W internal/syscall/windows from crypto/rand+
|
||||
LD internal/syscall/unix from crypto/internal/sysrand+
|
||||
W internal/syscall/windows from crypto/internal/sysrand+
|
||||
W internal/syscall/windows/registry from mime+
|
||||
W internal/syscall/windows/sysdll from internal/syscall/windows+
|
||||
internal/testlog from os
|
||||
internal/unsafeheader from internal/reflectlite+
|
||||
internal/weak from unique
|
||||
io from archive/tar+
|
||||
io/fs from archive/tar+
|
||||
io/ioutil from github.com/aws/aws-sdk-go-v2/aws/protocol/query+
|
||||
@@ -626,7 +654,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
net/netip from github.com/tailscale/wireguard-go/conn+
|
||||
net/textproto from github.com/aws/aws-sdk-go-v2/aws/signer/v4+
|
||||
net/url from crypto/x509+
|
||||
os from crypto/rand+
|
||||
os from crypto/internal/sysrand+
|
||||
os/exec from github.com/aws/aws-sdk-go-v2/credentials/processcreds+
|
||||
os/signal from tailscale.com/cmd/tailscaled
|
||||
os/user from archive/tar+
|
||||
@@ -637,8 +665,6 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
regexp/syntax from regexp
|
||||
runtime from archive/tar+
|
||||
runtime/debug from github.com/aws/aws-sdk-go-v2/internal/sync/singleflight+
|
||||
runtime/internal/math from runtime
|
||||
runtime/internal/sys from runtime
|
||||
runtime/pprof from net/http/pprof+
|
||||
runtime/trace from net/http/pprof
|
||||
slices from tailscale.com/appc+
|
||||
@@ -657,3 +683,4 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
unicode/utf8 from bufio+
|
||||
unique from net/netip
|
||||
unsafe from bytes+
|
||||
weak from unique
|
||||
|
||||
@@ -9,8 +9,12 @@ package flakytest
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"regexp"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"tailscale.com/util/mak"
|
||||
)
|
||||
|
||||
// FlakyTestLogMessage is a sentinel value that is printed to stderr when a
|
||||
@@ -25,6 +29,11 @@ const FlakeAttemptEnv = "TS_TESTWRAPPER_ATTEMPT"
|
||||
|
||||
var issueRegexp = regexp.MustCompile(`\Ahttps://github\.com/tailscale/[a-zA-Z0-9_.-]+/issues/\d+\z`)
|
||||
|
||||
var (
|
||||
rootFlakesMu sync.Mutex
|
||||
rootFlakes map[string]bool
|
||||
)
|
||||
|
||||
// Mark sets the current test as a flaky test, such that if it fails, it will
|
||||
// be retried a few times on failure. issue must be a GitHub issue that tracks
|
||||
// the status of the flaky test being marked, of the format:
|
||||
@@ -41,4 +50,24 @@ func Mark(t testing.TB, issue string) {
|
||||
fmt.Fprintf(os.Stderr, "%s: %s\n", FlakyTestLogMessage, issue)
|
||||
}
|
||||
t.Logf("flakytest: issue tracking this flaky test: %s", issue)
|
||||
|
||||
// Record the root test name as flakey.
|
||||
rootFlakesMu.Lock()
|
||||
defer rootFlakesMu.Unlock()
|
||||
mak.Set(&rootFlakes, t.Name(), true)
|
||||
}
|
||||
|
||||
// Marked reports whether the current test or one of its parents was marked flaky.
|
||||
func Marked(t testing.TB) bool {
|
||||
n := t.Name()
|
||||
for {
|
||||
if rootFlakes[n] {
|
||||
return true
|
||||
}
|
||||
n = path.Dir(n)
|
||||
if n == "." || n == "/" {
|
||||
break
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -41,3 +41,49 @@ func TestFlakeRun(t *testing.T) {
|
||||
t.Fatal("First run in testwrapper, failing so that test is retried. This is expected.")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarked_Root(t *testing.T) {
|
||||
Mark(t, "https://github.com/tailscale/tailscale/issues/0")
|
||||
|
||||
t.Run("child", func(t *testing.T) {
|
||||
t.Run("grandchild", func(t *testing.T) {
|
||||
if got, want := Marked(t), true; got != want {
|
||||
t.Fatalf("Marked(t) = %t, want %t", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
if got, want := Marked(t), true; got != want {
|
||||
t.Fatalf("Marked(t) = %t, want %t", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
if got, want := Marked(t), true; got != want {
|
||||
t.Fatalf("Marked(t) = %t, want %t", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarked_Subtest(t *testing.T) {
|
||||
t.Run("flaky", func(t *testing.T) {
|
||||
Mark(t, "https://github.com/tailscale/tailscale/issues/0")
|
||||
|
||||
t.Run("child", func(t *testing.T) {
|
||||
t.Run("grandchild", func(t *testing.T) {
|
||||
if got, want := Marked(t), true; got != want {
|
||||
t.Fatalf("Marked(t) = %t, want %t", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
if got, want := Marked(t), true; got != want {
|
||||
t.Fatalf("Marked(t) = %t, want %t", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
if got, want := Marked(t), true; got != want {
|
||||
t.Fatalf("Marked(t) = %t, want %t", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
if got, want := Marked(t), false; got != want {
|
||||
t.Fatalf("Marked(t) = %t, want %t", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ package main
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"cmp"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -59,11 +60,12 @@ type packageTests struct {
|
||||
}
|
||||
|
||||
type goTestOutput struct {
|
||||
Time time.Time
|
||||
Action string
|
||||
Package string
|
||||
Test string
|
||||
Output string
|
||||
Time time.Time
|
||||
Action string
|
||||
ImportPath string
|
||||
Package string
|
||||
Test string
|
||||
Output string
|
||||
}
|
||||
|
||||
var debug = os.Getenv("TS_TESTWRAPPER_DEBUG") != ""
|
||||
@@ -111,42 +113,43 @@ func runTests(ctx context.Context, attempt int, pt *packageTests, goTestArgs, te
|
||||
for s.Scan() {
|
||||
var goOutput goTestOutput
|
||||
if err := json.Unmarshal(s.Bytes(), &goOutput); err != nil {
|
||||
if errors.Is(err, io.EOF) || errors.Is(err, os.ErrClosed) {
|
||||
break
|
||||
}
|
||||
|
||||
// `go test -json` outputs invalid JSON when a build fails.
|
||||
// In that case, discard the the output and start reading again.
|
||||
// The build error will be printed to stderr.
|
||||
// See: https://github.com/golang/go/issues/35169
|
||||
if _, ok := err.(*json.SyntaxError); ok {
|
||||
fmt.Println(s.Text())
|
||||
continue
|
||||
}
|
||||
panic(err)
|
||||
return fmt.Errorf("failed to parse go test output %q: %w", s.Bytes(), err)
|
||||
}
|
||||
pkg := goOutput.Package
|
||||
pkg := cmp.Or(
|
||||
goOutput.Package,
|
||||
"build:"+goOutput.ImportPath, // can be "./cmd" while Package is "tailscale.com/cmd" so use separate namespace
|
||||
)
|
||||
pkgTests := resultMap[pkg]
|
||||
if pkgTests == nil {
|
||||
pkgTests = make(map[string]*testAttempt)
|
||||
pkgTests = map[string]*testAttempt{
|
||||
"": {}, // Used for start time and build logs.
|
||||
}
|
||||
resultMap[pkg] = pkgTests
|
||||
}
|
||||
if goOutput.Test == "" {
|
||||
switch goOutput.Action {
|
||||
case "start":
|
||||
pkgTests[""] = &testAttempt{start: goOutput.Time}
|
||||
case "fail", "pass", "skip":
|
||||
pkgTests[""].start = goOutput.Time
|
||||
case "build-output":
|
||||
pkgTests[""].logs.WriteString(goOutput.Output)
|
||||
case "build-fail", "fail", "pass", "skip":
|
||||
for _, test := range pkgTests {
|
||||
if test.testName != "" && test.outcome == "" {
|
||||
test.outcome = "fail"
|
||||
ch <- test
|
||||
}
|
||||
}
|
||||
outcome := goOutput.Action
|
||||
if outcome == "build-fail" {
|
||||
outcome = "FAIL"
|
||||
}
|
||||
pkgTests[""].logs.WriteString(goOutput.Output)
|
||||
ch <- &testAttempt{
|
||||
pkg: goOutput.Package,
|
||||
outcome: goOutput.Action,
|
||||
outcome: outcome,
|
||||
start: pkgTests[""].start,
|
||||
end: goOutput.Time,
|
||||
logs: pkgTests[""].logs,
|
||||
pkgFinished: true,
|
||||
}
|
||||
}
|
||||
@@ -215,6 +218,9 @@ func main() {
|
||||
}
|
||||
toRun := []*nextRun{firstRun}
|
||||
printPkgOutcome := func(pkg, outcome string, attempt int, runtime time.Duration) {
|
||||
if pkg == "" {
|
||||
return // We reach this path on a build error.
|
||||
}
|
||||
if outcome == "skip" {
|
||||
fmt.Printf("?\t%s [skipped/no tests] \n", pkg)
|
||||
return
|
||||
@@ -270,6 +276,7 @@ func main() {
|
||||
// when a package times out.
|
||||
failed = true
|
||||
}
|
||||
os.Stdout.ReadFrom(&tr.logs)
|
||||
printPkgOutcome(tr.pkg, tr.outcome, thisRun.attempt, tr.end.Sub(tr.start))
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
@@ -154,24 +155,24 @@ func TestBuildError(t *testing.T) {
|
||||
t.Fatalf("writing package: %s", err)
|
||||
}
|
||||
|
||||
buildErr := []byte("builderror_test.go:3:1: expected declaration, found derp\nFAIL command-line-arguments [setup failed]")
|
||||
wantErr := "builderror_test.go:3:1: expected declaration, found derp\nFAIL"
|
||||
|
||||
// Confirm `go test` exits with code 1.
|
||||
goOut, err := exec.Command("go", "test", testfile).CombinedOutput()
|
||||
if code, ok := errExitCode(err); !ok || code != 1 {
|
||||
t.Fatalf("go test %s: expected error with exit code 0 but got: %v", testfile, err)
|
||||
t.Fatalf("go test %s: got exit code %d, want 1 (err: %v)", testfile, code, err)
|
||||
}
|
||||
if !bytes.Contains(goOut, buildErr) {
|
||||
t.Fatalf("go test %s: expected build error containing %q but got:\n%s", testfile, buildErr, goOut)
|
||||
if !strings.Contains(string(goOut), wantErr) {
|
||||
t.Fatalf("go test %s: got output %q, want output containing %q", testfile, goOut, wantErr)
|
||||
}
|
||||
|
||||
// Confirm `testwrapper` exits with code 1.
|
||||
twOut, err := cmdTestwrapper(t, testfile).CombinedOutput()
|
||||
if code, ok := errExitCode(err); !ok || code != 1 {
|
||||
t.Fatalf("testwrapper %s: expected error with exit code 0 but got: %v", testfile, err)
|
||||
t.Fatalf("testwrapper %s: got exit code %d, want 1 (err: %v)", testfile, code, err)
|
||||
}
|
||||
if !bytes.Contains(twOut, buildErr) {
|
||||
t.Fatalf("testwrapper %s: expected build error containing %q but got:\n%s", testfile, buildErr, twOut)
|
||||
if !strings.Contains(string(twOut), wantErr) {
|
||||
t.Fatalf("testwrapper %s: got output %q, want output containing %q", testfile, twOut, wantErr)
|
||||
}
|
||||
|
||||
if testing.Verbose() {
|
||||
|
||||
@@ -176,6 +176,10 @@ func runEsbuild(buildOptions esbuild.BuildOptions) esbuild.BuildResult {
|
||||
// wasm_exec.js runtime helper library from the Go toolchain.
|
||||
func setupEsbuildWasmExecJS(build esbuild.PluginBuild) {
|
||||
wasmExecSrcPath := filepath.Join(runtime.GOROOT(), "misc", "wasm", "wasm_exec.js")
|
||||
if _, err := os.Stat(wasmExecSrcPath); os.IsNotExist(err) {
|
||||
// Go 1.24+ location:
|
||||
wasmExecSrcPath = filepath.Join(runtime.GOROOT(), "lib", "wasm", "wasm_exec.js")
|
||||
}
|
||||
build.OnResolve(esbuild.OnResolveOptions{
|
||||
Filter: "./wasm_exec$",
|
||||
}, func(args esbuild.OnResolveArgs) (esbuild.OnResolveResult, error) {
|
||||
|
||||
@@ -676,7 +676,9 @@ func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, new
|
||||
addLBHeader(req, request.OldNodeKey)
|
||||
addLBHeader(req, request.NodeKey)
|
||||
|
||||
start := time.Now()
|
||||
res, err := httpc.Do(req)
|
||||
loginLatencies.Observe(time.Since(start).Seconds())
|
||||
if err != nil {
|
||||
return regen, opt.URL, nil, fmt.Errorf("register request: %w", err)
|
||||
}
|
||||
@@ -964,7 +966,9 @@ func (c *Direct) sendMapRequest(ctx context.Context, isStreaming bool, nu Netmap
|
||||
}
|
||||
addLBHeader(req, nodeKey)
|
||||
|
||||
start := time.Now()
|
||||
res, err := httpc.Do(req)
|
||||
initialMapRequestLatencies.Observe(time.Since(start).Seconds())
|
||||
if err != nil {
|
||||
vlogf("netmap: Do: %v", err)
|
||||
return err
|
||||
@@ -1322,6 +1326,7 @@ func (c *Direct) answerPing(pr *tailcfg.PingRequest) {
|
||||
c.logf("invalid PingRequest with no URL")
|
||||
return
|
||||
}
|
||||
start := time.Now()
|
||||
switch pr.Types {
|
||||
case "":
|
||||
answerHeadPing(c.logf, httpc, pr)
|
||||
@@ -1334,6 +1339,7 @@ func (c *Direct) answerPing(pr *tailcfg.PingRequest) {
|
||||
answerC2NPing(c.logf, c.c2nHandler, httpc, pr)
|
||||
return
|
||||
}
|
||||
answerPingLatencies.Observe(time.Since(start).Seconds())
|
||||
for _, t := range strings.Split(pr.Types, ",") {
|
||||
switch pt := tailcfg.PingType(t); pt {
|
||||
case tailcfg.PingTSMP, tailcfg.PingDisco, tailcfg.PingICMP, tailcfg.PingPeerAPI:
|
||||
@@ -1638,7 +1644,9 @@ func (c *Direct) ReportHealthChange(w *health.Warnable, us *health.UnhealthyStat
|
||||
// Best effort, no logging:
|
||||
ctx, cancel := context.WithTimeout(c.closedCtx, 5*time.Second)
|
||||
defer cancel()
|
||||
start := time.Now()
|
||||
res, err := np.post(ctx, "/machine/update-health", nodeKey, req)
|
||||
updateHealthLatencies.Observe(time.Since(start).Seconds())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
39
control/controlclient/metrics.go
Normal file
39
control/controlclient/metrics.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package controlclient
|
||||
|
||||
import (
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
)
|
||||
|
||||
var (
|
||||
loginLatencies = promauto.NewHistogram(prometheus.HistogramOpts{
|
||||
Name: "controlclient_login_latency_seconds",
|
||||
Help: "Control login time",
|
||||
// 15 buckets from 10ms to 1m.
|
||||
Buckets: prometheus.ExponentialBucketsRange(0.01, 60, 15)},
|
||||
)
|
||||
dialLatencies = promauto.NewHistogram(prometheus.HistogramOpts{
|
||||
Name: "controlclient_dial_latency_seconds",
|
||||
Help: "Control dial time",
|
||||
// 15 buckets from 10ms to 1m.
|
||||
Buckets: prometheus.ExponentialBucketsRange(0.01, 60, 15)},
|
||||
)
|
||||
initialMapRequestLatencies = promauto.NewHistogram(prometheus.HistogramOpts{
|
||||
Name: "controlclient_initial_map_request_latency_seconds",
|
||||
Help: "Initial map request/response time",
|
||||
// 15 buckets from 10ms to 1m.
|
||||
Buckets: prometheus.ExponentialBucketsRange(0.01, 60, 15)},
|
||||
)
|
||||
updateHealthLatencies = promauto.NewHistogram(prometheus.HistogramOpts{
|
||||
Name: "controlclient_update_health_latency_seconds",
|
||||
Help: "Update health request/response time",
|
||||
// 15 buckets from 10ms to 1m.
|
||||
Buckets: prometheus.ExponentialBucketsRange(0.01, 60, 15)},
|
||||
)
|
||||
answerPingLatencies = promauto.NewHistogram(prometheus.HistogramOpts{
|
||||
Name: "controlclient_answer_ping_latency_seconds",
|
||||
Help: "Answer ping request/response time",
|
||||
// 15 buckets from 10ms to 1m.
|
||||
Buckets: prometheus.ExponentialBucketsRange(0.01, 60, 15)},
|
||||
)
|
||||
)
|
||||
@@ -239,7 +239,9 @@ func (nc *NoiseClient) getConn(ctx context.Context) (*noiseconn.Conn, error) {
|
||||
// which was canceled is our context and retry if our context is still
|
||||
// valid.
|
||||
conn, err, _ := nc.sfDial.Do(struct{}{}, func() (*noiseconn.Conn, error) {
|
||||
start := time.Now()
|
||||
c, err := nc.dial(ctx)
|
||||
dialLatencies.Observe(time.Since(start).Seconds())
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return nil, contextErr{ctx.Err()}
|
||||
|
||||
@@ -137,6 +137,7 @@ type Server struct {
|
||||
metaCert []byte // the encoded x509 cert to send after LetsEncrypt cert+intermediate
|
||||
dupPolicy dupPolicy
|
||||
debug bool
|
||||
localClient local.Client
|
||||
|
||||
// Counters:
|
||||
packetsSent, bytesSent expvar.Int
|
||||
@@ -485,6 +486,16 @@ func (s *Server) SetVerifyClientURLFailOpen(v bool) {
|
||||
s.verifyClientsURLFailOpen = v
|
||||
}
|
||||
|
||||
// SetTailscaledSocketPath sets the unix socket path to use to talk to
|
||||
// tailscaled if client verification is enabled.
|
||||
//
|
||||
// If unset or set to the empty string, the default path for the operating
|
||||
// system is used.
|
||||
func (s *Server) SetTailscaledSocketPath(path string) {
|
||||
s.localClient.Socket = path
|
||||
s.localClient.UseSocketOnly = path != ""
|
||||
}
|
||||
|
||||
// SetTCPWriteTimeout sets the timeout for writing to connected clients.
|
||||
// This timeout does not apply to mesh connections.
|
||||
// Defaults to 2 seconds.
|
||||
@@ -1320,8 +1331,6 @@ func (c *sclient) requestMeshUpdate() {
|
||||
}
|
||||
}
|
||||
|
||||
var localClient local.Client
|
||||
|
||||
// isMeshPeer reports whether the client is a trusted mesh peer
|
||||
// node in the DERP region.
|
||||
func (s *Server) isMeshPeer(info *clientInfo) bool {
|
||||
@@ -1340,7 +1349,7 @@ func (s *Server) verifyClient(ctx context.Context, clientKey key.NodePublic, inf
|
||||
|
||||
// tailscaled-based verification:
|
||||
if s.verifyClientsLocalTailscaled {
|
||||
_, err := localClient.WhoIsNodeKey(ctx, clientKey)
|
||||
_, err := s.localClient.WhoIsNodeKey(ctx, clientKey)
|
||||
if err == tailscale.ErrPeerNotFound {
|
||||
return fmt.Errorf("peer %v not authorized (not found in local tailscaled)", clientKey)
|
||||
}
|
||||
@@ -2240,7 +2249,7 @@ func (s *Server) ConsistencyCheck() error {
|
||||
func (s *Server) checkVerifyClientsLocalTailscaled() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
status, err := localClient.StatusWithoutPeers(ctx)
|
||||
status, err := s.localClient.StatusWithoutPeers(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("localClient.Status: %w", err)
|
||||
}
|
||||
|
||||
@@ -109,6 +109,14 @@ If you enable this policy setting, users will not be allowed to disconnect Tails
|
||||
If necessary, it can be used along with Unattended Mode to keep Tailscale connected regardless of whether a user is logged in. This can be used to facilitate remote access to a device or ensure connectivity to a Domain Controller before a user logs in.
|
||||
|
||||
If you disable or don't configure this policy setting, users will be allowed to disconnect Tailscale at their will.]]></string>
|
||||
<string id="ReconnectAfter">Configure automatic reconnect delay</string>
|
||||
<string id="ReconnectAfter_Help"><![CDATA[This policy setting controls when Tailscale will attempt to reconnect automatically after a user disconnects it. It helps users remain connected most of the time and retain access to corporate resources without preventing them from temporarily disconnecting Tailscale. To configure whether and when Tailscale can be disconnected, see the "Restrict users from disconnecting Tailscale (always-on mode)" policy setting.
|
||||
|
||||
If you enable this policy setting, you can specify how long Tailscale will wait before attempting to reconnect after a user disconnects. The value should be specified as a Go duration: for example, 30s, 5m, or 1h30m. If the value is left blank, or if the specified duration is zero, Tailscale will not attempt to reconnect automatically.
|
||||
|
||||
If you disable or don't configure this policy setting, Tailscale will only reconnect if a user chooses to or if required by a different policy setting.
|
||||
|
||||
Refer to https://pkg.go.dev/time#ParseDuration for information about the supported duration strings.]]></string>
|
||||
<string id="ExitNodeAllowLANAccess">Allow Local Network Access when an Exit Node is in use</string>
|
||||
<string id="ExitNodeAllowLANAccess_Help"><![CDATA[This policy can be used to require that the Allow Local Network Access setting is configured a certain way.
|
||||
|
||||
@@ -280,6 +288,12 @@ See https://tailscale.com/kb/1315/mdm-keys#set-your-organization-name for more d
|
||||
<text>The options below allow configuring exceptions where disconnecting Tailscale is permitted.</text>
|
||||
<dropdownList refId="AlwaysOn_OverrideWithReason" noSort="true" defaultItem="0">Disconnects with reason:</dropdownList>
|
||||
</presentation>
|
||||
<presentation id="ReconnectAfter">
|
||||
<text>The delay must be a valid Go duration string, such as 30s, 5m, or 1h30m, all without spaces or any other symbols.</text>
|
||||
<textBox refId="ReconnectAfterDelay">
|
||||
<label>Reconnect after:</label>
|
||||
</textBox>
|
||||
</presentation>
|
||||
<presentation id="ExitNodeID">
|
||||
<textBox refId="ExitNodeIDPrompt">
|
||||
<label>Exit Node:</label>
|
||||
|
||||
@@ -156,6 +156,13 @@
|
||||
</enum>
|
||||
</elements>
|
||||
</policy>
|
||||
<policy name="ReconnectAfter" class="Machine" displayName="$(string.ReconnectAfter)" explainText="$(string.ReconnectAfter_Help)" presentation="$(presentation.ReconnectAfter)" key="Software\Policies\Tailscale">
|
||||
<parentCategory ref="Settings_Category" />
|
||||
<supportedOn ref="SINCE_V1_82" />
|
||||
<elements>
|
||||
<text id="ReconnectAfterDelay" valueName="ReconnectAfter" required="true" />
|
||||
</elements>
|
||||
</policy>
|
||||
<policy name="ExitNodeAllowLANAccess" class="Machine" displayName="$(string.ExitNodeAllowLANAccess)" explainText="$(string.ExitNodeAllowLANAccess_Help)" key="Software\Policies\Tailscale" valueName="ExitNodeAllowLANAccess">
|
||||
<parentCategory ref="Settings_Category" />
|
||||
<supportedOn ref="PARTIAL_FULL_SINCE_V1_56" />
|
||||
|
||||
11
go.mod
11
go.mod
@@ -1,6 +1,6 @@
|
||||
module tailscale.com
|
||||
|
||||
go 1.23.6
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
filippo.io/mkcert v1.4.4
|
||||
@@ -20,6 +20,7 @@ require (
|
||||
github.com/coder/websocket v1.8.12
|
||||
github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6
|
||||
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf
|
||||
github.com/creachadair/taskgroup v0.13.2
|
||||
github.com/creack/pty v1.1.23
|
||||
github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa
|
||||
github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e
|
||||
@@ -32,7 +33,7 @@ require (
|
||||
github.com/frankban/quicktest v1.14.6
|
||||
github.com/fxamacker/cbor/v2 v2.7.0
|
||||
github.com/gaissmai/bart v0.18.0
|
||||
github.com/go-json-experiment/json v0.0.0-20250103232110-6a9a0fde9288
|
||||
github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874
|
||||
github.com/go-logr/zapr v1.3.0
|
||||
github.com/go-ole/go-ole v1.3.0
|
||||
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466
|
||||
@@ -74,10 +75,10 @@ require (
|
||||
github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e
|
||||
github.com/tailscale/depaware v0.0.0-20250112153213-b748de04d81b
|
||||
github.com/tailscale/goexpect v0.0.0-20210902213824-6e8c725cea41
|
||||
github.com/tailscale/golang-x-crypto v0.0.0-20240604161659-3fde5e568aa4
|
||||
github.com/tailscale/golang-x-crypto v0.0.0-20250218230618-9a281fd8faca
|
||||
github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05
|
||||
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a
|
||||
github.com/tailscale/mkctr v0.0.0-20250110151924-54977352e4a6
|
||||
github.com/tailscale/mkctr v0.0.0-20250228050937-c75ea1476830
|
||||
github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7
|
||||
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc
|
||||
github.com/tailscale/setec v0.0.0-20250205144240-8898a29c3fbb
|
||||
@@ -93,7 +94,7 @@ require (
|
||||
go.uber.org/zap v1.27.0
|
||||
go4.org/mem v0.0.0-20240501181205-ae6ca9944745
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba
|
||||
golang.org/x/crypto v0.33.0
|
||||
golang.org/x/crypto v0.35.0
|
||||
golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac
|
||||
golang.org/x/mod v0.23.0
|
||||
golang.org/x/net v0.35.0
|
||||
|
||||
20
go.sum
20
go.sum
@@ -231,6 +231,8 @@ github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf/go.mod h1:F5haX7
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/creachadair/mds v0.17.1 h1:lXQbTGKmb3nE3aK6OEp29L1gCx6B5ynzlQ6c1KOBurc=
|
||||
github.com/creachadair/mds v0.17.1/go.mod h1:4b//mUiL8YldH6TImXjmW45myzTLNS1LLjOmrk888eg=
|
||||
github.com/creachadair/taskgroup v0.13.2 h1:3KyqakBuFsm3KkXi/9XIb0QcA8tEzLHLgaoidf0MdVc=
|
||||
github.com/creachadair/taskgroup v0.13.2/go.mod h1:i3V1Zx7H8RjwljUEeUWYT30Lmb9poewSb2XI1yTwD0g=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/creack/pty v1.1.23 h1:4M6+isWdcStXEf15G/RbrMPOQj1dZ7HPZCGwE4kOeP0=
|
||||
github.com/creack/pty v1.1.23/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
@@ -298,6 +300,8 @@ github.com/firefart/nonamedreturns v1.0.4 h1:abzI1p7mAEPYuR4A+VLKn4eNDOycjYo2phm
|
||||
github.com/firefart/nonamedreturns v1.0.4/go.mod h1:TDhe/tjI1BXo48CmYbUduTV7BdIga8MAO/xbKdcVsGI=
|
||||
github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
|
||||
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
|
||||
github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw=
|
||||
github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||
@@ -327,8 +331,8 @@ github.com/go-git/go-git/v5 v5.13.1/go.mod h1:qryJB4cSBoq3FRoBRf5A77joojuBcmPJ0q
|
||||
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
github.com/go-json-experiment/json v0.0.0-20250103232110-6a9a0fde9288 h1:KbX3Z3CgiYlbaavUq3Cj9/MjpO+88S7/AGXzynVDv84=
|
||||
github.com/go-json-experiment/json v0.0.0-20250103232110-6a9a0fde9288/go.mod h1:BWmvoE1Xia34f3l/ibJweyhrT+aROb/FQ6d+37F0e2s=
|
||||
github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874 h1:F8d1AJ6M9UQCavhwmO6ZsrYLfG8zVFWfEfMS2MXPkSY=
|
||||
github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
|
||||
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
|
||||
@@ -900,14 +904,14 @@ github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8
|
||||
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg=
|
||||
github.com/tailscale/goexpect v0.0.0-20210902213824-6e8c725cea41 h1:/V2rCMMWcsjYaYO2MeovLw+ClP63OtXgCF2Y1eb8+Ns=
|
||||
github.com/tailscale/goexpect v0.0.0-20210902213824-6e8c725cea41/go.mod h1:/roCdA6gg6lQyw/Oz6gIIGu3ggJKYhF+WC/AQReE5XQ=
|
||||
github.com/tailscale/golang-x-crypto v0.0.0-20240604161659-3fde5e568aa4 h1:rXZGgEa+k2vJM8xT0PoSKfVXwFGPQ3z3CJfmnHJkZZw=
|
||||
github.com/tailscale/golang-x-crypto v0.0.0-20240604161659-3fde5e568aa4/go.mod h1:ikbF+YT089eInTp9f2vmvy4+ZVnW5hzX1q2WknxSprQ=
|
||||
github.com/tailscale/golang-x-crypto v0.0.0-20250218230618-9a281fd8faca h1:ecjHwH73Yvqf/oIdQ2vxAX+zc6caQsYdPzsxNW1J3G8=
|
||||
github.com/tailscale/golang-x-crypto v0.0.0-20250218230618-9a281fd8faca/go.mod h1:ikbF+YT089eInTp9f2vmvy4+ZVnW5hzX1q2WknxSprQ=
|
||||
github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 h1:4chzWmimtJPxRs2O36yuGRW3f9SYV+bMTTvMBI0EKio=
|
||||
github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05/go.mod h1:PdCqy9JzfWMJf1H5UJW2ip33/d4YkoKN0r67yKH1mG8=
|
||||
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a h1:SJy1Pu0eH1C29XwJucQo73FrleVK6t4kYz4NVhp34Yw=
|
||||
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a/go.mod h1:DFSS3NAGHthKo1gTlmEcSBiZrRJXi28rLNd/1udP1c8=
|
||||
github.com/tailscale/mkctr v0.0.0-20250110151924-54977352e4a6 h1:9SuADtKJAGQkIpnpg5znEJ86QaxacN25pHkiEXTDjzg=
|
||||
github.com/tailscale/mkctr v0.0.0-20250110151924-54977352e4a6/go.mod h1:qTslktI+Qh9hXo7ZP8xLkl5V8AxUMfxG0xLtkCFLxnw=
|
||||
github.com/tailscale/mkctr v0.0.0-20250228050937-c75ea1476830 h1:SwZ72kr1oRzzSPA5PYB4hzPh22UI0nm0dapn3bHaUPs=
|
||||
github.com/tailscale/mkctr v0.0.0-20250228050937-c75ea1476830/go.mod h1:qTslktI+Qh9hXo7ZP8xLkl5V8AxUMfxG0xLtkCFLxnw=
|
||||
github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 h1:uFsXVBE9Qr4ZoF094vE6iYTLDl0qCiKzYXlL6UeWObU=
|
||||
github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0=
|
||||
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc h1:24heQPtnFR+yfntqhI3oAu9i27nEojcQ4NuBQOo5ZFA=
|
||||
@@ -1041,8 +1045,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=
|
||||
golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
|
||||
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
||||
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
||||
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
|
||||
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||
|
||||
@@ -1 +1 @@
|
||||
tailscale.go1.23
|
||||
tailscale.go1.24
|
||||
|
||||
@@ -1 +1 @@
|
||||
65c3f5f3fc9d96f56a37a79cad4ebbd7ff985801
|
||||
2b494987ff3c1a6a26e10570c490394ff0a77aa4
|
||||
|
||||
@@ -8,9 +8,16 @@
|
||||
package tailscale
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
tsclient "tailscale.com/client/tailscale"
|
||||
)
|
||||
|
||||
// maxSize is the maximum read size (10MB) of responses from the server.
|
||||
const maxReadSize = 10 << 20
|
||||
|
||||
func init() {
|
||||
tsclient.I_Acknowledge_This_API_Is_Unstable = true
|
||||
}
|
||||
@@ -50,3 +57,27 @@ func NewClient(tailnet string, auth AuthMethod) *Client {
|
||||
type Client struct {
|
||||
*tsclient.Client
|
||||
}
|
||||
|
||||
// HandleErrorResponse is an alias to tailscale.com/client/tailscale.
|
||||
func HandleErrorResponse(b []byte, resp *http.Response) error {
|
||||
return tsclient.HandleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
// SendRequest add the authentication key to the request and sends it. It
|
||||
// receives the response and reads up to 10MB of it.
|
||||
func SendRequest(c *Client, req *http.Request) ([]byte, *http.Response, error) {
|
||||
resp, err := c.Do(req)
|
||||
if err != nil {
|
||||
return nil, resp, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read response. Limit the response to 10MB.
|
||||
// This limit is carried over from client/tailscale/tailscale.go.
|
||||
body := io.LimitReader(resp.Body, maxReadSize+1)
|
||||
b, err := io.ReadAll(body)
|
||||
if len(b) > maxReadSize {
|
||||
err = errors.New("API response too large")
|
||||
}
|
||||
return b, resp, err
|
||||
}
|
||||
|
||||
103
internal/client/tailscale/vip_service.go
Normal file
103
internal/client/tailscale/vip_service.go
Normal file
@@ -0,0 +1,103 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package tailscale
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/util/httpm"
|
||||
)
|
||||
|
||||
// VIPService is a Tailscale VIPService with Tailscale API JSON representation.
|
||||
type VIPService struct {
|
||||
// Name is a VIPService name in form svc:<leftmost-label-of-service-DNS-name>.
|
||||
Name tailcfg.ServiceName `json:"name,omitempty"`
|
||||
// Addrs are the IP addresses of the VIP Service. There are two addresses:
|
||||
// the first is IPv4 and the second is IPv6.
|
||||
// When creating a new VIP Service, the IP addresses are optional: if no
|
||||
// addresses are specified then they will be selected. If an IPv4 address is
|
||||
// specified at index 0, then that address will attempt to be used. An IPv6
|
||||
// address can not be specified upon creation.
|
||||
Addrs []string `json:"addrs,omitempty"`
|
||||
// Comment is an optional text string for display in the admin panel.
|
||||
Comment string `json:"comment,omitempty"`
|
||||
// Ports are the ports of a VIPService that will be configured via Tailscale serve config.
|
||||
// If set, any node wishing to advertise this VIPService must have this port configured via Tailscale serve.
|
||||
Ports []string `json:"ports,omitempty"`
|
||||
// Tags are optional ACL tags that will be applied to the VIPService.
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
}
|
||||
|
||||
// GetVIPService retrieves a VIPService by its name. It returns 404 if the VIPService is not found.
|
||||
func (client *Client) GetVIPService(ctx context.Context, name tailcfg.ServiceName) (*VIPService, error) {
|
||||
path := client.BuildTailnetURL("vip-services", name.String())
|
||||
req, err := http.NewRequestWithContext(ctx, httpm.GET, path, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error creating new HTTP request: %w", err)
|
||||
}
|
||||
b, resp, err := SendRequest(client, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error making Tailsale API request: %w", err)
|
||||
}
|
||||
// If status code was not successful, return the error.
|
||||
// TODO: Change the check for the StatusCode to include other 2XX success codes.
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, HandleErrorResponse(b, resp)
|
||||
}
|
||||
svc := &VIPService{}
|
||||
if err := json.Unmarshal(b, svc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
// CreateOrUpdateVIPService creates or updates a VIPService by its name. Caller must ensure that, if the
|
||||
// VIPService already exists, the VIPService is fetched first to ensure that any auto-allocated IP addresses are not
|
||||
// lost during the update. If the VIPService was created without any IP addresses explicitly set (so that they were
|
||||
// auto-allocated by Tailscale) any subsequent request to this function that does not set any IP addresses will error.
|
||||
func (client *Client) CreateOrUpdateVIPService(ctx context.Context, svc *VIPService) error {
|
||||
data, err := json.Marshal(svc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := client.BuildTailnetURL("vip-services", svc.Name.String())
|
||||
req, err := http.NewRequestWithContext(ctx, httpm.PUT, path, bytes.NewBuffer(data))
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating new HTTP request: %w", err)
|
||||
}
|
||||
b, resp, err := SendRequest(client, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error making Tailscale API request: %w", err)
|
||||
}
|
||||
// If status code was not successful, return the error.
|
||||
// TODO: Change the check for the StatusCode to include other 2XX success codes.
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return HandleErrorResponse(b, resp)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteVIPService deletes a VIPService by its name. It returns an error if the VIPService
|
||||
// does not exist or if the deletion fails.
|
||||
func (client *Client) DeleteVIPService(ctx context.Context, name tailcfg.ServiceName) error {
|
||||
path := client.BuildTailnetURL("vip-services", name.String())
|
||||
req, err := http.NewRequestWithContext(ctx, httpm.DELETE, path, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating new HTTP request: %w", err)
|
||||
}
|
||||
b, resp, err := SendRequest(client, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error making Tailscale API request: %w", err)
|
||||
}
|
||||
// If status code was not successful, return the error.
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return HandleErrorResponse(b, resp)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -250,15 +250,13 @@ type certStore interface {
|
||||
// for now. If they're expired, it returns errCertExpired.
|
||||
// If they don't exist, it returns ipn.ErrStateNotExist.
|
||||
Read(domain string, now time.Time) (*TLSCertKeyPair, error)
|
||||
// WriteCert writes the cert for domain.
|
||||
WriteCert(domain string, cert []byte) error
|
||||
// WriteKey writes the key for domain.
|
||||
WriteKey(domain string, key []byte) error
|
||||
// ACMEKey returns the value previously stored via WriteACMEKey.
|
||||
// It is a PEM encoded ECDSA key.
|
||||
ACMEKey() ([]byte, error)
|
||||
// WriteACMEKey stores the provided PEM encoded ECDSA key.
|
||||
WriteACMEKey([]byte) error
|
||||
// WriteTLSCertAndKey writes the cert and key for domain.
|
||||
WriteTLSCertAndKey(domain string, cert, key []byte) error
|
||||
}
|
||||
|
||||
var errCertExpired = errors.New("cert expired")
|
||||
@@ -344,6 +342,13 @@ func (f certFileStore) WriteKey(domain string, key []byte) error {
|
||||
return atomicfile.WriteFile(keyFile(f.dir, domain), key, 0600)
|
||||
}
|
||||
|
||||
func (f certFileStore) WriteTLSCertAndKey(domain string, cert, key []byte) error {
|
||||
if err := f.WriteKey(domain, key); err != nil {
|
||||
return err
|
||||
}
|
||||
return f.WriteCert(domain, cert)
|
||||
}
|
||||
|
||||
// certStateStore implements certStore by storing the cert & key files in an ipn.StateStore.
|
||||
type certStateStore struct {
|
||||
ipn.StateStore
|
||||
@@ -384,6 +389,27 @@ func (s certStateStore) WriteACMEKey(key []byte) error {
|
||||
return ipn.WriteState(s.StateStore, ipn.StateKey(acmePEMName), key)
|
||||
}
|
||||
|
||||
// TLSCertKeyWriter is an interface implemented by state stores that can write the TLS
|
||||
// cert and key in a single atomic operation. Currently this is only implemented
|
||||
// by the kubestore.StoreKube.
|
||||
type TLSCertKeyWriter interface {
|
||||
WriteTLSCertAndKey(domain string, cert, key []byte) error
|
||||
}
|
||||
|
||||
// WriteTLSCertAndKey writes the TLS cert and key for domain to the current
|
||||
// LocalBackend's StateStore.
|
||||
func (s certStateStore) WriteTLSCertAndKey(domain string, cert, key []byte) error {
|
||||
// If we're using a store that supports atomic writes, use that.
|
||||
if aw, ok := s.StateStore.(TLSCertKeyWriter); ok {
|
||||
return aw.WriteTLSCertAndKey(domain, cert, key)
|
||||
}
|
||||
// Otherwise fall back to separate writes for cert and key.
|
||||
if err := s.WriteKey(domain, key); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.WriteCert(domain, cert)
|
||||
}
|
||||
|
||||
// TLSCertKeyPair is a TLS public and private key, and whether they were obtained
|
||||
// from cache or freshly obtained.
|
||||
type TLSCertKeyPair struct {
|
||||
@@ -546,9 +572,6 @@ func (b *LocalBackend) getCertPEM(ctx context.Context, cs certStore, logf logger
|
||||
if err := encodeECDSAKey(&privPEM, certPrivKey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := cs.WriteKey(domain, privPEM.Bytes()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
csr, err := certRequest(certPrivKey, domain, nil)
|
||||
if err != nil {
|
||||
@@ -570,7 +593,7 @@ func (b *LocalBackend) getCertPEM(ctx context.Context, cs certStore, logf logger
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := cs.WriteCert(domain, certPEM.Bytes()); err != nil {
|
||||
if err := cs.WriteTLSCertAndKey(domain, certPEM.Bytes(), privPEM.Bytes()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b.domainRenewed(domain)
|
||||
|
||||
@@ -86,13 +86,9 @@ func TestCertStoreRoundTrip(t *testing.T) {
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if err := test.store.WriteCert(testDomain, testCert); err != nil {
|
||||
t.Fatalf("WriteCert: unexpected error: %v", err)
|
||||
if err := test.store.WriteTLSCertAndKey(testDomain, testCert, testKey); err != nil {
|
||||
t.Fatalf("WriteTLSCertAndKey: unexpected error: %v", err)
|
||||
}
|
||||
if err := test.store.WriteKey(testDomain, testKey); err != nil {
|
||||
t.Fatalf("WriteKey: unexpected error: %v", err)
|
||||
}
|
||||
|
||||
kp, err := test.store.Read(testDomain, testNow)
|
||||
if err != nil {
|
||||
t.Fatalf("Read: unexpected error: %v", err)
|
||||
|
||||
@@ -442,6 +442,10 @@ type LocalBackend struct {
|
||||
// See tailscale/corp#26146.
|
||||
overrideAlwaysOn bool
|
||||
|
||||
// reconnectTimer is used to schedule a reconnect by setting [ipn.Prefs.WantRunning]
|
||||
// to true after a delay, or nil if no reconnect is scheduled.
|
||||
reconnectTimer tstime.TimerController
|
||||
|
||||
// shutdownCbs are the callbacks to be called when the backend is shutting down.
|
||||
// Each callback is called exactly once in unspecified order and without b.mu held.
|
||||
// Returned errors are logged but otherwise ignored and do not affect the shutdown process.
|
||||
@@ -614,19 +618,6 @@ func NewLocalBackend(logf logger.Logf, logID logid.PublicID, sys *tsd.System, lo
|
||||
}
|
||||
}
|
||||
|
||||
// initialize Taildrive shares from saved state
|
||||
fs, ok := b.sys.DriveForRemote.GetOK()
|
||||
if ok {
|
||||
currentShares := b.pm.prefs.DriveShares()
|
||||
if currentShares.Len() > 0 {
|
||||
var shares []*drive.Share
|
||||
for _, share := range currentShares.All() {
|
||||
shares = append(shares, share.AsStruct())
|
||||
}
|
||||
fs.SetShares(shares)
|
||||
}
|
||||
}
|
||||
|
||||
for name, newFn := range registeredExtensions {
|
||||
ext, err := newFn(logf, sys)
|
||||
if err != nil {
|
||||
@@ -1070,6 +1061,8 @@ func (b *LocalBackend) Shutdown() {
|
||||
b.captiveCancel()
|
||||
}
|
||||
|
||||
b.stopReconnectTimerLocked()
|
||||
|
||||
if b.loginFlags&controlclient.LoginEphemeral != 0 {
|
||||
b.mu.Unlock()
|
||||
ctx, cancel := context.WithTimeout(b.ctx, 5*time.Second)
|
||||
@@ -2341,12 +2334,20 @@ func (b *LocalBackend) Start(opts ipn.Options) error {
|
||||
}); err != nil {
|
||||
b.logf("failed to save UpdatePrefs state: %v", err)
|
||||
}
|
||||
b.setAtomicValuesFromPrefsLocked(pv)
|
||||
} else {
|
||||
b.setAtomicValuesFromPrefsLocked(b.pm.CurrentPrefs())
|
||||
}
|
||||
|
||||
// Reset the always-on override whenever Start is called.
|
||||
b.resetAlwaysOnOverrideLocked()
|
||||
// And also apply syspolicy settings to the current profile.
|
||||
// This is important in two cases: when opts.UpdatePrefs is not nil,
|
||||
// and when Always Mode is enabled and we need to set WantRunning to true.
|
||||
if newp := b.pm.CurrentPrefs().AsStruct(); applySysPolicy(newp, b.lastSuggestedExitNode, b.overrideAlwaysOn) {
|
||||
setExitNodeID(newp, b.netMap)
|
||||
b.pm.setPrefsNoPermCheck(newp.View())
|
||||
}
|
||||
prefs := b.pm.CurrentPrefs()
|
||||
b.setAtomicValuesFromPrefsLocked(prefs)
|
||||
|
||||
wantRunning := prefs.WantRunning()
|
||||
if wantRunning {
|
||||
if err := b.initMachineKeyLocked(); err != nil {
|
||||
@@ -2444,6 +2445,16 @@ func (b *LocalBackend) Start(opts ipn.Options) error {
|
||||
b.logf("Backend: logs: be:%v fe:%v", blid, opts.FrontendLogID)
|
||||
b.sendToLocked(ipn.Notify{Prefs: &prefs}, allClients)
|
||||
|
||||
// initialize Taildrive shares from saved state
|
||||
if fs, ok := b.sys.DriveForRemote.GetOK(); ok {
|
||||
currentShares := b.pm.CurrentPrefs().DriveShares()
|
||||
var shares []*drive.Share
|
||||
for _, share := range currentShares.All() {
|
||||
shares = append(shares, share.AsStruct())
|
||||
}
|
||||
fs.SetShares(shares)
|
||||
}
|
||||
|
||||
if !loggedOut && (b.hasNodeKeyLocked() || confWantRunning) {
|
||||
// If we know that we're either logged in or meant to be
|
||||
// running, tell the controlclient that it should also assume
|
||||
@@ -4289,15 +4300,75 @@ func (b *LocalBackend) EditPrefsAs(mp *ipn.MaskedPrefs, actor ipnauth.Actor) (ip
|
||||
// mode on them until the policy changes, they switch to a different profile, etc.
|
||||
b.overrideAlwaysOn = true
|
||||
|
||||
// TODO(nickkhyl): check the ReconnectAfter policy here. If configured,
|
||||
// start a timer to automatically reconnect after the specified duration.
|
||||
if reconnectAfter, _ := syspolicy.GetDuration(syspolicy.ReconnectAfter, 0); reconnectAfter > 0 {
|
||||
b.startReconnectTimerLocked(reconnectAfter)
|
||||
}
|
||||
}
|
||||
|
||||
return b.editPrefsLockedOnEntry(mp, unlock)
|
||||
}
|
||||
|
||||
// startReconnectTimerLocked sets a timer to automatically set WantRunning to true
|
||||
// after the specified duration.
|
||||
func (b *LocalBackend) startReconnectTimerLocked(d time.Duration) {
|
||||
if b.reconnectTimer != nil {
|
||||
// Stop may return false if the timer has already fired,
|
||||
// and the function has been called in its own goroutine,
|
||||
// but lost the race to acquire b.mu. In this case, it'll
|
||||
// end up as a no-op due to a reconnectTimer mismatch
|
||||
// once it manages to acquire the lock. This is fine, and we
|
||||
// don't need to check the return value.
|
||||
b.reconnectTimer.Stop()
|
||||
}
|
||||
profileID := b.pm.CurrentProfile().ID()
|
||||
var reconnectTimer tstime.TimerController
|
||||
reconnectTimer = b.clock.AfterFunc(d, func() {
|
||||
unlock := b.lockAndGetUnlock()
|
||||
defer unlock()
|
||||
|
||||
if b.reconnectTimer != reconnectTimer {
|
||||
// We're either not the most recent timer, or we lost the race when
|
||||
// the timer was stopped. No need to reconnect.
|
||||
return
|
||||
}
|
||||
b.reconnectTimer = nil
|
||||
|
||||
cp := b.pm.CurrentProfile()
|
||||
if cp.ID() != profileID {
|
||||
// The timer fired before the profile changed but we lost the race
|
||||
// and acquired the lock shortly after.
|
||||
// No need to reconnect.
|
||||
return
|
||||
}
|
||||
|
||||
mp := &ipn.MaskedPrefs{WantRunningSet: true, Prefs: ipn.Prefs{WantRunning: true}}
|
||||
if _, err := b.editPrefsLockedOnEntry(mp, unlock); err != nil {
|
||||
b.logf("failed to automatically reconnect as %q after %v: %v", cp.Name(), d, err)
|
||||
} else {
|
||||
b.logf("automatically reconnected as %q after %v", cp.Name(), d)
|
||||
}
|
||||
})
|
||||
b.reconnectTimer = reconnectTimer
|
||||
b.logf("reconnect for %q has been scheduled and will be performed in %v", b.pm.CurrentProfile().Name(), d)
|
||||
}
|
||||
|
||||
func (b *LocalBackend) resetAlwaysOnOverrideLocked() {
|
||||
b.overrideAlwaysOn = false
|
||||
b.stopReconnectTimerLocked()
|
||||
}
|
||||
|
||||
func (b *LocalBackend) stopReconnectTimerLocked() {
|
||||
if b.reconnectTimer != nil {
|
||||
// Stop may return false if the timer has already fired,
|
||||
// and the function has been called in its own goroutine,
|
||||
// but lost the race to acquire b.mu.
|
||||
// In this case, it'll end up as a no-op due to a reconnectTimer
|
||||
// mismatch (see [LocalBackend.startReconnectTimerLocked])
|
||||
// once it manages to acquire the lock. This is fine, and we
|
||||
// don't need to check the return value.
|
||||
b.reconnectTimer.Stop()
|
||||
b.reconnectTimer = nil
|
||||
}
|
||||
}
|
||||
|
||||
// Warning: b.mu must be held on entry, but it unlocks it on the way out.
|
||||
@@ -4391,7 +4462,7 @@ func (b *LocalBackend) setPrefsLockedOnEntry(newp *ipn.Prefs, unlock unlockOnce)
|
||||
if oldp.Valid() {
|
||||
newp.Persist = oldp.Persist().AsStruct() // caller isn't allowed to override this
|
||||
}
|
||||
// applySysPolicyToPrefsLocked returns whether it updated newp,
|
||||
// applySysPolicy returns whether it updated newp,
|
||||
// but everything in this function treats b.prefs as completely new
|
||||
// anyway, so its return value can be ignored here.
|
||||
applySysPolicy(newp, b.lastSuggestedExitNode, b.overrideAlwaysOn)
|
||||
@@ -6635,7 +6706,7 @@ func (b *LocalBackend) FileTargets() ([]*apitype.FileTarget, error) {
|
||||
}
|
||||
|
||||
func (b *LocalBackend) taildropTargetStatus(p tailcfg.NodeView) ipnstate.TaildropTargetStatus {
|
||||
if b.netMap == nil || b.state != ipn.Running {
|
||||
if b.state != ipn.Running {
|
||||
return ipnstate.TaildropTargetIpnStateNotRunning
|
||||
}
|
||||
if b.netMap == nil {
|
||||
@@ -8148,15 +8219,13 @@ func (b *LocalBackend) vipServiceHash(services []*tailcfg.VIPService) string {
|
||||
func (b *LocalBackend) vipServicesFromPrefsLocked(prefs ipn.PrefsView) []*tailcfg.VIPService {
|
||||
// keyed by service name
|
||||
var services map[tailcfg.ServiceName]*tailcfg.VIPService
|
||||
if !b.serveConfig.Valid() {
|
||||
return nil
|
||||
}
|
||||
|
||||
for svc, config := range b.serveConfig.Services().All() {
|
||||
mak.Set(&services, svc, &tailcfg.VIPService{
|
||||
Name: svc,
|
||||
Ports: config.ServicePortRange(),
|
||||
})
|
||||
if b.serveConfig.Valid() {
|
||||
for svc, config := range b.serveConfig.Services().All() {
|
||||
mak.Set(&services, svc, &tailcfg.VIPService{
|
||||
Name: svc,
|
||||
Ports: config.ServicePortRange(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for _, s := range prefs.AdvertiseServices().All() {
|
||||
|
||||
@@ -216,6 +216,11 @@ type PeerStatusLite struct {
|
||||
}
|
||||
|
||||
// PeerStatus describes a peer node and its current state.
|
||||
// WARNING: The fields in PeerStatus are merged by the AddPeer method in the StatusBuilder.
|
||||
// When adding a new field to PeerStatus, you must update AddPeer to handle merging
|
||||
// the new field. The AddPeer function is responsible for combining multiple updates
|
||||
// to the same peer, and any new field that is not merged properly may lead to
|
||||
// inconsistencies or lost data in the peer status.
|
||||
type PeerStatus struct {
|
||||
ID tailcfg.StableNodeID
|
||||
PublicKey key.NodePublic
|
||||
@@ -533,6 +538,9 @@ func (sb *StatusBuilder) AddPeer(peer key.NodePublic, st *PeerStatus) {
|
||||
if v := st.Capabilities; v != nil {
|
||||
e.Capabilities = v
|
||||
}
|
||||
if v := st.TaildropTarget; v != TaildropTargetUnknown {
|
||||
e.TaildropTarget = v
|
||||
}
|
||||
e.Location = st.Location
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,9 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/aws/arn"
|
||||
@@ -28,6 +30,14 @@ const (
|
||||
|
||||
var parameterNameRx = regexp.MustCompile(parameterNameRxStr)
|
||||
|
||||
// Option defines a functional option type for configuring awsStore.
|
||||
type Option func(*storeOptions)
|
||||
|
||||
// storeOptions holds optional settings for creating a new awsStore.
|
||||
type storeOptions struct {
|
||||
kmsKey string
|
||||
}
|
||||
|
||||
// awsSSMClient is an interface allowing us to mock the couple of
|
||||
// API calls we are leveraging with the AWSStore provider
|
||||
type awsSSMClient interface {
|
||||
@@ -46,6 +56,10 @@ type awsStore struct {
|
||||
ssmClient awsSSMClient
|
||||
ssmARN arn.ARN
|
||||
|
||||
// kmsKey is optional. If empty, the parameter is stored in plaintext.
|
||||
// If non-empty, the parameter is encrypted with this KMS key.
|
||||
kmsKey string
|
||||
|
||||
memory mem.Store
|
||||
}
|
||||
|
||||
@@ -57,30 +71,80 @@ type awsStore struct {
|
||||
// Tailscaled to only only store new state in-memory and
|
||||
// restarting Tailscaled can fail until you delete your state
|
||||
// from the AWS Parameter Store.
|
||||
func New(_ logger.Logf, ssmARN string) (ipn.StateStore, error) {
|
||||
return newStore(ssmARN, nil)
|
||||
//
|
||||
// If you want to specify an optional KMS key,
|
||||
// pass one or more Option objects, e.g. awsstore.WithKeyID("alias/my-key").
|
||||
func New(_ logger.Logf, ssmARN string, opts ...Option) (ipn.StateStore, error) {
|
||||
// Apply all options to an empty storeOptions
|
||||
var so storeOptions
|
||||
for _, opt := range opts {
|
||||
opt(&so)
|
||||
}
|
||||
|
||||
return newStore(ssmARN, so, nil)
|
||||
}
|
||||
|
||||
// WithKeyID sets the KMS key to be used for encryption. It can be
|
||||
// a KeyID, an alias ("alias/my-key"), or a full ARN.
|
||||
//
|
||||
// If kmsKey is empty, the Option is a no-op.
|
||||
func WithKeyID(kmsKey string) Option {
|
||||
return func(o *storeOptions) {
|
||||
o.kmsKey = kmsKey
|
||||
}
|
||||
}
|
||||
|
||||
// ParseARNAndOpts parses an ARN and optional URL-encoded parameters
|
||||
// from arg.
|
||||
func ParseARNAndOpts(arg string) (ssmARN string, opts []Option, err error) {
|
||||
ssmARN = arg
|
||||
|
||||
// Support optional ?url-encoded-parameters.
|
||||
if s, q, ok := strings.Cut(arg, "?"); ok {
|
||||
ssmARN = s
|
||||
q, err := url.ParseQuery(q)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
for k := range q {
|
||||
switch k {
|
||||
default:
|
||||
return "", nil, fmt.Errorf("unknown arn option parameter %q", k)
|
||||
case "kmsKey":
|
||||
// We allow an ARN, a key ID, or an alias name for kmsKeyID.
|
||||
// If it doesn't look like an ARN and doesn't have a '/',
|
||||
// prepend "alias/" for KMS alias references.
|
||||
kmsKey := q.Get(k)
|
||||
if kmsKey != "" &&
|
||||
!strings.Contains(kmsKey, "/") &&
|
||||
!strings.HasPrefix(kmsKey, "arn:") {
|
||||
kmsKey = "alias/" + kmsKey
|
||||
}
|
||||
if kmsKey != "" {
|
||||
opts = append(opts, WithKeyID(kmsKey))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ssmARN, opts, nil
|
||||
}
|
||||
|
||||
// newStore is NewStore, but for tests. If client is non-nil, it's
|
||||
// used instead of making one.
|
||||
func newStore(ssmARN string, client awsSSMClient) (ipn.StateStore, error) {
|
||||
func newStore(ssmARN string, so storeOptions, client awsSSMClient) (ipn.StateStore, error) {
|
||||
s := &awsStore{
|
||||
ssmClient: client,
|
||||
kmsKey: so.kmsKey,
|
||||
}
|
||||
|
||||
var err error
|
||||
|
||||
// Parse the ARN
|
||||
if s.ssmARN, err = arn.Parse(ssmARN); err != nil {
|
||||
return nil, fmt.Errorf("unable to parse the ARN correctly: %v", err)
|
||||
}
|
||||
|
||||
// Validate the ARN corresponds to the SSM service
|
||||
if s.ssmARN.Service != "ssm" {
|
||||
return nil, fmt.Errorf("invalid service %q, expected 'ssm'", s.ssmARN.Service)
|
||||
}
|
||||
|
||||
// Validate the ARN corresponds to a parameter store resource
|
||||
if !parameterNameRx.MatchString(s.ssmARN.Resource) {
|
||||
return nil, fmt.Errorf("invalid resource %q, expected to match %v", s.ssmARN.Resource, parameterNameRxStr)
|
||||
}
|
||||
@@ -96,12 +160,11 @@ func newStore(ssmARN string, client awsSSMClient) (ipn.StateStore, error) {
|
||||
s.ssmClient = ssm.NewFromConfig(cfg)
|
||||
}
|
||||
|
||||
// Hydrate cache with the potentially current state
|
||||
// Preload existing state, if any
|
||||
if err := s.LoadState(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
|
||||
}
|
||||
|
||||
// LoadState attempts to read the state from AWS SSM parameter store key.
|
||||
@@ -172,15 +235,21 @@ func (s *awsStore) persistState() error {
|
||||
// which is free. However, if it exceeds 4kb it switches the parameter to advanced tiering
|
||||
// doubling the capacity to 8kb per the following docs:
|
||||
// https://aws.amazon.com/about-aws/whats-new/2019/08/aws-systems-manager-parameter-store-announces-intelligent-tiering-to-enable-automatic-parameter-tier-selection/
|
||||
_, err = s.ssmClient.PutParameter(
|
||||
context.TODO(),
|
||||
&ssm.PutParameterInput{
|
||||
Name: aws.String(s.ParameterName()),
|
||||
Value: aws.String(string(bs)),
|
||||
Overwrite: aws.Bool(true),
|
||||
Tier: ssmTypes.ParameterTierIntelligentTiering,
|
||||
Type: ssmTypes.ParameterTypeSecureString,
|
||||
},
|
||||
)
|
||||
in := &ssm.PutParameterInput{
|
||||
Name: aws.String(s.ParameterName()),
|
||||
Value: aws.String(string(bs)),
|
||||
Overwrite: aws.Bool(true),
|
||||
Tier: ssmTypes.ParameterTierIntelligentTiering,
|
||||
Type: ssmTypes.ParameterTypeSecureString,
|
||||
}
|
||||
|
||||
// If kmsKey is specified, encrypt with that key
|
||||
// NOTE: this input allows any alias, keyID or ARN
|
||||
// If this isn't specified, AWS will use the default KMS key
|
||||
if s.kmsKey != "" {
|
||||
in.KeyId = aws.String(s.kmsKey)
|
||||
}
|
||||
|
||||
_, err = s.ssmClient.PutParameter(context.TODO(), in)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !linux || ts_omit_aws
|
||||
|
||||
package awsstore
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/types/logger"
|
||||
)
|
||||
|
||||
func New(logger.Logf, string) (ipn.StateStore, error) {
|
||||
return nil, fmt.Errorf("AWS store is not supported on %v", runtime.GOOS)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build linux
|
||||
//go:build linux && !ts_omit_aws
|
||||
|
||||
package awsstore
|
||||
|
||||
@@ -65,7 +65,11 @@ func TestNewAWSStore(t *testing.T) {
|
||||
Resource: "parameter/foo",
|
||||
}
|
||||
|
||||
s, err := newStore(storeParameterARN.String(), mc)
|
||||
opts := storeOptions{
|
||||
kmsKey: "arn:aws:kms:eu-west-1:123456789:key/MyCustomKey",
|
||||
}
|
||||
|
||||
s, err := newStore(storeParameterARN.String(), opts, mc)
|
||||
if err != nil {
|
||||
t.Fatalf("creating aws store failed: %v", err)
|
||||
}
|
||||
@@ -73,7 +77,7 @@ func TestNewAWSStore(t *testing.T) {
|
||||
|
||||
// Build a brand new file store and check that both IDs written
|
||||
// above are still there.
|
||||
s2, err := newStore(storeParameterARN.String(), mc)
|
||||
s2, err := newStore(storeParameterARN.String(), opts, mc)
|
||||
if err != nil {
|
||||
t.Fatalf("creating second aws store failed: %v", err)
|
||||
}
|
||||
@@ -162,3 +166,54 @@ func testStoreSemantics(t *testing.T, store ipn.StateStore) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseARNAndOpts(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
arg string
|
||||
wantARN string
|
||||
wantKey string
|
||||
}{
|
||||
{
|
||||
name: "no-key",
|
||||
arg: "arn:aws:ssm:us-east-1:123456789012:parameter/myTailscaleParam",
|
||||
wantARN: "arn:aws:ssm:us-east-1:123456789012:parameter/myTailscaleParam",
|
||||
},
|
||||
{
|
||||
name: "custom-key",
|
||||
arg: "arn:aws:ssm:us-east-1:123456789012:parameter/myTailscaleParam?kmsKey=alias/MyCustomKey",
|
||||
wantARN: "arn:aws:ssm:us-east-1:123456789012:parameter/myTailscaleParam",
|
||||
wantKey: "alias/MyCustomKey",
|
||||
},
|
||||
{
|
||||
name: "bare-name",
|
||||
arg: "arn:aws:ssm:us-east-1:123456789012:parameter/myTailscaleParam?kmsKey=Bare",
|
||||
wantARN: "arn:aws:ssm:us-east-1:123456789012:parameter/myTailscaleParam",
|
||||
wantKey: "alias/Bare",
|
||||
},
|
||||
{
|
||||
name: "arn-arg",
|
||||
arg: "arn:aws:ssm:us-east-1:123456789012:parameter/myTailscaleParam?kmsKey=arn:foo",
|
||||
wantARN: "arn:aws:ssm:us-east-1:123456789012:parameter/myTailscaleParam",
|
||||
wantKey: "arn:foo",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
arn, opts, err := ParseARNAndOpts(tt.arg)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
if arn != tt.wantARN {
|
||||
t.Errorf("ARN = %q; want %q", arn, tt.wantARN)
|
||||
}
|
||||
var got storeOptions
|
||||
for _, opt := range opts {
|
||||
opt(&got)
|
||||
}
|
||||
if got.kmsKey != tt.wantKey {
|
||||
t.Errorf("kmsKey = %q; want %q", got.kmsKey, tt.wantKey)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"tailscale.com/kube/kubeapi"
|
||||
"tailscale.com/kube/kubeclient"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/util/mak"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -83,10 +84,26 @@ func (s *Store) ReadState(id ipn.StateKey) ([]byte, error) {
|
||||
|
||||
// WriteState implements the StateStore interface.
|
||||
func (s *Store) WriteState(id ipn.StateKey, bs []byte) (err error) {
|
||||
return s.updateStateSecret(map[string][]byte{string(id): bs})
|
||||
}
|
||||
|
||||
// WriteTLSCertAndKey writes a TLS cert and key to domain.crt, domain.key fields of a Tailscale Kubernetes node's state
|
||||
// Secret.
|
||||
func (s *Store) WriteTLSCertAndKey(domain string, cert, key []byte) error {
|
||||
return s.updateStateSecret(map[string][]byte{domain + ".crt": cert, domain + ".key": key})
|
||||
}
|
||||
|
||||
func (s *Store) updateStateSecret(data map[string][]byte) (err error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer func() {
|
||||
if err == nil {
|
||||
s.memory.WriteState(ipn.StateKey(sanitizeKey(id)), bs)
|
||||
for id, bs := range data {
|
||||
// The in-memory store does not distinguish between values read from state Secret on
|
||||
// init and values written to afterwards. Values read from the state
|
||||
// Secret will always be sanitized, so we also need to sanitize values written to store
|
||||
// later, so that the Read logic can just lookup keys in sanitized form.
|
||||
s.memory.WriteState(ipn.StateKey(sanitizeKey(id)), bs)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if err := s.client.Event(ctx, eventTypeWarning, reasonTailscaleStateUpdateFailed, err.Error()); err != nil {
|
||||
@@ -99,9 +116,9 @@ func (s *Store) WriteState(id ipn.StateKey, bs []byte) (err error) {
|
||||
}
|
||||
cancel()
|
||||
}()
|
||||
|
||||
secret, err := s.client.GetSecret(ctx, s.secretName)
|
||||
if err != nil {
|
||||
// If the Secret does not exist, create it with the required data.
|
||||
if kubeclient.IsNotFoundErr(err) {
|
||||
return s.client.CreateSecret(ctx, &kubeapi.Secret{
|
||||
TypeMeta: kubeapi.TypeMeta{
|
||||
@@ -111,40 +128,53 @@ func (s *Store) WriteState(id ipn.StateKey, bs []byte) (err error) {
|
||||
ObjectMeta: kubeapi.ObjectMeta{
|
||||
Name: s.secretName,
|
||||
},
|
||||
Data: map[string][]byte{
|
||||
sanitizeKey(id): bs,
|
||||
},
|
||||
Data: func(m map[string][]byte) map[string][]byte {
|
||||
d := make(map[string][]byte, len(m))
|
||||
for key, val := range m {
|
||||
d[sanitizeKey(key)] = val
|
||||
}
|
||||
return d
|
||||
}(data),
|
||||
})
|
||||
}
|
||||
return err
|
||||
}
|
||||
if s.canPatch {
|
||||
if len(secret.Data) == 0 { // if user has pre-created a blank Secret
|
||||
m := []kubeclient.JSONPatch{
|
||||
var m []kubeclient.JSONPatch
|
||||
// If the user has pre-created a Secret with no data, we need to ensure the top level /data field.
|
||||
if len(secret.Data) == 0 {
|
||||
m = []kubeclient.JSONPatch{
|
||||
{
|
||||
Op: "add",
|
||||
Path: "/data",
|
||||
Value: map[string][]byte{sanitizeKey(id): bs},
|
||||
Op: "add",
|
||||
Path: "/data",
|
||||
Value: func(m map[string][]byte) map[string][]byte {
|
||||
d := make(map[string][]byte, len(m))
|
||||
for key, val := range m {
|
||||
d[sanitizeKey(key)] = val
|
||||
}
|
||||
return d
|
||||
}(data),
|
||||
},
|
||||
}
|
||||
if err := s.client.JSONPatchResource(ctx, s.secretName, kubeclient.TypeSecrets, m); err != nil {
|
||||
return fmt.Errorf("error patching Secret %s with a /data field: %v", s.secretName, err)
|
||||
// If the Secret has data, patch it with the new data.
|
||||
} else {
|
||||
for key, val := range data {
|
||||
m = append(m, kubeclient.JSONPatch{
|
||||
Op: "add",
|
||||
Path: "/data/" + sanitizeKey(key),
|
||||
Value: val,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
m := []kubeclient.JSONPatch{
|
||||
{
|
||||
Op: "add",
|
||||
Path: "/data/" + sanitizeKey(id),
|
||||
Value: bs,
|
||||
},
|
||||
}
|
||||
if err := s.client.JSONPatchResource(ctx, s.secretName, kubeclient.TypeSecrets, m); err != nil {
|
||||
return fmt.Errorf("error patching Secret %s with /data/%s field: %v", s.secretName, sanitizeKey(id), err)
|
||||
return fmt.Errorf("error patching Secret %s: %w", s.secretName, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
secret.Data[sanitizeKey(id)] = bs
|
||||
// No patch permissions, use UPDATE instead.
|
||||
for key, val := range data {
|
||||
mak.Set(&secret.Data, sanitizeKey(key), val)
|
||||
}
|
||||
if err := s.client.UpdateSecret(ctx, secret); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -172,9 +202,10 @@ func (s *Store) loadState() (err error) {
|
||||
return nil
|
||||
}
|
||||
|
||||
func sanitizeKey(k ipn.StateKey) string {
|
||||
// The only valid characters in a Kubernetes secret key are alphanumeric, -,
|
||||
// _, and .
|
||||
// sanitizeKey converts any value that can be converted to a string into a valid Kubernetes Secret key.
|
||||
// Valid characters are alphanumeric, -, _, and .
|
||||
// https://kubernetes.io/docs/concepts/configuration/secret/#restriction-names-data.
|
||||
func sanitizeKey[T ~string](k T) string {
|
||||
return strings.Map(func(r rune) rune {
|
||||
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' || r == '.' {
|
||||
return r
|
||||
|
||||
183
ipn/store/kubestore/store_kube_test.go
Normal file
183
ipn/store/kubestore/store_kube_test.go
Normal file
@@ -0,0 +1,183 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package kubestore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/store/mem"
|
||||
"tailscale.com/kube/kubeapi"
|
||||
"tailscale.com/kube/kubeclient"
|
||||
)
|
||||
|
||||
func TestUpdateStateSecret(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
initial map[string][]byte
|
||||
updates map[string][]byte
|
||||
wantData map[string][]byte
|
||||
allowPatch bool
|
||||
}{
|
||||
{
|
||||
name: "basic_update",
|
||||
initial: map[string][]byte{
|
||||
"existing": []byte("old"),
|
||||
},
|
||||
updates: map[string][]byte{
|
||||
"foo": []byte("bar"),
|
||||
},
|
||||
wantData: map[string][]byte{
|
||||
"existing": []byte("old"),
|
||||
"foo": []byte("bar"),
|
||||
},
|
||||
allowPatch: true,
|
||||
},
|
||||
{
|
||||
name: "update_existing",
|
||||
initial: map[string][]byte{
|
||||
"foo": []byte("old"),
|
||||
},
|
||||
updates: map[string][]byte{
|
||||
"foo": []byte("new"),
|
||||
},
|
||||
wantData: map[string][]byte{
|
||||
"foo": []byte("new"),
|
||||
},
|
||||
allowPatch: true,
|
||||
},
|
||||
{
|
||||
name: "multiple_updates",
|
||||
initial: map[string][]byte{
|
||||
"keep": []byte("keep"),
|
||||
},
|
||||
updates: map[string][]byte{
|
||||
"foo": []byte("bar"),
|
||||
"baz": []byte("qux"),
|
||||
},
|
||||
wantData: map[string][]byte{
|
||||
"keep": []byte("keep"),
|
||||
"foo": []byte("bar"),
|
||||
"baz": []byte("qux"),
|
||||
},
|
||||
allowPatch: true,
|
||||
},
|
||||
{
|
||||
name: "create_new_secret",
|
||||
updates: map[string][]byte{
|
||||
"foo": []byte("bar"),
|
||||
},
|
||||
wantData: map[string][]byte{
|
||||
"foo": []byte("bar"),
|
||||
},
|
||||
allowPatch: true,
|
||||
},
|
||||
{
|
||||
name: "patch_denied",
|
||||
initial: map[string][]byte{
|
||||
"foo": []byte("old"),
|
||||
},
|
||||
updates: map[string][]byte{
|
||||
"foo": []byte("new"),
|
||||
},
|
||||
wantData: map[string][]byte{
|
||||
"foo": []byte("new"),
|
||||
},
|
||||
allowPatch: false,
|
||||
},
|
||||
{
|
||||
name: "sanitize_keys",
|
||||
initial: map[string][]byte{
|
||||
"clean-key": []byte("old"),
|
||||
},
|
||||
updates: map[string][]byte{
|
||||
"dirty@key": []byte("new"),
|
||||
"also/bad": []byte("value"),
|
||||
"good.key": []byte("keep"),
|
||||
},
|
||||
wantData: map[string][]byte{
|
||||
"clean-key": []byte("old"),
|
||||
"dirty_key": []byte("new"),
|
||||
"also_bad": []byte("value"),
|
||||
"good.key": []byte("keep"),
|
||||
},
|
||||
allowPatch: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
secret := tt.initial // track current state
|
||||
client := &kubeclient.FakeClient{
|
||||
GetSecretImpl: func(ctx context.Context, name string) (*kubeapi.Secret, error) {
|
||||
if secret == nil {
|
||||
return nil, &kubeapi.Status{Code: 404}
|
||||
}
|
||||
return &kubeapi.Secret{Data: secret}, nil
|
||||
},
|
||||
CheckSecretPermissionsImpl: func(ctx context.Context, name string) (bool, bool, error) {
|
||||
return tt.allowPatch, true, nil
|
||||
},
|
||||
CreateSecretImpl: func(ctx context.Context, s *kubeapi.Secret) error {
|
||||
secret = s.Data
|
||||
return nil
|
||||
},
|
||||
UpdateSecretImpl: func(ctx context.Context, s *kubeapi.Secret) error {
|
||||
secret = s.Data
|
||||
return nil
|
||||
},
|
||||
JSONPatchResourceImpl: func(ctx context.Context, name, resourceType string, patches []kubeclient.JSONPatch) error {
|
||||
if !tt.allowPatch {
|
||||
return &kubeapi.Status{Reason: "Forbidden"}
|
||||
}
|
||||
if secret == nil {
|
||||
secret = make(map[string][]byte)
|
||||
}
|
||||
for _, p := range patches {
|
||||
if p.Op == "add" && p.Path == "/data" {
|
||||
secret = p.Value.(map[string][]byte)
|
||||
} else if p.Op == "add" && strings.HasPrefix(p.Path, "/data/") {
|
||||
key := strings.TrimPrefix(p.Path, "/data/")
|
||||
secret[key] = p.Value.([]byte)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
s := &Store{
|
||||
client: client,
|
||||
canPatch: tt.allowPatch,
|
||||
secretName: "test-secret",
|
||||
memory: mem.Store{},
|
||||
}
|
||||
|
||||
err := s.updateStateSecret(tt.updates)
|
||||
if err != nil {
|
||||
t.Errorf("updateStateSecret() error = %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify secret data
|
||||
if diff := cmp.Diff(secret, tt.wantData); diff != "" {
|
||||
t.Errorf("secret data mismatch (-got +want):\n%s", diff)
|
||||
}
|
||||
|
||||
// Verify memory store was updated
|
||||
for k, v := range tt.updates {
|
||||
got, err := s.memory.ReadState(ipn.StateKey(sanitizeKey(k)))
|
||||
if err != nil {
|
||||
t.Errorf("reading from memory store: %v", err)
|
||||
continue
|
||||
}
|
||||
if !cmp.Equal(got, v) {
|
||||
t.Errorf("memory store key %q = %v, want %v", k, got, v)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,9 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/store/awsstore"
|
||||
"tailscale.com/types/logger"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -14,5 +16,11 @@ func init() {
|
||||
}
|
||||
|
||||
func registerAWSStore() {
|
||||
Register("arn:", awsstore.New)
|
||||
Register("arn:", func(logf logger.Logf, arg string) (ipn.StateStore, error) {
|
||||
ssmARN, opts, err := awsstore.ParseARNAndOpts(arg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return awsstore.New(logf, ssmARN, opts...)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@ var _ Client = &FakeClient{}
|
||||
type FakeClient struct {
|
||||
GetSecretImpl func(context.Context, string) (*kubeapi.Secret, error)
|
||||
CheckSecretPermissionsImpl func(ctx context.Context, name string) (bool, bool, error)
|
||||
CreateSecretImpl func(context.Context, *kubeapi.Secret) error
|
||||
UpdateSecretImpl func(context.Context, *kubeapi.Secret) error
|
||||
JSONPatchResourceImpl func(context.Context, string, string, []JSONPatch) error
|
||||
}
|
||||
|
||||
func (fc *FakeClient) CheckSecretPermissions(ctx context.Context, name string) (bool, bool, error) {
|
||||
@@ -33,8 +36,12 @@ func (fc *FakeClient) Event(context.Context, string, string, string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (fc *FakeClient) JSONPatchResource(context.Context, string, string, []JSONPatch) error {
|
||||
return nil
|
||||
func (fc *FakeClient) JSONPatchResource(ctx context.Context, resource, name string, patches []JSONPatch) error {
|
||||
return fc.JSONPatchResourceImpl(ctx, resource, name, patches)
|
||||
}
|
||||
func (fc *FakeClient) UpdateSecret(ctx context.Context, secret *kubeapi.Secret) error {
|
||||
return fc.UpdateSecretImpl(ctx, secret)
|
||||
}
|
||||
func (fc *FakeClient) CreateSecret(ctx context.Context, secret *kubeapi.Secret) error {
|
||||
return fc.CreateSecretImpl(ctx, secret)
|
||||
}
|
||||
func (fc *FakeClient) UpdateSecret(context.Context, *kubeapi.Secret) error { return nil }
|
||||
func (fc *FakeClient) CreateSecret(context.Context, *kubeapi.Secret) error { return nil }
|
||||
|
||||
35
licenses/README.md
Normal file
35
licenses/README.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Licenses
|
||||
|
||||
This directory contains a list of dependencies, and their licenses, that are included in the Tailscale clients.
|
||||
These lists are generated using the [go-licenses] tool to analyze all Go packages in the Tailscale binaries,
|
||||
as well as a set of custom output templates that includes any additional non-Go dependencies.
|
||||
For example, the clients for macOS and iOS include some additional Swift libraries.
|
||||
|
||||
These lists are updated roughly every week, so it is possible to see the dependencies in a given release by looking at the release tag.
|
||||
For example, the dependences for the 1.80.0 release of the macOS client can be seen at
|
||||
<https://github.com/tailscale/tailscale/blob/v1.80.0/licenses/apple.md>.
|
||||
|
||||
[go-licenses]: https://github.com/google/go-licenses
|
||||
|
||||
## Other formats
|
||||
|
||||
The go-licenses tool can output other formats like CSV, but that wouldn't include the non-Go dependencies.
|
||||
We can generate a CSV file if that's really needed by running a regex over the markdown files:
|
||||
|
||||
```sh
|
||||
cat apple.md | grep "^ -" | sed -E "s/- \[(.*)\]\(.*?\) \(\[(.*)\]\((.*)\)\)/\1,\2,\3/"
|
||||
```
|
||||
|
||||
## Reviewer instructions
|
||||
|
||||
The majority of changes in this directory are from updating dependency versions.
|
||||
In that case, only the URL for the license file will change to reflect the new version.
|
||||
Occasionally, a dependency is added or removed, or the import path is changed.
|
||||
|
||||
New dependencies require the closest review to ensure the license is acceptable.
|
||||
Because we generate the license reports **after** dependencies are changed,
|
||||
the new dependency would have already gone through one review when it was initially added.
|
||||
This is just a secondary review to double-check the license. If in doubt, ask legal.
|
||||
|
||||
Always do a normal GitHub code review on the license PR with a brief summary of what changed.
|
||||
For example, see #13936 or #14064. Then approve and merge the PR.
|
||||
72
maths/ewma.go
Normal file
72
maths/ewma.go
Normal file
@@ -0,0 +1,72 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package maths contains additional mathematical functions or structures not
|
||||
// found in the standard library.
|
||||
package maths
|
||||
|
||||
import (
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
// EWMA is an exponentially weighted moving average supporting updates at
|
||||
// irregular intervals with at most nanosecond resolution.
|
||||
// The zero value will compute a half-life of 1 second.
|
||||
// It is not safe for concurrent use.
|
||||
// TODO(raggi): de-duplicate with tstime/rate.Value, which has a more complex
|
||||
// and synchronized interface and does not provide direct access to the stable
|
||||
// value.
|
||||
type EWMA struct {
|
||||
value float64 // current value of the average
|
||||
lastTime int64 // time of last update in unix nanos
|
||||
halfLife float64 // half-life in seconds
|
||||
}
|
||||
|
||||
// NewEWMA creates a new EWMA with the specified half-life. If halfLifeSeconds
|
||||
// is 0, it defaults to 1.
|
||||
func NewEWMA(halfLifeSeconds float64) *EWMA {
|
||||
return &EWMA{
|
||||
halfLife: halfLifeSeconds,
|
||||
}
|
||||
}
|
||||
|
||||
// Update adds a new sample to the average. If t is zero or precedes the last
|
||||
// update, the update is ignored.
|
||||
func (e *EWMA) Update(value float64, t time.Time) {
|
||||
if t.IsZero() {
|
||||
return
|
||||
}
|
||||
hl := e.halfLife
|
||||
if hl == 0 {
|
||||
hl = 1
|
||||
}
|
||||
tn := t.UnixNano()
|
||||
if e.lastTime == 0 {
|
||||
e.value = value
|
||||
e.lastTime = tn
|
||||
return
|
||||
}
|
||||
|
||||
dt := (time.Duration(tn-e.lastTime) * time.Nanosecond).Seconds()
|
||||
if dt < 0 {
|
||||
// drop out of order updates
|
||||
return
|
||||
}
|
||||
|
||||
// decay = 2^(-dt/halfLife)
|
||||
decay := math.Exp2(-dt / hl)
|
||||
e.value = e.value*decay + value*(1-decay)
|
||||
e.lastTime = tn
|
||||
}
|
||||
|
||||
// Get returns the current value of the average
|
||||
func (e *EWMA) Get() float64 {
|
||||
return e.value
|
||||
}
|
||||
|
||||
// Reset clears the EWMA to its initial state
|
||||
func (e *EWMA) Reset() {
|
||||
e.value = 0
|
||||
e.lastTime = 0
|
||||
}
|
||||
178
maths/ewma_test.go
Normal file
178
maths/ewma_test.go
Normal file
@@ -0,0 +1,178 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package maths
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// some real world latency samples.
|
||||
var (
|
||||
latencyHistory1 = []int{
|
||||
14, 12, 15, 6, 19, 12, 13, 13, 13, 16, 17, 11, 17, 11, 14, 15, 14, 15,
|
||||
16, 16, 17, 14, 12, 16, 18, 14, 14, 11, 15, 15, 25, 11, 15, 14, 12, 15,
|
||||
13, 12, 13, 15, 11, 13, 15, 14, 14, 15, 12, 15, 18, 12, 15, 22, 12, 13,
|
||||
10, 14, 16, 15, 16, 11, 14, 17, 18, 20, 16, 11, 16, 14, 5, 15, 17, 12,
|
||||
15, 11, 15, 20, 12, 17, 12, 17, 15, 12, 12, 11, 14, 15, 11, 20, 14, 13,
|
||||
11, 12, 13, 13, 11, 13, 11, 15, 13, 13, 14, 12, 11, 12, 12, 14, 11, 13,
|
||||
12, 12, 12, 19, 14, 13, 13, 14, 11, 12, 10, 11, 15, 12, 14, 11, 11, 14,
|
||||
14, 12, 12, 11, 14, 12, 11, 12, 14, 11, 12, 15, 12, 14, 12, 12, 21, 16,
|
||||
21, 12, 16, 9, 11, 16, 14, 13, 14, 12, 13, 16,
|
||||
}
|
||||
latencyHistory2 = []int{
|
||||
18, 20, 21, 21, 20, 23, 18, 18, 20, 21, 20, 19, 22, 18, 20, 20, 19, 21,
|
||||
21, 22, 22, 19, 18, 22, 22, 19, 20, 17, 16, 11, 25, 16, 18, 21, 17, 22,
|
||||
19, 18, 22, 21, 20, 18, 22, 17, 17, 20, 19, 10, 19, 16, 19, 25, 17, 18,
|
||||
15, 20, 21, 20, 23, 22, 22, 22, 19, 22, 22, 17, 22, 20, 20, 19, 21, 22,
|
||||
20, 19, 17, 22, 16, 16, 20, 22, 17, 19, 21, 16, 20, 22, 19, 21, 20, 19,
|
||||
13, 14, 23, 19, 16, 10, 19, 15, 15, 17, 16, 18, 14, 16, 18, 22, 20, 18,
|
||||
18, 21, 15, 19, 18, 19, 18, 20, 17, 19, 21, 19, 20, 19, 20, 20, 17, 14,
|
||||
17, 17, 18, 21, 20, 18, 18, 17, 16, 17, 17, 20, 22, 19, 20, 21, 21, 20,
|
||||
21, 24, 20, 18, 12, 17, 18, 17, 19, 19, 19,
|
||||
}
|
||||
)
|
||||
|
||||
func TestEWMALatencyHistory(t *testing.T) {
|
||||
type result struct {
|
||||
t time.Time
|
||||
v float64
|
||||
s int
|
||||
}
|
||||
|
||||
for _, latencyHistory := range [][]int{latencyHistory1, latencyHistory2} {
|
||||
startTime := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
halfLife := 30.0
|
||||
|
||||
ewma := NewEWMA(halfLife)
|
||||
|
||||
var results []result
|
||||
sum := 0.0
|
||||
for i, latency := range latencyHistory {
|
||||
t := startTime.Add(time.Duration(i) * time.Second)
|
||||
ewma.Update(float64(latency), t)
|
||||
sum += float64(latency)
|
||||
|
||||
results = append(results, result{t, ewma.Get(), latency})
|
||||
}
|
||||
mean := sum / float64(len(latencyHistory))
|
||||
min := float64(slices.Min(latencyHistory))
|
||||
max := float64(slices.Max(latencyHistory))
|
||||
|
||||
t.Logf("EWMA Latency History (half-life: %.1f seconds):", halfLife)
|
||||
t.Logf("Mean latency: %.2f ms", mean)
|
||||
t.Logf("Range: [%.1f, %.1f]", min, max)
|
||||
|
||||
t.Log("Samples: ")
|
||||
sparkline := []rune("▁▂▃▄▅▆▇█")
|
||||
var sampleLine []rune
|
||||
for _, r := range results {
|
||||
idx := int(((float64(r.s) - min) / (max - min)) * float64(len(sparkline)-1))
|
||||
if idx >= len(sparkline) {
|
||||
idx = len(sparkline) - 1
|
||||
}
|
||||
sampleLine = append(sampleLine, sparkline[idx])
|
||||
}
|
||||
t.Log(string(sampleLine))
|
||||
|
||||
t.Log("EWMA: ")
|
||||
var ewmaLine []rune
|
||||
for _, r := range results {
|
||||
idx := int(((r.v - min) / (max - min)) * float64(len(sparkline)-1))
|
||||
if idx >= len(sparkline) {
|
||||
idx = len(sparkline) - 1
|
||||
}
|
||||
ewmaLine = append(ewmaLine, sparkline[idx])
|
||||
}
|
||||
t.Log(string(ewmaLine))
|
||||
t.Log("")
|
||||
|
||||
t.Logf("Time | Sample | Value | Value - Sample")
|
||||
t.Logf("")
|
||||
|
||||
for _, result := range results {
|
||||
t.Logf("%10s | % 6d | % 5.2f | % 5.2f", result.t.Format("15:04:05"), result.s, result.v, result.v-float64(result.s))
|
||||
}
|
||||
|
||||
// check that all results are greater than the min, and less than the max of the input,
|
||||
// and they're all close to the mean.
|
||||
for _, result := range results {
|
||||
if result.v < float64(min) || result.v > float64(max) {
|
||||
t.Errorf("result %f out of range [%f, %f]", result.v, min, max)
|
||||
}
|
||||
|
||||
if result.v < mean*0.9 || result.v > mean*1.1 {
|
||||
t.Errorf("result %f not close to mean %f", result.v, mean)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHalfLife(t *testing.T) {
|
||||
start := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
ewma := NewEWMA(30.0)
|
||||
ewma.Update(10, start)
|
||||
ewma.Update(0, start.Add(30*time.Second))
|
||||
|
||||
if ewma.Get() != 5 {
|
||||
t.Errorf("expected 5, got %f", ewma.Get())
|
||||
}
|
||||
|
||||
ewma.Update(10, start.Add(60*time.Second))
|
||||
if ewma.Get() != 7.5 {
|
||||
t.Errorf("expected 7.5, got %f", ewma.Get())
|
||||
}
|
||||
|
||||
ewma.Update(10, start.Add(90*time.Second))
|
||||
if ewma.Get() != 8.75 {
|
||||
t.Errorf("expected 8.75, got %f", ewma.Get())
|
||||
}
|
||||
}
|
||||
|
||||
func TestZeroValue(t *testing.T) {
|
||||
start := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
var ewma EWMA
|
||||
ewma.Update(10, start)
|
||||
ewma.Update(0, start.Add(time.Second))
|
||||
|
||||
if ewma.Get() != 5 {
|
||||
t.Errorf("expected 5, got %f", ewma.Get())
|
||||
}
|
||||
|
||||
ewma.Update(10, start.Add(2*time.Second))
|
||||
if ewma.Get() != 7.5 {
|
||||
t.Errorf("expected 7.5, got %f", ewma.Get())
|
||||
}
|
||||
|
||||
ewma.Update(10, start.Add(3*time.Second))
|
||||
if ewma.Get() != 8.75 {
|
||||
t.Errorf("expected 8.75, got %f", ewma.Get())
|
||||
}
|
||||
}
|
||||
|
||||
func TestReset(t *testing.T) {
|
||||
start := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
ewma := NewEWMA(30.0)
|
||||
ewma.Update(10, start)
|
||||
ewma.Update(0, start.Add(30*time.Second))
|
||||
|
||||
if ewma.Get() != 5 {
|
||||
t.Errorf("expected 5, got %f", ewma.Get())
|
||||
}
|
||||
|
||||
ewma.Reset()
|
||||
|
||||
if ewma.Get() != 0 {
|
||||
t.Errorf("expected 0, got %f", ewma.Get())
|
||||
}
|
||||
|
||||
ewma.Update(10, start.Add(90*time.Second))
|
||||
if ewma.Get() != 10 {
|
||||
t.Errorf("expected 10, got %f", ewma.Get())
|
||||
}
|
||||
}
|
||||
@@ -4,17 +4,22 @@
|
||||
package ktimeout
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/nettest"
|
||||
"golang.org/x/sys/unix"
|
||||
"tailscale.com/util/must"
|
||||
)
|
||||
|
||||
func TestSetUserTimeout(t *testing.T) {
|
||||
l := must.Get(nettest.NewLocalListener("tcp"))
|
||||
lc := net.ListenConfig{}
|
||||
// As of 2025-02-19, MPTCP does not support TCP_USER_TIMEOUT socket option
|
||||
// set in ktimeout.UserTimeout above.
|
||||
lc.SetMultipathTCP(false)
|
||||
|
||||
l := must.Get(lc.Listen(context.Background(), "tcp", "localhost:0"))
|
||||
defer l.Close()
|
||||
|
||||
var err error
|
||||
|
||||
@@ -37,14 +37,16 @@ type safesocketDarwin struct {
|
||||
sameuserproofFD *os.File // file descriptor for macos app store sameuserproof file
|
||||
sharedDir string // shared directory for location of sameuserproof file
|
||||
|
||||
checkConn bool // Check macsys safesocket port before returning it
|
||||
isMacSysExt func() bool // For testing only to force macsys
|
||||
checkConn bool // Check macsys safesocket port before returning it
|
||||
isMacSysExt func() bool // For testing only to force macsys
|
||||
isSandboxedMacos func() bool // For testing only to force macOS sandbox
|
||||
}
|
||||
|
||||
var ssd = safesocketDarwin{
|
||||
isMacSysExt: version.IsMacSysExt,
|
||||
checkConn: true,
|
||||
sharedDir: "/Library/Tailscale",
|
||||
isMacSysExt: version.IsMacSysExt,
|
||||
isSandboxedMacos: version.IsSandboxedMacOS,
|
||||
checkConn: true,
|
||||
sharedDir: "/Library/Tailscale",
|
||||
}
|
||||
|
||||
// There are three ways a Darwin binary can be run: as the Mac App Store (macOS)
|
||||
@@ -66,6 +68,10 @@ func localTCPPortAndTokenDarwin() (port int, token string, err error) {
|
||||
ssd.mu.Lock()
|
||||
defer ssd.mu.Unlock()
|
||||
|
||||
if !ssd.isSandboxedMacos() {
|
||||
return 0, "", ErrNoTokenOnOS
|
||||
}
|
||||
|
||||
if ssd.port != 0 && ssd.token != "" {
|
||||
return ssd.port, ssd.token, nil
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
func TestSetCredentials(t *testing.T) {
|
||||
wantPort := 123
|
||||
wantToken := "token"
|
||||
tstest.Replace(t, &ssd.isSandboxedMacos, func() bool { return true })
|
||||
SetCredentials(wantToken, wantPort)
|
||||
|
||||
gotPort, gotToken, err := LocalTCPPortAndToken()
|
||||
@@ -37,6 +38,8 @@ func TestSetCredentials(t *testing.T) {
|
||||
// returns a listener and a non-zero port and non-empty token.
|
||||
func TestInitListenerDarwin(t *testing.T) {
|
||||
temp := t.TempDir()
|
||||
tstest.Replace(t, &ssd.isSandboxedMacos, func() bool { return true })
|
||||
|
||||
ln, err := InitListenerDarwin(temp)
|
||||
if err != nil || ln == nil {
|
||||
t.Fatalf("InitListenerDarwin failed: %v", err)
|
||||
|
||||
@@ -186,6 +186,12 @@ main() {
|
||||
VERSION="$DEBIAN_CODENAME"
|
||||
fi
|
||||
;;
|
||||
sparky)
|
||||
OS="debian"
|
||||
PACKAGETYPE="apt"
|
||||
VERSION="$DEBIAN_CODENAME"
|
||||
APT_KEY_TYPE="keyring"
|
||||
;;
|
||||
centos)
|
||||
OS="$ID"
|
||||
VERSION="$VERSION_ID"
|
||||
@@ -487,10 +493,13 @@ main() {
|
||||
legacy)
|
||||
$CURL "https://pkgs.tailscale.com/$TRACK/$OS/$VERSION.asc" | $SUDO apt-key add -
|
||||
$CURL "https://pkgs.tailscale.com/$TRACK/$OS/$VERSION.list" | $SUDO tee /etc/apt/sources.list.d/tailscale.list
|
||||
$SUDO chmod 0644 /etc/apt/sources.list.d/tailscale.list
|
||||
;;
|
||||
keyring)
|
||||
$CURL "https://pkgs.tailscale.com/$TRACK/$OS/$VERSION.noarmor.gpg" | $SUDO tee /usr/share/keyrings/tailscale-archive-keyring.gpg >/dev/null
|
||||
$SUDO chmod 0644 /usr/share/keyrings/tailscale-archive-keyring.gpg
|
||||
$CURL "https://pkgs.tailscale.com/$TRACK/$OS/$VERSION.tailscale-keyring.list" | $SUDO tee /etc/apt/sources.list.d/tailscale.list
|
||||
$SUDO chmod 0644 /etc/apt/sources.list.d/tailscale.list
|
||||
;;
|
||||
esac
|
||||
$SUDO apt-get update
|
||||
|
||||
@@ -557,7 +557,11 @@ func (c *Client) Accept(ctx context.Context, chal *Challenge) (*Challenge, error
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res, err := c.post(ctx, nil, chal.URI, json.RawMessage("{}"), wantStatus(
|
||||
payload := json.RawMessage("{}")
|
||||
if len(chal.Payload) != 0 {
|
||||
payload = chal.Payload
|
||||
}
|
||||
res, err := c.post(ctx, nil, chal.URI, payload, wantStatus(
|
||||
http.StatusOK, // according to the spec
|
||||
http.StatusAccepted, // Let's Encrypt: see https://goo.gl/WsJ7VT (acme-divergences.md)
|
||||
))
|
||||
|
||||
@@ -875,7 +875,7 @@ func TestTLSALPN01ChallengeCert(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTLSChallengeCertOpt(t *testing.T) {
|
||||
key, err := rsa.GenerateKey(rand.Reader, 512)
|
||||
key, err := rsa.GenerateKey(rand.Reader, 1024)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"io"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -271,9 +272,27 @@ func (c *Client) httpClient() *http.Client {
|
||||
}
|
||||
|
||||
// packageVersion is the version of the module that contains this package, for
|
||||
// sending as part of the User-Agent header. It's set in version_go112.go.
|
||||
// sending as part of the User-Agent header.
|
||||
var packageVersion string
|
||||
|
||||
func init() {
|
||||
// Set packageVersion if the binary was built in modules mode and x/crypto
|
||||
// was not replaced with a different module.
|
||||
info, ok := debug.ReadBuildInfo()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
for _, m := range info.Deps {
|
||||
if m.Path != "golang.org/x/crypto" {
|
||||
continue
|
||||
}
|
||||
if m.Replace == nil {
|
||||
packageVersion = m.Version
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// userAgent returns the User-Agent header value. It includes the package name,
|
||||
// the module version (if available), and the c.UserAgent value (if set).
|
||||
func (c *Client) userAgent() string {
|
||||
|
||||
@@ -7,6 +7,7 @@ package acme
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -292,7 +293,7 @@ type Directory struct {
|
||||
// Renewal Information (ARI) Extension.
|
||||
RenewalInfoURL string
|
||||
|
||||
// Term is a URI identifying the current terms of service.
|
||||
// Terms is a URI identifying the current terms of service.
|
||||
Terms string
|
||||
|
||||
// Website is an HTTP or HTTPS URL locating a website
|
||||
@@ -531,6 +532,16 @@ type Challenge struct {
|
||||
// when this challenge was used.
|
||||
// The type of a non-nil value is *Error.
|
||||
Error error
|
||||
|
||||
// Payload is the JSON-formatted payload that the client sends
|
||||
// to the server to indicate it is ready to respond to the challenge.
|
||||
// When unset, it defaults to an empty JSON object: {}.
|
||||
// For most challenges, the client must not set Payload,
|
||||
// see https://tools.ietf.org/html/rfc8555#section-7.5.1.
|
||||
// Payload is used only for newer challenges (such as "device-attest-01")
|
||||
// where the client must send additional data for the server to validate
|
||||
// the challenge.
|
||||
Payload json.RawMessage
|
||||
}
|
||||
|
||||
// wireChallenge is ACME JSON challenge representation.
|
||||
|
||||
@@ -27,6 +27,7 @@ type DepChecker struct {
|
||||
BadDeps map[string]string // package => why
|
||||
WantDeps set.Set[string] // packages expected
|
||||
Tags string // comma-separated
|
||||
ExtraEnv []string // extra environment for "go list" (e.g. CGO_ENABLED=1)
|
||||
}
|
||||
|
||||
func (c DepChecker) Check(t *testing.T) {
|
||||
@@ -43,6 +44,7 @@ func (c DepChecker) Check(t *testing.T) {
|
||||
if c.GOARCH != "" {
|
||||
extraEnv = append(extraEnv, "GOARCH="+c.GOARCH)
|
||||
}
|
||||
extraEnv = append(extraEnv, c.ExtraEnv...)
|
||||
cmd.Env = append(os.Environ(), extraEnv...)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
|
||||
@@ -100,31 +100,31 @@ func (o Value[T]) Equal(v Value[T]) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// MarshalJSONV2 implements [jsonv2.MarshalerV2].
|
||||
func (o Value[T]) MarshalJSONV2(enc *jsontext.Encoder, opts jsonv2.Options) error {
|
||||
// MarshalJSONTo implements [jsonv2.MarshalerTo].
|
||||
func (o Value[T]) MarshalJSONTo(enc *jsontext.Encoder) error {
|
||||
if !o.set {
|
||||
return enc.WriteToken(jsontext.Null)
|
||||
}
|
||||
return jsonv2.MarshalEncode(enc, &o.value, opts)
|
||||
return jsonv2.MarshalEncode(enc, &o.value)
|
||||
}
|
||||
|
||||
// UnmarshalJSONV2 implements [jsonv2.UnmarshalerV2].
|
||||
func (o *Value[T]) UnmarshalJSONV2(dec *jsontext.Decoder, opts jsonv2.Options) error {
|
||||
// UnmarshalJSONFrom implements [jsonv2.UnmarshalerFrom].
|
||||
func (o *Value[T]) UnmarshalJSONFrom(dec *jsontext.Decoder) error {
|
||||
if dec.PeekKind() == 'n' {
|
||||
*o = Value[T]{}
|
||||
_, err := dec.ReadToken() // read null
|
||||
return err
|
||||
}
|
||||
o.set = true
|
||||
return jsonv2.UnmarshalDecode(dec, &o.value, opts)
|
||||
return jsonv2.UnmarshalDecode(dec, &o.value)
|
||||
}
|
||||
|
||||
// MarshalJSON implements [json.Marshaler].
|
||||
func (o Value[T]) MarshalJSON() ([]byte, error) {
|
||||
return jsonv2.Marshal(o) // uses MarshalJSONV2
|
||||
return jsonv2.Marshal(o) // uses MarshalJSONTo
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements [json.Unmarshaler].
|
||||
func (o *Value[T]) UnmarshalJSON(b []byte) error {
|
||||
return jsonv2.Unmarshal(b, o) // uses UnmarshalJSONV2
|
||||
return jsonv2.Unmarshal(b, o) // uses UnmarshalJSONFrom
|
||||
}
|
||||
|
||||
@@ -13,6 +13,11 @@ import (
|
||||
"tailscale.com/util/must"
|
||||
)
|
||||
|
||||
var (
|
||||
_ jsonv2.MarshalerTo = (*Value[bool])(nil)
|
||||
_ jsonv2.UnmarshalerFrom = (*Value[bool])(nil)
|
||||
)
|
||||
|
||||
type testStruct struct {
|
||||
Int int `json:",omitempty,omitzero"`
|
||||
Str string `json:",omitempty"`
|
||||
|
||||
@@ -152,15 +152,15 @@ func (iv ItemView[T, V]) Equal(iv2 ItemView[T, V]) bool {
|
||||
return iv.ж.Equal(*iv2.ж)
|
||||
}
|
||||
|
||||
// MarshalJSONV2 implements [jsonv2.MarshalerV2].
|
||||
func (iv ItemView[T, V]) MarshalJSONV2(out *jsontext.Encoder, opts jsonv2.Options) error {
|
||||
return iv.ж.MarshalJSONV2(out, opts)
|
||||
// MarshalJSONTo implements [jsonv2.MarshalerTo].
|
||||
func (iv ItemView[T, V]) MarshalJSONTo(out *jsontext.Encoder) error {
|
||||
return iv.ж.MarshalJSONTo(out)
|
||||
}
|
||||
|
||||
// UnmarshalJSONV2 implements [jsonv2.UnmarshalerV2].
|
||||
func (iv *ItemView[T, V]) UnmarshalJSONV2(in *jsontext.Decoder, opts jsonv2.Options) error {
|
||||
// UnmarshalJSONFrom implements [jsonv2.UnmarshalerFrom].
|
||||
func (iv *ItemView[T, V]) UnmarshalJSONFrom(in *jsontext.Decoder) error {
|
||||
var x Item[T]
|
||||
if err := x.UnmarshalJSONV2(in, opts); err != nil {
|
||||
if err := x.UnmarshalJSONFrom(in); err != nil {
|
||||
return err
|
||||
}
|
||||
iv.ж = &x
|
||||
@@ -169,10 +169,10 @@ func (iv *ItemView[T, V]) UnmarshalJSONV2(in *jsontext.Decoder, opts jsonv2.Opti
|
||||
|
||||
// MarshalJSON implements [json.Marshaler].
|
||||
func (iv ItemView[T, V]) MarshalJSON() ([]byte, error) {
|
||||
return jsonv2.Marshal(iv) // uses MarshalJSONV2
|
||||
return jsonv2.Marshal(iv) // uses MarshalJSONTo
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements [json.Unmarshaler].
|
||||
func (iv *ItemView[T, V]) UnmarshalJSON(b []byte) error {
|
||||
return jsonv2.Unmarshal(b, iv) // uses UnmarshalJSONV2
|
||||
return jsonv2.Unmarshal(b, iv) // uses UnmarshalJSONFrom
|
||||
}
|
||||
|
||||
@@ -157,15 +157,20 @@ func (lv ListView[T]) Equal(lv2 ListView[T]) bool {
|
||||
return lv.ж.Equal(*lv2.ж)
|
||||
}
|
||||
|
||||
// MarshalJSONV2 implements [jsonv2.MarshalerV2].
|
||||
func (lv ListView[T]) MarshalJSONV2(out *jsontext.Encoder, opts jsonv2.Options) error {
|
||||
return lv.ж.MarshalJSONV2(out, opts)
|
||||
var (
|
||||
_ jsonv2.MarshalerTo = (*ListView[bool])(nil)
|
||||
_ jsonv2.UnmarshalerFrom = (*ListView[bool])(nil)
|
||||
)
|
||||
|
||||
// MarshalJSONTo implements [jsonv2.MarshalerTo].
|
||||
func (lv ListView[T]) MarshalJSONTo(out *jsontext.Encoder) error {
|
||||
return lv.ж.MarshalJSONTo(out)
|
||||
}
|
||||
|
||||
// UnmarshalJSONV2 implements [jsonv2.UnmarshalerV2].
|
||||
func (lv *ListView[T]) UnmarshalJSONV2(in *jsontext.Decoder, opts jsonv2.Options) error {
|
||||
// UnmarshalJSONFrom implements [jsonv2.UnmarshalerFrom].
|
||||
func (lv *ListView[T]) UnmarshalJSONFrom(in *jsontext.Decoder) error {
|
||||
var x List[T]
|
||||
if err := x.UnmarshalJSONV2(in, opts); err != nil {
|
||||
if err := x.UnmarshalJSONFrom(in); err != nil {
|
||||
return err
|
||||
}
|
||||
lv.ж = &x
|
||||
@@ -174,10 +179,10 @@ func (lv *ListView[T]) UnmarshalJSONV2(in *jsontext.Decoder, opts jsonv2.Options
|
||||
|
||||
// MarshalJSON implements [json.Marshaler].
|
||||
func (lv ListView[T]) MarshalJSON() ([]byte, error) {
|
||||
return jsonv2.Marshal(lv) // uses MarshalJSONV2
|
||||
return jsonv2.Marshal(lv) // uses MarshalJSONTo
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements [json.Unmarshaler].
|
||||
func (lv *ListView[T]) UnmarshalJSON(b []byte) error {
|
||||
return jsonv2.Unmarshal(b, lv) // uses UnmarshalJSONV2
|
||||
return jsonv2.Unmarshal(b, lv) // uses UnmarshalJSONFrom
|
||||
}
|
||||
|
||||
@@ -133,15 +133,15 @@ func (mv MapView[K, V]) Equal(mv2 MapView[K, V]) bool {
|
||||
return mv.ж.Equal(*mv2.ж)
|
||||
}
|
||||
|
||||
// MarshalJSONV2 implements [jsonv2.MarshalerV2].
|
||||
func (mv MapView[K, V]) MarshalJSONV2(out *jsontext.Encoder, opts jsonv2.Options) error {
|
||||
return mv.ж.MarshalJSONV2(out, opts)
|
||||
// MarshalJSONTo implements [jsonv2.MarshalerTo].
|
||||
func (mv MapView[K, V]) MarshalJSONTo(out *jsontext.Encoder) error {
|
||||
return mv.ж.MarshalJSONTo(out)
|
||||
}
|
||||
|
||||
// UnmarshalJSONV2 implements [jsonv2.UnmarshalerV2].
|
||||
func (mv *MapView[K, V]) UnmarshalJSONV2(in *jsontext.Decoder, opts jsonv2.Options) error {
|
||||
// UnmarshalJSONFrom implements [jsonv2.UnmarshalerFrom].
|
||||
func (mv *MapView[K, V]) UnmarshalJSONFrom(in *jsontext.Decoder) error {
|
||||
var x Map[K, V]
|
||||
if err := x.UnmarshalJSONV2(in, opts); err != nil {
|
||||
if err := x.UnmarshalJSONFrom(in); err != nil {
|
||||
return err
|
||||
}
|
||||
mv.ж = &x
|
||||
@@ -150,10 +150,10 @@ func (mv *MapView[K, V]) UnmarshalJSONV2(in *jsontext.Decoder, opts jsonv2.Optio
|
||||
|
||||
// MarshalJSON implements [json.Marshaler].
|
||||
func (mv MapView[K, V]) MarshalJSON() ([]byte, error) {
|
||||
return jsonv2.Marshal(mv) // uses MarshalJSONV2
|
||||
return jsonv2.Marshal(mv) // uses MarshalJSONTo
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements [json.Unmarshaler].
|
||||
func (mv *MapView[K, V]) UnmarshalJSON(b []byte) error {
|
||||
return jsonv2.Unmarshal(b, mv) // uses UnmarshalJSONV2
|
||||
return jsonv2.Unmarshal(b, mv) // uses UnmarshalJSONFrom
|
||||
}
|
||||
|
||||
@@ -158,22 +158,27 @@ func (p *preference[T]) SetReadOnly(readonly bool) {
|
||||
p.s.Metadata.ReadOnly = readonly
|
||||
}
|
||||
|
||||
// MarshalJSONV2 implements [jsonv2.MarshalerV2].
|
||||
func (p preference[T]) MarshalJSONV2(out *jsontext.Encoder, opts jsonv2.Options) error {
|
||||
return jsonv2.MarshalEncode(out, &p.s, opts)
|
||||
var (
|
||||
_ jsonv2.MarshalerTo = (*preference[struct{}])(nil)
|
||||
_ jsonv2.UnmarshalerFrom = (*preference[struct{}])(nil)
|
||||
)
|
||||
|
||||
// MarshalJSONTo implements [jsonv2.MarshalerTo].
|
||||
func (p preference[T]) MarshalJSONTo(out *jsontext.Encoder) error {
|
||||
return jsonv2.MarshalEncode(out, &p.s)
|
||||
}
|
||||
|
||||
// UnmarshalJSONV2 implements [jsonv2.UnmarshalerV2].
|
||||
func (p *preference[T]) UnmarshalJSONV2(in *jsontext.Decoder, opts jsonv2.Options) error {
|
||||
return jsonv2.UnmarshalDecode(in, &p.s, opts)
|
||||
// UnmarshalJSONFrom implements [jsonv2.UnmarshalerFrom].
|
||||
func (p *preference[T]) UnmarshalJSONFrom(in *jsontext.Decoder) error {
|
||||
return jsonv2.UnmarshalDecode(in, &p.s)
|
||||
}
|
||||
|
||||
// MarshalJSON implements [json.Marshaler].
|
||||
func (p preference[T]) MarshalJSON() ([]byte, error) {
|
||||
return jsonv2.Marshal(p) // uses MarshalJSONV2
|
||||
return jsonv2.Marshal(p) // uses MarshalJSONTo
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements [json.Unmarshaler].
|
||||
func (p *preference[T]) UnmarshalJSON(b []byte) error {
|
||||
return jsonv2.Unmarshal(b, p) // uses UnmarshalJSONV2
|
||||
return jsonv2.Unmarshal(b, p) // uses UnmarshalJSONFrom
|
||||
}
|
||||
|
||||
@@ -48,10 +48,10 @@ import (
|
||||
// the `omitzero` JSON tag option. This option is not supported by the
|
||||
// [encoding/json] package as of 2024-08-21; see golang/go#45669.
|
||||
// It is recommended that a prefs type implements both
|
||||
// [jsonv2.MarshalerV2]/[jsonv2.UnmarshalerV2] and [json.Marshaler]/[json.Unmarshaler]
|
||||
// [jsonv2.MarshalerTo]/[jsonv2.UnmarshalerFrom] and [json.Marshaler]/[json.Unmarshaler]
|
||||
// to ensure consistent and more performant marshaling, regardless of the JSON package
|
||||
// used at the call sites; the standard marshalers can be implemented via [jsonv2].
|
||||
// See [Prefs.MarshalJSONV2], [Prefs.UnmarshalJSONV2], [Prefs.MarshalJSON],
|
||||
// See [Prefs.MarshalJSONTo], [Prefs.UnmarshalJSONFrom], [Prefs.MarshalJSON],
|
||||
// and [Prefs.UnmarshalJSON] for an example implementation.
|
||||
type Prefs struct {
|
||||
ControlURL prefs.Item[string] `json:",omitzero"`
|
||||
@@ -128,34 +128,39 @@ type AppConnectorPrefs struct {
|
||||
Advertise prefs.Item[bool] `json:",omitzero"`
|
||||
}
|
||||
|
||||
// MarshalJSONV2 implements [jsonv2.MarshalerV2].
|
||||
var (
|
||||
_ jsonv2.MarshalerTo = (*Prefs)(nil)
|
||||
_ jsonv2.UnmarshalerFrom = (*Prefs)(nil)
|
||||
)
|
||||
|
||||
// MarshalJSONTo implements [jsonv2.MarshalerTo].
|
||||
// It is implemented as a performance improvement and to enable omission of
|
||||
// unconfigured preferences from the JSON output. See the [Prefs] doc for details.
|
||||
func (p Prefs) MarshalJSONV2(out *jsontext.Encoder, opts jsonv2.Options) error {
|
||||
func (p Prefs) MarshalJSONTo(out *jsontext.Encoder) error {
|
||||
// The prefs type shadows the Prefs's method set,
|
||||
// causing [jsonv2] to use the default marshaler and avoiding
|
||||
// infinite recursion.
|
||||
type prefs Prefs
|
||||
return jsonv2.MarshalEncode(out, (*prefs)(&p), opts)
|
||||
return jsonv2.MarshalEncode(out, (*prefs)(&p))
|
||||
}
|
||||
|
||||
// UnmarshalJSONV2 implements [jsonv2.UnmarshalerV2].
|
||||
func (p *Prefs) UnmarshalJSONV2(in *jsontext.Decoder, opts jsonv2.Options) error {
|
||||
// UnmarshalJSONFrom implements [jsonv2.UnmarshalerFrom].
|
||||
func (p *Prefs) UnmarshalJSONFrom(in *jsontext.Decoder) error {
|
||||
// The prefs type shadows the Prefs's method set,
|
||||
// causing [jsonv2] to use the default unmarshaler and avoiding
|
||||
// infinite recursion.
|
||||
type prefs Prefs
|
||||
return jsonv2.UnmarshalDecode(in, (*prefs)(p), opts)
|
||||
return jsonv2.UnmarshalDecode(in, (*prefs)(p))
|
||||
}
|
||||
|
||||
// MarshalJSON implements [json.Marshaler].
|
||||
func (p Prefs) MarshalJSON() ([]byte, error) {
|
||||
return jsonv2.Marshal(p) // uses MarshalJSONV2
|
||||
return jsonv2.Marshal(p) // uses MarshalJSONTo
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements [json.Unmarshaler].
|
||||
func (p *Prefs) UnmarshalJSON(b []byte) error {
|
||||
return jsonv2.Unmarshal(b, p) // uses UnmarshalJSONV2
|
||||
return jsonv2.Unmarshal(b, p) // uses UnmarshalJSONFrom
|
||||
}
|
||||
|
||||
type marshalAsTrueInJSON struct{}
|
||||
|
||||
@@ -19,6 +19,20 @@ import (
|
||||
|
||||
//go:generate go run tailscale.com/cmd/viewer --tags=test --type=TestPrefs,TestBundle,TestValueStruct,TestGenericStruct,TestPrefsGroup
|
||||
|
||||
var (
|
||||
_ jsonv2.MarshalerTo = (*ItemView[*TestBundle, TestBundleView])(nil)
|
||||
_ jsonv2.UnmarshalerFrom = (*ItemView[*TestBundle, TestBundleView])(nil)
|
||||
|
||||
_ jsonv2.MarshalerTo = (*MapView[string, string])(nil)
|
||||
_ jsonv2.UnmarshalerFrom = (*MapView[string, string])(nil)
|
||||
|
||||
_ jsonv2.MarshalerTo = (*StructListView[*TestBundle, TestBundleView])(nil)
|
||||
_ jsonv2.UnmarshalerFrom = (*StructListView[*TestBundle, TestBundleView])(nil)
|
||||
|
||||
_ jsonv2.MarshalerTo = (*StructMapView[string, *TestBundle, TestBundleView])(nil)
|
||||
_ jsonv2.UnmarshalerFrom = (*StructMapView[string, *TestBundle, TestBundleView])(nil)
|
||||
)
|
||||
|
||||
type TestPrefs struct {
|
||||
Int32Item Item[int32] `json:",omitzero"`
|
||||
UInt64Item Item[uint64] `json:",omitzero"`
|
||||
@@ -53,32 +67,37 @@ type TestPrefs struct {
|
||||
Group TestPrefsGroup `json:",omitzero"`
|
||||
}
|
||||
|
||||
// MarshalJSONV2 implements [jsonv2.MarshalerV2].
|
||||
func (p TestPrefs) MarshalJSONV2(out *jsontext.Encoder, opts jsonv2.Options) error {
|
||||
var (
|
||||
_ jsonv2.MarshalerTo = (*TestPrefs)(nil)
|
||||
_ jsonv2.UnmarshalerFrom = (*TestPrefs)(nil)
|
||||
)
|
||||
|
||||
// MarshalJSONTo implements [jsonv2.MarshalerTo].
|
||||
func (p TestPrefs) MarshalJSONTo(out *jsontext.Encoder) error {
|
||||
// The testPrefs type shadows the TestPrefs's method set,
|
||||
// causing jsonv2 to use the default marshaler and avoiding
|
||||
// infinite recursion.
|
||||
type testPrefs TestPrefs
|
||||
return jsonv2.MarshalEncode(out, (*testPrefs)(&p), opts)
|
||||
return jsonv2.MarshalEncode(out, (*testPrefs)(&p))
|
||||
}
|
||||
|
||||
// UnmarshalJSONV2 implements [jsonv2.UnmarshalerV2].
|
||||
func (p *TestPrefs) UnmarshalJSONV2(in *jsontext.Decoder, opts jsonv2.Options) error {
|
||||
// UnmarshalJSONFrom implements [jsonv2.UnmarshalerFrom].
|
||||
func (p *TestPrefs) UnmarshalJSONFrom(in *jsontext.Decoder) error {
|
||||
// The testPrefs type shadows the TestPrefs's method set,
|
||||
// causing jsonv2 to use the default unmarshaler and avoiding
|
||||
// infinite recursion.
|
||||
type testPrefs TestPrefs
|
||||
return jsonv2.UnmarshalDecode(in, (*testPrefs)(p), opts)
|
||||
return jsonv2.UnmarshalDecode(in, (*testPrefs)(p))
|
||||
}
|
||||
|
||||
// MarshalJSON implements [json.Marshaler].
|
||||
func (p TestPrefs) MarshalJSON() ([]byte, error) {
|
||||
return jsonv2.Marshal(p) // uses MarshalJSONV2
|
||||
return jsonv2.Marshal(p) // uses MarshalJSONTo
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements [json.Unmarshaler].
|
||||
func (p *TestPrefs) UnmarshalJSON(b []byte) error {
|
||||
return jsonv2.Unmarshal(b, p) // uses UnmarshalJSONV2
|
||||
return jsonv2.Unmarshal(b, p) // uses UnmarshalJSONFrom
|
||||
}
|
||||
|
||||
// TestBundle is an example structure type that,
|
||||
|
||||
@@ -169,15 +169,15 @@ func (lv StructListView[T, V]) Equal(lv2 StructListView[T, V]) bool {
|
||||
return lv.ж.Equal(*lv2.ж)
|
||||
}
|
||||
|
||||
// MarshalJSONV2 implements [jsonv2.MarshalerV2].
|
||||
func (lv StructListView[T, V]) MarshalJSONV2(out *jsontext.Encoder, opts jsonv2.Options) error {
|
||||
return lv.ж.MarshalJSONV2(out, opts)
|
||||
// MarshalJSONTo implements [jsonv2.MarshalerTo].
|
||||
func (lv StructListView[T, V]) MarshalJSONTo(out *jsontext.Encoder) error {
|
||||
return lv.ж.MarshalJSONTo(out)
|
||||
}
|
||||
|
||||
// UnmarshalJSONV2 implements [jsonv2.UnmarshalerV2].
|
||||
func (lv *StructListView[T, V]) UnmarshalJSONV2(in *jsontext.Decoder, opts jsonv2.Options) error {
|
||||
// UnmarshalJSONFrom implements [jsonv2.UnmarshalerFrom].
|
||||
func (lv *StructListView[T, V]) UnmarshalJSONFrom(in *jsontext.Decoder) error {
|
||||
var x StructList[T]
|
||||
if err := x.UnmarshalJSONV2(in, opts); err != nil {
|
||||
if err := x.UnmarshalJSONFrom(in); err != nil {
|
||||
return err
|
||||
}
|
||||
lv.ж = &x
|
||||
@@ -186,10 +186,10 @@ func (lv *StructListView[T, V]) UnmarshalJSONV2(in *jsontext.Decoder, opts jsonv
|
||||
|
||||
// MarshalJSON implements [json.Marshaler].
|
||||
func (lv StructListView[T, V]) MarshalJSON() ([]byte, error) {
|
||||
return jsonv2.Marshal(lv) // uses MarshalJSONV2
|
||||
return jsonv2.Marshal(lv) // uses MarshalJSONTo
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements [json.Unmarshaler].
|
||||
func (lv *StructListView[T, V]) UnmarshalJSON(b []byte) error {
|
||||
return jsonv2.Unmarshal(b, lv) // uses UnmarshalJSONV2
|
||||
return jsonv2.Unmarshal(b, lv) // uses UnmarshalJSONFrom
|
||||
}
|
||||
|
||||
@@ -149,15 +149,15 @@ func (mv StructMapView[K, T, V]) Equal(mv2 StructMapView[K, T, V]) bool {
|
||||
return mv.ж.Equal(*mv2.ж)
|
||||
}
|
||||
|
||||
// MarshalJSONV2 implements [jsonv2.MarshalerV2].
|
||||
func (mv StructMapView[K, T, V]) MarshalJSONV2(out *jsontext.Encoder, opts jsonv2.Options) error {
|
||||
return mv.ж.MarshalJSONV2(out, opts)
|
||||
// MarshalJSONTo implements [jsonv2.MarshalerTo].
|
||||
func (mv StructMapView[K, T, V]) MarshalJSONTo(out *jsontext.Encoder) error {
|
||||
return mv.ж.MarshalJSONTo(out)
|
||||
}
|
||||
|
||||
// UnmarshalJSONV2 implements [jsonv2.UnmarshalerV2].
|
||||
func (mv *StructMapView[K, T, V]) UnmarshalJSONV2(in *jsontext.Decoder, opts jsonv2.Options) error {
|
||||
// UnmarshalJSONFrom implements [jsonv2.UnmarshalerFrom].
|
||||
func (mv *StructMapView[K, T, V]) UnmarshalJSONFrom(in *jsontext.Decoder) error {
|
||||
var x StructMap[K, T]
|
||||
if err := x.UnmarshalJSONV2(in, opts); err != nil {
|
||||
if err := x.UnmarshalJSONFrom(in); err != nil {
|
||||
return err
|
||||
}
|
||||
mv.ж = &x
|
||||
@@ -166,10 +166,10 @@ func (mv *StructMapView[K, T, V]) UnmarshalJSONV2(in *jsontext.Decoder, opts jso
|
||||
|
||||
// MarshalJSON implements [json.Marshaler].
|
||||
func (mv StructMapView[K, T, V]) MarshalJSON() ([]byte, error) {
|
||||
return jsonv2.Marshal(mv) // uses MarshalJSONV2
|
||||
return jsonv2.Marshal(mv) // uses MarshalJSONTo
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements [json.Unmarshaler].
|
||||
func (mv *StructMapView[K, T, V]) UnmarshalJSON(b []byte) error {
|
||||
return jsonv2.Unmarshal(b, mv) // uses UnmarshalJSONV2
|
||||
return jsonv2.Unmarshal(b, mv) // uses UnmarshalJSONFrom
|
||||
}
|
||||
|
||||
223
util/eventbus/bus.go
Normal file
223
util/eventbus/bus.go
Normal file
@@ -0,0 +1,223 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package eventbus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"slices"
|
||||
"sync"
|
||||
|
||||
"tailscale.com/util/set"
|
||||
)
|
||||
|
||||
// Bus is an event bus that distributes published events to interested
|
||||
// subscribers.
|
||||
type Bus struct {
|
||||
write chan any
|
||||
stop goroutineShutdownControl
|
||||
snapshot chan chan []any
|
||||
|
||||
topicsMu sync.Mutex // guards everything below.
|
||||
topics map[reflect.Type][]*Queue
|
||||
|
||||
// Used for introspection/debugging only, not in the normal event
|
||||
// publishing path.
|
||||
publishers set.Set[publisher]
|
||||
queues set.Set[*Queue]
|
||||
}
|
||||
|
||||
// New returns a new bus. Use [PublisherOf] to make event publishers,
|
||||
// and [Bus.Queue] and [Subscribe] to make event subscribers.
|
||||
func New() *Bus {
|
||||
stopCtl, stopWorker := newGoroutineShutdown()
|
||||
ret := &Bus{
|
||||
write: make(chan any),
|
||||
stop: stopCtl,
|
||||
snapshot: make(chan chan []any),
|
||||
topics: map[reflect.Type][]*Queue{},
|
||||
publishers: set.Set[publisher]{},
|
||||
queues: set.Set[*Queue]{},
|
||||
}
|
||||
go ret.pump(stopWorker)
|
||||
return ret
|
||||
}
|
||||
|
||||
func (b *Bus) pump(stop goroutineShutdownWorker) {
|
||||
defer stop.Done()
|
||||
var vals queue
|
||||
acceptCh := func() chan any {
|
||||
if vals.Full() {
|
||||
return nil
|
||||
}
|
||||
return b.write
|
||||
}
|
||||
for {
|
||||
// Drain all pending events. Note that while we're draining
|
||||
// events into subscriber queues, we continue to
|
||||
// opportunistically accept more incoming events, if we have
|
||||
// queue space for it.
|
||||
for !vals.Empty() {
|
||||
val := vals.Peek()
|
||||
dests := b.dest(reflect.ValueOf(val).Type())
|
||||
for _, d := range dests {
|
||||
deliverOne:
|
||||
for {
|
||||
select {
|
||||
case d.write <- val:
|
||||
break deliverOne
|
||||
case <-d.stop.WaitChan():
|
||||
// Queue closed, don't block but continue
|
||||
// delivering to others.
|
||||
break deliverOne
|
||||
case in := <-acceptCh():
|
||||
vals.Add(in)
|
||||
case <-stop.Stop():
|
||||
return
|
||||
case ch := <-b.snapshot:
|
||||
ch <- vals.Snapshot()
|
||||
}
|
||||
}
|
||||
}
|
||||
vals.Drop()
|
||||
}
|
||||
|
||||
// Inbound queue empty, wait for at least 1 work item before
|
||||
// resuming.
|
||||
for vals.Empty() {
|
||||
select {
|
||||
case <-stop.Stop():
|
||||
return
|
||||
case val := <-b.write:
|
||||
vals.Add(val)
|
||||
case ch := <-b.snapshot:
|
||||
ch <- nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bus) dest(t reflect.Type) []*Queue {
|
||||
b.topicsMu.Lock()
|
||||
defer b.topicsMu.Unlock()
|
||||
return b.topics[t]
|
||||
}
|
||||
|
||||
func (b *Bus) subscribe(t reflect.Type, q *Queue) (cancel func()) {
|
||||
b.topicsMu.Lock()
|
||||
defer b.topicsMu.Unlock()
|
||||
b.topics[t] = append(b.topics[t], q)
|
||||
return func() {
|
||||
b.unsubscribe(t, q)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bus) unsubscribe(t reflect.Type, q *Queue) {
|
||||
b.topicsMu.Lock()
|
||||
defer b.topicsMu.Unlock()
|
||||
// Topic slices are accessed by pump without holding a lock, so we
|
||||
// have to replace the entire slice when unsubscribing.
|
||||
// Unsubscribing should be infrequent enough that this won't
|
||||
// matter.
|
||||
i := slices.Index(b.topics[t], q)
|
||||
if i < 0 {
|
||||
return
|
||||
}
|
||||
b.topics[t] = slices.Delete(slices.Clone(b.topics[t]), i, i+1)
|
||||
}
|
||||
|
||||
func (b *Bus) Close() {
|
||||
b.stop.StopAndWait()
|
||||
}
|
||||
|
||||
// Queue returns a new queue with no subscriptions. Use [Subscribe] to
|
||||
// atach subscriptions to it.
|
||||
//
|
||||
// The queue's name should be a short, human-readable string that
|
||||
// identifies this queue. The name is only visible through debugging
|
||||
// APIs.
|
||||
func (b *Bus) Queue(name string) *Queue {
|
||||
return newQueue(b, name)
|
||||
}
|
||||
|
||||
func (b *Bus) addQueue(q *Queue) {
|
||||
b.topicsMu.Lock()
|
||||
defer b.topicsMu.Unlock()
|
||||
b.queues.Add(q)
|
||||
}
|
||||
|
||||
func (b *Bus) deleteQueue(q *Queue) {
|
||||
b.topicsMu.Lock()
|
||||
defer b.topicsMu.Unlock()
|
||||
b.queues.Delete(q)
|
||||
}
|
||||
|
||||
func (b *Bus) addPublisher(p publisher) {
|
||||
b.topicsMu.Lock()
|
||||
defer b.topicsMu.Unlock()
|
||||
b.publishers.Add(p)
|
||||
}
|
||||
|
||||
func (b *Bus) deletePublisher(p publisher) {
|
||||
b.topicsMu.Lock()
|
||||
defer b.topicsMu.Unlock()
|
||||
b.publishers.Delete(p)
|
||||
}
|
||||
|
||||
func newGoroutineShutdown() (goroutineShutdownControl, goroutineShutdownWorker) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
ctl := goroutineShutdownControl{
|
||||
startShutdown: cancel,
|
||||
shutdownFinished: make(chan struct{}),
|
||||
}
|
||||
work := goroutineShutdownWorker{
|
||||
startShutdown: ctx.Done(),
|
||||
shutdownFinished: ctl.shutdownFinished,
|
||||
}
|
||||
|
||||
return ctl, work
|
||||
}
|
||||
|
||||
// goroutineShutdownControl is a helper type to manage the shutdown of
|
||||
// a worker goroutine. The worker goroutine should use the
|
||||
// goroutineShutdownWorker related to this controller.
|
||||
type goroutineShutdownControl struct {
|
||||
startShutdown context.CancelFunc
|
||||
shutdownFinished chan struct{}
|
||||
}
|
||||
|
||||
func (ctl *goroutineShutdownControl) Stop() {
|
||||
ctl.startShutdown()
|
||||
}
|
||||
|
||||
func (ctl *goroutineShutdownControl) Wait() {
|
||||
<-ctl.shutdownFinished
|
||||
}
|
||||
|
||||
func (ctl *goroutineShutdownControl) WaitChan() <-chan struct{} {
|
||||
return ctl.shutdownFinished
|
||||
}
|
||||
|
||||
func (ctl *goroutineShutdownControl) StopAndWait() {
|
||||
ctl.Stop()
|
||||
ctl.Wait()
|
||||
}
|
||||
|
||||
// goroutineShutdownWorker is a helper type for a worker goroutine to
|
||||
// be notified that it should shut down, and to report that shutdown
|
||||
// has completed. The notification is triggered by the related
|
||||
// goroutineShutdownControl.
|
||||
type goroutineShutdownWorker struct {
|
||||
startShutdown <-chan struct{}
|
||||
shutdownFinished chan struct{}
|
||||
}
|
||||
|
||||
func (work *goroutineShutdownWorker) Stop() <-chan struct{} {
|
||||
return work.startShutdown
|
||||
}
|
||||
|
||||
func (work *goroutineShutdownWorker) Done() {
|
||||
close(work.shutdownFinished)
|
||||
}
|
||||
196
util/eventbus/bus_test.go
Normal file
196
util/eventbus/bus_test.go
Normal file
@@ -0,0 +1,196 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package eventbus_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/creachadair/taskgroup"
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"tailscale.com/util/eventbus"
|
||||
)
|
||||
|
||||
type EventA struct {
|
||||
Counter int
|
||||
}
|
||||
|
||||
type EventB struct {
|
||||
Counter int
|
||||
}
|
||||
|
||||
func TestBus(t *testing.T) {
|
||||
b := eventbus.New()
|
||||
defer b.Close()
|
||||
|
||||
q := b.Queue("TestBus")
|
||||
defer q.Close()
|
||||
s := eventbus.Subscribe[EventA](q)
|
||||
|
||||
go func() {
|
||||
pa := eventbus.PublisherOf[EventA](b, "TestBusA")
|
||||
defer pa.Close()
|
||||
pb := eventbus.PublisherOf[EventB](b, "TestBusB")
|
||||
defer pb.Close()
|
||||
pa.Publish(EventA{1})
|
||||
pb.Publish(EventB{2})
|
||||
pa.Publish(EventA{3})
|
||||
}()
|
||||
|
||||
want := expectEvents(t, EventA{1}, EventA{3})
|
||||
for !want.Empty() {
|
||||
select {
|
||||
case got := <-s.Events():
|
||||
want.Got(got)
|
||||
case <-q.Done():
|
||||
t.Fatalf("queue closed unexpectedly")
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("timed out waiting for event")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBusMultipleConsumers(t *testing.T) {
|
||||
b := eventbus.New()
|
||||
defer b.Close()
|
||||
|
||||
q1 := b.Queue("TestBusA")
|
||||
defer q1.Close()
|
||||
s1 := eventbus.Subscribe[EventA](q1)
|
||||
|
||||
q2 := b.Queue("TestBusAB")
|
||||
defer q2.Close()
|
||||
s2A := eventbus.Subscribe[EventA](q2)
|
||||
s2B := eventbus.Subscribe[EventB](q2)
|
||||
|
||||
go func() {
|
||||
pa := eventbus.PublisherOf[EventA](b, "TestBusA")
|
||||
defer pa.Close()
|
||||
pb := eventbus.PublisherOf[EventB](b, "TestBusB")
|
||||
defer pb.Close()
|
||||
pa.Publish(EventA{1})
|
||||
pb.Publish(EventB{2})
|
||||
pa.Publish(EventA{3})
|
||||
}()
|
||||
|
||||
wantA := expectEvents(t, EventA{1}, EventA{3})
|
||||
wantB := expectEvents(t, EventA{1}, EventB{2}, EventA{3})
|
||||
for !wantA.Empty() || !wantB.Empty() {
|
||||
select {
|
||||
case got := <-s1.Events():
|
||||
wantA.Got(got)
|
||||
case got := <-s2A.Events():
|
||||
wantB.Got(got)
|
||||
case got := <-s2B.Events():
|
||||
wantB.Got(got)
|
||||
case <-q1.Done():
|
||||
t.Fatalf("queue closed unexpectedly")
|
||||
case <-q2.Done():
|
||||
t.Fatalf("queue closed unexpectedly")
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("timed out waiting for event")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpam(t *testing.T) {
|
||||
b := eventbus.New()
|
||||
defer b.Close()
|
||||
|
||||
const (
|
||||
publishers = 100
|
||||
eventsPerPublisher = 20
|
||||
wantEvents = publishers * eventsPerPublisher
|
||||
subscribers = 100
|
||||
)
|
||||
|
||||
var g taskgroup.Group
|
||||
|
||||
received := make([][]EventA, subscribers)
|
||||
for i := range subscribers {
|
||||
q := b.Queue(fmt.Sprintf("Subscriber%d", i))
|
||||
defer q.Close()
|
||||
s := eventbus.Subscribe[EventA](q)
|
||||
g.Go(func() error {
|
||||
for range wantEvents {
|
||||
select {
|
||||
case evt := <-s.Events():
|
||||
received[i] = append(received[i], evt)
|
||||
case <-q.Done():
|
||||
t.Errorf("queue done before expected number of events received")
|
||||
return errors.New("queue prematurely closed")
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Errorf("timed out waiting for expected bus event after %d events", len(received[i]))
|
||||
return errors.New("timeout")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
published := make([][]EventA, publishers)
|
||||
for i := range publishers {
|
||||
g.Run(func() {
|
||||
p := eventbus.PublisherOf[EventA](b, fmt.Sprintf("Publisher%d", i))
|
||||
for j := range eventsPerPublisher {
|
||||
evt := EventA{i*eventsPerPublisher + j}
|
||||
p.Publish(evt)
|
||||
published[i] = append(published[i], evt)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var last []EventA
|
||||
for i, got := range received {
|
||||
if len(got) != wantEvents {
|
||||
// Receiving goroutine already reported an error, we just need
|
||||
// to fail early within the main test goroutine.
|
||||
t.FailNow()
|
||||
}
|
||||
if last == nil {
|
||||
continue
|
||||
}
|
||||
if diff := cmp.Diff(got, last); diff != "" {
|
||||
t.Errorf("Subscriber %d did not see the same events as %d (-got+want):\n%s", i, i-1, diff)
|
||||
}
|
||||
last = got
|
||||
}
|
||||
for i, sent := range published {
|
||||
if got := len(sent); got != eventsPerPublisher {
|
||||
t.Fatalf("Publisher %d sent %d events, want %d", i, got, eventsPerPublisher)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: check that the published sequences are proper
|
||||
// subsequences of the received slices.
|
||||
}
|
||||
|
||||
type queueChecker struct {
|
||||
t *testing.T
|
||||
want []any
|
||||
}
|
||||
|
||||
func expectEvents(t *testing.T, want ...any) *queueChecker {
|
||||
return &queueChecker{t, want}
|
||||
}
|
||||
|
||||
func (q *queueChecker) Got(v any) {
|
||||
q.t.Helper()
|
||||
if q.Empty() {
|
||||
q.t.Fatalf("queue got unexpected %v", v)
|
||||
}
|
||||
if v != q.want[0] {
|
||||
q.t.Fatalf("queue got %#v, want %#v", v, q.want[0])
|
||||
}
|
||||
q.want = q.want[1:]
|
||||
}
|
||||
|
||||
func (q *queueChecker) Empty() bool {
|
||||
return len(q.want) == 0
|
||||
}
|
||||
100
util/eventbus/doc.go
Normal file
100
util/eventbus/doc.go
Normal file
@@ -0,0 +1,100 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package eventbus provides an in-process event bus.
|
||||
//
|
||||
// The event bus connects publishers of typed events with subscribers
|
||||
// interested in those events.
|
||||
//
|
||||
// # Usage
|
||||
//
|
||||
// To publish events, use [PublisherOf] to get a typed publisher for
|
||||
// your event type, then call [Publisher.Publish] as needed. If your
|
||||
// event is expensive to construct, you can optionally use
|
||||
// [Publisher.ShouldPublish] to skip the work if nobody is listening
|
||||
// for the event.
|
||||
//
|
||||
// To receive events, first use [Bus.Queue] to create an event
|
||||
// delivery queue, then use [Subscribe] to get a [Subscriber] for each
|
||||
// event type you're interested in. Receive the events themselves by
|
||||
// selecting over all your [Subscriber.Chan] channels, as well as
|
||||
// [Queue.Done] for shutdown notifications.
|
||||
//
|
||||
// # Concurrency properties
|
||||
//
|
||||
// The bus serializes all published events, and preserves that
|
||||
// ordering when delivering to subscribers that are attached to the
|
||||
// same Queue. In more detail:
|
||||
//
|
||||
// - An event is published to the bus at some instant between the
|
||||
// start and end of the call to [Publisher.Publish].
|
||||
// - Events cannot be published at the same instant, and so are
|
||||
// totally ordered by their publication time. Given two events E1
|
||||
// and E2, either E1 happens before E2, or E2 happens before E1.
|
||||
// - Queues dispatch events to their Subscribers in publication
|
||||
// order: if E1 happens before E2, the queue always delivers E1
|
||||
// before E2.
|
||||
// - Queues do not synchronize with each other: given queues Q1 and
|
||||
// Q2, both subscribed to events E1 and E2, Q1 may deliver both E1
|
||||
// and E2 before Q2 delivers E1.
|
||||
//
|
||||
// Less formally: there is one true timeline of all published events.
|
||||
// If you make a Queue and subscribe to events on it, you will receive
|
||||
// those events one at a time, in the same order as the one true
|
||||
// timeline. You will "skip over" events you didn't subscribe to, but
|
||||
// your view of the world always moves forward in time, never
|
||||
// backwards, and you will observe events in the same order as
|
||||
// everyone else.
|
||||
//
|
||||
// However, you cannot assume that what your subscribers on your queue
|
||||
// see as "now" is the same as what other subscribers on other
|
||||
// queues. Their queue may be further behind you in the timeline, or
|
||||
// running ahead of you. This means you should be careful about
|
||||
// reaching out to another component directly after receiving an
|
||||
// event, as its view of the world may not yet (or ever) be exactly
|
||||
// consistent with yours.
|
||||
//
|
||||
// To make your code more testable and understandable, you should try
|
||||
// to structure it following the actor model: you have some local
|
||||
// state over which you have authority, but your only way to interact
|
||||
// with state elsewhere in the program is to receive and process
|
||||
// events coming from elsewhere, or to emit events of your own.
|
||||
//
|
||||
// # Expected subscriber behavior
|
||||
//
|
||||
// Subscribers are expected to promptly receive their events on
|
||||
// [Subscriber.Chan]. The bus has a small, fixed amount of internal
|
||||
// buffering, meaning that a slow subscriber will eventually cause
|
||||
// backpressure and block publication of all further events.
|
||||
//
|
||||
// In general, you should receive from your subscriber(s) in a loop,
|
||||
// and only do fast state updates within that loop. Any heavier work
|
||||
// should be offloaded to another goroutine.
|
||||
//
|
||||
// Causing publishers to block from backpressure is considered a bug
|
||||
// in the slow subscriber causing the backpressure, and should be
|
||||
// addressed there. Publishers should assume that Publish will not
|
||||
// block for extended periods of time, and should not make exceptional
|
||||
// effort to behave gracefully if they do get blocked.
|
||||
//
|
||||
// These blocking semantics are provisional and subject to
|
||||
// change. Please speak up if this causes development pain, so that we
|
||||
// can adapt the semantics to better suit our needs.
|
||||
//
|
||||
// # Debugging facilities
|
||||
//
|
||||
// (TODO, not implemented yet, sorry, I promise we're working on it next!)
|
||||
//
|
||||
// The bus comes with introspection facilities to help reason about
|
||||
// the state of the client, and diagnose issues such as slow
|
||||
// subscribers.
|
||||
//
|
||||
// The bus provide a tsweb debugging page that shows the current state
|
||||
// of the bus, including all publishers, subscribers, and queued
|
||||
// events.
|
||||
//
|
||||
// The bus also has a snooping and tracing facility, which lets you
|
||||
// observe all events flowing through the bus, along with their
|
||||
// source, destination(s) and timing information such as the time of
|
||||
// delivery to each subscriber and end-to-end bus delays.
|
||||
package eventbus
|
||||
79
util/eventbus/publish.go
Normal file
79
util/eventbus/publish.go
Normal file
@@ -0,0 +1,79 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package eventbus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
)
|
||||
|
||||
// publisher is a uniformly typed wrapper around Publisher[T], so that
|
||||
// debugging facilities can look at active publishers.
|
||||
type publisher interface {
|
||||
publisherName() string
|
||||
}
|
||||
|
||||
// A Publisher publishes events on the bus.
|
||||
type Publisher[T any] struct {
|
||||
bus *Bus
|
||||
name string
|
||||
stopCtx context.Context
|
||||
stop context.CancelFunc
|
||||
}
|
||||
|
||||
// PublisherOf returns a publisher for event type T on the given bus.
|
||||
//
|
||||
// The publisher's name should be a short, human-readable string that
|
||||
// identifies this event publisher. The name is only visible through
|
||||
// debugging APIs.
|
||||
func PublisherOf[T any](b *Bus, name string) *Publisher[T] {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
ret := &Publisher[T]{
|
||||
bus: b,
|
||||
name: name,
|
||||
stopCtx: ctx,
|
||||
stop: cancel,
|
||||
}
|
||||
b.addPublisher(ret)
|
||||
return ret
|
||||
}
|
||||
|
||||
func (p *Publisher[T]) publisherName() string { return p.name }
|
||||
|
||||
// Publish publishes event v on the bus.
|
||||
func (p *Publisher[T]) Publish(v T) {
|
||||
// Check for just a stopped publisher or bus before trying to
|
||||
// write, so that once closed Publish consistently does nothing.
|
||||
select {
|
||||
case <-p.stopCtx.Done():
|
||||
return
|
||||
case <-p.bus.stop.WaitChan():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
select {
|
||||
case p.bus.write <- v:
|
||||
case <-p.stopCtx.Done():
|
||||
case <-p.bus.stop.WaitChan():
|
||||
}
|
||||
}
|
||||
|
||||
// ShouldPublish reports whether anyone is subscribed to events of
|
||||
// type T.
|
||||
//
|
||||
// ShouldPublish can be used to skip expensive event construction if
|
||||
// nobody seems to care. Publishers must not assume that someone will
|
||||
// definitely receive an event if ShouldPublish returns true.
|
||||
func (p *Publisher[T]) ShouldPublish() bool {
|
||||
dests := p.bus.dest(reflect.TypeFor[T]())
|
||||
return len(dests) > 0
|
||||
}
|
||||
|
||||
// Close closes the publisher, indicating that no further events will
|
||||
// be published with it.
|
||||
func (p *Publisher[T]) Close() {
|
||||
p.stop()
|
||||
p.bus.deletePublisher(p)
|
||||
}
|
||||
83
util/eventbus/queue.go
Normal file
83
util/eventbus/queue.go
Normal file
@@ -0,0 +1,83 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package eventbus
|
||||
|
||||
import (
|
||||
"slices"
|
||||
)
|
||||
|
||||
const maxQueuedItems = 16
|
||||
|
||||
// queue is an ordered queue of length up to maxQueuedItems.
|
||||
type queue struct {
|
||||
vals []any
|
||||
start int
|
||||
}
|
||||
|
||||
// canAppend reports whether a value can be appended to q.vals without
|
||||
// shifting values around.
|
||||
func (q *queue) canAppend() bool {
|
||||
return cap(q.vals) < maxQueuedItems || len(q.vals) < cap(q.vals)
|
||||
}
|
||||
|
||||
func (q *queue) Full() bool {
|
||||
return q.start == 0 && !q.canAppend()
|
||||
}
|
||||
|
||||
func (q *queue) Empty() bool {
|
||||
return q.start == len(q.vals)
|
||||
}
|
||||
|
||||
func (q *queue) Len() int {
|
||||
return len(q.vals) - q.start
|
||||
}
|
||||
|
||||
// Add adds v to the end of the queue. Blocks until append can be
|
||||
// done.
|
||||
func (q *queue) Add(v any) {
|
||||
if !q.canAppend() {
|
||||
if q.start == 0 {
|
||||
panic("Add on a full queue")
|
||||
}
|
||||
|
||||
// Slide remaining values back to the start of the array.
|
||||
n := copy(q.vals, q.vals[q.start:])
|
||||
toClear := len(q.vals) - n
|
||||
clear(q.vals[len(q.vals)-toClear:])
|
||||
q.vals = q.vals[:n]
|
||||
q.start = 0
|
||||
}
|
||||
|
||||
q.vals = append(q.vals, v)
|
||||
}
|
||||
|
||||
// Peek returns the first value in the queue, without removing it from
|
||||
// the queue, or nil if the queue is empty.
|
||||
func (q *queue) Peek() any {
|
||||
if q.Empty() {
|
||||
return nil
|
||||
}
|
||||
|
||||
return q.vals[q.start]
|
||||
}
|
||||
|
||||
// Drop discards the first value in the queue, if any.
|
||||
func (q *queue) Drop() {
|
||||
if q.Empty() {
|
||||
return
|
||||
}
|
||||
|
||||
q.vals[q.start] = nil
|
||||
q.start++
|
||||
if q.Empty() {
|
||||
// Reset cursor to start of array, it's free to do.
|
||||
q.start = 0
|
||||
q.vals = q.vals[:0]
|
||||
}
|
||||
}
|
||||
|
||||
// Snapshot returns a copy of the queue's contents.
|
||||
func (q *queue) Snapshot() []any {
|
||||
return slices.Clone(q.vals[q.start:])
|
||||
}
|
||||
170
util/eventbus/subscribe.go
Normal file
170
util/eventbus/subscribe.go
Normal file
@@ -0,0 +1,170 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package eventbus
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type dispatchFn func(vals *queue, stop goroutineShutdownWorker, acceptCh func() chan any) bool
|
||||
|
||||
// A Queue receives events from a Bus.
|
||||
//
|
||||
// To receive events through the queue, see [Subscribe]. Subscribers
|
||||
// that share the same Queue receive events one at time, in the order
|
||||
// they were published.
|
||||
type Queue struct {
|
||||
bus *Bus
|
||||
name string
|
||||
|
||||
write chan any
|
||||
stop goroutineShutdownControl
|
||||
snapshot chan chan []any
|
||||
|
||||
outputsMu sync.Mutex
|
||||
outputs map[reflect.Type]dispatchFn
|
||||
}
|
||||
|
||||
func newQueue(b *Bus, name string) *Queue {
|
||||
stopCtl, stopWorker := newGoroutineShutdown()
|
||||
ret := &Queue{
|
||||
bus: b,
|
||||
name: name,
|
||||
write: make(chan any),
|
||||
stop: stopCtl,
|
||||
snapshot: make(chan chan []any),
|
||||
outputs: map[reflect.Type]dispatchFn{},
|
||||
}
|
||||
b.addQueue(ret)
|
||||
go ret.pump(stopWorker)
|
||||
return ret
|
||||
}
|
||||
|
||||
func (q *Queue) pump(stop goroutineShutdownWorker) {
|
||||
defer stop.Done()
|
||||
var vals queue
|
||||
acceptCh := func() chan any {
|
||||
if vals.Full() {
|
||||
return nil
|
||||
}
|
||||
return q.write
|
||||
}
|
||||
for {
|
||||
if !vals.Empty() {
|
||||
val := vals.Peek()
|
||||
fn := q.dispatchFn(val)
|
||||
if fn == nil {
|
||||
// Raced with unsubscribe.
|
||||
vals.Drop()
|
||||
continue
|
||||
}
|
||||
if !fn(&vals, stop, acceptCh) {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Keep the cases in this select in sync with
|
||||
// Subscriber.dispatch below. The only different should be
|
||||
// that this select doesn't deliver queued values to
|
||||
// anyone, and unconditionally accepts new values.
|
||||
select {
|
||||
case val := <-q.write:
|
||||
vals.Add(val)
|
||||
case <-stop.Stop():
|
||||
return
|
||||
case ch := <-q.snapshot:
|
||||
ch <- vals.Snapshot()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A Subscriber delivers one type of event from a [Queue].
|
||||
type Subscriber[T any] struct {
|
||||
recv *Queue
|
||||
read chan T
|
||||
}
|
||||
|
||||
func (s *Subscriber[T]) dispatch(vals *queue, stop goroutineShutdownWorker, acceptCh func() chan any) bool {
|
||||
t := vals.Peek().(T)
|
||||
for {
|
||||
// Keep the cases in this select in sync with Queue.pump
|
||||
// above. The only different should be that this select
|
||||
// delivers a value on s.read.
|
||||
select {
|
||||
case s.read <- t:
|
||||
vals.Drop()
|
||||
return true
|
||||
case val := <-acceptCh():
|
||||
vals.Add(val)
|
||||
case <-stop.Stop():
|
||||
return false
|
||||
case ch := <-s.recv.snapshot:
|
||||
ch <- vals.Snapshot()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Events returns a channel on which the subscriber's events are
|
||||
// delivered.
|
||||
func (s *Subscriber[T]) Events() <-chan T {
|
||||
return s.read
|
||||
}
|
||||
|
||||
// Close shuts down the Subscriber, indicating the caller no longer
|
||||
// wishes to receive these events. After Close, receives on
|
||||
// [Subscriber.Chan] block for ever.
|
||||
func (s *Subscriber[T]) Close() {
|
||||
t := reflect.TypeFor[T]()
|
||||
s.recv.bus.unsubscribe(t, s.recv)
|
||||
s.recv.deleteDispatchFn(t)
|
||||
}
|
||||
|
||||
func (q *Queue) dispatchFn(val any) dispatchFn {
|
||||
q.outputsMu.Lock()
|
||||
defer q.outputsMu.Unlock()
|
||||
return q.outputs[reflect.ValueOf(val).Type()]
|
||||
}
|
||||
|
||||
func (q *Queue) addDispatchFn(t reflect.Type, fn dispatchFn) {
|
||||
q.outputsMu.Lock()
|
||||
defer q.outputsMu.Unlock()
|
||||
if q.outputs[t] != nil {
|
||||
panic(fmt.Errorf("double subscription for event %s", t))
|
||||
}
|
||||
q.outputs[t] = fn
|
||||
}
|
||||
|
||||
func (q *Queue) deleteDispatchFn(t reflect.Type) {
|
||||
q.outputsMu.Lock()
|
||||
defer q.outputsMu.Unlock()
|
||||
delete(q.outputs, t)
|
||||
}
|
||||
|
||||
// Done returns a channel that is closed when the Queue is closed.
|
||||
func (q *Queue) Done() <-chan struct{} {
|
||||
return q.stop.WaitChan()
|
||||
}
|
||||
|
||||
// Close closes the queue. All Subscribers attached to the queue are
|
||||
// implicitly closed, and any pending events are discarded.
|
||||
func (q *Queue) Close() {
|
||||
q.stop.StopAndWait()
|
||||
q.bus.deleteQueue(q)
|
||||
}
|
||||
|
||||
// Subscribe requests delivery of events of type T through the given
|
||||
// Queue. Panics if the queue already has a subscriber for T.
|
||||
func Subscribe[T any](r *Queue) Subscriber[T] {
|
||||
t := reflect.TypeFor[T]()
|
||||
ret := Subscriber[T]{
|
||||
recv: r,
|
||||
read: make(chan T),
|
||||
}
|
||||
r.addDispatchFn(t, ret.dispatch)
|
||||
r.bus.subscribe(t, r)
|
||||
|
||||
return ret
|
||||
}
|
||||
@@ -56,10 +56,10 @@ func EqualJSONForTest(tb TB, j1, j2 jsontext.Value) (s1, s2 string, equal bool)
|
||||
return "", "", true
|
||||
}
|
||||
// Otherwise, format the values for display and return false.
|
||||
if err := j1.Indent("", "\t"); err != nil {
|
||||
if err := j1.Indent(); err != nil {
|
||||
tb.Fatal(err)
|
||||
}
|
||||
if err := j2.Indent("", "\t"); err != nil {
|
||||
if err := j2.Indent(); err != nil {
|
||||
tb.Fatal(err)
|
||||
}
|
||||
return j1.String(), j2.String(), false
|
||||
|
||||
@@ -42,6 +42,12 @@ const (
|
||||
// for auditing purposes. It has no effect when [AlwaysOn] is false.
|
||||
AlwaysOnOverrideWithReason Key = "AlwaysOn.OverrideWithReason"
|
||||
|
||||
// ReconnectAfter is a string value formatted for use with time.ParseDuration()
|
||||
// that defines the duration after which the client should automatically reconnect
|
||||
// to the Tailscale network following a user-initiated disconnect.
|
||||
// An empty string or a zero duration disables automatic reconnection.
|
||||
ReconnectAfter Key = "ReconnectAfter"
|
||||
|
||||
// ExitNodeID is the exit node's node id. default ""; if blank, no exit node is forced.
|
||||
// Exit node ID takes precedence over exit node IP.
|
||||
// To find the node ID, go to /api.md#device.
|
||||
@@ -176,6 +182,7 @@ var implicitDefinitions = []*setting.Definition{
|
||||
setting.NewDefinition(LogTarget, setting.DeviceSetting, setting.StringValue),
|
||||
setting.NewDefinition(MachineCertificateSubject, setting.DeviceSetting, setting.StringValue),
|
||||
setting.NewDefinition(PostureChecking, setting.DeviceSetting, setting.PreferenceOptionValue),
|
||||
setting.NewDefinition(ReconnectAfter, setting.DeviceSetting, setting.DurationValue),
|
||||
setting.NewDefinition(Tailnet, setting.DeviceSetting, setting.StringValue),
|
||||
|
||||
// User policy settings (can be configured on a user- or device-basis):
|
||||
|
||||
@@ -50,22 +50,27 @@ func (s Origin) String() string {
|
||||
return s.Scope().String()
|
||||
}
|
||||
|
||||
// MarshalJSONV2 implements [jsonv2.MarshalerV2].
|
||||
func (s Origin) MarshalJSONV2(out *jsontext.Encoder, opts jsonv2.Options) error {
|
||||
return jsonv2.MarshalEncode(out, &s.data, opts)
|
||||
var (
|
||||
_ jsonv2.MarshalerTo = (*Origin)(nil)
|
||||
_ jsonv2.UnmarshalerFrom = (*Origin)(nil)
|
||||
)
|
||||
|
||||
// MarshalJSONTo implements [jsonv2.MarshalerTo].
|
||||
func (s Origin) MarshalJSONTo(out *jsontext.Encoder) error {
|
||||
return jsonv2.MarshalEncode(out, &s.data)
|
||||
}
|
||||
|
||||
// UnmarshalJSONV2 implements [jsonv2.UnmarshalerV2].
|
||||
func (s *Origin) UnmarshalJSONV2(in *jsontext.Decoder, opts jsonv2.Options) error {
|
||||
return jsonv2.UnmarshalDecode(in, &s.data, opts)
|
||||
// UnmarshalJSONFrom implements [jsonv2.UnmarshalerFrom].
|
||||
func (s *Origin) UnmarshalJSONFrom(in *jsontext.Decoder) error {
|
||||
return jsonv2.UnmarshalDecode(in, &s.data)
|
||||
}
|
||||
|
||||
// MarshalJSON implements [json.Marshaler].
|
||||
func (s Origin) MarshalJSON() ([]byte, error) {
|
||||
return jsonv2.Marshal(s) // uses MarshalJSONV2
|
||||
return jsonv2.Marshal(s) // uses MarshalJSONTo
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements [json.Unmarshaler].
|
||||
func (s *Origin) UnmarshalJSON(b []byte) error {
|
||||
return jsonv2.Unmarshal(b, s) // uses UnmarshalJSONV2
|
||||
return jsonv2.Unmarshal(b, s) // uses UnmarshalJSONFrom
|
||||
}
|
||||
|
||||
@@ -75,31 +75,36 @@ func (i RawItem) String() string {
|
||||
return fmt.Sprintf("%v%s", i.data.Value.Value, suffix)
|
||||
}
|
||||
|
||||
// MarshalJSONV2 implements [jsonv2.MarshalerV2].
|
||||
func (i RawItem) MarshalJSONV2(out *jsontext.Encoder, opts jsonv2.Options) error {
|
||||
return jsonv2.MarshalEncode(out, &i.data, opts)
|
||||
var (
|
||||
_ jsonv2.MarshalerTo = (*RawItem)(nil)
|
||||
_ jsonv2.UnmarshalerFrom = (*RawItem)(nil)
|
||||
)
|
||||
|
||||
// MarshalJSONTo implements [jsonv2.MarshalerTo].
|
||||
func (i RawItem) MarshalJSONTo(out *jsontext.Encoder) error {
|
||||
return jsonv2.MarshalEncode(out, &i.data)
|
||||
}
|
||||
|
||||
// UnmarshalJSONV2 implements [jsonv2.UnmarshalerV2].
|
||||
func (i *RawItem) UnmarshalJSONV2(in *jsontext.Decoder, opts jsonv2.Options) error {
|
||||
return jsonv2.UnmarshalDecode(in, &i.data, opts)
|
||||
// UnmarshalJSONFrom implements [jsonv2.UnmarshalerFrom].
|
||||
func (i *RawItem) UnmarshalJSONFrom(in *jsontext.Decoder) error {
|
||||
return jsonv2.UnmarshalDecode(in, &i.data)
|
||||
}
|
||||
|
||||
// MarshalJSON implements [json.Marshaler].
|
||||
func (i RawItem) MarshalJSON() ([]byte, error) {
|
||||
return jsonv2.Marshal(i) // uses MarshalJSONV2
|
||||
return jsonv2.Marshal(i) // uses MarshalJSONTo
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements [json.Unmarshaler].
|
||||
func (i *RawItem) UnmarshalJSON(b []byte) error {
|
||||
return jsonv2.Unmarshal(b, i) // uses UnmarshalJSONV2
|
||||
return jsonv2.Unmarshal(b, i) // uses UnmarshalJSONFrom
|
||||
}
|
||||
|
||||
// RawValue represents a raw policy setting value read from a policy store.
|
||||
// It is JSON-marshallable and facilitates unmarshalling of JSON values
|
||||
// into corresponding policy setting types, with special handling for JSON numbers
|
||||
// (unmarshalled as float64) and JSON string arrays (unmarshalled as []string).
|
||||
// See also [RawValue.UnmarshalJSONV2].
|
||||
// See also [RawValue.UnmarshalJSONFrom].
|
||||
type RawValue struct {
|
||||
opt.Value[any]
|
||||
}
|
||||
@@ -114,16 +119,21 @@ func RawValueOf[T RawValueType](v T) RawValue {
|
||||
return RawValue{opt.ValueOf[any](v)}
|
||||
}
|
||||
|
||||
// MarshalJSONV2 implements [jsonv2.MarshalerV2].
|
||||
func (v RawValue) MarshalJSONV2(out *jsontext.Encoder, opts jsonv2.Options) error {
|
||||
return jsonv2.MarshalEncode(out, v.Value, opts)
|
||||
var (
|
||||
_ jsonv2.MarshalerTo = (*RawValue)(nil)
|
||||
_ jsonv2.UnmarshalerFrom = (*RawValue)(nil)
|
||||
)
|
||||
|
||||
// MarshalJSONTo implements [jsonv2.MarshalerTo].
|
||||
func (v RawValue) MarshalJSONTo(out *jsontext.Encoder) error {
|
||||
return jsonv2.MarshalEncode(out, v.Value)
|
||||
}
|
||||
|
||||
// UnmarshalJSONV2 implements [jsonv2.UnmarshalerV2] by attempting to unmarshal
|
||||
// UnmarshalJSONFrom implements [jsonv2.UnmarshalerFrom] by attempting to unmarshal
|
||||
// a JSON value as one of the supported policy setting value types (bool, string, uint64, or []string),
|
||||
// based on the JSON value type. It fails if the JSON value is an object, if it's a JSON number that
|
||||
// cannot be represented as a uint64, or if a JSON array contains anything other than strings.
|
||||
func (v *RawValue) UnmarshalJSONV2(in *jsontext.Decoder, opts jsonv2.Options) error {
|
||||
func (v *RawValue) UnmarshalJSONFrom(in *jsontext.Decoder) error {
|
||||
var valPtr any
|
||||
switch k := in.PeekKind(); k {
|
||||
case 't', 'f':
|
||||
@@ -139,7 +149,7 @@ func (v *RawValue) UnmarshalJSONV2(in *jsontext.Decoder, opts jsonv2.Options) er
|
||||
default:
|
||||
panic("unreachable")
|
||||
}
|
||||
if err := jsonv2.UnmarshalDecode(in, valPtr, opts); err != nil {
|
||||
if err := jsonv2.UnmarshalDecode(in, valPtr); err != nil {
|
||||
v.Value.Clear()
|
||||
return err
|
||||
}
|
||||
@@ -150,12 +160,12 @@ func (v *RawValue) UnmarshalJSONV2(in *jsontext.Decoder, opts jsonv2.Options) er
|
||||
|
||||
// MarshalJSON implements [json.Marshaler].
|
||||
func (v RawValue) MarshalJSON() ([]byte, error) {
|
||||
return jsonv2.Marshal(v) // uses MarshalJSONV2
|
||||
return jsonv2.Marshal(v) // uses MarshalJSONTo
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements [json.Unmarshaler].
|
||||
func (v *RawValue) UnmarshalJSON(b []byte) error {
|
||||
return jsonv2.Unmarshal(b, v) // uses UnmarshalJSONV2
|
||||
return jsonv2.Unmarshal(b, v) // uses UnmarshalJSONFrom
|
||||
}
|
||||
|
||||
// RawValues is a map of keyed setting values that can be read from a JSON.
|
||||
|
||||
@@ -147,23 +147,28 @@ type snapshotJSON struct {
|
||||
Settings map[Key]RawItem `json:",omitempty"`
|
||||
}
|
||||
|
||||
// MarshalJSONV2 implements [jsonv2.MarshalerV2].
|
||||
func (s *Snapshot) MarshalJSONV2(out *jsontext.Encoder, opts jsonv2.Options) error {
|
||||
var (
|
||||
_ jsonv2.MarshalerTo = (*Snapshot)(nil)
|
||||
_ jsonv2.UnmarshalerFrom = (*Snapshot)(nil)
|
||||
)
|
||||
|
||||
// MarshalJSONTo implements [jsonv2.MarshalerTo].
|
||||
func (s *Snapshot) MarshalJSONTo(out *jsontext.Encoder) error {
|
||||
data := &snapshotJSON{}
|
||||
if s != nil {
|
||||
data.Summary = s.summary
|
||||
data.Settings = s.m
|
||||
}
|
||||
return jsonv2.MarshalEncode(out, data, opts)
|
||||
return jsonv2.MarshalEncode(out, data)
|
||||
}
|
||||
|
||||
// UnmarshalJSONV2 implements [jsonv2.UnmarshalerV2].
|
||||
func (s *Snapshot) UnmarshalJSONV2(in *jsontext.Decoder, opts jsonv2.Options) error {
|
||||
// UnmarshalJSONFrom implements [jsonv2.UnmarshalerFrom].
|
||||
func (s *Snapshot) UnmarshalJSONFrom(in *jsontext.Decoder) error {
|
||||
if s == nil {
|
||||
return errors.New("s must not be nil")
|
||||
}
|
||||
data := &snapshotJSON{}
|
||||
if err := jsonv2.UnmarshalDecode(in, data, opts); err != nil {
|
||||
if err := jsonv2.UnmarshalDecode(in, data); err != nil {
|
||||
return err
|
||||
}
|
||||
*s = Snapshot{m: data.Settings, sig: deephash.Hash(&data.Settings), summary: data.Summary}
|
||||
@@ -172,12 +177,12 @@ func (s *Snapshot) UnmarshalJSONV2(in *jsontext.Decoder, opts jsonv2.Options) er
|
||||
|
||||
// MarshalJSON implements [json.Marshaler].
|
||||
func (s *Snapshot) MarshalJSON() ([]byte, error) {
|
||||
return jsonv2.Marshal(s) // uses MarshalJSONV2
|
||||
return jsonv2.Marshal(s) // uses MarshalJSONTo
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements [json.Unmarshaler].
|
||||
func (s *Snapshot) UnmarshalJSON(b []byte) error {
|
||||
return jsonv2.Unmarshal(b, s) // uses UnmarshalJSONV2
|
||||
return jsonv2.Unmarshal(b, s) // uses UnmarshalJSONFrom
|
||||
}
|
||||
|
||||
// MergeSnapshots returns a [Snapshot] that contains all [RawItem]s
|
||||
|
||||
@@ -54,24 +54,29 @@ func (s Summary) String() string {
|
||||
return s.data.Scope.String()
|
||||
}
|
||||
|
||||
// MarshalJSONV2 implements [jsonv2.MarshalerV2].
|
||||
func (s Summary) MarshalJSONV2(out *jsontext.Encoder, opts jsonv2.Options) error {
|
||||
return jsonv2.MarshalEncode(out, &s.data, opts)
|
||||
var (
|
||||
_ jsonv2.MarshalerTo = (*Summary)(nil)
|
||||
_ jsonv2.UnmarshalerFrom = (*Summary)(nil)
|
||||
)
|
||||
|
||||
// MarshalJSONTo implements [jsonv2.MarshalerTo].
|
||||
func (s Summary) MarshalJSONTo(out *jsontext.Encoder) error {
|
||||
return jsonv2.MarshalEncode(out, &s.data)
|
||||
}
|
||||
|
||||
// UnmarshalJSONV2 implements [jsonv2.UnmarshalerV2].
|
||||
func (s *Summary) UnmarshalJSONV2(in *jsontext.Decoder, opts jsonv2.Options) error {
|
||||
return jsonv2.UnmarshalDecode(in, &s.data, opts)
|
||||
// UnmarshalJSONFrom implements [jsonv2.UnmarshalerFrom].
|
||||
func (s *Summary) UnmarshalJSONFrom(in *jsontext.Decoder) error {
|
||||
return jsonv2.UnmarshalDecode(in, &s.data)
|
||||
}
|
||||
|
||||
// MarshalJSON implements [json.Marshaler].
|
||||
func (s Summary) MarshalJSON() ([]byte, error) {
|
||||
return jsonv2.Marshal(s) // uses MarshalJSONV2
|
||||
return jsonv2.Marshal(s) // uses MarshalJSONTo
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements [json.Unmarshaler].
|
||||
func (s *Summary) UnmarshalJSON(b []byte) error {
|
||||
return jsonv2.Unmarshal(b, s) // uses UnmarshalJSONV2
|
||||
return jsonv2.Unmarshal(b, s) // uses UnmarshalJSONFrom
|
||||
}
|
||||
|
||||
// SummaryOption is an option that configures [Summary]
|
||||
|
||||
Reference in New Issue
Block a user