docs: split README into overview and operator guide for release
test / test (push) Has been cancelled
test / test (push) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -174,7 +174,7 @@ func (s *Service) Delete(id int64) error {
|
||||
if err := s.sasl.Delete(a.Login); err != nil {
|
||||
return err
|
||||
}
|
||||
// Drop the application's level-2 limit, if any (README § Rate limiting);
|
||||
// Drop the application's level-2 limit, if any (guide § Rate limiting);
|
||||
// rate_limits has no cascade of its own.
|
||||
if err := s.store.DeleteRateLimit(store.RateLimitScopeApp, id); err != nil {
|
||||
return err
|
||||
@@ -182,7 +182,7 @@ func (s *Service) Delete(id int64) error {
|
||||
return s.Resync()
|
||||
}
|
||||
|
||||
// RateLimit returns the application-level differentiated rate limit (README §
|
||||
// RateLimit returns the application-level differentiated rate limit (guide §
|
||||
// Rate limiting), and whether one is configured, for the application's edit
|
||||
// form.
|
||||
func (s *Service) RateLimit(appID int64) (store.RateLimit, bool, error) {
|
||||
@@ -202,7 +202,7 @@ func (s *Service) SaveRateLimit(appID int64, ips []string, maxMessages, windowSe
|
||||
})
|
||||
}
|
||||
|
||||
// ClearRateLimit removes the application-level rate limit (README § Rate
|
||||
// ClearRateLimit removes the application-level rate limit (guide § Rate
|
||||
// limiting).
|
||||
func (s *Service) ClearRateLimit(appID int64) error {
|
||||
return s.store.DeleteRateLimit(store.RateLimitScopeApp, appID)
|
||||
|
||||
@@ -41,7 +41,7 @@ type Service struct {
|
||||
}
|
||||
|
||||
// NewService builds the domain service. selectorDefault is the DKIM selector
|
||||
// assigned to new domains (README § Environment variables:
|
||||
// assigned to new domains (guide § Environment variables:
|
||||
// DKIM_SELECTOR_DEFAULT); it is operator-configured, not user input. apps is
|
||||
// used only on deletion, to clear the SASL accounts and sender-map bindings of
|
||||
// the domain's applications.
|
||||
@@ -111,7 +111,7 @@ func (s *Service) Delete(id int64) error {
|
||||
}
|
||||
// Drop the domain's own level-2 limit and those of its applications while the
|
||||
// application rows still exist (the cleanup query joins them). rate_limits
|
||||
// has no cascade of its own (ref_id is a plain integer, README § Rate
|
||||
// has no cascade of its own (ref_id is a plain integer, guide § Rate
|
||||
// limiting; architecture.md § Persistence).
|
||||
if err := s.store.DeleteRateLimitsForDomain(id); err != nil {
|
||||
return fmt.Errorf("clear rate limits for %s: %w", d.Name, err)
|
||||
@@ -138,7 +138,7 @@ func (s *Service) DKIMRecord(d store.Domain) (DKIMRecord, error) {
|
||||
return s.odk.Record(d.Name, d.DKIMSelector)
|
||||
}
|
||||
|
||||
// RateLimit returns the domain-level differentiated rate limit (README § Rate
|
||||
// RateLimit returns the domain-level differentiated rate limit (guide § Rate
|
||||
// limiting), and whether one is configured, for the domain's edit form.
|
||||
func (s *Service) RateLimit(domainID int64) (store.RateLimit, bool, error) {
|
||||
return s.store.GetRateLimit(store.RateLimitScopeDomain, domainID)
|
||||
@@ -158,7 +158,7 @@ func (s *Service) SaveRateLimit(domainID int64, ips []string, maxMessages, windo
|
||||
}
|
||||
|
||||
// ClearRateLimit removes the domain-level rate limit, falling back to level 1
|
||||
// only (README § Rate limiting).
|
||||
// only (guide § Rate limiting).
|
||||
func (s *Service) ClearRateLimit(domainID int64) error {
|
||||
return s.store.DeleteRateLimit(store.RateLimitScopeDomain, domainID)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
const certWarnDays = 14
|
||||
|
||||
// Certificate is the state of the TLS certificate Postfix serves on 465/587
|
||||
// (README § Environment variables: TLS_CERT_FILE). The panel only reads it —
|
||||
// (guide § Environment variables: TLS_CERT_FILE). The panel only reads it —
|
||||
// the file is supplied by the reverse proxy through a read-only mount.
|
||||
type Certificate struct {
|
||||
Path string
|
||||
|
||||
@@ -40,7 +40,7 @@ const (
|
||||
// startup). The window itself is configurable; the cadence need not be.
|
||||
retentionInterval = 6 * time.Hour
|
||||
// defaultRetentionDays applies when the configured value is unset/invalid
|
||||
// (README § Environment variables: SEND_LOG_RETENTION_DAYS).
|
||||
// (guide § Environment variables: SEND_LOG_RETENTION_DAYS).
|
||||
defaultRetentionDays = 90
|
||||
)
|
||||
|
||||
@@ -280,12 +280,19 @@ func isQueueIDByte(b byte) bool {
|
||||
return b >= '0' && b <= '9' || b >= 'A' && b <= 'Z' || b >= 'a' && b <= 'z'
|
||||
}
|
||||
|
||||
// Timestamps at the head of a mail.log line. The first is what Postfix's own
|
||||
// postlogd writes, which is what this server runs (maillog_file in
|
||||
// build/postfix-config.sh) — RFC 3339 down to microseconds and with an offset.
|
||||
// The second is syslog's traditional format, for a deployment that routes the
|
||||
// log through syslogd instead; it carries no year and no zone, which is why it
|
||||
// is not the one being matched first.
|
||||
// Timestamps at the head of a mail.log line, in the two formats postlogd
|
||||
// writes (maillog_file in build/postfix-config.sh).
|
||||
//
|
||||
// syslogStampRe is the one that matches in practice today: the format is
|
||||
// controlled by maillog_file_format, which arrived in Postfix 3.9, and the
|
||||
// image is built on Debian's 3.7 — where the parameter does not exist and the
|
||||
// only format is syslog's traditional one. It carries no year and no zone, so
|
||||
// the stamp shown is a wall clock and nothing more, which is all this column
|
||||
// claims to be.
|
||||
//
|
||||
// isoStampRe is for the RFC 3339 format that same parameter selects once the
|
||||
// base image carries a Postfix new enough to offer it. Matching it first costs
|
||||
// one failed anchor per line and means the upgrade needs no change here.
|
||||
var (
|
||||
isoStampRe = regexp.MustCompile(`^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?\s`)
|
||||
syslogStampRe = regexp.MustCompile(`^([A-Z][a-z]{2}\s+\d{1,2} \d{2}:\d{2}:\d{2})\s`)
|
||||
|
||||
@@ -465,7 +465,7 @@ func TestSplitTimestamp(t *testing.T) {
|
||||
stamp, rest string
|
||||
}{
|
||||
{
|
||||
name: "postlogd, which is what this server writes",
|
||||
name: "RFC 3339, which maillog_file_format selects on Postfix 3.9 and up",
|
||||
line: "2026-08-03T05:15:52.219218+00:00 mail postfix/smtp[26]: 4A1B2C3D: to=<a@example.net>, status=sent (250 OK)",
|
||||
stamp: "2026-08-03 05:15:52",
|
||||
rest: "mail postfix/smtp[26]: 4A1B2C3D: to=<a@example.net>, status=sent (250 OK)",
|
||||
@@ -483,11 +483,20 @@ func TestSplitTimestamp(t *testing.T) {
|
||||
rest: "mail opendkim[30]: 4A1B2C3D: DKIM-Signature field added",
|
||||
},
|
||||
{
|
||||
name: "syslog's traditional format, padded day",
|
||||
name: "syslog's traditional format, space-padded day",
|
||||
line: "Aug 3 05:15:52 mail postfix/smtpd[20]: 4A1B2C3D: client=app.example.ru[203.0.113.4]",
|
||||
stamp: "Aug 3 05:15:52",
|
||||
rest: "mail postfix/smtpd[20]: 4A1B2C3D: client=app.example.ru[203.0.113.4]",
|
||||
},
|
||||
{
|
||||
// Copied off the live relay (Postfix 3.7, which has no
|
||||
// maillog_file_format), so this is the shape the panel actually
|
||||
// meets: zero-padded day, and the host is the container's name.
|
||||
name: "syslog's traditional format as the live relay writes it",
|
||||
line: "Aug 08 07:26:41 selfpost postfix/master[231]: daemon started -- version 3.7.11, configuration /etc/postfix",
|
||||
stamp: "Aug 08 07:26:41",
|
||||
rest: "selfpost postfix/master[231]: daemon started -- version 3.7.11, configuration /etc/postfix",
|
||||
},
|
||||
{
|
||||
name: "unrecognised head keeps the whole line",
|
||||
line: "mail postfix/smtp[26]: 4A1B2C3D: to=<a@example.net>, status=sent (250 OK)",
|
||||
|
||||
@@ -27,7 +27,7 @@ import (
|
||||
|
||||
// Store is the persistence the milter needs on the receive path: recording
|
||||
// accepted messages (architecture.md § Mail path) and, for level-2 rate
|
||||
// limiting (README § Rate limiting), looking up the configured limits and
|
||||
// limiting (guide § Rate limiting), looking up the configured limits and
|
||||
// counting recent messages. *store.Store satisfies it; tests substitute a
|
||||
// fake.
|
||||
type Store interface {
|
||||
@@ -76,7 +76,7 @@ func (s *session) Connect(host, family string, port uint16, addr net.IP, m *milt
|
||||
// macros). This is also the earliest stage where both the sending domain (from
|
||||
// the sender) and the application (the login) are known, so the level-2 rate
|
||||
// limit is enforced here: over the limit, the message is refused with a 4xx
|
||||
// tempfail before recipients are even offered (README § Rate limiting).
|
||||
// tempfail before recipients are even offered (guide § Rate limiting).
|
||||
// Enforcement is fail-open — see overLimit.
|
||||
func (s *session) MailFrom(from string, m *milter.Modifier) (milter.Response, error) {
|
||||
s.releaseReservations() // a previous transaction that ended without EOM/ABORT
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
)
|
||||
|
||||
// overLimit reports whether the message currently being received should be
|
||||
// refused under a level-2 differentiated limit (README § Rate limiting). It
|
||||
// refused under a level-2 differentiated limit (guide § Rate limiting). It
|
||||
// checks the domain-level and application-level limits in turn; either being
|
||||
// exceeded is enough to refuse.
|
||||
//
|
||||
@@ -80,7 +80,7 @@ func (s *session) releaseReservations() {
|
||||
}
|
||||
|
||||
// recordRejected writes a send-log row for a message refused by a level-2
|
||||
// limit (README § Rate limiting — refusals are recorded too), so the rejection
|
||||
// limit (guide § Rate limiting — refusals are recorded too), so the rejection
|
||||
// shows up in the monitoring screen. Only MAIL-stage fields are known; the
|
||||
// write is best-effort and never affects the response.
|
||||
func (s *session) recordRejected() {
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Rate-limit scopes (README § Rate limiting). A level-2 limit is attached
|
||||
// Rate-limit scopes (guide § Rate limiting). A level-2 limit is attached
|
||||
// either to a domain (counted across all its applications and IPs) or to a
|
||||
// single application.
|
||||
const (
|
||||
@@ -17,7 +17,7 @@ const (
|
||||
RateLimitScopeApp = "application"
|
||||
)
|
||||
|
||||
// RateLimit is a differentiated level-2 rate limit (README § Rate limiting):
|
||||
// RateLimit is a differentiated level-2 rate limit (guide § Rate limiting):
|
||||
// an optional set of expected client IPs plus a message ceiling over a sliding
|
||||
// window, attached to a domain or an application. It is enforced in the
|
||||
// journal-milter; level 1 (Postfix anvil, architecture.md § Mail path) is the
|
||||
@@ -27,7 +27,7 @@ const (
|
||||
// Both the IP binding and the ceiling are optional in the schema, but a limit
|
||||
// is only enforced when it is Active(): the design deliberately allows an
|
||||
// admin to leave the IP binding empty for apps that send from changing IPs, in
|
||||
// which case only level 1 protects them (README § Rate limiting).
|
||||
// which case only level 1 protects them (guide § Rate limiting).
|
||||
type RateLimit struct {
|
||||
Scope string
|
||||
RefID int64
|
||||
@@ -38,7 +38,7 @@ type RateLimit struct {
|
||||
|
||||
// Active reports whether the limit is fully configured and should be enforced.
|
||||
// A missing IP binding, ceiling or window leaves the differentiated limit
|
||||
// inert (README § Rate limiting): the IP binding is what scopes the limit to a
|
||||
// inert (guide § Rate limiting): the IP binding is what scopes the limit to a
|
||||
// known sender.
|
||||
func (r RateLimit) Active() bool {
|
||||
return len(r.AllowedIPs) > 0 && r.MaxMessages > 0 && r.WindowSeconds > 0
|
||||
@@ -153,7 +153,7 @@ func (s *Store) RateLimit(scope, ref string) (RateLimit, bool, error) {
|
||||
|
||||
// CountMessages returns how many distinct messages the reference (a domain
|
||||
// name or an application login) has queued since t, for the level-2 sliding
|
||||
// window (README § Rate limiting). It counts distinct queue-ids — one message
|
||||
// window (guide § Rate limiting). It counts distinct queue-ids — one message
|
||||
// with many recipients is one message, matching level 1's per-message
|
||||
// semantics — and excludes rows that were themselves rejected by a limit (they
|
||||
// were never sent). It reuses the send log the journal already writes (README
|
||||
|
||||
@@ -17,7 +17,7 @@ const (
|
||||
StatusDeferred = "deferred"
|
||||
StatusBounced = "bounced"
|
||||
// StatusRejected marks a message the journal-milter refused with a 4xx under
|
||||
// a level-2 rate limit (README § Rate limiting). Such a row never gets a
|
||||
// a level-2 rate limit (guide § Rate limiting). Such a row never gets a
|
||||
// queue-id and is excluded from the level-2 message count (it was never
|
||||
// sent).
|
||||
StatusRejected = "rejected"
|
||||
@@ -55,7 +55,7 @@ func (s *Store) InsertQueued(e SendLogEntry) error {
|
||||
}
|
||||
|
||||
// InsertRejected records a message the journal-milter refused under a level-2
|
||||
// rate limit (README § Rate limiting), so the rejection is visible in the
|
||||
// rate limit (guide § Rate limiting), so the rejection is visible in the
|
||||
// send-log UI. Only the fields known at MAIL FROM are set (domain, sender, app
|
||||
// login); there is no queue-id or recipient because the message was rejected
|
||||
// before it was queued.
|
||||
|
||||
@@ -30,7 +30,7 @@ type detailView struct {
|
||||
FormAddrs string
|
||||
NewCred *newCred
|
||||
// RateLimitErr surfaces a validation error from a domain- or
|
||||
// application-level rate-limit form (README § Rate limiting) as a page
|
||||
// application-level rate-limit form (guide § Rate limiting) as a page
|
||||
// banner.
|
||||
RateLimitErr string
|
||||
// ExportErr surfaces a rejected encryption password from the export card.
|
||||
|
||||
@@ -11,13 +11,13 @@ import (
|
||||
)
|
||||
|
||||
// defaultRateLimitWindowSeconds is the sliding-window length used when an
|
||||
// admin sets a message ceiling but leaves the window blank (README § Rate
|
||||
// limiting, matching the level-1 default hour; README § Environment variables:
|
||||
// admin sets a message ceiling but leaves the window blank (guide § Rate
|
||||
// limiting, matching the level-1 default hour; guide § Environment variables:
|
||||
// RATE_LIMIT_WINDOW_SECONDS).
|
||||
const defaultRateLimitWindowSeconds = 3600
|
||||
|
||||
// rateLimitInput is the validated result of a rate-limit form submission.
|
||||
// clear means "remove the differentiated limit" (README § Rate limiting: an
|
||||
// clear means "remove the differentiated limit" (guide § Rate limiting: an
|
||||
// empty IP binding leaves only level 1).
|
||||
type rateLimitInput struct {
|
||||
clear bool
|
||||
@@ -42,7 +42,7 @@ func parseRateLimitForm(r *http.Request) (rateLimitInput, error) {
|
||||
return rateLimitInput{}, err
|
||||
}
|
||||
if len(ips) == 0 {
|
||||
// No IP binding: the differentiated limit does not apply (README § Rate
|
||||
// No IP binding: the differentiated limit does not apply (guide § Rate
|
||||
// limiting).
|
||||
return rateLimitInput{clear: true}, nil
|
||||
}
|
||||
@@ -92,7 +92,7 @@ func parsePositiveInt(raw string, def int) (int, error) {
|
||||
}
|
||||
|
||||
// handleDomainRateLimit saves or clears a domain-level differentiated rate
|
||||
// limit (README § Rate limiting). No reload is needed — the milter reads the
|
||||
// limit (guide § Rate limiting). No reload is needed — the milter reads the
|
||||
// row live.
|
||||
func (s *Server) handleDomainRateLimit(w http.ResponseWriter, r *http.Request) {
|
||||
d, ok := s.lookupDomain(w, r)
|
||||
@@ -116,7 +116,7 @@ func (s *Server) handleDomainRateLimit(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// handleAppRateLimit saves or clears an application-level differentiated rate
|
||||
// limit (README § Rate limiting).
|
||||
// limit (guide § Rate limiting).
|
||||
func (s *Server) handleAppRateLimit(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := s.lookupApplication(w, r)
|
||||
if !ok {
|
||||
|
||||
+2
-2
@@ -24,7 +24,7 @@ var assetsFS embed.FS
|
||||
// Config holds the panel's HTTP-facing configuration.
|
||||
type Config struct {
|
||||
// Hostname is the server's external hostname, used to build the absolute
|
||||
// setup link shown in the logs (security.md; README § Environment
|
||||
// setup link shown in the logs (security.md; guide § Environment
|
||||
// variables for SELFPOST_HOSTNAME).
|
||||
Hostname string
|
||||
// CookieSecure sets the Secure attribute on the session cookie. It defaults
|
||||
@@ -53,7 +53,7 @@ type Config struct {
|
||||
// honoured, so the header can't be spoofed by anyone but a trusted proxy.
|
||||
// Empty (the default) keeps rate-limiting keyed on RemoteAddr only.
|
||||
TrustedProxyCIDRs []*net.IPNet
|
||||
// TLSCertFile is the certificate Postfix serves on 465/587 (README §
|
||||
// TLSCertFile is the certificate Postfix serves on 465/587 (guide §
|
||||
// Environment variables), read read-only by the status page to report how
|
||||
// much validity is left.
|
||||
TLSCertFile string
|
||||
|
||||
Reference in New Issue
Block a user