package main import ( "errors" "fmt" "io" "net/url" "os" "strings" "time" "github.com/spf13/cobra" "jarl/internal/client" ) type certStatus struct { State string `json:"state"` Message string `json:"message"` } type certInfo struct { ID int64 `json:"id"` Name string `json:"name"` Source string `json:"source"` Domains []string `json:"domains"` CertFile string `json:"cert_file,omitempty"` KeyFile string `json:"key_file,omitempty"` Uses []string `json:"uses"` Challenge string `json:"challenge,omitempty"` KeyType string `json:"key_type,omitempty"` Comment string `json:"comment"` Names []string `json:"names,omitempty"` NotBefore *time.Time `json:"not_before,omitempty"` NotAfter *time.Time `json:"not_after,omitempty"` Issuer string `json:"issuer,omitempty"` Serial string `json:"serial,omitempty"` RenewAt *time.Time `json:"renew_at,omitempty"` LastAttempt *time.Time `json:"last_attempt,omitempty"` LastSuccess *time.Time `json:"last_success,omitempty"` LastError string `json:"last_error,omitempty"` Failures int `json:"failures,omitempty"` LastRoutes []string `json:"last_routes,omitempty"` Status certStatus `json:"status"` NextAttempt *time.Time `json:"next_attempt,omitempty"` IssuingSince *time.Time `json:"issuing_since,omitempty"` Serving bool `json:"serving,omitempty"` Files *struct { Chain string `json:"chain"` Key string `json:"key"` } `json:"files,omitempty"` } type certAccount struct { Directory string `json:"directory"` Email string `json:"email"` AgreeTOS bool `json:"agree_tos"` EAB bool `json:"eab"` Registered bool `json:"registered"` URI string `json:"uri"` RegisteredAt *time.Time `json:"registered_at"` Porkbun bool `json:"porkbun"` Resolvers []string `json:"resolvers"` Dir string `json:"dir"` ActiveWebCertificate string `json:"active_web_certificate"` } type certRoute struct { Domain string `json:"domain"` Record string `json:"record"` Target string `json:"target"` Route string `json:"route"` Zone string `json:"zone"` PublicNS []string `json:"public_ns"` Why string `json:"why"` Error string `json:"error"` } func certPath(name string) string { return "/certificates/" + url.PathEscape(name) } func certCmd() *cobra.Command { cmd := &cobra.Command{ Use: "cert", Aliases: []string{"certificate", "certificates"}, Short: "TLS certificates Jarl obtains and renews, or reads from files (docs/certificates.md)", Long: `Certificates for jarld's own HTTPS (use "web") and for named's DNS over TLS and HTTPS (use "dns": written where named can read them). Jarl obtains them from an ACME CA (Let's Encrypt by default; the account is set up in jarl.yaml under acme.account) and renews them itself. It answers the DNS-01 challenges in a zone it serves, or through Porkbun's API while Porkbun serves the domain ("auto" decides at every renewal from the domain's public name servers). A certificate you renew yourself can be used too (--cert-file and --key-file): Jarl rereads it when it changes.`, Example: ` jarl cert check --domain ns1.adbraun.lol # where would the challenge go? jarl cert create ns1 --domain ns1.adbraun.lol --use web --use dns jarl cert create wild --domain adbraun.lol --domain '*.adbraun.lol' --use dns jarl cert create own --cert-file /etc/ssl/ns1/fullchain.pem --key-file /etc/ssl/ns1/privkey.pem --use web jarl cert list jarl cert issue ns1 # renew now jarl cert update ns1 --challenge porkbun # pin the route (auto, jarl or porkbun)`, } cmd.AddCommand(certListCmd(), certShowCmd(), certCreateCmd(), certUpdateCmd(), certIssueCmd(), certCheckCmd(), certDeleteCmd(), certAccountCmd()) return cmd } func certListCmd() *cobra.Command { return &cobra.Command{ Use: "list", Aliases: []string{"ls"}, Short: "List certificates", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { c, err := newClient() if err != nil { return err } var r struct { Certificates []certInfo `json:"certificates"` } if err := c.Get(ctx(), "/certificates", nil, &r); err != nil { return err } return output(r.Certificates, func(w io.Writer) { fmt.Fprintln(w, "NAME\tSOURCE\tUSES\tNAMES\tEXPIRES\tSTATUS") for _, ci := range r.Certificates { names := ci.Names if len(names) == 0 { names = ci.Domains } state := ci.Status.State if ci.IssuingSince != nil { state = "obtaining" } fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", ci.Name, ci.Source, dash(strings.Join(ci.Uses, ",")), strings.Join(names, " "), fmtDate(ci.NotAfter), state) } }) }, } } func fmtDate(t *time.Time) string { if t == nil { return "-" } return t.Local().Format("2006-01-02") } func printCert(w io.Writer, ci certInfo) { fmt.Fprintf(w, "Name:\t%s\n", ci.Name) switch ci.Source { case "acme": fmt.Fprintf(w, "Source:\tacme (Jarl obtains and renews it)\n") fmt.Fprintf(w, "Domains:\t%s\n", strings.Join(ci.Domains, " ")) fmt.Fprintf(w, "Challenge:\t%s\n", ci.Challenge) fmt.Fprintf(w, "Key type:\t%s\n", ci.KeyType) default: fmt.Fprintf(w, "Source:\tfile (you renew it)\n") fmt.Fprintf(w, "Cert file:\t%s\n", ci.CertFile) fmt.Fprintf(w, "Key file:\t%s\n", ci.KeyFile) } uses := []string{} for _, u := range ci.Uses { switch u { case "web": s := "web (jarld's HTTPS" if ci.Serving { s += ", serving now" } uses = append(uses, s+")") case "dns": uses = append(uses, "dns (for named)") } } fmt.Fprintf(w, "Uses:\t%s\n", dash(strings.Join(uses, ", "))) if ci.Comment != "" { fmt.Fprintf(w, "Comment:\t%s\n", ci.Comment) } state := ci.Status.State if ci.Status.Message != "" { state += ": " + ci.Status.Message } if ci.IssuingSince != nil { state = "obtaining since " + fmtTime(ci.IssuingSince) } fmt.Fprintf(w, "Status:\t%s\n", state) if ci.NotAfter != nil { fmt.Fprintf(w, "Names:\t%s\n", strings.Join(ci.Names, " ")) fmt.Fprintf(w, "Valid:\t%s to %s\n", fmtTime(ci.NotBefore), fmtTime(ci.NotAfter)) fmt.Fprintf(w, "Issuer:\t%s\n", ci.Issuer) fmt.Fprintf(w, "Serial:\t%s\n", ci.Serial) } if ci.Source == "acme" { if ci.NextAttempt != nil && ci.NotAfter != nil { fmt.Fprintf(w, "Renews:\t%s\n", fmtTime(ci.NextAttempt)) } else if ci.NextAttempt != nil { fmt.Fprintf(w, "Next try:\t%s\n", fmtTime(ci.NextAttempt)) } if len(ci.LastRoutes) > 0 { fmt.Fprintf(w, "Challenges:\t%s\n", strings.Join(ci.LastRoutes, "; ")) } } if ci.LastSuccess != nil { fmt.Fprintf(w, "Last success:\t%s\n", fmtTime(ci.LastSuccess)) } if ci.LastError != "" { fmt.Fprintf(w, "Last error:\t%s\n", ci.LastError) } if ci.Files != nil { fmt.Fprintf(w, "For named:\t%s\n\t%s\n", ci.Files.Chain, ci.Files.Key) } } func certShowCmd() *cobra.Command { return &cobra.Command{ Use: "show NAME", Short: "Show a certificate", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { c, err := newClient() if err != nil { return err } var ci certInfo if err := c.Get(ctx(), certPath(args[0]), nil, &ci); err != nil { return err } return output(ci, func(w io.Writer) { printCert(w, ci) }) }, } } type certFlags struct { domains []string uses []string challenge, keyType string comment string certFile, keyFile string } func (f *certFlags) add(cmd *cobra.Command) { fl := cmd.Flags() fl.StringArrayVarP(&f.domains, "domain", "d", nil, "a name on the certificate (repeat for several; *.example.com for a wildcard)") fl.StringArrayVarP(&f.uses, "use", "u", nil, "what it is for: web (jarld's HTTPS) or dns (named's DNS over TLS/HTTPS); repeat for both") fl.StringVar(&f.challenge, "challenge", "", "where DNS-01 challenges go: auto (from the domain's public name servers), jarl or porkbun") fl.StringVar(&f.keyType, "key-type", "", "key type: ec256 (default), ec384, rsa2048, rsa3072 or rsa4096") fl.StringVar(&f.comment, "comment", "", "a comment") fl.StringVar(&f.certFile, "cert-file", "", "a certificate you renew yourself: the full chain (PEM)") fl.StringVar(&f.keyFile, "key-file", "", "its private key (PEM)") } func (f *certFlags) body(cmd *cobra.Command) map[string]any { b := map[string]any{} ch := cmd.Flags().Changed if ch("domain") { b["domains"] = f.domains } if ch("use") { uses := []string{} for _, u := range f.uses { for _, x := range strings.Split(u, ",") { if x = strings.TrimSpace(x); x != "" && x != "none" { uses = append(uses, x) } } } b["uses"] = uses } for flag, key := range map[string]string{"challenge": "challenge", "key-type": "key_type", "comment": "comment", "cert-file": "cert_file", "key-file": "key_file"} { if ch(flag) { v, _ := cmd.Flags().GetString(flag) b[key] = v } } return b } func certCreateCmd() *cobra.Command { var f certFlags var noIssue, noWait bool cmd := &cobra.Command{ Use: "create NAME", Short: "Add a certificate: obtained by Jarl (--domain), or one you renew (--cert-file, --key-file)", Long: `Adds a certificate. With --domain, Jarl obtains it from the CA at once (and renews it from then on); the command waits for the outcome. With --cert-file and --key-file it is a certificate you renew yourself, which Jarl reads (and rereads when the files change). The name is used for the files for named (/NAME/).`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { c, err := newClient() if err != nil { return err } b := f.body(cmd) b["name"] = args[0] b["source"] = "acme" if f.certFile != "" || f.keyFile != "" { b["source"] = "file" } b["issue"] = !noIssue var r struct { Certificate certInfo `json:"certificate"` Error string `json:"error"` } if err := c.Do(ctx(), "POST", "/certificates", nil, b, &r); err != nil { return err } if r.Certificate.IssuingSince != nil && !noWait { return waitIssue(c, args[0]) } if r.Error != "" { return errors.New(r.Error) } return output(r.Certificate, func(w io.Writer) { printCert(w, r.Certificate) }) }, } f.add(cmd) cmd.Flags().BoolVar(&noIssue, "no-issue", false, "don't obtain it now (jarld does within minutes)") cmd.Flags().BoolVar(&noWait, "no-wait", false, "return at once instead of waiting for the CA") return cmd } func certUpdateCmd() *cobra.Command { var f certFlags cmd := &cobra.Command{ Use: "update NAME", Short: "Change a certificate (--domain and --use replace the whole list)", Long: `Changes a certificate. --domain and --use replace the whole list (--use none for no use). New names or a new key type make Jarl obtain it again soon (run 'jarl cert issue NAME' to do it now).`, Example: ` jarl cert update ns1 --use web --use dns jarl cert update ns1 --challenge auto jarl cert update ns1 --domain ns1.adbraun.lol --domain dns.adbraun.lol`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { c, err := newClient() if err != nil { return err } b := f.body(cmd) if len(b) == 0 { return errors.New("nothing to change") } var r struct { Certificate certInfo `json:"certificate"` } if err := c.Do(ctx(), "PATCH", certPath(args[0]), nil, b, &r); err != nil { return err } return output(r.Certificate, func(w io.Writer) { printCert(w, r.Certificate) }) }, } f.add(cmd) return cmd } func certIssueCmd() *cobra.Command { var noWait bool cmd := &cobra.Command{ Use: "issue NAME", Aliases: []string{"renew"}, Short: "Obtain the certificate now (reread the files of one you renew yourself)", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { c, err := newClient() if err != nil { return err } var r struct { Certificate certInfo `json:"certificate"` Error string `json:"error"` } if err := c.Do(ctx(), "POST", certPath(args[0])+"/issue", nil, map[string]any{}, &r); err != nil { return err } if r.Error != "" { return errors.New(r.Error) } if r.Certificate.IssuingSince != nil && !noWait { return waitIssue(c, args[0]) } return output(r.Certificate, func(w io.Writer) { printCert(w, r.Certificate) }) }, } cmd.Flags().BoolVar(&noWait, "no-wait", false, "return at once instead of waiting for the CA") return cmd } // waitIssue polls until jarld has finished obtaining the certificate and // reports the outcome. func waitIssue(c *client.Client, name string) error { tty := !flagQuiet && flagOutput == "table" if tty { fmt.Fprintf(os.Stderr, "Obtaining %s from the CA (this can take a few minutes while the challenges reach every name server)", name) } deadline := time.Now().Add(20 * time.Minute) var ci certInfo for { ci = certInfo{} // a field the answer leaves out must not keep the last value if err := c.Get(ctx(), certPath(name), nil, &ci); err != nil { return err } if ci.IssuingSince == nil || time.Now().After(deadline) { break } if tty { fmt.Fprint(os.Stderr, ".") } time.Sleep(2 * time.Second) } if tty { fmt.Fprintln(os.Stderr) } if ci.IssuingSince != nil { return fmt.Errorf("jarld is still obtaining %s; check later with: jarl cert show %s", name, name) } if err := output(ci, func(w io.Writer) { printCert(w, ci) }); err != nil { return err } if ci.LastError != "" && (ci.LastSuccess == nil || ci.LastAttempt != nil && ci.LastAttempt.After(*ci.LastSuccess)) { return fmt.Errorf("obtaining %s failed (jarld tries again at %s)", name, fmtTime(ci.NextAttempt)) } return nil } func certCheckCmd() *cobra.Command { var f certFlags cmd := &cobra.Command{ Use: "check [NAME]", Short: "Show where each name's DNS-01 challenge would go, without asking the CA", Long: `For an existing certificate, or for --domain names, shows where Jarl would publish each DNS-01 challenge (in a zone it serves, or at Porkbun) and why, seen from the resolvers in acme.resolvers, as the CA would see it. Nothing is published and the CA isn't asked.`, Example: ` jarl cert check ns1 jarl cert check --domain ns1.adbraun.lol --domain '*.adbraun.lol'`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { c, err := newClient() if err != nil { return err } var r struct { OK bool `json:"ok"` Routes []certRoute `json:"routes"` Account certAccount `json:"account"` } if len(args) == 1 { err = c.Do(ctx(), "POST", certPath(args[0])+"/check", nil, map[string]any{}, &r) } else { b := f.body(cmd) if b["domains"] == nil { return errors.New("give a certificate name, or --domain") } err = c.Do(ctx(), "POST", "/certificates/check", nil, b, &r) } if err != nil { return err } if err := output(r, func(w io.Writer) { fmt.Fprintln(w, "NAME\tROUTE\tRECORD\tWHY") for _, rt := range r.Routes { rec := strings.TrimSuffix(rt.Target, ".") if rt.Target != rt.Record && rt.Target != "" { rec += " (CNAME from " + strings.TrimSuffix(rt.Record, ".") + ")" } if rt.Error != "" { fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", rt.Domain, "none", dash(rec), rt.Error) continue } fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", rt.Domain, rt.Route, rec, rt.Why) } if !r.Account.AgreeTOS { fmt.Fprintln(w, "\nNote: acme.account.agree_tos isn't set in jarl.yaml, so Jarl can't register with the CA yet.") } }); err != nil { return err } if !r.OK { return errors.New("not every name can be validated") } return nil }, } cmd.Flags().StringArrayVarP(&f.domains, "domain", "d", nil, "a name to check (repeat for several)") cmd.Flags().StringVar(&f.challenge, "challenge", "", "the challenge setting to check with: auto, jarl or porkbun") return cmd } func certDeleteCmd() *cobra.Command { var yes bool cmd := &cobra.Command{ Use: "delete NAME", Short: "Delete a certificate (and its files for named)", Long: `Deletes a certificate. Its files for named are removed, so take it out of any tls block in named's configuration first. If jarld served it, jarld goes back to tls.cert_file, or to plain HTTP.`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { if !confirm("Delete certificate "+args[0]+"?", yes) { return errors.New("aborted") } c, err := newClient() if err != nil { return err } if err := c.Do(ctx(), "DELETE", certPath(args[0]), nil, nil, nil); err != nil { return err } if !flagQuiet { fmt.Println("deleted certificate", args[0]) } return nil }, } cmd.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask") return cmd } func certAccountCmd() *cobra.Command { return &cobra.Command{ Use: "account", Short: "Show the ACME account and settings (set in jarl.yaml under acme)", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { c, err := newClient() if err != nil { return err } var r struct { Account certAccount `json:"account"` } if err := c.Get(ctx(), "/certificates", nil, &r); err != nil { return err } a := r.Account return output(a, func(w io.Writer) { fmt.Fprintf(w, "Directory:\t%s\n", a.Directory) fmt.Fprintf(w, "Email:\t%s\n", dash(a.Email)) fmt.Fprintf(w, "Terms agreed:\t%v\n", a.AgreeTOS) if a.Registered { fmt.Fprintf(w, "Account:\t%s (since %s)\n", a.URI, fmtTime(a.RegisteredAt)) } else { fmt.Fprintf(w, "Account:\tnot registered yet (it is, with the first certificate)\n") } fmt.Fprintf(w, "EAB:\t%v\n", a.EAB) fmt.Fprintf(w, "Porkbun:\t%v\n", a.Porkbun) fmt.Fprintf(w, "Resolvers:\t%s\n", strings.Join(a.Resolvers, " ")) fmt.Fprintf(w, "Files for named:\t%s\n", a.Dir) fmt.Fprintf(w, "Serving HTTPS with:\t%s\n", dash(a.ActiveWebCertificate)) }) }, } }