certbot: certs/ links into /etc/letsencrypt, mounted at the same path; the backup follows them
test / test (push) Canceled after 0s

C-A1, C-Q4, D-guide:258, :291, :722. certbot's live/<host>/*.pem are
relative links into ../../archive/<host>/ (eff-certbot docs, "Where are
my certificates?": point a server at them "or create symlinks"). The
guide had `ln -s /etc/letsencrypt/live/<host> certs` and the nginx
fragment mounted live/<host> alone: inside the container the links led
into a directory it did not have - postfix-config.sh's [ -f ] false, no
copy, Postfix without a certificate - and the panel's backup, reading
certs/ through /selfpost-deploy, skipped links silently.

Now certs/fullchain.pem and certs/privkey.pem are links to
/etc/letsencrypt/live/<host>/, and /etc/letsencrypt is mounted read-only
at the same path: a commented line in deploy/docker-compose.yml for the
Apache scenario, mounted in the nginx fragment (./nginx/certbot-etc). The
guide's steps add certbot's own commands for a server that is not root -
chmod 0755 live archive, chgrp 998 + chmod 0640 the key, which certbot
keeps on renewal - so the panel reads them (Health, backup). Postfix's
side follows links already ([ -f ], cp, cmp, openssl). The vhost comment
says the same. The backup table: links are followed by the panel's
backup; a stopped tar stores them as links unless -h (GNU tar).

internal/backup: addTree follows, for certs/ only, a link to a regular
file and archives that file under the link's name; a link to nothing
reachable is an error naming it and the mount.

Tests: TestCertsLinksAreFollowed (certs/ -> live/ -> archive/, the
archive holds the files; the tree gone, Check names the dangling link;
Linux). Stand (2026-10-15, make e2e, exit 0): the stand mounts
./letsencrypt at /etc/letsencrypt; new certbot_layout_followed puts a new
pair into letsencrypt/archive/, relative links in live/, absolute links
in certs/, recreates the container: 465 serves the new serial, Health
names no renewal waiting, and the full backup holds certs/*.pem as
regular files with the archive's bytes. go vet and go test ./... pass on
the server (24 packages); the Linux-only tests of this session run there
and pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-15 01:34:19 +03:00
co-authored by Claude Opus 5.5
parent 3c63ecc1ad
commit 2e1544dac5
13 changed files with 273 additions and 33 deletions
+11
View File
@@ -147,6 +147,17 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
the next update; the second then failed, and with it that start or that the next update; the second then failed, and with it that start or that
report (nothing was damaged). Each update now holds the database from report (nothing was damaged). Each update now holds the database from
its start and checks the version again (assumptions D-S3). its start and checks the version again (assumptions D-S3).
- **Certificates from certbot reach Postfix and the backup.** The guide had
`certs` linked to certbot's `live/<host>`, and the nginx fragment mounted
that directory; its files are links into `archive/`, which the container
did not have, so Postfix started without a certificate and a full backup
went without one. `certs/` now holds two links into `/etc/letsencrypt`,
which is mounted read-only at the same path (a commented line in
`docker-compose.yml`, mounted in the nginx fragment); the guide gives the
two commands certbot's documentation has for a server that is not root.
A full backup follows links under `certs/` and carries the files; a link
that leads nowhere stops it, named (assumptions C-A1, C-Q4). If you use
certbot this way, follow the guide's new "Reverse proxy" steps.
- **Words for what happens to mail match Postfix's.** A message refused with - **Words for what happens to mail match Postfix's.** A message refused with
a temporary error while the panel or the spam filter does not answer was a temporary error while the panel or the spam filter does not answer was
called deferred, which in Postfix's words is mail waiting in its queue; called deferred, which in Postfix's words is mail waiting in its queue;
+7 -5
View File
@@ -7,11 +7,13 @@
# add the SSLCertificateFile/SSLCertificateKeyFile directives and a :80 -> :443 # add the SSLCertificateFile/SSLCertificateKeyFile directives and a :80 -> :443
# redirect vhost, and renews in the background via its own timer. The resulting # redirect vhost, and renews in the background via its own timer. The resulting
# PEM files land at # PEM files land at
# /etc/letsencrypt/live/mail.example.com/{fullchain,privkey}.pem on the host — # /etc/letsencrypt/live/mail.example.com/{fullchain,privkey}.pem on the host,
# bind-mount that directory read-only into the SelfPost container (see # as links into ../../archive/. Link certs/fullchain.pem and certs/privkey.pem
# ../docker-compose.yml, the `./certs:/etc/postfix/tls:ro` volume) so Postfix # to them and mount /etc/letsencrypt read-only at the same path (see
# can present the very same certificate on 465/587. One certificate, two # ../docker-compose.yml and docs/guide.md, "Reverse proxy") so Postfix can
# consumers — no separate cert just for the panel. # present the very same certificate on 465/587. Mounting live/mail.example.com
# alone gives the container links into a directory it does not have. One
# certificate, two consumers — no separate cert just for the panel.
<VirtualHost *:443> <VirtualHost *:443>
ServerName mail.example.com ServerName mail.example.com
+8 -2
View File
@@ -72,9 +72,15 @@ services:
# certs/ are archived alongside /data (architecture.md § Persistence). # certs/ are archived alongside /data (architecture.md § Persistence).
- .:/selfpost-deploy:ro - .:/selfpost-deploy:ro
# Read-only: SelfPost only ever reads certificates, never manages them. # Read-only: SelfPost only ever reads certificates, never manages them.
# Point this at wherever your reverse-proxy/certbot writes PEM files, e.g. # ./certs holds fullchain.pem and privkey.pem: the files themselves, or
# /etc/letsencrypt on the host. # links to where your reverse proxy's certbot keeps them.
- ./certs:/etc/postfix/tls:ro - ./certs:/etc/postfix/tls:ro
# With certbot on the host (docs/guide.md, "Reverse proxy", Apache):
# certs/ holds links into /etc/letsencrypt/live/, and certbot's live/
# files are themselves relative links into archive/ - so the whole
# directory is mounted, at the same path, for the links to lead
# somewhere inside the container. Uncomment it then.
# - /etc/letsencrypt:/etc/letsencrypt:ro
ports: ports:
# 465 (smtps, primary) and optionally 587 (submission/STARTTLS) are # 465 (smtps, primary) and optionally 587 (submission/STARTTLS) are
# published directly — mail traffic bypasses Apache entirely, it only ever # published directly — mail traffic bypasses Apache entirely, it only ever
+7 -4
View File
@@ -31,10 +31,13 @@ services:
volumes: !override volumes: !override
- ./data:/data - ./data:/data
- .:/selfpost-deploy:ro - .:/selfpost-deploy:ro
# Same host directory certbot below writes into — plain bind mount, no # certs/ holds two links, fullchain.pem and privkey.pem, to
# named volume, so the PEM files are as directly inspectable as in the # /etc/letsencrypt/live/mail.example.com/ - a path inside the
# Apache scenario. # container: certbot's directory below is mounted there whole, because
- ./nginx/certbot-etc/live/mail.example.com:/etc/postfix/tls:ro # its live/ files are relative links into archive/ (mounting live/ alone
# left them pointing at nothing). docs/guide.md, "Reverse proxy", nginx.
- ./certs:/etc/postfix/tls:ro
- ./nginx/certbot-etc:/etc/letsencrypt:ro
nginx: nginx:
image: nginx:1.27 image: nginx:1.27
+5 -1
View File
@@ -1005,12 +1005,16 @@ those after it move up one patch number each.
it; a test with two concurrent opens behind a held lock. - **Opus** it; a test with two concurrent opens behind a held lock. - **Opus**
*Done 2026-10-15:* the test fails without the fix exactly as the *Done 2026-10-15:* the test fails without the fix exactly as the
register said ("table settings already exists") and passes with it. register said ("table settings already exists") and passes with it.
- [ ] **Certificates from certbot** (C-A1, C-Q4, D-guide:258, :291, :722): - [x] **Certificates from certbot** (C-A1, C-Q4, D-guide:258, :291, :722):
certbot's `live/` files are relative links into `archive/`, so mounting certbot's `live/` files are relative links into `archive/`, so mounting
`live/<host>` alone, or linking `certs` to it, leaves Postfix without a `live/<host>` alone, or linking `certs` to it, leaves Postfix without a
certificate and the backup without it. The deploy fragments mount certificate and the backup without it. The deploy fragments mount
`/etc/letsencrypt` read-only and point the paths into it; the backup `/etc/letsencrypt` read-only and point the paths into it; the backup
follows a link under `certs/` to a regular file. - **Opus** follows a link under `certs/` to a regular file. - **Opus**
*Done 2026-10-15:* `certs/` holds links into `/etc/letsencrypt/live/`,
mounted read-only at the same path; certbot's own `chmod 0755` and
`chgrp`/`chmod 0640` for a server that is not root (kept on renewal) let the
panel read them; no copying hook.
- [ ] **The rest of what the sources contradict** (C-M7 `<pct>0</pct>`, - [ ] **The rest of what the sources contradict** (C-M7 `<pct>0</pct>`,
C-E2 the e2e zone serial, C-D1 a release gate comparing the image's C-E2 the e2e zone serial, C-D1 a release gate comparing the image's
package versions with the ones cited, C-K8/K9 and the comments the package versions with the ones cited, C-K8/K9 and the comments the
+50 -14
View File
@@ -255,14 +255,14 @@ SelfPost isn't tied to a specific proxy; pick whichever fits your host:
| Proxy | Where certs live | Fragment | | Proxy | Where certs live | Fragment |
|---|---|---| |---|---|---|
| **Apache** (default/recommended) | Host disk, via the certbot Apache plugin — PEM files ready to bind-mount, no extraction step. | [deploy/apache/selfpost-vhost.conf](../deploy/apache/selfpost-vhost.conf) | | **Apache** (default/recommended) | Host disk, via the certbot Apache plugin — `certs/` links to them, no extraction step. | [deploy/apache/selfpost-vhost.conf](../deploy/apache/selfpost-vhost.conf) |
| nginx | Host disk, via a certbot sidecar container — same PEM-ready shape as Apache. | [deploy/nginx/](../deploy/nginx/) | | nginx | Host disk, via a certbot sidecar container — the same shape as Apache. | [deploy/nginx/](../deploy/nginx/) |
| Caddy | Automatic ACME, zero extra containers — simplest, but its on-disk cert path is versioned internal layout, not a stable API; verify it for the Caddy version you run. | [deploy/caddy/](../deploy/caddy/) | | Caddy | Automatic ACME, zero extra containers — simplest, but its on-disk cert path is versioned internal layout, not a stable API; verify it for the Caddy version you run. | [deploy/caddy/](../deploy/caddy/) |
| Traefik | Bundled inside `acme.json` — needs a small extraction script to produce standalone PEM files. | [deploy/traefik/](../deploy/traefik/) | | Traefik | Bundled inside `acme.json` — needs a small extraction script to produce standalone PEM files. | [deploy/traefik/](../deploy/traefik/) |
Apache is the recommended default because the certbot Apache plugin already Apache is the recommended default because the certbot Apache plugin already
writes plain `fullchain.pem`/`privkey.pem` files to a predictable path with no keeps `fullchain.pem`/`privkey.pem` at a predictable path with no extra moving
extra moving parts between "certificate issued" and "Postfix can read it." parts between "certificate issued" and "Postfix can read it."
**The proxy needs no security configuration of its own.** The panel emits its **The proxy needs no security configuration of its own.** The panel emits its
own `Content-Security-Policy`, `Strict-Transport-Security`, `X-Frame-Options`, own `Content-Security-Policy`, `Strict-Transport-Security`, `X-Frame-Options`,
@@ -276,7 +276,8 @@ reject every form submission as cross-origin — the log says so explicitly,
printing the `Origin` and `Host` it compared. printing the `Origin` and `Host` it compared.
In every case the proxy terminates HTTPS for the panel; the resulting In every case the proxy terminates HTTPS for the panel; the resulting
certificate must end up under `./certs` as `fullchain.pem` and `privkey.pem`. certificate must end up under `./certs` as `fullchain.pem` and `privkey.pem` —
the files, or links that lead to them inside the container.
**Apache (recommended, on the host).** Install Apache with `ssl`, `proxy`, and **Apache (recommended, on the host).** Install Apache with `ssl`, `proxy`, and
`proxy_http` enabled. Copy `proxy_http` enabled. Copy
@@ -288,10 +289,30 @@ then issue a certificate:
sudo certbot --apache -d mail.example.com sudo certbot --apache -d mail.example.com
``` ```
Point `./certs` at the PEM files certbot wrote (symlink is fine): Link the two files into `./certs` and let the container see what the links
lead to. certbot's `live/` files are themselves relative links into
`archive/` (certbot's documentation, "Where are my certificates?", advises
pointing servers at them or linking to them), so the whole of
`/etc/letsencrypt` is mounted, read-only and at the same path — uncomment that
line in `docker-compose.yml`. Mounting `live/mail.example.com` alone, or
linking `certs` to it, gives the container links into a directory it does not
have: Postfix then starts without a certificate.
```sh ```sh
ln -s /etc/letsencrypt/live/mail.example.com certs mkdir certs
ln -s /etc/letsencrypt/live/mail.example.com/fullchain.pem certs/fullchain.pem
ln -s /etc/letsencrypt/live/mail.example.com/privkey.pem certs/privkey.pem
```
The panel reads them as its own user (Health shows the certificate, a full
backup carries both files), through the container's group 998. certbot's
documentation gives the way for a server that is not root, and certbot keeps
the key's group and mode at every renewal:
```sh
sudo chmod 0755 /etc/letsencrypt/live /etc/letsencrypt/archive
sudo chgrp 998 /etc/letsencrypt/archive/mail.example.com/privkey*.pem
sudo chmod 0640 /etc/letsencrypt/archive/mail.example.com/privkey*.pem
``` ```
**nginx (containerised).** From the `deploy/` directory, merge the nginx **nginx (containerised).** From the `deploy/` directory, merge the nginx
@@ -306,8 +327,20 @@ docker compose -f docker-compose.yml -f nginx/docker-compose.nginx.yml up -d
``` ```
Edit [deploy/nginx/nginx.conf.example](../deploy/nginx/nginx.conf.example) and Edit [deploy/nginx/nginx.conf.example](../deploy/nginx/nginx.conf.example) and
replace `mail.example.com` first. The fragment bind-mounts certbot's output into replace `mail.example.com` first. The fragment mounts certbot's directory into
both nginx and SelfPost. both nginx and SelfPost, at `/etc/letsencrypt`; `./certs` holds two links to
`/etc/letsencrypt/live/mail.example.com/` — a path that exists inside the
container, not on the host — and the panel reads the key through group 998,
as in the Apache case:
```sh
mkdir certs
ln -s /etc/letsencrypt/live/mail.example.com/fullchain.pem certs/fullchain.pem
ln -s /etc/letsencrypt/live/mail.example.com/privkey.pem certs/privkey.pem
sudo chmod 0755 nginx/certbot-etc/live nginx/certbot-etc/archive
sudo chgrp 998 nginx/certbot-etc/archive/mail.example.com/privkey*.pem
sudo chmod 0640 nginx/certbot-etc/archive/mail.example.com/privkey*.pem
```
**Caddy (containerised, automatic ACME).** Edit **Caddy (containerised, automatic ACME).** Edit
[deploy/caddy/Caddyfile](../deploy/caddy/Caddyfile) and the `<hostname>` placeholders [deploy/caddy/Caddyfile](../deploy/caddy/Caddyfile) and the `<hostname>` placeholders
@@ -654,10 +687,13 @@ both the panel and Postfix —
chgrp 998 certs/privkey.pem && chmod 640 certs/privkey.pem chgrp 998 certs/privkey.pem && chmod 640 certs/privkey.pem
``` ```
— and have whatever renews the certificate do the same (a certbot deploy hook, — and have whatever renews the certificate do the same; certbot does it by
for one). A key readable by root alone is enough for Postfix, but then the itself, keeping the group and mode of a key on renewal (with `certs/` as links
panel refuses to make a backup and names the file: it does not hand over an into `/etc/letsencrypt`, the commands of "Reverse proxy" above give certbot's
archive without the key. own files the group). A key readable by root alone is enough for Postfix, but
then the panel refuses to make a backup and names the file: it does not hand
over an archive without the key. Links under `certs/` are followed: the
archive holds the files they lead to.
**2. `selfpost-backup` CLI — while the container is running (no downtime).** **2. `selfpost-backup` CLI — while the container is running (no downtime).**
The panel button and this command call the same code path: a consistent SQLite The panel button and this command call the same code path: a consistent SQLite
@@ -719,7 +755,7 @@ methods avoid it by stopping first.
| SQLite consistency | `VACUUM INTO` snapshot | Safe when stopped | Safe when stopped | | SQLite consistency | `VACUUM INTO` snapshot | Safe when stopped | Safe when stopped |
| `data/log/` | Excluded | Included (optional exclude) | Included (optional exclude) | | `data/log/` | Excluded | Included (optional exclude) | Included (optional exclude) |
| Postfix queue | No | Yes | Yes | | Postfix queue | No | Yes | Yes |
| `certs/` private key | Yes (the panel needs it readable by group 998) | Yes | No — deploy files separate | | `certs/` private key | Yes (the panel needs it readable by group 998; links are followed) | Yes when `certs/` holds the files; links are stored as links unless `tar -h` | No — deploy files separate |
| `data/manifest.json` | Written (version stamp) | Absent | Absent | | `data/manifest.json` | Written (version stamp) | Absent | Absent |
| Restore version check | Yes — must match image tag | No | No | | Restore version check | Yes — must match image tag | No | No |
| Post-restore *Resync* | Yes, on first boot | No | No | | Post-restore *Resync* | Yes, on first boot | No | No |
+1 -1
View File
@@ -42,7 +42,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
| from-header-binding | Hold a message's `From:` header against the application that sends it | shipped `2.4.0` | 9/9 | [plans/from-header-binding.md](plans/from-header-binding.md) | | from-header-binding | Hold a message's `From:` header against the application that sends it | shipped `2.4.0` | 9/9 | [plans/from-header-binding.md](plans/from-header-binding.md) |
| panel-redesign | Panel redesign (accepted mockups, design contract) | shipped `2.0.0` | 27/27 | [plans/panel-redesign.md](plans/panel-redesign.md) | | panel-redesign | Panel redesign (accepted mockups, design contract) | shipped `2.0.0` | 27/27 | [plans/panel-redesign.md](plans/panel-redesign.md) |
| schema-squash | Squash SQLite migrations into a 2.x baseline | built in panel-redesign (stage 1) | — | [plans/panel-redesign.md](plans/panel-redesign.md) | | schema-squash | Squash SQLite migrations into a 2.x baseline | built in panel-redesign (stage 1) | — | [plans/panel-redesign.md](plans/panel-redesign.md) |
| audit-2026-10-12 | Follow-ups of the 2026-10-12 full-tree code audit, seven stages | in progress (stage 0 shipped `2.4.2`, stages 1+2 `2.5.0`, 2b `2.5.1`; next stage 2c → `2.5.2`) | 43/81 | [audit-2026-10-12.md](audit-2026-10-12.md) | | audit-2026-10-12 | Follow-ups of the 2026-10-12 full-tree code audit, seven stages | in progress (stage 0 shipped `2.4.2`, stages 1+2 `2.5.0`, 2b `2.5.1`; next stage 2c → `2.5.2`) | 44/81 | [audit-2026-10-12.md](audit-2026-10-12.md) |
**Recommended order** (not binding; owner, 2026-10-09: feature order follows **Recommended order** (not binding; owner, 2026-10-09: feature order follows
development convenience): **panel-redesign** shipped as `2.0.0` (2026-10-11) and development convenience): **panel-redesign** shipped as `2.0.0` (2026-10-11) and
+22 -5
View File
@@ -160,7 +160,7 @@ func Create(w io.Writer, p Params) error {
return err return err
} }
if err := addTree(tw, p.DataDir, DataArchivePrefix, excludedFromArchive, skipQueue); err != nil { if err := addTree(tw, p.DataDir, DataArchivePrefix, excludedFromArchive, skipQueue, false); err != nil {
return err return err
} }
@@ -221,7 +221,7 @@ func Check(p Params) error {
if err := ValidateDeployRoot(p.DeployRoot); err != nil { if err := ValidateDeployRoot(p.DeployRoot); err != nil {
return err return err
} }
if err := addTree(nil, p.DataDir, DataArchivePrefix, excludedFromArchive, skipQueue); err != nil { if err := addTree(nil, p.DataDir, DataArchivePrefix, excludedFromArchive, skipQueue, false); err != nil {
return err return err
} }
return addDeployFiles(nil, p.DeployRoot, nil) return addDeployFiles(nil, p.DeployRoot, nil)
@@ -267,7 +267,12 @@ func addDeployFiles(tw *tar.Writer, deployRoot string, onWarn func(string)) erro
} }
return fmt.Errorf("backup: stat %s: %w", CertsDirName, err) return fmt.Errorf("backup: stat %s: %w", CertsDirName, err)
} }
return addTree(tw, certsDir, CertsDirName+"/", nil, nil) // certs/ may hold links rather than files: certbot's own advice is to
// point a server at its live/ files "(or create symlinks)", and those
// are links again, relative, into archive/ (eff-certbot docs, "Where are
// my certificates?"; assumptions C-Q4, C-A1). The archive carries the
// files they lead to, under the links' names.
return addTree(tw, certsDir, CertsDirName+"/", nil, nil, true)
} }
// addTree walks root and adds every regular file (and directory, to preserve // addTree walks root and adds every regular file (and directory, to preserve
@@ -275,8 +280,11 @@ func addDeployFiles(tw *tar.Writer, deployRoot string, onWarn func(string)) erro
// When exclude is non-nil, top-level names relative to root are skipped; skip, // When exclude is non-nil, top-level names relative to root are skipped; skip,
// when non-nil, leaves out whatever it says. With a nil tw nothing is written: // when non-nil, leaves out whatever it says. With a nil tw nothing is written:
// every file is opened and closed, which is how Check finds what cannot be read. // every file is opened and closed, which is how Check finds what cannot be read.
// Non-regular, non-directory entries (symlinks, sockets) are skipped. // Non-regular, non-directory entries (symlinks, sockets) are skipped - except,
func addTree(tw *tar.Writer, root, archivePrefix string, exclude map[string]bool, skip skipFunc) error { // with follow, a symbolic link to a regular file, which is archived as that
// file under the link's name; a link that leads nowhere this process can
// reach is an error, as an unreadable file is.
func addTree(tw *tar.Writer, root, archivePrefix string, exclude map[string]bool, skip skipFunc, follow bool) error {
return filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { return filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil { if err != nil {
return err return err
@@ -330,6 +338,15 @@ func addTree(tw *tar.Writer, root, archivePrefix string, exclude map[string]bool
return nil return nil
} }
return err return err
case follow && info.Mode()&fs.ModeSymlink != 0:
target, err := os.Stat(path)
if err != nil {
return fmt.Errorf("backup: %s is a link to a file this container cannot reach - with certbot, /etc/letsencrypt is mounted at the same path (guide § Reverse proxy): %w", name, err)
}
if !target.Mode().IsRegular() {
return nil
}
return writeTarFile(tw, name, target.Mode().Perm(), path)
default: default:
return nil return nil
} }
+49
View File
@@ -10,6 +10,7 @@ import (
"io/fs" "io/fs"
"os" "os"
"path/filepath" "path/filepath"
"runtime"
"strings" "strings"
"testing" "testing"
@@ -310,3 +311,51 @@ func TestCheckReportsWhatCannotBeRead(t *testing.T) {
t.Fatal("Check passed with .env missing from the deploy root") t.Fatal("Check passed with .env missing from the deploy root")
} }
} }
// certs/ as certbot's advice leaves it: links into live/, which are relative
// links into archive/ (C-Q4). The archive holds the files, under the links'
// names; a link to nothing reachable stops the backup, naming it.
func TestCertsLinksAreFollowed(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symbolic links need privileges on Windows; the container is Linux")
}
dataDir, dbPath, deployRoot := seedDataDir(t)
le := t.TempDir()
writeFile(t, filepath.Join(le, "archive", "mail.example.com", "fullchain1.pem"), "CERT FROM CERTBOT")
writeFile(t, filepath.Join(le, "archive", "mail.example.com", "privkey1.pem"), "KEY FROM CERTBOT")
live := filepath.Join(le, "live", "mail.example.com")
if err := os.MkdirAll(live, 0o755); err != nil {
t.Fatal(err)
}
certs := filepath.Join(deployRoot, CertsDirName)
for _, f := range []string{"fullchain", "privkey"} {
if err := os.Symlink("../../archive/mail.example.com/"+f+"1.pem", filepath.Join(live, f+".pem")); err != nil {
t.Fatal(err)
}
os.Remove(filepath.Join(certs, f+".pem"))
if err := os.Symlink(filepath.Join(live, f+".pem"), filepath.Join(certs, f+".pem")); err != nil {
t.Fatal(err)
}
}
p := Params{DataDir: dataDir, DBPath: dbPath, Version: "1.2.3", DeployRoot: deployRoot}
if err := Check(p); err != nil {
t.Fatalf("Check: %v", err)
}
var buf bytes.Buffer
if err := Create(&buf, p); err != nil {
t.Fatalf("Create: %v", err)
}
files := readArchive(t, buf.Bytes())
if files[CertsDirName+"/fullchain.pem"] != "CERT FROM CERTBOT" || files[CertsDirName+"/privkey.pem"] != "KEY FROM CERTBOT" {
t.Fatalf("certs in the archive: %q, %q", files[CertsDirName+"/fullchain.pem"], files[CertsDirName+"/privkey.pem"])
}
// /etc/letsencrypt not mounted: the link leads nowhere.
if err := os.RemoveAll(le); err != nil {
t.Fatal(err)
}
err := Check(p)
if err == nil || !strings.Contains(err.Error(), "certs/fullchain.pem is a link to a file this container cannot reach") {
t.Fatalf("Check with a dangling link: %v", err)
}
}
+103
View File
@@ -0,0 +1,103 @@
package e2e
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// testCertbotLayout: certs/ as the guide now has an operator leave it with
// certbot - two links into /etc/letsencrypt/live/<host>/, whose files are
// relative links into archive/ - and /etc/letsencrypt mounted at the same
// path (compose.override.yml mounts the stage's ./letsencrypt there). Postfix
// serves the certificate the links lead to, Health shows it with nothing
// waiting, and a full backup carries the files, not the links (assumptions
// C-A1, C-Q4). Until 2.5.2 the guide had certs/ linked to live/<host>, and
// the container got links into a directory it did not have.
func testCertbotLayout(t *testing.T, sc *scenario) {
le := filepath.Join(h.stageDir, "letsencrypt")
archive := filepath.Join(le, "archive", selfpostHostname)
live := filepath.Join(le, "live", selfpostHostname)
for _, d := range []string{archive, live} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
// A new pair, so what is served afterwards can only have come through
// the links.
if err := writeSelfSignedCert(filepath.Join(archive, "fullchain1.pem"), filepath.Join(archive, "privkey1.pem")); err != nil {
t.Fatal(err)
}
certs := filepath.Join(h.stageDir, "certs")
for _, f := range []string{"fullchain", "privkey"} {
link := filepath.Join(live, f+".pem")
_ = os.Remove(link)
if err := os.Symlink("../../archive/"+selfpostHostname+"/"+f+"1.pem", link); err != nil {
t.Fatal(err)
}
mounted := filepath.Join(certs, f+".pem")
if err := os.Remove(mounted); err != nil {
t.Fatal(err)
}
if err := os.Symlink("/etc/letsencrypt/live/"+selfpostHostname+"/"+f+".pem", mounted); err != nil {
t.Fatal(err)
}
}
if err := h.recreateSelfpost(); err != nil {
t.Fatalf("recreate selfpost over certbot's layout: %v", err)
}
if err := waitForPanelReady(); err != nil {
t.Fatalf("%v\n%s", err, h.logs("selfpost"))
}
if err := waitForSMTPSReady(60 * time.Second); err != nil {
t.Fatalf("%v\n%s", err, h.logs("selfpost"))
}
want, err := fileSerial(filepath.Join(archive, "fullchain1.pem"))
if err != nil {
t.Fatal(err)
}
got, err := servedSerial()
if err != nil {
t.Fatal(err)
}
if got.Cmp(want) != 0 {
t.Fatalf("port 465 serves serial %s, want %s, the certificate certs/ links to", got, want)
}
_, body, err := sc.panel.get("/server/health")
if err != nil {
t.Fatal(err)
}
if strings.Contains(body, "<dt>Renewal expires</dt>") {
t.Fatalf("Health names a renewal waiting, but Postfix serves what certs/ leads to")
}
resp, archived, err := sc.panel.postForm("/server/backup", nil)
if err != nil || resp.StatusCode != 200 {
t.Fatalf("full backup over certbot's layout: %v, status %v\n%.600s", err, resp, archived)
}
dir := t.TempDir()
names, err := untar(dir, []byte(archived))
if err != nil {
t.Fatal(err)
}
for _, f := range []string{"fullchain", "privkey"} {
name := "certs/" + f + ".pem"
if !names[name] {
t.Fatalf("the backup has no %s", name)
}
fi, err := os.Lstat(filepath.Join(dir, name))
if err != nil || !fi.Mode().IsRegular() {
t.Fatalf("%s in the backup: %v, %v; want the file, not a link", name, fi, err)
}
inBackup, _ := os.ReadFile(filepath.Join(dir, name))
original, _ := os.ReadFile(filepath.Join(archive, f+"1.pem"))
if !bytes.Equal(inBackup, original) {
t.Fatalf("%s in the backup is not the file certbot's link leads to", name)
}
}
}
+6
View File
@@ -37,6 +37,12 @@ services:
# unset. The suite checks the stamp (image_stamped_with_version). # unset. The suite checks the stamp (image_stamped_with_version).
VERSION: ${SELFPOST_E2E_VERSION:-e2e} VERSION: ${SELFPOST_E2E_VERSION:-e2e}
image: selfpost:e2e image: selfpost:e2e
volumes:
# Where certs/ links lead when it is laid out as the guide has it with
# certbot (certbot_layout_followed): certbot's directory, mounted at the
# same path as on a host. Empty until that test fills it. Added to the
# base file's volumes (a new target).
- ./letsencrypt:/etc/letsencrypt:ro
environment: environment:
SELFPOST_HOSTNAME: mail.e2e.test SELFPOST_HOSTNAME: mail.e2e.test
# Plain HTTP panel for the test client — no reverse proxy in this stand. # Plain HTTP panel for the test client — no reverse proxy in this stand.
+3
View File
@@ -370,6 +370,9 @@ func TestE2E(t *testing.T) {
run("full_backup_restore", func(t *testing.T) { run("full_backup_restore", func(t *testing.T) {
testFullBackupRestore(t, sc) testFullBackupRestore(t, sc)
}) })
run("certbot_layout_followed", func(t *testing.T) {
testCertbotLayout(t, sc)
})
run("enable_port25_features", func(t *testing.T) { run("enable_port25_features", func(t *testing.T) {
testEnablePort25Features(t, sc) testEnablePort25Features(t, sc)
}) })
+1 -1
View File
@@ -27,7 +27,7 @@ func prepareStage(s *stack) error {
if err := removeAllBestEffort(s.stageDir); err != nil { if err := removeAllBestEffort(s.stageDir); err != nil {
return fmt.Errorf("clean stage dir: %w", err) return fmt.Errorf("clean stage dir: %w", err)
} }
dirs := []string{"data", "certs", "dns-stage", "mail-stage"} dirs := []string{"data", "certs", "letsencrypt", "dns-stage", "mail-stage"}
for _, d := range dirs { for _, d := range dirs {
if err := os.MkdirAll(filepath.Join(s.stageDir, d), 0o755); err != nil { if err := os.MkdirAll(filepath.Join(s.stageDir, d), 0o755); err != nil {
return fmt.Errorf("mkdir %s: %w", d, err) return fmt.Errorf("mkdir %s: %w", d, err)