Compare commits
80 Commits
bradfitz/m
...
angott/use
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d8033fd83 | ||
|
|
99bb355791 | ||
|
|
9843e922b8 | ||
|
|
82c1dd8732 | ||
|
|
3c276d7de2 | ||
|
|
67396d716b | ||
|
|
b8a4c96c53 | ||
|
|
727b1432a8 | ||
|
|
ad4c11aca1 | ||
|
|
45eafe1b06 | ||
|
|
eb9f1db269 | ||
|
|
343c0f1031 | ||
|
|
47ffbffa97 | ||
|
|
39ade4d0d4 | ||
|
|
9203916a4a | ||
|
|
3af051ea27 | ||
|
|
c0ade132e6 | ||
|
|
668a0dd5ab | ||
|
|
9ee173c256 | ||
|
|
7c1ed38ab3 | ||
|
|
12d4685328 | ||
|
|
ff6fadddb6 | ||
|
|
f06e64c562 | ||
|
|
42072683d6 | ||
|
|
4e91cf20a8 | ||
|
|
d050700a3b | ||
|
|
683ba62f3e | ||
|
|
0396366aae | ||
|
|
70ea073478 | ||
|
|
a5ffd5e7c3 | ||
|
|
9a86aa5732 | ||
|
|
f12c71e71c | ||
|
|
dc7aa98b76 | ||
|
|
d506a55c8a | ||
|
|
60e9bd6047 | ||
|
|
db307d35e1 | ||
|
|
95082a8dde | ||
|
|
d23b8ffb13 | ||
|
|
1073b56e18 | ||
|
|
1eadb2b608 | ||
|
|
4a38d8d372 | ||
|
|
0dc65b2e47 | ||
|
|
1383fc57ad | ||
|
|
0a0adb68ad | ||
|
|
a1d4144b18 | ||
|
|
8452d273e3 | ||
|
|
0909e90890 | ||
|
|
472eb6f6f5 | ||
|
|
18b2638b07 | ||
|
|
70a9854b39 | ||
|
|
5ee349e075 | ||
|
|
1bd3edbb46 | ||
|
|
50990f8931 | ||
|
|
96094cc07e | ||
|
|
6fd1961cd7 | ||
|
|
51d3220153 | ||
|
|
96c2cd2ada | ||
|
|
c2241248c8 | ||
|
|
ac7b4d62fd | ||
|
|
d413dd7ee5 | ||
|
|
d61494db68 | ||
|
|
9a56184bef | ||
|
|
86b0fc5295 | ||
|
|
7686ff6c46 | ||
|
|
7d60c19d7d | ||
|
|
f6a203fe23 | ||
|
|
45eeef244e | ||
|
|
cb3b281e98 | ||
|
|
a4aa6507fa | ||
|
|
7175f06e62 | ||
|
|
f824274093 | ||
|
|
3280c81c95 | ||
|
|
0f397baf77 | ||
|
|
52a19b5970 | ||
|
|
6bc15f3a73 | ||
|
|
1262df0578 | ||
|
|
8683ce78c2 | ||
|
|
d06a75dcd0 | ||
|
|
c6fadd6d71 | ||
|
|
9a3bc9049c |
2
.github/workflows/codeql-analysis.yml
vendored
2
.github/workflows/codeql-analysis.yml
vendored
@@ -45,7 +45,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
|
||||
2
.github/workflows/docker-file-build.yml
vendored
2
.github/workflows/docker-file-build.yml
vendored
@@ -10,6 +10,6 @@ jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: "Build Docker image"
|
||||
run: docker build .
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
id-token: "write"
|
||||
contents: "read"
|
||||
steps:
|
||||
- uses: "actions/checkout@v3"
|
||||
- uses: "actions/checkout@v4"
|
||||
with:
|
||||
ref: "${{ (inputs.tag != null) && format('refs/tags/{0}', inputs.tag) || '' }}"
|
||||
- uses: "DeterminateSystems/nix-installer-action@main"
|
||||
|
||||
2
.github/workflows/go-licenses.yml
vendored
2
.github/workflows/go-licenses.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v4
|
||||
|
||||
2
.github/workflows/golangci-lint.yml
vendored
2
.github/workflows/golangci-lint.yml
vendored
@@ -23,7 +23,7 @@ jobs:
|
||||
name: lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v4
|
||||
with:
|
||||
|
||||
2
.github/workflows/govulncheck.yml
vendored
2
.github/workflows/govulncheck.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install govulncheck
|
||||
run: ./tool/go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||
|
||||
2
.github/workflows/installer.yml
vendored
2
.github/workflows/installer.yml
vendored
@@ -91,7 +91,7 @@ jobs:
|
||||
|| contains(matrix.image, 'parrotsec')
|
||||
|| contains(matrix.image, 'kalilinux')
|
||||
- name: checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
- name: run installer
|
||||
run: scripts/installer.sh
|
||||
# Package installation can fail in docker because systemd is not running
|
||||
|
||||
26
.github/workflows/test.yml
vendored
26
.github/workflows/test.yml
vendored
@@ -51,7 +51,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
- name: Restore Cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
@@ -116,7 +116,7 @@ jobs:
|
||||
runs-on: windows-2022
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v4
|
||||
@@ -155,7 +155,7 @@ jobs:
|
||||
if: github.repository == 'tailscale/tailscale'
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
- name: Run VM tests
|
||||
run: ./tool/go test ./tstest/integration/vms -v -no-s3 -run-vm-tests -run=TestRunUbuntu2004
|
||||
env:
|
||||
@@ -204,7 +204,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
- name: Restore Cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
@@ -241,7 +241,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
- name: build some
|
||||
run: ./tool/go build ./ipn/... ./wgengine/ ./types/... ./control/controlclient
|
||||
env:
|
||||
@@ -255,7 +255,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
# Super minimal Android build that doesn't even use CGO and doesn't build everything that's needed
|
||||
# and is only arm64. But it's a smoke build: it's not meant to catch everything. But it'll catch
|
||||
# some Android breakages early.
|
||||
@@ -270,7 +270,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
- name: Restore Cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
@@ -304,7 +304,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
- name: test tailscale_go
|
||||
run: ./tool/go test -tags=tailscale_go,ts_enable_sockstats ./net/sockstats/...
|
||||
|
||||
@@ -372,7 +372,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
- name: check depaware
|
||||
run: |
|
||||
export PATH=$(./tool/go env GOROOT)/bin:$PATH
|
||||
@@ -382,7 +382,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
- name: check that 'go generate' is clean
|
||||
run: |
|
||||
pkgs=$(./tool/go list ./... | grep -v dnsfallback)
|
||||
@@ -395,7 +395,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
- name: check that 'go mod tidy' is clean
|
||||
run: |
|
||||
./tool/go mod tidy
|
||||
@@ -407,7 +407,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
- name: check licenses
|
||||
run: ./scripts/check_license_headers.sh .
|
||||
|
||||
@@ -423,7 +423,7 @@ jobs:
|
||||
goarch: "386"
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
- name: install staticcheck
|
||||
run: GOBIN=~/.local/bin ./tool/go install honnef.co/go/tools/cmd/staticcheck
|
||||
- name: run staticcheck
|
||||
|
||||
2
.github/workflows/update-flake.yml
vendored
2
.github/workflows/update-flake.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Run update-flakes
|
||||
run: ./update-flake.sh
|
||||
|
||||
21
README.md
21
README.md
@@ -41,6 +41,16 @@ We always require the latest Go release, currently Go 1.21. (While we build
|
||||
releases with our [Go fork](https://github.com/tailscale/go/), its use is not
|
||||
required.)
|
||||
|
||||
To include the embedded web client (accessed via the `tailscale web` command),
|
||||
first build the client assets using:
|
||||
|
||||
```
|
||||
./tool/yarn --cwd client/web install
|
||||
./tool/yarn --cwd client/web build
|
||||
```
|
||||
|
||||
Build the `tailscale` and `tailscaled` binaries:
|
||||
|
||||
```
|
||||
go install tailscale.com/cmd/tailscale{,d}
|
||||
```
|
||||
@@ -57,17 +67,6 @@ If your distro has conventions that preclude the use of
|
||||
`build_dist.sh`, please do the equivalent of what it does in your
|
||||
distro's way, so that bug reports contain useful version information.
|
||||
|
||||
## Building the web client
|
||||
|
||||
To include the embedded web client (accessed via the `tailscale web` command),
|
||||
you'll need to build the client assets using:
|
||||
|
||||
```
|
||||
./tool/yarn --cwd client/web build
|
||||
```
|
||||
|
||||
Do this before building the `tailscale.com/cmd/tailscale` binary.
|
||||
|
||||
## Bugs
|
||||
|
||||
Please file any issues about this code or the hosted service on
|
||||
|
||||
@@ -392,6 +392,20 @@ func (lc *LocalClient) DebugAction(ctx context.Context, action string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// DebugResultJSON invokes a debug action and returns its result as something JSON-able.
|
||||
// These are development tools and subject to change or removal over time.
|
||||
func (lc *LocalClient) DebugResultJSON(ctx context.Context, action string) (any, error) {
|
||||
body, err := lc.send(ctx, "POST", "/localapi/v0/debug?action="+url.QueryEscape(action), 200, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error %w: %s", err, body)
|
||||
}
|
||||
var x any
|
||||
if err := json.Unmarshal(body, &x); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return x, nil
|
||||
}
|
||||
|
||||
// DebugPortmapOpts contains options for the DebugPortmap command.
|
||||
type DebugPortmapOpts struct {
|
||||
// Duration is how long the mapping should be created for. It defaults
|
||||
@@ -1094,29 +1108,6 @@ func (lc *LocalClient) NetworkLockDisable(ctx context.Context, secret []byte) er
|
||||
return nil
|
||||
}
|
||||
|
||||
// StreamServe returns an io.ReadCloser that streams serve/Funnel
|
||||
// connections made to the provided HostPort.
|
||||
//
|
||||
// If Serve and Funnel were not already enabled for the HostPort in the ServeConfig,
|
||||
// the backend enables it for the duration of the context's lifespan and
|
||||
// then turns it back off once the context is closed. If either are already enabled,
|
||||
// then they remain that way but logs are still streamed
|
||||
func (lc *LocalClient) StreamServe(ctx context.Context, hp ipn.ServeStreamRequest) (io.ReadCloser, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", "http://"+apitype.LocalAPIHost+"/localapi/v0/stream-serve", jsonBody(hp))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res, err := lc.doLocalRequestNiceError(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if res.StatusCode != 200 {
|
||||
res.Body.Close()
|
||||
return nil, errors.New(res.Status)
|
||||
}
|
||||
return res.Body, nil
|
||||
}
|
||||
|
||||
// GetServeConfig return the current serve config.
|
||||
//
|
||||
// If the serve config is empty, it returns (nil, nil).
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
@@ -12,11 +14,42 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"tailscale.com/util/must"
|
||||
)
|
||||
|
||||
// This contains all files needed to build the frontend assets.
|
||||
// Because we assign this to the blank identifier, it does not actually embed the files.
|
||||
// However, this does cause `go mod vendor` to include the files when vendoring the package.
|
||||
// External packages that use the web client can `go mod vendor`, run `yarn build` to
|
||||
// build the assets, then those asset bundles will be embedded.
|
||||
//
|
||||
//go:embed yarn.lock index.html *.js *.json src/*
|
||||
var _ embed.FS
|
||||
|
||||
//go:embed build/*
|
||||
var embeddedFS embed.FS
|
||||
|
||||
// staticfiles serves static files from the build directory.
|
||||
var staticfiles http.Handler
|
||||
|
||||
func init() {
|
||||
buildFiles := must.Get(fs.Sub(embeddedFS, "build"))
|
||||
staticfiles = http.FileServer(http.FS(buildFiles))
|
||||
}
|
||||
|
||||
func assetsHandler(devMode bool) (_ http.Handler, cleanup func()) {
|
||||
if devMode {
|
||||
// When in dev mode, proxy asset requests to the Vite dev server.
|
||||
cleanup := startDevServer()
|
||||
return devServerProxy(), cleanup
|
||||
}
|
||||
return staticfiles, nil
|
||||
}
|
||||
|
||||
// startDevServer starts the JS dev server that does on-demand rebuilding
|
||||
// and serving of web client JS and CSS resources.
|
||||
func (s *Server) startDevServer() (cleanup func()) {
|
||||
func startDevServer() (cleanup func()) {
|
||||
root := gitRootDir()
|
||||
webClientPath := filepath.Join(root, "client", "web")
|
||||
|
||||
@@ -45,10 +78,8 @@ func (s *Server) startDevServer() (cleanup func()) {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) addProxyToDevServer() {
|
||||
if !s.devMode {
|
||||
return // only using Vite proxy in dev mode
|
||||
}
|
||||
// devServerProxy returns a reverse proxy to the vite dev server.
|
||||
func devServerProxy() *httputil.ReverseProxy {
|
||||
// We use Vite to develop on the web client.
|
||||
// Vite starts up its own local server for development,
|
||||
// which we proxy requests to from Server.ServeHTTP.
|
||||
@@ -62,8 +93,9 @@ func (s *Server) addProxyToDevServer() {
|
||||
w.Write([]byte("\n\nError: " + err.Error()))
|
||||
}
|
||||
viteTarget, _ := url.Parse("http://127.0.0.1:4000")
|
||||
s.devProxy = httputil.NewSingleHostReverseProxy(viteTarget)
|
||||
s.devProxy.ErrorHandler = handleErr
|
||||
devProxy := httputil.NewSingleHostReverseProxy(viteTarget)
|
||||
devProxy.ErrorHandler = handleErr
|
||||
return devProxy
|
||||
}
|
||||
|
||||
func gitRootDir() string {
|
||||
@@ -6,7 +6,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="shortcut icon" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAQAAADZc7J/AAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAAmJLR0QA/4ePzL8AAAAHdElNRQflAx4QGA4EvmzDAAAA30lEQVRIx2NgGAWMCKa8JKM4A8Ovt88ekyLCDGOoyDBJMjExMbFy8zF8/EKsCAMDE8yAPyIwFps48SJIBpAL4AZwvoSx/r0lXgQpDN58EWL5x/7/H+vL20+JFxluQKVe5b3Ke5V+0kQQCamfoYKBg4GDwUKI8d0BYkWQkrLKewYBKPPDHUFiRaiZkBgmwhj/F5IgggyUJ6i8V3mv0kCayDAAeEsklXqGAgYGhgV3CnGrwVciYSYk0kokhgS44/JxqqFpiYSZbEgskd4dEBRk1GD4wdB5twKXmlHAwMDAAACdEZau06NQUwAAACV0RVh0ZGF0ZTpjcmVhdGUAMjAyMC0wNy0xNVQxNTo1Mzo0MCswMDowMCVXsDIAAAAldEVYdGRhdGU6bW9kaWZ5ADIwMjAtMDctMTVUMTU6NTM6NDArMDA6MDBUCgiOAAAAAElFTkSuQmCC" />
|
||||
|
||||
<script type="module" crossorigin src="./assets/index-f8beba53.js"></script>
|
||||
<script type="module" crossorigin src="./assets/index-4d1f45ea.js"></script>
|
||||
<link rel="stylesheet" href="./assets/index-8612dca6.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -16,8 +16,6 @@ import (
|
||||
"net/url"
|
||||
)
|
||||
|
||||
const qnapPrefix = "/cgi-bin/qpkg/Tailscale/index.cgi/"
|
||||
|
||||
// authorizeQNAP authenticates the logged-in QNAP user and verifies
|
||||
// that they are authorized to use the web client. It returns true if the
|
||||
// request was handled and no further processing is required.
|
||||
|
||||
@@ -23,7 +23,7 @@ export function apiFetch(
|
||||
const url = `api${endpoint}${search ? `?${search}` : ""}`
|
||||
|
||||
var contentType: string
|
||||
if (unraidCsrfToken) {
|
||||
if (unraidCsrfToken && method === "POST") {
|
||||
const params = new URLSearchParams()
|
||||
params.append("csrf_token", unraidCsrfToken)
|
||||
if (body) {
|
||||
|
||||
@@ -15,8 +15,6 @@ import (
|
||||
"tailscale.com/util/groupmember"
|
||||
)
|
||||
|
||||
const synologyPrefix = "/webman/3rdparty/Tailscale/index.cgi/"
|
||||
|
||||
// authorizeSynology authenticates the logged-in Synology user and verifies
|
||||
// that they are authorized to use the web client. It returns true if the
|
||||
// request was handled and no further processing is required.
|
||||
|
||||
@@ -7,14 +7,11 @@ package web
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -31,35 +28,20 @@ import (
|
||||
"tailscale.com/net/netutil"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/util/httpm"
|
||||
"tailscale.com/util/must"
|
||||
"tailscale.com/version/distro"
|
||||
)
|
||||
|
||||
// This contains all files needed to build the frontend assets.
|
||||
// Because we assign this to the blank identifier, it does not actually embed the files.
|
||||
// However, this does cause `go mod vendor` to include the files when vendoring the package.
|
||||
// External packages that use the web client can `go mod vendor`, run `yarn build` to
|
||||
// build the assets, then those asset bundles will be embedded.
|
||||
//
|
||||
//go:embed yarn.lock index.html *.js *.json src/*
|
||||
var _ embed.FS
|
||||
|
||||
//go:embed build/*
|
||||
var embeddedFS embed.FS
|
||||
|
||||
// staticfiles serves static files from the build directory.
|
||||
var staticfiles http.Handler
|
||||
|
||||
// Server is the backend server for a Tailscale web client.
|
||||
type Server struct {
|
||||
lc *tailscale.LocalClient
|
||||
|
||||
devMode bool
|
||||
devProxy *httputil.ReverseProxy // only filled when devMode is on
|
||||
devMode bool
|
||||
|
||||
cgiMode bool
|
||||
cgiPath string
|
||||
apiHandler http.Handler // csrf-protected api handler
|
||||
pathPrefix string
|
||||
|
||||
assetsHandler http.Handler // serves frontend assets
|
||||
apiHandler http.Handler // serves api endpoints; csrf-protected
|
||||
}
|
||||
|
||||
// ServerOpts contains options for constructing a new Server.
|
||||
@@ -69,8 +51,8 @@ type ServerOpts struct {
|
||||
// CGIMode indicates if the server is running as a CGI script.
|
||||
CGIMode bool
|
||||
|
||||
// If running in CGIMode, CGIPath is the URL path prefix to the CGI script.
|
||||
CGIPath string
|
||||
// PathPrefix is the URL prefix added to requests by CGI or reverse proxy.
|
||||
PathPrefix string
|
||||
|
||||
// LocalClient is the tailscale.LocalClient to use for this web server.
|
||||
// If nil, a new one will be created.
|
||||
@@ -84,16 +66,12 @@ func NewServer(ctx context.Context, opts ServerOpts) (s *Server, cleanup func())
|
||||
opts.LocalClient = &tailscale.LocalClient{}
|
||||
}
|
||||
s = &Server{
|
||||
devMode: opts.DevMode,
|
||||
lc: opts.LocalClient,
|
||||
cgiMode: opts.CGIMode,
|
||||
cgiPath: opts.CGIPath,
|
||||
}
|
||||
cleanup = func() {}
|
||||
if s.devMode {
|
||||
cleanup = s.startDevServer()
|
||||
s.addProxyToDevServer()
|
||||
devMode: opts.DevMode,
|
||||
lc: opts.LocalClient,
|
||||
cgiMode: opts.CGIMode,
|
||||
pathPrefix: opts.PathPrefix,
|
||||
}
|
||||
s.assetsHandler, cleanup = assetsHandler(opts.DevMode)
|
||||
|
||||
// Create handler for "/api" requests with CSRF protection.
|
||||
// We don't require secure cookies, since the web client is regularly used
|
||||
@@ -107,29 +85,13 @@ func NewServer(ctx context.Context, opts ServerOpts) (s *Server, cleanup func())
|
||||
return s, cleanup
|
||||
}
|
||||
|
||||
func init() {
|
||||
buildFiles := must.Get(fs.Sub(embeddedFS, "build"))
|
||||
staticfiles = http.FileServer(http.FS(buildFiles))
|
||||
}
|
||||
|
||||
// ServeHTTP processes all requests for the Tailscale web client.
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
handler := s.serve
|
||||
|
||||
// if running in cgi mode, strip the cgi path prefix
|
||||
if s.cgiMode {
|
||||
prefix := s.cgiPath
|
||||
if prefix == "" {
|
||||
switch distro.Get() {
|
||||
case distro.Synology:
|
||||
prefix = synologyPrefix
|
||||
case distro.QNAP:
|
||||
prefix = qnapPrefix
|
||||
}
|
||||
}
|
||||
if prefix != "" {
|
||||
handler = enforcePrefix(prefix, handler)
|
||||
}
|
||||
// if path prefix is defined, strip it from requests.
|
||||
if s.pathPrefix != "" {
|
||||
handler = enforcePrefix(s.pathPrefix, handler)
|
||||
}
|
||||
|
||||
handler(w, r)
|
||||
@@ -162,14 +124,11 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request) {
|
||||
// Pass API requests through to the API handler.
|
||||
s.apiHandler.ServeHTTP(w, r)
|
||||
return
|
||||
case s.devMode:
|
||||
// When in dev mode, proxy non-api requests to the Vite dev server.
|
||||
s.devProxy.ServeHTTP(w, r)
|
||||
return
|
||||
default:
|
||||
// Otherwise, serve static files from the embedded filesystem.
|
||||
s.lc.IncrementCounter(context.Background(), "web_client_page_load", 1)
|
||||
staticfiles.ServeHTTP(w, r)
|
||||
if !s.devMode {
|
||||
s.lc.IncrementCounter(context.Background(), "web_client_page_load", 1)
|
||||
}
|
||||
s.assetsHandler.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -334,7 +293,6 @@ func (s *Server) servePostNodeUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
} else {
|
||||
io.WriteString(w, "{}")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Server) tailscaleUp(ctx context.Context, st *ipnstate.Status, postData nodeUpdate) (authURL string, retErr error) {
|
||||
@@ -469,7 +427,7 @@ func (s *Server) csrfKey() []byte {
|
||||
// create a new key
|
||||
key := make([]byte, 32)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
log.Fatal("error generating CSRF key: %w", err)
|
||||
log.Fatalf("error generating CSRF key: %v", err)
|
||||
}
|
||||
|
||||
// if running in CGI mode, try to write the newly created key to disk, and exit if it fails.
|
||||
@@ -487,6 +445,19 @@ func (s *Server) csrfKey() []byte {
|
||||
// Unlike http.StripPrefix, it does not return a 404 if the prefix is not present.
|
||||
// Instead, it returns a redirect to the prefix path.
|
||||
func enforcePrefix(prefix string, h http.HandlerFunc) http.HandlerFunc {
|
||||
if prefix == "" {
|
||||
return h
|
||||
}
|
||||
|
||||
// ensure that prefix always has both a leading and trailing slash so
|
||||
// that relative links for JS and CSS assets work correctly.
|
||||
if !strings.HasPrefix(prefix, "/") {
|
||||
prefix = "/" + prefix
|
||||
}
|
||||
if !strings.HasSuffix(prefix, "/") {
|
||||
prefix += "/"
|
||||
}
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.HasPrefix(r.URL.Path, prefix) {
|
||||
http.Redirect(w, r, prefix, http.StatusFound)
|
||||
|
||||
@@ -128,8 +128,8 @@ func NewUpdater(args Arguments) (*Updater, error) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if args.PkgsAddr == "" {
|
||||
args.PkgsAddr = "https://pkgs.tailscale.com"
|
||||
if up.Arguments.PkgsAddr == "" {
|
||||
up.Arguments.PkgsAddr = "https://pkgs.tailscale.com"
|
||||
}
|
||||
return &up, nil
|
||||
}
|
||||
@@ -219,7 +219,8 @@ func (up *Updater) updateSynology() error {
|
||||
}
|
||||
|
||||
// Get the latest version and list of SPKs from pkgs.tailscale.com.
|
||||
osName := fmt.Sprintf("dsm%d", distro.DSMVersion())
|
||||
dsmVersion := distro.DSMVersion()
|
||||
osName := fmt.Sprintf("dsm%d", dsmVersion)
|
||||
arch, err := synoArch(runtime.GOARCH, synoinfoConfPath)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -260,8 +261,20 @@ func (up *Updater) updateSynology() error {
|
||||
// just spits out a JSON result when done.
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
if dsmVersion == 6 && bytes.Contains(out, []byte("error = [290]")) {
|
||||
return fmt.Errorf("synopkg install failed: %w\noutput:\n%s\nplease make sure that packages from 'Any publisher' are allowed in the Package Center (Package Center -> Settings -> Trust Level -> Any publisher)", err, out)
|
||||
}
|
||||
return fmt.Errorf("synopkg install failed: %w\noutput:\n%s", err, out)
|
||||
}
|
||||
if dsmVersion == 6 {
|
||||
// DSM6 does not automatically restart the package on install. Do it
|
||||
// manually.
|
||||
cmd := exec.Command("nohup", "synopkg", "start", "Tailscale")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("synopkg start failed: %w\noutput:\n%s", err, out)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -863,7 +876,7 @@ func (up *Updater) updateLinuxBinary() error {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(dlPath); err != nil {
|
||||
up.Logf("failed to clean up %q: %w", dlPath, err)
|
||||
up.Logf("failed to clean up %q: %v", dlPath, err)
|
||||
}
|
||||
if err := restartSystemdUnit(context.Background()); err != nil {
|
||||
if errors.Is(err, errors.ErrUnsupported) {
|
||||
|
||||
@@ -247,6 +247,48 @@ func (c *Client) Download(ctx context.Context, srcPath, dstPath string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateLocalBinary fetches the latest signature associated with the binary
|
||||
// at srcURLPath and uses it to validate the file located on disk via
|
||||
// localFilePath. ValidateLocalBinary returns an error if anything goes wrong
|
||||
// with the signature download or with signature validation.
|
||||
func (c *Client) ValidateLocalBinary(srcURLPath, localFilePath string) error {
|
||||
// Always fetch a fresh signing key.
|
||||
sigPub, err := c.signingKeys()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
srcURL := c.url(srcURLPath)
|
||||
sigURL := srcURL + ".sig"
|
||||
|
||||
localFile, err := os.Open(localFilePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer localFile.Close()
|
||||
|
||||
h := NewPackageHash()
|
||||
_, err = io.Copy(h, localFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hash, hashLen := h.Sum(nil), h.Len()
|
||||
|
||||
c.logf("Downloading %q", sigURL)
|
||||
sig, err := fetch(sigURL, signatureSizeLimit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
msg := binary.LittleEndian.AppendUint64(hash, uint64(hashLen))
|
||||
if !VerifyAny(sigPub, msg, sig) {
|
||||
return fmt.Errorf("signature %q for file %q does not validate with the current release signing key; either you are under attack, or attempting to download an old version of Tailscale which was signed with an older signing key", sigURL, localFilePath)
|
||||
}
|
||||
c.logf("Signature OK")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// signingKeys fetches current signing keys from the server and validates them
|
||||
// against the roots. Should be called before validation of any downloaded file
|
||||
// to get the fresh keys.
|
||||
|
||||
@@ -119,6 +119,121 @@ func TestDownload(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateLocalBinary(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
c := srv.client(t)
|
||||
|
||||
tests := []struct {
|
||||
desc string
|
||||
before func(*testing.T)
|
||||
src string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
desc: "missing file",
|
||||
before: func(*testing.T) {},
|
||||
src: "hello",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "success",
|
||||
before: func(*testing.T) {
|
||||
srv.addSigned("hello", []byte("world"))
|
||||
},
|
||||
src: "hello",
|
||||
},
|
||||
{
|
||||
desc: "contents changed",
|
||||
before: func(*testing.T) {
|
||||
srv.addSigned("hello", []byte("new world"))
|
||||
},
|
||||
src: "hello",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "no signature",
|
||||
before: func(*testing.T) {
|
||||
srv.add("hello", []byte("world"))
|
||||
},
|
||||
src: "hello",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "bad signature",
|
||||
before: func(*testing.T) {
|
||||
srv.add("hello", []byte("world"))
|
||||
srv.add("hello.sig", []byte("potato"))
|
||||
},
|
||||
src: "hello",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "signed with untrusted key",
|
||||
before: func(t *testing.T) {
|
||||
srv.add("hello", []byte("world"))
|
||||
srv.add("hello.sig", newSigningKeyPair(t).sign([]byte("world")))
|
||||
},
|
||||
src: "hello",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "signed with root key",
|
||||
before: func(t *testing.T) {
|
||||
srv.add("hello", []byte("world"))
|
||||
srv.add("hello.sig", ed25519.Sign(srv.roots[0].k, []byte("world")))
|
||||
},
|
||||
src: "hello",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "bad signing key signature",
|
||||
before: func(t *testing.T) {
|
||||
srv.add("distsign.pub.sig", []byte("potato"))
|
||||
srv.addSigned("hello", []byte("world"))
|
||||
},
|
||||
src: "hello",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.desc, func(t *testing.T) {
|
||||
srv.reset()
|
||||
|
||||
// First just do a successful Download.
|
||||
want := []byte("world")
|
||||
srv.addSigned("hello", want)
|
||||
dst := filepath.Join(t.TempDir(), tt.src)
|
||||
err := c.Download(context.Background(), tt.src, dst)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error from Download(%q): %v", tt.src, err)
|
||||
}
|
||||
got, err := os.ReadFile(dst)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(want, got) {
|
||||
t.Errorf("Download(%q): got %q, want %q", tt.src, got, want)
|
||||
}
|
||||
|
||||
// Now we reset srv with the test case and validate against the local dst.
|
||||
srv.reset()
|
||||
tt.before(t)
|
||||
|
||||
err = c.ValidateLocalBinary(tt.src, dst)
|
||||
if err != nil {
|
||||
if tt.wantErr {
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected error from ValidateLocalBinary(%q): %v", tt.src, err)
|
||||
}
|
||||
if tt.wantErr {
|
||||
t.Fatalf("ValidateLocalBinary(%q) succeeded, expected an error", tt.src)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRotateRoot(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
c1 := srv.client(t)
|
||||
|
||||
3
clientupdate/distsign/roots/crawshaw-root.pem
Executable file
3
clientupdate/distsign/roots/crawshaw-root.pem
Executable file
@@ -0,0 +1,3 @@
|
||||
-----BEGIN ROOT PUBLIC KEY-----
|
||||
Psrabv2YNiEDhPlnLVSMtB5EKACm7zxvKxfvYD4i7X8=
|
||||
-----END ROOT PUBLIC KEY-----
|
||||
@@ -136,7 +136,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
tailscale.com/types/structs from tailscale.com/ipn+
|
||||
tailscale.com/types/tkatype from tailscale.com/types/key+
|
||||
tailscale.com/types/views from tailscale.com/ipn/ipnstate+
|
||||
W tailscale.com/util/clientmetric from tailscale.com/net/tshttpproxy
|
||||
tailscale.com/util/clientmetric from tailscale.com/net/tshttpproxy+
|
||||
tailscale.com/util/cloudenv from tailscale.com/hostinfo+
|
||||
W tailscale.com/util/cmpver from tailscale.com/net/tshttpproxy
|
||||
tailscale.com/util/cmpx from tailscale.com/cmd/derper+
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go environment
|
||||
uses: actions/setup-go@v3.2.0
|
||||
|
||||
@@ -130,6 +130,7 @@ change in the future.
|
||||
netlockCmd,
|
||||
licensesCmd,
|
||||
exitNodeCmd,
|
||||
updateCmd,
|
||||
},
|
||||
FlagSet: rootfs,
|
||||
Exec: func(context.Context, []string) error { return flag.ErrHelp },
|
||||
@@ -145,8 +146,6 @@ change in the future.
|
||||
switch {
|
||||
case slices.Contains(args, "debug"):
|
||||
rootCmd.Subcommands = append(rootCmd.Subcommands, debugCmd)
|
||||
case slices.Contains(args, "update"):
|
||||
rootCmd.Subcommands = append(rootCmd.Subcommands, updateCmd)
|
||||
}
|
||||
if runtime.GOOS == "linux" && distro.Get() == distro.Synology {
|
||||
rootCmd.Subcommands = append(rootCmd.Subcommands, configureHostCmd)
|
||||
|
||||
@@ -138,6 +138,11 @@ var debugCmd = &ffcli.Command{
|
||||
Exec: localAPIAction("break-derp-conns"),
|
||||
ShortHelp: "break any open DERP connections from the daemon",
|
||||
},
|
||||
{
|
||||
Name: "control-knobs",
|
||||
Exec: debugControlKnobs,
|
||||
ShortHelp: "see current control knobs",
|
||||
},
|
||||
{
|
||||
Name: "prefs",
|
||||
Exec: runPrefs,
|
||||
@@ -915,3 +920,17 @@ func runPeerEndpointChanges(ctx context.Context, args []string) error {
|
||||
fmt.Printf("%s", dst.String())
|
||||
return nil
|
||||
}
|
||||
|
||||
func debugControlKnobs(ctx context.Context, args []string) error {
|
||||
if len(args) > 0 {
|
||||
return errors.New("unexpected arguments")
|
||||
}
|
||||
v, err := localClient.DebugResultJSON(ctx, "control-knobs")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
e := json.NewEncoder(os.Stdout)
|
||||
e.SetIndent("", " ")
|
||||
e.Encode(v)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ var funnelCmd = func() *ffcli.Command {
|
||||
// implementation of the tailscale funnel command.
|
||||
// See https://github.com/tailscale/tailscale/issues/7844
|
||||
if envknob.UseWIPCode() {
|
||||
return newServeDevCommand(se, "funnel")
|
||||
return newServeDevCommand(se, funnel)
|
||||
}
|
||||
return newFunnelCommand(se)
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ func runNetcheck(ctx context.Context, args []string) error {
|
||||
return err
|
||||
}
|
||||
c := &netcheck.Client{
|
||||
PortMapper: portmapper.NewClient(logf, netMon, nil, nil),
|
||||
PortMapper: portmapper.NewClient(logf, netMon, nil, nil, nil),
|
||||
UseDNSCache: false, // always resolve, don't cache
|
||||
}
|
||||
if netcheckArgs.verbose {
|
||||
@@ -153,7 +153,11 @@ func printReport(dm *tailcfg.DERPMap, report *netcheck.Report) error {
|
||||
if len(report.RegionLatency) == 0 {
|
||||
printf("\t* Nearest DERP: unknown (no response to latency probes)\n")
|
||||
} else {
|
||||
printf("\t* Nearest DERP: %v\n", dm.Regions[report.PreferredDERP].RegionName)
|
||||
if report.PreferredDERP != 0 {
|
||||
printf("\t* Nearest DERP: %v\n", dm.Regions[report.PreferredDERP].RegionName)
|
||||
} else {
|
||||
printf("\t* Nearest DERP: [none]\n")
|
||||
}
|
||||
printf("\t* DERP latency:\n")
|
||||
var rids []int
|
||||
for rid := range dm.Regions {
|
||||
|
||||
@@ -39,7 +39,7 @@ var serveCmd = func() *ffcli.Command {
|
||||
// implementation of the tailscale funnel command.
|
||||
// See https://github.com/tailscale/tailscale/issues/7844
|
||||
if envknob.UseWIPCode() {
|
||||
return newServeDevCommand(se, "serve")
|
||||
return newServeDevCommand(se, serve)
|
||||
}
|
||||
return newServeCommand(se)
|
||||
}
|
||||
@@ -120,6 +120,10 @@ EXAMPLES
|
||||
}
|
||||
}
|
||||
|
||||
// errHelp is standard error text that prompts users to
|
||||
// run `serve --help` for information on how to use serve.
|
||||
var errHelp = errors.New("try `tailscale serve --help` for usage info")
|
||||
|
||||
func (e *serveEnv) newFlags(name string, setup func(fs *flag.FlagSet)) *flag.FlagSet {
|
||||
onError, out := flag.ExitOnError, Stderr
|
||||
if e.testFlagOut != nil {
|
||||
@@ -145,7 +149,6 @@ type localServeClient interface {
|
||||
QueryFeature(ctx context.Context, feature string) (*tailcfg.QueryFeatureResponse, error)
|
||||
WatchIPNBus(ctx context.Context, mask ipn.NotifyWatchOpt) (*tailscale.IPNBusWatcher, error)
|
||||
IncrementCounter(ctx context.Context, name string, delta int) error
|
||||
StreamServe(ctx context.Context, req ipn.ServeStreamRequest) (io.ReadCloser, error) // TODO: testing :)
|
||||
}
|
||||
|
||||
// serveEnv is the environment the serve command runs within. All I/O should be
|
||||
@@ -155,9 +158,18 @@ type localServeClient interface {
|
||||
//
|
||||
// It also contains the flags, as registered with newServeCommand.
|
||||
type serveEnv struct {
|
||||
// flags
|
||||
// v1 flags
|
||||
json bool // output JSON (status only for now)
|
||||
|
||||
// v2 specific flags
|
||||
bg bool // background mode
|
||||
setPath string // serve path
|
||||
https string // HTTP port
|
||||
http string // HTTP port
|
||||
tcp string // TCP port
|
||||
tlsTerminatedTCP string // a TLS terminated TCP port
|
||||
subcmd serveMode // subcommand
|
||||
|
||||
lc localServeClient // localClient interface, specific to serve
|
||||
|
||||
// optional stuff for tests:
|
||||
@@ -244,7 +256,7 @@ func (e *serveEnv) runServe(ctx context.Context, args []string) error {
|
||||
|
||||
if len(args) < 2 || ((srcType == "https" || srcType == "http") && !turnOff && len(args) < 3) {
|
||||
fmt.Fprintf(os.Stderr, "error: invalid number of arguments\n\n")
|
||||
return flag.ErrHelp
|
||||
return errHelp
|
||||
}
|
||||
|
||||
if srcType == "https" && !turnOff {
|
||||
@@ -286,7 +298,7 @@ func (e *serveEnv) runServe(ctx context.Context, args []string) error {
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "error: invalid serve type %q\n", srcType)
|
||||
fmt.Fprint(os.Stderr, "must be one of: http:<port>, https:<port>, tcp:<port> or tls-terminated-tcp:<port>\n\n", srcType)
|
||||
return flag.ErrHelp
|
||||
return errHelp
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,13 +334,13 @@ func (e *serveEnv) handleWebServe(ctx context.Context, srvPort uint16, useTLS bo
|
||||
}
|
||||
if !filepath.IsAbs(source) {
|
||||
fmt.Fprintf(os.Stderr, "error: path must be absolute\n\n")
|
||||
return flag.ErrHelp
|
||||
return errHelp
|
||||
}
|
||||
source = filepath.Clean(source)
|
||||
fi, err := os.Stat(source)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: invalid path: %v\n\n", err)
|
||||
return flag.ErrHelp
|
||||
return errHelp
|
||||
}
|
||||
if fi.IsDir() && !strings.HasSuffix(mount, "/") {
|
||||
// dir mount points must end in /
|
||||
@@ -354,7 +366,7 @@ func (e *serveEnv) handleWebServe(ctx context.Context, srvPort uint16, useTLS bo
|
||||
|
||||
if sc.IsTCPForwardingOnPort(srvPort) {
|
||||
fmt.Fprintf(os.Stderr, "error: cannot serve web; already serving TCP\n")
|
||||
return flag.ErrHelp
|
||||
return errHelp
|
||||
}
|
||||
|
||||
mak.Set(&sc.TCP, srvPort, &ipn.TCPPortHandler{HTTPS: useTLS, HTTP: !useTLS})
|
||||
@@ -542,18 +554,18 @@ func (e *serveEnv) handleTCPServe(ctx context.Context, srcType string, srcPort u
|
||||
terminateTLS = true
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "error: invalid TCP source %q\n\n", dest)
|
||||
return flag.ErrHelp
|
||||
return errHelp
|
||||
}
|
||||
|
||||
dstURL, err := url.Parse(dest)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: invalid TCP source %q: %v\n\n", dest, err)
|
||||
return flag.ErrHelp
|
||||
return errHelp
|
||||
}
|
||||
host, dstPortStr, err := net.SplitHostPort(dstURL.Host)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: invalid TCP source %q: %v\n\n", dest, err)
|
||||
return flag.ErrHelp
|
||||
return errHelp
|
||||
}
|
||||
|
||||
switch host {
|
||||
@@ -562,12 +574,12 @@ func (e *serveEnv) handleTCPServe(ctx context.Context, srcType string, srcPort u
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "error: invalid TCP source %q\n", dest)
|
||||
fmt.Fprint(os.Stderr, "must be one of: localhost or 127.0.0.1\n\n", dest)
|
||||
return flag.ErrHelp
|
||||
return errHelp
|
||||
}
|
||||
|
||||
if p, err := strconv.ParseUint(dstPortStr, 10, 16); p == 0 || err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: invalid port %q\n\n", dstPortStr)
|
||||
return flag.ErrHelp
|
||||
return errHelp
|
||||
}
|
||||
|
||||
cursc, err := e.lc.GetServeConfig(ctx)
|
||||
|
||||
@@ -5,64 +5,125 @@ package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/util/mak"
|
||||
"tailscale.com/version"
|
||||
)
|
||||
|
||||
type execFunc func(ctx context.Context, args []string) error
|
||||
|
||||
type commandInfo struct {
|
||||
Name string
|
||||
ShortHelp string
|
||||
LongHelp string
|
||||
}
|
||||
|
||||
var infoMap = map[string]commandInfo{
|
||||
"serve": {
|
||||
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).
|
||||
|
||||
EXAMPLES
|
||||
- Mount a local web server at 127.0.0.1:3000 in the foreground:
|
||||
$ tailscale %s localhost:3000
|
||||
|
||||
- Mount a local web server at 127.0.0.1:3000 in the background:
|
||||
$ tailscale %s --bg localhost:3000
|
||||
|
||||
For more examples and use cases visit our docs site https://tailscale.com/kb/1247/funnel-serve-use-cases
|
||||
`)
|
||||
|
||||
type serveMode int
|
||||
|
||||
const (
|
||||
serve serveMode = iota
|
||||
funnel
|
||||
)
|
||||
|
||||
type serveType int
|
||||
|
||||
const (
|
||||
serveTypeHTTPS serveType = iota
|
||||
serveTypeHTTP
|
||||
serveTypeTCP
|
||||
serveTypeTLSTerminatedTCP
|
||||
)
|
||||
|
||||
var infoMap = map[serveMode]commandInfo{
|
||||
serve: {
|
||||
Name: "serve",
|
||||
ShortHelp: "Serve content and local servers on your tailnet",
|
||||
LongHelp: strings.Join([]string{
|
||||
"Serve lets you share a local server securely within your tailnet.",
|
||||
"To share a local server on the internet, use \"tailscale funnel\"",
|
||||
"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"),
|
||||
},
|
||||
"funnel": {
|
||||
funnel: {
|
||||
Name: "funnel",
|
||||
ShortHelp: "Serve content and local servers on the internet",
|
||||
LongHelp: strings.Join([]string{
|
||||
"Funnel lets you share a local server on the internet using Tailscale.",
|
||||
"To share only within your tailnet, use \"tailscale serve\"",
|
||||
"Funnel enables you to share a local server on the internet using Tailscale.\n",
|
||||
"To share only within your tailnet, use `tailscale serve`\n\n",
|
||||
}, "\n"),
|
||||
},
|
||||
}
|
||||
|
||||
func buildShortUsage(subcmd string) string {
|
||||
return strings.Join([]string{
|
||||
subcmd + " [flags] <target> [off]",
|
||||
subcmd + " status [--json]",
|
||||
subcmd + " reset",
|
||||
}, "\n ")
|
||||
}
|
||||
|
||||
// newServeDevCommand returns a new "serve" subcommand using e as its environment.
|
||||
func newServeDevCommand(e *serveEnv, subcmd string) *ffcli.Command {
|
||||
if subcmd != "serve" && subcmd != "funnel" {
|
||||
func newServeDevCommand(e *serveEnv, subcmd serveMode) *ffcli.Command {
|
||||
if subcmd != serve && subcmd != funnel {
|
||||
log.Fatalf("newServeDevCommand called with unknown subcmd %q", subcmd)
|
||||
}
|
||||
|
||||
info := infoMap[subcmd]
|
||||
|
||||
return &ffcli.Command{
|
||||
Name: subcmd,
|
||||
Name: info.Name,
|
||||
ShortHelp: info.ShortHelp,
|
||||
ShortUsage: strings.Join([]string{
|
||||
fmt.Sprintf("%s <target>", subcmd),
|
||||
fmt.Sprintf("%s status [--json]", subcmd),
|
||||
fmt.Sprintf("%s reset", subcmd),
|
||||
fmt.Sprintf("%s <target>", info.Name),
|
||||
fmt.Sprintf("%s status [--json]", info.Name),
|
||||
fmt.Sprintf("%s reset", info.Name),
|
||||
}, "\n "),
|
||||
LongHelp: info.LongHelp,
|
||||
Exec: e.runServeDev(subcmd == "funnel"),
|
||||
LongHelp: info.LongHelp + fmt.Sprintf(strings.TrimSpace(serveHelpCommon), info.Name, info.Name),
|
||||
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")
|
||||
|
||||
}),
|
||||
UsageFunc: usageFunc,
|
||||
Subcommands: []*ffcli.Command{
|
||||
// TODO(tyler+marwan-at-work) Implement set, unset, and logs subcommands
|
||||
{
|
||||
Name: "status",
|
||||
Exec: e.runServeStatus,
|
||||
@@ -83,65 +144,612 @@ func newServeDevCommand(e *serveEnv, subcmd string) *ffcli.Command {
|
||||
}
|
||||
}
|
||||
|
||||
// runServeDev is the entry point for the "tailscale {serve,funnel}" commands.
|
||||
func (e *serveEnv) runServeDev(funnel bool) execFunc {
|
||||
func validateArgs(subcmd serveMode, args []string) error {
|
||||
switch len(args) {
|
||||
case 0:
|
||||
return flag.ErrHelp
|
||||
case 1, 2:
|
||||
if isLegacyInvocation(subcmd, args) {
|
||||
fmt.Fprintf(os.Stderr, "error: the CLI for serve and funnel has changed.")
|
||||
fmt.Fprintf(os.Stderr, "Please see https://tailscale.com/kb/1242/tailscale-serve for more information.")
|
||||
return errHelp
|
||||
}
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "error: invalid number of arguments (%d)", len(args))
|
||||
return errHelp
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// runServeCombined is the entry point for the "tailscale {serve,funnel}" commands.
|
||||
func (e *serveEnv) runServeCombined(subcmd serveMode) execFunc {
|
||||
e.subcmd = subcmd
|
||||
|
||||
return func(ctx context.Context, args []string) error {
|
||||
ctx, cancel := signal.NotifyContext(ctx, os.Interrupt)
|
||||
defer cancel()
|
||||
if len(args) != 1 {
|
||||
return flag.ErrHelp
|
||||
}
|
||||
var source string
|
||||
port64, err := strconv.ParseUint(args[0], 10, 16)
|
||||
if err == nil {
|
||||
source = fmt.Sprintf("http://127.0.0.1:%d", port64)
|
||||
} else {
|
||||
source, err = expandProxyTarget(args[0])
|
||||
}
|
||||
if err != nil {
|
||||
if err := validateArgs(subcmd, args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := signal.NotifyContext(ctx, os.Interrupt)
|
||||
defer cancel()
|
||||
|
||||
st, err := e.getLocalClientStatusWithoutPeers(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting client status: %w", err)
|
||||
}
|
||||
|
||||
funnel := subcmd == funnel
|
||||
if funnel {
|
||||
// verify node has funnel capabilities
|
||||
if err := e.verifyFunnelEnabled(ctx, st, 443); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
mount, err := cleanURLPath(e.setPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to clean the mount point: %w", err)
|
||||
}
|
||||
|
||||
if e.setPath != "" {
|
||||
// TODO(marwan-at-work): either
|
||||
// 1. Warn the user that this is a side effect.
|
||||
// 2. Force the user to pass --bg
|
||||
// 3. Allow set-path to be in the foreground.
|
||||
e.bg = true
|
||||
}
|
||||
|
||||
srvType, srvPort, err := srvTypeAndPortFromFlags(e)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n\n", err)
|
||||
return errHelp
|
||||
}
|
||||
|
||||
sc, err := e.lc.GetServeConfig(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting serve config: %w", err)
|
||||
}
|
||||
|
||||
// nil if no config
|
||||
if sc == nil {
|
||||
sc = new(ipn.ServeConfig)
|
||||
}
|
||||
dnsName := strings.TrimSuffix(st.Self.DNSName, ".")
|
||||
hp := ipn.HostPort(dnsName + ":443") // TODO(marwan-at-work): support the 2 other ports
|
||||
|
||||
// In the streaming case, the process stays running in the
|
||||
// foreground and prints out connections to the HostPort.
|
||||
//
|
||||
// The local backend handles updating the ServeConfig as
|
||||
// necessary, then restores it to its original state once
|
||||
// the process's context is closed or the client turns off
|
||||
// Tailscale.
|
||||
// TODO(tyler+marwan-at-work) support flag to run in the background
|
||||
return e.streamServe(ctx, ipn.ServeStreamRequest{
|
||||
Funnel: funnel,
|
||||
HostPort: hp,
|
||||
Source: source,
|
||||
MountPoint: "/", // TODO(marwan-at-work): support multiple mount points
|
||||
// set parent serve config to always be persisted
|
||||
// at the top level, but a nested config might be
|
||||
// the one that gets manipulated depending on
|
||||
// foreground or background.
|
||||
parentSC := sc
|
||||
|
||||
turnOff := "off" == args[len(args)-1]
|
||||
if !turnOff && srvType == serveTypeHTTPS {
|
||||
// Running serve with https requires that the tailnet has enabled
|
||||
// https cert provisioning. Send users through an interactive flow
|
||||
// to enable this if not already done.
|
||||
//
|
||||
// TODO(sonia,tailscale/corp#10577): The interactive feature flow
|
||||
// is behind a control flag. If the tailnet doesn't have the flag
|
||||
// on, enableFeatureInteractive will error. For now, we hide that
|
||||
// error and maintain the previous behavior (prior to 2023-08-15)
|
||||
// of letting them edit the serve config before enabling certs.
|
||||
if err := e.enableFeatureInteractive(ctx, "serve", func(caps []string) bool {
|
||||
return slices.Contains(caps, tailcfg.CapabilityHTTPS)
|
||||
}); err != nil {
|
||||
return fmt.Errorf("error enabling https feature: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
var watcher *tailscale.IPNBusWatcher
|
||||
if !e.bg && !turnOff {
|
||||
// if foreground mode, create a WatchIPNBus session
|
||||
// and use the nested config for all following operations
|
||||
// TODO(marwan-at-work): nested-config validations should happen here or previous to this point.
|
||||
watcher, err = e.lc.WatchIPNBus(ctx, ipn.NotifyInitialState)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer watcher.Close()
|
||||
n, err := watcher.Next()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n.SessionID == "" {
|
||||
return errors.New("missing SessionID")
|
||||
}
|
||||
fsc := &ipn.ServeConfig{}
|
||||
mak.Set(&sc.Foreground, n.SessionID, fsc)
|
||||
sc = fsc
|
||||
}
|
||||
|
||||
var msg string
|
||||
if turnOff {
|
||||
err = e.unsetServe(sc, dnsName, srvType, srvPort, mount)
|
||||
} else {
|
||||
err = e.setServe(sc, st, dnsName, srvType, srvPort, mount, args[0], funnel)
|
||||
msg = e.messageForPort(sc, st, dnsName, srvPort)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n\n", err)
|
||||
return errHelp
|
||||
}
|
||||
|
||||
if err := e.lc.SetServeConfig(ctx, parentSC); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if msg != "" {
|
||||
fmt.Fprintln(os.Stderr, msg)
|
||||
}
|
||||
|
||||
if watcher != nil {
|
||||
for {
|
||||
_, err = watcher.Next()
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (e *serveEnv) setServe(sc *ipn.ServeConfig, st *ipnstate.Status, dnsName string, srvType serveType, srvPort uint16, mount string, target string, allowFunnel bool) error {
|
||||
// update serve config based on the type
|
||||
switch srvType {
|
||||
case serveTypeHTTPS, serveTypeHTTP:
|
||||
useTLS := srvType == serveTypeHTTPS
|
||||
err := e.applyWebServe(sc, dnsName, srvPort, useTLS, mount, target)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed apply web serve: %w", err)
|
||||
}
|
||||
case serveTypeTCP, serveTypeTLSTerminatedTCP:
|
||||
err := e.applyTCPServe(sc, dnsName, srvType, srvPort, target)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to apply TCP serve: %w", err)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("invalid type %q", srvType)
|
||||
}
|
||||
|
||||
// update the serve config based on if funnel is enabled
|
||||
e.applyFunnel(sc, dnsName, srvPort, allowFunnel)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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")
|
||||
} else {
|
||||
output.WriteString("Available within your tailnet:\n")
|
||||
}
|
||||
|
||||
scheme := "https"
|
||||
if sc.IsServingHTTP(srvPort) {
|
||||
scheme = "http"
|
||||
}
|
||||
|
||||
portPart := ":" + fmt.Sprint(srvPort)
|
||||
if scheme == "http" && srvPort == 80 ||
|
||||
scheme == "https" && srvPort == 443 {
|
||||
portPart = ""
|
||||
}
|
||||
|
||||
output.WriteString(fmt.Sprintf("%s://%s%s\n\n", scheme, dnsName, portPart))
|
||||
|
||||
if !e.bg {
|
||||
output.WriteString("Press Ctrl+C to exit.")
|
||||
return output.String()
|
||||
}
|
||||
|
||||
srvTypeAndDesc := func(h *ipn.HTTPHandler) (string, string) {
|
||||
switch {
|
||||
case h.Path != "":
|
||||
return "path", h.Path
|
||||
case h.Proxy != "":
|
||||
return "proxy", h.Proxy
|
||||
case h.Text != "":
|
||||
return "text", "\"" + elipticallyTruncate(h.Text, 20) + "\""
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
if sc.Web[hp] != nil {
|
||||
var mounts []string
|
||||
|
||||
for k := range sc.Web[hp].Handlers {
|
||||
mounts = append(mounts, k)
|
||||
}
|
||||
sort.Slice(mounts, func(i, j int) bool {
|
||||
return len(mounts[i]) < len(mounts[j])
|
||||
})
|
||||
maxLen := len(mounts[len(mounts)-1])
|
||||
|
||||
for _, m := range mounts {
|
||||
h := sc.Web[hp].Handlers[m]
|
||||
t, d := srvTypeAndDesc(h)
|
||||
output.WriteString(fmt.Sprintf("%s %s%s %-5s %s\n", "|--", m, strings.Repeat(" ", maxLen-len(m)), t, d))
|
||||
}
|
||||
} else if sc.TCP[srvPort] != nil {
|
||||
h := sc.TCP[srvPort]
|
||||
|
||||
tlsStatus := "TLS over TCP"
|
||||
if h.TerminateTLS != "" {
|
||||
tlsStatus = "TLS terminated"
|
||||
}
|
||||
|
||||
output.WriteString(fmt.Sprintf("|-- tcp://%s (%s)\n", hp, tlsStatus))
|
||||
for _, a := range st.TailscaleIPs {
|
||||
ipp := net.JoinHostPort(a.String(), strconv.Itoa(int(srvPort)))
|
||||
output.WriteString(fmt.Sprintf("|-- tcp://%s\n", ipp))
|
||||
}
|
||||
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))
|
||||
|
||||
return output.String()
|
||||
}
|
||||
|
||||
func (e *serveEnv) streamServe(ctx context.Context, req ipn.ServeStreamRequest) error {
|
||||
stream, err := e.lc.StreamServe(ctx, req)
|
||||
func (e *serveEnv) applyWebServe(sc *ipn.ServeConfig, dnsName string, srvPort uint16, useTLS bool, mount, target string) error {
|
||||
h := new(ipn.HTTPHandler)
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(target, "text:"):
|
||||
text := strings.TrimPrefix(target, "text:")
|
||||
if text == "" {
|
||||
return errors.New("unable to serve; text cannot be an empty string")
|
||||
}
|
||||
h.Text = text
|
||||
case filepath.IsAbs(target):
|
||||
if version.IsSandboxedMacOS() {
|
||||
// don't allow path serving for now on macOS (2022-11-15)
|
||||
return errors.New("path serving is not supported if sandboxed on macOS")
|
||||
}
|
||||
|
||||
target = filepath.Clean(target)
|
||||
fi, err := os.Stat(target)
|
||||
if err != nil {
|
||||
return errors.New("invalid path")
|
||||
}
|
||||
|
||||
// TODO: need to understand this further
|
||||
if fi.IsDir() && !strings.HasSuffix(mount, "/") {
|
||||
// dir mount points must end in /
|
||||
// for relative file links to work
|
||||
mount += "/"
|
||||
}
|
||||
h.Path = target
|
||||
default:
|
||||
t, err := expandProxyTargetDev(target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h.Proxy = t
|
||||
}
|
||||
|
||||
// TODO: validation needs to check nested foreground configs
|
||||
if sc.IsTCPForwardingOnPort(srvPort) {
|
||||
return errors.New("cannot serve web; already serving TCP")
|
||||
}
|
||||
|
||||
mak.Set(&sc.TCP, srvPort, &ipn.TCPPortHandler{HTTPS: useTLS, HTTP: !useTLS})
|
||||
|
||||
hp := ipn.HostPort(net.JoinHostPort(dnsName, strconv.Itoa(int(srvPort))))
|
||||
if _, ok := sc.Web[hp]; !ok {
|
||||
mak.Set(&sc.Web, hp, new(ipn.WebServerConfig))
|
||||
}
|
||||
mak.Set(&sc.Web[hp].Handlers, mount, h)
|
||||
|
||||
// TODO: handle multiple web handlers from foreground mode
|
||||
for k, v := range sc.Web[hp].Handlers {
|
||||
if v == h {
|
||||
continue
|
||||
}
|
||||
// If the new mount point ends in / and another mount point
|
||||
// shares the same prefix, remove the other handler.
|
||||
// (e.g. /foo/ overwrites /foo)
|
||||
// The opposite example is also handled.
|
||||
m1 := strings.TrimSuffix(mount, "/")
|
||||
m2 := strings.TrimSuffix(k, "/")
|
||||
if m1 == m2 {
|
||||
delete(sc.Web[hp].Handlers, k)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *serveEnv) applyTCPServe(sc *ipn.ServeConfig, dnsName string, srcType serveType, srcPort uint16, target string) error {
|
||||
var terminateTLS bool
|
||||
switch srcType {
|
||||
case serveTypeTCP:
|
||||
terminateTLS = false
|
||||
case serveTypeTLSTerminatedTCP:
|
||||
terminateTLS = true
|
||||
default:
|
||||
return fmt.Errorf("invalid TCP target %q", target)
|
||||
}
|
||||
|
||||
dstURL, err := url.Parse(target)
|
||||
if err != nil {
|
||||
return err
|
||||
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)
|
||||
}
|
||||
defer stream.Close()
|
||||
|
||||
fmt.Fprintf(os.Stderr, "Serve started on \"https://%s\".\n", strings.TrimSuffix(string(req.HostPort), ":443"))
|
||||
fmt.Fprintf(os.Stderr, "Press Ctrl-C to stop.\n\n")
|
||||
_, err = io.Copy(os.Stdout, stream)
|
||||
return 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})
|
||||
|
||||
if terminateTLS {
|
||||
sc.TCP[srcPort].TerminateTLS = dnsName
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *serveEnv) applyFunnel(sc *ipn.ServeConfig, dnsName string, srvPort uint16, allowFunnel bool) {
|
||||
hp := ipn.HostPort(net.JoinHostPort(dnsName, strconv.Itoa(int(srvPort))))
|
||||
|
||||
// TODO: Should we return an error? Should not be possible.
|
||||
// nil if no config
|
||||
if sc == nil {
|
||||
sc = new(ipn.ServeConfig)
|
||||
}
|
||||
|
||||
// TODO: should ensure there is no other conflicting funnel
|
||||
// TODO: add error handling for if toggling for existing sc
|
||||
if allowFunnel {
|
||||
mak.Set(&sc.AllowFunnel, hp, true)
|
||||
}
|
||||
}
|
||||
|
||||
// unsetServe removes the serve config for the given serve port.
|
||||
func (e *serveEnv) unsetServe(sc *ipn.ServeConfig, dnsName string, srvType serveType, srvPort uint16, mount string) error {
|
||||
switch srvType {
|
||||
case serveTypeHTTPS, serveTypeHTTP:
|
||||
err := e.removeWebServe(sc, dnsName, srvPort, mount)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove web serve: %w", err)
|
||||
}
|
||||
case serveTypeTCP, serveTypeTLSTerminatedTCP:
|
||||
err := e.removeTCPServe(sc, srvPort)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove TCP serve: %w", err)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("invalid type %q", srvType)
|
||||
}
|
||||
|
||||
// TODO(tylersmalley): remove funnel
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func srvTypeAndPortFromFlags(e *serveEnv) (srvType serveType, srvPort uint16, err error) {
|
||||
sourceMap := map[serveType]string{
|
||||
serveTypeHTTP: e.http,
|
||||
serveTypeHTTPS: e.https,
|
||||
serveTypeTCP: e.tcp,
|
||||
serveTypeTLSTerminatedTCP: e.tlsTerminatedTCP,
|
||||
}
|
||||
|
||||
var srcTypeCount int
|
||||
var srcValue string
|
||||
|
||||
for k, v := range sourceMap {
|
||||
if v != "" {
|
||||
srcTypeCount++
|
||||
srvType = k
|
||||
srcValue = v
|
||||
}
|
||||
}
|
||||
|
||||
if srcTypeCount > 1 {
|
||||
return 0, 0, fmt.Errorf("cannot serve multiple types for a single mount point")
|
||||
} else if srcTypeCount == 0 {
|
||||
srvType = serveTypeHTTPS
|
||||
srcValue = "443"
|
||||
}
|
||||
|
||||
srvPort, err = parseServePort(srcValue)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("invalid port %q: %w", srcValue, err)
|
||||
}
|
||||
|
||||
return srvType, srvPort, nil
|
||||
}
|
||||
|
||||
func isLegacyInvocation(subcmd serveMode, args []string) bool {
|
||||
if subcmd == serve && len(args) == 2 {
|
||||
prefixes := []string{"http", "https", "tcp", "tls-terminated-tcp"}
|
||||
|
||||
for _, prefix := range prefixes {
|
||||
if strings.HasPrefix(args[0], prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// removeWebServe removes a web handler from the serve config
|
||||
// and removes funnel if no remaining mounts exist for the serve port.
|
||||
// The srvPort argument is the serving port and the mount argument is
|
||||
// the mount point or registered path to remove.
|
||||
func (e *serveEnv) removeWebServe(sc *ipn.ServeConfig, dnsName string, srvPort uint16, mount string) error {
|
||||
if sc.IsTCPForwardingOnPort(srvPort) {
|
||||
return errors.New("cannot remove web handler; currently serving TCP")
|
||||
}
|
||||
|
||||
hp := ipn.HostPort(net.JoinHostPort(dnsName, strconv.Itoa(int(srvPort))))
|
||||
if !sc.WebHandlerExists(hp, mount) {
|
||||
return errors.New("error: handler does not exist")
|
||||
}
|
||||
|
||||
// delete existing handler, then cascade delete if empty
|
||||
delete(sc.Web[hp].Handlers, mount)
|
||||
if len(sc.Web[hp].Handlers) == 0 {
|
||||
delete(sc.Web, hp)
|
||||
delete(sc.TCP, srvPort)
|
||||
}
|
||||
|
||||
// clear empty maps mostly for testing
|
||||
if len(sc.Web) == 0 {
|
||||
sc.Web = nil
|
||||
}
|
||||
|
||||
if len(sc.TCP) == 0 {
|
||||
sc.TCP = nil
|
||||
}
|
||||
|
||||
// disable funnel if no remaining mounts exist for the serve port
|
||||
if sc.Web == nil && sc.TCP == nil {
|
||||
delete(sc.AllowFunnel, hp)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeTCPServe removes the TCP forwarding configuration for the
|
||||
// given srvPort, or serving port.
|
||||
func (e *serveEnv) removeTCPServe(sc *ipn.ServeConfig, src uint16) error {
|
||||
if sc == nil {
|
||||
return nil
|
||||
}
|
||||
if sc.GetTCPPortHandler(src) == nil {
|
||||
return errors.New("error: serve config does not exist")
|
||||
}
|
||||
if sc.IsServingWeb(src) {
|
||||
return fmt.Errorf("unable to remove; serving web, not TCP forwarding on serve port %d", src)
|
||||
}
|
||||
delete(sc.TCP, src)
|
||||
// clear map mostly for testing
|
||||
if len(sc.TCP) == 0 {
|
||||
sc.TCP = nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// expandProxyTargetDev expands the supported target values to be proxied
|
||||
// allowing for input values to be a port number, a partial URL, or a full URL
|
||||
// including a path.
|
||||
//
|
||||
// examples:
|
||||
// - 3000
|
||||
// - 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"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// prepend scheme if not present
|
||||
if !strings.Contains(target, "://") {
|
||||
target = scheme + "://" + target
|
||||
}
|
||||
|
||||
// make sure we can parse the target
|
||||
u, err := url.ParseRequestURI(target)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid URL %w", err)
|
||||
}
|
||||
|
||||
// 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://")
|
||||
}
|
||||
|
||||
// validate the port
|
||||
port, err := strconv.ParseUint(u.Port(), 10, 16)
|
||||
if err != nil || port == 0 {
|
||||
return "", fmt.Errorf("invalid port %q", u.Port())
|
||||
}
|
||||
|
||||
// validate the host.
|
||||
switch u.Hostname() {
|
||||
case "localhost", "127.0.0.1":
|
||||
u.Host = fmt.Sprintf("%s:%d", host, port)
|
||||
default:
|
||||
return "", errors.New("only localhost or 127.0.0.1 proxies are currently supported")
|
||||
}
|
||||
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
// cleanURLPath ensures the path is clean and has a leading "/".
|
||||
func cleanURLPath(urlPath string) (string, error) {
|
||||
if urlPath == "" {
|
||||
return "/", nil
|
||||
}
|
||||
|
||||
// TODO(tylersmalley) verify still needed with path being a flag
|
||||
urlPath = cleanMinGWPathConversionIfNeeded(urlPath)
|
||||
if !strings.HasPrefix(urlPath, "/") {
|
||||
urlPath = "/" + urlPath
|
||||
}
|
||||
|
||||
c := path.Clean(urlPath)
|
||||
if urlPath == c || urlPath == c+"/" {
|
||||
return urlPath, nil
|
||||
}
|
||||
return "", fmt.Errorf("invalid mount point %q", urlPath)
|
||||
}
|
||||
|
||||
func (s serveType) String() string {
|
||||
switch s {
|
||||
case serveTypeHTTP:
|
||||
return "httpListener"
|
||||
case serveTypeHTTPS:
|
||||
return "httpsListener"
|
||||
case serveTypeTCP:
|
||||
return "tcpListener"
|
||||
case serveTypeTLSTerminatedTCP:
|
||||
return "tlsTerminatedTCPListener"
|
||||
default:
|
||||
return "unknownServeType"
|
||||
}
|
||||
}
|
||||
|
||||
955
cmd/tailscale/cli/serve_dev_test.go
Normal file
955
cmd/tailscale/cli/serve_dev_test.go
Normal file
@@ -0,0 +1,955 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/types/logger"
|
||||
)
|
||||
|
||||
func TestServeDevConfigMutations(t *testing.T) {
|
||||
// Stateful mutations, starting from an empty config.
|
||||
type step struct {
|
||||
command []string // serve args; nil means no command to run (only reset)
|
||||
reset bool // if true, reset all ServeConfig state
|
||||
want *ipn.ServeConfig // non-nil means we want a save of this value
|
||||
wantErr func(error) (badErrMsg string) // nil means no error is wanted
|
||||
line int // line number of addStep call, for error messages
|
||||
|
||||
debugBreak func()
|
||||
}
|
||||
var steps []step
|
||||
add := func(s step) {
|
||||
_, _, s.line, _ = runtime.Caller(1)
|
||||
steps = append(steps, s)
|
||||
}
|
||||
|
||||
// using port number
|
||||
add(step{reset: true})
|
||||
add(step{
|
||||
command: cmd("funnel --bg 3000"),
|
||||
want: &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},
|
||||
},
|
||||
})
|
||||
|
||||
// funnel background
|
||||
add(step{reset: true})
|
||||
add(step{
|
||||
command: cmd("funnel --bg localhost:3000"),
|
||||
want: &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},
|
||||
},
|
||||
})
|
||||
|
||||
// serve background
|
||||
add(step{reset: true})
|
||||
add(step{
|
||||
command: cmd("serve --bg localhost:3000"),
|
||||
want: &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"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// --set-path runs in background
|
||||
add(step{reset: true})
|
||||
add(step{
|
||||
command: cmd("serve --set-path=/ localhost:3000"),
|
||||
want: &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"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// using http listener
|
||||
add(step{reset: true})
|
||||
add(step{
|
||||
command: cmd("serve --bg --http=80 localhost:3000"),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{80: {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"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// using https listener with a valid port
|
||||
add(step{reset: true})
|
||||
add(step{
|
||||
command: cmd("serve --bg --https=8443 localhost:3000"),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{8443: {HTTPS: true}},
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
"foo.test.ts.net:8443": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/": {Proxy: "http://127.0.0.1:3000"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// https
|
||||
add(step{reset: true})
|
||||
add(step{ // allow omitting port (default to 80)
|
||||
command: cmd("serve --http=80 --bg http://localhost:3000"),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{80: {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"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{ // support non Funnel port
|
||||
command: cmd("serve --http=9999 --set-path=/abc http://localhost:3001"),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{80: {HTTP: true}, 9999: {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"},
|
||||
}},
|
||||
"foo.test.ts.net:9999": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/abc": {Proxy: "http://127.0.0.1:3001"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{
|
||||
command: cmd("serve --http=9999 --set-path=/abc off"),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{80: {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"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{
|
||||
command: cmd("serve --http=8080 --set-path=/abc http://127.0.0.1:3001"),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{80: {HTTP: true}, 8080: {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"},
|
||||
}},
|
||||
"foo.test.ts.net:8080": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/abc": {Proxy: "http://127.0.0.1:3001"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// // https
|
||||
add(step{reset: true})
|
||||
add(step{
|
||||
command: cmd("serve --https=443 --bg http://localhost:0"), // invalid port, too low
|
||||
wantErr: anyErr(),
|
||||
})
|
||||
add(step{
|
||||
command: cmd("serve --https=443 --bg http://localhost:65536"), // invalid port, too high
|
||||
wantErr: anyErr(),
|
||||
})
|
||||
add(step{
|
||||
command: cmd("serve --https=443 --bg http://somehost:3000"), // invalid host
|
||||
wantErr: anyErr(),
|
||||
})
|
||||
add(step{
|
||||
command: cmd("serve --https=443 --bg httpz://127.0.0.1"), // invalid scheme
|
||||
wantErr: anyErr(),
|
||||
})
|
||||
add(step{ // allow omitting port (default to 443)
|
||||
command: cmd("serve --https=443 --bg http://localhost:3000"),
|
||||
want: &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"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{ // support non Funnel port
|
||||
command: cmd("serve --https=9999 --set-path=/abc http://localhost:3001"),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 9999: {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"},
|
||||
}},
|
||||
"foo.test.ts.net:9999": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/abc": {Proxy: "http://127.0.0.1:3001"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{
|
||||
command: cmd("serve --https=9999 --set-path=/abc off"),
|
||||
want: &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"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{
|
||||
command: cmd("serve --https=8443 --set-path=/abc http://127.0.0.1:3001"),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {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"},
|
||||
}},
|
||||
"foo.test.ts.net:8443": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/abc": {Proxy: "http://127.0.0.1:3001"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{
|
||||
command: cmd("serve --https=10000 --bg text:hi"),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{
|
||||
443: {HTTPS: true}, 8443: {HTTPS: true}, 10000: {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"},
|
||||
}},
|
||||
"foo.test.ts.net:8443": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/abc": {Proxy: "http://127.0.0.1:3001"},
|
||||
}},
|
||||
"foo.test.ts.net:10000": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/": {Text: "hi"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{
|
||||
command: cmd("serve --https=443 --set-path=/foo off"),
|
||||
want: nil, // nothing to save
|
||||
wantErr: anyErr(),
|
||||
}) // handler doesn't exist, so we get an error
|
||||
add(step{
|
||||
command: cmd("serve --https=10000 off"),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {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"},
|
||||
}},
|
||||
"foo.test.ts.net:8443": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/abc": {Proxy: "http://127.0.0.1:3001"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{
|
||||
command: cmd("serve --https=443 off"),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{8443: {HTTPS: true}},
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
"foo.test.ts.net:8443": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/abc": {Proxy: "http://127.0.0.1:3001"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{
|
||||
command: cmd("serve --https=8443 --set-path=/abc off"),
|
||||
want: &ipn.ServeConfig{},
|
||||
})
|
||||
add(step{ // clean mount: "bar" becomes "/bar"
|
||||
command: cmd("serve --https=443 --set-path=bar https://127.0.0.1:8443"),
|
||||
want: &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{
|
||||
"/bar": {Proxy: "https://127.0.0.1:8443"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
// add(step{
|
||||
// command: cmd("serve --https=443 --set-path=bar https://127.0.0.1:8443"),
|
||||
// want: nil, // nothing to save
|
||||
// })
|
||||
add(step{ // try resetting using reset command
|
||||
command: cmd("serve reset"),
|
||||
want: &ipn.ServeConfig{},
|
||||
})
|
||||
add(step{
|
||||
command: cmd("serve --https=443 --bg https+insecure://127.0.0.1:3001"),
|
||||
want: &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: "https+insecure://127.0.0.1:3001"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{reset: true})
|
||||
add(step{
|
||||
command: cmd("serve --https=443 --set-path=/foo localhost:3000"),
|
||||
want: &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{
|
||||
"/foo": {Proxy: "http://127.0.0.1:3000"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{ // test a second handler on the same port
|
||||
command: cmd("serve --https=8443 --set-path=/foo localhost:3000"),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {HTTPS: true}},
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/foo": {Proxy: "http://127.0.0.1:3000"},
|
||||
}},
|
||||
"foo.test.ts.net:8443": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/foo": {Proxy: "http://127.0.0.1:3000"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{reset: true})
|
||||
add(step{ // support path in proxy
|
||||
command: cmd("serve --https=443 --bg http://127.0.0.1:3000/foo/bar"),
|
||||
want: &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/foo/bar"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// // 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"),
|
||||
})
|
||||
add(step{ // bad target port, too low
|
||||
command: cmd("serve --tls-terminated-tcp=443 --bg tcp://somehost:0"),
|
||||
wantErr: exactErr(errHelp, "errHelp"),
|
||||
})
|
||||
add(step{ // bad target port, too high
|
||||
command: cmd("serve --tls-terminated-tcp=443 --bg tcp://somehost:65536"),
|
||||
wantErr: exactErr(errHelp, "errHelp"),
|
||||
})
|
||||
add(step{
|
||||
command: cmd("serve --tls-terminated-tcp=443 --bg tcp://localhost:5432"),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{
|
||||
443: {
|
||||
TCPForward: "127.0.0.1:5432",
|
||||
TerminateTLS: "foo.test.ts.net",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{
|
||||
command: cmd("serve --tls-terminated-tcp=443 --bg tcp://127.0.0.1:8443"),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{
|
||||
443: {
|
||||
TCPForward: "127.0.0.1:8443",
|
||||
TerminateTLS: "foo.test.ts.net",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
// add(step{
|
||||
// command: cmd("serve --tls-terminated-tcp=443 --bg tcp://127.0.0.1:8443"),
|
||||
// want: nil, // nothing to save
|
||||
// })
|
||||
add(step{
|
||||
command: cmd("serve --tls-terminated-tcp=443 --bg tcp://localhost:8444"),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{
|
||||
443: {
|
||||
TCPForward: "127.0.0.1:8444",
|
||||
TerminateTLS: "foo.test.ts.net",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{
|
||||
command: cmd("serve --tls-terminated-tcp=443 --bg tcp://127.0.0.1:8445"),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{
|
||||
443: {
|
||||
TCPForward: "127.0.0.1:8445",
|
||||
TerminateTLS: "foo.test.ts.net",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{reset: true})
|
||||
add(step{
|
||||
command: cmd("serve --tls-terminated-tcp=443 --bg tcp://localhost:123"),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{
|
||||
443: {
|
||||
TCPForward: "127.0.0.1:123",
|
||||
TerminateTLS: "foo.test.ts.net",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{ // handler doesn't exist, so we get an error
|
||||
command: cmd("serve --tls-terminated-tcp=8443 off"),
|
||||
wantErr: anyErr(),
|
||||
})
|
||||
add(step{
|
||||
command: cmd("serve --tls-terminated-tcp=443 off"),
|
||||
want: &ipn.ServeConfig{},
|
||||
})
|
||||
|
||||
// // text
|
||||
add(step{reset: true})
|
||||
add(step{
|
||||
command: cmd("serve --https=443 --bg text:hello"),
|
||||
want: &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{
|
||||
"/": {Text: "hello"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// path
|
||||
td := t.TempDir()
|
||||
writeFile := func(suffix, contents string) {
|
||||
if err := os.WriteFile(filepath.Join(td, suffix), []byte(contents), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
add(step{reset: true})
|
||||
writeFile("foo", "this is foo")
|
||||
add(step{
|
||||
command: cmd("serve --https=443 --bg " + filepath.Join(td, "foo")),
|
||||
want: &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{
|
||||
"/": {Path: filepath.Join(td, "foo")},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
os.MkdirAll(filepath.Join(td, "subdir"), 0700)
|
||||
writeFile("subdir/file-a", "this is A")
|
||||
add(step{
|
||||
command: cmd("serve --https=443 --set-path=/some/where " + filepath.Join(td, "subdir/file-a")),
|
||||
want: &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{
|
||||
"/": {Path: filepath.Join(td, "foo")},
|
||||
"/some/where": {Path: filepath.Join(td, "subdir/file-a")},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{ // bad path
|
||||
command: cmd("serve --https=443 --bg bad/path"),
|
||||
wantErr: exactErr(errHelp, "errHelp"),
|
||||
})
|
||||
add(step{reset: true})
|
||||
add(step{
|
||||
command: cmd("serve --https=443 --bg " + filepath.Join(td, "subdir")),
|
||||
want: &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{
|
||||
"/": {Path: filepath.Join(td, "subdir/")},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{
|
||||
command: cmd("serve --https=443 off"),
|
||||
want: &ipn.ServeConfig{},
|
||||
})
|
||||
|
||||
// // combos
|
||||
add(step{reset: true})
|
||||
add(step{
|
||||
command: cmd("serve --bg localhost:3000"),
|
||||
want: &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"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{ // enable funnel for primary port
|
||||
command: cmd("funnel --bg localhost:3000"),
|
||||
want: &ipn.ServeConfig{
|
||||
AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:443": true},
|
||||
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"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{ // serving on secondary port doesn't change funnel on primary port
|
||||
command: cmd("serve --https=8443 --set-path=/bar localhost:3001"),
|
||||
want: &ipn.ServeConfig{
|
||||
AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:443": true},
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {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"},
|
||||
}},
|
||||
"foo.test.ts.net:8443": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/bar": {Proxy: "http://127.0.0.1:3001"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{ // turn funnel on for secondary port
|
||||
command: cmd("funnel --https=8443 --set-path=/bar localhost:3001"),
|
||||
want: &ipn.ServeConfig{
|
||||
AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:443": true, "foo.test.ts.net:8443": true},
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {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"},
|
||||
}},
|
||||
"foo.test.ts.net:8443": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/bar": {Proxy: "http://127.0.0.1:3001"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
// TODO(tylersmalley) resolve these failures
|
||||
// add(step{ // turn funnel off for primary port 443
|
||||
// command: cmd("serve --https=443 --set-path=/bar localhost:3001"),
|
||||
// want: &ipn.ServeConfig{
|
||||
// AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:8443": true},
|
||||
// TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {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"},
|
||||
// }},
|
||||
// "foo.test.ts.net:8443": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
// "/bar": {Proxy: "http://127.0.0.1:3001"},
|
||||
// }},
|
||||
// },
|
||||
// },
|
||||
// })
|
||||
// add(step{ // remove secondary port
|
||||
// command: cmd("https:8443 /bar off"),
|
||||
// want: &ipn.ServeConfig{
|
||||
// AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:8443": true},
|
||||
// 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"},
|
||||
// }},
|
||||
// },
|
||||
// },
|
||||
// })
|
||||
// add(step{ // start a tcp forwarder on 8443
|
||||
// command: cmd("tcp:8443 tcp://localhost:5432"),
|
||||
// want: &ipn.ServeConfig{
|
||||
// AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:8443": true},
|
||||
// TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {TCPForward: "127.0.0.1:5432"}},
|
||||
// Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
// "foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
// "/": {Proxy: "http://127.0.0.1:3000"},
|
||||
// }},
|
||||
// },
|
||||
// },
|
||||
// })
|
||||
// add(step{ // remove primary port http handler
|
||||
// command: cmd("https:443 / off"),
|
||||
// want: &ipn.ServeConfig{
|
||||
// AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:8443": true},
|
||||
// TCP: map[uint16]*ipn.TCPPortHandler{8443: {TCPForward: "127.0.0.1:5432"}},
|
||||
// },
|
||||
// })
|
||||
// add(step{ // remove tcp forwarder
|
||||
// command: cmd("tls-terminated-tcp:8443 off"),
|
||||
// want: &ipn.ServeConfig{
|
||||
// AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:8443": true},
|
||||
// },
|
||||
// })
|
||||
// add(step{ // turn off funnel
|
||||
// command: cmd("funnel 8443 off"),
|
||||
// want: &ipn.ServeConfig{},
|
||||
// })
|
||||
|
||||
// // tricky steps
|
||||
add(step{reset: true})
|
||||
add(step{ // a directory with a trailing slash mount point
|
||||
command: cmd("serve --https=443 --set-path=/dir " + filepath.Join(td, "subdir")),
|
||||
want: &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{
|
||||
"/dir/": {Path: filepath.Join(td, "subdir/")},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{ // this should overwrite the previous one
|
||||
command: cmd("serve --https=443 --set-path=/dir " + filepath.Join(td, "foo")),
|
||||
want: &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{
|
||||
"/dir": {Path: filepath.Join(td, "foo")},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{reset: true}) // reset and do the opposite
|
||||
add(step{ // a file without a trailing slash mount point
|
||||
command: cmd("serve --https=443 --set-path=/dir " + filepath.Join(td, "foo")),
|
||||
want: &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{
|
||||
"/dir": {Path: filepath.Join(td, "foo")},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{ // this should overwrite the previous one
|
||||
command: cmd("serve --https=443 --set-path=/dir " + filepath.Join(td, "subdir")),
|
||||
want: &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{
|
||||
"/dir/": {Path: filepath.Join(td, "subdir/")},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// // error states
|
||||
add(step{reset: true})
|
||||
add(step{ // tcp forward 5432 on serve port 443
|
||||
command: cmd("serve --tls-terminated-tcp=443 --bg tcp://localhost:5432"),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{
|
||||
443: {
|
||||
TCPForward: "127.0.0.1:5432",
|
||||
TerminateTLS: "foo.test.ts.net",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{ // try to start a web handler on the same port
|
||||
command: cmd("serve --https=443 --bg localhost:3000"),
|
||||
wantErr: exactErr(errHelp, "errHelp"),
|
||||
})
|
||||
add(step{reset: true})
|
||||
add(step{ // start a web handler on port 443
|
||||
command: cmd("serve --https=443 --bg localhost:3000"),
|
||||
want: &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"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{ // try to start a tcp forwarder on the same serve port
|
||||
command: cmd("serve --tls-terminated-tcp=443 --bg tcp://localhost:5432"),
|
||||
wantErr: anyErr(),
|
||||
})
|
||||
|
||||
lc := &fakeLocalServeClient{}
|
||||
// And now run the steps above.
|
||||
for i, st := range steps {
|
||||
if st.debugBreak != nil {
|
||||
st.debugBreak()
|
||||
}
|
||||
if st.reset {
|
||||
t.Logf("Executing step #%d, line %v: [reset]", i, st.line)
|
||||
lc.config = nil
|
||||
}
|
||||
if st.command == nil {
|
||||
continue
|
||||
}
|
||||
t.Logf("Executing step #%d, line %v: %q ... ", i, st.line, st.command)
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var flagOut bytes.Buffer
|
||||
e := &serveEnv{
|
||||
lc: lc,
|
||||
testFlagOut: &flagOut,
|
||||
testStdout: &stdout,
|
||||
}
|
||||
lastCount := lc.setCount
|
||||
var cmd *ffcli.Command
|
||||
var args []string
|
||||
|
||||
mode := serve
|
||||
if st.command[0] == "funnel" {
|
||||
mode = funnel
|
||||
}
|
||||
cmd = newServeDevCommand(e, mode)
|
||||
args = st.command[1:]
|
||||
|
||||
err := cmd.ParseAndRun(context.Background(), args)
|
||||
if flagOut.Len() > 0 {
|
||||
t.Logf("flag package output: %q", flagOut.Bytes())
|
||||
}
|
||||
if err != nil {
|
||||
if st.wantErr == nil {
|
||||
t.Fatalf("step #%d, line %v: unexpected error: %v", i, st.line, err)
|
||||
}
|
||||
if bad := st.wantErr(err); bad != "" {
|
||||
t.Fatalf("step #%d, line %v: unexpected error: %v", i, st.line, bad)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if st.wantErr != nil {
|
||||
t.Fatalf("step #%d, line %v: got success (saved=%v), but wanted an error", i, st.line, lc.config != nil)
|
||||
}
|
||||
var got *ipn.ServeConfig = nil
|
||||
if lc.setCount > lastCount {
|
||||
got = lc.config
|
||||
}
|
||||
if !reflect.DeepEqual(got, st.want) {
|
||||
t.Fatalf("[%d] %v: bad state. got:\n%v\n\nwant:\n%v\n",
|
||||
i, st.command, logger.AsJSON(got), logger.AsJSON(st.want))
|
||||
// NOTE: asJSON will omit empty fields, which might make
|
||||
// result in bad state got/want diffs being the same, even
|
||||
// though the actual state is different. Use below to debug:
|
||||
// t.Fatalf("[%d] %v: bad state. got:\n%+v\n\nwant:\n%+v\n",
|
||||
// i, st.command, got, st.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSrcTypeFromFlags(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
env *serveEnv
|
||||
expectedType serveType
|
||||
expectedPort uint16
|
||||
expectedErr bool
|
||||
}{
|
||||
{
|
||||
name: "only http set",
|
||||
env: &serveEnv{http: "80"},
|
||||
expectedType: serveTypeHTTP,
|
||||
expectedPort: 80,
|
||||
expectedErr: false,
|
||||
},
|
||||
{
|
||||
name: "only https set",
|
||||
env: &serveEnv{https: "10000"},
|
||||
expectedType: serveTypeHTTPS,
|
||||
expectedPort: 10000,
|
||||
expectedErr: false,
|
||||
},
|
||||
{
|
||||
name: "only tcp set",
|
||||
env: &serveEnv{tcp: "8000"},
|
||||
expectedType: serveTypeTCP,
|
||||
expectedPort: 8000,
|
||||
expectedErr: false,
|
||||
},
|
||||
{
|
||||
name: "only tls-terminated-tcp set",
|
||||
env: &serveEnv{tlsTerminatedTCP: "8080"},
|
||||
expectedType: serveTypeTLSTerminatedTCP,
|
||||
expectedPort: 8080,
|
||||
expectedErr: false,
|
||||
},
|
||||
{
|
||||
name: "defaults to https, port 443",
|
||||
env: &serveEnv{},
|
||||
expectedType: serveTypeHTTPS,
|
||||
expectedPort: 443,
|
||||
expectedErr: false,
|
||||
},
|
||||
{
|
||||
name: "multiple types set",
|
||||
env: &serveEnv{http: "80", https: "443"},
|
||||
expectedPort: 0,
|
||||
expectedErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
srcType, srcPort, err := srvTypeAndPortFromFlags(tt.env)
|
||||
if (err != nil) != tt.expectedErr {
|
||||
t.Errorf("Expected error: %v, got: %v", tt.expectedErr, err)
|
||||
}
|
||||
if srcType != tt.expectedType {
|
||||
t.Errorf("Expected srcType: %s, got: %s", tt.expectedType.String(), srcType)
|
||||
}
|
||||
if srcPort != tt.expectedPort {
|
||||
t.Errorf("Expected srcPort: %d, got: %d", tt.expectedPort, srcPort)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandProxyTargetDev(t *testing.T) {
|
||||
tests := []struct {
|
||||
input 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"},
|
||||
|
||||
// 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},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
actual, err := expandProxyTargetDev(tt.input)
|
||||
|
||||
if tt.wantErr == true && err == nil {
|
||||
t.Errorf("Expected an error but got none")
|
||||
return
|
||||
}
|
||||
|
||||
if tt.wantErr == false && err != nil {
|
||||
t.Errorf("Got an error, but didn't expect one: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if actual != tt.expected {
|
||||
t.Errorf("Got: %q; expected: %q", actual, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanURLPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
wantErr bool
|
||||
}{
|
||||
{input: "", expected: "/"},
|
||||
{input: "/", expected: "/"},
|
||||
{input: "/foo", expected: "/foo"},
|
||||
{input: "/foo/", expected: "/foo/"},
|
||||
{input: "/../bar", wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
actual, err := cleanURLPath(tt.input)
|
||||
|
||||
if tt.wantErr == true && err == nil {
|
||||
t.Errorf("Expected an error but got none")
|
||||
return
|
||||
}
|
||||
|
||||
if tt.wantErr == false && err != nil {
|
||||
t.Errorf("Got an error, but didn't expect one: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if actual != tt.expected {
|
||||
t.Errorf("Got: %q; expected: %q", actual, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsLegacyInvocation(t *testing.T) {
|
||||
tests := []struct {
|
||||
subcmd serveMode
|
||||
args []string
|
||||
expected bool
|
||||
}{
|
||||
{subcmd: serve, args: []string{"https", "localhost:3000"}, expected: true},
|
||||
{subcmd: serve, args: []string{"https:8443", "localhost:3000"}, expected: true},
|
||||
{subcmd: serve, args: []string{"http", "localhost:3000"}, expected: true},
|
||||
{subcmd: serve, args: []string{"http:80", "localhost:3000"}, expected: true},
|
||||
{subcmd: serve, args: []string{"tcp:2222", "tcp://localhost:22"}, expected: true},
|
||||
{subcmd: serve, args: []string{"tls-terminated-tcp:443", "tcp://localhost:80"}, expected: true},
|
||||
|
||||
// false
|
||||
{subcmd: serve, args: []string{"3000"}, expected: false},
|
||||
{subcmd: serve, args: []string{"localhost:3000"}, expected: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
args := strings.Join(tt.args, " ")
|
||||
t.Run(fmt.Sprintf("%v %s", infoMap[tt.subcmd].Name, args), func(t *testing.T) {
|
||||
actual := isLegacyInvocation(tt.subcmd, tt.args)
|
||||
|
||||
if actual != tt.expected {
|
||||
t.Errorf("Got: %v; expected: %v", actual, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
@@ -339,19 +338,19 @@ func TestServeConfigMutations(t *testing.T) {
|
||||
add(step{reset: true})
|
||||
add(step{ // must include scheme for tcp
|
||||
command: cmd("tls-terminated-tcp:443 localhost:5432"),
|
||||
wantErr: exactErr(flag.ErrHelp, "flag.ErrHelp"),
|
||||
wantErr: exactErr(errHelp, "errHelp"),
|
||||
})
|
||||
add(step{ // !somehost, must be localhost or 127.0.0.1
|
||||
command: cmd("tls-terminated-tcp:443 tcp://somehost:5432"),
|
||||
wantErr: exactErr(flag.ErrHelp, "flag.ErrHelp"),
|
||||
wantErr: exactErr(errHelp, "errHelp"),
|
||||
})
|
||||
add(step{ // bad target port, too low
|
||||
command: cmd("tls-terminated-tcp:443 tcp://somehost:0"),
|
||||
wantErr: exactErr(flag.ErrHelp, "flag.ErrHelp"),
|
||||
wantErr: exactErr(errHelp, "errHelp"),
|
||||
})
|
||||
add(step{ // bad target port, too high
|
||||
command: cmd("tls-terminated-tcp:443 tcp://somehost:65536"),
|
||||
wantErr: exactErr(flag.ErrHelp, "flag.ErrHelp"),
|
||||
wantErr: exactErr(errHelp, "errHelp"),
|
||||
})
|
||||
add(step{
|
||||
command: cmd("tls-terminated-tcp:443 tcp://localhost:5432"),
|
||||
@@ -472,7 +471,7 @@ func TestServeConfigMutations(t *testing.T) {
|
||||
})
|
||||
add(step{ // bad path
|
||||
command: cmd("https:443 / bad/path"),
|
||||
wantErr: exactErr(flag.ErrHelp, "flag.ErrHelp"),
|
||||
wantErr: exactErr(errHelp, "errHelp"),
|
||||
})
|
||||
add(step{reset: true})
|
||||
add(step{
|
||||
@@ -666,7 +665,7 @@ func TestServeConfigMutations(t *testing.T) {
|
||||
})
|
||||
add(step{ // try to start a web handler on the same port
|
||||
command: cmd("https:443 / localhost:3000"),
|
||||
wantErr: exactErr(flag.ErrHelp, "flag.ErrHelp"),
|
||||
wantErr: exactErr(errHelp, "errHelp"),
|
||||
})
|
||||
add(step{reset: true})
|
||||
add(step{ // start a web handler on port 443
|
||||
@@ -902,11 +901,6 @@ func (lc *fakeLocalServeClient) IncrementCounter(ctx context.Context, name strin
|
||||
return nil // unused in tests
|
||||
}
|
||||
|
||||
func (lc *fakeLocalServeClient) StreamServe(ctx context.Context, req ipn.ServeStreamRequest) (io.ReadCloser, error) {
|
||||
// TODO: testing :)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// exactError returns an error checker that wants exactly the provided want error.
|
||||
// If optName is non-empty, it's used in the error message.
|
||||
func exactErr(want error, optName ...string) func(error) string {
|
||||
|
||||
@@ -236,6 +236,9 @@ func runStatus(ctx context.Context, args []string) error {
|
||||
printHealth()
|
||||
}
|
||||
printFunnelStatus(ctx)
|
||||
if cv := st.ClientVersion; cv != nil && !cv.RunningLatest && cv.LatestVersion != "" {
|
||||
printf("# New Tailscale version is available: %q, run `tailscale update` to update.\n", cv.LatestVersion)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ Tailscale, as opposed to a CLI or a native app.
|
||||
webf.StringVar(&webArgs.listen, "listen", "localhost:8088", "listen address; use port 0 for automatic")
|
||||
webf.BoolVar(&webArgs.cgi, "cgi", false, "run as CGI script")
|
||||
webf.BoolVar(&webArgs.dev, "dev", false, "run web client in developer mode [this flag is in development, use is unsupported]")
|
||||
webf.StringVar(&webArgs.prefix, "prefix", "", "URL prefix added to requests (for cgi or reverse proxies)")
|
||||
return webf
|
||||
})(),
|
||||
Exec: runWeb,
|
||||
@@ -48,6 +49,7 @@ var webArgs struct {
|
||||
listen string
|
||||
cgi bool
|
||||
dev bool
|
||||
prefix string
|
||||
}
|
||||
|
||||
func tlsConfigFromEnvironment() *tls.Config {
|
||||
@@ -81,6 +83,7 @@ func runWeb(ctx context.Context, args []string) error {
|
||||
webServer, cleanup := web.NewServer(ctx, web.ServerOpts{
|
||||
DevMode: webArgs.dev,
|
||||
CGIMode: webArgs.cgi,
|
||||
PathPrefix: webArgs.prefix,
|
||||
LocalClient: &localClient,
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
@@ -95,7 +95,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
L github.com/google/nftables/expr from github.com/google/nftables+
|
||||
L github.com/google/nftables/internal/parseexprfunc from github.com/google/nftables+
|
||||
L github.com/google/nftables/xt from github.com/google/nftables/expr+
|
||||
github.com/google/uuid from tailscale.com/ipn/ipnlocal+
|
||||
github.com/google/uuid from tailscale.com/clientupdate
|
||||
github.com/hdevalence/ed25519consensus from tailscale.com/tka+
|
||||
L 💣 github.com/illarion/gonotify from tailscale.com/net/dns
|
||||
L github.com/insomniacslk/dhcp/dhcpv4 from tailscale.com/net/tstun
|
||||
@@ -290,7 +290,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
tailscale.com/paths from tailscale.com/ipn/ipnlocal+
|
||||
💣 tailscale.com/portlist from tailscale.com/ipn/ipnlocal
|
||||
tailscale.com/safesocket from tailscale.com/client/tailscale+
|
||||
tailscale.com/smallzstd from tailscale.com/cmd/tailscaled+
|
||||
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+
|
||||
@@ -315,7 +315,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
tailscale.com/types/netlogtype from tailscale.com/net/connstats+
|
||||
tailscale.com/types/netmap from tailscale.com/control/controlclient+
|
||||
tailscale.com/types/nettype from tailscale.com/wgengine/magicsock+
|
||||
tailscale.com/types/opt from tailscale.com/control/controlclient+
|
||||
tailscale.com/types/opt from tailscale.com/client/tailscale+
|
||||
tailscale.com/types/persist from tailscale.com/control/controlclient+
|
||||
tailscale.com/types/preftype from tailscale.com/ipn+
|
||||
tailscale.com/types/ptr from tailscale.com/hostinfo+
|
||||
@@ -343,6 +343,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
tailscale.com/util/osshare from tailscale.com/ipn/ipnlocal+
|
||||
W tailscale.com/util/pidowner from tailscale.com/ipn/ipnauth
|
||||
tailscale.com/util/racebuild from tailscale.com/logpolicy
|
||||
tailscale.com/util/rands from tailscale.com/ipn/localapi+
|
||||
tailscale.com/util/ringbuffer from tailscale.com/wgengine/magicsock
|
||||
tailscale.com/util/set from tailscale.com/health+
|
||||
tailscale.com/util/singleflight from tailscale.com/control/controlclient+
|
||||
|
||||
@@ -48,7 +48,6 @@ import (
|
||||
"tailscale.com/net/tstun"
|
||||
"tailscale.com/paths"
|
||||
"tailscale.com/safesocket"
|
||||
"tailscale.com/smallzstd"
|
||||
"tailscale.com/syncs"
|
||||
"tailscale.com/tsd"
|
||||
"tailscale.com/tsweb/varz"
|
||||
@@ -497,6 +496,7 @@ func getLocalBackend(ctx context.Context, logf logger.Logf, logID logid.PublicID
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("newNetstack: %w", err)
|
||||
}
|
||||
sys.Set(ns)
|
||||
ns.ProcessLocalIPs = onlyNetstack
|
||||
ns.ProcessSubnets = onlyNetstack || handleSubnetsInNetstack()
|
||||
|
||||
@@ -551,9 +551,6 @@ func getLocalBackend(ctx context.Context, logf logger.Logf, logID logid.PublicID
|
||||
if root := lb.TailscaleVarRoot(); root != "" {
|
||||
dnsfallback.SetCachePath(filepath.Join(root, "derpmap.cached.json"), logf)
|
||||
}
|
||||
lb.SetDecompressor(func() (controlclient.Decompressor, error) {
|
||||
return smallzstd.NewDecoder(nil)
|
||||
})
|
||||
configureTaildrop(logf, lb)
|
||||
if err := ns.Start(lb); err != nil {
|
||||
log.Fatalf("failed to start netstack: %v", err)
|
||||
@@ -611,6 +608,7 @@ func tryEngine(logf logger.Logf, sys *tsd.System, name string) (onlyNetstack boo
|
||||
NetMon: sys.NetMon.Get(),
|
||||
Dialer: sys.Dialer.Get(),
|
||||
SetSubsystem: sys.Set,
|
||||
ControlKnobs: sys.ControlKnobs(),
|
||||
}
|
||||
|
||||
onlyNetstack = name == "userspace-networking"
|
||||
|
||||
@@ -19,7 +19,8 @@ import (
|
||||
const FlakyTestLogMessage = "flakytest: this is a known flaky test"
|
||||
|
||||
// FlakeAttemptEnv is an environment variable that is set by cmd/testwrapper
|
||||
// when a flaky test is retried. It contains the attempt number, starting at 1.
|
||||
// when a flaky test is being (re)tried. It contains the attempt number,
|
||||
// starting at 1.
|
||||
const FlakeAttemptEnv = "TS_TESTWRAPPER_ATTEMPT"
|
||||
|
||||
var issueRegexp = regexp.MustCompile(`\Ahttps://github\.com/tailscale/[a-zA-Z0-9_.-]+/issues/\d+\z`)
|
||||
@@ -33,7 +34,11 @@ func Mark(t testing.TB, issue string) {
|
||||
if !issueRegexp.MatchString(issue) {
|
||||
t.Fatalf("bad issue format: %q", issue)
|
||||
}
|
||||
|
||||
fmt.Fprintln(os.Stderr, FlakyTestLogMessage) // sentinel value for testwrapper
|
||||
if _, ok := os.LookupEnv(FlakeAttemptEnv); ok {
|
||||
// We're being run under cmd/testwrapper so send our sentinel message
|
||||
// to stderr. (We avoid doing this when the env is absent to avoid
|
||||
// spamming people running tests without the wrapper)
|
||||
fmt.Fprintln(os.Stderr, FlakyTestLogMessage)
|
||||
}
|
||||
t.Logf("flakytest: issue tracking this flaky test: %s", issue)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
@@ -29,7 +30,8 @@ import (
|
||||
const maxAttempts = 3
|
||||
|
||||
type testAttempt struct {
|
||||
name testName
|
||||
pkg string // "tailscale.com/types/key"
|
||||
testName string // "TestFoo"
|
||||
outcome string // "pass", "fail", "skip"
|
||||
logs bytes.Buffer
|
||||
isMarkedFlaky bool // set if the test is marked as flaky
|
||||
@@ -37,11 +39,6 @@ type testAttempt struct {
|
||||
pkgFinished bool
|
||||
}
|
||||
|
||||
type testName struct {
|
||||
pkg string // "tailscale.com/types/key"
|
||||
name string // "TestFoo"
|
||||
}
|
||||
|
||||
type packageTests struct {
|
||||
// pattern is the package pattern to run.
|
||||
// Must be a single pattern, not a list of patterns.
|
||||
@@ -63,9 +60,10 @@ var debug = os.Getenv("TS_TESTWRAPPER_DEBUG") != ""
|
||||
|
||||
// runTests runs the tests in pt and sends the results on ch. It sends a
|
||||
// testAttempt for each test and a final testAttempt per pkg with pkgFinished
|
||||
// set to true.
|
||||
// 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) {
|
||||
func runTests(ctx context.Context, attempt int, pt *packageTests, otherArgs []string, ch chan<- *testAttempt) error {
|
||||
defer close(ch)
|
||||
args := []string{"test", "-json", pt.pattern}
|
||||
args = append(args, otherArgs...)
|
||||
@@ -91,17 +89,12 @@ func runTests(ctx context.Context, attempt int, pt *packageTests, otherArgs []st
|
||||
log.Printf("error starting test: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
cmd.Wait()
|
||||
}()
|
||||
|
||||
jd := json.NewDecoder(r)
|
||||
resultMap := make(map[testName]*testAttempt)
|
||||
for {
|
||||
s := bufio.NewScanner(r)
|
||||
resultMap := make(map[string]map[string]*testAttempt) // pkg -> test -> testAttempt
|
||||
for s.Scan() {
|
||||
var goOutput goTestOutput
|
||||
if err := jd.Decode(&goOutput); err != nil {
|
||||
if err := json.Unmarshal(s.Bytes(), &goOutput); err != nil {
|
||||
if errors.Is(err, io.EOF) || errors.Is(err, os.ErrClosed) {
|
||||
break
|
||||
}
|
||||
@@ -111,32 +104,39 @@ func runTests(ctx context.Context, attempt int, pt *packageTests, otherArgs []st
|
||||
// The build error will be printed to stderr.
|
||||
// See: https://github.com/golang/go/issues/35169
|
||||
if _, ok := err.(*json.SyntaxError); ok {
|
||||
jd = json.NewDecoder(r)
|
||||
fmt.Println(s.Text())
|
||||
continue
|
||||
}
|
||||
panic(err)
|
||||
}
|
||||
pkg := goOutput.Package
|
||||
pkgTests := resultMap[pkg]
|
||||
if goOutput.Test == "" {
|
||||
switch goOutput.Action {
|
||||
case "fail", "pass", "skip":
|
||||
for _, test := range pkgTests {
|
||||
if test.outcome == "" {
|
||||
test.outcome = "fail"
|
||||
ch <- test
|
||||
}
|
||||
}
|
||||
ch <- &testAttempt{
|
||||
name: testName{
|
||||
pkg: goOutput.Package,
|
||||
},
|
||||
pkg: goOutput.Package,
|
||||
outcome: goOutput.Action,
|
||||
pkgFinished: true,
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
name := testName{
|
||||
pkg: goOutput.Package,
|
||||
name: goOutput.Test,
|
||||
if pkgTests == nil {
|
||||
pkgTests = make(map[string]*testAttempt)
|
||||
resultMap[pkg] = pkgTests
|
||||
}
|
||||
testName := goOutput.Test
|
||||
if test, _, isSubtest := strings.Cut(goOutput.Test, "/"); isSubtest {
|
||||
name.name = test
|
||||
testName = test
|
||||
if goOutput.Action == "output" {
|
||||
resultMap[name].logs.WriteString(goOutput.Output)
|
||||
resultMap[pkg][testName].logs.WriteString(goOutput.Output)
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -144,21 +144,28 @@ func runTests(ctx context.Context, attempt int, pt *packageTests, otherArgs []st
|
||||
case "start":
|
||||
// ignore
|
||||
case "run":
|
||||
resultMap[name] = &testAttempt{
|
||||
name: name,
|
||||
pkgTests[testName] = &testAttempt{
|
||||
pkg: pkg,
|
||||
testName: testName,
|
||||
}
|
||||
case "skip", "pass", "fail":
|
||||
resultMap[name].outcome = goOutput.Action
|
||||
ch <- resultMap[name]
|
||||
pkgTests[testName].outcome = goOutput.Action
|
||||
ch <- pkgTests[testName]
|
||||
case "output":
|
||||
if strings.TrimSpace(goOutput.Output) == flakytest.FlakyTestLogMessage {
|
||||
resultMap[name].isMarkedFlaky = true
|
||||
pkgTests[testName].isMarkedFlaky = true
|
||||
} else {
|
||||
resultMap[name].logs.WriteString(goOutput.Output)
|
||||
pkgTests[testName].logs.WriteString(goOutput.Output)
|
||||
}
|
||||
}
|
||||
}
|
||||
<-done
|
||||
if err := cmd.Wait(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.Err(); err != nil {
|
||||
return fmt.Errorf("reading go test stdout: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -240,20 +247,32 @@ func main() {
|
||||
fmt.Printf("\n\nAttempt #%d: Retrying flaky tests:\n\n", thisRun.attempt)
|
||||
}
|
||||
|
||||
failed := false
|
||||
toRetry := make(map[string][]string) // pkg -> tests to retry
|
||||
for _, pt := range thisRun.tests {
|
||||
ch := make(chan *testAttempt)
|
||||
go runTests(ctx, thisRun.attempt, pt, otherArgs, ch)
|
||||
runErr := make(chan error, 1)
|
||||
go func() {
|
||||
defer close(runErr)
|
||||
runErr <- runTests(ctx, thisRun.attempt, pt, otherArgs, ch)
|
||||
}()
|
||||
|
||||
var failed bool
|
||||
for tr := range ch {
|
||||
// Go assigns the package name "command-line-arguments" when you
|
||||
// `go test FILE` rather than `go test PKG`. It's more
|
||||
// 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
|
||||
}
|
||||
if tr.pkgFinished {
|
||||
if tr.outcome == "fail" && len(toRetry[tr.name.pkg]) == 0 {
|
||||
if tr.outcome == "fail" && len(toRetry[tr.pkg]) == 0 {
|
||||
// If a package fails and we don't have any tests to
|
||||
// retry, then we should fail. This typically happens
|
||||
// when a package times out.
|
||||
failed = true
|
||||
}
|
||||
printPkgOutcome(tr.name.pkg, tr.outcome, thisRun.attempt)
|
||||
printPkgOutcome(tr.pkg, tr.outcome, thisRun.attempt)
|
||||
continue
|
||||
}
|
||||
if *v || tr.outcome == "fail" {
|
||||
@@ -263,15 +282,28 @@ func main() {
|
||||
continue
|
||||
}
|
||||
if tr.isMarkedFlaky {
|
||||
toRetry[tr.name.pkg] = append(toRetry[tr.name.pkg], tr.name.name)
|
||||
toRetry[tr.pkg] = append(toRetry[tr.pkg], tr.testName)
|
||||
} else {
|
||||
failed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if failed {
|
||||
fmt.Println("\n\nNot retrying flaky tests because non-flaky tests failed.")
|
||||
os.Exit(1)
|
||||
if failed {
|
||||
fmt.Println("\n\nNot retrying flaky tests because non-flaky tests failed.")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// If there's nothing to retry and no non-retryable tests have
|
||||
// failed then we've probably hit a build error.
|
||||
if err := <-runErr; len(toRetry) == 0 && err != nil {
|
||||
var exit *exec.ExitError
|
||||
if errors.As(err, &exit) {
|
||||
if code := exit.ExitCode(); code > -1 {
|
||||
os.Exit(exit.ExitCode())
|
||||
}
|
||||
}
|
||||
log.Printf("testwrapper: %s", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
if len(toRetry) == 0 {
|
||||
continue
|
||||
|
||||
218
cmd/testwrapper/testwrapper_test.go
Normal file
218
cmd/testwrapper/testwrapper_test.go
Normal file
@@ -0,0 +1,218 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package main_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var (
|
||||
buildPath string
|
||||
buildErr error
|
||||
buildOnce sync.Once
|
||||
)
|
||||
|
||||
func cmdTestwrapper(t *testing.T, args ...string) *exec.Cmd {
|
||||
buildOnce.Do(func() {
|
||||
buildPath, buildErr = buildTestWrapper()
|
||||
})
|
||||
if buildErr != nil {
|
||||
t.Fatalf("building testwrapper: %s", buildErr)
|
||||
}
|
||||
return exec.Command(buildPath, args...)
|
||||
}
|
||||
|
||||
func buildTestWrapper() (string, error) {
|
||||
dir, err := os.MkdirTemp("", "testwrapper")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("making temp dir: %w", err)
|
||||
}
|
||||
_, err = exec.Command("go", "build", "-o", dir, ".").Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("go build: %w", err)
|
||||
}
|
||||
return filepath.Join(dir, "testwrapper"), nil
|
||||
}
|
||||
|
||||
func TestRetry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testfile := filepath.Join(t.TempDir(), "retry_test.go")
|
||||
code := []byte(`package retry_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"tailscale.com/cmd/testwrapper/flakytest"
|
||||
)
|
||||
|
||||
func TestOK(t *testing.T) {}
|
||||
|
||||
func TestFlakeRun(t *testing.T) {
|
||||
flakytest.Mark(t, "https://github.com/tailscale/tailscale/issues/0") // random issue
|
||||
e := os.Getenv(flakytest.FlakeAttemptEnv)
|
||||
if e == "" {
|
||||
t.Skip("not running in testwrapper")
|
||||
}
|
||||
if e == "1" {
|
||||
t.Fatal("First run in testwrapper, failing so that test is retried. This is expected.")
|
||||
}
|
||||
}
|
||||
`)
|
||||
if err := os.WriteFile(testfile, code, 0o644); err != nil {
|
||||
t.Fatalf("writing package: %s", err)
|
||||
}
|
||||
|
||||
out, err := cmdTestwrapper(t, "-v", testfile).CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("go run . %s: %s with output:\n%s", testfile, err, out)
|
||||
}
|
||||
|
||||
want := []byte("ok\t" + testfile + " [attempt=2]")
|
||||
if !bytes.Contains(out, want) {
|
||||
t.Fatalf("wanted output containing %q but got:\n%s", want, out)
|
||||
}
|
||||
|
||||
if okRuns := bytes.Count(out, []byte("=== RUN TestOK")); okRuns != 1 {
|
||||
t.Fatalf("expected TestOK to be run once but was run %d times in output:\n%s", okRuns, out)
|
||||
}
|
||||
if flakeRuns := bytes.Count(out, []byte("=== RUN TestFlakeRun")); flakeRuns != 2 {
|
||||
t.Fatalf("expected TestFlakeRun to be run twice but was run %d times in output:\n%s", flakeRuns, out)
|
||||
}
|
||||
|
||||
if testing.Verbose() {
|
||||
t.Logf("success - output:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoRetry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testfile := filepath.Join(t.TempDir(), "noretry_test.go")
|
||||
code := []byte(`package noretry_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"tailscale.com/cmd/testwrapper/flakytest"
|
||||
)
|
||||
|
||||
func TestFlakeRun(t *testing.T) {
|
||||
flakytest.Mark(t, "https://github.com/tailscale/tailscale/issues/0") // random issue
|
||||
t.Error("shouldn't be retried")
|
||||
}
|
||||
|
||||
func TestAlwaysError(t *testing.T) {
|
||||
t.Error("error")
|
||||
}
|
||||
`)
|
||||
if err := os.WriteFile(testfile, code, 0o644); err != nil {
|
||||
t.Fatalf("writing package: %s", err)
|
||||
}
|
||||
|
||||
out, err := cmdTestwrapper(t, "-v", testfile).Output()
|
||||
if err == nil {
|
||||
t.Fatalf("go run . %s: expected error but it succeeded with output:\n%s", testfile, out)
|
||||
}
|
||||
if code, ok := errExitCode(err); ok && code != 1 {
|
||||
t.Fatalf("expected exit code 1 but got %d", code)
|
||||
}
|
||||
|
||||
want := []byte("Not retrying flaky tests because non-flaky tests failed.")
|
||||
if !bytes.Contains(out, want) {
|
||||
t.Fatalf("wanted output containing %q but got:\n%s", want, out)
|
||||
}
|
||||
|
||||
if flakeRuns := bytes.Count(out, []byte("=== RUN TestFlakeRun")); flakeRuns != 1 {
|
||||
t.Fatalf("expected TestFlakeRun to be run once but was run %d times in output:\n%s", flakeRuns, out)
|
||||
}
|
||||
|
||||
if testing.Verbose() {
|
||||
t.Logf("success - output:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Construct our broken package.
|
||||
testfile := filepath.Join(t.TempDir(), "builderror_test.go")
|
||||
code := []byte("package builderror_test\n\nderp")
|
||||
err := os.WriteFile(testfile, code, 0o644)
|
||||
if err != nil {
|
||||
t.Fatalf("writing package: %s", err)
|
||||
}
|
||||
|
||||
buildErr := []byte("builderror_test.go:3:1: expected declaration, found derp\nFAIL command-line-arguments [setup failed]")
|
||||
|
||||
// Confirm `go test` exits with code 1.
|
||||
goOut, err := exec.Command("go", "test", testfile).CombinedOutput()
|
||||
if code, ok := errExitCode(err); !ok || code != 1 {
|
||||
t.Fatalf("go test %s: expected error with exit code 0 but got: %v", testfile, err)
|
||||
}
|
||||
if !bytes.Contains(goOut, buildErr) {
|
||||
t.Fatalf("go test %s: expected build error containing %q but got:\n%s", testfile, buildErr, goOut)
|
||||
}
|
||||
|
||||
// Confirm `testwrapper` exits with code 1.
|
||||
twOut, err := cmdTestwrapper(t, testfile).CombinedOutput()
|
||||
if code, ok := errExitCode(err); !ok || code != 1 {
|
||||
t.Fatalf("testwrapper %s: expected error with exit code 0 but got: %v", testfile, err)
|
||||
}
|
||||
if !bytes.Contains(twOut, buildErr) {
|
||||
t.Fatalf("testwrapper %s: expected build error containing %q but got:\n%s", testfile, buildErr, twOut)
|
||||
}
|
||||
|
||||
if testing.Verbose() {
|
||||
t.Logf("success - output:\n%s", twOut)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeout(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Construct our broken package.
|
||||
testfile := filepath.Join(t.TempDir(), "timeout_test.go")
|
||||
code := []byte(`package noretry_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTimeout(t *testing.T) {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
`)
|
||||
err := os.WriteFile(testfile, code, 0o644)
|
||||
if err != nil {
|
||||
t.Fatalf("writing package: %s", err)
|
||||
}
|
||||
|
||||
out, err := cmdTestwrapper(t, testfile, "-timeout=20ms").CombinedOutput()
|
||||
if code, ok := errExitCode(err); !ok || code != 1 {
|
||||
t.Fatalf("testwrapper %s: expected error with exit code 0 but got: %v; output was:\n%s", testfile, err, out)
|
||||
}
|
||||
if want := "panic: test timed out after 20ms"; !bytes.Contains(out, []byte(want)) {
|
||||
t.Fatalf("testwrapper %s: expected build error containing %q but got:\n%s", testfile, buildErr, out)
|
||||
}
|
||||
|
||||
if testing.Verbose() {
|
||||
t.Logf("success - output:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func errExitCode(err error) (int, bool) {
|
||||
var exit *exec.ExitError
|
||||
if errors.As(err, &exit) {
|
||||
return exit.ExitCode(), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
@@ -35,7 +35,6 @@ import (
|
||||
"tailscale.com/net/netns"
|
||||
"tailscale.com/net/tsdial"
|
||||
"tailscale.com/safesocket"
|
||||
"tailscale.com/smallzstd"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/tsd"
|
||||
"tailscale.com/wgengine"
|
||||
@@ -103,6 +102,7 @@ func newIPN(jsConfig js.Value) map[string]any {
|
||||
eng, err := wgengine.NewUserspaceEngine(logf, wgengine.Config{
|
||||
Dialer: dialer,
|
||||
SetSubsystem: sys.Set,
|
||||
ControlKnobs: sys.ControlKnobs(),
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
@@ -113,6 +113,7 @@ func newIPN(jsConfig js.Value) map[string]any {
|
||||
if err != nil {
|
||||
log.Fatalf("netstack.Create: %v", err)
|
||||
}
|
||||
sys.Set(ns)
|
||||
ns.ProcessLocalIPs = true
|
||||
ns.ProcessSubnets = true
|
||||
|
||||
@@ -133,9 +134,6 @@ func newIPN(jsConfig js.Value) map[string]any {
|
||||
if err := ns.Start(lb); err != nil {
|
||||
log.Fatalf("failed to start netstack: %v", err)
|
||||
}
|
||||
lb.SetDecompressor(func() (controlclient.Decompressor, error) {
|
||||
return smallzstd.NewDecoder(nil)
|
||||
})
|
||||
srv.SetLocalBackend(lb)
|
||||
|
||||
jsIPN := &jsIPN{
|
||||
|
||||
@@ -38,7 +38,7 @@ var _ Client = (*Auto)(nil)
|
||||
// closed).
|
||||
func (c *Auto) waitUnpause(routineLogName string) (keepRunning bool) {
|
||||
c.mu.Lock()
|
||||
if !c.paused {
|
||||
if !c.paused || c.closed {
|
||||
defer c.mu.Unlock()
|
||||
return !c.closed
|
||||
}
|
||||
@@ -432,6 +432,8 @@ type mapRoutineState struct {
|
||||
bo *backoff.Backoff
|
||||
}
|
||||
|
||||
var _ NetmapDeltaUpdater = mapRoutineState{}
|
||||
|
||||
func (mrs mapRoutineState) UpdateFullNetmap(nm *netmap.NetworkMap) {
|
||||
c := mrs.c
|
||||
|
||||
@@ -453,6 +455,28 @@ func (mrs mapRoutineState) UpdateFullNetmap(nm *netmap.NetworkMap) {
|
||||
mrs.bo.BackOff(ctx, nil)
|
||||
}
|
||||
|
||||
func (mrs mapRoutineState) UpdateNetmapDelta(muts []netmap.NodeMutation) bool {
|
||||
c := mrs.c
|
||||
|
||||
c.mu.Lock()
|
||||
goodState := c.loggedIn && c.inMapPoll
|
||||
ndu, canDelta := c.observer.(NetmapDeltaUpdater)
|
||||
c.mu.Unlock()
|
||||
|
||||
if !goodState || !canDelta {
|
||||
return false
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.mapCtx, 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var ok bool
|
||||
err := c.observerQueue.RunSync(ctx, func() {
|
||||
ok = ndu.UpdateNetmapDelta(muts)
|
||||
})
|
||||
return err == nil && ok
|
||||
}
|
||||
|
||||
// mapRoutine is responsible for keeping a read-only streaming connection to the
|
||||
// control server, and keeping the netmap up to date.
|
||||
func (c *Auto) mapRoutine() {
|
||||
@@ -595,7 +619,7 @@ func (c *Auto) sendStatus(who string, err error, url string, nm *netmap.NetworkM
|
||||
// Launch a new goroutine to avoid blocking the caller while the observer
|
||||
// does its thing, which may result in a call back into the client.
|
||||
c.observerQueue.Add(func() {
|
||||
c.observer.SetControlClientStatus(new)
|
||||
c.observer.SetControlClientStatus(c, new)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -667,6 +691,7 @@ func (c *Auto) Shutdown() {
|
||||
direct := c.direct
|
||||
if !closed {
|
||||
c.closed = true
|
||||
c.observerQueue.shutdown()
|
||||
c.cancelAuthCtxLocked()
|
||||
c.cancelMapCtxLocked()
|
||||
for _, w := range c.unpauseWaiters {
|
||||
@@ -751,6 +776,27 @@ func (q *execQueue) Add(f func()) {
|
||||
}
|
||||
}
|
||||
|
||||
// RunSync waits for the queue to be drained and then synchronously runs f.
|
||||
// It returns an error if the queue is closed before f is run or ctx expires.
|
||||
func (q *execQueue) RunSync(ctx context.Context, f func()) error {
|
||||
for {
|
||||
if err := q.wait(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
q.mu.Lock()
|
||||
if q.inFlight {
|
||||
q.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
defer q.mu.Unlock()
|
||||
if q.closed {
|
||||
return errors.New("closed")
|
||||
}
|
||||
f()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (q *execQueue) run(f func()) {
|
||||
f()
|
||||
|
||||
|
||||
@@ -25,6 +25,9 @@ const (
|
||||
// Client represents a client connection to the control server.
|
||||
// Currently this is done through a pair of polling https requests in
|
||||
// the Auto client, but that might change eventually.
|
||||
//
|
||||
// The Client must be comparable as it is used by the Observer to detect stale
|
||||
// clients.
|
||||
type Client interface {
|
||||
// Shutdown closes this session, which should not be used any further
|
||||
// afterwards.
|
||||
|
||||
@@ -25,7 +25,6 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"go4.org/mem"
|
||||
@@ -43,14 +42,13 @@ import (
|
||||
"tailscale.com/net/tlsdial"
|
||||
"tailscale.com/net/tsdial"
|
||||
"tailscale.com/net/tshttpproxy"
|
||||
"tailscale.com/syncs"
|
||||
"tailscale.com/smallzstd"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/tka"
|
||||
"tailscale.com/tstime"
|
||||
"tailscale.com/types/key"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/types/netmap"
|
||||
"tailscale.com/types/opt"
|
||||
"tailscale.com/types/persist"
|
||||
"tailscale.com/types/ptr"
|
||||
"tailscale.com/types/tkatype"
|
||||
@@ -65,10 +63,10 @@ type Direct struct {
|
||||
httpc *http.Client // HTTP client used to talk to tailcontrol
|
||||
dialer *tsdial.Dialer
|
||||
dnsCache *dnscache.Resolver
|
||||
serverURL string // URL of the tailcontrol server
|
||||
controlKnobs *controlknobs.Knobs // always non-nil
|
||||
serverURL string // URL of the tailcontrol server
|
||||
clock tstime.Clock
|
||||
lastPrintMap time.Time
|
||||
newDecompressor func() (Decompressor, error)
|
||||
logf logger.Logf
|
||||
netMon *netmon.Monitor // or nil
|
||||
discoPubKey key.DiscoPublic
|
||||
@@ -104,7 +102,11 @@ type Direct struct {
|
||||
// Observer is implemented by users of the control client (such as LocalBackend)
|
||||
// to get notified of changes in the control client's status.
|
||||
type Observer interface {
|
||||
SetControlClientStatus(Status)
|
||||
// SetControlClientStatus is called when the client has a new status to
|
||||
// report. The Client is provided to allow the Observer to track which
|
||||
// Client is reporting the status, allowing it to ignore stale status
|
||||
// reports from previous Clients.
|
||||
SetControlClientStatus(Client, Status)
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
@@ -115,7 +117,6 @@ type Options struct {
|
||||
Clock tstime.Clock
|
||||
Hostinfo *tailcfg.Hostinfo // non-nil passes ownership, nil means to use default using os.Hostname, etc
|
||||
DiscoPublicKey key.DiscoPublic
|
||||
NewDecompressor func() (Decompressor, error)
|
||||
Logf logger.Logf
|
||||
HTTPTestClient *http.Client // optional HTTP client to use (for tests only)
|
||||
NoiseTestClient *http.Client // optional HTTP client to use for noise RPCs (tests only)
|
||||
@@ -126,6 +127,7 @@ type Options struct {
|
||||
OnControlTime func(time.Time) // optional func to notify callers of new time from control
|
||||
Dialer *tsdial.Dialer // non-nil
|
||||
C2NHandler http.Handler // or nil
|
||||
ControlKnobs *controlknobs.Knobs // or nil to ignore
|
||||
|
||||
// Observer is called when there's a change in status to report
|
||||
// from the control client.
|
||||
@@ -191,6 +193,19 @@ type NetmapUpdater interface {
|
||||
// the diff themselves between the previous full & next full network maps.
|
||||
}
|
||||
|
||||
// NetmapDeltaUpdater is an optional interface that can be implemented by
|
||||
// NetmapUpdater implementations to receive delta updates from the controlclient
|
||||
// rather than just full updates.
|
||||
type NetmapDeltaUpdater interface {
|
||||
// UpdateNetmapDelta is called with discrete changes to the network map.
|
||||
//
|
||||
// The ok result is whether the implementation was able to apply the
|
||||
// mutations. It might return false if its internal state doesn't
|
||||
// support applying them or a NetmapUpdater it's wrapping doesn't
|
||||
// implement the NetmapDeltaUpdater optional method.
|
||||
UpdateNetmapDelta([]netmap.NodeMutation) (ok bool)
|
||||
}
|
||||
|
||||
// NewDirect returns a new Direct client.
|
||||
func NewDirect(opts Options) (*Direct, error) {
|
||||
if opts.ServerURL == "" {
|
||||
@@ -199,6 +214,9 @@ func NewDirect(opts Options) (*Direct, error) {
|
||||
if opts.GetMachinePrivateKey == nil {
|
||||
return nil, errors.New("controlclient.New: no GetMachinePrivateKey specified")
|
||||
}
|
||||
if opts.ControlKnobs == nil {
|
||||
opts.ControlKnobs = &controlknobs.Knobs{}
|
||||
}
|
||||
opts.ServerURL = strings.TrimRight(opts.ServerURL, "/")
|
||||
serverURL, err := url.Parse(opts.ServerURL)
|
||||
if err != nil {
|
||||
@@ -246,11 +264,11 @@ func NewDirect(opts Options) (*Direct, error) {
|
||||
|
||||
c := &Direct{
|
||||
httpc: httpc,
|
||||
controlKnobs: opts.ControlKnobs,
|
||||
getMachinePrivKey: opts.GetMachinePrivateKey,
|
||||
serverURL: opts.ServerURL,
|
||||
clock: opts.Clock,
|
||||
logf: opts.Logf,
|
||||
newDecompressor: opts.NewDecompressor,
|
||||
persist: opts.Persist.View(),
|
||||
authKey: opts.AuthKey,
|
||||
discoPubKey: opts.DiscoPublicKey,
|
||||
@@ -887,9 +905,7 @@ func (c *Direct) sendMapRequest(ctx context.Context, isStreaming bool, nu Netmap
|
||||
old := request.DebugFlags
|
||||
request.DebugFlags = append(old[:len(old):len(old)], extraDebugFlags...)
|
||||
}
|
||||
if c.newDecompressor != nil {
|
||||
request.Compress = "zstd"
|
||||
}
|
||||
request.Compress = "zstd"
|
||||
|
||||
bodyData, err := encode(request, serverKey, serverNoiseKey, machinePrivKey)
|
||||
if err != nil {
|
||||
@@ -946,7 +962,7 @@ func (c *Direct) sendMapRequest(ctx context.Context, isStreaming bool, nu Netmap
|
||||
|
||||
var mapResIdx int // 0 for first message, then 1+ for deltas
|
||||
|
||||
sess := newMapSession(persist.PrivateNodeKey(), nu)
|
||||
sess := newMapSession(persist.PrivateNodeKey(), nu, c.controlKnobs)
|
||||
defer sess.Close()
|
||||
sess.cancel = cancel
|
||||
sess.logf = c.logf
|
||||
@@ -1173,19 +1189,14 @@ func (c *Direct) decodeMsg(msg []byte, v any, mkey key.MachinePrivate) error {
|
||||
} else {
|
||||
decrypted = msg
|
||||
}
|
||||
var b []byte
|
||||
if c.newDecompressor == nil {
|
||||
b = decrypted
|
||||
} else {
|
||||
decoder, err := c.newDecompressor()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer decoder.Close()
|
||||
b, err = decoder.DecodeAll(decrypted, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
decoder, err := smallzstd.NewDecoder(nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer decoder.Close()
|
||||
b, err := decoder.DecodeAll(decrypted, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if debugMap() {
|
||||
var buf bytes.Buffer
|
||||
@@ -1292,68 +1303,6 @@ func initDevKnob() devKnobs {
|
||||
|
||||
var clock tstime.Clock = tstime.StdClock{}
|
||||
|
||||
// config from control.
|
||||
var (
|
||||
controlDisableDRPO atomic.Bool
|
||||
controlKeepFullWGConfig atomic.Bool
|
||||
controlRandomizeClientPort atomic.Bool
|
||||
controlOneCGNAT syncs.AtomicValue[opt.Bool]
|
||||
)
|
||||
|
||||
// DisableDRPO reports whether control says to disable the
|
||||
// DERP route optimization (Issue 150).
|
||||
func DisableDRPO() bool {
|
||||
return controlDisableDRPO.Load()
|
||||
}
|
||||
|
||||
// KeepFullWGConfig reports whether control says we should disable the lazy
|
||||
// wireguard programming and instead give it the full netmap always.
|
||||
func KeepFullWGConfig() bool {
|
||||
return controlKeepFullWGConfig.Load()
|
||||
}
|
||||
|
||||
// RandomizeClientPort reports whether control says we should randomize
|
||||
// the client port.
|
||||
func RandomizeClientPort() bool {
|
||||
return controlRandomizeClientPort.Load()
|
||||
}
|
||||
|
||||
// ControlOneCGNATSetting returns control's OneCGNAT setting, if any.
|
||||
func ControlOneCGNATSetting() opt.Bool {
|
||||
return controlOneCGNAT.Load()
|
||||
}
|
||||
|
||||
func setControlKnobsFromNodeAttrs(selfNodeAttrs []string) {
|
||||
var (
|
||||
keepFullWG bool
|
||||
disableDRPO bool
|
||||
disableUPnP bool
|
||||
randomizeClientPort bool
|
||||
oneCGNAT opt.Bool
|
||||
)
|
||||
for _, attr := range selfNodeAttrs {
|
||||
switch attr {
|
||||
case tailcfg.NodeAttrDebugDisableWGTrim:
|
||||
keepFullWG = true
|
||||
case tailcfg.NodeAttrDebugDisableDRPO:
|
||||
disableDRPO = true
|
||||
case tailcfg.NodeAttrDisableUPnP:
|
||||
disableUPnP = true
|
||||
case tailcfg.NodeAttrRandomizeClientPort:
|
||||
randomizeClientPort = true
|
||||
case tailcfg.NodeAttrOneCGNATEnable:
|
||||
oneCGNAT.Set(true)
|
||||
case tailcfg.NodeAttrOneCGNATDisable:
|
||||
oneCGNAT.Set(false)
|
||||
}
|
||||
}
|
||||
controlKeepFullWGConfig.Store(keepFullWG)
|
||||
controlDisableDRPO.Store(disableDRPO)
|
||||
controlknobs.SetDisableUPnP(disableUPnP)
|
||||
controlRandomizeClientPort.Store(randomizeClientPort)
|
||||
controlOneCGNAT.Store(oneCGNAT)
|
||||
}
|
||||
|
||||
// ipForwardingBroken reports whether the system's IP forwarding is disabled
|
||||
// and will definitely not work for the routes provided.
|
||||
//
|
||||
|
||||
@@ -14,7 +14,9 @@ import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"tailscale.com/control/controlknobs"
|
||||
"tailscale.com/envknob"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/tstime"
|
||||
@@ -38,7 +40,8 @@ import (
|
||||
// one MapRequest).
|
||||
type mapSession struct {
|
||||
// Immutable fields.
|
||||
nu NetmapUpdater // called on changes (in addition to the optional hooks below)
|
||||
netmapUpdater NetmapUpdater // called on changes (in addition to the optional hooks below)
|
||||
controlKnobs *controlknobs.Knobs // or nil
|
||||
privateNodeKey key.NodePrivate
|
||||
publicNodeKey key.NodePublic
|
||||
logf logger.Logf
|
||||
@@ -94,9 +97,10 @@ type mapSession struct {
|
||||
// Modify its optional fields on the returned value before use.
|
||||
//
|
||||
// It must have its Close method called to release resources.
|
||||
func newMapSession(privateNodeKey key.NodePrivate, nu NetmapUpdater) *mapSession {
|
||||
func newMapSession(privateNodeKey key.NodePrivate, nu NetmapUpdater, controlKnobs *controlknobs.Knobs) *mapSession {
|
||||
ms := &mapSession{
|
||||
nu: nu,
|
||||
netmapUpdater: nu,
|
||||
controlKnobs: controlKnobs,
|
||||
privateNodeKey: privateNodeKey,
|
||||
publicNodeKey: privateNodeKey.Public(),
|
||||
lastDNSConfig: new(tailcfg.DNSConfig),
|
||||
@@ -184,7 +188,7 @@ func (ms *mapSession) HandleNonKeepAliveMapResponse(ctx context.Context, resp *t
|
||||
if DevKnob.StripCaps() {
|
||||
resp.Node.Capabilities = nil
|
||||
}
|
||||
setControlKnobsFromNodeAttrs(resp.Node.Capabilities)
|
||||
ms.controlKnobs.UpdateFromNodeAttributes(resp.Node.Capabilities)
|
||||
}
|
||||
|
||||
// Call Node.InitDisplayNames on any changed nodes.
|
||||
@@ -194,8 +198,16 @@ func (ms *mapSession) HandleNonKeepAliveMapResponse(ctx context.Context, resp *t
|
||||
|
||||
ms.updateStateFromResponse(resp)
|
||||
|
||||
nm := ms.netmap()
|
||||
if ms.tryHandleIncrementally(resp) {
|
||||
ms.onConciseNetMapSummary(ms.lastNetmapSummary) // every 5s log
|
||||
return nil
|
||||
}
|
||||
|
||||
// We have to rebuild the whole netmap (lots of garbage & work downstream of
|
||||
// our UpdateFullNetmap call). This is the part we tried to avoid but
|
||||
// some field mutations (especially rare ones) aren't yet handled.
|
||||
|
||||
nm := ms.netmap()
|
||||
ms.lastNetmapSummary = nm.VeryConcise()
|
||||
ms.onConciseNetMapSummary(ms.lastNetmapSummary)
|
||||
|
||||
@@ -204,10 +216,25 @@ func (ms *mapSession) HandleNonKeepAliveMapResponse(ctx context.Context, resp *t
|
||||
ms.onSelfNodeChanged(nm)
|
||||
}
|
||||
|
||||
ms.nu.UpdateFullNetmap(nm)
|
||||
ms.netmapUpdater.UpdateFullNetmap(nm)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ms *mapSession) tryHandleIncrementally(res *tailcfg.MapResponse) bool {
|
||||
if ms.controlKnobs != nil && ms.controlKnobs.DisableDeltaUpdates.Load() {
|
||||
return false
|
||||
}
|
||||
nud, ok := ms.netmapUpdater.(NetmapDeltaUpdater)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
mutations, ok := netmap.MutationsFromMapResponse(res, time.Now())
|
||||
if ok && len(mutations) > 0 {
|
||||
return nud.UpdateNetmapDelta(mutations)
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
// updateStats are some stats from updateStateFromResponse, primarily for
|
||||
// testing. It's meant to be cheap enough to always compute, though. It doesn't
|
||||
// allocate.
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"go4.org/mem"
|
||||
"tailscale.com/control/controlknobs"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/tstest"
|
||||
"tailscale.com/tstime"
|
||||
@@ -392,7 +393,7 @@ func formatNodes(nodes []*tailcfg.Node) string {
|
||||
}
|
||||
|
||||
func newTestMapSession(t testing.TB, nu NetmapUpdater) *mapSession {
|
||||
ms := newMapSession(key.NewNode(), nu)
|
||||
ms := newMapSession(key.NewNode(), nu, new(controlknobs.Knobs))
|
||||
t.Cleanup(ms.Close)
|
||||
ms.logf = t.Logf
|
||||
return ms
|
||||
|
||||
@@ -37,6 +37,10 @@ const (
|
||||
StateSynchronized // connected and received map update
|
||||
)
|
||||
|
||||
func (s State) AppendText(b []byte) ([]byte, error) {
|
||||
return append(b, s.String()...), nil
|
||||
}
|
||||
|
||||
func (s State) MarshalText() ([]byte, error) {
|
||||
return []byte(s.String()), nil
|
||||
}
|
||||
|
||||
@@ -8,22 +8,101 @@ package controlknobs
|
||||
import (
|
||||
"sync/atomic"
|
||||
|
||||
"tailscale.com/envknob"
|
||||
"tailscale.com/syncs"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/opt"
|
||||
)
|
||||
|
||||
// disableUPnP indicates whether to attempt UPnP mapping.
|
||||
var disableUPnPControl atomic.Bool
|
||||
// Knobs is the set of knobs that the control plane's coordination server can
|
||||
// adjust at runtime.
|
||||
type Knobs struct {
|
||||
// DisableUPnP indicates whether to attempt UPnP mapping.
|
||||
DisableUPnP atomic.Bool
|
||||
|
||||
var disableUPnpEnv = envknob.RegisterBool("TS_DISABLE_UPNP")
|
||||
// DisableDRPO is whether control says to disable the
|
||||
// DERP route optimization (Issue 150).
|
||||
DisableDRPO atomic.Bool
|
||||
|
||||
// DisableUPnP reports the last reported value from control
|
||||
// whether UPnP portmapping should be disabled.
|
||||
func DisableUPnP() bool {
|
||||
return disableUPnPControl.Load() || disableUPnpEnv()
|
||||
// KeepFullWGConfig is whether we should disable the lazy wireguard
|
||||
// programming and instead give WireGuard the full netmap always, even for
|
||||
// idle peers.
|
||||
KeepFullWGConfig atomic.Bool
|
||||
|
||||
// RandomizeClientPort is whether control says we should randomize
|
||||
// the client port.
|
||||
RandomizeClientPort atomic.Bool
|
||||
|
||||
// OneCGNAT is whether the the node should make one big CGNAT route
|
||||
// in the OS rather than one /32 per peer.
|
||||
OneCGNAT syncs.AtomicValue[opt.Bool]
|
||||
|
||||
// ForceBackgroundSTUN forces netcheck STUN queries to keep
|
||||
// running in magicsock, even when idle.
|
||||
ForceBackgroundSTUN atomic.Bool
|
||||
|
||||
// DisableDeltaUpdates is whether the node should not process
|
||||
// incremental (delta) netmap updates and should treat all netmap
|
||||
// changes as "full" ones as tailscaled did in 1.48.x and earlier.
|
||||
DisableDeltaUpdates atomic.Bool
|
||||
}
|
||||
|
||||
// SetDisableUPnP sets whether control says that UPnP should be
|
||||
// disabled.
|
||||
func SetDisableUPnP(v bool) {
|
||||
disableUPnPControl.Store(v)
|
||||
// UpdateFromNodeAttributes updates k (if non-nil) based on the provided self
|
||||
// node attributes (Node.Capabilities).
|
||||
func (k *Knobs) UpdateFromNodeAttributes(selfNodeAttrs []string) {
|
||||
if k == nil {
|
||||
return
|
||||
}
|
||||
var (
|
||||
keepFullWG bool
|
||||
disableDRPO bool
|
||||
disableUPnP bool
|
||||
randomizeClientPort bool
|
||||
disableDeltaUpdates bool
|
||||
oneCGNAT opt.Bool
|
||||
forceBackgroundSTUN bool
|
||||
)
|
||||
for _, attr := range selfNodeAttrs {
|
||||
switch attr {
|
||||
case tailcfg.NodeAttrDebugDisableWGTrim:
|
||||
keepFullWG = true
|
||||
case tailcfg.NodeAttrDebugDisableDRPO:
|
||||
disableDRPO = true
|
||||
case tailcfg.NodeAttrDisableUPnP:
|
||||
disableUPnP = true
|
||||
case tailcfg.NodeAttrRandomizeClientPort:
|
||||
randomizeClientPort = true
|
||||
case tailcfg.NodeAttrOneCGNATEnable:
|
||||
oneCGNAT.Set(true)
|
||||
case tailcfg.NodeAttrOneCGNATDisable:
|
||||
oneCGNAT.Set(false)
|
||||
case tailcfg.NodeAttrDebugForceBackgroundSTUN:
|
||||
forceBackgroundSTUN = true
|
||||
case tailcfg.NodeAttrDisableDeltaUpdates:
|
||||
disableDeltaUpdates = true
|
||||
}
|
||||
}
|
||||
k.KeepFullWGConfig.Store(keepFullWG)
|
||||
k.DisableDRPO.Store(disableDRPO)
|
||||
k.DisableUPnP.Store(disableUPnP)
|
||||
k.RandomizeClientPort.Store(randomizeClientPort)
|
||||
k.OneCGNAT.Store(oneCGNAT)
|
||||
k.ForceBackgroundSTUN.Store(forceBackgroundSTUN)
|
||||
k.DisableDeltaUpdates.Store(disableDeltaUpdates)
|
||||
}
|
||||
|
||||
// AsDebugJSON returns k as something that can be marshalled with json.Marshal
|
||||
// for debug.
|
||||
func (k *Knobs) AsDebugJSON() map[string]any {
|
||||
if k == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{
|
||||
"DisableUPnP": k.DisableUPnP.Load(),
|
||||
"DisableDRPO": k.DisableDRPO.Load(),
|
||||
"KeepFullWGConfig": k.KeepFullWGConfig.Load(),
|
||||
"RandomizeClientPort": k.RandomizeClientPort.Load(),
|
||||
"OneCGNAT": k.OneCGNAT.Load(),
|
||||
"ForceBackgroundSTUN": k.ForceBackgroundSTUN.Load(),
|
||||
"DisableDeltaUpdates": k.DisableDeltaUpdates.Load(),
|
||||
}
|
||||
}
|
||||
|
||||
21
control/controlknobs/controlknobs_test.go
Normal file
21
control/controlknobs/controlknobs_test.go
Normal file
@@ -0,0 +1,21 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package controlknobs
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAsDebugJSON(t *testing.T) {
|
||||
var nilPtr *Knobs
|
||||
if got := nilPtr.AsDebugJSON(); got != nil {
|
||||
t.Errorf("AsDebugJSON(nil) = %v; want nil", got)
|
||||
}
|
||||
k := new(Knobs)
|
||||
got := k.AsDebugJSON()
|
||||
if want := reflect.TypeOf(Knobs{}).NumField(); len(got) != want {
|
||||
t.Errorf("AsDebugJSON map has %d fields; want %v", len(got), want)
|
||||
}
|
||||
}
|
||||
@@ -389,12 +389,24 @@ func CanTaildrop() bool { return !Bool("TS_DISABLE_TAILDROP") }
|
||||
// SSHPolicyFile returns the path, if any, to the SSHPolicy JSON file for development.
|
||||
func SSHPolicyFile() string { return String("TS_DEBUG_SSH_POLICY_FILE") }
|
||||
|
||||
// SSHIgnoreTailnetPolicy is whether to ignore the Tailnet SSH policy for development.
|
||||
// SSHIgnoreTailnetPolicy reports whether to ignore the Tailnet SSH policy for development.
|
||||
func SSHIgnoreTailnetPolicy() bool { return Bool("TS_DEBUG_SSH_IGNORE_TAILNET_POLICY") }
|
||||
|
||||
// TKASkipSignatureCheck is whether to skip node-key signature checking for development.
|
||||
// TKASkipSignatureCheck reports whether to skip node-key signature checking for development.
|
||||
func TKASkipSignatureCheck() bool { return Bool("TS_UNSAFE_SKIP_NKS_VERIFICATION") }
|
||||
|
||||
// CrashOnUnexpected reports whether the Tailscale client should panic
|
||||
// on unexpected conditions. If TS_DEBUG_CRASH_ON_UNEXPECTED is set, that's
|
||||
// used. Otherwise the default value is true for unstable builds.
|
||||
func CrashOnUnexpected() bool {
|
||||
if v, ok := crashOnUnexpected().Get(); ok {
|
||||
return v
|
||||
}
|
||||
return version.IsUnstableBuild()
|
||||
}
|
||||
|
||||
var crashOnUnexpected = RegisterOptBool("TS_DEBUG_CRASH_ON_UNEXPECTED")
|
||||
|
||||
// NoLogsNoSupport reports whether the client's opted out of log uploads and
|
||||
// technical support.
|
||||
func NoLogsNoSupport() bool {
|
||||
|
||||
@@ -27,7 +27,7 @@ var (
|
||||
|
||||
sysErr = map[Subsystem]error{} // error key => err (or nil for no error)
|
||||
watchers = set.HandleSet[func(Subsystem, error)]{} // opt func to run if error state changes
|
||||
warnables = map[*Warnable]struct{}{} // set of warnables
|
||||
warnables = set.Set[*Warnable]{}
|
||||
timer *time.Timer
|
||||
|
||||
debugHandler = map[string]http.Handler{}
|
||||
@@ -84,7 +84,7 @@ func NewWarnable(opts ...WarnableOpt) *Warnable {
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
warnables[w] = struct{}{}
|
||||
warnables.Add(w)
|
||||
return w
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"tailscale.com/util/set"
|
||||
)
|
||||
|
||||
func TestAppendWarnableDebugFlags(t *testing.T) {
|
||||
@@ -35,5 +37,5 @@ func TestAppendWarnableDebugFlags(t *testing.T) {
|
||||
func resetWarnables() {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
warnables = make(map[*Warnable]struct{})
|
||||
warnables = set.Set[*Warnable]{}
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ const (
|
||||
// each one via RequestEngineStatus.
|
||||
NotifyWatchEngineUpdates NotifyWatchOpt = 1 << iota
|
||||
|
||||
NotifyInitialState // if set, the first Notify message (sent immediately) will contain the current State + BrowseToURL
|
||||
NotifyInitialState // if set, the first Notify message (sent immediately) will contain the current State + BrowseToURL + SessionID
|
||||
NotifyInitialPrefs // if set, the first Notify message (sent immediately) will contain the current Prefs
|
||||
NotifyInitialNetMap // if set, the first Notify message (sent immediately) will contain the current NetMap
|
||||
|
||||
@@ -77,6 +77,12 @@ type Notify struct {
|
||||
_ structs.Incomparable
|
||||
Version string // version number of IPN backend
|
||||
|
||||
// SessionID identifies the unique WatchIPNBus session.
|
||||
// This field is only set in the first message when requesting
|
||||
// NotifyInitialState. Clients must store it on their side as
|
||||
// following notifications will not include this field.
|
||||
SessionID string `json:",omitempty"`
|
||||
|
||||
// ErrMessage, if non-nil, contains a critical error message.
|
||||
// For State InUseOtherUser, ErrMessage is not critical and just contains the details.
|
||||
ErrMessage *string
|
||||
|
||||
@@ -76,6 +76,12 @@ func (src *ServeConfig) Clone() *ServeConfig {
|
||||
}
|
||||
}
|
||||
dst.AllowFunnel = maps.Clone(src.AllowFunnel)
|
||||
if dst.Foreground != nil {
|
||||
dst.Foreground = map[string]*ServeConfig{}
|
||||
for k, v := range src.Foreground {
|
||||
dst.Foreground[k] = v.Clone()
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
@@ -84,6 +90,7 @@ var _ServeConfigCloneNeedsRegeneration = ServeConfig(struct {
|
||||
TCP map[uint16]*TCPPortHandler
|
||||
Web map[HostPort]*WebServerConfig
|
||||
AllowFunnel map[HostPort]bool
|
||||
Foreground map[string]*ServeConfig
|
||||
}{})
|
||||
|
||||
// Clone makes a deep copy of TCPPortHandler.
|
||||
|
||||
@@ -177,11 +177,18 @@ func (v ServeConfigView) AllowFunnel() views.Map[HostPort, bool] {
|
||||
return views.MapOf(v.ж.AllowFunnel)
|
||||
}
|
||||
|
||||
func (v ServeConfigView) Foreground() views.MapFn[string, *ServeConfig, ServeConfigView] {
|
||||
return views.MapFnOf(v.ж.Foreground, func(t *ServeConfig) ServeConfigView {
|
||||
return t.View()
|
||||
})
|
||||
}
|
||||
|
||||
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
|
||||
var _ServeConfigViewNeedsRegeneration = ServeConfig(struct {
|
||||
TCP map[uint16]*TCPPortHandler
|
||||
Web map[HostPort]*WebServerConfig
|
||||
AllowFunnel map[HostPort]bool
|
||||
Foreground map[string]*ServeConfig
|
||||
}{})
|
||||
|
||||
// View returns a readonly view of TCPPortHandler.
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package ipnlocal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"tailscale.com/clientupdate"
|
||||
@@ -38,7 +40,15 @@ func (b *LocalBackend) handleC2N(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
w.Write(body)
|
||||
case "/update":
|
||||
b.handleC2NUpdate(w, r)
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
b.handleC2NUpdateGet(w, r)
|
||||
case http.MethodPost:
|
||||
b.handleC2NUpdatePost(w, r)
|
||||
default:
|
||||
http.Error(w, "bad method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
case "/logtail/flush":
|
||||
if r.Method != "POST" {
|
||||
http.Error(w, "bad method", http.StatusMethodNotAllowed)
|
||||
@@ -111,37 +121,27 @@ func (b *LocalBackend) handleC2N(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
func (b *LocalBackend) handleC2NUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
// TODO(bradfitz): add some sort of semaphore that prevents two concurrent
|
||||
// updates, or if one happened in the past 5 minutes, or something.
|
||||
func (b *LocalBackend) handleC2NUpdateGet(w http.ResponseWriter, r *http.Request) {
|
||||
b.logf("c2n: GET /update received")
|
||||
|
||||
// GET returns the current status, and POST actually begins an update.
|
||||
if r.Method != "GET" && r.Method != "POST" {
|
||||
http.Error(w, "bad method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
res := b.newC2NUpdateResponse()
|
||||
res.Started = b.c2nUpdateStarted()
|
||||
|
||||
// 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.
|
||||
prefs := b.Prefs().AutoUpdate()
|
||||
_, err := clientupdate.NewUpdater(clientupdate.Arguments{})
|
||||
res := tailcfg.C2NUpdateResponse{
|
||||
Enabled: envknob.AllowsRemoteUpdate() || prefs.Apply,
|
||||
Supported: err == nil && !version.IsMacSysExt(),
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(res)
|
||||
}
|
||||
|
||||
func (b *LocalBackend) handleC2NUpdatePost(w http.ResponseWriter, r *http.Request) {
|
||||
b.logf("c2n: POST /update received")
|
||||
res := b.newC2NUpdateResponse()
|
||||
defer func() {
|
||||
if res.Err != "" {
|
||||
b.logf("c2n: POST /update failed: %s", res.Err)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(res)
|
||||
}()
|
||||
|
||||
if r.Method == "GET" {
|
||||
return
|
||||
}
|
||||
if !res.Enabled {
|
||||
res.Err = "not enabled"
|
||||
return
|
||||
@@ -151,6 +151,18 @@ func (b *LocalBackend) handleC2NUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if update was already started, and mark as started.
|
||||
if !b.trySetC2NUpdateStarted() {
|
||||
res.Err = "update already started"
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
// Clear the started flag if something failed.
|
||||
if res.Err != "" {
|
||||
b.setC2NUpdateStarted(false)
|
||||
}
|
||||
}()
|
||||
|
||||
cmdTS, err := findCmdTailscale()
|
||||
if err != nil {
|
||||
res.Err = fmt.Sprintf("failed to find cmd/tailscale binary: %v", err)
|
||||
@@ -172,22 +184,64 @@ func (b *LocalBackend) handleC2NUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
res.Err = "cmd/tailscale version mismatch"
|
||||
return
|
||||
}
|
||||
|
||||
cmd := exec.Command(cmdTS, "update", "--yes")
|
||||
buf := new(bytes.Buffer)
|
||||
cmd.Stdout = buf
|
||||
cmd.Stderr = buf
|
||||
b.logf("c2n: running %q", strings.Join(cmd.Args, " "))
|
||||
if err := cmd.Start(); err != nil {
|
||||
res.Err = fmt.Sprintf("failed to start cmd/tailscale update: %v", err)
|
||||
return
|
||||
}
|
||||
res.Started = true
|
||||
|
||||
// TODO(bradfitz,andrew): There might be a race condition here on Windows:
|
||||
// * We start the update process.
|
||||
// * tailscale.exe copies itself and kicks off the update process
|
||||
// * msiexec stops this process during the update before the selfCopy exits(?)
|
||||
// * This doesn't return because the process is dead.
|
||||
// Run update asynchronously and respond that it started.
|
||||
go func() {
|
||||
if err := cmd.Wait(); err != nil {
|
||||
b.logf("c2n: update command failed: %v, output: %s", err, buf)
|
||||
} else {
|
||||
b.logf("c2n: update complete")
|
||||
}
|
||||
b.setC2NUpdateStarted(false)
|
||||
}()
|
||||
}
|
||||
|
||||
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.
|
||||
//
|
||||
// This seems fairly unlikely, but worth checking.
|
||||
defer cmd.Wait()
|
||||
return
|
||||
// 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.
|
||||
prefs := b.Prefs().AutoUpdate()
|
||||
_, err := clientupdate.NewUpdater(clientupdate.Arguments{})
|
||||
return tailcfg.C2NUpdateResponse{
|
||||
Enabled: envknob.AllowsRemoteUpdate() || prefs.Apply,
|
||||
Supported: err == nil && !version.IsMacSysExt(),
|
||||
}
|
||||
}
|
||||
|
||||
func (b *LocalBackend) c2nUpdateStarted() bool {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.c2nUpdateStatus.started
|
||||
}
|
||||
|
||||
func (b *LocalBackend) setC2NUpdateStarted(v bool) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.c2nUpdateStatus.started = v
|
||||
}
|
||||
|
||||
func (b *LocalBackend) trySetC2NUpdateStarted() bool {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if b.c2nUpdateStatus.started {
|
||||
return false
|
||||
}
|
||||
b.c2nUpdateStatus.started = true
|
||||
return true
|
||||
}
|
||||
|
||||
// findCmdTailscale looks for the cmd/tailscale that corresponds to the
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"maps"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
@@ -33,6 +34,7 @@ import (
|
||||
"gvisor.dev/gvisor/pkg/tcpip"
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
"tailscale.com/control/controlclient"
|
||||
"tailscale.com/control/controlknobs"
|
||||
"tailscale.com/doctor"
|
||||
"tailscale.com/doctor/permissions"
|
||||
"tailscale.com/doctor/routetable"
|
||||
@@ -78,6 +80,7 @@ import (
|
||||
"tailscale.com/util/mak"
|
||||
"tailscale.com/util/multierr"
|
||||
"tailscale.com/util/osshare"
|
||||
"tailscale.com/util/rands"
|
||||
"tailscale.com/util/set"
|
||||
"tailscale.com/util/systemd"
|
||||
"tailscale.com/util/testenv"
|
||||
@@ -151,10 +154,9 @@ type LocalBackend struct {
|
||||
portpoll *portlist.Poller // may be nil
|
||||
portpollOnce sync.Once // guards starting readPoller
|
||||
gotPortPollRes chan struct{} // closed upon first readPoller result
|
||||
newDecompressor func() (controlclient.Decompressor, error)
|
||||
varRoot string // or empty if SetVarRoot never called
|
||||
logFlushFunc func() // or nil if SetLogFlusher wasn't called
|
||||
em *expiryManager // non-nil
|
||||
varRoot string // or empty if SetVarRoot never called
|
||||
logFlushFunc func() // or nil if SetLogFlusher wasn't called
|
||||
em *expiryManager // non-nil
|
||||
sshAtomicBool atomic.Bool
|
||||
shutdownCalled bool // if Shutdown has been called
|
||||
debugSink *capture.Sink
|
||||
@@ -201,9 +203,8 @@ type LocalBackend struct {
|
||||
capFileSharing bool // whether netMap contains the file sharing capability
|
||||
capTailnetLock bool // whether netMap contains the tailnet lock capability
|
||||
// hostinfo is mutated in-place while mu is held.
|
||||
hostinfo *tailcfg.Hostinfo
|
||||
// netMap is not mutated in-place once set.
|
||||
netMap *netmap.NetworkMap
|
||||
hostinfo *tailcfg.Hostinfo
|
||||
netMap *netmap.NetworkMap // not mutated in place once set (except for Peers slice)
|
||||
nmExpiryTimer tstime.TimerController // for updating netMap on node expiry; can be nil
|
||||
nodeByAddr map[netip.Addr]tailcfg.NodeView
|
||||
activeLogin string // last logged LoginName from netMap
|
||||
@@ -238,16 +239,16 @@ type LocalBackend struct {
|
||||
directFileRoot string
|
||||
directFileDoFinalRename bool // false on macOS, true on several NAS platforms
|
||||
componentLogUntil map[string]componentLogState
|
||||
// c2nUpdateStatus is the status of c2n-triggered client update.
|
||||
c2nUpdateStatus updateStatus
|
||||
|
||||
// ServeConfig fields. (also guarded by mu)
|
||||
lastServeConfJSON mem.RO // last JSON that was parsed into serveConfig
|
||||
serveConfig ipn.ServeConfigView // or !Valid if none
|
||||
lastServeConfJSON mem.RO // last JSON that was parsed into serveConfig
|
||||
serveConfig ipn.ServeConfigView // or !Valid if none
|
||||
activeWatchSessions set.Set[string] // of WatchIPN SessionID
|
||||
|
||||
serveListeners map[netip.AddrPort]*serveListener // addrPort => serveListener
|
||||
serveProxyHandlers sync.Map // string (HTTPHandler.Proxy) => *httputil.ReverseProxy
|
||||
// serveStreamers is a map for those running Funnel in the foreground
|
||||
// and streaming incoming requests.
|
||||
serveStreamers map[uint16]map[uint32]func(ipn.FunnelRequestLog) // serve port => map of stream loggers (key is UUID)
|
||||
|
||||
// statusLock must be held before calling statusChanged.Wait() or
|
||||
// statusChanged.Broadcast().
|
||||
@@ -266,6 +267,13 @@ type LocalBackend struct {
|
||||
// at the moment that tkaSyncLock is taken).
|
||||
tkaSyncLock sync.Mutex
|
||||
clock tstime.Clock
|
||||
|
||||
// Last ClientVersion received in MapResponse, guarded by mu.
|
||||
lastClientVersion *tailcfg.ClientVersion
|
||||
}
|
||||
|
||||
type updateStatus struct {
|
||||
started bool
|
||||
}
|
||||
|
||||
// clientGen is a func that creates a control plane client.
|
||||
@@ -280,6 +288,7 @@ func NewLocalBackend(logf logger.Logf, logID logid.PublicID, sys *tsd.System, lo
|
||||
e := sys.Engine.Get()
|
||||
store := sys.StateStore.Get()
|
||||
dialer := sys.Dialer.Get()
|
||||
_ = sys.MagicSock.Get() // or panic
|
||||
|
||||
pm, err := newProfileManager(store, logf)
|
||||
if err != nil {
|
||||
@@ -303,23 +312,24 @@ func NewLocalBackend(logf logger.Logf, logID logid.PublicID, sys *tsd.System, lo
|
||||
clock := tstime.StdClock{}
|
||||
|
||||
b := &LocalBackend{
|
||||
ctx: ctx,
|
||||
ctxCancel: cancel,
|
||||
logf: logf,
|
||||
keyLogf: logger.LogOnChange(logf, 5*time.Minute, clock.Now),
|
||||
statsLogf: logger.LogOnChange(logf, 5*time.Minute, clock.Now),
|
||||
sys: sys,
|
||||
e: e,
|
||||
dialer: dialer,
|
||||
store: store,
|
||||
pm: pm,
|
||||
backendLogID: logID,
|
||||
state: ipn.NoState,
|
||||
portpoll: portpoll,
|
||||
em: newExpiryManager(logf),
|
||||
gotPortPollRes: make(chan struct{}),
|
||||
loginFlags: loginFlags,
|
||||
clock: clock,
|
||||
ctx: ctx,
|
||||
ctxCancel: cancel,
|
||||
logf: logf,
|
||||
keyLogf: logger.LogOnChange(logf, 5*time.Minute, clock.Now),
|
||||
statsLogf: logger.LogOnChange(logf, 5*time.Minute, clock.Now),
|
||||
sys: sys,
|
||||
e: e,
|
||||
dialer: dialer,
|
||||
store: store,
|
||||
pm: pm,
|
||||
backendLogID: logID,
|
||||
state: ipn.NoState,
|
||||
portpoll: portpoll,
|
||||
em: newExpiryManager(logf),
|
||||
gotPortPollRes: make(chan struct{}),
|
||||
loginFlags: loginFlags,
|
||||
clock: clock,
|
||||
activeWatchSessions: make(set.Set[string]),
|
||||
}
|
||||
|
||||
netMon := sys.NetMon.Get()
|
||||
@@ -395,11 +405,7 @@ func (b *LocalBackend) SetComponentDebugLogging(component string, until time.Tim
|
||||
var setEnabled func(bool)
|
||||
switch component {
|
||||
case "magicsock":
|
||||
mc, err := b.magicConn()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
setEnabled = mc.SetDebugLoggingEnabled
|
||||
setEnabled = b.magicConn().SetDebugLoggingEnabled
|
||||
case "sockstats":
|
||||
if b.sockstatLogger != nil {
|
||||
setEnabled = func(v bool) {
|
||||
@@ -664,6 +670,9 @@ func (b *LocalBackend) updateStatus(sb *ipnstate.StatusBuilder, extraLocked func
|
||||
s.TUN = !b.sys.IsNetstack()
|
||||
s.BackendState = b.state.String()
|
||||
s.AuthURL = b.authURLSticky
|
||||
if prefs := b.pm.CurrentPrefs(); prefs.Valid() && prefs.AutoUpdate().Check {
|
||||
s.ClientVersion = b.lastClientVersion
|
||||
}
|
||||
if err := health.OverallError(); err != nil {
|
||||
switch e := err.(type) {
|
||||
case multierr.Error:
|
||||
@@ -885,21 +894,18 @@ func (b *LocalBackend) peerCapsLocked(src netip.Addr) tailcfg.PeerCapMap {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetDecompressor sets a decompression function, which must be a zstd
|
||||
// reader.
|
||||
//
|
||||
// This exists because the iOS/Mac NetworkExtension is very resource
|
||||
// constrained, and the zstd package is too heavy to fit in the
|
||||
// constrained RSS limit.
|
||||
func (b *LocalBackend) SetDecompressor(fn func() (controlclient.Decompressor, error)) {
|
||||
b.newDecompressor = fn
|
||||
}
|
||||
|
||||
// SetControlClientStatus is the callback invoked by the control client whenever it posts a new status.
|
||||
// Among other things, this is where we update the netmap, packet filters, DNS and DERP maps.
|
||||
func (b *LocalBackend) SetControlClientStatus(st controlclient.Status) {
|
||||
func (b *LocalBackend) SetControlClientStatus(c controlclient.Client, st controlclient.Status) {
|
||||
b.mu.Lock()
|
||||
if b.cc != c {
|
||||
b.logf("Ignoring SetControlClientStatus from old client")
|
||||
b.mu.Unlock()
|
||||
return
|
||||
}
|
||||
// The following do not depend on any data for which we need to lock b.
|
||||
if st.Err != nil {
|
||||
b.mu.Unlock()
|
||||
if errors.Is(st.Err, io.EOF) {
|
||||
b.logf("[v1] Received error: EOF")
|
||||
return
|
||||
@@ -916,8 +922,6 @@ func (b *LocalBackend) SetControlClientStatus(st controlclient.Status) {
|
||||
// Track the number of calls
|
||||
currCall := b.numClientStatusCalls.Add(1)
|
||||
|
||||
b.mu.Lock()
|
||||
|
||||
// Handle node expiry in the netmap
|
||||
if st.NetMap != nil {
|
||||
now := b.clock.Now()
|
||||
@@ -953,7 +957,7 @@ func (b *LocalBackend) SetControlClientStatus(st controlclient.Status) {
|
||||
// Call ourselves with the current status again; the logic in
|
||||
// setClientStatus will take care of updating the expired field
|
||||
// of peers in the netmap.
|
||||
b.SetControlClientStatus(st)
|
||||
b.SetControlClientStatus(c, st)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1094,7 +1098,7 @@ func (b *LocalBackend) SetControlClientStatus(st controlclient.Status) {
|
||||
}
|
||||
|
||||
b.e.SetNetworkMap(st.NetMap)
|
||||
b.e.SetDERPMap(st.NetMap.DERPMap)
|
||||
b.magicConn().SetDERPMap(st.NetMap.DERPMap)
|
||||
|
||||
// Update our cached DERP map
|
||||
dnsfallback.UpdateCache(st.NetMap.DERPMap, b.logf)
|
||||
@@ -1113,6 +1117,52 @@ func (b *LocalBackend) SetControlClientStatus(st controlclient.Status) {
|
||||
b.authReconfig()
|
||||
}
|
||||
|
||||
var _ controlclient.NetmapDeltaUpdater = (*LocalBackend)(nil)
|
||||
|
||||
// UpdateNetmapDelta implements controlclient.NetmapDeltaUpdater.
|
||||
func (b *LocalBackend) UpdateNetmapDelta(muts []netmap.NodeMutation) (handled bool) {
|
||||
if !b.magicConn().UpdateNetmapDelta(muts) {
|
||||
return false
|
||||
}
|
||||
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.updateNetmapDeltaLocked(muts)
|
||||
}
|
||||
|
||||
func (b *LocalBackend) updateNetmapDeltaLocked(muts []netmap.NodeMutation) (handled bool) {
|
||||
if b.netMap == nil {
|
||||
return false
|
||||
}
|
||||
peers := b.netMap.Peers
|
||||
|
||||
for _, m := range muts {
|
||||
// LocalBackend only cares about some types of mutations.
|
||||
// (magicsock cares about different ones.)
|
||||
switch m.(type) {
|
||||
case netmap.NodeMutationOnline, netmap.NodeMutationLastSeen:
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
nodeID := m.NodeIDBeingMutated()
|
||||
idx := b.netMap.PeerIndexByNodeID(nodeID)
|
||||
if idx == -1 {
|
||||
continue
|
||||
}
|
||||
mut := peers[idx].AsStruct()
|
||||
|
||||
switch m := m.(type) {
|
||||
case netmap.NodeMutationOnline:
|
||||
mut.Online = ptr.To(m.Online)
|
||||
case netmap.NodeMutationLastSeen:
|
||||
mut.LastSeen = ptr.To(m.LastSeen)
|
||||
}
|
||||
peers[idx] = mut.View()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// setExitNodeID updates prefs to reference an exit node by ID, rather
|
||||
// than by IP. It returns whether prefs was mutated.
|
||||
func setExitNodeID(prefs *ipn.Prefs, nm *netmap.NetworkMap) (prefsChanged bool) {
|
||||
@@ -1319,7 +1369,7 @@ func (b *LocalBackend) Start(opts ipn.Options) error {
|
||||
// but meanwhile we can make Start cheaper here for such a
|
||||
// case and not restart the world (which takes a few seconds).
|
||||
// Instead, just send a notify with the state that iOS needs.
|
||||
if b.startIsNoopLocked(opts) && profileID == b.lastProfileID {
|
||||
if b.startIsNoopLocked(opts) && profileID == b.lastProfileID && profileID != "" {
|
||||
b.logf("Start: already running; sending notify")
|
||||
nm := b.netMap
|
||||
state := b.state
|
||||
@@ -1340,16 +1390,14 @@ func (b *LocalBackend) Start(opts ipn.Options) error {
|
||||
hostinfo.Userspace.Set(b.sys.IsNetstack())
|
||||
hostinfo.UserspaceRouter.Set(b.sys.IsNetstackRouter())
|
||||
|
||||
if b.cc != nil {
|
||||
// TODO(apenwarr): avoid the need to reinit controlclient.
|
||||
// This will trigger a full relogin/reconfigure cycle every
|
||||
// time a Handle reconnects to the backend. Ideally, we
|
||||
// would send the new Prefs and everything would get back
|
||||
// into sync with the minimal changes. But that's not how it
|
||||
// is right now, which is a sign that the code is still too
|
||||
// complicated.
|
||||
b.resetControlClientLockedAsync()
|
||||
}
|
||||
// TODO(apenwarr): avoid the need to reinit controlclient.
|
||||
// This will trigger a full relogin/reconfigure cycle every
|
||||
// time a Handle reconnects to the backend. Ideally, we
|
||||
// would send the new Prefs and everything would get back
|
||||
// into sync with the minimal changes. But that's not how it
|
||||
// is right now, which is a sign that the code is still too
|
||||
// complicated.
|
||||
prevCC := b.resetControlClientLocked()
|
||||
httpTestClient := b.httpTestClient
|
||||
|
||||
if b.hostinfo != nil {
|
||||
@@ -1419,7 +1467,7 @@ func (b *LocalBackend) Start(opts ipn.Options) error {
|
||||
})
|
||||
}
|
||||
|
||||
discoPublic := b.e.DiscoPublicKey()
|
||||
discoPublic := b.magicConn().DiscoPublicKey()
|
||||
|
||||
var err error
|
||||
|
||||
@@ -1429,6 +1477,10 @@ func (b *LocalBackend) Start(opts ipn.Options) error {
|
||||
debugFlags = append([]string{"netstack"}, debugFlags...)
|
||||
}
|
||||
|
||||
if prevCC != nil {
|
||||
prevCC.Shutdown()
|
||||
}
|
||||
|
||||
// TODO(apenwarr): The only way to change the ServerURL is to
|
||||
// re-run b.Start(), because this is the only place we create a
|
||||
// new controlclient. SetPrefs() allows you to overwrite ServerURL,
|
||||
@@ -1440,7 +1492,6 @@ func (b *LocalBackend) Start(opts ipn.Options) error {
|
||||
ServerURL: serverURL,
|
||||
AuthKey: opts.AuthKey,
|
||||
Hostinfo: hostinfo,
|
||||
NewDecompressor: b.newDecompressor,
|
||||
HTTPTestClient: httpTestClient,
|
||||
DiscoPublicKey: discoPublic,
|
||||
DebugFlags: debugFlags,
|
||||
@@ -1453,6 +1504,7 @@ func (b *LocalBackend) Start(opts ipn.Options) error {
|
||||
Observer: b,
|
||||
C2NHandler: http.HandlerFunc(b.handleC2N),
|
||||
DialPlan: &b.dialPlan, // pointer because it can't be copied
|
||||
ControlKnobs: b.sys.ControlKnobs(),
|
||||
|
||||
// Don't warn about broken Linux IP forwarding when
|
||||
// netstack is being used.
|
||||
@@ -1463,6 +1515,13 @@ func (b *LocalBackend) Start(opts ipn.Options) error {
|
||||
}
|
||||
|
||||
b.mu.Lock()
|
||||
// Even though we reset b.cc above, we might have raced with
|
||||
// another Start() call. If so, shut down the previous one again
|
||||
// as we do not know if it was created with the same options.
|
||||
prevCC = b.resetControlClientLocked()
|
||||
if prevCC != nil {
|
||||
defer prevCC.Shutdown() // must be called after b.mu is unlocked
|
||||
}
|
||||
b.cc = cc
|
||||
b.ccAuto, _ = cc.(*controlclient.Auto)
|
||||
endpoints := b.endpoints
|
||||
@@ -1486,7 +1545,7 @@ func (b *LocalBackend) Start(opts ipn.Options) error {
|
||||
}
|
||||
cc.SetTKAHead(tkaHead)
|
||||
|
||||
b.e.SetNetInfoCallback(b.setNetInfo)
|
||||
b.magicConn().SetNetInfoCallback(b.setNetInfo)
|
||||
|
||||
blid := b.backendLogID.String()
|
||||
b.logf("Backend: logs: be:%v fe:%v", blid, opts.FrontendLogID)
|
||||
@@ -1921,6 +1980,8 @@ func (b *LocalBackend) ResendHostinfoIfNeeded() {
|
||||
func (b *LocalBackend) WatchNotifications(ctx context.Context, mask ipn.NotifyWatchOpt, onWatchAdded func(), fn func(roNotify *ipn.Notify) (keepGoing bool)) {
|
||||
ch := make(chan *ipn.Notify, 128)
|
||||
|
||||
sessionID := rands.HexString(16)
|
||||
|
||||
origFn := fn
|
||||
if mask&ipn.NotifyNoPrivateKeys != 0 {
|
||||
fn = func(n *ipn.Notify) bool {
|
||||
@@ -1942,10 +2003,13 @@ func (b *LocalBackend) WatchNotifications(ctx context.Context, mask ipn.NotifyWa
|
||||
var ini *ipn.Notify
|
||||
|
||||
b.mu.Lock()
|
||||
b.activeWatchSessions.Add(sessionID)
|
||||
|
||||
const initialBits = ipn.NotifyInitialState | ipn.NotifyInitialPrefs | ipn.NotifyInitialNetMap
|
||||
if mask&initialBits != 0 {
|
||||
ini = &ipn.Notify{Version: version.Long()}
|
||||
if mask&ipn.NotifyInitialState != 0 {
|
||||
ini.SessionID = sessionID
|
||||
ini.State = ptr.To(b.state)
|
||||
if b.state == ipn.NeedsLogin {
|
||||
ini.BrowseToURL = ptr.To(b.authURLSticky)
|
||||
@@ -1965,6 +2029,7 @@ func (b *LocalBackend) WatchNotifications(ctx context.Context, mask ipn.NotifyWa
|
||||
defer func() {
|
||||
b.mu.Lock()
|
||||
delete(b.notifyWatchers, handle)
|
||||
delete(b.activeWatchSessions, sessionID)
|
||||
b.mu.Unlock()
|
||||
}()
|
||||
|
||||
@@ -1995,6 +2060,10 @@ func (b *LocalBackend) WatchNotifications(ctx context.Context, mask ipn.NotifyWa
|
||||
go b.pollRequestEngineStatus(ctx)
|
||||
}
|
||||
|
||||
// TODO(marwan-at-work): check err
|
||||
// TODO(marwan-at-work): streaming background logs?
|
||||
defer b.DeleteForegroundSession(sessionID)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -2150,6 +2219,9 @@ func (b *LocalBackend) tellClientToBrowseToURL(url string) {
|
||||
// onClientVersion is called on MapResponse updates when a MapResponse contains
|
||||
// a non-nil ClientVersion message.
|
||||
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
|
||||
@@ -2758,7 +2830,7 @@ func (b *LocalBackend) SetPrefs(newp *ipn.Prefs) {
|
||||
// doesn't affect security or correctness. And we also don't expect people to
|
||||
// modify their ServeConfig in raw mode.
|
||||
func (b *LocalBackend) wantIngressLocked() bool {
|
||||
return b.serveConfig.Valid() && b.serveConfig.AllowFunnel().Len() > 0
|
||||
return b.serveConfig.Valid() && b.serveConfig.HasAllowFunnel()
|
||||
}
|
||||
|
||||
// setPrefsLockedOnEntry requires b.mu be held to call it, but it
|
||||
@@ -2824,7 +2896,7 @@ func (b *LocalBackend) setPrefsLockedOnEntry(caller string, newp *ipn.Prefs) ipn
|
||||
}
|
||||
|
||||
if netMap != nil {
|
||||
b.e.SetDERPMap(netMap.DERPMap)
|
||||
b.magicConn().SetDERPMap(netMap.DERPMap)
|
||||
}
|
||||
|
||||
if !oldp.WantRunning() && newp.WantRunning {
|
||||
@@ -3056,7 +3128,7 @@ func (b *LocalBackend) authReconfig() {
|
||||
return
|
||||
}
|
||||
|
||||
oneCGNATRoute := shouldUseOneCGNATRoute(b.logf, version.OS())
|
||||
oneCGNATRoute := shouldUseOneCGNATRoute(b.logf, b.sys.ControlKnobs(), version.OS())
|
||||
rcfg := b.routerConfig(cfg, prefs, oneCGNATRoute)
|
||||
dcfg := dnsConfigForNetmap(nm, prefs, b.logf, version.OS())
|
||||
|
||||
@@ -3074,11 +3146,13 @@ func (b *LocalBackend) authReconfig() {
|
||||
//
|
||||
// The versionOS is a Tailscale-style version ("iOS", "macOS") and not
|
||||
// a runtime.GOOS.
|
||||
func shouldUseOneCGNATRoute(logf logger.Logf, versionOS string) bool {
|
||||
// Explicit enabling or disabling always take precedence.
|
||||
if v, ok := controlclient.ControlOneCGNATSetting().Get(); ok {
|
||||
logf("[v1] shouldUseOneCGNATRoute: explicit=%v", v)
|
||||
return v
|
||||
func shouldUseOneCGNATRoute(logf logger.Logf, controlKnobs *controlknobs.Knobs, versionOS string) bool {
|
||||
if controlKnobs != nil {
|
||||
// Explicit enabling or disabling always take precedence.
|
||||
if v, ok := controlKnobs.OneCGNAT.Load().Get(); ok {
|
||||
logf("[v1] shouldUseOneCGNATRoute: explicit=%v", v)
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
// Also prefer to do this on the Mac, so that we don't need to constantly
|
||||
@@ -3734,10 +3808,11 @@ func (b *LocalBackend) NodeKey() key.NodePublic {
|
||||
return b.pm.CurrentPrefs().Persist().PublicNodeKey()
|
||||
}
|
||||
|
||||
// nextState returns the state the backend seems to be in, based on
|
||||
// nextStateLocked returns the state the backend seems to be in, based on
|
||||
// its internal state.
|
||||
func (b *LocalBackend) nextState() ipn.State {
|
||||
b.mu.Lock()
|
||||
//
|
||||
// b.mu must be held
|
||||
func (b *LocalBackend) nextStateLocked() ipn.State {
|
||||
var (
|
||||
cc = b.cc
|
||||
netMap = b.netMap
|
||||
@@ -3753,10 +3828,9 @@ func (b *LocalBackend) nextState() ipn.State {
|
||||
wantRunning = p.WantRunning()
|
||||
loggedOut = p.LoggedOut()
|
||||
}
|
||||
b.mu.Unlock()
|
||||
|
||||
switch {
|
||||
case !wantRunning && !loggedOut && !blocked && b.hasNodeKey():
|
||||
case !wantRunning && !loggedOut && !blocked && b.hasNodeKeyLocked():
|
||||
return ipn.Stopped
|
||||
case netMap == nil:
|
||||
if (cc != nil && cc.AuthCantContinue()) || loggedOut {
|
||||
@@ -3819,7 +3893,8 @@ func (b *LocalBackend) RequestEngineStatus() {
|
||||
// TODO(apenwarr): use a channel or something to prevent reentrancy?
|
||||
// Or maybe just call the state machine from fewer places.
|
||||
func (b *LocalBackend) stateMachine() {
|
||||
b.enterState(b.nextState())
|
||||
b.mu.Lock()
|
||||
b.enterStateLockedOnEntry(b.nextStateLocked())
|
||||
}
|
||||
|
||||
// stopEngineAndWait deconfigures the local network data plane, and
|
||||
@@ -3847,12 +3922,12 @@ func (b *LocalBackend) requestEngineStatusAndWait() {
|
||||
b.statusLock.Unlock()
|
||||
}
|
||||
|
||||
// resetControlClientLockedAsync sets b.cc to nil, and starts a
|
||||
// goroutine to Shutdown the old client. It does not wait for the
|
||||
// shutdown to complete.
|
||||
func (b *LocalBackend) resetControlClientLockedAsync() {
|
||||
// resetControlClientLocked sets b.cc to nil and returns the old value. If the
|
||||
// returned value is non-nil, the caller must call Shutdown on it after
|
||||
// releasing b.mu.
|
||||
func (b *LocalBackend) resetControlClientLocked() controlclient.Client {
|
||||
if b.cc == nil {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
// When we clear the control client, stop any outstanding netmap expiry
|
||||
@@ -3868,9 +3943,10 @@ func (b *LocalBackend) resetControlClientLockedAsync() {
|
||||
// will abort.
|
||||
b.numClientStatusCalls.Add(1)
|
||||
}
|
||||
b.cc.Shutdown()
|
||||
prev := b.cc
|
||||
b.cc = nil
|
||||
b.ccAuto = nil
|
||||
return prev
|
||||
}
|
||||
|
||||
// ResetForClientDisconnect resets the backend for GUI clients running
|
||||
@@ -3880,11 +3956,15 @@ func (b *LocalBackend) resetControlClientLockedAsync() {
|
||||
// don't want to the user to have to reauthenticate in the future
|
||||
// when they restart the GUI.
|
||||
func (b *LocalBackend) ResetForClientDisconnect() {
|
||||
defer b.enterState(ipn.Stopped)
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.logf("LocalBackend.ResetForClientDisconnect")
|
||||
b.resetControlClientLockedAsync()
|
||||
|
||||
b.mu.Lock()
|
||||
prevCC := b.resetControlClientLocked()
|
||||
if prevCC != nil {
|
||||
// Needs to happen without b.mu held.
|
||||
defer prevCC.Shutdown()
|
||||
}
|
||||
|
||||
b.setNetMapLocked(nil)
|
||||
b.pm.Reset()
|
||||
b.keyExpired = false
|
||||
@@ -3892,6 +3972,7 @@ func (b *LocalBackend) ResetForClientDisconnect() {
|
||||
b.authURLSticky = ""
|
||||
b.activeLogin = ""
|
||||
b.setAtomicValuesFromPrefsLocked(ipn.PrefsView{})
|
||||
b.enterStateLockedOnEntry(ipn.Stopped)
|
||||
}
|
||||
|
||||
func (b *LocalBackend) ShouldRunSSH() bool { return b.sshAtomicBool.Load() && envknob.CanSSHD() }
|
||||
@@ -3989,6 +4070,9 @@ func hasCapability(nm *netmap.NetworkMap, cap string) bool {
|
||||
// Tailscale is turned off.
|
||||
func (b *LocalBackend) setNetMapLocked(nm *netmap.NetworkMap) {
|
||||
b.dialer.SetNetMap(nm)
|
||||
if ns, ok := b.sys.Netstack.GetOK(); ok {
|
||||
ns.UpdateNetstackIPs(nm)
|
||||
}
|
||||
var login string
|
||||
if nm != nil {
|
||||
login = cmpx.Or(nm.UserProfiles[nm.User()].LoginName, "<missing-profile>")
|
||||
@@ -3997,6 +4081,7 @@ func (b *LocalBackend) setNetMapLocked(nm *netmap.NetworkMap) {
|
||||
if login != b.activeLogin {
|
||||
b.logf("active login: %v", login)
|
||||
b.activeLogin = login
|
||||
b.lastProfileID = b.pm.CurrentProfile().ID
|
||||
}
|
||||
b.pauseOrResumeControlClientLocked()
|
||||
|
||||
@@ -4066,6 +4151,10 @@ func (b *LocalBackend) setDebugLogsByCapabilityLocked(nm *netmap.NetworkMap) {
|
||||
}
|
||||
}
|
||||
|
||||
// reloadServeConfigLocked reloads the serve config from the store or resets the
|
||||
// serve config to nil if not logged in. The "changed" parameter, when false, instructs
|
||||
// the method to only run the reset-logic and not reload the store from memory to ensure
|
||||
// foreground sessions are not removed if they are not saved on disk.
|
||||
func (b *LocalBackend) reloadServeConfigLocked(prefs ipn.PrefsView) {
|
||||
if b.netMap == nil || !b.netMap.SelfNode.Valid() || !prefs.Valid() || b.pm.CurrentProfile().ID == "" {
|
||||
// We're not logged in, so we don't have a profile.
|
||||
@@ -4074,6 +4163,7 @@ func (b *LocalBackend) reloadServeConfigLocked(prefs ipn.PrefsView) {
|
||||
b.serveConfig = ipn.ServeConfigView{}
|
||||
return
|
||||
}
|
||||
|
||||
confKey := ipn.ServeConfigKey(b.pm.CurrentProfile().ID)
|
||||
// TODO(maisem,bradfitz): prevent reading the config from disk
|
||||
// if the profile has not changed.
|
||||
@@ -4093,6 +4183,12 @@ func (b *LocalBackend) reloadServeConfigLocked(prefs ipn.PrefsView) {
|
||||
b.serveConfig = ipn.ServeConfigView{}
|
||||
return
|
||||
}
|
||||
|
||||
// remove inactive sessions
|
||||
maps.DeleteFunc(conf.Foreground, func(s string, sc *ipn.ServeConfig) bool {
|
||||
return !b.activeWatchSessions.Contains(s)
|
||||
})
|
||||
|
||||
b.serveConfig = conf.View()
|
||||
}
|
||||
|
||||
@@ -4110,7 +4206,7 @@ func (b *LocalBackend) setTCPPortsInterceptedFromNetmapAndPrefsLocked(prefs ipn.
|
||||
b.reloadServeConfigLocked(prefs)
|
||||
if b.serveConfig.Valid() {
|
||||
servePorts := make([]uint16, 0, 3)
|
||||
b.serveConfig.TCP().Range(func(port uint16, _ ipn.TCPPortHandlerView) bool {
|
||||
b.serveConfig.RangeOverTCPs(func(port uint16, _ ipn.TCPPortHandlerView) bool {
|
||||
if port > 0 {
|
||||
servePorts = append(servePorts, uint16(port))
|
||||
}
|
||||
@@ -4143,7 +4239,7 @@ func (b *LocalBackend) setServeProxyHandlersLocked() {
|
||||
return
|
||||
}
|
||||
var backends map[string]bool
|
||||
b.serveConfig.Web().Range(func(_ ipn.HostPort, conf ipn.WebServerConfigView) (cont bool) {
|
||||
b.serveConfig.RangeOverWebs(func(_ ipn.HostPort, conf ipn.WebServerConfigView) (cont bool) {
|
||||
conf.Handlers().Range(func(_ string, h ipn.HTTPHandlerView) (cont bool) {
|
||||
backend := h.Proxy()
|
||||
if backend == "" {
|
||||
@@ -4599,29 +4695,22 @@ func peerCanProxyDNS(p tailcfg.NodeView) bool {
|
||||
}
|
||||
|
||||
func (b *LocalBackend) DebugRebind() error {
|
||||
mc, err := b.magicConn()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mc.Rebind()
|
||||
b.magicConn().Rebind()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *LocalBackend) DebugReSTUN() error {
|
||||
mc, err := b.magicConn()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mc.ReSTUN("explicit-debug")
|
||||
b.magicConn().ReSTUN("explicit-debug")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *LocalBackend) magicConn() (*magicsock.Conn, error) {
|
||||
mc, ok := b.sys.MagicSock.GetOK()
|
||||
if !ok {
|
||||
return nil, errors.New("failed to get magicsock from sys")
|
||||
}
|
||||
return mc, nil
|
||||
// ControlKnobs returns the node's control knobs.
|
||||
func (b *LocalBackend) ControlKnobs() *controlknobs.Knobs {
|
||||
return b.sys.ControlKnobs()
|
||||
}
|
||||
|
||||
func (b *LocalBackend) magicConn() *magicsock.Conn {
|
||||
return b.sys.MagicSock.Get()
|
||||
}
|
||||
|
||||
type keyProvingNoiseRoundTripper struct {
|
||||
@@ -4997,7 +5086,10 @@ func (b *LocalBackend) ListProfiles() []ipn.LoginProfile {
|
||||
// called to register it as new node.
|
||||
func (b *LocalBackend) ResetAuth() error {
|
||||
b.mu.Lock()
|
||||
b.resetControlClientLockedAsync()
|
||||
prevCC := b.resetControlClientLocked()
|
||||
if prevCC != nil {
|
||||
defer prevCC.Shutdown() // call must happen after release b.mu
|
||||
}
|
||||
if err := b.clearMachineKeyLocked(); err != nil {
|
||||
b.mu.Unlock()
|
||||
return err
|
||||
@@ -5061,12 +5153,7 @@ func (b *LocalBackend) GetPeerEndpointChanges(ctx context.Context, ip netip.Addr
|
||||
}
|
||||
peer := pip.Node
|
||||
|
||||
mc, err := b.magicConn()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting magicsock conn: %w", err)
|
||||
}
|
||||
|
||||
chs, err := mc.GetEndpointChanges(peer)
|
||||
chs, err := b.magicConn().GetEndpointChanges(peer)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting endpoint changes: %w", err)
|
||||
}
|
||||
@@ -5083,9 +5170,5 @@ func (b *LocalBackend) DebugBreakTCPConns() error {
|
||||
}
|
||||
|
||||
func (b *LocalBackend) DebugBreakDERPConns() error {
|
||||
mc, err := b.magicConn()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return mc.DebugBreakDERPConns()
|
||||
return b.magicConn().DebugBreakDERPConns()
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ import (
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/types/logid"
|
||||
"tailscale.com/types/netmap"
|
||||
"tailscale.com/types/ptr"
|
||||
"tailscale.com/util/set"
|
||||
"tailscale.com/wgengine"
|
||||
"tailscale.com/wgengine/filter"
|
||||
"tailscale.com/wgengine/wgcfg"
|
||||
@@ -843,6 +845,9 @@ var _ legacyBackend = (*LocalBackend)(nil)
|
||||
|
||||
func TestWatchNotificationsCallbacks(t *testing.T) {
|
||||
b := new(LocalBackend)
|
||||
// activeWatchSessions is typically set in NewLocalBackend
|
||||
// so WatchNotifications expects it to be non-empty.
|
||||
b.activeWatchSessions = make(set.Set[string])
|
||||
n := new(ipn.Notify)
|
||||
b.WatchNotifications(context.Background(), 0, func() {
|
||||
b.mu.Lock()
|
||||
@@ -875,3 +880,75 @@ func TestWatchNotificationsCallbacks(t *testing.T) {
|
||||
t.Fatalf("unexpected number of watchers in new LocalBackend, want: 0 got: %v", len(b.notifyWatchers))
|
||||
}
|
||||
}
|
||||
|
||||
// tests LocalBackend.updateNetmapDeltaLocked
|
||||
func TestUpdateNetmapDelta(t *testing.T) {
|
||||
var b LocalBackend
|
||||
if b.updateNetmapDeltaLocked(nil) {
|
||||
t.Errorf("updateNetmapDeltaLocked() = true, want false with nil netmap")
|
||||
}
|
||||
|
||||
b.netMap = &netmap.NetworkMap{}
|
||||
for i := 0; i < 5; i++ {
|
||||
b.netMap.Peers = append(b.netMap.Peers, (&tailcfg.Node{ID: (tailcfg.NodeID(i) + 1)}).View())
|
||||
}
|
||||
|
||||
someTime := time.Unix(123, 0)
|
||||
muts, ok := netmap.MutationsFromMapResponse(&tailcfg.MapResponse{
|
||||
PeersChangedPatch: []*tailcfg.PeerChange{
|
||||
{
|
||||
NodeID: 1,
|
||||
DERPRegion: 1,
|
||||
},
|
||||
{
|
||||
NodeID: 2,
|
||||
Online: ptr.To(true),
|
||||
},
|
||||
{
|
||||
NodeID: 3,
|
||||
Online: ptr.To(false),
|
||||
},
|
||||
{
|
||||
NodeID: 4,
|
||||
LastSeen: ptr.To(someTime),
|
||||
},
|
||||
},
|
||||
}, someTime)
|
||||
if !ok {
|
||||
t.Fatal("netmap.MutationsFromMapResponse failed")
|
||||
}
|
||||
|
||||
if !b.updateNetmapDeltaLocked(muts) {
|
||||
t.Fatalf("updateNetmapDeltaLocked() = false, want true with new netmap")
|
||||
}
|
||||
|
||||
wants := []*tailcfg.Node{
|
||||
{
|
||||
ID: 1,
|
||||
DERP: "", // unmodified by the delta
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
Online: ptr.To(true),
|
||||
},
|
||||
{
|
||||
ID: 3,
|
||||
Online: ptr.To(false),
|
||||
},
|
||||
{
|
||||
ID: 4,
|
||||
LastSeen: ptr.To(someTime),
|
||||
},
|
||||
}
|
||||
for _, want := range wants {
|
||||
idx := b.netMap.PeerIndexByNodeID(want.ID)
|
||||
if idx == -1 {
|
||||
t.Errorf("ID %v not found in netmap", want.ID)
|
||||
continue
|
||||
}
|
||||
got := b.netMap.Peers[idx].AsStruct()
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("netmap.Peer %v wrong.\n got: %v\nwant: %v", want.ID, logger.AsJSON(got), logger.AsJSON(want))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ import (
|
||||
|
||||
type observerFunc func(controlclient.Status)
|
||||
|
||||
func (f observerFunc) SetControlClientStatus(s controlclient.Status) {
|
||||
func (f observerFunc) SetControlClientStatus(_ controlclient.Client, s controlclient.Status) {
|
||||
f(s)
|
||||
}
|
||||
|
||||
|
||||
@@ -1234,11 +1234,7 @@ func (h *peerAPIHandler) handleServeMagicsock(w http.ResponseWriter, r *http.Req
|
||||
http.Error(w, "denied; no debug access", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if mc, ok := h.ps.b.sys.MagicSock.GetOK(); ok {
|
||||
mc.ServeHTTPDebug(w, r)
|
||||
return
|
||||
}
|
||||
http.Error(w, "miswired", 500)
|
||||
h.ps.b.magicConn().ServeHTTPDebug(w, r)
|
||||
}
|
||||
|
||||
func (h *peerAPIHandler) handleServeMetrics(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -5,7 +5,9 @@ package ipnlocal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -23,7 +25,6 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/logtail/backoff"
|
||||
"tailscale.com/net/netutil"
|
||||
@@ -34,6 +35,11 @@ import (
|
||||
"tailscale.com/version"
|
||||
)
|
||||
|
||||
// ErrETagMismatch signals that the given
|
||||
// If-Match header does not match with the
|
||||
// current etag of a resource.
|
||||
var ErrETagMismatch = errors.New("etag mismatch")
|
||||
|
||||
// serveHTTPContextKey is the context.Value key for a *serveHTTPContext.
|
||||
type serveHTTPContextKey struct{}
|
||||
|
||||
@@ -215,10 +221,15 @@ func (b *LocalBackend) updateServeTCPPortNetMapAddrListenersLocked(ports []uint1
|
||||
}
|
||||
|
||||
// SetServeConfig establishes or replaces the current serve config.
|
||||
func (b *LocalBackend) SetServeConfig(config *ipn.ServeConfig) error {
|
||||
// ETag is an optional parameter to enforce Optimistic Concurrency Control.
|
||||
// If it is an empty string, then the config will be overwritten.
|
||||
func (b *LocalBackend) SetServeConfig(config *ipn.ServeConfig, etag string) error {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.setServeConfigLocked(config, etag)
|
||||
}
|
||||
|
||||
func (b *LocalBackend) setServeConfigLocked(config *ipn.ServeConfig, etag string) error {
|
||||
prefs := b.pm.CurrentPrefs()
|
||||
if config.IsFunnelOn() && prefs.ShieldsUp() {
|
||||
return errors.New("Unable to turn on Funnel while shields-up is enabled")
|
||||
@@ -231,8 +242,24 @@ func (b *LocalBackend) SetServeConfig(config *ipn.ServeConfig) error {
|
||||
if !nm.SelfNode.Valid() {
|
||||
return errors.New("netMap SelfNode is nil")
|
||||
}
|
||||
profileID := b.pm.CurrentProfile().ID
|
||||
confKey := ipn.ServeConfigKey(profileID)
|
||||
|
||||
// If etag is present, check that it has
|
||||
// not changed from the last config.
|
||||
if etag != "" {
|
||||
// Note that we marshal b.serveConfig
|
||||
// and not use b.lastServeConfJSON as that might
|
||||
// be a Go nil value, which produces a different
|
||||
// checksum from a JSON "null" value.
|
||||
previousCfg, err := json.Marshal(b.serveConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error encoding previous config: %w", err)
|
||||
}
|
||||
sum := sha256.Sum256(previousCfg)
|
||||
previousEtag := hex.EncodeToString(sum[:])
|
||||
if etag != previousEtag {
|
||||
return ErrETagMismatch
|
||||
}
|
||||
}
|
||||
|
||||
var bs []byte
|
||||
if config != nil {
|
||||
@@ -242,6 +269,9 @@ func (b *LocalBackend) SetServeConfig(config *ipn.ServeConfig) error {
|
||||
}
|
||||
bs = j
|
||||
}
|
||||
|
||||
profileID := b.pm.CurrentProfile().ID
|
||||
confKey := ipn.ServeConfigKey(profileID)
|
||||
if err := b.store.WriteState(confKey, bs); err != nil {
|
||||
return fmt.Errorf("writing ServeConfig to StateStore: %w", err)
|
||||
}
|
||||
@@ -258,164 +288,18 @@ func (b *LocalBackend) ServeConfig() ipn.ServeConfigView {
|
||||
return b.serveConfig
|
||||
}
|
||||
|
||||
// StreamServe opens a stream to write any incoming connections made
|
||||
// to the given HostPort out to the listening io.Writer.
|
||||
//
|
||||
// If Serve and Funnel were not already enabled for the HostPort in the ServeConfig,
|
||||
// the backend enables it for the duration of the context's lifespan and
|
||||
// then turns it back off once the context is closed. If either are already enabled,
|
||||
// then they remain that way but logs are still streamed
|
||||
func (b *LocalBackend) StreamServe(ctx context.Context, w io.Writer, req ipn.ServeStreamRequest) (err error) {
|
||||
f, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
return errors.New("writer not a flusher")
|
||||
}
|
||||
f.Flush()
|
||||
|
||||
port, err := req.HostPort.Port()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Turn on Funnel for the given HostPort.
|
||||
sc := b.ServeConfig().AsStruct()
|
||||
if sc == nil {
|
||||
sc = &ipn.ServeConfig{}
|
||||
}
|
||||
setHandler(sc, req)
|
||||
if err := b.SetServeConfig(sc); err != nil {
|
||||
return fmt.Errorf("errro setting serve config: %w", err)
|
||||
}
|
||||
// Defer turning off Funnel once stream ends.
|
||||
defer func() {
|
||||
sc := b.ServeConfig().AsStruct()
|
||||
deleteHandler(sc, req, port)
|
||||
err = errors.Join(err, b.SetServeConfig(sc))
|
||||
}()
|
||||
|
||||
var writeErrs []error
|
||||
writeToStream := func(log ipn.FunnelRequestLog) {
|
||||
jsonLog, err := json.Marshal(log)
|
||||
if err != nil {
|
||||
writeErrs = append(writeErrs, err)
|
||||
return
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "%s\n", jsonLog); err != nil {
|
||||
writeErrs = append(writeErrs, err)
|
||||
return
|
||||
}
|
||||
f.Flush()
|
||||
}
|
||||
|
||||
// Hook up connections stream.
|
||||
// DeleteForegroundSession deletes a ServeConfig's foreground session
|
||||
// in the LocalBackend if it exists. It also ensures check, delete, and
|
||||
// set operations happen within the same mutex lock to avoid any races.
|
||||
func (b *LocalBackend) DeleteForegroundSession(sessionID string) error {
|
||||
b.mu.Lock()
|
||||
mak.NonNilMapForJSON(&b.serveStreamers)
|
||||
if b.serveStreamers[port] == nil {
|
||||
b.serveStreamers[port] = make(map[uint32]func(ipn.FunnelRequestLog))
|
||||
}
|
||||
id := uuid.New().ID()
|
||||
b.serveStreamers[port][id] = writeToStream
|
||||
b.mu.Unlock()
|
||||
|
||||
// Clean up streamer when done.
|
||||
defer func() {
|
||||
b.mu.Lock()
|
||||
delete(b.serveStreamers[port], id)
|
||||
b.mu.Unlock()
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// Triggered by foreground `tailscale funnel` process
|
||||
// (the streamer) getting closed, or by turning off Tailscale.
|
||||
}
|
||||
|
||||
return errors.Join(writeErrs...)
|
||||
}
|
||||
|
||||
func setHandler(sc *ipn.ServeConfig, req ipn.ServeStreamRequest) {
|
||||
if sc.TCP == nil {
|
||||
sc.TCP = make(map[uint16]*ipn.TCPPortHandler)
|
||||
}
|
||||
if _, ok := sc.TCP[443]; !ok {
|
||||
sc.TCP[443] = &ipn.TCPPortHandler{
|
||||
HTTPS: true,
|
||||
}
|
||||
}
|
||||
if sc.Web == nil {
|
||||
sc.Web = make(map[ipn.HostPort]*ipn.WebServerConfig)
|
||||
}
|
||||
wsc, ok := sc.Web[req.HostPort]
|
||||
if !ok {
|
||||
wsc = &ipn.WebServerConfig{}
|
||||
sc.Web[req.HostPort] = wsc
|
||||
}
|
||||
if wsc.Handlers == nil {
|
||||
wsc.Handlers = make(map[string]*ipn.HTTPHandler)
|
||||
}
|
||||
wsc.Handlers[req.MountPoint] = &ipn.HTTPHandler{
|
||||
Proxy: req.Source,
|
||||
}
|
||||
if req.Funnel {
|
||||
if sc.AllowFunnel == nil {
|
||||
sc.AllowFunnel = make(map[ipn.HostPort]bool)
|
||||
}
|
||||
sc.AllowFunnel[req.HostPort] = true
|
||||
}
|
||||
}
|
||||
|
||||
func deleteHandler(sc *ipn.ServeConfig, req ipn.ServeStreamRequest, port uint16) {
|
||||
delete(sc.AllowFunnel, req.HostPort)
|
||||
if sc.TCP != nil {
|
||||
delete(sc.TCP, port)
|
||||
}
|
||||
if sc.Web == nil {
|
||||
return
|
||||
}
|
||||
if sc.Web[req.HostPort] == nil {
|
||||
return
|
||||
}
|
||||
wsc, ok := sc.Web[req.HostPort]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if wsc.Handlers == nil {
|
||||
return
|
||||
}
|
||||
if _, ok := wsc.Handlers[req.MountPoint]; !ok {
|
||||
return
|
||||
}
|
||||
delete(wsc.Handlers, req.MountPoint)
|
||||
if len(wsc.Handlers) == 0 {
|
||||
delete(sc.Web, req.HostPort)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *LocalBackend) maybeLogServeConnection(destPort uint16, srcAddr netip.AddrPort) {
|
||||
b.mu.Lock()
|
||||
streamers := b.serveStreamers[destPort]
|
||||
b.mu.Unlock()
|
||||
if len(streamers) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var log ipn.FunnelRequestLog
|
||||
log.SrcAddr = srcAddr
|
||||
log.Time = b.clock.Now()
|
||||
|
||||
if node, user, ok := b.WhoIs(srcAddr); ok {
|
||||
log.NodeName = node.ComputedName()
|
||||
if node.IsTagged() {
|
||||
log.NodeTags = node.Tags().AsSlice()
|
||||
} else {
|
||||
log.UserLoginName = user.LoginName
|
||||
log.UserDisplayName = user.DisplayName
|
||||
}
|
||||
}
|
||||
|
||||
for _, stream := range streamers {
|
||||
stream(log)
|
||||
defer b.mu.Unlock()
|
||||
if !b.serveConfig.Valid() || !b.serveConfig.Foreground().Has(sessionID) {
|
||||
return nil
|
||||
}
|
||||
sc := b.serveConfig.AsStruct()
|
||||
delete(sc.Foreground, sessionID)
|
||||
return b.setServeConfigLocked(sc, "")
|
||||
}
|
||||
|
||||
func (b *LocalBackend) HandleIngressTCPConn(ingressPeer tailcfg.NodeView, target ipn.HostPort, srcAddr netip.AddrPort, getConnOrReset func() (net.Conn, bool), sendRST func()) {
|
||||
@@ -429,7 +313,7 @@ func (b *LocalBackend) HandleIngressTCPConn(ingressPeer tailcfg.NodeView, target
|
||||
return
|
||||
}
|
||||
|
||||
if !sc.AllowFunnel().Get(target) {
|
||||
if !sc.HasFunnelForTarget(target) {
|
||||
b.logf("localbackend: got ingress conn for unconfigured %q; rejecting", target)
|
||||
sendRST()
|
||||
return
|
||||
@@ -487,7 +371,7 @@ func (b *LocalBackend) tcpHandlerForServe(dport uint16, srcAddr netip.AddrPort)
|
||||
return nil
|
||||
}
|
||||
|
||||
tcph, ok := sc.TCP().GetOk(dport)
|
||||
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
|
||||
@@ -520,7 +404,6 @@ func (b *LocalBackend) tcpHandlerForServe(dport uint16, srcAddr netip.AddrPort)
|
||||
if backDst := tcph.TCPForward(); backDst != "" {
|
||||
return func(conn net.Conn) error {
|
||||
defer conn.Close()
|
||||
b.maybeLogServeConnection(dport, srcAddr)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
backConn, err := b.dialer.SystemDial(ctx, "tcp", backDst)
|
||||
cancel()
|
||||
@@ -683,15 +566,14 @@ func (b *LocalBackend) addTailscaleIdentityHeaders(r *httputil.ProxyRequest) {
|
||||
r.Out.Header.Set("Tailscale-Headers-Info", "https://tailscale.com/s/serve-headers")
|
||||
}
|
||||
|
||||
// serveWebHandler is an http.HandlerFunc that maps incoming requests to the
|
||||
// correct *http.
|
||||
func (b *LocalBackend) serveWebHandler(w http.ResponseWriter, r *http.Request) {
|
||||
h, mountPoint, ok := b.getServeHandler(r)
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if c, ok := getServeHTTPContext(r); ok {
|
||||
b.maybeLogServeConnection(c.DestPort, c.SrcAddr)
|
||||
}
|
||||
if s := h.Text(); s != "" {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
io.WriteString(w, s)
|
||||
@@ -827,7 +709,7 @@ func (b *LocalBackend) webServerConfig(hostname string, port uint16) (c ipn.WebS
|
||||
if !b.serveConfig.Valid() {
|
||||
return c, false
|
||||
}
|
||||
return b.serveConfig.Web().GetOk(key)
|
||||
return b.serveConfig.FindWeb(key)
|
||||
}
|
||||
|
||||
func (b *LocalBackend) getTLSServeCertForPort(port uint16) func(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
|
||||
@@ -6,7 +6,11 @@ package ipnlocal
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -24,6 +28,7 @@ import (
|
||||
"tailscale.com/types/logid"
|
||||
"tailscale.com/types/netmap"
|
||||
"tailscale.com/util/cmpx"
|
||||
"tailscale.com/util/mak"
|
||||
"tailscale.com/util/must"
|
||||
"tailscale.com/wgengine"
|
||||
)
|
||||
@@ -169,50 +174,82 @@ func TestGetServeHandler(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeHTTPProxy(t *testing.T) {
|
||||
sys := &tsd.System{}
|
||||
e, err := wgengine.NewUserspaceEngine(t.Logf, wgengine.Config{SetSubsystem: sys.Set})
|
||||
func getEtag(t *testing.T, b any) string {
|
||||
t.Helper()
|
||||
bts, err := json.Marshal(b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sys.Set(e)
|
||||
sys.Set(new(mem.Store))
|
||||
b, err := NewLocalBackend(t.Logf, logid.PublicID{}, sys, 0)
|
||||
sum := sha256.Sum256(bts)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func TestServeConfigETag(t *testing.T) {
|
||||
b := newTestBackend(t)
|
||||
|
||||
// a nil config with initial etag should succeed
|
||||
err := b.SetServeConfig(nil, getEtag(t, nil))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer b.Shutdown()
|
||||
dir := t.TempDir()
|
||||
b.SetVarRoot(dir)
|
||||
|
||||
pm := must.Get(newProfileManager(new(mem.Store), t.Logf))
|
||||
pm.currentProfile = &ipn.LoginProfile{ID: "id0"}
|
||||
b.pm = pm
|
||||
// a nil config with an invalid etag should fail
|
||||
err = b.SetServeConfig(nil, "abc")
|
||||
if !errors.Is(err, ErrETagMismatch) {
|
||||
t.Fatal("expected an error but got nil")
|
||||
}
|
||||
|
||||
b.netMap = &netmap.NetworkMap{
|
||||
SelfNode: (&tailcfg.Node{
|
||||
Name: "example.ts.net",
|
||||
}).View(),
|
||||
UserProfiles: map[tailcfg.UserID]tailcfg.UserProfile{
|
||||
tailcfg.UserID(1): {
|
||||
LoginName: "someone@example.com",
|
||||
DisplayName: "Some One",
|
||||
ProfilePicURL: "https://example.com/photo.jpg",
|
||||
},
|
||||
// a new config with no etag should succeed
|
||||
conf := &ipn.ServeConfig{
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
"example.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/": {Proxy: "http://127.0.0.1:3000"},
|
||||
}},
|
||||
},
|
||||
}
|
||||
b.nodeByAddr = map[netip.Addr]tailcfg.NodeView{
|
||||
netip.MustParseAddr("100.150.151.152"): (&tailcfg.Node{
|
||||
ComputedName: "some-peer",
|
||||
User: tailcfg.UserID(1),
|
||||
}).View(),
|
||||
netip.MustParseAddr("100.150.151.153"): (&tailcfg.Node{
|
||||
ComputedName: "some-tagged-peer",
|
||||
Tags: []string{"tag:server", "tag:test"},
|
||||
User: tailcfg.UserID(1),
|
||||
}).View(),
|
||||
err = b.SetServeConfig(conf, getEtag(t, nil))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
confView := b.ServeConfig()
|
||||
etag := getEtag(t, confView)
|
||||
if etag == "" {
|
||||
t.Fatal("expected to get an etag but got an empty string")
|
||||
}
|
||||
conf = confView.AsStruct()
|
||||
mak.Set(&conf.AllowFunnel, "example.ts.net:443", true)
|
||||
|
||||
// replacing an existing config with an invalid etag should fail
|
||||
err = b.SetServeConfig(conf, "invalid etag")
|
||||
if !errors.Is(err, ErrETagMismatch) {
|
||||
t.Fatalf("expected an etag mismatch error but got %v", err)
|
||||
}
|
||||
|
||||
// replacing an existing config with a valid etag should succeed
|
||||
err = b.SetServeConfig(conf, etag)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// replacing an existing config with a previous etag should fail
|
||||
err = b.SetServeConfig(nil, etag)
|
||||
if !errors.Is(err, ErrETagMismatch) {
|
||||
t.Fatalf("expected an etag mismatch error but got %v", err)
|
||||
}
|
||||
|
||||
// replacing an existing config with the new etag should succeed
|
||||
newCfg := b.ServeConfig()
|
||||
etag = getEtag(t, newCfg)
|
||||
err = b.SetServeConfig(nil, etag)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeHTTPProxy(t *testing.T) {
|
||||
b := newTestBackend(t)
|
||||
|
||||
// Start test serve endpoint.
|
||||
testServ := httptest.NewServer(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -232,7 +269,7 @@ func TestServeHTTPProxy(t *testing.T) {
|
||||
}},
|
||||
},
|
||||
}
|
||||
if err := b.SetServeConfig(conf); err != nil {
|
||||
if err := b.SetServeConfig(conf, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -309,6 +346,52 @@ func TestServeHTTPProxy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func newTestBackend(t *testing.T) *LocalBackend {
|
||||
sys := &tsd.System{}
|
||||
e, err := wgengine.NewUserspaceEngine(t.Logf, wgengine.Config{SetSubsystem: sys.Set})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sys.Set(e)
|
||||
sys.Set(new(mem.Store))
|
||||
b, err := NewLocalBackend(t.Logf, logid.PublicID{}, sys, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(b.Shutdown)
|
||||
dir := t.TempDir()
|
||||
b.SetVarRoot(dir)
|
||||
|
||||
pm := must.Get(newProfileManager(new(mem.Store), t.Logf))
|
||||
pm.currentProfile = &ipn.LoginProfile{ID: "id0"}
|
||||
b.pm = pm
|
||||
|
||||
b.netMap = &netmap.NetworkMap{
|
||||
SelfNode: (&tailcfg.Node{
|
||||
Name: "example.ts.net",
|
||||
}).View(),
|
||||
UserProfiles: map[tailcfg.UserID]tailcfg.UserProfile{
|
||||
tailcfg.UserID(1): {
|
||||
LoginName: "someone@example.com",
|
||||
DisplayName: "Some One",
|
||||
ProfilePicURL: "https://example.com/photo.jpg",
|
||||
},
|
||||
},
|
||||
}
|
||||
b.nodeByAddr = map[netip.Addr]tailcfg.NodeView{
|
||||
netip.MustParseAddr("100.150.151.152"): (&tailcfg.Node{
|
||||
ComputedName: "some-peer",
|
||||
User: tailcfg.UserID(1),
|
||||
}).View(),
|
||||
netip.MustParseAddr("100.150.151.153"): (&tailcfg.Node{
|
||||
ComputedName: "some-tagged-peer",
|
||||
Tags: []string{"tag:server", "tag:test"},
|
||||
User: tailcfg.UserID(1),
|
||||
}).View(),
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func TestServeFileOrDirectory(t *testing.T) {
|
||||
td := t.TempDir()
|
||||
writeFile := func(suffix, contents string) {
|
||||
|
||||
@@ -173,7 +173,7 @@ func (cc *mockControl) send(err error, url string, loginFinished bool, nm *netma
|
||||
} else if url == "" && err == nil && nm == nil {
|
||||
s.SetStateForTest(controlclient.StateNotAuthenticated)
|
||||
}
|
||||
cc.opts.Observer.SetControlClientStatus(s)
|
||||
cc.opts.Observer.SetControlClientStatus(cc, s)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"io"
|
||||
"log"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -69,8 +70,17 @@ type Status struct {
|
||||
// trailing periods, and without any "_acme-challenge." prefix.
|
||||
CertDomains []string
|
||||
|
||||
// Peer is the state of each peer, keyed by each peer's current public key.
|
||||
Peer map[key.NodePublic]*PeerStatus
|
||||
|
||||
// User contains profile information about UserIDs referenced by
|
||||
// PeerStatus.UserID, PeerStatus.AltSharerUserID, etc.
|
||||
User map[tailcfg.UserID]tailcfg.UserProfile
|
||||
|
||||
// ClientVersion, when non-nil, contains information about the latest
|
||||
// version of the Tailscale client that's available. Depending on
|
||||
// the platform and client settings, it may not be available.
|
||||
ClientVersion *tailcfg.ClientVersion
|
||||
}
|
||||
|
||||
// TKAKey describes a key trusted by network lock.
|
||||
@@ -188,6 +198,7 @@ type PeerStatusLite struct {
|
||||
NodeKey key.NodePublic
|
||||
}
|
||||
|
||||
// PeerStatus describes a peer node and its current state.
|
||||
type PeerStatus struct {
|
||||
ID tailcfg.StableNodeID
|
||||
PublicKey key.NodePublic
|
||||
@@ -281,6 +292,9 @@ type PeerStatus struct {
|
||||
Location *tailcfg.Location `json:",omitempty"`
|
||||
}
|
||||
|
||||
// StatusBuilder is a request to construct a Status. A new StatusBuilder is
|
||||
// passed to various subsystems which then call methods on it to populate state.
|
||||
// Call its Status method to return the final constructed Status.
|
||||
type StatusBuilder struct {
|
||||
WantPeers bool // whether caller wants peers
|
||||
|
||||
@@ -301,6 +315,8 @@ func (sb *StatusBuilder) MutateStatus(f func(*Status)) {
|
||||
f(&sb.st)
|
||||
}
|
||||
|
||||
// Status returns the status that has been built up so far from previous
|
||||
// calls to MutateStatus, MutateSelfStatus, AddPeer, etc.
|
||||
func (sb *StatusBuilder) Status() *Status {
|
||||
sb.mu.Lock()
|
||||
defer sb.mu.Unlock()
|
||||
@@ -665,23 +681,29 @@ func (pr *PingResult) ToPingResponse(pingType tailcfg.PingType) *tailcfg.PingRes
|
||||
}
|
||||
}
|
||||
|
||||
// SortPeers sorts peers by either their DNS name, hostname, Tailscale IP,
|
||||
// or ultimately their current public key.
|
||||
func SortPeers(peers []*PeerStatus) {
|
||||
sort.Slice(peers, func(i, j int) bool { return sortKey(peers[i]) < sortKey(peers[j]) })
|
||||
slices.SortStableFunc(peers, (*PeerStatus).compare)
|
||||
}
|
||||
|
||||
func sortKey(ps *PeerStatus) string {
|
||||
if ps.DNSName != "" {
|
||||
return ps.DNSName
|
||||
func (a *PeerStatus) compare(b *PeerStatus) int {
|
||||
if a.DNSName != "" || b.DNSName != "" {
|
||||
if v := strings.Compare(a.DNSName, b.DNSName); v != 0 {
|
||||
return v
|
||||
}
|
||||
}
|
||||
if ps.HostName != "" {
|
||||
return ps.HostName
|
||||
if a.HostName != "" || b.HostName != "" {
|
||||
if v := strings.Compare(a.HostName, b.HostName); v != 0 {
|
||||
return v
|
||||
}
|
||||
}
|
||||
// TODO(bradfitz): add PeerStatus.Less and avoid these allocs in a Less func.
|
||||
if len(ps.TailscaleIPs) > 0 {
|
||||
return ps.TailscaleIPs[0].String()
|
||||
if len(a.TailscaleIPs) > 0 && len(b.TailscaleIPs) > 0 {
|
||||
if v := a.TailscaleIPs[0].Compare(b.TailscaleIPs[0]); v != 0 {
|
||||
return v
|
||||
}
|
||||
}
|
||||
raw := ps.PublicKey.Raw32()
|
||||
return string(raw[:])
|
||||
return a.PublicKey.Compare(b.PublicKey)
|
||||
}
|
||||
|
||||
// DebugDERPRegionReport is the result of a "tailscale debug derp" command,
|
||||
|
||||
@@ -7,7 +7,7 @@ package localapi
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -49,6 +49,7 @@ import (
|
||||
"tailscale.com/util/httpm"
|
||||
"tailscale.com/util/mak"
|
||||
"tailscale.com/util/osdiag"
|
||||
"tailscale.com/util/rands"
|
||||
"tailscale.com/version"
|
||||
)
|
||||
|
||||
@@ -98,7 +99,6 @@ var handler = map[string]localAPIHandler{
|
||||
"set-expiry-sooner": (*Handler).serveSetExpirySooner,
|
||||
"start": (*Handler).serveStart,
|
||||
"status": (*Handler).serveStatus,
|
||||
"stream-serve": (*Handler).serveStreamServe,
|
||||
"tka/init": (*Handler).serveTKAInit,
|
||||
"tka/log": (*Handler).serveTKALog,
|
||||
"tka/modify": (*Handler).serveTKAModify,
|
||||
@@ -118,12 +118,6 @@ var handler = map[string]localAPIHandler{
|
||||
"query-feature": (*Handler).serveQueryFeature,
|
||||
}
|
||||
|
||||
func randHex(n int) string {
|
||||
b := make([]byte, n)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
var (
|
||||
// The clientmetrics package is stateful, but we want to expose a simple
|
||||
// imperative API to local clients, so we need to keep track of
|
||||
@@ -318,7 +312,7 @@ func (h *Handler) serveBugReport(w http.ResponseWriter, r *http.Request) {
|
||||
defer h.b.TryFlushLogs() // kick off upload after bugreport's done logging
|
||||
|
||||
logMarker := func() string {
|
||||
return fmt.Sprintf("BUG-%v-%v-%v", h.backendLogID, h.clock.Now().UTC().Format("20060102150405Z"), randHex(8))
|
||||
return fmt.Sprintf("BUG-%v-%v-%v", h.backendLogID, h.clock.Now().UTC().Format("20060102150405Z"), rands.HexString(16))
|
||||
}
|
||||
if envknob.NoLogsNoSupport() {
|
||||
logMarker = func() string { return "BUG-NO-LOGS-NO-SUPPORT-this-node-has-had-its-logging-disabled" }
|
||||
@@ -563,6 +557,13 @@ func (h *Handler) serveDebug(w http.ResponseWriter, r *http.Request) {
|
||||
err = h.b.DebugBreakTCPConns()
|
||||
case "break-derp-conns":
|
||||
err = h.b.DebugBreakDERPConns()
|
||||
case "control-knobs":
|
||||
k := h.b.ControlKnobs()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
err = json.NewEncoder(w).Encode(k.AsDebugJSON())
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
case "":
|
||||
err = fmt.Errorf("missing parameter 'action'")
|
||||
default:
|
||||
@@ -702,7 +703,7 @@ func (h *Handler) serveDebugPortmap(w http.ResponseWriter, r *http.Request) {
|
||||
done := make(chan bool, 1)
|
||||
|
||||
var c *portmapper.Client
|
||||
c = portmapper.NewClient(logger.WithPrefix(logf, "portmapper: "), h.netMon, debugKnobs, func() {
|
||||
c = portmapper.NewClient(logger.WithPrefix(logf, "portmapper: "), h.netMon, debugKnobs, h.b.ControlKnobs(), func() {
|
||||
logf("portmapping changed.")
|
||||
logf("have mapping: %v", c.HaveMapping())
|
||||
|
||||
@@ -838,9 +839,17 @@ func (h *Handler) serveServeConfig(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "serve config denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
config := h.b.ServeConfig()
|
||||
json.NewEncoder(w).Encode(config)
|
||||
bts, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
http.Error(w, "error encoding config: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
sum := sha256.Sum256(bts)
|
||||
etag := hex.EncodeToString(sum[:])
|
||||
w.Header().Set("Etag", etag)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write(bts)
|
||||
case "POST":
|
||||
if !h.PermitWrite {
|
||||
http.Error(w, "serve config denied", http.StatusForbidden)
|
||||
@@ -851,7 +860,12 @@ func (h *Handler) serveServeConfig(w http.ResponseWriter, r *http.Request) {
|
||||
writeErrorJSON(w, fmt.Errorf("decoding config: %w", err))
|
||||
return
|
||||
}
|
||||
if err := h.b.SetServeConfig(configIn); err != nil {
|
||||
etag := r.Header.Get("If-Match")
|
||||
if err := h.b.SetServeConfig(configIn, etag); err != nil {
|
||||
if errors.Is(err, ipnlocal.ErrETagMismatch) {
|
||||
http.Error(w, err.Error(), http.StatusPreconditionFailed)
|
||||
return
|
||||
}
|
||||
writeErrorJSON(w, fmt.Errorf("updating config: %w", err))
|
||||
return
|
||||
}
|
||||
@@ -861,35 +875,6 @@ func (h *Handler) serveServeConfig(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// serveStreamServe handles foreground serve and funnel streams. This is
|
||||
// currently in development per https://github.com/tailscale/tailscale/issues/8489
|
||||
func (h *Handler) serveStreamServe(w http.ResponseWriter, r *http.Request) {
|
||||
if !envknob.UseWIPCode() {
|
||||
http.Error(w, "stream serve not yet available", http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
if !h.PermitWrite {
|
||||
// Write permission required because we modify the ServeConfig.
|
||||
http.Error(w, "serve stream denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if r.Method != "POST" {
|
||||
http.Error(w, "POST required", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var req ipn.ServeStreamRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErrorJSON(w, fmt.Errorf("decoding HostPort: %w", err))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := h.b.StreamServe(r.Context(), w, req); err != nil {
|
||||
writeErrorJSON(w, fmt.Errorf("streaming serve: %w", err))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (h *Handler) serveCheckIPForwarding(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.PermitRead {
|
||||
http.Error(w, "IP forwarding check access denied", http.StatusForbidden)
|
||||
|
||||
148
ipn/serve.go
148
ipn/serve.go
@@ -12,7 +12,6 @@ import (
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
@@ -37,6 +36,14 @@ type ServeConfig struct {
|
||||
// AllowFunnel is the set of SNI:port values for which funnel
|
||||
// traffic is allowed, from trusted ingress peers.
|
||||
AllowFunnel map[HostPort]bool `json:",omitempty"`
|
||||
|
||||
// Foreground is a map of an IPN Bus session ID to an alternate foreground
|
||||
// serve config that's valid for the life of that WatchIPNBus session ID.
|
||||
// This. This allows the config to specify ephemeral configs that are
|
||||
// used in the CLI's foreground mode to ensure ungraceful shutdowns
|
||||
// of either the client or the LocalBackend does not expose ports
|
||||
// that users are not aware of.
|
||||
Foreground map[string]*ServeConfig `json:",omitempty"`
|
||||
}
|
||||
|
||||
// HostPort is an SNI name and port number, joined by a colon.
|
||||
@@ -78,46 +85,6 @@ type FunnelConn struct {
|
||||
Src netip.AddrPort
|
||||
}
|
||||
|
||||
// ServeStreamRequest defines the JSON request body
|
||||
// for the serve stream endpoint
|
||||
type ServeStreamRequest struct {
|
||||
// HostPort is the DNS and port of the tailscale
|
||||
// URL.
|
||||
HostPort HostPort `json:",omitempty"`
|
||||
|
||||
// Source is the user's serve source
|
||||
// as defined in the `tailscale serve`
|
||||
// command such as http://127.0.0.1:3000
|
||||
Source string `json:",omitempty"`
|
||||
|
||||
// MountPoint is the path prefix for
|
||||
// the given HostPort.
|
||||
MountPoint string `json:",omitempty"`
|
||||
|
||||
// Funnel indicates whether the request
|
||||
// is a serve request or a funnel one.
|
||||
Funnel bool `json:",omitempty"`
|
||||
}
|
||||
|
||||
// FunnelRequestLog is the JSON type written out to io.Writers
|
||||
// watching funnel connections via ipnlocal.StreamServe.
|
||||
//
|
||||
// This structure is in development and subject to change.
|
||||
type FunnelRequestLog struct {
|
||||
Time time.Time `json:",omitempty"` // time of request forwarding
|
||||
|
||||
// SrcAddr is the address that initiated the Funnel request.
|
||||
SrcAddr netip.AddrPort `json:",omitempty"`
|
||||
|
||||
// The following fields are only populated if the connection
|
||||
// initiated from another node on the client's tailnet.
|
||||
|
||||
NodeName string `json:",omitempty"` // src node MagicDNS name
|
||||
NodeTags []string `json:",omitempty"` // src node tags
|
||||
UserLoginName string `json:",omitempty"` // src node's owner login (if not tagged)
|
||||
UserDisplayName string `json:",omitempty"` // src node's owner name (if not tagged)
|
||||
}
|
||||
|
||||
// WebServerConfig describes a web server's configuration.
|
||||
type WebServerConfig struct {
|
||||
Handlers map[string]*HTTPHandler // mountPoint => handler
|
||||
@@ -332,3 +299,102 @@ func CheckFunnelPort(wantedPort uint16, nodeAttrs []string) error {
|
||||
}
|
||||
return deny(portsStr)
|
||||
}
|
||||
|
||||
// RangeOverTCPs ranges over both background and foreground TCPs.
|
||||
// If the returned bool from the given f is false, then this function stops
|
||||
// iterating immediately and does not check other foreground configs.
|
||||
func (v ServeConfigView) RangeOverTCPs(f func(port uint16, _ TCPPortHandlerView) bool) {
|
||||
parentCont := true
|
||||
v.TCP().Range(func(k uint16, v TCPPortHandlerView) (cont bool) {
|
||||
parentCont = f(k, v)
|
||||
return parentCont
|
||||
})
|
||||
v.Foreground().Range(func(k string, v ServeConfigView) (cont bool) {
|
||||
if !parentCont {
|
||||
return false
|
||||
}
|
||||
v.TCP().Range(func(k uint16, v TCPPortHandlerView) (cont bool) {
|
||||
parentCont = f(k, v)
|
||||
return parentCont
|
||||
})
|
||||
return parentCont
|
||||
})
|
||||
}
|
||||
|
||||
// RangeOverWebs ranges over both background and foreground Webs.
|
||||
// If the returned bool from the given f is false, then this function stops
|
||||
// iterating immediately and does not check other foreground configs.
|
||||
func (v ServeConfigView) RangeOverWebs(f func(_ HostPort, conf WebServerConfigView) bool) {
|
||||
parentCont := true
|
||||
v.Web().Range(func(k HostPort, v WebServerConfigView) (cont bool) {
|
||||
parentCont = f(k, v)
|
||||
return parentCont
|
||||
})
|
||||
v.Foreground().Range(func(k string, v ServeConfigView) (cont bool) {
|
||||
if !parentCont {
|
||||
return false
|
||||
}
|
||||
v.Web().Range(func(k HostPort, v WebServerConfigView) (cont bool) {
|
||||
parentCont = f(k, v)
|
||||
return parentCont
|
||||
})
|
||||
return parentCont
|
||||
})
|
||||
}
|
||||
|
||||
// FindTCP returns the first TCP that matches with the given port. It
|
||||
// prefers a foreground match first followed by a background search if none
|
||||
// existed.
|
||||
func (v ServeConfigView) FindTCP(port uint16) (res TCPPortHandlerView, ok bool) {
|
||||
v.Foreground().Range(func(_ string, v ServeConfigView) (cont bool) {
|
||||
res, ok = v.TCP().GetOk(port)
|
||||
return !ok
|
||||
})
|
||||
if ok {
|
||||
return res, ok
|
||||
}
|
||||
return v.TCP().GetOk(port)
|
||||
}
|
||||
|
||||
// FindWeb returns the first Web that matches with the given HostPort. It
|
||||
// prefers a foreground match first followed by a background search if none
|
||||
// existed.
|
||||
func (v ServeConfigView) FindWeb(hp HostPort) (res WebServerConfigView, ok bool) {
|
||||
v.Foreground().Range(func(_ string, v ServeConfigView) (cont bool) {
|
||||
res, ok = v.Web().GetOk(hp)
|
||||
return !ok
|
||||
})
|
||||
if ok {
|
||||
return res, ok
|
||||
}
|
||||
return v.Web().GetOk(hp)
|
||||
}
|
||||
|
||||
// HasAllowFunnel returns whether this config has at least one AllowFunnel
|
||||
// set in the background or foreground configs.
|
||||
func (v ServeConfigView) HasAllowFunnel() bool {
|
||||
return v.AllowFunnel().Len() > 0 || func() bool {
|
||||
var exists bool
|
||||
v.Foreground().Range(func(k string, v ServeConfigView) (cont bool) {
|
||||
exists = v.AllowFunnel().Len() > 0
|
||||
return !exists
|
||||
})
|
||||
return exists
|
||||
}()
|
||||
}
|
||||
|
||||
// FindFunnel reports whether target exists in in either the background AllowFunnel
|
||||
// or any of the foreground configs.
|
||||
func (v ServeConfigView) HasFunnelForTarget(target HostPort) bool {
|
||||
if v.AllowFunnel().Get(target) {
|
||||
return true
|
||||
}
|
||||
var exists bool
|
||||
v.Foreground().Range(func(_ string, v ServeConfigView) (cont bool) {
|
||||
if exists = v.AllowFunnel().Get(target); exists {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
return exists
|
||||
}
|
||||
|
||||
@@ -33,12 +33,14 @@ Client][]. See also the dependencies in the [Tailscale CLI][].
|
||||
- [github.com/benoitkugler/textlayout/graphite](https://pkg.go.dev/github.com/benoitkugler/textlayout/graphite) ([MIT](https://github.com/benoitkugler/textlayout/blob/v0.3.0/graphite/LICENSE))
|
||||
- [github.com/benoitkugler/textlayout/harfbuzz](https://pkg.go.dev/github.com/benoitkugler/textlayout/harfbuzz) ([MIT](https://github.com/benoitkugler/textlayout/blob/v0.3.0/harfbuzz/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.6.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.4.0/LICENSE))
|
||||
- [github.com/fxamacker/cbor/v2](https://pkg.go.dev/github.com/fxamacker/cbor/v2) ([MIT](https://github.com/fxamacker/cbor/blob/v2.4.0/LICENSE))
|
||||
- [github.com/go-text/typesetting](https://pkg.go.dev/github.com/go-text/typesetting) ([BSD-3-Clause](https://github.com/go-text/typesetting/blob/0399769901d5/LICENSE))
|
||||
- [github.com/godbus/dbus/v5](https://pkg.go.dev/github.com/godbus/dbus/v5) ([BSD-2-Clause](https://github.com/godbus/dbus/blob/v5.1.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))
|
||||
- [github.com/google/btree](https://pkg.go.dev/github.com/google/btree) ([Apache-2.0](https://github.com/google/btree/blob/v1.1.2/LICENSE))
|
||||
- [github.com/google/nftables](https://pkg.go.dev/github.com/google/nftables) ([Apache-2.0](https://github.com/google/nftables/blob/9aa6fdf5a28c/LICENSE))
|
||||
- [github.com/google/uuid](https://pkg.go.dev/github.com/google/uuid) ([BSD-3-Clause](https://github.com/google/uuid/blob/v1.3.0/LICENSE))
|
||||
- [github.com/hdevalence/ed25519consensus](https://pkg.go.dev/github.com/hdevalence/ed25519consensus) ([BSD-3-Clause](https://github.com/hdevalence/ed25519consensus/blob/v0.1.0/LICENSE))
|
||||
- [github.com/illarion/gonotify](https://pkg.go.dev/github.com/illarion/gonotify) ([MIT](https://github.com/illarion/gonotify/blob/v1.0.1/LICENSE))
|
||||
- [github.com/insomniacslk/dhcp](https://pkg.go.dev/github.com/insomniacslk/dhcp) ([BSD-3-Clause](https://github.com/insomniacslk/dhcp/blob/974c6f05fe16/LICENSE))
|
||||
@@ -61,7 +63,7 @@ Client][]. See also the dependencies in the [Tailscale CLI][].
|
||||
- [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/netlink](https://pkg.go.dev/github.com/tailscale/netlink) ([Apache-2.0](https://github.com/tailscale/netlink/blob/cabfb018fe85/LICENSE))
|
||||
- [github.com/tailscale/tailscale-android](https://pkg.go.dev/github.com/tailscale/tailscale-android) ([BSD-3-Clause](https://github.com/tailscale/tailscale-android/blob/HEAD/LICENSE))
|
||||
- [github.com/tailscale/wireguard-go](https://pkg.go.dev/github.com/tailscale/wireguard-go) ([MIT](https://github.com/tailscale/wireguard-go/blob/bb2c8f22eccf/LICENSE))
|
||||
- [github.com/tailscale/wireguard-go](https://pkg.go.dev/github.com/tailscale/wireguard-go) ([MIT](https://github.com/tailscale/wireguard-go/blob/93bd5cbf7fd8/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))
|
||||
- [github.com/u-root/uio](https://pkg.go.dev/github.com/u-root/uio) ([BSD-3-Clause](https://github.com/u-root/uio/blob/3e8cd9d6bf63/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))
|
||||
@@ -71,15 +73,15 @@ Client][]. See also the dependencies in the [Tailscale CLI][].
|
||||
- [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/ad4cb58a6516/LICENSE))
|
||||
- [go4.org/unsafe/assume-no-moving-gc](https://pkg.go.dev/go4.org/unsafe/assume-no-moving-gc) ([BSD-3-Clause](https://github.com/go4org/unsafe-assume-no-moving-gc/blob/e7c30c78aeb2/LICENSE))
|
||||
- [golang.org/x/crypto](https://pkg.go.dev/golang.org/x/crypto) ([BSD-3-Clause](https://cs.opensource.google/go/x/crypto/+/v0.11.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.12.0:LICENSE))
|
||||
- [golang.org/x/exp](https://pkg.go.dev/golang.org/x/exp) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/515e97eb:LICENSE))
|
||||
- [golang.org/x/exp/shiny](https://pkg.go.dev/golang.org/x/exp/shiny) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/334a2380:shiny/LICENSE))
|
||||
- [golang.org/x/image](https://pkg.go.dev/golang.org/x/image) ([BSD-3-Clause](https://cs.opensource.google/go/x/image/+/v0.7.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.10.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.14.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.2.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.10.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.10.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.11.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.11.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.11.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.12.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/7b0a1988a28f/LICENSE))
|
||||
- [inet.af/netaddr](https://pkg.go.dev/inet.af/netaddr) ([BSD-3-Clause](https://github.com/inetaf/netaddr/blob/097006376321/LICENSE))
|
||||
|
||||
@@ -27,20 +27,22 @@ and [iOS][]. See also the dependencies in the [Tailscale CLI][].
|
||||
- [github.com/aws/smithy-go](https://pkg.go.dev/github.com/aws/smithy-go) ([Apache-2.0](https://github.com/aws/smithy-go/blob/v1.13.5/LICENSE))
|
||||
- [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.13.5/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.6.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.4.0/LICENSE))
|
||||
- [github.com/fxamacker/cbor/v2](https://pkg.go.dev/github.com/fxamacker/cbor/v2) ([MIT](https://github.com/fxamacker/cbor/blob/v2.4.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/v5.1.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))
|
||||
- [github.com/google/btree](https://pkg.go.dev/github.com/google/btree) ([Apache-2.0](https://github.com/google/btree/blob/v1.1.2/LICENSE))
|
||||
- [github.com/google/nftables](https://pkg.go.dev/github.com/google/nftables) ([Apache-2.0](https://github.com/google/nftables/blob/9aa6fdf5a28c/LICENSE))
|
||||
- [github.com/google/uuid](https://pkg.go.dev/github.com/google/uuid) ([BSD-3-Clause](https://github.com/google/uuid/blob/v1.3.0/LICENSE))
|
||||
- [github.com/hdevalence/ed25519consensus](https://pkg.go.dev/github.com/hdevalence/ed25519consensus) ([BSD-3-Clause](https://github.com/hdevalence/ed25519consensus/blob/v0.1.0/LICENSE))
|
||||
- [github.com/illarion/gonotify](https://pkg.go.dev/github.com/illarion/gonotify) ([MIT](https://github.com/illarion/gonotify/blob/v1.0.1/LICENSE))
|
||||
- [github.com/insomniacslk/dhcp](https://pkg.go.dev/github.com/insomniacslk/dhcp) ([BSD-3-Clause](https://github.com/insomniacslk/dhcp/blob/974c6f05fe16/LICENSE))
|
||||
- [github.com/jmespath/go-jmespath](https://pkg.go.dev/github.com/jmespath/go-jmespath) ([Apache-2.0](https://github.com/jmespath/go-jmespath/blob/v0.4.0/LICENSE))
|
||||
- [github.com/josharian/native](https://pkg.go.dev/github.com/josharian/native) ([MIT](https://github.com/josharian/native/blob/5c7d0dd6ab86/license))
|
||||
- [github.com/jsimonetti/rtnetlink](https://pkg.go.dev/github.com/jsimonetti/rtnetlink) ([MIT](https://github.com/jsimonetti/rtnetlink/blob/v1.3.2/LICENSE.md))
|
||||
- [github.com/klauspost/compress](https://pkg.go.dev/github.com/klauspost/compress) ([Apache-2.0](https://github.com/klauspost/compress/blob/v1.16.5/LICENSE))
|
||||
- [github.com/klauspost/compress/internal/snapref](https://pkg.go.dev/github.com/klauspost/compress/internal/snapref) ([BSD-3-Clause](https://github.com/klauspost/compress/blob/v1.16.5/internal/snapref/LICENSE))
|
||||
- [github.com/klauspost/compress/zstd/internal/xxhash](https://pkg.go.dev/github.com/klauspost/compress/zstd/internal/xxhash) ([MIT](https://github.com/klauspost/compress/blob/v1.16.5/zstd/internal/xxhash/LICENSE.txt))
|
||||
- [github.com/klauspost/compress](https://pkg.go.dev/github.com/klauspost/compress) ([Apache-2.0](https://github.com/klauspost/compress/blob/v1.16.7/LICENSE))
|
||||
- [github.com/klauspost/compress/internal/snapref](https://pkg.go.dev/github.com/klauspost/compress/internal/snapref) ([BSD-3-Clause](https://github.com/klauspost/compress/blob/v1.16.7/internal/snapref/LICENSE))
|
||||
- [github.com/klauspost/compress/zstd/internal/xxhash](https://pkg.go.dev/github.com/klauspost/compress/zstd/internal/xxhash) ([MIT](https://github.com/klauspost/compress/blob/v1.16.7/zstd/internal/xxhash/LICENSE.txt))
|
||||
- [github.com/kortschak/wol](https://pkg.go.dev/github.com/kortschak/wol) ([BSD-3-Clause](https://github.com/kortschak/wol/blob/da482cc4850a/LICENSE))
|
||||
- [github.com/mdlayher/genetlink](https://pkg.go.dev/github.com/mdlayher/genetlink) ([MIT](https://github.com/mdlayher/genetlink/blob/v1.3.2/LICENSE.md))
|
||||
- [github.com/mdlayher/netlink](https://pkg.go.dev/github.com/mdlayher/netlink) ([MIT](https://github.com/mdlayher/netlink/blob/v1.7.2/LICENSE.md))
|
||||
@@ -52,21 +54,21 @@ and [iOS][]. See also the dependencies in the [Tailscale CLI][].
|
||||
- [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/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/bb2c8f22eccf/LICENSE))
|
||||
- [github.com/tailscale/wireguard-go](https://pkg.go.dev/github.com/tailscale/wireguard-go) ([MIT](https://github.com/tailscale/wireguard-go/blob/93bd5cbf7fd8/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))
|
||||
- [github.com/u-root/uio](https://pkg.go.dev/github.com/u-root/uio) ([BSD-3-Clause](https://github.com/u-root/uio/blob/3e8cd9d6bf63/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))
|
||||
- [github.com/vishvananda/netns](https://pkg.go.dev/github.com/vishvananda/netns) ([Apache-2.0](https://github.com/vishvananda/netns/blob/v0.0.4/LICENSE))
|
||||
- [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/f1b76eb4bb35/LICENSE))
|
||||
- [golang.org/x/crypto](https://pkg.go.dev/golang.org/x/crypto) ([BSD-3-Clause](https://cs.opensource.google/go/x/crypto/+/v0.11.0:LICENSE))
|
||||
- [golang.org/x/exp](https://pkg.go.dev/golang.org/x/exp) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/47ecfdc1:LICENSE))
|
||||
- [go4.org/netipx](https://pkg.go.dev/go4.org/netipx) ([BSD-3-Clause](https://github.com/go4org/netipx/blob/ad4cb58a6516/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/+/515e97eb: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/sync/errgroup](https://pkg.go.dev/golang.org/x/sync/errgroup) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.2.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.10.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.10.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.11.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/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/7b0a1988a28f/LICENSE))
|
||||
- [inet.af/peercred](https://pkg.go.dev/inet.af/peercred) ([BSD-3-Clause](https://github.com/inetaf/peercred/blob/0893ea02156a/LICENSE))
|
||||
|
||||
@@ -34,11 +34,12 @@ Some packages may only be included on certain architectures or operating systems
|
||||
- [github.com/aws/smithy-go](https://pkg.go.dev/github.com/aws/smithy-go) ([Apache-2.0](https://github.com/aws/smithy-go/blob/v1.13.5/LICENSE))
|
||||
- [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.13.5/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.6.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.4.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/5c6286bb8c6e/LICENSE))
|
||||
- [github.com/dblohm7/wingoes](https://pkg.go.dev/github.com/dblohm7/wingoes) ([BSD-3-Clause](https://github.com/dblohm7/wingoes/blob/fc76608aecf0/LICENSE))
|
||||
- [github.com/fxamacker/cbor/v2](https://pkg.go.dev/github.com/fxamacker/cbor/v2) ([MIT](https://github.com/fxamacker/cbor/blob/v2.4.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.2.6/LICENSE))
|
||||
- [github.com/godbus/dbus/v5](https://pkg.go.dev/github.com/godbus/dbus/v5) ([BSD-2-Clause](https://github.com/godbus/dbus/blob/v5.1.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))
|
||||
- [github.com/google/btree](https://pkg.go.dev/github.com/google/btree) ([Apache-2.0](https://github.com/google/btree/blob/v1.1.2/LICENSE))
|
||||
- [github.com/google/nftables](https://pkg.go.dev/github.com/google/nftables) ([Apache-2.0](https://github.com/google/nftables/blob/9aa6fdf5a28c/LICENSE))
|
||||
@@ -73,7 +74,7 @@ Some packages may only be included on certain architectures or operating systems
|
||||
- [github.com/tailscale/certstore](https://pkg.go.dev/github.com/tailscale/certstore) ([MIT](https://github.com/tailscale/certstore/blob/78d6e1c49d8d/LICENSE.md))
|
||||
- [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/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/bb2c8f22eccf/LICENSE))
|
||||
- [github.com/tailscale/wireguard-go](https://pkg.go.dev/github.com/tailscale/wireguard-go) ([MIT](https://github.com/tailscale/wireguard-go/blob/93bd5cbf7fd8/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))
|
||||
- [github.com/toqueteos/webbrowser](https://pkg.go.dev/github.com/toqueteos/webbrowser) ([MIT](https://github.com/toqueteos/webbrowser/blob/v1.2.0/LICENSE.md))
|
||||
- [github.com/u-root/u-root/pkg/termios](https://pkg.go.dev/github.com/u-root/u-root/pkg/termios) ([BSD-3-Clause](https://github.com/u-root/u-root/blob/v0.11.0/LICENSE))
|
||||
@@ -83,14 +84,14 @@ 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/ad4cb58a6516/LICENSE))
|
||||
- [golang.org/x/crypto](https://pkg.go.dev/golang.org/x/crypto) ([BSD-3-Clause](https://cs.opensource.google/go/x/crypto/+/v0.11.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.12.0:LICENSE))
|
||||
- [golang.org/x/exp](https://pkg.go.dev/golang.org/x/exp) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/515e97eb:LICENSE))
|
||||
- [golang.org/x/net](https://pkg.go.dev/golang.org/x/net) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.10.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.14.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.7.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.2.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.10.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.10.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.11.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.11.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.11.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.12.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))
|
||||
- [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))
|
||||
|
||||
@@ -15,7 +15,7 @@ Windows][]. See also the dependencies in the [Tailscale CLI][].
|
||||
- [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.6.0/LICENSE))
|
||||
- [github.com/dblohm7/wingoes](https://pkg.go.dev/github.com/dblohm7/wingoes) ([BSD-3-Clause](https://github.com/dblohm7/wingoes/blob/111c8c3b57c8/LICENSE))
|
||||
- [github.com/dblohm7/wingoes](https://pkg.go.dev/github.com/dblohm7/wingoes) ([BSD-3-Clause](https://github.com/dblohm7/wingoes/blob/fc76608aecf0/LICENSE))
|
||||
- [github.com/fxamacker/cbor/v2](https://pkg.go.dev/github.com/fxamacker/cbor/v2) ([MIT](https://github.com/fxamacker/cbor/blob/v2.4.0/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))
|
||||
- [github.com/google/btree](https://pkg.go.dev/github.com/google/btree) ([Apache-2.0](https://github.com/google/btree/blob/v1.1.2/LICENSE))
|
||||
@@ -25,9 +25,9 @@ Windows][]. See also the dependencies in the [Tailscale CLI][].
|
||||
- [github.com/hdevalence/ed25519consensus](https://pkg.go.dev/github.com/hdevalence/ed25519consensus) ([BSD-3-Clause](https://github.com/hdevalence/ed25519consensus/blob/v0.1.0/LICENSE))
|
||||
- [github.com/josharian/native](https://pkg.go.dev/github.com/josharian/native) ([MIT](https://github.com/josharian/native/blob/5c7d0dd6ab86/license))
|
||||
- [github.com/jsimonetti/rtnetlink](https://pkg.go.dev/github.com/jsimonetti/rtnetlink) ([MIT](https://github.com/jsimonetti/rtnetlink/blob/v1.3.2/LICENSE.md))
|
||||
- [github.com/klauspost/compress](https://pkg.go.dev/github.com/klauspost/compress) ([Apache-2.0](https://github.com/klauspost/compress/blob/v1.16.5/LICENSE))
|
||||
- [github.com/klauspost/compress/internal/snapref](https://pkg.go.dev/github.com/klauspost/compress/internal/snapref) ([BSD-3-Clause](https://github.com/klauspost/compress/blob/v1.16.5/internal/snapref/LICENSE))
|
||||
- [github.com/klauspost/compress/zstd/internal/xxhash](https://pkg.go.dev/github.com/klauspost/compress/zstd/internal/xxhash) ([MIT](https://github.com/klauspost/compress/blob/v1.16.5/zstd/internal/xxhash/LICENSE.txt))
|
||||
- [github.com/klauspost/compress](https://pkg.go.dev/github.com/klauspost/compress) ([Apache-2.0](https://github.com/klauspost/compress/blob/v1.16.7/LICENSE))
|
||||
- [github.com/klauspost/compress/internal/snapref](https://pkg.go.dev/github.com/klauspost/compress/internal/snapref) ([BSD-3-Clause](https://github.com/klauspost/compress/blob/v1.16.7/internal/snapref/LICENSE))
|
||||
- [github.com/klauspost/compress/zstd/internal/xxhash](https://pkg.go.dev/github.com/klauspost/compress/zstd/internal/xxhash) ([MIT](https://github.com/klauspost/compress/blob/v1.16.7/zstd/internal/xxhash/LICENSE.txt))
|
||||
- [github.com/mdlayher/netlink](https://pkg.go.dev/github.com/mdlayher/netlink) ([MIT](https://github.com/mdlayher/netlink/blob/v1.7.2/LICENSE.md))
|
||||
- [github.com/mdlayher/socket](https://pkg.go.dev/github.com/mdlayher/socket) ([MIT](https://github.com/mdlayher/socket/blob/v0.4.1/LICENSE.md))
|
||||
- [github.com/miekg/dns](https://pkg.go.dev/github.com/miekg/dns) ([BSD-3-Clause](https://github.com/miekg/dns/blob/v1.1.55/LICENSE))
|
||||
@@ -36,28 +36,26 @@ 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/4b0a5c5d37ea/LICENSE))
|
||||
- [github.com/tailscale/walk](https://pkg.go.dev/github.com/tailscale/walk) ([BSD-3-Clause](https://github.com/tailscale/walk/blob/a3cf94ed774a/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.0/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))
|
||||
- [github.com/vishvananda/netns](https://pkg.go.dev/github.com/vishvananda/netns) ([Apache-2.0](https://github.com/vishvananda/netns/blob/v0.0.4/LICENSE))
|
||||
- [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/f1b76eb4bb35/LICENSE))
|
||||
- [golang.org/x/crypto](https://pkg.go.dev/golang.org/x/crypto) ([BSD-3-Clause](https://cs.opensource.google/go/x/crypto/+/v0.11.0:LICENSE))
|
||||
- [golang.org/x/exp](https://pkg.go.dev/golang.org/x/exp) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/47ecfdc1:LICENSE))
|
||||
- [go4.org/netipx](https://pkg.go.dev/go4.org/netipx) ([BSD-3-Clause](https://github.com/go4org/netipx/blob/ad4cb58a6516/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/constraints](https://pkg.go.dev/golang.org/x/exp/constraints) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/515e97eb: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.7.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.10.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/sync/errgroup](https://pkg.go.dev/golang.org/x/sync/errgroup) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.2.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.10.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.10.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.11.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.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/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))
|
||||
- [gopkg.in/Knetic/govaluate.v3](https://pkg.go.dev/gopkg.in/Knetic/govaluate.v3) ([MIT](https://github.com/Knetic/govaluate/blob/v3.0.0/LICENSE))
|
||||
- [gvisor.dev/gvisor/pkg](https://pkg.go.dev/gvisor.dev/gvisor/pkg) ([Apache-2.0](https://github.com/google/gvisor/blob/7b0a1988a28f/LICENSE))
|
||||
- [tailscale.com](https://pkg.go.dev/tailscale.com) ([BSD-3-Clause](https://github.com/tailscale/tailscale/blob/HEAD/LICENSE))
|
||||
|
||||
## Additional Dependencies
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build wasm || plan9
|
||||
//go:build wasm || plan9 || tamago
|
||||
|
||||
package filch
|
||||
|
||||
|
||||
89
mdm/mdm.go
Normal file
89
mdm/mdm.go
Normal file
@@ -0,0 +1,89 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package mdm contains functions to read platform-specific MDM-enforced flags
|
||||
// in a platform-independent manner.
|
||||
package mdm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"tailscale.com/version"
|
||||
)
|
||||
|
||||
func ReadBool(key string) (bool, error) {
|
||||
if runtime.GOOS == "darwin" || runtime.GOOS == "ios" {
|
||||
return readUserDefaultsBool(key)
|
||||
} else if runtime.GOOS == "windows" {
|
||||
return readRegistryBool(key)
|
||||
} else {
|
||||
return false, fmt.Errorf("unsupported platform")
|
||||
}
|
||||
}
|
||||
|
||||
func ReadString(key string) (string, error) {
|
||||
if runtime.GOOS == "darwin" || runtime.GOOS == "ios" {
|
||||
return readUserDefaultsString(key)
|
||||
} else if runtime.GOOS == "windows" {
|
||||
// TODO(angott): Windows
|
||||
return readRegistryString(key)
|
||||
} else {
|
||||
return "", fmt.Errorf("unsupported platform")
|
||||
}
|
||||
}
|
||||
|
||||
/// Darwin
|
||||
|
||||
// readUserDefaultsBool reads a boolean value with the given key from the macOS/iOS UserDefaults.
|
||||
func readUserDefaultsBool(key string) (bool, error) {
|
||||
cmd := exec.Command("defaults", "read", userDefaultsDomain(), key)
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
asString := string(output)
|
||||
if asString == "0" {
|
||||
return false, nil
|
||||
} else if asString == "1" {
|
||||
return true, nil
|
||||
} else {
|
||||
return false, fmt.Errorf("unexpected user defaults value for", key, ":", err)
|
||||
}
|
||||
}
|
||||
|
||||
// readRegistryString reads a string value with the given key from the macOS/iOS UserDefaults.
|
||||
func readUserDefaultsString(key string) (string, error) {
|
||||
cmd := exec.Command("defaults", "read", userDefaultsDomain(), key)
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
asString := string(output)
|
||||
return asString, nil
|
||||
}
|
||||
|
||||
// userDefaultsDomain returns the domain iOS or macOS store the Tailscale settings in.
|
||||
func userDefaultsDomain() string {
|
||||
var bundleIdentifierSuffix string
|
||||
if version.IsMacSysExt() {
|
||||
bundleIdentifierSuffix = "macsys"
|
||||
} else {
|
||||
bundleIdentifierSuffix = "macos"
|
||||
}
|
||||
return "io.tailscale.ipn." + bundleIdentifierSuffix
|
||||
}
|
||||
|
||||
/// Windows
|
||||
|
||||
// readRegistryBool reads a boolean value with the given key from the Windows registry.
|
||||
func readRegistryBool(key string) (bool, error) {
|
||||
// TODO(angott): Windows support
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// readRegistryBool reads a string value with the given key from the Windows registry.
|
||||
func readRegistryString(key string) (string, error) {
|
||||
// TODO(angott): Windows support
|
||||
return "", nil
|
||||
}
|
||||
@@ -138,8 +138,8 @@ func (h *Histogram) String() string {
|
||||
}
|
||||
first = false
|
||||
})
|
||||
fmt.Fprintf(&b, "\"sum\": %v,", &h.sum)
|
||||
fmt.Fprintf(&b, "\"count\": %v", &h.count)
|
||||
fmt.Fprintf(&b, ",\"sum\": %v", &h.sum)
|
||||
fmt.Fprintf(&b, ",\"count\": %v", &h.count)
|
||||
fmt.Fprintf(&b, "}")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"golang.org/x/sys/windows/registry"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/util/dnsname"
|
||||
"tailscale.com/util/set"
|
||||
"tailscale.com/util/winutil"
|
||||
)
|
||||
|
||||
@@ -158,14 +159,14 @@ func (db *nrptRuleDatabase) detectWriteAsGP() {
|
||||
}
|
||||
|
||||
// Add *all* rules from the GP subkey into a set.
|
||||
gpSubkeyMap := make(map[string]struct{}, len(gpSubkeyNames))
|
||||
gpSubkeyMap := make(set.Set[string], len(gpSubkeyNames))
|
||||
for _, gpSubkey := range gpSubkeyNames {
|
||||
gpSubkeyMap[strings.ToUpper(gpSubkey)] = struct{}{}
|
||||
gpSubkeyMap.Add(strings.ToUpper(gpSubkey))
|
||||
}
|
||||
|
||||
// Remove *our* rules from the set.
|
||||
for _, ourRuleID := range db.ruleIDs {
|
||||
delete(gpSubkeyMap, strings.ToUpper(ourRuleID))
|
||||
gpSubkeyMap.Delete(strings.ToUpper(ourRuleID))
|
||||
}
|
||||
|
||||
// Any leftover rules do not belong to us. When group policy is being used
|
||||
|
||||
@@ -129,6 +129,11 @@ func addDoH(ipStr, base string) {
|
||||
dohIPsOfBase[base] = append(dohIPsOfBase[base], ip)
|
||||
}
|
||||
|
||||
const (
|
||||
wikimediaDNSv4 = "185.71.138.138"
|
||||
wikimediaDNSv6 = "2001:67c:930::1"
|
||||
)
|
||||
|
||||
// populate is called once to initialize the knownDoH and dohIPsOfBase maps.
|
||||
func populate() {
|
||||
// Cloudflare
|
||||
@@ -185,6 +190,10 @@ func populate() {
|
||||
addDoH("194.242.2.3", "https://adblock.doh.mullvad.net/dns-query")
|
||||
addDoH("193.19.108.3", "https://adblock.doh.mullvad.net/dns-query")
|
||||
addDoH("2a07:e340::3", "https://adblock.doh.mullvad.net/dns-query")
|
||||
|
||||
// Wikimedia
|
||||
addDoH(wikimediaDNSv4, "https://wikimedia-dns.org/dns-query")
|
||||
addDoH(wikimediaDNSv6, "https://wikimedia-dns.org/dns-query")
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -207,6 +216,10 @@ var (
|
||||
nextDNSv4RangeB = netip.MustParsePrefix("45.90.30.0/24")
|
||||
nextDNSv4One = nextDNSv4RangeA.Addr()
|
||||
nextDNSv4Two = nextDNSv4RangeB.Addr()
|
||||
|
||||
// Wikimedia DNS server IPs (anycast)
|
||||
wikimediaDNSv4Addr = netip.MustParseAddr(wikimediaDNSv4)
|
||||
wikimediaDNSv6Addr = netip.MustParseAddr(wikimediaDNSv6)
|
||||
)
|
||||
|
||||
// nextDNSv6Gen generates a NextDNS IPv6 address from the upper 8 bytes in the
|
||||
@@ -224,5 +237,6 @@ func nextDNSv6Gen(ip netip.Addr, id []byte) netip.Addr {
|
||||
// DNS-over-HTTPS (not regular port 53 DNS).
|
||||
func IPIsDoHOnlyServer(ip netip.Addr) bool {
|
||||
return nextDNSv6RangeA.Contains(ip) || nextDNSv6RangeB.Contains(ip) ||
|
||||
nextDNSv4RangeA.Contains(ip) || nextDNSv4RangeB.Contains(ip)
|
||||
nextDNSv4RangeA.Contains(ip) || nextDNSv4RangeB.Contains(ip) ||
|
||||
ip == wikimediaDNSv4Addr || ip == wikimediaDNSv6Addr
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
"tailscale.com/net/interfaces"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/util/clientmetric"
|
||||
"tailscale.com/util/set"
|
||||
)
|
||||
|
||||
@@ -173,8 +174,14 @@ func (m *Monitor) GatewayAndSelfIP() (gw, myIP netip.Addr, ok bool) {
|
||||
return m.gw, m.gwSelfIP, true
|
||||
}
|
||||
gw, myIP, ok = interfaces.LikelyHomeRouterIP()
|
||||
changed := false
|
||||
if ok {
|
||||
m.gw, m.gwSelfIP, m.gwValid = gw, myIP, true
|
||||
changed = m.gw != gw || m.gwSelfIP != myIP
|
||||
m.gw, m.gwSelfIP = gw, myIP
|
||||
m.gwValid = true
|
||||
}
|
||||
if changed {
|
||||
m.logf("gateway and self IP changed: gw=%v self=%v", m.gw, m.gwSelfIP)
|
||||
}
|
||||
return gw, myIP, ok
|
||||
}
|
||||
@@ -369,6 +376,13 @@ func (m *Monitor) debounce() {
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
metricChangeEq = clientmetric.NewCounter("netmon_link_change_eq")
|
||||
metricChange = clientmetric.NewCounter("netmon_link_change")
|
||||
metricChangeTimeJump = clientmetric.NewCounter("netmon_link_change_timejump")
|
||||
metricChangeMajor = clientmetric.NewCounter("netmon_link_change_major")
|
||||
)
|
||||
|
||||
// handlePotentialChange considers whether newState is different enough to wake
|
||||
// up callers and updates the monitor's state if so.
|
||||
//
|
||||
@@ -380,6 +394,7 @@ func (m *Monitor) handlePotentialChange(newState *interfaces.State, forceCallbac
|
||||
timeJumped := shouldMonitorTimeJump && m.checkWallTimeAdvanceLocked()
|
||||
if !timeJumped && !forceCallbacks && oldState.Equal(newState) {
|
||||
// Exactly equal. Nothing to do.
|
||||
metricChangeEq.Add(1)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -410,6 +425,13 @@ func (m *Monitor) handlePotentialChange(newState *interfaces.State, forceCallbac
|
||||
delta.Major = true
|
||||
}
|
||||
}
|
||||
metricChange.Add(1)
|
||||
if delta.Major {
|
||||
metricChangeMajor.Add(1)
|
||||
}
|
||||
if delta.TimeJumped {
|
||||
metricChangeTimeJump.Add(1)
|
||||
}
|
||||
for _, cb := range m.cbs {
|
||||
go cb(delta)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,9 @@ import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"tailscale.com/control/controlknobs"
|
||||
"tailscale.com/net/netaddr"
|
||||
"tailscale.com/syncs"
|
||||
"tailscale.com/types/logger"
|
||||
)
|
||||
|
||||
@@ -24,6 +26,7 @@ type TestIGD struct {
|
||||
upnpConn net.PacketConn // for UPnP discovery
|
||||
pxpConn net.PacketConn // for NAT-PMP and/or PCP
|
||||
ts *httptest.Server
|
||||
upnpHTTP syncs.AtomicValue[http.Handler]
|
||||
logf logger.Logf
|
||||
closed atomic.Bool
|
||||
|
||||
@@ -125,8 +128,17 @@ func (d *TestIGD) stats() igdCounters {
|
||||
return d.counters
|
||||
}
|
||||
|
||||
func (d *TestIGD) SetUPnPHandler(h http.Handler) {
|
||||
d.upnpHTTP.Store(h)
|
||||
}
|
||||
|
||||
func (d *TestIGD) serveUPnPHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
http.NotFound(w, r) // TODO
|
||||
if handler := d.upnpHTTP.Load(); handler != nil {
|
||||
handler.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
||||
func (d *TestIGD) serveUPnPDiscovery() {
|
||||
@@ -249,7 +261,7 @@ func (d *TestIGD) handlePCPQuery(pkt []byte, src netip.AddrPort) {
|
||||
|
||||
func newTestClient(t *testing.T, igd *TestIGD) *Client {
|
||||
var c *Client
|
||||
c = NewClient(t.Logf, nil, nil, func() {
|
||||
c = NewClient(t.Logf, nil, nil, new(controlknobs.Knobs), func() {
|
||||
t.Logf("port map changed")
|
||||
t.Logf("have mapping: %v", c.HaveMapping())
|
||||
})
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"time"
|
||||
|
||||
"go4.org/mem"
|
||||
"tailscale.com/control/controlknobs"
|
||||
"tailscale.com/net/interfaces"
|
||||
"tailscale.com/net/netaddr"
|
||||
"tailscale.com/net/neterror"
|
||||
@@ -66,6 +67,7 @@ const trustServiceStillAvailableDuration = 10 * time.Minute
|
||||
type Client struct {
|
||||
logf logger.Logf
|
||||
netMon *netmon.Monitor // optional; nil means interfaces will be looked up on-demand
|
||||
controlKnobs *controlknobs.Knobs
|
||||
ipAndGateway func() (gw, ip netip.Addr, ok bool)
|
||||
onChange func() // or nil
|
||||
debug DebugKnobs
|
||||
@@ -166,15 +168,19 @@ func (m *pmpMapping) Release(ctx context.Context) {
|
||||
// The debug argument allows configuring the behaviour of the portmapper for
|
||||
// debugging; if nil, a sensible set of defaults will be used.
|
||||
//
|
||||
// The optional onChange argument specifies a func to run in a new
|
||||
// goroutine whenever the port mapping status has changed. If nil,
|
||||
// it doesn't make a callback.
|
||||
func NewClient(logf logger.Logf, netMon *netmon.Monitor, debug *DebugKnobs, onChange func()) *Client {
|
||||
// The controlKnobs, if non-nil, specifies the control knobs from the control
|
||||
// plane that might disable portmapping.
|
||||
//
|
||||
// The optional onChange argument specifies a func to run in a new goroutine
|
||||
// whenever the port mapping status has changed. If nil, it doesn't make a
|
||||
// callback.
|
||||
func NewClient(logf logger.Logf, netMon *netmon.Monitor, debug *DebugKnobs, controlKnobs *controlknobs.Knobs, onChange func()) *Client {
|
||||
ret := &Client{
|
||||
logf: logf,
|
||||
netMon: netMon,
|
||||
ipAndGateway: interfaces.LikelyHomeRouterIP,
|
||||
onChange: onChange,
|
||||
controlKnobs: controlKnobs,
|
||||
}
|
||||
if debug != nil {
|
||||
ret.debug = *debug
|
||||
|
||||
@@ -10,13 +10,15 @@ import (
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"tailscale.com/control/controlknobs"
|
||||
)
|
||||
|
||||
func TestCreateOrGetMapping(t *testing.T) {
|
||||
if v, _ := strconv.ParseBool(os.Getenv("HIT_NETWORK")); !v {
|
||||
t.Skip("skipping test without HIT_NETWORK=1")
|
||||
}
|
||||
c := NewClient(t.Logf, nil, nil, nil)
|
||||
c := NewClient(t.Logf, nil, nil, new(controlknobs.Knobs), nil)
|
||||
defer c.Close()
|
||||
c.SetLocalPort(1234)
|
||||
for i := 0; i < 2; i++ {
|
||||
@@ -32,7 +34,7 @@ func TestClientProbe(t *testing.T) {
|
||||
if v, _ := strconv.ParseBool(os.Getenv("HIT_NETWORK")); !v {
|
||||
t.Skip("skipping test without HIT_NETWORK=1")
|
||||
}
|
||||
c := NewClient(t.Logf, nil, nil, nil)
|
||||
c := NewClient(t.Logf, nil, nil, new(controlknobs.Knobs), nil)
|
||||
defer c.Close()
|
||||
for i := 0; i < 3; i++ {
|
||||
if i > 0 {
|
||||
@@ -47,7 +49,7 @@ func TestClientProbeThenMap(t *testing.T) {
|
||||
if v, _ := strconv.ParseBool(os.Getenv("HIT_NETWORK")); !v {
|
||||
t.Skip("skipping test without HIT_NETWORK=1")
|
||||
}
|
||||
c := NewClient(t.Logf, nil, nil, nil)
|
||||
c := NewClient(t.Logf, nil, nil, new(controlknobs.Knobs), nil)
|
||||
defer c.Close()
|
||||
c.SetLocalPort(1234)
|
||||
res, err := c.Probe(context.Background())
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
@@ -24,7 +25,8 @@ import (
|
||||
|
||||
"github.com/tailscale/goupnp"
|
||||
"github.com/tailscale/goupnp/dcps/internetgateway2"
|
||||
"tailscale.com/control/controlknobs"
|
||||
"github.com/tailscale/goupnp/soap"
|
||||
"tailscale.com/envknob"
|
||||
"tailscale.com/net/netns"
|
||||
"tailscale.com/types/logger"
|
||||
)
|
||||
@@ -192,7 +194,7 @@ func addAnyPortMapping(
|
||||
// The provided ctx is not retained in the returned upnpClient, but
|
||||
// its associated HTTP client is (if set via goupnp.WithHTTPClient).
|
||||
func getUPnPClient(ctx context.Context, logf logger.Logf, debug DebugKnobs, gw netip.Addr, meta uPnPDiscoResponse) (client upnpClient, err error) {
|
||||
if controlknobs.DisableUPnP() || debug.DisableUPnP {
|
||||
if debug.DisableUPnP {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -270,6 +272,10 @@ func (c *Client) upnpHTTPClientLocked() *http.Client {
|
||||
return c.uPnPHTTPClient
|
||||
}
|
||||
|
||||
var (
|
||||
disableUPnpEnv = envknob.RegisterBool("TS_DISABLE_UPNP")
|
||||
)
|
||||
|
||||
// getUPnPPortMapping attempts to create a port-mapping over the UPnP protocol. On success,
|
||||
// it will return the externally exposed IP and port. Otherwise, it will return a zeroed IP and
|
||||
// port and an error.
|
||||
@@ -279,7 +285,7 @@ func (c *Client) getUPnPPortMapping(
|
||||
internal netip.AddrPort,
|
||||
prevPort uint16,
|
||||
) (external netip.AddrPort, ok bool) {
|
||||
if controlknobs.DisableUPnP() || c.debug.DisableUPnP {
|
||||
if disableUPnpEnv() || c.debug.DisableUPnP || (c.controlKnobs != nil && c.controlKnobs.DisableUPnP.Load()) {
|
||||
return netip.AddrPort{}, false
|
||||
}
|
||||
|
||||
@@ -312,6 +318,7 @@ func (c *Client) getUPnPPortMapping(
|
||||
return netip.AddrPort{}, false
|
||||
}
|
||||
|
||||
// Start by trying to make a temporary lease with a duration.
|
||||
var newPort uint16
|
||||
newPort, err = addAnyPortMapping(
|
||||
ctx,
|
||||
@@ -319,14 +326,37 @@ func (c *Client) getUPnPPortMapping(
|
||||
prevPort,
|
||||
internal.Port(),
|
||||
internal.Addr().String(),
|
||||
time.Second*pmpMapLifetimeSec,
|
||||
pmpMapLifetimeSec*time.Second,
|
||||
)
|
||||
if c.debug.VerboseLogs {
|
||||
c.logf("addAnyPortMapping: %v, err=%q", newPort, err)
|
||||
}
|
||||
|
||||
// If this is an error and the code is
|
||||
// "OnlyPermanentLeasesSupported", then we retry with no lease
|
||||
// duration; see the following issue for details:
|
||||
// https://github.com/tailscale/tailscale/issues/9343
|
||||
if err != nil {
|
||||
// From the UPnP spec: http://upnp.org/specs/gw/UPnP-gw-WANIPConnection-v2-Service.pdf
|
||||
// 725: OnlyPermanentLeasesSupported
|
||||
if isUPnPError(err, 725) {
|
||||
newPort, err = addAnyPortMapping(
|
||||
ctx,
|
||||
client,
|
||||
prevPort,
|
||||
internal.Port(),
|
||||
internal.Addr().String(),
|
||||
0, // permanent
|
||||
)
|
||||
if c.debug.VerboseLogs {
|
||||
c.logf("addAnyPortMapping: 725 retry %v, err=%q", newPort, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return netip.AddrPort{}, false
|
||||
}
|
||||
|
||||
// TODO cache this ip somewhere?
|
||||
extIP, err := client.GetExternalIPAddress(ctx)
|
||||
if c.debug.VerboseLogs {
|
||||
@@ -342,6 +372,10 @@ func (c *Client) getUPnPPortMapping(
|
||||
}
|
||||
|
||||
upnp.external = netip.AddrPortFrom(externalIP, newPort)
|
||||
|
||||
// NOTE: this time might not technically be accurate if we created a
|
||||
// permanent lease above, but we should still re-check the presence of
|
||||
// the lease on a regular basis so we use it anyway.
|
||||
d := time.Duration(pmpMapLifetimeSec) * time.Second
|
||||
upnp.goodUntil = now.Add(d)
|
||||
upnp.renewAfter = now.Add(d / 2)
|
||||
@@ -353,6 +387,30 @@ func (c *Client) getUPnPPortMapping(
|
||||
return upnp.external, true
|
||||
}
|
||||
|
||||
// isUPnPError returns whether the provided error is a UPnP error response with
|
||||
// the given error code. It returns false if the error is not a SOAP error, or
|
||||
// the inner error details are not a UPnP error.
|
||||
func isUPnPError(err error, errCode int) bool {
|
||||
soapErr, ok := err.(*soap.SOAPFaultError)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
var upnpErr struct {
|
||||
XMLName xml.Name
|
||||
Code int `xml:"errorCode"`
|
||||
Description string `xml:"errorDescription"`
|
||||
}
|
||||
if err := xml.Unmarshal([]byte(soapErr.Detail.Raw), &upnpErr); err != nil {
|
||||
return false
|
||||
}
|
||||
if upnpErr.XMLName.Local != "UPnPError" {
|
||||
return false
|
||||
}
|
||||
|
||||
return upnpErr.Code == errCode
|
||||
}
|
||||
|
||||
type uPnPDiscoResponse struct {
|
||||
Location string
|
||||
// Server describes what version the UPnP is, such as MiniUPnPd/2.x.x
|
||||
|
||||
@@ -5,6 +5,7 @@ package portmapper
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
"net/netip"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"tailscale.com/tstest"
|
||||
@@ -129,3 +131,217 @@ func TestGetUPnPClient(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUPnPPortMapping(t *testing.T) {
|
||||
igd, err := NewTestIGD(t.Logf, TestIGDOptions{UPnP: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer igd.Close()
|
||||
|
||||
c := newTestClient(t, igd)
|
||||
t.Logf("Listening on upnp=%v", c.testUPnPPort)
|
||||
defer c.Close()
|
||||
|
||||
c.debug.VerboseLogs = true
|
||||
|
||||
// This is a very basic fake UPnP server handler.
|
||||
var sawRequestWithLease atomic.Bool
|
||||
igd.SetUPnPHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Logf("got UPnP request %s %s", r.Method, r.URL.Path)
|
||||
switch r.URL.Path {
|
||||
case "/rootDesc.xml":
|
||||
io.WriteString(w, testRootDesc)
|
||||
case "/ctl/IPConn":
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Errorf("error reading request body: %v", err)
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Decode the request type.
|
||||
var outerRequest struct {
|
||||
Body struct {
|
||||
Request struct {
|
||||
XMLName xml.Name
|
||||
} `xml:",any"`
|
||||
Inner string `xml:",innerxml"`
|
||||
} `xml:"Body"`
|
||||
}
|
||||
if err := xml.Unmarshal(body, &outerRequest); err != nil {
|
||||
t.Errorf("bad request: %v", err)
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
requestType := outerRequest.Body.Request.XMLName.Local
|
||||
upnpRequest := outerRequest.Body.Inner
|
||||
t.Logf("UPnP request: %s", requestType)
|
||||
|
||||
switch requestType {
|
||||
case "AddPortMapping":
|
||||
// Decode a minimal body to determine whether we skip the request or not.
|
||||
var req struct {
|
||||
Protocol string `xml:"NewProtocol"`
|
||||
InternalPort string `xml:"NewInternalPort"`
|
||||
ExternalPort string `xml:"NewExternalPort"`
|
||||
InternalClient string `xml:"NewInternalClient"`
|
||||
LeaseDuration string `xml:"NewLeaseDuration"`
|
||||
}
|
||||
if err := xml.Unmarshal([]byte(upnpRequest), &req); err != nil {
|
||||
t.Errorf("bad request: %v", err)
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Protocol != "UDP" {
|
||||
t.Errorf(`got Protocol=%q, want "UDP"`, req.Protocol)
|
||||
}
|
||||
if req.LeaseDuration != "0" {
|
||||
// Return a fake error to ensure that we fall back to a permanent lease.
|
||||
io.WriteString(w, testAddPortMappingPermanentLease)
|
||||
sawRequestWithLease.Store(true)
|
||||
} else {
|
||||
// Success!
|
||||
io.WriteString(w, testAddPortMappingResponse)
|
||||
}
|
||||
case "GetExternalIPAddress":
|
||||
io.WriteString(w, testGetExternalIPAddressResponse)
|
||||
|
||||
case "DeletePortMapping":
|
||||
// Do nothing for test
|
||||
|
||||
default:
|
||||
t.Errorf("unhandled UPnP request type %q", requestType)
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
}
|
||||
default:
|
||||
t.Logf("ignoring request")
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
|
||||
ctx := context.Background()
|
||||
res, err := c.Probe(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Probe: %v", err)
|
||||
}
|
||||
if !res.UPnP {
|
||||
t.Errorf("didn't detect UPnP")
|
||||
}
|
||||
|
||||
gw, myIP, ok := c.gatewayAndSelfIP()
|
||||
if !ok {
|
||||
t.Fatalf("could not get gateway and self IP")
|
||||
}
|
||||
t.Logf("gw=%v myIP=%v", gw, myIP)
|
||||
|
||||
ext, ok := c.getUPnPPortMapping(ctx, gw, netip.AddrPortFrom(myIP, 12345), 0)
|
||||
if !ok {
|
||||
t.Fatal("could not get UPnP port mapping")
|
||||
}
|
||||
if got, want := ext.Addr(), netip.MustParseAddr("123.123.123.123"); got != want {
|
||||
t.Errorf("bad external address; got %v want %v", got, want)
|
||||
}
|
||||
if !sawRequestWithLease.Load() {
|
||||
t.Errorf("wanted request with lease, but didn't see one")
|
||||
}
|
||||
t.Logf("external IP: %v", ext)
|
||||
}
|
||||
|
||||
const testRootDesc = `<?xml version="1.0"?>
|
||||
<root xmlns="urn:schemas-upnp-org:device-1-0" configId="1337">
|
||||
<specVersion>
|
||||
<major>1</major>
|
||||
<minor>1</minor>
|
||||
</specVersion>
|
||||
<device>
|
||||
<deviceType>urn:schemas-upnp-org:device:InternetGatewayDevice:1</deviceType>
|
||||
<friendlyName>Tailscale Test Router</friendlyName>
|
||||
<manufacturer>Tailscale</manufacturer>
|
||||
<manufacturerURL>https://tailscale.com</manufacturerURL>
|
||||
<modelDescription>Tailscale Test Router</modelDescription>
|
||||
<modelName>Tailscale Test Router</modelName>
|
||||
<modelNumber>2.5.0-RELEASE</modelNumber>
|
||||
<modelURL>https://tailscale.com</modelURL>
|
||||
<serialNumber>1234</serialNumber>
|
||||
<UDN>uuid:1974e83b-6dc7-4635-92b3-6a85a4037294</UDN>
|
||||
<deviceList>
|
||||
<device>
|
||||
<deviceType>urn:schemas-upnp-org:device:WANDevice:1</deviceType>
|
||||
<friendlyName>WANDevice</friendlyName>
|
||||
<manufacturer>MiniUPnP</manufacturer>
|
||||
<manufacturerURL>http://miniupnp.free.fr/</manufacturerURL>
|
||||
<modelDescription>WAN Device</modelDescription>
|
||||
<modelName>WAN Device</modelName>
|
||||
<modelNumber>20990102</modelNumber>
|
||||
<modelURL>http://miniupnp.free.fr/</modelURL>
|
||||
<serialNumber>1234</serialNumber>
|
||||
<UDN>uuid:1974e83b-6dc7-4635-92b3-6a85a4037294</UDN>
|
||||
<UPC>000000000000</UPC>
|
||||
<deviceList>
|
||||
<device>
|
||||
<deviceType>urn:schemas-upnp-org:device:WANConnectionDevice:1</deviceType>
|
||||
<friendlyName>WANConnectionDevice</friendlyName>
|
||||
<manufacturer>MiniUPnP</manufacturer>
|
||||
<manufacturerURL>http://miniupnp.free.fr/</manufacturerURL>
|
||||
<modelDescription>MiniUPnP daemon</modelDescription>
|
||||
<modelName>MiniUPnPd</modelName>
|
||||
<modelNumber>20210205</modelNumber>
|
||||
<modelURL>http://miniupnp.free.fr/</modelURL>
|
||||
<serialNumber>1234</serialNumber>
|
||||
<UDN>uuid:1974e83b-6dc7-4635-92b3-6a85a4037294</UDN>
|
||||
<UPC>000000000000</UPC>
|
||||
<serviceList>
|
||||
<service>
|
||||
<serviceType>urn:schemas-upnp-org:service:WANIPConnection:1</serviceType>
|
||||
<serviceId>urn:upnp-org:serviceId:WANIPConn1</serviceId>
|
||||
<SCPDURL>/WANIPCn.xml</SCPDURL>
|
||||
<controlURL>/ctl/IPConn</controlURL>
|
||||
<eventSubURL>/evt/IPConn</eventSubURL>
|
||||
</service>
|
||||
</serviceList>
|
||||
</device>
|
||||
</deviceList>
|
||||
</device>
|
||||
</deviceList>
|
||||
<presentationURL>https://127.0.0.1/</presentationURL>
|
||||
</device>
|
||||
</root>
|
||||
`
|
||||
|
||||
const testAddPortMappingPermanentLease = `<?xml version="1.0"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
|
||||
<s:Body>
|
||||
<s:Fault>
|
||||
<faultCode>s:Client</faultCode>
|
||||
<faultString>UPnPError</faultString>
|
||||
<detail>
|
||||
<UPnPError xmlns="urn:schemas-upnp-org:control-1-0">
|
||||
<errorCode>725</errorCode>
|
||||
<errorDescription>OnlyPermanentLeasesSupported</errorDescription>
|
||||
</UPnPError>
|
||||
</detail>
|
||||
</s:Fault>
|
||||
</s:Body>
|
||||
</s:Envelope>
|
||||
`
|
||||
|
||||
const testAddPortMappingResponse = `<?xml version="1.0"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
|
||||
<s:Body>
|
||||
<u:AddPortMappingResponse xmlns:u="urn:schemas-upnp-org:service:WANIPConnection:1"/>
|
||||
</s:Body>
|
||||
</s:Envelope>
|
||||
`
|
||||
|
||||
const testGetExternalIPAddressResponse = `<?xml version="1.0"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
|
||||
<s:Body>
|
||||
<u:GetExternalIPAddressResponse xmlns:u="urn:schemas-upnp-org:service:WANIPConnection:1">
|
||||
<NewExternalIPAddress>123.123.123.123</NewExternalIPAddress>
|
||||
</u:GetExternalIPAddressResponse>
|
||||
</s:Body>
|
||||
</s:Envelope>
|
||||
`
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"tailscale.com/net/netns"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/types/netmap"
|
||||
"tailscale.com/util/clientmetric"
|
||||
"tailscale.com/util/mak"
|
||||
)
|
||||
|
||||
@@ -138,16 +139,25 @@ func (d *Dialer) SetNetMon(netMon *netmon.Monitor) {
|
||||
d.netMonUnregister = d.netMon.RegisterChangeCallback(d.linkChanged)
|
||||
}
|
||||
|
||||
var (
|
||||
metricLinkChangeConnClosed = clientmetric.NewCounter("tsdial_linkchange_closes")
|
||||
)
|
||||
|
||||
func (d *Dialer) linkChanged(delta *netmon.ChangeDelta) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
var anyClosed bool
|
||||
for id, c := range d.activeSysConns {
|
||||
if changeAffectsConn(delta, c) {
|
||||
anyClosed = true
|
||||
d.logf("tsdial: closing system connection %v->%v due to link change", c.LocalAddr(), c.RemoteAddr())
|
||||
go c.Close()
|
||||
delete(d.activeSysConns, id)
|
||||
}
|
||||
}
|
||||
if anyClosed {
|
||||
metricLinkChangeConnClosed.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
// changeAffectsConn reports whether the network change delta affects
|
||||
|
||||
@@ -35,6 +35,7 @@ import (
|
||||
"tailscale.com/types/views"
|
||||
"tailscale.com/util/clientmetric"
|
||||
"tailscale.com/util/mak"
|
||||
"tailscale.com/util/set"
|
||||
"tailscale.com/wgengine/capture"
|
||||
"tailscale.com/wgengine/filter"
|
||||
"tailscale.com/wgengine/wgcfg"
|
||||
@@ -589,7 +590,7 @@ func natConfigFromWGConfig(wcfg *wgcfg.Config) *natV4Config {
|
||||
var (
|
||||
rt table.RoutingTableBuilder
|
||||
dstMasqAddrs map[key.NodePublic]netip.Addr
|
||||
listenAddrs map[netip.Addr]struct{}
|
||||
listenAddrs set.Set[netip.Addr]
|
||||
)
|
||||
|
||||
// When using an exit node that requires masquerading, we need to
|
||||
|
||||
@@ -27,6 +27,9 @@ func DefaultTailscaledSocket() string {
|
||||
if runtime.GOOS == "darwin" {
|
||||
return "/var/run/tailscaled.socket"
|
||||
}
|
||||
if runtime.GOOS == "plan9" {
|
||||
return "/srv/tailscaled.sock"
|
||||
}
|
||||
switch distro.Get() {
|
||||
case distro.Synology:
|
||||
if distro.DSMVersion() == 6 {
|
||||
|
||||
2
release/dist/synology/files/index.cgi
vendored
2
release/dist/synology/files/index.cgi
vendored
@@ -1,2 +1,2 @@
|
||||
#! /bin/sh
|
||||
exec /var/packages/Tailscale/target/bin/tailscale web -cgi
|
||||
exec /var/packages/Tailscale/target/bin/tailscale web -cgi -prefix="/webman/3rdparty/Tailscale/index.cgi/"
|
||||
|
||||
23
release/dist/unixpkgs/pkgs.go
vendored
23
release/dist/unixpkgs/pkgs.go
vendored
@@ -250,10 +250,25 @@ func (t *debTarget) Build(b *dist.Build) ([]string, error) {
|
||||
PreRemove: filepath.Join(repoDir, "release/deb/debian.prerm.sh"),
|
||||
PostRemove: filepath.Join(repoDir, "release/deb/debian.postrm.sh"),
|
||||
},
|
||||
Depends: []string{"iptables", "iproute2"},
|
||||
Recommends: []string{"tailscale-archive-keyring (>= 1.35.181)"},
|
||||
Replaces: []string{"tailscale-relay"},
|
||||
Conflicts: []string{"tailscale-relay"},
|
||||
Depends: []string{},
|
||||
Recommends: []string{
|
||||
"tailscale-archive-keyring (>= 1.35.181)",
|
||||
// iptables is often required but not strictly needed; see
|
||||
// https://github.com/tailscale/tailscale/issues/9236.
|
||||
// We want to let people be able to install without it
|
||||
// or remove it after the fact if they want.
|
||||
"iptables",
|
||||
// The "ip" command isn't needed since 2021-11-01 in
|
||||
// 408b0923a61972ed but kept as an option as of
|
||||
// 2021-11-18 in d24ed3f68e35e802d531371. See
|
||||
// https://github.com/tailscale/tailscale/issues/391.
|
||||
// We keep it recommended because it's usually
|
||||
// installed anyway and it's useful for debugging. But
|
||||
// we can live without it, so it's not Depends.
|
||||
"iproute2",
|
||||
},
|
||||
Replaces: []string{"tailscale-relay"},
|
||||
Conflicts: []string{"tailscale-relay"},
|
||||
},
|
||||
})
|
||||
pkg, err := nfpm.Get("deb")
|
||||
|
||||
@@ -8,12 +8,14 @@ import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -46,6 +48,17 @@ func localTCPPortAndTokenMacsys() (port int, token string, err error) {
|
||||
if auth == "" {
|
||||
return 0, "", errors.New("empty auth token in sameuserproof file")
|
||||
}
|
||||
|
||||
// The above files exist forever after the first run of
|
||||
// /Applications/Tailscale.app, so check we can connect to avoid returning a
|
||||
// port nothing is listening on. Connect to "127.0.0.1" rather than
|
||||
// "localhost" due to #7851.
|
||||
conn, err := net.DialTimeout("tcp", "127.0.0.1:"+portStr, time.Second)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
conn.Close()
|
||||
|
||||
return port, auth, nil
|
||||
}
|
||||
|
||||
|
||||
124
safesocket/safesocket_plan9.go
Normal file
124
safesocket/safesocket_plan9.go
Normal file
@@ -0,0 +1,124 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build plan9
|
||||
|
||||
package safesocket
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/plan9"
|
||||
)
|
||||
|
||||
// Plan 9's devsrv srv(3) is a server registry and
|
||||
// it is conventionally bound to "/srv" in the default
|
||||
// namespace. It is "a one level directory for holding
|
||||
// already open channels to services". Post one end of
|
||||
// a pipe to "/srv/tailscale.sock" and use the other
|
||||
// end for communication with a requestor. Plan 9 pipes
|
||||
// are bidirectional.
|
||||
|
||||
type plan9SrvAddr string
|
||||
|
||||
func (sl plan9SrvAddr) Network() string {
|
||||
return "/srv"
|
||||
}
|
||||
|
||||
func (sl plan9SrvAddr) String() string {
|
||||
return string(sl)
|
||||
}
|
||||
|
||||
// There is no net.FileListener for Plan 9 at this time
|
||||
type plan9SrvListener struct {
|
||||
name string
|
||||
srvf *os.File
|
||||
file *os.File
|
||||
}
|
||||
|
||||
func (sl *plan9SrvListener) Accept() (net.Conn, error) {
|
||||
// sl.file is the server end of the pipe that's
|
||||
// connected to /srv/tailscale.sock
|
||||
return plan9FileConn{name: sl.name, file: sl.file}, nil
|
||||
}
|
||||
|
||||
func (sl *plan9SrvListener) Close() error {
|
||||
sl.file.Close()
|
||||
return sl.srvf.Close()
|
||||
}
|
||||
|
||||
func (sl *plan9SrvListener) Addr() net.Addr {
|
||||
return plan9SrvAddr(sl.name)
|
||||
}
|
||||
|
||||
type plan9FileConn struct {
|
||||
name string
|
||||
file *os.File
|
||||
}
|
||||
|
||||
func (fc plan9FileConn) Read(b []byte) (n int, err error) {
|
||||
return fc.file.Read(b)
|
||||
}
|
||||
func (fc plan9FileConn) Write(b []byte) (n int, err error) {
|
||||
return fc.file.Write(b)
|
||||
}
|
||||
func (fc plan9FileConn) Close() error {
|
||||
return fc.file.Close()
|
||||
}
|
||||
func (fc plan9FileConn) LocalAddr() net.Addr {
|
||||
return plan9SrvAddr(fc.name)
|
||||
}
|
||||
func (fc plan9FileConn) RemoteAddr() net.Addr {
|
||||
return plan9SrvAddr(fc.name)
|
||||
}
|
||||
func (fc plan9FileConn) SetDeadline(t time.Time) error {
|
||||
return syscall.EPLAN9
|
||||
}
|
||||
func (fc plan9FileConn) SetReadDeadline(t time.Time) error {
|
||||
return syscall.EPLAN9
|
||||
}
|
||||
func (fc plan9FileConn) SetWriteDeadline(t time.Time) error {
|
||||
return syscall.EPLAN9
|
||||
}
|
||||
|
||||
func connect(s *ConnectionStrategy) (net.Conn, error) {
|
||||
f, err := os.OpenFile(s.path, os.O_RDWR, 0666)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return plan9FileConn{name: s.path, file: f}, nil
|
||||
}
|
||||
|
||||
// Create an entry in /srv, open a pipe, write the
|
||||
// client end to the entry and return the server
|
||||
// end of the pipe to the caller. When the server
|
||||
// end of the pipe is closed, /srv name associated
|
||||
// with it will be removed (controlled by ORCLOSE flag)
|
||||
func listen(path string) (net.Listener, error) {
|
||||
const O_RCLOSE = 64 // remove on close; should be in plan9 package
|
||||
var pip [2]int
|
||||
|
||||
err := plan9.Pipe(pip[:])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer plan9.Close(pip[1])
|
||||
|
||||
srvfd, err := plan9.Create(path, plan9.O_WRONLY|plan9.O_CLOEXEC|O_RCLOSE, 0600)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
srv := os.NewFile(uintptr(srvfd), path)
|
||||
|
||||
_, err = fmt.Fprintf(srv, "%d", pip[1])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &plan9SrvListener{name: path, srvf: srv, file: os.NewFile(uintptr(pip[0]), path)}, nil
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !windows && !js
|
||||
//go:build !windows && !js && !plan9
|
||||
|
||||
package safesocket
|
||||
|
||||
|
||||
@@ -1088,6 +1088,7 @@ func (ss *sshSession) run() {
|
||||
ss.Exit(1)
|
||||
return
|
||||
}
|
||||
ss.logf("startNewRecording: <nil>")
|
||||
if rec != nil {
|
||||
defer rec.Close()
|
||||
}
|
||||
@@ -1658,6 +1659,7 @@ func (ss *sshSession) startNewRecording() (_ *recording, err error) {
|
||||
err := <-errChan
|
||||
if err == nil {
|
||||
// Success.
|
||||
ss.logf("recording: finished uploading recording")
|
||||
return
|
||||
}
|
||||
if onFailure != nil && onFailure.NotifyURL != "" && len(attempts) > 0 {
|
||||
|
||||
@@ -39,7 +39,7 @@ type C2NSSHUsernamesResponse struct {
|
||||
// its Tailscale installation.
|
||||
type C2NUpdateResponse struct {
|
||||
// Err is the error message, if any.
|
||||
Err string
|
||||
Err string `json:",omitempty"`
|
||||
|
||||
// Enabled indicates whether the user has opted in to updates triggered from
|
||||
// control.
|
||||
|
||||
@@ -7,7 +7,6 @@ package tailcfg
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -111,7 +110,8 @@ type CapabilityVersion int
|
||||
// - 70: 2023-08-16: removed most Debug fields; added NodeAttrDisable*, NodeAttrDebug* instead
|
||||
// - 71: 2023-08-17: added NodeAttrOneCGNATEnable, NodeAttrOneCGNATDisable
|
||||
// - 72: 2023-08-23: TS-2023-006 UPnP issue fixed; UPnP can now be used again
|
||||
const CurrentCapabilityVersion CapabilityVersion = 72
|
||||
// - 73: 2023-09-01: Non-Windows clients expect to receive ClientVersion
|
||||
const CurrentCapabilityVersion CapabilityVersion = 73
|
||||
|
||||
type StableID string
|
||||
|
||||
@@ -445,6 +445,10 @@ const (
|
||||
MachineInvalid // server has explicitly rejected this machine key
|
||||
)
|
||||
|
||||
func (m MachineStatus) AppendText(b []byte) ([]byte, error) {
|
||||
return append(b, m.String()...), nil
|
||||
}
|
||||
|
||||
func (m MachineStatus) MarshalText() ([]byte, error) {
|
||||
return []byte(m.String()), nil
|
||||
}
|
||||
@@ -921,6 +925,10 @@ const (
|
||||
SignatureV2
|
||||
)
|
||||
|
||||
func (st SignatureType) AppendText(b []byte) ([]byte, error) {
|
||||
return append(b, st.String()...), nil
|
||||
}
|
||||
|
||||
func (st SignatureType) MarshalText() ([]byte, error) {
|
||||
return []byte(st.String()), nil
|
||||
}
|
||||
@@ -1069,7 +1077,9 @@ type Endpoint struct {
|
||||
Type EndpointType
|
||||
}
|
||||
|
||||
// MapRequest is sent by a client to start a long-poll network map updates.
|
||||
// MapRequest is sent by a client to either update the control plane
|
||||
// about its current state, or to start a long-poll of network map updates.
|
||||
//
|
||||
// The request includes a copy of the client's current set of WireGuard
|
||||
// endpoints and general host information.
|
||||
//
|
||||
@@ -1085,11 +1095,10 @@ type MapRequest struct {
|
||||
// For current values and history, see the CapabilityVersion type's docs.
|
||||
Version CapabilityVersion
|
||||
|
||||
Compress string // "zstd" or "" (no compression)
|
||||
KeepAlive bool // whether server should send keep-alives back to us
|
||||
NodeKey key.NodePublic
|
||||
DiscoKey key.DiscoPublic
|
||||
IncludeIPv6 bool `json:",omitempty"` // include IPv6 endpoints in returned Node Endpoints (for Version 4 clients)
|
||||
Compress string // "zstd" or "" (no compression)
|
||||
KeepAlive bool // whether server should send keep-alives back to us
|
||||
NodeKey key.NodePublic
|
||||
DiscoKey key.DiscoPublic
|
||||
|
||||
// Stream is whether the client wants to receive multiple MapResponses over
|
||||
// the same HTTP connection.
|
||||
@@ -1531,6 +1540,27 @@ type PingResponse struct {
|
||||
IsLocalIP bool `json:",omitempty"`
|
||||
}
|
||||
|
||||
// MapResponse is the response to a MapRequest. It describes the state of the
|
||||
// local node, the peer nodes, the DNS configuration, the packet filter, and
|
||||
// more. A MapRequest, depending on its parameters, may result in the control
|
||||
// plane coordination server sending 0, 1 or a stream of multiple MapResponse
|
||||
// values.
|
||||
//
|
||||
// When the client sets MapRequest.Stream, the server sends a stream of
|
||||
// MapResponses. That long-lived HTTP transaction is called a "map poll". In a
|
||||
// map poll, the first MapResponse will be complete and subsequent MapResponses
|
||||
// will be incremental updates with only changed information.
|
||||
//
|
||||
// The zero value for all fields means "unchanged". Unfortunately, several
|
||||
// fields were defined before that convention was established, so they use a
|
||||
// slice with omitempty, meaning this type can't be used to marshal JSON
|
||||
// containing non-nil zero-length slices (meaning explicitly now empty). The
|
||||
// control plane uses a separate type to marshal these fields. This type is
|
||||
// primarily used for unmarshaling responses so the omitempty annotations are
|
||||
// mostly useless, except that this type is also used for the integration test's
|
||||
// fake control server. (It's not necessary to marshal a non-nil zero-length
|
||||
// slice for the things we've needed to test in the integration tests as of
|
||||
// 2023-09-09).
|
||||
type MapResponse struct {
|
||||
// MapSessionHandle optionally specifies a unique opaque handle for this
|
||||
// stateful MapResponse session. Servers may choose not to send it, and it's
|
||||
@@ -1631,6 +1661,10 @@ type MapResponse struct {
|
||||
// previously streamed non-nil MapResponse.PacketFilter within
|
||||
// the same HTTP response. A non-nil but empty list always means
|
||||
// no PacketFilter (that is, to block everything).
|
||||
//
|
||||
// Note that this package's type, due its use of a slice and omitempty, is
|
||||
// unable to marshal a zero-length non-nil slice. The control server needs
|
||||
// to marshal this type using a separate type. See MapResponse docs.
|
||||
PacketFilter []FilterRule `json:",omitempty"`
|
||||
|
||||
// UserProfiles are the user profiles of nodes in the network.
|
||||
@@ -1638,12 +1672,15 @@ type MapResponse struct {
|
||||
// user profiles only.
|
||||
UserProfiles []UserProfile `json:",omitempty"`
|
||||
|
||||
// Health, if non-nil, sets the health state
|
||||
// of the node from the control plane's perspective.
|
||||
// A nil value means no change from the previous MapResponse.
|
||||
// A non-nil 0-length slice restores the health to good (no known problems).
|
||||
// A non-zero length slice are the list of problems that the control place
|
||||
// sees.
|
||||
// Health, if non-nil, sets the health state of the node from the control
|
||||
// plane's perspective. A nil value means no change from the previous
|
||||
// MapResponse. A non-nil 0-length slice restores the health to good (no
|
||||
// known problems). A non-zero length slice are the list of problems that
|
||||
// the control place sees.
|
||||
//
|
||||
// Note that this package's type, due its use of a slice and omitempty, is
|
||||
// unable to marshal a zero-length non-nil slice. The control server needs
|
||||
// to marshal this type using a separate type. See MapResponse docs.
|
||||
Health []string `json:",omitempty"`
|
||||
|
||||
// SSHPolicy, if non-nil, updates the SSH policy for how incoming
|
||||
@@ -1765,18 +1802,6 @@ type Debug struct {
|
||||
Exit *int `json:",omitempty"`
|
||||
}
|
||||
|
||||
func appendKey(base []byte, prefix string, k [32]byte) []byte {
|
||||
ret := append(base, make([]byte, len(prefix)+64)...)
|
||||
buf := ret[len(base):]
|
||||
copy(buf, prefix)
|
||||
hex.Encode(buf[len(prefix):], k[:])
|
||||
return ret
|
||||
}
|
||||
|
||||
func keyMarshalText(prefix string, k [32]byte) []byte {
|
||||
return appendKey(nil, prefix, k)
|
||||
}
|
||||
|
||||
func (id ID) String() string { return fmt.Sprintf("id:%x", int64(id)) }
|
||||
func (id UserID) String() string { return fmt.Sprintf("userid:%x", int64(id)) }
|
||||
func (id LoginID) String() string { return fmt.Sprintf("loginid:%x", int64(id)) }
|
||||
@@ -1970,6 +1995,11 @@ const (
|
||||
// new attempts at UPnP connections.
|
||||
NodeAttrDisableUPnP = "debug-disable-upnp"
|
||||
|
||||
// NodeAttrDisableDeltaUpdates makes the client not process updates via the
|
||||
// delta update mechanism and should instead treat all netmap changes as
|
||||
// "full" ones as tailscaled did in 1.48.x and earlier.
|
||||
NodeAttrDisableDeltaUpdates = "disable-delta-updates"
|
||||
|
||||
// NodeAttrRandomizeClientPort makes magicsock UDP bind to
|
||||
// :0 to get a random local port, ignoring any configured
|
||||
// fixed port.
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package tailcfg
|
||||
|
||||
var ExportKeyMarshalText = keyMarshalText
|
||||
@@ -16,11 +16,9 @@ import (
|
||||
"time"
|
||||
|
||||
. "tailscale.com/tailcfg"
|
||||
"tailscale.com/tstest"
|
||||
"tailscale.com/types/key"
|
||||
"tailscale.com/types/ptr"
|
||||
"tailscale.com/util/must"
|
||||
"tailscale.com/version"
|
||||
)
|
||||
|
||||
func fieldsOf(t reflect.Type) (fields []string) {
|
||||
@@ -683,29 +681,6 @@ func TestEndpointTypeMarshal(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
var sinkBytes []byte
|
||||
|
||||
func BenchmarkKeyMarshalText(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
var k [32]byte
|
||||
for i := 0; i < b.N; i++ {
|
||||
sinkBytes = ExportKeyMarshalText("prefix", k)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendKeyAllocs(t *testing.T) {
|
||||
if version.IsRace() {
|
||||
t.Skip("skipping in race detector") // append(b, make([]byte, N)...) not optimized in compiler with race
|
||||
}
|
||||
var k [32]byte
|
||||
err := tstest.MinAllocsPerRun(t, 1, func() {
|
||||
sinkBytes = ExportKeyMarshalText("prefix", k)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterRequestNilClone(t *testing.T) {
|
||||
var nilReq *RegisterRequest
|
||||
got := nilReq.Clone()
|
||||
@@ -730,3 +705,24 @@ func TestCurrentCapabilityVersion(t *testing.T) {
|
||||
t.Errorf("CurrentCapabilityVersion = %d; want %d", CurrentCapabilityVersion, max)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalHealth(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string // MapResponse JSON
|
||||
want []string // MapResponse.Health wanted value post-unmarshal
|
||||
}{
|
||||
{in: `{}`},
|
||||
{in: `{"Health":null}`},
|
||||
{in: `{"Health":[]}`, want: []string{}},
|
||||
{in: `{"Health":["bad"]}`, want: []string{"bad"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
var mr MapResponse
|
||||
if err := json.Unmarshal([]byte(tt.in), &mr); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(mr.Health, tt.want) {
|
||||
t.Errorf("for %#q: got %v; want %v", tt.in, mr.Health, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
25
tka/aum.go
25
tka/aum.go
@@ -9,10 +9,12 @@ import (
|
||||
"encoding/base32"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"github.com/fxamacker/cbor/v2"
|
||||
"golang.org/x/crypto/blake2s"
|
||||
"tailscale.com/types/tkatype"
|
||||
"tailscale.com/util/set"
|
||||
)
|
||||
|
||||
// AUMHash represents the BLAKE2s digest of an Authority Update Message (AUM).
|
||||
@@ -37,11 +39,22 @@ func (h *AUMHash) UnmarshalText(text []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// TODO(https://go.dev/issue/53693): Use base32.Encoding.AppendEncode instead.
|
||||
func base32AppendEncode(enc *base32.Encoding, dst, src []byte) []byte {
|
||||
n := enc.EncodedLen(len(src))
|
||||
dst = slices.Grow(dst, n)
|
||||
enc.Encode(dst[len(dst):][:n], src)
|
||||
return dst[:len(dst)+n]
|
||||
}
|
||||
|
||||
// AppendText implements encoding.TextAppender.
|
||||
func (h AUMHash) AppendText(b []byte) ([]byte, error) {
|
||||
return base32AppendEncode(base32StdNoPad, b, h[:]), nil
|
||||
}
|
||||
|
||||
// MarshalText implements encoding.TextMarshaler.
|
||||
func (h AUMHash) MarshalText() ([]byte, error) {
|
||||
b := make([]byte, base32StdNoPad.EncodedLen(len(h)))
|
||||
base32StdNoPad.Encode(b, h[:])
|
||||
return b, nil
|
||||
return h.AppendText(nil)
|
||||
}
|
||||
|
||||
// IsZero returns true if the hash is the empty value.
|
||||
@@ -314,7 +327,7 @@ func (a *AUM) Weight(state State) uint {
|
||||
// Despite the wire encoding being []byte, all KeyIDs are
|
||||
// 32 bytes. As such, we use that as the key for the map,
|
||||
// because map keys cannot be slices.
|
||||
seenKeys := make(map[[32]byte]struct{}, 6)
|
||||
seenKeys := make(set.Set[[32]byte], 6)
|
||||
for _, sig := range a.Signatures {
|
||||
if len(sig.KeyID) != 32 {
|
||||
panic("unexpected: keyIDs are 32 bytes")
|
||||
@@ -332,12 +345,12 @@ func (a *AUM) Weight(state State) uint {
|
||||
}
|
||||
panic(err)
|
||||
}
|
||||
if _, seen := seenKeys[keyID]; seen {
|
||||
if seenKeys.Contains(keyID) {
|
||||
continue
|
||||
}
|
||||
|
||||
weight += key.Votes
|
||||
seenKeys[keyID] = struct{}{}
|
||||
seenKeys.Add(keyID)
|
||||
}
|
||||
|
||||
return weight
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/fxamacker/cbor/v2"
|
||||
"tailscale.com/types/key"
|
||||
"tailscale.com/types/tkatype"
|
||||
"tailscale.com/util/set"
|
||||
)
|
||||
|
||||
// Strict settings for the CBOR decoder.
|
||||
@@ -260,13 +261,13 @@ func computeStateAt(storage Chonk, maxIter int, wantHash AUMHash) (State, error)
|
||||
var (
|
||||
curs = topAUM
|
||||
state State
|
||||
path = make(map[AUMHash]struct{}, 32) // 32 chosen arbitrarily.
|
||||
path = make(set.Set[AUMHash], 32) // 32 chosen arbitrarily.
|
||||
)
|
||||
for i := 0; true; i++ {
|
||||
if i > maxIter {
|
||||
return State{}, fmt.Errorf("iteration limit exceeded (%d)", maxIter)
|
||||
}
|
||||
path[curs.Hash()] = struct{}{}
|
||||
path.Add(curs.Hash())
|
||||
|
||||
// Checkpoints encapsulate the state at that point, dope.
|
||||
if curs.MessageKind == AUMCheckpoint {
|
||||
@@ -307,7 +308,7 @@ func computeStateAt(storage Chonk, maxIter int, wantHash AUMHash) (State, error)
|
||||
// such, we use a custom advancer here.
|
||||
advancer := func(state State, candidates []AUM) (next *AUM, out State, err error) {
|
||||
for _, c := range candidates {
|
||||
if _, inPath := path[c.Hash()]; inPath {
|
||||
if path.Contains(c.Hash()) {
|
||||
if state, err = state.applyVerifiedAUM(c); err != nil {
|
||||
return nil, State{}, fmt.Errorf("advancing state: %v", err)
|
||||
}
|
||||
|
||||
18
tsd/tsd.go
18
tsd/tsd.go
@@ -21,11 +21,13 @@ import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
|
||||
"tailscale.com/control/controlknobs"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/net/dns"
|
||||
"tailscale.com/net/netmon"
|
||||
"tailscale.com/net/tsdial"
|
||||
"tailscale.com/net/tstun"
|
||||
"tailscale.com/types/netmap"
|
||||
"tailscale.com/wgengine"
|
||||
"tailscale.com/wgengine/magicsock"
|
||||
"tailscale.com/wgengine/router"
|
||||
@@ -42,6 +44,15 @@ type System struct {
|
||||
Router SubSystem[router.Router]
|
||||
Tun SubSystem[*tstun.Wrapper]
|
||||
StateStore SubSystem[ipn.StateStore]
|
||||
Netstack SubSystem[NetstackImpl] // actually a *netstack.Impl
|
||||
controlKnobs controlknobs.Knobs
|
||||
}
|
||||
|
||||
// NetstackImpl is the interface that *netstack.Impl implements.
|
||||
// It's an interface for circular dependency reasons: netstack.Impl
|
||||
// references LocalBackend, and LocalBackend has a tsd.System.
|
||||
type NetstackImpl interface {
|
||||
UpdateNetstackIPs(*netmap.NetworkMap)
|
||||
}
|
||||
|
||||
// Set is a convenience method to set a subsystem value.
|
||||
@@ -65,6 +76,8 @@ func (s *System) Set(v any) {
|
||||
s.MagicSock.Set(v)
|
||||
case ipn.StateStore:
|
||||
s.StateStore.Set(v)
|
||||
case NetstackImpl:
|
||||
s.Netstack.Set(v)
|
||||
default:
|
||||
panic(fmt.Sprintf("unknown type %T", v))
|
||||
}
|
||||
@@ -85,6 +98,11 @@ func (s *System) IsNetstack() bool {
|
||||
return name == tstun.FakeTUNName
|
||||
}
|
||||
|
||||
// ControlKnobs returns the control knobs for this node.
|
||||
func (s *System) ControlKnobs() *controlknobs.Knobs {
|
||||
return &s.controlKnobs
|
||||
}
|
||||
|
||||
// SubSystem represents some subsystem of the Tailscale node daemon.
|
||||
//
|
||||
// A subsystem can be set to a value, and then later retrieved. A subsystem
|
||||
|
||||
@@ -501,6 +501,7 @@ func (s *Server) start() (reterr error) {
|
||||
NetMon: s.netMon,
|
||||
Dialer: s.dialer,
|
||||
SetSubsystem: sys.Set,
|
||||
ControlKnobs: sys.ControlKnobs(),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -512,6 +513,7 @@ func (s *Server) start() (reterr error) {
|
||||
if err != nil {
|
||||
return fmt.Errorf("netstack.Create: %w", err)
|
||||
}
|
||||
sys.Set(ns)
|
||||
ns.ProcessLocalIPs = true
|
||||
ns.GetTCPHandlerForFlow = s.getTCPHandlerForFlow
|
||||
ns.GetUDPHandlerForFlow = s.getUDPHandlerForFlow
|
||||
@@ -550,9 +552,6 @@ func (s *Server) start() (reterr error) {
|
||||
return fmt.Errorf("failed to start netstack: %w", err)
|
||||
}
|
||||
closePool.addFunc(func() { s.lb.Shutdown() })
|
||||
lb.SetDecompressor(func() (controlclient.Decompressor, error) {
|
||||
return smallzstd.NewDecoder(nil)
|
||||
})
|
||||
prefs := ipn.NewPrefs()
|
||||
prefs.Hostname = s.hostname
|
||||
prefs.WantRunning = true
|
||||
|
||||
@@ -41,6 +41,7 @@ import (
|
||||
"tailscale.com/tstest/integration/testcontrol"
|
||||
"tailscale.com/types/key"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/util/rands"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -119,6 +120,36 @@ func TestOneNodeExpiredKey(t *testing.T) {
|
||||
d1.MustCleanShutdown(t)
|
||||
}
|
||||
|
||||
func TestControlKnobs(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newTestEnv(t)
|
||||
n1 := newTestNode(t, env)
|
||||
|
||||
d1 := n1.StartDaemon()
|
||||
defer d1.MustCleanShutdown(t)
|
||||
n1.AwaitResponding()
|
||||
n1.MustUp()
|
||||
|
||||
t.Logf("Got IP: %v", n1.AwaitIP())
|
||||
n1.AwaitRunning()
|
||||
|
||||
cmd := n1.Tailscale("debug", "control-knobs")
|
||||
cmd.Stdout = nil // in case --verbose-tailscale was set
|
||||
cmd.Stderr = nil // in case --verbose-tailscale was set
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Logf("control-knobs output:\n%s", out)
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(out, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := m["DisableUPnP"], true; got != want {
|
||||
t.Errorf("control-knobs DisableUPnP = %v; want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectPanic(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newTestEnv(t)
|
||||
@@ -298,6 +329,92 @@ func TestTwoNodes(t *testing.T) {
|
||||
d2.MustCleanShutdown(t)
|
||||
}
|
||||
|
||||
// tests two nodes where the first gets a incremental MapResponse (with only
|
||||
// PeersRemoved set) saying that the second node disappeared.
|
||||
func TestIncrementalMapUpdatePeersRemoved(t *testing.T) {
|
||||
flakytest.Mark(t, "https://github.com/tailscale/tailscale/issues/3598")
|
||||
t.Parallel()
|
||||
env := newTestEnv(t)
|
||||
|
||||
// Create one node:
|
||||
n1 := newTestNode(t, env)
|
||||
d1 := n1.StartDaemon()
|
||||
n1.AwaitListening()
|
||||
n1.MustUp()
|
||||
n1.AwaitRunning()
|
||||
|
||||
all := env.Control.AllNodes()
|
||||
if len(all) != 1 {
|
||||
t.Fatalf("expected 1 node, got %d nodes", len(all))
|
||||
}
|
||||
tnode1 := all[0]
|
||||
|
||||
n2 := newTestNode(t, env)
|
||||
d2 := n2.StartDaemon()
|
||||
n2.AwaitListening()
|
||||
n2.MustUp()
|
||||
n2.AwaitRunning()
|
||||
|
||||
all = env.Control.AllNodes()
|
||||
if len(all) != 2 {
|
||||
t.Fatalf("expected 2 node, got %d nodes", len(all))
|
||||
}
|
||||
var tnode2 *tailcfg.Node
|
||||
for _, n := range all {
|
||||
if n.ID != tnode1.ID {
|
||||
tnode2 = n
|
||||
break
|
||||
}
|
||||
}
|
||||
if tnode2 == nil {
|
||||
t.Fatalf("failed to find second node ID (two dups?)")
|
||||
}
|
||||
|
||||
t.Logf("node1=%v, node2=%v", tnode1.ID, tnode2.ID)
|
||||
|
||||
if err := tstest.WaitFor(2*time.Second, func() error {
|
||||
st := n1.MustStatus()
|
||||
if len(st.Peer) == 0 {
|
||||
return errors.New("no peers")
|
||||
}
|
||||
if len(st.Peer) > 1 {
|
||||
return fmt.Errorf("got %d peers; want 1", len(st.Peer))
|
||||
}
|
||||
peer := st.Peer[st.Peers()[0]]
|
||||
if peer.ID == st.Self.ID {
|
||||
return errors.New("peer is self")
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Logf("node1 saw node2")
|
||||
|
||||
// Now tell node1 that node2 is removed.
|
||||
if !env.Control.AddRawMapResponse(tnode1.Key, &tailcfg.MapResponse{
|
||||
PeersRemoved: []tailcfg.NodeID{tnode2.ID},
|
||||
}) {
|
||||
t.Fatalf("failed to add map response")
|
||||
}
|
||||
|
||||
// And see that node1 saw that.
|
||||
if err := tstest.WaitFor(2*time.Second, func() error {
|
||||
st := n1.MustStatus()
|
||||
if len(st.Peer) == 0 {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("got %d peers; want 0", len(st.Peer))
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Logf("node1 saw node2 disappear")
|
||||
|
||||
d1.MustCleanShutdown(t)
|
||||
d2.MustCleanShutdown(t)
|
||||
}
|
||||
|
||||
func TestNodeAddressIPFields(t *testing.T) {
|
||||
flakytest.Mark(t, "https://github.com/tailscale/tailscale/issues/7008")
|
||||
t.Parallel()
|
||||
@@ -760,7 +877,9 @@ func newTestNode(t *testing.T, env *testEnv) *testNode {
|
||||
dir := t.TempDir()
|
||||
sockFile := filepath.Join(dir, "tailscale.sock")
|
||||
if len(sockFile) >= 104 {
|
||||
t.Fatalf("sockFile path %q (len %v) is too long, must be < 104", sockFile, len(sockFile))
|
||||
// Maximum length for a unix socket on darwin. Try something else.
|
||||
sockFile = filepath.Join(os.TempDir(), rands.HexString(8)+".sock")
|
||||
t.Cleanup(func() { os.Remove(sockFile) })
|
||||
}
|
||||
return &testNode{
|
||||
env: env,
|
||||
|
||||
@@ -33,7 +33,6 @@ import (
|
||||
_ "tailscale.com/net/tstun"
|
||||
_ "tailscale.com/paths"
|
||||
_ "tailscale.com/safesocket"
|
||||
_ "tailscale.com/smallzstd"
|
||||
_ "tailscale.com/ssh/tailssh"
|
||||
_ "tailscale.com/syncs"
|
||||
_ "tailscale.com/tailcfg"
|
||||
|
||||
@@ -33,7 +33,6 @@ import (
|
||||
_ "tailscale.com/net/tstun"
|
||||
_ "tailscale.com/paths"
|
||||
_ "tailscale.com/safesocket"
|
||||
_ "tailscale.com/smallzstd"
|
||||
_ "tailscale.com/ssh/tailssh"
|
||||
_ "tailscale.com/syncs"
|
||||
_ "tailscale.com/tailcfg"
|
||||
|
||||
@@ -33,7 +33,6 @@ import (
|
||||
_ "tailscale.com/net/tstun"
|
||||
_ "tailscale.com/paths"
|
||||
_ "tailscale.com/safesocket"
|
||||
_ "tailscale.com/smallzstd"
|
||||
_ "tailscale.com/ssh/tailssh"
|
||||
_ "tailscale.com/syncs"
|
||||
_ "tailscale.com/tailcfg"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user