Files
tailscale-custom/cmd/tailscale/cli/netcheck.go
T
Amal Bansode c38d1badba cmd/tailscale/cli: add bind-address and bind-port flags to netcheck command (#18621)
Add more explicit `--bind-address` and `--bind-port` flags to the `tailscale netcheck` CLI to give users control over UDP probes' source IP and UDP port.

This was already supported in a less documented manner via the` TS_DEBUG_NETCHECK_UDP_BIND` environment variable. The environment variable reference is preserved and used as a fallback value in the absence of these new CLI flags.

Updates tailscale/corp#36833

Signed-off-by: Amal Bansode <amal@tailscale.com>
2026-02-19 11:39:16 -08:00

333 lines
9.3 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package cli
import (
"context"
"encoding/json"
"flag"
"fmt"
"io"
"log"
"math"
"net/http"
"net/netip"
"sort"
"strings"
"time"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/envknob"
"tailscale.com/feature/buildfeatures"
"tailscale.com/ipn"
"tailscale.com/net/netcheck"
"tailscale.com/net/netmon"
"tailscale.com/net/portmapper/portmappertype"
"tailscale.com/net/tlsdial"
"tailscale.com/tailcfg"
"tailscale.com/types/logger"
"tailscale.com/util/eventbus"
"tailscale.com/util/set"
// The "netcheck" command also wants the portmapper linked.
//
// TODO: make that subcommand either hit LocalAPI for that info, or use a
// tailscaled subcommand, to avoid making the CLI also link in the portmapper.
// For now (2025-09-15), keep doing what we've done for the past five years and
// keep linking it here.
_ "tailscale.com/feature/condregister/portmapper"
)
var netcheckCmd = &ffcli.Command{
Name: "netcheck",
ShortUsage: "tailscale netcheck",
ShortHelp: "Print an analysis of local network conditions",
Exec: runNetcheck,
FlagSet: netcheckFlagSet,
}
var netcheckFlagSet = func() *flag.FlagSet {
fs := newFlagSet("netcheck")
fs.StringVar(&netcheckArgs.format, "format", "", `output format; empty (for human-readable), "json" or "json-line"`)
fs.DurationVar(&netcheckArgs.every, "every", 0, "if non-zero, do an incremental report with the given frequency")
fs.BoolVar(&netcheckArgs.verbose, "verbose", false, "verbose logs")
fs.StringVar(&netcheckArgs.bindAddress, "bind-address", "", "send and receive connectivity probes using this locally bound IP address; default: OS-assigned")
fs.IntVar(&netcheckArgs.bindPort, "bind-port", 0, "send and receive connectivity probes using this UDP port; default: OS-assigned")
return fs
}()
var netcheckArgs struct {
format string
every time.Duration
verbose bool
bindAddress string
bindPort int
}
func runNetcheck(ctx context.Context, args []string) error {
logf := logger.WithPrefix(log.Printf, "portmap: ")
bus := eventbus.New()
defer bus.Close()
netMon, err := netmon.New(bus, logf)
if err != nil {
return err
}
var pm portmappertype.Client
if buildfeatures.HasPortMapper {
// Ensure that we close the portmapper after running a netcheck; this
// will release any port mappings created.
pm = portmappertype.HookNewPortMapper.Get()(logf, bus, netMon, nil, nil)
defer pm.Close()
}
flagsProvided := set.Set[string]{}
netcheckFlagSet.Visit(func(f *flag.Flag) {
flagsProvided.Add(f.Name)
})
c := &netcheck.Client{
NetMon: netMon,
PortMapper: pm,
UseDNSCache: false, // always resolve, don't cache
}
if netcheckArgs.verbose {
c.Logf = logger.WithPrefix(log.Printf, "netcheck: ")
c.Verbose = true
} else {
c.Logf = logger.Discard
}
if strings.HasPrefix(netcheckArgs.format, "json") {
fmt.Fprintln(Stderr, "# Warning: this JSON format is not yet considered a stable interface")
}
bind, err := createNetcheckBindString(
netcheckArgs.bindAddress,
flagsProvided.Contains("bind-address"),
netcheckArgs.bindPort,
flagsProvided.Contains("bind-port"),
envknob.String("TS_DEBUG_NETCHECK_UDP_BIND"))
if err != nil {
return err
}
if err := c.Standalone(ctx, bind); err != nil {
fmt.Fprintln(Stderr, "netcheck: UDP test failure:", err)
}
dm, err := localClient.CurrentDERPMap(ctx)
noRegions := dm != nil && len(dm.Regions) == 0
if noRegions {
log.Printf("No DERP map from tailscaled; using default.")
}
if err != nil || noRegions {
hc := &http.Client{
Transport: tlsdial.NewTransport(),
Timeout: 10 * time.Second,
}
dm, err = prodDERPMap(ctx, hc)
if err != nil {
log.Println("Failed to fetch a DERP map, so netcheck cannot continue. Check your Internet connection.")
return err
}
}
for {
t0 := time.Now()
report, err := c.GetReport(ctx, dm, nil)
d := time.Since(t0)
if netcheckArgs.verbose {
c.Logf("GetReport took %v; err=%v", d.Round(time.Millisecond), err)
}
if err != nil {
return fmt.Errorf("netcheck: %w", err)
}
if err := printReport(dm, report); err != nil {
return err
}
if netcheckArgs.every == 0 {
return nil
}
time.Sleep(netcheckArgs.every)
}
}
func printReport(dm *tailcfg.DERPMap, report *netcheck.Report) error {
var j []byte
var err error
switch netcheckArgs.format {
case "":
case "json":
j, err = json.MarshalIndent(report, "", "\t")
case "json-line":
j, err = json.Marshal(report)
default:
return fmt.Errorf("unknown output format %q", netcheckArgs.format)
}
if err != nil {
return err
}
if j != nil {
j = append(j, '\n')
Stdout.Write(j)
return nil
}
printf("\nReport:\n")
printf("\t* Time: %v\n", report.Now.Format(time.RFC3339Nano))
printf("\t* UDP: %v\n", report.UDP)
if report.GlobalV4.IsValid() {
printf("\t* IPv4: yes, %s\n", report.GlobalV4)
} else {
printf("\t* IPv4: (no addr found)\n")
}
if report.GlobalV6.IsValid() {
printf("\t* IPv6: yes, %s\n", report.GlobalV6)
} else if report.IPv6 {
printf("\t* IPv6: (no addr found)\n")
} else if report.OSHasIPv6 {
printf("\t* IPv6: no, but OS has support\n")
} else {
printf("\t* IPv6: no, unavailable in OS\n")
}
printf("\t* MappingVariesByDestIP: %v\n", report.MappingVariesByDestIP)
printf("\t* PortMapping: %v\n", portMapping(report))
if report.CaptivePortal != "" {
printf("\t* CaptivePortal: %v\n", report.CaptivePortal)
}
// When DERP latency checking failed,
// magicsock will try to pick the DERP server that
// most of your other nodes are also using
if len(report.RegionLatency) == 0 {
printf("\t* Nearest DERP: unknown (no response to latency probes)\n")
} else {
if report.PreferredDERP != 0 {
if region, ok := dm.Regions[report.PreferredDERP]; ok {
printf("\t* Nearest DERP: %v\n", region.RegionName)
} else {
printf("\t* Nearest DERP: %v (region not found in map)\n", report.PreferredDERP)
}
} else {
printf("\t* Nearest DERP: [none]\n")
}
printf("\t* DERP latency:\n")
var rids []int
for rid := range dm.Regions {
rids = append(rids, rid)
}
sort.Slice(rids, func(i, j int) bool {
l1, ok1 := report.RegionLatency[rids[i]]
l2, ok2 := report.RegionLatency[rids[j]]
if ok1 != ok2 {
return ok1 // defined things sort first
}
if !ok1 {
return rids[i] < rids[j]
}
return l1 < l2
})
for _, rid := range rids {
d, ok := report.RegionLatency[rid]
var latency string
if ok {
latency = d.Round(time.Millisecond / 10).String()
}
r := dm.Regions[rid]
var derpNum string
if netcheckArgs.verbose {
derpNum = fmt.Sprintf("derp%d, ", rid)
}
printf("\t\t- %3s: %-7s (%s%s)\n", r.RegionCode, latency, derpNum, r.RegionName)
}
}
return nil
}
func portMapping(r *netcheck.Report) string {
if !buildfeatures.HasPortMapper {
return "binary built without portmapper support"
}
if !r.AnyPortMappingChecked() {
return "not checked"
}
var got []string
if r.UPnP.EqualBool(true) {
got = append(got, "UPnP")
}
if r.PMP.EqualBool(true) {
got = append(got, "NAT-PMP")
}
if r.PCP.EqualBool(true) {
got = append(got, "PCP")
}
return strings.Join(got, ", ")
}
func prodDERPMap(ctx context.Context, httpc *http.Client) (*tailcfg.DERPMap, error) {
log.Printf("attempting to fetch a DERPMap from %s", ipn.DefaultControlURL)
req, err := http.NewRequestWithContext(ctx, "GET", ipn.DefaultControlURL+"/derpmap/default", nil)
if err != nil {
return nil, fmt.Errorf("create prodDERPMap request: %w", err)
}
res, err := httpc.Do(req)
if err != nil {
return nil, fmt.Errorf("fetch prodDERPMap failed: %w", err)
}
defer res.Body.Close()
b, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
if err != nil {
return nil, fmt.Errorf("fetch prodDERPMap failed: %w", err)
}
if res.StatusCode != 200 {
return nil, fmt.Errorf("fetch prodDERPMap: %v: %s", res.Status, b)
}
var derpMap tailcfg.DERPMap
if err = json.Unmarshal(b, &derpMap); err != nil {
return nil, fmt.Errorf("fetch prodDERPMap: %w", err)
}
return &derpMap, nil
}
// createNetcheckBindString determines the netcheck socket bind "address:port" string based
// on the CLI args and environment variable values used to invoke the netcheck CLI.
// Arguments cliAddressIsSet and cliPortIsSet explicitly indicate whether the
// corresponding cliAddress and cliPort were set in CLI args, instead of relying
// on in-band sentinel values.
func createNetcheckBindString(cliAddress string, cliAddressIsSet bool, cliPort int, cliPortIsSet bool, envBind string) (string, error) {
// Default to port number 0 but overwrite with a valid CLI value, if set.
var port uint16 = 0
if cliPortIsSet {
// 0 is valid, results in OS picking port.
if cliPort >= 0 && cliPort <= math.MaxUint16 {
port = uint16(cliPort)
} else {
return "", fmt.Errorf("invalid bind port number: %d", cliPort)
}
}
// Use CLI address, if set.
if cliAddressIsSet {
addr, err := netip.ParseAddr(cliAddress)
if err != nil {
return "", fmt.Errorf("invalid bind address: %q", cliAddress)
}
return netip.AddrPortFrom(addr, port).String(), nil
} else {
// No CLI address set, but port is set.
if cliPortIsSet {
return fmt.Sprintf(":%d", port), nil
}
}
// Fall back to the environment variable.
// Intentionally skipping input validation here to avoid breaking legacy usage method.
if envBind != "" {
return envBind, nil
}
// OS picks both address and port.
return ":0", nil
}