From bfc46ef265f480ae40bfeae1d662f507f8fcb498 Mon Sep 17 00:00:00 2001 From: Dev Scribe Date: Wed, 7 Oct 2026 07:13:52 +0000 Subject: [PATCH] feat(cli): add opt-in macOS unprivileged log-send socket --- cmd/cli/commands_log.go | 23 +- cmd/cli/control_server.go | 6 +- cmd/cli/log_send.go | 134 ++++++++ cmd/cli/log_send_audit_test.go | 37 +++ cmd/cli/log_send_darwin.go | 193 ++++++++++++ cmd/cli/log_send_darwin_test.go | 143 +++++++++ cmd/cli/log_send_linux.go | 26 ++ cmd/cli/log_send_listener_test.go | 82 +++++ cmd/cli/log_send_other.go | 9 + cmd/cli/log_send_startup_test.go | 51 +++ cmd/cli/log_send_transport_unix.go | 80 +++++ cmd/cli/log_send_unix.go | 300 ++++++++++++++++++ cmd/cli/log_send_unix_test.go | 482 +++++++++++++++++++++++++++++ cmd/cli/log_writer.go | 1 - cmd/cli/prog.go | 7 +- config.go | 3 + config_test.go | 21 ++ docs/config.md | 34 ++ docs/runtime-internal-logging.md | 61 ++++ 19 files changed, 1686 insertions(+), 7 deletions(-) create mode 100644 cmd/cli/log_send.go create mode 100644 cmd/cli/log_send_audit_test.go create mode 100644 cmd/cli/log_send_darwin.go create mode 100644 cmd/cli/log_send_darwin_test.go create mode 100644 cmd/cli/log_send_linux.go create mode 100644 cmd/cli/log_send_listener_test.go create mode 100644 cmd/cli/log_send_other.go create mode 100644 cmd/cli/log_send_startup_test.go create mode 100644 cmd/cli/log_send_transport_unix.go create mode 100644 cmd/cli/log_send_unix.go create mode 100644 cmd/cli/log_send_unix_test.go diff --git a/cmd/cli/commands_log.go b/cmd/cli/commands_log.go index cc599d9..56c38b7 100644 --- a/cmd/cli/commands_log.go +++ b/cmd/cli/commands_log.go @@ -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") diff --git a/cmd/cli/control_server.go b/cmd/cli/control_server.go index 253df5c..855a81b 100644 --- a/cmd/cli/control_server.go +++ b/cmd/cli/control_server.go @@ -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) diff --git a/cmd/cli/log_send.go b/cmd/cli/log_send.go new file mode 100644 index 0000000..d7492dc --- /dev/null +++ b/cmd/cli/log_send.go @@ -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() + } +} diff --git a/cmd/cli/log_send_audit_test.go b/cmd/cli/log_send_audit_test.go new file mode 100644 index 0000000..d2efbda --- /dev/null +++ b/cmd/cli/log_send_audit_test.go @@ -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) + } +} diff --git a/cmd/cli/log_send_darwin.go b/cmd/cli/log_send_darwin.go new file mode 100644 index 0000000..2a486df --- /dev/null +++ b/cmd/cli/log_send_darwin.go @@ -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 +} diff --git a/cmd/cli/log_send_darwin_test.go b/cmd/cli/log_send_darwin_test.go new file mode 100644 index 0000000..25ecc92 --- /dev/null +++ b/cmd/cli/log_send_darwin_test.go @@ -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") + } +} diff --git a/cmd/cli/log_send_linux.go b/cmd/cli/log_send_linux.go new file mode 100644 index 0000000..b924674 --- /dev/null +++ b/cmd/cli/log_send_linux.go @@ -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 +} diff --git a/cmd/cli/log_send_listener_test.go b/cmd/cli/log_send_listener_test.go new file mode 100644 index 0000000..c8d7518 --- /dev/null +++ b/cmd/cli/log_send_listener_test.go @@ -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") + } +} diff --git a/cmd/cli/log_send_other.go b/cmd/cli/log_send_other.go new file mode 100644 index 0000000..7e60fb8 --- /dev/null +++ b/cmd/cli/log_send_other.go @@ -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() {} diff --git a/cmd/cli/log_send_startup_test.go b/cmd/cli/log_send_startup_test.go new file mode 100644 index 0000000..3e22375 --- /dev/null +++ b/cmd/cli/log_send_startup_test.go @@ -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) + } + }) + } + } +} diff --git a/cmd/cli/log_send_transport_unix.go b/cmd/cli/log_send_transport_unix.go new file mode 100644 index 0000000..b5e5967 --- /dev/null +++ b/cmd/cli/log_send_transport_unix.go @@ -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{})}) +} diff --git a/cmd/cli/log_send_unix.go b/cmd/cli/log_send_unix.go new file mode 100644 index 0000000..0d17f2c --- /dev/null +++ b/cmd/cli/log_send_unix.go @@ -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") + } +} diff --git a/cmd/cli/log_send_unix_test.go b/cmd/cli/log_send_unix_test.go new file mode 100644 index 0000000..a28858a --- /dev/null +++ b/cmd/cli/log_send_unix_test.go @@ -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) + } + } +} diff --git a/cmd/cli/log_writer.go b/cmd/cli/log_writer.go index 8fee289..b8c47bc 100644 --- a/cmd/cli/log_writer.go +++ b/cmd/cli/log_writer.go @@ -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 diff --git a/cmd/cli/prog.go b/cmd/cli/prog.go index e2d57d0..ebe38aa 100644 --- a/cmd/cli/prog.go +++ b/cmd/cli/prog.go @@ -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() diff --git a/config.go b/config.go index b9e07d5..e2bf6d6 100644 --- a/config.go +++ b/config.go @@ -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"` diff --git a/config_test.go b/config_test.go index bb2c7b2..42a9599 100644 --- a/config_test.go +++ b/config_test.go @@ -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") diff --git a/docs/config.md b/docs/config.md index 864ea3d..6561215 100644 --- a/docs/config.md +++ b/docs/config.md @@ -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. diff --git a/docs/runtime-internal-logging.md b/docs/runtime-internal-logging.md index 6dcd2c7..fd71306 100644 --- a/docs/runtime-internal-logging.md +++ b/docs/runtime-internal-logging.md @@ -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.