Files
selfpost/test/e2e/stage.go
T
mixandClaude Opus 5.5 1bafd78364 image: the TLS key's group is the panel's alone, 10997 (audit I1)
Security audit 2026-10-17, finding I1, stage 2; the help mockup is ff3a118.
The guide had operators give the TLS private key to group 10998 so the
panel can archive it, and the Dockerfile puts postfix in that group (for
sasldb2 and the maps): `docker exec -u postfix head privkey.pem` printed
the live key. postfix-config.sh copies the key to a root-only directory
precisely so that a compromised Postfix daemon cannot read it - the mounted
original undid that.

Now a group backupkey (gid 10997, outside the system range like 10998)
whose one member is the panel; the guide (Apache/certbot, nginx, Taking a
full backup, the backup table), Help, and the backup's refusal name 10997
and say why; postfix-config.sh, extract-cert.sh and the Dockerfile's
comments follow. A key still given to 10998 keeps working for the backup.
CHANGELOG has an Upgrading note.

e2e: the stage gives its key to 10997 at 0640 when the runner is root
(stageKeyMode; 0644 otherwise), and testFullBackupRestore, after the
refusal that now names chgrp 10997, checks that the postfix uid cannot read
/etc/postfix/tls/privkey.pem and the panel can.

Docs: security.md § Output and process (the groups); guide; CHANGELOG;
plan.

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

148 lines
5.7 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)})
// As the guide has the operator do: group 10997, the panel's alone, and
// 0640 - the panel can back it up and no Postfix daemon can read it
// (security audit 2026-10-17, I1). A runner that is not root cannot hand
// a file to a group it is not in; then the key is readable by everyone,
// which a real key must never be, and keyGroupStaged says so. The key is
// thrown away with the stage directory.
if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil {
return err
}
return stageKeyMode(keyPath)
}
// keyGroup is the container's group for the TLS key (build/Dockerfile).
const keyGroup = 10997
// keyGroupStaged is whether the stage's key went to keyGroup at 0640 (the
// runner is root) rather than 0644.
var keyGroupStaged bool
// stageKeyMode gives a key the group and mode the guide gives a real one, or
// 0644 when the runner may not.
func stageKeyMode(keyPath string) error {
if err := os.Chown(keyPath, -1, keyGroup); err == nil {
keyGroupStaged = true
return os.Chmod(keyPath, 0o640)
}
keyGroupStaged = false
return os.Chmod(keyPath, 0o644)
}