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
|
### 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 can be exported for use outside Jarl: **Export…** on the
|
||||||
Certificates page (download, or copy to the clipboard),
|
Certificates page (download, or copy to the clipboard),
|
||||||
`jarl cert export NAME [--dir DIR | --part PART]`
|
`jarl cert export NAME [--dir DIR | --part PART]`
|
||||||
@@ -27,6 +42,10 @@ All notable changes to Jarl are documented here. The format follows
|
|||||||
|
|
||||||
### Changed
|
### 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,
|
- 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
|
with the addresses in the tooltip, instead of listing every address. A
|
||||||
certificate with no use says "not used by Jarl" rather than "nothing
|
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
|
the zone now keep the dot, and the name server is only sent when it was
|
||||||
changed.
|
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
|
## [0.6.0] - 2026-10-13
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -378,6 +378,9 @@ func printUser(w io.Writer, u api.User) {
|
|||||||
if u.Comment != "" {
|
if u.Comment != "" {
|
||||||
fmt.Fprintf(w, "Comment:\t%s\n", 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))
|
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 admin, noPassword, disabled bool
|
||||||
var role, comment, rename string
|
var role, comment, rename string
|
||||||
|
var certLimit int
|
||||||
var grants []string
|
var grants []string
|
||||||
userBody := func(cmd *cobra.Command) (api.UserInput, error) {
|
userBody := func(cmd *cobra.Command) (api.UserInput, error) {
|
||||||
var in api.UserInput
|
var in api.UserInput
|
||||||
@@ -466,6 +470,9 @@ func userCmd() *cobra.Command {
|
|||||||
if f.Changed("rename") {
|
if f.Changed("rename") {
|
||||||
in.Username = &rename
|
in.Username = &rename
|
||||||
}
|
}
|
||||||
|
if f.Changed("cert-limit") {
|
||||||
|
in.CertLimit = &certLimit
|
||||||
|
}
|
||||||
return in, nil
|
return in, nil
|
||||||
}
|
}
|
||||||
addUserFlags := func(c *cobra.Command) {
|
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().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().StringArrayVar(&grants, "grant", nil, `grant (repeatable), e.g. "records:write zones=example.com"`)
|
||||||
c.Flags().StringVar(&comment, "comment", "", "comment")
|
c.Flags().StringVar(&comment, "comment", "", "comment")
|
||||||
|
c.Flags().IntVar(&certLimit, "cert-limit", -1, "how many certificates the user may own through jarl cert get (-1: certificates.max_per_user in jarl.yaml)")
|
||||||
}
|
}
|
||||||
create := &cobra.Command{
|
create := &cobra.Command{
|
||||||
Use: "create NAME",
|
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"`
|
Chain string `json:"chain"`
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
} `json:"files,omitempty"`
|
} `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 {
|
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 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`,
|
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
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,6 +188,20 @@ func printCert(w io.Writer, ci certInfo) {
|
|||||||
if ci.Comment != "" {
|
if ci.Comment != "" {
|
||||||
fmt.Fprintf(w, "Comment:\t%s\n", 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
|
state := ci.Status.State
|
||||||
if ci.Status.Message != "" {
|
if ci.Status.Message != "" {
|
||||||
state += ": " + ci.Status.Message
|
state += ": " + ci.Status.Message
|
||||||
|
|||||||
+36
-9
@@ -103,17 +103,30 @@ func saveCLIConfig(c *cliConfig) error {
|
|||||||
return os.WriteFile(flagConfig, b, 0o600)
|
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()
|
cfg, err := loadCLIConfig()
|
||||||
if err != nil {
|
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))
|
ts, err := resolveTargets(cfg, targetSpec(cfg))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return connection{}, err
|
||||||
}
|
}
|
||||||
if len(ts) > 1 {
|
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]
|
t := ts[0]
|
||||||
// A named server's own token wins: a token from the environment
|
// 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)
|
sock, isSocket := client.SocketPath(t.URL)
|
||||||
if isSocket {
|
if isSocket {
|
||||||
if err := checkSocket(sock, t.Name == localName && flagServer == "" && os.Getenv("JARL_SERVER") == ""); err != nil {
|
if err := checkSocket(sock, t.Name == localName && flagServer == "" && os.Getenv("JARL_SERVER") == ""); err != nil {
|
||||||
return nil, err
|
return connection{}, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if token == "" && !isSocket {
|
if token == "" && !isSocket {
|
||||||
if t.Name != "" {
|
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, "@") {
|
if strings.HasPrefix(token, "@") {
|
||||||
b, err := os.ReadFile(token[1:])
|
b, err := os.ReadFile(token[1:])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -141,7 +160,15 @@ func newClient() (*client.Client, error) {
|
|||||||
}
|
}
|
||||||
token = strings.TrimSpace(string(b))
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -167,7 +194,7 @@ func newClient() (*client.Client, error) {
|
|||||||
}
|
}
|
||||||
c.DryRun, c.Force, c.AcceptRisk = flagDryRun || c.Preflight, flagForce, flagRisk
|
c.DryRun, c.Force, c.AcceptRisk = flagDryRun || c.Preflight, flagForce, flagRisk
|
||||||
if !c.Preflight {
|
if !c.Preflight {
|
||||||
c.OnTokenExpiry = tokenExpiryWarning(t.Name)
|
c.OnTokenExpiry = tokenExpiryWarning(cn.Name)
|
||||||
}
|
}
|
||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -318,6 +318,7 @@ func serveCmd() *cobra.Command {
|
|||||||
EABKeyID: a.cfg.ACME.Account.EABKeyID, EABHMAC: a.cfg.ACME.Account.EABHMAC,
|
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,
|
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,
|
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)
|
}, a.store, a.eng, a.log)
|
||||||
cm.Start(ctx)
|
cm.Start(ctx)
|
||||||
if a.cfg.ACME.Account.EABHMAC != "" {
|
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}` | |
|
| 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}/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}/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 | `/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`,
|
`GET /status` includes `certificates` for administrators: `total`,
|
||||||
@@ -631,7 +632,7 @@ Success is `200 {"ok": true, "fqdn": ..., "changeset_id": ..., "zones": [...]}`.
|
|||||||
|--------|------|------|
|
|--------|------|------|
|
||||||
| GET | `/users` | admin |
|
| 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) |
|
| 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 | `/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 / 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 |
|
| 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
|
The parts are `fullchain` (the certificate and its chain), `cert` (the
|
||||||
certificate alone), `key` and `bundle` (the key, then the chain, in one
|
certificate alone), `key` and `bundle` (the key, then the chain, in one
|
||||||
file, as HAProxy wants). Only administrators can export, and every export
|
file, as HAProxy wants). Administrators can export any certificate, and
|
||||||
of the key is in the audit log (`certificate.export_key`).
|
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
|
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
|
renewal (`jarl cert show` says when that is). For another host, `jarl
|
||||||
cron job. A program on this server can instead read the files Jarl writes
|
cert get` (below) does that for you. A program on this server can instead
|
||||||
for named (use `dns`), which Jarl keeps current.
|
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
|
## 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 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 --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 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 delete ns1
|
||||||
jarl cert account # the ACME account (set in jarl.yaml)
|
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.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). |
|
| `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.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)
|
### 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,
|
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,
|
run `resync`, wipe a zone's DNSSEC keys, use the registrar (Porkbun,
|
||||||
whose key can move the domain), and manage TLS certificates (whose keys
|
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:
|
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) |
|
| `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) |
|
| `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 |
|
| `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.
|
`ACCESS` is `read` or `write` (write includes read). Filters narrow a grant.
|
||||||
Each takes comma separated patterns where `*` and `?` are wildcards, and a
|
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
|
* `zones=adbraun.lol,*.adbraun.lol`: zone names, without the final dot
|
||||||
* `views=internal`: view names (`_default` means zones outside any view)
|
* `views=internal`: view names (`_default` means zones outside any view)
|
||||||
* `names=_acme-challenge.*`: record owner names, as FQDNs without the final dot
|
* `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
|
* `types=TXT,CNAME`: record types
|
||||||
|
|
||||||
Examples:
|
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 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 names=_acme-challenge.* types=TXT ACME challenges anywhere
|
||||||
records:write zones=acme.adbraun.lol types=TXT only TXT records in a dedicated ACME zone
|
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
|
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
|
Role *string `json:"role,omitempty"` // a role template name
|
||||||
Disabled *bool `json:"disabled,omitempty"`
|
Disabled *bool `json:"disabled,omitempty"`
|
||||||
Comment *string `json:"comment,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.
|
// 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
|
ResAudit = "audit" // audit log and everyone's changesets
|
||||||
ResStats = "stats" // query statistics
|
ResStats = "stats" // query statistics
|
||||||
ResBlocking = "blocking" // blocklists and block/allow rules
|
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 = "*"
|
ResAll = "*"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Resources lists the resources in display order.
|
// 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.
|
// Access levels.
|
||||||
const (
|
const (
|
||||||
@@ -74,8 +77,11 @@ func ValidateGrant(g *model.Grant) error {
|
|||||||
for i, t := range g.Types {
|
for i, t := range g.Types {
|
||||||
g.Types[i] = strings.ToUpper(t)
|
g.Types[i] = strings.ToUpper(t)
|
||||||
}
|
}
|
||||||
if (len(g.Names) > 0 || len(g.Types) > 0) && g.Resource != ResRecords {
|
if len(g.Types) > 0 && g.Resource != ResRecords {
|
||||||
return fmt.Errorf("names and types filters only apply to the records resource")
|
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 {
|
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")
|
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 {
|
if g.Resource != ResAll && g.Resource != a.Resource {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if g.Resource == ResAll && a.Resource == ResCerts {
|
||||||
|
return false // only an explicit certificates grant
|
||||||
|
}
|
||||||
if a.Write && g.Access != Write {
|
if a.Write && g.Access != Write {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ type Config struct {
|
|||||||
PropagationTimeout time.Duration
|
PropagationTimeout time.Duration
|
||||||
// SkipPropagationCheck is !CheckPropagation (set by New).
|
// SkipPropagationCheck is !CheckPropagation (set by New).
|
||||||
SkipPropagationCheck bool
|
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.
|
// Manager keeps certificates current and serves jarld's own.
|
||||||
@@ -132,6 +135,8 @@ type Input struct {
|
|||||||
Challenge *string `json:"challenge"`
|
Challenge *string `json:"challenge"`
|
||||||
KeyType *string `json:"key_type"`
|
KeyType *string `json:"key_type"`
|
||||||
Comment *string `json:"comment"`
|
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}$`)
|
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 {
|
if in.Comment != nil {
|
||||||
c.Comment = *in.Comment
|
c.Comment = *in.Comment
|
||||||
}
|
}
|
||||||
|
if in.Owner != nil {
|
||||||
|
c.OwnerID = *in.Owner
|
||||||
|
}
|
||||||
switch c.Source {
|
switch c.Source {
|
||||||
case model.CertSourceACME:
|
case model.CertSourceACME:
|
||||||
if in.CertFile != nil && *in.CertFile != "" || in.KeyFile != nil && *in.KeyFile != "" {
|
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
|
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;
|
// Create adds a certificate. A file-source certificate is read at once;
|
||||||
// one Jarl obtains is left for Issue (or the background loop).
|
// 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) {
|
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.
|
// HTTPS): one directory per certificate, readable by bind.file_group.
|
||||||
// "" is <bind.config_dir>/tls.
|
// "" is <bind.config_dir>/tls.
|
||||||
Dir string `yaml:"dir"`
|
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).
|
// 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)},
|
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,
|
TopN: 100,
|
||||||
},
|
},
|
||||||
|
Certificates: Certificates{MaxPerUser: 10},
|
||||||
ACME: ACME{HTTPReq: HTTPReq{RequireHTTPS: true}, Account: ACMEAccount{Directory: "letsencrypt"},
|
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},
|
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},
|
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 says where the challenges went the last time ("adbraun.lol: porkbun").
|
||||||
LastRoutes []string `json:"last_routes,omitempty"`
|
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"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
CreatedBy string `json:"created_by"`
|
CreatedBy string `json:"created_by"`
|
||||||
UpdatedBy string `json:"updated_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.
|
// HasUse reports whether c is used for use.
|
||||||
func (c *Certificate) HasUse(use string) bool { return slices.Contains(c.Uses, use) }
|
func (c *Certificate) HasUse(use string) bool { return slices.Contains(c.Uses, use) }
|
||||||
@@ -76,6 +76,9 @@ type User struct {
|
|||||||
TOTPSecret string `json:"-"`
|
TOTPSecret string `json:"-"`
|
||||||
TOTPPending string `json:"-"`
|
TOTPPending string `json:"-"`
|
||||||
TOTPLastStep int64 `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.
|
// 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 {
|
func withCerts(env *apiEnv) *certs.Manager {
|
||||||
quiet := slog.New(slog.NewTextHandler(io.Discard, nil))
|
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
|
env.srv.cfg.Certs = m
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -444,6 +444,12 @@ func applyUserInput(u *model.User, in *api.UserInput) error {
|
|||||||
if in.Comment != nil {
|
if in.Comment != nil {
|
||||||
u.Comment = *in.Comment
|
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 != nil {
|
||||||
if *in.Password == "" {
|
if *in.Password == "" {
|
||||||
u.PasswordHash = "" // service account: tokens only
|
u.PasswordHash = "" // service account: tokens only
|
||||||
|
|||||||
+194
-6
@@ -4,7 +4,11 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"jarl/internal/api"
|
"jarl/internal/api"
|
||||||
@@ -28,6 +32,10 @@ type certView struct {
|
|||||||
NamedUse []string `json:"named_use,omitempty"`
|
NamedUse []string `json:"named_use,omitempty"`
|
||||||
// IssuingSince is set while Jarl is obtaining it.
|
// IssuingSince is set while Jarl is obtaining it.
|
||||||
IssuingSince *time.Time `json:"issuing_since,omitempty"`
|
IssuingSince *time.Time `json:"issuing_since,omitempty"`
|
||||||
|
// Owner is the user a certificate for another host belongs to; PulledBy
|
||||||
|
// who downloaded it (list and get, administrators only).
|
||||||
|
Owner string `json:"owner,omitempty"`
|
||||||
|
PulledBy []model.CertificatePull `json:"pulled_by,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type certFiles struct {
|
type certFiles struct {
|
||||||
@@ -62,6 +70,78 @@ func (s *Server) certsReady(p *auth.Principal) error {
|
|||||||
return nil
|
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 {
|
func certErr(err error) error {
|
||||||
var ie *certs.InputError
|
var ie *certs.InputError
|
||||||
switch {
|
switch {
|
||||||
@@ -71,13 +151,15 @@ func certErr(err error) error {
|
|||||||
return notFound("no such certificate")
|
return notFound("no such certificate")
|
||||||
case errors.Is(err, certs.ErrConflict):
|
case errors.Is(err, certs.ErrConflict):
|
||||||
return conflict("a certificate with that name exists")
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// certList: GET /certificates.
|
// certList: GET /certificates.
|
||||||
func (s *Server) certList(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
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
|
return err
|
||||||
}
|
}
|
||||||
all, err := s.cfg.Certs.List(r.Context())
|
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{}
|
out := []certView{}
|
||||||
for _, c := range all {
|
for _, c := range all {
|
||||||
|
if !certAllowed(p, c, false) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
v := s.presentCert(c)
|
v := s.presentCert(c)
|
||||||
v.NamedUse = encdns.CertUses(st, s.cfg.Certs.FilesDir(c.Name))
|
v.NamedUse = encdns.CertUses(st, s.cfg.Certs.FilesDir(c.Name))
|
||||||
out = append(out, v)
|
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())})
|
writeJSON(w, http.StatusOK, map[string]any{"certificates": out, "account": s.cfg.Certs.AccountInfo(r.Context())})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// certGet: GET /certificates/{name}.
|
// certGet: GET /certificates/{name}.
|
||||||
func (s *Server) certGet(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
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
|
return err
|
||||||
}
|
}
|
||||||
c, err := s.cfg.Certs.Get(r.Context(), r.PathValue("name"))
|
c, err := s.certFor(r, p, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return certErr(err)
|
return err
|
||||||
}
|
}
|
||||||
st, err := s.store.LoadState(r.Context())
|
st, err := s.store.LoadState(r.Context())
|
||||||
if err != nil {
|
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 := s.presentCert(c)
|
||||||
v.NamedUse = encdns.CertUses(st, s.cfg.Certs.FilesDir(c.Name))
|
v.NamedUse = encdns.CertUses(st, s.cfg.Certs.FilesDir(c.Name))
|
||||||
|
if p.IsAdmin() {
|
||||||
|
vs := []certView{v}
|
||||||
|
if err := s.withOwners(r.Context(), vs); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
v = vs[0]
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusOK, v)
|
writeJSON(w, http.StatusOK, v)
|
||||||
return nil
|
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,
|
// certPEM: GET /certificates/{name}/pem?part=fullchain|cert|key|bundle,
|
||||||
// the certificate as PEM for use elsewhere: the chain (default), the
|
// the certificate as PEM for use elsewhere: the chain (default), the
|
||||||
// certificate alone, the private key, or the key followed by the chain
|
// certificate alone, the private key, or the key followed by the chain
|
||||||
// (one file, as HAProxy wants). Anything with the key is audited.
|
// (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 {
|
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
|
return err
|
||||||
}
|
}
|
||||||
part := r.URL.Query().Get("part")
|
part := r.URL.Query().Get("part")
|
||||||
if part == "" {
|
if part == "" {
|
||||||
part = "fullchain"
|
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"))
|
c, chain, key, err := s.cfg.Certs.PEM(r.Context(), r.PathValue("name"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return certErr(err)
|
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" {
|
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+")")
|
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-Type", "application/x-pem-file")
|
||||||
w.Header().Set("Content-Disposition", `attachment; filename="`+file+`"`)
|
w.Header().Set("Content-Disposition", `attachment; filename="`+file+`"`)
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
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}.
|
// certDelete: DELETE /certificates/{name}.
|
||||||
func (s *Server) certDelete(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
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
|
return err
|
||||||
}
|
}
|
||||||
if err := s.cfg.Certs.Delete(r.Context(), r.PathValue("name"), p); err != nil {
|
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
|
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("POST "+p+"/certificates/check", s.authed(s.certCheck))
|
||||||
m.HandleFunc("GET "+p+"/certificates/{name}", s.authed(s.certGet))
|
m.HandleFunc("GET "+p+"/certificates/{name}", s.authed(s.certGet))
|
||||||
m.HandleFunc("GET "+p+"/certificates/{name}/pem", s.authed(s.certPEM))
|
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("PATCH "+p+"/certificates/{name}", s.authed(s.certUpdate))
|
||||||
m.HandleFunc("DELETE "+p+"/certificates/{name}", s.authed(s.certDelete))
|
m.HandleFunc("DELETE "+p+"/certificates/{name}", s.authed(s.certDelete))
|
||||||
m.HandleFunc("POST "+p+"/certificates/{name}/issue", s.authed(s.certIssue))
|
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,
|
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,
|
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) {
|
func scanCert(sc interface{ Scan(...any) error }) (*model.Certificate, error) {
|
||||||
c := &model.Certificate{}
|
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
|
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,
|
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,
|
&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
|
return nil, err
|
||||||
}
|
}
|
||||||
for _, f := range []struct {
|
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 {
|
func (s *Store) CreateCertificate(ctx context.Context, c *model.Certificate) error {
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
c.CreatedAt, c.UpdatedAt = now, now
|
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) {
|
if isUnique(err) {
|
||||||
return ErrConflict
|
return ErrConflict
|
||||||
}
|
}
|
||||||
@@ -87,7 +87,7 @@ func (s *Store) UpdateCertificate(ctx context.Context, c *model.Certificate) err
|
|||||||
c.UpdatedAt = time.Now().UTC()
|
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=?,
|
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=?,
|
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)...)
|
append(certArgs(c), c.ID)...)
|
||||||
if isUnique(err) {
|
if isUnique(err) {
|
||||||
return ErrConflict
|
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,
|
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),
|
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),
|
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 reverse_suggestions_off INTEGER NOT NULL DEFAULT 0;
|
||||||
ALTER TABLE views ADD COLUMN dismissed_reverse TEXT NOT NULL DEFAULT '[]';
|
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")
|
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) {
|
func scanUser(sc interface{ Scan(...any) error }) (*model.User, error) {
|
||||||
u := &model.User{}
|
u := &model.User{}
|
||||||
var grants, ca, ua string
|
var grants, ca, ua string
|
||||||
var admin, disabled int
|
var admin, disabled int
|
||||||
var ll, te sql.NullString
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if limit.Valid {
|
||||||
|
n := int(limit.Int64)
|
||||||
|
u.CertLimit = &n
|
||||||
|
}
|
||||||
u.Admin, u.Disabled = admin != 0, disabled != 0
|
u.Admin, u.Disabled = admin != 0, disabled != 0
|
||||||
u.HasPassword = u.PasswordHash != ""
|
u.HasPassword = u.PasswordHash != ""
|
||||||
if err := json.Unmarshal([]byte(grants), &u.Grants); err != nil {
|
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 {
|
if u.Grants == nil {
|
||||||
u.Grants = []model.Grant{}
|
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 (?,?,?,?,?,?,?,?)`,
|
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.Username, u.PasswordHash, boolInt(u.Admin), jsonText(u.Grants), boolInt(u.Disabled), u.Comment, ts(now), ts(now), u.CertLimit)
|
||||||
if isUnique(err) {
|
if isUnique(err) {
|
||||||
return ErrConflict
|
return ErrConflict
|
||||||
}
|
}
|
||||||
@@ -64,8 +69,8 @@ func (s *Store) CreateUser(ctx context.Context, u *model.User) error {
|
|||||||
// UpdateUser saves a user.
|
// UpdateUser saves a user.
|
||||||
func (s *Store) UpdateUser(ctx context.Context, u *model.User) error {
|
func (s *Store) UpdateUser(ctx context.Context, u *model.User) error {
|
||||||
u.UpdatedAt = time.Now()
|
u.UpdatedAt = time.Now()
|
||||||
res, err := s.db.ExecContext(ctx, `UPDATE users SET username = ?, password_hash = ?, admin = ?, grants = ?, disabled = ?, comment = ?, updated_at = ? WHERE 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.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) {
|
if isUnique(err) {
|
||||||
return ErrConflict
|
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("web") ? [badge(c.serving ? "web UI (serving)" : "web UI", c.serving ? "ok" : ""), " "] : null,
|
||||||
c.uses.includes("dns") ? namedUse(c) : 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] },
|
!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: "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: "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) => {
|
{ 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
|
// namedUse is what named uses a certificate for: DoT and DoH, with the
|
||||||
// addresses in the tooltip (the details list them).
|
// addresses in the tooltip (the details list them).
|
||||||
function namedUse(c) {
|
function namedUse(c) {
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ async function userDialog(u, grants) {
|
|||||||
tmpl.value = "";
|
tmpl.value = "";
|
||||||
});
|
});
|
||||||
const comment = input({ value: u ? u.comment : "" });
|
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" },
|
await modal(u ? "Edit " + u.username : "New user", h("div", null, h("div", { class: "form-grid" },
|
||||||
field("Username", name),
|
field("Username", name),
|
||||||
field("Password", pw),
|
field("Password", pw),
|
||||||
@@ -62,6 +63,7 @@ async function userDialog(u, grants) {
|
|||||||
u ? h("span") : null, u ? disabled : null,
|
u ? h("span") : null, u ? disabled : null,
|
||||||
field("Permissions", gr, "One grant per line (ignored for administrators)."),
|
field("Permissions", gr, "One grant per line (ignored for administrators)."),
|
||||||
h("span"), tmpl,
|
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), [
|
field("Comment", comment)), err), [
|
||||||
{ label: "Cancel", value: null },
|
{ label: "Cancel", value: null },
|
||||||
{ label: u ? "Save" : "Create user", kind: "primary", onclick: async () => {
|
{ label: u ? "Save" : "Create user", kind: "primary", onclick: async () => {
|
||||||
@@ -69,6 +71,8 @@ async function userDialog(u, grants) {
|
|||||||
if (service.input.checked) data.password = "";
|
if (service.input.checked) data.password = "";
|
||||||
else if (pw.value) data.password = pw.value;
|
else if (pw.value) data.password = pw.value;
|
||||||
if (u) data.disabled = disabled.input.checked;
|
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 {
|
try {
|
||||||
if (u) await api("PATCH", "/users/" + u.id, { body: data });
|
if (u) await api("PATCH", "/users/" + u.id, { body: data });
|
||||||
else await api("POST", "/users", { 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 "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
|
run "certificate metrics" 0 curl -sf http://127.0.0.1:$HTTP/metrics
|
||||||
contains '^jarl_certificate_failing{certificate="ns1",source="acme"} 0$'
|
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
|
# Encrypted DNS set up by Jarl (docs/encrypted-dns.md), next to the
|
||||||
# hand-written listener above.
|
# hand-written listener above.
|
||||||
EDOT=${EDOT_PORT:-25855}; EDOH=${EDOH_PORT:-25856}
|
EDOT=${EDOT_PORT:-25855}; EDOH=${EDOH_PORT:-25856}
|
||||||
|
|||||||
Reference in new issue
Block a user