mirror of
https://github.com/tailscale/tailscale.git
synced 2024-12-01 14:05:39 +00:00
0d19f5d421
The log ID types were moved to a separate package so that code that only depend on log ID types do not need to link in the logic for the logtail client itself. Not all code need the logtail client. Signed-off-by: Joe Tsai <joetsai@digital-static.net>
351 lines
9.1 KiB
Go
351 lines
9.1 KiB
Go
// Copyright (c) Tailscale Inc & AUTHORS
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
// Package integration contains Tailscale integration tests.
|
|
//
|
|
// This package is considered internal and the public API is subject
|
|
// to change without notice.
|
|
package integration
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/tls"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"os/exec"
|
|
"path"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/klauspost/compress/zstd"
|
|
"go4.org/mem"
|
|
"tailscale.com/derp"
|
|
"tailscale.com/derp/derphttp"
|
|
"tailscale.com/net/stun/stuntest"
|
|
"tailscale.com/smallzstd"
|
|
"tailscale.com/tailcfg"
|
|
"tailscale.com/types/key"
|
|
"tailscale.com/types/logger"
|
|
"tailscale.com/types/logid"
|
|
"tailscale.com/types/nettype"
|
|
"tailscale.com/version"
|
|
)
|
|
|
|
// CleanupBinaries cleans up any resources created by calls to BinaryDir, TailscaleBinary, or TailscaledBinary.
|
|
// It should be called from TestMain after all tests have completed.
|
|
func CleanupBinaries() {
|
|
buildOnce.Do(func() {})
|
|
if binDir != "" {
|
|
os.RemoveAll(binDir)
|
|
}
|
|
}
|
|
|
|
// BinaryDir returns a directory containing test tailscale and tailscaled binaries.
|
|
// If any test calls BinaryDir, there must be a TestMain function that calls
|
|
// CleanupBinaries after all tests are complete.
|
|
func BinaryDir(tb testing.TB) string {
|
|
buildOnce.Do(func() {
|
|
binDir, buildErr = buildTestBinaries()
|
|
})
|
|
if buildErr != nil {
|
|
tb.Fatal(buildErr)
|
|
}
|
|
return binDir
|
|
}
|
|
|
|
// TailscaleBinary returns the path to the test tailscale binary.
|
|
// If any test calls TailscaleBinary, there must be a TestMain function that calls
|
|
// CleanupBinaries after all tests are complete.
|
|
func TailscaleBinary(tb testing.TB) string {
|
|
return filepath.Join(BinaryDir(tb), "tailscale"+exe())
|
|
}
|
|
|
|
// TailscaledBinary returns the path to the test tailscaled binary.
|
|
// If any test calls TailscaleBinary, there must be a TestMain function that calls
|
|
// CleanupBinaries after all tests are complete.
|
|
func TailscaledBinary(tb testing.TB) string {
|
|
return filepath.Join(BinaryDir(tb), "tailscaled"+exe())
|
|
}
|
|
|
|
var (
|
|
buildOnce sync.Once
|
|
buildErr error
|
|
binDir string
|
|
)
|
|
|
|
// buildTestBinaries builds tailscale and tailscaled.
|
|
// It returns the dir containing the binaries.
|
|
func buildTestBinaries() (string, error) {
|
|
bindir, err := os.MkdirTemp("", "")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
err = build(bindir, "tailscale.com/cmd/tailscaled", "tailscale.com/cmd/tailscale")
|
|
if err != nil {
|
|
os.RemoveAll(bindir)
|
|
return "", err
|
|
}
|
|
return bindir, nil
|
|
}
|
|
|
|
func build(outDir string, targets ...string) error {
|
|
goBin, err := findGo()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cmd := exec.Command(goBin, "install")
|
|
if version.IsRace() {
|
|
cmd.Args = append(cmd.Args, "-race")
|
|
}
|
|
cmd.Args = append(cmd.Args, targets...)
|
|
cmd.Env = append(os.Environ(), "GOARCH="+runtime.GOARCH, "GOBIN="+outDir)
|
|
errOut, err := cmd.CombinedOutput()
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
if strings.Contains(string(errOut), "when GOBIN is set") {
|
|
// Fallback slow path for cross-compiled binaries.
|
|
for _, target := range targets {
|
|
outFile := filepath.Join(outDir, path.Base(target)+exe())
|
|
cmd := exec.Command(goBin, "build", "-o", outFile, target)
|
|
cmd.Env = append(os.Environ(), "GOARCH="+runtime.GOARCH)
|
|
if errOut, err := cmd.CombinedOutput(); err != nil {
|
|
return fmt.Errorf("failed to build %v with %v: %v, %s", target, goBin, err, errOut)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
return fmt.Errorf("failed to build %v with %v: %v, %s", targets, goBin, err, errOut)
|
|
}
|
|
|
|
func findGo() (string, error) {
|
|
// Go 1.19 attempted to be helpful by prepending $PATH with GOROOT/bin based
|
|
// on the executed go binary when invoked using `go test` or `go generate`,
|
|
// however, this doesn't cover cases when run otherwise, such as via `go run`.
|
|
// runtime.GOROOT() may often be empty these days, so the safe thing to do
|
|
// here is, in order:
|
|
// 1. Look for a go binary in $PATH[0].
|
|
// 2. Look for a go binary in runtime.GOROOT()/bin if runtime.GOROOT() is non-empty.
|
|
// 3. Look for a go binary in $PATH.
|
|
|
|
paths := strings.FieldsFunc(os.Getenv("PATH"), func(r rune) bool { return os.IsPathSeparator(uint8(r)) })
|
|
if len(paths) > 0 {
|
|
candidate := filepath.Join(paths[0], "go"+exe())
|
|
if path, err := exec.LookPath(candidate); err == nil {
|
|
return path, err
|
|
}
|
|
}
|
|
|
|
if runtime.GOROOT() != "" {
|
|
candidate := filepath.Join(runtime.GOROOT(), "bin", "go"+exe())
|
|
if path, err := exec.LookPath(candidate); err == nil {
|
|
return path, err
|
|
}
|
|
}
|
|
|
|
return exec.LookPath("go")
|
|
}
|
|
|
|
func exe() string {
|
|
if runtime.GOOS == "windows" {
|
|
return ".exe"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// RunDERPAndSTUN runs a local DERP and STUN server for tests, returning the derpMap
|
|
// that clients should use. This creates resources that must be cleaned up with the
|
|
// returned cleanup function.
|
|
func RunDERPAndSTUN(t testing.TB, logf logger.Logf, ipAddress string) (derpMap *tailcfg.DERPMap) {
|
|
t.Helper()
|
|
|
|
d := derp.NewServer(key.NewNode(), logf)
|
|
|
|
ln, err := net.Listen("tcp", net.JoinHostPort(ipAddress, "0"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
httpsrv := httptest.NewUnstartedServer(derphttp.Handler(d))
|
|
httpsrv.Listener = ln
|
|
httpsrv.Config.ErrorLog = logger.StdLogger(logf)
|
|
httpsrv.Config.TLSNextProto = make(map[string]func(*http.Server, *tls.Conn, http.Handler))
|
|
httpsrv.StartTLS()
|
|
|
|
stunAddr, stunCleanup := stuntest.ServeWithPacketListener(t, nettype.Std{})
|
|
|
|
m := &tailcfg.DERPMap{
|
|
Regions: map[int]*tailcfg.DERPRegion{
|
|
1: {
|
|
RegionID: 1,
|
|
RegionCode: "test",
|
|
Nodes: []*tailcfg.DERPNode{
|
|
{
|
|
Name: "t1",
|
|
RegionID: 1,
|
|
HostName: ipAddress,
|
|
IPv4: ipAddress,
|
|
IPv6: "none",
|
|
STUNPort: stunAddr.Port,
|
|
DERPPort: httpsrv.Listener.Addr().(*net.TCPAddr).Port,
|
|
InsecureForTests: true,
|
|
STUNTestIP: ipAddress,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
t.Logf("DERP httpsrv listener: %v", httpsrv.Listener.Addr())
|
|
|
|
t.Cleanup(func() {
|
|
httpsrv.CloseClientConnections()
|
|
httpsrv.Close()
|
|
d.Close()
|
|
stunCleanup()
|
|
ln.Close()
|
|
})
|
|
|
|
return m
|
|
}
|
|
|
|
// LogCatcher is a minimal logcatcher for the logtail upload client.
|
|
type LogCatcher struct {
|
|
mu sync.Mutex
|
|
logf logger.Logf
|
|
buf bytes.Buffer
|
|
gotErr error
|
|
reqs int
|
|
raw bool // indicates whether to store the raw JSON logs uploaded, instead of just the text
|
|
}
|
|
|
|
// UseLogf makes the logcatcher implementation use a given logf function
|
|
// to dump all logs to.
|
|
func (lc *LogCatcher) UseLogf(fn logger.Logf) {
|
|
lc.mu.Lock()
|
|
defer lc.mu.Unlock()
|
|
lc.logf = fn
|
|
}
|
|
|
|
// StoreRawJSON instructs lc to save the raw JSON uploads, rather than just the text.
|
|
func (lc *LogCatcher) StoreRawJSON() {
|
|
lc.mu.Lock()
|
|
defer lc.mu.Unlock()
|
|
lc.raw = true
|
|
}
|
|
|
|
func (lc *LogCatcher) logsContains(sub mem.RO) bool {
|
|
lc.mu.Lock()
|
|
defer lc.mu.Unlock()
|
|
return mem.Contains(mem.B(lc.buf.Bytes()), sub)
|
|
}
|
|
|
|
func (lc *LogCatcher) numRequests() int {
|
|
lc.mu.Lock()
|
|
defer lc.mu.Unlock()
|
|
return lc.reqs
|
|
}
|
|
|
|
func (lc *LogCatcher) logsString() string {
|
|
lc.mu.Lock()
|
|
defer lc.mu.Unlock()
|
|
return lc.buf.String()
|
|
}
|
|
|
|
// Reset clears the buffered logs from memory.
|
|
func (lc *LogCatcher) Reset() {
|
|
lc.mu.Lock()
|
|
defer lc.mu.Unlock()
|
|
lc.buf.Reset()
|
|
}
|
|
|
|
func (lc *LogCatcher) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
// POST /c/<collection-name>/<private-ID>
|
|
if r.Method != "POST" {
|
|
log.Printf("bad logcatcher method: %v", r.Method)
|
|
http.Error(w, "only POST is supported", 400)
|
|
return
|
|
}
|
|
pathParts := strings.Split(strings.TrimPrefix(r.URL.Path, "/c/"), "/")
|
|
if len(pathParts) != 2 {
|
|
log.Printf("bad logcatcher path: %q", r.URL.Path)
|
|
http.Error(w, "bad URL", 400)
|
|
return
|
|
}
|
|
// collectionName := pathPaths[0]
|
|
privID, err := logid.ParsePrivateID(pathParts[1])
|
|
if err != nil {
|
|
log.Printf("bad log ID: %q: %v", r.URL.Path, err)
|
|
}
|
|
|
|
var body io.Reader = r.Body
|
|
if r.Header.Get("Content-Encoding") == "zstd" {
|
|
var err error
|
|
var dec *zstd.Decoder
|
|
dec, err = smallzstd.NewDecoder(body)
|
|
if err != nil {
|
|
log.Printf("bad caught zstd: %v", err)
|
|
http.Error(w, err.Error(), 400)
|
|
return
|
|
}
|
|
defer dec.Close()
|
|
body = dec
|
|
}
|
|
bodyBytes, _ := io.ReadAll(body)
|
|
|
|
type Entry struct {
|
|
Logtail struct {
|
|
ClientTime time.Time `json:"client_time"`
|
|
ServerTime time.Time `json:"server_time"`
|
|
Error struct {
|
|
BadData string `json:"bad_data"`
|
|
} `json:"error"`
|
|
} `json:"logtail"`
|
|
Text string `json:"text"`
|
|
}
|
|
var jreq []Entry
|
|
if len(bodyBytes) > 0 && bodyBytes[0] == '[' {
|
|
err = json.Unmarshal(bodyBytes, &jreq)
|
|
} else {
|
|
var ent Entry
|
|
err = json.Unmarshal(bodyBytes, &ent)
|
|
jreq = append(jreq, ent)
|
|
}
|
|
|
|
lc.mu.Lock()
|
|
defer lc.mu.Unlock()
|
|
lc.reqs++
|
|
if lc.gotErr == nil && err != nil {
|
|
lc.gotErr = err
|
|
}
|
|
if err != nil {
|
|
fmt.Fprintf(&lc.buf, "error from %s of %#q: %v\n", r.Method, bodyBytes, err)
|
|
if lc.logf != nil {
|
|
lc.logf("error from %s of %#q: %v\n", r.Method, bodyBytes, err)
|
|
}
|
|
} else {
|
|
id := privID.Public().String()[:3] // good enough for integration tests
|
|
for _, ent := range jreq {
|
|
if lc.raw {
|
|
lc.buf.Write(bodyBytes)
|
|
continue
|
|
}
|
|
fmt.Fprintf(&lc.buf, "%s\n", strings.TrimSpace(ent.Text))
|
|
if lc.logf != nil {
|
|
lc.logf("logcatch:%s: %s", id, strings.TrimSpace(ent.Text))
|
|
}
|
|
}
|
|
}
|
|
w.WriteHeader(200) // must have no content, but not a 204
|
|
}
|