cmd/tailscale/cli: add 'wait' listening subcommand and ip --assert=<ip>

This provides a mechanism to block, waiting for Tailscale's IP to be
ready for a bind/listen, to gate the starting of other services.

It also adds a new --assert=[IP] option to "tailscale ip", for services
that want extra paranoia about what IP is in use, if they're worried about
having switched to the wrong tailnet prior to reboot or something.

Updates #3340
Updates #11504

... and many more, IIRC

Change-Id: I88ab19ac5fae58fd8c516065bab685e292395565
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
main
Brad Fitzpatrick 3 months ago committed by Brad Fitzpatrick
parent abdbca47af
commit 8736fbb754
  1. 1
      cmd/tailscale/cli/cli.go
  2. 16
      cmd/tailscale/cli/ip.go
  3. 157
      cmd/tailscale/cli/wait.go
  4. 1
      cmd/tailscale/depaware.txt

@ -278,6 +278,7 @@ change in the future.
configureHostCmd(),
systrayCmd,
appcRoutesCmd,
waitCmd,
),
FlagSet: rootfs,
Exec: func(ctx context.Context, args []string) error {

@ -25,14 +25,16 @@ var ipCmd = &ffcli.Command{
fs.BoolVar(&ipArgs.want1, "1", false, "only print one IP address")
fs.BoolVar(&ipArgs.want4, "4", false, "only print IPv4 address")
fs.BoolVar(&ipArgs.want6, "6", false, "only print IPv6 address")
fs.StringVar(&ipArgs.assert, "assert", "", "assert that one of the node's IP(s) matches this IP address")
return fs
})(),
}
var ipArgs struct {
want1 bool
want4 bool
want6 bool
want1 bool
want4 bool
want6 bool
assert string
}
func runIP(ctx context.Context, args []string) error {
@ -62,6 +64,14 @@ func runIP(ctx context.Context, args []string) error {
return err
}
ips := st.TailscaleIPs
if ipArgs.assert != "" {
for _, ip := range ips {
if ip.String() == ipArgs.assert {
return nil
}
}
return fmt.Errorf("assertion failed: IP %q not found among %v", ipArgs.assert, ips)
}
if of != "" {
ip, _, err := tailscaleIPFromArg(ctx, of)
if err != nil {

@ -0,0 +1,157 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package cli
import (
"context"
"flag"
"fmt"
"net"
"net/netip"
"strings"
"time"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/ipn"
"tailscale.com/types/logger"
"tailscale.com/util/backoff"
)
var waitCmd = &ffcli.Command{
Name: "wait",
ShortHelp: "Wait for Tailscale interface/IPs to be ready for binding",
LongHelp: strings.TrimSpace(`
Wait for Tailscale resources to be available. As of 2026-01-02, the only
resource that's available to wait for by is the Tailscale interface and its
IPs.
With no arguments, this command will block until tailscaled is up, its backend is running,
and the Tailscale interface is up and has a Tailscale IP address assigned to it.
If running in userspace-networking mode, this command only waits for tailscaled and
the Running state, as no physical network interface exists.
A future version of this command may support waiting for other types of resources.
The command returns exit code 0 on success, and non-zero on failure or timeout.
To wait on a specific type of IP address, use 'tailscale ip' in combination with
the 'tailscale wait' command. For example, to wait for an IPv4 address:
tailscale wait && tailscale ip --assert=<specific-IP-address>
Linux systemd users can wait for the "tailscale-online.target" target, which runs
this command.
More generally, a service that wants to bind to (listen on) a Tailscale interface or IP address
can run it like 'tailscale wait && /path/to/service [...]' to ensure that Tailscale is ready
before the program starts.
`),
ShortUsage: "tailscale wait",
Exec: runWait,
FlagSet: (func() *flag.FlagSet {
fs := newFlagSet("wait")
fs.DurationVar(&waitArgs.timeout, "timeout", 0, "how long to wait before giving up (0 means wait indefinitely)")
return fs
})(),
}
var waitArgs struct {
timeout time.Duration
}
func runWait(ctx context.Context, args []string) error {
if len(args) > 0 {
return fmt.Errorf("unexpected arguments: %q", args)
}
if waitArgs.timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, waitArgs.timeout)
defer cancel()
}
bo := backoff.NewBackoff("wait", logger.Discard, 2*time.Second)
for {
_, err := localClient.StatusWithoutPeers(ctx)
bo.BackOff(ctx, err)
if err == nil {
break
}
if ctx.Err() != nil {
return ctx.Err()
}
}
watcher, err := localClient.WatchIPNBus(ctx, ipn.NotifyInitialState)
if err != nil {
return err
}
defer watcher.Close()
var firstIP netip.Addr
for {
not, err := watcher.Next()
if err != nil {
return err
}
if not.State != nil && *not.State == ipn.Running {
st, err := localClient.StatusWithoutPeers(ctx)
if err != nil {
return err
}
if len(st.TailscaleIPs) > 0 {
firstIP = st.TailscaleIPs[0]
break
}
}
}
st, err := localClient.StatusWithoutPeers(ctx)
if err != nil {
return err
}
if !st.TUN {
// No TUN; nothing more to wait for.
return nil
}
// Verify we have an interface using that IP.
for {
err := checkForInterfaceIP(firstIP)
if err == nil {
return nil
}
bo.BackOff(ctx, err)
if ctx.Err() != nil {
return ctx.Err()
}
}
}
func checkForInterfaceIP(ip netip.Addr) error {
ifs, err := net.Interfaces()
if err != nil {
return err
}
for _, ifi := range ifs {
addrs, err := ifi.Addrs()
if err != nil {
return err
}
for _, addr := range addrs {
var aip netip.Addr
switch v := addr.(type) {
case *net.IPNet:
aip, _ = netip.AddrFromSlice(v.IP)
case *net.IPAddr:
aip, _ = netip.AddrFromSlice(v.IP)
}
if aip.Unmap() == ip {
return nil
}
}
}
return fmt.Errorf("no interface has IP %v", ip)
}

@ -251,6 +251,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
tailscale.com/types/structs from tailscale.com/ipn+
tailscale.com/types/tkatype from tailscale.com/types/key+
tailscale.com/types/views from tailscale.com/tailcfg+
tailscale.com/util/backoff from tailscale.com/cmd/tailscale/cli
tailscale.com/util/cibuild from tailscale.com/health+
tailscale.com/util/clientmetric from tailscale.com/net/netcheck+
tailscale.com/util/cloudenv from tailscale.com/net/dnscache+

Loading…
Cancel
Save