Files
selfpost/internal/store/inbound.go
T
mixandClaude Opus 5.5 6b4106f60e upstream-tls-verify: Verified TLS to an inbound domain's upstream
Owner, 2026-10-18: "Оба предложения делаем"; the mockup is c10cd78. The
security audit's M7: Required is Postfix's encrypt level, which checks no
certificate - it keeps the mail from a listener, not from one who answers
in the upstream's place.

- A fourth TLS mode, Verified, stored as Postfix's level secure (migration
  0014 rebuilds inbound_transports with the wider CHECK; schema 14) and
  written to the TLS policy map like the others. Postfix matches the
  certificate against the next hop's name (smtp_tls_secure_cert_match =
  nexthop, dot-nexthop, the default; postconf(5)), so Verified is refused
  for an upstream given by address (checkSecureHost).
- Postfix's SMTP client trusts Debian's ca-certificates bundle
  (smtp_tls_CAfile): Postfix trusts none by default (DEF_SMTP_TLS_CA_FILE
  "", DEF_TLS_APPEND_DEF_CA 0). The levels may and encrypt only log the
  verdict, so mail to recipients' MX is unchanged.
- The page: the select's fourth option, a help line under it, the lead
  "over verified TLS", the list's tag "verified"; Help word for word with
  the mockup; guide § Inbound relay, with a posttls-finger command to try
  an upstream first.

Tests: TestVerifiedTLSNeedsTheUpstreamsName, TestRenderInboundMapsSecure,
TestUpstreamTLSModeSecure; schema tests at 14. e2e
inbound_upstream_verified_tls: an address refused, a name saved as
"[sink]:25 secure" in tls_policy, the page says verified, the CA file set
and present, and posttls-finger -l secure with it calls the stand's
self-signed port 25 "Untrusted" (the stand has no TLS upstream). make e2e
(65 passed) and go vet + go test (26 packages) on the test server for this
tree. Tried against the live upstream from the 2.14.5 container:
mc-ent.mixfed.ru gives "Verified TLS connection established".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 10:27:37 +03:00

284 lines
8.7 KiB
Go

