Files
ctrld/cmd/cli/log_send_unix.go
T

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")
}
}