Compare commits

..
8 Commits
Author SHA1 Message Date
adamandClaude Opus 5.5 67a829be34 Release 0.6.4-rc.2
release / release (push) Successful in 4m44s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 00:24:45 +01:00
adamandClaude Opus 5.5 42e79af678 cert get: combined file for HAProxy, --local, saved NAME; cert list shows get and last download
- jarl cert get --combined PATH writes the key and full chain in one file
  (mode 600, atomic), kept outside --dir so it can live in HAProxy's crt
  directory; JARL_CERT_COMBINED for the hook. Turning it on, moving it or
  removing it ('') applies from the files on disk and runs the hook.
- jarl cert get --local writes the combined file and runs the hook from
  this host's files, without asking jarld.
- jarl cert saved NAME shows all of one certificate's settings.
- jarl cert list: USES "get" for certificates for other hosts, and a
  PULLED column (last download, "(old)" while a host is behind). Owners
  now see their own downloads (pulled_by), not only administrators.
- jarl cert get without a name refuses per-certificate flags.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 00:24:33 +01:00
adamandClaude Opus 5.5 3fb159cf4d Release 0.6.4-rc.1
release / release (push) Successful in 4m42s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 18:58:56 +01:00
adamandClaude Opus 5.5 71ebed2b73 Log failed logins and refused tokens, address first
release / release (push) Failing after 18s
For Guard's jarl preset and fail2ban: msg="login failed" ip=... user=...
and msg="token refused" ip=..., beside the audit records.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:29:37 +01:00
adamandClaude Opus 5.5 95125e7f50 Release 0.6.3
Documentation: lego 5's commands beside lego 4's, and lego on a host
inside a split DNS (its propagation check and the ways round it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 22:44:01 +01:00
adamandClaude Opus 5.5 c10ebe0d38 Docs: lego 5, and split DNS with lego's propagation check
docs/acme.md and jarl-lego show lego 5's commands (lego run, which also
renews; lego migrate when upgrading) beside lego 4's. A new section
explains why lego on a host inside a split DNS fails its own propagation
check when the challenge goes into the external view only (JARL_VIEW, a
one-view token, httpreq's /view/...), and the ways round it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 22:44:00 +01:00
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
23 changed files with 778 additions and 88 deletions

No files matched your search

+55
View File
@@ -6,6 +6,61 @@ All notable changes to Jarl are documented here. The format follows
## [Unreleased] ## [Unreleased]
### Added
- **Failed logins in the journal.** A failed login (a wrong password, or
a wrong two-factor code) and a refused API token are logged as well as
audited, with the address first (`msg="login failed" ip=… user=…`,
`msg="token refused" ip=…`), for Guard's `jarl` preset or a fail2ban
filter to ban such addresses (docs/operations.md).
- **`jarl cert get --combined PATH`** also writes the key and the full
chain in one file (mode 600, written all at once), for HAProxy. It
belongs outside `--dir`, so a directory of combined files can be
HAProxy's `crt` directory. The hook gets it as `JARL_CERT_COMBINED`.
Adding it to a certificate this host already has writes it from the
files on disk and runs the hook; `--combined ''` removes it.
- **`jarl cert get --local`** doesn't ask jarld: it writes the combined
file and runs the hook from this host's files, to apply a changed
setting or test a hook.
- **`jarl cert saved NAME`** shows all of one certificate's saved
settings (names, key type, dir, combined file, hook, server) and this
host's copy. The token is never shown.
- `jarl cert list` has a PULLED column: when the certificate was last
downloaded, with `(old)` while a host hasn't fetched the renewed one.
The owner of a certificate obtained with `jarl cert get` now sees their
own downloads (`pulled_by`) in `jarl cert list` and `jarl cert show`, not
only administrators.
### Changed
- `jarl cert list` shows `get` as USES for a certificate obtained with
`jarl cert get`, not `-`.
- `jarl cert get` without a name refuses `--dir`, `--hook`, `--key-type`,
`--combined`, `--replace` and `--no-save`, which change one
certificate; they used to be ignored.
## [0.6.3] - 2026-10-21
### Added
- A docs/acme.md section: lego on a host inside a split DNS fails its own
propagation check when the challenge is written into the external view
only (`JARL_VIEW`, a one-view token, `.../httpreq/view/...`): why, and
the ways round it (no view limit, `--dns.propagation.disable-ans` or
`--dns.propagation.wait`, Traefik's `propagation.disableANSChecks`).
- docs/acme.md and `jarl-lego` show lego 5's commands (`lego run ...`,
which also renews; `lego migrate` when upgrading), with lego 4's
beside them. Jarl's side is the same for both.
## [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 ## [0.6.1] - 2026-10-20
### Added ### Added
+6 -1
View File
@@ -202,7 +202,12 @@ review step.
## Status ## Status
Version 0.6.1 is the current release. It gets certificates onto your Version 0.6.3 is the current release; 0.6.4 is in testing (0.6.4-rc.2):
failed logins and refused tokens are logged, address first, for Guard
and fail2ban, and `jarl cert get` can write a combined file for HAProxy
(`--combined`) and apply settings without jarld (`--local`). 0.6.3 brings the lego docs up to
lego 5 and explains lego with split DNS. 0.6.2 added a `certificates` role
for tokens that get certificates for other hosts. 0.6.1 gets certificates onto your
other hosts (`jarl cert 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
+1 -1
View File
@@ -1 +1 @@
0.6.1 0.6.4-rc.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")
+268 -48
View File
@@ -2,7 +2,8 @@ package main
// jarl cert get: certificates for other hosts (docs/certificates.md#certificates-for-other-hosts). // jarl cert get: certificates for other hosts (docs/certificates.md#certificates-for-other-hosts).
// jarld obtains and renews the certificate; this host asks for it, waits, // jarld obtains and renews the certificate; this host asks for it, waits,
// downloads it when it is newer than its copy, and runs a hook. The // downloads it when it is newer than its copy, writes the combined file
// (--combined) and runs a hook. The
// settings of each certificate are saved, so a daily timer (jarl cert // settings of each certificate are saved, so a daily timer (jarl cert
// schedule) can run 'jarl cert get' for all of them. // schedule) can run 'jarl cert get' for all of them.
@@ -31,13 +32,16 @@ import (
// savedCert is a certificate's settings, saved by jarl cert get. // savedCert is a certificate's settings, saved by jarl cert get.
type savedCert struct { type savedCert struct {
Name string `json:"name"` Name string `json:"name"`
Domains []string `json:"domains"` Domains []string `json:"domains"`
KeyType string `json:"key_type,omitempty"` KeyType string `json:"key_type,omitempty"`
Dir string `json:"dir"` Dir string `json:"dir"`
Hook string `json:"hook,omitempty"` Hook string `json:"hook,omitempty"`
Conn connection `json:"connection"` // Combined is a file with the key and the full chain together, for
SavedAt time.Time `json:"saved_at"` // HAProxy (--combined); kept outside Dir.
Combined string `json:"combined,omitempty"`
Conn connection `json:"connection"`
SavedAt time.Time `json:"saved_at"`
} }
// localMeta is what this host has of a certificate, kept next to its // localMeta is what this host has of a certificate, kept next to its
@@ -48,6 +52,9 @@ type localMeta struct {
Serial string `json:"serial"` Serial string `json:"serial"`
NotAfter time.Time `json:"not_after"` NotAfter time.Time `json:"not_after"`
Fetched time.Time `json:"fetched"` Fetched time.Time `json:"fetched"`
// Combined is the combined file written for these files, so a changed
// --combined removes the old one.
Combined string `json:"combined,omitempty"`
// HookFailed is set when the hook failed for these files: the next // HookFailed is set when the hook failed for these files: the next
// run runs it again. // run runs it again.
HookFailed string `json:"hook_failed,omitempty"` HookFailed string `json:"hook_failed,omitempty"`
@@ -128,12 +135,53 @@ func checkCertClash(saved []savedCert, i int, sc savedCert, replace bool) error
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) 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 err := checkCombined(saved, i, sc); err != nil {
return err
}
if m, err := readMeta(sc.Dir); err == nil && m.Name != "" && m.Name != sc.Name && !replace { 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 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 return nil
} }
// checkCombined refuses a --combined path that is a directory, one of
// the files jarl cert get writes in a certificate's directory, or another
// saved certificate's combined file.
func checkCombined(saved []savedCert, i int, sc savedCert) error {
if sc.Combined == "" {
return nil
}
if st, err := os.Stat(sc.Combined); err == nil && st.IsDir() {
return fmt.Errorf("--combined %s is a directory: give the file's path, such as %s", sc.Combined, filepath.Join(sc.Combined, sc.Name+".pem"))
}
dir, base := filepath.Split(sc.Combined)
for j, o := range append(slices.Clone(saved), sc) {
if j == i {
continue // sc itself is last
}
if filepath.Clean(dir) == filepath.Clean(o.Dir) && (base == metaFile || slices.Contains(certFiles, base)) {
return fmt.Errorf("--combined %s would overwrite %s's %s: give another path", sc.Combined, o.Name, base)
}
if j < len(saved) && o.Name != sc.Name && o.Combined != "" && filepath.Clean(o.Combined) == filepath.Clean(sc.Combined) {
return fmt.Errorf("--combined %s is the combined file of %s: give %s another path", sc.Combined, o.Name, sc.Name)
}
}
return nil
}
// sameNames reports whether two lists hold the same names, in any order.
func sameNames(a, b []string) bool {
norm := func(l []string) []string {
out := make([]string, len(l))
for i, n := range l {
out[i] = strings.TrimSuffix(strings.ToLower(n), ".")
}
slices.Sort(out)
return slices.Compact(out)
}
return slices.Equal(norm(a), norm(b))
}
// sameServer reports whether two server addresses name the same jarld. // sameServer reports whether two server addresses name the same jarld.
func sameServer(a, b string) bool { func sameServer(a, b string) bool {
norm := func(s string) string { return strings.TrimRight(strings.ToLower(s), "/") } norm := func(s string) string { return strings.TrimRight(strings.ToLower(s), "/") }
@@ -191,12 +239,16 @@ type getResult struct {
Serial string `json:"serial"` Serial string `json:"serial"`
NotAfter time.Time `json:"not_after"` NotAfter time.Time `json:"not_after"`
Updated bool `json:"updated"` // new files written Updated bool `json:"updated"` // new files written
Hook string `json:"hook,omitempty"` // Combined is the combined file; CombinedWritten is set when it was
// written now.
Combined string `json:"combined,omitempty"`
CombinedWritten bool `json:"combined_written,omitempty"`
Hook string `json:"hook,omitempty"`
} }
// fetchCert asks jarld for the certificate, waits while it is obtained, // fetchCert asks jarld for the certificate, waits while it is obtained,
// downloads it when it is newer than this host's copy (or force) and runs // downloads it when it is newer than this host's copy (or force), and
// the hook. // then applies it (applyCert).
func fetchCert(c *client.Client, sc savedCert, force bool, wait time.Duration) (*getResult, error) { func fetchCert(c *client.Client, sc savedCert, force bool, wait time.Duration) (*getResult, error) {
var ans requestAnswer var ans requestAnswer
err := c.Do(ctx(), "POST", certPath(sc.Name)+"/request", nil, map[string]any{"domains": sc.Domains, "key_type": sc.KeyType}, &ans) err := c.Do(ctx(), "POST", certPath(sc.Name)+"/request", nil, map[string]any{"domains": sc.Domains, "key_type": sc.KeyType}, &ans)
@@ -241,23 +293,68 @@ func fetchCert(c *client.Client, sc savedCert, force bool, wait time.Duration) (
if ci.LastError != "" && ci.LastAttempt != nil && (ci.LastSuccess == nil || ci.LastAttempt.After(*ci.LastSuccess)) { if ci.LastError != "" && ci.LastAttempt != nil && (ci.LastSuccess == nil || ci.LastAttempt.After(*ci.LastSuccess)) {
fmt.Fprintf(os.Stderr, "%s: jarld's last attempt failed (%s); this is the certificate it has\n", sc.Name, ci.LastError) fmt.Fprintf(os.Stderr, "%s: jarld's last attempt failed (%s); this is the certificate it has\n", sc.Name, ci.LastError)
} }
res := &getResult{Name: sc.Name, Dir: sc.Dir, Serial: ci.Serial, NotAfter: *ci.NotAfter}
meta, _ := readMeta(sc.Dir) meta, _ := readMeta(sc.Dir)
have := meta != nil && meta.Serial == ci.Serial && filesPresent(sc.Dir) have := meta != nil && meta.Serial == ci.Serial && filesPresent(sc.Dir)
if have && !force && meta.HookFailed == "" {
return res, nil
}
renewed := meta != nil && meta.Serial != "" && meta.Serial != ci.Serial renewed := meta != nil && meta.Serial != "" && meta.Serial != ci.Serial
if !have || force { updated := !have || force
if updated {
if err := downloadCert(c, sc, ci); err != nil { if err := downloadCert(c, sc, ci); err != nil {
return nil, err return nil, err
} }
res.Updated = true old := ""
meta = &localMeta{Name: sc.Name, Domains: ci.Domains, Serial: ci.Serial, NotAfter: *ci.NotAfter, Fetched: time.Now().UTC()} if meta != nil {
old = meta.Combined
}
meta = &localMeta{Name: sc.Name, Domains: ci.Domains, Serial: ci.Serial, NotAfter: *ci.NotAfter, Fetched: time.Now().UTC(), Combined: old}
}
return applyCert(sc, meta, updated, renewed, force)
}
// localCert applies the files this host has, without asking jarld
// (--local): it writes the combined file and runs the hook.
func localCert(sc savedCert) (*getResult, error) {
meta, err := readMeta(sc.Dir)
if err != nil || !filesPresent(sc.Dir) {
return nil, fmt.Errorf("%s: this host has no files for it in %s yet: run jarl cert get %s without --local", sc.Name, sc.Dir, sc.Name)
}
if meta.Name != "" && meta.Name != sc.Name {
return nil, fmt.Errorf("%s holds the files of the certificate %s, not %s", sc.Dir, meta.Name, sc.Name)
}
return applyCert(sc, meta, false, false, true)
}
// applyCert brings what lies outside the certificate's directory in line
// with its files: it writes the combined file when the files are new, its
// path changed or it is missing (and removes one written for an older
// path), then runs the hook when anything changed, when it failed last
// time, or always.
func applyCert(sc savedCert, meta *localMeta, updated, renewed, always bool) (*getResult, error) {
res := &getResult{Name: sc.Name, Dir: sc.Dir, Serial: meta.Serial, NotAfter: meta.NotAfter, Updated: updated, Combined: sc.Combined}
changed := updated
if meta.Combined != "" && filepath.Clean(meta.Combined) != filepath.Clean(sc.Combined) {
if err := os.Remove(meta.Combined); err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("%s: removing the old combined file: %w", sc.Name, err)
}
meta.Combined = ""
changed = true
}
if sc.Combined != "" {
_, err := os.Stat(sc.Combined)
if updated || always || meta.Combined == "" || err != nil {
if err := writeCombined(sc); err != nil {
return nil, err
}
meta.Combined = sc.Combined
res.CombinedWritten = true
changed = true
}
}
if !changed && !always && meta.HookFailed == "" {
return res, nil
} }
if sc.Hook != "" { if sc.Hook != "" {
res.Hook = "ok" res.Hook = "ok"
if err := runHook(sc, ci, renewed); err != nil { if err := runHook(sc, meta, renewed); err != nil {
meta.HookFailed = err.Error() meta.HookFailed = err.Error()
res.Hook = "failed: " + err.Error() res.Hook = "failed: " + err.Error()
} else { } else {
@@ -275,6 +372,31 @@ func fetchCert(c *client.Client, sc savedCert, force bool, wait time.Duration) (
var certFiles = []string{"cert.pem", "chain.pem", "fullchain.pem", "privkey.pem"} var certFiles = []string{"cert.pem", "chain.pem", "fullchain.pem", "privkey.pem"}
// writeCombined writes the combined file from the certificate's files:
// the key, then the full chain (as jarl cert export --part bundle), mode
// 600, all at once.
func writeCombined(sc savedCert) error {
full, err := os.ReadFile(filepath.Join(sc.Dir, "fullchain.pem"))
if err != nil {
return err
}
key, err := os.ReadFile(filepath.Join(sc.Dir, "privkey.pem"))
if err != nil {
return err
}
if _, err := tls.X509KeyPair(full, key); err != nil {
return fmt.Errorf("%s: the files in %s don't match (%v): run jarl cert get %s --force", sc.Name, sc.Dir, err, sc.Name)
}
if err := os.MkdirAll(filepath.Dir(sc.Combined), 0o755); err != nil {
return err
}
data := append(append(append([]byte{}, key...), '\n'), full...)
if err := writeAtomic(sc.Combined, data, 0o600); err != nil {
return fmt.Errorf("%s: writing the combined file: %w", sc.Name, err)
}
return nil
}
func filesPresent(dir string) bool { func filesPresent(dir string) bool {
for _, f := range certFiles { for _, f := range certFiles {
if _, err := os.Stat(filepath.Join(dir, f)); err != nil { if _, err := os.Stat(filepath.Join(dir, f)); err != nil {
@@ -339,7 +461,7 @@ func downloadCert(c *client.Client, sc savedCert, ci certInfo) error {
// runHook runs the certificate's hook with sh, its files in the // runHook runs the certificate's hook with sh, its files in the
// environment. // environment.
func runHook(sc savedCert, ci certInfo, renewed bool) error { func runHook(sc savedCert, meta *localMeta, renewed bool) error {
c, cancel := context.WithTimeout(context.Background(), 5*time.Minute) c, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel() defer cancel()
cmd := exec.CommandContext(c, "sh", "-c", sc.Hook) cmd := exec.CommandContext(c, "sh", "-c", sc.Hook)
@@ -354,9 +476,10 @@ func runHook(sc savedCert, ci certInfo, renewed bool) error {
"JARL_CERT_CHAIN="+filepath.Join(sc.Dir, "chain.pem"), "JARL_CERT_CHAIN="+filepath.Join(sc.Dir, "chain.pem"),
"JARL_CERT_FULLCHAIN="+filepath.Join(sc.Dir, "fullchain.pem"), "JARL_CERT_FULLCHAIN="+filepath.Join(sc.Dir, "fullchain.pem"),
"JARL_CERT_KEY="+filepath.Join(sc.Dir, "privkey.pem"), "JARL_CERT_KEY="+filepath.Join(sc.Dir, "privkey.pem"),
"JARL_CERT_DOMAINS="+strings.Join(ci.Domains, " "), "JARL_CERT_COMBINED="+sc.Combined,
"JARL_CERT_SERIAL="+ci.Serial, "JARL_CERT_DOMAINS="+strings.Join(meta.Domains, " "),
"JARL_CERT_NOT_AFTER="+ci.NotAfter.UTC().Format(time.RFC3339), "JARL_CERT_SERIAL="+meta.Serial,
"JARL_CERT_NOT_AFTER="+meta.NotAfter.UTC().Format(time.RFC3339),
"JARL_CERT_RENEWED="+ren, "JARL_CERT_RENEWED="+ren,
) )
cmd.Stdout, cmd.Stderr = os.Stderr, os.Stderr cmd.Stdout, cmd.Stderr = os.Stderr, os.Stderr
@@ -365,8 +488,8 @@ 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, combined, configDir string
var noSave, force, replace bool var noSave, force, replace, local bool
var wait time.Duration var wait time.Duration
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "get [NAME]", Use: "get [NAME]",
@@ -374,7 +497,7 @@ func certGetCmd() *cobra.Command {
Long: `Gets a certificate for this host (a mail server, a reverse proxy). jarld Long: `Gets a certificate for this host (a mail server, a reverse proxy). jarld
obtains it from the CA and renews it, answering the challenges in its own obtains it from the CA and renews it, answering the challenges in its own
zones; this command asks for it, waits until it is there, downloads it zones; this command asks for it, waits until it is there, downloads it
when it is newer than this host's copy, and runs --hook. when it is newer than this host's copy, writes --combined and runs --hook.
The token needs a certificates grant for the names, and the certificate The token needs a certificates grant for the names, and the certificate
belongs to the token's user: another user can't take the name. Asking belongs to the token's user: another user can't take the name. Asking
@@ -383,11 +506,19 @@ removing a name gets a new one.
Files in --dir (default /etc/jarl/certs/NAME as root, ./NAME otherwise): Files in --dir (default /etc/jarl/certs/NAME as root, ./NAME otherwise):
cert.pem, chain.pem (the intermediates), fullchain.pem, privkey.pem (mode cert.pem, chain.pem (the intermediates), fullchain.pem, privkey.pem (mode
600), and jarl-cert.json (serial, expiry, when it was fetched). 600), and jarl-cert.json (serial, expiry, when it was fetched). Don't
point HAProxy's crt at --dir: it loads every file there.
The settings (names, --dir, --hook, the server and token) are saved in --combined PATH also writes the key and the full chain in one file (mode
600), as HAProxy wants. Keep it outside --dir; a directory of combined
files can be HAProxy's crt directory. --combined '' stops writing it and
removes the file.
The settings (names, --dir, --combined, --hook, the server and token) are
saved in
/etc/jarl/cert-get/NAME.json (as root; ~/.config/jarl/cert-get otherwise, /etc/jarl/cert-get/NAME.json (as root; ~/.config/jarl/cert-get otherwise,
readable only by you). 'jarl cert get' without a name then does every readable only by you). To change one later, run 'jarl cert get NAME' with
just that option; the others stay. 'jarl cert get' without a name 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.
@@ -395,14 +526,20 @@ 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 holding another certificate's files, unless --replace is given; a --dir
another saved certificate uses is always refused. 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 or the combined file
run if it failed), with JARL_CERT_NAME, JARL_CERT_DIR, JARL_CERT_CERT, changes (and again on the next run if it failed), with JARL_CERT_NAME,
JARL_CERT_CHAIN, JARL_CERT_FULLCHAIN, JARL_CERT_KEY, JARL_CERT_DOMAINS, JARL_CERT_DIR, JARL_CERT_CERT, JARL_CERT_CHAIN, JARL_CERT_FULLCHAIN,
JARL_CERT_SERIAL, JARL_CERT_NOT_AFTER and JARL_CERT_RENEWED (1 when it JARL_CERT_KEY, JARL_CERT_COMBINED (empty without --combined),
replaced an older certificate).`, JARL_CERT_DOMAINS, JARL_CERT_SERIAL, JARL_CERT_NOT_AFTER and
JARL_CERT_RENEWED (1 when it replaced an older certificate).
--local doesn't ask jarld: it uses the files this host has, writes the
combined file and runs the hook. Use it after changing --combined or
--hook, or to test a hook.`,
Example: ` jarl cert get mail -d mail.adbraun.lol --hook 'systemctl reload postfix dovecot' Example: ` jarl cert get mail -d mail.adbraun.lol --hook 'systemctl reload postfix dovecot'
jarl cert get proxy -d adbraun.lol -d '*.adbraun.lol' --dir /etc/haproxy/certs/proxy \ jarl cert get proxy -d adbraun.lol -d '*.adbraun.lol' \
--hook 'cat "$JARL_CERT_KEY" "$JARL_CERT_FULLCHAIN" > /etc/haproxy/proxy.pem && systemctl reload haproxy' --combined /etc/haproxy/certs/adbraun.lol.pem --hook 'systemctl reload haproxy'
jarl cert get proxy --hook 'systemctl reload haproxy' --local # change a setting, apply it now
jarl cert get # every saved certificate (what the timer runs) jarl cert get # every saved certificate (what the timer runs)
jarl cert saved # what this host has, and when it expires`, jarl cert saved # what this host has, and when it expires`,
Args: cobra.MaximumNArgs(1), Args: cobra.MaximumNArgs(1),
@@ -414,6 +551,9 @@ replaced an older certificate).`,
} }
var todo []savedCert var todo []savedCert
f := cmd.Flags() f := cmd.Flags()
if local && force {
return errors.New("--local and --force don't go together: --local always writes the combined file and runs the hook; --force downloads again from jarld")
}
switch { switch {
case len(args) == 0: case len(args) == 0:
if len(domains) > 0 { if len(domains) > 0 {
@@ -422,6 +562,11 @@ replaced an older certificate).`,
if len(saved) == 0 { if len(saved) == 0 {
return fmt.Errorf("no saved certificates in %s: get one first (jarl cert get NAME -d DOMAIN)", cdir) return fmt.Errorf("no saved certificates in %s: get one first (jarl cert get NAME -d DOMAIN)", cdir)
} }
for _, n := range []string{"dir", "hook", "key-type", "combined", "replace", "no-save"} {
if f.Changed(n) {
return fmt.Errorf("--%s changes one certificate: give its name (jarl cert get NAME --%s ...)", n, n)
}
}
todo = saved todo = saved
default: default:
name := args[0] name := args[0]
@@ -434,6 +579,9 @@ replaced an older certificate).`,
} }
sc.Name = name sc.Name = name
if len(domains) > 0 { if len(domains) > 0 {
if local && i >= 0 && !sameNames(domains, sc.Domains) {
return fmt.Errorf("%s: new names need a new certificate from jarld: run jarl cert get %s without --local", name, name)
}
sc.Domains = domains sc.Domains = domains
} }
if f.Changed("dir") || sc.Dir == "" { if f.Changed("dir") || sc.Dir == "" {
@@ -445,6 +593,16 @@ replaced an older certificate).`,
if f.Changed("key-type") { if f.Changed("key-type") {
sc.KeyType = keyType sc.KeyType = keyType
} }
if f.Changed("combined") {
sc.Combined = ""
if combined != "" {
abs, err := filepath.Abs(combined)
if err != nil {
return err
}
sc.Combined = abs
}
}
if abs, err := filepath.Abs(sc.Dir); err == nil { if abs, err := filepath.Abs(sc.Dir); err == nil {
sc.Dir = abs sc.Dir = abs
} }
@@ -466,14 +624,19 @@ replaced an older certificate).`,
var failed []string var failed []string
var lastErr error var lastErr error
for _, sc := range todo { for _, sc := range todo {
c, err := sc.Conn.open() var r *getResult
if err == nil { var err error
var r *getResult if local {
r, err = fetchCert(c, sc, force, wait) r, err = localCert(sc)
if r != nil { } else {
results = append(results, r) var c *client.Client
if c, err = sc.Conn.open(); err == nil {
r, err = fetchCert(c, sc, force, wait)
} }
} }
if r != nil {
results = append(results, r)
}
if err != nil { if err != nil {
if len(todo) > 1 { if len(todo) > 1 {
fmt.Fprintln(os.Stderr, "error:", err) fmt.Fprintln(os.Stderr, "error:", err)
@@ -492,8 +655,16 @@ replaced an older certificate).`,
if err := output(results, func(w io.Writer) { if err := output(results, func(w io.Writer) {
for _, r := range results { for _, r := range results {
what := "up to date" what := "up to date"
if r.Updated { switch {
case r.Updated:
what = "new files" what = "new files"
case local:
what = "this host's files"
case r.CombinedWritten:
what = "combined file written"
}
if r.Combined != "" {
what += ", combined " + r.Combined
} }
fmt.Fprintf(w, "%s\t%s\t%s, expires %s\t%s\n", r.Name, r.Dir, what, r.NotAfter.Local().Format("2006-01-02"), hookText(r.Hook)) fmt.Fprintf(w, "%s\t%s\t%s, expires %s\t%s\n", r.Name, r.Dir, what, r.NotAfter.Local().Format("2006-01-02"), hookText(r.Hook))
} }
@@ -512,7 +683,9 @@ replaced an older certificate).`,
f := cmd.Flags() f := cmd.Flags()
f.StringArrayVarP(&domains, "domain", "d", nil, "a name for the certificate (repeatable; a wildcard as '*.adbraun.lol')") f.StringArrayVarP(&domains, "domain", "d", nil, "a name for the certificate (repeatable; a wildcard as '*.adbraun.lol')")
f.StringVar(&dir, "dir", "", "directory for the files (default /etc/jarl/certs/NAME as root, ./NAME otherwise)") f.StringVar(&dir, "dir", "", "directory for the files (default /etc/jarl/certs/NAME as root, ./NAME otherwise)")
f.StringVar(&hook, "hook", "", "shell command to run after new files are written") f.StringVar(&hook, "hook", "", "shell command to run after new files are written ('' removes it)")
f.StringVar(&combined, "combined", "", "also write the key and full chain in one file at this path, for HAProxy ('' stops it)")
f.BoolVar(&local, "local", false, "don't ask jarld: write the combined file and run the hook from this host's files")
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")
@@ -533,9 +706,15 @@ func hookText(h string) string {
func certSavedCmd() *cobra.Command { func certSavedCmd() *cobra.Command {
var configDir string var configDir string
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "saved", Use: "saved [NAME]",
Short: "The certificates this host gets with jarl cert get, and when they expire", Short: "The certificates this host gets with jarl cert get, and when they expire",
Args: cobra.NoArgs, Long: `Lists the certificates saved on this host by jarl cert get. With a name,
shows all of that certificate's settings and what this host has of it.
The token is never shown.`,
Example: ` jarl cert saved
jarl cert saved proxy
jarl cert saved -o json`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
cdir := certConfigDir(configDir) cdir := certConfigDir(configDir)
saved, err := loadSaved(cdir) saved, err := loadSaved(cdir)
@@ -548,17 +727,27 @@ func certSavedCmd() *cobra.Command {
} }
var rows []row var rows []row
for _, sc := range saved { for _, sc := range saved {
if len(args) == 1 && sc.Name != args[0] {
continue
}
sc.Conn.Token = "" // never print it sc.Conn.Token = "" // never print it
m, _ := readMeta(sc.Dir) m, _ := readMeta(sc.Dir)
rows = append(rows, row{sc, m}) rows = append(rows, row{sc, m})
} }
if len(args) == 1 {
if len(rows) == 0 {
return fmt.Errorf("%s isn't saved in %s (jarl cert saved lists what is)", args[0], cdir)
}
r := rows[0]
return output(r, func(w io.Writer) { printSaved(w, r.savedCert, r.Local) })
}
return output(rows, func(w io.Writer) { return output(rows, func(w io.Writer) {
if len(rows) == 0 { if len(rows) == 0 {
fmt.Fprintf(w, "No saved certificates in %s.\n", cdir) fmt.Fprintf(w, "No saved certificates in %s.\n", cdir)
return return
} }
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "NAME\tNAMES\tDIR\tEXPIRES\tFETCHED\tSERVER\tHOOK") fmt.Fprintln(tw, "NAME\tNAMES\tDIR\tCOMBINED\tEXPIRES\tFETCHED\tSERVER\tHOOK")
for _, r := range rows { for _, r := range rows {
exp, fetched := "-", "-" exp, fetched := "-", "-"
if r.Local != nil { if r.Local != nil {
@@ -568,7 +757,7 @@ func certSavedCmd() *cobra.Command {
fetched += " HOOK FAILED" fetched += " HOOK FAILED"
} }
} }
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", r.Name, strings.Join(r.Domains, ","), r.Dir, exp, fetched, r.Conn.Server, firstNonEmpty(r.Hook, "-")) fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", r.Name, strings.Join(r.Domains, ","), r.Dir, dash(r.Combined), exp, fetched, r.Conn.Server, firstNonEmpty(r.Hook, "-"))
} }
tw.Flush() tw.Flush()
}) })
@@ -578,6 +767,37 @@ func certSavedCmd() *cobra.Command {
return cmd return cmd
} }
// printSaved shows one saved certificate: its settings, then this
// host's copy.
func printSaved(w io.Writer, sc savedCert, m *localMeta) {
fmt.Fprintf(w, "Name:\t%s\n", sc.Name)
fmt.Fprintf(w, "Names:\t%s\n", strings.Join(sc.Domains, " "))
fmt.Fprintf(w, "Key type:\t%s\n", firstNonEmpty(sc.KeyType, "ec256 (default)"))
fmt.Fprintf(w, "Dir:\t%s\n", sc.Dir)
fmt.Fprintf(w, "Combined:\t%s\n", dash(sc.Combined))
fmt.Fprintf(w, "Hook:\t%s\n", dash(sc.Hook))
fmt.Fprintf(w, "Server:\t%s\n", sc.Conn.Server)
if sc.Conn.CACert != "" {
fmt.Fprintf(w, "CA cert:\t%s\n", sc.Conn.CACert)
}
if sc.Conn.Insecure {
fmt.Fprintf(w, "TLS:\tnot verified (--insecure)\n")
}
if !sc.SavedAt.IsZero() {
fmt.Fprintf(w, "Saved:\t%s\n", fmtTime(&sc.SavedAt))
}
if m == nil {
fmt.Fprintf(w, "Files:\tnone yet (jarl cert get %s fetches them)\n", sc.Name)
return
}
fmt.Fprintf(w, "Serial:\t%s\n", m.Serial)
fmt.Fprintf(w, "Expires:\t%s (%dd)\n", fmtTime(&m.NotAfter), int(time.Until(m.NotAfter).Hours()/24))
fmt.Fprintf(w, "Fetched:\t%s\n", fmtTime(&m.Fetched))
if m.HookFailed != "" {
fmt.Fprintf(w, "Hook failed:\t%s (the next jarl cert get runs it again)\n", m.HookFailed)
}
}
func certForgetCmd() *cobra.Command { func certForgetCmd() *cobra.Command {
var configDir string var configDir string
var del, yes bool var del, yes bool
+144
View File
@@ -1,10 +1,19 @@
package main package main
import ( import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"time"
) )
func TestCheckCertClash(t *testing.T) { func TestCheckCertClash(t *testing.T) {
@@ -57,3 +66,138 @@ func mkdir(t *testing.T, dir string) string {
} }
return dir return dir
} }
func TestCheckCombined(t *testing.T) {
tmp := t.TempDir()
saved := []savedCert{
{Name: "proxy", Dir: filepath.Join(tmp, "proxy"), Combined: filepath.Join(tmp, "haproxy", "proxy.pem")},
{Name: "mail", Dir: filepath.Join(tmp, "mail")},
}
mkdir(t, filepath.Join(tmp, "haproxy"))
cases := []struct {
what string
i int
sc savedCert
want string
}{
{"none", 1, savedCert{Name: "mail", Dir: saved[1].Dir}, ""},
{"own again", 0, saved[0], ""},
{"beside the others", 1, savedCert{Name: "mail", Dir: saved[1].Dir, Combined: filepath.Join(tmp, "haproxy", "mail.pem")}, ""},
{"inside its own dir", 1, savedCert{Name: "mail", Dir: saved[1].Dir, Combined: filepath.Join(saved[1].Dir, "combined.pem")}, ""},
{"another's combined", 1, savedCert{Name: "mail", Dir: saved[1].Dir, Combined: saved[0].Combined}, "combined file of proxy"},
{"own fullchain", 1, savedCert{Name: "mail", Dir: saved[1].Dir, Combined: filepath.Join(saved[1].Dir, "fullchain.pem")}, "overwrite mail's fullchain.pem"},
{"another's key", 1, savedCert{Name: "mail", Dir: saved[1].Dir, Combined: filepath.Join(saved[0].Dir, "privkey.pem")}, "overwrite proxy's privkey.pem"},
{"a directory", 1, savedCert{Name: "mail", Dir: saved[1].Dir, Combined: filepath.Join(tmp, "haproxy")}, "is a directory"},
}
for _, c := range cases {
err := checkCombined(saved, c.i, c.sc)
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)
}
}
}
// TestApplyCert: the combined file follows --combined, and the hook runs
// only when something changed (always with --local).
func TestApplyCert(t *testing.T) {
tmp := t.TempDir()
dir := mkdir(t, filepath.Join(tmp, "proxy"))
full, key := testKeyPair(t)
for name, data := range map[string][]byte{"cert.pem": full, "chain.pem": nil, "fullchain.pem": full, "privkey.pem": key} {
if err := os.WriteFile(filepath.Join(dir, name), data, 0o600); err != nil {
t.Fatal(err)
}
}
if err := (&localMeta{Name: "proxy", Serial: "1", NotAfter: time.Now().Add(time.Hour)}).write(dir); err != nil {
t.Fatal(err)
}
runs := filepath.Join(tmp, "runs")
sc := savedCert{Name: "proxy", Dir: dir, Hook: `echo "$JARL_CERT_COMBINED" >> ` + runs}
hookRuns := func() []string {
b, _ := os.ReadFile(runs)
return strings.Fields(string(b))
}
apply := func(local bool) *getResult {
t.Helper()
var r *getResult
var err error
if local {
r, err = localCert(sc)
} else {
meta, _ := readMeta(dir)
r, err = applyCert(sc, meta, false, false, false)
}
if err != nil {
t.Fatal(err)
}
return r
}
if r := apply(false); r.CombinedWritten || len(hookRuns()) != 0 {
t.Fatalf("nothing changed, but: %+v, hook ran %d times", r, len(hookRuns()))
}
first := filepath.Join(tmp, "haproxy", "proxy.pem")
sc.Combined = first
if r := apply(false); !r.CombinedWritten || len(hookRuns()) != 1 {
t.Fatalf("new --combined: %+v, hook ran %d times", r, len(hookRuns()))
}
b, err := os.ReadFile(first)
if err != nil {
t.Fatal(err)
}
if _, err := tls.X509KeyPair(b, b); err != nil || !strings.HasPrefix(string(b), string(key)) {
t.Errorf("combined file isn't the key, then the chain: %v", err)
}
if st, _ := os.Stat(first); st.Mode().Perm() != 0o600 {
t.Errorf("combined file mode %v, want 600", st.Mode().Perm())
}
if r := apply(false); r.CombinedWritten || len(hookRuns()) != 1 {
t.Fatalf("second run changed something: %+v, hook ran %d times", r, len(hookRuns()))
}
os.Remove(first)
if r := apply(false); !r.CombinedWritten || len(hookRuns()) != 2 {
t.Fatalf("missing combined file not rewritten: %+v", r)
}
if r := apply(true); !r.CombinedWritten || len(hookRuns()) != 3 {
t.Fatalf("--local: %+v, hook ran %d times", r, len(hookRuns()))
}
second := filepath.Join(tmp, "haproxy", "adbraun.lol.pem")
sc.Combined = second
apply(false)
if _, err := os.Stat(first); !os.IsNotExist(err) {
t.Errorf("old combined file %s not removed: %v", first, err)
}
if _, err := os.Stat(second); err != nil {
t.Errorf("new combined file: %v", err)
}
sc.Combined = ""
apply(false)
if _, err := os.Stat(second); !os.IsNotExist(err) {
t.Errorf("--combined '' left %s: %v", second, err)
}
// Five runs; the last had JARL_CERT_COMBINED empty, so echo wrote no field.
if got := hookRuns(); len(got) != 4 || got[3] != second {
t.Errorf("hook runs: %q", got)
}
}
func testKeyPair(t *testing.T) (certPEM, keyPEM []byte) {
t.Helper()
k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
tpl := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "proxy.example"}, NotBefore: time.Now(), NotAfter: time.Now().Add(time.Hour)}
der, err := x509.CreateCertificate(rand.Reader, tpl, tpl, &k.PublicKey, k)
if err != nil {
t.Fatal(err)
}
kd, err := x509.MarshalECPrivateKey(k)
if err != nil {
t.Fatal(err)
}
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: kd})
}
+22 -2
View File
@@ -50,6 +50,7 @@ type certInfo struct {
Chain string `json:"chain"` Chain string `json:"chain"`
Key string `json:"key"` Key string `json:"key"`
} `json:"files,omitempty"` } `json:"files,omitempty"`
OwnerID int64 `json:"owner_id,omitempty"`
Owner string `json:"owner,omitempty"` Owner string `json:"owner,omitempty"`
PulledBy []struct { PulledBy []struct {
Who string `json:"who"` Who string `json:"who"`
@@ -131,7 +132,7 @@ func certListCmd() *cobra.Command {
return err return err
} }
return output(r.Certificates, func(w io.Writer) { return output(r.Certificates, func(w io.Writer) {
fmt.Fprintln(w, "NAME\tSOURCE\tUSES\tNAMES\tEXPIRES\tSTATUS") fmt.Fprintln(w, "NAME\tSOURCE\tUSES\tNAMES\tEXPIRES\tPULLED\tSTATUS")
for _, ci := range r.Certificates { for _, ci := range r.Certificates {
names := ci.Names names := ci.Names
if len(names) == 0 { if len(names) == 0 {
@@ -141,13 +142,32 @@ func certListCmd() *cobra.Command {
if ci.IssuingSince != nil { if ci.IssuingSince != nil {
state = "obtaining" state = "obtaining"
} }
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", ci.Name, ci.Source, dash(strings.Join(ci.Uses, ",")), strings.Join(names, " "), fmtDate(ci.NotAfter), state) uses := strings.Join(ci.Uses, ",")
if uses == "" && ci.OwnerID != 0 {
uses = "get" // for another host (jarl cert get)
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", ci.Name, ci.Source, dash(uses), strings.Join(names, " "), fmtDate(ci.NotAfter), lastPull(ci), state)
} }
}) })
}, },
} }
} }
// lastPull is when the certificate was last downloaded, marked "(old)"
// while someone who downloaded it still has the one before its renewal.
func lastPull(ci certInfo) string {
if len(ci.PulledBy) == 0 {
return "-"
}
s := fmtDate(&ci.PulledBy[0].At) // newest first
for _, p := range ci.PulledBy {
if p.Serial != ci.Serial {
return s + " (old)"
}
}
return s
}
func fmtDate(t *time.Time) string { func fmtDate(t *time.Time) string {
if t == nil { if t == nil {
return "-" return "-"
+6 -1
View File
@@ -7,12 +7,17 @@
# #
# Install it, then: # Install it, then:
# EXEC_PATH=/usr/local/bin/jarl-lego JARL_TOKEN=@/etc/jarl/lego.token \ # EXEC_PATH=/usr/local/bin/jarl-lego JARL_TOKEN=@/etc/jarl/lego.token \
# lego --email you@example.com --dns exec -d example.com -d '*.example.com' run # lego run --accept-tos --email you@example.com --dns exec -d example.com -d '*.example.com'
# (lego 4: lego --accept-tos --email ... --dns exec -d ... run, and "renew" to renew)
# #
# Options (environment): # Options (environment):
# JARL_SERVER Jarl server URL, e.g. https://ns1.example.com:8053 (optional if the jarl CLI is configured) # JARL_SERVER Jarl server URL, e.g. https://ns1.example.com:8053 (optional if the jarl CLI is configured)
# JARL_TOKEN API token, or @FILE (optional if the jarl CLI is configured) # JARL_TOKEN API token, or @FILE (optional if the jarl CLI is configured)
# JARL_VIEW Only write into this view (optional; default every view that has the zone) # JARL_VIEW Only write into this view (optional; default every view that has the zone)
# On a host inside a split DNS, lego's own propagation check then can't
# see the record: leave it unset, or run lego with
# --dns.propagation.disable-ans (lego 5; --dns.propagation-disable-ans in lego 4)
# (docs/acme.md, "Split DNS and lego's propagation check").
# JARL_BIN Path of the jarl binary (optional; default "jarl") # JARL_BIN Path of the jarl binary (optional; default "jarl")
JARL_BIN="${JARL_BIN:-jarl}" JARL_BIN="${JARL_BIN:-jarl}"
+87 -5
View File
@@ -98,10 +98,23 @@ sudo install -m 0755 contrib/lego/jarl-lego.sh /usr/local/bin/jarl-lego # or f
export EXEC_PATH=/usr/local/bin/jarl-lego export EXEC_PATH=/usr/local/bin/jarl-lego
export JARL_SERVER=https://ns1.adbraun.lol:8053 export JARL_SERVER=https://ns1.adbraun.lol:8053
export JARL_TOKEN=@/etc/jarl/certbot.token export JARL_TOKEN=@/etc/jarl/certbot.token
lego --email you@adbraun.lol --dns exec -d adbraun.lol -d '*.adbraun.lol' run lego run --accept-tos --email you@adbraun.lol --dns exec -d adbraun.lol -d '*.adbraun.lol'
``` ```
Renew with the same variables and `lego ... renew` in place of `run`. That is lego 5. `lego run` also renews: run the same command daily (a
cron job or systemd timer), and it renews once a third of the
certificate's lifetime is left. **lego 4** takes its options before the
command, and renews with `renew`:
```sh
lego --accept-tos --email you@adbraun.lol --dns exec -d adbraun.lol -d '*.adbraun.lol' run # first time
lego --email you@adbraun.lol --dns exec -d adbraun.lol -d '*.adbraun.lol' renew # then daily
```
Upgrading from lego 4 to 5, run `lego migrate` once (with `--path` if you
keep lego's data somewhere other than `./.lego`) before anything else:
lego 5 stores accounts and certificates differently. The exec and httpreq
providers, and so Jarl's side, are the same in both.
* lego runs `jarl-lego present FQDN VALUE` and `jarl-lego cleanup FQDN VALUE`, * lego runs `jarl-lego present FQDN VALUE` and `jarl-lego cleanup FQDN VALUE`,
and the script passes them to `jarl acme set|unset --exact`. FQDN is the and the script passes them to `jarl acme set|unset --exact`. FQDN is the
@@ -114,7 +127,66 @@ Renew with the same variables and `lego ... renew` in place of `run`.
* Leave `EXEC_MODE` unset: the script needs lego's default mode, not `RAW`. * Leave `EXEC_MODE` unset: the script needs lego's default mode, not `RAW`.
* lego waits until the record is visible on the zone's name servers * lego waits until the record is visible on the zone's name servers
(`EXEC_PROPAGATION_TIMEOUT`, 60 seconds by default). Jarl answers only (`EXEC_PROPAGATION_TIMEOUT`, 60 seconds by default). Jarl answers only
once named serves the record, so this check passes on its first try. once named serves the record, so this check passes on its first try,
as long as the view that answers lego's host has the record: with
`JARL_VIEW` on a host inside your network it doesn't. See
[Split DNS and lego's propagation check](#split-dns-and-legos-propagation-check).
### Split DNS and lego's propagation check
Before lego asks the CA to look, it checks the record itself, asking the
zone's name servers (`ns1.adbraun.lol`) directly. With views, they answer
lego's host from the view that matches *it*. On a host inside your
network that is the internal view. If the challenge was written into
another view only (`JARL_VIEW=external`, `--view external`, a token
limited to `views=external`, or the httpreq endpoint
`.../httpreq/view/external`), lego never sees it, and gives up after
its propagation timeout without the CA having looked:
```
propagation: time limit exceeded: last error: authoritative nameservers:
NS ns1.adbraun.lol.:53 returned NXDOMAIN for _acme-challenge.stats.adbraun.lol.
```
A name whose authorization is still valid from an earlier run ("authorization
already valid; skipping challenge") isn't checked at all, so one name can
work and the next fail with the same settings.
The ways round it, best first:
1. **Don't limit the view.** Leave `JARL_VIEW` unset (and give the token
no `views=` filter, the httpreq endpoint no `/view/...`): the record
goes into every view that has the zone, the internal one too, and
lego's check passes at once. The challenge is a random value that is
useless once the CA has checked it, and it is removed straight after.
2. **Skip lego's check of the name servers.** The record is already
served when Jarl answers, so lego's check adds nothing:
```sh
lego run --dns exec --dns.propagation.disable-ans ... # lego 5
lego --dns exec --dns.propagation-disable-ans ... run # lego 4.20 to 4.x
lego --dns exec --dns.disable-cp ... run # older lego 4
```
or replace the checks with a fixed wait, which also gives secondary
name servers time to catch up: `--dns.propagation.wait 30s` (lego 5;
`--dns.propagation-wait 30s` in lego 4.20 and later). In lego 5 these
can also be set in the environment: `LEGO_DNS_PROPAGATION_DISABLE_ANS=true`,
`LEGO_DNS_PROPAGATION_WAIT=30s`.
3. **In Traefik**, the same under `dnsChallenge` (Traefik 3.2 and later;
older versions have `disablePropagationCheck: true`):
```yaml
dnsChallenge:
provider: httpreq
propagation:
disableANSChecks: true # or delayBeforeChecks: 30s
```
4. **Run lego where the external view answers it**, such as a host outside
your network. Rarely the easy option.
certbot (section 3) doesn't check by itself, and acme.sh (section 4)
checks through public DNS-over-HTTPS resolvers, so neither runs into this.
## 6. lego and Traefik over HTTP (httpreq), without the jarl CLI ## 6. lego and Traefik over HTTP (httpreq), without the jarl CLI
@@ -299,14 +371,20 @@ of the `acme` role.
export HTTPREQ_ENDPOINT=https://ns1.adbraun.lol:8053/api/v1/acme/httpreq export HTTPREQ_ENDPOINT=https://ns1.adbraun.lol:8053/api/v1/acme/httpreq
export HTTPREQ_USERNAME=lego export HTTPREQ_USERNAME=lego
export HTTPREQ_PASSWORD_FILE=/etc/jarl/lego.token # or HTTPREQ_PASSWORD=jarl_... export HTTPREQ_PASSWORD_FILE=/etc/jarl/lego.token # or HTTPREQ_PASSWORD=jarl_...
lego --email you@adbraun.lol --dns httpreq -d adbraun.lol -d '*.adbraun.lol' run lego run --accept-tos --email you@adbraun.lol --dns httpreq -d adbraun.lol -d '*.adbraun.lol'
``` ```
That is lego 5; in lego 4 the options go before the command (`lego
--email ... --dns httpreq -d ... run`, and `renew` to renew). See
[section 5](#5-lego).
Leave `HTTPREQ_MODE` unset (its `RAW` mode sends a different body, which Leave `HTTPREQ_MODE` unset (its `RAW` mode sends a different body, which
jarld refuses with a message saying so). If jarld's certificate doesn't come from a public jarld refuses with a message saying so). If jarld's certificate doesn't come from a public
CA, lego has to trust yours: `SSL_CERT_FILE=/path/to/your-ca.pem` (lego's CA, lego has to trust yours: `SSL_CERT_FILE=/path/to/your-ca.pem` (lego's
`LEGO_CA_CERTIFICATES` covers only the ACME server). To write into one view only, put `LEGO_CA_CERTIFICATES` covers only the ACME server). To write into one view only, put
it in the endpoint: `.../api/v1/acme/httpreq/view/external`. it in the endpoint: `.../api/v1/acme/httpreq/view/external`; on a host inside
your network, read [Split DNS and lego's propagation check](#split-dns-and-legos-propagation-check)
first.
### 4. Traefik ### 4. Traefik
@@ -412,4 +490,8 @@ the main zone is never modified by automation.
`dig @ns1.adbraun.lol _acme-challenge.adbraun.lol TXT`. With views, make `dig @ns1.adbraun.lol _acme-challenge.adbraun.lol TXT`. With views, make
sure the record is in the view that matches the CA (it is by default) sure the record is in the view that matches the CA (it is by default)
and that `allow-query` lets the internet query the zone. and that `allow-query` lets the internet query the zone.
* lego says `propagation: time limit exceeded ... returned NXDOMAIN for
_acme-challenge...`: the record is in a view that doesn't answer lego's
host, usually `JARL_VIEW=external` on a host inside your network. See
[Split DNS and lego's propagation check](#split-dns-and-legos-propagation-check).
* `jarl audit --user certbot` shows every attempt. * `jarl audit --user certbot` shows every attempt.
+1 -1
View File
@@ -603,7 +603,7 @@ Administrators only. See [certificates.md](certificates.md#api).
| Method | Path | Body | | Method | Path | Body |
|--------|------|------| |--------|------|------|
| GET | `/certificates` | `certificates` (with `status`, `next_attempt`, `issuing_since`, `serving`, `files`) and `account` | | GET | `/certificates` | `certificates` (with `status`, `next_attempt`, `issuing_since`, `serving`, `files`, and for one obtained with `jarl cert get` `owner_id`, `owner` and `pulled_by`: every download for an administrator, the caller's own for the owner) and `account` |
| POST | `/certificates` | `{"name", "domains", "uses", "challenge", "key_type", "comment"}`, or `{"name", "source": "file", "cert_file", "key_file", "uses"}`; `"issue": false` doesn't start obtaining it | | POST | `/certificates` | `{"name", "domains", "uses", "challenge", "key_type", "comment"}`, or `{"name", "source": "file", "cert_file", "key_file", "uses"}`; `"issue": false` doesn't start obtaining it |
| POST | `/certificates/check` | `{"domains", "challenge"}`: the route of each challenge | | POST | `/certificates/check` | `{"domains", "challenge"}`: the route of each challenge |
| GET, PATCH, DELETE | `/certificates/{name}` | | | GET, PATCH, DELETE | `/certificates/{name}` | |
+67 -13
View File
@@ -274,6 +274,7 @@ export JARL_SERVER=https://jarl.adbraun.lol:8053 JARL_TOKEN=@/root/jarl.token
jarl cert get mail -d mail.adbraun.lol -d smtp.adbraun.lol --hook 'systemctl reload postfix dovecot' jarl cert get mail -d mail.adbraun.lol -d smtp.adbraun.lol --hook 'systemctl reload postfix dovecot'
sudo jarl cert schedule # a daily systemd timer runs 'jarl cert get' sudo jarl cert schedule # a daily systemd timer runs 'jarl cert get'
jarl cert saved # what this host has, and when it expires jarl cert saved # what this host has, and when it expires
jarl cert saved mail # all of mail's settings
``` ```
* **Asking again** with the same names returns the certificate jarld has: * **Asking again** with the same names returns the certificate jarld has:
@@ -286,12 +287,26 @@ jarl cert saved # what this host has, and when it exp
expiry, when it was fetched). Each file is replaced at once, never expiry, when it was fetched). Each file is replaced at once, never
half written. The key is checked against the certificate before half written. The key is checked against the certificate before
anything is written. anything is written.
* **Saved settings**: the names, `--dir`, `--hook`, the server and the * **A combined file** (`--combined PATH`): the key, then the full chain,
token are saved in `/etc/jarl/cert-get/NAME.json` (as root; in one file (mode 600), as HAProxy wants. See [HAProxy](#haproxy).
`~/.config/jarl/cert-get` otherwise), readable only by their owner. * **Saved settings**: the names, `--dir`, `--combined`, `--hook`, the
`jarl cert get` without a name does every saved certificate. A token server and the token are saved in `/etc/jarl/cert-get/NAME.json` (as
given as `@FILE` is saved as that reference, not copied. `jarl cert root; `~/.config/jarl/cert-get` otherwise), readable only by their
forget NAME` stops getting one (`--delete` deletes it on jarld too). owner. `jarl cert get` without a name does every saved certificate. A
token given as `@FILE` is saved as that reference, not copied. `jarl
cert saved NAME` shows them all (the token never). `jarl cert forget
NAME` stops getting one (`--delete` deletes it on jarld too).
* **Changing a setting**: run `jarl cert get NAME` with just that option
(`--hook '...'`, `--combined PATH`, `--dir DIR`); the others stay.
`--hook ''` removes the hook, `--combined ''` stops writing the
combined file and deletes it. The run still asks jarld for the
certificate, but downloads nothing while this host has the current one.
A new or moved combined file is written from the files this host
already has, and the hook runs.
* **`--local`** doesn't ask jarld at all: it writes the combined file and
runs the hook from the files this host has. Use it to apply a changed
setting while jarld is unreachable, or to test a hook. New names still
need jarld.
* **Several Jarl servers on one host**: each server has its own * **Several Jarl servers on one host**: each server has its own
certificate names, so two servers may both have a `proxy`. The host certificate names, so two servers may both have a `proxy`. The host
keeps one `proxy` per settings directory, though: asking another server keeps one `proxy` per settings directory, though: asking another server
@@ -299,12 +314,14 @@ jarl cert saved # what this host has, and when it exp
holding another certificate's files. Choose another name or `--dir`, or holding another certificate's files. Choose another name or `--dir`, or
pass `--replace` to take the name (or directory) over. A `--dir` that pass `--replace` to take the name (or directory) over. A `--dir` that
another saved certificate uses is always refused. 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 or the
`JARL_CERT_NAME`, `JARL_CERT_DIR`, `JARL_CERT_CERT`, `JARL_CERT_CHAIN`, combined file changes (and with `--local`), with `JARL_CERT_NAME`,
`JARL_CERT_FULLCHAIN`, `JARL_CERT_KEY`, `JARL_CERT_DOMAINS`, `JARL_CERT_DIR`, `JARL_CERT_CERT`, `JARL_CERT_CHAIN`,
`JARL_CERT_SERIAL`, `JARL_CERT_NOT_AFTER` and `JARL_CERT_RENEWED` (1 when `JARL_CERT_FULLCHAIN`, `JARL_CERT_KEY`, `JARL_CERT_COMBINED` (empty
it replaced an older certificate). If it fails, `jarl cert get` exits without `--combined`), `JARL_CERT_DOMAINS`, `JARL_CERT_SERIAL`,
non-zero and runs it again next time. It has 5 minutes. `JARL_CERT_NOT_AFTER` and `JARL_CERT_RENEWED` (1 when it replaced an
older certificate). If it fails, `jarl cert get` exits non-zero and runs
it again next time. It has 5 minutes.
* **`jarl cert schedule`** (Linux with systemd, as root) writes * **`jarl cert schedule`** (Linux with systemd, as root) writes
`/etc/systemd/system/jarl-cert-get.service` and `.timer`, which run `/etc/systemd/system/jarl-cert-get.service` and `.timer`, which run
`jarl cert get` daily at a random time within the hour after midnight, `jarl cert get` daily at a random time within the hour after midnight,
@@ -314,7 +331,44 @@ jarl cert saved # what this host has, and when it exp
**On the Certificates page**, the administrators see each certificate's **On the Certificates page**, the administrators see each certificate's
owner and who downloaded it: the token, from where, when, and whether owner and who downloaded it: the token, from where, when, and whether
that host has the current certificate or hasn't fetched the renewed one that host has the current certificate or hasn't fetched the renewed one
yet. `jarl cert show NAME` says the same. yet. `jarl cert show NAME` says the same. The owner's own tokens see
their own downloads there too. In `jarl cert list`, such a certificate's
USES is `get`, and PULLED is the last download, with `(old)` while a host
hasn't fetched the renewed one.
### HAProxy
HAProxy wants the key and the chain in one file. Give `jarl cert get` a
`--combined` path outside `--dir`:
```sh
jarl cert get proxy -d adbraun.lol -d '*.adbraun.lol' \
--combined /etc/haproxy/certs/adbraun.lol.pem --hook 'systemctl reload haproxy'
jarl cert get webmail -d webmail.adbraun.lol \
--combined /etc/haproxy/certs/webmail.pem --hook 'systemctl reload haproxy'
```
```
frontend https
bind :443 ssl crt /etc/haproxy/certs/
```
HAProxy loads every file in a `crt` directory and picks one by SNI, so
`/etc/haproxy/certs` should hold only combined files. Don't point `crt`
at `--dir` (`/etc/jarl/certs/NAME`): HAProxy would try to load
`cert.pem`, `privkey.pem` and `jarl-cert.json` there too, and refuse
to start. A `--combined` path that would overwrite a file in a saved
certificate's directory, or another certificate's combined file, is
refused.
The combined file is mode 600, owned by the user running `jarl cert get`
(root under the timer). HAProxy reads its certificates as root before it
drops privileges, so that's fine.
To add a combined file to a certificate this host already has, run
`jarl cert get proxy --combined /etc/haproxy/certs/adbraun.lol.pem`: it's
written from the files on disk and the hook runs, with nothing
downloaded.
## Security notes ## Security notes
+4
View File
@@ -452,7 +452,10 @@ jarl cert export mail --part bundle # one part to stdout: fullc
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 get proxy -s other --replace # take over a name saved here for another server
jarl cert get proxy --combined /etc/haproxy/certs/proxy.pem # also key + chain in one file (HAProxy); '' stops it
jarl cert get proxy --hook 'systemctl reload haproxy' --local # change a setting; apply it from this host's files, without jarld
jarl cert saved # this host's certificates and their expiry jarl cert saved # this host's certificates and their expiry
jarl cert saved proxy # all of one certificate's settings (never the token)
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
jarl cert delete ns1 jarl cert delete ns1
@@ -537,6 +540,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
+17
View File
@@ -360,6 +360,23 @@ cp /etc/bind/named.conf.pre-jarl-<date> /etc/bind/named.conf
rndc reconfig rndc reconfig
``` ```
## Failed logins in the journal
A failed login to the web UI or the API (a wrong password, or a wrong
two-factor code), and a refused API token, are written to the journal as
well as the audit log, with the address first:
```
level=WARN msg="login failed" ip=203.0.113.9 user=admin reason="wrong username or password"
level=WARN msg="token refused" ip=203.0.113.9 reason="wrong token secret"
```
Guard's `jarl` preset, or a fail2ban filter, can ban such addresses at
the firewall (with Housecarl's ban lists). The address comes before the
user name, which a caller chooses, so a pattern anchored on
`msg="login failed" ip=` can't be fooled by a name. With `log.format:
json` they are JSON objects with the same fields in the same order.
## Monitoring ## Monitoring
* `GET /healthz` (no authentication) reports that jarld is up. * `GET /healthz` (no authentication) reports that jarld is up.
+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}},
} }
+23
View File
@@ -0,0 +1,23 @@
package server
import (
"bytes"
"log/slog"
"strings"
"testing"
)
// TestFailedLoginLogged: a failed login and a refused token are logged
// with the address first, for Guard's and fail2ban's patterns.
func TestFailedLoginLogged(t *testing.T) {
e := newAPIEnv(t)
var buf bytes.Buffer
e.srv.log = slog.New(slog.NewTextHandler(&buf, nil))
e.req("POST", "/auth/login", "", map[string]string{"username": "x ip=8.8.8.8", "password": "wrong"})
e.req("GET", "/zones", "jarl_aaaaaaaaaaaaaaaa_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", nil)
for _, want := range []string{`msg="login failed" ip=127.0.0.1 user="x ip=8.8.8.8"`, `msg="token refused" ip=127.0.0.1 reason=`} {
if !strings.Contains(buf.String(), want) {
t.Errorf("no %q in\n%s", want, buf.String())
}
}
}
+4
View File
@@ -136,6 +136,8 @@ func (s *Server) authenticate(r *http.Request) (*auth.Principal, *model.Session,
p, err := s.tokenPrincipal(r.Context(), strings.TrimSpace(tok), ip, r.UserAgent()) p, err := s.tokenPrincipal(r.Context(), strings.TrimSpace(tok), ip, r.UserAgent())
if err != nil { if err != nil {
s.limiter.fail("ip:" + ip) s.limiter.fail("ip:" + ip)
// For Guard and fail2ban: the address first, so a name can't fake it.
s.log.Warn("token refused", "ip", ip, "reason", err.Error())
s.eng.Audit(r.Context(), &auth.Principal{IP: ip, Agent: r.UserAgent(), Method: "token"}, "auth.token_failed", "", 0, "", "failure", err.Error()) s.eng.Audit(r.Context(), &auth.Principal{IP: ip, Agent: r.UserAgent(), Method: "token"}, "auth.token_failed", "", 0, "", "failure", err.Error())
return nil, nil, &httpError{http.StatusUnauthorized, "unauthenticated", "invalid API token: " + err.Error()} return nil, nil, &httpError{http.StatusUnauthorized, "unauthenticated", "invalid API token: " + err.Error()}
} }
@@ -328,6 +330,8 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) error {
if err == nil && u.Disabled && ok { if err == nil && u.Disabled && ok {
reason = "account disabled" reason = "account disabled"
} }
// For Guard and fail2ban: the address first, so a name can't fake it.
s.log.Warn("login failed", "ip", ip, "user", name, "reason", reason)
s.eng.Audit(r.Context(), &auth.Principal{User: &model.User{Username: name}, IP: ip, Agent: r.UserAgent(), Method: "session"}, "auth.login_failed", "user", 0, name, "failure", reason) s.eng.Audit(r.Context(), &auth.Principal{User: &model.User{Username: name}, IP: ip, Agent: r.UserAgent(), Method: "session"}, "auth.login_failed", "user", 0, name, "failure", reason)
return &httpError{http.StatusUnauthorized, "login_failed", "wrong username or password"} return &httpError{http.StatusUnauthorized, "login_failed", "wrong username or password"}
} }
+2
View File
@@ -198,6 +198,8 @@ func (s *Server) httpreqPrincipal(w http.ResponseWriter, r *http.Request) (*auth
} }
fail := func(reason string) error { fail := func(reason string) error {
s.limiter.fail("ip:" + ip) s.limiter.fail("ip:" + ip)
// For Guard and fail2ban: the address first, so a name can't fake it.
s.log.Warn("token refused", "ip", ip, "reason", "httpreq: "+reason)
s.eng.Audit(r.Context(), &auth.Principal{User: &model.User{Username: user}, IP: ip, Agent: r.UserAgent(), Method: "token"}, "auth.token_failed", "", 0, "", "failure", "httpreq: "+reason) s.eng.Audit(r.Context(), &auth.Principal{User: &model.User{Username: user}, IP: ip, Agent: r.UserAgent(), Method: "token"}, "auth.token_failed", "", 0, "", "failure", "httpreq: "+reason)
w.Header().Set("WWW-Authenticate", httpreqRealm) w.Header().Set("WWW-Authenticate", httpreqRealm)
return &httpError{http.StatusUnauthorized, "unauthenticated", "invalid credentials: " + reason} return &httpError{http.StatusUnauthorized, "unauthenticated", "invalid credentials: " + reason}
+26 -12
View File
@@ -33,7 +33,8 @@ type certView struct {
// IssuingSince is set while Jarl is obtaining it. // IssuingSince is set while Jarl is obtaining it.
IssuingSince *time.Time `json:"issuing_since,omitempty"` IssuingSince *time.Time `json:"issuing_since,omitempty"`
// Owner is the user a certificate for another host belongs to; PulledBy // Owner is the user a certificate for another host belongs to; PulledBy
// who downloaded it (list and get, administrators only). // who downloaded it (list and get; an owner who isn't an administrator
// sees only their own downloads).
Owner string `json:"owner,omitempty"` Owner string `json:"owner,omitempty"`
PulledBy []model.CertificatePull `json:"pulled_by,omitempty"` PulledBy []model.CertificatePull `json:"pulled_by,omitempty"`
} }
@@ -116,12 +117,27 @@ func (s *Server) certFor(r *http.Request, p *auth.Principal, write bool) (*model
return c, nil return c, nil
} }
// withOwners adds owners and downloads to views (for administrators). // withOwners adds owners and downloads to views: all of them for an
func (s *Server) withOwners(ctx context.Context, views []certView) error { // administrator, the principal's own downloads for anyone else (who sees
// only their own certificates).
func (s *Server) withOwners(ctx context.Context, p *auth.Principal, views []certView) error {
pulls, err := s.store.CertificatePulls(ctx) pulls, err := s.store.CertificatePulls(ctx)
if err != nil { if err != nil {
return err return err
} }
if !p.IsAdmin() {
for i := range views {
for _, pl := range pulls[views[i].ID] {
if p.User != nil && pl.UserID == p.User.ID {
views[i].PulledBy = append(views[i].PulledBy, pl)
}
}
if p.User != nil && views[i].OwnerID != 0 {
views[i].Owner = p.User.Username
}
}
return nil
}
users, err := s.store.ListUsers(ctx) users, err := s.store.ListUsers(ctx)
if err != nil { if err != nil {
return err return err
@@ -179,13 +195,13 @@ func (s *Server) certList(w http.ResponseWriter, r *http.Request, p *auth.Princi
v.NamedUse = encdns.CertUses(st, s.cfg.Certs.FilesDir(c.Name)) v.NamedUse = encdns.CertUses(st, s.cfg.Certs.FilesDir(c.Name))
out = append(out, v) out = append(out, v)
} }
if err := s.withOwners(r.Context(), p, out); err != nil {
return err
}
if !p.IsAdmin() { if !p.IsAdmin() {
writeJSON(w, http.StatusOK, map[string]any{"certificates": out}) writeJSON(w, http.StatusOK, map[string]any{"certificates": out})
return nil return nil
} }
if err := s.withOwners(r.Context(), out); err != nil {
return err
}
writeJSON(w, http.StatusOK, map[string]any{"certificates": out, "account": s.cfg.Certs.AccountInfo(r.Context())}) writeJSON(w, http.StatusOK, map[string]any{"certificates": out, "account": s.cfg.Certs.AccountInfo(r.Context())})
return nil return nil
} }
@@ -205,13 +221,11 @@ func (s *Server) certGet(w http.ResponseWriter, r *http.Request, p *auth.Princip
} }
v := s.presentCert(c) v := s.presentCert(c)
v.NamedUse = encdns.CertUses(st, s.cfg.Certs.FilesDir(c.Name)) v.NamedUse = encdns.CertUses(st, s.cfg.Certs.FilesDir(c.Name))
if p.IsAdmin() { vs := []certView{v}
vs := []certView{v} if err := s.withOwners(r.Context(), p, vs); err != nil {
if err := s.withOwners(r.Context(), vs); err != nil { return err
return err
}
v = vs[0]
} }
v = vs[0]
writeJSON(w, http.StatusOK, v) writeJSON(w, http.StatusOK, v)
return nil return nil
} }
+2
View File
@@ -206,6 +206,8 @@ func (s *Server) loginTOTP(w http.ResponseWriter, r *http.Request) error {
if err != nil { if err != nil {
s.limiter.fail("ip:"+ip, userKey) s.limiter.fail("ip:"+ip, userKey)
left := s.pendingLogins.fail(in.Challenge) left := s.pendingLogins.fail(in.Challenge)
// For Guard and fail2ban: the address first, so a name can't fake it.
s.log.Warn("login failed", "ip", ip, "user", u.Username, "reason", "wrong code: "+err.Error())
s.eng.Audit(r.Context(), who, "auth.totp_failed", "user", u.ID, u.Username, "failure", err.Error()) s.eng.Audit(r.Context(), who, "auth.totp_failed", "user", u.ID, u.Username, "failure", err.Error())
msg := "wrong code: use the current code from your authenticator app, or a recovery code" msg := "wrong code: use the current code from your authenticator app, or a recovery code"
if left == 0 { if left == 0 {
+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
+22
View File
@@ -939,6 +939,23 @@ if [ -n "$PEBBLE" ]; then
if [ "$(wc -l < $CGD/hook.log)" = 1 ]; then ok "the hook didn't run again"; else bad "hook ran again: $(cat $CGD/hook.log)"; fi if [ "$(wc -l < $CGD/hook.log)" = 1 ]; then ok "the hook didn't run again"; else bad "hook ran again: $(cat $CGD/hook.log)"; fi
run "cert saved" 0 $J cert saved --config-dir $CGD/conf run "cert saved" 0 $J cert saved --config-dir $CGD/conf
contains "mail *mail.adbraun.test .*$CGD/mail" contains "mail *mail.adbraun.test .*$CGD/mail"
run "the owner's cert list: for jarl cert get, downloaded today" 0 $J $CG cert list
contains "^mail *acme *get *mail.adbraun.test *[0-9-]* *$(date +%Y-%m-%d) *ok"
run "cert get --combined: written from this host's files" 0 $J cert get mail --combined $CGD/haproxy/mail.pem --config-dir $CGD/conf
contains "mail .*combined file written, combined $CGD/haproxy/mail.pem"
run "the combined file is mode 600" 0 stat -c %a $CGD/haproxy/mail.pem
contains "^600$"
run "it holds the key" 0 sh -c "openssl pkey -in $CGD/haproxy/mail.pem -pubout | cmp - $CGD/pub.pem"
run "and the certificate" 0 sh -c "openssl x509 -in $CGD/haproxy/mail.pem -noout -serial | cmp - \$(openssl x509 -in $CGD/mail/cert.pem -noout -serial > $CGD/serial; echo $CGD/serial)"
if [ "$(wc -l < $CGD/hook.log)" = 2 ]; then ok "the hook ran for the combined file"; else bad "hook runs: $(cat $CGD/hook.log)"; fi
run "cert saved NAME shows every setting" 0 $J cert saved mail --config-dir $CGD/conf
contains "^Combined: *$CGD/haproxy/mail.pem$"
contains "^Hook: *echo"
run "a combined path inside the files' directory is refused" 1 $J cert get mail --combined $CGD/mail/fullchain.pem --config-dir $CGD/conf
contains "would overwrite mail's fullchain.pem"
run "cert get --local: the hook runs from this host's files" 0 $J cert get mail --local --config-dir $CGD/conf
contains "mail .*this host's files"
if [ "$(wc -l < $CGD/hook.log)" = 3 ]; then ok "the hook ran with --local"; else bad "hook runs: $(cat $CGD/hook.log)"; fi
run "another user can't take the name (exit 5)" 5 $J --token @$E/hosts2.token cert get mail -d mail.adbraun.test --dir $CGD/x --no-save --config-dir $CGD/conf2 run "another user can't take the name (exit 5)" 5 $J --token @$E/hosts2.token cert get mail -d mail.adbraun.test --dir $CGD/x --no-save --config-dir $CGD/conf2
contains "taken" contains "taken"
run "an ACME token can't get certificates (exit 3)" 3 $J --token @$E/nocert.token cert get x -d x.adbraun.test --dir $CGD/x --no-save --config-dir $CGD/conf2 run "an ACME token can't get certificates (exit 3)" 3 $J --token @$E/nocert.token cert get x -d x.adbraun.test --dir $CGD/x --no-save --config-dir $CGD/conf2
@@ -951,6 +968,11 @@ if [ -n "$PEBBLE" ]; then
contains "mail .*new files" contains "mail .*new files"
run "the hook ran for the renewal" 0 tail -1 $CGD/hook.log run "the hook ran for the renewal" 0 tail -1 $CGD/hook.log
contains "^mail 1 privkey.pem$" contains "^mail 1 privkey.pem$"
run "the combined file has the renewed certificate" 0 sh -c "openssl x509 -in $CGD/haproxy/mail.pem -noout -serial | cmp - \$(openssl x509 -in $CGD/mail/cert.pem -noout -serial > $CGD/serial; echo $CGD/serial)"
run "the owner sees the download is current" 0 $J $CG cert list
if echo "$OUT" | grep -q "(old)"; then bad "owner's list says (old)"; else ok "no (old) after the download"; fi
run "cert get --combined '': the combined file is removed" 0 $J cert get mail --combined '' --config-dir $CGD/conf
if [ ! -e $CGD/haproxy/mail.pem ]; then ok "combined file removed"; else bad "combined file still there"; fi
run "a name added: obtained again" 0 $J cert get mail -d mail.adbraun.test -d smtp.adbraun.test --config-dir $CGD/conf run "a name added: obtained again" 0 $J cert get mail -d mail.adbraun.test -d smtp.adbraun.test --config-dir $CGD/conf
run "the new certificate has both names" 0 openssl x509 -in $CGD/mail/cert.pem -noout -ext subjectAltName run "the new certificate has both names" 0 openssl x509 -in $CGD/mail/cert.pem -noout -ext subjectAltName
contains "smtp.adbraun.test" contains "smtp.adbraun.test"