Files
2026-05-05 13:47:24 +10:00

167 lines
4.3 KiB
Go

// tsnet-load launches N userspace Tailscale nodes and has each one periodically
// ping a tailnet peer (or HTTP-GET a subnet-routed host).
//
// Usage:
//
// TS_AUTHKEY=tskey-... go run . -count 10 -interval 5s
package main
import (
"context"
"flag"
"fmt"
"io"
"log"
"math/rand/v2"
"net/http"
"net/netip"
"os"
"os/signal"
"path/filepath"
"sync"
"syscall"
"time"
"tailscale.com/client/tailscale"
"tailscale.com/ipn"
"tailscale.com/tailcfg"
"tailscale.com/tsnet"
)
const (
httpTarget = "http://10.210.1.2"
pingTarget = "100.92.123.72"
)
func main() {
authKey := flag.String("authkey", os.Getenv("TS_AUTHKEY"),
"Tailscale reusable ephemeral auth key (or set TS_AUTHKEY).")
count := flag.Int("count", 10, "Number of tsnet nodes to launch.")
interval := flag.Duration("interval", 5*time.Second, "Interval between actions per node.")
hostnamePrefix := flag.String("hostname-prefix", "uncloud", "Hostname prefix for tsnet nodes.")
stateRoot := flag.String("state-dir", "./state", "Directory to keep tsnet state (default: a fresh temp dir).")
flag.Parse()
if *authKey == "" {
log.Fatal("missing Tailscale auth key: pass -authkey or set TS_AUTHKEY.")
}
root := *stateRoot
if root == "" {
var err error
root, err = os.MkdirTemp("", "tsnet-load-*")
if err != nil {
log.Fatalf("mktemp: %v", err)
}
defer os.RemoveAll(root)
} else if err := os.MkdirAll(root, 0o700); err != nil {
log.Fatalf("mkdir state dir: %v", err)
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
var wg sync.WaitGroup
for i := 0; i < *count; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
runNode(ctx, id, *authKey, *hostnamePrefix, root, *interval)
}(i)
}
wg.Wait()
}
func runNode(ctx context.Context, id int, authKey, prefix, stateRoot string, interval time.Duration) {
logPrefix := fmt.Sprintf("[node-%02d] ", id)
logger := log.New(os.Stderr, logPrefix, log.LstdFlags|log.Lmsgprefix)
s := &tsnet.Server{
Hostname: fmt.Sprintf("%s-%02d", prefix, id),
AuthKey: authKey,
Ephemeral: true,
Dir: filepath.Join(stateRoot, fmt.Sprintf("node-%02d", id)),
// Silence per-node tsnet chatter. Comment out to debug.
Logf: func(string, ...any) {},
}
defer s.Close()
upCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
status, err := s.Up(upCtx)
if err != nil {
logger.Printf("tsnet up: %v", err)
return
}
logger.Printf("up as %s (%v)", s.Hostname, status.TailscaleIPs)
// Accept subnet routes advertised by other tailnet peers. tsnet defaults
// to RouteAll=false, so without this 10.210.0.0/24 etc. are unreachable.
lc, err := s.LocalClient()
if err != nil {
logger.Printf("local client: %v", err)
return
}
if _, err := lc.EditPrefs(ctx, &ipn.MaskedPrefs{
RouteAllSet: true,
Prefs: ipn.Prefs{RouteAll: true},
}); err != nil {
logger.Printf("edit prefs (RouteAll): %v", err)
return
}
httpClient := &http.Client{
Transport: &http.Transport{DialContext: s.Dial},
Timeout: 10 * time.Second,
}
_ = httpClient
// Stagger ticks so all nodes don't fire in lockstep.
time.Sleep(time.Duration(rand.Int64N(int64(interval))))
tick := time.NewTicker(interval)
defer tick.Stop()
for {
select {
case <-ctx.Done():
return
case <-tick.C:
doPing(ctx, lc, logger)
//doHTTP(ctx, httpClient, logger)
}
}
}
func doPing(ctx context.Context, lc *tailscale.LocalClient, logger *log.Logger) {
pctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
res, err := lc.Ping(pctx, netip.MustParseAddr(pingTarget), tailcfg.PingICMP)
if err != nil {
logger.Printf("ping %s: %v", pingTarget, err)
return
}
if res.Err != "" {
logger.Printf("ping %s: %s", pingTarget, res.Err)
return
}
logger.Printf("ping %s ok in %.1fms via %s", pingTarget, res.LatencySeconds*1000, res.Endpoint)
}
func doHTTP(ctx context.Context, c *http.Client, logger *log.Logger) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, httpTarget, nil)
if err != nil {
logger.Printf("http build req: %v", err)
return
}
start := time.Now()
resp, err := c.Do(req)
if err != nil {
logger.Printf("http %s: %v", httpTarget, err)
return
}
defer resp.Body.Close()
n, _ := io.Copy(io.Discard, resp.Body)
logger.Printf("http %s: %s, %d bytes in %v", httpTarget, resp.Status, n, time.Since(start).Round(time.Millisecond))
}