package store
import (
"database/sql"
"errors"
"fmt"
"time"
)
// Recipient modes for an inbound domain. Kept in sync with the CHECK constraint.
const (
RecipientModeList = "list" // only explicitly listed addresses
RecipientModeAny = "any" // any address at the domain
)
// TLS modes for the hand-off to the upstream. Values are Postfix
// smtp_tls_policy_maps levels: may (opportunistic), encrypt (required),
// secure (verified: the certificate checked against the upstream's name;
// migration 0014), none.
const (
TLSModeMay = "may"
TLSModeEncrypt = "encrypt"
TLSModeSecure = "secure"
TLSModeNone = "none"
)
// ErrInboundDomainExists is returned when the inbound domain is already configured.
var ErrInboundDomainExists = errors.New("inbound domain already exists")
// ErrInboundDomainNotFound is returned when an inbound domain id/name does not exist.
var ErrInboundDomainNotFound = errors.New("inbound domain not found")
// InboundDomain is a backup-MX / forwarder domain. Host may be empty until the
// operator saves an upstream; map generation skips those rows so mail is never
// accepted with nowhere to send it. RecipientCount is populated by listing
// queries; Recipients is populated by Get.
type InboundDomain struct {
ID int64
Name string
RecipientMode string
Host string
Port int
TLSMode string
CreatedAt time.Time
RecipientCount int
Recipients []string
}
// AddInboundDomain inserts a new inbound domain with a default transport
// (empty host, port 25, opportunistic TLS) and listed-recipients mode. The
// caller must have validated name (security.md).
func (s *Store) AddInboundDomain(name string) (InboundDomain, error) {
now := time.Now().UTC()
// The first statement is a write (see UpdateStatus).
tx, err := s.db.Begin()
if err != nil {
return InboundDomain{}, fmt.Errorf("begin add inbound domain: %w", err)
}
defer tx.Rollback()
res, err := tx.Exec(
"INSERT INTO inbound_domains (name, recipient_mode, created_at) VALUES (?, ?, ?)",
name, RecipientModeList, now.Format(time.RFC3339),
)
if err != nil {
if isUniqueViolation(err) {
return InboundDomain{}, ErrInboundDomainExists
}
return InboundDomain{}, fmt.Errorf("insert inbound domain: %w", err)
}
id, err := res.LastInsertId()
if err != nil {
return InboundDomain{}, fmt.Errorf("inbound domain id: %w", err)
}
if _, err := tx.Exec(
"INSERT INTO inbound_transports (inbound_domain_id, host, port, tls_mode) VALUES (?, '', 25, ?)",
id, TLSModeMay,
); err != nil {
return InboundDomain{}, fmt.Errorf("insert inbound transport: %w", err)
}
if err := tx.Commit(); err != nil {
return InboundDomain{}, fmt.Errorf("commit add inbound domain: %w", err)
}
return InboundDomain{
ID: id,
Name: name,
RecipientMode: RecipientModeList,
Port: 25,
TLSMode: TLSModeMay,
CreatedAt: now,
}, nil
}
// ListInboundDomains returns every inbound domain with its transport and
// recipient count, ordered by name.
func (s *Store) ListInboundDomains() ([]InboundDomain, error) {
rows, err := s.db.Query(`
SELECT d.id, d.name, d.recipient_mode, d.created_at,
t.host, t.port, t.tls_mode,
(SELECT COUNT(*) FROM inbound_recipients r WHERE r.inbound_domain_id = d.id)
FROM inbound_domains d
INNER JOIN inbound_transports t ON t.inbound_domain_id = d.id
ORDER BY d.name`)
if err != nil {
return nil, fmt.Errorf("list inbound domains: %w", err)
}
defer rows.Close()
var out []InboundDomain
for rows.Next() {
d, err := scanInboundDomain(rows)
if err != nil {
return nil, err
}
out = append(out, d)
}
return out, rows.Err()
}
// ListInboundDomainsForUser returns the inbound domains a domain user reaches,
// ordered by name: the assigned ones, or every inbound domain when the user's
// All flag is set. Like ListDomainsForUser, the filter is in the query.
func (s *Store) ListInboundDomainsForUser(userID int64) ([]InboundDomain, error) {
rows, err := s.db.Query(`
SELECT d.id, d.name, d.recipient_mode, d.created_at,
t.host, t.port, t.tls_mode,
(SELECT COUNT(*) FROM inbound_recipients r WHERE r.inbound_domain_id = d.id)
FROM inbound_domains d
INNER JOIN inbound_transports t ON t.inbound_domain_id = d.id
WHERE EXISTS (SELECT 1 FROM users u WHERE u.id = ? AND u.all_inbound_domains = 1)
OR EXISTS (SELECT 1 FROM user_inbound_domains ud WHERE ud.user_id = ? AND ud.inbound_domain_id = d.id)
ORDER BY d.name`, userID, userID)
if err != nil {
return nil, fmt.Errorf("list inbound domains for user: %w", err)
}
defer rows.Close()
var out []InboundDomain
for rows.Next() {
d, err := scanInboundDomain(rows)
if err != nil {
return nil, err
}
out = append(out, d)
}
return out, rows.Err()
}
// GetInboundDomain returns one inbound domain with its recipient list, or
// ErrInboundDomainNotFound.
func (s *Store) GetInboundDomain(id int64) (InboundDomain, error) {
row := s.db.QueryRow(`
SELECT d.id, d.name, d.recipient_mode, d.created_at,
t.host, t.port, t.tls_mode,
(SELECT COUNT(*) FROM inbound_recipients r WHERE r.inbound_domain_id = d.id)
FROM inbound_domains d
INNER JOIN inbound_transports t ON t.inbound_domain_id = d.id
WHERE d.id = ?`, id)
d, err := scanInboundDomain(row)
if errors.Is(err, sql.ErrNoRows) {
return InboundDomain{}, ErrInboundDomainNotFound
}
if err != nil {
return InboundDomain{}, err
}
addrs, err := s.listInboundRecipients(id)
if err != nil {
return InboundDomain{}, err
}
d.Recipients = addrs
return d, nil
}
// UpdateInboundTransport sets the upstream host, port and TLS mode.
func (s *Store) UpdateInboundTransport(id int64, host string, port int, tlsMode string) error {
res, err := s.db.Exec(
"UPDATE inbound_transports SET host = ?, port = ?, tls_mode = ? WHERE inbound_domain_id = ?",
host, port, tlsMode, id,
)
if err != nil {
return fmt.Errorf("update inbound transport: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("update inbound transport rows: %w", err)
}
if n == 0 {
return ErrInboundDomainNotFound
}
return nil
}
// UpdateInboundRecipients replaces the recipient mode and, in list mode, the
// address list. In any mode the stored list is cleared.
func (s *Store) UpdateInboundRecipients(id int64, mode string, addresses []string) error {
// The first statement is a write (see UpdateStatus).
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("begin update inbound recipients: %w", err)
}
defer tx.Rollback()
res, err := tx.Exec("UPDATE inbound_domains SET recipient_mode = ? WHERE id = ?", mode, id)
if err != nil {
return fmt.Errorf("update inbound recipient mode: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("update inbound recipient mode rows: %w", err)
}
if n == 0 {
return ErrInboundDomainNotFound
}
if _, err := tx.Exec("DELETE FROM inbound_recipients WHERE inbound_domain_id = ?", id); err != nil {
return fmt.Errorf("clear inbound recipients: %w", err)
}
if mode == RecipientModeList {
for _, addr := range addresses {
if _, err := tx.Exec(
"INSERT INTO inbound_recipients (inbound_domain_id, address) VALUES (?, ?)",
id, addr,
); err != nil {
return fmt.Errorf("insert inbound recipient: %w", err)
}
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit update inbound recipients: %w", err)
}
return nil
}
// DeleteInboundDomain removes an inbound domain and its transport/recipients
// (ON DELETE CASCADE). Returns ErrInboundDomainNotFound if no such row existed.
func (s *Store) DeleteInboundDomain(id int64) error {
res, err := s.db.Exec("DELETE FROM inbound_domains WHERE id = ?", id)
if err != nil {
return fmt.Errorf("delete inbound domain: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("delete inbound domain rows: %w", err)
}
if n == 0 {
return ErrInboundDomainNotFound
}
return nil
}
func (s *Store) listInboundRecipients(id int64) ([]string, error) {
rows, err := s.db.Query(
"SELECT address FROM inbound_recipients WHERE inbound_domain_id = ? ORDER BY address",
id,
)
if err != nil {
return nil, fmt.Errorf("list inbound recipients: %w", err)
}
defer rows.Close()
var out []string
for rows.Next() {
var addr string
if err := rows.Scan(&addr); err != nil {
return nil, err
}
out = append(out, addr)
}
return out, rows.Err()
}
func scanInboundDomain(r scanRow) (InboundDomain, error) {
var (
d InboundDomain
createdAt string
)
if err := r.Scan(
&d.ID, &d.Name, &d.RecipientMode, &createdAt,
&d.Host, &d.Port, &d.TLSMode, &d.RecipientCount,
); err != nil {
return InboundDomain{}, err
}
d.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
return d, nil
}