Compare commits

..

2 Commits

Author SHA1 Message Date
Irbe Krumina
350d37286d Ingress for VIP
Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2024-02-25 09:03:35 +00:00
Irbe Krumina
ab1eb428d9 WIP
Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2024-02-21 18:04:08 +00:00
116 changed files with 3400 additions and 2586 deletions

View File

@@ -208,7 +208,7 @@ jobs:
env:
HOME: "/tmp"
TMPDIR: "/tmp"
XDG_CACHE_HOME: "/var/lib/ghrunner/cache"
XDB_CACHE_HOME: "/var/lib/ghrunner/cache"
race-build:
runs-on: ubuntu-22.04

View File

@@ -36,7 +36,7 @@ func TestDeps(t *testing.T) {
// Make sure we don't again accidentally bring in a dependency on
// TailFS or its transitive dependencies
"tailscale.com/tailfs/tailfsimpl": "https://github.com/tailscale/tailscale/pull/10631",
"github.com/studio-b12/gowebdav": "https://github.com/tailscale/tailscale/pull/10631",
"github.com/tailscale/gowebdav": "https://github.com/tailscale/tailscale/pull/10631",
},
}.Check(t)
}

View File

@@ -19,10 +19,9 @@
"zustand": "^4.4.7"
},
"devDependencies": {
"@types/node": "^18.16.1",
"@types/react": "^18.0.20",
"@types/react-dom": "^18.0.6",
"@vitejs/plugin-react-swc": "^3.6.0",
"@vitejs/plugin-react-swc": "^3.3.2",
"autoprefixer": "^10.4.15",
"eslint": "^8.23.1",
"eslint-config-react-app": "^7.0.1",
@@ -33,10 +32,11 @@
"prettier-plugin-organize-imports": "^3.2.2",
"tailwindcss": "^3.3.3",
"typescript": "^4.7.4",
"vite": "^5.1.4",
"vite-plugin-svgr": "^4.2.0",
"vite": "^4.5.2",
"vite-plugin-rewrite-all": "^1.0.1",
"vite-plugin-svgr": "^3.2.0",
"vite-tsconfig-paths": "^3.5.0",
"vitest": "^1.3.1"
"vitest": "^0.32.0"
},
"scripts": {
"build": "vite build",

View File

@@ -4,8 +4,8 @@
import * as Primitive from "@radix-ui/react-popover"
import cx from "classnames"
import React, { useCallback } from "react"
import ChevronDown from "src/assets/icons/chevron-down.svg?react"
import Copy from "src/assets/icons/copy.svg?react"
import { ReactComponent as ChevronDown } from "src/assets/icons/chevron-down.svg"
import { ReactComponent as Copy } from "src/assets/icons/copy.svg"
import NiceIP from "src/components/nice-ip"
import useToaster from "src/hooks/toaster"
import Button from "src/ui/button"

View File

@@ -2,7 +2,7 @@
// SPDX-License-Identifier: BSD-3-Clause
import React from "react"
import TailscaleIcon from "src/assets/icons/tailscale-icon.svg?react"
import { ReactComponent as TailscaleIcon } from "src/assets/icons/tailscale-icon.svg"
import LoginToggle from "src/components/login-toggle"
import DeviceDetailsView from "src/components/views/device-details-view"
import DisconnectedView from "src/components/views/disconnected-view"

View File

@@ -4,8 +4,8 @@
import cx from "classnames"
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useAPI } from "src/api"
import Check from "src/assets/icons/check.svg?react"
import ChevronDown from "src/assets/icons/chevron-down.svg?react"
import { ReactComponent as Check } from "src/assets/icons/check.svg"
import { ReactComponent as ChevronDown } from "src/assets/icons/chevron-down.svg"
import useExitNodes, {
noExitNode,
runAsExitNode,

View File

@@ -3,9 +3,9 @@
import cx from "classnames"
import React, { useCallback, useEffect, useState } from "react"
import ChevronDown from "src/assets/icons/chevron-down.svg?react"
import Eye from "src/assets/icons/eye.svg?react"
import User from "src/assets/icons/user.svg?react"
import { ReactComponent as ChevronDown } from "src/assets/icons/chevron-down.svg"
import { ReactComponent as Eye } from "src/assets/icons/eye.svg"
import { ReactComponent as User } from "src/assets/icons/user.svg"
import { AuthResponse, AuthType } from "src/hooks/auth"
import { NodeData } from "src/types"
import Button from "src/ui/button"

View File

@@ -2,7 +2,7 @@
// SPDX-License-Identifier: BSD-3-Clause
import React from "react"
import TailscaleIcon from "src/assets/icons/tailscale-icon.svg?react"
import { ReactComponent as TailscaleIcon } from "src/assets/icons/tailscale-icon.svg"
/**
* DisconnectedView is rendered after node logout.

View File

@@ -4,8 +4,8 @@
import cx from "classnames"
import React, { useMemo } from "react"
import { apiFetch } from "src/api"
import ArrowRight from "src/assets/icons/arrow-right.svg?react"
import Machine from "src/assets/icons/machine.svg?react"
import { ReactComponent as ArrowRight } from "src/assets/icons/arrow-right.svg"
import { ReactComponent as Machine } from "src/assets/icons/machine.svg"
import AddressCard from "src/components/address-copy-card"
import ExitNodeSelector from "src/components/exit-node-selector"
import { NodeData } from "src/types"

View File

@@ -3,7 +3,7 @@
import React, { useState } from "react"
import { useAPI } from "src/api"
import TailscaleIcon from "src/assets/icons/tailscale-icon.svg?react"
import { ReactComponent as TailscaleIcon } from "src/assets/icons/tailscale-icon.svg"
import { NodeData } from "src/types"
import Button from "src/ui/button"
import Collapsible from "src/ui/collapsible"

View File

@@ -4,9 +4,9 @@
import cx from "classnames"
import React, { useCallback, useMemo, useState } from "react"
import { useAPI } from "src/api"
import CheckCircle from "src/assets/icons/check-circle.svg?react"
import Clock from "src/assets/icons/clock.svg?react"
import Plus from "src/assets/icons/plus.svg?react"
import { ReactComponent as CheckCircle } from "src/assets/icons/check-circle.svg"
import { ReactComponent as Clock } from "src/assets/icons/clock.svg"
import { ReactComponent as Plus } from "src/assets/icons/plus.svg"
import * as Control from "src/components/control-components"
import { NodeData } from "src/types"
import Button from "src/ui/button"

View File

@@ -2,8 +2,8 @@
// SPDX-License-Identifier: BSD-3-Clause
import React from "react"
import CheckCircleIcon from "src/assets/icons/check-circle.svg?react"
import XCircleIcon from "src/assets/icons/x-circle.svg?react"
import { ReactComponent as CheckCircleIcon } from "src/assets/icons/check-circle.svg"
import { ReactComponent as XCircleIcon } from "src/assets/icons/x-circle.svg"
import { ChangelogText } from "src/components/update-available"
import { UpdateState, useInstallUpdate } from "src/hooks/self-update"
import { VersionInfo } from "src/types"

View File

@@ -3,7 +3,7 @@
import * as Primitive from "@radix-ui/react-collapsible"
import React, { useState } from "react"
import ChevronDown from "src/assets/icons/chevron-down.svg?react"
import { ReactComponent as ChevronDown } from "src/assets/icons/chevron-down.svg"
type CollapsibleProps = {
trigger?: string

View File

@@ -4,7 +4,7 @@
import * as DialogPrimitive from "@radix-ui/react-dialog"
import cx from "classnames"
import React, { Component, ComponentProps, FormEvent } from "react"
import X from "src/assets/icons/x.svg?react"
import { ReactComponent as X } from "src/assets/icons/x.svg"
import Button from "src/ui/button"
import PortalContainerContext from "src/ui/portal-container-context"
import { isObject } from "src/utils/util"

View File

@@ -3,7 +3,7 @@
import cx from "classnames"
import React, { forwardRef, InputHTMLAttributes } from "react"
import Search from "src/assets/icons/search.svg?react"
import { ReactComponent as Search } from "src/assets/icons/search.svg"
type Props = {
className?: string

View File

@@ -10,7 +10,7 @@ import React, {
useState,
} from "react"
import { createPortal } from "react-dom"
import X from "src/assets/icons/x.svg?react"
import { ReactComponent as X } from "src/assets/icons/x.svg"
import { noop } from "src/utils/util"
import { create } from "zustand"
import { shallow } from "zustand/shallow"

View File

@@ -5,7 +5,6 @@
"module": "ES2020",
"strict": true,
"sourceMap": true,
"skipLibCheck": true,
"isolatedModules": true,
"moduleResolution": "node",
"forceConsistentCasingInFileNames": true,

View File

@@ -1,5 +1,6 @@
/// <reference types="vitest" />
import { createLogger, defineConfig } from "vite"
import rewrite from "vite-plugin-rewrite-all"
import svgr from "vite-plugin-svgr"
import paths from "vite-tsconfig-paths"
@@ -23,6 +24,11 @@ export default defineConfig({
plugins: [
paths(),
svgr(),
// By default, the Vite dev server doesn't handle dots
// in path names and treats them as static files.
// This plugin changes Vite's routing logic to fix this.
// See: https://github.com/vitejs/vite/issues/2415
rewrite(),
],
build: {
outDir: "build",

File diff suppressed because it is too large Load Diff

View File

@@ -64,6 +64,12 @@
// cluster using the same hostname (in this case, the MagicDNS name of the ingress proxy)
// as a non-cluster workload on tailnet.
// This is only meant to be configured by the Kubernetes operator.
// - EXPERIMENTAL_AUTH_KEYS_ENDPOINT: if set and if running in Kubernetes, auth
// key will be retrieved by POST request to the endpoint passing service
// account token as an auth token. This is used by the Tailscale Kubernetes
// operator who also runs the endpoint.
// Tailscale IP range to DNAT to.
// - EXPERIMENTAL_TS_VIP // i.e 1.2.3.4
//
// When running on Kubernetes, containerboot defaults to storing state in the
// "tailscale" kube secret. To store state on local disk instead, set
@@ -80,8 +86,10 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"log"
"net/http"
"net/netip"
"os"
"os/exec"
@@ -140,6 +148,10 @@ func main() {
TailscaledConfigFilePath: defaultEnv("EXPERIMENTAL_TS_CONFIGFILE_PATH", ""),
AllowProxyingClusterTrafficViaIngress: defaultBool("EXPERIMENTAL_ALLOW_PROXYING_CLUSTER_TRAFFIC_VIA_INGRESS", false),
PodIP: defaultEnv("POD_IP", ""),
PodName: defaultEnv("POD_NAME", ""),
KeysEndpoint: defaultEnv("EXPERIMENTAL_AUTH_KEYS_ENDPOINT", ""),
KubeStateSecret: defaultEnv("EXPERIMENTAL_KUBE_STATE_SECRET", ""),
TSVIP: defaultEnv("EXPERIMENTAL_TS_VIP", ""),
}
if err := cfg.validate(); err != nil {
@@ -180,7 +192,10 @@ func main() {
}
cfg.KubernetesCanPatch = canPatch
if cfg.AuthKey == "" && !isOneStepConfig(cfg) {
// TODO: check that can do token request maybe?
// TODO: did I break something here?
if authKeySourceIsKubeSecret(cfg) {
key, err := findKeyInKubeSecret(bootCtx, cfg.KubeSecret)
if err != nil {
log.Fatalf("Getting authkey from kube secret: %v", err)
@@ -252,6 +267,18 @@ func main() {
}
didLogin = true
w.Close()
if cfg.KeysEndpoint != "" {
log.Printf("Creating Tailscale authkey by calling %s", cfg.KeysEndpoint)
key, err := getAuthKey(context.Background(), cfg)
if err != nil {
log.Fatalf("error getting Tailscale auth key: %v", err)
}
// TODO: this will not work with declarative config file
// that wants the auth key in there- figure out how to
// fix
// (So for now this does not work with Connector proxies)
cfg.AuthKey = string(key)
}
if err := tailscaleUp(bootCtx, cfg); err != nil {
return fmt.Errorf("failed to auth tailscale: %v", err)
}
@@ -262,6 +289,8 @@ func main() {
return nil
}
// Never with the Tailscale Kubernetes operator as it always sets
// cfg.AuthOnce=true
if isTwoStepConfigAlwaysAuth(cfg) {
if err := authTailscale(); err != nil {
log.Fatalf("failed to auth tailscale: %v", err)
@@ -279,13 +308,17 @@ authLoop:
switch *n.State {
case ipn.NeedsLogin:
if isOneStepConfig(cfg) {
// This could happen if this is the
// first time tailscaled was run for
// this device and the auth key was not
// passed via the configfile.
// if state secret is set, delete it to
// ensure that we start from a clean
// slate on next restart. This could
// happen if this is the first time
// tailscaled was run for this device
// and the auth key was not passed via
// the configfile.
log.Fatalf("invalid state: tailscaled daemon started with a config file, but tailscale is not logged in: ensure you pass a valid auth key in the config file.")
}
if err := authTailscale(); err != nil {
// delete state secret if set
log.Fatalf("failed to auth tailscale: %v", err)
}
case ipn.NeedsMachineAuth:
@@ -376,6 +409,18 @@ authLoop:
}
}()
var wg sync.WaitGroup
// We only need to do this once. Backend target change or VIP change
// comes in via env var change which would trigger restart.
if cfg.ProxyTo != "" && cfg.TSVIP != "" {
netIP, err := netip.ParsePrefix(cfg.TSVIP)
if err != nil {
log.Fatalf("error parsing VIP %s: %v", cfg.TSVIP, err)
}
log.Printf("Installing proxy rules for a virtual tailnet IP: %s", cfg.TSVIP)
if err := installIngressForwardingRule(ctx, cfg.ProxyTo, []netip.Prefix{netIP}, nfr); err != nil {
log.Fatalf("installing ingress proxy rules: %v", err)
}
}
runLoop:
for {
@@ -441,7 +486,7 @@ runLoop:
}
currentEgressIPs = newCurentEgressIPs
}
if cfg.ProxyTo != "" && len(addrs) > 0 && ipsHaveChanged {
if cfg.ProxyTo != "" && cfg.TSVIP == "" && len(addrs) > 0 && ipsHaveChanged {
log.Printf("Installing proxy rules")
if err := installIngressForwardingRule(ctx, cfg.ProxyTo, addrs, nfr); err != nil {
log.Fatalf("installing ingress proxy rules: %v", err)
@@ -639,10 +684,18 @@ func startTailscaled(ctx context.Context, cfg *settings) (*tailscale.LocalClient
func tailscaledArgs(cfg *settings) []string {
args := []string{"--socket=" + cfg.Socket}
switch {
case cfg.InKubernetes && cfg.KubeSecret != "":
args = append(args, "--state=kube:"+cfg.KubeSecret)
if cfg.StateDir == "" {
cfg.StateDir = "/tmp"
case cfg.InKubernetes:
if cfg.KeysEndpoint != "" {
stateSecretName := fmt.Sprintf("ts-state-%s", cfg.PodName)
args = append(args, "--state=kube:"+stateSecretName)
if cfg.StateDir == "" {
cfg.StateDir = "/tmp"
}
} else if cfg.KubeSecret != "" {
args = append(args, "--state=kube:"+cfg.KubeSecret)
if cfg.StateDir == "" {
cfg.StateDir = "/tmp"
}
}
fallthrough
case cfg.StateDir != "":
@@ -895,6 +948,9 @@ func installIngressForwardingRule(ctx context.Context, dstStr string, tsIPs []ne
if err != nil {
return err
}
// local can be either the Tailnet IP address of this Tailscale device
// or it can be a tailnet virtual IP that this tailnet node is a backend
// for.
var local netip.Addr
for _, pfx := range tsIPs {
if !pfx.IsSingleIP() {
@@ -943,6 +999,7 @@ type settings struct {
StateDir string
AcceptDNS *bool
KubeSecret string
KubeStateSecret string
SOCKSProxyAddr string
HTTPProxyAddr string
Socket string
@@ -957,7 +1014,10 @@ type settings struct {
// PodIP is the IP of the Pod if running in Kubernetes. This is used
// when setting up rules to proxy cluster traffic to cluster ingress
// target.
PodIP string
PodIP string
PodName string
KeysEndpoint string
TSVIP string
}
func (s *settings) validate() error {
@@ -990,6 +1050,9 @@ func (s *settings) validate() error {
if s.AllowProxyingClusterTrafficViaIngress && s.PodIP == "" {
return errors.New("EXPERIMENTAL_ALLOW_PROXYING_CLUSTER_TRAFFIC_VIA_INGRESS is set but POD_IP is not set")
}
if s.KeysEndpoint != "" && !s.InKubernetes {
return errors.New("EXPERIMENTAL_AUTH_KEYS_ENDPOINT is set, but the containerboot does not appear to be running on kube")
}
return nil
}
@@ -1089,3 +1152,37 @@ func isTwoStepConfigAlwaysAuth(cfg *settings) bool {
func isOneStepConfig(cfg *settings) bool {
return cfg.TailscaledConfigFilePath != ""
}
func authKeySourceIsKubeSecret(cfg *settings) bool {
return cfg.InKubernetes && cfg.AuthKey == "" && cfg.KeysEndpoint == ""
}
func getAuthKey(ctx context.Context, cfg *settings) ([]byte, error) {
client := http.Client{}
// TODO: somewhere check that this has permissions to create a token
token, err := kc.CreateTokenForPod(ctx, cfg.PodName, []string{"ts-keyserver"})
if err != nil {
return nil, fmt.Errorf("error generating token: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", cfg.KeysEndpoint, nil)
if err != nil {
return nil, fmt.Errorf("error creating new HTTP request: %v", err)
}
req.Header.Add("Authorization", "Bearer "+token)
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("error requesting auth key from URL %s: %w", cfg.KeysEndpoint, err)
}
defer resp.Body.Close()
respBs, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("error reading response body: %w", err)
}
if resp.StatusCode != 200 { // 200 is the only success code returned by the keyserver
return nil, fmt.Errorf("auth key response %s with unexpected status code %d", string(respBs), resp.StatusCode)
}
if len(respBs) == 0 {
return nil, errors.New("unexpected empty response")
}
return respBs, nil
}

View File

@@ -95,7 +95,6 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
tailscale.com/net/dnscache from tailscale.com/derp/derphttp
tailscale.com/net/flowtrack from tailscale.com/net/packet+
💣 tailscale.com/net/interfaces from tailscale.com/net/netmon+
tailscale.com/net/ktimeout from tailscale.com/cmd/derper
tailscale.com/net/netaddr from tailscale.com/ipn+
tailscale.com/net/netknob from tailscale.com/net/netns
tailscale.com/net/netmon from tailscale.com/derp/derphttp+

View File

@@ -32,7 +32,6 @@ import (
"tailscale.com/derp"
"tailscale.com/derp/derphttp"
"tailscale.com/metrics"
"tailscale.com/net/ktimeout"
"tailscale.com/net/stunserver"
"tailscale.com/tsweb"
"tailscale.com/types/key"
@@ -50,21 +49,14 @@ var (
runSTUN = flag.Bool("stun", true, "whether to run a STUN server. It will bind to the same IP (if any) as the --addr flag value.")
runDERP = flag.Bool("derp", true, "whether to run a DERP server. The only reason to set this false is if you're decommissioning a server but want to keep its bootstrap DNS functionality still running.")
meshPSKFile = flag.String("mesh-psk-file", defaultMeshPSKFile(), "if non-empty, path to file containing the mesh pre-shared key file. It should contain some hex string; whitespace is trimmed.")
meshWith = flag.String("mesh-with", "", "optional comma-separated list of hostnames to mesh with; the server's own hostname can be in the list")
bootstrapDNS = flag.String("bootstrap-dns-names", "", "optional comma-separated list of hostnames to make available at /bootstrap-dns")
unpublishedDNS = flag.String("unpublished-bootstrap-dns-names", "", "optional comma-separated list of hostnames to make available at /bootstrap-dns and not publish in the list")
verifyClients = flag.Bool("verify-clients", false, "verify clients to this DERP server through a local tailscaled instance.")
verifyClientURL = flag.String("verify-client-url", "", "if non-empty, an admission controller URL for permitting client connections; see tailcfg.DERPAdmitClientRequest")
verifyFailOpen = flag.Bool("verify-client-url-fail-open", true, "whether we fail open if --verify-client-url is unreachable")
meshPSKFile = flag.String("mesh-psk-file", defaultMeshPSKFile(), "if non-empty, path to file containing the mesh pre-shared key file. It should contain some hex string; whitespace is trimmed.")
meshWith = flag.String("mesh-with", "", "optional comma-separated list of hostnames to mesh with; the server's own hostname can be in the list")
bootstrapDNS = flag.String("bootstrap-dns-names", "", "optional comma-separated list of hostnames to make available at /bootstrap-dns")
unpublishedDNS = flag.String("unpublished-bootstrap-dns-names", "", "optional comma-separated list of hostnames to make available at /bootstrap-dns and not publish in the list")
verifyClients = flag.Bool("verify-clients", false, "verify clients to this DERP server through a local tailscaled instance.")
acceptConnLimit = flag.Float64("accept-connection-limit", math.Inf(+1), "rate limit for accepting new connection")
acceptConnBurst = flag.Int("accept-connection-burst", math.MaxInt, "burst limit for accepting new connection")
// tcpKeepAlive is intentionally long, to reduce battery cost. There is an L7 keepalive on a higher frequency schedule.
tcpKeepAlive = flag.Duration("tcp-keepalive-time", 10*time.Minute, "TCP keepalive time")
// tcpUserTimeout is intentionally short, so that hung connections are cleaned up promptly. DERPs should be nearby users.
tcpUserTimeout = flag.Duration("tcp-user-timeout", 15*time.Second, "TCP user timeout")
)
var (
@@ -155,8 +147,6 @@ func main() {
s := derp.NewServer(cfg.PrivateKey, log.Printf)
s.SetVerifyClient(*verifyClients)
s.SetVerifyClientURL(*verifyClientURL)
s.SetVerifyClientURLFailOpen(*verifyFailOpen)
if *meshPSKFile != "" {
b, err := os.ReadFile(*meshPSKFile)
@@ -226,15 +216,6 @@ func main() {
}))
debug.Handle("traffic", "Traffic check", http.HandlerFunc(s.ServeDebugTraffic))
// Longer lived DERP connections send an application layer keepalive. Note
// if the keepalive is hit, the user timeout will take precedence over the
// keepalive counter, so the probe if unanswered will take effect promptly,
// this is less tolerant of high loss, but high loss is unexpected.
lc := net.ListenConfig{
Control: ktimeout.UserTimeout(*tcpUserTimeout),
KeepAlive: *tcpKeepAlive,
}
quietLogger := log.New(logFilter{}, "", 0)
httpsrv := &http.Server{
Addr: *addr,
@@ -311,12 +292,7 @@ func main() {
// duration exceeds server's WriteTimeout".
WriteTimeout: 5 * time.Minute,
}
ln, err := lc.Listen(context.Background(), "tcp", port80srv.Addr)
if err != nil {
log.Fatal(err)
}
defer ln.Close()
err = port80srv.Serve(ln)
err := port80srv.ListenAndServe()
if err != nil {
if err != http.ErrServerClosed {
log.Fatal(err)
@@ -324,15 +300,10 @@ func main() {
}
}()
}
err = rateLimitedListenAndServeTLS(httpsrv, &lc)
err = rateLimitedListenAndServeTLS(httpsrv)
} else {
log.Printf("derper: serving on %s", *addr)
var ln net.Listener
ln, err = lc.Listen(context.Background(), "tcp", httpsrv.Addr)
if err != nil {
log.Fatal(err)
}
err = httpsrv.Serve(ln)
err = httpsrv.ListenAndServe()
}
if err != nil && err != http.ErrServerClosed {
log.Fatalf("derper: %v", err)
@@ -397,8 +368,8 @@ func defaultMeshPSKFile() string {
return ""
}
func rateLimitedListenAndServeTLS(srv *http.Server, lc *net.ListenConfig) error {
ln, err := lc.Listen(context.Background(), "tcp", cmp.Or(srv.Addr, ":https"))
func rateLimitedListenAndServeTLS(srv *http.Server) error {
ln, err := net.Listen("tcp", cmp.Or(srv.Addr, ":https"))
if err != nil {
return err
}

View File

@@ -78,6 +78,10 @@ spec:
- name: oauth
mountPath: /oauth
readOnly: true
ports:
- name: keyserver
containerPort: 8443
protocol: TCP
{{- with .Values.operatorConfig.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}

View File

@@ -0,0 +1,16 @@
# Copyright (c) Tailscale Inc & AUTHORS
# SPDX-License-Identifier: BSD-3-Clause
apiVersion: v1
kind: Service
metadata:
name: keyserver
namespace: {{ .Release.Namespace }}
spec:
ports:
- port: 8443
protocol: TCP
targetPort: 8443
selector:
app: operator
type: ClusterIP

View File

@@ -64,3 +64,50 @@ roleRef:
kind: Role
name: operator
apiGroup: rbac.authorization.k8s.io
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: keyserver
namespace: {{ .Release.Namespace }}
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["get","list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: keyserver
namespace: {{ .Release.Namespace }}
subjects:
- kind: ServiceAccount
name: operator
namespace: {{ .Release.Namespace }}
roleRef:
kind: Role
name: keyserver
apiGroup: rbac.authorization.k8s.io
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: tailscale-keyserver
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: tailscale-keyserver
subjects:
- kind: ServiceAccount
name: operator
namespace: {{ .Release.Namespace }}
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: tailscale-keyserver
rules:
- apiGroups: ["authentication.k8s.io"]
resources:
- tokenreviews
verbs: ["create"]

View File

@@ -30,3 +30,32 @@ roleRef:
kind: Role
name: proxies
apiGroup: rbac.authorization.k8s.io
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: proxies-token
rules:
- apiGroups: [""]
resources:
- "serviceaccounts/token"
- "serviceaccounts" # needed?
verbs:
- "create"
- "get"
resourceNames:
- "proxies"
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: proxies-token
namespace: {{ .Release.Namespace }}
subjects:
- kind: ServiceAccount
name: proxies
namespace: {{ .Release.Namespace }}
roleRef:
kind: ClusterRole
name: proxies-token
apiGroup: rbac.authorization.k8s.io

View File

@@ -452,6 +452,9 @@ spec:
value:
description: Value is the taint value the toleration matches to. If the operator is Exists, the value should be empty, otherwise just a regular string.
type: string
replicas:
type: integer
format: int32
status:
type: object
properties:

View File

@@ -4,6 +4,7 @@ metadata:
name: prod
spec:
statefulSet:
replicas: 2
annotations:
platform-component: infra
pod:

View File

@@ -603,6 +603,9 @@ spec:
type: object
type: array
type: object
replicas:
format: int32
type: integer
type: object
required:
- statefulSet
@@ -693,6 +696,34 @@ rules:
- update
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: tailscale-keyserver
rules:
- apiGroups:
- authentication.k8s.io
resources:
- tokenreviews
verbs:
- create
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: proxies-token
rules:
- apiGroups:
- ""
resourceNames:
- proxies
resources:
- serviceaccounts/token
- serviceaccounts
verbs:
- create
- get
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: tailscale-operator
@@ -706,6 +737,19 @@ subjects:
namespace: tailscale
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: tailscale-keyserver
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: tailscale-keyserver
subjects:
- kind: ServiceAccount
name: operator
namespace: tailscale
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: operator
@@ -726,6 +770,21 @@ rules:
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: keyserver
namespace: tailscale
rules:
- apiGroups:
- ""
resources:
- pods
verbs:
- get
- list
- watch
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: proxies
namespace: tailscale
@@ -753,6 +812,20 @@ subjects:
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: keyserver
namespace: tailscale
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: keyserver
subjects:
- kind: ServiceAccount
name: operator
namespace: tailscale
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: proxies
namespace: tailscale
@@ -765,6 +838,34 @@ subjects:
name: proxies
namespace: tailscale
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: proxies-token
namespace: tailscale
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: proxies-token
subjects:
- kind: ServiceAccount
name: proxies
namespace: tailscale
---
apiVersion: v1
kind: Service
metadata:
name: keyserver
namespace: tailscale
spec:
ports:
- port: 8443
protocol: TCP
targetPort: 8443
selector:
app: operator
type: ClusterIP
---
apiVersion: apps/v1
kind: Deployment
metadata:
@@ -811,6 +912,10 @@ spec:
image: tailscale/k8s-operator:unstable
imagePullPolicy: Always
name: operator
ports:
- containerPort: 8443
name: keyserver
protocol: TCP
volumeMounts:
- mountPath: /oauth
name: oauth

View File

@@ -34,6 +34,10 @@ spec:
valueFrom:
fieldRef:
fieldPath: status.podIP
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
securityContext:
capabilities:
add:

View File

@@ -22,3 +22,7 @@ spec:
value: "true"
- name: TS_AUTH_ONCE
value: "true"
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name

View File

@@ -155,6 +155,16 @@ func (a *IngressReconciler) maybeProvision(ctx context.Context, logger *zap.Suga
// magic443 is a fake hostname that we can use to tell containerboot to swap
// out with the real hostname once it's known.
const magic443 = "${TS_CERT_DOMAIN}:443"
tlsHostname := ""
if ing.Spec.TLS != nil && len(ing.Spec.TLS) > 0 && len(ing.Spec.TLS[0].Hosts) > 0 {
tlsHostname = ing.Spec.TLS[0].Hosts[0]
}
tlsHost := ipn.HostPort(fmt.Sprintf("%s:443", tlsHostname))
if tlsHostname == "" {
tlsHost = magic443
}
sc := &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{
443: {
@@ -162,18 +172,18 @@ func (a *IngressReconciler) maybeProvision(ctx context.Context, logger *zap.Suga
},
},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
magic443: {
tlsHost: {
Handlers: map[string]*ipn.HTTPHandler{},
},
},
}
if opt.Bool(ing.Annotations[AnnotationFunnel]).EqualBool(true) {
sc.AllowFunnel = map[ipn.HostPort]bool{
magic443: true,
tlsHost: true,
}
}
web := sc.Web[magic443]
web := sc.Web[tlsHost]
addIngressBackend := func(b *networkingv1.IngressBackend, path string) {
if b == nil {
return
@@ -216,14 +226,10 @@ func (a *IngressReconciler) maybeProvision(ctx context.Context, logger *zap.Suga
}
addIngressBackend(ing.Spec.DefaultBackend, "/")
var tlsHost string // hostname or FQDN or empty
if ing.Spec.TLS != nil && len(ing.Spec.TLS) > 0 && len(ing.Spec.TLS[0].Hosts) > 0 {
tlsHost = ing.Spec.TLS[0].Hosts[0]
}
for _, rule := range ing.Spec.Rules {
// Host is optional, but if it's present it must match the TLS host
// otherwise we ignore the rule.
if rule.Host != "" && rule.Host != tlsHost {
if rule.Host != "" && rule.Host != tlsHostname {
a.recorder.Eventf(ing, corev1.EventTypeWarning, "InvalidIngressBackend", "rule with host %q ignored, unsupported", rule.Host)
continue
}
@@ -253,10 +259,9 @@ func (a *IngressReconciler) maybeProvision(ctx context.Context, logger *zap.Suga
tags = strings.Split(tstr, ",")
}
hostname := ing.Namespace + "-" + ing.Name + "-ingress"
if tlsHost != "" {
hostname, _, _ = strings.Cut(tlsHost, ".")
}
// if tlsHost != "" {
// hostname, _, _ = strings.Cut(tlsHost, ".")
// }
sts := &tailscaleSTSConfig{
Hostname: hostname,
ParentResourceName: ing.Name,
@@ -265,6 +270,7 @@ func (a *IngressReconciler) maybeProvision(ctx context.Context, logger *zap.Suga
Tags: tags,
ChildResourceLabels: crl,
ProxyClass: proxyClass,
TSVIP: a.tailnetVIPForIngress(ing),
}
if val := ing.GetAnnotations()[AnnotationExperimentalForwardClusterTrafficViaL7IngresProxy]; val == "true" {
@@ -307,6 +313,13 @@ func (a *IngressReconciler) maybeProvision(ctx context.Context, logger *zap.Suga
return nil
}
func (a *IngressReconciler) tailnetVIPForIngress(ing *networkingv1.Ingress) string {
if !a.shouldExpose(ing) || ing.Annotations == nil {
return ""
}
return ing.GetAnnotations()[AnnotationTSVIP]
}
func (a *IngressReconciler) shouldExpose(ing *networkingv1.Ingress) bool {
return ing != nil &&
ing.Spec.IngressClassName != nil &&

View File

@@ -0,0 +1,244 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !plan9
package main
import (
"context"
"fmt"
"net/http"
"strings"
"go.uber.org/zap"
authv1 "k8s.io/api/authentication/v1"
corev1 "k8s.io/api/core/v1"
networkingv1 "k8s.io/api/networking/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apiserver/pkg/authentication/serviceaccount"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
"sigs.k8s.io/controller-runtime/pkg/client"
"tailscale.com/client/tailscale"
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
)
type keyServer struct {
restConfig *rest.Config
client.Client
logger *zap.SugaredLogger
tsNamespace string
defaultDeviceTags []string
tsClient tsClient
}
func (ks *keyServer) runKeyServer() error {
proxyServiceAccountName := fmt.Sprintf("system:serviceaccount:%s:proxies", ks.tsNamespace)
// create a client-go client as c/r client cannot be used to directly
// access Auth interface to create TokenReviews. TokenReviews are not
// objects that exist in cluster, so the normal c/r flow of 'CREATE and
// object, if needed to observe its current state GET it does not work
// here- we need to read the status from the TokenReview status as
// returned in response, so we need to use the actual auth client.
// TODO: maybe I actually don't need to do this because the object
// passed to c/r Create would get updated?
kubeClient, err := kubernetes.NewForConfig(ks.restConfig)
if err != nil {
return fmt.Errorf("error creating a new kube client: %v", err)
}
mux := http.NewServeMux()
mux.HandleFunc("/keys", func(w http.ResponseWriter, r *http.Request) {
ks.logger.Debugf("received request for an auth key")
// Get the auth token - like https://github.com/kubernetes/apiserver/blob/release-1.29/pkg/authentication/request/bearertoken/bearertoken.go#L42-L63
auth := strings.TrimSpace(r.Header.Get("Authorization"))
if auth == "" {
ks.logger.Info("received a request with no auth header")
http.Error(w, "permission denied", http.StatusForbidden)
return
}
parts := strings.SplitN(auth, " ", 3)
if len(parts) < 2 || strings.ToLower(parts[0]) != "bearer" {
ks.logger.Info("received a request with no bearer token")
http.Error(w, "permission denied", http.StatusForbidden)
return
}
token := parts[1]
// Empty bearer tokens aren't valid
if len(token) == 0 {
ks.logger.Info("received a request with an empty bearer token")
http.Error(w, "permission denied", http.StatusForbidden)
return
}
// create a TokenReview
tr := &authv1.TokenReview{
Spec: authv1.TokenReviewSpec{Token: token, Audiences: []string{"ts-keyserver"}},
}
// TODO: alt would be to delegate via auth webhook - that's how
// RBAC proxy does it. Compare.
resp, err := kubeClient.AuthenticationV1().TokenReviews().Create(r.Context(), tr, metav1.CreateOptions{})
if err != nil {
ks.logger.Errorf("error creating a TokenReview: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if !resp.Status.Authenticated {
ks.logger.Info("token was not authenticated")
http.Error(w, "permission denied", http.StatusForbidden)
return
}
// TODO: set and validate audience
// We know that only ServiceAccount 'proxies' in operator
// namespace should be allowed to call 'keys' endpoint.
// Alternatively we could assign 'proxies' an RBAC allowing it
// to call '/keys' endpoint (RBAC for non-resource URLs). At the
// moment I don't see a value in doing that as we know what
// ServiceAccount is allowed to perform the action and an
// operator installation always includes this ServiceAccount.
if username := resp.Status.User.Username; username != proxyServiceAccountName {
ks.logger.Info("received a request for token for user %s, expected %s", username, proxyServiceAccountName)
http.Error(w, "permission denied", http.StatusForbidden)
return
}
// TODO: ensure that this will always have extras when the token is sent from containerboot
if resp.Status.User.Extra == nil {
ks.logger.Info("received a request for a token that does not contain extra information, please report this")
http.Error(w, "unable to identify caller Pod", http.StatusForbidden)
return
}
if len(resp.Status.User.Extra[serviceaccount.PodNameKey]) != 1 || resp.Status.User.Extra[serviceaccount.PodNameKey][0] == "" {
ks.logger.Infof("impossible to identify caller Pod from token review response: %#+v", resp.Status.User.Extra[serviceaccount.PodNameKey])
http.Error(w, "unable to identify caller Pod", http.StatusForbidden)
return
}
podName := types.NamespacedName{Namespace: ks.tsNamespace, Name: resp.Status.User.Extra[serviceaccount.PodNameKey][0]}
ks.logger.Debugf("request for key authenticated as from Pod %s", podName)
// TODO: cache metadata only for these, filter ts namespace and labels
pod := &corev1.Pod{}
// TODO: is it right to use this context?
if err := ks.Client.Get(r.Context(), podName, pod); err != nil {
ks.logger.Errorf("unable to retrieve caller Pod from cache: %v", err)
http.Error(w, "unable to identify caller Pod", http.StatusForbidden)
return
}
// Get the parent resource and figure out what tags are needed.
// Alternatives could be 1) annotate Pods with the desired ACL
// tags 2) pass each StatefulSet a specific URL that includes
// the tags (i.e base64 encoded). But 2) would probably require
// RBAC for calling _that_ URL (and we currently use the same
// ServiceAccount for all proxies). 1) could be ok (and would
// also solve the problem where user updating ACL tags is not
// picked up by proxies), but should discuss the model
// (including what should happen when ACL tags are updated).
// Generally of course should speed this up much as possible.
tags, err := ks.tagsForPod(r.Context(), pod)
if err != nil {
ks.logger.Errorf("error determining ACL tags to apply to the auth key: %v", err)
http.Error(w, "error determining ACL tags", http.StatusInternalServerError)
return
}
// create the device
// TODO: bump a metric here. probably should also be user facing?
key, err := ks.newAuthKey(r.Context(), tags)
if err != nil {
ks.logger.Errorf("error determining ACL tags to apply to the auth key")
http.Error(w, "error creating a new auth key", http.StatusInternalServerError)
return
}
w.WriteHeader(200)
// probably?
w.Header().Set("Content-Type", "text/plain;charset=UTF-8")
w.Write([]byte(key))
})
srv := http.Server{
Handler: mux,
Addr: ":8443", // 443 is auth proxy if that's too running on this operator instance
}
ks.logger.Infof("running key server on %v", srv.Addr)
return srv.ListenAndServe()
}
func (ks *keyServer) newAuthKey(ctx context.Context, tags []string) (string, error) {
caps := tailscale.KeyCapabilities{
Devices: tailscale.KeyDeviceCapabilities{
Create: tailscale.KeyDeviceCreateCapabilities{
Reusable: false,
Preauthorized: true,
Tags: tags,
},
},
}
key, _, err := ks.tsClient.CreateKey(ctx, caps)
if err != nil {
return "", err
}
return key, nil
}
func (ks *keyServer) tagsForPod(ctx context.Context, pod *corev1.Pod) ([]string, error) {
parentLabels, err := managedLabelsFromPod(pod)
if err != nil {
return nil, fmt.Errorf("error determining parent resource labels: %v", err)
}
tags, err := ks.aclTagsForResource(ctx, parentLabels)
if err != nil {
return nil, fmt.Errorf("error determining ACl tags: %v", err)
}
return tags, nil
}
func (ks *keyServer) aclTagsForResource(ctx context.Context, labels map[string]string) ([]string, error) {
switch labels[LabelParentType] {
case "svc":
svcName := types.NamespacedName{Namespace: labels[LabelParentNamespace], Name: labels[LabelParentName]}
svc := &corev1.Service{}
if err := ks.Get(ctx, svcName, svc); err != nil {
return nil, fmt.Errorf("error getting Service: %v", err)
}
return ks.aclsForObjectAnnotations(svc.Annotations), nil
case "ingress":
ingName := types.NamespacedName{Namespace: labels[LabelParentNamespace], Name: labels[LabelParentName]}
ing := &networkingv1.Ingress{}
if err := ks.Get(ctx, ingName, ing); err != nil {
return nil, fmt.Errorf("error getting Ingress: %v", err)
}
return ks.aclsForObjectAnnotations(ing.Annotations), nil
case "connector":
connectorName := types.NamespacedName{Name: labels[LabelParentName]}
conn := &tsapi.Connector{}
if err := ks.Get(ctx, connectorName, conn); err != nil {
return nil, fmt.Errorf("error getting Connector: %v", err)
}
if len(conn.Spec.Tags) > 0 {
return conn.Spec.Tags.Stringify(), nil
}
return ks.defaultDeviceTags, nil
default:
return nil, fmt.Errorf("unkown parent type: %s", labels[LabelParentType])
}
}
func (ks *keyServer) aclsForObjectAnnotations(annots map[string]string) []string {
if annots == nil || annots[AnnotationTags] == "" {
return ks.defaultDeviceTags
}
return strings.Split(annots[AnnotationTags], ",")
}
func managedLabelsFromPod(pod *corev1.Pod) (map[string]string, error) {
labels := make(map[string]string)
for _, labelName := range []string{LabelManaged, LabelParentName, LabelParentNamespace, LabelParentType} {
if labelVal := pod.GetLabels()[labelName]; labelVal == "" {
return nil, fmt.Errorf("Pod does not have label: %s", labelName)
} else {
labels[labelName] = labelVal
}
}
return labels, nil
}

View File

@@ -222,6 +222,8 @@ func runReconcilers(zlog *zap.SugaredLogger, s *tsnet.Server, tsNamespace string
ByObject: map[client.Object]cache.ByObject{
&corev1.Secret{}: nsFilter,
&appsv1.StatefulSet{}: nsFilter,
// TODO (irberkrm): cahce metadata only for Pods
&corev1.Pod{}: nsFilter,
},
},
Scheme: tsapi.GlobalScheme,
@@ -316,6 +318,16 @@ func runReconcilers(zlog *zap.SugaredLogger, s *tsnet.Server, tsNamespace string
if err != nil {
startlog.Fatal("could not create proxyclass reconciler: %v", err)
}
// TODO: maybe put in a better place, but this needs rest config and c/r client
ks := &keyServer{
Client: mgr.GetClient(),
restConfig: mgr.GetConfig(),
logger: zlog.Named("keyserver"),
tsNamespace: tsNamespace,
defaultDeviceTags: strings.Split(tags, ","), // or do differently
tsClient: tsClient,
}
go ks.runKeyServer()
startlog.Infof("Startup complete, operator running, version: %s", version.Long())
if err := mgr.Start(signals.SetupSignalHandler()); err != nil {
startlog.Fatalf("could not start manager: %v", err)

View File

@@ -67,6 +67,9 @@ const (
// Annotations settable by users on ingresses.
AnnotationFunnel = "tailscale.com/funnel"
// Tailnet VIP that this proxy should satisfy
AnnotationTSVIP = "tailscale.com/expose-via-vip"
// If set to true, set up iptables/nftables rules in the proxy forward
// cluster traffic to the tailnet IP of that proxy. This can only be set
// on an Ingress. This is useful in cases where a cluster target needs
@@ -127,6 +130,7 @@ type tailscaleSTSConfig struct {
Connector *connector
ProxyClass string
TSVIP string // a tailnet VIP that should route to this proxy
}
type connector struct {
@@ -308,9 +312,7 @@ func (a *tailscaleSTSReconciler) createOrGetSecret(ctx context.Context, logger *
return "", "", err
}
var (
authKey, hash string
)
var hash string
if orig == nil {
// Secret doesn't exist yet, create one. Initially it contains
// only the Tailscale authkey, but once Tailscale starts it'll
@@ -327,21 +329,23 @@ func (a *tailscaleSTSReconciler) createOrGetSecret(ctx context.Context, logger *
}
// Create API Key secret which is going to be used by the statefulset
// to authenticate with Tailscale.
logger.Debugf("creating authkey for new tailscale proxy")
tags := stsC.Tags
if len(tags) == 0 {
tags = a.defaultTags
}
authKey, err = a.newAuthKey(ctx, tags)
if err != nil {
return "", "", err
}
}
if !shouldDoTailscaledDeclarativeConfig(stsC) && authKey != "" {
mak.Set(&secret.StringData, "authkey", authKey)
// logger.Debugf("creating authkey for new tailscale proxy")
// tags := stsC.Tags
// if len(tags) == 0 {
// tags = a.defaultTags
// }
// authKey, err = a.newAuthKey(ctx, tags)
// if err != nil {
// return "", "", err
// }
}
// if !shouldDoTailscaledDeclarativeConfig(stsC) && authKey != "" {
// mak.Set(&secret.StringData, "authkey", authKey)
// }
// TODO: this is going to be broken now, fix
if shouldDoTailscaledDeclarativeConfig(stsC) {
confFileBytes, h, err := tailscaledConfig(stsC, authKey, orig)
confFileBytes, h, err := tailscaledConfig(stsC, "", orig)
if err != nil {
return "", "", fmt.Errorf("error creating tailscaled config: %w", err)
}
@@ -424,7 +428,7 @@ var userspaceProxyYaml []byte
func (a *tailscaleSTSReconciler) reconcileSTS(ctx context.Context, logger *zap.SugaredLogger, sts *tailscaleSTSConfig, headlessSvc *corev1.Service, proxySecret, tsConfigHash string) (*appsv1.StatefulSet, error) {
ss := new(appsv1.StatefulSet)
if sts.ServeConfig != nil && sts.ForwardClusterTrafficViaL7IngressProxy != true { // If forwarding cluster traffic via is required we need non-userspace + NET_ADMIN + forwarding
if sts.ServeConfig != nil && sts.ForwardClusterTrafficViaL7IngressProxy != true && sts.TSVIP == "" { // If forwarding cluster traffic via is required we need non-userspace + NET_ADMIN + forwarding
if err := yaml.Unmarshal(userspaceProxyYaml, &ss); err != nil {
return nil, fmt.Errorf("failed to unmarshal userspace proxy spec: %v", err)
}
@@ -484,6 +488,17 @@ func (a *tailscaleSTSReconciler) reconcileSTS(ctx context.Context, logger *zap.S
Value: "true",
})
}
if sts.TSVIP != "" {
container.Env = append(container.Env, corev1.EnvVar{
Name: "EXPERIMENTAL_TS_VIP",
Value: sts.TSVIP + "/32",
})
container.Env = append(container.Env, corev1.EnvVar{
Name: "TS_ROUTES",
Value: sts.TSVIP + "/32",
})
}
if !shouldDoTailscaledDeclarativeConfig(sts) {
container.Env = append(container.Env, corev1.EnvVar{
Name: "TS_HOSTNAME",
@@ -529,6 +544,12 @@ func (a *tailscaleSTSReconciler) reconcileSTS(ctx context.Context, logger *zap.S
}
pod.Spec.PriorityClassName = a.proxyPriorityClassName
keyURL := fmt.Sprintf("http://keyserver.%s.svc.cluster.local:8443/keys", a.operatorNamespace)
container.Env = append(container.Env, corev1.EnvVar{
Name: "EXPERIMENTAL_AUTH_KEYS_ENDPOINT",
Value: keyURL,
})
// Ingress/egress proxy configuration options.
if sts.ClusterTargetIP != "" {
container.Env = append(container.Env, corev1.EnvVar{
@@ -618,6 +639,10 @@ func applyProxyClassToStatefulSet(pc *tsapi.ProxyClass, ss *appsv1.StatefulSet)
ss.ObjectMeta.Annotations = mergeStatefulSetLabelsOrAnnots(ss.ObjectMeta.Annotations, wantsSSAnnots, tailscaleManagedAnnotations)
}
if pc.Spec.StatefulSet.Replicas != nil {
ss.Spec.Replicas = pc.Spec.StatefulSet.Replicas
}
// Update Pod fields.
if pc.Spec.StatefulSet.Pod == nil {
return ss

View File

@@ -197,6 +197,7 @@ func (a *ServiceReconciler) maybeProvision(ctx context.Context, logger *zap.Suga
Tags: tags,
ChildResourceLabels: crl,
ProxyClass: proxyClass,
TSVIP: a.tailnetVIPForService(svc),
}
a.mu.Lock()
@@ -337,6 +338,13 @@ func proxyClassForObject(o client.Object) string {
return o.GetLabels()[LabelProxyClass]
}
func (a *ServiceReconciler) tailnetVIPForService(svc *corev1.Service) string {
if !a.shouldExpose(svc) || svc.Annotations == nil {
return ""
}
return svc.GetAnnotations()[AnnotationTSVIP]
}
func proxyClassIsReady(ctx context.Context, name string, cl client.Client) (bool, error) {
proxyClass := new(tsapi.ProxyClass)
if err := cl.Get(ctx, types.NamespacedName{Name: name}, proxyClass); err != nil {

View File

@@ -130,14 +130,14 @@ var debugCmd = &ffcli.Command{
ShortHelp: "force a magicsock rebind",
},
{
Name: "derp-set-on-demand",
Name: "derp-set-homeless",
Exec: localAPIAction("derp-set-homeless"),
ShortHelp: "enable DERP on-demand mode (breaks reachability)",
ShortHelp: "enable DERP homeless mode (breaks reachablility)",
},
{
Name: "derp-unset-on-demand",
Name: "derp-unset-homeless",
Exec: localAPIAction("derp-unset-homeless"),
ShortHelp: "disable DERP on-demand mode",
ShortHelp: "disable DERP homeless mode",
},
{
Name: "break-tcp-conns",

View File

@@ -140,24 +140,9 @@ func buildShareLongHelp() string {
var shareLongHelpBase = `Tailscale share allows you to share directories with other machines on your tailnet.
In order to share folders, your node needs to have the node attribute "tailfs:share".
In order to access shares, your node needs to have the node attribute "tailfs:access".
For example, to enable sharing and accessing shares for all member nodes:
"nodeAttrs": [
{
"target": ["autogroup:member"],
"attr": [
"tailfs:share",
"tailfs:access",
],
}]
Each share is identified by a name and points to a directory at a specific path. For example, to share the path /Users/me/Documents under the name "docs", you would run:
$ tailscale share add docs /Users/me/Documents
$ tailscale share add docs /Users/me/Documents
Note that the system forces share names to lowercase to avoid problems with clients that don't support case-sensitive filenames.
@@ -165,59 +150,57 @@ Share names may only contain the letters a-z, underscore _, parentheses (), or s
All Tailscale shares have a globally unique path consisting of the tailnet, the machine name and the share name. For example, if the above share was created on the machine "mylaptop" on the tailnet "mydomain.com", the share's path would be:
/mydomain.com/mylaptop/docs
/mydomain.com/mylaptop/docs
In order to access this share, other machines on the tailnet can connect to the above path on a WebDAV server running at 100.100.100.100:8080, for example:
http://100.100.100.100:8080/mydomain.com/mylaptop/docs
http://100.100.100.100:8080/mydomain.com/mylaptop/docs
Permissions to access shares are controlled via ACLs. For example, to give yourself read/write access and give the group "home" read-only access to the above share, use the below ACL grants:
"grants": [
{
"src": ["mylogin@domain.com"],
"dst": ["mylaptop's ip address"],
"app": {
"tailscale.com/cap/tailfs": [{
"shares": ["docs"],
"access": "rw"
}]
}
},
{
"src": ["group:home"],
"dst": ["mylaptop"],
"app": {
"tailscale.com/cap/tailfs": [{
"shares": ["docs"],
"access": "ro"
}]
}
}]
{
"src": ["mylogin@domain.com"],
"dst": ["mylaptop's ip address"],
"app": {
"tailscale.com/cap/tailfs": [{
"shares": ["docs"],
"access": "rw"
}]
}
},
{
"src": ["group:home"],
"dst": ["mylaptop"],
"app": {
"tailscale.com/cap/tailfs": [{
"shares": ["docs"],
"access": "ro"
}]
}
}
To categorically give yourself access to all your shares, you can use the below ACL grant:
{
"src": ["autogroup:member"],
"dst": ["autogroup:self"],
"app": {
"tailscale.com/cap/tailfs": [{
"shares": ["*"],
"access": "rw"
}]
}
},
"grants": [
{
"src": ["autogroup:member"],
"dst": ["autogroup:self"],
"app": {
"tailscale.com/cap/tailfs": [{
"shares": ["*"],
"access": "rw"
}]
}
}]
Whenever either you or anyone in the group "home" connects to the share, they connect as if they are using your local machine user. They'll be able to read the same files as your user and if they create files, those files will be owned by your user.%s
You can remove shares by name, for example you could remove the above share by running:
$ tailscale share remove docs
$ tailscale share remove docs
You can get a list of currently published shares by running:
$ tailscale share list`
$ tailscale share list`
var shareLongHelpAs = `

View File

@@ -103,7 +103,6 @@ func runSSH(ctx context.Context, args []string) error {
"-o", fmt.Sprintf("UserKnownHostsFile %q", knownHostsFile),
"-o", "UpdateHostKeys no",
"-o", "StrictHostKeyChecking yes",
"-o", "CanonicalizeHostname no", // https://github.com/tailscale/tailscale/issues/10348
)
// TODO(bradfitz): nc is currently broken on macOS:

View File

@@ -109,7 +109,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
L github.com/insomniacslk/dhcp/iana from github.com/insomniacslk/dhcp/dhcpv4
L github.com/insomniacslk/dhcp/interfaces from github.com/insomniacslk/dhcp/dhcpv4
L github.com/insomniacslk/dhcp/rfc1035label from github.com/insomniacslk/dhcp/dhcpv4
github.com/jellydator/ttlcache/v3 from tailscale.com/tailfs/tailfsimpl/compositedav
github.com/jellydator/ttlcache/v3 from tailscale.com/tailfs/tailfsimpl/webdavfs
L github.com/jmespath/go-jmespath from github.com/aws/aws-sdk-go-v2/service/ssm
L github.com/josharian/native from github.com/mdlayher/netlink+
L 💣 github.com/jsimonetti/rtnetlink from tailscale.com/net/interfaces+
@@ -155,6 +155,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
github.com/tailscale/goupnp/scpd from github.com/tailscale/goupnp
github.com/tailscale/goupnp/soap from github.com/tailscale/goupnp+
github.com/tailscale/goupnp/ssdp from github.com/tailscale/goupnp
github.com/tailscale/gowebdav from tailscale.com/tailfs/tailfsimpl/webdavfs
github.com/tailscale/hujson from tailscale.com/ipn/conffile
L 💣 github.com/tailscale/netlink from tailscale.com/net/routetable+
github.com/tailscale/peercred from tailscale.com/ipn/ipnauth
@@ -322,9 +323,9 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/taildrop from tailscale.com/ipn/ipnlocal+
tailscale.com/tailfs from tailscale.com/client/tailscale+
tailscale.com/tailfs/tailfsimpl from tailscale.com/cmd/tailscaled
tailscale.com/tailfs/tailfsimpl/compositedav from tailscale.com/tailfs/tailfsimpl
tailscale.com/tailfs/tailfsimpl/dirfs from tailscale.com/tailfs/tailfsimpl+
tailscale.com/tailfs/tailfsimpl/compositefs from tailscale.com/tailfs/tailfsimpl
tailscale.com/tailfs/tailfsimpl/shared from tailscale.com/tailfs/tailfsimpl+
tailscale.com/tailfs/tailfsimpl/webdavfs from tailscale.com/tailfs/tailfsimpl
💣 tailscale.com/tempfork/device from tailscale.com/net/tstun/table
LD tailscale.com/tempfork/gliderlabs/ssh from tailscale.com/ssh/tailssh
tailscale.com/tempfork/heap from tailscale.com/wgengine/magicsock
@@ -508,7 +509,7 @@ 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 html/template+
html/template from github.com/gorilla/csrf+
html/template from github.com/gorilla/csrf
io from archive/tar+
io/fs from archive/tar+
io/ioutil from github.com/aws/aws-sdk-go-v2/aws/protocol/query+

View File

@@ -755,8 +755,6 @@ func newNetstack(logf logger.Logf, sys *tsd.System) (*netstack.Impl, error) {
// Only register debug info if we have a debug mux
if debugMux != nil {
expvar.Publish("netstack", ret.ExpVar())
debugMux.HandleFunc("/debug/netstack/tcp-forwarder", ret.DebugTCPForwarder)
}
return ret, nil
}

View File

@@ -278,7 +278,6 @@ func main() {
goTestArgsWithCoverage,
fmt.Sprintf("-coverprofile=%v", coverageFile),
"-covermode=set",
"-coverpkg=./...",
)
}

View File

@@ -264,7 +264,7 @@ func (i *jsIPN) run(jsCallbacks js.Value) {
name = p.Hostinfo().Hostname()
}
addrs := make([]string, p.Addresses().Len())
for i := range p.Addresses().Len() {
for i := range p.Addresses().LenIter() {
addrs[i] = p.Addresses().At(i).Addr().String()
}
return jsNetMapPeerNode{
@@ -582,7 +582,7 @@ func mapSlice[T any, M any](a []T, f func(T) M) []M {
func mapSliceView[T any, M any](a views.Slice[T], f func(T) M) []M {
n := make([]M, a.Len())
for i := range a.Len() {
for i := range a.LenIter() {
n[i] = f(a.At(i))
}
return n

View File

@@ -728,7 +728,7 @@ func peerChangeDiff(was tailcfg.NodeView, n *tailcfg.Node) (_ *tailcfg.PeerChang
return nil, false
}
for i := range va.Len() {
for i := range va.LenIter() {
if !va.At(i).Equal(vb.At(i)) {
return nil, false
}

View File

@@ -7,7 +7,6 @@ package derp
import (
"bufio"
"bytes"
"context"
"crypto/ed25519"
crand "crypto/rand"
@@ -41,7 +40,6 @@ import (
"tailscale.com/envknob"
"tailscale.com/metrics"
"tailscale.com/syncs"
"tailscale.com/tailcfg"
"tailscale.com/tstime"
"tailscale.com/tstime/rate"
"tailscale.com/types/key"
@@ -146,13 +144,9 @@ type Server struct {
avgQueueDuration *uint64 // In milliseconds; accessed atomically
tcpRtt metrics.LabelMap // histogram
// verifyClientsLocalTailscaled only accepts client connections to the DERP
// server if the clientKey is a known peer in the network, as specified by a
// running tailscaled's client's LocalAPI.
verifyClientsLocalTailscaled bool
verifyClientsURL string
verifyClientsURLFailOpen bool
// verifyClients only accepts client connections to the DERP server if the clientKey is a
// known peer in the network, as specified by a running tailscaled's client's LocalAPI.
verifyClients bool
mu sync.Mutex
closed bool
@@ -359,20 +353,7 @@ func (s *Server) SetMeshKey(v string) {
//
// It must be called before serving begins.
func (s *Server) SetVerifyClient(v bool) {
s.verifyClientsLocalTailscaled = v
}
// SetVerifyClientURL sets the admission controller URL to use for verifying clients.
// If empty, all clients are accepted (unless restricted by SetVerifyClient checking
// against tailscaled).
func (s *Server) SetVerifyClientURL(v string) {
s.verifyClientsURL = v
}
// SetVerifyClientURLFailOpen sets whether to allow clients to connect if the
// admission controller URL is unreachable.
func (s *Server) SetVerifyClientURLFailOpen(v bool) {
s.verifyClientsURLFailOpen = v
s.verifyClients = v
}
// HasMeshKey reports whether the server is configured with a mesh key.
@@ -710,9 +691,7 @@ func (s *Server) accept(ctx context.Context, nc Conn, brw *bufio.ReadWriter, rem
if err != nil {
return fmt.Errorf("receive client key: %v", err)
}
clientAP, _ := netip.ParseAddrPort(remoteAddr)
if err := s.verifyClient(ctx, clientKey, clientInfo, clientAP.Addr()); err != nil {
if err := s.verifyClient(clientKey, clientInfo); err != nil {
return fmt.Errorf("client %x rejected: %v", clientKey, err)
}
@@ -1137,60 +1116,21 @@ func (c *sclient) requestMeshUpdate() {
}
}
// verifyClient checks whether the client is allowed to connect to the derper,
// depending on how & whether the server's been configured to verify.
func (s *Server) verifyClient(ctx context.Context, clientKey key.NodePublic, info *clientInfo, clientIP netip.Addr) error {
// tailscaled-based verification:
if s.verifyClientsLocalTailscaled {
status, err := tailscale.Status(ctx)
if err != nil {
return fmt.Errorf("failed to query local tailscaled status: %w", err)
}
if clientKey == status.Self.PublicKey {
return nil
}
if _, exists := status.Peer[clientKey]; !exists {
return fmt.Errorf("client %v not in set of peers", clientKey)
}
func (s *Server) verifyClient(clientKey key.NodePublic, info *clientInfo) error {
if !s.verifyClients {
return nil
}
// admission controller-based verification:
if s.verifyClientsURL != "" {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
jreq, err := json.Marshal(&tailcfg.DERPAdmitClientRequest{
NodePublic: clientKey,
Source: clientIP,
})
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, "POST", s.verifyClientsURL, bytes.NewReader(jreq))
if err != nil {
return err
}
res, err := http.DefaultClient.Do(req)
if err != nil {
if s.verifyClientsURLFailOpen {
s.logf("admission controller unreachable; allowing client %v", clientKey)
return nil
}
return err
}
defer res.Body.Close()
if res.StatusCode != 200 {
return fmt.Errorf("admission controller: %v", res.Status)
}
var jres tailcfg.DERPAdmitClientResponse
if err := json.NewDecoder(io.LimitReader(res.Body, 4<<10)).Decode(&jres); err != nil {
return err
}
if !jres.Allow {
return fmt.Errorf("admission controller: %v/%v not allowed", clientKey, clientIP)
}
// TODO(bradfitz): add policy for configurable bandwidth rate per client?
status, err := tailscale.Status(context.TODO())
if err != nil {
return fmt.Errorf("failed to query local tailscaled status: %w", err)
}
if clientKey == status.Self.PublicKey {
return nil
}
if _, exists := status.Peer[clientKey]; !exists {
return fmt.Errorf("client %v not in set of peers", clientKey)
}
// TODO(bradfitz): add policy for configurable bandwidth rate per client?
return nil
}

2
go.mod
View File

@@ -63,12 +63,12 @@ require (
github.com/prometheus/common v0.46.0
github.com/safchain/ethtool v0.3.0
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/studio-b12/gowebdav v0.9.0
github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e
github.com/tailscale/depaware v0.0.0-20210622194025-720c4b409502
github.com/tailscale/goexpect v0.0.0-20210902213824-6e8c725cea41
github.com/tailscale/golang-x-crypto v0.0.0-20240108194725-7ce1f622c780
github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05
github.com/tailscale/gowebdav v0.0.0-20240130173557-d49b872b5126
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a
github.com/tailscale/mkctr v0.0.0-20240102155253-bf50773ba734
github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85

4
go.sum
View File

@@ -853,8 +853,6 @@ github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/studio-b12/gowebdav v0.9.0 h1:1j1sc9gQnNxbXXM4M/CebPOX4aXYtr7MojAVcN4dHjU=
github.com/studio-b12/gowebdav v0.9.0/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE=
github.com/subosito/gotenv v1.4.2 h1:X1TuBLAMDFbaTAChgCBLu3DU3UPyELpnF2jjJ2cz/S8=
github.com/subosito/gotenv v1.4.2/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0=
github.com/t-yuki/gocover-cobertura v0.0.0-20180217150009-aaee18c8195c h1:+aPplBwWcHBo6q9xrfWdMrT9o4kltkmmvpemgIjep/8=
@@ -871,6 +869,8 @@ github.com/tailscale/golang-x-crypto v0.0.0-20240108194725-7ce1f622c780 h1:U0J2C
github.com/tailscale/golang-x-crypto v0.0.0-20240108194725-7ce1f622c780/go.mod h1:ikbF+YT089eInTp9f2vmvy4+ZVnW5hzX1q2WknxSprQ=
github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 h1:4chzWmimtJPxRs2O36yuGRW3f9SYV+bMTTvMBI0EKio=
github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05/go.mod h1:PdCqy9JzfWMJf1H5UJW2ip33/d4YkoKN0r67yKH1mG8=
github.com/tailscale/gowebdav v0.0.0-20240130173557-d49b872b5126 h1:EBLH+PeC3efXmUi82yEMxjlcKhDwAUZTi0tIT4Q8oTg=
github.com/tailscale/gowebdav v0.0.0-20240130173557-d49b872b5126/go.mod h1:UCbnLJ2ebWLs28V9ubpXbq4Qx3e0q1TVoM1AC3Z2b40=
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a h1:SJy1Pu0eH1C29XwJucQo73FrleVK6t4kYz4NVhp34Yw=
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a/go.mod h1:DFSS3NAGHthKo1gTlmEcSBiZrRJXi28rLNd/1udP1c8=
github.com/tailscale/mkctr v0.0.0-20240102155253-bf50773ba734 h1:93cvKHbvsPK3MKfFTvR00d0b0R0bzRKBW9yrj813fhI=

View File

@@ -688,8 +688,23 @@ func checkCertDomain(st *ipnstate.Status, domain string) error {
return nil
}
}
if len(st.CertDomains) == 0 {
return errors.New("your Tailscale account does not support getting TLS certs")
// Transitional way while server doesn't yet populate CertDomains: also permit the client
// attempting Self.DNSName.
okay := st.CertDomains[:len(st.CertDomains):len(st.CertDomains)]
if st.Self != nil {
if v := strings.Trim(st.Self.DNSName, "."); v != "" {
if v == domain {
return nil
}
okay = append(okay, v)
}
}
switch len(okay) {
case 0:
return errors.New("your Tailscale account does not support getting TLS certs")
case 1:
return fmt.Errorf("invalid domain %q; only %q is permitted", domain, okay[0])
default:
return fmt.Errorf("invalid domain %q; must be one of %q", domain, okay)
}
return fmt.Errorf("invalid domain %q; must be one of %q", domain, st.CertDomains)
}

View File

@@ -792,7 +792,7 @@ func (b *LocalBackend) UpdateStatus(sb *ipnstate.StatusBuilder) {
var tailscaleIPs []netip.Addr
if b.netMap != nil {
addrs := b.netMap.GetAddresses()
for i := range addrs.Len() {
for i := range addrs.LenIter() {
if addr := addrs.At(i); addr.IsSingleIP() {
sb.AddTailscaleIP(addr.Addr())
tailscaleIPs = append(tailscaleIPs, addr.Addr())
@@ -856,7 +856,7 @@ func (b *LocalBackend) populatePeerStatusLocked(sb *ipnstate.StatusBuilder) {
lastSeen = *p.LastSeen()
}
tailscaleIPs := make([]netip.Addr, 0, p.Addresses().Len())
for i := range p.Addresses().Len() {
for i := range p.Addresses().LenIter() {
addr := p.Addresses().At(i)
if addr.IsSingleIP() && tsaddr.IsTailscaleIP(addr.Addr()) {
tailscaleIPs = append(tailscaleIPs, addr.Addr())
@@ -977,7 +977,7 @@ func (b *LocalBackend) peerCapsLocked(src netip.Addr) tailcfg.PeerCapMap {
return nil
}
addrs := b.netMap.GetAddresses()
for i := range addrs.Len() {
for i := range addrs.LenIter() {
a := addrs.At(i)
if !a.IsSingleIP() {
continue
@@ -1433,7 +1433,7 @@ func setExitNodeID(prefs *ipn.Prefs, nm *netmap.NetworkMap) (prefsChanged bool)
}
for _, peer := range nm.Peers {
for i := range peer.Addresses().Len() {
for i := range peer.Addresses().LenIter() {
addr := peer.Addresses().At(i)
if !addr.IsSingleIP() || addr.Addr() != prefs.ExitNodeIP {
continue
@@ -1877,7 +1877,7 @@ func (b *LocalBackend) updateFilterLocked(netMap *netmap.NetworkMap, prefs ipn.P
logNetsB.RemovePrefix(tsaddr.ChromeOSVMRange())
if haveNetmap {
addrs = netMap.GetAddresses()
for i := range addrs.Len() {
for i := range addrs.LenIter() {
localNetsB.AddPrefix(addrs.At(i))
}
packetFilter = netMap.PacketFilter
@@ -1987,7 +1987,7 @@ func packetFilterPermitsUnlockedNodes(peers map[tailcfg.NodeID]tailcfg.NodeView,
continue
}
numUnlocked++
for i := range p.AllowedIPs().Len() { // not only addresses!
for i := range p.AllowedIPs().LenIter() { // not only addresses!
b.AddPrefix(p.AllowedIPs().At(i))
}
}
@@ -3299,9 +3299,13 @@ func (b *LocalBackend) handlePeerAPIConn(remote, local netip.AddrPort, c net.Con
return
}
func (b *LocalBackend) isLocalIP(ip netip.Addr) bool {
func (b *LocalBackend) isLocallyAvailable(ip netip.Addr) bool {
nm := b.NetMap()
return nm != nil && views.SliceContains(nm.GetAddresses(), netip.PrefixFrom(ip, ip.BitLen()))
if nm == nil {
return false
}
pfx := netip.PrefixFrom(ip, ip.BitLen())
return views.SliceContains(nm.SelfNode.AllowedIPs(), pfx)
}
var (
@@ -3319,7 +3323,7 @@ func (b *LocalBackend) TCPHandlerForDst(src, dst netip.AddrPort) (handler func(c
}
return b.HandleQuad100Port80Conn, opts
}
if !b.isLocalIP(dst.Addr()) {
if !b.isLocallyAvailable(dst.Addr()) {
return nil, nil
}
if dst.Port() == 22 && b.ShouldRunSSH() {
@@ -3640,14 +3644,14 @@ func dnsConfigForNetmap(nm *netmap.NetworkMap, peers map[tailcfg.NodeID]tailcfg.
return // TODO: propagate error?
}
var have4 bool
for i := range addrs.Len() {
for i := range addrs.LenIter() {
if addrs.At(i).Addr().Is4() {
have4 = true
break
}
}
var ips []netip.Addr
for i := range addrs.Len() {
for i := range addrs.LenIter() {
addr := addrs.At(i)
if selfV6Only {
if addr.Addr().Is6() {
@@ -3936,7 +3940,7 @@ func (b *LocalBackend) initPeerAPIListener() {
b.peerAPIServer = ps
isNetstack := b.sys.IsNetstack()
for i := range addrs.Len() {
for i := range addrs.LenIter() {
a := addrs.At(i)
var ln net.Listener
var err error
@@ -4250,7 +4254,7 @@ func (b *LocalBackend) enterStateLockedOnEntry(newState ipn.State) {
case ipn.Running:
var addrStrs []string
addrs := netMap.GetAddresses()
for i := range addrs.Len() {
for i := range addrs.LenIter() {
addrStrs = append(addrStrs, addrs.At(i).Addr().String())
}
systemd.Status("Connected; %s; %s", activeLogin, strings.Join(addrStrs, " "))
@@ -4626,7 +4630,7 @@ func (b *LocalBackend) setNetMapLocked(nm *netmap.NetworkMap) {
b.nodeByAddr[k] = 0
}
addNode := func(n tailcfg.NodeView) {
for i := range n.Addresses().Len() {
for i := range n.Addresses().LenIter() {
if ipp := n.Addresses().At(i); ipp.IsSingleIP() {
b.nodeByAddr[ipp.Addr()] = n.ID()
}
@@ -5062,7 +5066,7 @@ func (b *LocalBackend) SetDNS(ctx context.Context, name, value string) error {
func peerAPIPorts(peer tailcfg.NodeView) (p4, p6 uint16) {
svcs := peer.Hostinfo().Services()
for i := range svcs.Len() {
for i := range svcs.LenIter() {
s := svcs.At(i)
switch s.Proto {
case tailcfg.PeerAPI4:
@@ -5095,7 +5099,7 @@ func peerAPIBase(nm *netmap.NetworkMap, peer tailcfg.NodeView) string {
var have4, have6 bool
addrs := nm.GetAddresses()
for i := range addrs.Len() {
for i := range addrs.LenIter() {
a := addrs.At(i)
if !a.IsSingleIP() {
continue
@@ -5118,7 +5122,7 @@ func peerAPIBase(nm *netmap.NetworkMap, peer tailcfg.NodeView) string {
}
func nodeIP(n tailcfg.NodeView, pred func(netip.Addr) bool) netip.Addr {
for i := range n.Addresses().Len() {
for i := range n.Addresses().LenIter() {
a := n.Addresses().At(i)
if a.IsSingleIP() && pred(a.Addr()) {
return a.Addr()
@@ -5296,7 +5300,7 @@ func wireguardExitNodeDNSResolvers(nm *netmap.NetworkMap, peers map[tailcfg.Node
resolvers := p.ExitNodeDNSResolvers()
if !resolvers.IsNil() && resolvers.Len() > 0 {
copies := make([]*dnstype.Resolver, resolvers.Len())
for i := range resolvers.Len() {
for i := range resolvers.LenIter() {
copies[i] = resolvers.At(i).AsStruct()
}
return copies, true
@@ -5319,7 +5323,7 @@ func peerCanProxyDNS(p tailcfg.NodeView) bool {
// If p.Cap is not populated (e.g. older control server), then do the old
// thing of searching through services.
services := p.Hostinfo().Services()
for i := range services.Len() {
for i := range services.LenIter() {
if s := services.At(i); s.Proto == tailcfg.PeerAPIDNS && s.Port >= 1 {
return true
}
@@ -5495,7 +5499,7 @@ func (b *LocalBackend) handleQuad100Port80Conn(w http.ResponseWriter, r *http.Re
return
}
io.WriteString(w, "<p>Local addresses:</p><ul>\n")
for i := range addrs.Len() {
for i := range addrs.LenIter() {
fmt.Fprintf(w, "<li>%v</li>\n", addrs.At(i).Addr())
}
io.WriteString(w, "</ul>\n")

View File

@@ -100,7 +100,7 @@ func (b *LocalBackend) tkaFilterNetmapLocked(nm *netmap.NetworkMap) {
TailscaleIPs: make([]netip.Addr, p.Addresses().Len()),
NodeKey: p.Key(),
}
for i := range p.Addresses().Len() {
for i := range p.Addresses().LenIter() {
addr := p.Addresses().At(i)
if addr.IsSingleIP() && tsaddr.IsTailscaleIP(addr.Addr()) {
fp.TailscaleIPs[i] = addr.Addr()

View File

@@ -222,7 +222,7 @@ func (b *LocalBackend) updateServeTCPPortNetMapAddrListenersLocked(ports []uint1
}
addrs := nm.GetAddresses()
for i := range addrs.Len() {
for i := range addrs.LenIter() {
a := addrs.At(i)
for _, p := range ports {
addrPort := netip.AddrPortFrom(a.Addr(), p)

View File

@@ -121,7 +121,7 @@ func (b *LocalBackend) updateWebClientListenersLocked() {
}
addrs := b.netMap.GetAddresses()
for i := range addrs.Len() {
for i := range addrs.LenIter() {
addrPort := netip.AddrPortFrom(addrs.At(i).Addr(), webClientPort)
if _, ok := b.webClientListeners[addrPort]; ok {
continue // already listening

View File

@@ -62,6 +62,11 @@ type StatefulSet struct {
// Configuration for the proxy Pod.
// +optional
Pod *Pod `json:"pod,omitempty"`
// In future: allow users to tell the operator that spec.replicas for a
// statefulset should be unset to allow HPA manage this field (i.e if
// users set it to 0 here, unset the replicas field).
// +optional
Replicas *int32 `json:"replicas,omitempty"`
}
type Pod struct {

View File

@@ -363,6 +363,11 @@ func (in *StatefulSet) DeepCopyInto(out *StatefulSet) {
*out = new(Pod)
(*in).DeepCopyInto(*out)
}
if in.Replicas != nil {
in, out := &in.Replicas, &out.Replicas
*out = new(int32)
**out = **in
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StatefulSet.

View File

@@ -3,7 +3,9 @@
package kube
import "time"
import (
"time"
)
// Note: The API types are copied from k8s.io/api{,machinery} to not introduce a
// module dependency on the Kubernetes API as it pulls in many more dependencies.
@@ -147,6 +149,31 @@ type Secret struct {
Data map[string][]byte `json:"data,omitempty"`
}
type TokenRequest struct {
TypeMeta `json:",inline"`
ObjectMeta `json:"metadata"`
Spec TokenRequestSpec `json:"spec"`
Status TokenRequestStatus `json:"status,omitempty"`
}
type TokenRequestSpec struct {
Audiences []string `json:"audiences"`
ExpirationSeconds *int64 `json:"expirationSeconds"`
BoundObjectRef *BoundObjectReference `json:"boundObjectRef"`
}
// BoundObjectReference is a reference to an object that a token is bound to.
type BoundObjectReference struct {
Kind string `json:"kind,omitempty"`
APIVersion string `json:"apiVersion,omitempty"`
Name string `json:"name,omitempty"`
}
type TokenRequestStatus struct {
Token string `json:"token"`
ExpirationSeconds time.Time `json:"expirationSeconds"`
}
// Status is a return value for calls that don't return other objects.
type Status struct {
TypeMeta `json:",inline"`

View File

@@ -127,6 +127,10 @@ func (c *Client) secretURL(name string) string {
return fmt.Sprintf("%s/api/v1/namespaces/%s/secrets/%s", c.url, c.ns, name)
}
func (c *Client) tokenRequestURL() string {
return fmt.Sprintf("%s/api/v1/namespaces/%s/serviceaccounts/proxies/token", c.url, c.ns)
}
func getError(resp *http.Response) error {
if resp.StatusCode == 200 || resp.StatusCode == 201 {
// These are the only success codes returned by the Kubernetes API.
@@ -154,7 +158,11 @@ func setHeader(key, value string) func(*http.Request) {
// If the request fails with a 401, the token is expired and a new one is
// requested.
func (c *Client) doRequest(ctx context.Context, method, url string, in, out any, opts ...func(*http.Request)) error {
req, err := c.newRequest(ctx, method, url, in)
tk, err := c.getOrRenewToken()
if err != nil {
return err
}
req, err := c.newRequest(ctx, method, url, in, tk)
if err != nil {
return err
}
@@ -178,11 +186,7 @@ func (c *Client) doRequest(ctx context.Context, method, url string, in, out any,
return nil
}
func (c *Client) newRequest(ctx context.Context, method, url string, in any) (*http.Request, error) {
tk, err := c.getOrRenewToken()
if err != nil {
return nil, err
}
func (c *Client) newRequest(ctx context.Context, method, url string, in any, token string) (*http.Request, error) {
var body io.Reader
if in != nil {
switch in := in.(type) {
@@ -204,7 +208,7 @@ func (c *Client) newRequest(ctx context.Context, method, url string, in any) (*h
req.Header.Add("Content-Type", "application/json")
}
req.Header.Add("Accept", "application/json")
req.Header.Add("Authorization", "Bearer "+tk)
req.Header.Add("Authorization", "Bearer "+token)
return req, nil
}
@@ -217,6 +221,28 @@ func (c *Client) GetSecret(ctx context.Context, name string) (*Secret, error) {
return s, nil
}
func (c *Client) GetOrCreateSecret(ctx context.Context, name string) (*Secret, error) {
secret, err := c.GetSecret(ctx, name)
if err != nil {
if st, ok := err.(*Status); ok && st.Code == 404 {
secret = &Secret{
TypeMeta: TypeMeta{
APIVersion: "v1",
Kind: "Secret",
},
ObjectMeta: ObjectMeta{
Name: name,
},
}
if err := c.CreateSecret(ctx, secret); err != nil {
return nil, fmt.Errorf("error creating Secret: %w", err)
}
}
}
return secret, nil
}
// CreateSecret creates a secret in the Kubernetes API.
func (c *Client) CreateSecret(ctx context.Context, s *Secret) error {
s.Namespace = c.ns
@@ -228,6 +254,28 @@ func (c *Client) UpdateSecret(ctx context.Context, s *Secret) error {
return c.doRequest(ctx, "PUT", c.secretURL(s.Name), s, nil)
}
func (c *Client) CreateTokenForPod(ctx context.Context, name string, audiences []string) (string, error) {
tReq := &TokenRequest{
Spec: TokenRequestSpec{
BoundObjectRef: &BoundObjectReference{
Kind: "Pod",
Name: name,
},
},
}
if len(audiences) != 0 {
tReq.Spec.Audiences = audiences
}
err := c.doRequest(ctx, "POST", c.tokenRequestURL(), tReq, tReq)
if err != nil {
return "", fmt.Errorf("error creating a token: %w", err)
}
if tReq.Status.Token == "" {
return "", fmt.Errorf("Kubernetes did not give us a token, full request: %+#v\n", tReq)
}
return tReq.Status.Token, nil
}
// JSONPatch is a JSON patch operation.
// It currently (2023-03-02) only supports the "remove" operation.
//

View File

@@ -203,9 +203,6 @@ func populate() {
// Mullvad (extended)
addDoH("194.242.2.5", "https://extended.dns.mullvad.net/dns-query")
addDoH("2a07:e340::5", "https://extended.dns.mullvad.net/dns-query")
// Mullvad (family)
addDoH("194.242.2.6", "https://family.dns.mullvad.net/dns-query")
addDoH("2a07:e340::6", "https://family.dns.mullvad.net/dns-query")
// Mullvad (all)
addDoH("194.242.2.9", "https://all.dns.mullvad.net/dns-query")
addDoH("2a07:e340::9", "https://all.dns.mullvad.net/dns-query")

View File

@@ -405,9 +405,6 @@ func (f *forwarder) getKnownDoHClientForProvider(urlBase string) (c *http.Client
Transport: &http.Transport{
ForceAttemptHTTP2: true,
IdleConnTimeout: dohTransportTimeout,
// On mobile platforms TCP KeepAlive is disabled in the dialer,
// ensure that we timeout if the connection appears to be hung.
ResponseHeaderTimeout: 10 * time.Second,
DialContext: func(ctx context.Context, netw, addr string) (net.Conn, error) {
if !strings.HasPrefix(netw, "tcp") {
return nil, fmt.Errorf("unexpected network %q", netw)

View File

@@ -21,9 +21,8 @@ func UpdateLastKnownDefaultRouteInterface(ifName string) {
if ifName == "" {
return
}
if old := lastKnownDefaultRouteIfName.Swap(ifName); old != ifName {
log.Printf("defaultroute_ios: update from Swift, ifName = %s (was %s)", ifName, old)
}
lastKnownDefaultRouteIfName.Store(ifName)
log.Printf("defaultroute_ios: update from Swift, ifName = %s", ifName)
}
func defaultRoute() (d DefaultRouteDetails, err error) {
@@ -57,13 +56,13 @@ func defaultRoute() (d DefaultRouteDetails, err error) {
}
if !ifc.IsUp() {
log.Printf("defaultroute_ios: %s is down", name)
log.Println("defaultroute_ios: %s is down", name)
return nil
}
addrs, _ := ifc.Addrs()
if len(addrs) == 0 {
log.Printf("defaultroute_ios: %s has no addresses", name)
log.Println("defaultroute_ios: %s has no addresses", name)
return nil
}
return &ifc
@@ -77,6 +76,7 @@ func defaultRoute() (d DefaultRouteDetails, err error) {
if swiftIfName := lastKnownDefaultRouteIfName.Load(); swiftIfName != "" {
ifc := getInterfaceByName(swiftIfName)
if ifc != nil {
log.Printf("defaultroute_ios: using %s (provided by Swift)", ifc.Name)
d.InterfaceName = ifc.Name
d.InterfaceIndex = ifc.Index
return d, nil

View File

@@ -1,36 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package ktimeout configures kernel TCP stack timeouts via the provided
// control functions. Platform support varies; on unsupported platforms control
// functions may be entirely no-ops.
package ktimeout
import (
"fmt"
"syscall"
"time"
)
// UserTimeout returns a control function that sets the TCP user timeout
// (TCP_USER_TIMEOUT on linux). A user timeout specifies the maximum age of
// unacknowledged data on the connection (either in buffer, or sent but not
// acknowledged) before the connection is terminated. This timer has no effect
// on limiting the lifetime of idle connections. This may be entirely local to
// the network stack or may also apply RFC 5482 options to packets.
func UserTimeout(timeout time.Duration) func(network, address string, c syscall.RawConn) error {
return func(network, address string, c syscall.RawConn) error {
switch network {
case "tcp", "tcp4", "tcp6":
default:
return fmt.Errorf("ktimeout.UserTimeout: unsupported network: %s", network)
}
var err error
if e := c.Control(func(fd uintptr) {
err = SetUserTimeout(fd, timeout)
}); e != nil {
return e
}
return err
}
}

View File

@@ -1,15 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !linux
package ktimeout
import (
"time"
)
// SetUserTimeout is a no-op on this platform.
func SetUserTimeout(fd uintptr, timeout time.Duration) error {
return nil
}

View File

@@ -1,15 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package ktimeout
import (
"time"
"golang.org/x/sys/unix"
)
// SetUserTimeout sets the TCP_USER_TIMEOUT option on the given file descriptor.
func SetUserTimeout(fd uintptr, timeout time.Duration) error {
return unix.SetsockoptInt(int(fd), unix.SOL_TCP, unix.TCP_USER_TIMEOUT, int(timeout/time.Millisecond))
}

View File

@@ -1,46 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package ktimeout
import (
"net"
"testing"
"time"
"golang.org/x/net/nettest"
"golang.org/x/sys/unix"
"tailscale.com/util/must"
)
func TestSetUserTimeout(t *testing.T) {
l := must.Get(nettest.NewLocalListener("tcp"))
defer l.Close()
var err error
if e := must.Get(l.(*net.TCPListener).SyscallConn()).Control(func(fd uintptr) {
err = SetUserTimeout(fd, 0)
}); e != nil {
t.Fatal(e)
}
if err != nil {
t.Fatal(err)
}
v := must.Get(unix.GetsockoptInt(int(must.Get(l.(*net.TCPListener).File()).Fd()), unix.SOL_TCP, unix.TCP_USER_TIMEOUT))
if v != 0 {
t.Errorf("TCP_USER_TIMEOUT: got %v; want 0", v)
}
if e := must.Get(l.(*net.TCPListener).SyscallConn()).Control(func(fd uintptr) {
err = SetUserTimeout(fd, 30*time.Second)
}); e != nil {
t.Fatal(e)
}
if err != nil {
t.Fatal(err)
}
v = must.Get(unix.GetsockoptInt(int(must.Get(l.(*net.TCPListener).File()).Fd()), unix.SOL_TCP, unix.TCP_USER_TIMEOUT))
if v != 30000 {
t.Errorf("TCP_USER_TIMEOUT: got %v; want 30000", v)
}
}

View File

@@ -1,24 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package ktimeout
import (
"context"
"fmt"
"net"
"time"
)
func ExampleUserTimeout() {
lc := net.ListenConfig{
Control: UserTimeout(30 * time.Second),
}
l, err := lc.Listen(context.TODO(), "tcp", "127.0.0.1:0")
if err != nil {
fmt.Printf("error: %v", err)
return
}
l.Close()
// Output:
}

View File

@@ -203,7 +203,7 @@ func NewContainsIPFunc(addrs views.Slice[netip.Prefix]) func(ip netip.Addr) bool
}
// General case:
m := map[netip.Addr]bool{}
for i := range addrs.Len() {
for i := range addrs.LenIter() {
m[addrs.At(i).Addr()] = true
}
return func(ip netip.Addr) bool { return m[ip] }
@@ -229,7 +229,7 @@ func PrefixIs6(p netip.Prefix) bool { return p.Addr().Is6() }
// IPv6 /0 route.
func ContainsExitRoutes(rr views.Slice[netip.Prefix]) bool {
var v4, v6 bool
for i := range rr.Len() {
for i := range rr.LenIter() {
r := rr.At(i)
if r == allIPv4 {
v4 = true
@@ -243,7 +243,7 @@ func ContainsExitRoutes(rr views.Slice[netip.Prefix]) bool {
// ContainsNonExitSubnetRoutes reports whether v contains Subnet
// Routes other than ExitNode Routes.
func ContainsNonExitSubnetRoutes(rr views.Slice[netip.Prefix]) bool {
for i := range rr.Len() {
for i := range rr.LenIter() {
if rr.At(i).Bits() != 0 {
return true
}
@@ -274,7 +274,7 @@ func SortPrefixes(p []netip.Prefix) {
// in that match f.
func FilterPrefixesCopy(in views.Slice[netip.Prefix], f func(netip.Prefix) bool) []netip.Prefix {
var out []netip.Prefix
for i := range in.Len() {
for i := range in.LenIter() {
if v := in.At(i); f(v) {
out = append(out, v)
}

View File

@@ -42,7 +42,7 @@ func dnsMapFromNetworkMap(nm *netmap.NetworkMap) dnsMap {
if dnsname.HasSuffix(nm.Name, suffix) {
ret[canonMapKey(dnsname.TrimSuffix(nm.Name, suffix))] = ip
}
for i := range addrs.Len() {
for i := range addrs.LenIter() {
if addrs.At(i).Addr().Is4() {
have4 = true
}
@@ -52,7 +52,7 @@ func dnsMapFromNetworkMap(nm *netmap.NetworkMap) dnsMap {
if p.Name() == "" {
continue
}
for i := range p.Addresses().Len() {
for i := range p.Addresses().LenIter() {
a := p.Addresses().At(i)
ip := a.Addr()
if ip.Is4() && !have4 {

View File

@@ -19,7 +19,6 @@ import (
"time"
"golang.org/x/net/http/httpproxy"
"tailscale.com/util/mak"
)
// InvalidateCache invalidates the package-level cache for ProxyFromEnvironment.
@@ -118,33 +117,10 @@ func SetSelfProxy(addrs ...string) {
// For example, WPAD PAC files on Windows.
var sysProxyFromEnv func(*http.Request) (*url.URL, error)
// These variables track whether we've printed a log message for a given proxy
// URL; we only print them once to avoid log spam.
var (
logMessageMu sync.Mutex
logMessagePrinted map[string]bool
)
// ProxyFromEnvironment is like the standard library's http.ProxyFromEnvironment
// but additionally does OS-specific proxy lookups if the environment variables
// alone don't specify a proxy.
func ProxyFromEnvironment(req *http.Request) (ret *url.URL, _ error) {
defer func() {
if ret == nil {
return
}
ss := ret.String()
logMessageMu.Lock()
defer logMessageMu.Unlock()
if logMessagePrinted[ss] {
return
}
log.Printf("tshttpproxy: using proxy %q for URL: %q", ss, req.URL.String())
mak.Set(&logMessagePrinted, ss, true)
}()
func ProxyFromEnvironment(req *http.Request) (*url.URL, error) {
localProxyFunc := getProxyFunc()
u, err := localProxyFunc(req.URL)
if u != nil && err == nil {

View File

@@ -12,7 +12,6 @@ import (
"io"
"net"
"net/http"
"net/netip"
"time"
"github.com/pkg/errors"
@@ -24,33 +23,25 @@ const expiresSoon = 7 * 24 * time.Hour // 7 days from now
// TLS returns a Probe that healthchecks a TLS endpoint.
//
// The ProbeFunc connects to a hostPort (host:port string), does a TLS
// The ProbeFunc connects to a hostname (host:port string), does a TLS
// handshake, verifies that the hostname matches the presented certificate,
// checks certificate validity time and OCSP revocation status.
func TLS(hostPort string) ProbeFunc {
func TLS(hostname string) ProbeFunc {
return func(ctx context.Context) error {
certDomain, _, err := net.SplitHostPort(hostPort)
if err != nil {
return err
}
return probeTLS(ctx, certDomain, hostPort)
return probeTLS(ctx, hostname)
}
}
// TLSWithIP is like TLS, but dials the provided dialAddr instead
// of using DNS resolution. The certDomain is the expected name in
// the cert (and the SNI name to send).
func TLSWithIP(certDomain string, dialAddr netip.AddrPort) ProbeFunc {
return func(ctx context.Context) error {
return probeTLS(ctx, certDomain, dialAddr.String())
}
}
func probeTLS(ctx context.Context, certDomain string, dialHostPort string) error {
dialer := &tls.Dialer{Config: &tls.Config{ServerName: certDomain}}
conn, err := dialer.DialContext(ctx, "tcp", dialHostPort)
func probeTLS(ctx context.Context, hostname string) error {
host, _, err := net.SplitHostPort(hostname)
if err != nil {
return fmt.Errorf("connecting to %q: %w", dialHostPort, err)
return err
}
dialer := &tls.Dialer{Config: &tls.Config{ServerName: host}}
conn, err := dialer.DialContext(ctx, "tcp", hostname)
if err != nil {
return fmt.Errorf("connecting to %q: %w", hostname, err)
}
defer conn.Close()

View File

@@ -85,7 +85,7 @@ func TestTLSConnection(t *testing.T) {
srv.StartTLS()
defer srv.Close()
err = probeTLS(context.Background(), "fail.example.com", srv.Listener.Addr().String())
err = probeTLS(context.Background(), srv.Listener.Addr().String())
// The specific error message here is platform-specific ("certificate is not trusted"
// on macOS and "certificate signed by unknown authority" on Linux), so only check
// that it contains the word 'certificate'.

View File

@@ -3,12 +3,7 @@
package tailcfg
import (
"net/netip"
"sort"
"tailscale.com/types/key"
)
import "sort"
// DERPMap describes the set of DERP packet relay servers that are available.
type DERPMap struct {
@@ -181,17 +176,3 @@ type DERPNode struct {
// DotInvalid is a fake DNS TLD used in tests for an invalid hostname.
const DotInvalid = ".invalid"
// DERPAdmitClientRequest is the JSON request body of a POST to derper's
// --verify-client-url admission controller URL.
type DERPAdmitClientRequest struct {
NodePublic key.NodePublic // key to query for admission
Source netip.Addr // derp client's IP address
}
// DERPAdmitClientResponse is the response to a DERPAdmitClientRequest.
type DERPAdmitClientResponse struct {
Allow bool // whether to permit client
// TODO(bradfitz,maisem): bandwidth limits, etc?
}

View File

@@ -850,7 +850,7 @@ func TestDeps(t *testing.T) {
// Make sure we don't again accidentally bring in a dependency on
// TailFS or its transitive dependencies
"tailscale.com/tailfs/tailfsimpl": "https://github.com/tailscale/tailscale/pull/10631",
"github.com/studio-b12/gowebdav": "https://github.com/tailscale/tailscale/pull/10631",
"github.com/tailscale/gowebdav": "https://github.com/tailscale/tailscale/pull/10631",
},
}.Check(t)
}

View File

@@ -1,233 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package compositedav provides an http.Handler that composes multiple WebDAV
// services into a single WebDAV service that presents each of them as its own
// folder.
package compositedav
import (
"log"
"net/http"
"net/http/httputil"
"net/url"
"path"
"slices"
"strings"
"sync"
"github.com/tailscale/xnet/webdav"
"tailscale.com/tailfs/tailfsimpl/dirfs"
"tailscale.com/tailfs/tailfsimpl/shared"
"tailscale.com/tstime"
"tailscale.com/types/logger"
)
// Child is a child folder of this compositedav.
type Child struct {
*dirfs.Child
// BaseURL is the base URL of the WebDAV service to which we'll proxy
// requests for this Child. We will append the filename from the original
// URL to this.
BaseURL string
// Transport (if specified) is the http transport to use when communicating
// with this Child's WebDAV service.
Transport http.RoundTripper
rp *httputil.ReverseProxy
initOnce sync.Once
}
// CloseIdleConnections forcibly closes any idle connections on this Child's
// reverse proxy.
func (c *Child) CloseIdleConnections() {
tr, ok := c.Transport.(*http.Transport)
if ok {
tr.CloseIdleConnections()
}
}
func (c *Child) init() {
c.initOnce.Do(func() {
c.rp = &httputil.ReverseProxy{
Transport: c.Transport,
Rewrite: func(r *httputil.ProxyRequest) {},
}
})
}
// Handler implements http.Handler by using a dirfs.FS for showing a virtual
// read-only folder that represents the Child WebDAV services as sub-folders
// and proxying all requests for resources on the children to those children
// via httputil.ReverseProxy instances.
type Handler struct {
// Logf specifies a logging function to use.
Logf logger.Logf
// Clock, if specified, determines the current time. If not specified, we
// default to time.Now().
Clock tstime.Clock
// StatCache is an optional cache for PROPFIND results.
StatCache *StatCache
// childrenMu guards the fields below. Note that we do read the contents of
// children after releasing the read lock, which we can do because we never
// modify children but only ever replace it in SetChildren.
childrenMu sync.RWMutex
children []*Child
staticRoot string
}
// ServeHTTP implements http.Handler.
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method == "PROPFIND" {
h.handlePROPFIND(w, r)
return
}
if r.Method != "GET" {
// If the user is performing a modification (e.g. PUT, MKDIR, etc),
// we need to invalidate the StatCache to make sure we're not knowingly
// showing stale stats.
// TODO(oxtoacart): maybe be more selective about invalidating cache
h.StatCache.invalidate()
}
mpl := h.maxPathLength(r)
pathComponents := shared.CleanAndSplit(r.URL.Path)
if len(pathComponents) >= mpl {
h.delegate(pathComponents[mpl-1:], w, r)
return
}
h.handle(w, r)
}
// handle handles the request locally using our dirfs.FS.
func (h *Handler) handle(w http.ResponseWriter, r *http.Request) {
h.childrenMu.RLock()
clk, kids, root := h.Clock, h.children, h.staticRoot
h.childrenMu.RUnlock()
children := make([]*dirfs.Child, 0, len(kids))
for _, child := range kids {
children = append(children, child.Child)
}
wh := &webdav.Handler{
LockSystem: webdav.NewMemLS(),
FileSystem: &dirfs.FS{
Clock: clk,
Children: children,
StaticRoot: root,
},
}
wh.ServeHTTP(w, r)
}
// delegate sends the request to the Child WebDAV server.
func (h *Handler) delegate(pathComponents []string, w http.ResponseWriter, r *http.Request) string {
childName := pathComponents[0]
child := h.GetChild(childName)
if child == nil {
w.WriteHeader(http.StatusNotFound)
return childName
}
u, err := url.Parse(child.BaseURL)
if err != nil {
h.logf("warning: parse base URL %s failed: %s", child.BaseURL, err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return childName
}
u.Path = path.Join(u.Path, shared.Join(pathComponents[1:]...))
r.URL = u
r.Host = u.Host
child.rp.ServeHTTP(w, r)
return childName
}
// SetChildren replaces the entire existing set of children with the given
// ones. If staticRoot is given, the children will appear with a subfolder
// bearing named <staticRoot>.
func (h *Handler) SetChildren(staticRoot string, children ...*Child) {
for _, child := range children {
child.init()
}
slices.SortFunc(children, func(a, b *Child) int {
return strings.Compare(a.Name, b.Name)
})
h.childrenMu.Lock()
oldChildren := children
h.children = children
h.staticRoot = staticRoot
h.childrenMu.Unlock()
for _, child := range oldChildren {
child.CloseIdleConnections()
}
}
// GetChild gets the Child identified by name, or nil if no matching child
// found.
func (h *Handler) GetChild(name string) *Child {
h.childrenMu.RLock()
defer h.childrenMu.RUnlock()
_, child := h.findChildLocked(name)
return child
}
// Close closes this Handler,including closing all idle connections on children
// and stopping the StatCache (if caching is enabled).
func (h *Handler) Close() {
h.childrenMu.RLock()
oldChildren := h.children
h.children = nil
h.childrenMu.RUnlock()
for _, child := range oldChildren {
child.CloseIdleConnections()
}
if h.StatCache != nil {
h.StatCache.stop()
}
}
func (h *Handler) findChildLocked(name string) (int, *Child) {
var child *Child
i, found := slices.BinarySearchFunc(h.children, name, func(child *Child, name string) int {
return strings.Compare(child.Name, name)
})
if found {
return i, h.children[i]
}
return i, child
}
func (h *Handler) logf(format string, args ...any) {
if h.Logf != nil {
h.Logf(format, args...)
return
}
log.Printf(format, args...)
}
// maxPathLength calculates the maximum length of a path that can be handled by
// this handler without delegating to a Child. It's always at least 1, and if
// staticRoot is configured, it's 2.
func (h *Handler) maxPathLength(r *http.Request) int {
h.childrenMu.RLock()
defer h.childrenMu.RUnlock()
if h.staticRoot != "" {
return 2
}
return 1
}

View File

@@ -1,84 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package compositedav
import (
"bytes"
"fmt"
"math"
"net/http"
"regexp"
"tailscale.com/tailfs/tailfsimpl/shared"
)
var (
hrefRegex = regexp.MustCompile(`(?s)<D:href>/?([^<]*)/?</D:href>`)
)
func (h *Handler) handlePROPFIND(w http.ResponseWriter, r *http.Request) {
pathComponents := shared.CleanAndSplit(r.URL.Path)
mpl := h.maxPathLength(r)
if !shared.IsRoot(r.URL.Path) && len(pathComponents)+getDepth(r) > mpl {
// Delegate to a Child.
depth := getDepth(r)
cached := h.StatCache.get(r.URL.Path, depth)
if cached != nil {
w.Header().Del("Content-Length")
w.WriteHeader(http.StatusMultiStatus)
w.Write(cached)
return
}
// Use a buffering ResponseWriter so that we can manipulate the result.
// The only thing we use from the original ResponseWriter is Header().
bw := &bufferingResponseWriter{ResponseWriter: w}
mpl := h.maxPathLength(r)
h.delegate(pathComponents[mpl-1:], bw, r)
// Fixup paths to add the requested path as a prefix.
pathPrefix := shared.Join(pathComponents[0:mpl]...)
b := hrefRegex.ReplaceAll(bw.buf.Bytes(), []byte(fmt.Sprintf("<D:href>%s/$1</D:href>", pathPrefix)))
if h.StatCache != nil && bw.status == http.StatusMultiStatus && b != nil {
h.StatCache.set(r.URL.Path, depth, b)
}
w.Header().Del("Content-Length")
w.WriteHeader(bw.status)
w.Write(b)
return
}
h.handle(w, r)
}
func getDepth(r *http.Request) int {
switch r.Header.Get("Depth") {
case "0":
return 0
case "1":
return 1
case "infinity":
return math.MaxInt
}
return 0
}
type bufferingResponseWriter struct {
http.ResponseWriter
status int
buf bytes.Buffer
}
func (bw *bufferingResponseWriter) WriteHeader(statusCode int) {
bw.status = statusCode
}
func (bw *bufferingResponseWriter) Write(p []byte) (int, error) {
return bw.buf.Write(p)
}

View File

@@ -1,92 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package compositedav
import (
"sync"
"time"
"github.com/jellydator/ttlcache/v3"
)
// StatCache provides a cache for directory listings and file metadata.
// Especially when used from the command-line, mapped WebDAV drives can
// generate repetitive requests for the same file metadata. This cache helps
// reduce the number of round-trips to the WebDAV server for such requests.
// This is similar to the DirectoryCacheLifetime setting of Windows' built-in
// SMB client, see
// https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-7/ff686200(v=ws.10)
type StatCache struct {
TTL time.Duration
// mu guards the below values.
mu sync.Mutex
cachesByDepthAndPath map[int]*ttlcache.Cache[string, []byte]
}
func (c *StatCache) get(name string, depth int) []byte {
if c == nil {
return nil
}
c.mu.Lock()
defer c.mu.Unlock()
if c.cachesByDepthAndPath == nil {
return nil
}
cache := c.cachesByDepthAndPath[depth]
if cache == nil {
return nil
}
item := cache.Get(name)
if item == nil {
return nil
}
return item.Value()
}
func (c *StatCache) set(name string, depth int, value []byte) {
if c == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
if c.cachesByDepthAndPath == nil {
c.cachesByDepthAndPath = make(map[int]*ttlcache.Cache[string, []byte])
}
cache := c.cachesByDepthAndPath[depth]
if cache == nil {
cache = ttlcache.New(
ttlcache.WithTTL[string, []byte](c.TTL),
)
go cache.Start()
c.cachesByDepthAndPath[depth] = cache
}
cache.Set(name, value, ttlcache.DefaultTTL)
}
func (c *StatCache) invalidate() {
if c == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
for _, cache := range c.cachesByDepthAndPath {
cache.DeleteAll()
}
}
func (c *StatCache) stop() {
c.mu.Lock()
defer c.mu.Unlock()
for _, cache := range c.cachesByDepthAndPath {
cache.Stop()
}
}

View File

@@ -1,75 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package compositedav
import (
"bytes"
"testing"
"time"
"tailscale.com/tstest"
)
var (
val = []byte("1")
file = "file"
)
func TestStatCacheNoTimeout(t *testing.T) {
// Make sure we don't leak goroutines
tstest.ResourceCheck(t)
c := &StatCache{TTL: 5 * time.Second}
defer c.stop()
// check get before set
fetched := c.get(file, 1)
if fetched != nil {
t.Errorf("got %q, want nil", fetched)
}
// set new stat
c.set(file, 1, val)
fetched = c.get(file, 1)
if !bytes.Equal(fetched, val) {
t.Errorf("got %q, want %q", fetched, val)
}
// fetch stat again, should still be cached
fetched = c.get(file, 1)
if !bytes.Equal(fetched, val) {
t.Errorf("got %q, want %q", fetched, val)
}
}
func TestStatCacheTimeout(t *testing.T) {
// Make sure we don't leak goroutines
tstest.ResourceCheck(t)
c := &StatCache{TTL: 250 * time.Millisecond}
defer c.stop()
// set new stat
c.set(file, 1, val)
fetched := c.get(file, 1)
if !bytes.Equal(fetched, val) {
t.Errorf("got %q, want %q", fetched, val)
}
// wait for cache to expire and refetch stat, should be empty now
time.Sleep(c.TTL * 2)
fetched = c.get(file, 1)
if fetched != nil {
t.Errorf("invalidate should have cleared cached value")
}
c.set(file, 1, val)
// invalidate the cache and make sure nothing is returned
c.invalidate()
fetched = c.get(file, 1)
if fetched != nil {
t.Errorf("invalidate should have cleared cached value")
}
}

View File

@@ -0,0 +1,227 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package compositefs provides a webdav.FileSystem that is composi
package compositefs
import (
"io"
"log"
"os"
"path"
"slices"
"strings"
"sync"
"time"
"github.com/tailscale/xnet/webdav"
"tailscale.com/tailfs/tailfsimpl/shared"
"tailscale.com/tstime"
"tailscale.com/types/logger"
)
// Child is a child filesystem of a CompositeFileSystem
type Child struct {
// Name is the name of the child
Name string
// FS is the child's FileSystem
FS webdav.FileSystem
// Available is a function indicating whether or not the child is currently
// available.
Available func() bool
}
func (c *Child) isAvailable() bool {
if c.Available == nil {
return true
}
return c.Available()
}
// Options specifies options for configuring a CompositeFileSystem.
type Options struct {
// Logf specifies a logging function to use
Logf logger.Logf
// StatChildren, if true, causes the CompositeFileSystem to stat its child
// folders when generating a root directory listing. This gives more
// accurate information but increases latency.
StatChildren bool
// Clock, if specified, determines the current time. If not specified, we
// default to time.Now().
Clock tstime.Clock
}
// New constructs a CompositeFileSystem that logs using the given logf.
func New(opts Options) *CompositeFileSystem {
logf := opts.Logf
if logf == nil {
logf = log.Printf
}
fs := &CompositeFileSystem{
logf: logf,
statChildren: opts.StatChildren,
}
if opts.Clock != nil {
fs.now = opts.Clock.Now
} else {
fs.now = time.Now
}
return fs
}
// CompositeFileSystem is a webdav.FileSystem that is composed of multiple
// child webdav.FileSystems. Each child is identified by a name and appears
// as a folder within the root of the CompositeFileSystem, with the children
// sorted lexicographically by name.
//
// Children in a CompositeFileSystem can only be added or removed via calls to
// the AddChild and RemoveChild methods, they cannot be added via operations
// on the webdav.FileSystem interface like filesystem.Mkdir or filesystem.OpenFile.
// In other words, the root of the CompositeFileSystem acts as read-only, not
// permitting the addition, removal or renaming of folders.
//
// Rename is only supported within a single child. Renaming across children
// is not supported, as it wouldn't be possible to perform it atomically.
type CompositeFileSystem struct {
logf logger.Logf
statChildren bool
now func() time.Time
// childrenMu guards children
childrenMu sync.Mutex
children []*Child
}
// AddChild ads a single child with the given name, replacing any existing
// child with the same name.
func (cfs *CompositeFileSystem) AddChild(child *Child) {
cfs.childrenMu.Lock()
oldIdx, oldChild := cfs.findChildLocked(child.Name)
if oldChild != nil {
// replace old child
cfs.children[oldIdx] = child
} else {
// insert new child
cfs.children = slices.Insert(cfs.children, oldIdx, child)
}
cfs.childrenMu.Unlock()
if oldChild != nil {
if c, ok := oldChild.FS.(io.Closer); ok {
if err := c.Close(); err != nil {
cfs.logf("closing child filesystem %v: %v", child.Name, err)
}
}
}
}
// RemoveChild removes the child with the given name, if it exists.
func (cfs *CompositeFileSystem) RemoveChild(name string) {
cfs.childrenMu.Lock()
oldPos, oldChild := cfs.findChildLocked(name)
if oldChild != nil {
// remove old child
copy(cfs.children[oldPos:], cfs.children[oldPos+1:])
cfs.children = cfs.children[:len(cfs.children)-1]
}
cfs.childrenMu.Unlock()
if oldChild != nil {
closer, ok := oldChild.FS.(io.Closer)
if ok {
err := closer.Close()
if err != nil {
cfs.logf("failed to close child filesystem %v: %v", name, err)
}
}
}
}
// SetChildren replaces the entire existing set of children with the given
// ones.
func (cfs *CompositeFileSystem) SetChildren(children ...*Child) {
slices.SortFunc(children, func(a, b *Child) int {
return strings.Compare(a.Name, b.Name)
})
cfs.childrenMu.Lock()
oldChildren := cfs.children
cfs.children = children
cfs.childrenMu.Unlock()
for _, child := range oldChildren {
closer, ok := child.FS.(io.Closer)
if ok {
_ = closer.Close()
}
}
}
// GetChild returns the child with the given name and a boolean indicating
// whether or not it was found.
func (cfs *CompositeFileSystem) GetChild(name string) (webdav.FileSystem, bool) {
_, child := cfs.findChildLocked(name)
if child == nil {
return nil, false
}
return child.FS, true
}
func (cfs *CompositeFileSystem) findChildLocked(name string) (int, *Child) {
var child *Child
i, found := slices.BinarySearchFunc(cfs.children, name, func(child *Child, name string) int {
return strings.Compare(child.Name, name)
})
if found {
child = cfs.children[i]
}
return i, child
}
// pathInfoFor returns a pathInfo for the given filename. If the filename
// refers to a Child that does not exist within this CompositeFileSystem,
// it will return the error os.ErrNotExist. Even when returning an error,
// it will still return a complete pathInfo.
func (cfs *CompositeFileSystem) pathInfoFor(name string) (pathInfo, error) {
cfs.childrenMu.Lock()
defer cfs.childrenMu.Unlock()
var info pathInfo
pathComponents := shared.CleanAndSplit(name)
_, info.child = cfs.findChildLocked(pathComponents[0])
info.refersToChild = len(pathComponents) == 1
if !info.refersToChild {
info.pathOnChild = path.Join(pathComponents[1:]...)
}
if info.child == nil {
return info, os.ErrNotExist
}
return info, nil
}
// pathInfo provides information about a path
type pathInfo struct {
// child is the Child corresponding to the first component of the path.
child *Child
// refersToChild indicates that that path refers directly to the child
// (i.e. the path has only 1 component).
refersToChild bool
// pathOnChild is the path within the child (i.e. path minus leading component)
// if and only if refersToChild is false.
pathOnChild string
}
func (cfs *CompositeFileSystem) Close() error {
cfs.childrenMu.Lock()
children := cfs.children
cfs.childrenMu.Unlock()
for _, child := range children {
closer, ok := child.FS.(io.Closer)
if ok {
_ = closer.Close()
}
}
return nil
}

View File

@@ -0,0 +1,497 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package compositefs
import (
"context"
"errors"
"io/fs"
"net"
"net/http"
"os"
"path/filepath"
"testing"
"time"
"github.com/tailscale/xnet/webdav"
"tailscale.com/tailfs/tailfsimpl/shared"
"tailscale.com/tstest"
)
func TestStat(t *testing.T) {
cfs, dir1, _, clock, close := createFileSystem(t, nil)
defer close()
tests := []struct {
label string
name string
expected fs.FileInfo
err error
}{
{
label: "root folder",
name: "/",
expected: &shared.StaticFileInfo{
Named: "/",
Sized: 0,
ModdedTime: clock.Now(),
Dir: true,
},
},
{
label: "remote1",
name: "/remote1",
expected: &shared.StaticFileInfo{
Named: "/remote1",
Sized: 0,
ModdedTime: clock.Now(),
Dir: true,
},
},
{
label: "remote2",
name: "/remote2",
expected: &shared.StaticFileInfo{
Named: "/remote2",
Sized: 0,
ModdedTime: clock.Now(),
Dir: true,
},
},
{
label: "non-existent remote",
name: "/remote3",
err: os.ErrNotExist,
},
{
label: "file on remote1",
name: "/remote1/file1.txt",
expected: &shared.StaticFileInfo{
Named: "/remote1/file1.txt",
Sized: stat(t, filepath.Join(dir1, "file1.txt")).Size(),
ModdedTime: stat(t, filepath.Join(dir1, "file1.txt")).ModTime(),
Dir: false,
},
},
}
ctx := context.Background()
for _, test := range tests {
t.Run(test.label, func(t *testing.T) {
fi, err := cfs.Stat(ctx, test.name)
if test.err != nil {
if err == nil || !errors.Is(err, test.err) {
t.Errorf("expected error: %v got: %v", test.err, err)
}
} else {
if err != nil {
t.Errorf("unable to stat file: %v", err)
} else {
infosEqual(t, test.expected, fi)
}
}
})
}
}
func TestStatWithStatChildren(t *testing.T) {
cfs, dir1, dir2, _, close := createFileSystem(t, &Options{StatChildren: true})
defer close()
tests := []struct {
label string
name string
expected fs.FileInfo
}{
{
label: "root folder",
name: "/",
expected: &shared.StaticFileInfo{
Named: "/",
Sized: 0,
ModdedTime: stat(t, dir2).ModTime(), // ModTime should be greatest modtime of children
Dir: true,
},
},
{
label: "remote1",
name: "/remote1",
expected: &shared.StaticFileInfo{
Named: "/remote1",
Sized: stat(t, dir1).Size(),
ModdedTime: stat(t, dir1).ModTime(),
Dir: true,
},
},
{
label: "remote2",
name: "/remote2",
expected: &shared.StaticFileInfo{
Named: "/remote2",
Sized: stat(t, dir2).Size(),
ModdedTime: stat(t, dir2).ModTime(),
Dir: true,
},
},
}
ctx := context.Background()
for _, test := range tests {
t.Run(test.label, func(t *testing.T) {
fi, err := cfs.Stat(ctx, test.name)
if err != nil {
t.Errorf("unable to stat file: %v", err)
} else {
infosEqual(t, test.expected, fi)
}
})
}
}
func TestMkdir(t *testing.T) {
fs, _, _, _, close := createFileSystem(t, nil)
defer close()
tests := []struct {
label string
name string
perm os.FileMode
err error
}{
{
label: "attempt to create root folder",
name: "/",
},
{
label: "attempt to create remote",
name: "/remote1",
},
{
label: "attempt to create non-existent remote",
name: "/remote3",
err: os.ErrPermission,
},
{
label: "attempt to create file on non-existent remote",
name: "/remote3/somefile.txt",
err: os.ErrNotExist,
},
{
label: "success",
name: "/remote1/newfile.txt",
perm: 0772,
},
}
ctx := context.Background()
for _, test := range tests {
t.Run(test.label, func(t *testing.T) {
err := fs.Mkdir(ctx, test.name, test.perm)
if test.err != nil {
if err == nil || !errors.Is(err, test.err) {
t.Errorf("expected error: %v got: %v", test.err, err)
}
} else {
if err != nil {
t.Errorf("unexpected error: %v", err)
} else {
fi, err := fs.Stat(ctx, test.name)
if err != nil {
t.Errorf("unable to stat file: %v", err)
} else {
if fi.Name() != test.name {
t.Errorf("expected name: %v got: %v", test.name, fi.Name())
}
if !fi.IsDir() {
t.Error("expected directory")
}
}
}
}
})
}
}
func TestRemoveAll(t *testing.T) {
fs, _, _, _, close := createFileSystem(t, nil)
defer close()
tests := []struct {
label string
name string
err error
}{
{
label: "attempt to remove root folder",
name: "/",
err: os.ErrPermission,
},
{
label: "attempt to remove remote",
name: "/remote1",
err: os.ErrPermission,
},
{
label: "attempt to remove non-existent remote",
name: "/remote3",
err: os.ErrPermission,
},
{
label: "attempt to remove file on non-existent remote",
name: "/remote3/somefile.txt",
err: os.ErrNotExist,
},
{
label: "remove non-existent file",
name: "/remote1/nonexistent.txt",
},
{
label: "remove existing file",
name: "/remote1/dir1",
},
}
ctx := context.Background()
for _, test := range tests {
t.Run(test.label, func(t *testing.T) {
err := fs.RemoveAll(ctx, test.name)
if test.err != nil {
if err == nil || !errors.Is(err, test.err) {
t.Errorf("expected error: %v got: %v", test.err, err)
}
} else {
if err != nil {
t.Errorf("unexpected error: %v", err)
} else {
_, err := fs.Stat(ctx, test.name)
if !os.IsNotExist(err) {
t.Errorf("expected dir to be gone: %v", err)
}
}
}
})
}
}
func TestRename(t *testing.T) {
fs, _, _, _, close := createFileSystem(t, nil)
defer close()
tests := []struct {
label string
oldName string
newName string
err error
expectedNewInfo *shared.StaticFileInfo
}{
{
label: "attempt to move root folder",
oldName: "/",
newName: "/remote2/copy.txt",
err: os.ErrPermission,
},
{
label: "attempt to move to root folder",
oldName: "/remote1/file1.txt",
newName: "/",
err: os.ErrPermission,
},
{
label: "attempt to move to remote",
oldName: "/remote1/file1.txt",
newName: "/remote2",
err: os.ErrPermission,
},
{
label: "attempt to move to non-existent remote",
oldName: "/remote1/file1.txt",
newName: "/remote3",
err: os.ErrPermission,
},
{
label: "attempt to move file from non-existent remote",
oldName: "/remote3/file1.txt",
newName: "/remote1/file1.txt",
err: os.ErrNotExist,
},
{
label: "attempt to move file to a non-existent remote",
oldName: "/remote2/file2.txt",
newName: "/remote3/file2.txt",
err: os.ErrNotExist,
},
{
label: "attempt to move file across remotes",
oldName: "/remote1/file1.txt",
newName: "/remote2/file1.txt",
err: os.ErrPermission,
},
{
label: "attempt to move remote itself",
oldName: "/remote1",
newName: "/remote2",
err: os.ErrPermission,
},
{
label: "attempt to move to a remote",
oldName: "/remote1/file2.txt",
newName: "/remote2",
err: os.ErrPermission,
},
{
label: "move file within remote",
oldName: "/remote2/file2.txt",
newName: "/remote2/file3.txt",
expectedNewInfo: &shared.StaticFileInfo{
Named: "/remote2/file3.txt",
Sized: 5,
Dir: false,
},
},
}
ctx := context.Background()
for _, test := range tests {
t.Run(test.label, func(t *testing.T) {
err := fs.Rename(ctx, test.oldName, test.newName)
if test.err != nil {
if err == nil || test.err.Error() != err.Error() {
t.Errorf("expected error: %v got: %v", test.err, err)
}
} else {
if err != nil {
t.Errorf("unexpected error: %v", err)
} else {
fi, err := fs.Stat(ctx, test.newName)
if err != nil {
t.Errorf("unexpected error: %v", err)
} else {
// Override modTime to avoid having to compare it
test.expectedNewInfo.ModdedTime = fi.ModTime()
infosEqual(t, test.expectedNewInfo, fi)
}
}
}
})
}
}
func createFileSystem(t *testing.T, opts *Options) (webdav.FileSystem, string, string, *tstest.Clock, func()) {
l1, dir1 := startRemote(t)
l2, dir2 := startRemote(t)
// Make some files, use perms 0666 as lowest common denominator that works
// on both UNIX and Windows.
err := os.WriteFile(filepath.Join(dir1, "file1.txt"), []byte("12345"), 0666)
if err != nil {
t.Fatal(err)
}
err = os.WriteFile(filepath.Join(dir2, "file2.txt"), []byte("54321"), 0666)
if err != nil {
t.Fatal(err)
}
// make some directories
err = os.Mkdir(filepath.Join(dir1, "dir1"), 0666)
if err != nil {
t.Fatal(err)
}
err = os.Mkdir(filepath.Join(dir2, "dir2"), 0666)
if err != nil {
t.Fatal(err)
}
if opts == nil {
opts = &Options{}
}
if opts.Logf == nil {
opts.Logf = t.Logf
}
clock := tstest.NewClock(tstest.ClockOpts{Start: time.Now()})
opts.Clock = clock
fs := New(*opts)
fs.AddChild(&Child{Name: "remote4", FS: &closeableFS{webdav.Dir(dir2)}})
fs.SetChildren(&Child{Name: "remote2", FS: webdav.Dir(dir2)},
&Child{Name: "remote3", FS: &closeableFS{webdav.Dir(dir2)}},
)
fs.AddChild(&Child{Name: "remote1", FS: webdav.Dir(dir1)})
fs.RemoveChild("remote3")
child, ok := fs.GetChild("remote1")
if !ok || child == nil {
t.Fatal("unable to GetChild(remote1)")
}
child, ok = fs.GetChild("remote2")
if !ok || child == nil {
t.Fatal("unable to GetChild(remote2)")
}
child, ok = fs.GetChild("remote3")
if ok || child != nil {
t.Fatal("should have been able to GetChild(remote3)")
}
child, ok = fs.GetChild("remote4")
if ok || child != nil {
t.Fatal("should have been able to GetChild(remote4)")
}
return fs, dir1, dir2, clock, func() {
defer l1.Close()
defer os.RemoveAll(dir1)
defer l2.Close()
defer os.RemoveAll(dir2)
}
}
func stat(t *testing.T, path string) fs.FileInfo {
fi, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
return fi
}
func startRemote(t *testing.T) (net.Listener, string) {
dir := t.TempDir()
l, err := net.Listen("tcp", "127.0.0.1:")
if err != nil {
t.Fatal(err)
}
h := &webdav.Handler{
FileSystem: webdav.Dir(dir),
LockSystem: webdav.NewMemLS(),
}
s := &http.Server{Handler: h}
go s.Serve(l)
return l, dir
}
func infosEqual(t *testing.T, expected, actual fs.FileInfo) {
t.Helper()
if expected.Name() != actual.Name() {
t.Errorf("expected name: %v got: %v", expected.Name(), actual.Name())
}
if expected.Size() != actual.Size() {
t.Errorf("expected Size: %v got: %v", expected.Size(), actual.Size())
}
if !expected.ModTime().Truncate(time.Second).UTC().Equal(actual.ModTime().Truncate(time.Second).UTC()) {
t.Errorf("expected ModTime: %v got: %v", expected.ModTime(), actual.ModTime())
}
if expected.IsDir() != actual.IsDir() {
t.Errorf("expected IsDir: %v got: %v", expected.IsDir(), actual.IsDir())
}
}
// closeableFS is a webdav.FileSystem that implements io.Closer()
type closeableFS struct {
webdav.FileSystem
}
func (cfs *closeableFS) Close() error {
return nil
}

View File

@@ -0,0 +1,39 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package compositefs
import (
"context"
"os"
"tailscale.com/tailfs/tailfsimpl/shared"
)
// Mkdir implements webdav.Filesystem. The root of this file system is
// read-only, so any attempts to make directories within the root will fail
// with os.ErrPermission. Attempts to make directories within one of the child
// filesystems will be handled by the respective child.
func (cfs *CompositeFileSystem) Mkdir(ctx context.Context, name string, perm os.FileMode) error {
if shared.IsRoot(name) {
// root directory already exists, consider this okay
return nil
}
pathInfo, err := cfs.pathInfoFor(name)
if pathInfo.refersToChild {
// children can't be made
if pathInfo.child != nil {
// since child already exists, consider this okay
return nil
}
// since child doesn't exist, return permission error
return os.ErrPermission
}
if err != nil {
return err
}
return pathInfo.child.FS.Mkdir(ctx, pathInfo.pathOnChild, perm)
}

View File

@@ -0,0 +1,65 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package compositefs
import (
"context"
"io/fs"
"os"
"github.com/tailscale/xnet/webdav"
"tailscale.com/tailfs/tailfsimpl/shared"
)
// OpenFile implements interface webdav.Filesystem.
func (cfs *CompositeFileSystem) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (webdav.File, error) {
if !shared.IsRoot(name) {
pathInfo, err := cfs.pathInfoFor(name)
if err != nil {
return nil, err
}
if pathInfo.refersToChild {
// this is the child itself, ask it to open its root
return pathInfo.child.FS.OpenFile(ctx, "/", flag, perm)
}
return pathInfo.child.FS.OpenFile(ctx, pathInfo.pathOnChild, flag, perm)
}
// the root directory contains one directory for each child
di, err := cfs.Stat(ctx, name)
if err != nil {
return nil, err
}
return &shared.DirFile{
Info: di,
LoadChildren: func() ([]fs.FileInfo, error) {
cfs.childrenMu.Lock()
children := cfs.children
cfs.childrenMu.Unlock()
childInfos := make([]fs.FileInfo, 0, len(cfs.children))
for _, c := range children {
if c.isAvailable() {
var childInfo fs.FileInfo
if cfs.statChildren {
fi, err := c.FS.Stat(ctx, "/")
if err != nil {
return nil, err
}
// we use the full name
childInfo = shared.RenamedFileInfo(ctx, c.Name, fi)
} else {
// always use now() as the modified time to bust caches
childInfo = shared.ReadOnlyDirInfo(c.Name, cfs.now())
}
childInfos = append(childInfos, childInfo)
}
}
return childInfos, nil
},
}, nil
}

View File

@@ -0,0 +1,33 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package compositefs
import (
"context"
"os"
"tailscale.com/tailfs/tailfsimpl/shared"
)
// RemoveAll implements webdav.File. The root of this file system is read-only,
// so attempting to call RemoveAll on the root will fail with os.ErrPermission.
// RemoveAll within a child will be handled by the respective child.
func (cfs *CompositeFileSystem) RemoveAll(ctx context.Context, name string) error {
if shared.IsRoot(name) {
// root directory is read-only
return os.ErrPermission
}
pathInfo, err := cfs.pathInfoFor(name)
if pathInfo.refersToChild {
// children can't be removed
return os.ErrPermission
}
if err != nil {
return err
}
return pathInfo.child.FS.RemoveAll(ctx, pathInfo.pathOnChild)
}

View File

@@ -0,0 +1,49 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package compositefs
import (
"context"
"os"
"tailscale.com/tailfs/tailfsimpl/shared"
)
// Rename implements interface webdav.FileSystem. The root of this file system
// is read-only, so any attempt to rename a child within the root of this
// filesystem will fail with os.ErrPermission. Renaming across children is not
// supported and will fail with os.ErrPermission. Renaming within a child will
// be handled by the respective child.
func (cfs *CompositeFileSystem) Rename(ctx context.Context, oldName, newName string) error {
if shared.IsRoot(oldName) || shared.IsRoot(newName) {
// root directory is read-only
return os.ErrPermission
}
oldPathInfo, err := cfs.pathInfoFor(oldName)
if oldPathInfo.refersToChild {
// children themselves are read-only
return os.ErrPermission
}
if err != nil {
return err
}
newPathInfo, err := cfs.pathInfoFor(newName)
if newPathInfo.refersToChild {
// children themselves are read-only
return os.ErrPermission
}
if err != nil {
return err
}
if oldPathInfo.child != newPathInfo.child {
// moving a file across children is not permitted
return os.ErrPermission
}
// file is moving within the same child, let the child handle it
return oldPathInfo.child.FS.Rename(ctx, oldPathInfo.pathOnChild, newPathInfo.pathOnChild)
}

View File

@@ -0,0 +1,55 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package compositefs
import (
"context"
"io/fs"
"tailscale.com/tailfs/tailfsimpl/shared"
)
// Stat implements webdav.FileSystem.
func (cfs *CompositeFileSystem) Stat(ctx context.Context, name string) (fs.FileInfo, error) {
if shared.IsRoot(name) {
// Root is a directory
// always use now() as the modified time to bust caches
fi := shared.ReadOnlyDirInfo(name, cfs.now())
if cfs.statChildren {
// update last modified time based on children
cfs.childrenMu.Lock()
children := cfs.children
cfs.childrenMu.Unlock()
for i, child := range children {
childInfo, err := child.FS.Stat(ctx, "/")
if err != nil {
return nil, err
}
if i == 0 || childInfo.ModTime().After(fi.ModTime()) {
fi.ModdedTime = childInfo.ModTime()
}
}
}
return fi, nil
}
pathInfo, err := cfs.pathInfoFor(name)
if err != nil {
return nil, err
}
if pathInfo.refersToChild && !cfs.statChildren {
// Return a read-only FileInfo for this child.
// Always use now() as the modified time to bust caches.
return shared.ReadOnlyDirInfo(name, cfs.now()), nil
}
fi, err := pathInfo.child.FS.Stat(ctx, pathInfo.pathOnChild)
if err != nil {
return nil, err
}
// we use the full name, which is different than what the child sees
return shared.RenamedFileInfo(ctx, name, fi), nil
}

View File

@@ -1,101 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package dirfs provides a webdav.FileSystem that looks like a read-only
// directory containing only subdirectories.
package dirfs
import (
"slices"
"strings"
"time"
"tailscale.com/tailfs/tailfsimpl/shared"
"tailscale.com/tstime"
)
// Child is subdirectory of an FS.
type Child struct {
// Name is the name of the child
Name string
// Available is a function indicating whether or not the child is currently
// available. Unavailable children are excluded from the FS's directory
// listing. Available must be safe for concurrent use.
Available func() bool
}
func (c *Child) isAvailable() bool {
if c.Available == nil {
return true
}
return c.Available()
}
// FS is a read-only webdav.FileSystem that is composed of multiple child
// folders.
//
// When listing the contents of this FileSystem's root directory, children will
// be ordered in the order they're given to the FS.
//
// Children in an FS cannot be added, removed or renamed via operations on the
// webdav.FileSystem interface like filesystem.Mkdir or filesystem.OpenFile.
//
// Any attempts to perform operations on paths inside of children will result
// in a panic, as these are not expected to be performed on this FS.
//
// An FS an optionally have a StaticRoot, which will insert a folder with that
// StaticRoot into the tree, like this:
//
// -- <StaticRoot>
// ----- <Child>
// ----- <Child>
type FS struct {
// Children configures the full set of children of this FS.
Children []*Child
// Clock, if given, will cause this FS to use Clock.now() as the current
// time.
Clock tstime.Clock
// StaticRoot, if given, will insert the given name as a static root into
// every path.
StaticRoot string
}
func (dfs *FS) findChild(name string) (int, *Child) {
var child *Child
i, found := slices.BinarySearchFunc(dfs.Children, name, func(child *Child, name string) int {
return strings.Compare(child.Name, name)
})
if found {
child = dfs.Children[i]
}
return i, child
}
// childFor returns the child for the given filename. If the filename refers to
// a path inside of a child, this will panic.
func (dfs *FS) childFor(name string) *Child {
pathComponents := shared.CleanAndSplit(name)
if len(pathComponents) != 1 {
panic("dirfs does not permit reaching into child directories")
}
_, child := dfs.findChild(pathComponents[0])
return child
}
func (dfs *FS) now() time.Time {
if dfs.Clock != nil {
return dfs.Clock.Now()
}
return time.Now()
}
func (dfs *FS) trimStaticRoot(name string) (string, bool) {
before, after, found := strings.Cut(name, "/"+dfs.StaticRoot)
if !found {
return before, false
}
return after, shared.IsRoot(after)
}

View File

@@ -1,348 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package dirfs
import (
"context"
"errors"
"io/fs"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/tailscale/xnet/webdav"
"tailscale.com/tailfs/tailfsimpl/shared"
"tailscale.com/tstest"
)
func TestStat(t *testing.T) {
cfs, _, _, clock := createFileSystem(t)
tests := []struct {
label string
name string
expected fs.FileInfo
err error
}{
{
label: "root folder",
name: "",
expected: &shared.StaticFileInfo{
Named: "",
Sized: 0,
Moded: 0555,
ModdedTime: clock.Now(),
Dir: true,
},
},
{
label: "static root folder",
name: "/domain",
expected: &shared.StaticFileInfo{
Named: "domain",
Sized: 0,
Moded: 0555,
ModdedTime: clock.Now(),
Dir: true,
},
},
{
label: "remote1",
name: "/domain/remote1",
expected: &shared.StaticFileInfo{
Named: "remote1",
Sized: 0,
Moded: 0555,
ModdedTime: clock.Now(),
Dir: true,
},
},
{
label: "remote2",
name: "/domain/remote2",
expected: &shared.StaticFileInfo{
Named: "remote2",
Sized: 0,
Moded: 0555,
ModdedTime: clock.Now(),
Dir: true,
},
},
{
label: "non-existent remote",
name: "remote3",
err: os.ErrNotExist,
},
}
ctx := context.Background()
for _, test := range tests {
t.Run(test.label, func(t *testing.T) {
fi, err := cfs.Stat(ctx, test.name)
if test.err != nil {
if !errors.Is(err, test.err) {
t.Errorf("got %v, want %v", err, test.err)
}
} else {
if err != nil {
t.Errorf("unable to stat file: %v", err)
} else {
infosEqual(t, test.expected, fi)
}
}
})
}
}
func TestListDir(t *testing.T) {
cfs, _, _, clock := createFileSystem(t)
tests := []struct {
label string
name string
expected []fs.FileInfo
err error
}{
{
label: "root folder",
name: "",
expected: []fs.FileInfo{
&shared.StaticFileInfo{
Named: "domain",
Sized: 0,
Moded: 0555,
ModdedTime: clock.Now(),
Dir: true,
},
},
},
{
label: "static root folder",
name: "/domain",
expected: []fs.FileInfo{
&shared.StaticFileInfo{
Named: "remote1",
Sized: 0,
Moded: 0555,
ModdedTime: clock.Now(),
Dir: true,
},
&shared.StaticFileInfo{
Named: "remote2",
Sized: 0,
Moded: 0555,
ModdedTime: clock.Now(),
Dir: true,
},
&shared.StaticFileInfo{
Named: "remote4",
Sized: 0,
Moded: 0555,
ModdedTime: clock.Now(),
Dir: true,
},
},
},
}
ctx := context.Background()
for _, test := range tests {
t.Run(test.label, func(t *testing.T) {
var infos []fs.FileInfo
file, err := cfs.OpenFile(ctx, test.name, os.O_RDONLY, 0)
if err == nil {
defer file.Close()
infos, err = file.Readdir(0)
}
if test.err != nil {
if !errors.Is(err, test.err) {
t.Errorf("got %v, want %v", err, test.err)
}
} else {
if err != nil {
t.Errorf("unable to stat file: %v", err)
} else {
if len(infos) != len(test.expected) {
t.Errorf("wrong number of file infos, want %d, got %d", len(test.expected), len(infos))
} else {
for i, expected := range test.expected {
infosEqual(t, expected, infos[i])
}
}
}
}
})
}
}
func TestMkdir(t *testing.T) {
fs, _, _, _ := createFileSystem(t)
tests := []struct {
label string
name string
perm os.FileMode
err error
}{
{
label: "attempt to create root folder",
name: "/",
},
{
label: "attempt to create static root folder",
name: "/domain",
},
{
label: "attempt to create remote",
name: "/domain/remote1",
},
{
label: "attempt to create non-existent remote",
name: "/domain/remote3",
err: os.ErrPermission,
},
}
ctx := context.Background()
for _, test := range tests {
t.Run(test.label, func(t *testing.T) {
err := fs.Mkdir(ctx, test.name, test.perm)
if test.err != nil {
if !errors.Is(err, test.err) {
t.Errorf("got %v, want %v", err, test.err)
}
} else if err != nil {
t.Errorf("unexpected error: %v", err)
}
})
}
}
func TestRemoveAll(t *testing.T) {
fs, _, _, _ := createFileSystem(t)
tests := []struct {
label string
name string
err error
}{
{
label: "attempt to remove root folder",
name: "/",
err: os.ErrPermission,
},
}
ctx := context.Background()
for _, test := range tests {
t.Run(test.label, func(t *testing.T) {
err := fs.RemoveAll(ctx, test.name)
if !errors.Is(err, test.err) {
t.Errorf("got %v, want %v", err, test.err)
}
})
}
}
func TestRename(t *testing.T) {
fs, _, _, _ := createFileSystem(t)
tests := []struct {
label string
oldName string
newName string
err error
}{
{
label: "attempt to move root folder",
oldName: "/",
newName: "/domain/remote2/copy.txt",
err: os.ErrPermission,
},
}
ctx := context.Background()
for _, test := range tests {
t.Run(test.label, func(t *testing.T) {
err := fs.Rename(ctx, test.oldName, test.newName)
if !errors.Is(err, test.err) {
t.Errorf("got %v, want: %v", err, test.err)
}
})
}
}
func createFileSystem(t *testing.T) (webdav.FileSystem, string, string, *tstest.Clock) {
s1, dir1 := startRemote(t)
s2, dir2 := startRemote(t)
// Make some files, use perms 0666 as lowest common denominator that works
// on both UNIX and Windows.
err := os.WriteFile(filepath.Join(dir1, "file1.txt"), []byte("12345"), 0666)
if err != nil {
t.Fatal(err)
}
err = os.WriteFile(filepath.Join(dir2, "file2.txt"), []byte("54321"), 0666)
if err != nil {
t.Fatal(err)
}
// make some directories
err = os.Mkdir(filepath.Join(dir1, "dir1"), 0666)
if err != nil {
t.Fatal(err)
}
err = os.Mkdir(filepath.Join(dir2, "dir2"), 0666)
if err != nil {
t.Fatal(err)
}
clock := tstest.NewClock(tstest.ClockOpts{Start: time.Now()})
fs := &FS{
Clock: clock,
StaticRoot: "domain",
Children: []*Child{
{Name: "remote1"},
{Name: "remote2"},
{Name: "remote4"},
},
}
t.Cleanup(func() {
defer s1.Close()
defer os.RemoveAll(dir1)
defer s2.Close()
defer os.RemoveAll(dir2)
})
return fs, dir1, dir2, clock
}
func startRemote(t *testing.T) (*httptest.Server, string) {
dir := t.TempDir()
h := &webdav.Handler{
FileSystem: webdav.Dir(dir),
LockSystem: webdav.NewMemLS(),
}
s := httptest.NewServer(h)
t.Cleanup(s.Close)
return s, dir
}
func infosEqual(t *testing.T, expected, actual fs.FileInfo) {
t.Helper()
sfi, ok := actual.(*shared.StaticFileInfo)
if ok {
// zero out BirthedTime because we don't want to compare that
sfi.BirthedTime = time.Time{}
}
if diff := cmp.Diff(actual, expected); diff != "" {
t.Errorf("Wrong file info (-got, +want):\n%s", diff)
}
}

View File

@@ -1,30 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package dirfs
import (
"context"
"os"
"tailscale.com/tailfs/tailfsimpl/shared"
)
// Mkdir implements webdav.FileSystem. All attempts to Mkdir a directory that
// already exists will succeed. All other attempts will fail with
// os.ErrPermission.
func (dfs *FS) Mkdir(ctx context.Context, name string, perm os.FileMode) error {
nameWithoutStaticRoot, isStaticRoot := dfs.trimStaticRoot(name)
if isStaticRoot || shared.IsRoot(name) {
// root directory already exists, consider this okay
return nil
}
child := dfs.childFor(nameWithoutStaticRoot)
if child != nil {
// child already exists, consider this okay
return nil
}
return &os.PathError{Op: "mkdir", Path: name, Err: os.ErrPermission}
}

View File

@@ -1,63 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package dirfs
import (
"context"
"io/fs"
"os"
"github.com/tailscale/xnet/webdav"
"tailscale.com/tailfs/tailfsimpl/shared"
)
// OpenFile implements interface webdav.Filesystem.
func (dfs *FS) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (webdav.File, error) {
_, isStaticRoot := dfs.trimStaticRoot(name)
if !isStaticRoot && !shared.IsRoot(name) {
// Show a folder with no children to represent the requested child. In
// practice, the children of this folder are never read, we just need
// to give webdav a file here which it uses to call file.Stat(). So,
// even though the Child may in fact have its own children, it doesn't
// matter here.
return &shared.DirFile{
Info: shared.ReadOnlyDirInfo(name, dfs.now()),
LoadChildren: func() ([]fs.FileInfo, error) {
return nil, nil
},
}, nil
}
di, err := dfs.Stat(ctx, name)
if err != nil {
return nil, err
}
if dfs.StaticRoot != "" && !isStaticRoot {
// Show a folder with a single subfolder that is the static root.
return &shared.DirFile{
Info: di,
LoadChildren: func() ([]fs.FileInfo, error) {
return []fs.FileInfo{
shared.ReadOnlyDirInfo(dfs.StaticRoot, dfs.now()),
}, nil
},
}, nil
}
// Show a folder with one subfolder for each Child of this FS.
return &shared.DirFile{
Info: di,
LoadChildren: func() ([]fs.FileInfo, error) {
childInfos := make([]fs.FileInfo, 0, len(dfs.Children))
for _, c := range dfs.Children {
if c.isAvailable() {
childInfo := shared.ReadOnlyDirInfo(c.Name, dfs.now())
childInfos = append(childInfos, childInfo)
}
}
return childInfos, nil
},
}, nil
}

View File

@@ -1,15 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package dirfs
import (
"context"
"os"
)
// RemoveAll implements webdav.File. No removal is supported and this always
// returns os.ErrPermission.
func (dfs *FS) RemoveAll(ctx context.Context, name string) error {
return &os.PathError{Op: "rm", Path: name, Err: os.ErrPermission}
}

View File

@@ -1,15 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package dirfs
import (
"context"
"os"
)
// Rename implements interface webdav.FileSystem. No renaming is supported and
// this always returns os.ErrPermission.
func (dfs *FS) Rename(ctx context.Context, oldName, newName string) error {
return &os.PathError{Op: "mv", Path: oldName, Err: os.ErrPermission}
}

View File

@@ -1,30 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package dirfs
import (
"context"
"io/fs"
"os"
"tailscale.com/tailfs/tailfsimpl/shared"
)
// Stat implements webdav.FileSystem.
func (dfs *FS) Stat(ctx context.Context, name string) (fs.FileInfo, error) {
nameWithoutStaticRoot, isStaticRoot := dfs.trimStaticRoot(name)
if isStaticRoot || shared.IsRoot(name) {
// Static root is a directory, always use now() as the modified time to
// bust caches.
fi := shared.ReadOnlyDirInfo(name, dfs.now())
return fi, nil
}
child := dfs.childFor(nameWithoutStaticRoot)
if child == nil {
return nil, &os.PathError{Op: "stat", Path: name, Err: os.ErrNotExist}
}
return shared.ReadOnlyDirInfo(name, dfs.now()), nil
}

View File

@@ -10,9 +10,10 @@ import (
"net/http"
"time"
"github.com/tailscale/xnet/webdav"
"tailscale.com/tailfs"
"tailscale.com/tailfs/tailfsimpl/compositedav"
"tailscale.com/tailfs/tailfsimpl/dirfs"
"tailscale.com/tailfs/tailfsimpl/compositefs"
"tailscale.com/tailfs/tailfsimpl/webdavfs"
"tailscale.com/types/logger"
)
@@ -31,11 +32,8 @@ func NewFileSystemForLocal(logf logger.Logf) *FileSystemForLocal {
logf = log.Printf
}
fs := &FileSystemForLocal{
logf: logf,
h: &compositedav.Handler{
Logf: logf,
StatCache: &compositedav.StatCache{TTL: statCacheTTL},
},
logf: logf,
cfs: compositefs.New(compositefs.Options{Logf: logf}),
listener: newConnListener(),
}
fs.startServing()
@@ -46,12 +44,17 @@ func NewFileSystemForLocal(logf logger.Logf) *FileSystemForLocal {
// provides a unified WebDAV interface to remote TailFS shares on other nodes.
type FileSystemForLocal struct {
logf logger.Logf
h *compositedav.Handler
cfs *compositefs.CompositeFileSystem
listener *connListener
}
func (s *FileSystemForLocal) startServing() {
hs := &http.Server{Handler: s.h}
hs := &http.Server{
Handler: &webdav.Handler{
FileSystem: s.cfs,
LockSystem: webdav.NewMemLS(),
},
}
go func() {
err := hs.Serve(s.listener)
if err != nil {
@@ -70,24 +73,31 @@ func (s *FileSystemForLocal) HandleConn(conn net.Conn, remoteAddr net.Addr) erro
// using a map of name -> url. If transport is specified, that transport
// will be used to connect to these remotes.
func (s *FileSystemForLocal) SetRemotes(domain string, remotes []*tailfs.Remote, transport http.RoundTripper) {
children := make([]*compositedav.Child, 0, len(remotes))
children := make([]*compositefs.Child, 0, len(remotes))
for _, remote := range remotes {
children = append(children, &compositedav.Child{
Child: &dirfs.Child{
Name: remote.Name,
Available: remote.Available,
},
BaseURL: remote.URL,
Transport: transport,
opts := webdavfs.Options{
URL: remote.URL,
Transport: transport,
StatCacheTTL: statCacheTTL,
Logf: s.logf,
}
children = append(children, &compositefs.Child{
Name: remote.Name,
FS: webdavfs.New(opts),
Available: remote.Available,
})
}
s.h.SetChildren(domain, children...)
domainChild, found := s.cfs.GetChild(domain)
if !found {
domainChild = compositefs.New(compositefs.Options{Logf: s.logf})
s.cfs.SetChildren(&compositefs.Child{Name: domain, FS: domainChild})
}
domainChild.(*compositefs.CompositeFileSystem).SetChildren(children...)
}
// Close() stops serving the WebDAV content
func (s *FileSystemForLocal) Close() error {
err := s.listener.Close()
s.h.Close()
return err
s.cfs.Close()
return s.listener.Close()
}

View File

@@ -12,7 +12,6 @@ import (
"net"
"net/http"
"net/netip"
"net/url"
"os"
"os/exec"
"strings"
@@ -22,9 +21,9 @@ import (
"github.com/tailscale/xnet/webdav"
"tailscale.com/safesocket"
"tailscale.com/tailfs"
"tailscale.com/tailfs/tailfsimpl/compositedav"
"tailscale.com/tailfs/tailfsimpl/dirfs"
"tailscale.com/tailfs/tailfsimpl/compositefs"
"tailscale.com/tailfs/tailfsimpl/shared"
"tailscale.com/tailfs/tailfsimpl/webdavfs"
"tailscale.com/types/logger"
)
@@ -35,7 +34,7 @@ func NewFileSystemForRemote(logf logger.Logf) *FileSystemForRemote {
fs := &FileSystemForRemote{
logf: logf,
lockSystem: webdav.NewMemLS(),
children: make(map[string]*compositedav.Child),
fileSystems: make(map[string]webdav.FileSystem),
userServers: make(map[string]*userServer),
}
return fs
@@ -51,7 +50,7 @@ type FileSystemForRemote struct {
mu sync.RWMutex
fileServerAddr string
shares map[string]*tailfs.Share
children map[string]*compositedav.Child
fileSystems map[string]webdav.FileSystem
userServers map[string]*userServer
}
@@ -82,29 +81,27 @@ func (s *FileSystemForRemote) SetShares(shares map[string]*tailfs.Share) {
}
}
children := make(map[string]*compositedav.Child, len(shares))
fileSystems := make(map[string]webdav.FileSystem, len(shares))
for _, share := range shares {
children[share.Name] = s.buildChild(share)
fileSystems[share.Name] = s.buildWebDAVFS(share)
}
s.mu.Lock()
s.shares = shares
oldFileSystems := s.fileSystems
oldUserServers := s.userServers
oldChildren := s.children
s.children = children
s.fileSystems = fileSystems
s.userServers = userServers
s.mu.Unlock()
s.stopUserServers(oldUserServers)
s.closeChildren(oldChildren)
s.closeFileSystems(oldFileSystems)
}
func (s *FileSystemForRemote) buildChild(share *tailfs.Share) *compositedav.Child {
return &compositedav.Child{
Child: &dirfs.Child{
Name: share.Name,
},
BaseURL: fmt.Sprintf("http://%v/%v", hex.EncodeToString([]byte(share.Name)), url.PathEscape(share.Name)),
func (s *FileSystemForRemote) buildWebDAVFS(share *tailfs.Share) webdav.FileSystem {
return webdavfs.New(webdavfs.Options{
Logf: s.logf,
URL: fmt.Sprintf("http://%v/%v", hex.EncodeToString([]byte(share.Name)), share.Name),
Transport: &http.Transport{
Dial: func(_, shareAddr string) (net.Conn, error) {
shareNameHex, _, err := net.SplitHostPort(shareAddr)
@@ -154,7 +151,8 @@ func (s *FileSystemForRemote) buildChild(share *tailfs.Share) *compositedav.Chil
return safesocket.Connect(addr)
},
},
}
StatRoot: true,
})
}
// ServeHTTPWithPerms implements tailfs.FileSystemForRemote.
@@ -175,23 +173,29 @@ func (s *FileSystemForRemote) ServeHTTPWithPerms(permissions tailfs.Permissions,
}
s.mu.RLock()
childrenMap := s.children
fileSystems := s.fileSystems
s.mu.RUnlock()
children := make([]*compositedav.Child, 0, len(childrenMap))
children := make([]*compositefs.Child, 0, len(fileSystems))
// filter out shares to which the connecting principal has no access
for name, child := range childrenMap {
for name, fs := range fileSystems {
if permissions.For(name) == tailfs.PermissionNone {
continue
}
children = append(children, child)
children = append(children, &compositefs.Child{Name: name, FS: fs})
}
h := compositedav.Handler{
Logf: s.logf,
cfs := compositefs.New(
compositefs.Options{
Logf: s.logf,
StatChildren: true,
})
cfs.SetChildren(children...)
h := webdav.Handler{
FileSystem: cfs,
LockSystem: s.lockSystem,
}
h.SetChildren("", children...)
h.ServeHTTP(w, r)
}
@@ -203,9 +207,14 @@ func (s *FileSystemForRemote) stopUserServers(userServers map[string]*userServer
}
}
func (s *FileSystemForRemote) closeChildren(children map[string]*compositedav.Child) {
for _, child := range children {
child.CloseIdleConnections()
func (s *FileSystemForRemote) closeFileSystems(fileSystems map[string]webdav.FileSystem) {
for _, fs := range fileSystems {
closer, ok := fs.(interface{ Close() error })
if ok {
if err := closer.Close(); err != nil {
s.logf("error closing tailfs filesystem: %v", err)
}
}
}
}
@@ -213,13 +222,11 @@ func (s *FileSystemForRemote) closeChildren(children map[string]*compositedav.Ch
func (s *FileSystemForRemote) Close() error {
s.mu.Lock()
userServers := s.userServers
children := s.children
s.userServers = make(map[string]*userServer)
s.children = make(map[string]*compositedav.Child)
fileSystems := s.fileSystems
s.mu.Unlock()
s.stopUserServers(userServers)
s.closeChildren(children)
s.closeFileSystems(fileSystems)
return nil
}

View File

@@ -40,11 +40,3 @@ func Join(parts ...string) string {
func IsRoot(p string) bool {
return p == "" || p == sepString
}
// Base is like path.Base except that it returns "" for the root folder
func Base(p string) string {
if IsRoot(p) {
return ""
}
return path.Base(p)
}

View File

@@ -50,7 +50,7 @@ func RenamedFileInfo(ctx context.Context, name string, fi fs.FileInfo) *StaticFi
}
return &StaticFileInfo{
Named: Base(name),
Named: name,
Sized: fi.Size(),
Moded: fi.Mode(),
BirthedTime: birthTime,
@@ -63,7 +63,7 @@ func RenamedFileInfo(ctx context.Context, name string, fi fs.FileInfo) *StaticFi
// ReadOnlyDirInfo returns a static fs.FileInfo for a read-only directory
func ReadOnlyDirInfo(name string, ts time.Time) *StaticFileInfo {
return &StaticFileInfo{
Named: Base(name),
Named: name,
Sized: 0,
Moded: 0555,
BirthedTime: ts,

View File

@@ -4,7 +4,9 @@
package tailfsimpl
import (
"context"
"fmt"
"io"
"io/fs"
"log"
"net"
@@ -17,9 +19,10 @@ import (
"time"
"github.com/google/go-cmp/cmp"
"github.com/studio-b12/gowebdav"
"github.com/tailscale/xnet/webdav"
"tailscale.com/tailfs"
"tailscale.com/tailfs/tailfsimpl/shared"
"tailscale.com/tailfs/tailfsimpl/webdavfs"
"tailscale.com/tstest"
)
@@ -43,28 +46,24 @@ func init() {
// going over the Tailscale network stack.
func TestDirectoryListing(t *testing.T) {
s := newSystem(t)
defer s.stop()
s.addRemote(remote1)
s.checkDirList("root directory should contain the one and only domain once a remote has been set", "/", domain)
s.checkDirList("domain should contain its only remote", shared.Join(domain), remote1)
s.checkDirList("remote with no shares should be empty", shared.Join(domain, remote1))
s.addShare(remote1, share11, tailfs.PermissionReadWrite)
s.checkDirList("remote with one share should contain that share", shared.Join(domain, remote1), share11)
s.addShare(remote1, share12, tailfs.PermissionReadOnly)
s.checkDirList("remote with two shares should contain both in lexicographical order", shared.Join(domain, remote1), share12, share11)
s.writeFile("writing file to read/write remote should succeed", remote1, share11, file111, "hello world", true)
s.checkDirList("remote share should contain file", shared.Join(domain, remote1, share11), file111)
s.checkDirListIncremental("remote with two shares should contain both in lexicographical order even when reading directory incrementally", shared.Join(domain, remote1), share12, share11)
s.addRemote(remote2)
s.checkDirList("domain with two remotes should contain both in lexicographical order", shared.Join(domain), remote2, remote1)
s.freezeRemote(remote1)
s.checkDirList("domain with two remotes should contain both in lexicographical order even if one is unreachable", shared.Join(domain), remote2, remote1)
_, err := s.client.ReadDir(shared.Join(domain, remote1))
if err == nil {
t.Error("directory listing for offline remote should fail")
}
s.checkDirList("directory listing for offline remote should return empty list", shared.Join(domain, remote1))
s.unfreezeRemote(remote1)
s.checkDirList("attempt at lateral traversal should simply list shares", shared.Join(domain, remote1, share11, ".."), share12, share11)
@@ -72,6 +71,7 @@ func TestDirectoryListing(t *testing.T) {
func TestFileManipulation(t *testing.T) {
s := newSystem(t)
defer s.stop()
s.addRemote(remote1)
s.addShare(remote1, share11, tailfs.PermissionReadWrite)
@@ -86,6 +86,178 @@ func TestFileManipulation(t *testing.T) {
s.writeFile("writing file to non-existent share should fail", remote1, "non-existent", file111, "hello world", false)
}
func TestFileOps(t *testing.T) {
ctx := context.Background()
s := newSystem(t)
defer s.stop()
s.addRemote(remote1)
s.addShare(remote1, share11, tailfs.PermissionReadWrite)
s.writeFile("writing file to read/write remote should succeed", remote1, share11, file111, "hello world", true)
fi, err := s.fs.Stat(context.Background(), pathTo(remote1, share11, file111))
if err != nil {
t.Fatalf("failed to Stat: %s", err)
}
bt, ok := fi.(webdav.BirthTimer)
if !ok {
t.Fatal("FileInfo should be a BirthTimer")
}
birthTime, err := bt.BirthTime(ctx)
if err != nil {
t.Fatalf("failed to BirthTime: %s", err)
}
if birthTime.IsZero() {
t.Fatal("BirthTime() should return a non-zero time")
}
_, err = s.fs.OpenFile(ctx, pathTo(remote1, share11, "nonexistent.txt"), os.O_RDONLY, 0)
if err == nil {
t.Fatal("opening non-existent file for read should fail")
}
dir, err := s.fs.OpenFile(ctx, shared.Join(domain, remote1), os.O_RDONLY, 0)
if err != nil {
t.Fatalf("failed to open directory for read: %s", err)
}
defer dir.Close()
_, err = dir.Seek(0, io.SeekStart)
if err == nil {
t.Fatal("seeking in directory should fail")
}
_, err = dir.Read(make([]byte, 8))
if err == nil {
t.Fatal("reading bytes from directory should fail")
}
_, err = dir.Write(make([]byte, 8))
if err == nil {
t.Fatal("writing bytes to directory should fail")
}
readOnlyFile, err := s.fs.OpenFile(ctx, pathTo(remote1, share11, file111), os.O_RDONLY, 0)
if err != nil {
t.Fatalf("failed to open file for read: %s", err)
}
defer readOnlyFile.Close()
n, err := readOnlyFile.Seek(0, io.SeekStart)
if err != nil {
t.Fatalf("failed to seek 0 from start of read-only file: %s", err)
}
if n != 0 {
t.Fatal("seeking 0 from start of read-only file should return 0")
}
n, err = readOnlyFile.Seek(1, io.SeekStart)
if err != nil {
t.Fatalf("failed to seek 1 from start of read-only file: %s", err)
}
if n != 1 {
t.Fatal("seeking 1 from start of read-only file should return 1")
}
n, err = readOnlyFile.Seek(0, io.SeekEnd)
if err != nil {
t.Fatalf("failed to seek 0 from end of read-only file: %s", err)
}
if n != fi.Size() {
t.Fatal("seeking 0 from end of read-only file should return file size")
}
_, err = readOnlyFile.Seek(1, io.SeekEnd)
if err == nil {
t.Fatal("seeking 1 from end of read-only file should fail")
}
_, err = readOnlyFile.Seek(0, io.SeekCurrent)
if err == nil {
t.Fatal("seeking from current of read-only file should fail")
}
_, err = readOnlyFile.Write(make([]byte, 8))
if err == nil {
t.Fatal("writing bytes to read-only file should fail")
}
writeOnlyFile, err := s.fs.OpenFile(ctx, pathTo(remote1, share11, file111), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0)
if err != nil {
t.Fatalf("failed to OpenFile for write: %s", err)
}
defer writeOnlyFile.Close()
_, err = writeOnlyFile.Seek(0, io.SeekStart)
if err == nil {
t.Fatal("seeking in write only file should fail")
}
_, err = writeOnlyFile.Read(make([]byte, 8))
if err == nil {
t.Fatal("reading bytes from a write only file should fail")
}
}
func TestFileRewind(t *testing.T) {
ctx := context.Background()
s := newSystem(t)
defer s.stop()
s.addRemote(remote1)
s.addShare(remote1, share11, tailfs.PermissionReadWrite)
// Create a file slightly longer than our max rewind buffer of 512
fileLength := webdavfs.MaxRewindBuffer + 1
data := make([]byte, fileLength)
for i := 0; i < fileLength; i++ {
data[i] = byte(i % 256)
}
s.writeFile("writing file to read/write remote should succeed", remote1, share11, file111, string(data), true)
// Try reading and rewinding in every size up to the maximum buffer length
for i := 0; i < webdavfs.MaxRewindBuffer; i++ {
t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
f, err := s.fs.OpenFile(ctx, pathTo(remote1, share11, file111), os.O_RDONLY, 0)
if err != nil {
t.Fatalf("failed top OpenFile for read: %s", err)
}
defer f.Close()
b := make([]byte, fileLength)
n, err := io.ReadFull(f, b[:i])
if err != nil {
t.Fatalf("failed to read first %d bytes from file: %s", i, err)
}
if n != i {
log.Fatalf("Reading first %d bytes should report correct count, but reported %d", i, n)
}
_, err = f.Seek(0, io.SeekStart)
if err != nil {
t.Fatalf("failed to seek back %d bytes: %s", i, err)
}
n, err = io.ReadFull(f, b)
if err != nil {
t.Fatalf("failed to read full file: %s", err)
}
if n != fileLength {
t.Fatalf("reading full file reported incorrect count, got %d, want %d", n, fileLength)
}
if string(b) != string(data) {
t.Fatalf("read wrong data, got %q, want %q", b, data)
}
_, err = f.Seek(0, io.SeekStart)
if err == nil {
t.Fatal("Attempting to seek to beginning of file after having read past rewind buffer should fail")
}
})
}
}
type local struct {
l net.Listener
fs *FileSystemForLocal
@@ -117,7 +289,7 @@ func (r *remote) ServeHTTP(w http.ResponseWriter, req *http.Request) {
type system struct {
t *testing.T
local *local
client *gowebdav.Client
fs webdav.FileSystem
remotes map[string]*remote
}
@@ -142,16 +314,15 @@ func newSystem(t *testing.T) *system {
}
}()
client := gowebdav.NewClient(fmt.Sprintf("http://%s", l.Addr()), "", "")
client.SetTransport(&http.Transport{DisableKeepAlives: true})
s := &system{
t: t,
local: &local{l: l, fs: fs},
client: client,
return &system{
t: t,
local: &local{l: l, fs: fs},
fs: webdavfs.New(webdavfs.Options{
URL: fmt.Sprintf("http://%s", l.Addr()),
Transport: &http.Transport{DisableKeepAlives: true},
}),
remotes: make(map[string]*remote),
}
t.Cleanup(s.stop)
return s
}
func (s *system) addRemote(name string) {
@@ -186,13 +357,7 @@ func (s *system) addRemote(name string) {
URL: fmt.Sprintf("http://%s", r.l.Addr()),
})
}
s.local.fs.SetRemotes(
domain,
remotes,
&http.Transport{
DisableKeepAlives: true,
ResponseHeaderTimeout: 5 * time.Second,
})
s.local.fs.SetRemotes(domain, remotes, &http.Transport{})
}
func (s *system) addShare(remoteName, shareName string, permission tailfs.Permission) {
@@ -234,11 +399,26 @@ func (s *system) unfreezeRemote(remoteName string) {
func (s *system) writeFile(label, remoteName, shareName, name, contents string, expectSuccess bool) {
path := pathTo(remoteName, shareName, name)
err := s.client.Write(path, []byte(contents), 0644)
file, err := s.fs.OpenFile(context.Background(), path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
if expectSuccess && err != nil {
s.t.Fatalf("%v: expected success writing file %q, but got error %v", label, path, err)
} else if !expectSuccess && err == nil {
s.t.Fatalf("%v: expected error writing file %q", label, path)
}
defer func() {
if !expectSuccess && err == nil {
s.t.Fatalf("%v: expected error writing file %q", label, path)
}
}()
defer func() {
err = file.Close()
if expectSuccess && err != nil {
s.t.Fatalf("error closing %v: %v", path, err)
}
}()
_, err = file.Write([]byte(contents))
if expectSuccess && err != nil {
s.t.Fatalf("%v: writing file %q: %v", label, path, err)
}
}
@@ -257,7 +437,12 @@ func (s *system) checkFileContents(remoteName, shareName, name string) {
}
func (s *system) checkDirList(label string, path string, want ...string) {
got, err := s.client.ReadDir(path)
file, err := s.fs.OpenFile(context.Background(), path, os.O_RDONLY, 0)
if err != nil {
s.t.Fatalf("failed to OpenFile: %s", err)
}
got, err := file.Readdir(0)
if err != nil {
s.t.Fatalf("failed to Readdir: %s", err)
}
@@ -275,6 +460,35 @@ func (s *system) checkDirList(label string, path string, want ...string) {
}
}
func (s *system) checkDirListIncremental(label string, path string, want ...string) {
file, err := s.fs.OpenFile(context.Background(), path, os.O_RDONLY, 0)
if err != nil {
s.t.Fatal(err)
}
var gotNames []string
for {
got, err := file.Readdir(1)
for _, fi := range got {
gotNames = append(gotNames, fi.Name())
}
if err == io.EOF {
break
}
if err != nil {
s.t.Fatalf("failed to Readdir: %s", err)
}
}
if len(want) == 0 && len(gotNames) == 0 {
return
}
if diff := cmp.Diff(want, gotNames); diff != "" {
s.t.Errorf("%v: (-got, +want):\n%s", label, diff)
}
}
func (s *system) stat(remoteName, shareName, name string) os.FileInfo {
filename := filepath.Join(s.remotes[remoteName].shares[shareName], name)
fi, err := os.Stat(filename)
@@ -287,7 +501,7 @@ func (s *system) stat(remoteName, shareName, name string) os.FileInfo {
func (s *system) statViaWebDAV(remoteName, shareName, name string) os.FileInfo {
path := pathTo(remoteName, shareName, name)
fi, err := s.client.Stat(path)
fi, err := s.fs.Stat(context.Background(), path)
if err != nil {
s.t.Fatalf("failed to Stat: %s", err)
}
@@ -307,10 +521,17 @@ func (s *system) read(remoteName, shareName, name string) string {
func (s *system) readViaWebDAV(remoteName, shareName, name string) string {
path := pathTo(remoteName, shareName, name)
b, err := s.client.Read(path)
file, err := s.fs.OpenFile(context.Background(), path, os.O_RDONLY, 0)
if err != nil {
s.t.Fatalf("failed to OpenFile: %s", err)
}
defer file.Close()
b, err := io.ReadAll(file)
if err != nil {
s.t.Fatalf("failed to ReadAll: %s", err)
}
return string(b)
}

View File

@@ -0,0 +1,192 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package webdavfs
import (
"context"
"errors"
"io"
"io/fs"
"os"
"sync"
"github.com/tailscale/gowebdav"
)
const (
// MaxRewindBuffer specifies the size of the rewind buffer for reading
// from files. For some files, net/http performs content type detection
// by reading up to the first 512 bytes of a file, then seeking back to the
// beginning before actually transmitting the file. To support this, we
// maintain a rewind buffer of 512 bytes.
MaxRewindBuffer = 512
)
type readOnlyFile struct {
name string
client *gowebdav.Client
rewindBuffer []byte
position int
// mu guards the below values. Acquire a write lock before updating any of
// them, acquire a read lock before reading any of them.
mu sync.RWMutex
io.ReadCloser
initialFI fs.FileInfo
fi fs.FileInfo
}
// Readdir implements webdav.File. Since this is a file, it always failes with
// an os.PathError.
func (f *readOnlyFile) Readdir(count int) ([]fs.FileInfo, error) {
return nil, &os.PathError{
Op: "readdir",
Path: f.fi.Name(),
Err: errors.New("is a file"), // TODO(oxtoacart): make sure this and below errors match what a regular os.File does
}
}
// Seek implements webdav.File. Only the specific types of seek used by the
// webdav package are implemented, namely:
//
// - Seek to 0 from end of file
// - Seek to 0 from beginning of file, provided that fewer than 512 bytes
// have already been read.
// - Seek to n from beginning of file, provided that no bytes have already
// been read.
//
// Any other type of seek will fail with an os.PathError.
func (f *readOnlyFile) Seek(offset int64, whence int) (int64, error) {
err := f.statIfNecessary()
if err != nil {
return 0, err
}
switch whence {
case io.SeekEnd:
if offset == 0 {
// seek to end is usually done to check size, let's play along
size := f.fi.Size()
return size, nil
}
case io.SeekStart:
if offset == 0 {
// this is usually done to start reading after getting size
if f.position > MaxRewindBuffer {
return 0, errors.New("attempted seek after having read past rewind buffer")
}
f.position = 0
return 0, nil
} else if f.position == 0 {
// this is usually done to perform a range request to skip the head of the file
f.position = int(offset)
return offset, nil
}
}
// unknown seek scenario, error out
return 0, &os.PathError{
Op: "seek",
Path: f.fi.Name(),
Err: errors.New("seek not supported"),
}
}
// Stat implements webdav.File, returning either the FileInfo with which this
// file was initialized, or the more recently fetched FileInfo if available.
func (f *readOnlyFile) Stat() (fs.FileInfo, error) {
f.mu.RLock()
defer f.mu.RUnlock()
if f.fi != nil {
return f.fi, nil
}
return f.initialFI, nil
}
// Read implements webdav.File.
func (f *readOnlyFile) Read(p []byte) (int, error) {
err := f.initReaderIfNecessary()
if err != nil {
return 0, err
}
amountToReadFromBuffer := len(f.rewindBuffer) - f.position
if amountToReadFromBuffer > 0 {
n := copy(p, f.rewindBuffer)
f.position += n
return n, nil
}
n, err := f.ReadCloser.Read(p)
if n > 0 && f.position < MaxRewindBuffer {
amountToReadIntoBuffer := MaxRewindBuffer - f.position
if amountToReadIntoBuffer > n {
amountToReadIntoBuffer = n
}
f.rewindBuffer = append(f.rewindBuffer, p[:amountToReadIntoBuffer]...)
}
f.position += n
return n, err
}
// Write implements webdav.File. As this file is read-only, it always fails
// with an os.PathError.
func (f *readOnlyFile) Write(p []byte) (int, error) {
return 0, &os.PathError{
Op: "write",
Path: f.fi.Name(),
Err: errors.New("read-only"),
}
}
// Close implements webdav.File.
func (f *readOnlyFile) Close() error {
f.mu.Lock()
defer f.mu.Unlock()
if f.ReadCloser == nil {
return nil
}
return f.ReadCloser.Close()
}
// statIfNecessary lazily initializes the FileInfo, bypassing the stat cache to
// make sure we have fresh info before trying to read the file.
func (f *readOnlyFile) statIfNecessary() error {
f.mu.Lock()
defer f.mu.Unlock()
if f.fi == nil {
ctxWithTimeout, cancel := context.WithTimeout(context.Background(), opTimeout)
defer cancel()
var err error
f.fi, err = f.client.Stat(ctxWithTimeout, f.name)
if err != nil {
return translateWebDAVError(err)
}
}
return nil
}
// initReaderIfNecessary initializes the Reader if it hasn't been opened yet. We
// do this lazily because github.com/tailscale/xnet/webdav often opens files in
// read-only mode without ever actually reading from them, so we can improve
// performance by avoiding the round-trip to the server.
func (f *readOnlyFile) initReaderIfNecessary() error {
f.mu.Lock()
defer f.mu.Unlock()
if f.ReadCloser == nil {
var err error
f.ReadCloser, err = f.client.ReadStreamOffset(context.Background(), f.name, f.position)
if err != nil {
return translateWebDAVError(err)
}
}
return nil
}

View File

@@ -0,0 +1,71 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package webdavfs
import (
"io/fs"
"path/filepath"
"sync"
"time"
"github.com/jellydator/ttlcache/v3"
)
// statCache provides a cache for file directory and file metadata. Especially
// when used from the command-line, mapped WebDAV drives can generate
// repetitive requests for the same file metadata. This cache helps reduce the
// number of round-trips to the WebDAV server for such requests.
type statCache struct {
// mu guards the below values.
mu sync.Mutex
cache *ttlcache.Cache[string, fs.FileInfo]
}
func newStatCache(ttl time.Duration) *statCache {
cache := ttlcache.New(
ttlcache.WithTTL[string, fs.FileInfo](ttl),
)
go cache.Start()
return &statCache{cache: cache}
}
func (c *statCache) getOrFetch(name string, fetch func(string) (fs.FileInfo, error)) (fs.FileInfo, error) {
c.mu.Lock()
item := c.cache.Get(name)
c.mu.Unlock()
if item != nil {
return item.Value(), nil
}
fi, err := fetch(name)
if err == nil {
c.mu.Lock()
c.cache.Set(name, fi, ttlcache.DefaultTTL)
c.mu.Unlock()
}
return fi, err
}
func (c *statCache) set(parentPath string, infos []fs.FileInfo) {
c.mu.Lock()
defer c.mu.Unlock()
for _, info := range infos {
path := filepath.Join(parentPath, filepath.Base(info.Name()))
c.cache.Set(path, info, ttlcache.DefaultTTL)
}
}
func (c *statCache) invalidate() {
c.mu.Lock()
defer c.mu.Unlock()
c.cache.DeleteAll()
}
func (c *statCache) stop() {
c.cache.Stop()
}

View File

@@ -0,0 +1,104 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package webdavfs
import (
"io/fs"
"os"
"path/filepath"
"testing"
"time"
"tailscale.com/tailfs/tailfsimpl/shared"
"tailscale.com/tstest"
)
func TestStatCache(t *testing.T) {
// Make sure we don't leak goroutines
tstest.ResourceCheck(t)
dir, err := os.MkdirTemp("", "")
if err != nil {
t.Fatal(err)
}
// create file of size 1
filename := filepath.Join(dir, "thefile")
err = os.WriteFile(filename, []byte("1"), 0644)
if err != nil {
t.Fatal(err)
}
stat := func(name string) (os.FileInfo, error) {
return os.Stat(name)
}
ttl := 1 * time.Second
c := newStatCache(ttl)
// fetch new stat
fi, err := c.getOrFetch(filename, stat)
if err != nil {
t.Fatal(err)
}
if fi.Size() != 1 {
t.Errorf("got size %d, want 1", fi.Size())
}
// save original FileInfo as a StaticFileInfo so we can reuse it later
// without worrying about the underlying FileInfo changing.
originalFI := &shared.StaticFileInfo{
Named: fi.Name(),
Sized: fi.Size(),
Moded: fi.Mode(),
ModdedTime: fi.ModTime(),
Dir: fi.IsDir(),
}
// update file to size 2
err = os.WriteFile(filename, []byte("12"), 0644)
if err != nil {
t.Fatal(err)
}
// fetch stat again, should still be cached
fi, err = c.getOrFetch(filename, stat)
if err != nil {
t.Fatal(err)
}
if fi.Size() != 1 {
t.Errorf("got size %d, want 1", fi.Size())
}
// wait for cache to expire and refetch stat, size should reflect new size
time.Sleep(ttl * 2)
fi, err = c.getOrFetch(filename, stat)
if err != nil {
t.Fatal(err)
}
if fi.Size() != 2 {
t.Errorf("got size %d, want 2", fi.Size())
}
// explicitly set the original FileInfo and make sure it's returned
c.set(dir, []fs.FileInfo{originalFI})
fi, err = c.getOrFetch(filename, stat)
if err != nil {
t.Fatal(err)
}
if fi.Size() != 1 {
t.Errorf("got size %d, want 1", fi.Size())
}
// invalidate the cache and make sure the new size is returned
c.invalidate()
fi, err = c.getOrFetch(filename, stat)
if err != nil {
t.Fatal(err)
}
if fi.Size() != 2 {
t.Errorf("got size %d, want 2", fi.Size())
}
c.stop()
}

View File

@@ -0,0 +1,256 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package webdavfs provides an implementation of webdav.FileSystem backed by
// a gowebdav.Client.
package webdavfs
import (
"context"
"errors"
"fmt"
"io"
"io/fs"
"log"
"net/http"
"os"
"time"
"github.com/tailscale/gowebdav"
"github.com/tailscale/xnet/webdav"
"tailscale.com/tailfs/tailfsimpl/shared"
"tailscale.com/tstime"
"tailscale.com/types/logger"
)
const (
// keep requests from taking too long if the server is down or slow to respond
opTimeout = 2 * time.Second // TODO(oxtoacart): tune this
)
type Options struct {
// Logf us a logging function to use for debug and error logging.
Logf logger.Logf
// URL is the base URL of the remote WebDAV server.
URL string
// Transport is the http.Transport to use for connecting to the WebDAV
// server.
Transport http.RoundTripper
// StatRoot, if true, will cause this filesystem to actually stat its own
// root via the remote server. If false, it will use a static directory
// info for the root to avoid a round-trip.
StatRoot bool
// StatCacheTTL, when greater than 0, enables caching of file metadata
StatCacheTTL time.Duration
// Clock, if specified, determines the current time. If not specified, we
// default to time.Now().
Clock tstime.Clock
}
// webdavFS adapts gowebdav.Client to webdav.FileSystem
type webdavFS struct {
logf logger.Logf
transport http.RoundTripper
*gowebdav.Client
now func() time.Time
statRoot bool
statCache *statCache
}
// New creates a new webdav.FileSystem backed by the given gowebdav.Client.
// If cacheTTL is greater than zero, the filesystem will cache results from
// Stat calls for the given duration.
func New(opts Options) webdav.FileSystem {
if opts.Logf == nil {
opts.Logf = log.Printf
}
wfs := &webdavFS{
logf: opts.Logf,
transport: opts.Transport,
Client: gowebdav.New(&gowebdav.Opts{URI: opts.URL, Transport: opts.Transport}),
statRoot: opts.StatRoot,
}
if opts.StatCacheTTL > 0 {
wfs.statCache = newStatCache(opts.StatCacheTTL)
}
if opts.Clock != nil {
wfs.now = opts.Clock.Now
} else {
wfs.now = time.Now
}
return wfs
}
// Mkdir implements webdav.FileSystem.
func (wfs *webdavFS) Mkdir(ctx context.Context, name string, perm os.FileMode) error {
ctxWithTimeout, cancel := context.WithTimeout(ctx, opTimeout)
defer cancel()
if wfs.statCache != nil {
wfs.statCache.invalidate()
}
return translateWebDAVError(wfs.Client.Mkdir(ctxWithTimeout, name, perm))
}
// OpenFile implements webdav.FileSystem.
func (wfs *webdavFS) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (webdav.File, error) {
if hasFlag(flag, os.O_APPEND) {
return nil, &os.PathError{
Op: "open",
Path: name,
Err: errors.New("mode APPEND not supported"),
}
}
ctxWithTimeout, cancel := context.WithTimeout(ctx, opTimeout)
defer cancel()
if hasFlag(flag, os.O_WRONLY) || hasFlag(flag, os.O_RDWR) {
if wfs.statCache != nil {
wfs.statCache.invalidate()
}
fi, err := wfs.Stat(ctxWithTimeout, name)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, err
}
if err == nil && fi.IsDir() {
return nil, &os.PathError{
Op: "open",
Path: name,
Err: errors.New("is a directory"),
}
}
pipeReader, pipeWriter := io.Pipe()
f := &writeOnlyFile{
WriteCloser: pipeWriter,
name: name,
perm: perm,
fs: wfs,
finalError: make(chan error, 1),
}
go func() {
defer pipeReader.Close()
err := wfs.Client.WriteStream(context.Background(), name, pipeReader, perm)
f.finalError <- err
close(f.finalError)
}()
return f, nil
}
// Assume reading
fi, err := wfs.Stat(ctxWithTimeout, name)
if err != nil {
return nil, translateWebDAVError(err)
}
if fi.IsDir() {
return wfs.dirWithChildren(name, fi), nil
}
return &readOnlyFile{
client: wfs.Client,
name: name,
initialFI: fi,
rewindBuffer: make([]byte, 0, MaxRewindBuffer),
}, nil
}
func (wfs *webdavFS) dirWithChildren(name string, fi fs.FileInfo) webdav.File {
return &shared.DirFile{
Info: fi,
LoadChildren: func() ([]fs.FileInfo, error) {
ctxWithTimeout, cancel := context.WithTimeout(context.Background(), opTimeout)
defer cancel()
dirInfos, err := wfs.Client.ReadDir(ctxWithTimeout, name)
if err != nil {
wfs.logf("encountered error reading children of '%v', returning empty list: %v", name, err)
// We do not return the actual error here because some WebDAV clients
// will take that as an invitation to retry, hanging in the process.
return dirInfos, nil
}
if wfs.statCache != nil {
wfs.statCache.set(name, dirInfos)
}
return dirInfos, nil
},
}
}
// RemoveAll implements webdav.FileSystem.
func (wfs *webdavFS) RemoveAll(ctx context.Context, name string) error {
ctxWithTimeout, cancel := context.WithTimeout(ctx, opTimeout)
defer cancel()
if wfs.statCache != nil {
wfs.statCache.invalidate()
}
return wfs.Client.RemoveAll(ctxWithTimeout, name)
}
// Rename implements webdav.FileSystem.
func (wfs *webdavFS) Rename(ctx context.Context, oldName, newName string) error {
ctxWithTimeout, cancel := context.WithTimeout(ctx, opTimeout)
defer cancel()
if wfs.statCache != nil {
wfs.statCache.invalidate()
}
return wfs.Client.Rename(ctxWithTimeout, oldName, newName, false)
}
// Stat implements webdav.FileSystem.
func (wfs *webdavFS) Stat(ctx context.Context, name string) (fs.FileInfo, error) {
if wfs.statCache != nil {
return wfs.statCache.getOrFetch(name, wfs.doStat)
}
return wfs.doStat(name)
}
// Close implements webdav.FileSystem.
func (wfs *webdavFS) Close() error {
if wfs.statCache != nil {
wfs.statCache.stop()
}
tr, ok := wfs.transport.(*http.Transport)
if ok {
tr.CloseIdleConnections()
}
return nil
}
func (wfs *webdavFS) doStat(name string) (fs.FileInfo, error) {
ctxWithTimeout, cancel := context.WithTimeout(context.Background(), opTimeout)
defer cancel()
if !wfs.statRoot && shared.IsRoot(name) {
// use a static directory info for the root
// always use now() as the modified time to bust caches
return shared.ReadOnlyDirInfo(name, wfs.now()), nil
}
fi, err := wfs.Client.Stat(ctxWithTimeout, name)
return fi, translateWebDAVError(err)
}
func translateWebDAVError(err error) error {
if err == nil {
return nil
}
var se gowebdav.StatusError
if errors.As(err, &se) {
if se.Status == http.StatusNotFound {
return os.ErrNotExist
}
}
// Note, we intentionally don't wrap the error because we don't want
// github.com/tailscale/xnet/webdav to try to interpret the underlying
// error.
return fmt.Errorf("unexpected WebDAV error: %v", err)
}
func hasFlag(flags int, flag int) bool {
return (flags & flag) == flag
}

View File

@@ -0,0 +1,89 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package webdavfs
import (
"context"
"errors"
"io"
"io/fs"
"os"
"tailscale.com/tailfs/tailfsimpl/shared"
)
type writeOnlyFile struct {
io.WriteCloser
name string
perm os.FileMode
fs *webdavFS
finalError chan error
}
// Readdir implements webdav.File. As this is a file, this always fails with an
// os.PathError.
func (f *writeOnlyFile) Readdir(count int) ([]fs.FileInfo, error) {
return nil, &os.PathError{
Op: "readdir",
Path: f.name,
Err: errors.New("is a file"), // TODO(oxtoacart): make sure this and below errors match what a regular os.File does
}
}
// Seek implements webdav.File. This always fails with an os.PathError.
func (f *writeOnlyFile) Seek(offset int64, whence int) (int64, error) {
return 0, &os.PathError{
Op: "seek",
Path: f.name,
Err: errors.New("seek not supported"),
}
}
// Stat implements webdav.File.
func (f *writeOnlyFile) Stat() (fs.FileInfo, error) {
fi, err := f.fs.Stat(context.Background(), f.name)
if err != nil {
// use static info for newly created file
now := f.fs.now()
fi = &shared.StaticFileInfo{
Named: f.name,
Sized: 0,
Moded: f.perm,
BirthedTime: now,
ModdedTime: now,
Dir: false,
}
}
return fi, nil
}
// Read implements webdav.File. As this is a write-only file, it always fails
// with an os.PathError.
func (f *writeOnlyFile) Read(p []byte) (int, error) {
return 0, &os.PathError{
Op: "write",
Path: f.name,
Err: errors.New("write-only"),
}
}
// Write implements webdav.File.
func (f *writeOnlyFile) Write(p []byte) (int, error) {
select {
case err := <-f.finalError:
return 0, err
default:
return f.WriteCloser.Write(p)
}
}
// Close implements webdav.File.
func (f *writeOnlyFile) Close() error {
err := f.WriteCloser.Close()
writeErr := <-f.finalError
if writeErr != nil {
return writeErr
}
return err
}

View File

@@ -428,7 +428,7 @@ func (s *Server) TailscaleIPs() (ip4, ip6 netip.Addr) {
return
}
addrs := nm.GetAddresses()
for i := range addrs.Len() {
for i := range addrs.LenIter() {
addr := addrs.At(i)
ip := addr.Addr()
if ip.Is6() {

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