Compare commits

...

38 Commits

Author SHA1 Message Date
Patrick O'Doherty
a4e843c1b6 client/web: fix CSRF handler order in web UI
Fix the order of the CSRF handlers (HTTP plaintext context setting,
_then_ enforcement) in the construction of the web UI server. This
resolves false-positive "invalid Origin" 403 exceptions when attempting
to update settings in the web UI.

Add unit test to exercise the CSRF protection failure and success cases
for our web UI configuration.

Updates #14822
Updates #14872

Signed-off-by: Patrick O'Doherty <patrick@tailscale.com>
2025-02-27 11:42:53 -08:00
Joe Tsai
ae303d41dd go.mod: bump github.com/go-json-experiment/json (#15010)
The upstream module has seen significant work making
the v1 emulation layer a high fidelity re-implementation
of v1 "encoding/json".

This addresses several upstream breaking changes:
* MarshalJSONV2 renamed as MarshalJSONTo
* UnmarshalJSONV2 renamed as UnmarshalJSONFrom
* Options argument removed from MarshalJSONV2
* Options argument removed from UnmarshalJSONV2

Updates tailscale/corp#791

Signed-off-by: Joe Tsai <joetsai@digital-static.net>
2025-02-27 11:35:54 -08:00
Irbe Krumina
c174d3c795 scripts/installer.sh: ensure default umask for the installer (#15139)
Ensures default Linux umask 022 for the installer script to
make sure that files created by the installer can be accessed
by other tools, such as apt.

Updates tailscale/tailscale#15133

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2025-02-26 17:02:40 +00:00
James Tucker
820bdb870a maths: add exponentially weighted moving average type
In order to improve latency tracking, we will use an exponentially
weighted moving average that will smooth change over time and suppress
large outlier values.

Updates tailscale/corp#26649

Signed-off-by: James Tucker <james@tailscale.com>
2025-02-25 11:59:19 -08:00
Andrew Lytvynov
d7508b24c6 go.mod: bump golang.org/x/crypto (#15123)
There were two recent CVEs. The one that sorta affects us is
https://groups.google.com/g/golang-announce/c/qN_GDasRQSA (SSH DoS).

Updates #15124

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2025-02-25 08:39:56 -08:00
Brad Fitzpatrick
83c104652d cmd/derper: add --socket flag to change unix socket path to tailscaled
Fixes #10359

Change-Id: Ide49941c486d29856841016686827316878c9433
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2025-02-25 07:06:00 -08:00
Nick Khyl
8d7033fe7f ipn/ipnlocal,util/syspolicy,docs/windows/policy: implement the ReconnectAfter policy setting
In this PR, we update the LocalBackend so that when the ReconnectAfter policy setting is configured
and a user disconnects Tailscale by setting WantRunning to false in the profile prefs, the LocalBackend
will now start a timer to set WantRunning back to true once the ReconnectAfter timer expires.

We also update the ADMX/ADML policy definitions to allow configuring this policy setting for Windows
via Group Policy and Intune.

Updates #14824

Signed-off-by: Nick Khyl <nickk@tailscale.com>
2025-02-24 17:07:19 -06:00
Paul Scott
d1b0e1af06 cmd/testwrapper/flakytest: add Marked to check if in flakytest (#15119)
Updates tailscale/corp#26637

Signed-off-by: Paul Scott <paul@tailscale.com>
2025-02-24 21:26:41 +00:00
Brad Fitzpatrick
781c1e9624 tstest/deptest: add DepChecker.ExtraEnv option for callers to set
For tests (in another repo) that use cgo, we'd like to set CGO_ENABLED=1
explicitly when evaluating cross-compiled deps with "go list".

Updates tailscale/corp#26717
Updates tailscale/corp#26737

Change-Id: Ic21a54379ae91688d2456985068a47e73d04a645
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2025-02-24 12:41:45 -08:00
Brad Fitzpatrick
f5997b3c57 go.toolchain.rev: bump Tailscale Go 1.24 for a Tailscale revert + upstream bump
Diff:
7c08383913

This reverts our previous CGO_ENABLED change: c1d3e9e814

It was causing depaware problems and is no longer necessary it seems? Upstream cmd/go is static nowadays.

And pulls in:

    [release-branch.go1.24] doc/godebug: mention GODEBUG=fips140
    [release-branch.go1.24] cmd/compile: avoid infinite recursion when inlining closures
    [release-branch.go1.24] syscall: don't truncate newly created files on Windows
    [release-branch.go1.24] runtime: fix usleep on s390x/linux
    [release-branch.go1.24] runtime: add some linknames back for `github.com/bytedance/sonic`

Of those, really the only the 2nd and 3rd might affect us.

Updates #15015
Updates tailscale/go#52

Change-Id: I0fa479f8b2d39f43f2dcdff6c28289dbe50b0773
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2025-02-21 13:27:25 -08:00
Will Norris
dcd7cd3c6a client/systray: show message on localapi permission error
When LocalAPI returns an AccessDeniedError, display a message in the
menu and hide or disable most other menu items. This currently includes
a placeholder KB link which I'll update if we end up using something
different.

I debated whether to change the app icon to indicate an error, but opted
not to since there is actually nothing wrong with the client itself and
Tailscale will continue to function normally. It's just that the systray
app itself is in a read-only state.

Updates #1708

Change-Id: Ia101a4a3005adb9118051b3416f5a64a4a45987d
Signed-off-by: Will Norris <will@tailscale.com>
2025-02-20 17:17:38 -08:00
Erisa A
074372d6c5 scripts/installer.sh: add SparkyLinux as a Debian derivative (#15076)
Fixes #15075

Signed-off-by: Erisa A <erisa@tailscale.com>
2025-02-20 18:22:08 +00:00
Andrew Lytvynov
2c3338c46b client/tailscale: fix Client.BuildURL and Client.BuildTailnetURL (#15064)
This method uses `path.Join` to build the URL. Turns out with 1.24 this
started stripping consecutive "/" characters, so "http://..." in baseURL
becomes "http:/...".

Also, `c.Tailnet` is a function that returns `c.tailnet`. Using it as a
path element would encode as a pointer instead of the tailnet name.

Finally, provide a way to prevent escaping of path elements e.g. for `?`
in `acl?details=1`.

Updates #15015

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2025-02-19 17:19:54 -08:00
Brad Fitzpatrick
836c01258d go.toolchain.branch: update to Go 1.24 (#15016)
* go.toolchain.branch: update to Go 1.24

Updates #15015

Change-Id: I29c934ec17e60c3ac3264f30fbbe68fc21422f4d
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>

* cmd/testwrapper: fix for go1.24

Updates #15015

Signed-off-by: Paul Scott <paul@tailscale.com>

* go.mod,Dockerfile: bump to Go 1.24

Also bump golangci-lint to a version that was built with 1.24

Updates #15015

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>

---------

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Signed-off-by: Paul Scott <paul@tailscale.com>
Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
Co-authored-by: Paul Scott <paul@tailscale.com>
Co-authored-by: Andrew Lytvynov <awly@tailscale.com>
2025-02-19 10:55:49 -08:00
Andrew Lytvynov
cc923713f6 tempfork/acme: pull in latest changes for Go 1.24 (#15062)
9a281fd8fa

Updates #15015

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2025-02-19 10:42:06 -08:00
Andrew Lytvynov
323747c3e0 various: disable MPTCP when setting TCP_USER_TIMEOUT sockopt (#15063)
There's nothing about it on
https://github.com/multipath-tcp/mptcp_net-next/issues/ but empirically
MPTCP doesn't support this option on awly's kernel 6.13.2 and in GitHub
actions.

Updates #15015

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2025-02-19 10:41:45 -08:00
Nick Khyl
09982e1918 ipn/ipnlocal: reset always-on override and apply policy settings on start
We already reset the always-on override flag when switching profiles and in a few other cases.
In this PR, we update (*LocalBackend).Start() to reset it as well. This is necessary to support
scenarios where Start() is called explicitly, such as when the GUI starts or when tailscale up is used
with additional flags and passes prefs via ipn.Options in a call to Start() rather than via EditPrefs.

Additionally, we update it to apply policy settings to the current prefs, which is necessary
for properly overriding prefs specified in ipn.Options.

Updates #14823

Signed-off-by: Nick Khyl <nickk@tailscale.com>
2025-02-18 16:49:25 -06:00
Percy Wegmann
1f1a26776b client/tailscale,cmd/k8s-operator,internal/client/tailscale: move VIP service client methods into internal control client
Updates tailscale/corp#22748

Signed-off-by: Percy Wegmann <percy@tailscale.com>
2025-02-18 16:25:17 -06:00
Percy Wegmann
9c731b848b cmd/gitops-pusher: log error details when unable to fetch ACL ETag
This will help debug unexpected issues encountered by consumers of the gitops-pusher.

Updates tailscale/corp#26664

Signed-off-by: Percy Wegmann <percy@tailscale.com>
2025-02-18 14:29:14 -06:00
Andrew Lytvynov
ec5f04b274 appc: fix a deadlock in route advertisements (#15031)
`routeAdvertiser` is the `iplocal.LocalBackend`. Calls to
`Advertise/UnadvertiseRoute` end up calling `EditPrefs` which in turn
calls `authReconfig` which finally calls `readvertiseAppConnectorRoutes`
which calls `AppConnector.DomainRoutes` and gets stuck on a mutex that
was already held when `routeAdvertiser` was called.

Make all calls to `routeAdvertiser` in `app.AppConnector` go through the
execqueue instead as a short-term fix.

Updates tailscale/corp#25965

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
Co-authored-by: Irbe Krumina <irbe@tailscale.com>
2025-02-18 11:31:14 -08:00
Percy Wegmann
052eefbcce tsnet: require I_Acknowledge_This_API_Is_Experimental to use AuthenticatedAPITransport()
It's not entirely clear whether this capability will be maintained, or in what form,
so this serves as a warning to that effect.

Updates tailscale/corp#22748

Signed-off-by: Percy Wegmann <percy@tailscale.com>
2025-02-18 10:23:04 -06:00
Percy Wegmann
9ae9de469a internal/client/tailscale: change Client from alias into wrapper
This will allow Client to be extended with additional functions for internal use.

Updates tailscale/corp#22748

Signed-off-by: Percy Wegmann <percy@tailscale.com>
2025-02-18 10:23:04 -06:00
Percy Wegmann
8a792ab540 tsnet: provide AuthenticatedAPITransport for use with tailscale.com/client/tailscale/v2
This allows use of the officially supported control server API,
authenticated with the tsnet node's nodekey.

Updates tailscale/corp#22748

Signed-off-by: Percy Wegmann <percy@tailscale.com>
2025-02-18 10:23:04 -06:00
Percy Wegmann
4f0222388a cmd,tsnet,internal/client: create internal shim to deprecated control plane API
Even after we remove the deprecated API, we will want to maintain a minimal
API for internal use, in order to avoid importing the external
tailscale.com/client/tailscale/v2 package. This shim exposes only the necessary
parts of the deprecated API for internal use, which gains us the following:

1. It removes deprecation warnings for internal use of the API.
2. It gives us an inventory of which parts we will want to keep for internal use.

Updates tailscale/corp#22748

Signed-off-by: Percy Wegmann <percy@tailscale.com>
2025-02-18 10:23:04 -06:00
Percy Wegmann
d923979e65 client/tailscale: mark control API client deprecated
The official client for 3rd party use is at tailscale.com/client/tailscale/v2.

Updates #22748

Signed-off-by: Percy Wegmann <percy@tailscale.com>
2025-02-18 10:23:04 -06:00
Brad Fitzpatrick
cbf3852b5d cmd/testwrapper: temporarily remove test coverage support
testwrapper doesn't work with Go 1.24 and the coverage support is
making it harder to debug.

Updates #15015
Updates tailscale/corp#26659

Change-Id: I0125e881d08c92f1ecef88b57344f6bbb571b569
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2025-02-17 09:51:23 -08:00
Irbe Krumina
b21eec7621 ipn/ipnlocal,tailcfg: don't send WireIngress if IngressEnabled already true (#14960)
Hostinfo.WireIngress is used as a hint that the node intends to use
funnel. We now send another field, IngressEnabled, in cases where
funnel is explicitly enabled, and the logic control-side has
been changed to look at IngressEnabled as well as WireIngress in all
cases where previously the hint was used - so we can now stop sending
WireIngress when IngressEnabled is true to save some bandwidth.

Updates tailscale/tailscale#11572
Updates tailscale/corp#25931

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2025-02-16 09:38:02 +00:00
James Tucker
606f7ef2c6 net/netcheck: remove unnecessary custom map clone function
Updates #8419
Updates #cleanup

Signed-off-by: James Tucker <james@tailscale.com>
2025-02-14 18:56:10 -08:00
Nick Khyl
6df5c8f32e various: keep tailscale connected when Always On mode is enabled on Windows
In this PR, we enable the registration of LocalBackend extensions to exclude code specific to certain
platforms or environments. We then introduce desktopSessionsExt, which is included only in Windows builds
and only if the ts_omit_desktop_sessions tag is disabled for the build. This extension tracks desktop sessions
and switches to (or remains on) the appropriate profile when a user signs in or out, locks their screen,
or disconnects a remote session.

As desktopSessionsExt requires an ipn/desktop.SessionManager, we register it with tsd.System
for the tailscaled subprocess on Windows.

We also fix a bug in the sessionWatcher implementation where it attempts to close a nil channel on stop.

Updates #14823
Updates tailscale/corp#26247

Signed-off-by: Nick Khyl <nickk@tailscale.com>
2025-02-14 16:40:54 -06:00
Irbe Krumina
e11ff28443 cmd/k8s-operator: allow to optionally configure an HTTP endpoint for the HA Ingress (#14986)
Updates tailscale/corp#24795

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2025-02-14 18:07:17 +00:00
James Sanderson
45f29a208a control/controlclient,tailcfg:types: remove MaxKeyduration from NetMap
This reverts most of 124dc10261 (#10401).

Removing in favour of adding this in CapMaps instead (#14829).

Updates tailscale/corp#16016

Signed-off-by: James Sanderson <jsanderson@tailscale.com>
2025-02-14 18:06:23 +00:00
James Sanderson
717fa68f3a tailcfg: read max key duration from node cap map [capver 114]
This will be used by clients to make better decisions on when to warn users
about impending key expiry.

Updates tailscale/corp#16016

Signed-off-by: James Sanderson <jsanderson@tailscale.com>
2025-02-14 18:06:23 +00:00
kari-ts
4c3c04a413 ipn, tailscale/cli: add TaildropTargetStatus and remove race with FileTargets (#15017)
Introduce new TaildropTargetStatus in PeerStatus
Refactor getTargetStableID to solely rely on Status() instead of calling FileTargets(). This removes a possible race condition between the two calls and provides more detailed failure information if a peer can't receive files.

Updates tailscale/tailscale#14393

Signed-off-by: kari-ts <kari@tailscale.com>
2025-02-14 09:56:50 -08:00
James 'zofrex' Sanderson
e142571397 ipn/ipnlocal: add GetFilterForTest (#15025)
Needed to test full packet filter in e2e tests. See tailscale/corp#26596

Updates tailscale/corp#20514

Signed-off-by: James Sanderson <jsanderson@tailscale.com>
2025-02-14 15:25:48 +00:00
Joe Tsai
1d035db4df types/bools: fix doc typo (#15021)
The Select function was renamed as IfElse.

Updates #cleanup

Signed-off-by: Joe Tsai <joetsai@digital-static.net>
2025-02-13 16:12:00 -08:00
Percy Wegmann
db231107a2 ssh/tailssh: accept passwords and public keys
Some clients don't request 'none' authentication. Instead, they immediately supply
a password or public key. This change allows them to do so, but ignores the supplied
credentials and authenticates using Tailscale instead.

Updates #14922

Signed-off-by: Percy Wegmann <percy@tailscale.com>
2025-02-13 11:29:45 -06:00
James Tucker
f2f7fd12eb go.mod: bump bart
Bart has had some substantial improvements in internal representation,
update functions, and other optimizations to reduce memory usage and
improve runtime performance.

Updates tailscale/corp#26353

Signed-off-by: James Tucker <james@tailscale.com>
2025-02-12 17:52:33 -08:00
Nick Khyl
7aef4fd44d ipn/ipn{local,server}: extract logic that determines the "best" Tailscale profile to use
In this PR, we further refactor LocalBackend and Unattended Mode to extract the logic that determines
which profile should be used at the time of the check, such as when a LocalAPI client connects or disconnects.
We then update (*LocalBackend).switchProfileLockedOnEntry to to switch to the profile returned by
(*LocalBackend).resolveBestProfileLocked() rather than to the caller-specified specified profile, and rename it
to switchToBestProfileLockedOnEntry.

This is done in preparation for updating (*LocalBackend).getBackgroundProfileIDLocked to support Always-On
mode by determining which profile to use based on which users, if any, are currently logged in and have an active
foreground desktop session.

Updates #14823
Updates tailscale/corp#26247

Signed-off-by: Nick Khyl <nickk@tailscale.com>
2025-02-12 19:06:40 -06:00
93 changed files with 2886 additions and 1120 deletions

View File

@@ -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

View File

@@ -64,7 +64,6 @@ jobs:
matrix:
include:
- goarch: amd64
coverflags: "-coverprofile=/tmp/coverage.out"
- goarch: amd64
buildflags: "-race"
shard: '1/3'
@@ -119,15 +118,10 @@ jobs:
- name: build test wrapper
run: ./tool/go build -o /tmp/testwrapper ./cmd/testwrapper
- name: test all
run: NOBASHDEBUG=true PATH=$PWD/tool:$PATH /tmp/testwrapper ${{matrix.coverflags}} ./... ${{matrix.buildflags}}
run: NOBASHDEBUG=true PATH=$PWD/tool:$PATH /tmp/testwrapper ./... ${{matrix.buildflags}}
env:
GOARCH: ${{ matrix.goarch }}
TS_TEST_SHARD: ${{ matrix.shard }}
- name: Publish to coveralls.io
if: matrix.coverflags != '' # only publish results if we've tracked coverage
uses: shogo82148/actions-goveralls@v1
with:
path-to-profile: /tmp/coverage.out
- name: bench all
run: ./tool/go test ${{matrix.buildflags}} -bench=. -benchtime=1x -run=^$ $(for x in $(git grep -l "^func Benchmark" | xargs dirname | sort | uniq); do echo "./$x"; done)
env:

View File

@@ -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

View File

@@ -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 {

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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", "")

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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
}

View File

@@ -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

View File

@@ -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

View File

@@ -5,8 +5,10 @@
// Package tailscale contains a Go client for the Tailscale control plane API.
//
// Warning: this package is in development and makes no API compatibility
// promises as of 2022-04-29. It is subject to change at any time.
// This package is only intended for internal and transitional use.
//
// Deprecated: the official control plane client is available at
// tailscale.com/client/tailscale/v2.
package tailscale
import (
@@ -15,13 +17,12 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"path"
)
// I_Acknowledge_This_API_Is_Unstable must be set true to use this package
// for now. It was added 2022-04-29 when it was moved to this git repo
// and will be removed when the public API has settled.
//
// TODO(bradfitz): remove this after the we're happy with the public API.
// for now. This package is being replaced by tailscale.com/client/tailscale/v2.
var I_Acknowledge_This_API_Is_Unstable = false
// TODO: use url.PathEscape() for deviceID and tailnets when constructing requests.
@@ -35,6 +36,8 @@ const maxReadSize = 10 << 20
//
// Use NewClient to instantiate one. Exported fields should be set before
// the client is used and not changed thereafter.
//
// Deprecated: use tailscale.com/client/tailscale/v2 instead.
type Client struct {
// tailnet is the globally unique identifier for a Tailscale network, such
// as "example.com" or "user@gmail.com".
@@ -62,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
@@ -97,6 +140,8 @@ func (c *Client) setAuth(r *http.Request) {
// If httpClient is nil, then http.DefaultClient is used.
// "api.tailscale.com" is set as the BaseURL for the returned client
// and can be changed manually by the user.
//
// Deprecated: use tailscale.com/client/tailscale/v2 instead.
func NewClient(tailnet string, auth AuthMethod) *Client {
return &Client{
tailnet: tailnet,
@@ -147,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

View 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)
}
})
}
}

View File

@@ -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 {

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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{

View File

@@ -16,14 +16,10 @@ import (
"strings"
"golang.org/x/oauth2/clientcredentials"
"tailscale.com/client/tailscale"
"tailscale.com/internal/client/tailscale"
)
func main() {
// Required to use our client API. We're fine with the instability since the
// client lives in the same repo as this code.
tailscale.I_Acknowledge_This_API_Is_Unstable = true
reusable := flag.Bool("reusable", false, "allocate a reusable authkey")
ephemeral := flag.Bool("ephemeral", false, "allocate an ephemeral authkey")
preauth := flag.Bool("preauth", true, "set the authkey as pre-authorized")

View File

@@ -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

View File

@@ -81,7 +81,6 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
L github.com/aws/smithy-go/transport/http/internal/io from github.com/aws/smithy-go/transport/http
L github.com/aws/smithy-go/waiter from github.com/aws/aws-sdk-go-v2/service/ssm
github.com/beorn7/perks/quantile from github.com/prometheus/client_golang/prometheus
github.com/bits-and-blooms/bitset from github.com/gaissmai/bart
💣 github.com/cespare/xxhash/v2 from github.com/prometheus/client_golang/prometheus
L github.com/coreos/go-iptables/iptables from tailscale.com/util/linuxfw
💣 github.com/davecgh/go-spew/spew from k8s.io/apimachinery/pkg/util/dump
@@ -99,6 +98,8 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
💣 github.com/fsnotify/fsnotify from sigs.k8s.io/controller-runtime/pkg/certwatcher
github.com/fxamacker/cbor/v2 from tailscale.com/tka+
github.com/gaissmai/bart from tailscale.com/net/ipset+
github.com/gaissmai/bart/internal/bitset from github.com/gaissmai/bart+
github.com/gaissmai/bart/internal/sparse from github.com/gaissmai/bart
github.com/go-json-experiment/json from tailscale.com/types/opt+
github.com/go-json-experiment/json/internal from github.com/go-json-experiment/json/internal/jsonflags+
github.com/go-json-experiment/json/internal/jsonflags from github.com/go-json-experiment/json/internal/jsonopts+
@@ -810,9 +811,11 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
tailscale.com/health from tailscale.com/control/controlclient+
tailscale.com/health/healthmsg from tailscale.com/ipn/ipnlocal
tailscale.com/hostinfo from tailscale.com/client/web+
tailscale.com/internal/client/tailscale from tailscale.com/cmd/k8s-operator
tailscale.com/internal/noiseconn from tailscale.com/control/controlclient
tailscale.com/ipn from tailscale.com/client/local+
tailscale.com/ipn/conffile from tailscale.com/ipn/ipnlocal+
💣 tailscale.com/ipn/desktop from tailscale.com/ipn/ipnlocal+
💣 tailscale.com/ipn/ipnauth from tailscale.com/ipn/ipnlocal+
tailscale.com/ipn/ipnlocal from tailscale.com/ipn/localapi+
tailscale.com/ipn/ipnstate from tailscale.com/client/local+
@@ -994,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+
@@ -1052,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+
@@ -1061,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+
@@ -1089,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+
@@ -1109,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+
@@ -1121,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+
@@ -1148,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+
@@ -1188,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+
@@ -1199,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
@@ -1220,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

View File

@@ -21,7 +21,7 @@ import (
"sigs.k8s.io/controller-runtime/pkg/client"
logf "sigs.k8s.io/controller-runtime/pkg/log"
kzap "sigs.k8s.io/controller-runtime/pkg/log/zap"
"tailscale.com/client/tailscale"
"tailscale.com/internal/client/tailscale"
)
const (
@@ -64,7 +64,6 @@ func TestMain(m *testing.M) {
func runTests(m *testing.M) (int, error) {
zlog := kzap.NewRaw([]kzap.Opts{kzap.UseDevMode(true), kzap.Level(zapcore.DebugLevel)}...).Sugar()
logf.SetLogger(zapr.NewLogger(zlog.Desugar()))
tailscale.I_Acknowledge_This_API_Is_Unstable = true
if clientID := os.Getenv("TS_API_CLIENT_ID"); clientID != "" {
cleanup, err := setupClientAndACLs()

View File

@@ -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"
@@ -46,6 +46,9 @@ const (
FinalizerNamePG = "tailscale.com/ingress-pg-finalizer"
indexIngressProxyGroup = ".metadata.annotations.ingress-proxy-group"
// annotationHTTPEndpoint can be used to configure the Ingress to expose an HTTP endpoint to tailnet (as
// well as the default HTTPS endpoint).
annotationHTTPEndpoint = "tailscale.com/http-endpoint"
)
var gaugePGIngressResources = clientmetric.NewGauge(kubetypes.MetricIngressPGResourceCount)
@@ -183,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.
@@ -202,16 +205,16 @@ func (a *IngressPGReconciler) maybeProvision(ctx context.Context, hostname strin
// 3. Ensure that the serve config for the ProxyGroup contains the VIPService
cm, cfg, err := a.proxyGroupServeConfig(ctx, pgName)
if err != nil {
return fmt.Errorf("error getting ingress serve config: %w", err)
return fmt.Errorf("error getting Ingress serve config: %w", err)
}
if cm == nil {
logger.Infof("no ingress serve config ConfigMap found, unable to update serve config. Ensure that ProxyGroup is healthy.")
logger.Infof("no Ingress serve config ConfigMap found, unable to update serve config. Ensure that ProxyGroup is healthy.")
return nil
}
ep := ipn.HostPort(fmt.Sprintf("%s:443", dnsName))
handlers, err := handlersForIngress(ctx, ing, a.Client, a.recorder, dnsName, logger)
if err != nil {
return fmt.Errorf("failed to get handlers for ingress: %w", err)
return fmt.Errorf("failed to get handlers for Ingress: %w", err)
}
ingCfg := &ipn.ServiceConfig{
TCP: map[uint16]*ipn.TCPPortHandler{
@@ -225,6 +228,19 @@ func (a *IngressPGReconciler) maybeProvision(ctx context.Context, hostname strin
},
},
}
// Add HTTP endpoint if configured.
if isHTTPEndpointEnabled(ing) {
logger.Infof("exposing Ingress over HTTP")
epHTTP := ipn.HostPort(fmt.Sprintf("%s:80", dnsName))
ingCfg.TCP[80] = &ipn.TCPPortHandler{
HTTP: true,
}
ingCfg.Web[epHTTP] = &ipn.WebServerConfig{
Handlers: handlers,
}
}
var gotCfg *ipn.ServiceConfig
if cfg != nil && cfg.Services != nil {
gotCfg = cfg.Services[serviceName]
@@ -248,18 +264,25 @@ func (a *IngressPGReconciler) maybeProvision(ctx context.Context, hostname strin
tags = strings.Split(tstr, ",")
}
vipSvc := &VIPService{
vipPorts := []string{"443"} // always 443 for Ingress
if isHTTPEndpointEnabled(ing) {
vipPorts = append(vipPorts, "80")
}
vipSvc := &tailscale.VIPService{
Name: serviceName,
Tags: tags,
Ports: []string{"443"}, // always 443 for Ingress
Ports: vipPorts,
Comment: fmt.Sprintf(VIPSvcOwnerRef, ing.UID),
}
if existingVIPSvc != nil {
vipSvc.Addrs = existingVIPSvc.Addrs
}
if existingVIPSvc == nil || !reflect.DeepEqual(vipSvc.Tags, existingVIPSvc.Tags) {
if existingVIPSvc == nil ||
!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)
}
@@ -267,16 +290,22 @@ func (a *IngressPGReconciler) maybeProvision(ctx context.Context, hostname strin
// 5. Update Ingress status
oldStatus := ing.Status.DeepCopy()
// TODO(irbekrm): once we have ingress ProxyGroup, we can determine if instances are ready to route traffic to the VIPService
ports := []networkingv1.IngressPortStatus{
{
Protocol: "TCP",
Port: 443,
},
}
if isHTTPEndpointEnabled(ing) {
ports = append(ports, networkingv1.IngressPortStatus{
Protocol: "TCP",
Port: 80,
})
}
ing.Status.LoadBalancer.Ingress = []networkingv1.IngressLoadBalancerIngress{
{
Hostname: dnsName,
Ports: []networkingv1.IngressPortStatus{
{
Protocol: "TCP",
Port: 443,
},
},
Ports: ports,
},
}
if apiequality.Semantic.DeepEqual(oldStatus, ing.Status) {
@@ -332,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)
@@ -480,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 {
@@ -492,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
}
@@ -564,8 +593,16 @@ 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
}
// isHTTPEndpointEnabled returns true if the Ingress has been configured to expose an HTTP endpoint to tailnet.
func isHTTPEndpointEnabled(ing *networkingv1.Ingress) bool {
if ing == nil {
return false
}
return ing.Annotations[annotationHTTPEndpoint] == "enabled"
}

View File

@@ -8,6 +8,8 @@ package main
import (
"context"
"encoding/json"
"maps"
"reflect"
"testing"
"slices"
@@ -18,81 +20,18 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/tools/record"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
"tailscale.com/tailcfg"
"tailscale.com/types/ptr"
)
func TestIngressPGReconciler(t *testing.T) {
tsIngressClass := &networkingv1.IngressClass{
ObjectMeta: metav1.ObjectMeta{Name: "tailscale"},
Spec: networkingv1.IngressClassSpec{Controller: "tailscale.com/ts-ingress"},
}
ingPGR, fc, ft := setupIngressTest(t)
// Pre-create the ProxyGroup
pg := &tsapi.ProxyGroup{
ObjectMeta: metav1.ObjectMeta{
Name: "test-pg",
Generation: 1,
},
Spec: tsapi.ProxyGroupSpec{
Type: tsapi.ProxyGroupTypeIngress,
},
}
// Pre-create the ConfigMap for the ProxyGroup
pgConfigMap := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-pg-ingress-config",
Namespace: "operator-ns",
},
BinaryData: map[string][]byte{
"serve-config.json": []byte(`{"Services":{}}`),
},
}
fc := fake.NewClientBuilder().
WithScheme(tsapi.GlobalScheme).
WithObjects(pg, pgConfigMap, tsIngressClass).
WithStatusSubresource(pg).
Build()
mustUpdateStatus(t, fc, "", pg.Name, func(pg *tsapi.ProxyGroup) {
pg.Status.Conditions = []metav1.Condition{
{
Type: string(tsapi.ProxyGroupReady),
Status: metav1.ConditionTrue,
ObservedGeneration: 1,
},
}
})
ft := &fakeTSClient{}
fakeTsnetServer := &fakeTSNetServer{certDomains: []string{"foo.com"}}
zl, err := zap.NewDevelopment()
if err != nil {
t.Fatal(err)
}
lc := &fakeLocalClient{
status: &ipnstate.Status{
CurrentTailnet: &ipnstate.TailnetStatus{
MagicDNSSuffix: "ts.net",
},
},
}
ingPGR := &IngressPGReconciler{
Client: fc,
tsClient: ft,
tsnetServer: fakeTsnetServer,
defaultTags: []string{"tag:k8s"},
tsNamespace: "operator-ns",
logger: zl.Sugar(),
recorder: record.NewFakeRecorder(10),
lc: lc,
}
// Test 1: Default tags
ing := &networkingv1.Ingress{
TypeMeta: metav1.TypeMeta{Kind: "Ingress", APIVersion: "networking.k8s.io/v1"},
ObjectMeta: metav1.ObjectMeta{
@@ -122,8 +61,74 @@ func TestIngressPGReconciler(t *testing.T) {
// Verify initial reconciliation
expectReconciled(t, ingPGR, "default", "test-ingress")
verifyServeConfig(t, fc, "svc:my-svc", false)
verifyVIPService(t, ft, "svc:my-svc", []string{"443"})
// Get and verify the ConfigMap was updated
mustUpdate(t, fc, "default", "test-ingress", func(ing *networkingv1.Ingress) {
ing.Annotations["tailscale.com/tags"] = "tag:custom,tag:test"
})
expectReconciled(t, ingPGR, "default", "test-ingress")
// Verify VIPService uses custom tags
vipSvc, err := ft.GetVIPService(context.Background(), "svc:my-svc")
if err != nil {
t.Fatalf("getting VIPService: %v", err)
}
if vipSvc == nil {
t.Fatal("VIPService not created")
}
wantTags := []string{"tag:custom", "tag:test"} // custom tags only
gotTags := slices.Clone(vipSvc.Tags)
slices.Sort(gotTags)
slices.Sort(wantTags)
if !slices.Equal(gotTags, wantTags) {
t.Errorf("incorrect VIPService tags: got %v, want %v", gotTags, wantTags)
}
// Create second Ingress
ing2 := &networkingv1.Ingress{
TypeMeta: metav1.TypeMeta{Kind: "Ingress", APIVersion: "networking.k8s.io/v1"},
ObjectMeta: metav1.ObjectMeta{
Name: "my-other-ingress",
Namespace: "default",
UID: types.UID("5678-UID"),
Annotations: map[string]string{
"tailscale.com/proxy-group": "test-pg",
},
},
Spec: networkingv1.IngressSpec{
IngressClassName: ptr.To("tailscale"),
DefaultBackend: &networkingv1.IngressBackend{
Service: &networkingv1.IngressServiceBackend{
Name: "test",
Port: networkingv1.ServiceBackendPort{
Number: 8080,
},
},
},
TLS: []networkingv1.IngressTLS{
{Hosts: []string{"my-other-svc.tailnetxyz.ts.net"}},
},
},
}
mustCreate(t, fc, ing2)
// Verify second Ingress reconciliation
expectReconciled(t, ingPGR, "default", "my-other-ingress")
verifyServeConfig(t, fc, "svc:my-other-svc", false)
verifyVIPService(t, ft, "svc:my-other-svc", []string{"443"})
// Verify first Ingress is still working
verifyServeConfig(t, fc, "svc:my-svc", false)
verifyVIPService(t, ft, "svc:my-svc", []string{"443"})
// Delete second Ingress
if err := fc.Delete(context.Background(), ing2); err != nil {
t.Fatalf("deleting second Ingress: %v", err)
}
expectReconciled(t, ingPGR, "default", "my-other-ingress")
// Verify second Ingress cleanup
cm := &corev1.ConfigMap{}
if err := fc.Get(context.Background(), types.NamespacedName{
Name: "test-pg-ingress-config",
@@ -137,46 +142,16 @@ func TestIngressPGReconciler(t *testing.T) {
t.Fatalf("unmarshaling serve config: %v", err)
}
// Verify first Ingress is still configured
if cfg.Services["svc:my-svc"] == nil {
t.Error("expected serve config to contain VIPService configuration")
t.Error("first Ingress service config was incorrectly removed")
}
// Verify second Ingress was cleaned up
if cfg.Services["svc:my-other-svc"] != nil {
t.Error("second Ingress service config was not cleaned up")
}
// Verify VIPService uses default tags
vipSvc, err := ft.getVIPService(context.Background(), "svc:my-svc")
if err != nil {
t.Fatalf("getting VIPService: %v", err)
}
if vipSvc == nil {
t.Fatal("VIPService not created")
}
wantTags := []string{"tag:k8s"} // default tags
if !slices.Equal(vipSvc.Tags, wantTags) {
t.Errorf("incorrect VIPService tags: got %v, want %v", vipSvc.Tags, wantTags)
}
// Test 2: Custom tags
mustUpdate(t, fc, "default", "test-ingress", func(ing *networkingv1.Ingress) {
ing.Annotations["tailscale.com/tags"] = "tag:custom,tag:test"
})
expectReconciled(t, ingPGR, "default", "test-ingress")
// Verify VIPService uses custom tags
vipSvc, err = ft.getVIPService(context.Background(), "svc:my-svc")
if err != nil {
t.Fatalf("getting VIPService: %v", err)
}
if vipSvc == nil {
t.Fatal("VIPService not created")
}
wantTags = []string{"tag:custom", "tag:test"} // custom tags only
gotTags := slices.Clone(vipSvc.Tags)
slices.Sort(gotTags)
slices.Sort(wantTags)
if !slices.Equal(gotTags, wantTags) {
t.Errorf("incorrect VIPService tags: got %v, want %v", gotTags, wantTags)
}
// Delete the Ingress and verify cleanup
// Delete the first Ingress and verify cleanup
if err := fc.Delete(context.Background(), ing); err != nil {
t.Fatalf("deleting Ingress: %v", err)
}
@@ -335,3 +310,233 @@ func TestValidateIngress(t *testing.T) {
})
}
}
func TestIngressPGReconciler_HTTPEndpoint(t *testing.T) {
ingPGR, fc, ft := setupIngressTest(t)
// Create test Ingress with HTTP endpoint enabled
ing := &networkingv1.Ingress{
TypeMeta: metav1.TypeMeta{Kind: "Ingress", APIVersion: "networking.k8s.io/v1"},
ObjectMeta: metav1.ObjectMeta{
Name: "test-ingress",
Namespace: "default",
UID: types.UID("1234-UID"),
Annotations: map[string]string{
"tailscale.com/proxy-group": "test-pg",
"tailscale.com/http-endpoint": "enabled",
},
},
Spec: networkingv1.IngressSpec{
IngressClassName: ptr.To("tailscale"),
DefaultBackend: &networkingv1.IngressBackend{
Service: &networkingv1.IngressServiceBackend{
Name: "test",
Port: networkingv1.ServiceBackendPort{
Number: 8080,
},
},
},
TLS: []networkingv1.IngressTLS{
{Hosts: []string{"my-svc"}},
},
},
}
if err := fc.Create(context.Background(), ing); err != nil {
t.Fatal(err)
}
// Verify initial reconciliation with HTTP enabled
expectReconciled(t, ingPGR, "default", "test-ingress")
verifyVIPService(t, ft, "svc:my-svc", []string{"80", "443"})
verifyServeConfig(t, fc, "svc:my-svc", true)
// Verify Ingress status
ing = &networkingv1.Ingress{}
if err := fc.Get(context.Background(), types.NamespacedName{
Name: "test-ingress",
Namespace: "default",
}, ing); err != nil {
t.Fatal(err)
}
wantStatus := []networkingv1.IngressPortStatus{
{Port: 443, Protocol: "TCP"},
{Port: 80, Protocol: "TCP"},
}
if !reflect.DeepEqual(ing.Status.LoadBalancer.Ingress[0].Ports, wantStatus) {
t.Errorf("incorrect status ports: got %v, want %v",
ing.Status.LoadBalancer.Ingress[0].Ports, wantStatus)
}
// Remove HTTP endpoint annotation
mustUpdate(t, fc, "default", "test-ingress", func(ing *networkingv1.Ingress) {
delete(ing.Annotations, "tailscale.com/http-endpoint")
})
// Verify reconciliation after removing HTTP
expectReconciled(t, ingPGR, "default", "test-ingress")
verifyVIPService(t, ft, "svc:my-svc", []string{"443"})
verifyServeConfig(t, fc, "svc:my-svc", false)
// Verify Ingress status
ing = &networkingv1.Ingress{}
if err := fc.Get(context.Background(), types.NamespacedName{
Name: "test-ingress",
Namespace: "default",
}, ing); err != nil {
t.Fatal(err)
}
wantStatus = []networkingv1.IngressPortStatus{
{Port: 443, Protocol: "TCP"},
}
if !reflect.DeepEqual(ing.Status.LoadBalancer.Ingress[0].Ports, wantStatus) {
t.Errorf("incorrect status ports: got %v, want %v",
ing.Status.LoadBalancer.Ingress[0].Ports, wantStatus)
}
}
func verifyVIPService(t *testing.T, ft *fakeTSClient, serviceName string, wantPorts []string) {
t.Helper()
vipSvc, err := ft.GetVIPService(context.Background(), tailcfg.ServiceName(serviceName))
if err != nil {
t.Fatalf("getting VIPService %q: %v", serviceName, err)
}
if vipSvc == nil {
t.Fatalf("VIPService %q not created", serviceName)
}
gotPorts := slices.Clone(vipSvc.Ports)
slices.Sort(gotPorts)
slices.Sort(wantPorts)
if !slices.Equal(gotPorts, wantPorts) {
t.Errorf("incorrect ports for VIPService %q: got %v, want %v", serviceName, gotPorts, wantPorts)
}
}
func verifyServeConfig(t *testing.T, fc client.Client, serviceName string, wantHTTP bool) {
t.Helper()
cm := &corev1.ConfigMap{}
if err := fc.Get(context.Background(), types.NamespacedName{
Name: "test-pg-ingress-config",
Namespace: "operator-ns",
}, cm); err != nil {
t.Fatalf("getting ConfigMap: %v", err)
}
cfg := &ipn.ServeConfig{}
if err := json.Unmarshal(cm.BinaryData["serve-config.json"], cfg); err != nil {
t.Fatalf("unmarshaling serve config: %v", err)
}
t.Logf("Looking for service %q in config: %+v", serviceName, cfg)
svc := cfg.Services[tailcfg.ServiceName(serviceName)]
if svc == nil {
t.Fatalf("service %q not found in serve config, services: %+v", serviceName, maps.Keys(cfg.Services))
}
wantHandlers := 1
if wantHTTP {
wantHandlers = 2
}
// Check TCP handlers
if len(svc.TCP) != wantHandlers {
t.Errorf("incorrect number of TCP handlers for service %q: got %d, want %d", serviceName, len(svc.TCP), wantHandlers)
}
if wantHTTP {
if h, ok := svc.TCP[uint16(80)]; !ok {
t.Errorf("HTTP (port 80) handler not found for service %q", serviceName)
} else if !h.HTTP {
t.Errorf("HTTP not enabled for port 80 handler for service %q", serviceName)
}
}
if h, ok := svc.TCP[uint16(443)]; !ok {
t.Errorf("HTTPS (port 443) handler not found for service %q", serviceName)
} else if !h.HTTPS {
t.Errorf("HTTPS not enabled for port 443 handler for service %q", serviceName)
}
// Check Web handlers
if len(svc.Web) != wantHandlers {
t.Errorf("incorrect number of Web handlers for service %q: got %d, want %d", serviceName, len(svc.Web), wantHandlers)
}
}
func setupIngressTest(t *testing.T) (*IngressPGReconciler, client.Client, *fakeTSClient) {
t.Helper()
tsIngressClass := &networkingv1.IngressClass{
ObjectMeta: metav1.ObjectMeta{Name: "tailscale"},
Spec: networkingv1.IngressClassSpec{Controller: "tailscale.com/ts-ingress"},
}
// Pre-create the ProxyGroup
pg := &tsapi.ProxyGroup{
ObjectMeta: metav1.ObjectMeta{
Name: "test-pg",
Generation: 1,
},
Spec: tsapi.ProxyGroupSpec{
Type: tsapi.ProxyGroupTypeIngress,
},
}
// Pre-create the ConfigMap for the ProxyGroup
pgConfigMap := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-pg-ingress-config",
Namespace: "operator-ns",
},
BinaryData: map[string][]byte{
"serve-config.json": []byte(`{"Services":{}}`),
},
}
fc := fake.NewClientBuilder().
WithScheme(tsapi.GlobalScheme).
WithObjects(pg, pgConfigMap, tsIngressClass).
WithStatusSubresource(pg).
Build()
// Set ProxyGroup status to ready
pg.Status.Conditions = []metav1.Condition{
{
Type: string(tsapi.ProxyGroupReady),
Status: metav1.ConditionTrue,
ObservedGeneration: 1,
},
}
if err := fc.Status().Update(context.Background(), pg); err != nil {
t.Fatal(err)
}
ft := &fakeTSClient{}
fakeTsnetServer := &fakeTSNetServer{certDomains: []string{"foo.com"}}
zl, err := zap.NewDevelopment()
if err != nil {
t.Fatal(err)
}
lc := &fakeLocalClient{
status: &ipnstate.Status{
CurrentTailnet: &ipnstate.TailnetStatus{
MagicDNSSuffix: "ts.net",
},
},
}
ingPGR := &IngressPGReconciler{
Client: fc,
tsClient: ft,
tsnetServer: fakeTsnetServer,
defaultTags: []string{"tag:k8s"},
tsNamespace: "operator-ns",
logger: zl.Sugar(),
recorder: record.NewFakeRecorder(10),
lc: lc,
}
return ingPGR, fc, ft
}

View File

@@ -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 {

View File

@@ -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/client/tailscale"
"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
}

View File

@@ -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

View File

@@ -28,6 +28,7 @@ import (
"tailscale.com/client/tailscale/apitype"
"tailscale.com/cmd/tailscale/cli/ffcomplete"
"tailscale.com/envknob"
"tailscale.com/ipn/ipnstate"
"tailscale.com/net/tsaddr"
"tailscale.com/syncs"
"tailscale.com/tailcfg"
@@ -268,46 +269,77 @@ func getTargetStableID(ctx context.Context, ipStr string) (id tailcfg.StableNode
if err != nil {
return "", false, err
}
fts, err := localClient.FileTargets(ctx)
if err != nil {
return "", false, err
}
for _, ft := range fts {
n := ft.Node
for _, a := range n.Addresses {
if a.Addr() != ip {
continue
}
isOffline = n.Online != nil && !*n.Online
return n.StableID, isOffline, nil
}
}
return "", false, fileTargetErrorDetail(ctx, ip)
}
// fileTargetErrorDetail returns a non-nil error saying why ip is an
// invalid file sharing target.
func fileTargetErrorDetail(ctx context.Context, ip netip.Addr) error {
found := false
if st, err := localClient.Status(ctx); err == nil && st.Self != nil {
for _, peer := range st.Peer {
for _, pip := range peer.TailscaleIPs {
if pip == ip {
found = true
if peer.UserID != st.Self.UserID {
return errors.New("owned by different user; can only send files to your own devices")
}
}
st, err := localClient.Status(ctx)
if err != nil {
// This likely means tailscaled is unreachable or returned an error on /localapi/v0/status.
return "", false, fmt.Errorf("failed to get local status: %w", err)
}
if st == nil {
// Handle the case if the daemon returns nil with no error.
return "", false, errors.New("no status available")
}
if st.Self == nil {
// We have a status structure, but it doesnt include Self info. Probably not connected.
return "", false, errors.New("local node is not configured or missing Self information")
}
// Find the PeerStatus that corresponds to ip.
var foundPeer *ipnstate.PeerStatus
peerLoop:
for _, ps := range st.Peer {
for _, pip := range ps.TailscaleIPs {
if pip == ip {
foundPeer = ps
break peerLoop
}
}
}
if found {
return errors.New("target seems to be running an old Tailscale version")
// If we didnt find a matching peer at all:
if foundPeer == nil {
if !tsaddr.IsTailscaleIP(ip) {
return "", false, fmt.Errorf("unknown target; %v is not a Tailscale IP address", ip)
}
return "", false, errors.New("unknown target; not in your Tailnet")
}
if !tsaddr.IsTailscaleIP(ip) {
return fmt.Errorf("unknown target; %v is not a Tailscale IP address", ip)
// We found a peer. Decide whether we can send files to it:
isOffline = !foundPeer.Online
switch foundPeer.TaildropTarget {
case ipnstate.TaildropTargetAvailable:
return foundPeer.ID, isOffline, nil
case ipnstate.TaildropTargetNoNetmapAvailable:
return "", isOffline, errors.New("cannot send files: no netmap available on this node")
case ipnstate.TaildropTargetIpnStateNotRunning:
return "", isOffline, errors.New("cannot send files: local Tailscale is not connected to the tailnet")
case ipnstate.TaildropTargetMissingCap:
return "", isOffline, errors.New("cannot send files: missing required Taildrop capability")
case ipnstate.TaildropTargetOffline:
return "", isOffline, errors.New("cannot send files: peer is offline")
case ipnstate.TaildropTargetNoPeerInfo:
return "", isOffline, errors.New("cannot send files: invalid or unrecognized peer")
case ipnstate.TaildropTargetUnsupportedOS:
return "", isOffline, errors.New("cannot send files: target's OS does not support Taildrop")
case ipnstate.TaildropTargetNoPeerAPI:
return "", isOffline, errors.New("cannot send files: target is not advertising a file sharing API")
case ipnstate.TaildropTargetOwnedByOtherUser:
return "", isOffline, errors.New("cannot send files: peer is owned by a different user")
case ipnstate.TaildropTargetUnknown:
fallthrough
default:
return "", isOffline, fmt.Errorf("cannot send files: unknown or indeterminate reason")
}
return errors.New("unknown target; not in your Tailnet")
}
const maxSniff = 4 << 20

View File

@@ -27,8 +27,8 @@ import (
"github.com/peterbourgon/ff/v3/ffcli"
qrcode "github.com/skip2/go-qrcode"
"golang.org/x/oauth2/clientcredentials"
"tailscale.com/client/tailscale"
"tailscale.com/health/healthmsg"
"tailscale.com/internal/client/tailscale"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
"tailscale.com/net/netutil"
@@ -1097,12 +1097,6 @@ func exitNodeIP(p *ipn.Prefs, st *ipnstate.Status) (ip netip.Addr) {
return
}
func init() {
// Required to use our client API. We're fine with the instability since the
// client lives in the same repo as this code.
tailscale.I_Acknowledge_This_API_Is_Unstable = true
}
// resolveAuthKey either returns v unchanged (in the common case) or, if it
// starts with "tskey-client-" (as Tailscale OAuth secrets do) parses it like
//

View File

@@ -93,6 +93,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
tailscale.com/health from tailscale.com/net/tlsdial+
tailscale.com/health/healthmsg from tailscale.com/cmd/tailscale/cli
tailscale.com/hostinfo from tailscale.com/client/web+
tailscale.com/internal/client/tailscale from tailscale.com/cmd/tailscale/cli
tailscale.com/internal/noiseconn from tailscale.com/cmd/tailscale/cli
tailscale.com/ipn from tailscale.com/client/local+
tailscale.com/ipn/ipnstate from tailscale.com/client/local+
@@ -194,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+
@@ -245,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
@@ -254,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+
@@ -306,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+
@@ -331,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+
@@ -368,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+
@@ -379,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+
@@ -397,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

View File

@@ -81,7 +81,6 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
L github.com/aws/smithy-go/transport/http from github.com/aws/aws-sdk-go-v2/aws/middleware+
L github.com/aws/smithy-go/transport/http/internal/io from github.com/aws/smithy-go/transport/http
L github.com/aws/smithy-go/waiter from github.com/aws/aws-sdk-go-v2/service/ssm
github.com/bits-and-blooms/bitset from github.com/gaissmai/bart
L github.com/coreos/go-iptables/iptables from tailscale.com/util/linuxfw
LD 💣 github.com/creack/pty from tailscale.com/ssh/tailssh
W 💣 github.com/dblohm7/wingoes from github.com/dblohm7/wingoes/com+
@@ -93,6 +92,8 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
💣 github.com/djherbis/times from tailscale.com/drive/driveimpl
github.com/fxamacker/cbor/v2 from tailscale.com/tka
github.com/gaissmai/bart from tailscale.com/net/tstun+
github.com/gaissmai/bart/internal/bitset from github.com/gaissmai/bart+
github.com/gaissmai/bart/internal/sparse from github.com/gaissmai/bart
github.com/go-json-experiment/json from tailscale.com/types/opt+
github.com/go-json-experiment/json/internal from github.com/go-json-experiment/json/internal/jsonflags+
github.com/go-json-experiment/json/internal/jsonflags from github.com/go-json-experiment/json/internal/jsonopts+
@@ -271,6 +272,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/internal/noiseconn from tailscale.com/control/controlclient
tailscale.com/ipn from tailscale.com/client/local+
tailscale.com/ipn/conffile from tailscale.com/cmd/tailscaled+
💣 tailscale.com/ipn/desktop from tailscale.com/cmd/tailscaled+
💣 tailscale.com/ipn/ipnauth from tailscale.com/ipn/ipnlocal+
tailscale.com/ipn/ipnlocal from tailscale.com/cmd/tailscaled+
tailscale.com/ipn/ipnserver from tailscale.com/cmd/tailscaled
@@ -447,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+
@@ -502,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+
@@ -511,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+
@@ -560,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+
@@ -586,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+
@@ -624,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+
@@ -635,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+
@@ -655,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

View File

@@ -44,6 +44,7 @@ import (
"golang.zx2c4.com/wireguard/windows/tunnel/winipcfg"
"tailscale.com/drive/driveimpl"
"tailscale.com/envknob"
"tailscale.com/ipn/desktop"
"tailscale.com/logpolicy"
"tailscale.com/logtail/backoff"
"tailscale.com/net/dns"
@@ -335,6 +336,13 @@ func beWindowsSubprocess() bool {
sys.Set(driveimpl.NewFileSystemForRemote(log.Printf))
if sessionManager, err := desktop.NewSessionManager(log.Printf); err == nil {
sys.Set(sessionManager)
} else {
// Errors creating the session manager are unexpected, but not fatal.
log.Printf("[unexpected]: error creating a desktop session manager: %v", err)
}
publicLogID, _ := logid.ParsePublicID(logID)
err = startIPNServer(ctx, log.Printf, publicLogID, sys)
if err != nil {

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -10,6 +10,7 @@ package main
import (
"bufio"
"bytes"
"cmp"
"context"
"encoding/json"
"errors"
@@ -22,13 +23,7 @@ import (
"sort"
"strings"
"time"
"unicode"
"github.com/dave/courtney/scanner"
"github.com/dave/courtney/shared"
"github.com/dave/courtney/tester"
"github.com/dave/patsy"
"github.com/dave/patsy/vos"
"tailscale.com/cmd/testwrapper/flakytest"
"tailscale.com/util/slicesx"
)
@@ -65,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") != ""
@@ -117,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,
}
}
@@ -221,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
@@ -238,30 +238,6 @@ func main() {
fmt.Printf("%s\t%s\t%.3fs\n", outcome, pkg, runtime.Seconds())
}
// Check for -coverprofile argument and filter it out
combinedCoverageFilename := ""
filteredGoTestArgs := make([]string, 0, len(goTestArgs))
preceededByCoverProfile := false
for _, arg := range goTestArgs {
if arg == "-coverprofile" {
preceededByCoverProfile = true
} else if preceededByCoverProfile {
combinedCoverageFilename = strings.TrimSpace(arg)
preceededByCoverProfile = false
} else {
filteredGoTestArgs = append(filteredGoTestArgs, arg)
}
}
goTestArgs = filteredGoTestArgs
runningWithCoverage := combinedCoverageFilename != ""
if runningWithCoverage {
fmt.Printf("Will log coverage to %v\n", combinedCoverageFilename)
}
// Keep track of all test coverage files. With each retry, we'll end up
// with additional coverage files that will be combined when we finish.
coverageFiles := make([]string, 0)
for len(toRun) > 0 {
var thisRun *nextRun
thisRun, toRun = toRun[0], toRun[1:]
@@ -275,27 +251,13 @@ func main() {
fmt.Printf("\n\nAttempt #%d: Retrying flaky tests:\n\nflakytest failures JSON: %s\n\n", thisRun.attempt, j)
}
goTestArgsWithCoverage := testArgs
if runningWithCoverage {
coverageFile := fmt.Sprintf("/tmp/coverage_%d.out", thisRun.attempt)
coverageFiles = append(coverageFiles, coverageFile)
goTestArgsWithCoverage = make([]string, len(goTestArgs), len(goTestArgs)+2)
copy(goTestArgsWithCoverage, goTestArgs)
goTestArgsWithCoverage = append(
goTestArgsWithCoverage,
fmt.Sprintf("-coverprofile=%v", coverageFile),
"-covermode=set",
"-coverpkg=./...",
)
}
toRetry := make(map[string][]*testAttempt) // pkg -> tests to retry
for _, pt := range thisRun.tests {
ch := make(chan *testAttempt)
runErr := make(chan error, 1)
go func() {
defer close(runErr)
runErr <- runTests(ctx, thisRun.attempt, pt, goTestArgsWithCoverage, testArgs, ch)
runErr <- runTests(ctx, thisRun.attempt, pt, goTestArgs, testArgs, ch)
}()
var failed bool
@@ -314,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
}
@@ -372,107 +335,4 @@ func main() {
}
toRun = append(toRun, nextRun)
}
if runningWithCoverage {
intermediateCoverageFilename := "/tmp/coverage.out_intermediate"
if err := combineCoverageFiles(intermediateCoverageFilename, coverageFiles); err != nil {
fmt.Printf("error combining coverage files: %v\n", err)
os.Exit(2)
}
if err := processCoverageWithCourtney(intermediateCoverageFilename, combinedCoverageFilename, testArgs); err != nil {
fmt.Printf("error processing coverage with courtney: %v\n", err)
os.Exit(3)
}
fmt.Printf("Wrote combined coverage to %v\n", combinedCoverageFilename)
}
}
func combineCoverageFiles(intermediateCoverageFilename string, coverageFiles []string) error {
combinedCoverageFile, err := os.OpenFile(intermediateCoverageFilename, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
if err != nil {
return fmt.Errorf("create /tmp/coverage.out: %w", err)
}
defer combinedCoverageFile.Close()
w := bufio.NewWriter(combinedCoverageFile)
defer w.Flush()
for fileNumber, coverageFile := range coverageFiles {
f, err := os.Open(coverageFile)
if err != nil {
return fmt.Errorf("open %v: %w", coverageFile, err)
}
defer f.Close()
in := bufio.NewReader(f)
line := 0
for {
r, _, err := in.ReadRune()
if err != nil {
if err != io.EOF {
return fmt.Errorf("read %v: %w", coverageFile, err)
}
break
}
// On all but the first coverage file, skip the coverage file header
if fileNumber > 0 && line == 0 {
continue
}
if r == '\n' {
line++
}
// filter for only printable characters because coverage file sometimes includes junk on 2nd line
if unicode.IsPrint(r) || r == '\n' {
if _, err := w.WriteRune(r); err != nil {
return fmt.Errorf("write %v: %w", combinedCoverageFile.Name(), err)
}
}
}
}
return nil
}
// processCoverageWithCourtney post-processes code coverage to exclude less
// meaningful sections like 'if err != nil { return err}', as well as
// anything marked with a '// notest' comment.
//
// instead of running the courtney as a separate program, this embeds
// courtney for easier integration.
func processCoverageWithCourtney(intermediateCoverageFilename, combinedCoverageFilename string, testArgs []string) error {
env := vos.Os()
setup := &shared.Setup{
Env: vos.Os(),
Paths: patsy.NewCache(env),
TestArgs: testArgs,
Load: intermediateCoverageFilename,
Output: combinedCoverageFilename,
}
if err := setup.Parse(testArgs); err != nil {
return fmt.Errorf("parse args: %w", err)
}
s := scanner.New(setup)
if err := s.LoadProgram(); err != nil {
return fmt.Errorf("load program: %w", err)
}
if err := s.ScanPackages(); err != nil {
return fmt.Errorf("scan packages: %w", err)
}
t := tester.New(setup)
if err := t.Load(); err != nil {
return fmt.Errorf("load: %w", err)
}
if err := t.ProcessExcludes(s.Excludes); err != nil {
return fmt.Errorf("process excludes: %w", err)
}
if err := t.Save(); err != nil {
return fmt.Errorf("save: %w", err)
}
return nil
}

View File

@@ -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() {

View File

@@ -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) {

View File

@@ -89,7 +89,6 @@ type mapSession struct {
lastPopBrowserURL string
lastTKAInfo *tailcfg.TKAInfo
lastNetmapSummary string // from NetworkMap.VeryConcise
lastMaxExpiry time.Duration
}
// newMapSession returns a mostly unconfigured new mapSession.
@@ -384,9 +383,6 @@ func (ms *mapSession) updateStateFromResponse(resp *tailcfg.MapResponse) {
if resp.TKAInfo != nil {
ms.lastTKAInfo = resp.TKAInfo
}
if resp.MaxKeyDuration > 0 {
ms.lastMaxExpiry = resp.MaxKeyDuration
}
}
var (
@@ -819,7 +815,6 @@ func (ms *mapSession) netmap() *netmap.NetworkMap {
DERPMap: ms.lastDERPMap,
ControlHealth: ms.lastHealth,
TKAEnabled: ms.lastTKAInfo != nil && !ms.lastTKAInfo.Disabled,
MaxKeyDuration: ms.lastMaxExpiry,
}
if ms.lastTKAInfo != nil && ms.lastTKAInfo.Head != "" {

View File

@@ -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)
}

View File

@@ -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>

View File

@@ -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" />

15
go.mod
View File

@@ -1,6 +1,6 @@
module tailscale.com
go 1.23.1
go 1.24.0
require (
filippo.io/mkcert v1.4.4
@@ -21,8 +21,6 @@ require (
github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf
github.com/creack/pty v1.1.23
github.com/dave/courtney v0.4.0
github.com/dave/patsy v0.0.0-20210517141501-957256f50cba
github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa
github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e
github.com/distribution/reference v0.6.0
@@ -33,8 +31,8 @@ require (
github.com/fogleman/gg v1.3.0
github.com/frankban/quicktest v1.14.6
github.com/fxamacker/cbor/v2 v2.7.0
github.com/gaissmai/bart v0.11.1
github.com/go-json-experiment/json v0.0.0-20250103232110-6a9a0fde9288
github.com/gaissmai/bart v0.18.0
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
@@ -76,7 +74,7 @@ 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
@@ -95,7 +93,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
@@ -129,15 +127,12 @@ require (
github.com/OpenPeeDeeP/depguard/v2 v2.2.0 // indirect
github.com/alecthomas/go-check-sumtype v0.1.4 // indirect
github.com/alexkohler/nakedret/v2 v2.0.4 // indirect
github.com/bits-and-blooms/bitset v1.13.0 // indirect
github.com/bombsimon/wsl/v4 v4.2.1 // indirect
github.com/butuzov/mirror v1.1.0 // indirect
github.com/catenacyber/perfsprint v0.7.1 // indirect
github.com/ccojocar/zxcvbn-go v1.0.2 // indirect
github.com/ckaznocha/intrange v0.1.0 // indirect
github.com/cyphar/filepath-securejoin v0.3.6 // indirect
github.com/dave/astrid v0.0.0-20170323122508-8c2895878b14 // indirect
github.com/dave/brenda v1.1.0 // indirect
github.com/docker/go-connections v0.5.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect

26
go.sum
View File

@@ -167,8 +167,6 @@ github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bits-and-blooms/bitset v1.13.0 h1:bAQ9OPNFYbGHV6Nez0tmNI0RiEu7/hxlYJRUA0wFAVE=
github.com/bits-and-blooms/bitset v1.13.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/bkielbasa/cyclop v1.2.1 h1:AeF71HZDob1P2/pRm1so9cd1alZnrpyc4q2uP2l0gJY=
github.com/bkielbasa/cyclop v1.2.1/go.mod h1:K/dT/M0FPAiYjBgQGau7tz+3TMh4FWAEqlMhzFWCrgM=
github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb h1:m935MPodAbYS46DG4pJSv7WO+VECIWUQ7OJYSoTrMh4=
@@ -242,14 +240,6 @@ github.com/cyphar/filepath-securejoin v0.3.6 h1:4d9N5ykBnSp5Xn2JkhocYDkOpURL/18C
github.com/cyphar/filepath-securejoin v0.3.6/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
github.com/daixiang0/gci v0.12.3 h1:yOZI7VAxAGPQmkb1eqt5g/11SUlwoat1fSblGLmdiQc=
github.com/daixiang0/gci v0.12.3/go.mod h1:xtHP9N7AHdNvtRNfcx9gwTDfw7FRJx4bZUsiEfiNNAI=
github.com/dave/astrid v0.0.0-20170323122508-8c2895878b14 h1:YI1gOOdmMk3xodBao7fehcvoZsEeOyy/cfhlpCSPgM4=
github.com/dave/astrid v0.0.0-20170323122508-8c2895878b14/go.mod h1:Sth2QfxfATb/nW4EsrSi2KyJmbcniZ8TgTaji17D6ms=
github.com/dave/brenda v1.1.0 h1:Sl1LlwXnbw7xMhq3y2x11McFu43AjDcwkllxxgZ3EZw=
github.com/dave/brenda v1.1.0/go.mod h1:4wCUr6gSlu5/1Tk7akE5X7UorwiQ8Rij0SKH3/BGMOM=
github.com/dave/courtney v0.4.0 h1:Vb8hi+k3O0h5++BR96FIcX0x3NovRbnhGd/dRr8inBk=
github.com/dave/courtney v0.4.0/go.mod h1:3WSU3yaloZXYAxRuWt8oRyVb9SaRiMBt5Kz/2J227tM=
github.com/dave/patsy v0.0.0-20210517141501-957256f50cba h1:1o36L4EKbZzazMk8iGC4kXpVnZ6TPxR2mZ9qVKjNNAs=
github.com/dave/patsy v0.0.0-20210517141501-957256f50cba/go.mod h1:qfR88CgEGLoiqDaE+xxDCi5QA5v4vUoW0UCX2Nd5Tlc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
@@ -316,8 +306,8 @@ github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
github.com/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo=
github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA=
github.com/gaissmai/bart v0.11.1 h1:5Uv5XwsaFBRo4E5VBcb9TzY8B7zxFf+U7isDxqOrRfc=
github.com/gaissmai/bart v0.11.1/go.mod h1:KHeYECXQiBjTzQz/om2tqn3sZF1J7hw9m6z41ftj3fg=
github.com/gaissmai/bart v0.18.0 h1:jQLBT/RduJu0pv/tLwXE+xKPgtWJejbxuXAR+wLJafo=
github.com/gaissmai/bart v0.18.0/go.mod h1:JJzMAhNF5Rjo4SF4jWBrANuJfqY+FvsFhW7t1UZJ+XY=
github.com/ghostiam/protogetter v0.3.5 h1:+f7UiF8XNd4w3a//4DnusQ2SZjPkUjxkMEfjbxOK4Ug=
github.com/ghostiam/protogetter v0.3.5/go.mod h1:7lpeDnEJ1ZjL/YtyoN99ljO4z0pd3H0d18/t2dPBxHw=
github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I=
@@ -337,8 +327,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=
@@ -910,8 +900,8 @@ 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=
@@ -1051,8 +1041,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=

View File

@@ -1 +1 @@
tailscale.go1.23
tailscale.go1.24

View File

@@ -1 +1 @@
65c3f5f3fc9d96f56a37a79cad4ebbd7ff985801
2b494987ff3c1a6a26e10570c490394ff0a77aa4

View File

@@ -0,0 +1,83 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package tailscale provides a minimal control plane API client for internal
// use. A full client for 3rd party use is available at
// tailscale.com/client/tailscale/v2. The internal client is provided to avoid
// having to import that whole package.
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
}
// AuthMethod is an alias to tailscale.com/client/tailscale.
type AuthMethod = tsclient.AuthMethod
// Device is an alias to tailscale.com/client/tailscale.
type Device = tsclient.Device
// DeviceFieldsOpts is an alias to tailscale.com/client/tailscale.
type DeviceFieldsOpts = tsclient.DeviceFieldsOpts
// Key is an alias to tailscale.com/client/tailscale.
type Key = tsclient.Key
// KeyCapabilities is an alias to tailscale.com/client/tailscale.
type KeyCapabilities = tsclient.KeyCapabilities
// KeyDeviceCapabilities is an alias to tailscale.com/client/tailscale.
type KeyDeviceCapabilities = tsclient.KeyDeviceCapabilities
// KeyDeviceCreateCapabilities is an alias to tailscale.com/client/tailscale.
type KeyDeviceCreateCapabilities = tsclient.KeyDeviceCreateCapabilities
// ErrResponse is an alias to tailscale.com/client/tailscale.
type ErrResponse = tsclient.ErrResponse
// NewClient is an alias to tailscale.com/client/tailscale.
func NewClient(tailnet string, auth AuthMethod) *Client {
return &Client{
Client: tsclient.NewClient(tailnet, auth),
}
}
// Client is a wrapper of tailscale.com/client/tailscale.
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
}

View 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
}

View File

@@ -359,7 +359,7 @@ func (sw *sessionWatcher) Start() error {
sw.doneCh = make(chan error, 1)
startedCh := make(chan error, 1)
go sw.run(startedCh)
go sw.run(startedCh, sw.doneCh)
if err := <-startedCh; err != nil {
return err
}
@@ -372,11 +372,11 @@ func (sw *sessionWatcher) Start() error {
return nil
}
func (sw *sessionWatcher) run(started chan<- error) {
func (sw *sessionWatcher) run(started, done chan<- error) {
runtime.LockOSThread()
defer func() {
runtime.UnlockOSThread()
close(sw.doneCh)
close(done)
}()
err := sw.createMessageWindow()
started <- err

View File

@@ -0,0 +1,178 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Both the desktop session manager and multi-user support
// are currently available only on Windows.
// This file does not need to be built for other platforms.
//go:build windows && !ts_omit_desktop_sessions
package ipnlocal
import (
"cmp"
"errors"
"fmt"
"sync"
"tailscale.com/feature"
"tailscale.com/ipn"
"tailscale.com/ipn/desktop"
"tailscale.com/tsd"
"tailscale.com/types/logger"
"tailscale.com/util/syspolicy"
)
func init() {
feature.Register("desktop-sessions")
RegisterExtension("desktop-sessions", newDesktopSessionsExt)
}
// desktopSessionsExt implements [localBackendExtension].
var _ localBackendExtension = (*desktopSessionsExt)(nil)
// desktopSessionsExt extends [LocalBackend] with desktop session management.
// It keeps Tailscale running in the background if Always-On mode is enabled,
// and switches to an appropriate profile when a user signs in or out,
// locks their screen, or disconnects a remote session.
type desktopSessionsExt struct {
logf logger.Logf
sm desktop.SessionManager
*LocalBackend // or nil, until Init is called
cleanup []func() // cleanup functions to call on shutdown
// mu protects all following fields.
// When both mu and [LocalBackend.mu] need to be taken,
// [LocalBackend.mu] must be taken before mu.
mu sync.Mutex
id2sess map[desktop.SessionID]*desktop.Session
}
// newDesktopSessionsExt returns a new [desktopSessionsExt],
// or an error if [desktop.SessionManager] is not available.
func newDesktopSessionsExt(logf logger.Logf, sys *tsd.System) (localBackendExtension, error) {
sm, ok := sys.SessionManager.GetOK()
if !ok {
return nil, errors.New("session manager is not available")
}
return &desktopSessionsExt{logf: logf, sm: sm, id2sess: make(map[desktop.SessionID]*desktop.Session)}, nil
}
// Init implements [localBackendExtension].
func (e *desktopSessionsExt) Init(lb *LocalBackend) (err error) {
e.LocalBackend = lb
unregisterResolver := lb.RegisterBackgroundProfileResolver(e.getBackgroundProfile)
unregisterSessionCb, err := e.sm.RegisterStateCallback(e.updateDesktopSessionState)
if err != nil {
unregisterResolver()
return fmt.Errorf("session callback registration failed: %w", err)
}
e.cleanup = []func(){unregisterResolver, unregisterSessionCb}
return nil
}
// updateDesktopSessionState is a [desktop.SessionStateCallback]
// invoked by [desktop.SessionManager] once for each existing session
// and whenever the session state changes. It updates the session map
// and switches to the best profile if necessary.
func (e *desktopSessionsExt) updateDesktopSessionState(session *desktop.Session) {
e.mu.Lock()
if session.Status != desktop.ClosedSession {
e.id2sess[session.ID] = session
} else {
delete(e.id2sess, session.ID)
}
e.mu.Unlock()
var action string
switch session.Status {
case desktop.ForegroundSession:
// The user has either signed in or unlocked their session.
// For remote sessions, this may also mean the user has connected.
// The distinction isn't important for our purposes,
// so let's always say "signed in".
action = "signed in to"
case desktop.BackgroundSession:
action = "locked"
case desktop.ClosedSession:
action = "signed out from"
default:
panic("unreachable")
}
maybeUsername, _ := session.User.Username()
userIdentifier := cmp.Or(maybeUsername, string(session.User.UserID()), "user")
reason := fmt.Sprintf("%s %s session %v", userIdentifier, action, session.ID)
e.SwitchToBestProfile(reason)
}
// getBackgroundProfile is a [profileResolver] that works as follows:
//
// If Always-On mode is disabled, it returns no profile ("","",false).
//
// If AlwaysOn mode is enabled, it returns the current profile unless:
// - The current user has signed out.
// - Another user has a foreground (i.e. active/unlocked) session.
//
// If the current user's session runs in the background and no other user
// has a foreground session, it returns the current profile. This applies
// when a locally signed-in user locks their screen or when a remote user
// disconnects without signing out.
//
// In all other cases, it returns no profile ("","",false).
//
// It is called with [LocalBackend.mu] locked.
func (e *desktopSessionsExt) getBackgroundProfile() (_ ipn.WindowsUserID, _ ipn.ProfileID, ok bool) {
e.mu.Lock()
defer e.mu.Unlock()
if alwaysOn, _ := syspolicy.GetBoolean(syspolicy.AlwaysOn, false); !alwaysOn {
return "", "", false
}
isCurrentUserSingedIn := false
var foregroundUIDs []ipn.WindowsUserID
for _, s := range e.id2sess {
switch uid := s.User.UserID(); uid {
case e.pm.CurrentUserID():
isCurrentUserSingedIn = true
if s.Status == desktop.ForegroundSession {
// Keep the current profile if the user has a foreground session.
return e.pm.CurrentUserID(), e.pm.CurrentProfile().ID(), true
}
default:
if s.Status == desktop.ForegroundSession {
foregroundUIDs = append(foregroundUIDs, uid)
}
}
}
// If there's no current user (e.g., tailscaled just started), or if the current
// user has no foreground session, switch to the default profile of the first user
// with a foreground session, if any.
for _, uid := range foregroundUIDs {
if profileID := e.pm.DefaultUserProfileID(uid); profileID != "" {
return uid, profileID, true
}
}
// If no user has a foreground session but the current user is still signed in,
// keep the current profile even if the session is not in the foreground,
// such as when the screen is locked or a remote session is disconnected.
if len(foregroundUIDs) == 0 && isCurrentUserSingedIn {
return e.pm.CurrentUserID(), e.pm.CurrentProfile().ID(), true
}
return "", "", false
}
// Shutdown implements [localBackendExtension].
func (e *desktopSessionsExt) Shutdown() error {
for _, f := range e.cleanup {
f()
}
e.cleanup = nil
e.LocalBackend = nil
return nil
}

View File

@@ -168,6 +168,49 @@ type watchSession struct {
cancel context.CancelFunc // to shut down the session
}
// localBackendExtension extends [LocalBackend] with additional functionality.
type localBackendExtension interface {
// Init is called to initialize the extension when the [LocalBackend] is created
// and before it starts running. If the extension cannot be initialized,
// it must return an error, and the Shutdown method will not be called.
// Any returned errors are not fatal; they are used for logging.
// TODO(nickkhyl): should we allow returning a fatal error?
Init(*LocalBackend) error
// Shutdown is called when the [LocalBackend] is shutting down,
// if the extension was initialized. Any returned errors are not fatal;
// they are used for logging.
Shutdown() error
}
// newLocalBackendExtension is a function that instantiates a [localBackendExtension].
type newLocalBackendExtension func(logger.Logf, *tsd.System) (localBackendExtension, error)
// registeredExtensions is a map of registered local backend extensions,
// where the key is the name of the extension and the value is the function
// that instantiates the extension.
var registeredExtensions map[string]newLocalBackendExtension
// RegisterExtension registers a function that creates a [localBackendExtension].
// It panics if newExt is nil or if an extension with the same name has already been registered.
func RegisterExtension(name string, newExt newLocalBackendExtension) {
if newExt == nil {
panic(fmt.Sprintf("lb: newExt is nil: %q", name))
}
if _, ok := registeredExtensions[name]; ok {
panic(fmt.Sprintf("lb: duplicate extensions: %q", name))
}
mak.Set(&registeredExtensions, name, newExt)
}
// profileResolver is any function that returns user and profile IDs
// along with a flag indicating whether it succeeded. Since an empty
// profile ID ("") represents an empty profile, the ok return parameter
// distinguishes between an empty profile and no profile.
//
// It is called with [LocalBackend.mu] held.
type profileResolver func() (_ ipn.WindowsUserID, _ ipn.ProfileID, ok bool)
// LocalBackend is the glue between the major pieces of the Tailscale
// network software: the cloud control plane (via controlclient), the
// network data plane (via wgengine), and the user-facing UIs and CLIs
@@ -302,8 +345,12 @@ type LocalBackend struct {
directFileRoot string
componentLogUntil map[string]componentLogState
// c2nUpdateStatus is the status of c2n-triggered client update.
c2nUpdateStatus updateStatus
currentUser ipnauth.Actor
c2nUpdateStatus updateStatus
currentUser ipnauth.Actor
// backgroundProfileResolvers are optional background profile resolvers.
backgroundProfileResolvers set.HandleSet[profileResolver]
selfUpdateProgress []ipnstate.UpdateProgress
lastSelfUpdateState ipnstate.SelfUpdateStatus
// capForcedNetfilter is the netfilter that control instructs Linux clients
@@ -394,6 +441,15 @@ type LocalBackend struct {
// and the user has disconnected with a reason.
// 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.
shutdownCbs set.HandleSet[func() error]
}
// HealthTracker returns the health tracker for the backend.
@@ -575,6 +631,19 @@ func NewLocalBackend(logf logger.Logf, logID logid.PublicID, sys *tsd.System, lo
}
}
for name, newFn := range registeredExtensions {
ext, err := newFn(logf, sys)
if err != nil {
b.logf("lb: failed to create %q extension: %v", name, err)
continue
}
if err := ext.Init(b); err != nil {
b.logf("lb: failed to initialize %q extension: %v", name, err)
continue
}
b.shutdownCbs.Add(ext.Shutdown)
}
return b, nil
}
@@ -1005,6 +1074,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)
@@ -1033,9 +1104,17 @@ func (b *LocalBackend) Shutdown() {
if b.notifyCancel != nil {
b.notifyCancel()
}
shutdownCbs := slices.Collect(maps.Values(b.shutdownCbs))
b.shutdownCbs = nil
b.mu.Unlock()
b.webClientShutdown()
for _, cb := range shutdownCbs {
if err := cb(); err != nil {
b.logf("shutdown callback failed: %v", err)
}
}
if b.sockstatLogger != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -1256,6 +1335,7 @@ func (b *LocalBackend) populatePeerStatusLocked(sb *ipnstate.StatusBuilder) {
SSH_HostKeys: p.Hostinfo().SSH_HostKeys().AsSlice(),
Location: p.Hostinfo().Location().AsStruct(),
Capabilities: p.Capabilities().AsSlice(),
TaildropTarget: b.taildropTargetStatus(p),
}
if cm := p.CapMap(); cm.Len() > 0 {
ps.CapMap = make(tailcfg.NodeCapMap, cm.Len())
@@ -1438,6 +1518,10 @@ func (b *LocalBackend) peerCapsLocked(src netip.Addr) tailcfg.PeerCapMap {
return nil
}
func (b *LocalBackend) GetFilterForTest() *filter.Filter {
return b.filterAtomic.Load()
}
// SetControlClientStatus is the callback invoked by the control client whenever it posts a new status.
// Among other things, this is where we update the netmap, packet filters, DNS and DERP maps.
func (b *LocalBackend) SetControlClientStatus(c controlclient.Client, st controlclient.Status) {
@@ -2263,12 +2347,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 {
@@ -3795,14 +3887,15 @@ func (b *LocalBackend) shouldUploadServices() bool {
//
// On non-multi-user systems, the actor should be set to nil.
func (b *LocalBackend) SetCurrentUser(actor ipnauth.Actor) {
var uid ipn.WindowsUserID
if actor != nil {
uid = actor.UserID()
}
unlock := b.lockAndGetUnlock()
defer unlock()
var userIdentifier string
if user := cmp.Or(actor, b.currentUser); user != nil {
maybeUsername, _ := user.Username()
userIdentifier = cmp.Or(maybeUsername, string(user.UserID()))
}
if actor != b.currentUser {
if c, ok := b.currentUser.(ipnauth.ActorCloser); ok {
c.Close()
@@ -3810,50 +3903,146 @@ func (b *LocalBackend) SetCurrentUser(actor ipnauth.Actor) {
b.currentUser = actor
}
if b.pm.CurrentUserID() == uid {
return
}
var profileID ipn.ProfileID
if actor != nil {
profileID = b.pm.DefaultUserProfileID(uid)
} else if uid, profileID = b.getBackgroundProfileIDLocked(); profileID != "" {
b.logf("client disconnected; staying alive in server mode")
var action string
if actor == nil {
action = "disconnected"
} else {
b.logf("client disconnected; stopping server")
}
if err := b.switchProfileLockedOnEntry(uid, profileID, unlock); err != nil {
b.logf("failed switching profile to %q: %v", profileID, err)
action = "connected"
}
reason := fmt.Sprintf("client %s (%s)", action, userIdentifier)
b.switchToBestProfileLockedOnEntry(reason, unlock)
}
// switchProfileLockedOnEntry is like [LocalBackend.SwitchProfile],
// but b.mu must held on entry, but it is released on exit.
func (b *LocalBackend) switchProfileLockedOnEntry(uid ipn.WindowsUserID, profileID ipn.ProfileID, unlock unlockOnce) error {
// SwitchToBestProfile selects the best profile to use,
// as reported by [LocalBackend.resolveBestProfileLocked], and switches
// to it, unless it's already the current profile. The reason indicates
// why the profile is being switched, such as due to a client connecting
// or disconnecting, or a change in the desktop session state, and is used
// for logging.
func (b *LocalBackend) SwitchToBestProfile(reason string) {
b.switchToBestProfileLockedOnEntry(reason, b.lockAndGetUnlock())
}
// switchToBestProfileLockedOnEntry is like [LocalBackend.SwitchToBestProfile],
// but b.mu must held on entry. It is released on exit.
func (b *LocalBackend) switchToBestProfileLockedOnEntry(reason string, unlock unlockOnce) {
defer unlock()
if b.pm.CurrentUserID() == uid && b.pm.CurrentProfile().ID() == profileID {
return nil
}
oldControlURL := b.pm.CurrentPrefs().ControlURLOrDefault()
if changed := b.pm.SetCurrentUserAndProfile(uid, profileID); !changed {
return nil
uid, profileID, background := b.resolveBestProfileLocked()
cp, switched := b.pm.SetCurrentUserAndProfile(uid, profileID)
switch {
case !switched && cp.ID() == "":
b.logf("%s: staying on empty profile", reason)
case !switched:
b.logf("%s: staying on profile %q (%s)", reason, cp.UserProfile().LoginName, cp.ID())
case cp.ID() == "":
b.logf("%s: disconnecting Tailscale", reason)
case background:
b.logf("%s: switching to background profile %q (%s)", reason, cp.UserProfile().LoginName, cp.ID())
default:
b.logf("%s: switching to profile %q (%s)", reason, cp.UserProfile().LoginName, cp.ID())
}
if !switched {
return
}
// As an optimization, only reset the dialPlan if the control URL changed.
if newControlURL := b.pm.CurrentPrefs().ControlURLOrDefault(); oldControlURL != newControlURL {
b.resetDialPlan()
}
return b.resetForProfileChangeLockedOnEntry(unlock)
if err := b.resetForProfileChangeLockedOnEntry(unlock); err != nil {
// TODO(nickkhyl): The actual reset cannot fail. However,
// the TKA initialization or [LocalBackend.Start] can fail.
// These errors are not critical as far as we're concerned.
// But maybe we should post a notification to the API watchers?
b.logf("failed switching profile to %q: %v", profileID, err)
}
}
// getBackgroundProfileIDLocked returns the profile ID to use when no GUI/CLI
// client is connected, or "" if Tailscale should not run in the background.
// resolveBestProfileLocked returns the best profile to use based on the current
// state of the backend, such as whether a GUI/CLI client is connected, whether
// the unattended mode is enabled, the current state of the desktop sessions,
// and other factors.
//
// It returns the user ID, profile ID, and whether the returned profile is
// considered a background profile. A background profile is used when no OS user
// is actively using Tailscale, such as when no GUI/CLI client is connected
// and Unattended Mode is enabled (see also [LocalBackend.getBackgroundProfileLocked]).
// An empty profile ID indicates that Tailscale should switch to an empty profile.
//
// b.mu must be held.
func (b *LocalBackend) resolveBestProfileLocked() (userID ipn.WindowsUserID, profileID ipn.ProfileID, isBackground bool) {
// If a GUI/CLI client is connected, use the connected user's profile, which means
// either the current profile if owned by the user, or their default profile.
if b.currentUser != nil {
cp := b.pm.CurrentProfile()
uid := b.currentUser.UserID()
var profileID ipn.ProfileID
// TODO(nickkhyl): check if the current profile is allowed on the device,
// such as when [syspolicy.Tailnet] policy setting requires a specific Tailnet.
// See tailscale/corp#26249.
if cp.LocalUserID() == uid {
profileID = cp.ID()
} else {
profileID = b.pm.DefaultUserProfileID(uid)
}
return uid, profileID, false
}
// Otherwise, if on Windows, use the background profile if one is set.
// This includes staying on the current profile if Unattended Mode is enabled
// or if AlwaysOn mode is enabled and the current user is still signed in.
// If the returned background profileID is "", Tailscale will disconnect
// and remain idle until a GUI or CLI client connects.
if goos := envknob.GOOS(); goos == "windows" {
uid, profileID := b.getBackgroundProfileLocked()
return uid, profileID, true
}
// On other platforms, however, Tailscale continues to run in the background
// using the current profile.
//
// TODO(nickkhyl): check if the current profile is allowed on the device,
// such as when [syspolicy.Tailnet] policy setting requires a specific Tailnet.
// See tailscale/corp#26249.
return b.pm.CurrentUserID(), b.pm.CurrentProfile().ID(), false
}
// RegisterBackgroundProfileResolver registers a function to be used when
// resolving the background profile, until the returned unregister function is called.
func (b *LocalBackend) RegisterBackgroundProfileResolver(resolver profileResolver) (unregister func()) {
// TODO(nickkhyl): should we allow specifying some kind of priority/altitude for the resolver?
b.mu.Lock()
defer b.mu.Unlock()
handle := b.backgroundProfileResolvers.Add(resolver)
return func() {
b.mu.Lock()
defer b.mu.Unlock()
delete(b.backgroundProfileResolvers, handle)
}
}
// getBackgroundProfileLocked returns the user and profile ID to use when no GUI/CLI
// client is connected, or "","" if Tailscale should not run in the background.
// As of 2025-02-07, it is only used on Windows.
func (b *LocalBackend) getBackgroundProfileIDLocked() (ipn.WindowsUserID, ipn.ProfileID) {
func (b *LocalBackend) getBackgroundProfileLocked() (ipn.WindowsUserID, ipn.ProfileID) {
// TODO(nickkhyl): check if the returned profile is allowed on the device,
// such as when [syspolicy.Tailnet] policy setting requires a specific Tailnet.
// See tailscale/corp#26249.
// If Unattended Mode is enabled for the current profile, keep using it.
if b.pm.CurrentPrefs().ForceDaemon() {
return b.pm.CurrentProfile().LocalUserID(), b.pm.CurrentProfile().ID()
}
// Otherwise, attempt to resolve the background profile using the background
// profile resolvers available on the current platform.
for _, resolver := range b.backgroundProfileResolvers {
if uid, profileID, ok := resolver(); ok {
return uid, profileID
}
}
// Otherwise, switch to an empty profile and disconnect Tailscale
// until a GUI or CLI client connects.
return "", ""
@@ -4114,15 +4303,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.
@@ -4197,6 +4446,12 @@ func (b *LocalBackend) hasIngressEnabledLocked() bool {
return b.serveConfig.Valid() && b.serveConfig.IsFunnelOn()
}
// shouldWireInactiveIngressLocked reports whether the node is in a state where funnel is not actively enabled, but it
// seems that it is intended to be used with funnel.
func (b *LocalBackend) shouldWireInactiveIngressLocked() bool {
return b.serveConfig.Valid() && !b.hasIngressEnabledLocked() && b.wantIngressLocked()
}
// setPrefsLockedOnEntry requires b.mu be held to call it, but it
// unlocks b.mu when done. newp ownership passes to this function.
// It returns a read-only copy of the new prefs.
@@ -4210,7 +4465,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)
@@ -5304,18 +5559,18 @@ func (b *LocalBackend) applyPrefsToHostinfoLocked(hi *tailcfg.Hostinfo, prefs ip
hi.ServicesHash = b.vipServiceHash(b.vipServicesFromPrefsLocked(prefs))
// The Hostinfo.WantIngress field tells control whether this node wants to
// be wired up for ingress connections. If harmless if it's accidentally
// true; the actual policy is controlled in tailscaled by ServeConfig. But
// if this is accidentally false, then control may not configure DNS
// properly. This exists as an optimization to control to program fewer DNS
// records that have ingress enabled but are not actually being used.
// TODO(irbekrm): once control knows that if hostinfo.IngressEnabled is true,
// then wireIngress can be considered true, don't send wireIngress in that case.
hi.WireIngress = b.wantIngressLocked()
// The Hostinfo.IngressEnabled field is used to communicate to control whether
// the funnel is actually enabled.
// the node has funnel enabled.
hi.IngressEnabled = b.hasIngressEnabledLocked()
// The Hostinfo.WantIngress field tells control whether the user intends
// to use funnel with this node even though it is not currently enabled.
// This is an optimization to control- Funnel requires creation of DNS
// records and because DNS propagation can take time, we want to ensure
// that the records exist for any node that intends to use funnel even
// if it's not enabled. If hi.IngressEnabled is true, control knows that
// DNS records are needed, so we can save bandwidth and not send
// WireIngress.
hi.WireIngress = b.shouldWireInactiveIngressLocked()
hi.AppConnector.Set(prefs.AppConnector().Advertise)
}
@@ -6229,8 +6484,6 @@ func (b *LocalBackend) setTCPPortsInterceptedFromNetmapAndPrefsLocked(prefs ipn.
// updateIngressLocked updates the hostinfo.WireIngress and hostinfo.IngressEnabled fields and kicks off a Hostinfo
// update if the values have changed.
// TODO(irbekrm): once control knows that if hostinfo.IngressEnabled is true, then wireIngress can be considered true,
// we can stop sending hostinfo.WireIngress in that case.
//
// b.mu must be held.
func (b *LocalBackend) updateIngressLocked() {
@@ -6238,16 +6491,16 @@ func (b *LocalBackend) updateIngressLocked() {
return
}
hostInfoChanged := false
if wire := b.wantIngressLocked(); b.hostinfo.WireIngress != wire {
b.logf("Hostinfo.WireIngress changed to %v", wire)
b.hostinfo.WireIngress = wire
hostInfoChanged = true
}
if ie := b.hasIngressEnabledLocked(); b.hostinfo.IngressEnabled != ie {
b.logf("Hostinfo.IngressEnabled changed to %v", ie)
b.hostinfo.IngressEnabled = ie
hostInfoChanged = true
}
if wire := b.shouldWireInactiveIngressLocked(); b.hostinfo.WireIngress != wire {
b.logf("Hostinfo.WireIngress changed to %v", wire)
b.hostinfo.WireIngress = wire
hostInfoChanged = true
}
// Kick off a Hostinfo update to control if ingress status has changed.
if hostInfoChanged {
b.goTracker.Go(b.doSetHostinfoFilterServices)
@@ -6455,6 +6708,41 @@ func (b *LocalBackend) FileTargets() ([]*apitype.FileTarget, error) {
return ret, nil
}
func (b *LocalBackend) taildropTargetStatus(p tailcfg.NodeView) ipnstate.TaildropTargetStatus {
if b.netMap == nil || b.state != ipn.Running {
return ipnstate.TaildropTargetIpnStateNotRunning
}
if b.netMap == nil {
return ipnstate.TaildropTargetNoNetmapAvailable
}
if !b.capFileSharing {
return ipnstate.TaildropTargetMissingCap
}
if !p.Online().Get() {
return ipnstate.TaildropTargetOffline
}
if !p.Valid() {
return ipnstate.TaildropTargetNoPeerInfo
}
if b.netMap.User() != p.User() {
// Different user must have the explicit file sharing target capability
if p.Addresses().Len() == 0 ||
!b.peerHasCapLocked(p.Addresses().At(0).Addr(), tailcfg.PeerCapabilityFileSharingTarget) {
return ipnstate.TaildropTargetOwnedByOtherUser
}
}
if p.Hostinfo().OS() == "tvOS" {
return ipnstate.TaildropTargetUnsupportedOS
}
if peerAPIBase(b.netMap, p) == "" {
return ipnstate.TaildropTargetNoPeerAPI
}
return ipnstate.TaildropTargetAvailable
}
// peerIsTaildropTargetLocked reports whether p is a valid Taildrop file
// recipient from this node according to its ownership and the capabilities in
// the netmap.
@@ -7190,9 +7478,9 @@ func (b *LocalBackend) resetForProfileChangeLockedOnEntry(unlock unlockOnce) err
// Needs to happen without b.mu held.
defer prevCC.Shutdown()
}
if err := b.initTKALocked(); err != nil {
return err
}
// TKA errors should not prevent resetting the backend state.
// However, we should still return the error to the caller.
tkaErr := b.initTKALocked()
b.lastServeConfJSON = mem.B(nil)
b.serveConfig = ipn.ServeConfigView{}
b.lastSuggestedExitNode = ""
@@ -7201,6 +7489,9 @@ func (b *LocalBackend) resetForProfileChangeLockedOnEntry(unlock unlockOnce) err
b.setAtomicValuesFromPrefsLocked(b.pm.CurrentPrefs())
b.enterStateLockedOnEntry(ipn.NoState, unlock) // Reset state; releases b.mu
b.health.SetLocalLogConfigHealth(nil)
if tkaErr != nil {
return tkaErr
}
return b.Start(ipn.Options{})
}

View File

@@ -5084,7 +5084,7 @@ func TestUpdateIngressLocked(t *testing.T) {
},
},
wantIngress: true,
wantWireIngress: true,
wantWireIngress: false, // implied by wantIngress
wantControlUpdate: true,
},
{
@@ -5111,7 +5111,6 @@ func TestUpdateIngressLocked(t *testing.T) {
name: "funnel_enabled_no_change",
hi: &tailcfg.Hostinfo{
IngressEnabled: true,
WireIngress: true,
},
sc: &ipn.ServeConfig{
AllowFunnel: map[ipn.HostPort]bool{
@@ -5119,7 +5118,7 @@ func TestUpdateIngressLocked(t *testing.T) {
},
},
wantIngress: true,
wantWireIngress: true,
wantWireIngress: false, // implied by wantIngress
},
{
name: "funnel_disabled_no_change",
@@ -5137,7 +5136,6 @@ func TestUpdateIngressLocked(t *testing.T) {
name: "funnel_changes_to_disabled",
hi: &tailcfg.Hostinfo{
IngressEnabled: true,
WireIngress: true,
},
sc: &ipn.ServeConfig{
AllowFunnel: map[ipn.HostPort]bool{
@@ -5157,8 +5155,8 @@ func TestUpdateIngressLocked(t *testing.T) {
"tailnet.xyz:443": true,
},
},
wantWireIngress: true,
wantIngress: true,
wantWireIngress: false, // implied by wantIngress
wantControlUpdate: true,
},
}

View File

@@ -91,24 +91,25 @@ func (pm *profileManager) SetCurrentUserID(uid ipn.WindowsUserID) {
// profile for the user and switches to it, unless the current profile
// is already a new, empty profile owned by the user.
//
// It reports whether the call resulted in a profile switch.
func (pm *profileManager) SetCurrentUserAndProfile(uid ipn.WindowsUserID, profileID ipn.ProfileID) (changed bool) {
// It returns the current profile and whether the call resulted
// in a profile switch.
func (pm *profileManager) SetCurrentUserAndProfile(uid ipn.WindowsUserID, profileID ipn.ProfileID) (cp ipn.LoginProfileView, changed bool) {
pm.currentUserID = uid
if profileID == "" {
if pm.currentProfile.ID() == "" && pm.currentProfile.LocalUserID() == uid {
return false
return pm.currentProfile, false
}
pm.NewProfileForUser(uid)
return true
return pm.currentProfile, true
}
if profile, err := pm.ProfileByID(profileID); err == nil {
if pm.CurrentProfile().ID() == profileID {
return false
return pm.currentProfile, false
}
if err := pm.SwitchProfile(profile.ID()); err == nil {
return true
return pm.currentProfile, true
}
}
@@ -116,7 +117,7 @@ func (pm *profileManager) SetCurrentUserAndProfile(uid ipn.WindowsUserID, profil
pm.logf("%q's default profile cannot be used; creating a new one: %v", uid, err)
pm.NewProfile()
}
return true
return pm.currentProfile, true
}
// DefaultUserProfileID returns [ipn.ProfileID] of the default (last used) profile for the specified user,

View File

@@ -32,6 +32,7 @@ type actor struct {
ci *ipnauth.ConnIdentity
clientID ipnauth.ClientID
userID ipn.WindowsUserID // cached Windows user ID of the connected client process.
// accessOverrideReason specifies the reason for overriding certain access restrictions,
// such as permitting a user to disconnect when the always-on mode is enabled,
// provided that such justification is allowed by the policy.
@@ -59,7 +60,14 @@ func newActor(logf logger.Logf, c net.Conn) (*actor, error) {
// connectivity on domain-joined devices and/or be slow.
clientID = ipnauth.ClientIDFrom(pid)
}
return &actor{logf: logf, ci: ci, clientID: clientID, isLocalSystem: connIsLocalSystem(ci)}, nil
return &actor{
logf: logf,
ci: ci,
clientID: clientID,
userID: ci.WindowsUserID(),
isLocalSystem: connIsLocalSystem(ci),
},
nil
}
// actorWithAccessOverride returns a new actor that carries the specified
@@ -73,6 +81,7 @@ func actorWithAccessOverride(baseActor *actor, reason string) *actor {
logf: baseActor.logf,
ci: baseActor.ci,
clientID: baseActor.clientID,
userID: baseActor.userID,
accessOverrideReason: reason,
isLocalSystem: baseActor.isLocalSystem,
}
@@ -106,7 +115,7 @@ func (a *actor) IsLocalAdmin(operatorUID string) bool {
// UserID implements [ipnauth.Actor].
func (a *actor) UserID() ipn.WindowsUserID {
return a.ci.WindowsUserID()
return a.userID
}
func (a *actor) pid() int {

View File

@@ -270,6 +270,12 @@ type PeerStatus struct {
// PeerAPIURL are the URLs of the node's PeerAPI servers.
PeerAPIURL []string
// TaildropTargetStatus represents the node's eligibility to have files shared to it.
TaildropTarget TaildropTargetStatus
// Reason why this peer cannot receive files. Empty if CanReceiveFiles=true
NoFileSharingReason string
// Capabilities are capabilities that the node has.
// They're free-form strings, but should be in the form of URLs/URIs
// such as:
@@ -318,6 +324,21 @@ type PeerStatus struct {
Location *tailcfg.Location `json:",omitempty"`
}
type TaildropTargetStatus int
const (
TaildropTargetUnknown TaildropTargetStatus = iota
TaildropTargetAvailable
TaildropTargetNoNetmapAvailable
TaildropTargetIpnStateNotRunning
TaildropTargetMissingCap
TaildropTargetOffline
TaildropTargetNoPeerInfo
TaildropTargetUnsupportedOS
TaildropTargetNoPeerAPI
TaildropTargetOwnedByOtherUser
)
// HasCap reports whether ps has the given capability.
func (ps *PeerStatus) HasCap(cap tailcfg.NodeCapability) bool {
return ps.CapMap.Contains(cap)

72
maths/ewma.go Normal file
View 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
View 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())
}
}

View File

@@ -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

View File

@@ -172,25 +172,14 @@ func (r *Report) Clone() *Report {
return nil
}
r2 := *r
r2.RegionLatency = cloneDurationMap(r2.RegionLatency)
r2.RegionV4Latency = cloneDurationMap(r2.RegionV4Latency)
r2.RegionV6Latency = cloneDurationMap(r2.RegionV6Latency)
r2.RegionLatency = maps.Clone(r2.RegionLatency)
r2.RegionV4Latency = maps.Clone(r2.RegionV4Latency)
r2.RegionV6Latency = maps.Clone(r2.RegionV6Latency)
r2.GlobalV4Counters = maps.Clone(r2.GlobalV4Counters)
r2.GlobalV6Counters = maps.Clone(r2.GlobalV6Counters)
return &r2
}
func cloneDurationMap(m map[int]time.Duration) map[int]time.Duration {
if m == nil {
return nil
}
m2 := make(map[int]time.Duration, len(m))
for k, v := range m {
m2[k] = v
}
return m2
}
// Client generates Reports describing the result of both passive and active
// network configuration probing. It provides two different modes of report, a
// full report (see MakeNextReportFull) and a more lightweight incremental

View File

@@ -7,6 +7,14 @@
set -eu
# Ensure that this script runs with the default umask for Linux. In practice,
# this means that files created by this script (such as keyring files) will be
# created with 644 permissions. This ensures that keyrings and other files
# created by this script are readable by installers on systems where the
# umask is set to a more restrictive value.
# See https://github.com/tailscale/tailscale/issues/15133
umask 022
# All the code is wrapped in a main function that gets called at the
# bottom of the file, so that a truncated partial download doesn't end
# up executing half a script.
@@ -186,6 +194,12 @@ main() {
VERSION="$DEBIAN_CODENAME"
fi
;;
sparky)
OS="debian"
PACKAGETYPE="apt"
VERSION="$DEBIAN_CODENAME"
APT_KEY_TYPE="keyring"
;;
centos)
OS="$ID"
VERSION="$VERSION_ID"

View File

@@ -51,6 +51,11 @@ var (
sshDisableSFTP = envknob.RegisterBool("TS_SSH_DISABLE_SFTP")
sshDisableForwarding = envknob.RegisterBool("TS_SSH_DISABLE_FORWARDING")
sshDisablePTY = envknob.RegisterBool("TS_SSH_DISABLE_PTY")
// errTerminal is an empty gossh.PartialSuccessError (with no 'Next'
// authentication methods that may proceed), which results in the SSH
// server immediately disconnecting the client.
errTerminal = &gossh.PartialSuccessError{}
)
const (
@@ -230,8 +235,8 @@ type conn struct {
finalAction *tailcfg.SSHAction // set by clientAuth
info *sshConnInfo // set by setInfo
localUser *userMeta // set by doPolicyAuth
userGroupIDs []string // set by doPolicyAuth
localUser *userMeta // set by clientAuth
userGroupIDs []string // set by clientAuth
acceptEnv []string
// mu protects the following fields.
@@ -255,46 +260,73 @@ func (c *conn) vlogf(format string, args ...any) {
}
// errDenied is returned by auth callbacks when a connection is denied by the
// policy. It returns a gossh.BannerError to make sure the message gets
// displayed as an auth banner.
func errDenied(message string) error {
// policy. It writes the message to an auth banner and then returns an empty
// gossh.PartialSuccessError in order to stop processing authentication
// attempts and immediately disconnect the client.
func (c *conn) errDenied(message string) error {
if message == "" {
message = "tailscale: access denied"
}
return &gossh.BannerError{
Message: message,
if err := c.spac.SendAuthBanner(message); err != nil {
c.logf("failed to send auth banner: %s", err)
}
return errTerminal
}
// bannerError creates a gossh.BannerError that will result in the given
// message being displayed to the client. If err != nil, this also logs
// message:error. The contents of err is not leaked to clients in the banner.
func (c *conn) bannerError(message string, err error) error {
// errBanner writes the given message to an auth banner and then returns an
// empty gossh.PartialSuccessError in order to stop processing authentication
// attempts and immediately disconnect the client. The contents of err is not
// leaked in the auth banner, but it is logged to the server's log.
func (c *conn) errBanner(message string, err error) error {
if err != nil {
c.logf("%s: %s", message, err)
}
return &gossh.BannerError{
Err: err,
Message: fmt.Sprintf("tailscale: %s", message),
if err := c.spac.SendAuthBanner("tailscale: " + message); err != nil {
c.logf("failed to send auth banner: %s", err)
}
return errTerminal
}
// errUnexpected is returned by auth callbacks that encounter an unexpected
// error, such as being unable to send an auth banner. It sends an empty
// gossh.PartialSuccessError to tell gossh.Server to stop processing
// authentication attempts and instead disconnect immediately.
func (c *conn) errUnexpected(err error) error {
c.logf("terminal error: %s", err)
return errTerminal
}
// clientAuth is responsible for performing client authentication.
//
// If policy evaluation fails, it returns an error.
// If access is denied, it returns an error.
func (c *conn) clientAuth(cm gossh.ConnMetadata) (*gossh.Permissions, error) {
// If access is denied, it returns an error. This must always be an empty
// gossh.PartialSuccessError to prevent further authentication methods from
// being tried.
func (c *conn) clientAuth(cm gossh.ConnMetadata) (perms *gossh.Permissions, retErr error) {
defer func() {
if pse, ok := retErr.(*gossh.PartialSuccessError); ok {
if pse.Next.GSSAPIWithMICConfig != nil ||
pse.Next.KeyboardInteractiveCallback != nil ||
pse.Next.PasswordCallback != nil ||
pse.Next.PublicKeyCallback != nil {
panic("clientAuth attempted to return a non-empty PartialSuccessError")
}
} else if retErr != nil {
panic(fmt.Sprintf("clientAuth attempted to return a non-PartialSuccessError error of type: %t", retErr))
}
}()
if c.insecureSkipTailscaleAuth {
return &gossh.Permissions{}, nil
}
if err := c.setInfo(cm); err != nil {
return nil, c.bannerError("failed to get connection info", err)
return nil, c.errBanner("failed to get connection info", err)
}
action, localUser, acceptEnv, err := c.evaluatePolicy()
if err != nil {
return nil, c.bannerError("failed to evaluate SSH policy", err)
return nil, c.errBanner("failed to evaluate SSH policy", err)
}
c.action0 = action
@@ -304,11 +336,11 @@ func (c *conn) clientAuth(cm gossh.ConnMetadata) (*gossh.Permissions, error) {
// hold and delegate URL (if necessary).
lu, err := userLookup(localUser)
if err != nil {
return nil, c.bannerError(fmt.Sprintf("failed to look up local user %q ", localUser), err)
return nil, c.errBanner(fmt.Sprintf("failed to look up local user %q ", localUser), err)
}
gids, err := lu.GroupIds()
if err != nil {
return nil, c.bannerError("failed to look up local user's group IDs", err)
return nil, c.errBanner("failed to look up local user's group IDs", err)
}
c.userGroupIDs = gids
c.localUser = lu
@@ -321,7 +353,7 @@ func (c *conn) clientAuth(cm gossh.ConnMetadata) (*gossh.Permissions, error) {
metricTerminalAccept.Add(1)
if action.Message != "" {
if err := c.spac.SendAuthBanner(action.Message); err != nil {
return nil, fmt.Errorf("error sending auth welcome message: %w", err)
return nil, c.errUnexpected(fmt.Errorf("error sending auth welcome message: %w", err))
}
}
c.finalAction = action
@@ -329,11 +361,11 @@ func (c *conn) clientAuth(cm gossh.ConnMetadata) (*gossh.Permissions, error) {
case action.Reject:
metricTerminalReject.Add(1)
c.finalAction = action
return nil, errDenied(action.Message)
return nil, c.errDenied(action.Message)
case action.HoldAndDelegate != "":
if action.Message != "" {
if err := c.spac.SendAuthBanner(action.Message); err != nil {
return nil, fmt.Errorf("error sending hold and delegate message: %w", err)
return nil, c.errUnexpected(fmt.Errorf("error sending hold and delegate message: %w", err))
}
}
@@ -349,11 +381,11 @@ func (c *conn) clientAuth(cm gossh.ConnMetadata) (*gossh.Permissions, error) {
action, err = c.fetchSSHAction(ctx, url)
if err != nil {
metricTerminalFetchError.Add(1)
return nil, c.bannerError("failed to fetch next SSH action", fmt.Errorf("fetch failed from %s: %w", url, err))
return nil, c.errBanner("failed to fetch next SSH action", fmt.Errorf("fetch failed from %s: %w", url, err))
}
default:
metricTerminalMalformed.Add(1)
return nil, c.bannerError("reached Action that had neither Accept, Reject, nor HoldAndDelegate", nil)
return nil, c.errBanner("reached Action that had neither Accept, Reject, nor HoldAndDelegate", nil)
}
}
}
@@ -390,6 +422,20 @@ func (c *conn) ServerConfig(ctx ssh.Context) *gossh.ServerConfig {
return perms, nil
},
PasswordCallback: func(cm gossh.ConnMetadata, pword []byte) (*gossh.Permissions, error) {
// Some clients don't request 'none' authentication. Instead, they
// immediately supply a password. We humor them by accepting the
// password, but authenticate as usual, ignoring the actual value of
// the password.
return c.clientAuth(cm)
},
PublicKeyCallback: func(cm gossh.ConnMetadata, key gossh.PublicKey) (*gossh.Permissions, error) {
// Some clients don't request 'none' authentication. Instead, they
// immediately supply a public key. We humor them by accepting the
// key, but authenticate as usual, ignoring the actual content of
// the key.
return c.clientAuth(cm)
},
}
}
@@ -400,7 +446,7 @@ func (srv *server) newConn() (*conn, error) {
// Stop accepting new connections.
// Connections in the auth phase are handled in handleConnPostSSHAuth.
// Existing sessions are terminated by Shutdown.
return nil, errDenied("tailscale: server is shutting down")
return nil, errors.New("server is shutting down")
}
srv.mu.Unlock()
c := &conn{srv: srv}

View File

@@ -2,7 +2,6 @@
// SPDX-License-Identifier: BSD-3-Clause
//go:build integrationtest
// +build integrationtest
package tailssh
@@ -410,6 +409,48 @@ func TestSSHAgentForwarding(t *testing.T) {
}
}
// TestIntegrationParamiko attempts to connect to Tailscale SSH using the
// paramiko Python library. This library does not request 'none' auth. This
// test ensures that Tailscale SSH can correctly handle clients that don't
// request 'none' auth and instead immediately authenticate with a public key
// or password.
func TestIntegrationParamiko(t *testing.T) {
debugTest.Store(true)
t.Cleanup(func() {
debugTest.Store(false)
})
addr := testServer(t, "testuser", true, false)
host, port, err := net.SplitHostPort(addr)
if err != nil {
t.Fatalf("Failed to split addr %q: %s", addr, err)
}
out, err := exec.Command("python3", "-c", fmt.Sprintf(`
import paramiko.client as pm
from paramiko.ecdsakey import ECDSAKey
client = pm.SSHClient()
client.set_missing_host_key_policy(pm.AutoAddPolicy)
client.connect('%s', port=%s, username='testuser', pkey=ECDSAKey.generate(), allow_agent=False, look_for_keys=False)
client.exec_command('pwd')
`, host, port)).CombinedOutput()
if err != nil {
t.Fatalf("failed to connect with Paramiko using public key auth: %s\n%q", err, string(out))
}
out, err = exec.Command("python3", "-c", fmt.Sprintf(`
import paramiko.client as pm
from paramiko.ecdsakey import ECDSAKey
client = pm.SSHClient()
client.set_missing_host_key_policy(pm.AutoAddPolicy)
client.connect('%s', port=%s, username='testuser', password='doesntmatter', allow_agent=False, look_for_keys=False)
client.exec_command('pwd')
`, host, port)).CombinedOutput()
if err != nil {
t.Fatalf("failed to connect with Paramiko using password auth: %s\n%q", err, string(out))
}
}
func fallbackToSUAvailable() bool {
if runtime.GOOS != "linux" {
return false

View File

@@ -8,12 +8,15 @@ package tailssh
import (
"bytes"
"context"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"net/http/httptest"
@@ -41,7 +44,7 @@ import (
"tailscale.com/sessionrecording"
"tailscale.com/tailcfg"
"tailscale.com/tempfork/gliderlabs/ssh"
sshtest "tailscale.com/tempfork/sshtest/ssh"
testssh "tailscale.com/tempfork/sshtest/ssh"
"tailscale.com/tsd"
"tailscale.com/tstest"
"tailscale.com/types/key"
@@ -56,8 +59,6 @@ import (
"tailscale.com/wgengine"
)
type _ = sshtest.Client // TODO(bradfitz,percy): sshtest; delete this line
func TestMatchRule(t *testing.T) {
someAction := new(tailcfg.SSHAction)
tests := []struct {
@@ -510,9 +511,9 @@ func TestSSHRecordingCancelsSessionsOnUploadFailure(t *testing.T) {
defer s.Shutdown()
const sshUser = "alice"
cfg := &gossh.ClientConfig{
cfg := &testssh.ClientConfig{
User: sshUser,
HostKeyCallback: gossh.InsecureIgnoreHostKey(),
HostKeyCallback: testssh.InsecureIgnoreHostKey(),
}
tests := []struct {
@@ -559,12 +560,12 @@ func TestSSHRecordingCancelsSessionsOnUploadFailure(t *testing.T) {
wg.Add(1)
go func() {
defer wg.Done()
c, chans, reqs, err := gossh.NewClientConn(sc, sc.RemoteAddr().String(), cfg)
c, chans, reqs, err := testssh.NewClientConn(sc, sc.RemoteAddr().String(), cfg)
if err != nil {
t.Errorf("client: %v", err)
return
}
client := gossh.NewClient(c, chans, reqs)
client := testssh.NewClient(c, chans, reqs)
defer client.Close()
session, err := client.NewSession()
if err != nil {
@@ -645,21 +646,21 @@ func TestMultipleRecorders(t *testing.T) {
sc, dc := memnet.NewTCPConn(src, dst, 1024)
const sshUser = "alice"
cfg := &gossh.ClientConfig{
cfg := &testssh.ClientConfig{
User: sshUser,
HostKeyCallback: gossh.InsecureIgnoreHostKey(),
HostKeyCallback: testssh.InsecureIgnoreHostKey(),
}
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
c, chans, reqs, err := gossh.NewClientConn(sc, sc.RemoteAddr().String(), cfg)
c, chans, reqs, err := testssh.NewClientConn(sc, sc.RemoteAddr().String(), cfg)
if err != nil {
t.Errorf("client: %v", err)
return
}
client := gossh.NewClient(c, chans, reqs)
client := testssh.NewClient(c, chans, reqs)
defer client.Close()
session, err := client.NewSession()
if err != nil {
@@ -736,21 +737,21 @@ func TestSSHRecordingNonInteractive(t *testing.T) {
sc, dc := memnet.NewTCPConn(src, dst, 1024)
const sshUser = "alice"
cfg := &gossh.ClientConfig{
cfg := &testssh.ClientConfig{
User: sshUser,
HostKeyCallback: gossh.InsecureIgnoreHostKey(),
HostKeyCallback: testssh.InsecureIgnoreHostKey(),
}
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
c, chans, reqs, err := gossh.NewClientConn(sc, sc.RemoteAddr().String(), cfg)
c, chans, reqs, err := testssh.NewClientConn(sc, sc.RemoteAddr().String(), cfg)
if err != nil {
t.Errorf("client: %v", err)
return
}
client := gossh.NewClient(c, chans, reqs)
client := testssh.NewClient(c, chans, reqs)
defer client.Close()
session, err := client.NewSession()
if err != nil {
@@ -886,80 +887,151 @@ func TestSSHAuthFlow(t *testing.T) {
},
}
s := &server{
logf: logger.Discard,
logf: log.Printf,
}
defer s.Shutdown()
src, dst := must.Get(netip.ParseAddrPort("100.100.100.101:2231")), must.Get(netip.ParseAddrPort("100.100.100.102:22"))
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
sc, dc := memnet.NewTCPConn(src, dst, 1024)
s.lb = tc.state
sshUser := "alice"
if tc.sshUser != "" {
sshUser = tc.sshUser
}
var passwordUsed atomic.Bool
cfg := &gossh.ClientConfig{
User: sshUser,
HostKeyCallback: gossh.InsecureIgnoreHostKey(),
Auth: []gossh.AuthMethod{
gossh.PasswordCallback(func() (secret string, err error) {
if !tc.usesPassword {
t.Error("unexpected use of PasswordCallback")
return "", errors.New("unexpected use of PasswordCallback")
}
for _, authMethods := range [][]string{nil, {"publickey", "password"}, {"password", "publickey"}} {
t.Run(fmt.Sprintf("%s-skip-none-auth-%v", tc.name, strings.Join(authMethods, "-then-")), func(t *testing.T) {
sc, dc := memnet.NewTCPConn(src, dst, 1024)
s.lb = tc.state
sshUser := "alice"
if tc.sshUser != "" {
sshUser = tc.sshUser
}
wantBanners := slices.Clone(tc.wantBanners)
noneAuthEnabled := len(authMethods) == 0
var publicKeyUsed atomic.Bool
var passwordUsed atomic.Bool
var methods []testssh.AuthMethod
for _, authMethod := range authMethods {
switch authMethod {
case "publickey":
methods = append(methods,
testssh.PublicKeysCallback(func() (signers []testssh.Signer, err error) {
publicKeyUsed.Store(true)
key, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
if err != nil {
return nil, err
}
sig, err := testssh.NewSignerFromKey(key)
if err != nil {
return nil, err
}
return []testssh.Signer{sig}, nil
}))
case "password":
methods = append(methods, testssh.PasswordCallback(func() (secret string, err error) {
passwordUsed.Store(true)
return "any-pass", nil
}))
}
}
if noneAuthEnabled && tc.usesPassword {
methods = append(methods, testssh.PasswordCallback(func() (secret string, err error) {
passwordUsed.Store(true)
return "any-pass", nil
}),
},
BannerCallback: func(message string) error {
if len(tc.wantBanners) == 0 {
t.Errorf("unexpected banner: %q", message)
} else if message != tc.wantBanners[0] {
t.Errorf("banner = %q; want %q", message, tc.wantBanners[0])
} else {
t.Logf("banner = %q", message)
tc.wantBanners = tc.wantBanners[1:]
}))
}
cfg := &testssh.ClientConfig{
User: sshUser,
HostKeyCallback: testssh.InsecureIgnoreHostKey(),
SkipNoneAuth: !noneAuthEnabled,
Auth: methods,
BannerCallback: func(message string) error {
if len(wantBanners) == 0 {
t.Errorf("unexpected banner: %q", message)
} else if message != wantBanners[0] {
t.Errorf("banner = %q; want %q", message, wantBanners[0])
} else {
t.Logf("banner = %q", message)
wantBanners = wantBanners[1:]
}
return nil
},
}
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
c, chans, reqs, err := testssh.NewClientConn(sc, sc.RemoteAddr().String(), cfg)
if err != nil {
if !tc.authErr {
t.Errorf("client: %v", err)
}
return
} else if tc.authErr {
c.Close()
t.Errorf("client: expected error, got nil")
return
}
return nil
},
}
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
c, chans, reqs, err := gossh.NewClientConn(sc, sc.RemoteAddr().String(), cfg)
if err != nil {
if !tc.authErr {
client := testssh.NewClient(c, chans, reqs)
defer client.Close()
session, err := client.NewSession()
if err != nil {
t.Errorf("client: %v", err)
return
}
defer session.Close()
_, err = session.CombinedOutput("echo Ran echo!")
if err != nil {
t.Errorf("client: %v", err)
}
return
} else if tc.authErr {
c.Close()
t.Errorf("client: expected error, got nil")
return
}()
if err := s.HandleSSHConn(dc); err != nil {
t.Errorf("unexpected error: %v", err)
}
client := gossh.NewClient(c, chans, reqs)
defer client.Close()
session, err := client.NewSession()
if err != nil {
t.Errorf("client: %v", err)
return
wg.Wait()
if len(wantBanners) > 0 {
t.Errorf("missing banners: %v", wantBanners)
}
defer session.Close()
_, err = session.CombinedOutput("echo Ran echo!")
if err != nil {
t.Errorf("client: %v", err)
// Check to see which callbacks were invoked.
//
// When `none` auth is enabled, the public key callback should
// never fire, and the password callback should only fire if
// authentication succeeded and the client was trying to force
// password authentication by connecting with the '-password'
// username suffix.
//
// When skipping `none` auth, the first callback should always
// fire, and the 2nd callback should fire only if
// authentication failed.
wantPublicKey := false
wantPassword := false
if noneAuthEnabled {
wantPassword = !tc.authErr && tc.usesPassword
} else {
for i, authMethod := range authMethods {
switch authMethod {
case "publickey":
wantPublicKey = i == 0 || tc.authErr
case "password":
wantPassword = i == 0 || tc.authErr
}
}
}
}()
if err := s.HandleSSHConn(dc); err != nil {
t.Errorf("unexpected error: %v", err)
}
wg.Wait()
if len(tc.wantBanners) > 0 {
t.Errorf("missing banners: %v", tc.wantBanners)
}
})
if wantPublicKey && !publicKeyUsed.Load() {
t.Error("public key should have been attempted")
} else if !wantPublicKey && publicKeyUsed.Load() {
t.Errorf("public key should not have been attempted")
}
if wantPassword && !passwordUsed.Load() {
t.Error("password should have been attempted")
} else if !wantPassword && passwordUsed.Load() {
t.Error("password should not have been attempted")
}
})
}
}
}

View File

@@ -3,9 +3,12 @@ FROM ${BASE}
ARG BASE
RUN echo "Install openssh, needed for scp."
RUN if echo "$BASE" | grep "ubuntu:"; then apt-get update -y && apt-get install -y openssh-client; fi
RUN if echo "$BASE" | grep "alpine:"; then apk add openssh; fi
RUN echo "Install openssh, needed for scp. Also install python3"
RUN if echo "$BASE" | grep "ubuntu:"; then apt-get update -y && apt-get install -y openssh-client python3 python3-pip; fi
RUN if echo "$BASE" | grep "alpine:"; then apk add openssh python3 py3-pip; fi
RUN echo "Install paramiko"
RUN pip3 install paramiko==3.5.1 || pip3 install --break-system-packages paramiko==3.5.1
# Note - on Ubuntu, we do not create the user's home directory, pam_mkhomedir will do that
# for us, and we want to test that PAM gets triggered by Tailscale SSH.
@@ -33,6 +36,8 @@ RUN if echo "$BASE" | grep "ubuntu:"; then rm -Rf /home/testuser; fi
RUN TAILSCALED_PATH=`pwd`tailscaled ./tailssh.test -test.v -test.run TestIntegrationSCP
RUN if echo "$BASE" | grep "ubuntu:"; then rm -Rf /home/testuser; fi
RUN TAILSCALED_PATH=`pwd`tailscaled ./tailssh.test -test.v -test.run TestIntegrationSSH
RUN if echo "$BASE" | grep "ubuntu:"; then rm -Rf /home/testuser; fi
RUN TAILSCALED_PATH=`pwd`tailscaled ./tailssh.test -test.v -test.run TestIntegrationParamiko
RUN echo "Then run tests as non-root user testuser and make sure tests still pass."
RUN touch /tmp/tailscalessh.log

View File

@@ -158,7 +158,8 @@ type CapabilityVersion int
// - 111: 2025-01-14: Client supports a peer having Node.HomeDERP (issue #14636)
// - 112: 2025-01-14: Client interprets AllowedIPs of nil as meaning same as Addresses
// - 113: 2025-01-20: Client communicates to control whether funnel is enabled by sending Hostinfo.IngressEnabled (#14688)
const CurrentCapabilityVersion CapabilityVersion = 113
// - 114: 2025-01-30: NodeAttrMaxKeyDuration CapMap defined, clients might use it (no tailscaled code change) (#14829)
const CurrentCapabilityVersion CapabilityVersion = 114
// ID is an integer ID for a user, node, or login allocated by the
// control plane.
@@ -834,15 +835,22 @@ type Hostinfo struct {
// App is used to disambiguate Tailscale clients that run using tsnet.
App string `json:",omitempty"` // "k8s-operator", "golinks", ...
Desktop opt.Bool `json:",omitempty"` // if a desktop was detected on Linux
Package string `json:",omitempty"` // Tailscale package to disambiguate ("choco", "appstore", etc; "" for unknown)
DeviceModel string `json:",omitempty"` // mobile phone model ("Pixel 3a", "iPhone12,3")
PushDeviceToken string `json:",omitempty"` // macOS/iOS APNs device token for notifications (and Android in the future)
Hostname string `json:",omitempty"` // name of the host the client runs on
ShieldsUp bool `json:",omitempty"` // indicates whether the host is blocking incoming connections
ShareeNode bool `json:",omitempty"` // indicates this node exists in netmap because it's owned by a shared-to user
NoLogsNoSupport bool `json:",omitempty"` // indicates that the user has opted out of sending logs and support
WireIngress bool `json:",omitempty"` // indicates that the node wants the option to receive ingress connections
Desktop opt.Bool `json:",omitempty"` // if a desktop was detected on Linux
Package string `json:",omitempty"` // Tailscale package to disambiguate ("choco", "appstore", etc; "" for unknown)
DeviceModel string `json:",omitempty"` // mobile phone model ("Pixel 3a", "iPhone12,3")
PushDeviceToken string `json:",omitempty"` // macOS/iOS APNs device token for notifications (and Android in the future)
Hostname string `json:",omitempty"` // name of the host the client runs on
ShieldsUp bool `json:",omitempty"` // indicates whether the host is blocking incoming connections
ShareeNode bool `json:",omitempty"` // indicates this node exists in netmap because it's owned by a shared-to user
NoLogsNoSupport bool `json:",omitempty"` // indicates that the user has opted out of sending logs and support
// WireIngress indicates that the node would like to be wired up server-side
// (DNS, etc) to be able to use Tailscale Funnel, even if it's not currently
// enabled. For example, the user might only use it for intermittent
// foreground CLI serve sessions, for which they'd like it to work right
// away, even if it's disabled most of the time. As an optimization, this is
// only sent if IngressEnabled is false, as IngressEnabled implies that this
// option is true.
WireIngress bool `json:",omitempty"`
IngressEnabled bool `json:",omitempty"` // if the node has any funnel endpoint enabled
AllowsUpdate bool `json:",omitempty"` // indicates that the node has opted-in to admin-console-drive remote updates
Machine string `json:",omitempty"` // the current host's machine type (uname -m)
@@ -2021,10 +2029,6 @@ type MapResponse struct {
// auto-update setting doesn't change if the tailnet admin flips the
// default after the node registered.
DefaultAutoUpdate opt.Bool `json:",omitempty"`
// MaxKeyDuration describes the MaxKeyDuration setting for the tailnet.
// If zero, the value is unchanged.
MaxKeyDuration time.Duration `json:",omitempty"`
}
// ClientVersion is information about the latest client version that's available
@@ -2430,6 +2434,12 @@ const (
// If multiple values of this key exist, they should be merged in sequence
// (replace conflicting keys).
NodeAttrServiceHost NodeCapability = "service-host"
// NodeAttrMaxKeyDuration represents the MaxKeyDuration setting on the
// tailnet. The value of this key in [NodeCapMap] will be only one entry of
// type float64 representing the duration in seconds. This cap will be
// omitted if the tailnet's MaxKeyDuration is the default.
NodeAttrMaxKeyDuration NodeCapability = "tailnet.maxKeyDuration"
)
// SetDNSRequest is a request to add a DNS record.

View File

@@ -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)
))

View File

@@ -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)
}

View File

@@ -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 {

View File

@@ -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.

View File

@@ -239,6 +239,14 @@ type ClientConfig struct {
//
// A Timeout of zero means no timeout.
Timeout time.Duration
// SkipNoneAuth allows skipping the initial "none" auth request. This is unusual
// behavior, but it is allowed by [RFC4252 5.2](https://datatracker.ietf.org/doc/html/rfc4252#section-5.2),
// and some clients in the wild behave like this. One such client is the paramiko Python
// library, which is used in pgadmin4 via the sshtunnel library.
// When SkipNoneAuth is true, the client will attempt all configured
// [AuthMethod]s until one works, or it runs out.
SkipNoneAuth bool
}
// InsecureIgnoreHostKey returns a function that can be used for

View File

@@ -68,7 +68,16 @@ func (c *connection) clientAuthenticate(config *ClientConfig) error {
var lastMethods []string
sessionID := c.transport.getSessionID()
for auth := AuthMethod(new(noneAuth)); auth != nil; {
var auth AuthMethod
if !config.SkipNoneAuth {
auth = AuthMethod(new(noneAuth))
} else if len(config.Auth) > 0 {
auth = config.Auth[0]
for _, a := range config.Auth {
lastMethods = append(lastMethods, a.method())
}
}
for auth != nil {
ok, methods, err := auth.auth(sessionID, config.User, c.transport, config.Rand, extensions)
if err != nil {
// On disconnect, return error immediately

View File

@@ -26,6 +26,7 @@ import (
"tailscale.com/health"
"tailscale.com/ipn"
"tailscale.com/ipn/conffile"
"tailscale.com/ipn/desktop"
"tailscale.com/net/dns"
"tailscale.com/net/netmon"
"tailscale.com/net/tsdial"
@@ -52,6 +53,7 @@ type System struct {
Netstack SubSystem[NetstackImpl] // actually a *netstack.Impl
DriveForLocal SubSystem[drive.FileSystemForLocal]
DriveForRemote SubSystem[drive.FileSystemForRemote]
SessionManager SubSystem[desktop.SessionManager]
// InitialConfig is initial server config, if any.
// It is nil if the node is not in declarative mode.
@@ -110,6 +112,8 @@ func (s *System) Set(v any) {
s.DriveForLocal.Set(v)
case drive.FileSystemForRemote:
s.DriveForRemote.Set(v)
case desktop.SessionManager:
s.SessionManager.Set(v)
default:
panic(fmt.Sprintf("unknown type %T", v))
}

View File

@@ -930,6 +930,8 @@ func getTSNetDir(logf logger.Logf, confDir, prog string) (string, error) {
// APIClient returns a tailscale.Client that can be used to make authenticated
// requests to the Tailscale control server.
// It requires the user to set tailscale.I_Acknowledge_This_API_Is_Unstable.
//
// Deprecated: use AuthenticatedAPITransport with tailscale.com/client/tailscale/v2 instead.
func (s *Server) APIClient() (*tailscale.Client, error) {
if !tailscale.I_Acknowledge_This_API_Is_Unstable {
return nil, errors.New("use of Client without setting I_Acknowledge_This_API_Is_Unstable")
@@ -944,6 +946,41 @@ func (s *Server) APIClient() (*tailscale.Client, error) {
return c, nil
}
// I_Acknowledge_This_API_Is_Experimental must be set true to use AuthenticatedAPITransport()
// for now.
var I_Acknowledge_This_API_Is_Experimental = false
// AuthenticatedAPITransport provides an HTTP transport that can be used with
// the control server API without needing additional authentication details. It
// authenticates using the current client's nodekey.
//
// It requires the user to set I_Acknowledge_This_API_Is_Experimental.
//
// For example:
//
// import "net/http"
// import "tailscale.com/client/tailscale/v2"
// import "tailscale.com/tsnet"
//
// var s *tsnet.Server
// ...
// rt, err := s.AuthenticatedAPITransport()
// // handler err ...
// var client tailscale.Client{HTTP: http.Client{
// Timeout: 1*time.Minute,
// UserAgent: "your-useragent-here",
// Transport: rt,
// }}
func (s *Server) AuthenticatedAPITransport() (http.RoundTripper, error) {
if !I_Acknowledge_This_API_Is_Experimental {
return nil, errors.New("use of AuthenticatedAPITransport without setting I_Acknowledge_This_API_Is_Experimental")
}
if err := s.Start(); err != nil {
return nil, err
}
return s.lb.KeyProvingNoiseRoundTripper(), nil
}
// Listen announces only on the Tailscale network.
// It will start the server if it has not been started yet.
//

View File

@@ -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 {

View File

@@ -29,6 +29,7 @@ import (
_ "tailscale.com/hostinfo"
_ "tailscale.com/ipn"
_ "tailscale.com/ipn/conffile"
_ "tailscale.com/ipn/desktop"
_ "tailscale.com/ipn/ipnlocal"
_ "tailscale.com/ipn/ipnserver"
_ "tailscale.com/ipn/store"

View File

@@ -1,7 +1,7 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package bools contains the [Int], [Compare], and [Select] functions.
// Package bools contains the [Int], [Compare], and [IfElse] functions.
package bools
// Int returns 1 for true and 0 for false.

View File

@@ -79,9 +79,6 @@ type NetworkMap struct {
// UserProfiles contains the profile information of UserIDs referenced
// in SelfNode and Peers.
UserProfiles map[tailcfg.UserID]tailcfg.UserProfileView
// MaxKeyDuration describes the MaxKeyDuration setting for the tailnet.
MaxKeyDuration time.Duration
}
// User returns nm.SelfNode.User if nm.SelfNode is non-nil, otherwise it returns

View File

@@ -176,6 +176,5 @@ func mapResponseContainsNonPatchFields(res *tailcfg.MapResponse) bool {
// function is called, so it should never be set anyway. But for
// completedness, and for tests, check it too:
res.PeersChanged != nil ||
res.DefaultAutoUpdate != "" ||
res.MaxKeyDuration > 0
res.DefaultAutoUpdate != ""
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -157,15 +157,15 @@ 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)
// 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 +174,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
}

View File

@@ -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
}

View File

@@ -158,22 +158,22 @@ 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)
// 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
}

View File

@@ -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,34 @@ type AppConnectorPrefs struct {
Advertise prefs.Item[bool] `json:",omitzero"`
}
// MarshalJSONV2 implements [jsonv2.MarshalerV2].
// 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{}

View File

@@ -53,32 +53,32 @@ type TestPrefs struct {
Group TestPrefsGroup `json:",omitzero"`
}
// MarshalJSONV2 implements [jsonv2.MarshalerV2].
func (p TestPrefs) MarshalJSONV2(out *jsontext.Encoder, opts jsonv2.Options) error {
// 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,

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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):

View File

@@ -50,22 +50,22 @@ 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)
// 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
}

View File

@@ -75,31 +75,31 @@ 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)
// 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 +114,16 @@ 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)
// 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 +139,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 +150,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.

View File

@@ -147,23 +147,23 @@ type snapshotJSON struct {
Settings map[Key]RawItem `json:",omitempty"`
}
// MarshalJSONV2 implements [jsonv2.MarshalerV2].
func (s *Snapshot) MarshalJSONV2(out *jsontext.Encoder, opts jsonv2.Options) error {
// 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 +172,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

View File

@@ -54,24 +54,24 @@ 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)
// 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]