From 56f4ef049884df6dd83a3bcd8eae378ce3e7ac1b Mon Sep 17 00:00:00 2001 From: pzzzy <119996856+pzzzy@users.noreply.github.com> Date: Wed, 15 Jul 2026 23:40:10 -0400 Subject: [PATCH 1/2] Reduce monitor noise and classify canceled downloads --- docs/large-transfers.md | 4 +++ internal/activity/activity.go | 5 +++ internal/ftpserver/server.go | 50 +++++++++++++++++++++++++- internal/ftpserver/server_test.go | 59 +++++++++++++++++++++++++++++++ internal/httpapi/server.go | 36 ++++++++++++++++++- internal/httpapi/server_test.go | 31 ++++++++++++++++ scripts/monitor.sh | 1 + scripts/protocol-lab.sh | 2 +- scripts/weekly-report.sh | 11 +++++- 9 files changed, 195 insertions(+), 4 deletions(-) diff --git a/docs/large-transfers.md b/docs/large-transfers.md index 7f8bf6f..ed5a6e4 100644 --- a/docs/large-transfers.md +++ b/docs/large-transfers.md @@ -10,6 +10,8 @@ The request history contained several failures at roughly 60 seconds followed by Media clients commonly request a small tail range to inspect container metadata. macftpd records a partial response as a completed download only when it reaches EOF and transfers a meaningful amount: one percent of the object, capped at 8 MiB. A tiny tail probe therefore does not inflate download counts, while a substantial resume that completes the object does. +If a browser, media player, or tunnel closes a response before it finishes, macftpd records the transfer as `canceled`. The byte count and range remain in the event for diagnosis, but the weekly report keeps these client-side interruptions separate from server failures. + ## Integrity checks For an end-to-end transfer test, compare the byte count and SHA-256 digest at the source and destination: @@ -46,4 +48,6 @@ Stale chunk parts older than 24 hours are cleaned opportunistically. The upload, - Confirm sufficient free space for the incoming staged file and, on overwrite, one retained copy of the previous destination. - Compare the final byte count and digest for release or incident validation. - Treat tiny EOF range requests as media probes; use the activity detail, status, bytes, and range fields to distinguish them from completed transfers. +- Review `Client Cancellations` separately from `Failures` in the weekly report. Repeated cancellations at a consistent duration can still reveal a proxy or timeout problem, but isolated broken pipes and resets normally mean the client stopped reading. +- The loopback FTP monitor identifies itself with `CLNT macftpd-monitor`. Intermediate successful probe actions are suppressed, one completed cycle is retained per hour, and every failed action is retained. - When Cloudflare caching is enabled, configure a cache tag and public base URL. Public mutations purge the tag; HTTP mutations fall back to exact object and parent-listing URLs. diff --git a/internal/activity/activity.go b/internal/activity/activity.go index c5b7a59..9f6d73f 100644 --- a/internal/activity/activity.go +++ b/internal/activity/activity.go @@ -225,6 +225,11 @@ func (e Event) humanMessage() string { return fmt.Sprintf("%s %s failed for %s", actor, action, subject) } return fmt.Sprintf("%s %s failed", actor, action) + case "canceled", "cancelled": + if subject != "" { + return fmt.Sprintf("%s %s canceled for %s", actor, action, subject) + } + return fmt.Sprintf("%s %s canceled", actor, action) } if e.DestPath != "" && subject != "" { return fmt.Sprintf("%s %s %s to %s", actor, action, subject, e.DestPath) diff --git a/internal/ftpserver/server.go b/internal/ftpserver/server.go index 08875e3..9e30e49 100644 --- a/internal/ftpserver/server.go +++ b/internal/ftpserver/server.go @@ -49,6 +49,8 @@ type Server struct { publicHook func(string) readNoiseMu sync.Mutex readNoise map[string]readNoiseEvent + monitorMu sync.Mutex + monitorLast time.Time } type readNoiseEvent struct { @@ -59,6 +61,11 @@ type readNoiseEvent struct { const readNoiseReportInterval = 10 * time.Minute +const ( + monitorClientName = "macftpd-monitor" + monitorSuccessInterval = time.Hour +) + type session struct { server *Server conn net.Conn @@ -78,6 +85,7 @@ type session struct { restartSet bool secure bool protPrivate bool + monitor bool statusID int64 } @@ -336,11 +344,16 @@ func (s *session) dispatch(cmd, arg string) bool { case "SYST": s.reply(215, "UNIX Type: L8") case "FEAT": - features := []string{"UTF8", "EPSV", "PASV", "REST STREAM", "SIZE", "MDTM", "MLST type*;size*;modify*;perm*;", "MLSD"} + features := []string{"UTF8", "CLNT", "EPSV", "PASV", "REST STREAM", "SIZE", "MDTM", "MLST type*;size*;modify*;perm*;", "MLSD"} if s.server.tlsConfig != nil { features = append(features, "AUTH TLS", "PBSZ", "PROT") } s.multiline(211, features, "End") + case "CLNT": + if monitorClientAllowed(s.conn.RemoteAddr(), arg) { + s.monitor = true + } + s.reply(200, "Client name noted") case "OPTS": s.reply(200, "OK") case "PWD", "XPWD": @@ -1467,6 +1480,15 @@ func (s *session) loginLimitKey() string { } func (s *session) logActivity(action, outcome, pathValue, destPath string, bytes int64, detail string) { + if s.monitor && outcome == "ok" { + // A successful cleanup is the end-to-end monitor signal. Keep one per + // hour and discard intermediate login/mkdir/upload/download successes. + // Failures are never coalesced. + if action != "delete" || !s.server.monitorSuccessDue() { + return + } + detail = "FTP monitor cycle completed" + } actor := s.username if s.user.Username != "" { actor = s.user.Username @@ -1488,6 +1510,32 @@ func (s *session) logActivity(action, outcome, pathValue, destPath string, bytes }) } +func monitorClientAllowed(remote net.Addr, name string) bool { + if !strings.EqualFold(strings.TrimSpace(name), monitorClientName) || remote == nil { + return false + } + if tcp, ok := remote.(*net.TCPAddr); ok { + return tcp.IP.IsLoopback() + } + host, _, err := net.SplitHostPort(remote.String()) + if err != nil { + host = remote.String() + } + ip := net.ParseIP(strings.Trim(host, "[]")) + return ip != nil && ip.IsLoopback() +} + +func (s *Server) monitorSuccessDue() bool { + s.monitorMu.Lock() + defer s.monitorMu.Unlock() + now := time.Now() + if !s.monitorLast.IsZero() && now.Sub(s.monitorLast) < monitorSuccessInterval { + return false + } + s.monitorLast = now + return true +} + func (s *session) updateStatus(mutate func(*status.Session)) { if s.server.tracker == nil || s.statusID == 0 { return diff --git a/internal/ftpserver/server_test.go b/internal/ftpserver/server_test.go index 5befb20..a9c1027 100644 --- a/internal/ftpserver/server_test.go +++ b/internal/ftpserver/server_test.go @@ -1,6 +1,7 @@ package ftpserver import ( + "bufio" "bytes" "context" "crypto/ecdsa" @@ -50,6 +51,64 @@ func TestPassiveDataPeerMustMatchControlPeer(t *testing.T) { } } +func TestFTPMonitorMarkerRequiresLoopback(t *testing.T) { + loopback := &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 50000} + external := &net.TCPAddr{IP: net.ParseIP("203.0.113.10"), Port: 50000} + if !monitorClientAllowed(loopback, "macftpd-monitor") { + t.Fatal("loopback monitor marker was denied") + } + if monitorClientAllowed(external, "macftpd-monitor") { + t.Fatal("external client was allowed to suppress monitor activity") + } + if monitorClientAllowed(loopback, "ordinary-client") { + t.Fatal("ordinary client was treated as the monitor") + } + + left, right := net.Pipe() + defer left.Close() + defer right.Close() + conn := &testRemoteConn{Conn: left, remote: loopback} + ss := &session{server: &Server{}, conn: conn, writer: bufio.NewWriter(io.Discard)} + ss.dispatch("CLNT", "macftpd-monitor") + if !ss.monitor { + t.Fatal("CLNT did not mark the loopback monitor session") + } +} + +func TestFTPMonitorSuccessesAreCoalescedAfterCompletedCycle(t *testing.T) { + activityLog := activity.New(100) + server := &Server{activity: activityLog} + left, right := net.Pipe() + defer left.Close() + defer right.Close() + ss := &session{server: server, conn: left, username: "admin", monitor: true} + + for _, action := range []string{"login", "mkdir", "upload", "download"} { + ss.logActivity(action, "ok", "/_monitor/probe.txt", "", 10, "probe step") + } + if events := activityLog.Recent(10, 0); len(events) != 0 { + t.Fatalf("intermediate monitor successes were logged: %#v", events) + } + ss.logActivity("delete", "ok", "/_monitor/probe.txt", "", 0, "cleanup") + ss.logActivity("delete", "ok", "/_monitor/probe-2.txt", "", 0, "cleanup") + events := activityLog.Recent(10, 0) + if len(events) != 1 || events[0].Outcome != "ok" || events[0].Detail != "FTP monitor cycle completed" { + t.Fatalf("completed monitor cycle was not coalesced: %#v", events) + } + ss.logActivity("download", "failed", "/_monitor/probe.txt", "", 3, "connection reset") + events = activityLog.Recent(10, 0) + if len(events) != 2 || events[0].Outcome != "failed" { + t.Fatalf("monitor failure was suppressed: %#v", events) + } +} + +type testRemoteConn struct { + net.Conn + remote net.Addr +} + +func (c *testRemoteConn) RemoteAddr() net.Addr { return c.remote } + func TestFTPActiveSessionRevalidatesDisabledUser(t *testing.T) { dir := t.TempDir() store, err := auth.Open(dir + "/users.json") diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 787616c..1a69bea 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -24,6 +24,7 @@ import ( "strconv" "strings" "sync" + "syscall" "time" ftpclient "github.com/jlaffaye/ftp" @@ -2593,7 +2594,15 @@ func (w *countingResponseWriter) Unwrap() http.ResponseWriter { func (s *Server) logDownloadActivity(event activity.Event, result fileServeResult) { event.Bytes = result.Bytes - if result.Err != nil || result.Status >= 400 { + if result.Err != nil && isClientDisconnect(result.Err) { + event.Outcome = "canceled" + detail := fmt.Sprintf("%s canceled status=%d bytes=%d", event.Detail, result.Status, result.Bytes) + if result.Range != "" { + detail += " range=" + result.Range + } + detail += " error=" + result.Err.Error() + event.Detail = detail + } else if result.Err != nil || result.Status >= 400 { event.Outcome = "failed" detail := fmt.Sprintf("%s failed status=%d bytes=%d", event.Detail, result.Status, result.Bytes) if result.Range != "" { @@ -2616,6 +2625,31 @@ func (s *Server) logDownloadActivity(event activity.Event, result fileServeResul s.logActivity(event) } +func isClientDisconnect(err error) bool { + if err == nil { + return false + } + if errors.Is(err, context.Canceled) || errors.Is(err, net.ErrClosed) || + errors.Is(err, io.ErrClosedPipe) || errors.Is(err, syscall.EPIPE) || + errors.Is(err, syscall.ECONNRESET) { + return true + } + message := strings.ToLower(err.Error()) + for _, signal := range []string{ + "broken pipe", + "connection reset by peer", + "client disconnected", + "stream canceled", + "stream cancelled", + "; cancel", + } { + if strings.Contains(message, signal) { + return true + } + } + return false +} + func rangeRunsToEOF(header string, size int64) bool { if size <= 0 { return false diff --git a/internal/httpapi/server_test.go b/internal/httpapi/server_test.go index d85aeed..29da8b8 100644 --- a/internal/httpapi/server_test.go +++ b/internal/httpapi/server_test.go @@ -3,6 +3,7 @@ package httpapi import ( "bytes" "encoding/json" + "errors" "html/template" "mime/multipart" "net/http" @@ -11,6 +12,7 @@ import ( "os" "strconv" "strings" + "syscall" "testing" "time" @@ -54,6 +56,35 @@ func TestTinyTailProbeDoesNotCountAsCompletedLargeDownload(t *testing.T) { } } +func TestClientDisconnectsAreCanceledRatherThanFailed(t *testing.T) { + for _, tc := range []struct { + name string + err error + want bool + }{ + {name: "broken pipe", err: syscall.EPIPE, want: true}, + {name: "reset", err: syscall.ECONNRESET, want: true}, + {name: "HTTP/2 cancel", err: errors.New("stream error: stream ID 7; CANCEL"), want: true}, + {name: "storage failure", err: errors.New("storage read failed"), want: false}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := isClientDisconnect(tc.err); got != tc.want { + t.Fatalf("isClientDisconnect(%v) = %v, want %v", tc.err, got, tc.want) + } + }) + } + + srv := testServer(t) + srv.logDownloadActivity(activity.Event{ + Type: "share_download", Protocol: "http", Actor: "share-link", + Action: "download", Path: "/public/movie.mkv", Detail: "public share download", + }, fileServeResult{Status: http.StatusOK, Bytes: 4 << 20, Method: http.MethodGet, Err: syscall.EPIPE}) + events := srv.activity.Recent(10, 0) + if len(events) != 1 || events[0].Outcome != "canceled" || !strings.Contains(events[0].Message, "canceled") || isSecurityActivity(events[0]) { + t.Fatalf("client disconnect was not recorded as a non-failure cancellation: %#v", events) + } +} + func TestAdminFilesURLRemainsSafeInTemplateURLContext(t *testing.T) { tmpl := template.Must(template.New("url").Funcs(templateFuncs()).Parse(`open`)) var rendered strings.Builder diff --git a/scripts/monitor.sh b/scripts/monitor.sh index 0b0e7cf..23f2fcb 100755 --- a/scripts/monitor.sh +++ b/scripts/monitor.sh @@ -52,6 +52,7 @@ remote_file = f"{remote_dir}/{stamp}.txt" ftp = ftplib.FTP() ftp.connect(host, port, timeout=20) +ftp.sendcmd("CLNT macftpd-monitor") ftp.login(user, password) try: ftp.mkd(remote_dir) diff --git a/scripts/protocol-lab.sh b/scripts/protocol-lab.sh index 878301c..697d08d 100755 --- a/scripts/protocol-lab.sh +++ b/scripts/protocol-lab.sh @@ -45,7 +45,7 @@ ensure_dir(ftp, "_protocol_lab") ensure_dir(ftp, base) ftp.voidcmd("TYPE I") features = "\n".join(ftp.sendcmd("FEAT").splitlines()) -required = ["UTF8", "PASV", "EPSV", "REST STREAM", "SIZE", "MDTM", "MLSD"] +required = ["UTF8", "CLNT", "PASV", "EPSV", "REST STREAM", "SIZE", "MDTM", "MLSD"] missing = [x for x in required if x not in features] if missing: raise SystemExit(f"missing FEAT entries: {missing}") diff --git a/scripts/weekly-report.sh b/scripts/weekly-report.sh index 31810ba..22d7484 100755 --- a/scripts/weekly-report.sh +++ b/scripts/weekly-report.sh @@ -74,6 +74,7 @@ paths.extend(sorted(var_dir.glob("activity.jsonl.*.gz"))) counts = collections.Counter() monitor_counts = collections.Counter() failures = collections.Counter() +cancellations = collections.Counter() monitor_failures = collections.Counter() bytes_by_action = collections.Counter() paths_by_action = collections.Counter() @@ -175,7 +176,10 @@ for path in paths: bytes_by_action[action] += int(size or 0) except (TypeError, ValueError): pass - if outcome not in ("ok", "success"): + if outcome in ("canceled", "cancelled"): + if not monitor and not maintenance: + cancellations[(action, event.get("detail") or "canceled")] += 1 + elif outcome not in ("ok", "success"): if monitor: monitor_failures[(action, event.get("detail") or "failed")] += 1 elif not maintenance: @@ -203,6 +207,11 @@ if failures: for (action, detail), count in failures.most_common(10): detail = str(detail).replace("`", "'") print(f"- {action}: `{count}` `{detail[:160]}`") +if cancellations: + print("\n### Client Cancellations\n") + for (action, detail), count in cancellations.most_common(10): + detail = str(detail).replace("`", "'") + print(f"- {action}: `{count}` `{detail[:160]}`") if monitor_counts: print("\n### Monitor Summary\n") for (action, status), count in monitor_counts.most_common(): From 972e57236a3966e65293f2535cd826b574836161 Mon Sep 17 00:00:00 2001 From: pzzzy <119996856+pzzzy@users.noreply.github.com> Date: Tue, 21 Jul 2026 19:42:29 -0400 Subject: [PATCH 2/2] Improve operational diagnostics and dependency gate --- README.md | 2 + internal/activity/activity.go | 30 +- internal/activity/activity_test.go | 20 + internal/httpapi/server.go | 106 ++++- internal/httpapi/server_test.go | 63 +++ .../httpapi/templates/partial_status.html | 11 +- package-lock.json | 368 ++++++++++-------- package.json | 5 +- scripts/check.sh | 1 + 9 files changed, 421 insertions(+), 185 deletions(-) diff --git a/README.md b/README.md index 7620f8c..98ba87a 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,8 @@ curl "${auth[@]}" 'https://ftp.example.com/api/stats?path=/public/example.mp4' Other admin endpoints include `/api/users`, `/api/groups`, `/api/files`, `/api/files/action`, `/api/upload/chunk`, `/api/download`, `/api/fxp`, `/api/activity`, `/api/status`, `/api/doctor`, `/api/retention`, `/api/retention/restore`, and `/api/cloudflare/purge`. +The authenticated `/api/doctor` response includes build provenance and uptime, effective HTTP timeouts, activity-buffer capacity and age, and individual storage/integration checks. Optional integrations that are disabled are reported as informational rather than failed. The activity dashboard scans the full in-memory history before filtering monitor traffic, so a busy probe loop cannot hide human or security events. + ## Release Gate Before a release candidate, run: diff --git a/internal/activity/activity.go b/internal/activity/activity.go index 9f6d73f..b2752bb 100644 --- a/internal/activity/activity.go +++ b/internal/activity/activity.go @@ -36,6 +36,15 @@ type PathStats struct { Recent []Event `json:"recent,omitempty"` } +type State struct { + Count int `json:"count"` + Capacity int `json:"capacity"` + OldestID int64 `json:"oldest_id,omitempty"` + NewestID int64 `json:"newest_id,omitempty"` + OldestTime time.Time `json:"oldest_time,omitempty"` + NewestTime time.Time `json:"newest_time,omitempty"` +} + type Store struct { mu sync.RWMutex nextID int64 @@ -107,7 +116,7 @@ func (s *Store) Recent(limit int, afterID int64) []Event { if s == nil { return nil } - if limit <= 0 || limit > 500 { + if limit <= 0 { limit = 100 } s.mu.RLock() @@ -123,6 +132,25 @@ func (s *Store) Recent(limit int, afterID int64) []Event { return out } +func (s *Store) State() State { + if s == nil { + return State{} + } + s.mu.RLock() + defer s.mu.RUnlock() + state := State{Count: len(s.events), Capacity: s.limit} + if len(s.events) == 0 { + return state + } + oldest := s.events[0] + newest := s.events[len(s.events)-1] + state.OldestID = oldest.ID + state.NewestID = newest.ID + state.OldestTime = oldest.Time + state.NewestTime = newest.Time + return state +} + func (s *Store) StatsForPath(path string, limit int) PathStats { stats := PathStats{Path: path, Referrers: map[string]int{}} if s == nil { diff --git a/internal/activity/activity_test.go b/internal/activity/activity_test.go index c0c451d..04d3c9a 100644 --- a/internal/activity/activity_test.go +++ b/internal/activity/activity_test.go @@ -3,6 +3,7 @@ package activity import ( "path/filepath" "testing" + "time" ) func TestFileStoreReloadsRecentEvents(t *testing.T) { @@ -30,3 +31,22 @@ func TestFileStoreReloadsRecentEvents(t *testing.T) { t.Fatalf("next ID = %d, want greater than %d", next.ID, events[0].ID) } } + +func TestStateTracksBoundedHistory(t *testing.T) { + store := New(2) + firstTime := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC) + store.Add(Event{Time: firstTime, Action: "first"}) + second := store.Add(Event{Time: firstTime.Add(time.Minute), Action: "second"}) + third := store.Add(Event{Time: firstTime.Add(2 * time.Minute), Action: "third"}) + + state := store.State() + if state.Count != 2 || state.Capacity != 2 { + t.Fatalf("state size = %#v, want 2/2", state) + } + if state.OldestID != second.ID || state.NewestID != third.ID { + t.Fatalf("state IDs = %#v, want oldest=%d newest=%d", state, second.ID, third.ID) + } + if !state.OldestTime.Equal(firstTime.Add(time.Minute)) || !state.NewestTime.Equal(firstTime.Add(2*time.Minute)) { + t.Fatalf("state times = %#v", state) + } +} diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 1a69bea..69d9473 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -20,6 +20,8 @@ import ( "os" "path" "path/filepath" + "runtime" + "runtime/debug" "sort" "strconv" "strings" @@ -53,6 +55,7 @@ type Server struct { tracker *status.Tracker uploadMu sync.Mutex uploads map[string]*uploadLock + startedAt time.Time } type uploadLock struct { @@ -109,7 +112,7 @@ func (r userRequest) user() auth.User { } func New(cfg config.HTTPConfig, store *auth.Store, root *storage.Root, cf *cloudflare.Client, activityLog *activity.Store, links *share.Store, tracker *status.Tracker) *Server { - return &Server{cfg: cfg, store: store, root: root, cloudflare: cf, sessionKey: []byte(cfg.SessionKey), limiter: ratelimit.New(5, 10*time.Minute, 5*time.Minute), shareLimit: ratelimit.New(5, 10*time.Minute, 5*time.Minute), activity: activityLog, links: links, tracker: tracker, uploads: map[string]*uploadLock{}} + return &Server{cfg: cfg, store: store, root: root, cloudflare: cf, sessionKey: []byte(cfg.SessionKey), limiter: ratelimit.New(5, 10*time.Minute, 5*time.Minute), shareLimit: ratelimit.New(5, 10*time.Minute, 5*time.Minute), activity: activityLog, links: links, tracker: tracker, uploads: map[string]*uploadLock{}, startedAt: time.Now().UTC()} } func (s *Server) ListenAndServe(ctx context.Context) error { @@ -1878,13 +1881,10 @@ func (s *Server) activityDashboard(limit int, after int64) activityDashboard { if limit <= 0 || limit > 200 { limit = 80 } - scanLimit := limit * 6 - if scanLimit < 200 { - scanLimit = 200 - } - if scanLimit > 500 { - scanLimit = 500 - } + // The production activity buffer holds 2,000 events. Scan it in full so a + // burst of monitor or maintenance traffic cannot hide human activity and + // security events from the filtered dashboard. + const scanLimit = 2000 dashboard := activityDashboard{ Events: []activity.Event{}, Security: []activity.Event{}, @@ -2011,34 +2011,102 @@ func (s *Server) doctorAPI(w http.ResponseWriter, r *http.Request, _ principal) writeJSON(w, http.StatusMethodNotAllowed, errorBody("method not allowed")) return } - writeJSON(w, http.StatusOK, map[string]any{"checks": s.doctorChecks(), "time": time.Now().UTC()}) + payload := map[string]any{ + "checks": s.doctorChecks(), + "http": s.doctorHTTPStatus(), + "runtime": s.doctorRuntimeStatus(), + "time": time.Now().UTC(), + } + if s.activity != nil { + payload["activity"] = s.activity.State() + } + writeJSON(w, http.StatusOK, payload) } func (s *Server) doctorChecks() []map[string]any { checks := []map[string]any{} - add := func(name string, ok bool, detail string) { - checks = append(checks, map[string]any{"name": name, "ok": ok, "detail": detail}) + add := func(name, level, detail string) { + checks = append(checks, map[string]any{"name": name, "ok": level != "fail", "level": level, "detail": detail}) } if info, err := os.Stat(s.root.Base); err == nil && info.IsDir() { - add("storage root", true, s.root.Base) + add("storage root", "ok", s.root.Base) } else { - add("storage root", false, fmt.Sprint(err)) + add("storage root", "fail", fmt.Sprint(err)) } for _, dir := range []string{s.root.PublicDir, s.root.DropboxDir, "._macftpd_trash", "._macftpd_versions"} { real := filepath.Join(s.root.Base, dir) if err := os.MkdirAll(real, 0o750); err != nil { - add("storage "+dir, false, err.Error()) + add("storage "+dir, "fail", err.Error()) } else { - add("storage "+dir, true, real) + add("storage "+dir, "ok", real) } } - add("cloudflare client", s.cloudflare.Enabled(), "configured="+strconv.FormatBool(s.cloudflare.Enabled())) - add("share store", s.links != nil, strconv.Itoa(len(s.links.List()))+" links") - add("activity store", s.activity != nil, "ready") - add("turnstile", s.cfg.TurnstileSecret != "", "configured="+strconv.FormatBool(s.cfg.TurnstileSecret != "")) + cloudflareLevel := "info" + if s.cloudflare.Enabled() { + cloudflareLevel = "ok" + } + add("cloudflare client", cloudflareLevel, "optional; configured="+strconv.FormatBool(s.cloudflare.Enabled())) + if s.links == nil { + add("share store", "fail", "unavailable") + } else { + add("share store", "ok", strconv.Itoa(len(s.links.List()))+" links") + } + if s.activity == nil { + add("activity store", "fail", "unavailable") + } else { + state := s.activity.State() + detail := fmt.Sprintf("%d/%d events", state.Count, state.Capacity) + if !state.OldestTime.IsZero() { + detail += "; oldest=" + state.OldestTime.UTC().Format(time.RFC3339) + } + add("activity store", "ok", detail) + } + streamLevel := "ok" + if s.cfg.ReadTimeout != 0 || s.cfg.WriteTimeout != 0 { + streamLevel = "warning" + } + add("HTTP streaming", streamLevel, fmt.Sprintf("read_timeout=%s write_timeout=%s", time.Duration(s.cfg.ReadTimeout), time.Duration(s.cfg.WriteTimeout))) + turnstileLevel := "info" + if s.cfg.TurnstileSecret != "" { + turnstileLevel = "ok" + } + add("turnstile", turnstileLevel, "optional; configured="+strconv.FormatBool(s.cfg.TurnstileSecret != "")) return checks } +func (s *Server) doctorHTTPStatus() map[string]string { + return map[string]string{ + "read_header_timeout": s.cfg.ReadHeaderTimeout.Std(10 * time.Second).String(), + "read_timeout": time.Duration(s.cfg.ReadTimeout).String(), + "write_timeout": time.Duration(s.cfg.WriteTimeout).String(), + "idle_timeout": s.cfg.IdleTimeout.Std(60 * time.Second).String(), + } +} + +func (s *Server) doctorRuntimeStatus() map[string]any { + status := map[string]any{ + "go_version": runtime.Version(), + "started_at": s.startedAt, + } + if !s.startedAt.IsZero() { + status["uptime_seconds"] = int64(time.Since(s.startedAt).Seconds()) + } + if info, ok := debug.ReadBuildInfo(); ok { + status["module_version"] = info.Main.Version + for _, setting := range info.Settings { + switch setting.Key { + case "vcs.revision": + status["vcs_revision"] = setting.Value + case "vcs.time": + status["vcs_time"] = setting.Value + case "vcs.modified": + status["vcs_modified"] = setting.Value == "true" + } + } + } + return status +} + func (s *Server) sharesAPI(w http.ResponseWriter, r *http.Request, p principal) { switch r.Method { case http.MethodGet: diff --git a/internal/httpapi/server_test.go b/internal/httpapi/server_test.go index 29da8b8..02a0aba 100644 --- a/internal/httpapi/server_test.go +++ b/internal/httpapi/server_test.go @@ -303,6 +303,69 @@ func TestActivityDashboardSuppressesMonitorAndSeparatesSecurity(t *testing.T) { } } +func TestActivityDashboardScansPastMonitorFlood(t *testing.T) { + srv := testServer(t) + srv.activity = activity.New(2000) + human := srv.activity.Add(activity.Event{Type: "admin_file", Protocol: "http", Actor: "admin", Action: "copy", Path: "/public/important.txt"}) + for i := 0; i < 700; i++ { + srv.activity.Add(activity.Event{Type: "ftp_delete", Protocol: "ftp", Actor: "admin", Remote: "127.0.0.1:50000", Action: "delete", Path: "_monitor/probe.txt", Detail: "FTP monitor cleanup"}) + } + + dashboard := srv.activityDashboard(20, 0) + if dashboard.Monitor.Count != 700 { + t.Fatalf("monitor count = %d, want 700", dashboard.Monitor.Count) + } + if len(dashboard.Events) != 1 || dashboard.Events[0].ID != human.ID { + t.Fatalf("human event was hidden by monitor traffic: %#v", dashboard.Events) + } +} + +func TestDoctorReportsOperationalMetadataAndOptionalChecks(t *testing.T) { + srv := testServer(t) + srv.cfg.WriteTimeout = config.Duration(time.Minute) + srv.activity.Add(activity.Event{Action: "test"}) + + req := httptest.NewRequest(http.MethodGet, "/api/doctor", nil) + req.SetBasicAuth("admin", "secret") + rr := httptest.NewRecorder() + srv.requireAdmin(srv.doctorAPI)(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("doctor status = %d body=%s", rr.Code, rr.Body.String()) + } + var body struct { + Checks []struct { + Name string `json:"name"` + OK bool `json:"ok"` + Level string `json:"level"` + } `json:"checks"` + HTTP map[string]string `json:"http"` + Runtime map[string]any `json:"runtime"` + Activity activity.State `json:"activity"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil { + t.Fatalf("decode doctor response: %v", err) + } + if body.HTTP["write_timeout"] != "1m0s" || body.HTTP["read_timeout"] != "0s" { + t.Fatalf("unexpected HTTP timeout metadata: %#v", body.HTTP) + } + if body.Runtime["go_version"] == "" || body.Runtime["started_at"] == nil { + t.Fatalf("missing runtime metadata: %#v", body.Runtime) + } + if body.Activity.Count != 1 || body.Activity.Capacity != 200 { + t.Fatalf("unexpected activity state: %#v", body.Activity) + } + levels := map[string]string{} + for _, check := range body.Checks { + if !check.OK { + t.Fatalf("non-failing doctor check reported ok=false: %#v", check) + } + levels[check.Name] = check.Level + } + if levels["HTTP streaming"] != "warning" || levels["cloudflare client"] != "info" || levels["turnstile"] != "info" { + t.Fatalf("unexpected doctor check levels: %#v", levels) + } +} + func TestUploadRejectsIgnoredDestination(t *testing.T) { srv := testServer(t) var body bytes.Buffer diff --git a/internal/httpapi/templates/partial_status.html b/internal/httpapi/templates/partial_status.html index 1a8cb47..1c1ebd4 100644 --- a/internal/httpapi/templates/partial_status.html +++ b/internal/httpapi/templates/partial_status.html @@ -13,7 +13,16 @@