mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-10-12 01:58:46 +02:00
301 lines
8.6 KiB
Go
301 lines
8.6 KiB
Go
//go:build darwin || linux
|
|
|
|
package cli
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/pelletier/go-toml/v2"
|
|
"golang.org/x/sys/unix"
|
|
)
|
|
|
|
const delegatedDebugBytes int64 = 10 << 20
|
|
|
|
var errLogSendTrust = errors.New("unsafe log-send location")
|
|
|
|
// trustedLogTree walks from a trusted anchor using descriptor-relative opens.
|
|
// Production always uses / and uid 0; tests anchor at their own private temp
|
|
// directory and uid, without needing root or touching system locations.
|
|
type trustedLogTree struct {
|
|
root string
|
|
uid uint32
|
|
}
|
|
|
|
func canonicalLogSendPath(path string) string {
|
|
path = filepath.Clean(path)
|
|
// macOS's system aliases. Do not resolve arbitrary symlinks in user-selected
|
|
// config/log paths: that could turn an attacker-controlled path into a trusted one.
|
|
for _, alias := range []string{"/var", "/etc", "/tmp"} {
|
|
if path == alias || strings.HasPrefix(path, alias+"/") {
|
|
return "/private" + path
|
|
}
|
|
}
|
|
return path
|
|
}
|
|
|
|
func (t trustedLogTree) check(fd int, dir bool) error {
|
|
var st unix.Stat_t
|
|
if err := unix.Fstat(fd, &st); err != nil {
|
|
return err
|
|
}
|
|
kind := uint32(unix.S_IFREG)
|
|
if dir {
|
|
kind = unix.S_IFDIR
|
|
}
|
|
if st.Uid != t.uid || uint32(st.Mode)&unix.S_IFMT != kind || st.Mode&0022 != 0 || (!dir && st.Nlink != 1) {
|
|
return errLogSendTrust
|
|
}
|
|
return logSendCheckACL(fd)
|
|
}
|
|
|
|
func (t trustedLogTree) open(path string, dir bool) (*os.File, error) {
|
|
if !filepath.IsAbs(path) {
|
|
return nil, errLogSendTrust
|
|
}
|
|
rel, err := filepath.Rel(t.root, filepath.Clean(path))
|
|
if err != nil || rel == ".." || strings.HasPrefix(rel, "../") {
|
|
return nil, errLogSendTrust
|
|
}
|
|
fd, err := unix.Open(t.root, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err = t.check(fd, true); err != nil {
|
|
unix.Close(fd)
|
|
return nil, err
|
|
}
|
|
if rel != "." {
|
|
parts := strings.Split(rel, string(os.PathSeparator))
|
|
for i, part := range parts {
|
|
isDir := i < len(parts)-1 || dir
|
|
flags := unix.O_RDONLY | unix.O_CLOEXEC | unix.O_NOFOLLOW | unix.O_NONBLOCK
|
|
if isDir {
|
|
flags |= unix.O_DIRECTORY
|
|
}
|
|
next, openErr := unix.Openat(fd, part, flags, 0)
|
|
unix.Close(fd)
|
|
if openErr != nil {
|
|
return nil, openErr
|
|
}
|
|
fd = next
|
|
if err = t.check(fd, isDir); err != nil {
|
|
unix.Close(fd)
|
|
return nil, err
|
|
}
|
|
}
|
|
} else if !dir {
|
|
unix.Close(fd)
|
|
return nil, errLogSendTrust
|
|
}
|
|
return os.NewFile(uintptr(fd), path), nil
|
|
}
|
|
|
|
type delegatedLogSource struct {
|
|
path string
|
|
backups int
|
|
budget int64
|
|
}
|
|
type delegatedLogCollector struct {
|
|
tree trustedLogTree
|
|
config string
|
|
sources []delegatedLogSource
|
|
}
|
|
|
|
// collect opens only immutable daemon-selected paths. It never calls the legacy
|
|
// reader (which tolerates untrusted homes and skips errors), never shells out for
|
|
// a network snapshot, and never reads an unbounded journal or memory fallback.
|
|
// Like the administrative upload, the body is stripped of the color codes of the
|
|
// internal debug stream; the source budgets count the bytes before that removal.
|
|
func (c delegatedLogCollector) collect(ctx context.Context) (io.ReadCloser, error) {
|
|
upload, err := c.open(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
lr, err := upload.reader(errLogFileEmpty)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &multiCloser{Reader: newANSIStripReader(lr.r), closers: []io.Closer{lr.r}}, nil
|
|
}
|
|
|
|
// validate checks the same sources as an upload, but empty logs do not prevent
|
|
// startup. No log bytes are read, and every checked descriptor is closed.
|
|
func (c delegatedLogCollector) validate(ctx context.Context) error {
|
|
upload, err := c.open(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
upload.close()
|
|
return nil
|
|
}
|
|
|
|
func (c delegatedLogCollector) open(ctx context.Context) (*logParts, error) {
|
|
config, err := c.tree.open(c.config, false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
data, err := io.ReadAll(io.LimitReader(config, (1<<20)+1))
|
|
config.Close()
|
|
if err != nil || len(data) > 1<<20 {
|
|
return nil, errLogSendTrust
|
|
}
|
|
var permission struct {
|
|
Service struct {
|
|
Allow bool `toml:"allow_unprivileged_log_send"`
|
|
} `toml:"service"`
|
|
}
|
|
if err := toml.Unmarshal(data, &permission); err != nil || !permission.Service.Allow {
|
|
return nil, errLogSendTrust
|
|
}
|
|
var upload logParts
|
|
fail := func(err error) (*logParts, error) { upload.close(); return nil, err }
|
|
if len(c.sources) == 0 || len(c.sources) > 2 {
|
|
return fail(errLogSendTrust)
|
|
}
|
|
for index, source := range c.sources {
|
|
if source.backups < 0 || source.backups > 64 || source.budget <= 0 || source.budget > delegatedDebugBytes {
|
|
return fail(errLogSendTrust)
|
|
}
|
|
// Check the directory even when every file is missing.
|
|
dir, err := c.tree.open(filepath.Dir(source.path), true)
|
|
if err != nil {
|
|
return fail(err)
|
|
}
|
|
dir.Close()
|
|
remaining := source.budget
|
|
var parts []logFilePart
|
|
// Open newest first; hold at most 65 descriptors per stream. Missing rotated
|
|
// files are normal, but unsafe files are errors, not silently skipped.
|
|
for i := 0; i <= source.backups; i++ {
|
|
if err := ctx.Err(); err != nil {
|
|
for _, p := range parts {
|
|
p.f.Close()
|
|
}
|
|
return fail(err)
|
|
}
|
|
path := source.path
|
|
if i > 0 {
|
|
path = fmt.Sprintf("%s.%d", path, i)
|
|
}
|
|
f, err := c.tree.open(path, false)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
continue
|
|
}
|
|
if err != nil {
|
|
for _, p := range parts {
|
|
p.f.Close()
|
|
}
|
|
return fail(err)
|
|
}
|
|
st, err := f.Stat()
|
|
if err != nil {
|
|
f.Close()
|
|
for _, p := range parts {
|
|
p.f.Close()
|
|
}
|
|
return fail(err)
|
|
}
|
|
size := st.Size()
|
|
offset := int64(0)
|
|
if size > remaining {
|
|
offset = size - remaining
|
|
}
|
|
remaining -= size - offset
|
|
parts = append(parts, logFilePart{f: f, size: size, offset: offset})
|
|
}
|
|
// Tail sections are bounded even for a single enormous line. Unlike the
|
|
// admin reader, delegated collection deliberately does no line-boundary scan.
|
|
for i, j := 0, len(parts)-1; i < j; i, j = i+1, j-1 {
|
|
parts[i], parts[j] = parts[j], parts[i]
|
|
}
|
|
if index != 0 && (upload.size > 0 || remaining < source.budget) {
|
|
upload.addBytes([]byte(logWriterLogEndMarker))
|
|
}
|
|
upload.addFiles(parts)
|
|
}
|
|
return &upload, nil
|
|
}
|
|
|
|
func logSendPeer(c net.Conn) (uint32, error) {
|
|
u, ok := c.(syscall.Conn)
|
|
if !ok {
|
|
return 0, errLogSendTrust
|
|
}
|
|
raw, err := u.SyscallConn()
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
var uid uint32
|
|
var credErr error
|
|
if err = raw.Control(func(fd uintptr) { uid, credErr = logSendPeerUID(int(fd)) }); err != nil {
|
|
return 0, err
|
|
}
|
|
return uid, credErr
|
|
}
|
|
|
|
// dialLogSend verifies the protected namespace and the actual connected peer.
|
|
// Socket mode is deliberately world-connectable; its parent must not be writable.
|
|
func dialLogSend(ctx context.Context, tree trustedLogTree, path string) (net.Conn, error) {
|
|
dir, err := tree.open(filepath.Dir(path), true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer dir.Close()
|
|
var st unix.Stat_t
|
|
if err := unix.Fstatat(int(dir.Fd()), filepath.Base(path), &st, unix.AT_SYMLINK_NOFOLLOW); err != nil {
|
|
return nil, err
|
|
}
|
|
if st.Uid != tree.uid || uint32(st.Mode)&unix.S_IFMT != unix.S_IFSOCK {
|
|
return nil, errLogSendTrust
|
|
}
|
|
d := net.Dialer{Timeout: 2 * time.Second}
|
|
conn, err := d.DialContext(ctx, "unix", path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
uid, err := logSendPeer(conn)
|
|
if err != nil || uid != tree.uid {
|
|
conn.Close()
|
|
return nil, errLogSendTrust
|
|
}
|
|
return conn, nil
|
|
}
|
|
|
|
func requestDelegatedLogSend(ctx context.Context, full bool, dial func(context.Context) (net.Conn, error)) error {
|
|
if full {
|
|
return errors.New("log send --full requires administrator privileges")
|
|
}
|
|
transport := &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { return dial(ctx) }, DisableKeepAlives: true}
|
|
defer transport.CloseIdleConnections()
|
|
client := &http.Client{Transport: transport, Timeout: delegatedSendTimeout + 10*time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://unix"+sendLogsPath, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return errors.New("log send unavailable: ask an administrator to enable allow_unprivileged_log_send and restart ctrld, or use sudo")
|
|
}
|
|
defer resp.Body.Close()
|
|
switch resp.StatusCode {
|
|
case http.StatusOK:
|
|
return nil
|
|
case http.StatusServiceUnavailable:
|
|
return errors.New("log send busy or cooling down; retry after one minute")
|
|
case http.StatusPreconditionFailed:
|
|
return errors.New("log send unavailable: administrator must check configuration and log permissions")
|
|
default:
|
|
return errors.New("log send failed; ask an administrator to check the daemon log")
|
|
}
|
|
}
|