Compare commits
62 Commits
tsweb/clie
...
bradfitz/g
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b256c319c0 | ||
|
|
57da1f1501 | ||
|
|
37c0b9be63 | ||
|
|
18280ebf7d | ||
|
|
623d72c83b | ||
|
|
f101a75dce | ||
|
|
f75a36f9bc | ||
|
|
cf31b58ed1 | ||
|
|
6c791f7d60 | ||
|
|
7ed3681cbe | ||
|
|
95d776bd8c | ||
|
|
9c4364e0b7 | ||
|
|
ddba4824c4 | ||
|
|
bd02d00608 | ||
|
|
25a8daf405 | ||
|
|
17ce75347c | ||
|
|
1a64166073 | ||
|
|
0052830c64 | ||
|
|
8e63d75018 | ||
|
|
c17a817769 | ||
|
|
411e3364a9 | ||
|
|
12238dab48 | ||
|
|
b07347640c | ||
|
|
1fcae42055 | ||
|
|
2398993804 | ||
|
|
4940a718a1 | ||
|
|
9e24a6508a | ||
|
|
c40d095c35 | ||
|
|
a1b8d703d6 | ||
|
|
cc3caa4b2a | ||
|
|
de8e55fda6 | ||
|
|
d5ac18d2c4 | ||
|
|
21e32b23f7 | ||
|
|
3f12b9c8b2 | ||
|
|
98ec8924c2 | ||
|
|
92fc9a01fa | ||
|
|
99e06d3544 | ||
|
|
16bc9350e3 | ||
|
|
215480a022 | ||
|
|
53c722924b | ||
|
|
d16946854f | ||
|
|
7a5263e6d0 | ||
|
|
3d56cafd7d | ||
|
|
6ee85ba412 | ||
|
|
2bc98abbd9 | ||
|
|
7815fbe17a | ||
|
|
90081a25ca | ||
|
|
3d2e35c053 | ||
|
|
f9066ac1f4 | ||
|
|
69f1324c9e | ||
|
|
b3618c23bf | ||
|
|
be4eb6a39e | ||
|
|
66f27c4beb | ||
|
|
682fd72f7b | ||
|
|
3e255d76e1 | ||
|
|
500b9579d5 | ||
|
|
734928d3cb | ||
|
|
6aaf1d48df | ||
|
|
ae63c51ff1 | ||
|
|
17ed2da94d | ||
|
|
82454b57dd | ||
|
|
25a7204bb4 |
2
.github/workflows/installer.yml
vendored
2
.github/workflows/installer.yml
vendored
@@ -78,7 +78,7 @@ jobs:
|
||||
|| contains(matrix.image, 'amazonlinux')
|
||||
- name: install dependencies (zypper)
|
||||
# tar and gzip are needed by the actions/checkout below.
|
||||
run: zypper --non-interactive install tar gzip
|
||||
run: zypper --non-interactive install tar gzip ${{ matrix.deps }}
|
||||
if: contains(matrix.image, 'opensuse')
|
||||
- name: install dependencies (apt-get)
|
||||
run: |
|
||||
|
||||
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@@ -144,7 +144,7 @@ jobs:
|
||||
# Don't use -bench=. -benchtime=1x.
|
||||
# Somewhere in the layers (powershell?)
|
||||
# the equals signs cause great confusion.
|
||||
run: go test -bench . -benchtime 1x ./...
|
||||
run: go run ./cmd/testwrapper ./... -bench . -benchtime 1x
|
||||
|
||||
vm:
|
||||
runs-on: ["self-hosted", "linux", "vm"]
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -35,8 +35,10 @@ cmd/tailscaled/tailscaled
|
||||
# Ignore direnv nix-shell environment cache
|
||||
.direnv/
|
||||
|
||||
# Ignore web client node modules
|
||||
.vite/
|
||||
webui/node_modules
|
||||
client/web/node_modules
|
||||
client/web/build
|
||||
|
||||
/gocross
|
||||
/dist
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
# $ docker exec tailscaled tailscale status
|
||||
|
||||
|
||||
FROM golang:1.20-alpine AS build-env
|
||||
FROM golang:1.21-alpine AS build-env
|
||||
|
||||
WORKDIR /go/src/tailscale
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ not open source.
|
||||
|
||||
## Building
|
||||
|
||||
We always require the latest Go release, currently Go 1.20. (While we build
|
||||
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.)
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
1.47.0
|
||||
1.49.0
|
||||
|
||||
@@ -10,6 +10,7 @@ type DNSConfig struct {
|
||||
Domains []string `json:"domains"`
|
||||
Nameservers []string `json:"nameservers"`
|
||||
Proxied bool `json:"proxied"`
|
||||
DNSFilterURL string `json:"DNSFilterURL"`
|
||||
}
|
||||
|
||||
type DNSResolver struct {
|
||||
|
||||
@@ -259,6 +259,28 @@ func (lc *LocalClient) DaemonMetrics(ctx context.Context) ([]byte, error) {
|
||||
return lc.get200(ctx, "/localapi/v0/metrics")
|
||||
}
|
||||
|
||||
// IncrementCounter increments the value of a Tailscale daemon's counter
|
||||
// metric by the given delta. If the metric has yet to exist, a new counter
|
||||
// metric is created and initialized to delta.
|
||||
//
|
||||
// IncrementCounter does not support gauge metrics or negative delta values.
|
||||
func (lc *LocalClient) IncrementCounter(ctx context.Context, name string, delta int) error {
|
||||
type metricUpdate struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Value int `json:"value"` // amount to increment by
|
||||
}
|
||||
if delta < 0 {
|
||||
return errors.New("negative delta not allowed")
|
||||
}
|
||||
_, err := lc.send(ctx, "POST", "/localapi/v0/upload-client-metrics", 200, jsonBody([]metricUpdate{{
|
||||
Name: name,
|
||||
Type: "counter",
|
||||
Value: delta,
|
||||
}}))
|
||||
return err
|
||||
}
|
||||
|
||||
// TailDaemonLogs returns a stream the Tailscale daemon's logs as they arrive.
|
||||
// Close the context to stop the stream.
|
||||
func (lc *LocalClient) TailDaemonLogs(ctx context.Context) (io.Reader, error) {
|
||||
@@ -807,11 +829,25 @@ func (lc *LocalClient) ExpandSNIName(ctx context.Context, name string) (fqdn str
|
||||
return "", false
|
||||
}
|
||||
|
||||
// PingOpts contains options for the ping request.
|
||||
//
|
||||
// The zero value is valid, which means to use defaults.
|
||||
type PingOpts struct {
|
||||
// Size is the length of the ping message in bytes. It's ignored if it's
|
||||
// smaller than the minimum message size.
|
||||
//
|
||||
// For disco pings, it specifies the length of the packet's payload. That
|
||||
// is, it includes the disco headers and message, but not the IP and UDP
|
||||
// headers.
|
||||
Size int
|
||||
}
|
||||
|
||||
// Ping sends a ping of the provided type to the provided IP and waits
|
||||
// for its response.
|
||||
func (lc *LocalClient) Ping(ctx context.Context, ip netip.Addr, pingtype tailcfg.PingType) (*ipnstate.PingResult, error) {
|
||||
// for its response. The opts type specifies additional options.
|
||||
func (lc *LocalClient) PingWithOpts(ctx context.Context, ip netip.Addr, pingtype tailcfg.PingType, opts PingOpts) (*ipnstate.PingResult, error) {
|
||||
v := url.Values{}
|
||||
v.Set("ip", ip.String())
|
||||
v.Set("size", strconv.Itoa(opts.Size))
|
||||
v.Set("type", string(pingtype))
|
||||
body, err := lc.send(ctx, "POST", "/localapi/v0/ping?"+v.Encode(), 200, nil)
|
||||
if err != nil {
|
||||
@@ -820,6 +856,12 @@ func (lc *LocalClient) Ping(ctx context.Context, ip netip.Addr, pingtype tailcfg
|
||||
return decodeJSON[*ipnstate.PingResult](body)
|
||||
}
|
||||
|
||||
// Ping sends a ping of the provided type to the provided IP and waits
|
||||
// for its response.
|
||||
func (lc *LocalClient) Ping(ctx context.Context, ip netip.Addr, pingtype tailcfg.PingType) (*ipnstate.PingResult, error) {
|
||||
return lc.PingWithOpts(ctx, ip, pingtype, PingOpts{})
|
||||
}
|
||||
|
||||
// NetworkLockStatus fetches information about the tailnet key authority, if one is configured.
|
||||
func (lc *LocalClient) NetworkLockStatus(ctx context.Context) (*ipnstate.NetworkLockStatus, error) {
|
||||
body, err := lc.send(ctx, "GET", "/localapi/v0/tka/status", 200, nil)
|
||||
@@ -1124,18 +1166,18 @@ func (lc *LocalClient) DeleteProfile(ctx context.Context, profile ipn.ProfileID)
|
||||
return err
|
||||
}
|
||||
|
||||
// QueryFeature makes a request for instructions on how to enable a
|
||||
// feature, such as Funnel, for the node's tailnet.
|
||||
// QueryFeature makes a request for instructions on how to enable
|
||||
// a feature, such as Funnel, for the node's tailnet. If relevant,
|
||||
// this includes a control server URL the user can visit to enable
|
||||
// the feature.
|
||||
//
|
||||
// This request itself does not directly enable the feature on behalf
|
||||
// of the node, but rather returns information that can be presented
|
||||
// to the acting user about where/how to enable the feature.
|
||||
// If you are looking to use QueryFeature, you'll likely want to
|
||||
// use cli.enableFeatureInteractive instead, which handles the logic
|
||||
// of wraping QueryFeature and translating its response into an
|
||||
// interactive flow for the user, including using the IPN notify bus
|
||||
// to block until the feature has been enabled.
|
||||
//
|
||||
// If relevant, this includes a control URL the user can visit to
|
||||
// explicitly consent to using the feature. LocalClient.WatchIPNBus
|
||||
// can be used to block on the feature being enabled.
|
||||
//
|
||||
// 2023-08-02: Valid feature values are "serve" and "funnel".
|
||||
// 2023-08-09: Valid feature values are "serve" and "funnel".
|
||||
func (lc *LocalClient) QueryFeature(ctx context.Context, feature string) (*tailcfg.QueryFeatureResponse, error) {
|
||||
v := url.Values{"feature": {feature}}
|
||||
body, err := lc.send(ctx, "POST", "/localapi/v0/query-feature?"+v.Encode(), 200, nil)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !go1.20
|
||||
//go:build !go1.21
|
||||
|
||||
package tailscale
|
||||
|
||||
func init() {
|
||||
you_need_Go_1_20_to_compile_Tailscale()
|
||||
you_need_Go_1_21_to_compile_Tailscale()
|
||||
}
|
||||
|
||||
75
client/web/dev.go
Normal file
75
client/web/dev.go
Normal file
@@ -0,0 +1,75 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 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()) {
|
||||
root := gitRootDir()
|
||||
webClientPath := filepath.Join(root, "client", "web")
|
||||
|
||||
yarn := filepath.Join(root, "tool", "yarn")
|
||||
node := filepath.Join(root, "tool", "node")
|
||||
vite := filepath.Join(webClientPath, "node_modules", ".bin", "vite")
|
||||
|
||||
log.Printf("installing JavaScript deps using %s... (might take ~30s)", yarn)
|
||||
out, err := exec.Command(yarn, "--non-interactive", "-s", "--cwd", webClientPath, "install").CombinedOutput()
|
||||
if err != nil {
|
||||
log.Fatalf("error running tailscale web's yarn install: %v, %s", err, out)
|
||||
}
|
||||
log.Printf("starting JavaScript dev server...")
|
||||
cmd := exec.Command(node, vite)
|
||||
cmd.Dir = webClientPath
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Start(); err != nil {
|
||||
log.Fatalf("Starting JS dev server: %v", err)
|
||||
}
|
||||
log.Printf("JavaScript dev server running as pid %d", cmd.Process.Pid)
|
||||
return func() {
|
||||
cmd.Process.Signal(os.Interrupt)
|
||||
err := cmd.Wait()
|
||||
log.Printf("JavaScript dev server exited: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) addProxyToDevServer() {
|
||||
if !s.devMode {
|
||||
return // only using Vite proxy in dev mode
|
||||
}
|
||||
// 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.
|
||||
// Here we set up the proxy to Vite's server.
|
||||
handleErr := func(w http.ResponseWriter, r *http.Request, err error) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
w.Write([]byte("The web client development server isn't running. " +
|
||||
"Run `./tool/yarn --cwd client/web start` from " +
|
||||
"the repo root to start the development server."))
|
||||
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
|
||||
}
|
||||
|
||||
func gitRootDir() string {
|
||||
top, err := exec.Command("git", "rev-parse", "--show-toplevel").Output()
|
||||
if err != nil {
|
||||
log.Fatalf("failed to find git top level (not in corp git?): %v", err)
|
||||
}
|
||||
return strings.TrimSpace(string(top))
|
||||
}
|
||||
29
client/web/index.html
Normal file
29
client/web/index.html
Normal file
@@ -0,0 +1,29 @@
|
||||
<!doctype html>
|
||||
<html class="bg-gray-50">
|
||||
<head>
|
||||
<title>Tailscale</title>
|
||||
<meta charset="utf-8" />
|
||||
<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" />
|
||||
<link rel="stylesheet" type="text/css" href="/src/index.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="min-h-screen py-10 flex justify-center items-center" style="display: none">
|
||||
<div class="max-w-md">
|
||||
<h3 class="font-semibold text-lg mb-4">Your web browser is unsupported.</h3>
|
||||
<p class="mb-2">
|
||||
Update to a modern browser to access the Tailscale web client. You can use
|
||||
<a class="link" href="https://www.mozilla.org/en-US/firefox/new/" target="_blank">Firefox</a>,
|
||||
<a class="link" href="https://www.microsoft.com/en-us/edge" target="_blank">Edge</a>,
|
||||
<a class="link" href="https://www.apple.com/safari/" target="_blank">Safari</a>,
|
||||
or <a class="link" href="https://www.google.com/chrome/" target="_blank">Chrome</a>.</p>
|
||||
<p>If you need any help, feel free to <a href="mailto:support+webclient@tailscale.com" class="link">contact us</a></p>
|
||||
</div>
|
||||
</div>
|
||||
<noscript>
|
||||
<p class="mb-2">You need to enable Javascript to access the Tailscale web client.</p>
|
||||
<p>If you need any help, feel free to <a href="mailto:support+webclient@tailscale.com" class="link">contact us</a>.</p>
|
||||
</noscript>
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
42
client/web/package.json
Normal file
42
client/web/package.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "webclient",
|
||||
"version": "0.0.1",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": "18.16.1",
|
||||
"yarn": "1.22.19"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.0.20",
|
||||
"@types/react-dom": "^18.0.6",
|
||||
"@vitejs/plugin-react-swc": "^3.3.2",
|
||||
"autoprefixer": "^10.4.15",
|
||||
"postcss": "^8.4.27",
|
||||
"prettier": "^2.5.1",
|
||||
"prettier-plugin-organize-imports": "^3.2.2",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"typescript": "^4.7.4",
|
||||
"vite": "^4.3.9",
|
||||
"vite-plugin-rewrite-all": "^1.0.1",
|
||||
"vite-plugin-svgr": "^3.2.0",
|
||||
"vite-tsconfig-paths": "^3.5.0",
|
||||
"vitest": "^0.32.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"start": "vite",
|
||||
"lint": "tsc --noEmit",
|
||||
"test": "vitest",
|
||||
"format": "prettier --write 'src/**/*.{ts,tsx}'",
|
||||
"format-check": "prettier --check 'src/**/*.{ts,tsx}'"
|
||||
},
|
||||
"prettier": {
|
||||
"semi": false,
|
||||
"printWidth": 80
|
||||
}
|
||||
}
|
||||
6
client/web/postcss.config.js
Normal file
6
client/web/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
25
client/web/src/components/app.tsx
Normal file
25
client/web/src/components/app.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React from "react"
|
||||
import { Footer, Header, IP, State } from "src/components/legacy"
|
||||
import useNodeData from "src/hooks/node-data"
|
||||
|
||||
export default function App() {
|
||||
const data = useNodeData()
|
||||
|
||||
return (
|
||||
<div className="py-14">
|
||||
{!data ? (
|
||||
// TODO(sonia): add a loading view
|
||||
<div className="text-center">Loading...</div>
|
||||
) : (
|
||||
<>
|
||||
<main className="container max-w-lg mx-auto mb-8 py-6 px-8 bg-white rounded-md shadow-2xl">
|
||||
<Header data={data} />
|
||||
<IP data={data} />
|
||||
<State data={data} />
|
||||
</main>
|
||||
<Footer data={data} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
272
client/web/src/components/legacy.tsx
Normal file
272
client/web/src/components/legacy.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
import React from "react"
|
||||
import { NodeData } from "src/hooks/node-data"
|
||||
|
||||
// TODO(tailscale/corp#13775): legacy.tsx contains a set of components
|
||||
// that (crudely) implement the pre-2023 web client. These are implemented
|
||||
// purely to ease migration to the new React-based web client, and will
|
||||
// eventually be completely removed.
|
||||
|
||||
export function Header(props: { data: NodeData }) {
|
||||
const { data } = props
|
||||
|
||||
return (
|
||||
<header className="flex justify-between items-center min-width-0 py-2 mb-8">
|
||||
<svg
|
||||
width="26"
|
||||
height="26"
|
||||
viewBox="0 0 23 23"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="flex-shrink-0 mr-4"
|
||||
>
|
||||
<circle
|
||||
opacity="0.2"
|
||||
cx="3.4"
|
||||
cy="3.25"
|
||||
r="2.7"
|
||||
fill="currentColor"
|
||||
></circle>
|
||||
<circle cx="3.4" cy="11.3" r="2.7" fill="currentColor"></circle>
|
||||
<circle
|
||||
opacity="0.2"
|
||||
cx="3.4"
|
||||
cy="19.5"
|
||||
r="2.7"
|
||||
fill="currentColor"
|
||||
></circle>
|
||||
<circle cx="11.5" cy="11.3" r="2.7" fill="currentColor"></circle>
|
||||
<circle cx="11.5" cy="19.5" r="2.7" fill="currentColor"></circle>
|
||||
<circle
|
||||
opacity="0.2"
|
||||
cx="11.5"
|
||||
cy="3.25"
|
||||
r="2.7"
|
||||
fill="currentColor"
|
||||
></circle>
|
||||
<circle
|
||||
opacity="0.2"
|
||||
cx="19.5"
|
||||
cy="3.25"
|
||||
r="2.7"
|
||||
fill="currentColor"
|
||||
></circle>
|
||||
<circle cx="19.5" cy="11.3" r="2.7" fill="currentColor"></circle>
|
||||
<circle
|
||||
opacity="0.2"
|
||||
cx="19.5"
|
||||
cy="19.5"
|
||||
r="2.7"
|
||||
fill="currentColor"
|
||||
></circle>
|
||||
</svg>
|
||||
<div className="flex items-center justify-end space-x-2 w-2/3">
|
||||
{data.Profile && (
|
||||
<>
|
||||
<div className="text-right w-full leading-4">
|
||||
<h4 className="truncate leading-normal">
|
||||
{data.Profile.LoginName}
|
||||
</h4>
|
||||
<div className="text-xs text-gray-500 text-right">
|
||||
<a href="#" className="hover:text-gray-700 js-loginButton">
|
||||
Switch account
|
||||
</a>{" "}
|
||||
|{" "}
|
||||
<a href="#" className="hover:text-gray-700 js-loginButton">
|
||||
Reauthenticate
|
||||
</a>{" "}
|
||||
|{" "}
|
||||
<a href="#" className="hover:text-gray-700 js-logoutButton">
|
||||
Logout
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex-shrink-0 w-8 h-8 rounded-full overflow-hidden">
|
||||
{data.Profile.ProfilePicURL ? (
|
||||
<div
|
||||
className="w-8 h-8 flex pointer-events-none rounded-full bg-gray-200"
|
||||
style={{
|
||||
backgroundImage: `url(${data.Profile.ProfilePicURL})`,
|
||||
backgroundSize: "cover",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-8 h-8 flex pointer-events-none rounded-full border border-gray-400 border-dashed" />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
export function IP(props: { data: NodeData }) {
|
||||
const { data } = props
|
||||
|
||||
if (!data.IP) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="border border-gray-200 bg-gray-50 rounded-md p-2 pl-3 pr-3 width-full flex items-center justify-between">
|
||||
<div className="flex items-center min-width-0">
|
||||
<svg
|
||||
className="flex-shrink-0 text-gray-600 mr-3 ml-1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect>
|
||||
<rect x="2" y="14" width="20" height="8" rx="2" ry="2"></rect>
|
||||
<line x1="6" y1="6" x2="6.01" y2="6"></line>
|
||||
<line x1="6" y1="18" x2="6.01" y2="18"></line>
|
||||
</svg>
|
||||
<div>
|
||||
<h4 className="font-semibold truncate mr-2">{data.DeviceName}</h4>
|
||||
</div>
|
||||
</div>
|
||||
<h5>{data.IP}</h5>
|
||||
</div>
|
||||
<p className="mt-1 ml-1 mb-6 text-xs text-gray-600">
|
||||
Debug info: Tailscale {data.IPNVersion}, tun={data.TUNMode.toString()}
|
||||
{data.IsSynology && (
|
||||
<>
|
||||
, DSM{data.DSMVersion}
|
||||
{data.TUNMode || (
|
||||
<>
|
||||
{" "}
|
||||
(
|
||||
<a
|
||||
href="https://tailscale.com/kb/1152/synology-outbound/"
|
||||
className="link-underline text-gray-600"
|
||||
target="_blank"
|
||||
aria-label="Configure outbound synology traffic"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
outgoing access not configured
|
||||
</a>
|
||||
)
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function State(props: { data: NodeData }) {
|
||||
const { data } = props
|
||||
|
||||
switch (data.Status) {
|
||||
case "NeedsLogin":
|
||||
case "NoState":
|
||||
if (data.IP) {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<p className="text-gray-700">
|
||||
Your device's key has expired. Reauthenticate this device by
|
||||
logging in again, or{" "}
|
||||
<a
|
||||
href="https://tailscale.com/kb/1028/key-expiry"
|
||||
className="link"
|
||||
target="_blank"
|
||||
>
|
||||
learn more
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
<a href="#" className="mb-4 js-loginButton" target="_blank">
|
||||
<button className="button button-blue w-full">
|
||||
Reauthenticate
|
||||
</button>
|
||||
</a>
|
||||
</>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<h3 className="text-3xl font-semibold mb-3">Log in</h3>
|
||||
<p className="text-gray-700">
|
||||
Get started by logging in to your Tailscale network.
|
||||
Or, learn more at{" "}
|
||||
<a
|
||||
href="https://tailscale.com/"
|
||||
className="link"
|
||||
target="_blank"
|
||||
>
|
||||
tailscale.com
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
<a href="#" className="mb-4 js-loginButton" target="_blank">
|
||||
<button className="button button-blue w-full">Log In</button>
|
||||
</a>
|
||||
</>
|
||||
)
|
||||
}
|
||||
case "NeedsMachineAuth":
|
||||
return (
|
||||
<div className="mb-4">
|
||||
This device is authorized, but needs approval from a network admin
|
||||
before it can connect to the network.
|
||||
</div>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<p>
|
||||
You are connected! Access this device over Tailscale using the
|
||||
device name or IP address above.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<a href="#" className="mb-4 js-advertiseExitNode">
|
||||
{data.AdvertiseExitNode ? (
|
||||
<button
|
||||
className="button button-red button-medium"
|
||||
id="enabled"
|
||||
>
|
||||
Stop advertising Exit Node
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="button button-blue button-medium"
|
||||
id="enabled"
|
||||
>
|
||||
Advertise as Exit Node
|
||||
</button>
|
||||
)}
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function Footer(props: { data: NodeData }) {
|
||||
const { data } = props
|
||||
|
||||
return (
|
||||
<footer className="container max-w-lg mx-auto text-center">
|
||||
<a
|
||||
className="text-xs text-gray-500 hover:text-gray-600"
|
||||
href={data.LicensesURL}
|
||||
>
|
||||
Open Source Licenses
|
||||
</a>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
37
client/web/src/hooks/node-data.ts
Normal file
37
client/web/src/hooks/node-data.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
export type NodeData = {
|
||||
Profile: UserProfile
|
||||
Status: string
|
||||
DeviceName: string
|
||||
IP: string
|
||||
AdvertiseExitNode: boolean
|
||||
AdvertiseRoutes: string
|
||||
LicensesURL: string
|
||||
TUNMode: boolean
|
||||
IsSynology: boolean
|
||||
DSMVersion: number
|
||||
IsUnraid: boolean
|
||||
UnraidToken: string
|
||||
IPNVersion: string
|
||||
}
|
||||
|
||||
export type UserProfile = {
|
||||
LoginName: string
|
||||
DisplayName: string
|
||||
ProfilePicURL: string
|
||||
}
|
||||
|
||||
// useNodeData returns basic data about the current node.
|
||||
export default function useNodeData() {
|
||||
const [data, setData] = useState<NodeData>()
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/data")
|
||||
.then((response) => response.json())
|
||||
.then((json) => setData(json))
|
||||
.catch((error) => console.error(error))
|
||||
}, [])
|
||||
|
||||
return data
|
||||
}
|
||||
130
client/web/src/index.css
Normal file
130
client/web/src/index.css
Normal file
@@ -0,0 +1,130 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/**
|
||||
* Non-Tailwind styles begin here.
|
||||
*/
|
||||
|
||||
.bg-gray-0 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgba(250, 249, 248, var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-gray-50 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgba(249, 247, 246, var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
html {
|
||||
letter-spacing: -0.015em;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.link {
|
||||
--text-opacity: 1;
|
||||
color: #4b70cc;
|
||||
color: rgba(75, 112, 204, var(--text-opacity));
|
||||
}
|
||||
|
||||
.link:hover,
|
||||
.link:active {
|
||||
--text-opacity: 1;
|
||||
color: #19224a;
|
||||
color: rgba(25, 34, 74, var(--text-opacity));
|
||||
}
|
||||
|
||||
.link-underline {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.link-underline:hover,
|
||||
.link-underline:active {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.link-muted {
|
||||
/* same as text-gray-500 */
|
||||
--tw-text-opacity: 1;
|
||||
color: rgba(112, 110, 109, var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.link-muted:hover,
|
||||
.link-muted:active {
|
||||
/* same as text-gray-500 */
|
||||
--tw-text-opacity: 1;
|
||||
color: rgba(68, 67, 66, var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.button {
|
||||
font-weight: 500;
|
||||
padding-top: 0.45rem;
|
||||
padding-bottom: 0.45rem;
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
border-radius: 0.375rem;
|
||||
border-width: 1px;
|
||||
border-color: transparent;
|
||||
transition-property: background-color, border-color, color, box-shadow;
|
||||
transition-duration: 120ms;
|
||||
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.04);
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.button:focus {
|
||||
outline: 0;
|
||||
box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.5);
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
cursor: not-allowed;
|
||||
-webkit-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.button-blue {
|
||||
--bg-opacity: 1;
|
||||
background-color: #4b70cc;
|
||||
background-color: rgba(75, 112, 204, var(--bg-opacity));
|
||||
--border-opacity: 1;
|
||||
border-color: #4b70cc;
|
||||
border-color: rgba(75, 112, 204, var(--border-opacity));
|
||||
--text-opacity: 1;
|
||||
color: #fff;
|
||||
color: rgba(255, 255, 255, var(--text-opacity));
|
||||
}
|
||||
|
||||
.button-blue:enabled:hover {
|
||||
--bg-opacity: 1;
|
||||
background-color: #3f5db3;
|
||||
background-color: rgba(63, 93, 179, var(--bg-opacity));
|
||||
--border-opacity: 1;
|
||||
border-color: #3f5db3;
|
||||
border-color: rgba(63, 93, 179, var(--border-opacity));
|
||||
}
|
||||
|
||||
.button-blue:disabled {
|
||||
--text-opacity: 1;
|
||||
color: #cedefd;
|
||||
color: rgba(206, 222, 253, var(--text-opacity));
|
||||
--bg-opacity: 1;
|
||||
background-color: #6c94ec;
|
||||
background-color: rgba(108, 148, 236, var(--bg-opacity));
|
||||
--border-opacity: 1;
|
||||
border-color: #6c94ec;
|
||||
border-color: rgba(108, 148, 236, var(--border-opacity));
|
||||
}
|
||||
|
||||
.button-red {
|
||||
background-color: #d04841;
|
||||
border-color: #d04841;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.button-red:enabled:hover {
|
||||
background-color: #b22d30;
|
||||
border-color: #b22d30;
|
||||
}
|
||||
16
client/web/src/index.tsx
Normal file
16
client/web/src/index.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import React from "react"
|
||||
import { createRoot } from "react-dom/client"
|
||||
import App from "src/components/app"
|
||||
|
||||
const rootEl = document.createElement("div")
|
||||
rootEl.id = "app-root"
|
||||
rootEl.classList.add("relative", "z-0")
|
||||
document.body.append(rootEl)
|
||||
|
||||
const root = createRoot(rootEl)
|
||||
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
12
client/web/tailwind.config.js
Normal file
12
client/web/tailwind.config.js
Normal file
@@ -0,0 +1,12 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
||||
16
client/web/tsconfig.json
Normal file
16
client/web/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"target": "ES2017",
|
||||
"module": "ES2020",
|
||||
"strict": true,
|
||||
"sourceMap": true,
|
||||
"isolatedModules": true,
|
||||
"moduleResolution": "node",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"jsx": "react",
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import paths from "vite-tsconfig-paths"
|
||||
|
||||
// Use a custom logger that filters out Vite's logging of server URLs, since
|
||||
// they are an attractive nuisance (we run a proxy in front of Vite, and the
|
||||
// admin panel should be accessed through that).
|
||||
// tailscale web client should be accessed through that).
|
||||
// Unfortunately there's no option to disable this logging, so the best we can
|
||||
// do it to ignore calls from a specific function.
|
||||
const filteringLogger = createLogger(undefined, { allowClearScreen: false })
|
||||
@@ -24,8 +24,8 @@ export default defineConfig({
|
||||
plugins: [
|
||||
paths(),
|
||||
svgr(),
|
||||
// By default, the Vite dev server doesn't handle dots in path names and
|
||||
// treats them as static files, which breaks URLs like /admin/machines/100.101.102.103.
|
||||
// By default, the Vite dev server doesn't handle dots
|
||||
// in path names and treats them as static files.
|
||||
// This plugin changes Vite's routing logic to fix this.
|
||||
// See: https://github.com/vitejs/vite/issues/2415
|
||||
rewrite(),
|
||||
529
client/web/web.go
Normal file
529
client/web/web.go
Normal file
@@ -0,0 +1,529 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package web provides the Tailscale client for web.
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/envknob"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/licenses"
|
||||
"tailscale.com/net/netutil"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/util/groupmember"
|
||||
"tailscale.com/util/httpm"
|
||||
"tailscale.com/version/distro"
|
||||
)
|
||||
|
||||
//go:embed web.html
|
||||
var webHTML string
|
||||
|
||||
//go:embed web.css
|
||||
var webCSS string
|
||||
|
||||
//go:embed auth-redirect.html
|
||||
var authenticationRedirectHTML string
|
||||
|
||||
var tmpl *template.Template
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// NewServer constructs a new Tailscale web client server.
|
||||
//
|
||||
// lc is an optional parameter. When not filled, NewServer
|
||||
// initializes its own tailscale.LocalClient.
|
||||
func NewServer(devMode bool, lc *tailscale.LocalClient) (s *Server, cleanup func()) {
|
||||
if lc == nil {
|
||||
lc = &tailscale.LocalClient{}
|
||||
}
|
||||
s = &Server{
|
||||
devMode: devMode,
|
||||
lc: lc,
|
||||
}
|
||||
cleanup = func() {}
|
||||
if s.devMode {
|
||||
cleanup = s.startDevServer()
|
||||
s.addProxyToDevServer()
|
||||
}
|
||||
return s, cleanup
|
||||
}
|
||||
|
||||
func init() {
|
||||
tmpl = template.Must(template.New("web.html").Parse(webHTML))
|
||||
template.Must(tmpl.New("web.css").Parse(webCSS))
|
||||
}
|
||||
|
||||
// authorize returns the name of the user accessing the web UI after verifying
|
||||
// whether the user has access to the web UI. The function will write the
|
||||
// error to the provided http.ResponseWriter.
|
||||
// Note: This is different from a tailscale user, and is typically the local
|
||||
// user on the node.
|
||||
func authorize(w http.ResponseWriter, r *http.Request) (string, error) {
|
||||
switch distro.Get() {
|
||||
case distro.Synology:
|
||||
user, err := synoAuthn()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return "", err
|
||||
}
|
||||
if err := authorizeSynology(user); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusForbidden)
|
||||
return "", err
|
||||
}
|
||||
return user, nil
|
||||
case distro.QNAP:
|
||||
user, resp, err := qnapAuthn(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return "", err
|
||||
}
|
||||
if resp.IsAdmin == 0 {
|
||||
http.Error(w, err.Error(), http.StatusForbidden)
|
||||
return "", err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// authorizeSynology checks whether the provided user has access to the web UI
|
||||
// by consulting the membership of the "administrators" group.
|
||||
func authorizeSynology(name string) error {
|
||||
yes, err := groupmember.IsMemberOfGroup("administrators", name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !yes {
|
||||
return fmt.Errorf("not a member of administrators group")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type qnapAuthResponse struct {
|
||||
AuthPassed int `xml:"authPassed"`
|
||||
IsAdmin int `xml:"isAdmin"`
|
||||
AuthSID string `xml:"authSid"`
|
||||
ErrorValue int `xml:"errorValue"`
|
||||
}
|
||||
|
||||
func qnapAuthn(r *http.Request) (string, *qnapAuthResponse, error) {
|
||||
user, err := r.Cookie("NAS_USER")
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
token, err := r.Cookie("qtoken")
|
||||
if err == nil {
|
||||
return qnapAuthnQtoken(r, user.Value, token.Value)
|
||||
}
|
||||
sid, err := r.Cookie("NAS_SID")
|
||||
if err == nil {
|
||||
return qnapAuthnSid(r, user.Value, sid.Value)
|
||||
}
|
||||
return "", nil, fmt.Errorf("not authenticated by any mechanism")
|
||||
}
|
||||
|
||||
// qnapAuthnURL returns the auth URL to use by inferring where the UI is
|
||||
// running based on the request URL. This is necessary because QNAP has so
|
||||
// many options, see https://github.com/tailscale/tailscale/issues/7108
|
||||
// and https://github.com/tailscale/tailscale/issues/6903
|
||||
func qnapAuthnURL(requestUrl string, query url.Values) string {
|
||||
in, err := url.Parse(requestUrl)
|
||||
scheme := ""
|
||||
host := ""
|
||||
if err != nil || in.Scheme == "" {
|
||||
log.Printf("Cannot parse QNAP login URL %v", err)
|
||||
|
||||
// try localhost and hope for the best
|
||||
scheme = "http"
|
||||
host = "localhost"
|
||||
} else {
|
||||
scheme = in.Scheme
|
||||
host = in.Host
|
||||
}
|
||||
|
||||
u := url.URL{
|
||||
Scheme: scheme,
|
||||
Host: host,
|
||||
Path: "/cgi-bin/authLogin.cgi",
|
||||
RawQuery: query.Encode(),
|
||||
}
|
||||
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func qnapAuthnQtoken(r *http.Request, user, token string) (string, *qnapAuthResponse, error) {
|
||||
query := url.Values{
|
||||
"qtoken": []string{token},
|
||||
"user": []string{user},
|
||||
}
|
||||
return qnapAuthnFinish(user, qnapAuthnURL(r.URL.String(), query))
|
||||
}
|
||||
|
||||
func qnapAuthnSid(r *http.Request, user, sid string) (string, *qnapAuthResponse, error) {
|
||||
query := url.Values{
|
||||
"sid": []string{sid},
|
||||
}
|
||||
return qnapAuthnFinish(user, qnapAuthnURL(r.URL.String(), query))
|
||||
}
|
||||
|
||||
func qnapAuthnFinish(user, url string) (string, *qnapAuthResponse, error) {
|
||||
// QNAP Force HTTPS mode uses a self-signed certificate. Even importing
|
||||
// the QNAP root CA isn't enough, the cert doesn't have a usable CN nor
|
||||
// SAN. See https://github.com/tailscale/tailscale/issues/6903
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
client := &http.Client{Transport: tr}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
out, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
authResp := &qnapAuthResponse{}
|
||||
if err := xml.Unmarshal(out, authResp); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if authResp.AuthPassed == 0 {
|
||||
return "", nil, fmt.Errorf("not authenticated")
|
||||
}
|
||||
return user, authResp, nil
|
||||
}
|
||||
|
||||
func synoAuthn() (string, error) {
|
||||
cmd := exec.Command("/usr/syno/synoman/webman/modules/authenticate.cgi")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("auth: %v: %s", err, out)
|
||||
}
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
|
||||
func authRedirect(w http.ResponseWriter, r *http.Request) bool {
|
||||
if distro.Get() == distro.Synology {
|
||||
return synoTokenRedirect(w, r)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func synoTokenRedirect(w http.ResponseWriter, r *http.Request) bool {
|
||||
if r.Header.Get("X-Syno-Token") != "" {
|
||||
return false
|
||||
}
|
||||
if r.URL.Query().Get("SynoToken") != "" {
|
||||
return false
|
||||
}
|
||||
if r.Method == "POST" && r.FormValue("SynoToken") != "" {
|
||||
return false
|
||||
}
|
||||
// We need a SynoToken for authenticate.cgi.
|
||||
// So we tell the client to get one.
|
||||
_, _ = fmt.Fprint(w, synoTokenRedirectHTML)
|
||||
return true
|
||||
}
|
||||
|
||||
const synoTokenRedirectHTML = `<html><body>
|
||||
Redirecting with session token...
|
||||
<script>
|
||||
var serverURL = window.location.protocol + "//" + window.location.host;
|
||||
var req = new XMLHttpRequest();
|
||||
req.overrideMimeType("application/json");
|
||||
req.open("GET", serverURL + "/webman/login.cgi", true);
|
||||
req.onload = function() {
|
||||
var jsonResponse = JSON.parse(req.responseText);
|
||||
var token = jsonResponse["SynoToken"];
|
||||
document.location.href = serverURL + "/webman/3rdparty/Tailscale/?SynoToken=" + token;
|
||||
};
|
||||
req.send(null);
|
||||
</script>
|
||||
</body></html>
|
||||
`
|
||||
|
||||
// ServeHTTP processes all requests for the Tailscale web client.
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if s.devMode {
|
||||
if r.URL.Path == "/api/data" {
|
||||
user, err := authorize(w, r)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case httpm.GET:
|
||||
s.serveGetNodeDataJSON(w, r, user)
|
||||
case httpm.POST:
|
||||
s.servePostNodeUpdate(w, r)
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
return
|
||||
}
|
||||
// When in dev mode, proxy to the Vite dev server.
|
||||
s.devProxy.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if authRedirect(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
user, err := authorize(w, r)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch {
|
||||
case r.URL.Path == "/redirect" || r.URL.Path == "/redirect/":
|
||||
io.WriteString(w, authenticationRedirectHTML)
|
||||
return
|
||||
case r.Method == "POST":
|
||||
s.servePostNodeUpdate(w, r)
|
||||
return
|
||||
default:
|
||||
s.serveGetNodeData(w, r, user)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
type nodeData struct {
|
||||
Profile tailcfg.UserProfile
|
||||
SynologyUser string
|
||||
Status string
|
||||
DeviceName string
|
||||
IP string
|
||||
AdvertiseExitNode bool
|
||||
AdvertiseRoutes string
|
||||
LicensesURL string
|
||||
TUNMode bool
|
||||
IsSynology bool
|
||||
DSMVersion int // 6 or 7, if IsSynology=true
|
||||
IsUnraid bool
|
||||
UnraidToken string
|
||||
IPNVersion string
|
||||
}
|
||||
|
||||
func (s *Server) getNodeData(ctx context.Context, user string) (*nodeData, error) {
|
||||
st, err := s.lc.Status(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prefs, err := s.lc.GetPrefs(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
profile := st.User[st.Self.UserID]
|
||||
deviceName := strings.Split(st.Self.DNSName, ".")[0]
|
||||
versionShort := strings.Split(st.Version, "-")[0]
|
||||
data := &nodeData{
|
||||
SynologyUser: user,
|
||||
Profile: profile,
|
||||
Status: st.BackendState,
|
||||
DeviceName: deviceName,
|
||||
LicensesURL: licenses.LicensesURL(),
|
||||
TUNMode: st.TUN,
|
||||
IsSynology: distro.Get() == distro.Synology || envknob.Bool("TS_FAKE_SYNOLOGY"),
|
||||
DSMVersion: distro.DSMVersion(),
|
||||
IsUnraid: distro.Get() == distro.Unraid,
|
||||
UnraidToken: os.Getenv("UNRAID_CSRF_TOKEN"),
|
||||
IPNVersion: versionShort,
|
||||
}
|
||||
exitNodeRouteV4 := netip.MustParsePrefix("0.0.0.0/0")
|
||||
exitNodeRouteV6 := netip.MustParsePrefix("::/0")
|
||||
for _, r := range prefs.AdvertiseRoutes {
|
||||
if r == exitNodeRouteV4 || r == exitNodeRouteV6 {
|
||||
data.AdvertiseExitNode = true
|
||||
} else {
|
||||
if data.AdvertiseRoutes != "" {
|
||||
data.AdvertiseRoutes += ","
|
||||
}
|
||||
data.AdvertiseRoutes += r.String()
|
||||
}
|
||||
}
|
||||
if len(st.TailscaleIPs) != 0 {
|
||||
data.IP = st.TailscaleIPs[0].String()
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request, user string) {
|
||||
data, err := s.getNodeData(r.Context(), user)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
buf := new(bytes.Buffer)
|
||||
if err := tmpl.Execute(buf, *data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Write(buf.Bytes())
|
||||
}
|
||||
|
||||
func (s *Server) serveGetNodeDataJSON(w http.ResponseWriter, r *http.Request, user string) {
|
||||
data, err := s.getNodeData(r.Context(), user)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(*data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
return
|
||||
}
|
||||
|
||||
type nodeUpdate struct {
|
||||
AdvertiseRoutes string
|
||||
AdvertiseExitNode bool
|
||||
Reauthenticate bool
|
||||
ForceLogout bool
|
||||
}
|
||||
|
||||
func (s *Server) servePostNodeUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
defer r.Body.Close()
|
||||
|
||||
st, err := s.lc.Status(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var postData nodeUpdate
|
||||
type mi map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&postData); err != nil {
|
||||
w.WriteHeader(400)
|
||||
json.NewEncoder(w).Encode(mi{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
routes, err := netutil.CalcAdvertiseRoutes(postData.AdvertiseRoutes, postData.AdvertiseExitNode)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(mi{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
mp := &ipn.MaskedPrefs{
|
||||
AdvertiseRoutesSet: true,
|
||||
WantRunningSet: true,
|
||||
}
|
||||
mp.Prefs.WantRunning = true
|
||||
mp.Prefs.AdvertiseRoutes = routes
|
||||
log.Printf("Doing edit: %v", mp.Pretty())
|
||||
|
||||
if _, err := s.lc.EditPrefs(r.Context(), mp); err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(mi{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
var reauth, logout bool
|
||||
if postData.Reauthenticate {
|
||||
reauth = true
|
||||
}
|
||||
if postData.ForceLogout {
|
||||
logout = true
|
||||
}
|
||||
log.Printf("tailscaleUp(reauth=%v, logout=%v) ...", reauth, logout)
|
||||
url, err := s.tailscaleUp(r.Context(), st, postData)
|
||||
log.Printf("tailscaleUp = (URL %v, %v)", url != "", err)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(mi{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if url != "" {
|
||||
json.NewEncoder(w).Encode(mi{"url": url})
|
||||
} else {
|
||||
io.WriteString(w, "{}")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Server) tailscaleUp(ctx context.Context, st *ipnstate.Status, postData nodeUpdate) (authURL string, retErr error) {
|
||||
if postData.ForceLogout {
|
||||
if err := s.lc.Logout(ctx); err != nil {
|
||||
return "", fmt.Errorf("Logout error: %w", err)
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
origAuthURL := st.AuthURL
|
||||
isRunning := st.BackendState == ipn.Running.String()
|
||||
|
||||
forceReauth := postData.Reauthenticate
|
||||
if !forceReauth {
|
||||
if origAuthURL != "" {
|
||||
return origAuthURL, nil
|
||||
}
|
||||
if isRunning {
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
|
||||
// printAuthURL reports whether we should print out the
|
||||
// provided auth URL from an IPN notify.
|
||||
printAuthURL := func(url string) bool {
|
||||
return url != origAuthURL
|
||||
}
|
||||
|
||||
watchCtx, cancelWatch := context.WithCancel(ctx)
|
||||
defer cancelWatch()
|
||||
watcher, err := s.lc.WatchIPNBus(watchCtx, 0)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer watcher.Close()
|
||||
|
||||
go func() {
|
||||
if !isRunning {
|
||||
s.lc.Start(ctx, ipn.Options{})
|
||||
}
|
||||
if forceReauth {
|
||||
s.lc.StartLoginInteractive(ctx)
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
n, err := watcher.Next()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if n.ErrMessage != nil {
|
||||
msg := *n.ErrMessage
|
||||
return "", fmt.Errorf("backend error: %v", msg)
|
||||
}
|
||||
if url := n.BrowseToURL; url != nil && printAuthURL(*url) {
|
||||
return *url, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
64
client/web/web_test.go
Normal file
64
client/web/web_test.go
Normal file
@@ -0,0 +1,64 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestQnapAuthnURL(t *testing.T) {
|
||||
query := url.Values{
|
||||
"qtoken": []string{"token"},
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "localhost http",
|
||||
in: "http://localhost:8088/",
|
||||
want: "http://localhost:8088/cgi-bin/authLogin.cgi?qtoken=token",
|
||||
},
|
||||
{
|
||||
name: "localhost https",
|
||||
in: "https://localhost:5000/",
|
||||
want: "https://localhost:5000/cgi-bin/authLogin.cgi?qtoken=token",
|
||||
},
|
||||
{
|
||||
name: "IP http",
|
||||
in: "http://10.1.20.4:80/",
|
||||
want: "http://10.1.20.4:80/cgi-bin/authLogin.cgi?qtoken=token",
|
||||
},
|
||||
{
|
||||
name: "IP6 https",
|
||||
in: "https://[ff7d:0:1:2::1]/",
|
||||
want: "https://[ff7d:0:1:2::1]/cgi-bin/authLogin.cgi?qtoken=token",
|
||||
},
|
||||
{
|
||||
name: "hostname https",
|
||||
in: "https://qnap.example.com/",
|
||||
want: "https://qnap.example.com/cgi-bin/authLogin.cgi?qtoken=token",
|
||||
},
|
||||
{
|
||||
name: "invalid URL",
|
||||
in: "This is not a URL, it is a really really really really really really really really really really really really long string to exercise the URL truncation code in the error path.",
|
||||
want: "http://localhost/cgi-bin/authLogin.cgi?qtoken=token",
|
||||
},
|
||||
{
|
||||
name: "err != nil",
|
||||
in: "http://192.168.0.%31/",
|
||||
want: "http://localhost/cgi-bin/authLogin.cgi?qtoken=token",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
u := qnapAuthnURL(tt.in, query)
|
||||
if u != tt.want {
|
||||
t.Errorf("expected url: %q, got: %q", tt.want, u)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -309,6 +309,13 @@
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz#786c5f41f043b07afb1af37683d7c33668858f6d"
|
||||
integrity sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==
|
||||
|
||||
"@jest/schemas@^29.6.0":
|
||||
version "29.6.0"
|
||||
resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-29.6.0.tgz#0f4cb2c8e3dca80c135507ba5635a4fd755b0040"
|
||||
integrity sha512-rxLjXyJBTL4LQeJW3aKo0M/+GkCOXsO+8i9Iu7eDb6KwtP65ayoDsitrdPBtujxQ88k4wI2FNYfa6TOGwSn6cQ==
|
||||
dependencies:
|
||||
"@sinclair/typebox" "^0.27.8"
|
||||
|
||||
"@jridgewell/gen-mapping@^0.3.0", "@jridgewell/gen-mapping@^0.3.2":
|
||||
version "0.3.3"
|
||||
resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz#7e02e6eb5df901aaedb08514203b096614024098"
|
||||
@@ -328,7 +335,7 @@
|
||||
resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.1.2.tgz#7c6cf998d6d20b914c0a55a91ae928ff25965e72"
|
||||
integrity sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==
|
||||
|
||||
"@jridgewell/sourcemap-codec@^1.4.10", "@jridgewell/sourcemap-codec@^1.4.14":
|
||||
"@jridgewell/sourcemap-codec@^1.4.10", "@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.4.15":
|
||||
version "1.4.15"
|
||||
resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz#d7c6e6755c78567a951e04ab52ef0fd26de59f32"
|
||||
integrity sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==
|
||||
@@ -371,6 +378,11 @@
|
||||
estree-walker "^2.0.2"
|
||||
picomatch "^2.3.1"
|
||||
|
||||
"@sinclair/typebox@^0.27.8":
|
||||
version "0.27.8"
|
||||
resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.27.8.tgz#6667fac16c436b5434a387a34dedb013198f6e6e"
|
||||
integrity sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==
|
||||
|
||||
"@svgr/babel-plugin-add-jsx-attribute@^7.0.0":
|
||||
version "7.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-7.0.0.tgz#80856c1b7a3b7422d232f6e079f0beb90c4a13e9"
|
||||
@@ -453,77 +465,120 @@
|
||||
"@svgr/hast-util-to-babel-ast" "^7.0.0"
|
||||
svg-parser "^2.0.4"
|
||||
|
||||
"@swc/core-darwin-arm64@1.3.75":
|
||||
version "1.3.75"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.3.75.tgz#f6b2fb9dd03839ff3153902e09f1772963a1bbb6"
|
||||
integrity sha512-anDnx9L465lGbjB2mvcV54NGHW6illr0IDvVV7JmkabYUVneaRdQvTr0tbHv3xjHnjrK1wuwVOHKV0LcQF2tnQ==
|
||||
"@swc/core-darwin-arm64@1.3.76":
|
||||
version "1.3.76"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.3.76.tgz#757f10c6482a44b8cea3e85b8ae714ce9b31b4b5"
|
||||
integrity sha512-ovviEhZ/1E81Z9OGrO0ivLWk4VCa3I3ZzM+cd3gugglRRwVwtlIaoIYqY5S3KiCAupDd1+UCl5X7Vbio7a/V8g==
|
||||
|
||||
"@swc/core-darwin-x64@1.3.75":
|
||||
version "1.3.75"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-darwin-x64/-/core-darwin-x64-1.3.75.tgz#b5a4fcc668f15fe664c9bdddac12b7c0685e6c81"
|
||||
integrity sha512-dIHDfrLmeZfr2xwi1whO7AmzdI3HdamgvxthaL+S8L1x8TeczAZEvsmZTjy3s8p3Va4rbGXcb3+uBhmfkqCbfw==
|
||||
"@swc/core-darwin-x64@1.3.76":
|
||||
version "1.3.76"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-darwin-x64/-/core-darwin-x64-1.3.76.tgz#edba4a4dbbc7454bc914fc8cf61545a74622d46f"
|
||||
integrity sha512-tcySTDqs0SHCebtW35sCdcLWsmTEo7bEwx0gNL/spetqVT9fpFi6qU8qcnt7i2KaZHbeNl9g1aadu+Yrni+GzA==
|
||||
|
||||
"@swc/core-linux-arm-gnueabihf@1.3.75":
|
||||
version "1.3.75"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.3.75.tgz#a4b19babc256390790b50d75dd300a3201275f9f"
|
||||
integrity sha512-qeJmvMGrjC6xt+G0R4kVqqxvlhxJx7tTzhcEoWgLJnfvGZiF6SJdsef4OSM7HuReXrlBoEtJbfGPrLJtbV+C0w==
|
||||
"@swc/core-linux-arm-gnueabihf@1.3.76":
|
||||
version "1.3.76"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.3.76.tgz#d998f0e51ebec03e8666f02cee3fc6e40ceaf680"
|
||||
integrity sha512-apgzpGWy1AwoMF4urAAASsAjE7rEzZFIF+p6utuxhS7cNHzE0AyEVDYJbo+pzBdlZ8orBdzzsHtFwoEgKOjebA==
|
||||
|
||||
"@swc/core-linux-arm64-gnu@1.3.75":
|
||||
version "1.3.75"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.3.75.tgz#347e44d12a3fd71e9fc109b68a7fff81696ecbc3"
|
||||
integrity sha512-sqA9JqHEJBF4AdNuwo5zRqq0HC3l31SPsG9zpRa4nRzG5daBBJ80H7fi6PZQud1rfNNq+Q08gjYrdrxwHstvjw==
|
||||
"@swc/core-linux-arm64-gnu@1.3.76":
|
||||
version "1.3.76"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.3.76.tgz#4f4d98f699e92ebafb10ed75e468384a81ab128c"
|
||||
integrity sha512-c3c0zz6S0eludqidDpuqbadE0WT3OZczyQxe9Vw8lFFXES85mvNGtwYzyGK2o7TICpsuHrndwDIoYpmpWk879g==
|
||||
|
||||
"@swc/core-linux-arm64-musl@1.3.75":
|
||||
version "1.3.75"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.3.75.tgz#ca21e597ff52c0a25848be1838cd923a32963584"
|
||||
integrity sha512-95rQT5xTAL3eKhMJbJbLsZHHP9EUlh1rcrFoLf0gUApoVF8g94QjZ9hYZiI72mMP5WPjgTEXQVnVB9O2GxeaLw==
|
||||
"@swc/core-linux-arm64-musl@1.3.76":
|
||||
version "1.3.76"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.3.76.tgz#4341ca15e4a398de73af149c52c4d45b8cf5c4c8"
|
||||
integrity sha512-Is3bpq7F2qtlnkzEeOD6HIZJPpOmu3q6c82lKww90Q0NnrlSluVMozTHJgwVoFZyizH7uLnk0LuNcEAWLnmJIw==
|
||||
|
||||
"@swc/core-linux-x64-gnu@1.3.75":
|
||||
version "1.3.75"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.3.75.tgz#56abe19475a72bdc5333461b55ca3c2cd60e4611"
|
||||
integrity sha512-If7UpAhnPduMmtC+TSgPpZ1UXZfp2hIpjUFxpeCmHHYLS6Fn/2GZC5hpEiu+wvFJF0hzPh93eNAHa9gUxGUG+w==
|
||||
"@swc/core-linux-x64-gnu@1.3.76":
|
||||
version "1.3.76"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.3.76.tgz#cc2e6f0f90f0e9d6dcb8bc62cd31172e0967b396"
|
||||
integrity sha512-iwCeRzd9oSvUzqt7nU6p/ztceAWfnO9XVxBn502R5gs6QCBbE1HCKrWHDO77aKPK7ss+0NcIGHvXTd9L8/wRzw==
|
||||
|
||||
"@swc/core-linux-x64-musl@1.3.75":
|
||||
version "1.3.75"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.3.75.tgz#bfa90d24071930effeb4514540f89266a6d6957a"
|
||||
integrity sha512-HOhxX0YNHTElCZqIviquka3CGYTN8rSQ6BdFfSk/K0O+ZEHx3qGte0qr+gGLPF/237GxreUkp3OMaWKuURtuCg==
|
||||
"@swc/core-linux-x64-musl@1.3.76":
|
||||
version "1.3.76"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.3.76.tgz#ebc327df5e07aa02e41309e56590f505f1fc64c0"
|
||||
integrity sha512-a671g4tW8kyFeuICsgq4uB9ukQfiIyXJT4V6YSnmqhCTz5mazWuDxZ5wKnx/1g5nXTl+U5cWH2TZaCJatp4GKA==
|
||||
|
||||
"@swc/core-win32-arm64-msvc@1.3.75":
|
||||
version "1.3.75"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.3.75.tgz#2fd8ea75ffe1a9153c523b6135b7169266a60e54"
|
||||
integrity sha512-7QPI+mvBXAerVfWahrgBNe+g7fK8PuetxFnZSEmXUcDXvWcdJXAndD7GjAJzbDyjQpLKHbsDKMiHYvfNxZoN/A==
|
||||
"@swc/core-win32-arm64-msvc@1.3.76":
|
||||
version "1.3.76"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.3.76.tgz#34fb884d2ee2eec3382c01f712bde0f05e058a3b"
|
||||
integrity sha512-+swEFtjdMezS0vKUhJC3psdSDtOJGY5pEOt4e8XOPvn7aQpKQ9LfF49XVtIwDSk5SGuWtVoLFzkSY3reWUJCyg==
|
||||
|
||||
"@swc/core-win32-ia32-msvc@1.3.75":
|
||||
version "1.3.75"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.3.75.tgz#9dae46582027ffeb03f258d05ab797701250d465"
|
||||
integrity sha512-EfABCy4Wlq7O5ShWsm32FgDkSjyeyj/SQ4wnUIvWpkXhgfT1iNXky7KRU1HtX+SmnVk/k/NnabVZpIklYbjtZA==
|
||||
"@swc/core-win32-ia32-msvc@1.3.76":
|
||||
version "1.3.76"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.3.76.tgz#a0dc94357d72eca6572522ed1202b6476222c249"
|
||||
integrity sha512-5CqwAykpGBJ3PqGLOlWGLGIPpBAG1IwWVDUfro3hhjQ7XJxV5Z1aQf5V5OJ90HJVtrEAVx2xx59UV/Dh081LOg==
|
||||
|
||||
"@swc/core-win32-x64-msvc@1.3.75":
|
||||
version "1.3.75"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.3.75.tgz#c8e6d30c5deed1ae0fa162a42d6d9ef165b8041f"
|
||||
integrity sha512-cTvP0pOD9C3pSp1cwtt85ZsrUkQz8RZfSPhM+jCGxKxmoowDCnInoOQ4Ica/ehyuUnQ4/IstSdYtYpO5yzPDJg==
|
||||
"@swc/core-win32-x64-msvc@1.3.76":
|
||||
version "1.3.76"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.3.76.tgz#eea647407895a5a410a459b2abf8572adb147927"
|
||||
integrity sha512-CiMpWLLlR3Cew9067E7XxaLBwYYJ90r9EhGSO6V1pvYSWj7ET/Ppmtj1ZhzPJMqRXAP6xflfl5R5o4ee1m4WLA==
|
||||
|
||||
"@swc/core@^1.3.61":
|
||||
version "1.3.75"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core/-/core-1.3.75.tgz#b06d32144a5be0b7b25dbbff09dcd1ab18e48b67"
|
||||
integrity sha512-YLqd5oZVnaOq/OzkjRSsJUQqAfKYiD0fzUyVUPVlNNCoQEfVfSMcXH80hLmYe9aDH0T/a7qEMjWyIr/0kWqy1A==
|
||||
version "1.3.76"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core/-/core-1.3.76.tgz#f5259bd718e11854d9bd3a05f91f40bca21dffbc"
|
||||
integrity sha512-aYYTA2aVYkwJAZepQXtPnkUthhOfn8qd6rsh+lrJxonFrjmpI7RHt2tMDVTXP6XDX7fvnvrVtT1bwZfmBFPh0Q==
|
||||
optionalDependencies:
|
||||
"@swc/core-darwin-arm64" "1.3.75"
|
||||
"@swc/core-darwin-x64" "1.3.75"
|
||||
"@swc/core-linux-arm-gnueabihf" "1.3.75"
|
||||
"@swc/core-linux-arm64-gnu" "1.3.75"
|
||||
"@swc/core-linux-arm64-musl" "1.3.75"
|
||||
"@swc/core-linux-x64-gnu" "1.3.75"
|
||||
"@swc/core-linux-x64-musl" "1.3.75"
|
||||
"@swc/core-win32-arm64-msvc" "1.3.75"
|
||||
"@swc/core-win32-ia32-msvc" "1.3.75"
|
||||
"@swc/core-win32-x64-msvc" "1.3.75"
|
||||
"@swc/core-darwin-arm64" "1.3.76"
|
||||
"@swc/core-darwin-x64" "1.3.76"
|
||||
"@swc/core-linux-arm-gnueabihf" "1.3.76"
|
||||
"@swc/core-linux-arm64-gnu" "1.3.76"
|
||||
"@swc/core-linux-arm64-musl" "1.3.76"
|
||||
"@swc/core-linux-x64-gnu" "1.3.76"
|
||||
"@swc/core-linux-x64-musl" "1.3.76"
|
||||
"@swc/core-win32-arm64-msvc" "1.3.76"
|
||||
"@swc/core-win32-ia32-msvc" "1.3.76"
|
||||
"@swc/core-win32-x64-msvc" "1.3.76"
|
||||
|
||||
"@types/chai-subset@^1.3.3":
|
||||
version "1.3.3"
|
||||
resolved "https://registry.yarnpkg.com/@types/chai-subset/-/chai-subset-1.3.3.tgz#97893814e92abd2c534de422cb377e0e0bdaac94"
|
||||
integrity sha512-frBecisrNGz+F4T6bcc+NLeolfiojh5FxW2klu669+8BARtyQv2C/GkNW6FUodVe4BroGMP/wER/YDGc7rEllw==
|
||||
dependencies:
|
||||
"@types/chai" "*"
|
||||
|
||||
"@types/chai@*", "@types/chai@^4.3.5":
|
||||
version "4.3.5"
|
||||
resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.3.5.tgz#ae69bcbb1bebb68c4ac0b11e9d8ed04526b3562b"
|
||||
integrity sha512-mEo1sAde+UCE6b2hxn332f1g1E8WfYRu6p5SvTKr2ZKC1f7gFJXk4h5PyGP9Dt6gCaG8y8XhwnXWC6Iy2cmBng==
|
||||
|
||||
"@types/estree@^1.0.0":
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.1.tgz#aa22750962f3bf0e79d753d3cc067f010c95f194"
|
||||
integrity sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==
|
||||
|
||||
"@types/node@*":
|
||||
version "20.4.9"
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.4.9.tgz#c7164e0f8d3f12dfae336af0b1f7fdec8c6b204f"
|
||||
integrity sha512-8e2HYcg7ohnTUbHk8focoklEQYvemQmu9M/f43DZVx43kHn0tE3BY/6gSDxS7k0SprtS0NHvj+L80cGLnoOUcQ==
|
||||
|
||||
"@types/prop-types@*":
|
||||
version "15.7.5"
|
||||
resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.5.tgz#5f19d2b85a98e9558036f6a3cacc8819420f05cf"
|
||||
integrity sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==
|
||||
|
||||
"@types/react-dom@^18.0.6":
|
||||
version "18.2.7"
|
||||
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.2.7.tgz#67222a08c0a6ae0a0da33c3532348277c70abb63"
|
||||
integrity sha512-GRaAEriuT4zp9N4p1i8BDBYmEyfo+xQ3yHjJU4eiK5NDa1RmUZG+unZABUTK4/Ox/M+GaHwb6Ow8rUITrtjszA==
|
||||
dependencies:
|
||||
"@types/react" "*"
|
||||
|
||||
"@types/react@*", "@types/react@^18.0.20":
|
||||
version "18.2.20"
|
||||
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.20.tgz#1605557a83df5c8a2cc4eeb743b3dfc0eb6aaeb2"
|
||||
integrity sha512-WKNtmsLWJM/3D5mG4U84cysVY31ivmyw85dE84fOCk5Hx78wezB/XEjVPWl2JTZ5FkEeaTJf+VgUAUn3PE7Isw==
|
||||
dependencies:
|
||||
"@types/prop-types" "*"
|
||||
"@types/scheduler" "*"
|
||||
csstype "^3.0.2"
|
||||
|
||||
"@types/scheduler@*":
|
||||
version "0.16.3"
|
||||
resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.3.tgz#cef09e3ec9af1d63d2a6cc5b383a737e24e6dcf5"
|
||||
integrity sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==
|
||||
|
||||
"@vitejs/plugin-react-swc@^3.3.2":
|
||||
version "3.3.2"
|
||||
resolved "https://registry.yarnpkg.com/@vitejs/plugin-react-swc/-/plugin-react-swc-3.3.2.tgz#34a82c1728066f48a86dfecb2f15df60f89207fb"
|
||||
@@ -531,6 +586,59 @@
|
||||
dependencies:
|
||||
"@swc/core" "^1.3.61"
|
||||
|
||||
"@vitest/expect@0.32.4":
|
||||
version "0.32.4"
|
||||
resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-0.32.4.tgz#4aa4eec78112cdbe299834b965420d4fb3afa91d"
|
||||
integrity sha512-m7EPUqmGIwIeoU763N+ivkFjTzbaBn0n9evsTOcde03ugy2avPs3kZbYmw3DkcH1j5mxhMhdamJkLQ6dM1bk/A==
|
||||
dependencies:
|
||||
"@vitest/spy" "0.32.4"
|
||||
"@vitest/utils" "0.32.4"
|
||||
chai "^4.3.7"
|
||||
|
||||
"@vitest/runner@0.32.4":
|
||||
version "0.32.4"
|
||||
resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-0.32.4.tgz#2872c697994745f1b70e2bd6568236ad2d9eade6"
|
||||
integrity sha512-cHOVCkiRazobgdKLnczmz2oaKK9GJOw6ZyRcaPdssO1ej+wzHVIkWiCiNacb3TTYPdzMddYkCgMjZ4r8C0JFCw==
|
||||
dependencies:
|
||||
"@vitest/utils" "0.32.4"
|
||||
p-limit "^4.0.0"
|
||||
pathe "^1.1.1"
|
||||
|
||||
"@vitest/snapshot@0.32.4":
|
||||
version "0.32.4"
|
||||
resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-0.32.4.tgz#75166b1c772d018278a7f0e79f43f3eae813f5ae"
|
||||
integrity sha512-IRpyqn9t14uqsFlVI2d7DFMImGMs1Q9218of40bdQQgMePwVdmix33yMNnebXcTzDU5eiV3eUsoxxH5v0x/IQA==
|
||||
dependencies:
|
||||
magic-string "^0.30.0"
|
||||
pathe "^1.1.1"
|
||||
pretty-format "^29.5.0"
|
||||
|
||||
"@vitest/spy@0.32.4":
|
||||
version "0.32.4"
|
||||
resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-0.32.4.tgz#c3212bc60c1430c3b5c39d6a384a75458b8f1e80"
|
||||
integrity sha512-oA7rCOqVOOpE6rEoXuCOADX7Lla1LIa4hljI2MSccbpec54q+oifhziZIJXxlE/CvI2E+ElhBHzVu0VEvJGQKQ==
|
||||
dependencies:
|
||||
tinyspy "^2.1.1"
|
||||
|
||||
"@vitest/utils@0.32.4":
|
||||
version "0.32.4"
|
||||
resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-0.32.4.tgz#36283e3aa3f3b1a378e19493c7b3b9107dc4ea71"
|
||||
integrity sha512-Gwnl8dhd1uJ+HXrYyV0eRqfmk9ek1ASE/LWfTCuWMw+d07ogHqp4hEAV28NiecimK6UY9DpSEPh+pXBA5gtTBg==
|
||||
dependencies:
|
||||
diff-sequences "^29.4.3"
|
||||
loupe "^2.3.6"
|
||||
pretty-format "^29.5.0"
|
||||
|
||||
acorn-walk@^8.2.0:
|
||||
version "8.2.0"
|
||||
resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.2.0.tgz#741210f2e2426454508853a2f44d0ab83b7f69c1"
|
||||
integrity sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==
|
||||
|
||||
acorn@^8.10.0, acorn@^8.9.0:
|
||||
version "8.10.0"
|
||||
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.10.0.tgz#8be5b3907a67221a81ab23c7889c4c5526b62ec5"
|
||||
integrity sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==
|
||||
|
||||
ansi-styles@^3.2.1:
|
||||
version "3.2.1"
|
||||
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d"
|
||||
@@ -538,6 +646,11 @@ ansi-styles@^3.2.1:
|
||||
dependencies:
|
||||
color-convert "^1.9.0"
|
||||
|
||||
ansi-styles@^5.0.0:
|
||||
version "5.2.0"
|
||||
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-5.2.0.tgz#07449690ad45777d1924ac2abb2fc8895dba836b"
|
||||
integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==
|
||||
|
||||
any-promise@^1.0.0:
|
||||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f"
|
||||
@@ -561,6 +674,23 @@ argparse@^2.0.1:
|
||||
resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38"
|
||||
integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==
|
||||
|
||||
assertion-error@^1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.1.0.tgz#e60b6b0e8f301bd97e5375215bda406c85118c0b"
|
||||
integrity sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==
|
||||
|
||||
autoprefixer@^10.4.15:
|
||||
version "10.4.15"
|
||||
resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.4.15.tgz#a1230f4aeb3636b89120b34a1f513e2f6834d530"
|
||||
integrity sha512-KCuPB8ZCIqFdA4HwKXsvz7j6gvSDNhDP7WnUjBleRkKjPdvCmHFuQ77ocavI8FT6NdvlBnE2UFr2H4Mycn8Vew==
|
||||
dependencies:
|
||||
browserslist "^4.21.10"
|
||||
caniuse-lite "^1.0.30001520"
|
||||
fraction.js "^4.2.0"
|
||||
normalize-range "^0.1.2"
|
||||
picocolors "^1.0.0"
|
||||
postcss-value-parser "^4.2.0"
|
||||
|
||||
balanced-match@^1.0.0:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
|
||||
@@ -586,7 +716,7 @@ braces@^3.0.2, braces@~3.0.2:
|
||||
dependencies:
|
||||
fill-range "^7.0.1"
|
||||
|
||||
browserslist@^4.21.9:
|
||||
browserslist@^4.21.10, browserslist@^4.21.9:
|
||||
version "4.21.10"
|
||||
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.21.10.tgz#dbbac576628c13d3b2231332cb2ec5a46e015bb0"
|
||||
integrity sha512-bipEBdZfVH5/pwrvqc+Ub0kUPVfGUhlKxbvfD+z1BDnPEO/X98ruXGA1WP5ASpAFKan7Qr6j736IacbZQuAlKQ==
|
||||
@@ -596,6 +726,11 @@ browserslist@^4.21.9:
|
||||
node-releases "^2.0.13"
|
||||
update-browserslist-db "^1.0.11"
|
||||
|
||||
cac@^6.7.14:
|
||||
version "6.7.14"
|
||||
resolved "https://registry.yarnpkg.com/cac/-/cac-6.7.14.tgz#804e1e6f506ee363cb0e3ccbb09cad5dd9870959"
|
||||
integrity sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==
|
||||
|
||||
callsites@^3.0.0:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73"
|
||||
@@ -616,6 +751,24 @@ caniuse-lite@^1.0.30001517:
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001519.tgz#3e7b8b8a7077e78b0eb054d69e6edf5c7df35601"
|
||||
integrity sha512-0QHgqR+Jv4bxHMp8kZ1Kn8CH55OikjKJ6JmKkZYP1F3D7w+lnFXF70nG5eNfsZS89jadi5Ywy5UCSKLAglIRkg==
|
||||
|
||||
caniuse-lite@^1.0.30001520:
|
||||
version "1.0.30001520"
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001520.tgz#62e2b7a1c7b35269594cf296a80bdf8cb9565006"
|
||||
integrity sha512-tahF5O9EiiTzwTUqAeFjIZbn4Dnqxzz7ktrgGlMYNLH43Ul26IgTMH/zvL3DG0lZxBYnlT04axvInszUsZULdA==
|
||||
|
||||
chai@^4.3.7:
|
||||
version "4.3.7"
|
||||
resolved "https://registry.yarnpkg.com/chai/-/chai-4.3.7.tgz#ec63f6df01829088e8bf55fca839bcd464a8ec51"
|
||||
integrity sha512-HLnAzZ2iupm25PlN0xFreAlBA5zaBSv3og0DdeGA4Ar6h6rJ3A0rolRUKJhSF2V10GZKDgWF/VmAEsNWjCRB+A==
|
||||
dependencies:
|
||||
assertion-error "^1.1.0"
|
||||
check-error "^1.0.2"
|
||||
deep-eql "^4.1.2"
|
||||
get-func-name "^2.0.0"
|
||||
loupe "^2.3.1"
|
||||
pathval "^1.1.1"
|
||||
type-detect "^4.0.5"
|
||||
|
||||
chalk@^2.4.2:
|
||||
version "2.4.2"
|
||||
resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424"
|
||||
@@ -625,6 +778,11 @@ chalk@^2.4.2:
|
||||
escape-string-regexp "^1.0.5"
|
||||
supports-color "^5.3.0"
|
||||
|
||||
check-error@^1.0.2:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/check-error/-/check-error-1.0.2.tgz#574d312edd88bb5dd8912e9286dd6c0aed4aac82"
|
||||
integrity sha512-BrgHpW9NURQgzoNyjfq0Wu6VFO6D7IZEmJNdtgNqpzGG8RuNFHt2jQxWlAs4HMe119chBnv+34syEZtc6IhLtA==
|
||||
|
||||
chokidar@^3.5.3:
|
||||
version "3.5.3"
|
||||
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd"
|
||||
@@ -687,27 +845,44 @@ cssesc@^3.0.0:
|
||||
resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee"
|
||||
integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==
|
||||
|
||||
debug@^4.1.0, debug@^4.1.1:
|
||||
csstype@^3.0.2:
|
||||
version "3.1.2"
|
||||
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.2.tgz#1d4bf9d572f11c14031f0436e1c10bc1f571f50b"
|
||||
integrity sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==
|
||||
|
||||
debug@^4.1.0, debug@^4.1.1, debug@^4.3.4:
|
||||
version "4.3.4"
|
||||
resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865"
|
||||
integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==
|
||||
dependencies:
|
||||
ms "2.1.2"
|
||||
|
||||
deep-eql@^4.1.2:
|
||||
version "4.1.3"
|
||||
resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-4.1.3.tgz#7c7775513092f7df98d8df9996dd085eb668cc6d"
|
||||
integrity sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==
|
||||
dependencies:
|
||||
type-detect "^4.0.0"
|
||||
|
||||
didyoumean@^1.2.2:
|
||||
version "1.2.2"
|
||||
resolved "https://registry.yarnpkg.com/didyoumean/-/didyoumean-1.2.2.tgz#989346ffe9e839b4555ecf5666edea0d3e8ad037"
|
||||
integrity sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==
|
||||
|
||||
diff-sequences@^29.4.3:
|
||||
version "29.4.3"
|
||||
resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-29.4.3.tgz#9314bc1fabe09267ffeca9cbafc457d8499a13f2"
|
||||
integrity sha512-ofrBgwpPhCD85kMKtE9RYFFq6OC1A89oW2vvgWZNCwxrUpRUILopY7lsYyMDSjc8g6U6aiO0Qubg6r4Wgt5ZnA==
|
||||
|
||||
dlv@^1.1.3:
|
||||
version "1.1.3"
|
||||
resolved "https://registry.yarnpkg.com/dlv/-/dlv-1.1.3.tgz#5c198a8a11453596e751494d49874bc7732f2e79"
|
||||
integrity sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==
|
||||
|
||||
electron-to-chromium@^1.4.477:
|
||||
version "1.4.488"
|
||||
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.488.tgz#442b1855f8c84fb1ed79f518985c65db94f64cc9"
|
||||
integrity sha512-Dv4sTjiW7t/UWGL+H8ZkgIjtUAVZDgb/PwGWvMsCT7jipzUV/u5skbLXPFKb6iV0tiddVi/bcS2/kUrczeWgIQ==
|
||||
version "1.4.490"
|
||||
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.490.tgz#d99286f6e915667fa18ea4554def1aa60eb4d5f1"
|
||||
integrity sha512-6s7NVJz+sATdYnIwhdshx/N/9O6rvMxmhVoDSDFdj6iA45gHR8EQje70+RYsF4GeB+k0IeNSBnP7yG9ZXJFr7A==
|
||||
|
||||
entities@^4.4.0:
|
||||
version "4.5.0"
|
||||
@@ -789,6 +964,11 @@ fill-range@^7.0.1:
|
||||
dependencies:
|
||||
to-regex-range "^5.0.1"
|
||||
|
||||
fraction.js@^4.2.0:
|
||||
version "4.2.0"
|
||||
resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.2.0.tgz#448e5109a313a3527f5a3ab2119ec4cf0e0e2950"
|
||||
integrity sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA==
|
||||
|
||||
fs.realpath@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
|
||||
@@ -809,6 +989,11 @@ gensync@^1.0.0-beta.2:
|
||||
resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"
|
||||
integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==
|
||||
|
||||
get-func-name@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/get-func-name/-/get-func-name-2.0.0.tgz#ead774abee72e20409433a066366023dd6887a41"
|
||||
integrity sha512-Hm0ixYtaSZ/V7C8FJrtZIuBBI+iSgL+1Aq82zSu8VQNB4S3Gk8e7Qs3VwBDJAhmRZcFqkl3tQu36g/Foh5I5ig==
|
||||
|
||||
glob-parent@^5.1.2, glob-parent@~5.1.2:
|
||||
version "5.1.2"
|
||||
resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4"
|
||||
@@ -924,7 +1109,7 @@ jiti@^1.18.2:
|
||||
resolved "https://registry.yarnpkg.com/jiti/-/jiti-1.19.1.tgz#fa99e4b76a23053e0e7cde098efe1704a14c16f1"
|
||||
integrity sha512-oVhqoRDaBXf7sjkll95LHVS6Myyyb1zaunVwk4Z0+WPSW4gjS0pl01zYKHScTuyEhQsFxV5L4DR5r+YqSyqyyg==
|
||||
|
||||
js-tokens@^4.0.0:
|
||||
"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
|
||||
integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
|
||||
@@ -951,6 +1136,11 @@ json5@^2.2.2:
|
||||
resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283"
|
||||
integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==
|
||||
|
||||
jsonc-parser@^3.2.0:
|
||||
version "3.2.0"
|
||||
resolved "https://registry.yarnpkg.com/jsonc-parser/-/jsonc-parser-3.2.0.tgz#31ff3f4c2b9793f89c67212627c51c6394f88e76"
|
||||
integrity sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==
|
||||
|
||||
lilconfig@^2.0.5, lilconfig@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.1.0.tgz#78e23ac89ebb7e1bfbf25b18043de756548e7f52"
|
||||
@@ -961,6 +1151,25 @@ lines-and-columns@^1.1.6:
|
||||
resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632"
|
||||
integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==
|
||||
|
||||
local-pkg@^0.4.3:
|
||||
version "0.4.3"
|
||||
resolved "https://registry.yarnpkg.com/local-pkg/-/local-pkg-0.4.3.tgz#0ff361ab3ae7f1c19113d9bb97b98b905dbc4963"
|
||||
integrity sha512-SFppqq5p42fe2qcZQqqEOiVRXl+WCP1MdT6k7BDEW1j++sp5fIY+/fdRQitvKgB5BrBcmrs5m/L0v2FrU5MY1g==
|
||||
|
||||
loose-envify@^1.1.0:
|
||||
version "1.4.0"
|
||||
resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
|
||||
integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==
|
||||
dependencies:
|
||||
js-tokens "^3.0.0 || ^4.0.0"
|
||||
|
||||
loupe@^2.3.1, loupe@^2.3.6:
|
||||
version "2.3.6"
|
||||
resolved "https://registry.yarnpkg.com/loupe/-/loupe-2.3.6.tgz#76e4af498103c532d1ecc9be102036a21f787b53"
|
||||
integrity sha512-RaPMZKiMy8/JruncMU5Bt6na1eftNoo++R4Y+N2FrxkDVTrGvcyzFTsaGif4QTeKESheMGegbhw6iUAq+5A8zA==
|
||||
dependencies:
|
||||
get-func-name "^2.0.0"
|
||||
|
||||
lru-cache@^5.1.1:
|
||||
version "5.1.1"
|
||||
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920"
|
||||
@@ -968,6 +1177,13 @@ lru-cache@^5.1.1:
|
||||
dependencies:
|
||||
yallist "^3.0.2"
|
||||
|
||||
magic-string@^0.30.0:
|
||||
version "0.30.2"
|
||||
resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.2.tgz#dcf04aad3d0d1314bc743d076c50feb29b3c7aca"
|
||||
integrity sha512-lNZdu7pewtq/ZvWUp9Wpf/x7WzMTsR26TWV03BRZrXFsv+BI6dy8RAiKgm1uM/kyR0rCfUcqvOlXKG66KhIGug==
|
||||
dependencies:
|
||||
"@jridgewell/sourcemap-codec" "^1.4.15"
|
||||
|
||||
merge2@^1.3.0:
|
||||
version "1.4.1"
|
||||
resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae"
|
||||
@@ -993,6 +1209,16 @@ minimist@^1.2.6:
|
||||
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c"
|
||||
integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==
|
||||
|
||||
mlly@^1.2.0, mlly@^1.4.0:
|
||||
version "1.4.0"
|
||||
resolved "https://registry.yarnpkg.com/mlly/-/mlly-1.4.0.tgz#830c10d63f1f97bd8785377b24dc2a15d972832b"
|
||||
integrity sha512-ua8PAThnTwpprIaU47EPeZ/bPUVp2QYBbWMphUQpVdBI3Lgqzm5KZQ45Agm3YJedHXaIHl6pBGabaLSUPPSptg==
|
||||
dependencies:
|
||||
acorn "^8.9.0"
|
||||
pathe "^1.1.1"
|
||||
pkg-types "^1.0.3"
|
||||
ufo "^1.1.2"
|
||||
|
||||
ms@2.1.2:
|
||||
version "2.1.2"
|
||||
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
|
||||
@@ -1022,6 +1248,11 @@ normalize-path@^3.0.0, normalize-path@~3.0.0:
|
||||
resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65"
|
||||
integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==
|
||||
|
||||
normalize-range@^0.1.2:
|
||||
version "0.1.2"
|
||||
resolved "https://registry.yarnpkg.com/normalize-range/-/normalize-range-0.1.2.tgz#2d10c06bdfd312ea9777695a4d28439456b75942"
|
||||
integrity sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==
|
||||
|
||||
object-assign@^4.0.1:
|
||||
version "4.1.1"
|
||||
resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
|
||||
@@ -1039,6 +1270,13 @@ once@^1.3.0:
|
||||
dependencies:
|
||||
wrappy "1"
|
||||
|
||||
p-limit@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-4.0.0.tgz#914af6544ed32bfa54670b061cafcbd04984b644"
|
||||
integrity sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==
|
||||
dependencies:
|
||||
yocto-queue "^1.0.0"
|
||||
|
||||
parent-module@^1.0.0:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2"
|
||||
@@ -1071,6 +1309,16 @@ path-type@^4.0.0:
|
||||
resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b"
|
||||
integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==
|
||||
|
||||
pathe@^1.1.0, pathe@^1.1.1:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/pathe/-/pathe-1.1.1.tgz#1dd31d382b974ba69809adc9a7a347e65d84829a"
|
||||
integrity sha512-d+RQGp0MAYTIaDBIMmOfMwz3E+LOZnxx1HZd5R18mmCZY0QBlK0LDZfPc8FW8Ed2DlvsuE6PRjroDY+wg4+j/Q==
|
||||
|
||||
pathval@^1.1.1:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/pathval/-/pathval-1.1.1.tgz#8534e77a77ce7ac5a2512ea21e0fdb8fcf6c3d8d"
|
||||
integrity sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==
|
||||
|
||||
picocolors@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c"
|
||||
@@ -1091,6 +1339,15 @@ pirates@^4.0.1:
|
||||
resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.6.tgz#3018ae32ecfcff6c29ba2267cbf21166ac1f36b9"
|
||||
integrity sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==
|
||||
|
||||
pkg-types@^1.0.3:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/pkg-types/-/pkg-types-1.0.3.tgz#988b42ab19254c01614d13f4f65a2cfc7880f868"
|
||||
integrity sha512-nN7pYi0AQqJnoLPC9eHFQ8AcyaixBUOwvqc5TDnIKCMEE6I0y8P7OKA7fPexsXGCGxQDl/cmrLAp26LhcwxZ4A==
|
||||
dependencies:
|
||||
jsonc-parser "^3.2.0"
|
||||
mlly "^1.2.0"
|
||||
pathe "^1.1.0"
|
||||
|
||||
postcss-import@^15.1.0:
|
||||
version "15.1.0"
|
||||
resolved "https://registry.yarnpkg.com/postcss-import/-/postcss-import-15.1.0.tgz#41c64ed8cc0e23735a9698b3249ffdbf704adc70"
|
||||
@@ -1130,7 +1387,7 @@ postcss-selector-parser@^6.0.11:
|
||||
cssesc "^3.0.0"
|
||||
util-deprecate "^1.0.2"
|
||||
|
||||
postcss-value-parser@^4.0.0:
|
||||
postcss-value-parser@^4.0.0, postcss-value-parser@^4.2.0:
|
||||
version "4.2.0"
|
||||
resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514"
|
||||
integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
|
||||
@@ -1144,11 +1401,50 @@ postcss@^8.4.23, postcss@^8.4.27:
|
||||
picocolors "^1.0.0"
|
||||
source-map-js "^1.0.2"
|
||||
|
||||
prettier-plugin-organize-imports@^3.2.2:
|
||||
version "3.2.3"
|
||||
resolved "https://registry.yarnpkg.com/prettier-plugin-organize-imports/-/prettier-plugin-organize-imports-3.2.3.tgz#6b0141ac71f7ee9a673ce83e95456319e3a7cf0d"
|
||||
integrity sha512-KFvk8C/zGyvUaE3RvxN2MhCLwzV6OBbFSkwZ2OamCrs9ZY4i5L77jQ/w4UmUr+lqX8qbaqVq6bZZkApn+IgJSg==
|
||||
|
||||
prettier@^2.5.1:
|
||||
version "2.8.8"
|
||||
resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.8.8.tgz#e8c5d7e98a4305ffe3de2e1fc4aca1a71c28b1da"
|
||||
integrity sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==
|
||||
|
||||
pretty-format@^29.5.0:
|
||||
version "29.6.2"
|
||||
resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.6.2.tgz#3d5829261a8a4d89d8b9769064b29c50ed486a47"
|
||||
integrity sha512-1q0oC8eRveTg5nnBEWMXAU2qpv65Gnuf2eCQzSjxpWFkPaPARwqZZDGuNE0zPAZfTCHzIk3A8dIjwlQKKLphyg==
|
||||
dependencies:
|
||||
"@jest/schemas" "^29.6.0"
|
||||
ansi-styles "^5.0.0"
|
||||
react-is "^18.0.0"
|
||||
|
||||
queue-microtask@^1.2.2:
|
||||
version "1.2.3"
|
||||
resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243"
|
||||
integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==
|
||||
|
||||
react-dom@^18.2.0:
|
||||
version "18.2.0"
|
||||
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d"
|
||||
integrity sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==
|
||||
dependencies:
|
||||
loose-envify "^1.1.0"
|
||||
scheduler "^0.23.0"
|
||||
|
||||
react-is@^18.0.0:
|
||||
version "18.2.0"
|
||||
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b"
|
||||
integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==
|
||||
|
||||
react@^18.2.0:
|
||||
version "18.2.0"
|
||||
resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5"
|
||||
integrity sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==
|
||||
dependencies:
|
||||
loose-envify "^1.1.0"
|
||||
|
||||
read-cache@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/read-cache/-/read-cache-1.0.0.tgz#e664ef31161166c9751cdbe8dbcf86b5fb58f774"
|
||||
@@ -1194,9 +1490,9 @@ reusify@^1.0.4:
|
||||
integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==
|
||||
|
||||
rollup@^3.27.1:
|
||||
version "3.27.2"
|
||||
resolved "https://registry.yarnpkg.com/rollup/-/rollup-3.27.2.tgz#59adc973504408289be89e5978e938ce852c9520"
|
||||
integrity sha512-YGwmHf7h2oUHkVBT248x0yt6vZkYQ3/rvE5iQuVBh3WO8GcJ6BNeOkpoX1yMHIiBm18EMLjBPIoUDkhgnyxGOQ==
|
||||
version "3.28.0"
|
||||
resolved "https://registry.yarnpkg.com/rollup/-/rollup-3.28.0.tgz#a3c70004b01934760c0cb8df717c7a1d932389a2"
|
||||
integrity sha512-d7zhvo1OUY2SXSM6pfNjgD5+d0Nz87CUp4mt8l/GgVP3oBsPwzNvSzyu1me6BSG9JIgWNTVcafIXBIyM8yQ3yw==
|
||||
optionalDependencies:
|
||||
fsevents "~2.3.2"
|
||||
|
||||
@@ -1207,11 +1503,23 @@ run-parallel@^1.1.9:
|
||||
dependencies:
|
||||
queue-microtask "^1.2.2"
|
||||
|
||||
scheduler@^0.23.0:
|
||||
version "0.23.0"
|
||||
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.0.tgz#ba8041afc3d30eb206a487b6b384002e4e61fdfe"
|
||||
integrity sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==
|
||||
dependencies:
|
||||
loose-envify "^1.1.0"
|
||||
|
||||
semver@^6.3.1:
|
||||
version "6.3.1"
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4"
|
||||
integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==
|
||||
|
||||
siginfo@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/siginfo/-/siginfo-2.0.0.tgz#32e76c70b79724e3bb567cb9d543eb858ccfaf30"
|
||||
integrity sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==
|
||||
|
||||
slash@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634"
|
||||
@@ -1222,11 +1530,28 @@ source-map-js@^1.0.2:
|
||||
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c"
|
||||
integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==
|
||||
|
||||
stackback@0.0.2:
|
||||
version "0.0.2"
|
||||
resolved "https://registry.yarnpkg.com/stackback/-/stackback-0.0.2.tgz#1ac8a0d9483848d1695e418b6d031a3c3ce68e3b"
|
||||
integrity sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==
|
||||
|
||||
std-env@^3.3.3:
|
||||
version "3.3.3"
|
||||
resolved "https://registry.yarnpkg.com/std-env/-/std-env-3.3.3.tgz#a54f06eb245fdcfef53d56f3c0251f1d5c3d01fe"
|
||||
integrity sha512-Rz6yejtVyWnVjC1RFvNmYL10kgjC49EOghxWn0RFqlCHGFpQx+Xe7yW3I4ceK1SGrWIGMjD5Kbue8W/udkbMJg==
|
||||
|
||||
strip-bom@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3"
|
||||
integrity sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==
|
||||
|
||||
strip-literal@^1.0.1:
|
||||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/strip-literal/-/strip-literal-1.3.0.tgz#db3942c2ec1699e6836ad230090b84bb458e3a07"
|
||||
integrity sha512-PugKzOsyXpArk0yWmUwqOZecSO0GH0bPoctLcqNDH9J04pVW3lflYE0ujElBGTloevcxF5MofAOZ7C5l2b+wLg==
|
||||
dependencies:
|
||||
acorn "^8.10.0"
|
||||
|
||||
sucrase@^3.20.3, sucrase@^3.32.0:
|
||||
version "3.34.0"
|
||||
resolved "https://registry.yarnpkg.com/sucrase/-/sucrase-3.34.0.tgz#1e0e2d8fcf07f8b9c3569067d92fbd8690fb576f"
|
||||
@@ -1257,7 +1582,7 @@ svg-parser@^2.0.4:
|
||||
resolved "https://registry.yarnpkg.com/svg-parser/-/svg-parser-2.0.4.tgz#fdc2e29e13951736140b76cb122c8ee6630eb6b5"
|
||||
integrity sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==
|
||||
|
||||
tailwindcss@^3.1.6:
|
||||
tailwindcss@^3.3.3:
|
||||
version "3.3.3"
|
||||
resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.3.3.tgz#90da807393a2859189e48e9e7000e6880a736daf"
|
||||
integrity sha512-A0KgSkef7eE4Mf+nKJ83i75TMyq8HqY3qmFIJSWy8bNt0v1lG7jUcpGpoTFxAwYcWOphcTBLPPJg+bDfhDf52w==
|
||||
@@ -1299,6 +1624,21 @@ thenify-all@^1.0.0:
|
||||
dependencies:
|
||||
any-promise "^1.0.0"
|
||||
|
||||
tinybench@^2.5.0:
|
||||
version "2.5.0"
|
||||
resolved "https://registry.yarnpkg.com/tinybench/-/tinybench-2.5.0.tgz#4711c99bbf6f3e986f67eb722fed9cddb3a68ba5"
|
||||
integrity sha512-kRwSG8Zx4tjF9ZiyH4bhaebu+EDz1BOx9hOigYHlUW4xxI/wKIUQUqo018UlU4ar6ATPBsaMrdbKZ+tmPdohFA==
|
||||
|
||||
tinypool@^0.5.0:
|
||||
version "0.5.0"
|
||||
resolved "https://registry.yarnpkg.com/tinypool/-/tinypool-0.5.0.tgz#3861c3069bf71e4f1f5aa2d2e6b3aaacc278961e"
|
||||
integrity sha512-paHQtnrlS1QZYKF/GnLoOM/DN9fqaGOFbCbxzAhwniySnzl9Ebk8w73/dd34DAhe/obUbPAOldTyYXQZxnPBPQ==
|
||||
|
||||
tinyspy@^2.1.1:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.yarnpkg.com/tinyspy/-/tinyspy-2.1.1.tgz#9e6371b00c259e5c5b301917ca18c01d40ae558c"
|
||||
integrity sha512-XPJL2uSzcOyBMky6OFrusqWlzfFrXtE0hPuMgW8A2HmaqrPo4ZQHRN/V0QXN3FSjKxpsbRrFc5LI7KOwBsT1/w==
|
||||
|
||||
to-fast-properties@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e"
|
||||
@@ -1330,11 +1670,21 @@ tslib@^1.9.3:
|
||||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
|
||||
integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
|
||||
|
||||
type-detect@^4.0.0, type-detect@^4.0.5:
|
||||
version "4.0.8"
|
||||
resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c"
|
||||
integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==
|
||||
|
||||
typescript@^4.7.4:
|
||||
version "4.9.5"
|
||||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a"
|
||||
integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==
|
||||
|
||||
ufo@^1.1.2:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/ufo/-/ufo-1.2.0.tgz#28d127a087a46729133fdc89cb1358508b3f80ba"
|
||||
integrity sha512-RsPyTbqORDNDxqAdQPQBpgqhWle1VcTSou/FraClYlHf6TZnQcGslpLcAphNR+sQW4q5lLWLbOsRlh9j24baQg==
|
||||
|
||||
update-browserslist-db@^1.0.11:
|
||||
version "1.0.11"
|
||||
resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.11.tgz#9a2a641ad2907ae7b3616506f4b977851db5b940"
|
||||
@@ -1348,6 +1698,18 @@ util-deprecate@^1.0.2:
|
||||
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
|
||||
integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==
|
||||
|
||||
vite-node@0.32.4:
|
||||
version "0.32.4"
|
||||
resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-0.32.4.tgz#7b3f94af5a87c631fbc380ba662914bafbd04d80"
|
||||
integrity sha512-L2gIw+dCxO0LK14QnUMoqSYpa9XRGnTTTDjW2h19Mr+GR0EFj4vx52W41gFXfMLqpA00eK4ZjOVYo1Xk//LFEw==
|
||||
dependencies:
|
||||
cac "^6.7.14"
|
||||
debug "^4.3.4"
|
||||
mlly "^1.4.0"
|
||||
pathe "^1.1.1"
|
||||
picocolors "^1.0.0"
|
||||
vite "^3.0.0 || ^4.0.0"
|
||||
|
||||
vite-plugin-rewrite-all@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/vite-plugin-rewrite-all/-/vite-plugin-rewrite-all-1.0.1.tgz#ee711a3d114634abb922a0e50e56736d7e9a324a"
|
||||
@@ -1374,7 +1736,7 @@ vite-tsconfig-paths@^3.5.0:
|
||||
recrawl-sync "^2.0.3"
|
||||
tsconfig-paths "^4.0.0"
|
||||
|
||||
vite@^4.3.9:
|
||||
"vite@^3.0.0 || ^4.0.0", vite@^4.3.9:
|
||||
version "4.4.9"
|
||||
resolved "https://registry.yarnpkg.com/vite/-/vite-4.4.9.tgz#1402423f1a2f8d66fd8d15e351127c7236d29d3d"
|
||||
integrity sha512-2mbUn2LlUmNASWwSCNSJ/EG2HuSRTnVNaydp6vMCm5VIqJsjMfbIWtbH2kDuwUVW5mMUKKZvGPX/rqeqVvv1XA==
|
||||
@@ -1385,6 +1747,44 @@ vite@^4.3.9:
|
||||
optionalDependencies:
|
||||
fsevents "~2.3.2"
|
||||
|
||||
vitest@^0.32.0:
|
||||
version "0.32.4"
|
||||
resolved "https://registry.yarnpkg.com/vitest/-/vitest-0.32.4.tgz#a0558ae44c2ccdc254eece0365f16c4ffc5231bb"
|
||||
integrity sha512-3czFm8RnrsWwIzVDu/Ca48Y/M+qh3vOnF16czJm98Q/AN1y3B6PBsyV8Re91Ty5s7txKNjEhpgtGPcfdbh2MZg==
|
||||
dependencies:
|
||||
"@types/chai" "^4.3.5"
|
||||
"@types/chai-subset" "^1.3.3"
|
||||
"@types/node" "*"
|
||||
"@vitest/expect" "0.32.4"
|
||||
"@vitest/runner" "0.32.4"
|
||||
"@vitest/snapshot" "0.32.4"
|
||||
"@vitest/spy" "0.32.4"
|
||||
"@vitest/utils" "0.32.4"
|
||||
acorn "^8.9.0"
|
||||
acorn-walk "^8.2.0"
|
||||
cac "^6.7.14"
|
||||
chai "^4.3.7"
|
||||
debug "^4.3.4"
|
||||
local-pkg "^0.4.3"
|
||||
magic-string "^0.30.0"
|
||||
pathe "^1.1.1"
|
||||
picocolors "^1.0.0"
|
||||
std-env "^3.3.3"
|
||||
strip-literal "^1.0.1"
|
||||
tinybench "^2.5.0"
|
||||
tinypool "^0.5.0"
|
||||
vite "^3.0.0 || ^4.0.0"
|
||||
vite-node "0.32.4"
|
||||
why-is-node-running "^2.2.2"
|
||||
|
||||
why-is-node-running@^2.2.2:
|
||||
version "2.2.2"
|
||||
resolved "https://registry.yarnpkg.com/why-is-node-running/-/why-is-node-running-2.2.2.tgz#4185b2b4699117819e7154594271e7e344c9973e"
|
||||
integrity sha512-6tSwToZxTOcotxHeA+qGCq1mVzKR3CwcJGmVcY+QE8SHy6TnpFnh8PAvPNHYr7EcuVeG0QSMxtYCuO1ta/G/oA==
|
||||
dependencies:
|
||||
siginfo "^2.0.0"
|
||||
stackback "0.0.2"
|
||||
|
||||
wrappy@1:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
|
||||
@@ -1399,3 +1799,8 @@ yaml@^2.1.1:
|
||||
version "2.3.1"
|
||||
resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.3.1.tgz#02fe0975d23cd441242aa7204e09fc28ac2ac33b"
|
||||
integrity sha512-2eHWfjaoXgTBC2jNM1LRef62VQa0umtvRiDSk6HSzW7RvS5YtkabJrwYLLEKWBc8a5U2PTSCs+dJjUTJdlHsWQ==
|
||||
|
||||
yocto-queue@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.0.0.tgz#7f816433fb2cbc511ec8bf7d263c3b58a1a3c251"
|
||||
integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==
|
||||
998
clientupdate/clientupdate.go
Normal file
998
clientupdate/clientupdate.go
Normal file
@@ -0,0 +1,998 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package clientupdate implements tailscale client update for all supported
|
||||
// platforms. This package can be used from both tailscaled and tailscale
|
||||
// binaries.
|
||||
package clientupdate
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"tailscale.com/hostinfo"
|
||||
"tailscale.com/net/tshttpproxy"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/util/must"
|
||||
"tailscale.com/util/winutil"
|
||||
"tailscale.com/version"
|
||||
"tailscale.com/version/distro"
|
||||
)
|
||||
|
||||
const (
|
||||
CurrentTrack = ""
|
||||
StableTrack = "stable"
|
||||
UnstableTrack = "unstable"
|
||||
)
|
||||
|
||||
func versionToTrack(v string) (string, error) {
|
||||
_, rest, ok := strings.Cut(v, ".")
|
||||
if !ok {
|
||||
return "", fmt.Errorf("malformed version %q", v)
|
||||
}
|
||||
minorStr, _, ok := strings.Cut(rest, ".")
|
||||
if !ok {
|
||||
return "", fmt.Errorf("malformed version %q", v)
|
||||
}
|
||||
minor, err := strconv.Atoi(minorStr)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("malformed version %q", v)
|
||||
}
|
||||
if minor%2 == 0 {
|
||||
return "stable", nil
|
||||
}
|
||||
return "unstable", nil
|
||||
}
|
||||
|
||||
type updater struct {
|
||||
UpdateArgs
|
||||
track string
|
||||
update func() error
|
||||
}
|
||||
|
||||
// UpdateArgs contains arguments needed to run an update.
|
||||
type UpdateArgs struct {
|
||||
// Version can be a specific version number or one of the predefined track
|
||||
// constants:
|
||||
//
|
||||
// - CurrentTrack will use the latest version from the same track as the
|
||||
// running binary
|
||||
// - StableTrack and UnstableTrack will use the latest versions of the
|
||||
// corresponding tracks
|
||||
//
|
||||
// Leaving this empty is the same as using CurrentTrack.
|
||||
Version string
|
||||
// AppStore forces a local app store check, even if the current binary was
|
||||
// not installed via an app store.
|
||||
AppStore bool
|
||||
// Logf is a logger for update progress messages.
|
||||
Logf logger.Logf
|
||||
// Confirm is called when a new version is available and should return true
|
||||
// if this new version should be installed. When Confirm returns false, the
|
||||
// update is aborted.
|
||||
Confirm func(newVer string) bool
|
||||
}
|
||||
|
||||
func (args UpdateArgs) validate() error {
|
||||
if args.Confirm == nil {
|
||||
return errors.New("missing Confirm callback in UpdateArgs")
|
||||
}
|
||||
if args.Logf == nil {
|
||||
return errors.New("missing Logf callback in UpdateArgs")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update runs a single update attempt using the platform-specific mechanism.
|
||||
//
|
||||
// On Windows, this copies the calling binary and re-executes it to apply the
|
||||
// update. The calling binary should handle an "update" subcommand and call
|
||||
// this function again for the re-executed binary to proceed.
|
||||
func Update(args UpdateArgs) error {
|
||||
if err := args.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
up := &updater{
|
||||
UpdateArgs: args,
|
||||
}
|
||||
switch up.Version {
|
||||
case StableTrack, UnstableTrack:
|
||||
up.track = up.Version
|
||||
case CurrentTrack:
|
||||
if version.IsUnstableBuild() {
|
||||
up.track = UnstableTrack
|
||||
} else {
|
||||
up.track = StableTrack
|
||||
}
|
||||
default:
|
||||
var err error
|
||||
up.track, err = versionToTrack(args.Version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
up.update = up.updateWindows
|
||||
case "linux":
|
||||
switch distro.Get() {
|
||||
case distro.Synology:
|
||||
up.update = up.updateSynology
|
||||
case distro.Debian: // includes Ubuntu
|
||||
up.update = up.updateDebLike
|
||||
case distro.Arch:
|
||||
up.update = up.updateArchLike
|
||||
case distro.Alpine:
|
||||
up.update = up.updateAlpineLike
|
||||
}
|
||||
switch {
|
||||
case haveExecutable("pacman"):
|
||||
up.update = up.updateArchLike
|
||||
case haveExecutable("apt-get"): // TODO(awly): add support for "apt"
|
||||
// The distro.Debian switch case above should catch most apt-based
|
||||
// systems, but add this fallback just in case.
|
||||
up.update = up.updateDebLike
|
||||
case haveExecutable("dnf"):
|
||||
up.update = up.updateFedoraLike("dnf")
|
||||
case haveExecutable("yum"):
|
||||
up.update = up.updateFedoraLike("yum")
|
||||
case haveExecutable("apk"):
|
||||
up.update = up.updateAlpineLike
|
||||
}
|
||||
case "darwin":
|
||||
switch {
|
||||
case !args.AppStore && !version.IsSandboxedMacOS():
|
||||
return errors.ErrUnsupported
|
||||
case !args.AppStore && strings.HasSuffix(os.Getenv("HOME"), "/io.tailscale.ipn.macsys/Data"):
|
||||
up.update = up.updateMacSys
|
||||
default:
|
||||
up.update = up.updateMacAppStore
|
||||
}
|
||||
case "freebsd":
|
||||
up.update = up.updateFreeBSD
|
||||
}
|
||||
if up.update == nil {
|
||||
return errors.ErrUnsupported
|
||||
}
|
||||
return up.update()
|
||||
}
|
||||
|
||||
func (up *updater) confirm(ver string) bool {
|
||||
if version.Short() == ver {
|
||||
up.Logf("already running %v; no update needed", ver)
|
||||
return false
|
||||
}
|
||||
if up.Confirm != nil {
|
||||
return up.Confirm(ver)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (up *updater) updateSynology() error {
|
||||
if up.Version != "" {
|
||||
return errors.New("installing a specific version on Synology is not supported")
|
||||
}
|
||||
|
||||
// Get the latest version and list of SPKs from pkgs.tailscale.com.
|
||||
osName := fmt.Sprintf("dsm%d", distro.DSMVersion())
|
||||
arch, err := synoArch(hostinfo.New())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
latest, err := latestPackages(up.track)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if latest.Version == "" {
|
||||
return fmt.Errorf("no latest version found for %q track", up.track)
|
||||
}
|
||||
spkName := latest.SPKs[osName][arch]
|
||||
if spkName == "" {
|
||||
return fmt.Errorf("cannot find Synology package for os=%s arch=%s, please report a bug with your device model", osName, arch)
|
||||
}
|
||||
|
||||
if !up.confirm(latest.Version) {
|
||||
return nil
|
||||
}
|
||||
if err := requireRoot(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Download the SPK into a temporary directory.
|
||||
spkDir, err := os.MkdirTemp("", "tailscale-update")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
url := fmt.Sprintf("https://pkgs.tailscale.com/%s/%s", up.track, spkName)
|
||||
spkPath := filepath.Join(spkDir, path.Base(url))
|
||||
// TODO(awly): we should sign SPKs and validate signatures here too.
|
||||
if err := up.downloadURLToFile(url, spkPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Install the SPK. Run via nohup to allow install to succeed when we're
|
||||
// connected over tailscale ssh and this parent process dies. Otherwise, if
|
||||
// you abort synopkg install mid-way, tailscaled is not restarted.
|
||||
cmd := exec.Command("nohup", "synopkg", "install", spkPath)
|
||||
// Don't attach cmd.Stdout to os.Stdout because nohup will redirect that
|
||||
// into nohup.out file. synopkg doesn't have any progress output anyway, it
|
||||
// just spits out a JSON result when done.
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("synopkg install failed: %w\noutput:\n%s", err, out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// synoArch returns the Synology CPU architecture matching one of the SPK
|
||||
// architectures served from pkgs.tailscale.com.
|
||||
func synoArch(hinfo *tailcfg.Hostinfo) (string, error) {
|
||||
// Most Synology boxes just use a different arch name from GOARCH.
|
||||
arch := map[string]string{
|
||||
"amd64": "x86_64",
|
||||
"386": "i686",
|
||||
"arm64": "armv8",
|
||||
}[hinfo.GoArch]
|
||||
// Here's the fun part, some older ARM boxes require you to use SPKs
|
||||
// specifically for their CPU.
|
||||
//
|
||||
// See https://github.com/SynoCommunity/spksrc/wiki/Synology-and-SynoCommunity-Package-Architectures
|
||||
// for a complete list. Here, we override GOARCH for those older boxes that
|
||||
// support at least DSM6.
|
||||
//
|
||||
// This is an artisanal hand-crafted list based on the wiki page. Some
|
||||
// values may be wrong, since we don't have all those devices to actually
|
||||
// test with.
|
||||
switch hinfo.DeviceModel {
|
||||
case "DS213air", "DS213", "DS413j",
|
||||
"DS112", "DS112+", "DS212", "DS212+", "RS212", "RS812", "DS212j", "DS112j",
|
||||
"DS111", "DS211", "DS211+", "DS411slim", "DS411", "RS411", "DS211j", "DS411j":
|
||||
arch = "88f6281"
|
||||
case "NVR1218", "NVR216", "VS960HD", "VS360HD":
|
||||
arch = "hi3535"
|
||||
case "DS1517", "DS1817", "DS416", "DS2015xs", "DS715", "DS1515", "DS215+":
|
||||
arch = "alpine"
|
||||
case "DS216se", "DS115j", "DS114", "DS214se", "DS414slim", "RS214", "DS14", "EDS14", "DS213j":
|
||||
arch = "armada370"
|
||||
case "DS115", "DS215j":
|
||||
arch = "armada375"
|
||||
case "DS419slim", "DS218j", "RS217", "DS116", "DS216j", "DS216", "DS416slim", "RS816", "DS416j":
|
||||
arch = "armada38x"
|
||||
case "RS815", "DS214", "DS214+", "DS414", "RS814":
|
||||
arch = "armadaxp"
|
||||
case "DS414j":
|
||||
arch = "comcerto2k"
|
||||
case "DS216play":
|
||||
arch = "monaco"
|
||||
}
|
||||
if arch == "" {
|
||||
return "", fmt.Errorf("cannot determine CPU architecture for Synology model %q (Go arch %q), please report a bug at https://github.com/tailscale/tailscale/issues/new/choose", hinfo.DeviceModel, hinfo.GoArch)
|
||||
}
|
||||
return arch, nil
|
||||
}
|
||||
|
||||
func (up *updater) updateDebLike() error {
|
||||
ver, err := requestedTailscaleVersion(up.Version, up.track)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !up.confirm(ver) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := requireRoot(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if updated, err := updateDebianAptSourcesList(up.track); err != nil {
|
||||
return err
|
||||
} else if updated {
|
||||
up.Logf("Updated %s to use the %s track", aptSourcesFile, up.track)
|
||||
}
|
||||
|
||||
cmd := exec.Command("apt-get", "update",
|
||||
// Only update the tailscale repo, not the other ones, treating
|
||||
// the tailscale.list file as the main "sources.list" file.
|
||||
"-o", "Dir::Etc::SourceList=sources.list.d/tailscale.list",
|
||||
// Disable the "sources.list.d" directory:
|
||||
"-o", "Dir::Etc::SourceParts=-",
|
||||
// Don't forget about packages in the other repos just because
|
||||
// we're not updating them:
|
||||
"-o", "APT::Get::List-Cleanup=0",
|
||||
)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd = exec.Command("apt-get", "install", "--yes", "--allow-downgrades", "tailscale="+ver)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
const aptSourcesFile = "/etc/apt/sources.list.d/tailscale.list"
|
||||
|
||||
// updateDebianAptSourcesList updates the /etc/apt/sources.list.d/tailscale.list
|
||||
// file to make sure it has the provided track (stable or unstable) in it.
|
||||
//
|
||||
// If it already has the right track (including containing both stable and
|
||||
// unstable), it does nothing.
|
||||
func updateDebianAptSourcesList(dstTrack string) (rewrote bool, err error) {
|
||||
was, err := os.ReadFile(aptSourcesFile)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
newContent, err := updateDebianAptSourcesListBytes(was, dstTrack)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if bytes.Equal(was, newContent) {
|
||||
return false, nil
|
||||
}
|
||||
return true, os.WriteFile(aptSourcesFile, newContent, 0644)
|
||||
}
|
||||
|
||||
func updateDebianAptSourcesListBytes(was []byte, dstTrack string) (newContent []byte, err error) {
|
||||
trackURLPrefix := []byte("https://pkgs.tailscale.com/" + dstTrack + "/")
|
||||
var buf bytes.Buffer
|
||||
var changes int
|
||||
bs := bufio.NewScanner(bytes.NewReader(was))
|
||||
hadCorrect := false
|
||||
commentLine := regexp.MustCompile(`^\s*\#`)
|
||||
pkgsURL := regexp.MustCompile(`\bhttps://pkgs\.tailscale\.com/((un)?stable)/`)
|
||||
for bs.Scan() {
|
||||
line := bs.Bytes()
|
||||
if !commentLine.Match(line) {
|
||||
line = pkgsURL.ReplaceAllFunc(line, func(m []byte) []byte {
|
||||
if bytes.Equal(m, trackURLPrefix) {
|
||||
hadCorrect = true
|
||||
} else {
|
||||
changes++
|
||||
}
|
||||
return trackURLPrefix
|
||||
})
|
||||
}
|
||||
buf.Write(line)
|
||||
buf.WriteByte('\n')
|
||||
}
|
||||
if hadCorrect || (changes == 1 && bytes.Equal(bytes.TrimSpace(was), bytes.TrimSpace(buf.Bytes()))) {
|
||||
// Unchanged or close enough.
|
||||
return was, nil
|
||||
}
|
||||
if changes != 1 {
|
||||
// No changes, or an unexpected number of changes (what?). Bail.
|
||||
// They probably editted it by hand and we don't know what to do.
|
||||
return nil, fmt.Errorf("unexpected/unsupported %s contents", aptSourcesFile)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func (up *updater) updateArchLike() (err error) {
|
||||
if up.Version != "" {
|
||||
return errors.New("installing a specific version on Arch-based distros is not supported")
|
||||
}
|
||||
if err := requireRoot(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf(`%w; you can try updating using "pacman --sync --refresh tailscale"`, err)
|
||||
}
|
||||
}()
|
||||
|
||||
out, err := exec.Command("pacman", "--sync", "--refresh", "--info", "tailscale").CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed checking pacman for latest tailscale version: %w, output: %q", err, out)
|
||||
}
|
||||
ver, err := parsePacmanVersion(out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !up.confirm(ver) {
|
||||
return nil
|
||||
}
|
||||
|
||||
cmd := exec.Command("pacman", "--sync", "--noconfirm", "tailscale")
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed tailscale update using pacman: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parsePacmanVersion(out []byte) (string, error) {
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
// The line we're looking for looks like this:
|
||||
// Version : 1.44.2-1
|
||||
if !strings.HasPrefix(line, "Version") {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return "", fmt.Errorf("version output from pacman is malformed: %q, cannot determine upgrade version", line)
|
||||
}
|
||||
ver := strings.TrimSpace(parts[1])
|
||||
// Trim the Arch patch version.
|
||||
ver = strings.Split(ver, "-")[0]
|
||||
if ver == "" {
|
||||
return "", fmt.Errorf("version output from pacman is malformed: %q, cannot determine upgrade version", line)
|
||||
}
|
||||
return ver, nil
|
||||
}
|
||||
return "", fmt.Errorf("could not find latest version of tailscale via pacman")
|
||||
}
|
||||
|
||||
const yumRepoConfigFile = "/etc/yum.repos.d/tailscale.repo"
|
||||
|
||||
// updateFedoraLike updates tailscale on any distros in the Fedora family,
|
||||
// specifically anything that uses "dnf" or "yum" package managers. The actual
|
||||
// package manager is passed via packageManager.
|
||||
func (up *updater) updateFedoraLike(packageManager string) func() error {
|
||||
return func() (err error) {
|
||||
if err := requireRoot(); err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf(`%w; you can try updating using "%s upgrade tailscale"`, err, packageManager)
|
||||
}
|
||||
}()
|
||||
|
||||
ver, err := requestedTailscaleVersion(up.Version, up.track)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !up.confirm(ver) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if updated, err := updateYUMRepoTrack(yumRepoConfigFile, up.track); err != nil {
|
||||
return err
|
||||
} else if updated {
|
||||
up.Logf("Updated %s to use the %s track", yumRepoConfigFile, up.track)
|
||||
}
|
||||
|
||||
cmd := exec.Command(packageManager, "install", "--assumeyes", fmt.Sprintf("tailscale-%s-1", ver))
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// updateYUMRepoTrack updates the repoFile file to make sure it has the
|
||||
// provided track (stable or unstable) in it.
|
||||
func updateYUMRepoTrack(repoFile, dstTrack string) (rewrote bool, err error) {
|
||||
was, err := os.ReadFile(repoFile)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
urlRe := regexp.MustCompile(`^(baseurl|gpgkey)=https://pkgs\.tailscale\.com/(un)?stable/`)
|
||||
urlReplacement := fmt.Sprintf("$1=https://pkgs.tailscale.com/%s/", dstTrack)
|
||||
|
||||
s := bufio.NewScanner(bytes.NewReader(was))
|
||||
newContent := bytes.NewBuffer(make([]byte, 0, len(was)))
|
||||
for s.Scan() {
|
||||
line := s.Text()
|
||||
// Handle repo section name, like "[tailscale-stable]".
|
||||
if len(line) > 0 && line[0] == '[' {
|
||||
if !strings.HasPrefix(line, "[tailscale-") {
|
||||
return false, fmt.Errorf("%q does not look like a tailscale repo file, it contains an unexpected %q section", repoFile, line)
|
||||
}
|
||||
fmt.Fprintf(newContent, "[tailscale-%s]\n", dstTrack)
|
||||
continue
|
||||
}
|
||||
// Update the track mentioned in repo name.
|
||||
if strings.HasPrefix(line, "name=") {
|
||||
fmt.Fprintf(newContent, "name=Tailscale %s\n", dstTrack)
|
||||
continue
|
||||
}
|
||||
// Update the actual repo URLs.
|
||||
if strings.HasPrefix(line, "baseurl=") || strings.HasPrefix(line, "gpgkey=") {
|
||||
fmt.Fprintln(newContent, urlRe.ReplaceAllString(line, urlReplacement))
|
||||
continue
|
||||
}
|
||||
fmt.Fprintln(newContent, line)
|
||||
}
|
||||
if bytes.Equal(was, newContent.Bytes()) {
|
||||
return false, nil
|
||||
}
|
||||
return true, os.WriteFile(repoFile, newContent.Bytes(), 0644)
|
||||
}
|
||||
|
||||
func (up *updater) updateAlpineLike() (err error) {
|
||||
if up.Version != "" {
|
||||
return errors.New("installing a specific version on Alpine-based distros is not supported")
|
||||
}
|
||||
if err := requireRoot(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf(`%w; you can try updating using "apk upgrade tailscale"`, err)
|
||||
}
|
||||
}()
|
||||
|
||||
out, err := exec.Command("apk", "update").CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed refresh apk repository indexes: %w, output: %q", err, out)
|
||||
}
|
||||
out, err = exec.Command("apk", "info", "tailscale").CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed checking apk for latest tailscale version: %w, output: %q", err, out)
|
||||
}
|
||||
ver, err := parseAlpinePackageVersion(out)
|
||||
if err != nil {
|
||||
return fmt.Errorf(`failed to parse latest version from "apk info tailscale": %w`, err)
|
||||
}
|
||||
if !up.confirm(ver) {
|
||||
return nil
|
||||
}
|
||||
|
||||
cmd := exec.Command("apk", "upgrade", "tailscale")
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed tailscale update using apk: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseAlpinePackageVersion(out []byte) (string, error) {
|
||||
s := bufio.NewScanner(bytes.NewReader(out))
|
||||
for s.Scan() {
|
||||
// The line should look like this:
|
||||
// tailscale-1.44.2-r0 description:
|
||||
line := strings.TrimSpace(s.Text())
|
||||
if !strings.HasPrefix(line, "tailscale-") {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(line, "-", 3)
|
||||
if len(parts) < 3 {
|
||||
return "", fmt.Errorf("malformed info line: %q", line)
|
||||
}
|
||||
return parts[1], nil
|
||||
}
|
||||
return "", errors.New("tailscale version not found in output")
|
||||
}
|
||||
|
||||
func (up *updater) updateMacSys() error {
|
||||
return errors.New("NOTREACHED: On MacSys builds, `tailscale update` is handled in Swift to launch the GUI updater")
|
||||
}
|
||||
|
||||
func (up *updater) updateMacAppStore() error {
|
||||
out, err := exec.Command("defaults", "read", "/Library/Preferences/com.apple.commerce.plist", "AutoUpdate").CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("can't check App Store auto-update setting: %w, output: %q", err, string(out))
|
||||
}
|
||||
const on = "1\n"
|
||||
if string(out) != on {
|
||||
up.Logf("NOTE: Automatic updating for App Store apps is turned off. You can change this setting in System Settings (search for ‘update’).")
|
||||
}
|
||||
|
||||
out, err = exec.Command("softwareupdate", "--list").CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("can't check App Store for available updates: %w, output: %q", err, string(out))
|
||||
}
|
||||
|
||||
newTailscale := parseSoftwareupdateList(out)
|
||||
if newTailscale == "" {
|
||||
up.Logf("no Tailscale update available")
|
||||
return nil
|
||||
}
|
||||
|
||||
newTailscaleVer := strings.TrimPrefix(newTailscale, "Tailscale-")
|
||||
if !up.confirm(newTailscaleVer) {
|
||||
return nil
|
||||
}
|
||||
|
||||
cmd := exec.Command("sudo", "softwareupdate", "--install", newTailscale)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("can't install App Store update for Tailscale: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var macOSAppStoreListPattern = regexp.MustCompile(`(?m)^\s+\*\s+Label:\s*(Tailscale-\d[\d\.]+)`)
|
||||
|
||||
// parseSoftwareupdateList searches the output of `softwareupdate --list` on
|
||||
// Darwin and returns the matching Tailscale package label. If there is none,
|
||||
// returns the empty string.
|
||||
//
|
||||
// See TestParseSoftwareupdateList for example inputs.
|
||||
func parseSoftwareupdateList(stdout []byte) string {
|
||||
matches := macOSAppStoreListPattern.FindSubmatch(stdout)
|
||||
if len(matches) < 2 {
|
||||
return ""
|
||||
}
|
||||
return string(matches[1])
|
||||
}
|
||||
|
||||
// winMSIEnv is the environment variable that, if set, is the MSI file for the
|
||||
// update command to install. It's passed like this so we can stop the
|
||||
// tailscale.exe process from running before the msiexec process runs and tries
|
||||
// to overwrite ourselves.
|
||||
const winMSIEnv = "TS_UPDATE_WIN_MSI"
|
||||
|
||||
var (
|
||||
verifyAuthenticode func(string) error // or nil on non-Windows
|
||||
markTempFileFunc func(string) error // or nil on non-Windows
|
||||
)
|
||||
|
||||
func (up *updater) updateWindows() error {
|
||||
if msi := os.Getenv(winMSIEnv); msi != "" {
|
||||
up.Logf("installing %v ...", msi)
|
||||
if err := up.installMSI(msi); err != nil {
|
||||
up.Logf("MSI install failed: %v", err)
|
||||
return err
|
||||
}
|
||||
up.Logf("success.")
|
||||
return nil
|
||||
}
|
||||
ver, err := requestedTailscaleVersion(up.Version, up.track)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
arch := runtime.GOARCH
|
||||
if arch == "386" {
|
||||
arch = "x86"
|
||||
}
|
||||
|
||||
if !up.confirm(ver) {
|
||||
return nil
|
||||
}
|
||||
if !winutil.IsCurrentProcessElevated() {
|
||||
return errors.New("must be run as Administrator")
|
||||
}
|
||||
|
||||
tsDir := filepath.Join(os.Getenv("ProgramData"), "Tailscale")
|
||||
msiDir := filepath.Join(tsDir, "MSICache")
|
||||
if fi, err := os.Stat(tsDir); err != nil {
|
||||
return fmt.Errorf("expected %s to exist, got stat error: %w", tsDir, err)
|
||||
} else if !fi.IsDir() {
|
||||
return fmt.Errorf("expected %s to be a directory; got %v", tsDir, fi.Mode())
|
||||
}
|
||||
if err := os.MkdirAll(msiDir, 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
url := fmt.Sprintf("https://pkgs.tailscale.com/%s/tailscale-setup-%s-%s.msi", up.track, ver, arch)
|
||||
msiTarget := filepath.Join(msiDir, path.Base(url))
|
||||
if err := up.downloadURLToFile(url, msiTarget); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
up.Logf("verifying MSI authenticode...")
|
||||
if err := verifyAuthenticode(msiTarget); err != nil {
|
||||
return fmt.Errorf("authenticode verification of %s failed: %w", msiTarget, err)
|
||||
}
|
||||
up.Logf("authenticode verification succeeded")
|
||||
|
||||
up.Logf("making tailscale.exe copy to switch to...")
|
||||
selfCopy, err := makeSelfCopy()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(selfCopy)
|
||||
up.Logf("running tailscale.exe copy for final install...")
|
||||
|
||||
cmd := exec.Command(selfCopy, "update")
|
||||
cmd.Env = append(os.Environ(), winMSIEnv+"="+msiTarget)
|
||||
cmd.Stdout = os.Stderr
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Stdin = os.Stdin
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
// Once it's started, exit ourselves, so the binary is free
|
||||
// to be replaced.
|
||||
os.Exit(0)
|
||||
panic("unreachable")
|
||||
}
|
||||
|
||||
func (up *updater) installMSI(msi string) error {
|
||||
var err error
|
||||
for tries := 0; tries < 2; tries++ {
|
||||
cmd := exec.Command("msiexec.exe", "/i", filepath.Base(msi), "/quiet", "/promptrestart", "/qn")
|
||||
cmd.Dir = filepath.Dir(msi)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Stdin = os.Stdin
|
||||
err = cmd.Run()
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
uninstallVersion := version.Short()
|
||||
if v := os.Getenv("TS_DEBUG_UNINSTALL_VERSION"); v != "" {
|
||||
uninstallVersion = v
|
||||
}
|
||||
// Assume it's a downgrade, which msiexec won't permit. Uninstall our current version first.
|
||||
up.Logf("Uninstalling current version %q for downgrade...", uninstallVersion)
|
||||
cmd = exec.Command("msiexec.exe", "/x", msiUUIDForVersion(uninstallVersion), "/norestart", "/qn")
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Stdin = os.Stdin
|
||||
err = cmd.Run()
|
||||
up.Logf("msiexec uninstall: %v", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func msiUUIDForVersion(ver string) string {
|
||||
arch := runtime.GOARCH
|
||||
if arch == "386" {
|
||||
arch = "x86"
|
||||
}
|
||||
track, err := versionToTrack(ver)
|
||||
if err != nil {
|
||||
track = UnstableTrack
|
||||
}
|
||||
msiURL := fmt.Sprintf("https://pkgs.tailscale.com/%s/tailscale-setup-%s-%s.msi", track, ver, arch)
|
||||
return "{" + strings.ToUpper(uuid.NewSHA1(uuid.NameSpaceURL, []byte(msiURL)).String()) + "}"
|
||||
}
|
||||
|
||||
func makeSelfCopy() (tmpPathExe string, err error) {
|
||||
selfExe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
f, err := os.Open(selfExe)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
f2, err := os.CreateTemp("", "tailscale-updater-*.exe")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if f := markTempFileFunc; f != nil {
|
||||
if err := f(f2.Name()); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if _, err := io.Copy(f2, f); err != nil {
|
||||
f2.Close()
|
||||
return "", err
|
||||
}
|
||||
return f2.Name(), f2.Close()
|
||||
}
|
||||
|
||||
func (up *updater) downloadURLToFile(urlSrc, fileDst string) (ret error) {
|
||||
tr := http.DefaultTransport.(*http.Transport).Clone()
|
||||
tr.Proxy = tshttpproxy.ProxyFromEnvironment
|
||||
defer tr.CloseIdleConnections()
|
||||
c := &http.Client{Transport: tr}
|
||||
|
||||
quickCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
headReq := must.Get(http.NewRequestWithContext(quickCtx, "HEAD", urlSrc, nil))
|
||||
|
||||
res, err := c.Do(headReq)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("HEAD %s: %v", urlSrc, res.Status)
|
||||
}
|
||||
if res.ContentLength <= 0 {
|
||||
return fmt.Errorf("HEAD %s: unexpected Content-Length %v", urlSrc, res.ContentLength)
|
||||
}
|
||||
up.Logf("Download size: %v", res.ContentLength)
|
||||
|
||||
hashReq := must.Get(http.NewRequestWithContext(quickCtx, "GET", urlSrc+".sha256", nil))
|
||||
hashRes, err := c.Do(hashReq)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hashHex, err := io.ReadAll(io.LimitReader(hashRes.Body, 100))
|
||||
hashRes.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("GET %s.sha256: %v", urlSrc, res.Status)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
wantHash, err := hex.DecodeString(string(strings.TrimSpace(string(hashHex))))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hash := sha256.New()
|
||||
|
||||
dlReq := must.Get(http.NewRequestWithContext(context.Background(), "GET", urlSrc, nil))
|
||||
dlRes, err := c.Do(dlReq)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// TODO(bradfitz): resume from existing partial file on disk
|
||||
if dlRes.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("GET %s: %v", urlSrc, dlRes.Status)
|
||||
}
|
||||
|
||||
of, err := os.Create(fileDst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if ret != nil {
|
||||
of.Close()
|
||||
// TODO(bradfitz): os.Remove(fileDst) too? or keep it to resume from/debug later.
|
||||
}
|
||||
}()
|
||||
pw := &progressWriter{total: res.ContentLength, logf: up.Logf}
|
||||
n, err := io.Copy(io.MultiWriter(hash, of, pw), io.LimitReader(dlRes.Body, res.ContentLength))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n != res.ContentLength {
|
||||
return fmt.Errorf("downloaded %v; want %v", n, res.ContentLength)
|
||||
}
|
||||
if err := of.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
pw.print()
|
||||
|
||||
if !bytes.Equal(hash.Sum(nil), wantHash) {
|
||||
return fmt.Errorf("SHA-256 of downloaded MSI didn't match expected value")
|
||||
}
|
||||
up.Logf("hash matched")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type progressWriter struct {
|
||||
done int64
|
||||
total int64
|
||||
lastPrint time.Time
|
||||
logf logger.Logf
|
||||
}
|
||||
|
||||
func (pw *progressWriter) Write(p []byte) (n int, err error) {
|
||||
pw.done += int64(len(p))
|
||||
if time.Since(pw.lastPrint) > 2*time.Second {
|
||||
pw.print()
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (pw *progressWriter) print() {
|
||||
pw.lastPrint = time.Now()
|
||||
pw.logf("Downloaded %v/%v (%.1f%%)", pw.done, pw.total, float64(pw.done)/float64(pw.total)*100)
|
||||
}
|
||||
|
||||
func (up *updater) updateFreeBSD() (err error) {
|
||||
if up.Version != "" {
|
||||
return errors.New("installing a specific version on FreeBSD is not supported")
|
||||
}
|
||||
if err := requireRoot(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf(`%w; you can try updating using "pkg upgrade tailscale"`, err)
|
||||
}
|
||||
}()
|
||||
|
||||
out, err := exec.Command("pkg", "update").CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed refresh pkg repository indexes: %w, output: %q", err, out)
|
||||
}
|
||||
out, err = exec.Command("pkg", "rquery", "%v", "tailscale").CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed checking pkg for latest tailscale version: %w, output: %q", err, out)
|
||||
}
|
||||
ver := string(bytes.TrimSpace(out))
|
||||
if !up.confirm(ver) {
|
||||
return nil
|
||||
}
|
||||
|
||||
cmd := exec.Command("pkg", "upgrade", "tailscale")
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed tailscale update using pkg: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func haveExecutable(name string) bool {
|
||||
path, err := exec.LookPath(name)
|
||||
return err == nil && path != ""
|
||||
}
|
||||
|
||||
func requestedTailscaleVersion(ver, track string) (string, error) {
|
||||
if ver != "" {
|
||||
return ver, nil
|
||||
}
|
||||
return LatestTailscaleVersion(track)
|
||||
}
|
||||
|
||||
// LatestTailscaleVersion returns the latest released version for the given
|
||||
// track from pkgs.tailscale.com.
|
||||
func LatestTailscaleVersion(track string) (string, error) {
|
||||
if track == CurrentTrack {
|
||||
if version.IsUnstableBuild() {
|
||||
track = UnstableTrack
|
||||
} else {
|
||||
track = StableTrack
|
||||
}
|
||||
}
|
||||
|
||||
latest, err := latestPackages(track)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if latest.Version == "" {
|
||||
return "", fmt.Errorf("no latest version found for %q track", track)
|
||||
}
|
||||
return latest.Version, nil
|
||||
}
|
||||
|
||||
type trackPackages struct {
|
||||
Version string
|
||||
Tarballs map[string]string
|
||||
Exes []string
|
||||
MSIs map[string]string
|
||||
MacZips map[string]string
|
||||
SPKs map[string]map[string]string
|
||||
}
|
||||
|
||||
func latestPackages(track string) (*trackPackages, error) {
|
||||
url := fmt.Sprintf("https://pkgs.tailscale.com/%s/?mode=json&os=%s", track, runtime.GOOS)
|
||||
res, err := http.Get(url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetching latest tailscale version: %w", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
var latest trackPackages
|
||||
if err := json.NewDecoder(res.Body).Decode(&latest); err != nil {
|
||||
return nil, fmt.Errorf("decoding JSON: %v: %w", res.Status, err)
|
||||
}
|
||||
return &latest, nil
|
||||
}
|
||||
|
||||
func requireRoot() error {
|
||||
if os.Geteuid() == 0 {
|
||||
return nil
|
||||
}
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
return errors.New("must be root; use sudo")
|
||||
case "freebsd", "openbsd":
|
||||
return errors.New("must be root; use doas")
|
||||
default:
|
||||
return errors.New("must be root")
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package cli
|
||||
package clientupdate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
func TestUpdateDebianAptSourcesListBytes(t *testing.T) {
|
||||
@@ -19,38 +22,38 @@ func TestUpdateDebianAptSourcesListBytes(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "stable-to-unstable",
|
||||
toTrack: "unstable",
|
||||
toTrack: UnstableTrack,
|
||||
in: "# Tailscale packages for debian buster\ndeb https://pkgs.tailscale.com/stable/debian bullseye main\n",
|
||||
want: "# Tailscale packages for debian buster\ndeb https://pkgs.tailscale.com/unstable/debian bullseye main\n",
|
||||
},
|
||||
{
|
||||
name: "stable-unchanged",
|
||||
toTrack: "stable",
|
||||
toTrack: StableTrack,
|
||||
in: "# Tailscale packages for debian buster\ndeb https://pkgs.tailscale.com/stable/debian bullseye main\n",
|
||||
},
|
||||
{
|
||||
name: "if-both-stable-and-unstable-dont-change",
|
||||
toTrack: "stable",
|
||||
toTrack: StableTrack,
|
||||
in: "# Tailscale packages for debian buster\n" +
|
||||
"deb https://pkgs.tailscale.com/stable/debian bullseye main\n" +
|
||||
"deb https://pkgs.tailscale.com/unstable/debian bullseye main\n",
|
||||
},
|
||||
{
|
||||
name: "if-both-stable-and-unstable-dont-change-unstable",
|
||||
toTrack: "unstable",
|
||||
toTrack: UnstableTrack,
|
||||
in: "# Tailscale packages for debian buster\n" +
|
||||
"deb https://pkgs.tailscale.com/stable/debian bullseye main\n" +
|
||||
"deb https://pkgs.tailscale.com/unstable/debian bullseye main\n",
|
||||
},
|
||||
{
|
||||
name: "signed-by-form",
|
||||
toTrack: "unstable",
|
||||
toTrack: UnstableTrack,
|
||||
in: "# Tailscale packages for ubuntu jammy\ndeb [signed-by=/usr/share/keyrings/tailscale-archive-keyring.gpg] https://pkgs.tailscale.com/stable/ubuntu jammy main\n",
|
||||
want: "# Tailscale packages for ubuntu jammy\ndeb [signed-by=/usr/share/keyrings/tailscale-archive-keyring.gpg] https://pkgs.tailscale.com/unstable/ubuntu jammy main\n",
|
||||
},
|
||||
{
|
||||
name: "unsupported-lines",
|
||||
toTrack: "unstable",
|
||||
toTrack: UnstableTrack,
|
||||
in: "# Tailscale packages for ubuntu jammy\ndeb [signed-by=/usr/share/keyrings/tailscale-archive-keyring.gpg] https://pkgs.tailscale.com/foobar/ubuntu jammy main\n",
|
||||
wantErr: "unexpected/unsupported /etc/apt/sources.list.d/tailscale.list contents",
|
||||
},
|
||||
@@ -279,7 +282,7 @@ repo_gpgcheck=1
|
||||
gpgcheck=0
|
||||
gpgkey=https://pkgs.tailscale.com/stable/fedora/repo.gpg
|
||||
`,
|
||||
track: "stable",
|
||||
track: StableTrack,
|
||||
after: `
|
||||
[tailscale-stable]
|
||||
name=Tailscale stable
|
||||
@@ -303,7 +306,7 @@ repo_gpgcheck=1
|
||||
gpgcheck=0
|
||||
gpgkey=https://pkgs.tailscale.com/stable/fedora/repo.gpg
|
||||
`,
|
||||
track: "unstable",
|
||||
track: UnstableTrack,
|
||||
after: `
|
||||
[tailscale-unstable]
|
||||
name=Tailscale unstable
|
||||
@@ -332,7 +335,7 @@ gpgcheck=1
|
||||
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-fedora-$releasever-$basearch
|
||||
skip_if_unavailable=False
|
||||
`,
|
||||
track: "stable",
|
||||
track: StableTrack,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
@@ -440,3 +443,44 @@ tailscale installed size:
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSynoArch(t *testing.T) {
|
||||
tests := []struct {
|
||||
goarch string
|
||||
model string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{goarch: "amd64", model: "DS224+", want: "x86_64"},
|
||||
{goarch: "arm64", model: "DS124", want: "armv8"},
|
||||
{goarch: "386", model: "DS415play", want: "i686"},
|
||||
{goarch: "arm", model: "DS213air", want: "88f6281"},
|
||||
{goarch: "arm", model: "NVR1218", want: "hi3535"},
|
||||
{goarch: "arm", model: "DS1517", want: "alpine"},
|
||||
{goarch: "arm", model: "DS216se", want: "armada370"},
|
||||
{goarch: "arm", model: "DS115", want: "armada375"},
|
||||
{goarch: "arm", model: "DS419slim", want: "armada38x"},
|
||||
{goarch: "arm", model: "RS815", want: "armadaxp"},
|
||||
{goarch: "arm", model: "DS414j", want: "comcerto2k"},
|
||||
{goarch: "arm", model: "DS216play", want: "monaco"},
|
||||
{goarch: "riscv64", model: "DS999", wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(fmt.Sprintf("%s-%s", tt.goarch, tt.model), func(t *testing.T) {
|
||||
got, err := synoArch(&tailcfg.Hostinfo{GoArch: tt.goarch, DeviceModel: tt.model})
|
||||
if err != nil {
|
||||
if !tt.wantErr {
|
||||
t.Fatalf("got unexpected error %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if tt.wantErr {
|
||||
t.Fatalf("got %q, expected an error", got)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("got %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,28 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Windows-specific stuff that can't go in update.go because it needs
|
||||
// Windows-specific stuff that can't go in clientupdate.go because it needs
|
||||
// x/sys/windows.
|
||||
|
||||
package cli
|
||||
package clientupdate
|
||||
|
||||
import (
|
||||
"golang.org/x/sys/windows"
|
||||
"tailscale.com/util/winutil/authenticode"
|
||||
)
|
||||
|
||||
func init() {
|
||||
markTempFileFunc = markTempFileWindows
|
||||
verifyAuthenticode = verifyTailscale
|
||||
}
|
||||
|
||||
func markTempFileWindows(name string) error {
|
||||
name16 := windows.StringToUTF16Ptr(name)
|
||||
return windows.MoveFileEx(name16, nil, windows.MOVEFILE_DELAY_UNTIL_REBOOT)
|
||||
}
|
||||
|
||||
const certSubjectTailscale = "Tailscale Inc."
|
||||
|
||||
func verifyTailscale(path string) error {
|
||||
return authenticode.Verify(path, certSubjectTailscale)
|
||||
}
|
||||
@@ -116,7 +116,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
tailscale.com/tka from tailscale.com/client/tailscale+
|
||||
W tailscale.com/tsconst from tailscale.com/net/interfaces
|
||||
tailscale.com/tstime from tailscale.com/derp+
|
||||
💣 tailscale.com/tstime/mono from tailscale.com/tstime/rate
|
||||
tailscale.com/tstime/mono from tailscale.com/tstime/rate
|
||||
tailscale.com/tstime/rate from tailscale.com/wgengine/filter+
|
||||
tailscale.com/tsweb from tailscale.com/cmd/derper
|
||||
tailscale.com/tsweb/promvarz from tailscale.com/tsweb
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -67,7 +68,7 @@ func startMeshWithHost(s *derp.Server, host string) error {
|
||||
return d.DialContext(ctx, network, addr)
|
||||
})
|
||||
|
||||
add := func(k key.NodePublic) { s.AddPacketForwarder(k, c) }
|
||||
add := func(k key.NodePublic, _ netip.AddrPort) { s.AddPacketForwarder(k, c) }
|
||||
remove := func(k key.NodePublic) { s.RemovePacketForwarder(k, c) }
|
||||
go c.RunWatchConnectionLoop(context.Background(), s.PublicKey(), logf, add, remove)
|
||||
return nil
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
/* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Copyright (C) 2019-2022 WireGuard LLC. All Rights Reserved.
|
||||
*/
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"tailscale.com/util/winutil/authenticode"
|
||||
)
|
||||
|
||||
func init() {
|
||||
verifyAuthenticode = verifyTailscale
|
||||
}
|
||||
|
||||
const certSubjectTailscale = "Tailscale Inc."
|
||||
|
||||
func verifyTailscale(path string) error {
|
||||
return authenticode.Verify(path, certSubjectTailscale)
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"tailscale.com/health/healthmsg"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/tka"
|
||||
"tailscale.com/tstest"
|
||||
"tailscale.com/types/persist"
|
||||
@@ -834,7 +835,7 @@ func TestUpdatePrefs(t *testing.T) {
|
||||
flags: []string{},
|
||||
curPrefs: &ipn.Prefs{
|
||||
ControlURL: ipn.DefaultControlURL,
|
||||
Persist: &persist.Persist{LoginName: "crawshaw.github"},
|
||||
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
|
||||
},
|
||||
env: upCheckEnv{
|
||||
backendState: "Stopped",
|
||||
@@ -846,7 +847,7 @@ func TestUpdatePrefs(t *testing.T) {
|
||||
flags: []string{},
|
||||
curPrefs: &ipn.Prefs{
|
||||
ControlURL: ipn.DefaultControlURL,
|
||||
Persist: &persist.Persist{LoginName: "crawshaw.github"},
|
||||
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
|
||||
},
|
||||
env: upCheckEnv{backendState: "Running"},
|
||||
wantSimpleUp: true,
|
||||
@@ -857,7 +858,7 @@ func TestUpdatePrefs(t *testing.T) {
|
||||
flags: []string{"--reset"},
|
||||
curPrefs: &ipn.Prefs{
|
||||
ControlURL: ipn.DefaultControlURL,
|
||||
Persist: &persist.Persist{LoginName: "crawshaw.github"},
|
||||
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
|
||||
},
|
||||
env: upCheckEnv{backendState: "Running"},
|
||||
wantJustEditMP: &ipn.MaskedPrefs{
|
||||
@@ -884,7 +885,7 @@ func TestUpdatePrefs(t *testing.T) {
|
||||
flags: []string{},
|
||||
curPrefs: &ipn.Prefs{
|
||||
ControlURL: "https://login.tailscale.com",
|
||||
Persist: &persist.Persist{LoginName: "crawshaw.github"},
|
||||
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
|
||||
},
|
||||
env: upCheckEnv{backendState: "Running"},
|
||||
wantSimpleUp: true,
|
||||
@@ -895,7 +896,7 @@ func TestUpdatePrefs(t *testing.T) {
|
||||
flags: []string{"--login-server=https://localhost:1000"},
|
||||
curPrefs: &ipn.Prefs{
|
||||
ControlURL: "https://login.tailscale.com",
|
||||
Persist: &persist.Persist{LoginName: "crawshaw.github"},
|
||||
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
|
||||
AllowSingleHosts: true,
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
@@ -910,7 +911,7 @@ func TestUpdatePrefs(t *testing.T) {
|
||||
flags: []string{"--advertise-tags=tag:foo"},
|
||||
curPrefs: &ipn.Prefs{
|
||||
ControlURL: "https://login.tailscale.com",
|
||||
Persist: &persist.Persist{LoginName: "crawshaw.github"},
|
||||
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
|
||||
AllowSingleHosts: true,
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
@@ -944,7 +945,7 @@ func TestUpdatePrefs(t *testing.T) {
|
||||
flags: []string{"--ssh"},
|
||||
curPrefs: &ipn.Prefs{
|
||||
ControlURL: "https://login.tailscale.com",
|
||||
Persist: &persist.Persist{LoginName: "crawshaw.github"},
|
||||
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
|
||||
AllowSingleHosts: true,
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
@@ -965,7 +966,7 @@ func TestUpdatePrefs(t *testing.T) {
|
||||
flags: []string{"--ssh=false"},
|
||||
curPrefs: &ipn.Prefs{
|
||||
ControlURL: "https://login.tailscale.com",
|
||||
Persist: &persist.Persist{LoginName: "crawshaw.github"},
|
||||
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
|
||||
AllowSingleHosts: true,
|
||||
CorpDNS: true,
|
||||
RunSSH: true,
|
||||
@@ -990,7 +991,7 @@ func TestUpdatePrefs(t *testing.T) {
|
||||
sshOverTailscale: true,
|
||||
curPrefs: &ipn.Prefs{
|
||||
ControlURL: "https://login.tailscale.com",
|
||||
Persist: &persist.Persist{LoginName: "crawshaw.github"},
|
||||
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
|
||||
AllowSingleHosts: true,
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
@@ -1014,7 +1015,7 @@ func TestUpdatePrefs(t *testing.T) {
|
||||
sshOverTailscale: true,
|
||||
curPrefs: &ipn.Prefs{
|
||||
ControlURL: "https://login.tailscale.com",
|
||||
Persist: &persist.Persist{LoginName: "crawshaw.github"},
|
||||
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
|
||||
AllowSingleHosts: true,
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
@@ -1037,7 +1038,7 @@ func TestUpdatePrefs(t *testing.T) {
|
||||
sshOverTailscale: true,
|
||||
curPrefs: &ipn.Prefs{
|
||||
ControlURL: "https://login.tailscale.com",
|
||||
Persist: &persist.Persist{LoginName: "crawshaw.github"},
|
||||
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
|
||||
AllowSingleHosts: true,
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
@@ -1059,7 +1060,7 @@ func TestUpdatePrefs(t *testing.T) {
|
||||
sshOverTailscale: true,
|
||||
curPrefs: &ipn.Prefs{
|
||||
ControlURL: "https://login.tailscale.com",
|
||||
Persist: &persist.Persist{LoginName: "crawshaw.github"},
|
||||
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
|
||||
AllowSingleHosts: true,
|
||||
CorpDNS: true,
|
||||
RunSSH: true,
|
||||
|
||||
@@ -127,6 +127,16 @@ var debugCmd = &ffcli.Command{
|
||||
Exec: localAPIAction("rebind"),
|
||||
ShortHelp: "force a magicsock rebind",
|
||||
},
|
||||
{
|
||||
Name: "break-tcp-conns",
|
||||
Exec: localAPIAction("break-tcp-conns"),
|
||||
ShortHelp: "break any open TCP connections from the daemon",
|
||||
},
|
||||
{
|
||||
Name: "break-derp-conns",
|
||||
Exec: localAPIAction("break-derp-conns"),
|
||||
ShortHelp: "break any open DERP connections from the daemon",
|
||||
},
|
||||
{
|
||||
Name: "prefs",
|
||||
Exec: runPrefs,
|
||||
|
||||
@@ -66,7 +66,7 @@ func runExitNodeList(ctx context.Context, args []string) error {
|
||||
var peers []*ipnstate.PeerStatus
|
||||
for _, ps := range st.Peer {
|
||||
if !ps.ExitNodeOption {
|
||||
// We only show location based exit nodes.
|
||||
// We only show exit nodes under the exit-node subcommand.
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,10 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"golang.org/x/exp/slices"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/util/mak"
|
||||
)
|
||||
|
||||
@@ -91,9 +94,15 @@ func (e *serveEnv) runFunnel(ctx context.Context, args []string) error {
|
||||
}
|
||||
port := uint16(port64)
|
||||
|
||||
if err := ipn.CheckFunnelAccess(port, st.Self.Capabilities); err != nil {
|
||||
return err
|
||||
if on {
|
||||
// Don't block from turning off existing Funnel if
|
||||
// network configuration/capabilities have changed.
|
||||
// Only block from starting new Funnels.
|
||||
if err := e.verifyFunnelEnabled(ctx, st, port); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
dnsName := strings.TrimSuffix(st.Self.DNSName, ".")
|
||||
hp := ipn.HostPort(dnsName + ":" + strconv.Itoa(int(port)))
|
||||
if on == sc.AllowFunnel[hp] {
|
||||
@@ -117,6 +126,49 @@ func (e *serveEnv) runFunnel(ctx context.Context, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// verifyFunnelEnabled verifies that the self node is allowed to use Funnel.
|
||||
//
|
||||
// If Funnel is not yet enabled by the current node capabilities,
|
||||
// the user is sent through an interactive flow to enable the feature.
|
||||
// Once enabled, verifyFunnelEnabled checks that the given port is allowed
|
||||
// with Funnel.
|
||||
//
|
||||
// If an error is reported, the CLI should stop execution and return the error.
|
||||
//
|
||||
// verifyFunnelEnabled may refresh the local state and modify the st input.
|
||||
func (e *serveEnv) verifyFunnelEnabled(ctx context.Context, st *ipnstate.Status, port uint16) error {
|
||||
hasFunnelAttrs := func(attrs []string) bool {
|
||||
hasHTTPS := slices.Contains(attrs, tailcfg.CapabilityHTTPS)
|
||||
hasFunnel := slices.Contains(attrs, tailcfg.NodeAttrFunnel)
|
||||
return hasHTTPS && hasFunnel
|
||||
}
|
||||
if hasFunnelAttrs(st.Self.Capabilities) {
|
||||
return nil // already enabled
|
||||
}
|
||||
enableErr := e.enableFeatureInteractive(ctx, "funnel", hasFunnelAttrs)
|
||||
st, statusErr := e.getLocalClientStatus(ctx) // get updated status; interactive flow may block
|
||||
switch {
|
||||
case statusErr != nil:
|
||||
return fmt.Errorf("getting client status: %w", statusErr)
|
||||
case enableErr != nil:
|
||||
// enableFeatureInteractive is a new flow behind a control server
|
||||
// feature flag. If anything caused it to error, fallback to using
|
||||
// the old CheckFunnelAccess call. Likely this domain does not have
|
||||
// the feature flag on.
|
||||
// TODO(sonia,tailscale/corp#10577): Remove this fallback once the
|
||||
// control flag is turned on for all domains.
|
||||
if err := ipn.CheckFunnelAccess(port, st.Self.Capabilities); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
// Done with enablement, make sure the requested port is allowed.
|
||||
if err := ipn.CheckFunnelPort(port, st.Self.Capabilities); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// printFunnelWarning prints a warning if the Funnel is on but there is no serve
|
||||
// config for its host:port.
|
||||
func printFunnelWarning(sc *ipn.ServeConfig) {
|
||||
@@ -129,7 +181,7 @@ func printFunnelWarning(sc *ipn.ServeConfig) {
|
||||
p, _ := strconv.ParseUint(portStr, 10, 16)
|
||||
if _, ok := sc.TCP[uint16(p)]; !ok {
|
||||
warn = true
|
||||
fmt.Fprintf(os.Stderr, "Warning: funnel=on for %s, but no serve config\n", hp)
|
||||
fmt.Fprintf(os.Stderr, "\nWarning: funnel=on for %s, but no serve config\n", hp)
|
||||
}
|
||||
}
|
||||
if warn {
|
||||
|
||||
@@ -5,9 +5,9 @@ package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"runtime"
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"tailscale.com/licenses"
|
||||
)
|
||||
|
||||
var licensesCmd = &ffcli.Command{
|
||||
@@ -18,27 +18,13 @@ var licensesCmd = &ffcli.Command{
|
||||
Exec: runLicenses,
|
||||
}
|
||||
|
||||
// licensesURL returns the absolute URL containing open source license information for the current platform.
|
||||
func licensesURL() string {
|
||||
switch runtime.GOOS {
|
||||
case "android":
|
||||
return "https://tailscale.com/licenses/android"
|
||||
case "darwin", "ios":
|
||||
return "https://tailscale.com/licenses/apple"
|
||||
case "windows":
|
||||
return "https://tailscale.com/licenses/windows"
|
||||
default:
|
||||
return "https://tailscale.com/licenses/tailscale"
|
||||
}
|
||||
}
|
||||
|
||||
func runLicenses(ctx context.Context, args []string) error {
|
||||
licenses := licensesURL()
|
||||
url := licenses.LicensesURL()
|
||||
outln(`
|
||||
Tailscale wouldn't be possible without the contributions of thousands of open
|
||||
source developers. To see the open source packages included in Tailscale and
|
||||
their respective license information, visit:
|
||||
|
||||
` + licenses)
|
||||
` + url)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -52,7 +52,6 @@ func runNetcheck(ctx context.Context, args []string) error {
|
||||
return err
|
||||
}
|
||||
c := &netcheck.Client{
|
||||
UDPBindAddr: envknob.String("TS_DEBUG_NETCHECK_UDP_BIND"),
|
||||
PortMapper: portmapper.NewClient(logf, netMon, nil, nil),
|
||||
UseDNSCache: false, // always resolve, don't cache
|
||||
}
|
||||
@@ -67,6 +66,10 @@ func runNetcheck(ctx context.Context, args []string) error {
|
||||
fmt.Fprintln(Stderr, "# Warning: this JSON format is not yet considered a stable interface")
|
||||
}
|
||||
|
||||
if err := c.Standalone(ctx, envknob.String("TS_DEBUG_NETCHECK_UDP_BIND")); err != nil {
|
||||
fmt.Fprintln(Stderr, "netcheck: UDP test failure:", err)
|
||||
}
|
||||
|
||||
dm, err := localClient.CurrentDERPMap(ctx)
|
||||
noRegions := dm != nil && len(dm.Regions) == 0
|
||||
if noRegions {
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
@@ -53,12 +54,14 @@ relay node.
|
||||
fs.BoolVar(&pingArgs.peerAPI, "peerapi", false, "try hitting the peer's peerapi HTTP server")
|
||||
fs.IntVar(&pingArgs.num, "c", 10, "max number of pings to send. 0 for infinity.")
|
||||
fs.DurationVar(&pingArgs.timeout, "timeout", 5*time.Second, "timeout before giving up on a ping")
|
||||
fs.IntVar(&pingArgs.size, "size", 0, "size of the ping message (disco pings only). 0 for minimum size.")
|
||||
return fs
|
||||
})(),
|
||||
}
|
||||
|
||||
var pingArgs struct {
|
||||
num int
|
||||
size int
|
||||
untilDirect bool
|
||||
verbose bool
|
||||
tsmp bool
|
||||
@@ -115,7 +118,7 @@ func runPing(ctx context.Context, args []string) error {
|
||||
for {
|
||||
n++
|
||||
ctx, cancel := context.WithTimeout(ctx, pingArgs.timeout)
|
||||
pr, err := localClient.Ping(ctx, netip.MustParseAddr(ip), pingType())
|
||||
pr, err := localClient.PingWithOpts(ctx, netip.MustParseAddr(ip), pingType(), tailscale.PingOpts{Size: pingArgs.size})
|
||||
cancel()
|
||||
if err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"tailscale.com/util/testenv"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -56,7 +58,7 @@ func presentRiskToUser(riskType, riskMessage, acceptedRisks string) error {
|
||||
if isRiskAccepted(riskType, acceptedRisks) {
|
||||
return nil
|
||||
}
|
||||
if inTest() {
|
||||
if testenv.InTest() {
|
||||
return errAborted
|
||||
}
|
||||
outln(riskMessage)
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -22,6 +23,8 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"golang.org/x/exp/slices"
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/tailcfg"
|
||||
@@ -129,7 +132,9 @@ type localServeClient interface {
|
||||
Status(context.Context) (*ipnstate.Status, error)
|
||||
GetServeConfig(context.Context) (*ipn.ServeConfig, error)
|
||||
SetServeConfig(context.Context, *ipn.ServeConfig) error
|
||||
QueryFeature(context.Context, string) (*tailcfg.QueryFeatureResponse, error)
|
||||
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
|
||||
}
|
||||
|
||||
// serveEnv is the environment the serve command runs within. All I/O should be
|
||||
@@ -229,6 +234,21 @@ func (e *serveEnv) runServe(ctx context.Context, args []string) error {
|
||||
return flag.ErrHelp
|
||||
}
|
||||
|
||||
if srcType == "https" && !turnOff {
|
||||
// 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.
|
||||
e.enableFeatureInteractive(ctx, "serve", func(caps []string) bool {
|
||||
return slices.Contains(caps, tailcfg.CapabilityHTTPS)
|
||||
})
|
||||
}
|
||||
|
||||
srcPort, err := parseServePort(srcPortStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid port %q: %w", srcPortStr, err)
|
||||
@@ -766,3 +786,75 @@ func parseServePort(s string) (uint16, error) {
|
||||
}
|
||||
return uint16(p), nil
|
||||
}
|
||||
|
||||
// enableFeatureInteractive sends the node's user through an interactive
|
||||
// flow to enable a feature, such as Funnel, on their tailnet.
|
||||
//
|
||||
// hasRequiredCapabilities should be provided as a function that checks
|
||||
// whether a slice of node capabilities encloses the necessary values
|
||||
// needed to use the feature.
|
||||
//
|
||||
// If err is returned empty, the feature has been successfully enabled.
|
||||
//
|
||||
// If err is returned non-empty, the client failed to query the control
|
||||
// server for information about how to enable the feature.
|
||||
//
|
||||
// If the feature cannot be enabled, enableFeatureInteractive terminates
|
||||
// the CLI process.
|
||||
//
|
||||
// 2023-08-09: The only valid feature values are "serve" and "funnel".
|
||||
// This can be moved to some CLI lib when expanded past serve/funnel.
|
||||
func (e *serveEnv) enableFeatureInteractive(ctx context.Context, feature string, hasRequiredCapabilities func(caps []string) bool) (err error) {
|
||||
info, err := e.lc.QueryFeature(ctx, feature)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Complete {
|
||||
return nil // already enabled
|
||||
}
|
||||
if info.Text != "" {
|
||||
fmt.Fprintln(os.Stdout, "\n"+info.Text)
|
||||
}
|
||||
if info.URL != "" {
|
||||
fmt.Fprintln(os.Stdout, "\n "+info.URL+"\n")
|
||||
}
|
||||
if !info.ShouldWait {
|
||||
e.lc.IncrementCounter(ctx, fmt.Sprintf("%s_not_awaiting_enablement", feature), 1)
|
||||
// The feature has not been enabled yet,
|
||||
// but the CLI should not block on user action.
|
||||
// Once info.Text is printed, exit the CLI.
|
||||
os.Exit(0)
|
||||
}
|
||||
e.lc.IncrementCounter(ctx, fmt.Sprintf("%s_awaiting_enablement", feature), 1)
|
||||
// Block until feature is enabled.
|
||||
watchCtx, cancelWatch := context.WithCancel(ctx)
|
||||
defer cancelWatch()
|
||||
watcher, err := e.lc.WatchIPNBus(watchCtx, 0)
|
||||
if err != nil {
|
||||
// If we fail to connect to the IPN notification bus,
|
||||
// don't block. We still present the URL in the CLI,
|
||||
// then close the process. Swallow the error.
|
||||
log.Fatalf("lost connection to tailscaled: %v", err)
|
||||
e.lc.IncrementCounter(ctx, fmt.Sprintf("%s_enablement_lost_connection", feature), 1)
|
||||
return err
|
||||
}
|
||||
defer watcher.Close()
|
||||
for {
|
||||
n, err := watcher.Next()
|
||||
if err != nil {
|
||||
// Stop blocking if we error.
|
||||
// Let the user finish enablement then rerun their
|
||||
// command themselves.
|
||||
log.Fatalf("lost connection to tailscaled: %v", err)
|
||||
e.lc.IncrementCounter(ctx, fmt.Sprintf("%s_enablement_lost_connection", feature), 1)
|
||||
return err
|
||||
}
|
||||
if nm := n.NetMap; nm != nil && nm.SelfNode != nil {
|
||||
if hasRequiredCapabilities(nm.SelfNode.Capabilities) {
|
||||
e.lc.IncrementCounter(ctx, fmt.Sprintf("%s_enabled", feature), 1)
|
||||
fmt.Fprintln(os.Stdout, "Success.")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ package cli
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -16,6 +17,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/tailcfg"
|
||||
@@ -745,14 +747,105 @@ func TestServeConfigMutations(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyFunnelEnabled(t *testing.T) {
|
||||
lc := &fakeLocalServeClient{}
|
||||
var stdout bytes.Buffer
|
||||
var flagOut bytes.Buffer
|
||||
e := &serveEnv{
|
||||
lc: lc,
|
||||
testFlagOut: &flagOut,
|
||||
testStdout: &stdout,
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
// queryFeatureResponse is the mock response desired from the
|
||||
// call made to lc.QueryFeature by verifyFunnelEnabled.
|
||||
queryFeatureResponse mockQueryFeatureResponse
|
||||
caps []string // optionally set at fakeStatus.Capabilities
|
||||
wantErr string
|
||||
wantPanic string
|
||||
}{
|
||||
{
|
||||
name: "enabled",
|
||||
queryFeatureResponse: mockQueryFeatureResponse{resp: &tailcfg.QueryFeatureResponse{Complete: true}, err: nil},
|
||||
wantErr: "", // no error, success
|
||||
},
|
||||
{
|
||||
name: "fallback-to-non-interactive-flow",
|
||||
queryFeatureResponse: mockQueryFeatureResponse{resp: nil, err: errors.New("not-allowed")},
|
||||
wantErr: "Funnel not available; HTTPS must be enabled. See https://tailscale.com/s/https.",
|
||||
},
|
||||
{
|
||||
name: "fallback-flow-missing-acl-rule",
|
||||
queryFeatureResponse: mockQueryFeatureResponse{resp: nil, err: errors.New("not-allowed")},
|
||||
caps: []string{tailcfg.CapabilityHTTPS},
|
||||
wantErr: `Funnel not available; "funnel" node attribute not set. See https://tailscale.com/s/no-funnel.`,
|
||||
},
|
||||
{
|
||||
name: "fallback-flow-enabled",
|
||||
queryFeatureResponse: mockQueryFeatureResponse{resp: nil, err: errors.New("not-allowed")},
|
||||
caps: []string{tailcfg.CapabilityHTTPS, tailcfg.NodeAttrFunnel},
|
||||
wantErr: "", // no error, success
|
||||
},
|
||||
{
|
||||
name: "not-allowed-to-enable",
|
||||
queryFeatureResponse: mockQueryFeatureResponse{resp: &tailcfg.QueryFeatureResponse{
|
||||
Complete: false,
|
||||
Text: "You don't have permission to enable this feature.",
|
||||
ShouldWait: false,
|
||||
}, err: nil},
|
||||
wantErr: "",
|
||||
wantPanic: "unexpected call to os.Exit(0) during test", // os.Exit(0) should be called to end process
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
lc.setQueryFeatureResponse(tt.queryFeatureResponse)
|
||||
|
||||
if tt.caps != nil {
|
||||
oldCaps := fakeStatus.Self.Capabilities
|
||||
defer func() { fakeStatus.Self.Capabilities = oldCaps }() // reset after test
|
||||
fakeStatus.Self.Capabilities = tt.caps
|
||||
}
|
||||
st, err := e.getLocalClientStatus(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
r := recover()
|
||||
var gotPanic string
|
||||
if r != nil {
|
||||
gotPanic = fmt.Sprint(r)
|
||||
}
|
||||
if gotPanic != tt.wantPanic {
|
||||
t.Errorf("wrong panic; got=%s, want=%s", gotPanic, tt.wantPanic)
|
||||
}
|
||||
}()
|
||||
gotErr := e.verifyFunnelEnabled(ctx, st, 443)
|
||||
var got string
|
||||
if gotErr != nil {
|
||||
got = gotErr.Error()
|
||||
}
|
||||
if got != tt.wantErr {
|
||||
t.Errorf("wrong error; got=%s, want=%s", gotErr, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// fakeLocalServeClient is a fake tailscale.LocalClient for tests.
|
||||
// It's not a full implementation, just enough to test the serve command.
|
||||
//
|
||||
// The fake client is stateful, and is used to test manipulating
|
||||
// ServeConfig state. This implementation cannot be used concurrently.
|
||||
type fakeLocalServeClient struct {
|
||||
config *ipn.ServeConfig
|
||||
setCount int // counts calls to SetServeConfig
|
||||
config *ipn.ServeConfig
|
||||
setCount int // counts calls to SetServeConfig
|
||||
queryFeatureResponse *mockQueryFeatureResponse // mock response to QueryFeature calls
|
||||
}
|
||||
|
||||
// fakeStatus is a fake ipnstate.Status value for tests.
|
||||
@@ -782,8 +875,29 @@ func (lc *fakeLocalServeClient) SetServeConfig(ctx context.Context, config *ipn.
|
||||
return nil
|
||||
}
|
||||
|
||||
func (lc *fakeLocalServeClient) QueryFeature(context.Context, string) (*tailcfg.QueryFeatureResponse, error) {
|
||||
return nil, nil
|
||||
type mockQueryFeatureResponse struct {
|
||||
resp *tailcfg.QueryFeatureResponse
|
||||
err error
|
||||
}
|
||||
|
||||
func (lc *fakeLocalServeClient) setQueryFeatureResponse(resp mockQueryFeatureResponse) {
|
||||
lc.queryFeatureResponse = &resp
|
||||
}
|
||||
|
||||
func (lc *fakeLocalServeClient) QueryFeature(ctx context.Context, feature string) (*tailcfg.QueryFeatureResponse, error) {
|
||||
if resp := lc.queryFeatureResponse; resp != nil {
|
||||
// If we're testing QueryFeature, use the response value set for the test.
|
||||
return resp.resp, resp.err
|
||||
}
|
||||
return &tailcfg.QueryFeatureResponse{Complete: true}, nil // fallback to already enabled
|
||||
}
|
||||
|
||||
func (lc *fakeLocalServeClient) WatchIPNBus(ctx context.Context, mask ipn.NotifyWatchOpt) (*tailscale.IPNBusWatcher, error) {
|
||||
return nil, nil // unused in tests
|
||||
}
|
||||
|
||||
func (lc *fakeLocalServeClient) IncrementCounter(ctx context.Context, name string, delta int) error {
|
||||
return nil // unused in tests
|
||||
}
|
||||
|
||||
// exactError returns an error checker that wants exactly the provided want error.
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/net/netutil"
|
||||
"tailscale.com/net/tsaddr"
|
||||
"tailscale.com/safesocket"
|
||||
)
|
||||
@@ -159,11 +160,11 @@ func runSet(ctx context.Context, args []string) (retErr error) {
|
||||
// setArgs is the parsed command-line arguments.
|
||||
func calcAdvertiseRoutesForSet(advertiseExitNodeSet, advertiseRoutesSet bool, curPrefs *ipn.Prefs, setArgs setArgsT) (routes []netip.Prefix, err error) {
|
||||
if advertiseExitNodeSet && advertiseRoutesSet {
|
||||
return calcAdvertiseRoutes(setArgs.advertiseRoutes, setArgs.advertiseDefaultRoute)
|
||||
return netutil.CalcAdvertiseRoutes(setArgs.advertiseRoutes, setArgs.advertiseDefaultRoute)
|
||||
|
||||
}
|
||||
if advertiseRoutesSet {
|
||||
return calcAdvertiseRoutes(setArgs.advertiseRoutes, curPrefs.AdvertisesExitNode())
|
||||
return netutil.CalcAdvertiseRoutes(setArgs.advertiseRoutes, curPrefs.AdvertisesExitNode())
|
||||
}
|
||||
if advertiseExitNodeSet {
|
||||
alreadyAdvertisesExitNode := curPrefs.AdvertisesExitNode()
|
||||
|
||||
@@ -6,7 +6,6 @@ package cli
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
@@ -33,7 +32,7 @@ import (
|
||||
"tailscale.com/health/healthmsg"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/net/tsaddr"
|
||||
"tailscale.com/net/netutil"
|
||||
"tailscale.com/safesocket"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/logger"
|
||||
@@ -91,8 +90,6 @@ func acceptRouteDefault(goos string) bool {
|
||||
|
||||
var upFlagSet = newUpFlagSet(effectiveGOOS(), &upArgsGlobal, "up")
|
||||
|
||||
func inTest() bool { return flag.Lookup("test.v") != nil }
|
||||
|
||||
// newUpFlagSet returns a new flag set for the "up" and "login" commands.
|
||||
func newUpFlagSet(goos string, upArgs *upArgsT, cmd string) *flag.FlagSet {
|
||||
if cmd != "up" && cmd != "login" {
|
||||
@@ -222,82 +219,6 @@ func warnf(format string, args ...any) {
|
||||
printf("Warning: "+format+"\n", args...)
|
||||
}
|
||||
|
||||
var (
|
||||
ipv4default = netip.MustParsePrefix("0.0.0.0/0")
|
||||
ipv6default = netip.MustParsePrefix("::/0")
|
||||
)
|
||||
|
||||
func validateViaPrefix(ipp netip.Prefix) error {
|
||||
if !tsaddr.IsViaPrefix(ipp) {
|
||||
return fmt.Errorf("%v is not a 4-in-6 prefix", ipp)
|
||||
}
|
||||
if ipp.Bits() < (128 - 32) {
|
||||
return fmt.Errorf("%v 4-in-6 prefix must be at least a /%v", ipp, 128-32)
|
||||
}
|
||||
a := ipp.Addr().As16()
|
||||
// The first 64 bits of a are the via prefix.
|
||||
// The next 32 bits are the "site ID".
|
||||
// The last 32 bits are the IPv4.
|
||||
// For now, we reserve the top 3 bytes of the site ID,
|
||||
// and only allow users to use site IDs 0-255.
|
||||
siteID := binary.BigEndian.Uint32(a[8:12])
|
||||
if siteID > 0xFF {
|
||||
return fmt.Errorf("route %v contains invalid site ID %08x; must be 0xff or less", ipp, siteID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func calcAdvertiseRoutes(advertiseRoutes string, advertiseDefaultRoute bool) ([]netip.Prefix, error) {
|
||||
routeMap := map[netip.Prefix]bool{}
|
||||
if advertiseRoutes != "" {
|
||||
var default4, default6 bool
|
||||
advroutes := strings.Split(advertiseRoutes, ",")
|
||||
for _, s := range advroutes {
|
||||
ipp, err := netip.ParsePrefix(s)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%q is not a valid IP address or CIDR prefix", s)
|
||||
}
|
||||
if ipp != ipp.Masked() {
|
||||
return nil, fmt.Errorf("%s has non-address bits set; expected %s", ipp, ipp.Masked())
|
||||
}
|
||||
if tsaddr.IsViaPrefix(ipp) {
|
||||
if err := validateViaPrefix(ipp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if ipp == ipv4default {
|
||||
default4 = true
|
||||
} else if ipp == ipv6default {
|
||||
default6 = true
|
||||
}
|
||||
routeMap[ipp] = true
|
||||
}
|
||||
if default4 && !default6 {
|
||||
return nil, fmt.Errorf("%s advertised without its IPv6 counterpart, please also advertise %s", ipv4default, ipv6default)
|
||||
} else if default6 && !default4 {
|
||||
return nil, fmt.Errorf("%s advertised without its IPv4 counterpart, please also advertise %s", ipv6default, ipv4default)
|
||||
}
|
||||
}
|
||||
if advertiseDefaultRoute {
|
||||
routeMap[netip.MustParsePrefix("0.0.0.0/0")] = true
|
||||
routeMap[netip.MustParsePrefix("::/0")] = true
|
||||
}
|
||||
if len(routeMap) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
routes := make([]netip.Prefix, 0, len(routeMap))
|
||||
for r := range routeMap {
|
||||
routes = append(routes, r)
|
||||
}
|
||||
sort.Slice(routes, func(i, j int) bool {
|
||||
if routes[i].Bits() != routes[j].Bits() {
|
||||
return routes[i].Bits() < routes[j].Bits()
|
||||
}
|
||||
return routes[i].Addr().Less(routes[j].Addr())
|
||||
})
|
||||
return routes, nil
|
||||
}
|
||||
|
||||
// prefsFromUpArgs returns the ipn.Prefs for the provided args.
|
||||
//
|
||||
// Note that the parameters upArgs and warnf are named intentionally
|
||||
@@ -305,7 +226,7 @@ func calcAdvertiseRoutes(advertiseRoutes string, advertiseDefaultRoute bool) ([]
|
||||
// function exists for testing and should have no side effects or
|
||||
// outside interactions (e.g. no making Tailscale LocalAPI calls).
|
||||
func prefsFromUpArgs(upArgs upArgsT, warnf logger.Logf, st *ipnstate.Status, goos string) (*ipn.Prefs, error) {
|
||||
routes, err := calcAdvertiseRoutes(upArgs.advertiseRoutes, upArgs.advertiseDefaultRoute)
|
||||
routes, err := netutil.CalcAdvertiseRoutes(upArgs.advertiseRoutes, upArgs.advertiseDefaultRoute)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -424,7 +345,7 @@ func updatePrefs(prefs, curPrefs *ipn.Prefs, env upCheckEnv) (simpleUp bool, jus
|
||||
|
||||
simpleUp = env.flagSet.NFlag() == 0 &&
|
||||
curPrefs.Persist != nil &&
|
||||
curPrefs.Persist.LoginName != "" &&
|
||||
curPrefs.Persist.UserProfile.LoginName != "" &&
|
||||
env.backendState != ipn.NeedsLogin.String()
|
||||
|
||||
justEdit := env.backendState == ipn.Running.String() &&
|
||||
|
||||
@@ -4,33 +4,15 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"tailscale.com/net/tshttpproxy"
|
||||
"tailscale.com/util/must"
|
||||
"tailscale.com/util/winutil"
|
||||
"tailscale.com/clientupdate"
|
||||
"tailscale.com/version"
|
||||
"tailscale.com/version/distro"
|
||||
)
|
||||
@@ -67,147 +49,38 @@ var updateArgs struct {
|
||||
version string // explicit version; empty means auto
|
||||
}
|
||||
|
||||
// winMSIEnv is the environment variable that, if set, is the MSI file for the
|
||||
// update command to install. It's passed like this so we can stop the
|
||||
// tailscale.exe process from running before the msiexec process runs and tries
|
||||
// to overwrite ourselves.
|
||||
const winMSIEnv = "TS_UPDATE_WIN_MSI"
|
||||
|
||||
func runUpdate(ctx context.Context, args []string) error {
|
||||
if msi := os.Getenv(winMSIEnv); msi != "" {
|
||||
log.Printf("installing %v ...", msi)
|
||||
if err := installMSI(msi); err != nil {
|
||||
log.Printf("MSI install failed: %v", err)
|
||||
return err
|
||||
}
|
||||
log.Printf("success.")
|
||||
return nil
|
||||
}
|
||||
if len(args) > 0 {
|
||||
return flag.ErrHelp
|
||||
}
|
||||
if updateArgs.version != "" && updateArgs.track != "" {
|
||||
return errors.New("cannot specify both --version and --track")
|
||||
}
|
||||
up, err := newUpdater()
|
||||
if err != nil {
|
||||
return err
|
||||
ver := updateArgs.version
|
||||
if updateArgs.track != "" {
|
||||
ver = updateArgs.track
|
||||
}
|
||||
return up.update()
|
||||
err := clientupdate.Update(clientupdate.UpdateArgs{
|
||||
Version: ver,
|
||||
AppStore: updateArgs.appStore,
|
||||
Logf: func(format string, args ...any) { fmt.Printf(format+"\n", args...) },
|
||||
Confirm: confirmUpdate,
|
||||
})
|
||||
if errors.Is(err, errors.ErrUnsupported) {
|
||||
return errors.New("The 'update' command is not supported on this platform; see https://tailscale.com/s/client-updates")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func versionIsStable(v string) (stable, wellFormed bool) {
|
||||
_, rest, ok := strings.Cut(v, ".")
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
minorStr, _, ok := strings.Cut(rest, ".")
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
minor, err := strconv.Atoi(minorStr)
|
||||
if err != nil {
|
||||
return false, false
|
||||
}
|
||||
return minor%2 == 0, true
|
||||
}
|
||||
|
||||
func newUpdater() (*updater, error) {
|
||||
up := &updater{
|
||||
track: updateArgs.track,
|
||||
}
|
||||
switch up.track {
|
||||
case "stable", "unstable":
|
||||
case "":
|
||||
if version.IsUnstableBuild() {
|
||||
up.track = "unstable"
|
||||
} else {
|
||||
up.track = "stable"
|
||||
}
|
||||
if updateArgs.version != "" {
|
||||
stable, ok := versionIsStable(updateArgs.version)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("malformed version %q", updateArgs.version)
|
||||
}
|
||||
if stable {
|
||||
up.track = "stable"
|
||||
} else {
|
||||
up.track = "unstable"
|
||||
}
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown track %q; must be 'stable' or 'unstable'", up.track)
|
||||
}
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
up.update = up.updateWindows
|
||||
case "linux":
|
||||
switch distro.Get() {
|
||||
case distro.Synology:
|
||||
up.update = up.updateSynology
|
||||
case distro.Debian: // includes Ubuntu
|
||||
up.update = up.updateDebLike
|
||||
case distro.Arch:
|
||||
up.update = up.updateArchLike
|
||||
case distro.Alpine:
|
||||
up.update = up.updateAlpineLike
|
||||
}
|
||||
// TODO(awly): add support for Alpine
|
||||
switch {
|
||||
case haveExecutable("pacman"):
|
||||
up.update = up.updateArchLike
|
||||
case haveExecutable("apt-get"): // TODO(awly): add support for "apt"
|
||||
// The distro.Debian switch case above should catch most apt-based
|
||||
// systems, but add this fallback just in case.
|
||||
up.update = up.updateDebLike
|
||||
case haveExecutable("dnf"):
|
||||
up.update = up.updateFedoraLike("dnf")
|
||||
case haveExecutable("yum"):
|
||||
up.update = up.updateFedoraLike("yum")
|
||||
case haveExecutable("apk"):
|
||||
up.update = up.updateAlpineLike
|
||||
}
|
||||
case "darwin":
|
||||
switch {
|
||||
case !updateArgs.appStore && !version.IsSandboxedMacOS():
|
||||
return nil, errors.New("The 'update' command is not yet supported on this platform; see https://github.com/tailscale/tailscale/wiki/Tailscaled-on-macOS/ for now")
|
||||
case !updateArgs.appStore && strings.HasSuffix(os.Getenv("HOME"), "/io.tailscale.ipn.macsys/Data"):
|
||||
up.update = up.updateMacSys
|
||||
default:
|
||||
up.update = up.updateMacAppStore
|
||||
}
|
||||
case "freebsd":
|
||||
up.update = up.updateFreeBSD
|
||||
}
|
||||
if up.update == nil {
|
||||
return nil, errors.New("The 'update' command is not supported on this platform; see https://tailscale.com/s/client-updates")
|
||||
}
|
||||
return up, nil
|
||||
}
|
||||
|
||||
type updater struct {
|
||||
track string
|
||||
update func() error
|
||||
}
|
||||
|
||||
func (up *updater) currentOrDryRun(ver string) bool {
|
||||
if version.Short() == ver {
|
||||
fmt.Printf("already running %v; no update needed\n", ver)
|
||||
func confirmUpdate(ver string) bool {
|
||||
if updateArgs.yes {
|
||||
fmt.Printf("Updating Tailscale from %v to %v; --yes given, continuing without prompts.\n", version.Short(), ver)
|
||||
return true
|
||||
}
|
||||
|
||||
if updateArgs.dryRun {
|
||||
fmt.Printf("Current: %v, Latest: %v\n", version.Short(), ver)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var errUserAborted = errors.New("aborting update")
|
||||
|
||||
func (up *updater) confirm(ver string) error {
|
||||
if updateArgs.yes {
|
||||
log.Printf("Updating Tailscale from %v to %v; --yes given, continuing without prompts.\n", version.Short(), ver)
|
||||
return nil
|
||||
return false
|
||||
}
|
||||
|
||||
fmt.Printf("This will update Tailscale from %v to %v. Continue? [y/n] ", version.Short(), ver)
|
||||
@@ -216,697 +89,7 @@ func (up *updater) confirm(ver string) error {
|
||||
resp = strings.ToLower(resp)
|
||||
switch resp {
|
||||
case "y", "yes", "sure":
|
||||
return nil
|
||||
}
|
||||
return errUserAborted
|
||||
}
|
||||
|
||||
func (up *updater) updateSynology() error {
|
||||
// TODO(bradfitz): detect, map GOARCH+CPU to the right Synology arch.
|
||||
// TODO(bradfitz): add pkgs.tailscale.com endpoint to get release info
|
||||
// TODO(bradfitz): require root/sudo
|
||||
// TODO(bradfitz): run /usr/syno/bin/synopkg install tailscale.spk
|
||||
return errors.New("The 'update' command is not yet implemented on Synology.")
|
||||
}
|
||||
|
||||
func (up *updater) updateDebLike() error {
|
||||
ver, err := requestedTailscaleVersion(updateArgs.version, up.track)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if up.currentOrDryRun(ver) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := requireRoot(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if updated, err := updateDebianAptSourcesList(up.track); err != nil {
|
||||
return err
|
||||
} else if updated {
|
||||
fmt.Printf("Updated %s to use the %s track\n", aptSourcesFile, up.track)
|
||||
}
|
||||
|
||||
cmd := exec.Command("apt-get", "update",
|
||||
// Only update the tailscale repo, not the other ones, treating
|
||||
// the tailscale.list file as the main "sources.list" file.
|
||||
"-o", "Dir::Etc::SourceList=sources.list.d/tailscale.list",
|
||||
// Disable the "sources.list.d" directory:
|
||||
"-o", "Dir::Etc::SourceParts=-",
|
||||
// Don't forget about packages in the other repos just because
|
||||
// we're not updating them:
|
||||
"-o", "APT::Get::List-Cleanup=0",
|
||||
)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd = exec.Command("apt-get", "install", "--yes", "--allow-downgrades", "tailscale="+ver)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
const aptSourcesFile = "/etc/apt/sources.list.d/tailscale.list"
|
||||
|
||||
// updateDebianAptSourcesList updates the /etc/apt/sources.list.d/tailscale.list
|
||||
// file to make sure it has the provided track (stable or unstable) in it.
|
||||
//
|
||||
// If it already has the right track (including containing both stable and
|
||||
// unstable), it does nothing.
|
||||
func updateDebianAptSourcesList(dstTrack string) (rewrote bool, err error) {
|
||||
was, err := os.ReadFile(aptSourcesFile)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
newContent, err := updateDebianAptSourcesListBytes(was, dstTrack)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if bytes.Equal(was, newContent) {
|
||||
return false, nil
|
||||
}
|
||||
return true, os.WriteFile(aptSourcesFile, newContent, 0644)
|
||||
}
|
||||
|
||||
func updateDebianAptSourcesListBytes(was []byte, dstTrack string) (newContent []byte, err error) {
|
||||
trackURLPrefix := []byte("https://pkgs.tailscale.com/" + dstTrack + "/")
|
||||
var buf bytes.Buffer
|
||||
var changes int
|
||||
bs := bufio.NewScanner(bytes.NewReader(was))
|
||||
hadCorrect := false
|
||||
commentLine := regexp.MustCompile(`^\s*\#`)
|
||||
pkgsURL := regexp.MustCompile(`\bhttps://pkgs\.tailscale\.com/((un)?stable)/`)
|
||||
for bs.Scan() {
|
||||
line := bs.Bytes()
|
||||
if !commentLine.Match(line) {
|
||||
line = pkgsURL.ReplaceAllFunc(line, func(m []byte) []byte {
|
||||
if bytes.Equal(m, trackURLPrefix) {
|
||||
hadCorrect = true
|
||||
} else {
|
||||
changes++
|
||||
}
|
||||
return trackURLPrefix
|
||||
})
|
||||
}
|
||||
buf.Write(line)
|
||||
buf.WriteByte('\n')
|
||||
}
|
||||
if hadCorrect || (changes == 1 && bytes.Equal(bytes.TrimSpace(was), bytes.TrimSpace(buf.Bytes()))) {
|
||||
// Unchanged or close enough.
|
||||
return was, nil
|
||||
}
|
||||
if changes != 1 {
|
||||
// No changes, or an unexpected number of changes (what?). Bail.
|
||||
// They probably editted it by hand and we don't know what to do.
|
||||
return nil, fmt.Errorf("unexpected/unsupported %s contents", aptSourcesFile)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func (up *updater) updateArchLike() (err error) {
|
||||
if err := requireRoot(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err != nil && !errors.Is(err, errUserAborted) {
|
||||
err = fmt.Errorf(`%w; you can try updating using "pacman --sync --refresh tailscale"`, err)
|
||||
}
|
||||
}()
|
||||
|
||||
out, err := exec.Command("pacman", "--sync", "--refresh", "--info", "tailscale").CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed checking pacman for latest tailscale version: %w, output: %q", err, out)
|
||||
}
|
||||
ver, err := parsePacmanVersion(out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if up.currentOrDryRun(ver) {
|
||||
return nil
|
||||
}
|
||||
if err := up.confirm(ver); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd := exec.Command("pacman", "--sync", "--noconfirm", "tailscale")
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed tailscale update using pacman: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parsePacmanVersion(out []byte) (string, error) {
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
// The line we're looking for looks like this:
|
||||
// Version : 1.44.2-1
|
||||
if !strings.HasPrefix(line, "Version") {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return "", fmt.Errorf("version output from pacman is malformed: %q, cannot determine upgrade version", line)
|
||||
}
|
||||
ver := strings.TrimSpace(parts[1])
|
||||
// Trim the Arch patch version.
|
||||
ver = strings.Split(ver, "-")[0]
|
||||
if ver == "" {
|
||||
return "", fmt.Errorf("version output from pacman is malformed: %q, cannot determine upgrade version", line)
|
||||
}
|
||||
return ver, nil
|
||||
}
|
||||
return "", fmt.Errorf("could not find latest version of tailscale via pacman")
|
||||
}
|
||||
|
||||
const yumRepoConfigFile = "/etc/yum.repos.d/tailscale.repo"
|
||||
|
||||
// updateFedoraLike updates tailscale on any distros in the Fedora family,
|
||||
// specifically anything that uses "dnf" or "yum" package managers. The actual
|
||||
// package manager is passed via packageManager.
|
||||
func (up *updater) updateFedoraLike(packageManager string) func() error {
|
||||
return func() (err error) {
|
||||
if err := requireRoot(); err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if err != nil && !errors.Is(err, errUserAborted) {
|
||||
err = fmt.Errorf(`%w; you can try updating using "%s upgrade tailscale"`, err, packageManager)
|
||||
}
|
||||
}()
|
||||
|
||||
ver, err := requestedTailscaleVersion(updateArgs.version, up.track)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if up.currentOrDryRun(ver) {
|
||||
return nil
|
||||
}
|
||||
if err := up.confirm(ver); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if updated, err := updateYUMRepoTrack(yumRepoConfigFile, up.track); err != nil {
|
||||
return err
|
||||
} else if updated {
|
||||
fmt.Printf("Updated %s to use the %s track\n", yumRepoConfigFile, up.track)
|
||||
}
|
||||
|
||||
cmd := exec.Command(packageManager, "install", "--assumeyes", fmt.Sprintf("tailscale-%s-1", ver))
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// updateYUMRepoTrack updates the repoFile file to make sure it has the
|
||||
// provided track (stable or unstable) in it.
|
||||
func updateYUMRepoTrack(repoFile, dstTrack string) (rewrote bool, err error) {
|
||||
was, err := os.ReadFile(repoFile)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
urlRe := regexp.MustCompile(`^(baseurl|gpgkey)=https://pkgs\.tailscale\.com/(un)?stable/`)
|
||||
urlReplacement := fmt.Sprintf("$1=https://pkgs.tailscale.com/%s/", dstTrack)
|
||||
|
||||
s := bufio.NewScanner(bytes.NewReader(was))
|
||||
newContent := bytes.NewBuffer(make([]byte, 0, len(was)))
|
||||
for s.Scan() {
|
||||
line := s.Text()
|
||||
// Handle repo section name, like "[tailscale-stable]".
|
||||
if len(line) > 0 && line[0] == '[' {
|
||||
if !strings.HasPrefix(line, "[tailscale-") {
|
||||
return false, fmt.Errorf("%q does not look like a tailscale repo file, it contains an unexpected %q section", repoFile, line)
|
||||
}
|
||||
fmt.Fprintf(newContent, "[tailscale-%s]\n", dstTrack)
|
||||
continue
|
||||
}
|
||||
// Update the track mentioned in repo name.
|
||||
if strings.HasPrefix(line, "name=") {
|
||||
fmt.Fprintf(newContent, "name=Tailscale %s\n", dstTrack)
|
||||
continue
|
||||
}
|
||||
// Update the actual repo URLs.
|
||||
if strings.HasPrefix(line, "baseurl=") || strings.HasPrefix(line, "gpgkey=") {
|
||||
fmt.Fprintln(newContent, urlRe.ReplaceAllString(line, urlReplacement))
|
||||
continue
|
||||
}
|
||||
fmt.Fprintln(newContent, line)
|
||||
}
|
||||
if bytes.Equal(was, newContent.Bytes()) {
|
||||
return false, nil
|
||||
}
|
||||
return true, os.WriteFile(repoFile, newContent.Bytes(), 0644)
|
||||
}
|
||||
|
||||
func (up *updater) updateAlpineLike() (err error) {
|
||||
if err := requireRoot(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err != nil && !errors.Is(err, errUserAborted) {
|
||||
err = fmt.Errorf(`%w; you can try updating using "apk upgrade tailscale"`, err)
|
||||
}
|
||||
}()
|
||||
|
||||
out, err := exec.Command("apk", "update").CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed refresh apk repository indexes: %w, output: %q", err, out)
|
||||
}
|
||||
out, err = exec.Command("apk", "info", "tailscale").CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed checking apk for latest tailscale version: %w, output: %q", err, out)
|
||||
}
|
||||
ver, err := parseAlpinePackageVersion(out)
|
||||
if err != nil {
|
||||
return fmt.Errorf(`failed to parse latest version from "apk info tailscale": %w`, err)
|
||||
}
|
||||
if up.currentOrDryRun(ver) {
|
||||
return nil
|
||||
}
|
||||
if err := up.confirm(ver); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd := exec.Command("apk", "upgrade", "tailscale")
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed tailscale update using apk: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseAlpinePackageVersion(out []byte) (string, error) {
|
||||
s := bufio.NewScanner(bytes.NewReader(out))
|
||||
for s.Scan() {
|
||||
// The line should look like this:
|
||||
// tailscale-1.44.2-r0 description:
|
||||
line := strings.TrimSpace(s.Text())
|
||||
if !strings.HasPrefix(line, "tailscale-") {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(line, "-", 3)
|
||||
if len(parts) < 3 {
|
||||
return "", fmt.Errorf("malformed info line: %q", line)
|
||||
}
|
||||
return parts[1], nil
|
||||
}
|
||||
return "", errors.New("tailscale version not found in output")
|
||||
}
|
||||
|
||||
func (up *updater) updateMacSys() error {
|
||||
// use sparkle? do we have permissions from this context? does sudo help?
|
||||
// We can at least fail with a command they can run to update from the shell.
|
||||
// Like "tailscale update --macsys | sudo sh" or something.
|
||||
//
|
||||
// TODO(bradfitz,mihai): implement. But for now:
|
||||
return errors.New("The 'update' command is not yet implemented on macOS.")
|
||||
}
|
||||
|
||||
func (up *updater) updateMacAppStore() error {
|
||||
out, err := exec.Command("defaults", "read", "/Library/Preferences/com.apple.commerce.plist", "AutoUpdate").CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("can't check App Store auto-update setting: %w, output: %q", err, string(out))
|
||||
}
|
||||
const on = "1\n"
|
||||
if string(out) != on {
|
||||
fmt.Fprintln(os.Stderr, "NOTE: Automatic updating for App Store apps is turned off. You can change this setting in System Settings (search for ‘update’).")
|
||||
}
|
||||
|
||||
out, err = exec.Command("softwareupdate", "--list").CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("can't check App Store for available updates: %w, output: %q", err, string(out))
|
||||
}
|
||||
|
||||
newTailscale := parseSoftwareupdateList(out)
|
||||
if newTailscale == "" {
|
||||
fmt.Println("no Tailscale update available")
|
||||
return nil
|
||||
}
|
||||
|
||||
newTailscaleVer := strings.TrimPrefix(newTailscale, "Tailscale-")
|
||||
if up.currentOrDryRun(newTailscaleVer) {
|
||||
return nil
|
||||
}
|
||||
if err := up.confirm(newTailscaleVer); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd := exec.Command("sudo", "softwareupdate", "--install", newTailscale)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("can't install App Store update for Tailscale: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var macOSAppStoreListPattern = regexp.MustCompile(`(?m)^\s+\*\s+Label:\s*(Tailscale-\d[\d\.]+)`)
|
||||
|
||||
// parseSoftwareupdateList searches the output of `softwareupdate --list` on
|
||||
// Darwin and returns the matching Tailscale package label. If there is none,
|
||||
// returns the empty string.
|
||||
//
|
||||
// See TestParseSoftwareupdateList for example inputs.
|
||||
func parseSoftwareupdateList(stdout []byte) string {
|
||||
matches := macOSAppStoreListPattern.FindSubmatch(stdout)
|
||||
if len(matches) < 2 {
|
||||
return ""
|
||||
}
|
||||
return string(matches[1])
|
||||
}
|
||||
|
||||
var (
|
||||
verifyAuthenticode func(string) error // or nil on non-Windows
|
||||
markTempFileFunc func(string) error // or nil on non-Windows
|
||||
)
|
||||
|
||||
func (up *updater) updateWindows() error {
|
||||
ver, err := requestedTailscaleVersion(updateArgs.version, up.track)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
arch := runtime.GOARCH
|
||||
if arch == "386" {
|
||||
arch = "x86"
|
||||
}
|
||||
url := fmt.Sprintf("https://pkgs.tailscale.com/%s/tailscale-setup-%s-%s.msi", up.track, ver, arch)
|
||||
|
||||
if up.currentOrDryRun(ver) {
|
||||
return nil
|
||||
}
|
||||
if !winutil.IsCurrentProcessElevated() {
|
||||
return errors.New("must be run as Administrator")
|
||||
}
|
||||
|
||||
tsDir := filepath.Join(os.Getenv("ProgramData"), "Tailscale")
|
||||
msiDir := filepath.Join(tsDir, "MSICache")
|
||||
if fi, err := os.Stat(tsDir); err != nil {
|
||||
return fmt.Errorf("expected %s to exist, got stat error: %w", tsDir, err)
|
||||
} else if !fi.IsDir() {
|
||||
return fmt.Errorf("expected %s to be a directory; got %v", tsDir, fi.Mode())
|
||||
}
|
||||
if err := os.MkdirAll(msiDir, 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := up.confirm(ver); err != nil {
|
||||
return err
|
||||
}
|
||||
msiTarget := filepath.Join(msiDir, path.Base(url))
|
||||
if err := downloadURLToFile(url, msiTarget); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf("verifying MSI authenticode...")
|
||||
if err := verifyAuthenticode(msiTarget); err != nil {
|
||||
return fmt.Errorf("authenticode verification of %s failed: %w", msiTarget, err)
|
||||
}
|
||||
log.Printf("authenticode verification succeeded")
|
||||
|
||||
log.Printf("making tailscale.exe copy to switch to...")
|
||||
selfCopy, err := makeSelfCopy()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(selfCopy)
|
||||
log.Printf("running tailscale.exe copy for final install...")
|
||||
|
||||
cmd := exec.Command(selfCopy, "update")
|
||||
cmd.Env = append(os.Environ(), winMSIEnv+"="+msiTarget)
|
||||
cmd.Stdout = os.Stderr
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Stdin = os.Stdin
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
// Once it's started, exit ourselves, so the binary is free
|
||||
// to be replaced.
|
||||
os.Exit(0)
|
||||
panic("unreachable")
|
||||
}
|
||||
|
||||
func installMSI(msi string) error {
|
||||
var err error
|
||||
for tries := 0; tries < 2; tries++ {
|
||||
cmd := exec.Command("msiexec.exe", "/i", filepath.Base(msi), "/quiet", "/promptrestart", "/qn")
|
||||
cmd.Dir = filepath.Dir(msi)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Stdin = os.Stdin
|
||||
err = cmd.Run()
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
uninstallVersion := version.Short()
|
||||
if v := os.Getenv("TS_DEBUG_UNINSTALL_VERSION"); v != "" {
|
||||
uninstallVersion = v
|
||||
}
|
||||
// Assume it's a downgrade, which msiexec won't permit. Uninstall our current version first.
|
||||
log.Printf("Uninstalling current version %q for downgrade...", uninstallVersion)
|
||||
cmd = exec.Command("msiexec.exe", "/x", msiUUIDForVersion(uninstallVersion), "/norestart", "/qn")
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Stdin = os.Stdin
|
||||
err = cmd.Run()
|
||||
log.Printf("msiexec uninstall: %v", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func msiUUIDForVersion(ver string) string {
|
||||
arch := runtime.GOARCH
|
||||
if arch == "386" {
|
||||
arch = "x86"
|
||||
}
|
||||
track := "unstable"
|
||||
if stable, ok := versionIsStable(ver); ok && stable {
|
||||
track = "stable"
|
||||
}
|
||||
msiURL := fmt.Sprintf("https://pkgs.tailscale.com/%s/tailscale-setup-%s-%s.msi", track, ver, arch)
|
||||
return "{" + strings.ToUpper(uuid.NewSHA1(uuid.NameSpaceURL, []byte(msiURL)).String()) + "}"
|
||||
}
|
||||
|
||||
func makeSelfCopy() (tmpPathExe string, err error) {
|
||||
selfExe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
f, err := os.Open(selfExe)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
f2, err := os.CreateTemp("", "tailscale-updater-*.exe")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if f := markTempFileFunc; f != nil {
|
||||
if err := f(f2.Name()); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if _, err := io.Copy(f2, f); err != nil {
|
||||
f2.Close()
|
||||
return "", err
|
||||
}
|
||||
return f2.Name(), f2.Close()
|
||||
}
|
||||
|
||||
func downloadURLToFile(urlSrc, fileDst string) (ret error) {
|
||||
tr := http.DefaultTransport.(*http.Transport).Clone()
|
||||
tr.Proxy = tshttpproxy.ProxyFromEnvironment
|
||||
defer tr.CloseIdleConnections()
|
||||
c := &http.Client{Transport: tr}
|
||||
|
||||
quickCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
headReq := must.Get(http.NewRequestWithContext(quickCtx, "HEAD", urlSrc, nil))
|
||||
|
||||
res, err := c.Do(headReq)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("HEAD %s: %v", urlSrc, res.Status)
|
||||
}
|
||||
if res.ContentLength <= 0 {
|
||||
return fmt.Errorf("HEAD %s: unexpected Content-Length %v", urlSrc, res.ContentLength)
|
||||
}
|
||||
log.Printf("Download size: %v", res.ContentLength)
|
||||
|
||||
hashReq := must.Get(http.NewRequestWithContext(quickCtx, "GET", urlSrc+".sha256", nil))
|
||||
hashRes, err := c.Do(hashReq)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hashHex, err := io.ReadAll(io.LimitReader(hashRes.Body, 100))
|
||||
hashRes.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("GET %s.sha256: %v", urlSrc, res.Status)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
wantHash, err := hex.DecodeString(string(strings.TrimSpace(string(hashHex))))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hash := sha256.New()
|
||||
|
||||
dlReq := must.Get(http.NewRequestWithContext(context.Background(), "GET", urlSrc, nil))
|
||||
dlRes, err := c.Do(dlReq)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// TODO(bradfitz): resume from existing partial file on disk
|
||||
if dlRes.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("GET %s: %v", urlSrc, dlRes.Status)
|
||||
}
|
||||
|
||||
of, err := os.Create(fileDst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if ret != nil {
|
||||
of.Close()
|
||||
// TODO(bradfitz): os.Remove(fileDst) too? or keep it to resume from/debug later.
|
||||
}
|
||||
}()
|
||||
pw := &progressWriter{total: res.ContentLength}
|
||||
n, err := io.Copy(io.MultiWriter(hash, of, pw), io.LimitReader(dlRes.Body, res.ContentLength))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n != res.ContentLength {
|
||||
return fmt.Errorf("downloaded %v; want %v", n, res.ContentLength)
|
||||
}
|
||||
if err := of.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
pw.print()
|
||||
|
||||
if !bytes.Equal(hash.Sum(nil), wantHash) {
|
||||
return fmt.Errorf("SHA-256 of downloaded MSI didn't match expected value")
|
||||
}
|
||||
log.Printf("hash matched")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type progressWriter struct {
|
||||
done int64
|
||||
total int64
|
||||
lastPrint time.Time
|
||||
}
|
||||
|
||||
func (pw *progressWriter) Write(p []byte) (n int, err error) {
|
||||
pw.done += int64(len(p))
|
||||
if time.Since(pw.lastPrint) > 2*time.Second {
|
||||
pw.print()
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (pw *progressWriter) print() {
|
||||
pw.lastPrint = time.Now()
|
||||
log.Printf("Downloaded %v/%v (%.1f%%)", pw.done, pw.total, float64(pw.done)/float64(pw.total)*100)
|
||||
}
|
||||
|
||||
func (up *updater) updateFreeBSD() (err error) {
|
||||
if err := requireRoot(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err != nil && !errors.Is(err, errUserAborted) {
|
||||
err = fmt.Errorf(`%w; you can try updating using "pkg upgrade tailscale"`, err)
|
||||
}
|
||||
}()
|
||||
|
||||
out, err := exec.Command("pkg", "update").CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed refresh pkg repository indexes: %w, output: %q", err, out)
|
||||
}
|
||||
out, err = exec.Command("pkg", "rquery", "%v", "tailscale").CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed checking pkg for latest tailscale version: %w, output: %q", err, out)
|
||||
}
|
||||
ver := string(bytes.TrimSpace(out))
|
||||
if up.currentOrDryRun(ver) {
|
||||
return nil
|
||||
}
|
||||
if err := up.confirm(ver); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd := exec.Command("pkg", "upgrade", "tailscale")
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed tailscale update using pkg: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func haveExecutable(name string) bool {
|
||||
path, err := exec.LookPath(name)
|
||||
return err == nil && path != ""
|
||||
}
|
||||
|
||||
func requestedTailscaleVersion(ver, track string) (string, error) {
|
||||
if ver != "" {
|
||||
return ver, nil
|
||||
}
|
||||
return latestTailscaleVersion(track)
|
||||
}
|
||||
|
||||
func latestTailscaleVersion(track string) (string, error) {
|
||||
url := fmt.Sprintf("https://pkgs.tailscale.com/%s/?mode=json&os=%s", track, runtime.GOOS)
|
||||
res, err := http.Get(url)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("fetching latest tailscale version: %w", err)
|
||||
}
|
||||
var latest struct {
|
||||
Version string
|
||||
}
|
||||
err = json.NewDecoder(res.Body).Decode(&latest)
|
||||
res.Body.Close()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("decoding JSON: %v: %w", res.Status, err)
|
||||
}
|
||||
if latest.Version == "" {
|
||||
return "", fmt.Errorf("no version found at %q", url)
|
||||
}
|
||||
return latest.Version, nil
|
||||
}
|
||||
|
||||
func requireRoot() error {
|
||||
if os.Geteuid() == 0 {
|
||||
return nil
|
||||
}
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
return errors.New("must be root; use sudo")
|
||||
case "freebsd", "openbsd":
|
||||
return errors.New("must be root; use doas")
|
||||
default:
|
||||
return errors.New("must be root")
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"os"
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"tailscale.com/clientupdate"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/version"
|
||||
)
|
||||
@@ -51,11 +52,7 @@ func runVersion(ctx context.Context, args []string) error {
|
||||
|
||||
var upstreamVer string
|
||||
if versionArgs.upstream {
|
||||
track := "stable"
|
||||
if version.IsUnstableBuild() {
|
||||
track = "unstable"
|
||||
}
|
||||
upstreamVer, err = latestTailscaleVersion(track)
|
||||
upstreamVer, err = clientupdate.LatestTailscaleVersion(clientupdate.CurrentTrack)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -4,77 +4,23 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"flag"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/cgi"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"tailscale.com/envknob"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/client/web"
|
||||
"tailscale.com/util/cmpx"
|
||||
"tailscale.com/util/groupmember"
|
||||
"tailscale.com/version/distro"
|
||||
"tailscale.com/webui"
|
||||
)
|
||||
|
||||
//go:embed web.html
|
||||
var webHTML string
|
||||
|
||||
//go:embed web.css
|
||||
var webCSS string
|
||||
|
||||
//go:embed auth-redirect.html
|
||||
var authenticationRedirectHTML string
|
||||
|
||||
var tmpl *template.Template
|
||||
|
||||
func init() {
|
||||
tmpl = template.Must(template.New("web.html").Parse(webHTML))
|
||||
template.Must(tmpl.New("web.css").Parse(webCSS))
|
||||
}
|
||||
|
||||
type tmplData struct {
|
||||
Profile tailcfg.UserProfile
|
||||
SynologyUser string
|
||||
Status string
|
||||
DeviceName string
|
||||
IP string
|
||||
AdvertiseExitNode bool
|
||||
AdvertiseRoutes string
|
||||
LicensesURL string
|
||||
TUNMode bool
|
||||
IsSynology bool
|
||||
DSMVersion int // 6 or 7, if IsSynology=true
|
||||
IsUnraid bool
|
||||
UnraidToken string
|
||||
IPNVersion string
|
||||
}
|
||||
|
||||
type postedData struct {
|
||||
AdvertiseRoutes string
|
||||
AdvertiseExitNode bool
|
||||
Reauthenticate bool
|
||||
ForceLogout bool
|
||||
}
|
||||
|
||||
var webCmd = &ffcli.Command{
|
||||
Name: "web",
|
||||
ShortUsage: "web [flags]",
|
||||
@@ -92,7 +38,7 @@ Tailscale, as opposed to a CLI or a native app.
|
||||
webf := newFlagSet("web")
|
||||
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 in dev mode")
|
||||
webf.BoolVar(&webArgs.dev, "dev", false, "run web client in developer mode [this flag is in development, use is unsupported]")
|
||||
return webf
|
||||
})(),
|
||||
Exec: runWeb,
|
||||
@@ -132,18 +78,11 @@ func runWeb(ctx context.Context, args []string) error {
|
||||
return fmt.Errorf("too many non-flag arguments: %q", args)
|
||||
}
|
||||
|
||||
handler := webHandler
|
||||
if true {
|
||||
newServer := &webui.Server{
|
||||
DevMode: webArgs.dev,
|
||||
}
|
||||
cleanup := webui.RunJSDevServer()
|
||||
defer cleanup()
|
||||
handler = newServer.Handle
|
||||
}
|
||||
webServer, cleanup := web.NewServer(webArgs.dev, nil)
|
||||
defer cleanup()
|
||||
|
||||
if webArgs.cgi {
|
||||
if err := cgi.Serve(http.HandlerFunc(handler)); err != nil {
|
||||
if err := cgi.Serve(webServer); err != nil {
|
||||
log.Printf("tailscale.cgi: %v", err)
|
||||
return err
|
||||
}
|
||||
@@ -155,14 +94,14 @@ func runWeb(ctx context.Context, args []string) error {
|
||||
server := &http.Server{
|
||||
Addr: webArgs.listen,
|
||||
TLSConfig: tlsConfig,
|
||||
Handler: http.HandlerFunc(handler),
|
||||
Handler: webServer,
|
||||
}
|
||||
|
||||
log.Printf("web server running on: https://%s", server.Addr)
|
||||
return server.ListenAndServeTLS("", "")
|
||||
} else {
|
||||
log.Printf("web server running on: %s", urlOfListenAddr(webArgs.listen))
|
||||
return http.ListenAndServe(webArgs.listen, http.HandlerFunc(handler))
|
||||
return http.ListenAndServe(webArgs.listen, webServer)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,372 +110,3 @@ func urlOfListenAddr(addr string) string {
|
||||
host, port, _ := net.SplitHostPort(addr)
|
||||
return fmt.Sprintf("http://%s", net.JoinHostPort(cmpx.Or(host, "127.0.0.1"), port))
|
||||
}
|
||||
|
||||
// authorize returns the name of the user accessing the web UI after verifying
|
||||
// whether the user has access to the web UI. The function will write the
|
||||
// error to the provided http.ResponseWriter.
|
||||
// Note: This is different from a tailscale user, and is typically the local
|
||||
// user on the node.
|
||||
func authorize(w http.ResponseWriter, r *http.Request) (string, error) {
|
||||
switch distro.Get() {
|
||||
case distro.Synology:
|
||||
user, err := synoAuthn()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return "", err
|
||||
}
|
||||
if err := authorizeSynology(user); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusForbidden)
|
||||
return "", err
|
||||
}
|
||||
return user, nil
|
||||
case distro.QNAP:
|
||||
user, resp, err := qnapAuthn(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return "", err
|
||||
}
|
||||
if resp.IsAdmin == 0 {
|
||||
http.Error(w, err.Error(), http.StatusForbidden)
|
||||
return "", err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// authorizeSynology checks whether the provided user has access to the web UI
|
||||
// by consulting the membership of the "administrators" group.
|
||||
func authorizeSynology(name string) error {
|
||||
yes, err := groupmember.IsMemberOfGroup("administrators", name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !yes {
|
||||
return fmt.Errorf("not a member of administrators group")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type qnapAuthResponse struct {
|
||||
AuthPassed int `xml:"authPassed"`
|
||||
IsAdmin int `xml:"isAdmin"`
|
||||
AuthSID string `xml:"authSid"`
|
||||
ErrorValue int `xml:"errorValue"`
|
||||
}
|
||||
|
||||
func qnapAuthn(r *http.Request) (string, *qnapAuthResponse, error) {
|
||||
user, err := r.Cookie("NAS_USER")
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
token, err := r.Cookie("qtoken")
|
||||
if err == nil {
|
||||
return qnapAuthnQtoken(r, user.Value, token.Value)
|
||||
}
|
||||
sid, err := r.Cookie("NAS_SID")
|
||||
if err == nil {
|
||||
return qnapAuthnSid(r, user.Value, sid.Value)
|
||||
}
|
||||
return "", nil, fmt.Errorf("not authenticated by any mechanism")
|
||||
}
|
||||
|
||||
// qnapAuthnURL returns the auth URL to use by inferring where the UI is
|
||||
// running based on the request URL. This is necessary because QNAP has so
|
||||
// many options, see https://github.com/tailscale/tailscale/issues/7108
|
||||
// and https://github.com/tailscale/tailscale/issues/6903
|
||||
func qnapAuthnURL(requestUrl string, query url.Values) string {
|
||||
in, err := url.Parse(requestUrl)
|
||||
scheme := ""
|
||||
host := ""
|
||||
if err != nil || in.Scheme == "" {
|
||||
log.Printf("Cannot parse QNAP login URL %v", err)
|
||||
|
||||
// try localhost and hope for the best
|
||||
scheme = "http"
|
||||
host = "localhost"
|
||||
} else {
|
||||
scheme = in.Scheme
|
||||
host = in.Host
|
||||
}
|
||||
|
||||
u := url.URL{
|
||||
Scheme: scheme,
|
||||
Host: host,
|
||||
Path: "/cgi-bin/authLogin.cgi",
|
||||
RawQuery: query.Encode(),
|
||||
}
|
||||
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func qnapAuthnQtoken(r *http.Request, user, token string) (string, *qnapAuthResponse, error) {
|
||||
query := url.Values{
|
||||
"qtoken": []string{token},
|
||||
"user": []string{user},
|
||||
}
|
||||
return qnapAuthnFinish(user, qnapAuthnURL(r.URL.String(), query))
|
||||
}
|
||||
|
||||
func qnapAuthnSid(r *http.Request, user, sid string) (string, *qnapAuthResponse, error) {
|
||||
query := url.Values{
|
||||
"sid": []string{sid},
|
||||
}
|
||||
return qnapAuthnFinish(user, qnapAuthnURL(r.URL.String(), query))
|
||||
}
|
||||
|
||||
func qnapAuthnFinish(user, url string) (string, *qnapAuthResponse, error) {
|
||||
// QNAP Force HTTPS mode uses a self-signed certificate. Even importing
|
||||
// the QNAP root CA isn't enough, the cert doesn't have a usable CN nor
|
||||
// SAN. See https://github.com/tailscale/tailscale/issues/6903
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
client := &http.Client{Transport: tr}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
out, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
authResp := &qnapAuthResponse{}
|
||||
if err := xml.Unmarshal(out, authResp); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if authResp.AuthPassed == 0 {
|
||||
return "", nil, fmt.Errorf("not authenticated")
|
||||
}
|
||||
return user, authResp, nil
|
||||
}
|
||||
|
||||
func synoAuthn() (string, error) {
|
||||
cmd := exec.Command("/usr/syno/synoman/webman/modules/authenticate.cgi")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("auth: %v: %s", err, out)
|
||||
}
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
|
||||
func authRedirect(w http.ResponseWriter, r *http.Request) bool {
|
||||
if distro.Get() == distro.Synology {
|
||||
return synoTokenRedirect(w, r)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func synoTokenRedirect(w http.ResponseWriter, r *http.Request) bool {
|
||||
if r.Header.Get("X-Syno-Token") != "" {
|
||||
return false
|
||||
}
|
||||
if r.URL.Query().Get("SynoToken") != "" {
|
||||
return false
|
||||
}
|
||||
if r.Method == "POST" && r.FormValue("SynoToken") != "" {
|
||||
return false
|
||||
}
|
||||
// We need a SynoToken for authenticate.cgi.
|
||||
// So we tell the client to get one.
|
||||
_, _ = fmt.Fprint(w, synoTokenRedirectHTML)
|
||||
return true
|
||||
}
|
||||
|
||||
const synoTokenRedirectHTML = `<html><body>
|
||||
Redirecting with session token...
|
||||
<script>
|
||||
var serverURL = window.location.protocol + "//" + window.location.host;
|
||||
var req = new XMLHttpRequest();
|
||||
req.overrideMimeType("application/json");
|
||||
req.open("GET", serverURL + "/webman/login.cgi", true);
|
||||
req.onload = function() {
|
||||
var jsonResponse = JSON.parse(req.responseText);
|
||||
var token = jsonResponse["SynoToken"];
|
||||
document.location.href = serverURL + "/webman/3rdparty/Tailscale/?SynoToken=" + token;
|
||||
};
|
||||
req.send(null);
|
||||
</script>
|
||||
</body></html>
|
||||
`
|
||||
|
||||
func webHandler(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
if authRedirect(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
user, err := authorize(w, r)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if r.URL.Path == "/redirect" || r.URL.Path == "/redirect/" {
|
||||
io.WriteString(w, authenticationRedirectHTML)
|
||||
return
|
||||
}
|
||||
|
||||
st, err := localClient.StatusWithoutPeers(ctx)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
prefs, err := localClient.GetPrefs(ctx)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method == "POST" {
|
||||
defer r.Body.Close()
|
||||
var postData postedData
|
||||
type mi map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&postData); err != nil {
|
||||
w.WriteHeader(400)
|
||||
json.NewEncoder(w).Encode(mi{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
routes, err := calcAdvertiseRoutes(postData.AdvertiseRoutes, postData.AdvertiseExitNode)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(mi{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
mp := &ipn.MaskedPrefs{
|
||||
AdvertiseRoutesSet: true,
|
||||
WantRunningSet: true,
|
||||
}
|
||||
mp.Prefs.WantRunning = true
|
||||
mp.Prefs.AdvertiseRoutes = routes
|
||||
log.Printf("Doing edit: %v", mp.Pretty())
|
||||
|
||||
if _, err := localClient.EditPrefs(ctx, mp); err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(mi{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
var reauth, logout bool
|
||||
if postData.Reauthenticate {
|
||||
reauth = true
|
||||
}
|
||||
if postData.ForceLogout {
|
||||
logout = true
|
||||
}
|
||||
log.Printf("tailscaleUp(reauth=%v, logout=%v) ...", reauth, logout)
|
||||
url, err := tailscaleUp(r.Context(), st, postData)
|
||||
log.Printf("tailscaleUp = (URL %v, %v)", url != "", err)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(mi{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if url != "" {
|
||||
json.NewEncoder(w).Encode(mi{"url": url})
|
||||
} else {
|
||||
io.WriteString(w, "{}")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
profile := st.User[st.Self.UserID]
|
||||
deviceName := strings.Split(st.Self.DNSName, ".")[0]
|
||||
versionShort := strings.Split(st.Version, "-")[0]
|
||||
data := tmplData{
|
||||
SynologyUser: user,
|
||||
Profile: profile,
|
||||
Status: st.BackendState,
|
||||
DeviceName: deviceName,
|
||||
LicensesURL: licensesURL(),
|
||||
TUNMode: st.TUN,
|
||||
IsSynology: distro.Get() == distro.Synology || envknob.Bool("TS_FAKE_SYNOLOGY"),
|
||||
DSMVersion: distro.DSMVersion(),
|
||||
IsUnraid: distro.Get() == distro.Unraid,
|
||||
UnraidToken: os.Getenv("UNRAID_CSRF_TOKEN"),
|
||||
IPNVersion: versionShort,
|
||||
}
|
||||
exitNodeRouteV4 := netip.MustParsePrefix("0.0.0.0/0")
|
||||
exitNodeRouteV6 := netip.MustParsePrefix("::/0")
|
||||
for _, r := range prefs.AdvertiseRoutes {
|
||||
if r == exitNodeRouteV4 || r == exitNodeRouteV6 {
|
||||
data.AdvertiseExitNode = true
|
||||
} else {
|
||||
if data.AdvertiseRoutes != "" {
|
||||
data.AdvertiseRoutes += ","
|
||||
}
|
||||
data.AdvertiseRoutes += r.String()
|
||||
}
|
||||
}
|
||||
if len(st.TailscaleIPs) != 0 {
|
||||
data.IP = st.TailscaleIPs[0].String()
|
||||
}
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
if err := tmpl.Execute(buf, data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Write(buf.Bytes())
|
||||
}
|
||||
|
||||
func tailscaleUp(ctx context.Context, st *ipnstate.Status, postData postedData) (authURL string, retErr error) {
|
||||
if postData.ForceLogout {
|
||||
if err := localClient.Logout(ctx); err != nil {
|
||||
return "", fmt.Errorf("Logout error: %w", err)
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
origAuthURL := st.AuthURL
|
||||
isRunning := st.BackendState == ipn.Running.String()
|
||||
|
||||
forceReauth := postData.Reauthenticate
|
||||
if !forceReauth {
|
||||
if origAuthURL != "" {
|
||||
return origAuthURL, nil
|
||||
}
|
||||
if isRunning {
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
|
||||
// printAuthURL reports whether we should print out the
|
||||
// provided auth URL from an IPN notify.
|
||||
printAuthURL := func(url string) bool {
|
||||
return url != origAuthURL
|
||||
}
|
||||
|
||||
watchCtx, cancelWatch := context.WithCancel(ctx)
|
||||
defer cancelWatch()
|
||||
watcher, err := localClient.WatchIPNBus(watchCtx, 0)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer watcher.Close()
|
||||
|
||||
go func() {
|
||||
if !isRunning {
|
||||
localClient.Start(ctx, ipn.Options{})
|
||||
}
|
||||
if forceReauth {
|
||||
localClient.StartLoginInteractive(ctx)
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
n, err := watcher.Next()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if n.ErrMessage != nil {
|
||||
msg := *n.ErrMessage
|
||||
return "", fmt.Errorf("backend error: %v", msg)
|
||||
}
|
||||
if url := n.BrowseToURL; url != nil && printAuthURL(*url) {
|
||||
return *url, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -44,58 +43,3 @@ func TestUrlOfListenAddr(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestQnapAuthnURL(t *testing.T) {
|
||||
query := url.Values{
|
||||
"qtoken": []string{"token"},
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "localhost http",
|
||||
in: "http://localhost:8088/",
|
||||
want: "http://localhost:8088/cgi-bin/authLogin.cgi?qtoken=token",
|
||||
},
|
||||
{
|
||||
name: "localhost https",
|
||||
in: "https://localhost:5000/",
|
||||
want: "https://localhost:5000/cgi-bin/authLogin.cgi?qtoken=token",
|
||||
},
|
||||
{
|
||||
name: "IP http",
|
||||
in: "http://10.1.20.4:80/",
|
||||
want: "http://10.1.20.4:80/cgi-bin/authLogin.cgi?qtoken=token",
|
||||
},
|
||||
{
|
||||
name: "IP6 https",
|
||||
in: "https://[ff7d:0:1:2::1]/",
|
||||
want: "https://[ff7d:0:1:2::1]/cgi-bin/authLogin.cgi?qtoken=token",
|
||||
},
|
||||
{
|
||||
name: "hostname https",
|
||||
in: "https://qnap.example.com/",
|
||||
want: "https://qnap.example.com/cgi-bin/authLogin.cgi?qtoken=token",
|
||||
},
|
||||
{
|
||||
name: "invalid URL",
|
||||
in: "This is not a URL, it is a really really really really really really really really really really really really long string to exercise the URL truncation code in the error path.",
|
||||
want: "http://localhost/cgi-bin/authLogin.cgi?qtoken=token",
|
||||
},
|
||||
{
|
||||
name: "err != nil",
|
||||
in: "http://192.168.0.%31/",
|
||||
want: "http://localhost/cgi-bin/authLogin.cgi?qtoken=token",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
u := qnapAuthnURL(tt.in, query)
|
||||
if u != tt.want {
|
||||
t.Errorf("expected url: %q, got: %q", tt.want, u)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +68,8 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
|
||||
tailscale.com/atomicfile from tailscale.com/ipn+
|
||||
tailscale.com/client/tailscale from tailscale.com/cmd/tailscale/cli+
|
||||
tailscale.com/client/tailscale/apitype from tailscale.com/cmd/tailscale/cli+
|
||||
tailscale.com/client/web from tailscale.com/cmd/tailscale/cli
|
||||
tailscale.com/clientupdate from tailscale.com/cmd/tailscale/cli
|
||||
tailscale.com/cmd/tailscale/cli from tailscale.com/cmd/tailscale
|
||||
tailscale.com/control/controlbase from tailscale.com/control/controlhttp
|
||||
tailscale.com/control/controlhttp from tailscale.com/cmd/tailscale/cli
|
||||
@@ -81,6 +83,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
|
||||
tailscale.com/hostinfo from tailscale.com/net/interfaces+
|
||||
tailscale.com/ipn from tailscale.com/cmd/tailscale/cli+
|
||||
tailscale.com/ipn/ipnstate from tailscale.com/cmd/tailscale/cli+
|
||||
tailscale.com/licenses from tailscale.com/cmd/tailscale/cli+
|
||||
tailscale.com/metrics from tailscale.com/derp
|
||||
tailscale.com/net/dns/recursive from tailscale.com/net/dnsfallback
|
||||
tailscale.com/net/dnscache from tailscale.com/derp/derphttp+
|
||||
@@ -111,7 +114,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
|
||||
tailscale.com/tka from tailscale.com/client/tailscale+
|
||||
W tailscale.com/tsconst from tailscale.com/net/interfaces
|
||||
tailscale.com/tstime from tailscale.com/control/controlhttp+
|
||||
💣 tailscale.com/tstime/mono from tailscale.com/tstime/rate
|
||||
tailscale.com/tstime/mono from tailscale.com/tstime/rate
|
||||
tailscale.com/tstime/rate from tailscale.com/wgengine/filter+
|
||||
tailscale.com/types/dnstype from tailscale.com/tailcfg
|
||||
tailscale.com/types/empty from tailscale.com/ipn
|
||||
@@ -134,19 +137,20 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
|
||||
tailscale.com/util/cmpx from tailscale.com/cmd/tailscale/cli+
|
||||
L 💣 tailscale.com/util/dirwalk from tailscale.com/metrics
|
||||
tailscale.com/util/dnsname from tailscale.com/cmd/tailscale/cli+
|
||||
tailscale.com/util/groupmember from tailscale.com/cmd/tailscale/cli
|
||||
tailscale.com/util/httpm from tailscale.com/client/tailscale
|
||||
tailscale.com/util/groupmember from tailscale.com/client/web
|
||||
tailscale.com/util/httpm from tailscale.com/client/tailscale+
|
||||
tailscale.com/util/lineread from tailscale.com/net/interfaces+
|
||||
L tailscale.com/util/linuxfw from tailscale.com/net/netns
|
||||
tailscale.com/util/mak from tailscale.com/net/netcheck+
|
||||
tailscale.com/util/multierr from tailscale.com/control/controlhttp+
|
||||
tailscale.com/util/must from tailscale.com/cmd/tailscale/cli
|
||||
tailscale.com/util/must from tailscale.com/cmd/tailscale/cli+
|
||||
tailscale.com/util/quarantine from tailscale.com/cmd/tailscale/cli
|
||||
tailscale.com/util/set from tailscale.com/health+
|
||||
tailscale.com/util/singleflight from tailscale.com/net/dnscache
|
||||
tailscale.com/util/slicesx from tailscale.com/net/dnscache+
|
||||
tailscale.com/util/testenv from tailscale.com/cmd/tailscale/cli
|
||||
💣 tailscale.com/util/winutil from tailscale.com/hostinfo+
|
||||
W 💣 tailscale.com/util/winutil/authenticode from tailscale.com/cmd/tailscale/cli
|
||||
W 💣 tailscale.com/util/winutil/authenticode from tailscale.com/clientupdate
|
||||
tailscale.com/version from tailscale.com/cmd/tailscale/cli+
|
||||
tailscale.com/version/distro from tailscale.com/cmd/tailscale/cli+
|
||||
tailscale.com/wgengine/capture from tailscale.com/cmd/tailscale/cli
|
||||
@@ -233,7 +237,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
|
||||
encoding/hex from crypto/x509+
|
||||
encoding/json from expvar+
|
||||
encoding/pem from crypto/tls+
|
||||
encoding/xml from tailscale.com/cmd/tailscale/cli+
|
||||
encoding/xml from github.com/tailscale/goupnp+
|
||||
errors from bufio+
|
||||
expvar from tailscale.com/derp+
|
||||
flag from github.com/peterbourgon/ff/v3+
|
||||
@@ -243,7 +247,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
|
||||
hash/crc32 from compress/gzip+
|
||||
hash/maphash from go4.org/mem
|
||||
html from tailscale.com/ipn/ipnstate+
|
||||
html/template from tailscale.com/cmd/tailscale/cli
|
||||
html/template from tailscale.com/client/web
|
||||
image from github.com/skip2/go-qrcode+
|
||||
image/color from github.com/skip2/go-qrcode+
|
||||
image/png from github.com/skip2/go-qrcode
|
||||
@@ -263,7 +267,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
|
||||
net/http from expvar+
|
||||
net/http/cgi from tailscale.com/cmd/tailscale/cli
|
||||
net/http/httptrace from github.com/tcnksm/go-httpstat+
|
||||
net/http/httputil from tailscale.com/cmd/tailscale/cli
|
||||
net/http/httputil from tailscale.com/cmd/tailscale/cli+
|
||||
net/http/internal from net/http+
|
||||
net/netip from net+
|
||||
net/textproto from golang.org/x/net/http/httpguts+
|
||||
|
||||
@@ -292,11 +292,12 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
tailscale.com/tailcfg from tailscale.com/client/tailscale/apitype+
|
||||
💣 tailscale.com/tempfork/device from tailscale.com/net/tstun/table
|
||||
LD tailscale.com/tempfork/gliderlabs/ssh from tailscale.com/ssh/tailssh
|
||||
tailscale.com/tempfork/heap from tailscale.com/wgengine/magicsock
|
||||
tailscale.com/tka from tailscale.com/ipn/ipnlocal+
|
||||
W tailscale.com/tsconst from tailscale.com/net/interfaces
|
||||
tailscale.com/tsd from tailscale.com/cmd/tailscaled+
|
||||
tailscale.com/tstime from tailscale.com/wgengine/magicsock+
|
||||
💣 tailscale.com/tstime/mono from tailscale.com/net/tstun+
|
||||
tailscale.com/tstime/mono from tailscale.com/net/tstun+
|
||||
tailscale.com/tstime/rate from tailscale.com/wgengine/filter+
|
||||
tailscale.com/tsweb/varz from tailscale.com/cmd/tailscaled
|
||||
tailscale.com/types/dnstype from tailscale.com/ipn/ipnlocal+
|
||||
@@ -343,6 +344,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
tailscale.com/util/slicesx from tailscale.com/net/dnscache+
|
||||
tailscale.com/util/sysresources from tailscale.com/wgengine/magicsock
|
||||
tailscale.com/util/systemd from tailscale.com/control/controlclient+
|
||||
tailscale.com/util/testenv from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/util/uniq from tailscale.com/wgengine/magicsock+
|
||||
💣 tailscale.com/util/winutil from tailscale.com/control/controlclient+
|
||||
W 💣 tailscale.com/util/winutil/authenticode from tailscale.com/util/osdiag
|
||||
@@ -410,6 +412,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
golang.org/x/time/rate from gvisor.dev/gvisor/pkg/tcpip/stack+
|
||||
bufio from compress/flate+
|
||||
bytes from bufio+
|
||||
cmp from slices
|
||||
compress/flate from compress/gzip+
|
||||
compress/gzip from golang.org/x/net/http2+
|
||||
W compress/zlib from debug/pe
|
||||
@@ -494,6 +497,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
runtime/debug from github.com/klauspost/compress/zstd+
|
||||
runtime/pprof from tailscale.com/log/logheap+
|
||||
runtime/trace from net/http/pprof
|
||||
slices from tailscale.com/wgengine/magicsock
|
||||
sort from compress/flate+
|
||||
strconv from compress/flate+
|
||||
strings from bufio+
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !go1.20
|
||||
//go:build !go1.21
|
||||
|
||||
package main
|
||||
|
||||
func init() {
|
||||
you_need_Go_1_20_to_compile_Tailscale()
|
||||
you_need_Go_1_21_to_compile_Tailscale()
|
||||
}
|
||||
|
||||
@@ -32,6 +32,9 @@ func commonSetup(dev bool) (*esbuild.BuildOptions, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if *yarnPath == "" {
|
||||
*yarnPath = path.Join(root, "tool", "yarn")
|
||||
}
|
||||
tsConnectDir := filepath.Join(root, "cmd", "tsconnect")
|
||||
if err := os.Chdir(tsConnectDir); err != nil {
|
||||
return nil, fmt.Errorf("Cannot change cwd: %w", err)
|
||||
|
||||
@@ -20,7 +20,7 @@ var (
|
||||
addr = flag.String("addr", ":9090", "address to listen on")
|
||||
distDir = flag.String("distdir", "./dist", "path of directory to place build output in")
|
||||
pkgDir = flag.String("pkgdir", "./pkg", "path of directory to place NPM package build output in")
|
||||
yarnPath = flag.String("yarnpath", "../../tool/yarn", "path yarn executable used to install JavaScript dependencies")
|
||||
yarnPath = flag.String("yarnpath", "", "path yarn executable used to install JavaScript dependencies")
|
||||
fastCompression = flag.Bool("fast-compression", false, "Use faster compression when building, to speed up build time. Meant to iterative/debugging use only.")
|
||||
devControl = flag.String("dev-control", "", "URL of a development control server to be used with dev. If provided without specifying dev, an error will be returned.")
|
||||
rootDir = flag.String("rootdir", "", "Root directory of repo. If not specified, will be inferred from the cwd.")
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"tailscale.com/health"
|
||||
@@ -21,6 +22,7 @@ import (
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/types/netmap"
|
||||
"tailscale.com/types/persist"
|
||||
"tailscale.com/types/ptr"
|
||||
"tailscale.com/types/structs"
|
||||
)
|
||||
|
||||
@@ -45,6 +47,94 @@ func (g *LoginGoal) sendLogoutError(err error) {
|
||||
|
||||
var _ Client = (*Auto)(nil)
|
||||
|
||||
// waitUnpause waits until the client is unpaused then returns. It only
|
||||
// returns an error if the client is closed.
|
||||
func (c *Auto) waitUnpause(routineLogName string) error {
|
||||
c.mu.Lock()
|
||||
if !c.paused {
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
unpaused := c.unpausedChanLocked()
|
||||
c.mu.Unlock()
|
||||
c.logf("%s: awaiting unpause", routineLogName)
|
||||
select {
|
||||
case <-unpaused:
|
||||
c.logf("%s: unpaused", routineLogName)
|
||||
return nil
|
||||
case <-c.quit:
|
||||
return errors.New("quit")
|
||||
}
|
||||
}
|
||||
|
||||
// updateRoutine is responsible for informing the server of worthy changes to
|
||||
// our local state. It runs in its own goroutine.
|
||||
func (c *Auto) updateRoutine() {
|
||||
defer close(c.updateDone)
|
||||
bo := backoff.NewBackoff("updateRoutine", c.logf, 30*time.Second)
|
||||
|
||||
// lastUpdateGenInformed is the value of lastUpdateAt that we've successfully
|
||||
// informed the server of.
|
||||
var lastUpdateGenInformed updateGen
|
||||
|
||||
for {
|
||||
if err := c.waitUnpause("updateRoutine"); err != nil {
|
||||
c.logf("updateRoutine: exiting")
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
gen := c.lastUpdateGen
|
||||
ctx := c.mapCtx
|
||||
needUpdate := gen > 0 && gen != lastUpdateGenInformed && c.loggedIn
|
||||
c.mu.Unlock()
|
||||
|
||||
if needUpdate {
|
||||
select {
|
||||
case <-c.quit:
|
||||
c.logf("updateRoutine: exiting")
|
||||
return
|
||||
default:
|
||||
}
|
||||
} else {
|
||||
// Nothing to do, wait for a signal.
|
||||
select {
|
||||
case <-c.quit:
|
||||
c.logf("updateRoutine: exiting")
|
||||
return
|
||||
case <-c.updateCh:
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
t0 := c.clock.Now()
|
||||
err := c.direct.SendUpdate(ctx)
|
||||
d := time.Since(t0).Round(time.Millisecond)
|
||||
if err != nil {
|
||||
if ctx.Err() == nil {
|
||||
c.direct.logf("lite map update error after %v: %v", d, err)
|
||||
}
|
||||
bo.BackOff(ctx, err)
|
||||
continue
|
||||
}
|
||||
bo.BackOff(ctx, nil)
|
||||
c.direct.logf("[v1] successful lite map update in %v", d)
|
||||
|
||||
lastUpdateGenInformed = gen
|
||||
}
|
||||
}
|
||||
|
||||
// atomicGen is an atomic int64 generator. It is used to generate monotonically
|
||||
// increasing numbers for updateGen.
|
||||
var atomicGen atomic.Int64
|
||||
|
||||
func nextUpdateGen() updateGen {
|
||||
return updateGen(atomicGen.Add(1))
|
||||
}
|
||||
|
||||
// updateGen is a monotonically increasing number that represents a particular
|
||||
// update to the local state.
|
||||
type updateGen int64
|
||||
|
||||
// Auto connects to a tailcontrol server for a node.
|
||||
// It's a concrete implementation of the Client interface.
|
||||
type Auto struct {
|
||||
@@ -53,6 +143,7 @@ type Auto struct {
|
||||
logf logger.Logf
|
||||
expiry *time.Time
|
||||
closed bool
|
||||
updateCh chan struct{} // readable when we should inform the server of a change
|
||||
newMapCh chan struct{} // readable when we must restart a map request
|
||||
statusFunc func(Status) // called to update Client status; always non-nil
|
||||
|
||||
@@ -60,25 +151,26 @@ type Auto struct {
|
||||
|
||||
mu sync.Mutex // mutex guards the following fields
|
||||
|
||||
paused bool // whether we should stop making HTTP requests
|
||||
unpauseWaiters []chan struct{}
|
||||
loggedIn bool // true if currently logged in
|
||||
loginGoal *LoginGoal // non-nil if some login activity is desired
|
||||
synced bool // true if our netmap is up-to-date
|
||||
inPollNetMap bool // true if currently running a PollNetMap
|
||||
inLiteMapUpdate bool // true if a lite (non-streaming) map request is outstanding
|
||||
liteMapUpdateCancel context.CancelFunc // cancels a lite map update, may be nil
|
||||
liteMapUpdateCancels int // how many times we've canceled a lite map update
|
||||
inSendStatus int // number of sendStatus calls currently in progress
|
||||
state State
|
||||
// lastUpdateGen is the gen of last update we had an update worth sending to
|
||||
// the server.
|
||||
lastUpdateGen updateGen
|
||||
|
||||
paused bool // whether we should stop making HTTP requests
|
||||
unpauseWaiters []chan struct{}
|
||||
loggedIn bool // true if currently logged in
|
||||
loginGoal *LoginGoal // non-nil if some login activity is desired
|
||||
synced bool // true if our netmap is up-to-date
|
||||
inSendStatus int // number of sendStatus calls currently in progress
|
||||
state State
|
||||
|
||||
authCtx context.Context // context used for auth requests
|
||||
mapCtx context.Context // context used for netmap requests
|
||||
authCancel func() // cancel the auth context
|
||||
mapCancel func() // cancel the netmap context
|
||||
mapCtx context.Context // context used for netmap and update requests
|
||||
authCancel func() // cancel authCtx
|
||||
mapCancel func() // cancel mapCtx
|
||||
quit chan struct{} // when closed, goroutines should all exit
|
||||
authDone chan struct{} // when closed, auth goroutine is done
|
||||
mapDone chan struct{} // when closed, map goroutine is done
|
||||
authDone chan struct{} // when closed, authRoutine is done
|
||||
mapDone chan struct{} // when closed, mapRoutine is done
|
||||
updateDone chan struct{} // when closed, updateRoutine is done
|
||||
}
|
||||
|
||||
// New creates and starts a new Auto.
|
||||
@@ -115,10 +207,12 @@ func NewNoStart(opts Options) (_ *Auto, err error) {
|
||||
direct: direct,
|
||||
clock: opts.Clock,
|
||||
logf: opts.Logf,
|
||||
updateCh: make(chan struct{}, 1),
|
||||
newMapCh: make(chan struct{}, 1),
|
||||
quit: make(chan struct{}),
|
||||
authDone: make(chan struct{}),
|
||||
mapDone: make(chan struct{}),
|
||||
updateDone: make(chan struct{}),
|
||||
statusFunc: opts.Status,
|
||||
}
|
||||
c.authCtx, c.authCancel = context.WithCancel(context.Background())
|
||||
@@ -161,85 +255,34 @@ func (c *Auto) SetPaused(paused bool) {
|
||||
func (c *Auto) Start() {
|
||||
go c.authRoutine()
|
||||
go c.mapRoutine()
|
||||
go c.updateRoutine()
|
||||
}
|
||||
|
||||
// sendNewMapRequest either sends a new OmitPeers, non-streaming map request
|
||||
// (to just send Hostinfo/Netinfo/Endpoints info, while keeping an existing
|
||||
// streaming response open), or start a new streaming one if necessary.
|
||||
// updateControl sends a new OmitPeers, non-streaming map request (to just send
|
||||
// Hostinfo/Netinfo/Endpoints info, while keeping an existing streaming response
|
||||
// open).
|
||||
//
|
||||
// It should be called whenever there's something new to tell the server.
|
||||
func (c *Auto) sendNewMapRequest() {
|
||||
func (c *Auto) updateControl() {
|
||||
gen := nextUpdateGen()
|
||||
c.mu.Lock()
|
||||
|
||||
// If we're not already streaming a netmap, then tear down everything
|
||||
// and start a new stream (which starts by sending a new map request)
|
||||
if !c.inPollNetMap || !c.loggedIn {
|
||||
if gen < c.lastUpdateGen {
|
||||
// This update is out of date.
|
||||
c.mu.Unlock()
|
||||
c.cancelMapSafely()
|
||||
return
|
||||
}
|
||||
c.lastUpdateGen = gen
|
||||
c.mu.Unlock()
|
||||
|
||||
// If we are already in process of doing a LiteMapUpdate, cancel it and
|
||||
// try a new one. If this is the 10th time we have done this
|
||||
// cancelation, tear down everything and start again.
|
||||
const maxLiteMapUpdateAttempts = 10
|
||||
if c.inLiteMapUpdate {
|
||||
// Always cancel the in-flight lite map update, regardless of
|
||||
// whether we cancel the streaming map request or not.
|
||||
c.liteMapUpdateCancel()
|
||||
c.inLiteMapUpdate = false
|
||||
|
||||
if c.liteMapUpdateCancels >= maxLiteMapUpdateAttempts {
|
||||
// Not making progress
|
||||
c.mu.Unlock()
|
||||
c.cancelMapSafely()
|
||||
return
|
||||
}
|
||||
|
||||
// Increment our cancel counter and continue below to start a
|
||||
// new lite update.
|
||||
c.liteMapUpdateCancels++
|
||||
select {
|
||||
case c.updateCh <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
|
||||
// Otherwise, send a lite update that doesn't keep a
|
||||
// long-running stream response.
|
||||
defer c.mu.Unlock()
|
||||
c.inLiteMapUpdate = true
|
||||
ctx, cancel := context.WithTimeout(c.mapCtx, 10*time.Second)
|
||||
c.liteMapUpdateCancel = cancel
|
||||
go func() {
|
||||
defer cancel()
|
||||
t0 := c.clock.Now()
|
||||
err := c.direct.SendLiteMapUpdate(ctx)
|
||||
d := time.Since(t0).Round(time.Millisecond)
|
||||
|
||||
c.mu.Lock()
|
||||
c.inLiteMapUpdate = false
|
||||
c.liteMapUpdateCancel = nil
|
||||
if err == nil {
|
||||
c.liteMapUpdateCancels = 0
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
if err == nil {
|
||||
c.logf("[v1] successful lite map update in %v", d)
|
||||
return
|
||||
}
|
||||
if ctx.Err() == nil {
|
||||
c.logf("lite map update after %v: %v", d, err)
|
||||
}
|
||||
if !errors.Is(ctx.Err(), context.Canceled) {
|
||||
// Fall back to restarting the long-polling map
|
||||
// request (the old heavy way) if the lite update
|
||||
// failed for reasons other than the context being
|
||||
// canceled.
|
||||
c.cancelMapSafely()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (c *Auto) cancelAuth() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.authCancel != nil {
|
||||
c.authCancel()
|
||||
}
|
||||
@@ -247,9 +290,9 @@ func (c *Auto) cancelAuth() {
|
||||
c.authCtx, c.authCancel = context.WithCancel(context.Background())
|
||||
c.authCtx = sockstats.WithSockStats(c.authCtx, sockstats.LabelControlClientAuto, c.logf)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// cancelMapLocked is like cancelMap, but assumes the caller holds c.mu.
|
||||
func (c *Auto) cancelMapLocked() {
|
||||
if c.mapCancel != nil {
|
||||
c.mapCancel()
|
||||
@@ -257,56 +300,36 @@ func (c *Auto) cancelMapLocked() {
|
||||
if !c.closed {
|
||||
c.mapCtx, c.mapCancel = context.WithCancel(context.Background())
|
||||
c.mapCtx = sockstats.WithSockStats(c.mapCtx, sockstats.LabelControlClientAuto, c.logf)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Auto) cancelMapUnsafely() {
|
||||
c.mu.Lock()
|
||||
c.cancelMapLocked()
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *Auto) cancelMapSafely() {
|
||||
// cancelMap cancels the existing mapPoll and liteUpdates.
|
||||
func (c *Auto) cancelMap() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.cancelMapLocked()
|
||||
}
|
||||
|
||||
// Always reset our lite map cancels counter if we're canceling
|
||||
// everything, since we're about to restart with a new map update; this
|
||||
// allows future calls to sendNewMapRequest to retry sending lite
|
||||
// updates.
|
||||
c.liteMapUpdateCancels = 0
|
||||
// restartMap cancels the existing mapPoll and liteUpdates, and then starts a
|
||||
// new one.
|
||||
func (c *Auto) restartMap() {
|
||||
c.mu.Lock()
|
||||
c.cancelMapLocked()
|
||||
synced := c.synced
|
||||
c.mu.Unlock()
|
||||
|
||||
c.logf("[v1] cancelMapSafely: synced=%v", c.synced)
|
||||
c.logf("[v1] restartMap: synced=%v", synced)
|
||||
|
||||
if c.inPollNetMap {
|
||||
// received at least one netmap since the last
|
||||
// interruption. That means the server has already
|
||||
// fully processed our last request, which might
|
||||
// include UpdateEndpoints(). Interrupt it and try
|
||||
// again.
|
||||
c.cancelMapLocked()
|
||||
} else {
|
||||
// !synced means we either haven't done a netmap
|
||||
// request yet, or it hasn't answered yet. So the
|
||||
// server is in an undefined state. If we send
|
||||
// another netmap request too soon, it might race
|
||||
// with the last one, and if we're very unlucky,
|
||||
// the new request will be applied before the old one,
|
||||
// and the wrong endpoints will get registered. We
|
||||
// have to tell the client to abort politely, only
|
||||
// after it receives a response to its existing netmap
|
||||
// request.
|
||||
select {
|
||||
case c.newMapCh <- struct{}{}:
|
||||
c.logf("[v1] cancelMapSafely: wrote to channel")
|
||||
default:
|
||||
// if channel write failed, then there was already
|
||||
// an outstanding newMapCh request. One is enough,
|
||||
// since it'll always use the latest endpoints.
|
||||
c.logf("[v1] cancelMapSafely: channel was full")
|
||||
}
|
||||
select {
|
||||
case c.newMapCh <- struct{}{}:
|
||||
c.logf("[v1] restartMap: wrote to channel")
|
||||
default:
|
||||
// if channel write failed, then there was already
|
||||
// an outstanding newMapCh request. One is enough,
|
||||
// since it'll always use the latest endpoints.
|
||||
c.logf("[v1] restartMap: channel was full")
|
||||
}
|
||||
c.updateControl()
|
||||
}
|
||||
|
||||
func (c *Auto) authRoutine() {
|
||||
@@ -427,7 +450,7 @@ func (c *Auto) authRoutine() {
|
||||
c.mu.Unlock()
|
||||
|
||||
c.sendStatus("authRoutine-success", nil, "", nil)
|
||||
c.cancelMapSafely()
|
||||
c.restartMap()
|
||||
bo.BackOff(ctx, nil)
|
||||
}
|
||||
}
|
||||
@@ -457,25 +480,50 @@ func (c *Auto) unpausedChanLocked() <-chan struct{} {
|
||||
return unpaused
|
||||
}
|
||||
|
||||
// mapRoutineState is the state of Auto.mapRoutine while it's running.
|
||||
type mapRoutineState struct {
|
||||
c *Auto
|
||||
bo *backoff.Backoff
|
||||
}
|
||||
|
||||
func (mrs mapRoutineState) UpdateFullNetmap(nm *netmap.NetworkMap) {
|
||||
c := mrs.c
|
||||
health.SetInPollNetMap(true)
|
||||
|
||||
c.mu.Lock()
|
||||
ctx := c.mapCtx
|
||||
c.synced = true
|
||||
if c.loggedIn {
|
||||
c.state = StateSynchronized
|
||||
}
|
||||
c.expiry = ptr.To(nm.Expiry)
|
||||
stillAuthed := c.loggedIn
|
||||
c.logf("[v1] mapRoutine: netmap received: %s", c.state)
|
||||
c.mu.Unlock()
|
||||
|
||||
if stillAuthed {
|
||||
c.sendStatus("mapRoutine-got-netmap", nil, "", nm)
|
||||
}
|
||||
// Reset the backoff timer if we got a netmap.
|
||||
mrs.bo.BackOff(ctx, nil)
|
||||
}
|
||||
|
||||
// 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() {
|
||||
defer close(c.mapDone)
|
||||
bo := backoff.NewBackoff("mapRoutine", c.logf, 30*time.Second)
|
||||
mrs := &mapRoutineState{
|
||||
c: c,
|
||||
bo: backoff.NewBackoff("mapRoutine", c.logf, 30*time.Second),
|
||||
}
|
||||
|
||||
for {
|
||||
c.mu.Lock()
|
||||
if c.paused {
|
||||
unpaused := c.unpausedChanLocked()
|
||||
c.mu.Unlock()
|
||||
c.logf("mapRoutine: awaiting unpause")
|
||||
select {
|
||||
case <-unpaused:
|
||||
c.logf("mapRoutine: unpaused")
|
||||
case <-c.quit:
|
||||
c.logf("mapRoutine: quit")
|
||||
return
|
||||
}
|
||||
continue
|
||||
if err := c.waitUnpause("mapRoutine"); err != nil {
|
||||
c.logf("mapRoutine: exiting")
|
||||
return
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.logf("[v1] mapRoutine: %s", c.state)
|
||||
loggedIn := c.loggedIn
|
||||
ctx := c.mapCtx
|
||||
@@ -512,54 +560,13 @@ func (c *Auto) mapRoutine() {
|
||||
c.logf("[v1] mapRoutine: new map needed while idle.")
|
||||
}
|
||||
} else {
|
||||
// Be sure this is false when we're not inside
|
||||
// PollNetMap, so that cancelMapSafely() can notify
|
||||
// us correctly.
|
||||
c.mu.Lock()
|
||||
c.inPollNetMap = false
|
||||
c.mu.Unlock()
|
||||
health.SetInPollNetMap(false)
|
||||
|
||||
err := c.direct.PollNetMap(ctx, func(nm *netmap.NetworkMap) {
|
||||
health.SetInPollNetMap(true)
|
||||
c.mu.Lock()
|
||||
|
||||
select {
|
||||
case <-c.newMapCh:
|
||||
c.logf("[v1] mapRoutine: new map request during PollNetMap. canceling.")
|
||||
c.cancelMapLocked()
|
||||
|
||||
// Don't emit this netmap; we're
|
||||
// about to request a fresh one.
|
||||
c.mu.Unlock()
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
c.synced = true
|
||||
c.inPollNetMap = true
|
||||
if c.loggedIn {
|
||||
c.state = StateSynchronized
|
||||
}
|
||||
exp := nm.Expiry
|
||||
c.expiry = &exp
|
||||
stillAuthed := c.loggedIn
|
||||
state := c.state
|
||||
|
||||
c.mu.Unlock()
|
||||
|
||||
c.logf("[v1] mapRoutine: netmap received: %s", state)
|
||||
if stillAuthed {
|
||||
c.sendStatus("mapRoutine-got-netmap", nil, "", nm)
|
||||
}
|
||||
// Reset the backoff timer if we got a netmap.
|
||||
bo.BackOff(ctx, nil)
|
||||
})
|
||||
err := c.direct.PollNetMap(ctx, mrs)
|
||||
|
||||
health.SetInPollNetMap(false)
|
||||
c.mu.Lock()
|
||||
c.synced = false
|
||||
c.inPollNetMap = false
|
||||
if c.state == StateSynchronized {
|
||||
c.state = StateAuthenticated
|
||||
}
|
||||
@@ -567,16 +574,14 @@ func (c *Auto) mapRoutine() {
|
||||
c.mu.Unlock()
|
||||
|
||||
if paused {
|
||||
mrs.bo.BackOff(ctx, nil)
|
||||
c.logf("mapRoutine: paused")
|
||||
continue
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
report(err, "PollNetMap")
|
||||
bo.BackOff(ctx, err)
|
||||
continue
|
||||
}
|
||||
bo.BackOff(ctx, nil)
|
||||
report(err, "PollNetMap")
|
||||
mrs.bo.BackOff(ctx, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -601,7 +606,7 @@ func (c *Auto) SetHostinfo(hi *tailcfg.Hostinfo) {
|
||||
}
|
||||
|
||||
// Send new Hostinfo to server
|
||||
c.sendNewMapRequest()
|
||||
c.updateControl()
|
||||
}
|
||||
|
||||
func (c *Auto) SetNetInfo(ni *tailcfg.NetInfo) {
|
||||
@@ -613,12 +618,17 @@ func (c *Auto) SetNetInfo(ni *tailcfg.NetInfo) {
|
||||
}
|
||||
|
||||
// Send new NetInfo to server
|
||||
c.sendNewMapRequest()
|
||||
c.updateControl()
|
||||
}
|
||||
|
||||
// SetTKAHead updates the TKA head hash that map-request infrastructure sends.
|
||||
func (c *Auto) SetTKAHead(headHash string) {
|
||||
c.direct.SetTKAHead(headHash)
|
||||
if !c.direct.SetTKAHead(headHash) {
|
||||
return
|
||||
}
|
||||
|
||||
// Send new TKAHead to server
|
||||
c.updateControl()
|
||||
}
|
||||
|
||||
func (c *Auto) sendStatus(who string, err error, url string, nm *netmap.NetworkMap) {
|
||||
@@ -644,8 +654,7 @@ func (c *Auto) sendStatus(who string, err error, url string, nm *netmap.NetworkM
|
||||
logoutFin = new(empty.Message)
|
||||
}
|
||||
if nm != nil && loggedIn && synced {
|
||||
pp := c.direct.GetPersist()
|
||||
p = &pp
|
||||
p = ptr.To(c.direct.GetPersist())
|
||||
} else {
|
||||
// don't send netmap status, as it's misleading when we're
|
||||
// not logged in.
|
||||
@@ -728,7 +737,7 @@ func (c *Auto) SetExpirySooner(ctx context.Context, expiry time.Time) error {
|
||||
func (c *Auto) UpdateEndpoints(endpoints []tailcfg.Endpoint) {
|
||||
changed := c.direct.SetEndpoints(endpoints)
|
||||
if changed {
|
||||
c.sendNewMapRequest()
|
||||
c.updateControl()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -750,8 +759,9 @@ func (c *Auto) Shutdown() {
|
||||
close(c.quit)
|
||||
c.cancelAuth()
|
||||
<-c.authDone
|
||||
c.cancelMapUnsafely()
|
||||
c.cancelMap()
|
||||
<-c.mapDone
|
||||
<-c.updateDone
|
||||
if direct != nil {
|
||||
direct.Close()
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ import (
|
||||
"tailscale.com/types/netmap"
|
||||
"tailscale.com/types/opt"
|
||||
"tailscale.com/types/persist"
|
||||
"tailscale.com/types/ptr"
|
||||
"tailscale.com/types/tkatype"
|
||||
"tailscale.com/util/clientmetric"
|
||||
"tailscale.com/util/multierr"
|
||||
@@ -171,7 +172,7 @@ type ControlDialPlanner interface {
|
||||
// Pinger is the LocalBackend.Ping method.
|
||||
type Pinger interface {
|
||||
// Ping is a request to do a ping with the peer handling the given IP.
|
||||
Ping(ctx context.Context, ip netip.Addr, pingType tailcfg.PingType) (*ipnstate.PingResult, error)
|
||||
Ping(ctx context.Context, ip netip.Addr, pingType tailcfg.PingType, size int) (*ipnstate.PingResult, error)
|
||||
}
|
||||
|
||||
type Decompressor interface {
|
||||
@@ -179,6 +180,16 @@ type Decompressor interface {
|
||||
Close()
|
||||
}
|
||||
|
||||
// NetmapUpdater is the interface needed by the controlclient to enact change in
|
||||
// the world as a function of updates received from the network.
|
||||
type NetmapUpdater interface {
|
||||
UpdateFullNetmap(*netmap.NetworkMap)
|
||||
|
||||
// TODO(bradfitz): add methods to do fine-grained updates, mutating just
|
||||
// parts of peers, without implementations of NetmapUpdater needing to do
|
||||
// the diff themselves between the previous full & next full network maps.
|
||||
}
|
||||
|
||||
// NewDirect returns a new Direct client.
|
||||
func NewDirect(opts Options) (*Direct, error) {
|
||||
if opts.ServerURL == "" {
|
||||
@@ -259,10 +270,8 @@ func NewDirect(opts Options) (*Direct, error) {
|
||||
if opts.Hostinfo == nil {
|
||||
c.SetHostinfo(hostinfo.New())
|
||||
} else {
|
||||
ni := opts.Hostinfo.NetInfo
|
||||
opts.Hostinfo.NetInfo = nil
|
||||
c.SetHostinfo(opts.Hostinfo)
|
||||
if ni != nil {
|
||||
if ni := opts.Hostinfo.NetInfo; ni != nil {
|
||||
c.SetNetInfo(ni)
|
||||
}
|
||||
}
|
||||
@@ -294,6 +303,8 @@ func (c *Direct) SetHostinfo(hi *tailcfg.Hostinfo) bool {
|
||||
if hi == nil {
|
||||
panic("nil Hostinfo")
|
||||
}
|
||||
hi = ptr.To(*hi)
|
||||
hi.NetInfo = nil
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
@@ -560,7 +571,7 @@ func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, new
|
||||
request.NodeKey.ShortString(), opt.URL != "", len(nodeKeySignature) > 0)
|
||||
request.Auth.Oauth2Token = opt.Token
|
||||
request.Auth.Provider = persist.Provider
|
||||
request.Auth.LoginName = persist.LoginName
|
||||
request.Auth.LoginName = persist.UserProfile.LoginName
|
||||
request.Auth.AuthKey = authKey
|
||||
err = signRegisterRequest(&request, c.serverURL, c.serverKey, machinePrivKey.Public())
|
||||
if err != nil {
|
||||
@@ -646,9 +657,6 @@ func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, new
|
||||
if resp.Login.Provider != "" {
|
||||
persist.Provider = resp.Login.Provider
|
||||
}
|
||||
if resp.Login.LoginName != "" {
|
||||
persist.LoginName = resp.Login.LoginName
|
||||
}
|
||||
persist.UserProfile = tailcfg.UserProfile{
|
||||
ID: resp.User.ID,
|
||||
DisplayName: resp.Login.DisplayName,
|
||||
@@ -769,31 +777,38 @@ func (c *Direct) SetEndpoints(endpoints []tailcfg.Endpoint) (changed bool) {
|
||||
return c.newEndpoints(endpoints)
|
||||
}
|
||||
|
||||
// PollNetMap makes a /map request to download the network map, calling cb with
|
||||
// each new netmap.
|
||||
// It always returns a non-nil error describing the reason for the failure
|
||||
// or why the request ended.
|
||||
func (c *Direct) PollNetMap(ctx context.Context, cb func(*netmap.NetworkMap)) error {
|
||||
return c.sendMapRequest(ctx, -1, false, cb)
|
||||
// PollNetMap makes a /map request to download the network map, calling
|
||||
// NetmapUpdater on each update from the control plane.
|
||||
//
|
||||
// It always returns a non-nil error describing the reason for the failure or
|
||||
// why the request ended.
|
||||
func (c *Direct) PollNetMap(ctx context.Context, nu NetmapUpdater) error {
|
||||
return c.sendMapRequest(ctx, true, nu)
|
||||
}
|
||||
|
||||
// FetchNetMap fetches the netmap once.
|
||||
func (c *Direct) FetchNetMap(ctx context.Context) (*netmap.NetworkMap, error) {
|
||||
var ret *netmap.NetworkMap
|
||||
err := c.sendMapRequest(ctx, 1, false, func(nm *netmap.NetworkMap) {
|
||||
ret = nm
|
||||
})
|
||||
if err == nil && ret == nil {
|
||||
type rememberLastNetmapUpdater struct {
|
||||
last *netmap.NetworkMap
|
||||
}
|
||||
|
||||
func (nu *rememberLastNetmapUpdater) UpdateFullNetmap(nm *netmap.NetworkMap) {
|
||||
nu.last = nm
|
||||
}
|
||||
|
||||
// FetchNetMapForTest fetches the netmap once.
|
||||
func (c *Direct) FetchNetMapForTest(ctx context.Context) (*netmap.NetworkMap, error) {
|
||||
var nu rememberLastNetmapUpdater
|
||||
err := c.sendMapRequest(ctx, false, &nu)
|
||||
if err == nil && nu.last == nil {
|
||||
return nil, errors.New("[unexpected] sendMapRequest success without callback")
|
||||
}
|
||||
return ret, err
|
||||
return nu.last, err
|
||||
}
|
||||
|
||||
// SendLiteMapUpdate makes a /map request to update the server of our latest state,
|
||||
// but does not fetch anything. It returns an error if the server did not return a
|
||||
// SendUpdate makes a /map request to update the server of our latest state, but
|
||||
// does not fetch anything. It returns an error if the server did not return a
|
||||
// successful 200 OK response.
|
||||
func (c *Direct) SendLiteMapUpdate(ctx context.Context) error {
|
||||
return c.sendMapRequest(ctx, 1, false, nil)
|
||||
func (c *Direct) SendUpdate(ctx context.Context) error {
|
||||
return c.sendMapRequest(ctx, false, nil)
|
||||
}
|
||||
|
||||
// If we go more than pollTimeout without hearing from the server,
|
||||
@@ -801,17 +816,21 @@ func (c *Direct) SendLiteMapUpdate(ctx context.Context) error {
|
||||
// every minute.
|
||||
const pollTimeout = 120 * time.Second
|
||||
|
||||
// sendMapRequest makes a /map request to download the network map, calling cb with
|
||||
// each new netmap. If maxPolls is -1, it will poll forever and only returns if
|
||||
// the context expires or the server returns an error/closes the connection and as
|
||||
// such always returns a non-nil error.
|
||||
// sendMapRequest makes a /map request to download the network map, calling cb
|
||||
// with each new netmap. If isStreaming, it will poll forever and only returns
|
||||
// if the context expires or the server returns an error/closes the connection
|
||||
// and as such always returns a non-nil error.
|
||||
//
|
||||
// If cb is nil, OmitPeers will be set to true.
|
||||
func (c *Direct) sendMapRequest(ctx context.Context, maxPolls int, readOnly bool, cb func(*netmap.NetworkMap)) error {
|
||||
func (c *Direct) sendMapRequest(ctx context.Context, isStreaming bool, nu NetmapUpdater) error {
|
||||
if isStreaming && nu == nil {
|
||||
panic("cb must be non-nil if isStreaming is true")
|
||||
}
|
||||
|
||||
metricMapRequests.Add(1)
|
||||
metricMapRequestsActive.Add(1)
|
||||
defer metricMapRequestsActive.Add(-1)
|
||||
if maxPolls == -1 {
|
||||
if isStreaming {
|
||||
metricMapRequestsPoll.Add(1)
|
||||
} else {
|
||||
metricMapRequestsLite.Add(1)
|
||||
@@ -847,8 +866,7 @@ func (c *Direct) sendMapRequest(ctx context.Context, maxPolls int, readOnly bool
|
||||
return errors.New("hostinfo: BackendLogID missing")
|
||||
}
|
||||
|
||||
allowStream := maxPolls != 1
|
||||
c.logf("[v1] PollNetMap: stream=%v ep=%v", allowStream, epStrs)
|
||||
c.logf("[v1] PollNetMap: stream=%v ep=%v", isStreaming, epStrs)
|
||||
|
||||
vlogf := logger.Discard
|
||||
if DevKnob.DumpNetMaps() {
|
||||
@@ -864,23 +882,11 @@ func (c *Direct) sendMapRequest(ctx context.Context, maxPolls int, readOnly bool
|
||||
DiscoKey: c.discoPubKey,
|
||||
Endpoints: epStrs,
|
||||
EndpointTypes: epTypes,
|
||||
Stream: allowStream,
|
||||
Stream: isStreaming,
|
||||
Hostinfo: hi,
|
||||
DebugFlags: c.debugFlags,
|
||||
OmitPeers: cb == nil,
|
||||
OmitPeers: nu == nil,
|
||||
TKAHead: c.tkaHead,
|
||||
|
||||
// Previously we'd set ReadOnly to true if we didn't have any endpoints
|
||||
// yet as we expected to learn them in a half second and restart the full
|
||||
// streaming map poll, however as we are trying to reduce the number of
|
||||
// times we restart the full streaming map poll we now just set ReadOnly
|
||||
// false when we're doing a full streaming map poll.
|
||||
//
|
||||
// TODO(maisem/bradfitz): really ReadOnly should be set to true if for
|
||||
// all streams and we should only do writes via lite map updates.
|
||||
// However that requires an audit and a bunch of testing to make sure we
|
||||
// don't break anything.
|
||||
ReadOnly: readOnly && !allowStream,
|
||||
}
|
||||
var extraDebugFlags []string
|
||||
if hi != nil && c.netMon != nil && !c.skipIPForwardingCheck &&
|
||||
@@ -950,7 +956,7 @@ func (c *Direct) sendMapRequest(ctx context.Context, maxPolls int, readOnly bool
|
||||
|
||||
health.NoteMapRequestHeard(request)
|
||||
|
||||
if cb == nil {
|
||||
if nu == nil {
|
||||
io.Copy(io.Discard, res.Body)
|
||||
return nil
|
||||
}
|
||||
@@ -997,7 +1003,7 @@ func (c *Direct) sendMapRequest(ctx context.Context, maxPolls int, readOnly bool
|
||||
// the same format before just closing the connection.
|
||||
// We can use this same read loop either way.
|
||||
var msg []byte
|
||||
for i := 0; i < maxPolls || maxPolls < 0; i++ {
|
||||
for i := 0; i == 0 || isStreaming; i++ {
|
||||
vlogf("netmap: starting size read after %v (poll %v)", time.Since(t0).Round(time.Millisecond), i)
|
||||
var siz [4]byte
|
||||
if _, err := io.ReadFull(res.Body, siz[:]); err != nil {
|
||||
@@ -1021,7 +1027,7 @@ func (c *Direct) sendMapRequest(ctx context.Context, maxPolls int, readOnly bool
|
||||
|
||||
metricMapResponseMessages.Add(1)
|
||||
|
||||
if allowStream {
|
||||
if isStreaming {
|
||||
health.GotStreamedMapResponse()
|
||||
}
|
||||
|
||||
@@ -1104,6 +1110,16 @@ func (c *Direct) sendMapRequest(ctx context.Context, maxPolls int, readOnly bool
|
||||
}
|
||||
|
||||
nm := sess.netmapForResponse(&resp)
|
||||
|
||||
// Occasionally print the netmap header.
|
||||
// This is handy for debugging, and our logs processing
|
||||
// pipeline depends on it. (TODO: Remove this dependency.)
|
||||
// Code elsewhere prints netmap diffs every time they are received.
|
||||
now := c.clock.Now()
|
||||
if now.Sub(c.lastPrintMap) >= 5*time.Minute {
|
||||
c.lastPrintMap = now
|
||||
c.logf("[v1] new network map[%d]:\n%s", i, nm.VeryConcise())
|
||||
}
|
||||
if nm.SelfNode == nil {
|
||||
c.logf("MapResponse lacked node")
|
||||
return errors.New("MapResponse lacked node")
|
||||
@@ -1123,21 +1139,21 @@ func (c *Direct) sendMapRequest(ctx context.Context, maxPolls int, readOnly bool
|
||||
nm.SelfNode.Capabilities = nil
|
||||
}
|
||||
|
||||
// Occasionally print the netmap header.
|
||||
// This is handy for debugging, and our logs processing
|
||||
// pipeline depends on it. (TODO: Remove this dependency.)
|
||||
// Code elsewhere prints netmap diffs every time they are received.
|
||||
now := c.clock.Now()
|
||||
if now.Sub(c.lastPrintMap) >= 5*time.Minute {
|
||||
c.lastPrintMap = now
|
||||
c.logf("[v1] new network map[%d]:\n%s", i, nm.VeryConcise())
|
||||
}
|
||||
newPersist := persist.AsStruct()
|
||||
newPersist.NodeID = nm.SelfNode.StableID
|
||||
newPersist.UserProfile = nm.UserProfiles[nm.User]
|
||||
|
||||
c.mu.Lock()
|
||||
// If we are the ones who last updated persist, then we can update it
|
||||
// again. Otherwise, we should not touch it.
|
||||
if persist == c.persist {
|
||||
c.persist = newPersist.View()
|
||||
persist = c.persist
|
||||
}
|
||||
c.expiry = &nm.Expiry
|
||||
c.mu.Unlock()
|
||||
|
||||
cb(nm)
|
||||
nu.UpdateFullNetmap(nm)
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
@@ -1671,7 +1687,7 @@ func doPingerPing(logf logger.Logf, c *http.Client, pr *tailcfg.PingRequest, pin
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
res, err := pinger.Ping(ctx, pr.IP, pingType)
|
||||
res, err := pinger.Ping(ctx, pr.IP, pingType, 0)
|
||||
if err != nil {
|
||||
d := time.Since(start).Round(time.Millisecond)
|
||||
logf("doPingerPing: ping error of type %q to %v after %v: %v", pingType, pr.IP, d, err)
|
||||
|
||||
@@ -42,7 +42,10 @@ func TestNewDirect(t *testing.T) {
|
||||
t.Errorf("c.serverURL got %v want %v", c.serverURL, opts.ServerURL)
|
||||
}
|
||||
|
||||
if !hi.Equal(c.hostinfo) {
|
||||
// hi is stored without its NetInfo field.
|
||||
hiWithoutNi := *hi
|
||||
hiWithoutNi.NetInfo = nil
|
||||
if !hiWithoutNi.Equal(c.hostinfo) {
|
||||
t.Errorf("c.hostinfo got %v want %v", c.hostinfo, hi)
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
"tailscale.com/net/tsdial"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/tstest"
|
||||
"tailscale.com/tstime"
|
||||
"tailscale.com/types/key"
|
||||
"tailscale.com/types/logger"
|
||||
)
|
||||
@@ -172,8 +173,12 @@ func testControlHTTP(t *testing.T, param httpTestParam) {
|
||||
}
|
||||
|
||||
var httpHandler http.Handler = handler
|
||||
const fallbackDelay = 50 * time.Millisecond
|
||||
clock := tstest.NewClock(tstest.ClockOpts{Step: 2 * fallbackDelay})
|
||||
// Advance once to init the clock.
|
||||
clock.Now()
|
||||
if param.makeHTTPHangAfterUpgrade {
|
||||
httpHandler = http.HandlerFunc(brokenMITMHandler)
|
||||
httpHandler = brokenMITMHandler(clock)
|
||||
}
|
||||
httpServer := &http.Server{Handler: httpHandler}
|
||||
go httpServer.Serve(httpLn)
|
||||
@@ -204,8 +209,8 @@ func testControlHTTP(t *testing.T, param httpTestParam) {
|
||||
Dialer: new(tsdial.Dialer).SystemDial,
|
||||
Logf: t.Logf,
|
||||
omitCertErrorLogging: true,
|
||||
testFallbackDelay: 50 * time.Millisecond,
|
||||
Clock: &tstest.Clock{},
|
||||
testFallbackDelay: fallbackDelay,
|
||||
Clock: clock,
|
||||
}
|
||||
|
||||
if proxy != nil {
|
||||
@@ -471,12 +476,16 @@ EKTcWGekdmdDPsHloRNtsiCa697B2O9IFA==
|
||||
}
|
||||
}
|
||||
|
||||
func brokenMITMHandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Upgrade", upgradeHeaderValue)
|
||||
w.Header().Set("Connection", "upgrade")
|
||||
w.WriteHeader(http.StatusSwitchingProtocols)
|
||||
w.(http.Flusher).Flush()
|
||||
<-r.Context().Done()
|
||||
func brokenMITMHandler(clock tstime.Clock) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Upgrade", upgradeHeaderValue)
|
||||
w.Header().Set("Connection", "upgrade")
|
||||
w.WriteHeader(http.StatusSwitchingProtocols)
|
||||
w.(http.Flusher).Flush()
|
||||
// Advance the clock to trigger HTTPs fallback.
|
||||
clock.Now()
|
||||
<-r.Context().Done()
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialPlan(t *testing.T) {
|
||||
@@ -621,12 +630,15 @@ func TestDialPlan(t *testing.T) {
|
||||
}
|
||||
for _, tt := range testCases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// TODO(awly): replace this with tstest.NewClock and update the
|
||||
// test to advance the clock correctly.
|
||||
clock := tstime.StdClock{}
|
||||
makeHandler(t, "fallback", fallbackAddr, nil)
|
||||
makeHandler(t, "good", goodAddr, nil)
|
||||
makeHandler(t, "other", otherAddr, nil)
|
||||
makeHandler(t, "other2", other2Addr, nil)
|
||||
makeHandler(t, "broken", brokenAddr, func(h http.Handler) http.Handler {
|
||||
return http.HandlerFunc(brokenMITMHandler)
|
||||
return brokenMITMHandler(clock)
|
||||
})
|
||||
|
||||
dialer := closeTrackDialer{
|
||||
@@ -662,7 +674,7 @@ func TestDialPlan(t *testing.T) {
|
||||
drainFinished: drained,
|
||||
omitCertErrorLogging: true,
|
||||
testFallbackDelay: 50 * time.Millisecond,
|
||||
Clock: &tstest.Clock{},
|
||||
Clock: clock,
|
||||
}
|
||||
|
||||
conn, err := a.dial(ctx)
|
||||
|
||||
11
derp/derp.go
11
derp/derp.go
@@ -85,7 +85,7 @@ const (
|
||||
|
||||
// framePeerPresent is like framePeerGone, but for other
|
||||
// members of the DERP region when they're meshed up together.
|
||||
framePeerPresent = frameType(0x09) // 32B pub key of peer that's connected
|
||||
framePeerPresent = frameType(0x09) // 32B pub key of peer that's connected + optional 18B ip:port (16 byte IP + 2 byte BE uint16 port)
|
||||
|
||||
// frameWatchConns is how one DERP node in a regional mesh
|
||||
// subscribes to the others in the region.
|
||||
@@ -199,7 +199,7 @@ func readFrame(br *bufio.Reader, maxSize uint32, b []byte) (t frameType, frameLe
|
||||
return 0, 0, fmt.Errorf("frame header size %d exceeds reader limit of %d", frameLen, maxSize)
|
||||
}
|
||||
|
||||
n, err := io.ReadFull(br, b[:minUint32(frameLen, uint32(len(b)))])
|
||||
n, err := io.ReadFull(br, b[:min(frameLen, uint32(len(b)))])
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
@@ -233,10 +233,3 @@ func writeFrame(bw *bufio.Writer, t frameType, b []byte) error {
|
||||
}
|
||||
return bw.Flush()
|
||||
}
|
||||
|
||||
func minUint32(a, b uint32) uint32 {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
@@ -363,7 +363,12 @@ func (PeerGoneMessage) msg() {}
|
||||
|
||||
// PeerPresentMessage is a ReceivedMessage that indicates that the client
|
||||
// is connected to the server. (Only used by trusted mesh clients)
|
||||
type PeerPresentMessage key.NodePublic
|
||||
type PeerPresentMessage struct {
|
||||
// Key is the public key of the client.
|
||||
Key key.NodePublic
|
||||
// IPPort is the remote IP and port of the client.
|
||||
IPPort netip.AddrPort
|
||||
}
|
||||
|
||||
func (PeerPresentMessage) msg() {}
|
||||
|
||||
@@ -546,8 +551,15 @@ func (c *Client) recvTimeout(timeout time.Duration) (m ReceivedMessage, err erro
|
||||
c.logf("[unexpected] dropping short peerPresent frame from DERP server")
|
||||
continue
|
||||
}
|
||||
pg := PeerPresentMessage(key.NodePublicFromRaw32(mem.B(b[:keyLen])))
|
||||
return pg, nil
|
||||
var msg PeerPresentMessage
|
||||
msg.Key = key.NodePublicFromRaw32(mem.B(b[:keyLen]))
|
||||
if n >= keyLen+16+2 {
|
||||
msg.IPPort = netip.AddrPortFrom(
|
||||
netip.AddrFrom16([16]byte(b[keyLen:keyLen+16])).Unmap(),
|
||||
binary.BigEndian.Uint16(b[keyLen+16:keyLen+16+2]),
|
||||
)
|
||||
}
|
||||
return msg, nil
|
||||
|
||||
case frameRecvPacket:
|
||||
var rp ReceivedPacket
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
crand "crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"expvar"
|
||||
@@ -43,6 +44,7 @@ import (
|
||||
"tailscale.com/tstime/rate"
|
||||
"tailscale.com/types/key"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/util/set"
|
||||
"tailscale.com/version"
|
||||
)
|
||||
|
||||
@@ -150,7 +152,7 @@ type Server struct {
|
||||
closed bool
|
||||
netConns map[Conn]chan struct{} // chan is closed when conn closes
|
||||
clients map[key.NodePublic]clientSet
|
||||
watchers map[*sclient]bool // mesh peer -> true
|
||||
watchers set.Set[*sclient] // mesh peers
|
||||
// clientsMesh tracks all clients in the cluster, both locally
|
||||
// and to mesh peers. If the value is nil, that means the
|
||||
// peer is only local (and thus in the clients Map, but not
|
||||
@@ -219,8 +221,7 @@ func (s singleClient) ForeachClient(f func(*sclient)) { f(s.c) }
|
||||
// All fields are guarded by Server.mu.
|
||||
type dupClientSet struct {
|
||||
// set is the set of connected clients for sclient.key.
|
||||
// The values are all true.
|
||||
set map[*sclient]bool
|
||||
set set.Set[*sclient]
|
||||
|
||||
// last is the most recent addition to set, or nil if the most
|
||||
// recent one has since disconnected and nobody else has send
|
||||
@@ -261,7 +262,7 @@ func (s *dupClientSet) removeClient(c *sclient) bool {
|
||||
|
||||
trim := s.sendHistory[:0]
|
||||
for _, v := range s.sendHistory {
|
||||
if s.set[v] && (len(trim) == 0 || trim[len(trim)-1] != v) {
|
||||
if s.set.Contains(v) && (len(trim) == 0 || trim[len(trim)-1] != v) {
|
||||
trim = append(trim, v)
|
||||
}
|
||||
}
|
||||
@@ -316,7 +317,7 @@ func NewServer(privateKey key.NodePrivate, logf logger.Logf) *Server {
|
||||
clientsMesh: map[key.NodePublic]PacketForwarder{},
|
||||
netConns: map[Conn]chan struct{}{},
|
||||
memSys0: ms.Sys,
|
||||
watchers: map[*sclient]bool{},
|
||||
watchers: set.Set[*sclient]{},
|
||||
sentTo: map[key.NodePublic]map[key.NodePublic]int64{},
|
||||
avgQueueDuration: new(uint64),
|
||||
tcpRtt: metrics.LabelMap{Label: "le"},
|
||||
@@ -498,8 +499,8 @@ func (s *Server) registerClient(c *sclient) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
set := s.clients[c.key]
|
||||
switch set := set.(type) {
|
||||
curSet := s.clients[c.key]
|
||||
switch curSet := curSet.(type) {
|
||||
case nil:
|
||||
s.clients[c.key] = singleClient{c}
|
||||
c.debugLogf("register single client")
|
||||
@@ -507,14 +508,14 @@ func (s *Server) registerClient(c *sclient) {
|
||||
s.dupClientKeys.Add(1)
|
||||
s.dupClientConns.Add(2) // both old and new count
|
||||
s.dupClientConnTotal.Add(1)
|
||||
old := set.ActiveClient()
|
||||
old := curSet.ActiveClient()
|
||||
old.isDup.Store(true)
|
||||
c.isDup.Store(true)
|
||||
s.clients[c.key] = &dupClientSet{
|
||||
last: c,
|
||||
set: map[*sclient]bool{
|
||||
old: true,
|
||||
c: true,
|
||||
set: set.Set[*sclient]{
|
||||
old: struct{}{},
|
||||
c: struct{}{},
|
||||
},
|
||||
sendHistory: []*sclient{old},
|
||||
}
|
||||
@@ -523,9 +524,9 @@ func (s *Server) registerClient(c *sclient) {
|
||||
s.dupClientConns.Add(1) // the gauge
|
||||
s.dupClientConnTotal.Add(1) // the counter
|
||||
c.isDup.Store(true)
|
||||
set.set[c] = true
|
||||
set.last = c
|
||||
set.sendHistory = append(set.sendHistory, c)
|
||||
curSet.set.Add(c)
|
||||
curSet.last = c
|
||||
curSet.sendHistory = append(curSet.sendHistory, c)
|
||||
c.debugLogf("register another duplicate client")
|
||||
}
|
||||
|
||||
@@ -534,7 +535,7 @@ func (s *Server) registerClient(c *sclient) {
|
||||
}
|
||||
s.keyOfAddr[c.remoteIPPort] = c.key
|
||||
s.curClients.Add(1)
|
||||
s.broadcastPeerStateChangeLocked(c.key, true)
|
||||
s.broadcastPeerStateChangeLocked(c.key, c.remoteIPPort, true)
|
||||
}
|
||||
|
||||
// broadcastPeerStateChangeLocked enqueues a message to all watchers
|
||||
@@ -542,9 +543,13 @@ func (s *Server) registerClient(c *sclient) {
|
||||
// presence changed.
|
||||
//
|
||||
// s.mu must be held.
|
||||
func (s *Server) broadcastPeerStateChangeLocked(peer key.NodePublic, present bool) {
|
||||
func (s *Server) broadcastPeerStateChangeLocked(peer key.NodePublic, ipPort netip.AddrPort, present bool) {
|
||||
for w := range s.watchers {
|
||||
w.peerStateChange = append(w.peerStateChange, peerConnState{peer: peer, present: present})
|
||||
w.peerStateChange = append(w.peerStateChange, peerConnState{
|
||||
peer: peer,
|
||||
present: present,
|
||||
ipPort: ipPort,
|
||||
})
|
||||
go w.requestMeshUpdate()
|
||||
}
|
||||
}
|
||||
@@ -565,7 +570,7 @@ func (s *Server) unregisterClient(c *sclient) {
|
||||
delete(s.clientsMesh, c.key)
|
||||
s.notePeerGoneFromRegionLocked(c.key)
|
||||
}
|
||||
s.broadcastPeerStateChangeLocked(c.key, false)
|
||||
s.broadcastPeerStateChangeLocked(c.key, netip.AddrPort{}, false)
|
||||
case *dupClientSet:
|
||||
c.debugLogf("removed duplicate client")
|
||||
if set.removeClient(c) {
|
||||
@@ -655,13 +660,21 @@ func (s *Server) addWatcher(c *sclient) {
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// Queue messages for each already-connected client.
|
||||
for peer := range s.clients {
|
||||
c.peerStateChange = append(c.peerStateChange, peerConnState{peer: peer, present: true})
|
||||
for peer, clientSet := range s.clients {
|
||||
ac := clientSet.ActiveClient()
|
||||
if ac == nil {
|
||||
continue
|
||||
}
|
||||
c.peerStateChange = append(c.peerStateChange, peerConnState{
|
||||
peer: peer,
|
||||
present: true,
|
||||
ipPort: ac.remoteIPPort,
|
||||
})
|
||||
}
|
||||
|
||||
// And enroll the watcher in future updates (of both
|
||||
// connections & disconnections).
|
||||
s.watchers[c] = true
|
||||
s.watchers.Add(c)
|
||||
|
||||
go c.requestMeshUpdate()
|
||||
}
|
||||
@@ -1349,6 +1362,7 @@ type sclient struct {
|
||||
type peerConnState struct {
|
||||
peer key.NodePublic
|
||||
present bool
|
||||
ipPort netip.AddrPort // if present, the peer's IP:port
|
||||
}
|
||||
|
||||
// pkt is a request to write a data frame to an sclient.
|
||||
@@ -1542,12 +1556,18 @@ func (c *sclient) sendPeerGone(peer key.NodePublic, reason PeerGoneReasonType) e
|
||||
}
|
||||
|
||||
// sendPeerPresent sends a peerPresent frame, without flushing.
|
||||
func (c *sclient) sendPeerPresent(peer key.NodePublic) error {
|
||||
func (c *sclient) sendPeerPresent(peer key.NodePublic, ipPort netip.AddrPort) error {
|
||||
c.setWriteDeadline()
|
||||
if err := writeFrameHeader(c.bw.bw(), framePeerPresent, keyLen); err != nil {
|
||||
const frameLen = keyLen + 16 + 2
|
||||
if err := writeFrameHeader(c.bw.bw(), framePeerPresent, frameLen); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := c.bw.Write(peer.AppendTo(nil))
|
||||
payload := make([]byte, frameLen)
|
||||
_ = peer.AppendTo(payload[:0])
|
||||
a16 := ipPort.Addr().As16()
|
||||
copy(payload[keyLen:], a16[:])
|
||||
binary.BigEndian.PutUint16(payload[keyLen+16:], ipPort.Port())
|
||||
_, err := c.bw.Write(payload)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1566,7 +1586,7 @@ func (c *sclient) sendMeshUpdates() error {
|
||||
}
|
||||
var err error
|
||||
if pcs.present {
|
||||
err = c.sendPeerPresent(pcs.peer)
|
||||
err = c.sendPeerPresent(pcs.peer, pcs.ipPort)
|
||||
} else {
|
||||
err = c.sendPeerGone(pcs.peer, PeerGoneReasonDisconnected)
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ func TestSendRecv(t *testing.T) {
|
||||
defer cancel()
|
||||
|
||||
brwServer := bufio.NewReadWriter(bufio.NewReader(cin), bufio.NewWriter(cin))
|
||||
go s.Accept(ctx, cin, brwServer, fmt.Sprintf("test-client-%d", i))
|
||||
go s.Accept(ctx, cin, brwServer, fmt.Sprintf("[abc::def]:%v", i))
|
||||
|
||||
key := clientPrivateKeys[i]
|
||||
brw := bufio.NewReadWriter(bufio.NewReader(cout), bufio.NewWriter(cout))
|
||||
@@ -528,7 +528,7 @@ func newTestServer(t *testing.T, ctx context.Context) *testServer {
|
||||
// TODO: register c in ts so Close also closes it?
|
||||
go func(i int) {
|
||||
brwServer := bufio.NewReadWriter(bufio.NewReader(c), bufio.NewWriter(c))
|
||||
go s.Accept(ctx, c, brwServer, fmt.Sprintf("test-client-%d", i))
|
||||
go s.Accept(ctx, c, brwServer, c.RemoteAddr().String())
|
||||
}(i)
|
||||
}
|
||||
}()
|
||||
@@ -615,7 +615,7 @@ func (tc *testClient) wantPresent(t *testing.T, peers ...key.NodePublic) {
|
||||
}
|
||||
switch m := m.(type) {
|
||||
case PeerPresentMessage:
|
||||
got := key.NodePublic(m)
|
||||
got := m.Key
|
||||
if !want[got] {
|
||||
t.Fatalf("got peer present for %v; want present for %v", tc.ts.keyName(got), logger.ArgWriter(func(bw *bufio.Writer) {
|
||||
for _, pub := range peers {
|
||||
@@ -623,6 +623,7 @@ func (tc *testClient) wantPresent(t *testing.T, peers ...key.NodePublic) {
|
||||
}
|
||||
}))
|
||||
}
|
||||
t.Logf("got present with IP %v", m.IPPort)
|
||||
delete(want, got)
|
||||
if len(want) == 0 {
|
||||
return
|
||||
|
||||
@@ -5,6 +5,7 @@ package derphttp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/netip"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -26,7 +27,7 @@ import (
|
||||
//
|
||||
// To force RunWatchConnectionLoop to return quickly, its ctx needs to
|
||||
// be closed, and c itself needs to be closed.
|
||||
func (c *Client) RunWatchConnectionLoop(ctx context.Context, ignoreServerKey key.NodePublic, infoLogf logger.Logf, add, remove func(key.NodePublic)) {
|
||||
func (c *Client) RunWatchConnectionLoop(ctx context.Context, ignoreServerKey key.NodePublic, infoLogf logger.Logf, add func(key.NodePublic, netip.AddrPort), remove func(key.NodePublic)) {
|
||||
if infoLogf == nil {
|
||||
infoLogf = logger.Discard
|
||||
}
|
||||
@@ -68,9 +69,9 @@ func (c *Client) RunWatchConnectionLoop(ctx context.Context, ignoreServerKey key
|
||||
})
|
||||
defer timer.Stop()
|
||||
|
||||
updatePeer := func(k key.NodePublic, isPresent bool) {
|
||||
updatePeer := func(k key.NodePublic, ipPort netip.AddrPort, isPresent bool) {
|
||||
if isPresent {
|
||||
add(k)
|
||||
add(k, ipPort)
|
||||
} else {
|
||||
remove(k)
|
||||
}
|
||||
@@ -126,7 +127,7 @@ func (c *Client) RunWatchConnectionLoop(ctx context.Context, ignoreServerKey key
|
||||
}
|
||||
switch m := m.(type) {
|
||||
case derp.PeerPresentMessage:
|
||||
updatePeer(key.NodePublic(m), true)
|
||||
updatePeer(m.Key, m.IPPort, true)
|
||||
case derp.PeerGoneMessage:
|
||||
switch m.Reason {
|
||||
case derp.PeerGoneReasonDisconnected:
|
||||
@@ -138,7 +139,7 @@ func (c *Client) RunWatchConnectionLoop(ctx context.Context, ignoreServerKey key
|
||||
logf("Recv: peer %s not at server %s for unknown reason %v",
|
||||
key.NodePublic(m.Peer).ShortString(), c.ServerPublicKey().ShortString(), m.Reason)
|
||||
}
|
||||
updatePeer(key.NodePublic(m.Peer), false)
|
||||
updatePeer(key.NodePublic(m.Peer), netip.AddrPort{}, false)
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -94,6 +94,9 @@ type Message interface {
|
||||
AppendMarshal([]byte) []byte
|
||||
}
|
||||
|
||||
// MessageHeaderLen is the length of a message header, 2 bytes for type and version.
|
||||
const MessageHeaderLen = 2
|
||||
|
||||
// appendMsgHeader appends two bytes (for t and ver) and then also
|
||||
// dataLen bytes to b, returning the appended slice in all. The
|
||||
// returned data slice is a subslice of all with just dataLen bytes of
|
||||
@@ -117,15 +120,24 @@ type Ping struct {
|
||||
// netmap data to reduce the discokey:nodekey relation from 1:N to
|
||||
// 1:1.
|
||||
NodeKey key.NodePublic
|
||||
|
||||
// Padding is the number of 0 bytes at the end of the
|
||||
// message. (It's used to probe path MTU.)
|
||||
Padding int
|
||||
}
|
||||
|
||||
// PingLen is the length of a marshalled ping message, without the message
|
||||
// header or padding.
|
||||
const PingLen = 12 + key.NodePublicRawLen
|
||||
|
||||
func (m *Ping) AppendMarshal(b []byte) []byte {
|
||||
dataLen := 12
|
||||
hasKey := !m.NodeKey.IsZero()
|
||||
if hasKey {
|
||||
dataLen += key.NodePublicRawLen
|
||||
}
|
||||
ret, d := appendMsgHeader(b, TypePing, v0, dataLen)
|
||||
|
||||
ret, d := appendMsgHeader(b, TypePing, v0, dataLen+m.Padding)
|
||||
n := copy(d, m.TxID[:])
|
||||
if hasKey {
|
||||
m.NodeKey.AppendTo(d[:n])
|
||||
@@ -138,11 +150,14 @@ func parsePing(ver uint8, p []byte) (m *Ping, err error) {
|
||||
return nil, errShort
|
||||
}
|
||||
m = new(Ping)
|
||||
m.Padding = len(p)
|
||||
p = p[copy(m.TxID[:], p):]
|
||||
m.Padding -= 12
|
||||
// Deliberately lax on longer-than-expected messages, for future
|
||||
// compatibility.
|
||||
if len(p) >= key.NodePublicRawLen {
|
||||
m.NodeKey = key.NodePublicFromRaw32(mem.B(p[:key.NodePublicRawLen]))
|
||||
m.Padding -= key.NodePublicRawLen
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
@@ -214,6 +229,8 @@ type Pong struct {
|
||||
Src netip.AddrPort // 18 bytes (16+2) on the wire; v4-mapped ipv6 for IPv4
|
||||
}
|
||||
|
||||
// pongLen is the length of a marshalled pong message, without the message
|
||||
// header or padding.
|
||||
const pongLen = 12 + 16 + 2
|
||||
|
||||
func (m *Pong) AppendMarshal(b []byte) []byte {
|
||||
|
||||
@@ -35,6 +35,23 @@ func TestMarshalAndParse(t *testing.T) {
|
||||
},
|
||||
want: "01 00 01 02 03 04 05 06 07 08 09 0a 0b 0c 00 01 02 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 1e 1f",
|
||||
},
|
||||
{
|
||||
name: "ping_with_padding",
|
||||
m: &Ping{
|
||||
TxID: [12]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12},
|
||||
Padding: 3,
|
||||
},
|
||||
want: "01 00 01 02 03 04 05 06 07 08 09 0a 0b 0c 00 00 00",
|
||||
},
|
||||
{
|
||||
name: "ping_with_padding_and_nodekey_src",
|
||||
m: &Ping{
|
||||
TxID: [12]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12},
|
||||
NodeKey: key.NodePublicFromRaw32(mem.B([]byte{1: 1, 2: 2, 30: 30, 31: 31})),
|
||||
Padding: 3,
|
||||
},
|
||||
want: "01 00 01 02 03 04 05 06 07 08 09 0a 0b 0c 00 01 02 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 1e 1f 00 00 00",
|
||||
},
|
||||
{
|
||||
name: "pong",
|
||||
m: &Pong{
|
||||
|
||||
2
go.mod
2
go.mod
@@ -1,6 +1,6 @@
|
||||
module tailscale.com
|
||||
|
||||
go 1.20
|
||||
go 1.21
|
||||
|
||||
require (
|
||||
filippo.io/mkcert v1.4.4
|
||||
|
||||
24
go.sum
24
go.sum
@@ -59,6 +59,7 @@ github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak
|
||||
github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
|
||||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||
github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ=
|
||||
github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
|
||||
github.com/Djarvur/go-err113 v0.1.0 h1:uCRZZOdMQ0TZPHYTdYpoC0bLYJKPEHPUJ8MeAa51lNU=
|
||||
github.com/Djarvur/go-err113 v0.1.0/go.mod h1:4UJr5HIiMZrwgkSPdsjy2uOQExX/WEILpIrO9UPGuXs=
|
||||
github.com/GaijinEntertainment/go-exhaustruct/v2 v2.3.0 h1:+r1rSv4gvYn0wmRjC8X7IAzX8QezqtFV9m0MUHFJgts=
|
||||
@@ -81,7 +82,9 @@ github.com/OpenPeeDeeP/depguard v1.1.1/go.mod h1:JtAMzWkmFEzDPyAd+W0NHl1lvpQKTvT
|
||||
github.com/ProtonMail/go-crypto v0.0.0-20230626094100-7e9e0395ebec h1:vV3RryLxt42+ZIVOFbYJCH1jsZNTNmj2NYru5zfx+4E=
|
||||
github.com/ProtonMail/go-crypto v0.0.0-20230626094100-7e9e0395ebec/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0=
|
||||
github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f h1:tCbYj7/299ekTTXpdwKYF8eBlsYsDVoggDAuAjoK66k=
|
||||
github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f/go.mod h1:gcr0kNtGBqin9zDW9GOHcVntrwnjrK+qdJ06mWYBybw=
|
||||
github.com/ProtonMail/gopenpgp/v2 v2.7.1 h1:Awsg7MPc2gD3I7IFac2qE3Gdls0lZW8SzrFZ3k1oz0s=
|
||||
github.com/ProtonMail/gopenpgp/v2 v2.7.1/go.mod h1:/BU5gfAVwqyd8EfC3Eu7zmuhwYQpKs+cGD8M//iiaxs=
|
||||
github.com/acomagu/bufpipe v1.0.4 h1:e3H4WUzM3npvo5uv95QuJM3cQspFNtFBzvJ2oNjKIDQ=
|
||||
github.com/acomagu/bufpipe v1.0.4/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4=
|
||||
github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A=
|
||||
@@ -104,6 +107,7 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuW
|
||||
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
|
||||
github.com/armon/go-proxyproto v0.0.0-20210323213023-7e956b284f0a/go.mod h1:QmP9hvJ91BbJmGVGSbutW19IC0Q9phDCLGaomwTJbgU=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
||||
github.com/ashanbrown/forbidigo v1.5.1 h1:WXhzLjOlnuDYPYQo/eFlcFMi8X/kLfvWLYu6CSoebis=
|
||||
github.com/ashanbrown/forbidigo v1.5.1/go.mod h1:Y8j9jy9ZYAEHXdu723cUlraTqbzjKF1MUyfOKL+AjcU=
|
||||
github.com/ashanbrown/makezero v1.1.1 h1:iCQ87C0V0vSyO+M9E/FZYbu65auqH0lnsOkf5FcB28s=
|
||||
@@ -171,6 +175,7 @@ github.com/butuzov/ireturn v0.2.0 h1:kCHi+YzC150GE98WFuZQu9yrTn6GEydO2AuPLbTgnO4
|
||||
github.com/butuzov/ireturn v0.2.0/go.mod h1:Wh6Zl3IMtTpaIKbmwzqi6olnM9ptYQxxVacMsOEFPoc=
|
||||
github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
|
||||
github.com/caarlos0/go-rpmutils v0.2.1-0.20211112020245-2cd62ff89b11 h1:IRrDwVlWQr6kS1U8/EtyA1+EHcc4yl8pndcqXWrEamg=
|
||||
github.com/caarlos0/go-rpmutils v0.2.1-0.20211112020245-2cd62ff89b11/go.mod h1:je2KZ+LxaCNvCoKg32jtOIULcFogJKcL1ZWUaIBjKj0=
|
||||
github.com/caarlos0/testfs v0.4.4 h1:3PHvzHi5Lt+g332CiShwS8ogTgS3HjrmzZxCm6JCDr8=
|
||||
github.com/caarlos0/testfs v0.4.4/go.mod h1:bRN55zgG4XCUVVHZCeU+/Tz1Q6AxEJOEJTliBy+1DMk=
|
||||
github.com/cavaliergopher/cpio v1.0.1 h1:KQFSeKmZhv0cr+kawA3a0xTQCU4QxXF1vhU7P7av2KM=
|
||||
@@ -189,6 +194,7 @@ github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWR
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||
github.com/cilium/ebpf v0.10.0 h1:nk5HPMeoBXtOzbkZBWym+ZWq1GIiHUsBFXxwewXAHLQ=
|
||||
github.com/cilium/ebpf v0.10.0/go.mod h1:DPiVdY/kT534dgc9ERmvP8mWA+9gvwgKfRvk4nNWnoE=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cloudflare/circl v1.3.3 h1:fE/Qz0QdIGqeWfnwq0RE0R7MI51s0M2E4Ga9kq5AEMs=
|
||||
github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
|
||||
@@ -232,6 +238,7 @@ github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3
|
||||
github.com/dsnet/try v0.0.3 h1:ptR59SsrcFUYbT/FhAbKTV6iLkeD6O18qfIWRml2fqI=
|
||||
github.com/dsnet/try v0.0.3/go.mod h1:WBM8tRpUmnXXhY1U6/S8dt6UWdHTQ7y8A5YSkRCkq40=
|
||||
github.com/elazarl/goproxy v0.0.0-20221015165544-a0805db90819 h1:RIB4cRk+lBqKK3Oy0r2gRX4ui7tuhiZq2SuTtTCi0/0=
|
||||
github.com/elazarl/goproxy v0.0.0-20221015165544-a0805db90819/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM=
|
||||
github.com/emicklei/go-restful/v3 v3.10.2 h1:hIovbnmBTLjHXkqEBUz3HGpXZdM7ZrE9fJIZIqlJLqE=
|
||||
github.com/emicklei/go-restful/v3 v3.10.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
|
||||
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
||||
@@ -276,6 +283,7 @@ github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwv
|
||||
github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I=
|
||||
github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo=
|
||||
github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY=
|
||||
github.com/gliderlabs/ssh v0.3.5/go.mod h1:8XB4KraRrX39qHhT6yxPsHedjA08I/uBVwj4xC+/+z4=
|
||||
github.com/go-critic/go-critic v0.8.0 h1:4zOcpvDoKvBOl+R1W81IBznr78f8YaE4zKXkfDVxGGA=
|
||||
github.com/go-critic/go-critic v0.8.0/go.mod h1:5TjdkPI9cu/yKbYS96BTsslihjKd6zg6vd8O9RZXj2s=
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
|
||||
@@ -283,6 +291,7 @@ github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmS
|
||||
github.com/go-git/go-billy/v5 v5.4.1 h1:Uwp5tDRkPr+l/TnbHOQzp+tmJfLceOlbVucgpTz8ix4=
|
||||
github.com/go-git/go-billy/v5 v5.4.1/go.mod h1:vjbugF6Fz7JIflbVpl1hJsGjSHNltrSw45YK/ukIvQg=
|
||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20230305113008-0c11038e723f h1:Pz0DHeFij3XFhoBRGUDPzSJ+w2UcK5/0JvF8DRI58r8=
|
||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20230305113008-0c11038e723f/go.mod h1:8LHG1a3SRW71ettAD/jW13h8c6AqjVSeL11RAdgaqpo=
|
||||
github.com/go-git/go-git/v5 v5.7.0 h1:t9AudWVLmqzlo+4bqdf7GY+46SUuRsx59SboFxkq2aE=
|
||||
github.com/go-git/go-git/v5 v5.7.0/go.mod h1:coJHKEOk5kUClpsNlXrUvPrDxY3w3gjHvhcZd8Fodw8=
|
||||
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
||||
@@ -318,6 +327,7 @@ github.com/go-playground/validator/v10 v10.2.0 h1:KgJ0snyC2R9VXYN2rneOtQcw5aHQB1
|
||||
github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI=
|
||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
|
||||
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls=
|
||||
github.com/go-toolsmith/astcast v1.1.0 h1:+JN9xZV1A+Re+95pgnMgDboWNVnIMMQXwfBwLRPgSC8=
|
||||
github.com/go-toolsmith/astcast v1.1.0/go.mod h1:qdcuFWeGGS2xX5bLM/c3U9lewg7+Zu4mr+xPwZIB4ZU=
|
||||
github.com/go-toolsmith/astcopy v1.1.0 h1:YGwBN0WM+ekI/6SS6+52zLDEf8Yvp3n2seZITCUBt5s=
|
||||
@@ -330,6 +340,7 @@ github.com/go-toolsmith/astfmt v1.1.0/go.mod h1:OrcLlRwu0CuiIBp/8b5PYF9ktGVZUjlN
|
||||
github.com/go-toolsmith/astp v1.1.0 h1:dXPuCl6u2llURjdPLLDxJeZInAeZ0/eZwFJmqZMnpQA=
|
||||
github.com/go-toolsmith/astp v1.1.0/go.mod h1:0T1xFGz9hicKs8Z5MfAqSUitoUYS30pDMsRVIDHs8CA=
|
||||
github.com/go-toolsmith/pkgload v1.2.2 h1:0CtmHq/02QhxcF7E9N5LIFcYFsMR5rdovfqTtRKkgIk=
|
||||
github.com/go-toolsmith/pkgload v1.2.2/go.mod h1:R2hxLNRKuAsiXCo2i5J6ZQPhnPMOVtU+f0arbFPWCus=
|
||||
github.com/go-toolsmith/strparse v1.0.0/go.mod h1:YI2nUKP9YGZnL/L1/DLFBfixrcjslWct4wyljWhSRy8=
|
||||
github.com/go-toolsmith/strparse v1.1.0 h1:GAioeZUK9TGxnLS+qfdqNbA4z0SSm5zVNtCQiyP2Bvw=
|
||||
github.com/go-toolsmith/strparse v1.1.0/go.mod h1:7ksGy58fsaQkGQlY8WVoBFNyEPMGuJin1rfoPS4lBSQ=
|
||||
@@ -446,6 +457,7 @@ github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLe
|
||||
github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 h1:K6RDEckDVWvDI9JAJYCmNdQXq6neHJOYx3V6jnqNEec=
|
||||
github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||
github.com/google/rpmpack v0.5.0 h1:L16KZ3QvkFGpYhmp23iQip+mx1X39foEsqszjMNBm8A=
|
||||
github.com/google/rpmpack v0.5.0/go.mod h1:uqVAUVQLq8UY2hCDfmJ/+rtO3aw7qyhc90rCVEabEfI=
|
||||
@@ -457,6 +469,7 @@ github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+
|
||||
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
||||
github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=
|
||||
github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
|
||||
github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k=
|
||||
github.com/gordonklaus/ineffassign v0.0.0-20230107090616-13ace0543b28 h1:9alfqbrhuD+9fLZ4iaAVwhlp5PEhmnBt7yvK2Oy5C1U=
|
||||
github.com/gordonklaus/ineffassign v0.0.0-20230107090616-13ace0543b28/go.mod h1:Qcp2HIAYhR7mNUVSIxZww3Guk4it82ghYcEXIAk+QT0=
|
||||
github.com/goreleaser/chglog v0.5.0 h1:Sk6BMIpx8+vpAf8KyPit34OgWui8c7nKTMHhYx88jJ4=
|
||||
@@ -467,6 +480,7 @@ github.com/goreleaser/nfpm/v2 v2.32.1-0.20230803123630-24a43c5ad7cf h1:X8rzot0Te
|
||||
github.com/goreleaser/nfpm/v2 v2.32.1-0.20230803123630-24a43c5ad7cf/go.mod h1:Z7rAxucnQGMGfAhpxm/UIrdH0/EcxEt91RW3mmVzx2U=
|
||||
github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
|
||||
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/gostaticanalysis/analysisutil v0.7.1 h1:ZMCjoue3DtDWQ5WyU16YbjbQEQ3VuzwxALrpYd+HeKk=
|
||||
github.com/gostaticanalysis/analysisutil v0.7.1/go.mod h1:v21E3hY37WKMGSnbsw2S/ojApNWb6C1//mXO48CXbVc=
|
||||
github.com/gostaticanalysis/comment v1.4.1/go.mod h1:ih6ZxzTHLdadaiSnF5WY3dxUoXfXAlTaRzuaNDlSado=
|
||||
@@ -478,6 +492,7 @@ github.com/gostaticanalysis/nilerr v0.1.1 h1:ThE+hJP0fEp4zWLkWHWcRyI2Od0p7DlgYG3
|
||||
github.com/gostaticanalysis/nilerr v0.1.1/go.mod h1:wZYb6YI5YAxxq0i1+VJbY0s2YONW0HU0GPE3+5PWN4A=
|
||||
github.com/gostaticanalysis/testutil v0.3.1-0.20210208050101-bfb5c8eec0e4/go.mod h1:D+FIZ+7OahH3ePw/izIEeH5I06eKs1IKI4Xr64/Am3M=
|
||||
github.com/gostaticanalysis/testutil v0.4.0 h1:nhdCmubdmDF6VEatUNjgUZBJKWRqugoISdUv3PPQgHY=
|
||||
github.com/gostaticanalysis/testutil v0.4.0/go.mod h1:bLIoPefWXrRi/ssLFWX1dx7Repi5x3CuviD3dgAZaBU=
|
||||
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
|
||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||
@@ -541,6 +556,7 @@ github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHm
|
||||
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
|
||||
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
|
||||
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
|
||||
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
|
||||
github.com/julz/importas v0.1.0 h1:F78HnrsjY3cR7j0etXy5+TU1Zuy7Xt08X/1aJnH5xXY=
|
||||
@@ -675,7 +691,9 @@ github.com/nunnatsa/ginkgolinter v0.11.2/go.mod h1:dJIGXYXbkBswqa/pIzG0QlVTTDSBM
|
||||
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
|
||||
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
|
||||
github.com/onsi/ginkgo/v2 v2.9.5 h1:+6Hr4uxzP4XIUyAkg61dWBw8lb/gc4/X5luuxN/EC+Q=
|
||||
github.com/onsi/ginkgo/v2 v2.9.5/go.mod h1:tvAoo1QUJwNEU2ITftXTpR7R1RbCzoZUOs3RonqW57k=
|
||||
github.com/onsi/gomega v1.27.7 h1:fVih9JD6ogIiHUN6ePK7HJidyEDpWGVB5mzM7cWNXoU=
|
||||
github.com/onsi/gomega v1.27.7/go.mod h1:1p8OOlwo2iUUDsHnOrjE5UKYJ+e3W8eQ3qSlRahPmr4=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.0-rc3 h1:fzg1mXZFj8YdPeNkRXMg+zb88BFV0Ys52cJydRwBkb8=
|
||||
@@ -792,7 +810,9 @@ github.com/skeema/knownhosts v1.1.1/go.mod h1:g4fPeYpque7P0xefxtGzV81ihjC8sX2Iqp
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||
github.com/smartystreets/assertions v1.13.1 h1:Ef7KhSmjZcK6AVf9YbJdvPYG9avaF0ZxudX+ThRdWfU=
|
||||
github.com/smartystreets/assertions v1.13.1/go.mod h1:cXr/IwVfSo/RbCSPhoAPv73p3hlSdrBH/b3SdnW/LMY=
|
||||
github.com/smartystreets/goconvey v1.8.0 h1:Oi49ha/2MURE0WexF052Z0m+BNSGirfjg5RL+JXWq3w=
|
||||
github.com/smartystreets/goconvey v1.8.0/go.mod h1:EdX8jtrTIj26jmjCOVNMVSIYAtgexqXKHOXW2Dx9JLg=
|
||||
github.com/sonatard/noctx v0.0.2 h1:L7Dz4De2zDQhW8S0t+KUjY0MAQJd6SgVwhzNIc4ok00=
|
||||
github.com/sonatard/noctx v0.0.2/go.mod h1:kzFz+CzWSjQ2OzIm46uJZoXuBpa2+0y3T36U18dWqIo=
|
||||
github.com/sourcegraph/go-diff v0.7.0 h1:9uLlrd5T46OXs5qpp8L/MTltk0zikUGi0sNNyCpA8G0=
|
||||
@@ -879,6 +899,7 @@ github.com/tommy-muehle/go-mnd/v2 v2.5.1/go.mod h1:WsUAkMJMYww6l/ufffCD3m+P7LEvr
|
||||
github.com/toqueteos/webbrowser v1.2.0 h1:tVP/gpK69Fx+qMJKsLE7TD8LuGWPnEV71wBN9rrstGQ=
|
||||
github.com/toqueteos/webbrowser v1.2.0/go.mod h1:XWoZq4cyp9WeUeak7w7LXRUQf1F1ATJMir8RTqb4ayM=
|
||||
github.com/u-root/gobusybox/src v0.0.0-20221229083637-46b2883a7f90 h1:zTk5683I9K62wtZ6eUa6vu6IWwVHXPnoKK5n2unAwv0=
|
||||
github.com/u-root/gobusybox/src v0.0.0-20221229083637-46b2883a7f90/go.mod h1:lYt+LVfZBBwDZ3+PHk4k/c/TnKOkjJXiJO73E32Mmpc=
|
||||
github.com/u-root/u-root v0.11.0 h1:6gCZLOeRyevw7gbTwMj3fKxnr9+yHFlgF3N7udUVNO8=
|
||||
github.com/u-root/u-root v0.11.0/go.mod h1:DBkDtiZyONk9hzVEdB/PWI9B4TxDkElWlVTHseglrZY=
|
||||
github.com/u-root/uio v0.0.0-20230305220412-3e8cd9d6bf63 h1:YcojQL98T/OO+rybuzn2+5KrD5dBwXIvYBvQ2cD3Avg=
|
||||
@@ -911,6 +932,7 @@ github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2
|
||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
|
||||
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
|
||||
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo=
|
||||
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos=
|
||||
github.com/yagipy/maintidx v1.0.0 h1:h5NvIsCz+nRDapQ0exNv4aJ0yXSI0420omVANTv3GJM=
|
||||
github.com/yagipy/maintidx v1.0.0/go.mod h1:0qNf/I/CCZXSMhsRsrEPDZ+DkekpKLXAJfsTACwgXLk=
|
||||
github.com/yeya24/promlinter v0.2.0 h1:xFKDQ82orCU5jQujdaD8stOHiv8UN68BSdn2a8u8Y3o=
|
||||
@@ -938,6 +960,7 @@ go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
|
||||
go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A=
|
||||
go.uber.org/goleak v1.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4=
|
||||
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
@@ -1405,6 +1428,7 @@ gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gotest.tools/v3 v3.4.0 h1:ZazjZUfuVeZGLAmlKKuyv3IKP5orXcwtOwDQH6YVr6o=
|
||||
gotest.tools/v3 v3.4.0/go.mod h1:CtbdzLSsqVhDgMtKsx03ird5YTGB3ar27v0u/yKBW5g=
|
||||
gvisor.dev/gvisor v0.0.0-20230504175454-7b0a1988a28f h1:8GE2MRjGiFmfpon8dekPI08jEuNMQzSffVHgdupcO4E=
|
||||
gvisor.dev/gvisor v0.0.0-20230504175454-7b0a1988a28f/go.mod h1:pzr6sy8gDLfVmDAg8OYrlKvGEHw5C3PGTiBXBTCx76Q=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
|
||||
@@ -1 +1 @@
|
||||
d149af282305d5365d5a4fb576d9fa81247eb6da
|
||||
27f103a44f8fd34a2cc36995ce7bf83d04433ead
|
||||
|
||||
@@ -141,15 +141,16 @@ func packageTypeCached() string {
|
||||
type EnvType string
|
||||
|
||||
const (
|
||||
KNative = EnvType("kn")
|
||||
AWSLambda = EnvType("lm")
|
||||
Heroku = EnvType("hr")
|
||||
AzureAppService = EnvType("az")
|
||||
AWSFargate = EnvType("fg")
|
||||
FlyDotIo = EnvType("fly")
|
||||
Kubernetes = EnvType("k8s")
|
||||
DockerDesktop = EnvType("dde")
|
||||
Replit = EnvType("repl")
|
||||
KNative = EnvType("kn")
|
||||
AWSLambda = EnvType("lm")
|
||||
Heroku = EnvType("hr")
|
||||
AzureAppService = EnvType("az")
|
||||
AWSFargate = EnvType("fg")
|
||||
FlyDotIo = EnvType("fly")
|
||||
Kubernetes = EnvType("k8s")
|
||||
DockerDesktop = EnvType("dde")
|
||||
Replit = EnvType("repl")
|
||||
HomeAssistantAddOn = EnvType("haao")
|
||||
)
|
||||
|
||||
var envType atomic.Value // of EnvType
|
||||
@@ -170,6 +171,7 @@ var (
|
||||
desktopAtomic atomic.Value // of opt.Bool
|
||||
packagingType atomic.Value // of string
|
||||
appType atomic.Value // of string
|
||||
firewallMode atomic.Value // of string
|
||||
)
|
||||
|
||||
// SetPushDeviceToken sets the device token for use in Hostinfo updates.
|
||||
@@ -181,6 +183,9 @@ func SetDeviceModel(model string) { deviceModelAtomic.Store(model) }
|
||||
// SetOSVersion sets the OS version.
|
||||
func SetOSVersion(v string) { osVersionAtomic.Store(v) }
|
||||
|
||||
// SetFirewallMode sets the firewall mode for the app.
|
||||
func SetFirewallMode(v string) { firewallMode.Store(v) }
|
||||
|
||||
// SetPackage sets the packaging type for the app.
|
||||
//
|
||||
// As of 2022-03-25, this is used by Android ("nogoogle" for the
|
||||
@@ -202,6 +207,13 @@ func pushDeviceToken() string {
|
||||
return s
|
||||
}
|
||||
|
||||
// FirewallMode returns the firewall mode for the app.
|
||||
// It is empty if unset.
|
||||
func FirewallMode() string {
|
||||
s, _ := firewallMode.Load().(string)
|
||||
return s
|
||||
}
|
||||
|
||||
func desktop() (ret opt.Bool) {
|
||||
if runtime.GOOS != "linux" {
|
||||
return opt.Bool("")
|
||||
@@ -255,6 +267,9 @@ func getEnvType() EnvType {
|
||||
if inReplit() {
|
||||
return Replit
|
||||
}
|
||||
if inHomeAssistantAddOn() {
|
||||
return HomeAssistantAddOn
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -364,6 +379,13 @@ func inDockerDesktop() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func inHomeAssistantAddOn() bool {
|
||||
if os.Getenv("SUPERVISOR_TOKEN") != "" || os.Getenv("HASSIO_TOKEN") != "" {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// goArchVar returns the GOARM or GOAMD64 etc value that the binary was built
|
||||
// with.
|
||||
func goArchVar() string {
|
||||
|
||||
@@ -64,6 +64,7 @@ const (
|
||||
NotifyInitialState // if set, the first Notify message (sent immediately) will contain the current State + BrowseToURL
|
||||
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
|
||||
NotifyGUINetMap // if set, only use the Notify.GUINetMap; Notify.Netmap will always be nil. Also impacts NotifyInitialNetMap.
|
||||
|
||||
NotifyNoPrivateKeys // if set, private keys that would normally be sent in updates are zeroed out
|
||||
)
|
||||
@@ -81,13 +82,14 @@ type Notify struct {
|
||||
// For State InUseOtherUser, ErrMessage is not critical and just contains the details.
|
||||
ErrMessage *string
|
||||
|
||||
LoginFinished *empty.Message // non-nil when/if the login process succeeded
|
||||
State *State // if non-nil, the new or current IPN state
|
||||
Prefs *PrefsView // if non-nil && Valid, the new or current preferences
|
||||
NetMap *netmap.NetworkMap // if non-nil, the new or current netmap
|
||||
Engine *EngineStatus // if non-nil, the new or current wireguard stats
|
||||
BrowseToURL *string // if non-nil, UI should open a browser right now
|
||||
BackendLogID *string // if non-nil, the public logtail ID used by backend
|
||||
LoginFinished *empty.Message // non-nil when/if the login process succeeded
|
||||
State *State // if non-nil, the new or current IPN state
|
||||
Prefs *PrefsView // if non-nil && Valid, the new or current preferences
|
||||
//NetMap *netmap.NetworkMap // if non-nil, the new or current netmap
|
||||
GUINetMap *netmap.GUINetworkMap // if non-nil, the new or current netmap
|
||||
Engine *EngineStatus // if non-nil, the new or current wireguard stats
|
||||
BrowseToURL *string // if non-nil, UI should open a browser right now
|
||||
BackendLogID *string // if non-nil, the public logtail ID used by backend
|
||||
|
||||
// FilesWaiting if non-nil means that files are buffered in
|
||||
// the Tailscale daemon and ready for local transfer to the
|
||||
@@ -133,9 +135,9 @@ func (n Notify) String() string {
|
||||
if n.Prefs != nil && n.Prefs.Valid() {
|
||||
fmt.Fprintf(&sb, "%v ", n.Prefs.Pretty())
|
||||
}
|
||||
if n.NetMap != nil {
|
||||
sb.WriteString("NetMap{...} ")
|
||||
}
|
||||
// if n.NetMap != nil {
|
||||
// sb.WriteString("NetMap{...} ")
|
||||
// }
|
||||
if n.Engine != nil {
|
||||
fmt.Fprintf(&sb, "wg=%v ", *n.Engine)
|
||||
}
|
||||
|
||||
30
ipn/ipnlocal/breaktcp_darwin.go
Normal file
30
ipn/ipnlocal/breaktcp_darwin.go
Normal file
@@ -0,0 +1,30 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package ipnlocal
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func init() {
|
||||
breakTCPConns = breakTCPConnsDarwin
|
||||
}
|
||||
|
||||
func breakTCPConnsDarwin() error {
|
||||
var matched int
|
||||
for fd := 0; fd < 1000; fd++ {
|
||||
_, err := unix.GetsockoptTCPConnectionInfo(fd, unix.IPPROTO_TCP, unix.TCP_CONNECTION_INFO)
|
||||
if err == nil {
|
||||
matched++
|
||||
err = unix.Close(fd)
|
||||
log.Printf("debug: closed TCP fd %v: %v", fd, err)
|
||||
}
|
||||
}
|
||||
if matched == 0 {
|
||||
log.Printf("debug: no TCP connections found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
30
ipn/ipnlocal/breaktcp_linux.go
Normal file
30
ipn/ipnlocal/breaktcp_linux.go
Normal file
@@ -0,0 +1,30 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package ipnlocal
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func init() {
|
||||
breakTCPConns = breakTCPConnsLinux
|
||||
}
|
||||
|
||||
func breakTCPConnsLinux() error {
|
||||
var matched int
|
||||
for fd := 0; fd < 1000; fd++ {
|
||||
_, err := unix.GetsockoptTCPInfo(fd, unix.IPPROTO_TCP, unix.TCP_INFO)
|
||||
if err == nil {
|
||||
matched++
|
||||
err = unix.Close(fd)
|
||||
log.Printf("debug: closed TCP fd %v: %v", fd, err)
|
||||
}
|
||||
}
|
||||
if matched == 0 {
|
||||
log.Printf("debug: no TCP connections found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -79,6 +79,7 @@ import (
|
||||
"tailscale.com/util/osshare"
|
||||
"tailscale.com/util/set"
|
||||
"tailscale.com/util/systemd"
|
||||
"tailscale.com/util/testenv"
|
||||
"tailscale.com/util/uniq"
|
||||
"tailscale.com/version"
|
||||
"tailscale.com/version/distro"
|
||||
@@ -490,13 +491,17 @@ func (b *LocalBackend) SetDirectFileDoFinalRename(v bool) {
|
||||
b.directFileDoFinalRename = v
|
||||
}
|
||||
|
||||
// pauseOrResumeControlClientLocked pauses b.cc if there is no network available
|
||||
// or if the LocalBackend is in Stopped state with a valid NetMap. In all other
|
||||
// cases, it unpauses it. It is a no-op if b.cc is nil.
|
||||
//
|
||||
// b.mu must be held.
|
||||
func (b *LocalBackend) maybePauseControlClientLocked() {
|
||||
func (b *LocalBackend) pauseOrResumeControlClientLocked() {
|
||||
if b.cc == nil {
|
||||
return
|
||||
}
|
||||
networkUp := b.prevIfState.AnyInterfaceUp()
|
||||
b.cc.SetPaused((b.state == ipn.Stopped && b.netMap != nil) || !networkUp)
|
||||
b.cc.SetPaused((b.state == ipn.Stopped && b.netMap != nil) || (!networkUp && !testenv.InTest()))
|
||||
}
|
||||
|
||||
// linkChange is our network monitor callback, called whenever the network changes.
|
||||
@@ -507,7 +512,7 @@ func (b *LocalBackend) linkChange(major bool, ifst *interfaces.State) {
|
||||
|
||||
hadPAC := b.prevIfState.HasPAC()
|
||||
b.prevIfState = ifst
|
||||
b.maybePauseControlClientLocked()
|
||||
b.pauseOrResumeControlClientLocked()
|
||||
|
||||
// If the PAC-ness of the network changed, reconfig wireguard+route to
|
||||
// add/remove subnets.
|
||||
@@ -947,7 +952,7 @@ func (b *LocalBackend) setClientStatus(st controlclient.Status) {
|
||||
b.mu.Lock()
|
||||
|
||||
if st.LogoutFinished != nil {
|
||||
if p := b.pm.CurrentPrefs(); !p.Persist().Valid() || p.Persist().LoginName() == "" {
|
||||
if p := b.pm.CurrentPrefs(); !p.Persist().Valid() || p.Persist().UserProfile().LoginName() == "" {
|
||||
b.mu.Unlock()
|
||||
return
|
||||
}
|
||||
@@ -994,18 +999,13 @@ func (b *LocalBackend) setClientStatus(st controlclient.Status) {
|
||||
prefs.WantRunning = true
|
||||
prefs.LoggedOut = false
|
||||
}
|
||||
if findExitNodeIDLocked(prefs, st.NetMap) {
|
||||
if setExitNodeID(prefs, st.NetMap) {
|
||||
prefsChanged = true
|
||||
}
|
||||
|
||||
// Perform all mutations of prefs based on the netmap here.
|
||||
if st.NetMap != nil {
|
||||
if b.updatePersistFromNetMapLocked(st.NetMap, prefs) {
|
||||
prefsChanged = true
|
||||
}
|
||||
}
|
||||
// Prefs will be written out if stale; this is not safe unless locked or cloned.
|
||||
if prefsChanged {
|
||||
// Prefs will be written out if stale; this is not safe unless locked or cloned.
|
||||
if err := b.pm.SetPrefs(prefs.View()); err != nil {
|
||||
b.logf("Failed to save new controlclient state: %v", err)
|
||||
}
|
||||
@@ -1093,9 +1093,9 @@ func (b *LocalBackend) setClientStatus(st controlclient.Status) {
|
||||
b.authReconfig()
|
||||
}
|
||||
|
||||
// findExitNodeIDLocked updates prefs to reference an exit node by ID, rather
|
||||
// setExitNodeID updates prefs to reference an exit node by ID, rather
|
||||
// than by IP. It returns whether prefs was mutated.
|
||||
func findExitNodeIDLocked(prefs *ipn.Prefs, nm *netmap.NetworkMap) (prefsChanged bool) {
|
||||
func setExitNodeID(prefs *ipn.Prefs, nm *netmap.NetworkMap) (prefsChanged bool) {
|
||||
if nm == nil {
|
||||
// No netmap, can't resolve anything.
|
||||
return false
|
||||
@@ -2400,7 +2400,7 @@ func (b *LocalBackend) StartLoginInteractive() {
|
||||
}
|
||||
}
|
||||
|
||||
func (b *LocalBackend) Ping(ctx context.Context, ip netip.Addr, pingType tailcfg.PingType) (*ipnstate.PingResult, error) {
|
||||
func (b *LocalBackend) Ping(ctx context.Context, ip netip.Addr, pingType tailcfg.PingType, size int) (*ipnstate.PingResult, error) {
|
||||
if pingType == tailcfg.PingPeerAPI {
|
||||
t0 := b.clock.Now()
|
||||
node, base, err := b.pingPeerAPI(ctx, ip)
|
||||
@@ -2423,7 +2423,7 @@ func (b *LocalBackend) Ping(ctx context.Context, ip netip.Addr, pingType tailcfg
|
||||
return pr, nil
|
||||
}
|
||||
ch := make(chan *ipnstate.PingResult, 1)
|
||||
b.e.Ping(ip, pingType, func(pr *ipnstate.PingResult) {
|
||||
b.e.Ping(ip, pingType, size, func(pr *ipnstate.PingResult) {
|
||||
select {
|
||||
case ch <- pr:
|
||||
default:
|
||||
@@ -2751,7 +2751,7 @@ func (b *LocalBackend) setPrefsLockedOnEntry(caller string, newp *ipn.Prefs) ipn
|
||||
// findExitNodeIDLocked returns whether it updated b.prefs, but
|
||||
// everything in this function treats b.prefs as completely new
|
||||
// anyway. No-op if no exit node resolution is needed.
|
||||
findExitNodeIDLocked(newp, netMap)
|
||||
setExitNodeID(newp, netMap)
|
||||
// We do this to avoid holding the lock while doing everything else.
|
||||
|
||||
oldHi := b.hostinfo
|
||||
@@ -2774,16 +2774,16 @@ func (b *LocalBackend) setPrefsLockedOnEntry(caller string, newp *ipn.Prefs) ipn
|
||||
}
|
||||
}
|
||||
if netMap != nil {
|
||||
up := netMap.UserProfiles[netMap.User]
|
||||
if login := up.LoginName; login != "" {
|
||||
if newp.Persist == nil {
|
||||
b.logf("active login: %s", login)
|
||||
newProfile := netMap.UserProfiles[netMap.User]
|
||||
if newLoginName := newProfile.LoginName; newLoginName != "" {
|
||||
if !oldp.Persist().Valid() {
|
||||
b.logf("active login: %s", newLoginName)
|
||||
} else {
|
||||
if newp.Persist.LoginName != login {
|
||||
b.logf("active login: %q (changed from %q)", login, newp.Persist.LoginName)
|
||||
newp.Persist.LoginName = login
|
||||
oldLoginName := oldp.Persist().UserProfile().LoginName()
|
||||
if oldLoginName != newLoginName {
|
||||
b.logf("active login: %q (changed from %q)", newLoginName, oldLoginName)
|
||||
}
|
||||
newp.Persist.UserProfile = up
|
||||
newp.Persist.UserProfile = newProfile
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3635,7 +3635,7 @@ func (b *LocalBackend) enterStateLockedOnEntry(newState ipn.State) {
|
||||
// Transitioning away from running.
|
||||
b.closePeerAPIListenersLocked()
|
||||
}
|
||||
b.maybePauseControlClientLocked()
|
||||
b.pauseOrResumeControlClientLocked()
|
||||
b.mu.Unlock()
|
||||
|
||||
// prefs may change irrespective of state; WantRunning should be explicitly
|
||||
@@ -3952,28 +3952,9 @@ func hasCapability(nm *netmap.NetworkMap, cap string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (b *LocalBackend) updatePersistFromNetMapLocked(nm *netmap.NetworkMap, prefs *ipn.Prefs) (changed bool) {
|
||||
if nm == nil || nm.SelfNode == nil {
|
||||
return
|
||||
}
|
||||
up := nm.UserProfiles[nm.User]
|
||||
if prefs.Persist.UserProfile.ID != up.ID {
|
||||
// If the current profile doesn't match the
|
||||
// network map's user profile, then we need to
|
||||
// update the persisted UserProfile to match.
|
||||
prefs.Persist.UserProfile = up
|
||||
changed = true
|
||||
}
|
||||
if prefs.Persist.NodeID == "" {
|
||||
// If the current profile doesn't have a NodeID,
|
||||
// then we need to update the persisted NodeID to
|
||||
// match.
|
||||
prefs.Persist.NodeID = nm.SelfNode.StableID
|
||||
changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// setNetMapLocked updates the LocalBackend state to reflect the newly
|
||||
// received nm. If nm is nil, it resets all configuration as though
|
||||
// Tailscale is turned off.
|
||||
func (b *LocalBackend) setNetMapLocked(nm *netmap.NetworkMap) {
|
||||
b.dialer.SetNetMap(nm)
|
||||
var login string
|
||||
@@ -3985,7 +3966,7 @@ func (b *LocalBackend) setNetMapLocked(nm *netmap.NetworkMap) {
|
||||
b.logf("active login: %v", login)
|
||||
b.activeLogin = login
|
||||
}
|
||||
b.maybePauseControlClientLocked()
|
||||
b.pauseOrResumeControlClientLocked()
|
||||
|
||||
if nm != nil {
|
||||
health.SetControlHealth(nm.ControlHealth)
|
||||
@@ -5049,3 +5030,20 @@ func (b *LocalBackend) GetPeerEndpointChanges(ctx context.Context, ip netip.Addr
|
||||
}
|
||||
return chs, nil
|
||||
}
|
||||
|
||||
var breakTCPConns func() error
|
||||
|
||||
func (b *LocalBackend) DebugBreakTCPConns() error {
|
||||
if breakTCPConns == nil {
|
||||
return errors.New("TCP connection breaking not available on this platform")
|
||||
}
|
||||
return breakTCPConns()
|
||||
}
|
||||
|
||||
func (b *LocalBackend) DebugBreakDERPConns() error {
|
||||
mc, err := b.magicConn()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return mc.DebugBreakDERPConns()
|
||||
}
|
||||
|
||||
@@ -16,9 +16,9 @@ import (
|
||||
"golang.org/x/exp/slices"
|
||||
"tailscale.com/envknob"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/util/clientmetric"
|
||||
"tailscale.com/util/cmpx"
|
||||
"tailscale.com/util/winutil"
|
||||
)
|
||||
|
||||
@@ -33,15 +33,9 @@ type profileManager struct {
|
||||
logf logger.Logf
|
||||
|
||||
currentUserID ipn.WindowsUserID
|
||||
knownProfiles map[ipn.ProfileID]*ipn.LoginProfile
|
||||
currentProfile *ipn.LoginProfile // always non-nil
|
||||
prefs ipn.PrefsView // always Valid.
|
||||
|
||||
// isNewProfile is a sentinel value that indicates that the
|
||||
// current profile is new and has not been saved to disk yet.
|
||||
// It is reset to false after a call to SetPrefs with a filled
|
||||
// in LoginName.
|
||||
isNewProfile bool
|
||||
knownProfiles map[ipn.ProfileID]*ipn.LoginProfile // always non-nil
|
||||
currentProfile *ipn.LoginProfile // always non-nil
|
||||
prefs ipn.PrefsView // always Valid.
|
||||
}
|
||||
|
||||
func (pm *profileManager) dlogf(format string, args ...any) {
|
||||
@@ -107,40 +101,45 @@ func (pm *profileManager) SetCurrentUserID(uid ipn.WindowsUserID) error {
|
||||
}
|
||||
pm.currentProfile = prof
|
||||
pm.prefs = prefs
|
||||
pm.isNewProfile = false
|
||||
return nil
|
||||
}
|
||||
|
||||
// allProfiles returns all profiles that belong to the currentUserID.
|
||||
// The returned profiles are sorted by Name.
|
||||
func (pm *profileManager) allProfiles() (out []*ipn.LoginProfile) {
|
||||
for _, p := range pm.knownProfiles {
|
||||
if p.LocalUserID == pm.currentUserID {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
slices.SortFunc(out, func(a, b *ipn.LoginProfile) int {
|
||||
return cmpx.Compare(a.Name, b.Name)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// matchingProfiles returns all profiles that match the given predicate and
|
||||
// belong to the currentUserID.
|
||||
// The returned profiles are sorted by Name.
|
||||
func (pm *profileManager) matchingProfiles(f func(*ipn.LoginProfile) bool) (out []*ipn.LoginProfile) {
|
||||
for _, p := range pm.knownProfiles {
|
||||
if p.LocalUserID == pm.currentUserID && f(p) {
|
||||
all := pm.allProfiles()
|
||||
out = all[:0]
|
||||
for _, p := range all {
|
||||
if f(p) {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// findProfilesByNodeID returns all profiles that have the provided nodeID and
|
||||
// belong to the same control server.
|
||||
func (pm *profileManager) findProfilesByNodeID(controlURL string, nodeID tailcfg.StableNodeID) []*ipn.LoginProfile {
|
||||
if nodeID.IsZero() {
|
||||
return nil
|
||||
}
|
||||
// findMatchinProfiles returns all profiles that represent the same node/user as
|
||||
// prefs.
|
||||
// The returned profiles are sorted by Name.
|
||||
func (pm *profileManager) findMatchingProfiles(prefs *ipn.Prefs) []*ipn.LoginProfile {
|
||||
return pm.matchingProfiles(func(p *ipn.LoginProfile) bool {
|
||||
return p.NodeID == nodeID && p.ControlURL == controlURL
|
||||
})
|
||||
}
|
||||
|
||||
// findProfilesByUserID returns all profiles that have the provided userID and
|
||||
// belong to the same control server.
|
||||
func (pm *profileManager) findProfilesByUserID(controlURL string, userID tailcfg.UserID) []*ipn.LoginProfile {
|
||||
if userID.IsZero() {
|
||||
return nil
|
||||
}
|
||||
return pm.matchingProfiles(func(p *ipn.LoginProfile) bool {
|
||||
return p.UserProfile.ID == userID && p.ControlURL == controlURL
|
||||
return p.ControlURL == prefs.ControlURL &&
|
||||
(p.UserProfile.ID == prefs.Persist.UserProfile.ID ||
|
||||
p.NodeID == prefs.Persist.NodeID)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -206,46 +205,47 @@ func init() {
|
||||
// It also saves the prefs to the StateStore. It stores a copy of the
|
||||
// provided prefs, which may be accessed via CurrentPrefs.
|
||||
func (pm *profileManager) SetPrefs(prefsIn ipn.PrefsView) error {
|
||||
prefs := prefsIn.AsStruct().View()
|
||||
newPersist := prefs.Persist().AsStruct()
|
||||
if newPersist == nil || newPersist.NodeID == "" {
|
||||
return pm.setPrefsLocked(prefs)
|
||||
prefs := prefsIn.AsStruct()
|
||||
newPersist := prefs.Persist
|
||||
if newPersist == nil || newPersist.NodeID == "" || newPersist.UserProfile.LoginName == "" {
|
||||
// We don't know anything about this profile, so ignore it for now.
|
||||
return pm.setPrefsLocked(prefs.View())
|
||||
}
|
||||
up := newPersist.UserProfile
|
||||
if up.LoginName == "" {
|
||||
// Backwards compatibility with old prefs files.
|
||||
up.LoginName = newPersist.LoginName
|
||||
} else {
|
||||
newPersist.LoginName = up.LoginName
|
||||
}
|
||||
if up.DisplayName == "" {
|
||||
up.DisplayName = up.LoginName
|
||||
}
|
||||
cp := pm.currentProfile
|
||||
if pm.isNewProfile {
|
||||
pm.isNewProfile = false
|
||||
// Check if we already have a profile for this user.
|
||||
existing := pm.findProfilesByUserID(prefs.ControlURL(), newPersist.UserProfile.ID)
|
||||
// Also check if we have a profile with the same NodeID.
|
||||
existing = append(existing, pm.findProfilesByNodeID(prefs.ControlURL(), newPersist.NodeID)...)
|
||||
if len(existing) == 0 {
|
||||
cp.ID, cp.Key = newUnusedID(pm.knownProfiles)
|
||||
} else {
|
||||
// Only one profile per user/nodeID should exist.
|
||||
for _, p := range existing[1:] {
|
||||
// Best effort cleanup.
|
||||
pm.DeleteProfile(p.ID)
|
||||
// Check if we already have an existing profile that matches the user/node.
|
||||
if existing := pm.findMatchingProfiles(prefs); len(existing) > 0 {
|
||||
// We already have a profile for this user/node we should reuse it. Also
|
||||
// cleanup any other duplicate profiles.
|
||||
cp = existing[0]
|
||||
existing = existing[1:]
|
||||
for _, p := range existing {
|
||||
// Clear the state.
|
||||
if err := pm.store.WriteState(p.Key, nil); err != nil {
|
||||
// We couldn't delete the state, so keep the profile around.
|
||||
continue
|
||||
}
|
||||
cp = existing[0]
|
||||
// Remove the profile, knownProfiles will be persisted below.
|
||||
delete(pm.knownProfiles, p.ID)
|
||||
}
|
||||
} else if cp.ID == "" {
|
||||
// We didn't have an existing profile, so create a new one.
|
||||
cp.ID, cp.Key = newUnusedID(pm.knownProfiles)
|
||||
cp.LocalUserID = pm.currentUserID
|
||||
} else {
|
||||
// This means that there was a force-reauth as a new node that
|
||||
// we haven't seen before.
|
||||
}
|
||||
if prefs.ProfileName() != "" {
|
||||
cp.Name = prefs.ProfileName()
|
||||
|
||||
if prefs.ProfileName != "" {
|
||||
cp.Name = prefs.ProfileName
|
||||
} else {
|
||||
cp.Name = up.LoginName
|
||||
}
|
||||
cp.ControlURL = prefs.ControlURL()
|
||||
cp.ControlURL = prefs.ControlURL
|
||||
cp.UserProfile = newPersist.UserProfile
|
||||
cp.NodeID = newPersist.NodeID
|
||||
pm.knownProfiles[cp.ID] = cp
|
||||
@@ -256,7 +256,7 @@ func (pm *profileManager) SetPrefs(prefsIn ipn.PrefsView) error {
|
||||
if err := pm.setAsUserSelectedProfileLocked(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := pm.setPrefsLocked(prefs); err != nil {
|
||||
if err := pm.setPrefsLocked(prefs.View()); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
@@ -279,7 +279,7 @@ func newUnusedID(knownProfiles map[ipn.ProfileID]*ipn.LoginProfile) (ipn.Profile
|
||||
// is not new.
|
||||
func (pm *profileManager) setPrefsLocked(clonedPrefs ipn.PrefsView) error {
|
||||
pm.prefs = clonedPrefs
|
||||
if pm.isNewProfile {
|
||||
if pm.currentProfile.ID == "" {
|
||||
return nil
|
||||
}
|
||||
if err := pm.writePrefsToStore(pm.currentProfile.Key, pm.prefs); err != nil {
|
||||
@@ -301,12 +301,9 @@ func (pm *profileManager) writePrefsToStore(key ipn.StateKey, prefs ipn.PrefsVie
|
||||
|
||||
// Profiles returns the list of known profiles.
|
||||
func (pm *profileManager) Profiles() []ipn.LoginProfile {
|
||||
profiles := pm.matchingProfiles(func(*ipn.LoginProfile) bool { return true })
|
||||
slices.SortFunc(profiles, func(a, b *ipn.LoginProfile) int {
|
||||
return strings.Compare(a.Name, b.Name)
|
||||
})
|
||||
out := make([]ipn.LoginProfile, 0, len(profiles))
|
||||
for _, p := range profiles {
|
||||
allProfiles := pm.allProfiles()
|
||||
out := make([]ipn.LoginProfile, 0, len(allProfiles))
|
||||
for _, p := range allProfiles {
|
||||
out = append(out, *p)
|
||||
}
|
||||
return out
|
||||
@@ -334,7 +331,6 @@ func (pm *profileManager) SwitchProfile(id ipn.ProfileID) error {
|
||||
}
|
||||
pm.prefs = prefs
|
||||
pm.currentProfile = kp
|
||||
pm.isNewProfile = false
|
||||
return pm.setAsUserSelectedProfileLocked()
|
||||
}
|
||||
|
||||
@@ -386,7 +382,7 @@ var errProfileNotFound = errors.New("profile not found")
|
||||
func (pm *profileManager) DeleteProfile(id ipn.ProfileID) error {
|
||||
metricDeleteProfile.Add(1)
|
||||
|
||||
if id == "" && pm.isNewProfile {
|
||||
if id == "" {
|
||||
// Deleting the in-memory only new profile, just create a new one.
|
||||
pm.NewProfile()
|
||||
return nil
|
||||
@@ -437,7 +433,6 @@ func (pm *profileManager) NewProfile() {
|
||||
metricNewProfile.Add(1)
|
||||
|
||||
pm.prefs = defaultPrefs
|
||||
pm.isNewProfile = true
|
||||
pm.currentProfile = &ipn.LoginProfile{}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,9 +16,7 @@ import (
|
||||
func (pm *profileManager) loadLegacyPrefs() (string, ipn.PrefsView, error) {
|
||||
k := ipn.LegacyGlobalDaemonStateKey
|
||||
switch {
|
||||
case runtime.GOOS == "ios":
|
||||
k = "ipn-go-bridge"
|
||||
case version.IsSandboxedMacOS():
|
||||
case runtime.GOOS == "ios", version.IsSandboxedMacOS():
|
||||
k = "ipn-go-bridge"
|
||||
case runtime.GOOS == "android":
|
||||
k = "ipn-android"
|
||||
|
||||
@@ -4,17 +4,21 @@
|
||||
package ipnlocal
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/user"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/google/go-cmp/cmp/cmpopts"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/store/mem"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/key"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/types/persist"
|
||||
"tailscale.com/util/must"
|
||||
)
|
||||
|
||||
func TestProfileCurrentUserSwitch(t *testing.T) {
|
||||
@@ -32,7 +36,6 @@ func TestProfileCurrentUserSwitch(t *testing.T) {
|
||||
p := pm.CurrentPrefs().AsStruct()
|
||||
p.Persist = &persist.Persist{
|
||||
NodeID: tailcfg.StableNodeID(fmt.Sprint(id)),
|
||||
LoginName: loginName,
|
||||
PrivateNodeKey: key.NewNode(),
|
||||
UserProfile: tailcfg.UserProfile{
|
||||
ID: tailcfg.UserID(id),
|
||||
@@ -88,7 +91,6 @@ func TestProfileList(t *testing.T) {
|
||||
p := pm.CurrentPrefs().AsStruct()
|
||||
p.Persist = &persist.Persist{
|
||||
NodeID: tailcfg.StableNodeID(fmt.Sprint(id)),
|
||||
LoginName: loginName,
|
||||
PrivateNodeKey: key.NewNode(),
|
||||
UserProfile: tailcfg.UserProfile{
|
||||
ID: tailcfg.UserID(id),
|
||||
@@ -132,22 +134,186 @@ func TestProfileList(t *testing.T) {
|
||||
if lp := pm.findProfileByName(carol.Name); lp != nil {
|
||||
t.Fatalf("found profile for user2 in user1's profile list")
|
||||
}
|
||||
if lp := pm.findProfilesByNodeID(carol.ControlURL, carol.NodeID); lp != nil {
|
||||
t.Fatalf("found profile for user2 in user1's profile list")
|
||||
}
|
||||
if lp := pm.findProfilesByUserID(carol.ControlURL, carol.UserProfile.ID); lp != nil {
|
||||
t.Fatalf("found profile for user2 in user1's profile list")
|
||||
}
|
||||
|
||||
pm.SetCurrentUserID("user2")
|
||||
checkProfiles(t, "carol")
|
||||
if lp := pm.findProfilesByNodeID(carol.ControlURL, carol.NodeID); lp == nil {
|
||||
t.Fatalf("did not find profile for user2 in user2's profile list")
|
||||
}
|
||||
|
||||
func TestProfileDupe(t *testing.T) {
|
||||
newPersist := func(user, node int) *persist.Persist {
|
||||
return &persist.Persist{
|
||||
NodeID: tailcfg.StableNodeID(fmt.Sprintf("node%d", node)),
|
||||
UserProfile: tailcfg.UserProfile{
|
||||
ID: tailcfg.UserID(user),
|
||||
LoginName: fmt.Sprintf("user%d@example.com", user),
|
||||
},
|
||||
}
|
||||
}
|
||||
if lp := pm.findProfilesByUserID(carol.ControlURL, carol.UserProfile.ID); lp == nil {
|
||||
t.Fatalf("did not find profile for user2 in user2's profile list")
|
||||
user1Node1 := newPersist(1, 1)
|
||||
user1Node2 := newPersist(1, 2)
|
||||
user2Node1 := newPersist(2, 1)
|
||||
user2Node2 := newPersist(2, 2)
|
||||
user3Node3 := newPersist(3, 3)
|
||||
|
||||
reauth := func(pm *profileManager, p *persist.Persist) {
|
||||
prefs := ipn.NewPrefs()
|
||||
prefs.Persist = p
|
||||
must.Do(pm.SetPrefs(prefs.View()))
|
||||
}
|
||||
login := func(pm *profileManager, p *persist.Persist) {
|
||||
pm.NewProfile()
|
||||
reauth(pm, p)
|
||||
}
|
||||
|
||||
type step struct {
|
||||
fn func(pm *profileManager, p *persist.Persist)
|
||||
p *persist.Persist
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
steps []step
|
||||
profs []*persist.Persist
|
||||
}{
|
||||
{
|
||||
name: "reauth-new-node",
|
||||
steps: []step{
|
||||
{login, user1Node1},
|
||||
{reauth, user3Node3},
|
||||
},
|
||||
profs: []*persist.Persist{
|
||||
user3Node3,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "reauth-same-node",
|
||||
steps: []step{
|
||||
{login, user1Node1},
|
||||
{reauth, user1Node1},
|
||||
},
|
||||
profs: []*persist.Persist{
|
||||
user1Node1,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "reauth-other-profile",
|
||||
steps: []step{
|
||||
{login, user1Node1},
|
||||
{login, user2Node2},
|
||||
{reauth, user1Node1},
|
||||
},
|
||||
profs: []*persist.Persist{
|
||||
user1Node1,
|
||||
user2Node2,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "reauth-replace-user",
|
||||
steps: []step{
|
||||
{login, user1Node1},
|
||||
{login, user3Node3},
|
||||
{reauth, user2Node1},
|
||||
},
|
||||
profs: []*persist.Persist{
|
||||
user2Node1,
|
||||
user3Node3,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "reauth-replace-node",
|
||||
steps: []step{
|
||||
{login, user1Node1},
|
||||
{login, user3Node3},
|
||||
{reauth, user1Node2},
|
||||
},
|
||||
profs: []*persist.Persist{
|
||||
user1Node2,
|
||||
user3Node3,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "login-same-node",
|
||||
steps: []step{
|
||||
{login, user1Node1},
|
||||
{login, user3Node3}, // random other profile
|
||||
{login, user1Node1},
|
||||
},
|
||||
profs: []*persist.Persist{
|
||||
user1Node1,
|
||||
user3Node3,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "login-replace-user",
|
||||
steps: []step{
|
||||
{login, user1Node1},
|
||||
{login, user3Node3}, // random other profile
|
||||
{login, user2Node1},
|
||||
},
|
||||
profs: []*persist.Persist{
|
||||
user2Node1,
|
||||
user3Node3,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "login-replace-node",
|
||||
steps: []step{
|
||||
{login, user1Node1},
|
||||
{login, user3Node3}, // random other profile
|
||||
{login, user1Node2},
|
||||
},
|
||||
profs: []*persist.Persist{
|
||||
user1Node2,
|
||||
user3Node3,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "login-new-node",
|
||||
steps: []step{
|
||||
{login, user1Node1},
|
||||
{login, user2Node2},
|
||||
},
|
||||
profs: []*persist.Persist{
|
||||
user1Node1,
|
||||
user2Node2,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
store := new(mem.Store)
|
||||
pm, err := newProfileManagerWithGOOS(store, logger.Discard, "linux")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, s := range tc.steps {
|
||||
s.fn(pm, s.p)
|
||||
}
|
||||
profs := pm.Profiles()
|
||||
var got []*persist.Persist
|
||||
for _, p := range profs {
|
||||
prefs, err := pm.loadSavedPrefs(p.Key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got = append(got, prefs.Persist().AsStruct())
|
||||
}
|
||||
d := cmp.Diff(tc.profs, got, cmpopts.SortSlices(func(a, b *persist.Persist) bool {
|
||||
if a.NodeID != b.NodeID {
|
||||
return a.NodeID < b.NodeID
|
||||
}
|
||||
return a.UserProfile.ID < b.UserProfile.ID
|
||||
}))
|
||||
if d != "" {
|
||||
t.Fatal(d)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func asJSON(v any) []byte {
|
||||
return must.Get(json.MarshalIndent(v, "", " "))
|
||||
}
|
||||
|
||||
// TestProfileManagement tests creating, loading, and switching profiles.
|
||||
@@ -211,7 +377,6 @@ func TestProfileManagement(t *testing.T) {
|
||||
nodeIDs[loginName] = nid
|
||||
}
|
||||
p.Persist = &persist.Persist{
|
||||
LoginName: loginName,
|
||||
PrivateNodeKey: key.NewNode(),
|
||||
UserProfile: tailcfg.UserProfile{
|
||||
ID: uid,
|
||||
@@ -340,7 +505,6 @@ func TestProfileManagementWindows(t *testing.T) {
|
||||
p := pm.CurrentPrefs().AsStruct()
|
||||
p.ForceDaemon = forceDaemon
|
||||
p.Persist = &persist.Persist{
|
||||
LoginName: loginName,
|
||||
UserProfile: tailcfg.UserProfile{
|
||||
ID: id,
|
||||
LoginName: loginName,
|
||||
|
||||
@@ -476,7 +476,6 @@ func TestStateMachine(t *testing.T) {
|
||||
// The backend should propagate this upward for the UI.
|
||||
t.Logf("\n\nLoginFinished")
|
||||
notifies.expect(3)
|
||||
cc.persist.LoginName = "user1"
|
||||
cc.persist.UserProfile.LoginName = "user1"
|
||||
cc.persist.NodeID = "node1"
|
||||
cc.send(nil, "", true, &netmap.NetworkMap{})
|
||||
@@ -494,7 +493,7 @@ func TestStateMachine(t *testing.T) {
|
||||
c.Assert(nn[0].LoginFinished, qt.IsNotNil)
|
||||
c.Assert(nn[1].Prefs, qt.IsNotNil)
|
||||
c.Assert(nn[2].State, qt.IsNotNil)
|
||||
c.Assert(nn[1].Prefs.Persist().LoginName(), qt.Equals, "user1")
|
||||
c.Assert(nn[1].Prefs.Persist().UserProfile().LoginName(), qt.Equals, "user1")
|
||||
c.Assert(ipn.NeedsMachineAuth, qt.Equals, *nn[2].State)
|
||||
c.Assert(ipn.NeedsMachineAuth, qt.Equals, b.State())
|
||||
}
|
||||
@@ -703,7 +702,6 @@ func TestStateMachine(t *testing.T) {
|
||||
b.Login(nil)
|
||||
t.Logf("\n\nLoginFinished3")
|
||||
notifies.expect(3)
|
||||
cc.persist.LoginName = "user2"
|
||||
cc.persist.UserProfile.LoginName = "user2"
|
||||
cc.persist.NodeID = "node2"
|
||||
cc.send(nil, "", true, &netmap.NetworkMap{
|
||||
@@ -717,7 +715,7 @@ func TestStateMachine(t *testing.T) {
|
||||
c.Assert(nn[1].Prefs.Persist(), qt.IsNotNil)
|
||||
c.Assert(nn[2].State, qt.IsNotNil)
|
||||
// Prefs after finishing the login, so LoginName updated.
|
||||
c.Assert(nn[1].Prefs.Persist().LoginName(), qt.Equals, "user2")
|
||||
c.Assert(nn[1].Prefs.Persist().UserProfile().LoginName(), qt.Equals, "user2")
|
||||
c.Assert(nn[1].Prefs.LoggedOut(), qt.IsFalse)
|
||||
c.Assert(nn[1].Prefs.WantRunning(), qt.IsTrue)
|
||||
c.Assert(ipn.Starting, qt.Equals, *nn[2].State)
|
||||
@@ -840,7 +838,6 @@ func TestStateMachine(t *testing.T) {
|
||||
// interactive login, so we end up unpaused.
|
||||
t.Logf("\n\nLoginDifferent URL visited")
|
||||
notifies.expect(3)
|
||||
cc.persist.LoginName = "user3"
|
||||
cc.persist.UserProfile.LoginName = "user3"
|
||||
cc.persist.NodeID = "node3"
|
||||
cc.send(nil, "", true, &netmap.NetworkMap{
|
||||
@@ -859,7 +856,7 @@ func TestStateMachine(t *testing.T) {
|
||||
c.Assert(nn[1].Prefs, qt.IsNotNil)
|
||||
c.Assert(nn[2].State, qt.IsNotNil)
|
||||
// Prefs after finishing the login, so LoginName updated.
|
||||
c.Assert(nn[1].Prefs.Persist().LoginName(), qt.Equals, "user3")
|
||||
c.Assert(nn[1].Prefs.Persist().UserProfile().LoginName(), qt.Equals, "user3")
|
||||
c.Assert(nn[1].Prefs.LoggedOut(), qt.IsFalse)
|
||||
c.Assert(nn[1].Prefs.WantRunning(), qt.IsTrue)
|
||||
c.Assert(ipn.Starting, qt.Equals, *nn[2].State)
|
||||
|
||||
@@ -37,6 +37,7 @@ import (
|
||||
"tailscale.com/net/netmon"
|
||||
"tailscale.com/net/netutil"
|
||||
"tailscale.com/net/portmapper"
|
||||
"tailscale.com/net/tstun"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/tka"
|
||||
"tailscale.com/tstime"
|
||||
@@ -558,7 +559,10 @@ func (h *Handler) serveDebug(w http.ResponseWriter, r *http.Request) {
|
||||
break
|
||||
}
|
||||
h.b.DebugNotify(n)
|
||||
|
||||
case "break-tcp-conns":
|
||||
err = h.b.DebugBreakTCPConns()
|
||||
case "break-derp-conns":
|
||||
err = h.b.DebugBreakDERPConns()
|
||||
case "":
|
||||
err = fmt.Errorf("missing parameter 'action'")
|
||||
default:
|
||||
@@ -1346,7 +1350,24 @@ func (h *Handler) servePing(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "missing 'type' parameter", 400)
|
||||
return
|
||||
}
|
||||
res, err := h.b.Ping(ctx, ip, tailcfg.PingType(pingTypeStr))
|
||||
size := 0
|
||||
sizeStr := r.FormValue("size")
|
||||
if sizeStr != "" {
|
||||
size, err = strconv.Atoi(sizeStr)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid 'size' parameter", 400)
|
||||
return
|
||||
}
|
||||
if size != 0 && tailcfg.PingType(pingTypeStr) != tailcfg.PingDisco {
|
||||
http.Error(w, "'size' parameter is only supported with disco pings", 400)
|
||||
return
|
||||
}
|
||||
if size > int(tstun.DefaultMTU()) {
|
||||
http.Error(w, fmt.Sprintf("maximum value for 'size' is %v", tstun.DefaultMTU()), 400)
|
||||
return
|
||||
}
|
||||
}
|
||||
res, err := h.b.Ping(ctx, ip, tailcfg.PingType(pingTypeStr), size)
|
||||
if err != nil {
|
||||
writeErrorJSON(w, err)
|
||||
return
|
||||
@@ -1437,10 +1458,9 @@ func (h *Handler) serveUploadClientMetrics(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
type clientMetricJSON struct {
|
||||
Name string `json:"name"`
|
||||
// One of "counter" or "gauge"
|
||||
Type string `json:"type"`
|
||||
Value int `json:"value"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"` // one of "counter" or "gauge"
|
||||
Value int `json:"value"` // amount to increment metric by
|
||||
}
|
||||
|
||||
var clientMetrics []clientMetricJSON
|
||||
|
||||
15
ipn/prefs.go
15
ipn/prefs.go
@@ -651,18 +651,11 @@ func PrefsFromBytes(b []byte) (*Prefs, error) {
|
||||
if len(b) == 0 {
|
||||
return p, nil
|
||||
}
|
||||
persist := &persist.Persist{}
|
||||
err := json.Unmarshal(b, persist)
|
||||
if err == nil && (persist.Provider != "" || persist.LoginName != "") {
|
||||
// old-style relaynode config; import it
|
||||
p.Persist = persist
|
||||
} else {
|
||||
err = json.Unmarshal(b, &p)
|
||||
if err != nil {
|
||||
log.Printf("Prefs parse: %v: %v\n", err, b)
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(b, p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p, err
|
||||
return p, nil
|
||||
}
|
||||
|
||||
var jsonEscapedZero = []byte(`\u0000`)
|
||||
|
||||
@@ -264,12 +264,18 @@ func TestPrefsEqual(t *testing.T) {
|
||||
|
||||
{
|
||||
&Prefs{Persist: &persist.Persist{}},
|
||||
&Prefs{Persist: &persist.Persist{LoginName: "dave"}},
|
||||
&Prefs{Persist: &persist.Persist{
|
||||
UserProfile: tailcfg.UserProfile{LoginName: "dave"},
|
||||
}},
|
||||
false,
|
||||
},
|
||||
{
|
||||
&Prefs{Persist: &persist.Persist{LoginName: "dave"}},
|
||||
&Prefs{Persist: &persist.Persist{LoginName: "dave"}},
|
||||
&Prefs{Persist: &persist.Persist{
|
||||
UserProfile: tailcfg.UserProfile{LoginName: "dave"},
|
||||
}},
|
||||
&Prefs{Persist: &persist.Persist{
|
||||
UserProfile: tailcfg.UserProfile{LoginName: "dave"},
|
||||
}},
|
||||
true,
|
||||
},
|
||||
{
|
||||
@@ -345,7 +351,9 @@ func TestPrefsPersist(t *testing.T) {
|
||||
tstest.PanicOnLog()
|
||||
|
||||
c := persist.Persist{
|
||||
LoginName: "test@example.com",
|
||||
UserProfile: tailcfg.UserProfile{
|
||||
LoginName: "test@example.com",
|
||||
},
|
||||
}
|
||||
p := Prefs{
|
||||
ControlURL: "https://controlplane.tailscale.com",
|
||||
|
||||
18
ipn/serve.go
18
ipn/serve.go
@@ -204,31 +204,27 @@ func (sc *ServeConfig) IsFunnelOn() bool {
|
||||
// CheckFunnelAccess checks whether Funnel access is allowed for the given node
|
||||
// and port.
|
||||
// It checks:
|
||||
// 1. Funnel is enabled on the Tailnet
|
||||
// 2. HTTPS is enabled on the Tailnet
|
||||
// 3. the node has the "funnel" nodeAttr
|
||||
// 4. the port is allowed for Funnel
|
||||
// 1. HTTPS is enabled on the Tailnet
|
||||
// 2. the node has the "funnel" nodeAttr
|
||||
// 3. the port is allowed for Funnel
|
||||
//
|
||||
// The nodeAttrs arg should be the node's Self.Capabilities which should contain
|
||||
// the attribute we're checking for and possibly warning-capabilities for
|
||||
// Funnel.
|
||||
func CheckFunnelAccess(port uint16, nodeAttrs []string) error {
|
||||
if slices.Contains(nodeAttrs, tailcfg.CapabilityWarnFunnelNoInvite) {
|
||||
return errors.New("Funnel not enabled; See https://tailscale.com/s/no-funnel.")
|
||||
}
|
||||
if slices.Contains(nodeAttrs, tailcfg.CapabilityWarnFunnelNoHTTPS) {
|
||||
if !slices.Contains(nodeAttrs, tailcfg.CapabilityHTTPS) {
|
||||
return errors.New("Funnel not available; HTTPS must be enabled. See https://tailscale.com/s/https.")
|
||||
}
|
||||
if !slices.Contains(nodeAttrs, tailcfg.NodeAttrFunnel) {
|
||||
return errors.New("Funnel not available; \"funnel\" node attribute not set. See https://tailscale.com/s/no-funnel.")
|
||||
}
|
||||
return checkFunnelPort(port, nodeAttrs)
|
||||
return CheckFunnelPort(port, nodeAttrs)
|
||||
}
|
||||
|
||||
// checkFunnelPort checks whether the given port is allowed for Funnel.
|
||||
// CheckFunnelPort checks whether the given port is allowed for Funnel.
|
||||
// It uses the tailcfg.CapabilityFunnelPorts nodeAttr to determine the allowed
|
||||
// ports.
|
||||
func checkFunnelPort(wantedPort uint16, nodeAttrs []string) error {
|
||||
func CheckFunnelPort(wantedPort uint16, nodeAttrs []string) error {
|
||||
deny := func(allowedPorts string) error {
|
||||
if allowedPorts == "" {
|
||||
return fmt.Errorf("port %d is not allowed for funnel", wantedPort)
|
||||
|
||||
@@ -16,14 +16,13 @@ func TestCheckFunnelAccess(t *testing.T) {
|
||||
wantErr bool
|
||||
}{
|
||||
{443, []string{portAttr}, true}, // No "funnel" attribute
|
||||
{443, []string{portAttr, tailcfg.CapabilityWarnFunnelNoInvite}, true},
|
||||
{443, []string{portAttr, tailcfg.CapabilityWarnFunnelNoHTTPS}, true},
|
||||
{443, []string{portAttr, tailcfg.NodeAttrFunnel}, false},
|
||||
{8443, []string{portAttr, tailcfg.NodeAttrFunnel}, false},
|
||||
{8321, []string{portAttr, tailcfg.NodeAttrFunnel}, true},
|
||||
{8083, []string{portAttr, tailcfg.NodeAttrFunnel}, false},
|
||||
{8091, []string{portAttr, tailcfg.NodeAttrFunnel}, true},
|
||||
{3000, []string{portAttr, tailcfg.NodeAttrFunnel}, true},
|
||||
{443, []string{portAttr, tailcfg.NodeAttrFunnel}, true},
|
||||
{443, []string{portAttr, tailcfg.CapabilityHTTPS, tailcfg.NodeAttrFunnel}, false},
|
||||
{8443, []string{portAttr, tailcfg.CapabilityHTTPS, tailcfg.NodeAttrFunnel}, false},
|
||||
{8321, []string{portAttr, tailcfg.CapabilityHTTPS, tailcfg.NodeAttrFunnel}, true},
|
||||
{8083, []string{portAttr, tailcfg.CapabilityHTTPS, tailcfg.NodeAttrFunnel}, false},
|
||||
{8091, []string{portAttr, tailcfg.CapabilityHTTPS, tailcfg.NodeAttrFunnel}, true},
|
||||
{3000, []string{portAttr, tailcfg.CapabilityHTTPS, tailcfg.NodeAttrFunnel}, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
err := CheckFunnelAccess(tt.port, tt.caps)
|
||||
|
||||
21
licenses/licenses.go
Normal file
21
licenses/licenses.go
Normal file
@@ -0,0 +1,21 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package licenses provides utilities for working with open source licenses.
|
||||
package licenses
|
||||
|
||||
import "runtime"
|
||||
|
||||
// LicensesURL returns the absolute URL containing open source license information for the current platform.
|
||||
func LicensesURL() string {
|
||||
switch runtime.GOOS {
|
||||
case "android":
|
||||
return "https://tailscale.com/licenses/android"
|
||||
case "darwin", "ios":
|
||||
return "https://tailscale.com/licenses/apple"
|
||||
case "windows":
|
||||
return "https://tailscale.com/licenses/windows"
|
||||
default:
|
||||
return "https://tailscale.com/licenses/tailscale"
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
@@ -49,13 +48,12 @@ import (
|
||||
"tailscale.com/util/clientmetric"
|
||||
"tailscale.com/util/must"
|
||||
"tailscale.com/util/racebuild"
|
||||
"tailscale.com/util/testenv"
|
||||
"tailscale.com/util/winutil"
|
||||
"tailscale.com/version"
|
||||
"tailscale.com/version/distro"
|
||||
)
|
||||
|
||||
func inTest() bool { return flag.Lookup("test.v") != nil }
|
||||
|
||||
var getLogTargetOnce struct {
|
||||
sync.Once
|
||||
v string // URL of logs server, or empty for default
|
||||
@@ -576,7 +574,7 @@ func NewWithConfigPath(collection, dir, cmdName string, netMon *netmon.Monitor,
|
||||
conf.IncludeProcSequence = true
|
||||
}
|
||||
|
||||
if envknob.NoLogsNoSupport() || inTest() {
|
||||
if envknob.NoLogsNoSupport() || testenv.InTest() {
|
||||
logf("You have disabled logging. Tailscale will not be able to provide support.")
|
||||
conf.HTTPC = &http.Client{Transport: noopPretendSuccessTransport{}}
|
||||
} else if val := getLogTarget(); val != "" {
|
||||
@@ -756,7 +754,7 @@ func dialContext(ctx context.Context, netw, addr string, netMon *netmon.Monitor,
|
||||
// The logf parameter is optional; if non-nil, logs are printed using the
|
||||
// provided function; if nil, log.Printf will be used instead.
|
||||
func NewLogtailTransport(host string, netMon *netmon.Monitor, logf logger.Logf) http.RoundTripper {
|
||||
if inTest() {
|
||||
if testenv.InTest() {
|
||||
return noopPretendSuccessTransport{}
|
||||
}
|
||||
// Start with a copy of http.DefaultTransport and tweak it a bit.
|
||||
|
||||
@@ -29,8 +29,6 @@ type strideEntry[T any] struct {
|
||||
prefixIndex int
|
||||
// value is the value associated with the strideEntry, if any.
|
||||
value *T
|
||||
// child is the child strideTable associated with the strideEntry, if any.
|
||||
child *strideTable[T]
|
||||
}
|
||||
|
||||
// strideTable is a binary tree that implements an 8-bit routing table.
|
||||
@@ -50,12 +48,17 @@ type strideTable[T any] struct {
|
||||
// parent of the node at index i is located at index i>>1, and its children
|
||||
// at indices i<<1 and (i<<1)+1.
|
||||
//
|
||||
// A few consequences of this arrangement: host routes (/8) occupy the last
|
||||
// 256 entries in the table; the single default route /0 is at index 1, and
|
||||
// index 0 is unused (in the original paper, it's hijacked through sneaky C
|
||||
// memory trickery to store the refcount, but this is Go, where we don't
|
||||
// store random bits in pointers lest we confuse the GC)
|
||||
// A few consequences of this arrangement: host routes (/8) occupy
|
||||
// the last numChildren entries in the table; the single default
|
||||
// route /0 is at index 1, and index 0 is unused (in the original
|
||||
// paper, it's hijacked through sneaky C memory trickery to store
|
||||
// the refcount, but this is Go, where we don't store random bits
|
||||
// in pointers lest we confuse the GC)
|
||||
entries [lastHostIndex + 1]strideEntry[T]
|
||||
// children are the child tables of this table. Each child
|
||||
// represents the address space within one of this table's host
|
||||
// routes (/8).
|
||||
children [numChildren]*strideTable[T]
|
||||
// routeRefs is the number of route entries in this table.
|
||||
routeRefs uint16
|
||||
// childRefs is the number of child strideTables referenced by this table.
|
||||
@@ -67,63 +70,60 @@ const (
|
||||
firstHostIndex = 0b1_0000_0000
|
||||
// lastHostIndex is the array index of the last host route. This is hostIndex(0xFF/8).
|
||||
lastHostIndex = 0b1_1111_1111
|
||||
|
||||
// numChildren is the maximum number of child tables a strideTable can hold.
|
||||
numChildren = 256
|
||||
)
|
||||
|
||||
// getChild returns the child strideTable pointer for addr (if any), and an
|
||||
// internal array index that can be used with deleteChild.
|
||||
func (t *strideTable[T]) getChild(addr uint8) (child *strideTable[T], idx int) {
|
||||
idx = hostIndex(addr)
|
||||
return t.entries[idx].child, idx
|
||||
// getChild returns the child strideTable pointer for addr, or nil if none.
|
||||
func (t *strideTable[T]) getChild(addr uint8) *strideTable[T] {
|
||||
return t.children[addr]
|
||||
}
|
||||
|
||||
// deleteChild deletes the child strideTable at idx (if any). idx should be
|
||||
// obtained via a call to getChild.
|
||||
func (t *strideTable[T]) deleteChild(idx int) {
|
||||
t.entries[idx].child = nil
|
||||
t.childRefs--
|
||||
// deleteChild deletes the child strideTable at addr. It is valid to
|
||||
// delete a non-existent child.
|
||||
func (t *strideTable[T]) deleteChild(addr uint8) {
|
||||
if t.children[addr] != nil {
|
||||
t.childRefs--
|
||||
}
|
||||
t.children[addr] = nil
|
||||
}
|
||||
|
||||
// setChild replaces the child strideTable for addr (if any) with child.
|
||||
// setChild sets the child strideTable for addr to child.
|
||||
func (t *strideTable[T]) setChild(addr uint8, child *strideTable[T]) {
|
||||
t.setChildByIndex(hostIndex(addr), child)
|
||||
}
|
||||
|
||||
// setChildByIndex replaces the child strideTable at idx (if any) with
|
||||
// child. idx should be obtained via a call to getChild.
|
||||
func (t *strideTable[T]) setChildByIndex(idx int, child *strideTable[T]) {
|
||||
if t.entries[idx].child == nil {
|
||||
if t.children[addr] == nil {
|
||||
t.childRefs++
|
||||
}
|
||||
t.entries[idx].child = child
|
||||
t.children[addr] = child
|
||||
}
|
||||
|
||||
// getOrCreateChild returns the child strideTable for addr, creating it if
|
||||
// necessary.
|
||||
func (t *strideTable[T]) getOrCreateChild(addr uint8) (child *strideTable[T], created bool) {
|
||||
idx := hostIndex(addr)
|
||||
if t.entries[idx].child == nil {
|
||||
t.entries[idx].child = &strideTable[T]{
|
||||
ret := t.children[addr]
|
||||
if ret == nil {
|
||||
ret = &strideTable[T]{
|
||||
prefix: childPrefixOf(t.prefix, addr),
|
||||
}
|
||||
t.children[addr] = ret
|
||||
t.childRefs++
|
||||
return t.entries[idx].child, true
|
||||
return ret, true
|
||||
}
|
||||
return t.entries[idx].child, false
|
||||
return ret, false
|
||||
}
|
||||
|
||||
// getValAndChild returns both the prefix and child strideTable for
|
||||
// addr. Both returned values can be nil if no entry of that type
|
||||
// exists for addr.
|
||||
func (t *strideTable[T]) getValAndChild(addr uint8) (*T, *strideTable[T]) {
|
||||
idx := hostIndex(addr)
|
||||
return t.entries[idx].value, t.entries[idx].child
|
||||
return t.entries[hostIndex(addr)].value, t.children[addr]
|
||||
}
|
||||
|
||||
// findFirstChild returns the first child strideTable in t, or nil if
|
||||
// t has no children.
|
||||
func (t *strideTable[T]) findFirstChild() *strideTable[T] {
|
||||
for i := firstHostIndex; i <= lastHostIndex; i++ {
|
||||
if child := t.entries[i].child; child != nil {
|
||||
for _, child := range t.children {
|
||||
if child != nil {
|
||||
return child
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,7 +364,7 @@ func (t *Table[T]) Delete(pfx netip.Prefix) {
|
||||
// write to strideTables[N] and strideIndexes[N-1].
|
||||
strideIdx := 0
|
||||
strideTables := [16]*strideTable[T]{st}
|
||||
strideIndexes := [15]int{}
|
||||
strideIndexes := [15]uint8{}
|
||||
|
||||
// Similar to Insert, navigate down the tree of strideTables,
|
||||
// looking for the one that houses this prefix. This part is
|
||||
@@ -384,7 +384,7 @@ func (t *Table[T]) Delete(pfx netip.Prefix) {
|
||||
if debugDelete {
|
||||
fmt.Printf("delete: loop byteIdx=%d numBits=%d st.prefix=%s\n", byteIdx, numBits, st.prefix)
|
||||
}
|
||||
child, idx := st.getChild(bs[byteIdx])
|
||||
child := st.getChild(bs[byteIdx])
|
||||
if child == nil {
|
||||
// Prefix can't exist in the table, because one of the
|
||||
// necessary strideTables doesn't exist.
|
||||
@@ -393,7 +393,7 @@ func (t *Table[T]) Delete(pfx netip.Prefix) {
|
||||
}
|
||||
return
|
||||
}
|
||||
strideIndexes[strideIdx] = idx
|
||||
strideIndexes[strideIdx] = bs[byteIdx]
|
||||
strideTables[strideIdx+1] = child
|
||||
strideIdx++
|
||||
|
||||
@@ -475,7 +475,7 @@ func (t *Table[T]) Delete(pfx netip.Prefix) {
|
||||
if debugDelete {
|
||||
fmt.Printf("delete: compact parent.prefix=%s st.prefix=%s child.prefix=%s\n", parent.prefix, cur.prefix, child.prefix)
|
||||
}
|
||||
strideTables[strideIdx-1].setChildByIndex(strideIndexes[strideIdx-1], child)
|
||||
strideTables[strideIdx-1].setChild(strideIndexes[strideIdx-1], child)
|
||||
return
|
||||
default:
|
||||
// This table has two or more children, so it's acting as a "fork in
|
||||
@@ -505,12 +505,12 @@ func strideSummary[T any](w io.Writer, st *strideTable[T], indent int) {
|
||||
fmt.Fprintf(w, "%s: %d routes, %d children\n", st.prefix, st.routeRefs, st.childRefs)
|
||||
indent += 4
|
||||
st.treeDebugStringRec(w, 1, indent)
|
||||
for i := firstHostIndex; i <= lastHostIndex; i++ {
|
||||
if child := st.entries[i].child; child != nil {
|
||||
addr, len := inversePrefixIndex(i)
|
||||
fmt.Fprintf(w, "%s%d/%d (%02x/%d): ", strings.Repeat(" ", indent), addr, len, addr, len)
|
||||
strideSummary(w, child, indent)
|
||||
for addr, child := range st.children {
|
||||
if child == nil {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(w, "%s%d/8 (%02x/8): ", strings.Repeat(" ", indent), addr, addr)
|
||||
strideSummary(w, child, indent)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -607,7 +607,7 @@ func TestInsertCompare(t *testing.T) {
|
||||
seenVals4[fastVal] = true
|
||||
}
|
||||
if slowVal != fastVal {
|
||||
t.Errorf("get(%q) = %p, want %p", a, fastVal, slowVal)
|
||||
t.Fatalf("get(%q) = %p, want %p", a, fastVal, slowVal)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1092,11 +1092,12 @@ func (t *Table[T]) numStridesRec(seen map[*strideTable[T]]bool, st *strideTable[
|
||||
if st.childRefs == 0 {
|
||||
return ret
|
||||
}
|
||||
for i := firstHostIndex; i <= lastHostIndex; i++ {
|
||||
if c := st.entries[i].child; c != nil && !seen[c] {
|
||||
seen[c] = true
|
||||
ret += t.numStridesRec(seen, c)
|
||||
for _, c := range st.children {
|
||||
if c == nil || seen[c] {
|
||||
continue
|
||||
}
|
||||
seen[c] = true
|
||||
ret += t.numStridesRec(seen, c)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
"golang.org/x/exp/constraints"
|
||||
"golang.org/x/exp/slices"
|
||||
"tailscale.com/envknob"
|
||||
"tailscale.com/net/netns"
|
||||
@@ -484,13 +483,6 @@ func (r *Resolver) resolveRecursive(
|
||||
return nil, 0, ErrNoResponses
|
||||
}
|
||||
|
||||
func min[T constraints.Ordered](a, b T) T {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// queryNameserver sends a query for "name" to the nameserver "nameserver" for
|
||||
// records of type "qtype", trying both UDP and TCP connections as
|
||||
// appropriate.
|
||||
|
||||
@@ -27,7 +27,6 @@ import (
|
||||
"tailscale.com/envknob"
|
||||
"tailscale.com/net/dnscache"
|
||||
"tailscale.com/net/interfaces"
|
||||
"tailscale.com/net/netaddr"
|
||||
"tailscale.com/net/neterror"
|
||||
"tailscale.com/net/netmon"
|
||||
"tailscale.com/net/netns"
|
||||
@@ -40,7 +39,6 @@ import (
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/types/nettype"
|
||||
"tailscale.com/types/opt"
|
||||
"tailscale.com/types/ptr"
|
||||
"tailscale.com/types/views"
|
||||
"tailscale.com/util/clientmetric"
|
||||
"tailscale.com/util/cmpx"
|
||||
@@ -154,7 +152,12 @@ func cloneDurationMap(m map[int]time.Duration) map[int]time.Duration {
|
||||
return m2
|
||||
}
|
||||
|
||||
// Client generates a netcheck Report.
|
||||
// Client generates Reports describing the result of both passive and active
|
||||
// network configuration probing. It provides two different modes of report, a
|
||||
// full report (see MakeNextReportFull) and a more lightweight incremental
|
||||
// report. The client must be provided with SendPacket in order to perform
|
||||
// active probes, and must receive STUN packet replies via ReceiveSTUNPacket.
|
||||
// Client can be used in a standalone fashion via the Standalone method.
|
||||
type Client struct {
|
||||
// Verbose enables verbose logging.
|
||||
Verbose bool
|
||||
@@ -173,23 +176,15 @@ type Client struct {
|
||||
// TimeNow, if non-nil, is used instead of time.Now.
|
||||
TimeNow func() time.Time
|
||||
|
||||
// GetSTUNConn4 optionally provides a func to return the
|
||||
// connection to use for sending & receiving IPv4 packets. If
|
||||
// nil, an ephemeral one is created as needed.
|
||||
GetSTUNConn4 func() STUNConn
|
||||
|
||||
// GetSTUNConn6 is like GetSTUNConn4, but for IPv6.
|
||||
GetSTUNConn6 func() STUNConn
|
||||
// SendPacket is required to send a packet to the specified address. For
|
||||
// convenience it shares a signature with WriteToUDPAddrPort.
|
||||
SendPacket func([]byte, netip.AddrPort) (int, error)
|
||||
|
||||
// SkipExternalNetwork controls whether the client should not try
|
||||
// to reach things other than localhost. This is set to true
|
||||
// in tests to avoid probing the local LAN's router, etc.
|
||||
SkipExternalNetwork bool
|
||||
|
||||
// UDPBindAddr, if non-empty, is the address to listen on for UDP.
|
||||
// It defaults to ":0".
|
||||
UDPBindAddr string
|
||||
|
||||
// PortMapper, if non-nil, is used for portmap queries.
|
||||
// If nil, portmap discovery is not done.
|
||||
PortMapper *portmapper.Client // lazily initialized on first use
|
||||
@@ -216,13 +211,6 @@ type Client struct {
|
||||
resolver *dnscache.Resolver // only set if UseDNSCache is true
|
||||
}
|
||||
|
||||
// STUNConn is the interface required by the netcheck Client when
|
||||
// reusing an existing UDP connection.
|
||||
type STUNConn interface {
|
||||
WriteToUDPAddrPort([]byte, netip.AddrPort) (int, error)
|
||||
ReadFromUDPAddrPort([]byte) (int, netip.AddrPort, error)
|
||||
}
|
||||
|
||||
func (c *Client) enoughRegions() int {
|
||||
if c.testEnoughRegions > 0 {
|
||||
return c.testEnoughRegions
|
||||
@@ -282,6 +270,10 @@ func (c *Client) MakeNextReportFull() {
|
||||
c.nextFull = true
|
||||
}
|
||||
|
||||
// ReceiveSTUNPacket must be called when a STUN packet is received as a reply to
|
||||
// packet the client sent using SendPacket. In Standalone this is performed by
|
||||
// the loop started by Standalone, in normal operation in tailscaled incoming
|
||||
// STUN replies are routed to this method.
|
||||
func (c *Client) ReceiveSTUNPacket(pkt []byte, src netip.AddrPort) {
|
||||
c.vlogf("received STUN packet from %s", src)
|
||||
|
||||
@@ -528,53 +520,12 @@ func nodeMight4(n *tailcfg.DERPNode) bool {
|
||||
return ip.Is4()
|
||||
}
|
||||
|
||||
type packetReaderFromCloser interface {
|
||||
ReadFromUDPAddrPort([]byte) (int, netip.AddrPort, error)
|
||||
io.Closer
|
||||
}
|
||||
|
||||
// readPackets reads STUN packets from pc until there's an error or ctx is done.
|
||||
// In either case, it closes pc.
|
||||
func (c *Client) readPackets(ctx context.Context, pc packetReaderFromCloser) {
|
||||
done := make(chan struct{})
|
||||
defer close(done)
|
||||
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-done:
|
||||
}
|
||||
pc.Close()
|
||||
}()
|
||||
|
||||
var buf [64 << 10]byte
|
||||
for {
|
||||
n, addr, err := pc.ReadFromUDPAddrPort(buf[:])
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
c.logf("ReadFrom: %v", err)
|
||||
return
|
||||
}
|
||||
pkt := buf[:n]
|
||||
if !stun.Is(pkt) {
|
||||
continue
|
||||
}
|
||||
if ap := netaddr.Unmap(addr); ap.IsValid() {
|
||||
c.ReceiveSTUNPacket(pkt, ap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// reportState holds the state for a single invocation of Client.GetReport.
|
||||
type reportState struct {
|
||||
c *Client
|
||||
hairTX stun.TxID
|
||||
gotHairSTUN chan netip.AddrPort
|
||||
hairTimeout chan struct{} // closed on timeout
|
||||
pc4 STUNConn
|
||||
pc6 STUNConn
|
||||
pc4Hair nettype.PacketConn
|
||||
incremental bool // doing a lite, follow-up netcheck
|
||||
stopProbeCh chan struct{}
|
||||
@@ -785,13 +736,6 @@ func newReport() *Report {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) udpBindAddr() string {
|
||||
if v := c.UDPBindAddr; v != "" {
|
||||
return v
|
||||
}
|
||||
return ":0"
|
||||
}
|
||||
|
||||
// GetReport gets a report.
|
||||
//
|
||||
// It may not be called concurrently with itself.
|
||||
@@ -924,42 +868,6 @@ func (c *Client) GetReport(ctx context.Context, dm *tailcfg.DERPMap) (_ *Report,
|
||||
[]byte("tailscale netcheck; see https://github.com/tailscale/tailscale/issues/188"),
|
||||
netip.AddrPortFrom(netip.MustParseAddr(documentationIP), 12345))
|
||||
|
||||
if f := c.GetSTUNConn4; f != nil {
|
||||
rs.pc4 = f()
|
||||
} else {
|
||||
u4, err := nettype.MakePacketListenerWithNetIP(netns.Listener(c.logf, nil)).ListenPacket(ctx, "udp4", c.udpBindAddr())
|
||||
if err != nil {
|
||||
c.logf("udp4: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
rs.pc4 = u4
|
||||
go c.readPackets(ctx, u4)
|
||||
}
|
||||
|
||||
if ifState.HaveV6 {
|
||||
if f := c.GetSTUNConn6; f != nil {
|
||||
rs.pc6 = f()
|
||||
} else {
|
||||
u6, err := nettype.MakePacketListenerWithNetIP(netns.Listener(c.logf, nil)).ListenPacket(ctx, "udp6", c.udpBindAddr())
|
||||
if err != nil {
|
||||
c.logf("udp6: %v", err)
|
||||
} else {
|
||||
rs.pc6 = u6
|
||||
go c.readPackets(ctx, u6)
|
||||
}
|
||||
}
|
||||
|
||||
// If our interfaces.State suggested we have IPv6 support but then we
|
||||
// failed to get an IPv6 sending socket (as in
|
||||
// https://github.com/tailscale/tailscale/issues/7949), then change
|
||||
// ifState.HaveV6 before we make a probe plan that involves sending IPv6
|
||||
// packets and thus assuming rs.pc6 is non-nil.
|
||||
if rs.pc6 == nil {
|
||||
ifState = ptr.To(*ifState) // shallow clone
|
||||
ifState.HaveV6 = false
|
||||
}
|
||||
}
|
||||
|
||||
plan := makeProbePlan(dm, ifState, last)
|
||||
|
||||
// If we're doing a full probe, also check for a captive portal. We
|
||||
@@ -1614,26 +1522,35 @@ func (rs *reportState) runProbe(ctx context.Context, dm *tailcfg.DERPMap, probe
|
||||
}
|
||||
rs.mu.Unlock()
|
||||
|
||||
if rs.c.SendPacket == nil {
|
||||
rs.mu.Lock()
|
||||
rs.report.IPv4CanSend = false
|
||||
rs.report.IPv6CanSend = false
|
||||
rs.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
switch probe.proto {
|
||||
case probeIPv4:
|
||||
metricSTUNSend4.Add(1)
|
||||
n, err := rs.pc4.WriteToUDPAddrPort(req, addr)
|
||||
if n == len(req) && err == nil || neterror.TreatAsLostUDP(err) {
|
||||
rs.mu.Lock()
|
||||
rs.report.IPv4CanSend = true
|
||||
rs.mu.Unlock()
|
||||
}
|
||||
case probeIPv6:
|
||||
metricSTUNSend6.Add(1)
|
||||
n, err := rs.pc6.WriteToUDPAddrPort(req, addr)
|
||||
if n == len(req) && err == nil || neterror.TreatAsLostUDP(err) {
|
||||
rs.mu.Lock()
|
||||
rs.report.IPv6CanSend = true
|
||||
rs.mu.Unlock()
|
||||
}
|
||||
default:
|
||||
panic("bad probe proto " + fmt.Sprint(probe.proto))
|
||||
}
|
||||
|
||||
n, err := rs.c.SendPacket(req, addr)
|
||||
if n == len(req) && err == nil || neterror.TreatAsLostUDP(err) {
|
||||
rs.mu.Lock()
|
||||
switch probe.proto {
|
||||
case probeIPv4:
|
||||
rs.report.IPv4CanSend = true
|
||||
case probeIPv6:
|
||||
rs.report.IPv6CanSend = true
|
||||
}
|
||||
rs.mu.Unlock()
|
||||
}
|
||||
|
||||
c.vlogf("sent to %v", addr)
|
||||
}
|
||||
|
||||
|
||||
@@ -159,13 +159,16 @@ func TestBasic(t *testing.T) {
|
||||
defer cleanup()
|
||||
|
||||
c := &Client{
|
||||
Logf: t.Logf,
|
||||
UDPBindAddr: "127.0.0.1:0",
|
||||
Logf: t.Logf,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := c.Standalone(ctx, "127.0.0.1:0"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r, err := c.GetReport(ctx, stuntest.DERPMapOf(stunAddr.String()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -851,7 +854,6 @@ func TestNoCaptivePortalWhenUDP(t *testing.T) {
|
||||
|
||||
c := &Client{
|
||||
Logf: t.Logf,
|
||||
UDPBindAddr: "127.0.0.1:0",
|
||||
testEnoughRegions: 1,
|
||||
|
||||
// Set the delay long enough that we have time to cancel it
|
||||
@@ -862,6 +864,10 @@ func TestNoCaptivePortalWhenUDP(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := c.Standalone(ctx, "127.0.0.1:0"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r, err := c.GetReport(ctx, stuntest.DERPMapOf(stunAddr.String()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -885,7 +891,6 @@ func (f RoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
func TestNodeAddrResolve(t *testing.T) {
|
||||
c := &Client{
|
||||
Logf: t.Logf,
|
||||
UDPBindAddr: "127.0.0.1:0",
|
||||
UseDNSCache: true,
|
||||
}
|
||||
|
||||
|
||||
99
net/netcheck/standalone.go
Normal file
99
net/netcheck/standalone.go
Normal file
@@ -0,0 +1,99 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package netcheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/netip"
|
||||
|
||||
"tailscale.com/net/netaddr"
|
||||
"tailscale.com/net/netns"
|
||||
"tailscale.com/net/stun"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/types/nettype"
|
||||
"tailscale.com/util/multierr"
|
||||
)
|
||||
|
||||
// Standalone creates the necessary UDP sockets on the given bindAddr and starts
|
||||
// an IO loop so that the Client can perform active probes with no further need
|
||||
// for external driving of IO (no need to set/implement SendPacket, or call
|
||||
// ReceiveSTUNPacket). It must be called prior to starting any reports and is
|
||||
// shut down by cancellation of the provided context. If both IPv4 and IPv6 fail
|
||||
// to bind, errors will be returned, if one or both protocols can bind no error
|
||||
// is returned.
|
||||
func (c *Client) Standalone(ctx context.Context, bindAddr string) error {
|
||||
if bindAddr == "" {
|
||||
bindAddr = ":0"
|
||||
}
|
||||
var errs []error
|
||||
|
||||
u4, err := nettype.MakePacketListenerWithNetIP(netns.Listener(c.logf, nil)).ListenPacket(ctx, "udp4", bindAddr)
|
||||
if err != nil {
|
||||
c.logf("udp4: %v", err)
|
||||
errs = append(errs, err)
|
||||
} else {
|
||||
go readPackets(ctx, c.logf, u4, c.ReceiveSTUNPacket)
|
||||
}
|
||||
|
||||
u6, err := nettype.MakePacketListenerWithNetIP(netns.Listener(c.logf, nil)).ListenPacket(ctx, "udp6", bindAddr)
|
||||
if err != nil {
|
||||
c.logf("udp6: %v", err)
|
||||
errs = append(errs, err)
|
||||
} else {
|
||||
go readPackets(ctx, c.logf, u6, c.ReceiveSTUNPacket)
|
||||
}
|
||||
|
||||
c.SendPacket = func(pkt []byte, dst netip.AddrPort) (int, error) {
|
||||
pc := u4
|
||||
if dst.Addr().Is6() {
|
||||
pc = u6
|
||||
}
|
||||
if pc == nil {
|
||||
return 0, errors.New("no UDP socket")
|
||||
}
|
||||
|
||||
return pc.WriteToUDPAddrPort(pkt, dst)
|
||||
}
|
||||
|
||||
// If both v4 and v6 failed, report an error, otherwise let one succeed.
|
||||
if len(errs) == 2 {
|
||||
return multierr.New(errs...)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// readPackets reads STUN packets from pc until there's an error or ctx is done.
|
||||
// In either case, it closes pc.
|
||||
func readPackets(ctx context.Context, logf logger.Logf, pc nettype.PacketConn, recv func([]byte, netip.AddrPort)) {
|
||||
done := make(chan struct{})
|
||||
defer close(done)
|
||||
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-done:
|
||||
}
|
||||
pc.Close()
|
||||
}()
|
||||
|
||||
var buf [64 << 10]byte
|
||||
for {
|
||||
n, addr, err := pc.ReadFromUDPAddrPort(buf[:])
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
logf("ReadFrom: %v", err)
|
||||
return
|
||||
}
|
||||
pkt := buf[:n]
|
||||
if !stun.Is(pkt) {
|
||||
continue
|
||||
}
|
||||
if ap := netaddr.Unmap(addr); ap.IsValid() {
|
||||
recv(pkt, ap)
|
||||
}
|
||||
}
|
||||
}
|
||||
93
net/netutil/routes.go
Normal file
93
net/netutil/routes.go
Normal file
@@ -0,0 +1,93 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package netutil
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"tailscale.com/net/tsaddr"
|
||||
)
|
||||
|
||||
var (
|
||||
ipv4default = netip.MustParsePrefix("0.0.0.0/0")
|
||||
ipv6default = netip.MustParsePrefix("::/0")
|
||||
)
|
||||
|
||||
func validateViaPrefix(ipp netip.Prefix) error {
|
||||
if !tsaddr.IsViaPrefix(ipp) {
|
||||
return fmt.Errorf("%v is not a 4-in-6 prefix", ipp)
|
||||
}
|
||||
if ipp.Bits() < (128 - 32) {
|
||||
return fmt.Errorf("%v 4-in-6 prefix must be at least a /%v", ipp, 128-32)
|
||||
}
|
||||
a := ipp.Addr().As16()
|
||||
// The first 64 bits of a are the via prefix.
|
||||
// The next 32 bits are the "site ID".
|
||||
// The last 32 bits are the IPv4.
|
||||
// For now, we reserve the top 3 bytes of the site ID,
|
||||
// and only allow users to use site IDs 0-255.
|
||||
siteID := binary.BigEndian.Uint32(a[8:12])
|
||||
if siteID > 0xFF {
|
||||
return fmt.Errorf("route %v contains invalid site ID %08x; must be 0xff or less", ipp, siteID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CalcAdvertiseRoutes calculates the requested routes to be advertised by a node.
|
||||
// advertiseRoutes is the user-provided, comma-separated list of routes (IP addresses or CIDR prefixes) to advertise.
|
||||
// advertiseDefaultRoute indicates whether the node should act as an exit node and advertise default routes.
|
||||
func CalcAdvertiseRoutes(advertiseRoutes string, advertiseDefaultRoute bool) ([]netip.Prefix, error) {
|
||||
routeMap := map[netip.Prefix]bool{}
|
||||
if advertiseRoutes != "" {
|
||||
var default4, default6 bool
|
||||
advroutes := strings.Split(advertiseRoutes, ",")
|
||||
for _, s := range advroutes {
|
||||
ipp, err := netip.ParsePrefix(s)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%q is not a valid IP address or CIDR prefix", s)
|
||||
}
|
||||
if ipp != ipp.Masked() {
|
||||
return nil, fmt.Errorf("%s has non-address bits set; expected %s", ipp, ipp.Masked())
|
||||
}
|
||||
if tsaddr.IsViaPrefix(ipp) {
|
||||
if err := validateViaPrefix(ipp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if ipp == ipv4default {
|
||||
default4 = true
|
||||
} else if ipp == ipv6default {
|
||||
default6 = true
|
||||
}
|
||||
routeMap[ipp] = true
|
||||
}
|
||||
if default4 && !default6 {
|
||||
return nil, fmt.Errorf("%s advertised without its IPv6 counterpart, please also advertise %s", ipv4default, ipv6default)
|
||||
} else if default6 && !default4 {
|
||||
return nil, fmt.Errorf("%s advertised without its IPv4 counterpart, please also advertise %s", ipv6default, ipv4default)
|
||||
}
|
||||
}
|
||||
if advertiseDefaultRoute {
|
||||
routeMap[netip.MustParsePrefix("0.0.0.0/0")] = true
|
||||
routeMap[netip.MustParsePrefix("::/0")] = true
|
||||
}
|
||||
if len(routeMap) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
routes := make([]netip.Prefix, 0, len(routeMap))
|
||||
for r := range routeMap {
|
||||
routes = append(routes, r)
|
||||
}
|
||||
sort.Slice(routes, func(i, j int) bool {
|
||||
if routes[i].Bits() != routes[j].Bits() {
|
||||
return routes[i].Bits() < routes[j].Bits()
|
||||
}
|
||||
return routes[i].Addr().Less(routes[j].Addr())
|
||||
})
|
||||
return routes, nil
|
||||
}
|
||||
@@ -203,6 +203,8 @@ func (d *Dialer) SetNetMap(nm *netmap.NetworkMap) {
|
||||
d.dns = m
|
||||
}
|
||||
|
||||
// userDialResolve resolves addr as if a user initiating the dial. (e.g. from a
|
||||
// SOCKS or HTTP outbound proxy)
|
||||
func (d *Dialer) userDialResolve(ctx context.Context, network, addr string) (netip.AddrPort, error) {
|
||||
d.mu.Lock()
|
||||
dns := d.dns
|
||||
@@ -298,8 +300,8 @@ func (d *Dialer) SystemDial(ctx context.Context, network, addr string) (net.Conn
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UserDial connects to the provided network address as if a user were initiating the dial.
|
||||
// (e.g. from a SOCKS or HTTP outbound proxy)
|
||||
// UserDial connects to the provided network address as if a user were
|
||||
// initiating the dial. (e.g. from a SOCKS or HTTP outbound proxy)
|
||||
func (d *Dialer) UserDial(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
ipp, err := d.userDialResolve(ctx, network, addr)
|
||||
if err != nil {
|
||||
|
||||
@@ -159,8 +159,10 @@ main() {
|
||||
PACKAGETYPE="apt"
|
||||
if [ "$VERSION_ID" -lt 20 ]; then
|
||||
APT_KEY_TYPE="legacy"
|
||||
VERSION="buster"
|
||||
else
|
||||
APT_KEY_TYPE="keyring"
|
||||
VERSION="bullseye"
|
||||
fi
|
||||
;;
|
||||
centos)
|
||||
|
||||
@@ -59,12 +59,14 @@ func userLookup(username string) (*userMeta, error) {
|
||||
if distro.Get() == distro.Gokrazy {
|
||||
um, err := userLookupStd(username)
|
||||
if err != nil {
|
||||
um.User = user.User{
|
||||
Uid: "0",
|
||||
Gid: "0",
|
||||
Username: "root",
|
||||
Name: "Gokrazy",
|
||||
HomeDir: "/",
|
||||
um = &userMeta{
|
||||
User: user.User{
|
||||
Uid: "0",
|
||||
Gid: "0",
|
||||
Username: "root",
|
||||
Name: "Gokrazy",
|
||||
HomeDir: "/",
|
||||
},
|
||||
}
|
||||
}
|
||||
um.loginShellCached = "/tmp/serial-busybox/ash"
|
||||
|
||||
@@ -105,7 +105,8 @@ type CapabilityVersion int
|
||||
// - 65: 2023-07-12: Client understands DERPMap.HomeParams + incremental DERPMap updates with params
|
||||
// - 66: 2023-07-23: UserProfile.Groups added (available via WhoIs)
|
||||
// - 67: 2023-07-25: Client understands PeerCapMap
|
||||
const CurrentCapabilityVersion CapabilityVersion = 67
|
||||
// - 68: 2023-08-09: Client has dedicated updateRoutine; MapRequest.Stream true means ignore Hostinfo+Endpoints
|
||||
const CurrentCapabilityVersion CapabilityVersion = 68
|
||||
|
||||
type StableID string
|
||||
|
||||
@@ -733,6 +734,11 @@ type NetInfo struct {
|
||||
// the control plane.
|
||||
DERPLatency map[string]float64 `json:",omitempty"`
|
||||
|
||||
// FirewallMode is the current firewall utility in use by router (iptables, nftables).
|
||||
// FirewallMode ipt means iptables, nft means nftables. When it's empty user is not using
|
||||
// our netfilter runners to manage firewall rules.
|
||||
FirewallMode string `json:",omitempty"`
|
||||
|
||||
// Update BasicallyEqual when adding fields.
|
||||
}
|
||||
|
||||
@@ -740,10 +746,10 @@ func (ni *NetInfo) String() string {
|
||||
if ni == nil {
|
||||
return "NetInfo(nil)"
|
||||
}
|
||||
return fmt.Sprintf("NetInfo{varies=%v hairpin=%v ipv6=%v ipv6os=%v udp=%v icmpv4=%v derp=#%v portmap=%v link=%q}",
|
||||
return fmt.Sprintf("NetInfo{varies=%v hairpin=%v ipv6=%v ipv6os=%v udp=%v icmpv4=%v derp=#%v portmap=%v link=%q firewallmode=%q}",
|
||||
ni.MappingVariesByDestIP, ni.HairPinning, ni.WorkingIPv6,
|
||||
ni.OSHasIPv6, ni.WorkingUDP, ni.WorkingICMPv4,
|
||||
ni.PreferredDERP, ni.portMapSummary(), ni.LinkType)
|
||||
ni.PreferredDERP, ni.portMapSummary(), ni.LinkType, ni.FirewallMode)
|
||||
}
|
||||
|
||||
func (ni *NetInfo) portMapSummary() string {
|
||||
@@ -791,7 +797,8 @@ func (ni *NetInfo) BasicallyEqual(ni2 *NetInfo) bool {
|
||||
ni.PMP == ni2.PMP &&
|
||||
ni.PCP == ni2.PCP &&
|
||||
ni.PreferredDERP == ni2.PreferredDERP &&
|
||||
ni.LinkType == ni2.LinkType
|
||||
ni.LinkType == ni2.LinkType &&
|
||||
ni.FirewallMode == ni2.FirewallMode
|
||||
}
|
||||
|
||||
// Equal reports whether h and h2 are equal.
|
||||
@@ -1082,8 +1089,21 @@ type MapRequest struct {
|
||||
NodeKey key.NodePublic
|
||||
DiscoKey key.DiscoPublic
|
||||
IncludeIPv6 bool `json:",omitempty"` // include IPv6 endpoints in returned Node Endpoints (for Version 4 clients)
|
||||
Stream bool // if true, multiple MapResponse objects are returned
|
||||
Hostinfo *Hostinfo
|
||||
|
||||
// Stream is whether the client wants to receive multiple MapResponses over
|
||||
// the same HTTP connection.
|
||||
//
|
||||
// If false, the server will send a single MapResponse and then close the
|
||||
// connection.
|
||||
//
|
||||
// If true and Version >= 68, the server should treat this as a read-only
|
||||
// request and ignore any Hostinfo or other fields that might be set.
|
||||
Stream bool
|
||||
|
||||
// Hostinfo is the client's current Hostinfo. Although it is always included
|
||||
// in the request, the server may choose to ignore it when Stream is true
|
||||
// and Version >= 68.
|
||||
Hostinfo *Hostinfo
|
||||
|
||||
// MapSessionHandle, if non-empty, is a request to reattach to a previous
|
||||
// map session after a previous map session was interrupted for whatever
|
||||
@@ -1105,6 +1125,7 @@ type MapRequest struct {
|
||||
MapSessionSeq int64 `json:",omitempty"`
|
||||
|
||||
// Endpoints are the client's magicsock UDP ip:port endpoints (IPv4 or IPv6).
|
||||
// These can be ignored if Stream is true and Version >= 68.
|
||||
Endpoints []string
|
||||
// EndpointTypes are the types of the corresponding endpoints in Endpoints.
|
||||
EndpointTypes []EndpointType `json:",omitempty"`
|
||||
@@ -1114,13 +1135,12 @@ type MapRequest struct {
|
||||
// It is encoded as tka.AUMHash.MarshalText.
|
||||
TKAHead string `json:",omitempty"`
|
||||
|
||||
// ReadOnly is whether the client just wants to fetch the
|
||||
// MapResponse, without updating their Endpoints. The
|
||||
// Endpoints field will be ignored and LastSeen will not be
|
||||
// updated and peers will not be notified of changes.
|
||||
// ReadOnly was set when client just wanted to fetch the MapResponse,
|
||||
// without updating their Endpoints. The intended use was for clients to
|
||||
// discover the DERP map at start-up before their first real endpoint
|
||||
// update.
|
||||
//
|
||||
// The intended use is for clients to discover the DERP map at
|
||||
// start-up before their first real endpoint update.
|
||||
// Deprecated: always false as of Version 68.
|
||||
ReadOnly bool `json:",omitempty"`
|
||||
|
||||
// OmitPeers is whether the client is okay with the Peers list being omitted
|
||||
@@ -1383,6 +1403,8 @@ type DNSConfig struct {
|
||||
//
|
||||
// Matches are case insensitive.
|
||||
ExitNodeFilteredSet []string `json:",omitempty"`
|
||||
// DNSFilterURL contains a user inputed URL that should have a list of domains to be blocked
|
||||
DNSFilterURL string `json:",omitempty"`
|
||||
}
|
||||
|
||||
// DNSRecord is an extra DNS record to add to MagicDNS.
|
||||
@@ -1964,10 +1986,11 @@ const (
|
||||
// Funnel warning capabilities used for reporting errors to the user.
|
||||
|
||||
// CapabilityWarnFunnelNoInvite indicates whether Funnel is enabled for the tailnet.
|
||||
// NOTE: In transition from Alpha to Beta, this capability is being reused as the enablement.
|
||||
// This cap is no longer used 2023-08-09 onwards.
|
||||
CapabilityWarnFunnelNoInvite = "https://tailscale.com/cap/warn-funnel-no-invite"
|
||||
|
||||
// CapabilityWarnFunnelNoHTTPS indicates HTTPS has not been enabled for the tailnet.
|
||||
// This cap is no longer used 2023-08-09 onwards.
|
||||
CapabilityWarnFunnelNoHTTPS = "https://tailscale.com/cap/warn-funnel-no-https"
|
||||
|
||||
// Debug logging capabilities
|
||||
@@ -2270,6 +2293,7 @@ type QueryFeatureRequest struct {
|
||||
}
|
||||
|
||||
// QueryFeatureResponse is the response to an QueryFeatureRequest.
|
||||
// See cli.enableFeatureInteractive for usage.
|
||||
type QueryFeatureResponse struct {
|
||||
// Complete is true when the feature is already enabled.
|
||||
Complete bool `json:",omitempty"`
|
||||
@@ -2287,14 +2311,18 @@ type QueryFeatureResponse struct {
|
||||
// When empty, there is no action for this user to take.
|
||||
URL string `json:",omitempty"`
|
||||
|
||||
// WaitOn specifies the self node capability required to use
|
||||
// the feature. The CLI can watch for changes to the presence,
|
||||
// of this capability, and once included, can proceed with
|
||||
// using the feature.
|
||||
// ShouldWait specifies whether the CLI should block and
|
||||
// wait for the user to enable the feature.
|
||||
//
|
||||
// If WaitOn is empty, the user does not have an action that
|
||||
// the CLI should block on.
|
||||
WaitOn string `json:",omitempty"`
|
||||
// If this is true, the enablement from the control server
|
||||
// is expected to be a quick and uninterrupted process for
|
||||
// the user, and blocking allows them to immediately start
|
||||
// using the feature once enabled without rerunning the
|
||||
// command (e.g. no need to re-run "funnel on").
|
||||
//
|
||||
// The CLI can watch the IPN notification bus for changes in
|
||||
// required node capabilities to know when to continue.
|
||||
ShouldWait bool `json:",omitempty"`
|
||||
}
|
||||
|
||||
// OverTLSPublicKeyResponse is the JSON response to /key?v=<n>
|
||||
|
||||
@@ -195,6 +195,7 @@ var _NetInfoCloneNeedsRegeneration = NetInfo(struct {
|
||||
PreferredDERP int
|
||||
LinkType string
|
||||
DERPLatency map[string]float64
|
||||
FirewallMode string
|
||||
}{})
|
||||
|
||||
// Clone makes a deep copy of Login.
|
||||
@@ -260,6 +261,7 @@ var _DNSConfigCloneNeedsRegeneration = DNSConfig(struct {
|
||||
CertDomains []string
|
||||
ExtraRecords []DNSRecord
|
||||
ExitNodeFilteredSet []string
|
||||
DNSFilterURL string
|
||||
}{})
|
||||
|
||||
// Clone makes a deep copy of RegisterResponse.
|
||||
|
||||
@@ -571,6 +571,7 @@ func TestNetInfoFields(t *testing.T) {
|
||||
"PreferredDERP",
|
||||
"LinkType",
|
||||
"DERPLatency",
|
||||
"FirewallMode",
|
||||
}
|
||||
if have := fieldsOf(reflect.TypeOf(NetInfo{})); !reflect.DeepEqual(have, handled) {
|
||||
t.Errorf("NetInfo.Clone/BasicallyEqually check might be out of sync\nfields: %q\nhandled: %q\n",
|
||||
|
||||
@@ -408,6 +408,7 @@ func (v NetInfoView) PreferredDERP() int { return v.ж.PreferredDER
|
||||
func (v NetInfoView) LinkType() string { return v.ж.LinkType }
|
||||
|
||||
func (v NetInfoView) DERPLatency() views.Map[string, float64] { return views.MapOf(v.ж.DERPLatency) }
|
||||
func (v NetInfoView) FirewallMode() string { return v.ж.FirewallMode }
|
||||
func (v NetInfoView) String() string { return v.ж.String() }
|
||||
|
||||
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
|
||||
@@ -425,6 +426,7 @@ var _NetInfoViewNeedsRegeneration = NetInfo(struct {
|
||||
PreferredDERP int
|
||||
LinkType string
|
||||
DERPLatency map[string]float64
|
||||
FirewallMode string
|
||||
}{})
|
||||
|
||||
// View returns a readonly view of Login.
|
||||
@@ -555,6 +557,7 @@ func (v DNSConfigView) ExtraRecords() views.Slice[DNSRecord] { return views.Slic
|
||||
func (v DNSConfigView) ExitNodeFilteredSet() views.Slice[string] {
|
||||
return views.SliceOf(v.ж.ExitNodeFilteredSet)
|
||||
}
|
||||
func (v DNSConfigView) DNSFilterURL() string { return v.ж.DNSFilterURL }
|
||||
|
||||
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
|
||||
var _DNSConfigViewNeedsRegeneration = DNSConfig(struct {
|
||||
@@ -567,6 +570,7 @@ var _DNSConfigViewNeedsRegeneration = DNSConfig(struct {
|
||||
CertDomains []string
|
||||
ExtraRecords []DNSRecord
|
||||
ExitNodeFilteredSet []string
|
||||
DNSFilterURL string
|
||||
}{})
|
||||
|
||||
// View returns a readonly view of RegisterResponse.
|
||||
|
||||
45
tsnet/example/web-client/web-client.go
Normal file
45
tsnet/example/web-client/web-client.go
Normal file
@@ -0,0 +1,45 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// The web-client command demonstrates serving the Tailscale web client over tsnet.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"tailscale.com/client/web"
|
||||
"tailscale.com/tsnet"
|
||||
)
|
||||
|
||||
var (
|
||||
devMode = flag.Bool("dev", false, "run web client in dev mode")
|
||||
)
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
s := new(tsnet.Server)
|
||||
defer s.Close()
|
||||
|
||||
ln, err := s.Listen("tcp", ":80")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
|
||||
lc, err := s.LocalClient()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Serve the Tailscale web client.
|
||||
ws, cleanup := web.NewServer(*devMode, lc)
|
||||
defer cleanup()
|
||||
if err := http.Serve(ln, ws); err != nil {
|
||||
if err != http.ErrServerClosed {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user