Certificates for other hosts: jarl cert get
jarld obtains and renews a certificate for another host; the host asks
for it with one command, waits, downloads it when it is newer than its
copy and runs a hook with the paths in JARL_CERT_* variables.
- Certificates have an owner user; another user can't take the name.
An existing one is obtained again only when a name is added or
removed. A new certificates grant (names=) says which names a user
may have; it is never part of "*". Users may own
certificates.max_per_user certificates (10) or their own cert_limit.
- POST /certificates/{name}/request; owners may list, show, export and
delete theirs; keys only over HTTPS, the socket or loopback; every
download is recorded and shown as "Pulled by".
- CLI: jarl cert get (saved settings, all saved when no name), cert
saved, cert forget, cert schedule (daily systemd timer, Linux, root).
- Migration 14. Tests: server, auth, and e2e against Pebble.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b4bb99b482
commit
9db76a113c
29 files changed
+1568
-40
No files matched your search
@@ -378,6 +378,9 @@ func printUser(w io.Writer, u api.User) {
|
||||
if u.Comment != "" {
|
||||
fmt.Fprintf(w, "Comment:\t%s\n", u.Comment)
|
||||
}
|
||||
if u.CertLimit != nil {
|
||||
fmt.Fprintf(w, "Certificates:\tat most %d (jarl cert get)\n", *u.CertLimit)
|
||||
}
|
||||
fmt.Fprintf(w, "Last login:\t%s\n", fmtTime(u.LastLoginAt))
|
||||
}
|
||||
|
||||
@@ -444,6 +447,7 @@ func userCmd() *cobra.Command {
|
||||
}
|
||||
var admin, noPassword, disabled bool
|
||||
var role, comment, rename string
|
||||
var certLimit int
|
||||
var grants []string
|
||||
userBody := func(cmd *cobra.Command) (api.UserInput, error) {
|
||||
var in api.UserInput
|
||||
@@ -466,6 +470,9 @@ func userCmd() *cobra.Command {
|
||||
if f.Changed("rename") {
|
||||
in.Username = &rename
|
||||
}
|
||||
if f.Changed("cert-limit") {
|
||||
in.CertLimit = &certLimit
|
||||
}
|
||||
return in, nil
|
||||
}
|
||||
addUserFlags := func(c *cobra.Command) {
|
||||
@@ -473,6 +480,7 @@ func userCmd() *cobra.Command {
|
||||
c.Flags().StringVar(&role, "role", "", "replace the grants with a role: readonly, dns-editor, operator or acme")
|
||||
c.Flags().StringArrayVar(&grants, "grant", nil, `grant (repeatable), e.g. "records:write zones=example.com"`)
|
||||
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)")
|
||||
}
|
||||
create := &cobra.Command{
|
||||
Use: "create NAME",
|
||||
|
||||
@@ -0,0 +1,718 @@
|
||||
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)
|
||||
}
|
||||
|
||||
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 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.
|
||||
|
||||
--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
|
||||
}
|
||||
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, 6 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.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
|
||||
}
|
||||
+22
-1
@@ -50,6 +50,13 @@ type certInfo struct {
|
||||
Chain string `json:"chain"`
|
||||
Key string `json:"key"`
|
||||
} `json:"files,omitempty"`
|
||||
Owner string `json:"owner,omitempty"`
|
||||
PulledBy []struct {
|
||||
Who string `json:"who"`
|
||||
Serial string `json:"serial"`
|
||||
IP string `json:"ip"`
|
||||
At time.Time `json:"at"`
|
||||
} `json:"pulled_by,omitempty"`
|
||||
}
|
||||
|
||||
type certAccount struct {
|
||||
@@ -102,7 +109,7 @@ used too (--cert-file and --key-file): Jarl rereads it when it changes.`,
|
||||
jarl cert update ns1 --challenge porkbun # pin the route (auto, jarl or porkbun)
|
||||
jarl cert export ns1 --dir /etc/ssl/ns1 # fullchain.pem and privkey.pem, for another program`,
|
||||
}
|
||||
cmd.AddCommand(certListCmd(), certShowCmd(), certCreateCmd(), certUpdateCmd(), certIssueCmd(), certCheckCmd(), certExportCmd(), certDeleteCmd(), certAccountCmd())
|
||||
cmd.AddCommand(certListCmd(), certShowCmd(), certCreateCmd(), certUpdateCmd(), certIssueCmd(), certCheckCmd(), certExportCmd(), certGetCmd(), certSavedCmd(), certForgetCmd(), certScheduleCmd(), certDeleteCmd(), certAccountCmd())
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -181,6 +188,20 @@ func printCert(w io.Writer, ci certInfo) {
|
||||
if ci.Comment != "" {
|
||||
fmt.Fprintf(w, "Comment:\t%s\n", ci.Comment)
|
||||
}
|
||||
if ci.Owner != "" {
|
||||
fmt.Fprintf(w, "Owner:\t%s (jarl cert get)\n", ci.Owner)
|
||||
}
|
||||
for i, p := range ci.PulledBy {
|
||||
label := ""
|
||||
if i == 0 {
|
||||
label = "Pulled by:"
|
||||
}
|
||||
cur := "current"
|
||||
if p.Serial != ci.Serial {
|
||||
cur = "an older one: not fetched since the renewal"
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s from %s, %s (%s)\n", label, p.Who, dash(p.IP), fmtTime(&p.At), cur)
|
||||
}
|
||||
state := ci.Status.State
|
||||
if ci.Status.Message != "" {
|
||||
state += ": " + ci.Status.Message
|
||||
|
||||
+36
-9
@@ -103,17 +103,30 @@ func saveCLIConfig(c *cliConfig) error {
|
||||
return os.WriteFile(flagConfig, b, 0o600)
|
||||
}
|
||||
|
||||
func newClient() (*client.Client, error) {
|
||||
// connection is the server a command talks to and how: Token may be
|
||||
// "@FILE" (read by open).
|
||||
type connection struct {
|
||||
Server string `json:"server"`
|
||||
Token string `json:"token,omitempty"`
|
||||
Insecure bool `json:"insecure,omitempty"`
|
||||
CACert string `json:"ca_cert,omitempty"`
|
||||
// Name is the named server's name ("" for one given by address).
|
||||
Name string `json:"-"`
|
||||
}
|
||||
|
||||
// resolveConnection works out the connection from the flags, the
|
||||
// environment and the CLI configuration.
|
||||
func resolveConnection() (connection, error) {
|
||||
cfg, err := loadCLIConfig()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading %s: %w", flagConfig, err)
|
||||
return connection{}, fmt.Errorf("reading %s: %w", flagConfig, err)
|
||||
}
|
||||
ts, err := resolveTargets(cfg, targetSpec(cfg))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return connection{}, err
|
||||
}
|
||||
if len(ts) > 1 {
|
||||
return nil, fmt.Errorf("--server names %d servers; this command works on one", len(ts))
|
||||
return connection{}, fmt.Errorf("--server names %d servers; this command works on one", len(ts))
|
||||
}
|
||||
t := ts[0]
|
||||
// A named server's own token wins: a token from the environment
|
||||
@@ -125,15 +138,21 @@ func newClient() (*client.Client, error) {
|
||||
sock, isSocket := client.SocketPath(t.URL)
|
||||
if isSocket {
|
||||
if err := checkSocket(sock, t.Name == localName && flagServer == "" && os.Getenv("JARL_SERVER") == ""); err != nil {
|
||||
return nil, err
|
||||
return connection{}, err
|
||||
}
|
||||
}
|
||||
if token == "" && !isSocket {
|
||||
if t.Name != "" {
|
||||
return nil, fmt.Errorf("no API token for server %s: use 'jarl --server %s config set token'", t.Name, t.Name)
|
||||
return connection{}, fmt.Errorf("no API token for server %s: use 'jarl --server %s config set token'", t.Name, t.Name)
|
||||
}
|
||||
return nil, fmt.Errorf("no API token: use --token, JARL_TOKEN or 'jarl config set token'")
|
||||
return connection{}, fmt.Errorf("no API token: use --token, JARL_TOKEN or 'jarl config set token'")
|
||||
}
|
||||
return connection{Server: t.URL, Token: token, Insecure: flagInsecure || t.Insecure, CACert: firstNonEmpty(flagCACert, t.CACert), Name: t.Name}, nil
|
||||
}
|
||||
|
||||
// open returns a client for the connection.
|
||||
func (cn connection) open() (*client.Client, error) {
|
||||
token := cn.Token
|
||||
if strings.HasPrefix(token, "@") {
|
||||
b, err := os.ReadFile(token[1:])
|
||||
if err != nil {
|
||||
@@ -141,7 +160,15 @@ func newClient() (*client.Client, error) {
|
||||
}
|
||||
token = strings.TrimSpace(string(b))
|
||||
}
|
||||
c, err := client.New(client.Options{Server: t.URL, Token: token, Insecure: flagInsecure || t.Insecure, CACert: firstNonEmpty(flagCACert, t.CACert)})
|
||||
return client.New(client.Options{Server: cn.Server, Token: token, Insecure: cn.Insecure, CACert: cn.CACert})
|
||||
}
|
||||
|
||||
func newClient() (*client.Client, error) {
|
||||
cn, err := resolveConnection()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c, err := cn.open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -167,7 +194,7 @@ func newClient() (*client.Client, error) {
|
||||
}
|
||||
c.DryRun, c.Force, c.AcceptRisk = flagDryRun || c.Preflight, flagForce, flagRisk
|
||||
if !c.Preflight {
|
||||
c.OnTokenExpiry = tokenExpiryWarning(t.Name)
|
||||
c.OnTokenExpiry = tokenExpiryWarning(cn.Name)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
@@ -318,6 +318,7 @@ func serveCmd() *cobra.Command {
|
||||
EABKeyID: a.cfg.ACME.Account.EABKeyID, EABHMAC: a.cfg.ACME.Account.EABHMAC,
|
||||
Resolvers: a.cfg.ACME.Resolvers, CABundle: a.cfg.ACME.CABundle, CheckPropagation: a.cfg.ACME.CheckPropagation,
|
||||
Dir: a.cfg.CertDir(), Version: version.String(), Porkbun: pb, PorkbunAllows: a.cfg.Porkbun.Allows,
|
||||
MaxPerUser: a.cfg.Certificates.MaxPerUser,
|
||||
}, a.store, a.eng, a.log)
|
||||
cm.Start(ctx)
|
||||
if a.cfg.ACME.Account.EABHMAC != "" {
|
||||
|
||||
Reference in new issue
Block a user