diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d0b9d2..3b8eb9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ All notable changes to Jarl are documented here. The format follows ## [Unreleased] +## [0.6.1] - 2026-10-20 + ### Added - Certificates for other hosts: `jarl cert get NAME -d DOMAIN --dir DIR @@ -105,6 +107,11 @@ All notable changes to Jarl are documented here. The format follows ### Fixed +- `jarl cert get NAME` no longer takes over a name this host has saved + for another Jarl server, which overwrote that certificate's settings + (and, with the default `--dir`, its files), so it stopped being + renewed. It is refused, as is a `--dir` holding another certificate's + files; `--replace` takes the name or directory over. - The "query log is off" error named a setting that doesn't exist, `stats.query_retention`; it's `stats.retention.queries`. - `jarl zone show` for a reverse zone printed only "and 1 more" under diff --git a/cmd/jarl/certget.go b/cmd/jarl/certget.go index d4e4e8e..fc20633 100644 --- a/cmd/jarl/certget.go +++ b/cmd/jarl/certget.go @@ -114,6 +114,32 @@ func (sc savedCert) save(dir string) error { return writeAtomic(filepath.Join(dir, sc.Name+".json"), append(b, '\n'), 0o600) } +// checkCertClash refuses a certificate that would take over what this +// host keeps for another one: a name saved for another Jarl server +// (each server has its own names, so the server can't refuse it), or a +// directory with another certificate's files. saved[i] is sc's saved +// settings (i < 0: none). +func checkCertClash(saved []savedCert, i int, sc savedCert, replace bool) error { + if i >= 0 && !replace && !sameServer(saved[i].Conn.Server, sc.Conn.Server) { + return fmt.Errorf("%s is saved here for the Jarl server %s, not %s: choose another name, or take it over with --replace", sc.Name, saved[i].Conn.Server, sc.Conn.Server) + } + for j, o := range saved { + if j != i && o.Name != sc.Name && filepath.Clean(o.Dir) == filepath.Clean(sc.Dir) { + return fmt.Errorf("%s holds the files of %s, saved for %s: give %s another --dir", sc.Dir, o.Name, o.Conn.Server, sc.Name) + } + } + if m, err := readMeta(sc.Dir); err == nil && m.Name != "" && m.Name != sc.Name && !replace { + return fmt.Errorf("%s holds the files of the certificate %s: give %s another --dir, or overwrite them with --replace", sc.Dir, m.Name, sc.Name) + } + return nil +} + +// sameServer reports whether two server addresses name the same jarld. +func sameServer(a, b string) bool { + norm := func(s string) string { return strings.TrimRight(strings.ToLower(s), "/") } + return norm(a) == norm(b) +} + func readMeta(dir string) (*localMeta, error) { b, err := os.ReadFile(filepath.Join(dir, metaFile)) if err != nil { @@ -340,7 +366,7 @@ func runHook(sc savedCert, ci certInfo, renewed bool) error { func certGetCmd() *cobra.Command { var domains []string var dir, hook, keyType, configDir string - var noSave, force bool + var noSave, force, replace bool var wait time.Duration cmd := &cobra.Command{ Use: "get [NAME]", @@ -365,6 +391,10 @@ readable only by you). 'jarl cert get' without a name then does every saved certificate: run it daily (jarl cert schedule does that with a systemd timer). Nothing is downloaded until jarld has renewed one. +A name saved here for another Jarl server isn't taken over, nor a --dir +holding another certificate's files, unless --replace is given; a --dir +another saved certificate uses is always refused. + --hook runs with sh after new files are written (and again on the next run if it failed), with JARL_CERT_NAME, JARL_CERT_DIR, JARL_CERT_CERT, JARL_CERT_CHAIN, JARL_CERT_FULLCHAIN, JARL_CERT_KEY, JARL_CERT_DOMAINS, @@ -427,6 +457,9 @@ replaced an older certificate).`, } sc.Conn = cn } + if err := checkCertClash(saved, i, sc, replace); err != nil { + return err + } todo = []savedCert{sc} } var results []*getResult @@ -469,7 +502,7 @@ replaced an older certificate).`, } switch { case len(failed) == 1 && len(todo) == 1: - return lastErr // its own exit code (3 forbidden, 6 conflict, ...) + return lastErr // its own exit code (3 forbidden, 5 conflict, ...) case len(failed) > 0: return fmt.Errorf("failed: %s", strings.Join(failed, ", ")) } @@ -483,6 +516,7 @@ replaced an older certificate).`, f.StringVar(&keyType, "key-type", "", "key type of a new certificate: ec256 (default), ec384, rsa2048, rsa3072, rsa4096") f.BoolVar(&force, "force", false, "download and run the hook even when this host has the current certificate") f.BoolVar(&noSave, "no-save", false, "don't save the settings for later runs") + f.BoolVar(&replace, "replace", false, "take over a name saved here for another Jarl server, or a --dir holding another certificate's files") f.DurationVar(&wait, "wait", 15*time.Minute, "how long to wait while jarld obtains it") f.StringVar(&configDir, "config-dir", "", "where the settings are saved (default /etc/jarl/cert-get as root, ~/.config/jarl/cert-get otherwise)") _ = cmd.RegisterFlagCompletionFunc("key-type", cobra.FixedCompletions([]string{"ec256", "ec384", "rsa2048", "rsa3072", "rsa4096"}, cobra.ShellCompDirectiveNoFileComp)) diff --git a/cmd/jarl/certget_test.go b/cmd/jarl/certget_test.go new file mode 100644 index 0000000..21b3805 --- /dev/null +++ b/cmd/jarl/certget_test.go @@ -0,0 +1,59 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestCheckCertClash(t *testing.T) { + tmp := t.TempDir() + a := connection{Server: "https://dns-a.example:8053"} + b := connection{Server: "https://dns-b.example:8053"} + saved := []savedCert{ + {Name: "proxy", Dir: filepath.Join(tmp, "proxy"), Conn: a}, + {Name: "mail", Dir: filepath.Join(tmp, "mail"), Conn: a}, + } + // mail's files are on disk; "other" has files but no saved settings. + if err := (&localMeta{Name: "mail"}).write(mkdir(t, saved[1].Dir)); err != nil { + t.Fatal(err) + } + if err := (&localMeta{Name: "other"}).write(mkdir(t, filepath.Join(tmp, "other"))); err != nil { + t.Fatal(err) + } + cases := []struct { + what string + i int + sc savedCert + replace bool + want string // "" = allowed + }{ + {"same server again", 0, savedCert{Name: "proxy", Dir: saved[0].Dir, Conn: a}, false, ""}, + {"same server, other spelling", 0, savedCert{Name: "proxy", Dir: saved[0].Dir, Conn: connection{Server: "HTTPS://dns-a.example:8053/"}}, false, ""}, + {"other server", 0, savedCert{Name: "proxy", Dir: saved[0].Dir, Conn: b}, false, "saved here for the Jarl server"}, + {"other server, replace", 0, savedCert{Name: "proxy", Dir: saved[0].Dir, Conn: b}, true, ""}, + {"new name, saved cert's dir", -1, savedCert{Name: "web", Dir: saved[1].Dir, Conn: b}, false, "holds the files of mail"}, + {"new name, saved cert's dir, replace", -1, savedCert{Name: "web", Dir: saved[1].Dir + "/", Conn: b}, true, "holds the files of mail"}, + {"new name, unsaved cert's files", -1, savedCert{Name: "web", Dir: filepath.Join(tmp, "other"), Conn: b}, false, "certificate other"}, + {"new name, unsaved cert's files, replace", -1, savedCert{Name: "web", Dir: filepath.Join(tmp, "other"), Conn: b}, true, ""}, + {"new name, empty dir", -1, savedCert{Name: "web", Dir: filepath.Join(tmp, "web"), Conn: b}, false, ""}, + } + for _, c := range cases { + err := checkCertClash(saved, c.i, c.sc, c.replace) + switch { + case c.want == "" && err != nil: + t.Errorf("%s: %v", c.what, err) + case c.want != "" && (err == nil || !strings.Contains(err.Error(), c.want)): + t.Errorf("%s: got %v, want %q", c.what, err, c.want) + } + } +} + +func mkdir(t *testing.T, dir string) string { + t.Helper() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + return dir +} diff --git a/docs/certificates.md b/docs/certificates.md index 09de774..3d8270c 100644 --- a/docs/certificates.md +++ b/docs/certificates.md @@ -292,6 +292,13 @@ jarl cert saved # what this host has, and when it exp `jarl cert get` without a name does every saved certificate. A token given as `@FILE` is saved as that reference, not copied. `jarl cert forget NAME` stops getting one (`--delete` deletes it on jarld too). +* **Several Jarl servers on one host**: each server has its own + certificate names, so two servers may both have a `proxy`. The host + keeps one `proxy` per settings directory, though: asking another server + for a name saved here for the first one is refused, as is a `--dir` + holding another certificate's files. Choose another name or `--dir`, or + pass `--replace` to take the name (or directory) over. A `--dir` that + another saved certificate uses is always refused. * **The hook** runs with `sh` after new files are written, with `JARL_CERT_NAME`, `JARL_CERT_DIR`, `JARL_CERT_CERT`, `JARL_CERT_CHAIN`, `JARL_CERT_FULLCHAIN`, `JARL_CERT_KEY`, `JARL_CERT_DOMAINS`, diff --git a/docs/cli.md b/docs/cli.md index 55ed46d..3e86ff6 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -451,6 +451,7 @@ jarl cert export mail --dir /etc/ssl/mail # fullchain.pem and privkey jarl cert export mail --part bundle # one part to stdout: fullchain, cert, key, bundle jarl cert get mail -d mail.adbraun.lol --hook 'systemctl reload postfix' # on another host: get, download, hook jarl cert get # every saved certificate (what the timer runs) +jarl cert get proxy -s other --replace # take over a name saved here for another server jarl cert saved # this host's certificates and their expiry jarl cert forget mail [--delete] # stop getting it here sudo jarl cert schedule [--remove] # daily systemd timer for jarl cert get