mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-10-12 01:58:46 +02:00
feat(cli): add opt-in macOS unprivileged log-send socket
This commit is contained in:
1 parent
cee5bbe7ae
commit
bfc46ef265
19 files changed
+1686
-7
No files matched your search
+21
-2
@@ -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")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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() {}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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{})})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user