Compare commits
11 Commits
dependabot
...
bradfitz/m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c525b20511 | ||
|
|
5aa1c27aad | ||
|
|
725c8d298a | ||
|
|
08c8ccb48e | ||
|
|
e78055eb01 | ||
|
|
ea79dc161d | ||
|
|
b3455fa99a | ||
|
|
14db99241f | ||
|
|
156cd53e77 | ||
|
|
5c0e08fbbd | ||
|
|
d0c50c6072 |
@@ -28,6 +28,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"tailscale.com/hostinfo"
|
||||
"tailscale.com/types/lazy"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/util/cmpver"
|
||||
"tailscale.com/version"
|
||||
@@ -249,9 +250,13 @@ func (up *Updater) getUpdateFunction() (fn updateFunction, canAutoUpdate bool) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
var canAutoUpdateCache lazy.SyncValue[bool]
|
||||
|
||||
// CanAutoUpdate reports whether auto-updating via the clientupdate package
|
||||
// is supported for the current os/distro.
|
||||
func CanAutoUpdate() bool {
|
||||
func CanAutoUpdate() bool { return canAutoUpdateCache.Get(canAutoUpdateUncached) }
|
||||
|
||||
func canAutoUpdateUncached() bool {
|
||||
if version.IsMacSysExt() {
|
||||
// Macsys uses Sparkle for auto-updates, which doesn't have an update
|
||||
// function in this package.
|
||||
|
||||
365
cmd/natc/natc_test.go
Normal file
365
cmd/natc/natc_test.go
Normal file
@@ -0,0 +1,365 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/gaissmai/bart"
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"golang.org/x/net/dns/dnsmessage"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
func prefixEqual(a, b netip.Prefix) bool {
|
||||
return a.Bits() == b.Bits() && a.Addr() == b.Addr()
|
||||
}
|
||||
|
||||
func TestULA(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
siteID uint16
|
||||
expected string
|
||||
}{
|
||||
{"zero", 0, "fd7a:115c:a1e0:a99c:0000::/80"},
|
||||
{"one", 1, "fd7a:115c:a1e0:a99c:0001::/80"},
|
||||
{"max", 65535, "fd7a:115c:a1e0:a99c:ffff::/80"},
|
||||
{"random", 12345, "fd7a:115c:a1e0:a99c:3039::/80"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := ula(tc.siteID)
|
||||
expected := netip.MustParsePrefix(tc.expected)
|
||||
if !prefixEqual(got, expected) {
|
||||
t.Errorf("ula(%d) = %s; want %s", tc.siteID, got, expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRandV4(t *testing.T) {
|
||||
pfx := netip.MustParsePrefix("100.64.1.0/24")
|
||||
|
||||
for i := 0; i < 512; i++ {
|
||||
ip := randV4(pfx)
|
||||
if !pfx.Contains(ip) {
|
||||
t.Errorf("randV4(%s) = %s; not contained in prefix", pfx, ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSResponse(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
questions []dnsmessage.Question
|
||||
addrs []netip.Addr
|
||||
wantEmpty bool
|
||||
wantAnswers []struct {
|
||||
name string
|
||||
qType dnsmessage.Type
|
||||
addr netip.Addr
|
||||
}
|
||||
}{
|
||||
{
|
||||
name: "empty_request",
|
||||
questions: []dnsmessage.Question{},
|
||||
addrs: []netip.Addr{},
|
||||
wantEmpty: false,
|
||||
wantAnswers: nil,
|
||||
},
|
||||
{
|
||||
name: "a_record",
|
||||
questions: []dnsmessage.Question{
|
||||
{
|
||||
Name: dnsmessage.MustNewName("example.com."),
|
||||
Type: dnsmessage.TypeA,
|
||||
Class: dnsmessage.ClassINET,
|
||||
},
|
||||
},
|
||||
addrs: []netip.Addr{netip.MustParseAddr("100.64.1.5")},
|
||||
wantAnswers: []struct {
|
||||
name string
|
||||
qType dnsmessage.Type
|
||||
addr netip.Addr
|
||||
}{
|
||||
{
|
||||
name: "example.com.",
|
||||
qType: dnsmessage.TypeA,
|
||||
addr: netip.MustParseAddr("100.64.1.5"),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "aaaa_record",
|
||||
questions: []dnsmessage.Question{
|
||||
{
|
||||
Name: dnsmessage.MustNewName("example.com."),
|
||||
Type: dnsmessage.TypeAAAA,
|
||||
Class: dnsmessage.ClassINET,
|
||||
},
|
||||
},
|
||||
addrs: []netip.Addr{netip.MustParseAddr("fd7a:115c:a1e0:a99c:0001:0505:0505:0505")},
|
||||
wantAnswers: []struct {
|
||||
name string
|
||||
qType dnsmessage.Type
|
||||
addr netip.Addr
|
||||
}{
|
||||
{
|
||||
name: "example.com.",
|
||||
qType: dnsmessage.TypeAAAA,
|
||||
addr: netip.MustParseAddr("fd7a:115c:a1e0:a99c:0001:0505:0505:0505"),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "soa_record",
|
||||
questions: []dnsmessage.Question{
|
||||
{
|
||||
Name: dnsmessage.MustNewName("example.com."),
|
||||
Type: dnsmessage.TypeSOA,
|
||||
Class: dnsmessage.ClassINET,
|
||||
},
|
||||
},
|
||||
addrs: []netip.Addr{},
|
||||
wantAnswers: nil,
|
||||
},
|
||||
{
|
||||
name: "ns_record",
|
||||
questions: []dnsmessage.Question{
|
||||
{
|
||||
Name: dnsmessage.MustNewName("example.com."),
|
||||
Type: dnsmessage.TypeNS,
|
||||
Class: dnsmessage.ClassINET,
|
||||
},
|
||||
},
|
||||
addrs: []netip.Addr{},
|
||||
wantAnswers: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := &dnsmessage.Message{
|
||||
Header: dnsmessage.Header{
|
||||
ID: 1234,
|
||||
},
|
||||
Questions: tc.questions,
|
||||
}
|
||||
|
||||
resp, err := dnsResponse(req, tc.addrs)
|
||||
if err != nil {
|
||||
t.Fatalf("dnsResponse() error = %v", err)
|
||||
}
|
||||
|
||||
if tc.wantEmpty && len(resp) != 0 {
|
||||
t.Errorf("dnsResponse() returned non-empty response when expected empty")
|
||||
}
|
||||
|
||||
if !tc.wantEmpty && len(resp) == 0 {
|
||||
t.Errorf("dnsResponse() returned empty response when expected non-empty")
|
||||
}
|
||||
|
||||
if len(resp) > 0 {
|
||||
var msg dnsmessage.Message
|
||||
err = msg.Unpack(resp)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to unpack response: %v", err)
|
||||
}
|
||||
|
||||
if !msg.Header.Response {
|
||||
t.Errorf("Response header is not set")
|
||||
}
|
||||
|
||||
if msg.Header.ID != req.Header.ID {
|
||||
t.Errorf("Response ID = %d, want %d", msg.Header.ID, req.Header.ID)
|
||||
}
|
||||
|
||||
if len(tc.wantAnswers) > 0 {
|
||||
if len(msg.Answers) != len(tc.wantAnswers) {
|
||||
t.Errorf("got %d answers, want %d", len(msg.Answers), len(tc.wantAnswers))
|
||||
} else {
|
||||
for i, want := range tc.wantAnswers {
|
||||
ans := msg.Answers[i]
|
||||
|
||||
gotName := ans.Header.Name.String()
|
||||
if gotName != want.name {
|
||||
t.Errorf("answer[%d] name = %s, want %s", i, gotName, want.name)
|
||||
}
|
||||
|
||||
if ans.Header.Type != want.qType {
|
||||
t.Errorf("answer[%d] type = %v, want %v", i, ans.Header.Type, want.qType)
|
||||
}
|
||||
|
||||
var gotIP netip.Addr
|
||||
switch want.qType {
|
||||
case dnsmessage.TypeA:
|
||||
if ans.Body.(*dnsmessage.AResource) == nil {
|
||||
t.Errorf("answer[%d] not an A record", i)
|
||||
continue
|
||||
}
|
||||
resource := ans.Body.(*dnsmessage.AResource)
|
||||
gotIP = netip.AddrFrom4([4]byte(resource.A))
|
||||
case dnsmessage.TypeAAAA:
|
||||
if ans.Body.(*dnsmessage.AAAAResource) == nil {
|
||||
t.Errorf("answer[%d] not an AAAA record", i)
|
||||
continue
|
||||
}
|
||||
resource := ans.Body.(*dnsmessage.AAAAResource)
|
||||
gotIP = netip.AddrFrom16([16]byte(resource.AAAA))
|
||||
}
|
||||
|
||||
if gotIP != want.addr {
|
||||
t.Errorf("answer[%d] IP = %s, want %s", i, gotIP, want.addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPerPeerState(t *testing.T) {
|
||||
c := &connector{
|
||||
v4Ranges: []netip.Prefix{netip.MustParsePrefix("100.64.1.0/24")},
|
||||
v6ULA: netip.MustParsePrefix("fd7a:115c:a1e0:a99c:0001::/80"),
|
||||
dnsAddr: netip.MustParseAddr("100.64.1.1"),
|
||||
}
|
||||
|
||||
ps := &perPeerState{c: c}
|
||||
|
||||
addrs, err := ps.ipForDomain("example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("ipForDomain() error = %v", err)
|
||||
}
|
||||
|
||||
if len(addrs) != 2 {
|
||||
t.Fatalf("ipForDomain() returned %d addresses, want 2", len(addrs))
|
||||
}
|
||||
|
||||
v4 := addrs[0]
|
||||
v6 := addrs[1]
|
||||
|
||||
if !v4.Is4() {
|
||||
t.Errorf("First address is not IPv4: %s", v4)
|
||||
}
|
||||
|
||||
if !v6.Is6() {
|
||||
t.Errorf("Second address is not IPv6: %s", v6)
|
||||
}
|
||||
|
||||
if !c.v4Ranges[0].Contains(v4) {
|
||||
t.Errorf("IPv4 address %s not in range %s", v4, c.v4Ranges[0])
|
||||
}
|
||||
|
||||
domain, ok := ps.domainForIP(v4)
|
||||
if !ok {
|
||||
t.Errorf("domainForIP(%s) not found", v4)
|
||||
} else if domain != "example.com" {
|
||||
t.Errorf("domainForIP(%s) = %s, want %s", v4, domain, "example.com")
|
||||
}
|
||||
|
||||
domain, ok = ps.domainForIP(v6)
|
||||
if !ok {
|
||||
t.Errorf("domainForIP(%s) not found", v6)
|
||||
} else if domain != "example.com" {
|
||||
t.Errorf("domainForIP(%s) = %s, want %s", v6, domain, "example.com")
|
||||
}
|
||||
|
||||
addrs2, err := ps.ipForDomain("example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("ipForDomain() second call error = %v", err)
|
||||
}
|
||||
|
||||
if !slices.Equal(addrs, addrs2) {
|
||||
t.Errorf("ipForDomain() second call = %v, want %v", addrs2, addrs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIgnoreDestination(t *testing.T) {
|
||||
ignoreDstTable := &bart.Table[bool]{}
|
||||
ignoreDstTable.Insert(netip.MustParsePrefix("192.168.1.0/24"), true)
|
||||
ignoreDstTable.Insert(netip.MustParsePrefix("10.0.0.0/8"), true)
|
||||
|
||||
c := &connector{
|
||||
ignoreDsts: ignoreDstTable,
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
addrs []netip.Addr
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "no_match",
|
||||
addrs: []netip.Addr{netip.MustParseAddr("8.8.8.8"), netip.MustParseAddr("1.1.1.1")},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "one_match",
|
||||
addrs: []netip.Addr{netip.MustParseAddr("8.8.8.8"), netip.MustParseAddr("192.168.1.5")},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "all_match",
|
||||
addrs: []netip.Addr{netip.MustParseAddr("10.0.0.1"), netip.MustParseAddr("192.168.1.5")},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "empty_addrs",
|
||||
addrs: []netip.Addr{},
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := c.ignoreDestination(tc.addrs)
|
||||
if got != tc.expected {
|
||||
t.Errorf("ignoreDestination(%v) = %v, want %v", tc.addrs, got, tc.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectorGenerateDNSResponse(t *testing.T) {
|
||||
c := &connector{
|
||||
v4Ranges: []netip.Prefix{netip.MustParsePrefix("100.64.1.0/24")},
|
||||
v6ULA: netip.MustParsePrefix("fd7a:115c:a1e0:a99c:0001::/80"),
|
||||
dnsAddr: netip.MustParseAddr("100.64.1.1"),
|
||||
}
|
||||
|
||||
req := &dnsmessage.Message{
|
||||
Header: dnsmessage.Header{ID: 1234},
|
||||
Questions: []dnsmessage.Question{
|
||||
{
|
||||
Name: dnsmessage.MustNewName("example.com."),
|
||||
Type: dnsmessage.TypeA,
|
||||
Class: dnsmessage.ClassINET,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
nodeID := tailcfg.NodeID(12345)
|
||||
|
||||
resp1, err := c.generateDNSResponse(req, nodeID)
|
||||
if err != nil {
|
||||
t.Fatalf("generateDNSResponse() error = %v", err)
|
||||
}
|
||||
if len(resp1) == 0 {
|
||||
t.Fatalf("generateDNSResponse() returned empty response")
|
||||
}
|
||||
|
||||
resp2, err := c.generateDNSResponse(req, nodeID)
|
||||
if err != nil {
|
||||
t.Fatalf("generateDNSResponse() second call error = %v", err)
|
||||
}
|
||||
|
||||
if !cmp.Equal(resp1, resp2) {
|
||||
t.Errorf("generateDNSResponse() responses differ between calls")
|
||||
}
|
||||
}
|
||||
@@ -96,6 +96,9 @@ func (a *Dialer) httpsFallbackDelay() time.Duration {
|
||||
var _ = envknob.RegisterBool("TS_USE_CONTROL_DIAL_PLAN") // to record at init time whether it's in use
|
||||
|
||||
func (a *Dialer) dial(ctx context.Context) (*ClientConn, error) {
|
||||
|
||||
a.logPort80Failure.Store(true)
|
||||
|
||||
// If we don't have a dial plan, just fall back to dialing the single
|
||||
// host we know about.
|
||||
useDialPlan := envknob.BoolDefaultTrue("TS_USE_CONTROL_DIAL_PLAN")
|
||||
@@ -278,7 +281,9 @@ func (d *Dialer) forceNoise443() bool {
|
||||
// This heuristic works around networks where port 80 is MITMed and
|
||||
// appears to work for a bit post-Upgrade but then gets closed,
|
||||
// such as seen in https://github.com/tailscale/tailscale/issues/13597.
|
||||
d.logf("controlhttp: forcing port 443 dial due to recent noise dial")
|
||||
if d.logPort80Failure.CompareAndSwap(true, false) {
|
||||
d.logf("controlhttp: forcing port 443 dial due to recent noise dial")
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ package controlhttp
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"tailscale.com/health"
|
||||
@@ -90,6 +91,11 @@ type Dialer struct {
|
||||
|
||||
proxyFunc func(*http.Request) (*url.URL, error) // or nil
|
||||
|
||||
// logPort80Failure is whether we should log about port 80 interceptions
|
||||
// and forcing a port 443 dial. We do this only once per "dial" method
|
||||
// which can result in many concurrent racing dialHost calls.
|
||||
logPort80Failure atomic.Bool
|
||||
|
||||
// For tests only
|
||||
drainFinished chan struct{}
|
||||
omitCertErrorLogging bool
|
||||
|
||||
@@ -958,7 +958,9 @@ func (b *LocalBackend) linkChange(delta *netmon.ChangeDelta) {
|
||||
|
||||
if peerAPIListenAsync && b.netMap != nil && b.state == ipn.Running {
|
||||
want := b.netMap.GetAddresses().Len()
|
||||
if len(b.peerAPIListeners) < want {
|
||||
have := len(b.peerAPIListeners)
|
||||
b.logf("[v1] linkChange: have %d peerAPIListeners, want %d", have, want)
|
||||
if have < want {
|
||||
b.logf("linkChange: peerAPIListeners too low; trying again")
|
||||
b.goTracker.Go(b.initPeerAPIListener)
|
||||
}
|
||||
@@ -2402,11 +2404,9 @@ func (b *LocalBackend) Start(opts ipn.Options) error {
|
||||
}
|
||||
|
||||
var auditLogShutdown func()
|
||||
// Audit logging is only available if the client has set up a proper persistent
|
||||
// store for the logs in sys.
|
||||
store, ok := b.sys.AuditLogStore.GetOK()
|
||||
if !ok {
|
||||
b.logf("auditlog: [unexpected] no persistent audit log storage configured. using memory store.")
|
||||
// Use memory store by default if no explicit store is provided.
|
||||
store = auditlog.NewLogStore(&memstore.Store{})
|
||||
}
|
||||
|
||||
@@ -3479,18 +3479,20 @@ func (b *LocalBackend) onTailnetDefaultAutoUpdate(au bool) {
|
||||
// can still manually enable auto-updates on this node.
|
||||
return
|
||||
}
|
||||
b.logf("using tailnet default auto-update setting: %v", au)
|
||||
prefsClone := prefs.AsStruct()
|
||||
prefsClone.AutoUpdate.Apply = opt.NewBool(au)
|
||||
_, err := b.editPrefsLockedOnEntry(&ipn.MaskedPrefs{
|
||||
Prefs: *prefsClone,
|
||||
AutoUpdateSet: ipn.AutoUpdatePrefsMask{
|
||||
ApplySet: true,
|
||||
},
|
||||
}, unlock)
|
||||
if err != nil {
|
||||
b.logf("failed to apply tailnet-wide default for auto-updates (%v): %v", au, err)
|
||||
return
|
||||
if clientupdate.CanAutoUpdate() {
|
||||
b.logf("using tailnet default auto-update setting: %v", au)
|
||||
prefsClone := prefs.AsStruct()
|
||||
prefsClone.AutoUpdate.Apply = opt.NewBool(au)
|
||||
_, err := b.editPrefsLockedOnEntry(&ipn.MaskedPrefs{
|
||||
Prefs: *prefsClone,
|
||||
AutoUpdateSet: ipn.AutoUpdatePrefsMask{
|
||||
ApplySet: true,
|
||||
},
|
||||
}, unlock)
|
||||
if err != nil {
|
||||
b.logf("failed to apply tailnet-wide default for auto-updates (%v): %v", au, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4966,7 +4968,7 @@ func (b *LocalBackend) authReconfig() {
|
||||
return
|
||||
}
|
||||
|
||||
oneCGNATRoute := shouldUseOneCGNATRoute(b.logf, b.sys.ControlKnobs(), version.OS())
|
||||
oneCGNATRoute := shouldUseOneCGNATRoute(b.logf, b.sys.NetMon.Get(), b.sys.ControlKnobs(), version.OS())
|
||||
rcfg := b.routerConfig(cfg, prefs, oneCGNATRoute)
|
||||
|
||||
err = b.e.Reconfig(cfg, rcfg, dcfg)
|
||||
@@ -4990,7 +4992,7 @@ func (b *LocalBackend) authReconfig() {
|
||||
//
|
||||
// The versionOS is a Tailscale-style version ("iOS", "macOS") and not
|
||||
// a runtime.GOOS.
|
||||
func shouldUseOneCGNATRoute(logf logger.Logf, controlKnobs *controlknobs.Knobs, versionOS string) bool {
|
||||
func shouldUseOneCGNATRoute(logf logger.Logf, mon *netmon.Monitor, controlKnobs *controlknobs.Knobs, versionOS string) bool {
|
||||
if controlKnobs != nil {
|
||||
// Explicit enabling or disabling always take precedence.
|
||||
if v, ok := controlKnobs.OneCGNAT.Load().Get(); ok {
|
||||
@@ -5005,7 +5007,7 @@ func shouldUseOneCGNATRoute(logf logger.Logf, controlKnobs *controlknobs.Knobs,
|
||||
// use fine-grained routes if another interfaces is also using the CGNAT
|
||||
// IP range.
|
||||
if versionOS == "macOS" {
|
||||
hasCGNATInterface, err := netmon.HasCGNATInterface()
|
||||
hasCGNATInterface, err := mon.HasCGNATInterface()
|
||||
if err != nil {
|
||||
logf("shouldUseOneCGNATRoute: Could not determine if any interfaces use CGNAT: %v", err)
|
||||
return false
|
||||
@@ -5367,6 +5369,7 @@ func (b *LocalBackend) initPeerAPIListener() {
|
||||
ln, err = ps.listen(a.Addr(), b.prevIfState)
|
||||
if err != nil {
|
||||
if peerAPIListenAsync {
|
||||
b.logf("possibly transient peerapi listen(%q) error, will try again on linkChange: %v", a.Addr(), err)
|
||||
// Expected. But we fix it later in linkChange
|
||||
// ("peerAPIListeners too low").
|
||||
continue
|
||||
|
||||
@@ -481,7 +481,7 @@ func (h *peerAPIHandler) handleServeInterfaces(w http.ResponseWriter, r *http.Re
|
||||
fmt.Fprintf(w, "<h3>Could not get the default route: %s</h3>\n", html.EscapeString(err.Error()))
|
||||
}
|
||||
|
||||
if hasCGNATInterface, err := netmon.HasCGNATInterface(); hasCGNATInterface {
|
||||
if hasCGNATInterface, err := h.ps.b.sys.NetMon.Get().HasCGNATInterface(); hasCGNATInterface {
|
||||
fmt.Fprintln(w, "<p>There is another interface using the CGNAT range.</p>")
|
||||
} else if err != nil {
|
||||
fmt.Fprintf(w, "<p>Could not check for CGNAT interfaces: %s</p>\n", html.EscapeString(err.Error()))
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"net/netip"
|
||||
"sort"
|
||||
|
||||
"tailscale.com/control/controlknobs"
|
||||
"tailscale.com/envknob"
|
||||
"tailscale.com/net/dns/publicdns"
|
||||
"tailscale.com/net/dns/resolver"
|
||||
"tailscale.com/net/tsaddr"
|
||||
@@ -47,11 +49,28 @@ type Config struct {
|
||||
OnlyIPv6 bool
|
||||
}
|
||||
|
||||
func (c *Config) serviceIP() netip.Addr {
|
||||
var magicDNSDualStack = envknob.RegisterBool("TS_DEBUG_MAGIC_DNS_DUAL_STACK")
|
||||
|
||||
// serviceIPs returns the list of service IPs where MagicDNS is reachable.
|
||||
//
|
||||
// The provided knobs may be nil.
|
||||
func (c *Config) serviceIPs(knobs *controlknobs.Knobs) []netip.Addr {
|
||||
if c.OnlyIPv6 {
|
||||
return tsaddr.TailscaleServiceIPv6()
|
||||
return []netip.Addr{tsaddr.TailscaleServiceIPv6()}
|
||||
}
|
||||
return tsaddr.TailscaleServiceIP()
|
||||
|
||||
// TODO(bradfitz,mikeodr,raggi): include IPv6 here too; tailscale/tailscale#15404
|
||||
// And add a controlknobs knob to disable dual stack.
|
||||
//
|
||||
// For now, opt-in for testing.
|
||||
if magicDNSDualStack() {
|
||||
return []netip.Addr{
|
||||
tsaddr.TailscaleServiceIP(),
|
||||
tsaddr.TailscaleServiceIPv6(),
|
||||
}
|
||||
}
|
||||
|
||||
return []netip.Addr{tsaddr.TailscaleServiceIP()}
|
||||
}
|
||||
|
||||
// WriteToBufioWriter write a debug version of c for logs to w, omitting
|
||||
|
||||
@@ -307,7 +307,7 @@ func (m *Manager) compileConfig(cfg Config) (rcfg resolver.Config, ocfg OSConfig
|
||||
// through quad-100.
|
||||
rcfg.Routes = routes
|
||||
rcfg.Routes["."] = cfg.DefaultResolvers
|
||||
ocfg.Nameservers = []netip.Addr{cfg.serviceIP()}
|
||||
ocfg.Nameservers = cfg.serviceIPs(m.knobs)
|
||||
return rcfg, ocfg, nil
|
||||
}
|
||||
|
||||
@@ -345,7 +345,7 @@ func (m *Manager) compileConfig(cfg Config) (rcfg resolver.Config, ocfg OSConfig
|
||||
// or routes + MagicDNS, or just MagicDNS, or on an OS that cannot
|
||||
// split-DNS. Install a split config pointing at quad-100.
|
||||
rcfg.Routes = routes
|
||||
ocfg.Nameservers = []netip.Addr{cfg.serviceIP()}
|
||||
ocfg.Nameservers = cfg.serviceIPs(m.knobs)
|
||||
|
||||
var baseCfg *OSConfig // base config; non-nil if/when known
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
)
|
||||
|
||||
func TestGetState(t *testing.T) {
|
||||
st, err := GetState()
|
||||
st, err := getState("")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -161,7 +161,7 @@ func (m *Monitor) InterfaceState() *State {
|
||||
}
|
||||
|
||||
func (m *Monitor) interfaceStateUncached() (*State, error) {
|
||||
return GetState()
|
||||
return getState(m.tsIfName)
|
||||
}
|
||||
|
||||
// SetTailscaleInterfaceName sets the name of the Tailscale interface. For
|
||||
|
||||
@@ -461,21 +461,22 @@ func isTailscaleInterface(name string, ips []netip.Prefix) bool {
|
||||
// getPAC, if non-nil, returns the current PAC file URL.
|
||||
var getPAC func() string
|
||||
|
||||
// GetState returns the state of all the current machine's network interfaces.
|
||||
// getState returns the state of all the current machine's network interfaces.
|
||||
//
|
||||
// It does not set the returned State.IsExpensive. The caller can populate that.
|
||||
//
|
||||
// Deprecated: use netmon.Monitor.InterfaceState instead.
|
||||
func GetState() (*State, error) {
|
||||
// optTSInterfaceName is the name of the Tailscale interface, if known.
|
||||
func getState(optTSInterfaceName string) (*State, error) {
|
||||
s := &State{
|
||||
InterfaceIPs: make(map[string][]netip.Prefix),
|
||||
Interface: make(map[string]Interface),
|
||||
}
|
||||
if err := ForeachInterface(func(ni Interface, pfxs []netip.Prefix) {
|
||||
isTSInterfaceName := optTSInterfaceName != "" && ni.Name == optTSInterfaceName
|
||||
ifUp := ni.IsUp()
|
||||
s.Interface[ni.Name] = ni
|
||||
s.InterfaceIPs[ni.Name] = append(s.InterfaceIPs[ni.Name], pfxs...)
|
||||
if !ifUp || isTailscaleInterface(ni.Name, pfxs) {
|
||||
if !ifUp || isTSInterfaceName || isTailscaleInterface(ni.Name, pfxs) {
|
||||
return
|
||||
}
|
||||
for _, pfx := range pfxs {
|
||||
@@ -755,11 +756,12 @@ func DefaultRoute() (DefaultRouteDetails, error) {
|
||||
|
||||
// HasCGNATInterface reports whether there are any non-Tailscale interfaces that
|
||||
// use a CGNAT IP range.
|
||||
func HasCGNATInterface() (bool, error) {
|
||||
func (m *Monitor) HasCGNATInterface() (bool, error) {
|
||||
hasCGNATInterface := false
|
||||
cgnatRange := tsaddr.CGNATRange()
|
||||
err := ForeachInterface(func(i Interface, pfxs []netip.Prefix) {
|
||||
if hasCGNATInterface || !i.IsUp() || isTailscaleInterface(i.Name, pfxs) {
|
||||
isTSInterfaceName := m.tsIfName != "" && i.Name == m.tsIfName
|
||||
if hasCGNATInterface || !i.IsUp() || isTSInterfaceName || isTailscaleInterface(i.Name, pfxs) {
|
||||
return
|
||||
}
|
||||
for _, pfx := range pfxs {
|
||||
|
||||
@@ -596,11 +596,23 @@ func (d *derpProber) updateMap(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (d *derpProber) ProbeUDP(ipaddr string, port int) ProbeClass {
|
||||
initLabels := make(Labels)
|
||||
ip := net.ParseIP(ipaddr)
|
||||
|
||||
if ip.To4() != nil {
|
||||
initLabels["address_family"] = "ipv4"
|
||||
} else if ip.To16() != nil { // Will return an IPv4 as 16 byte, so ensure the check for IPv4 precedes this
|
||||
initLabels["address_family"] = "ipv6"
|
||||
} else {
|
||||
initLabels["address_family"] = "unknown"
|
||||
}
|
||||
|
||||
return ProbeClass{
|
||||
Probe: func(ctx context.Context) error {
|
||||
return derpProbeUDP(ctx, ipaddr, port)
|
||||
},
|
||||
Class: "derp_udp",
|
||||
Class: "derp_udp",
|
||||
Labels: initLabels,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -404,10 +404,14 @@ func (p *Probe) recordEndLocked(err error) {
|
||||
p.mSeconds.WithLabelValues("ok").Add(latency.Seconds())
|
||||
p.latencyHist.Value = latency
|
||||
p.latencyHist = p.latencyHist.Next()
|
||||
p.mAttempts.WithLabelValues("fail").Add(0)
|
||||
p.mSeconds.WithLabelValues("fail").Add(0)
|
||||
} else {
|
||||
p.latency = 0
|
||||
p.mAttempts.WithLabelValues("fail").Inc()
|
||||
p.mSeconds.WithLabelValues("fail").Add(latency.Seconds())
|
||||
p.mAttempts.WithLabelValues("ok").Add(0)
|
||||
p.mSeconds.WithLabelValues("ok").Add(0)
|
||||
}
|
||||
p.successHist.Value = p.succeeded
|
||||
p.successHist = p.successHist.Next()
|
||||
|
||||
@@ -839,15 +839,17 @@ func (s *Server) serveMap(w http.ResponseWriter, r *http.Request, mkey key.Machi
|
||||
|
||||
w.WriteHeader(200)
|
||||
for {
|
||||
if resBytes, ok := s.takeRawMapMessage(req.NodeKey); ok {
|
||||
if err := s.sendMapMsg(w, compress, resBytes); err != nil {
|
||||
s.logf("sendMapMsg of raw message: %v", err)
|
||||
return
|
||||
}
|
||||
if streaming {
|
||||
// Only send raw map responses to the streaming poll, to avoid a
|
||||
// non-streaming map request beating the streaming poll in a race and
|
||||
// potentially dropping the map response.
|
||||
if streaming {
|
||||
if resBytes, ok := s.takeRawMapMessage(req.NodeKey); ok {
|
||||
if err := s.sendMapMsg(w, compress, resBytes); err != nil {
|
||||
s.logf("sendMapMsg of raw message: %v", err)
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if s.canGenerateAutomaticMapResponseFor(req.NodeKey) {
|
||||
|
||||
599
tstest/mts/mts.go
Normal file
599
tstest/mts/mts.go
Normal file
@@ -0,0 +1,599 @@
|
||||
// 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"
|
||||
"encoding/json"
|
||||
"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")
|
||||
}
|
||||
Reference in New Issue
Block a user