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()