Zones, address sets (IPv4 and IPv6 mixed, dynamic sets with timeouts) and services, ordered rules in the early, input, forward and output chains, port forwards with hairpin NAT and IPv6 pinholes, source NAT, redirects, and switchable built-ins (loopback, conntrack, ICMPv6, antispoofing, MSS clamping, DHCPv6 client, flowtable). Every change is validated, checked with nft -c and applied in one transaction to Housecarl's own table, inet housecarl; changes made over the network roll back unless confirmed, and the confirmed ruleset is loaded at boot by housecarl-ruleset.service. An empty configuration applies nothing. Also: hit counters and the drop log (nflog), 'hc import' of an existing nftables ruleset and 'hc takeover', the hc CLI, the REST API and the web UI, users, scoped tokens (sets:write:NAME), the audit log, the web UI's certificate through Jarl, Debian packages, docs, unit tests with real nft 1.1.3, and an e2e test with real traffic (71 checks). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
159 lines
4.7 KiB
Go
159 lines
4.7 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"housecarl/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 housecarld obtains itself for its web UI (tls.acme in
|
|
housecarl.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 housecarld 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 'hc 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 housecarl.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))
|
|
}
|
|
}
|
|
}
|
|
}
|