Compare commits
16 Commits
awly/go_12
...
icio/testw
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99865276a7 | ||
|
|
052eefbcce | ||
|
|
9ae9de469a | ||
|
|
8a792ab540 | ||
|
|
4f0222388a | ||
|
|
d923979e65 | ||
|
|
cbf3852b5d | ||
|
|
b21eec7621 | ||
|
|
606f7ef2c6 | ||
|
|
6df5c8f32e | ||
|
|
e11ff28443 | ||
|
|
45f29a208a | ||
|
|
717fa68f3a | ||
|
|
4c3c04a413 | ||
|
|
e142571397 | ||
|
|
1d035db4df |
8
.github/workflows/test.yml
vendored
8
.github/workflows/test.yml
vendored
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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+
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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 doesn’t 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 didn’t 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
|
||||
|
||||
@@ -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
|
||||
//
|
||||
|
||||
@@ -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+
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
239
cmd/testwrapper/testwrapper2.go
Normal file
239
cmd/testwrapper/testwrapper2.go
Normal 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)
|
||||
}
|
||||
@@ -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
4
go.mod
@@ -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
8
go.sum
@@ -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=
|
||||
|
||||
52
internal/client/tailscale/tailscale.go
Normal file
52
internal/client/tailscale/tailscale.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
178
ipn/ipnlocal/desktop_sessions.go
Normal file
178
ipn/ipnlocal/desktop_sessions.go
Normal 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
|
||||
}
|
||||
@@ -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(®isteredExtensions, 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.
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
//
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 != ""
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user