Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -812,6 +812,10 @@ the proxy will pick it up on its own. An `acme` hold means the certificate
authority rejected an order, and lifts the same way. A `rate_limited` hold is
the authority's own window, and waits it out.

Both `domains list` and `domains stats` take `--json` for scripts and the `dash`
gem; the hold shows up under `quarantine.<domain>` with `until`, `failures` and
`kind`.

If you know the cause is already fixed and do not want to wait:

kamal-proxy domains retry new.example.com
Expand Down
1 change: 1 addition & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ Proxy-side roadmap for the dash fork. The cross-repo release sequencing, strateg
| ~~**On-demand TLS with `ask` endpoint**~~ | DONE (PR #50) | `internal/server/tls_on_demand.go`; per-handshake gate in `Router.GetCertificate`, which asks the on-demand endpoint before any shared manager sees the name |
| Min-TLS version / ciphers (no `MinVersion` today → TLS 1.2 default) | port PR #199 | `server.go:158` TLS config; `run` flag |
| Cert observability — dashboards/alerts on the R1-wired metrics | — | `internal/metrics` |
| Machine-readable domain status for the gem | #121 | DONE — `domains list --json` emits `DomainsStatusResponse` verbatim (`internal/cmd/domains.go`), the same struct the RPC already carries, so there is one contract, not a CLI-only copy of it; `domains stats --json` emits a CLI-side `DomainsStatsSummary` folded from the same response. The JSON tags on the status structs (`internal/server/commands.go`) are now a public surface: `quarantine.<domain>.{until,failures,kind}` is what `dash doctor` reads to name a hold. `expires_at`/`fetched_at` switched from `omitempty` (a no-op on `time.Time`) to `omitzero`, so an uncertified host no longer reports a year-0001 expiry. The "dynamic domains are not enabled" RPC error stays a non-zero exit in JSON mode — the gem degrades on exit status, not on parsing `{}` |

## R5 — Traffic shaping & headers

Expand Down
187 changes: 120 additions & 67 deletions internal/cmd/domains.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cmd

import (
"encoding/json"
"errors"
"fmt"
"maps"
Expand Down Expand Up @@ -32,7 +33,7 @@ func newDomainsCommand() *domainsCommand {
return domainsCommand
}

func fetchDomainsStatus(fn func(response server.DomainsStatusResponse)) error {
func fetchDomainsStatus(fn func(response server.DomainsStatusResponse) error) error {
return withRPCClient(globalConfig.SocketPath(), func(client *rpc.Client) error {
var response server.DomainsStatusResponse

Expand All @@ -41,13 +42,23 @@ func fetchDomainsStatus(fn func(response server.DomainsStatusResponse)) error {
return err
}

fn(response)
return nil
return fn(response)
})
}

func printJSON(value any) error {
data, err := json.MarshalIndent(value, "", " ")
if err != nil {
return err
}

fmt.Println(string(data))
return nil
}

type domainsListCommand struct {
cmd *cobra.Command
cmd *cobra.Command
json bool
}

func newDomainsListCommand() *domainsListCommand {
Expand All @@ -60,58 +71,69 @@ func newDomainsListCommand() *domainsListCommand {
Aliases: []string{"ls"},
}

domainsListCommand.cmd.Flags().BoolVar(&domainsListCommand.json, "json", false, "Output the domain list as JSON")

return domainsListCommand
}

func (c *domainsListCommand) run(cmd *cobra.Command, args []string) error {
return fetchDomainsStatus(func(response server.DomainsStatusResponse) {
table := NewTable()
table.AddRow([]string{"Service", "Domain", "Certified", "Quarantined until", "Removal held"})

for _, name := range slices.Sorted(maps.Keys(response.Services)) {
service := response.Services[name]
domains := slices.SortedFunc(slices.Values(service.Domains), func(a, b server.DomainStatus) int {
return strings.Compare(a.Domain, b.Domain)
})

heldRemovals := make(map[string]struct{}, len(service.HeldRemovals))
for _, domain := range service.HeldRemovals {
heldRemovals[domain] = struct{}{}
}
return fetchDomainsStatus(func(response server.DomainsStatusResponse) error {
if c.json {
return printJSON(response)
}

for _, domain := range domains {
certified := "no"
if domain.Certified {
certified = "yes"
}
c.displayTable(response)
return nil
})
}

quarantined := holdDescription(response.Quarantine, domain.Domain)
func (c *domainsListCommand) displayTable(response server.DomainsStatusResponse) {
table := NewTable()
table.AddRow([]string{"Service", "Domain", "Certified", "Quarantined until", "Removal held"})

held := ""
if _, ok := heldRemovals[domain.Domain]; ok {
held = "yes"
}
for _, name := range slices.Sorted(maps.Keys(response.Services)) {
service := response.Services[name]
domains := slices.SortedFunc(slices.Values(service.Domains), func(a, b server.DomainStatus) int {
return strings.Compare(a.Domain, b.Domain)
})

table.AddRow([]string{name, domain.Domain, certified, quarantined, held})
}
heldRemovals := make(map[string]struct{}, len(service.HeldRemovals))
for _, domain := range service.HeldRemovals {
heldRemovals[domain] = struct{}{}
}

// Deploy-registered hosts have no domain source to group them under,
// but they are the common case — and the one an operator is staring at
// during a DNS cutover, wondering why the certificate has not arrived.
for _, domain := range slices.Sorted(maps.Keys(response.Registered)) {
entry := response.Registered[domain]

for _, domain := range domains {
certified := "no"
if entry.Certified {
if domain.Certified {
certified = "yes"
}

table.AddRow([]string{entry.Service, domain, certified, holdDescription(response.Quarantine, domain), ""})
quarantined := holdDescription(response.Quarantine, domain.Domain)

held := ""
if _, ok := heldRemovals[domain.Domain]; ok {
held = "yes"
}

table.AddRow([]string{name, domain.Domain, certified, quarantined, held})
}
}

table.Print()
})
// Deploy-registered hosts have no domain source to group them under,
// but they are the common case — and the one an operator is staring at
// during a DNS cutover, wondering why the certificate has not arrived.
for _, domain := range slices.Sorted(maps.Keys(response.Registered)) {
entry := response.Registered[domain]

certified := "no"
if entry.Certified {
certified = "yes"
}

table.AddRow([]string{entry.Service, domain, certified, holdDescription(response.Quarantine, domain), ""})
}

table.Print()
}

// holdDescription renders a domain's issuance hold for the listing: when it
Expand All @@ -130,7 +152,21 @@ func holdDescription(quarantine map[string]server.QuarantineStatus, domain strin
}

type domainsStatsCommand struct {
cmd *cobra.Command
cmd *cobra.Command
json bool
}

// DomainsStatsSummary is the machine-readable form of `domains stats`.
type DomainsStatsSummary struct {
Services int `json:"services"`
DynamicDomains int `json:"dynamic_domains"`
DynamicCertified int `json:"dynamic_certified"`
RegisteredHosts int `json:"registered_hosts"`
RegisteredCertified int `json:"registered_certified"`
Queued int `json:"queued"`
Quarantined int `json:"quarantined"`
HeldRemovals int `json:"held_removals"`
Certificates int `json:"certificates"`
}

func newDomainsStatsCommand() *domainsStatsCommand {
Expand All @@ -142,41 +178,58 @@ func newDomainsStatsCommand() *domainsStatsCommand {
Args: cobra.NoArgs,
}

domainsStatsCommand.cmd.Flags().BoolVar(&domainsStatsCommand.json, "json", false, "Output the counters as JSON")

return domainsStatsCommand
}

func (c *domainsStatsCommand) run(cmd *cobra.Command, args []string) error {
return fetchDomainsStatus(func(response server.DomainsStatusResponse) {
domains := 0
certified := 0
held := 0
for _, service := range response.Services {
domains += len(service.Domains)
held += len(service.HeldRemovals)
for _, domain := range service.Domains {
if domain.Certified {
certified++
}
}
return fetchDomainsStatus(func(response server.DomainsStatusResponse) error {
summary := summarizeDomains(response)

if c.json {
return printJSON(summary)
}

registeredCertified := 0
for _, entry := range response.Registered {
if entry.Certified {
registeredCertified++
fmt.Printf("Services with domain sources: %d\n", summary.Services)
fmt.Printf("Dynamic domains: %d\n", summary.DynamicDomains)
fmt.Printf("Dynamic certified: %d\n", summary.DynamicCertified)
fmt.Printf("Registered hosts: %d\n", summary.RegisteredHosts)
fmt.Printf("Registered certified: %d\n", summary.RegisteredCertified)
fmt.Printf("Queued for issuance: %d\n", summary.Queued)
fmt.Printf("Quarantined: %d\n", summary.Quarantined)
fmt.Printf("Held removals: %d\n", summary.HeldRemovals)
fmt.Printf("Managed certificates: %d\n", summary.Certificates)
return nil
})
}

func summarizeDomains(response server.DomainsStatusResponse) DomainsStatsSummary {
summary := DomainsStatsSummary{
Services: len(response.Services),
RegisteredHosts: len(response.Registered),
Queued: response.QueueLength,
Quarantined: len(response.Quarantine),
Certificates: response.Certificates,
}

for _, service := range response.Services {
summary.DynamicDomains += len(service.Domains)
summary.HeldRemovals += len(service.HeldRemovals)
for _, domain := range service.Domains {
if domain.Certified {
summary.DynamicCertified++
}
}
}

fmt.Printf("Services with domain sources: %d\n", len(response.Services))
fmt.Printf("Dynamic domains: %d\n", domains)
fmt.Printf("Dynamic certified: %d\n", certified)
fmt.Printf("Registered hosts: %d\n", len(response.Registered))
fmt.Printf("Registered certified: %d\n", registeredCertified)
fmt.Printf("Queued for issuance: %d\n", response.QueueLength)
fmt.Printf("Quarantined: %d\n", len(response.Quarantine))
fmt.Printf("Held removals: %d\n", held)
fmt.Printf("Managed certificates: %d\n", response.Certificates)
})
for _, entry := range response.Registered {
if entry.Certified {
summary.RegisteredCertified++
}
}

return summary
}

type domainsRefreshCommand struct {
Expand Down
Loading