git » chasquid » commit e417d67

chasquid: Return error on inaccessible private key file

author Alberto Bertogli
2026-09-27 19:35:48 UTC
committer Alberto Bertogli
2026-09-27 19:47:56 UTC
parent f22d2289af73cd35cf125fbed3ad21219cf557af

chasquid: Return error on inaccessible private key file

In commit 3f9d0c0762, we added a new possible private file. If the file
exists but there's an error reading it (common for permission issues),
currently we just move on to the next file, and if it doesn't exist we
may give up with that error.

That can "hide" the access problems making them harder to debug.

To help with those kinds of errors, this patch updates the logic to
return an error if a private key exists but is inaccessible.

It also adds an unit test for this function to cover the different
scenarios, and updates the docs to match.

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.