Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67a829be34 | ||
|
|
42e79af678 | ||
|
|
3fb159cf4d | ||
|
|
71ebed2b73 | ||
|
|
95125e7f50 | ||
|
|
c10ebe0d38 | ||
|
|
3d1911dd38 | ||
|
|
310ed8061e | ||
|
|
b367e0c830 | ||
|
|
254867c61d | ||
|
|
3ddd32f97f | ||
|
|
0976faba3f | ||
|
|
1d535c8063 | ||
|
|
7f3564c0a5 |
No files matched your search
@@ -8,6 +8,63 @@ All notable changes to Jarl are documented here. The format follows
|
|||||||
|
|
||||||
### Added
|
### 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
|
||||||
|
|
||||||
|
### 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
|
||||||
--hook CMD` on a mail server or proxy has jarld obtain the certificate
|
--hook CMD` on a mail server or proxy has jarld obtain the certificate
|
||||||
(and renew it), waits, downloads it when it is newer than the host's
|
(and renew it), waits, downloads it when it is newer than the host's
|
||||||
@@ -42,6 +99,14 @@ All notable changes to Jarl are documented here. The format follows
|
|||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
- The Statistics page's range buttons follow `stats.retention`: ranges
|
||||||
|
longer than any data kept are left out, and **1y** and **2y** appear
|
||||||
|
when `retention.day` (or `retention.hour`) covers them (with the
|
||||||
|
default 730d, both do). The busiest-times heatmap says how far back it
|
||||||
|
really goes (at most `retention.hour`), and links to the Explore page
|
||||||
|
keep within `retention.queries` instead of assuming 7 days.
|
||||||
|
`GET /stats/status` and `GET /stats/explore` add `retention_seconds`.
|
||||||
|
|
||||||
- A reverse zone's missing PTR records count only its own view's A and
|
- A reverse zone's missing PTR records count only its own view's A and
|
||||||
AAAA records. An address only another view's records use (an internal
|
AAAA records. An address only another view's records use (an internal
|
||||||
host, seen from the vps view's copy of `10.0.10.in-addr.arpa`) is no
|
host, seen from the vps view's copy of `10.0.10.in-addr.arpa`) is no
|
||||||
@@ -97,6 +162,21 @@ 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,
|
||||||
|
`stats.query_retention`; it's `stats.retention.queries`.
|
||||||
|
- `jarl zone show` for a reverse zone printed only "and 1 more" under
|
||||||
|
"Other views:" when the server was older than the CLI (0.6.1-rc.2
|
||||||
|
sends the count but not the addresses). It now prints just the count
|
||||||
|
line then.
|
||||||
|
- `jarl changeset show` printed its Created and Applied times in UTC,
|
||||||
|
while `jarl history` and `jarl status` print local time. It uses local
|
||||||
|
time now too.
|
||||||
|
|
||||||
- The statistics' "Busiest times" grid drew hours without data taller
|
- The statistics' "Busiest times" grid drew hours without data taller
|
||||||
than the others (they picked up the page's padding for empty
|
than the others (they picked up the page's padding for empty
|
||||||
messages).
|
messages).
|
||||||
|
|||||||
@@ -202,9 +202,13 @@ review step.
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Version 0.6.0 is the current release, and 0.6.1 is in testing
|
Version 0.6.3 is the current release; 0.6.4 is in testing (0.6.4-rc.2):
|
||||||
(0.6.1-rc.3): it gets certificates onto your other hosts (`jarl cert
|
failed logins and refused tokens are logged, address first, for Guard
|
||||||
get`: Jarl obtains and renews them, a host downloads them and runs a
|
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
|
||||||
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.
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
+292
-38
@@ -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.
|
||||||
|
|
||||||
@@ -36,6 +37,9 @@ type savedCert struct {
|
|||||||
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"`
|
||||||
|
// Combined is a file with the key and the full chain together, for
|
||||||
|
// HAProxy (--combined); kept outside Dir.
|
||||||
|
Combined string `json:"combined,omitempty"`
|
||||||
Conn connection `json:"connection"`
|
Conn connection `json:"connection"`
|
||||||
SavedAt time.Time `json:"saved_at"`
|
SavedAt time.Time `json:"saved_at"`
|
||||||
}
|
}
|
||||||
@@ -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"`
|
||||||
@@ -114,6 +121,73 @@ 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 err := checkCombined(saved, i, sc); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
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 {
|
||||||
@@ -165,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
|
||||||
|
// 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"`
|
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)
|
||||||
@@ -215,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 {
|
||||||
@@ -249,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 {
|
||||||
@@ -313,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)
|
||||||
@@ -328,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
|
||||||
@@ -339,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 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]",
|
||||||
@@ -348,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
|
||||||
@@ -357,22 +506,40 @@ 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.
|
||||||
|
|
||||||
--hook runs with sh after new files are written (and again on the next
|
A name saved here for another Jarl server isn't taken over, nor a --dir
|
||||||
run if it failed), with JARL_CERT_NAME, JARL_CERT_DIR, JARL_CERT_CERT,
|
holding another certificate's files, unless --replace is given; a --dir
|
||||||
JARL_CERT_CHAIN, JARL_CERT_FULLCHAIN, JARL_CERT_KEY, JARL_CERT_DOMAINS,
|
another saved certificate uses is always refused.
|
||||||
JARL_CERT_SERIAL, JARL_CERT_NOT_AFTER and JARL_CERT_RENEWED (1 when it
|
|
||||||
replaced an older certificate).`,
|
--hook runs with sh after new files are written or the combined file
|
||||||
|
changes (and again on the next run if it failed), with JARL_CERT_NAME,
|
||||||
|
JARL_CERT_DIR, JARL_CERT_CERT, JARL_CERT_CHAIN, JARL_CERT_FULLCHAIN,
|
||||||
|
JARL_CERT_KEY, JARL_CERT_COMBINED (empty without --combined),
|
||||||
|
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),
|
||||||
@@ -384,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 {
|
||||||
@@ -392,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]
|
||||||
@@ -404,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 == "" {
|
||||||
@@ -415,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
|
||||||
}
|
}
|
||||||
@@ -427,20 +615,28 @@ 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
|
||||||
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()
|
|
||||||
if err == nil {
|
|
||||||
var r *getResult
|
var r *getResult
|
||||||
|
var err error
|
||||||
|
if local {
|
||||||
|
r, err = localCert(sc)
|
||||||
|
} else {
|
||||||
|
var c *client.Client
|
||||||
|
if c, err = sc.Conn.open(); err == nil {
|
||||||
r, err = fetchCert(c, sc, force, wait)
|
r, err = fetchCert(c, sc, force, wait)
|
||||||
|
}
|
||||||
|
}
|
||||||
if r != nil {
|
if r != nil {
|
||||||
results = append(results, r)
|
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)
|
||||||
@@ -459,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))
|
||||||
}
|
}
|
||||||
@@ -469,7 +673,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, ", "))
|
||||||
}
|
}
|
||||||
@@ -479,10 +683,13 @@ 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")
|
||||||
|
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))
|
||||||
@@ -499,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)
|
||||||
@@ -514,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 {
|
||||||
@@ -534,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()
|
||||||
})
|
})
|
||||||
@@ -544,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
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/pem"
|
||||||
|
"math/big"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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
@@ -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 "-"
|
||||||
|
|||||||
+2
-2
@@ -272,9 +272,9 @@ func changesetCmd() *cobra.Command {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return output(cs, func(w io.Writer) {
|
return output(cs, func(w io.Writer) {
|
||||||
fmt.Fprintf(w, "Changeset:\t%d (%s)\nDescription:\t%s\nCreated:\t%s by %s via %s\n", cs.ID, cs.Status, cs.Description, cs.CreatedAt.Format("2006-01-02 15:04"), cs.Username, cs.Via)
|
fmt.Fprintf(w, "Changeset:\t%d (%s)\nDescription:\t%s\nCreated:\t%s by %s via %s\n", cs.ID, cs.Status, cs.Description, cs.CreatedAt.Local().Format("2006-01-02 15:04"), cs.Username, cs.Via)
|
||||||
if cs.AppliedAt != nil {
|
if cs.AppliedAt != nil {
|
||||||
fmt.Fprintf(w, "Applied:\t%s by %s\n", cs.AppliedAt.Format("2006-01-02 15:04"), cs.AppliedBy)
|
fmt.Fprintf(w, "Applied:\t%s by %s\n", cs.AppliedAt.Local().Format("2006-01-02 15:04"), cs.AppliedBy)
|
||||||
}
|
}
|
||||||
fmt.Fprintln(w, "\n#\tOPERATION")
|
fmt.Fprintln(w, "\n#\tOPERATION")
|
||||||
for _, op := range cs.Ops {
|
for _, op := range cs.Ops {
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"jarl/internal/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPrintPTRFills(t *testing.T) {
|
||||||
|
var b bytes.Buffer
|
||||||
|
printPTRFills(&b, []api.PTRFill{{Address: "10.0.10.20", Target: "nextcloud.adbraun.lol.", From: "internal"}}, 3)
|
||||||
|
out := b.String()
|
||||||
|
if !strings.Contains(out, "10.0.10.20 -> nextcloud.adbraun.lol (view internal)") || !strings.Contains(out, "and 2 more") {
|
||||||
|
t.Errorf("listed fills:\n%s", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An older server sends the count without the list.
|
||||||
|
b.Reset()
|
||||||
|
printPTRFills(&b, nil, 1)
|
||||||
|
if b.Len() != 0 {
|
||||||
|
t.Errorf("no list from the server printed %q, want nothing", b.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-1
@@ -1183,8 +1183,13 @@ func terminalWidth() int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// printPTRFills lists PTR records a reverse zone lacks under zone show's
|
// printPTRFills lists PTR records a reverse zone lacks under zone show's
|
||||||
// line about them: address -> name, and the view the name is from.
|
// line about them: address -> name, and the view the name is from. A server
|
||||||
|
// older than the CLI may send the count without the list; then nothing is
|
||||||
|
// listed, rather than "and N more" under no addresses.
|
||||||
func printPTRFills(w io.Writer, fills []api.PTRFill, total int) {
|
func printPTRFills(w io.Writer, fills []api.PTRFill, total int) {
|
||||||
|
if len(fills) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
for _, f := range fills {
|
for _, f := range fills {
|
||||||
from := ""
|
from := ""
|
||||||
if f.From != "" {
|
if f.From != "" {
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
+3
-3
@@ -579,12 +579,12 @@ Needs `stats:read`. When statistics are disabled, every endpoint but
|
|||||||
|
|
||||||
| Method | Path | Query | Returns |
|
| Method | Path | Query | Returns |
|
||||||
|--------|------|-------|---------|
|
|--------|------|-------|---------|
|
||||||
| GET | `/stats/status` | | `{enabled, query_log, statistics_channel, log_file, last_poll, last_log_line, log_lines_read, first_data, retention, warnings}` |
|
| GET | `/stats/status` | | `{enabled, query_log, statistics_channel, log_file, last_poll, last_log_line, log_lines_read, first_data, retention, retention_seconds, warnings}`: `retention` has `minute`, `hour`, `day`, `queries` as `"7d"`, `retention_seconds` the same in seconds (`queries` 0 without a query log) |
|
||||||
| GET | `/stats/summary` | `since` (90m, 24h, 7d, 2w; default 24h), `top` (default 10) | totals, `avg_qps`, `peak`, `rcodes`, `qtypes`, `views`, `zones`, `answers`, `transport`, `cache_hit_ratio`, `nxdomain_ratio` (without blocked answers), `blocked`, `blocked_ratio`, `blocklists` (blocked per list; `rules` for your rules), `tops` |
|
| GET | `/stats/summary` | `since` (90m, 24h, 7d, 2w; default 24h), `top` (default 10) | totals, `avg_qps`, `peak`, `rcodes`, `qtypes`, `views`, `zones`, `answers`, `transport`, `cache_hit_ratio`, `nxdomain_ratio` (without blocked answers), `blocked`, `blocked_ratio`, `blocklists` (blocked per list; `rules` for your rules), `tops` |
|
||||||
| GET | `/stats/timeline` | `since`, `step` (auto, 1m, 5m, 15m, 1h, 6h, 1d), `key` (repeatable or comma separated; `rcode:*` = every counter of a group; default `queries`) | `{step, times: [unix...], series: [{key, values}]}` |
|
| GET | `/stats/timeline` | `since`, `step` (auto, 1m, 5m, 15m, 1h, 6h, 1d), `key` (repeatable or comma separated; `rcode:*` = every counter of a group; default `queries`) | `{step, times: [unix...], series: [{key, values}]}` |
|
||||||
| GET | `/stats/top` | `kind` (name, client, nxdomain, servfail, refused, blocked, blocked_client), `since`, `limit` (default 20) | `[{key, count}]`; client, refused and blocked_client entries add `name`, the host name of Jarl's record for the address (the summary's client list too) |
|
| GET | `/stats/top` | `kind` (name, client, nxdomain, servfail, refused, blocked, blocked_client), `since`, `limit` (default 20) | `[{key, count}]`; client, refused and blocked_client entries add `name`, the host name of Jarl's record for the address (the summary's client list too) |
|
||||||
| GET | `/stats/heatmap` | `since` (default 28d), `key` (default queries) | `{key, from, avg[7][24], samples[7][24]}`, Monday first, local time, `-1` = no data |
|
| GET | `/stats/heatmap` | `since` (default 28d), `key` (default queries) | `{key, from, avg[7][24], samples[7][24]}`, Monday first, local time, `-1` = no data |
|
||||||
| GET | `/stats/explore` | `?client=&name=&domain=&view=&type=&rcode=&blocked=yes\|no\|LIST&since=24h&views=`; each takes several values, comma separated (any of them), `!value` to leave one out, and names `*.example.com` for it and below | The query log for a filter ([stats.md](stats.md#exploring)): `total`, `blocked`, `series` (per `step` seconds: `t`, `count`, `blocked`), `by` (top 15 of each dimension the filter leaves open: client, name, view, type, rcode, blocked; clients with `name`), `recent` (latest 100: ts, client, client_name, view, name, type, rcode, blocked), `oldest`, `retention`, `client_name`. 404 `query_log_off` without a query log |
|
| GET | `/stats/explore` | `?client=&name=&domain=&view=&type=&rcode=&blocked=yes\|no\|LIST&since=24h&views=`; each takes several values, comma separated (any of them), `!value` to leave one out, and names `*.example.com` for it and below | The query log for a filter ([stats.md](stats.md#exploring)): `total`, `blocked`, `series` (per `step` seconds: `t`, `count`, `blocked`), `by` (top 15 of each dimension the filter leaves open: client, name, view, type, rcode, blocked; clients with `name`), `recent` (latest 100: ts, client, client_name, view, name, type, rcode, blocked), `oldest`, `retention` (`"7d"`) and `retention_seconds`, `client_name`. 404 `query_log_off` without a query log |
|
||||||
| GET | `/stats/server` | | `{server: {rss, mem_in_use, cpu_percent, threads, pid, cache_rrsets, cache_mem, sockets_udp, sockets_tcp, recursion_highwater, tcp_highwater, version, boot_time, config_time, host: {mem_total, mem_available, load, cpus, uptime}}, named: {recursive_clients, tcp_clients, zones, cpus, worker_threads}, disks: [{path, holds, total, free}], database: {path, bytes, query_log_rows}}`; gauges over time: `/stats/timeline?key=g:named:rss` (averages, -1 without a sample) |
|
| GET | `/stats/server` | | `{server: {rss, mem_in_use, cpu_percent, threads, pid, cache_rrsets, cache_mem, sockets_udp, sockets_tcp, recursion_highwater, tcp_highwater, version, boot_time, config_time, host: {mem_total, mem_available, load, cpus, uptime}}, named: {recursive_clients, tcp_clients, zones, cpus, worker_threads}, disks: [{path, holds, total, free}], database: {path, bytes, query_log_rows}}`; gauges over time: `/stats/timeline?key=g:named:rss` (averages, -1 without a sample) |
|
||||||
| GET | `/stats/breakdown` | `group` (qtype, rcode, view, zone, answer, transport, rtt, dnssec, cache, ratelimit, blocklist), `since` | `[{key, count}]` |
|
| GET | `/stats/breakdown` | `group` (qtype, rcode, view, zone, answer, transport, rtt, dnssec, cache, ratelimit, blocklist), `since` | `[{key, count}]` |
|
||||||
|
|
||||||
@@ -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}` | |
|
||||||
|
|||||||
+74
-13
@@ -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,18 +287,41 @@ 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
|
||||||
* **The hook** runs with `sh` after new files are written, with
|
token given as `@FILE` is saved as that reference, not copied. `jarl
|
||||||
`JARL_CERT_NAME`, `JARL_CERT_DIR`, `JARL_CERT_CERT`, `JARL_CERT_CHAIN`,
|
cert saved NAME` shows them all (the token never). `jarl cert forget
|
||||||
`JARL_CERT_FULLCHAIN`, `JARL_CERT_KEY`, `JARL_CERT_DOMAINS`,
|
NAME` stops getting one (`--delete` deletes it on jarld too).
|
||||||
`JARL_CERT_SERIAL`, `JARL_CERT_NOT_AFTER` and `JARL_CERT_RENEWED` (1 when
|
* **Changing a setting**: run `jarl cert get NAME` with just that option
|
||||||
it replaced an older certificate). If it fails, `jarl cert get` exits
|
(`--hook '...'`, `--combined PATH`, `--dir DIR`); the others stay.
|
||||||
non-zero and runs it again next time. It has 5 minutes.
|
`--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
|
||||||
|
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 or the
|
||||||
|
combined file changes (and with `--local`), with `JARL_CERT_NAME`,
|
||||||
|
`JARL_CERT_DIR`, `JARL_CERT_CERT`, `JARL_CERT_CHAIN`,
|
||||||
|
`JARL_CERT_FULLCHAIN`, `JARL_CERT_KEY`, `JARL_CERT_COMBINED` (empty
|
||||||
|
without `--combined`), `JARL_CERT_DOMAINS`, `JARL_CERT_SERIAL`,
|
||||||
|
`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,
|
||||||
@@ -307,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
|
||||||
|
|
||||||
|
|||||||
@@ -451,7 +451,11 @@ 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 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
|
||||||
@@ -536,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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
+5
-1
@@ -263,7 +263,11 @@ database, roughly in proportion to the number of views.
|
|||||||
lists for `retention.hour`, daily ones for `retention.day`. Older data is
|
lists for `retention.hour`, daily ones for `retention.day`. Older data is
|
||||||
deleted every hour.
|
deleted every hour.
|
||||||
- Charts use the finest resolution that covers the range: minutes up to 48
|
- Charts use the finest resolution that covers the range: minutes up to 48
|
||||||
hours, hours up to 90 days, days beyond.
|
hours (and `retention.minute`), hours up to `retention.hour`, days beyond.
|
||||||
|
- The Statistics page offers only the ranges the data is kept for: up to 90
|
||||||
|
days with the defaults, plus a year and 2 years when `retention.day` (or
|
||||||
|
`retention.hour`) is that long. The Explore page offers the ranges up to
|
||||||
|
`retention.queries`.
|
||||||
- Top lists are exact within an hour (up to 200,000 distinct names or
|
- Top lists are exact within an hour (up to 200,000 distinct names or
|
||||||
clients per hour, beyond which the rarest are dropped). Daily lists are
|
clients per hour, beyond which the rarest are dropped). Daily lists are
|
||||||
built from the hourly top lists, so for very long tails they are
|
built from the hourly top lists, so for very long tails they are
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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}},
|
||||||
}
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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(), vs); err != nil {
|
if err := s.withOwners(r.Context(), p, 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -95,6 +95,13 @@ type Status struct {
|
|||||||
Day string `json:"day"`
|
Day string `json:"day"`
|
||||||
Queries string `json:"queries"` // the query log ("" when off)
|
Queries string `json:"queries"` // the query log ("" when off)
|
||||||
} `json:"retention"`
|
} `json:"retention"`
|
||||||
|
// RetentionSeconds is Retention in seconds, for the web UI's ranges.
|
||||||
|
RetentionSeconds struct {
|
||||||
|
Minute int64 `json:"minute"`
|
||||||
|
Hour int64 `json:"hour"`
|
||||||
|
Day int64 `json:"day"`
|
||||||
|
Queries int64 `json:"queries"` // 0 when the query log is off
|
||||||
|
} `json:"retention_seconds"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collector gathers statistics into a Store.
|
// Collector gathers statistics into a Store.
|
||||||
@@ -164,8 +171,12 @@ func NewCollector(store *Store, opts Options, zones ZoneIndex, log *slog.Logger)
|
|||||||
c.status.Retention.Minute = humanDuration(opts.RetainMinute)
|
c.status.Retention.Minute = humanDuration(opts.RetainMinute)
|
||||||
c.status.Retention.Hour = humanDuration(opts.RetainHour)
|
c.status.Retention.Hour = humanDuration(opts.RetainHour)
|
||||||
c.status.Retention.Day = humanDuration(opts.RetainDay)
|
c.status.Retention.Day = humanDuration(opts.RetainDay)
|
||||||
|
c.status.RetentionSeconds.Minute = int64(opts.RetainMinute / time.Second)
|
||||||
|
c.status.RetentionSeconds.Hour = int64(opts.RetainHour / time.Second)
|
||||||
|
c.status.RetentionSeconds.Day = int64(opts.RetainDay / time.Second)
|
||||||
if opts.QueryLog && opts.RetainQueries > 0 {
|
if opts.QueryLog && opts.RetainQueries > 0 {
|
||||||
c.status.Retention.Queries = humanDuration(opts.RetainQueries)
|
c.status.Retention.Queries = humanDuration(opts.RetainQueries)
|
||||||
|
c.status.RetentionSeconds.Queries = int64(opts.RetainQueries / time.Second)
|
||||||
}
|
}
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Exploring the query log (docs/stats.md#exploring): every answer of the
|
// Exploring the query log (docs/stats.md#exploring): every answer of the
|
||||||
// last days (stats.query_retention), filtered by any mix of client, name,
|
// last days (stats.retention.queries), filtered by any mix of client, name,
|
||||||
// view, type, result and blocking, broken down by each of them.
|
// view, type, result and blocking, broken down by each of them.
|
||||||
|
|
||||||
// ExploreFilter narrows the query log. Empty fields don't filter. Each
|
// ExploreFilter narrows the query log. Empty fields don't filter. Each
|
||||||
@@ -47,8 +47,9 @@ type Exploration struct {
|
|||||||
Recent []QueryRow `json:"recent"`
|
Recent []QueryRow `json:"recent"`
|
||||||
// Oldest is the first answer the query log still has.
|
// Oldest is the first answer the query log still has.
|
||||||
Oldest *time.Time `json:"oldest,omitempty"`
|
Oldest *time.Time `json:"oldest,omitempty"`
|
||||||
// Retention is how long it keeps them.
|
// Retention is how long it keeps them, also in seconds.
|
||||||
Retention string `json:"retention"`
|
Retention string `json:"retention"`
|
||||||
|
RetentionSeconds int64 `json:"retention_seconds"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExplorePoint is one step of the series.
|
// ExplorePoint is one step of the series.
|
||||||
@@ -59,7 +60,7 @@ type ExplorePoint struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ErrNoQueryLog is returned when the query log is off.
|
// ErrNoQueryLog is returned when the query log is off.
|
||||||
var ErrNoQueryLog = fmt.Errorf("the query log is off: it needs stats.query_log and a stats.query_retention above 0 in /etc/jarl/jarl.yaml (docs/stats.md#exploring)")
|
var ErrNoQueryLog = fmt.Errorf("the query log is off: it needs stats.query_log and a stats.retention.queries above 0 in /etc/jarl/jarl.yaml (docs/stats.md#exploring)")
|
||||||
|
|
||||||
// values splits a filter field into the values it must be one of and the
|
// values splits a filter field into the values it must be one of and the
|
||||||
// ones (written !VALUE) it must not be: "10.0.0.5, !10.0.0.9".
|
// ones (written !VALUE) it must not be: "10.0.0.5, !10.0.0.9".
|
||||||
@@ -178,7 +179,7 @@ func (q *Querier) Explore(ctx context.Context, f ExploreFilter, topN, recent int
|
|||||||
}
|
}
|
||||||
from := now.Add(-since).Unix()
|
from := now.Add(-since).Unix()
|
||||||
out := &Exploration{From: time.Unix(from, 0), To: now, By: map[string][]Entry{}, Series: []ExplorePoint{}, Recent: []QueryRow{},
|
out := &Exploration{From: time.Unix(from, 0), To: now, By: map[string][]Entry{}, Series: []ExplorePoint{}, Recent: []QueryRow{},
|
||||||
Retention: humanDuration(q.Opts.RetainQueries)}
|
Retention: humanDuration(q.Opts.RetainQueries), RetentionSeconds: int64(q.Opts.RetainQueries / time.Second)}
|
||||||
cond, args := q.where(f, from)
|
cond, args := q.where(f, from)
|
||||||
db := q.Store.db
|
db := q.Store.db
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ var schema = []string{
|
|||||||
PRIMARY KEY (res, ts, kind, key)
|
PRIMARY KEY (res, ts, kind, key)
|
||||||
) WITHOUT ROWID;
|
) WITHOUT ROWID;
|
||||||
CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);`,
|
CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);`,
|
||||||
// 2: the query log, every answer for a while (stats.query_retention),
|
// 2: the query log, every answer for a while (stats.retention.queries),
|
||||||
// for drilling down: what a client asked, who asked for a name
|
// for drilling down: what a client asked, who asked for a name
|
||||||
// (docs/stats.md#exploring).
|
// (docs/stats.md#exploring).
|
||||||
`CREATE TABLE queries (
|
`CREATE TABLE queries (
|
||||||
|
|||||||
@@ -16,6 +16,13 @@ const filterWords = { client: "client", name: "name", domain: "domain", view: "v
|
|||||||
const keys = Object.keys(filterWords);
|
const keys = Object.keys(filterWords);
|
||||||
const ranges = [["1h", 3600], ["6h", 6 * 3600], ["24h", 86400], ["3d", 3 * 86400], ["7d", 7 * 86400], ["30d", 30 * 86400]];
|
const ranges = [["1h", 3600], ["6h", 6 * 3600], ["24h", 86400], ["3d", 3 * 86400], ["7d", 7 * 86400], ["30d", 30 * 86400]];
|
||||||
|
|
||||||
|
// exploreRange is the longest range here of at most secs ("1h" at least).
|
||||||
|
export function exploreRange(secs) {
|
||||||
|
let out = "1h";
|
||||||
|
for (const [k, s] of ranges) if (s <= secs) out = k;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
// exploreHref is the explore page for these filters (and range).
|
// exploreHref is the explore page for these filters (and range).
|
||||||
export function exploreHref(filters, since) {
|
export function exploreHref(filters, since) {
|
||||||
const q = new URLSearchParams();
|
const q = new URLSearchParams();
|
||||||
@@ -74,7 +81,7 @@ export async function render(el, params, query) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const parts = [head(f, go, since, ex), filterBar(f, since, ex, allViews)];
|
const parts = [head(f, go, since, ex), filterBar(f, since, ex, allViews)];
|
||||||
const maxSecs = retentionSecs(ex.retention);
|
const maxSecs = ex.retention_seconds || 7 * 86400;
|
||||||
parts.push(h("div", { class: "filter-row", role: "group", "aria-label": "Time range" },
|
parts.push(h("div", { class: "filter-row", role: "group", "aria-label": "Time range" },
|
||||||
h("span", { class: "muted" }, "Range"),
|
h("span", { class: "muted" }, "Range"),
|
||||||
h("div", { class: "segmented" }, ranges.filter(([, s]) => s <= maxSecs).map(([k]) =>
|
h("div", { class: "segmented" }, ranges.filter(([, s]) => s <= maxSecs).map(([k]) =>
|
||||||
@@ -234,9 +241,3 @@ function stepWord(s) {
|
|||||||
if (s % 3600 === 0) return s === 3600 ? "hour" : s / 3600 + " hours";
|
if (s % 3600 === 0) return s === 3600 ? "hour" : s / 3600 + " hours";
|
||||||
return s === 60 ? "minute" : s / 60 + " minutes";
|
return s === 60 ? "minute" : s / 60 + " minutes";
|
||||||
}
|
}
|
||||||
|
|
||||||
function retentionSecs(r) {
|
|
||||||
const m = /^(\d+)([dhm])$/.exec(r || "");
|
|
||||||
if (!m) return 7 * 86400;
|
|
||||||
return Number(m[1]) * { d: 86400, h: 3600, m: 60 }[m[2]];
|
|
||||||
}
|
|
||||||
@@ -2,10 +2,27 @@ import { h, mount, fmtTime, relTime, errorBox } from "../ui.js";
|
|||||||
import { get } from "../api.js";
|
import { get } from "../api.js";
|
||||||
import { rerender } from "../app.js";
|
import { rerender } from "../app.js";
|
||||||
import { lineChart, chartCard, legend, timeTable, heatmap, heatmapTable, barList, compact, stepSeconds, endLabelWidth } from "../charts.js";
|
import { lineChart, chartCard, legend, timeTable, heatmap, heatmapTable, barList, compact, stepSeconds, endLabelWidth } from "../charts.js";
|
||||||
import { exploreHref } from "./explore.js";
|
import { exploreHref, exploreRange } from "./explore.js";
|
||||||
|
|
||||||
const ranges = [["1h", "1 hour"], ["6h", "6 hours"], ["24h", "24 hours"], ["7d", "7 days"], ["30d", "30 days"], ["90d", "90 days"]];
|
// Ranges: the since value, its words and its button. Only the ones the
|
||||||
const rangeSecs = { "1h": 3600, "6h": 6 * 3600, "24h": 86400, "7d": 7 * 86400, "30d": 30 * 86400, "90d": 90 * 86400 };
|
// statistics are kept for are offered (keptSecs).
|
||||||
|
const ranges = [["1h", "1 hour"], ["6h", "6 hours"], ["24h", "24 hours"], ["7d", "7 days"], ["30d", "30 days"], ["90d", "90 days"],
|
||||||
|
["365d", "year", "1y"], ["730d", "2 years", "2y"]];
|
||||||
|
const rangeSecs = { "1h": 3600, "6h": 6 * 3600, "24h": 86400, "7d": 7 * 86400, "30d": 30 * 86400, "90d": 90 * 86400, "365d": 365 * 86400, "730d": 730 * 86400 };
|
||||||
|
|
||||||
|
// keptSecs is how far back the statistics go: the longest of the minute,
|
||||||
|
// hour and day retentions (a server older than retention_seconds: 2 years).
|
||||||
|
function keptSecs(status) {
|
||||||
|
const r = status.retention_seconds;
|
||||||
|
return r ? Math.max(r.minute, r.hour, r.day) : 730 * 86400;
|
||||||
|
}
|
||||||
|
|
||||||
|
// longestWithin is the longest range of at most secs ("1h" at least).
|
||||||
|
function longestWithin(secs) {
|
||||||
|
let out = "1h";
|
||||||
|
for (const [k] of ranges) if (rangeSecs[k] <= secs) out = k;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
// Failed answers keep the same color whatever the range: color follows
|
// Failed answers keep the same color whatever the range: color follows
|
||||||
// the result code, never its rank.
|
// the result code, never its rank.
|
||||||
@@ -36,8 +53,9 @@ const perViewGroups = ["qtype", "rcode", "view", "zone", "blocklist"];
|
|||||||
export async function render(el, params, query) {
|
export async function render(el, params, query) {
|
||||||
const gen = ++generation;
|
const gen = ++generation;
|
||||||
const want = query && query.get ? query.get("since") : null;
|
const want = query && query.get ? query.get("since") : null;
|
||||||
const since = rangeSecs[want] ? want : "24h";
|
|
||||||
const status = await get("/stats/status");
|
const status = await get("/stats/status");
|
||||||
|
const kept = keptSecs(status);
|
||||||
|
const since = rangeSecs[want] && rangeSecs[want] <= kept ? want : longestWithin(Math.min(86400, kept));
|
||||||
const head = h("div", { class: "page-head" },
|
const head = h("div", { class: "page-head" },
|
||||||
h("div", { class: "grow" }, h("h1", null, "Statistics"),
|
h("div", { class: "grow" }, h("h1", null, "Statistics"),
|
||||||
h("div", { class: "muted" }, status.enabled
|
h("div", { class: "muted" }, status.enabled
|
||||||
@@ -50,7 +68,10 @@ export async function render(el, params, query) {
|
|||||||
mount(el, head, disabledPanel());
|
mount(el, head, disabledPanel());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const heatSince = rangeSecs[since] < 7 * 86400 ? "7d" : since;
|
// The heatmap needs hourly counters: at least a week of them, at most
|
||||||
|
// what is kept.
|
||||||
|
const hourSecs = status.retention_seconds ? status.retention_seconds.hour : 90 * 86400;
|
||||||
|
const heatSince = longestWithin(Math.min(Math.max(rangeSecs[since], 7 * 86400), hourSecs));
|
||||||
// Which views: all of them unless some are switched off.
|
// Which views: all of them unless some are switched off.
|
||||||
const allViews = status.query_log ? (await get("/views").catch(() => [])).map((v) => v.name) : [];
|
const allViews = status.query_log ? (await get("/views").catch(() => [])).map((v) => v.name) : [];
|
||||||
const off = viewsOff();
|
const off = viewsOff();
|
||||||
@@ -58,14 +79,14 @@ export async function render(el, params, query) {
|
|||||||
const filtered = allViews.length > 0 && shown.length < allViews.length;
|
const filtered = allViews.length > 0 && shown.length < allViews.length;
|
||||||
const vf = filtered ? { views: shown.join(",") } : {};
|
const vf = filtered ? { views: shown.join(",") } : {};
|
||||||
if (filtered && !shown.length) {
|
if (filtered && !shown.length) {
|
||||||
mount(el, head, filterRow(since, allViews, off), h("div", { class: "empty" }, "No views chosen: switch one on above."));
|
mount(el, head, filterRow(since, kept, allViews, off), h("div", { class: "empty" }, "No views chosen: switch one on above."));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let data;
|
let data;
|
||||||
try {
|
try {
|
||||||
data = await loadAll(since, heatSince, status.query_log, vf, filtered);
|
data = await loadAll(since, heatSince, status.query_log, vf, filtered);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
mount(el, head, filterRow(since, allViews, off), errorBox(e));
|
mount(el, head, filterRow(since, kept, allViews, off), errorBox(e));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (gen !== generation) return; // the user moved on
|
if (gen !== generation) return; // the user moved on
|
||||||
@@ -74,7 +95,7 @@ export async function render(el, params, query) {
|
|||||||
// sudden drop in traffic.
|
// sudden drop in traffic.
|
||||||
const timeline = complete(data.timeline), errors = complete(data.errors);
|
const timeline = complete(data.timeline), errors = complete(data.errors);
|
||||||
const step = stepSeconds[timeline.step];
|
const step = stepSeconds[timeline.step];
|
||||||
const parts = [head, filterRow(since, allViews, off)];
|
const parts = [head, filterRow(since, kept, allViews, off)];
|
||||||
for (const w of status.warnings || []) parts.push(h("div", { class: "msg warn" }, w));
|
for (const w of status.warnings || []) parts.push(h("div", { class: "msg warn" }, w));
|
||||||
if (filtered) {
|
if (filtered) {
|
||||||
parts.push(h("div", { class: "msg info small" }, "Showing view" + (shown.length > 1 ? "s " : " ") + shown.join(", ") + ": answers from named's response log" +
|
parts.push(h("div", { class: "msg info small" }, "Showing view" + (shown.length > 1 ? "s " : " ") + shown.join(", ") + ": answers from named's response log" +
|
||||||
@@ -120,7 +141,7 @@ export async function render(el, params, query) {
|
|||||||
// Who asks for what. With the query log, every value leads to the
|
// Who asks for what. With the query log, every value leads to the
|
||||||
// explore page: a client to what it asked, a name to who asked.
|
// explore page: a client to what it asked, a name to who asked.
|
||||||
const ql = !!(status.retention || {}).queries;
|
const ql = !!(status.retention || {}).queries;
|
||||||
const ex = (filters) => (ql ? (k) => exploreHref(filters(k), exploreSince(since)) : null);
|
const ex = (filters) => (ql ? (k) => exploreHref(filters(k), exploreSince(since, status)) : null);
|
||||||
if (status.query_log) {
|
if (status.query_log) {
|
||||||
const t = data.tops;
|
const t = data.tops;
|
||||||
parts.push(h("div", { class: "chart-grid" },
|
parts.push(h("div", { class: "chart-grid" },
|
||||||
@@ -201,11 +222,11 @@ function complete(tl) {
|
|||||||
return tl;
|
return tl;
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterRow(since, views, off) {
|
function filterRow(since, kept, views, off) {
|
||||||
const range = h("div", { class: "filter-row", role: "group", "aria-label": "Time range" },
|
const range = h("div", { class: "filter-row", role: "group", "aria-label": "Time range" },
|
||||||
h("span", { class: "muted" }, "Range"),
|
h("span", { class: "muted" }, "Range"),
|
||||||
h("div", { class: "segmented" }, ranges.map(([k, label]) =>
|
h("div", { class: "segmented" }, ranges.filter(([k]) => rangeSecs[k] <= kept).map(([k, label, button]) =>
|
||||||
h("a", { href: "#/stats?since=" + k, class: k === since ? "active" : "", "aria-current": k === since ? "true" : null, title: "last " + label }, k))));
|
h("a", { href: "#/stats?since=" + k, class: k === since ? "active" : "", "aria-current": k === since ? "true" : null, title: "last " + label }, button || k))));
|
||||||
if (!views || views.length < 2) return range;
|
if (!views || views.length < 2) return range;
|
||||||
// A toggle per view (together: linked views, say), and all or none.
|
// A toggle per view (together: linked views, say), and all or none.
|
||||||
const set = (next) => { saveViewsOff(next); rerender(); };
|
const set = (next) => { saveViewsOff(next); rerender(); };
|
||||||
@@ -312,10 +333,11 @@ function disabledPanel() {
|
|||||||
"(never individual queries) in a separate database, by default for 7 days per minute, 90 days per hour and 2 years per day. See docs/stats.md."));
|
"(never individual queries) in a separate database, by default for 7 days per minute, 90 days per hour and 2 years per day. See docs/stats.md."));
|
||||||
}
|
}
|
||||||
|
|
||||||
// exploreSince is the Statistics range for the explore page: the query log
|
// exploreSince is the Statistics range for the explore page: at most what
|
||||||
// keeps days, not months.
|
// the query log keeps (days, usually, not months).
|
||||||
function exploreSince(since) {
|
function exploreSince(since, status) {
|
||||||
return rangeSecs[since] > 7 * 86400 ? "7d" : since;
|
const q = status.retention_seconds ? status.retention_seconds.queries : 7 * 86400;
|
||||||
|
return exploreRange(Math.min(rangeSecs[since], q));
|
||||||
}
|
}
|
||||||
|
|
||||||
// dbTile is the statistics database's size: what the statistics cost on
|
// dbTile is the statistics database's size: what the statistics cost on
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in new issue
Block a user