Compare commits

..
4 Commits
Author SHA1 Message Date
adamandClaude Opus 5.5 3d1911dd38 Release 0.6.2
release / release (push) Successful in 4m45s
A certificates role (template) for tokens and users that get
certificates for other hosts with jarl cert get.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 22:08:54 +01:00
adamandClaude Opus 5.5 310ed8061e Permissions: a certificates role
A template for tokens and users that get certificates for other hosts
(jarl cert get): certificates:write, to narrow with names= in the web
UI's "Insert a template…", or --role certificates in the CLI. The token
form's template filter no longer counts "*" as covering certificates,
as the server doesn't.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 22:07:49 +01:00
adamandClaude Opus 5.5 b367e0c830 Release 0.6.1
release / release (push) Successful in 4m42s
Certificates for other hosts (jarl cert get, with a daily timer),
certificate export, PTR record and override fixes in linked zones,
one-view tokens with linked zones, numeric order in reverse zones, and
cert get refusing to take over a name saved for another Jarl server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:42:42 +01:00
adamandClaude Opus 5.5 254867c61d CLI: cert get doesn't take over another server's saved name
Each Jarl server has its own certificate names, but a host keeps one
saved NAME.json (and, by default, one /etc/jarl/certs/NAME). Asking a
second server for the same name overwrote the first one's settings and
files, so the first certificate quietly stopped being renewed. Now that
is refused, as is a --dir holding another certificate's files;
--replace takes the name or directory over. A --dir another saved
certificate uses is always refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:42:42 +01:00
12 changed files with 145 additions and 10 deletions

No files matched your search

