Compare commits
18 Commits
andrew/cur
...
flakes
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93aa395223 | ||
|
|
f0b395d851 | ||
|
|
0663412559 | ||
|
|
eb680edbce | ||
|
|
cd391b37a6 | ||
|
|
45ecc0f85a | ||
|
|
6d217d81d1 | ||
|
|
d83024a63f | ||
|
|
640b2fa3ae | ||
|
|
52710945f5 | ||
|
|
06ae52d309 | ||
|
|
5ebc135397 | ||
|
|
8f0080c7a4 | ||
|
|
03f7f1860e | ||
|
|
ce0d8b0fb9 | ||
|
|
660b0515b9 | ||
|
|
a6e19f2881 | ||
|
|
e38e5c38cc |
6
.github/workflows/codeql-analysis.yml
vendored
6
.github/workflows/codeql-analysis.yml
vendored
@@ -55,7 +55,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d # v3.28.10
|
||||
uses: github/codeql-action/init@6bb031afdd8eb862ea3fc1848194185e076637e5 # v3.28.11
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d # v3.28.10
|
||||
uses: github/codeql-action/autobuild@6bb031afdd8eb862ea3fc1848194185e076637e5 # v3.28.11
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
@@ -80,4 +80,4 @@ jobs:
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d # v3.28.10
|
||||
uses: github/codeql-action/analyze@6bb031afdd8eb862ea3fc1848194185e076637e5 # v3.28.11
|
||||
|
||||
2
.github/workflows/update-flake.yml
vendored
2
.github/workflows/update-flake.yml
vendored
@@ -36,7 +36,7 @@ jobs:
|
||||
private_key: ${{ secrets.LICENSING_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Send pull request
|
||||
uses: peter-evans/create-pull-request@dd2324fc52d5d43c699a5636bcf19fceaa70c284 #v7.0.7
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e #v7.0.8
|
||||
with:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
author: Flakes Updater <noreply+flakes-updater@tailscale.com>
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
|
||||
- name: Send pull request
|
||||
id: pull-request
|
||||
uses: peter-evans/create-pull-request@dd2324fc52d5d43c699a5636bcf19fceaa70c284 #v7.0.7
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e #v7.0.8
|
||||
with:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
author: OSS Updater <noreply+oss-updater@tailscale.com>
|
||||
|
||||
@@ -814,6 +814,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
||||
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/auditlog from tailscale.com/ipn/ipnlocal+
|
||||
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+
|
||||
@@ -904,6 +905,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
||||
tailscale.com/tstime/rate from tailscale.com/derp+
|
||||
tailscale.com/tsweb/varz from tailscale.com/util/usermetric
|
||||
tailscale.com/types/appctype from tailscale.com/ipn/ipnlocal
|
||||
tailscale.com/types/bools from tailscale.com/tsnet
|
||||
tailscale.com/types/dnstype from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/types/empty from tailscale.com/ipn+
|
||||
tailscale.com/types/ipproto from tailscale.com/net/flowtrack+
|
||||
|
||||
@@ -271,6 +271,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
tailscale.com/hostinfo from tailscale.com/client/web+
|
||||
tailscale.com/internal/noiseconn from tailscale.com/control/controlclient
|
||||
tailscale.com/ipn from tailscale.com/client/local+
|
||||
tailscale.com/ipn/auditlog from tailscale.com/ipn/ipnlocal+
|
||||
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+
|
||||
|
||||
@@ -259,6 +259,7 @@ func main() {
|
||||
fmt.Printf("\n\nAttempt #%d: Retrying flaky tests:\n\nflakytest failures JSON: %s\n\n", thisRun.attempt, j)
|
||||
}
|
||||
|
||||
fatalFailures := make(map[string]struct{}) // pkg.Test key
|
||||
toRetry := make(map[string][]*testAttempt) // pkg -> tests to retry
|
||||
for _, pt := range thisRun.tests {
|
||||
ch := make(chan *testAttempt)
|
||||
@@ -301,11 +302,24 @@ func main() {
|
||||
if tr.isMarkedFlaky {
|
||||
toRetry[tr.pkg] = append(toRetry[tr.pkg], tr)
|
||||
} else {
|
||||
fatalFailures[tr.pkg+"."+tr.testName] = struct{}{}
|
||||
failed = true
|
||||
}
|
||||
}
|
||||
if failed {
|
||||
fmt.Println("\n\nNot retrying flaky tests because non-flaky tests failed.")
|
||||
|
||||
// Print the list of non-flakytest failures.
|
||||
// We will later analyze the retried GitHub Action runs to see
|
||||
// if non-flakytest failures succeeded upon retry. This will
|
||||
// highlight tests which are flaky but not yet flagged as such.
|
||||
if len(fatalFailures) > 0 {
|
||||
tests := slicesx.MapKeys(fatalFailures)
|
||||
sort.Strings(tests)
|
||||
j, _ := json.Marshal(tests)
|
||||
fmt.Printf("non-flakytest failures: %s\n", j)
|
||||
}
|
||||
fmt.Println()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
|
||||
@@ -765,6 +765,18 @@ var (
|
||||
)
|
||||
|
||||
func (s *idpServer) serveOpenIDConfig(w http.ResponseWriter, r *http.Request) {
|
||||
h := w.Header()
|
||||
h.Set("Access-Control-Allow-Origin", "*")
|
||||
h.Set("Access-Control-Allow-Method", "GET, OPTIONS")
|
||||
// allow all to prevent errors from client sending their own bespoke headers
|
||||
// and having the server reject the request.
|
||||
h.Set("Access-Control-Allow-Headers", "*")
|
||||
|
||||
// early return for pre-flight OPTIONS requests.
|
||||
if r.Method == "OPTIONS" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
if r.URL.Path != oidcConfigPath {
|
||||
http.Error(w, "tsidp: not found", http.StatusNotFound)
|
||||
return
|
||||
|
||||
@@ -119,6 +119,7 @@ type Auto struct {
|
||||
updateCh chan struct{} // readable when we should inform the server of a change
|
||||
observer Observer // called to update Client status; always non-nil
|
||||
observerQueue execqueue.ExecQueue
|
||||
shutdownFn func() // to be called prior to shutdown or nil
|
||||
|
||||
unregisterHealthWatch func()
|
||||
|
||||
@@ -189,6 +190,7 @@ func NewNoStart(opts Options) (_ *Auto, err error) {
|
||||
mapDone: make(chan struct{}),
|
||||
updateDone: make(chan struct{}),
|
||||
observer: opts.Observer,
|
||||
shutdownFn: opts.Shutdown,
|
||||
}
|
||||
c.authCtx, c.authCancel = context.WithCancel(context.Background())
|
||||
c.authCtx = sockstats.WithSockStats(c.authCtx, sockstats.LabelControlClientAuto, opts.Logf)
|
||||
@@ -755,6 +757,7 @@ func (c *Auto) Shutdown() {
|
||||
return
|
||||
}
|
||||
c.logf("client.Shutdown ...")
|
||||
shutdownFn := c.shutdownFn
|
||||
|
||||
direct := c.direct
|
||||
c.closed = true
|
||||
@@ -767,6 +770,10 @@ func (c *Auto) Shutdown() {
|
||||
c.unpauseWaiters = nil
|
||||
c.mu.Unlock()
|
||||
|
||||
if shutdownFn != nil {
|
||||
shutdownFn()
|
||||
}
|
||||
|
||||
c.unregisterHealthWatch()
|
||||
<-c.authDone
|
||||
<-c.mapDone
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
package controlclient
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"reflect"
|
||||
"slices"
|
||||
@@ -147,3 +149,42 @@ func TestCanSkipStatus(t *testing.T) {
|
||||
t.Errorf("Status fields = %q; this code was only written to handle fields %q", f, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryableErrors(t *testing.T) {
|
||||
errorTests := []struct {
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{errNoNoiseClient, true},
|
||||
{errNoNodeKey, true},
|
||||
{fmt.Errorf("%w: %w", errNoNoiseClient, errors.New("no noise")), true},
|
||||
{fmt.Errorf("%w: %w", errHTTPPostFailure, errors.New("bad post")), true},
|
||||
{fmt.Errorf("%w: %w", errNoNodeKey, errors.New("not node key")), true},
|
||||
{errBadHTTPResponse(429, "too may requests"), true},
|
||||
{errBadHTTPResponse(500, "internal server eror"), true},
|
||||
{errBadHTTPResponse(502, "bad gateway"), true},
|
||||
{errBadHTTPResponse(503, "service unavailable"), true},
|
||||
{errBadHTTPResponse(504, "gateway timeout"), true},
|
||||
{errBadHTTPResponse(1234, "random error"), false},
|
||||
}
|
||||
|
||||
for _, tt := range errorTests {
|
||||
t.Run(tt.err.Error(), func(t *testing.T) {
|
||||
if isRetryableErrorForTest(tt.err) != tt.want {
|
||||
t.Fatalf("retriable: got %v, want %v", tt.err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type retryableForTest interface {
|
||||
Retryable() bool
|
||||
}
|
||||
|
||||
func isRetryableErrorForTest(err error) bool {
|
||||
var ae retryableForTest
|
||||
if errors.As(err, &ae) {
|
||||
return ae.Retryable()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -156,6 +156,11 @@ type Options struct {
|
||||
// If we receive a new DialPlan from the server, this value will be
|
||||
// updated.
|
||||
DialPlan ControlDialPlanner
|
||||
|
||||
// Shutdown is an optional function that will be called before client shutdown is
|
||||
// attempted. It is used to allow the client to clean up any resources or complete any
|
||||
// tasks that are dependent on a live client.
|
||||
Shutdown func()
|
||||
}
|
||||
|
||||
// ControlDialPlanner is the interface optionally supplied when creating a
|
||||
@@ -1662,11 +1667,11 @@ func (c *Auto) SetDeviceAttrs(ctx context.Context, attrs tailcfg.AttrUpdate) err
|
||||
func (c *Direct) SetDeviceAttrs(ctx context.Context, attrs tailcfg.AttrUpdate) error {
|
||||
nc, err := c.getNoiseClient()
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("%w: %w", errNoNoiseClient, err)
|
||||
}
|
||||
nodeKey, ok := c.GetPersist().PublicNodeKeyOK()
|
||||
if !ok {
|
||||
return errors.New("no node key")
|
||||
return errNoNodeKey
|
||||
}
|
||||
if c.panicOnUse {
|
||||
panic("tainted client")
|
||||
@@ -1697,6 +1702,47 @@ func (c *Direct) SetDeviceAttrs(ctx context.Context, attrs tailcfg.AttrUpdate) e
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendAuditLog implements [auditlog.Transport] by sending an audit log synchronously to the control plane.
|
||||
//
|
||||
// See docs on [tailcfg.AuditLogRequest] and [auditlog.Logger] for background.
|
||||
func (c *Auto) SendAuditLog(ctx context.Context, auditLog tailcfg.AuditLogRequest) (err error) {
|
||||
return c.direct.sendAuditLog(ctx, auditLog)
|
||||
}
|
||||
|
||||
func (c *Direct) sendAuditLog(ctx context.Context, auditLog tailcfg.AuditLogRequest) (err error) {
|
||||
nc, err := c.getNoiseClient()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %w", errNoNoiseClient, err)
|
||||
}
|
||||
|
||||
nodeKey, ok := c.GetPersist().PublicNodeKeyOK()
|
||||
if !ok {
|
||||
return errNoNodeKey
|
||||
}
|
||||
|
||||
req := &tailcfg.AuditLogRequest{
|
||||
Version: tailcfg.CurrentCapabilityVersion,
|
||||
NodeKey: nodeKey,
|
||||
Action: auditLog.Action,
|
||||
Details: auditLog.Details,
|
||||
}
|
||||
|
||||
if c.panicOnUse {
|
||||
panic("tainted client")
|
||||
}
|
||||
|
||||
res, err := nc.post(ctx, "/machine/audit-log", nodeKey, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %w", errHTTPPostFailure, err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != 200 {
|
||||
all, _ := io.ReadAll(res.Body)
|
||||
return errBadHTTPResponse(res.StatusCode, string(all))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func addLBHeader(req *http.Request, nodeKey key.NodePublic) {
|
||||
if !nodeKey.IsZero() {
|
||||
req.Header.Add(tailcfg.LBHeader, nodeKey.String())
|
||||
|
||||
51
control/controlclient/errors.go
Normal file
51
control/controlclient/errors.go
Normal file
@@ -0,0 +1,51 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package controlclient
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// apiResponseError is an error type that can be returned by controlclient
|
||||
// api requests.
|
||||
//
|
||||
// It wraps an underlying error and a flag for clients to query if the
|
||||
// error is retryable via the Retryable() method.
|
||||
type apiResponseError struct {
|
||||
err error
|
||||
retryable bool
|
||||
}
|
||||
|
||||
// Error implements [error].
|
||||
func (e *apiResponseError) Error() string {
|
||||
return e.err.Error()
|
||||
}
|
||||
|
||||
// Retryable reports whether the error is retryable.
|
||||
func (e *apiResponseError) Retryable() bool {
|
||||
return e.retryable
|
||||
}
|
||||
|
||||
func (e *apiResponseError) Unwrap() error { return e.err }
|
||||
|
||||
var (
|
||||
errNoNodeKey = &apiResponseError{errors.New("no node key"), true}
|
||||
errNoNoiseClient = &apiResponseError{errors.New("no noise client"), true}
|
||||
errHTTPPostFailure = &apiResponseError{errors.New("http failure"), true}
|
||||
)
|
||||
|
||||
func errBadHTTPResponse(code int, msg string) error {
|
||||
retryable := false
|
||||
switch code {
|
||||
case http.StatusTooManyRequests,
|
||||
http.StatusInternalServerError,
|
||||
http.StatusBadGateway,
|
||||
http.StatusServiceUnavailable,
|
||||
http.StatusGatewayTimeout:
|
||||
retryable = true
|
||||
}
|
||||
return &apiResponseError{fmt.Errorf("http error %d: %s", code, msg), retryable}
|
||||
}
|
||||
@@ -417,6 +417,23 @@ func App() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// IsCertShareReadOnlyMode returns true if this replica should never attempt to
|
||||
// issue or renew TLS credentials for any of the HTTPS endpoints that it is
|
||||
// serving. It should only return certs found in its cert store. Currently,
|
||||
// this is used by the Kubernetes Operator's HA Ingress via VIPServices, where
|
||||
// multiple Ingress proxy instances serve the same HTTPS endpoint with a shared
|
||||
// TLS credentials. The TLS credentials should only be issued by one of the
|
||||
// replicas.
|
||||
// For HTTPS Ingress the operator and containerboot ensure
|
||||
// that read-only replicas will not be serving the HTTPS endpoints before there
|
||||
// is a shared cert available.
|
||||
func IsCertShareReadOnlyMode() bool {
|
||||
m := String("TS_CERT_SHARE_MODE")
|
||||
return m == modeRO
|
||||
}
|
||||
|
||||
const modeRO = "ro"
|
||||
|
||||
// CrashOnUnexpected reports whether the Tailscale client should panic
|
||||
// on unexpected conditions. If TS_DEBUG_CRASH_ON_UNEXPECTED is set, that's
|
||||
// used. Otherwise the default value is true for unstable builds.
|
||||
|
||||
@@ -130,4 +130,4 @@
|
||||
in
|
||||
flake-utils.lib.eachDefaultSystem (system: flakeForSystem nixpkgs system);
|
||||
}
|
||||
# nix-direnv cache busting line: sha256-xO1DuLWi6/lpA9ubA2ZYVJM+CkVNA5IaVGZxX9my0j0=
|
||||
# nix-direnv cache busting line: sha256-SiUkN6BQK1IQmLfkfPetzvYqRu9ENK6+6txtGxegF5Y=
|
||||
|
||||
2
go.mod
2
go.mod
@@ -97,7 +97,7 @@ require (
|
||||
golang.org/x/crypto v0.35.0
|
||||
golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac
|
||||
golang.org/x/mod v0.23.0
|
||||
golang.org/x/net v0.35.0
|
||||
golang.org/x/net v0.36.0
|
||||
golang.org/x/oauth2 v0.26.0
|
||||
golang.org/x/sync v0.11.0
|
||||
golang.org/x/sys v0.30.0
|
||||
|
||||
@@ -1 +1 @@
|
||||
sha256-xO1DuLWi6/lpA9ubA2ZYVJM+CkVNA5IaVGZxX9my0j0=
|
||||
sha256-SiUkN6BQK1IQmLfkfPetzvYqRu9ENK6+6txtGxegF5Y=
|
||||
|
||||
4
go.sum
4
go.sum
@@ -1135,8 +1135,8 @@ golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
|
||||
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
|
||||
golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||
golang.org/x/net v0.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA=
|
||||
golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
|
||||
466
ipn/auditlog/auditlog.go
Normal file
466
ipn/auditlog/auditlog.go
Normal file
@@ -0,0 +1,466 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package auditlog provides a mechanism for logging audit events.
|
||||
package auditlog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/util/rands"
|
||||
"tailscale.com/util/set"
|
||||
)
|
||||
|
||||
// transaction represents an audit log that has not yet been sent to the control plane.
|
||||
type transaction struct {
|
||||
// EventID is the unique identifier for the event being logged.
|
||||
// This is used on the client side only and is not sent to control.
|
||||
EventID string `json:",omitempty"`
|
||||
// Retries is the number of times the logger has attempted to send this log.
|
||||
// This is used on the client side only and is not sent to control.
|
||||
Retries int `json:",omitempty"`
|
||||
|
||||
// Action is the action to be logged. It must correspond to a known action in the control plane.
|
||||
Action tailcfg.ClientAuditAction `json:",omitempty"`
|
||||
// Details is an opaque string specific to the action being logged. Empty strings may not
|
||||
// be valid depending on the action being logged.
|
||||
Details string `json:",omitempty"`
|
||||
// TimeStamp is the time at which the audit log was generated on the node.
|
||||
TimeStamp time.Time `json:",omitzero"`
|
||||
}
|
||||
|
||||
// Transport provides a means for a client to send audit logs to a consumer (typically the control plane).
|
||||
type Transport interface {
|
||||
// SendAuditLog sends an audit log to a consumer of audit logs.
|
||||
// Errors should be checked with [IsRetryableError] for retryability.
|
||||
SendAuditLog(context.Context, tailcfg.AuditLogRequest) error
|
||||
}
|
||||
|
||||
// LogStore provides a means for a [Logger] to persist logs to disk or memory.
|
||||
type LogStore interface {
|
||||
// Save saves the given data to a persistent store. Save will overwrite existing data
|
||||
// for the given key.
|
||||
save(key ipn.ProfileID, txns []*transaction) error
|
||||
|
||||
// Load retrieves the data from a persistent store. Returns a nil slice and
|
||||
// no error if no data exists for the given key.
|
||||
load(key ipn.ProfileID) ([]*transaction, error)
|
||||
}
|
||||
|
||||
// Opts contains the configuration options for a [Logger].
|
||||
type Opts struct {
|
||||
// RetryLimit is the maximum number of attempts the logger will make to send a log before giving up.
|
||||
RetryLimit int
|
||||
// Store is the persistent store used to save logs to disk. Must be non-nil.
|
||||
Store LogStore
|
||||
// Logf is the logger used to log messages from the audit logger. Must be non-nil.
|
||||
Logf logger.Logf
|
||||
}
|
||||
|
||||
// IsRetryableError returns true if the given error is retryable
|
||||
// See [controlclient.apiResponseError]. Potentially retryable errors implement the Retryable() method.
|
||||
func IsRetryableError(err error) bool {
|
||||
var retryable interface{ Retryable() bool }
|
||||
return errors.As(err, &retryable) && retryable.Retryable()
|
||||
}
|
||||
|
||||
type backoffOpts struct {
|
||||
min, max time.Duration
|
||||
multiplier float64
|
||||
}
|
||||
|
||||
// .5, 1, 2, 4, 8, 10, 10, 10, 10, 10...
|
||||
var defaultBackoffOpts = backoffOpts{
|
||||
min: time.Millisecond * 500,
|
||||
max: 10 * time.Second,
|
||||
multiplier: 2,
|
||||
}
|
||||
|
||||
// Logger provides a queue-based mechanism for submitting audit logs to the control plane - or
|
||||
// another suitable consumer. Logs are stored to disk and retried until they are successfully sent,
|
||||
// or until they permanently fail.
|
||||
//
|
||||
// Each individual profile/controlclient tuple should construct and manage a unique [Logger] instance.
|
||||
type Logger struct {
|
||||
logf logger.Logf
|
||||
retryLimit int // the maximum number of attempts to send a log before giving up.
|
||||
flusher chan struct{} // channel used to signal a flush operation.
|
||||
done chan struct{} // closed when the flush worker exits.
|
||||
ctx context.Context // canceled when the logger is stopped.
|
||||
ctxCancel context.CancelFunc // cancels ctx.
|
||||
backoffOpts // backoff settings for retry operations.
|
||||
|
||||
// mu protects the fields below.
|
||||
mu sync.Mutex
|
||||
store LogStore // persistent storage for unsent logs.
|
||||
profileID ipn.ProfileID // empty if [Logger.SetProfileID] has not been called.
|
||||
transport Transport // nil until [Logger.Start] is called.
|
||||
}
|
||||
|
||||
// NewLogger creates a new [Logger] with the given options.
|
||||
func NewLogger(opts Opts) *Logger {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
al := &Logger{
|
||||
retryLimit: opts.RetryLimit,
|
||||
logf: logger.WithPrefix(opts.Logf, "auditlog: "),
|
||||
store: opts.Store,
|
||||
flusher: make(chan struct{}, 1),
|
||||
done: make(chan struct{}),
|
||||
ctx: ctx,
|
||||
ctxCancel: cancel,
|
||||
backoffOpts: defaultBackoffOpts,
|
||||
}
|
||||
al.logf("created")
|
||||
return al
|
||||
}
|
||||
|
||||
// FlushAndStop synchronously flushes all pending logs and stops the audit logger.
|
||||
// This will block until a final flush operation completes or context is done.
|
||||
// If the logger is already stopped, this will return immediately. All unsent
|
||||
// logs will be persisted to the store.
|
||||
func (al *Logger) FlushAndStop(ctx context.Context) {
|
||||
al.stop()
|
||||
al.flush(ctx)
|
||||
}
|
||||
|
||||
// SetProfileID sets the profileID for the logger. This must be called before any logs can be enqueued.
|
||||
// The profileID of a logger cannot be changed once set.
|
||||
func (al *Logger) SetProfileID(profileID ipn.ProfileID) error {
|
||||
al.mu.Lock()
|
||||
defer al.mu.Unlock()
|
||||
if al.profileID != "" {
|
||||
return errors.New("profileID already set")
|
||||
}
|
||||
|
||||
al.profileID = profileID
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start starts the audit logger with the given transport.
|
||||
// It returns an error if the logger is already started.
|
||||
func (al *Logger) Start(t Transport) error {
|
||||
al.mu.Lock()
|
||||
defer al.mu.Unlock()
|
||||
|
||||
if al.transport != nil {
|
||||
return errors.New("already started")
|
||||
}
|
||||
|
||||
al.transport = t
|
||||
pending, err := al.storedCountLocked()
|
||||
if err != nil {
|
||||
al.logf("[unexpected] failed to restore logs: %v", err)
|
||||
}
|
||||
go al.flushWorker()
|
||||
if pending > 0 {
|
||||
al.flushAsync()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ErrAuditLogStorageFailure is returned when the logger fails to persist logs to the store.
|
||||
var ErrAuditLogStorageFailure = errors.New("audit log storage failure")
|
||||
|
||||
// Enqueue queues an audit log to be sent to the control plane (or another suitable consumer/transport).
|
||||
// This will return an error if the underlying store fails to save the log or we fail to generate a unique
|
||||
// eventID for the log.
|
||||
func (al *Logger) Enqueue(action tailcfg.ClientAuditAction, details string) error {
|
||||
txn := &transaction{
|
||||
Action: action,
|
||||
Details: details,
|
||||
TimeStamp: time.Now(),
|
||||
}
|
||||
// Generate a suitably random eventID for the transaction.
|
||||
txn.EventID = fmt.Sprint(txn.TimeStamp, rands.HexString(16))
|
||||
return al.enqueue(txn)
|
||||
}
|
||||
|
||||
// flushAsync requests an asynchronous flush.
|
||||
// It is a no-op if a flush is already pending.
|
||||
func (al *Logger) flushAsync() {
|
||||
select {
|
||||
case al.flusher <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (al *Logger) flushWorker() {
|
||||
defer close(al.done)
|
||||
|
||||
var retryDelay time.Duration
|
||||
retry := time.NewTimer(0)
|
||||
retry.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-al.ctx.Done():
|
||||
return
|
||||
case <-al.flusher:
|
||||
err := al.flush(al.ctx)
|
||||
switch {
|
||||
case errors.Is(err, context.Canceled):
|
||||
// The logger was stopped, no need to retry.
|
||||
return
|
||||
case err != nil:
|
||||
retryDelay = max(al.backoffOpts.min, min(retryDelay*time.Duration(al.backoffOpts.multiplier), al.backoffOpts.max))
|
||||
al.logf("retrying after %v, %v", retryDelay, err)
|
||||
retry.Reset(retryDelay)
|
||||
default:
|
||||
retryDelay = 0
|
||||
retry.Stop()
|
||||
}
|
||||
case <-retry.C:
|
||||
al.flushAsync()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// flush attempts to send all pending logs to the control plane.
|
||||
// l.mu must not be held.
|
||||
func (al *Logger) flush(ctx context.Context) error {
|
||||
al.mu.Lock()
|
||||
pending, err := al.store.load(al.profileID)
|
||||
t := al.transport
|
||||
al.mu.Unlock()
|
||||
|
||||
if err != nil {
|
||||
// This will catch nil profileIDs
|
||||
return fmt.Errorf("failed to restore pending logs: %w", err)
|
||||
}
|
||||
if len(pending) == 0 {
|
||||
return nil
|
||||
}
|
||||
if t == nil {
|
||||
return errors.New("no transport")
|
||||
}
|
||||
|
||||
complete, unsent := al.sendToTransport(ctx, pending, t)
|
||||
al.markTransactionsDone(complete)
|
||||
|
||||
al.mu.Lock()
|
||||
defer al.mu.Unlock()
|
||||
if err = al.appendToStoreLocked(unsent); err != nil {
|
||||
al.logf("[unexpected] failed to persist logs: %v", err)
|
||||
}
|
||||
|
||||
if len(unsent) != 0 {
|
||||
return fmt.Errorf("failed to send %d logs", len(unsent))
|
||||
}
|
||||
|
||||
if len(complete) != 0 {
|
||||
al.logf("complete %d audit log transactions", len(complete))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendToTransport sends all pending logs to the control plane. Returns a pair of slices
|
||||
// containing the logs that were successfully sent (or failed permanently) and those that were not.
|
||||
//
|
||||
// This may require multiple round trips to the control plane and can be a long running transaction.
|
||||
func (al *Logger) sendToTransport(ctx context.Context, pending []*transaction, t Transport) (complete []*transaction, unsent []*transaction) {
|
||||
for i, txn := range pending {
|
||||
req := tailcfg.AuditLogRequest{
|
||||
Action: tailcfg.ClientAuditAction(txn.Action),
|
||||
Details: txn.Details,
|
||||
Timestamp: txn.TimeStamp,
|
||||
}
|
||||
|
||||
if err := t.SendAuditLog(ctx, req); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded):
|
||||
// The contex is done. All further attempts will fail.
|
||||
unsent = append(unsent, pending[i:]...)
|
||||
return complete, unsent
|
||||
case IsRetryableError(err) && txn.Retries+1 < al.retryLimit:
|
||||
// We permit a maximum number of retries for each log. All retriable
|
||||
// errors should be transient and we should be able to send the log eventually, but
|
||||
// we don't want logs to be persisted indefinitely.
|
||||
txn.Retries++
|
||||
unsent = append(unsent, txn)
|
||||
default:
|
||||
complete = append(complete, txn)
|
||||
al.logf("failed permanently: %v", err)
|
||||
}
|
||||
} else {
|
||||
// No error - we're done.
|
||||
complete = append(complete, txn)
|
||||
}
|
||||
}
|
||||
|
||||
return complete, unsent
|
||||
}
|
||||
|
||||
func (al *Logger) stop() {
|
||||
al.mu.Lock()
|
||||
t := al.transport
|
||||
al.mu.Unlock()
|
||||
|
||||
if t == nil {
|
||||
// No transport means no worker goroutine and done will not be
|
||||
// closed if we cancel the context.
|
||||
return
|
||||
}
|
||||
|
||||
al.ctxCancel()
|
||||
<-al.done
|
||||
al.logf("stopped for profileID: %v", al.profileID)
|
||||
}
|
||||
|
||||
// appendToStoreLocked persists logs to the store. This will deduplicate
|
||||
// logs so it is safe to call this with the same logs multiple time, to
|
||||
// requeue failed transactions for example.
|
||||
//
|
||||
// l.mu must be held.
|
||||
func (al *Logger) appendToStoreLocked(txns []*transaction) error {
|
||||
if len(txns) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if al.profileID == "" {
|
||||
return errors.New("no logId set")
|
||||
}
|
||||
|
||||
persisted, err := al.store.load(al.profileID)
|
||||
if err != nil {
|
||||
al.logf("[unexpected] append failed to restore logs: %v", err)
|
||||
}
|
||||
|
||||
// The order is important here. We want the latest transactions first, which will
|
||||
// ensure when we dedup, the new transactions are seen and the older transactions
|
||||
// are discarded.
|
||||
txnsOut := append(txns, persisted...)
|
||||
txnsOut = deduplicateAndSort(txnsOut)
|
||||
|
||||
return al.store.save(al.profileID, txnsOut)
|
||||
}
|
||||
|
||||
// storedCountLocked returns the number of logs persisted to the store.
|
||||
// al.mu must be held.
|
||||
func (al *Logger) storedCountLocked() (int, error) {
|
||||
persisted, err := al.store.load(al.profileID)
|
||||
return len(persisted), err
|
||||
}
|
||||
|
||||
// markTransactionsDone removes logs from the store that are complete (sent or failed permanently).
|
||||
// al.mu must not be held.
|
||||
func (al *Logger) markTransactionsDone(sent []*transaction) {
|
||||
al.mu.Lock()
|
||||
defer al.mu.Unlock()
|
||||
|
||||
ids := set.Set[string]{}
|
||||
for _, txn := range sent {
|
||||
ids.Add(txn.EventID)
|
||||
}
|
||||
|
||||
persisted, err := al.store.load(al.profileID)
|
||||
if err != nil {
|
||||
al.logf("[unexpected] markTransactionsDone failed to restore logs: %v", err)
|
||||
}
|
||||
var unsent []*transaction
|
||||
for _, txn := range persisted {
|
||||
if !ids.Contains(txn.EventID) {
|
||||
unsent = append(unsent, txn)
|
||||
}
|
||||
}
|
||||
al.store.save(al.profileID, unsent)
|
||||
}
|
||||
|
||||
// deduplicateAndSort removes duplicate logs from the given slice and sorts them by timestamp.
|
||||
// The first log entry in the slice will be retained, subsequent logs with the same EventID will be discarded.
|
||||
func deduplicateAndSort(txns []*transaction) []*transaction {
|
||||
seen := set.Set[string]{}
|
||||
deduped := make([]*transaction, 0, len(txns))
|
||||
for _, txn := range txns {
|
||||
if !seen.Contains(txn.EventID) {
|
||||
deduped = append(deduped, txn)
|
||||
seen.Add(txn.EventID)
|
||||
}
|
||||
}
|
||||
// Sort logs by timestamp - oldest to newest. This will put the oldest logs at
|
||||
// the front of the queue.
|
||||
sort.Slice(deduped, func(i, j int) bool {
|
||||
return deduped[i].TimeStamp.Before(deduped[j].TimeStamp)
|
||||
})
|
||||
return deduped
|
||||
}
|
||||
|
||||
func (al *Logger) enqueue(txn *transaction) error {
|
||||
al.mu.Lock()
|
||||
defer al.mu.Unlock()
|
||||
|
||||
if err := al.appendToStoreLocked([]*transaction{txn}); err != nil {
|
||||
return fmt.Errorf("%w: %w", ErrAuditLogStorageFailure, err)
|
||||
}
|
||||
|
||||
// If a.transport is nil if the logger is stopped.
|
||||
if al.transport != nil {
|
||||
al.flushAsync()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ LogStore = (*logStateStore)(nil)
|
||||
|
||||
// logStateStore is a concrete implementation of [LogStore]
|
||||
// using [ipn.StateStore] as the underlying storage.
|
||||
type logStateStore struct {
|
||||
store ipn.StateStore
|
||||
}
|
||||
|
||||
// NewLogStore creates a new LogStateStore with the given [ipn.StateStore].
|
||||
func NewLogStore(store ipn.StateStore) LogStore {
|
||||
return &logStateStore{
|
||||
store: store,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *logStateStore) generateKey(key ipn.ProfileID) string {
|
||||
return "auditlog-" + string(key)
|
||||
}
|
||||
|
||||
// Save saves the given logs to an [ipn.StateStore]. This overwrites
|
||||
// any existing entries for the given key.
|
||||
func (s *logStateStore) save(key ipn.ProfileID, txns []*transaction) error {
|
||||
if key == "" {
|
||||
return errors.New("empty key")
|
||||
}
|
||||
|
||||
data, err := json.Marshal(txns)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
k := ipn.StateKey(s.generateKey(key))
|
||||
return s.store.WriteState(k, data)
|
||||
}
|
||||
|
||||
// Load retrieves the logs from an [ipn.StateStore].
|
||||
func (s *logStateStore) load(key ipn.ProfileID) ([]*transaction, error) {
|
||||
if key == "" {
|
||||
return nil, errors.New("empty key")
|
||||
}
|
||||
|
||||
k := ipn.StateKey(s.generateKey(key))
|
||||
data, err := s.store.ReadState(k)
|
||||
|
||||
switch {
|
||||
case errors.Is(err, ipn.ErrStateNotExist):
|
||||
return nil, nil
|
||||
case err != nil:
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var txns []*transaction
|
||||
err = json.Unmarshal(data, &txns)
|
||||
return txns, err
|
||||
}
|
||||
481
ipn/auditlog/auditlog_test.go
Normal file
481
ipn/auditlog/auditlog_test.go
Normal file
@@ -0,0 +1,481 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package auditlog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
qt "github.com/frankban/quicktest"
|
||||
"tailscale.com/ipn/store/mem"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/tstest"
|
||||
)
|
||||
|
||||
// loggerForTest creates an auditLogger for you and cleans it up
|
||||
// (and ensures no goroutines are leaked) when the test is done.
|
||||
func loggerForTest(t *testing.T, opts Opts) *Logger {
|
||||
t.Helper()
|
||||
tstest.ResourceCheck(t)
|
||||
|
||||
if opts.Logf == nil {
|
||||
opts.Logf = t.Logf
|
||||
}
|
||||
|
||||
if opts.Store == nil {
|
||||
t.Fatalf("opts.Store must be set")
|
||||
}
|
||||
|
||||
a := NewLogger(opts)
|
||||
|
||||
t.Cleanup(func() {
|
||||
a.FlushAndStop(context.Background())
|
||||
})
|
||||
return a
|
||||
}
|
||||
|
||||
func TestNonRetryableErrors(t *testing.T) {
|
||||
errorTests := []struct {
|
||||
desc string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{"DeadlineExceeded", context.DeadlineExceeded, false},
|
||||
{"Canceled", context.Canceled, false},
|
||||
{"Canceled wrapped", fmt.Errorf("%w: %w", context.Canceled, errors.New("ctx cancelled")), false},
|
||||
{"Random error", errors.New("random error"), false},
|
||||
}
|
||||
|
||||
for _, tt := range errorTests {
|
||||
t.Run(tt.desc, func(t *testing.T) {
|
||||
if IsRetryableError(tt.err) != tt.want {
|
||||
t.Fatalf("retriable: got %v, want %v", !tt.want, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnqueueAndFlush enqueues n logs and flushes them.
|
||||
// We expect all logs to be flushed and for no
|
||||
// logs to remain in the store once FlushAndStop returns.
|
||||
func TestEnqueueAndFlush(t *testing.T) {
|
||||
c := qt.New(t)
|
||||
mockTransport := newMockTransport(nil)
|
||||
al := loggerForTest(t, Opts{
|
||||
RetryLimit: 200,
|
||||
Logf: t.Logf,
|
||||
Store: NewLogStore(&mem.Store{}),
|
||||
})
|
||||
|
||||
c.Assert(al.SetProfileID("test"), qt.IsNil)
|
||||
c.Assert(al.Start(mockTransport), qt.IsNil)
|
||||
|
||||
wantSent := 10
|
||||
|
||||
for i := range wantSent {
|
||||
err := al.Enqueue(tailcfg.AuditNodeDisconnect, fmt.Sprintf("log %d", i))
|
||||
c.Assert(err, qt.IsNil)
|
||||
}
|
||||
|
||||
al.FlushAndStop(context.Background())
|
||||
|
||||
al.mu.Lock()
|
||||
defer al.mu.Unlock()
|
||||
gotStored, err := al.storedCountLocked()
|
||||
c.Assert(err, qt.IsNil)
|
||||
|
||||
if wantStored := 0; gotStored != wantStored {
|
||||
t.Fatalf("stored: got %d, want %d", gotStored, wantStored)
|
||||
}
|
||||
|
||||
if gotSent := mockTransport.sentCount(); gotSent != wantSent {
|
||||
t.Fatalf("sent: got %d, want %d", gotSent, wantSent)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnqueueAndFlushWithFlushCancel calls FlushAndCancel with a cancelled
|
||||
// context. We expect nothing to be sent and all logs to be stored.
|
||||
func TestEnqueueAndFlushWithFlushCancel(t *testing.T) {
|
||||
c := qt.New(t)
|
||||
mockTransport := newMockTransport(&retriableError)
|
||||
al := loggerForTest(t, Opts{
|
||||
RetryLimit: 200,
|
||||
Logf: t.Logf,
|
||||
Store: NewLogStore(&mem.Store{}),
|
||||
})
|
||||
|
||||
c.Assert(al.SetProfileID("test"), qt.IsNil)
|
||||
c.Assert(al.Start(mockTransport), qt.IsNil)
|
||||
|
||||
for i := range 10 {
|
||||
err := al.Enqueue(tailcfg.AuditNodeDisconnect, fmt.Sprintf("log %d", i))
|
||||
c.Assert(err, qt.IsNil)
|
||||
}
|
||||
|
||||
// Cancel the context before calling FlushAndStop - nothing should get sent.
|
||||
// This mimics a timeout before flush() has a chance to execute.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
al.FlushAndStop(ctx)
|
||||
|
||||
al.mu.Lock()
|
||||
defer al.mu.Unlock()
|
||||
gotStored, err := al.storedCountLocked()
|
||||
c.Assert(err, qt.IsNil)
|
||||
|
||||
if wantStored := 10; gotStored != wantStored {
|
||||
t.Fatalf("stored: got %d, want %d", gotStored, wantStored)
|
||||
}
|
||||
|
||||
if gotSent, wantSent := mockTransport.sentCount(), 0; gotSent != wantSent {
|
||||
t.Fatalf("sent: got %d, want %d", gotSent, wantSent)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeduplicateAndSort tests that the most recent log is kept when deduplicating logs
|
||||
func TestDeduplicateAndSort(t *testing.T) {
|
||||
c := qt.New(t)
|
||||
al := loggerForTest(t, Opts{
|
||||
RetryLimit: 100,
|
||||
Logf: t.Logf,
|
||||
Store: NewLogStore(&mem.Store{}),
|
||||
})
|
||||
|
||||
c.Assert(al.SetProfileID("test"), qt.IsNil)
|
||||
|
||||
logs := []*transaction{
|
||||
{EventID: "1", Details: "log 1", TimeStamp: time.Now().Add(-time.Minute * 1), Retries: 1},
|
||||
}
|
||||
|
||||
al.mu.Lock()
|
||||
defer al.mu.Unlock()
|
||||
al.appendToStoreLocked(logs)
|
||||
|
||||
// Update the transaction and re-append it
|
||||
logs[0].Retries = 2
|
||||
al.appendToStoreLocked(logs)
|
||||
|
||||
fromStore, err := al.store.load("test")
|
||||
c.Assert(err, qt.IsNil)
|
||||
|
||||
// We should see only one transaction
|
||||
if wantStored, gotStored := len(logs), len(fromStore); gotStored != wantStored {
|
||||
t.Fatalf("stored: got %d, want %d", gotStored, wantStored)
|
||||
}
|
||||
|
||||
// We should see the latest transaction
|
||||
if wantRetryCount, gotRetryCount := 2, fromStore[0].Retries; gotRetryCount != wantRetryCount {
|
||||
t.Fatalf("reties: got %d, want %d", gotRetryCount, wantRetryCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangeProfileId(t *testing.T) {
|
||||
c := qt.New(t)
|
||||
al := loggerForTest(t, Opts{
|
||||
RetryLimit: 100,
|
||||
Logf: t.Logf,
|
||||
Store: NewLogStore(&mem.Store{}),
|
||||
})
|
||||
c.Assert(al.SetProfileID("test"), qt.IsNil)
|
||||
|
||||
// Changing a profile ID must fail
|
||||
c.Assert(al.SetProfileID("test"), qt.IsNotNil)
|
||||
}
|
||||
|
||||
// TestSendOnRestore pushes a n logs to the persistent store, and ensures they
|
||||
// are sent as soon as Start is called then checks to ensure the sent logs no
|
||||
// longer exist in the store.
|
||||
func TestSendOnRestore(t *testing.T) {
|
||||
c := qt.New(t)
|
||||
mockTransport := newMockTransport(nil)
|
||||
al := loggerForTest(t, Opts{
|
||||
RetryLimit: 100,
|
||||
Logf: t.Logf,
|
||||
Store: NewLogStore(&mem.Store{}),
|
||||
})
|
||||
al.SetProfileID("test")
|
||||
|
||||
wantTotal := 10
|
||||
|
||||
for range 10 {
|
||||
al.Enqueue(tailcfg.AuditNodeDisconnect, "log")
|
||||
}
|
||||
|
||||
c.Assert(al.Start(mockTransport), qt.IsNil)
|
||||
|
||||
al.FlushAndStop(context.Background())
|
||||
|
||||
al.mu.Lock()
|
||||
defer al.mu.Unlock()
|
||||
gotStored, err := al.storedCountLocked()
|
||||
c.Assert(err, qt.IsNil)
|
||||
|
||||
if wantStored := 0; gotStored != wantStored {
|
||||
t.Fatalf("stored: got %d, want %d", gotStored, wantStored)
|
||||
}
|
||||
|
||||
if gotSent, wantSent := mockTransport.sentCount(), wantTotal; gotSent != wantSent {
|
||||
t.Fatalf("sent: got %d, want %d", gotSent, wantSent)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFailureExhaustion enqueues n logs, with the transport in a failable state.
|
||||
// We then set it to a non-failing state, call FlushAndStop and expect all logs to be sent.
|
||||
func TestFailureExhaustion(t *testing.T) {
|
||||
c := qt.New(t)
|
||||
mockTransport := newMockTransport(&retriableError)
|
||||
|
||||
al := loggerForTest(t, Opts{
|
||||
RetryLimit: 1,
|
||||
Logf: t.Logf,
|
||||
Store: NewLogStore(&mem.Store{}),
|
||||
})
|
||||
|
||||
c.Assert(al.SetProfileID("test"), qt.IsNil)
|
||||
c.Assert(al.Start(mockTransport), qt.IsNil)
|
||||
|
||||
for range 10 {
|
||||
err := al.Enqueue(tailcfg.AuditNodeDisconnect, "log")
|
||||
c.Assert(err, qt.IsNil)
|
||||
}
|
||||
|
||||
al.FlushAndStop(context.Background())
|
||||
al.mu.Lock()
|
||||
defer al.mu.Unlock()
|
||||
gotStored, err := al.storedCountLocked()
|
||||
c.Assert(err, qt.IsNil)
|
||||
|
||||
if wantStored := 0; gotStored != wantStored {
|
||||
t.Fatalf("stored: got %d, want %d", gotStored, wantStored)
|
||||
}
|
||||
|
||||
if gotSent, wantSent := mockTransport.sentCount(), 0; gotSent != wantSent {
|
||||
t.Fatalf("sent: got %d, want %d", gotSent, wantSent)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnqueueAndFailNoRetry enqueues a set of logs, all of which will fail and are not
|
||||
// retriable. We then call FlushAndStop and expect all to be unsent.
|
||||
func TestEnqueueAndFailNoRetry(t *testing.T) {
|
||||
c := qt.New(t)
|
||||
mockTransport := newMockTransport(&nonRetriableError)
|
||||
|
||||
al := loggerForTest(t, Opts{
|
||||
RetryLimit: 100,
|
||||
Logf: t.Logf,
|
||||
Store: NewLogStore(&mem.Store{}),
|
||||
})
|
||||
|
||||
c.Assert(al.SetProfileID("test"), qt.IsNil)
|
||||
c.Assert(al.Start(mockTransport), qt.IsNil)
|
||||
|
||||
for i := range 10 {
|
||||
err := al.Enqueue(tailcfg.AuditNodeDisconnect, fmt.Sprintf("log %d", i))
|
||||
c.Assert(err, qt.IsNil)
|
||||
}
|
||||
|
||||
al.FlushAndStop(context.Background())
|
||||
al.mu.Lock()
|
||||
defer al.mu.Unlock()
|
||||
gotStored, err := al.storedCountLocked()
|
||||
c.Assert(err, qt.IsNil)
|
||||
|
||||
if wantStored := 0; gotStored != wantStored {
|
||||
t.Fatalf("stored: got %d, want %d", gotStored, wantStored)
|
||||
}
|
||||
|
||||
if gotSent, wantSent := mockTransport.sentCount(), 0; gotSent != wantSent {
|
||||
t.Fatalf("sent: got %d, want %d", gotSent, wantSent)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnqueueAndRetry enqueues a set of logs, all of which will fail and are retriable.
|
||||
// Mid-test, we set the transport to not-fail and expect the queue to flush properly
|
||||
// We set the backoff parameters to 0 seconds so retries are immediate.
|
||||
func TestEnqueueAndRetry(t *testing.T) {
|
||||
c := qt.New(t)
|
||||
mockTransport := newMockTransport(&retriableError)
|
||||
|
||||
al := loggerForTest(t, Opts{
|
||||
RetryLimit: 100,
|
||||
Logf: t.Logf,
|
||||
Store: NewLogStore(&mem.Store{}),
|
||||
})
|
||||
|
||||
al.backoffOpts = backoffOpts{
|
||||
min: 1 * time.Millisecond,
|
||||
max: 4 * time.Millisecond,
|
||||
multiplier: 2.0,
|
||||
}
|
||||
|
||||
c.Assert(al.SetProfileID("test"), qt.IsNil)
|
||||
c.Assert(al.Start(mockTransport), qt.IsNil)
|
||||
|
||||
err := al.Enqueue(tailcfg.AuditNodeDisconnect, fmt.Sprintf("log 1"))
|
||||
c.Assert(err, qt.IsNil)
|
||||
|
||||
// This will wait for at least 2 retries
|
||||
gotRetried, wantRetried := mockTransport.waitForSendAttemptsToReach(3), true
|
||||
if gotRetried != wantRetried {
|
||||
t.Fatalf("retried: got %v, want %v", gotRetried, wantRetried)
|
||||
}
|
||||
|
||||
mockTransport.setErrorCondition(nil)
|
||||
|
||||
al.FlushAndStop(context.Background())
|
||||
al.mu.Lock()
|
||||
defer al.mu.Unlock()
|
||||
|
||||
gotStored, err := al.storedCountLocked()
|
||||
c.Assert(err, qt.IsNil)
|
||||
|
||||
if wantStored := 0; gotStored != wantStored {
|
||||
t.Fatalf("stored: got %d, want %d", gotStored, wantStored)
|
||||
}
|
||||
|
||||
if gotSent, wantSent := mockTransport.sentCount(), 1; gotSent != wantSent {
|
||||
t.Fatalf("sent: got %d, want %d", gotSent, wantSent)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnqueueBeforeSetProfileID tests that logs enqueued before SetProfileId are not sent
|
||||
func TestEnqueueBeforeSetProfileID(t *testing.T) {
|
||||
c := qt.New(t)
|
||||
al := loggerForTest(t, Opts{
|
||||
RetryLimit: 100,
|
||||
Logf: t.Logf,
|
||||
Store: NewLogStore(&mem.Store{}),
|
||||
})
|
||||
|
||||
err := al.Enqueue(tailcfg.AuditNodeDisconnect, "log")
|
||||
c.Assert(err, qt.IsNotNil)
|
||||
al.FlushAndStop(context.Background())
|
||||
|
||||
al.mu.Lock()
|
||||
defer al.mu.Unlock()
|
||||
gotStored, err := al.storedCountLocked()
|
||||
c.Assert(err, qt.IsNotNil)
|
||||
|
||||
if wantStored := 0; gotStored != wantStored {
|
||||
t.Fatalf("stored: got %d, want %d", gotStored, wantStored)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLogStoring tests that audit logs are persisted sorted by timestamp, oldest to newest
|
||||
func TestLogSorting(t *testing.T) {
|
||||
c := qt.New(t)
|
||||
mockStore := NewLogStore(&mem.Store{})
|
||||
|
||||
logs := []*transaction{
|
||||
{EventID: "1", Details: "log 3", TimeStamp: time.Now().Add(-time.Minute * 1)},
|
||||
{EventID: "1", Details: "log 3", TimeStamp: time.Now().Add(-time.Minute * 2)},
|
||||
{EventID: "2", Details: "log 2", TimeStamp: time.Now().Add(-time.Minute * 3)},
|
||||
{EventID: "3", Details: "log 1", TimeStamp: time.Now().Add(-time.Minute * 4)},
|
||||
}
|
||||
|
||||
wantLogs := []transaction{
|
||||
{Details: "log 1"},
|
||||
{Details: "log 2"},
|
||||
{Details: "log 3"},
|
||||
}
|
||||
|
||||
mockStore.save("test", logs)
|
||||
|
||||
gotLogs, err := mockStore.load("test")
|
||||
c.Assert(err, qt.IsNil)
|
||||
gotLogs = deduplicateAndSort(gotLogs)
|
||||
|
||||
for i := range gotLogs {
|
||||
if want, got := wantLogs[i].Details, gotLogs[i].Details; want != got {
|
||||
t.Fatalf("Details: got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// mock implementations for testing
|
||||
|
||||
// newMockTransport returns a mock transport for testing
|
||||
// If err is no nil, SendAuditLog will return this error if the send is attempted
|
||||
// before the context is cancelled.
|
||||
func newMockTransport(err error) *mockAuditLogTransport {
|
||||
return &mockAuditLogTransport{
|
||||
err: err,
|
||||
attempts: make(chan int, 1),
|
||||
}
|
||||
}
|
||||
|
||||
type mockAuditLogTransport struct {
|
||||
attempts chan int // channel to notify of send attempts
|
||||
|
||||
mu sync.Mutex
|
||||
sendAttmpts int // number of attempts to send logs
|
||||
sendCount int // number of logs sent by the transport
|
||||
err error // error to return when sending logs
|
||||
}
|
||||
|
||||
// waitForSendAttemptsToReach blocks until the number of send attempts reaches n
|
||||
// This should be use only in tests where the transport is expected to retry sending logs
|
||||
func (t *mockAuditLogTransport) waitForSendAttemptsToReach(n int) bool {
|
||||
for attempts := range t.attempts {
|
||||
if attempts >= n {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (t *mockAuditLogTransport) setErrorCondition(err error) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.err = err
|
||||
}
|
||||
|
||||
func (t *mockAuditLogTransport) sentCount() int {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return t.sendCount
|
||||
}
|
||||
|
||||
func (t *mockAuditLogTransport) SendAuditLog(ctx context.Context, _ tailcfg.AuditLogRequest) (err error) {
|
||||
t.mu.Lock()
|
||||
t.sendAttmpts += 1
|
||||
defer func() {
|
||||
a := t.sendAttmpts
|
||||
t.mu.Unlock()
|
||||
select {
|
||||
case t.attempts <- a:
|
||||
default:
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
if t.err != nil {
|
||||
return t.err
|
||||
}
|
||||
t.sendCount += 1
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
retriableError = mockError{errors.New("retriable error")}
|
||||
nonRetriableError = mockError{errors.New("permanent failure error")}
|
||||
)
|
||||
|
||||
type mockError struct {
|
||||
error
|
||||
}
|
||||
|
||||
func (e mockError) Retryable() bool {
|
||||
return e == retriableError
|
||||
}
|
||||
@@ -10,12 +10,11 @@ import (
|
||||
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
// AuditLogFunc is any function that can be used to log audit actions performed by an [Actor].
|
||||
//
|
||||
// TODO(nickkhyl,barnstar): define a named string type for the action (in tailcfg?) and use it here.
|
||||
type AuditLogFunc func(action, details string)
|
||||
type AuditLogFunc func(action tailcfg.ClientAuditAction, details string) error
|
||||
|
||||
// Actor is any actor using the [ipnlocal.LocalBackend].
|
||||
//
|
||||
@@ -45,7 +44,7 @@ type Actor interface {
|
||||
//
|
||||
// If the auditLogger is non-nil, it is used to write details about the action
|
||||
// to the audit log when required by the policy.
|
||||
CheckProfileAccess(profile ipn.LoginProfileView, requestedAccess ProfileAccess, auditLogger AuditLogFunc) error
|
||||
CheckProfileAccess(profile ipn.LoginProfileView, requestedAccess ProfileAccess, auditLogFn AuditLogFunc) error
|
||||
|
||||
// IsLocalSystem reports whether the actor is the Windows' Local System account.
|
||||
//
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/util/syspolicy"
|
||||
)
|
||||
|
||||
@@ -48,7 +49,7 @@ func (a actorWithPolicyChecks) CheckProfileAccess(profile ipn.LoginProfileView,
|
||||
//
|
||||
// TODO(nickkhyl): unexport it when we move [ipn.Actor] implementations from [ipnserver]
|
||||
// and corp to this package.
|
||||
func CheckDisconnectPolicy(actor Actor, profile ipn.LoginProfileView, reason string, auditLogger AuditLogFunc) error {
|
||||
func CheckDisconnectPolicy(actor Actor, profile ipn.LoginProfileView, reason string, auditFn AuditLogFunc) error {
|
||||
if alwaysOn, _ := syspolicy.GetBoolean(syspolicy.AlwaysOn, false); !alwaysOn {
|
||||
return nil
|
||||
}
|
||||
@@ -58,15 +59,16 @@ func CheckDisconnectPolicy(actor Actor, profile ipn.LoginProfileView, reason str
|
||||
if reason == "" {
|
||||
return errors.New("disconnect not allowed: reason required")
|
||||
}
|
||||
if auditLogger != nil {
|
||||
if auditFn != nil {
|
||||
var details string
|
||||
if username, _ := actor.Username(); username != "" { // best-effort; we don't have it on all platforms
|
||||
details = fmt.Sprintf("%q is being disconnected by %q: %v", profile.Name(), username, reason)
|
||||
} else {
|
||||
details = fmt.Sprintf("%q is being disconnected: %v", profile.Name(), reason)
|
||||
}
|
||||
// TODO(nickkhyl,barnstar): use a const for DISCONNECT_NODE.
|
||||
auditLogger("DISCONNECT_NODE", details)
|
||||
if err := auditFn(tailcfg.AuditNodeDisconnect, details); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -119,6 +119,9 @@ func (b *LocalBackend) GetCertPEMWithValidity(ctx context.Context, domain string
|
||||
}
|
||||
|
||||
if pair, err := getCertPEMCached(cs, domain, now); err == nil {
|
||||
if envknob.IsCertShareReadOnlyMode() {
|
||||
return pair, nil
|
||||
}
|
||||
// If we got here, we have a valid unexpired cert.
|
||||
// Check whether we should start an async renewal.
|
||||
shouldRenew, err := b.shouldStartDomainRenewal(cs, domain, now, pair, minValidity)
|
||||
@@ -134,7 +137,7 @@ func (b *LocalBackend) GetCertPEMWithValidity(ctx context.Context, domain string
|
||||
if minValidity == 0 {
|
||||
logf("starting async renewal")
|
||||
// Start renewal in the background, return current valid cert.
|
||||
go b.getCertPEM(context.Background(), cs, logf, traceACME, domain, now, minValidity)
|
||||
b.goTracker.Go(func() { getCertPEM(context.Background(), b, cs, logf, traceACME, domain, now, minValidity) })
|
||||
return pair, nil
|
||||
}
|
||||
// If the caller requested a specific validity duration, fall through
|
||||
@@ -142,7 +145,11 @@ func (b *LocalBackend) GetCertPEMWithValidity(ctx context.Context, domain string
|
||||
logf("starting sync renewal")
|
||||
}
|
||||
|
||||
pair, err := b.getCertPEM(ctx, cs, logf, traceACME, domain, now, minValidity)
|
||||
if envknob.IsCertShareReadOnlyMode() {
|
||||
return nil, fmt.Errorf("retrieving cached TLS certificate failed and cert store is configured in read-only mode, not attempting to issue a new certificate: %w", err)
|
||||
}
|
||||
|
||||
pair, err := getCertPEM(ctx, b, cs, logf, traceACME, domain, now, minValidity)
|
||||
if err != nil {
|
||||
logf("getCertPEM: %v", err)
|
||||
return nil, err
|
||||
@@ -358,7 +365,29 @@ type certStateStore struct {
|
||||
testRoots *x509.CertPool
|
||||
}
|
||||
|
||||
// TLSCertKeyReader is an interface implemented by state stores where it makes
|
||||
// sense to read the TLS cert and key in a single operation that can be
|
||||
// distinguished from generic state value reads. Currently this is only implemented
|
||||
// by the kubestore.Store, which, in some cases, need to read cert and key from a
|
||||
// non-cached TLS Secret.
|
||||
type TLSCertKeyReader interface {
|
||||
ReadTLSCertAndKey(domain string) ([]byte, []byte, error)
|
||||
}
|
||||
|
||||
func (s certStateStore) Read(domain string, now time.Time) (*TLSCertKeyPair, error) {
|
||||
// If we're using a store that supports atomic reads, use that
|
||||
if kr, ok := s.StateStore.(TLSCertKeyReader); ok {
|
||||
cert, key, err := kr.ReadTLSCertAndKey(domain)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !validCertPEM(domain, key, cert, s.testRoots, now) {
|
||||
return nil, errCertExpired
|
||||
}
|
||||
return &TLSCertKeyPair{CertPEM: cert, KeyPEM: key, Cached: true}, nil
|
||||
}
|
||||
|
||||
// Otherwise fall back to separate reads
|
||||
certPEM, err := s.ReadState(ipn.StateKey(domain + ".crt"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -446,7 +475,9 @@ func getCertPEMCached(cs certStore, domain string, now time.Time) (p *TLSCertKey
|
||||
return cs.Read(domain, now)
|
||||
}
|
||||
|
||||
func (b *LocalBackend) getCertPEM(ctx context.Context, cs certStore, logf logger.Logf, traceACME func(any), domain string, now time.Time, minValidity time.Duration) (*TLSCertKeyPair, error) {
|
||||
// getCertPem checks if a cert needs to be renewed and if so, renews it.
|
||||
// It can be overridden in tests.
|
||||
var getCertPEM = func(ctx context.Context, b *LocalBackend, cs certStore, logf logger.Logf, traceACME func(any), domain string, now time.Time, minValidity time.Duration) (*TLSCertKeyPair, error) {
|
||||
acmeMu.Lock()
|
||||
defer acmeMu.Unlock()
|
||||
|
||||
@@ -471,6 +502,10 @@ func (b *LocalBackend) getCertPEM(ctx context.Context, cs certStore, logf logger
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !isDefaultDirectoryURL(ac.DirectoryURL) {
|
||||
logf("acme: using Directory URL %q", ac.DirectoryURL)
|
||||
}
|
||||
|
||||
a, err := ac.GetReg(ctx, "" /* pre-RFC param */)
|
||||
switch {
|
||||
case err == nil:
|
||||
@@ -737,7 +772,28 @@ func validateLeaf(leaf *x509.Certificate, intermediates *x509.CertPool, domain s
|
||||
// binary's baked-in roots (LetsEncrypt). See tailscale/tailscale#14690.
|
||||
return validateLeaf(leaf, intermediates, domain, now, bakedroots.Get())
|
||||
}
|
||||
return err == nil
|
||||
|
||||
if err == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
// When pointed at a non-prod ACME server, we don't expect to have the CA
|
||||
// in our system or baked-in roots. Verify only throws UnknownAuthorityError
|
||||
// after first checking the leaf cert's expiry, hostnames etc, so we know
|
||||
// that the only reason for an error is to do with constructing a full chain.
|
||||
// Allow this error so that cert caching still works in testing environments.
|
||||
if errors.As(err, &x509.UnknownAuthorityError{}) {
|
||||
acmeURL := envknob.String("TS_DEBUG_ACME_DIRECTORY_URL")
|
||||
if !isDefaultDirectoryURL(acmeURL) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func isDefaultDirectoryURL(u string) bool {
|
||||
return u == "" || u == acme.LetsEncryptURL
|
||||
}
|
||||
|
||||
// validLookingCertDomain reports whether name looks like a valid domain name that
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
package ipnlocal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
@@ -14,11 +15,17 @@ import (
|
||||
"embed"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"tailscale.com/envknob"
|
||||
"tailscale.com/ipn/store/mem"
|
||||
"tailscale.com/tstest"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/util/must"
|
||||
)
|
||||
|
||||
func TestValidLookingCertDomain(t *testing.T) {
|
||||
@@ -47,10 +54,10 @@ var certTestFS embed.FS
|
||||
func TestCertStoreRoundTrip(t *testing.T) {
|
||||
const testDomain = "example.com"
|
||||
|
||||
// Use a fixed verification timestamp so validity doesn't fall off when the
|
||||
// cert expires. If you update the test data below, this may also need to be
|
||||
// updated.
|
||||
// Use fixed verification timestamps so validity doesn't change over time.
|
||||
// If you update the test data below, these may also need to be updated.
|
||||
testNow := time.Date(2023, time.February, 10, 0, 0, 0, 0, time.UTC)
|
||||
testExpired := time.Date(2026, time.February, 10, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
// To re-generate a root certificate and domain certificate for testing,
|
||||
// use:
|
||||
@@ -78,14 +85,20 @@ func TestCertStoreRoundTrip(t *testing.T) {
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
store certStore
|
||||
name string
|
||||
store certStore
|
||||
debugACMEURL bool
|
||||
}{
|
||||
{"FileStore", certFileStore{dir: t.TempDir(), testRoots: roots}},
|
||||
{"StateStore", certStateStore{StateStore: new(mem.Store), testRoots: roots}},
|
||||
{"FileStore", certFileStore{dir: t.TempDir(), testRoots: roots}, false},
|
||||
{"FileStore_UnknownCA", certFileStore{dir: t.TempDir()}, true},
|
||||
{"StateStore", certStateStore{StateStore: new(mem.Store), testRoots: roots}, false},
|
||||
{"StateStore_UnknownCA", certStateStore{StateStore: new(mem.Store)}, true},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if test.debugACMEURL {
|
||||
t.Setenv("TS_DEBUG_ACME_DIRECTORY_URL", "https://acme-staging-v02.api.letsencrypt.org/directory")
|
||||
}
|
||||
if err := test.store.WriteTLSCertAndKey(testDomain, testCert, testKey); err != nil {
|
||||
t.Fatalf("WriteTLSCertAndKey: unexpected error: %v", err)
|
||||
}
|
||||
@@ -99,6 +112,10 @@ func TestCertStoreRoundTrip(t *testing.T) {
|
||||
if diff := cmp.Diff(kp.KeyPEM, testKey); diff != "" {
|
||||
t.Errorf("Key (-got, +want):\n%s", diff)
|
||||
}
|
||||
unexpected, err := test.store.Read(testDomain, testExpired)
|
||||
if err != errCertExpired {
|
||||
t.Fatalf("Read: expected expiry error: %v", string(unexpected.CertPEM))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -211,3 +228,151 @@ func TestDebugACMEDirectoryURL(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCertPEMWithValidity(t *testing.T) {
|
||||
const testDomain = "example.com"
|
||||
b := &LocalBackend{
|
||||
store: &mem.Store{},
|
||||
varRoot: t.TempDir(),
|
||||
ctx: context.Background(),
|
||||
logf: t.Logf,
|
||||
}
|
||||
certDir, err := b.certDir()
|
||||
if err != nil {
|
||||
t.Fatalf("certDir error: %v", err)
|
||||
}
|
||||
if _, err := b.getCertStore(); err != nil {
|
||||
t.Fatalf("getCertStore error: %v", err)
|
||||
}
|
||||
testRoot, err := certTestFS.ReadFile("testdata/rootCA.pem")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
roots := x509.NewCertPool()
|
||||
if !roots.AppendCertsFromPEM(testRoot) {
|
||||
t.Fatal("Unable to add test CA to the cert pool")
|
||||
}
|
||||
testX509Roots = roots
|
||||
defer func() { testX509Roots = nil }()
|
||||
tests := []struct {
|
||||
name string
|
||||
now time.Time
|
||||
// storeCerts is true if the test cert and key should be written to store.
|
||||
storeCerts bool
|
||||
readOnlyMode bool // TS_READ_ONLY_CERTS env var
|
||||
wantAsyncRenewal bool // async issuance should be started
|
||||
wantIssuance bool // sync issuance should be started
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid_no_renewal",
|
||||
now: time.Date(2023, time.February, 20, 0, 0, 0, 0, time.UTC),
|
||||
storeCerts: true,
|
||||
wantAsyncRenewal: false,
|
||||
wantIssuance: false,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "issuance_needed",
|
||||
now: time.Date(2023, time.February, 20, 0, 0, 0, 0, time.UTC),
|
||||
storeCerts: false,
|
||||
wantAsyncRenewal: false,
|
||||
wantIssuance: true,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "renewal_needed",
|
||||
now: time.Date(2025, time.May, 1, 0, 0, 0, 0, time.UTC),
|
||||
storeCerts: true,
|
||||
wantAsyncRenewal: true,
|
||||
wantIssuance: false,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "renewal_needed_read_only_mode",
|
||||
now: time.Date(2025, time.May, 1, 0, 0, 0, 0, time.UTC),
|
||||
storeCerts: true,
|
||||
readOnlyMode: true,
|
||||
wantAsyncRenewal: false,
|
||||
wantIssuance: false,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "no_certs_read_only_mode",
|
||||
now: time.Date(2025, time.May, 1, 0, 0, 0, 0, time.UTC),
|
||||
storeCerts: false,
|
||||
readOnlyMode: true,
|
||||
wantAsyncRenewal: false,
|
||||
wantIssuance: false,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
if tt.readOnlyMode {
|
||||
envknob.Setenv("TS_CERT_SHARE_MODE", "ro")
|
||||
}
|
||||
|
||||
os.RemoveAll(certDir)
|
||||
if tt.storeCerts {
|
||||
os.MkdirAll(certDir, 0755)
|
||||
if err := os.WriteFile(filepath.Join(certDir, "example.com.crt"),
|
||||
must.Get(os.ReadFile("testdata/example.com.pem")), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(certDir, "example.com.key"),
|
||||
must.Get(os.ReadFile("testdata/example.com-key.pem")), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
b.clock = tstest.NewClock(tstest.ClockOpts{Start: tt.now})
|
||||
|
||||
allDone := make(chan bool, 1)
|
||||
defer b.goTracker.AddDoneCallback(func() {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if b.goTracker.RunningGoroutines() > 0 {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case allDone <- true:
|
||||
default:
|
||||
}
|
||||
})()
|
||||
|
||||
// Set to true if get getCertPEM is called. GetCertPEM can be called in a goroutine for async
|
||||
// renewal or in the main goroutine if issuance is required to obtain valid TLS credentials.
|
||||
getCertPemWasCalled := false
|
||||
getCertPEM = func(ctx context.Context, b *LocalBackend, cs certStore, logf logger.Logf, traceACME func(any), domain string, now time.Time, minValidity time.Duration) (*TLSCertKeyPair, error) {
|
||||
getCertPemWasCalled = true
|
||||
return nil, nil
|
||||
}
|
||||
prevGoRoutines := b.goTracker.StartedGoroutines()
|
||||
_, err = b.GetCertPEMWithValidity(context.Background(), testDomain, 0)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("b.GetCertPemWithValidity got err %v, wants error: '%v'", err, tt.wantErr)
|
||||
}
|
||||
// GetCertPEMWithValidity calls getCertPEM in a goroutine if async renewal is needed. That's the
|
||||
// only goroutine it starts, so this can be used to test if async renewal was started.
|
||||
gotAsyncRenewal := b.goTracker.StartedGoroutines()-prevGoRoutines != 0
|
||||
if gotAsyncRenewal {
|
||||
select {
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timed out waiting for goroutines to finish")
|
||||
case <-allDone:
|
||||
}
|
||||
}
|
||||
// Verify that async renewal was triggered if expected.
|
||||
if tt.wantAsyncRenewal != gotAsyncRenewal {
|
||||
t.Fatalf("wants getCertPem to be called async: %v, got called %v", tt.wantAsyncRenewal, gotAsyncRenewal)
|
||||
}
|
||||
// Verify that (non-async) issuance was started if expected.
|
||||
gotIssuance := getCertPemWasCalled && !gotAsyncRenewal
|
||||
if tt.wantIssuance != gotIssuance {
|
||||
t.Errorf("wants getCertPem to be called: %v, got called %v", tt.wantIssuance, gotIssuance)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,10 +57,12 @@ import (
|
||||
"tailscale.com/health/healthmsg"
|
||||
"tailscale.com/hostinfo"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/auditlog"
|
||||
"tailscale.com/ipn/conffile"
|
||||
"tailscale.com/ipn/ipnauth"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/ipn/policy"
|
||||
memstore "tailscale.com/ipn/store/mem"
|
||||
"tailscale.com/log/sockstatlog"
|
||||
"tailscale.com/logpolicy"
|
||||
"tailscale.com/net/captivedetection"
|
||||
@@ -450,6 +452,12 @@ type LocalBackend struct {
|
||||
// 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]
|
||||
|
||||
// auditLogger, if non-nil, manages audit logging for the backend.
|
||||
//
|
||||
// It queues, persists, and sends audit logs
|
||||
// to the control client. auditLogger has the same lifespan as b.cc.
|
||||
auditLogger *auditlog.Logger
|
||||
}
|
||||
|
||||
// HealthTracker returns the health tracker for the backend.
|
||||
@@ -1679,6 +1687,15 @@ func (b *LocalBackend) SetControlClientStatus(c controlclient.Client, st control
|
||||
b.logf("Failed to save new controlclient state: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Update the audit logger with the current profile ID.
|
||||
if b.auditLogger != nil && prefsChanged {
|
||||
pid := b.pm.CurrentProfile().ID()
|
||||
if err := b.auditLogger.SetProfileID(pid); err != nil {
|
||||
b.logf("Failed to set profile ID in audit logger: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// initTKALocked is dependent on CurrentProfile.ID, which is initialized
|
||||
// (for new profiles) on the first call to b.pm.SetPrefs.
|
||||
if err := b.initTKALocked(); err != nil {
|
||||
@@ -2386,6 +2403,27 @@ func (b *LocalBackend) Start(opts ipn.Options) error {
|
||||
debugFlags = append([]string{"netstack"}, debugFlags...)
|
||||
}
|
||||
|
||||
var auditLogShutdown func()
|
||||
// Audit logging is only available if the client has set up a proper persistent
|
||||
// store for the logs in sys.
|
||||
store, ok := b.sys.AuditLogStore.GetOK()
|
||||
if !ok {
|
||||
b.logf("auditlog: [unexpected] no persistent audit log storage configured. using memory store.")
|
||||
store = auditlog.NewLogStore(&memstore.Store{})
|
||||
}
|
||||
|
||||
al := auditlog.NewLogger(auditlog.Opts{
|
||||
Logf: b.logf,
|
||||
RetryLimit: 32,
|
||||
Store: store,
|
||||
})
|
||||
b.auditLogger = al
|
||||
auditLogShutdown = func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
al.FlushAndStop(ctx)
|
||||
}
|
||||
|
||||
// TODO(apenwarr): The only way to change the ServerURL is to
|
||||
// re-run b.Start, because this is the only place we create a
|
||||
// new controlclient. EditPrefs allows you to overwrite ServerURL,
|
||||
@@ -2411,6 +2449,7 @@ func (b *LocalBackend) Start(opts ipn.Options) error {
|
||||
C2NHandler: http.HandlerFunc(b.handleC2N),
|
||||
DialPlan: &b.dialPlan, // pointer because it can't be copied
|
||||
ControlKnobs: b.sys.ControlKnobs(),
|
||||
Shutdown: auditLogShutdown,
|
||||
|
||||
// Don't warn about broken Linux IP forwarding when
|
||||
// netstack is being used.
|
||||
@@ -4263,6 +4302,21 @@ func (b *LocalBackend) MaybeClearAppConnector(mp *ipn.MaskedPrefs) error {
|
||||
return err
|
||||
}
|
||||
|
||||
var errNoAuditLogger = errors.New("no audit logger configured")
|
||||
|
||||
func (b *LocalBackend) getAuditLoggerLocked() ipnauth.AuditLogFunc {
|
||||
logger := b.auditLogger
|
||||
return func(action tailcfg.ClientAuditAction, details string) error {
|
||||
if logger == nil {
|
||||
return errNoAuditLogger
|
||||
}
|
||||
if err := logger.Enqueue(action, details); err != nil {
|
||||
return fmt.Errorf("failed to enqueue audit log %v %q: %w", action, details, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// EditPrefs applies the changes in mp to the current prefs,
|
||||
// acting as the tailscaled itself rather than a specific user.
|
||||
func (b *LocalBackend) EditPrefs(mp *ipn.MaskedPrefs) (ipn.PrefsView, error) {
|
||||
@@ -4288,9 +4342,8 @@ func (b *LocalBackend) EditPrefsAs(mp *ipn.MaskedPrefs, actor ipnauth.Actor) (ip
|
||||
unlock := b.lockAndGetUnlock()
|
||||
defer unlock()
|
||||
if mp.WantRunningSet && !mp.WantRunning && b.pm.CurrentPrefs().WantRunning() {
|
||||
// TODO(barnstar,nickkhyl): replace loggerFn with the actual audit logger.
|
||||
loggerFn := func(action, details string) { b.logf("[audit]: %s: %s", action, details) }
|
||||
if err := actor.CheckProfileAccess(b.pm.CurrentProfile(), ipnauth.Disconnect, loggerFn); err != nil {
|
||||
if err := actor.CheckProfileAccess(b.pm.CurrentProfile(), ipnauth.Disconnect, b.getAuditLoggerLocked()); err != nil {
|
||||
b.logf("check profile access failed: %v", err)
|
||||
return ipn.PrefsView{}, err
|
||||
}
|
||||
|
||||
@@ -5874,6 +5927,15 @@ func (b *LocalBackend) requestEngineStatusAndWait() {
|
||||
func (b *LocalBackend) setControlClientLocked(cc controlclient.Client) {
|
||||
b.cc = cc
|
||||
b.ccAuto, _ = cc.(*controlclient.Auto)
|
||||
if b.auditLogger != nil {
|
||||
if err := b.auditLogger.SetProfileID(b.pm.CurrentProfile().ID()); err != nil {
|
||||
b.logf("audit logger set profile ID failure: %v", err)
|
||||
}
|
||||
|
||||
if err := b.auditLogger.Start(b.ccAuto); err != nil {
|
||||
b.logf("audit logger start failure: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// resetControlClientLocked sets b.cc to nil and returns the old value. If the
|
||||
|
||||
42
net/netmon/loghelper.go
Normal file
42
net/netmon/loghelper.go
Normal file
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package netmon
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"tailscale.com/types/logger"
|
||||
)
|
||||
|
||||
// LinkChangeLogLimiter returns a new [logger.Logf] that logs each unique
|
||||
// format string to the underlying logger only once per major LinkChange event.
|
||||
//
|
||||
// The returned function should be called when the logger is no longer needed,
|
||||
// to release resources from the Monitor.
|
||||
func LinkChangeLogLimiter(logf logger.Logf, nm *Monitor) (_ logger.Logf, unregister func()) {
|
||||
var formatSeen sync.Map // map[string]bool
|
||||
unregister = nm.RegisterChangeCallback(func(cd *ChangeDelta) {
|
||||
// If we're in a major change or a time jump, clear the seen map.
|
||||
if cd.Major || cd.TimeJumped {
|
||||
formatSeen.Clear()
|
||||
}
|
||||
})
|
||||
|
||||
return func(format string, args ...any) {
|
||||
// We only store 'true' in the map, so if it's present then it
|
||||
// means we've already logged this format string.
|
||||
_, loaded := formatSeen.LoadOrStore(format, true)
|
||||
if loaded {
|
||||
// TODO(andrew-d): we may still want to log this
|
||||
// message every N minutes (1x/hour?) even if it's been
|
||||
// seen, so that debugging doesn't require searching
|
||||
// back in the logs for an unbounded amount of time.
|
||||
//
|
||||
// See: https://github.com/tailscale/tailscale/issues/13145
|
||||
return
|
||||
}
|
||||
|
||||
logf(format, args...)
|
||||
}, unregister
|
||||
}
|
||||
78
net/netmon/loghelper_test.go
Normal file
78
net/netmon/loghelper_test.go
Normal file
@@ -0,0 +1,78 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package netmon
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLinkChangeLogLimiter(t *testing.T) {
|
||||
mon, err := New(t.Logf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer mon.Close()
|
||||
|
||||
var logBuffer bytes.Buffer
|
||||
logf := func(format string, args ...any) {
|
||||
t.Logf("captured log: "+format, args...)
|
||||
|
||||
if format[len(format)-1] != '\n' {
|
||||
format += "\n"
|
||||
}
|
||||
fmt.Fprintf(&logBuffer, format, args...)
|
||||
}
|
||||
|
||||
logf, unregister := LinkChangeLogLimiter(logf, mon)
|
||||
defer unregister()
|
||||
|
||||
// Log once, which should write to our log buffer.
|
||||
logf("hello %s", "world")
|
||||
if got := logBuffer.String(); got != "hello world\n" {
|
||||
t.Errorf("unexpected log buffer contents: %q", got)
|
||||
}
|
||||
|
||||
// Log again, which should not write to our log buffer.
|
||||
logf("hello %s", "andrew")
|
||||
if got := logBuffer.String(); got != "hello world\n" {
|
||||
t.Errorf("unexpected log buffer contents: %q", got)
|
||||
}
|
||||
|
||||
// Log a different message, which should write to our log buffer.
|
||||
logf("other message")
|
||||
if got := logBuffer.String(); got != "hello world\nother message\n" {
|
||||
t.Errorf("unexpected log buffer contents: %q", got)
|
||||
}
|
||||
|
||||
// Synthesize a fake major change event, which should clear the format
|
||||
// string cache and allow the next log to write to our log buffer.
|
||||
//
|
||||
// InjectEvent doesn't work because it's not a major event, so we
|
||||
// instead reach into the netmon and grab the callback, and then call
|
||||
// it ourselves.
|
||||
mon.mu.Lock()
|
||||
var cb func(*ChangeDelta)
|
||||
for _, c := range mon.cbs {
|
||||
cb = c
|
||||
break
|
||||
}
|
||||
mon.mu.Unlock()
|
||||
|
||||
cb(&ChangeDelta{Major: true})
|
||||
|
||||
logf("hello %s", "world")
|
||||
if got := logBuffer.String(); got != "hello world\nother message\nhello world\n" {
|
||||
t.Errorf("unexpected log buffer contents: %q", got)
|
||||
}
|
||||
|
||||
// Unregistering the callback should clear our 'cbs' set.
|
||||
unregister()
|
||||
mon.mu.Lock()
|
||||
if len(mon.cbs) != 0 {
|
||||
t.Errorf("expected no callbacks, got %v", mon.cbs)
|
||||
}
|
||||
mon.mu.Unlock()
|
||||
}
|
||||
@@ -34,17 +34,17 @@ type safesocketDarwin struct {
|
||||
mu sync.Mutex
|
||||
token string // safesocket auth token
|
||||
port int // safesocket port
|
||||
sameuserproofFD *os.File // file descriptor for macos app store sameuserproof file
|
||||
sharedDir string // shared directory for location of sameuserproof file
|
||||
sameuserproofFD *os.File // File descriptor for macos app store sameuserproof file
|
||||
sharedDir string // Shared directory for location of sameuserproof file
|
||||
|
||||
checkConn bool // Check macsys safesocket port before returning it
|
||||
isMacSysExt func() bool // For testing only to force macsys
|
||||
isMacGUIApp func() bool // For testing only to force macOS sandbox
|
||||
checkConn bool // If true, check macsys safesocket port before returning it
|
||||
isMacSysExt func() bool // Reports true if this binary is the macOS System Extension
|
||||
isMacGUIApp func() bool // Reports true if running as a macOS GUI app (Tailscale.app)
|
||||
}
|
||||
|
||||
var ssd = safesocketDarwin{
|
||||
isMacSysExt: version.IsMacSysExt,
|
||||
isMacGUIApp: func() bool { return version.IsMacAppStore() || version.IsMacSysApp() || version.IsMacSysExt() },
|
||||
isMacGUIApp: func() bool { return version.IsMacAppStoreGUI() || version.IsMacSysGUI() },
|
||||
checkConn: true,
|
||||
sharedDir: "/Library/Tailscale",
|
||||
}
|
||||
@@ -63,22 +63,25 @@ var ssd = safesocketDarwin{
|
||||
// calls InitListenerDarwin.
|
||||
|
||||
// localTCPPortAndTokenDarwin returns the localhost TCP port number and auth token
|
||||
// either generated, or sourced from the NEPacketTunnelProvider managed tailscaled process.
|
||||
// either from the sameuserproof mechanism, or source and set directly from the
|
||||
// NEPacketTunnelProvider managed tailscaled process when the CLI is invoked
|
||||
// from the Tailscale.app GUI.
|
||||
func localTCPPortAndTokenDarwin() (port int, token string, err error) {
|
||||
ssd.mu.Lock()
|
||||
defer ssd.mu.Unlock()
|
||||
|
||||
if !ssd.isMacGUIApp() {
|
||||
return 0, "", ErrNoTokenOnOS
|
||||
}
|
||||
|
||||
if ssd.port != 0 && ssd.token != "" {
|
||||
switch {
|
||||
case ssd.port != 0 && ssd.token != "":
|
||||
// If something has explicitly set our credentials (typically non-standalone macos binary), use them.
|
||||
return ssd.port, ssd.token, nil
|
||||
case !ssd.isMacGUIApp():
|
||||
// We're not a GUI app (probably cmd/tailscale), so try falling back to sameuserproof.
|
||||
// If portAndTokenFromSameUserProof returns an error here, cmd/tailscale will
|
||||
// attempt to use the default unix socket mechanism supported by tailscaled.
|
||||
return portAndTokenFromSameUserProof()
|
||||
default:
|
||||
return 0, "", ErrTokenNotFound
|
||||
}
|
||||
|
||||
// Credentials were not explicitly, this is likely a standalone CLI binary.
|
||||
// Fallback to reading the sameuserproof file.
|
||||
return portAndTokenFromSameUserProof()
|
||||
}
|
||||
|
||||
// SetCredentials sets an token and port used to authenticate safesocket generated
|
||||
@@ -341,6 +344,11 @@ func readMacosSameUserProof() (port int, token string, err error) {
|
||||
}
|
||||
|
||||
func portAndTokenFromSameUserProof() (port int, token string, err error) {
|
||||
// When we're cmd/tailscale, we have no idea what tailscaled is, so we'll try
|
||||
// macos, then macsys and finally, fallback to tailscaled via a unix socket
|
||||
// if both of those return an error. You can run macos or macsys and
|
||||
// tailscaled at the same time, but we are forced to choose one and the GUI
|
||||
// clients are first in line here. You cannot run macos and macsys simultaneously.
|
||||
if port, token, err := readMacosSameUserProof(); err == nil {
|
||||
return port, token, nil
|
||||
}
|
||||
@@ -349,5 +357,5 @@ func portAndTokenFromSameUserProof() (port int, token string, err error) {
|
||||
return port, token, nil
|
||||
}
|
||||
|
||||
return 0, "", err
|
||||
return 0, "", ErrTokenNotFound
|
||||
}
|
||||
|
||||
@@ -15,9 +15,12 @@ import (
|
||||
// sets the port and token correctly and that LocalTCPPortAndToken
|
||||
// returns the given values.
|
||||
func TestSetCredentials(t *testing.T) {
|
||||
wantPort := 123
|
||||
wantToken := "token"
|
||||
tstest.Replace(t, &ssd.isMacGUIApp, func() bool { return true })
|
||||
const (
|
||||
wantToken = "token"
|
||||
wantPort = 123
|
||||
)
|
||||
|
||||
tstest.Replace(t, &ssd.isMacGUIApp, func() bool { return false })
|
||||
SetCredentials(wantToken, wantPort)
|
||||
|
||||
gotPort, gotToken, err := LocalTCPPortAndToken()
|
||||
@@ -26,11 +29,47 @@ func TestSetCredentials(t *testing.T) {
|
||||
}
|
||||
|
||||
if gotPort != wantPort {
|
||||
t.Errorf("got port %d, want %d", gotPort, wantPort)
|
||||
t.Errorf("port: got %d, want %d", gotPort, wantPort)
|
||||
}
|
||||
|
||||
if gotToken != wantToken {
|
||||
t.Errorf("got token %s, want %s", gotToken, wantToken)
|
||||
t.Errorf("token: got %s, want %s", gotToken, wantToken)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFallbackToSameuserproof verifies that we fallback to the
|
||||
// sameuserproof file via LocalTCPPortAndToken when we're running
|
||||
//
|
||||
// s cmd/tailscale
|
||||
func TestFallbackToSameuserproof(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
const (
|
||||
wantToken = "token"
|
||||
wantPort = 123
|
||||
)
|
||||
|
||||
// Mimics cmd/tailscale falling back to sameuserproof
|
||||
tstest.Replace(t, &ssd.isMacGUIApp, func() bool { return false })
|
||||
tstest.Replace(t, &ssd.sharedDir, dir)
|
||||
tstest.Replace(t, &ssd.checkConn, false)
|
||||
|
||||
// Behave as macSysExt when initializing sameuserproof
|
||||
tstest.Replace(t, &ssd.isMacSysExt, func() bool { return true })
|
||||
if err := initSameUserProofToken(dir, wantPort, wantToken); err != nil {
|
||||
t.Fatalf("initSameUserProofToken: %v", err)
|
||||
}
|
||||
|
||||
gotPort, gotToken, err := LocalTCPPortAndToken()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if gotPort != wantPort {
|
||||
t.Errorf("port: got %d, want %d", gotPort, wantPort)
|
||||
}
|
||||
|
||||
if gotToken != wantToken {
|
||||
t.Errorf("token: got %s, want %s", gotToken, wantToken)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +77,7 @@ func TestSetCredentials(t *testing.T) {
|
||||
// returns a listener and a non-zero port and non-empty token.
|
||||
func TestInitListenerDarwin(t *testing.T) {
|
||||
temp := t.TempDir()
|
||||
tstest.Replace(t, &ssd.isMacGUIApp, func() bool { return true })
|
||||
tstest.Replace(t, &ssd.isMacGUIApp, func() bool { return false })
|
||||
|
||||
ln, err := InitListenerDarwin(temp)
|
||||
if err != nil || ln == nil {
|
||||
@@ -52,15 +91,14 @@ func TestInitListenerDarwin(t *testing.T) {
|
||||
}
|
||||
|
||||
if port == 0 {
|
||||
t.Errorf("expected non-zero port, got %d", port)
|
||||
t.Errorf("port: got %d, want non-zero", port)
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
t.Errorf("expected non-empty token, got empty string")
|
||||
t.Errorf("token: got %s, want non-empty", token)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTokenGeneration verifies token generation behavior
|
||||
func TestTokenGeneration(t *testing.T) {
|
||||
token, err := getToken()
|
||||
if err != nil {
|
||||
@@ -70,7 +108,7 @@ func TestTokenGeneration(t *testing.T) {
|
||||
// Verify token length (hex string is 2x byte length)
|
||||
wantLen := sameUserProofTokenLength * 2
|
||||
if got := len(token); got != wantLen {
|
||||
t.Errorf("token length = %d, want %d", got, wantLen)
|
||||
t.Errorf("token length: got %d, want %d", got, wantLen)
|
||||
}
|
||||
|
||||
// Verify token persistence
|
||||
@@ -79,7 +117,7 @@ func TestTokenGeneration(t *testing.T) {
|
||||
t.Fatalf("subsequent getToken: %v", err)
|
||||
}
|
||||
if subsequentToken != token {
|
||||
t.Errorf("subsequent token = %q, want %q", subsequentToken, token)
|
||||
t.Errorf("subsequent token: got %q, want %q", subsequentToken, token)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,10 +145,10 @@ func TestMacsysSameuserproof(t *testing.T) {
|
||||
}
|
||||
|
||||
if gotPort != wantPort {
|
||||
t.Errorf("got port = %d, want %d", gotPort, wantPort)
|
||||
t.Errorf("port: got %d, want %d", gotPort, wantPort)
|
||||
}
|
||||
if wantToken != gotToken {
|
||||
t.Errorf("got token = %s, want %s", wantToken, gotToken)
|
||||
t.Errorf("token: got %s, want %s", wantToken, gotToken)
|
||||
}
|
||||
assertFileCount(t, dir, 1, "sameuserproof-")
|
||||
}
|
||||
@@ -138,7 +176,7 @@ func assertFileCount(t *testing.T, dir string, want int, prefix string) {
|
||||
|
||||
files, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
t.Fatalf("[unexpected] error: %v", err)
|
||||
}
|
||||
count := 0
|
||||
for _, file := range files {
|
||||
@@ -147,6 +185,6 @@ func assertFileCount(t *testing.T, dir string, want int, prefix string) {
|
||||
}
|
||||
}
|
||||
if count != want {
|
||||
t.Errorf("expected 1 file, got %d", count)
|
||||
t.Errorf("files: got %d, want 1", count)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,4 +16,4 @@
|
||||
) {
|
||||
src = ./.;
|
||||
}).shellNix
|
||||
# nix-direnv cache busting line: sha256-xO1DuLWi6/lpA9ubA2ZYVJM+CkVNA5IaVGZxX9my0j0=
|
||||
# nix-direnv cache busting line: sha256-SiUkN6BQK1IQmLfkfPetzvYqRu9ENK6+6txtGxegF5Y=
|
||||
|
||||
@@ -254,32 +254,44 @@ func parseIncubatorArgs(args []string) (incubatorArgs, error) {
|
||||
return ia, nil
|
||||
}
|
||||
|
||||
func (ia incubatorArgs) forwadedEnviron() ([]string, string, error) {
|
||||
// forwardedEnviron returns the concatenation of the current environment with
|
||||
// any environment variables specified in ia.encodedEnv.
|
||||
//
|
||||
// It also returns allowedExtraKeys, containing the env keys that were passed in
|
||||
// to ia.encodedEnv.
|
||||
func (ia incubatorArgs) forwardedEnviron() (env, allowedExtraKeys []string, err error) {
|
||||
environ := os.Environ()
|
||||
|
||||
// pass through SSH_AUTH_SOCK environment variable to support ssh agent forwarding
|
||||
allowListKeys := "SSH_AUTH_SOCK"
|
||||
// TODO(bradfitz,percy): why is this listed specially? If the parent wanted to included
|
||||
// it, couldn't it have just passed it to the incubator in encodedEnv?
|
||||
// If it didn't, no reason for us to pass it to "su -w ..." if it's not in our env
|
||||
// anyway? (Surely we don't want to inherit the tailscaled parent SSH_AUTH_SOCK, if any)
|
||||
allowedExtraKeys = []string{"SSH_AUTH_SOCK"}
|
||||
|
||||
if ia.encodedEnv != "" {
|
||||
unquoted, err := strconv.Unquote(ia.encodedEnv)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("unable to parse encodedEnv %q: %w", ia.encodedEnv, err)
|
||||
return nil, nil, fmt.Errorf("unable to parse encodedEnv %q: %w", ia.encodedEnv, err)
|
||||
}
|
||||
|
||||
var extraEnviron []string
|
||||
|
||||
err = json.Unmarshal([]byte(unquoted), &extraEnviron)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("unable to parse encodedEnv %q: %w", ia.encodedEnv, err)
|
||||
return nil, nil, fmt.Errorf("unable to parse encodedEnv %q: %w", ia.encodedEnv, err)
|
||||
}
|
||||
|
||||
environ = append(environ, extraEnviron...)
|
||||
|
||||
for _, v := range extraEnviron {
|
||||
allowListKeys = fmt.Sprintf("%s,%s", allowListKeys, strings.Split(v, "=")[0])
|
||||
for _, kv := range extraEnviron {
|
||||
if k, _, ok := strings.Cut(kv, "="); ok {
|
||||
allowedExtraKeys = append(allowedExtraKeys, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return environ, allowListKeys, nil
|
||||
return environ, allowedExtraKeys, nil
|
||||
}
|
||||
|
||||
// beIncubator is the entrypoint to the `tailscaled be-child ssh` subcommand.
|
||||
@@ -459,7 +471,7 @@ func tryExecLogin(dlogf logger.Logf, ia incubatorArgs) error {
|
||||
loginArgs := ia.loginArgs(loginCmdPath)
|
||||
dlogf("logging in with %+v", loginArgs)
|
||||
|
||||
environ, _, err := ia.forwadedEnviron()
|
||||
environ, _, err := ia.forwardedEnviron()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -498,14 +510,14 @@ func trySU(dlogf logger.Logf, ia incubatorArgs) (handled bool, err error) {
|
||||
defer sessionCloser()
|
||||
}
|
||||
|
||||
environ, allowListEnvKeys, err := ia.forwadedEnviron()
|
||||
environ, allowListEnvKeys, err := ia.forwardedEnviron()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
loginArgs := []string{
|
||||
su,
|
||||
"-w", allowListEnvKeys,
|
||||
"-w", strings.Join(allowListEnvKeys, ","),
|
||||
"-l",
|
||||
ia.localUser,
|
||||
}
|
||||
@@ -546,7 +558,7 @@ func findSU(dlogf logger.Logf, ia incubatorArgs) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
_, allowListEnvKeys, err := ia.forwadedEnviron()
|
||||
_, allowListEnvKeys, err := ia.forwardedEnviron()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
@@ -555,7 +567,7 @@ func findSU(dlogf logger.Logf, ia incubatorArgs) string {
|
||||
// to make sure su supports the necessary arguments.
|
||||
err = exec.Command(
|
||||
su,
|
||||
"-w", allowListEnvKeys,
|
||||
"-w", strings.Join(allowListEnvKeys, ","),
|
||||
"-l",
|
||||
ia.localUser,
|
||||
"-c", "true",
|
||||
@@ -582,7 +594,7 @@ func handleSSHInProcess(dlogf logger.Logf, ia incubatorArgs) error {
|
||||
return err
|
||||
}
|
||||
|
||||
environ, _, err := ia.forwadedEnviron()
|
||||
environ, _, err := ia.forwardedEnviron()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -2982,3 +2982,33 @@ const LBHeader = "Ts-Lb"
|
||||
// correspond to those IPs. Any services that don't correspond to a service
|
||||
// this client is hosting can be ignored.
|
||||
type ServiceIPMappings map[ServiceName][]netip.Addr
|
||||
|
||||
// ClientAuditAction represents an auditable action that a client can report to the
|
||||
// control plane. These actions must correspond to the supported actions
|
||||
// in the control plane.
|
||||
type ClientAuditAction string
|
||||
|
||||
const (
|
||||
// AuditNodeDisconnect action is sent when a node has disconnected
|
||||
// from the control plane. The details must include a reason in the Details
|
||||
// field, either generated, or entered by the user.
|
||||
AuditNodeDisconnect = ClientAuditAction("DISCONNECT_NODE")
|
||||
)
|
||||
|
||||
// AuditLogRequest represents an audit log request to be sent to the control plane.
|
||||
//
|
||||
// This is JSON-encoded and sent over the control plane connection to:
|
||||
// POST https://<control-plane>/machine/audit-log
|
||||
type AuditLogRequest struct {
|
||||
// Version is the client's current CapabilityVersion.
|
||||
Version CapabilityVersion `json:",omitempty"`
|
||||
// NodeKey is the client's current node key.
|
||||
NodeKey key.NodePublic `json:",omitzero"`
|
||||
// Action is the action to be logged. It must correspond to a known action in the control plane.
|
||||
Action ClientAuditAction `json:",omitempty"`
|
||||
// Details is an opaque string, specific to the action being logged. Empty strings may not
|
||||
// be valid depending on the action being logged.
|
||||
Details string `json:",omitempty"`
|
||||
// Timestamp is the time at which the audit log was generated on the node.
|
||||
Timestamp time.Time `json:",omitzero"`
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"tailscale.com/drive"
|
||||
"tailscale.com/health"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/auditlog"
|
||||
"tailscale.com/ipn/conffile"
|
||||
"tailscale.com/ipn/desktop"
|
||||
"tailscale.com/net/dns"
|
||||
@@ -50,6 +51,7 @@ type System struct {
|
||||
Router SubSystem[router.Router]
|
||||
Tun SubSystem[*tstun.Wrapper]
|
||||
StateStore SubSystem[ipn.StateStore]
|
||||
AuditLogStore SubSystem[auditlog.LogStore]
|
||||
Netstack SubSystem[NetstackImpl] // actually a *netstack.Impl
|
||||
DriveForLocal SubSystem[drive.FileSystemForLocal]
|
||||
DriveForRemote SubSystem[drive.FileSystemForRemote]
|
||||
@@ -106,6 +108,8 @@ func (s *System) Set(v any) {
|
||||
s.MagicSock.Set(v)
|
||||
case ipn.StateStore:
|
||||
s.StateStore.Set(v)
|
||||
case auditlog.LogStore:
|
||||
s.AuditLogStore.Set(v)
|
||||
case NetstackImpl:
|
||||
s.Netstack.Set(v)
|
||||
case drive.FileSystemForLocal:
|
||||
|
||||
@@ -49,6 +49,7 @@ import (
|
||||
"tailscale.com/net/socks5"
|
||||
"tailscale.com/net/tsdial"
|
||||
"tailscale.com/tsd"
|
||||
"tailscale.com/types/bools"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/types/logid"
|
||||
"tailscale.com/types/nettype"
|
||||
@@ -601,7 +602,9 @@ func (s *Server) start() (reterr error) {
|
||||
// Note: don't just return ns.DialContextTCP or we'll return
|
||||
// *gonet.TCPConn(nil) instead of a nil interface which trips up
|
||||
// callers.
|
||||
tcpConn, err := ns.DialContextTCP(ctx, dst)
|
||||
v4, v6 := s.TailscaleIPs()
|
||||
src := bools.IfElse(dst.Addr().Is6(), v6, v4)
|
||||
tcpConn, err := ns.DialContextTCPWithBind(ctx, src, dst)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -611,7 +614,9 @@ func (s *Server) start() (reterr error) {
|
||||
// Note: don't just return ns.DialContextUDP or we'll return
|
||||
// *gonet.UDPConn(nil) instead of a nil interface which trips up
|
||||
// callers.
|
||||
udpConn, err := ns.DialContextUDP(ctx, dst)
|
||||
v4, v6 := s.TailscaleIPs()
|
||||
src := bools.IfElse(dst.Addr().Is6(), v6, v4)
|
||||
udpConn, err := ns.DialContextUDPWithBind(ctx, src, dst)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ type DebugHandler struct {
|
||||
kvs []func(io.Writer) // output one <li>...</li> each, see KV()
|
||||
urls []string // one <li>...</li> block with link each
|
||||
sections []func(io.Writer, *http.Request) // invoked in registration order prior to outputting </body>
|
||||
title string // title displayed on index page
|
||||
}
|
||||
|
||||
// Debugger returns the DebugHandler registered on mux at /debug/,
|
||||
@@ -44,7 +45,8 @@ func Debugger(mux *http.ServeMux) *DebugHandler {
|
||||
return d
|
||||
}
|
||||
ret := &DebugHandler{
|
||||
mux: mux,
|
||||
mux: mux,
|
||||
title: fmt.Sprintf("%s debug", version.CmdName()),
|
||||
}
|
||||
mux.Handle("/debug/", ret)
|
||||
|
||||
@@ -85,7 +87,7 @@ func (d *DebugHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
AddBrowserHeaders(w)
|
||||
f := func(format string, args ...any) { fmt.Fprintf(w, format, args...) }
|
||||
f("<html><body><h1>%s debug</h1><ul>", version.CmdName())
|
||||
f("<html><body><h1>%s</h1><ul>", html.EscapeString(d.title))
|
||||
for _, kv := range d.kvs {
|
||||
kv(w)
|
||||
}
|
||||
@@ -103,14 +105,20 @@ func (d *DebugHandler) handle(slug string, handler http.Handler) string {
|
||||
return href
|
||||
}
|
||||
|
||||
// Handle registers handler at /debug/<slug> and creates a descriptive
|
||||
// entry in /debug/ for it.
|
||||
// Handle registers handler at /debug/<slug> and adds a link to it
|
||||
// on /debug/ with the provided description.
|
||||
func (d *DebugHandler) Handle(slug, desc string, handler http.Handler) {
|
||||
href := d.handle(slug, handler)
|
||||
d.URL(href, desc)
|
||||
}
|
||||
|
||||
// HandleSilent registers handler at /debug/<slug>. It does not create
|
||||
// Handle registers handler at /debug/<slug> and adds a link to it
|
||||
// on /debug/ with the provided description.
|
||||
func (d *DebugHandler) HandleFunc(slug, desc string, handler http.HandlerFunc) {
|
||||
d.Handle(slug, desc, handler)
|
||||
}
|
||||
|
||||
// HandleSilent registers handler at /debug/<slug>. It does not add
|
||||
// a descriptive entry in /debug/ for it. This should be used
|
||||
// sparingly, for things that need to be registered but would pollute
|
||||
// the list of debug links.
|
||||
@@ -118,6 +126,14 @@ func (d *DebugHandler) HandleSilent(slug string, handler http.Handler) {
|
||||
d.handle(slug, handler)
|
||||
}
|
||||
|
||||
// HandleSilent registers handler at /debug/<slug>. It does not add
|
||||
// a descriptive entry in /debug/ for it. This should be used
|
||||
// sparingly, for things that need to be registered but would pollute
|
||||
// the list of debug links.
|
||||
func (d *DebugHandler) HandleSilentFunc(slug string, handler http.HandlerFunc) {
|
||||
d.HandleSilent(slug, handler)
|
||||
}
|
||||
|
||||
// KV adds a key/value list item to /debug/.
|
||||
func (d *DebugHandler) KV(k string, v any) {
|
||||
val := html.EscapeString(fmt.Sprintf("%v", v))
|
||||
@@ -149,6 +165,11 @@ func (d *DebugHandler) Section(f func(w io.Writer, r *http.Request)) {
|
||||
d.sections = append(d.sections, f)
|
||||
}
|
||||
|
||||
// Title sets the title at the top of the debug page.
|
||||
func (d *DebugHandler) Title(title string) {
|
||||
d.title = title
|
||||
}
|
||||
|
||||
func gcHandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("running GC...\n"))
|
||||
if f, ok := w.(http.Flusher); ok {
|
||||
|
||||
6
util/eventbus/assets/event.html
Normal file
6
util/eventbus/assets/event.html
Normal file
@@ -0,0 +1,6 @@
|
||||
<li id="monitor" hx-swap-oob="afterbegin">
|
||||
<details>
|
||||
<summary>{{.Count}}: {{.Type}} from {{.Event.From.Name}}, {{len .Event.To}} recipients</summary>
|
||||
{{.Event.Event}}
|
||||
</details>
|
||||
</li>
|
||||
BIN
util/eventbus/assets/htmx-websocket.min.js.gz
Normal file
BIN
util/eventbus/assets/htmx-websocket.min.js.gz
Normal file
Binary file not shown.
BIN
util/eventbus/assets/htmx.min.js.gz
Normal file
BIN
util/eventbus/assets/htmx.min.js.gz
Normal file
Binary file not shown.
97
util/eventbus/assets/main.html
Normal file
97
util/eventbus/assets/main.html
Normal file
@@ -0,0 +1,97 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<script src="bus/htmx.min.js"></script>
|
||||
<script src="bus/htmx-websocket.min.js"></script>
|
||||
<link rel="stylesheet" href="bus/style.css">
|
||||
</head>
|
||||
<body hx-ext="ws">
|
||||
<h1>Event bus</h1>
|
||||
|
||||
<section>
|
||||
<h2>General</h2>
|
||||
{{with $.PublishQueue}}
|
||||
{{len .}} pending
|
||||
{{end}}
|
||||
|
||||
<button hx-post="bus/monitor" hx-swap="outerHTML">Monitor all events</button>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Clients</h2>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Publishing</th>
|
||||
<th>Subscribing</th>
|
||||
<th>Pending</th>
|
||||
</tr>
|
||||
</thead>
|
||||
{{range .Clients}}
|
||||
<tr id="{{.Name}}">
|
||||
<td>{{.Name}}</td>
|
||||
<td class="list">
|
||||
<ul>
|
||||
{{range .Publish}}
|
||||
<li><a href="#{{.}}">{{.}}</a></li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</td>
|
||||
<td class="list">
|
||||
<ul>
|
||||
{{range .Subscribe}}
|
||||
<li><a href="#{{.}}">{{.}}</a></li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</td>
|
||||
<td>
|
||||
{{len ($.SubscribeQueue .Client)}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Types</h2>
|
||||
|
||||
{{range .Types}}
|
||||
|
||||
<section id="{{.}}">
|
||||
<h3>{{.Name}}</h3>
|
||||
<h4>Definition</h4>
|
||||
<code>{{prettyPrintStruct .}}</code>
|
||||
|
||||
<h4>Published by:</h4>
|
||||
{{if len (.Publish)}}
|
||||
<ul>
|
||||
{{range .Publish}}
|
||||
<li><a href="#{{.Name}}">{{.Name}}</a></li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{else}}
|
||||
<ul>
|
||||
<li>No publishers.</li>
|
||||
</ul>
|
||||
{{end}}
|
||||
|
||||
<h4>Received by:</h4>
|
||||
{{if len (.Subscribe)}}
|
||||
<ul>
|
||||
{{range .Subscribe}}
|
||||
<li><a href="#{{.Name}}">{{.Name}}</a></li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{else}}
|
||||
<ul>
|
||||
<li>No subscribers.</li>
|
||||
</ul>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
</section>
|
||||
</body>
|
||||
</html>
|
||||
5
util/eventbus/assets/monitor.html
Normal file
5
util/eventbus/assets/monitor.html
Normal file
@@ -0,0 +1,5 @@
|
||||
<div>
|
||||
<ul id="monitor" ws-connect="bus/monitor">
|
||||
</ul>
|
||||
<button hx-get="bus" hx-target="body">Stop monitoring</button>
|
||||
</div>
|
||||
90
util/eventbus/assets/style.css
Normal file
90
util/eventbus/assets/style.css
Normal file
@@ -0,0 +1,90 @@
|
||||
/* CSS reset, thanks Josh Comeau: https://www.joshwcomeau.com/css/custom-css-reset/ */
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
* { margin: 0; }
|
||||
input, button, textarea, select { font: inherit; }
|
||||
p, h1, h2, h3, h4, h5, h6 { overflow-wrap: break-word; }
|
||||
p { text-wrap: pretty; }
|
||||
h1, h2, h3, h4, h5, h6 { text-wrap: balance; }
|
||||
#root, #__next { isolation: isolate; }
|
||||
body {
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
img, picture, video, canvas, svg {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Local styling begins */
|
||||
|
||||
body {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
div {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-gap: 6px;
|
||||
align-items: flex-start;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
section > * {
|
||||
margin-left: 24px;
|
||||
}
|
||||
|
||||
section > h2, section > h3 {
|
||||
margin-left: 0;
|
||||
padding-bottom: 6px;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
details {
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
table {
|
||||
table-layout: fixed;
|
||||
width: calc(100% - 48px);
|
||||
border-collapse: collapse;
|
||||
border: 1px solid black;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 12px;
|
||||
border: 1px solid black;
|
||||
}
|
||||
|
||||
td.list {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
td ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
code {
|
||||
padding: 12px;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
#monitor {
|
||||
width: calc(100% - 48px);
|
||||
resize: vertical;
|
||||
padding: 12px;
|
||||
overflow: scroll;
|
||||
height: 15lh;
|
||||
border: 1px inset;
|
||||
min-height: 1em;
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
125
util/eventbus/bench_test.go
Normal file
125
util/eventbus/bench_test.go
Normal file
@@ -0,0 +1,125 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package eventbus_test
|
||||
|
||||
import (
|
||||
"math/rand/v2"
|
||||
"testing"
|
||||
|
||||
"tailscale.com/util/eventbus"
|
||||
)
|
||||
|
||||
func BenchmarkBasicThroughput(b *testing.B) {
|
||||
bus := eventbus.New()
|
||||
pcli := bus.Client(b.Name() + "-pub")
|
||||
scli := bus.Client(b.Name() + "-sub")
|
||||
|
||||
type emptyEvent [0]byte
|
||||
|
||||
// One publisher and a corresponding subscriber shoveling events as fast as
|
||||
// they can through the plumbing.
|
||||
pub := eventbus.Publish[emptyEvent](pcli)
|
||||
sub := eventbus.Subscribe[emptyEvent](scli)
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-sub.Events():
|
||||
continue
|
||||
case <-sub.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for b.Loop() {
|
||||
pub.Publish(emptyEvent{})
|
||||
}
|
||||
bus.Close()
|
||||
}
|
||||
|
||||
func BenchmarkSubsThroughput(b *testing.B) {
|
||||
bus := eventbus.New()
|
||||
pcli := bus.Client(b.Name() + "-pub")
|
||||
scli1 := bus.Client(b.Name() + "-sub1")
|
||||
scli2 := bus.Client(b.Name() + "-sub2")
|
||||
|
||||
type emptyEvent [0]byte
|
||||
|
||||
// One publisher and two subscribers shoveling events as fast as they can
|
||||
// through the plumbing.
|
||||
pub := eventbus.Publish[emptyEvent](pcli)
|
||||
sub1 := eventbus.Subscribe[emptyEvent](scli1)
|
||||
sub2 := eventbus.Subscribe[emptyEvent](scli2)
|
||||
|
||||
for _, sub := range []*eventbus.Subscriber[emptyEvent]{sub1, sub2} {
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-sub.Events():
|
||||
continue
|
||||
case <-sub.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
for b.Loop() {
|
||||
pub.Publish(emptyEvent{})
|
||||
}
|
||||
bus.Close()
|
||||
}
|
||||
|
||||
func BenchmarkMultiThroughput(b *testing.B) {
|
||||
bus := eventbus.New()
|
||||
cli := bus.Client(b.Name())
|
||||
|
||||
type eventA struct{}
|
||||
type eventB struct{}
|
||||
|
||||
// Two disjoint event streams routed through the global order.
|
||||
apub := eventbus.Publish[eventA](cli)
|
||||
asub := eventbus.Subscribe[eventA](cli)
|
||||
bpub := eventbus.Publish[eventB](cli)
|
||||
bsub := eventbus.Subscribe[eventB](cli)
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-asub.Events():
|
||||
continue
|
||||
case <-asub.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-bsub.Events():
|
||||
continue
|
||||
case <-bsub.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
var rng uint64
|
||||
var bits int
|
||||
for b.Loop() {
|
||||
if bits == 0 {
|
||||
rng = rand.Uint64()
|
||||
bits = 64
|
||||
}
|
||||
if rng&1 == 0 {
|
||||
apub.Publish(eventA{})
|
||||
} else {
|
||||
bpub.Publish(eventB{})
|
||||
}
|
||||
rng >>= 1
|
||||
bits--
|
||||
}
|
||||
bus.Close()
|
||||
}
|
||||
@@ -73,8 +73,8 @@ func (b *Bus) Client(name string) *Client {
|
||||
}
|
||||
|
||||
// Debugger returns the debugging facility for the bus.
|
||||
func (b *Bus) Debugger() Debugger {
|
||||
return Debugger{b}
|
||||
func (b *Bus) Debugger() *Debugger {
|
||||
return &Debugger{b}
|
||||
}
|
||||
|
||||
// Close closes the bus. Implicitly closes all clients, publishers and
|
||||
|
||||
103
util/eventbus/debug-demo/main.go
Normal file
103
util/eventbus/debug-demo/main.go
Normal file
@@ -0,0 +1,103 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// debug-demo is a program that serves a bus's debug interface over
|
||||
// HTTP, then generates some fake traffic from a handful of
|
||||
// clients. It is an aid to development, to have something to present
|
||||
// on the debug interfaces while writing them.
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"math/rand/v2"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"time"
|
||||
|
||||
"tailscale.com/tsweb"
|
||||
"tailscale.com/types/key"
|
||||
"tailscale.com/util/eventbus"
|
||||
)
|
||||
|
||||
func main() {
|
||||
b := eventbus.New()
|
||||
c := b.Client("RouteMonitor")
|
||||
go testPub[RouteAdded](c, 5*time.Second)
|
||||
go testPub[RouteRemoved](c, 5*time.Second)
|
||||
c = b.Client("ControlClient")
|
||||
go testPub[PeerAdded](c, 3*time.Second)
|
||||
go testPub[PeerRemoved](c, 6*time.Second)
|
||||
c = b.Client("Portmapper")
|
||||
go testPub[PortmapAcquired](c, 10*time.Second)
|
||||
go testPub[PortmapLost](c, 15*time.Second)
|
||||
go testSub[RouteAdded](c)
|
||||
c = b.Client("WireguardConfig")
|
||||
go testSub[PeerAdded](c)
|
||||
go testSub[PeerRemoved](c)
|
||||
c = b.Client("Magicsock")
|
||||
go testPub[PeerPathChanged](c, 5*time.Second)
|
||||
go testSub[RouteAdded](c)
|
||||
go testSub[RouteRemoved](c)
|
||||
go testSub[PortmapAcquired](c)
|
||||
go testSub[PortmapLost](c)
|
||||
|
||||
m := http.NewServeMux()
|
||||
d := tsweb.Debugger(m)
|
||||
b.Debugger().RegisterHTTP(d)
|
||||
|
||||
m.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/debug/bus", http.StatusFound)
|
||||
})
|
||||
log.Printf("Serving debug interface at http://localhost:8185/debug/bus")
|
||||
http.ListenAndServe(":8185", m)
|
||||
}
|
||||
|
||||
func testPub[T any](c *eventbus.Client, every time.Duration) {
|
||||
p := eventbus.Publish[T](c)
|
||||
for {
|
||||
jitter := time.Duration(rand.N(2000)) * time.Millisecond
|
||||
time.Sleep(jitter)
|
||||
var zero T
|
||||
log.Printf("%s publish: %T", c.Name(), zero)
|
||||
p.Publish(zero)
|
||||
time.Sleep(every)
|
||||
}
|
||||
}
|
||||
|
||||
func testSub[T any](c *eventbus.Client) {
|
||||
s := eventbus.Subscribe[T](c)
|
||||
for v := range s.Events() {
|
||||
log.Printf("%s received: %T", c.Name(), v)
|
||||
}
|
||||
}
|
||||
|
||||
type RouteAdded struct {
|
||||
Prefix netip.Prefix
|
||||
Via netip.Addr
|
||||
Priority int
|
||||
}
|
||||
type RouteRemoved struct {
|
||||
Prefix netip.Addr
|
||||
}
|
||||
|
||||
type PeerAdded struct {
|
||||
ID int
|
||||
Key key.NodePublic
|
||||
}
|
||||
type PeerRemoved struct {
|
||||
ID int
|
||||
Key key.NodePublic
|
||||
}
|
||||
|
||||
type PortmapAcquired struct {
|
||||
Endpoint netip.Addr
|
||||
}
|
||||
type PortmapLost struct {
|
||||
Endpoint netip.Addr
|
||||
}
|
||||
|
||||
type PeerPathChanged struct {
|
||||
ID int
|
||||
EndpointID int
|
||||
Quality int
|
||||
}
|
||||
@@ -4,11 +4,14 @@
|
||||
package eventbus
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"slices"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"tailscale.com/tsweb"
|
||||
)
|
||||
|
||||
// A Debugger offers access to a bus's privileged introspection and
|
||||
@@ -29,7 +32,11 @@ type Debugger struct {
|
||||
|
||||
// Clients returns a list of all clients attached to the bus.
|
||||
func (d *Debugger) Clients() []*Client {
|
||||
return d.bus.listClients()
|
||||
ret := d.bus.listClients()
|
||||
slices.SortFunc(ret, func(a, b *Client) int {
|
||||
return cmp.Compare(a.Name(), b.Name())
|
||||
})
|
||||
return ret
|
||||
}
|
||||
|
||||
// PublishQueue returns the contents of the publish queue.
|
||||
@@ -130,6 +137,8 @@ func (d *Debugger) SubscribeTypes(client *Client) []reflect.Type {
|
||||
return client.subscribeTypes()
|
||||
}
|
||||
|
||||
func (d *Debugger) RegisterHTTP(td *tsweb.DebugHandler) { registerHTTPDebugger(d, td) }
|
||||
|
||||
// A hook collects hook functions that can be run as a group.
|
||||
type hook[T any] struct {
|
||||
sync.Mutex
|
||||
|
||||
238
util/eventbus/debughttp.go
Normal file
238
util/eventbus/debughttp.go
Normal file
@@ -0,0 +1,238 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package eventbus
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"embed"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"tailscale.com/tsweb"
|
||||
)
|
||||
|
||||
type httpDebugger struct {
|
||||
*Debugger
|
||||
}
|
||||
|
||||
func registerHTTPDebugger(d *Debugger, td *tsweb.DebugHandler) {
|
||||
dh := httpDebugger{d}
|
||||
td.Handle("bus", "Event bus", dh)
|
||||
td.HandleSilent("bus/monitor", http.HandlerFunc(dh.serveMonitor))
|
||||
td.HandleSilent("bus/style.css", serveStatic("style.css"))
|
||||
td.HandleSilent("bus/htmx.min.js", serveStatic("htmx.min.js.gz"))
|
||||
td.HandleSilent("bus/htmx-websocket.min.js", serveStatic("htmx-websocket.min.js.gz"))
|
||||
}
|
||||
|
||||
//go:embed assets/*.html
|
||||
var templatesSrc embed.FS
|
||||
|
||||
var templates = sync.OnceValue(func() *template.Template {
|
||||
d, err := fs.Sub(templatesSrc, "assets")
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("getting eventbus debughttp templates subdir: %w", err))
|
||||
}
|
||||
ret := template.New("").Funcs(map[string]any{
|
||||
"prettyPrintStruct": prettyPrintStruct,
|
||||
})
|
||||
return template.Must(ret.ParseFS(d, "*"))
|
||||
})
|
||||
|
||||
//go:generate go run fetch-htmx.go
|
||||
|
||||
//go:embed assets/*.css assets/*.min.js.gz
|
||||
var static embed.FS
|
||||
|
||||
func serveStatic(name string) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.HasSuffix(name, ".css"):
|
||||
w.Header().Set("Content-Type", "text/css")
|
||||
case strings.HasSuffix(name, ".min.js.gz"):
|
||||
w.Header().Set("Content-Type", "text/javascript")
|
||||
w.Header().Set("Content-Encoding", "gzip")
|
||||
case strings.HasSuffix(name, ".js"):
|
||||
w.Header().Set("Content-Type", "text/javascript")
|
||||
default:
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
f, err := static.Open(filepath.Join("assets", name))
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("opening asset: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
if _, err := io.Copy(w, f); err != nil {
|
||||
http.Error(w, fmt.Sprintf("serving asset: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func render(w http.ResponseWriter, name string, data any) {
|
||||
err := templates().ExecuteTemplate(w, name+".html", data)
|
||||
if err != nil {
|
||||
err := fmt.Errorf("rendering template: %v", err)
|
||||
log.Print(err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (h httpDebugger) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
type clientInfo struct {
|
||||
*Client
|
||||
Publish []reflect.Type
|
||||
Subscribe []reflect.Type
|
||||
}
|
||||
type typeInfo struct {
|
||||
reflect.Type
|
||||
Publish []*Client
|
||||
Subscribe []*Client
|
||||
}
|
||||
type info struct {
|
||||
*Debugger
|
||||
Clients map[string]*clientInfo
|
||||
Types map[string]*typeInfo
|
||||
}
|
||||
|
||||
data := info{
|
||||
Debugger: h.Debugger,
|
||||
Clients: map[string]*clientInfo{},
|
||||
Types: map[string]*typeInfo{},
|
||||
}
|
||||
|
||||
getTypeInfo := func(t reflect.Type) *typeInfo {
|
||||
if data.Types[t.Name()] == nil {
|
||||
data.Types[t.Name()] = &typeInfo{
|
||||
Type: t,
|
||||
}
|
||||
}
|
||||
return data.Types[t.Name()]
|
||||
}
|
||||
|
||||
for _, c := range h.Clients() {
|
||||
ci := &clientInfo{
|
||||
Client: c,
|
||||
Publish: h.PublishTypes(c),
|
||||
Subscribe: h.SubscribeTypes(c),
|
||||
}
|
||||
slices.SortFunc(ci.Publish, func(a, b reflect.Type) int { return cmp.Compare(a.Name(), b.Name()) })
|
||||
slices.SortFunc(ci.Subscribe, func(a, b reflect.Type) int { return cmp.Compare(a.Name(), b.Name()) })
|
||||
data.Clients[c.Name()] = ci
|
||||
|
||||
for _, t := range ci.Publish {
|
||||
ti := getTypeInfo(t)
|
||||
ti.Publish = append(ti.Publish, c)
|
||||
}
|
||||
for _, t := range ci.Subscribe {
|
||||
ti := getTypeInfo(t)
|
||||
ti.Subscribe = append(ti.Subscribe, c)
|
||||
}
|
||||
}
|
||||
|
||||
render(w, "main", data)
|
||||
}
|
||||
|
||||
func (h httpDebugger) serveMonitor(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Upgrade") == "websocket" {
|
||||
h.serveMonitorStream(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
render(w, "monitor", nil)
|
||||
}
|
||||
|
||||
func (h httpDebugger) serveMonitorStream(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := websocket.Accept(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.CloseNow()
|
||||
wsCtx := conn.CloseRead(r.Context())
|
||||
|
||||
mon := h.WatchBus()
|
||||
defer mon.Close()
|
||||
|
||||
i := 0
|
||||
for {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case <-wsCtx.Done():
|
||||
return
|
||||
case <-mon.Done():
|
||||
return
|
||||
case event := <-mon.Events():
|
||||
msg, err := conn.Writer(r.Context(), websocket.MessageText)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
data := map[string]any{
|
||||
"Count": i,
|
||||
"Type": reflect.TypeOf(event.Event),
|
||||
"Event": event,
|
||||
}
|
||||
i++
|
||||
if err := templates().ExecuteTemplate(msg, "event.html", data); err != nil {
|
||||
log.Println(err)
|
||||
return
|
||||
}
|
||||
if err := msg.Close(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func prettyPrintStruct(t reflect.Type) string {
|
||||
if t.Kind() != reflect.Struct {
|
||||
return t.String()
|
||||
}
|
||||
var rec func(io.Writer, int, reflect.Type)
|
||||
rec = func(out io.Writer, indent int, t reflect.Type) {
|
||||
ind := strings.Repeat(" ", indent)
|
||||
fmt.Fprintf(out, "%s", t.String())
|
||||
fs := collectFields(t)
|
||||
if len(fs) > 0 {
|
||||
io.WriteString(out, " {\n")
|
||||
for _, f := range fs {
|
||||
fmt.Fprintf(out, "%s %s ", ind, f.Name)
|
||||
if f.Type.Kind() == reflect.Struct {
|
||||
rec(out, indent+1, f.Type)
|
||||
} else {
|
||||
fmt.Fprint(out, f.Type)
|
||||
}
|
||||
io.WriteString(out, "\n")
|
||||
}
|
||||
fmt.Fprintf(out, "%s}", ind)
|
||||
}
|
||||
}
|
||||
|
||||
var ret bytes.Buffer
|
||||
rec(&ret, 0, t)
|
||||
return ret.String()
|
||||
}
|
||||
|
||||
func collectFields(t reflect.Type) (ret []reflect.StructField) {
|
||||
for _, f := range reflect.VisibleFields(t) {
|
||||
if !f.IsExported() {
|
||||
continue
|
||||
}
|
||||
ret = append(ret, f)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
93
util/eventbus/fetch-htmx.go
Normal file
93
util/eventbus/fetch-htmx.go
Normal file
@@ -0,0 +1,93 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build ignore
|
||||
|
||||
// Program fetch-htmx fetches and installs local copies of the HTMX
|
||||
// library and its dependencies, used by the debug UI. It is meant to
|
||||
// be run via go generate.
|
||||
package main
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"crypto/sha512"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Hash from https://htmx.org/docs/#installing
|
||||
htmx, err := fetchHashed("https://unpkg.com/htmx.org@2.0.4", "HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+")
|
||||
if err != nil {
|
||||
log.Fatalf("fetching htmx: %v", err)
|
||||
}
|
||||
|
||||
// Hash SHOULD be from https://htmx.org/extensions/ws/ , but the
|
||||
// hash is currently incorrect, see
|
||||
// https://github.com/bigskysoftware/htmx-extensions/issues/153
|
||||
//
|
||||
// Until that bug is resolved, hash was obtained by rebuilding the
|
||||
// extension from git source, and verifying that the hash matches
|
||||
// what unpkg is serving.
|
||||
ws, err := fetchHashed("https://unpkg.com/htmx-ext-ws@2.0.2", "932iIqjARv+Gy0+r6RTGrfCkCKS5MsF539Iqf6Vt8L4YmbnnWI2DSFoMD90bvXd0")
|
||||
if err != nil {
|
||||
log.Fatalf("fetching htmx-websockets: %v", err)
|
||||
}
|
||||
|
||||
if err := writeGz("assets/htmx.min.js.gz", htmx); err != nil {
|
||||
log.Fatalf("writing htmx.min.js.gz: %v", err)
|
||||
}
|
||||
if err := writeGz("assets/htmx-websocket.min.js.gz", ws); err != nil {
|
||||
log.Fatalf("writing htmx-websocket.min.js.gz: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeGz(path string, bs []byte) error {
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
g, err := gzip.NewWriterLevel(f, gzip.BestCompression)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := g.Write(bs); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := g.Flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func fetchHashed(url, wantHash string) ([]byte, error) {
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("fetching %q returned error status: %s", url, resp.Status)
|
||||
}
|
||||
ret, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading file from %q: %v", url, err)
|
||||
}
|
||||
h := sha512.Sum384(ret)
|
||||
got := base64.StdEncoding.EncodeToString(h[:])
|
||||
if got != wantHash {
|
||||
return nil, fmt.Errorf("wrong hash for %q: got %q, want %q", url, got, wantHash)
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
@@ -62,26 +62,21 @@ func IsSandboxedMacOS() bool {
|
||||
// Tailscale for macOS, either the main GUI process (non-sandboxed) or the
|
||||
// system extension (sandboxed).
|
||||
func IsMacSys() bool {
|
||||
return IsMacSysExt() || IsMacSysApp()
|
||||
return IsMacSysExt() || IsMacSysGUI()
|
||||
}
|
||||
|
||||
var isMacSysApp lazy.SyncValue[bool]
|
||||
|
||||
// IsMacSysApp reports whether this process is the main, non-sandboxed GUI process
|
||||
// IsMacSysGUI reports whether this process is the main, non-sandboxed GUI process
|
||||
// that ships with the Standalone variant of Tailscale for macOS.
|
||||
func IsMacSysApp() bool {
|
||||
func IsMacSysGUI() bool {
|
||||
if runtime.GOOS != "darwin" {
|
||||
return false
|
||||
}
|
||||
|
||||
return isMacSysApp.Get(func() bool {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
// Check that this is the GUI binary, and it is not sandboxed. The GUI binary
|
||||
// shipped in the App Store will always have the App Sandbox enabled.
|
||||
return strings.HasSuffix(exe, "/Contents/MacOS/Tailscale") && !IsMacAppStore()
|
||||
return strings.Contains(os.Getenv("HOME"), "/Containers/io.tailscale.ipn.macsys/") ||
|
||||
strings.Contains(os.Getenv("XPC_SERVICE_NAME"), "io.tailscale.ipn.macsys")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -95,10 +90,6 @@ func IsMacSysExt() bool {
|
||||
return false
|
||||
}
|
||||
return isMacSysExt.Get(func() bool {
|
||||
if strings.Contains(os.Getenv("HOME"), "/Containers/io.tailscale.ipn.macsys/") ||
|
||||
strings.Contains(os.Getenv("XPC_SERVICE_NAME"), "io.tailscale.ipn.macsys") {
|
||||
return true
|
||||
}
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return false
|
||||
@@ -109,8 +100,8 @@ func IsMacSysExt() bool {
|
||||
|
||||
var isMacAppStore lazy.SyncValue[bool]
|
||||
|
||||
// IsMacAppStore whether this binary is from the App Store version of Tailscale
|
||||
// for macOS.
|
||||
// IsMacAppStore returns whether this binary is from the App Store version of Tailscale
|
||||
// for macOS. Returns true for both the network extension and the GUI app.
|
||||
func IsMacAppStore() bool {
|
||||
if runtime.GOOS != "darwin" {
|
||||
return false
|
||||
@@ -124,6 +115,25 @@ func IsMacAppStore() bool {
|
||||
})
|
||||
}
|
||||
|
||||
var isMacAppStoreGUI lazy.SyncValue[bool]
|
||||
|
||||
// IsMacAppStoreGUI reports whether this binary is the GUI app from the App Store
|
||||
// version of Tailscale for macOS.
|
||||
func IsMacAppStoreGUI() bool {
|
||||
if runtime.GOOS != "darwin" {
|
||||
return false
|
||||
}
|
||||
return isMacAppStoreGUI.Get(func() bool {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
// Check that this is the GUI binary, and it is not sandboxed. The GUI binary
|
||||
// shipped in the App Store will always have the App Sandbox enabled.
|
||||
return strings.Contains(exe, "/Tailscale") && !IsMacSysGUI()
|
||||
})
|
||||
}
|
||||
|
||||
var isAppleTV lazy.SyncValue[bool]
|
||||
|
||||
// IsAppleTV reports whether this binary is part of the Tailscale network extension for tvOS.
|
||||
|
||||
@@ -177,6 +177,10 @@ type Conn struct {
|
||||
// port mappings from NAT devices.
|
||||
portMapper *portmapper.Client
|
||||
|
||||
// portMapperLogfUnregister is the function to call to unregister
|
||||
// the portmapper log limiter.
|
||||
portMapperLogfUnregister func()
|
||||
|
||||
// derpRecvCh is used by receiveDERP to read DERP messages.
|
||||
// It must have buffer size > 0; see issue 3736.
|
||||
derpRecvCh chan derpReadResult
|
||||
@@ -532,10 +536,15 @@ func NewConn(opts Options) (*Conn, error) {
|
||||
c.idleFunc = opts.IdleFunc
|
||||
c.testOnlyPacketListener = opts.TestOnlyPacketListener
|
||||
c.noteRecvActivity = opts.NoteRecvActivity
|
||||
|
||||
// Don't log the same log messages possibly every few seconds in our
|
||||
// portmapper.
|
||||
portmapperLogf := logger.WithPrefix(c.logf, "portmapper: ")
|
||||
portmapperLogf, c.portMapperLogfUnregister = netmon.LinkChangeLogLimiter(portmapperLogf, opts.NetMon)
|
||||
portMapOpts := &portmapper.DebugKnobs{
|
||||
DisableAll: func() bool { return opts.DisablePortMapper || c.onlyTCP443.Load() },
|
||||
}
|
||||
c.portMapper = portmapper.NewClient(logger.WithPrefix(c.logf, "portmapper: "), opts.NetMon, portMapOpts, opts.ControlKnobs, c.onPortMapChanged)
|
||||
c.portMapper = portmapper.NewClient(portmapperLogf, opts.NetMon, portMapOpts, opts.ControlKnobs, c.onPortMapChanged)
|
||||
c.portMapper.SetGatewayLookupFunc(opts.NetMon.GatewayAndSelfIP)
|
||||
c.netMon = opts.NetMon
|
||||
c.health = opts.HealthTracker
|
||||
@@ -2481,6 +2490,7 @@ func (c *Conn) Close() error {
|
||||
}
|
||||
c.stopPeriodicReSTUNTimerLocked()
|
||||
c.portMapper.Close()
|
||||
c.portMapperLogfUnregister()
|
||||
|
||||
c.peerMap.forEachEndpoint(func(ep *endpoint) {
|
||||
ep.stopAndReset()
|
||||
|
||||
@@ -843,6 +843,27 @@ func (ns *Impl) DialContextTCP(ctx context.Context, ipp netip.AddrPort) (*gonet.
|
||||
return gonet.DialContextTCP(ctx, ns.ipstack, remoteAddress, ipType)
|
||||
}
|
||||
|
||||
// DialContextTCPWithBind creates a new gonet.TCPConn connected to the specified
|
||||
// remoteAddress with its local address bound to localAddr on an available port.
|
||||
func (ns *Impl) DialContextTCPWithBind(ctx context.Context, localAddr netip.Addr, remoteAddr netip.AddrPort) (*gonet.TCPConn, error) {
|
||||
remoteAddress := tcpip.FullAddress{
|
||||
NIC: nicID,
|
||||
Addr: tcpip.AddrFromSlice(remoteAddr.Addr().AsSlice()),
|
||||
Port: remoteAddr.Port(),
|
||||
}
|
||||
localAddress := tcpip.FullAddress{
|
||||
NIC: nicID,
|
||||
Addr: tcpip.AddrFromSlice(localAddr.AsSlice()),
|
||||
}
|
||||
var ipType tcpip.NetworkProtocolNumber
|
||||
if remoteAddr.Addr().Is4() {
|
||||
ipType = ipv4.ProtocolNumber
|
||||
} else {
|
||||
ipType = ipv6.ProtocolNumber
|
||||
}
|
||||
return gonet.DialTCPWithBind(ctx, ns.ipstack, localAddress, remoteAddress, ipType)
|
||||
}
|
||||
|
||||
func (ns *Impl) DialContextUDP(ctx context.Context, ipp netip.AddrPort) (*gonet.UDPConn, error) {
|
||||
remoteAddress := &tcpip.FullAddress{
|
||||
NIC: nicID,
|
||||
@@ -859,6 +880,28 @@ func (ns *Impl) DialContextUDP(ctx context.Context, ipp netip.AddrPort) (*gonet.
|
||||
return gonet.DialUDP(ns.ipstack, nil, remoteAddress, ipType)
|
||||
}
|
||||
|
||||
// DialContextUDPWithBind creates a new gonet.UDPConn. Connected to remoteAddr.
|
||||
// With its local address bound to localAddr on an available port.
|
||||
func (ns *Impl) DialContextUDPWithBind(ctx context.Context, localAddr netip.Addr, remoteAddr netip.AddrPort) (*gonet.UDPConn, error) {
|
||||
remoteAddress := &tcpip.FullAddress{
|
||||
NIC: nicID,
|
||||
Addr: tcpip.AddrFromSlice(remoteAddr.Addr().AsSlice()),
|
||||
Port: remoteAddr.Port(),
|
||||
}
|
||||
localAddress := &tcpip.FullAddress{
|
||||
NIC: nicID,
|
||||
Addr: tcpip.AddrFromSlice(localAddr.AsSlice()),
|
||||
}
|
||||
var ipType tcpip.NetworkProtocolNumber
|
||||
if remoteAddr.Addr().Is4() {
|
||||
ipType = ipv4.ProtocolNumber
|
||||
} else {
|
||||
ipType = ipv6.ProtocolNumber
|
||||
}
|
||||
|
||||
return gonet.DialUDP(ns.ipstack, localAddress, remoteAddress, ipType)
|
||||
}
|
||||
|
||||
// getInjectInboundBuffsSizes returns packet memory and a sizes slice for usage
|
||||
// when calling tstun.Wrapper.InjectInboundPacketBuffer(). These are sized with
|
||||
// consideration for MTU and GSO support on ns.linkEP. They should be recycled
|
||||
|
||||
@@ -399,3 +399,44 @@ rankine
|
||||
piano
|
||||
ruler
|
||||
scoville
|
||||
oratrice
|
||||
teeth
|
||||
cliff
|
||||
degree
|
||||
company
|
||||
economy
|
||||
court
|
||||
justitia
|
||||
themis
|
||||
carat
|
||||
carob
|
||||
karat
|
||||
barley
|
||||
corn
|
||||
penny
|
||||
pound
|
||||
mark
|
||||
pence
|
||||
mine
|
||||
stairs
|
||||
escalator
|
||||
elevator
|
||||
skilift
|
||||
gondola
|
||||
firefighter
|
||||
newton
|
||||
smoot
|
||||
city
|
||||
truck
|
||||
everest
|
||||
wall
|
||||
fence
|
||||
fort
|
||||
trench
|
||||
matrix
|
||||
census
|
||||
likert
|
||||
sidemirror
|
||||
wage
|
||||
salary
|
||||
fujita
|
||||
|
||||
@@ -694,3 +694,29 @@ ussuri
|
||||
kitty
|
||||
tanuki
|
||||
neko
|
||||
wind
|
||||
airplane
|
||||
time
|
||||
gumiho
|
||||
eel
|
||||
moray
|
||||
twin
|
||||
hair
|
||||
braid
|
||||
gate
|
||||
end
|
||||
queue
|
||||
miku
|
||||
at
|
||||
fin
|
||||
solarflare
|
||||
asymptote
|
||||
reverse
|
||||
bone
|
||||
stern
|
||||
quaver
|
||||
note
|
||||
mining
|
||||
coat
|
||||
follow
|
||||
stalk
|
||||
|
||||
Reference in New Issue
Block a user