test / test (push) Canceled after 0s
Code and config: - C-M7: a DMARC report's explicit <pct>0</pct> stays 0; only an absent pct takes RFC 7489 § 6.3's default 100 (Pct *int). - C-D1: docs/development.md now tables the Debian versions the citations were read at (postfix 3.7.11-0+deb12u1, opendkim and libopendkim11 2.11.0~beta2-8+deb12u1, libmilter1.0.1 8.17.1.9-2+deb12u2, libsasl2-2 and sasl2-bin 2.1.28+dfsg-10, supervisor 4.2.5-1, logrotate 3.21.0-1 - read off the image with dpkg-query, 2026-10-15); the e2e stand's new checkCitedPackageVersions fails when the image it built differs. - C-E2: the e2e zone's SOA serial only grows (CoreDNS v1.14.6's file plugin reloads on a grown serial, not on mtime); Corefile comment says so; TestZoneSerialGrows. - C-K8, C-K9: the e2e compose and stage comments say how --project-directory and .env resolve. - D-arch:154: postfix-config.sh's "Postfix insists" comment says what TLS_README advises and that smtpd reads the key as root. - B-G7: inflight.go's reservationTTL comment: a dropped client does give ABORT (smtpd_proto() -> mail_reset() -> milter_abort()); the TTL is for smtpd dying or the milter connection breaking. - D-guide:301: the nginx fragment's first-certificate command now reaches certbot (--entrypoint certbot, --standalone, port 80 free), and the renewal loop passes --webroot -w /var/www/certbot - a standalone certificate would otherwise renew standalone while nginx holds port 80 (certbot docs, "Renewing certificates": the issuing plugin is used "unless you specify other plugins or options", and options of a successful renewal are saved). The register's proposal missed that. Documents: every D-arch, D-guide and D-sec claim the register's Answers E found false or partly true, rewritten - by three Sonnet agents, one per document, then reviewed: two proposals were obsolete after this stage's fixes and not used (a bounce's From: line - extract_from = "smtp" now; "Origin: null" - Referrer-Policy is same-origin now); a stand observation the agent had dated from a commit is now tied to the e2e test that checks it; the SQLite writers paragraph describes _txlock=immediate; the log tailer's rotation paragraph describes the minute's grace; the guide's nginx steps were put in working order (links and modes before `up`, no reconfigure step). "Deferred" is kept for Postfix's queue in the guide too. guide.md's CRLF from the agent normalised. Stand (2026-10-15, make e2e, exit 0, the version gate and zone serial test included); go vet and go test ./... pass on the server (24 packages). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
129 lines
5.0 KiB
Go
129 lines
5.0 KiB
Go
package e2e
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"math/big"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"time"
|
|
)
|
|
|
|
// selfpostHostname is SELFPOST_HOSTNAME for the whole e2e stand: the
|
|
// certificate's CN/SAN, the panel's SASL realm and Postfix's myhostname/HELO
|
|
// all have to agree on it (plan B.3), so it is defined once here.
|
|
const selfpostHostname = "mail.e2e.test"
|
|
|
|
// prepareStage (re)creates the scratch directory compose.override.yml mounts
|
|
// everything from: /data, the TLS cert Postfix serves on 465, the DNS zone
|
|
// CoreDNS is authoritative for, and the sink-MX's dump directory. Called once
|
|
// per run before `docker compose up`, so every run starts from a clean slate.
|
|
func prepareStage(s *stack) error {
|
|
if err := removeAllBestEffort(s.stageDir); err != nil {
|
|
return fmt.Errorf("clean stage dir: %w", err)
|
|
}
|
|
dirs := []string{"data", "certs", "letsencrypt", "dns-stage", "mail-stage"}
|
|
for _, d := range dirs {
|
|
if err := os.MkdirAll(filepath.Join(s.stageDir, d), 0o755); err != nil {
|
|
return fmt.Errorf("mkdir %s: %w", d, err)
|
|
}
|
|
}
|
|
if err := writeSelfSignedCert(
|
|
filepath.Join(s.stageDir, "certs", "fullchain.pem"),
|
|
filepath.Join(s.stageDir, "certs", "privkey.pem"),
|
|
); err != nil {
|
|
return err
|
|
}
|
|
if err := writeDeployFiles(s); err != nil {
|
|
return err
|
|
}
|
|
corefile, err := os.ReadFile(filepath.Join("dns", "Corefile"))
|
|
if err != nil {
|
|
return fmt.Errorf("read Corefile: %w", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(s.stageDir, "dns-stage", "Corefile"), corefile, 0o644); err != nil {
|
|
return fmt.Errorf("write Corefile: %w", err)
|
|
}
|
|
return writeZone(s.stageDir, nil)
|
|
}
|
|
|
|
// writeDeployFiles puts docker-compose.yml and .env into the stage directory.
|
|
// The stage directory is what the container sees as /selfpost-deploy, the
|
|
// operator's project directory, and a full backup refuses to run unless both
|
|
// files are there to be archived. compose reads the .env too - without
|
|
// --env-file it takes .env from --project-directory - but the shell's value
|
|
// set by stack.compose takes precedence, and the two agree anyway
|
|
// (docs.docker.com/compose/how-tos/environment-variables/variable-interpolation/;
|
|
// assumptions C-K9). The compose file itself is named with -f.
|
|
func writeDeployFiles(s *stack) error {
|
|
compose, err := os.ReadFile(s.baseComposeFile)
|
|
if err != nil {
|
|
return fmt.Errorf("read the shipped compose file: %w", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(s.stageDir, "docker-compose.yml"), compose, 0o644); err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(filepath.Join(s.stageDir, ".env"), []byte("SELFPOST_HOSTNAME="+selfpostHostname+"\n"), 0o644)
|
|
}
|
|
|
|
// removeAllBestEffort deletes path; if a previous run left container-UID files
|
|
// on the bind mount, a root alpine one-shot removes them first.
|
|
func removeAllBestEffort(path string) error {
|
|
if err := os.RemoveAll(path); err == nil {
|
|
return nil
|
|
}
|
|
_ = exec.Command("docker", "run", "--rm", "-v", path+":/wipe", "alpine:3.20",
|
|
"sh", "-c", "rm -rf /wipe/..?* /wipe/.[!.]* /wipe/*").Run()
|
|
return os.RemoveAll(path)
|
|
}
|
|
|
|
// writeSelfSignedCert generates a throwaway RSA key + self-signed certificate
|
|
// for selfpostHostname, valid for a day — this stand never outlives that.
|
|
// Postfix's smtpd_tls_security_level is "may" (opportunistic), not enforced,
|
|
// so the e2e SMTP client simply skips verification of it (plan C.4: "test
|
|
// dependencies... zero new dependencies" — no need for a real CA here).
|
|
func writeSelfSignedCert(certPath, keyPath string) error {
|
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
if err != nil {
|
|
return fmt.Errorf("generate TLS key: %w", err)
|
|
}
|
|
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tmpl := &x509.Certificate{
|
|
SerialNumber: serial,
|
|
Subject: pkix.Name{CommonName: selfpostHostname},
|
|
DNSNames: []string{selfpostHostname},
|
|
NotBefore: time.Now().Add(-time.Hour),
|
|
NotAfter: time.Now().Add(24 * time.Hour),
|
|
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
|
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
|
BasicConstraintsValid: true,
|
|
IsCA: true,
|
|
}
|
|
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
|
if err != nil {
|
|
return fmt.Errorf("create certificate: %w", err)
|
|
}
|
|
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
|
if err := os.WriteFile(certPath, certPEM, 0o644); err != nil {
|
|
return err
|
|
}
|
|
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
|
|
// Readable by everyone, which a real key must never be. On a server the
|
|
// operator gives the key to the container's selfpost group (998) so the
|
|
// panel can put it into a full backup; the runner of this suite is not
|
|
// always root and cannot hand a file to a group it is not in. The key is
|
|
// thrown away with the stage directory.
|
|
if err := os.WriteFile(keyPath, keyPEM, 0o644); err != nil {
|
|
return err
|
|
}
|
|
return os.Chmod(keyPath, 0o644)
|
|
}
|