Compare commits

...

16 Commits

Author SHA1 Message Date
Paul Scott
99865276a7 cmd/testwrapper: wip idea
Signed-off-by: Paul Scott <paul@tailscale.com>
2025-02-18 08:29:55 -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
35 changed files with 1235 additions and 414 deletions

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

@@ -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 (
@@ -18,10 +20,7 @@ import (
)
// 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 +34,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".
@@ -97,6 +98,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,

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

@@ -811,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+

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

@@ -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)
@@ -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,16 +264,23 @@ func (a *IngressPGReconciler) maybeProvision(ctx context.Context, hostname strin
tags = strings.Split(tstr, ",")
}
vipPorts := []string{"443"} // always 443 for Ingress
if isHTTPEndpointEnabled(ing) {
vipPorts = append(vipPorts, "80")
}
vipSvc := &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 {
logger.Infof("error creating VIPService: %v", 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) {
@@ -569,3 +598,11 @@ func (a *IngressPGReconciler) deleteVIPServiceIfExists(ctx context.Context, name
}
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

@@ -16,7 +16,7 @@ import (
"os"
"golang.org/x/oauth2/clientcredentials"
"tailscale.com/client/tailscale"
"tailscale.com/internal/client/tailscale"
"tailscale.com/tailcfg"
"tailscale.com/util/httpm"
)

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+

View File

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

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

@@ -7,9 +7,9 @@
package flakytest
import (
"fmt"
"os"
"regexp"
"strings"
"testing"
)
@@ -38,7 +38,14 @@ func Mark(t testing.TB, issue string) {
// We're being run under cmd/testwrapper so send our sentinel message
// to stderr. (We avoid doing this when the env is absent to avoid
// spamming people running tests without the wrapper)
fmt.Fprintf(os.Stderr, "%s: %s\n", FlakyTestLogMessage, issue)
t.Cleanup(func() {
if t.Failed() {
// FIXME: this won't catch panics because t.Failed() won't yet
// be correctly set. https://github.com/golang/go/issues/49929
root, _, _ := strings.Cut(t.Name(), "/")
t.Logf("flakytest: retry: %s %s", root, strings.Join(os.Args, " "))
}
})
}
t.Logf("flakytest: issue tracking this flaky test: %s", issue)
}

View File

@@ -41,3 +41,32 @@ func TestFlakeRun(t *testing.T) {
t.Fatal("First run in testwrapper, failing so that test is retried. This is expected.")
}
}
// TestFlakePanic is a test that panics when run in the testwrapper
// for the first time, but succeeds on the second run.
// It's used to test whether the testwrapper retries flaky tests.
func TestFlakeExit(t *testing.T) {
Mark(t, "https://github.com/tailscale/tailscale/issues/0") // random issue
e := os.Getenv(FlakeAttemptEnv)
if e == "" {
t.Skip("not running in testwrapper")
}
if e == "1" {
t.Log("First run in testwrapper, failing so exiting so test is retried. This is expected.")
os.Exit(0)
}
}
// TestFlakePanic is a test that panics when run in the testwrapper
// for the first time, but succeeds on the second run.
// It's used to test whether the testwrapper retries flaky tests.
func TestFlakePanic(t *testing.T) {
Mark(t, "https://github.com/tailscale/tailscale/issues/0") // random issue
e := os.Getenv(FlakeAttemptEnv)
if e == "" {
t.Skip("not running in testwrapper")
}
if e == "1" {
panic("First run in testwrapper, failing so that test is retried. This is expected.")
}
}

View File

