cert get: combined file for HAProxy, --local, saved NAME; cert list shows get and last download
- jarl cert get --combined PATH writes the key and full chain in one file
(mode 600, atomic), kept outside --dir so it can live in HAProxy's crt
directory; JARL_CERT_COMBINED for the hook. Turning it on, moving it or
removing it ('') applies from the files on disk and runs the hook.
- jarl cert get --local writes the combined file and runs the hook from
this host's files, without asking jarld.
- jarl cert saved NAME shows all of one certificate's settings.
- jarl cert list: USES "get" for certificates for other hosts, and a
PULLED column (last download, "(old)" while a host is behind). Owners
now see their own downloads (pulled_by), not only administrators.
- jarl cert get without a name refuses per-certificate flags.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3fb159cf4d
commit
42e79af678
9 files changed
+578
-76
No files matched your search
+268
-48
@@ -2,7 +2,8 @@ package main
|
||||
|
||||
// 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,
|
||||
// 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
|
||||
// schedule) can run 'jarl cert get' for all of them.
|
||||
|
||||
@@ -31,13 +32,16 @@ import (
|
||||
|
||||
// savedCert is a certificate's settings, saved by jarl cert get.
|
||||
type savedCert struct {
|
||||
Name string `json:"name"`
|
||||
Domains []string `json:"domains"`
|
||||
KeyType string `json:"key_type,omitempty"`
|
||||
Dir string `json:"dir"`
|
||||
Hook string `json:"hook,omitempty"`
|
||||
Conn connection `json:"connection"`
|
||||
SavedAt time.Time `json:"saved_at"`
|
||||
Name string `json:"name"`
|
||||
Domains []string `json:"domains"`
|
||||
KeyType string `json:"key_type,omitempty"`
|
||||
Dir string `json:"dir"`
|
||||
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"`
|
||||
SavedAt time.Time `json:"saved_at"`
|
||||
}
|
||||
|
||||
// localMeta is what this host has of a certificate, kept next to its
|
||||
@@ -48,6 +52,9 @@ type localMeta struct {
|
||||
Serial string `json:"serial"`
|
||||
NotAfter time.Time `json:"not_after"`
|
||||
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
|
||||
// run runs it again.
|
||||
HookFailed string `json:"hook_failed,omitempty"`
|
||||
@@ -128,12 +135,53 @@ func checkCertClash(saved []savedCert, i int, sc savedCert, replace bool) error
|
||||
return fmt.Errorf("%s holds the files of %s, saved for %s: give %s another --dir", sc.Dir, o.Name, o.Conn.Server, sc.Name)
|
||||
}
|
||||
}
|
||||
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), "/") }
|
||||
@@ -191,12 +239,16 @@ type getResult struct {
|
||||
Serial string `json:"serial"`
|
||||
NotAfter time.Time `json:"not_after"`
|
||||
Updated bool `json:"updated"` // new files written
|
||||
Hook string `json:"hook,omitempty"`
|
||||
// Combined is the combined file; CombinedWritten is set when it was
|
||||
// written now.
|
||||
Combined string `json:"combined,omitempty"`
|
||||
CombinedWritten bool `json:"combined_written,omitempty"`
|
||||
Hook string `json:"hook,omitempty"`
|
||||
}
|
||||
|
||||
// fetchCert asks jarld for the certificate, waits while it is obtained,
|
||||
// downloads it when it is newer than this host's copy (or force) and runs
|
||||
// the hook.
|
||||
// downloads it when it is newer than this host's copy (or force), and
|
||||
// then applies it (applyCert).
|
||||
func fetchCert(c *client.Client, sc savedCert, force bool, wait time.Duration) (*getResult, error) {
|
||||
var ans requestAnswer
|
||||
err := c.Do(ctx(), "POST", certPath(sc.Name)+"/request", nil, map[string]any{"domains": sc.Domains, "key_type": sc.KeyType}, &ans)
|
||||
@@ -241,23 +293,68 @@ func fetchCert(c *client.Client, sc savedCert, force bool, wait time.Duration) (
|
||||
if ci.LastError != "" && ci.LastAttempt != nil && (ci.LastSuccess == nil || ci.LastAttempt.After(*ci.LastSuccess)) {
|
||||
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)
|
||||
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
|
||||
if !have || force {
|
||||
updated := !have || force
|
||||
if updated {
|
||||
if err := downloadCert(c, sc, ci); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res.Updated = true
|
||||
meta = &localMeta{Name: sc.Name, Domains: ci.Domains, Serial: ci.Serial, NotAfter: *ci.NotAfter, Fetched: time.Now().UTC()}
|
||||
old := ""
|
||||
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 != "" {
|
||||
res.Hook = "ok"
|
||||
if err := runHook(sc, ci, renewed); err != nil {
|
||||
if err := runHook(sc, meta, renewed); err != nil {
|
||||
meta.HookFailed = err.Error()
|
||||
res.Hook = "failed: " + err.Error()
|
||||
} else {
|
||||
@@ -275,6 +372,31 @@ func fetchCert(c *client.Client, sc savedCert, force bool, wait time.Duration) (
|
||||
|
||||
var certFiles = []string{"cert.pem", "chain.pem", "fullchain.pem", "privkey.pem"}
|
||||
|
||||
// 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 {
|
||||
for _, f := range certFiles {
|
||||
if _, err := os.Stat(filepath.Join(dir, f)); err != nil {
|
||||
@@ -339,7 +461,7 @@ func downloadCert(c *client.Client, sc savedCert, ci certInfo) error {
|
||||
|
||||
// runHook runs the certificate's hook with sh, its files in the
|
||||
// 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)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(c, "sh", "-c", sc.Hook)
|
||||
@@ -354,9 +476,10 @@ func runHook(sc savedCert, ci certInfo, renewed bool) error {
|
||||
"JARL_CERT_CHAIN="+filepath.Join(sc.Dir, "chain.pem"),
|
||||
"JARL_CERT_FULLCHAIN="+filepath.Join(sc.Dir, "fullchain.pem"),
|
||||
"JARL_CERT_KEY="+filepath.Join(sc.Dir, "privkey.pem"),
|
||||
"JARL_CERT_DOMAINS="+strings.Join(ci.Domains, " "),
|
||||
"JARL_CERT_SERIAL="+ci.Serial,
|
||||
"JARL_CERT_NOT_AFTER="+ci.NotAfter.UTC().Format(time.RFC3339),
|
||||
"JARL_CERT_COMBINED="+sc.Combined,
|
||||
"JARL_CERT_DOMAINS="+strings.Join(meta.Domains, " "),
|
||||
"JARL_CERT_SERIAL="+meta.Serial,
|
||||
"JARL_CERT_NOT_AFTER="+meta.NotAfter.UTC().Format(time.RFC3339),
|
||||
"JARL_CERT_RENEWED="+ren,
|
||||
)
|
||||
cmd.Stdout, cmd.Stderr = os.Stderr, os.Stderr
|
||||
@@ -365,8 +488,8 @@ func runHook(sc savedCert, ci certInfo, renewed bool) error {
|
||||
|
||||
func certGetCmd() *cobra.Command {
|
||||
var domains []string
|
||||
var dir, hook, keyType, configDir string
|
||||
var noSave, force, replace bool
|
||||
var dir, hook, keyType, combined, configDir string
|
||||
var noSave, force, replace, local bool
|
||||
var wait time.Duration
|
||||
cmd := &cobra.Command{
|
||||
Use: "get [NAME]",
|
||||
@@ -374,7 +497,7 @@ func certGetCmd() *cobra.Command {
|
||||
Long: `Gets a certificate for this host (a mail server, a reverse proxy). jarld
|
||||
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
|
||||
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
|
||||
belongs to the token's user: another user can't take the name. Asking
|
||||
@@ -383,11 +506,19 @@ removing a name gets a new one.
|
||||
|
||||
Files in --dir (default /etc/jarl/certs/NAME as root, ./NAME otherwise):
|
||||
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,
|
||||
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
|
||||
systemd timer). Nothing is downloaded until jarld has renewed one.
|
||||
|
||||
@@ -395,14 +526,20 @@ A name saved here for another Jarl server isn't taken over, nor a --dir
|
||||
holding another certificate's files, unless --replace is given; a --dir
|
||||
another saved certificate uses is always refused.
|
||||
|
||||
--hook runs with sh after new files are written (and again on the next
|
||||
run if it failed), with JARL_CERT_NAME, JARL_CERT_DIR, JARL_CERT_CERT,
|
||||
JARL_CERT_CHAIN, JARL_CERT_FULLCHAIN, JARL_CERT_KEY, JARL_CERT_DOMAINS,
|
||||
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'
|
||||
jarl cert get proxy -d adbraun.lol -d '*.adbraun.lol' --dir /etc/haproxy/certs/proxy \
|
||||
--hook 'cat "$JARL_CERT_KEY" "$JARL_CERT_FULLCHAIN" > /etc/haproxy/proxy.pem && systemctl reload haproxy'
|
||||
jarl cert get proxy -d adbraun.lol -d '*.adbraun.lol' \
|
||||
--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 saved # what this host has, and when it expires`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
@@ -414,6 +551,9 @@ replaced an older certificate).`,
|
||||
}
|
||||
var todo []savedCert
|
||||
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 {
|
||||
case len(args) == 0:
|
||||
if len(domains) > 0 {
|
||||
@@ -422,6 +562,11 @@ replaced an older certificate).`,
|
||||
if len(saved) == 0 {
|
||||
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
|
||||
default:
|
||||
name := args[0]
|
||||
@@ -434,6 +579,9 @@ replaced an older certificate).`,
|
||||
}
|
||||
sc.Name = name
|
||||
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
|
||||
}
|
||||
if f.Changed("dir") || sc.Dir == "" {
|
||||
@@ -445,6 +593,16 @@ replaced an older certificate).`,
|
||||
if f.Changed("key-type") {
|
||||
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 {
|
||||
sc.Dir = abs
|
||||
}
|
||||
@@ -466,14 +624,19 @@ replaced an older certificate).`,
|
||||
var failed []string
|
||||
var lastErr error
|
||||
for _, sc := range todo {
|
||||
c, err := sc.Conn.open()
|
||||
if err == nil {
|
||||
var r *getResult
|
||||
r, err = fetchCert(c, sc, force, wait)
|
||||
if r != nil {
|
||||
results = append(results, r)
|
||||
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)
|
||||
}
|
||||
}
|
||||
if r != nil {
|
||||
results = append(results, r)
|
||||
}
|
||||
if err != nil {
|
||||
if len(todo) > 1 {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
@@ -492,8 +655,16 @@ replaced an older certificate).`,
|
||||
if err := output(results, func(w io.Writer) {
|
||||
for _, r := range results {
|
||||
what := "up to date"
|
||||
if r.Updated {
|
||||
switch {
|
||||
case r.Updated:
|
||||
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))
|
||||
}
|
||||
@@ -512,7 +683,9 @@ replaced an older certificate).`,
|
||||
f := cmd.Flags()
|
||||
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(&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.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")
|
||||
@@ -533,9 +706,15 @@ func hookText(h string) string {
|
||||
func certSavedCmd() *cobra.Command {
|
||||
var configDir string
|
||||
cmd := &cobra.Command{
|
||||
Use: "saved",
|
||||
Use: "saved [NAME]",
|
||||
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 {
|
||||
cdir := certConfigDir(configDir)
|
||||
saved, err := loadSaved(cdir)
|
||||
@@ -548,17 +727,27 @@ func certSavedCmd() *cobra.Command {
|
||||
}
|
||||
var rows []row
|
||||
for _, sc := range saved {
|
||||
if len(args) == 1 && sc.Name != args[0] {
|
||||
continue
|
||||
}
|
||||
sc.Conn.Token = "" // never print it
|
||||
m, _ := readMeta(sc.Dir)
|
||||
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) {
|
||||
if len(rows) == 0 {
|
||||
fmt.Fprintf(w, "No saved certificates in %s.\n", cdir)
|
||||
return
|
||||
}
|
||||
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 {
|
||||
exp, fetched := "-", "-"
|
||||
if r.Local != nil {
|
||||
@@ -568,7 +757,7 @@ func certSavedCmd() *cobra.Command {
|
||||
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()
|
||||
})
|
||||
@@ -578,6 +767,37 @@ func certSavedCmd() *cobra.Command {
|
||||
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 {
|
||||
var configDir string
|
||||
var del, yes bool
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
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) {
|
||||
@@ -57,3 +66,138 @@ func mkdir(t *testing.T, dir string) string {
|
||||
}
|
||||
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"`
|
||||
Key string `json:"key"`
|
||||
} `json:"files,omitempty"`
|
||||
OwnerID int64 `json:"owner_id,omitempty"`
|
||||
Owner string `json:"owner,omitempty"`
|
||||
PulledBy []struct {
|
||||
Who string `json:"who"`
|
||||
@@ -131,7 +132,7 @@ func certListCmd() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
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 {
|
||||
names := ci.Names
|
||||
if len(names) == 0 {
|
||||
@@ -141,13 +142,32 @@ func certListCmd() *cobra.Command {
|
||||
if ci.IssuingSince != nil {
|
||||
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 {
|
||||
if t == nil {
|
||||
return "-"
|
||||
|
||||
Reference in new issue
Block a user