2025-02-26 15:42:24 -08:00
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build linux || darwin
// The mts ("Multiple Tailscale") command runs multiple tailscaled instances for
// development, managing their directories and sockets, and lets you easily direct
// tailscale CLI commands to them.
package main
import (
"bufio"
"context"
2025-07-14 14:41:16 -07:00
"github.com/go-json-experiment/json/v1"
2025-02-26 15:42:24 -08:00
"flag"
"fmt"
"io"
"log"
"maps"
"net"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"regexp"
"slices"
"strings"
"sync"
"syscall"
"time"
"tailscale.com/client/local"
"tailscale.com/types/bools"
"tailscale.com/types/lazy"
"tailscale.com/util/mak"
)
func usage ( args ... any ) {
var format string
if len ( args ) > 0 {
format , args = args [ 0 ] . ( string ) , args [ 1 : ]
}
if format != "" {
format = strings . TrimSpace ( format ) + "\n\n"
fmt . Fprintf ( os . Stderr , format , args ... )
}
io . WriteString ( os . Stderr , strings . TrimSpace ( `
usage :
mts server < subcommand > # manage tailscaled instances
mts server run # run the mts server ( parent process of all tailscaled )
mts server list # list all tailscaled and their state
mts server list < name > # show details of named instance
mts server add < name > # add + start new named tailscaled
mts server start < name > # start a previously added tailscaled
mts server stop < name > # stop & remove a named tailscaled
mts server rm < name > # stop & remove a named tailscaled
mts server logs [ - f ] < name > # get / follow tailscaled logs
mts < inst - name > [ tailscale CLI args ] # run Tailscale CLI against a named instance
e . g .
mts gmail1 up
mts github2 status -- json
` ) + "\n" )
os . Exit ( 1 )
}
func main ( ) {
// Don't use flag.Parse here; we mostly just delegate through
// to the Tailscale CLI.
if len ( os . Args ) < 2 {
usage ( )
}
firstArg , args := os . Args [ 1 ] , os . Args [ 2 : ]
if firstArg == "server" || firstArg == "s" {
if err := runMTSServer ( args ) ; err != nil {
log . Fatal ( err )
}
} else {
var c Client
inst := firstArg
c . RunCommand ( inst , args )
}
}
func runMTSServer ( args [ ] string ) error {
if len ( args ) == 0 {
usage ( )
}
cmd , args := args [ 0 ] , args [ 1 : ]
if cmd == "run" {
var s Server
return s . Run ( )
}
// Commands other than "run" all use the HTTP client to
// hit the mts server over its unix socket.
var c Client
switch cmd {
default :
usage ( "unknown mts server subcommand %q" , cmd )
case "list" , "ls" :
list , err := c . List ( )
if err != nil {
return err
}
if len ( args ) == 0 {
names := slices . Sorted ( maps . Keys ( list . Instances ) )
for _ , name := range names {
running := list . Instances [ name ] . Running
fmt . Printf ( "%10s %s\n" , bools . IfElse ( running , "RUNNING" , "stopped" ) , name )
}
} else {
for _ , name := range args {
inst , ok := list . Instances [ name ]
if ! ok {
return fmt . Errorf ( "no instance named %q" , name )
}
je := json . NewEncoder ( os . Stdout )
je . SetIndent ( "" , " " )
if err := je . Encode ( inst ) ; err != nil {
return err
}
}
}
case "rm" :
if len ( args ) == 0 {
return fmt . Errorf ( "missing instance name(s) to remove" )
}
log . SetFlags ( 0 )
for _ , name := range args {
ok , err := c . Remove ( name )
if err != nil {
return err
}
if ok {
log . Printf ( "%s deleted." , name )
} else {
log . Printf ( "%s didn't exist." , name )
}
}
case "stop" :
if len ( args ) == 0 {
return fmt . Errorf ( "missing instance name(s) to stop" )
}
log . SetFlags ( 0 )
for _ , name := range args {
ok , err := c . Stop ( name )
if err != nil {
return err
}
if ok {
log . Printf ( "%s stopped." , name )
} else {
log . Printf ( "%s didn't exist." , name )
}
}
case "start" , "restart" :
list , err := c . List ( )
if err != nil {
return err
}
shouldStop := cmd == "restart"
for _ , arg := range args {
is , ok := list . Instances [ arg ]
if ! ok {
return fmt . Errorf ( "no instance named %q" , arg )
}
if is . Running {
if shouldStop {
if _ , err := c . Stop ( arg ) ; err != nil {
return fmt . Errorf ( "stopping %q: %w" , arg , err )
}
} else {
log . SetFlags ( 0 )
log . Printf ( "%s already running." , arg )
continue
}
}
// Creating an existing one starts it up.
if err := c . Create ( arg ) ; err != nil {
return fmt . Errorf ( "starting %q: %w" , arg , err )
}
}
case "add" :
if len ( args ) == 0 {
return fmt . Errorf ( "missing instance name(s) to add" )
}
for _ , name := range args {
if err := c . Create ( name ) ; err != nil {
return fmt . Errorf ( "creating %q: %w" , name , err )
}
}
case "logs" :
fs := flag . NewFlagSet ( "logs" , flag . ExitOnError )
fs . Usage = func ( ) { usage ( ) }
follow := fs . Bool ( "f" , false , "follow logs" )
fs . Parse ( args )
log . Printf ( "Parsed; following=%v, args=%q" , * follow , fs . Args ( ) )
if fs . NArg ( ) != 1 {
usage ( )
}
cmd := bools . IfElse ( * follow , "tail" , "cat" )
args := [ ] string { cmd }
if * follow {
args = append ( args , "-f" )
}
path , err := exec . LookPath ( cmd )
if err != nil {
return fmt . Errorf ( "looking up %q: %w" , cmd , err )
}
args = append ( args , instLogsFile ( fs . Arg ( 0 ) ) )
log . Fatal ( syscall . Exec ( path , args , os . Environ ( ) ) )
}
return nil
}
type Client struct {
}
func ( c * Client ) client ( ) * http . Client {
return & http . Client {
Transport : & http . Transport {
DialContext : func ( ctx context . Context , network , addr string ) ( net . Conn , error ) {
return net . Dial ( "unix" , mtsSock ( ) )
} ,
} ,
}
}
func getJSON [ T any ] ( res * http . Response , err error ) ( T , error ) {
var ret T
if err != nil {
return ret , err
}
defer res . Body . Close ( )
if res . StatusCode != http . StatusOK {
body , _ := io . ReadAll ( res . Body )
return ret , fmt . Errorf ( "unexpected status: %v: %s" , res . Status , body )
}
if err := json . NewDecoder ( res . Body ) . Decode ( & ret ) ; err != nil {
return ret , err
}
return ret , nil
}
func ( c * Client ) List ( ) ( listResponse , error ) {
return getJSON [ listResponse ] ( c . client ( ) . Get ( "http://mts/list" ) )
}
func ( c * Client ) Remove ( name string ) ( found bool , err error ) {
return getJSON [ bool ] ( c . client ( ) . PostForm ( "http://mts/rm" , url . Values {
"name" : [ ] string { name } ,
} ) )
}
func ( c * Client ) Stop ( name string ) ( found bool , err error ) {
return getJSON [ bool ] ( c . client ( ) . PostForm ( "http://mts/stop" , url . Values {
"name" : [ ] string { name } ,
} ) )
}
func ( c * Client ) Create ( name string ) error {
req , err := http . NewRequest ( "POST" , "http://mts/create/" + name , nil )
if err != nil {
return err
}
resp , err := c . client ( ) . Do ( req )
if err != nil {
return err
}
defer resp . Body . Close ( )
if resp . StatusCode != http . StatusOK {
body , _ := io . ReadAll ( resp . Body )
return fmt . Errorf ( "unexpected status: %v: %s" , resp . Status , body )
}
return nil
}
func ( c * Client ) RunCommand ( name string , args [ ] string ) {
sock := instSock ( name )
lc := & local . Client {
Socket : sock ,
UseSocketOnly : true ,
}
probeCtx , cancel := context . WithTimeout ( context . Background ( ) , 250 * time . Millisecond )
defer cancel ( )
if _ , err := lc . StatusWithoutPeers ( probeCtx ) ; err != nil {
log . Fatalf ( "instance %q not running? start with 'mts server start %q'; got error: %v" , name , name , err )
}
args = append ( [ ] string { "run" , "tailscale.com/cmd/tailscale" , "--socket=" + sock } , args ... )
cmd := exec . Command ( "go" , args ... )
cmd . Stdout = os . Stdout
cmd . Stderr = os . Stderr
cmd . Stdin = os . Stdin
err := cmd . Run ( )
if err == nil {
os . Exit ( 0 )
}
if exitErr , ok := err . ( * exec . ExitError ) ; ok {
os . Exit ( exitErr . ExitCode ( ) )
}
panic ( err )
}
type Server struct {
lazyTailscaled lazy . GValue [ string ]
mu sync . Mutex
cmds map [ string ] * exec . Cmd // running tailscaled instances
}
func ( s * Server ) tailscaled ( ) string {
v , err := s . lazyTailscaled . GetErr ( func ( ) ( string , error ) {
out , err := exec . Command ( "go" , "list" , "-f" , "{{.Target}}" , "tailscale.com/cmd/tailscaled" ) . CombinedOutput ( )
if err != nil {
return "" , err
}
return strings . TrimSpace ( string ( out ) ) , nil
} )
if err != nil {
panic ( err )
}
return v
}
func ( s * Server ) Run ( ) error {
if err := os . MkdirAll ( mtsRoot ( ) , 0700 ) ; err != nil {
return err
}
sock := mtsSock ( )
os . Remove ( sock )
log . Printf ( "Multi-Tailscaled Server running; listening on %q ..." , sock )
ln , err := net . Listen ( "unix" , sock )
if err != nil {
return err
}
return http . Serve ( ln , s )
}
var validNameRx = regexp . MustCompile ( ` ^[a-zA-Z0-9_-]+$ ` )
func validInstanceName ( name string ) bool {
return validNameRx . MatchString ( name )
}
func ( s * Server ) InstanceRunning ( name string ) bool {
s . mu . Lock ( )
defer s . mu . Unlock ( )
_ , ok := s . cmds [ name ]
return ok
}
func ( s * Server ) Stop ( name string ) {
s . mu . Lock ( )
defer s . mu . Unlock ( )
if cmd , ok := s . cmds [ name ] ; ok {
if err := cmd . Process . Kill ( ) ; err != nil {
log . Printf ( "error killing %q: %v" , name , err )
}
delete ( s . cmds , name )
}
}
func ( s * Server ) RunInstance ( name string ) error {
s . mu . Lock ( )
defer s . mu . Unlock ( )
if _ , ok := s . cmds [ name ] ; ok {
return fmt . Errorf ( "instance %q already running" , name )
}
if ! validInstanceName ( name ) {
return fmt . Errorf ( "invalid instance name %q" , name )
}
dir := filepath . Join ( mtsRoot ( ) , name )
if err := os . MkdirAll ( dir , 0700 ) ; err != nil {
return err
}
env := os . Environ ( )
env = append ( env , "TS_DEBUG_LOG_RATE=all" )
if ef , err := os . Open ( instEnvFile ( name ) ) ; err == nil {
defer ef . Close ( )
sc := bufio . NewScanner ( ef )
for sc . Scan ( ) {
t := strings . TrimSpace ( sc . Text ( ) )
if strings . HasPrefix ( t , "#" ) || ! strings . Contains ( t , "=" ) {
continue
}
env = append ( env , t )
}
} else if os . IsNotExist ( err ) {
// Write an example one.
os . WriteFile ( instEnvFile ( name ) , fmt . Appendf ( nil , "# Example mts env.txt file; uncomment/add stuff you want for %q\n\n#TS_DEBUG_MAP=1\n#TS_DEBUG_REGISTER=1\n#TS_NO_LOGS_NO_SUPPORT=1\n" , name ) , 0600 )
}
extraArgs := [ ] string { "--verbose=1" }
if af , err := os . Open ( instArgsFile ( name ) ) ; err == nil {
extraArgs = nil // clear default args
defer af . Close ( )
sc := bufio . NewScanner ( af )
for sc . Scan ( ) {
t := strings . TrimSpace ( sc . Text ( ) )
if strings . HasPrefix ( t , "#" ) || t == "" {
continue
}
extraArgs = append ( extraArgs , t )
}
} else if os . IsNotExist ( err ) {
// Write an example one.
os . WriteFile ( instArgsFile ( name ) , fmt . Appendf ( nil , "# Example mts args.txt file for instance %q.\n# One line per extra arg to tailscaled; no magic string quoting\n\n--verbose=1\n#--socks5-server=127.0.0.1:5000\n" , name ) , 0600 )
}
log . Printf ( "Running Tailscale daemon %q in %q" , name , dir )
args := [ ] string {
"--tun=userspace-networking" ,
"--statedir=" + filepath . Join ( dir ) ,
"--socket=" + filepath . Join ( dir , "tailscaled.sock" ) ,
}
args = append ( args , extraArgs ... )
cmd := exec . Command ( s . tailscaled ( ) , args ... )
cmd . Dir = dir
cmd . Env = env
out , err := cmd . StdoutPipe ( )
if err != nil {
return err
}
cmd . Stderr = cmd . Stdout
logs := instLogsFile ( name )
logFile , err := os . OpenFile ( logs , os . O_CREATE | os . O_WRONLY | os . O_APPEND | os . O_TRUNC , 0644 )
if err != nil {
return fmt . Errorf ( "opening logs file: %w" , err )
}
go func ( ) {
bs := bufio . NewScanner ( out )
for bs . Scan ( ) {
// TODO(bradfitz): record in memory too, serve via HTTP
line := strings . TrimSpace ( bs . Text ( ) )
fmt . Fprintf ( logFile , "%s\n" , line )
fmt . Printf ( "tailscaled[%s]: %s\n" , name , line )
}
} ( )
if err := cmd . Start ( ) ; err != nil {
return err
}
go func ( ) {
err := cmd . Wait ( )
logFile . Close ( )
log . Printf ( "Tailscale daemon %q exited: %v" , name , err )
s . mu . Lock ( )
defer s . mu . Unlock ( )
delete ( s . cmds , name )
} ( )
mak . Set ( & s . cmds , name , cmd )
return nil
}
type listResponse struct {
// Instances maps instance name to its details.
Instances map [ string ] listResponseInstance ` json:"instances" `
}
type listResponseInstance struct {
Name string ` json:"name" `
Dir string ` json:"dir" `
Sock string ` json:"sock" `
Running bool ` json:"running" `
Env string ` json:"env" `
Args string ` json:"args" `
Logs string ` json:"logs" `
}
func writeJSON ( w http . ResponseWriter , v any ) {
w . Header ( ) . Set ( "Content-Type" , "application/json" )
e := json . NewEncoder ( w )
e . SetIndent ( "" , " " )
e . Encode ( v )
}
func ( s * Server ) ServeHTTP ( w http . ResponseWriter , r * http . Request ) {
if r . URL . Path == "/list" {
var res listResponse
for _ , name := range s . InstanceNames ( ) {
mak . Set ( & res . Instances , name , listResponseInstance {
Name : name ,
Dir : instDir ( name ) ,
Sock : instSock ( name ) ,
Running : s . InstanceRunning ( name ) ,
Env : instEnvFile ( name ) ,
Args : instArgsFile ( name ) ,
Logs : instLogsFile ( name ) ,
} )
}
writeJSON ( w , res )
return
}
if r . URL . Path == "/rm" || r . URL . Path == "/stop" {
shouldRemove := r . URL . Path == "/rm"
if r . Method != "POST" {
http . Error ( w , "POST required" , http . StatusMethodNotAllowed )
return
}
target := r . FormValue ( "name" )
var ok bool
for _ , name := range s . InstanceNames ( ) {
if name != target {
continue
}
ok = true
s . Stop ( name )
if shouldRemove {
if err := os . RemoveAll ( instDir ( name ) ) ; err != nil {
http . Error ( w , err . Error ( ) , http . StatusInternalServerError )
return
}
}
break
}
writeJSON ( w , ok )
return
}
if inst , ok := strings . CutPrefix ( r . URL . Path , "/create/" ) ; ok {
if ! s . InstanceRunning ( inst ) {
if err := s . RunInstance ( inst ) ; err != nil {
http . Error ( w , err . Error ( ) , http . StatusInternalServerError )
return
}
}
fmt . Fprintf ( w , "OK\n" )
return
}
if r . URL . Path == "/" {
fmt . Fprintf ( w , "This is mts, the multi-tailscaled server.\n" )
return
}
http . NotFound ( w , r )
}
func ( s * Server ) InstanceNames ( ) [ ] string {
var ret [ ] string
des , err := os . ReadDir ( mtsRoot ( ) )
if err != nil {
if os . IsNotExist ( err ) {
return nil
}
panic ( err )
}
for _ , de := range des {
if ! de . IsDir ( ) {
continue
}
ret = append ( ret , de . Name ( ) )
}
return ret
}
func mtsRoot ( ) string {
dir , err := os . UserConfigDir ( )
if err != nil {
panic ( err )
}
return filepath . Join ( dir , "multi-tailscale-dev" )
}
func instDir ( name string ) string {
return filepath . Join ( mtsRoot ( ) , name )
}
func instSock ( name string ) string {
return filepath . Join ( instDir ( name ) , "tailscaled.sock" )
}
func instEnvFile ( name string ) string {
return filepath . Join ( mtsRoot ( ) , name , "env.txt" )
}
func instArgsFile ( name string ) string {
return filepath . Join ( mtsRoot ( ) , name , "args.txt" )
}
func instLogsFile ( name string ) string {
return filepath . Join ( mtsRoot ( ) , name , "logs.txt" )
}
func mtsSock ( ) string {
return filepath . Join ( mtsRoot ( ) , "mts.sock" )
}