An ACME client designed to obtain publicly-trusted SSL/TLS certificates for internal resources.

Compare changes

Choose any two refs to compare.

+562 -1
+37
README.md
··· 43 - Basic certificate request and renewal 44 - File-based storage for certificates and private keys 45 - Simple certificate deployment (includes a temporary [Ephemeral Local Download API](docs/ephemeral-api.md)) 46 47 ### Phase 2: Secure Storage 48 - Integration with OpenBao for secure cryptographic material storage ··· 64 - Webhook notifications for renewal events 65 - Certificate monitoring and alerting 66 - iLO/iDRAC deployment plugins 67 68 ## Status 69
··· 43 - Basic certificate request and renewal 44 - File-based storage for certificates and private keys 45 - Simple certificate deployment (includes a temporary [Ephemeral Local Download API](docs/ephemeral-api.md)) 46 + - Optional Caddy on-demand TLS integration via a smart "ask" endpoint 47 48 ### Phase 2: Secure Storage 49 - Integration with OpenBao for secure cryptographic material storage ··· 65 - Webhook notifications for renewal events 66 - Certificate monitoring and alerting 67 - iLO/iDRAC deployment plugins 68 + 69 + ## Caddy On-Demand TLS Ask Endpoint 70 + 71 + Lantern can expose an optional endpoint compatible with Caddy's `on_demand_tls` `ask` feature. When enabled, Lantern analyzes managed certificate domains to: 72 + 73 + - Infer a primary base domain (eTLD+1) 74 + - Detect active site-code length (e.g., two-character codes like `c2`) 75 + - Suggest a honeypot policy (e.g., allow 4+ character unused site codes) 76 + 77 + Enable in `config.yaml`: 78 + 79 + ```yaml 80 + ask_api: 81 + enabled: true 82 + path: "/ask" 83 + allow_public: false 84 + ``` 85 + 86 + Basic Caddyfile example: 87 + 88 + ```caddyfile 89 + { 90 + on_demand_tls { 91 + ask http://lantern.local:8000/ask 92 + } 93 + } 94 + 95 + :443 { 96 + tls { 97 + on_demand 98 + } 99 + respond "hello" 100 + } 101 + ``` 102 + 103 + The endpoint returns 2xx to authorize issuance, otherwise a non-2xx. It also responds with a small JSON body for observability. 104 105 ## Status 106
+27 -1
config.example.yaml
··· 74 # curl -fsS "http://localhost:8000/api/deploy/bundle?domain=example.com&token=<TOKEN>" -o example.com-bundle.zip 75 # Or individually: 76 # curl -fsS "http://localhost:8000/api/deploy/cert?domain=example.com&token=<TOKEN>" -o fullchain.pem 77 - # curl -fsS "http://localhost:8000/api/deploy/key?domain=example.com&token=<TOKEN>" -o privkey.pem
··· 74 # curl -fsS "http://localhost:8000/api/deploy/bundle?domain=example.com&token=<TOKEN>" -o example.com-bundle.zip 75 # Or individually: 76 # curl -fsS "http://localhost:8000/api/deploy/cert?domain=example.com&token=<TOKEN>" -o fullchain.pem 77 + # curl -fsS "http://localhost:8000/api/deploy/key?domain=example.com&token=<TOKEN>" -o privkey.pem 78 + 79 + # Caddy On-Demand TLS ask endpoint (optional) 80 + # If enabled, Lantern exposes an endpoint suitable for Caddy's `ask` feature. 81 + # It analyzes existing managed domains to infer a primary base domain and 82 + # prevalent site-code length. Example policy: if two-character site codes are 83 + # in use (e.g., x1.example.com), allow issuance for unused 4+ character site 84 + # codes directly under the base (e.g., honey1.example.com). 85 + ask_api: 86 + enabled: false 87 + # Path where the ask endpoint is mounted 88 + path: "/ask" 89 + # If false (default), only allow requests from RFC1918/localhost 90 + allow_public: false 91 + 92 + # Example Caddyfile usage: 93 + # { 94 + # on_demand_tls { 95 + # ask http://lantern.local:8000/ask 96 + # } 97 + # } 98 + # :443 { 99 + # tls { 100 + # on_demand 101 + # } 102 + # respond "hello" 103 + # }
+13
internal/config/config.go
··· 16 Certificates []CertificateSpec `yaml:"certificates"` 17 Renewal RenewalConfig `yaml:"renewal"` 18 EphemeralAPI EphemeralAPIConfig `yaml:"ephemeral_api"` 19 } 20 21 // ACMEConfig holds ACME provider settings ··· 57 type EphemeralAPIConfig struct { 58 Enabled bool `yaml:"enabled"` 59 TokenTTLMinutes int `yaml:"token_ttl_minutes"` 60 } 61 62 // StorageConfig holds storage location settings ··· 125 // Ephemeral API defaults 126 if cfg.EphemeralAPI.TokenTTLMinutes == 0 { 127 cfg.EphemeralAPI.TokenTTLMinutes = 60 128 } 129 130 // Validate
··· 16 Certificates []CertificateSpec `yaml:"certificates"` 17 Renewal RenewalConfig `yaml:"renewal"` 18 EphemeralAPI EphemeralAPIConfig `yaml:"ephemeral_api"` 19 + AskAPI AskAPIConfig `yaml:"ask_api"` 20 } 21 22 // ACMEConfig holds ACME provider settings ··· 58 type EphemeralAPIConfig struct { 59 Enabled bool `yaml:"enabled"` 60 TokenTTLMinutes int `yaml:"token_ttl_minutes"` 61 + } 62 + 63 + // AskAPIConfig controls the optional Caddy on-demand TLS ask endpoint 64 + type AskAPIConfig struct { 65 + Enabled bool `yaml:"enabled"` 66 + Path string `yaml:"path"` // default: /ask 67 + AllowPublic bool `yaml:"allow_public"` // if false, restrict to RFC1918/localhost 68 } 69 70 // StorageConfig holds storage location settings ··· 133 // Ephemeral API defaults 134 if cfg.EphemeralAPI.TokenTTLMinutes == 0 { 135 cfg.EphemeralAPI.TokenTTLMinutes = 60 136 + } 137 + 138 + // Ask API defaults 139 + if cfg.AskAPI.Path == "" { 140 + cfg.AskAPI.Path = "/ask" 141 } 142 143 // Validate
+62
internal/db/db.go
··· 66 ); 67 68 CREATE INDEX IF NOT EXISTS idx_certificates_enabled ON certificates(enabled); 69 ` 70 71 _, err := db.conn.Exec(schema) ··· 212 func (c *Certificate) GetDomains() []string { 213 return strings.Split(c.Domains, ",") 214 }
··· 66 ); 67 68 CREATE INDEX IF NOT EXISTS idx_certificates_enabled ON certificates(enabled); 69 + 70 + CREATE TABLE IF NOT EXISTS ask_logs ( 71 + id INTEGER PRIMARY KEY AUTOINCREMENT, 72 + domain TEXT NOT NULL, 73 + allowed INTEGER NOT NULL, 74 + reason TEXT NOT NULL, 75 + decided_at DATETIME DEFAULT CURRENT_TIMESTAMP 76 + ); 77 + 78 + CREATE INDEX IF NOT EXISTS idx_ask_logs_decided_at ON ask_logs(decided_at DESC); 79 ` 80 81 _, err := db.conn.Exec(schema) ··· 222 func (c *Certificate) GetDomains() []string { 223 return strings.Split(c.Domains, ",") 224 } 225 + 226 + // AskLog represents a single ask decision 227 + type AskLog struct { 228 + ID int64 229 + Domain string 230 + Allowed bool 231 + Reason string 232 + DecidedAt string 233 + } 234 + 235 + // AddAskLog inserts a new ask log row 236 + func (db *DB) AddAskLog(domain string, allowed bool, reason string) error { 237 + allowedInt := 0 238 + if allowed { 239 + allowedInt = 1 240 + } 241 + _, err := db.conn.Exec( 242 + "INSERT INTO ask_logs (domain, allowed, reason) VALUES (?, ?, ?)", 243 + domain, allowedInt, reason, 244 + ) 245 + if err != nil { 246 + return fmt.Errorf("inserting ask log: %w", err) 247 + } 248 + return nil 249 + } 250 + 251 + // ListRecentAskLogs returns the N most recent ask logs (default 20 if n<=0) 252 + func (db *DB) ListRecentAskLogs(n int) ([]*AskLog, error) { 253 + if n <= 0 { 254 + n = 20 255 + } 256 + rows, err := db.conn.Query( 257 + "SELECT id, domain, allowed, reason, strftime('%Y-%m-%d %H:%M:%S', decided_at) FROM ask_logs ORDER BY decided_at DESC, id DESC LIMIT ?", 258 + n, 259 + ) 260 + if err != nil { 261 + return nil, fmt.Errorf("querying ask logs: %w", err) 262 + } 263 + defer rows.Close() 264 + 265 + var logs []*AskLog 266 + for rows.Next() { 267 + var l AskLog 268 + var allowedInt int 269 + if err := rows.Scan(&l.ID, &l.Domain, &allowedInt, &l.Reason, &l.DecidedAt); err != nil { 270 + return nil, fmt.Errorf("scanning ask log: %w", err) 271 + } 272 + l.Allowed = allowedInt == 1 273 + logs = append(logs, &l) 274 + } 275 + return logs, rows.Err() 276 + }
+263
internal/server/ask.go
···
··· 1 + package server 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + "sort" 8 + "strings" 9 + ) 10 + 11 + // askResponse is returned as JSON for observability while status code controls Caddy behavior 12 + type askResponse struct { 13 + RequestedDomain string `json:"requested_domain"` 14 + Allowed bool `json:"allowed"` 15 + Reason string `json:"reason"` 16 + PrimaryBaseDomain string `json:"primary_base_domain"` 17 + ActiveSiteCodeLength int `json:"active_site_code_length"` 18 + UsedSiteCodes []string `json:"used_site_codes"` 19 + SuggestedHoneypotPolicy string `json:"suggested_honeypot_policy"` 20 + } 21 + 22 + // handleAsk implements a smart Caddy on-demand TLS ask endpoint 23 + // Query: /ask?domain=example.com 24 + // Returns 2xx if issuance should be allowed, non-2xx otherwise. 25 + func (cs *ChallengeServer) handleAsk(w http.ResponseWriter, r *http.Request) { 26 + // Restrict access to RFC1918 by default unless explicitly allowed 27 + allowPublic := false 28 + if cs.dashboard != nil && cs.dashboard.cfg != nil { 29 + allowPublic = cs.dashboard.cfg.AskAPI.AllowPublic 30 + } 31 + if !allowPublic { 32 + if ok, _ := isRFC1918Address(r.RemoteAddr); !ok { 33 + http.Error(w, "Forbidden", http.StatusForbidden) 34 + return 35 + } 36 + } 37 + 38 + domain := strings.TrimSpace(r.URL.Query().Get("domain")) 39 + if domain == "" { 40 + http.Error(w, "Bad Request: missing domain", http.StatusBadRequest) 41 + return 42 + } 43 + 44 + // Need DB and config via dashboard 45 + if cs.dashboard == nil || cs.dashboard.db == nil || cs.dashboard.cfg == nil { 46 + http.Error(w, "Service Unavailable", http.StatusServiceUnavailable) 47 + return 48 + } 49 + 50 + // Gather all enabled domains from DB 51 + certs, err := cs.dashboard.db.ListCertificates(true) 52 + if err != nil { 53 + http.Error(w, "Internal Server Error", http.StatusInternalServerError) 54 + return 55 + } 56 + allDomains := make(map[string]struct{}) 57 + baseCount := make(map[string]int) 58 + // track frequencies of eTLD+1 59 + 60 + for _, c := range certs { 61 + for _, d := range strings.Split(c.Domains, ",") { 62 + name := strings.TrimSpace(d) 63 + if name == "" || strings.HasPrefix(name, "*.") { 64 + continue 65 + } 66 + allDomains[name] = struct{}{} 67 + if base, err := etldPlusOne(name); err == nil { 68 + baseCount[base]++ 69 + } 70 + } 71 + } 72 + 73 + // Determine primary base domain (most frequent eTLD+1) 74 + var primaryBase string 75 + var maxCount int 76 + for b, n := range baseCount { 77 + if n > maxCount || (n == maxCount && len(b) < len(primaryBase)) { 78 + primaryBase, maxCount = b, n 79 + } 80 + } 81 + 82 + // If we lack data, default deny 83 + if primaryBase == "" { 84 + writeAskJSON(w, askResponse{ 85 + RequestedDomain: domain, 86 + Allowed: false, 87 + Reason: "no baseline domain information", 88 + }, http.StatusForbidden) 89 + return 90 + } 91 + 92 + // Collect site codes: the label immediately left of the primary base 93 + usedSiteCodesSet := make(map[string]struct{}) 94 + siteCodeLengths := make(map[int]int) 95 + for existing := range allDomains { 96 + base, err := etldPlusOne(existing) 97 + if err != nil || base != primaryBase { 98 + continue 99 + } 100 + labels := strings.Split(existing, ".") 101 + // base has at least two labels, site code if there is at least one label before base 102 + if len(labels) < 3 { 103 + continue 104 + } 105 + // siteCode is the label directly preceding base 106 + siteCode := labels[len(labels)-3] 107 + usedSiteCodesSet[siteCode] = struct{}{} 108 + siteCodeLengths[len(siteCode)]++ 109 + } 110 + 111 + // Predominant active site code length 112 + predominantLen := 0 113 + predominantCount := 0 114 + for l, n := range siteCodeLengths { 115 + if n > predominantCount || (n == predominantCount && l < predominantLen) { 116 + predominantLen, predominantCount = l, n 117 + } 118 + } 119 + 120 + // Evaluate requested domain 121 + reqBase, err := etldPlusOne(domain) 122 + if err != nil || reqBase != primaryBase { 123 + writeAskJSON(w, askResponse{ 124 + RequestedDomain: domain, 125 + Allowed: false, 126 + Reason: "domain not under primary base", 127 + PrimaryBaseDomain: primaryBase, 128 + ActiveSiteCodeLength: predominantLen, 129 + UsedSiteCodes: sortedKeys(usedSiteCodesSet), 130 + SuggestedHoneypotPolicy: honeypotPolicy(predominantLen), 131 + }, http.StatusForbidden) 132 + return 133 + } 134 + 135 + // Deny if exact domain is already managed 136 + if _, exists := allDomains[domain]; exists { 137 + writeAskJSON(w, askResponse{ 138 + RequestedDomain: domain, 139 + Allowed: false, 140 + Reason: "domain already managed", 141 + PrimaryBaseDomain: primaryBase, 142 + ActiveSiteCodeLength: predominantLen, 143 + UsedSiteCodes: sortedKeys(usedSiteCodesSet), 144 + SuggestedHoneypotPolicy: honeypotPolicy(predominantLen), 145 + }, http.StatusConflict) 146 + return 147 + } 148 + 149 + labels := strings.Split(domain, ".") 150 + if len(labels) < 3 { 151 + writeAskJSON(w, askResponse{ 152 + RequestedDomain: domain, 153 + Allowed: false, 154 + Reason: "insufficient labels (need site code)", 155 + PrimaryBaseDomain: primaryBase, 156 + ActiveSiteCodeLength: predominantLen, 157 + UsedSiteCodes: sortedKeys(usedSiteCodesSet), 158 + SuggestedHoneypotPolicy: honeypotPolicy(predominantLen), 159 + }, http.StatusForbidden) 160 + return 161 + } 162 + 163 + siteCode := labels[len(labels)-3] 164 + 165 + // Honeypot policy: if active site codes are mostly short (<=2), prefer 4+ char unused site codes 166 + allowed := false 167 + reason := "" 168 + 169 + if predominantLen > 0 && predominantLen <= 2 { 170 + if len(siteCode) >= 4 { 171 + if _, used := usedSiteCodesSet[siteCode]; !used { 172 + // For honeypots, we expect exactly one label of site code (no extra left labels) 173 + // i.e., domain should be {siteCode}.{primaryBase} 174 + if len(labels) == len(strings.Split(primaryBase, "."))+1 { 175 + allowed = true 176 + reason = "unused 4+ char site code under primary base" 177 + } else { 178 + allowed = false 179 + reason = "extra labels present; require {siteCode}.{base}" 180 + } 181 + } else { 182 + allowed = false 183 + reason = "site code already in use" 184 + } 185 + } else { 186 + allowed = false 187 + reason = "site code too short; require >= 4 chars" 188 + } 189 + } else { 190 + // Fallback: deny by default if we cannot infer pattern confidently 191 + allowed = false 192 + reason = "no confident pattern; deny by default" 193 + } 194 + 195 + status := http.StatusForbidden 196 + if allowed { 197 + status = http.StatusOK 198 + } 199 + 200 + // Record in DB (best-effort) 201 + if cs.dashboard != nil && cs.dashboard.db != nil { 202 + _ = cs.dashboard.db.AddAskLog(domain, allowed, reason) 203 + } 204 + 205 + writeAskJSON(w, askResponse{ 206 + RequestedDomain: domain, 207 + Allowed: allowed, 208 + Reason: reason, 209 + PrimaryBaseDomain: primaryBase, 210 + ActiveSiteCodeLength: predominantLen, 211 + UsedSiteCodes: sortedKeys(usedSiteCodesSet), 212 + SuggestedHoneypotPolicy: honeypotPolicy(predominantLen), 213 + }, status) 214 + } 215 + 216 + func writeAskJSON(w http.ResponseWriter, resp askResponse, status int) { 217 + w.Header().Set("Content-Type", "application/json") 218 + w.WriteHeader(status) 219 + _ = json.NewEncoder(w).Encode(resp) 220 + } 221 + 222 + func sortedKeys(m map[string]struct{}) []string { 223 + out := make([]string, 0, len(m)) 224 + for k := range m { 225 + out = append(out, k) 226 + } 227 + sort.Strings(out) 228 + return out 229 + } 230 + 231 + func honeypotPolicy(predominantLen int) string { 232 + if predominantLen > 0 && predominantLen <= 2 { 233 + return "allow 4+ char unused site codes directly under primary base" 234 + } 235 + return "deny by default (no clear site code pattern)" 236 + } 237 + 238 + // etldPlusOne provides a simplified effective TLD+1 extractor without external deps. 239 + // It is NOT a full PSL implementation; it handles common cases like *.co.uk by 240 + // recognizing a small set of multi-label TLDs and otherwise returning the last 241 + // two labels. 242 + func etldPlusOne(domain string) (string, error) { 243 + d := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(domain)), ".") 244 + parts := strings.Split(d, ".") 245 + if len(parts) < 2 { 246 + return "", fmt.Errorf("domain has fewer than 2 labels") 247 + } 248 + // Minimal set of common multi-label public suffixes; extend as needed 249 + multi := map[string]struct{}{ 250 + "co.uk": {}, "org.uk": {}, "ac.uk": {}, "gov.uk": {}, 251 + "com.au": {}, "net.au": {}, "org.au": {}, 252 + } 253 + // Try last two labels as suffix candidate (e.g., example.com) 254 + suffix2 := parts[len(parts)-2] + "." + parts[len(parts)-1] 255 + if _, ok := multi[suffix2]; ok { 256 + if len(parts) < 3 { 257 + return "", fmt.Errorf("insufficient labels for multi-suffix") 258 + } 259 + return parts[len(parts)-3] + "." + suffix2, nil 260 + } 261 + // Default: last two labels 262 + return suffix2, nil 263 + }
+146
internal/server/dashboard.go
··· 7 "html/template" 8 "log" 9 "net/http" 10 "strconv" 11 "strings" 12 "time" ··· 45 IsStaging bool 46 } 47 48 // HandleDashboard serves the main dashboard page 49 func (d *Dashboard) HandleDashboard(w http.ResponseWriter, r *http.Request) { 50 allowed, reason := isRFC1918Address(r.RemoteAddr) ··· 74 // Check if manual trigger is allowed (check rate limit) 75 canTrigger, manualTimeRemaining := d.checkManualTriggerAllowed() 76 77 tmpl := template.Must(template.New("dashboard").Parse(dashboardTemplate)) 78 data := struct { 79 Certs []*CertInfo ··· 85 CheckInterval int 86 CanTriggerManualRenewal bool 87 ManualTriggerTimeRemaining int // in seconds 88 }{ 89 Certs: certInfos, 90 Provider: d.cfg.ACME.Provider, ··· 95 CheckInterval: d.cfg.Renewal.CheckInterval, 96 CanTriggerManualRenewal: canTrigger, 97 ManualTriggerTimeRemaining: int(manualTimeRemaining.Seconds()), 98 } 99 100 w.Header().Set("Content-Type", "text/html; charset=utf-8") ··· 384 return info 385 } 386 387 const dashboardTemplate = `<!DOCTYPE html> 388 <html lang="en"> 389 <head> ··· 663 </div> 664 {{end}} 665 </div> 666 </div> 667 </body> 668 </html>`
··· 7 "html/template" 8 "log" 9 "net/http" 10 + "sort" 11 "strconv" 12 "strings" 13 "time" ··· 46 IsStaging bool 47 } 48 49 + // AskStatus holds derived information about the Ask API and inferred patterns 50 + type AskStatus struct { 51 + Enabled bool 52 + Path string 53 + AllowPublic bool 54 + PrimaryBase string 55 + PredominantLen int 56 + UsedSiteCodes []string 57 + SuggestedPolicy string 58 + Recent []*db.AskLog 59 + } 60 + 61 // HandleDashboard serves the main dashboard page 62 func (d *Dashboard) HandleDashboard(w http.ResponseWriter, r *http.Request) { 63 allowed, reason := isRFC1918Address(r.RemoteAddr) ··· 87 // Check if manual trigger is allowed (check rate limit) 88 canTrigger, manualTimeRemaining := d.checkManualTriggerAllowed() 89 90 + // Compute ask status info (only shown if enabled) 91 + askStatus := d.computeAskStatus() 92 + 93 tmpl := template.Must(template.New("dashboard").Parse(dashboardTemplate)) 94 data := struct { 95 Certs []*CertInfo ··· 101 CheckInterval int 102 CanTriggerManualRenewal bool 103 ManualTriggerTimeRemaining int // in seconds 104 + Ask *AskStatus 105 }{ 106 Certs: certInfos, 107 Provider: d.cfg.ACME.Provider, ··· 112 CheckInterval: d.cfg.Renewal.CheckInterval, 113 CanTriggerManualRenewal: canTrigger, 114 ManualTriggerTimeRemaining: int(manualTimeRemaining.Seconds()), 115 + Ask: askStatus, 116 } 117 118 w.Header().Set("Content-Type", "text/html; charset=utf-8") ··· 402 return info 403 } 404 405 + // computeAskStatus derives current Ask API info and domain pattern inference 406 + func (d *Dashboard) computeAskStatus() *AskStatus { 407 + if d.cfg == nil || !d.cfg.AskAPI.Enabled { 408 + return &AskStatus{Enabled: false} 409 + } 410 + 411 + status := &AskStatus{ 412 + Enabled: true, 413 + Path: d.cfg.AskAPI.Path, 414 + AllowPublic: d.cfg.AskAPI.AllowPublic, 415 + } 416 + 417 + // Load enabled certificates 418 + certs, err := d.db.ListCertificates(true) 419 + if err != nil { 420 + return status 421 + } 422 + 423 + allDomains := make(map[string]struct{}) 424 + baseCount := make(map[string]int) 425 + for _, c := range certs { 426 + for _, dname := range strings.Split(c.Domains, ",") { 427 + name := strings.TrimSpace(dname) 428 + if name == "" || strings.HasPrefix(name, "*.") { 429 + continue 430 + } 431 + allDomains[name] = struct{}{} 432 + if base, err := etldPlusOne(name); err == nil { 433 + baseCount[base]++ 434 + } 435 + } 436 + } 437 + 438 + // Determine primary base 439 + var primaryBase string 440 + var maxCount int 441 + for b, n := range baseCount { 442 + if n > maxCount || (n == maxCount && len(b) < len(primaryBase)) { 443 + primaryBase, maxCount = b, n 444 + } 445 + } 446 + status.PrimaryBase = primaryBase 447 + 448 + if primaryBase == "" { 449 + return status 450 + } 451 + 452 + // Site code stats 453 + usedSiteCodes := make(map[string]struct{}) 454 + siteCodeLengths := make(map[int]int) 455 + for existing := range allDomains { 456 + base, err := etldPlusOne(existing) 457 + if err != nil || base != primaryBase { 458 + continue 459 + } 460 + labels := strings.Split(existing, ".") 461 + if len(labels) < 3 { 462 + continue 463 + } 464 + sc := labels[len(labels)-3] 465 + usedSiteCodes[sc] = struct{}{} 466 + siteCodeLengths[len(sc)]++ 467 + } 468 + 469 + // Predominant length 470 + predLen := 0 471 + predCount := 0 472 + for l, n := range siteCodeLengths { 473 + if n > predCount || (n == predCount && l < predLen) { 474 + predLen, predCount = l, n 475 + } 476 + } 477 + status.PredominantLen = predLen 478 + 479 + // Sort used site codes 480 + if len(usedSiteCodes) > 0 { 481 + status.UsedSiteCodes = make([]string, 0, len(usedSiteCodes)) 482 + for k := range usedSiteCodes { 483 + status.UsedSiteCodes = append(status.UsedSiteCodes, k) 484 + } 485 + sort.Strings(status.UsedSiteCodes) 486 + } 487 + 488 + status.SuggestedPolicy = honeypotPolicy(predLen) 489 + 490 + // Load recent ask logs (last 20) 491 + if logs, err := d.db.ListRecentAskLogs(20); err == nil { 492 + status.Recent = logs 493 + } 494 + return status 495 + } 496 + 497 const dashboardTemplate = `<!DOCTYPE html> 498 <html lang="en"> 499 <head> ··· 773 </div> 774 {{end}} 775 </div> 776 + {{if .Ask.Enabled}} 777 + <div class="section"> 778 + <h2>Caddy Ask Endpoint</h2> 779 + <p>Path: <code>{{.Ask.Path}}</code> | Access: {{if .Ask.AllowPublic}}Public{{else}}RFC1918 only{{end}}</p> 780 + {{if .Ask.PrimaryBase}} 781 + <p>Primary base: <strong>{{.Ask.PrimaryBase}}</strong></p> 782 + <p>Active site-code length: <strong>{{.Ask.PredominantLen}}</strong> ({{len .Ask.UsedSiteCodes}} used)</p> 783 + <p>Suggested policy: <em>{{.Ask.SuggestedPolicy}}</em></p> 784 + {{else}} 785 + <p>No baseline domain information yet. Add some enabled certificates to infer patterns.</p> 786 + {{end}} 787 + {{if .Ask.Recent}} 788 + <h3 style="margin-top:16px">Recent Ask Requests</h3> 789 + <table> 790 + <thead> 791 + <tr> 792 + <th>When</th> 793 + <th>Domain</th> 794 + <th>Decision</th> 795 + <th>Reason</th> 796 + </tr> 797 + </thead> 798 + <tbody> 799 + {{range .Ask.Recent}} 800 + <tr> 801 + <td style="white-space:nowrap">{{.DecidedAt}}</td> 802 + <td>{{.Domain}}</td> 803 + <td>{{if .Allowed}}<span class="badge badge-enabled">Allowed</span>{{else}}<span class="badge badge-disabled">Denied</span>{{end}}</td> 804 + <td>{{.Reason}}</td> 805 + </tr> 806 + {{end}} 807 + </tbody> 808 + </table> 809 + {{end}} 810 + </div> 811 + {{end}} 812 </div> 813 </body> 814 </html>`
+9
internal/server/server.go
··· 77 return cs 78 } 79 80 // EnableEphemeralAPI sets up the temporary token-gated download API. 81 // It generates a random token, stores expiry, and registers handlers. 82 func (cs *ChallengeServer) EnableEphemeralAPI(stor *storage.Storage, ttl time.Duration) error {
··· 77 return cs 78 } 79 80 + // EnableAskAPI registers the Caddy ask endpoint at the provided path 81 + func (cs *ChallengeServer) EnableAskAPI(path string) { 82 + if path == "" { 83 + path = "/ask" 84 + } 85 + cs.mux.HandleFunc(path, cs.handleAsk) 86 + log.Printf("Ask API enabled at http://localhost%[1]s%[2]s", cs.addr, path) 87 + } 88 + 89 // EnableEphemeralAPI sets up the temporary token-gated download API. 90 // It generates a random token, stores expiry, and registers handlers. 91 func (cs *ChallengeServer) EnableEphemeralAPI(stor *storage.Storage, ttl time.Duration) error {
+5
main.go
··· 105 } 106 } 107 108 if err := challengeServer.Start(); err != nil { 109 log.Fatalf("Failed to start HTTP server: %v", err) 110 }
··· 105 } 106 } 107 108 + // Enable Caddy ask API if configured 109 + if cfg.AskAPI.Enabled { 110 + challengeServer.EnableAskAPI(cfg.AskAPI.Path) 111 + } 112 + 113 if err := challengeServer.Start(); err != nil { 114 log.Fatalf("Failed to start HTTP server: %v", err) 115 }