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