Compare commits

..

1 Commits

Author SHA1 Message Date
Denton Gentry
2a9a470a80 Test commit
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2023-10-22 08:31:43 -07:00
132 changed files with 2473 additions and 6364 deletions

View File

@@ -1,28 +0,0 @@
name: checklocks
on:
push:
branches:
- main
pull_request:
paths:
- '**/*.go'
- '.github/workflows/checklocks.yml'
concurrency:
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
checklocks:
runs-on: [ ubuntu-latest ]
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Build checklocks
run: ./tool/go build -o /tmp/checklocks gvisor.dev/gvisor/tools/checklocks/cmd/checklocks
- name: Run checklocks vet
# TODO: remove || true once we have applied checklocks annotations everywhere.
run: ./tool/go vet -vettool=/tmp/checklocks ./... || true

30
.github/workflows/coverage.yml vendored Normal file
View File

@@ -0,0 +1,30 @@
name: Code Coverage
on:
push:
branches:
- main
pull_request:
branches:
- '*'
jobs:
build:
runs-on: ubuntu-latest
if: "!contains(github.event.head_commit.message, '[ci skip]')"
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version-file: go.mod
- name: build all
run: ./tool/go install ./cmd/...
- name: Run tests on linux with coverage data
run: ./tool/go test -race -covermode atomic -coverprofile=covprofile ./...

View File

@@ -1,24 +0,0 @@
name: "Kubernetes manifests"
on:
pull_request:
paths:
- './cmd/k8s-operator/'
- '.github/workflows/kubemanifests.yaml'
# Cancel workflow run if there is a newer push to the same PR for which it is
# running
concurrency:
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
testchart:
runs-on: [ ubuntu-latest ]
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Build and lint Helm chart
run: |
eval `./tool/go run ./cmd/mkversion`
./tool/helm package --app-version="${VERSION_SHORT}" --version=${VERSION_SHORT} './cmd/k8s-operator/deploy/chart'
./tool/helm lint "tailscale-operator-${VERSION_SHORT}.tgz"

View File

@@ -22,7 +22,8 @@ on:
- "main"
- "release-branch/*"
pull_request:
# all PRs on all branches
branches:
- "*"
merge_group:
branches:
- "main"

View File

@@ -1 +1 @@
1.53.0
1.51.0

View File

@@ -1,7 +1,8 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package main
// Package appc implements App Connectors.
package appc
import (
"expvar"
@@ -30,7 +31,7 @@ type target struct {
Matching tailcfg.ProtoPortRange
}
// Server implements an App Connector as expressed in sniproxy.
// Server implements an App Connector.
type Server struct {
mu sync.RWMutex // mu guards following fields
connectors map[appctype.ConfigID]connector
@@ -66,7 +67,6 @@ func (s *Server) Configure(cfg *appctype.AppConnectorConfig) {
s.mu.Lock()
defer s.mu.Unlock()
s.connectors = makeConnectorsFromConfig(cfg)
log.Printf("installed app connector config: %+v", s.connectors)
}
// HandleTCPFlow implements tsnet.FallbackTCPHandler.
@@ -193,7 +193,8 @@ func (c *connector) handleDNS(req *dnsmessage.Message, localAddr netip.Addr) (re
}
func makeDNSResponse(req *dnsmessage.Message, reachableIPs []netip.Addr) (response []byte, err error) {
resp := dnsmessage.NewBuilder(response,
buf := make([]byte, 1500)
resp := dnsmessage.NewBuilder(buf,
dnsmessage.Header{
ID: req.Header.ID,
Response: true,
@@ -202,8 +203,8 @@ func makeDNSResponse(req *dnsmessage.Message, reachableIPs []netip.Addr) (respon
resp.EnableCompression()
if len(req.Questions) == 0 {
response, _ = resp.Finish()
return response, nil
buf, _ = resp.Finish()
return buf, nil
}
q := req.Questions[0]
err = resp.StartQuestions()

View File

@@ -1,7 +1,7 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package main
package appc
import (
"net/netip"

View File

@@ -1,174 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package appc implements App Connectors.
// An AppConnector provides DNS domain oriented routing of traffic. An App
// Connector becomes a DNS server for a peer, authoritative for the set of
// configured domains. DNS resolution of the target domain triggers dynamic
// publication of routes to ensure that traffic to the domain is routed through
// the App Connector.
package appc
import (
"net/netip"
"slices"
"strings"
"sync"
xmaps "golang.org/x/exp/maps"
"golang.org/x/net/dns/dnsmessage"
"tailscale.com/types/logger"
"tailscale.com/types/views"
)
// RouteAdvertiser is an interface that allows the AppConnector to advertise
// newly discovered routes that need to be served through the AppConnector.
type RouteAdvertiser interface {
// AdvertiseRoute adds a new route advertisement if the route is not already
// being advertised.
AdvertiseRoute(netip.Prefix) error
}
// AppConnector is an implementation of an AppConnector that performs
// its function as a subsystem inside of a tailscale node. At the control plane
// side App Connector routing is configured in terms of domains rather than IP
// addresses.
// The AppConnectors responsibility inside tailscaled is to apply the routing
// and domain configuration as supplied in the map response.
// DNS requests for configured domains are observed. If the domains resolve to
// routes not yet served by the AppConnector the local node configuration is
// updated to advertise the new route.
type AppConnector struct {
logf logger.Logf
routeAdvertiser RouteAdvertiser
// mu guards the fields that follow
mu sync.Mutex
// domains is a map of lower case domain names with no trailing dot, to a
// list of resolved IP addresses.
domains map[string][]netip.Addr
}
// NewAppConnector creates a new AppConnector.
func NewAppConnector(logf logger.Logf, routeAdvertiser RouteAdvertiser) *AppConnector {
return &AppConnector{
logf: logger.WithPrefix(logf, "appc: "),
routeAdvertiser: routeAdvertiser,
}
}
// UpdateDomains replaces the current set of configured domains with the
// supplied set of domains. Domains must not contain a trailing dot, and should
// be lower case.
func (e *AppConnector) UpdateDomains(domains []string) {
e.mu.Lock()
defer e.mu.Unlock()
var old map[string][]netip.Addr
old, e.domains = e.domains, make(map[string][]netip.Addr, len(domains))
for _, d := range domains {
d = strings.ToLower(d)
e.domains[d] = old[d]
}
e.logf("handling domains: %v", xmaps.Keys(e.domains))
}
// Domains returns the currently configured domain list.
func (e *AppConnector) Domains() views.Slice[string] {
e.mu.Lock()
defer e.mu.Unlock()
return views.SliceOf(xmaps.Keys(e.domains))
}
// ObserveDNSResponse is a callback invoked by the DNS resolver when a DNS
// response is being returned over the PeerAPI. The response is parsed and
// matched against the configured domains, if matched the routeAdvertiser is
// advised to advertise the discovered route.
func (e *AppConnector) ObserveDNSResponse(res []byte) {
var p dnsmessage.Parser
if _, err := p.Start(res); err != nil {
return
}
if err := p.SkipAllQuestions(); err != nil {
return
}
for {
h, err := p.AnswerHeader()
if err == dnsmessage.ErrSectionDone {
break
}
if err != nil {
return
}
if h.Class != dnsmessage.ClassINET {
if err := p.SkipAnswer(); err != nil {
return
}
continue
}
if h.Type != dnsmessage.TypeA && h.Type != dnsmessage.TypeAAAA {
if err := p.SkipAnswer(); err != nil {
return
}
continue
}
domain := h.Name.String()
if len(domain) == 0 {
return
}
if domain[len(domain)-1] == '.' {
domain = domain[:len(domain)-1]
}
domain = strings.ToLower(domain)
e.logf("[v2] observed DNS response for %s", domain)
e.mu.Lock()
addrs, ok := e.domains[domain]
e.mu.Unlock()
if !ok {
if err := p.SkipAnswer(); err != nil {
return
}
continue
}
var addr netip.Addr
switch h.Type {
case dnsmessage.TypeA:
r, err := p.AResource()
if err != nil {
return
}
addr = netip.AddrFrom4(r.A)
case dnsmessage.TypeAAAA:
r, err := p.AAAAResource()
if err != nil {
return
}
addr = netip.AddrFrom16(r.AAAA)
default:
if err := p.SkipAnswer(); err != nil {
return
}
continue
}
if slices.Contains(addrs, addr) {
continue
}
// TODO(raggi): check for existing prefixes
if err := e.routeAdvertiser.AdvertiseRoute(netip.PrefixFrom(addr, addr.BitLen())); err != nil {
e.logf("failed to advertise route for %v: %v", addr, err)
continue
}
e.logf("[v2] advertised route for %v: %v", domain, addr)
e.mu.Lock()
e.domains[domain] = append(addrs, addr)
e.mu.Unlock()
}
}

View File

@@ -1,118 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package appc
import (
"net/netip"
"slices"
"testing"
xmaps "golang.org/x/exp/maps"
"golang.org/x/net/dns/dnsmessage"
"tailscale.com/util/must"
)
func TestUpdateDomains(t *testing.T) {
a := NewAppConnector(t.Logf, nil)
a.UpdateDomains([]string{"example.com"})
if got, want := a.Domains().AsSlice(), []string{"example.com"}; !slices.Equal(got, want) {
t.Errorf("got %v; want %v", got, want)
}
addr := netip.MustParseAddr("192.0.0.8")
a.domains["example.com"] = append(a.domains["example.com"], addr)
a.UpdateDomains([]string{"example.com"})
if got, want := a.domains["example.com"], []netip.Addr{addr}; !slices.Equal(got, want) {
t.Errorf("got %v; want %v", got, want)
}
// domains are explicitly downcased on set.
a.UpdateDomains([]string{"UP.EXAMPLE.COM"})
if got, want := xmaps.Keys(a.domains), []string{"up.example.com"}; !slices.Equal(got, want) {
t.Errorf("got %v; want %v", got, want)
}
}
func TestObserveDNSResponse(t *testing.T) {
rc := &routeCollector{}
a := NewAppConnector(t.Logf, rc)
// a has no domains configured, so it should not advertise any routes
a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8"))
if got, want := rc.routes, ([]netip.Prefix)(nil); !slices.Equal(got, want) {
t.Errorf("got %v; want %v", got, want)
}
wantRoutes := []netip.Prefix{netip.MustParsePrefix("192.0.0.8/32")}
a.UpdateDomains([]string{"example.com"})
a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8"))
if got, want := rc.routes, wantRoutes; !slices.Equal(got, want) {
t.Errorf("got %v; want %v", got, want)
}
wantRoutes = append(wantRoutes, netip.MustParsePrefix("2001:db8::1/128"))
a.ObserveDNSResponse(dnsResponse("example.com.", "2001:db8::1"))
if got, want := rc.routes, wantRoutes; !slices.Equal(got, want) {
t.Errorf("got %v; want %v", got, want)
}
// don't re-advertise routes that have already been advertised
a.ObserveDNSResponse(dnsResponse("example.com.", "2001:db8::1"))
if !slices.Equal(rc.routes, wantRoutes) {
t.Errorf("got %v; want %v", rc.routes, wantRoutes)
}
}
// dnsResponse is a test helper that creates a DNS response buffer for the given domain and address
func dnsResponse(domain, address string) []byte {
addr := netip.MustParseAddr(address)
b := dnsmessage.NewBuilder(nil, dnsmessage.Header{})
b.EnableCompression()
b.StartAnswers()
switch addr.BitLen() {
case 32:
b.AResource(
dnsmessage.ResourceHeader{
Name: dnsmessage.MustNewName(domain),
Type: dnsmessage.TypeA,
Class: dnsmessage.ClassINET,
TTL: 0,
},
dnsmessage.AResource{
A: addr.As4(),
},
)
case 128:
b.AAAAResource(
dnsmessage.ResourceHeader{
Name: dnsmessage.MustNewName(domain),
Type: dnsmessage.TypeAAAA,
Class: dnsmessage.ClassINET,
TTL: 0,
},
dnsmessage.AAAAResource{
AAAA: addr.As16(),
},
)
default:
panic("invalid address length")
}
return must.Get(b.Finish())
}
// routeCollector is a test helper that collects the list of routes advertised
type routeCollector struct {
routes []netip.Prefix
}
// routeCollector implements RouteAdvertiser
var _ RouteAdvertiser = (*routeCollector)(nil)
func (rc *routeCollector) AdvertiseRoute(pfx netip.Prefix) error {
rc.routes = append(rc.routes, pfx)
return nil
}

View File

@@ -1,7 +1,7 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package main
package appc
import (
"context"

View File

@@ -1,7 +1,7 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package main
package appc
import (
"bytes"

View File

@@ -1254,6 +1254,9 @@ func (lc *LocalClient) ReloadConfig(ctx context.Context) (ok bool, err error) {
if err != nil {
return
}
if err != nil {
return false, err
}
if res.Err != "" {
return false, errors.New(res.Err)
}

View File

@@ -1,248 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package web
import (
"bytes"
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"time"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/tailcfg"
)
const (
sessionCookieName = "TS-Web-Session"
sessionCookieExpiry = time.Hour * 24 * 30 // 30 days
)
// browserSession holds data about a user's browser session
// on the full management web client.
type browserSession struct {
// ID is the unique identifier for the session.
// It is passed in the user's "TS-Web-Session" browser cookie.
ID string
SrcNode tailcfg.NodeID
SrcUser tailcfg.UserID
AuthID string // from tailcfg.WebClientAuthResponse
AuthURL string // from tailcfg.WebClientAuthResponse
Created time.Time
Authenticated bool
}
// isAuthorized reports true if the given session is authorized
// to be used by its associated user to access the full management
// web client.
//
// isAuthorized is true only when s.Authenticated is true (i.e.
// the user has authenticated the session) and the session is not
// expired.
// 2023-10-05: Sessions expire by default 30 days after creation.
func (s *browserSession) isAuthorized(now time.Time) bool {
switch {
case s == nil:
return false
case !s.Authenticated:
return false // awaiting auth
case s.isExpired(now):
return false // expired
}
return true
}
// isExpired reports true if s is expired.
// 2023-10-05: Sessions expire by default 30 days after creation.
func (s *browserSession) isExpired(now time.Time) bool {
return !s.Created.IsZero() && now.After(s.expires())
}
// expires reports when the given session expires.
func (s *browserSession) expires() time.Time {
return s.Created.Add(sessionCookieExpiry)
}
var (
errNoSession = errors.New("no-browser-session")
errNotUsingTailscale = errors.New("not-using-tailscale")
errTaggedRemoteSource = errors.New("tagged-remote-source")
errTaggedLocalSource = errors.New("tagged-local-source")
errNotOwner = errors.New("not-owner")
)
// getSession retrieves the browser session associated with the request,
// if one exists.
//
// An error is returned in any of the following cases:
//
// - (errNotUsingTailscale) The request was not made over tailscale.
//
// - (errNoSession) The request does not have a session.
//
// - (errTaggedRemoteSource) The source is remote (another node) and tagged.
// Users must use their own user-owned devices to manage other nodes'
// web clients.
//
// - (errTaggedLocalSource) The source is local (the same node) and tagged.
// Tagged nodes can only be remotely managed, allowing ACLs to dictate
// access to web clients.
//
// - (errNotOwner) The source is not the owner of this client (if the
// client is user-owned). Only the owner is allowed to manage the
// node via the web client.
//
// If no error is returned, the browserSession is always non-nil.
// getTailscaleBrowserSession does not check whether the session has been
// authorized by the user. Callers can use browserSession.isAuthorized.
//
// The WhoIsResponse is always populated, with a non-nil Node and UserProfile,
// unless getTailscaleBrowserSession reports errNotUsingTailscale.
func (s *Server) getSession(r *http.Request) (*browserSession, *apitype.WhoIsResponse, error) {
whoIs, whoIsErr := s.lc.WhoIs(r.Context(), r.RemoteAddr)
status, statusErr := s.lc.StatusWithoutPeers(r.Context())
switch {
case whoIsErr != nil:
return nil, nil, errNotUsingTailscale
case statusErr != nil:
return nil, whoIs, statusErr
case status.Self == nil:
return nil, whoIs, errors.New("missing self node in tailscale status")
case whoIs.Node.IsTagged() && whoIs.Node.StableID == status.Self.ID:
return nil, whoIs, errTaggedLocalSource
case whoIs.Node.IsTagged():
return nil, whoIs, errTaggedRemoteSource
case !status.Self.IsTagged() && status.Self.UserID != whoIs.UserProfile.ID:
return nil, whoIs, errNotOwner
}
srcNode := whoIs.Node.ID
srcUser := whoIs.UserProfile.ID
cookie, err := r.Cookie(sessionCookieName)
if errors.Is(err, http.ErrNoCookie) {
return nil, whoIs, errNoSession
} else if err != nil {
return nil, whoIs, err
}
v, ok := s.browserSessions.Load(cookie.Value)
if !ok {
return nil, whoIs, errNoSession
}
session := v.(*browserSession)
if session.SrcNode != srcNode || session.SrcUser != srcUser {
// In this case the browser cookie is associated with another tailscale node.
// Maybe the source browser's machine was logged out and then back in as a different node.
// Return errNoSession because there is no session for this user.
return nil, whoIs, errNoSession
} else if session.isExpired(s.timeNow()) {
// Session expired, remove from session map and return errNoSession.
s.browserSessions.Delete(session.ID)
return nil, whoIs, errNoSession
}
return session, whoIs, nil
}
// newSession creates a new session associated with the given source user/node,
// and stores it back to the session cache. Creating of a new session includes
// generating a new auth URL from the control server.
func (s *Server) newSession(ctx context.Context, src *apitype.WhoIsResponse) (*browserSession, error) {
d, err := s.getOrAwaitAuth(ctx, "", src.Node.ID)
if err != nil {
return nil, err
}
sid, err := s.newSessionID()
if err != nil {
return nil, err
}
session := &browserSession{
ID: sid,
SrcNode: src.Node.ID,
SrcUser: src.UserProfile.ID,
AuthID: d.ID,
AuthURL: d.URL,
Created: s.timeNow(),
}
s.browserSessions.Store(sid, session)
return session, nil
}
// awaitUserAuth blocks until the given session auth has been completed
// by the user on the control server, then updates the session cache upon
// completion. An error is returned if control auth failed for any reason.
func (s *Server) awaitUserAuth(ctx context.Context, session *browserSession) error {
if session.isAuthorized(s.timeNow()) {
return nil // already authorized
}
d, err := s.getOrAwaitAuth(ctx, session.AuthID, session.SrcNode)
if err != nil {
// Clean up the session. Doing this on any error from control
// server to avoid the user getting stuck with a bad session
// cookie.
s.browserSessions.Delete(session.ID)
return err
}
if d.Complete {
session.Authenticated = d.Complete
s.browserSessions.Store(session.ID, session)
}
return nil
}
// getOrAwaitAuth connects to the control server for user auth,
// with the following behavior:
//
// 1. If authID is provided empty, a new auth URL is created on the control
// server and reported back here, which can then be used to redirect the
// user on the frontend.
// 2. If authID is provided non-empty, the connection to control blocks until
// the user has completed authenticating the associated auth URL,
// or until ctx is canceled.
func (s *Server) getOrAwaitAuth(ctx context.Context, authID string, src tailcfg.NodeID) (*tailcfg.WebClientAuthResponse, error) {
type data struct {
ID string
Src tailcfg.NodeID
}
var b bytes.Buffer
if err := json.NewEncoder(&b).Encode(data{ID: authID, Src: src}); err != nil {
return nil, err
}
url := "http://" + apitype.LocalAPIHost + "/localapi/v0/debug-web-client"
req, err := http.NewRequestWithContext(ctx, "POST", url, &b)
if err != nil {
return nil, err
}
resp, err := s.lc.DoLocalRequest(req)
if err != nil {
return nil, err
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed request: %s", body)
}
var authResp *tailcfg.WebClientAuthResponse
if err := json.Unmarshal(body, &authResp); err != nil {
return nil, err
}
return authResp, nil
}
func (s *Server) newSessionID() (string, error) {
raw := make([]byte, 16)
for i := 0; i < 5; i++ {
if _, err := rand.Read(raw); err != nil {
return "", err
}
cookie := "ts-web-" + base64.RawURLEncoding.EncodeToString(raw)
if _, ok := s.browserSessions.Load(cookie); !ok {
return cookie, nil
}
}
return "", errors.New("too many collisions generating new session; please refresh page")
}

View File

@@ -18,7 +18,7 @@
"@types/react-dom": "^18.0.6",
"@vitejs/plugin-react-swc": "^3.3.2",
"autoprefixer": "^10.4.15",
"postcss": "^8.4.31",
"postcss": "^8.4.27",
"prettier": "^2.5.1",
"prettier-plugin-organize-imports": "^3.2.2",
"tailwindcss": "^3.3.3",

View File

@@ -9,7 +9,6 @@ package web
import (
"crypto/tls"
"encoding/xml"
"errors"
"fmt"
"io"
"log"
@@ -19,17 +18,21 @@ import (
// authorizeQNAP authenticates the logged-in QNAP user and verifies that they
// are authorized to use the web client.
// If the user is not authorized to use the client, an error is returned.
func authorizeQNAP(r *http.Request) (ar authResponse, err error) {
// It reports true if the request is authorized to continue, and false otherwise.
// authorizeQNAP manages writing out any relevant authorization errors to the
// ResponseWriter itself.
func authorizeQNAP(w http.ResponseWriter, r *http.Request) (ok bool) {
_, resp, err := qnapAuthn(r)
if err != nil {
return ar, err
http.Error(w, err.Error(), http.StatusUnauthorized)
return false
}
if resp.IsAdmin == 0 {
return ar, errors.New("user is not an admin")
http.Error(w, "user is not an admin", http.StatusForbidden)
return false
}
return authResponse{OK: true}, nil
return true
}
type qnapAuthResponse struct {

View File

@@ -1,5 +1,4 @@
let csrfToken: string
let synoToken: string | undefined // required for synology API requests
let unraidCsrfToken: string | undefined // required for unraid POST requests (#8062)
// apiFetch wraps the standard JS fetch function with csrf header
@@ -16,13 +15,9 @@ export function apiFetch(
): Promise<Response> {
const urlParams = new URLSearchParams(window.location.search)
const nextParams = new URLSearchParams(params)
if (synoToken) {
nextParams.set("SynoToken", synoToken)
} else {
const token = urlParams.get("SynoToken")
if (token) {
nextParams.set("SynoToken", token)
}
const token = urlParams.get("SynoToken")
if (token) {
nextParams.set("SynoToken", token)
}
const search = nextParams.toString()
const url = `api${endpoint}${search ? `?${search}` : ""}`
@@ -67,10 +62,6 @@ function updateCsrfToken(r: Response) {
}
}
export function setSynoToken(token?: string) {
synoToken = token
}
export function setUnraidCsrfToken(token?: string) {
unraidCsrfToken = token
}

View File

@@ -1,75 +1,156 @@
import cx from "classnames"
import React from "react"
import LegacyClientView from "src/components/views/legacy-client-view"
import LoginClientView from "src/components/views/login-client-view"
import ReadonlyClientView from "src/components/views/readonly-client-view"
import useAuth, { AuthResponse, SessionsCallbacks } from "src/hooks/auth"
import useNodeData from "src/hooks/node-data"
import ManagementClientView from "./views/management-client-view"
import { Footer, Header, IP, State } from "src/components/legacy"
import useAuth, { AuthResponse } from "src/hooks/auth"
import useNodeData, { NodeData } from "src/hooks/node-data"
import { ReactComponent as ConnectedDeviceIcon } from "src/icons/connected-device.svg"
import { ReactComponent as TailscaleIcon } from "src/icons/tailscale-icon.svg"
import { ReactComponent as TailscaleLogo } from "src/icons/tailscale-logo.svg"
export default function App() {
const { data: auth, loading: loadingAuth, sessions } = useAuth()
// TODO(sonia): use isPosting value from useNodeData
// to fill loading states.
const { data, refreshData, updateNode } = useNodeData()
return (
<div className="flex flex-col items-center min-w-sm max-w-lg mx-auto py-14">
{loadingAuth ? (
<div className="text-center py-14">Loading...</div> // TODO(sonia): add a loading view
) : (
<WebClient auth={auth} sessions={sessions} />
)}
if (!data) {
// TODO(sonia): add a loading view
return <div className="text-center py-14">Loading...</div>
}
const needsLogin = data?.Status === "NeedsLogin" || data?.Status === "NoState"
return !needsLogin &&
(data.DebugMode === "login" || data.DebugMode === "full") ? (
<WebClient {...data} />
) : (
// Legacy client UI
<div className="py-14">
<main className="container max-w-lg mx-auto mb-8 py-6 px-8 bg-white rounded-md shadow-2xl">
<Header data={data} refreshData={refreshData} updateNode={updateNode} />
<IP data={data} />
<State data={data} updateNode={updateNode} />
</main>
<Footer licensesURL={data.LicensesURL} />
</div>
)
}
function WebClient({
auth,
sessions,
}: {
auth?: AuthResponse
sessions: SessionsCallbacks
}) {
const { data, refreshData, updateNode } = useNodeData()
function WebClient(props: NodeData) {
const { data: auth, loading: loadingAuth, waitOnAuth } = useAuth()
if (loadingAuth) {
return <div className="text-center py-14">Loading...</div>
}
return (
<>
{!data ? (
<div className="text-center py-14">Loading...</div> // TODO(sonia): add a loading view
) : data?.Status === "NeedsLogin" || data?.Status === "NoState" ? (
// Client not on a tailnet, render login.
<LoginClientView
data={data}
onLoginClick={() => updateNode({ Reauthenticate: true })}
/>
) : data.DebugMode === "full" && auth?.ok ? (
// Render new client interface in management mode.
<ManagementClientView {...data} />
) : data.DebugMode === "login" || data.DebugMode === "full" ? (
// Render new client interface in readonly mode.
<ReadonlyClientView data={data} auth={auth} sessions={sessions} />
<div className="flex flex-col items-center min-w-sm max-w-lg mx-auto py-10">
{props.DebugMode === "full" && auth?.ok ? (
<ManagementView {...props} />
) : (
// Render legacy client interface.
<LegacyClientView
data={data}
refreshData={refreshData}
updateNode={updateNode}
/>
<ReadonlyView data={props} auth={auth} waitOnAuth={waitOnAuth} />
)}
{data && <Footer licensesURL={data.LicensesURL} />}
<Footer className="mt-20" licensesURL={props.LicensesURL} />
</div>
)
}
function ReadonlyView({
data,
auth,
waitOnAuth,
}: {
data: NodeData
auth?: AuthResponse
waitOnAuth: () => Promise<void>
}) {
return (
<>
<div className="pb-52 mx-auto">
<TailscaleLogo />
</div>
<div className="w-full p-4 bg-stone-50 rounded-3xl border border-gray-200 flex flex-col gap-4">
<div className="flex gap-2.5">
<ProfilePic url={data.Profile.ProfilePicURL} />
<div className="font-medium">
<div className="text-neutral-500 text-xs uppercase tracking-wide">
Owned by
</div>
<div className="text-neutral-800 text-sm leading-tight">
{/* TODO(sonia): support tagged node profile view more eloquently */}
{data.Profile.LoginName}
</div>
</div>
</div>
<div className="px-5 py-4 bg-white rounded-lg border border-gray-200 justify-between items-center flex">
<div className="flex gap-3">
<ConnectedDeviceIcon />
<div className="text-neutral-800">
<div className="text-lg font-medium leading-[25.20px]">
{data.DeviceName}
</div>
<div className="text-sm leading-tight">{data.IP}</div>
</div>
</div>
{data.DebugMode === "full" && (
<button
className="button button-blue ml-6"
onClick={() => {
window.open(auth?.authUrl, "_blank")
waitOnAuth()
}}
>
Access
</button>
)}
</div>
</div>
</>
)
}
export function Footer(props: { licensesURL: string; className?: string }) {
function ManagementView(props: NodeData) {
return (
<footer
className={cx("container max-w-lg mx-auto text-center", props.className)}
>
<a
className="text-xs text-gray-500 hover:text-gray-600"
href={props.licensesURL}
>
Open Source Licenses
</a>
</footer>
<div className="px-5">
<div className="flex justify-between mb-12">
<TailscaleIcon />
<div className="flex">
<p className="mr-2">{props.Profile.LoginName}</p>
{/* TODO(sonia): support tagged node profile view more eloquently */}
<ProfilePic url={props.Profile.ProfilePicURL} />
</div>
</div>
<p className="tracking-wide uppercase text-gray-600 pb-3">This device</p>
<div className="-mx-5 border rounded-md px-5 py-4 bg-white">
<div className="flex justify-between items-center text-lg">
<div className="flex items-center">
<ConnectedDeviceIcon />
<p className="font-medium ml-3">{props.DeviceName}</p>
</div>
<p className="tracking-widest">{props.IP}</p>
</div>
</div>
<p className="text-gray-500 pt-2">
Tailscale is up and running. You can connect to this device from devices
in your tailnet by using its name or IP address.
</p>
<button className="button button-blue mt-6">Advertise exit node</button>
</div>
)
}
function ProfilePic({ url }: { url: string }) {
return (
<div className="relative flex-shrink-0 w-8 h-8 rounded-full overflow-hidden">
{url ? (
<div
className="w-8 h-8 flex pointer-events-none rounded-full bg-gray-200"
style={{
backgroundImage: `url(${url})`,
backgroundSize: "cover",
}}
/>
) : (
<div className="w-8 h-8 flex pointer-events-none rounded-full border border-gray-400 border-dashed" />
)}
</div>
)
}

View File

@@ -8,52 +8,6 @@ import { NodeData, NodeUpdate } from "src/hooks/node-data"
// purely to ease migration to the new React-based web client, and will
// eventually be completely removed.
export default function LegacyClientView({
data,
refreshData,
updateNode,
}: {
data: NodeData
refreshData: () => void
updateNode: (update: NodeUpdate) => void
}) {
return (
<div className="container max-w-lg mx-auto mb-8 py-6 px-8 bg-white rounded-md shadow-2xl">
<Header data={data} refreshData={refreshData} updateNode={updateNode} />
<IP data={data} />
{data.Status === "NeedsMachineAuth" ? (
<div className="mb-4">
This device is authorized, but needs approval from a network admin
before it can connect to the network.
</div>
) : (
<>
<div className="mb-4">
<p>
You are connected! Access this device over Tailscale using the
device name or IP address above.
</p>
</div>
<button
className={cx("button button-medium mb-4", {
"button-red": data.AdvertiseExitNode,
"button-blue": !data.AdvertiseExitNode,
})}
id="enabled"
onClick={() =>
updateNode({ AdvertiseExitNode: !data.AdvertiseExitNode })
}
>
{data.AdvertiseExitNode
? "Stop advertising Exit Node"
: "Advertise as Exit Node"}
</button>
</>
)}
</div>
)
}
export function Header({
data,
refreshData,
@@ -230,3 +184,115 @@ export function IP(props: { data: NodeData }) {
</>
)
}
export function State({
data,
updateNode,
}: {
data: NodeData
updateNode: (update: NodeUpdate) => void
}) {
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>
<button
onClick={() => updateNode({ Reauthenticate: true })}
className="button button-blue w-full mb-4"
>
Reauthenticate
</button>
</>
)
} 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,&nbsp;learn&nbsp;more at{" "}
<a
href="https://tailscale.com/"
className="link"
target="_blank"
>
tailscale.com
</a>
.
</p>
</div>
<button
onClick={() => updateNode({ Reauthenticate: true })}
className="button button-blue w-full mb-4"
>
Log In
</button>
</>
)
}
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>
<button
className={cx("button button-medium mb-4", {
"button-red": data.AdvertiseExitNode,
"button-blue": !data.AdvertiseExitNode,
})}
id="enabled"
onClick={() =>
updateNode({ AdvertiseExitNode: !data.AdvertiseExitNode })
}
>
{data.AdvertiseExitNode
? "Stop advertising Exit Node"
: "Advertise as Exit Node"}
</button>
</>
)
}
}
export function Footer(props: { licensesURL: string; className?: string }) {
return (
<footer
className={cx("container max-w-lg mx-auto text-center", props.className)}
>
<a
className="text-xs text-gray-500 hover:text-gray-600"
href={props.licensesURL}
>
Open Source Licenses
</a>
</footer>
)
}

View File

@@ -1,65 +0,0 @@
import React from "react"
import { NodeData } from "src/hooks/node-data"
import { ReactComponent as TailscaleIcon } from "src/icons/tailscale-icon.svg"
/**
* LoginClientView is rendered when the client is not authenticated
* to a tailnet.
*/
export default function LoginClientView({
data,
onLoginClick,
}: {
data: NodeData
onLoginClick: () => void
}) {
return (
<div className="mb-8 py-6 px-8 bg-white rounded-md shadow-2xl">
<TailscaleIcon className="my-2 mb-8" />
{data.IP ? (
<>
<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>
<button
onClick={onLoginClick}
className="button button-blue w-full mb-4"
>
Reauthenticate
</button>
</>
) : (
<>
<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,&nbsp;learn&nbsp;more at{" "}
<a href="https://tailscale.com/" className="link" target="_blank">
tailscale.com
</a>
.
</p>
</div>
<button
onClick={onLoginClick}
className="button button-blue w-full mb-4"
>
Log In
</button>
</>
)}
</div>
)
}

View File

@@ -1,35 +0,0 @@
import React from "react"
import { NodeData } from "src/hooks/node-data"
import { ReactComponent as ConnectedDeviceIcon } from "src/icons/connected-device.svg"
import { ReactComponent as TailscaleIcon } from "src/icons/tailscale-icon.svg"
import ProfilePic from "src/ui/profile-pic"
export default function ManagementClientView(props: NodeData) {
return (
<div className="px-5 mb-12">
<div className="flex justify-between mb-12">
<TailscaleIcon />
<div className="flex">
<p className="mr-2">{props.Profile.LoginName}</p>
{/* TODO(sonia): support tagged node profile view more eloquently */}
<ProfilePic url={props.Profile.ProfilePicURL} />
</div>
</div>
<p className="tracking-wide uppercase text-gray-600 pb-3">This device</p>
<div className="-mx-5 border rounded-md px-5 py-4 bg-white">
<div className="flex justify-between items-center text-lg">
<div className="flex items-center">
<ConnectedDeviceIcon />
<p className="font-medium ml-3">{props.DeviceName}</p>
</div>
<p className="tracking-widest">{props.IP}</p>
</div>
</div>
<p className="text-gray-500 pt-2">
Tailscale is up and running. You can connect to this device from devices
in your tailnet by using its name or IP address.
</p>
<button className="button button-blue mt-6">Advertise exit node</button>
</div>
)
}

View File

@@ -1,71 +0,0 @@
import React from "react"
import { AuthResponse, AuthType, SessionsCallbacks } from "src/hooks/auth"
import { NodeData } from "src/hooks/node-data"
import { ReactComponent as ConnectedDeviceIcon } from "src/icons/connected-device.svg"
import { ReactComponent as TailscaleLogo } from "src/icons/tailscale-logo.svg"
import ProfilePic from "src/ui/profile-pic"
/**
* ReadonlyClientView is rendered when the web interface is either
*
* 1. being viewed by a user not allowed to manage the node
* (e.g. user does not own the node)
*
* 2. or the user is allowed to manage the node but does not
* yet have a valid browser session.
*/
export default function ReadonlyClientView({
data,
auth,
sessions,
}: {
data: NodeData
auth?: AuthResponse
sessions: SessionsCallbacks
}) {
return (
<>
<div className="pb-52 mx-auto">
<TailscaleLogo />
</div>
<div className="w-full p-4 bg-stone-50 rounded-3xl border border-gray-200 flex flex-col gap-4">
<div className="flex gap-2.5">
<ProfilePic url={data.Profile.ProfilePicURL} />
<div className="font-medium">
<div className="text-neutral-500 text-xs uppercase tracking-wide">
Managed by
</div>
<div className="text-neutral-800 text-sm leading-tight">
{/* TODO(sonia): support tagged node profile view more eloquently */}
{data.Profile.LoginName}
</div>
</div>
</div>
<div className="px-5 py-4 bg-white rounded-lg border border-gray-200 justify-between items-center flex">
<div className="flex gap-3">
<ConnectedDeviceIcon />
<div className="text-neutral-800">
<div className="text-lg font-medium leading-[25.20px]">
{data.DeviceName}
</div>
<div className="text-sm leading-tight">{data.IP}</div>
</div>
</div>
{auth?.authNeeded == AuthType.tailscale && (
<button
className="button button-blue ml-6"
onClick={() => {
sessions
.new()
.then((url) => window.open(url, "_blank"))
.then(() => sessions.wait())
}}
>
Access
</button>
)}
</div>
</div>
</>
)
}

View File

@@ -1,45 +1,25 @@
import { useCallback, useEffect, useState } from "react"
import { apiFetch, setSynoToken } from "src/api"
export enum AuthType {
synology = "synology",
tailscale = "tailscale",
}
import { apiFetch } from "src/api"
export type AuthResponse = {
ok: boolean
authNeeded?: AuthType
}
export type SessionsCallbacks = {
new: () => Promise<string> // creates new auth session and returns authURL
wait: () => Promise<void> // blocks until auth is completed
authUrl?: string
}
// useAuth reports and refreshes Tailscale auth status
// for the web client.
export default function useAuth() {
const [data, setData] = useState<AuthResponse>()
const [loading, setLoading] = useState<boolean>(true)
const [loading, setLoading] = useState<boolean>(false)
const loadAuth = useCallback(() => {
const loadAuth = useCallback((wait?: boolean) => {
const url = wait ? "/auth?wait=true" : "/auth"
setLoading(true)
return apiFetch("/auth", "GET")
return apiFetch(url, "GET")
.then((r) => r.json())
.then((d) => {
setLoading(false)
setData(d)
switch ((d as AuthResponse).authNeeded) {
case AuthType.synology:
fetch("/webman/login.cgi")
.then((r) => r.json())
.then((a) => {
setSynoToken(a.SynoToken)
setLoading(false)
})
break
default:
setLoading(false)
}
})
.catch((error) => {
setLoading(false)
@@ -47,33 +27,11 @@ export default function useAuth() {
})
}, [])
const newSession = useCallback(() => {
return apiFetch("/auth/session/new", "GET")
.then((r) => r.json())
.then((d) => d.authUrl)
.catch((error) => {
console.error(error)
})
}, [])
const waitForSessionCompletion = useCallback(() => {
return apiFetch("/auth/session/wait", "GET")
.then(() => loadAuth()) // refresh auth data
.catch((error) => {
console.error(error)
})
}, [])
useEffect(() => {
loadAuth()
}, [])
return {
data,
loading,
sessions: {
new: newSession,
wait: waitForSessionCompletion,
},
}
const waitOnAuth = useCallback(() => loadAuth(true), [])
return { data, loading, waitOnAuth }
}

View File

@@ -1,19 +0,0 @@
import React from "react"
export default function ProfilePic({ url }: { url: string }) {
return (
<div className="relative flex-shrink-0 w-8 h-8 rounded-full overflow-hidden">
{url ? (
<div
className="w-8 h-8 flex pointer-events-none rounded-full bg-gray-200"
style={{
backgroundImage: `url(${url})`,
backgroundSize: "cover",
}}
/>
) : (
<div className="w-8 h-8 flex pointer-events-none rounded-full border border-gray-400 border-dashed" />
)}
</div>
)
}

View File

@@ -7,7 +7,6 @@
package web
import (
"errors"
"fmt"
"net/http"
"os/exec"
@@ -18,44 +17,62 @@ import (
// authorizeSynology authenticates the logged-in Synology user and verifies
// that they are authorized to use the web client.
// The returned authResponse indicates if the user is authorized,
// and if additional steps are needed to authenticate the user.
// If the user is authenticated, but not authorized to use the client, an error is returned.
func authorizeSynology(r *http.Request) (resp authResponse, err error) {
if !hasSynoToken(r) {
return authResponse{OK: false, AuthNeeded: synoAuth}, nil
// It reports true if the request is authorized to continue, and false otherwise.
// authorizeSynology manages writing out any relevant authorization errors to the
// ResponseWriter itself.
func authorizeSynology(w http.ResponseWriter, r *http.Request) (ok bool) {
if synoTokenRedirect(w, r) {
return false
}
// authenticate the Synology user
cmd := exec.Command("/usr/syno/synoman/webman/modules/authenticate.cgi")
out, err := cmd.CombinedOutput()
if err != nil {
return resp, fmt.Errorf("auth: %v: %s", err, out)
http.Error(w, fmt.Sprintf("auth: %v: %s", err, out), http.StatusUnauthorized)
return false
}
user := strings.TrimSpace(string(out))
// check if the user is in the administrators group
isAdmin, err := groupmember.IsMemberOfGroup("administrators", user)
if err != nil {
return resp, err
http.Error(w, err.Error(), http.StatusForbidden)
return false
}
if !isAdmin {
return resp, errors.New("not a member of administrators group")
http.Error(w, "not a member of administrators group", http.StatusForbidden)
return false
}
return authResponse{OK: true}, nil
return true
}
// hasSynoToken returns true if the request include a SynoToken used for synology auth.
func hasSynoToken(r *http.Request) bool {
func synoTokenRedirect(w http.ResponseWriter, r *http.Request) bool {
if r.Header.Get("X-Syno-Token") != "" {
return true
return false
}
if r.URL.Query().Get("SynoToken") != "" {
return true
return false
}
if r.Method == "POST" && r.FormValue("SynoToken") != "" {
return true
return false
}
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>
Redirecting with session token...
<script>
fetch("/webman/login.cgi")
.then(r => r.json())
.then(data => {
u = new URL(window.location)
u.searchParams.set("SynoToken", data.SynoToken)
document.location = u
})
</script>
`

View File

@@ -5,8 +5,10 @@
package web
import (
"bytes"
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
@@ -29,35 +31,24 @@ import (
"tailscale.com/ipn/ipnstate"
"tailscale.com/licenses"
"tailscale.com/net/netutil"
"tailscale.com/net/tsaddr"
"tailscale.com/tailcfg"
"tailscale.com/types/logger"
"tailscale.com/util/httpm"
"tailscale.com/version/distro"
)
// ListenPort is the static port used for the web client when run inside tailscaled.
// (5252 are the numbers above the letters "TSTS" on a qwerty keyboard.)
const ListenPort = 5252
// Server is the backend server for a Tailscale web client.
type Server struct {
mode ServerMode
logf logger.Logf
lc *tailscale.LocalClient
timeNow func() time.Time
// devMode indicates that the server run with frontend assets
// served by a Vite dev server, allowing for local development
// on the web client frontend.
devMode bool
devMode bool
tsDebugMode string
cgiMode bool
pathPrefix string
apiHandler http.Handler // serves api endpoints; csrf-protected
assetsHandler http.Handler // serves frontend assets
assetsCleanup func() // called from Server.Shutdown
apiHandler http.Handler // serves api endpoints; csrf-protected
// browserSessions is an in-memory cache of browser sessions for the
// full management web client, which is only accessible over Tailscale.
@@ -73,29 +64,9 @@ type Server struct {
browserSessions sync.Map
}
// ServerMode specifies the mode of a running web.Server.
type ServerMode string
const (
// LoginServerMode serves a readonly login client for logging a
// node into a tailnet, and viewing a readonly interface of the
// node's current Tailscale settings.
//
// In this mode, API calls are authenticated via platform auth.
LoginServerMode ServerMode = "login"
// ManageServerMode serves a management client for editing tailscale
// settings of a node.
//
// This mode restricts the app to only being assessible over Tailscale,
// and API calls are authenticated via browser sessions associated with
// the source's Tailscale identity. If the source browser does not have
// a valid session, a readonly version of the app is displayed.
ManageServerMode ServerMode = "manage"
// LegacyServerMode serves the legacy web client, visible to users
// prior to release of tailscale/corp#14335.
LegacyServerMode ServerMode = "legacy"
sessionCookieName = "TS-Web-Session"
sessionCookieExpiry = time.Hour * 24 * 30 // 30 days
)
var (
@@ -103,10 +74,54 @@ var (
exitNodeRouteV6 = netip.MustParsePrefix("::/0")
)
// browserSession holds data about a user's browser session
// on the full management web client.
type browserSession struct {
// ID is the unique identifier for the session.
// It is passed in the user's "TS-Web-Session" browser cookie.
ID string
SrcNode tailcfg.NodeID
SrcUser tailcfg.UserID
AuthID string // from tailcfg.WebClientAuthResponse
AuthURL string // from tailcfg.WebClientAuthResponse
Created time.Time
Authenticated bool
}
// isAuthorized reports true if the given session is authorized
// to be used by its associated user to access the full management
// web client.
//
// isAuthorized is true only when s.Authenticated is true (i.e.
// the user has authenticated the session) and the session is not
// expired.
// 2023-10-05: Sessions expire by default 30 days after creation.
func (s *browserSession) isAuthorized() bool {
switch {
case s == nil:
return false
case !s.Authenticated:
return false // awaiting auth
case s.isExpired():
return false // expired
}
return true
}
// isExpired reports true if s is expired.
// 2023-10-05: Sessions expire by default 30 days after creation.
func (s *browserSession) isExpired() bool {
return !s.Created.IsZero() && time.Now().After(s.expires()) // TODO: use Server.timeNow field
}
// expires reports when the given session expires.
func (s *browserSession) expires() time.Time {
return s.Created.Add(sessionCookieExpiry)
}
// ServerOpts contains options for constructing a new Server.
type ServerOpts struct {
// Mode specifies the mode of web client being constructed.
Mode ServerMode
DevMode bool
// CGIMode indicates if the server is running as a CGI script.
CGIMode bool
@@ -121,46 +136,24 @@ type ServerOpts struct {
// TimeNow optionally provides a time function.
// time.Now is used as default.
TimeNow func() time.Time
// Logf optionally provides a logger function.
// log.Printf is used as default.
Logf logger.Logf
}
// NewServer constructs a new Tailscale web client server.
// If err is empty, s is always non-nil.
// ctx is only required to live the duration of the NewServer call,
// and not the lifespan of the web server.
func NewServer(opts ServerOpts) (s *Server, err error) {
switch opts.Mode {
case LoginServerMode, ManageServerMode, LegacyServerMode:
// valid types
case "":
return nil, fmt.Errorf("must specify a Mode")
default:
return nil, fmt.Errorf("invalid Mode provided")
}
func NewServer(opts ServerOpts) (s *Server, cleanup func()) {
if opts.LocalClient == nil {
opts.LocalClient = &tailscale.LocalClient{}
}
s = &Server{
mode: opts.Mode,
logf: opts.Logf,
devMode: envknob.Bool("TS_DEBUG_WEB_CLIENT_DEV"),
devMode: opts.DevMode,
lc: opts.LocalClient,
cgiMode: opts.CGIMode,
pathPrefix: opts.PathPrefix,
timeNow: opts.TimeNow,
}
if s.timeNow == nil {
s.timeNow = time.Now
}
if s.logf == nil {
s.logf = log.Printf
}
s.assetsHandler, s.assetsCleanup = assetsHandler(s.devMode)
var metric string // clientmetric to report on startup
s.tsDebugMode = s.debugMode()
s.assetsHandler, cleanup = assetsHandler(opts.DevMode)
// Create handler for "/api" requests with CSRF protection.
// We don't require secure cookies, since the web client is regularly used
@@ -168,30 +161,31 @@ func NewServer(opts ServerOpts) (s *Server, err error) {
// The client is secured by limiting the interface it listens on,
// or by authenticating requests before they reach the web client.
csrfProtect := csrf.Protect(s.csrfKey(), csrf.Secure(false))
if s.mode == LoginServerMode {
if s.tsDebugMode == "login" {
// For the login client, we don't serve the full web client API,
// only the login endpoints.
s.apiHandler = csrfProtect(http.HandlerFunc(s.serveLoginAPI))
metric = "web_login_client_initialization"
s.lc.IncrementCounter(context.Background(), "web_login_client_initialization", 1)
} else {
s.apiHandler = csrfProtect(http.HandlerFunc(s.serveAPI))
metric = "web_client_initialization"
s.lc.IncrementCounter(context.Background(), "web_client_initialization", 1)
}
// Don't block startup on reporting metric.
// Report in separate go routine with 5 second timeout.
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
s.lc.IncrementCounter(ctx, metric, 1)
}()
return s, nil
return s, cleanup
}
func (s *Server) Shutdown() {
s.logf("web.Server: shutting down")
if s.assetsCleanup != nil {
s.assetsCleanup()
// debugMode returns the debug mode the web client is being run in.
// The empty string is returned in the case that this instance is
// not running in any debug mode.
func (s *Server) debugMode() string {
if !s.devMode {
return "" // debug modes only available in dev
}
switch mode := os.Getenv("TS_DEBUG_WEB_CLIENT_MODE"); mode {
case "login", "full": // valid debug modes
return mode
}
return ""
}
// ServeHTTP processes all requests for the Tailscale web client.
@@ -207,43 +201,10 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) serve(w http.ResponseWriter, r *http.Request) {
if s.mode == ManageServerMode {
// In manage mode, requests must be sent directly to the bare Tailscale IP address.
// If a request comes in on any other hostname, redirect.
if s.requireTailscaleIP(w, r) {
return // user was redirected
}
// serve HTTP 204 on /ok requests as connectivity check
if r.Method == httpm.GET && r.URL.Path == "/ok" {
w.WriteHeader(http.StatusNoContent)
return
}
if !s.devMode {
w.Header().Set("X-Frame-Options", "DENY")
// TODO: use CSP nonce or hash to eliminate need for unsafe-inline
w.Header().Set("Content-Security-Policy", "default-src 'self' 'unsafe-inline'; img-src * data:")
w.Header().Set("Cross-Origin-Resource-Policy", "same-origin")
}
if ok := s.authorizeRequest(w, r); !ok {
return
}
if strings.HasPrefix(r.URL.Path, "/api/") {
switch {
case r.URL.Path == "/api/auth" && r.Method == httpm.GET:
s.serveAPIAuth(w, r) // serve auth status
return
case r.URL.Path == "/api/auth/session/new" && r.Method == httpm.GET:
s.serveAPIAuthSessionNew(w, r) // create new session
return
case r.URL.Path == "/api/auth/session/wait" && r.Method == httpm.GET:
s.serveAPIAuthSessionWait(w, r) // wait for session to be authorized
return
}
if ok := s.authorizeRequest(w, r); !ok {
http.Error(w, "not authorized", http.StatusUnauthorized)
return
}
// Pass API requests through to the API handler.
s.apiHandler.ServeHTTP(w, r)
return
@@ -254,52 +215,13 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request) {
s.assetsHandler.ServeHTTP(w, r)
}
// requireTailscaleIP redirects an incoming request if the HTTP request was not made to a bare Tailscale IP address.
// The request will be redirected to the Tailscale IP, port 5252, with the original request path.
// This allows any custom hostname to be used to access the device, but protects against DNS rebinding attacks.
// Returns true if the request has been fully handled, either be returning a redirect or an HTTP error.
func (s *Server) requireTailscaleIP(w http.ResponseWriter, r *http.Request) (handled bool) {
const (
ipv4ServiceHost = tsaddr.TailscaleServiceIPString
ipv6ServiceHost = "[" + tsaddr.TailscaleServiceIPv6String + "]"
)
// allow requests on quad-100 (or ipv6 equivalent)
if r.Host == ipv4ServiceHost || r.Host == ipv6ServiceHost {
return false
}
st, err := s.lc.StatusWithoutPeers(r.Context())
if err != nil {
s.logf("error getting status: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return true
}
var ipv4 string // store the first IPv4 address we see for redirect later
for _, ip := range st.Self.TailscaleIPs {
if ip.Is4() {
if r.Host == fmt.Sprintf("%s:%d", ip, ListenPort) {
return false
}
ipv4 = ip.String()
}
if ip.Is6() && r.Host == fmt.Sprintf("[%s]:%d", ip, ListenPort) {
return false
}
}
newURL := *r.URL
newURL.Host = fmt.Sprintf("%s:%d", ipv4, ListenPort)
http.Redirect(w, r, newURL.String(), http.StatusMovedPermanently)
return true
}
// authorizeRequest reports whether the request from the web client
// is authorized to be completed.
// It reports true if the request is authorized, and false otherwise.
// authorizeRequest manages writing out any relevant authorization
// errors to the ResponseWriter itself.
func (s *Server) authorizeRequest(w http.ResponseWriter, r *http.Request) (ok bool) {
if s.mode == ManageServerMode { // client using tailscale auth
if s.tsDebugMode == "full" { // client using tailscale auth
_, err := s.lc.WhoIs(r.Context(), r.RemoteAddr)
switch {
case err != nil:
@@ -309,14 +231,17 @@ func (s *Server) authorizeRequest(w http.ResponseWriter, r *http.Request) (ok bo
case r.URL.Path == "/api/data" && r.Method == httpm.GET:
// Readonly endpoint allowed without browser session.
return true
case r.URL.Path == "/api/auth":
// Endpoint for browser to request auth allowed without browser session.
return true
case strings.HasPrefix(r.URL.Path, "/api/"):
// All other /api/ endpoints require a valid browser session.
//
// TODO(sonia): s.getSession calls whois again,
// TODO(sonia): s.getTailscaleBrowserSession calls whois again,
// should try and use the above call instead of running another
// localapi request.
session, _, err := s.getSession(r)
if err != nil || !session.isAuthorized(s.timeNow()) {
session, _, err := s.getTailscaleBrowserSession(r)
if err != nil || !session.isAuthorized() {
http.Error(w, "no valid session", http.StatusUnauthorized)
return false
}
@@ -327,13 +252,15 @@ func (s *Server) authorizeRequest(w http.ResponseWriter, r *http.Request) (ok bo
}
}
// Client using system-specific auth.
switch distro.Get() {
case distro.Synology:
resp, _ := authorizeSynology(r)
return resp.OK
case distro.QNAP:
resp, _ := authorizeQNAP(r)
return resp.OK
d := distro.Get()
switch {
case strings.HasPrefix(r.URL.Path, "/assets/") && r.Method == httpm.GET:
// Don't require authorization for static assets.
return true
case d == distro.Synology:
return authorizeSynology(w, r)
case d == distro.QNAP:
return authorizeQNAP(w, r)
default:
return true // no additional auth for this distro
}
@@ -352,117 +279,219 @@ func (s *Server) serveLoginAPI(w http.ResponseWriter, r *http.Request) {
case httpm.GET:
// TODO(soniaappasamy): we may want a minimal node data response here
s.serveGetNodeData(w, r)
return
case httpm.POST:
// TODO(soniaappasamy): implement
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
http.Error(w, "invalid endpoint", http.StatusNotFound)
return
}
type authType string
var (
synoAuth authType = "synology" // user needs a SynoToken for subsequent API calls
tailscaleAuth authType = "tailscale" // user needs to complete Tailscale check mode
errNoSession = errors.New("no-browser-session")
errNotUsingTailscale = errors.New("not-using-tailscale")
errTaggedSource = errors.New("tagged-source")
errNotOwner = errors.New("not-owner")
)
type authResponse struct {
OK bool `json:"ok"` // true when user has valid auth session
AuthNeeded authType `json:"authNeeded,omitempty"` // filled when user needs to complete a specific type of auth
// getTailscaleBrowserSession retrieves the browser session associated with
// the request, if one exists.
//
// An error is returned in any of the following cases:
//
// - (errNotUsingTailscale) The request was not made over tailscale.
//
// - (errNoSession) The request does not have a session.
//
// - (errTaggedSource) The source is a tagged node. Users must use their
// own user-owned devices to manage other nodes' web clients.
//
// - (errNotOwner) The source is not the owner of this client (if the
// client is user-owned). Only the owner is allowed to manage the
// node via the web client.
//
// If no error is returned, the browserSession is always non-nil.
// getTailscaleBrowserSession does not check whether the session has been
// authorized by the user. Callers can use browserSession.isAuthorized.
//
// The WhoIsResponse is always populated, with a non-nil Node and UserProfile,
// unless getTailscaleBrowserSession reports errNotUsingTailscale.
func (s *Server) getTailscaleBrowserSession(r *http.Request) (*browserSession, *apitype.WhoIsResponse, error) {
whoIs, err := s.lc.WhoIs(r.Context(), r.RemoteAddr)
switch {
case err != nil:
return nil, nil, errNotUsingTailscale
case whoIs.Node.IsTagged():
return nil, whoIs, errTaggedSource
}
srcNode := whoIs.Node.ID
srcUser := whoIs.UserProfile.ID
status, err := s.lc.StatusWithoutPeers(r.Context())
switch {
case err != nil:
return nil, whoIs, err
case status.Self == nil:
return nil, whoIs, errors.New("missing self node in tailscale status")
case !status.Self.IsTagged() && status.Self.UserID != srcUser:
return nil, whoIs, errNotOwner
}
cookie, err := r.Cookie(sessionCookieName)
if errors.Is(err, http.ErrNoCookie) {
return nil, whoIs, errNoSession
} else if err != nil {
return nil, whoIs, err
}
v, ok := s.browserSessions.Load(cookie.Value)
if !ok {
return nil, whoIs, errNoSession
}
session := v.(*browserSession)
if session.SrcNode != srcNode || session.SrcUser != srcUser {
// In this case the browser cookie is associated with another tailscale node.
// Maybe the source browser's machine was logged out and then back in as a different node.
// Return errNoSession because there is no session for this user.
return nil, whoIs, errNoSession
} else if session.isExpired() {
// Session expired, remove from session map and return errNoSession.
s.browserSessions.Delete(session.ID)
return nil, whoIs, errNoSession
}
return session, whoIs, nil
}
// serverAPIAuth handles requests to the /api/auth endpoint
// and returns an authResponse indicating the current auth state and any steps the user needs to take.
func (s *Server) serveAPIAuth(w http.ResponseWriter, r *http.Request) {
type authResponse struct {
OK bool `json:"ok"` // true when user has valid auth session
AuthURL string `json:"authUrl,omitempty"` // filled when user has control auth action to take
}
func (s *Server) serveTailscaleAuth(w http.ResponseWriter, r *http.Request) {
if r.Method != httpm.GET {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var resp authResponse
session, _, err := s.getSession(r)
session, whois, err := s.getTailscaleBrowserSession(r)
switch {
case err != nil && errors.Is(err, errNotUsingTailscale):
// not using tailscale, so perform platform auth
switch distro.Get() {
case distro.Synology:
resp, err = authorizeSynology(r)
if err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
case distro.QNAP:
resp, err = authorizeQNAP(r)
if err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
default:
resp.OK = true // no additional auth for this distro
}
case err != nil && (errors.Is(err, errNotOwner) ||
errors.Is(err, errNotUsingTailscale) ||
errors.Is(err, errTaggedLocalSource) ||
errors.Is(err, errTaggedRemoteSource)):
// These cases are all restricted to the readonly view.
// No auth action to take.
resp = authResponse{OK: false}
case err != nil && !errors.Is(err, errNoSession):
// Any other error.
http.Error(w, err.Error(), http.StatusInternalServerError)
return
case session.isAuthorized(s.timeNow()):
resp = authResponse{OK: true}
default:
resp = authResponse{OK: false, AuthNeeded: tailscaleAuth}
}
writeJSON(w, resp)
}
type newSessionAuthResponse struct {
AuthURL string `json:"authUrl,omitempty"`
}
// serveAPIAuthSessionNew handles requests to the /api/auth/session/new endpoint.
func (s *Server) serveAPIAuthSessionNew(w http.ResponseWriter, r *http.Request) {
session, whois, err := s.getSession(r)
if err != nil && !errors.Is(err, errNoSession) {
// Source associated with request not allowed to create
// a session for this web client.
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
if session == nil {
case session == nil:
// Create a new session.
// If one already existed, we return that authURL rather than creating a new one.
session, err = s.newSession(r.Context(), whois)
d, err := s.getOrAwaitAuth(r.Context(), "", whois.Node.ID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
sid, err := s.newSessionID()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
session := &browserSession{
ID: sid,
SrcNode: whois.Node.ID,
SrcUser: whois.UserProfile.ID,
AuthID: d.ID,
AuthURL: d.URL,
Created: s.timeNow(),
}
s.browserSessions.Store(sid, session)
// Set the cookie on browser.
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: session.ID,
Raw: session.ID,
Value: sid,
Raw: sid,
Path: "/",
Expires: session.expires(),
})
resp = authResponse{OK: false, AuthURL: d.URL}
case !session.isAuthorized():
if r.URL.Query().Get("wait") == "true" {
// Client requested we block until user completes auth.
d, err := s.getOrAwaitAuth(r.Context(), session.AuthID, whois.Node.ID)
if err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
// Clean up the session. Doing this on any error from control
// server to avoid the user getting stuck with a bad session
// cookie.
s.browserSessions.Delete(session.ID)
return
}
if d.Complete {
session.Authenticated = d.Complete
s.browserSessions.Store(session.ID, session)
}
}
if session.isAuthorized() {
resp = authResponse{OK: true}
} else {
resp = authResponse{OK: false, AuthURL: session.AuthURL}
}
default:
resp = authResponse{OK: true}
}
writeJSON(w, newSessionAuthResponse{AuthURL: session.AuthURL})
if err := json.NewEncoder(w).Encode(resp); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
}
// serveAPIAuthSessionWait handles requests to the /api/auth/session/wait endpoint.
func (s *Server) serveAPIAuthSessionWait(w http.ResponseWriter, r *http.Request) {
session, _, err := s.getSession(r)
func (s *Server) newSessionID() (string, error) {
raw := make([]byte, 16)
for i := 0; i < 5; i++ {
if _, err := rand.Read(raw); err != nil {
return "", err
}
cookie := "ts-web-" + base64.RawURLEncoding.EncodeToString(raw)
if _, ok := s.browserSessions.Load(cookie); !ok {
return cookie, nil
}
}
return "", errors.New("too many collisions generating new session; please refresh page")
}
// getOrAwaitAuth connects to the control server for user auth,
// with the following behavior:
//
// 1. If authID is provided empty, a new auth URL is created on the control
// server and reported back here, which can then be used to redirect the
// user on the frontend.
// 2. If authID is provided non-empty, the connection to control blocks until
// the user has completed authenticating the associated auth URL,
// or until ctx is canceled.
func (s *Server) getOrAwaitAuth(ctx context.Context, authID string, src tailcfg.NodeID) (*tailcfg.WebClientAuthResponse, error) {
type data struct {
ID string
Src tailcfg.NodeID
}
var b bytes.Buffer
if err := json.NewEncoder(&b).Encode(data{ID: authID, Src: src}); err != nil {
return nil, err
}
url := "http://" + apitype.LocalAPIHost + "/localapi/v0/debug-web-client"
req, err := http.NewRequestWithContext(ctx, "POST", url, &b)
if err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
return
return nil, err
}
if session.isAuthorized(s.timeNow()) {
return // already authorized
resp, err := s.lc.DoLocalRequest(req)
if err != nil {
return nil, err
}
if err := s.awaitUserAuth(r.Context(), session); err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
return
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed request: %s", body)
}
var authResp *tailcfg.WebClientAuthResponse
if err := json.Unmarshal(body, &authResp); err != nil {
return nil, err
}
return authResp, nil
}
// serveAPI serves requests for the web client api.
@@ -472,6 +501,11 @@ func (s *Server) serveAPI(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-CSRF-Token", csrf.Token(r))
path := strings.TrimPrefix(r.URL.Path, "/api")
switch {
case path == "/auth":
if s.tsDebugMode == "full" { // behind debug flag
s.serveTailscaleAuth(w, r)
return
}
case path == "/data":
switch r.Method {
case httpm.GET:
@@ -520,12 +554,6 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
profile := st.User[st.Self.UserID]
deviceName := strings.Split(st.Self.DNSName, ".")[0]
versionShort := strings.Split(st.Version, "-")[0]
var debugMode string
if s.mode == ManageServerMode {
debugMode = "full"
} else if s.mode == LoginServerMode {
debugMode = "login"
}
data := &nodeData{
Profile: profile,
Status: st.BackendState,
@@ -537,7 +565,7 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
IsUnraid: distro.Get() == distro.Unraid,
UnraidToken: os.Getenv("UNRAID_CSRF_TOKEN"),
IPNVersion: versionShort,
DebugMode: debugMode, // TODO(sonia,will): just pass back s.mode directly?
DebugMode: s.tsDebugMode,
}
for _, r := range prefs.AdvertiseRoutes {
if r == exitNodeRouteV4 || r == exitNodeRouteV6 {
@@ -552,7 +580,11 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
if len(st.TailscaleIPs) != 0 {
data.IP = st.TailscaleIPs[0].String()
}
writeJSON(w, *data)
if err := json.NewEncoder(w).Encode(*data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
}
type nodeUpdate struct {
@@ -607,7 +639,7 @@ func (s *Server) servePostNodeUpdate(w http.ResponseWriter, r *http.Request) {
}
mp.Prefs.WantRunning = true
mp.Prefs.AdvertiseRoutes = routes
s.logf("Doing edit: %v", mp.Pretty())
log.Printf("Doing edit: %v", mp.Pretty())
if _, err := s.lc.EditPrefs(r.Context(), mp); err != nil {
w.WriteHeader(http.StatusInternalServerError)
@@ -623,9 +655,9 @@ func (s *Server) servePostNodeUpdate(w http.ResponseWriter, r *http.Request) {
if postData.ForceLogout {
logout = true
}
s.logf("tailscaleUp(reauth=%v, logout=%v) ...", reauth, logout)
log.Printf("tailscaleUp(reauth=%v, logout=%v) ...", reauth, logout)
url, err := s.tailscaleUp(r.Context(), st, postData)
s.logf("tailscaleUp = (URL %v, %v)", url != "", err)
log.Printf("tailscaleUp = (URL %v, %v)", url != "", err)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(mi{"error": err.Error()})
@@ -810,12 +842,3 @@ func enforcePrefix(prefix string, h http.HandlerFunc) http.HandlerFunc {
http.StripPrefix(prefix, h).ServeHTTP(w, r)
}
}
func writeJSON(w http.ResponseWriter, data any) {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(data); err != nil {
w.Header().Set("Content-Type", "text/plain")
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}

View File

@@ -10,7 +10,6 @@ import (
"io"
"net/http"
"net/http/httptest"
"net/netip"
"net/url"
"strings"
"testing"
@@ -152,15 +151,15 @@ func TestGetTailscaleBrowserSession(t *testing.T) {
tags := views.SliceOf([]string{"tag:server"})
tailnetNodes := map[string]*apitype.WhoIsResponse{
userANodeIP: {
Node: &tailcfg.Node{ID: 1, StableID: "1"},
Node: &tailcfg.Node{ID: 1},
UserProfile: userA,
},
userBNodeIP: {
Node: &tailcfg.Node{ID: 2, StableID: "2"},
Node: &tailcfg.Node{ID: 2},
UserProfile: userB,
},
taggedNodeIP: {
Node: &tailcfg.Node{ID: 3, StableID: "3", Tags: tags.AsSlice()},
Node: &tailcfg.Node{ID: 3, Tags: tags.AsSlice()},
},
}
@@ -170,10 +169,7 @@ func TestGetTailscaleBrowserSession(t *testing.T) {
defer localapi.Close()
go localapi.Serve(lal)
s := &Server{
timeNow: time.Now,
lc: &tailscale.LocalClient{Dial: lal.Dial},
}
s := &Server{lc: &tailscale.LocalClient{Dial: lal.Dial}}
// Add some browser sessions to cache state.
userASession := &browserSession{
@@ -241,26 +237,11 @@ func TestGetTailscaleBrowserSession(t *testing.T) {
wantError: errNotOwner,
},
{
name: "tagged-remote-source",
name: "tagged-source",
selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userA.ID},
remoteAddr: taggedNodeIP,
wantSession: nil,
wantError: errTaggedRemoteSource,
},
{
name: "tagged-local-source",
selfNode: &ipnstate.PeerStatus{ID: "3"},
remoteAddr: taggedNodeIP, // same node as selfNode
wantSession: nil,
wantError: errTaggedLocalSource,
},
{
name: "not-tagged-local-source",
selfNode: &ipnstate.PeerStatus{ID: "1", UserID: userA.ID},
remoteAddr: userANodeIP, // same node as selfNode
cookie: userASession.ID,
wantSession: userASession,
wantError: nil, // should not error
wantError: errTaggedSource,
},
{
name: "has-session",
@@ -303,14 +284,14 @@ func TestGetTailscaleBrowserSession(t *testing.T) {
if tt.cookie != "" {
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tt.cookie})
}
session, _, err := s.getSession(r)
session, _, err := s.getTailscaleBrowserSession(r)
if !errors.Is(err, tt.wantError) {
t.Errorf("wrong error; want=%v, got=%v", tt.wantError, err)
}
if diff := cmp.Diff(session, tt.wantSession); diff != "" {
t.Errorf("wrong session; (-got+want):%v", diff)
}
if gotIsAuthorized := session.isAuthorized(s.timeNow()); gotIsAuthorized != tt.wantIsAuthorized {
if gotIsAuthorized := session.isAuthorized(); gotIsAuthorized != tt.wantIsAuthorized {
t.Errorf("wrong isAuthorized; want=%v, got=%v", tt.wantIsAuthorized, gotIsAuthorized)
}
})
@@ -338,9 +319,8 @@ func TestAuthorizeRequest(t *testing.T) {
go localapi.Serve(lal)
s := &Server{
mode: ManageServerMode,
lc: &tailscale.LocalClient{Dial: lal.Dial},
timeNow: time.Now,
lc: &tailscale.LocalClient{Dial: lal.Dial},
tsDebugMode: "full",
}
validCookie := "ts-cookie"
s.browserSessions.Store(validCookie, &browserSession{
@@ -370,6 +350,12 @@ func TestAuthorizeRequest(t *testing.T) {
wantOkNotOverTailscale: false,
wantOkWithoutSession: false,
wantOkWithSession: true,
}, {
reqPath: "/api/auth",
reqMethod: httpm.GET,
wantOkNotOverTailscale: false,
wantOkWithoutSession: true,
wantOkWithSession: true,
}, {
reqPath: "/api/somethingelse",
reqMethod: httpm.GET,
@@ -409,13 +395,9 @@ func TestAuthorizeRequest(t *testing.T) {
}
}
func TestServeAuth(t *testing.T) {
func TestServeTailscaleAuth(t *testing.T) {
user := &tailcfg.UserProfile{ID: tailcfg.UserID(1)}
self := &ipnstate.PeerStatus{
ID: "self",
UserID: user.ID,
TailscaleIPs: []netip.Addr{netip.MustParseAddr("100.1.2.3")},
}
self := &ipnstate.PeerStatus{ID: "self", UserID: user.ID}
remoteNode := &apitype.WhoIsResponse{Node: &tailcfg.Node{ID: 1}, UserProfile: user}
remoteIP := "100.100.100.101"
@@ -433,9 +415,9 @@ func TestServeAuth(t *testing.T) {
sixtyDaysAgo := timeNow.Add(-sessionCookieExpiry * 2)
s := &Server{
mode: ManageServerMode,
lc: &tailscale.LocalClient{Dial: lal.Dial},
timeNow: func() time.Time { return timeNow },
lc: &tailscale.LocalClient{Dial: lal.Dial},
tsDebugMode: "full",
timeNow: func() time.Time { return timeNow },
}
successCookie := "ts-cookie-success"
@@ -467,29 +449,18 @@ func TestServeAuth(t *testing.T) {
})
tests := []struct {
name string
cookie string // cookie attached to request
wantNewCookie bool // want new cookie generated during request
wantSession *browserSession // session associated w/ cookie after request
path string
wantStatus int
wantResp any
name string
cookie string
query string
wantStatus int
wantResp *authResponse
wantNewCookie bool // new cookie generated
wantSession *browserSession // session associated w/ cookie at end of request
}{
{
name: "no-session",
path: "/api/auth",
name: "new-session-created",
wantStatus: http.StatusOK,
wantResp: &authResponse{OK: false, AuthNeeded: tailscaleAuth},
wantNewCookie: false,
wantSession: nil,
},
{
name: "new-session",
path: "/api/auth/session/new",
wantStatus: http.StatusOK,
wantResp: &newSessionAuthResponse{AuthURL: testControlURL + testAuthPath},
wantResp: &authResponse{OK: false, AuthURL: testControlURL + testAuthPath},
wantNewCookie: true,
wantSession: &browserSession{
ID: "GENERATED_ID", // gets swapped for newly created ID by test
@@ -503,10 +474,9 @@ func TestServeAuth(t *testing.T) {
},
{
name: "query-existing-incomplete-session",
path: "/api/auth",
cookie: successCookie,
wantStatus: http.StatusOK,
wantResp: &authResponse{OK: false, AuthNeeded: tailscaleAuth},
wantResp: &authResponse{OK: false, AuthURL: testControlURL + testAuthPathSuccess},
wantSession: &browserSession{
ID: successCookie,
SrcNode: remoteNode.Node.ID,
@@ -518,27 +488,13 @@ func TestServeAuth(t *testing.T) {
},
},
{
name: "existing-session-used",
path: "/api/auth/session/new", // should not create new session
cookie: successCookie,
name: "transition-to-successful-session",
cookie: successCookie,
// query "wait" indicates the FE wants to make
// local api call to wait until session completed.
query: "wait=true",
wantStatus: http.StatusOK,
wantResp: &newSessionAuthResponse{AuthURL: testControlURL + testAuthPathSuccess},
wantSession: &browserSession{
ID: successCookie,
SrcNode: remoteNode.Node.ID,
SrcUser: user.ID,
Created: oneHourAgo,
AuthID: testAuthPathSuccess,
AuthURL: testControlURL + testAuthPathSuccess,
Authenticated: false,
},
},
{
name: "transition-to-successful-session",
path: "/api/auth/session/wait",
cookie: successCookie,
wantStatus: http.StatusOK,
wantResp: nil,
wantResp: &authResponse{OK: true},
wantSession: &browserSession{
ID: successCookie,
SrcNode: remoteNode.Node.ID,
@@ -551,7 +507,6 @@ func TestServeAuth(t *testing.T) {
},
{
name: "query-existing-complete-session",
path: "/api/auth",
cookie: successCookie,
wantStatus: http.StatusOK,
wantResp: &authResponse{OK: true},
@@ -567,18 +522,17 @@ func TestServeAuth(t *testing.T) {
},
{
name: "transition-to-failed-session",
path: "/api/auth/session/wait",
cookie: failureCookie,
query: "wait=true",
wantStatus: http.StatusUnauthorized,
wantResp: nil,
wantSession: nil, // session deleted
},
{
name: "failed-session-cleaned-up",
path: "/api/auth/session/new",
cookie: failureCookie,
wantStatus: http.StatusOK,
wantResp: &newSessionAuthResponse{AuthURL: testControlURL + testAuthPath},
wantResp: &authResponse{OK: false, AuthURL: testControlURL + testAuthPath},
wantNewCookie: true,
wantSession: &browserSession{
ID: "GENERATED_ID",
@@ -592,10 +546,9 @@ func TestServeAuth(t *testing.T) {
},
{
name: "expired-cookie-gets-new-session",
path: "/api/auth/session/new",
cookie: expiredCookie,
wantStatus: http.StatusOK,
wantResp: &newSessionAuthResponse{AuthURL: testControlURL + testAuthPath},
wantResp: &authResponse{OK: false, AuthURL: testControlURL + testAuthPath},
wantNewCookie: true,
wantSession: &browserSession{
ID: "GENERATED_ID",
@@ -610,11 +563,12 @@ func TestServeAuth(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := httptest.NewRequest("GET", "http://100.1.2.3:5252"+tt.path, nil)
r := httptest.NewRequest("GET", "/api/auth", nil)
r.URL.RawQuery = tt.query
r.RemoteAddr = remoteIP
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tt.cookie})
w := httptest.NewRecorder()
s.serve(w, r)
s.serveTailscaleAuth(w, r)
res := w.Result()
defer res.Body.Close()
@@ -622,20 +576,17 @@ func TestServeAuth(t *testing.T) {
if gotStatus := res.StatusCode; tt.wantStatus != gotStatus {
t.Errorf("wrong status; want=%v, got=%v", tt.wantStatus, gotStatus)
}
var gotResp string
var gotResp *authResponse
if res.StatusCode == http.StatusOK {
body, err := io.ReadAll(res.Body)
if err != nil {
t.Fatal(err)
}
gotResp = strings.Trim(string(body), "\n")
if err := json.Unmarshal(body, &gotResp); err != nil {
t.Fatal(err)
}
}
var wantResp string
if tt.wantResp != nil {
b, _ := json.Marshal(tt.wantResp)
wantResp = string(b)
}
if diff := cmp.Diff(gotResp, string(wantResp)); diff != "" {
if diff := cmp.Diff(gotResp, tt.wantResp); diff != "" {
t.Errorf("wrong response; (-got+want):%v", diff)
}
// Validate cookie creation.
@@ -668,92 +619,6 @@ func TestServeAuth(t *testing.T) {
}
}
func TestRequireTailscaleIP(t *testing.T) {
self := &ipnstate.PeerStatus{
TailscaleIPs: []netip.Addr{
netip.MustParseAddr("100.1.2.3"),
netip.MustParseAddr("fd7a:115c::1234"),
},
}
lal := memnet.Listen("local-tailscaled.sock:80")
defer lal.Close()
localapi := mockLocalAPI(t, nil, func() *ipnstate.PeerStatus { return self })
defer localapi.Close()
go localapi.Serve(lal)
s := &Server{
mode: ManageServerMode,
lc: &tailscale.LocalClient{Dial: lal.Dial},
timeNow: time.Now,
logf: t.Logf,
}
tests := []struct {
name string
target string
wantHandled bool
wantLocation string
}{
{
name: "localhost",
target: "http://localhost/",
wantHandled: true,
wantLocation: "http://100.1.2.3:5252/",
},
{
name: "ipv4-no-port",
target: "http://100.1.2.3/",
wantHandled: true,
wantLocation: "http://100.1.2.3:5252/",
},
{
name: "ipv4-correct-port",
target: "http://100.1.2.3:5252/",
wantHandled: false,
},
{
name: "ipv6-no-port",
target: "http://[fd7a:115c::1234]/",
wantHandled: true,
wantLocation: "http://100.1.2.3:5252/",
},
{
name: "ipv6-correct-port",
target: "http://[fd7a:115c::1234]:5252/",
wantHandled: false,
},
{
name: "quad-100",
target: "http://100.100.100.100/",
wantHandled: false,
},
{
name: "ipv6-service-addr",
target: "http://[fd7a:115c:a1e0::53]/",
wantHandled: false,
},
}
for _, tt := range tests {
t.Run(tt.target, func(t *testing.T) {
s.logf = t.Logf
r := httptest.NewRequest(httpm.GET, tt.target, nil)
w := httptest.NewRecorder()
handled := s.requireTailscaleIP(w, r)
if handled != tt.wantHandled {
t.Errorf("request(%q) was handled; want=%v, got=%v", tt.target, tt.wantHandled, handled)
}
location := w.Header().Get("Location")
if location != tt.wantLocation {
t.Errorf("request(%q) wrong location; want=%q, got=%q", tt.target, tt.wantLocation, location)
}
})
}
}
var (
testControlURL = "http://localhost:8080"
testAuthPath = "/a/12345"
@@ -776,13 +641,22 @@ func mockLocalAPI(t *testing.T, whoIs map[string]*apitype.WhoIsResponse, self fu
t.Fatalf("/whois call missing \"addr\" query")
}
if node := whoIs[addr]; node != nil {
writeJSON(w, &node)
if err := json.NewEncoder(w).Encode(&node); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
return
}
http.Error(w, "not a node", http.StatusUnauthorized)
return
case "/localapi/v0/status":
writeJSON(w, ipnstate.Status{Self: self()})
status := ipnstate.Status{Self: self()}
if err := json.NewEncoder(w).Encode(status); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
return
case "/localapi/v0/debug-web-client": // used by TestServeTailscaleAuth
type reqData struct {
@@ -807,7 +681,11 @@ func mockLocalAPI(t *testing.T, whoIs map[string]*apitype.WhoIsResponse, self fu
http.Error(w, "authenticated as wrong user", http.StatusUnauthorized)
return
}
writeJSON(w, resp)
if err := json.NewEncoder(w).Encode(resp); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
return
default:
t.Fatalf("unhandled localapi test endpoint %q, add to localapi handler func in test", r.URL.Path)

View File

@@ -23,14 +23,6 @@
"@babel/highlight" "^7.22.10"
chalk "^2.4.2"
"@babel/code-frame@^7.22.13":
version "7.22.13"
resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.22.13.tgz#e3c1c099402598483b7a8c46a721d1038803755e"
integrity sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==
dependencies:
"@babel/highlight" "^7.22.13"
chalk "^2.4.2"
"@babel/compat-data@^7.22.9":
version "7.22.9"
resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.22.9.tgz#71cdb00a1ce3a329ce4cbec3a44f9fef35669730"
@@ -67,16 +59,6 @@
"@jridgewell/trace-mapping" "^0.3.17"
jsesc "^2.5.1"
"@babel/generator@^7.23.0":
version "7.23.0"
resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.23.0.tgz#df5c386e2218be505b34837acbcb874d7a983420"
integrity sha512-lN85QRR+5IbYrMWM6Y4pE/noaQtg4pNiqeNGX60eqOfo6gtEj6uw/JagelB8vVztSd7R6M5n1+PQkDbHbBRU4g==
dependencies:
"@babel/types" "^7.23.0"
"@jridgewell/gen-mapping" "^0.3.2"
"@jridgewell/trace-mapping" "^0.3.17"
jsesc "^2.5.1"
"@babel/helper-compilation-targets@^7.22.10":
version "7.22.10"
resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.22.10.tgz#01d648bbc25dd88f513d862ee0df27b7d4e67024"
@@ -88,23 +70,18 @@
lru-cache "^5.1.1"
semver "^6.3.1"
"@babel/helper-environment-visitor@^7.22.20":
version "7.22.20"
resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz#96159db61d34a29dba454c959f5ae4a649ba9167"
integrity sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==
"@babel/helper-environment-visitor@^7.22.5":
version "7.22.5"
resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.5.tgz#f06dd41b7c1f44e1f8da6c4055b41ab3a09a7e98"
integrity sha512-XGmhECfVA/5sAt+H+xpSg0mfrHq6FzNr9Oxh7PSEBBRUb/mL7Kz3NICXb194rCqAEdxkhPT1a88teizAFyvk8Q==
"@babel/helper-function-name@^7.23.0":
version "7.23.0"
resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz#1f9a3cdbd5b2698a670c30d2735f9af95ed52759"
integrity sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==
"@babel/helper-function-name@^7.22.5":
version "7.22.5"
resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.22.5.tgz#ede300828905bb15e582c037162f99d5183af1be"
integrity sha512-wtHSq6jMRE3uF2otvfuD3DIvVhOsSNshQl0Qrd7qC9oQJzHvOL4qQXlQn2916+CXGywIjpGuIkoyZRRxHPiNQQ==
dependencies:
"@babel/template" "^7.22.15"
"@babel/types" "^7.23.0"
"@babel/template" "^7.22.5"
"@babel/types" "^7.22.5"
"@babel/helper-hoist-variables@^7.22.5":
version "7.22.5"
@@ -150,11 +127,6 @@
resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz#533f36457a25814cf1df6488523ad547d784a99f"
integrity sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==
"@babel/helper-validator-identifier@^7.22.20":
version "7.22.20"
resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz#c4ae002c61d2879e724581d96665583dbc1dc0e0"
integrity sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==
"@babel/helper-validator-identifier@^7.22.5":
version "7.22.5"
resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.5.tgz#9544ef6a33999343c8740fa51350f30eeaaaf193"
@@ -183,34 +155,11 @@
chalk "^2.4.2"
js-tokens "^4.0.0"
"@babel/highlight@^7.22.13":
version "7.22.20"
resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.22.20.tgz#4ca92b71d80554b01427815e06f2df965b9c1f54"
integrity sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==
dependencies:
"@babel/helper-validator-identifier" "^7.22.20"
chalk "^2.4.2"
js-tokens "^4.0.0"
"@babel/parser@^7.22.10", "@babel/parser@^7.22.5":
version "7.22.10"
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.22.10.tgz#e37634f9a12a1716136c44624ef54283cabd3f55"
integrity sha512-lNbdGsQb9ekfsnjFGhEiF4hfFqGgfOP3H3d27re3n+CGhNuTSUEQdfWk556sTLNTloczcdM5TYF2LhzmDQKyvQ==
"@babel/parser@^7.22.15", "@babel/parser@^7.23.0":
version "7.23.0"
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.23.0.tgz#da950e622420bf96ca0d0f2909cdddac3acd8719"
integrity sha512-vvPKKdMemU85V9WE/l5wZEmImpCtLqbnTvqDS2U1fJ96KrxoW7KrXhNsNCblQlg8Ck4b85yxdTyelsMUgFUXiw==
"@babel/template@^7.22.15":
version "7.22.15"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.22.15.tgz#09576efc3830f0430f4548ef971dde1350ef2f38"
integrity sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==
dependencies:
"@babel/code-frame" "^7.22.13"
"@babel/parser" "^7.22.15"
"@babel/types" "^7.22.15"
"@babel/template@^7.22.5":
version "7.22.5"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.22.5.tgz#0c8c4d944509875849bd0344ff0050756eefc6ec"
@@ -221,18 +170,18 @@
"@babel/types" "^7.22.5"
"@babel/traverse@^7.22.10":
version "7.23.2"
resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.23.2.tgz#329c7a06735e144a506bdb2cad0268b7f46f4ad8"
integrity sha512-azpe59SQ48qG6nu2CzcMLbxUudtN+dOM9kDbUqGq3HXUJRlo7i8fvPoxQUzYgLZ4cMVmuZgm8vvBpNeRhd6XSw==
version "7.22.10"
resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.22.10.tgz#20252acb240e746d27c2e82b4484f199cf8141aa"
integrity sha512-Q/urqV4pRByiNNpb/f5OSv28ZlGJiFiiTh+GAHktbIrkPhPbl90+uW6SmpoLyZqutrg9AEaEf3Q/ZBRHBXgxig==
dependencies:
"@babel/code-frame" "^7.22.13"
"@babel/generator" "^7.23.0"
"@babel/helper-environment-visitor" "^7.22.20"
"@babel/helper-function-name" "^7.23.0"
"@babel/code-frame" "^7.22.10"
"@babel/generator" "^7.22.10"
"@babel/helper-environment-visitor" "^7.22.5"
"@babel/helper-function-name" "^7.22.5"
"@babel/helper-hoist-variables" "^7.22.5"
"@babel/helper-split-export-declaration" "^7.22.6"
"@babel/parser" "^7.23.0"
"@babel/types" "^7.23.0"
"@babel/parser" "^7.22.10"
"@babel/types" "^7.22.10"
debug "^4.1.0"
globals "^11.1.0"
@@ -245,15 +194,6 @@
"@babel/helper-validator-identifier" "^7.22.5"
to-fast-properties "^2.0.0"
"@babel/types@^7.22.15", "@babel/types@^7.23.0":
version "7.23.0"
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.23.0.tgz#8c1f020c9df0e737e4e247c0619f58c68458aaeb"
integrity sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==
dependencies:
"@babel/helper-string-parser" "^7.22.5"
"@babel/helper-validator-identifier" "^7.22.20"
to-fast-properties "^2.0.0"
"@cush/relative@^1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@cush/relative/-/relative-1.0.0.tgz#8cd1769bf9bde3bb27dac356b1bc94af40f6cc16"
@@ -1062,9 +1002,9 @@ gensync@^1.0.0-beta.2:
integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==
get-func-name@^2.0.0:
version "2.0.2"
resolved "https://registry.yarnpkg.com/get-func-name/-/get-func-name-2.0.2.tgz#0d7cf20cd13fda808669ffa88f4ffc7a3943fc41"
integrity sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==
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"
@@ -1464,10 +1404,10 @@ postcss-value-parser@^4.0.0, postcss-value-parser@^4.2.0:
resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514"
integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
postcss@^8.4.23, postcss@^8.4.27, postcss@^8.4.31:
version "8.4.31"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.31.tgz#92b451050a9f914da6755af352bdc0192508656d"
integrity sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==
postcss@^8.4.23, postcss@^8.4.27:
version "8.4.27"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.27.tgz#234d7e4b72e34ba5a92c29636734349e0d9c3057"
integrity sha512-gY/ACJtJPSmUFPDCHtX78+01fHa64FaU4zaaWfuh1MhGJISufJAH4cun6k/8fwsHYeK4UQmENQK+tRLCFJE8JQ==
dependencies:
nanoid "^3.3.6"
picocolors "^1.0.0"

View File

@@ -86,10 +86,6 @@ type Arguments struct {
// PkgsAddr is the address of the pkgs server to fetch updates from.
// Defaults to "https://pkgs.tailscale.com".
PkgsAddr string
// ForAutoUpdate should be true when Updater is created in auto-update
// context. When true, NewUpdater returns an error if it cannot be used for
// auto-updates (even if Updater.Update field is non-nil).
ForAutoUpdate bool
}
func (args Arguments) validate() error {
@@ -120,14 +116,10 @@ func NewUpdater(args Arguments) (*Updater, error) {
if up.Stderr == nil {
up.Stderr = os.Stderr
}
var canAutoUpdate bool
up.Update, canAutoUpdate = up.getUpdateFunction()
up.Update = up.getUpdateFunction()
if up.Update == nil {
return nil, errors.ErrUnsupported
}
if args.ForAutoUpdate && !canAutoUpdate {
return nil, errors.ErrUnsupported
}
switch up.Version {
case StableTrack, UnstableTrack:
up.track = up.Version
@@ -152,75 +144,52 @@ func NewUpdater(args Arguments) (*Updater, error) {
type updateFunction func() error
func (up *Updater) getUpdateFunction() (fn updateFunction, canAutoUpdate bool) {
func (up *Updater) getUpdateFunction() updateFunction {
switch runtime.GOOS {
case "windows":
return up.updateWindows, true
return up.updateWindows
case "linux":
switch distro.Get() {
case distro.Synology:
// Synology updates use our own pkgs.tailscale.com instead of the
// Synology Package Center. We should eventually get to a regular
// release cadence with Synology Package Center and use their
// auto-update mechanism.
return up.updateSynology, false
return up.updateSynology
case distro.Debian: // includes Ubuntu
return up.updateDebLike, true
return up.updateDebLike
case distro.Arch:
if up.archPackageInstalled() {
// Arch update func just prints a message about how to update,
// it doesn't support auto-updates.
return up.updateArchLike, false
}
return up.updateLinuxBinary, true
return up.updateArchLike
case distro.Alpine:
return up.updateAlpineLike, true
case distro.Unraid:
// Unraid runs from memory, updates must be installed via the Unraid
// plugin manager to be persistent.
// TODO(awly): implement Unraid updates using the 'plugin' CLI.
return nil, false
return up.updateAlpineLike
}
switch {
case haveExecutable("pacman"):
if up.archPackageInstalled() {
// Arch update func just prints a message about how to update,
// it doesn't support auto-updates.
return up.updateArchLike, false
}
return up.updateLinuxBinary, true
return 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.
return up.updateDebLike, true
return up.updateDebLike
case haveExecutable("dnf"):
return up.updateFedoraLike("dnf"), true
return up.updateFedoraLike("dnf")
case haveExecutable("yum"):
return up.updateFedoraLike("yum"), true
return up.updateFedoraLike("yum")
case haveExecutable("apk"):
return up.updateAlpineLike, true
return up.updateAlpineLike
}
// If nothing matched, fall back to tarball updates.
if up.Update == nil {
return up.updateLinuxBinary, true
return up.updateLinuxBinary
}
case "darwin":
switch {
case version.IsMacAppStore():
// App store update func just opens the store page, it doesn't
// support auto-updates.
return up.updateMacAppStore, false
return up.updateMacAppStore
case version.IsMacSysExt():
// Macsys update func kicks off Sparkle. Auto-updates are done by
// Sparkle.
return up.updateMacSys, false
return up.updateMacSys
default:
return nil, false
return nil
}
case "freebsd":
return up.updateFreeBSD, true
return up.updateFreeBSD
}
return nil, false
return nil
}
// Update runs a single update attempt using the platform-specific mechanism.
@@ -242,10 +211,10 @@ func Update(args Arguments) error {
func (up *Updater) confirm(ver string) bool {
switch cmpver.Compare(version.Short(), ver) {
case 0:
up.Logf("already running %v version %v; no update needed", up.track, ver)
up.Logf("already running %v; no update needed", ver)
return false
case 1:
up.Logf("installed %v version %v is newer than the latest available version %v; no update needed", up.track, version.Short(), ver)
up.Logf("installed version %v is newer than the latest available version %v; no update needed", version.Short(), ver)
return false
}
if up.Confirm != nil {
@@ -277,14 +246,13 @@ func (up *Updater) updateSynology() error {
return fmt.Errorf("cannot find Synology package for os=%s arch=%s, please report a bug with your device model", osName, arch)
}
if err := requireRoot(); err != nil {
return err
}
if !up.confirm(latest.SPKsVersion) {
return nil
}
if err := requireRoot(); err != nil {
return err
}
up.cleanupOldDownloads(filepath.Join(os.TempDir(), "tailscale-update*", "*.spk"))
// Download the SPK into a temporary directory.
spkDir, err := os.MkdirTemp("", "tailscale-update")
if err != nil {
@@ -486,12 +454,12 @@ func updateDebianAptSourcesListBytes(was []byte, dstTrack string) (newContent []
return buf.Bytes(), nil
}
func (up *Updater) archPackageInstalled() bool {
err := exec.Command("pacman", "--query", "tailscale").Run()
return err == nil
}
func (up *Updater) updateArchLike() error {
if err := exec.Command("pacman", "--query", "tailscale").Run(); err != nil && isExitError(err) {
// Tailscale was not installed via pacman, update via tarball download
// instead.
return up.updateLinuxBinary()
}
// Arch maintainer asked us not to implement "tailscale update" or
// auto-updates on Arch-based distros:
// https://github.com/tailscale/tailscale/issues/6995#issuecomment-1687080106
@@ -663,53 +631,24 @@ func (up *Updater) updateMacAppStore() error {
return nil
}
const (
// 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.
winMSIEnv = "TS_UPDATE_WIN_MSI"
// winExePathEnv is the environment variable that is set along with
// winMSIEnv and carries the full path of the calling tailscale.exe binary.
// It is used to re-launch the GUI process (tailscale-ipn.exe) after
// install is complete.
winExePathEnv = "TS_UPDATE_WIN_EXE_PATH"
)
// 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
launchTailscaleAsWinGUIUser func(string) error // or nil on non-Windows
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 != "" {
// stdout/stderr from this part of the install could be lost since the
// parent tailscaled is replaced. Create a temp log file to have some
// output to debug with in case update fails.
close, err := up.switchOutputToFile()
if err != nil {
up.Logf("failed to create log file for installation: %v; proceeding with existing outputs", err)
} else {
defer close.Close()
}
up.Logf("installing %v ...", msi)
if err := up.installMSI(msi); err != nil {
up.Logf("MSI install failed: %v", err)
return err
}
up.Logf("relaunching tailscale-ipn.exe...")
exePath := os.Getenv(winExePathEnv)
if exePath == "" {
up.Logf("env var %q not passed to installer binary copy", winExePathEnv)
return fmt.Errorf("env var %q not passed to installer binary copy", winExePathEnv)
}
if err := launchTailscaleAsWinGUIUser(exePath); err != nil {
up.Logf("Failed to re-launch tailscale after update: %v", err)
return err
}
up.Logf("success.")
return nil
}
@@ -722,17 +661,12 @@ func (up *Updater) updateWindows() error {
arch = "x86"
}
if !winutil.IsCurrentProcessElevated() {
return errors.New(`update must be run as Administrator
you can run the command prompt as Administrator one of these ways:
* right-click cmd.exe, select 'Run as administrator'
* press Windows+x, then press a
* press Windows+r, type in "cmd", then press Ctrl+Shift+Enter`)
}
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")
@@ -744,7 +678,6 @@ you can run the command prompt as Administrator one of these ways:
if err := os.MkdirAll(msiDir, 0700); err != nil {
return err
}
up.cleanupOldDownloads(filepath.Join(msiDir, "*.msi"))
pkgsPath := fmt.Sprintf("%s/tailscale-setup-%s-%s.msi", up.track, ver, arch)
msiTarget := filepath.Join(msiDir, path.Base(pkgsPath))
if err := up.downloadURLToFile(pkgsPath, msiTarget); err != nil {
@@ -758,7 +691,7 @@ you can run the command prompt as Administrator one of these ways:
up.Logf("authenticode verification succeeded")
up.Logf("making tailscale.exe copy to switch to...")
selfOrig, selfCopy, err := makeSelfCopy()
selfCopy, err := makeSelfCopy()
if err != nil {
return err
}
@@ -766,7 +699,7 @@ you can run the command prompt as Administrator one of these ways:
up.Logf("running tailscale.exe copy for final install...")
cmd := exec.Command(selfCopy, "update")
cmd.Env = append(os.Environ(), winMSIEnv+"="+msiTarget, winExePathEnv+"="+selfOrig)
cmd.Env = append(os.Environ(), winMSIEnv+"="+msiTarget)
cmd.Stdout = up.Stderr
cmd.Stderr = up.Stderr
cmd.Stdin = os.Stdin
@@ -779,35 +712,10 @@ you can run the command prompt as Administrator one of these ways:
panic("unreachable")
}
func (up *Updater) switchOutputToFile() (io.Closer, error) {
var logFilePath string
exePath, err := os.Executable()
if err != nil {
logFilePath = filepath.Join(os.TempDir(), "tailscale-updater.log")
} else {
logFilePath = strings.TrimSuffix(exePath, ".exe") + ".log"
}
up.Logf("writing update output to %q", logFilePath)
logFile, err := os.Create(logFilePath)
if err != nil {
return nil, err
}
up.Logf = func(m string, args ...any) {
fmt.Fprintf(logFile, m+"\n", args...)
}
up.Stdout = logFile
up.Stderr = logFile
return logFile, nil
}
func (up *Updater) installMSI(msi string) error {
var err error
for tries := 0; tries < 2; tries++ {
// TS_NOLAUNCH: don't automatically launch the app after install.
// We will launch it explicitly as the current GUI user afterwards.
cmd := exec.Command("msiexec.exe", "/i", filepath.Base(msi), "/quiet", "/promptrestart", "/qn", "TS_NOLAUNCH=true")
cmd := exec.Command("msiexec.exe", "/i", filepath.Base(msi), "/quiet", "/promptrestart", "/qn")
cmd.Dir = filepath.Dir(msi)
cmd.Stdout = up.Stdout
cmd.Stderr = up.Stderr
@@ -816,7 +724,6 @@ func (up *Updater) installMSI(msi string) error {
if err == nil {
break
}
up.Logf("Install attempt failed: %v", err)
uninstallVersion := version.Short()
if v := os.Getenv("TS_DEBUG_UNINSTALL_VERSION"); v != "" {
uninstallVersion = v
@@ -833,30 +740,6 @@ func (up *Updater) installMSI(msi string) error {
return err
}
// cleanupOldDownloads removes all files matching glob (see filepath.Glob).
// Only regular files are removed, so the glob must match specific files and
// not directories.
func (up *Updater) cleanupOldDownloads(glob string) {
matches, err := filepath.Glob(glob)
if err != nil {
up.Logf("cleaning up old downloads: %v", err)
return
}
for _, m := range matches {
s, err := os.Lstat(m)
if err != nil {
up.Logf("cleaning up old downloads: %v", err)
continue
}
if !s.Mode().IsRegular() {
continue
}
if err := os.Remove(m); err != nil {
up.Logf("cleaning up old downloads: %v", err)
}
}
}
func msiUUIDForVersion(ver string) string {
arch := runtime.GOARCH
if arch == "386" {
@@ -870,30 +753,30 @@ func msiUUIDForVersion(ver string) string {
return "{" + strings.ToUpper(uuid.NewSHA1(uuid.NameSpaceURL, []byte(msiURL)).String()) + "}"
}
func makeSelfCopy() (origPathExe, tmpPathExe string, err error) {
func makeSelfCopy() (tmpPathExe string, err error) {
selfExe, err := os.Executable()
if err != nil {
return "", "", err
return "", err
}
f, err := os.Open(selfExe)
if err != nil {
return "", "", err
return "", err
}
defer f.Close()
f2, err := os.CreateTemp("", "tailscale-updater-*.exe")
if err != nil {
return "", "", err
return "", err
}
if f := markTempFileFunc; f != nil {
if err := f(f2.Name()); err != nil {
return "", "", err
return "", err
}
}
if _, err := io.Copy(f2, f); err != nil {
f2.Close()
return "", "", err
return "", err
}
return selfExe, f2.Name(), f2.Close()
return f2.Name(), f2.Close()
}
func (up *Updater) downloadURLToFile(pathSrc, fileDst string) (ret error) {
@@ -950,13 +833,13 @@ func (up *Updater) updateLinuxBinary() error {
if err != nil {
return err
}
if !up.confirm(ver) {
return nil
}
// Root is needed to overwrite binaries and restart systemd unit.
if err := requireRoot(); err != nil {
return err
}
if !up.confirm(ver) {
return nil
}
dlPath, err := up.downloadLinuxTarball(ver)
if err != nil {

View File

@@ -11,8 +11,6 @@ import (
"maps"
"os"
"path/filepath"
"slices"
"sort"
"strings"
"testing"
)
@@ -685,113 +683,3 @@ func TestWriteFileSymlink(t *testing.T) {
}
}
}
func TestCleanupOldDownloads(t *testing.T) {
tests := []struct {
desc string
before []string
symlinks map[string]string
glob string
after []string
}{
{
desc: "MSIs",
before: []string{
"MSICache/tailscale-1.0.0.msi",
"MSICache/tailscale-1.1.0.msi",
"MSICache/readme.txt",
},
glob: "MSICache/*.msi",
after: []string{
"MSICache/readme.txt",
},
},
{
desc: "SPKs",
before: []string{
"tmp/tailscale-update-1/tailscale-1.0.0.spk",
"tmp/tailscale-update-2/tailscale-1.1.0.spk",
"tmp/readme.txt",
"tmp/tailscale-update-3",
"tmp/tailscale-update-4/tailscale-1.3.0",
},
glob: "tmp/tailscale-update*/*.spk",
after: []string{
"tmp/readme.txt",
"tmp/tailscale-update-3",
"tmp/tailscale-update-4/tailscale-1.3.0",
},
},
{
desc: "empty-target",
before: []string{},
glob: "tmp/tailscale-update*/*.spk",
after: []string{},
},
{
desc: "keep-dirs",
before: []string{
"tmp/tailscale-update-1/tailscale-1.0.0.spk",
},
glob: "tmp/tailscale-update*",
after: []string{
"tmp/tailscale-update-1/tailscale-1.0.0.spk",
},
},
{
desc: "no-follow-symlinks",
before: []string{
"MSICache/tailscale-1.0.0.msi",
"MSICache/tailscale-1.1.0.msi",
"MSICache/readme.txt",
},
symlinks: map[string]string{
"MSICache/tailscale-1.3.0.msi": "MSICache/tailscale-1.0.0.msi",
"MSICache/tailscale-1.4.0.msi": "MSICache/readme.txt",
},
glob: "MSICache/*.msi",
after: []string{
"MSICache/tailscale-1.3.0.msi",
"MSICache/tailscale-1.4.0.msi",
"MSICache/readme.txt",
},
},
}
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
dir := t.TempDir()
for _, p := range tt.before {
if err := os.MkdirAll(filepath.Join(dir, filepath.Dir(p)), 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, p), []byte(tt.desc), 0600); err != nil {
t.Fatal(err)
}
}
for from, to := range tt.symlinks {
if err := os.Symlink(filepath.Join(dir, to), filepath.Join(dir, from)); err != nil {
t.Fatal(err)
}
}
up := &Updater{Arguments: Arguments{Logf: t.Logf}}
up.cleanupOldDownloads(filepath.Join(dir, tt.glob))
var after []string
if err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if !d.IsDir() {
after = append(after, strings.TrimPrefix(filepath.ToSlash(path), filepath.ToSlash(dir)+"/"))
}
return nil
}); err != nil {
t.Fatal(err)
}
sort.Strings(after)
sort.Strings(tt.after)
if !slices.Equal(after, tt.after) {
t.Errorf("got files after cleanup: %q, want: %q", after, tt.after)
}
})
}
}

View File

@@ -7,20 +7,13 @@
package clientupdate
import (
"os/exec"
"os/user"
"path/filepath"
"syscall"
"golang.org/x/sys/windows"
"tailscale.com/util/winutil"
"tailscale.com/util/winutil/authenticode"
)
func init() {
markTempFileFunc = markTempFileWindows
verifyAuthenticode = verifyTailscale
launchTailscaleAsWinGUIUser = launchTailscaleAsGUIUser
}
func markTempFileWindows(name string) error {
@@ -33,25 +26,3 @@ const certSubjectTailscale = "Tailscale Inc."
func verifyTailscale(path string) error {
return authenticode.Verify(path, certSubjectTailscale)
}
func launchTailscaleAsGUIUser(exePath string) error {
exePath = filepath.Join(filepath.Dir(exePath), "tailscale-ipn.exe")
var token windows.Token
if u, err := user.Current(); err == nil && u.Name == "SYSTEM" {
sessionID := winutil.WTSGetActiveConsoleSessionId()
if sessionID != 0xFFFFFFFF {
if err := windows.WTSQueryUserToken(sessionID, &token); err != nil {
return err
}
defer token.Close()
}
}
cmd := exec.Command(exePath)
cmd.SysProcAttr = &syscall.SysProcAttr{
Token: syscall.Token(token),
HideWindow: true,
}
return cmd.Start()
}

View File

@@ -2,6 +2,11 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
filippo.io/edwards25519 from github.com/hdevalence/ed25519consensus
filippo.io/edwards25519/field from filippo.io/edwards25519
W 💣 github.com/Microsoft/go-winio from tailscale.com/safesocket
W 💣 github.com/Microsoft/go-winio/internal/fs from github.com/Microsoft/go-winio
W 💣 github.com/Microsoft/go-winio/internal/socket from github.com/Microsoft/go-winio
W github.com/Microsoft/go-winio/internal/stringbuffer from github.com/Microsoft/go-winio/internal/fs
W github.com/Microsoft/go-winio/pkg/guid from github.com/Microsoft/go-winio+
W 💣 github.com/alexbrainman/sspi from github.com/alexbrainman/sspi/internal/common+
W github.com/alexbrainman/sspi/internal/common from github.com/alexbrainman/sspi/negotiate
W 💣 github.com/alexbrainman/sspi/negotiate from tailscale.com/net/tshttpproxy
@@ -38,11 +43,6 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
LD github.com/prometheus/procfs from github.com/prometheus/client_golang/prometheus
LD github.com/prometheus/procfs/internal/fs from github.com/prometheus/procfs
LD github.com/prometheus/procfs/internal/util from github.com/prometheus/procfs
W 💣 github.com/tailscale/go-winio from tailscale.com/safesocket
W 💣 github.com/tailscale/go-winio/internal/fs from github.com/tailscale/go-winio
W 💣 github.com/tailscale/go-winio/internal/socket from github.com/tailscale/go-winio
W github.com/tailscale/go-winio/internal/stringbuffer from github.com/tailscale/go-winio/internal/fs
W github.com/tailscale/go-winio/pkg/guid from github.com/tailscale/go-winio+
L 💣 github.com/tailscale/netlink from tailscale.com/util/linuxfw
L 💣 github.com/vishvananda/netlink/nl from github.com/tailscale/netlink
L github.com/vishvananda/netns from github.com/tailscale/netlink+
@@ -111,7 +111,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
💣 tailscale.com/net/tshttpproxy from tailscale.com/derp/derphttp+
tailscale.com/net/wsconn from tailscale.com/cmd/derper+
tailscale.com/paths from tailscale.com/client/tailscale
💣 tailscale.com/safesocket from tailscale.com/client/tailscale
tailscale.com/safesocket from tailscale.com/client/tailscale
tailscale.com/syncs from tailscale.com/cmd/derper+
tailscale.com/tailcfg from tailscale.com/client/tailscale+
tailscale.com/tka from tailscale.com/client/tailscale+

View File

@@ -41,7 +41,6 @@ func startMeshWithHost(s *derp.Server, host string) error {
return err
}
c.MeshKey = s.MeshKey()
c.WatchConnectionChanges = true
// For meshed peers within a region, connect via VPC addresses.
c.SetURLDialer(func(ctx context.Context, network, addr string) (net.Conn, error) {

View File

@@ -1,23 +0,0 @@
# Patterns to ignore when building packages.
# This supports shell glob matching, relative path matching, and
# negation (prefixed with !). Only one pattern per line.
.DS_Store
# Common VCS dirs
.git/
.gitignore
.bzr/
.bzrignore
.hg/
.hgignore
.svn/
# Common backup files
*.swp
*.bak
*.tmp
*.orig
*~
# Various IDEs
.project
.idea/
*.tmproj
.vscode/

View File

@@ -1,29 +0,0 @@
# Copyright (c) Tailscale Inc & AUTHORS
# SPDX-License-Identifier: BSD-3-Clause
apiVersion: v2
name: tailscale-operator
description: A Helm chart for Tailscale Kubernetes operator
home: https://github.com/tailscale/tailscale
keywords:
- "tailscale"
- "vpn"
- "ingress"
- "egress"
- "wireguard"
sources:
- https://github.com/tailscale/tailscale
type: application
maintainers:
- name: tailscale-maintainers
url: https://tailscale.com/
# version will be set to Tailscale repo tag (without 'v') at release time.
version: 0.1.0
# appVersion will be set to Tailscale repo tag at release time.
appVersion: "unstable"

View File

@@ -1,26 +0,0 @@
# Copyright (c) Tailscale Inc & AUTHORS
# SPDX-License-Identifier: BSD-3-Clause
{{ if eq .Values.apiServerProxyConfig.mode "true" }}
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: tailscale-auth-proxy
rules:
- apiGroups: [""]
resources: ["users", "groups"]
verbs: ["impersonate"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: tailscale-auth-proxy
subjects:
- kind: ServiceAccount
name: operator
namespace: {{ .Release.Namespace }}
roleRef:
kind: ClusterRole
name: tailscale-auth-proxy
apiGroup: rbac.authorization.k8s.io
{{ end }}

View File

@@ -1,90 +0,0 @@
# Copyright (c) Tailscale Inc & AUTHORS
# SPDX-License-Identifier: BSD-3-Clause
apiVersion: apps/v1
kind: Deployment
metadata:
name: operator
namespace: {{ .Release.Namespace }}
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: operator
template:
metadata:
{{- with .Values.operatorConfig.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
app: operator
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: operator
{{- with .Values.operatorConfig.podSecurityContext }}
securityContext:
{{- toYaml .Values.operatorConfig.podSecurityContext | nindent 8 }}
{{- end }}
volumes:
- name: oauth
secret:
secretName: operator-oauth
containers:
- name: operator
{{- with .Values.operatorConfig.securityContext }}
securityContext:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.operatorConfig.resources }}
resources:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- $operatorTag:= printf ":%s" ( .Values.operatorConfig.image.tag | default .Chart.AppVersion )}}
image: {{ .Values.operatorConfig.image.repo }}{{- if .Values.operatorConfig.image.digest -}}{{ printf "@%s" .Values.operatorConfig.image.digest}}{{- else -}}{{ printf "%s" $operatorTag }}{{- end }}
imagePullPolicy: {{ .Values.operatorConfig.image.pullPolicy }}
env:
- name: OPERATOR_HOSTNAME
value: {{ .Values.operatorConfig.hostname }}
- name: OPERATOR_SECRET
value: operator
- name: OPERATOR_LOGGING
value: {{ .Values.operatorConfig.logging }}
- name: OPERATOR_NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
- name: CLIENT_ID_FILE
value: /oauth/client_id
- name: CLIENT_SECRET_FILE
value: /oauth/client_secret
{{- $proxyTag := printf ":%s" ( .Values.proxyConfig.image.tag | default .Chart.AppVersion )}}
- name: PROXY_IMAGE
value: {{ .Values.proxyConfig.image.repo }}{{- if .Values.proxyConfig.image.digest -}}{{ printf "@%s" .Values.proxyConfig.image.digest}}{{- else -}}{{ printf "%s" $proxyTag }}{{- end }}
- name: PROXY_TAGS
value: {{ .Values.proxyConfig.defaultTags }}
- name: APISERVER_PROXY
value: "{{ .Values.apiServerProxyConfig.mode }}"
- name: PROXY_FIREWALL_MODE
value: {{ .Values.proxyConfig.firewallMode }}
volumeMounts:
- name: oauth
mountPath: /oauth
readOnly: true
{{- with .Values.operatorConfig.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.operatorConfig.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.operatorConfig.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}

View File

@@ -1,13 +0,0 @@
# Copyright (c) Tailscale Inc & AUTHORS
# SPDX-License-Identifier: BSD-3-Clause
{{ if and .Values.oauth .Values.oauth.clientId -}}
apiVersion: v1
kind: Secret
metadata:
name: operator-oauth
namespace: {{ .Release.Namespace }}
stringData:
client_id: {{ .Values.oauth.clientId }}
client_secret: {{ .Values.oauth.clientSecret }}
{{- end -}}

View File

@@ -1,60 +0,0 @@
# Copyright (c) Tailscale Inc & AUTHORS
# SPDX-License-Identifier: BSD-3-Clause
apiVersion: v1
kind: ServiceAccount
metadata:
name: operator
namespace: {{ .Release.Namespace }}
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: tailscale-operator
rules:
- apiGroups: [""]
resources: ["events", "services", "services/status"]
verbs: ["*"]
- apiGroups: ["networking.k8s.io"]
resources: ["ingresses", "ingresses/status"]
verbs: ["*"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: tailscale-operator
subjects:
- kind: ServiceAccount
name: operator
namespace: {{ .Release.Namespace }}
roleRef:
kind: ClusterRole
name: tailscale-operator
apiGroup: rbac.authorization.k8s.io
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: operator
namespace: {{ .Release.Namespace }}
rules:
- apiGroups: [""]
resources: ["secrets"]
verbs: ["*"]
- apiGroups: ["apps"]
resources: ["statefulsets"]
verbs: ["*"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: operator
namespace: {{ .Release.Namespace }}
subjects:
- kind: ServiceAccount
name: operator
namespace: {{ .Release.Namespace }}
roleRef:
kind: Role
name: operator
apiGroup: rbac.authorization.k8s.io

View File

@@ -1,32 +0,0 @@
# Copyright (c) Tailscale Inc & AUTHORS
# SPDX-License-Identifier: BSD-3-Clause
apiVersion: v1
kind: ServiceAccount
metadata:
name: proxies
namespace: {{ .Release.Namespace }}
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: proxies
namespace: {{ .Release.Namespace }}
rules:
- apiGroups: [""]
resources: ["secrets"]
verbs: ["*"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: proxies
namespace: {{ .Release.Namespace }}
subjects:
- kind: ServiceAccount
name: proxies
namespace: {{ .Release.Namespace }}
roleRef:
kind: Role
name: proxies
apiGroup: rbac.authorization.k8s.io

View File

@@ -1,45 +0,0 @@
# Copyright (c) Tailscale Inc & AUTHORS
# SPDX-License-Identifier: BSD-3-Clause
# Operator oauth credentials. If set a Kubernetes Secret with the provided
# values will be created in the operator namespace. If unset a Secret named
# operator-oauth must be precreated.
# oauth:
# clientId: ""
# clientSecret: ""
operatorConfig:
image:
repo: tailscale/k8s-operator
# Digest will be prioritized over tag. If neither are set appVersion will be
# used.
tag: ""
digest: ""
logging: "info"
hostname: "tailscale-operator"
nodeSelector:
kubernetes.io/os: linux
# proxyConfig contains configuraton that will be applied to any ingress/egress
# proxies created by the operator.
# https://tailscale.com/kb/1236/kubernetes-operator/#cluster-ingress
# https://tailscale.com/kb/1236/kubernetes-operator/#cluster-egress
proxyConfig:
image:
repo: tailscale/tailscale
# Digest will be prioritized over tag. If neither are set appVersion will be
# used.
tag: ""
digest: ""
# ACL tag that operator will tag proxies with. Operator must be made owner of
# these tags
# https://tailscale.com/kb/1236/kubernetes-operator/?q=operator#setting-up-the-kubernetes-operator
defaultTags: tag:k8s
firewallMode: auto
# apiServerProxyConfig allows to configure whether the operator should expose
# Kubernetes API server.
# https://tailscale.com/kb/1236/kubernetes-operator/#accessing-the-kubernetes-control-plane-using-an-api-server-proxy
apiServerProxyConfig:
mode: "false" # "true", "false", "noauth"

View File

@@ -67,20 +67,10 @@ func main() {
zlog := kzap.NewRaw(opts...).Sugar()
logf.SetLogger(zapr.NewLogger(zlog.Desugar()))
// The operator can run either as a plain operator or it can
// additionally act as api-server proxy
// https://tailscale.com/kb/1236/kubernetes-operator/?q=kubernetes#accessing-the-kubernetes-control-plane-using-an-api-server-proxy.
mode := parseAPIProxyMode()
if mode == apiserverProxyModeDisabled {
hostinfo.SetApp("k8s-operator")
} else {
hostinfo.SetApp("k8s-operator-proxy")
}
s, tsClient := initTSNet(zlog)
defer s.Close()
restConfig := config.GetConfigOrDie()
maybeLaunchAPIServerProxy(zlog, restConfig, s, mode)
maybeLaunchAPIServerProxy(zlog, restConfig, s)
runReconcilers(zlog, s, tsNamespace, restConfig, tsClient, image, priorityClassName, tags, tsFirewallMode)
}
@@ -88,6 +78,7 @@ func main() {
// CLIENT_ID_FILE and CLIENT_SECRET_FILE environment variables to authenticate
// with Tailscale.
func initTSNet(zlog *zap.SugaredLogger) (*tsnet.Server, *tailscale.Client) {
hostinfo.SetApp("k8s-operator")
var (
clientIDPath = defaultEnv("CLIENT_ID_FILE", "")
clientSecretPath = defaultEnv("CLIENT_SECRET_FILE", "")

View File

@@ -21,6 +21,7 @@ import (
"k8s.io/client-go/transport"
"tailscale.com/client/tailscale"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/hostinfo"
"tailscale.com/tailcfg"
"tailscale.com/tsnet"
"tailscale.com/types/logger"
@@ -83,14 +84,13 @@ func parseAPIProxyMode() apiServerProxyMode {
// maybeLaunchAPIServerProxy launches the auth proxy, which is a small HTTP server
// that authenticates requests using the Tailscale LocalAPI and then proxies
// them to the kube-apiserver.
func maybeLaunchAPIServerProxy(zlog *zap.SugaredLogger, restConfig *rest.Config, s *tsnet.Server, mode apiServerProxyMode) {
func maybeLaunchAPIServerProxy(zlog *zap.SugaredLogger, restConfig *rest.Config, s *tsnet.Server) {
mode := parseAPIProxyMode()
if mode == apiserverProxyModeDisabled {
return
}
hostinfo.SetApp("k8s-operator-proxy")
startlog := zlog.Named("launchAPIProxy")
if mode == apiserverProxyModeNoAuth {
restConfig = rest.AnonymousClientConfig(restConfig)
}
cfg, err := restConfig.TransportConfig()
if err != nil {
startlog.Fatalf("could not get rest.TransportConfig(): %v", err)
@@ -166,11 +166,10 @@ func runAPIServerProxy(s *tsnet.Server, rt http.RoundTripper, logf logger.Logf,
logf: logf,
lc: lc,
rp: &httputil.ReverseProxy{
Rewrite: func(r *httputil.ProxyRequest) {
Director: func(r *http.Request) {
// Replace the URL with the Kubernetes APIServer.
r.Out.URL.Scheme = u.Scheme
r.Out.URL.Host = u.Host
r.URL.Scheme = u.Scheme
r.URL.Host = u.Host
if mode == apiserverProxyModeNoAuth {
// If we are not providing authentication, then we are just
// proxying to the Kubernetes API, so we don't need to do
@@ -185,18 +184,18 @@ func runAPIServerProxy(s *tsnet.Server, rt http.RoundTripper, logf logger.Logf,
// Out of paranoia, remove all authentication headers that might
// have been set by the client.
r.Out.Header.Del("Authorization")
r.Out.Header.Del("Impersonate-Group")
r.Out.Header.Del("Impersonate-User")
r.Out.Header.Del("Impersonate-Uid")
for k := range r.Out.Header {
r.Header.Del("Authorization")
r.Header.Del("Impersonate-Group")
r.Header.Del("Impersonate-User")
r.Header.Del("Impersonate-Uid")
for k := range r.Header {
if strings.HasPrefix(k, "Impersonate-Extra-") {
r.Out.Header.Del(k)
r.Header.Del(k)
}
}
// Now add the impersonation headers that we want.
if err := addImpersonationHeaders(r.Out); err != nil {
if err := addImpersonationHeaders(r); err != nil {
panic("failed to add impersonation headers: " + err.Error())
}
},

View File

@@ -9,9 +9,7 @@ import (
"context"
_ "embed"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"strings"
@@ -151,16 +149,10 @@ func (a *tailscaleSTSReconciler) Cleanup(ctx context.Context, logger *zap.Sugare
return false, fmt.Errorf("getting device info: %w", err)
}
if id != "" {
logger.Debugf("deleting device %s from control", string(id))
// TODO: handle case where the device is already deleted, but the secret
// is still around.
if err := a.tsClient.DeleteDevice(ctx, string(id)); err != nil {
errResp := &tailscale.ErrResponse{}
if ok := errors.As(err, errResp); ok && errResp.Status == http.StatusNotFound {
logger.Debugf("device %s not found, likely because it has already been deleted from control", string(id))
} else {
return false, fmt.Errorf("deleting device: %w", err)
}
} else {
logger.Debugf("device %s deleted from control", string(id))
return false, fmt.Errorf("deleting device: %w", err)
}
}
@@ -307,10 +299,10 @@ func (a *tailscaleSTSReconciler) newAuthKey(ctx context.Context, tags []string)
return key, nil
}
//go:embed deploy/manifests/proxy.yaml
//go:embed manifests/proxy.yaml
var proxyYaml []byte
//go:embed deploy/manifests/userspace-proxy.yaml
//go:embed manifests/userspace-proxy.yaml
var userspaceProxyYaml []byte
func (a *tailscaleSTSReconciler) reconcileSTS(ctx context.Context, logger *zap.SugaredLogger, sts *tailscaleSTSConfig, headlessSvc *corev1.Service, authKeySecret string) (*appsv1.StatefulSet, error) {

View File

@@ -10,31 +10,31 @@ package main
import (
"context"
"errors"
"expvar"
"flag"
"fmt"
"log"
"net"
"net/http"
"net/netip"
"os"
"sort"
"strconv"
"strings"
"time"
"github.com/peterbourgon/ff/v3"
"golang.org/x/net/dns/dnsmessage"
"inet.af/tcpproxy"
"tailscale.com/client/tailscale"
"tailscale.com/hostinfo"
"tailscale.com/ipn"
"tailscale.com/tailcfg"
"tailscale.com/metrics"
"tailscale.com/net/netutil"
"tailscale.com/tsnet"
"tailscale.com/tsweb"
"tailscale.com/types/appctype"
"tailscale.com/types/ipproto"
"tailscale.com/types/nettype"
"tailscale.com/util/mak"
"tailscale.com/util/clientmetric"
)
const configCapKey = "tailscale.com/sniproxy"
var tsMBox = dnsmessage.MustNewName("support.tailscale.com.")
// portForward is the state for a single port forwarding entry, as passed to the --forward flag.
type portForward struct {
@@ -68,7 +68,6 @@ func parseForward(value string) (*portForward, error) {
}
func main() {
// Parse flags
fs := flag.NewFlagSet("sniproxy", flag.ContinueOnError)
var (
ports = fs.String("ports", "443", "comma-separated list of ports to proxy")
@@ -78,214 +77,334 @@ func main() {
debugPort = fs.Int("debug-port", 8893, "Listening port for debug/metrics endpoint")
hostname = fs.String("hostname", "", "Hostname to register the service under")
)
err := ff.Parse(fs, os.Args[1:], ff.WithEnvVarPrefix("TS_APPC"))
if err != nil {
log.Fatal("ff.Parse")
}
if *ports == "" {
log.Fatal("no ports")
}
var ts tsnet.Server
defer ts.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
run(ctx, &ts, *wgPort, *hostname, *promoteHTTPS, *debugPort, *ports, *forwards)
}
// run actually runs the sniproxy. Its separate from main() to assist in testing.
func run(ctx context.Context, ts *tsnet.Server, wgPort int, hostname string, promoteHTTPS bool, debugPort int, ports, forwards string) {
// Wire up Tailscale node + app connector server
hostinfo.SetApp("sniproxy")
var s sniproxy
s.ts = ts
s.ts.Port = uint16(wgPort)
s.ts.Hostname = hostname
var s server
s.ts.Port = uint16(*wgPort)
s.ts.Hostname = *hostname
defer s.ts.Close()
lc, err := s.ts.LocalClient()
if err != nil {
log.Fatalf("LocalClient() failed: %v", err)
log.Fatal(err)
}
s.lc = lc
s.ts.RegisterFallbackTCPHandler(s.srv.HandleTCPFlow)
s.initMetrics()
// Start special-purpose listeners: dns, http promotion, debug server
ln, err := s.ts.Listen("udp", ":53")
if err != nil {
log.Fatalf("failed listening on port 53: %v", err)
}
defer ln.Close()
go s.serveDNS(ln)
if promoteHTTPS {
ln, err := s.ts.Listen("tcp", ":80")
for _, portStr := range strings.Split(*ports, ",") {
ln, err := s.ts.Listen("tcp", ":"+portStr)
if err != nil {
log.Fatalf("failed listening on port 80: %v", err)
log.Fatal(err)
}
defer ln.Close()
log.Printf("Promoting HTTP to HTTPS ...")
go s.promoteHTTPS(ln)
}
if debugPort != 0 {
mux := http.NewServeMux()
tsweb.Debugger(mux)
dln, err := s.ts.Listen("tcp", fmt.Sprintf(":%d", debugPort))
if err != nil {
log.Fatalf("failed listening on debug port: %v", err)
}
defer dln.Close()
go func() {
log.Fatalf("debug serve: %v", http.Serve(dln, mux))
}()
log.Printf("Serving on port %v ...", portStr)
go s.serve(ln)
}
// Finally, start mainloop to configure app connector based on information
// in the netmap.
// We set the NotifyInitialNetMap flag so we will always get woken with the
// current netmap, before only being woken on changes.
bus, err := lc.WatchIPNBus(ctx, ipn.NotifyWatchEngineUpdates|ipn.NotifyInitialNetMap|ipn.NotifyNoPrivateKeys)
if err != nil {
log.Fatalf("watching IPN bus: %v", err)
}
defer bus.Close()
for {
msg, err := bus.Next()
if err != nil {
if errors.Is(err, context.Canceled) {
return
}
log.Fatalf("reading IPN bus: %v", err)
}
// NetMap contains app-connector configuration
if nm := msg.NetMap; nm != nil && nm.SelfNode.Valid() {
sn := nm.SelfNode.AsStruct()
var c appctype.AppConnectorConfig
nmConf, err := tailcfg.UnmarshalNodeCapJSON[appctype.AppConnectorConfig](sn.CapMap, configCapKey)
if err != nil {
log.Printf("failed to read app connector configuration from coordination server: %v", err)
} else if len(nmConf) > 0 {
c = nmConf[0]
}
if c.AdvertiseRoutes {
if err := s.advertiseRoutesFromConfig(ctx, &c); err != nil {
log.Printf("failed to advertise routes: %v", err)
}
}
// Backwards compatibility: combine any configuration from control with flags specified
// on the command line. This is intentionally done after we advertise any routes
// because its never correct to advertise the nodes native IP addresses.
s.mergeConfigFromFlags(&c, ports, forwards)
s.srv.Configure(&c)
}
}
}
type sniproxy struct {
srv Server
ts *tsnet.Server
lc *tailscale.LocalClient
}
func (s *sniproxy) advertiseRoutesFromConfig(ctx context.Context, c *appctype.AppConnectorConfig) error {
// Collect the set of addresses to advertise, using a map
// to avoid duplicate entries.
addrs := map[netip.Addr]struct{}{}
for _, c := range c.SNIProxy {
for _, ip := range c.Addrs {
addrs[ip] = struct{}{}
}
}
for _, c := range c.DNAT {
for _, ip := range c.Addrs {
addrs[ip] = struct{}{}
}
}
var routes []netip.Prefix
for a := range addrs {
routes = append(routes, netip.PrefixFrom(a, a.BitLen()))
}
sort.SliceStable(routes, func(i, j int) bool {
return routes[i].Addr().Less(routes[j].Addr()) // determinism r us
})
_, err := s.lc.EditPrefs(ctx, &ipn.MaskedPrefs{
Prefs: ipn.Prefs{
AdvertiseRoutes: routes,
},
AdvertiseRoutesSet: true,
})
return err
}
func (s *sniproxy) mergeConfigFromFlags(out *appctype.AppConnectorConfig, ports, forwards string) {
ip4, ip6 := s.ts.TailscaleIPs()
sniConfigFromFlags := appctype.SNIProxyConfig{
Addrs: []netip.Addr{ip4, ip6},
}
if ports != "" {
for _, portStr := range strings.Split(ports, ",") {
port, err := strconv.ParseUint(portStr, 10, 16)
if err != nil {
log.Fatalf("invalid port: %s", portStr)
}
sniConfigFromFlags.IP = append(sniConfigFromFlags.IP, tailcfg.ProtoPortRange{
Proto: int(ipproto.TCP),
Ports: tailcfg.PortRange{First: uint16(port), Last: uint16(port)},
})
}
}
var forwardConfigFromFlags []appctype.DNATConfig
for _, forwStr := range strings.Split(forwards, ",") {
for _, forwStr := range strings.Split(*forwards, ",") {
if forwStr == "" {
continue
}
forw, err := parseForward(forwStr)
if err != nil {
log.Printf("invalid forwarding spec: %v", err)
continue
log.Fatal(err)
}
forwardConfigFromFlags = append(forwardConfigFromFlags, appctype.DNATConfig{
Addrs: []netip.Addr{ip4, ip6},
To: []string{forw.Destination},
IP: []tailcfg.ProtoPortRange{
{
Proto: int(ipproto.TCP),
Ports: tailcfg.PortRange{First: uint16(forw.Port), Last: uint16(forw.Port)},
},
},
ln, err := s.ts.Listen("tcp", ":"+strconv.Itoa(forw.Port))
if err != nil {
log.Fatal(err)
}
log.Printf("Serving on port %d to %s...", forw.Port, forw.Destination)
// Add an entry to the expvar LabelMap for Prometheus metrics,
// and create a clientmetric to report that same value.
service := portNumberToName(forw)
s.numTCPsessions.SetInt64(service, 0)
metric := fmt.Sprintf("sniproxy_tcp_sessions_%s", service)
clientmetric.NewCounterFunc(metric, func() int64 {
return s.numTCPsessions.Get(service).Value()
})
go s.forward(ln, forw)
}
if len(forwardConfigFromFlags) == 0 && len(sniConfigFromFlags.IP) == 0 {
return // no config specified on the command line
ln, err := s.ts.Listen("udp", ":53")
if err != nil {
log.Fatal(err)
}
go s.serveDNS(ln)
if *promoteHTTPS {
ln, err := s.ts.Listen("tcp", ":80")
if err != nil {
log.Fatal(err)
}
log.Printf("Promoting HTTP to HTTPS ...")
go s.promoteHTTPS(ln)
}
mak.Set(&out.SNIProxy, "flags", sniConfigFromFlags)
for i, forward := range forwardConfigFromFlags {
mak.Set(&out.DNAT, appctype.ConfigID(fmt.Sprintf("flags_%d", i)), forward)
if *debugPort != 0 {
mux := http.NewServeMux()
tsweb.Debugger(mux)
dln, err := s.ts.Listen("tcp", fmt.Sprintf(":%d", *debugPort))
if err != nil {
log.Fatal(err)
}
go func() {
log.Fatal(http.Serve(dln, mux))
}()
}
select {}
}
func (s *sniproxy) serveDNS(ln net.Listener) {
type server struct {
ts tsnet.Server
lc *tailscale.LocalClient
numTLSsessions expvar.Int
numTCPsessions *metrics.LabelMap
numBadAddrPort expvar.Int
dnsResponses expvar.Int
dnsFailures expvar.Int
httpPromoted expvar.Int
}
func (s *server) serve(ln net.Listener) {
for {
c, err := ln.Accept()
if err != nil {
log.Printf("serveDNS accept: %v", err)
return
log.Fatal(err)
}
go s.srv.HandleDNS(c.(nettype.ConnPacketConn))
go s.serveConn(c)
}
}
func (s *sniproxy) promoteHTTPS(ln net.Listener) {
func (s *server) forward(ln net.Listener, forw *portForward) {
for {
c, err := ln.Accept()
if err != nil {
log.Fatal(err)
}
go s.forwardConn(c, forw)
}
}
func (s *server) serveDNS(ln net.Listener) {
for {
c, err := ln.Accept()
if err != nil {
log.Fatal(err)
}
go s.serveDNSConn(c.(nettype.ConnPacketConn))
}
}
func (s *server) serveDNSConn(c nettype.ConnPacketConn) {
defer c.Close()
c.SetReadDeadline(time.Now().Add(5 * time.Second))
buf := make([]byte, 1500)
n, err := c.Read(buf)
if err != nil {
log.Printf("c.Read failed: %v\n ", err)
s.dnsFailures.Add(1)
return
}
var msg dnsmessage.Message
err = msg.Unpack(buf[:n])
if err != nil {
log.Printf("dnsmessage unpack failed: %v\n ", err)
s.dnsFailures.Add(1)
return
}
buf, err = s.dnsResponse(&msg)
if err != nil {
log.Printf("s.dnsResponse failed: %v\n", err)
s.dnsFailures.Add(1)
return
}
_, err = c.Write(buf)
if err != nil {
log.Printf("c.Write failed: %v\n", err)
s.dnsFailures.Add(1)
return
}
s.dnsResponses.Add(1)
}
func (s *server) serveConn(c net.Conn) {
addrPortStr := c.LocalAddr().String()
_, port, err := net.SplitHostPort(addrPortStr)
if err != nil {
log.Printf("bogus addrPort %q", addrPortStr)
s.numBadAddrPort.Add(1)
c.Close()
return
}
var dialer net.Dialer
dialer.Timeout = 5 * time.Second
var p tcpproxy.Proxy
p.ListenFunc = func(net, laddr string) (net.Listener, error) {
return netutil.NewOneConnListener(c, nil), nil
}
p.AddSNIRouteFunc(addrPortStr, func(ctx context.Context, sniName string) (t tcpproxy.Target, ok bool) {
s.numTLSsessions.Add(1)
return &tcpproxy.DialProxy{
Addr: net.JoinHostPort(sniName, port),
DialContext: dialer.DialContext,
}, true
})
p.Start()
}
// portNumberToName returns a human-readable name for several port numbers commonly forwarded,
// and "tcp###" for everything else. It is used for metric label names.
func portNumberToName(forw *portForward) string {
switch forw.Port {
case 22:
return "ssh"
case 1433:
return "sqlserver"
case 3306:
return "mysql"
case 3389:
return "rdp"
case 5432:
return "postgres"
default:
return fmt.Sprintf("%s%d", forw.Proto, forw.Port)
}
}
// forwardConn sets up a forwarder for a TCP connection. It does not inspect of the data
// like the SNI forwarding does, it merely forwards all data to the destination specified
// in the --forward=tcp/22/github.com argument.
func (s *server) forwardConn(c net.Conn, forw *portForward) {
addrPortStr := c.LocalAddr().String()
var dialer net.Dialer
dialer.Timeout = 30 * time.Second
var p tcpproxy.Proxy
p.ListenFunc = func(net, laddr string) (net.Listener, error) {
return netutil.NewOneConnListener(c, nil), nil
}
dial := &tcpproxy.DialProxy{
Addr: fmt.Sprintf("%s:%d", forw.Destination, forw.Port),
DialContext: dialer.DialContext,
}
p.AddRoute(addrPortStr, dial)
s.numTCPsessions.Add(portNumberToName(forw), 1)
p.Start()
}
func (s *server) dnsResponse(req *dnsmessage.Message) (buf []byte, err error) {
resp := dnsmessage.NewBuilder(buf,
dnsmessage.Header{
ID: req.Header.ID,
Response: true,
Authoritative: true,
})
resp.EnableCompression()
if len(req.Questions) == 0 {
buf, _ = resp.Finish()
return
}
q := req.Questions[0]
err = resp.StartQuestions()
if err != nil {
return
}
resp.Question(q)
ip4, ip6 := s.ts.TailscaleIPs()
err = resp.StartAnswers()
if err != nil {
return
}
switch q.Type {
case dnsmessage.TypeAAAA:
err = resp.AAAAResource(
dnsmessage.ResourceHeader{Name: q.Name, Class: q.Class, TTL: 120},
dnsmessage.AAAAResource{AAAA: ip6.As16()},
)
case dnsmessage.TypeA:
err = resp.AResource(
dnsmessage.ResourceHeader{Name: q.Name, Class: q.Class, TTL: 120},
dnsmessage.AResource{A: ip4.As4()},
)
case dnsmessage.TypeSOA:
err = resp.SOAResource(
dnsmessage.ResourceHeader{Name: q.Name, Class: q.Class, TTL: 120},
dnsmessage.SOAResource{NS: q.Name, MBox: tsMBox, Serial: 2023030600,
Refresh: 120, Retry: 120, Expire: 120, MinTTL: 60},
)
case dnsmessage.TypeNS:
err = resp.NSResource(
dnsmessage.ResourceHeader{Name: q.Name, Class: q.Class, TTL: 120},
dnsmessage.NSResource{NS: tsMBox},
)
}
if err != nil {
return
}
return resp.Finish()
}
func (s *server) promoteHTTPS(ln net.Listener) {
err := http.Serve(ln, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
s.httpPromoted.Add(1)
http.Redirect(w, r, "https://"+r.Host+r.RequestURI, http.StatusFound)
}))
log.Fatalf("promoteHTTPS http.Serve: %v", err)
}
// initMetrics sets up local prometheus metrics, and creates clientmetrics to report those
// same counters.
func (s *server) initMetrics() {
stats := new(metrics.Set)
stats.Set("tls_sessions", &s.numTLSsessions)
clientmetric.NewCounterFunc("sniproxy_tls_sessions", s.numTLSsessions.Value)
s.numTCPsessions = &metrics.LabelMap{Label: "proto"}
stats.Set("tcp_sessions", s.numTCPsessions)
// clientmetric doesn't have a good way to implement a Map type.
// We create clientmetrics dynamically when parsing the --forwards argument
stats.Set("bad_addrport", &s.numBadAddrPort)
clientmetric.NewCounterFunc("sniproxy_bad_addrport", s.numBadAddrPort.Value)
stats.Set("dns_responses", &s.dnsResponses)
clientmetric.NewCounterFunc("sniproxy_dns_responses", s.dnsResponses.Value)
stats.Set("dns_failed", &s.dnsFailures)
clientmetric.NewCounterFunc("sniproxy_dns_failed", s.dnsFailures.Value)
stats.Set("http_promoted", &s.httpPromoted)
clientmetric.NewCounterFunc("sniproxy_http_promoted", s.httpPromoted.Value)
expvar.Publish("sniproxy", stats)
}

View File

@@ -4,30 +4,10 @@
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"net"
"net/http/httptest"
"net/netip"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"tailscale.com/ipn/store/mem"
"tailscale.com/net/netns"
"tailscale.com/tailcfg"
"tailscale.com/tsnet"
"tailscale.com/tstest/integration"
"tailscale.com/tstest/integration/testcontrol"
"tailscale.com/types/appctype"
"tailscale.com/types/ipproto"
"tailscale.com/types/key"
"tailscale.com/types/logger"
)
func TestPortForwardingArguments(t *testing.T) {
@@ -55,169 +35,3 @@ func TestPortForwardingArguments(t *testing.T) {
}
}
}
var verboseDERP = flag.Bool("verbose-derp", false, "if set, print DERP and STUN logs")
var verboseNodes = flag.Bool("verbose-nodes", false, "if set, print tsnet.Server logs")
func startControl(t *testing.T) (control *testcontrol.Server, controlURL string) {
// Corp#4520: don't use netns for tests.
netns.SetEnabled(false)
t.Cleanup(func() {
netns.SetEnabled(true)
})
derpLogf := logger.Discard
if *verboseDERP {
derpLogf = t.Logf
}
derpMap := integration.RunDERPAndSTUN(t, derpLogf, "127.0.0.1")
control = &testcontrol.Server{
DERPMap: derpMap,
DNSConfig: &tailcfg.DNSConfig{
Proxied: true,
},
MagicDNSDomain: "tail-scale.ts.net",
}
control.HTTPTestServer = httptest.NewUnstartedServer(control)
control.HTTPTestServer.Start()
t.Cleanup(control.HTTPTestServer.Close)
controlURL = control.HTTPTestServer.URL
t.Logf("testcontrol listening on %s", controlURL)
return control, controlURL
}
func startNode(t *testing.T, ctx context.Context, controlURL, hostname string) (*tsnet.Server, key.NodePublic, netip.Addr) {
t.Helper()
tmp := filepath.Join(t.TempDir(), hostname)
os.MkdirAll(tmp, 0755)
s := &tsnet.Server{
Dir: tmp,
ControlURL: controlURL,
Hostname: hostname,
Store: new(mem.Store),
Ephemeral: true,
}
if !*verboseNodes {
s.Logf = logger.Discard
}
t.Cleanup(func() { s.Close() })
status, err := s.Up(ctx)
if err != nil {
t.Fatal(err)
}
return s, status.Self.PublicKey, status.TailscaleIPs[0]
}
func TestSNIProxyWithNetmapConfig(t *testing.T) {
c, controlURL := startControl(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Create a listener to proxy connections to.
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
// Start sniproxy
sni, nodeKey, ip := startNode(t, ctx, controlURL, "snitest")
go run(ctx, sni, 0, sni.Hostname, false, 0, "", "")
// Configure the mock coordination server to send down app connector config.
config := &appctype.AppConnectorConfig{
DNAT: map[appctype.ConfigID]appctype.DNATConfig{
"nic_test": {
Addrs: []netip.Addr{ip},
To: []string{"127.0.0.1"},
IP: []tailcfg.ProtoPortRange{
{
Proto: int(ipproto.TCP),
Ports: tailcfg.PortRange{First: uint16(ln.Addr().(*net.TCPAddr).Port), Last: uint16(ln.Addr().(*net.TCPAddr).Port)},
},
},
},
},
}
b, err := json.Marshal(config)
if err != nil {
t.Fatal(err)
}
c.SetNodeCapMap(nodeKey, tailcfg.NodeCapMap{
configCapKey: []tailcfg.RawMessage{tailcfg.RawMessage(b)},
})
// Lets spin up a second node (to represent the client).
client, _, _ := startNode(t, ctx, controlURL, "client")
// Make sure that the sni node has received its config.
l, err := sni.LocalClient()
if err != nil {
t.Fatal(err)
}
gotConfigured := false
for i := 0; i < 100; i++ {
s, err := l.StatusWithoutPeers(ctx)
if err != nil {
t.Fatal(err)
}
if len(s.Self.CapMap) > 0 {
gotConfigured = true
break // we got it
}
time.Sleep(10 * time.Millisecond)
}
if !gotConfigured {
t.Error("sni node never received its configuration from the coordination server!")
}
// Lets make the client open a connection to the sniproxy node, and
// make sure it results in a connection to our test listener.
w, err := client.Dial(ctx, "tcp", fmt.Sprintf("%s:%d", ip, ln.Addr().(*net.TCPAddr).Port))
if err != nil {
t.Fatal(err)
}
defer w.Close()
r, err := ln.Accept()
if err != nil {
t.Fatal(err)
}
r.Close()
}
func TestSNIProxyWithFlagConfig(t *testing.T) {
_, controlURL := startControl(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Create a listener to proxy connections to.
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
// Start sniproxy
sni, _, ip := startNode(t, ctx, controlURL, "snitest")
go run(ctx, sni, 0, sni.Hostname, false, 0, "", fmt.Sprintf("tcp/%d/localhost", ln.Addr().(*net.TCPAddr).Port))
// Lets spin up a second node (to represent the client).
client, _, _ := startNode(t, ctx, controlURL, "client")
// Lets make the client open a connection to the sniproxy node, and
// make sure it results in a connection to our test listener.
w, err := client.Dial(ctx, "tcp", fmt.Sprintf("%s:%d", ip, ln.Addr().(*net.TCPAddr).Port))
if err != nil {
t.Fatal(err)
}
defer w.Close()
r, err := ln.Accept()
if err != nil {
t.Fatal(err)
}
r.Close()
}

View File

@@ -810,9 +810,6 @@ func TestPrefFlagMapping(t *testing.T) {
case "Egg":
// Not applicable.
continue
case "RunWebClient":
// TODO(tailscale/corp#14335): Currently behind a feature flag.
continue
}
t.Errorf("unexpected new ipn.Pref field %q is not handled by up.go (see addPrefFlagMapping and checkForAccidentalSettingReverts)", prefName)
}
@@ -893,7 +890,6 @@ func TestUpdatePrefs(t *testing.T) {
AdvertiseRoutesSet: true,
AdvertiseTagsSet: true,
AllowSingleHostsSet: true,
AppConnectorSet: true,
ControlURLSet: true,
CorpDNSSet: true,
ExitNodeAllowLANAccessSet: true,
@@ -1132,49 +1128,6 @@ func TestUpdatePrefs(t *testing.T) {
wantJustEditMP: nil,
env: upCheckEnv{backendState: "Running"},
},
{
name: "advertise_connector",
flags: []string{"--advertise-connector"},
curPrefs: &ipn.Prefs{
ControlURL: ipn.DefaultControlURL,
AllowSingleHosts: true,
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
},
wantJustEditMP: &ipn.MaskedPrefs{
AppConnectorSet: true,
WantRunningSet: true,
},
env: upCheckEnv{backendState: "Running"},
checkUpdatePrefsMutations: func(t *testing.T, newPrefs *ipn.Prefs) {
if !newPrefs.AppConnector.Advertise {
t.Errorf("prefs.AppConnector.Advertise not set")
}
},
},
{
name: "no_advertise_connector",
flags: []string{"--advertise-connector=false"},
curPrefs: &ipn.Prefs{
ControlURL: ipn.DefaultControlURL,
AllowSingleHosts: true,
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
AppConnector: ipn.AppConnectorPrefs{
Advertise: true,
},
},
wantJustEditMP: &ipn.MaskedPrefs{
AppConnectorSet: true,
WantRunningSet: true,
},
env: upCheckEnv{backendState: "Running"},
checkUpdatePrefsMutations: func(t *testing.T, newPrefs *ipn.Prefs) {
if newPrefs.AppConnector.Advertise {
t.Errorf("prefs.AppConnector.Advertise not unset")
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {

View File

@@ -14,6 +14,7 @@ import (
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
"tailscale.com/util/mak"
)
@@ -87,6 +88,10 @@ func (e *serveEnv) runFunnel(ctx context.Context, args []string) error {
if sc == nil {
sc = new(ipn.ServeConfig)
}
st, err := e.getLocalClientStatusWithoutPeers(ctx)
if err != nil {
return fmt.Errorf("getting client status: %w", err)
}
port64, err := strconv.ParseUint(args[0], 10, 16)
if err != nil {
@@ -98,15 +103,11 @@ func (e *serveEnv) runFunnel(ctx context.Context, args []string) error {
// 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, port); err != nil {
if err := e.verifyFunnelEnabled(ctx, st, port); err != nil {
return err
}
}
st, err := e.getLocalClientStatusWithoutPeers(ctx)
if err != nil {
return fmt.Errorf("getting client status: %w", err)
}
dnsName := strings.TrimSuffix(st.Self.DNSName, ".")
hp := ipn.HostPort(dnsName + ":" + strconv.Itoa(int(port)))
if on == sc.AllowFunnel[hp] {
@@ -140,7 +141,13 @@ func (e *serveEnv) runFunnel(ctx context.Context, args []string) error {
// 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, port uint16) error {
func (e *serveEnv) verifyFunnelEnabled(ctx context.Context, st *ipnstate.Status, port uint16) error {
hasFunnelAttrs := func(selfNode *ipnstate.PeerStatus) bool {
return selfNode.HasCap(tailcfg.CapabilityHTTPS) && selfNode.HasCap(tailcfg.NodeAttrFunnel)
}
if hasFunnelAttrs(st.Self) {
return nil // already enabled
}
enableErr := e.enableFeatureInteractive(ctx, "funnel", tailcfg.CapabilityHTTPS, tailcfg.NodeAttrFunnel)
st, statusErr := e.getLocalClientStatusWithoutPeers(ctx) // get updated status; interactive flow may block
switch {

View File

@@ -159,19 +159,17 @@ type serveEnv struct {
// v2 specific flags
bg bool // background mode
setPath string // serve path
https uint // HTTP port
http uint // HTTP port
tcp uint // TCP port
tlsTerminatedTCP uint // a TLS terminated TCP port
https string // HTTP port
http string // HTTP port
tcp string // TCP port
tlsTerminatedTCP string // a TLS terminated TCP port
subcmd serveMode // subcommand
yes bool // update without prompt
lc localServeClient // localClient interface, specific to serve
// optional stuff for tests:
testFlagOut io.Writer
testStdout io.Writer
testStderr io.Writer
}
// getSelfDNSName returns the DNS name of the current node.
@@ -682,6 +680,13 @@ func (e *serveEnv) runServeStatus(ctx context.Context, args []string) error {
return nil
}
func (e *serveEnv) stdout() io.Writer {
if e.testStdout != nil {
return e.testStdout
}
return os.Stdout
}
func printTCPStatusTree(ctx context.Context, sc *ipn.ServeConfig, st *ipnstate.Status) error {
dnsName := strings.TrimSuffix(st.Self.DNSName, ".")
for p, h := range sc.TCP {
@@ -818,24 +823,6 @@ func parseServePort(s string) (uint16, error) {
// 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, caps ...tailcfg.NodeCapability) (err error) {
st, err := e.getLocalClientStatusWithoutPeers(ctx)
if err != nil {
return fmt.Errorf("getting client status: %w", err)
}
if st.Self == nil {
return errors.New("no self node")
}
hasCaps := func() bool {
for _, c := range caps {
if !st.Self.HasCap(c) {
return false
}
}
return true
}
if hasCaps() {
return nil // already enabled
}
info, err := e.lc.QueryFeature(ctx, feature)
if err != nil {
return err

View File

@@ -786,7 +786,7 @@ func TestVerifyFunnelEnabled(t *testing.T) {
{
name: "fallback-flow-enabled",
queryFeatureResponse: mockQueryFeatureResponse{resp: nil, err: errors.New("not-allowed")},
caps: []tailcfg.NodeCapability{tailcfg.CapabilityHTTPS, tailcfg.NodeAttrFunnel, "https://tailscale.com/cap/funnel-ports?ports=80,443,8080-8090"},
caps: []tailcfg.NodeCapability{tailcfg.CapabilityHTTPS, tailcfg.NodeAttrFunnel},
wantErr: "", // no error, success
},
{
@@ -811,6 +811,10 @@ func TestVerifyFunnelEnabled(t *testing.T) {
defer func() { fakeStatus.Self.Capabilities = oldCaps }() // reset after test
fakeStatus.Self.Capabilities = tt.caps
}
st, err := e.getLocalClientStatusWithoutPeers(ctx)
if err != nil {
t.Fatal(err)
}
defer func() {
r := recover()
@@ -822,7 +826,7 @@ func TestVerifyFunnelEnabled(t *testing.T) {
t.Errorf("wrong panic; got=%s, want=%s", gotPanic, tt.wantPanic)
}
}()
gotErr := e.verifyFunnelEnabled(ctx, 443)
gotErr := e.verifyFunnelEnabled(ctx, st, 443)
var got string
if gotErr != nil {
got = gotErr.Error()

View File

@@ -5,13 +5,11 @@ package cli
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"log"
"math"
"net"
"net/url"
"os"
@@ -47,13 +45,16 @@ a partial URL (e.g., localhost:3000), or a full URL including a path (e.g., http
EXAMPLES
- Expose an HTTP server running at 127.0.0.1:3000 in the foreground:
$ tailscale %[1]s 3000
$ tailscale %s 3000
- Expose an HTTP server running at 127.0.0.1:3000 in the background:
$ tailscale %[1]s --bg 3000
$ tailscale %s --bg 3000
- Expose an HTTPS server with a valid certificate at https://localhost:8443
$ tailscale %s https://localhost:8443
- Expose an HTTPS server with invalid or self-signed certificates at https://localhost:8443
$ tailscale %[1]s https+insecure://localhost:8443
$ tailscale %s https+insecure://localhost:8443
For more examples and use cases visit our docs site https://tailscale.com/kb/1247/funnel-serve-use-cases
`)
@@ -101,12 +102,6 @@ func buildShortUsage(subcmd string) string {
}, "\n ")
}
// errHelpFunc is standard error text that prompts users to
// run `$subcmd --help` for information on how to use serve.
var errHelpFunc = func(m serveMode) error {
return fmt.Errorf("try `tailscale %s --help` for usage info", infoMap[m].Name)
}
// newServeV2Command returns a new "serve" subcommand using e as its environment.
func newServeV2Command(e *serveEnv, subcmd serveMode) *ffcli.Command {
if subcmd != serve && subcmd != funnel {
@@ -123,21 +118,19 @@ func newServeV2Command(e *serveEnv, subcmd serveMode) *ffcli.Command {
fmt.Sprintf("%s status [--json]", info.Name),
fmt.Sprintf("%s reset", info.Name),
}, "\n "),
LongHelp: info.LongHelp + fmt.Sprintf(strings.TrimSpace(serveHelpCommon), info.Name),
LongHelp: info.LongHelp + fmt.Sprintf(strings.TrimSpace(serveHelpCommon), info.Name, info.Name),
Exec: e.runServeCombined(subcmd),
FlagSet: e.newFlags("serve-set", func(fs *flag.FlagSet) {
fs.BoolVar(&e.bg, "bg", false, "Run the command as a background process (default false)")
fs.BoolVar(&e.bg, "bg", false, "Run the command as a background process")
fs.StringVar(&e.setPath, "set-path", "", "Appends the specified path to the base URL for accessing the underlying service")
fs.UintVar(&e.https, "https", 0, "Expose an HTTPS server at the specified port (default mode)")
if subcmd == serve {
fs.UintVar(&e.http, "http", 0, "Expose an HTTP server at the specified port")
}
fs.UintVar(&e.tcp, "tcp", 0, "Expose a TCP forwarder to forward raw TCP packets at the specified port")
fs.UintVar(&e.tlsTerminatedTCP, "tls-terminated-tcp", 0, "Expose a TCP forwarder to forward TLS-terminated TCP packets at the specified port")
fs.BoolVar(&e.yes, "yes", false, "Update without interactive prompts (default false)")
fs.StringVar(&e.https, "https", "", "Expose an HTTPS server at the specified port (default")
fs.StringVar(&e.http, "http", "", "Expose an HTTP server at the specified port")
fs.StringVar(&e.tcp, "tcp", "", "Expose a TCP forwarder to forward raw TCP packets at the specified port")
fs.StringVar(&e.tlsTerminatedTCP, "tls-terminated-tcp", "", "Expose a TCP forwarder to forward TLS-terminated TCP packets at the specified port")
}),
UsageFunc: usageFuncNoDefaultValues,
UsageFunc: usageFunc,
Subcommands: []*ffcli.Command{
{
Name: "status",
@@ -159,31 +152,20 @@ func newServeV2Command(e *serveEnv, subcmd serveMode) *ffcli.Command {
}
}
func (e *serveEnv) validateArgs(subcmd serveMode, args []string) error {
if translation, ok := isLegacyInvocation(subcmd, args); ok {
fmt.Fprint(e.stderr(), "Error: the CLI for serve and funnel has changed.")
if translation != "" {
fmt.Fprint(e.stderr(), " You can run the following command instead:\n")
fmt.Fprintf(e.stderr(), "\t- %s\n", translation)
}
fmt.Fprint(e.stderr(), "\nPlease see https://tailscale.com/kb/1242/tailscale-serve for more information.\n")
return errHelpFunc(subcmd)
}
if len(args) == 0 {
func validateArgs(subcmd serveMode, args []string) error {
switch len(args) {
case 0:
return flag.ErrHelp
case 1, 2:
if isLegacyInvocation(subcmd, args) {
fmt.Fprintf(os.Stderr, "error: the CLI for serve and funnel has changed.")
fmt.Fprintf(os.Stderr, "Please see https://tailscale.com/kb/1242/tailscale-serve for more information.")
return errHelp
}
default:
fmt.Fprintf(os.Stderr, "error: invalid number of arguments (%d)", len(args))
return errHelp
}
if len(args) > 2 {
fmt.Fprintf(e.stderr(), "Error: invalid number of arguments (%d)\n", len(args))
return errHelpFunc(subcmd)
}
turnOff := args[len(args)-1] == "off"
if len(args) == 2 && !turnOff {
fmt.Fprintln(e.stderr(), "Error: invalid argument format")
return errHelpFunc(subcmd)
}
// Given the two checks above, we can assume there
// are only 1 or 2 arguments which is valid.
return nil
}
@@ -192,31 +174,22 @@ func (e *serveEnv) runServeCombined(subcmd serveMode) execFunc {
e.subcmd = subcmd
return func(ctx context.Context, args []string) error {
// Undocumented debug command (not using ffcli subcommands) to set raw
// configs from stdin for now (2022-11-13).
if len(args) == 1 && args[0] == "set-raw" {
valb, err := io.ReadAll(os.Stdin)
if err != nil {
return err
}
sc := new(ipn.ServeConfig)
if err := json.Unmarshal(valb, sc); err != nil {
return fmt.Errorf("invalid JSON: %w", err)
}
return e.lc.SetServeConfig(ctx, sc)
}
if err := e.validateArgs(subcmd, args); err != nil {
if err := validateArgs(subcmd, args); err != nil {
return err
}
ctx, cancel := signal.NotifyContext(ctx, os.Interrupt)
defer cancel()
st, err := e.getLocalClientStatusWithoutPeers(ctx)
if err != nil {
return fmt.Errorf("getting client status: %w", err)
}
funnel := subcmd == funnel
if funnel {
// verify node has funnel capabilities
if err := e.verifyFunnelEnabled(ctx, 443); err != nil {
if err := e.verifyFunnelEnabled(ctx, st, 443); err != nil {
return err
}
}
@@ -226,10 +199,18 @@ func (e *serveEnv) runServeCombined(subcmd serveMode) execFunc {
return fmt.Errorf("failed to clean the mount point: %w", err)
}
if e.setPath != "" {
// TODO(marwan-at-work): either
// 1. Warn the user that this is a side effect.
// 2. Force the user to pass --bg
// 3. Allow set-path to be in the foreground.
e.bg = true
}
srvType, srvPort, err := srvTypeAndPortFromFlags(e)
if err != nil {
fmt.Fprintf(e.stderr(), "error: %v\n\n", err)
return errHelpFunc(subcmd)
fmt.Fprintf(os.Stderr, "error: %v\n\n", err)
return errHelp
}
sc, err := e.lc.GetServeConfig(ctx)
@@ -241,10 +222,6 @@ func (e *serveEnv) runServeCombined(subcmd serveMode) execFunc {
if sc == nil {
sc = new(ipn.ServeConfig)
}
st, err := e.getLocalClientStatusWithoutPeers(ctx)
if err != nil {
return fmt.Errorf("getting client status: %w", err)
}
dnsName := strings.TrimSuffix(st.Self.DNSName, ".")
// set parent serve config to always be persisted
@@ -270,13 +247,7 @@ func (e *serveEnv) runServeCombined(subcmd serveMode) execFunc {
}
var watcher *tailscale.IPNBusWatcher
wantFg := !e.bg && !turnOff
if wantFg {
// validate the config before creating a WatchIPNBus session
if err := e.validateConfig(parentSC, srvPort, srvType); err != nil {
return err
}
if !e.bg && !turnOff {
// if foreground mode, create a WatchIPNBus session
// and use the nested config for all following operations
// TODO(marwan-at-work): nested-config validations should happen here or previous to this point.
@@ -308,19 +279,19 @@ func (e *serveEnv) runServeCombined(subcmd serveMode) execFunc {
msg = e.messageForPort(sc, st, dnsName, srvType, srvPort)
}
if err != nil {
fmt.Fprintf(e.stderr(), "error: %v\n\n", err)
return errHelpFunc(subcmd)
fmt.Fprintf(os.Stderr, "error: %v\n\n", err)
return errHelp
}
if err := e.lc.SetServeConfig(ctx, parentSC); err != nil {
if tailscale.IsPreconditionsFailedError(err) {
fmt.Fprintln(e.stderr(), "Another client is changing the serve config; please try again.")
fmt.Fprintln(os.Stderr, "Another client is changing the serve config; please try again.")
}
return err
}
if msg != "" {
fmt.Fprintln(e.stdout(), msg)
fmt.Fprintln(os.Stderr, msg)
}
if watcher != nil {
@@ -339,8 +310,6 @@ func (e *serveEnv) runServeCombined(subcmd serveMode) execFunc {
}
}
const backgroundExistsMsg = "background configuration already exists, use `tailscale %s --%s=%d off` to remove the existing configuration"
func (e *serveEnv) validateConfig(sc *ipn.ServeConfig, port uint16, wantServe serveType) error {
sc, isFg := findConfig(sc, port)
if sc == nil {
@@ -350,7 +319,7 @@ func (e *serveEnv) validateConfig(sc *ipn.ServeConfig, port uint16, wantServe se
return errors.New("foreground already exists under this port")
}
if !e.bg {
return fmt.Errorf(backgroundExistsMsg, infoMap[e.subcmd].Name, wantServe.String(), port)
return errors.New("background serve already exists under this port")
}
existingServe := serveFromPortHandler(sc.TCP[port])
if wantServe != existingServe {
@@ -402,10 +371,6 @@ func (e *serveEnv) setServe(sc *ipn.ServeConfig, st *ipnstate.Status, dnsName st
return fmt.Errorf("failed apply web serve: %w", err)
}
case serveTypeTCP, serveTypeTLSTerminatedTCP:
if e.setPath != "" {
return fmt.Errorf("cannot mount a path for TCP serve")
}
err := e.applyTCPServe(sc, dnsName, srvType, srvPort, target)
if err != nil {
return fmt.Errorf("failed to apply TCP serve: %w", err)
@@ -440,7 +405,7 @@ func (e *serveEnv) messageForPort(sc *ipn.ServeConfig, st *ipnstate.Status, dnsN
} else {
output.WriteString(msgServeAvailable)
}
output.WriteString("\n\n")
output.WriteString("\n")
scheme := "https"
if sc.IsServingHTTP(srvPort) {
@@ -453,6 +418,13 @@ func (e *serveEnv) messageForPort(sc *ipn.ServeConfig, st *ipnstate.Status, dnsN
portPart = ""
}
output.WriteString(fmt.Sprintf("%s://%s%s\n\n", scheme, dnsName, portPart))
if !e.bg {
output.WriteString(msgToExit)
return output.String()
}
srvTypeAndDesc := func(h *ipn.HTTPHandler) (string, string) {
switch {
case h.Path != "":
@@ -474,12 +446,12 @@ func (e *serveEnv) messageForPort(sc *ipn.ServeConfig, st *ipnstate.Status, dnsN
sort.Slice(mounts, func(i, j int) bool {
return len(mounts[i]) < len(mounts[j])
})
maxLen := len(mounts[len(mounts)-1])
for _, m := range mounts {
h := sc.Web[hp].Handlers[m]
t, d := srvTypeAndDesc(h)
output.WriteString(fmt.Sprintf("%s://%s%s%s\n", scheme, dnsName, portPart, m))
output.WriteString(fmt.Sprintf("%s %-5s %s\n\n", "|--", t, d))
output.WriteString(fmt.Sprintf("%s %s%s %-5s %s\n", "|--", m, strings.Repeat(" ", maxLen-len(m)), t, d))
}
} else if sc.TCP[srvPort] != nil {
h := sc.TCP[srvPort]
@@ -489,7 +461,6 @@ func (e *serveEnv) messageForPort(sc *ipn.ServeConfig, st *ipnstate.Status, dnsN
tlsStatus = "TLS terminated"
}
output.WriteString(fmt.Sprintf("%s://%s%s\n", scheme, dnsName, portPart))
output.WriteString(fmt.Sprintf("|-- tcp://%s (%s)\n", hp, tlsStatus))
for _, a := range st.TailscaleIPs {
ipp := net.JoinHostPort(a.String(), strconv.Itoa(int(srvPort)))
@@ -498,15 +469,11 @@ func (e *serveEnv) messageForPort(sc *ipn.ServeConfig, st *ipnstate.Status, dnsN
output.WriteString(fmt.Sprintf("|--> tcp://%s\n", h.TCPForward))
}
if !e.bg {
output.WriteString(msgToExit)
return output.String()
}
subCmd := infoMap[e.subcmd].Name
subCmdUpper := strings.ToUpper(string(subCmd[0])) + subCmd[1:]
subCmdSentance := strings.ToUpper(string(subCmd[0])) + subCmd[1:]
output.WriteString(fmt.Sprintf(msgRunningInBackground, subCmdUpper))
output.WriteString("\n")
output.WriteString(fmt.Sprintf(msgRunningInBackground, subCmdSentance))
output.WriteString("\n")
output.WriteString(fmt.Sprintf(msgDisableProxy, subCmd, srvType.String(), srvPort))
@@ -630,9 +597,6 @@ func (e *serveEnv) applyFunnel(sc *ipn.ServeConfig, dnsName string, srvPort uint
// TODO: add error handling for if toggling for existing sc
if allowFunnel {
mak.Set(&sc.AllowFunnel, hp, true)
} else if _, exists := sc.AllowFunnel[hp]; exists {
fmt.Fprintf(e.stderr(), "Removing Funnel for %s\n", hp)
delete(sc.AllowFunnel, hp)
}
}
@@ -659,7 +623,7 @@ func (e *serveEnv) unsetServe(sc *ipn.ServeConfig, dnsName string, srvType serve
}
func srvTypeAndPortFromFlags(e *serveEnv) (srvType serveType, srvPort uint16, err error) {
sourceMap := map[serveType]uint{
sourceMap := map[serveType]string{
serveTypeHTTP: e.http,
serveTypeHTTPS: e.https,
serveTypeTCP: e.tcp,
@@ -667,15 +631,13 @@ func srvTypeAndPortFromFlags(e *serveEnv) (srvType serveType, srvPort uint16, er
}
var srcTypeCount int
var srcValue string
for k, v := range sourceMap {
if v != 0 {
if v > math.MaxUint16 {
return 0, 0, fmt.Errorf("port number %d is too high for %s flag", v, srvType)
}
if v != "" {
srcTypeCount++
srvType = k
srvPort = uint16(v)
srcValue = v
}
}
@@ -683,104 +645,29 @@ func srvTypeAndPortFromFlags(e *serveEnv) (srvType serveType, srvPort uint16, er
return 0, 0, fmt.Errorf("cannot serve multiple types for a single mount point")
} else if srcTypeCount == 0 {
srvType = serveTypeHTTPS
srvPort = 443
srcValue = "443"
}
srvPort, err = parseServePort(srcValue)
if err != nil {
return 0, 0, fmt.Errorf("invalid port %q: %w", srcValue, err)
}
return srvType, srvPort, nil
}
// isLegacyInvocation helps transition customers who have been using the beta
// CLI to the newer API by returning a translation from the old command to the new command.
// The second result is a boolean that only returns true if the given arguments is a valid
// legacy invocation. If the given args are in the old format but are not valid, it will
// return false and expects the new code path has enough validations to reject the request.
func isLegacyInvocation(subcmd serveMode, args []string) (string, bool) {
if subcmd == funnel {
if len(args) != 2 {
return "", false
}
_, err := strconv.ParseUint(args[0], 10, 16)
return "", err == nil && (args[1] == "on" || args[1] == "off")
}
turnOff := len(args) > 1 && args[len(args)-1] == "off"
if turnOff {
args = args[:len(args)-1]
}
if len(args) == 0 {
return "", false
}
func isLegacyInvocation(subcmd serveMode, args []string) bool {
if subcmd == serve && len(args) == 2 {
prefixes := []string{"http", "https", "tcp", "tls-terminated-tcp"}
srcType, srcPortStr, found := strings.Cut(args[0], ":")
if !found {
if srcType == "https" && srcPortStr == "" {
// Default https port to 443.
srcPortStr = "443"
} else if srcType == "http" && srcPortStr == "" {
// Default http port to 80.
srcPortStr = "80"
} else {
return "", false
for _, prefix := range prefixes {
if strings.HasPrefix(args[0], prefix) {
return true
}
}
}
var wantLength int
switch srcType {
case "https", "http":
wantLength = 3
case "tcp", "tls-terminated-tcp":
wantLength = 2
default:
// return non-legacy, and let new code handle validation.
return "", false
}
// The length is either exactlly the same as in "https / <target>"
// or target is omitted as in "https / off" where omit the off at
// the top.
if len(args) != wantLength && !(turnOff && len(args) == wantLength-1) {
return "", false
}
cmd := []string{"tailscale", "serve", "--bg"}
switch srcType {
case "https":
// In the new code, we default to https:443,
// so we don't need to pass the flag explicitly.
if srcPortStr != "443" {
cmd = append(cmd, fmt.Sprintf("--https %s", srcPortStr))
}
case "http":
cmd = append(cmd, fmt.Sprintf("--http %s", srcPortStr))
case "tcp", "tls-terminated-tcp":
cmd = append(cmd, fmt.Sprintf("--%s %s", srcType, srcPortStr))
}
var mount string
if srcType == "https" || srcType == "http" {
mount = args[1]
if _, err := cleanMountPoint(mount); err != nil {
return "", false
}
if mount != "/" {
cmd = append(cmd, "--set-path "+mount)
}
}
// If there's no "off" there must always be a target destination.
// If there is "off", target is optional so check if it exists
// first before appending it.
hasTarget := !turnOff || (turnOff && len(args) == wantLength)
if hasTarget {
dest := args[len(args)-1]
if strings.Contains(dest, " ") {
dest = strconv.Quote(dest)
}
cmd = append(cmd, dest)
}
if turnOff {
cmd = append(cmd, "off")
}
return strings.Join(cmd, " "), true
return false
}
// removeWebServe removes a web handler from the serve config
@@ -792,43 +679,15 @@ func (e *serveEnv) removeWebServe(sc *ipn.ServeConfig, dnsName string, srvPort u
return errors.New("cannot remove web handler; currently serving TCP")
}
portStr := strconv.Itoa(int(srvPort))
hp := ipn.HostPort(net.JoinHostPort(dnsName, portStr))
var targetExists bool
var mounts []string
// mount is deduced from e.setPath but it is ambiguous as
// to whether the user explicitly passed "/" or it was defaulted to.
if e.setPath == "" {
targetExists = sc.Web[hp] != nil && len(sc.Web[hp].Handlers) > 0
if targetExists {
for mount := range sc.Web[hp].Handlers {
mounts = append(mounts, mount)
}
}
} else {
targetExists = sc.WebHandlerExists(hp, mount)
mounts = []string{mount}
}
if !targetExists {
hp := ipn.HostPort(net.JoinHostPort(dnsName, strconv.Itoa(int(srvPort))))
if !sc.WebHandlerExists(hp, mount) {
return errors.New("error: handler does not exist")
}
if len(mounts) > 1 {
msg := fmt.Sprintf("Are you sure you want to delete %d handlers under port %s?", len(mounts), portStr)
if !e.yes && !promptYesNo(msg) {
return nil
}
}
// delete existing handler, then cascade delete if empty
for _, m := range mounts {
delete(sc.Web[hp].Handlers, m)
}
delete(sc.Web[hp].Handlers, mount)
if len(sc.Web[hp].Handlers) == 0 {
delete(sc.Web, hp)
delete(sc.AllowFunnel, hp)
delete(sc.TCP, srvPort)
}
@@ -846,10 +705,6 @@ func (e *serveEnv) removeWebServe(sc *ipn.ServeConfig, dnsName string, srvPort u
delete(sc.AllowFunnel, hp)
}
if len(sc.AllowFunnel) == 0 {
sc.AllowFunnel = nil
}
return nil
}
@@ -886,7 +741,7 @@ func (e *serveEnv) removeTCPServe(sc *ipn.ServeConfig, src uint16) error {
// - https-insecure://localhost:3000
// - https-insecure://localhost:3000/foo
func expandProxyTargetDev(target string, supportedSchemes []string, defaultScheme string) (string, error) {
const host = "127.0.0.1"
var host = "127.0.0.1"
// support target being a port number
if port, err := strconv.ParseUint(target, 10, 16); err == nil {
@@ -909,20 +764,19 @@ func expandProxyTargetDev(target string, supportedSchemes []string, defaultSchem
return "", fmt.Errorf("must be a URL starting with one of the supported schemes: %v", supportedSchemes)
}
// validate the host.
switch u.Hostname() {
case "localhost", "127.0.0.1":
default:
return "", errors.New("only localhost or 127.0.0.1 proxies are currently supported")
}
// validate the port
port, err := strconv.ParseUint(u.Port(), 10, 16)
if err != nil || port == 0 {
return "", fmt.Errorf("invalid port %q", u.Port())
}
u.Host = fmt.Sprintf("%s:%d", host, port)
// validate the host.
switch u.Hostname() {
case "localhost", "127.0.0.1":
u.Host = fmt.Sprintf("%s:%d", host, port)
default:
return "", errors.New("only localhost or 127.0.0.1 proxies are currently supported")
}
return u.String(), nil
}
@@ -960,17 +814,3 @@ func (s serveType) String() string {
return "unknownServeType"
}
}
func (e *serveEnv) stdout() io.Writer {
if e.testStdout != nil {
return e.testStdout
}
return os.Stdout
}
func (e *serveEnv) stderr() io.Writer {
if e.testStderr != nil {
return e.testStderr
}
return os.Stderr
}

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,6 @@ import (
"flag"
"fmt"
"net/netip"
"os/exec"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/clientupdate"
@@ -18,7 +17,6 @@ import (
"tailscale.com/net/tsaddr"
"tailscale.com/safesocket"
"tailscale.com/types/views"
"tailscale.com/version"
)
var setCmd = &ffcli.Command{
@@ -45,7 +43,6 @@ type setArgsT struct {
hostname string
advertiseRoutes string
advertiseDefaultRoute bool
advertiseConnector bool
opUser string
acceptedRisks string
profileName string
@@ -68,9 +65,8 @@ func newSetFlagSet(goos string, setArgs *setArgsT) *flag.FlagSet {
setf.StringVar(&setArgs.hostname, "hostname", "", "hostname to use instead of the one provided by the OS")
setf.StringVar(&setArgs.advertiseRoutes, "advertise-routes", "", "routes to advertise to other nodes (comma-separated, e.g. \"10.0.0.0/8,192.168.0.0/24\") or empty string to not advertise routes")
setf.BoolVar(&setArgs.advertiseDefaultRoute, "advertise-exit-node", false, "offer to be an exit node for internet traffic for the tailnet")
setf.BoolVar(&setArgs.advertiseConnector, "advertise-connector", false, "offer to be an exit node for internet traffic for the tailnet")
setf.BoolVar(&setArgs.updateCheck, "update-check", true, "notify about available Tailscale updates")
setf.BoolVar(&setArgs.updateApply, "auto-update", false, "automatically update to the latest available version")
setf.BoolVar(&setArgs.updateCheck, "update-check", true, "HIDDEN: notify about available Tailscale updates")
setf.BoolVar(&setArgs.updateApply, "auto-update", false, "HIDDEN: automatically update to the latest available version")
setf.BoolVar(&setArgs.postureChecking, "posture-checking", false, "HIDDEN: allow management plane to gather device posture information")
if safesocket.GOOSUsesPeerCreds(goos) {
@@ -115,9 +111,6 @@ func runSet(ctx context.Context, args []string) (retErr error) {
Check: setArgs.updateCheck,
Apply: setArgs.updateApply,
},
AppConnector: ipn.AppConnectorPrefs{
Advertise: setArgs.advertiseConnector,
},
PostureChecking: setArgs.postureChecking,
},
}
@@ -164,22 +157,9 @@ func runSet(ctx context.Context, args []string) (retErr error) {
}
}
if maskedPrefs.AutoUpdateSet {
// On macsys, tailscaled will set the Sparkle auto-update setting. It
// does not use clientupdate.
if version.IsMacSysExt() {
apply := "0"
if maskedPrefs.AutoUpdate.Apply {
apply = "1"
}
out, err := exec.Command("defaults", "write", "io.tailscale.ipn.macsys", "SUAutomaticallyUpdate", apply).CombinedOutput()
if err != nil {
return fmt.Errorf("failed to enable automatic updates: %v, %q", err, out)
}
} else {
_, err := clientupdate.NewUpdater(clientupdate.Arguments{ForAutoUpdate: true})
if errors.Is(err, errors.ErrUnsupported) {
return errors.New("automatic updates are not supported on this platform")
}
_, err := clientupdate.NewUpdater(clientupdate.Arguments{})
if errors.Is(err, errors.ErrUnsupported) {
return errors.New("automatic updates are not supported on this platform")
}
}
checkPrefs := curPrefs.Clone()

View File

@@ -113,7 +113,6 @@ func newUpFlagSet(goos string, upArgs *upArgsT, cmd string) *flag.FlagSet {
upf.StringVar(&upArgs.advertiseTags, "advertise-tags", "", "comma-separated ACL tags to request; each must start with \"tag:\" (e.g. \"tag:eng,tag:montreal,tag:ssh\")")
upf.StringVar(&upArgs.hostname, "hostname", "", "hostname to use instead of the one provided by the OS")
upf.StringVar(&upArgs.advertiseRoutes, "advertise-routes", "", "routes to advertise to other nodes (comma-separated, e.g. \"10.0.0.0/8,192.168.0.0/24\") or empty string to not advertise routes")
upf.BoolVar(&upArgs.advertiseConnector, "advertise-connector", false, "advertise this node as an app connector")
upf.BoolVar(&upArgs.advertiseDefaultRoute, "advertise-exit-node", false, "offer to be an exit node for internet traffic for the tailnet")
if safesocket.GOOSUsesPeerCreds(goos) {
@@ -166,7 +165,6 @@ type upArgsT struct {
advertiseRoutes string
advertiseDefaultRoute bool
advertiseTags string
advertiseConnector bool
snat bool
netfilterMode string
authKeyOrFile string // "secret" or "file:/path/to/secret"
@@ -285,7 +283,6 @@ func prefsFromUpArgs(upArgs upArgsT, warnf logger.Logf, st *ipnstate.Status, goo
prefs.ForceDaemon = upArgs.forceDaemon
prefs.OperatorUser = upArgs.opUser
prefs.ProfileName = upArgs.profileName
prefs.AppConnector.Advertise = upArgs.advertiseConnector
if goos == "linux" {
prefs.NoSNAT = !upArgs.snat
@@ -733,7 +730,6 @@ func init() {
addPrefFlagMapping("nickname", "ProfileName")
addPrefFlagMapping("update-check", "AutoUpdate")
addPrefFlagMapping("auto-update", "AutoUpdate")
addPrefFlagMapping("advertise-connector", "AppConnector")
addPrefFlagMapping("posture-checking", "PostureChecking")
}
@@ -969,8 +965,6 @@ func prefsToFlags(env upCheckEnv, prefs *ipn.Prefs) (flagVal map[string]any) {
set(sb.String())
case "advertise-exit-node":
set(hasExitNodeRoutes(prefs.AdvertiseRoutes))
case "advertise-connector":
set(prefs.AppConnector.Advertise)
case "snat-subnet-routes":
set(!prefs.NoSNAT)
case "netfilter-mode":

View File

@@ -20,7 +20,7 @@ import (
var updateCmd = &ffcli.Command{
Name: "update",
ShortUsage: "update",
ShortHelp: "[BETA] Update Tailscale to the latest/different version",
ShortHelp: "[ALPHA] Update Tailscale to the latest/different version",
Exec: runUpdate,
FlagSet: (func() *flag.FlagSet {
fs := newFlagSet("update")
@@ -82,14 +82,7 @@ func confirmUpdate(ver string) bool {
return false
}
msg := fmt.Sprintf("This will update Tailscale from %v to %v. Continue?", version.Short(), ver)
return promptYesNo(msg)
}
// PromptYesNo takes a question and prompts the user to answer the
// question with a yes or no. It appends a [y/n] to the message.
func promptYesNo(msg string) bool {
fmt.Print(msg + " [y/n] ")
fmt.Printf("This will update Tailscale from %v to %v. Continue? [y/n] ", version.Short(), ver)
var resp string
fmt.Scanln(&resp)
resp = strings.ToLower(resp)

View File

@@ -14,13 +14,10 @@ import (
"net/http"
"net/http/cgi"
"os"
"os/signal"
"strings"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/client/web"
"tailscale.com/ipn"
"tailscale.com/tailcfg"
"tailscale.com/util/cmpx"
)
@@ -41,6 +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 web client in developer mode [this flag is in development, use is unsupported]")
webf.StringVar(&webArgs.prefix, "prefix", "", "URL prefix added to requests (for cgi or reverse proxies)")
return webf
})(),
@@ -50,6 +48,7 @@ Tailscale, as opposed to a CLI or a native app.
var webArgs struct {
listen string
cgi bool
dev bool
prefix string
}
@@ -77,74 +76,34 @@ func tlsConfigFromEnvironment() *tls.Config {
}
func runWeb(ctx context.Context, args []string) error {
ctx, cancel := signal.NotifyContext(ctx, os.Interrupt)
defer cancel()
if len(args) > 0 {
return fmt.Errorf("too many non-flag arguments: %q", args)
}
st, err := localClient.StatusWithoutPeers(ctx)
if err != nil {
return fmt.Errorf("getting client status: %w", err)
}
hasPreviewCap := st.Self.HasCap(tailcfg.CapabilityPreviewWebClient)
cliServerMode := web.LegacyServerMode
var existingWebClient bool
if prefs, err := localClient.GetPrefs(ctx); err == nil {
existingWebClient = prefs.RunWebClient
}
if hasPreviewCap {
cliServerMode = web.LoginServerMode
if !existingWebClient {
// Also start full client in tailscaled.
log.Printf("starting tailscaled web client at %s:5252\n", st.Self.TailscaleIPs[0])
if err := setRunWebClient(ctx, true); err != nil {
return fmt.Errorf("starting web client in tailscaled: %w", err)
}
}
}
webServer, err := web.NewServer(web.ServerOpts{
Mode: cliServerMode,
webServer, cleanup := web.NewServer(web.ServerOpts{
DevMode: webArgs.dev,
CGIMode: webArgs.cgi,
PathPrefix: webArgs.prefix,
LocalClient: &localClient,
})
if err != nil {
log.Printf("tailscale.web: %v", err)
return err
}
go func() {
select {
case <-ctx.Done():
// Shutdown the server.
webServer.Shutdown()
if hasPreviewCap && !webArgs.cgi && !existingWebClient {
log.Println("stopping tailscaled web client")
// When not in cgi mode, shut down the tailscaled
// web client on cli termination.
if err := setRunWebClient(context.Background(), false); err != nil {
log.Printf("stopping tailscaled web client: %v", err)
}
}
}
os.Exit(0)
}()
defer cleanup()
if webArgs.cgi {
if err := cgi.Serve(webServer); err != nil {
log.Printf("tailscale.cgi: %v", err)
return err
}
return nil
} else if tlsConfig := tlsConfigFromEnvironment(); tlsConfig != nil {
}
tlsConfig := tlsConfigFromEnvironment()
if tlsConfig != nil {
server := &http.Server{
Addr: webArgs.listen,
TLSConfig: tlsConfig,
Handler: webServer,
}
defer server.Shutdown(ctx)
log.Printf("web server running on: https://%s", server.Addr)
return server.ListenAndServeTLS("", "")
} else {
@@ -153,14 +112,6 @@ func runWeb(ctx context.Context, args []string) error {
}
}
func setRunWebClient(ctx context.Context, val bool) error {
_, err := localClient.EditPrefs(ctx, &ipn.MaskedPrefs{
Prefs: ipn.Prefs{RunWebClient: val},
RunWebClientSet: true,
})
return err
}
// urlOfListenAddr parses a given listen address into a formatted URL
func urlOfListenAddr(addr string) string {
host, port, _ := net.SplitHostPort(addr)

View File

@@ -2,6 +2,11 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
filippo.io/edwards25519 from github.com/hdevalence/ed25519consensus
filippo.io/edwards25519/field from filippo.io/edwards25519
W 💣 github.com/Microsoft/go-winio from tailscale.com/safesocket
W 💣 github.com/Microsoft/go-winio/internal/fs from github.com/Microsoft/go-winio
W 💣 github.com/Microsoft/go-winio/internal/socket from github.com/Microsoft/go-winio
W github.com/Microsoft/go-winio/internal/stringbuffer from github.com/Microsoft/go-winio/internal/fs
W github.com/Microsoft/go-winio/pkg/guid from github.com/Microsoft/go-winio+
W 💣 github.com/alexbrainman/sspi from github.com/alexbrainman/sspi/negotiate+
W github.com/alexbrainman/sspi/internal/common from github.com/alexbrainman/sspi/negotiate
W 💣 github.com/alexbrainman/sspi/negotiate from tailscale.com/net/tshttpproxy
@@ -42,11 +47,6 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
github.com/skip2/go-qrcode from tailscale.com/cmd/tailscale/cli
github.com/skip2/go-qrcode/bitset from github.com/skip2/go-qrcode+
github.com/skip2/go-qrcode/reedsolomon from github.com/skip2/go-qrcode
W 💣 github.com/tailscale/go-winio from tailscale.com/safesocket
W 💣 github.com/tailscale/go-winio/internal/fs from github.com/tailscale/go-winio
W 💣 github.com/tailscale/go-winio/internal/socket from github.com/tailscale/go-winio
W github.com/tailscale/go-winio/internal/stringbuffer from github.com/tailscale/go-winio/internal/fs
W github.com/tailscale/go-winio/pkg/guid from github.com/tailscale/go-winio+
github.com/tailscale/goupnp from github.com/tailscale/goupnp/dcps/internetgateway2+
github.com/tailscale/goupnp/dcps/internetgateway2 from tailscale.com/net/portmapper
github.com/tailscale/goupnp/httpu from github.com/tailscale/goupnp+
@@ -116,7 +116,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
💣 tailscale.com/net/tshttpproxy from tailscale.com/derp/derphttp+
tailscale.com/net/wsconn from tailscale.com/control/controlhttp+
tailscale.com/paths from tailscale.com/cmd/tailscale/cli+
💣 tailscale.com/safesocket from tailscale.com/cmd/tailscale/cli+
tailscale.com/safesocket from tailscale.com/cmd/tailscale/cli+
tailscale.com/syncs from tailscale.com/net/netcheck+
tailscale.com/tailcfg from tailscale.com/cmd/tailscale/cli+
tailscale.com/tka from tailscale.com/client/tailscale+

View File

@@ -2,6 +2,11 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
filippo.io/edwards25519 from github.com/hdevalence/ed25519consensus
filippo.io/edwards25519/field from filippo.io/edwards25519
W 💣 github.com/Microsoft/go-winio from tailscale.com/safesocket
W 💣 github.com/Microsoft/go-winio/internal/fs from github.com/Microsoft/go-winio
W 💣 github.com/Microsoft/go-winio/internal/socket from github.com/Microsoft/go-winio
W github.com/Microsoft/go-winio/internal/stringbuffer from github.com/Microsoft/go-winio/internal/fs
W github.com/Microsoft/go-winio/pkg/guid from github.com/Microsoft/go-winio+
W 💣 github.com/alexbrainman/sspi from github.com/alexbrainman/sspi/internal/common+
W github.com/alexbrainman/sspi/internal/common from github.com/alexbrainman/sspi/negotiate
W 💣 github.com/alexbrainman/sspi/negotiate from tailscale.com/net/tshttpproxy
@@ -95,8 +100,6 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
L github.com/google/nftables/internal/parseexprfunc from github.com/google/nftables+
L github.com/google/nftables/xt from github.com/google/nftables/expr+
github.com/google/uuid from tailscale.com/clientupdate
github.com/gorilla/csrf from tailscale.com/client/web
github.com/gorilla/securecookie from github.com/gorilla/csrf
github.com/hdevalence/ed25519consensus from tailscale.com/tka+
L 💣 github.com/illarion/gonotify from tailscale.com/net/dns
L github.com/insomniacslk/dhcp/dhcpv4 from tailscale.com/net/tstun
@@ -130,15 +133,9 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
L github.com/pierrec/lz4/v4/internal/lz4errors from github.com/pierrec/lz4/v4+
L github.com/pierrec/lz4/v4/internal/lz4stream from github.com/pierrec/lz4/v4
L github.com/pierrec/lz4/v4/internal/xxh32 from github.com/pierrec/lz4/v4/internal/lz4stream
github.com/pkg/errors from github.com/gorilla/csrf
LD github.com/pkg/sftp from tailscale.com/ssh/tailssh
LD github.com/pkg/sftp/internal/encoding/ssh/filexfer from github.com/pkg/sftp
W 💣 github.com/tailscale/certstore from tailscale.com/control/controlclient
W 💣 github.com/tailscale/go-winio from tailscale.com/safesocket
W 💣 github.com/tailscale/go-winio/internal/fs from github.com/tailscale/go-winio
W 💣 github.com/tailscale/go-winio/internal/socket from github.com/tailscale/go-winio
W github.com/tailscale/go-winio/internal/stringbuffer from github.com/tailscale/go-winio/internal/fs
W github.com/tailscale/go-winio/pkg/guid from github.com/tailscale/go-winio+
github.com/tailscale/golang-x-crypto/acme from tailscale.com/ipn/ipnlocal
LD github.com/tailscale/golang-x-crypto/chacha20 from github.com/tailscale/golang-x-crypto/ssh
LD 💣 github.com/tailscale/golang-x-crypto/internal/alias from github.com/tailscale/golang-x-crypto/chacha20
@@ -152,7 +149,6 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
github.com/tailscale/goupnp/ssdp from github.com/tailscale/goupnp
github.com/tailscale/hujson from tailscale.com/ipn/conffile
L 💣 github.com/tailscale/netlink from tailscale.com/wgengine/router+
github.com/tailscale/web-client-prebuilt from tailscale.com/client/web
💣 github.com/tailscale/wireguard-go/conn from github.com/tailscale/wireguard-go/device+
W 💣 github.com/tailscale/wireguard-go/conn/winrio from github.com/tailscale/wireguard-go/conn
💣 github.com/tailscale/wireguard-go/device from tailscale.com/net/tstun+
@@ -221,12 +217,10 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
nhooyr.io/websocket/internal/errd from nhooyr.io/websocket
nhooyr.io/websocket/internal/xsync from nhooyr.io/websocket
tailscale.com from tailscale.com/version
tailscale.com/appc from tailscale.com/ipn/ipnlocal
tailscale.com/atomicfile from tailscale.com/ipn+
LD tailscale.com/chirp from tailscale.com/cmd/tailscaled
tailscale.com/client/tailscale from tailscale.com/derp+
tailscale.com/client/tailscale from tailscale.com/derp
tailscale.com/client/tailscale/apitype from tailscale.com/ipn/ipnlocal+
tailscale.com/client/web from tailscale.com/ipn/ipnlocal
tailscale.com/clientupdate from tailscale.com/ipn/ipnlocal
tailscale.com/clientupdate/distsign from tailscale.com/clientupdate
tailscale.com/cmd/tailscaled/childproc from tailscale.com/ssh/tailssh+
@@ -257,7 +251,6 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
L tailscale.com/ipn/store/kubestore from tailscale.com/ipn/store
tailscale.com/ipn/store/mem from tailscale.com/ipn/store+
L tailscale.com/kube from tailscale.com/ipn/store/kubestore
tailscale.com/licenses from tailscale.com/client/web
tailscale.com/log/filelogger from tailscale.com/logpolicy
tailscale.com/log/sockstatlog from tailscale.com/ipn/ipnlocal
tailscale.com/logpolicy from tailscale.com/cmd/tailscaled+
@@ -270,7 +263,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/net/dns/publicdns from tailscale.com/net/dns/resolver+
tailscale.com/net/dns/recursive from tailscale.com/net/dnsfallback
tailscale.com/net/dns/resolvconffile from tailscale.com/net/dns+
tailscale.com/net/dns/resolver from tailscale.com/net/dns
tailscale.com/net/dns/resolver from tailscale.com/ipn/ipnlocal+
tailscale.com/net/dnscache from tailscale.com/control/controlclient+
tailscale.com/net/dnsfallback from tailscale.com/control/controlclient+
tailscale.com/net/flowtrack from tailscale.com/net/packet+
@@ -304,7 +297,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
💣 tailscale.com/portlist from tailscale.com/ipn/ipnlocal
tailscale.com/posture from tailscale.com/ipn/ipnlocal
tailscale.com/proxymap from tailscale.com/tsd+
💣 tailscale.com/safesocket from tailscale.com/client/tailscale+
tailscale.com/safesocket from tailscale.com/client/tailscale+
tailscale.com/smallzstd from tailscale.com/control/controlclient+
LD 💣 tailscale.com/ssh/tailssh from tailscale.com/cmd/tailscaled
tailscale.com/syncs from tailscale.com/net/netcheck+
@@ -320,7 +313,6 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
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/appctype from tailscale.com/ipn/ipnlocal
tailscale.com/types/dnstype from tailscale.com/ipn/ipnlocal+
tailscale.com/types/empty from tailscale.com/ipn+
tailscale.com/types/flagtype from tailscale.com/cmd/tailscaled
@@ -347,7 +339,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
L 💣 tailscale.com/util/dirwalk from tailscale.com/metrics+
tailscale.com/util/dnsname from tailscale.com/hostinfo+
tailscale.com/util/goroutines from tailscale.com/ipn/ipnlocal
tailscale.com/util/groupmember from tailscale.com/ipn/ipnauth+
tailscale.com/util/groupmember from tailscale.com/ipn/ipnauth
💣 tailscale.com/util/hashx from tailscale.com/util/deephash
tailscale.com/util/httphdr from tailscale.com/ipn/ipnlocal+
tailscale.com/util/httpm from tailscale.com/client/tailscale+
@@ -360,6 +352,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
💣 tailscale.com/util/osdiag from tailscale.com/cmd/tailscaled+
W 💣 tailscale.com/util/osdiag/internal/wsc from tailscale.com/util/osdiag
tailscale.com/util/osshare from tailscale.com/ipn/ipnlocal+
W tailscale.com/util/pidowner from tailscale.com/ipn/ipnauth
tailscale.com/util/race from tailscale.com/net/dns/resolver
tailscale.com/util/racebuild from tailscale.com/logpolicy
tailscale.com/util/rands from tailscale.com/ipn/ipnlocal+
@@ -476,7 +469,6 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
encoding/base32 from tailscale.com/tka+
encoding/base64 from encoding/json+
encoding/binary from compress/gzip+
encoding/gob from github.com/gorilla/securecookie
encoding/hex from crypto/x509+
encoding/json from expvar+
encoding/pem from crypto/tls+
@@ -491,7 +483,6 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
hash/fnv from tailscale.com/wgengine/magicsock+
hash/maphash from go4.org/mem
html from tailscale.com/ipn/ipnlocal+
html/template from github.com/gorilla/csrf
io from bufio+
io/fs from crypto/x509+
io/ioutil from github.com/godbus/dbus/v5+
@@ -536,8 +527,6 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
sync/atomic from context+
syscall from crypto/rand+
text/tabwriter from runtime/pprof
text/template from html/template
text/template/parse from html/template+
time from compress/gzip+
unicode from bytes+
unicode/utf16 from crypto/x509+

View File

@@ -29,7 +29,6 @@ import (
"syscall"
"time"
"tailscale.com/client/tailscale"
"tailscale.com/cmd/tailscaled/childproc"
"tailscale.com/control/controlclient"
"tailscale.com/envknob"
@@ -570,7 +569,6 @@ func getLocalBackend(ctx context.Context, logf logger.Logf, logID logid.PublicID
if root := lb.TailscaleVarRoot(); root != "" {
dnsfallback.SetCachePath(filepath.Join(root, "derpmap.cached.json"), logf)
}
lb.SetWebLocalClient(&tailscale.LocalClient{Socket: args.socketpath, UseSocketOnly: args.socketpath != ""})
configureTaildrop(logf, lb)
if err := ns.Start(lb); err != nil {
log.Fatalf("failed to start netstack: %v", err)

View File

@@ -30,7 +30,6 @@ import (
"os"
"os/exec"
"os/signal"
"path/filepath"
"sync"
"syscall"
"time"
@@ -300,14 +299,6 @@ func beWindowsSubprocess() bool {
}
}()
// Pre-load wintun.dll using a fully-qualified path so that wintun-go
// loads our copy and not some (possibly outdated) copy dropped in system32.
// (OSS Issue #10023)
fqWintunPath := fullyQualifiedWintunPath(log.Printf)
if _, err := windows.LoadDLL(fqWintunPath); err != nil {
log.Printf("Error pre-loading \"%s\": %v", fqWintunPath, err)
}
sys := new(tsd.System)
netMon, err := netmon.New(log.Printf)
if err != nil {
@@ -516,7 +507,7 @@ func babysitProc(ctx context.Context, args []string, logf logger.Logf) {
}
func uninstallWinTun(logf logger.Logf) {
dll := windows.NewLazyDLL(fullyQualifiedWintunPath(logf))
dll := windows.NewLazyDLL("wintun.dll")
if err := dll.Load(); err != nil {
logf("Cannot load wintun.dll for uninstall: %v", err)
return
@@ -526,16 +517,3 @@ func uninstallWinTun(logf logger.Logf) {
err := wintun.Uninstall()
logf("Uninstall: %v", err)
}
func fullyQualifiedWintunPath(logf logger.Logf) string {
var dir string
var buf [windows.MAX_PATH]uint16
length := uint32(len(buf))
if err := windows.QueryFullProcessImageName(windows.CurrentProcess(), 0, &buf[0], &length); err != nil {
logf("QueryFullProcessImageName failed: %v", err)
} else {
dir = filepath.Dir(windows.UTF16ToString(buf[:length]))
}
return filepath.Join(dir, "wintun.dll")
}

View File

@@ -481,7 +481,7 @@ func (mrs mapRoutineState) UpdateNetmapDelta(muts []netmap.NodeMutation) bool {
// control server, and keeping the netmap up to date.
func (c *Auto) mapRoutine() {
defer close(c.mapDone)
mrs := mapRoutineState{
mrs := &mapRoutineState{
c: c,
bo: backoff.NewBackoff("mapRoutine", c.logf, 30*time.Second),
}

View File

@@ -67,6 +67,7 @@ type Direct struct {
controlKnobs *controlknobs.Knobs // always non-nil
serverURL string // URL of the tailcontrol server
clock tstime.Clock
lastPrintMap time.Time
logf logger.Logf
netMon *netmon.Monitor // or nil
discoPubKey key.DiscoPublic
@@ -828,7 +829,7 @@ const watchdogTimeout = 120 * time.Second
// if the context expires or the server returns an error/closes the connection
// and as such always returns a non-nil error.
//
// If nu is nil, OmitPeers will be set to true.
// If cb is nil, OmitPeers will be set to true.
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")
@@ -968,6 +969,8 @@ func (c *Direct) sendMapRequest(ctx context.Context, isStreaming bool, nu Netmap
return nil
}
var mapResIdx int // 0 for first message, then 1+ for deltas
sess := newMapSession(persist.PrivateNodeKey(), nu, c.controlKnobs)
defer sess.Close()
sess.cancel = cancel
@@ -976,6 +979,17 @@ func (c *Direct) sendMapRequest(ctx context.Context, isStreaming bool, nu Netmap
sess.altClock = c.clock
sess.machinePubKey = machinePubKey
sess.onDebug = c.handleDebugMessage
sess.onConciseNetMapSummary = func(summary string) {
// Occasionally print the netmap header.
// This is handy for debugging, and our logs processing
// pipeline depends on it. (TODO: Remove this dependency.)
now := c.clock.Now()
if now.Sub(c.lastPrintMap) < 5*time.Minute {
return
}
c.lastPrintMap = now
c.logf("[v1] new network map[%d]:\n%s", mapResIdx, summary)
}
sess.onSelfNodeChanged = func(nm *netmap.NetworkMap) {
c.mu.Lock()
defer c.mu.Unlock()
@@ -992,27 +1006,7 @@ func (c *Direct) sendMapRequest(ctx context.Context, isStreaming bool, nu Netmap
}
c.expiry = nm.Expiry
}
// Create a watchdog timer that breaks the connection if we don't receive a
// MapResponse from the network at least once every two minutes. The
// watchdog timer is stopped every time we receive a MapResponse (so it
// doesn't run when we're processing a MapResponse message, including any
// long-running requested operations like Debug.Sleep) and is reset whenever
// we go back to blocking on network reads.
watchdogTimer, watchdogTimedOut := c.clock.NewTimer(watchdogTimeout)
defer watchdogTimer.Stop()
go func() {
select {
case <-ctx.Done():
vlogf("netmap: ending timeout goroutine")
return
case <-watchdogTimedOut:
c.logf("map response long-poll timed out!")
cancel()
return
}
}()
sess.StartWatchdog()
// gotNonKeepAliveMessage is whether we've yet received a MapResponse message without
// KeepAlive set.
@@ -1025,8 +1019,7 @@ func (c *Direct) sendMapRequest(ctx context.Context, isStreaming bool, nu Netmap
// the same format before just closing the connection.
// We can use this same read loop either way.
var msg []byte
for mapResIdx := 0; mapResIdx == 0 || isStreaming; mapResIdx++ {
watchdogTimer.Reset(watchdogTimeout)
for ; mapResIdx == 0 || isStreaming; mapResIdx++ {
vlogf("netmap: starting size read after %v (poll %v)", time.Since(t0).Round(time.Millisecond), mapResIdx)
var siz [4]byte
if _, err := io.ReadFull(res.Body, siz[:]); err != nil {
@@ -1047,7 +1040,6 @@ func (c *Direct) sendMapRequest(ctx context.Context, isStreaming bool, nu Netmap
vlogf("netmap: decode error: %v")
return err
}
watchdogTimer.Stop()
metricMapResponseMessages.Add(1)
@@ -1090,6 +1082,14 @@ func (c *Direct) sendMapRequest(ctx context.Context, isStreaming bool, nu Netmap
c.logf("netmap: [unexpected] new dial plan; nowhere to store it")
}
}
select {
case sess.watchdogReset <- struct{}{}:
vlogf("netmap: sent timer reset")
case <-ctx.Done():
c.logf("[v1] netmap: not resetting timer; context done: %v", ctx.Err())
return ctx.Err()
}
if resp.KeepAlive {
metricMapResponseKeepAlives.Add(1)
continue
@@ -1116,7 +1116,7 @@ func (c *Direct) sendMapRequest(ctx context.Context, isStreaming bool, nu Netmap
return nil
}
func (c *Direct) handleDebugMessage(ctx context.Context, debug *tailcfg.Debug) error {
func (c *Direct) handleDebugMessage(ctx context.Context, debug *tailcfg.Debug, watchdogReset chan<- struct{}) error {
if code := debug.Exit; code != nil {
c.logf("exiting process with status %v per controlplane", *code)
os.Exit(*code)
@@ -1126,7 +1126,7 @@ func (c *Direct) handleDebugMessage(ctx context.Context, debug *tailcfg.Debug) e
envknob.SetNoLogsNoSupport()
}
if sleep := time.Duration(debug.SleepSeconds * float64(time.Second)); sleep > 0 {
if err := sleepAsRequested(ctx, c.logf, sleep, c.clock); err != nil {
if err := sleepAsRequested(ctx, c.logf, watchdogReset, sleep, c.clock); err != nil {
return err
}
}
@@ -1458,7 +1458,7 @@ func answerC2NPing(logf logger.Logf, c2nHandler http.Handler, c *http.Client, pr
// that the client sleep. The complication is that while we're sleeping (if for
// a long time), we need to periodically reset the watchdog timer before it
// expires.
func sleepAsRequested(ctx context.Context, logf logger.Logf, d time.Duration, clock tstime.Clock) error {
func sleepAsRequested(ctx context.Context, logf logger.Logf, watchdogReset chan<- struct{}, d time.Duration, clock tstime.Clock) error {
const maxSleep = 5 * time.Minute
if d > maxSleep {
logf("sleeping for %v, capped from server-requested %v ...", maxSleep, d)
@@ -1467,13 +1467,25 @@ func sleepAsRequested(ctx context.Context, logf logger.Logf, d time.Duration, cl
logf("sleeping for server-requested %v ...", d)
}
ticker, tickerChannel := clock.NewTicker(watchdogTimeout / 2)
defer ticker.Stop()
timer, timerChannel := clock.NewTimer(d)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timerChannel:
return nil
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-timerChannel:
return nil
case <-tickerChannel:
select {
case watchdogReset <- struct{}{}:
case <-timerChannel:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
}
}

View File

@@ -49,6 +49,7 @@ type mapSession struct {
machinePubKey key.MachinePublic
altClock tstime.Clock // if nil, regular time is used
cancel context.CancelFunc // always non-nil, shuts down caller's base long poll context
watchdogReset chan struct{} // send to request that the long poll activity watchdog timeout be reset
// sessionAliveCtx is a Background-based context that's alive for the
// duration of the mapSession that we own the lifetime of. It's closed by
@@ -56,19 +57,22 @@ type mapSession struct {
sessionAliveCtx context.Context
sessionAliveCtxClose context.CancelFunc // closes sessionAliveCtx
// Optional hooks, guaranteed non-nil (set to no-op funcs) by the
// newMapSession constructor. They must be overridden if desired
// before the mapSession is used.
// Optional hooks, set once before use.
// onDebug specifies what to do with a *tailcfg.Debug message.
onDebug func(context.Context, *tailcfg.Debug) error
// If the watchdogReset chan is nil, it's not used. Otherwise it can be sent to
// to request that the long poll activity watchdog timeout be reset.
onDebug func(_ context.Context, _ *tailcfg.Debug, watchdogReset chan<- struct{}) error
// onConciseNetMapSummary, if non-nil, is called with the Netmap.VeryConcise summary
// whenever a map response is received.
onConciseNetMapSummary func(string)
// onSelfNodeChanged is called before the NetmapUpdater if the self node was
// changed.
onSelfNodeChanged func(*netmap.NetworkMap)
// Fields storing state over the course of multiple MapResponses.
lastPrintMap time.Time
lastNode tailcfg.NodeView
peers map[tailcfg.NodeID]*tailcfg.NodeView // pointer to view (oddly). same pointers as sortedPeers.
sortedPeers []*tailcfg.NodeView // same pointers as peers, but sorted by Node.ID
@@ -101,36 +105,56 @@ func newMapSession(privateNodeKey key.NodePrivate, nu NetmapUpdater, controlKnob
publicNodeKey: privateNodeKey.Public(),
lastDNSConfig: new(tailcfg.DNSConfig),
lastUserProfile: map[tailcfg.UserID]tailcfg.UserProfile{},
watchdogReset: make(chan struct{}),
// Non-nil no-op defaults, to be optionally overridden by the caller.
logf: logger.Discard,
vlogf: logger.Discard,
cancel: func() {},
onDebug: func(context.Context, *tailcfg.Debug) error { return nil },
onSelfNodeChanged: func(*netmap.NetworkMap) {},
logf: logger.Discard,
vlogf: logger.Discard,
cancel: func() {},
onDebug: func(context.Context, *tailcfg.Debug, chan<- struct{}) error { return nil },
onConciseNetMapSummary: func(string) {},
onSelfNodeChanged: func(*netmap.NetworkMap) {},
}
ms.sessionAliveCtx, ms.sessionAliveCtxClose = context.WithCancel(context.Background())
return ms
}
// occasionallyPrintSummary logs summary at most once very 5 minutes. The
// summary is the Netmap.VeryConcise result from the last received map response.
func (ms *mapSession) occasionallyPrintSummary(summary string) {
// Occasionally print the netmap header.
// This is handy for debugging, and our logs processing
// pipeline depends on it. (TODO: Remove this dependency.)
now := ms.clock().Now()
if now.Sub(ms.lastPrintMap) < 5*time.Minute {
return
}
ms.lastPrintMap = now
ms.logf("[v1] new network map (periodic):\n%s", summary)
}
func (ms *mapSession) clock() tstime.Clock {
return cmpx.Or[tstime.Clock](ms.altClock, tstime.StdClock{})
}
// StartWatchdog starts the session's watchdog timer.
// If there's no activity in too long, it tears down the connection.
// Call Close to release these resources.
func (ms *mapSession) StartWatchdog() {
timer, timedOutChan := ms.clock().NewTimer(watchdogTimeout)
go func() {
defer timer.Stop()
for {
select {
case <-ms.sessionAliveCtx.Done():
ms.vlogf("netmap: ending timeout goroutine")
return
case <-timedOutChan:
ms.logf("map response long-poll timed out!")
ms.cancel()
return
case <-ms.watchdogReset:
if !timer.Stop() {
select {
case <-timedOutChan:
case <-ms.sessionAliveCtx.Done():
ms.vlogf("netmap: ending timeout goroutine")
return
}
}
ms.vlogf("netmap: reset timeout timer")
timer.Reset(watchdogTimeout)
}
}
}()
}
func (ms *mapSession) Close() {
ms.sessionAliveCtxClose()
}
@@ -145,7 +169,7 @@ func (ms *mapSession) Close() {
// is [re]factoring progress enough.
func (ms *mapSession) HandleNonKeepAliveMapResponse(ctx context.Context, resp *tailcfg.MapResponse) error {
if debug := resp.Debug; debug != nil {
if err := ms.onDebug(ctx, debug); err != nil {
if err := ms.onDebug(ctx, debug, ms.watchdogReset); err != nil {
return err
}
}
@@ -176,7 +200,7 @@ func (ms *mapSession) HandleNonKeepAliveMapResponse(ctx context.Context, resp *t
ms.updateStateFromResponse(resp)
if ms.tryHandleIncrementally(resp) {
ms.occasionallyPrintSummary(ms.lastNetmapSummary)
ms.onConciseNetMapSummary(ms.lastNetmapSummary) // every 5s log
return nil
}
@@ -186,7 +210,7 @@ func (ms *mapSession) HandleNonKeepAliveMapResponse(ctx context.Context, resp *t
nm := ms.netmap()
ms.lastNetmapSummary = nm.VeryConcise()
ms.occasionallyPrintSummary(ms.lastNetmapSummary)
ms.onConciseNetMapSummary(ms.lastNetmapSummary)
// If the self node changed, we might need to update persist.
if resp.Node != nil {

View File

@@ -56,12 +56,6 @@ type Client struct {
MeshKey string // optional; for trusted clients
IsProber bool // optional; for probers to optional declare themselves as such
// WatchConnectionChanges is whether the client wishes to subscribe to
// notifications about clients connecting & disconnecting.
//
// Only trusted connections (using MeshKey) are allowed to use this.
WatchConnectionChanges bool
// BaseContext, if non-nil, returns the base context to use for dialing a
// new derp server. If nil, context.Background is used.
// In either case, additional timeouts may be added to the base context.
@@ -86,7 +80,6 @@ type Client struct {
addrFamSelAtomic syncs.AtomicValue[AddressFamilySelector]
mu sync.Mutex
started bool // true upon first connect, never transitions to false
preferred bool
canAckPings bool
closed bool
@@ -100,7 +93,7 @@ type Client struct {
}
func (c *Client) String() string {
return fmt.Sprintf("<derphttp_client.Client %s url=%s>", c.ServerPublicKey().ShortString(), c.url)
return fmt.Sprintf("<derphttp_client.Client %s url=%s>", c.serverPubKey.ShortString(), c.url)
}
// NewRegionClient returns a new DERP-over-HTTP client. It connects lazily.
@@ -149,15 +142,6 @@ func NewClient(privateKey key.NodePrivate, serverURL string, logf logger.Logf) (
return c, nil
}
// isStarted reports whether this client has been used yet.
//
// If if reports false, it may still have its exported fields configured.
func (c *Client) isStarted() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.started
}
// Connect connects or reconnects to the server, unless already connected.
// It returns nil if there was already a good connection, or if one was made.
func (c *Client) Connect(ctx context.Context) error {
@@ -242,7 +226,7 @@ func (c *Client) useHTTPS() bool {
// tlsServerName returns the tls.Config.ServerName value (for the TLS ClientHello).
func (c *Client) tlsServerName(node *tailcfg.DERPNode) string {
if c.url != nil {
return c.url.Hostname()
return c.url.Host
}
return node.HostName
}
@@ -300,7 +284,6 @@ func useWebsockets() bool {
func (c *Client) connect(ctx context.Context, caller string) (client *derp.Client, connGen int, err error) {
c.mu.Lock()
defer c.mu.Unlock()
c.started = true
if c.closed {
return nil, 0, ErrClientClosed
}
@@ -512,13 +495,6 @@ func (c *Client) connect(ctx context.Context, caller string) (client *derp.Clien
}
}
if c.WatchConnectionChanges {
if err := derpClient.WatchConnectionChanges(); err != nil {
go httpConn.Close()
return nil, 0, err
}
}
c.serverPubKey = derpClient.ServerPublicKey()
c.client = derpClient
c.netConn = tcpConn
@@ -980,6 +956,22 @@ func (c *Client) NotePreferred(v bool) {
}
}
// WatchConnectionChanges sends a request to subscribe to
// notifications about clients connecting & disconnecting.
//
// Only trusted connections (using MeshKey) are allowed to use this.
func (c *Client) WatchConnectionChanges() error {
client, _, err := c.connect(c.newContext(), "derphttp.Client.WatchConnectionChanges")
if err != nil {
return err
}
err = client.WatchConnectionChanges()
if err != nil {
c.closeForReconnect(client)
}
return err
}
// ClosePeer asks the server to close target's TCP connection.
//
// Only trusted connections (using MeshKey) are allowed to use this.

View File

@@ -9,7 +9,6 @@ import (
"crypto/tls"
"net"
"net/http"
"net/netip"
"sync"
"testing"
"time"
@@ -207,243 +206,3 @@ func TestPing(t *testing.T) {
t.Fatalf("Ping: %v", err)
}
}
func newTestServer(t *testing.T, k key.NodePrivate) (serverURL string, s *derp.Server) {
s = derp.NewServer(k, t.Logf)
httpsrv := &http.Server{
TLSNextProto: make(map[string]func(*http.Server, *tls.Conn, http.Handler)),
Handler: Handler(s),
}
ln, err := net.Listen("tcp4", "localhost:0")
if err != nil {
t.Fatal(err)
}
serverURL = "http://" + ln.Addr().String()
s.SetMeshKey("1234")
go func() {
if err := httpsrv.Serve(ln); err != nil {
if err == http.ErrServerClosed {
t.Logf("server closed")
return
}
panic(err)
}
}()
return
}
func newWatcherClient(t *testing.T, watcherPrivateKey key.NodePrivate, serverToWatchURL string) (c *Client) {
c, err := NewClient(watcherPrivateKey, serverToWatchURL, t.Logf)
if err != nil {
t.Fatal(err)
}
c.MeshKey = "1234"
return
}
// breakConnection breaks the connection, which should trigger a reconnect.
func (c *Client) breakConnection(brokenClient *derp.Client) {
c.mu.Lock()
defer c.mu.Unlock()
if c.client != brokenClient {
return
}
if c.netConn != nil {
c.netConn.Close()
c.netConn = nil
}
c.client = nil
}
// Test that a watcher connection successfully reconnects and processes peer
// updates after a different thread breaks and reconnects the connection, while
// the watcher is waiting on recv().
func TestBreakWatcherConnRecv(t *testing.T) {
// Set the wait time before a retry after connection failure to be much lower.
// This needs to be early in the test, for defer to run right at the end after
// the DERP client has finished.
origRetryInterval := retryInterval
retryInterval = 50 * time.Millisecond
defer func() { retryInterval = origRetryInterval }()
var wg sync.WaitGroup
defer wg.Wait()
// Make the watcher server
serverPrivateKey1 := key.NewNode()
_, s1 := newTestServer(t, serverPrivateKey1)
defer s1.Close()
// Make the watched server
serverPrivateKey2 := key.NewNode()
serverURL2, s2 := newTestServer(t, serverPrivateKey2)
defer s2.Close()
// Make the watcher (but it is not connected yet)
watcher1 := newWatcherClient(t, serverPrivateKey1, serverURL2)
defer watcher1.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
watcherChan := make(chan int, 1)
// Start the watcher thread (which connects to the watched server)
wg.Add(1) // To avoid using t.Logf after the test ends. See https://golang.org/issue/40343
go func() {
defer wg.Done()
var peers int
add := func(k key.NodePublic, _ netip.AddrPort) {
t.Logf("add: %v", k.ShortString())
peers++
// Signal that the watcher has run
watcherChan <- peers
}
remove := func(k key.NodePublic) { t.Logf("remove: %v", k.ShortString()); peers-- }
watcher1.RunWatchConnectionLoop(ctx, serverPrivateKey1.Public(), t.Logf, add, remove)
}()
timer := time.NewTimer(5 * time.Second)
defer timer.Stop()
// Wait for the watcher to run, then break the connection and check if it
// reconnected and received peer updates.
for i := 0; i < 10; i++ {
select {
case peers := <-watcherChan:
if peers != 1 {
t.Fatal("wrong number of peers added during watcher connection")
}
case <-timer.C:
t.Fatalf("watcher did not process the peer update")
}
watcher1.breakConnection(watcher1.client)
// re-establish connection by sending a packet
watcher1.ForwardPacket(key.NodePublic{}, key.NodePublic{}, []byte("bogus"))
timer.Reset(5 * time.Second)
}
}
// Test that a watcher connection successfully reconnects and processes peer
// updates after a different thread breaks and reconnects the connection, while
// the watcher is not waiting on recv().
func TestBreakWatcherConn(t *testing.T) {
// Set the wait time before a retry after connection failure to be much lower.
// This needs to be early in the test, for defer to run right at the end after
// the DERP client has finished.
origRetryInterval := retryInterval
retryInterval = 50 * time.Millisecond
defer func() { retryInterval = origRetryInterval }()
var wg sync.WaitGroup
defer wg.Wait()
// Make the watcher server
serverPrivateKey1 := key.NewNode()
_, s1 := newTestServer(t, serverPrivateKey1)
defer s1.Close()
// Make the watched server
serverPrivateKey2 := key.NewNode()
serverURL2, s2 := newTestServer(t, serverPrivateKey2)
defer s2.Close()
// Make the watcher (but it is not connected yet)
watcher1 := newWatcherClient(t, serverPrivateKey1, serverURL2)
defer watcher1.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
watcherChan := make(chan int, 1)
breakerChan := make(chan bool, 1)
// Start the watcher thread (which connects to the watched server)
wg.Add(1) // To avoid using t.Logf after the test ends. See https://golang.org/issue/40343
go func() {
defer wg.Done()
var peers int
add := func(k key.NodePublic, _ netip.AddrPort) {
t.Logf("add: %v", k.ShortString())
peers++
// Signal that the watcher has run
watcherChan <- peers
// Wait for breaker to run
<-breakerChan
}
remove := func(k key.NodePublic) { t.Logf("remove: %v", k.ShortString()); peers-- }
watcher1.RunWatchConnectionLoop(ctx, serverPrivateKey1.Public(), t.Logf, add, remove)
}()
timer := time.NewTimer(5 * time.Second)
defer timer.Stop()
// Wait for the watcher to run, then break the connection and check if it
// reconnected and received peer updates.
for i := 0; i < 10; i++ {
select {
case peers := <-watcherChan:
if peers != 1 {
t.Fatal("wrong number of peers added during watcher connection")
}
case <-timer.C:
t.Fatalf("watcher did not process the peer update")
}
watcher1.breakConnection(watcher1.client)
// re-establish connection by sending a packet
watcher1.ForwardPacket(key.NodePublic{}, key.NodePublic{}, []byte("bogus"))
// signal that the breaker is done
breakerChan <- true
timer.Reset(5 * time.Second)
}
}
func noopAdd(key.NodePublic, netip.AddrPort) {}
func noopRemove(key.NodePublic) {}
func TestRunWatchConnectionLoopServeConnect(t *testing.T) {
defer func() { testHookWatchLookConnectResult = nil }()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
priv := key.NewNode()
serverURL, s := newTestServer(t, priv)
defer s.Close()
pub := priv.Public()
watcher := newWatcherClient(t, priv, serverURL)
defer watcher.Close()
// Test connecting to ourselves, and that we get hung up on.
testHookWatchLookConnectResult = func(err error, wasSelfConnect bool) bool {
t.Helper()
if err != nil {
t.Fatalf("error connecting to server: %v", err)
}
if !wasSelfConnect {
t.Error("wanted self-connect; wasn't")
}
return false
}
watcher.RunWatchConnectionLoop(ctx, pub, t.Logf, noopAdd, noopRemove)
// Test connecting to the server with a zero value for ignoreServerKey,
// so we should always connect.
testHookWatchLookConnectResult = func(err error, wasSelfConnect bool) bool {
t.Helper()
if err != nil {
t.Fatalf("error connecting to server: %v", err)
}
if wasSelfConnect {
t.Error("wanted normal connect; got self connect")
}
return false
}
watcher.RunWatchConnectionLoop(ctx, key.NodePublic{}, t.Logf, noopAdd, noopRemove)
}

View File

@@ -14,40 +14,25 @@ import (
"tailscale.com/types/logger"
)
var retryInterval = 5 * time.Second
// testHookWatchLookConnectResult, if non-nil for tests, is called by RunWatchConnectionLoop
// with the connect result. If it returns false, the loop ends.
var testHookWatchLookConnectResult func(connectError error, wasSelfConnect bool) (keepRunning bool)
// RunWatchConnectionLoop loops until ctx is done, sending
// WatchConnectionChanges and subscribing to connection changes.
// RunWatchConnectionLoop loops until ctx is done, sending WatchConnectionChanges and subscribing to
// connection changes.
//
// If the server's public key is ignoreServerKey, RunWatchConnectionLoop
// returns.
// If the server's public key is ignoreServerKey, RunWatchConnectionLoop returns.
//
// Otherwise, the add and remove funcs are called as clients come & go.
//
// infoLogf, if non-nil, is the logger to write periodic status updates about
// how many peers are on the server. Error log output is set to the c's logger,
// regardless of infoLogf's value.
// infoLogf, if non-nil, is the logger to write periodic status
// updates about how many peers are on the server. Error log output is
// set to the c's logger, regardless of infoLogf's value.
//
// To force RunWatchConnectionLoop to return quickly, its ctx needs to be
// closed, and c itself needs to be closed.
//
// It is a fatal error to call this on an already-started Client withoutq having
// initialized Client.WatchConnectionChanges to true.
// 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 func(key.NodePublic, netip.AddrPort), remove func(key.NodePublic)) {
if !c.WatchConnectionChanges {
if c.isStarted() {
panic("invalid use of RunWatchConnectionLoop on already-started Client without setting Client.RunWatchConnectionLoop")
}
c.WatchConnectionChanges = true
}
if infoLogf == nil {
infoLogf = logger.Discard
}
logf := c.logf
const retryInterval = 5 * time.Second
const statusInterval = 10 * time.Second
var (
mu sync.Mutex
@@ -116,21 +101,15 @@ func (c *Client) RunWatchConnectionLoop(ctx context.Context, ignoreServerKey key
}
for ctx.Err() == nil {
// Make sure we're connected before calling s.ServerPublicKey.
_, _, err := c.connect(ctx, "RunWatchConnectionLoop")
err := c.WatchConnectionChanges()
if err != nil {
if f := testHookWatchLookConnectResult; f != nil && !f(err, false) {
return
}
logf("mesh connect: %v", err)
clear()
logf("WatchConnectionChanges: %v", err)
sleep(retryInterval)
continue
}
selfConnect := c.ServerPublicKey() == ignoreServerKey
if f := testHookWatchLookConnectResult; f != nil && !f(err, selfConnect) {
return
}
if selfConnect {
if c.ServerPublicKey() == ignoreServerKey {
logf("detected self-connect; ignoring host")
return
}

View File

@@ -41,6 +41,14 @@
};
outputs = { self, nixpkgs, flake-utils, flake-compat }: let
# Grab a helper func out of the Nix language libraries. Annoyingly
# these are only accessible through legacyPackages right now,
# which forces us to indirect through a platform-specific
# path. The x86_64-linux in here doesn't really matter, since all
# we're grabbing is a pure Nix string manipulation function that
# doesn't build any software.
fileContents = nixpkgs.legacyPackages.x86_64-linux.lib.fileContents;
# tailscaleRev is the git commit at which this flake was imported,
# or the empty string when building from a local checkout of the
# tailscale repo.
@@ -66,29 +74,17 @@
name = "tailscale";
src = ./.;
vendorSha256 = pkgs.lib.fileContents ./go.mod.sri;
nativeBuildInputs = pkgs.lib.optionals pkgs.stdenv.isLinux [ pkgs.makeWrapper ];
vendorSha256 = fileContents ./go.mod.sri;
nativeBuildInputs = pkgs.lib.optionals pkgs.stdenv.isLinux [ pkgs.makeWrapper pkgs.git ];
ldflags = ["-X tailscale.com/version.GitCommit=${tailscaleRev}"];
CGO_ENABLED = 0;
subPackages = [ "cmd/tailscale" "cmd/tailscaled" ];
doCheck = false;
# NOTE: We strip the ${PORT} and $FLAGS because they are unset in the
# environment and cause issues (specifically the unset PORT). At some
# point, there should be a NixOS module that allows configuration of these
# things, but for now, we hardcode the default of port 41641 (taken from
# ./cmd/tailscaled/tailscaled.defaults).
postInstall = pkgs.lib.optionalString pkgs.stdenv.isLinux ''
wrapProgram $out/bin/tailscaled --prefix PATH : ${pkgs.lib.makeBinPath [ pkgs.iproute2 pkgs.iptables pkgs.getent pkgs.shadow ]}
wrapProgram $out/bin/tailscale --suffix PATH : ${pkgs.lib.makeBinPath [ pkgs.procps ]}
sed -i \
-e "s#/usr/sbin#$out/bin#" \
-e "/^EnvironmentFile/d" \
-e 's/''${PORT}/41641/' \
-e 's/$FLAGS//' \
./cmd/tailscaled/tailscaled.service
sed -i -e "s#/usr/sbin#$out/bin#" -e "/^EnvironmentFile/d" ./cmd/tailscaled/tailscaled.service
install -D -m0444 -t $out/lib/systemd/system ./cmd/tailscaled/tailscaled.service
'';
};
@@ -101,7 +97,6 @@
ts = tailscale pkgs;
in {
packages = {
default = ts;
tailscale = ts;
};
devShell = pkgs.mkShell {
@@ -120,4 +115,4 @@
in
flake-utils.lib.eachDefaultSystem (system: flakeForSystem nixpkgs system);
}
# nix-direnv cache busting line: sha256-ynBPVRKWPcoEbbZ/atvO6VdjHVwhnWcugSD3RGJA34E=
# nix-direnv cache busting line: sha256-tzMLCNvIjG5e2aslmMt8GWgnfImd0J2a11xutOe59Ss=

9
go.mod
View File

@@ -4,6 +4,7 @@ go 1.21
require (
filippo.io/mkcert v1.4.4
github.com/Microsoft/go-winio v0.6.1
github.com/akutz/memconn v0.1.0
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa
github.com/andybalholm/brotli v1.0.5
@@ -65,8 +66,8 @@ require (
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a
github.com/tailscale/mkctr v0.0.0-20220601142259-c0b937af2e89
github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85
github.com/tailscale/web-client-prebuilt v0.0.0-20231103075435-8a84ac6b1db2
github.com/tailscale/wireguard-go v0.0.0-20231101022006-db7604d1aa90
github.com/tailscale/web-client-prebuilt v0.0.0-20230919211114-7bcd7bca7bc5
github.com/tailscale/wireguard-go v0.0.0-20230929223258-2f6748dc88e7
github.com/tc-hib/winres v0.2.1
github.com/tcnksm/go-httpstat v0.2.0
github.com/toqueteos/webbrowser v1.2.0
@@ -103,7 +104,6 @@ require (
)
require (
github.com/Microsoft/go-winio v0.6.1 // indirect
github.com/google/gnostic-models v0.6.9-0.20230804172637-c7be7c783f49 // indirect
github.com/gorilla/securecookie v1.1.1 // indirect
)
@@ -166,7 +166,7 @@ require (
github.com/denis-tingaikin/go-header v0.4.3 // indirect
github.com/docker/cli v24.0.6+incompatible // indirect
github.com/docker/distribution v2.8.2+incompatible // indirect
github.com/docker/docker v24.0.7+incompatible // indirect
github.com/docker/docker v24.0.6+incompatible // indirect
github.com/docker/docker-credential-helpers v0.8.0 // indirect
github.com/emicklei/go-restful/v3 v3.11.0 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
@@ -321,7 +321,6 @@ require (
github.com/stretchr/testify v1.8.4 // indirect
github.com/subosito/gotenv v1.4.2 // indirect
github.com/t-yuki/gocover-cobertura v0.0.0-20180217150009-aaee18c8195c // indirect
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55
github.com/tdakkota/asciicheck v0.2.0 // indirect
github.com/tetafro/godot v1.4.11 // indirect
github.com/timakin/bodyclose v0.0.0-20230421092635-574207250966 // indirect

View File

@@ -1 +1 @@
sha256-ynBPVRKWPcoEbbZ/atvO6VdjHVwhnWcugSD3RGJA34E=
sha256-tzMLCNvIjG5e2aslmMt8GWgnfImd0J2a11xutOe59Ss=

14
go.sum
View File

@@ -239,8 +239,8 @@ github.com/docker/cli v24.0.6+incompatible h1:fF+XCQCgJjjQNIMjzaSmiKJSCcfcXb3TWT
github.com/docker/cli v24.0.6+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/distribution v2.8.2+incompatible h1:T3de5rq0dB1j30rp0sA2rER+m322EBzniBPB6ZIzuh8=
github.com/docker/distribution v2.8.2+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
github.com/docker/docker v24.0.7+incompatible h1:Wo6l37AuwP3JaMnZa226lzVXGA3F9Ig1seQen0cKYlM=
github.com/docker/docker v24.0.7+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/docker v24.0.6+incompatible h1:hceabKCtUgDqPu+qm0NgsaXf28Ljf4/pWFL7xjWWDgE=
github.com/docker/docker v24.0.6+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/docker-credential-helpers v0.8.0 h1:YQFtbBQb4VrpoPxhFuzEBPQ9E16qz5SpHLS+uswaCp8=
github.com/docker/docker-credential-helpers v0.8.0/go.mod h1:UGFXcuoQ5TxPiB54nHOZ32AWRqQdECoh/Mg0AlEYb40=
github.com/dsnet/try v0.0.3 h1:ptR59SsrcFUYbT/FhAbKTV6iLkeD6O18qfIWRml2fqI=
@@ -868,8 +868,6 @@ github.com/tailscale/certstore v0.1.1-0.20231020161753-77811a65f4ff h1:vnxdYZUJb
github.com/tailscale/certstore v0.1.1-0.20231020161753-77811a65f4ff/go.mod h1:XrBNfAFN+pwoWuksbFS9Ccxnopa15zJGgXRFN90l3K4=
github.com/tailscale/depaware v0.0.0-20210622194025-720c4b409502 h1:34icjjmqJ2HPjrSuJYEkdZ+0ItmGQAQ75cRHIiftIyE=
github.com/tailscale/depaware v0.0.0-20210622194025-720c4b409502/go.mod h1:p9lPsd+cx33L3H9nNoecRRxPssFKUwwI50I3pZ0yT+8=
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4=
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg=
github.com/tailscale/goexpect v0.0.0-20210902213824-6e8c725cea41 h1:/V2rCMMWcsjYaYO2MeovLw+ClP63OtXgCF2Y1eb8+Ns=
github.com/tailscale/goexpect v0.0.0-20210902213824-6e8c725cea41/go.mod h1:/roCdA6gg6lQyw/Oz6gIIGu3ggJKYhF+WC/AQReE5XQ=
github.com/tailscale/golang-x-crypto v0.0.0-20230713185742-f0b76a10a08e h1:JyeJF/HuSwvxWtsR1c0oKX1lzaSH5Wh4aX+MgiStaGQ=
@@ -882,10 +880,10 @@ github.com/tailscale/mkctr v0.0.0-20220601142259-c0b937af2e89 h1:7xU7AFQE83h0wz/
github.com/tailscale/mkctr v0.0.0-20220601142259-c0b937af2e89/go.mod h1:OGMqrTzDqmJkGumUTtOv44Rp3/4xS+QFbE8Rn0AGlaU=
github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85 h1:zrsUcqrG2uQSPhaUPjUQwozcRdDdSxxqhNgNZ3drZFk=
github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0=
github.com/tailscale/web-client-prebuilt v0.0.0-20231103075435-8a84ac6b1db2 h1:KzNItTAwrc5UmP+JpzRa8ZVNs0x/boBXleVXZq4qd/U=
github.com/tailscale/web-client-prebuilt v0.0.0-20231103075435-8a84ac6b1db2/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ=
github.com/tailscale/wireguard-go v0.0.0-20231101022006-db7604d1aa90 h1:lMGYrokOq9NKDw1UMBH7AsS4boZ41jcduvYaRIdedhE=
github.com/tailscale/wireguard-go v0.0.0-20231101022006-db7604d1aa90/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4=
github.com/tailscale/web-client-prebuilt v0.0.0-20230919211114-7bcd7bca7bc5 h1:wKUtQPRpjhZZvAuwYRMcjMZnpWSUEJWIbNJmLtDbR0k=
github.com/tailscale/web-client-prebuilt v0.0.0-20230919211114-7bcd7bca7bc5/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ=
github.com/tailscale/wireguard-go v0.0.0-20230929223258-2f6748dc88e7 h1:P1od5W+cX/LZZyvbKrNUXuuzxensnKEywLhxhPOeHuY=
github.com/tailscale/wireguard-go v0.0.0-20230929223258-2f6748dc88e7/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4=
github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA=
github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk=
github.com/tcnksm/go-httpstat v0.2.0 h1:rP7T5e5U2HfmOBmZzGgGZjBQ5/GluWUylujl0tJ04I0=

View File

@@ -1 +1 @@
56d25cd9a2efe0eee3945518fc532ec45912ccb2
d1c91593484a1db2d4de2564f2ef2669814af9c8

View File

@@ -53,6 +53,7 @@ func New() *tailcfg.Hostinfo {
GoVersion: runtime.Version(),
Machine: condCall(unameMachine),
DeviceModel: deviceModel(),
PushDeviceToken: pushDeviceToken(),
Cloud: string(cloudenv.Get()),
NoLogsNoSupport: envknob.NoLogsNoSupport(),
AllowsUpdate: envknob.AllowsRemoteUpdate(),
@@ -165,14 +166,18 @@ func GetEnvType() EnvType {
}
var (
deviceModelAtomic atomic.Value // of string
osVersionAtomic atomic.Value // of string
desktopAtomic atomic.Value // of opt.Bool
packagingType atomic.Value // of string
appType atomic.Value // of string
firewallMode atomic.Value // of string
pushDeviceTokenAtomic atomic.Value // of string
deviceModelAtomic atomic.Value // of string
osVersionAtomic atomic.Value // of string
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.
func SetPushDeviceToken(token string) { pushDeviceTokenAtomic.Store(token) }
// SetDeviceModel sets the device model for use in Hostinfo updates.
func SetDeviceModel(model string) { deviceModelAtomic.Store(model) }
@@ -198,6 +203,11 @@ func deviceModel() string {
return s
}
func pushDeviceToken() string {
s, _ := pushDeviceTokenAtomic.Load().(string)
return s
}
// FirewallMode returns the firewall mode for the app.
// It is empty if unset.
func FirewallMode() string {
@@ -335,7 +345,10 @@ func inAzureAppService() bool {
}
func inAWSFargate() bool {
return os.Getenv("AWS_EXECUTION_ENV") == "AWS_ECS_FARGATE"
if os.Getenv("AWS_EXECUTION_ENV") == "AWS_ECS_FARGATE" {
return true
}
return false
}
func inFlyDotIo() bool {
@@ -361,7 +374,10 @@ func inKubernetes() bool {
}
func inDockerDesktop() bool {
return os.Getenv("TS_HOST_ENV") == "dde"
if os.Getenv("TS_HOST_ENV") == "dde" {
return true
}
return false
}
func inHomeAssistantAddOn() bool {

View File

@@ -36,7 +36,6 @@ type ConfigVAlpha struct {
PostureChecking opt.Bool `json:",omitempty"`
RunSSHServer opt.Bool `json:",omitempty"` // Tailscale SSH
RunWebClient opt.Bool `json:",omitempty"`
ShieldsUp opt.Bool `json:",omitempty"`
AutoUpdate *AutoUpdatePrefs `json:",omitempty"`
ServeConfigTemp *ServeConfig `json:",omitempty"` // TODO(bradfitz,maisem): make separate stable type for this
@@ -114,10 +113,6 @@ func (c *ConfigVAlpha) ToPrefs() (MaskedPrefs, error) {
mp.RunSSH = c.RunSSHServer.EqualBool(true)
mp.RunSSHSet = true
}
if c.RunWebClient != "" {
mp.RunWebClient = c.RunWebClient.EqualBool(true)
mp.RunWebClientSet = true
}
if c.ShieldsUp != "" {
mp.ShieldsUp = c.ShieldsUp.EqualBool(true)
mp.ShieldsUpSet = true

View File

@@ -38,7 +38,6 @@ var _PrefsCloneNeedsRegeneration = Prefs(struct {
ExitNodeAllowLANAccess bool
CorpDNS bool
RunSSH bool
RunWebClient bool
WantRunning bool
LoggedOut bool
ShieldsUp bool
@@ -53,7 +52,6 @@ var _PrefsCloneNeedsRegeneration = Prefs(struct {
OperatorUser string
ProfileName string
AutoUpdate AutoUpdatePrefs
AppConnector AppConnectorPrefs
PostureChecking bool
Persist *persist.Persist
}{})

View File

@@ -71,7 +71,6 @@ func (v PrefsView) ExitNodeIP() netip.Addr { return v.ж.ExitNodeIP
func (v PrefsView) ExitNodeAllowLANAccess() bool { return v.ж.ExitNodeAllowLANAccess }
func (v PrefsView) CorpDNS() bool { return v.ж.CorpDNS }
func (v PrefsView) RunSSH() bool { return v.ж.RunSSH }
func (v PrefsView) RunWebClient() bool { return v.ж.RunWebClient }
func (v PrefsView) WantRunning() bool { return v.ж.WantRunning }
func (v PrefsView) LoggedOut() bool { return v.ж.LoggedOut }
func (v PrefsView) ShieldsUp() bool { return v.ж.ShieldsUp }
@@ -88,7 +87,6 @@ func (v PrefsView) NetfilterMode() preftype.NetfilterMode { return v.ж.Netfilte
func (v PrefsView) OperatorUser() string { return v.ж.OperatorUser }
func (v PrefsView) ProfileName() string { return v.ж.ProfileName }
func (v PrefsView) AutoUpdate() AutoUpdatePrefs { return v.ж.AutoUpdate }
func (v PrefsView) AppConnector() AppConnectorPrefs { return v.ж.AppConnector }
func (v PrefsView) PostureChecking() bool { return v.ж.PostureChecking }
func (v PrefsView) Persist() persist.PersistView { return v.ж.Persist.View() }
@@ -102,7 +100,6 @@ var _PrefsViewNeedsRegeneration = Prefs(struct {
ExitNodeAllowLANAccess bool
CorpDNS bool
RunSSH bool
RunWebClient bool
WantRunning bool
LoggedOut bool
ShieldsUp bool
@@ -117,7 +114,6 @@ var _PrefsViewNeedsRegeneration = Prefs(struct {
OperatorUser string
ProfileName string
AutoUpdate AutoUpdatePrefs
AppConnector AppConnectorPrefs
PostureChecking bool
Persist *persist.Persist
}{})

View File

@@ -5,9 +5,7 @@
package ipnauth
import (
"errors"
"fmt"
"io"
"net"
"net/netip"
"os"
@@ -27,35 +25,6 @@ import (
"tailscale.com/version/distro"
)
// ErrNotImplemented is returned by ConnIdentity.WindowsToken when it is not
// implemented for the current GOOS.
var ErrNotImplemented = errors.New("not implemented for GOOS=" + runtime.GOOS)
// WindowsToken represents the current security context of a Windows user.
type WindowsToken interface {
io.Closer
// EqualUIDs reports whether other refers to the same user ID as the receiver.
EqualUIDs(other WindowsToken) bool
// IsAdministrator reports whether the receiver is a member of the built-in
// Administrators group, or else an error. Use IsElevated to determine whether
// the receiver is actually utilizing administrative rights.
IsAdministrator() (bool, error)
// IsUID reports whether the receiver's user ID matches uid.
IsUID(uid ipn.WindowsUserID) bool
// UID returns the ipn.WindowsUserID associated with the receiver, or else
// an error.
UID() (ipn.WindowsUserID, error)
// IsElevated reports whether the receiver is currently executing as an
// elevated administrative user.
IsElevated() bool
// UserDir returns the special directory identified by folderID as associated
// with the receiver. folderID must be one of the KNOWNFOLDERID values from
// the x/sys/windows package, serialized as a stringified GUID.
UserDir(folderID string) (string, error)
// Username returns the user name associated with the receiver.
Username() (string, error)
}
// ConnIdentity represents the owner of a localhost TCP or unix socket connection
// connecting to the LocalAPI.
type ConnIdentity struct {
@@ -69,7 +38,9 @@ type ConnIdentity struct {
// Used on Windows:
// TODO(bradfitz): merge these into the peercreds package and
// use that for all.
pid int
pid int
userID ipn.WindowsUserID
user *user.User
}
// WindowsUserID returns the local machine's userid of the connection
@@ -81,11 +52,8 @@ func (ci *ConnIdentity) WindowsUserID() ipn.WindowsUserID {
if envknob.GOOS() != "windows" {
return ""
}
if tok, err := ci.WindowsToken(); err == nil {
defer tok.Close()
if uid, err := tok.UID(); err == nil {
return uid
}
if ci.userID != "" {
return ci.userID
}
// For Linux tests running as Windows:
const isBroken = true // TODO(bradfitz,maisem): fix tests; this doesn't work yet
@@ -97,6 +65,7 @@ func (ci *ConnIdentity) WindowsUserID() ipn.WindowsUserID {
return ""
}
func (ci *ConnIdentity) User() *user.User { return ci.user }
func (ci *ConnIdentity) Pid() int { return ci.pid }
func (ci *ConnIdentity) IsUnixSock() bool { return ci.isUnixSock }
func (ci *ConnIdentity) Creds() *peercred.Creds { return ci.creds }

View File

@@ -21,9 +21,3 @@ func GetConnIdentity(_ logger.Logf, c net.Conn) (ci *ConnIdentity, err error) {
ci.creds, _ = peercred.Get(c)
return ci, nil
}
// WindowsToken is unsupported when GOOS != windows and always returns
// ErrNotImplemented.
func (ci *ConnIdentity) WindowsToken() (WindowsToken, error) {
return nil, ErrNotImplemented
}

View File

@@ -6,179 +6,53 @@ package ipnauth
import (
"fmt"
"net"
"runtime"
"syscall"
"unsafe"
"golang.org/x/sys/windows"
"tailscale.com/ipn"
"tailscale.com/safesocket"
"tailscale.com/types/logger"
"tailscale.com/util/winutil"
"tailscale.com/util/pidowner"
)
var (
kernel32 = syscall.NewLazyDLL("kernel32.dll")
procGetNamedPipeClientProcessId = kernel32.NewProc("GetNamedPipeClientProcessId")
)
func getNamedPipeClientProcessId(h windows.Handle) (pid uint32, err error) {
r1, _, err := procGetNamedPipeClientProcessId.Call(uintptr(h), uintptr(unsafe.Pointer(&pid)))
if r1 > 0 {
return pid, nil
}
return 0, err
}
// GetConnIdentity extracts the identity information from the connection
// based on the user who owns the other end of the connection.
// If c is not backed by a named pipe, an error is returned.
func GetConnIdentity(logf logger.Logf, c net.Conn) (ci *ConnIdentity, err error) {
ci = &ConnIdentity{conn: c}
wcc, ok := c.(*safesocket.WindowsClientConn)
h, ok := c.(interface {
Fd() uintptr
})
if !ok {
return nil, fmt.Errorf("not a WindowsClientConn: %T", c)
return ci, fmt.Errorf("not a windows handle: %T", c)
}
ci.pid, err = wcc.ClientPID()
pid, err := getNamedPipeClientProcessId(windows.Handle(h.Fd()))
if err != nil {
return nil, err
return ci, fmt.Errorf("getNamedPipeClientProcessId: %v", err)
}
ci.pid = int(pid)
uid, err := pidowner.OwnerOfPID(ci.pid)
if err != nil {
return ci, fmt.Errorf("failed to map connection's pid to a user (WSL?): %w", err)
}
ci.userID = ipn.WindowsUserID(uid)
u, err := LookupUserFromID(logf, uid)
if err != nil {
return ci, fmt.Errorf("failed to look up user from userid: %w", err)
}
ci.user = u
return ci, nil
}
type token struct {
t windows.Token
}
func (t *token) UID() (ipn.WindowsUserID, error) {
sid, err := t.uid()
if err != nil {
return "", fmt.Errorf("failed to look up user from token: %w", err)
}
return ipn.WindowsUserID(sid.String()), nil
}
func (t *token) Username() (string, error) {
sid, err := t.uid()
if err != nil {
return "", fmt.Errorf("failed to look up user from token: %w", err)
}
username, domain, _, err := sid.LookupAccount("")
if err != nil {
return "", fmt.Errorf("failed to look up username from SID: %w", err)
}
return fmt.Sprintf(`%s\%s`, domain, username), nil
}
func (t *token) IsAdministrator() (bool, error) {
baSID, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid)
if err != nil {
return false, err
}
isMember, err := t.t.IsMember(baSID)
if err != nil {
return false, err
}
if isMember {
return true, nil
}
isLimited, err := winutil.IsTokenLimited(t.t)
if err != nil || !isLimited {
return false, err
}
// Try to obtain a linked token, and if present, check it.
// (This should be the elevated token associated with limited UAC accounts.)
linkedToken, err := t.t.GetLinkedToken()
if err != nil {
return false, err
}
defer linkedToken.Close()
return linkedToken.IsMember(baSID)
}
func (t *token) IsElevated() bool {
return t.t.IsElevated()
}
func (t *token) UserDir(folderID string) (string, error) {
guid, err := windows.GUIDFromString(folderID)
if err != nil {
return "", err
}
return t.t.KnownFolderPath((*windows.KNOWNFOLDERID)(unsafe.Pointer(&guid)), 0)
}
func (t *token) Close() error {
if t.t == 0 {
return nil
}
if err := t.t.Close(); err != nil {
return err
}
t.t = 0
runtime.SetFinalizer(t, nil)
return nil
}
func (t *token) EqualUIDs(other WindowsToken) bool {
if t != nil && other == nil || t == nil && other != nil {
return false
}
ot, ok := other.(*token)
if !ok {
return false
}
if t == ot {
return true
}
uid, err := t.uid()
if err != nil {
return false
}
oUID, err := ot.uid()
if err != nil {
return false
}
return uid.Equals(oUID)
}
func (t *token) uid() (*windows.SID, error) {
tu, err := t.t.GetTokenUser()
if err != nil {
return nil, err
}
return tu.User.Sid, nil
}
func (t *token) IsUID(uid ipn.WindowsUserID) bool {
tUID, err := t.UID()
if err != nil {
return false
}
return tUID == uid
}
// WindowsToken returns the WindowsToken representing the security context
// of the connection's client.
func (ci *ConnIdentity) WindowsToken() (WindowsToken, error) {
var wcc *safesocket.WindowsClientConn
var ok bool
if wcc, ok = ci.conn.(*safesocket.WindowsClientConn); !ok {
return nil, fmt.Errorf("not a WindowsClientConn: %T", ci.conn)
}
// We duplicate the token's handle so that the WindowsToken we return may have
// a lifetime independent from the original connection.
var h windows.Handle
if err := windows.DuplicateHandle(
windows.CurrentProcess(),
windows.Handle(wcc.Token()),
windows.CurrentProcess(),
&h,
0,
false,
windows.DUPLICATE_SAME_ACCESS,
); err != nil {
return nil, err
}
result := &token{t: windows.Token(h)}
runtime.SetFinalizer(result, func(t *token) { t.Close() })
return result, nil
}

View File

@@ -265,7 +265,7 @@ func (b *LocalBackend) newC2NUpdateResponse() tailcfg.C2NUpdateResponse {
// Note that we create the Updater solely to check for errors; we do not
// invoke it here. For this purpose, it is ok to pass it a zero Arguments.
prefs := b.Prefs().AutoUpdate()
_, err := clientupdate.NewUpdater(clientupdate.Arguments{ForAutoUpdate: true})
_, err := clientupdate.NewUpdater(clientupdate.Arguments{})
return tailcfg.C2NUpdateResponse{
Enabled: envknob.AllowsRemoteUpdate() || prefs.Apply,
Supported: err == nil,

View File

@@ -32,7 +32,6 @@ import (
"go4.org/netipx"
xmaps "golang.org/x/exp/maps"
"gvisor.dev/gvisor/pkg/tcpip"
"tailscale.com/appc"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/control/controlclient"
"tailscale.com/control/controlknobs"
@@ -67,7 +66,6 @@ import (
"tailscale.com/tka"
"tailscale.com/tsd"
"tailscale.com/tstime"
"tailscale.com/types/appctype"
"tailscale.com/types/dnstype"
"tailscale.com/types/empty"
"tailscale.com/types/key"
@@ -159,7 +157,6 @@ type LocalBackend struct {
e wgengine.Engine // non-nil; TODO(bradfitz): remove; use sys
store ipn.StateStore // non-nil; TODO(bradfitz): remove; use sys
dialer *tsdial.Dialer // non-nil; TODO(bradfitz): remove; use sys
pushDeviceToken syncs.AtomicValue[string]
backendLogID logid.PublicID
unregisterNetMon func()
unregisterHealthWatch func()
@@ -170,7 +167,6 @@ type LocalBackend struct {
logFlushFunc func() // or nil if SetLogFlusher wasn't called
em *expiryManager // non-nil
sshAtomicBool atomic.Bool
webClientAtomicBool atomic.Bool
shutdownCalled bool // if Shutdown has been called
debugSink *capture.Sink
sockstatLogger *sockstatlog.Logger
@@ -205,11 +201,9 @@ type LocalBackend struct {
conf *conffile.Config // latest parsed config, or nil if not in declarative mode
pm *profileManager // mu guards access
filterHash deephash.Sum
httpTestClient *http.Client // for controlclient. nil by default, used by tests.
ccGen clientGen // function for producing controlclient; lazily populated
sshServer SSHServer // or nil, initialized lazily.
appConnector *appc.AppConnector // or nil, initialized when configured.
webClient webClient
httpTestClient *http.Client // for controlclient. nil by default, used by tests.
ccGen clientGen // function for producing controlclient; lazily populated
sshServer SSHServer // or nil, initialized lazily.
notify func(ipn.Notify)
cc controlclient.Client
ccAuto *controlclient.Auto // if cc is of type *controlclient.Auto
@@ -266,7 +260,6 @@ type LocalBackend struct {
componentLogUntil map[string]componentLogState
// c2nUpdateStatus is the status of c2n-triggered client update.
c2nUpdateStatus updateStatus
currentUser ipnauth.WindowsToken
// ServeConfig fields. (also guarded by mu)
lastServeConfJSON mem.RO // last JSON that was parsed into serveConfig
@@ -537,7 +530,7 @@ func (b *LocalBackend) SetDirectFileDoFinalRename(v bool) {
b.directFileDoFinalRename = v
}
// ReloadConfig reloads the backend's config from disk.
// ReloadCOnfig reloads the backend's config from disk.
//
// It returns (false, nil) if not running in declarative mode, (true, nil) on
// success, or (false, error) on failure.
@@ -648,7 +641,6 @@ func (b *LocalBackend) Shutdown() {
b.debugSink = nil
}
b.mu.Unlock()
b.WebClientShutdown()
if b.sockstatLogger != nil {
b.sockstatLogger.Shutdown()
@@ -1113,7 +1105,6 @@ func (b *LocalBackend) SetControlClientStatus(c controlclient.Client, st control
// Perform all reconfiguration based on the netmap here.
if st.NetMap != nil {
b.capTailnetLock = hasCapability(st.NetMap, tailcfg.CapabilityTailnetLock)
b.setWebClientAtomicBoolLocked(st.NetMap, prefs.View())
b.mu.Unlock() // respect locking rules for tkaSyncIfNeeded
if err := b.tkaSyncIfNeeded(st.NetMap, prefs.View()); err != nil {
@@ -2033,9 +2024,10 @@ func (b *LocalBackend) readPoller() {
b.hostinfo = new(tailcfg.Hostinfo)
}
b.hostinfo.Services = sl
hi := b.hostinfo
b.mu.Unlock()
b.doSetHostinfoFilterServices()
b.doSetHostinfoFilterServices(hi)
if isFirst {
isFirst = false
@@ -2044,19 +2036,22 @@ func (b *LocalBackend) readPoller() {
}
}
// GetPushDeviceToken returns the push notification device token.
func (b *LocalBackend) GetPushDeviceToken() string {
return b.pushDeviceToken.Load()
}
// ResendHostinfoIfNeeded is called to recompute the Hostinfo and send
// the new version to the control server.
func (b *LocalBackend) ResendHostinfoIfNeeded() {
// TODO(maisem,bradfitz): this is all wrong. hostinfo has been modified
// a dozen ways elsewhere that this omits. This method should be rethought.
hi := hostinfo.New()
// SetPushDeviceToken sets the push notification device token and informs the
// controlclient of the new value.
func (b *LocalBackend) SetPushDeviceToken(tk string) {
old := b.pushDeviceToken.Swap(tk)
if old == tk {
return
b.mu.Lock()
applyConfigToHostinfo(hi, b.conf)
if b.hostinfo != nil {
hi.Services = b.hostinfo.Services
}
b.doSetHostinfoFilterServices()
b.hostinfo = hi
b.mu.Unlock()
b.doSetHostinfoFilterServices(hi)
}
func applyConfigToHostinfo(hi *tailcfg.Hostinfo, c *conffile.Config) {
@@ -2505,7 +2500,6 @@ func (b *LocalBackend) setTCPPortsIntercepted(ports []uint16) {
// and shouldInterceptTCPPortAtomic from the prefs p, which may be !Valid().
func (b *LocalBackend) setAtomicValuesFromPrefsLocked(p ipn.PrefsView) {
b.sshAtomicBool.Store(p.Valid() && p.RunSSH() && envknob.CanSSHD())
b.setWebClientAtomicBoolLocked(b.netMap, p)
if !p.Valid() {
b.containsViaIPFuncAtomic.Store(tsaddr.FalseContainsIPFunc())
@@ -2731,7 +2725,7 @@ func (b *LocalBackend) shouldUploadServices() bool {
return !p.ShieldsUp() && b.netMap.CollectServices
}
// SetCurrentUser is used to implement support for multi-user systems (only
// SetCurrentUserID is used to implement support for multi-user systems (only
// Windows 2022-11-25). On such systems, the uid is used to determine which
// user's state should be used. The current user is maintained by active
// connections open to the backend.
@@ -2746,35 +2740,18 @@ func (b *LocalBackend) shouldUploadServices() bool {
// unattended mode. The user must disable unattended mode before the user can be
// changed.
//
// On non-multi-user systems, the token should be set to nil.
//
// SetCurrentUser returns the ipn.WindowsUserID associated with token
// when successful.
func (b *LocalBackend) SetCurrentUser(token ipnauth.WindowsToken) (ipn.WindowsUserID, error) {
var uid ipn.WindowsUserID
if token != nil {
var err error
uid, err = token.UID()
if err != nil {
return "", err
}
}
// On non-multi-user systems, the uid should be set to empty string.
func (b *LocalBackend) SetCurrentUserID(uid ipn.WindowsUserID) {
b.mu.Lock()
if b.pm.CurrentUserID() == uid {
b.mu.Unlock()
return uid, nil
return
}
if err := b.pm.SetCurrentUserID(uid); err != nil {
b.mu.Unlock()
return uid, nil
return
}
if b.currentUser != nil {
b.currentUser.Close()
}
b.currentUser = token
b.resetForProfileChangeLockedOnEntry()
return uid, nil
}
func (b *LocalBackend) CheckPrefs(p *ipn.Prefs) error {
@@ -2909,7 +2886,7 @@ func (b *LocalBackend) EditPrefs(mp *ipn.MaskedPrefs) (ipn.PrefsView, error) {
if mp.EggSet {
mp.EggSet = false
b.egg = true
go b.doSetHostinfoFilterServices()
go b.doSetHostinfoFilterServices(b.hostinfo.Clone())
}
p0 := b.pm.CurrentPrefs()
p1 := b.pm.CurrentPrefs().AsStruct()
@@ -2999,9 +2976,6 @@ func (b *LocalBackend) setPrefsLockedOnEntry(caller string, newp *ipn.Prefs) ipn
oldHi := b.hostinfo
newHi := oldHi.Clone()
if newHi == nil {
newHi = new(tailcfg.Hostinfo)
}
b.applyPrefsToHostinfoLocked(newHi, newp.View())
b.hostinfo = newHi
hostInfoChanged := !oldHi.Equal(newHi)
@@ -3042,7 +3016,7 @@ func (b *LocalBackend) setPrefsLockedOnEntry(caller string, newp *ipn.Prefs) ipn
b.mu.Unlock()
if oldp.ShieldsUp() != newp.ShieldsUp || hostInfoChanged {
b.doSetHostinfoFilterServices()
b.doSetHostinfoFilterServices(newHi)
}
if netMap != nil {
@@ -3113,9 +3087,6 @@ var (
// apply to the socket before calling the handler.
func (b *LocalBackend) TCPHandlerForDst(src, dst netip.AddrPort) (handler func(c net.Conn) error, opts []tcpip.SettableSocketOption) {
if dst.Port() == 80 && (dst.Addr() == magicDNSIP || dst.Addr() == magicDNSIPv6) {
if b.ShouldRunWebClient() {
return b.handleWebClientConn, opts
}
return b.HandleQuad100Port80Conn, opts
}
if !b.isLocalIP(dst.Addr()) {
@@ -3131,10 +3102,6 @@ func (b *LocalBackend) TCPHandlerForDst(src, dst netip.AddrPort) (handler func(c
opts = append(opts, ptr.To(tcpip.KeepaliveIdleOption(72*time.Hour)))
return b.handleSSHConn, opts
}
// TODO(will,sonia): allow customizing web client port ?
if dst.Port() == webClientPort && b.ShouldRunWebClient() {
return b.handleWebClientConn, opts
}
if port, ok := b.GetPeerAPIPort(dst.Addr()); ok && dst.Port() == port {
return func(c net.Conn) error {
b.handlePeerAPIConn(src, dst, c)
@@ -3167,12 +3134,6 @@ func (b *LocalBackend) peerAPIServicesLocked() (ret []tailcfg.Service) {
Port: 1, // version
})
}
if b.appConnector != nil {
ret = append(ret, tailcfg.Service{
Proto: tailcfg.AppConnector,
Port: 1, // version
})
}
return ret
}
@@ -3181,7 +3142,11 @@ func (b *LocalBackend) peerAPIServicesLocked() (ret []tailcfg.Service) {
//
// TODO(danderson): we shouldn't be mangling hostinfo here after
// painstakingly constructing it in twelvety other places.
func (b *LocalBackend) doSetHostinfoFilterServices() {
func (b *LocalBackend) doSetHostinfoFilterServices(hi *tailcfg.Hostinfo) {
if hi == nil {
b.logf("[unexpected] doSetHostinfoFilterServices with nil hostinfo")
return
}
b.mu.Lock()
cc := b.cc
if cc == nil {
@@ -3189,31 +3154,23 @@ func (b *LocalBackend) doSetHostinfoFilterServices() {
b.mu.Unlock()
return
}
if b.hostinfo == nil {
b.mu.Unlock()
b.logf("[unexpected] doSetHostinfoFilterServices with nil hostinfo")
return
}
peerAPIServices := b.peerAPIServicesLocked()
if b.egg {
peerAPIServices = append(peerAPIServices, tailcfg.Service{Proto: "egg", Port: 1})
}
// TODO(maisem,bradfitz): store hostinfo as a view, not as a mutable struct.
hi := *b.hostinfo // shallow copy
b.mu.Unlock()
// Make a shallow copy of hostinfo so we can mutate
// at the Service field.
hi2 := *hi // shallow copy
if !b.shouldUploadServices() {
hi.Services = []tailcfg.Service{}
hi2.Services = []tailcfg.Service{}
}
// Don't mutate hi.Service's underlying array. Append to
// the slice with no free capacity.
c := len(hi.Services)
hi.Services = append(hi.Services[:c:c], peerAPIServices...)
hi.PushDeviceToken = b.pushDeviceToken.Load()
cc.SetHostinfo(&hi)
c := len(hi2.Services)
hi2.Services = append(hi2.Services[:c:c], peerAPIServices...)
cc.SetHostinfo(&hi2)
}
// NetMap returns the latest cached network map received from
@@ -3241,49 +3198,6 @@ func (b *LocalBackend) blockEngineUpdates(block bool) {
b.mu.Unlock()
}
// reconfigAppConnectorLocked updates the app connector state based on the
// current network map and preferences.
// b.mu must be held.
func (b *LocalBackend) reconfigAppConnectorLocked(nm *netmap.NetworkMap, prefs ipn.PrefsView) {
const appConnectorCapName = "tailscale.com/app-connectors"
if !prefs.AppConnector().Advertise {
b.appConnector = nil
return
}
if b.appConnector == nil {
b.appConnector = appc.NewAppConnector(b.logf, b)
}
if nm == nil {
return
}
// TODO(raggi): rework the view infrastructure so the large deep clone is no
// longer required
sn := nm.SelfNode.AsStruct()
attrs, err := tailcfg.UnmarshalNodeCapJSON[appctype.AppConnectorAttr](sn.CapMap, appConnectorCapName)
if err != nil {
b.logf("[unexpected] error parsing app connector mapcap: %v", err)
return
}
var domains []string
for _, attr := range attrs {
// Geometric cost, assumes that the number of advertised tags is small
if !nm.SelfNode.Tags().ContainsFunc(func(tag string) bool {
return slices.Contains(attr.Connectors, tag)
}) {
continue
}
domains = append(domains, attr.Domains...)
}
slices.Sort(domains)
slices.Compact(domains)
b.appConnector.UpdateDomains(domains)
}
// authReconfig pushes a new configuration into wgengine, if engine
// updates are not currently blocked, based on the cached netmap and
// user prefs.
@@ -3296,8 +3210,6 @@ func (b *LocalBackend) authReconfig() {
disableSubnetsIfPAC := hasCapability(nm, tailcfg.NodeAttrDisableSubnetsIfPAC)
dohURL, dohURLOK := exitNodeCanProxyDNS(nm, b.peers, prefs.ExitNodeID())
dcfg := dnsConfigForNetmap(nm, b.peers, prefs, b.logf, version.OS())
// If the current node is an app connector, ensure the app connector machine is started
b.reconfigAppConnectorLocked(nm, prefs)
b.mu.Unlock()
if blocked {
@@ -3750,7 +3662,7 @@ func (b *LocalBackend) initPeerAPIListener() {
b.peerAPIListeners = append(b.peerAPIListeners, pln)
}
go b.doSetHostinfoFilterServices()
go b.doSetHostinfoFilterServices(b.hostinfo.Clone())
}
// magicDNSRootDomains returns the subset of nm.DNS.Domains that are the search domains for MagicDNS.
@@ -4199,10 +4111,6 @@ func (b *LocalBackend) ResetForClientDisconnect() {
b.setNetMapLocked(nil)
b.pm.Reset()
if b.currentUser != nil {
b.currentUser.Close()
b.currentUser = nil
}
b.keyExpired = false
b.authURL = ""
b.authURLSticky = ""
@@ -4213,19 +4121,6 @@ func (b *LocalBackend) ResetForClientDisconnect() {
func (b *LocalBackend) ShouldRunSSH() bool { return b.sshAtomicBool.Load() && envknob.CanSSHD() }
// ShouldRunWebClient reports whether the web client is being run
// within this tailscaled instance. ShouldRunWebClient is safe to
// call regardless of whether b.mu is held or not.
func (b *LocalBackend) ShouldRunWebClient() bool { return b.webClientAtomicBool.Load() }
func (b *LocalBackend) setWebClientAtomicBoolLocked(nm *netmap.NetworkMap, prefs ipn.PrefsView) {
shouldRun := prefs.Valid() && prefs.RunWebClient() && hasCapability(nm, tailcfg.CapabilityPreviewWebClient)
wasRunning := b.webClientAtomicBool.Swap(shouldRun)
if wasRunning && !shouldRun {
go b.WebClientShutdown() // stop web client
}
}
// ShouldHandleViaIP reports whether ip is an IPv6 address in the
// Tailscale ULA's v6 "via" range embedding an IPv4 address to be forwarded to
// by Tailscale.
@@ -4473,9 +4368,6 @@ func (b *LocalBackend) setTCPPortsInterceptedFromNetmapAndPrefsLocked(prefs ipn.
if prefs.Valid() && prefs.RunSSH() && envknob.CanSSHD() {
handlePorts = append(handlePorts, 22)
}
if b.ShouldRunWebClient() {
handlePorts = append(handlePorts, webClientPort)
}
b.reloadServeConfigLocked(prefs)
if b.serveConfig.Valid() {
@@ -4499,7 +4391,7 @@ func (b *LocalBackend) setTCPPortsInterceptedFromNetmapAndPrefsLocked(prefs ipn.
if wire := b.wantIngressLocked(); b.hostinfo != nil && b.hostinfo.WireIngress != wire {
b.logf("Hostinfo.WireIngress changed to %v", wire)
b.hostinfo.WireIngress = wire
go b.doSetHostinfoFilterServices()
go b.doSetHostinfoFilterServices(b.hostinfo.Clone())
}
b.setTCPPortsIntercepted(handlePorts)
@@ -4690,9 +4582,6 @@ func (b *LocalBackend) FileTargets() ([]*apitype.FileTarget, error) {
if !b.peerIsTaildropTargetLocked(p) {
continue
}
if p.Hostinfo().OS() == "tvOS" {
continue
}
peerAPI := peerAPIBase(b.netMap, p)
if peerAPI == "" {
continue
@@ -4882,14 +4771,6 @@ func (b *LocalBackend) OfferingExitNode() bool {
return def4 && def6
}
// OfferingAppConnector reports whether b is currently offering app
// connector services.
func (b *LocalBackend) OfferingAppConnector() bool {
b.mu.Lock()
defer b.mu.Unlock()
return b.appConnector != nil
}
// allowExitNodeDNSProxyToServeName reports whether the Exit Node DNS
// proxy is allowed to serve responses for the provided DNS name.
func (b *LocalBackend) allowExitNodeDNSProxyToServeName(name string) bool {
@@ -5476,39 +5357,6 @@ func (b *LocalBackend) DebugBreakDERPConns() error {
return b.magicConn().DebugBreakDERPConns()
}
// ObserveDNSResponse passes a DNS response from the PeerAPI DNS server to the
// App Connector to enable route discovery.
func (b *LocalBackend) ObserveDNSResponse(res []byte) {
var appConnector *appc.AppConnector
b.mu.Lock()
if b.appConnector == nil {
b.mu.Unlock()
return
}
appConnector = b.appConnector
b.mu.Unlock()
appConnector.ObserveDNSResponse(res)
}
// AdvertiseRoute implements the appc.RouteAdvertiser interface. It sets a new
// route advertisement if one is not already present in the existing routes.
func (b *LocalBackend) AdvertiseRoute(ipp netip.Prefix) error {
currentRoutes := b.Prefs().AdvertiseRoutes()
// TODO(raggi): check if the new route is a subset of an existing route.
if currentRoutes.ContainsFunc(func(r netip.Prefix) bool { return r == ipp }) {
return nil
}
routes := append(currentRoutes.AsSlice(), ipp)
_, err := b.EditPrefs(&ipn.MaskedPrefs{
Prefs: ipn.Prefs{
AdvertiseRoutes: routes,
},
AdvertiseRoutesSet: true,
})
return err
}
// mayDeref dereferences p if non-nil, otherwise it returns the zero value.
func mayDeref[T any](p *T) (v T) {
if p == nil {

View File

@@ -10,13 +10,10 @@ import (
"net/http"
"net/netip"
"reflect"
"slices"
"testing"
"time"
"go4.org/netipx"
"golang.org/x/net/dns/dnsmessage"
"tailscale.com/appc"
"tailscale.com/control/controlclient"
"tailscale.com/ipn"
"tailscale.com/ipn/store/mem"
@@ -32,8 +29,6 @@ import (
"tailscale.com/types/netmap"
"tailscale.com/types/ptr"
"tailscale.com/util/dnsname"
"tailscale.com/util/mak"
"tailscale.com/util/must"
"tailscale.com/util/set"
"tailscale.com/wgengine"
"tailscale.com/wgengine/filter"
@@ -473,24 +468,6 @@ func TestFileTargets(t *testing.T) {
if len(got) != 0 {
t.Fatalf("unexpected %d peers", len(got))
}
var peerMap map[tailcfg.NodeID]tailcfg.NodeView
mak.NonNil(&peerMap)
var nodeID tailcfg.NodeID
nodeID = 1234
peer := &tailcfg.Node{
ID: 1234,
Hostinfo: (&tailcfg.Hostinfo{OS: "tvOS"}).View(),
}
peerMap[nodeID] = peer.View()
b.peers = peerMap
got, err = b.FileTargets()
if err != nil {
t.Fatal(err)
}
if len(got) != 0 {
t.Fatalf("unexpected %d peers", len(got))
}
// (other cases handled by TestPeerAPIBase above)
}
@@ -1146,112 +1123,6 @@ func TestDNSConfigForNetmapForExitNodeConfigs(t *testing.T) {
}
}
func TestOfferingAppConnector(t *testing.T) {
b := newTestBackend(t)
if b.OfferingAppConnector() {
t.Fatal("unexpected offering app connector")
}
b.appConnector = appc.NewAppConnector(t.Logf, nil)
if !b.OfferingAppConnector() {
t.Fatal("unexpected not offering app connector")
}
}
func TestAppConnectorHostinfoService(t *testing.T) {
hasAppConnectorService := func(s []tailcfg.Service) bool {
for _, s := range s {
if s.Proto == tailcfg.AppConnector && s.Port == 1 {
return true
}
}
return false
}
b := newTestBackend(t)
b.mu.Lock()
defer b.mu.Unlock()
if hasAppConnectorService(b.peerAPIServicesLocked()) {
t.Fatal("unexpected app connector service")
}
b.appConnector = appc.NewAppConnector(t.Logf, nil)
if !hasAppConnectorService(b.peerAPIServicesLocked()) {
t.Fatal("expected app connector service")
}
}
func TestRouteAdvertiser(t *testing.T) {
b := newTestBackend(t)
testPrefix := netip.MustParsePrefix("192.0.0.8/32")
ra := appc.RouteAdvertiser(b)
must.Do(ra.AdvertiseRoute(testPrefix))
routes := b.Prefs().AdvertiseRoutes()
if routes.Len() != 1 || routes.At(0) != testPrefix {
t.Fatalf("got routes %v, want %v", routes, []netip.Prefix{testPrefix})
}
}
func TestObserveDNSResponse(t *testing.T) {
b := newTestBackend(t)
// ensure no error when no app connector is configured
b.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8"))
rc := &routeCollector{}
b.appConnector = appc.NewAppConnector(t.Logf, rc)
b.appConnector.UpdateDomains([]string{"example.com"})
b.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8"))
wantRoutes := []netip.Prefix{netip.MustParsePrefix("192.0.0.8/32")}
if !slices.Equal(rc.routes, wantRoutes) {
t.Fatalf("got routes %v, want %v", rc.routes, wantRoutes)
}
}
func TestReconfigureAppConnector(t *testing.T) {
b := newTestBackend(t)
b.reconfigAppConnectorLocked(b.netMap, b.pm.prefs)
if b.appConnector != nil {
t.Fatal("unexpected app connector")
}
b.EditPrefs(&ipn.MaskedPrefs{
Prefs: ipn.Prefs{
AppConnector: ipn.AppConnectorPrefs{
Advertise: true,
},
},
AppConnectorSet: true,
})
b.reconfigAppConnectorLocked(b.netMap, b.pm.prefs)
if b.appConnector == nil {
t.Fatal("expected app connector")
}
appCfg := `{
"name": "example",
"domains": ["example.com"],
"connectors": ["tag:example"]
}`
b.netMap.SelfNode = (&tailcfg.Node{
Name: "example.ts.net",
Tags: []string{"tag:example"},
CapMap: (tailcfg.NodeCapMap)(map[tailcfg.NodeCapability][]tailcfg.RawMessage{
"tailscale.com/app-connectors": {tailcfg.RawMessage(appCfg)},
}),
}).View()
b.reconfigAppConnectorLocked(b.netMap, b.pm.prefs)
want := []string{"example.com"}
if !slices.Equal(b.appConnector.Domains().AsSlice(), want) {
t.Fatalf("got domains %v, want %v", b.appConnector.Domains(), want)
}
}
func resolversEqual(t *testing.T, a, b []*dnstype.Resolver) bool {
if a == nil && b == nil {
return true
@@ -1286,50 +1157,3 @@ func routesEqual(t *testing.T, a, b map[dnsname.FQDN][]*dnstype.Resolver) bool {
}
return true
}
// dnsResponse is a test helper that creates a DNS response buffer for the given domain and address
func dnsResponse(domain, address string) []byte {
addr := netip.MustParseAddr(address)
b := dnsmessage.NewBuilder(nil, dnsmessage.Header{})
b.EnableCompression()
b.StartAnswers()
switch addr.BitLen() {
case 32:
b.AResource(
dnsmessage.ResourceHeader{
Name: dnsmessage.MustNewName(domain),
Type: dnsmessage.TypeA,
Class: dnsmessage.ClassINET,
TTL: 0,
},
dnsmessage.AResource{
A: addr.As4(),
},
)
case 128:
b.AAAAResource(
dnsmessage.ResourceHeader{
Name: dnsmessage.MustNewName(domain),
Type: dnsmessage.TypeAAAA,
Class: dnsmessage.ClassINET,
TTL: 0,
},
dnsmessage.AAAAResource{
AAAA: addr.As16(),
},
)
default:
panic("invalid address length")
}
return must.Get(b.Finish())
}
// routeCollector is a test helper that collects the list of routes advertised
type routeCollector struct {
routes []netip.Prefix
}
func (rc *routeCollector) AdvertiseRoute(pfx netip.Prefix) error {
rc.routes = append(rc.routes, pfx)
return nil
}

View File

@@ -32,6 +32,7 @@ import (
"tailscale.com/health"
"tailscale.com/hostinfo"
"tailscale.com/ipn"
"tailscale.com/net/dns/resolver"
"tailscale.com/net/interfaces"
"tailscale.com/net/netaddr"
"tailscale.com/net/netutil"
@@ -50,14 +51,9 @@ var initListenConfig func(*net.ListenConfig, netip.Addr, *interfaces.State, stri
// ("cleartext" HTTP/2) support to the peerAPI.
var addH2C func(*http.Server)
// peerDNSQueryHandler is implemented by tsdns.Resolver.
type peerDNSQueryHandler interface {
HandlePeerDNSQuery(context.Context, []byte, netip.AddrPort, func(name string) bool) (res []byte, err error)
}
type peerAPIServer struct {
b *LocalBackend
resolver peerDNSQueryHandler
resolver *resolver.Resolver
taildrop *taildrop.Manager
}
@@ -865,9 +861,9 @@ func (h *peerAPIHandler) replyToDNSQueries() bool {
return true
}
b := h.ps.b
if !b.OfferingExitNode() && !b.OfferingAppConnector() {
// If we're not an exit node or app connector, there's
// no point to being a DNS server for somebody.
if !b.OfferingExitNode() {
// If we're not an exit node, there's no point to
// being a DNS server for somebody.
return false
}
if !h.remoteAddr.IsValid() {
@@ -931,7 +927,7 @@ func (h *peerAPIHandler) handleDNSQuery(w http.ResponseWriter, r *http.Request)
ctx, cancel := context.WithTimeout(r.Context(), arbitraryTimeout)
defer cancel()
res, err := h.ps.resolver.HandlePeerDNSQuery(ctx, q, h.remoteAddr, h.ps.b.allowExitNodeDNSProxyToServeName)
res, err := h.ps.resolver.HandleExitNodeDNSQuery(ctx, q, h.remoteAddr, h.ps.b.allowExitNodeDNSProxyToServeName)
if err != nil {
h.logf("handleDNS fwd error: %v", err)
if err := ctx.Err(); err != nil {
@@ -941,13 +937,6 @@ func (h *peerAPIHandler) handleDNSQuery(w http.ResponseWriter, r *http.Request)
}
return
}
// TODO(raggi): consider pushing the integration down into the resolver
// instead to avoid re-parsing the DNS response for improved performance in
// the future.
if h.ps.b.OfferingAppConnector() {
h.ps.b.ObserveDNSResponse(res)
}
if pretty {
// Non-standard response for interactive debugging.
w.Header().Set("Content-Type", "application/json")
@@ -1003,7 +992,7 @@ func dnsQueryForName(name, typStr string) []byte {
b := dnsmessage.NewBuilder(nil, dnsmessage.Header{
OpCode: 0, // query
RecursionDesired: true,
ID: 1, // arbitrary, but 0 is rejected by some servers
ID: 0,
})
if !strings.HasSuffix(name, ".") {
name += "."

View File

@@ -5,7 +5,6 @@ package ipnlocal
import (
"bytes"
"context"
"fmt"
"io"
"io/fs"
@@ -15,14 +14,11 @@ import (
"net/netip"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"github.com/google/go-cmp/cmp"
"go4.org/netipx"
"golang.org/x/net/dns/dnsmessage"
"tailscale.com/appc"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/ipn"
"tailscale.com/ipn/store/mem"
@@ -684,63 +680,3 @@ func TestPeerAPIReplyToDNSQueries(t *testing.T) {
t.Errorf("unexpectedly IPv6 deny; wanted to be a DNS server")
}
}
func TestPeerAPIReplyToDNSQueriesAreObserved(t *testing.T) {
var h peerAPIHandler
h.remoteAddr = netip.MustParseAddrPort("100.150.151.152:12345")
rc := &routeCollector{}
eng, _ := wgengine.NewFakeUserspaceEngine(logger.Discard, 0)
pm := must.Get(newProfileManager(new(mem.Store), t.Logf))
h.ps = &peerAPIServer{
b: &LocalBackend{
e: eng,
pm: pm,
store: pm.Store(),
appConnector: appc.NewAppConnector(t.Logf, rc),
},
}
h.ps.b.appConnector.UpdateDomains([]string{"example.com"})
h.ps.resolver = &fakeResolver{}
f := filter.NewAllowAllForTest(logger.Discard)
h.ps.b.setFilter(f)
if !h.ps.b.OfferingAppConnector() {
t.Fatal("expecting to be offering app connector")
}
if !h.replyToDNSQueries() {
t.Errorf("unexpectedly deny; wanted to be a DNS server")
}
w := httptest.NewRecorder()
h.handleDNSQuery(w, httptest.NewRequest("GET", "/dns-query?q=true&t=example.com.", nil))
if w.Code != http.StatusOK {
t.Errorf("unexpected status code: %v", w.Code)
}
wantRoutes := []netip.Prefix{netip.MustParsePrefix("192.0.0.8/32")}
if !slices.Equal(rc.routes, wantRoutes) {
t.Errorf("got %v; want %v", rc.routes, wantRoutes)
}
}
type fakeResolver struct{}
func (f *fakeResolver) HandlePeerDNSQuery(ctx context.Context, q []byte, from netip.AddrPort, allowName func(name string) bool) (res []byte, err error) {
b := dnsmessage.NewBuilder(nil, dnsmessage.Header{})
b.EnableCompression()
b.StartAnswers()
b.AResource(
dnsmessage.ResourceHeader{
Name: dnsmessage.MustNewName("example.com."),
Type: dnsmessage.TypeA,
Class: dnsmessage.ClassINET,
TTL: 0,
},
dnsmessage.AResource{
A: [4]byte{192, 0, 0, 8},
},
)
return b.Finish()
}

View File

@@ -1,90 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ios && !android
package ipnlocal
import (
"errors"
"fmt"
"net"
"net/http"
"tailscale.com/client/tailscale"
"tailscale.com/client/web"
"tailscale.com/net/netutil"
)
const webClientPort = web.ListenPort
// webClient holds state for the web interface for managing
// this tailscale instance. The web interface is not used by
// default, but initialized by calling LocalBackend.WebOrInit.
type webClient struct {
server *web.Server // or nil, initialized lazily
// lc optionally specifies a LocalClient to use to connect
// to the localapi for this tailscaled instance.
// If nil, a default is used.
lc *tailscale.LocalClient
}
// SetWebLocalClient sets the b.web.lc function.
// If lc is provided as nil, b.web.lc is cleared out.
func (b *LocalBackend) SetWebLocalClient(lc *tailscale.LocalClient) {
b.mu.Lock()
defer b.mu.Unlock()
b.webClient.lc = lc
}
// WebClientInit initializes the web interface for managing this
// tailscaled instance.
// If the web interface is already running, WebClientInit is a no-op.
func (b *LocalBackend) WebClientInit() (err error) {
if !b.ShouldRunWebClient() {
return errors.New("web client not enabled for this device")
}
b.mu.Lock()
defer b.mu.Unlock()
if b.webClient.server != nil {
return nil
}
b.logf("WebClientInit: initializing web ui")
if b.webClient.server, err = web.NewServer(web.ServerOpts{
Mode: web.ManageServerMode,
LocalClient: b.webClient.lc,
Logf: b.logf,
}); err != nil {
return fmt.Errorf("web.NewServer: %w", err)
}
b.logf("WebClientInit: started web ui")
return nil
}
// WebClientShutdown shuts down any running b.webClient servers and
// clears out b.webClient state (besides the b.webClient.lc field,
// which is left untouched because required for future web startups).
// WebClientShutdown obtains the b.mu lock.
func (b *LocalBackend) WebClientShutdown() {
b.mu.Lock()
server := b.webClient.server
b.webClient.server = nil
b.mu.Unlock() // release lock before shutdown
if server != nil {
server.Shutdown()
b.logf("WebClientShutdown: shut down web ui")
}
}
// handleWebClientConn serves web client requests.
func (b *LocalBackend) handleWebClientConn(c net.Conn) error {
if err := b.WebClientInit(); err != nil {
return err
}
s := http.Server{Handler: b.webClient.server}
return s.Serve(netutil.NewOneConnListener(c, nil))
}

View File

@@ -1,29 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build ios || android
package ipnlocal
import (
"errors"
"net"
"tailscale.com/client/tailscale"
)
const webClientPort = 5252
type webClient struct{}
func (b *LocalBackend) SetWebLocalClient(lc *tailscale.LocalClient) {}
func (b *LocalBackend) WebClientInit() error {
return errors.New("not implemented")
}
func (b *LocalBackend) WebClientShutdown() {}
func (b *LocalBackend) handleWebClientConn(c net.Conn) error {
return errors.New("not implemented")
}

View File

@@ -202,7 +202,6 @@ func (s *Server) serveHTTP(w http.ResponseWriter, r *http.Request) {
lah := localapi.NewHandler(lb, s.logf, s.netMon, s.backendLogID)
lah.PermitRead, lah.PermitWrite = s.localAPIPermissions(ci)
lah.PermitCert = s.connCanFetchCerts(ci)
lah.CallerIsLocalAdmin = s.connIsLocalAdmin(ci)
lah.ServeHTTP(w, r)
return
}
@@ -243,30 +242,8 @@ func (s *Server) checkConnIdentityLocked(ci *ipnauth.ConnIdentity) error {
for _, active = range s.activeReqs {
break
}
if active != nil {
chkTok, err := ci.WindowsToken()
if err == nil {
defer chkTok.Close()
} else if !errors.Is(err, ipnauth.ErrNotImplemented) {
return err
}
activeTok, err := active.WindowsToken()
if err == nil {
defer activeTok.Close()
} else if !errors.Is(err, ipnauth.ErrNotImplemented) {
return err
}
if chkTok != nil && !chkTok.EqualUIDs(activeTok) {
var b strings.Builder
b.WriteString("Tailscale already in use")
if username, err := activeTok.Username(); err == nil {
fmt.Fprintf(&b, " by %s", username)
}
fmt.Fprintf(&b, ", pid %d", active.Pid())
return inUseOtherUserError{errors.New(b.String())}
}
if active != nil && ci.WindowsUserID() != active.WindowsUserID() {
return inUseOtherUserError{fmt.Errorf("Tailscale already in use by %s, pid %d", active.User().Username, active.Pid())}
}
}
if err := s.mustBackend().CheckIPNConnectionAllowed(ci); err != nil {
@@ -364,25 +341,6 @@ func (s *Server) connCanFetchCerts(ci *ipnauth.ConnIdentity) bool {
return false
}
// connIsLocalAdmin reports whether ci has administrative access to the local
// machine, for whatever that means with respect to the current OS.
//
// This returns true only on Windows machines when the client user is elevated.
// This is useful because, on Windows, tailscaled itself always runs with
// elevated rights: we want to avoid privilege escalation for certain mutative operations.
func (s *Server) connIsLocalAdmin(ci *ipnauth.ConnIdentity) bool {
tok, err := ci.WindowsToken()
if err != nil {
if !errors.Is(err, ipnauth.ErrNotImplemented) {
s.logf("ipnauth.ConnIdentity.WindowsToken() error: %v", err)
}
return false
}
defer tok.Close()
return tok.IsElevated()
}
// addActiveHTTPRequest adds c to the server's list of active HTTP requests.
//
// If the returned error may be of type inUseOtherUserError.
@@ -414,25 +372,14 @@ func (s *Server) addActiveHTTPRequest(req *http.Request, ci *ipnauth.ConnIdentit
mak.Set(&s.activeReqs, req, ci)
if len(s.activeReqs) == 1 {
token, err := ci.WindowsToken()
if err != nil {
if !errors.Is(err, ipnauth.ErrNotImplemented) {
s.logf("error obtaining access token: %v", err)
}
} else {
// Tell the LocalBackend about the identity we're now running as.
uid, err := lb.SetCurrentUser(token)
if err != nil {
token.Close()
return nil, err
}
if s.lastUserID != uid {
if s.lastUserID != "" {
doReset = true
}
s.lastUserID = uid
if uid := ci.WindowsUserID(); uid != "" && len(s.activeReqs) == 1 {
// Tell the LocalBackend about the identity we're now running as.
lb.SetCurrentUserID(uid)
if s.lastUserID != uid {
if s.lastUserID != "" {
doReset = true
}
s.lastUserID = uid
}
}

View File

@@ -85,7 +85,6 @@ var handler = map[string]localAPIHandler{
"derpmap": (*Handler).serveDERPMap,
"dev-set-state-store": (*Handler).serveDevSetStateStore,
"set-push-device-token": (*Handler).serveSetPushDeviceToken,
"handle-push-message": (*Handler).serveHandlePushMessage,
"dial": (*Handler).serveDial,
"file-targets": (*Handler).serveFileTargets,
"goroutines": (*Handler).serveGoroutines,
@@ -158,17 +157,6 @@ type Handler struct {
// cert fetching access.
PermitCert bool
// CallerIsLocalAdmin is whether the this handler is being invoked as a
// result of a LocalAPI call from a user who is a local admin of the current
// machine.
//
// As of 2023-10-26 it is only populated on Windows.
//
// It can be used to to restrict some LocalAPI operations which should only
// be run by an admin and not unprivileged users in a computing environment
// managed by IT admins.
CallerIsLocalAdmin bool
b *ipnlocal.LocalBackend
logf logger.Logf
netMon *netmon.Monitor // optional; nil means interfaces will be looked up on-demand
@@ -916,15 +904,6 @@ func (h *Handler) serveServeConfig(w http.ResponseWriter, r *http.Request) {
writeErrorJSON(w, fmt.Errorf("decoding config: %w", err))
return
}
// require a local admin when setting a path handler
// TODO: roll-up this Windows-specific check into either PermitWrite
// or a global admin escalation check.
if shouldDenyServeConfigForGOOSAndUserContext(runtime.GOOS, configIn, h) {
http.Error(w, "must be a Windows local admin to serve a path", http.StatusUnauthorized)
return
}
etag := r.Header.Get("If-Match")
if err := h.b.SetServeConfig(configIn, etag); err != nil {
if errors.Is(err, ipnlocal.ErrETagMismatch) {
@@ -940,16 +919,6 @@ func (h *Handler) serveServeConfig(w http.ResponseWriter, r *http.Request) {
}
}
func shouldDenyServeConfigForGOOSAndUserContext(goos string, configIn *ipn.ServeConfig, h *Handler) bool {
if goos != "windows" {
return false
}
if !configIn.HasPathHandler() {
return false
}
return !h.CallerIsLocalAdmin
}
func (h *Handler) serveCheckIPForwarding(w http.ResponseWriter, r *http.Request) {
if !h.PermitRead {
http.Error(w, "IP forwarding check access denied", http.StatusForbidden)
@@ -1048,6 +1017,7 @@ func (h *Handler) serveWatchIPNBus(w http.ResponseWriter, r *http.Request) {
http.Error(w, "not a flusher", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
var mask ipn.NotifyWatchOpt
if s := r.FormValue("mask"); s != "" {
@@ -1058,16 +1028,6 @@ func (h *Handler) serveWatchIPNBus(w http.ResponseWriter, r *http.Request) {
}
mask = ipn.NotifyWatchOpt(v)
}
// Users with only read access must request private key filtering. If they
// don't filter out private keys, require write access.
if (mask & ipn.NotifyNoPrivateKeys) == 0 {
if !h.PermitWrite {
http.Error(w, "watch IPN bus access denied, must set ipn.NotifyNoPrivateKeys when not running as admin/root or operator", http.StatusForbidden)
return
}
}
w.Header().Set("Content-Type", "application/json")
ctx := r.Context()
h.b.WatchNotifications(ctx, mask, f.Flush, func(roNotify *ipn.Notify) (keepGoing bool) {
js, err := json.Marshal(roNotify)
@@ -1400,7 +1360,7 @@ func (h *Handler) serveFilePut(w http.ResponseWriter, r *http.Request) {
outReq.ContentLength = r.ContentLength
if offset > 0 {
h.logf("resuming put at offset %d after %v", offset, resumeDuration)
rangeHdr, _ := httphdr.FormatRange([]httphdr.Range{{Start: offset, Length: 0}})
rangeHdr, _ := httphdr.FormatRange([]httphdr.Range{{offset, 0}})
outReq.Header.Set("Range", rangeHdr)
if outReq.ContentLength >= 0 {
outReq.ContentLength -= offset
@@ -1593,31 +1553,11 @@ func (h *Handler) serveSetPushDeviceToken(w http.ResponseWriter, r *http.Request
http.Error(w, "invalid JSON body", http.StatusBadRequest)
return
}
h.b.SetPushDeviceToken(params.PushDeviceToken)
hostinfo.SetPushDeviceToken(params.PushDeviceToken)
h.b.ResendHostinfoIfNeeded()
w.WriteHeader(http.StatusOK)
}
func (h *Handler) serveHandlePushMessage(w http.ResponseWriter, r *http.Request) {
if !h.PermitWrite {
http.Error(w, "handle push message not allowed", http.StatusForbidden)
return
}
if r.Method != "POST" {
http.Error(w, "unsupported method", http.StatusMethodNotAllowed)
return
}
var pushMessageBody map[string]any
if err := json.NewDecoder(r.Body).Decode(&pushMessageBody); err != nil {
http.Error(w, "failed to decode JSON body: "+err.Error(), http.StatusBadRequest)
return
}
// TODO(bradfitz): do something with pushMessageBody
h.logf("localapi: got push message: %v", logger.AsJSON(pushMessageBody))
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) serveUploadClientMetrics(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "unsupported method", http.StatusMethodNotAllowed)
@@ -1685,8 +1625,8 @@ func (h *Handler) serveTKAStatus(w http.ResponseWriter, r *http.Request) {
}
func (h *Handler) serveTKASign(w http.ResponseWriter, r *http.Request) {
if !h.PermitWrite {
http.Error(w, "lock sign access denied", http.StatusForbidden)
if !h.PermitRead {
http.Error(w, "lock status access denied", http.StatusForbidden)
return
}
if r.Method != httpm.POST {

View File

@@ -5,10 +5,7 @@ package localapi
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
@@ -18,15 +15,10 @@ import (
"testing"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/ipn"
"tailscale.com/hostinfo"
"tailscale.com/ipn/ipnlocal"
"tailscale.com/ipn/store/mem"
"tailscale.com/tailcfg"
"tailscale.com/tsd"
"tailscale.com/tstest"
"tailscale.com/types/logger"
"tailscale.com/types/logid"
"tailscale.com/wgengine"
)
func TestValidHost(t *testing.T) {
@@ -85,7 +77,7 @@ func TestSetPushDeviceToken(t *testing.T) {
if res.StatusCode != 200 {
t.Errorf("res.StatusCode=%d, want 200. body: %s", res.StatusCode, body)
}
if got := h.b.GetPushDeviceToken(); got != want {
if got := hostinfo.New().PushDeviceToken; got != want {
t.Errorf("hostinfo.PushDeviceToken=%q, want %q", got, want)
}
}
@@ -154,158 +146,3 @@ func TestWhoIsJustIP(t *testing.T) {
})
}
}
func TestShouldDenyServeConfigForGOOSAndUserContext(t *testing.T) {
tests := []struct {
name string
goos string
configIn *ipn.ServeConfig
h *Handler
want bool
}{
{
name: "linux",
goos: "linux",
configIn: &ipn.ServeConfig{},
h: &Handler{CallerIsLocalAdmin: false},
want: false,
},
{
name: "windows-not-path-handler",
goos: "windows",
configIn: &ipn.ServeConfig{
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000"},
}},
},
},
h: &Handler{CallerIsLocalAdmin: false},
want: false,
},
{
name: "windows-path-handler-admin",
goos: "windows",
configIn: &ipn.ServeConfig{
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Path: "/tmp"},
}},
},
},
h: &Handler{CallerIsLocalAdmin: true},
want: false,
},
{
name: "windows-path-handler-not-admin",
goos: "windows",
configIn: &ipn.ServeConfig{
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Path: "/tmp"},
}},
},
},
h: &Handler{CallerIsLocalAdmin: false},
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := shouldDenyServeConfigForGOOSAndUserContext(tt.goos, tt.configIn, tt.h)
if got != tt.want {
t.Errorf("shouldDenyServeConfigForGOOSAndUserContext() got = %v, want %v", got, tt.want)
}
})
}
}
func TestServeWatchIPNBus(t *testing.T) {
tstest.Replace(t, &validLocalHostForTesting, true)
tests := []struct {
desc string
permitRead, permitWrite bool
mask ipn.NotifyWatchOpt // extra bits in addition to ipn.NotifyInitialState
wantStatus int
}{
{
desc: "no-permission",
permitRead: false,
permitWrite: false,
wantStatus: http.StatusForbidden,
},
{
desc: "read-initial-state",
permitRead: true,
permitWrite: false,
wantStatus: http.StatusForbidden,
},
{
desc: "read-initial-state-no-private-keys",
permitRead: true,
permitWrite: false,
mask: ipn.NotifyNoPrivateKeys,
wantStatus: http.StatusOK,
},
{
desc: "read-initial-state-with-private-keys",
permitRead: true,
permitWrite: true,
wantStatus: http.StatusOK,
},
}
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
h := &Handler{
PermitRead: tt.permitRead,
PermitWrite: tt.permitWrite,
b: newTestLocalBackend(t),
}
s := httptest.NewServer(h)
defer s.Close()
c := s.Client()
ctx, cancel := context.WithCancel(context.Background())
req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/localapi/v0/watch-ipn-bus?mask=%d", s.URL, ipn.NotifyInitialState|tt.mask), nil)
if err != nil {
t.Fatal(err)
}
res, err := c.Do(req)
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
// Cancel the context so that localapi stops streaming IPN bus
// updates.
cancel()
body, err := io.ReadAll(res.Body)
if err != nil && !errors.Is(err, context.Canceled) {
t.Fatal(err)
}
if res.StatusCode != tt.wantStatus {
t.Errorf("res.StatusCode=%d, want %d. body: %s", res.StatusCode, tt.wantStatus, body)
}
})
}
}
func newTestLocalBackend(t testing.TB) *ipnlocal.LocalBackend {
var logf logger.Logf = logger.Discard
sys := new(tsd.System)
store := new(mem.Store)
sys.Set(store)
eng, err := wgengine.NewFakeUserspaceEngine(logf, sys.Set)
if err != nil {
t.Fatalf("NewFakeUserspaceEngine: %v", err)
}
t.Cleanup(eng.Close)
sys.Set(eng)
lb, err := ipnlocal.NewLocalBackend(logf, logid.PublicID{}, sys, 0)
if err != nil {
t.Fatalf("NewLocalBackend: %v", err)
}
return lb
}

View File

@@ -112,11 +112,6 @@ type Prefs struct {
// policies as configured by the Tailnet's admin(s).
RunSSH bool
// RunWebClient bool is whether this node should run a web client,
// permitting access to peers according to the
// policies as configured by the Tailnet's admin(s).
RunWebClient bool
// WantRunning indicates whether networking should be active on
// this node.
WantRunning bool
@@ -205,10 +200,6 @@ type Prefs struct {
// AutoUpdatePrefs docs for more details.
AutoUpdate AutoUpdatePrefs
// AppConnector sets the app connector preferences for the node agent. See
// AppConnectorPrefs docs for more details.
AppConnector AppConnectorPrefs
// PostureChecking enables the collection of information used for device
// posture checks.
PostureChecking bool
@@ -233,13 +224,6 @@ type AutoUpdatePrefs struct {
Apply bool
}
// AppConnectorPrefs are the app connector settings for the node agent.
type AppConnectorPrefs struct {
// Advertise specifies whether the app connector subsystem is advertising
// this node as a connector.
Advertise bool
}
// MaskedPrefs is a Prefs with an associated bitmask of which fields are set.
type MaskedPrefs struct {
Prefs
@@ -252,7 +236,6 @@ type MaskedPrefs struct {
ExitNodeAllowLANAccessSet bool `json:",omitempty"`
CorpDNSSet bool `json:",omitempty"`
RunSSHSet bool `json:",omitempty"`
RunWebClientSet bool `json:",omitempty"`
WantRunningSet bool `json:",omitempty"`
LoggedOutSet bool `json:",omitempty"`
ShieldsUpSet bool `json:",omitempty"`
@@ -267,7 +250,6 @@ type MaskedPrefs struct {
OperatorUserSet bool `json:",omitempty"`
ProfileNameSet bool `json:",omitempty"`
AutoUpdateSet bool `json:",omitempty"`
AppConnectorSet bool `json:",omitempty"`
PostureCheckingSet bool `json:",omitempty"`
}
@@ -368,9 +350,6 @@ func (p *Prefs) pretty(goos string) string {
if p.RunSSH {
sb.WriteString("ssh=true ")
}
if p.RunWebClient {
sb.WriteString("webclient=true ")
}
if p.LoggedOut {
sb.WriteString("loggedout=true ")
}
@@ -410,7 +389,6 @@ func (p *Prefs) pretty(goos string) string {
fmt.Fprintf(&sb, "op=%q ", p.OperatorUser)
}
sb.WriteString(p.AutoUpdate.Pretty())
sb.WriteString(p.AppConnector.Pretty())
if p.Persist != nil {
sb.WriteString(p.Persist.Pretty())
} else {
@@ -453,7 +431,6 @@ func (p *Prefs) Equals(p2 *Prefs) bool {
p.ExitNodeAllowLANAccess == p2.ExitNodeAllowLANAccess &&
p.CorpDNS == p2.CorpDNS &&
p.RunSSH == p2.RunSSH &&
p.RunWebClient == p2.RunWebClient &&
p.WantRunning == p2.WantRunning &&
p.LoggedOut == p2.LoggedOut &&
p.NotepadURLs == p2.NotepadURLs &&
@@ -468,7 +445,6 @@ func (p *Prefs) Equals(p2 *Prefs) bool {
p.Persist.Equals(p2.Persist) &&
p.ProfileName == p2.ProfileName &&
p.AutoUpdate == p2.AutoUpdate &&
p.AppConnector == p2.AppConnector &&
p.PostureChecking == p2.PostureChecking
}
@@ -482,13 +458,6 @@ func (au AutoUpdatePrefs) Pretty() string {
return "update=off "
}
func (ap AppConnectorPrefs) Pretty() string {
if ap.Advertise {
return "appconnector=advertise "
}
return ""
}
func compareIPNets(a, b []netip.Prefix) bool {
if len(a) != len(b) {
return false
@@ -722,18 +691,6 @@ func (p *Prefs) ShouldSSHBeRunning() bool {
return p.WantRunning && p.RunSSH
}
// ShouldWebClientBeRunning reports whether the web client server should be running based on
// the prefs.
func (p PrefsView) ShouldWebClientBeRunning() bool {
return p.Valid() && p.ж.ShouldWebClientBeRunning()
}
// ShouldWebClientBeRunning reports whether the web client server should be running based on
// the prefs.
func (p *Prefs) ShouldWebClientBeRunning() bool {
return p.WantRunning && p.RunWebClient
}
// PrefsFromBytes deserializes Prefs from a JSON blob.
func PrefsFromBytes(b []byte) (*Prefs, error) {
p := NewPrefs()

View File

@@ -43,7 +43,6 @@ func TestPrefsEqual(t *testing.T) {
"ExitNodeAllowLANAccess",
"CorpDNS",
"RunSSH",
"RunWebClient",
"WantRunning",
"LoggedOut",
"ShieldsUp",
@@ -58,7 +57,6 @@ func TestPrefsEqual(t *testing.T) {
"OperatorUser",
"ProfileName",
"AutoUpdate",
"AppConnector",
"PostureChecking",
"Persist",
}
@@ -307,16 +305,6 @@ func TestPrefsEqual(t *testing.T) {
&Prefs{AutoUpdate: AutoUpdatePrefs{Check: true, Apply: false}},
true,
},
{
&Prefs{AppConnector: AppConnectorPrefs{Advertise: true}},
&Prefs{AppConnector: AppConnectorPrefs{Advertise: true}},
true,
},
{
&Prefs{AppConnector: AppConnectorPrefs{Advertise: true}},
&Prefs{AppConnector: AppConnectorPrefs{Advertise: false}},
false,
},
{
&Prefs{PostureChecking: true},
&Prefs{PostureChecking: true},
@@ -527,24 +515,6 @@ func TestPrefsPretty(t *testing.T) {
"linux",
`Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off update=on Persist=nil}`,
},
{
Prefs{
AppConnector: AppConnectorPrefs{
Advertise: true,
},
},
"linux",
`Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off update=off appconnector=advertise Persist=nil}`,
},
{
Prefs{
AppConnector: AppConnectorPrefs{
Advertise: false,
},
},
"linux",
`Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off update=off Persist=nil}`,
},
}
for i, tt := range tests {
got := tt.p.pretty(tt.os)

View File

@@ -163,30 +163,6 @@ func (sc *ServeConfig) GetTCPPortHandler(port uint16) *TCPPortHandler {
return sc.TCP[port]
}
// HasPathHandler reports whether if ServeConfig has at least
// one path handler, including foreground configs.
func (sc *ServeConfig) HasPathHandler() bool {
if sc.Web != nil {
for _, webServerConfig := range sc.Web {
for _, httpHandler := range webServerConfig.Handlers {
if httpHandler.Path != "" {
return true
}
}
}
}
if sc.Foreground != nil {
for _, fgConfig := range sc.Foreground {
if fgConfig.HasPathHandler() {
return true
}
}
}
return false
}
// IsTCPForwardingAny reports whether ServeConfig is currently forwarding in
// TCPForward mode on any port. This is exclusive of Web/HTTPS serving.
func (sc *ServeConfig) IsTCPForwardingAny() bool {

View File

@@ -43,86 +43,3 @@ func TestCheckFunnelAccess(t *testing.T) {
}
}
}
func TestHasPathHandler(t *testing.T) {
tests := []struct {
name string
cfg ServeConfig
want bool
}{
{
name: "empty-config",
cfg: ServeConfig{},
want: false,
},
{
name: "with-bg-path-handler",
cfg: ServeConfig{
TCP: map[uint16]*TCPPortHandler{80: {HTTP: true}},
Web: map[HostPort]*WebServerConfig{
"foo.test.ts.net:80": {Handlers: map[string]*HTTPHandler{
"/": {Path: "/tmp"},
}},
},
},
want: true,
},
{
name: "with-fg-path-handler",
cfg: ServeConfig{
TCP: map[uint16]*TCPPortHandler{
443: {HTTPS: true},
},
Foreground: map[string]*ServeConfig{
"abc123": {
TCP: map[uint16]*TCPPortHandler{80: {HTTP: true}},
Web: map[HostPort]*WebServerConfig{
"foo.test.ts.net:80": {Handlers: map[string]*HTTPHandler{
"/": {Path: "/tmp"},
}},
},
},
},
},
want: true,
},
{
name: "with-no-bg-path-handler",
cfg: ServeConfig{
TCP: map[uint16]*TCPPortHandler{443: {HTTPS: true}},
Web: map[HostPort]*WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000"},
}},
},
AllowFunnel: map[HostPort]bool{"foo.test.ts.net:443": true},
},
want: false,
},
{
name: "with-no-fg-path-handler",
cfg: ServeConfig{
Foreground: map[string]*ServeConfig{
"abc123": {
TCP: map[uint16]*TCPPortHandler{443: {HTTPS: true}},
Web: map[HostPort]*WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000"},
}},
},
AllowFunnel: map[HostPort]bool{"foo.test.ts.net:443": true},
},
},
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.cfg.HasPathHandler()
if tt.want != got {
t.Errorf("HasPathHandler() = %v, want %v", got, tt.want)
}
})
}
}

View File

@@ -13,59 +13,57 @@ Client][]. See also the dependencies in the [Tailscale CLI][].
- [gioui.org](https://pkg.go.dev/gioui.org) ([MIT](https://git.sr.ht/~eliasnaur/gio/tree/32c6a9b10d0b/LICENSE))
- [gioui.org/cpu](https://pkg.go.dev/gioui.org/cpu) ([MIT](https://git.sr.ht/~eliasnaur/gio-cpu/tree/8d6a761490d2/LICENSE))
- [gioui.org/shader](https://pkg.go.dev/gioui.org/shader) ([MIT](https://git.sr.ht/~eliasnaur/gio-shader/tree/v1.0.6/LICENSE))
- [github.com/aws/aws-sdk-go-v2](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/v1.21.0/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/config](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/config) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/config/v1.18.42/config/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/credentials](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/credentials) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/credentials/v1.13.40/credentials/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/feature/ec2/imds](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/feature/ec2/imds) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/feature/ec2/imds/v1.13.11/feature/ec2/imds/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/internal/configsources](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/internal/configsources) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/internal/configsources/v1.1.41/internal/configsources/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/internal/endpoints/v2](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/internal/endpoints/v2) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/internal/endpoints/v2.4.35/internal/endpoints/v2/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/internal/ini](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/internal/ini) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/internal/ini/v1.3.43/internal/ini/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/internal/sync/singleflight](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/internal/sync/singleflight) ([BSD-3-Clause](https://github.com/aws/aws-sdk-go-v2/blob/v1.21.0/internal/sync/singleflight/LICENSE))
- [github.com/aws/aws-sdk-go-v2/service/internal/presigned-url](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/service/internal/presigned-url) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/service/internal/presigned-url/v1.9.35/service/internal/presigned-url/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/service/ssm](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/service/ssm) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/service/ssm/v1.38.0/service/ssm/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/service/sso](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/service/sso) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/service/sso/v1.14.1/service/sso/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/service/ssooidc](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/service/ssooidc) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/service/ssooidc/v1.17.1/service/ssooidc/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/service/sts](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/service/sts) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/service/sts/v1.22.0/service/sts/LICENSE.txt))
- [github.com/aws/smithy-go](https://pkg.go.dev/github.com/aws/smithy-go) ([Apache-2.0](https://github.com/aws/smithy-go/blob/v1.14.2/LICENSE))
- [github.com/aws/smithy-go/internal/sync/singleflight](https://pkg.go.dev/github.com/aws/smithy-go/internal/sync/singleflight) ([BSD-3-Clause](https://github.com/aws/smithy-go/blob/v1.14.2/internal/sync/singleflight/LICENSE))
- [github.com/aws/aws-sdk-go-v2](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/v1.18.0/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/config](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/config) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/config/v1.18.22/config/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/credentials](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/credentials) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/credentials/v1.13.21/credentials/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/feature/ec2/imds](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/feature/ec2/imds) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/feature/ec2/imds/v1.13.3/feature/ec2/imds/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/internal/configsources](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/internal/configsources) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/internal/configsources/v1.1.33/internal/configsources/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/internal/endpoints/v2](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/internal/endpoints/v2) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/internal/endpoints/v2.4.27/internal/endpoints/v2/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/internal/ini](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/internal/ini) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/internal/ini/v1.3.34/internal/ini/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/internal/sync/singleflight](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/internal/sync/singleflight) ([BSD-3-Clause](https://github.com/aws/aws-sdk-go-v2/blob/v1.18.0/internal/sync/singleflight/LICENSE))
- [github.com/aws/aws-sdk-go-v2/service/internal/presigned-url](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/service/internal/presigned-url) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/service/internal/presigned-url/v1.9.27/service/internal/presigned-url/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/service/ssm](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/service/ssm) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/service/ssm/v1.36.3/service/ssm/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/service/sso](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/service/sso) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/service/sso/v1.12.9/service/sso/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/service/ssooidc](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/service/ssooidc) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/service/ssooidc/v1.14.9/service/ssooidc/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/service/sts](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/service/sts) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/service/sts/v1.18.10/service/sts/LICENSE.txt))
- [github.com/aws/smithy-go](https://pkg.go.dev/github.com/aws/smithy-go) ([Apache-2.0](https://github.com/aws/smithy-go/blob/v1.13.5/LICENSE))
- [github.com/aws/smithy-go/internal/sync/singleflight](https://pkg.go.dev/github.com/aws/smithy-go/internal/sync/singleflight) ([BSD-3-Clause](https://github.com/aws/smithy-go/blob/v1.13.5/internal/sync/singleflight/LICENSE))
- [github.com/benoitkugler/textlayout](https://pkg.go.dev/github.com/benoitkugler/textlayout) ([MIT](https://github.com/benoitkugler/textlayout/blob/v0.3.0/LICENSE))
- [github.com/benoitkugler/textlayout/fonts](https://pkg.go.dev/github.com/benoitkugler/textlayout/fonts) ([MIT](https://github.com/benoitkugler/textlayout/blob/v0.3.0/fonts/LICENSE))
- [github.com/benoitkugler/textlayout/graphite](https://pkg.go.dev/github.com/benoitkugler/textlayout/graphite) ([MIT](https://github.com/benoitkugler/textlayout/blob/v0.3.0/graphite/LICENSE))
- [github.com/benoitkugler/textlayout/harfbuzz](https://pkg.go.dev/github.com/benoitkugler/textlayout/harfbuzz) ([MIT](https://github.com/benoitkugler/textlayout/blob/v0.3.0/harfbuzz/LICENSE))
- [github.com/coreos/go-iptables/iptables](https://pkg.go.dev/github.com/coreos/go-iptables/iptables) ([Apache-2.0](https://github.com/coreos/go-iptables/blob/v0.7.0/LICENSE))
- [github.com/coreos/go-systemd/v22/dbus](https://pkg.go.dev/github.com/coreos/go-systemd/v22/dbus) ([Apache-2.0](https://github.com/coreos/go-systemd/blob/v22.5.0/LICENSE))
- [github.com/digitalocean/go-smbios/smbios](https://pkg.go.dev/github.com/digitalocean/go-smbios/smbios) ([Apache-2.0](https://github.com/digitalocean/go-smbios/blob/390a4f403a8e/LICENSE.md))
- [github.com/fxamacker/cbor/v2](https://pkg.go.dev/github.com/fxamacker/cbor/v2) ([MIT](https://github.com/fxamacker/cbor/blob/v2.5.0/LICENSE))
- [github.com/coreos/go-iptables/iptables](https://pkg.go.dev/github.com/coreos/go-iptables/iptables) ([Apache-2.0](https://github.com/coreos/go-iptables/blob/v0.6.0/LICENSE))
- [github.com/coreos/go-systemd/v22/dbus](https://pkg.go.dev/github.com/coreos/go-systemd/v22/dbus) ([Apache-2.0](https://github.com/coreos/go-systemd/blob/v22.4.0/LICENSE))
- [github.com/fxamacker/cbor/v2](https://pkg.go.dev/github.com/fxamacker/cbor/v2) ([MIT](https://github.com/fxamacker/cbor/blob/v2.4.0/LICENSE))
- [github.com/go-text/typesetting](https://pkg.go.dev/github.com/go-text/typesetting) ([BSD-3-Clause](https://github.com/go-text/typesetting/blob/0399769901d5/LICENSE))
- [github.com/godbus/dbus/v5](https://pkg.go.dev/github.com/godbus/dbus/v5) ([BSD-2-Clause](https://github.com/godbus/dbus/blob/76236955d466/LICENSE))
- [github.com/golang/groupcache/lru](https://pkg.go.dev/github.com/golang/groupcache/lru) ([Apache-2.0](https://github.com/golang/groupcache/blob/41bb18bfe9da/LICENSE))
- [github.com/google/btree](https://pkg.go.dev/github.com/google/btree) ([Apache-2.0](https://github.com/google/btree/blob/v1.1.2/LICENSE))
- [github.com/google/nftables](https://pkg.go.dev/github.com/google/nftables) ([Apache-2.0](https://github.com/google/nftables/blob/9aa6fdf5a28c/LICENSE))
- [github.com/google/uuid](https://pkg.go.dev/github.com/google/uuid) ([BSD-3-Clause](https://github.com/google/uuid/blob/v1.3.1/LICENSE))
- [github.com/google/uuid](https://pkg.go.dev/github.com/google/uuid) ([BSD-3-Clause](https://github.com/google/uuid/blob/v1.3.0/LICENSE))
- [github.com/hdevalence/ed25519consensus](https://pkg.go.dev/github.com/hdevalence/ed25519consensus) ([BSD-3-Clause](https://github.com/hdevalence/ed25519consensus/blob/v0.1.0/LICENSE))
- [github.com/illarion/gonotify](https://pkg.go.dev/github.com/illarion/gonotify) ([MIT](https://github.com/illarion/gonotify/blob/v1.0.1/LICENSE))
- [github.com/insomniacslk/dhcp](https://pkg.go.dev/github.com/insomniacslk/dhcp) ([BSD-3-Clause](https://github.com/insomniacslk/dhcp/blob/65c27093e38a/LICENSE))
- [github.com/insomniacslk/dhcp](https://pkg.go.dev/github.com/insomniacslk/dhcp) ([BSD-3-Clause](https://github.com/insomniacslk/dhcp/blob/974c6f05fe16/LICENSE))
- [github.com/jmespath/go-jmespath](https://pkg.go.dev/github.com/jmespath/go-jmespath) ([Apache-2.0](https://github.com/jmespath/go-jmespath/blob/v0.4.0/LICENSE))
- [github.com/josharian/native](https://pkg.go.dev/github.com/josharian/native) ([MIT](https://github.com/josharian/native/blob/5c7d0dd6ab86/license))
- [github.com/jsimonetti/rtnetlink](https://pkg.go.dev/github.com/jsimonetti/rtnetlink) ([MIT](https://github.com/jsimonetti/rtnetlink/blob/v1.3.5/LICENSE.md))
- [github.com/klauspost/compress](https://pkg.go.dev/github.com/klauspost/compress) ([Apache-2.0](https://github.com/klauspost/compress/blob/v1.17.0/LICENSE))
- [github.com/klauspost/compress/internal/snapref](https://pkg.go.dev/github.com/klauspost/compress/internal/snapref) ([BSD-3-Clause](https://github.com/klauspost/compress/blob/v1.17.0/internal/snapref/LICENSE))
- [github.com/klauspost/compress/zstd/internal/xxhash](https://pkg.go.dev/github.com/klauspost/compress/zstd/internal/xxhash) ([MIT](https://github.com/klauspost/compress/blob/v1.17.0/zstd/internal/xxhash/LICENSE.txt))
- [github.com/jsimonetti/rtnetlink](https://pkg.go.dev/github.com/jsimonetti/rtnetlink) ([MIT](https://github.com/jsimonetti/rtnetlink/blob/v1.3.2/LICENSE.md))
- [github.com/klauspost/compress](https://pkg.go.dev/github.com/klauspost/compress) ([Apache-2.0](https://github.com/klauspost/compress/blob/v1.16.7/LICENSE))
- [github.com/klauspost/compress/internal/snapref](https://pkg.go.dev/github.com/klauspost/compress/internal/snapref) ([BSD-3-Clause](https://github.com/klauspost/compress/blob/v1.16.7/internal/snapref/LICENSE))
- [github.com/klauspost/compress/zstd/internal/xxhash](https://pkg.go.dev/github.com/klauspost/compress/zstd/internal/xxhash) ([MIT](https://github.com/klauspost/compress/blob/v1.16.7/zstd/internal/xxhash/LICENSE.txt))
- [github.com/kortschak/wol](https://pkg.go.dev/github.com/kortschak/wol) ([BSD-3-Clause](https://github.com/kortschak/wol/blob/da482cc4850a/LICENSE))
- [github.com/mdlayher/genetlink](https://pkg.go.dev/github.com/mdlayher/genetlink) ([MIT](https://github.com/mdlayher/genetlink/blob/v1.3.2/LICENSE.md))
- [github.com/mdlayher/netlink](https://pkg.go.dev/github.com/mdlayher/netlink) ([MIT](https://github.com/mdlayher/netlink/blob/v1.7.2/LICENSE.md))
- [github.com/mdlayher/sdnotify](https://pkg.go.dev/github.com/mdlayher/sdnotify) ([MIT](https://github.com/mdlayher/sdnotify/blob/v1.0.0/LICENSE.md))
- [github.com/mdlayher/socket](https://pkg.go.dev/github.com/mdlayher/socket) ([MIT](https://github.com/mdlayher/socket/blob/v0.5.0/LICENSE.md))
- [github.com/miekg/dns](https://pkg.go.dev/github.com/miekg/dns) ([BSD-3-Clause](https://github.com/miekg/dns/blob/v1.1.56/LICENSE))
- [github.com/mdlayher/socket](https://pkg.go.dev/github.com/mdlayher/socket) ([MIT](https://github.com/mdlayher/socket/blob/v0.4.1/LICENSE.md))
- [github.com/miekg/dns](https://pkg.go.dev/github.com/miekg/dns) ([BSD-3-Clause](https://github.com/miekg/dns/blob/v1.1.55/LICENSE))
- [github.com/mitchellh/go-ps](https://pkg.go.dev/github.com/mitchellh/go-ps) ([MIT](https://github.com/mitchellh/go-ps/blob/v1.0.0/LICENSE.md))
- [github.com/pierrec/lz4/v4](https://pkg.go.dev/github.com/pierrec/lz4/v4) ([BSD-3-Clause](https://github.com/pierrec/lz4/blob/v4.1.18/LICENSE))
- [github.com/pierrec/lz4/v4](https://pkg.go.dev/github.com/pierrec/lz4/v4) ([BSD-3-Clause](https://github.com/pierrec/lz4/blob/v4.1.17/LICENSE))
- [github.com/skip2/go-qrcode](https://pkg.go.dev/github.com/skip2/go-qrcode) ([MIT](https://github.com/skip2/go-qrcode/blob/da1b6568686e/LICENSE))
- [github.com/tailscale/golang-x-crypto](https://pkg.go.dev/github.com/tailscale/golang-x-crypto) ([BSD-3-Clause](https://github.com/tailscale/golang-x-crypto/blob/f0b76a10a08e/LICENSE))
- [github.com/tailscale/goupnp](https://pkg.go.dev/github.com/tailscale/goupnp) ([BSD-2-Clause](https://github.com/tailscale/goupnp/blob/c64d0f06ea05/LICENSE))
- [github.com/tailscale/hujson](https://pkg.go.dev/github.com/tailscale/hujson) ([BSD-3-Clause](https://github.com/tailscale/hujson/blob/20486734a56a/LICENSE))
- [github.com/tailscale/netlink](https://pkg.go.dev/github.com/tailscale/netlink) ([Apache-2.0](https://github.com/tailscale/netlink/blob/cabfb018fe85/LICENSE))
- [github.com/tailscale/tailscale-android](https://pkg.go.dev/github.com/tailscale/tailscale-android) ([BSD-3-Clause](https://github.com/tailscale/tailscale-android/blob/HEAD/LICENSE))
- [github.com/tailscale/wireguard-go](https://pkg.go.dev/github.com/tailscale/wireguard-go) ([MIT](https://github.com/tailscale/wireguard-go/blob/2f6748dc88e7/LICENSE))
- [github.com/tailscale/wireguard-go](https://pkg.go.dev/github.com/tailscale/wireguard-go) ([MIT](https://github.com/tailscale/wireguard-go/blob/93bd5cbf7fd8/LICENSE))
- [github.com/tcnksm/go-httpstat](https://pkg.go.dev/github.com/tcnksm/go-httpstat) ([MIT](https://github.com/tcnksm/go-httpstat/blob/v0.2.0/LICENSE))
- [github.com/u-root/uio](https://pkg.go.dev/github.com/u-root/uio) ([BSD-3-Clause](https://github.com/u-root/uio/blob/3e8cd9d6bf63/LICENSE))
- [github.com/vishvananda/netlink/nl](https://pkg.go.dev/github.com/vishvananda/netlink/nl) ([Apache-2.0](https://github.com/vishvananda/netlink/blob/v1.2.1-beta.2/LICENSE))
@@ -73,19 +71,19 @@ Client][]. See also the dependencies in the [Tailscale CLI][].
- [github.com/x448/float16](https://pkg.go.dev/github.com/x448/float16) ([MIT](https://github.com/x448/float16/blob/v0.8.4/LICENSE))
- [go4.org/intern](https://pkg.go.dev/go4.org/intern) ([BSD-3-Clause](https://github.com/go4org/intern/blob/ae77deb06f29/LICENSE))
- [go4.org/mem](https://pkg.go.dev/go4.org/mem) ([Apache-2.0](https://github.com/go4org/mem/blob/4f986261bf13/LICENSE))
- [go4.org/netipx](https://pkg.go.dev/go4.org/netipx) ([BSD-3-Clause](https://github.com/go4org/netipx/blob/6213f710f925/LICENSE))
- [go4.org/netipx](https://pkg.go.dev/go4.org/netipx) ([BSD-3-Clause](https://github.com/go4org/netipx/blob/ad4cb58a6516/LICENSE))
- [go4.org/unsafe/assume-no-moving-gc](https://pkg.go.dev/go4.org/unsafe/assume-no-moving-gc) ([BSD-3-Clause](https://github.com/go4org/unsafe-assume-no-moving-gc/blob/e7c30c78aeb2/LICENSE))
- [golang.org/x/crypto](https://pkg.go.dev/golang.org/x/crypto) ([BSD-3-Clause](https://cs.opensource.google/go/x/crypto/+/v0.14.0:LICENSE))
- [golang.org/x/exp](https://pkg.go.dev/golang.org/x/exp) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/92128663:LICENSE))
- [golang.org/x/crypto](https://pkg.go.dev/golang.org/x/crypto) ([BSD-3-Clause](https://cs.opensource.google/go/x/crypto/+/v0.12.0:LICENSE))
- [golang.org/x/exp](https://pkg.go.dev/golang.org/x/exp) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/515e97eb:LICENSE))
- [golang.org/x/exp/shiny](https://pkg.go.dev/golang.org/x/exp/shiny) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/334a2380:shiny/LICENSE))
- [golang.org/x/image](https://pkg.go.dev/golang.org/x/image) ([BSD-3-Clause](https://cs.opensource.google/go/x/image/+/v0.12.0:LICENSE))
- [golang.org/x/net](https://pkg.go.dev/golang.org/x/net) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.17.0:LICENSE))
- [golang.org/x/sync/errgroup](https://pkg.go.dev/golang.org/x/sync/errgroup) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.3.0:LICENSE))
- [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.13.0:LICENSE))
- [golang.org/x/term](https://pkg.go.dev/golang.org/x/term) ([BSD-3-Clause](https://cs.opensource.google/go/x/term/+/v0.13.0:LICENSE))
- [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.13.0:LICENSE))
- [golang.org/x/image](https://pkg.go.dev/golang.org/x/image) ([BSD-3-Clause](https://cs.opensource.google/go/x/image/+/v0.7.0:LICENSE))
- [golang.org/x/net](https://pkg.go.dev/golang.org/x/net) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.14.0:LICENSE))
- [golang.org/x/sync/errgroup](https://pkg.go.dev/golang.org/x/sync/errgroup) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.2.0:LICENSE))
- [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.11.0:LICENSE))
- [golang.org/x/term](https://pkg.go.dev/golang.org/x/term) ([BSD-3-Clause](https://cs.opensource.google/go/x/term/+/v0.11.0:LICENSE))
- [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.12.0:LICENSE))
- [golang.org/x/time/rate](https://pkg.go.dev/golang.org/x/time/rate) ([BSD-3-Clause](https://cs.opensource.google/go/x/time/+/v0.3.0:LICENSE))
- [gvisor.dev/gvisor/pkg](https://pkg.go.dev/gvisor.dev/gvisor/pkg) ([Apache-2.0](https://github.com/google/gvisor/blob/4fe30062272c/LICENSE))
- [gvisor.dev/gvisor/pkg](https://pkg.go.dev/gvisor.dev/gvisor/pkg) ([Apache-2.0](https://github.com/google/gvisor/blob/7b0a1988a28f/LICENSE))
- [inet.af/netaddr](https://pkg.go.dev/inet.af/netaddr) ([BSD-3-Clause](https://github.com/inetaf/netaddr/blob/097006376321/LICENSE))
- [inet.af/peercred](https://pkg.go.dev/inet.af/peercred) ([BSD-3-Clause](https://github.com/inetaf/peercred/blob/0893ea02156a/LICENSE))
- [nhooyr.io/websocket](https://pkg.go.dev/nhooyr.io/websocket) ([MIT](https://github.com/nhooyr/websocket/blob/v1.8.7/LICENSE.txt))

View File

@@ -14,8 +14,9 @@ Some packages may only be included on certain architectures or operating systems
- [filippo.io/edwards25519](https://pkg.go.dev/filippo.io/edwards25519) ([BSD-3-Clause](https://github.com/FiloSottile/edwards25519/blob/v1.0.0/LICENSE))
- [github.com/Microsoft/go-winio](https://pkg.go.dev/github.com/Microsoft/go-winio) ([MIT](https://github.com/Microsoft/go-winio/blob/v0.6.1/LICENSE))
- [github.com/akutz/memconn](https://pkg.go.dev/github.com/akutz/memconn) ([Apache-2.0](https://github.com/akutz/memconn/blob/v0.1.0/LICENSE))
- [github.com/alexbrainman/sspi](https://pkg.go.dev/github.com/alexbrainman/sspi) ([BSD-3-Clause](https://github.com/alexbrainman/sspi/blob/1a75b4708caa/LICENSE))
- [github.com/alexbrainman/sspi](https://pkg.go.dev/github.com/alexbrainman/sspi) ([BSD-3-Clause](https://github.com/alexbrainman/sspi/blob/909beea2cc74/LICENSE))
- [github.com/anmitsu/go-shlex](https://pkg.go.dev/github.com/anmitsu/go-shlex) ([MIT](https://github.com/anmitsu/go-shlex/blob/38f4b401e2be/LICENSE))
- [github.com/aws/aws-sdk-go-v2](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/v1.21.0/LICENSE.txt))
- [github.com/aws/aws-sdk-go-v2/config](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/config) ([Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/config/v1.18.42/config/LICENSE.txt))
@@ -71,10 +72,8 @@ Some packages may only be included on certain architectures or operating systems
- [github.com/pkg/errors](https://pkg.go.dev/github.com/pkg/errors) ([BSD-2-Clause](https://github.com/pkg/errors/blob/v0.9.1/LICENSE))
- [github.com/pkg/sftp](https://pkg.go.dev/github.com/pkg/sftp) ([BSD-2-Clause](https://github.com/pkg/sftp/blob/v1.13.6/LICENSE))
- [github.com/skip2/go-qrcode](https://pkg.go.dev/github.com/skip2/go-qrcode) ([MIT](https://github.com/skip2/go-qrcode/blob/da1b6568686e/LICENSE))
- [github.com/tailscale/certstore](https://pkg.go.dev/github.com/tailscale/certstore) ([MIT](https://github.com/tailscale/certstore/blob/77811a65f4ff/LICENSE.md))
- [github.com/tailscale/go-winio](https://pkg.go.dev/github.com/tailscale/go-winio) ([MIT](https://github.com/tailscale/go-winio/blob/c4f33415bf55/LICENSE))
- [github.com/tailscale/certstore](https://pkg.go.dev/github.com/tailscale/certstore) ([MIT](https://github.com/tailscale/certstore/blob/78d6e1c49d8d/LICENSE.md))
- [github.com/tailscale/golang-x-crypto](https://pkg.go.dev/github.com/tailscale/golang-x-crypto) ([BSD-3-Clause](https://github.com/tailscale/golang-x-crypto/blob/f0b76a10a08e/LICENSE))
- [github.com/tailscale/hujson](https://pkg.go.dev/github.com/tailscale/hujson) ([BSD-3-Clause](https://github.com/tailscale/hujson/blob/20486734a56a/LICENSE))
- [github.com/tailscale/netlink](https://pkg.go.dev/github.com/tailscale/netlink) ([Apache-2.0](https://github.com/tailscale/netlink/blob/cabfb018fe85/LICENSE))
- [github.com/tailscale/web-client-prebuilt](https://pkg.go.dev/github.com/tailscale/web-client-prebuilt) ([BSD-3-Clause](https://github.com/tailscale/web-client-prebuilt/blob/7bcd7bca7bc5/LICENSE))
- [github.com/tailscale/wireguard-go](https://pkg.go.dev/github.com/tailscale/wireguard-go) ([MIT](https://github.com/tailscale/wireguard-go/blob/2f6748dc88e7/LICENSE))

View File

@@ -64,7 +64,7 @@ var (
var rootServersV4 = []netip.Addr{
netip.MustParseAddr("198.41.0.4"), // a.root-servers.net
netip.MustParseAddr("170.247.170.2"), // b.root-servers.net
netip.MustParseAddr("199.9.14.201"), // b.root-servers.net
netip.MustParseAddr("192.33.4.12"), // c.root-servers.net
netip.MustParseAddr("199.7.91.13"), // d.root-servers.net
netip.MustParseAddr("192.203.230.10"), // e.root-servers.net
@@ -80,7 +80,7 @@ var rootServersV4 = []netip.Addr{
var rootServersV6 = []netip.Addr{
netip.MustParseAddr("2001:503:ba3e::2:30"), // a.root-servers.net
netip.MustParseAddr("2801:1b8:10::b"), // b.root-servers.net
netip.MustParseAddr("2001:500:200::b"), // b.root-servers.net
netip.MustParseAddr("2001:500:2::c"), // c.root-servers.net
netip.MustParseAddr("2001:500:2d::d"), // d.root-servers.net
netip.MustParseAddr("2001:500:a8::e"), // e.root-servers.net

View File

@@ -314,9 +314,9 @@ func parseExitNodeQuery(q []byte) *response {
return p.response()
}
// HandlePeerDNSQuery handles a DNS query that arrived from a peer
// via the peerapi's DoH server. This is used when the local
// node is being an exit node or an app connector.
// HandleExitNodeDNSQuery handles a DNS query that arrived from a peer
// via the peerapi's DoH server. This is only used when the local
// node is being an exit node.
//
// The provided allowName callback is whether a DNS query for a name
// (as found by parsing q) is allowed.
@@ -325,7 +325,7 @@ func parseExitNodeQuery(q []byte) *response {
// still result in a response DNS packet (saying there's a failure)
// and a nil error.
// TODO: figure out if we even need an error result.
func (r *Resolver) HandlePeerDNSQuery(ctx context.Context, q []byte, from netip.AddrPort, allowName func(name string) bool) (res []byte, err error) {
func (r *Resolver) HandleExitNodeDNSQuery(ctx context.Context, q []byte, from netip.AddrPort, allowName func(name string) bool) (res []byte, err error) {
metricDNSExitProxyQuery.Add(1)
ch := make(chan packet, 1)

View File

@@ -1,6 +1,8 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// TODO(bradfitz): update this code to use netaddr more
// Package dnscache contains a minimal DNS cache that makes a bunch of
// assumptions that are only valid for us. Not recommended for general use.
package dnscache

Some files were not shown because too many files have changed in this diff Show More