mirror of
https://github.com/tailscale/tailscale.git
synced 2026-05-05 12:16:44 +02:00
drive: add taildrive "magic" share with name-encoded ACLs
Reserves the share name "magic" for a share whose top-level directory names encode their own ACL (e.g. "fserb+rhea"). Peers only see and can descend into top-level dirs that name both the sharer and themselves. Top-level dir creation/deletion via WebDAV is denied for remote peers; the directory layout is sharer-local only. New package drive/magic parses and matches the directory names. The peerapi handler plumbs the peer's and the local node's tailnet logins into a new drive.Authz value passed to ServeHTTPWithPerms. The CLI recognizes the reserved name and validates the path is a directory. Change-Id: If7b6ad9fdab46b99e7ac5a7c5417a57d61b44478
This commit is contained in:
parent
3a05c450ce
commit
02631749ba
@ -8,11 +8,13 @@ package cli
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"tailscale.com/drive"
|
||||
"tailscale.com/feature/buildfeatures"
|
||||
)
|
||||
|
||||
const (
|
||||
@ -80,10 +82,27 @@ func runDriveShare(ctx context.Context, args []string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
err = localClient.DriveShareSet(ctx, &drive.Share{
|
||||
share := &drive.Share{
|
||||
Name: name,
|
||||
Path: absolutePath,
|
||||
})
|
||||
}
|
||||
|
||||
// "magic" is a reserved share name (drive.MagicShareName). Magic shares
|
||||
// are treated specially server-side: each top-level directory inside the
|
||||
// share encodes its own ACL (see package drive/magic). The path must
|
||||
// already exist and be a directory; for non-magic shares we leave that
|
||||
// check to the backend.
|
||||
if buildfeatures.HasDriveMagic && share.IsMagic() {
|
||||
fi, err := os.Stat(absolutePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("magic share path: %w", err)
|
||||
}
|
||||
if !fi.IsDir() {
|
||||
return fmt.Errorf("magic share path %q is not a directory", absolutePath)
|
||||
}
|
||||
}
|
||||
|
||||
err = localClient.DriveShareSet(ctx, share)
|
||||
if err == nil {
|
||||
fmt.Printf("Sharing %q as %q\n", path, name)
|
||||
}
|
||||
@ -144,11 +163,19 @@ func runDriveList(ctx context.Context, args []string) error {
|
||||
longestAs = len(share.As)
|
||||
}
|
||||
}
|
||||
formatString := fmt.Sprintf("%%-%ds %%-%ds %%s\n", longestName, longestPath)
|
||||
fmt.Printf(formatString, "name", "path", "as")
|
||||
fmt.Printf(formatString, strings.Repeat("-", longestName), strings.Repeat("-", longestPath), strings.Repeat("-", longestAs))
|
||||
formatString := fmt.Sprintf("%%-%ds %%-%ds %%-%ds %%s\n", longestName, longestPath, longestAs)
|
||||
fmt.Printf(formatString, "name", "path", "as", "type")
|
||||
fmt.Printf(formatString,
|
||||
strings.Repeat("-", longestName),
|
||||
strings.Repeat("-", longestPath),
|
||||
strings.Repeat("-", longestAs),
|
||||
"----")
|
||||
for _, share := range shares {
|
||||
fmt.Printf(formatString, share.Name, share.Path, share.As)
|
||||
typ := "normal"
|
||||
if buildfeatures.HasDriveMagic && share.IsMagic() {
|
||||
typ = "magic"
|
||||
}
|
||||
fmt.Printf(formatString, share.Name, share.Path, share.As, typ)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@ -93,6 +93,17 @@ func TestOmitDrive(t *testing.T) {
|
||||
}.Check(t)
|
||||
}
|
||||
|
||||
func TestOmitDriveMagic(t *testing.T) {
|
||||
deptest.DepChecker{
|
||||
GOOS: "linux",
|
||||
GOARCH: "amd64",
|
||||
Tags: "ts_omit_drive_magic,ts_include_cli",
|
||||
BadDeps: map[string]string{
|
||||
"tailscale.com/drive/magic": "unexpected dep with ts_omit_drive_magic",
|
||||
},
|
||||
}.Check(t)
|
||||
}
|
||||
|
||||
func TestOmitPortmapper(t *testing.T) {
|
||||
deptest.DepChecker{
|
||||
GOOS: "linux",
|
||||
|
||||
@ -438,6 +438,8 @@ type remote struct {
|
||||
fileServer *FileServer
|
||||
shares map[string]string
|
||||
permissions map[string]drive.Permission
|
||||
peerLogin string
|
||||
sharerLogin string
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
@ -452,7 +454,11 @@ func (r *remote) unfreeze() {
|
||||
func (r *remote) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
r.fs.ServeHTTPWithPerms(r.permissions, w, req)
|
||||
r.fs.ServeHTTPWithPerms(drive.Authz{
|
||||
PeerLogin: r.peerLogin,
|
||||
SharerLogin: r.sharerLogin,
|
||||
Permissions: r.permissions,
|
||||
}, w, req)
|
||||
}
|
||||
|
||||
type system struct {
|
||||
@ -745,7 +751,7 @@ func pathTo(remote, share, name string) string {
|
||||
type noopAuthorizer struct{}
|
||||
|
||||
func (a *noopAuthorizer) NewAuthenticator(body io.Reader) (gowebdav.Authenticator, io.Reader) {
|
||||
return &noopAuthenticator{}, nil
|
||||
return &noopAuthenticator{}, body
|
||||
}
|
||||
|
||||
func (a *noopAuthorizer) AddAuthenticator(key string, fn gowebdav.AuthFactory) {
|
||||
|
||||
220
drive/driveimpl/magic_test.go
Normal file
220
drive/driveimpl/magic_test.go
Normal file
@ -0,0 +1,220 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !ts_omit_drive_magic
|
||||
|
||||
package driveimpl
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"tailscale.com/drive"
|
||||
)
|
||||
|
||||
// addMagicShare configures the named remote to expose a magic share.
|
||||
// The share's on-disk path is returned so the caller can prepopulate
|
||||
// ACL directories.
|
||||
func (s *system) addMagicShare(remoteName, shareName, peerLogin, sharerLogin string) string {
|
||||
r, ok := s.remotes[remoteName]
|
||||
if !ok {
|
||||
s.t.Fatalf("unknown remote %q", remoteName)
|
||||
}
|
||||
|
||||
folder := s.t.TempDir()
|
||||
r.shares[shareName] = folder
|
||||
r.permissions[shareName] = drive.PermissionReadWrite
|
||||
r.peerLogin = peerLogin
|
||||
r.sharerLogin = sharerLogin
|
||||
|
||||
shares := make([]*drive.Share, 0, len(r.shares))
|
||||
for n, f := range r.shares {
|
||||
shares = append(shares, &drive.Share{Name: n, Path: f})
|
||||
}
|
||||
slices.SortFunc(shares, drive.CompareShares)
|
||||
r.fs.SetShares(shares)
|
||||
r.fileServer.SetShares(r.shares)
|
||||
return folder
|
||||
}
|
||||
|
||||
// setPeer rebinds the peer/sharer login for the given remote. The remote's
|
||||
// ServeHTTP picks up these values on each request, so this works for tests
|
||||
// that simulate different peer identities against the same sharer.
|
||||
func (s *system) setPeer(remoteName, peerLogin, sharerLogin string) {
|
||||
r, ok := s.remotes[remoteName]
|
||||
if !ok {
|
||||
s.t.Fatalf("unknown remote %q", remoteName)
|
||||
}
|
||||
r.mu.Lock()
|
||||
r.peerLogin = peerLogin
|
||||
r.sharerLogin = sharerLogin
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
func mkdir(t *testing.T, parent, name string) {
|
||||
t.Helper()
|
||||
if err := os.Mkdir(filepath.Join(parent, name), 0755); err != nil {
|
||||
t.Fatalf("mkdir %q: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMagicShareDiscoveryAndListing(t *testing.T) {
|
||||
const (
|
||||
magicShare = "magic"
|
||||
sharerLogin = "fserb@example.com"
|
||||
peerRhea = "rhea@example.com"
|
||||
peerJoe = "joe@example.com"
|
||||
peerStranger = "stranger@example.com"
|
||||
dirSharerOnly = "fserb"
|
||||
dirSharerRhea = "fserb+rhea"
|
||||
dirNoSharer = "rhea+joe" // invalid: no sharer
|
||||
dirGarbage = "not a name" // invalid grammar
|
||||
)
|
||||
|
||||
s := newSystem(t)
|
||||
s.addRemote(remote1)
|
||||
folder := s.addMagicShare(remote1, magicShare, peerRhea, sharerLogin)
|
||||
|
||||
mkdir(t, folder, dirSharerOnly)
|
||||
mkdir(t, folder, dirSharerRhea)
|
||||
mkdir(t, folder, dirNoSharer)
|
||||
mkdir(t, folder, dirGarbage)
|
||||
|
||||
// rhea sees only the dirs that name both sharer and rhea.
|
||||
checkMagicListing(t, s, magicShare, []string{dirSharerRhea})
|
||||
|
||||
// joe is matched by no dir.
|
||||
s.setPeer(remote1, peerJoe, sharerLogin)
|
||||
checkMagicListing(t, s, magicShare, nil)
|
||||
|
||||
// fserb on another node sees their own dirs.
|
||||
s.setPeer(remote1, sharerLogin, sharerLogin)
|
||||
checkMagicListing(t, s, magicShare, []string{dirSharerOnly, dirSharerRhea})
|
||||
|
||||
// A peer not in any dir, but with the share grant, gets an empty
|
||||
// listing (not 404).
|
||||
s.setPeer(remote1, peerStranger, sharerLogin)
|
||||
checkMagicListing(t, s, magicShare, nil)
|
||||
}
|
||||
|
||||
func TestMagicShareReadWrite(t *testing.T) {
|
||||
const (
|
||||
magicShare = "magic"
|
||||
sharerLogin = "fserb@example.com"
|
||||
peerRhea = "rhea@example.com"
|
||||
peerJoe = "joe@example.com"
|
||||
dirShared = "fserb+rhea"
|
||||
dirOther = "fserb"
|
||||
dirInvalid = "rhea+joe"
|
||||
fname = "note.txt"
|
||||
)
|
||||
|
||||
s := newSystem(t)
|
||||
s.addRemote(remote1)
|
||||
folder := s.addMagicShare(remote1, magicShare, peerRhea, sharerLogin)
|
||||
|
||||
mkdir(t, folder, dirShared)
|
||||
mkdir(t, folder, dirOther)
|
||||
mkdir(t, folder, dirInvalid)
|
||||
|
||||
// rhea can read/write inside dirShared.
|
||||
rheaPath := path.Join(domain, remote1, magicShare, dirShared, fname)
|
||||
if err := s.client.Write(rheaPath, []byte("hi from rhea"), 0644); err != nil {
|
||||
t.Fatalf("rhea write into %q: %v", dirShared, err)
|
||||
}
|
||||
// Verify it actually landed on disk under the share folder.
|
||||
onDisk := filepath.Join(folder, dirShared, fname)
|
||||
if b, err := os.ReadFile(onDisk); err != nil {
|
||||
t.Fatalf("on-disk read after write: %v (file %s)", err, onDisk)
|
||||
} else if string(b) != "hi from rhea" {
|
||||
t.Fatalf("on-disk content %q != written %q", b, "hi from rhea")
|
||||
}
|
||||
got, err := s.client.Read(rheaPath)
|
||||
if err != nil {
|
||||
t.Fatalf("rhea read from %q: %v", dirShared, err)
|
||||
}
|
||||
if string(got) != "hi from rhea" {
|
||||
t.Errorf("rhea read got %q, want %q", got, "hi from rhea")
|
||||
}
|
||||
|
||||
// rhea cannot write into a dir she's not in.
|
||||
otherPath := path.Join(domain, remote1, magicShare, dirOther, fname)
|
||||
if err := s.client.Write(otherPath, []byte("nope"), 0644); err == nil {
|
||||
t.Errorf("rhea write into %q should have failed", dirOther)
|
||||
}
|
||||
|
||||
// rhea cannot read from a dir she's not in.
|
||||
if _, err := s.client.Read(otherPath); err == nil {
|
||||
t.Errorf("rhea read from %q should have failed", dirOther)
|
||||
}
|
||||
|
||||
// dirInvalid is invalid (no sharer in name): nobody, including the
|
||||
// principals named, can access it.
|
||||
s.setPeer(remote1, peerJoe, sharerLogin)
|
||||
invalidPath := path.Join(domain, remote1, magicShare, dirInvalid, fname)
|
||||
if err := s.client.Write(invalidPath, []byte("x"), 0644); err == nil {
|
||||
t.Errorf("joe write into invalid dir %q should have failed", dirInvalid)
|
||||
}
|
||||
|
||||
// joe also cannot list anything (no matching dirs).
|
||||
checkMagicListing(t, s, magicShare, nil)
|
||||
}
|
||||
|
||||
func TestMagicShareTopLevelMutationsDenied(t *testing.T) {
|
||||
const (
|
||||
magicShare = "magic"
|
||||
sharerLogin = "fserb@example.com"
|
||||
peerRhea = "rhea@example.com"
|
||||
dirShared = "fserb+rhea"
|
||||
)
|
||||
|
||||
s := newSystem(t)
|
||||
s.addRemote(remote1)
|
||||
folder := s.addMagicShare(remote1, magicShare, peerRhea, sharerLogin)
|
||||
mkdir(t, folder, dirShared)
|
||||
|
||||
// MKCOL of a brand new top-level dir name must fail.
|
||||
newDir := path.Join(domain, remote1, magicShare, "fserb+rhea+joe")
|
||||
if err := s.client.Mkdir(newDir, 0755); err == nil {
|
||||
t.Errorf("mkcol of new top-level dir should have been denied")
|
||||
}
|
||||
|
||||
// DELETE of an existing top-level dir must fail.
|
||||
existingDir := path.Join(domain, remote1, magicShare, dirShared)
|
||||
if err := s.client.RemoveAll(existingDir); err == nil {
|
||||
t.Errorf("delete of existing top-level dir should have been denied")
|
||||
}
|
||||
|
||||
// But files inside the dir can be deleted normally.
|
||||
filePath := path.Join(existingDir, "f.txt")
|
||||
if err := s.client.Write(filePath, []byte("hi"), 0644); err != nil {
|
||||
t.Fatalf("write inside acl dir: %v", err)
|
||||
}
|
||||
if err := s.client.Remove(filePath); err != nil {
|
||||
t.Errorf("delete inside acl dir failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// checkMagicListing verifies that a depth-1 listing of /<domain>/<remote>/<magicShare>/
|
||||
// returns exactly want (in any order).
|
||||
func checkMagicListing(t *testing.T, s *system, magicShare string, want []string) {
|
||||
t.Helper()
|
||||
entries, err := s.client.ReadDir(path.Join(domain, remote1, magicShare))
|
||||
if err != nil {
|
||||
t.Fatalf("readdir: %v", err)
|
||||
}
|
||||
got := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
got = append(got, e.Name())
|
||||
}
|
||||
sort.Strings(got)
|
||||
sortedWant := append([]string(nil), want...)
|
||||
sort.Strings(sortedWant)
|
||||
if !slices.Equal(got, sortedWant) {
|
||||
t.Errorf("listing got %v, want %v", got, sortedWant)
|
||||
}
|
||||
}
|
||||
@ -27,6 +27,7 @@ import (
|
||||
"tailscale.com/drive/driveimpl/compositedav"
|
||||
"tailscale.com/drive/driveimpl/dirfs"
|
||||
"tailscale.com/drive/driveimpl/shared"
|
||||
"tailscale.com/feature/buildfeatures"
|
||||
"tailscale.com/safesocket"
|
||||
"tailscale.com/types/logger"
|
||||
)
|
||||
@ -199,11 +200,14 @@ func (s *FileSystemForRemote) buildChild(share *drive.Share) *compositedav.Child
|
||||
}
|
||||
|
||||
// ServeHTTPWithPerms implements drive.FileSystemForRemote.
|
||||
func (s *FileSystemForRemote) ServeHTTPWithPerms(permissions drive.Permissions, w http.ResponseWriter, r *http.Request) {
|
||||
func (s *FileSystemForRemote) ServeHTTPWithPerms(authz drive.Authz, w http.ResponseWriter, r *http.Request) {
|
||||
permissions := authz.Permissions
|
||||
pathComponents := shared.CleanAndSplit(r.URL.Path)
|
||||
shareName := pathComponents[0]
|
||||
|
||||
isWrite := writeMethods[r.Method]
|
||||
if isWrite {
|
||||
share := shared.CleanAndSplit(r.URL.Path)[0]
|
||||
switch permissions.For(share) {
|
||||
switch permissions.For(shareName) {
|
||||
case drive.PermissionNone:
|
||||
// If we have no permissions to this share, treat it as not found
|
||||
// to avoid leaking any information about the share's existence.
|
||||
@ -216,16 +220,32 @@ func (s *FileSystemForRemote) ServeHTTPWithPerms(permissions drive.Permissions,
|
||||
}
|
||||
|
||||
s.mu.RLock()
|
||||
var matchedShare *drive.Share
|
||||
for _, sh := range s.shares {
|
||||
if sh.Name == shareName {
|
||||
matchedShare = sh
|
||||
break
|
||||
}
|
||||
}
|
||||
childrenMap := s.children
|
||||
s.mu.RUnlock()
|
||||
|
||||
if s.maybeServeMagic(authz, matchedShare, pathComponents, w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
children := make([]*compositedav.Child, 0, len(childrenMap))
|
||||
// filter out shares to which the connecting principal has no access
|
||||
// filter out shares to which the connecting principal has no access. The
|
||||
// magic share, if present, is also excluded here so a peer with the
|
||||
// share-level grant can't bypass per-dir filtering by hitting top-level
|
||||
// dirs directly through compositedav: it's served only via serveMagic.
|
||||
for name, child := range childrenMap {
|
||||
if permissions.For(name) == drive.PermissionNone {
|
||||
continue
|
||||
}
|
||||
|
||||
if buildfeatures.HasDriveMagic && name == drive.MagicShareName {
|
||||
continue
|
||||
}
|
||||
children = append(children, child)
|
||||
}
|
||||
|
||||
|
||||
147
drive/driveimpl/remote_impl_magic.go
Normal file
147
drive/driveimpl/remote_impl_magic.go
Normal file
@ -0,0 +1,147 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !ts_omit_drive_magic
|
||||
|
||||
package driveimpl
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
|
||||
"github.com/tailscale/xnet/webdav"
|
||||
"tailscale.com/drive"
|
||||
"tailscale.com/drive/driveimpl/compositedav"
|
||||
"tailscale.com/drive/driveimpl/dirfs"
|
||||
"tailscale.com/drive/magic"
|
||||
)
|
||||
|
||||
// topLevelDirWriteMethods are write-ish WebDAV methods that, when targeted
|
||||
// at a magic share's top-level ACL directory itself (e.g. /<magic>/<acldir>),
|
||||
// must be denied. Top-level dir creation/deletion is equivalent to writing
|
||||
// a grant and is sharer-local only.
|
||||
var topLevelDirWriteMethods = map[string]bool{
|
||||
"MKCOL": true,
|
||||
"DELETE": true,
|
||||
"MOVE": true,
|
||||
"PROPPATCH": true,
|
||||
"COPY": true,
|
||||
}
|
||||
|
||||
// maybeServeMagic dispatches to the magic-share handler if share is a magic
|
||||
// share, returning true if the request was handled.
|
||||
func (s *FileSystemForRemote) maybeServeMagic(authz drive.Authz, share *drive.Share, pathComponents []string, w http.ResponseWriter, r *http.Request) bool {
|
||||
if !share.IsMagic() {
|
||||
return false
|
||||
}
|
||||
s.serveMagic(authz, share, pathComponents, w, r)
|
||||
return true
|
||||
}
|
||||
|
||||
// serveMagic handles a request whose first path segment is a magic share.
|
||||
// It enforces ACL-name filtering: peers only see (and can only descend into)
|
||||
// top-level directories whose name encodes an ACL they match. Top-level dir
|
||||
// creation/deletion is denied for remote peers.
|
||||
func (s *FileSystemForRemote) serveMagic(authz drive.Authz, share *drive.Share, pathComponents []string, w http.ResponseWriter, r *http.Request) {
|
||||
if len(pathComponents) == 1 || (len(pathComponents) == 2 && pathComponents[1] == "") {
|
||||
if writeMethods[r.Method] {
|
||||
http.Error(w, "magic share top-level is read-only for remote peers", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
s.serveMagicTopLevel(authz, share, w, r)
|
||||
return
|
||||
}
|
||||
|
||||
aclDir := pathComponents[1]
|
||||
if !aclDirGrantsPeer(authz, share.Path, aclDir) {
|
||||
// Use 404 rather than 403 to avoid confirming the dir's existence.
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if len(pathComponents) == 2 && topLevelDirWriteMethods[r.Method] {
|
||||
http.Error(w, "magic top-level dir is sharer-local only", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Forward to the regular compositedav pipeline. We rebuild children
|
||||
// rather than reusing the cached map directly, because the magic share
|
||||
// must remain accessible here even though it was filtered out above.
|
||||
s.mu.RLock()
|
||||
childrenMap := s.children
|
||||
s.mu.RUnlock()
|
||||
|
||||
children := make([]*compositedav.Child, 0, len(childrenMap))
|
||||
for name, child := range childrenMap {
|
||||
if name != share.Name && authz.Permissions.For(name) == drive.PermissionNone {
|
||||
continue
|
||||
}
|
||||
children = append(children, child)
|
||||
}
|
||||
|
||||
h := compositedav.Handler{Logf: s.logf}
|
||||
h.SetChildren("", children...)
|
||||
h.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// serveMagicTopLevel serves a synthetic listing of /<share>/ that contains
|
||||
// only the top-level dirs the peer matches.
|
||||
func (s *FileSystemForRemote) serveMagicTopLevel(authz drive.Authz, share *drive.Share, w http.ResponseWriter, r *http.Request) {
|
||||
var children []*dirfs.Child
|
||||
if authz.SharerLogin != "" {
|
||||
entries, err := os.ReadDir(share.Path)
|
||||
if err != nil {
|
||||
s.logf("taildrive magic: read dir %q: %v", share.Path, err)
|
||||
}
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
acl, err := magic.ParseDirACL(e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if acl.Matches(authz.PeerLogin, authz.SharerLogin) {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
children = make([]*dirfs.Child, 0, len(names))
|
||||
for _, n := range names {
|
||||
children = append(children, &dirfs.Child{Name: n})
|
||||
}
|
||||
}
|
||||
wh := &webdav.Handler{
|
||||
LockSystem: webdav.NewMemLS(),
|
||||
FileSystem: &dirfs.FS{
|
||||
Children: children,
|
||||
StaticRoot: share.Name,
|
||||
},
|
||||
}
|
||||
wh.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// aclDirGrantsPeer reports whether aclDir exists on disk under sharePath as a
|
||||
// directory and its parsed ACL grants the peer access (sharer-in-name rule
|
||||
// plus peer match). It does no enumeration; only the single aclDir is
|
||||
// inspected.
|
||||
func aclDirGrantsPeer(authz drive.Authz, sharePath, aclDir string) bool {
|
||||
if authz.SharerLogin == "" {
|
||||
return false
|
||||
}
|
||||
acl, err := magic.ParseDirACL(aclDir)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if !acl.Matches(authz.PeerLogin, authz.SharerLogin) {
|
||||
return false
|
||||
}
|
||||
fi, err := os.Stat(filepath.Join(sharePath, aclDir))
|
||||
if err != nil || !fi.IsDir() {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
19
drive/driveimpl/remote_impl_nomagic.go
Normal file
19
drive/driveimpl/remote_impl_nomagic.go
Normal file
@ -0,0 +1,19 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build ts_omit_drive_magic
|
||||
|
||||
package driveimpl
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"tailscale.com/drive"
|
||||
)
|
||||
|
||||
// maybeServeMagic is a stub for builds without the magic-share feature.
|
||||
// It never claims the request, so a share named "magic" is served as a
|
||||
// regular share with no name-encoded ACL semantics.
|
||||
func (s *FileSystemForRemote) maybeServeMagic(authz drive.Authz, share *drive.Share, pathComponents []string, w http.ResponseWriter, r *http.Request) bool {
|
||||
return false
|
||||
}
|
||||
144
drive/magic/magic.go
Normal file
144
drive/magic/magic.go
Normal file
@ -0,0 +1,144 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package magic implements the parsing and matching logic for the
|
||||
// taildrive "magic" share, where the directory name itself encodes the ACL.
|
||||
//
|
||||
// A magic ACL directory name is a "+"-delimited list of principals. Each
|
||||
// principal is either a short login (matching the local-part of a tailnet
|
||||
// LoginName) or a full login email. The sharer's own login must appear in
|
||||
// every directory name; a directory whose name omits the sharer is
|
||||
// considered invalid and shared with no one.
|
||||
package magic
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ErrInvalidName is returned by ParseDirACL when the directory name is empty
|
||||
// or contains a malformed principal.
|
||||
var ErrInvalidName = errors.New("invalid magic acl directory name")
|
||||
|
||||
// DirACL is a parsed magic-share directory name.
|
||||
type DirACL struct {
|
||||
// Users is the deduplicated list of user specs in the directory name,
|
||||
// in the order they first appeared, normalized to lowercase.
|
||||
// Each spec is either a short login (no "@") or a full login email.
|
||||
Users []string
|
||||
}
|
||||
|
||||
// ParseDirACL parses name as a magic ACL. Names are normalized to lowercase
|
||||
// and split on "+". Returns ErrInvalidName if name is empty or contains a
|
||||
// malformed principal.
|
||||
func ParseDirACL(name string) (DirACL, error) {
|
||||
name = strings.ToLower(strings.TrimSpace(name))
|
||||
if name == "" {
|
||||
return DirACL{}, ErrInvalidName
|
||||
}
|
||||
parts := strings.Split(name, "+")
|
||||
users := make([]string, 0, len(parts))
|
||||
seen := make(map[string]bool, len(parts))
|
||||
for _, p := range parts {
|
||||
if !validUserSpec(p) {
|
||||
return DirACL{}, fmt.Errorf("%w: %q", ErrInvalidName, p)
|
||||
}
|
||||
if seen[p] {
|
||||
continue
|
||||
}
|
||||
seen[p] = true
|
||||
users = append(users, p)
|
||||
}
|
||||
return DirACL{Users: users}, nil
|
||||
}
|
||||
|
||||
// validUserSpec reports whether s is a syntactically valid user spec.
|
||||
// A short login is a non-empty sequence of [a-z0-9_.-]. An email is a short
|
||||
// login, "@", and a non-empty sequence of [a-z0-9.-] (a hostname-ish
|
||||
// fragment).
|
||||
func validUserSpec(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
local, host, hasAt := strings.Cut(s, "@")
|
||||
if local == "" {
|
||||
return false
|
||||
}
|
||||
if !validLocalPart(local) {
|
||||
return false
|
||||
}
|
||||
if !hasAt {
|
||||
return true
|
||||
}
|
||||
return validHost(host)
|
||||
}
|
||||
|
||||
func validLocalPart(s string) bool {
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case 'a' <= r && r <= 'z':
|
||||
case '0' <= r && r <= '9':
|
||||
case r == '_' || r == '.' || r == '-':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validHost(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case 'a' <= r && r <= 'z':
|
||||
case '0' <= r && r <= '9':
|
||||
case r == '.' || r == '-':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Matches reports whether peerLogin is granted access by this ACL given that
|
||||
// the local node's tailnet login is sharerLogin. Both arguments are full
|
||||
// LoginNames (e.g. "alice@example.com") and are matched case-insensitively.
|
||||
//
|
||||
// The "sharer-in-name" rule is enforced: if no principal in the ACL matches
|
||||
// sharerLogin, the directory is invalid and Matches returns false for every
|
||||
// peer.
|
||||
func (a DirACL) Matches(peerLogin, sharerLogin string) bool {
|
||||
peerLogin = strings.ToLower(peerLogin)
|
||||
sharerLogin = strings.ToLower(sharerLogin)
|
||||
sharerOK := false
|
||||
peerOK := false
|
||||
for _, u := range a.Users {
|
||||
if matchesUser(u, sharerLogin) {
|
||||
sharerOK = true
|
||||
}
|
||||
if matchesUser(u, peerLogin) {
|
||||
peerOK = true
|
||||
}
|
||||
}
|
||||
return sharerOK && peerOK
|
||||
}
|
||||
|
||||
// matchesUser reports whether spec matches login. spec and login are
|
||||
// pre-lowercased. spec is either a short login (matched against the
|
||||
// local-part of login) or a full email (matched against login as a whole).
|
||||
func matchesUser(spec, login string) bool {
|
||||
if login == "" {
|
||||
return false
|
||||
}
|
||||
if strings.ContainsRune(spec, '@') {
|
||||
return spec == login
|
||||
}
|
||||
local, _, ok := strings.Cut(login, "@")
|
||||
if !ok {
|
||||
local = login
|
||||
}
|
||||
return spec == local
|
||||
}
|
||||
82
drive/magic/magic_test.go
Normal file
82
drive/magic/magic_test.go
Normal file
@ -0,0 +1,82 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package magic
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseDirACL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
want []string
|
||||
wantErr bool
|
||||
}{
|
||||
{"fserb", []string{"fserb"}, false},
|
||||
{"fserb+rhea", []string{"fserb", "rhea"}, false},
|
||||
{"Fserb+RHEA", []string{"fserb", "rhea"}, false},
|
||||
{"fserb+fserb", []string{"fserb"}, false},
|
||||
{"fserb+rhea+joe", []string{"fserb", "rhea", "joe"}, false},
|
||||
{"fserb@example.com+rhea", []string{"fserb@example.com", "rhea"}, false},
|
||||
|
||||
{"", nil, true},
|
||||
{"+fserb", nil, true},
|
||||
{"fserb+", nil, true},
|
||||
{"fserb++rhea", nil, true},
|
||||
{"fserb rhea", nil, true},
|
||||
{"fserb!", nil, true},
|
||||
{"@example.com", nil, true},
|
||||
{"fserb@", nil, true},
|
||||
{"fserb@@example.com", nil, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := ParseDirACL(tt.name)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("err=%v, wantErr=%v", err, tt.wantErr)
|
||||
}
|
||||
if tt.wantErr {
|
||||
return
|
||||
}
|
||||
if !slices.Equal(got.Users, tt.want) {
|
||||
t.Errorf("Users=%v, want %v", got.Users, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatches(t *testing.T) {
|
||||
const sharer = "fserb@example.com"
|
||||
tests := []struct {
|
||||
name string
|
||||
dir string
|
||||
peerLogin string
|
||||
want bool
|
||||
}{
|
||||
{"sharer matches own dir", "fserb", sharer, true},
|
||||
{"sharer matches paired dir", "fserb+rhea", sharer, true},
|
||||
{"peer matches paired dir (short)", "fserb+rhea", "rhea@example.com", true},
|
||||
{"peer matches paired dir (email)", "fserb+rhea@example.com", "rhea@example.com", true},
|
||||
{"peer mismatch full email", "fserb+rhea@other.com", "rhea@example.com", false},
|
||||
{"peer not in dir", "fserb+rhea", "joe@example.com", false},
|
||||
{"sharer-not-in-name invalidates dir for peer", "rhea+joe", "rhea@example.com", false},
|
||||
{"sharer-not-in-name invalidates dir for sharer too", "rhea+joe", sharer, false},
|
||||
{"empty peer login", "fserb", "", false},
|
||||
{"three principal dir, peer ok", "fserb+rhea+joe", "joe@example.com", true},
|
||||
{"case-insensitive peer", "fserb+rhea", "RHEA@example.com", true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
acl, err := ParseDirACL(tt.dir)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %q: %v", tt.dir, err)
|
||||
}
|
||||
got := acl.Matches(tt.peerLogin, sharer)
|
||||
if got != tt.want {
|
||||
t.Errorf("Matches(%q, %q)=%v, want %v", tt.peerLogin, sharer, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -48,6 +48,17 @@ type Share struct {
|
||||
BookmarkData []byte `json:"bookmarkData,omitempty"`
|
||||
}
|
||||
|
||||
// MagicShareName is the reserved share name for a magic share, where each
|
||||
// top-level directory name inside the share encodes its own ACL (see package
|
||||
// drive/magic). A share is treated as magic if and only if its Name equals
|
||||
// MagicShareName.
|
||||
const MagicShareName = "magic"
|
||||
|
||||
// IsMagic reports whether s is a magic share.
|
||||
func (s *Share) IsMagic() bool {
|
||||
return s != nil && s.Name == MagicShareName
|
||||
}
|
||||
|
||||
func ShareViewsEqual(a, b ShareView) bool {
|
||||
if !a.Valid() && !b.Valid() {
|
||||
return true
|
||||
@ -102,9 +113,8 @@ type FileSystemForRemote interface {
|
||||
SetShares(shares []*Share)
|
||||
|
||||
// ServeHTTPWithPerms behaves like the similar method from http.Handler but
|
||||
// also accepts a Permissions map that captures the permissions of the
|
||||
// connecting node.
|
||||
ServeHTTPWithPerms(permissions Permissions, w http.ResponseWriter, r *http.Request)
|
||||
// also accepts an Authz value capturing the peer's identity and permissions.
|
||||
ServeHTTPWithPerms(authz Authz, w http.ResponseWriter, r *http.Request)
|
||||
|
||||
// Close() stops serving the WebDAV content
|
||||
Close() error
|
||||
|
||||
@ -27,6 +27,23 @@ const (
|
||||
// set of shares.
|
||||
type Permissions map[string]Permission
|
||||
|
||||
// Authz captures the authorization context for a request to
|
||||
// FileSystemForRemote: the peer's tailnet identity and per-share permissions,
|
||||
// plus the local node's tailnet identity (used for the magic share's
|
||||
// "sharer-in-name" rule).
|
||||
type Authz struct {
|
||||
// PeerLogin is the requesting peer's tailnet LoginName (e.g.
|
||||
// "alice@example.com"). Lowercased for case-insensitive matching.
|
||||
PeerLogin string
|
||||
|
||||
// SharerLogin is the local (sharing) node's tailnet LoginName.
|
||||
// Lowercased for case-insensitive matching.
|
||||
SharerLogin string
|
||||
|
||||
// Permissions maps share name to the peer's permission on that share.
|
||||
Permissions Permissions
|
||||
}
|
||||
|
||||
type grant struct {
|
||||
Shares []string
|
||||
Access string
|
||||
|
||||
13
feature/buildfeatures/feature_drive_magic_disabled.go
Normal file
13
feature/buildfeatures/feature_drive_magic_disabled.go
Normal file
@ -0,0 +1,13 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Code generated by gen.go; DO NOT EDIT.
|
||||
|
||||
//go:build ts_omit_drive_magic
|
||||
|
||||
package buildfeatures
|
||||
|
||||
// HasDriveMagic is whether the binary was built with support for modular feature "Taildrive \"magic\" share (directory-name-encoded ACLs)".
|
||||
// Specifically, it's whether the binary was NOT built with the "ts_omit_drive_magic" build tag.
|
||||
// It's a const so it can be used for dead code elimination.
|
||||
const HasDriveMagic = false
|
||||
13
feature/buildfeatures/feature_drive_magic_enabled.go
Normal file
13
feature/buildfeatures/feature_drive_magic_enabled.go
Normal file
@ -0,0 +1,13 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Code generated by gen.go; DO NOT EDIT.
|
||||
|
||||
//go:build !ts_omit_drive_magic
|
||||
|
||||
package buildfeatures
|
||||
|
||||
// HasDriveMagic is whether the binary was built with support for modular feature "Taildrive \"magic\" share (directory-name-encoded ACLs)".
|
||||
// Specifically, it's whether the binary was NOT built with the "ts_omit_drive_magic" build tag.
|
||||
// It's a const so it can be used for dead code elimination.
|
||||
const HasDriveMagic = true
|
||||
@ -160,6 +160,11 @@ var Features = map[FeatureTag]FeatureMeta{
|
||||
"desktop_sessions": {Sym: "DesktopSessions", Desc: "Desktop sessions support"},
|
||||
"doctor": {Sym: "Doctor", Desc: "Diagnose possible issues with Tailscale and its host environment"},
|
||||
"drive": {Sym: "Drive", Desc: "Tailscale Drive (file server) support"},
|
||||
"drive_magic": {
|
||||
Sym: "DriveMagic",
|
||||
Desc: "Taildrive \"magic\" share (directory-name-encoded ACLs)",
|
||||
Deps: []FeatureTag{"drive"},
|
||||
},
|
||||
"gro": {
|
||||
Sym: "GRO",
|
||||
Desc: "Generic Receive Offload support (performance)",
|
||||
|
||||
@ -68,7 +68,7 @@ func TestRequiredBy(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
in: "drive",
|
||||
want: setOf("drive"),
|
||||
want: setOf("drive", "drive_magic"),
|
||||
},
|
||||
{
|
||||
in: "webclient",
|
||||
|
||||
@ -11,6 +11,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"tailscale.com/drive"
|
||||
"tailscale.com/feature/buildfeatures"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/util/httpm"
|
||||
)
|
||||
@ -53,6 +54,20 @@ func handleServeDrive(hi PeerAPIHandler, w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
var sharerLogin string
|
||||
if buildfeatures.HasDriveMagic {
|
||||
if nm := h.ps.b.NetMap(); nm != nil {
|
||||
if up, ok := nm.UserProfiles[nm.User()]; ok {
|
||||
sharerLogin = up.LoginName()
|
||||
}
|
||||
}
|
||||
}
|
||||
authz := drive.Authz{
|
||||
PeerLogin: strings.ToLower(h.peerUser.LoginName),
|
||||
SharerLogin: strings.ToLower(sharerLogin),
|
||||
Permissions: p,
|
||||
}
|
||||
|
||||
fs, ok := h.ps.b.sys.DriveForRemote.GetOK()
|
||||
if !ok {
|
||||
h.logf("taildrive: not supported on platform")
|
||||
@ -86,7 +101,7 @@ func handleServeDrive(hi PeerAPIHandler, w http.ResponseWriter, r *http.Request)
|
||||
}()
|
||||
|
||||
r.URL.Path = strings.TrimPrefix(r.URL.Path, taildrivePrefix)
|
||||
fs.ServeHTTPWithPerms(p, wr, r)
|
||||
fs.ServeHTTPWithPerms(authz, wr, r)
|
||||
}
|
||||
|
||||
// parseDriveFileExtensionForLog parses the file extension, if available.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user