Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions docs/large-transfers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.
35 changes: 34 additions & 1 deletion internal/activity/activity.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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 {
Expand Down Expand Up @@ -225,6 +253,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)
Expand Down
20 changes: 20 additions & 0 deletions internal/activity/activity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package activity
import (
"path/filepath"
"testing"
"time"
)

func TestFileStoreReloadsRecentEvents(t *testing.T) {
Expand Down Expand Up @@ -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)
}
}
50 changes: 49 additions & 1 deletion internal/ftpserver/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -78,6 +85,7 @@ type session struct {
restartSet bool
secure bool
protPrivate bool
monitor bool
statusID int64
}

Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
59 changes: 59 additions & 0 deletions internal/ftpserver/server_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package ftpserver

import (
"bufio"
"bytes"
"context"
"crypto/ecdsa"
Expand Down Expand Up @@ -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")
Expand Down
Loading