Each Jarl server has its own certificate names, but a host keeps one saved NAME.json (and, by default, one /etc/jarl/certs/NAME). Asking a second server for the same name overwrote the first one's settings and files, so the first certificate quietly stopped being renewed. Now that is refused, as is a --dir holding another certificate's files; --replace takes the name or directory over. A --dir another saved certificate uses is always refused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
753 lines
24 KiB
Go
753 lines
24 KiB
Go
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
|
|
// settings of each certificate are saved, so a daily timer (jarl cert
|
|
// schedule) can run 'jarl cert get' for all of them.
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"slices"
|
|
"strings"
|
|
"text/tabwriter"
|
|
"time"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"jarl/internal/client"
|
|
)
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// localMeta is what this host has of a certificate, kept next to its
|
|
// files as jarl-cert.json.
|
|
type localMeta struct {
|
|
Name string `json:"name"`
|
|
Domains []string `json:"domains"`
|
|
Serial string `json:"serial"`
|
|
NotAfter time.Time `json:"not_after"`
|
|
Fetched time.Time `json:"fetched"`
|
|
// HookFailed is set when the hook failed for these files: the next
|
|
// run runs it again.
|
|
HookFailed string `json:"hook_failed,omitempty"`
|
|
}
|
|
|
|
const metaFile = "jarl-cert.json"
|
|
|
|
// certConfigDir is where saved settings live: /etc/jarl/cert-get for
|
|
// root, ~/.config/jarl/cert-get otherwise.
|
|
func certConfigDir(flag string) string {
|
|
if flag != "" {
|
|
return flag
|
|
}
|
|
if os.Geteuid() == 0 {
|
|
return "/etc/jarl/cert-get"
|
|
}
|
|
if d, err := os.UserConfigDir(); err == nil {
|
|
return filepath.Join(d, "jarl", "cert-get")
|
|
}
|
|
return ".jarl-cert-get"
|
|
}
|
|
|
|
// defaultCertDir is where a certificate's files go without --dir.
|
|
func defaultCertDir(name string) string {
|
|
if os.Geteuid() == 0 {
|
|
return filepath.Join("/etc/jarl/certs", name)
|
|
}
|
|
return name
|
|
}
|
|
|
|
func loadSaved(dir string) ([]savedCert, error) {
|
|
entries, err := os.ReadDir(dir)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var out []savedCert
|
|
for _, e := range entries {
|
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
|
|
continue
|
|
}
|
|
b, err := os.ReadFile(filepath.Join(dir, e.Name()))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var sc savedCert
|
|
if err := json.Unmarshal(b, &sc); err != nil {
|
|
return nil, fmt.Errorf("%s: %w", filepath.Join(dir, e.Name()), err)
|
|
}
|
|
out = append(out, sc)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// save writes a certificate's settings, readable only by this user: they
|
|
// may hold the API token.
|
|
func (sc savedCert) save(dir string) error {
|
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
|
return err
|
|
}
|
|
b, _ := json.MarshalIndent(sc, "", " ")
|
|
return writeAtomic(filepath.Join(dir, sc.Name+".json"), append(b, '\n'), 0o600)
|
|
}
|
|
|
|
// checkCertClash refuses a certificate that would take over what this
|
|
// host keeps for another one: a name saved for another Jarl server
|
|
// (each server has its own names, so the server can't refuse it), or a
|
|
// directory with another certificate's files. saved[i] is sc's saved
|
|
// settings (i < 0: none).
|
|
func checkCertClash(saved []savedCert, i int, sc savedCert, replace bool) error {
|
|
if i >= 0 && !replace && !sameServer(saved[i].Conn.Server, sc.Conn.Server) {
|
|
return fmt.Errorf("%s is saved here for the Jarl server %s, not %s: choose another name, or take it over with --replace", sc.Name, saved[i].Conn.Server, sc.Conn.Server)
|
|
}
|
|
for j, o := range saved {
|
|
if j != i && o.Name != sc.Name && filepath.Clean(o.Dir) == filepath.Clean(sc.Dir) {
|
|
return fmt.Errorf("%s holds the files of %s, saved for %s: give %s another --dir", sc.Dir, o.Name, o.Conn.Server, sc.Name)
|
|
}
|
|
}
|
|
if m, err := readMeta(sc.Dir); err == nil && m.Name != "" && m.Name != sc.Name && !replace {
|
|
return fmt.Errorf("%s holds the files of the certificate %s: give %s another --dir, or overwrite them with --replace", sc.Dir, m.Name, sc.Name)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// sameServer reports whether two server addresses name the same jarld.
|
|
func sameServer(a, b string) bool {
|
|
norm := func(s string) string { return strings.TrimRight(strings.ToLower(s), "/") }
|
|
return norm(a) == norm(b)
|
|
}
|
|
|
|
func readMeta(dir string) (*localMeta, error) {
|
|
b, err := os.ReadFile(filepath.Join(dir, metaFile))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var m localMeta
|
|
return &m, json.Unmarshal(b, &m)
|
|
}
|
|
|
|
func (m *localMeta) write(dir string) error {
|
|
b, _ := json.MarshalIndent(m, "", " ")
|
|
return writeAtomic(filepath.Join(dir, metaFile), append(b, '\n'), 0o644)
|
|
}
|
|
|
|
// writeAtomic replaces path with data, so a program never reads half a
|
|
// file.
|
|
func writeAtomic(path string, data []byte, mode os.FileMode) error {
|
|
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer os.Remove(tmp.Name())
|
|
if err := tmp.Chmod(mode); err != nil {
|
|
tmp.Close()
|
|
return err
|
|
}
|
|
if _, err := tmp.Write(data); err != nil {
|
|
tmp.Close()
|
|
return err
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmp.Name(), path)
|
|
}
|
|
|
|
// requestAnswer is POST /certificates/{name}/request.
|
|
type requestAnswer struct {
|
|
Certificate certInfo `json:"certificate"`
|
|
Created bool `json:"created"`
|
|
Changed bool `json:"changed"`
|
|
Warnings []string `json:"warnings"`
|
|
}
|
|
|
|
// getResult is what one jarl cert get did.
|
|
type getResult struct {
|
|
Name string `json:"name"`
|
|
Dir string `json:"dir"`
|
|
Serial string `json:"serial"`
|
|
NotAfter time.Time `json:"not_after"`
|
|
Updated bool `json:"updated"` // new files written
|
|
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.
|
|
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)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, w := range ans.Warnings {
|
|
fmt.Fprintf(os.Stderr, "%s: %s\n", sc.Name, w)
|
|
}
|
|
ci := ans.Certificate
|
|
if ci.IssuingSince != nil {
|
|
tty := !flagQuiet && flagOutput == "table"
|
|
if tty {
|
|
what := "Obtaining"
|
|
if ans.Changed {
|
|
what = "Obtaining (its names changed)"
|
|
}
|
|
fmt.Fprintf(os.Stderr, "%s %s from the CA on jarld", what, sc.Name)
|
|
}
|
|
deadline := time.Now().Add(wait)
|
|
for ci.IssuingSince != nil && time.Now().Before(deadline) {
|
|
time.Sleep(2 * time.Second)
|
|
if tty {
|
|
fmt.Fprint(os.Stderr, ".")
|
|
}
|
|
ci = certInfo{}
|
|
if err := c.Get(ctx(), certPath(sc.Name), nil, &ci); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if tty {
|
|
fmt.Fprintln(os.Stderr)
|
|
}
|
|
if ci.IssuingSince != nil {
|
|
return nil, fmt.Errorf("%s: jarld is still obtaining it; run jarl cert get again later", sc.Name)
|
|
}
|
|
}
|
|
if ci.NotAfter == nil {
|
|
msg := firstNonEmpty(ci.LastError, ci.Status.Message, "jarld has no certificate for it yet")
|
|
return nil, fmt.Errorf("%s: %s", sc.Name, msg)
|
|
}
|
|
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 {
|
|
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()}
|
|
}
|
|
if sc.Hook != "" {
|
|
res.Hook = "ok"
|
|
if err := runHook(sc, ci, renewed); err != nil {
|
|
meta.HookFailed = err.Error()
|
|
res.Hook = "failed: " + err.Error()
|
|
} else {
|
|
meta.HookFailed = ""
|
|
}
|
|
}
|
|
if err := meta.write(sc.Dir); err != nil {
|
|
return nil, err
|
|
}
|
|
if meta.HookFailed != "" {
|
|
return res, fmt.Errorf("%s: the hook failed (%s); the next jarl cert get runs it again", sc.Name, meta.HookFailed)
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
var certFiles = []string{"cert.pem", "chain.pem", "fullchain.pem", "privkey.pem"}
|
|
|
|
func filesPresent(dir string) bool {
|
|
for _, f := range certFiles {
|
|
if _, err := os.Stat(filepath.Join(dir, f)); err != nil {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// downloadCert writes the certificate's files: cert.pem, chain.pem (the
|
|
// intermediates), fullchain.pem and privkey.pem (mode 600).
|
|
func downloadCert(c *client.Client, sc savedCert, ci certInfo) error {
|
|
get := func(part string) ([]byte, error) {
|
|
var out string
|
|
err := c.Get(ctx(), certPath(sc.Name)+"/pem", url.Values{"part": {part}}, &out)
|
|
return []byte(out), err
|
|
}
|
|
var full, key []byte
|
|
// Twice at most: a renewal between the two downloads gives a key
|
|
// that doesn't match.
|
|
for try := 0; ; try++ {
|
|
var err error
|
|
if full, err = get("fullchain"); err != nil {
|
|
return err
|
|
}
|
|
if key, err = get("key"); err != nil {
|
|
return err
|
|
}
|
|
if _, err = tls.X509KeyPair(full, key); err == nil {
|
|
break
|
|
} else if try == 1 {
|
|
return fmt.Errorf("%s: the key jarld sent doesn't match the certificate: %w", sc.Name, err)
|
|
}
|
|
}
|
|
block, rest := pem.Decode(full)
|
|
if block == nil {
|
|
return fmt.Errorf("%s: jarld sent no certificate", sc.Name)
|
|
}
|
|
if err := os.MkdirAll(sc.Dir, 0o755); err != nil {
|
|
return err
|
|
}
|
|
files := []struct {
|
|
name string
|
|
data []byte
|
|
mode os.FileMode
|
|
}{
|
|
{"cert.pem", pem.EncodeToMemory(block), 0o644},
|
|
{"chain.pem", []byte(strings.TrimLeft(string(rest), "\n")), 0o644},
|
|
{"fullchain.pem", full, 0o644},
|
|
{"privkey.pem", key, 0o600},
|
|
}
|
|
// The key first: a program that reads both never pairs a new
|
|
// certificate with the old key for long.
|
|
slices.Reverse(files)
|
|
for _, f := range files {
|
|
if err := writeAtomic(filepath.Join(sc.Dir, f.name), f.data, f.mode); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// runHook runs the certificate's hook with sh, its files in the
|
|
// environment.
|
|
func runHook(sc savedCert, ci certInfo, renewed bool) error {
|
|
c, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
|
defer cancel()
|
|
cmd := exec.CommandContext(c, "sh", "-c", sc.Hook)
|
|
ren := "0"
|
|
if renewed {
|
|
ren = "1"
|
|
}
|
|
cmd.Env = append(os.Environ(),
|
|
"JARL_CERT_NAME="+sc.Name,
|
|
"JARL_CERT_DIR="+sc.Dir,
|
|
"JARL_CERT_CERT="+filepath.Join(sc.Dir, "cert.pem"),
|
|
"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_RENEWED="+ren,
|
|
)
|
|
cmd.Stdout, cmd.Stderr = os.Stderr, os.Stderr
|
|
return cmd.Run()
|
|
}
|
|
|
|
func certGetCmd() *cobra.Command {
|
|
var domains []string
|
|
var dir, hook, keyType, configDir string
|
|
var noSave, force, replace bool
|
|
var wait time.Duration
|
|
cmd := &cobra.Command{
|
|
Use: "get [NAME]",
|
|
Short: "Get a certificate for this host: jarld obtains it, this host downloads it",
|
|
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.
|
|
|
|
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
|
|
again with the same names returns the certificate jarld has; adding or
|
|
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).
|
|
|
|
The settings (names, --dir, --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
|
|
saved certificate: run it daily (jarl cert schedule does that with a
|
|
systemd timer). Nothing is downloaded until jarld has renewed one.
|
|
|
|
A name saved here for another Jarl server isn't taken over, nor a --dir
|
|
holding another certificate's files, unless --replace is given; a --dir
|
|
another saved certificate uses is always refused.
|
|
|
|
--hook runs with sh after new files are written (and again on the next
|
|
run if it failed), with JARL_CERT_NAME, JARL_CERT_DIR, JARL_CERT_CERT,
|
|
JARL_CERT_CHAIN, JARL_CERT_FULLCHAIN, JARL_CERT_KEY, JARL_CERT_DOMAINS,
|
|
JARL_CERT_SERIAL, JARL_CERT_NOT_AFTER and JARL_CERT_RENEWED (1 when it
|
|
replaced an older certificate).`,
|
|
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 # every saved certificate (what the timer runs)
|
|
jarl cert saved # what this host has, and when it expires`,
|
|
Args: cobra.MaximumNArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
cdir := certConfigDir(configDir)
|
|
saved, err := loadSaved(cdir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var todo []savedCert
|
|
f := cmd.Flags()
|
|
switch {
|
|
case len(args) == 0:
|
|
if len(domains) > 0 {
|
|
return errors.New("give the certificate a name: jarl cert get NAME -d DOMAIN")
|
|
}
|
|
if len(saved) == 0 {
|
|
return fmt.Errorf("no saved certificates in %s: get one first (jarl cert get NAME -d DOMAIN)", cdir)
|
|
}
|
|
todo = saved
|
|
default:
|
|
name := args[0]
|
|
i := slices.IndexFunc(saved, func(s savedCert) bool { return s.Name == name })
|
|
var sc savedCert
|
|
if i >= 0 {
|
|
sc = saved[i]
|
|
} else if len(domains) == 0 {
|
|
return fmt.Errorf("%s isn't saved here: give its names (-d DOMAIN)", name)
|
|
}
|
|
sc.Name = name
|
|
if len(domains) > 0 {
|
|
sc.Domains = domains
|
|
}
|
|
if f.Changed("dir") || sc.Dir == "" {
|
|
sc.Dir = firstNonEmpty(dir, defaultCertDir(name))
|
|
}
|
|
if f.Changed("hook") {
|
|
sc.Hook = hook
|
|
}
|
|
if f.Changed("key-type") {
|
|
sc.KeyType = keyType
|
|
}
|
|
if abs, err := filepath.Abs(sc.Dir); err == nil {
|
|
sc.Dir = abs
|
|
}
|
|
// The server and token given now (flags, environment, the
|
|
// CLI configuration) replace the saved ones.
|
|
if i < 0 || f.Changed("server") || cmd.Root().PersistentFlags().Changed("server") || cmd.Root().PersistentFlags().Changed("token") || os.Getenv("JARL_TOKEN") != "" || os.Getenv("JARL_SERVER") != "" {
|
|
cn, err := resolveConnection()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
sc.Conn = cn
|
|
}
|
|
if err := checkCertClash(saved, i, sc, replace); err != nil {
|
|
return err
|
|
}
|
|
todo = []savedCert{sc}
|
|
}
|
|
var results []*getResult
|
|
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)
|
|
}
|
|
}
|
|
if err != nil {
|
|
if len(todo) > 1 {
|
|
fmt.Fprintln(os.Stderr, "error:", err)
|
|
}
|
|
failed = append(failed, sc.Name)
|
|
lastErr = err
|
|
continue
|
|
}
|
|
if len(args) == 1 && !noSave {
|
|
sc.SavedAt = time.Now().UTC()
|
|
if err := sc.save(cdir); err != nil {
|
|
return fmt.Errorf("saving the settings: %w", err)
|
|
}
|
|
}
|
|
}
|
|
if err := output(results, func(w io.Writer) {
|
|
for _, r := range results {
|
|
what := "up to date"
|
|
if r.Updated {
|
|
what = "new files"
|
|
}
|
|
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))
|
|
}
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
switch {
|
|
case len(failed) == 1 && len(todo) == 1:
|
|
return lastErr // its own exit code (3 forbidden, 5 conflict, ...)
|
|
case len(failed) > 0:
|
|
return fmt.Errorf("failed: %s", strings.Join(failed, ", "))
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
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(&keyType, "key-type", "", "key type of a new certificate: ec256 (default), ec384, rsa2048, rsa3072, rsa4096")
|
|
f.BoolVar(&force, "force", false, "download and run the hook even when this host has the current certificate")
|
|
f.BoolVar(&noSave, "no-save", false, "don't save the settings for later runs")
|
|
f.BoolVar(&replace, "replace", false, "take over a name saved here for another Jarl server, or a --dir holding another certificate's files")
|
|
f.DurationVar(&wait, "wait", 15*time.Minute, "how long to wait while jarld obtains it")
|
|
f.StringVar(&configDir, "config-dir", "", "where the settings are saved (default /etc/jarl/cert-get as root, ~/.config/jarl/cert-get otherwise)")
|
|
_ = cmd.RegisterFlagCompletionFunc("key-type", cobra.FixedCompletions([]string{"ec256", "ec384", "rsa2048", "rsa3072", "rsa4096"}, cobra.ShellCompDirectiveNoFileComp))
|
|
return cmd
|
|
}
|
|
|
|
func hookText(h string) string {
|
|
if h == "" {
|
|
return ""
|
|
}
|
|
return "hook " + h
|
|
}
|
|
|
|
func certSavedCmd() *cobra.Command {
|
|
var configDir string
|
|
cmd := &cobra.Command{
|
|
Use: "saved",
|
|
Short: "The certificates this host gets with jarl cert get, and when they expire",
|
|
Args: cobra.NoArgs,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
cdir := certConfigDir(configDir)
|
|
saved, err := loadSaved(cdir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
type row struct {
|
|
savedCert
|
|
Local *localMeta `json:"local,omitempty"`
|
|
}
|
|
var rows []row
|
|
for _, sc := range saved {
|
|
sc.Conn.Token = "" // never print it
|
|
m, _ := readMeta(sc.Dir)
|
|
rows = append(rows, row{sc, m})
|
|
}
|
|
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")
|
|
for _, r := range rows {
|
|
exp, fetched := "-", "-"
|
|
if r.Local != nil {
|
|
exp = r.Local.NotAfter.Local().Format("2006-01-02") + fmt.Sprintf(" (%dd)", int(time.Until(r.Local.NotAfter).Hours()/24))
|
|
fetched = r.Local.Fetched.Local().Format("2006-01-02 15:04")
|
|
if r.Local.HookFailed != "" {
|
|
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, "-"))
|
|
}
|
|
tw.Flush()
|
|
})
|
|
},
|
|
}
|
|
cmd.Flags().StringVar(&configDir, "config-dir", "", "where the settings are saved")
|
|
return cmd
|
|
}
|
|
|
|
func certForgetCmd() *cobra.Command {
|
|
var configDir string
|
|
var del, yes bool
|
|
cmd := &cobra.Command{
|
|
Use: "forget NAME",
|
|
Short: "Stop getting a certificate on this host (its files stay)",
|
|
Long: `Removes a certificate's saved settings, so 'jarl cert get' (and the timer)
|
|
no longer fetches it. Its files stay where they are. With --delete, the
|
|
certificate is deleted on jarld too, so it is no longer renewed.`,
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
cdir := certConfigDir(configDir)
|
|
path := filepath.Join(cdir, args[0]+".json")
|
|
saved, err := loadSaved(cdir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
i := slices.IndexFunc(saved, func(s savedCert) bool { return s.Name == args[0] })
|
|
if i < 0 {
|
|
return fmt.Errorf("%s isn't saved in %s", args[0], cdir)
|
|
}
|
|
if del {
|
|
if !confirm("Delete certificate "+args[0]+" on jarld too?", yes) {
|
|
return errors.New("aborted")
|
|
}
|
|
c, err := saved[i].Conn.open()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := c.Do(ctx(), "DELETE", certPath(args[0]), nil, nil, nil); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := os.Remove(path); err != nil {
|
|
return err
|
|
}
|
|
if !flagQuiet {
|
|
fmt.Printf("forgot %s (its files stay in %s)\n", args[0], saved[i].Dir)
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
cmd.Flags().StringVar(&configDir, "config-dir", "", "where the settings are saved")
|
|
cmd.Flags().BoolVar(&del, "delete", false, "delete the certificate on jarld too")
|
|
cmd.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask")
|
|
return cmd
|
|
}
|
|
|
|
// Units written by jarl cert schedule.
|
|
const (
|
|
scheduleUnit = "jarl-cert-get"
|
|
unitDir = "/etc/systemd/system"
|
|
)
|
|
|
|
func scheduleUnits(bin, configDir string) (service, timer string) {
|
|
service = fmt.Sprintf(`# Written by 'jarl cert schedule'; 'jarl cert schedule --remove' removes it.
|
|
[Unit]
|
|
Description=Fetch certificates from Jarl (jarl cert get)
|
|
Wants=network-online.target
|
|
After=network-online.target
|
|
|
|
[Service]
|
|
Type=oneshot
|
|
ExecStart=%s cert get --config-dir %s
|
|
`, bin, configDir)
|
|
timer = `# Written by 'jarl cert schedule'; 'jarl cert schedule --remove' removes it.
|
|
[Unit]
|
|
Description=Fetch certificates from Jarl daily
|
|
|
|
[Timer]
|
|
OnCalendar=daily
|
|
RandomizedDelaySec=1h
|
|
Persistent=true
|
|
|
|
[Install]
|
|
WantedBy=timers.target
|
|
`
|
|
return service, timer
|
|
}
|
|
|
|
func certScheduleCmd() *cobra.Command {
|
|
var remove, noSystemctl bool
|
|
var dir string
|
|
cmd := &cobra.Command{
|
|
Use: "schedule",
|
|
Short: "Run jarl cert get daily with a systemd timer (Linux, as root)",
|
|
Long: `Writes /etc/systemd/system/jarl-cert-get.service and .timer, which run
|
|
'jarl cert get' once a day for every certificate saved in /etc/jarl/cert-get,
|
|
and enables the timer. jarld renews certificates well before they expire;
|
|
the daily run downloads a renewed one and runs its hook.
|
|
|
|
Linux with systemd only, as root. Elsewhere, run 'jarl cert get' daily
|
|
yourself (cron, launchd, Task Scheduler).`,
|
|
Example: ` sudo jarl cert schedule
|
|
systemctl list-timers jarl-cert-get.timer
|
|
sudo jarl cert schedule --remove`,
|
|
Args: cobra.NoArgs,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
if runtime.GOOS != "linux" {
|
|
return fmt.Errorf("jarl cert schedule writes systemd units, so it only works on Linux; run 'jarl cert get' daily with this system's scheduler")
|
|
}
|
|
if dir == unitDir && os.Geteuid() != 0 {
|
|
return errors.New("jarl cert schedule writes to /etc/systemd/system: run it as root (sudo)")
|
|
}
|
|
service := filepath.Join(dir, scheduleUnit+".service")
|
|
timer := filepath.Join(dir, scheduleUnit+".timer")
|
|
systemctl := func(a ...string) error {
|
|
if noSystemctl {
|
|
return nil
|
|
}
|
|
out, err := exec.Command("systemctl", a...).CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("systemctl %s: %v: %s", strings.Join(a, " "), err, strings.TrimSpace(string(out)))
|
|
}
|
|
return nil
|
|
}
|
|
if remove {
|
|
_ = systemctl("disable", "--now", scheduleUnit+".timer")
|
|
for _, f := range []string{timer, service} {
|
|
if err := os.Remove(f); err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
return err
|
|
}
|
|
}
|
|
if err := systemctl("daemon-reload"); err != nil {
|
|
return err
|
|
}
|
|
if !flagQuiet {
|
|
fmt.Println("removed the timer; saved certificates stay in", certConfigDir(""))
|
|
}
|
|
return nil
|
|
}
|
|
bin, err := os.Executable()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if b, err := filepath.EvalSymlinks(bin); err == nil {
|
|
bin = b
|
|
}
|
|
cdir := certConfigDir("")
|
|
saved, err := loadSaved(cdir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(saved) == 0 {
|
|
fmt.Fprintf(os.Stderr, "note: no certificates are saved in %s yet; get one with: jarl cert get NAME -d DOMAIN\n", cdir)
|
|
}
|
|
svc, tmr := scheduleUnits(bin, cdir)
|
|
if err := writeAtomic(service, []byte(svc), 0o644); err != nil {
|
|
return err
|
|
}
|
|
if err := writeAtomic(timer, []byte(tmr), 0o644); err != nil {
|
|
return err
|
|
}
|
|
if err := systemctl("daemon-reload"); err != nil {
|
|
return err
|
|
}
|
|
if err := systemctl("enable", "--now", scheduleUnit+".timer"); err != nil {
|
|
return err
|
|
}
|
|
if !flagQuiet {
|
|
fmt.Printf("wrote %s and %s; the timer runs jarl cert get daily (systemctl list-timers %s.timer)\n", service, timer, scheduleUnit)
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
cmd.Flags().BoolVar(&remove, "remove", false, "disable and remove the timer")
|
|
cmd.Flags().StringVar(&dir, "unit-dir", unitDir, "where to write the units")
|
|
cmd.Flags().BoolVar(&noSystemctl, "no-systemctl", false, "only write the units")
|
|
_ = cmd.Flags().MarkHidden("unit-dir")
|
|
_ = cmd.Flags().MarkHidden("no-systemctl")
|
|
return cmd
|
|
}
|