@@ -22,13 +22,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"
)
@@ -197,7 +191,7 @@ func runTests(ctx context.Context, attempt int, pt *packageTests, goTestArgs, te
return nil
}
func main() {
func _main() {
goTestArgs, packages, testArgs, err := splitArgs(os.Args[1:])
if err != nil {
log.Fatal(err)
@@ -238,30 +232,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 +245,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
@@ -372,107 +328,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

@@ -0,0 +1,239 @@
package main
import (
"bufio"
"bytes"
"errors"
"io"
"log"
"os"
"os/exec"
"path/filepath"
"slices"
"strconv"
"strings"
"sync"
"tailscale.com/cmd/testwrapper/flakytest"
"tailscale.com/util/mak"
)
func main() {
log.SetFlags(log.Lshortfile)
log.SetPrefix("testwrapper: ")
// Build go args: test [-work] ...
var workdir string
var args = []string{"test"}
if !slices.Contains(args, "-work") && !slices.Contains(args, "--work") {
args = append(args, "-work")
defer func() {
if workdir != "" {
// Clean up the WORK directory as the user didn't want it.
if err := os.RemoveAll(workdir); err != nil {
log.Printf("error removing workdir: %s", err)
}
}
}()
}
args = append(args, os.Args[1:]...)
// Run go test.
attempt := 1
r, xerr := run("go", args, []string{attemptenv(attempt)}, os.Stdout, os.Stderr)
if nonexecerr(xerr) {
log.Fatal("go test: ", xerr)
}
// Check whether anything needs retried.
log.Printf("failures: builds=%d tests=%d retryable=%d", r.buildFailures, r.testFailures, r.testFailuresRetryable)
if r.buildFailures > 0 || r.testFailuresRetryable == 0 || r.testFailures > r.testFailuresRetryable {
exit(xerr)
}
// Retry tests we found.
const maxAttempts = 3
for cmd := range r.retryCmds {
pkg := strings.TrimSuffix(cmdPkg(cmd), ".test")
for {
attempt++
p := r.retryCmds[cmd]
log.Printf("attempt %d: %s %s", attempt, pkg, strings.Join(p.tests, " "))
// Retry the test by invoking the built pkg.test binary directly.
pr, xerr := run(
cmd,
append(p.args, "-test.run=^"+strings.Join(p.tests, "$|^")+"$"),
[]string{attemptenv(attempt)},
os.Stdout, os.Stdout, // go test copies all underlying pkg.test output to stdout
)
if nonexecerr(xerr) {
log.Fatalf("%s: %s", cmd, xerr)
}
if code, _ := exitcode(xerr); code == 0 {
break // all tests passed.
}
if attempt == maxAttempts {
log.Fatalf("failed %d times: %s %s", attempt, pkg, strings.Join(p.tests, " "))
}
// Try again with the new failure instructions. Hopefully with fewer
// failed tests...
r.retryCmds[cmd] = pr.retryCmds[cmd]
}
}
}
// attemptenv returns the environment variable value K=V used to signal
// [flakytest] that it's in a test environment.
func attemptenv(attempt int) string {
return flakytest.FlakeAttemptEnv + "=" + strconv.Itoa(attempt)
}
type testRun struct {
workDir string
buildFailures int
testFailures int
testFailuresRetryable int
retryCmds map[string]pkgRetry // cmd path => retry instructions
}
type pkgRetry struct {
cmd string
args []string
tests []string
}
// run executes prog with args and environ, writing output to stdout and stderr
// and returns the error from [exec.Cmd.Wait], along with information parsed
// from the output about how many builds or tests failed and how to retry them.
func run(prog string, args []string, environ []string, stdout, stderr io.Writer) (r testRun, _ error) {
cmd := exec.Command(prog, args...)
cmd.Env = append(os.Environ(), environ...)
cmdout, err := cmd.StdoutPipe()
if err != nil {
log.Fatalf("StdoutPipe: %s", err)
}
cmderr, err := cmd.StderrPipe()
if err != nil {
log.Fatalf("StderrPipe: %s", err)
}
if err := cmd.Start(); err != nil {
log.Fatalf("Start: %s", err)
}
var wg sync.WaitGroup
// Read WORK= from first line of stderr. We retain this so we can clean it
// when testwrapper ends.
wg.Add(1)
go func() {
defer wg.Done()
err := readthrulines(cmderr, stderr, func(line string) {
if r.workDir == "" {
if w, ok := strings.CutPrefix(line, "WORK="); ok {
r.workDir = w
}
}
})
if err != nil {
log.Fatalf("reading stderr: %s", err)
}
}()
wg.Add(1)
go func() {
defer wg.Done()
err := readthrulines(cmdout, stdout, func(line string) {
if strings.HasPrefix(line, "--- FAIL: Test") {
r.testFailures++
return
}
if strings.HasPrefix(line, "FAIL\t") && strings.HasSuffix(line, "[build failed]") {
r.buildFailures++
return
}
if _, args, ok := strings.Cut(line, "flakytest: retry:"); ok {
wargs := strings.Split(strings.TrimSpace(args), " ")
if len(wargs) < 2 {
log.Printf("failed to retry log line %q", line)
return
}
test, cmd, args := wargs[0], wargs[1], wargs[2:]
p := r.retryCmds[cmd]
p.cmd = cmd
p.args = args
p.tests = append(p.tests, test)
mak.Set(&r.retryCmds, cmd, p)
r.testFailuresRetryable++
return
}
})
if err != nil {
log.Fatalf("reading stdout: %s", err)
}
}()
wg.Wait()
xerr := cmd.Wait()
return r, xerr
}
// exit calls os.Exit with the exit code for err.
func exit(err error) {
code, _ := exitcode(err)
os.Exit(code)
}
// nonexecerr reports whether err is an error which prevented a program executing.
func nonexecerr(err error) bool {
if err == nil {
return false
}
xe := &exec.ExitError{}
return !errors.As(err, &xe) || xe.ExitCode() < 0
}
// exitcode returns a representative error code for err. If err has an
// ExitCode() int method, its exit code is returned.
func exitcode(err error) (code int, ok bool) {
if xe := (interface{ ExitCode() int })(nil); errors.As(err, &xe) {
return xe.ExitCode(), true
}
if err != nil {
return 1, false
}
return 0, false
}
// readthrulines copies r to w, calling f with each line of text.
func readthrulines(r io.Reader, w io.Writer, f func(line string)) error {
s := bufio.NewScanner(r)
for s.Scan() {
line := s.Text()
f(line)
io.WriteString(w, line)
io.WriteString(w, "\n")
}
return s.Err()
}
// cmdPkg will return the package of the binary that was built. From Go 1.24 on,
// this will return the full package path followed by the ".test" from the
// autogenerated main test pkg. For earlier Go versions return base(exe).
func cmdPkg(exe string) string {
v, _ := exec.Command("go", "version", "-m", exe).Output()
_, vp, ok := bytes.Cut(v, []byte("\n\tpath\t"))
if ok {
p, _, _ := bytes.Cut(vp, []byte("\n"))
p = bytes.TrimSpace(p)
if len(p) > 0 {
return string(p)
}
}
return filepath.Base(exe)
}

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 != "" {

4
go.mod
View File

@@ -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
@@ -135,8 +133,6 @@ require (
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

8
go.sum
View File

@@ -240,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=

View File

@@ -0,0 +1,52 @@
// 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 (
tsclient "tailscale.com/client/tailscale"
)
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
}

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,11 @@ type LocalBackend struct {
// and the user has disconnected with a reason.
// See tailscale/corp#26146.
overrideAlwaysOn bool
// 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 +627,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
}
@@ -1033,9 +1098,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 +1329,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 +1512,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) {
@@ -3821,13 +3899,18 @@ func (b *LocalBackend) SetCurrentUser(actor ipnauth.Actor) {
b.switchToBestProfileLockedOnEntry(reason, unlock)
}
// switchToBestProfileLockedOnEntry selects the best profile to use,
// 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 and is used for logging.
//
// b.mu must held on entry. It is released on exit.
// 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()
oldControlURL := b.pm.CurrentPrefs().ControlURLOrDefault()
@@ -3862,8 +3945,9 @@ func (b *LocalBackend) switchToBestProfileLockedOnEntry(reason string, unlock un
}
// resolveBestProfileLocked returns the best profile to use based on the current
// state of the backend, such as whether a GUI/CLI client is connected and whether
// the unattended mode is enabled.
// 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
@@ -3892,7 +3976,8 @@ func (b *LocalBackend) resolveBestProfileLocked() (userID ipn.WindowsUserID, pro
}
// Otherwise, if on Windows, use the background profile if one is set.
// This includes staying on the current profile if Unattended Mode is enabled.
// 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" {
@@ -3909,14 +3994,41 @@ func (b *LocalBackend) resolveBestProfileLocked() (userID ipn.WindowsUserID, pro
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) 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 "", ""
@@ -4260,6 +4372,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.
@@ -5367,18 +5485,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)
}
@@ -6292,8 +6410,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() {
@@ -6301,16 +6417,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)
@@ -6518,6 +6634,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.

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

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

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)

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

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

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

@@ -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 != ""
}