Files
steward/cmd/steward/cert.go
T
abrown38andClaude Opus 5.5 7b3f0f764c The web UI's certificate from Let's Encrypt, through Jarl
tls.acme names the certificate and jarl: the Jarl server and a token that
may only write the challenge record. Steward gets the certificate with
DNS-01 (lego), writing the record through Jarl's API into every view that
has the zone, renews it with a third of its lifetime left and swaps it
in without a restart. A check runs before every order: Jarl, the zone,
the token's permission, a test record, the zone's name servers, public
resolvers, clean-up; each step says what to fix. Staging by default.

With a certificate, each listener serves HTTPS and plain HTTP on one port
(redirecting plain HTTP, except from the server itself).

steward cert / cert check / cert renew, Server -> Certificate in the web
UI, GET /certificate, POST /certificate/check|renew. docs/certificates.md
and docs/jarl.md (the Jarl user and the token's grant).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:46:36 +01:00

159 lines
4.7 KiB
Go

package main
import (
"fmt"
"io"
"os"
"strings"
"time"
"github.com/spf13/cobra"
"steward/internal/api"
)
func certCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "cert",
Aliases: []string{"certificate"},
Short: "The web UI's certificate, obtained through Jarl (administrators)",
Long: `Shows the certificate stewardd obtains itself for its web UI (tls.acme in
steward.yaml, with Jarl publishing the DNS-01 challenge; docs/certificates.md).
'check' tests the whole path without asking the CA; 'renew' gets a new
certificate now.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error { return certStatus() },
}
cmd.AddCommand(&cobra.Command{Use: "status", Short: "Show the certificate", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error { return certStatus() }})
cmd.AddCommand(&cobra.Command{
Use: "check",
Short: "Check that the challenge can be published and seen, without the CA",
Long: `Runs the check stewardd runs before every certificate order: Jarl answers
and knows the token, a zone holds _acme-challenge.DOMAIN, the token may
write it, a test record is written, the zone's name servers and the public
resolvers serve it, and it is removed again. It can take a minute or two.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
c.HTTP.Timeout = 5 * time.Minute
if !flagQuiet && flagOutput == "table" {
fmt.Fprintln(os.Stderr, "checking (this can take a minute or two)…")
}
var ch api.CertCheck
if err := c.Do(ctx(), "POST", "/certificate/check", nil, nil, &ch); err != nil {
return err
}
if err := output(ch, func(w io.Writer) { printCheck(w, &ch) }); err != nil {
return err
}
if !ch.OK {
return fmt.Errorf("the check failed")
}
return nil
},
})
var wait bool
renew := &cobra.Command{
Use: "renew",
Short: "Get a new certificate now (the check runs first)",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var r api.CertificateResponse
if err := c.Do(ctx(), "POST", "/certificate/renew", nil, nil, &r); err != nil {
return err
}
if !wait {
fmt.Println("renewing in the background; follow it with 'steward cert'")
return nil
}
start := time.Now()
for {
time.Sleep(3 * time.Second)
if err := c.Get(ctx(), "/certificate", nil, &r); err != nil {
return err
}
st := r.Status
if st.State != "working" && st.LastAttempt != nil && !st.LastAttempt.Before(start.Add(-time.Second)) {
break
}
}
if err := certStatus(); err != nil {
return err
}
if r.Status.LastError != "" {
return fmt.Errorf("renewing failed")
}
return nil
},
}
renew.Flags().BoolVarP(&wait, "wait", "w", false, "wait until it is done")
cmd.AddCommand(renew)
return cmd
}
func certStatus() error {
c, err := newClient()
if err != nil {
return err
}
var r api.CertificateResponse
if err := c.Get(ctx(), "/certificate", nil, &r); err != nil {
return err
}
return output(r, func(w io.Writer) {
switch r.Source {
case "file":
fmt.Fprintln(w, "Source:\ttls.cert_file (renewed by you)")
return
case "none":
fmt.Fprintln(w, "Source:\tnone: the web UI is plain HTTP (set tls.acme or tls.cert_file in steward.yaml; docs/certificates.md)")
return
}
st := r.Status
fmt.Fprintf(w, "Domain:\t%s\n", st.Domain)
ca := st.Directory
if st.Staging {
ca += " (staging: browsers don't trust it)"
}
fmt.Fprintf(w, "CA:\t%s\n", ca)
fmt.Fprintf(w, "State:\t%s\n", st.State)
if st.NotAfter != nil {
fmt.Fprintf(w, "Valid:\t%s to %s, from %s\n", st.NotBefore.Local().Format("2006-01-02"), st.NotAfter.Local().Format("2006-01-02 15:04"), st.Issuer)
fmt.Fprintf(w, "Renews:\t%s\n", st.RenewAt.Local().Format("2006-01-02"))
}
if st.LastAttempt != nil {
fmt.Fprintf(w, "Last attempt:\t%s\n", st.LastAttempt.Local().Format("2006-01-02 15:04"))
}
if st.NextAttempt != nil && st.State != "working" {
fmt.Fprintf(w, "Next check:\t%s\n", st.NextAttempt.Local().Format("2006-01-02 15:04"))
}
if st.LastError != "" {
fmt.Fprintf(w, "Error:\t%s\n", st.LastError)
}
if st.Check != nil {
fmt.Fprintf(w, "\nLast check, %s:\n", st.Check.Time.Local().Format("2006-01-02 15:04"))
printCheck(w, st.Check)
}
})
}
func printCheck(w io.Writer, c *api.CertCheck) {
for _, s := range c.Steps {
mark := map[string]string{"ok": "ok ", "fail": "FAIL", "warn": "warn", "skip": "skip"}[s.Status]
fmt.Fprintf(w, " %s\t%s\t%s\n", mark, s.Name, s.Detail)
if s.Status == "fail" && len(s.Queries) > 0 {
for _, q := range s.Queries {
fmt.Fprintf(w, " \t\t%s\n", strings.TrimSpace(q))
}
}
}
}