feat(cli): add opt-in macOS unprivileged log-send socket

This commit is contained in:
Dev Scribe authored and Cuong Manh Le committed 2026-10-07 14:18:22 +07:00
1 parent cee5bbe7ae
commit bfc46ef265
19 files changed
+1686 -7

No files matched your search

+21 -2
View File
@@ -226,11 +226,30 @@ func InitLogCmd(rootCmd *cobra.Command) *cobra.Command {
logSendCmd := &cobra.Command{
Use: "send",
Short: "Send runtime debug logs to ControlD",
Long: "Send runtime debug logs to ControlD. On macOS, an administrator can enable service.allow_unprivileged_log_send and restart ctrld to allow standard users to send bounded diagnostics. --full still requires elevation.",
Args: cobra.NoArgs,
PreRun: func(cmd *cobra.Command, args []string) {
PreRunE: func(cmd *cobra.Command, args []string) error {
if delegatedLogSendCLI() {
if full, _ := cmd.Flags().GetBool("full"); full {
return errors.New("log send --full requires administrator privileges")
}
return nil
}
checkHasElevatedPrivilege()
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
// The standard-user macOS route must precede service.Status and the
// administrative socket dial: both depend on the calling user's
// launchd context. socketDir already ran when InitLogCmd built the
// command; for a standard user it falls back to the home directory
// without error, so it is harmless here.
if delegatedLogSendCLI() {
full, _ := cmd.Flags().GetBool("full")
return runDelegatedLogSend(cmd.Context(), full)
}
return lc.SendLogs(cmd, args)
},
RunE: lc.SendLogs,
}
logSendCmd.Flags().Bool("full", false, "Send every log file, not only the newest 10 MB of debug")
+3 -3
View File
@@ -335,17 +335,18 @@ func (p *prog) registerControlServerHandler() {
}))
p.cs.register(viewLogsPath, http.HandlerFunc(p.handleLogView))
p.cs.register(sendLogsPath, http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
if time.Since(p.internalLogSent) < logWriterSentInterval {
if !p.logUpload.reserve() {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
defer p.logUpload.release()
r, err := p.logReader(wantFullLogs(request), true)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
defer r.r.Close()
if r.size == 0 {
r.r.Close()
w.WriteHeader(http.StatusMovedPermanently)
return
}
@@ -371,7 +372,6 @@ func (p *prog) registerControlServerHandler() {
if err := json.NewEncoder(w).Encode(&resp); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
p.internalLogSent = time.Now()
}))
p.cs.register(tailLogsPath, http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
flusher, ok := w.(http.Flusher)
+134
View File
@@ -0,0 +1,134 @@
package cli
import (
"context"
"io"
"net/http"
"sync"
"time"
)
// logUploadGate is shared by both sockets. Reserve before opening or collecting
// anything, and cool down after completion, including failures. Logging reloads
// must never reset this state.
type logUploadGate struct {
mu sync.Mutex
busy bool
next time.Time
}
func (g *logUploadGate) reserve() bool {
g.mu.Lock()
defer g.mu.Unlock()
if g.busy || time.Now().Before(g.next) {
return false
}
g.busy = true
return true
}
func (g *logUploadGate) release() {
g.mu.Lock()
defer g.mu.Unlock()
g.busy = false
g.next = time.Now().Add(logWriterSentInterval)
}
const delegatedSendTimeout = 5 * time.Minute
type logSendPeerKey struct{}
// logSendServer has no administrative mux. Its dependencies are immutable
// snapshots of daemon state, never request arguments or reloaded config pointers.
type logSendServer struct {
gate *logUploadGate
collect func(context.Context) (io.ReadCloser, error)
upload func(context.Context, io.ReadCloser) error
audit func(int64, int)
server *http.Server
cancel context.CancelFunc
mu sync.Mutex
stopped bool
active sync.WaitGroup
}
func (s *logSendServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
uid, known := r.Context().Value(logSendPeerKey{}).(uint32)
caller := int64(-1)
if known {
caller = int64(uid)
}
code := http.StatusForbidden
accepted := false
admitted := false
defer func() {
if admitted {
defer s.active.Done()
}
if accepted && s.audit != nil {
s.audit(caller, code)
}
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Connection", "close")
w.WriteHeader(code) // status only: no logs, credentials, paths or API errors
}()
if !known {
return
}
if r.Method != http.MethodPost || r.RequestURI != sendLogsPath || r.URL.RawQuery != "" || r.ContentLength != 0 || len(r.TransferEncoding) != 0 || r.Header.Get("Expect") != "" {
code = http.StatusBadRequest
return
}
s.mu.Lock()
if s.stopped {
s.mu.Unlock()
code = http.StatusServiceUnavailable
return
}
s.active.Add(1)
admitted = true
s.mu.Unlock()
if !s.gate.reserve() {
code = http.StatusServiceUnavailable
return
}
accepted = true
defer s.gate.release()
ctx, cancel := context.WithTimeout(r.Context(), delegatedSendTimeout)
defer cancel()
body, err := s.collect(ctx)
if err != nil {
code = http.StatusPreconditionFailed
return
}
defer body.Close()
if err := s.upload(ctx, body); err != nil {
code = http.StatusBadGateway
return
}
code = http.StatusOK
}
func (s *logSendServer) stop() {
s.mu.Lock()
s.stopped = true
s.mu.Unlock()
if s.cancel != nil {
s.cancel()
}
if s.server != nil {
_ = s.server.Close()
}
s.active.Wait()
}
// The lifecycle lock also covers startup racing with shutdown. Stop fences new
// requests, cancels active uploads, and joins them before log writers close.
func (p *prog) stopLogSendServer() {
p.logSendMu.Lock()
defer p.logSendMu.Unlock()
p.logSendStopped = true
if p.logSend != nil {
p.logSend.stop()
}
}
+37
View File
@@ -0,0 +1,37 @@
package cli
import (
"context"
"errors"
"io"
"net/http/httptest"
"testing"
)
func TestDelegatedLogSendAuditDoesNotAmplifyRejections(t *testing.T) {
var audits int
s := &logSendServer{gate: &logUploadGate{},
collect: func(context.Context) (io.ReadCloser, error) { return nil, errors.New("synthetic failure") },
audit: func(uid int64, status int) {
audits++
if uid != 501 || status != 412 {
t.Errorf("audit uid=%d status=%d", uid, status)
}
},
}
for i := 0; i < 100; i++ {
s.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("POST", sendLogsPath, nil))
req := httptest.NewRequest("GET", sendLogsPath+"?full=1", nil).WithContext(context.WithValue(context.Background(), logSendPeerKey{}, uint32(501)))
s.ServeHTTP(httptest.NewRecorder(), req)
}
if audits != 0 {
t.Fatalf("rejected request audits=%d", audits)
}
req := httptest.NewRequest("POST", sendLogsPath, nil).WithContext(context.WithValue(context.Background(), logSendPeerKey{}, uint32(501)))
for i := 0; i < 100; i++ {
s.ServeHTTP(httptest.NewRecorder(), req)
}
if audits != 1 {
t.Fatalf("admitted plus cooldown audits=%d, want 1", audits)
}
}
+193
View File
@@ -0,0 +1,193 @@
package cli
import (
"context"
"encoding/binary"
"errors"
"fmt"
"io"
"net"
"os"
"path/filepath"
"syscall"
"time"
"unsafe"
"github.com/Control-D-Inc/ctrld"
"github.com/Control-D-Inc/ctrld/internal/controld"
"golang.org/x/sys/unix"
)
// /private/var/run is group-writable on macOS and fails the trust walk.
const logSendSocketDir = "/private/var/ctrld-log-send"
const logSendSocketPath = logSendSocketDir + "/send.sock"
const delegatedJournalBytes int64 = 6 << 20
func logSendPeerUID(fd int) (uint32, error) {
cred, err := unix.GetsockoptXucred(fd, unix.SOL_LOCAL, unix.LOCAL_PEERCRED)
if err != nil {
return 0, err
}
// XNU bsd/sys/ucred.h defines XUCRED_VERSION as 0; this version of
// x/sys/unix exposes Xucred but does not export that constant.
if cred.Version != 0 {
return 0, errLogSendTrust
}
return cred.Uid, nil
}
// A POSIX mode alone cannot exclude a macOS ACL grant. Conservatively refuse
// extended ACLs (even deny-only ACLs), and fail closed if the kernel cannot report
// them. fgetattrlist returns a length and an attrreference; absent ACL => length 0.
// See XNU vfs_attrlist.c, ATTR_CMN_EXTENDED_SECURITY packing. Using the open fd
// avoids a pathname race and does not need cgo or a privileged helper process.
func logSendCheckACL(fd int) error {
attrs := unix.Attrlist{Bitmapcount: 5, Commonattr: unix.ATTR_CMN_EXTENDED_SECURITY}
var buf [12]byte
//lint:ignore SA1019 x/sys has no fgetattrlist libSystem wrapper; preserve descriptor-relative ACL checks in CGO_ENABLED=0 builds.
_, _, errno := syscall.Syscall6(unix.SYS_FGETATTRLIST, uintptr(fd), uintptr(unsafe.Pointer(&attrs)), uintptr(unsafe.Pointer(&buf[0])), uintptr(len(buf)), 0, 0)
if errno != 0 {
return errno
}
if binary.LittleEndian.Uint32(buf[:4]) != 12 || binary.LittleEndian.Uint32(buf[8:]) != 0 {
return errLogSendTrust
}
return nil
}
func delegatedLogSendCLI() bool { return os.Geteuid() != 0 }
func runDelegatedLogSend(ctx context.Context, full bool) error {
err := requestDelegatedLogSend(ctx, full, func(ctx context.Context) (net.Conn, error) {
return dialLogSend(ctx, trustedLogTree{root: "/", uid: 0}, logSendSocketPath)
})
if err != nil {
return fmt.Errorf("failed to send logs: %w", err)
}
mainLog.Load().Notice().Msg("Runtime logs sent successfully")
return nil
}
func (p *prog) startLogSendServer() {
p.logSendMu.Lock()
defer p.logSendMu.Unlock()
if p.logSendStopped || p.logSend != nil {
return
}
p.mu.Lock()
enabled := p.cfg.Service.AllowUnprivilegedLogSend
p.mu.Unlock()
if !enabled {
return
}
s, err := p.newDelegatedLogSendServer()
if err != nil {
p.Error().Err(err).Msg("Unprivileged log send disabled: unsafe or unavailable configuration, logs or socket; correct as administrator and restart")
return
}
p.logSend = s
ctrld.Journal(p.Info()).Msg("Unprivileged log send enabled for all local users until service stop")
}
func (p *prog) newDelegatedLogSendServer() (*logSendServer, error) {
if os.Geteuid() != 0 || cdUID == "" || configBase64 != "" {
return nil, errLogSendTrust
}
tree := trustedLogTree{root: "/", uid: 0}
collector := delegatedLogCollector{tree: tree, config: canonicalLogSendPath(v.ConfigFileUsed())}
// Capture source paths and budgets once. A reload cannot redirect this socket
// to new files, identities, or destinations, nor enable an initially off socket.
var files []*rotatingFile
if p.needInternalLogging() {
lw, jw := p.internalWriters()
if lw == nil || jw == nil {
return nil, errLogSendTrust
}
files = []*rotatingFile{lw.rotating(), jw.rotating()}
} else {
files = []*rotatingFile{logPathFile.Load()}
}
for i, rf := range files {
if rf == nil {
return nil, errLogSendTrust
}
rf.mu.Lock()
source := delegatedLogSource{path: canonicalLogSendPath(rf.path), backups: rf.budget.backups, budget: delegatedDebugBytes}
rf.mu.Unlock()
if i == 1 {
source.budget = delegatedJournalBytes
}
collector.sources = append(collector.sources, source)
}
listener, err := collector.listen(logSendSocketDir)
if err != nil {
return nil, err
}
uid, dev := cdUID, cdDev
s := &logSendServer{gate: &p.logUpload, collect: collector.collect,
upload: func(ctx context.Context, body io.ReadCloser) error {
return controld.SendLogs(ctrld.LoggerCtx(ctx, p.logger.Load()), &controld.LogsRequest{UID: uid, Data: body}, dev)
},
audit: func(uid int64, code int) {
ctrld.Journal(p.Info()).Int64("caller_uid", uid).Int("status", code).Msg("Unprivileged log send outcome")
},
}
s.serve(listener, logSendPeer)
return s, nil
}
func (c delegatedLogCollector) listen(dir string) (net.Listener, error) {
// Validate before exposing any capability; repeat the same checks per upload.
if err := c.validate(context.Background()); err != nil {
return nil, err
}
return listenDelegatedLogSend(c.tree, dir)
}
func listenDelegatedLogSend(tree trustedLogTree, dir string) (net.Listener, error) {
parent, err := tree.open(filepath.Dir(dir), true)
if err != nil {
return nil, err
}
defer parent.Close()
if err := unix.Mkdirat(int(parent.Fd()), filepath.Base(dir), 0755); err != nil && !errors.Is(err, unix.EEXIST) {
return nil, err
}
protected, err := tree.open(dir, true)
if err != nil {
return nil, err
}
protected.Close()
path := filepath.Join(dir, "send.sock")
if st, err := os.Lstat(path); err == nil {
stat, ok := st.Sys().(*syscall.Stat_t)
if !ok || stat.Uid != tree.uid || st.Mode()&os.ModeSocket == 0 {
return nil, errLogSendTrust
}
c, err := net.DialTimeout("unix", path, time.Second)
if err == nil {
c.Close()
return nil, errors.New("log-send socket already active")
}
// Only an unequivocally stale endpoint may be removed, not timeout/permission errors.
if !errors.Is(err, syscall.ECONNREFUSED) {
return nil, err
}
if err := os.Remove(path); err != nil {
return nil, err
}
} else if !errors.Is(err, os.ErrNotExist) {
return nil, err
}
l, err := net.ListenUnix("unix", &net.UnixAddr{Name: path, Net: "unix"})
if err != nil {
return nil, err
}
// No handler is serving until chmod succeeds. Parent ownership prevents
// replacement. The administrative control socket remains 0600.
if err := os.Chmod(path, 0666); err != nil {
l.Close()
return nil, err
}
l.SetUnlinkOnClose(true)
return l, nil
}
+143
View File
@@ -0,0 +1,143 @@
package cli
import (
"context"
"errors"
"io"
"net"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// These tests touch only owned temporary files and sockets: no service, PF,
// DNS, elevation, or Control D network calls. Execute on native macOS.
func TestDelegatedLogSendDarwinACL(t *testing.T) {
tree, c := logSendFixture(t)
if got := readDelegated(t, c); got != "89abcdef" {
t.Fatal(got)
}
for _, target := range []string{c.config, c.sources[0].path, tree.root} {
t.Run(filepath.Base(target), func(t *testing.T) {
cmd := exec.Command("/bin/chmod", "+a", "everyone allow read", target)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("set temp ACL: %v: %s", err, out)
}
defer func() {
if out, err := exec.Command("/bin/chmod", "-N", target).CombinedOutput(); err != nil {
t.Errorf("remove temp ACL: %v: %s", err, out)
}
}()
if r, err := c.collect(context.Background()); err == nil {
r.Close()
t.Fatal("extended ACL accepted")
}
})
}
}
func TestDelegatedLogSendDarwinProductionParent(t *testing.T) {
// Read only: exercise the actual ancestor chain, not a 0700 temp substitute.
tree := trustedLogTree{root: "/", uid: 0}
parent, err := tree.open(filepath.Dir(logSendSocketDir), true)
if err != nil {
t.Fatalf("production socket parent is not trusted: %v", err)
}
parent.Close()
}
func TestDelegatedLogSendDarwinWritableParent(t *testing.T) {
tree, _ := logSendFixture(t)
parent := filepath.Join(tree.root, "run")
if err := os.Mkdir(parent, 0755); err != nil {
t.Fatal(err)
}
// Chmod after mkdir makes the fixture independent of the runner's umask.
if err := os.Chmod(parent, 0775); err != nil {
t.Fatal(err)
}
dir := filepath.Join(parent, "socket")
if l, err := listenDelegatedLogSend(tree, dir); !errors.Is(err, errLogSendTrust) {
if l != nil {
l.Close()
}
t.Fatalf("group-writable parent listener error=%v", err)
}
if err := os.Mkdir(dir, 0755); err != nil {
t.Fatal(err)
}
if c, err := dialLogSend(context.Background(), tree, filepath.Join(dir, "send.sock")); !errors.Is(err, errLogSendTrust) {
if c != nil {
c.Close()
}
t.Fatalf("group-writable parent dial error=%v", err)
}
st, err := os.Stat(parent)
if err != nil || st.Mode().Perm() != 0775 {
t.Fatalf("parent permissions were changed: %v, %v", st, err)
}
}
func TestDelegatedLogSendDarwinEmptyStartup(t *testing.T) {
tree, collector := logSendFixture(t)
writeLogSendFixture(t, collector.sources[0].path, "")
dir := filepath.Join(tree.root, "socket")
l, err := collector.listen(dir)
if err != nil {
t.Fatalf("empty trusted logs prevent listener startup: %v", err)
}
s := &logSendServer{gate: &logUploadGate{}, collect: collector.collect,
upload: func(context.Context, io.ReadCloser) error { t.Error("uploaded empty logs"); return nil },
}
s.serve(l, logSendPeer)
t.Cleanup(s.stop)
err = requestDelegatedLogSend(context.Background(), false, func(ctx context.Context) (net.Conn, error) {
return dialLogSend(ctx, tree, filepath.Join(dir, "send.sock"))
})
if err == nil || !strings.Contains(err.Error(), "administrator must check") {
t.Fatalf("empty upload should return a precondition error: %v", err)
}
s.stop()
writeLogSendFixture(t, collector.config, "[service]\nallow_unprivileged_log_send = false\n")
if l, err := collector.listen(dir); err == nil {
l.Close()
t.Fatal("disabled config exposed a listener")
}
}
func TestDelegatedLogSendDarwinListenerLifecycle(t *testing.T) {
tree, _ := logSendFixture(t)
dir := filepath.Join(tree.root, "socket")
l, err := listenDelegatedLogSend(tree, dir)
if err != nil {
t.Fatal(err)
}
path := filepath.Join(dir, "send.sock")
st, err := os.Stat(path)
if err != nil {
l.Close()
t.Fatal(err)
}
if st.Mode().Perm() != 0666 {
t.Errorf("socket mode=%o", st.Mode().Perm())
}
if other, err := listenDelegatedLogSend(tree, dir); err == nil {
other.Close()
t.Error("replaced live listener")
}
if err := l.Close(); err != nil {
t.Fatal(err)
}
if _, err := os.Lstat(path); !os.IsNotExist(err) {
t.Fatalf("socket retained: %v", err)
}
if err := os.Symlink(tree.root, dir+"-alias"); err != nil {
t.Fatal(err)
}
if other, err := listenDelegatedLogSend(tree, dir+"-alias"); err == nil {
other.Close()
t.Fatal("symlink parent accepted")
}
}
+26
View File
@@ -0,0 +1,26 @@
package cli
import (
"errors"
"golang.org/x/sys/unix"
)
// Linux supplies real peer credentials for the portable boundary tests; the
// production capability and CLI remain disabled on this platform.
func logSendPeerUID(fd int) (uint32, error) {
cred, err := unix.GetsockoptUcred(fd, unix.SOL_SOCKET, unix.SO_PEERCRED)
if err != nil {
return 0, err
}
return cred.Uid, nil
}
func logSendCheckACL(fd int) error {
_, err := unix.Fgetxattr(fd, "system.posix_acl_access", nil)
if errors.Is(err, unix.ENODATA) || errors.Is(err, unix.ENOTSUP) {
return nil
}
if err != nil {
return err
}
return errLogSendTrust
}
+82
View File
@@ -0,0 +1,82 @@
//go:build darwin || linux
package cli
import (
"net"
"path/filepath"
"testing"
"time"
)
func TestDelegatedLogSendListenerLimitAndClose(t *testing.T) {
tree, _ := logSendFixture(t)
path := filepath.Join(tree.root, "limit.sock")
base, err := net.Listen("unix", path)
if err != nil {
t.Fatal(err)
}
l := &logSendListener{Listener: base, slots: make(chan struct{}, 1), done: make(chan struct{})}
defer l.Close()
client, err := net.Dial("unix", path)
if err != nil {
t.Fatal(err)
}
defer client.Close()
first, err := l.Accept()
if err != nil {
t.Fatal(err)
}
defer first.Close()
if uid, err := logSendPeer(first); err != nil || uid != tree.uid {
t.Fatalf("wrapped peer uid=%d err=%v", uid, err)
}
accepted := make(chan net.Conn, 1)
failed := make(chan error, 1)
go func() {
c, err := l.Accept()
if err != nil {
failed <- err
return
}
accepted <- c
}()
client2, err := net.Dial("unix", path)
if err != nil {
t.Fatal(err)
}
defer client2.Close()
select {
case c := <-accepted:
c.Close()
t.Fatal("connection limit bypassed")
case <-time.After(20 * time.Millisecond):
}
first.Close()
first.Close() // repeated closes must not release another connection's slot
var second net.Conn
select {
case second = <-accepted:
case err := <-failed:
t.Fatal(err)
case <-time.After(time.Second):
t.Fatal("slot not released")
}
defer second.Close()
go func() {
c, err := l.Accept()
if err == nil {
c.Close()
}
failed <- err
}()
l.Close() // blocked acquisition must terminate, even while second is still open
select {
case err := <-failed:
if err == nil {
t.Fatal("closed listener accepted")
}
case <-time.After(time.Second):
t.Fatal("close did not unblock accept")
}
}
+9
View File
@@ -0,0 +1,9 @@
//go:build !darwin
package cli
import "context"
func delegatedLogSendCLI() bool { return false }
func runDelegatedLogSend(context.Context, bool) error { return nil }
func (p *prog) startLogSendServer() {}
+51
View File
@@ -0,0 +1,51 @@
//go:build darwin || linux
package cli
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"testing"
)
func TestDelegatedLogValidateEmptySources(t *testing.T) {
for _, journal := range []bool{false, true} {
for _, missing := range []bool{false, true} {
t.Run(fmt.Sprintf("journal=%t/missing=%t", journal, missing), func(t *testing.T) {
_, c := logSendFixture(t)
if journal {
c.sources = append(c.sources, delegatedLogSource{path: filepath.Join(c.tree.root, "journal.log"), budget: 4, backups: 2})
}
for _, source := range c.sources {
writeLogSendFixture(t, source.path, "")
if missing {
if err := os.Remove(source.path); err != nil {
t.Fatal(err)
}
}
}
if err := c.validate(context.Background()); err != nil {
t.Fatalf("trusted empty sources prevent startup: %v", err)
}
if r, err := c.collect(context.Background()); !errors.Is(err, errLogFileEmpty) {
if r != nil {
r.Close()
}
t.Fatalf("empty upload error=%v, want %v", err, errLogFileEmpty)
}
// Files can gain data after startup without a service restart.
writeLogSendFixture(t, c.sources[0].path, "new-data")
want := "new-data"
if journal {
want += logWriterLogEndMarker
}
if got := readDelegated(t, c); got != want {
t.Fatalf("upload after append=%q, want %q", got, want)
}
})
}
}
}
+80
View File
@@ -0,0 +1,80 @@
//go:build darwin || linux
package cli
import (
"context"
"net"
"net/http"
"sync"
"syscall"
"time"
)
// Unlike netutil.LimitListener, this wrapper preserves syscall.Conn so the
// HTTP server can authenticate the actual Unix peer after accepting it.
type logSendListener struct {
net.Listener
slots chan struct{}
done chan struct{}
once sync.Once
}
type logSendConn struct {
net.Conn
release func()
once sync.Once
}
func (c *logSendConn) SyscallConn() (syscall.RawConn, error) {
sc, ok := c.Conn.(syscall.Conn)
if !ok {
return nil, net.ErrClosed
}
return sc.SyscallConn()
}
func (c *logSendConn) Close() error {
err := c.Conn.Close()
c.once.Do(c.release)
return err
}
func (l *logSendListener) Accept() (net.Conn, error) {
select {
case l.slots <- struct{}{}:
case <-l.done:
return nil, net.ErrClosed
}
c, err := l.Listener.Accept()
if err != nil {
<-l.slots
return nil, err
}
return &logSendConn{Conn: c, release: func() { <-l.slots }}, nil
}
func (l *logSendListener) Close() error {
l.once.Do(func() { close(l.done) })
return l.Listener.Close()
}
func (s *logSendServer) serve(l net.Listener, peer func(net.Conn) (uint32, error)) {
ctx, cancel := context.WithCancel(context.Background())
s.cancel = cancel
s.server = &http.Server{
Handler: s, ReadHeaderTimeout: 2 * time.Second, ReadTimeout: 3 * time.Second,
WriteTimeout: delegatedSendTimeout + 5*time.Second, MaxHeaderBytes: 1024,
BaseContext: func(net.Listener) context.Context { return ctx },
ConnContext: func(ctx context.Context, c net.Conn) context.Context {
uid, err := peer(c)
if err != nil {
_ = c.Close()
return ctx
}
return context.WithValue(ctx, logSendPeerKey{}, uid)
},
}
s.server.SetKeepAlivesEnabled(false)
go s.server.Serve(&logSendListener{Listener: l, slots: make(chan struct{}, 8), done: make(chan struct{})})
}
+300
View File
@@ -0,0 +1,300 @@
//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")
}
}
+482
View File
@@ -0,0 +1,482 @@
//go:build darwin || linux
package cli
import (
"bufio"
"bytes"
"context"
"errors"
"io"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
ctrld "github.com/Control-D-Inc/ctrld"
"golang.org/x/sys/unix"
)
func logSendFixture(t *testing.T) (trustedLogTree, delegatedLogCollector) {
t.Helper()
// Short names also fit Darwin's 104-byte unix socket path limit.
dir, err := os.MkdirTemp("", "ls-")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.RemoveAll(dir) })
tree := trustedLogTree{root: dir, uid: uint32(os.Geteuid())}
c := delegatedLogCollector{tree: tree, config: filepath.Join(dir, "config.toml"), sources: []delegatedLogSource{{path: filepath.Join(dir, "debug.log"), backups: 2, budget: 8}}}
writeLogSendFixture(t, c.config, "[service]\nallow_unprivileged_log_send = true\n")
writeLogSendFixture(t, c.sources[0].path, "0123456789abcdef")
return tree, c
}
func writeLogSendFixture(t *testing.T, path, data string) {
t.Helper()
if err := os.WriteFile(path, []byte(data), 0600); err != nil {
t.Fatal(err)
}
}
func readDelegated(t *testing.T, c delegatedLogCollector) string {
t.Helper()
r, err := c.collect(context.Background())
if err != nil {
t.Fatal(err)
}
defer r.Close()
b, err := io.ReadAll(r)
if err != nil {
t.Fatal(err)
}
return string(b)
}
func TestDelegatedLogCollectionStripsColor(t *testing.T) {
_, c := logSendFixture(t)
colored := "\x1b[34mINFO\x1b[0m ok\n\x1b[31mERROR\x1b[0m bad\n"
writeLogSendFixture(t, c.sources[0].path, colored)
// The budget counts raw bytes, so it must cover the escapes too.
c.sources[0].budget = int64(len(colored))
got := readDelegated(t, c)
if strings.Contains(got, "\x1b") {
t.Fatalf("delegated upload kept color codes: %q", got)
}
if want := "INFO ok\nERROR bad\n"; got != want {
t.Fatalf("body=%q, want %q", got, want)
}
}
func TestDelegatedLogCollectionBoundedAndPinned(t *testing.T) {
_, c := logSendFixture(t)
writeLogSendFixture(t, c.sources[0].path+".1", strings.Repeat("older", 100))
if got := readDelegated(t, c); got != "89abcdef" {
t.Fatalf("tail=%q", got)
}
c.sources = append(c.sources, delegatedLogSource{path: filepath.Join(c.tree.root, "journal.log"), budget: 4, backups: 2})
writeLogSendFixture(t, c.sources[1].path, "journal-data")
if got := readDelegated(t, c); got != "89abcdef"+logWriterLogEndMarker+"data" {
t.Fatalf("bundle=%q", got)
}
r, err := c.collect(context.Background())
if err != nil {
t.Fatal(err)
}
defer r.Close()
// An opened upload keeps the checked inode even if a privileged rotation
// replaces its name; it cannot switch to the replacement's bytes.
if err := os.Rename(c.sources[0].path, c.sources[0].path+".old"); err != nil {
t.Fatal(err)
}
writeLogSendFixture(t, c.sources[0].path, "replacement-secret")
data, err := io.ReadAll(r)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(data), "secret") || !bytes.HasPrefix(data, []byte("89abcdef")) {
t.Fatalf("unpinned data: %q", data)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if r, err := c.collect(ctx); err == nil {
r.Close()
t.Fatal("canceled collection succeeded")
}
}
func TestDelegatedLogCollectionTrustFailures(t *testing.T) {
cases := map[string]func(*testing.T, *delegatedLogCollector){
"config writable": func(t *testing.T, c *delegatedLogCollector) { os.Chmod(c.config, 0666) },
"log writable": func(t *testing.T, c *delegatedLogCollector) { os.Chmod(c.sources[0].path, 0666) },
"directory writable": func(t *testing.T, c *delegatedLogCollector) { os.Chmod(c.tree.root, 0777) },
"wrong owner": func(t *testing.T, c *delegatedLogCollector) { c.tree.uid++ },
"config symlink": func(t *testing.T, c *delegatedLogCollector) {
os.Rename(c.config, c.config+".real")
os.Symlink(c.config+".real", c.config)
},
"log symlink": func(t *testing.T, c *delegatedLogCollector) {
os.Remove(c.sources[0].path)
os.Symlink(c.config, c.sources[0].path)
},
"backup symlink": func(t *testing.T, c *delegatedLogCollector) { os.Symlink(c.config, c.sources[0].path+".2") },
"hardlink": func(t *testing.T, c *delegatedLogCollector) {
if err := os.Link(c.sources[0].path, c.sources[0].path+".link"); err != nil {
t.Fatal(err)
}
},
"fifo": func(t *testing.T, c *delegatedLogCollector) {
os.Remove(c.sources[0].path)
if err := unix.Mkfifo(c.sources[0].path, 0600); err != nil {
t.Fatal(err)
}
},
"directory as log": func(t *testing.T, c *delegatedLogCollector) {
os.Remove(c.sources[0].path)
os.Mkdir(c.sources[0].path, 0700)
},
"disabled": func(t *testing.T, c *delegatedLogCollector) {
writeLogSendFixture(t, c.config, "[service]\nallow_unprivileged_log_send = false\n")
},
"absent optin": func(t *testing.T, c *delegatedLogCollector) { writeLogSendFixture(t, c.config, "[service]\n") },
"invalid config": func(t *testing.T, c *delegatedLogCollector) { writeLogSendFixture(t, c.config, "[service") },
"huge config": func(t *testing.T, c *delegatedLogCollector) {
writeLogSendFixture(t, c.config, strings.Repeat("a", (1<<20)+1))
},
"missing config": func(t *testing.T, c *delegatedLogCollector) { os.Remove(c.config) },
"relative log": func(t *testing.T, c *delegatedLogCollector) { c.sources[0].path = "debug.log" },
"escape anchor": func(t *testing.T, c *delegatedLogCollector) {
c.sources[0].path = filepath.Join(c.tree.root, "..", "debug.log")
},
"too many backups": func(t *testing.T, c *delegatedLogCollector) { c.sources[0].backups = 65 },
"unbounded budget": func(t *testing.T, c *delegatedLogCollector) { c.sources[0].budget = 0 },
"intermediate symlink": func(t *testing.T, c *delegatedLogCollector) {
os.Symlink(c.tree.root, filepath.Join(c.tree.root, "alias"))
c.sources[0].path = filepath.Join(c.tree.root, "alias", "debug.log")
},
}
for name, mutate := range cases {
t.Run(name, func(t *testing.T) {
_, c := logSendFixture(t)
mutate(t, &c)
if err := c.validate(context.Background()); err == nil {
t.Fatal("unsafe startup validation succeeded")
}
if r, err := c.collect(context.Background()); err == nil {
r.Close()
t.Fatal("unsafe collection succeeded")
}
})
}
}
func startLogSendFixture(t *testing.T, s *logSendServer, peer func(net.Conn) (uint32, error)) (*http.Client, string) {
t.Helper()
tree, _ := logSendFixture(t)
path := filepath.Join(tree.root, "send.sock")
l, err := net.Listen("unix", path)
if err != nil {
t.Fatal(err)
}
s.serve(l, peer)
t.Cleanup(s.stop)
tr := &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { return dialLogSend(ctx, tree, path) }, DisableKeepAlives: true}
t.Cleanup(tr.CloseIdleConnections)
return &http.Client{Transport: tr, Timeout: 5 * time.Second}, path
}
func logSendStatus(t *testing.T, c *http.Client, method, path, body string) int {
t.Helper()
req, err := http.NewRequest(method, "http://unix"+path, strings.NewReader(body))
if err != nil {
t.Fatal(err)
}
resp, err := c.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
if len(b) != 0 {
t.Fatalf("response leaked data: %q", b)
}
return resp.StatusCode
}
func TestDelegatedLogSendRealTransportAndIsolation(t *testing.T) {
_, collector := logSendFixture(t)
var sent string
var caller int64 = -2
var auditCode int
var mu sync.Mutex
s := &logSendServer{gate: &logUploadGate{}, collect: collector.collect,
upload: func(ctx context.Context, r io.ReadCloser) error {
b, err := io.ReadAll(r)
sent = string(b)
return err
},
audit: func(uid int64, code int) { mu.Lock(); defer mu.Unlock(); caller, auditCode = uid, code },
}
client, _ := startLogSendFixture(t, s, logSendPeer)
for _, path := range []string{"/clients", "/started", "/reload", "/deactivation", "/cd", "/iface", "/log/view", "/log/tail", "/log/send?full=1", "/log/send?url=https://attacker", "/log/send?path=/etc/passwd", "/log/send/", "/log/%73end", "//log/send"} {
if got := logSendStatus(t, client, "POST", path, ""); got != 400 {
t.Fatalf("%s status=%d", path, got)
}
}
for _, method := range []string{"GET", "PUT", "DELETE", "CONNECT"} {
if got := logSendStatus(t, client, method, sendLogsPath, ""); got != 400 {
t.Fatalf("%s status=%d", method, got)
}
}
if got := logSendStatus(t, client, "POST", sendLogsPath, `{"uid":"attacker","full":true}`); got != 400 {
t.Fatal(got)
}
if got := logSendStatus(t, client, "POST", sendLogsPath, ""); got != 200 {
t.Fatal(got)
}
if sent != "89abcdef" {
t.Fatalf("uploaded=%q", sent)
}
mu.Lock()
uid, code := caller, auditCode
mu.Unlock()
if uid != int64(os.Geteuid()) || code != 200 {
t.Fatalf("audit uid=%d status=%d", uid, code)
}
if got := logSendStatus(t, client, "POST", sendLogsPath, ""); got != 503 {
t.Fatal(got)
}
}
func TestDelegatedLogSendGateSharedWithAdmin(t *testing.T) {
p := &prog{cfg: &ctrld.Config{}, cs: &controlServer{mux: http.NewServeMux()}}
p.registerControlServerHandler()
entered, release := make(chan struct{}), make(chan struct{})
var collections atomic.Int32
s := &logSendServer{gate: &p.logUpload, collect: func(context.Context) (io.ReadCloser, error) {
collections.Add(1)
close(entered)
<-release
return io.NopCloser(strings.NewReader("data")), nil
}, upload: func(context.Context, io.ReadCloser) error { return errors.New("secret upstream error") }}
client, _ := startLogSendFixture(t, s, logSendPeer)
done := make(chan int, 1)
go func() {
resp, err := client.Post("http://unix"+sendLogsPath, "", nil)
if err != nil {
done <- 0
return
}
defer resp.Body.Close()
done <- resp.StatusCode
}()
<-entered
var wg sync.WaitGroup
for i := 0; i < 24; i++ {
wg.Add(1)
go func() {
defer wg.Done()
r := httptest.NewRecorder()
p.cs.mux.ServeHTTP(r, httptest.NewRequest("POST", sendLogsPath+"?full=1", nil))
if r.Code != 503 {
t.Errorf("admin concurrent=%d", r.Code)
}
}()
}
for i := 0; i < 8; i++ {
wg.Add(1)
go func() {
defer wg.Done()
r := httptest.NewRecorder()
req := httptest.NewRequest("POST", sendLogsPath, nil).WithContext(context.WithValue(context.Background(), logSendPeerKey{}, uint32(42)))
s.ServeHTTP(r, req)
if r.Code != 503 {
t.Errorf("delegated concurrent=%d", r.Code)
}
}()
}
wg.Wait()
close(release)
if got := <-done; got != 502 {
t.Fatal(got)
}
if collections.Load() != 1 {
t.Fatal(collections.Load())
}
r := httptest.NewRecorder()
p.cs.mux.ServeHTTP(r, httptest.NewRequest("POST", sendLogsPath, nil))
if r.Code != 503 {
t.Fatalf("admin failure cooldown=%d", r.Code)
}
// Expire the cooldown explicitly, without sleeping a minute. The actual admin
// handler fails before an upload on an empty config, then reserves cooldown too.
p.logUpload.mu.Lock()
p.logUpload.next = time.Time{}
p.logUpload.mu.Unlock()
oldUID := cdUID
cdUID = ""
defer func() { cdUID = oldUID }()
r = httptest.NewRecorder()
p.cs.mux.ServeHTTP(r, httptest.NewRequest("POST", sendLogsPath, nil))
if r.Code != 301 {
t.Fatalf("empty admin=%d", r.Code)
}
if got := logSendStatus(t, client, "POST", sendLogsPath, ""); got != 503 {
t.Fatalf("delegated after admin=%d", got)
}
}
func TestDelegatedLogSendFailureCooldownAndMissingIdentity(t *testing.T) {
var calls int
s := &logSendServer{gate: &logUploadGate{}, collect: func(context.Context) (io.ReadCloser, error) { calls++; return nil, errors.New("secret path") }}
req := httptest.NewRequest("POST", sendLogsPath, nil)
r := httptest.NewRecorder()
s.ServeHTTP(r, req)
if r.Code != 403 || calls != 0 {
t.Fatalf("missing identity: code=%d calls=%d", r.Code, calls)
}
req = req.WithContext(context.WithValue(context.Background(), logSendPeerKey{}, uint32(501)))
r = httptest.NewRecorder()
s.ServeHTTP(r, req)
if r.Code != 412 || r.Body.Len() != 0 || calls != 1 {
t.Fatalf("collection failure: %+v calls=%d", r, calls)
}
r = httptest.NewRecorder()
s.ServeHTTP(r, req)
if r.Code != 503 || calls != 1 {
t.Fatal("failure did not reserve cooldown")
}
client, _ := startLogSendFixture(t, &logSendServer{}, func(net.Conn) (uint32, error) { return 0, errors.New("no credentials") })
if resp, err := client.Post("http://unix"+sendLogsPath, "", nil); err == nil {
resp.Body.Close()
t.Fatal("unverified peer accepted")
}
}
func TestDelegatedLogSendShutdownCancelsAndJoins(t *testing.T) {
entered, exited := make(chan struct{}), make(chan struct{})
s := &logSendServer{gate: &logUploadGate{}, collect: func(context.Context) (io.ReadCloser, error) { return io.NopCloser(strings.NewReader("data")), nil }, upload: func(ctx context.Context, _ io.ReadCloser) error {
close(entered)
<-ctx.Done()
close(exited)
return ctx.Err()
}}
client, path := startLogSendFixture(t, s, logSendPeer)
done := make(chan struct{})
go func() {
defer close(done)
resp, err := client.Post("http://unix"+sendLogsPath, "", nil)
if err == nil {
resp.Body.Close()
}
}()
<-entered
s.stop()
select {
case <-exited:
default:
t.Fatal("stop did not join upload")
}
<-done
if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("socket not removed: %v", err)
}
p := &prog{}
p.stopLogSendServer()
if !p.logSendStopped {
t.Fatal("startup not fenced")
}
}
func TestDelegatedLogSendClientAndImpersonation(t *testing.T) {
tree, _ := logSendFixture(t)
path := filepath.Join(tree.root, "send.sock")
var dials int
dial := func(context.Context) (net.Conn, error) { dials++; return nil, errors.New("not running") }
if err := requestDelegatedLogSend(context.Background(), true, dial); err == nil || dials != 0 {
t.Fatal("full dialed")
}
if err := requestDelegatedLogSend(context.Background(), false, dial); err == nil || !strings.Contains(err.Error(), "administrator") {
t.Fatal(err)
}
writeLogSendFixture(t, path, "not a socket")
if c, err := dialLogSend(context.Background(), tree, path); err == nil {
c.Close()
t.Fatal("regular file accepted")
}
os.Remove(path)
s := &logSendServer{gate: &logUploadGate{}, collect: func(context.Context) (io.ReadCloser, error) { return io.NopCloser(strings.NewReader("x")), nil }, upload: func(context.Context, io.ReadCloser) error { return nil }}
l, err := net.Listen("unix", path)
if err != nil {
t.Fatal(err)
}
s.serve(l, logSendPeer)
defer s.stop()
if err := requestDelegatedLogSend(context.Background(), false, func(ctx context.Context) (net.Conn, error) { return dialLogSend(ctx, tree, path) }); err != nil {
t.Fatal(err)
}
os.Chmod(tree.root, 0777)
if c, err := dialLogSend(context.Background(), tree, path); err == nil {
c.Close()
t.Fatal("writable location accepted")
}
os.Chmod(tree.root, 0700)
// A nonroot peer cannot impersonate root, even though it can create a socket
// in its own directory. Production validation starts at /, not this test anchor.
bad := tree
bad.uid++
if c, err := dialLogSend(context.Background(), bad, path); err == nil {
c.Close()
t.Fatal("wrong owner accepted")
}
os.Symlink(path, path+".alias")
if c, err := dialLogSend(context.Background(), tree, path+".alias"); err == nil {
c.Close()
t.Fatal("symlink socket accepted")
}
}
func TestDelegatedLogSendRawRequestsBounded(t *testing.T) {
s := &logSendServer{gate: &logUploadGate{}, collect: func(context.Context) (io.ReadCloser, error) {
t.Error("invalid request collected logs")
return nil, errors.New("unexpected")
}}
_, path := startLogSendFixture(t, s, logSendPeer)
for _, raw := range []string{
"POST /log/send HTTP/1.1\r\nHost: unix\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\n",
"POST /log/send HTTP/1.1\r\nHost: unix\r\nContent-Length: 999999999\r\n\r\n",
"POST /log/send HTTP/1.1\r\nHost: unix\r\nExpect: 100-continue\r\n\r\n",
"POST /log/send HTTP/1.1\r\nHost: unix\r\nX-Large: " + strings.Repeat("x", 8192) + "\r\n\r\n",
} {
conn, err := net.Dial("unix", path)
if err != nil {
t.Fatal(err)
}
conn.SetDeadline(time.Now().Add(5 * time.Second))
io.WriteString(conn, raw)
resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
if err != nil {
conn.Close()
t.Fatal(err)
}
if resp.StatusCode != 400 && resp.StatusCode != 431 {
t.Errorf("unexpected status %d", resp.StatusCode)
}
resp.Body.Close()
conn.Close()
}
}
func TestDelegatedLogSendCanonicalAliases(t *testing.T) {
for from, to := range map[string]string{"/var/log/ctrld.log": "/private/var/log/ctrld.log", "/etc/ctrld.toml": "/private/etc/ctrld.toml", "/private/var/log/a": "/private/var/log/a", "/Users/u/alias/log": "/Users/u/alias/log"} {
if got := canonicalLogSendPath(from); got != to {
t.Fatalf("%q => %q", from, got)
}
}
}
-1
View File
@@ -410,7 +410,6 @@ func (p *prog) initInternalLogging(externalCores []zapcore.Core) {
// mainLog, not p.logger: a test drives this setup on a bare prog.
mainLog.Load().Notice().Msg("Internal logging enabled")
p.internalLogWriter = newLogWriter()
p.internalLogSent = time.Now().Add(-logWriterSentInterval)
p.internalJournalWriter = newSmallLogWriter()
p.openInternalLogFiles()
// A restart appends to the file it finds, so the header marks the
+6 -1
View File
@@ -149,7 +149,10 @@ type prog struct {
internalJournalWriter *logWriter
querySampler errorSampler
lastNetworkState atomic.Pointer[netmon.State]
internalLogSent time.Time
logUpload logUploadGate
logSendMu sync.Mutex
logSendStopped bool
logSend *logSendServer
runningIface string
requiredMultiNICsConfig bool
@@ -1033,6 +1036,7 @@ func (p *prog) run(reload bool, reloadCh chan struct{}) {
}
p.Debug().Msgf("Control server started: %s", p.cs.addr)
}
p.startLogSendServer()
}
p.onStartedDone = make(chan struct{})
p.loop = make(map[string]bool)
@@ -1378,6 +1382,7 @@ func (p *prog) startOSState(f func()) bool {
// resolver that is already gone. Safe to call multiple times.
func (p *prog) restoreOSState() error {
p.restoreOnce.Do(func() {
p.stopLogSendServer()
p.osStateMu.Lock()
p.osStateRestored = true
p.osStateMu.Unlock()
+3
View File
@@ -228,6 +228,9 @@ func (c *Config) FirstUpstream() *UpstreamConfig {
// ServiceConfig specifies the general ctrld config.
type ServiceConfig struct {
// AllowUnprivilegedLogSend is a macOS-only, administrator-controlled opt-in.
AllowUnprivilegedLogSend bool `mapstructure:"allow_unprivileged_log_send" toml:"allow_unprivileged_log_send,omitempty"`
LogLevel string `mapstructure:"log_level" toml:"log_level,omitempty"`
LogPath string `mapstructure:"log_path" toml:"log_path,omitempty"`
LogMaxSizeMB int `mapstructure:"log_max_size_mb" toml:"log_max_size_mb,omitempty" validate:"omitempty,gte=1,lte=1024"`
+21
View File
@@ -67,6 +67,27 @@ func TestLoadDefaultConfig(t *testing.T) {
assert.Len(t, cfg.Upstream, 2)
}
func TestAllowUnprivilegedLogSendConfig(t *testing.T) {
for _, tc := range []struct {
name string
body string
want bool
}{
{"absent", "[service]\n", false},
{"disabled", "[service]\nallow_unprivileged_log_send = false\n", false},
{"enabled", "[service]\nallow_unprivileged_log_send = true\n", true},
} {
t.Run(tc.name, func(t *testing.T) {
v := viper.New()
v.SetConfigType("toml")
require.NoError(t, v.ReadConfig(strings.NewReader(tc.body)))
var cfg ctrld.Config
require.NoError(t, v.Unmarshal(&cfg))
assert.Equal(t, tc.want, cfg.Service.AllowUnprivilegedLogSend)
})
}
}
func TestConfigOverride(t *testing.T) {
v := viper.NewWithOptions(viper.KeyDelimiter("::"))
ctrld.InitConfig(v, "test_load_config")
+34
View File
@@ -105,6 +105,40 @@ The `[service]` section controls general behaviors.
log_path = "log.txt"
```
### allow_unprivileged_log_send
macOS only. Default: `false`. An administrator can opt in using the service's
root-owned configuration file:
```toml
[service]
allow_unprivileged_log_send = true
```
Restart the service as administrator after enabling. Standard users can then run
`ctrld log send` without `sudo`. **Every local-user process can trigger a
machine-wide diagnostic upload to Control D**, not just the interactive user.
This grants upload permission, not permission to read logs or administer ctrld.
`--full`, `log view`, `log tail`, and other privileged commands remain restricted.
Other platforms retain their existing behavior.
This requires Control D mode, an on-disk TOML configuration, and file-backed logs.
The config, log files, rotated logs, and every parent directory must be root-owned,
not group/world writable, and free of extended ACLs. Symlinks and hard-linked log
files are refused; only the standard macOS `/var`, `/etc`, and `/tmp` aliases are
canonicalized. A user-owned home directory is not a safe service log location.
Do not loosen log permissions or the administrative control socket to enable this
feature. The service does not repair unsafe directories automatically.
The setting and source paths are captured at startup. Reload does not activate a
previously disabled socket or change its sources. Removing the setting or setting
it to `false` in the protected config causes the next upload to be refused even
before restart; an upload already in progress may finish. Restart after disabling
to remove the socket. Service stop cancels and joins active delegated requests.
See [Standard-user uploads on macOS](runtime-internal-logging.md#standard-user-uploads-on-macos)
for bounds, refusal messages, and the native QA release gate.
### log_level
Logging level you wish to enable.
+61
View File
@@ -102,6 +102,67 @@ In `log_path` mode the body holds the send-time header, then the `log_path` file
`ctrld log tail` follows the live debug stream and prints each new line, journal events included.
## Standard-user uploads on macOS
Administrator opt-in `service.allow_unprivileged_log_send` defaults to `false`.
See the [configuration and activation requirements](config.md#allow_unprivileged_log_send).
Enabling it allows **all local-user processes** to request machine-wide diagnostic
uploads. Diagnostics can include other users' activity. It does not expose log
contents to the requester and is not a general authorization framework.
The CLI connects directly to `/private/var/ctrld-log-send/send.sock`, without
checking the user's launchd service status or searching their home directory.
Its root-owned protected parent and connected peer credentials are checked.
The daemon creates the leaf directory without changing existing ancestor
permissions. It does not use macOS's group-writable `/private/var/run`.
The separate socket permits only a bodyless `POST /log/send`, with no query
parameters. Its responses contain status codes only. It cannot select files,
identity, upload destination, commands, or configuration. The administrative
socket remains restricted to mode 0600; `--full` still requires elevation.
Unlike privileged uploads described above, delegated uploads use only checked,
file-backed source snapshots: up to 10 MiB of newest debug bytes and, in internal
logging mode, up to 6 MiB of journal bytes separated by `=== LOG_END ===`.
There is no fresh send-time header, network-command collection, memory fallback,
or line-boundary scan. Existing file headers remain in the retained bytes.
Source/config trust is checked again for every attempt. Unsafe sources fail closed
instead of being skipped. The daemon fixes the device identity and destination.
Trusted empty logs do not prevent socket startup, but an upload requires actual
log bytes; the separator alone is not an upload.
Both privileged and delegated sends share one single-flight gate. The gate is
reserved before collection, and every admitted attempt, successful or failed,
starts a one-minute cooldown on completion. Reloading logs does not reset it.
Delegated requests have a five-minute operation deadline, short header/read
timeouts, bounded headers, at most eight accepted connections, and no keep-alive.
Admitted attempts record caller UID and outcome, without log contents or API error
text. Rejected/malformed/cooldown requests do not produce a per-request audit log,
so local callers cannot force diagnostic rotation by flooding rejections.
Refusals and recovery:
- `log send --full requires administrator privileges`: use the administrator path.
- `log send unavailable: ask an administrator ...`: the opt-in socket may be
disabled, unavailable, unsafe, or absent on an older daemon. Have an administrator
check configuration and restart; the CLI never falls back to a user-owned socket.
- `log send busy or cooling down; retry after one minute`: wait for the current
operation and its cooldown; parallel callers do not bypass the shared limit.
- `administrator must check configuration and log permissions`: the opt-in was
removed or config/log trust checks failed. Correct the protected paths as admin.
- Other failures return a generic message directing the administrator to the
daemon log, without returning upload payloads or upstream credentials.
### Native macOS validation
Before release, verify default-off, enable/restart, disable-before-restart,
restart socket removal, shutdown during upload, standard-user success, `--full`
refusal, privileged command compatibility, older/unavailable daemon behavior,
forbidden direct IPC, and socket impersonation. Verify root-owned default service
paths and actual Darwin peer-credential/ACL syscalls, including extended-ACL
refusals. Linux tests and Darwin cross-compilation do not satisfy this gate.
Automated boundary tests use only temporary synthetic files/sockets and upload
stubs; they do not run service, DNS, or PF operations or upload real diagnostics.
## Notes
- Internal logging is not a replacement for a configured `log_path` file in production.