| author | Alberto Bertogli
<albertito@blitiri.com.ar> 2026-09-27 19:35:48 UTC |
| committer | Alberto Bertogli
<albertito@blitiri.com.ar> 2026-09-27 19:47:56 UTC |
| parent | f22d2289af73cd35cf125fbed3ad21219cf557af |
| chasquid.go | +12 | -7 |
| chasquid_test.go | +72 | -0 |
| docs/install.md | +3 | -1 |
| docs/man/chasquid.1 | +3 | -2 |
| docs/man/chasquid.1.md | +2 | -1 |
| docs/man/chasquid.1.pod | +2 | -1 |
| etc/chasquid/README | +2 | -1 |
diff --git a/chasquid.go b/chasquid.go index 5be3e96..804b8f4 100644 --- a/chasquid.go +++ b/chasquid.go @@ -6,6 +6,7 @@ package main import ( "context" + "errors" "flag" "fmt" "net" @@ -284,24 +285,28 @@ func loadCert(name, dir string, s *smtpsrv.Server) { func findCertKey(dir string) (string, error) { keyFiles := []string{ - // Lego (NixOS ACME module). - "key.pem", // Letsencrypt Certbot. "privkey.pem", + // Lego (NixOS ACME module). + "key.pem", } - var err error - for _, name := range keyFiles { keyPath := filepath.Join(dir, name) - - _, err = os.Stat(keyPath) + _, err := os.Stat(keyPath) if err == nil { return keyPath, nil } + if !errors.Is(err, os.ErrNotExist) { + // If the file does not exist, we move on to try the next file. + // But if it exists but it's inaccessible, return the error so + // users can debug it more clearly. + return "", err + } } - return "", err + return "", fmt.Errorf( + "no private key found in %q (tried %v)", dir, keyFiles) } // Helper to load a single domain configuration into the server. diff --git a/chasquid_test.go b/chasquid_test.go new file mode 100644 index 0000000..18d9184 --- /dev/null +++ b/chasquid_test.go @@ -0,0 +1,72 @@ +package main + +import ( + "errors" + "os" + "path/filepath" + "strings" + "syscall" + "testing" + + "blitiri.com.ar/go/chasquid/internal/testlib" +) + +func mustMkdir(t *testing.T, path string) string { + t.Helper() + err := os.Mkdir(path, 0700) + if err != nil { + t.Fatalf("failed to create directory: %v", err) + } + return path +} + +func TestFindCertKey(t *testing.T) { + tmpDir := testlib.MustTempDir(t) + defer testlib.RemoveIfOk(t, tmpDir) + + cases := []struct { + name string + files []string + want string + }{ + {"certbot", []string{"privkey.pem"}, "privkey.pem"}, + {"lego", []string{"key.pem"}, "key.pem"}, + {"both", []string{"privkey.pem", "key.pem"}, "privkey.pem"}, + } + for _, c := range cases { + dir := mustMkdir(t, filepath.Join(tmpDir, c.name)) + for _, f := range c.files { + testlib.Rewrite(t, filepath.Join(dir, f), "") + } + + got, err := findCertKey(dir) + if err != nil { + t.Errorf("%s: unexpected error: %v", c.name, err) + } + if want := filepath.Join(dir, c.want); got != want { + t.Errorf("%s: got %q, expected %q", c.name, got, want) + } + } + + // No key files: the error should mention the directory. + dir := mustMkdir(t, filepath.Join(tmpDir, "empty")) + got, err := findCertKey(dir) + if err == nil || !strings.Contains(err.Error(), dir) { + t.Errorf("empty: got %q, %v, expected 'no private key' error", + got, err) + } + + // privkey.pem is there but can't be stat'ed (a symlink loop here, which + // also works when running as root). We expect that error to be returned, + // instead of silently falling back to key.pem. + dir = mustMkdir(t, filepath.Join(tmpDir, "loop")) + err = os.Symlink("privkey.pem", filepath.Join(dir, "privkey.pem")) + if err != nil { + t.Fatalf("failed to create symlink: %v", err) + } + testlib.Rewrite(t, filepath.Join(dir, "key.pem"), "") + got, err = findCertKey(dir) + if !errors.Is(err, syscall.ELOOP) { + t.Errorf("loop: got %q, %v, expected ELOOP error", got, err) + } +} diff --git a/docs/install.md b/docs/install.md index abc5f1d..6517c59 100644 --- a/docs/install.md +++ b/docs/install.md @@ -81,7 +81,7 @@ structure: - certs/ Certificates to use, one dir per pair. - mx.example.com/ - fullchain.pem Certificate (full chain). - - privkey.pem Private key. + - privkey.pem Private key (can also be named key.pem). ... ``` @@ -91,6 +91,8 @@ The certs/ directory layout matches the one from [certbot](https://certbot.eff.org/), [letsencrypt](https://letsencrypt.org)'s default client, to make it easier to integrate. +The layout from the [NixOS ACME module](https://wiki.nixos.org/wiki/ACME) +(`/var/lib/acme`) is also supported. A convenient way to set this up is: diff --git a/docs/man/chasquid.1 b/docs/man/chasquid.1 index 83a5cb4..c896b2d 100644 --- a/docs/man/chasquid.1 +++ b/docs/man/chasquid.1 @@ -58,7 +58,7 @@ .\" ======================================================================== .\" .IX Title "chasquid 1" -.TH chasquid 1 2023-10-03 "" "" +.TH chasquid 1 2026-09-27 "" "" .\" For nroff, turn off justification. Always turn off hyphenation; it makes .\" way too many mistakes in technical documents. .if n .ad l @@ -134,11 +134,12 @@ Certificates for this domain. Certificate (full chain). .IP \fIcerts/mx.example.com/privkey.pem\fR 8 .IX Item "certs/mx.example.com/privkey.pem" -Private key. +Private key (can also be named \fIkey.pem\fR). .PP Note the \fIcerts/\fR directory layout matches the one from certbot (client for Let\*(Aqs Encrypt CA), so you can just symlink \fIcerts/\fR to \&\fI/etc/letsencrypt/live\fR. +The layout from the NixOS ACME module (\fI/var/lib/acme\fR) is also supported. .PP Make sure the user you use to run chasquid under ("mail" in the example config) can access the certificates and private keys. diff --git a/docs/man/chasquid.1.md b/docs/man/chasquid.1.md index c402743..4f19f40 100644 --- a/docs/man/chasquid.1.md +++ b/docs/man/chasquid.1.md @@ -90,11 +90,12 @@ Inside that directory, the daemon expects the following structure: - `certs/mx.example.com/privkey.pem` - Private key. + Private key (can also be named `key.pem`). Note the `certs/` directory layout matches the one from certbot (client for Let's Encrypt CA), so you can just symlink `certs/` to `/etc/letsencrypt/live`. +The layout from the NixOS ACME module (`/var/lib/acme`) is also supported. Make sure the user you use to run chasquid under ("mail" in the example config) can access the certificates and private keys. diff --git a/docs/man/chasquid.1.pod b/docs/man/chasquid.1.pod index 110da0f..65ba5d3 100644 --- a/docs/man/chasquid.1.pod +++ b/docs/man/chasquid.1.pod @@ -98,13 +98,14 @@ Certificate (full chain). =item F<certs/mx.example.com/privkey.pem> -Private key. +Private key (can also be named F<key.pem>). =back Note the F<certs/> directory layout matches the one from certbot (client for Let's Encrypt CA), so you can just symlink F<certs/> to F</etc/letsencrypt/live>. +The layout from the NixOS ACME module (F</var/lib/acme>) is also supported. Make sure the user you use to run chasquid under ("mail" in the example config) can access the certificates and private keys. diff --git a/etc/chasquid/README b/etc/chasquid/README index 9f0df00..fe33f9b 100644 --- a/etc/chasquid/README +++ b/etc/chasquid/README @@ -12,12 +12,13 @@ This directory contains chasquid's configuration. - certs/ Certificates to use, one dir per pair. - example.com/ - fullchain.pem Certificate (full chain). - - privkey.pem Private key. + - privkey.pem Private key (can also be named key.pem). ... Note the certs/ directory matches certbot's structure, so if you use it you can just symlink to /etc/letsencrypt/live. +The NixOS ACME module's layout (/var/lib/acme) is also supported. You need at least one certificate, or the server will refuse to start. Ideally there should be a certificate for each DNS name pointing to you.