+16
View File
@@ -6,6 +6,17 @@ All notable changes to Jarl are documented here. The format follows
## [Unreleased] ## [Unreleased]
## [0.6.2] - 2026-10-21
### Added
- A `certificates` role (template): `certificates:write`, for tokens and
users that get certificates for other hosts with `jarl cert get`. In
the web UI's user and token forms it is in "Insert a template…", to
narrow with `names=`; in the CLI, `--role certificates`.
## [0.6.1] - 2026-10-20
### Added ### Added
- Certificates for other hosts: `jarl cert get NAME -d DOMAIN --dir DIR - Certificates for other hosts: `jarl cert get NAME -d DOMAIN --dir DIR
@@ -105,6 +116,11 @@ All notable changes to Jarl are documented here. The format follows
### Fixed ### 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, - The "query log is off" error named a setting that doesn't exist,
`stats.query_retention`; it's `stats.retention.queries`. `stats.query_retention`; it's `stats.retention.queries`.
- `jarl zone show` for a reverse zone printed only "and 1 more" under - `jarl zone show` for a reverse zone printed only "and 1 more" under
+3 -3
View File
@@ -202,9 +202,9 @@ review step.
## Status ## Status
Version 0.6.0 is the current release, and 0.6.1 is in testing Version 0.6.2 is the current release. It adds a `certificates` role
(0.6.1-rc.4): it gets certificates onto your other hosts (`jarl cert for tokens that get certificates for other hosts. 0.6.1 gets certificates onto your
get`: Jarl obtains and renews them, a host downloads them and runs a other hosts (`jarl cert get`: Jarl obtains and renews them, a host downloads them and runs a
hook, a daily timer keeps them current), exports certificates, fixes hook, a daily timer keeps them current), exports certificates, fixes
PTR records and overrides in linked zones, and makes one-view tokens PTR records and overrides in linked zones, and makes one-view tokens
(such as ACME tokens for the external view) work with linked zones. (such as ACME tokens for the external view) work with linked zones.
+1 -1
View File
@@ -1 +1 @@
0.6.1-rc.4 0.6.2
+4 -3
View File
@@ -392,7 +392,8 @@ ACCESS is read or write, and filters take comma separated glob patterns:
records:write zones=adbraun.lol names=_acme-challenge.* types=TXT records:write zones=adbraun.lol names=_acme-challenge.* types=TXT
*:read *:read
Roles are ready-made grant sets: readonly, dns-editor, operator, acme.` Roles are ready-made grant sets: readonly, dns-editor, operator, acme,
certificates.`
func userCmd() *cobra.Command { func userCmd() *cobra.Command {
cmd := &cobra.Command{Use: "user", Aliases: []string{"users"}, Short: "Manage users (admin)", Long: "Manage users (admin).\n\n" + grantHelp} cmd := &cobra.Command{Use: "user", Aliases: []string{"users"}, Short: "Manage users (admin)", Long: "Manage users (admin).\n\n" + grantHelp}
@@ -477,7 +478,7 @@ func userCmd() *cobra.Command {
} }
addUserFlags := func(c *cobra.Command) { addUserFlags := func(c *cobra.Command) {
c.Flags().BoolVar(&admin, "admin", false, "make the user an administrator (--admin=false to remove)") c.Flags().BoolVar(&admin, "admin", false, "make the user an administrator (--admin=false to remove)")
c.Flags().StringVar(&role, "role", "", "replace the grants with a role: readonly, dns-editor, operator or acme") c.Flags().StringVar(&role, "role", "", "replace the grants with a role: readonly, dns-editor, operator, acme or certificates")
c.Flags().StringArrayVar(&grants, "grant", nil, `grant (repeatable), e.g. "records:write zones=example.com"`) c.Flags().StringArrayVar(&grants, "grant", nil, `grant (repeatable), e.g. "records:write zones=example.com"`)
c.Flags().StringVar(&comment, "comment", "", "comment") c.Flags().StringVar(&comment, "comment", "", "comment")
c.Flags().IntVar(&certLimit, "cert-limit", -1, "how many certificates the user may own through jarl cert get (-1: certificates.max_per_user in jarl.yaml)") c.Flags().IntVar(&certLimit, "cert-limit", -1, "how many certificates the user may own through jarl cert get (-1: certificates.max_per_user in jarl.yaml)")
@@ -758,7 +759,7 @@ func tokenCmd() *cobra.Command {
}, },
} }
create.Flags().StringVar(&user, "user", "", "create the token for this user (admin)") create.Flags().StringVar(&user, "user", "", "create the token for this user (admin)")
create.Flags().StringVar(&role, "role", "", "restrict the token to a role: readonly, dns-editor, operator or acme") create.Flags().StringVar(&role, "role", "", "restrict the token to a role: readonly, dns-editor, operator, acme or certificates")
create.Flags().StringArrayVar(&grants, "grant", nil, "restrict the token to these grants (repeatable)") create.Flags().StringArrayVar(&grants, "grant", nil, "restrict the token to these grants (repeatable)")
create.Flags().StringVar(&expires, "expires", "", "lifetime, e.g. 90d or 720h (default: never)") create.Flags().StringVar(&expires, "expires", "", "lifetime, e.g. 90d or 720h (default: never)")
create.Flags().BoolVar(&save, "save", false, "store the new token in the CLI configuration file") create.Flags().BoolVar(&save, "save", false, "store the new token in the CLI configuration file")
+36 -2
View File
@@ -114,6 +114,32 @@ func (sc savedCert) save(dir string) error {
return writeAtomic(filepath.Join(dir, sc.Name+".json"), append(b, '\n'), 0o600) 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) { func readMeta(dir string) (*localMeta, error) {
b, err := os.ReadFile(filepath.Join(dir, metaFile)) b, err := os.ReadFile(filepath.Join(dir, metaFile))
if err != nil { if err != nil {
@@ -340,7 +366,7 @@ func runHook(sc savedCert, ci certInfo, renewed bool) error {
func certGetCmd() *cobra.Command { func certGetCmd() *cobra.Command {
var domains []string var domains []string
var dir, hook, keyType, configDir string var dir, hook, keyType, configDir string
var noSave, force bool var noSave, force, replace bool
var wait time.Duration var wait time.Duration
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "get [NAME]", 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 saved certificate: run it daily (jarl cert schedule does that with a
systemd timer). Nothing is downloaded until jarld has renewed one. 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 --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, 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, JARL_CERT_CHAIN, JARL_CERT_FULLCHAIN, JARL_CERT_KEY, JARL_CERT_DOMAINS,
@@ -427,6 +457,9 @@ replaced an older certificate).`,
} }
sc.Conn = cn sc.Conn = cn
} }
if err := checkCertClash(saved, i, sc, replace); err != nil {
return err
}
todo = []savedCert{sc} todo = []savedCert{sc}
} }
var results []*getResult var results []*getResult
@@ -469,7 +502,7 @@ replaced an older certificate).`,
} }
switch { switch {
case len(failed) == 1 && len(todo) == 1: 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: case len(failed) > 0:
return fmt.Errorf("failed: %s", strings.Join(failed, ", ")) 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.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(&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(&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.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)") 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)) _ = cmd.RegisterFlagCompletionFunc("key-type", cobra.FixedCompletions([]string{"ec256", "ec384", "rsa2048", "rsa3072", "rsa4096"}, cobra.ShellCompDirectiveNoFileComp))
+59
View File
@@ -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
}
+7
View File
@@ -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 `jarl cert get` without a name does every saved certificate. A token
given as `@FILE` is saved as that reference, not copied. `jarl cert given as `@FILE` is saved as that reference, not copied. `jarl cert
forget NAME` stops getting one (`--delete` deletes it on jarld too). 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 * **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_NAME`, `JARL_CERT_DIR`, `JARL_CERT_CERT`, `JARL_CERT_CHAIN`,
`JARL_CERT_FULLCHAIN`, `JARL_CERT_KEY`, `JARL_CERT_DOMAINS`, `JARL_CERT_FULLCHAIN`, `JARL_CERT_KEY`, `JARL_CERT_DOMAINS`,
+2
View File
@@ -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 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 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 # 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 saved # this host's certificates and their expiry
jarl cert forget mail [--delete] # stop getting it here jarl cert forget mail [--delete] # stop getting it here
sudo jarl cert schedule [--remove] # daily systemd timer for jarl cert get sudo jarl cert schedule [--remove] # daily systemd timer for jarl cert get
@@ -536,6 +537,7 @@ jarl token list --all --expiring [60d] # expiring within 14 days
jarl token create laptop --expires 365d jarl token create laptop --expires 365d
jarl token create backup-script --grant "*:read" jarl token create backup-script --grant "*:read"
jarl token create acme --user certbot --role acme jarl token create acme --user certbot --role acme
jarl token create web01 --grant "certificates:write names=web01.adbraun.lol" # jarl cert get on one host (or --role certificates)
jarl token rotate laptop --save # new secret, same token and grants jarl token rotate laptop --save # new secret, same token and grants
jarl token rotate --current --save --grace 10m # the token this CLI uses jarl token rotate --current --save --grace 10m # the token this CLI uses
jarl token revoke 7 jarl token revoke 7
+2
View File
@@ -185,6 +185,7 @@ you can then edit.
| `dns-editor` | `zones:write`, `records:write`, `views:read`, `acls:read`, `options:read`, `blocking:read` | | `dns-editor` | `zones:write`, `records:write`, `views:read`, `acls:read`, `options:read`, `blocking:read` |
| `operator` | `*:write` (everything except user management) | | `operator` | `*:write` (everything except user management) |
| `acme` | `records:write names=_acme-challenge.* types=TXT` | | `acme` | `records:write names=_acme-challenge.* types=TXT` |
| `certificates` | `certificates:write`: certificates for other hosts (`jarl cert get`) for any name in Jarl's zones. Narrow it to a host's names, e.g. `certificates:write names=web01.adbraun.lol` ([certificates.md](certificates.md#certificates-for-other-hosts)) |
```sh ```sh
jarl user create alice --role dns-editor jarl user create alice --role dns-editor
@@ -210,6 +211,7 @@ The audit log shows the user and the token name.
jarl token create laptop # full rights of the user, no expiry jarl token create laptop # full rights of the user, no expiry
jarl token create ci --grant "records:write zones=lab.adbraun.lol" --expires 90d jarl token create ci --grant "records:write zones=lab.adbraun.lol" --expires 90d
jarl token create certbot --user certbot --role acme # admin: a token for another user jarl token create certbot --user certbot --role acme # admin: a token for another user
jarl token create web01 --grant "certificates:write names=web01.adbraun.lol" # jarl cert get on one host
jarl token list --all jarl token list --all
jarl token revoke 12 jarl token revoke 12
``` ```
+10
View File
@@ -135,6 +135,16 @@ func TestRoleTemplatesValid(t *testing.T) {
} }
} }
func TestCertificatesRole(t *testing.T) {
p := &Principal{User: &model.User{Grants: RoleTemplates["certificates"]}}
if !p.Can(Action{Resource: ResCerts, Write: true, Name: "web01.adbraun.lol"}) {
t.Error("the certificates role can't get a certificate")
}
if p.Can(Action{Resource: ResRecords, Write: true, Zone: "adbraun.lol."}) || p.Can(Action{Resource: ResZones, Zone: "adbraun.lol."}) {
t.Error("the certificates role reaches beyond certificates")
}
}
func TestCertificateGrants(t *testing.T) { func TestCertificateGrants(t *testing.T) {
g, err := ParseGrant("certificates:write names=*.adbraun.lol") g, err := ParseGrant("certificates:write names=*.adbraun.lol")
if err != nil { if err != nil {
+3
View File
@@ -391,4 +391,7 @@ var RoleTemplates = map[string][]model.Grant{
}, },
"operator": {{Resource: ResAll, Access: Write}}, "operator": {{Resource: ResAll, Access: Write}},
"acme": {{Resource: ResRecords, Access: Write, Names: []string{"_acme-challenge.*"}, Types: []string{"TXT"}}}, "acme": {{Resource: ResRecords, Access: Write, Names: []string{"_acme-challenge.*"}, Types: []string{"TXT"}}},
// certificates for other hosts (jarl cert get): narrow it with names=
// to the host's own.
"certificates": {{Resource: ResCerts, Access: Write}},
} }
+2 -1
View File
@@ -195,7 +195,8 @@ async function rotateDialog(t, redraw) {
function coveredBy(user, grant) { function coveredBy(user, grant) {
if (!user || user.admin) return true; if (!user || user.admin) return true;
const [res, acc] = grant.split(/\s+/)[0].split(":"); const [res, acc] = grant.split(/\s+/)[0].split(":");
return (user.grants || []).some((g) => (g.resource === "*" || g.resource === res) && (g.access === "write" || acc === "read")); // "*" never covers certificates (they hand out private keys).
return (user.grants || []).some((g) => ((g.resource === "*" && res !== "certificates") || g.resource === res) && (g.access === "write" || acc === "read"));
} }
// newTokenForm makes a token for owner (the user it belongs to). Its // newTokenForm makes a token for owner (the user it belongs to). Its