Compare commits
36 Commits
irbekrm/op
...
andrew/net
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35dc1fea72 | ||
|
|
50fb8b9123 | ||
|
|
e1bd7488d0 | ||
|
|
ff1391a97e | ||
|
|
6ad6d6b252 | ||
|
|
8b9474b06a | ||
|
|
8d0d46462b | ||
|
|
0c5e65eb3f | ||
|
|
c9b6d19fc9 | ||
|
|
651c4899ac | ||
|
|
c8c999d7a9 | ||
|
|
ac281dd493 | ||
|
|
15b2c674bf | ||
|
|
131f9094fd | ||
|
|
e8d2fc7f7f | ||
|
|
713d2928b1 | ||
|
|
72140da000 | ||
|
|
edbad6d274 | ||
|
|
0359c2f94e | ||
|
|
10d130b845 | ||
|
|
2988c1ec52 | ||
|
|
7708ab68c0 | ||
|
|
91a1019ee2 | ||
|
|
d756622432 | ||
|
|
8fe504241d | ||
|
|
a4a909a20b | ||
|
|
794af40f68 | ||
|
|
6c3899e6ee | ||
|
|
70b7201744 | ||
|
|
44e337cc0e | ||
|
|
6b582cb8b6 | ||
|
|
24487815e1 | ||
|
|
69f5664075 | ||
|
|
3aca29e00e | ||
|
|
4d668416b8 | ||
|
|
52f16b5d10 |
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@@ -208,7 +208,7 @@ jobs:
|
||||
env:
|
||||
HOME: "/tmp"
|
||||
TMPDIR: "/tmp"
|
||||
XDB_CACHE_HOME: "/var/lib/ghrunner/cache"
|
||||
XDG_CACHE_HOME: "/var/lib/ghrunner/cache"
|
||||
|
||||
race-build:
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
@@ -1 +1 @@
|
||||
1.59.0
|
||||
1.61.0
|
||||
|
||||
@@ -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/tailscale/gowebdav": "https://github.com/tailscale/tailscale/pull/10631",
|
||||
"github.com/studio-b12/gowebdav": "https://github.com/tailscale/tailscale/pull/10631",
|
||||
},
|
||||
}.Check(t)
|
||||
}
|
||||
|
||||
@@ -19,9 +19,10 @@
|
||||
"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.3.2",
|
||||
"@vitejs/plugin-react-swc": "^3.6.0",
|
||||
"autoprefixer": "^10.4.15",
|
||||
"eslint": "^8.23.1",
|
||||
"eslint-config-react-app": "^7.0.1",
|
||||
@@ -32,11 +33,10 @@
|
||||
"prettier-plugin-organize-imports": "^3.2.2",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"typescript": "^4.7.4",
|
||||
"vite": "^4.5.2",
|
||||
"vite-plugin-rewrite-all": "^1.0.1",
|
||||
"vite-plugin-svgr": "^3.2.0",
|
||||
"vite": "^5.1.4",
|
||||
"vite-plugin-svgr": "^4.2.0",
|
||||
"vite-tsconfig-paths": "^3.5.0",
|
||||
"vitest": "^0.32.0"
|
||||
"vitest": "^1.3.1"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
import * as Primitive from "@radix-ui/react-popover"
|
||||
import cx from "classnames"
|
||||
import React, { useCallback } from "react"
|
||||
import { ReactComponent as ChevronDown } from "src/assets/icons/chevron-down.svg"
|
||||
import { ReactComponent as Copy } from "src/assets/icons/copy.svg"
|
||||
import ChevronDown from "src/assets/icons/chevron-down.svg?react"
|
||||
import Copy from "src/assets/icons/copy.svg?react"
|
||||
import NiceIP from "src/components/nice-ip"
|
||||
import useToaster from "src/hooks/toaster"
|
||||
import Button from "src/ui/button"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import React from "react"
|
||||
import { ReactComponent as TailscaleIcon } from "src/assets/icons/tailscale-icon.svg"
|
||||
import TailscaleIcon from "src/assets/icons/tailscale-icon.svg?react"
|
||||
import LoginToggle from "src/components/login-toggle"
|
||||
import DeviceDetailsView from "src/components/views/device-details-view"
|
||||
import DisconnectedView from "src/components/views/disconnected-view"
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
import cx from "classnames"
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { useAPI } from "src/api"
|
||||
import { ReactComponent as Check } from "src/assets/icons/check.svg"
|
||||
import { ReactComponent as ChevronDown } from "src/assets/icons/chevron-down.svg"
|
||||
import Check from "src/assets/icons/check.svg?react"
|
||||
import ChevronDown from "src/assets/icons/chevron-down.svg?react"
|
||||
import useExitNodes, {
|
||||
noExitNode,
|
||||
runAsExitNode,
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
|
||||
import cx from "classnames"
|
||||
import React, { useCallback, useEffect, useState } from "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 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 { AuthResponse, AuthType } from "src/hooks/auth"
|
||||
import { NodeData } from "src/types"
|
||||
import Button from "src/ui/button"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import React from "react"
|
||||
import { ReactComponent as TailscaleIcon } from "src/assets/icons/tailscale-icon.svg"
|
||||
import TailscaleIcon from "src/assets/icons/tailscale-icon.svg?react"
|
||||
|
||||
/**
|
||||
* DisconnectedView is rendered after node logout.
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
import cx from "classnames"
|
||||
import React, { useMemo } from "react"
|
||||
import { apiFetch } from "src/api"
|
||||
import { ReactComponent as ArrowRight } from "src/assets/icons/arrow-right.svg"
|
||||
import { ReactComponent as Machine } from "src/assets/icons/machine.svg"
|
||||
import ArrowRight from "src/assets/icons/arrow-right.svg?react"
|
||||
import Machine from "src/assets/icons/machine.svg?react"
|
||||
import AddressCard from "src/components/address-copy-card"
|
||||
import ExitNodeSelector from "src/components/exit-node-selector"
|
||||
import { NodeData } from "src/types"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
import React, { useState } from "react"
|
||||
import { useAPI } from "src/api"
|
||||
import { ReactComponent as TailscaleIcon } from "src/assets/icons/tailscale-icon.svg"
|
||||
import TailscaleIcon from "src/assets/icons/tailscale-icon.svg?react"
|
||||
import { NodeData } from "src/types"
|
||||
import Button from "src/ui/button"
|
||||
import Collapsible from "src/ui/collapsible"
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
import cx from "classnames"
|
||||
import React, { useCallback, useMemo, useState } from "react"
|
||||
import { useAPI } from "src/api"
|
||||
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 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 * as Control from "src/components/control-components"
|
||||
import { NodeData } from "src/types"
|
||||
import Button from "src/ui/button"
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import React from "react"
|
||||
import { ReactComponent as CheckCircleIcon } from "src/assets/icons/check-circle.svg"
|
||||
import { ReactComponent as XCircleIcon } from "src/assets/icons/x-circle.svg"
|
||||
import CheckCircleIcon from "src/assets/icons/check-circle.svg?react"
|
||||
import XCircleIcon from "src/assets/icons/x-circle.svg?react"
|
||||
import { ChangelogText } from "src/components/update-available"
|
||||
import { UpdateState, useInstallUpdate } from "src/hooks/self-update"
|
||||
import { VersionInfo } from "src/types"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
import * as Primitive from "@radix-ui/react-collapsible"
|
||||
import React, { useState } from "react"
|
||||
import { ReactComponent as ChevronDown } from "src/assets/icons/chevron-down.svg"
|
||||
import ChevronDown from "src/assets/icons/chevron-down.svg?react"
|
||||
|
||||
type CollapsibleProps = {
|
||||
trigger?: string
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import cx from "classnames"
|
||||
import React, { Component, ComponentProps, FormEvent } from "react"
|
||||
import { ReactComponent as X } from "src/assets/icons/x.svg"
|
||||
import X from "src/assets/icons/x.svg?react"
|
||||
import Button from "src/ui/button"
|
||||
import PortalContainerContext from "src/ui/portal-container-context"
|
||||
import { isObject } from "src/utils/util"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
import cx from "classnames"
|
||||
import React, { forwardRef, InputHTMLAttributes } from "react"
|
||||
import { ReactComponent as Search } from "src/assets/icons/search.svg"
|
||||
import Search from "src/assets/icons/search.svg?react"
|
||||
|
||||
type Props = {
|
||||
className?: string
|
||||
|
||||
@@ -10,7 +10,7 @@ import React, {
|
||||
useState,
|
||||
} from "react"
|
||||
import { createPortal } from "react-dom"
|
||||
import { ReactComponent as X } from "src/assets/icons/x.svg"
|
||||
import X from "src/assets/icons/x.svg?react"
|
||||
import { noop } from "src/utils/util"
|
||||
import { create } from "zustand"
|
||||
import { shallow } from "zustand/shallow"
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"module": "ES2020",
|
||||
"strict": true,
|
||||
"sourceMap": true,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"moduleResolution": "node",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
/// <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"
|
||||
|
||||
@@ -24,11 +23,6 @@ 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",
|
||||
|
||||
1082
client/web/yarn.lock
1082
client/web/yarn.lock
File diff suppressed because it is too large
Load Diff
@@ -95,6 +95,7 @@ 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+
|
||||
|
||||
@@ -32,6 +32,7 @@ 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"
|
||||
@@ -49,14 +50,21 @@ 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.")
|
||||
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")
|
||||
|
||||
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 (
|
||||
@@ -147,6 +155,8 @@ 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)
|
||||
@@ -216,6 +226,15 @@ 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,
|
||||
@@ -292,7 +311,12 @@ func main() {
|
||||
// duration exceeds server's WriteTimeout".
|
||||
WriteTimeout: 5 * time.Minute,
|
||||
}
|
||||
err := port80srv.ListenAndServe()
|
||||
ln, err := lc.Listen(context.Background(), "tcp", port80srv.Addr)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
err = port80srv.Serve(ln)
|
||||
if err != nil {
|
||||
if err != http.ErrServerClosed {
|
||||
log.Fatal(err)
|
||||
@@ -300,10 +324,15 @@ func main() {
|
||||
}
|
||||
}()
|
||||
}
|
||||
err = rateLimitedListenAndServeTLS(httpsrv)
|
||||
err = rateLimitedListenAndServeTLS(httpsrv, &lc)
|
||||
} else {
|
||||
log.Printf("derper: serving on %s", *addr)
|
||||
err = httpsrv.ListenAndServe()
|
||||
var ln net.Listener
|
||||
ln, err = lc.Listen(context.Background(), "tcp", httpsrv.Addr)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
err = httpsrv.Serve(ln)
|
||||
}
|
||||
if err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("derper: %v", err)
|
||||
@@ -368,8 +397,8 @@ func defaultMeshPSKFile() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func rateLimitedListenAndServeTLS(srv *http.Server) error {
|
||||
ln, err := net.Listen("tcp", cmp.Or(srv.Addr, ":https"))
|
||||
func rateLimitedListenAndServeTLS(srv *http.Server, lc *net.ListenConfig) error {
|
||||
ln, err := lc.Listen(context.Background(), "tcp", cmp.Or(srv.Addr, ":https"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -130,14 +130,14 @@ var debugCmd = &ffcli.Command{
|
||||
ShortHelp: "force a magicsock rebind",
|
||||
},
|
||||
{
|
||||
Name: "derp-set-homeless",
|
||||
Name: "derp-set-on-demand",
|
||||
Exec: localAPIAction("derp-set-homeless"),
|
||||
ShortHelp: "enable DERP homeless mode (breaks reachablility)",
|
||||
ShortHelp: "enable DERP on-demand mode (breaks reachability)",
|
||||
},
|
||||
{
|
||||
Name: "derp-unset-homeless",
|
||||
Name: "derp-unset-on-demand",
|
||||
Exec: localAPIAction("derp-unset-homeless"),
|
||||
ShortHelp: "disable DERP homeless mode",
|
||||
ShortHelp: "disable DERP on-demand mode",
|
||||
},
|
||||
{
|
||||
Name: "break-tcp-conns",
|
||||
|
||||
@@ -140,9 +140,24 @@ 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.
|
||||
|
||||
@@ -150,57 +165,59 @@ 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:
|
||||
|
||||
{
|
||||
"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"
|
||||
}]
|
||||
}
|
||||
}
|
||||
"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"
|
||||
}]
|
||||
}
|
||||
}]
|
||||
|
||||
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 = `
|
||||
|
||||
|
||||
@@ -103,6 +103,7 @@ 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:
|
||||
|
||||
@@ -99,7 +99,7 @@ func runWeb(ctx context.Context, args []string) error {
|
||||
var startedManagementClient bool // we started the management client
|
||||
if !existingWebClient && !webArgs.readonly {
|
||||
// Also start full client in tailscaled.
|
||||
log.Printf("starting tailscaled web client at %s:%d\n", selfIP.String(), web.ListenPort)
|
||||
log.Printf("starting tailscaled web client at http://%s\n", netip.AddrPortFrom(selfIP, web.ListenPort))
|
||||
if err := setRunWebClient(ctx, true); err != nil {
|
||||
return fmt.Errorf("starting web client in tailscaled: %w", err)
|
||||
}
|
||||
|
||||
@@ -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/webdavfs
|
||||
github.com/jellydator/ttlcache/v3 from tailscale.com/tailfs/tailfsimpl/compositedav
|
||||
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+
|
||||
@@ -138,7 +138,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
L github.com/pierrec/lz4/v4/internal/xxh32 from github.com/pierrec/lz4/v4/internal/lz4stream
|
||||
LD github.com/pkg/sftp from tailscale.com/ssh/tailssh
|
||||
LD github.com/pkg/sftp/internal/encoding/ssh/filexfer from github.com/pkg/sftp
|
||||
L 💣 github.com/safchain/ethtool from tailscale.com/net/netkernelconf
|
||||
L 💣 github.com/safchain/ethtool from tailscale.com/net/netkernelconf+
|
||||
W 💣 github.com/tailscale/certstore from tailscale.com/control/controlclient
|
||||
W 💣 github.com/tailscale/go-winio from tailscale.com/safesocket
|
||||
W 💣 github.com/tailscale/go-winio/internal/fs from github.com/tailscale/go-winio
|
||||
@@ -155,7 +155,6 @@ 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
|
||||
@@ -247,6 +246,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
tailscale.com/derp/derphttp from tailscale.com/cmd/tailscaled+
|
||||
tailscale.com/disco from tailscale.com/derp+
|
||||
tailscale.com/doctor from tailscale.com/ipn/ipnlocal
|
||||
tailscale.com/doctor/ethtool from tailscale.com/ipn/ipnlocal
|
||||
💣 tailscale.com/doctor/permissions from tailscale.com/ipn/ipnlocal
|
||||
tailscale.com/doctor/routetable from tailscale.com/ipn/ipnlocal
|
||||
tailscale.com/envknob from tailscale.com/client/tailscale+
|
||||
@@ -322,9 +322,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/compositefs from tailscale.com/tailfs/tailfsimpl
|
||||
tailscale.com/tailfs/tailfsimpl/compositedav from tailscale.com/tailfs/tailfsimpl
|
||||
tailscale.com/tailfs/tailfsimpl/dirfs 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 +508,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+
|
||||
|
||||
@@ -755,6 +755,8 @@ 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
|
||||
}
|
||||
|
||||
@@ -278,6 +278,7 @@ func main() {
|
||||
goTestArgsWithCoverage,
|
||||
fmt.Sprintf("-coverprofile=%v", coverageFile),
|
||||
"-covermode=set",
|
||||
"-coverpkg=./...",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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().LenIter() {
|
||||
for i := range p.Addresses().Len() {
|
||||
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.LenIter() {
|
||||
for i := range a.Len() {
|
||||
n[i] = f(a.At(i))
|
||||
}
|
||||
return n
|
||||
|
||||
@@ -728,7 +728,7 @@ func peerChangeDiff(was tailcfg.NodeView, n *tailcfg.Node) (_ *tailcfg.PeerChang
|
||||
return nil, false
|
||||
}
|
||||
|
||||
for i := range va.LenIter() {
|
||||
for i := range va.Len() {
|
||||
if !va.At(i).Equal(vb.At(i)) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ package derp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
crand "crypto/rand"
|
||||
@@ -40,6 +41,7 @@ import (
|
||||
"tailscale.com/envknob"
|
||||
"tailscale.com/metrics"
|
||||
"tailscale.com/syncs"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/tstime"
|
||||
"tailscale.com/tstime/rate"
|
||||
"tailscale.com/types/key"
|
||||
@@ -144,9 +146,13 @@ type Server struct {
|
||||
avgQueueDuration *uint64 // In milliseconds; accessed atomically
|
||||
tcpRtt metrics.LabelMap // histogram
|
||||
|
||||
// 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
|
||||
// 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
|
||||
|
||||
mu sync.Mutex
|
||||
closed bool
|
||||
@@ -353,7 +359,20 @@ func (s *Server) SetMeshKey(v string) {
|
||||
//
|
||||
// It must be called before serving begins.
|
||||
func (s *Server) SetVerifyClient(v bool) {
|
||||
s.verifyClients = v
|
||||
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
|
||||
}
|
||||
|
||||
// HasMeshKey reports whether the server is configured with a mesh key.
|
||||
@@ -691,7 +710,9 @@ func (s *Server) accept(ctx context.Context, nc Conn, brw *bufio.ReadWriter, rem
|
||||
if err != nil {
|
||||
return fmt.Errorf("receive client key: %v", err)
|
||||
}
|
||||
if err := s.verifyClient(clientKey, clientInfo); err != nil {
|
||||
|
||||
clientAP, _ := netip.ParseAddrPort(remoteAddr)
|
||||
if err := s.verifyClient(ctx, clientKey, clientInfo, clientAP.Addr()); err != nil {
|
||||
return fmt.Errorf("client %x rejected: %v", clientKey, err)
|
||||
}
|
||||
|
||||
@@ -1116,21 +1137,60 @@ func (c *sclient) requestMeshUpdate() {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) verifyClient(clientKey key.NodePublic, info *clientInfo) error {
|
||||
if !s.verifyClients {
|
||||
return nil
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
status, err := tailscale.Status(context.TODO())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to query local tailscaled status: %w", err)
|
||||
|
||||
// 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?
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
23
doctor/ethtool/ethtool.go
Normal file
23
doctor/ethtool/ethtool.go
Normal file
@@ -0,0 +1,23 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package ethtool provides a doctor.Check that prints diagnostic information
|
||||
// obtained from the 'ethtool' utility on the current system.
|
||||
package ethtool
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"tailscale.com/types/logger"
|
||||
)
|
||||
|
||||
// Check implements the doctor.Check interface.
|
||||
type Check struct{}
|
||||
|
||||
func (Check) Name() string {
|
||||
return "ethtool"
|
||||
}
|
||||
|
||||
func (Check) Run(_ context.Context, logf logger.Logf) error {
|
||||
return ethtoolImpl(logf)
|
||||
}
|
||||
78
doctor/ethtool/ethtool_linux.go
Normal file
78
doctor/ethtool/ethtool_linux.go
Normal file
@@ -0,0 +1,78 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package ethtool
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"sort"
|
||||
|
||||
"github.com/safchain/ethtool"
|
||||
"tailscale.com/net/interfaces"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/util/set"
|
||||
)
|
||||
|
||||
func ethtoolImpl(logf logger.Logf) error {
|
||||
et, err := ethtool.NewEthtool()
|
||||
if err != nil {
|
||||
logf("could not create ethtool: %v", err)
|
||||
return nil
|
||||
}
|
||||
defer et.Close()
|
||||
|
||||
interfaces.ForeachInterface(func(iface interfaces.Interface, _ []netip.Prefix) {
|
||||
ilogf := logger.WithPrefix(logf, iface.Name+": ")
|
||||
features, err := et.Features(iface.Name)
|
||||
if err == nil {
|
||||
enabled := []string{}
|
||||
for feature, value := range features {
|
||||
if value {
|
||||
enabled = append(enabled, feature)
|
||||
}
|
||||
}
|
||||
sort.Strings(enabled)
|
||||
ilogf("features: %v", enabled)
|
||||
} else {
|
||||
ilogf("features: error: %v", err)
|
||||
}
|
||||
|
||||
stats, err := et.Stats(iface.Name)
|
||||
if err == nil {
|
||||
printStats(ilogf, stats)
|
||||
} else {
|
||||
ilogf("stats: error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stats that should be printed if non-zero
|
||||
var nonzeroStats = set.SetOf([]string{
|
||||
// AWS ENA driver statistics; see:
|
||||
// https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/monitoring-network-performance-ena.html
|
||||
"bw_in_allowance_exceeded",
|
||||
"bw_out_allowance_exceeded",
|
||||
"conntrack_allowance_exceeded",
|
||||
"linklocal_allowance_exceeded",
|
||||
"pps_allowance_exceeded",
|
||||
})
|
||||
|
||||
// Stats that should be printed if zero
|
||||
var zeroStats = set.SetOf([]string{
|
||||
// AWS ENA driver statistics; see:
|
||||
// https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/monitoring-network-performance-ena.html
|
||||
"conntrack_allowance_available",
|
||||
})
|
||||
|
||||
func printStats(logf logger.Logf, stats map[string]uint64) {
|
||||
for name, value := range stats {
|
||||
if value != 0 && nonzeroStats.Contains(name) {
|
||||
logf("stats: warning: %s = %d > 0", name, value)
|
||||
}
|
||||
if value == 0 && zeroStats.Contains(name) {
|
||||
logf("stats: warning: %s = %d == 0", name, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
17
doctor/ethtool/ethtool_other.go
Normal file
17
doctor/ethtool/ethtool_other.go
Normal file
@@ -0,0 +1,17 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !linux
|
||||
|
||||
package ethtool
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"tailscale.com/types/logger"
|
||||
)
|
||||
|
||||
func ethtoolImpl(logf logger.Logf) error {
|
||||
logf("unsupported on %s/%s", runtime.GOOS, runtime.GOARCH)
|
||||
return nil
|
||||
}
|
||||
2
go.mod
2
go.mod
@@ -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
4
go.sum
@@ -853,6 +853,8 @@ 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=
|
||||
@@ -869,8 +871,6 @@ 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=
|
||||
|
||||
@@ -688,23 +688,8 @@ func checkCertDomain(st *ipnstate.Status, domain string) error {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
// 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:
|
||||
if len(st.CertDomains) == 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)
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ import (
|
||||
"tailscale.com/control/controlclient"
|
||||
"tailscale.com/control/controlknobs"
|
||||
"tailscale.com/doctor"
|
||||
"tailscale.com/doctor/ethtool"
|
||||
"tailscale.com/doctor/permissions"
|
||||
"tailscale.com/doctor/routetable"
|
||||
"tailscale.com/envknob"
|
||||
@@ -791,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.LenIter() {
|
||||
for i := range addrs.Len() {
|
||||
if addr := addrs.At(i); addr.IsSingleIP() {
|
||||
sb.AddTailscaleIP(addr.Addr())
|
||||
tailscaleIPs = append(tailscaleIPs, addr.Addr())
|
||||
@@ -855,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().LenIter() {
|
||||
for i := range p.Addresses().Len() {
|
||||
addr := p.Addresses().At(i)
|
||||
if addr.IsSingleIP() && tsaddr.IsTailscaleIP(addr.Addr()) {
|
||||
tailscaleIPs = append(tailscaleIPs, addr.Addr())
|
||||
@@ -976,7 +977,7 @@ func (b *LocalBackend) peerCapsLocked(src netip.Addr) tailcfg.PeerCapMap {
|
||||
return nil
|
||||
}
|
||||
addrs := b.netMap.GetAddresses()
|
||||
for i := range addrs.LenIter() {
|
||||
for i := range addrs.Len() {
|
||||
a := addrs.At(i)
|
||||
if !a.IsSingleIP() {
|
||||
continue
|
||||
@@ -1432,7 +1433,7 @@ func setExitNodeID(prefs *ipn.Prefs, nm *netmap.NetworkMap) (prefsChanged bool)
|
||||
}
|
||||
|
||||
for _, peer := range nm.Peers {
|
||||
for i := range peer.Addresses().LenIter() {
|
||||
for i := range peer.Addresses().Len() {
|
||||
addr := peer.Addresses().At(i)
|
||||
if !addr.IsSingleIP() || addr.Addr() != prefs.ExitNodeIP {
|
||||
continue
|
||||
@@ -1876,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.LenIter() {
|
||||
for i := range addrs.Len() {
|
||||
localNetsB.AddPrefix(addrs.At(i))
|
||||
}
|
||||
packetFilter = netMap.PacketFilter
|
||||
@@ -1986,7 +1987,7 @@ func packetFilterPermitsUnlockedNodes(peers map[tailcfg.NodeID]tailcfg.NodeView,
|
||||
continue
|
||||
}
|
||||
numUnlocked++
|
||||
for i := range p.AllowedIPs().LenIter() { // not only addresses!
|
||||
for i := range p.AllowedIPs().Len() { // not only addresses!
|
||||
b.AddPrefix(p.AllowedIPs().At(i))
|
||||
}
|
||||
}
|
||||
@@ -3639,14 +3640,14 @@ func dnsConfigForNetmap(nm *netmap.NetworkMap, peers map[tailcfg.NodeID]tailcfg.
|
||||
return // TODO: propagate error?
|
||||
}
|
||||
var have4 bool
|
||||
for i := range addrs.LenIter() {
|
||||
for i := range addrs.Len() {
|
||||
if addrs.At(i).Addr().Is4() {
|
||||
have4 = true
|
||||
break
|
||||
}
|
||||
}
|
||||
var ips []netip.Addr
|
||||
for i := range addrs.LenIter() {
|
||||
for i := range addrs.Len() {
|
||||
addr := addrs.At(i)
|
||||
if selfV6Only {
|
||||
if addr.Addr().Is6() {
|
||||
@@ -3935,7 +3936,7 @@ func (b *LocalBackend) initPeerAPIListener() {
|
||||
b.peerAPIServer = ps
|
||||
|
||||
isNetstack := b.sys.IsNetstack()
|
||||
for i := range addrs.LenIter() {
|
||||
for i := range addrs.Len() {
|
||||
a := addrs.At(i)
|
||||
var ln net.Listener
|
||||
var err error
|
||||
@@ -4249,7 +4250,7 @@ func (b *LocalBackend) enterStateLockedOnEntry(newState ipn.State) {
|
||||
case ipn.Running:
|
||||
var addrStrs []string
|
||||
addrs := netMap.GetAddresses()
|
||||
for i := range addrs.LenIter() {
|
||||
for i := range addrs.Len() {
|
||||
addrStrs = append(addrStrs, addrs.At(i).Addr().String())
|
||||
}
|
||||
systemd.Status("Connected; %s; %s", activeLogin, strings.Join(addrStrs, " "))
|
||||
@@ -4625,7 +4626,7 @@ func (b *LocalBackend) setNetMapLocked(nm *netmap.NetworkMap) {
|
||||
b.nodeByAddr[k] = 0
|
||||
}
|
||||
addNode := func(n tailcfg.NodeView) {
|
||||
for i := range n.Addresses().LenIter() {
|
||||
for i := range n.Addresses().Len() {
|
||||
if ipp := n.Addresses().At(i); ipp.IsSingleIP() {
|
||||
b.nodeByAddr[ipp.Addr()] = n.ID()
|
||||
}
|
||||
@@ -5061,7 +5062,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.LenIter() {
|
||||
for i := range svcs.Len() {
|
||||
s := svcs.At(i)
|
||||
switch s.Proto {
|
||||
case tailcfg.PeerAPI4:
|
||||
@@ -5094,7 +5095,7 @@ func peerAPIBase(nm *netmap.NetworkMap, peer tailcfg.NodeView) string {
|
||||
|
||||
var have4, have6 bool
|
||||
addrs := nm.GetAddresses()
|
||||
for i := range addrs.LenIter() {
|
||||
for i := range addrs.Len() {
|
||||
a := addrs.At(i)
|
||||
if !a.IsSingleIP() {
|
||||
continue
|
||||
@@ -5117,7 +5118,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().LenIter() {
|
||||
for i := range n.Addresses().Len() {
|
||||
a := n.Addresses().At(i)
|
||||
if a.IsSingleIP() && pred(a.Addr()) {
|
||||
return a.Addr()
|
||||
@@ -5295,7 +5296,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.LenIter() {
|
||||
for i := range resolvers.Len() {
|
||||
copies[i] = resolvers.At(i).AsStruct()
|
||||
}
|
||||
return copies, true
|
||||
@@ -5318,7 +5319,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.LenIter() {
|
||||
for i := range services.Len() {
|
||||
if s := services.At(i); s.Proto == tailcfg.PeerAPIDNS && s.Port >= 1 {
|
||||
return true
|
||||
}
|
||||
@@ -5494,7 +5495,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.LenIter() {
|
||||
for i := range addrs.Len() {
|
||||
fmt.Fprintf(w, "<li>%v</li>\n", addrs.At(i).Addr())
|
||||
}
|
||||
io.WriteString(w, "</ul>\n")
|
||||
@@ -5511,6 +5512,7 @@ func (b *LocalBackend) Doctor(ctx context.Context, logf logger.Logf) {
|
||||
checks = append(checks,
|
||||
permissions.Check{},
|
||||
routetable.Check{},
|
||||
ethtool.Check{},
|
||||
)
|
||||
|
||||
// Print a log message if any of the global DNS resolvers are Tailscale
|
||||
|
||||
@@ -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().LenIter() {
|
||||
for i := range p.Addresses().Len() {
|
||||
addr := p.Addresses().At(i)
|
||||
if addr.IsSingleIP() && tsaddr.IsTailscaleIP(addr.Addr()) {
|
||||
fp.TailscaleIPs[i] = addr.Addr()
|
||||
|
||||
@@ -351,6 +351,7 @@ func (h *peerAPIHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
case "/v0/doctor":
|
||||
h.handleServeDoctor(w, r)
|
||||
return
|
||||
case "/v0/sockstats":
|
||||
h.handleServeSockStats(w, r)
|
||||
return
|
||||
|
||||
@@ -222,7 +222,7 @@ func (b *LocalBackend) updateServeTCPPortNetMapAddrListenersLocked(ports []uint1
|
||||
}
|
||||
|
||||
addrs := nm.GetAddresses()
|
||||
for i := range addrs.LenIter() {
|
||||
for i := range addrs.Len() {
|
||||
a := addrs.At(i)
|
||||
for _, p := range ports {
|
||||
addrPort := netip.AddrPortFrom(a.Addr(), p)
|
||||
|
||||
@@ -121,7 +121,7 @@ func (b *LocalBackend) updateWebClientListenersLocked() {
|
||||
}
|
||||
|
||||
addrs := b.netMap.GetAddresses()
|
||||
for i := range addrs.LenIter() {
|
||||
for i := range addrs.Len() {
|
||||
addrPort := netip.AddrPortFrom(addrs.At(i).Addr(), webClientPort)
|
||||
if _, ok := b.webClientListeners[addrPort]; ok {
|
||||
continue // already listening
|
||||
|
||||
@@ -769,6 +769,11 @@ func NewLogtailTransport(host string, netMon *netmon.Monitor, logf logger.Logf)
|
||||
}
|
||||
tr.DialContext = MakeDialFunc(netMon, logf)
|
||||
|
||||
// We're uploading logs ideally infrequently, with specific timing that will
|
||||
// change over time. Try to keep the connection open, to avoid repeatedly
|
||||
// paying the cost of TLS setup.
|
||||
tr.IdleConnTimeout = time.Hour
|
||||
|
||||
// We're contacting exactly 1 hostname, so the default's 100
|
||||
// max idle conns is very high for our needs. Even 2 is
|
||||
// probably double what we need:
|
||||
|
||||
@@ -16,11 +16,13 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"tailscale.com/net/dns/resolvconffile"
|
||||
"tailscale.com/net/tsaddr"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/util/dnsname"
|
||||
"tailscale.com/version/distro"
|
||||
@@ -371,7 +373,33 @@ func (m *directManager) GetBaseConfig() (OSConfig, error) {
|
||||
fileToRead = backupConf
|
||||
}
|
||||
|
||||
return m.readResolvFile(fileToRead)
|
||||
oscfg, err := m.readResolvFile(fileToRead)
|
||||
if err != nil {
|
||||
return OSConfig{}, err
|
||||
}
|
||||
|
||||
// On some systems, the backup configuration file is actually a
|
||||
// symbolic link to something owned by another DNS service (commonly,
|
||||
// resolved). Thus, it can be updated out from underneath us to contain
|
||||
// the Tailscale service IP, which results in an infinite loop of us
|
||||
// trying to send traffic to resolved, which sends back to us, and so
|
||||
// on. To solve this, drop the Tailscale service IP from the base
|
||||
// configuration; we do this in all situations since there's
|
||||
// essentially no world where we want to forward to ourselves.
|
||||
//
|
||||
// See: https://github.com/tailscale/tailscale/issues/7816
|
||||
var removed bool
|
||||
oscfg.Nameservers = slices.DeleteFunc(oscfg.Nameservers, func(ip netip.Addr) bool {
|
||||
if ip == tsaddr.TailscaleServiceIP() || ip == tsaddr.TailscaleServiceIPv6() {
|
||||
removed = true
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
if removed {
|
||||
m.logf("[v1] dropped Tailscale IP from base config that was a symlink")
|
||||
}
|
||||
return oscfg, nil
|
||||
}
|
||||
|
||||
func (m *directManager) Close() error {
|
||||
|
||||
@@ -203,6 +203,9 @@ 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")
|
||||
|
||||
@@ -405,6 +405,9 @@ 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)
|
||||
|
||||
@@ -21,8 +21,9 @@ func UpdateLastKnownDefaultRouteInterface(ifName string) {
|
||||
if ifName == "" {
|
||||
return
|
||||
}
|
||||
lastKnownDefaultRouteIfName.Store(ifName)
|
||||
log.Printf("defaultroute_ios: update from Swift, ifName = %s", ifName)
|
||||
if old := lastKnownDefaultRouteIfName.Swap(ifName); old != ifName {
|
||||
log.Printf("defaultroute_ios: update from Swift, ifName = %s (was %s)", ifName, old)
|
||||
}
|
||||
}
|
||||
|
||||
func defaultRoute() (d DefaultRouteDetails, err error) {
|
||||
@@ -56,13 +57,13 @@ func defaultRoute() (d DefaultRouteDetails, err error) {
|
||||
}
|
||||
|
||||
if !ifc.IsUp() {
|
||||
log.Println("defaultroute_ios: %s is down", name)
|
||||
log.Printf("defaultroute_ios: %s is down", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
addrs, _ := ifc.Addrs()
|
||||
if len(addrs) == 0 {
|
||||
log.Println("defaultroute_ios: %s has no addresses", name)
|
||||
log.Printf("defaultroute_ios: %s has no addresses", name)
|
||||
return nil
|
||||
}
|
||||
return &ifc
|
||||
@@ -76,7 +77,6 @@ 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
|
||||
|
||||
36
net/ktimeout/ktimeout.go
Normal file
36
net/ktimeout/ktimeout.go
Normal file
@@ -0,0 +1,36 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
15
net/ktimeout/ktimeout_default.go
Normal file
15
net/ktimeout/ktimeout_default.go
Normal file
@@ -0,0 +1,15 @@
|
||||
// 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
|
||||
}
|
||||
15
net/ktimeout/ktimeout_linux.go
Normal file
15
net/ktimeout/ktimeout_linux.go
Normal file
@@ -0,0 +1,15 @@
|
||||
// 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))
|
||||
}
|
||||
46
net/ktimeout/ktimeout_linux_test.go
Normal file
46
net/ktimeout/ktimeout_linux_test.go
Normal file
@@ -0,0 +1,46 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
24
net/ktimeout/ktimeout_test.go
Normal file
24
net/ktimeout/ktimeout_test.go
Normal file
@@ -0,0 +1,24 @@
|
||||
// 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:
|
||||
}
|
||||
@@ -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.LenIter() {
|
||||
for i := range addrs.Len() {
|
||||
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.LenIter() {
|
||||
for i := range rr.Len() {
|
||||
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.LenIter() {
|
||||
for i := range rr.Len() {
|
||||
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.LenIter() {
|
||||
for i := range in.Len() {
|
||||
if v := in.At(i); f(v) {
|
||||
out = append(out, v)
|
||||
}
|
||||
|
||||
@@ -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.LenIter() {
|
||||
for i := range addrs.Len() {
|
||||
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().LenIter() {
|
||||
for i := range p.Addresses().Len() {
|
||||
a := p.Addresses().At(i)
|
||||
ip := a.Addr()
|
||||
if ip.Is4() && !have4 {
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/http/httpproxy"
|
||||
"tailscale.com/util/mak"
|
||||
)
|
||||
|
||||
// InvalidateCache invalidates the package-level cache for ProxyFromEnvironment.
|
||||
@@ -117,10 +118,33 @@ 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) (*url.URL, error) {
|
||||
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)
|
||||
}()
|
||||
|
||||
localProxyFunc := getProxyFunc()
|
||||
u, err := localProxyFunc(req.URL)
|
||||
if u != nil && err == nil {
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
@@ -23,25 +24,33 @@ const expiresSoon = 7 * 24 * time.Hour // 7 days from now
|
||||
|
||||
// TLS returns a Probe that healthchecks a TLS endpoint.
|
||||
//
|
||||
// The ProbeFunc connects to a hostname (host:port string), does a TLS
|
||||
// The ProbeFunc connects to a hostPort (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(hostname string) ProbeFunc {
|
||||
func TLS(hostPort string) ProbeFunc {
|
||||
return func(ctx context.Context) error {
|
||||
return probeTLS(ctx, hostname)
|
||||
certDomain, _, err := net.SplitHostPort(hostPort)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return probeTLS(ctx, certDomain, hostPort)
|
||||
}
|
||||
}
|
||||
|
||||
func probeTLS(ctx context.Context, hostname string) error {
|
||||
host, _, err := net.SplitHostPort(hostname)
|
||||
if err != nil {
|
||||
return err
|
||||
// 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())
|
||||
}
|
||||
}
|
||||
|
||||
dialer := &tls.Dialer{Config: &tls.Config{ServerName: host}}
|
||||
conn, err := dialer.DialContext(ctx, "tcp", hostname)
|
||||
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)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connecting to %q: %w", hostname, err)
|
||||
return fmt.Errorf("connecting to %q: %w", dialHostPort, err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ func TestTLSConnection(t *testing.T) {
|
||||
srv.StartTLS()
|
||||
defer srv.Close()
|
||||
|
||||
err = probeTLS(context.Background(), srv.Listener.Addr().String())
|
||||
err = probeTLS(context.Background(), "fail.example.com", 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'.
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
|
||||
package tailcfg
|
||||
|
||||
import "sort"
|
||||
import (
|
||||
"net/netip"
|
||||
"sort"
|
||||
|
||||
"tailscale.com/types/key"
|
||||
)
|
||||
|
||||
// DERPMap describes the set of DERP packet relay servers that are available.
|
||||
type DERPMap struct {
|
||||
@@ -176,3 +181,17 @@ 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?
|
||||
}
|
||||
|
||||
@@ -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/tailscale/gowebdav": "https://github.com/tailscale/tailscale/pull/10631",
|
||||
"github.com/studio-b12/gowebdav": "https://github.com/tailscale/tailscale/pull/10631",
|
||||
},
|
||||
}.Check(t)
|
||||
}
|
||||
|
||||
233
tailfs/tailfsimpl/compositedav/compositedav.go
Normal file
233
tailfs/tailfsimpl/compositedav/compositedav.go
Normal file
@@ -0,0 +1,233 @@
|
||||
// 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
|
||||
}
|
||||
84
tailfs/tailfsimpl/compositedav/propfind.go
Normal file
84
tailfs/tailfsimpl/compositedav/propfind.go
Normal file
@@ -0,0 +1,84 @@
|
||||
// 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)
|
||||
}
|
||||
92
tailfs/tailfsimpl/compositedav/stat_cache.go
Normal file
92
tailfs/tailfsimpl/compositedav/stat_cache.go
Normal file
@@ -0,0 +1,92 @@
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
75
tailfs/tailfsimpl/compositedav/stat_cache_test.go
Normal file
75
tailfs/tailfsimpl/compositedav/stat_cache_test.go
Normal file
@@ -0,0 +1,75 @@
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,497 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
101
tailfs/tailfsimpl/dirfs/dirfs.go
Normal file
101
tailfs/tailfsimpl/dirfs/dirfs.go
Normal file
@@ -0,0 +1,101 @@
|
||||
// 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)
|
||||
}
|
||||
348
tailfs/tailfsimpl/dirfs/dirfs_test.go
Normal file
348
tailfs/tailfsimpl/dirfs/dirfs_test.go
Normal file
@@ -0,0 +1,348 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
30
tailfs/tailfsimpl/dirfs/mkdir.go
Normal file
30
tailfs/tailfsimpl/dirfs/mkdir.go
Normal file
@@ -0,0 +1,30 @@
|
||||
// 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}
|
||||
}
|
||||
63
tailfs/tailfsimpl/dirfs/openfile.go
Normal file
63
tailfs/tailfsimpl/dirfs/openfile.go
Normal file
@@ -0,0 +1,63 @@
|
||||
// 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
|
||||
}
|
||||
15
tailfs/tailfsimpl/dirfs/removeall.go
Normal file
15
tailfs/tailfsimpl/dirfs/removeall.go
Normal file
@@ -0,0 +1,15 @@
|
||||
// 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}
|
||||
}
|
||||
15
tailfs/tailfsimpl/dirfs/rename.go
Normal file
15
tailfs/tailfsimpl/dirfs/rename.go
Normal file
@@ -0,0 +1,15 @@
|
||||
// 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}
|
||||
}
|
||||
30
tailfs/tailfsimpl/dirfs/stat.go
Normal file
30
tailfs/tailfsimpl/dirfs/stat.go
Normal file
@@ -0,0 +1,30 @@
|
||||
// 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
|
||||
}
|
||||
@@ -10,10 +10,9 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/tailscale/xnet/webdav"
|
||||
"tailscale.com/tailfs"
|
||||
"tailscale.com/tailfs/tailfsimpl/compositefs"
|
||||
"tailscale.com/tailfs/tailfsimpl/webdavfs"
|
||||
"tailscale.com/tailfs/tailfsimpl/compositedav"
|
||||
"tailscale.com/tailfs/tailfsimpl/dirfs"
|
||||
"tailscale.com/types/logger"
|
||||
)
|
||||
|
||||
@@ -32,8 +31,11 @@ func NewFileSystemForLocal(logf logger.Logf) *FileSystemForLocal {
|
||||
logf = log.Printf
|
||||
}
|
||||
fs := &FileSystemForLocal{
|
||||
logf: logf,
|
||||
cfs: compositefs.New(compositefs.Options{Logf: logf}),
|
||||
logf: logf,
|
||||
h: &compositedav.Handler{
|
||||
Logf: logf,
|
||||
StatCache: &compositedav.StatCache{TTL: statCacheTTL},
|
||||
},
|
||||
listener: newConnListener(),
|
||||
}
|
||||
fs.startServing()
|
||||
@@ -44,17 +46,12 @@ func NewFileSystemForLocal(logf logger.Logf) *FileSystemForLocal {
|
||||
// provides a unified WebDAV interface to remote TailFS shares on other nodes.
|
||||
type FileSystemForLocal struct {
|
||||
logf logger.Logf
|
||||
cfs *compositefs.CompositeFileSystem
|
||||
h *compositedav.Handler
|
||||
listener *connListener
|
||||
}
|
||||
|
||||
func (s *FileSystemForLocal) startServing() {
|
||||
hs := &http.Server{
|
||||
Handler: &webdav.Handler{
|
||||
FileSystem: s.cfs,
|
||||
LockSystem: webdav.NewMemLS(),
|
||||
},
|
||||
}
|
||||
hs := &http.Server{Handler: s.h}
|
||||
go func() {
|
||||
err := hs.Serve(s.listener)
|
||||
if err != nil {
|
||||
@@ -73,31 +70,24 @@ 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([]*compositefs.Child, 0, len(remotes))
|
||||
children := make([]*compositedav.Child, 0, len(remotes))
|
||||
for _, remote := range remotes {
|
||||
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,
|
||||
children = append(children, &compositedav.Child{
|
||||
Child: &dirfs.Child{
|
||||
Name: remote.Name,
|
||||
Available: remote.Available,
|
||||
},
|
||||
BaseURL: remote.URL,
|
||||
Transport: transport,
|
||||
})
|
||||
}
|
||||
|
||||
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...)
|
||||
s.h.SetChildren(domain, children...)
|
||||
}
|
||||
|
||||
// Close() stops serving the WebDAV content
|
||||
func (s *FileSystemForLocal) Close() error {
|
||||
s.cfs.Close()
|
||||
return s.listener.Close()
|
||||
err := s.listener.Close()
|
||||
s.h.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
@@ -21,9 +22,9 @@ import (
|
||||
"github.com/tailscale/xnet/webdav"
|
||||
"tailscale.com/safesocket"
|
||||
"tailscale.com/tailfs"
|
||||
"tailscale.com/tailfs/tailfsimpl/compositefs"
|
||||
"tailscale.com/tailfs/tailfsimpl/compositedav"
|
||||
"tailscale.com/tailfs/tailfsimpl/dirfs"
|
||||
"tailscale.com/tailfs/tailfsimpl/shared"
|
||||
"tailscale.com/tailfs/tailfsimpl/webdavfs"
|
||||
"tailscale.com/types/logger"
|
||||
)
|
||||
|
||||
@@ -34,7 +35,7 @@ func NewFileSystemForRemote(logf logger.Logf) *FileSystemForRemote {
|
||||
fs := &FileSystemForRemote{
|
||||
logf: logf,
|
||||
lockSystem: webdav.NewMemLS(),
|
||||
fileSystems: make(map[string]webdav.FileSystem),
|
||||
children: make(map[string]*compositedav.Child),
|
||||
userServers: make(map[string]*userServer),
|
||||
}
|
||||
return fs
|
||||
@@ -50,7 +51,7 @@ type FileSystemForRemote struct {
|
||||
mu sync.RWMutex
|
||||
fileServerAddr string
|
||||
shares map[string]*tailfs.Share
|
||||
fileSystems map[string]webdav.FileSystem
|
||||
children map[string]*compositedav.Child
|
||||
userServers map[string]*userServer
|
||||
}
|
||||
|
||||
@@ -81,27 +82,29 @@ func (s *FileSystemForRemote) SetShares(shares map[string]*tailfs.Share) {
|
||||
}
|
||||
}
|
||||
|
||||
fileSystems := make(map[string]webdav.FileSystem, len(shares))
|
||||
children := make(map[string]*compositedav.Child, len(shares))
|
||||
for _, share := range shares {
|
||||
fileSystems[share.Name] = s.buildWebDAVFS(share)
|
||||
children[share.Name] = s.buildChild(share)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.shares = shares
|
||||
oldFileSystems := s.fileSystems
|
||||
oldUserServers := s.userServers
|
||||
s.fileSystems = fileSystems
|
||||
oldChildren := s.children
|
||||
s.children = children
|
||||
s.userServers = userServers
|
||||
s.mu.Unlock()
|
||||
|
||||
s.stopUserServers(oldUserServers)
|
||||
s.closeFileSystems(oldFileSystems)
|
||||
s.closeChildren(oldChildren)
|
||||
}
|
||||
|
||||
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),
|
||||
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)),
|
||||
Transport: &http.Transport{
|
||||
Dial: func(_, shareAddr string) (net.Conn, error) {
|
||||
shareNameHex, _, err := net.SplitHostPort(shareAddr)
|
||||
@@ -151,8 +154,7 @@ func (s *FileSystemForRemote) buildWebDAVFS(share *tailfs.Share) webdav.FileSyst
|
||||
return safesocket.Connect(addr)
|
||||
},
|
||||
},
|
||||
StatRoot: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ServeHTTPWithPerms implements tailfs.FileSystemForRemote.
|
||||
@@ -173,29 +175,23 @@ func (s *FileSystemForRemote) ServeHTTPWithPerms(permissions tailfs.Permissions,
|
||||
}
|
||||
|
||||
s.mu.RLock()
|
||||
fileSystems := s.fileSystems
|
||||
childrenMap := s.children
|
||||
s.mu.RUnlock()
|
||||
|
||||
children := make([]*compositefs.Child, 0, len(fileSystems))
|
||||
children := make([]*compositedav.Child, 0, len(childrenMap))
|
||||
// filter out shares to which the connecting principal has no access
|
||||
for name, fs := range fileSystems {
|
||||
for name, child := range childrenMap {
|
||||
if permissions.For(name) == tailfs.PermissionNone {
|
||||
continue
|
||||
}
|
||||
|
||||
children = append(children, &compositefs.Child{Name: name, FS: fs})
|
||||
children = append(children, child)
|
||||
}
|
||||
|
||||
cfs := compositefs.New(
|
||||
compositefs.Options{
|
||||
Logf: s.logf,
|
||||
StatChildren: true,
|
||||
})
|
||||
cfs.SetChildren(children...)
|
||||
h := webdav.Handler{
|
||||
FileSystem: cfs,
|
||||
LockSystem: s.lockSystem,
|
||||
h := compositedav.Handler{
|
||||
Logf: s.logf,
|
||||
}
|
||||
h.SetChildren("", children...)
|
||||
h.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
@@ -207,14 +203,9 @@ func (s *FileSystemForRemote) stopUserServers(userServers map[string]*userServer
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
func (s *FileSystemForRemote) closeChildren(children map[string]*compositedav.Child) {
|
||||
for _, child := range children {
|
||||
child.CloseIdleConnections()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,11 +213,13 @@ func (s *FileSystemForRemote) closeFileSystems(fileSystems map[string]webdav.Fil
|
||||
func (s *FileSystemForRemote) Close() error {
|
||||
s.mu.Lock()
|
||||
userServers := s.userServers
|
||||
fileSystems := s.fileSystems
|
||||
children := s.children
|
||||
s.userServers = make(map[string]*userServer)
|
||||
s.children = make(map[string]*compositedav.Child)
|
||||
s.mu.Unlock()
|
||||
|
||||
s.stopUserServers(userServers)
|
||||
s.closeFileSystems(fileSystems)
|
||||
s.closeChildren(children)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -40,3 +40,11 @@ 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)
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ func RenamedFileInfo(ctx context.Context, name string, fi fs.FileInfo) *StaticFi
|
||||
}
|
||||
|
||||
return &StaticFileInfo{
|
||||
Named: name,
|
||||
Named: Base(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: name,
|
||||
Named: Base(name),
|
||||
Sized: 0,
|
||||
Moded: 0555,
|
||||
BirthedTime: ts,
|
||||
|
||||
@@ -4,9 +4,7 @@
|
||||
package tailfsimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net"
|
||||
@@ -19,10 +17,9 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/tailscale/xnet/webdav"
|
||||
"github.com/studio-b12/gowebdav"
|
||||
"tailscale.com/tailfs"
|
||||
"tailscale.com/tailfs/tailfsimpl/shared"
|
||||
"tailscale.com/tailfs/tailfsimpl/webdavfs"
|
||||
"tailscale.com/tstest"
|
||||
)
|
||||
|
||||
@@ -46,24 +43,28 @@ 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.checkDirListIncremental("remote with two shares should contain both in lexicographical order even when reading directory incrementally", 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.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)
|
||||
s.checkDirList("directory listing for offline remote should return empty list", shared.Join(domain, remote1))
|
||||
_, err := s.client.ReadDir(shared.Join(domain, remote1))
|
||||
if err == nil {
|
||||
t.Error("directory listing for offline remote should fail")
|
||||
}
|
||||
s.unfreezeRemote(remote1)
|
||||
|
||||
s.checkDirList("attempt at lateral traversal should simply list shares", shared.Join(domain, remote1, share11, ".."), share12, share11)
|
||||
@@ -71,7 +72,6 @@ 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,178 +86,6 @@ 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
|
||||
@@ -289,7 +117,7 @@ func (r *remote) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
type system struct {
|
||||
t *testing.T
|
||||
local *local
|
||||
fs webdav.FileSystem
|
||||
client *gowebdav.Client
|
||||
remotes map[string]*remote
|
||||
}
|
||||
|
||||
@@ -314,15 +142,16 @@ func newSystem(t *testing.T) *system {
|
||||
}
|
||||
}()
|
||||
|
||||
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},
|
||||
}),
|
||||
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,
|
||||
remotes: make(map[string]*remote),
|
||||
}
|
||||
t.Cleanup(s.stop)
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *system) addRemote(name string) {
|
||||
@@ -357,7 +186,13 @@ func (s *system) addRemote(name string) {
|
||||
URL: fmt.Sprintf("http://%s", r.l.Addr()),
|
||||
})
|
||||
}
|
||||
s.local.fs.SetRemotes(domain, remotes, &http.Transport{})
|
||||
s.local.fs.SetRemotes(
|
||||
domain,
|
||||
remotes,
|
||||
&http.Transport{
|
||||
DisableKeepAlives: true,
|
||||
ResponseHeaderTimeout: 5 * time.Second,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *system) addShare(remoteName, shareName string, permission tailfs.Permission) {
|
||||
@@ -399,26 +234,11 @@ func (s *system) unfreezeRemote(remoteName string) {
|
||||
|
||||
func (s *system) writeFile(label, remoteName, shareName, name, contents string, expectSuccess bool) {
|
||||
path := pathTo(remoteName, shareName, name)
|
||||
file, err := s.fs.OpenFile(context.Background(), path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
|
||||
err := s.client.Write(path, []byte(contents), 0644)
|
||||
if expectSuccess && err != nil {
|
||||
s.t.Fatalf("%v: expected success writing file %q, but got error %v", label, path, err)
|
||||
}
|
||||
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)
|
||||
} else if !expectSuccess && err == nil {
|
||||
s.t.Fatalf("%v: expected error writing file %q", label, path)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -437,12 +257,7 @@ func (s *system) checkFileContents(remoteName, shareName, name string) {
|
||||
}
|
||||
|
||||
func (s *system) checkDirList(label string, path string, want ...string) {
|
||||
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)
|
||||
got, err := s.client.ReadDir(path)
|
||||
if err != nil {
|
||||
s.t.Fatalf("failed to Readdir: %s", err)
|
||||
}
|
||||
@@ -460,35 +275,6 @@ 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)
|
||||
@@ -501,7 +287,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.fs.Stat(context.Background(), path)
|
||||
fi, err := s.client.Stat(path)
|
||||
if err != nil {
|
||||
s.t.Fatalf("failed to Stat: %s", err)
|
||||
}
|
||||
@@ -521,17 +307,10 @@ func (s *system) read(remoteName, shareName, name string) string {
|
||||
|
||||
func (s *system) readViaWebDAV(remoteName, shareName, name string) string {
|
||||
path := pathTo(remoteName, shareName, name)
|
||||
file, err := s.fs.OpenFile(context.Background(), path, os.O_RDONLY, 0)
|
||||
b, err := s.client.Read(path)
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
// 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()
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
// 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()
|
||||
}
|
||||
@@ -1,256 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -128,6 +128,12 @@ func autoflagsForTest(argv []string, env *Environment, goroot, nativeGOOS, nativ
|
||||
// Minimum OS version being targeted, results in
|
||||
// e.g. -mmacosx-version-min=11.3, -miphoneos-version-min=15.0
|
||||
switch {
|
||||
case env.IsSet("XROS_DEPLOYMENT_TARGET"):
|
||||
if env.Get("TARGET_DEVICE_PLATFORM_NAME", "") == "xrsimulator" {
|
||||
xcodeFlags = append(xcodeFlags, "-mtargetos=xros"+env.Get("XROS_DEPLOYMENT_TARGET", "")+"-simulator")
|
||||
} else {
|
||||
xcodeFlags = append(xcodeFlags, "-mtargetos=xros"+env.Get("XROS_DEPLOYMENT_TARGET", ""))
|
||||
}
|
||||
case env.IsSet("IPHONEOS_DEPLOYMENT_TARGET"):
|
||||
if env.Get("TARGET_DEVICE_PLATFORM_NAME", "") == "iphonesimulator" {
|
||||
xcodeFlags = append(xcodeFlags, "-miphonesimulator-version-min="+env.Get("IPHONEOS_DEPLOYMENT_TARGET", ""))
|
||||
|
||||
@@ -428,7 +428,7 @@ func (s *Server) TailscaleIPs() (ip4, ip6 netip.Addr) {
|
||||
return
|
||||
}
|
||||
addrs := nm.GetAddresses()
|
||||
for i := range addrs.LenIter() {
|
||||
for i := range addrs.Len() {
|
||||
addr := addrs.At(i)
|
||||
ip := addr.Addr()
|
||||
if ip.Is6() {
|
||||
|
||||
@@ -77,11 +77,6 @@ func (v ByteSlice[T]) AppendTo(dst T) T {
|
||||
return append(dst, v.ж...)
|
||||
}
|
||||
|
||||
// LenIter returns a slice the same length as the v.Len().
|
||||
// The caller can then range over it to get the valid indexes.
|
||||
// It does not allocate.
|
||||
func (v ByteSlice[T]) LenIter() []struct{} { return make([]struct{}, len(v.ж)) }
|
||||
|
||||
// At returns the byte at index `i` of the slice.
|
||||
func (v ByteSlice[T]) At(i int) byte { return v.ж[i] }
|
||||
|
||||
@@ -154,11 +149,6 @@ func (v SliceView[T, V]) IsNil() bool { return v.ж == nil }
|
||||
// Len returns the length of the slice.
|
||||
func (v SliceView[T, V]) Len() int { return len(v.ж) }
|
||||
|
||||
// LenIter returns a slice the same length as the v.Len().
|
||||
// The caller can then range over it to get the valid indexes.
|
||||
// It does not allocate.
|
||||
func (v SliceView[T, V]) LenIter() []struct{} { return make([]struct{}, len(v.ж)) }
|
||||
|
||||
// At returns a View of the element at index `i` of the slice.
|
||||
func (v SliceView[T, V]) At(i int) V { return v.ж[i].View() }
|
||||
|
||||
@@ -245,11 +235,6 @@ func (v Slice[T]) IsNil() bool { return v.ж == nil }
|
||||
// Len returns the length of the slice.
|
||||
func (v Slice[T]) Len() int { return len(v.ж) }
|
||||
|
||||
// LenIter returns a slice the same length as the v.Len().
|
||||
// The caller can then range over it to get the valid indexes.
|
||||
// It does not allocate.
|
||||
func (v Slice[T]) LenIter() []struct{} { return make([]struct{}, len(v.ж)) }
|
||||
|
||||
// At returns the element at index `i` of the slice.
|
||||
func (v Slice[T]) At(i int) T { return v.ж[i] }
|
||||
|
||||
|
||||
@@ -141,27 +141,6 @@ func TestViewUtils(t *testing.T) {
|
||||
qt.Equals, true)
|
||||
}
|
||||
|
||||
func TestLenIter(t *testing.T) {
|
||||
orig := []string{"foo", "bar"}
|
||||
var got []string
|
||||
v := SliceOf(orig)
|
||||
for i := range v.LenIter() {
|
||||
got = append(got, v.At(i))
|
||||
}
|
||||
if !reflect.DeepEqual(orig, got) {
|
||||
t.Errorf("got %q; want %q", got, orig)
|
||||
}
|
||||
x := 0
|
||||
n := testing.AllocsPerRun(10000, func() {
|
||||
for range v.LenIter() {
|
||||
x++
|
||||
}
|
||||
})
|
||||
if n > 0 {
|
||||
t.Errorf("allocs = %v; want 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSliceEqual(t *testing.T) {
|
||||
a := SliceOf([]string{"foo", "bar"})
|
||||
b := SliceOf([]string{"foo", "bar"})
|
||||
|
||||
@@ -42,10 +42,11 @@ const (
|
||||
// The default is "show" unless otherwise stated. Enforcement of these
|
||||
// policies is typically performed by the UI code for the relevant operating
|
||||
// system.
|
||||
AdminConsoleVisibility Key = "AdminConsole"
|
||||
NetworkDevicesVisibility Key = "NetworkDevices"
|
||||
TestMenuVisibility Key = "TestMenu"
|
||||
UpdateMenuVisibility Key = "UpdateMenu"
|
||||
AdminConsoleVisibility Key = "AdminConsole"
|
||||
NetworkDevicesVisibility Key = "NetworkDevices"
|
||||
TestMenuVisibility Key = "TestMenu"
|
||||
UpdateMenuVisibility Key = "UpdateMenu"
|
||||
ResetToDefaultsVisibility Key = "ResetToDefaults"
|
||||
// RunExitNodeVisibility controls if the "run as exit node" menu item is
|
||||
// visible, without controlling the setting itself. This is preserved for
|
||||
// backwards compatibility but prefer EnableRunExitNode in new deployments.
|
||||
@@ -72,4 +73,15 @@ const (
|
||||
// Key is a string value that specifies an option: "always", "never", "user-decides".
|
||||
// The default is "user-decides" unless otherwise stated.
|
||||
PostureChecking Key = "PostureChecking"
|
||||
|
||||
// ManagedByOrganizationName indicates the name of the organization managing the Tailscale
|
||||
// install. It is displayed inside the client UI in a prominent location.
|
||||
ManagedByOrganizationName Key = "ManagedByOrganizationName"
|
||||
// ManagedByCaption is an info message displayed inside the client UI as a caption when
|
||||
// ManagedByOrganizationName is set. It can be used to provide a pointer to support resources
|
||||
// for Tailscale within the organization.
|
||||
ManagedByCaption Key = "ManagedByCaption"
|
||||
// ManagedByURL is a valid URL pointing to a support help desk for Tailscale within the
|
||||
// organization. A button in the client UI provides easy access to this URL.
|
||||
ManagedByURL Key = "ManagedByURL"
|
||||
)
|
||||
|
||||
@@ -22,8 +22,12 @@ var stringKeys = []Key{
|
||||
PreferencesMenuVisibility,
|
||||
ExitNodeMenuVisibility,
|
||||
AutoUpdateVisibility,
|
||||
ResetToDefaultsVisibility,
|
||||
KeyExpirationNoticeTime,
|
||||
PostureChecking,
|
||||
ManagedByOrganizationName,
|
||||
ManagedByCaption,
|
||||
ManagedByURL,
|
||||
}
|
||||
|
||||
var boolKeys = []Key{
|
||||
|
||||
@@ -102,7 +102,7 @@ func (c *Conn) ServeHTTPDebug(w http.ResponseWriter, r *http.Request) {
|
||||
sort.Slice(ent, func(i, j int) bool { return ent[i].pub.Less(ent[j].pub) })
|
||||
|
||||
peers := map[key.NodePublic]tailcfg.NodeView{}
|
||||
for i := range c.peers.LenIter() {
|
||||
for i := range c.peers.Len() {
|
||||
p := c.peers.At(i)
|
||||
peers[p.Key()] = p
|
||||
}
|
||||
|
||||
@@ -1366,7 +1366,7 @@ func (de *endpoint) updateFromNode(n tailcfg.NodeView, heartbeatDisabled bool, p
|
||||
}
|
||||
|
||||
func (de *endpoint) setEndpointsLocked(eps interface {
|
||||
LenIter() []struct{}
|
||||
Len() int
|
||||
At(i int) netip.AddrPort
|
||||
}) {
|
||||
for _, st := range de.endpointState {
|
||||
@@ -1374,7 +1374,7 @@ func (de *endpoint) setEndpointsLocked(eps interface {
|
||||
}
|
||||
|
||||
var newIpps []netip.AddrPort
|
||||
for i := range eps.LenIter() {
|
||||
for i := range eps.Len() {
|
||||
if i > math.MaxInt16 {
|
||||
// Seems unlikely.
|
||||
break
|
||||
|
||||
@@ -1793,7 +1793,7 @@ func nodesEqual(x, y views.Slice[tailcfg.NodeView]) bool {
|
||||
if x.Len() != y.Len() {
|
||||
return false
|
||||
}
|
||||
for i := range x.LenIter() {
|
||||
for i := range x.Len() {
|
||||
if !x.At(i).Equal(y.At(i)) {
|
||||
return false
|
||||
}
|
||||
@@ -2056,7 +2056,7 @@ func (c *Conn) logEndpointCreated(n tailcfg.NodeView) {
|
||||
fmt.Fprintf(w, "derp=%v%s ", regionID, code)
|
||||
}
|
||||
|
||||
for i := range n.AllowedIPs().LenIter() {
|
||||
for i := range n.AllowedIPs().Len() {
|
||||
a := n.AllowedIPs().At(i)
|
||||
if a.IsSingleIP() {
|
||||
fmt.Fprintf(w, "aip=%v ", a.Addr())
|
||||
@@ -2064,7 +2064,7 @@ func (c *Conn) logEndpointCreated(n tailcfg.NodeView) {
|
||||
fmt.Fprintf(w, "aip=%v ", a)
|
||||
}
|
||||
}
|
||||
for i := range n.Endpoints().LenIter() {
|
||||
for i := range n.Endpoints().Len() {
|
||||
ep := n.Endpoints().At(i)
|
||||
fmt.Fprintf(w, "ep=%v ", ep)
|
||||
}
|
||||
|
||||
129
wgengine/netstack/debug.go
Normal file
129
wgengine/netstack/debug.go
Normal file
@@ -0,0 +1,129 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package netstack
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
xmaps "golang.org/x/exp/maps"
|
||||
"gvisor.dev/gvisor/pkg/tcpip"
|
||||
)
|
||||
|
||||
var tcpForwarderTemplate = template.Must(template.New("").Parse(`
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
body { font-family: monospace; font-size: 12; }
|
||||
td { padding: 0.3em; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>TCP Forwarder</h1>
|
||||
|
||||
<h2>TCP Statistics</h2>
|
||||
<table border=1>
|
||||
<tr>
|
||||
<th>Metric</th>
|
||||
<th>Value</th>
|
||||
</tr>
|
||||
{{ range .Stats }}
|
||||
<tr><td>{{ .Key }}</td><td>{{ .Value }}</td></tr>
|
||||
{{ end }}
|
||||
</table>
|
||||
|
||||
<h2>In-Flight Outbound Connections</h2>
|
||||
<table border=1>
|
||||
<tr>
|
||||
<th>Start Time</th>
|
||||
<th>Client IP</th>
|
||||
<th>Remote IP</th>
|
||||
</tr>
|
||||
{{ range .InFlightDials }}
|
||||
<tr>
|
||||
<td>{{ .Start.Format "2006-01-02T15:04:05Z07:00" }} ({{ printf "%.2f" .DurationSecs }} seconds ago)</td>
|
||||
<td>{{ .ClientIP }}</td>
|
||||
<td>{{ .RemoteAddr }}</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`))
|
||||
|
||||
// DebugTCPForwarder writes debug information about this netstack
|
||||
// implementation's current TCP forwarder in HTML format.
|
||||
func (ns *Impl) DebugTCPForwarder(w http.ResponseWriter, r *http.Request) {
|
||||
// Grab data while holding the mutex
|
||||
ns.tcpDebugMu.Lock()
|
||||
tcpDials := xmaps.Values(ns.inFlightDials)
|
||||
ns.tcpDebugMu.Unlock()
|
||||
|
||||
slices.SortFunc(tcpDials, func(a, b tcpDialInfo) int {
|
||||
return a.start.Compare(b.start)
|
||||
})
|
||||
|
||||
type templateDataStats struct {
|
||||
Key string
|
||||
Value uint64
|
||||
}
|
||||
type templateDataDial struct {
|
||||
Start time.Time
|
||||
DurationSecs float64
|
||||
ClientIP netip.Addr
|
||||
RemoteAddr netip.AddrPort
|
||||
}
|
||||
type templateData struct {
|
||||
Stats []templateDataStats
|
||||
InFlightDials []templateDataDial
|
||||
}
|
||||
|
||||
var data templateData
|
||||
|
||||
// Statistics from gVisor
|
||||
tcpStats := ns.ipstack.Stats().TCP
|
||||
tcpMetrics := []struct {
|
||||
name string
|
||||
field *tcpip.StatCounter
|
||||
}{
|
||||
{"Active Connection Openings", tcpStats.ActiveConnectionOpenings},
|
||||
{"Passive Connection Openings", tcpStats.PassiveConnectionOpenings},
|
||||
{"Established Connections", tcpStats.CurrentEstablished},
|
||||
{"Connected Connections", tcpStats.CurrentConnected},
|
||||
{"Dropped In-Flight Forwarder Connections", tcpStats.ForwardMaxInFlightDrop},
|
||||
{"Established Resets", tcpStats.EstablishedResets},
|
||||
{"Established Timeout", tcpStats.EstablishedTimedout},
|
||||
{"Failed Connection Attempts", tcpStats.FailedConnectionAttempts},
|
||||
{"Retransmits", tcpStats.Retransmits},
|
||||
{"Timeouts", tcpStats.Timeouts},
|
||||
{"Checksum Errors", tcpStats.ChecksumErrors},
|
||||
{"Failed Port Reservations", tcpStats.FailedPortReservations},
|
||||
}
|
||||
for _, metric := range tcpMetrics {
|
||||
data.Stats = append(data.Stats, templateDataStats{
|
||||
Key: metric.name,
|
||||
Value: metric.field.Value(),
|
||||
})
|
||||
}
|
||||
|
||||
// Any in-flight DialContext calls in the TCP forwarding path.
|
||||
now := time.Now()
|
||||
for _, dial := range tcpDials {
|
||||
elapsed := now.Sub(dial.start)
|
||||
data.InFlightDials = append(data.InFlightDials, templateDataDial{
|
||||
Start: dial.start,
|
||||
DurationSecs: elapsed.Seconds(),
|
||||
ClientIP: dial.clientRemoteIP,
|
||||
RemoteAddr: dial.dialAddr,
|
||||
})
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
tcpForwarderTemplate.Execute(w, &data)
|
||||
}
|
||||
@@ -150,6 +150,18 @@ type Impl struct {
|
||||
// TCP connections, so they can be unregistered when connections are
|
||||
// closed.
|
||||
connsOpenBySubnetIP map[netip.Addr]int
|
||||
|
||||
// Debug information for the TCP forwarding code; all fields protected
|
||||
// by tcpDebugMu.
|
||||
tcpDebugMu sync.Mutex
|
||||
inFlightDialCtr int
|
||||
inFlightDials map[int]tcpDialInfo // keyed by a random integer
|
||||
}
|
||||
|
||||
type tcpDialInfo struct {
|
||||
clientRemoteIP netip.Addr
|
||||
dialAddr netip.AddrPort
|
||||
start time.Time
|
||||
}
|
||||
|
||||
const nicID = 1
|
||||
@@ -242,6 +254,7 @@ func Create(logf logger.Logf, tundev *tstun.Wrapper, e wgengine.Engine, mc *magi
|
||||
connsOpenBySubnetIP: make(map[netip.Addr]int),
|
||||
dns: dns,
|
||||
tailFSForLocal: tailFSForLocal,
|
||||
inFlightDials: make(map[int]tcpDialInfo),
|
||||
}
|
||||
ns.ctx, ns.ctxCancel = context.WithCancel(context.Background())
|
||||
ns.atomicIsLocalIPFunc.Store(tsaddr.FalseContainsIPFunc())
|
||||
@@ -366,12 +379,12 @@ func (ns *Impl) UpdateNetstackIPs(nm *netmap.NetworkMap) {
|
||||
newPfx := make(map[netip.Prefix]bool)
|
||||
|
||||
if selfNode.Valid() {
|
||||
for i := range selfNode.Addresses().LenIter() {
|
||||
for i := range selfNode.Addresses().Len() {
|
||||
p := selfNode.Addresses().At(i)
|
||||
newPfx[p] = true
|
||||
}
|
||||
if ns.ProcessSubnets {
|
||||
for i := range selfNode.AllowedIPs().LenIter() {
|
||||
for i := range selfNode.AllowedIPs().Len() {
|
||||
p := selfNode.AllowedIPs().At(i)
|
||||
newPfx[p] = true
|
||||
}
|
||||
@@ -981,6 +994,24 @@ func (ns *Impl) acceptTCP(r *tcp.ForwarderRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
func (ns *Impl) debugForwardedTCP(clientRemoteIP netip.Addr, remote netip.AddrPort) func() {
|
||||
ns.tcpDebugMu.Lock()
|
||||
debugKey := ns.inFlightDialCtr
|
||||
ns.inFlightDialCtr++
|
||||
ns.inFlightDials[debugKey] = tcpDialInfo{
|
||||
clientRemoteIP: clientRemoteIP,
|
||||
dialAddr: remote,
|
||||
start: time.Now(),
|
||||
}
|
||||
ns.tcpDebugMu.Unlock()
|
||||
|
||||
return func() {
|
||||
ns.tcpDebugMu.Lock()
|
||||
delete(ns.inFlightDials, debugKey)
|
||||
ns.tcpDebugMu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (ns *Impl) forwardTCP(getClient func(...tcpip.SettableSocketOption) *gonet.TCPConn, clientRemoteIP netip.Addr, wq *waiter.Queue, dialAddr netip.AddrPort) (handled bool) {
|
||||
dialAddrStr := dialAddr.String()
|
||||
if debugNetstack() {
|
||||
@@ -1008,9 +1039,13 @@ func (ns *Impl) forwardTCP(getClient func(...tcpip.SettableSocketOption) *gonet.
|
||||
cancel()
|
||||
}()
|
||||
|
||||
// Insert debug info, and remove it once we've dialed our outbound conn.
|
||||
debugDialDone := ns.debugForwardedTCP(clientRemoteIP, dialAddr)
|
||||
|
||||
// Attempt to dial the outbound connection before we accept the inbound one.
|
||||
var stdDialer net.Dialer
|
||||
server, err := stdDialer.DialContext(ctx, "tcp", dialAddrStr)
|
||||
debugDialDone()
|
||||
if err != nil {
|
||||
ns.logf("netstack: could not connect to local server at %s: %v", dialAddr.String(), err)
|
||||
return
|
||||
|
||||
@@ -160,7 +160,7 @@ func (e *userspaceEngine) onOpenTimeout(flow flowtrack.Tuple) {
|
||||
ps, found := e.getPeerStatusLite(n.Key())
|
||||
if !found {
|
||||
onlyZeroRoute := true // whether peerForIP returned n only because its /0 route matched
|
||||
for i := range n.AllowedIPs().LenIter() {
|
||||
for i := range n.AllowedIPs().Len() {
|
||||
r := n.AllowedIPs().At(i)
|
||||
if r.Bits() != 0 && r.Contains(flow.Dst.Addr()) {
|
||||
onlyZeroRoute = false
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user