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
+1567
-39
No files matched your search
@@ -8,6 +8,21 @@ All notable changes to Jarl are documented here. The format follows
|
||||
|
||||
### Added
|
||||
|
||||
- Certificates for other hosts: `jarl cert get NAME -d DOMAIN --dir DIR
|
||||
--hook CMD` on a mail server or proxy has jarld obtain the certificate
|
||||
(and renew it), waits, downloads it when it is newer than the host's
|
||||
copy, and runs the hook with the file paths in `JARL_CERT_*`
|
||||
variables. Its settings are saved, so `jarl cert get` alone refreshes
|
||||
them all; `jarl cert schedule` writes a daily systemd timer for that;
|
||||
`jarl cert saved` lists them with their expiry; `jarl cert forget`
|
||||
stops one. A certificate belongs to the user that asked for it (other
|
||||
users can't take the name), is obtained again only when a name is
|
||||
added or removed, and the Certificates page shows its owner and who
|
||||
downloaded it, flagging hosts that haven't fetched a renewal. A new
|
||||
`certificates` grant (`certificates:write names=*.adbraun.lol`) 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`. API: `POST /certificates/{name}/request`.
|
||||
- Certificates can be exported for use outside Jarl: **Export…** on the
|
||||
Certificates page (download, or copy to the clipboard),
|
||||
`jarl cert export NAME [--dir DIR | --part PART]`
|
||||
@@ -27,6 +42,10 @@ All notable changes to Jarl are documented here. The format follows
|
||||
|
||||
### Changed
|
||||
|
||||
- A certificate's private key is only exported over HTTPS, the local
|
||||
socket, or to the server itself (plain HTTP from another host is
|
||||
refused with `https_required`).
|
||||
|
||||
- The Certificates page's "Used for" column says DoT and DoH for named,
|
||||
with the addresses in the tooltip, instead of listing every address. A
|
||||
certificate with no use says "not used by Jarl" rather than "nothing
|
||||
@@ -97,6 +116,12 @@ All notable changes to Jarl are documented here. The format follows
|
||||
the zone now keep the dot, and the name server is only sent when it was
|
||||
changed.
|
||||
|
||||
### Upgrading
|
||||
|
||||
- The database gains a migration (certificate owners, per-user
|
||||
certificate limits, certificate downloads). Going back to an older
|
||||
version afterwards means restoring a backup made before the upgrade.
|
||||
|
||||
## [0.6.0] - 2026-10-13
|
||||
|
||||
### Added
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
+2
-1
@@ -604,6 +604,7 @@ Administrators only. See [certificates.md](certificates.md#api).
|
||||
| GET, PATCH, DELETE | `/certificates/{name}` | |
|
||||
| POST | `/certificates/{name}/issue` | 202: being obtained (poll until `issuing_since` is gone); a file certificate is reread (200) |
|
||||
| POST | `/certificates/{name}/check` | the routes of a certificate's names |
|
||||
| POST | `/certificates/{name}/request` | `{"domains": [...], "key_type": "ec256"}`: a certificate for another host, owned by the caller's user (needs `certificates:write` for every name). New: created and obtained, 202 (poll `GET /certificates/{name}` until `issuing_since` is gone). Existing: must be the caller's (else 409); obtained again only when its names change. Answers `certificate`, `created`, `changed` ([certificates.md](certificates.md#certificates-for-other-hosts)) |
|
||||
| GET | `/certificates/{name}/pem?part=PART` | the certificate as PEM: `fullchain` (default), `cert`, `key`, `bundle`; key exports audited ([certificates.md](certificates.md#using-a-certificate-outside-jarl)) |
|
||||
|
||||
`GET /status` includes `certificates` for administrators: `total`,
|
||||
@@ -631,7 +632,7 @@ Success is `200 {"ok": true, "fqdn": ..., "changeset_id": ..., "zones": [...]}`.
|
||||
|--------|------|------|
|
||||
| GET | `/users` | admin |
|
||||
| POST | `/users` | `{"username": "alice", "password": "...", "admin": false, "role": "dns-editor", "grant_strings": ["records:write zones=lab.adbraun.lol"], "comment": ""}` (`password: ""` = service account) |
|
||||
| GET / PATCH / DELETE | `/users/{id}` | PATCH also takes `disabled`, and `username` to rename. Users have `totp_enabled_at` (two-factor login on since) and `totp_required` |
|
||||
| GET / PATCH / DELETE | `/users/{id}` | PATCH also takes `disabled`, `username` to rename, and `cert_limit` (certificates the user may own through `jarl cert get`; negative: the `certificates.max_per_user` default). Users have `totp_enabled_at` (two-factor login on since) and `totp_required` |
|
||||
| GET / POST | `/users/{id}/tokens` | admin (or yourself) |
|
||||
| GET / POST | `/me/tokens` | `{"name": "laptop", "role": "", "grant_strings": [...], "expires_in": "90d"}`. The response's `token` field is the secret, shown once |
|
||||
| GET | `/tokens` | every token (admin); `?all=1` includes revoked tokens |
|
||||
|
||||
+75
-5
@@ -231,13 +231,83 @@ jarl cert export mail --part bundle > /etc/haproxy/mail.pem
|
||||
|
||||
The parts are `fullchain` (the certificate and its chain), `cert` (the
|
||||
certificate alone), `key` and `bundle` (the key, then the chain, in one
|
||||
file, as HAProxy wants). Only administrators can export, and every export
|
||||
of the key is in the audit log (`certificate.export_key`).
|
||||
file, as HAProxy wants). Administrators can export any certificate, and
|
||||
the owner of a certificate for another host (below) their own. Every
|
||||
export of the key is in the audit log (`certificate.export_key`), and a key
|
||||
only goes over HTTPS, the local socket, or to this server itself.
|
||||
|
||||
Jarl renews the certificate, not your copy: export it again after each
|
||||
renewal (`jarl cert show` says when that is), for example from a daily
|
||||
cron job. A program on this server can instead read the files Jarl writes
|
||||
for named (use `dns`), which Jarl keeps current.
|
||||
renewal (`jarl cert show` says when that is). For another host, `jarl
|
||||
cert get` (below) does that for you. A program on this server can instead
|
||||
read the files Jarl writes for named (use `dns`), which Jarl keeps current.
|
||||
|
||||
## Certificates for other hosts
|
||||
|
||||
`jarl cert get` gets a certificate onto another host (a mail server, a
|
||||
reverse proxy) with one command: jarld obtains it from the CA and renews
|
||||
it, answering the challenges in its own zones as for its own certificates;
|
||||
the host asks for it, waits until it is there, downloads it and runs a
|
||||
hook. The host needs only a token, not an ACME client or access to DNS.
|
||||
|
||||
**On Jarl**, as an administrator, give the hosts a user and a token each:
|
||||
|
||||
```sh
|
||||
jarl user create hosts --no-password --grant "certificates:write names=*.adbraun.lol"
|
||||
jarl token create mx --user hosts # one token per host
|
||||
```
|
||||
|
||||
The `certificates` grant says which names the user may have certificates
|
||||
for. It is never part of `*`, so `operator` and `readonly` users can't
|
||||
get certificates (or download keys) unless you add one. A certificate
|
||||
belongs to the user that asked for it: its tokens can download it, other
|
||||
users can't take its name or see it, and the certificates Jarl uses
|
||||
itself are the administrators'. Hosts that should share certificates use
|
||||
the same user (each with its own token, so you can revoke one host
|
||||
alone); hosts that shouldn't, use different users. A user may own
|
||||
`certificates.max_per_user` certificates (10 by default), or what `jarl
|
||||
user update NAME --cert-limit N` (or the user's form) sets.
|
||||
|
||||
**On the host:**
|
||||
|
||||
```sh
|
||||
export JARL_SERVER=https://jarl.adbraun.lol:8053 JARL_TOKEN=@/root/jarl.token
|
||||
jarl cert get mail -d mail.adbraun.lol -d smtp.adbraun.lol --hook 'systemctl reload postfix dovecot'
|
||||
sudo jarl cert schedule # a daily systemd timer runs 'jarl cert get'
|
||||
jarl cert saved # what this host has, and when it expires
|
||||
```
|
||||
|
||||
* **Asking again** with the same names returns the certificate jarld has:
|
||||
nothing is obtained, nothing is downloaded until jarld renews it.
|
||||
**Adding or removing a name** gets a new certificate. A different
|
||||
`--key-type` alone doesn't; delete the certificate for that.
|
||||
* **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). Each file is replaced at once, never
|
||||
half written. The key is checked against the certificate before
|
||||
anything is written.
|
||||
* **Saved settings**: the names, `--dir`, `--hook`, the server and the
|
||||
token are saved in `/etc/jarl/cert-get/NAME.json` (as root;
|
||||
`~/.config/jarl/cert-get` otherwise), readable only by their owner.
|
||||
`jarl cert get` without a name does every saved certificate. A token
|
||||
given as `@FILE` is saved as that reference, not copied. `jarl cert
|
||||
forget NAME` stops getting one (`--delete` deletes it on jarld too).
|
||||
* **The hook** runs with `sh` after new files are written, 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). If it fails, `jarl cert get` exits
|
||||
non-zero and runs it again next time. It has 5 minutes.
|
||||
* **`jarl cert schedule`** (Linux with systemd, as root) writes
|
||||
`/etc/systemd/system/jarl-cert-get.service` and `.timer`, which run
|
||||
`jarl cert get` daily at a random time within the hour after midnight,
|
||||
and catch up after the host was off. `--remove` takes them away. On
|
||||
other systems, run `jarl cert get` daily with their scheduler.
|
||||
|
||||
**On the Certificates page**, the administrators see each certificate's
|
||||
owner and who downloaded it: the token, from where, when, and whether
|
||||
that host has the current certificate or hasn't fetched the renewed one
|
||||
yet. `jarl cert show NAME` says the same.
|
||||
|
||||
## Security notes
|
||||
|
||||
|
||||
@@ -448,6 +448,11 @@ jarl cert update ns1 --challenge porkbun # auto, jarl or porkbun
|
||||
jarl cert update ns1 --use dns # --use and --domain replace the list (--use none)
|
||||
jarl cert export mail --dir /etc/ssl/mail # fullchain.pem and privkey.pem, for another program
|
||||
jarl cert export mail --part bundle # one part to stdout: fullchain, cert, key, bundle
|
||||
jarl cert get mail -d mail.adbraun.lol --hook 'systemctl reload postfix' # on another host: get, download, hook
|
||||
jarl cert get # every saved certificate (what the timer runs)
|
||||
jarl cert saved # this host's certificates and their expiry
|
||||
jarl cert forget mail [--delete] # stop getting it here
|
||||
sudo jarl cert schedule [--remove] # daily systemd timer for jarl cert get
|
||||
jarl cert delete ns1
|
||||
jarl cert account # the ACME account (set in jarl.yaml)
|
||||
```
|
||||
|
||||
@@ -101,6 +101,7 @@ jarld restarts.
|
||||
| `acme.ca_bundle` | `""` | PEM file of extra CA certificates to trust for the ACME server (a private CA). |
|
||||
| `acme.check_propagation` | `true` | Wait until every name server of the zone serves a challenge before the CA is told to look (they are asked directly, on port 53). |
|
||||
| `certificates.dir` | `<bind.config_dir>/tls` | Where certificates with the `dns` use are written for named: `<dir>/<name>/fullchain.pem` and `privkey.pem`. |
|
||||
| `certificates.max_per_user` | `10` | How many certificates a user may own through `jarl cert get` ([certificates.md](certificates.md#certificates-for-other-hosts)). A user's own limit (`jarl user update NAME --cert-limit N`) replaces it. |
|
||||
|
||||
### ACME (lego httpreq endpoint)
|
||||
|
||||
|
||||
+7
-2
@@ -118,7 +118,8 @@ A user's permissions are a list of grants. Administrators (`admin: true`)
|
||||
may do everything, and only they can manage users, see everyone's tokens,
|
||||
run `resync`, wipe a zone's DNSSEC keys, use the registrar (Porkbun,
|
||||
whose key can move the domain), and manage TLS certificates (whose keys
|
||||
can impersonate the server; [certificates.md](certificates.md)).
|
||||
can impersonate the server; [certificates.md](certificates.md)). The one
|
||||
exception is certificates for other hosts, below.
|
||||
|
||||
A grant is written as:
|
||||
|
||||
@@ -137,7 +138,8 @@ RESOURCE:ACCESS [zones=PATTERNS] [views=PATTERNS] [names=PATTERNS] [types=TYPES]
|
||||
| `audit` | everyone's audit events and changesets (without it, users see only their own) |
|
||||
| `stats` | query statistics, including client addresses and the names they look up (read only) |
|
||||
| `blocking` | blocklists and block/allow rules ([blocking.md](blocking.md)); `write` also downloads lists now |
|
||||
| `*` | all of the above |
|
||||
| `certificates` | certificates for other hosts (`jarl cert get`), by the names in them: `write` gets one obtained, `read` only downloads it. Never part of `*` |
|
||||
| `*` | all of the above, except `certificates` |
|
||||
|
||||
`ACCESS` is `read` or `write` (write includes read). Filters narrow a grant.
|
||||
Each takes comma separated patterns where `*` and `?` are wildcards, and a
|
||||
@@ -146,6 +148,8 @@ missing filter matches everything:
|
||||
* `zones=adbraun.lol,*.adbraun.lol`: zone names, without the final dot
|
||||
* `views=internal`: view names (`_default` means zones outside any view)
|
||||
* `names=_acme-challenge.*`: record owner names, as FQDNs without the final dot
|
||||
(for `certificates`: the names in the certificate; `*.adbraun.lol` also
|
||||
allows the wildcard certificate `*.adbraun.lol`)
|
||||
* `types=TXT,CNAME`: record types
|
||||
|
||||
Examples:
|
||||
@@ -157,6 +161,7 @@ zones:write zones=*.lab.adbraun.lol create/delete zones be
|
||||
records:write zones=adbraun.lol views=internal only the internal copy of a split zone
|
||||
records:write names=_acme-challenge.* types=TXT ACME challenges anywhere
|
||||
records:write zones=acme.adbraun.lol types=TXT only TXT records in a dedicated ACME zone
|
||||
certificates:write names=mail.adbraun.lol,smtp.adbraun.lol certificates for a mail server (jarl cert get)
|
||||
```
|
||||
|
||||
A user who may see some records of a zone may also see that the zone exists
|
||||
|
||||
@@ -420,6 +420,9 @@ type UserInput struct {
|
||||
Role *string `json:"role,omitempty"` // a role template name
|
||||
Disabled *bool `json:"disabled,omitempty"`
|
||||
Comment *string `json:"comment,omitempty"`
|
||||
// CertLimit is how many certificates the user may own (jarl cert
|
||||
// get); a negative number goes back to certificates.max_per_user.
|
||||
CertLimit *int `json:"cert_limit,omitempty"`
|
||||
}
|
||||
|
||||
// TokenInput creates a token.
|
||||
|
||||
@@ -134,3 +134,23 @@ func TestRoleTemplatesValid(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertificateGrants(t *testing.T) {
|
||||
g, err := ParseGrant("certificates:write names=*.adbraun.lol")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !GrantAllows(g, Action{Resource: ResCerts, Write: true, Name: "mail.adbraun.lol"}) || GrantAllows(g, Action{Resource: ResCerts, Write: true, Name: "mail.example.org"}) {
|
||||
t.Error("names filter")
|
||||
}
|
||||
if _, err := ParseGrant("certificates:read types=TXT"); err == nil {
|
||||
t.Error("types on certificates accepted")
|
||||
}
|
||||
if _, err := ParseGrant("certificates:read zones=adbraun.lol"); err == nil {
|
||||
t.Error("zones on certificates accepted")
|
||||
}
|
||||
// "*" never covers certificates: they hand out private keys.
|
||||
if GrantAllows(model.Grant{Resource: ResAll, Access: Write}, Action{Resource: ResCerts, Name: "mail.adbraun.lol"}) {
|
||||
t.Error("* covers certificates")
|
||||
}
|
||||
}
|
||||
+12
-3
@@ -21,11 +21,14 @@ const (
|
||||
ResAudit = "audit" // audit log and everyone's changesets
|
||||
ResStats = "stats" // query statistics
|
||||
ResBlocking = "blocking" // blocklists and block/allow rules
|
||||
// ResCerts is certificates for other hosts (jarl cert get), by the
|
||||
// names in them. It is never part of "*": it hands out private keys.
|
||||
ResCerts = "certificates"
|
||||
ResAll = "*"
|
||||
)
|
||||
|
||||
// Resources lists the resources in display order.
|
||||
var Resources = []string{ResZones, ResRecords, ResViews, ResACLs, ResTSIG, ResOptions, ResAudit, ResStats, ResBlocking}
|
||||
var Resources = []string{ResZones, ResRecords, ResViews, ResACLs, ResTSIG, ResOptions, ResAudit, ResStats, ResBlocking, ResCerts}
|
||||
|
||||
// Access levels.
|
||||
const (
|
||||
@@ -74,8 +77,11 @@ func ValidateGrant(g *model.Grant) error {
|
||||
for i, t := range g.Types {
|
||||
g.Types[i] = strings.ToUpper(t)
|
||||
}
|
||||
if (len(g.Names) > 0 || len(g.Types) > 0) && g.Resource != ResRecords {
|
||||
return fmt.Errorf("names and types filters only apply to the records resource")
|
||||
if len(g.Types) > 0 && g.Resource != ResRecords {
|
||||
return fmt.Errorf("the types filter only applies to the records resource")
|
||||
}
|
||||
if len(g.Names) > 0 && g.Resource != ResRecords && g.Resource != ResCerts {
|
||||
return fmt.Errorf("the names filter only applies to the records and certificates resources")
|
||||
}
|
||||
if (len(g.Zones) > 0 || len(g.Views) > 0) && g.Resource != ResRecords && g.Resource != ResZones && g.Resource != ResViews {
|
||||
return fmt.Errorf("zone and view filters only apply to the zones, records and views resources")
|
||||
@@ -228,6 +234,9 @@ func GrantAllows(g model.Grant, a Action) bool {
|
||||
if g.Resource != ResAll && g.Resource != a.Resource {
|
||||
return false
|
||||
}
|
||||
if g.Resource == ResAll && a.Resource == ResCerts {
|
||||
return false // only an explicit certificates grant
|
||||
}
|
||||
if a.Write && g.Access != Write {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -52,6 +52,9 @@ type Config struct {
|
||||
PropagationTimeout time.Duration
|
||||
// SkipPropagationCheck is !CheckPropagation (set by New).
|
||||
SkipPropagationCheck bool
|
||||
// MaxPerUser is how many certificates a user may own (Ensure), unless
|
||||
// the user has a limit of their own.
|
||||
MaxPerUser int
|
||||
}
|
||||
|
||||
// Manager keeps certificates current and serves jarld's own.
|
||||
@@ -132,6 +135,8 @@ type Input struct {
|
||||
Challenge *string `json:"challenge"`
|
||||
KeyType *string `json:"key_type"`
|
||||
Comment *string `json:"comment"`
|
||||
// Owner sets the owning user (Ensure only).
|
||||
Owner *int64 `json:"-"`
|
||||
}
|
||||
|
||||
var nameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,62}$`)
|
||||
@@ -210,6 +215,9 @@ func (m *Manager) apply(c *model.Certificate, in Input, creating bool) error {
|
||||
if in.Comment != nil {
|
||||
c.Comment = *in.Comment
|
||||
}
|
||||
if in.Owner != nil {
|
||||
c.OwnerID = *in.Owner
|
||||
}
|
||||
switch c.Source {
|
||||
case model.CertSourceACME:
|
||||
if in.CertFile != nil && *in.CertFile != "" || in.KeyFile != nil && *in.KeyFile != "" {
|
||||
@@ -320,6 +328,103 @@ func (m *Manager) PEM(ctx context.Context, name string) (*model.Certificate, []b
|
||||
return c, []byte(c.CertPEM), []byte(c.KeyPEM), nil
|
||||
}
|
||||
|
||||
// ErrTaken is Ensure's answer for a name another user's certificate (or
|
||||
// the administrators') has.
|
||||
var ErrTaken = errors.New("certificate name taken")
|
||||
|
||||
// LimitFor is how many certificates u may own.
|
||||
func (m *Manager) LimitFor(u *model.User) int {
|
||||
if u != nil && u.CertLimit != nil {
|
||||
return *u.CertLimit
|
||||
}
|
||||
return m.cfg.MaxPerUser
|
||||
}
|
||||
|
||||
// EnsureResult is what Ensure did.
|
||||
type EnsureResult struct {
|
||||
Certificate *model.Certificate
|
||||
Created bool // a new certificate
|
||||
Changed bool // its names changed, so it is obtained again
|
||||
// Ignored says what of the request an existing certificate keeps as
|
||||
// it is (a different key type).
|
||||
Ignored string
|
||||
}
|
||||
|
||||
// Ensure is jarl cert get: the certificate name, owned by p's user, for
|
||||
// domains. A new one is created (within the user's limit) and obtained
|
||||
// at once. An existing one must be the user's: it is obtained again only
|
||||
// when its names change (a name added or removed); otherwise it is
|
||||
// returned as it is, renewed by the loop as usual. The caller checks that
|
||||
// p may have these names.
|
||||
func (m *Manager) Ensure(ctx context.Context, name string, domains []string, keyType string, p *auth.Principal) (*EnsureResult, error) {
|
||||
owner := p.User
|
||||
want, err := normDomains(domains)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c, err := m.Get(ctx, name)
|
||||
switch {
|
||||
case errors.Is(err, ErrNotFound):
|
||||
all, err := m.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n := 0
|
||||
for _, o := range all {
|
||||
if o.OwnerID == owner.ID {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if limit := m.LimitFor(owner); n >= limit {
|
||||
return nil, inputErr("user %s has %d certificates, its limit (an administrator can raise it: jarl user update %s --cert-limit N, or certificates.max_per_user in jarl.yaml)", owner.Username, n, owner.Username)
|
||||
}
|
||||
in := Input{Name: &name, Domains: &want, Owner: &owner.ID, Comment: model.Ptr("for " + owner.Username + " (jarl cert get)")}
|
||||
if keyType != "" {
|
||||
in.KeyType = &keyType
|
||||
}
|
||||
c, err = m.Create(ctx, in, p)
|
||||
if errors.Is(err, ErrConflict) {
|
||||
return nil, ErrTaken // created meanwhile
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := m.IssueAsync(ctx, name, p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c, err = m.Get(ctx, name)
|
||||
return &EnsureResult{Certificate: c, Created: true}, err
|
||||
case err != nil:
|
||||
return nil, err
|
||||
}
|
||||
if c.OwnerID != owner.ID && !(p.IsAdmin() && c.OwnerID == 0) {
|
||||
return nil, ErrTaken
|
||||
}
|
||||
if c.Source != model.CertSourceACME {
|
||||
return nil, inputErr("certificate %s is read from files on the server, so Jarl can't obtain it for you", name)
|
||||
}
|
||||
res := &EnsureResult{Certificate: c}
|
||||
if keyType != "" && keyType != c.KeyType {
|
||||
res.Ignored = "it keeps its key type " + c.KeyType + " (changing it means a new certificate: delete this one, or ask an administrator)"
|
||||
}
|
||||
have := slices.Clone(c.Domains)
|
||||
slices.Sort(have)
|
||||
sorted := slices.Clone(want)
|
||||
slices.Sort(sorted)
|
||||
if !slices.Equal(have, sorted) {
|
||||
if c, err = m.Update(ctx, name, Input{Domains: &want}, p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res.Certificate, res.Changed = c, true
|
||||
}
|
||||
if res.Changed || c.CertPEM == "" && m.Issuing(name).IsZero() && c.Failures == 0 {
|
||||
if _, err := m.IssueAsync(ctx, name, p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// Create adds a certificate. A file-source certificate is read at once;
|
||||
// one Jarl obtains is left for Issue (or the background loop).
|
||||
func (m *Manager) Create(ctx context.Context, in Input, p *auth.Principal) (*model.Certificate, error) {
|
||||
|
||||
@@ -51,6 +51,10 @@ type Certificates struct {
|
||||
// HTTPS): one directory per certificate, readable by bind.file_group.
|
||||
// "" is <bind.config_dir>/tls.
|
||||
Dir string `yaml:"dir"`
|
||||
// MaxPerUser is how many certificates a user may own through jarl
|
||||
// cert get (certificates for other hosts), unless the user has a
|
||||
// limit of their own (jarl user update --cert-limit). Default 10.
|
||||
MaxPerUser int `yaml:"max_per_user"`
|
||||
}
|
||||
|
||||
// Metrics configures the Prometheus endpoint /metrics (docs/metrics.md).
|
||||
@@ -467,6 +471,7 @@ func Default() *Config {
|
||||
Retention: StatsRetention{Minute: Duration(7 * 24 * time.Hour), Hour: Duration(90 * 24 * time.Hour), Day: Duration(730 * 24 * time.Hour), Queries: Duration(7 * 24 * time.Hour)},
|
||||
TopN: 100,
|
||||
},
|
||||
Certificates: Certificates{MaxPerUser: 10},
|
||||
ACME: ACME{HTTPReq: HTTPReq{RequireHTTPS: true}, Account: ACMEAccount{Directory: "letsencrypt"},
|
||||
Resolvers: []string{"1.1.1.1:53", "9.9.9.9:53", "8.8.8.8:53"}, CheckPropagation: true},
|
||||
Blocking: Blocking{Refresh: Duration(24 * time.Hour), MaxDownloadMB: 64, MaxEntries: 1_000_000},
|
||||
|
||||
@@ -62,11 +62,27 @@ type Certificate struct {
|
||||
// LastRoutes says where the challenges went the last time ("adbraun.lol: porkbun").
|
||||
LastRoutes []string `json:"last_routes,omitempty"`
|
||||
|
||||
// OwnerID is the user a certificate for another host belongs to
|
||||
// (jarl cert get); 0 for the administrators' own.
|
||||
OwnerID int64 `json:"owner_id,omitempty"`
|
||||
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
UpdatedBy string `json:"updated_by"`
|
||||
}
|
||||
|
||||
// CertificatePull records that someone downloaded a certificate: who
|
||||
// ("token NAME" or "user NAME"), which one (Serial), when and from where.
|
||||
type CertificatePull struct {
|
||||
CertID int64 `json:"-"`
|
||||
Who string `json:"who"`
|
||||
UserID int64 `json:"user_id,omitempty"`
|
||||
TokenID int64 `json:"token_id,omitempty"`
|
||||
Serial string `json:"serial"`
|
||||
IP string `json:"ip,omitempty"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// HasUse reports whether c is used for use.
|
||||
func (c *Certificate) HasUse(use string) bool { return slices.Contains(c.Uses, use) }
|
||||
@@ -76,6 +76,9 @@ type User struct {
|
||||
TOTPSecret string `json:"-"`
|
||||
TOTPPending string `json:"-"`
|
||||
TOTPLastStep int64 `json:"-"`
|
||||
// CertLimit is how many certificates the user may own (jarl cert get);
|
||||
// nil: certificates.max_per_user in jarl.yaml.
|
||||
CertLimit *int `json:"cert_limit,omitempty"`
|
||||
}
|
||||
|
||||
// HasTOTP reports whether the user logs in with a TOTP code.
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/model"
|
||||
)
|
||||
|
||||
// certUser creates a service user with grants and returns a token.
|
||||
func certUser(t *testing.T, e *apiEnv, name string, grants ...string) (string, *model.User) {
|
||||
t.Helper()
|
||||
gs := []model.Grant{}
|
||||
for _, s := range grants {
|
||||
g, err := auth.ParseGrant(s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gs = append(gs, g)
|
||||
}
|
||||
u := &model.User{Username: name, Grants: gs}
|
||||
if err := e.st.CreateUser(context.Background(), u); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tok, pid, sh := auth.NewToken()
|
||||
if err := e.st.CreateToken(context.Background(), &model.Token{UserID: u.ID, Name: name + "-host", PublicID: pid, SecretHash: sh}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return tok, u
|
||||
}
|
||||
|
||||
// obtained gives a certificate a current certificate, as if the CA had
|
||||
// signed it.
|
||||
func obtained(t *testing.T, e *apiEnv, name string, domains ...string) {
|
||||
t.Helper()
|
||||
certFile, keyFile := selfSigned(t, t.TempDir(), domains...)
|
||||
cb, _ := os.ReadFile(certFile)
|
||||
kb, _ := os.ReadFile(keyFile)
|
||||
ctx := context.Background()
|
||||
for i := 0; i < 50 && e.srv.cfg.Certs.Issuing(name) != (time.Time{}); i++ {
|
||||
time.Sleep(20 * time.Millisecond) // the failing attempt (no CA) ends
|
||||
}
|
||||
c, err := e.st.CertificateByName(ctx, name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
na, nb := time.Now().Add(30*24*time.Hour), time.Now()
|
||||
c.CertPEM, c.KeyPEM, c.NotAfter, c.NotBefore, c.Serial, c.Failures, c.LastError = string(cb), string(kb), &na, &nb, "s-"+name, 0, ""
|
||||
if err := e.st.UpdateCertificate(ctx, c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertGet(t *testing.T) {
|
||||
e := newAPIEnv(t)
|
||||
withCerts(e)
|
||||
mx, mxUser := certUser(t, e, "mx", "certificates:write names=*.adbraun.lol")
|
||||
other, _ := certUser(t, e, "other", "certificates:write names=*.adbraun.lol")
|
||||
wide, _ := certUser(t, e, "wide", "*:write")
|
||||
|
||||
req := func(tok, name string, domains ...string) (map[string]any, int) {
|
||||
b, status := e.req("POST", "/certificates/"+name+"/request", tok, map[string]any{"domains": domains})
|
||||
var out map[string]any
|
||||
_ = jsonUnmarshal(b, &out)
|
||||
return out, status
|
||||
}
|
||||
// "*" doesn't cover certificates.
|
||||
if _, st := req(wide, "mail", "mail.adbraun.lol"); st != 403 {
|
||||
t.Errorf("*:write: %d", st)
|
||||
}
|
||||
// Names outside the grant.
|
||||
if out, st := req(mx, "mail", "mail.example.org"); st != 403 || !strings.Contains(errMsg(out), "mail.example.org") {
|
||||
t.Errorf("other names: %d %v", st, out)
|
||||
}
|
||||
// A new certificate, owned by mx.
|
||||
if out, st := req(mx, "mail", "mail.adbraun.lol"); st != 200 && st != 202 || out["created"] != true {
|
||||
t.Fatalf("create: %d %v", st, out)
|
||||
}
|
||||
c, _ := e.st.CertificateByName(context.Background(), "mail")
|
||||
if c.OwnerID != mxUser.ID || len(c.Uses) != 0 {
|
||||
t.Errorf("owner %d uses %v", c.OwnerID, c.Uses)
|
||||
}
|
||||
// Another user can't have the name, or see it.
|
||||
if out, st := req(other, "mail", "mail.adbraun.lol"); st != 409 || !strings.Contains(errMsg(out), "taken") {
|
||||
t.Errorf("taken: %d %v", st, out)
|
||||
}
|
||||
e.do("GET", "/certificates/mail", other, nil, 404)
|
||||
if l := e.do("GET", "/certificates", other, nil, 200)["certificates"].([]any); len(l) != 0 {
|
||||
t.Errorf("other's list: %v", l)
|
||||
}
|
||||
// Nothing to download until it is obtained.
|
||||
e.do("GET", "/certificates/mail/pem", mx, nil, 400)
|
||||
obtained(t, e, "mail", "mail.adbraun.lol")
|
||||
|
||||
// The same names: the same certificate.
|
||||
if out, st := req(mx, "mail", "mail.adbraun.lol"); st != 200 || out["created"] != false || out["changed"] != false {
|
||||
t.Errorf("again: %d %v", st, out)
|
||||
}
|
||||
if c2, _ := e.st.CertificateByName(context.Background(), "mail"); c2.Serial != "s-mail" {
|
||||
t.Errorf("replaced: %v", c2.Serial)
|
||||
}
|
||||
// Download: recorded, with the token.
|
||||
if b, st := e.req("GET", "/certificates/mail/pem?part=key", mx, nil); st != 200 || !strings.Contains(string(b), "PRIVATE KEY") {
|
||||
t.Errorf("key: %d", st)
|
||||
}
|
||||
got := e.do("GET", "/certificates/mail", e.token, nil, 200)
|
||||
pulls, _ := got["pulled_by"].([]any)
|
||||
if got["owner"] != "mx" || len(pulls) != 1 || !strings.Contains(pulls[0].(map[string]any)["who"].(string), "mx-host") || pulls[0].(map[string]any)["serial"] != "s-mail" {
|
||||
t.Errorf("owner %v pulled_by %v", got["owner"], got["pulled_by"])
|
||||
}
|
||||
// A name added: obtained again.
|
||||
if out, st := req(mx, "mail", "mail.adbraun.lol", "smtp.adbraun.lol"); out["changed"] != true || st != 200 && st != 202 {
|
||||
t.Errorf("SAN added: %d %v", st, out)
|
||||
}
|
||||
// The owner's own list.
|
||||
if l := e.do("GET", "/certificates", mx, nil, 200)["certificates"].([]any); len(l) != 1 {
|
||||
t.Errorf("mx's list: %v", l)
|
||||
}
|
||||
// Jarl's own certificates are the administrators'.
|
||||
certFile, keyFile := selfSigned(t, t.TempDir(), "jarl.adbraun.lol")
|
||||
e.do("POST", "/certificates", e.token, map[string]any{"name": "web", "source": "file", "cert_file": certFile, "key_file": keyFile}, 201)
|
||||
if _, st := req(mx, "web", "jarl.adbraun.lol"); st != 409 {
|
||||
t.Errorf("Jarl's own: %d", st)
|
||||
}
|
||||
e.do("GET", "/certificates/web/pem", mx, nil, 404)
|
||||
// The limit: one more for mx, then no more.
|
||||
limit := 2
|
||||
mxUser.CertLimit = &limit
|
||||
if err := e.st.UpdateUser(context.Background(), mxUser); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out, st := req(mx, "www", "www.adbraun.lol"); st != 200 && st != 202 {
|
||||
t.Errorf("second: %d %v", st, out)
|
||||
}
|
||||
if out, st := req(mx, "third", "x.adbraun.lol"); st != 400 || !strings.Contains(errMsg(out), "limit") {
|
||||
t.Errorf("over the limit: %d %v", st, out)
|
||||
}
|
||||
// The owner may delete theirs.
|
||||
e.do("DELETE", "/certificates/www", mx, nil, 200)
|
||||
}
|
||||
|
||||
func jsonUnmarshal(b []byte, v any) error { return json.Unmarshal(b, v) }
|
||||
|
||||
// errMsg is an error answer's message.
|
||||
func errMsg(out map[string]any) string {
|
||||
e, _ := out["error"].(map[string]any)
|
||||
m, _ := e["message"].(string)
|
||||
return m
|
||||
}
|
||||
|
||||
// A private key goes back only over HTTPS or to this server itself.
|
||||
func TestKeyTransport(t *testing.T) {
|
||||
e := newAPIEnv(t)
|
||||
for addr, want := range map[string]bool{"127.0.0.1:5000": true, "[::1]:5000": true, "192.0.2.7:5000": false} {
|
||||
r, _ := http.NewRequest("GET", "/x", nil)
|
||||
r.RemoteAddr = addr
|
||||
if ok, why := e.srv.keyTransport(r); ok != want {
|
||||
t.Errorf("%s: %v (%s)", addr, ok, why)
|
||||
}
|
||||
}
|
||||
r, _ := http.NewRequest("GET", "/x", nil)
|
||||
r.RemoteAddr, r.TLS = "192.0.2.7:5000", &tls.ConnectionState{}
|
||||
if ok, _ := e.srv.keyTransport(r); !ok {
|
||||
t.Error("HTTPS refused")
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,7 @@ func selfSigned(t *testing.T, dir string, names ...string) (certFile, keyFile st
|
||||
|
||||
func withCerts(env *apiEnv) *certs.Manager {
|
||||
quiet := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
m := certs.New(certs.Config{Dir: filepath.Join(env.t.TempDir(), "tls"), Directory: "https://acme.invalid/dir"}, env.st, env.srv.eng, quiet)
|
||||
m := certs.New(certs.Config{Dir: filepath.Join(env.t.TempDir(), "tls"), Directory: "https://acme.invalid/dir", MaxPerUser: 10}, env.st, env.srv.eng, quiet)
|
||||
env.srv.cfg.Certs = m
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -444,6 +444,12 @@ func applyUserInput(u *model.User, in *api.UserInput) error {
|
||||
if in.Comment != nil {
|
||||
u.Comment = *in.Comment
|
||||
}
|
||||
if in.CertLimit != nil {
|
||||
u.CertLimit = in.CertLimit
|
||||
if *in.CertLimit < 0 {
|
||||
u.CertLimit = nil
|
||||
}
|
||||
}
|
||||
if in.Password != nil {
|
||||
if *in.Password == "" {
|
||||
u.PasswordHash = "" // service account: tokens only
|
||||
|
||||
+194
-6
@@ -4,7 +4,11 @@ import (
|
||||
"context"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"jarl/internal/api"
|
||||
@@ -28,6 +32,10 @@ type certView struct {
|
||||
NamedUse []string `json:"named_use,omitempty"`
|
||||
// IssuingSince is set while Jarl is obtaining it.
|
||||
IssuingSince *time.Time `json:"issuing_since,omitempty"`
|
||||
// Owner is the user a certificate for another host belongs to; PulledBy
|
||||
// who downloaded it (list and get, administrators only).
|
||||
Owner string `json:"owner,omitempty"`
|
||||
PulledBy []model.CertificatePull `json:"pulled_by,omitempty"`
|
||||
}
|
||||
|
||||
type certFiles struct {
|
||||
@@ -62,6 +70,78 @@ func (s *Server) certsReady(p *auth.Principal) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// certsAccess lets in administrators and users with a certificates grant
|
||||
// (certificates for other hosts: their own only).
|
||||
func (s *Server) certsAccess(p *auth.Principal) error {
|
||||
if s.cfg.Certs == nil {
|
||||
return &httpError{http.StatusNotFound, "disabled", "certificates aren't available in this jarld"}
|
||||
}
|
||||
if p.IsAdmin() || p.Can(auth.Action{Resource: auth.ResCerts}) {
|
||||
return nil
|
||||
}
|
||||
return forbidden("certificates need an administrator, or a certificates grant for their names (certificates:write names=*.example.com)")
|
||||
}
|
||||
|
||||
// certAllowed reports whether p may see (or, with write, change) c: an
|
||||
// administrator any; anyone else their own, while their grants cover all
|
||||
// its names.
|
||||
func certAllowed(p *auth.Principal, c *model.Certificate, write bool) bool {
|
||||
if p.IsAdmin() {
|
||||
return true
|
||||
}
|
||||
if p.User == nil || c.OwnerID == 0 || c.OwnerID != p.User.ID {
|
||||
return false
|
||||
}
|
||||
for _, d := range c.Domains {
|
||||
if !p.Can(auth.Action{Resource: auth.ResCerts, Write: write, Name: d}) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// certFor returns the certificate in the path, if p may see it (else not
|
||||
// found, so names of others' certificates don't leak).
|
||||
func (s *Server) certFor(r *http.Request, p *auth.Principal, write bool) (*model.Certificate, error) {
|
||||
c, err := s.cfg.Certs.Get(r.Context(), r.PathValue("name"))
|
||||
if err != nil {
|
||||
return nil, certErr(err)
|
||||
}
|
||||
if !certAllowed(p, c, write) {
|
||||
if write && certAllowed(p, c, false) {
|
||||
return nil, forbidden("you may only download certificate %s", c.Name)
|
||||
}
|
||||
return nil, notFound("no such certificate")
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// withOwners adds owners and downloads to views (for administrators).
|
||||
func (s *Server) withOwners(ctx context.Context, views []certView) error {
|
||||
pulls, err := s.store.CertificatePulls(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
users, err := s.store.ListUsers(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
names := map[int64]string{}
|
||||
for _, u := range users {
|
||||
names[u.ID] = u.Username
|
||||
}
|
||||
for i := range views {
|
||||
if id := views[i].OwnerID; id != 0 {
|
||||
views[i].Owner = names[id]
|
||||
if views[i].Owner == "" {
|
||||
views[i].Owner = fmt.Sprintf("deleted user %d", id)
|
||||
}
|
||||
}
|
||||
views[i].PulledBy = pulls[views[i].ID]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func certErr(err error) error {
|
||||
var ie *certs.InputError
|
||||
switch {
|
||||
@@ -71,13 +151,15 @@ func certErr(err error) error {
|
||||
return notFound("no such certificate")
|
||||
case errors.Is(err, certs.ErrConflict):
|
||||
return conflict("a certificate with that name exists")
|
||||
case errors.Is(err, certs.ErrTaken):
|
||||
return conflict("the certificate name is taken by another user's certificate (or one Jarl uses): choose another name")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// certList: GET /certificates.
|
||||
func (s *Server) certList(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := s.certsReady(p); err != nil {
|
||||
if err := s.certsAccess(p); err != nil {
|
||||
return err
|
||||
}
|
||||
all, err := s.cfg.Certs.List(r.Context())
|
||||
@@ -90,22 +172,32 @@ func (s *Server) certList(w http.ResponseWriter, r *http.Request, p *auth.Princi
|
||||
}
|
||||
out := []certView{}
|
||||
for _, c := range all {
|
||||
if !certAllowed(p, c, false) {
|
||||
continue
|
||||
}
|
||||
v := s.presentCert(c)
|
||||
v.NamedUse = encdns.CertUses(st, s.cfg.Certs.FilesDir(c.Name))
|
||||
out = append(out, v)
|
||||
}
|
||||
if !p.IsAdmin() {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"certificates": out})
|
||||
return nil
|
||||
}
|
||||
if err := s.withOwners(r.Context(), out); err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"certificates": out, "account": s.cfg.Certs.AccountInfo(r.Context())})
|
||||
return nil
|
||||
}
|
||||
|
||||
// certGet: GET /certificates/{name}.
|
||||
func (s *Server) certGet(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := s.certsReady(p); err != nil {
|
||||
if err := s.certsAccess(p); err != nil {
|
||||
return err
|
||||
}
|
||||
c, err := s.cfg.Certs.Get(r.Context(), r.PathValue("name"))
|
||||
c, err := s.certFor(r, p, false)
|
||||
if err != nil {
|
||||
return certErr(err)
|
||||
return err
|
||||
}
|
||||
st, err := s.store.LoadState(r.Context())
|
||||
if err != nil {
|
||||
@@ -113,22 +205,80 @@ func (s *Server) certGet(w http.ResponseWriter, r *http.Request, p *auth.Princip
|
||||
}
|
||||
v := s.presentCert(c)
|
||||
v.NamedUse = encdns.CertUses(st, s.cfg.Certs.FilesDir(c.Name))
|
||||
if p.IsAdmin() {
|
||||
vs := []certView{v}
|
||||
if err := s.withOwners(r.Context(), vs); err != nil {
|
||||
return err
|
||||
}
|
||||
v = vs[0]
|
||||
}
|
||||
writeJSON(w, http.StatusOK, v)
|
||||
return nil
|
||||
}
|
||||
|
||||
// certRequest: POST /certificates/{name}/request {"domains": [...],
|
||||
// "key_type": "ec256"}, for jarl cert get: the certificate for another
|
||||
// host, owned by the caller's user. A new one is created and obtained
|
||||
// (202 while that runs; poll GET /certificates/{name} until
|
||||
// issuing_since is gone). An existing one is the caller's, obtained again
|
||||
// only when its names change; otherwise it is returned as it is (200).
|
||||
func (s *Server) certRequest(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := s.certsAccess(p); err != nil {
|
||||
return err
|
||||
}
|
||||
if p.User == nil || p.User.ID == 0 {
|
||||
return forbidden("certificates for other hosts belong to a user: use an API token, not the local socket as root")
|
||||
}
|
||||
var in struct {
|
||||
Domains []string `json:"domains"`
|
||||
KeyType string `json:"key_type"`
|
||||
}
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, d := range in.Domains {
|
||||
d = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(d), "."))
|
||||
if !p.Can(auth.Action{Resource: auth.ResCerts, Write: true, Name: d}) {
|
||||
return forbidden("you may not get a certificate for %s: that needs certificates:write with a names pattern that matches it", d)
|
||||
}
|
||||
}
|
||||
res, err := s.cfg.Certs.Ensure(r.Context(), r.PathValue("name"), in.Domains, in.KeyType, p)
|
||||
if err != nil {
|
||||
return certErr(err)
|
||||
}
|
||||
v := s.presentCert(res.Certificate)
|
||||
out := map[string]any{"certificate": v, "created": res.Created, "changed": res.Changed}
|
||||
if res.Ignored != "" {
|
||||
out["warnings"] = []string{res.Ignored}
|
||||
}
|
||||
status := http.StatusOK
|
||||
if v.IssuingSince != nil {
|
||||
status = http.StatusAccepted
|
||||
}
|
||||
writeJSON(w, status, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
// certPEM: GET /certificates/{name}/pem?part=fullchain|cert|key|bundle,
|
||||
// the certificate as PEM for use elsewhere: the chain (default), the
|
||||
// certificate alone, the private key, or the key followed by the chain
|
||||
// (one file, as HAProxy wants). Anything with the key is audited.
|
||||
func (s *Server) certPEM(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := s.certsReady(p); err != nil {
|
||||
if err := s.certsAccess(p); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.certFor(r, p, false); err != nil {
|
||||
return err
|
||||
}
|
||||
part := r.URL.Query().Get("part")
|
||||
if part == "" {
|
||||
part = "fullchain"
|
||||
}
|
||||
if part == "key" || part == "bundle" {
|
||||
if ok, why := s.keyTransport(r); !ok {
|
||||
return &httpError{http.StatusForbidden, "https_required", "a private key is only handed out over HTTPS (or to this server itself), and this request came by " + why}
|
||||
}
|
||||
}
|
||||
c, chain, key, err := s.cfg.Certs.PEM(r.Context(), r.PathValue("name"))
|
||||
if err != nil {
|
||||
return certErr(err)
|
||||
@@ -155,6 +305,16 @@ func (s *Server) certPEM(w http.ResponseWriter, r *http.Request, p *auth.Princip
|
||||
if part == "key" || part == "bundle" {
|
||||
s.eng.Audit(r.Context(), p, "certificate.export_key", "certificate", c.ID, c.Name, "success", "exported the private key ("+part+")")
|
||||
}
|
||||
pull := &model.CertificatePull{CertID: c.ID, Who: pullWho(p), Serial: c.Serial, IP: s.clientIP(r), At: time.Now().UTC()}
|
||||
if p.User != nil {
|
||||
pull.UserID = p.User.ID
|
||||
}
|
||||
if p.Token != nil {
|
||||
pull.TokenID = p.Token.ID
|
||||
}
|
||||
if err := s.store.RecordCertificatePull(r.Context(), pull); err != nil {
|
||||
s.log.Warn("recording a certificate download failed", "certificate", c.Name, "err", err)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/x-pem-file")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="`+file+`"`)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
@@ -208,7 +368,10 @@ func (s *Server) certUpdate(w http.ResponseWriter, r *http.Request, p *auth.Prin
|
||||
|
||||
// certDelete: DELETE /certificates/{name}.
|
||||
func (s *Server) certDelete(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := s.certsReady(p); err != nil {
|
||||
if err := s.certsAccess(p); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.certFor(r, p, true); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.cfg.Certs.Delete(r.Context(), r.PathValue("name"), p); err != nil {
|
||||
@@ -303,3 +466,28 @@ func (s *Server) certSummary(ctx context.Context) *api.CertSummary {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// keyTransport reports whether a private key may go back over r: HTTPS
|
||||
// (also through a trusted proxy), the local socket, or loopback.
|
||||
func (s *Server) keyTransport(r *http.Request) (bool, string) {
|
||||
https, why := s.transport(r)
|
||||
if https {
|
||||
return true, why
|
||||
}
|
||||
host, _, _ := net.SplitHostPort(r.RemoteAddr)
|
||||
if a, err := netip.ParseAddr(host); err == nil && a.Unmap().IsLoopback() && !s.trusted(a.Unmap()) {
|
||||
return true, "plain HTTP from this server"
|
||||
}
|
||||
return false, why
|
||||
}
|
||||
|
||||
// pullWho names a downloader: its token (and user), or the user.
|
||||
func pullWho(p *auth.Principal) string {
|
||||
switch {
|
||||
case p.Token != nil:
|
||||
return "token " + p.Token.Name + " (" + p.Name() + ")"
|
||||
case p.User != nil && p.Method != "":
|
||||
return p.Method + " " + p.Name()
|
||||
}
|
||||
return p.Name()
|
||||
}
|
||||
@@ -175,6 +175,7 @@ func (s *Server) routes() {
|
||||
m.HandleFunc("POST "+p+"/certificates/check", s.authed(s.certCheck))
|
||||
m.HandleFunc("GET "+p+"/certificates/{name}", s.authed(s.certGet))
|
||||
m.HandleFunc("GET "+p+"/certificates/{name}/pem", s.authed(s.certPEM))
|
||||
m.HandleFunc("POST "+p+"/certificates/{name}/request", s.authed(s.certRequest))
|
||||
m.HandleFunc("PATCH "+p+"/certificates/{name}", s.authed(s.certUpdate))
|
||||
m.HandleFunc("DELETE "+p+"/certificates/{name}", s.authed(s.certDelete))
|
||||
m.HandleFunc("POST "+p+"/certificates/{name}/issue", s.authed(s.certIssue))
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
|
||||
const certCols = `id, name, source, domains, cert_file, key_file, uses, challenge, key_type, comment, cert_pem, key_pem, names,
|
||||
not_before, not_after, issuer, serial, renew_at, ari_checked_at, last_attempt, last_success, last_error, failures, last_routes,
|
||||
created_at, updated_at, created_by, updated_by`
|
||||
created_at, updated_at, created_by, updated_by, owner_id`
|
||||
|
||||
func scanCert(sc interface{ Scan(...any) error }) (*model.Certificate, error) {
|
||||
c := &model.Certificate{}
|
||||
@@ -20,7 +20,7 @@ func scanCert(sc interface{ Scan(...any) error }) (*model.Certificate, error) {
|
||||
var nb, na, ra, ac, la, ls sql.NullString
|
||||
if err := sc.Scan(&c.ID, &c.Name, &c.Source, &domains, &c.CertFile, &c.KeyFile, &uses, &c.Challenge, &c.KeyType, &c.Comment,
|
||||
&c.CertPEM, &c.KeyPEM, &names, &nb, &na, &c.Issuer, &c.Serial, &ra, &ac, &la, &ls, &c.LastError, &c.Failures, &routes,
|
||||
&ca, &ua, &c.CreatedBy, &c.UpdatedBy); err != nil {
|
||||
&ca, &ua, &c.CreatedBy, &c.UpdatedBy, &c.OwnerID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, f := range []struct {
|
||||
@@ -71,7 +71,7 @@ func (s *Store) CertificateByName(ctx context.Context, name string) (*model.Cert
|
||||
func (s *Store) CreateCertificate(ctx context.Context, c *model.Certificate) error {
|
||||
now := time.Now().UTC()
|
||||
c.CreatedAt, c.UpdatedAt = now, now
|
||||
res, err := s.db.ExecContext(ctx, `INSERT INTO certificates (`+certCols[len("id, "):]+`) VALUES (`+placeholders(27)+`)`, certArgs(c)...)
|
||||
res, err := s.db.ExecContext(ctx, `INSERT INTO certificates (`+certCols[len("id, "):]+`) VALUES (`+placeholders(28)+`)`, certArgs(c)...)
|
||||
if isUnique(err) {
|
||||
return ErrConflict
|
||||
}
|
||||
@@ -87,7 +87,7 @@ func (s *Store) UpdateCertificate(ctx context.Context, c *model.Certificate) err
|
||||
c.UpdatedAt = time.Now().UTC()
|
||||
res, err := s.db.ExecContext(ctx, `UPDATE certificates SET name=?, source=?, domains=?, cert_file=?, key_file=?, uses=?, challenge=?, key_type=?,
|
||||
comment=?, cert_pem=?, key_pem=?, names=?, not_before=?, not_after=?, issuer=?, serial=?, renew_at=?, ari_checked_at=?, last_attempt=?,
|
||||
last_success=?, last_error=?, failures=?, last_routes=?, created_at=?, updated_at=?, created_by=?, updated_by=? WHERE id=?`,
|
||||
last_success=?, last_error=?, failures=?, last_routes=?, created_at=?, updated_at=?, created_by=?, updated_by=?, owner_id=? WHERE id=?`,
|
||||
append(certArgs(c), c.ID)...)
|
||||
if isUnique(err) {
|
||||
return ErrConflict
|
||||
@@ -123,5 +123,35 @@ func certArgs(c *model.Certificate) []any {
|
||||
return []any{c.Name, c.Source, list(c.Domains), c.CertFile, c.KeyFile, list(c.Uses), c.Challenge, c.KeyType, c.Comment,
|
||||
c.CertPEM, c.KeyPEM, list(c.Names), tsPtr(c.NotBefore), tsPtr(c.NotAfter), c.Issuer, c.Serial, tsPtr(c.RenewAt),
|
||||
tsPtr(c.ARICheckedAt), tsPtr(c.LastAttempt), tsPtr(c.LastSuccess), c.LastError, c.Failures, list(c.LastRoutes),
|
||||
ts(c.CreatedAt), ts(c.UpdatedAt), c.CreatedBy, c.UpdatedBy}
|
||||
ts(c.CreatedAt), ts(c.UpdatedAt), c.CreatedBy, c.UpdatedBy, c.OwnerID}
|
||||
}
|
||||
|
||||
// RecordCertificatePull notes a download of a certificate, replacing the
|
||||
// same downloader's earlier one.
|
||||
func (s *Store) RecordCertificatePull(ctx context.Context, p *model.CertificatePull) error {
|
||||
_, err := s.db.ExecContext(ctx, `INSERT INTO certificate_pulls (cert_id, who, user_id, token_id, serial, ip, at) VALUES (?,?,?,?,?,?,?)
|
||||
ON CONFLICT (cert_id, who) DO UPDATE SET user_id = excluded.user_id, token_id = excluded.token_id, serial = excluded.serial, ip = excluded.ip, at = excluded.at`,
|
||||
p.CertID, p.Who, p.UserID, p.TokenID, p.Serial, p.IP, ts(p.At))
|
||||
return err
|
||||
}
|
||||
|
||||
// CertificatePulls returns who downloaded each certificate, newest first,
|
||||
// by certificate ID.
|
||||
func (s *Store) CertificatePulls(ctx context.Context) (map[int64][]model.CertificatePull, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT cert_id, who, user_id, token_id, serial, ip, at FROM certificate_pulls ORDER BY at DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[int64][]model.CertificatePull{}
|
||||
for rows.Next() {
|
||||
var p model.CertificatePull
|
||||
var at string
|
||||
if err := rows.Scan(&p.CertID, &p.Who, &p.UserID, &p.TokenID, &p.Serial, &p.IP, &at); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
p.At = parseTS(at)
|
||||
out[p.CertID] = append(out[p.CertID], p)
|
||||
}
|
||||
return out, closeRows(rows)
|
||||
}
|
||||
@@ -384,6 +384,22 @@ ALTER TABLE zones ADD COLUMN auto_ptr INTEGER NOT NULL DEFAULT 0;
|
||||
`
|
||||
ALTER TABLE views ADD COLUMN reverse_suggestions_off INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE views ADD COLUMN dismissed_reverse TEXT NOT NULL DEFAULT '[]';
|
||||
`,
|
||||
// 14: certificates for other hosts (docs/certificates.md#certificates-for-other-hosts):
|
||||
// an owner, a per-user limit, and who downloaded them.
|
||||
`
|
||||
ALTER TABLE certificates ADD COLUMN owner_id INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE users ADD COLUMN cert_limit INTEGER;
|
||||
CREATE TABLE certificate_pulls (
|
||||
cert_id INTEGER NOT NULL REFERENCES certificates(id) ON DELETE CASCADE,
|
||||
who TEXT NOT NULL,
|
||||
user_id INTEGER NOT NULL DEFAULT 0,
|
||||
token_id INTEGER NOT NULL DEFAULT 0,
|
||||
serial TEXT NOT NULL DEFAULT '',
|
||||
ip TEXT NOT NULL DEFAULT '',
|
||||
at TEXT NOT NULL,
|
||||
PRIMARY KEY (cert_id, who)
|
||||
);
|
||||
`,
|
||||
}
|
||||
|
||||
|
||||
+11
-6
@@ -18,16 +18,21 @@ func isUnique(err error) bool {
|
||||
return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed")
|
||||
}
|
||||
|
||||
const userCols = `id, username, password_hash, admin, grants, disabled, comment, created_at, updated_at, last_login_at, totp_secret, totp_pending, totp_enabled_at, totp_last_step`
|
||||
const userCols = `id, username, password_hash, admin, grants, disabled, comment, created_at, updated_at, last_login_at, totp_secret, totp_pending, totp_enabled_at, totp_last_step, cert_limit`
|
||||
|
||||
func scanUser(sc interface{ Scan(...any) error }) (*model.User, error) {
|
||||
u := &model.User{}
|
||||
var grants, ca, ua string
|
||||
var admin, disabled int
|
||||
var ll, te sql.NullString
|
||||
if err := sc.Scan(&u.ID, &u.Username, &u.PasswordHash, &admin, &grants, &disabled, &u.Comment, &ca, &ua, &ll, &u.TOTPSecret, &u.TOTPPending, &te, &u.TOTPLastStep); err != nil {
|
||||
var limit sql.NullInt64
|
||||
if err := sc.Scan(&u.ID, &u.Username, &u.PasswordHash, &admin, &grants, &disabled, &u.Comment, &ca, &ua, &ll, &u.TOTPSecret, &u.TOTPPending, &te, &u.TOTPLastStep, &limit); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if limit.Valid {
|
||||
n := int(limit.Int64)
|
||||
u.CertLimit = &n
|
||||
}
|
||||
u.Admin, u.Disabled = admin != 0, disabled != 0
|
||||
u.HasPassword = u.PasswordHash != ""
|
||||
if err := json.Unmarshal([]byte(grants), &u.Grants); err != nil {
|
||||
@@ -48,8 +53,8 @@ func (s *Store) CreateUser(ctx context.Context, u *model.User) error {
|
||||
if u.Grants == nil {
|
||||
u.Grants = []model.Grant{}
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx, `INSERT INTO users (username, password_hash, admin, grants, disabled, comment, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)`,
|
||||
u.Username, u.PasswordHash, boolInt(u.Admin), jsonText(u.Grants), boolInt(u.Disabled), u.Comment, ts(now), ts(now))
|
||||
res, err := s.db.ExecContext(ctx, `INSERT INTO users (username, password_hash, admin, grants, disabled, comment, created_at, updated_at, cert_limit) VALUES (?,?,?,?,?,?,?,?,?)`,
|
||||
u.Username, u.PasswordHash, boolInt(u.Admin), jsonText(u.Grants), boolInt(u.Disabled), u.Comment, ts(now), ts(now), u.CertLimit)
|
||||
if isUnique(err) {
|
||||
return ErrConflict
|
||||
}
|
||||
@@ -64,8 +69,8 @@ func (s *Store) CreateUser(ctx context.Context, u *model.User) error {
|
||||
// UpdateUser saves a user.
|
||||
func (s *Store) UpdateUser(ctx context.Context, u *model.User) error {
|
||||
u.UpdatedAt = time.Now()
|
||||
res, err := s.db.ExecContext(ctx, `UPDATE users SET username = ?, password_hash = ?, admin = ?, grants = ?, disabled = ?, comment = ?, updated_at = ? WHERE id = ?`,
|
||||
u.Username, u.PasswordHash, boolInt(u.Admin), jsonText(u.Grants), boolInt(u.Disabled), u.Comment, ts(u.UpdatedAt), u.ID)
|
||||
res, err := s.db.ExecContext(ctx, `UPDATE users SET username = ?, password_hash = ?, admin = ?, grants = ?, disabled = ?, comment = ?, updated_at = ?, cert_limit = ? WHERE id = ?`,
|
||||
u.Username, u.PasswordHash, boolInt(u.Admin), jsonText(u.Grants), boolInt(u.Disabled), u.Comment, ts(u.UpdatedAt), u.CertLimit, u.ID)
|
||||
if isUnique(err) {
|
||||
return ErrConflict
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ export async function render(el) {
|
||||
c.uses.includes("web") ? [badge(c.serving ? "web UI (serving)" : "web UI", c.serving ? "ok" : ""), " "] : null,
|
||||
c.uses.includes("dns") ? namedUse(c) : null,
|
||||
!c.uses.length ? h("span", { class: "muted", title: "Export it to use it in another program" }, "not used by Jarl") : null] },
|
||||
{ label: "Owner", render: (c) => c.owner ? h("span", null, c.owner, pulledBy(c)) : h("span", { class: "muted" }, "Jarl") },
|
||||
{ label: "Source", render: (c) => c.source === "acme" ? h("span", null, "Jarl", h("div", { class: "small muted" }, "challenge " + c.challenge)) : h("span", null, "your files", h("div", { class: "small muted mono" }, c.cert_file)) },
|
||||
{ label: "Expires", render: (c) => c.not_after ? h("span", null, time(c.not_after), c.source === "acme" && c.next_attempt ? h("div", { class: "small muted" }, "renews " + fmtTime(c.next_attempt)) : null) : h("span", { class: "muted" }, "–") },
|
||||
{ label: "Status", render: (c) => {
|
||||
@@ -64,6 +65,15 @@ export async function render(el) {
|
||||
}
|
||||
}
|
||||
|
||||
// pulledBy says who downloaded a certificate for another host, and
|
||||
// whether they have the current one.
|
||||
function pulledBy(c) {
|
||||
const ps = c.pulled_by || [];
|
||||
if (!ps.length) return h("div", { class: "small muted" }, "not downloaded yet");
|
||||
return ps.map((p) => h("div", { class: "small " + (p.serial === c.serial ? "muted" : "warn-text"), title: p.who + " from " + (p.ip || "?") + ", " + fmtTime(p.at) },
|
||||
p.who.replace(/^token /, "") + ", " + fmtTime(p.at) + (p.serial === c.serial ? "" : " (old: not fetched since the renewal)")));
|
||||
}
|
||||
|
||||
// namedUse is what named uses a certificate for: DoT and DoH, with the
|
||||
// addresses in the tooltip (the details list them).
|
||||
function namedUse(c) {
|
||||
|
||||
@@ -54,6 +54,7 @@ async function userDialog(u, grants) {
|
||||
tmpl.value = "";
|
||||
});
|
||||
const comment = input({ value: u ? u.comment : "" });
|
||||
const certLimit = input({ type: "number", min: "0", value: u && u.cert_limit != null ? String(u.cert_limit) : "", placeholder: "certificates.max_per_user" });
|
||||
await modal(u ? "Edit " + u.username : "New user", h("div", null, h("div", { class: "form-grid" },
|
||||
field("Username", name),
|
||||
field("Password", pw),
|
||||
@@ -62,6 +63,7 @@ async function userDialog(u, grants) {
|
||||
u ? h("span") : null, u ? disabled : null,
|
||||
field("Permissions", gr, "One grant per line (ignored for administrators)."),
|
||||
h("span"), tmpl,
|
||||
field("Certificates", certLimit, "How many certificates this user may own through jarl cert get (needs a certificates grant). Empty: the default from jarl.yaml."),
|
||||
field("Comment", comment)), err), [
|
||||
{ label: "Cancel", value: null },
|
||||
{ label: u ? "Save" : "Create user", kind: "primary", onclick: async () => {
|
||||
@@ -69,6 +71,8 @@ async function userDialog(u, grants) {
|
||||
if (service.input.checked) data.password = "";
|
||||
else if (pw.value) data.password = pw.value;
|
||||
if (u) data.disabled = disabled.input.checked;
|
||||
if (certLimit.value.trim() !== "") data.cert_limit = Number(certLimit.value);
|
||||
else if (u && u.cert_limit != null) data.cert_limit = -1;
|
||||
try {
|
||||
if (u) await api("PATCH", "/users/" + u.id, { body: data });
|
||||
else await api("POST", "/users", { body: data });
|
||||
|
||||
@@ -896,6 +896,58 @@ if [ -n "$PEBBLE" ]; then
|
||||
run "a second certificate for the web UI is refused (exit 6)" 6 $J cert create other --domain nas.adbraun.test --use web
|
||||
run "certificate metrics" 0 curl -sf http://127.0.0.1:$HTTP/metrics
|
||||
contains '^jarl_certificate_failing{certificate="ns1",source="acme"} 0$'
|
||||
# Certificates for other hosts: jarl cert get (docs/certificates.md#certificates-for-other-hosts).
|
||||
run "user for other hosts' certificates" 0 $J user create hosts --no-password --grant "certificates:write names=*.adbraun.test"
|
||||
run "its token" 0 sh -c "$J token create mx --user hosts -q > $E/hosts.token"
|
||||
run "a second user with the same grant" 0 $J user create hosts2 --no-password --grant "certificates:write names=*.adbraun.test"
|
||||
run "and its token" 0 sh -c "$J token create web --user hosts2 -q > $E/hosts2.token"
|
||||
run "a user without a certificates grant" 0 $J user create nocert --no-password --role acme
|
||||
run "and its token too" 0 sh -c "$J token create nocert --user nocert -q > $E/nocert.token"
|
||||
CG="--token @$E/hosts.token"; CGD=$E/certget
|
||||
mkdir -p $CGD
|
||||
HOOK='echo "$JARL_CERT_NAME $JARL_CERT_RENEWED $(basename $JARL_CERT_KEY)" >> '$CGD/hook.log
|
||||
run "cert get: obtained by jarld, downloaded here" 0 $J $CG cert get mail -d mail.adbraun.test --dir $CGD/mail --hook "$HOOK" --config-dir $CGD/conf
|
||||
contains "mail .*new files"
|
||||
run "the files" 0 ls $CGD/mail
|
||||
contains "^chain.pem$"
|
||||
contains "^fullchain.pem$"
|
||||
contains "^jarl-cert.json$"
|
||||
run "the key is mode 600" 0 stat -c %a $CGD/mail/privkey.pem
|
||||
contains "^600$"
|
||||
run "the key matches" 0 sh -c "openssl x509 -in $CGD/mail/cert.pem -noout -pubkey | cmp - \$(openssl pkey -in $CGD/mail/privkey.pem -pubout > $CGD/pub.pem; echo $CGD/pub.pem)"
|
||||
run "the hook ran" 0 cat $CGD/hook.log
|
||||
contains "^mail 0 privkey.pem$"
|
||||
run "the settings are saved, readable only by their owner" 0 stat -c %a $CGD/conf/mail.json
|
||||
contains "^600$"
|
||||
run "cert get again: up to date, no hook" 0 $J cert get --config-dir $CGD/conf
|
||||
contains "mail .*up to date"
|
||||
if [ "$(wc -l < $CGD/hook.log)" = 1 ]; then ok "the hook didn't run again"; else bad "hook ran again: $(cat $CGD/hook.log)"; fi
|
||||
run "cert saved" 0 $J cert saved --config-dir $CGD/conf
|
||||
contains "mail *mail.adbraun.test .*$CGD/mail"
|
||||
run "another user can't take the name (exit 5)" 5 $J --token @$E/hosts2.token cert get mail -d mail.adbraun.test --dir $CGD/x --no-save --config-dir $CGD/conf2
|
||||
contains "taken"
|
||||
run "an ACME token can't get certificates (exit 3)" 3 $J --token @$E/nocert.token cert get x -d x.adbraun.test --dir $CGD/x --no-save --config-dir $CGD/conf2
|
||||
run "a name outside the grant (exit 3)" 3 $J $CG cert get other -d mail.example.org --dir $CGD/other --no-save --config-dir $CGD/conf
|
||||
run "the admin sees the owner and who downloaded it" 0 $J cert show mail
|
||||
contains "Owner: *hosts"
|
||||
contains "Pulled by: *token mx (hosts)"
|
||||
run "jarld renews it" 0 $J cert issue mail
|
||||
run "cert get: the renewed one, hook told" 0 $J cert get --config-dir $CGD/conf
|
||||
contains "mail .*new files"
|
||||
run "the hook ran for the renewal" 0 tail -1 $CGD/hook.log
|
||||
contains "^mail 1 privkey.pem$"
|
||||
run "a name added: obtained again" 0 $J cert get mail -d mail.adbraun.test -d smtp.adbraun.test --config-dir $CGD/conf
|
||||
run "the new certificate has both names" 0 openssl x509 -in $CGD/mail/cert.pem -noout -ext subjectAltName
|
||||
contains "smtp.adbraun.test"
|
||||
run "cert schedule writes the units" 0 $J cert schedule --unit-dir $CGD --no-systemctl
|
||||
run "the service runs cert get" 0 cat $CGD/jarl-cert-get.service
|
||||
contains "^ExecStart=.* cert get --config-dir"
|
||||
run "the timer runs daily" 0 cat $CGD/jarl-cert-get.timer
|
||||
contains "^OnCalendar=daily$"
|
||||
run "cert forget" 0 $J cert forget mail --config-dir $CGD/conf
|
||||
run "nothing saved now" 0 $J cert saved --config-dir $CGD/conf
|
||||
contains "No saved certificates"
|
||||
run "the owner may delete it" 0 $J $CG cert delete mail --yes
|
||||
# Encrypted DNS set up by Jarl (docs/encrypted-dns.md), next to the
|
||||
# hand-written listener above.
|
||||
EDOT=${EDOT_PORT:-25855}; EDOH=${EDOH_PORT:-25856}
|
||||
|
||||
Reference in new issue
Block a user