tangled
alpha
login
or
join now
rcsheets.net
/
acme-lantern
An ACME client designed to obtain publicly-trusted SSL/TLS certificates for internal resources.
1
fork
atom
overview
issues
pulls
pipelines
Compare changes
Choose any two refs to compare.
base:
main
feat/caddy-ask
no tags found
compare:
main
feat/caddy-ask
no tags found
go
+562
-1
8 changed files
expand all
collapse all
unified
split
README.md
config.example.yaml
internal
config
config.go
db
db.go
server
ask.go
dashboard.go
server.go
main.go
+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))
0
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
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
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
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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"`
0
19
}
20
21
// ACMEConfig holds ACME provider settings
···
57
type EphemeralAPIConfig struct {
58
Enabled bool `yaml:"enabled"`
59
TokenTTLMinutes int `yaml:"token_ttl_minutes"`
0
0
0
0
0
0
0
60
}
61
62
// StorageConfig holds storage location settings
···
125
// Ephemeral API defaults
126
if cfg.EphemeralAPI.TokenTTLMinutes == 0 {
127
cfg.EphemeralAPI.TokenTTLMinutes = 60
0
0
0
0
0
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);
0
0
0
0
0
0
0
0
0
0
69
`
70
71
_, err := db.conn.Exec(schema)
···
212
func (c *Certificate) GetDomains() []string {
213
return strings.Split(c.Domains, ",")
214
}
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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"
0
10
"strconv"
11
"strings"
12
"time"
···
45
IsStaging bool
46
}
47
0
0
0
0
0
0
0
0
0
0
0
0
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
0
0
0
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
0
88
}{
89
Certs: certInfos,
90
Provider: d.cfg.ACME.Provider,
···
95
CheckInterval: d.cfg.Renewal.CheckInterval,
96
CanTriggerManualRenewal: canTrigger,
97
ManualTriggerTimeRemaining: int(manualTimeRemaining.Seconds()),
0
98
}
99
100
w.Header().Set("Content-Type", "text/html; charset=utf-8")
···
384
return info
385
}
386
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
387
const dashboardTemplate = `<!DOCTYPE html>
388
<html lang="en">
389
<head>
···
663
</div>
664
{{end}}
665
</div>
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
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
0
0
0
0
0
0
0
0
0
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
0
0
0
0
0
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
}