mirror of
https://github.com/tailscale/tailscale.git
synced 2026-10-29 06:28:57 -04:00
Close the specific forwarded listener on connection cancellation and stop its callback when the accept loop exits. Canceling a forward no longer retains a connection-lifetime waiter or looks up a replacement listener by address. Test repeated cancel and rebind operations on one connection, along with callback cleanup when the listener closes independently. Make the existing connection-cancellation test fail if its listener stays open. Updates tailscale/corp#50380 Change-Id: Ife7fb0acde012b0abf1a8d804b9c6a8994ee5703 Signed-off-by: James Tucker <james@tailscale.com>
537 lines
18 KiB
Go
537 lines
18 KiB
Go
// Copyright (c) Tailscale Inc & contributors
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
//go:build (linux && !android) || (darwin && !ios) || freebsd || openbsd
|
|
|
|
// The monitor-backed forwarding paths. On unix, all forwarded sockets are
|
|
// dialed and bound by one-shot children of the per-connection ssh-monitor
|
|
// running with the authenticated local user's credentials.
|
|
//
|
|
// - TCP -L/-D, direct-tcpip: policy stays in tailscaled, then name
|
|
// resolution and dialing run in a one-shot child as the bound user.
|
|
// The connected socket is handed back without changing its kernel owner.
|
|
// - unix -L, direct-streamlocal: the lexical allowlist check stays in
|
|
// tailscaled as coarse policy, then the dial and service-side payload
|
|
// writes run as the user in a relay child. See conn.fwdAllowedSocketPath
|
|
// for the path policy.
|
|
// - unix -R, streamlocal-forward: the listener child binds with
|
|
// umask-at-bind and unlink-if-owned semantics. The socket is 0600 and
|
|
// user-owned from the moment it exists.
|
|
// - TCP -R, tcpip-forward: the bind goes through the monitor's listen
|
|
// op, run by a user-cred listener child. Privileged ports fail with
|
|
// EACCES for non-root-mapped users. Bind addresses are
|
|
// loopback-only unless TS_SSH_FORWARD_GATEWAY_PORTS is set.
|
|
// - agent forwarding: the socket and its containing directory are
|
|
// created by a user-cred child. The socket is 0600 in a 0700
|
|
// user-owned directory.
|
|
//
|
|
|
|
package tailssh
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"math"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
|
|
"golang.org/x/sys/unix"
|
|
|
|
"tailscale.com/types/logger"
|
|
|
|
gliderssh "github.com/tailscale/gliderssh"
|
|
gossh "golang.org/x/crypto/ssh"
|
|
)
|
|
|
|
// fwdMaxSunPathLen is the sun_path capacity. It is the lexical length
|
|
// cap on unix socket paths, the same one gliderssh's path validator
|
|
// enforced. The cap is 108 on Linux and 104 on macOS.
|
|
var fwdMaxSunPathLen = len(unix.RawSockaddrUnix{}.Path)
|
|
|
|
// fwdAllowedSocketPath applies the lexical socket-path allowlist and
|
|
// returns the cleaned path to dial or bind. The allowlist is the same one
|
|
// gliderssh's UnixForwardingOptions enforced: the user's home directory,
|
|
// /tmp, and /run/user/<uid>. Relative paths and abstract socket names
|
|
// (a leading "@") are rejected.
|
|
func (c *conn) fwdAllowedSocketPath(socketPath string) (string, error) {
|
|
if !filepath.IsAbs(socketPath) || strings.HasPrefix(socketPath, "@") || strings.ContainsRune(socketPath, 0) {
|
|
return "", fmt.Errorf("socket path %q must be an absolute filesystem path: %w", socketPath, gliderssh.ErrRejected)
|
|
}
|
|
cleaned := filepath.Clean(socketPath)
|
|
// The sun_path length cap rejects over-long paths lexically, per
|
|
// fwdMaxSunPathLen.
|
|
if len(cleaned) >= fwdMaxSunPathLen {
|
|
return "", fmt.Errorf("socket path too long (%d >= %d): %w", len(cleaned), fwdMaxSunPathLen, gliderssh.ErrRejected)
|
|
}
|
|
for _, dir := range []string{c.localUser.HomeDir, "/tmp", filepath.Join("/run/user", c.localUser.Uid)} {
|
|
prefix := filepath.Clean(dir)
|
|
if dir == "" || prefix == string(filepath.Separator) {
|
|
// A home dir of "/" cleans to the separator itself, whose prefix
|
|
// matches every absolute path and collapses the coarse
|
|
// allowlist to "any absolute path". Drop the entry.
|
|
continue
|
|
}
|
|
if !strings.HasSuffix(prefix, string(filepath.Separator)) {
|
|
prefix += string(filepath.Separator)
|
|
}
|
|
if strings.HasPrefix(cleaned, prefix) {
|
|
return cleaned, nil
|
|
}
|
|
}
|
|
return "", fmt.Errorf("socket path %q is not in an allowed directory: %w", cleaned, gliderssh.ErrRejected)
|
|
}
|
|
|
|
// fwdDirectTCPIPHandler serves both TCP -L and SOCKS -D forwards: both use
|
|
// RFC 4254 direct-tcpip channels. Policy is checked before spawning a monitor
|
|
// or dialer. The child resolves/dials using the local account selected during
|
|
// SSH authentication (c.localUser): its UID, GID and supplementary groups are
|
|
// fixed when the per-connection monitor is spawned. The forwarding request
|
|
// supplies no user identity and cannot change those credentials.
|
|
func (c *conn) fwdDirectTCPIPHandler(srv *gliderssh.Server, _ *gossh.ServerConn, newChan gossh.NewChannel, ctx gliderssh.Context) {
|
|
var d struct {
|
|
DestAddr string
|
|
DestPort uint32
|
|
OriginAddr string
|
|
OriginPort uint32
|
|
}
|
|
if err := gossh.Unmarshal(newChan.ExtraData(), &d); err != nil {
|
|
newChan.Reject(gossh.ConnectionFailed, "error parsing forward data: "+err.Error())
|
|
return
|
|
}
|
|
if d.DestPort == 0 || d.DestPort > math.MaxUint16 {
|
|
newChan.Reject(gossh.ConnectionFailed, "destination port must be between 1 and 65535")
|
|
return
|
|
}
|
|
if srv.LocalPortForwardingCallback == nil || !srv.LocalPortForwardingCallback(ctx, d.DestAddr, d.DestPort) {
|
|
newChan.Reject(gossh.Prohibited, "port forwarding is disabled")
|
|
return
|
|
}
|
|
mc, err := c.connMonitor()
|
|
if err != nil {
|
|
newChan.Reject(gossh.ConnectionFailed, err.Error())
|
|
return
|
|
}
|
|
dconn, err := mc.DialTCP(ctx, net.JoinHostPort(d.DestAddr, strconv.FormatUint(uint64(d.DestPort), 10)))
|
|
if err != nil {
|
|
newChan.Reject(gossh.ConnectionFailed, err.Error())
|
|
return
|
|
}
|
|
fwdAcceptAndBicopy(ctx, newChan, dconn)
|
|
}
|
|
|
|
// fwdDirectStreamLocalHandler keeps channel lifetime separate from data EOF.
|
|
// gliderssh's generic forwarding loop uses the connection context, which
|
|
// does not end when just this SSH channel closes. The request stream closes
|
|
// on CHANNEL_CLOSE (not CHANNEL_EOF), so use it to end this channel's relay.
|
|
func fwdDirectStreamLocalHandler(srv *gliderssh.Server, _ *gossh.ServerConn, newChan gossh.NewChannel, ctx gliderssh.Context) {
|
|
var d struct {
|
|
SocketPath string
|
|
Reserved1 string
|
|
Reserved2 uint32
|
|
}
|
|
if err := gossh.Unmarshal(newChan.ExtraData(), &d); err != nil {
|
|
newChan.Reject(gossh.ConnectionFailed, "error parsing direct-streamlocal data: "+err.Error())
|
|
return
|
|
}
|
|
if srv.LocalUnixForwardingCallback == nil {
|
|
newChan.Reject(gossh.Prohibited, "unix forwarding is disabled")
|
|
return
|
|
}
|
|
dconn, err := srv.LocalUnixForwardingCallback(ctx, d.SocketPath)
|
|
if err != nil {
|
|
if errors.Is(err, gliderssh.ErrRejected) {
|
|
msg := err.Error()
|
|
if err == gliderssh.ErrRejected {
|
|
msg = "unix forwarding is disabled"
|
|
}
|
|
newChan.Reject(gossh.Prohibited, msg)
|
|
} else {
|
|
newChan.Reject(gossh.ConnectionFailed, fmt.Sprintf("dial unix socket %q: %v", d.SocketPath, err))
|
|
}
|
|
return
|
|
}
|
|
fwdAcceptAndBicopy(ctx, newChan, dconn)
|
|
}
|
|
|
|
// fwdAcceptAndBicopy owns dconn, accepting the channel and copying data until
|
|
// completion or closure. Channel EOF permits a response; full channel closure
|
|
// stops waiting for the service while allowing buffered client payload to drain.
|
|
func fwdAcceptAndBicopy(ctx context.Context, newChan gossh.NewChannel, dconn net.Conn) {
|
|
ch, reqs, err := newChan.Accept()
|
|
if err != nil {
|
|
dconn.Close()
|
|
return
|
|
}
|
|
go func() {
|
|
gossh.DiscardRequests(reqs)
|
|
// CHANNEL_CLOSE ends service output, but the channel may still
|
|
// have buffered client payload to drain. Bound that final write
|
|
// rather than canceling both copy directions and losing the data.
|
|
dconn.SetWriteDeadline(time.Now().Add(fwdRelayCloseDrainTimeout))
|
|
dconn.SetReadDeadline(time.Now())
|
|
}()
|
|
fwdBicopy(ctx, ch, dconn)
|
|
}
|
|
|
|
// fwdDialUnix is the unix -L path (direct-streamlocal): dial socketPath
|
|
// through a child with the local user's credentials. The child retains the
|
|
// service socket and relays payload bytes over the returned socketpair end.
|
|
func (c *conn) fwdDialUnix(ctx gliderssh.Context, socketPath string) (net.Conn, error) {
|
|
path, err := c.fwdAllowedSocketPath(socketPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
mc, err := c.connMonitor()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
uc, err := mc.Dial(ctx, path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("dial unix socket %q: %w", path, err)
|
|
}
|
|
return uc, nil
|
|
}
|
|
|
|
// fwdListenUnix is the unix -R path, streamlocal-forward. It listens at
|
|
// socketPath through a one-shot child with the local user's credentials,
|
|
// umasking before the bind so the socket is created directly at 0600
|
|
// user-owned, and unlinking an existing socket only when the session uid
|
|
// owns it. Closing the returned listener does not unlink the socket
|
|
// file; a canceled forward may leave a stale socket that the next bind's
|
|
// unlink-if-owned reclaims.
|
|
func (c *conn) fwdListenUnix(ctx gliderssh.Context, socketPath string) (net.Listener, error) {
|
|
path, err := c.fwdAllowedSocketPath(socketPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
mc, err := c.connMonitor()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return mc.Listen(ctx, "unix", path, true) // unlink: if-owned
|
|
}
|
|
|
|
// fwdListenAgent returns the agent-forwarding listener and socket path,
|
|
// both created by a child with the local user's credentials: a 0700
|
|
// user-owned temp dir and, inside it, a 0600 user-owned socket. Removing
|
|
// the directory is the caller's job at session end. lu is the session's
|
|
// local user, unused on unix.
|
|
func (c *conn) fwdListenAgent(ctx gliderssh.Context, lu *userMeta) (net.Listener, string, error) {
|
|
_ = lu
|
|
mc, err := c.connMonitor()
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
return mc.ListenAgent(ctx)
|
|
}
|
|
|
|
// fwdTCPHandler is tailssh's replacement for gliderssh's
|
|
// ForwardedTCPHandler, serving "tcpip-forward" and
|
|
// "cancel-tcpip-forward". The bind goes through the conn's monitor, so
|
|
// the listener is created by a child bound to the local user's
|
|
// credentials; privileged ports below 1024 fail with EACCES for
|
|
// non-root-mapped users. Bind addresses are loopback-only unless
|
|
// sshForwardGatewayPorts is set.
|
|
type fwdTCPHandler struct {
|
|
conn *conn
|
|
|
|
mu sync.Mutex
|
|
forwards map[string]net.Listener
|
|
}
|
|
|
|
// The remote-forward request and channel payloads of RFC 4254 §7.
|
|
type fwdRemoteForwardRequest struct {
|
|
BindAddr string
|
|
BindPort uint32
|
|
}
|
|
|
|
type fwdRemoteForwardSuccess struct {
|
|
BindPort uint32
|
|
}
|
|
|
|
type fwdRemoteForwardCancelRequest struct {
|
|
BindAddr string
|
|
BindPort uint32
|
|
}
|
|
|
|
type fwdRemoteForwardChannelData struct {
|
|
DestAddr string
|
|
DestPort uint32
|
|
OriginAddr string
|
|
OriginPort uint32
|
|
}
|
|
|
|
// fwdLoopbackBindAddr reports the bind address to use for a reverse TCP
|
|
// forward and whether the requested one is permitted. Only loopback is
|
|
// allowed, 127.0.0.0/8 and ::1, unless TS_SSH_FORWARD_GATEWAY_PORTS is
|
|
// set. An empty address binds 127.0.0.1. The listener resolves
|
|
// "localhost".
|
|
func fwdLoopbackBindAddr(bindAddr string) (string, bool) {
|
|
if sshForwardGatewayPorts() {
|
|
return bindAddr, true
|
|
}
|
|
switch bindAddr {
|
|
case "":
|
|
// ssh(1) documents -R without an address as binding loopback. Bind
|
|
// it explicitly so the listener is unambiguous.
|
|
return "127.0.0.1", true
|
|
case "localhost":
|
|
return bindAddr, true
|
|
}
|
|
if ip := net.ParseIP(bindAddr); ip != nil && ip.IsLoopback() {
|
|
return bindAddr, true
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// HandleSSHRequest serves tcpip-forward and cancel-tcpip-forward. The
|
|
// bind-address policy and the monitor bind live here.
|
|
func (h *fwdTCPHandler) HandleSSHRequest(ctx gliderssh.Context, _ *gliderssh.Server, req *gossh.Request) (bool, []byte) {
|
|
h.mu.Lock()
|
|
if h.forwards == nil {
|
|
h.forwards = make(map[string]net.Listener)
|
|
}
|
|
h.mu.Unlock()
|
|
sshConn, ok := ctx.Value(gliderssh.ContextKeyConn).(gossh.Conn)
|
|
if !ok {
|
|
return false, nil
|
|
}
|
|
switch req.Type {
|
|
case "tcpip-forward":
|
|
var reqPayload fwdRemoteForwardRequest
|
|
if err := gossh.Unmarshal(req.Payload, &reqPayload); err != nil {
|
|
return false, nil
|
|
}
|
|
if !h.conn.mayReversePortForwardTo(ctx, reqPayload.BindAddr, reqPayload.BindPort) {
|
|
return false, []byte("port forwarding is disabled")
|
|
}
|
|
bindAddr, ok := fwdLoopbackBindAddr(reqPayload.BindAddr)
|
|
if !ok {
|
|
return false, []byte("binding to non-loopback addresses is not permitted")
|
|
}
|
|
mc, err := h.conn.connMonitor()
|
|
if err != nil {
|
|
h.conn.logf("ssh: reverse port forward: %v", err)
|
|
return false, []byte{}
|
|
}
|
|
ln, err := mc.Listen(ctx, "tcp", net.JoinHostPort(bindAddr, strconv.Itoa(int(reqPayload.BindPort))), false)
|
|
if err != nil {
|
|
// A privileged port under a non-root bound identity fails the
|
|
// bind with EACCES.
|
|
h.conn.logf("ssh: reverse port forward bind %s:%d: %v", reqPayload.BindAddr, reqPayload.BindPort, err)
|
|
return false, []byte{}
|
|
}
|
|
|
|
// If the bind port was 0, use the actual port in the forwards map.
|
|
destPort := reqPayload.BindPort
|
|
if tl, ok := ln.Addr().(*net.TCPAddr); ok {
|
|
destPort = uint32(tl.Port)
|
|
}
|
|
addr := net.JoinHostPort(reqPayload.BindAddr, strconv.Itoa(int(destPort)))
|
|
h.mu.Lock()
|
|
h.forwards[addr] = ln
|
|
h.mu.Unlock()
|
|
// Close this listener when the SSH connection ends. Stop watching
|
|
// the connection when the accept loop exits, including on cancellation
|
|
// of this forward while the connection remains open.
|
|
stop := context.AfterFunc(ctx, func() { ln.Close() })
|
|
go func() {
|
|
defer stop()
|
|
defer func() {
|
|
h.mu.Lock()
|
|
if h.forwards[addr] == ln {
|
|
delete(h.forwards, addr)
|
|
}
|
|
h.mu.Unlock()
|
|
ln.Close()
|
|
}()
|
|
for {
|
|
nc, err := ln.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
originAddr, originPortStr, _ := net.SplitHostPort(nc.RemoteAddr().String())
|
|
originPort, _ := strconv.ParseUint(originPortStr, 10, 16)
|
|
payload := gossh.Marshal(&fwdRemoteForwardChannelData{
|
|
DestAddr: reqPayload.BindAddr,
|
|
DestPort: destPort,
|
|
OriginAddr: originAddr,
|
|
OriginPort: uint32(originPort),
|
|
})
|
|
go func() {
|
|
ch, reqs, err := sshConn.OpenChannel("forwarded-tcpip", payload)
|
|
if err != nil {
|
|
nc.Close()
|
|
return
|
|
}
|
|
go gossh.DiscardRequests(reqs)
|
|
fwdBicopy(ctx, ch, nc)
|
|
}()
|
|
}
|
|
}()
|
|
return true, gossh.Marshal(&fwdRemoteForwardSuccess{destPort})
|
|
|
|
case "cancel-tcpip-forward":
|
|
var reqPayload fwdRemoteForwardCancelRequest
|
|
if err := gossh.Unmarshal(req.Payload, &reqPayload); err != nil {
|
|
return false, nil
|
|
}
|
|
addr := net.JoinHostPort(reqPayload.BindAddr, strconv.Itoa(int(reqPayload.BindPort)))
|
|
h.mu.Lock()
|
|
ln, ok := h.forwards[addr]
|
|
if ok {
|
|
delete(h.forwards, addr)
|
|
}
|
|
h.mu.Unlock()
|
|
if ok {
|
|
ln.Close()
|
|
}
|
|
return true, nil
|
|
|
|
default:
|
|
return false, nil
|
|
}
|
|
}
|
|
|
|
// fwdBicopy copies data bidirectionally between c1 and c2 until both
|
|
// directions complete or the context is canceled, half-closing the write
|
|
// side of each destination on EOF (RFC 4254 §5.3).
|
|
func fwdBicopy(ctx context.Context, c1, c2 io.ReadWriteCloser) {
|
|
defer c1.Close()
|
|
defer c2.Close()
|
|
|
|
var wg sync.WaitGroup
|
|
wg.Go(func() {
|
|
defer fwdHalfCloseWrite(c1)
|
|
_, _ = io.Copy(c1, c2)
|
|
})
|
|
wg.Go(func() {
|
|
defer fwdHalfCloseWrite(c2)
|
|
_, _ = io.Copy(c2, c1)
|
|
})
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
wg.Wait()
|
|
close(done)
|
|
}()
|
|
select {
|
|
case <-done:
|
|
return
|
|
case <-ctx.Done():
|
|
c1.Close()
|
|
c2.Close()
|
|
<-done
|
|
}
|
|
}
|
|
|
|
// fwdHalfCloseWrite signals EOF on the write side of c if c supports
|
|
// CloseWrite, as *net.TCPConn, *net.UnixConn, and gossh.Channel do.
|
|
// Otherwise the deferred full Close in fwdBicopy handles cleanup.
|
|
func fwdHalfCloseWrite(c io.ReadWriteCloser) {
|
|
type closeWriter interface {
|
|
CloseWrite() error
|
|
}
|
|
if cw, ok := c.(closeWriter); ok {
|
|
_ = cw.CloseWrite()
|
|
}
|
|
}
|
|
|
|
// wireForwarding installs the conn's direct forwarding and reverse TCP
|
|
// handlers. Socket creation goes through the credential-bound monitor children;
|
|
// direct forwarding also handles channel closure independently of the connection.
|
|
func (c *conn) wireForwarding(ss *gliderssh.Server) {
|
|
ss.ChannelHandlers["direct-tcpip"] = c.fwdDirectTCPIPHandler
|
|
ss.ChannelHandlers["direct-streamlocal@openssh.com"] = fwdDirectStreamLocalHandler
|
|
h := &fwdTCPHandler{conn: c}
|
|
ss.RequestHandlers["tcpip-forward"] = h.HandleSSHRequest
|
|
ss.RequestHandlers["cancel-tcpip-forward"] = h.HandleSSHRequest
|
|
}
|
|
|
|
// removeAgentForwardingDir removes the agent-forwarding directory dir at
|
|
// session end. The removal runs with the daemon's credentials while dir
|
|
// and everything in it belong to the connection's user, so a hostile
|
|
// user must not steer it. It is refused unless dir is still a directory
|
|
// directly under agentDirRoot, and agentDirRoot is itself root-owned, so
|
|
// no component of the path can be swapped for a symlink by the session
|
|
// user. The removal is resolved relative to a pinned handle on
|
|
// agentDirRoot, which follows no symlinks in the name or below it. If
|
|
// the checked directory was swapped for a user-owned one between the
|
|
// check and the removal, what is removed is still only a user-owned
|
|
// entry in agentDirRoot, which the user can remove itself. Failures are
|
|
// logged, never fatal: a leftover temp dir is the fallback.
|
|
func removeAgentForwardingDir(dir string, lu *userMeta, logf logger.Logf) {
|
|
if filepath.Dir(dir) != agentDirRoot || !strings.HasPrefix(filepath.Base(dir), "auth-agent") {
|
|
logf("ssh: refusing to remove agent forwarding dir %s: not an auth-agent directory directly inside %s", dir, agentDirRoot)
|
|
return
|
|
}
|
|
fi, err := os.Stat(agentDirRoot)
|
|
if err != nil {
|
|
logf("ssh: agent forwarding dir root %s: %v", agentDirRoot, err)
|
|
return
|
|
}
|
|
st, ok := fi.Sys().(*syscall.Stat_t)
|
|
if !ok {
|
|
logf("ssh: refusing to remove agent forwarding dir %s: %s owner not available", dir, agentDirRoot)
|
|
return
|
|
}
|
|
if st.Uid != 0 {
|
|
logf("ssh: refusing to remove agent forwarding dir %s: %s is not root-owned", dir, agentDirRoot)
|
|
return
|
|
}
|
|
r, err := os.OpenRoot(agentDirRoot)
|
|
if err != nil {
|
|
logf("ssh: agent forwarding dir root %s: %v", agentDirRoot, err)
|
|
return
|
|
}
|
|
defer r.Close()
|
|
name := filepath.Base(dir)
|
|
fi, err = r.Lstat(name)
|
|
if err != nil {
|
|
if !os.IsNotExist(err) {
|
|
logf("ssh: agent forwarding dir %s: %v", dir, err)
|
|
}
|
|
return
|
|
}
|
|
if err := checkAgentDirRemovable(fi, lu); err != nil {
|
|
logf("ssh: refusing to remove agent forwarding dir %s: %v", dir, err)
|
|
return
|
|
}
|
|
if err := r.RemoveAll(name); err != nil {
|
|
logf("ssh: removing agent forwarding dir: %v", err)
|
|
}
|
|
}
|
|
|
|
// checkAgentDirRemovable verifies one agent-forwarding directory before
|
|
// its root-run removal: a directory (never a symlink), owned by the
|
|
// connection's user, with no group or other permission bits — the shape
|
|
// MkdirTemp leaves behind.
|
|
func checkAgentDirRemovable(fi os.FileInfo, lu *userMeta) error {
|
|
if !fi.Mode().IsDir() {
|
|
return errors.New("agent forwarding dir is not a directory")
|
|
}
|
|
uid, err := strconv.ParseUint(lu.Uid, 10, 32)
|
|
if err != nil {
|
|
return fmt.Errorf("agent forwarding dir: parsing bound uid %q: %w", lu.Uid, err)
|
|
}
|
|
st, ok := fi.Sys().(*syscall.Stat_t)
|
|
if !ok {
|
|
return errors.New("agent forwarding dir: owner not available")
|
|
}
|
|
if st.Uid != uint32(uid) {
|
|
return fmt.Errorf("agent forwarding dir owned by uid %d, want %d", st.Uid, uid)
|
|
}
|
|
if fi.Mode().Perm()&0o077 != 0 {
|
|
return fmt.Errorf("agent forwarding dir mode %04o is group- or other-accessible", fi.Mode().Perm())
|
|
}
|
|
return nil
|
|
}
|