Compare commits
121 Commits
bradfitz/s
...
dgentry-co
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a9a470a80 | ||
|
|
f398712c00 | ||
|
|
d9081d6ba2 | ||
|
|
5347e6a292 | ||
|
|
68da15516f | ||
|
|
70f9c8a6ed | ||
|
|
eced054796 | ||
|
|
1df2d14c8f | ||
|
|
6ada33db77 | ||
|
|
25b6974219 | ||
|
|
b4247fabec | ||
|
|
7e933a8816 | ||
|
|
02908a2d8d | ||
|
|
469b7cabad | ||
|
|
7a3ae39025 | ||
|
|
35376d52d4 | ||
|
|
f09cb45f9d | ||
|
|
73bbf941f8 | ||
|
|
09b5bb3e55 | ||
|
|
891d964bd4 | ||
|
|
d603d18956 | ||
|
|
cf27761265 | ||
|
|
cb00eac850 | ||
|
|
674beabc73 | ||
|
|
afb72ecd73 | ||
|
|
851536044a | ||
|
|
c3a8e63100 | ||
|
|
b47cf04624 | ||
|
|
a8fbe284b2 | ||
|
|
756a4c43b6 | ||
|
|
3f27087e9d | ||
|
|
7971333603 | ||
|
|
77127a2494 | ||
|
|
c27870e160 | ||
|
|
c2a551469c | ||
|
|
33bb2bbfe9 | ||
|
|
cac290da87 | ||
|
|
ddb2a6eb8d | ||
|
|
f53c3be07c | ||
|
|
1fc3573446 | ||
|
|
6ca8650c7b | ||
|
|
4dec0c6eb9 | ||
|
|
e6ab7d3c14 | ||
|
|
9d3c6bf52e | ||
|
|
4899c2c1f4 | ||
|
|
b949e208bb | ||
|
|
18bd98d35b | ||
|
|
71271e41d6 | ||
|
|
95faefd1f6 | ||
|
|
8a5b02133d | ||
|
|
51078b6486 | ||
|
|
7fd6cc3caa | ||
|
|
feabb34ea0 | ||
|
|
e06f2f1873 | ||
|
|
97ee3891f1 | ||
|
|
56ebcd1ed4 | ||
|
|
e89927de2b | ||
|
|
18e2936d25 | ||
|
|
c363b9055d | ||
|
|
a6270826a3 | ||
|
|
5297bd2cff | ||
|
|
5c555cdcbb | ||
|
|
8c7169105e | ||
|
|
9cb6c5bb78 | ||
|
|
af5a586463 | ||
|
|
754fb9a8a8 | ||
|
|
8f948638c5 | ||
|
|
b1867eb23f | ||
|
|
24f322bc43 | ||
|
|
1a78f240b5 | ||
|
|
7783a960e8 | ||
|
|
ce0830837d | ||
|
|
37c646d9d3 | ||
|
|
1294b89792 | ||
|
|
2d4f808a4c | ||
|
|
4abd470322 | ||
|
|
96f01a73b1 | ||
|
|
d62af8e643 | ||
|
|
1cb9e33a95 | ||
|
|
c1ef55249a | ||
|
|
319607625f | ||
|
|
9d96e05267 | ||
|
|
8b630c91bc | ||
|
|
0a412eba40 | ||
|
|
11348fbe72 | ||
|
|
fbfee6a8c0 | ||
|
|
7a0de2997e | ||
|
|
aad3584319 | ||
|
|
fffafc65d6 | ||
|
|
9f05018419 | ||
|
|
04a8b8bb8e | ||
|
|
4e083e4548 | ||
|
|
78a083e144 | ||
|
|
05a1f5bf71 | ||
|
|
56c0a75ea9 | ||
|
|
ba6ec42f6d | ||
|
|
677d486830 | ||
|
|
7f08bddfe1 | ||
|
|
00977f6de9 | ||
|
|
0ccfcb515c | ||
|
|
3749a3bbbb | ||
|
|
6b1ed732df | ||
|
|
70de16bda7 | ||
|
|
7f540042d5 | ||
|
|
d0b8bdf8f7 | ||
|
|
9eedf86563 | ||
|
|
249edaa349 | ||
|
|
893bdd729c | ||
|
|
b4e587c3bd | ||
|
|
9593cd3871 | ||
|
|
623926a25d | ||
|
|
886917c42b | ||
|
|
553f657248 | ||
|
|
6f36f8842c | ||
|
|
13767e5108 | ||
|
|
f991c8a61f | ||
|
|
498f7ec663 | ||
|
|
e4cb83b18b | ||
|
|
e6aa7b815d | ||
|
|
b7988b3825 | ||
|
|
557ddced6c |
30
.github/workflows/coverage.yml
vendored
Normal file
30
.github/workflows/coverage.yml
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
name: Code Coverage
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
if: "!contains(github.event.head_commit.message, '[ci skip]')"
|
||||
|
||||
steps:
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: build all
|
||||
run: ./tool/go install ./cmd/...
|
||||
|
||||
- name: Run tests on linux with coverage data
|
||||
run: ./tool/go test -race -covermode atomic -coverprofile=covprofile ./...
|
||||
4
.github/workflows/golangci-lint.yml
vendored
4
.github/workflows/golangci-lint.yml
vendored
@@ -31,10 +31,10 @@ jobs:
|
||||
cache: false
|
||||
|
||||
- name: golangci-lint
|
||||
# Note: this is the 'v3' tag as of 2023-04-17
|
||||
# Note: this is the 'v3' tag as of 2023-08-14
|
||||
uses: golangci/golangci-lint-action@639cd343e1d3b897ff35927a75193d57cfcba299
|
||||
with:
|
||||
version: v1.52.2
|
||||
version: v1.54.2
|
||||
|
||||
# Show only new issues if it's a pull request.
|
||||
only-new-issues: true
|
||||
|
||||
41
.github/workflows/test.yml
vendored
41
.github/workflows/test.yml
vendored
@@ -39,6 +39,26 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
race-root-integration:
|
||||
runs-on: ubuntu-22.04
|
||||
strategy:
|
||||
fail-fast: false # don't abort the entire matrix if one element fails
|
||||
matrix:
|
||||
include:
|
||||
- shard: '1/4'
|
||||
- shard: '2/4'
|
||||
- shard: '3/4'
|
||||
- shard: '4/4'
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: build test wrapper
|
||||
run: ./tool/go build -o /tmp/testwrapper ./cmd/testwrapper
|
||||
- name: integration tests as root
|
||||
run: PATH=$PWD/tool:$PATH /tmp/testwrapper -exec "sudo -E" -race ./tstest/integration/
|
||||
env:
|
||||
TS_TEST_SHARD: ${{ matrix.shard }}
|
||||
|
||||
test:
|
||||
strategy:
|
||||
fail-fast: false # don't abort the entire matrix if one element fails
|
||||
@@ -47,6 +67,13 @@ jobs:
|
||||
- goarch: amd64
|
||||
- goarch: amd64
|
||||
buildflags: "-race"
|
||||
shard: '1/3'
|
||||
- goarch: amd64
|
||||
buildflags: "-race"
|
||||
shard: '2/3'
|
||||
- goarch: amd64
|
||||
buildflags: "-race"
|
||||
shard: '3/3'
|
||||
- goarch: "386" # thanks yaml
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
@@ -70,6 +97,7 @@ jobs:
|
||||
${{ github.job }}-${{ runner.os }}-${{ matrix.goarch }}-${{ matrix.buildflags }}-go-2-${{ hashFiles('**/go.sum') }}
|
||||
${{ github.job }}-${{ runner.os }}-${{ matrix.goarch }}-${{ matrix.buildflags }}-go-2-
|
||||
- name: build all
|
||||
if: matrix.buildflags == '' # skip on race builder
|
||||
run: ./tool/go build ${{matrix.buildflags}} ./...
|
||||
env:
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
@@ -94,6 +122,7 @@ jobs:
|
||||
run: PATH=$PWD/tool:$PATH /tmp/testwrapper ./... ${{matrix.buildflags}}
|
||||
env:
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
TS_TEST_SHARD: ${{ matrix.shard }}
|
||||
- name: bench all
|
||||
run: ./tool/go test ${{matrix.buildflags}} -bench=. -benchtime=1x -run=^$ $(for x in $(git grep -l "^func Benchmark" | xargs dirname | sort | uniq); do echo "./$x"; done)
|
||||
env:
|
||||
@@ -162,7 +191,17 @@ jobs:
|
||||
HOME: "/tmp"
|
||||
TMPDIR: "/tmp"
|
||||
XDB_CACHE_HOME: "/var/lib/ghrunner/cache"
|
||||
|
||||
|
||||
race-build:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: build all
|
||||
run: ./tool/go install -race ./cmd/...
|
||||
- name: build tests
|
||||
run: ./tool/go test -race -exec=true ./...
|
||||
|
||||
cross: # cross-compile checks, build only.
|
||||
strategy:
|
||||
fail-fast: false # don't abort the entire matrix if one element fails
|
||||
|
||||
4
api.md
4
api.md
@@ -209,10 +209,6 @@ You can also [list all devices in the tailnet](#list-tailnet-devices) to get the
|
||||
"192.68.0.21:59128"
|
||||
],
|
||||
|
||||
// derp (string) is the IP:port of the DERP server currently being used.
|
||||
// Learn about DERP servers at https://tailscale.com/kb/1232/.
|
||||
"derp":"",
|
||||
|
||||
// mappingVariesByDestIP (boolean) is 'true' if the host's NAT mappings
|
||||
// vary based on the destination IP.
|
||||
"mappingVariesByDestIP":false,
|
||||
|
||||
328
appc/appc.go
Normal file
328
appc/appc.go
Normal file
@@ -0,0 +1,328 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package appc implements App Connectors.
|
||||
package appc
|
||||
|
||||
import (
|
||||
"expvar"
|
||||
"log"
|
||||
"net"
|
||||
"net/netip"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/dns/dnsmessage"
|
||||
"tailscale.com/metrics"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/appctype"
|
||||
"tailscale.com/types/ipproto"
|
||||
"tailscale.com/types/nettype"
|
||||
"tailscale.com/util/clientmetric"
|
||||
"tailscale.com/util/mak"
|
||||
)
|
||||
|
||||
var tsMBox = dnsmessage.MustNewName("support.tailscale.com.")
|
||||
|
||||
// target describes the predicates which route some inbound
|
||||
// traffic to the app connector to a specific handler.
|
||||
type target struct {
|
||||
Dest netip.Prefix
|
||||
Matching tailcfg.ProtoPortRange
|
||||
}
|
||||
|
||||
// Server implements an App Connector.
|
||||
type Server struct {
|
||||
mu sync.RWMutex // mu guards following fields
|
||||
connectors map[appctype.ConfigID]connector
|
||||
}
|
||||
|
||||
type appcMetrics struct {
|
||||
dnsResponses expvar.Int
|
||||
dnsFailures expvar.Int
|
||||
tcpConns expvar.Int
|
||||
sniConns expvar.Int
|
||||
unhandledConns expvar.Int
|
||||
}
|
||||
|
||||
var getMetrics = sync.OnceValue[*appcMetrics](func() *appcMetrics {
|
||||
m := appcMetrics{}
|
||||
|
||||
stats := new(metrics.Set)
|
||||
stats.Set("tls_sessions", &m.sniConns)
|
||||
clientmetric.NewCounterFunc("sniproxy_tls_sessions", m.sniConns.Value)
|
||||
stats.Set("tcp_sessions", &m.tcpConns)
|
||||
clientmetric.NewCounterFunc("sniproxy_tcp_sessions", m.tcpConns.Value)
|
||||
stats.Set("dns_responses", &m.dnsResponses)
|
||||
clientmetric.NewCounterFunc("sniproxy_dns_responses", m.dnsResponses.Value)
|
||||
stats.Set("dns_failed", &m.dnsFailures)
|
||||
clientmetric.NewCounterFunc("sniproxy_dns_failed", m.dnsFailures.Value)
|
||||
expvar.Publish("sniproxy", stats)
|
||||
|
||||
return &m
|
||||
})
|
||||
|
||||
// Configure applies the provided configuration to the app connector.
|
||||
func (s *Server) Configure(cfg *appctype.AppConnectorConfig) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.connectors = makeConnectorsFromConfig(cfg)
|
||||
}
|
||||
|
||||
// HandleTCPFlow implements tsnet.FallbackTCPHandler.
|
||||
func (s *Server) HandleTCPFlow(src, dst netip.AddrPort) (handler func(net.Conn), intercept bool) {
|
||||
m := getMetrics()
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
for _, c := range s.connectors {
|
||||
if handler, intercept := c.handleTCPFlow(src, dst, m); intercept {
|
||||
return handler, intercept
|
||||
}
|
||||
}
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// HandleDNS handles a DNS request to the app connector.
|
||||
func (s *Server) HandleDNS(c nettype.ConnPacketConn) {
|
||||
defer c.Close()
|
||||
c.SetReadDeadline(time.Now().Add(5 * time.Second))
|
||||
m := getMetrics()
|
||||
|
||||
buf := make([]byte, 1500)
|
||||
n, err := c.Read(buf)
|
||||
if err != nil {
|
||||
log.Printf("HandleDNS: read failed: %v\n ", err)
|
||||
m.dnsFailures.Add(1)
|
||||
return
|
||||
}
|
||||
|
||||
addrPortStr := c.LocalAddr().String()
|
||||
host, _, err := net.SplitHostPort(addrPortStr)
|
||||
if err != nil {
|
||||
log.Printf("HandleDNS: bogus addrPort %q", addrPortStr)
|
||||
m.dnsFailures.Add(1)
|
||||
return
|
||||
}
|
||||
localAddr, err := netip.ParseAddr(host)
|
||||
if err != nil {
|
||||
log.Printf("HandleDNS: bogus local address %q", host)
|
||||
m.dnsFailures.Add(1)
|
||||
return
|
||||
}
|
||||
|
||||
var msg dnsmessage.Message
|
||||
err = msg.Unpack(buf[:n])
|
||||
if err != nil {
|
||||
log.Printf("HandleDNS: dnsmessage unpack failed: %v\n ", err)
|
||||
m.dnsFailures.Add(1)
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for _, connector := range s.connectors {
|
||||
resp, err := connector.handleDNS(&msg, localAddr)
|
||||
if err != nil {
|
||||
log.Printf("HandleDNS: connector handling failed: %v\n", err)
|
||||
m.dnsFailures.Add(1)
|
||||
return
|
||||
}
|
||||
if len(resp) > 0 {
|
||||
// This connector handled the DNS request
|
||||
_, err = c.Write(resp)
|
||||
if err != nil {
|
||||
log.Printf("HandleDNS: write failed: %v\n", err)
|
||||
m.dnsFailures.Add(1)
|
||||
return
|
||||
}
|
||||
|
||||
m.dnsResponses.Add(1)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// connector describes a logical collection of
|
||||
// services which need to be proxied.
|
||||
type connector struct {
|
||||
Handlers map[target]handler
|
||||
}
|
||||
|
||||
// handleTCPFlow implements tsnet.FallbackTCPHandler.
|
||||
func (c *connector) handleTCPFlow(src, dst netip.AddrPort, m *appcMetrics) (handler func(net.Conn), intercept bool) {
|
||||
for t, h := range c.Handlers {
|
||||
if t.Matching.Proto != 0 && t.Matching.Proto != int(ipproto.TCP) {
|
||||
continue
|
||||
}
|
||||
if !t.Dest.Contains(dst.Addr()) {
|
||||
continue
|
||||
}
|
||||
if !t.Matching.Ports.Contains(dst.Port()) {
|
||||
continue
|
||||
}
|
||||
|
||||
switch h.(type) {
|
||||
case *tcpSNIHandler:
|
||||
m.sniConns.Add(1)
|
||||
case *tcpRoundRobinHandler:
|
||||
m.tcpConns.Add(1)
|
||||
default:
|
||||
log.Printf("handleTCPFlow: unhandled handler type %T", h)
|
||||
}
|
||||
|
||||
return h.Handle, true
|
||||
}
|
||||
|
||||
m.unhandledConns.Add(1)
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// handleDNS returns the DNS response to the given query. If this
|
||||
// connector is unable to handle the request, nil is returned.
|
||||
func (c *connector) handleDNS(req *dnsmessage.Message, localAddr netip.Addr) (response []byte, err error) {
|
||||
for t, h := range c.Handlers {
|
||||
if t.Dest.Contains(localAddr) {
|
||||
return makeDNSResponse(req, h.ReachableOn())
|
||||
}
|
||||
}
|
||||
|
||||
// Did not match, signal 'not handled' to caller
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func makeDNSResponse(req *dnsmessage.Message, reachableIPs []netip.Addr) (response []byte, err error) {
|
||||
buf := make([]byte, 1500)
|
||||
resp := dnsmessage.NewBuilder(buf,
|
||||
dnsmessage.Header{
|
||||
ID: req.Header.ID,
|
||||
Response: true,
|
||||
Authoritative: true,
|
||||
})
|
||||
resp.EnableCompression()
|
||||
|
||||
if len(req.Questions) == 0 {
|
||||
buf, _ = resp.Finish()
|
||||
return buf, nil
|
||||
}
|
||||
q := req.Questions[0]
|
||||
err = resp.StartQuestions()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
resp.Question(q)
|
||||
|
||||
err = resp.StartAnswers()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch q.Type {
|
||||
case dnsmessage.TypeAAAA:
|
||||
for _, ip := range reachableIPs {
|
||||
if ip.Is6() {
|
||||
err = resp.AAAAResource(
|
||||
dnsmessage.ResourceHeader{Name: q.Name, Class: q.Class, TTL: 120},
|
||||
dnsmessage.AAAAResource{AAAA: ip.As16()},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
case dnsmessage.TypeA:
|
||||
for _, ip := range reachableIPs {
|
||||
if ip.Is4() {
|
||||
err = resp.AResource(
|
||||
dnsmessage.ResourceHeader{Name: q.Name, Class: q.Class, TTL: 120},
|
||||
dnsmessage.AResource{A: ip.As4()},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
case dnsmessage.TypeSOA:
|
||||
err = resp.SOAResource(
|
||||
dnsmessage.ResourceHeader{Name: q.Name, Class: q.Class, TTL: 120},
|
||||
dnsmessage.SOAResource{NS: q.Name, MBox: tsMBox, Serial: 2023030600,
|
||||
Refresh: 120, Retry: 120, Expire: 120, MinTTL: 60},
|
||||
)
|
||||
case dnsmessage.TypeNS:
|
||||
err = resp.NSResource(
|
||||
dnsmessage.ResourceHeader{Name: q.Name, Class: q.Class, TTL: 120},
|
||||
dnsmessage.NSResource{NS: tsMBox},
|
||||
)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp.Finish()
|
||||
}
|
||||
|
||||
type handler interface {
|
||||
// Handle handles the given socket.
|
||||
Handle(c net.Conn)
|
||||
|
||||
// ReachableOn returns the IP addresses this handler is reachable on.
|
||||
ReachableOn() []netip.Addr
|
||||
}
|
||||
|
||||
func installDNATHandler(d *appctype.DNATConfig, out *connector) {
|
||||
// These handlers don't actually do DNAT, they just
|
||||
// proxy the data over the connection.
|
||||
var dialer net.Dialer
|
||||
dialer.Timeout = 5 * time.Second
|
||||
h := tcpRoundRobinHandler{
|
||||
To: d.To,
|
||||
DialContext: dialer.DialContext,
|
||||
ReachableIPs: d.Addrs,
|
||||
}
|
||||
|
||||
for _, addr := range d.Addrs {
|
||||
for _, protoPort := range d.IP {
|
||||
t := target{
|
||||
Dest: netip.PrefixFrom(addr, addr.BitLen()),
|
||||
Matching: protoPort,
|
||||
}
|
||||
|
||||
mak.Set(&out.Handlers, t, handler(&h))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func installSNIHandler(c *appctype.SNIProxyConfig, out *connector) {
|
||||
var dialer net.Dialer
|
||||
dialer.Timeout = 5 * time.Second
|
||||
h := tcpSNIHandler{
|
||||
Allowlist: c.AllowedDomains,
|
||||
DialContext: dialer.DialContext,
|
||||
ReachableIPs: c.Addrs,
|
||||
}
|
||||
|
||||
for _, addr := range c.Addrs {
|
||||
for _, protoPort := range c.IP {
|
||||
t := target{
|
||||
Dest: netip.PrefixFrom(addr, addr.BitLen()),
|
||||
Matching: protoPort,
|
||||
}
|
||||
|
||||
mak.Set(&out.Handlers, t, handler(&h))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func makeConnectorsFromConfig(cfg *appctype.AppConnectorConfig) map[appctype.ConfigID]connector {
|
||||
var connectors map[appctype.ConfigID]connector
|
||||
|
||||
for cID, d := range cfg.DNAT {
|
||||
c := connectors[cID]
|
||||
installDNATHandler(&d, &c)
|
||||
mak.Set(&connectors, cID, c)
|
||||
}
|
||||
for cID, d := range cfg.SNIProxy {
|
||||
c := connectors[cID]
|
||||
installSNIHandler(&d, &c)
|
||||
mak.Set(&connectors, cID, c)
|
||||
}
|
||||
|
||||
return connectors
|
||||
}
|
||||
95
appc/appc_test.go
Normal file
95
appc/appc_test.go
Normal file
@@ -0,0 +1,95 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package appc
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/google/go-cmp/cmp/cmpopts"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/appctype"
|
||||
)
|
||||
|
||||
func TestMakeConnectorsFromConfig(t *testing.T) {
|
||||
tcs := []struct {
|
||||
name string
|
||||
input *appctype.AppConnectorConfig
|
||||
want map[appctype.ConfigID]connector
|
||||
}{
|
||||
{
|
||||
"empty",
|
||||
&appctype.AppConnectorConfig{},
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"DNAT",
|
||||
&appctype.AppConnectorConfig{
|
||||
DNAT: map[appctype.ConfigID]appctype.DNATConfig{
|
||||
"swiggity_swooty": {
|
||||
Addrs: []netip.Addr{netip.MustParseAddr("100.64.0.1"), netip.MustParseAddr("fd7a:115c:a1e0::1")},
|
||||
To: []string{"example.org"},
|
||||
IP: []tailcfg.ProtoPortRange{{Proto: 0, Ports: tailcfg.PortRange{First: 0, Last: 65535}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
map[appctype.ConfigID]connector{
|
||||
"swiggity_swooty": {
|
||||
Handlers: map[target]handler{
|
||||
{
|
||||
Dest: netip.MustParsePrefix("100.64.0.1/32"),
|
||||
Matching: tailcfg.ProtoPortRange{Proto: 0, Ports: tailcfg.PortRange{First: 0, Last: 65535}},
|
||||
}: &tcpRoundRobinHandler{To: []string{"example.org"}, ReachableIPs: []netip.Addr{netip.MustParseAddr("100.64.0.1"), netip.MustParseAddr("fd7a:115c:a1e0::1")}},
|
||||
{
|
||||
Dest: netip.MustParsePrefix("fd7a:115c:a1e0::1/128"),
|
||||
Matching: tailcfg.ProtoPortRange{Proto: 0, Ports: tailcfg.PortRange{First: 0, Last: 65535}},
|
||||
}: &tcpRoundRobinHandler{To: []string{"example.org"}, ReachableIPs: []netip.Addr{netip.MustParseAddr("100.64.0.1"), netip.MustParseAddr("fd7a:115c:a1e0::1")}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"SNIProxy",
|
||||
&appctype.AppConnectorConfig{
|
||||
SNIProxy: map[appctype.ConfigID]appctype.SNIProxyConfig{
|
||||
"swiggity_swooty": {
|
||||
Addrs: []netip.Addr{netip.MustParseAddr("100.64.0.1"), netip.MustParseAddr("fd7a:115c:a1e0::1")},
|
||||
AllowedDomains: []string{"example.org"},
|
||||
IP: []tailcfg.ProtoPortRange{{Proto: 0, Ports: tailcfg.PortRange{First: 0, Last: 65535}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
map[appctype.ConfigID]connector{
|
||||
"swiggity_swooty": {
|
||||
Handlers: map[target]handler{
|
||||
{
|
||||
Dest: netip.MustParsePrefix("100.64.0.1/32"),
|
||||
Matching: tailcfg.ProtoPortRange{Proto: 0, Ports: tailcfg.PortRange{First: 0, Last: 65535}},
|
||||
}: &tcpSNIHandler{Allowlist: []string{"example.org"}, ReachableIPs: []netip.Addr{netip.MustParseAddr("100.64.0.1"), netip.MustParseAddr("fd7a:115c:a1e0::1")}},
|
||||
{
|
||||
Dest: netip.MustParsePrefix("fd7a:115c:a1e0::1/128"),
|
||||
Matching: tailcfg.ProtoPortRange{Proto: 0, Ports: tailcfg.PortRange{First: 0, Last: 65535}},
|
||||
}: &tcpSNIHandler{Allowlist: []string{"example.org"}, ReachableIPs: []netip.Addr{netip.MustParseAddr("100.64.0.1"), netip.MustParseAddr("fd7a:115c:a1e0::1")}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tcs {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
connectors := makeConnectorsFromConfig(tc.input)
|
||||
|
||||
if diff := cmp.Diff(connectors, tc.want,
|
||||
cmpopts.IgnoreFields(tcpRoundRobinHandler{}, "DialContext"),
|
||||
cmpopts.IgnoreFields(tcpSNIHandler{}, "DialContext"),
|
||||
cmp.Comparer(func(x, y netip.Addr) bool {
|
||||
return x == y
|
||||
})); diff != "" {
|
||||
t.Fatalf("mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
104
appc/handlers.go
Normal file
104
appc/handlers.go
Normal file
@@ -0,0 +1,104 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package appc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/netip"
|
||||
"slices"
|
||||
|
||||
"inet.af/tcpproxy"
|
||||
"tailscale.com/net/netutil"
|
||||
)
|
||||
|
||||
type tcpRoundRobinHandler struct {
|
||||
// To is a list of destination addresses to forward to.
|
||||
// An entry may be either an IP address or a DNS name.
|
||||
To []string
|
||||
|
||||
// DialContext is used to make the outgoing TCP connection.
|
||||
DialContext func(ctx context.Context, network, address string) (net.Conn, error)
|
||||
|
||||
// ReachableIPs enumerates the IP addresses this handler is reachable on.
|
||||
ReachableIPs []netip.Addr
|
||||
}
|
||||
|
||||
// ReachableOn returns the IP addresses this handler is reachable on.
|
||||
func (h *tcpRoundRobinHandler) ReachableOn() []netip.Addr {
|
||||
return h.ReachableIPs
|
||||
}
|
||||
|
||||
func (h *tcpRoundRobinHandler) Handle(c net.Conn) {
|
||||
addrPortStr := c.LocalAddr().String()
|
||||
_, port, err := net.SplitHostPort(addrPortStr)
|
||||
if err != nil {
|
||||
log.Printf("tcpRoundRobinHandler.Handle: bogus addrPort %q", addrPortStr)
|
||||
c.Close()
|
||||
return
|
||||
}
|
||||
|
||||
var p tcpproxy.Proxy
|
||||
p.ListenFunc = func(net, laddr string) (net.Listener, error) {
|
||||
return netutil.NewOneConnListener(c, nil), nil
|
||||
}
|
||||
|
||||
dest := h.To[rand.Intn(len(h.To))]
|
||||
dial := &tcpproxy.DialProxy{
|
||||
Addr: fmt.Sprintf("%s:%s", dest, port),
|
||||
DialContext: h.DialContext,
|
||||
}
|
||||
|
||||
p.AddRoute(addrPortStr, dial)
|
||||
p.Start()
|
||||
}
|
||||
|
||||
type tcpSNIHandler struct {
|
||||
// Allowlist enumerates the FQDNs which may be proxied via SNI. An
|
||||
// empty slice means all domains are permitted.
|
||||
Allowlist []string
|
||||
|
||||
// DialContext is used to make the outgoing TCP connection.
|
||||
DialContext func(ctx context.Context, network, address string) (net.Conn, error)
|
||||
|
||||
// ReachableIPs enumerates the IP addresses this handler is reachable on.
|
||||
ReachableIPs []netip.Addr
|
||||
}
|
||||
|
||||
// ReachableOn returns the IP addresses this handler is reachable on.
|
||||
func (h *tcpSNIHandler) ReachableOn() []netip.Addr {
|
||||
return h.ReachableIPs
|
||||
}
|
||||
|
||||
func (h *tcpSNIHandler) Handle(c net.Conn) {
|
||||
addrPortStr := c.LocalAddr().String()
|
||||
_, port, err := net.SplitHostPort(addrPortStr)
|
||||
if err != nil {
|
||||
log.Printf("tcpSNIHandler.Handle: bogus addrPort %q", addrPortStr)
|
||||
c.Close()
|
||||
return
|
||||
}
|
||||
|
||||
var p tcpproxy.Proxy
|
||||
p.ListenFunc = func(net, laddr string) (net.Listener, error) {
|
||||
return netutil.NewOneConnListener(c, nil), nil
|
||||
}
|
||||
p.AddSNIRouteFunc(addrPortStr, func(ctx context.Context, sniName string) (t tcpproxy.Target, ok bool) {
|
||||
if len(h.Allowlist) > 0 {
|
||||
// TODO(tom): handle subdomains
|
||||
if slices.Index(h.Allowlist, sniName) < 0 {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
return &tcpproxy.DialProxy{
|
||||
Addr: net.JoinHostPort(sniName, port),
|
||||
DialContext: h.DialContext,
|
||||
}, true
|
||||
})
|
||||
p.Start()
|
||||
}
|
||||
159
appc/handlers_test.go
Normal file
159
appc/handlers_test.go
Normal file
@@ -0,0 +1,159 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package appc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"net"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"tailscale.com/net/memnet"
|
||||
)
|
||||
|
||||
func echoConnOnce(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
|
||||
b := make([]byte, 256)
|
||||
n, err := conn.Read(b)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := conn.Write(b[:n]); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func TestTCPRoundRobinHandler(t *testing.T) {
|
||||
h := tcpRoundRobinHandler{
|
||||
To: []string{"yeet.com"},
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
if network != "tcp" {
|
||||
t.Errorf("network = %s, want %s", network, "tcp")
|
||||
}
|
||||
if addr != "yeet.com:22" {
|
||||
t.Errorf("addr = %s, want %s", addr, "yeet.com:22")
|
||||
}
|
||||
|
||||
c, s := memnet.NewConn("outbound", 1024)
|
||||
go echoConnOnce(s)
|
||||
return c, nil
|
||||
},
|
||||
}
|
||||
|
||||
cSock, sSock := memnet.NewTCPConn(netip.MustParseAddrPort("10.64.1.2:22"), netip.MustParseAddrPort("10.64.1.2:22"), 1024)
|
||||
h.Handle(sSock)
|
||||
|
||||
// Test data write and read, the other end will echo back
|
||||
// a single stanza
|
||||
want := "hello"
|
||||
if _, err := io.WriteString(cSock, want); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := make([]byte, len(want))
|
||||
if _, err := io.ReadAtLeast(cSock, got, len(got)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != want {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
}
|
||||
|
||||
// The other end closed the socket after the first echo, so
|
||||
// any following read should error.
|
||||
io.WriteString(cSock, "deadass heres some data on god fr")
|
||||
if _, err := io.ReadAtLeast(cSock, got, len(got)); err == nil {
|
||||
t.Error("read succeeded on closed socket")
|
||||
}
|
||||
}
|
||||
|
||||
// Capture of first TCP data segment for a connection to https://pkgs.tailscale.com
|
||||
const tlsStart = `45000239ff1840004006f9f5c0a801f2
|
||||
c726b5efcf9e01bbe803b21394e3b752
|
||||
801801f641dc00000101080ade3474f2
|
||||
2fb93ee71603010200010001fc030303
|
||||
c3acbd19d2624765bb19af4bce03365e
|
||||
1d197f5bb939cdadeff26b0f8e7a0620
|
||||
295b04127b82bae46aac4ff58cffef25
|
||||
eba75a4b7a6de729532c411bd9dd0d2c
|
||||
00203a3a130113021303c02bc02fc02c
|
||||
c030cca9cca8c013c014009c009d002f
|
||||
003501000193caca0000000a000a0008
|
||||
1a1a001d001700180010000e000c0268
|
||||
3208687474702f312e31002b0007062a
|
||||
2a03040303ff01000100000d00120010
|
||||
04030804040105030805050108060601
|
||||
000b00020100002300000033002b0029
|
||||
1a1a000100001d0020d3c76bef062979
|
||||
a812ce935cfb4dbe6b3a84dc5ba9226f
|
||||
23b0f34af9d1d03b4a001b0003020002
|
||||
00120000446900050003026832000000
|
||||
170015000012706b67732e7461696c73
|
||||
63616c652e636f6d002d000201010005
|
||||
00050100000000001700003a3a000100
|
||||
0015002d000000000000000000000000
|
||||
00000000000000000000000000000000
|
||||
00000000000000000000000000000000
|
||||
0000290094006f0069e76f2016f963ad
|
||||
38c8632d1f240cd75e00e25fdef295d4
|
||||
7042b26f3a9a543b1c7dc74939d77803
|
||||
20527d423ff996997bda2c6383a14f49
|
||||
219eeef8a053e90a32228df37ddbe126
|
||||
eccf6b085c93890d08341d819aea6111
|
||||
0d909f4cd6b071d9ea40618e74588a33
|
||||
90d494bbb5c3002120d5a164a16c9724
|
||||
c9ef5e540d8d6f007789a7acf9f5f16f
|
||||
bf6a1907a6782ed02b`
|
||||
|
||||
func fakeSNIHeader() []byte {
|
||||
b, err := hex.DecodeString(strings.Replace(tlsStart, "\n", "", -1))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return b[0x34:] // trim IP + TCP header
|
||||
}
|
||||
|
||||
func TestTCPSNIHandler(t *testing.T) {
|
||||
h := tcpSNIHandler{
|
||||
Allowlist: []string{"pkgs.tailscale.com"},
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
if network != "tcp" {
|
||||
t.Errorf("network = %s, want %s", network, "tcp")
|
||||
}
|
||||
if addr != "pkgs.tailscale.com:443" {
|
||||
t.Errorf("addr = %s, want %s", addr, "pkgs.tailscale.com:443")
|
||||
}
|
||||
|
||||
c, s := memnet.NewConn("outbound", 1024)
|
||||
go echoConnOnce(s)
|
||||
return c, nil
|
||||
},
|
||||
}
|
||||
|
||||
cSock, sSock := memnet.NewTCPConn(netip.MustParseAddrPort("10.64.1.2:22"), netip.MustParseAddrPort("10.64.1.2:443"), 1024)
|
||||
h.Handle(sSock)
|
||||
|
||||
// Fake a TLS handshake record with an SNI in it.
|
||||
if _, err := cSock.Write(fakeSNIHeader()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Test read, the other end will echo back
|
||||
// a single stanza, which is at least the beginning of the SNI header.
|
||||
want := fakeSNIHeader()[:5]
|
||||
if _, err := cSock.Write(want); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := make([]byte, len(want))
|
||||
if _, err := io.ReadAtLeast(cSock, got, len(got)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -40,3 +40,12 @@ type SetPushDeviceTokenRequest struct {
|
||||
// PushDeviceToken is the iOS/macOS APNs device token (and any future Android equivalent).
|
||||
PushDeviceToken string
|
||||
}
|
||||
|
||||
// ReloadConfigResponse is the response to a LocalAPI reload-config request.
|
||||
//
|
||||
// There are three possible outcomes: (false, "") if no config mode in use,
|
||||
// (true, "") on success, or (false, "error message") on failure.
|
||||
type ReloadConfigResponse struct {
|
||||
Reloaded bool // whether the config was reloaded
|
||||
Err string // any error message
|
||||
}
|
||||
|
||||
@@ -1244,6 +1244,25 @@ func (lc *LocalClient) ProfileStatus(ctx context.Context) (current ipn.LoginProf
|
||||
return current, all, err
|
||||
}
|
||||
|
||||
// ReloadConfig reloads the config file, if possible.
|
||||
func (lc *LocalClient) ReloadConfig(ctx context.Context) (ok bool, err error) {
|
||||
body, err := lc.send(ctx, "POST", "/localapi/v0/reload-config", 200, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
res, err := decodeJSON[apitype.ReloadConfigResponse](body)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if res.Err != "" {
|
||||
return false, errors.New(res.Err)
|
||||
}
|
||||
return res.Reloaded, nil
|
||||
}
|
||||
|
||||
// SwitchToEmptyProfile creates and switches to a new unnamed profile. The new
|
||||
// profile is not assigned an ID until it is persisted after a successful login.
|
||||
// In order to login to the new profile, the user must call LoginInteractive.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from "react"
|
||||
import { Footer, Header, IP, State } from "src/components/legacy"
|
||||
import useAuth, { AuthResponse } from "src/hooks/auth"
|
||||
import useNodeData, { NodeData } from "src/hooks/node-data"
|
||||
import { ReactComponent as ConnectedDeviceIcon } from "src/icons/connected-device.svg"
|
||||
import { ReactComponent as TailscaleIcon } from "src/icons/tailscale-icon.svg"
|
||||
@@ -19,14 +20,7 @@ export default function App() {
|
||||
|
||||
return !needsLogin &&
|
||||
(data.DebugMode === "login" || data.DebugMode === "full") ? (
|
||||
<div className="flex flex-col items-center min-w-sm max-w-lg mx-auto py-10">
|
||||
{data.DebugMode === "login" ? (
|
||||
<LoginView {...data} />
|
||||
) : (
|
||||
<ManageView {...data} />
|
||||
)}
|
||||
<Footer className="mt-20" licensesURL={data.LicensesURL} />
|
||||
</div>
|
||||
<WebClient {...data} />
|
||||
) : (
|
||||
// Legacy client UI
|
||||
<div className="py-14">
|
||||
@@ -40,7 +34,34 @@ export default function App() {
|
||||
)
|
||||
}
|
||||
|
||||
function LoginView(props: NodeData) {
|
||||
function WebClient(props: NodeData) {
|
||||
const { data: auth, loading: loadingAuth, waitOnAuth } = useAuth()
|
||||
|
||||
if (loadingAuth) {
|
||||
return <div className="text-center py-14">Loading...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center min-w-sm max-w-lg mx-auto py-10">
|
||||
{props.DebugMode === "full" && auth?.ok ? (
|
||||
<ManagementView {...props} />
|
||||
) : (
|
||||
<ReadonlyView data={props} auth={auth} waitOnAuth={waitOnAuth} />
|
||||
)}
|
||||
<Footer className="mt-20" licensesURL={props.LicensesURL} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ReadonlyView({
|
||||
data,
|
||||
auth,
|
||||
waitOnAuth,
|
||||
}: {
|
||||
data: NodeData
|
||||
auth?: AuthResponse
|
||||
waitOnAuth: () => Promise<void>
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="pb-52 mx-auto">
|
||||
@@ -48,14 +69,14 @@ function LoginView(props: NodeData) {
|
||||
</div>
|
||||
<div className="w-full p-4 bg-stone-50 rounded-3xl border border-gray-200 flex flex-col gap-4">
|
||||
<div className="flex gap-2.5">
|
||||
<ProfilePic url={props.Profile.ProfilePicURL} />
|
||||
<ProfilePic url={data.Profile.ProfilePicURL} />
|
||||
<div className="font-medium">
|
||||
<div className="text-neutral-500 text-xs uppercase tracking-wide">
|
||||
Owned by
|
||||
</div>
|
||||
<div className="text-neutral-800 text-sm leading-tight">
|
||||
{/* TODO(sonia): support tagged node profile view more eloquently */}
|
||||
{props.Profile.LoginName}
|
||||
{data.Profile.LoginName}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -64,19 +85,29 @@ function LoginView(props: NodeData) {
|
||||
<ConnectedDeviceIcon />
|
||||
<div className="text-neutral-800">
|
||||
<div className="text-lg font-medium leading-[25.20px]">
|
||||
{props.DeviceName}
|
||||
{data.DeviceName}
|
||||
</div>
|
||||
<div className="text-sm leading-tight">{props.IP}</div>
|
||||
<div className="text-sm leading-tight">{data.IP}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button className="button button-blue ml-6">Access</button>
|
||||
{data.DebugMode === "full" && (
|
||||
<button
|
||||
className="button button-blue ml-6"
|
||||
onClick={() => {
|
||||
window.open(auth?.authUrl, "_blank")
|
||||
waitOnAuth()
|
||||
}}
|
||||
>
|
||||
Access
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ManageView(props: NodeData) {
|
||||
function ManagementView(props: NodeData) {
|
||||
return (
|
||||
<div className="px-5">
|
||||
<div className="flex justify-between mb-12">
|
||||
@@ -101,6 +132,7 @@ function ManageView(props: NodeData) {
|
||||
Tailscale is up and running. You can connect to this device from devices
|
||||
in your tailnet by using its name or IP address.
|
||||
</p>
|
||||
<button className="button button-blue mt-6">Advertise exit node</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
37
client/web/src/hooks/auth.ts
Normal file
37
client/web/src/hooks/auth.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { apiFetch } from "src/api"
|
||||
|
||||
export type AuthResponse = {
|
||||
ok: boolean
|
||||
authUrl?: string
|
||||
}
|
||||
|
||||
// useAuth reports and refreshes Tailscale auth status
|
||||
// for the web client.
|
||||
export default function useAuth() {
|
||||
const [data, setData] = useState<AuthResponse>()
|
||||
const [loading, setLoading] = useState<boolean>(false)
|
||||
|
||||
const loadAuth = useCallback((wait?: boolean) => {
|
||||
const url = wait ? "/auth?wait=true" : "/auth"
|
||||
setLoading(true)
|
||||
return apiFetch(url, "GET")
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
setLoading(false)
|
||||
setData(d)
|
||||
})
|
||||
.catch((error) => {
|
||||
setLoading(false)
|
||||
console.error(error)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadAuth()
|
||||
}, [])
|
||||
|
||||
const waitOnAuth = useCallback(() => loadAuth(true), [])
|
||||
|
||||
return { data, loading, waitOnAuth }
|
||||
}
|
||||
@@ -5,8 +5,10 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -36,7 +38,8 @@ import (
|
||||
|
||||
// Server is the backend server for a Tailscale web client.
|
||||
type Server struct {
|
||||
lc *tailscale.LocalClient
|
||||
lc *tailscale.LocalClient
|
||||
timeNow func() time.Time
|
||||
|
||||
devMode bool
|
||||
tsDebugMode string
|
||||
@@ -66,53 +69,60 @@ const (
|
||||
sessionCookieExpiry = time.Hour * 24 * 30 // 30 days
|
||||
)
|
||||
|
||||
var (
|
||||
exitNodeRouteV4 = netip.MustParsePrefix("0.0.0.0/0")
|
||||
exitNodeRouteV6 = netip.MustParsePrefix("::/0")
|
||||
)
|
||||
|
||||
// browserSession holds data about a user's browser session
|
||||
// on the full management web client.
|
||||
type browserSession struct {
|
||||
// ID is the unique identifier for the session.
|
||||
// It is passed in the user's "TS-Web-Session" browser cookie.
|
||||
ID string
|
||||
SrcNode tailcfg.StableNodeID
|
||||
SrcNode tailcfg.NodeID
|
||||
SrcUser tailcfg.UserID
|
||||
AuthURL string // control server URL for user to authenticate the session
|
||||
Authenticated time.Time // when zero, authentication not complete
|
||||
AuthID string // from tailcfg.WebClientAuthResponse
|
||||
AuthURL string // from tailcfg.WebClientAuthResponse
|
||||
Created time.Time
|
||||
Authenticated bool
|
||||
}
|
||||
|
||||
// isAuthorized reports true if the given session is authorized
|
||||
// to be used by its associated user to access the full management
|
||||
// web client.
|
||||
//
|
||||
// isAuthorized is true only when s.Authenticated is non-zero
|
||||
// (i.e. the user has authenticated the session) and the session
|
||||
// is not expired.
|
||||
// 2023-10-05: Sessions expire by default after 30 days.
|
||||
// isAuthorized is true only when s.Authenticated is true (i.e.
|
||||
// the user has authenticated the session) and the session is not
|
||||
// expired.
|
||||
// 2023-10-05: Sessions expire by default 30 days after creation.
|
||||
func (s *browserSession) isAuthorized() bool {
|
||||
switch {
|
||||
case s == nil:
|
||||
return false
|
||||
case s.Authenticated.IsZero():
|
||||
case !s.Authenticated:
|
||||
return false // awaiting auth
|
||||
case s.isExpired(): // TODO: add time field to server?
|
||||
case s.isExpired():
|
||||
return false // expired
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// isExpired reports true if s is expired.
|
||||
// 2023-10-05: Sessions expire by default after 30 days.
|
||||
// If s.Authenticated is zero, isExpired reports false.
|
||||
// 2023-10-05: Sessions expire by default 30 days after creation.
|
||||
func (s *browserSession) isExpired() bool {
|
||||
return !s.Authenticated.IsZero() && s.Authenticated.Before(time.Now().Add(-sessionCookieExpiry)) // TODO: add time field to server?
|
||||
return !s.Created.IsZero() && time.Now().After(s.expires()) // TODO: use Server.timeNow field
|
||||
}
|
||||
|
||||
// expires reports when the given session expires.
|
||||
func (s *browserSession) expires() time.Time {
|
||||
return s.Created.Add(sessionCookieExpiry)
|
||||
}
|
||||
|
||||
// ServerOpts contains options for constructing a new Server.
|
||||
type ServerOpts struct {
|
||||
DevMode bool
|
||||
|
||||
// LoginOnly indicates that the server should only serve the minimal
|
||||
// login client and not the full web client.
|
||||
LoginOnly bool
|
||||
|
||||
// CGIMode indicates if the server is running as a CGI script.
|
||||
CGIMode bool
|
||||
|
||||
@@ -122,11 +132,14 @@ type ServerOpts struct {
|
||||
// LocalClient is the tailscale.LocalClient to use for this web server.
|
||||
// If nil, a new one will be created.
|
||||
LocalClient *tailscale.LocalClient
|
||||
|
||||
// TimeNow optionally provides a time function.
|
||||
// time.Now is used as default.
|
||||
TimeNow func() time.Time
|
||||
}
|
||||
|
||||
// NewServer constructs a new Tailscale web client server.
|
||||
// The provided context should live for the duration of the Server's lifetime.
|
||||
func NewServer(ctx context.Context, opts ServerOpts) (s *Server, cleanup func()) {
|
||||
func NewServer(opts ServerOpts) (s *Server, cleanup func()) {
|
||||
if opts.LocalClient == nil {
|
||||
opts.LocalClient = &tailscale.LocalClient{}
|
||||
}
|
||||
@@ -134,6 +147,10 @@ func NewServer(ctx context.Context, opts ServerOpts) (s *Server, cleanup func())
|
||||
devMode: opts.DevMode,
|
||||
lc: opts.LocalClient,
|
||||
pathPrefix: opts.PathPrefix,
|
||||
timeNow: opts.TimeNow,
|
||||
}
|
||||
if s.timeNow == nil {
|
||||
s.timeNow = time.Now
|
||||
}
|
||||
s.tsDebugMode = s.debugMode()
|
||||
s.assetsHandler, cleanup = assetsHandler(opts.DevMode)
|
||||
@@ -184,42 +201,75 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) serve(w http.ResponseWriter, r *http.Request) {
|
||||
if ok := s.authorizeRequest(w, r); !ok {
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
// Pass API requests through to the API handler.
|
||||
s.apiHandler.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if !s.devMode {
|
||||
s.lc.IncrementCounter(context.Background(), "web_client_page_load", 1)
|
||||
s.lc.IncrementCounter(r.Context(), "web_client_page_load", 1)
|
||||
}
|
||||
s.assetsHandler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// authorizePlatformRequest reports whether the request from the web client
|
||||
// is authorized to access the client for those platforms that support it.
|
||||
// authorizeRequest reports whether the request from the web client
|
||||
// is authorized to be completed.
|
||||
// It reports true if the request is authorized, and false otherwise.
|
||||
// authorizePlatformRequest manages writing out any relevant authorization
|
||||
// authorizeRequest manages writing out any relevant authorization
|
||||
// errors to the ResponseWriter itself.
|
||||
func authorizePlatformRequest(w http.ResponseWriter, r *http.Request) (ok bool) {
|
||||
switch distro.Get() {
|
||||
case distro.Synology:
|
||||
return authorizeSynology(w, r)
|
||||
case distro.QNAP:
|
||||
return authorizeQNAP(w, r)
|
||||
func (s *Server) authorizeRequest(w http.ResponseWriter, r *http.Request) (ok bool) {
|
||||
if s.tsDebugMode == "full" { // client using tailscale auth
|
||||
_, err := s.lc.WhoIs(r.Context(), r.RemoteAddr)
|
||||
switch {
|
||||
case err != nil:
|
||||
// All requests must be made over tailscale.
|
||||
http.Error(w, "must access over tailscale", http.StatusUnauthorized)
|
||||
return false
|
||||
case r.URL.Path == "/api/data" && r.Method == httpm.GET:
|
||||
// Readonly endpoint allowed without browser session.
|
||||
return true
|
||||
case r.URL.Path == "/api/auth":
|
||||
// Endpoint for browser to request auth allowed without browser session.
|
||||
return true
|
||||
case strings.HasPrefix(r.URL.Path, "/api/"):
|
||||
// All other /api/ endpoints require a valid browser session.
|
||||
//
|
||||
// TODO(sonia): s.getTailscaleBrowserSession calls whois again,
|
||||
// should try and use the above call instead of running another
|
||||
// localapi request.
|
||||
session, _, err := s.getTailscaleBrowserSession(r)
|
||||
if err != nil || !session.isAuthorized() {
|
||||
http.Error(w, "no valid session", http.StatusUnauthorized)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
default:
|
||||
// No additional auth on non-api (assets, index.html, etc).
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Client using system-specific auth.
|
||||
d := distro.Get()
|
||||
switch {
|
||||
case strings.HasPrefix(r.URL.Path, "/assets/") && r.Method == httpm.GET:
|
||||
// Don't require authorization for static assets.
|
||||
return true
|
||||
case d == distro.Synology:
|
||||
return authorizeSynology(w, r)
|
||||
case d == distro.QNAP:
|
||||
return authorizeQNAP(w, r)
|
||||
default:
|
||||
return true // no additional auth for this distro
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// serveLoginAPI serves requests for the web login client.
|
||||
// It should only be called by Server.ServeHTTP, via Server.apiHandler,
|
||||
// which protects the handler using gorilla csrf.
|
||||
func (s *Server) serveLoginAPI(w http.ResponseWriter, r *http.Request) {
|
||||
// The login client is run directly from client plugins,
|
||||
// so first authenticate and authorize the request for the host platform.
|
||||
if ok := authorizePlatformRequest(w, r); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("X-CSRF-Token", csrf.Token(r))
|
||||
if r.URL.Path != "/api/data" { // only endpoint allowed for login client
|
||||
http.Error(w, "invalid endpoint", http.StatusNotFound)
|
||||
@@ -263,70 +313,123 @@ var (
|
||||
// If no error is returned, the browserSession is always non-nil.
|
||||
// getTailscaleBrowserSession does not check whether the session has been
|
||||
// authorized by the user. Callers can use browserSession.isAuthorized.
|
||||
func (s *Server) getTailscaleBrowserSession(r *http.Request) (*browserSession, error) {
|
||||
//
|
||||
// The WhoIsResponse is always populated, with a non-nil Node and UserProfile,
|
||||
// unless getTailscaleBrowserSession reports errNotUsingTailscale.
|
||||
func (s *Server) getTailscaleBrowserSession(r *http.Request) (*browserSession, *apitype.WhoIsResponse, error) {
|
||||
whoIs, err := s.lc.WhoIs(r.Context(), r.RemoteAddr)
|
||||
switch {
|
||||
case err != nil:
|
||||
return nil, errNotUsingTailscale
|
||||
return nil, nil, errNotUsingTailscale
|
||||
case whoIs.Node.IsTagged():
|
||||
return nil, errTaggedSource
|
||||
return nil, whoIs, errTaggedSource
|
||||
}
|
||||
srcNode := whoIs.Node.StableID
|
||||
srcNode := whoIs.Node.ID
|
||||
srcUser := whoIs.UserProfile.ID
|
||||
|
||||
status, err := s.lc.StatusWithoutPeers(r.Context())
|
||||
switch {
|
||||
case err != nil:
|
||||
return nil, err
|
||||
return nil, whoIs, err
|
||||
case status.Self == nil:
|
||||
return nil, errors.New("missing self node in tailscale status")
|
||||
return nil, whoIs, errors.New("missing self node in tailscale status")
|
||||
case !status.Self.IsTagged() && status.Self.UserID != srcUser:
|
||||
return nil, errNotOwner
|
||||
return nil, whoIs, errNotOwner
|
||||
}
|
||||
|
||||
cookie, err := r.Cookie(sessionCookieName)
|
||||
if errors.Is(err, http.ErrNoCookie) {
|
||||
return nil, errNoSession
|
||||
return nil, whoIs, errNoSession
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
return nil, whoIs, err
|
||||
}
|
||||
v, ok := s.browserSessions.Load(cookie.Value)
|
||||
if !ok {
|
||||
return nil, errNoSession
|
||||
return nil, whoIs, errNoSession
|
||||
}
|
||||
session := v.(*browserSession)
|
||||
if session.SrcNode != srcNode || session.SrcUser != srcUser {
|
||||
// In this case the browser cookie is associated with another tailscale node.
|
||||
// Maybe the source browser's machine was logged out and then back in as a different node.
|
||||
// Return errNoSession because there is no session for this user.
|
||||
return nil, errNoSession
|
||||
return nil, whoIs, errNoSession
|
||||
} else if session.isExpired() {
|
||||
// Session expired, remove from session map and return errNoSession.
|
||||
s.browserSessions.Delete(session.ID)
|
||||
return nil, errNoSession
|
||||
return nil, whoIs, errNoSession
|
||||
}
|
||||
return session, nil
|
||||
return session, whoIs, nil
|
||||
}
|
||||
|
||||
type authResponse struct {
|
||||
OK bool `json:"ok"` // true when user has valid auth session
|
||||
AuthURL string `json:"authUrl,omitempty"` // filled when user has control auth action to take
|
||||
Error string `json:"error,omitempty"` // filled when Ok is false
|
||||
}
|
||||
|
||||
func (s *Server) serveTailscaleAuth(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != httpm.GET {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var resp authResponse
|
||||
|
||||
session, err := s.getTailscaleBrowserSession(r)
|
||||
session, whois, err := s.getTailscaleBrowserSession(r)
|
||||
switch {
|
||||
case err != nil && !errors.Is(err, errNoSession):
|
||||
resp = authResponse{OK: false, Error: err.Error()}
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
case session == nil:
|
||||
// TODO(tailscale/corp#14335): Create a new auth path from control,
|
||||
// and store back to s.browserSessions and request cookie.
|
||||
// Create a new session.
|
||||
d, err := s.getOrAwaitAuth(r.Context(), "", whois.Node.ID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
sid, err := s.newSessionID()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
session := &browserSession{
|
||||
ID: sid,
|
||||
SrcNode: whois.Node.ID,
|
||||
SrcUser: whois.UserProfile.ID,
|
||||
AuthID: d.ID,
|
||||
AuthURL: d.URL,
|
||||
Created: s.timeNow(),
|
||||
}
|
||||
s.browserSessions.Store(sid, session)
|
||||
// Set the cookie on browser.
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: sid,
|
||||
Raw: sid,
|
||||
Path: "/",
|
||||
Expires: session.expires(),
|
||||
})
|
||||
resp = authResponse{OK: false, AuthURL: d.URL}
|
||||
case !session.isAuthorized():
|
||||
// TODO(tailscale/corp#14335): Check on the session auth path status from control,
|
||||
// and store back to s.browserSessions.
|
||||
if r.URL.Query().Get("wait") == "true" {
|
||||
// Client requested we block until user completes auth.
|
||||
d, err := s.getOrAwaitAuth(r.Context(), session.AuthID, whois.Node.ID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
// Clean up the session. Doing this on any error from control
|
||||
// server to avoid the user getting stuck with a bad session
|
||||
// cookie.
|
||||
s.browserSessions.Delete(session.ID)
|
||||
return
|
||||
}
|
||||
if d.Complete {
|
||||
session.Authenticated = d.Complete
|
||||
s.browserSessions.Store(session.ID, session)
|
||||
}
|
||||
}
|
||||
if session.isAuthorized() {
|
||||
resp = authResponse{OK: true}
|
||||
} else {
|
||||
resp = authResponse{OK: false, AuthURL: session.AuthURL}
|
||||
}
|
||||
default:
|
||||
resp = authResponse{OK: true}
|
||||
}
|
||||
@@ -338,35 +441,71 @@ func (s *Server) serveTailscaleAuth(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
func (s *Server) newSessionID() (string, error) {
|
||||
raw := make([]byte, 16)
|
||||
for i := 0; i < 5; i++ {
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", err
|
||||
}
|
||||
cookie := "ts-web-" + base64.RawURLEncoding.EncodeToString(raw)
|
||||
if _, ok := s.browserSessions.Load(cookie); !ok {
|
||||
return cookie, nil
|
||||
}
|
||||
}
|
||||
return "", errors.New("too many collisions generating new session; please refresh page")
|
||||
}
|
||||
|
||||
// getOrAwaitAuth connects to the control server for user auth,
|
||||
// with the following behavior:
|
||||
//
|
||||
// 1. If authID is provided empty, a new auth URL is created on the control
|
||||
// server and reported back here, which can then be used to redirect the
|
||||
// user on the frontend.
|
||||
// 2. If authID is provided non-empty, the connection to control blocks until
|
||||
// the user has completed authenticating the associated auth URL,
|
||||
// or until ctx is canceled.
|
||||
func (s *Server) getOrAwaitAuth(ctx context.Context, authID string, src tailcfg.NodeID) (*tailcfg.WebClientAuthResponse, error) {
|
||||
type data struct {
|
||||
ID string
|
||||
Src tailcfg.NodeID
|
||||
}
|
||||
var b bytes.Buffer
|
||||
if err := json.NewEncoder(&b).Encode(data{ID: authID, Src: src}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
url := "http://" + apitype.LocalAPIHost + "/localapi/v0/debug-web-client"
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", url, &b)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := s.lc.DoLocalRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("failed request: %s", body)
|
||||
}
|
||||
var authResp *tailcfg.WebClientAuthResponse
|
||||
if err := json.Unmarshal(body, &authResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return authResp, nil
|
||||
}
|
||||
|
||||
// serveAPI serves requests for the web client api.
|
||||
// It should only be called by Server.ServeHTTP, via Server.apiHandler,
|
||||
// which protects the handler using gorilla csrf.
|
||||
func (s *Server) serveAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if s.tsDebugMode == "full" {
|
||||
// tailscale/corp#14335: Only restrict to tailscale auth in debug "full" web client mode.
|
||||
// TODO(sonia,will): Switch serveAPI over to always require TS auth when we're ready
|
||||
// to remove the debug flags.
|
||||
// For now, existing client uses platform auth (else case below).
|
||||
|
||||
if r.URL.Path == "/api/auth" {
|
||||
// Serve auth, which creates a new session for the user to authenticate,
|
||||
// in the case that the request doesn't already have one.
|
||||
s.serveTailscaleAuth(w, r)
|
||||
return
|
||||
}
|
||||
// For all other endpoints, require a valid session to proceed.
|
||||
session, err := s.getTailscaleBrowserSession(r)
|
||||
if err != nil || !session.isAuthorized() {
|
||||
http.Error(w, "no valid session", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
} else if ok := authorizePlatformRequest(w, r); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("X-CSRF-Token", csrf.Token(r))
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api")
|
||||
switch {
|
||||
case path == "/auth":
|
||||
if s.tsDebugMode == "full" { // behind debug flag
|
||||
s.serveTailscaleAuth(w, r)
|
||||
return
|
||||
}
|
||||
case path == "/data":
|
||||
switch r.Method {
|
||||
case httpm.GET:
|
||||
@@ -428,8 +567,6 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
|
||||
IPNVersion: versionShort,
|
||||
DebugMode: s.tsDebugMode,
|
||||
}
|
||||
exitNodeRouteV4 := netip.MustParsePrefix("0.0.0.0/0")
|
||||
exitNodeRouteV6 := netip.MustParsePrefix("::/0")
|
||||
for _, r := range prefs.AdvertiseRoutes {
|
||||
if r == exitNodeRouteV4 || r == exitNodeRouteV6 {
|
||||
data.AdvertiseExitNode = true
|
||||
@@ -474,6 +611,22 @@ func (s *Server) servePostNodeUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
prefs, err := s.lc.GetPrefs(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
isCurrentlyExitNode := slices.Contains(prefs.AdvertiseRoutes, exitNodeRouteV4) || slices.Contains(prefs.AdvertiseRoutes, exitNodeRouteV6)
|
||||
|
||||
if postData.AdvertiseExitNode != isCurrentlyExitNode {
|
||||
if postData.AdvertiseExitNode {
|
||||
s.lc.IncrementCounter(r.Context(), "web_client_advertise_exitnode_enable", 1)
|
||||
} else {
|
||||
s.lc.IncrementCounter(r.Context(), "web_client_advertise_exitnode_disable", 1)
|
||||
}
|
||||
}
|
||||
|
||||
routes, err := netutil.CalcAdvertiseRoutes(postData.AdvertiseRoutes, postData.AdvertiseExitNode)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"tailscale.com/net/memnet"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/views"
|
||||
"tailscale.com/util/httpm"
|
||||
)
|
||||
|
||||
func TestQnapAuthnURL(t *testing.T) {
|
||||
@@ -124,7 +125,7 @@ func TestServeAPI(t *testing.T) {
|
||||
res := w.Result()
|
||||
defer res.Body.Close()
|
||||
if gotStatus := res.StatusCode; tt.wantStatus != gotStatus {
|
||||
t.Errorf("wrong status; want=%q, got=%q", tt.wantStatus, gotStatus)
|
||||
t.Errorf("wrong status; want=%v, got=%v", tt.wantStatus, gotStatus)
|
||||
}
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
@@ -150,53 +151,21 @@ func TestGetTailscaleBrowserSession(t *testing.T) {
|
||||
tags := views.SliceOf([]string{"tag:server"})
|
||||
tailnetNodes := map[string]*apitype.WhoIsResponse{
|
||||
userANodeIP: {
|
||||
Node: &tailcfg.Node{StableID: "Node1"},
|
||||
Node: &tailcfg.Node{ID: 1},
|
||||
UserProfile: userA,
|
||||
},
|
||||
userBNodeIP: {
|
||||
Node: &tailcfg.Node{StableID: "Node2"},
|
||||
Node: &tailcfg.Node{ID: 2},
|
||||
UserProfile: userB,
|
||||
},
|
||||
taggedNodeIP: {
|
||||
Node: &tailcfg.Node{StableID: "Node3", Tags: tags.AsSlice()},
|
||||
Node: &tailcfg.Node{ID: 3, Tags: tags.AsSlice()},
|
||||
},
|
||||
}
|
||||
|
||||
lal := memnet.Listen("local-tailscaled.sock:80")
|
||||
defer lal.Close()
|
||||
// Serve a testing localapi handler so we can simulate
|
||||
// whois responses without a functioning tailnet.
|
||||
localapi := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/localapi/v0/whois":
|
||||
addr := r.URL.Query().Get("addr")
|
||||
if addr == "" {
|
||||
t.Fatalf("/whois call missing \"addr\" query")
|
||||
}
|
||||
if node := tailnetNodes[addr]; node != nil {
|
||||
if err := json.NewEncoder(w).Encode(&node); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
return
|
||||
}
|
||||
http.Error(w, "not a node", http.StatusUnauthorized)
|
||||
return
|
||||
case "/localapi/v0/status":
|
||||
status := ipnstate.Status{Self: selfNode}
|
||||
if err := json.NewEncoder(w).Encode(status); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
return
|
||||
default:
|
||||
// Only the above two endpoints get triggered from getTailscaleBrowserSession.
|
||||
// No need to mock any of the other localapi endpoint.
|
||||
t.Fatalf("unhandled localapi test endpoint %q, add to localapi handler func in test", r.URL.Path)
|
||||
}
|
||||
})}
|
||||
localapi := mockLocalAPI(t, tailnetNodes, func() *ipnstate.PeerStatus { return selfNode })
|
||||
defer localapi.Close()
|
||||
go localapi.Serve(lal)
|
||||
|
||||
@@ -205,21 +174,24 @@ func TestGetTailscaleBrowserSession(t *testing.T) {
|
||||
// Add some browser sessions to cache state.
|
||||
userASession := &browserSession{
|
||||
ID: "cookie1",
|
||||
SrcNode: "Node1",
|
||||
SrcNode: 1,
|
||||
SrcUser: userA.ID,
|
||||
Authenticated: time.Time{}, // not yet authenticated
|
||||
Created: time.Now(),
|
||||
Authenticated: false, // not yet authenticated
|
||||
}
|
||||
userBSession := &browserSession{
|
||||
ID: "cookie2",
|
||||
SrcNode: "Node2",
|
||||
SrcNode: 2,
|
||||
SrcUser: userB.ID,
|
||||
Authenticated: time.Now().Add(-2 * sessionCookieExpiry), // expired
|
||||
Created: time.Now().Add(-2 * sessionCookieExpiry),
|
||||
Authenticated: true, // expired
|
||||
}
|
||||
userASessionAuthorized := &browserSession{
|
||||
ID: "cookie3",
|
||||
SrcNode: "Node1",
|
||||
SrcNode: 1,
|
||||
SrcUser: userA.ID,
|
||||
Authenticated: time.Now(), // authenticated and not expired
|
||||
Created: time.Now(),
|
||||
Authenticated: true, // authenticated and not expired
|
||||
}
|
||||
s.browserSessions.Store(userASession.ID, userASession)
|
||||
s.browserSessions.Store(userBSession.ID, userBSession)
|
||||
@@ -312,7 +284,7 @@ func TestGetTailscaleBrowserSession(t *testing.T) {
|
||||
if tt.cookie != "" {
|
||||
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tt.cookie})
|
||||
}
|
||||
session, err := s.getTailscaleBrowserSession(r)
|
||||
session, _, err := s.getTailscaleBrowserSession(r)
|
||||
if !errors.Is(err, tt.wantError) {
|
||||
t.Errorf("wrong error; want=%v, got=%v", tt.wantError, err)
|
||||
}
|
||||
@@ -325,3 +297,398 @@ func TestGetTailscaleBrowserSession(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthorizeRequest tests the s.authorizeRequest function.
|
||||
// 2023-10-18: These tests currently cover tailscale auth mode (not platform auth).
|
||||
func TestAuthorizeRequest(t *testing.T) {
|
||||
// Create self and remoteNode owned by same user.
|
||||
// See TestGetTailscaleBrowserSession for tests of
|
||||
// browser sessions w/ different users.
|
||||
user := &tailcfg.UserProfile{ID: tailcfg.UserID(1)}
|
||||
self := &ipnstate.PeerStatus{ID: "self", UserID: user.ID}
|
||||
remoteNode := &apitype.WhoIsResponse{Node: &tailcfg.Node{StableID: "node"}, UserProfile: user}
|
||||
remoteIP := "100.100.100.101"
|
||||
|
||||
lal := memnet.Listen("local-tailscaled.sock:80")
|
||||
defer lal.Close()
|
||||
localapi := mockLocalAPI(t,
|
||||
map[string]*apitype.WhoIsResponse{remoteIP: remoteNode},
|
||||
func() *ipnstate.PeerStatus { return self },
|
||||
)
|
||||
defer localapi.Close()
|
||||
go localapi.Serve(lal)
|
||||
|
||||
s := &Server{
|
||||
lc: &tailscale.LocalClient{Dial: lal.Dial},
|
||||
tsDebugMode: "full",
|
||||
}
|
||||
validCookie := "ts-cookie"
|
||||
s.browserSessions.Store(validCookie, &browserSession{
|
||||
ID: validCookie,
|
||||
SrcNode: remoteNode.Node.ID,
|
||||
SrcUser: user.ID,
|
||||
Created: time.Now(),
|
||||
Authenticated: true,
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
reqPath string
|
||||
reqMethod string
|
||||
|
||||
wantOkNotOverTailscale bool // simulates req over public internet
|
||||
wantOkWithoutSession bool // simulates req over TS without valid browser session
|
||||
wantOkWithSession bool // simulates req over TS with valid browser session
|
||||
}{{
|
||||
reqPath: "/api/data",
|
||||
reqMethod: httpm.GET,
|
||||
wantOkNotOverTailscale: false,
|
||||
wantOkWithoutSession: true,
|
||||
wantOkWithSession: true,
|
||||
}, {
|
||||
reqPath: "/api/data",
|
||||
reqMethod: httpm.POST,
|
||||
wantOkNotOverTailscale: false,
|
||||
wantOkWithoutSession: false,
|
||||
wantOkWithSession: true,
|
||||
}, {
|
||||
reqPath: "/api/auth",
|
||||
reqMethod: httpm.GET,
|
||||
wantOkNotOverTailscale: false,
|
||||
wantOkWithoutSession: true,
|
||||
wantOkWithSession: true,
|
||||
}, {
|
||||
reqPath: "/api/somethingelse",
|
||||
reqMethod: httpm.GET,
|
||||
wantOkNotOverTailscale: false,
|
||||
wantOkWithoutSession: false,
|
||||
wantOkWithSession: true,
|
||||
}, {
|
||||
reqPath: "/assets/styles.css",
|
||||
wantOkNotOverTailscale: false,
|
||||
wantOkWithoutSession: true,
|
||||
wantOkWithSession: true,
|
||||
}}
|
||||
for _, tt := range tests {
|
||||
t.Run(fmt.Sprintf("%s-%s", tt.reqMethod, tt.reqPath), func(t *testing.T) {
|
||||
doAuthorize := func(remoteAddr string, cookie string) bool {
|
||||
r := httptest.NewRequest(tt.reqMethod, tt.reqPath, nil)
|
||||
r.RemoteAddr = remoteAddr
|
||||
if cookie != "" {
|
||||
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: cookie})
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
return s.authorizeRequest(w, r)
|
||||
}
|
||||
// Do request from non-Tailscale IP.
|
||||
if gotOk := doAuthorize("123.456.789.999", ""); gotOk != tt.wantOkNotOverTailscale {
|
||||
t.Errorf("wantOkNotOverTailscale; want=%v, got=%v", tt.wantOkNotOverTailscale, gotOk)
|
||||
}
|
||||
// Do request from Tailscale IP w/o associated session.
|
||||
if gotOk := doAuthorize(remoteIP, ""); gotOk != tt.wantOkWithoutSession {
|
||||
t.Errorf("wantOkWithoutSession; want=%v, got=%v", tt.wantOkWithoutSession, gotOk)
|
||||
}
|
||||
// Do request from Tailscale IP w/ associated session.
|
||||
if gotOk := doAuthorize(remoteIP, validCookie); gotOk != tt.wantOkWithSession {
|
||||
t.Errorf("wantOkWithSession; want=%v, got=%v", tt.wantOkWithSession, gotOk)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeTailscaleAuth(t *testing.T) {
|
||||
user := &tailcfg.UserProfile{ID: tailcfg.UserID(1)}
|
||||
self := &ipnstate.PeerStatus{ID: "self", UserID: user.ID}
|
||||
remoteNode := &apitype.WhoIsResponse{Node: &tailcfg.Node{ID: 1}, UserProfile: user}
|
||||
remoteIP := "100.100.100.101"
|
||||
|
||||
lal := memnet.Listen("local-tailscaled.sock:80")
|
||||
defer lal.Close()
|
||||
localapi := mockLocalAPI(t,
|
||||
map[string]*apitype.WhoIsResponse{remoteIP: remoteNode},
|
||||
func() *ipnstate.PeerStatus { return self },
|
||||
)
|
||||
defer localapi.Close()
|
||||
go localapi.Serve(lal)
|
||||
|
||||
timeNow := time.Now()
|
||||
oneHourAgo := timeNow.Add(-time.Hour)
|
||||
sixtyDaysAgo := timeNow.Add(-sessionCookieExpiry * 2)
|
||||
|
||||
s := &Server{
|
||||
lc: &tailscale.LocalClient{Dial: lal.Dial},
|
||||
tsDebugMode: "full",
|
||||
timeNow: func() time.Time { return timeNow },
|
||||
}
|
||||
|
||||
successCookie := "ts-cookie-success"
|
||||
s.browserSessions.Store(successCookie, &browserSession{
|
||||
ID: successCookie,
|
||||
SrcNode: remoteNode.Node.ID,
|
||||
SrcUser: user.ID,
|
||||
Created: oneHourAgo,
|
||||
AuthID: testAuthPathSuccess,
|
||||
AuthURL: testControlURL + testAuthPathSuccess,
|
||||
})
|
||||
failureCookie := "ts-cookie-failure"
|
||||
s.browserSessions.Store(failureCookie, &browserSession{
|
||||
ID: failureCookie,
|
||||
SrcNode: remoteNode.Node.ID,
|
||||
SrcUser: user.ID,
|
||||
Created: oneHourAgo,
|
||||
AuthID: testAuthPathError,
|
||||
AuthURL: testControlURL + testAuthPathError,
|
||||
})
|
||||
expiredCookie := "ts-cookie-expired"
|
||||
s.browserSessions.Store(expiredCookie, &browserSession{
|
||||
ID: expiredCookie,
|
||||
SrcNode: remoteNode.Node.ID,
|
||||
SrcUser: user.ID,
|
||||
Created: sixtyDaysAgo,
|
||||
AuthID: "/a/old-auth-url",
|
||||
AuthURL: testControlURL + "/a/old-auth-url",
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cookie string
|
||||
query string
|
||||
wantStatus int
|
||||
wantResp *authResponse
|
||||
wantNewCookie bool // new cookie generated
|
||||
wantSession *browserSession // session associated w/ cookie at end of request
|
||||
}{
|
||||
{
|
||||
name: "new-session-created",
|
||||
wantStatus: http.StatusOK,
|
||||
wantResp: &authResponse{OK: false, AuthURL: testControlURL + testAuthPath},
|
||||
wantNewCookie: true,
|
||||
wantSession: &browserSession{
|
||||
ID: "GENERATED_ID", // gets swapped for newly created ID by test
|
||||
SrcNode: remoteNode.Node.ID,
|
||||
SrcUser: user.ID,
|
||||
Created: timeNow,
|
||||
AuthID: testAuthPath,
|
||||
AuthURL: testControlURL + testAuthPath,
|
||||
Authenticated: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "query-existing-incomplete-session",
|
||||
cookie: successCookie,
|
||||
wantStatus: http.StatusOK,
|
||||
wantResp: &authResponse{OK: false, AuthURL: testControlURL + testAuthPathSuccess},
|
||||
wantSession: &browserSession{
|
||||
ID: successCookie,
|
||||
SrcNode: remoteNode.Node.ID,
|
||||
SrcUser: user.ID,
|
||||
Created: oneHourAgo,
|
||||
AuthID: testAuthPathSuccess,
|
||||
AuthURL: testControlURL + testAuthPathSuccess,
|
||||
Authenticated: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "transition-to-successful-session",
|
||||
cookie: successCookie,
|
||||
// query "wait" indicates the FE wants to make
|
||||
// local api call to wait until session completed.
|
||||
query: "wait=true",
|
||||
wantStatus: http.StatusOK,
|
||||
wantResp: &authResponse{OK: true},
|
||||
wantSession: &browserSession{
|
||||
ID: successCookie,
|
||||
SrcNode: remoteNode.Node.ID,
|
||||
SrcUser: user.ID,
|
||||
Created: oneHourAgo,
|
||||
AuthID: testAuthPathSuccess,
|
||||
AuthURL: testControlURL + testAuthPathSuccess,
|
||||
Authenticated: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "query-existing-complete-session",
|
||||
cookie: successCookie,
|
||||
wantStatus: http.StatusOK,
|
||||
wantResp: &authResponse{OK: true},
|
||||
wantSession: &browserSession{
|
||||
ID: successCookie,
|
||||
SrcNode: remoteNode.Node.ID,
|
||||
SrcUser: user.ID,
|
||||
Created: oneHourAgo,
|
||||
AuthID: testAuthPathSuccess,
|
||||
AuthURL: testControlURL + testAuthPathSuccess,
|
||||
Authenticated: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "transition-to-failed-session",
|
||||
cookie: failureCookie,
|
||||
query: "wait=true",
|
||||
wantStatus: http.StatusUnauthorized,
|
||||
wantResp: nil,
|
||||
wantSession: nil, // session deleted
|
||||
},
|
||||
{
|
||||
name: "failed-session-cleaned-up",
|
||||
cookie: failureCookie,
|
||||
wantStatus: http.StatusOK,
|
||||
wantResp: &authResponse{OK: false, AuthURL: testControlURL + testAuthPath},
|
||||
wantNewCookie: true,
|
||||
wantSession: &browserSession{
|
||||
ID: "GENERATED_ID",
|
||||
SrcNode: remoteNode.Node.ID,
|
||||
SrcUser: user.ID,
|
||||
Created: timeNow,
|
||||
AuthID: testAuthPath,
|
||||
AuthURL: testControlURL + testAuthPath,
|
||||
Authenticated: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "expired-cookie-gets-new-session",
|
||||
cookie: expiredCookie,
|
||||
wantStatus: http.StatusOK,
|
||||
wantResp: &authResponse{OK: false, AuthURL: testControlURL + testAuthPath},
|
||||
wantNewCookie: true,
|
||||
wantSession: &browserSession{
|
||||
ID: "GENERATED_ID",
|
||||
SrcNode: remoteNode.Node.ID,
|
||||
SrcUser: user.ID,
|
||||
Created: timeNow,
|
||||
AuthID: testAuthPath,
|
||||
AuthURL: testControlURL + testAuthPath,
|
||||
Authenticated: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r := httptest.NewRequest("GET", "/api/auth", nil)
|
||||
r.URL.RawQuery = tt.query
|
||||
r.RemoteAddr = remoteIP
|
||||
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tt.cookie})
|
||||
w := httptest.NewRecorder()
|
||||
s.serveTailscaleAuth(w, r)
|
||||
res := w.Result()
|
||||
defer res.Body.Close()
|
||||
|
||||
// Validate response status/data.
|
||||
if gotStatus := res.StatusCode; tt.wantStatus != gotStatus {
|
||||
t.Errorf("wrong status; want=%v, got=%v", tt.wantStatus, gotStatus)
|
||||
}
|
||||
var gotResp *authResponse
|
||||
if res.StatusCode == http.StatusOK {
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(body, &gotResp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if diff := cmp.Diff(gotResp, tt.wantResp); diff != "" {
|
||||
t.Errorf("wrong response; (-got+want):%v", diff)
|
||||
}
|
||||
// Validate cookie creation.
|
||||
sessionID := tt.cookie
|
||||
var gotCookie bool
|
||||
for _, c := range w.Result().Cookies() {
|
||||
if c.Name == sessionCookieName {
|
||||
gotCookie = true
|
||||
sessionID = c.Value
|
||||
break
|
||||
}
|
||||
}
|
||||
if gotCookie != tt.wantNewCookie {
|
||||
t.Errorf("wantNewCookie wrong; want=%v, got=%v", tt.wantNewCookie, gotCookie)
|
||||
}
|
||||
// Validate browser session contents.
|
||||
var gotSesson *browserSession
|
||||
if s, ok := s.browserSessions.Load(sessionID); ok {
|
||||
gotSesson = s.(*browserSession)
|
||||
}
|
||||
if tt.wantSession != nil && tt.wantSession.ID == "GENERATED_ID" {
|
||||
// If requested, swap in the generated session ID before
|
||||
// comparing got/want.
|
||||
tt.wantSession.ID = sessionID
|
||||
}
|
||||
if diff := cmp.Diff(gotSesson, tt.wantSession); diff != "" {
|
||||
t.Errorf("wrong session; (-got+want):%v", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
testControlURL = "http://localhost:8080"
|
||||
testAuthPath = "/a/12345"
|
||||
testAuthPathSuccess = "/a/will-succeed"
|
||||
testAuthPathError = "/a/will-error"
|
||||
)
|
||||
|
||||
// mockLocalAPI constructs a test localapi handler that can be used
|
||||
// to simulate localapi responses without a functioning tailnet.
|
||||
//
|
||||
// self accepts a function that resolves to a self node status,
|
||||
// so that tests may swap out the /localapi/v0/status response
|
||||
// as desired.
|
||||
func mockLocalAPI(t *testing.T, whoIs map[string]*apitype.WhoIsResponse, self func() *ipnstate.PeerStatus) *http.Server {
|
||||
return &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/localapi/v0/whois":
|
||||
addr := r.URL.Query().Get("addr")
|
||||
if addr == "" {
|
||||
t.Fatalf("/whois call missing \"addr\" query")
|
||||
}
|
||||
if node := whoIs[addr]; node != nil {
|
||||
if err := json.NewEncoder(w).Encode(&node); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
return
|
||||
}
|
||||
http.Error(w, "not a node", http.StatusUnauthorized)
|
||||
return
|
||||
case "/localapi/v0/status":
|
||||
status := ipnstate.Status{Self: self()}
|
||||
if err := json.NewEncoder(w).Encode(status); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
return
|
||||
case "/localapi/v0/debug-web-client": // used by TestServeTailscaleAuth
|
||||
type reqData struct {
|
||||
ID string
|
||||
Src tailcfg.NodeID
|
||||
}
|
||||
var data reqData
|
||||
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
|
||||
http.Error(w, "invalid JSON body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if data.Src == 0 {
|
||||
http.Error(w, "missing Src node", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var resp *tailcfg.WebClientAuthResponse
|
||||
if data.ID == "" {
|
||||
resp = &tailcfg.WebClientAuthResponse{ID: testAuthPath, URL: testControlURL + testAuthPath}
|
||||
} else if data.ID == testAuthPathSuccess {
|
||||
resp = &tailcfg.WebClientAuthResponse{Complete: true}
|
||||
} else if data.ID == testAuthPathError {
|
||||
http.Error(w, "authenticated as wrong user", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
return
|
||||
default:
|
||||
t.Fatalf("unhandled localapi test endpoint %q, add to localapi handler func in test", r.URL.Path)
|
||||
}
|
||||
})}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"tailscale.com/clientupdate/distsign"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/util/cmpver"
|
||||
"tailscale.com/util/winutil"
|
||||
"tailscale.com/version"
|
||||
"tailscale.com/version/distro"
|
||||
@@ -72,11 +73,12 @@ type Arguments struct {
|
||||
//
|
||||
// Leaving this empty is the same as using CurrentTrack.
|
||||
Version string
|
||||
// AppStore forces a local app store check, even if the current binary was
|
||||
// not installed via an app store. TODO(cpalmer): Remove this.
|
||||
AppStore bool
|
||||
// Logf is a logger for update progress messages.
|
||||
Logf logger.Logf
|
||||
// Stdout and Stderr should be used for output instead of os.Stdout and
|
||||
// os.Stderr.
|
||||
Stdout io.Writer
|
||||
Stderr io.Writer
|
||||
// Confirm is called when a new version is available and should return true
|
||||
// if this new version should be installed. When Confirm returns false, the
|
||||
// update is aborted.
|
||||
@@ -108,6 +110,12 @@ func NewUpdater(args Arguments) (*Updater, error) {
|
||||
up := Updater{
|
||||
Arguments: args,
|
||||
}
|
||||
if up.Stdout == nil {
|
||||
up.Stdout = os.Stdout
|
||||
}
|
||||
if up.Stderr == nil {
|
||||
up.Stderr = os.Stderr
|
||||
}
|
||||
up.Update = up.getUpdateFunction()
|
||||
if up.Update == nil {
|
||||
return nil, errors.ErrUnsupported
|
||||
@@ -171,12 +179,12 @@ func (up *Updater) getUpdateFunction() updateFunction {
|
||||
}
|
||||
case "darwin":
|
||||
switch {
|
||||
case !up.Arguments.AppStore && !version.IsSandboxedMacOS():
|
||||
return nil
|
||||
case !up.Arguments.AppStore && strings.HasSuffix(os.Getenv("HOME"), "/io.tailscale.ipn.macsys/Data"):
|
||||
case version.IsMacAppStore():
|
||||
return up.updateMacAppStore
|
||||
case version.IsMacSysExt():
|
||||
return up.updateMacSys
|
||||
default:
|
||||
return up.updateMacAppStore
|
||||
return nil
|
||||
}
|
||||
case "freebsd":
|
||||
return up.updateFreeBSD
|
||||
@@ -201,9 +209,13 @@ func Update(args Arguments) error {
|
||||
}
|
||||
|
||||
func (up *Updater) confirm(ver string) bool {
|
||||
if version.Short() == ver {
|
||||
switch cmpver.Compare(version.Short(), ver) {
|
||||
case 0:
|
||||
up.Logf("already running %v; no update needed", ver)
|
||||
return false
|
||||
case 1:
|
||||
up.Logf("installed version %v is newer than the latest available version %v; no update needed", version.Short(), ver)
|
||||
return false
|
||||
}
|
||||
if up.Confirm != nil {
|
||||
return up.Confirm(ver)
|
||||
@@ -256,9 +268,9 @@ func (up *Updater) updateSynology() error {
|
||||
// connected over tailscale ssh and this parent process dies. Otherwise, if
|
||||
// you abort synopkg install mid-way, tailscaled is not restarted.
|
||||
cmd := exec.Command("nohup", "synopkg", "install", spkPath)
|
||||
// Don't attach cmd.Stdout to os.Stdout because nohup will redirect that
|
||||
// into nohup.out file. synopkg doesn't have any progress output anyway, it
|
||||
// just spits out a JSON result when done.
|
||||
// Don't attach cmd.Stdout to Stdout because nohup will redirect that into
|
||||
// nohup.out file. synopkg doesn't have any progress output anyway, it just
|
||||
// spits out a JSON result when done.
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
if dsmVersion == 6 && bytes.Contains(out, []byte("error = [290]")) {
|
||||
@@ -369,15 +381,15 @@ func (up *Updater) updateDebLike() error {
|
||||
// we're not updating them:
|
||||
"-o", "APT::Get::List-Cleanup=0",
|
||||
)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Stdout = up.Stdout
|
||||
cmd.Stderr = up.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd = exec.Command("apt-get", "install", "--yes", "--allow-downgrades", "tailscale="+ver)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Stdout = up.Stdout
|
||||
cmd.Stderr = up.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -491,8 +503,8 @@ func (up *Updater) updateFedoraLike(packageManager string) func() error {
|
||||
}
|
||||
|
||||
cmd := exec.Command(packageManager, "install", "--assumeyes", fmt.Sprintf("tailscale-%s-1", ver))
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Stdout = up.Stdout
|
||||
cmd.Stderr = up.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -577,8 +589,8 @@ func (up *Updater) updateAlpineLike() (err error) {
|
||||
}
|
||||
|
||||
cmd := exec.Command("apk", "upgrade", "tailscale")
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Stdout = up.Stdout
|
||||
cmd.Stderr = up.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed tailscale update using apk: %w", err)
|
||||
}
|
||||
@@ -608,55 +620,17 @@ func (up *Updater) updateMacSys() error {
|
||||
}
|
||||
|
||||
func (up *Updater) updateMacAppStore() error {
|
||||
out, err := exec.Command("defaults", "read", "/Library/Preferences/com.apple.commerce.plist", "AutoUpdate").CombinedOutput()
|
||||
// We can't trigger the update via App Store from the sandboxed app. At
|
||||
// most, we can open the App Store page for them.
|
||||
up.Logf("Please use the App Store to update Tailscale.\nConsider enabling Automatic Updates in the App Store Settings, if you haven't already.\nOpening the Tailscale app page...")
|
||||
|
||||
out, err := exec.Command("open", "https://apps.apple.com/us/app/tailscale/id1475387142").CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("can't check App Store auto-update setting: %w, output: %q", err, string(out))
|
||||
}
|
||||
const on = "1\n"
|
||||
if string(out) != on {
|
||||
up.Logf("NOTE: Automatic updating for App Store apps is turned off. You can change this setting in System Settings (search for ‘update’).")
|
||||
}
|
||||
|
||||
out, err = exec.Command("softwareupdate", "--list").CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("can't check App Store for available updates: %w, output: %q", err, string(out))
|
||||
}
|
||||
|
||||
newTailscale := parseSoftwareupdateList(out)
|
||||
if newTailscale == "" {
|
||||
up.Logf("no Tailscale update available")
|
||||
return nil
|
||||
}
|
||||
|
||||
newTailscaleVer := strings.TrimPrefix(newTailscale, "Tailscale-")
|
||||
if !up.confirm(newTailscaleVer) {
|
||||
return nil
|
||||
}
|
||||
|
||||
cmd := exec.Command("sudo", "softwareupdate", "--install", newTailscale)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("can't install App Store update for Tailscale: %w", err)
|
||||
return fmt.Errorf("can't open the Tailscale page in App Store: %w, output: %q", err, string(out))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var macOSAppStoreListPattern = regexp.MustCompile(`(?m)^\s+\*\s+Label:\s*(Tailscale-\d[\d\.]+)`)
|
||||
|
||||
// parseSoftwareupdateList searches the output of `softwareupdate --list` on
|
||||
// Darwin and returns the matching Tailscale package label. If there is none,
|
||||
// returns the empty string.
|
||||
//
|
||||
// See TestParseSoftwareupdateList for example inputs.
|
||||
func parseSoftwareupdateList(stdout []byte) string {
|
||||
matches := macOSAppStoreListPattern.FindSubmatch(stdout)
|
||||
if len(matches) < 2 {
|
||||
return ""
|
||||
}
|
||||
return string(matches[1])
|
||||
}
|
||||
|
||||
// winMSIEnv is the environment variable that, if set, is the MSI file for the
|
||||
// update command to install. It's passed like this so we can stop the
|
||||
// tailscale.exe process from running before the msiexec process runs and tries
|
||||
@@ -726,8 +700,8 @@ func (up *Updater) updateWindows() error {
|
||||
|
||||
cmd := exec.Command(selfCopy, "update")
|
||||
cmd.Env = append(os.Environ(), winMSIEnv+"="+msiTarget)
|
||||
cmd.Stdout = os.Stderr
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Stdout = up.Stderr
|
||||
cmd.Stderr = up.Stderr
|
||||
cmd.Stdin = os.Stdin
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
@@ -743,8 +717,8 @@ func (up *Updater) installMSI(msi string) error {
|
||||
for tries := 0; tries < 2; tries++ {
|
||||
cmd := exec.Command("msiexec.exe", "/i", filepath.Base(msi), "/quiet", "/promptrestart", "/qn")
|
||||
cmd.Dir = filepath.Dir(msi)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Stdout = up.Stdout
|
||||
cmd.Stderr = up.Stderr
|
||||
cmd.Stdin = os.Stdin
|
||||
err = cmd.Run()
|
||||
if err == nil {
|
||||
@@ -757,8 +731,8 @@ func (up *Updater) installMSI(msi string) error {
|
||||
// Assume it's a downgrade, which msiexec won't permit. Uninstall our current version first.
|
||||
up.Logf("Uninstalling current version %q for downgrade...", uninstallVersion)
|
||||
cmd = exec.Command("msiexec.exe", "/x", msiUUIDForVersion(uninstallVersion), "/norestart", "/qn")
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Stdout = up.Stdout
|
||||
cmd.Stderr = up.Stderr
|
||||
cmd.Stdin = os.Stdin
|
||||
err = cmd.Run()
|
||||
up.Logf("msiexec uninstall: %v", err)
|
||||
@@ -846,8 +820,8 @@ func (up *Updater) updateFreeBSD() (err error) {
|
||||
}
|
||||
|
||||
cmd := exec.Command("pkg", "upgrade", "tailscale")
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Stdout = up.Stdout
|
||||
cmd.Stderr = up.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed tailscale update using pkg: %w", err)
|
||||
}
|
||||
@@ -894,7 +868,7 @@ func (up *Updater) updateLinuxBinary() error {
|
||||
func (up *Updater) downloadLinuxTarball(ver string) (string, error) {
|
||||
dlDir, err := os.UserCacheDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
dlDir = os.TempDir()
|
||||
}
|
||||
dlDir = filepath.Join(dlDir, "tailscale-update")
|
||||
if err := os.MkdirAll(dlDir, 0700); err != nil {
|
||||
|
||||
@@ -84,84 +84,6 @@ func TestUpdateDebianAptSourcesListBytes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSoftwareupdateList(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input []byte
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "update-at-end-of-list",
|
||||
input: []byte(`
|
||||
Software Update Tool
|
||||
|
||||
Finding available software
|
||||
Software Update found the following new or updated software:
|
||||
* Label: MacBookAirEFIUpdate2.4-2.4
|
||||
Title: MacBook Air EFI Firmware Update, Version: 2.4, Size: 3817K, Recommended: YES, Action: restart,
|
||||
* Label: ProAppsQTCodecs-1.0
|
||||
Title: ProApps QuickTime codecs, Version: 1.0, Size: 968K, Recommended: YES,
|
||||
* Label: Tailscale-1.23.4
|
||||
Title: The Tailscale VPN, Version: 1.23.4, Size: 1023K, Recommended: YES,
|
||||
`),
|
||||
want: "Tailscale-1.23.4",
|
||||
},
|
||||
{
|
||||
name: "update-in-middle-of-list",
|
||||
input: []byte(`
|
||||
Software Update Tool
|
||||
|
||||
Finding available software
|
||||
Software Update found the following new or updated software:
|
||||
* Label: MacBookAirEFIUpdate2.4-2.4
|
||||
Title: MacBook Air EFI Firmware Update, Version: 2.4, Size: 3817K, Recommended: YES, Action: restart,
|
||||
* Label: Tailscale-1.23.5000
|
||||
Title: The Tailscale VPN, Version: 1.23.4, Size: 1023K, Recommended: YES,
|
||||
* Label: ProAppsQTCodecs-1.0
|
||||
Title: ProApps QuickTime codecs, Version: 1.0, Size: 968K, Recommended: YES,
|
||||
`),
|
||||
want: "Tailscale-1.23.5000",
|
||||
},
|
||||
{
|
||||
name: "update-not-in-list",
|
||||
input: []byte(`
|
||||
Software Update Tool
|
||||
|
||||
Finding available software
|
||||
Software Update found the following new or updated software:
|
||||
* Label: MacBookAirEFIUpdate2.4-2.4
|
||||
Title: MacBook Air EFI Firmware Update, Version: 2.4, Size: 3817K, Recommended: YES, Action: restart,
|
||||
* Label: ProAppsQTCodecs-1.0
|
||||
Title: ProApps QuickTime codecs, Version: 1.0, Size: 968K, Recommended: YES,
|
||||
`),
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "decoy-in-list",
|
||||
input: []byte(`
|
||||
Software Update Tool
|
||||
|
||||
Finding available software
|
||||
Software Update found the following new or updated software:
|
||||
* Label: MacBookAirEFIUpdate2.4-2.4
|
||||
Title: MacBook Air EFI Firmware Update, Version: 2.4, Size: 3817K, Recommended: YES, Action: restart,
|
||||
* Label: Malware-1.0
|
||||
Title: * Label: Tailscale-0.99.0, Version: 1.0, Size: 968K, Recommended: NOT REALLY TBH,
|
||||
`),
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got := parseSoftwareupdateList(test.input)
|
||||
if test.want != got {
|
||||
t.Fatalf("got %q, want %q", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateYUMRepoTrack(t *testing.T) {
|
||||
tests := []struct {
|
||||
desc string
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
-----BEGIN ROOT PUBLIC KEY-----
|
||||
Muw5GkO5mASsJ7k6kS+svfuanr6XcW9I7fPGtyqOTeI=
|
||||
ZjjKhUHBtLNRSO1dhOTjrXJGJ8lDe1594WM2XDuheVQ=
|
||||
-----END ROOT PUBLIC KEY-----
|
||||
@@ -19,8 +19,7 @@
|
||||
// - TS_TAILNET_TARGET_IP: proxy all incoming non-Tailscale traffic to the given
|
||||
// destination.
|
||||
// - TS_TAILSCALED_EXTRA_ARGS: extra arguments to 'tailscaled'.
|
||||
// - TS_EXTRA_ARGS: extra arguments to 'tailscale login', these are not
|
||||
// reset on restart.
|
||||
// - TS_EXTRA_ARGS: extra arguments to 'tailscale up'.
|
||||
// - TS_USERSPACE: run with userspace networking (the default)
|
||||
// instead of kernel networking.
|
||||
// - TS_STATE_DIR: the directory in which to store tailscaled
|
||||
@@ -36,15 +35,9 @@
|
||||
// - TS_SOCKET: the path where the tailscaled LocalAPI socket should
|
||||
// be created.
|
||||
// - TS_AUTH_ONCE: if true, only attempt to log in if not already
|
||||
// logged in. If false, forcibly log in every time the container starts.
|
||||
// The default until 1.50.0 was false, but that was misleading: until
|
||||
// 1.50, containerboot used `tailscale up` which would ignore an authkey
|
||||
// argument if there was already a node key. Effectively, this behaved
|
||||
// as though TS_AUTH_ONCE were always true.
|
||||
// In 1.50.0 the change was made to use `tailscale login` instead of `up`,
|
||||
// and login will reauthenticate every time it is given an authkey.
|
||||
// In 1.50.1 we set the TS_AUTH_ONCE to true, to match the previously
|
||||
// observed behavior.
|
||||
// logged in. If false (the default, for backwards
|
||||
// compatibility), forcibly log in every time the
|
||||
// container starts.
|
||||
// - TS_SERVE_CONFIG: if specified, is the file path where the ipn.ServeConfig is located.
|
||||
// It will be applied once tailscaled is up and running. If the file contains
|
||||
// ${TS_CERT_DOMAIN}, it will be replaced with the value of the available FQDN.
|
||||
@@ -84,10 +77,19 @@ import (
|
||||
"golang.org/x/sys/unix"
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/types/ptr"
|
||||
"tailscale.com/util/deephash"
|
||||
"tailscale.com/util/linuxfw"
|
||||
)
|
||||
|
||||
func newNetfilterRunner(logf logger.Logf) (linuxfw.NetfilterRunner, error) {
|
||||
if defaultBool("TS_TEST_FAKE_NETFILTER", false) {
|
||||
return linuxfw.NewFakeIPTablesRunner(), nil
|
||||
}
|
||||
return linuxfw.New(logf)
|
||||
}
|
||||
|
||||
func main() {
|
||||
log.SetPrefix("boot: ")
|
||||
tailscale.I_Acknowledge_This_API_Is_Unstable = true
|
||||
@@ -109,7 +111,7 @@ func main() {
|
||||
SOCKSProxyAddr: defaultEnv("TS_SOCKS5_SERVER", ""),
|
||||
HTTPProxyAddr: defaultEnv("TS_OUTBOUND_HTTP_PROXY_LISTEN", ""),
|
||||
Socket: defaultEnv("TS_SOCKET", "/tmp/tailscaled.sock"),
|
||||
AuthOnce: defaultBool("TS_AUTH_ONCE", true),
|
||||
AuthOnce: defaultBool("TS_AUTH_ONCE", false),
|
||||
Root: defaultEnv("TS_TEST_ONLY_ROOT", "/"),
|
||||
}
|
||||
|
||||
@@ -203,7 +205,7 @@ func main() {
|
||||
}
|
||||
didLogin = true
|
||||
w.Close()
|
||||
if err := tailscaleLogin(bootCtx, cfg); err != nil {
|
||||
if err := tailscaleUp(bootCtx, cfg); err != nil {
|
||||
return fmt.Errorf("failed to auth tailscale: %v", err)
|
||||
}
|
||||
w, err = client.WatchIPNBus(bootCtx, ipn.NotifyInitialNetMap|ipn.NotifyInitialState)
|
||||
@@ -253,10 +255,12 @@ authLoop:
|
||||
ctx, cancel := context.WithCancel(context.Background()) // no deadline now that we're in steady state
|
||||
defer cancel()
|
||||
|
||||
// Now that we are authenticated, we can set/reset any of the
|
||||
// settings that we need to.
|
||||
if err := tailscaleSet(ctx, cfg); err != nil {
|
||||
log.Fatalf("failed to auth tailscale: %v", err)
|
||||
if cfg.AuthOnce {
|
||||
// Now that we are authenticated, we can set/reset any of the
|
||||
// settings that we need to.
|
||||
if err := tailscaleSet(ctx, cfg); err != nil {
|
||||
log.Fatalf("failed to auth tailscale: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.ServeConfigPath != "" {
|
||||
@@ -295,6 +299,13 @@ authLoop:
|
||||
if cfg.ServeConfigPath != "" {
|
||||
go watchServeConfigChanges(ctx, cfg.ServeConfigPath, certDomainChanged, certDomain, client)
|
||||
}
|
||||
var nfr linuxfw.NetfilterRunner
|
||||
if wantProxy {
|
||||
nfr, err = newNetfilterRunner(log.Printf)
|
||||
if err != nil {
|
||||
log.Fatalf("error creating new netfilter runner: %v", err)
|
||||
}
|
||||
}
|
||||
for {
|
||||
n, err := w.Next()
|
||||
if err != nil {
|
||||
@@ -315,7 +326,7 @@ authLoop:
|
||||
ipsHaveChanged := newCurrentIPs != currentIPs
|
||||
if cfg.ProxyTo != "" && len(addrs) > 0 && ipsHaveChanged {
|
||||
log.Printf("Installing proxy rules")
|
||||
if err := installIngressForwardingRule(ctx, cfg.ProxyTo, addrs); err != nil {
|
||||
if err := installIngressForwardingRule(ctx, cfg.ProxyTo, addrs, nfr); err != nil {
|
||||
log.Fatalf("installing ingress proxy rules: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -330,7 +341,7 @@ authLoop:
|
||||
}
|
||||
}
|
||||
if cfg.TailnetTargetIP != "" && ipsHaveChanged && len(addrs) > 0 {
|
||||
if err := installEgressForwardingRule(ctx, cfg.TailnetTargetIP, addrs); err != nil {
|
||||
if err := installEgressForwardingRule(ctx, cfg.TailnetTargetIP, addrs, nfr); err != nil {
|
||||
log.Fatalf("installing egress proxy rules: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -385,19 +396,20 @@ func watchServeConfigChanges(ctx context.Context, path string, cdChanged <-chan
|
||||
panic("cd must not be nil")
|
||||
}
|
||||
var tickChan <-chan time.Time
|
||||
w, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
var eventChan <-chan fsnotify.Event
|
||||
if w, err := fsnotify.NewWatcher(); err != nil {
|
||||
log.Printf("failed to create fsnotify watcher, timer-only mode: %v", err)
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
tickChan = ticker.C
|
||||
} else {
|
||||
defer w.Close()
|
||||
if err := w.Add(filepath.Dir(path)); err != nil {
|
||||
log.Fatalf("failed to add fsnotify watch: %v", err)
|
||||
}
|
||||
eventChan = w.Events
|
||||
}
|
||||
|
||||
if err := w.Add(filepath.Dir(path)); err != nil {
|
||||
log.Fatalf("failed to add fsnotify watch: %v", err)
|
||||
}
|
||||
var certDomain string
|
||||
var prevServeConfig *ipn.ServeConfig
|
||||
for {
|
||||
@@ -407,7 +419,7 @@ func watchServeConfigChanges(ctx context.Context, path string, cdChanged <-chan
|
||||
case <-cdChanged:
|
||||
certDomain = *certDomainAtomic.Load()
|
||||
case <-tickChan:
|
||||
case <-w.Events:
|
||||
case <-eventChan:
|
||||
// We can't do any reasonable filtering on the event because of how
|
||||
// k8s handles these mounts. So just re-read the file and apply it
|
||||
// if it's changed.
|
||||
@@ -528,29 +540,40 @@ func tailscaledArgs(cfg *settings) []string {
|
||||
return args
|
||||
}
|
||||
|
||||
// tailscaleLogin uses cfg to run 'tailscale login' everytime containerboot
|
||||
// starts, or if TS_AUTH_ONCE is set, only the first time containerboot starts.
|
||||
func tailscaleLogin(ctx context.Context, cfg *settings) error {
|
||||
args := []string{"--socket=" + cfg.Socket, "login"}
|
||||
// tailscaleUp uses cfg to run 'tailscale up' everytime containerboot starts, or
|
||||
// if TS_AUTH_ONCE is set, only the first time containerboot starts.
|
||||
func tailscaleUp(ctx context.Context, cfg *settings) error {
|
||||
args := []string{"--socket=" + cfg.Socket, "up"}
|
||||
if cfg.AcceptDNS {
|
||||
args = append(args, "--accept-dns=true")
|
||||
} else {
|
||||
args = append(args, "--accept-dns=false")
|
||||
}
|
||||
if cfg.AuthKey != "" {
|
||||
args = append(args, "--authkey="+cfg.AuthKey)
|
||||
}
|
||||
if cfg.Routes != "" {
|
||||
args = append(args, "--advertise-routes="+cfg.Routes)
|
||||
}
|
||||
if cfg.Hostname != "" {
|
||||
args = append(args, "--hostname="+cfg.Hostname)
|
||||
}
|
||||
if cfg.ExtraArgs != "" {
|
||||
args = append(args, strings.Fields(cfg.ExtraArgs)...)
|
||||
}
|
||||
log.Printf("Running 'tailscale login'")
|
||||
log.Printf("Running 'tailscale up'")
|
||||
cmd := exec.CommandContext(ctx, "tailscale", args...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("tailscale login failed: %v", err)
|
||||
return fmt.Errorf("tailscale up failed: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// tailscaleSet uses cfg to run 'tailscale set' to set any known configuration
|
||||
// options that are passed in via environment variables. This is run after the
|
||||
// node is in Running state.
|
||||
// node is in Running state and only if TS_AUTH_ONCE is set.
|
||||
func tailscaleSet(ctx context.Context, cfg *settings) error {
|
||||
args := []string{"--socket=" + cfg.Socket, "set"}
|
||||
if cfg.AcceptDNS {
|
||||
@@ -662,16 +685,12 @@ func ensureIPForwarding(root, clusterProxyTarget, tailnetTargetiP, routes string
|
||||
return nil
|
||||
}
|
||||
|
||||
func installEgressForwardingRule(ctx context.Context, dstStr string, tsIPs []netip.Prefix) error {
|
||||
func installEgressForwardingRule(ctx context.Context, dstStr string, tsIPs []netip.Prefix, nfr linuxfw.NetfilterRunner) error {
|
||||
dst, err := netip.ParseAddr(dstStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
argv0 := "iptables"
|
||||
if dst.Is6() {
|
||||
argv0 = "ip6tables"
|
||||
}
|
||||
var local string
|
||||
var local netip.Addr
|
||||
for _, pfx := range tsIPs {
|
||||
if !pfx.IsSingleIP() {
|
||||
continue
|
||||
@@ -679,52 +698,30 @@ func installEgressForwardingRule(ctx context.Context, dstStr string, tsIPs []net
|
||||
if pfx.Addr().Is4() != dst.Is4() {
|
||||
continue
|
||||
}
|
||||
local = pfx.Addr().String()
|
||||
local = pfx.Addr()
|
||||
break
|
||||
}
|
||||
if local == "" {
|
||||
if !local.IsValid() {
|
||||
return fmt.Errorf("no tailscale IP matching family of %s found in %v", dstStr, tsIPs)
|
||||
}
|
||||
// Technically, if the control server ever changes the IPs assigned to this
|
||||
// node, we'll slowly accumulate iptables rules. This shouldn't happen, so
|
||||
// for now we'll live with it.
|
||||
// Set up a rule that ensures that all packets
|
||||
// except for those received on tailscale0 interface is forwarded to
|
||||
// destination address
|
||||
cmdDNAT := exec.CommandContext(ctx, argv0, "-t", "nat", "-I", "PREROUTING", "1", "!", "-i", "tailscale0", "-j", "DNAT", "--to-destination", dstStr)
|
||||
cmdDNAT.Stdout = os.Stdout
|
||||
cmdDNAT.Stderr = os.Stderr
|
||||
if err := cmdDNAT.Run(); err != nil {
|
||||
return fmt.Errorf("executing iptables failed: %w", err)
|
||||
if err := nfr.DNATNonTailscaleTraffic("tailscale0", dst); err != nil {
|
||||
return fmt.Errorf("installing egress proxy rules: %w", err)
|
||||
}
|
||||
// Set up a rule that ensures that all packets sent to the destination
|
||||
// address will have the proxy's IP set as source IP
|
||||
cmdSNAT := exec.CommandContext(ctx, argv0, "-t", "nat", "-I", "POSTROUTING", "1", "--destination", dstStr, "-j", "SNAT", "--to-source", local)
|
||||
cmdSNAT.Stdout = os.Stdout
|
||||
cmdSNAT.Stderr = os.Stderr
|
||||
if err := cmdSNAT.Run(); err != nil {
|
||||
return fmt.Errorf("setting up SNAT via iptables failed: %w", err)
|
||||
if err := nfr.AddSNATRuleForDst(local, dst); err != nil {
|
||||
return fmt.Errorf("installing egress proxy rules: %w", err)
|
||||
}
|
||||
|
||||
cmdClamp := exec.CommandContext(ctx, argv0, "-t", "mangle", "-A", "FORWARD", "-o", "tailscale0", "-p", "tcp", "-m", "tcp", "--tcp-flags", "SYN,RST", "SYN", "-j", "TCPMSS", "--clamp-mss-to-pmtu")
|
||||
cmdClamp.Stdout = os.Stdout
|
||||
cmdClamp.Stderr = os.Stderr
|
||||
if err := cmdClamp.Run(); err != nil {
|
||||
return fmt.Errorf("executing iptables failed: %w", err)
|
||||
if err := nfr.ClampMSSToPMTU("tailscale0", dst); err != nil {
|
||||
return fmt.Errorf("installing egress proxy rules: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func installIngressForwardingRule(ctx context.Context, dstStr string, tsIPs []netip.Prefix) error {
|
||||
func installIngressForwardingRule(ctx context.Context, dstStr string, tsIPs []netip.Prefix, nfr linuxfw.NetfilterRunner) error {
|
||||
dst, err := netip.ParseAddr(dstStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
argv0 := "iptables"
|
||||
if dst.Is6() {
|
||||
argv0 = "ip6tables"
|
||||
}
|
||||
var local string
|
||||
var local netip.Addr
|
||||
for _, pfx := range tsIPs {
|
||||
if !pfx.IsSingleIP() {
|
||||
continue
|
||||
@@ -732,26 +729,17 @@ func installIngressForwardingRule(ctx context.Context, dstStr string, tsIPs []ne
|
||||
if pfx.Addr().Is4() != dst.Is4() {
|
||||
continue
|
||||
}
|
||||
local = pfx.Addr().String()
|
||||
local = pfx.Addr()
|
||||
break
|
||||
}
|
||||
if local == "" {
|
||||
if !local.IsValid() {
|
||||
return fmt.Errorf("no tailscale IP matching family of %s found in %v", dstStr, tsIPs)
|
||||
}
|
||||
// Technically, if the control server ever changes the IPs assigned to this
|
||||
// node, we'll slowly accumulate iptables rules. This shouldn't happen, so
|
||||
// for now we'll live with it.
|
||||
cmd := exec.CommandContext(ctx, argv0, "-t", "nat", "-I", "PREROUTING", "1", "-d", local, "-j", "DNAT", "--to-destination", dstStr)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("executing iptables failed: %w", err)
|
||||
if err := nfr.AddDNATRule(local, dst); err != nil {
|
||||
return fmt.Errorf("installing ingress proxy rules: %w", err)
|
||||
}
|
||||
cmdClamp := exec.CommandContext(ctx, argv0, "-t", "mangle", "-A", "FORWARD", "-o", "tailscale0", "-p", "tcp", "-m", "tcp", "--tcp-flags", "SYN,RST", "SYN", "-j", "TCPMSS", "--clamp-mss-to-pmtu")
|
||||
cmdClamp.Stdout = os.Stdout
|
||||
cmdClamp.Stderr = os.Stderr
|
||||
if err := cmdClamp.Run(); err != nil {
|
||||
return fmt.Errorf("executing iptables failed: %w", err)
|
||||
if err := nfr.ClampMSSToPMTU("tailscale0", dst); err != nil {
|
||||
return fmt.Errorf("installing ingress proxy rules: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -129,22 +129,16 @@ func TestContainerBoot(t *testing.T) {
|
||||
{
|
||||
// Out of the box default: runs in userspace mode, ephemeral storage, interactive login.
|
||||
Name: "no_args",
|
||||
Env: map[string]string{
|
||||
"TS_AUTH_ONCE": "false",
|
||||
},
|
||||
|
||||
Env: nil,
|
||||
Phases: []phase{
|
||||
{
|
||||
WantCmds: []string{
|
||||
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false",
|
||||
},
|
||||
},
|
||||
{
|
||||
Notify: runningNotify,
|
||||
WantCmds: []string{
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -152,21 +146,17 @@ func TestContainerBoot(t *testing.T) {
|
||||
// Userspace mode, ephemeral storage, authkey provided on every run.
|
||||
Name: "authkey",
|
||||
Env: map[string]string{
|
||||
"TS_AUTHKEY": "tskey-key",
|
||||
"TS_AUTH_ONCE": "false",
|
||||
"TS_AUTHKEY": "tskey-key",
|
||||
},
|
||||
Phases: []phase{
|
||||
{
|
||||
WantCmds: []string{
|
||||
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login --authkey=tskey-key",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key",
|
||||
},
|
||||
},
|
||||
{
|
||||
Notify: runningNotify,
|
||||
WantCmds: []string{
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -174,21 +164,17 @@ func TestContainerBoot(t *testing.T) {
|
||||
// Userspace mode, ephemeral storage, authkey provided on every run.
|
||||
Name: "authkey-old-flag",
|
||||
Env: map[string]string{
|
||||
"TS_AUTH_KEY": "tskey-key",
|
||||
"TS_AUTH_ONCE": "false",
|
||||
"TS_AUTH_KEY": "tskey-key",
|
||||
},
|
||||
Phases: []phase{
|
||||
{
|
||||
WantCmds: []string{
|
||||
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login --authkey=tskey-key",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key",
|
||||
},
|
||||
},
|
||||
{
|
||||
Notify: runningNotify,
|
||||
WantCmds: []string{
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -197,35 +183,30 @@ func TestContainerBoot(t *testing.T) {
|
||||
Env: map[string]string{
|
||||
"TS_AUTHKEY": "tskey-key",
|
||||
"TS_STATE_DIR": filepath.Join(d, "tmp"),
|
||||
"TS_AUTH_ONCE": "false",
|
||||
},
|
||||
Phases: []phase{
|
||||
{
|
||||
WantCmds: []string{
|
||||
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --statedir=/tmp --tun=userspace-networking",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login --authkey=tskey-key",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key",
|
||||
},
|
||||
},
|
||||
{
|
||||
Notify: runningNotify,
|
||||
WantCmds: []string{
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "routes",
|
||||
Env: map[string]string{
|
||||
"TS_AUTHKEY": "tskey-key",
|
||||
"TS_ROUTES": "1.2.3.0/24,10.20.30.0/24",
|
||||
"TS_AUTH_ONCE": "false",
|
||||
"TS_AUTHKEY": "tskey-key",
|
||||
"TS_ROUTES": "1.2.3.0/24,10.20.30.0/24",
|
||||
},
|
||||
Phases: []phase{
|
||||
{
|
||||
WantCmds: []string{
|
||||
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login --authkey=tskey-key",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key --advertise-routes=1.2.3.0/24,10.20.30.0/24",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -234,9 +215,6 @@ func TestContainerBoot(t *testing.T) {
|
||||
"proc/sys/net/ipv4/ip_forward": "0",
|
||||
"proc/sys/net/ipv6/conf/all/forwarding": "0",
|
||||
},
|
||||
WantCmds: []string{
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false --advertise-routes=1.2.3.0/24,10.20.30.0/24",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -246,13 +224,12 @@ func TestContainerBoot(t *testing.T) {
|
||||
"TS_AUTHKEY": "tskey-key",
|
||||
"TS_ROUTES": "1.2.3.0/24,10.20.30.0/24",
|
||||
"TS_USERSPACE": "false",
|
||||
"TS_AUTH_ONCE": "false",
|
||||
},
|
||||
Phases: []phase{
|
||||
{
|
||||
WantCmds: []string{
|
||||
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login --authkey=tskey-key",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key --advertise-routes=1.2.3.0/24,10.20.30.0/24",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -261,9 +238,6 @@ func TestContainerBoot(t *testing.T) {
|
||||
"proc/sys/net/ipv4/ip_forward": "1",
|
||||
"proc/sys/net/ipv6/conf/all/forwarding": "0",
|
||||
},
|
||||
WantCmds: []string{
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false --advertise-routes=1.2.3.0/24,10.20.30.0/24",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -273,13 +247,12 @@ func TestContainerBoot(t *testing.T) {
|
||||
"TS_AUTHKEY": "tskey-key",
|
||||
"TS_ROUTES": "::/64,1::/64",
|
||||
"TS_USERSPACE": "false",
|
||||
"TS_AUTH_ONCE": "false",
|
||||
},
|
||||
Phases: []phase{
|
||||
{
|
||||
WantCmds: []string{
|
||||
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login --authkey=tskey-key",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key --advertise-routes=::/64,1::/64",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -288,9 +261,6 @@ func TestContainerBoot(t *testing.T) {
|
||||
"proc/sys/net/ipv4/ip_forward": "0",
|
||||
"proc/sys/net/ipv6/conf/all/forwarding": "1",
|
||||
},
|
||||
WantCmds: []string{
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false --advertise-routes=::/64,1::/64",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -300,13 +270,12 @@ func TestContainerBoot(t *testing.T) {
|
||||
"TS_AUTHKEY": "tskey-key",
|
||||
"TS_ROUTES": "::/64,1.2.3.0/24",
|
||||
"TS_USERSPACE": "false",
|
||||
"TS_AUTH_ONCE": "false",
|
||||
},
|
||||
Phases: []phase{
|
||||
{
|
||||
WantCmds: []string{
|
||||
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login --authkey=tskey-key",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key --advertise-routes=::/64,1.2.3.0/24",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -315,9 +284,6 @@ func TestContainerBoot(t *testing.T) {
|
||||
"proc/sys/net/ipv4/ip_forward": "1",
|
||||
"proc/sys/net/ipv6/conf/all/forwarding": "1",
|
||||
},
|
||||
WantCmds: []string{
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false --advertise-routes=::/64,1.2.3.0/24",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -327,22 +293,16 @@ func TestContainerBoot(t *testing.T) {
|
||||
"TS_AUTHKEY": "tskey-key",
|
||||
"TS_DEST_IP": "1.2.3.4",
|
||||
"TS_USERSPACE": "false",
|
||||
"TS_AUTH_ONCE": "false",
|
||||
},
|
||||
Phases: []phase{
|
||||
{
|
||||
WantCmds: []string{
|
||||
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login --authkey=tskey-key",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key",
|
||||
},
|
||||
},
|
||||
{
|
||||
Notify: runningNotify,
|
||||
WantCmds: []string{
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false",
|
||||
"/usr/bin/iptables -t nat -I PREROUTING 1 -d 100.64.0.1 -j DNAT --to-destination 1.2.3.4",
|
||||
"/usr/bin/iptables -t mangle -A FORWARD -o tailscale0 -p tcp -m tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -352,23 +312,16 @@ func TestContainerBoot(t *testing.T) {
|
||||
"TS_AUTHKEY": "tskey-key",
|
||||
"TS_TAILNET_TARGET_IP": "100.99.99.99",
|
||||
"TS_USERSPACE": "false",
|
||||
"TS_AUTH_ONCE": "false",
|
||||
},
|
||||
Phases: []phase{
|
||||
{
|
||||
WantCmds: []string{
|
||||
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login --authkey=tskey-key",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key",
|
||||
},
|
||||
},
|
||||
{
|
||||
Notify: runningNotify,
|
||||
WantCmds: []string{
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false",
|
||||
"/usr/bin/iptables -t nat -I PREROUTING 1 ! -i tailscale0 -j DNAT --to-destination 100.99.99.99",
|
||||
"/usr/bin/iptables -t nat -I POSTROUTING 1 --destination 100.99.99.99 -j SNAT --to-source 100.64.0.1",
|
||||
"/usr/bin/iptables -t mangle -A FORWARD -o tailscale0 -p tcp -m tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -389,7 +342,7 @@ func TestContainerBoot(t *testing.T) {
|
||||
State: ptr.To(ipn.NeedsLogin),
|
||||
},
|
||||
WantCmds: []string{
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login --authkey=tskey-key",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -405,7 +358,6 @@ func TestContainerBoot(t *testing.T) {
|
||||
Env: map[string]string{
|
||||
"KUBERNETES_SERVICE_HOST": kube.Host,
|
||||
"KUBERNETES_SERVICE_PORT_HTTPS": kube.Port,
|
||||
"TS_AUTH_ONCE": "false",
|
||||
},
|
||||
KubeSecret: map[string]string{
|
||||
"authkey": "tskey-key",
|
||||
@@ -414,7 +366,7 @@ func TestContainerBoot(t *testing.T) {
|
||||
{
|
||||
WantCmds: []string{
|
||||
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=kube:tailscale --statedir=/tmp --tun=userspace-networking",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login --authkey=tskey-key",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key",
|
||||
},
|
||||
WantKubeSecret: map[string]string{
|
||||
"authkey": "tskey-key",
|
||||
@@ -422,9 +374,6 @@ func TestContainerBoot(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Notify: runningNotify,
|
||||
WantCmds: []string{
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false",
|
||||
},
|
||||
WantKubeSecret: map[string]string{
|
||||
"authkey": "tskey-key",
|
||||
"device_fqdn": "test-node.test.ts.net",
|
||||
@@ -443,22 +392,18 @@ func TestContainerBoot(t *testing.T) {
|
||||
"TS_KUBE_SECRET": "",
|
||||
"TS_STATE_DIR": filepath.Join(d, "tmp"),
|
||||
"TS_AUTHKEY": "tskey-key",
|
||||
"TS_AUTH_ONCE": "false",
|
||||
},
|
||||
KubeSecret: map[string]string{},
|
||||
Phases: []phase{
|
||||
{
|
||||
WantCmds: []string{
|
||||
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --statedir=/tmp --tun=userspace-networking",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login --authkey=tskey-key",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key",
|
||||
},
|
||||
WantKubeSecret: map[string]string{},
|
||||
},
|
||||
{
|
||||
Notify: runningNotify,
|
||||
WantCmds: []string{
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false",
|
||||
},
|
||||
Notify: runningNotify,
|
||||
WantKubeSecret: map[string]string{},
|
||||
},
|
||||
},
|
||||
@@ -469,7 +414,6 @@ func TestContainerBoot(t *testing.T) {
|
||||
"KUBERNETES_SERVICE_HOST": kube.Host,
|
||||
"KUBERNETES_SERVICE_PORT_HTTPS": kube.Port,
|
||||
"TS_AUTHKEY": "tskey-key",
|
||||
"TS_AUTH_ONCE": "false",
|
||||
},
|
||||
KubeSecret: map[string]string{},
|
||||
KubeDenyPatch: true,
|
||||
@@ -477,15 +421,12 @@ func TestContainerBoot(t *testing.T) {
|
||||
{
|
||||
WantCmds: []string{
|
||||
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=kube:tailscale --statedir=/tmp --tun=userspace-networking",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login --authkey=tskey-key",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key",
|
||||
},
|
||||
WantKubeSecret: map[string]string{},
|
||||
},
|
||||
{
|
||||
Notify: runningNotify,
|
||||
WantCmds: []string{
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false",
|
||||
},
|
||||
Notify: runningNotify,
|
||||
WantKubeSecret: map[string]string{},
|
||||
},
|
||||
},
|
||||
@@ -515,7 +456,7 @@ func TestContainerBoot(t *testing.T) {
|
||||
State: ptr.To(ipn.NeedsLogin),
|
||||
},
|
||||
WantCmds: []string{
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login --authkey=tskey-key",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key",
|
||||
},
|
||||
WantKubeSecret: map[string]string{
|
||||
"authkey": "tskey-key",
|
||||
@@ -539,7 +480,6 @@ func TestContainerBoot(t *testing.T) {
|
||||
Env: map[string]string{
|
||||
"KUBERNETES_SERVICE_HOST": kube.Host,
|
||||
"KUBERNETES_SERVICE_PORT_HTTPS": kube.Port,
|
||||
"TS_AUTH_ONCE": "false",
|
||||
},
|
||||
KubeSecret: map[string]string{
|
||||
"authkey": "tskey-key",
|
||||
@@ -548,7 +488,7 @@ func TestContainerBoot(t *testing.T) {
|
||||
{
|
||||
WantCmds: []string{
|
||||
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=kube:tailscale --statedir=/tmp --tun=userspace-networking",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login --authkey=tskey-key",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key",
|
||||
},
|
||||
WantKubeSecret: map[string]string{
|
||||
"authkey": "tskey-key",
|
||||
@@ -556,9 +496,6 @@ func TestContainerBoot(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Notify: runningNotify,
|
||||
WantCmds: []string{
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false",
|
||||
},
|
||||
WantKubeSecret: map[string]string{
|
||||
"authkey": "tskey-key",
|
||||
"device_fqdn": "test-node.test.ts.net",
|
||||
@@ -591,20 +528,16 @@ func TestContainerBoot(t *testing.T) {
|
||||
Env: map[string]string{
|
||||
"TS_SOCKS5_SERVER": "localhost:1080",
|
||||
"TS_OUTBOUND_HTTP_PROXY_LISTEN": "localhost:8080",
|
||||
"TS_AUTH_ONCE": "false",
|
||||
},
|
||||
Phases: []phase{
|
||||
{
|
||||
WantCmds: []string{
|
||||
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking --socks5-server=localhost:1080 --outbound-http-proxy-listen=localhost:8080",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false",
|
||||
},
|
||||
},
|
||||
{
|
||||
Notify: runningNotify,
|
||||
WantCmds: []string{
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -612,20 +545,16 @@ func TestContainerBoot(t *testing.T) {
|
||||
Name: "dns",
|
||||
Env: map[string]string{
|
||||
"TS_ACCEPT_DNS": "true",
|
||||
"TS_AUTH_ONCE": "false",
|
||||
},
|
||||
Phases: []phase{
|
||||
{
|
||||
WantCmds: []string{
|
||||
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=true",
|
||||
},
|
||||
},
|
||||
{
|
||||
Notify: runningNotify,
|
||||
WantCmds: []string{
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=true",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -634,41 +563,31 @@ func TestContainerBoot(t *testing.T) {
|
||||
Env: map[string]string{
|
||||
"TS_EXTRA_ARGS": "--widget=rotated",
|
||||
"TS_TAILSCALED_EXTRA_ARGS": "--experiments=widgets",
|
||||
"TS_AUTH_ONCE": "false",
|
||||
},
|
||||
Phases: []phase{
|
||||
{
|
||||
WantCmds: []string{
|
||||
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking --experiments=widgets",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login --widget=rotated",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --widget=rotated",
|
||||
},
|
||||
},
|
||||
{
|
||||
}, {
|
||||
Notify: runningNotify,
|
||||
WantCmds: []string{
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "hostname",
|
||||
Env: map[string]string{
|
||||
"TS_HOSTNAME": "my-server",
|
||||
"TS_AUTH_ONCE": "false",
|
||||
"TS_HOSTNAME": "my-server",
|
||||
},
|
||||
Phases: []phase{
|
||||
{
|
||||
WantCmds: []string{
|
||||
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --hostname=my-server",
|
||||
},
|
||||
},
|
||||
{
|
||||
}, {
|
||||
Notify: runningNotify,
|
||||
WantCmds: []string{
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false --hostname=my-server",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -694,6 +613,7 @@ func TestContainerBoot(t *testing.T) {
|
||||
fmt.Sprintf("TS_TEST_SOCKET=%s", lapi.Path),
|
||||
fmt.Sprintf("TS_SOCKET=%s", runningSockPath),
|
||||
fmt.Sprintf("TS_TEST_ONLY_ROOT=%s", d),
|
||||
fmt.Sprint("TS_TEST_FAKE_NETFILTER=true"),
|
||||
}
|
||||
for k, v := range test.Env {
|
||||
cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", k, v))
|
||||
|
||||
@@ -17,7 +17,6 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
github.com/fxamacker/cbor/v2 from tailscale.com/tka
|
||||
github.com/golang/groupcache/lru from tailscale.com/net/dnscache
|
||||
github.com/golang/protobuf/proto from github.com/matttproud/golang_protobuf_extensions/pbutil
|
||||
github.com/google/btree from gvisor.dev/gvisor/pkg/tcpip/header
|
||||
L github.com/google/nftables from tailscale.com/util/linuxfw
|
||||
L 💣 github.com/google/nftables/alignedbuff from github.com/google/nftables/xt
|
||||
L 💣 github.com/google/nftables/binaryutil from github.com/google/nftables+
|
||||
@@ -79,22 +78,6 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
google.golang.org/protobuf/runtime/protoimpl from github.com/golang/protobuf/proto+
|
||||
google.golang.org/protobuf/types/descriptorpb from google.golang.org/protobuf/reflect/protodesc
|
||||
google.golang.org/protobuf/types/known/timestamppb from github.com/prometheus/client_golang/prometheus+
|
||||
gvisor.dev/gvisor/pkg/atomicbitops from gvisor.dev/gvisor/pkg/buffer+
|
||||
gvisor.dev/gvisor/pkg/bits from gvisor.dev/gvisor/pkg/buffer
|
||||
💣 gvisor.dev/gvisor/pkg/buffer from gvisor.dev/gvisor/pkg/tcpip+
|
||||
gvisor.dev/gvisor/pkg/context from gvisor.dev/gvisor/pkg/refs
|
||||
💣 gvisor.dev/gvisor/pkg/gohacks from gvisor.dev/gvisor/pkg/state/wire+
|
||||
gvisor.dev/gvisor/pkg/linewriter from gvisor.dev/gvisor/pkg/log
|
||||
gvisor.dev/gvisor/pkg/log from gvisor.dev/gvisor/pkg/context+
|
||||
gvisor.dev/gvisor/pkg/refs from gvisor.dev/gvisor/pkg/buffer
|
||||
💣 gvisor.dev/gvisor/pkg/state from gvisor.dev/gvisor/pkg/atomicbitops+
|
||||
gvisor.dev/gvisor/pkg/state/wire from gvisor.dev/gvisor/pkg/state
|
||||
💣 gvisor.dev/gvisor/pkg/sync from gvisor.dev/gvisor/pkg/atomicbitops+
|
||||
gvisor.dev/gvisor/pkg/tcpip from gvisor.dev/gvisor/pkg/tcpip/header+
|
||||
gvisor.dev/gvisor/pkg/tcpip/checksum from gvisor.dev/gvisor/pkg/buffer+
|
||||
gvisor.dev/gvisor/pkg/tcpip/header from tailscale.com/net/packet
|
||||
gvisor.dev/gvisor/pkg/tcpip/seqnum from gvisor.dev/gvisor/pkg/tcpip/header
|
||||
gvisor.dev/gvisor/pkg/waiter from gvisor.dev/gvisor/pkg/context+
|
||||
nhooyr.io/websocket from tailscale.com/cmd/derper+
|
||||
nhooyr.io/websocket/internal/errd from nhooyr.io/websocket
|
||||
nhooyr.io/websocket/internal/xsync from nhooyr.io/websocket
|
||||
@@ -164,10 +147,11 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
L tailscale.com/util/linuxfw from tailscale.com/net/netns
|
||||
tailscale.com/util/mak from tailscale.com/syncs+
|
||||
tailscale.com/util/multierr from tailscale.com/health+
|
||||
tailscale.com/util/nocasemaps from tailscale.com/types/ipproto
|
||||
tailscale.com/util/set from tailscale.com/health+
|
||||
tailscale.com/util/singleflight from tailscale.com/net/dnscache
|
||||
tailscale.com/util/slicesx from tailscale.com/cmd/derper+
|
||||
tailscale.com/util/vizerror from tailscale.com/tsweb
|
||||
tailscale.com/util/vizerror from tailscale.com/tsweb+
|
||||
W 💣 tailscale.com/util/winutil from tailscale.com/hostinfo+
|
||||
tailscale.com/version from tailscale.com/derp+
|
||||
tailscale.com/version/distro from tailscale.com/hostinfo+
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"tailscale.com/net/stun"
|
||||
"tailscale.com/tstest/deptest"
|
||||
)
|
||||
|
||||
func TestProdAutocertHostPolicy(t *testing.T) {
|
||||
@@ -128,3 +129,14 @@ func TestNoContent(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeps(t *testing.T) {
|
||||
deptest.DepChecker{
|
||||
BadDeps: map[string]string{
|
||||
"gvisor.dev/gvisor/pkg/buffer": "https://github.com/tailscale/tailscale/issues/9756",
|
||||
"gvisor.dev/gvisor/pkg/cpuid": "https://github.com/tailscale/tailscale/issues/9756",
|
||||
"gvisor.dev/gvisor/pkg/tcpip": "https://github.com/tailscale/tailscale/issues/9756",
|
||||
"gvisor.dev/gvisor/pkg/tcpip/header": "https://github.com/tailscale/tailscale/issues/9756",
|
||||
},
|
||||
}.Check(t)
|
||||
}
|
||||
|
||||
@@ -192,8 +192,15 @@ func (a *IngressReconciler) maybeProvision(ctx context.Context, logger *zap.Suga
|
||||
}
|
||||
}
|
||||
addIngressBackend(ing.Spec.DefaultBackend, "/")
|
||||
|
||||
var tlsHost string // hostname or FQDN or empty
|
||||
if ing.Spec.TLS != nil && len(ing.Spec.TLS) > 0 && len(ing.Spec.TLS[0].Hosts) > 0 {
|
||||
tlsHost = ing.Spec.TLS[0].Hosts[0]
|
||||
}
|
||||
for _, rule := range ing.Spec.Rules {
|
||||
if rule.Host != "" {
|
||||
// Host is optional, but if it's present it must match the TLS host
|
||||
// otherwise we ignore the rule.
|
||||
if rule.Host != "" && rule.Host != tlsHost {
|
||||
a.recorder.Eventf(ing, corev1.EventTypeWarning, "InvalidIngressBackend", "rule with host %q ignored, unsupported", rule.Host)
|
||||
continue
|
||||
}
|
||||
@@ -208,8 +215,8 @@ func (a *IngressReconciler) maybeProvision(ctx context.Context, logger *zap.Suga
|
||||
tags = strings.Split(tstr, ",")
|
||||
}
|
||||
hostname := ing.Namespace + "-" + ing.Name + "-ingress"
|
||||
if ing.Spec.TLS != nil && len(ing.Spec.TLS) > 0 && len(ing.Spec.TLS[0].Hosts) > 0 {
|
||||
hostname, _, _ = strings.Cut(ing.Spec.TLS[0].Hosts[0], ".")
|
||||
if tlsHost != "" {
|
||||
hostname, _, _ = strings.Cut(tlsHost, ".")
|
||||
}
|
||||
|
||||
sts := &tailscaleSTSConfig{
|
||||
|
||||
@@ -151,8 +151,10 @@ spec:
|
||||
value: tailscale/tailscale:unstable
|
||||
- name: PROXY_TAGS
|
||||
value: tag:k8s
|
||||
- name: AUTH_PROXY
|
||||
- name: APISERVER_PROXY
|
||||
value: "false"
|
||||
- name: PROXY_FIREWALL_MODE
|
||||
value: auto
|
||||
volumeMounts:
|
||||
- name: oauth
|
||||
mountPath: /oauth
|
||||
|
||||
@@ -12,7 +12,6 @@ spec:
|
||||
serviceAccountName: proxies
|
||||
initContainers:
|
||||
- name: sysctler
|
||||
image: busybox
|
||||
securityContext:
|
||||
privileged: true
|
||||
command: ["/bin/sh"]
|
||||
|
||||
@@ -52,6 +52,7 @@ func main() {
|
||||
image = defaultEnv("PROXY_IMAGE", "tailscale/tailscale:latest")
|
||||
priorityClassName = defaultEnv("PROXY_PRIORITY_CLASS_NAME", "")
|
||||
tags = defaultEnv("PROXY_TAGS", "tag:k8s")
|
||||
tsFirewallMode = defaultEnv("PROXY_FIREWALL_MODE", "")
|
||||
)
|
||||
|
||||
var opts []kzap.Opts
|
||||
@@ -70,7 +71,7 @@ func main() {
|
||||
defer s.Close()
|
||||
restConfig := config.GetConfigOrDie()
|
||||
maybeLaunchAPIServerProxy(zlog, restConfig, s)
|
||||
runReconcilers(zlog, s, tsNamespace, restConfig, tsClient, image, priorityClassName, tags)
|
||||
runReconcilers(zlog, s, tsNamespace, restConfig, tsClient, image, priorityClassName, tags, tsFirewallMode)
|
||||
}
|
||||
|
||||
// initTSNet initializes the tsnet.Server and logs in to Tailscale. It uses the
|
||||
@@ -179,7 +180,7 @@ waitOnline:
|
||||
|
||||
// runReconcilers starts the controller-runtime manager and registers the
|
||||
// ServiceReconciler. It blocks forever.
|
||||
func runReconcilers(zlog *zap.SugaredLogger, s *tsnet.Server, tsNamespace string, restConfig *rest.Config, tsClient *tailscale.Client, image, priorityClassName, tags string) {
|
||||
func runReconcilers(zlog *zap.SugaredLogger, s *tsnet.Server, tsNamespace string, restConfig *rest.Config, tsClient *tailscale.Client, image, priorityClassName, tags, tsFirewallMode string) {
|
||||
var (
|
||||
isDefaultLoadBalancer = defaultBool("OPERATOR_DEFAULT_LOAD_BALANCER", false)
|
||||
)
|
||||
@@ -216,6 +217,7 @@ func runReconcilers(zlog *zap.SugaredLogger, s *tsnet.Server, tsNamespace string
|
||||
operatorNamespace: tsNamespace,
|
||||
proxyImage: image,
|
||||
proxyPriorityClassName: priorityClassName,
|
||||
tsFirewallMode: tsFirewallMode,
|
||||
}
|
||||
err = builder.
|
||||
ControllerManagedBy(mgr).
|
||||
@@ -228,6 +230,7 @@ func runReconcilers(zlog *zap.SugaredLogger, s *tsnet.Server, tsNamespace string
|
||||
Client: mgr.GetClient(),
|
||||
logger: zlog.Named("service-reconciler"),
|
||||
isDefaultLoadBalancer: isDefaultLoadBalancer,
|
||||
recorder: eventRecorder,
|
||||
})
|
||||
if err != nil {
|
||||
startlog.Fatalf("could not create controller: %v", err)
|
||||
|
||||
@@ -70,7 +70,12 @@ func TestLoadBalancerClass(t *testing.T) {
|
||||
|
||||
expectEqual(t, fc, expectedSecret(fullName))
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName))
|
||||
expectEqual(t, fc, expectedSTS(shortName, fullName, "default-test", ""))
|
||||
o := stsOpts{
|
||||
name: shortName,
|
||||
secretName: fullName,
|
||||
hostname: "default-test",
|
||||
}
|
||||
expectEqual(t, fc, expectedSTS(o))
|
||||
|
||||
// Normally the Tailscale proxy pod would come up here and write its info
|
||||
// into the secret. Simulate that, then verify reconcile again and verify
|
||||
@@ -202,7 +207,13 @@ func TestTailnetTargetIPAnnotation(t *testing.T) {
|
||||
|
||||
expectEqual(t, fc, expectedSecret(fullName))
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName))
|
||||
expectEqual(t, fc, expectedEgressSTS(shortName, fullName, tailnetTargetIP, "default-test", ""))
|
||||
o := stsOpts{
|
||||
name: shortName,
|
||||
secretName: fullName,
|
||||
tailnetTargetIP: tailnetTargetIP,
|
||||
hostname: "default-test",
|
||||
}
|
||||
expectEqual(t, fc, expectedSTS(o))
|
||||
want := &corev1.Service{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: "Service",
|
||||
@@ -226,7 +237,13 @@ func TestTailnetTargetIPAnnotation(t *testing.T) {
|
||||
expectEqual(t, fc, want)
|
||||
expectEqual(t, fc, expectedSecret(fullName))
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName))
|
||||
expectEqual(t, fc, expectedEgressSTS(shortName, fullName, tailnetTargetIP, "default-test", ""))
|
||||
o = stsOpts{
|
||||
name: shortName,
|
||||
secretName: fullName,
|
||||
tailnetTargetIP: tailnetTargetIP,
|
||||
hostname: "default-test",
|
||||
}
|
||||
expectEqual(t, fc, expectedSTS(o))
|
||||
|
||||
// Change the tailscale-target-ip annotation which should update the
|
||||
// StatefulSet
|
||||
@@ -305,7 +322,12 @@ func TestAnnotations(t *testing.T) {
|
||||
|
||||
expectEqual(t, fc, expectedSecret(fullName))
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName))
|
||||
expectEqual(t, fc, expectedSTS(shortName, fullName, "default-test", ""))
|
||||
o := stsOpts{
|
||||
name: shortName,
|
||||
secretName: fullName,
|
||||
hostname: "default-test",
|
||||
}
|
||||
expectEqual(t, fc, expectedSTS(o))
|
||||
want := &corev1.Service{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: "Service",
|
||||
@@ -405,7 +427,12 @@ func TestAnnotationIntoLB(t *testing.T) {
|
||||
|
||||
expectEqual(t, fc, expectedSecret(fullName))
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName))
|
||||
expectEqual(t, fc, expectedSTS(shortName, fullName, "default-test", ""))
|
||||
o := stsOpts{
|
||||
name: shortName,
|
||||
secretName: fullName,
|
||||
hostname: "default-test",
|
||||
}
|
||||
expectEqual(t, fc, expectedSTS(o))
|
||||
|
||||
// Normally the Tailscale proxy pod would come up here and write its info
|
||||
// into the secret. Simulate that, since it would have normally happened at
|
||||
@@ -450,7 +477,12 @@ func TestAnnotationIntoLB(t *testing.T) {
|
||||
expectReconciled(t, sr, "default", "test")
|
||||
// None of the proxy machinery should have changed...
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName))
|
||||
expectEqual(t, fc, expectedSTS(shortName, fullName, "default-test", ""))
|
||||
o = stsOpts{
|
||||
name: shortName,
|
||||
secretName: fullName,
|
||||
hostname: "default-test",
|
||||
}
|
||||
expectEqual(t, fc, expectedSTS(o))
|
||||
// ... but the service should have a LoadBalancer status.
|
||||
|
||||
want = &corev1.Service{
|
||||
@@ -528,7 +560,12 @@ func TestLBIntoAnnotation(t *testing.T) {
|
||||
|
||||
expectEqual(t, fc, expectedSecret(fullName))
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName))
|
||||
expectEqual(t, fc, expectedSTS(shortName, fullName, "default-test", ""))
|
||||
o := stsOpts{
|
||||
name: shortName,
|
||||
secretName: fullName,
|
||||
hostname: "default-test",
|
||||
}
|
||||
expectEqual(t, fc, expectedSTS(o))
|
||||
|
||||
// Normally the Tailscale proxy pod would come up here and write its info
|
||||
// into the secret. Simulate that, then verify reconcile again and verify
|
||||
@@ -591,7 +628,12 @@ func TestLBIntoAnnotation(t *testing.T) {
|
||||
expectReconciled(t, sr, "default", "test")
|
||||
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName))
|
||||
expectEqual(t, fc, expectedSTS(shortName, fullName, "default-test", ""))
|
||||
o = stsOpts{
|
||||
name: shortName,
|
||||
secretName: fullName,
|
||||
hostname: "default-test",
|
||||
}
|
||||
expectEqual(t, fc, expectedSTS(o))
|
||||
|
||||
want = &corev1.Service{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
@@ -661,7 +703,12 @@ func TestCustomHostname(t *testing.T) {
|
||||
|
||||
expectEqual(t, fc, expectedSecret(fullName))
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName))
|
||||
expectEqual(t, fc, expectedSTS(shortName, fullName, "reindeer-flotilla", ""))
|
||||
o := stsOpts{
|
||||
name: shortName,
|
||||
secretName: fullName,
|
||||
hostname: "reindeer-flotilla",
|
||||
}
|
||||
expectEqual(t, fc, expectedSTS(o))
|
||||
want := &corev1.Service{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: "Service",
|
||||
@@ -735,7 +782,7 @@ func TestCustomPriorityClassName(t *testing.T) {
|
||||
defaultTags: []string{"tag:k8s"},
|
||||
operatorNamespace: "operator-ns",
|
||||
proxyImage: "tailscale/tailscale",
|
||||
proxyPriorityClassName: "tailscale-critical",
|
||||
proxyPriorityClassName: "custom-priority-class-name",
|
||||
},
|
||||
logger: zl.Sugar(),
|
||||
}
|
||||
@@ -752,7 +799,7 @@ func TestCustomPriorityClassName(t *testing.T) {
|
||||
UID: types.UID("1234-UID"),
|
||||
Annotations: map[string]string{
|
||||
"tailscale.com/expose": "true",
|
||||
"tailscale.com/hostname": "custom-priority-class-name",
|
||||
"tailscale.com/hostname": "tailscale-critical",
|
||||
},
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
@@ -764,8 +811,14 @@ func TestCustomPriorityClassName(t *testing.T) {
|
||||
expectReconciled(t, sr, "default", "test")
|
||||
|
||||
fullName, shortName := findGenName(t, fc, "default", "test")
|
||||
o := stsOpts{
|
||||
name: shortName,
|
||||
secretName: fullName,
|
||||
hostname: "tailscale-critical",
|
||||
priorityClassName: "custom-priority-class-name",
|
||||
}
|
||||
|
||||
expectEqual(t, fc, expectedSTS(shortName, fullName, "custom-priority-class-name", "tailscale-critical"))
|
||||
expectEqual(t, fc, expectedSTS(o))
|
||||
}
|
||||
|
||||
func TestDefaultLoadBalancer(t *testing.T) {
|
||||
@@ -811,7 +864,63 @@ func TestDefaultLoadBalancer(t *testing.T) {
|
||||
|
||||
expectEqual(t, fc, expectedSecret(fullName))
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName))
|
||||
expectEqual(t, fc, expectedSTS(shortName, fullName, "default-test", ""))
|
||||
o := stsOpts{
|
||||
name: shortName,
|
||||
secretName: fullName,
|
||||
hostname: "default-test",
|
||||
}
|
||||
expectEqual(t, fc, expectedSTS(o))
|
||||
}
|
||||
|
||||
func TestProxyFirewallMode(t *testing.T) {
|
||||
fc := fake.NewFakeClient()
|
||||
ft := &fakeTSClient{}
|
||||
zl, err := zap.NewDevelopment()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sr := &ServiceReconciler{
|
||||
Client: fc,
|
||||
ssr: &tailscaleSTSReconciler{
|
||||
Client: fc,
|
||||
tsClient: ft,
|
||||
defaultTags: []string{"tag:k8s"},
|
||||
operatorNamespace: "operator-ns",
|
||||
proxyImage: "tailscale/tailscale",
|
||||
tsFirewallMode: "nftables",
|
||||
},
|
||||
logger: zl.Sugar(),
|
||||
isDefaultLoadBalancer: true,
|
||||
}
|
||||
|
||||
// Create a service that we should manage, and check that the initial round
|
||||
// of objects looks right.
|
||||
mustCreate(t, fc, &corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "default",
|
||||
// The apiserver is supposed to set the UID, but the fake client
|
||||
// doesn't. So, set it explicitly because other code later depends
|
||||
// on it being set.
|
||||
UID: types.UID("1234-UID"),
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
ClusterIP: "10.20.30.40",
|
||||
Type: corev1.ServiceTypeLoadBalancer,
|
||||
},
|
||||
})
|
||||
|
||||
expectReconciled(t, sr, "default", "test")
|
||||
|
||||
fullName, shortName := findGenName(t, fc, "default", "test")
|
||||
o := stsOpts{
|
||||
name: shortName,
|
||||
secretName: fullName,
|
||||
hostname: "default-test",
|
||||
firewallMode: "nftables",
|
||||
}
|
||||
expectEqual(t, fc, expectedSTS(o))
|
||||
|
||||
}
|
||||
|
||||
func expectedSecret(name string) *corev1.Secret {
|
||||
@@ -862,83 +971,44 @@ func expectedHeadlessService(name string) *corev1.Service {
|
||||
}
|
||||
}
|
||||
|
||||
func expectedSTS(stsName, secretName, hostname, priorityClassName string) *appsv1.StatefulSet {
|
||||
return &appsv1.StatefulSet{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: "StatefulSet",
|
||||
APIVersion: "apps/v1",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: stsName,
|
||||
Namespace: "operator-ns",
|
||||
Labels: map[string]string{
|
||||
"tailscale.com/managed": "true",
|
||||
"tailscale.com/parent-resource": "test",
|
||||
"tailscale.com/parent-resource-ns": "default",
|
||||
"tailscale.com/parent-resource-type": "svc",
|
||||
},
|
||||
},
|
||||
Spec: appsv1.StatefulSetSpec{
|
||||
Replicas: ptr.To[int32](1),
|
||||
Selector: &metav1.LabelSelector{
|
||||
MatchLabels: map[string]string{"app": "1234-UID"},
|
||||
},
|
||||
ServiceName: stsName,
|
||||
Template: corev1.PodTemplateSpec{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Annotations: map[string]string{
|
||||
"tailscale.com/operator-last-set-hostname": hostname,
|
||||
"tailscale.com/operator-last-set-cluster-ip": "10.20.30.40",
|
||||
},
|
||||
DeletionGracePeriodSeconds: ptr.To[int64](10),
|
||||
Labels: map[string]string{"app": "1234-UID"},
|
||||
},
|
||||
Spec: corev1.PodSpec{
|
||||
ServiceAccountName: "proxies",
|
||||
PriorityClassName: priorityClassName,
|
||||
InitContainers: []corev1.Container{
|
||||
{
|
||||
Name: "sysctler",
|
||||
Image: "busybox",
|
||||
Command: []string{"/bin/sh"},
|
||||
Args: []string{"-c", "sysctl -w net.ipv4.ip_forward=1 net.ipv6.conf.all.forwarding=1"},
|
||||
SecurityContext: &corev1.SecurityContext{
|
||||
Privileged: ptr.To(true),
|
||||
},
|
||||
},
|
||||
},
|
||||
Containers: []corev1.Container{
|
||||
{
|
||||
Name: "tailscale",
|
||||
Image: "tailscale/tailscale",
|
||||
Env: []corev1.EnvVar{
|
||||
{Name: "TS_USERSPACE", Value: "false"},
|
||||
{Name: "TS_AUTH_ONCE", Value: "true"},
|
||||
{Name: "TS_KUBE_SECRET", Value: secretName},
|
||||
{Name: "TS_HOSTNAME", Value: hostname},
|
||||
{Name: "TS_DEST_IP", Value: "10.20.30.40"},
|
||||
},
|
||||
SecurityContext: &corev1.SecurityContext{
|
||||
Capabilities: &corev1.Capabilities{
|
||||
Add: []corev1.Capability{"NET_ADMIN"},
|
||||
},
|
||||
},
|
||||
ImagePullPolicy: "Always",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
func expectedSTS(opts stsOpts) *appsv1.StatefulSet {
|
||||
containerEnv := []corev1.EnvVar{
|
||||
{Name: "TS_USERSPACE", Value: "false"},
|
||||
{Name: "TS_AUTH_ONCE", Value: "true"},
|
||||
{Name: "TS_KUBE_SECRET", Value: opts.secretName},
|
||||
{Name: "TS_HOSTNAME", Value: opts.hostname},
|
||||
}
|
||||
annots := map[string]string{
|
||||
"tailscale.com/operator-last-set-hostname": opts.hostname,
|
||||
}
|
||||
if opts.tailnetTargetIP != "" {
|
||||
annots["tailscale.com/operator-last-set-ts-tailnet-target-ip"] = opts.tailnetTargetIP
|
||||
containerEnv = append(containerEnv, corev1.EnvVar{
|
||||
Name: "TS_TAILNET_TARGET_IP",
|
||||
Value: opts.tailnetTargetIP,
|
||||
})
|
||||
} else {
|
||||
containerEnv = append(containerEnv, corev1.EnvVar{
|
||||
Name: "TS_DEST_IP",
|
||||
Value: "10.20.30.40",
|
||||
})
|
||||
|
||||
annots["tailscale.com/operator-last-set-cluster-ip"] = "10.20.30.40"
|
||||
|
||||
}
|
||||
if opts.firewallMode != "" {
|
||||
containerEnv = append(containerEnv, corev1.EnvVar{
|
||||
Name: "TS_DEBUG_FIREWALL_MODE",
|
||||
Value: opts.firewallMode,
|
||||
})
|
||||
}
|
||||
}
|
||||
func expectedEgressSTS(stsName, secretName, tailnetTargetIP, hostname, priorityClassName string) *appsv1.StatefulSet {
|
||||
return &appsv1.StatefulSet{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: "StatefulSet",
|
||||
APIVersion: "apps/v1",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: stsName,
|
||||
Name: opts.name,
|
||||
Namespace: "operator-ns",
|
||||
Labels: map[string]string{
|
||||
"tailscale.com/managed": "true",
|
||||
@@ -952,23 +1022,20 @@ func expectedEgressSTS(stsName, secretName, tailnetTargetIP, hostname, priorityC
|
||||
Selector: &metav1.LabelSelector{
|
||||
MatchLabels: map[string]string{"app": "1234-UID"},
|
||||
},
|
||||
ServiceName: stsName,
|
||||
ServiceName: opts.name,
|
||||
Template: corev1.PodTemplateSpec{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Annotations: map[string]string{
|
||||
"tailscale.com/operator-last-set-hostname": hostname,
|
||||
"tailscale.com/operator-last-set-ts-tailnet-target-ip": tailnetTargetIP,
|
||||
},
|
||||
Annotations: annots,
|
||||
DeletionGracePeriodSeconds: ptr.To[int64](10),
|
||||
Labels: map[string]string{"app": "1234-UID"},
|
||||
},
|
||||
Spec: corev1.PodSpec{
|
||||
ServiceAccountName: "proxies",
|
||||
PriorityClassName: priorityClassName,
|
||||
PriorityClassName: opts.priorityClassName,
|
||||
InitContainers: []corev1.Container{
|
||||
{
|
||||
Name: "sysctler",
|
||||
Image: "busybox",
|
||||
Image: "tailscale/tailscale",
|
||||
Command: []string{"/bin/sh"},
|
||||
Args: []string{"-c", "sysctl -w net.ipv4.ip_forward=1 net.ipv6.conf.all.forwarding=1"},
|
||||
SecurityContext: &corev1.SecurityContext{
|
||||
@@ -980,13 +1047,7 @@ func expectedEgressSTS(stsName, secretName, tailnetTargetIP, hostname, priorityC
|
||||
{
|
||||
Name: "tailscale",
|
||||
Image: "tailscale/tailscale",
|
||||
Env: []corev1.EnvVar{
|
||||
{Name: "TS_USERSPACE", Value: "false"},
|
||||
{Name: "TS_AUTH_ONCE", Value: "true"},
|
||||
{Name: "TS_KUBE_SECRET", Value: secretName},
|
||||
{Name: "TS_HOSTNAME", Value: hostname},
|
||||
{Name: "TS_TAILNET_TARGET_IP", Value: tailnetTargetIP},
|
||||
},
|
||||
Env: containerEnv,
|
||||
SecurityContext: &corev1.SecurityContext{
|
||||
Capabilities: &corev1.Capabilities{
|
||||
Add: []corev1.Capability{"NET_ADMIN"},
|
||||
@@ -1126,6 +1187,15 @@ func expectRequeue(t *testing.T, sr *ServiceReconciler, ns, name string) {
|
||||
}
|
||||
}
|
||||
|
||||
type stsOpts struct {
|
||||
name string
|
||||
secretName string
|
||||
hostname string
|
||||
priorityClassName string
|
||||
firewallMode string
|
||||
tailnetTargetIP string
|
||||
}
|
||||
|
||||
type fakeTSClient struct {
|
||||
sync.Mutex
|
||||
keyRequests []tailscale.KeyCapabilities
|
||||
|
||||
@@ -79,6 +79,14 @@ type tailscaleSTSReconciler struct {
|
||||
operatorNamespace string
|
||||
proxyImage string
|
||||
proxyPriorityClassName string
|
||||
tsFirewallMode string
|
||||
}
|
||||
|
||||
func (sts tailscaleSTSReconciler) validate() error {
|
||||
if sts.tsFirewallMode != "" && !isValidFirewallMode(sts.tsFirewallMode) {
|
||||
return fmt.Errorf("invalid proxy firewall mode %s, valid modes are iptables, nftables or unset", sts.tsFirewallMode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsHTTPSEnabledOnTailnet reports whether HTTPS is enabled on the tailnet.
|
||||
@@ -307,6 +315,13 @@ func (a *tailscaleSTSReconciler) reconcileSTS(ctx context.Context, logger *zap.S
|
||||
if err := yaml.Unmarshal(proxyYaml, &ss); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal proxy spec: %w", err)
|
||||
}
|
||||
for i := range ss.Spec.Template.Spec.InitContainers {
|
||||
c := &ss.Spec.Template.Spec.InitContainers[i]
|
||||
if c.Name == "sysctler" {
|
||||
c.Image = a.proxyImage
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
container := &ss.Spec.Template.Spec.Containers[0]
|
||||
container.Image = a.proxyImage
|
||||
@@ -353,6 +368,13 @@ func (a *tailscaleSTSReconciler) reconcileSTS(ctx context.Context, logger *zap.S
|
||||
},
|
||||
})
|
||||
}
|
||||
if a.tsFirewallMode != "" {
|
||||
container.Env = append(container.Env, corev1.EnvVar{
|
||||
Name: "TS_DEBUG_FIREWALL_MODE",
|
||||
Value: a.tsFirewallMode,
|
||||
},
|
||||
)
|
||||
}
|
||||
ss.ObjectMeta = metav1.ObjectMeta{
|
||||
Name: headlessSvc.Name,
|
||||
Namespace: a.operatorNamespace,
|
||||
@@ -492,3 +514,7 @@ func nameForService(svc *corev1.Service) (string, error) {
|
||||
}
|
||||
return svc.Namespace + "-" + svc.Name, nil
|
||||
}
|
||||
|
||||
func isValidFirewallMode(m string) bool {
|
||||
return m == "auto" || m == "nftables" || m == "iptables"
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"k8s.io/client-go/tools/record"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
||||
"tailscale.com/util/clientmetric"
|
||||
@@ -37,6 +38,8 @@ type ServiceReconciler struct {
|
||||
// managedEgressProxies is a set of all egress proxies that we're currently
|
||||
// managing. This is only used for metrics.
|
||||
managedEgressProxies set.Slice[types.UID]
|
||||
|
||||
recorder record.EventRecorder
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -136,6 +139,15 @@ func (a *ServiceReconciler) maybeCleanup(ctx context.Context, logger *zap.Sugare
|
||||
// This function adds a finalizer to svc, ensuring that we can handle orderly
|
||||
// deprovisioning later.
|
||||
func (a *ServiceReconciler) maybeProvision(ctx context.Context, logger *zap.SugaredLogger, svc *corev1.Service) error {
|
||||
// run for proxy config related validations here as opposed to running
|
||||
// them earlier. This is to prevent cleanup etc being blocked on a
|
||||
// misconfigured proxy param
|
||||
if err := a.ssr.validate(); err != nil {
|
||||
msg := fmt.Sprintf("unable to provision proxy resources: invalid config: %v", err)
|
||||
a.recorder.Event(svc, corev1.EventTypeWarning, "INVALIDCONFIG", msg)
|
||||
a.logger.Error(msg)
|
||||
return nil
|
||||
}
|
||||
hostname, err := nameForService(svc)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -75,6 +75,7 @@ func main() {
|
||||
wgPort = fs.Int("wg-listen-port", 0, "UDP port to listen on for WireGuard and peer-to-peer traffic; 0 means automatically select")
|
||||
promoteHTTPS = fs.Bool("promote-https", true, "promote HTTP to HTTPS")
|
||||
debugPort = fs.Int("debug-port", 8893, "Listening port for debug/metrics endpoint")
|
||||
hostname = fs.String("hostname", "", "Hostname to register the service under")
|
||||
)
|
||||
|
||||
err := ff.Parse(fs, os.Args[1:], ff.WithEnvVarPrefix("TS_APPC"))
|
||||
@@ -89,6 +90,7 @@ func main() {
|
||||
|
||||
var s server
|
||||
s.ts.Port = uint16(*wgPort)
|
||||
s.ts.Hostname = *hostname
|
||||
defer s.ts.Close()
|
||||
|
||||
lc, err := s.ts.LocalClient()
|
||||
|
||||
@@ -139,11 +139,22 @@ var debugCmd = &ffcli.Command{
|
||||
Exec: localAPIAction("break-derp-conns"),
|
||||
ShortHelp: "break any open DERP connections from the daemon",
|
||||
},
|
||||
{
|
||||
Name: "pick-new-derp",
|
||||
Exec: localAPIAction("pick-new-derp"),
|
||||
ShortHelp: "switch to some other random DERP home region for a short time",
|
||||
},
|
||||
{
|
||||
Name: "force-netmap-update",
|
||||
Exec: localAPIAction("force-netmap-update"),
|
||||
ShortHelp: "force a full no-op netmap update (for load testing)",
|
||||
},
|
||||
{
|
||||
// TODO(bradfitz,maisem): eventually promote this out of debug
|
||||
Name: "reload-config",
|
||||
Exec: reloadConfig,
|
||||
ShortHelp: "reload config",
|
||||
},
|
||||
{
|
||||
Name: "control-knobs",
|
||||
Exec: debugControlKnobs,
|
||||
@@ -446,6 +457,20 @@ func localAPIAction(action string) func(context.Context, []string) error {
|
||||
}
|
||||
}
|
||||
|
||||
func reloadConfig(ctx context.Context, args []string) error {
|
||||
ok, err := localClient.ReloadConfig(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ok {
|
||||
printf("config reloaded\n")
|
||||
return nil
|
||||
}
|
||||
printf("config mode not in use\n")
|
||||
os.Exit(1)
|
||||
panic("unreachable")
|
||||
}
|
||||
|
||||
func runEnv(ctx context.Context, args []string) error {
|
||||
for _, e := range os.Environ() {
|
||||
outln(e)
|
||||
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
@@ -29,8 +28,11 @@ import (
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
"tailscale.com/envknob"
|
||||
"tailscale.com/net/tsaddr"
|
||||
"tailscale.com/syncs"
|
||||
"tailscale.com/tailcfg"
|
||||
tsrate "tailscale.com/tstime/rate"
|
||||
"tailscale.com/util/quarantine"
|
||||
"tailscale.com/util/truncate"
|
||||
"tailscale.com/version"
|
||||
)
|
||||
|
||||
@@ -52,12 +54,12 @@ var fileCmd = &ffcli.Command{
|
||||
|
||||
type countingReader struct {
|
||||
io.Reader
|
||||
n atomic.Uint64
|
||||
n atomic.Int64
|
||||
}
|
||||
|
||||
func (c *countingReader) Read(buf []byte) (int, error) {
|
||||
n, err := c.Reader.Read(buf)
|
||||
c.n.Add(uint64(n))
|
||||
c.n.Add(int64(n))
|
||||
return n, err
|
||||
}
|
||||
|
||||
@@ -170,75 +172,100 @@ func runCp(ctx context.Context, args []string) error {
|
||||
log.Printf("sending %q to %v/%v/%v ...", name, target, ip, stableID)
|
||||
}
|
||||
|
||||
var (
|
||||
done = make(chan struct{}, 1)
|
||||
wg sync.WaitGroup
|
||||
)
|
||||
var group syncs.WaitGroup
|
||||
ctxProgress, cancelProgress := context.WithCancel(ctx)
|
||||
defer cancelProgress()
|
||||
if isatty.IsTerminal(os.Stderr.Fd()) {
|
||||
go printProgress(&wg, done, fileContents, name, contentLength)
|
||||
wg.Add(1)
|
||||
group.Go(func() { progressPrinter(ctxProgress, name, fileContents.n.Load, contentLength) })
|
||||
}
|
||||
|
||||
err := localClient.PushFile(ctx, stableID, contentLength, name, fileContents)
|
||||
cancelProgress()
|
||||
group.Wait() // wait for progress printer to stop before reporting the error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cpArgs.verbose {
|
||||
log.Printf("sent %q", name)
|
||||
}
|
||||
done <- struct{}{}
|
||||
wg.Wait()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const vtRestartLine = "\r\x1b[K"
|
||||
func progressPrinter(ctx context.Context, name string, contentCount func() int64, contentLength int64) {
|
||||
var rateValueFast, rateValueSlow tsrate.Value
|
||||
rateValueFast.HalfLife = 1 * time.Second // fast response for rate measurement
|
||||
rateValueSlow.HalfLife = 10 * time.Second // slow response for ETA measurement
|
||||
var prevContentCount int64
|
||||
print := func() {
|
||||
currContentCount := contentCount()
|
||||
rateValueFast.Add(float64(currContentCount - prevContentCount))
|
||||
rateValueSlow.Add(float64(currContentCount - prevContentCount))
|
||||
prevContentCount = currContentCount
|
||||
|
||||
func printProgress(wg *sync.WaitGroup, done <-chan struct{}, r *countingReader, name string, contentLength int64) {
|
||||
defer wg.Done()
|
||||
var lastBytesRead uint64
|
||||
const vtRestartLine = "\r\x1b[K"
|
||||
fmt.Fprintf(os.Stderr, "%s%s %s %s",
|
||||
vtRestartLine,
|
||||
rightPad(name, 36),
|
||||
leftPad(formatIEC(float64(currContentCount), "B"), len("1023.00MiB")),
|
||||
leftPad(formatIEC(rateValueFast.Rate(), "B/s"), len("1023.00MiB/s")))
|
||||
if contentLength >= 0 {
|
||||
currContentCount = min(currContentCount, contentLength) // cap at 100%
|
||||
ratioRemain := float64(currContentCount) / float64(contentLength)
|
||||
bytesRemain := float64(contentLength - currContentCount)
|
||||
secsRemain := bytesRemain / rateValueSlow.Rate()
|
||||
secs := int(min(max(0, secsRemain), 99*60*60+59+60+59))
|
||||
fmt.Fprintf(os.Stderr, " %s %s",
|
||||
leftPad(fmt.Sprintf("%0.2f%%", 100.0*ratioRemain), len("100.00%")),
|
||||
fmt.Sprintf("ETA %02d:%02d:%02d", secs/60/60, (secs/60)%60, secs%60))
|
||||
}
|
||||
}
|
||||
|
||||
tc := time.NewTicker(250 * time.Millisecond)
|
||||
defer tc.Stop()
|
||||
print()
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
case <-ctx.Done():
|
||||
print()
|
||||
fmt.Fprintln(os.Stderr)
|
||||
return
|
||||
case <-time.After(time.Second):
|
||||
n := r.n.Load()
|
||||
contentLengthStr := "???"
|
||||
if contentLength > 0 {
|
||||
contentLengthStr = fmt.Sprint(contentLength / 1024)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "%s%s\t\t%s", vtRestartLine, padTruncateString(name, 36), padTruncateString(fmt.Sprintf("%d/%s kb", n/1024, contentLengthStr), 16))
|
||||
if contentLength > 0 {
|
||||
fmt.Fprintf(os.Stderr, "\t%.02f%%", float64(n)/float64(contentLength)*100)
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "\t-------%%")
|
||||
}
|
||||
if lastBytesRead > 0 {
|
||||
fmt.Fprintf(os.Stderr, "\t%d kb/s", (n-lastBytesRead)/1024)
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "\t-------")
|
||||
}
|
||||
lastBytesRead = n
|
||||
case <-tc.C:
|
||||
print()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func padTruncateString(str string, truncateAt int) string {
|
||||
if len(str) <= truncateAt {
|
||||
return str + strings.Repeat(" ", truncateAt-len(str))
|
||||
}
|
||||
func leftPad(s string, n int) string {
|
||||
s = truncateString(s, n)
|
||||
return strings.Repeat(" ", max(n-len(s), 0)) + s
|
||||
}
|
||||
|
||||
// Truncate the string, but respect unicode codepoint boundaries.
|
||||
// As of RFC3629 utf-8 codepoints can be at most 4 bytes wide.
|
||||
for i := 1; i <= 4 && i < len(str)-truncateAt; i++ {
|
||||
if utf8.ValidString(str[:truncateAt-i]) {
|
||||
return str[:truncateAt-i] + "…"
|
||||
}
|
||||
func rightPad(s string, n int) string {
|
||||
s = truncateString(s, n)
|
||||
return s + strings.Repeat(" ", max(n-len(s), 0))
|
||||
}
|
||||
|
||||
func truncateString(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return truncate.String(s, max(n-1, 0)) + "…"
|
||||
}
|
||||
|
||||
func formatIEC(n float64, unit string) string {
|
||||
switch {
|
||||
case n < 1<<10:
|
||||
return fmt.Sprintf("%0.2f%s", n/(1<<0), unit)
|
||||
case n < 1<<20:
|
||||
return fmt.Sprintf("%0.2fKi%s", n/(1<<10), unit)
|
||||
case n < 1<<30:
|
||||
return fmt.Sprintf("%0.2fMi%s", n/(1<<20), unit)
|
||||
case n < 1<<40:
|
||||
return fmt.Sprintf("%0.2fGi%s", n/(1<<30), unit)
|
||||
default:
|
||||
return fmt.Sprintf("%0.2fTi%s", n/(1<<40), unit)
|
||||
}
|
||||
return "" // Should be unreachable
|
||||
}
|
||||
|
||||
func getTargetStableID(ctx context.Context, ipStr string) (id tailcfg.StableNodeID, isOffline bool, err error) {
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"tailscale.com/envknob"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/tailcfg"
|
||||
@@ -22,13 +21,10 @@ import (
|
||||
|
||||
var funnelCmd = func() *ffcli.Command {
|
||||
se := &serveEnv{lc: &localClient}
|
||||
// This flag is used to switch to an in-development
|
||||
// implementation of the tailscale funnel command.
|
||||
// See https://github.com/tailscale/tailscale/issues/7844
|
||||
if envknob.UseWIPCode() {
|
||||
return newServeDevCommand(se, funnel)
|
||||
}
|
||||
return newFunnelCommand(se)
|
||||
// previously used to serve legacy newFunnelCommand unless useWIPCode is true
|
||||
// change is limited to make a revert easier and full cleanup to come after the relase.
|
||||
// TODO(tylersmalley): cleanup and removal of newFunnelCommand as of 2023-10-16
|
||||
return newServeV2Command(se, funnel)
|
||||
}
|
||||
|
||||
// newFunnelCommand returns a new "funnel" subcommand using e as its environment.
|
||||
|
||||
@@ -24,7 +24,6 @@ import (
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/envknob"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/tailcfg"
|
||||
@@ -34,17 +33,14 @@ import (
|
||||
|
||||
var serveCmd = func() *ffcli.Command {
|
||||
se := &serveEnv{lc: &localClient}
|
||||
// This flag is used to switch to an in-development
|
||||
// implementation of the tailscale funnel command.
|
||||
// See https://github.com/tailscale/tailscale/issues/7844
|
||||
if envknob.UseWIPCode() {
|
||||
return newServeDevCommand(se, serve)
|
||||
}
|
||||
return newServeCommand(se)
|
||||
// previously used to serve legacy newFunnelCommand unless useWIPCode is true
|
||||
// change is limited to make a revert easier and full cleanup to come after the relase.
|
||||
// TODO(tylersmalley): cleanup and removal of newServeLegacyCommand as of 2023-10-16
|
||||
return newServeV2Command(se, serve)
|
||||
}
|
||||
|
||||
// newServeCommand returns a new "serve" subcommand using e as its environment.
|
||||
func newServeCommand(e *serveEnv) *ffcli.Command {
|
||||
// newServeLegacyCommand returns a new "serve" subcommand using e as its environment.
|
||||
func newServeLegacyCommand(e *serveEnv) *ffcli.Command {
|
||||
return &ffcli.Command{
|
||||
Name: "serve",
|
||||
ShortHelp: "Serve content and local servers",
|
||||
@@ -713,7 +713,7 @@ func TestServeConfigMutations(t *testing.T) {
|
||||
cmd = newFunnelCommand(e)
|
||||
args = st.command[1:]
|
||||
} else {
|
||||
cmd = newServeCommand(e)
|
||||
cmd = newServeLegacyCommand(e)
|
||||
args = st.command
|
||||
}
|
||||
err := cmd.ParseAndRun(context.Background(), args)
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"os/signal"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -38,15 +39,22 @@ type commandInfo struct {
|
||||
}
|
||||
|
||||
var serveHelpCommon = strings.TrimSpace(`
|
||||
<target> can be a port number (e.g., 3000), a partial URL (e.g., localhost:3000), or a
|
||||
full URL including a path (e.g., http://localhost:3000/foo, https+insecure://localhost:3000/foo).
|
||||
<target> can be a file, directory, text, or most commonly the location to a service running on the
|
||||
local machine. The location to the location service can be expressed as a port number (e.g., 3000),
|
||||
a partial URL (e.g., localhost:3000), or a full URL including a path (e.g., http://localhost:3000/foo).
|
||||
|
||||
EXAMPLES
|
||||
- Mount a local web server at 127.0.0.1:3000 in the foreground:
|
||||
$ tailscale %s localhost:3000
|
||||
- Expose an HTTP server running at 127.0.0.1:3000 in the foreground:
|
||||
$ tailscale %s 3000
|
||||
|
||||
- Mount a local web server at 127.0.0.1:3000 in the background:
|
||||
$ tailscale %s --bg localhost:3000
|
||||
- Expose an HTTP server running at 127.0.0.1:3000 in the background:
|
||||
$ tailscale %s --bg 3000
|
||||
|
||||
- Expose an HTTPS server with a valid certificate at https://localhost:8443
|
||||
$ tailscale %s https://localhost:8443
|
||||
|
||||
- Expose an HTTPS server with invalid or self-signed certificates at https://localhost:8443
|
||||
$ tailscale %s https+insecure://localhost:8443
|
||||
|
||||
For more examples and use cases visit our docs site https://tailscale.com/kb/1247/funnel-serve-use-cases
|
||||
`)
|
||||
@@ -72,7 +80,7 @@ var infoMap = map[serveMode]commandInfo{
|
||||
Name: "serve",
|
||||
ShortHelp: "Serve content and local servers on your tailnet",
|
||||
LongHelp: strings.Join([]string{
|
||||
"Serve enables you to share a local server securely within your tailnet.\n",
|
||||
"Tailscale Serve enables you to share a local server securely within your tailnet.\n",
|
||||
"To share a local server on the internet, use `tailscale funnel`\n\n",
|
||||
}, "\n"),
|
||||
},
|
||||
@@ -94,8 +102,8 @@ func buildShortUsage(subcmd string) string {
|
||||
}, "\n ")
|
||||
}
|
||||
|
||||
// newServeDevCommand returns a new "serve" subcommand using e as its environment.
|
||||
func newServeDevCommand(e *serveEnv, subcmd serveMode) *ffcli.Command {
|
||||
// newServeV2Command returns a new "serve" subcommand using e as its environment.
|
||||
func newServeV2Command(e *serveEnv, subcmd serveMode) *ffcli.Command {
|
||||
if subcmd != serve && subcmd != funnel {
|
||||
log.Fatalf("newServeDevCommand called with unknown subcmd %q", subcmd)
|
||||
}
|
||||
@@ -114,12 +122,12 @@ func newServeDevCommand(e *serveEnv, subcmd serveMode) *ffcli.Command {
|
||||
Exec: e.runServeCombined(subcmd),
|
||||
|
||||
FlagSet: e.newFlags("serve-set", func(fs *flag.FlagSet) {
|
||||
fs.BoolVar(&e.bg, "bg", false, "run the command in the background")
|
||||
fs.StringVar(&e.setPath, "set-path", "", "set a path for a specific target and run in the background")
|
||||
fs.StringVar(&e.https, "https", "", "default; HTTPS listener")
|
||||
fs.StringVar(&e.http, "http", "", "HTTP listener")
|
||||
fs.StringVar(&e.tcp, "tcp", "", "TCP listener")
|
||||
fs.StringVar(&e.tlsTerminatedTCP, "tls-terminated-tcp", "", "TLS terminated TCP listener")
|
||||
fs.BoolVar(&e.bg, "bg", false, "Run the command as a background process")
|
||||
fs.StringVar(&e.setPath, "set-path", "", "Appends the specified path to the base URL for accessing the underlying service")
|
||||
fs.StringVar(&e.https, "https", "", "Expose an HTTPS server at the specified port (default")
|
||||
fs.StringVar(&e.http, "http", "", "Expose an HTTP server at the specified port")
|
||||
fs.StringVar(&e.tcp, "tcp", "", "Expose a TCP forwarder to forward raw TCP packets at the specified port")
|
||||
fs.StringVar(&e.tlsTerminatedTCP, "tls-terminated-tcp", "", "Expose a TCP forwarder to forward TLS-terminated TCP packets at the specified port")
|
||||
|
||||
}),
|
||||
UsageFunc: usageFunc,
|
||||
@@ -268,7 +276,7 @@ func (e *serveEnv) runServeCombined(subcmd serveMode) execFunc {
|
||||
return err
|
||||
}
|
||||
err = e.setServe(sc, st, dnsName, srvType, srvPort, mount, args[0], funnel)
|
||||
msg = e.messageForPort(sc, st, dnsName, srvPort)
|
||||
msg = e.messageForPort(sc, st, dnsName, srvType, srvPort)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n\n", err)
|
||||
@@ -377,18 +385,27 @@ func (e *serveEnv) setServe(sc *ipn.ServeConfig, st *ipnstate.Status, dnsName st
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
msgFunnelAvailable = "Available on the internet:"
|
||||
msgServeAvailable = "Available within your tailnet:"
|
||||
msgRunningInBackground = "%s started and running in the background."
|
||||
msgDisableProxy = "To disable the proxy, run: tailscale %s --%s=%d off"
|
||||
msgToExit = "Press Ctrl+C to exit."
|
||||
)
|
||||
|
||||
// messageForPort returns a message for the given port based on the
|
||||
// serve config and status.
|
||||
func (e *serveEnv) messageForPort(sc *ipn.ServeConfig, st *ipnstate.Status, dnsName string, srvPort uint16) string {
|
||||
func (e *serveEnv) messageForPort(sc *ipn.ServeConfig, st *ipnstate.Status, dnsName string, srvType serveType, srvPort uint16) string {
|
||||
var output strings.Builder
|
||||
|
||||
hp := ipn.HostPort(net.JoinHostPort(dnsName, strconv.Itoa(int(srvPort))))
|
||||
|
||||
if sc.AllowFunnel[hp] == true {
|
||||
output.WriteString("Available on the internet:\n")
|
||||
output.WriteString(msgFunnelAvailable)
|
||||
} else {
|
||||
output.WriteString("Available within your tailnet:\n")
|
||||
output.WriteString(msgServeAvailable)
|
||||
}
|
||||
output.WriteString("\n")
|
||||
|
||||
scheme := "https"
|
||||
if sc.IsServingHTTP(srvPort) {
|
||||
@@ -404,7 +421,7 @@ func (e *serveEnv) messageForPort(sc *ipn.ServeConfig, st *ipnstate.Status, dnsN
|
||||
output.WriteString(fmt.Sprintf("%s://%s%s\n\n", scheme, dnsName, portPart))
|
||||
|
||||
if !e.bg {
|
||||
output.WriteString("Press Ctrl+C to exit.")
|
||||
output.WriteString(msgToExit)
|
||||
return output.String()
|
||||
}
|
||||
|
||||
@@ -452,8 +469,13 @@ func (e *serveEnv) messageForPort(sc *ipn.ServeConfig, st *ipnstate.Status, dnsN
|
||||
output.WriteString(fmt.Sprintf("|--> tcp://%s\n", h.TCPForward))
|
||||
}
|
||||
|
||||
output.WriteString("\nServe started and running in the background.\n")
|
||||
output.WriteString(fmt.Sprintf("To disable the proxy, run: tailscale %s off", infoMap[e.subcmd].Name))
|
||||
subCmd := infoMap[e.subcmd].Name
|
||||
subCmdSentance := strings.ToUpper(string(subCmd[0])) + subCmd[1:]
|
||||
|
||||
output.WriteString("\n")
|
||||
output.WriteString(fmt.Sprintf(msgRunningInBackground, subCmdSentance))
|
||||
output.WriteString("\n")
|
||||
output.WriteString(fmt.Sprintf(msgDisableProxy, subCmd, srvType.String(), srvPort))
|
||||
|
||||
return output.String()
|
||||
}
|
||||
@@ -488,7 +510,7 @@ func (e *serveEnv) applyWebServe(sc *ipn.ServeConfig, dnsName string, srvPort ui
|
||||
}
|
||||
h.Path = target
|
||||
default:
|
||||
t, err := expandProxyTargetDev(target)
|
||||
t, err := expandProxyTargetDev(target, []string{"http", "https", "https+insecure"}, "http")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -538,34 +560,22 @@ func (e *serveEnv) applyTCPServe(sc *ipn.ServeConfig, dnsName string, srcType se
|
||||
return fmt.Errorf("invalid TCP target %q", target)
|
||||
}
|
||||
|
||||
dstURL, err := url.Parse(target)
|
||||
targetURL, err := expandProxyTargetDev(target, []string{"tcp"}, "tcp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to expand target: %v", err)
|
||||
}
|
||||
|
||||
dstURL, err := url.Parse(targetURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid TCP target %q: %v", target, err)
|
||||
}
|
||||
host, dstPortStr, err := net.SplitHostPort(dstURL.Host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid TCP target %q: %v", target, err)
|
||||
}
|
||||
|
||||
switch host {
|
||||
case "localhost", "127.0.0.1":
|
||||
// ok
|
||||
default:
|
||||
return fmt.Errorf("invalid TCP target %q, must be one of localhost or 127.0.0.1", target)
|
||||
}
|
||||
|
||||
if p, err := strconv.ParseUint(dstPortStr, 10, 16); p == 0 || err != nil {
|
||||
return fmt.Errorf("invalid port %q", dstPortStr)
|
||||
}
|
||||
|
||||
fwdAddr := "127.0.0.1:" + dstPortStr
|
||||
|
||||
// TODO: needs to account for multiple configs from foreground mode
|
||||
if sc.IsServingWeb(srcPort) {
|
||||
return fmt.Errorf("cannot serve TCP; already serving web on %d", srcPort)
|
||||
}
|
||||
|
||||
mak.Set(&sc.TCP, srcPort, &ipn.TCPPortHandler{TCPForward: fwdAddr})
|
||||
mak.Set(&sc.TCP, srcPort, &ipn.TCPPortHandler{TCPForward: dstURL.Host})
|
||||
|
||||
if terminateTLS {
|
||||
sc.TCP[srcPort].TerminateTLS = dnsName
|
||||
@@ -725,24 +735,22 @@ func (e *serveEnv) removeTCPServe(sc *ipn.ServeConfig, src uint16) error {
|
||||
// examples:
|
||||
// - 3000
|
||||
// - localhost:3000
|
||||
// - tcp://localhost:3000
|
||||
// - http://localhost:3000
|
||||
// - https://localhost:3000
|
||||
// - https-insecure://localhost:3000
|
||||
// - https-insecure://localhost:3000/foo
|
||||
func expandProxyTargetDev(target string) (string, error) {
|
||||
var (
|
||||
scheme = "http"
|
||||
host = "127.0.0.1"
|
||||
)
|
||||
func expandProxyTargetDev(target string, supportedSchemes []string, defaultScheme string) (string, error) {
|
||||
var host = "127.0.0.1"
|
||||
|
||||
// support target being a port number
|
||||
if port, err := strconv.ParseUint(target, 10, 16); err == nil {
|
||||
return fmt.Sprintf("%s://%s:%d", scheme, host, port), nil
|
||||
return fmt.Sprintf("%s://%s:%d", defaultScheme, host, port), nil
|
||||
}
|
||||
|
||||
// prepend scheme if not present
|
||||
if !strings.Contains(target, "://") {
|
||||
target = scheme + "://" + target
|
||||
target = defaultScheme + "://" + target
|
||||
}
|
||||
|
||||
// make sure we can parse the target
|
||||
@@ -752,10 +760,8 @@ func expandProxyTargetDev(target string) (string, error) {
|
||||
}
|
||||
|
||||
// ensure a supported scheme
|
||||
switch u.Scheme {
|
||||
case "http", "https", "https+insecure":
|
||||
default:
|
||||
return "", errors.New("must be a URL starting with http://, https://, or https+insecure://")
|
||||
if !slices.Contains(supportedSchemes, u.Scheme) {
|
||||
return "", fmt.Errorf("must be a URL starting with one of the supported schemes: %v", supportedSchemes)
|
||||
}
|
||||
|
||||
// validate the port
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/types/logger"
|
||||
)
|
||||
|
||||
@@ -366,10 +367,6 @@ func TestServeDevConfigMutations(t *testing.T) {
|
||||
|
||||
// // tcp
|
||||
add(step{reset: true})
|
||||
add(step{ // must include scheme for tcp
|
||||
command: cmd("serve --tls-terminated-tcp=443 --bg localhost:5432"),
|
||||
wantErr: exactErr(errHelp, "errHelp"),
|
||||
})
|
||||
add(step{ // !somehost, must be localhost or 127.0.0.1
|
||||
command: cmd("serve --tls-terminated-tcp=443 --bg tcp://somehost:5432"),
|
||||
wantErr: exactErr(errHelp, "errHelp"),
|
||||
@@ -382,6 +379,18 @@ func TestServeDevConfigMutations(t *testing.T) {
|
||||
command: cmd("serve --tls-terminated-tcp=443 --bg tcp://somehost:65536"),
|
||||
wantErr: exactErr(errHelp, "errHelp"),
|
||||
})
|
||||
add(step{ // support shorthand
|
||||
command: cmd("serve --tls-terminated-tcp=443 --bg 5432"),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{
|
||||
443: {
|
||||
TCPForward: "127.0.0.1:5432",
|
||||
TerminateTLS: "foo.test.ts.net",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{reset: true})
|
||||
add(step{
|
||||
command: cmd("serve --tls-terminated-tcp=443 --bg tcp://localhost:5432"),
|
||||
want: &ipn.ServeConfig{
|
||||
@@ -746,7 +755,7 @@ func TestServeDevConfigMutations(t *testing.T) {
|
||||
if st.command[0] == "funnel" {
|
||||
mode = funnel
|
||||
}
|
||||
cmd = newServeDevCommand(e, mode)
|
||||
cmd = newServeV2Command(e, mode)
|
||||
args = st.command[1:]
|
||||
|
||||
err := cmd.ParseAndRun(context.Background(), args)
|
||||
@@ -955,28 +964,43 @@ func TestSrcTypeFromFlags(t *testing.T) {
|
||||
|
||||
func TestExpandProxyTargetDev(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
wantErr bool
|
||||
name string
|
||||
input string
|
||||
defaultScheme string
|
||||
supportedSchemes []string
|
||||
expected string
|
||||
wantErr bool
|
||||
}{
|
||||
{input: "8080", expected: "http://127.0.0.1:8080"},
|
||||
{input: "localhost:8080", expected: "http://127.0.0.1:8080"},
|
||||
{input: "http://localhost:8080", expected: "http://127.0.0.1:8080"},
|
||||
{input: "http://127.0.0.1:8080", expected: "http://127.0.0.1:8080"},
|
||||
{input: "http://127.0.0.1:8080/foo", expected: "http://127.0.0.1:8080/foo"},
|
||||
{input: "https://localhost:8080", expected: "https://127.0.0.1:8080"},
|
||||
{input: "https+insecure://localhost:8080", expected: "https+insecure://127.0.0.1:8080"},
|
||||
{name: "port-only", input: "8080", expected: "http://127.0.0.1:8080"},
|
||||
{name: "hostname+port", input: "localhost:8080", expected: "http://127.0.0.1:8080"},
|
||||
{name: "convert-localhost", input: "http://localhost:8080", expected: "http://127.0.0.1:8080"},
|
||||
{name: "no-change", input: "http://127.0.0.1:8080", expected: "http://127.0.0.1:8080"},
|
||||
{name: "include-path", input: "http://127.0.0.1:8080/foo", expected: "http://127.0.0.1:8080/foo"},
|
||||
{name: "https-scheme", input: "https://localhost:8080", expected: "https://127.0.0.1:8080"},
|
||||
{name: "https+insecure-scheme", input: "https+insecure://localhost:8080", expected: "https+insecure://127.0.0.1:8080"},
|
||||
{name: "change-default-scheme", input: "localhost:8080", defaultScheme: "https", expected: "https://127.0.0.1:8080"},
|
||||
{name: "change-supported-schemes", input: "localhost:8080", defaultScheme: "tcp", supportedSchemes: []string{"tcp"}, expected: "tcp://127.0.0.1:8080"},
|
||||
|
||||
// errors
|
||||
{input: "localhost:9999999", wantErr: true},
|
||||
{input: "ftp://localhost:8080", expected: "", wantErr: true},
|
||||
{input: "https://tailscale.com:8080", expected: "", wantErr: true},
|
||||
{input: "", expected: "", wantErr: true},
|
||||
{name: "invalid-port", input: "localhost:9999999", wantErr: true},
|
||||
{name: "unsupported-scheme", input: "ftp://localhost:8080", expected: "", wantErr: true},
|
||||
{name: "not-localhost", input: "https://tailscale.com:8080", expected: "", wantErr: true},
|
||||
{name: "empty-input", input: "", expected: "", wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
actual, err := expandProxyTargetDev(tt.input)
|
||||
defaultScheme := "http"
|
||||
supportedSchemes := []string{"http", "https", "https+insecure"}
|
||||
|
||||
if tt.supportedSchemes != nil {
|
||||
supportedSchemes = tt.supportedSchemes
|
||||
}
|
||||
if tt.defaultScheme != "" {
|
||||
defaultScheme = tt.defaultScheme
|
||||
}
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
actual, err := expandProxyTargetDev(tt.input, supportedSchemes, defaultScheme)
|
||||
|
||||
if tt.wantErr == true && err == nil {
|
||||
t.Errorf("Expected an error but got none")
|
||||
@@ -1029,6 +1053,105 @@ func TestCleanURLPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageForPort(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
subcmd serveMode
|
||||
serveConfig *ipn.ServeConfig
|
||||
status *ipnstate.Status
|
||||
dnsName string
|
||||
srvType serveType
|
||||
srvPort uint16
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "funnel-https",
|
||||
subcmd: funnel,
|
||||
serveConfig: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{
|
||||
443: {HTTPS: true},
|
||||
},
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
"foo.test.ts.net:443": {
|
||||
Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/": {Proxy: "http://127.0.0.1:3000"},
|
||||
},
|
||||
},
|
||||
},
|
||||
AllowFunnel: map[ipn.HostPort]bool{
|
||||
"foo.test.ts.net:443": true,
|
||||
},
|
||||
},
|
||||
status: &ipnstate.Status{},
|
||||
dnsName: "foo.test.ts.net",
|
||||
srvType: serveTypeHTTPS,
|
||||
srvPort: 443,
|
||||
expected: strings.Join([]string{
|
||||
msgFunnelAvailable,
|
||||
"https://foo.test.ts.net",
|
||||
"",
|
||||
"|-- / proxy http://127.0.0.1:3000",
|
||||
"",
|
||||
fmt.Sprintf(msgRunningInBackground, "Funnel"),
|
||||
fmt.Sprintf(msgDisableProxy, "funnel", "https", 443),
|
||||
}, "\n"),
|
||||
},
|
||||
{
|
||||
name: "serve-http",
|
||||
subcmd: serve,
|
||||
serveConfig: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{
|
||||
443: {HTTP: true},
|
||||
},
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
"foo.test.ts.net:80": {
|
||||
Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/": {Proxy: "http://127.0.0.1:3000"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
status: &ipnstate.Status{},
|
||||
dnsName: "foo.test.ts.net",
|
||||
srvType: serveTypeHTTP,
|
||||
srvPort: 80,
|
||||
expected: strings.Join([]string{
|
||||
msgServeAvailable,
|
||||
"https://foo.test.ts.net:80",
|
||||
"",
|
||||
"|-- / proxy http://127.0.0.1:3000",
|
||||
"",
|
||||
fmt.Sprintf(msgRunningInBackground, "Serve"),
|
||||
fmt.Sprintf(msgDisableProxy, "serve", "http", 80),
|
||||
}, "\n"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
e := &serveEnv{bg: true, subcmd: tt.subcmd}
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
actual := e.messageForPort(tt.serveConfig, tt.status, tt.dnsName, tt.srvType, tt.srvPort)
|
||||
|
||||
if actual == "" {
|
||||
t.Errorf("Got empty message")
|
||||
}
|
||||
|
||||
if actual != tt.expected {
|
||||
t.Errorf("Got: %q; expected: %q", actual, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func unindent(s string) string {
|
||||
lines := strings.Split(s, "\n")
|
||||
for i, line := range lines {
|
||||
lines[i] = strings.TrimSpace(line)
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func TestIsLegacyInvocation(t *testing.T) {
|
||||
tests := []struct {
|
||||
subcmd serveMode
|
||||
@@ -49,6 +49,7 @@ type setArgsT struct {
|
||||
forceDaemon bool
|
||||
updateCheck bool
|
||||
updateApply bool
|
||||
postureChecking bool
|
||||
}
|
||||
|
||||
func newSetFlagSet(goos string, setArgs *setArgsT) *flag.FlagSet {
|
||||
@@ -66,6 +67,8 @@ func newSetFlagSet(goos string, setArgs *setArgsT) *flag.FlagSet {
|
||||
setf.BoolVar(&setArgs.advertiseDefaultRoute, "advertise-exit-node", false, "offer to be an exit node for internet traffic for the tailnet")
|
||||
setf.BoolVar(&setArgs.updateCheck, "update-check", true, "HIDDEN: notify about available Tailscale updates")
|
||||
setf.BoolVar(&setArgs.updateApply, "auto-update", false, "HIDDEN: automatically update to the latest available version")
|
||||
setf.BoolVar(&setArgs.postureChecking, "posture-checking", false, "HIDDEN: allow management plane to gather device posture information")
|
||||
|
||||
if safesocket.GOOSUsesPeerCreds(goos) {
|
||||
setf.StringVar(&setArgs.opUser, "operator", "", "Unix username to allow to operate on tailscaled without sudo")
|
||||
}
|
||||
@@ -108,6 +111,7 @@ func runSet(ctx context.Context, args []string) (retErr error) {
|
||||
Check: setArgs.updateCheck,
|
||||
Apply: setArgs.updateApply,
|
||||
},
|
||||
PostureChecking: setArgs.postureChecking,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -238,7 +238,11 @@ func runStatus(ctx context.Context, args []string) error {
|
||||
}
|
||||
printFunnelStatus(ctx)
|
||||
if cv := st.ClientVersion; cv != nil && !cv.RunningLatest && cv.LatestVersion != "" {
|
||||
printf("# Update available: %v -> %v, run `tailscale update` or `tailscale set --auto-update` to update.\n", version.Short(), cv.LatestVersion)
|
||||
if cv.UrgentSecurityUpdate {
|
||||
printf("# Security update available: %v -> %v, run `tailscale update` or `tailscale set --auto-update` to update.\n", version.Short(), cv.LatestVersion)
|
||||
} else {
|
||||
printf("# Update available: %v -> %v, run `tailscale update` or `tailscale set --auto-update` to update.\n", version.Short(), cv.LatestVersion)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -114,6 +114,7 @@ func newUpFlagSet(goos string, upArgs *upArgsT, cmd string) *flag.FlagSet {
|
||||
upf.StringVar(&upArgs.hostname, "hostname", "", "hostname to use instead of the one provided by the OS")
|
||||
upf.StringVar(&upArgs.advertiseRoutes, "advertise-routes", "", "routes to advertise to other nodes (comma-separated, e.g. \"10.0.0.0/8,192.168.0.0/24\") or empty string to not advertise routes")
|
||||
upf.BoolVar(&upArgs.advertiseDefaultRoute, "advertise-exit-node", false, "offer to be an exit node for internet traffic for the tailnet")
|
||||
|
||||
if safesocket.GOOSUsesPeerCreds(goos) {
|
||||
upf.StringVar(&upArgs.opUser, "operator", "", "Unix username to allow to operate on tailscaled without sudo")
|
||||
}
|
||||
@@ -532,7 +533,11 @@ func runUp(ctx context.Context, cmd string, args []string, upArgs upArgsT) (retE
|
||||
// Only need to print an update if we printed the "please click" message earlier.
|
||||
fmt.Fprintf(Stderr, "Success.\n")
|
||||
if cv != nil && !cv.RunningLatest && cv.LatestVersion != "" {
|
||||
fmt.Fprintf(Stderr, "\nUpdate available: %v -> %v\n", version.Short(), cv.LatestVersion)
|
||||
if cv.UrgentSecurityUpdate {
|
||||
fmt.Fprintf(Stderr, "\nSecurity update available: %v -> %v\n", version.Short(), cv.LatestVersion)
|
||||
} else {
|
||||
fmt.Fprintf(Stderr, "\nUpdate available: %v -> %v\n", version.Short(), cv.LatestVersion)
|
||||
}
|
||||
fmt.Fprintln(Stderr, "Changelog: https://tailscale.com/changelog/#client")
|
||||
fmt.Fprintln(Stderr, "Run `tailscale update` or `tailscale set --auto-update` to update")
|
||||
}
|
||||
@@ -725,6 +730,7 @@ func init() {
|
||||
addPrefFlagMapping("nickname", "ProfileName")
|
||||
addPrefFlagMapping("update-check", "AutoUpdate")
|
||||
addPrefFlagMapping("auto-update", "AutoUpdate")
|
||||
addPrefFlagMapping("posture-checking", "PostureChecking")
|
||||
}
|
||||
|
||||
func addPrefFlagMapping(flagName string, prefNames ...string) {
|
||||
|
||||
@@ -26,7 +26,6 @@ var updateCmd = &ffcli.Command{
|
||||
fs := newFlagSet("update")
|
||||
fs.BoolVar(&updateArgs.yes, "yes", false, "update without interactive prompts")
|
||||
fs.BoolVar(&updateArgs.dryRun, "dry-run", false, "print what update would do without doing it, or prompts")
|
||||
fs.BoolVar(&updateArgs.appStore, "app-store", false, "HIDDEN: check the App Store for updates, even if this is not an App Store install (for testing only)")
|
||||
// These flags are not supported on several systems that only provide
|
||||
// the latest version of Tailscale:
|
||||
//
|
||||
@@ -42,11 +41,10 @@ var updateCmd = &ffcli.Command{
|
||||
}
|
||||
|
||||
var updateArgs struct {
|
||||
yes bool
|
||||
dryRun bool
|
||||
appStore bool
|
||||
track string // explicit track; empty means same as current
|
||||
version string // explicit version; empty means auto
|
||||
yes bool
|
||||
dryRun bool
|
||||
track string // explicit track; empty means same as current
|
||||
version string // explicit version; empty means auto
|
||||
}
|
||||
|
||||
func runUpdate(ctx context.Context, args []string) error {
|
||||
@@ -61,10 +59,11 @@ func runUpdate(ctx context.Context, args []string) error {
|
||||
ver = updateArgs.track
|
||||
}
|
||||
err := clientupdate.Update(clientupdate.Arguments{
|
||||
Version: ver,
|
||||
AppStore: updateArgs.appStore,
|
||||
Logf: func(format string, args ...any) { fmt.Printf(format+"\n", args...) },
|
||||
Confirm: confirmUpdate,
|
||||
Version: ver,
|
||||
Logf: func(f string, a ...any) { printf(f+"\n", a...) },
|
||||
Stdout: Stdout,
|
||||
Stderr: Stderr,
|
||||
Confirm: confirmUpdate,
|
||||
})
|
||||
if errors.Is(err, errors.ErrUnsupported) {
|
||||
return errors.New("The 'update' command is not supported on this platform; see https://tailscale.com/s/client-updates")
|
||||
|
||||
@@ -80,7 +80,7 @@ func runWeb(ctx context.Context, args []string) error {
|
||||
return fmt.Errorf("too many non-flag arguments: %q", args)
|
||||
}
|
||||
|
||||
webServer, cleanup := web.NewServer(ctx, web.ServerOpts{
|
||||
webServer, cleanup := web.NewServer(web.ServerOpts{
|
||||
DevMode: webArgs.dev,
|
||||
CGIMode: webArgs.cgi,
|
||||
PathPrefix: webArgs.prefix,
|
||||
|
||||
@@ -17,7 +17,6 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
|
||||
github.com/fxamacker/cbor/v2 from tailscale.com/tka
|
||||
L 💣 github.com/godbus/dbus/v5 from github.com/coreos/go-systemd/v22/dbus
|
||||
github.com/golang/groupcache/lru from tailscale.com/net/dnscache
|
||||
github.com/google/btree from gvisor.dev/gvisor/pkg/tcpip/header
|
||||
L github.com/google/nftables from tailscale.com/util/linuxfw
|
||||
L 💣 github.com/google/nftables/alignedbuff from github.com/google/nftables/xt
|
||||
L 💣 github.com/google/nftables/binaryutil from github.com/google/nftables+
|
||||
@@ -65,22 +64,6 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
|
||||
go4.org/netipx from tailscale.com/wgengine/filter+
|
||||
W 💣 golang.zx2c4.com/wireguard/windows/tunnel/winipcfg from tailscale.com/net/interfaces+
|
||||
gopkg.in/yaml.v2 from sigs.k8s.io/yaml
|
||||
gvisor.dev/gvisor/pkg/atomicbitops from gvisor.dev/gvisor/pkg/buffer+
|
||||
gvisor.dev/gvisor/pkg/bits from gvisor.dev/gvisor/pkg/buffer
|
||||
💣 gvisor.dev/gvisor/pkg/buffer from gvisor.dev/gvisor/pkg/tcpip+
|
||||
gvisor.dev/gvisor/pkg/context from gvisor.dev/gvisor/pkg/refs
|
||||
💣 gvisor.dev/gvisor/pkg/gohacks from gvisor.dev/gvisor/pkg/state/wire+
|
||||
gvisor.dev/gvisor/pkg/linewriter from gvisor.dev/gvisor/pkg/log
|
||||
gvisor.dev/gvisor/pkg/log from gvisor.dev/gvisor/pkg/context+
|
||||
gvisor.dev/gvisor/pkg/refs from gvisor.dev/gvisor/pkg/buffer
|
||||
💣 gvisor.dev/gvisor/pkg/state from gvisor.dev/gvisor/pkg/atomicbitops+
|
||||
gvisor.dev/gvisor/pkg/state/wire from gvisor.dev/gvisor/pkg/state
|
||||
💣 gvisor.dev/gvisor/pkg/sync from gvisor.dev/gvisor/pkg/atomicbitops+
|
||||
gvisor.dev/gvisor/pkg/tcpip from gvisor.dev/gvisor/pkg/tcpip/header+
|
||||
gvisor.dev/gvisor/pkg/tcpip/checksum from gvisor.dev/gvisor/pkg/buffer+
|
||||
gvisor.dev/gvisor/pkg/tcpip/header from tailscale.com/net/packet
|
||||
gvisor.dev/gvisor/pkg/tcpip/seqnum from gvisor.dev/gvisor/pkg/tcpip/header
|
||||
gvisor.dev/gvisor/pkg/waiter from gvisor.dev/gvisor/pkg/context+
|
||||
k8s.io/client-go/util/homedir from tailscale.com/cmd/tailscale/cli
|
||||
nhooyr.io/websocket from tailscale.com/derp/derphttp+
|
||||
nhooyr.io/websocket/internal/errd from nhooyr.io/websocket
|
||||
@@ -158,7 +141,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
|
||||
tailscale.com/types/views from tailscale.com/tailcfg+
|
||||
tailscale.com/util/clientmetric from tailscale.com/net/netcheck+
|
||||
tailscale.com/util/cloudenv from tailscale.com/net/dnscache+
|
||||
W tailscale.com/util/cmpver from tailscale.com/net/tshttpproxy
|
||||
tailscale.com/util/cmpver from tailscale.com/net/tshttpproxy+
|
||||
tailscale.com/util/cmpx from tailscale.com/cmd/tailscale/cli+
|
||||
L 💣 tailscale.com/util/dirwalk from tailscale.com/metrics
|
||||
tailscale.com/util/dnsname from tailscale.com/cmd/tailscale/cli+
|
||||
@@ -169,11 +152,14 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
|
||||
tailscale.com/util/mak from tailscale.com/net/netcheck+
|
||||
tailscale.com/util/multierr from tailscale.com/control/controlhttp+
|
||||
tailscale.com/util/must from tailscale.com/cmd/tailscale/cli+
|
||||
tailscale.com/util/nocasemaps from tailscale.com/types/ipproto
|
||||
tailscale.com/util/quarantine from tailscale.com/cmd/tailscale/cli
|
||||
tailscale.com/util/set from tailscale.com/health+
|
||||
tailscale.com/util/singleflight from tailscale.com/net/dnscache
|
||||
tailscale.com/util/slicesx from tailscale.com/net/dnscache+
|
||||
tailscale.com/util/testenv from tailscale.com/cmd/tailscale/cli
|
||||
tailscale.com/util/truncate from tailscale.com/cmd/tailscale/cli
|
||||
tailscale.com/util/vizerror from tailscale.com/types/ipproto+
|
||||
💣 tailscale.com/util/winutil from tailscale.com/hostinfo+
|
||||
W 💣 tailscale.com/util/winutil/authenticode from tailscale.com/clientupdate
|
||||
tailscale.com/version from tailscale.com/cmd/tailscale/cli+
|
||||
|
||||
21
cmd/tailscale/tailscale_test.go
Normal file
21
cmd/tailscale/tailscale_test.go
Normal file
@@ -0,0 +1,21 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"tailscale.com/tstest/deptest"
|
||||
)
|
||||
|
||||
func TestDeps(t *testing.T) {
|
||||
deptest.DepChecker{
|
||||
BadDeps: map[string]string{
|
||||
"gvisor.dev/gvisor/pkg/buffer": "https://github.com/tailscale/tailscale/issues/9756",
|
||||
"gvisor.dev/gvisor/pkg/cpuid": "https://github.com/tailscale/tailscale/issues/9756",
|
||||
"gvisor.dev/gvisor/pkg/tcpip": "https://github.com/tailscale/tailscale/issues/9756",
|
||||
"gvisor.dev/gvisor/pkg/tcpip/header": "https://github.com/tailscale/tailscale/issues/9756",
|
||||
},
|
||||
}.Check(t)
|
||||
}
|
||||
@@ -86,6 +86,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
W 💣 github.com/dblohm7/wingoes/com/automation from tailscale.com/util/osdiag/internal/wsc
|
||||
W github.com/dblohm7/wingoes/internal from github.com/dblohm7/wingoes/com
|
||||
W 💣 github.com/dblohm7/wingoes/pe from tailscale.com/util/osdiag+
|
||||
LW 💣 github.com/digitalocean/go-smbios/smbios from tailscale.com/posture
|
||||
github.com/fxamacker/cbor/v2 from tailscale.com/tka
|
||||
W 💣 github.com/go-ole/go-ole from github.com/go-ole/go-ole/oleutil+
|
||||
W 💣 github.com/go-ole/go-ole/oleutil from tailscale.com/wgengine/winnet
|
||||
@@ -132,7 +133,6 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
L github.com/pierrec/lz4/v4/internal/lz4errors from github.com/pierrec/lz4/v4+
|
||||
L github.com/pierrec/lz4/v4/internal/lz4stream from github.com/pierrec/lz4/v4
|
||||
L github.com/pierrec/lz4/v4/internal/xxh32 from github.com/pierrec/lz4/v4/internal/lz4stream
|
||||
W github.com/pkg/errors from github.com/tailscale/certstore
|
||||
LD github.com/pkg/sftp from tailscale.com/ssh/tailssh
|
||||
LD github.com/pkg/sftp/internal/encoding/ssh/filexfer from github.com/pkg/sftp
|
||||
W 💣 github.com/tailscale/certstore from tailscale.com/control/controlclient
|
||||
@@ -147,6 +147,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
github.com/tailscale/goupnp/scpd from github.com/tailscale/goupnp
|
||||
github.com/tailscale/goupnp/soap from github.com/tailscale/goupnp+
|
||||
github.com/tailscale/goupnp/ssdp from github.com/tailscale/goupnp
|
||||
github.com/tailscale/hujson from tailscale.com/ipn/conffile
|
||||
L 💣 github.com/tailscale/netlink from tailscale.com/wgengine/router+
|
||||
💣 github.com/tailscale/wireguard-go/conn from github.com/tailscale/wireguard-go/device+
|
||||
W 💣 github.com/tailscale/wireguard-go/conn/winrio from github.com/tailscale/wireguard-go/conn
|
||||
@@ -238,6 +239,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
tailscale.com/health/healthmsg from tailscale.com/ipn/ipnlocal
|
||||
tailscale.com/hostinfo from tailscale.com/control/controlclient+
|
||||
tailscale.com/ipn from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/ipn/conffile from tailscale.com/cmd/tailscaled+
|
||||
💣 tailscale.com/ipn/ipnauth from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/ipn/ipnlocal from tailscale.com/ssh/tailssh+
|
||||
tailscale.com/ipn/ipnserver from tailscale.com/cmd/tailscaled
|
||||
@@ -275,6 +277,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
💣 tailscale.com/net/netstat from tailscale.com/ipn/ipnauth+
|
||||
tailscale.com/net/netutil from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/net/packet from tailscale.com/net/tstun+
|
||||
tailscale.com/net/packet/checksum from tailscale.com/net/tstun
|
||||
tailscale.com/net/ping from tailscale.com/net/netcheck+
|
||||
tailscale.com/net/portmapper from tailscale.com/net/netcheck+
|
||||
tailscale.com/net/proxymux from tailscale.com/cmd/tailscaled
|
||||
@@ -292,13 +295,14 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
tailscale.com/net/wsconn from tailscale.com/control/controlhttp+
|
||||
tailscale.com/paths from tailscale.com/ipn/ipnlocal+
|
||||
💣 tailscale.com/portlist from tailscale.com/ipn/ipnlocal
|
||||
tailscale.com/posture from tailscale.com/ipn/ipnlocal
|
||||
tailscale.com/proxymap from tailscale.com/tsd+
|
||||
tailscale.com/safesocket from tailscale.com/client/tailscale+
|
||||
tailscale.com/smallzstd from tailscale.com/control/controlclient+
|
||||
LD 💣 tailscale.com/ssh/tailssh from tailscale.com/cmd/tailscaled
|
||||
tailscale.com/syncs from tailscale.com/net/netcheck+
|
||||
tailscale.com/tailcfg from tailscale.com/client/tailscale/apitype+
|
||||
tailscale.com/taildrop from tailscale.com/ipn/ipnlocal
|
||||
tailscale.com/taildrop from tailscale.com/ipn/ipnlocal+
|
||||
💣 tailscale.com/tempfork/device from tailscale.com/net/tstun/table
|
||||
LD tailscale.com/tempfork/gliderlabs/ssh from tailscale.com/ssh/tailssh
|
||||
tailscale.com/tempfork/heap from tailscale.com/wgengine/magicsock
|
||||
@@ -329,7 +333,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
tailscale.com/types/views from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/util/clientmetric from tailscale.com/control/controlclient+
|
||||
tailscale.com/util/cloudenv from tailscale.com/net/dns/resolver+
|
||||
LW tailscale.com/util/cmpver from tailscale.com/net/dns+
|
||||
tailscale.com/util/cmpver from tailscale.com/net/dns+
|
||||
tailscale.com/util/cmpx from tailscale.com/derp/derphttp+
|
||||
💣 tailscale.com/util/deephash from tailscale.com/ipn/ipnlocal+
|
||||
L 💣 tailscale.com/util/dirwalk from tailscale.com/metrics+
|
||||
@@ -337,12 +341,14 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
tailscale.com/util/goroutines from tailscale.com/ipn/ipnlocal
|
||||
tailscale.com/util/groupmember from tailscale.com/ipn/ipnauth
|
||||
💣 tailscale.com/util/hashx from tailscale.com/util/deephash
|
||||
tailscale.com/util/httphdr from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/util/httpm from tailscale.com/client/tailscale+
|
||||
tailscale.com/util/lineread from tailscale.com/hostinfo+
|
||||
L tailscale.com/util/linuxfw from tailscale.com/net/netns+
|
||||
tailscale.com/util/mak from tailscale.com/control/controlclient+
|
||||
tailscale.com/util/multierr from tailscale.com/control/controlclient+
|
||||
tailscale.com/util/must from tailscale.com/logpolicy+
|
||||
tailscale.com/util/nocasemaps from tailscale.com/types/ipproto
|
||||
💣 tailscale.com/util/osdiag from tailscale.com/cmd/tailscaled+
|
||||
W 💣 tailscale.com/util/osdiag/internal/wsc from tailscale.com/util/osdiag
|
||||
tailscale.com/util/osshare from tailscale.com/ipn/ipnlocal+
|
||||
@@ -354,12 +360,13 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
tailscale.com/util/set from tailscale.com/health+
|
||||
tailscale.com/util/singleflight from tailscale.com/control/controlclient+
|
||||
tailscale.com/util/slicesx from tailscale.com/net/dnscache+
|
||||
W tailscale.com/util/syspolicy from tailscale.com/cmd/tailscaled
|
||||
tailscale.com/util/syspolicy from tailscale.com/cmd/tailscaled+
|
||||
tailscale.com/util/sysresources from tailscale.com/wgengine/magicsock
|
||||
tailscale.com/util/systemd from tailscale.com/control/controlclient+
|
||||
tailscale.com/util/testenv from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/util/uniq from tailscale.com/wgengine/magicsock+
|
||||
💣 tailscale.com/util/winutil from tailscale.com/control/controlclient+
|
||||
tailscale.com/util/vizerror from tailscale.com/types/ipproto+
|
||||
💣 tailscale.com/util/winutil from tailscale.com/clientupdate+
|
||||
W 💣 tailscale.com/util/winutil/authenticode from tailscale.com/util/osdiag+
|
||||
W tailscale.com/util/winutil/policy from tailscale.com/ipn/ipnlocal
|
||||
tailscale.com/version from tailscale.com/derp+
|
||||
@@ -386,7 +393,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
golang.org/x/crypto/cryptobyte from crypto/ecdsa+
|
||||
golang.org/x/crypto/cryptobyte/asn1 from crypto/ecdsa+
|
||||
golang.org/x/crypto/curve25519 from github.com/tailscale/golang-x-crypto/ssh+
|
||||
LD golang.org/x/crypto/ed25519 from golang.org/x/crypto/ssh+
|
||||
LD golang.org/x/crypto/ed25519 from github.com/tailscale/golang-x-crypto/ssh
|
||||
golang.org/x/crypto/hkdf from crypto/tls+
|
||||
golang.org/x/crypto/nacl/box from tailscale.com/types/key
|
||||
golang.org/x/crypto/nacl/secretbox from golang.org/x/crypto/nacl/box
|
||||
|
||||
@@ -32,6 +32,7 @@ import (
|
||||
"tailscale.com/cmd/tailscaled/childproc"
|
||||
"tailscale.com/control/controlclient"
|
||||
"tailscale.com/envknob"
|
||||
"tailscale.com/ipn/conffile"
|
||||
"tailscale.com/ipn/ipnlocal"
|
||||
"tailscale.com/ipn/ipnserver"
|
||||
"tailscale.com/ipn/store"
|
||||
@@ -127,6 +128,7 @@ var args struct {
|
||||
tunname string
|
||||
|
||||
cleanup bool
|
||||
confFile string
|
||||
debug string
|
||||
port uint16
|
||||
statepath string
|
||||
@@ -172,6 +174,7 @@ func main() {
|
||||
flag.StringVar(&args.birdSocketPath, "bird-socket", "", "path of the bird unix socket")
|
||||
flag.BoolVar(&printVersion, "version", false, "print version information and exit")
|
||||
flag.BoolVar(&args.disableLogs, "no-logs-no-support", false, "disable log uploads; this also disables any technical support")
|
||||
flag.StringVar(&args.confFile, "config", "", "path to config file")
|
||||
|
||||
if len(os.Args) > 0 && filepath.Base(os.Args[0]) == "tailscale" && beCLI != nil {
|
||||
beCLI()
|
||||
@@ -339,6 +342,17 @@ func run() error {
|
||||
|
||||
sys := new(tsd.System)
|
||||
|
||||
// Parse config, if specified, to fail early if it's invalid.
|
||||
var conf *conffile.Config
|
||||
if args.confFile != "" {
|
||||
var err error
|
||||
conf, err = conffile.Load(args.confFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error reading config file: %w", err)
|
||||
}
|
||||
sys.InitialConfig = conf
|
||||
}
|
||||
|
||||
netMon, err := netmon.New(func(format string, args ...any) {
|
||||
logf(format, args...)
|
||||
})
|
||||
@@ -540,6 +554,10 @@ func getLocalBackend(ctx context.Context, logf logger.Logf, logID logid.PublicID
|
||||
}
|
||||
sys.Set(store)
|
||||
|
||||
if w, ok := sys.Tun.GetOK(); ok {
|
||||
w.Start()
|
||||
}
|
||||
|
||||
lb, err := ipnlocal.NewLocalBackend(logf, logID, sys, opts.LoginFlags)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ipnlocal.NewLocalBackend: %w", err)
|
||||
|
||||
130
cmd/testwrapper/args.go
Normal file
130
cmd/testwrapper/args.go
Normal file
@@ -0,0 +1,130 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"io"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// defaultTestArgs contains the default values for all flags in the testing
|
||||
// package. It is used to reset the flag values in testwrapper tests to allow
|
||||
// parsing the flags again.
|
||||
var defaultTestArgs map[string]string
|
||||
|
||||
// initDefaultTestArgs initializes defaultTestArgs.
|
||||
func initDefaultTestArgs() {
|
||||
if defaultTestArgs != nil {
|
||||
return
|
||||
}
|
||||
defaultTestArgs = make(map[string]string)
|
||||
flag.CommandLine.VisitAll(func(f *flag.Flag) {
|
||||
defaultTestArgs[f.Name] = f.DefValue
|
||||
})
|
||||
}
|
||||
|
||||
// registerTestFlags registers all flags from the testing package with the
|
||||
// provided flag set. It does so by calling testing.Init() and then iterating
|
||||
// over all flags registered on flag.CommandLine.
|
||||
func registerTestFlags(fs *flag.FlagSet) {
|
||||
testing.Init()
|
||||
type bv interface {
|
||||
IsBoolFlag() bool
|
||||
}
|
||||
|
||||
flag.CommandLine.VisitAll(func(f *flag.Flag) {
|
||||
if b, ok := f.Value.(bv); ok && b.IsBoolFlag() {
|
||||
fs.Bool(f.Name, f.DefValue == "true", f.Usage)
|
||||
if name, ok := strings.CutPrefix(f.Name, "test."); ok {
|
||||
fs.Bool(name, f.DefValue == "true", f.Usage)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// We don't actually care about the value of the flag, so we just
|
||||
// register it as a string. The values will be passed to `go test` which
|
||||
// will parse and validate them anyway.
|
||||
fs.String(f.Name, f.DefValue, f.Usage)
|
||||
if name, ok := strings.CutPrefix(f.Name, "test."); ok {
|
||||
fs.String(name, f.DefValue, f.Usage)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// splitArgs splits args into three parts as consumed by go test.
|
||||
//
|
||||
// go test [build/test flags] [packages] [build/test flags & test binary flags]
|
||||
//
|
||||
// We return these as three slices of strings [pre] [pkgs] [post].
|
||||
//
|
||||
// It is used to split the arguments passed to testwrapper into the arguments
|
||||
// passed to go test and the arguments passed to the tests.
|
||||
func splitArgs(args []string) (pre, pkgs, post []string, _ error) {
|
||||
if len(args) == 0 {
|
||||
return nil, nil, nil, nil
|
||||
}
|
||||
|
||||
fs := newTestFlagSet()
|
||||
// Parse stops at the first non-flag argument, so this allows us
|
||||
// to parse those as values and then reconstruct them as args.
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
fs.Visit(func(f *flag.Flag) {
|
||||
if f.Value.String() != f.DefValue && f.DefValue != "false" {
|
||||
pre = append(pre, "-"+f.Name, f.Value.String())
|
||||
} else {
|
||||
pre = append(pre, "-"+f.Name)
|
||||
}
|
||||
})
|
||||
|
||||
// fs.Args() now contains [packages]+[build/test flags & test binary flags],
|
||||
// to split it we need to find the first non-flag argument.
|
||||
rem := fs.Args()
|
||||
ix := slices.IndexFunc(rem, func(s string) bool { return strings.HasPrefix(s, "-") })
|
||||
if ix == -1 {
|
||||
return pre, rem, nil, nil
|
||||
}
|
||||
pkgs = rem[:ix]
|
||||
post = rem[ix:]
|
||||
return pre, pkgs, post, nil
|
||||
}
|
||||
|
||||
func newTestFlagSet() *flag.FlagSet {
|
||||
fs := flag.NewFlagSet("testwrapper", flag.ContinueOnError)
|
||||
fs.SetOutput(io.Discard)
|
||||
|
||||
// Register all flags from the testing package.
|
||||
registerTestFlags(fs)
|
||||
// Also register the -exec flag, which is not part of the testing package.
|
||||
// TODO(maisem): figure out what other flags we need to register explicitly.
|
||||
fs.String("exec", "", "Command to run tests with")
|
||||
fs.Bool("race", false, "build with race detector")
|
||||
return fs
|
||||
}
|
||||
|
||||
// testingVerbose reports whether the test is being run with verbose logging.
|
||||
var testingVerbose = func() bool {
|
||||
verbose := false
|
||||
|
||||
// Likely doesn't matter, but to be correct follow the go flag parsing logic
|
||||
// of overriding previous values.
|
||||
for _, arg := range os.Args[1:] {
|
||||
switch arg {
|
||||
case "-test.v", "--test.v",
|
||||
"-test.v=true", "--test.v=true",
|
||||
"-v", "--v",
|
||||
"-v=true", "--v=true":
|
||||
verbose = true
|
||||
case "-test.v=false", "--test.v=false",
|
||||
"-v=false", "--v=false":
|
||||
verbose = false
|
||||
}
|
||||
}
|
||||
return verbose
|
||||
}()
|
||||
97
cmd/testwrapper/args_test.go
Normal file
97
cmd/testwrapper/args_test.go
Normal file
@@ -0,0 +1,97 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSplitArgs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in []string
|
||||
pre, pkgs, post []string
|
||||
}{
|
||||
{
|
||||
name: "empty",
|
||||
},
|
||||
{
|
||||
name: "all",
|
||||
in: []string{"-v", "pkg1", "pkg2", "-run", "TestFoo", "-timeout=20s"},
|
||||
pre: []string{"-v"},
|
||||
pkgs: []string{"pkg1", "pkg2"},
|
||||
post: []string{"-run", "TestFoo", "-timeout=20s"},
|
||||
},
|
||||
{
|
||||
name: "only_pkgs",
|
||||
in: []string{"./..."},
|
||||
pkgs: []string{"./..."},
|
||||
},
|
||||
{
|
||||
name: "pkgs_and_post",
|
||||
in: []string{"pkg1", "-run", "TestFoo"},
|
||||
pkgs: []string{"pkg1"},
|
||||
post: []string{"-run", "TestFoo"},
|
||||
},
|
||||
{
|
||||
name: "pkgs_and_post",
|
||||
in: []string{"-v", "pkg2"},
|
||||
pre: []string{"-v"},
|
||||
pkgs: []string{"pkg2"},
|
||||
},
|
||||
{
|
||||
name: "only_args",
|
||||
in: []string{"-v", "-run=TestFoo"},
|
||||
pre: []string{"-run", "TestFoo", "-v"}, // sorted
|
||||
},
|
||||
{
|
||||
name: "space_in_pre_arg",
|
||||
in: []string{"-run", "TestFoo", "./cmd/testwrapper"},
|
||||
pre: []string{"-run", "TestFoo"},
|
||||
pkgs: []string{"./cmd/testwrapper"},
|
||||
},
|
||||
{
|
||||
name: "space_in_arg",
|
||||
in: []string{"-exec", "sudo -E", "./cmd/testwrapper"},
|
||||
pre: []string{"-exec", "sudo -E"},
|
||||
pkgs: []string{"./cmd/testwrapper"},
|
||||
},
|
||||
{
|
||||
name: "test-arg",
|
||||
in: []string{"-exec", "sudo -E", "./cmd/testwrapper", "--", "--some-flag"},
|
||||
pre: []string{"-exec", "sudo -E"},
|
||||
pkgs: []string{"./cmd/testwrapper"},
|
||||
post: []string{"--", "--some-flag"},
|
||||
},
|
||||
{
|
||||
name: "dupe-args",
|
||||
in: []string{"-v", "-v", "-race", "-race", "./cmd/testwrapper", "--", "--some-flag"},
|
||||
pre: []string{"-race", "-v"},
|
||||
pkgs: []string{"./cmd/testwrapper"},
|
||||
post: []string{"--", "--some-flag"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
pre, pkgs, post, err := splitArgs(tt.in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Equal(pre, tt.pre) {
|
||||
t.Errorf("pre = %q; want %q", pre, tt.pre)
|
||||
}
|
||||
if !slices.Equal(pkgs, tt.pkgs) {
|
||||
t.Errorf("pattern = %q; want %q", pkgs, tt.pkgs)
|
||||
}
|
||||
if !slices.Equal(post, tt.post) {
|
||||
t.Errorf("post = %q; want %q", post, tt.post)
|
||||
}
|
||||
if t.Failed() {
|
||||
t.Logf("SplitArgs(%q) = %q %q %q", tt.in, pre, pkgs, post)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
@@ -71,18 +70,24 @@ var debug = os.Getenv("TS_TESTWRAPPER_DEBUG") != ""
|
||||
// set to true. Package build errors will not emit a testAttempt (as no valid
|
||||
// JSON is produced) but the [os/exec.ExitError] will be returned.
|
||||
// It calls close(ch) when it's done.
|
||||
func runTests(ctx context.Context, attempt int, pt *packageTests, otherArgs []string, ch chan<- *testAttempt) error {
|
||||
func runTests(ctx context.Context, attempt int, pt *packageTests, goTestArgs, testArgs []string, ch chan<- *testAttempt) error {
|
||||
defer close(ch)
|
||||
args := []string{"test", "-json", pt.Pattern}
|
||||
args = append(args, otherArgs...)
|
||||
args := []string{"test"}
|
||||
args = append(args, goTestArgs...)
|
||||
args = append(args, pt.Pattern)
|
||||
if len(pt.Tests) > 0 {
|
||||
runArg := strings.Join(pt.Tests, "|")
|
||||
args = append(args, "-run", runArg)
|
||||
args = append(args, "--run", runArg)
|
||||
}
|
||||
args = append(args, testArgs...)
|
||||
args = append(args, "-json")
|
||||
if debug {
|
||||
fmt.Println("running", strings.Join(args, " "))
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, "go", args...)
|
||||
if len(pt.Tests) > 0 {
|
||||
cmd.Env = append(os.Environ(), "TS_TEST_SHARD=") // clear test shard; run all tests we say to run
|
||||
}
|
||||
r, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
log.Printf("error creating stdout pipe: %v", err)
|
||||
@@ -178,54 +183,28 @@ func runTests(ctx context.Context, attempt int, pt *packageTests, otherArgs []st
|
||||
}
|
||||
|
||||
func main() {
|
||||
goTestArgs, packages, testArgs, err := splitArgs(os.Args[1:])
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
return
|
||||
}
|
||||
if len(packages) == 0 {
|
||||
fmt.Println("testwrapper: no packages specified")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// We only need to parse the -v flag to figure out whether to print the logs
|
||||
// for a test. We don't need to parse any other flags, so we just use the
|
||||
// flag package to parse the -v flag and then pass the rest of the args
|
||||
// through to 'go test'.
|
||||
// We run `go test -json` which returns the same information as `go test -v`,
|
||||
// but in a machine-readable format. So this flag is only for testwrapper's
|
||||
// output.
|
||||
v := flag.Bool("v", false, "verbose")
|
||||
|
||||
flag.Usage = func() {
|
||||
fmt.Println("usage: testwrapper [testwrapper-flags] [pattern] [build/test flags & test binary flags]")
|
||||
fmt.Println()
|
||||
fmt.Println("testwrapper-flags:")
|
||||
flag.CommandLine.PrintDefaults()
|
||||
fmt.Println()
|
||||
fmt.Println("examples:")
|
||||
fmt.Println("\ttestwrapper -v ./... -count=1")
|
||||
fmt.Println("\ttestwrapper ./pkg/foo -run TestBar -count=1")
|
||||
fmt.Println()
|
||||
fmt.Println("Unlike 'go test', testwrapper requires a package pattern as the first positional argument and only supports a single pattern.")
|
||||
}
|
||||
flag.Parse()
|
||||
|
||||
args := flag.Args()
|
||||
if len(args) < 1 || strings.HasPrefix(args[0], "-") {
|
||||
fmt.Println("no pattern specified")
|
||||
flag.Usage()
|
||||
os.Exit(1)
|
||||
} else if len(args) > 1 && !strings.HasPrefix(args[1], "-") {
|
||||
fmt.Println("expected single pattern")
|
||||
flag.Usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
pattern, otherArgs := args[0], args[1:]
|
||||
|
||||
type nextRun struct {
|
||||
tests []*packageTests
|
||||
attempt int // starting at 1
|
||||
}
|
||||
|
||||
toRun := []*nextRun{
|
||||
{
|
||||
tests: []*packageTests{{Pattern: pattern}},
|
||||
attempt: 1,
|
||||
},
|
||||
firstRun := &nextRun{
|
||||
attempt: 1,
|
||||
}
|
||||
for _, pkg := range packages {
|
||||
firstRun.tests = append(firstRun.tests, &packageTests{Pattern: pkg})
|
||||
}
|
||||
toRun := []*nextRun{firstRun}
|
||||
printPkgOutcome := func(pkg, outcome string, attempt int) {
|
||||
if outcome == "skip" {
|
||||
fmt.Printf("?\t%s [skipped/no tests] \n", pkg)
|
||||
@@ -263,7 +242,7 @@ func main() {
|
||||
runErr := make(chan error, 1)
|
||||
go func() {
|
||||
defer close(runErr)
|
||||
runErr <- runTests(ctx, thisRun.attempt, pt, otherArgs, ch)
|
||||
runErr <- runTests(ctx, thisRun.attempt, pt, goTestArgs, testArgs, ch)
|
||||
}()
|
||||
|
||||
var failed bool
|
||||
@@ -273,7 +252,7 @@ func main() {
|
||||
// convenient for us to to specify files in tests, so fix tr.pkg
|
||||
// so that subsequent testwrapper attempts run correctly.
|
||||
if tr.pkg == "command-line-arguments" {
|
||||
tr.pkg = pattern
|
||||
tr.pkg = packages[0]
|
||||
}
|
||||
if tr.pkgFinished {
|
||||
if tr.outcome == "fail" && len(toRetry[tr.pkg]) == 0 {
|
||||
@@ -285,7 +264,7 @@ func main() {
|
||||
printPkgOutcome(tr.pkg, tr.outcome, thisRun.attempt)
|
||||
continue
|
||||
}
|
||||
if *v || tr.outcome == "fail" {
|
||||
if testingVerbose || tr.outcome == "fail" {
|
||||
io.Copy(os.Stdout, &tr.logs)
|
||||
}
|
||||
if tr.outcome != "fail" {
|
||||
|
||||
@@ -125,6 +125,7 @@ func newIPN(jsConfig js.Value) map[string]any {
|
||||
return ns.DialContextTCP(ctx, dst)
|
||||
}
|
||||
sys.NetstackRouter.Set(true)
|
||||
sys.Tun.Get().Start()
|
||||
|
||||
logid := lpc.PublicID
|
||||
srv := ipnserver.New(logf, logid, sys.NetMon.Get())
|
||||
|
||||
@@ -55,6 +55,7 @@ import (
|
||||
"tailscale.com/util/clientmetric"
|
||||
"tailscale.com/util/multierr"
|
||||
"tailscale.com/util/singleflight"
|
||||
"tailscale.com/util/syspolicy"
|
||||
"tailscale.com/util/systemd"
|
||||
)
|
||||
|
||||
@@ -566,6 +567,11 @@ func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, new
|
||||
err = errors.New("hostinfo: BackendLogID missing")
|
||||
return regen, opt.URL, nil, err
|
||||
}
|
||||
|
||||
tailnet, err := syspolicy.GetString(syspolicy.Tailnet, "")
|
||||
if err != nil {
|
||||
c.logf("unable to provide Tailnet field in register request. err: %v", err)
|
||||
}
|
||||
now := c.clock.Now().Round(time.Second)
|
||||
request := tailcfg.RegisterRequest{
|
||||
Version: 1,
|
||||
@@ -577,6 +583,7 @@ func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, new
|
||||
Timestamp: &now,
|
||||
Ephemeral: (opt.Flags & LoginEphemeral) != 0,
|
||||
NodeKeySignature: nodeKeySignature,
|
||||
Tailnet: tailnet,
|
||||
}
|
||||
if opt.Logout {
|
||||
request.Expiry = time.Unix(123, 0) // far in the past
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build windows && cgo
|
||||
//go:build windows
|
||||
|
||||
// darwin,cgo is also supported by certstore but machineCertificateSubject will
|
||||
// need to be loaded by a different mechanism, so this is not currently enabled
|
||||
// on darwin.
|
||||
// darwin,cgo is also supported by certstore but untested, so it is not enabled.
|
||||
|
||||
package controlclient
|
||||
|
||||
@@ -21,7 +19,7 @@ import (
|
||||
"github.com/tailscale/certstore"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/key"
|
||||
"tailscale.com/util/winutil"
|
||||
"tailscale.com/util/syspolicy"
|
||||
)
|
||||
|
||||
var getMachineCertificateSubjectOnce struct {
|
||||
@@ -40,7 +38,7 @@ var getMachineCertificateSubjectOnce struct {
|
||||
// Example: "CN=Tailscale Inc Test Root CA,OU=Tailscale Inc Test Certificate Authority,O=Tailscale Inc,ST=ON,C=CA"
|
||||
func getMachineCertificateSubject() string {
|
||||
getMachineCertificateSubjectOnce.Do(func() {
|
||||
getMachineCertificateSubjectOnce.v, _ = winutil.GetRegString("MachineCertificateSubject")
|
||||
getMachineCertificateSubjectOnce.v, _ = syspolicy.GetString("MachineCertificateSubject", "")
|
||||
})
|
||||
|
||||
return getMachineCertificateSubjectOnce.v
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !windows || !cgo
|
||||
//go:build !windows
|
||||
|
||||
package controlclient
|
||||
|
||||
|
||||
@@ -7,9 +7,7 @@ package controlknobs
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"tailscale.com/syncs"
|
||||
"tailscale.com/tailcfg"
|
||||
@@ -54,10 +52,6 @@ type Knobs struct {
|
||||
// DisableDNSForwarderTCPRetries is whether the DNS forwarder should
|
||||
// skip retrying truncated queries over TCP.
|
||||
DisableDNSForwarderTCPRetries atomic.Bool
|
||||
|
||||
// MagicsockSessionActiveTimeout is an alternate magicsock session timeout
|
||||
// duration to use. If zero or unset, the default is used.
|
||||
MagicsockSessionActiveTimeout syncs.AtomicValue[time.Duration]
|
||||
}
|
||||
|
||||
// UpdateFromNodeAttributes updates k (if non-nil) based on the provided self
|
||||
@@ -97,17 +91,6 @@ func (k *Knobs) UpdateFromNodeAttributes(selfNodeAttrs []tailcfg.NodeCapability,
|
||||
k.DisableDeltaUpdates.Store(disableDeltaUpdates)
|
||||
k.PeerMTUEnable.Store(peerMTUEnable)
|
||||
k.DisableDNSForwarderTCPRetries.Store(dnsForwarderDisableTCPRetries)
|
||||
|
||||
var timeout time.Duration
|
||||
if vv := capMap[tailcfg.NodeAttrMagicsockSessionTimeout]; len(vv) > 0 {
|
||||
if v, _ := strconv.Unquote(string(vv[0])); v != "" {
|
||||
timeout, _ = time.ParseDuration(v)
|
||||
timeout = max(timeout, 0)
|
||||
}
|
||||
}
|
||||
if was := k.MagicsockSessionActiveTimeout.Load(); was != timeout {
|
||||
k.MagicsockSessionActiveTimeout.Store(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
// AsDebugJSON returns k as something that can be marshalled with json.Marshal
|
||||
@@ -126,6 +109,5 @@ func (k *Knobs) AsDebugJSON() map[string]any {
|
||||
"DisableDeltaUpdates": k.DisableDeltaUpdates.Load(),
|
||||
"PeerMTUEnable": k.PeerMTUEnable.Load(),
|
||||
"DisableDNSForwarderTCPRetries": k.DisableDNSForwarderTCPRetries.Load(),
|
||||
"MagicsockSessionActiveTimeout": k.MagicsockSessionActiveTimeout.Load().String(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,7 +261,7 @@ func parsePong(ver uint8, p []byte) (m *Pong, err error) {
|
||||
func MessageSummary(m Message) string {
|
||||
switch m := m.(type) {
|
||||
case *Ping:
|
||||
return fmt.Sprintf("ping tx=%x", m.TxID[:6])
|
||||
return fmt.Sprintf("ping tx=%x padding=%v", m.TxID[:6], m.Padding)
|
||||
case *Pong:
|
||||
return fmt.Sprintf("pong tx=%x", m.TxID[:6])
|
||||
case *CallMeMaybe:
|
||||
|
||||
@@ -11,7 +11,7 @@ spec:
|
||||
# the container. The `net.ipv4.ip_forward` sysctl is not allowlisted
|
||||
# in Kubelet by default.
|
||||
- name: sysctler
|
||||
image: busybox
|
||||
image: "ghcr.io/tailscale/tailscale:latest"
|
||||
securityContext:
|
||||
privileged: true
|
||||
command: ["/bin/sh"]
|
||||
|
||||
63
docs/sysv/tailscale.init
Executable file
63
docs/sysv/tailscale.init
Executable file
@@ -0,0 +1,63 @@
|
||||
#!/bin/sh
|
||||
# Copyright (c) Tailscale Inc & AUTHORS
|
||||
# SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
### BEGIN INIT INFO
|
||||
# Provides: tailscaled
|
||||
# Required-Start:
|
||||
# Required-Stop:
|
||||
# Default-Start:
|
||||
# Default-Stop:
|
||||
# Short-Description: Tailscale Mesh Wireguard VPN
|
||||
### END INIT INFO
|
||||
|
||||
set -e
|
||||
|
||||
# /etc/init.d/tailscale: start and stop the Tailscale VPN service
|
||||
|
||||
test -x /usr/sbin/tailscaled || exit 0
|
||||
|
||||
umask 022
|
||||
|
||||
. /lib/lsb/init-functions
|
||||
|
||||
# Are we running from init?
|
||||
run_by_init() {
|
||||
([ "$previous" ] && [ "$runlevel" ]) || [ "$runlevel" = S ]
|
||||
}
|
||||
|
||||
export PATH="${PATH:+$PATH:}/usr/sbin:/sbin"
|
||||
|
||||
case "$1" in
|
||||
start)
|
||||
log_daemon_msg "Starting Tailscale VPN" "tailscaled" || true
|
||||
if start-stop-daemon --start --oknodo --name tailscaled -m --pidfile /run/tailscaled.pid --background \
|
||||
--exec /usr/sbin/tailscaled -- \
|
||||
--state=/var/lib/tailscale/tailscaled.state \
|
||||
--socket=/run/tailscale/tailscaled.sock \
|
||||
--port 41641;
|
||||
then
|
||||
log_end_msg 0 || true
|
||||
else
|
||||
log_end_msg 1 || true
|
||||
fi
|
||||
;;
|
||||
stop)
|
||||
log_daemon_msg "Stopping Tailscale VPN" "tailscaled" || true
|
||||
if start-stop-daemon --stop --remove-pidfile --pidfile /run/tailscaled.pid --exec /usr/sbin/tailscaled; then
|
||||
log_end_msg 0 || true
|
||||
else
|
||||
log_end_msg 1 || true
|
||||
fi
|
||||
;;
|
||||
|
||||
status)
|
||||
status_of_proc -p /run/tailscaled.pid /usr/sbin/tailscaled tailscaled && exit 0 || exit $?
|
||||
;;
|
||||
|
||||
*)
|
||||
log_action_msg "Usage: /etc/init.d/tailscaled {start|stop|status}" || true
|
||||
exit 1
|
||||
esac
|
||||
|
||||
exit 0
|
||||
@@ -115,4 +115,4 @@
|
||||
in
|
||||
flake-utils.lib.eachDefaultSystem (system: flakeForSystem nixpkgs system);
|
||||
}
|
||||
# nix-direnv cache busting line: sha256-tCc7+umCKgOmKXbElnCmDI4ntPvvHldkxi+RwQuj9ng=
|
||||
# nix-direnv cache busting line: sha256-tzMLCNvIjG5e2aslmMt8GWgnfImd0J2a11xutOe59Ss=
|
||||
|
||||
13
go.mod
13
go.mod
@@ -6,7 +6,7 @@ require (
|
||||
filippo.io/mkcert v1.4.4
|
||||
github.com/Microsoft/go-winio v0.6.1
|
||||
github.com/akutz/memconn v0.1.0
|
||||
github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74
|
||||
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa
|
||||
github.com/andybalholm/brotli v1.0.5
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be
|
||||
github.com/aws/aws-sdk-go-v2 v1.21.0
|
||||
@@ -20,6 +20,7 @@ require (
|
||||
github.com/creack/pty v1.1.18
|
||||
github.com/dave/jennifer v1.7.0
|
||||
github.com/dblohm7/wingoes v0.0.0-20230929194252-e994401fc077
|
||||
github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e
|
||||
github.com/dsnet/try v0.0.3
|
||||
github.com/evanw/esbuild v0.19.4
|
||||
github.com/frankban/quicktest v1.14.5
|
||||
@@ -57,7 +58,7 @@ require (
|
||||
github.com/prometheus/client_golang v1.17.0
|
||||
github.com/prometheus/common v0.44.0
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
github.com/tailscale/certstore v0.1.1-0.20220316223106-78d6e1c49d8d
|
||||
github.com/tailscale/certstore v0.1.1-0.20231020161753-77811a65f4ff
|
||||
github.com/tailscale/depaware v0.0.0-20210622194025-720c4b409502
|
||||
github.com/tailscale/goexpect v0.0.0-20210902213824-6e8c725cea41
|
||||
github.com/tailscale/golang-x-crypto v0.0.0-20230713185742-f0b76a10a08e
|
||||
@@ -76,14 +77,14 @@ require (
|
||||
go.uber.org/zap v1.26.0
|
||||
go4.org/mem v0.0.0-20220726221520-4f986261bf13
|
||||
go4.org/netipx v0.0.0-20230824141953-6213f710f925
|
||||
golang.org/x/crypto v0.13.0
|
||||
golang.org/x/crypto v0.14.0
|
||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9
|
||||
golang.org/x/mod v0.12.0
|
||||
golang.org/x/net v0.15.0
|
||||
golang.org/x/net v0.17.0
|
||||
golang.org/x/oauth2 v0.12.0
|
||||
golang.org/x/sync v0.3.0
|
||||
golang.org/x/sys v0.12.0
|
||||
golang.org/x/term v0.12.0
|
||||
golang.org/x/sys v0.13.0
|
||||
golang.org/x/term v0.13.0
|
||||
golang.org/x/time v0.3.0
|
||||
golang.org/x/tools v0.13.0
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2
|
||||
|
||||
@@ -1 +1 @@
|
||||
sha256-tCc7+umCKgOmKXbElnCmDI4ntPvvHldkxi+RwQuj9ng=
|
||||
sha256-tzMLCNvIjG5e2aslmMt8GWgnfImd0J2a11xutOe59Ss=
|
||||
|
||||
26
go.sum
26
go.sum
@@ -93,8 +93,8 @@ github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuy
|
||||
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
|
||||
github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74 h1:Kk6a4nehpJ3UuJRqlA3JxYxBZEqCeOmATOvrbT4p9RA=
|
||||
github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
|
||||
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||
github.com/alexkohler/prealloc v1.0.0 h1:Hbq0/3fJPQhNkN0dR95AVrr6R7tou91y0uHG5pOcUuw=
|
||||
github.com/alexkohler/prealloc v1.0.0/go.mod h1:VetnK3dIgFBBKmg0YnD9F9x6Icjd+9cvfHR56wJVlKE=
|
||||
github.com/alingse/asasalint v0.0.11 h1:SFwnQXJ49Kx/1GghOFz1XGqHYKp21Kq1nHad/0WQRnw=
|
||||
@@ -233,6 +233,8 @@ github.com/dblohm7/wingoes v0.0.0-20230929194252-e994401fc077 h1:WphxHslVftszsr0
|
||||
github.com/dblohm7/wingoes v0.0.0-20230929194252-e994401fc077/go.mod h1:6NCrWM5jRefaG7iN0iMShPalLsljHWBh9v1zxM2f8Xs=
|
||||
github.com/denis-tingaikin/go-header v0.4.3 h1:tEaZKAlqql6SKCY++utLmkPLd6K8IBM20Ha7UVm+mtU=
|
||||
github.com/denis-tingaikin/go-header v0.4.3/go.mod h1:0wOCWuN71D5qIgE2nz9KrKmuYBAC2Mra5RassOIQ2/c=
|
||||
github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e h1:vUmf0yezR0y7jJ5pceLHthLaYf4bA5T14B6q39S4q2Q=
|
||||
github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e/go.mod h1:YTIHhz/QFSYnu/EhlF2SpU2Uk+32abacUYA5ZPljz1A=
|
||||
github.com/docker/cli v24.0.6+incompatible h1:fF+XCQCgJjjQNIMjzaSmiKJSCcfcXb3TWTcc7GAneOY=
|
||||
github.com/docker/cli v24.0.6+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
||||
github.com/docker/distribution v2.8.2+incompatible h1:T3de5rq0dB1j30rp0sA2rER+m322EBzniBPB6ZIzuh8=
|
||||
@@ -862,8 +864,8 @@ github.com/subosito/gotenv v1.4.2 h1:X1TuBLAMDFbaTAChgCBLu3DU3UPyELpnF2jjJ2cz/S8
|
||||
github.com/subosito/gotenv v1.4.2/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0=
|
||||
github.com/t-yuki/gocover-cobertura v0.0.0-20180217150009-aaee18c8195c h1:+aPplBwWcHBo6q9xrfWdMrT9o4kltkmmvpemgIjep/8=
|
||||
github.com/t-yuki/gocover-cobertura v0.0.0-20180217150009-aaee18c8195c/go.mod h1:SbErYREK7xXdsRiigaQiQkI9McGRzYMvlKYaP3Nimdk=
|
||||
github.com/tailscale/certstore v0.1.1-0.20220316223106-78d6e1c49d8d h1:K3j02b5j2Iw1xoggN9B2DIEkhWGheqFOeDkdJdBrJI8=
|
||||
github.com/tailscale/certstore v0.1.1-0.20220316223106-78d6e1c49d8d/go.mod h1:2P+hpOwd53e7JMX/L4f3VXkv1G+33ES6IWZSrkIeWNs=
|
||||
github.com/tailscale/certstore v0.1.1-0.20231020161753-77811a65f4ff h1:vnxdYZUJbsSRcIcduDW3DcQqfqaiv4FUgy25q8X+vfI=
|
||||
github.com/tailscale/certstore v0.1.1-0.20231020161753-77811a65f4ff/go.mod h1:XrBNfAFN+pwoWuksbFS9Ccxnopa15zJGgXRFN90l3K4=
|
||||
github.com/tailscale/depaware v0.0.0-20210622194025-720c4b409502 h1:34icjjmqJ2HPjrSuJYEkdZ+0ItmGQAQ75cRHIiftIyE=
|
||||
github.com/tailscale/depaware v0.0.0-20210622194025-720c4b409502/go.mod h1:p9lPsd+cx33L3H9nNoecRRxPssFKUwwI50I3pZ0yT+8=
|
||||
github.com/tailscale/goexpect v0.0.0-20210902213824-6e8c725cea41 h1:/V2rCMMWcsjYaYO2MeovLw+ClP63OtXgCF2Y1eb8+Ns=
|
||||
@@ -984,8 +986,8 @@ golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw
|
||||
golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
|
||||
golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
|
||||
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
|
||||
golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc=
|
||||
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||
@@ -1080,8 +1082,8 @@ golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
|
||||
golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
|
||||
golang.org/x/net v0.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
|
||||
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
@@ -1177,8 +1179,8 @@ golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.4.1-0.20230131160137-e7d7f63158de/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
|
||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
@@ -1187,8 +1189,8 @@ golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
|
||||
golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
|
||||
golang.org/x/term v0.12.0 h1:/ZfYdc3zq+q02Rv9vGqTeSItdzZTSNDmfTi0mBAuidU=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek=
|
||||
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
|
||||
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
|
||||
@@ -1 +1 @@
|
||||
f242beecd311476f6e6b9fa3052e253e2301e170
|
||||
d1c91593484a1db2d4de2564f2ef2669814af9c8
|
||||
|
||||
125
ipn/conf.go
Normal file
125
ipn/conf.go
Normal file
@@ -0,0 +1,125 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package ipn
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/opt"
|
||||
"tailscale.com/types/preftype"
|
||||
)
|
||||
|
||||
// ConfigVAlpha is the config file format for the "alpha0" version.
|
||||
type ConfigVAlpha struct {
|
||||
Version string // "alpha0" for now
|
||||
Locked opt.Bool `json:",omitempty"` // whether the config is locked from being changed by 'tailscale set'; it defaults to true
|
||||
|
||||
ServerURL *string `json:",omitempty"` // defaults to https://controlplane.tailscale.com
|
||||
AuthKey *string `json:",omitempty"` // as needed if NeedsLogin. either key or path to a file (if prefixed with "file:")
|
||||
Enabled opt.Bool `json:",omitempty"` // wantRunning; empty string defaults to true
|
||||
|
||||
OperatorUser *string `json:",omitempty"` // local user name who is allowed to operate tailscaled without being root or using sudo
|
||||
Hostname *string `json:",omitempty"`
|
||||
|
||||
AcceptDNS opt.Bool `json:"acceptDNS,omitempty"` // --accept-dns
|
||||
AcceptRoutes opt.Bool `json:"acceptRoutes,omitempty"`
|
||||
|
||||
ExitNode *string `json:"exitNode,omitempty"` // IP, StableID, or MagicDNS base name
|
||||
AllowLANWhileUsingExitNode opt.Bool `json:"allowLANWhileUsingExitNode,omitempty"`
|
||||
|
||||
AdvertiseRoutes []netip.Prefix `json:",omitempty"`
|
||||
DisableSNAT opt.Bool `json:",omitempty"`
|
||||
|
||||
NetfilterMode *string `json:",omitempty"` // "on", "off", "nodivert"
|
||||
|
||||
PostureChecking opt.Bool `json:",omitempty"`
|
||||
RunSSHServer opt.Bool `json:",omitempty"` // Tailscale SSH
|
||||
ShieldsUp opt.Bool `json:",omitempty"`
|
||||
AutoUpdate *AutoUpdatePrefs `json:",omitempty"`
|
||||
ServeConfigTemp *ServeConfig `json:",omitempty"` // TODO(bradfitz,maisem): make separate stable type for this
|
||||
|
||||
// TODO(bradfitz,maisem): future something like:
|
||||
// Profile map[string]*Config // keyed by alice@gmail.com, corp.com (TailnetSID)
|
||||
}
|
||||
|
||||
func (c *ConfigVAlpha) ToPrefs() (MaskedPrefs, error) {
|
||||
var mp MaskedPrefs
|
||||
if c == nil {
|
||||
return mp, nil
|
||||
}
|
||||
mp.WantRunning = !c.Enabled.EqualBool(false)
|
||||
mp.WantRunningSet = mp.WantRunning || c.Enabled != ""
|
||||
if c.ServerURL != nil {
|
||||
mp.ControlURL = *c.ServerURL
|
||||
mp.ControlURLSet = true
|
||||
}
|
||||
if c.AuthKey != nil && *c.AuthKey != "" {
|
||||
mp.LoggedOut = false
|
||||
mp.LoggedOutSet = true
|
||||
}
|
||||
if c.OperatorUser != nil {
|
||||
mp.OperatorUser = *c.OperatorUser
|
||||
mp.OperatorUserSet = true
|
||||
}
|
||||
if c.Hostname != nil {
|
||||
mp.Hostname = *c.Hostname
|
||||
mp.HostnameSet = true
|
||||
}
|
||||
if c.AcceptDNS != "" {
|
||||
mp.CorpDNS = c.AcceptDNS.EqualBool(true)
|
||||
mp.CorpDNSSet = true
|
||||
}
|
||||
if c.AcceptRoutes != "" {
|
||||
mp.RouteAll = c.AcceptRoutes.EqualBool(true)
|
||||
mp.RouteAllSet = true
|
||||
}
|
||||
if c.ExitNode != nil {
|
||||
ip, err := netip.ParseAddr(*c.ExitNode)
|
||||
if err == nil {
|
||||
mp.ExitNodeIP = ip
|
||||
mp.ExitNodeIPSet = true
|
||||
} else {
|
||||
mp.ExitNodeID = tailcfg.StableNodeID(*c.ExitNode)
|
||||
mp.ExitNodeIDSet = true
|
||||
}
|
||||
}
|
||||
if c.AllowLANWhileUsingExitNode != "" {
|
||||
mp.ExitNodeAllowLANAccess = c.AllowLANWhileUsingExitNode.EqualBool(true)
|
||||
mp.ExitNodeAllowLANAccessSet = true
|
||||
}
|
||||
if c.AdvertiseRoutes != nil {
|
||||
mp.AdvertiseRoutes = c.AdvertiseRoutes
|
||||
mp.AdvertiseRoutesSet = true
|
||||
}
|
||||
if c.DisableSNAT != "" {
|
||||
mp.NoSNAT = c.DisableSNAT.EqualBool(true)
|
||||
mp.NoSNAT = true
|
||||
}
|
||||
if c.NetfilterMode != nil {
|
||||
m, err := preftype.ParseNetfilterMode(*c.NetfilterMode)
|
||||
if err != nil {
|
||||
return mp, err
|
||||
}
|
||||
mp.NetfilterMode = m
|
||||
mp.NetfilterModeSet = true
|
||||
}
|
||||
if c.PostureChecking != "" {
|
||||
mp.PostureChecking = c.PostureChecking.EqualBool(true)
|
||||
mp.PostureCheckingSet = true
|
||||
}
|
||||
if c.RunSSHServer != "" {
|
||||
mp.RunSSH = c.RunSSHServer.EqualBool(true)
|
||||
mp.RunSSHSet = true
|
||||
}
|
||||
if c.ShieldsUp != "" {
|
||||
mp.ShieldsUp = c.ShieldsUp.EqualBool(true)
|
||||
mp.ShieldsUpSet = true
|
||||
}
|
||||
if c.AutoUpdate != nil {
|
||||
mp.AutoUpdate = *c.AutoUpdate
|
||||
mp.AutoUpdateSet = true
|
||||
}
|
||||
return mp, nil
|
||||
}
|
||||
77
ipn/conffile/conffile.go
Normal file
77
ipn/conffile/conffile.go
Normal file
@@ -0,0 +1,77 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package conffile contains code to load, manipulate, and access config file
|
||||
// settings.
|
||||
package conffile
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/tailscale/hujson"
|
||||
"tailscale.com/ipn"
|
||||
)
|
||||
|
||||
// Config describes a config file.
|
||||
type Config struct {
|
||||
Path string // disk path of HuJSON
|
||||
Raw []byte // raw bytes from disk, in HuJSON form
|
||||
Std []byte // standardized JSON form
|
||||
Version string // "alpha0" for now
|
||||
|
||||
// Parsed is the parsed config, converted from its on-disk version to the
|
||||
// latest known format.
|
||||
//
|
||||
// As of 2023-10-15 there is exactly one format ("alpha0") so this is both
|
||||
// the on-disk format and the in-memory upgraded format.
|
||||
Parsed ipn.ConfigVAlpha
|
||||
}
|
||||
|
||||
// WantRunning reports whether c is non-nil and it's configured to be running.
|
||||
func (c *Config) WantRunning() bool {
|
||||
return c != nil && !c.Parsed.Enabled.EqualBool(false)
|
||||
}
|
||||
|
||||
// Load reads and parses the config file at the provided path on disk.
|
||||
func Load(path string) (*Config, error) {
|
||||
var c Config
|
||||
c.Path = path
|
||||
|
||||
var err error
|
||||
c.Raw, err = os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.Std, err = hujson.Standardize(c.Raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error parsing config file %s HuJSON/JSON: %w", path, err)
|
||||
}
|
||||
var ver struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
if err := json.Unmarshal(c.Std, &ver); err != nil {
|
||||
return nil, fmt.Errorf("error parsing config file %s: %w", path, err)
|
||||
}
|
||||
switch ver.Version {
|
||||
case "":
|
||||
return nil, fmt.Errorf("error parsing config file %s: no \"version\" field defined", path)
|
||||
case "alpha0":
|
||||
default:
|
||||
return nil, fmt.Errorf("error parsing config file %s: unsupported \"version\" value %q; want \"alpha0\" for now", path, ver.Version)
|
||||
}
|
||||
c.Version = ver.Version
|
||||
|
||||
jd := json.NewDecoder(bytes.NewReader(c.Std))
|
||||
jd.DisallowUnknownFields()
|
||||
err = jd.Decode(&c.Parsed)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error parsing config file %s: %w", path, err)
|
||||
}
|
||||
if jd.More() {
|
||||
return nil, fmt.Errorf("error parsing config file %s: trailing data after JSON object", path)
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
@@ -52,6 +52,7 @@ var _PrefsCloneNeedsRegeneration = Prefs(struct {
|
||||
OperatorUser string
|
||||
ProfileName string
|
||||
AutoUpdate AutoUpdatePrefs
|
||||
PostureChecking bool
|
||||
Persist *persist.Persist
|
||||
}{})
|
||||
|
||||
|
||||
21
ipn/ipn_test.go
Normal file
21
ipn/ipn_test.go
Normal file
@@ -0,0 +1,21 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package ipn
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"tailscale.com/tstest/deptest"
|
||||
)
|
||||
|
||||
func TestDeps(t *testing.T) {
|
||||
deptest.DepChecker{
|
||||
BadDeps: map[string]string{
|
||||
"gvisor.dev/gvisor/pkg/buffer": "https://github.com/tailscale/tailscale/issues/9756",
|
||||
"gvisor.dev/gvisor/pkg/cpuid": "https://github.com/tailscale/tailscale/issues/9756",
|
||||
"gvisor.dev/gvisor/pkg/tcpip": "https://github.com/tailscale/tailscale/issues/9756",
|
||||
"gvisor.dev/gvisor/pkg/tcpip/header": "https://github.com/tailscale/tailscale/issues/9756",
|
||||
},
|
||||
}.Check(t)
|
||||
}
|
||||
@@ -87,6 +87,7 @@ func (v PrefsView) NetfilterMode() preftype.NetfilterMode { return v.ж.Netfilte
|
||||
func (v PrefsView) OperatorUser() string { return v.ж.OperatorUser }
|
||||
func (v PrefsView) ProfileName() string { return v.ж.ProfileName }
|
||||
func (v PrefsView) AutoUpdate() AutoUpdatePrefs { return v.ж.AutoUpdate }
|
||||
func (v PrefsView) PostureChecking() bool { return v.ж.PostureChecking }
|
||||
func (v PrefsView) Persist() persist.PersistView { return v.ж.Persist.View() }
|
||||
|
||||
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
|
||||
@@ -113,6 +114,7 @@ var _PrefsViewNeedsRegeneration = Prefs(struct {
|
||||
OperatorUser string
|
||||
ProfileName string
|
||||
AutoUpdate AutoUpdatePrefs
|
||||
PostureChecking bool
|
||||
Persist *persist.Persist
|
||||
}{})
|
||||
|
||||
|
||||
@@ -24,10 +24,12 @@ import (
|
||||
"tailscale.com/clientupdate"
|
||||
"tailscale.com/envknob"
|
||||
"tailscale.com/net/sockstats"
|
||||
"tailscale.com/posture"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/util/clientmetric"
|
||||
"tailscale.com/util/goroutines"
|
||||
"tailscale.com/util/httpm"
|
||||
"tailscale.com/util/syspolicy"
|
||||
"tailscale.com/version"
|
||||
)
|
||||
|
||||
@@ -67,6 +69,14 @@ func (b *LocalBackend) handleC2N(w http.ResponseWriter, r *http.Request) {
|
||||
} else {
|
||||
http.Error(w, "no log flusher wired up", http.StatusInternalServerError)
|
||||
}
|
||||
case "/posture/identity":
|
||||
switch r.Method {
|
||||
case httpm.GET:
|
||||
b.handleC2NPostureIdentityGet(w, r)
|
||||
default:
|
||||
http.Error(w, "bad method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
case "/debug/goroutines":
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.Write(goroutines.ScrubbedGoroutineDump(true))
|
||||
@@ -215,10 +225,42 @@ func (b *LocalBackend) handleC2NUpdatePost(w http.ResponseWriter, r *http.Reques
|
||||
}()
|
||||
}
|
||||
|
||||
func (b *LocalBackend) handleC2NPostureIdentityGet(w http.ResponseWriter, r *http.Request) {
|
||||
b.logf("c2n: GET /posture/identity received")
|
||||
|
||||
res := tailcfg.C2NPostureIdentityResponse{}
|
||||
|
||||
// Only collect serial numbers if enabled on the client,
|
||||
// this will first check syspolicy, MDM settings like Registry
|
||||
// on Windows or defaults on macOS. If they are not set, it falls
|
||||
// back to the cli-flag, `--posture-checking`.
|
||||
choice, err := syspolicy.GetPreferenceOption(syspolicy.PostureChecking)
|
||||
if err != nil {
|
||||
b.logf(
|
||||
"c2n: failed to read PostureChecking from syspolicy, returning default from CLI: %s; got error: %s",
|
||||
b.Prefs().PostureChecking(),
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
if choice.ShouldEnable(b.Prefs().PostureChecking()) {
|
||||
sns, err := posture.GetSerialNumbers(b.logf)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
res.SerialNumbers = sns
|
||||
} else {
|
||||
res.PostureDisabled = true
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(res)
|
||||
}
|
||||
|
||||
func (b *LocalBackend) newC2NUpdateResponse() tailcfg.C2NUpdateResponse {
|
||||
// If NewUpdater does not return an error, we can update the installation.
|
||||
// Exception: When version.IsMacSysExt returns true, we don't support that
|
||||
// yet. TODO(cpalmer, #6995): Implement it.
|
||||
//
|
||||
// Note that we create the Updater solely to check for errors; we do not
|
||||
// invoke it here. For this purpose, it is ok to pass it a zero Arguments.
|
||||
@@ -226,7 +268,7 @@ func (b *LocalBackend) newC2NUpdateResponse() tailcfg.C2NUpdateResponse {
|
||||
_, err := clientupdate.NewUpdater(clientupdate.Arguments{})
|
||||
return tailcfg.C2NUpdateResponse{
|
||||
Enabled: envknob.AllowsRemoteUpdate() || prefs.Apply,
|
||||
Supported: err == nil && !version.IsMacSysExt(),
|
||||
Supported: err == nil,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,9 +305,12 @@ func findCmdTailscale() (string, error) {
|
||||
}
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
if self == "/usr/sbin/tailscaled" {
|
||||
if self == "/usr/sbin/tailscaled" || self == "/usr/bin/tailscaled" {
|
||||
return "/usr/bin/tailscale", nil
|
||||
}
|
||||
if self == "/usr/local/sbin/tailscaled" || self == "/usr/local/bin/tailscaled" {
|
||||
return "/usr/local/bin/tailscale", nil
|
||||
}
|
||||
return "", errors.New("tailscale not found in expected place")
|
||||
case "windows":
|
||||
dir := filepath.Dir(self)
|
||||
|
||||
@@ -84,11 +84,9 @@ var acmeDebug = envknob.RegisterBool("TS_DEBUG_ACME")
|
||||
// ACME process. ACME process is used for new domain certs, existing expired
|
||||
// certs or existing certs that should get renewed due to upcoming expiry.
|
||||
//
|
||||
// syncRenewal changes renewal behavior for existing certs that are still valid
|
||||
// but need renewal. When syncRenewal is set, the method blocks until a new
|
||||
// cert is issued. When syncRenewal is not set, existing cert is returned right
|
||||
// away and renewal is kicked off in a background goroutine.
|
||||
func (b *LocalBackend) GetCertPEM(ctx context.Context, domain string, syncRenewal bool) (*TLSCertKeyPair, error) {
|
||||
// If a cert is expired, it will be renewed synchronously otherwise it will be
|
||||
// renewed asynchronously.
|
||||
func (b *LocalBackend) GetCertPEM(ctx context.Context, domain string) (*TLSCertKeyPair, error) {
|
||||
if !validLookingCertDomain(domain) {
|
||||
return nil, errors.New("invalid domain")
|
||||
}
|
||||
@@ -108,18 +106,16 @@ func (b *LocalBackend) GetCertPEM(ctx context.Context, domain string, syncRenewa
|
||||
}
|
||||
|
||||
if pair, err := getCertPEMCached(cs, domain, now); err == nil {
|
||||
shouldRenew, err := b.shouldStartDomainRenewal(cs, domain, now, pair)
|
||||
if err != nil {
|
||||
// If we got here, we have a valid unexpired cert.
|
||||
// Check whether we should start an async renewal.
|
||||
if shouldRenew, err := b.shouldStartDomainRenewal(cs, domain, now, pair); err != nil {
|
||||
logf("error checking for certificate renewal: %v", err)
|
||||
} else if !shouldRenew {
|
||||
return pair, nil
|
||||
}
|
||||
if !syncRenewal {
|
||||
} else if shouldRenew {
|
||||
logf("starting async renewal")
|
||||
// Start renewal in the background.
|
||||
go b.getCertPEM(context.Background(), cs, logf, traceACME, domain, now)
|
||||
}
|
||||
// Synchronous renewal happens below.
|
||||
return pair, nil
|
||||
}
|
||||
|
||||
pair, err := b.getCertPEM(ctx, cs, logf, traceACME, domain, now)
|
||||
@@ -130,6 +126,8 @@ func (b *LocalBackend) GetCertPEM(ctx context.Context, domain string, syncRenewa
|
||||
return pair, nil
|
||||
}
|
||||
|
||||
// shouldStartDomainRenewal reports whether the domain's cert should be renewed
|
||||
// based on the current time, the cert's expiry, and the ARI check.
|
||||
func (b *LocalBackend) shouldStartDomainRenewal(cs certStore, domain string, now time.Time, pair *TLSCertKeyPair) (bool, error) {
|
||||
renewMu.Lock()
|
||||
defer renewMu.Unlock()
|
||||
@@ -365,8 +363,9 @@ type TLSCertKeyPair struct {
|
||||
func keyFile(dir, domain string) string { return filepath.Join(dir, domain+".key") }
|
||||
func certFile(dir, domain string) string { return filepath.Join(dir, domain+".crt") }
|
||||
|
||||
// getCertPEMCached returns a non-nil keyPair and true if a cached keypair for
|
||||
// domain exists on disk in dir that is valid at the provided now time.
|
||||
// getCertPEMCached returns a non-nil keyPair if a cached keypair for domain
|
||||
// exists on disk in dir that is valid at the provided now time.
|
||||
//
|
||||
// If the keypair is expired, it returns errCertExpired.
|
||||
// If the keypair doesn't exist, it returns ipn.ErrStateNotExist.
|
||||
func getCertPEMCached(cs certStore, domain string, now time.Time) (p *TLSCertKeyPair, err error) {
|
||||
|
||||
@@ -12,6 +12,6 @@ type TLSCertKeyPair struct {
|
||||
CertPEM, KeyPEM []byte
|
||||
}
|
||||
|
||||
func (b *LocalBackend) GetCertPEM(ctx context.Context, domain string, syncRenewal bool) (*TLSCertKeyPair, error) {
|
||||
func (b *LocalBackend) GetCertPEM(ctx context.Context, domain string) (*TLSCertKeyPair, error) {
|
||||
return nil, errors.New("not implemented for js/wasm")
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"maps"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -44,6 +43,7 @@ import (
|
||||
"tailscale.com/health/healthmsg"
|
||||
"tailscale.com/hostinfo"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/conffile"
|
||||
"tailscale.com/ipn/ipnauth"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/ipn/policy"
|
||||
@@ -198,7 +198,8 @@ type LocalBackend struct {
|
||||
|
||||
// The mutex protects the following elements.
|
||||
mu sync.Mutex
|
||||
pm *profileManager // mu guards access
|
||||
conf *conffile.Config // latest parsed config, or nil if not in declarative mode
|
||||
pm *profileManager // mu guards access
|
||||
filterHash deephash.Sum
|
||||
httpTestClient *http.Client // for controlclient. nil by default, used by tests.
|
||||
ccGen clientGen // function for producing controlclient; lazily populated
|
||||
@@ -239,7 +240,6 @@ type LocalBackend struct {
|
||||
peerAPIServer *peerAPIServer // or nil
|
||||
peerAPIListeners []*peerAPIListener
|
||||
loginFlags controlclient.LoginFlags
|
||||
incomingFiles map[*incomingFile]bool
|
||||
fileWaiters set.HandleSet[context.CancelFunc] // of wake-up funcs
|
||||
notifyWatchers set.HandleSet[*watchSession]
|
||||
lastStatusTime time.Time // status.AsOf value of the last processed status update
|
||||
@@ -267,7 +267,7 @@ type LocalBackend struct {
|
||||
activeWatchSessions set.Set[string] // of WatchIPN SessionID
|
||||
|
||||
serveListeners map[netip.AddrPort]*serveListener // addrPort => serveListener
|
||||
serveProxyHandlers sync.Map // string (HTTPHandler.Proxy) => *httputil.ReverseProxy
|
||||
serveProxyHandlers sync.Map // string (HTTPHandler.Proxy) => *reverseProxy
|
||||
|
||||
// statusLock must be held before calling statusChanged.Wait() or
|
||||
// statusChanged.Broadcast().
|
||||
@@ -321,8 +321,18 @@ func NewLocalBackend(logf logger.Logf, logID logid.PublicID, sys *tsd.System, lo
|
||||
sds.SetDialer(dialer.SystemDial)
|
||||
}
|
||||
|
||||
hi := hostinfo.New()
|
||||
logf.JSON(1, "Hostinfo", hi)
|
||||
if sys.InitialConfig != nil {
|
||||
p := pm.CurrentPrefs().AsStruct()
|
||||
mp, err := sys.InitialConfig.Parsed.ToPrefs()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.ApplyEdits(&mp)
|
||||
if err := pm.SetPrefs(p.View(), ""); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
envknob.LogCurrent(logf)
|
||||
if dialer == nil {
|
||||
dialer = &tsdial.Dialer{Logf: logf}
|
||||
@@ -341,6 +351,7 @@ func NewLocalBackend(logf logger.Logf, logID logid.PublicID, sys *tsd.System, lo
|
||||
keyLogf: logger.LogOnChange(logf, 5*time.Minute, clock.Now),
|
||||
statsLogf: logger.LogOnChange(logf, 5*time.Minute, clock.Now),
|
||||
sys: sys,
|
||||
conf: sys.InitialConfig,
|
||||
e: e,
|
||||
dialer: dialer,
|
||||
store: store,
|
||||
@@ -519,6 +530,25 @@ func (b *LocalBackend) SetDirectFileDoFinalRename(v bool) {
|
||||
b.directFileDoFinalRename = v
|
||||
}
|
||||
|
||||
// ReloadCOnfig reloads the backend's config from disk.
|
||||
//
|
||||
// It returns (false, nil) if not running in declarative mode, (true, nil) on
|
||||
// success, or (false, error) on failure.
|
||||
func (b *LocalBackend) ReloadConfig() (ok bool, err error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if b.conf == nil {
|
||||
return false, nil
|
||||
}
|
||||
conf, err := conffile.Load(b.conf.Path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
b.conf = conf
|
||||
// TODO(bradfitz): apply things
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// pauseOrResumeControlClientLocked pauses b.cc if there is no network available
|
||||
// or if the LocalBackend is in Stopped state with a valid NetMap. In all other
|
||||
// cases, it unpauses it. It is a no-op if b.cc is nil.
|
||||
@@ -615,6 +645,9 @@ func (b *LocalBackend) Shutdown() {
|
||||
if b.sockstatLogger != nil {
|
||||
b.sockstatLogger.Shutdown()
|
||||
}
|
||||
if b.peerAPIServer != nil {
|
||||
b.peerAPIServer.taildrop.Shutdown()
|
||||
}
|
||||
|
||||
b.unregisterNetMon()
|
||||
b.unregisterHealthWatch()
|
||||
@@ -1452,6 +1485,17 @@ func (b *LocalBackend) Start(opts ipn.Options) error {
|
||||
}
|
||||
}
|
||||
profileID := b.pm.CurrentProfile().ID
|
||||
if b.state != ipn.Running && b.conf != nil && b.conf.Parsed.AuthKey != nil && opts.AuthKey == "" {
|
||||
v := *b.conf.Parsed.AuthKey
|
||||
if filename, ok := strings.CutPrefix(v, "file:"); ok {
|
||||
b, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error reading config file authKey: %w", err)
|
||||
}
|
||||
v = strings.TrimSpace(string(b))
|
||||
}
|
||||
opts.AuthKey = v
|
||||
}
|
||||
|
||||
// The iOS client sends a "Start" whenever its UI screen comes
|
||||
// up, just because it wants a netmap. That should be fixed,
|
||||
@@ -1474,10 +1518,12 @@ func (b *LocalBackend) Start(opts ipn.Options) error {
|
||||
}
|
||||
|
||||
hostinfo := hostinfo.New()
|
||||
applyConfigToHostinfo(hostinfo, b.conf)
|
||||
hostinfo.BackendLogID = b.backendLogID.String()
|
||||
hostinfo.FrontendLogID = opts.FrontendLogID
|
||||
hostinfo.Userspace.Set(b.sys.IsNetstack())
|
||||
hostinfo.UserspaceRouter.Set(b.sys.IsNetstackRouter())
|
||||
b.logf.JSON(1, "Hostinfo", hostinfo)
|
||||
|
||||
// TODO(apenwarr): avoid the need to reinit controlclient.
|
||||
// This will trigger a full relogin/reconfigure cycle every
|
||||
@@ -1627,6 +1673,7 @@ func (b *LocalBackend) Start(opts ipn.Options) error {
|
||||
}
|
||||
tkaHead = string(head)
|
||||
}
|
||||
confWantRunning := b.conf != nil && wantRunning
|
||||
b.mu.Unlock()
|
||||
|
||||
if endpoints != nil {
|
||||
@@ -1641,7 +1688,7 @@ func (b *LocalBackend) Start(opts ipn.Options) error {
|
||||
b.send(ipn.Notify{BackendLogID: &blid})
|
||||
b.send(ipn.Notify{Prefs: &prefs})
|
||||
|
||||
if !loggedOut && b.hasNodeKey() {
|
||||
if !loggedOut && (b.hasNodeKey() || confWantRunning) {
|
||||
// Even if !WantRunning, we should verify our key, if there
|
||||
// is one. If you want tailscaled to be completely idle,
|
||||
// use logout instead.
|
||||
@@ -1992,9 +2039,12 @@ func (b *LocalBackend) readPoller() {
|
||||
// ResendHostinfoIfNeeded is called to recompute the Hostinfo and send
|
||||
// the new version to the control server.
|
||||
func (b *LocalBackend) ResendHostinfoIfNeeded() {
|
||||
// TODO(maisem,bradfitz): this is all wrong. hostinfo has been modified
|
||||
// a dozen ways elsewhere that this omits. This method should be rethought.
|
||||
hi := hostinfo.New()
|
||||
|
||||
b.mu.Lock()
|
||||
applyConfigToHostinfo(hi, b.conf)
|
||||
if b.hostinfo != nil {
|
||||
hi.Services = b.hostinfo.Services
|
||||
}
|
||||
@@ -2004,6 +2054,15 @@ func (b *LocalBackend) ResendHostinfoIfNeeded() {
|
||||
b.doSetHostinfoFilterServices(hi)
|
||||
}
|
||||
|
||||
func applyConfigToHostinfo(hi *tailcfg.Hostinfo, c *conffile.Config) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
if c.Parsed.Hostname != nil {
|
||||
hi.Hostname = *c.Parsed.Hostname
|
||||
}
|
||||
}
|
||||
|
||||
// WatchNotifications subscribes to the ipn.Notify message bus notification
|
||||
// messages.
|
||||
//
|
||||
@@ -2157,6 +2216,12 @@ func (b *LocalBackend) DebugForceNetmapUpdate() {
|
||||
b.setNetMapLocked(nm)
|
||||
}
|
||||
|
||||
// DebugPickNewDERP forwards to magicsock.Conn.DebugPickNewDERP.
|
||||
// See its docs.
|
||||
func (b *LocalBackend) DebugPickNewDERP() error {
|
||||
return b.sys.MagicSock.Get().DebugPickNewDERP()
|
||||
}
|
||||
|
||||
// send delivers n to the connected frontend and any API watchers from
|
||||
// LocalBackend.WatchNotifications (via the LocalAPI).
|
||||
//
|
||||
@@ -2213,10 +2278,7 @@ func (b *LocalBackend) sendFileNotify() {
|
||||
// Make sure we always set n.IncomingFiles non-nil so it gets encoded
|
||||
// in JSON to clients. They distinguish between empty and non-nil
|
||||
// to know whether a Notify should be able about files.
|
||||
n.IncomingFiles = make([]ipn.PartialFile, 0)
|
||||
for f := range b.incomingFiles {
|
||||
n.IncomingFiles = append(n.IncomingFiles, f.PartialFile())
|
||||
}
|
||||
n.IncomingFiles = apiSrv.taildrop.IncomingFiles()
|
||||
b.mu.Unlock()
|
||||
|
||||
sort.Slice(n.IncomingFiles, func(i, j int) bool {
|
||||
@@ -2281,16 +2343,7 @@ func (b *LocalBackend) onClientVersion(v *tailcfg.ClientVersion) {
|
||||
b.mu.Lock()
|
||||
b.lastClientVersion = v
|
||||
b.mu.Unlock()
|
||||
switch runtime.GOOS {
|
||||
case "darwin", "ios":
|
||||
// These auto-update well enough, and we haven't converted the
|
||||
// ClientVersion types to Swift yet, so don't send them in ipn.Notify
|
||||
// messages.
|
||||
default:
|
||||
// But everything else is a Go client and can deal with this field, even
|
||||
// if they ignore it.
|
||||
b.send(ipn.Notify{ClientVersion: v})
|
||||
}
|
||||
b.send(ipn.Notify{ClientVersion: v})
|
||||
}
|
||||
|
||||
// For testing lazy machine key generation.
|
||||
@@ -2707,7 +2760,19 @@ func (b *LocalBackend) CheckPrefs(p *ipn.Prefs) error {
|
||||
return b.checkPrefsLocked(p)
|
||||
}
|
||||
|
||||
// isConfigLocked_Locked reports whether the parsed config file is locked.
|
||||
// b.mu must be held.
|
||||
func (b *LocalBackend) isConfigLocked_Locked() bool {
|
||||
// TODO(bradfitz,maisem): make this more fine-grained, permit changing
|
||||
// some things if they're not explicitly set in the config. But for now
|
||||
// (2023-10-16), just blanket disable everything.
|
||||
return b.conf != nil && !b.conf.Parsed.Locked.EqualBool(false)
|
||||
}
|
||||
|
||||
func (b *LocalBackend) checkPrefsLocked(p *ipn.Prefs) error {
|
||||
if b.isConfigLocked_Locked() {
|
||||
return errors.New("can't reconfigure tailscaled when using a config file; config file is locked")
|
||||
}
|
||||
var errs []error
|
||||
if p.Hostname == "badhostname.tailscale." {
|
||||
// Keep this one just for testing.
|
||||
@@ -3548,13 +3613,14 @@ func (b *LocalBackend) initPeerAPIListener() {
|
||||
|
||||
ps := &peerAPIServer{
|
||||
b: b,
|
||||
taildrop: &taildrop.Handler{
|
||||
Logf: b.logf,
|
||||
Clock: b.clock,
|
||||
RootDir: fileRoot,
|
||||
DirectFileMode: b.directFileRoot != "",
|
||||
DirectFileDoFinalRename: b.directFileDoFinalRename,
|
||||
},
|
||||
taildrop: taildrop.ManagerOptions{
|
||||
Logf: b.logf,
|
||||
Clock: tstime.DefaultClock{Clock: b.clock},
|
||||
Dir: fileRoot,
|
||||
DirectFileMode: b.directFileRoot != "",
|
||||
AvoidFinalRename: !b.directFileDoFinalRename,
|
||||
SendFileNotify: b.sendFileNotify,
|
||||
}.New(),
|
||||
}
|
||||
if dm, ok := b.sys.DNSManager.GetOK(); ok {
|
||||
ps.resolver = dm.Resolver()
|
||||
@@ -3768,6 +3834,7 @@ func (b *LocalBackend) applyPrefsToHostinfoLocked(hi *tailcfg.Hostinfo, prefs ip
|
||||
hi.RoutableIPs = prefs.AdvertiseRoutes().AsSlice()
|
||||
hi.RequestTags = prefs.AdvertiseTags().AsSlice()
|
||||
hi.ShieldsUp = prefs.ShieldsUp()
|
||||
hi.AllowsUpdate = envknob.AllowsRemoteUpdate() || prefs.AutoUpdate().Apply
|
||||
|
||||
var sshHostKeys []string
|
||||
if prefs.RunSSH() && envknob.CanSSHD() {
|
||||
@@ -4370,8 +4437,8 @@ func (b *LocalBackend) setServeProxyHandlersLocked() {
|
||||
backend := key.(string)
|
||||
if !backends[backend] {
|
||||
b.logf("serve: closing idle connections to %s", backend)
|
||||
value.(*httputil.ReverseProxy).Transport.(*http.Transport).CloseIdleConnections()
|
||||
b.serveProxyHandlers.Delete(backend)
|
||||
value.(*reverseProxy).close()
|
||||
}
|
||||
return true
|
||||
})
|
||||
@@ -4590,19 +4657,6 @@ func (b *LocalBackend) SetDNS(ctx context.Context, name, value string) error {
|
||||
return cc.SetDNS(ctx, req)
|
||||
}
|
||||
|
||||
func (b *LocalBackend) registerIncomingFile(inf *incomingFile, active bool) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if b.incomingFiles == nil {
|
||||
b.incomingFiles = make(map[*incomingFile]bool)
|
||||
}
|
||||
if active {
|
||||
b.incomingFiles[inf] = true
|
||||
} else {
|
||||
delete(b.incomingFiles, inf)
|
||||
}
|
||||
}
|
||||
|
||||
func peerAPIPorts(peer tailcfg.NodeView) (p4, p6 uint16) {
|
||||
svcs := peer.Hostinfo().Services()
|
||||
for i := range svcs.LenIter() {
|
||||
|
||||
@@ -39,10 +39,9 @@ import (
|
||||
"tailscale.com/net/sockstats"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/taildrop"
|
||||
"tailscale.com/tstime"
|
||||
"tailscale.com/types/views"
|
||||
"tailscale.com/util/clientmetric"
|
||||
"tailscale.com/version/distro"
|
||||
"tailscale.com/util/httphdr"
|
||||
"tailscale.com/wgengine/filter"
|
||||
)
|
||||
|
||||
@@ -56,7 +55,7 @@ type peerAPIServer struct {
|
||||
b *LocalBackend
|
||||
resolver *resolver.Resolver
|
||||
|
||||
taildrop *taildrop.Handler
|
||||
taildrop *taildrop.Manager
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -307,7 +306,9 @@ func (h *peerAPIHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
}
|
||||
if strings.HasPrefix(r.URL.Path, "/v0/put/") {
|
||||
metricPutCalls.Add(1)
|
||||
if r.Method == "PUT" {
|
||||
metricPutCalls.Add(1)
|
||||
}
|
||||
h.handlePeerPut(w, r)
|
||||
return
|
||||
}
|
||||
@@ -586,64 +587,6 @@ func (h *peerAPIHandler) handleServeSockStats(w http.ResponseWriter, r *http.Req
|
||||
fmt.Fprintln(w, "</pre>")
|
||||
}
|
||||
|
||||
type incomingFile struct {
|
||||
clock tstime.Clock
|
||||
|
||||
name string // "foo.jpg"
|
||||
started time.Time
|
||||
size int64 // or -1 if unknown; never 0
|
||||
w io.Writer // underlying writer
|
||||
sendFileNotify func() // called when done
|
||||
partialPath string // non-empty in direct mode
|
||||
|
||||
mu sync.Mutex
|
||||
copied int64
|
||||
done bool
|
||||
lastNotify time.Time
|
||||
}
|
||||
|
||||
func (f *incomingFile) markAndNotifyDone() {
|
||||
f.mu.Lock()
|
||||
f.done = true
|
||||
f.mu.Unlock()
|
||||
f.sendFileNotify()
|
||||
}
|
||||
|
||||
func (f *incomingFile) Write(p []byte) (n int, err error) {
|
||||
n, err = f.w.Write(p)
|
||||
|
||||
var needNotify bool
|
||||
defer func() {
|
||||
if needNotify {
|
||||
f.sendFileNotify()
|
||||
}
|
||||
}()
|
||||
if n > 0 {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.copied += int64(n)
|
||||
now := f.clock.Now()
|
||||
if f.lastNotify.IsZero() || now.Sub(f.lastNotify) > time.Second {
|
||||
f.lastNotify = now
|
||||
needNotify = true
|
||||
}
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (f *incomingFile) PartialFile() ipn.PartialFile {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return ipn.PartialFile{
|
||||
Name: f.name,
|
||||
Started: f.started,
|
||||
DeclaredSize: f.size,
|
||||
Received: f.copied,
|
||||
PartialPath: f.partialPath,
|
||||
Done: f.done,
|
||||
}
|
||||
}
|
||||
|
||||
// canPutFile reports whether h can put a file ("Taildrop") to this node.
|
||||
func (h *peerAPIHandler) canPutFile() bool {
|
||||
if h.peerNode.UnsignedPeerAPIOnly() {
|
||||
@@ -687,129 +630,97 @@ func (h *peerAPIHandler) peerHasCap(wantCap tailcfg.PeerCapability) bool {
|
||||
}
|
||||
|
||||
func (h *peerAPIHandler) handlePeerPut(w http.ResponseWriter, r *http.Request) {
|
||||
if !envknob.CanTaildrop() {
|
||||
http.Error(w, "Taildrop disabled on device", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if !h.canPutFile() {
|
||||
http.Error(w, "Taildrop access denied", http.StatusForbidden)
|
||||
http.Error(w, taildrop.ErrNoTaildrop.Error(), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if !h.ps.b.hasCapFileSharing() {
|
||||
http.Error(w, "file sharing not enabled by Tailscale admin", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if r.Method != "PUT" {
|
||||
http.Error(w, "expected method PUT", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if mayDeref(h.ps.taildrop).RootDir == "" {
|
||||
http.Error(w, taildrop.ErrNoTaildrop.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if distro.Get() == distro.Unraid && !h.ps.taildrop.DirectFileMode {
|
||||
http.Error(w, "Taildrop folder not configured or accessible", http.StatusInternalServerError)
|
||||
http.Error(w, taildrop.ErrNoTaildrop.Error(), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
rawPath := r.URL.EscapedPath()
|
||||
suffix, ok := strings.CutPrefix(rawPath, "/v0/put/")
|
||||
prefix, ok := strings.CutPrefix(rawPath, "/v0/put/")
|
||||
if !ok {
|
||||
http.Error(w, "misconfigured internals", 500)
|
||||
http.Error(w, "misconfigured internals", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if suffix == "" {
|
||||
http.Error(w, "empty filename", 400)
|
||||
return
|
||||
}
|
||||
if strings.Contains(suffix, "/") {
|
||||
http.Error(w, "directories not supported", 400)
|
||||
return
|
||||
}
|
||||
baseName, err := url.PathUnescape(suffix)
|
||||
baseName, err := url.PathUnescape(prefix)
|
||||
if err != nil {
|
||||
http.Error(w, "bad path encoding", 400)
|
||||
http.Error(w, taildrop.ErrInvalidFileName.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
dstFile, ok := h.ps.taildrop.DiskPath(baseName)
|
||||
if !ok {
|
||||
http.Error(w, "bad filename", 400)
|
||||
return
|
||||
}
|
||||
t0 := h.ps.b.clock.Now()
|
||||
// TODO(bradfitz): prevent same filename being sent by two peers at once
|
||||
enc := json.NewEncoder(w)
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
id := taildrop.ClientID(h.peerNode.StableID())
|
||||
if prefix == "" {
|
||||
// List all the partial files.
|
||||
files, err := h.ps.taildrop.PartialFiles(id)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := enc.Encode(files); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
h.logf("json.Encoder.Encode error: %v", err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Stream all the block hashes for the specified file.
|
||||
next, close, err := h.ps.taildrop.HashPartialFile(id, baseName)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer close()
|
||||
for {
|
||||
switch cs, err := next(); {
|
||||
case err == io.EOF:
|
||||
return
|
||||
case err != nil:
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
h.logf("HashPartialFile.next error: %v", err)
|
||||
return
|
||||
default:
|
||||
if err := enc.Encode(cs); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
h.logf("json.Encoder.Encode error: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
case "PUT":
|
||||
t0 := h.ps.b.clock.Now()
|
||||
id := taildrop.ClientID(h.peerNode.StableID())
|
||||
|
||||
// prevent same filename being sent twice
|
||||
if _, err := os.Stat(dstFile); err == nil {
|
||||
http.Error(w, "file exists", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
partialFile := dstFile + taildrop.PartialSuffix
|
||||
f, err := os.Create(partialFile)
|
||||
if err != nil {
|
||||
h.logf("put Create error: %v", taildrop.RedactErr(err))
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var success bool
|
||||
defer func() {
|
||||
if !success {
|
||||
os.Remove(partialFile)
|
||||
var offset int64
|
||||
if rangeHdr := r.Header.Get("Range"); rangeHdr != "" {
|
||||
ranges, ok := httphdr.ParseRange(rangeHdr)
|
||||
if !ok || len(ranges) != 1 || ranges[0].Length != 0 {
|
||||
http.Error(w, "invalid Range header", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
offset = ranges[0].Start
|
||||
}
|
||||
}()
|
||||
var finalSize int64
|
||||
var inFile *incomingFile
|
||||
if r.ContentLength != 0 {
|
||||
inFile = &incomingFile{
|
||||
clock: h.ps.b.clock,
|
||||
name: baseName,
|
||||
started: h.ps.b.clock.Now(),
|
||||
size: r.ContentLength,
|
||||
w: f,
|
||||
sendFileNotify: h.ps.b.sendFileNotify,
|
||||
}
|
||||
if h.ps.taildrop.DirectFileMode {
|
||||
inFile.partialPath = partialFile
|
||||
}
|
||||
h.ps.b.registerIncomingFile(inFile, true)
|
||||
defer h.ps.b.registerIncomingFile(inFile, false)
|
||||
n, err := io.Copy(inFile, r.Body)
|
||||
if err != nil {
|
||||
err = taildrop.RedactErr(err)
|
||||
f.Close()
|
||||
h.logf("put Copy error: %v", err)
|
||||
n, err := h.ps.taildrop.PutFile(taildrop.ClientID(fmt.Sprint(id)), baseName, r.Body, offset, r.ContentLength)
|
||||
switch err {
|
||||
case nil:
|
||||
d := h.ps.b.clock.Since(t0).Round(time.Second / 10)
|
||||
h.logf("got put of %s in %v from %v/%v", approxSize(n), d, h.remoteAddr.Addr(), h.peerNode.ComputedName)
|
||||
io.WriteString(w, "{}\n")
|
||||
case taildrop.ErrNoTaildrop:
|
||||
http.Error(w, err.Error(), http.StatusForbidden)
|
||||
case taildrop.ErrInvalidFileName:
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
case taildrop.ErrFileExists:
|
||||
http.Error(w, err.Error(), http.StatusConflict)
|
||||
default:
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
finalSize = n
|
||||
default:
|
||||
http.Error(w, "expected method GET or PUT", http.StatusMethodNotAllowed)
|
||||
}
|
||||
if err := taildrop.RedactErr(f.Close()); err != nil {
|
||||
h.logf("put Close error: %v", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if h.ps.taildrop.DirectFileMode && !h.ps.taildrop.DirectFileDoFinalRename {
|
||||
if inFile != nil { // non-zero length; TODO: notify even for zero length
|
||||
inFile.markAndNotifyDone()
|
||||
}
|
||||
} else {
|
||||
if err := os.Rename(partialFile, dstFile); err != nil {
|
||||
err = taildrop.RedactErr(err)
|
||||
h.logf("put final rename: %v", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
d := h.ps.b.clock.Since(t0).Round(time.Second / 10)
|
||||
h.logf("got put of %s in %v from %v/%v", approxSize(finalSize), d, h.remoteAddr.Addr(), h.peerNode.ComputedName)
|
||||
|
||||
// TODO: set modtime
|
||||
// TODO: some real response
|
||||
success = true
|
||||
io.WriteString(w, "{}\n")
|
||||
h.ps.taildrop.KnownEmpty.Store(false)
|
||||
h.ps.b.sendFileNotify()
|
||||
}
|
||||
|
||||
func approxSize(n int64) string {
|
||||
@@ -882,7 +793,7 @@ func (h *peerAPIHandler) handleServeDNSFwd(w http.ResponseWriter, r *http.Reques
|
||||
}
|
||||
dh := health.DebugHandler("dnsfwd")
|
||||
if dh == nil {
|
||||
http.Error(w, "not wired up", 500)
|
||||
http.Error(w, "not wired up", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
dh.ServeHTTP(w, r)
|
||||
@@ -1020,9 +931,9 @@ func (h *peerAPIHandler) handleDNSQuery(w http.ResponseWriter, r *http.Request)
|
||||
if err != nil {
|
||||
h.logf("handleDNS fwd error: %v", err)
|
||||
if err := ctx.Err(); err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
} else {
|
||||
http.Error(w, "DNS forwarding error", 500)
|
||||
http.Error(w, "DNS forwarding error", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ package ipnlocal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
@@ -15,11 +14,12 @@ import (
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"go4.org/netipx"
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/store/mem"
|
||||
"tailscale.com/tailcfg"
|
||||
@@ -68,7 +68,7 @@ func bodyNotContains(sub string) check {
|
||||
|
||||
func fileHasSize(name string, size int) check {
|
||||
return func(t *testing.T, e *peerAPITestEnv) {
|
||||
root := e.ph.ps.taildrop.RootDir
|
||||
root := e.ph.ps.taildrop.Dir()
|
||||
if root == "" {
|
||||
t.Errorf("no rootdir; can't check whether %q has size %v", name, size)
|
||||
return
|
||||
@@ -84,7 +84,7 @@ func fileHasSize(name string, size int) check {
|
||||
|
||||
func fileHasContents(name string, want string) check {
|
||||
return func(t *testing.T, e *peerAPITestEnv) {
|
||||
root := e.ph.ps.taildrop.RootDir
|
||||
root := e.ph.ps.taildrop.Dir()
|
||||
if root == "" {
|
||||
t.Errorf("no rootdir; can't check contents of %q", name)
|
||||
return
|
||||
@@ -173,7 +173,7 @@ func TestHandlePeerAPI(t *testing.T) {
|
||||
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/foo", nil)},
|
||||
checks: checks(
|
||||
httpStatus(http.StatusForbidden),
|
||||
bodyContains("Taildrop access denied"),
|
||||
bodyContains("Taildrop disabled"),
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -183,7 +183,7 @@ func TestHandlePeerAPI(t *testing.T) {
|
||||
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/foo", nil)},
|
||||
checks: checks(
|
||||
httpStatus(http.StatusForbidden),
|
||||
bodyContains("file sharing not enabled by Tailscale admin"),
|
||||
bodyContains("Taildrop disabled"),
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -193,7 +193,7 @@ func TestHandlePeerAPI(t *testing.T) {
|
||||
capSharing: true,
|
||||
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/foo", nil)},
|
||||
checks: checks(
|
||||
httpStatus(http.StatusInternalServerError),
|
||||
httpStatus(http.StatusForbidden),
|
||||
bodyContains("Taildrop disabled; no storage directory"),
|
||||
),
|
||||
},
|
||||
@@ -204,7 +204,7 @@ func TestHandlePeerAPI(t *testing.T) {
|
||||
reqs: []*http.Request{httptest.NewRequest("POST", "/v0/put/foo", nil)},
|
||||
checks: checks(
|
||||
httpStatus(405),
|
||||
bodyContains("expected method PUT"),
|
||||
bodyContains("expected method GET or PUT"),
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -250,7 +250,7 @@ func TestHandlePeerAPI(t *testing.T) {
|
||||
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/foo.partial", nil)},
|
||||
checks: checks(
|
||||
httpStatus(400),
|
||||
bodyContains("bad filename"),
|
||||
bodyContains("invalid filename"),
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -260,7 +260,7 @@ func TestHandlePeerAPI(t *testing.T) {
|
||||
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/foo.deleted", nil)},
|
||||
checks: checks(
|
||||
httpStatus(400),
|
||||
bodyContains("bad filename"),
|
||||
bodyContains("invalid filename"),
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -270,7 +270,7 @@ func TestHandlePeerAPI(t *testing.T) {
|
||||
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/.", nil)},
|
||||
checks: checks(
|
||||
httpStatus(400),
|
||||
bodyContains("bad filename"),
|
||||
bodyContains("invalid filename"),
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -280,7 +280,7 @@ func TestHandlePeerAPI(t *testing.T) {
|
||||
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/", nil)},
|
||||
checks: checks(
|
||||
httpStatus(400),
|
||||
bodyContains("empty filename"),
|
||||
bodyContains("invalid filename"),
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -290,7 +290,7 @@ func TestHandlePeerAPI(t *testing.T) {
|
||||
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/foo/bar", nil)},
|
||||
checks: checks(
|
||||
httpStatus(400),
|
||||
bodyContains("directories not supported"),
|
||||
bodyContains("invalid filename"),
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -300,7 +300,7 @@ func TestHandlePeerAPI(t *testing.T) {
|
||||
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/"+hexAll("."), nil)},
|
||||
checks: checks(
|
||||
httpStatus(400),
|
||||
bodyContains("bad filename"),
|
||||
bodyContains("invalid filename"),
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -310,7 +310,7 @@ func TestHandlePeerAPI(t *testing.T) {
|
||||
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/"+hexAll("/"), nil)},
|
||||
checks: checks(
|
||||
httpStatus(400),
|
||||
bodyContains("bad filename"),
|
||||
bodyContains("invalid filename"),
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -320,7 +320,7 @@ func TestHandlePeerAPI(t *testing.T) {
|
||||
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/"+hexAll("\\"), nil)},
|
||||
checks: checks(
|
||||
httpStatus(400),
|
||||
bodyContains("bad filename"),
|
||||
bodyContains("invalid filename"),
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -330,7 +330,7 @@ func TestHandlePeerAPI(t *testing.T) {
|
||||
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/"+hexAll(".."), nil)},
|
||||
checks: checks(
|
||||
httpStatus(400),
|
||||
bodyContains("bad filename"),
|
||||
bodyContains("invalid filename"),
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -340,7 +340,7 @@ func TestHandlePeerAPI(t *testing.T) {
|
||||
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/"+hexAll("foo/../../../../../etc/passwd"), nil)},
|
||||
checks: checks(
|
||||
httpStatus(400),
|
||||
bodyContains("bad filename"),
|
||||
bodyContains("invalid filename"),
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -372,7 +372,7 @@ func TestHandlePeerAPI(t *testing.T) {
|
||||
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/"+(hexAll("😜")[:3]), nil)},
|
||||
checks: checks(
|
||||
httpStatus(400),
|
||||
bodyContains("bad filename"),
|
||||
bodyContains("invalid filename"),
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -382,7 +382,7 @@ func TestHandlePeerAPI(t *testing.T) {
|
||||
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/%00", nil)},
|
||||
checks: checks(
|
||||
httpStatus(400),
|
||||
bodyContains("bad filename"),
|
||||
bodyContains("invalid filename"),
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -392,7 +392,7 @@ func TestHandlePeerAPI(t *testing.T) {
|
||||
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/%01", nil)},
|
||||
checks: checks(
|
||||
httpStatus(400),
|
||||
bodyContains("bad filename"),
|
||||
bodyContains("invalid filename"),
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -402,7 +402,7 @@ func TestHandlePeerAPI(t *testing.T) {
|
||||
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/"+hexAll("nul:"), nil)},
|
||||
checks: checks(
|
||||
httpStatus(400),
|
||||
bodyContains("bad filename"),
|
||||
bodyContains("invalid filename"),
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -412,7 +412,7 @@ func TestHandlePeerAPI(t *testing.T) {
|
||||
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/"+hexAll(" foo "), nil)},
|
||||
checks: checks(
|
||||
httpStatus(400),
|
||||
bodyContains("bad filename"),
|
||||
bodyContains("invalid filename"),
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -443,23 +443,69 @@ func TestHandlePeerAPI(t *testing.T) {
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "bad_duplicate_zero_length",
|
||||
name: "duplicate_zero_length",
|
||||
isSelf: true,
|
||||
capSharing: true,
|
||||
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/foo", nil), httptest.NewRequest("PUT", "/v0/put/foo", nil)},
|
||||
reqs: []*http.Request{
|
||||
httptest.NewRequest("PUT", "/v0/put/foo", nil),
|
||||
httptest.NewRequest("PUT", "/v0/put/foo", nil),
|
||||
},
|
||||
checks: checks(
|
||||
httpStatus(409),
|
||||
bodyContains("file exists"),
|
||||
httpStatus(200),
|
||||
func(t *testing.T, env *peerAPITestEnv) {
|
||||
got, err := env.ph.ps.taildrop.WaitingFiles()
|
||||
if err != nil {
|
||||
t.Fatalf("WaitingFiles error: %v", err)
|
||||
}
|
||||
want := []apitype.WaitingFile{{Name: "foo", Size: 0}}
|
||||
if diff := cmp.Diff(got, want); diff != "" {
|
||||
t.Fatalf("WaitingFile mismatch (-got +want):\n%s", diff)
|
||||
}
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "bad_duplicate_non_zero_length_content_length",
|
||||
name: "duplicate_non_zero_length_content_length",
|
||||
isSelf: true,
|
||||
capSharing: true,
|
||||
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/foo", strings.NewReader("contents")), httptest.NewRequest("PUT", "/v0/put/foo", strings.NewReader("contents"))},
|
||||
reqs: []*http.Request{
|
||||
httptest.NewRequest("PUT", "/v0/put/foo", strings.NewReader("contents")),
|
||||
httptest.NewRequest("PUT", "/v0/put/foo", strings.NewReader("contents")),
|
||||
},
|
||||
checks: checks(
|
||||
httpStatus(409),
|
||||
bodyContains("file exists"),
|
||||
httpStatus(200),
|
||||
func(t *testing.T, env *peerAPITestEnv) {
|
||||
got, err := env.ph.ps.taildrop.WaitingFiles()
|
||||
if err != nil {
|
||||
t.Fatalf("WaitingFiles error: %v", err)
|
||||
}
|
||||
want := []apitype.WaitingFile{{Name: "foo", Size: 8}}
|
||||
if diff := cmp.Diff(got, want); diff != "" {
|
||||
t.Fatalf("WaitingFile mismatch (-got +want):\n%s", diff)
|
||||
}
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "duplicate_different_files",
|
||||
isSelf: true,
|
||||
capSharing: true,
|
||||
reqs: []*http.Request{
|
||||
httptest.NewRequest("PUT", "/v0/put/foo", strings.NewReader("fizz")),
|
||||
httptest.NewRequest("PUT", "/v0/put/foo", strings.NewReader("buzz")),
|
||||
},
|
||||
checks: checks(
|
||||
httpStatus(200),
|
||||
func(t *testing.T, env *peerAPITestEnv) {
|
||||
got, err := env.ph.ps.taildrop.WaitingFiles()
|
||||
if err != nil {
|
||||
t.Fatalf("WaitingFiles error: %v", err)
|
||||
}
|
||||
want := []apitype.WaitingFile{{Name: "foo", Size: 4}, {Name: "foo (1)", Size: 4}}
|
||||
if diff := cmp.Diff(got, want); diff != "" {
|
||||
t.Fatalf("WaitingFile mismatch (-got +want):\n%s", diff)
|
||||
}
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
@@ -494,9 +540,11 @@ func TestHandlePeerAPI(t *testing.T) {
|
||||
if !tt.omitRoot {
|
||||
rootDir = t.TempDir()
|
||||
if e.ph.ps.taildrop == nil {
|
||||
e.ph.ps.taildrop = &taildrop.Handler{}
|
||||
e.ph.ps.taildrop = taildrop.ManagerOptions{
|
||||
Logf: e.logBuf.Logf,
|
||||
Dir: rootDir,
|
||||
}.New()
|
||||
}
|
||||
e.ph.ps.taildrop.RootDir = rootDir
|
||||
}
|
||||
for _, req := range tt.reqs {
|
||||
e.rr = httptest.NewRecorder()
|
||||
@@ -535,11 +583,10 @@ func TestFileDeleteRace(t *testing.T) {
|
||||
capFileSharing: true,
|
||||
clock: &tstest.Clock{},
|
||||
},
|
||||
taildrop: &taildrop.Handler{
|
||||
Logf: t.Logf,
|
||||
Clock: &tstest.Clock{},
|
||||
RootDir: dir,
|
||||
},
|
||||
taildrop: taildrop.ManagerOptions{
|
||||
Logf: t.Logf,
|
||||
Dir: dir,
|
||||
}.New(),
|
||||
}
|
||||
ph := &peerAPIHandler{
|
||||
isSelf: true,
|
||||
@@ -579,92 +626,6 @@ func TestFileDeleteRace(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Tests "foo.jpg.deleted" marks (for Windows).
|
||||
func TestDeletedMarkers(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ps := &peerAPIServer{
|
||||
b: &LocalBackend{
|
||||
logf: t.Logf,
|
||||
capFileSharing: true,
|
||||
},
|
||||
taildrop: &taildrop.Handler{
|
||||
RootDir: dir,
|
||||
},
|
||||
}
|
||||
|
||||
nothingWaiting := func() {
|
||||
t.Helper()
|
||||
ps.taildrop.KnownEmpty.Store(false)
|
||||
if ps.taildrop.HasFilesWaiting() {
|
||||
t.Fatal("unexpected files waiting")
|
||||
}
|
||||
}
|
||||
touch := func(base string) {
|
||||
t.Helper()
|
||||
if err := taildrop.TouchFile(filepath.Join(dir, base)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
wantEmptyTempDir := func() {
|
||||
t.Helper()
|
||||
if fis, err := os.ReadDir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if len(fis) > 0 && runtime.GOOS != "windows" {
|
||||
for _, fi := range fis {
|
||||
t.Errorf("unexpected file in tempdir: %q", fi.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nothingWaiting()
|
||||
wantEmptyTempDir()
|
||||
|
||||
touch("foo.jpg.deleted")
|
||||
nothingWaiting()
|
||||
wantEmptyTempDir()
|
||||
|
||||
touch("foo.jpg.deleted")
|
||||
touch("foo.jpg")
|
||||
nothingWaiting()
|
||||
wantEmptyTempDir()
|
||||
|
||||
touch("foo.jpg.deleted")
|
||||
touch("foo.jpg")
|
||||
wf, err := ps.taildrop.WaitingFiles()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(wf) != 0 {
|
||||
t.Fatalf("WaitingFiles = %d; want 0", len(wf))
|
||||
}
|
||||
wantEmptyTempDir()
|
||||
|
||||
touch("foo.jpg.deleted")
|
||||
touch("foo.jpg")
|
||||
if rc, _, err := ps.taildrop.OpenFile("foo.jpg"); err == nil {
|
||||
rc.Close()
|
||||
t.Fatal("unexpected foo.jpg open")
|
||||
}
|
||||
wantEmptyTempDir()
|
||||
|
||||
// And verify basics still work in non-deleted cases.
|
||||
touch("foo.jpg")
|
||||
touch("bar.jpg.deleted")
|
||||
if wf, err := ps.taildrop.WaitingFiles(); err != nil {
|
||||
t.Error(err)
|
||||
} else if len(wf) != 1 {
|
||||
t.Errorf("WaitingFiles = %d; want 1", len(wf))
|
||||
} else if wf[0].Name != "foo.jpg" {
|
||||
t.Errorf("unexpected waiting file %+v", wf[0])
|
||||
}
|
||||
if rc, _, err := ps.taildrop.OpenFile("foo.jpg"); err != nil {
|
||||
t.Fatal(err)
|
||||
} else {
|
||||
rc.Close()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestPeerAPIReplyToDNSQueries(t *testing.T) {
|
||||
var h peerAPIHandler
|
||||
|
||||
@@ -719,67 +680,3 @@ func TestPeerAPIReplyToDNSQueries(t *testing.T) {
|
||||
t.Errorf("unexpectedly IPv6 deny; wanted to be a DNS server")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactErr(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
err func() error
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "PathError",
|
||||
err: func() error {
|
||||
return &os.PathError{
|
||||
Op: "open",
|
||||
Path: "/tmp/sensitive.txt",
|
||||
Err: fs.ErrNotExist,
|
||||
}
|
||||
},
|
||||
want: `open redacted.41360718: file does not exist`,
|
||||
},
|
||||
{
|
||||
name: "LinkError",
|
||||
err: func() error {
|
||||
return &os.LinkError{
|
||||
Op: "symlink",
|
||||
Old: "/tmp/sensitive.txt",
|
||||
New: "/tmp/othersensitive.txt",
|
||||
Err: fs.ErrNotExist,
|
||||
}
|
||||
},
|
||||
want: `symlink redacted.41360718 redacted.6bcf093a: file does not exist`,
|
||||
},
|
||||
{
|
||||
name: "something else",
|
||||
err: func() error { return errors.New("i am another error type") },
|
||||
want: `i am another error type`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// For debugging
|
||||
var i int
|
||||
for err := tc.err(); err != nil; err = errors.Unwrap(err) {
|
||||
t.Logf("%d: %T @ %p", i, err, err)
|
||||
i++
|
||||
}
|
||||
|
||||
t.Run("Root", func(t *testing.T) {
|
||||
got := taildrop.RedactErr(tc.err()).Error()
|
||||
if got != tc.want {
|
||||
t.Errorf("err = %q; want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
t.Run("Wrapped", func(t *testing.T) {
|
||||
wrapped := fmt.Errorf("wrapped error: %w", tc.err())
|
||||
want := "wrapped error: " + tc.want
|
||||
|
||||
got := taildrop.RedactErr(wrapped).Error()
|
||||
if got != want {
|
||||
t.Errorf("err = %q; want %q", got, want)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,18 +23,26 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/http2"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/logtail/backoff"
|
||||
"tailscale.com/net/netutil"
|
||||
"tailscale.com/syncs"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/lazy"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/util/mak"
|
||||
"tailscale.com/version"
|
||||
)
|
||||
|
||||
const (
|
||||
contentTypeHeader = "Content-Type"
|
||||
grpcBaseContentType = "application/grpc"
|
||||
)
|
||||
|
||||
// ErrETagMismatch signals that the given
|
||||
// If-Match header does not match with the
|
||||
// current etag of a resource.
|
||||
@@ -160,8 +168,9 @@ func (s *serveListener) shouldWarnAboutListenError(err error) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// handleServeListenersAccept accepts connections for the Listener.
|
||||
// Calls incoming handler in a new goroutine for each accepted connection.
|
||||
// handleServeListenersAccept accepts connections for the Listener. It calls the
|
||||
// handler in a new goroutine for each accepted connection. This is used to
|
||||
// handle local "tailscale serve" traffic originating from the machine itself.
|
||||
func (s *serveListener) handleServeListenersAccept(ln net.Listener) error {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
@@ -171,7 +180,7 @@ func (s *serveListener) handleServeListenersAccept(ln net.Listener) error {
|
||||
srcAddr := conn.RemoteAddr().(*net.TCPAddr).AddrPort()
|
||||
handler := s.b.tcpHandlerForServe(s.ap.Port(), srcAddr)
|
||||
if handler == nil {
|
||||
s.b.logf("serve RST for %v", srcAddr)
|
||||
s.b.logf("[unexpected] local-serve: no handler for %v to port %v", srcAddr, s.ap.Port())
|
||||
conn.Close()
|
||||
continue
|
||||
}
|
||||
@@ -236,6 +245,9 @@ func (b *LocalBackend) setServeConfigLocked(config *ipn.ServeConfig, etag string
|
||||
if config.IsFunnelOn() && prefs.ShieldsUp() {
|
||||
return errors.New("Unable to turn on Funnel while shields-up is enabled")
|
||||
}
|
||||
if b.isConfigLocked_Locked() {
|
||||
return errors.New("can't reconfigure tailscaled when using a config file; config file is locked")
|
||||
}
|
||||
|
||||
nm := b.netMap
|
||||
if nm == nil {
|
||||
@@ -325,32 +337,43 @@ func (b *LocalBackend) DeleteForegroundSession(sessionID string) error {
|
||||
return b.setServeConfigLocked(sc, "")
|
||||
}
|
||||
|
||||
// HandleIngressTCPConn handles a TCP connection initiated by the ingressPeer
|
||||
// proxied to the local node over the PeerAPI.
|
||||
// Target represents the destination HostPort of the conn.
|
||||
// srcAddr represents the source AddrPort and not that of the ingressPeer.
|
||||
// getConnOrReset is a callback to get the connection, or reset if the connection
|
||||
// is no longer available.
|
||||
// sendRST is a callback to send a TCP RST to the ingressPeer indicating that
|
||||
// the connection was not accepted.
|
||||
func (b *LocalBackend) HandleIngressTCPConn(ingressPeer tailcfg.NodeView, target ipn.HostPort, srcAddr netip.AddrPort, getConnOrReset func() (net.Conn, bool), sendRST func()) {
|
||||
b.mu.Lock()
|
||||
sc := b.serveConfig
|
||||
b.mu.Unlock()
|
||||
|
||||
// TODO(maisem,bradfitz): make this not alloc for every conn.
|
||||
logf := logger.WithPrefix(b.logf, "handleIngress: ")
|
||||
|
||||
if !sc.Valid() {
|
||||
b.logf("localbackend: got ingress conn w/o serveConfig; rejecting")
|
||||
logf("got ingress conn w/o serveConfig; rejecting")
|
||||
sendRST()
|
||||
return
|
||||
}
|
||||
|
||||
if !sc.HasFunnelForTarget(target) {
|
||||
b.logf("localbackend: got ingress conn for unconfigured %q; rejecting", target)
|
||||
logf("got ingress conn for unconfigured %q; rejecting", target)
|
||||
sendRST()
|
||||
return
|
||||
}
|
||||
|
||||
_, port, err := net.SplitHostPort(string(target))
|
||||
if err != nil {
|
||||
b.logf("localbackend: got ingress conn for bad target %q; rejecting", target)
|
||||
logf("got ingress conn for bad target %q; rejecting", target)
|
||||
sendRST()
|
||||
return
|
||||
}
|
||||
port16, err := strconv.ParseUint(port, 10, 16)
|
||||
if err != nil {
|
||||
b.logf("localbackend: got ingress conn for bad target %q; rejecting", target)
|
||||
logf("got ingress conn for bad target %q; rejecting", target)
|
||||
sendRST()
|
||||
return
|
||||
}
|
||||
@@ -360,7 +383,7 @@ func (b *LocalBackend) HandleIngressTCPConn(ingressPeer tailcfg.NodeView, target
|
||||
if handler != nil {
|
||||
c, ok := getConnOrReset()
|
||||
if !ok {
|
||||
b.logf("localbackend: getConn didn't complete from %v to port %v", srcAddr, dport)
|
||||
logf("getConn didn't complete from %v to port %v", srcAddr, dport)
|
||||
return
|
||||
}
|
||||
handler(c)
|
||||
@@ -371,12 +394,13 @@ func (b *LocalBackend) HandleIngressTCPConn(ingressPeer tailcfg.NodeView, target
|
||||
// extend serveHTTPContext or similar.
|
||||
handler := b.tcpHandlerForServe(dport, srcAddr)
|
||||
if handler == nil {
|
||||
logf("[unexpected] no matching ingress serve handler for %v to port %v", srcAddr, dport)
|
||||
sendRST()
|
||||
return
|
||||
}
|
||||
c, ok := getConnOrReset()
|
||||
if !ok {
|
||||
b.logf("localbackend: getConn didn't complete from %v to port %v", srcAddr, dport)
|
||||
logf("getConn didn't complete from %v to port %v", srcAddr, dport)
|
||||
return
|
||||
}
|
||||
handler(c)
|
||||
@@ -390,13 +414,11 @@ func (b *LocalBackend) tcpHandlerForServe(dport uint16, srcAddr netip.AddrPort)
|
||||
b.mu.Unlock()
|
||||
|
||||
if !sc.Valid() {
|
||||
b.logf("[unexpected] localbackend: got TCP conn w/o serveConfig; from %v to port %v", srcAddr, dport)
|
||||
return nil
|
||||
}
|
||||
|
||||
tcph, ok := sc.FindTCP(dport)
|
||||
if !ok {
|
||||
b.logf("[unexpected] localbackend: got TCP conn without TCP config for port %v; from %v", dport, srcAddr)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -440,7 +462,7 @@ func (b *LocalBackend) tcpHandlerForServe(dport uint16, srcAddr netip.AddrPort)
|
||||
GetCertificate: func(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
pair, err := b.GetCertPEM(ctx, sni, false)
|
||||
pair, err := b.GetCertPEM(ctx, sni)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -468,7 +490,6 @@ func (b *LocalBackend) tcpHandlerForServe(dport uint16, srcAddr netip.AddrPort)
|
||||
}
|
||||
}
|
||||
|
||||
b.logf("closing TCP conn to port %v (from %v) with actionless TCPPortHandler", dport, srcAddr)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -524,23 +545,92 @@ func (b *LocalBackend) getServeHandler(r *http.Request) (_ ipn.HTTPHandlerView,
|
||||
|
||||
// proxyHandlerForBackend creates a new HTTP reverse proxy for a particular backend that
|
||||
// we serve requests for. `backend` is a HTTPHandler.Proxy string (url, hostport or just port).
|
||||
func (b *LocalBackend) proxyHandlerForBackend(backend string) (*httputil.ReverseProxy, error) {
|
||||
func (b *LocalBackend) proxyHandlerForBackend(backend string) (http.Handler, error) {
|
||||
targetURL, insecure := expandProxyArg(backend)
|
||||
u, err := url.Parse(targetURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid url %s: %w", targetURL, err)
|
||||
}
|
||||
rp := &httputil.ReverseProxy{
|
||||
Rewrite: func(r *httputil.ProxyRequest) {
|
||||
r.SetURL(u)
|
||||
r.Out.Host = r.In.Host
|
||||
addProxyForwardedHeaders(r)
|
||||
b.addTailscaleIdentityHeaders(r)
|
||||
},
|
||||
Transport: &http.Transport{
|
||||
DialContext: b.dialer.SystemDial,
|
||||
p := &reverseProxy{
|
||||
logf: b.logf,
|
||||
url: u,
|
||||
insecure: insecure,
|
||||
backend: backend,
|
||||
lb: b,
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// reverseProxy is a proxy that forwards a request to a backend host
|
||||
// (preconfigured via ipn.ServeConfig). If the host is configured with
|
||||
// http+insecure prefix, connection between proxy and backend will be over
|
||||
// insecure TLS. If the backend host has a http prefix and the incoming request
|
||||
// has application/grpc content type header, the connection will be over h2c.
|
||||
// Otherwise standard Go http transport will be used.
|
||||
type reverseProxy struct {
|
||||
logf logger.Logf
|
||||
url *url.URL
|
||||
// insecure tracks whether the connection to an https backend should be
|
||||
// insecure (i.e because we cannot verify its CA).
|
||||
insecure bool
|
||||
backend string
|
||||
lb *LocalBackend
|
||||
httpTransport lazy.SyncValue[*http.Transport] // transport for non-h2c backends
|
||||
h2cTransport lazy.SyncValue[*http2.Transport] // transport for h2c backends
|
||||
// closed tracks whether proxy is closed/currently closing.
|
||||
closed atomic.Bool
|
||||
}
|
||||
|
||||
// close ensures that any open backend connections get closed.
|
||||
func (rp *reverseProxy) close() {
|
||||
rp.closed.Store(true)
|
||||
if h2cT := rp.h2cTransport.Get(func() *http2.Transport {
|
||||
return nil
|
||||
}); h2cT != nil {
|
||||
h2cT.CloseIdleConnections()
|
||||
}
|
||||
if httpTransport := rp.httpTransport.Get(func() *http.Transport {
|
||||
return nil
|
||||
}); httpTransport != nil {
|
||||
httpTransport.CloseIdleConnections()
|
||||
}
|
||||
}
|
||||
|
||||
func (rp *reverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if closed := rp.closed.Load(); closed {
|
||||
rp.logf("received a request for a proxy that's being closed or has been closed")
|
||||
http.Error(w, "proxy is closed", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
p := &httputil.ReverseProxy{Rewrite: func(r *httputil.ProxyRequest) {
|
||||
r.SetURL(rp.url)
|
||||
r.Out.Host = r.In.Host
|
||||
addProxyForwardedHeaders(r)
|
||||
rp.lb.addTailscaleIdentityHeaders(r)
|
||||
}}
|
||||
|
||||
// There is no way to autodetect h2c as per RFC 9113
|
||||
// https://datatracker.ietf.org/doc/html/rfc9113#name-starting-http-2.
|
||||
// However, we assume that http:// proxy prefix in combination with the
|
||||
// protoccol being HTTP/2 is sufficient to detect h2c for our needs. Only use this for
|
||||
// gRPC to fix a known problem of plaintext gRPC backends
|
||||
if rp.shouldProxyViaH2C(r) {
|
||||
rp.logf("received a proxy request for plaintext gRPC")
|
||||
p.Transport = rp.getH2CTransport()
|
||||
} else {
|
||||
p.Transport = rp.getTransport()
|
||||
}
|
||||
p.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// getTransport returns the Transport used for regular (non-GRPC) requests
|
||||
// to the backend. The Transport gets created lazily, at most once.
|
||||
func (rp *reverseProxy) getTransport() *http.Transport {
|
||||
return rp.httpTransport.Get(func() *http.Transport {
|
||||
return &http.Transport{
|
||||
DialContext: rp.lb.dialer.SystemDial,
|
||||
TLSClientConfig: &tls.Config{
|
||||
InsecureSkipVerify: insecure,
|
||||
InsecureSkipVerify: rp.insecure,
|
||||
},
|
||||
// Values for the following parameters have been copied from http.DefaultTransport.
|
||||
ForceAttemptHTTP2: true,
|
||||
@@ -548,9 +638,38 @@ func (b *LocalBackend) proxyHandlerForBackend(backend string) (*httputil.Reverse
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
},
|
||||
}
|
||||
return rp, nil
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// getH2CTransport returns the Transport used for GRPC requests to the backend.
|
||||
// The Transport gets created lazily, at most once.
|
||||
func (rp *reverseProxy) getH2CTransport() *http2.Transport {
|
||||
return rp.h2cTransport.Get(func() *http2.Transport {
|
||||
return &http2.Transport{
|
||||
AllowHTTP: true,
|
||||
DialTLSContext: func(ctx context.Context, network string, addr string, _ *tls.Config) (net.Conn, error) {
|
||||
return rp.lb.dialer.SystemDial(ctx, "tcp", rp.url.Host)
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// This is not a generally reliable way how to determine whether a request is
|
||||
// for a h2c server, but sufficient for our particular use case.
|
||||
func (rp *reverseProxy) shouldProxyViaH2C(r *http.Request) bool {
|
||||
contentType := r.Header.Get(contentTypeHeader)
|
||||
return r.ProtoMajor == 2 && strings.HasPrefix(rp.backend, "http://") && isGRPCContentType(contentType)
|
||||
}
|
||||
|
||||
// isGRPC accepts an HTTP request's content type header value and determines
|
||||
// whether this is gRPC content. grpc-go considers a value that equals
|
||||
// application/grpc or has a prefix of application/grpc+ or application/grpc; a
|
||||
// valid grpc content type header.
|
||||
// https://github.com/grpc/grpc-go/blob/v1.60.0-dev/internal/grpcutil/method.go#L41-L78
|
||||
func isGRPCContentType(contentType string) bool {
|
||||
s, ok := strings.CutPrefix(contentType, grpcBaseContentType)
|
||||
return ok && (len(s) == 0 || s[0] == '+' || s[0] == ';')
|
||||
}
|
||||
|
||||
func addProxyForwardedHeaders(r *httputil.ProxyRequest) {
|
||||
@@ -747,7 +866,7 @@ func (b *LocalBackend) getTLSServeCertForPort(port uint16) func(hi *tls.ClientHe
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
pair, err := b.GetCertPEM(ctx, hi.ServerName, false)
|
||||
pair, err := b.GetCertPEM(ctx, hi.ServerName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -446,6 +447,119 @@ func TestServeHTTPProxy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func Test_reverseProxyConfiguration(t *testing.T) {
|
||||
b := newTestBackend(t)
|
||||
type test struct {
|
||||
backend string
|
||||
path string
|
||||
// set to false to test that a proxy has been removed
|
||||
shouldExist bool
|
||||
wantsInsecure bool
|
||||
wantsURL url.URL
|
||||
}
|
||||
runner := func(name string, tests []test) {
|
||||
t.Logf("running tests for %s", name)
|
||||
host := ipn.HostPort("http://example.ts.net:80")
|
||||
conf := &ipn.ServeConfig{
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
host: {Handlers: map[string]*ipn.HTTPHandler{}},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if tt.shouldExist {
|
||||
conf.Web[host].Handlers[tt.path] = &ipn.HTTPHandler{Proxy: tt.backend}
|
||||
}
|
||||
}
|
||||
if err := b.setServeConfigLocked(conf, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// test that reverseproxies have been set up as expected
|
||||
for _, tt := range tests {
|
||||
rp, ok := b.serveProxyHandlers.Load(tt.backend)
|
||||
if !tt.shouldExist && ok {
|
||||
t.Errorf("proxy for backend %s should not exist, but it does", tt.backend)
|
||||
}
|
||||
if !tt.shouldExist {
|
||||
continue
|
||||
}
|
||||
parsedRp, ok := rp.(*reverseProxy)
|
||||
if !ok {
|
||||
t.Errorf("proxy for backend %q is not a reverseproxy", tt.backend)
|
||||
}
|
||||
if parsedRp.insecure != tt.wantsInsecure {
|
||||
t.Errorf("proxy for backend %q should be insecure: %v got insecure: %v", tt.backend, tt.wantsInsecure, parsedRp.insecure)
|
||||
}
|
||||
if !reflect.DeepEqual(*parsedRp.url, tt.wantsURL) {
|
||||
t.Errorf("proxy for backend %q should have URL %#+v, got URL %+#v", tt.backend, &tt.wantsURL, parsedRp.url)
|
||||
}
|
||||
if tt.backend != parsedRp.backend {
|
||||
t.Errorf("proxy for backend %q should have backend %q got %q", tt.backend, tt.backend, parsedRp.backend)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// configure local backend with some proxy backends
|
||||
runner("initial proxy configs", []test{
|
||||
{
|
||||
backend: "http://example.com/docs",
|
||||
path: "/example",
|
||||
shouldExist: true,
|
||||
wantsInsecure: false,
|
||||
wantsURL: mustCreateURL(t, "http://example.com/docs"),
|
||||
},
|
||||
{
|
||||
backend: "https://example1.com",
|
||||
path: "/example1",
|
||||
shouldExist: true,
|
||||
wantsInsecure: false,
|
||||
wantsURL: mustCreateURL(t, "https://example1.com"),
|
||||
},
|
||||
{
|
||||
backend: "https+insecure://example2.com",
|
||||
path: "/example2",
|
||||
shouldExist: true,
|
||||
wantsInsecure: true,
|
||||
wantsURL: mustCreateURL(t, "https://example2.com"),
|
||||
},
|
||||
})
|
||||
|
||||
// reconfigure the local backend with different proxies
|
||||
runner("reloaded proxy configs", []test{
|
||||
{
|
||||
backend: "http://example.com/docs",
|
||||
path: "/example",
|
||||
shouldExist: true,
|
||||
wantsInsecure: false,
|
||||
wantsURL: mustCreateURL(t, "http://example.com/docs"),
|
||||
},
|
||||
{
|
||||
backend: "https://example1.com",
|
||||
shouldExist: false,
|
||||
},
|
||||
{
|
||||
backend: "https+insecure://example2.com",
|
||||
shouldExist: false,
|
||||
},
|
||||
{
|
||||
backend: "https+insecure://example3.com",
|
||||
path: "/example3",
|
||||
shouldExist: true,
|
||||
wantsInsecure: true,
|
||||
wantsURL: mustCreateURL(t, "https://example3.com"),
|
||||
},
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
func mustCreateURL(t *testing.T, u string) url.URL {
|
||||
t.Helper()
|
||||
uParsed, err := url.Parse(u)
|
||||
if err != nil {
|
||||
t.Fatalf("failed parsing url: %v", err)
|
||||
}
|
||||
return *uParsed
|
||||
}
|
||||
|
||||
func newTestBackend(t *testing.T) *LocalBackend {
|
||||
sys := &tsd.System{}
|
||||
e, err := wgengine.NewUserspaceEngine(t.Logf, wgengine.Config{SetSubsystem: sys.Set})
|
||||
@@ -587,3 +701,23 @@ func TestServeFileOrDirectory(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func Test_isGRPCContentType(t *testing.T) {
|
||||
tests := []struct {
|
||||
contentType string
|
||||
want bool
|
||||
}{
|
||||
{contentType: "application/grpc", want: true},
|
||||
{contentType: "application/grpc;", want: true},
|
||||
{contentType: "application/grpc+", want: true},
|
||||
{contentType: "application/grpcfoobar"},
|
||||
{contentType: "application/text"},
|
||||
{contentType: "foobar"},
|
||||
{contentType: ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := isGRPCContentType(tt.contentType); got != tt.want {
|
||||
t.Errorf("isGRPCContentType(%q) = %v, want %v", tt.contentType, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ func (h *Handler) serveCert(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "internal handler config wired wrong", 500)
|
||||
return
|
||||
}
|
||||
pair, err := h.b.GetCertPEM(r.Context(), domain, true)
|
||||
pair, err := h.b.GetCertPEM(r.Context(), domain)
|
||||
if err != nil {
|
||||
// TODO(bradfitz): 500 is a little lazy here. The errors returned from
|
||||
// GetCertPEM (and everywhere) should carry info info to get whether
|
||||
|
||||
@@ -37,6 +37,7 @@ import (
|
||||
"tailscale.com/net/netutil"
|
||||
"tailscale.com/net/portmapper"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/taildrop"
|
||||
"tailscale.com/tka"
|
||||
"tailscale.com/tstime"
|
||||
"tailscale.com/types/key"
|
||||
@@ -45,6 +46,7 @@ import (
|
||||
"tailscale.com/types/ptr"
|
||||
"tailscale.com/types/tkatype"
|
||||
"tailscale.com/util/clientmetric"
|
||||
"tailscale.com/util/httphdr"
|
||||
"tailscale.com/util/httpm"
|
||||
"tailscale.com/util/mak"
|
||||
"tailscale.com/util/osdiag"
|
||||
@@ -79,6 +81,7 @@ var handler = map[string]localAPIHandler{
|
||||
"debug-peer-endpoint-changes": (*Handler).serveDebugPeerEndpointChanges,
|
||||
"debug-capture": (*Handler).serveDebugCapture,
|
||||
"debug-log": (*Handler).serveDebugLog,
|
||||
"debug-web-client": (*Handler).serveDebugWebClient,
|
||||
"derpmap": (*Handler).serveDERPMap,
|
||||
"dev-set-state-store": (*Handler).serveDevSetStateStore,
|
||||
"set-push-device-token": (*Handler).serveSetPushDeviceToken,
|
||||
@@ -93,6 +96,7 @@ var handler = map[string]localAPIHandler{
|
||||
"ping": (*Handler).servePing,
|
||||
"prefs": (*Handler).servePrefs,
|
||||
"pprof": (*Handler).servePprof,
|
||||
"reload-config": (*Handler).reloadConfig,
|
||||
"reset-auth": (*Handler).serveResetAuth,
|
||||
"serve-config": (*Handler).serveServeConfig,
|
||||
"set-dns": (*Handler).serveSetDNS,
|
||||
@@ -279,23 +283,23 @@ func (h *Handler) serveIDToken(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
b, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
httpReq, err := http.NewRequest("POST", "https://unused/machine/id-token", bytes.NewReader(b))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
resp, err := h.b.DoNoiseRequest(httpReq)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
if _, err := io.Copy(w, resp.Body); err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -408,36 +412,52 @@ func (h *Handler) serveBugReport(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *Handler) serveWhoIs(w http.ResponseWriter, r *http.Request) {
|
||||
h.serveWhoIsWithBackend(w, r, h.b)
|
||||
}
|
||||
|
||||
// localBackendWhoIsMethods is the subset of ipn.LocalBackend as needed
|
||||
// by the localapi WhoIs method.
|
||||
type localBackendWhoIsMethods interface {
|
||||
WhoIs(netip.AddrPort) (n tailcfg.NodeView, u tailcfg.UserProfile, ok bool)
|
||||
PeerCaps(netip.Addr) tailcfg.PeerCapMap
|
||||
}
|
||||
|
||||
func (h *Handler) serveWhoIsWithBackend(w http.ResponseWriter, r *http.Request, b localBackendWhoIsMethods) {
|
||||
if !h.PermitRead {
|
||||
http.Error(w, "whois access denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
b := h.b
|
||||
var ipp netip.AddrPort
|
||||
if v := r.FormValue("addr"); v != "" {
|
||||
var err error
|
||||
ipp, err = netip.ParseAddrPort(v)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid 'addr' parameter", 400)
|
||||
return
|
||||
if ip, err := netip.ParseAddr(v); err == nil {
|
||||
ipp = netip.AddrPortFrom(ip, 0)
|
||||
} else {
|
||||
var err error
|
||||
ipp, err = netip.ParseAddrPort(v)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid 'addr' parameter", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
} else {
|
||||
http.Error(w, "missing 'addr' parameter", 400)
|
||||
http.Error(w, "missing 'addr' parameter", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
n, u, ok := b.WhoIs(ipp)
|
||||
if !ok {
|
||||
http.Error(w, "no match for IP:port", 404)
|
||||
http.Error(w, "no match for IP:port", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
res := &apitype.WhoIsResponse{
|
||||
Node: n.AsStruct(), // always non-nil per WhoIsResponse contract
|
||||
UserProfile: &u, // always non-nil per WhoIsResponse contract
|
||||
CapMap: b.PeerCaps(ipp.Addr()),
|
||||
}
|
||||
if n.Addresses().Len() > 0 {
|
||||
res.CapMap = b.PeerCaps(n.Addresses().At(0).Addr())
|
||||
}
|
||||
j, err := json.MarshalIndent(res, "", "\t")
|
||||
if err != nil {
|
||||
http.Error(w, "JSON encoding error", 500)
|
||||
http.Error(w, "JSON encoding error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -566,13 +586,15 @@ func (h *Handler) serveDebug(w http.ResponseWriter, r *http.Request) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
case "pick-new-derp":
|
||||
err = h.b.DebugPickNewDERP()
|
||||
case "":
|
||||
err = fmt.Errorf("missing parameter 'action'")
|
||||
default:
|
||||
err = fmt.Errorf("unknown action %q", action)
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 400)
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
@@ -589,7 +611,7 @@ func (h *Handler) serveDevSetStateStore(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
if err := h.b.SetDevStateStore(r.FormValue("key"), r.FormValue("value")); err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
@@ -817,6 +839,26 @@ func (h *Handler) servePprof(w http.ResponseWriter, r *http.Request) {
|
||||
servePprofFunc(w, r)
|
||||
}
|
||||
|
||||
func (h *Handler) reloadConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.PermitWrite {
|
||||
http.Error(w, "access denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if r.Method != httpm.POST {
|
||||
http.Error(w, "use POST", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
ok, err := h.b.ReloadConfig()
|
||||
var res apitype.ReloadConfigResponse
|
||||
res.Reloaded = ok
|
||||
if err != nil {
|
||||
res.Err = err.Error()
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(&res)
|
||||
}
|
||||
|
||||
func (h *Handler) serveResetAuth(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.PermitWrite {
|
||||
http.Error(w, "reset-auth modify access denied", http.StatusForbidden)
|
||||
@@ -919,18 +961,18 @@ func (h *Handler) serveDebugPeerEndpointChanges(w http.ResponseWriter, r *http.R
|
||||
|
||||
ipStr := r.FormValue("ip")
|
||||
if ipStr == "" {
|
||||
http.Error(w, "missing 'ip' parameter", 400)
|
||||
http.Error(w, "missing 'ip' parameter", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ip, err := netip.ParseAddr(ipStr)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid IP", 400)
|
||||
http.Error(w, "invalid IP", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
chs, err := h.b.GetPeerEndpointChanges(r.Context(), ip)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1007,7 +1049,7 @@ func (h *Handler) serveLoginInteractive(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
if r.Method != "POST" {
|
||||
http.Error(w, "want POST", 400)
|
||||
http.Error(w, "want POST", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
h.b.StartLoginInteractive()
|
||||
@@ -1021,7 +1063,7 @@ func (h *Handler) serveStart(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if r.Method != "POST" {
|
||||
http.Error(w, "want POST", 400)
|
||||
http.Error(w, "want POST", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var o ipn.Options
|
||||
@@ -1044,7 +1086,7 @@ func (h *Handler) serveLogout(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if r.Method != "POST" {
|
||||
http.Error(w, "want POST", 400)
|
||||
http.Error(w, "want POST", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
err := h.b.Logout(r.Context())
|
||||
@@ -1052,7 +1094,7 @@ func (h *Handler) serveLogout(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
http.Error(w, err.Error(), 500)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
func (h *Handler) servePrefs(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -1069,7 +1111,7 @@ func (h *Handler) servePrefs(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
mp := new(ipn.MaskedPrefs)
|
||||
if err := json.NewDecoder(r.Body).Decode(mp); err != nil {
|
||||
http.Error(w, err.Error(), 400)
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var err error
|
||||
@@ -1107,7 +1149,7 @@ func (h *Handler) serveCheckPrefs(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
p := new(ipn.Prefs)
|
||||
if err := json.NewDecoder(r.Body).Decode(p); err != nil {
|
||||
http.Error(w, "invalid JSON body", 400)
|
||||
http.Error(w, "invalid JSON body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
err := h.b.CheckPrefs(p)
|
||||
@@ -1131,7 +1173,7 @@ func (h *Handler) serveFiles(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
if suffix == "" {
|
||||
if r.Method != "GET" {
|
||||
http.Error(w, "want GET to list files", 400)
|
||||
http.Error(w, "want GET to list files", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
@@ -1148,7 +1190,7 @@ func (h *Handler) serveFiles(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
wfs, err := h.b.AwaitWaitingFiles(ctx)
|
||||
if err != nil && ctx.Err() == nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -1157,12 +1199,12 @@ func (h *Handler) serveFiles(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
name, err := url.PathUnescape(suffix)
|
||||
if err != nil {
|
||||
http.Error(w, "bad filename", 400)
|
||||
http.Error(w, "bad filename", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if r.Method == "DELETE" {
|
||||
if err := h.b.DeleteFile(name); err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
@@ -1170,7 +1212,7 @@ func (h *Handler) serveFiles(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
rc, size, err := h.b.OpenFile(name)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rc.Close()
|
||||
@@ -1184,7 +1226,7 @@ func writeErrorJSON(w http.ResponseWriter, err error) {
|
||||
err = errors.New("unexpected nil error")
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(500)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
type E struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
@@ -1197,7 +1239,7 @@ func (h *Handler) serveFileTargets(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if r.Method != "GET" {
|
||||
http.Error(w, "want GET to list targets", 400)
|
||||
http.Error(w, "want GET to list targets", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
fts, err := h.b.FileTargets()
|
||||
@@ -1237,12 +1279,12 @@ func (h *Handler) serveFilePut(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if r.Method != "PUT" {
|
||||
http.Error(w, "want PUT to put file", 400)
|
||||
http.Error(w, "want PUT to put file", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
fts, err := h.b.FileTargets()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1253,7 +1295,7 @@ func (h *Handler) serveFilePut(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
stableIDStr, filenameEscaped, ok := strings.Cut(upath, "/")
|
||||
if !ok {
|
||||
http.Error(w, "bogus URL", 400)
|
||||
http.Error(w, "bogus URL", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
stableID := tailcfg.StableNodeID(stableIDStr)
|
||||
@@ -1266,20 +1308,64 @@ func (h *Handler) serveFilePut(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
if ft == nil {
|
||||
http.Error(w, "node not found", 404)
|
||||
http.Error(w, "node not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
dstURL, err := url.Parse(ft.PeerAPIURL)
|
||||
if err != nil {
|
||||
http.Error(w, "bogus peer URL", 500)
|
||||
http.Error(w, "bogus peer URL", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
outReq, err := http.NewRequestWithContext(r.Context(), "PUT", "http://peer/v0/put/"+filenameEscaped, r.Body)
|
||||
|
||||
// Before we PUT a file we check to see if there are any existing partial file and if so,
|
||||
// we resume the upload from where we left off by sending the remaining file instead of
|
||||
// the full file.
|
||||
var offset int64
|
||||
var resumeDuration time.Duration
|
||||
remainingBody := io.Reader(r.Body)
|
||||
client := &http.Client{
|
||||
Transport: h.b.Dialer().PeerAPITransport(),
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
req, err := http.NewRequestWithContext(r.Context(), "GET", dstURL.String()+"/v0/put/"+filenameEscaped, nil)
|
||||
if err != nil {
|
||||
http.Error(w, "bogus outreq", 500)
|
||||
http.Error(w, "bogus peer URL", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
switch resp, err := client.Do(req); {
|
||||
case err != nil:
|
||||
h.logf("could not fetch remote hashes: %v", err)
|
||||
case resp.StatusCode == http.StatusMethodNotAllowed || resp.StatusCode == http.StatusNotFound:
|
||||
// noop; implies older peerapi without resume support
|
||||
case resp.StatusCode != http.StatusOK:
|
||||
h.logf("fetch remote hashes status code: %d", resp.StatusCode)
|
||||
default:
|
||||
resumeStart := time.Now()
|
||||
dec := json.NewDecoder(resp.Body)
|
||||
offset, remainingBody, err = taildrop.ResumeReader(r.Body, func() (out taildrop.BlockChecksum, err error) {
|
||||
err = dec.Decode(&out)
|
||||
return out, err
|
||||
})
|
||||
if err != nil {
|
||||
h.logf("reader could not be fully resumed: %v", err)
|
||||
}
|
||||
resumeDuration = time.Since(resumeStart).Round(time.Millisecond)
|
||||
}
|
||||
|
||||
outReq, err := http.NewRequestWithContext(r.Context(), "PUT", "http://peer/v0/put/"+filenameEscaped, remainingBody)
|
||||
if err != nil {
|
||||
http.Error(w, "bogus outreq", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
outReq.ContentLength = r.ContentLength
|
||||
if offset > 0 {
|
||||
h.logf("resuming put at offset %d after %v", offset, resumeDuration)
|
||||
rangeHdr, _ := httphdr.FormatRange([]httphdr.Range{{offset, 0}})
|
||||
outReq.Header.Set("Range", rangeHdr)
|
||||
if outReq.ContentLength >= 0 {
|
||||
outReq.ContentLength -= offset
|
||||
}
|
||||
}
|
||||
|
||||
rp := httputil.NewSingleHostReverseProxy(dstURL)
|
||||
rp.Transport = h.b.Dialer().PeerAPITransport()
|
||||
@@ -1292,7 +1378,7 @@ func (h *Handler) serveSetDNS(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if r.Method != "POST" {
|
||||
http.Error(w, "want POST", 400)
|
||||
http.Error(w, "want POST", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
@@ -1307,7 +1393,7 @@ func (h *Handler) serveSetDNS(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (h *Handler) serveDERPMap(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" {
|
||||
http.Error(w, "want GET", 400)
|
||||
http.Error(w, "want GET", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -1352,22 +1438,22 @@ func (h *Handler) serveSetExpirySooner(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *Handler) servePing(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
if r.Method != "POST" {
|
||||
http.Error(w, "want POST", 400)
|
||||
http.Error(w, "want POST", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ipStr := r.FormValue("ip")
|
||||
if ipStr == "" {
|
||||
http.Error(w, "missing 'ip' parameter", 400)
|
||||
http.Error(w, "missing 'ip' parameter", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ip, err := netip.ParseAddr(ipStr)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid IP", 400)
|
||||
http.Error(w, "invalid IP", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
pingTypeStr := r.FormValue("type")
|
||||
if pingTypeStr == "" {
|
||||
http.Error(w, "missing 'type' parameter", 400)
|
||||
http.Error(w, "missing 'type' parameter", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
size := 0
|
||||
@@ -1375,15 +1461,15 @@ func (h *Handler) servePing(w http.ResponseWriter, r *http.Request) {
|
||||
if sizeStr != "" {
|
||||
size, err = strconv.Atoi(sizeStr)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid 'size' parameter", 400)
|
||||
http.Error(w, "invalid 'size' parameter", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if size != 0 && tailcfg.PingType(pingTypeStr) != tailcfg.PingDisco {
|
||||
http.Error(w, "'size' parameter is only supported with disco pings", 400)
|
||||
http.Error(w, "'size' parameter is only supported with disco pings", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if size > magicsock.MaxDiscoPingSize {
|
||||
http.Error(w, fmt.Sprintf("maximum value for 'size' is %v", magicsock.MaxDiscoPingSize), 400)
|
||||
http.Error(w, fmt.Sprintf("maximum value for 'size' is %v", magicsock.MaxDiscoPingSize), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -1464,7 +1550,7 @@ func (h *Handler) serveSetPushDeviceToken(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
var params apitype.SetPushDeviceTokenRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil {
|
||||
http.Error(w, "invalid JSON body", 400)
|
||||
http.Error(w, "invalid JSON body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
hostinfo.SetPushDeviceToken(params.PushDeviceToken)
|
||||
@@ -1485,7 +1571,7 @@ func (h *Handler) serveUploadClientMetrics(w http.ResponseWriter, r *http.Reques
|
||||
|
||||
var clientMetrics []clientMetricJSON
|
||||
if err := json.NewDecoder(r.Body).Decode(&clientMetrics); err != nil {
|
||||
http.Error(w, "invalid JSON body", 400)
|
||||
http.Error(w, "invalid JSON body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1497,7 +1583,7 @@ func (h *Handler) serveUploadClientMetrics(w http.ResponseWriter, r *http.Reques
|
||||
metric.Add(int64(m.Value))
|
||||
} else {
|
||||
if clientmetric.HasPublished(m.Name) {
|
||||
http.Error(w, "Already have a metric named "+m.Name, 400)
|
||||
http.Error(w, "Already have a metric named "+m.Name, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var metric *clientmetric.Metric
|
||||
@@ -1507,7 +1593,7 @@ func (h *Handler) serveUploadClientMetrics(w http.ResponseWriter, r *http.Reques
|
||||
case "gauge":
|
||||
metric = clientmetric.NewGauge(m.Name)
|
||||
default:
|
||||
http.Error(w, "Unknown metric type "+m.Type, 400)
|
||||
http.Error(w, "Unknown metric type "+m.Type, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
metrics[m.Name] = metric
|
||||
@@ -1531,7 +1617,7 @@ func (h *Handler) serveTKAStatus(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
j, err := json.MarshalIndent(h.b.NetworkLockStatus(), "", "\t")
|
||||
if err != nil {
|
||||
http.Error(w, "JSON encoding error", 500)
|
||||
http.Error(w, "JSON encoding error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -1583,7 +1669,7 @@ func (h *Handler) serveTKAInit(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
var req initRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid JSON body", 400)
|
||||
http.Error(w, "invalid JSON body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1594,7 +1680,7 @@ func (h *Handler) serveTKAInit(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
j, err := json.MarshalIndent(h.b.NetworkLockStatus(), "", "\t")
|
||||
if err != nil {
|
||||
http.Error(w, "JSON encoding error", 500)
|
||||
http.Error(w, "JSON encoding error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -1617,7 +1703,7 @@ func (h *Handler) serveTKAModify(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
var req modifyRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid JSON body", 400)
|
||||
http.Error(w, "invalid JSON body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1677,14 +1763,14 @@ func (h *Handler) serveTKAVerifySigningDeeplink(w http.ResponseWriter, r *http.R
|
||||
}
|
||||
var req verifyRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid JSON for verifyRequest body", 400)
|
||||
http.Error(w, "invalid JSON for verifyRequest body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
res := h.b.NetworkLockVerifySigningDeeplink(req.URL)
|
||||
j, err := json.MarshalIndent(res, "", "\t")
|
||||
if err != nil {
|
||||
http.Error(w, "JSON encoding error", 500)
|
||||
http.Error(w, "JSON encoding error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -1704,7 +1790,7 @@ func (h *Handler) serveTKADisable(w http.ResponseWriter, r *http.Request) {
|
||||
body := io.LimitReader(r.Body, 1024*1024)
|
||||
secret, err := io.ReadAll(body)
|
||||
if err != nil {
|
||||
http.Error(w, "reading secret", 400)
|
||||
http.Error(w, "reading secret", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1728,7 +1814,7 @@ func (h *Handler) serveTKALocalDisable(w http.ResponseWriter, r *http.Request) {
|
||||
// Require a JSON stanza for the body as an additional CSRF protection.
|
||||
var req struct{}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid JSON body", 400)
|
||||
http.Error(w, "invalid JSON body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1763,7 +1849,7 @@ func (h *Handler) serveTKALog(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
j, err := json.MarshalIndent(updates, "", "\t")
|
||||
if err != nil {
|
||||
http.Error(w, "JSON encoding error", 500)
|
||||
http.Error(w, "JSON encoding error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -1826,7 +1912,7 @@ func (h *Handler) serveTKAGenerateRecoveryAUM(w http.ResponseWriter, r *http.Req
|
||||
|
||||
res, err := h.b.NetworkLockGenerateRecoveryAUM(req.Keys, forkFrom)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
@@ -2037,6 +2123,65 @@ func (h *Handler) serveQueryFeature(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// serveDebugWebClient is for use by the web client to communicate with
|
||||
// the control server for browser auth sessions.
|
||||
//
|
||||
// This is an unsupported localapi endpoint and restricted to flagged
|
||||
// domains on the control side. TODO(tailscale/#14335): Rename this handler.
|
||||
func (h *Handler) serveDebugWebClient(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.PermitWrite {
|
||||
http.Error(w, "access denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if r.Method != "POST" {
|
||||
http.Error(w, "POST required", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
type reqData struct {
|
||||
ID string
|
||||
Src tailcfg.NodeID
|
||||
}
|
||||
var data reqData
|
||||
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
|
||||
http.Error(w, "invalid JSON body", 400)
|
||||
return
|
||||
}
|
||||
nm := h.b.NetMap()
|
||||
if nm == nil || !nm.SelfNode.Valid() {
|
||||
http.Error(w, "[unexpected] no self node", 400)
|
||||
return
|
||||
}
|
||||
dst := nm.SelfNode.ID()
|
||||
|
||||
var noiseURL string
|
||||
if data.ID != "" {
|
||||
noiseURL = fmt.Sprintf("https://unused/machine/webclient/wait/%d/to/%d/%s", data.Src, dst, data.ID)
|
||||
} else {
|
||||
noiseURL = fmt.Sprintf("https://unused/machine/webclient/init/%d/to/%d", data.Src, dst)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(r.Context(), "POST", noiseURL, nil)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := h.b.DoNoiseRequest(req)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
http.Error(w, string(body), resp.StatusCode)
|
||||
return
|
||||
}
|
||||
w.Write(body)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
func defBool(a string, def bool) bool {
|
||||
if a == "" {
|
||||
return def
|
||||
@@ -2081,7 +2226,7 @@ func (h *Handler) serveDebugLog(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var logRequest logRequestJSON
|
||||
if err := json.NewDecoder(r.Body).Decode(&logRequest); err != nil {
|
||||
http.Error(w, "invalid JSON body", 400)
|
||||
http.Error(w, "invalid JSON body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -9,11 +9,15 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
"tailscale.com/hostinfo"
|
||||
"tailscale.com/ipn/ipnlocal"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/tstest"
|
||||
)
|
||||
|
||||
@@ -77,3 +81,68 @@ func TestSetPushDeviceToken(t *testing.T) {
|
||||
t.Errorf("hostinfo.PushDeviceToken=%q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
type whoIsBackend struct {
|
||||
whoIs func(ipp netip.AddrPort) (n tailcfg.NodeView, u tailcfg.UserProfile, ok bool)
|
||||
peerCaps map[netip.Addr]tailcfg.PeerCapMap
|
||||
}
|
||||
|
||||
func (b whoIsBackend) WhoIs(ipp netip.AddrPort) (n tailcfg.NodeView, u tailcfg.UserProfile, ok bool) {
|
||||
return b.whoIs(ipp)
|
||||
}
|
||||
|
||||
func (b whoIsBackend) PeerCaps(ip netip.Addr) tailcfg.PeerCapMap {
|
||||
return b.peerCaps[ip]
|
||||
}
|
||||
|
||||
// Tests that the WhoIs handler accepts either IPs or IP:ports.
|
||||
//
|
||||
// From https://github.com/tailscale/tailscale/pull/9714 (a PR that is effectively a bug report)
|
||||
func TestWhoIsJustIP(t *testing.T) {
|
||||
h := &Handler{
|
||||
PermitRead: true,
|
||||
}
|
||||
for _, input := range []string{"100.101.102.103", "127.0.0.1:123"} {
|
||||
rec := httptest.NewRecorder()
|
||||
t.Run(input, func(t *testing.T) {
|
||||
b := whoIsBackend{
|
||||
whoIs: func(ipp netip.AddrPort) (n tailcfg.NodeView, u tailcfg.UserProfile, ok bool) {
|
||||
if !strings.Contains(input, ":") {
|
||||
want := netip.MustParseAddrPort("100.101.102.103:0")
|
||||
if ipp != want {
|
||||
t.Fatalf("backend called with %v; want %v", ipp, want)
|
||||
}
|
||||
}
|
||||
return (&tailcfg.Node{
|
||||
ID: 123,
|
||||
Addresses: []netip.Prefix{
|
||||
netip.MustParsePrefix("100.101.102.103/32"),
|
||||
},
|
||||
}).View(),
|
||||
tailcfg.UserProfile{ID: 456, DisplayName: "foo"},
|
||||
true
|
||||
},
|
||||
peerCaps: map[netip.Addr]tailcfg.PeerCapMap{
|
||||
netip.MustParseAddr("100.101.102.103"): map[tailcfg.PeerCapability][]tailcfg.RawMessage{
|
||||
"foo": {`"bar"`},
|
||||
},
|
||||
},
|
||||
}
|
||||
h.serveWhoIsWithBackend(rec, httptest.NewRequest("GET", "/v0/whois?addr="+url.QueryEscape(input), nil), b)
|
||||
|
||||
var res apitype.WhoIsResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &res); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := res.Node.ID, tailcfg.NodeID(123); got != want {
|
||||
t.Errorf("res.Node.ID=%v, want %v", got, want)
|
||||
}
|
||||
if got, want := res.UserProfile.DisplayName, "foo"; got != want {
|
||||
t.Errorf("res.UserProfile.DisplayName=%q, want %q", got, want)
|
||||
}
|
||||
if got, want := len(res.CapMap), 1; got != want {
|
||||
t.Errorf("capmap size=%v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,6 +200,10 @@ type Prefs struct {
|
||||
// AutoUpdatePrefs docs for more details.
|
||||
AutoUpdate AutoUpdatePrefs
|
||||
|
||||
// PostureChecking enables the collection of information used for device
|
||||
// posture checks.
|
||||
PostureChecking bool
|
||||
|
||||
// The Persist field is named 'Config' in the file for backward
|
||||
// compatibility with earlier versions.
|
||||
// TODO(apenwarr): We should move this out of here, it's not a pref.
|
||||
@@ -246,6 +250,7 @@ type MaskedPrefs struct {
|
||||
OperatorUserSet bool `json:",omitempty"`
|
||||
ProfileNameSet bool `json:",omitempty"`
|
||||
AutoUpdateSet bool `json:",omitempty"`
|
||||
PostureCheckingSet bool `json:",omitempty"`
|
||||
}
|
||||
|
||||
// ApplyEdits mutates p, assigning fields from m.Prefs for each MaskedPrefs
|
||||
@@ -439,7 +444,8 @@ func (p *Prefs) Equals(p2 *Prefs) bool {
|
||||
compareStrings(p.AdvertiseTags, p2.AdvertiseTags) &&
|
||||
p.Persist.Equals(p2.Persist) &&
|
||||
p.ProfileName == p2.ProfileName &&
|
||||
p.AutoUpdate == p2.AutoUpdate
|
||||
p.AutoUpdate == p2.AutoUpdate &&
|
||||
p.PostureChecking == p2.PostureChecking
|
||||
}
|
||||
|
||||
func (au AutoUpdatePrefs) Pretty() string {
|
||||
|
||||
@@ -57,6 +57,7 @@ func TestPrefsEqual(t *testing.T) {
|
||||
"OperatorUser",
|
||||
"ProfileName",
|
||||
"AutoUpdate",
|
||||
"PostureChecking",
|
||||
"Persist",
|
||||
}
|
||||
if have := fieldsOf(reflect.TypeOf(Prefs{})); !reflect.DeepEqual(have, prefsHandles) {
|
||||
@@ -304,6 +305,16 @@ func TestPrefsEqual(t *testing.T) {
|
||||
&Prefs{AutoUpdate: AutoUpdatePrefs{Check: true, Apply: false}},
|
||||
true,
|
||||
},
|
||||
{
|
||||
&Prefs{PostureChecking: true},
|
||||
&Prefs{PostureChecking: true},
|
||||
true,
|
||||
},
|
||||
{
|
||||
&Prefs{PostureChecking: true},
|
||||
&Prefs{PostureChecking: false},
|
||||
false,
|
||||
},
|
||||
}
|
||||
for i, tt := range tests {
|
||||
got := tt.a.Equals(tt.b)
|
||||
|
||||
@@ -28,6 +28,7 @@ and [iOS][]. See also the dependencies in the [Tailscale CLI][].
|
||||
- [github.com/aws/smithy-go/internal/sync/singleflight](https://pkg.go.dev/github.com/aws/smithy-go/internal/sync/singleflight) ([BSD-3-Clause](https://github.com/aws/smithy-go/blob/v1.14.2/internal/sync/singleflight/LICENSE))
|
||||
- [github.com/coreos/go-iptables/iptables](https://pkg.go.dev/github.com/coreos/go-iptables/iptables) ([Apache-2.0](https://github.com/coreos/go-iptables/blob/v0.7.0/LICENSE))
|
||||
- [github.com/coreos/go-systemd/v22/dbus](https://pkg.go.dev/github.com/coreos/go-systemd/v22/dbus) ([Apache-2.0](https://github.com/coreos/go-systemd/blob/v22.5.0/LICENSE))
|
||||
- [github.com/digitalocean/go-smbios/smbios](https://pkg.go.dev/github.com/digitalocean/go-smbios/smbios) ([Apache-2.0](https://github.com/digitalocean/go-smbios/blob/390a4f403a8e/LICENSE.md))
|
||||
- [github.com/fxamacker/cbor/v2](https://pkg.go.dev/github.com/fxamacker/cbor/v2) ([MIT](https://github.com/fxamacker/cbor/blob/v2.5.0/LICENSE))
|
||||
- [github.com/godbus/dbus/v5](https://pkg.go.dev/github.com/godbus/dbus/v5) ([BSD-2-Clause](https://github.com/godbus/dbus/blob/76236955d466/LICENSE))
|
||||
- [github.com/golang/groupcache/lru](https://pkg.go.dev/github.com/golang/groupcache/lru) ([Apache-2.0](https://github.com/golang/groupcache/blob/41bb18bfe9da/LICENSE))
|
||||
@@ -53,6 +54,7 @@ and [iOS][]. See also the dependencies in the [Tailscale CLI][].
|
||||
- [github.com/pierrec/lz4/v4](https://pkg.go.dev/github.com/pierrec/lz4/v4) ([BSD-3-Clause](https://github.com/pierrec/lz4/blob/v4.1.18/LICENSE))
|
||||
- [github.com/tailscale/golang-x-crypto](https://pkg.go.dev/github.com/tailscale/golang-x-crypto) ([BSD-3-Clause](https://github.com/tailscale/golang-x-crypto/blob/f0b76a10a08e/LICENSE))
|
||||
- [github.com/tailscale/goupnp](https://pkg.go.dev/github.com/tailscale/goupnp) ([BSD-2-Clause](https://github.com/tailscale/goupnp/blob/c64d0f06ea05/LICENSE))
|
||||
- [github.com/tailscale/hujson](https://pkg.go.dev/github.com/tailscale/hujson) ([BSD-3-Clause](https://github.com/tailscale/hujson/blob/20486734a56a/LICENSE))
|
||||
- [github.com/tailscale/netlink](https://pkg.go.dev/github.com/tailscale/netlink) ([Apache-2.0](https://github.com/tailscale/netlink/blob/cabfb018fe85/LICENSE))
|
||||
- [github.com/tailscale/wireguard-go](https://pkg.go.dev/github.com/tailscale/wireguard-go) ([MIT](https://github.com/tailscale/wireguard-go/blob/2f6748dc88e7/LICENSE))
|
||||
- [github.com/tcnksm/go-httpstat](https://pkg.go.dev/github.com/tcnksm/go-httpstat) ([MIT](https://github.com/tcnksm/go-httpstat/blob/v0.2.0/LICENSE))
|
||||
@@ -62,12 +64,12 @@ and [iOS][]. See also the dependencies in the [Tailscale CLI][].
|
||||
- [github.com/x448/float16](https://pkg.go.dev/github.com/x448/float16) ([MIT](https://github.com/x448/float16/blob/v0.8.4/LICENSE))
|
||||
- [go4.org/mem](https://pkg.go.dev/go4.org/mem) ([Apache-2.0](https://github.com/go4org/mem/blob/4f986261bf13/LICENSE))
|
||||
- [go4.org/netipx](https://pkg.go.dev/go4.org/netipx) ([BSD-3-Clause](https://github.com/go4org/netipx/blob/6213f710f925/LICENSE))
|
||||
- [golang.org/x/crypto](https://pkg.go.dev/golang.org/x/crypto) ([BSD-3-Clause](https://cs.opensource.google/go/x/crypto/+/v0.13.0:LICENSE))
|
||||
- [golang.org/x/crypto](https://pkg.go.dev/golang.org/x/crypto) ([BSD-3-Clause](https://cs.opensource.google/go/x/crypto/+/v0.14.0:LICENSE))
|
||||
- [golang.org/x/exp](https://pkg.go.dev/golang.org/x/exp) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/92128663:LICENSE))
|
||||
- [golang.org/x/net](https://pkg.go.dev/golang.org/x/net) ([BSD-3-Clause](https://github.com/tailscale/golang-x-net/blob/9a58c47922fd/LICENSE))
|
||||
- [golang.org/x/net](https://pkg.go.dev/golang.org/x/net) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.17.0:LICENSE))
|
||||
- [golang.org/x/sync/errgroup](https://pkg.go.dev/golang.org/x/sync/errgroup) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.3.0:LICENSE))
|
||||
- [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.12.0:LICENSE))
|
||||
- [golang.org/x/term](https://pkg.go.dev/golang.org/x/term) ([BSD-3-Clause](https://cs.opensource.google/go/x/term/+/v0.12.0:LICENSE))
|
||||
- [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.13.0:LICENSE))
|
||||
- [golang.org/x/term](https://pkg.go.dev/golang.org/x/term) ([BSD-3-Clause](https://cs.opensource.google/go/x/term/+/v0.13.0:LICENSE))
|
||||
- [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.13.0:LICENSE))
|
||||
- [golang.org/x/time/rate](https://pkg.go.dev/golang.org/x/time/rate) ([BSD-3-Clause](https://cs.opensource.google/go/x/time/+/v0.3.0:LICENSE))
|
||||
- [gvisor.dev/gvisor/pkg](https://pkg.go.dev/gvisor.dev/gvisor/pkg) ([Apache-2.0](https://github.com/google/gvisor/blob/4fe30062272c/LICENSE))
|
||||
|
||||
@@ -37,6 +37,7 @@ Some packages may only be included on certain architectures or operating systems
|
||||
- [github.com/coreos/go-systemd/v22/dbus](https://pkg.go.dev/github.com/coreos/go-systemd/v22/dbus) ([Apache-2.0](https://github.com/coreos/go-systemd/blob/v22.5.0/LICENSE))
|
||||
- [github.com/creack/pty](https://pkg.go.dev/github.com/creack/pty) ([MIT](https://github.com/creack/pty/blob/v1.1.18/LICENSE))
|
||||
- [github.com/dblohm7/wingoes](https://pkg.go.dev/github.com/dblohm7/wingoes) ([BSD-3-Clause](https://github.com/dblohm7/wingoes/blob/e994401fc077/LICENSE))
|
||||
- [github.com/digitalocean/go-smbios/smbios](https://pkg.go.dev/github.com/digitalocean/go-smbios/smbios) ([Apache-2.0](https://github.com/digitalocean/go-smbios/blob/390a4f403a8e/LICENSE.md))
|
||||
- [github.com/fxamacker/cbor/v2](https://pkg.go.dev/github.com/fxamacker/cbor/v2) ([MIT](https://github.com/fxamacker/cbor/blob/v2.5.0/LICENSE))
|
||||
- [github.com/go-ole/go-ole](https://pkg.go.dev/github.com/go-ole/go-ole) ([MIT](https://github.com/go-ole/go-ole/blob/v1.3.0/LICENSE))
|
||||
- [github.com/godbus/dbus/v5](https://pkg.go.dev/github.com/godbus/dbus/v5) ([BSD-2-Clause](https://github.com/godbus/dbus/blob/76236955d466/LICENSE))
|
||||
@@ -85,13 +86,13 @@ Some packages may only be included on certain architectures or operating systems
|
||||
- [github.com/x448/float16](https://pkg.go.dev/github.com/x448/float16) ([MIT](https://github.com/x448/float16/blob/v0.8.4/LICENSE))
|
||||
- [go4.org/mem](https://pkg.go.dev/go4.org/mem) ([Apache-2.0](https://github.com/go4org/mem/blob/4f986261bf13/LICENSE))
|
||||
- [go4.org/netipx](https://pkg.go.dev/go4.org/netipx) ([BSD-3-Clause](https://github.com/go4org/netipx/blob/6213f710f925/LICENSE))
|
||||
- [golang.org/x/crypto](https://pkg.go.dev/golang.org/x/crypto) ([BSD-3-Clause](https://cs.opensource.google/go/x/crypto/+/v0.13.0:LICENSE))
|
||||
- [golang.org/x/crypto](https://pkg.go.dev/golang.org/x/crypto) ([BSD-3-Clause](https://cs.opensource.google/go/x/crypto/+/v0.14.0:LICENSE))
|
||||
- [golang.org/x/exp](https://pkg.go.dev/golang.org/x/exp) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/92128663:LICENSE))
|
||||
- [golang.org/x/net](https://pkg.go.dev/golang.org/x/net) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.15.0:LICENSE))
|
||||
- [golang.org/x/net](https://pkg.go.dev/golang.org/x/net) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.17.0:LICENSE))
|
||||
- [golang.org/x/oauth2](https://pkg.go.dev/golang.org/x/oauth2) ([BSD-3-Clause](https://cs.opensource.google/go/x/oauth2/+/v0.12.0:LICENSE))
|
||||
- [golang.org/x/sync/errgroup](https://pkg.go.dev/golang.org/x/sync/errgroup) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.3.0:LICENSE))
|
||||
- [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.12.0:LICENSE))
|
||||
- [golang.org/x/term](https://pkg.go.dev/golang.org/x/term) ([BSD-3-Clause](https://cs.opensource.google/go/x/term/+/v0.12.0:LICENSE))
|
||||
- [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.13.0:LICENSE))
|
||||
- [golang.org/x/term](https://pkg.go.dev/golang.org/x/term) ([BSD-3-Clause](https://cs.opensource.google/go/x/term/+/v0.13.0:LICENSE))
|
||||
- [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.13.0:LICENSE))
|
||||
- [golang.org/x/time/rate](https://pkg.go.dev/golang.org/x/time/rate) ([BSD-3-Clause](https://cs.opensource.google/go/x/time/+/v0.3.0:LICENSE))
|
||||
- [golang.zx2c4.com/wintun](https://pkg.go.dev/golang.zx2c4.com/wintun) ([MIT](https://git.zx2c4.com/wintun-go/tree/LICENSE?id=0fa3db229ce2))
|
||||
|
||||
@@ -11,7 +11,7 @@ Windows][]. See also the dependencies in the [Tailscale CLI][].
|
||||
|
||||
- [filippo.io/edwards25519](https://pkg.go.dev/filippo.io/edwards25519) ([BSD-3-Clause](https://github.com/FiloSottile/edwards25519/blob/v1.0.0/LICENSE))
|
||||
- [github.com/Microsoft/go-winio](https://pkg.go.dev/github.com/Microsoft/go-winio) ([MIT](https://github.com/Microsoft/go-winio/blob/v0.6.1/LICENSE))
|
||||
- [github.com/alexbrainman/sspi](https://pkg.go.dev/github.com/alexbrainman/sspi) ([BSD-3-Clause](https://github.com/alexbrainman/sspi/blob/909beea2cc74/LICENSE))
|
||||
- [github.com/alexbrainman/sspi](https://pkg.go.dev/github.com/alexbrainman/sspi) ([BSD-3-Clause](https://github.com/alexbrainman/sspi/blob/1a75b4708caa/LICENSE))
|
||||
- [github.com/apenwarr/fixconsole](https://pkg.go.dev/github.com/apenwarr/fixconsole) ([Apache-2.0](https://github.com/apenwarr/fixconsole/blob/5a9f6489cc29/LICENSE))
|
||||
- [github.com/apenwarr/w32](https://pkg.go.dev/github.com/apenwarr/w32) ([BSD-3-Clause](https://github.com/apenwarr/w32/blob/aa00fece76ab/LICENSE))
|
||||
- [github.com/coreos/go-iptables/iptables](https://pkg.go.dev/github.com/coreos/go-iptables/iptables) ([Apache-2.0](https://github.com/coreos/go-iptables/blob/v0.7.0/LICENSE))
|
||||
@@ -36,7 +36,7 @@ Windows][]. See also the dependencies in the [Tailscale CLI][].
|
||||
- [github.com/peterbourgon/diskv](https://pkg.go.dev/github.com/peterbourgon/diskv) ([MIT](https://github.com/peterbourgon/diskv/blob/v2.0.1/LICENSE))
|
||||
- [github.com/skip2/go-qrcode](https://pkg.go.dev/github.com/skip2/go-qrcode) ([MIT](https://github.com/skip2/go-qrcode/blob/da1b6568686e/LICENSE))
|
||||
- [github.com/tailscale/netlink](https://pkg.go.dev/github.com/tailscale/netlink) ([Apache-2.0](https://github.com/tailscale/netlink/blob/cabfb018fe85/LICENSE))
|
||||
- [github.com/tailscale/walk](https://pkg.go.dev/github.com/tailscale/walk) ([BSD-3-Clause](https://github.com/tailscale/walk/blob/df3128d017f4/LICENSE))
|
||||
- [github.com/tailscale/walk](https://pkg.go.dev/github.com/tailscale/walk) ([BSD-3-Clause](https://github.com/tailscale/walk/blob/dff4ed649e49/LICENSE))
|
||||
- [github.com/tailscale/win](https://pkg.go.dev/github.com/tailscale/win) ([BSD-3-Clause](https://github.com/tailscale/win/blob/84569fd814a9/LICENSE))
|
||||
- [github.com/tc-hib/winres](https://pkg.go.dev/github.com/tc-hib/winres) ([0BSD](https://github.com/tc-hib/winres/blob/v0.2.1/LICENSE))
|
||||
- [github.com/vishvananda/netlink/nl](https://pkg.go.dev/github.com/vishvananda/netlink/nl) ([Apache-2.0](https://github.com/vishvananda/netlink/blob/v1.2.1-beta.2/LICENSE))
|
||||
@@ -44,14 +44,14 @@ Windows][]. See also the dependencies in the [Tailscale CLI][].
|
||||
- [github.com/x448/float16](https://pkg.go.dev/github.com/x448/float16) ([MIT](https://github.com/x448/float16/blob/v0.8.4/LICENSE))
|
||||
- [go4.org/mem](https://pkg.go.dev/go4.org/mem) ([Apache-2.0](https://github.com/go4org/mem/blob/4f986261bf13/LICENSE))
|
||||
- [go4.org/netipx](https://pkg.go.dev/go4.org/netipx) ([BSD-3-Clause](https://github.com/go4org/netipx/blob/6213f710f925/LICENSE))
|
||||
- [golang.org/x/crypto](https://pkg.go.dev/golang.org/x/crypto) ([BSD-3-Clause](https://cs.opensource.google/go/x/crypto/+/v0.13.0:LICENSE))
|
||||
- [golang.org/x/exp](https://pkg.go.dev/golang.org/x/exp) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/92128663:LICENSE))
|
||||
- [golang.org/x/crypto](https://pkg.go.dev/golang.org/x/crypto) ([BSD-3-Clause](https://cs.opensource.google/go/x/crypto/+/v0.14.0:LICENSE))
|
||||
- [golang.org/x/exp/constraints](https://pkg.go.dev/golang.org/x/exp/constraints) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/92128663:LICENSE))
|
||||
- [golang.org/x/image/bmp](https://pkg.go.dev/golang.org/x/image/bmp) ([BSD-3-Clause](https://cs.opensource.google/go/x/image/+/v0.12.0:LICENSE))
|
||||
- [golang.org/x/mod](https://pkg.go.dev/golang.org/x/mod) ([BSD-3-Clause](https://cs.opensource.google/go/x/mod/+/v0.12.0:LICENSE))
|
||||
- [golang.org/x/net](https://pkg.go.dev/golang.org/x/net) ([BSD-3-Clause](https://github.com/tailscale/golang-x-net/blob/9a58c47922fd/LICENSE))
|
||||
- [golang.org/x/net](https://pkg.go.dev/golang.org/x/net) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.17.0:LICENSE))
|
||||
- [golang.org/x/sync/errgroup](https://pkg.go.dev/golang.org/x/sync/errgroup) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.3.0:LICENSE))
|
||||
- [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.12.0:LICENSE))
|
||||
- [golang.org/x/term](https://pkg.go.dev/golang.org/x/term) ([BSD-3-Clause](https://cs.opensource.google/go/x/term/+/v0.12.0:LICENSE))
|
||||
- [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.13.0:LICENSE))
|
||||
- [golang.org/x/term](https://pkg.go.dev/golang.org/x/term) ([BSD-3-Clause](https://cs.opensource.google/go/x/term/+/v0.13.0:LICENSE))
|
||||
- [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.13.0:LICENSE))
|
||||
- [golang.zx2c4.com/wintun](https://pkg.go.dev/golang.zx2c4.com/wintun) ([MIT](https://git.zx2c4.com/wintun-go/tree/LICENSE?id=0fa3db229ce2))
|
||||
- [golang.zx2c4.com/wireguard/windows/tunnel/winipcfg](https://pkg.go.dev/golang.zx2c4.com/wireguard/windows/tunnel/winipcfg) ([MIT](https://git.zx2c4.com/wireguard-windows/tree/COPYING?h=v0.5.3))
|
||||
|
||||
@@ -265,6 +265,13 @@ func dnsMode(logf logger.Logf, env newOSConfigEnv) (ret string, err error) {
|
||||
dbg("nm-safe", "yes")
|
||||
return "network-manager", nil
|
||||
}
|
||||
if err := env.nmIsUsingResolved(); err != nil {
|
||||
// If systemd-resolved is not running at all, then we don't have any
|
||||
// other choice: we take direct control of DNS.
|
||||
dbg("nm-resolved", "no")
|
||||
return "direct", nil
|
||||
}
|
||||
|
||||
health.SetDNSManagerHealth(errors.New("systemd-resolved and NetworkManager are wired together incorrectly; MagicDNS will probably not work. For more info, see https://tailscale.com/s/resolved-nm"))
|
||||
dbg("nm-safe", "no")
|
||||
return "systemd-resolved", nil
|
||||
|
||||
@@ -270,6 +270,18 @@ func TestLinuxDNSMode(t *testing.T) {
|
||||
wantLog: "dns: [resolved-ping=yes rc=resolved resolved=file nm=no resolv-conf-mode=fortests ret=systemd-resolved]",
|
||||
want: "systemd-resolved",
|
||||
},
|
||||
{
|
||||
// regression test for https://github.com/tailscale/tailscale/issues/9687
|
||||
name: "networkmanager_endeavouros",
|
||||
env: env(resolvDotConf(
|
||||
"# Generated by NetworkManager",
|
||||
"search example.com localdomain",
|
||||
"nameserver 10.0.0.1"),
|
||||
nmRunning("1.44.2", false)),
|
||||
wantLog: "dns: resolvedIsActuallyResolver error: resolv.conf doesn't point to systemd-resolved; points to [10.0.0.1]\n" +
|
||||
"dns: [rc=nm resolved=not-in-use ret=direct]",
|
||||
want: "direct",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
package dns
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
@@ -13,13 +14,17 @@ func resolvconfStyle() string {
|
||||
if _, err := exec.LookPath("resolvconf"); err != nil {
|
||||
return ""
|
||||
}
|
||||
if _, err := exec.Command("resolvconf", "--version").CombinedOutput(); err != nil {
|
||||
output, err := exec.Command("resolvconf", "--version").CombinedOutput()
|
||||
if err != nil {
|
||||
// Debian resolvconf doesn't understand --version, and
|
||||
// exits with a specific error code.
|
||||
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 99 {
|
||||
return "debian"
|
||||
}
|
||||
}
|
||||
if bytes.HasPrefix(output, []byte("Debian resolvconf")) {
|
||||
return "debian"
|
||||
}
|
||||
// Treat everything else as openresolv, by far the more popular implementation.
|
||||
return "openresolv"
|
||||
}
|
||||
|
||||
@@ -39,14 +39,22 @@ import (
|
||||
"tailscale.com/util/slicesx"
|
||||
)
|
||||
|
||||
var disableRecursiveResolver = envknob.RegisterBool("TS_DNSFALLBACK_DISABLE_RECURSIVE_RESOLVER")
|
||||
var (
|
||||
optRecursiveResolver = envknob.RegisterOptBool("TS_DNSFALLBACK_RECURSIVE_RESOLVER")
|
||||
disableRecursiveResolver = envknob.RegisterBool("TS_DNSFALLBACK_DISABLE_RECURSIVE_RESOLVER") // legacy pre-1.52 env knob name
|
||||
)
|
||||
|
||||
// MakeLookupFunc creates a function that can be used to resolve hostnames
|
||||
// (e.g. as a LookupIPFallback from dnscache.Resolver).
|
||||
// The netMon parameter is optional; if non-nil it's used to do faster interface lookups.
|
||||
func MakeLookupFunc(logf logger.Logf, netMon *netmon.Monitor) func(ctx context.Context, host string) ([]netip.Addr, error) {
|
||||
return func(ctx context.Context, host string) ([]netip.Addr, error) {
|
||||
if disableRecursiveResolver() {
|
||||
// If they've explicitly disabled the recursive resolver with the legacy
|
||||
// TS_DNSFALLBACK_DISABLE_RECURSIVE_RESOLVER envknob or not set the
|
||||
// newer TS_DNSFALLBACK_RECURSIVE_RESOLVER to true, then don't use the
|
||||
// recursive resolver. (tailscale/corp#15261) In the future, we might
|
||||
// change the default (the opt.Bool being unset) to mean enabled.
|
||||
if disableRecursiveResolver() || !optRecursiveResolver().EqualBool(true) {
|
||||
return lookup(ctx, host, logf, netMon)
|
||||
}
|
||||
|
||||
|
||||
197
net/packet/checksum/checksum.go
Normal file
197
net/packet/checksum/checksum.go
Normal file
@@ -0,0 +1,197 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package checksum provides functions for updating checksums in parsed packets.
|
||||
package checksum
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"net/netip"
|
||||
|
||||
"gvisor.dev/gvisor/pkg/tcpip"
|
||||
"gvisor.dev/gvisor/pkg/tcpip/header"
|
||||
"tailscale.com/net/packet"
|
||||
"tailscale.com/types/ipproto"
|
||||
)
|
||||
|
||||
// UpdateSrcAddr updates the source address in the packet buffer (e.g. during
|
||||
// SNAT). It also updates the checksum. Currently (2023-09-22) only TCP/UDP/ICMP
|
||||
// is supported. It panics if provided with an address in a different
|
||||
// family to the parsed packet.
|
||||
func UpdateSrcAddr(q *packet.Parsed, src netip.Addr) {
|
||||
if src.Is6() && q.IPVersion != 6 {
|
||||
panic("UpdateSrcAddr: cannot write IPv6 address to v4 packet")
|
||||
} else if src.Is4() && q.IPVersion != 4 {
|
||||
panic("UpdateSrcAddr: cannot write IPv4 address to v6 packet")
|
||||
}
|
||||
q.CaptureMeta.DidSNAT = true
|
||||
q.CaptureMeta.OriginalSrc = q.Src
|
||||
|
||||
old := q.Src.Addr()
|
||||
q.Src = netip.AddrPortFrom(src, q.Src.Port())
|
||||
|
||||
b := q.Buffer()
|
||||
if src.Is6() {
|
||||
v6 := src.As16()
|
||||
copy(b[8:24], v6[:])
|
||||
updateV6PacketChecksums(q, old, src)
|
||||
} else {
|
||||
v4 := src.As4()
|
||||
copy(b[12:16], v4[:])
|
||||
updateV4PacketChecksums(q, old, src)
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateDstAddr updates the destination address in the packet buffer (e.g. during
|
||||
// DNAT). It also updates the checksum. Currently (2022-12-10) only TCP/UDP/ICMP
|
||||
// is supported. It panics if provided with an address in a different
|
||||
// family to the parsed packet.
|
||||
func UpdateDstAddr(q *packet.Parsed, dst netip.Addr) {
|
||||
if dst.Is6() && q.IPVersion != 6 {
|
||||
panic("UpdateDstAddr: cannot write IPv6 address to v4 packet")
|
||||
} else if dst.Is4() && q.IPVersion != 4 {
|
||||
panic("UpdateDstAddr: cannot write IPv4 address to v6 packet")
|
||||
}
|
||||
q.CaptureMeta.DidDNAT = true
|
||||
q.CaptureMeta.OriginalDst = q.Dst
|
||||
|
||||
old := q.Dst.Addr()
|
||||
q.Dst = netip.AddrPortFrom(dst, q.Dst.Port())
|
||||
|
||||
b := q.Buffer()
|
||||
if dst.Is6() {
|
||||
v6 := dst.As16()
|
||||
copy(b[24:36], v6[:])
|
||||
updateV6PacketChecksums(q, old, dst)
|
||||
} else {
|
||||
v4 := dst.As4()
|
||||
copy(b[16:20], v4[:])
|
||||
updateV4PacketChecksums(q, old, dst)
|
||||
}
|
||||
}
|
||||
|
||||
// updateV4PacketChecksums updates the checksums in the packet buffer.
|
||||
// Currently (2023-03-01) only TCP/UDP/ICMP over IPv4 is supported.
|
||||
// p is modified in place.
|
||||
// If p.IPProto is unknown, only the IP header checksum is updated.
|
||||
func updateV4PacketChecksums(p *packet.Parsed, old, new netip.Addr) {
|
||||
if len(p.Buffer()) < 12 {
|
||||
// Not enough space for an IPv4 header.
|
||||
return
|
||||
}
|
||||
o4, n4 := old.As4(), new.As4()
|
||||
|
||||
// First update the checksum in the IP header.
|
||||
updateV4Checksum(p.Buffer()[10:12], o4[:], n4[:])
|
||||
|
||||
// Now update the transport layer checksums, where applicable.
|
||||
tr := p.Transport()
|
||||
switch p.IPProto {
|
||||
case ipproto.UDP, ipproto.DCCP:
|
||||
if len(tr) < header.UDPMinimumSize {
|
||||
// Not enough space for a UDP header.
|
||||
return
|
||||
}
|
||||
updateV4Checksum(tr[6:8], o4[:], n4[:])
|
||||
case ipproto.TCP:
|
||||
if len(tr) < header.TCPMinimumSize {
|
||||
// Not enough space for a TCP header.
|
||||
return
|
||||
}
|
||||
updateV4Checksum(tr[16:18], o4[:], n4[:])
|
||||
case ipproto.GRE:
|
||||
if len(tr) < 6 {
|
||||
// Not enough space for a GRE header.
|
||||
return
|
||||
}
|
||||
if tr[0] == 1 { // checksum present
|
||||
updateV4Checksum(tr[4:6], o4[:], n4[:])
|
||||
}
|
||||
case ipproto.SCTP, ipproto.ICMPv4:
|
||||
// No transport layer update required.
|
||||
}
|
||||
}
|
||||
|
||||
// updateV6PacketChecksums updates the checksums in the packet buffer.
|
||||
// p is modified in place.
|
||||
// If p.IPProto is unknown, no checksums are updated.
|
||||
func updateV6PacketChecksums(p *packet.Parsed, old, new netip.Addr) {
|
||||
if len(p.Buffer()) < 40 {
|
||||
// Not enough space for an IPv6 header.
|
||||
return
|
||||
}
|
||||
o6, n6 := tcpip.AddrFrom16Slice(old.AsSlice()), tcpip.AddrFrom16Slice(new.AsSlice())
|
||||
|
||||
// Now update the transport layer checksums, where applicable.
|
||||
tr := p.Transport()
|
||||
switch p.IPProto {
|
||||
case ipproto.ICMPv6:
|
||||
if len(tr) < header.ICMPv6MinimumSize {
|
||||
return
|
||||
}
|
||||
header.ICMPv6(tr).UpdateChecksumPseudoHeaderAddress(o6, n6)
|
||||
case ipproto.UDP, ipproto.DCCP:
|
||||
if len(tr) < header.UDPMinimumSize {
|
||||
return
|
||||
}
|
||||
header.UDP(tr).UpdateChecksumPseudoHeaderAddress(o6, n6, true)
|
||||
case ipproto.TCP:
|
||||
if len(tr) < header.TCPMinimumSize {
|
||||
return
|
||||
}
|
||||
header.TCP(tr).UpdateChecksumPseudoHeaderAddress(o6, n6, true)
|
||||
case ipproto.SCTP:
|
||||
// No transport layer update required.
|
||||
}
|
||||
}
|
||||
|
||||
// updateV4Checksum calculates and updates the checksum in the packet buffer for
|
||||
// a change between old and new. The oldSum must point to the 16-bit checksum
|
||||
// field in the packet buffer that holds the old checksum value, it will be
|
||||
// updated in place.
|
||||
//
|
||||
// The old and new must be the same length, and must be an even number of bytes.
|
||||
func updateV4Checksum(oldSum, old, new []byte) {
|
||||
if len(old) != len(new) {
|
||||
panic("old and new must be the same length")
|
||||
}
|
||||
if len(old)%2 != 0 {
|
||||
panic("old and new must be of even length")
|
||||
}
|
||||
/*
|
||||
RFC 1624
|
||||
Given the following notation:
|
||||
|
||||
HC - old checksum in header
|
||||
C - one's complement sum of old header
|
||||
HC' - new checksum in header
|
||||
C' - one's complement sum of new header
|
||||
m - old value of a 16-bit field
|
||||
m' - new value of a 16-bit field
|
||||
|
||||
HC' = ~(C + (-m) + m') -- [Eqn. 3]
|
||||
HC' = ~(~HC + ~m + m')
|
||||
|
||||
This can be simplified to:
|
||||
HC' = ~(C + ~m + m') -- [Eqn. 3]
|
||||
HC' = ~C'
|
||||
C' = C + ~m + m'
|
||||
*/
|
||||
|
||||
c := uint32(^binary.BigEndian.Uint16(oldSum))
|
||||
|
||||
cPrime := c
|
||||
for len(new) > 0 {
|
||||
mNot := uint32(^binary.BigEndian.Uint16(old[:2]))
|
||||
mPrime := uint32(binary.BigEndian.Uint16(new[:2]))
|
||||
cPrime += mPrime + mNot
|
||||
new, old = new[2:], old[2:]
|
||||
}
|
||||
|
||||
// Account for overflows by adding the carry bits back into the sum.
|
||||
for (cPrime >> 16) > 0 {
|
||||
cPrime = cPrime&0xFFFF + cPrime>>16
|
||||
}
|
||||
hcPrime := ^uint16(cPrime)
|
||||
binary.BigEndian.PutUint16(oldSum, hcPrime)
|
||||
}
|
||||
196
net/packet/checksum/checksum_test.go
Normal file
196
net/packet/checksum/checksum_test.go
Normal file
@@ -0,0 +1,196 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package checksum
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"gvisor.dev/gvisor/pkg/tcpip"
|
||||
"gvisor.dev/gvisor/pkg/tcpip/checksum"
|
||||
"gvisor.dev/gvisor/pkg/tcpip/header"
|
||||
"tailscale.com/net/packet"
|
||||
)
|
||||
|
||||
func fullHeaderChecksumV4(b []byte) uint16 {
|
||||
s := uint32(0)
|
||||
for i := 0; i < len(b); i += 2 {
|
||||
if i == 10 {
|
||||
// Skip checksum field.
|
||||
continue
|
||||
}
|
||||
s += uint32(binary.BigEndian.Uint16(b[i : i+2]))
|
||||
}
|
||||
for s>>16 > 0 {
|
||||
s = s&0xFFFF + s>>16
|
||||
}
|
||||
return ^uint16(s)
|
||||
}
|
||||
|
||||
func TestHeaderChecksumsV4(t *testing.T) {
|
||||
// This is not a good enough test, because it doesn't
|
||||
// check the various packet types or the many edge cases
|
||||
// of the checksum algorithm. But it's a start.
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
packet []byte
|
||||
}{
|
||||
{
|
||||
name: "ICMPv4",
|
||||
packet: []byte{
|
||||
0x45, 0x00, 0x00, 0x54, 0xb7, 0x96, 0x40, 0x00, 0x40, 0x01, 0x7a, 0x06, 0x64, 0x7f, 0x3f, 0x4c, 0x64, 0x40, 0x01, 0x01, 0x08, 0x00, 0x47, 0x1a, 0x00, 0x11, 0x01, 0xac, 0xcc, 0xf5, 0x95, 0x63, 0x00, 0x00, 0x00, 0x00, 0x8d, 0xfc, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "TLS",
|
||||
packet: []byte{
|
||||
0x45, 0x00, 0x00, 0x3c, 0x54, 0x29, 0x40, 0x00, 0x40, 0x06, 0xb1, 0xac, 0x64, 0x42, 0xd4, 0x33, 0x64, 0x61, 0x98, 0x0f, 0xb1, 0x94, 0x01, 0xbb, 0x0a, 0x51, 0xce, 0x7c, 0x00, 0x00, 0x00, 0x00, 0xa0, 0x02, 0xfb, 0xe0, 0x38, 0xf6, 0x00, 0x00, 0x02, 0x04, 0x04, 0xd8, 0x04, 0x02, 0x08, 0x0a, 0x86, 0x2b, 0xcc, 0xd5, 0x00, 0x00, 0x00, 0x00, 0x01, 0x03, 0x03, 0x07,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "DNS",
|
||||
packet: []byte{
|
||||
0x45, 0x00, 0x00, 0x74, 0xe2, 0x85, 0x00, 0x00, 0x40, 0x11, 0x96, 0xb5, 0x64, 0x64, 0x64, 0x64, 0x64, 0x42, 0xd4, 0x33, 0x00, 0x35, 0xec, 0x55, 0x00, 0x60, 0xd9, 0x19, 0xed, 0xfd, 0x81, 0x80, 0x00, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0x01, 0x08, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x73, 0x34, 0x06, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x03, 0x63, 0x6f, 0x6d, 0x00, 0x00, 0x01, 0x00, 0x01, 0xc0, 0x0c, 0x00, 0x05, 0x00, 0x01, 0x00, 0x00, 0x01, 0x1e, 0x00, 0x0c, 0x07, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x73, 0x01, 0x6c, 0xc0, 0x15, 0xc0, 0x31, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x01, 0x1e, 0x00, 0x04, 0x8e, 0xfa, 0xbd, 0xce, 0x00, 0x00, 0x29, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "DCCP",
|
||||
packet: []byte{
|
||||
0x45, 0x00, 0x00, 0x28, 0x15, 0x06, 0x40, 0x00, 0x40, 0x21, 0x5f, 0x2f, 0xc0, 0xa8, 0x01, 0x1f, 0xc9, 0x0b, 0x3b, 0xad, 0x80, 0x04, 0x13, 0x89, 0x05, 0x00, 0x08, 0xdb, 0x01, 0x00, 0x00, 0x04, 0x29, 0x01, 0x6d, 0xdc, 0x00, 0x00, 0x00, 0x00,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "SCTP",
|
||||
packet: []byte{
|
||||
0x45, 0x00, 0x00, 0x30, 0x09, 0xd9, 0x40, 0x00, 0xff, 0x84, 0x50, 0xe2, 0x0a, 0x1c, 0x06, 0x2c, 0x0a, 0x1c, 0x06, 0x2b, 0x0b, 0x80, 0x40, 0x00, 0x21, 0x44, 0x15, 0x23, 0x2b, 0xf2, 0x02, 0x4e, 0x03, 0x00, 0x00, 0x10, 0x28, 0x02, 0x43, 0x45, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
},
|
||||
},
|
||||
// TODO(maisem): add test for GRE.
|
||||
}
|
||||
var p packet.Parsed
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
p.Decode(tt.packet)
|
||||
t.Log(p.String())
|
||||
UpdateSrcAddr(&p, netip.MustParseAddr("100.64.0.1"))
|
||||
|
||||
got := binary.BigEndian.Uint16(tt.packet[10:12])
|
||||
want := fullHeaderChecksumV4(tt.packet[:20])
|
||||
if got != want {
|
||||
t.Fatalf("got %x want %x", got, want)
|
||||
}
|
||||
|
||||
UpdateDstAddr(&p, netip.MustParseAddr("100.64.0.2"))
|
||||
got = binary.BigEndian.Uint16(tt.packet[10:12])
|
||||
want = fullHeaderChecksumV4(tt.packet[:20])
|
||||
if got != want {
|
||||
t.Fatalf("got %x want %x", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNatChecksumsV6UDP(t *testing.T) {
|
||||
a1, a2 := netip.MustParseAddr("a::1"), netip.MustParseAddr("b::1")
|
||||
|
||||
// Make a fake UDP packet with 32 bytes of zeros as the datagram payload.
|
||||
b := header.IPv6(make([]byte, header.IPv6MinimumSize+header.UDPMinimumSize+32))
|
||||
b.Encode(&header.IPv6Fields{
|
||||
PayloadLength: header.UDPMinimumSize + 32,
|
||||
TransportProtocol: header.UDPProtocolNumber,
|
||||
HopLimit: 16,
|
||||
SrcAddr: tcpip.AddrFrom16Slice(a1.AsSlice()),
|
||||
DstAddr: tcpip.AddrFrom16Slice(a2.AsSlice()),
|
||||
})
|
||||
udp := header.UDP(b[header.IPv6MinimumSize:])
|
||||
udp.Encode(&header.UDPFields{
|
||||
SrcPort: 42,
|
||||
DstPort: 43,
|
||||
Length: header.UDPMinimumSize + 32,
|
||||
})
|
||||
xsum := header.PseudoHeaderChecksum(
|
||||
header.UDPProtocolNumber,
|
||||
tcpip.AddrFrom16Slice(a1.AsSlice()),
|
||||
tcpip.AddrFrom16Slice(a2.AsSlice()),
|
||||
uint16(header.UDPMinimumSize+32),
|
||||
)
|
||||
xsum = checksum.Checksum(b.Payload()[header.UDPMinimumSize:], xsum)
|
||||
udp.SetChecksum(^udp.CalculateChecksum(xsum))
|
||||
if !udp.IsChecksumValid(tcpip.AddrFrom16Slice(a1.AsSlice()), tcpip.AddrFrom16Slice(a2.AsSlice()), checksum.Checksum(b.Payload()[header.UDPMinimumSize:], 0)) {
|
||||
t.Fatal("test broken; initial packet has incorrect checksum")
|
||||
}
|
||||
|
||||
// Parse the packet.
|
||||
var p packet.Parsed
|
||||
p.Decode(b)
|
||||
t.Log(p.String())
|
||||
|
||||
// Update the source address of the packet to be the same as the dest.
|
||||
UpdateSrcAddr(&p, a2)
|
||||
if !udp.IsChecksumValid(tcpip.AddrFrom16Slice(a2.AsSlice()), tcpip.AddrFrom16Slice(a2.AsSlice()), checksum.Checksum(b.Payload()[header.UDPMinimumSize:], 0)) {
|
||||
t.Fatal("incorrect checksum after updating source address")
|
||||
}
|
||||
|
||||
// Update the dest address of the packet to be the original source address.
|
||||
UpdateDstAddr(&p, a1)
|
||||
if !udp.IsChecksumValid(tcpip.AddrFrom16Slice(a2.AsSlice()), tcpip.AddrFrom16Slice(a1.AsSlice()), checksum.Checksum(b.Payload()[header.UDPMinimumSize:], 0)) {
|
||||
t.Fatal("incorrect checksum after updating destination address")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNatChecksumsV6TCP(t *testing.T) {
|
||||
a1, a2 := netip.MustParseAddr("a::1"), netip.MustParseAddr("b::1")
|
||||
|
||||
// Make a fake TCP packet with no payload.
|
||||
b := header.IPv6(make([]byte, header.IPv6MinimumSize+header.TCPMinimumSize))
|
||||
b.Encode(&header.IPv6Fields{
|
||||
PayloadLength: header.TCPMinimumSize,
|
||||
TransportProtocol: header.TCPProtocolNumber,
|
||||
HopLimit: 16,
|
||||
SrcAddr: tcpip.AddrFrom16Slice(a1.AsSlice()),
|
||||
DstAddr: tcpip.AddrFrom16Slice(a2.AsSlice()),
|
||||
})
|
||||
tcp := header.TCP(b[header.IPv6MinimumSize:])
|
||||
tcp.Encode(&header.TCPFields{
|
||||
SrcPort: 42,
|
||||
DstPort: 43,
|
||||
SeqNum: 1,
|
||||
AckNum: 2,
|
||||
DataOffset: header.TCPMinimumSize,
|
||||
Flags: 3,
|
||||
WindowSize: 4,
|
||||
Checksum: 0,
|
||||
UrgentPointer: 5,
|
||||
})
|
||||
xsum := header.PseudoHeaderChecksum(
|
||||
header.TCPProtocolNumber,
|
||||
tcpip.AddrFrom16Slice(a1.AsSlice()),
|
||||
tcpip.AddrFrom16Slice(a2.AsSlice()),
|
||||
uint16(header.TCPMinimumSize),
|
||||
)
|
||||
tcp.SetChecksum(^tcp.CalculateChecksum(xsum))
|
||||
|
||||
if !tcp.IsChecksumValid(tcpip.AddrFrom16Slice(a1.AsSlice()), tcpip.AddrFrom16Slice(a2.AsSlice()), 0, 0) {
|
||||
t.Fatal("test broken; initial packet has incorrect checksum")
|
||||
}
|
||||
|
||||
// Parse the packet.
|
||||
var p packet.Parsed
|
||||
p.Decode(b)
|
||||
t.Log(p.String())
|
||||
|
||||
// Update the source address of the packet to be the same as the dest.
|
||||
UpdateSrcAddr(&p, a2)
|
||||
if !tcp.IsChecksumValid(tcpip.AddrFrom16Slice(a2.AsSlice()), tcpip.AddrFrom16Slice(a2.AsSlice()), 0, 0) {
|
||||
t.Fatal("incorrect checksum after updating source address")
|
||||
}
|
||||
|
||||
// Update the dest address of the packet to be the original source address.
|
||||
UpdateDstAddr(&p, a1)
|
||||
if !tcp.IsChecksumValid(tcpip.AddrFrom16Slice(a2.AsSlice()), tcpip.AddrFrom16Slice(a1.AsSlice()), 0, 0) {
|
||||
t.Fatal("incorrect checksum after updating destination address")
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,6 @@ import (
|
||||
"net/netip"
|
||||
"strings"
|
||||
|
||||
"gvisor.dev/gvisor/pkg/tcpip"
|
||||
"gvisor.dev/gvisor/pkg/tcpip/header"
|
||||
"tailscale.com/net/netaddr"
|
||||
"tailscale.com/types/ipproto"
|
||||
)
|
||||
@@ -454,62 +452,6 @@ func (q *Parsed) IsEchoResponse() bool {
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateSrcAddr updates the source address in the packet buffer (e.g. during
|
||||
// SNAT). It also updates the checksum. Currently (2023-09-22) only TCP/UDP/ICMP
|
||||
// is supported. It panics if provided with an address in a different
|
||||
// family to the parsed packet.
|
||||
func (q *Parsed) UpdateSrcAddr(src netip.Addr) {
|
||||
if src.Is6() && q.IPVersion != 6 {
|
||||
panic("UpdateSrcAddr: cannot write IPv6 address to v4 packet")
|
||||
} else if src.Is4() && q.IPVersion != 4 {
|
||||
panic("UpdateSrcAddr: cannot write IPv4 address to v6 packet")
|
||||
}
|
||||
q.CaptureMeta.DidSNAT = true
|
||||
q.CaptureMeta.OriginalSrc = q.Src
|
||||
|
||||
old := q.Src.Addr()
|
||||
q.Src = netip.AddrPortFrom(src, q.Src.Port())
|
||||
|
||||
b := q.Buffer()
|
||||
if src.Is6() {
|
||||
v6 := src.As16()
|
||||
copy(b[8:24], v6[:])
|
||||
updateV6PacketChecksums(q, old, src)
|
||||
} else {
|
||||
v4 := src.As4()
|
||||
copy(b[12:16], v4[:])
|
||||
updateV4PacketChecksums(q, old, src)
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateDstAddr updates the destination address in the packet buffer (e.g. during
|
||||
// DNAT). It also updates the checksum. Currently (2022-12-10) only TCP/UDP/ICMP
|
||||
// is supported. It panics if provided with an address in a different
|
||||
// family to the parsed packet.
|
||||
func (q *Parsed) UpdateDstAddr(dst netip.Addr) {
|
||||
if dst.Is6() && q.IPVersion != 6 {
|
||||
panic("UpdateDstAddr: cannot write IPv6 address to v4 packet")
|
||||
} else if dst.Is4() && q.IPVersion != 4 {
|
||||
panic("UpdateDstAddr: cannot write IPv4 address to v6 packet")
|
||||
}
|
||||
q.CaptureMeta.DidDNAT = true
|
||||
q.CaptureMeta.OriginalDst = q.Dst
|
||||
|
||||
old := q.Dst.Addr()
|
||||
q.Dst = netip.AddrPortFrom(dst, q.Dst.Port())
|
||||
|
||||
b := q.Buffer()
|
||||
if dst.Is6() {
|
||||
v6 := dst.As16()
|
||||
copy(b[24:36], v6[:])
|
||||
updateV6PacketChecksums(q, old, dst)
|
||||
} else {
|
||||
v4 := dst.As4()
|
||||
copy(b[16:20], v4[:])
|
||||
updateV4PacketChecksums(q, old, dst)
|
||||
}
|
||||
}
|
||||
|
||||
// EchoIDSeq extracts the identifier/sequence bytes from an ICMP Echo response,
|
||||
// and returns them as a uint32, used to lookup internally routed ICMP echo
|
||||
// responses. This function is intentionally lightweight as it is called on
|
||||
@@ -572,129 +514,3 @@ func withIP(ap netip.AddrPort, ip netip.Addr) netip.AddrPort {
|
||||
func withPort(ap netip.AddrPort, port uint16) netip.AddrPort {
|
||||
return netip.AddrPortFrom(ap.Addr(), port)
|
||||
}
|
||||
|
||||
// updateV4PacketChecksums updates the checksums in the packet buffer.
|
||||
// Currently (2023-03-01) only TCP/UDP/ICMP over IPv4 is supported.
|
||||
// p is modified in place.
|
||||
// If p.IPProto is unknown, only the IP header checksum is updated.
|
||||
func updateV4PacketChecksums(p *Parsed, old, new netip.Addr) {
|
||||
if len(p.Buffer()) < 12 {
|
||||
// Not enough space for an IPv4 header.
|
||||
return
|
||||
}
|
||||
o4, n4 := old.As4(), new.As4()
|
||||
|
||||
// First update the checksum in the IP header.
|
||||
updateV4Checksum(p.Buffer()[10:12], o4[:], n4[:])
|
||||
|
||||
// Now update the transport layer checksums, where applicable.
|
||||
tr := p.Transport()
|
||||
switch p.IPProto {
|
||||
case ipproto.UDP, ipproto.DCCP:
|
||||
if len(tr) < header.UDPMinimumSize {
|
||||
// Not enough space for a UDP header.
|
||||
return
|
||||
}
|
||||
updateV4Checksum(tr[6:8], o4[:], n4[:])
|
||||
case ipproto.TCP:
|
||||
if len(tr) < header.TCPMinimumSize {
|
||||
// Not enough space for a TCP header.
|
||||
return
|
||||
}
|
||||
updateV4Checksum(tr[16:18], o4[:], n4[:])
|
||||
case ipproto.GRE:
|
||||
if len(tr) < 6 {
|
||||
// Not enough space for a GRE header.
|
||||
return
|
||||
}
|
||||
if tr[0] == 1 { // checksum present
|
||||
updateV4Checksum(tr[4:6], o4[:], n4[:])
|
||||
}
|
||||
case ipproto.SCTP, ipproto.ICMPv4:
|
||||
// No transport layer update required.
|
||||
}
|
||||
}
|
||||
|
||||
// updateV6PacketChecksums updates the checksums in the packet buffer.
|
||||
// p is modified in place.
|
||||
// If p.IPProto is unknown, no checksums are updated.
|
||||
func updateV6PacketChecksums(p *Parsed, old, new netip.Addr) {
|
||||
if len(p.Buffer()) < 40 {
|
||||
// Not enough space for an IPv6 header.
|
||||
return
|
||||
}
|
||||
o6, n6 := tcpip.AddrFrom16Slice(old.AsSlice()), tcpip.AddrFrom16Slice(new.AsSlice())
|
||||
|
||||
// Now update the transport layer checksums, where applicable.
|
||||
tr := p.Transport()
|
||||
switch p.IPProto {
|
||||
case ipproto.ICMPv6:
|
||||
if len(tr) < header.ICMPv6MinimumSize {
|
||||
return
|
||||
}
|
||||
header.ICMPv6(tr).UpdateChecksumPseudoHeaderAddress(o6, n6)
|
||||
case ipproto.UDP, ipproto.DCCP:
|
||||
if len(tr) < header.UDPMinimumSize {
|
||||
return
|
||||
}
|
||||
header.UDP(tr).UpdateChecksumPseudoHeaderAddress(o6, n6, true)
|
||||
case ipproto.TCP:
|
||||
if len(tr) < header.TCPMinimumSize {
|
||||
return
|
||||
}
|
||||
header.TCP(tr).UpdateChecksumPseudoHeaderAddress(o6, n6, true)
|
||||
case ipproto.SCTP:
|
||||
// No transport layer update required.
|
||||
}
|
||||
}
|
||||
|
||||
// updateV4Checksum calculates and updates the checksum in the packet buffer for
|
||||
// a change between old and new. The oldSum must point to the 16-bit checksum
|
||||
// field in the packet buffer that holds the old checksum value, it will be
|
||||
// updated in place.
|
||||
//
|
||||
// The old and new must be the same length, and must be an even number of bytes.
|
||||
func updateV4Checksum(oldSum, old, new []byte) {
|
||||
if len(old) != len(new) {
|
||||
panic("old and new must be the same length")
|
||||
}
|
||||
if len(old)%2 != 0 {
|
||||
panic("old and new must be of even length")
|
||||
}
|
||||
/*
|
||||
RFC 1624
|
||||
Given the following notation:
|
||||
|
||||
HC - old checksum in header
|
||||
C - one's complement sum of old header
|
||||
HC' - new checksum in header
|
||||
C' - one's complement sum of new header
|
||||
m - old value of a 16-bit field
|
||||
m' - new value of a 16-bit field
|
||||
|
||||
HC' = ~(C + (-m) + m') -- [Eqn. 3]
|
||||
HC' = ~(~HC + ~m + m')
|
||||
|
||||
This can be simplified to:
|
||||
HC' = ~(C + ~m + m') -- [Eqn. 3]
|
||||
HC' = ~C'
|
||||
C' = C + ~m + m'
|
||||
*/
|
||||
|
||||
c := uint32(^binary.BigEndian.Uint16(oldSum))
|
||||
|
||||
cPrime := c
|
||||
for len(new) > 0 {
|
||||
mNot := uint32(^binary.BigEndian.Uint16(old[:2]))
|
||||
mPrime := uint32(binary.BigEndian.Uint16(new[:2]))
|
||||
cPrime += mPrime + mNot
|
||||
new, old = new[2:], old[2:]
|
||||
}
|
||||
|
||||
// Account for overflows by adding the carry bits back into the sum.
|
||||
for (cPrime >> 16) > 0 {
|
||||
cPrime = cPrime&0xFFFF + cPrime>>16
|
||||
}
|
||||
hcPrime := ^uint16(cPrime)
|
||||
binary.BigEndian.PutUint16(oldSum, hcPrime)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ package packet
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"net/netip"
|
||||
"reflect"
|
||||
@@ -13,9 +12,6 @@ import (
|
||||
"testing"
|
||||
"unicode"
|
||||
|
||||
"gvisor.dev/gvisor/pkg/tcpip"
|
||||
"gvisor.dev/gvisor/pkg/tcpip/checksum"
|
||||
"gvisor.dev/gvisor/pkg/tcpip/header"
|
||||
"tailscale.com/tstest"
|
||||
"tailscale.com/types/ipproto"
|
||||
"tailscale.com/util/must"
|
||||
@@ -33,187 +29,6 @@ const (
|
||||
Fragment = ipproto.Fragment
|
||||
)
|
||||
|
||||
func fullHeaderChecksumV4(b []byte) uint16 {
|
||||
s := uint32(0)
|
||||
for i := 0; i < len(b); i += 2 {
|
||||
if i == 10 {
|
||||
// Skip checksum field.
|
||||
continue
|
||||
}
|
||||
s += uint32(binary.BigEndian.Uint16(b[i : i+2]))
|
||||
}
|
||||
for s>>16 > 0 {
|
||||
s = s&0xFFFF + s>>16
|
||||
}
|
||||
return ^uint16(s)
|
||||
}
|
||||
|
||||
func TestHeaderChecksumsV4(t *testing.T) {
|
||||
// This is not a good enough test, because it doesn't
|
||||
// check the various packet types or the many edge cases
|
||||
// of the checksum algorithm. But it's a start.
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
packet []byte
|
||||
}{
|
||||
{
|
||||
name: "ICMPv4",
|
||||
packet: []byte{
|
||||
0x45, 0x00, 0x00, 0x54, 0xb7, 0x96, 0x40, 0x00, 0x40, 0x01, 0x7a, 0x06, 0x64, 0x7f, 0x3f, 0x4c, 0x64, 0x40, 0x01, 0x01, 0x08, 0x00, 0x47, 0x1a, 0x00, 0x11, 0x01, 0xac, 0xcc, 0xf5, 0x95, 0x63, 0x00, 0x00, 0x00, 0x00, 0x8d, 0xfc, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "TLS",
|
||||
packet: []byte{
|
||||
0x45, 0x00, 0x00, 0x3c, 0x54, 0x29, 0x40, 0x00, 0x40, 0x06, 0xb1, 0xac, 0x64, 0x42, 0xd4, 0x33, 0x64, 0x61, 0x98, 0x0f, 0xb1, 0x94, 0x01, 0xbb, 0x0a, 0x51, 0xce, 0x7c, 0x00, 0x00, 0x00, 0x00, 0xa0, 0x02, 0xfb, 0xe0, 0x38, 0xf6, 0x00, 0x00, 0x02, 0x04, 0x04, 0xd8, 0x04, 0x02, 0x08, 0x0a, 0x86, 0x2b, 0xcc, 0xd5, 0x00, 0x00, 0x00, 0x00, 0x01, 0x03, 0x03, 0x07,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "DNS",
|
||||
packet: []byte{
|
||||
0x45, 0x00, 0x00, 0x74, 0xe2, 0x85, 0x00, 0x00, 0x40, 0x11, 0x96, 0xb5, 0x64, 0x64, 0x64, 0x64, 0x64, 0x42, 0xd4, 0x33, 0x00, 0x35, 0xec, 0x55, 0x00, 0x60, 0xd9, 0x19, 0xed, 0xfd, 0x81, 0x80, 0x00, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0x01, 0x08, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x73, 0x34, 0x06, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x03, 0x63, 0x6f, 0x6d, 0x00, 0x00, 0x01, 0x00, 0x01, 0xc0, 0x0c, 0x00, 0x05, 0x00, 0x01, 0x00, 0x00, 0x01, 0x1e, 0x00, 0x0c, 0x07, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x73, 0x01, 0x6c, 0xc0, 0x15, 0xc0, 0x31, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x01, 0x1e, 0x00, 0x04, 0x8e, 0xfa, 0xbd, 0xce, 0x00, 0x00, 0x29, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "DCCP",
|
||||
packet: []byte{
|
||||
0x45, 0x00, 0x00, 0x28, 0x15, 0x06, 0x40, 0x00, 0x40, 0x21, 0x5f, 0x2f, 0xc0, 0xa8, 0x01, 0x1f, 0xc9, 0x0b, 0x3b, 0xad, 0x80, 0x04, 0x13, 0x89, 0x05, 0x00, 0x08, 0xdb, 0x01, 0x00, 0x00, 0x04, 0x29, 0x01, 0x6d, 0xdc, 0x00, 0x00, 0x00, 0x00,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "SCTP",
|
||||
packet: []byte{
|
||||
0x45, 0x00, 0x00, 0x30, 0x09, 0xd9, 0x40, 0x00, 0xff, 0x84, 0x50, 0xe2, 0x0a, 0x1c, 0x06, 0x2c, 0x0a, 0x1c, 0x06, 0x2b, 0x0b, 0x80, 0x40, 0x00, 0x21, 0x44, 0x15, 0x23, 0x2b, 0xf2, 0x02, 0x4e, 0x03, 0x00, 0x00, 0x10, 0x28, 0x02, 0x43, 0x45, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
},
|
||||
},
|
||||
// TODO(maisem): add test for GRE.
|
||||
}
|
||||
var p Parsed
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
p.Decode(tt.packet)
|
||||
t.Log(p.String())
|
||||
p.UpdateSrcAddr(netip.MustParseAddr("100.64.0.1"))
|
||||
|
||||
got := binary.BigEndian.Uint16(tt.packet[10:12])
|
||||
want := fullHeaderChecksumV4(tt.packet[:20])
|
||||
if got != want {
|
||||
t.Fatalf("got %x want %x", got, want)
|
||||
}
|
||||
|
||||
p.UpdateDstAddr(netip.MustParseAddr("100.64.0.2"))
|
||||
got = binary.BigEndian.Uint16(tt.packet[10:12])
|
||||
want = fullHeaderChecksumV4(tt.packet[:20])
|
||||
if got != want {
|
||||
t.Fatalf("got %x want %x", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNatChecksumsV6UDP(t *testing.T) {
|
||||
a1, a2 := netip.MustParseAddr("a::1"), netip.MustParseAddr("b::1")
|
||||
|
||||
// Make a fake UDP packet with 32 bytes of zeros as the datagram payload.
|
||||
b := header.IPv6(make([]byte, header.IPv6MinimumSize+header.UDPMinimumSize+32))
|
||||
b.Encode(&header.IPv6Fields{
|
||||
PayloadLength: header.UDPMinimumSize + 32,
|
||||
TransportProtocol: header.UDPProtocolNumber,
|
||||
HopLimit: 16,
|
||||
SrcAddr: tcpip.AddrFrom16Slice(a1.AsSlice()),
|
||||
DstAddr: tcpip.AddrFrom16Slice(a2.AsSlice()),
|
||||
})
|
||||
udp := header.UDP(b[header.IPv6MinimumSize:])
|
||||
udp.Encode(&header.UDPFields{
|
||||
SrcPort: 42,
|
||||
DstPort: 43,
|
||||
Length: header.UDPMinimumSize + 32,
|
||||
})
|
||||
xsum := header.PseudoHeaderChecksum(
|
||||
header.UDPProtocolNumber,
|
||||
tcpip.AddrFrom16Slice(a1.AsSlice()),
|
||||
tcpip.AddrFrom16Slice(a2.AsSlice()),
|
||||
uint16(header.UDPMinimumSize+32),
|
||||
)
|
||||
xsum = checksum.Checksum(b.Payload()[header.UDPMinimumSize:], xsum)
|
||||
udp.SetChecksum(^udp.CalculateChecksum(xsum))
|
||||
if !udp.IsChecksumValid(tcpip.AddrFrom16Slice(a1.AsSlice()), tcpip.AddrFrom16Slice(a2.AsSlice()), checksum.Checksum(b.Payload()[header.UDPMinimumSize:], 0)) {
|
||||
t.Fatal("test broken; initial packet has incorrect checksum")
|
||||
}
|
||||
|
||||
// Parse the packet.
|
||||
var p Parsed
|
||||
p.Decode(b)
|
||||
t.Log(p.String())
|
||||
|
||||
// Update the source address of the packet to be the same as the dest.
|
||||
p.UpdateSrcAddr(a2)
|
||||
if !udp.IsChecksumValid(tcpip.AddrFrom16Slice(a2.AsSlice()), tcpip.AddrFrom16Slice(a2.AsSlice()), checksum.Checksum(b.Payload()[header.UDPMinimumSize:], 0)) {
|
||||
t.Fatal("incorrect checksum after updating source address")
|
||||
}
|
||||
|
||||
// Update the dest address of the packet to be the original source address.
|
||||
p.UpdateDstAddr(a1)
|
||||
if !udp.IsChecksumValid(tcpip.AddrFrom16Slice(a2.AsSlice()), tcpip.AddrFrom16Slice(a1.AsSlice()), checksum.Checksum(b.Payload()[header.UDPMinimumSize:], 0)) {
|
||||
t.Fatal("incorrect checksum after updating destination address")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNatChecksumsV6TCP(t *testing.T) {
|
||||
a1, a2 := netip.MustParseAddr("a::1"), netip.MustParseAddr("b::1")
|
||||
|
||||
// Make a fake TCP packet with no payload.
|
||||
b := header.IPv6(make([]byte, header.IPv6MinimumSize+header.TCPMinimumSize))
|
||||
b.Encode(&header.IPv6Fields{
|
||||
PayloadLength: header.TCPMinimumSize,
|
||||
TransportProtocol: header.TCPProtocolNumber,
|
||||
HopLimit: 16,
|
||||
SrcAddr: tcpip.AddrFrom16Slice(a1.AsSlice()),
|
||||
DstAddr: tcpip.AddrFrom16Slice(a2.AsSlice()),
|
||||
})
|
||||
tcp := header.TCP(b[header.IPv6MinimumSize:])
|
||||
tcp.Encode(&header.TCPFields{
|
||||
SrcPort: 42,
|
||||
DstPort: 43,
|
||||
SeqNum: 1,
|
||||
AckNum: 2,
|
||||
DataOffset: header.TCPMinimumSize,
|
||||
Flags: 3,
|
||||
WindowSize: 4,
|
||||
Checksum: 0,
|
||||
UrgentPointer: 5,
|
||||
})
|
||||
xsum := header.PseudoHeaderChecksum(
|
||||
header.TCPProtocolNumber,
|
||||
tcpip.AddrFrom16Slice(a1.AsSlice()),
|
||||
tcpip.AddrFrom16Slice(a2.AsSlice()),
|
||||
uint16(header.TCPMinimumSize),
|
||||
)
|
||||
tcp.SetChecksum(^tcp.CalculateChecksum(xsum))
|
||||
|
||||
if !tcp.IsChecksumValid(tcpip.AddrFrom16Slice(a1.AsSlice()), tcpip.AddrFrom16Slice(a2.AsSlice()), 0, 0) {
|
||||
t.Fatal("test broken; initial packet has incorrect checksum")
|
||||
}
|
||||
|
||||
// Parse the packet.
|
||||
var p Parsed
|
||||
p.Decode(b)
|
||||
t.Log(p.String())
|
||||
|
||||
// Update the source address of the packet to be the same as the dest.
|
||||
p.UpdateSrcAddr(a2)
|
||||
if !tcp.IsChecksumValid(tcpip.AddrFrom16Slice(a2.AsSlice()), tcpip.AddrFrom16Slice(a2.AsSlice()), 0, 0) {
|
||||
t.Fatal("incorrect checksum after updating source address")
|
||||
}
|
||||
|
||||
// Update the dest address of the packet to be the original source address.
|
||||
p.UpdateDstAddr(a1)
|
||||
if !tcp.IsChecksumValid(tcpip.AddrFrom16Slice(a2.AsSlice()), tcpip.AddrFrom16Slice(a1.AsSlice()), 0, 0) {
|
||||
t.Fatal("incorrect checksum after updating destination address")
|
||||
}
|
||||
}
|
||||
|
||||
func mustIPPort(s string) netip.AddrPort {
|
||||
ipp, err := netip.ParseAddrPort(s)
|
||||
if err != nil {
|
||||
|
||||
@@ -55,3 +55,4 @@ func (t *fakeTUN) MTU() (int, error) { return 1500, nil }
|
||||
func (t *fakeTUN) Name() (string, error) { return FakeTUNName, nil }
|
||||
func (t *fakeTUN) Events() <-chan tun.Event { return t.evchan }
|
||||
func (t *fakeTUN) BatchSize() int { return 1 }
|
||||
func (t *fakeTUN) IsFakeTun() bool { return true }
|
||||
|
||||
@@ -79,14 +79,16 @@ const (
|
||||
safeTUNMTU TUNMTU = 1280
|
||||
)
|
||||
|
||||
// MaxProbedWireMTU is the largest MTU we will test for path MTU
|
||||
// discovery.
|
||||
var MaxProbedWireMTU WireMTU = 9000
|
||||
|
||||
func init() {
|
||||
if MaxProbedWireMTU > WireMTU(maxTUNMTU) {
|
||||
MaxProbedWireMTU = WireMTU(maxTUNMTU)
|
||||
}
|
||||
// WireMTUsToProbe is a list of the on-the-wire MTUs we want to probe. Each time
|
||||
// magicsock discovery begins, it will send a set of pings, one of each size
|
||||
// listed below.
|
||||
var WireMTUsToProbe = []WireMTU{
|
||||
WireMTU(safeTUNMTU), // Tailscale over Tailscale :)
|
||||
TUNToWireMTU(safeTUNMTU), // Smallest MTU allowed for IPv6, current default
|
||||
1400, // Most common MTU minus a few bytes for tunnels
|
||||
1500, // Most common MTU
|
||||
8000, // Should fit inside all jumbo frame sizes
|
||||
9000, // Most jumbo frames are this size or larger
|
||||
}
|
||||
|
||||
// wgHeaderLen is the length of all the headers Wireguard adds to a packet
|
||||
@@ -125,7 +127,7 @@ func WireToTUNMTU(w WireMTU) TUNMTU {
|
||||
// MTU. It is also the path MTU that we default to if we have no
|
||||
// information about the path to a peer.
|
||||
//
|
||||
// 1. If set, the value of TS_DEBUG_MTU clamped to a maximum of MaxTunMTU
|
||||
// 1. If set, the value of TS_DEBUG_MTU clamped to a maximum of MaxTUNMTU
|
||||
// 2. If TS_DEBUG_ENABLE_PMTUD is set, the maximum size MTU we probe, minus wg overhead
|
||||
// 3. If TS_DEBUG_ENABLE_PMTUD is not set, the Safe MTU
|
||||
func DefaultTUNMTU() TUNMTU {
|
||||
@@ -135,12 +137,23 @@ func DefaultTUNMTU() TUNMTU {
|
||||
|
||||
debugPMTUD, _ := envknob.LookupBool("TS_DEBUG_ENABLE_PMTUD")
|
||||
if debugPMTUD {
|
||||
return WireToTUNMTU(MaxProbedWireMTU)
|
||||
// TODO: While we are just probing MTU but not generating PTB,
|
||||
// this has to continue to return the safe MTU. When we add the
|
||||
// code to generate PTB, this will be:
|
||||
//
|
||||
// return WireToTUNMTU(maxProbedWireMTU)
|
||||
return safeTUNMTU
|
||||
}
|
||||
|
||||
return safeTUNMTU
|
||||
}
|
||||
|
||||
// SafeWireMTU returns the wire MTU that is safe to use if we have no
|
||||
// information about the path MTU to this peer.
|
||||
func SafeWireMTU() WireMTU {
|
||||
return TUNToWireMTU(safeTUNMTU)
|
||||
}
|
||||
|
||||
// DefaultWireMTU returns the default TUN MTU, adjusted for wireguard
|
||||
// overhead.
|
||||
func DefaultWireMTU() WireMTU {
|
||||
|
||||
@@ -39,15 +39,18 @@ func TestDefaultTunMTU(t *testing.T) {
|
||||
t.Errorf("default TUN MTU = %d, want %d, clamping failed", DefaultTUNMTU(), maxTUNMTU)
|
||||
}
|
||||
|
||||
// If PMTUD is enabled, the MTU should default to the largest probed
|
||||
// MTU, but only if the user hasn't requested a specific MTU.
|
||||
// If PMTUD is enabled, the MTU should default to the safe MTU, but only
|
||||
// if the user hasn't requested a specific MTU.
|
||||
//
|
||||
// TODO: When PMTUD is generating PTB responses, this will become the
|
||||
// largest MTU we probe.
|
||||
os.Setenv("TS_DEBUG_MTU", "")
|
||||
os.Setenv("TS_DEBUG_ENABLE_PMTUD", "true")
|
||||
if DefaultTUNMTU() != WireToTUNMTU(MaxProbedWireMTU) {
|
||||
t.Errorf("default TUN MTU = %d, want %d", DefaultTUNMTU(), WireToTUNMTU(MaxProbedWireMTU))
|
||||
if DefaultTUNMTU() != safeTUNMTU {
|
||||
t.Errorf("default TUN MTU = %d, want %d", DefaultTUNMTU(), safeTUNMTU)
|
||||
}
|
||||
// TS_DEBUG_MTU should take precedence over TS_DEBUG_ENABLE_PMTUD.
|
||||
mtu = WireToTUNMTU(MaxProbedWireMTU - 1)
|
||||
mtu = WireToTUNMTU(MaxPacketSize - 1)
|
||||
os.Setenv("TS_DEBUG_MTU", strconv.Itoa(int(mtu)))
|
||||
if DefaultTUNMTU() != mtu {
|
||||
t.Errorf("default TUN MTU = %d, want %d", DefaultTUNMTU(), mtu)
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"tailscale.com/disco"
|
||||
"tailscale.com/net/connstats"
|
||||
"tailscale.com/net/packet"
|
||||
"tailscale.com/net/packet/checksum"
|
||||
"tailscale.com/net/tsaddr"
|
||||
"tailscale.com/net/tstun/table"
|
||||
"tailscale.com/syncs"
|
||||
@@ -77,6 +78,9 @@ var parsedPacketPool = sync.Pool{New: func() any { return new(packet.Parsed) }}
|
||||
type FilterFunc func(*packet.Parsed, *Wrapper) filter.Response
|
||||
|
||||
// Wrapper augments a tun.Device with packet filtering and injection.
|
||||
//
|
||||
// A Wrapper starts in a "corked" mode where Read calls are blocked
|
||||
// until the Wrapper's Start method is called.
|
||||
type Wrapper struct {
|
||||
logf logger.Logf
|
||||
limitedLogf logger.Logf // aggressively rate-limited logf used for potentially high volume errors
|
||||
@@ -84,6 +88,9 @@ type Wrapper struct {
|
||||
tdev tun.Device
|
||||
isTAP bool // whether tdev is a TAP device
|
||||
|
||||
started atomic.Bool // whether Start has been called
|
||||
startCh chan struct{} // closed in Start
|
||||
|
||||
closeOnce sync.Once
|
||||
|
||||
// lastActivityAtomic is read/written atomically.
|
||||
@@ -218,6 +225,16 @@ type setWrapperer interface {
|
||||
setWrapper(*Wrapper)
|
||||
}
|
||||
|
||||
// Start unblocks any Wrapper.Read calls that have already started
|
||||
// and makes the Wrapper functional.
|
||||
//
|
||||
// Start must be called exactly once after the various Tailscale
|
||||
// subsystems have been wired up to each other.
|
||||
func (w *Wrapper) Start() {
|
||||
w.started.Store(true)
|
||||
close(w.startCh)
|
||||
}
|
||||
|
||||
func WrapTAP(logf logger.Logf, tdev tun.Device) *Wrapper {
|
||||
return wrap(logf, tdev, true)
|
||||
}
|
||||
@@ -243,6 +260,7 @@ func wrap(logf logger.Logf, tdev tun.Device, isTAP bool) *Wrapper {
|
||||
eventsOther: make(chan tun.Event),
|
||||
// TODO(dmytro): (highly rate-limited) hexdumps should happen on unknown packets.
|
||||
filterFlags: filter.LogAccepts | filter.LogDrops,
|
||||
startCh: make(chan struct{}),
|
||||
}
|
||||
|
||||
w.vectorBuffer = make([][]byte, tdev.BatchSize())
|
||||
@@ -308,6 +326,9 @@ func (t *Wrapper) isSelfDisco(p *packet.Parsed) bool {
|
||||
func (t *Wrapper) Close() error {
|
||||
var err error
|
||||
t.closeOnce.Do(func() {
|
||||
if t.started.CompareAndSwap(false, true) {
|
||||
close(t.startCh)
|
||||
}
|
||||
close(t.closed)
|
||||
t.bufferConsumedMu.Lock()
|
||||
t.bufferConsumedClosed = true
|
||||
@@ -487,7 +508,7 @@ func (t *Wrapper) snat(p *packet.Parsed) {
|
||||
oldSrc := p.Src.Addr()
|
||||
newSrc := nc.selectSrcIP(oldSrc, p.Dst.Addr())
|
||||
if oldSrc != newSrc {
|
||||
p.UpdateSrcAddr(newSrc)
|
||||
checksum.UpdateSrcAddr(p, newSrc)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -497,7 +518,7 @@ func (t *Wrapper) dnat(p *packet.Parsed) {
|
||||
oldDst := p.Dst.Addr()
|
||||
newDst := nc.mapDstIP(oldDst)
|
||||
if newDst != oldDst {
|
||||
p.UpdateDstAddr(newDst)
|
||||
checksum.UpdateDstAddr(p, newDst)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -673,16 +694,16 @@ func (c *natFamilyConfig) selectSrcIP(oldSrc, dst netip.Addr) netip.Addr {
|
||||
// natConfigFromWGConfig generates a natFamilyConfig from nm,
|
||||
// for the indicated address family.
|
||||
// If NAT is not required for that address family, it returns nil.
|
||||
func natConfigFromWGConfig(wcfg *wgcfg.Config, addrFam ipproto.IPProtoVersion) *natFamilyConfig {
|
||||
func natConfigFromWGConfig(wcfg *wgcfg.Config, addrFam ipproto.Version) *natFamilyConfig {
|
||||
if wcfg == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var nativeAddr netip.Addr
|
||||
switch addrFam {
|
||||
case ipproto.IPProtoVersion4:
|
||||
case ipproto.Version4:
|
||||
nativeAddr = findV4(wcfg.Addresses)
|
||||
case ipproto.IPProtoVersion6:
|
||||
case ipproto.Version6:
|
||||
nativeAddr = findV6(wcfg.Addresses)
|
||||
}
|
||||
if !nativeAddr.IsValid() {
|
||||
@@ -703,8 +724,8 @@ func natConfigFromWGConfig(wcfg *wgcfg.Config, addrFam ipproto.IPProtoVersion) *
|
||||
isExitNode := slices.Contains(p.AllowedIPs, tsaddr.AllIPv4()) || slices.Contains(p.AllowedIPs, tsaddr.AllIPv6())
|
||||
if isExitNode {
|
||||
hasMasqAddrsForFamily := false ||
|
||||
(addrFam == ipproto.IPProtoVersion4 && p.V4MasqAddr != nil && p.V4MasqAddr.IsValid()) ||
|
||||
(addrFam == ipproto.IPProtoVersion6 && p.V6MasqAddr != nil && p.V6MasqAddr.IsValid())
|
||||
(addrFam == ipproto.Version4 && p.V4MasqAddr != nil && p.V4MasqAddr.IsValid()) ||
|
||||
(addrFam == ipproto.Version6 && p.V6MasqAddr != nil && p.V6MasqAddr.IsValid())
|
||||
if hasMasqAddrsForFamily {
|
||||
exitNodeRequiresMasq = true
|
||||
}
|
||||
@@ -714,10 +735,10 @@ func natConfigFromWGConfig(wcfg *wgcfg.Config, addrFam ipproto.IPProtoVersion) *
|
||||
for i := range wcfg.Peers {
|
||||
p := &wcfg.Peers[i]
|
||||
var addrToUse netip.Addr
|
||||
if addrFam == ipproto.IPProtoVersion4 && p.V4MasqAddr != nil && p.V4MasqAddr.IsValid() {
|
||||
if addrFam == ipproto.Version4 && p.V4MasqAddr != nil && p.V4MasqAddr.IsValid() {
|
||||
addrToUse = *p.V4MasqAddr
|
||||
mak.Set(&listenAddrs, addrToUse, struct{}{})
|
||||
} else if addrFam == ipproto.IPProtoVersion6 && p.V6MasqAddr != nil && p.V6MasqAddr.IsValid() {
|
||||
} else if addrFam == ipproto.Version6 && p.V6MasqAddr != nil && p.V6MasqAddr.IsValid() {
|
||||
addrToUse = *p.V6MasqAddr
|
||||
mak.Set(&listenAddrs, addrToUse, struct{}{})
|
||||
} else if exitNodeRequiresMasq {
|
||||
@@ -741,7 +762,7 @@ func natConfigFromWGConfig(wcfg *wgcfg.Config, addrFam ipproto.IPProtoVersion) *
|
||||
|
||||
// SetNetMap is called when a new NetworkMap is received.
|
||||
func (t *Wrapper) SetWGConfig(wcfg *wgcfg.Config) {
|
||||
v4, v6 := natConfigFromWGConfig(wcfg, ipproto.IPProtoVersion4), natConfigFromWGConfig(wcfg, ipproto.IPProtoVersion6)
|
||||
v4, v6 := natConfigFromWGConfig(wcfg, ipproto.Version4), natConfigFromWGConfig(wcfg, ipproto.Version6)
|
||||
var cfg *natConfig
|
||||
if v4 != nil || v6 != nil {
|
||||
cfg = &natConfig{v4: v4, v6: v6}
|
||||
@@ -835,6 +856,9 @@ func (t *Wrapper) IdleDuration() time.Duration {
|
||||
}
|
||||
|
||||
func (t *Wrapper) Read(buffs [][]byte, sizes []int, offset int) (int, error) {
|
||||
if !t.started.Load() {
|
||||
<-t.startCh
|
||||
}
|
||||
// packet from OS read and sent to WG
|
||||
res, ok := <-t.vectorOutbound
|
||||
if !ok {
|
||||
|
||||
@@ -178,6 +178,7 @@ func newChannelTUN(logf logger.Logf, secure bool) (*tuntest.ChannelTUN, *Wrapper
|
||||
} else {
|
||||
tun.disableFilter = true
|
||||
}
|
||||
tun.Start()
|
||||
return chtun, tun
|
||||
}
|
||||
|
||||
@@ -617,7 +618,7 @@ func TestNATCfg(t *testing.T) {
|
||||
p.AllowedIPs = append(p.AllowedIPs, otherAllowedIPs...)
|
||||
return p
|
||||
}
|
||||
test := func(addrFam ipproto.IPProtoVersion) {
|
||||
test := func(addrFam ipproto.Version) {
|
||||
var (
|
||||
noIP netip.Addr
|
||||
|
||||
@@ -635,7 +636,7 @@ func TestNATCfg(t *testing.T) {
|
||||
exitRoute = netip.MustParsePrefix("0.0.0.0/0")
|
||||
publicIP = netip.MustParseAddr("8.8.8.8")
|
||||
)
|
||||
if addrFam == ipproto.IPProtoVersion6 {
|
||||
if addrFam == ipproto.Version6 {
|
||||
selfNativeIP = netip.MustParseAddr("fd7a:115c:a1e0::a")
|
||||
selfEIP1 = netip.MustParseAddr("fd7a:115c:a1e0::1a")
|
||||
selfEIP2 = netip.MustParseAddr("fd7a:115c:a1e0::1b")
|
||||
@@ -817,8 +818,8 @@ func TestNATCfg(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
test(ipproto.IPProtoVersion4)
|
||||
test(ipproto.IPProtoVersion6)
|
||||
test(ipproto.Version4)
|
||||
test(ipproto.Version6)
|
||||
}
|
||||
|
||||
// TestCaptureHook verifies that the Wrapper.captureHook callback is called
|
||||
|
||||
74
posture/serialnumber_macos.go
Normal file
74
posture/serialnumber_macos.go
Normal file
@@ -0,0 +1,74 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build cgo && darwin && !ios
|
||||
|
||||
package posture
|
||||
|
||||
// #cgo LDFLAGS: -framework CoreFoundation -framework IOKit
|
||||
// #include <CoreFoundation/CoreFoundation.h>
|
||||
// #include <IOKit/IOKitLib.h>
|
||||
//
|
||||
// #if __MAC_OS_X_VERSION_MIN_REQUIRED < 120000
|
||||
// #define kIOMainPortDefault kIOMasterPortDefault
|
||||
// #endif
|
||||
//
|
||||
// const char *
|
||||
// getSerialNumber()
|
||||
// {
|
||||
// CFMutableDictionaryRef matching = IOServiceMatching("IOPlatformExpertDevice");
|
||||
// if (!matching) {
|
||||
// return "err: failed to create dictionary to match IOServices";
|
||||
// }
|
||||
//
|
||||
// io_service_t service = IOServiceGetMatchingService(kIOMainPortDefault, matching);
|
||||
// if (!service) {
|
||||
// return "err: failed to look up registered IOService objects that match a matching dictionary";
|
||||
// }
|
||||
//
|
||||
// CFStringRef serialNumberRef = IORegistryEntryCreateCFProperty(
|
||||
// service,
|
||||
// CFSTR("IOPlatformSerialNumber"),
|
||||
// kCFAllocatorDefault,
|
||||
// 0
|
||||
// );
|
||||
// if (!serialNumberRef) {
|
||||
// return "err: failed to look up serial number in IORegistry";
|
||||
// }
|
||||
//
|
||||
// CFIndex length = CFStringGetLength(serialNumberRef);
|
||||
// CFIndex max_size = CFStringGetMaximumSizeForEncoding(length, kCFStringEncodingUTF8) + 1;
|
||||
// char *serialNumberBuf = (char *)malloc(max_size);
|
||||
//
|
||||
// bool result = CFStringGetCString(serialNumberRef, serialNumberBuf, max_size, kCFStringEncodingUTF8);
|
||||
//
|
||||
// CFRelease(serialNumberRef);
|
||||
// IOObjectRelease(service);
|
||||
//
|
||||
// if (!result) {
|
||||
// free(serialNumberBuf);
|
||||
//
|
||||
// return "err: failed to convert serial number reference to string";
|
||||
// }
|
||||
//
|
||||
// return serialNumberBuf;
|
||||
// }
|
||||
import "C"
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"tailscale.com/types/logger"
|
||||
)
|
||||
|
||||
// GetSerialNumber returns the platform serial sumber as reported by IOKit.
|
||||
func GetSerialNumbers(_ logger.Logf) ([]string, error) {
|
||||
csn := C.getSerialNumber()
|
||||
serialNumber := C.GoString(csn)
|
||||
|
||||
if err, ok := strings.CutPrefix(serialNumber, "err: "); ok {
|
||||
return nil, fmt.Errorf("failed to get serial number from IOKit: %s", err)
|
||||
}
|
||||
|
||||
return []string{serialNumber}, nil
|
||||
}
|
||||
37
posture/serialnumber_macos_test.go
Normal file
37
posture/serialnumber_macos_test.go
Normal file
@@ -0,0 +1,37 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build cgo && darwin && !ios
|
||||
|
||||
package posture
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/util/cibuild"
|
||||
)
|
||||
|
||||
func TestGetSerialNumberMac(t *testing.T) {
|
||||
// Do not run this test on CI, it can only be ran on macOS
|
||||
// and we currenty only use Linux runners.
|
||||
if cibuild.On() {
|
||||
t.Skip()
|
||||
}
|
||||
|
||||
sns, err := GetSerialNumbers(logger.Discard)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get serial number: %s", err)
|
||||
}
|
||||
|
||||
if len(sns) != 1 {
|
||||
t.Errorf("expected list of one serial number, got %v", sns)
|
||||
}
|
||||
|
||||
if len(sns[0]) <= 0 {
|
||||
t.Errorf("expected a serial number with more than zero characters, got %s", sns[0])
|
||||
}
|
||||
|
||||
fmt.Printf("serials: %v\n", sns)
|
||||
}
|
||||
143
posture/serialnumber_notmacos.go
Normal file
143
posture/serialnumber_notmacos.go
Normal file
@@ -0,0 +1,143 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Build on Windows, Linux and *BSD
|
||||
|
||||
//go:build windows || (linux && !android) || freebsd || openbsd || dragonfly || netbsd
|
||||
|
||||
package posture
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/digitalocean/go-smbios/smbios"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/util/multierr"
|
||||
)
|
||||
|
||||
// getByteFromSmbiosStructure retrieves a 8-bit unsigned integer at the given specOffset.
|
||||
func getByteFromSmbiosStructure(s *smbios.Structure, specOffset int) uint8 {
|
||||
// the `Formatted` byte slice is missing the first 4 bytes of the structure that are stripped out as header info.
|
||||
// so we need to subtract 4 from the offset mentioned in the SMBIOS documentation to get the right value.
|
||||
index := specOffset - 4
|
||||
if index >= len(s.Formatted) || index < 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
return s.Formatted[index]
|
||||
}
|
||||
|
||||
// getStringFromSmbiosStructure retrieves a string at the given specOffset.
|
||||
// Returns an empty string if no string was present.
|
||||
func getStringFromSmbiosStructure(s *smbios.Structure, specOffset int) (string, error) {
|
||||
index := getByteFromSmbiosStructure(s, specOffset)
|
||||
|
||||
if index == 0 || int(index) > len(s.Strings) {
|
||||
return "", errors.New("specified offset does not exist in smbios structure")
|
||||
}
|
||||
|
||||
str := s.Strings[index-1]
|
||||
trimmed := strings.TrimSpace(str)
|
||||
|
||||
return trimmed, nil
|
||||
}
|
||||
|
||||
// Product Table (Type 1) structure
|
||||
// https://web.archive.org/web/20220126173219/https://www.dmtf.org/sites/default/files/standards/documents/DSP0134_3.1.1.pdf
|
||||
// Page 34 and onwards.
|
||||
const (
|
||||
// Serial is present at the same offset in all IDs
|
||||
serialNumberOffset = 0x07
|
||||
|
||||
productID = 1
|
||||
baseboardID = 2
|
||||
chassisID = 3
|
||||
)
|
||||
|
||||
var (
|
||||
idToTableName = map[int]string{
|
||||
1: "product",
|
||||
2: "baseboard",
|
||||
3: "chassis",
|
||||
}
|
||||
validTables []string
|
||||
numOfTables int
|
||||
)
|
||||
|
||||
func init() {
|
||||
for _, table := range idToTableName {
|
||||
validTables = append(validTables, table)
|
||||
}
|
||||
numOfTables = len(validTables)
|
||||
|
||||
}
|
||||
|
||||
// serialFromSmbiosStructure extracts a serial number from a product,
|
||||
// baseboard or chassis SMBIOS table.
|
||||
func serialFromSmbiosStructure(s *smbios.Structure) (string, error) {
|
||||
id := s.Header.Type
|
||||
if (id != productID) && (id != baseboardID) && (id != chassisID) {
|
||||
return "", fmt.Errorf(
|
||||
"cannot get serial table type %d, supported tables are %v",
|
||||
id,
|
||||
validTables,
|
||||
)
|
||||
}
|
||||
|
||||
serial, err := getStringFromSmbiosStructure(s, serialNumberOffset)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf(
|
||||
"failed to get serial from %s table: %w",
|
||||
idToTableName[int(s.Header.Type)],
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
return serial, nil
|
||||
}
|
||||
|
||||
func GetSerialNumbers(logf logger.Logf) ([]string, error) {
|
||||
// Find SMBIOS data in operating system-specific location.
|
||||
rc, _, err := smbios.Stream()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open dmi/smbios stream: %w", err)
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
// Decode SMBIOS structures from the stream.
|
||||
d := smbios.NewDecoder(rc)
|
||||
ss, err := d.Decode()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode dmi/smbios structures: %w", err)
|
||||
}
|
||||
|
||||
serials := make([]string, 0, numOfTables)
|
||||
errs := make([]error, 0, numOfTables)
|
||||
|
||||
for _, s := range ss {
|
||||
switch s.Header.Type {
|
||||
case productID, baseboardID, chassisID:
|
||||
serial, err := serialFromSmbiosStructure(s)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
continue
|
||||
}
|
||||
|
||||
serials = append(serials, serial)
|
||||
}
|
||||
}
|
||||
|
||||
err = multierr.New(errs...)
|
||||
|
||||
// if there were no serial numbers, check if any errors were
|
||||
// returned and combine them.
|
||||
if len(serials) == 0 && err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logf("got serial numbers %v (errors: %s)", serials, err)
|
||||
|
||||
return serials, nil
|
||||
}
|
||||
38
posture/serialnumber_notmacos_test.go
Normal file
38
posture/serialnumber_notmacos_test.go
Normal file
@@ -0,0 +1,38 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Build on Windows, Linux and *BSD
|
||||
|
||||
//go:build windows || (linux && !android) || freebsd || openbsd || dragonfly || netbsd
|
||||
|
||||
package posture
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"tailscale.com/types/logger"
|
||||
)
|
||||
|
||||
func TestGetSerialNumberNotMac(t *testing.T) {
|
||||
// This test is intentionally skipped as it will
|
||||
// require root on Linux to get access to the serials.
|
||||
// The test case is intended for local testing.
|
||||
// Comment out skip for local testing.
|
||||
t.Skip()
|
||||
|
||||
sns, err := GetSerialNumbers(logger.Discard)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get serial number: %s", err)
|
||||
}
|
||||
|
||||
if len(sns) == 0 {
|
||||
t.Fatalf("expected at least one serial number, got %v", sns)
|
||||
}
|
||||
|
||||
if len(sns[0]) <= 0 {
|
||||
t.Errorf("expected a serial number with more than zero characters, got %s", sns[0])
|
||||
}
|
||||
|
||||
fmt.Printf("serials: %v\n", sns)
|
||||
}
|
||||
24
posture/serialnumber_stub.go
Normal file
24
posture/serialnumber_stub.go
Normal file
@@ -0,0 +1,24 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// ios: Apple does not allow getting serials on iOS
|
||||
// android: not implemented
|
||||
// js: not implemented
|
||||
// plan9: not implemented
|
||||
// solaris: currently unsupported by go-smbios:
|
||||
// https://github.com/digitalocean/go-smbios/pull/21
|
||||
|
||||
//go:build ios || android || solaris || plan9 || js || wasm || (darwin && !cgo)
|
||||
|
||||
package posture
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"tailscale.com/types/logger"
|
||||
)
|
||||
|
||||
// GetSerialNumber returns client machine serial number(s).
|
||||
func GetSerialNumbers(_ logger.Logf) ([]string, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user