feat(hub,site): enhance domain management and monitor UI
Build Docker images / Hub (push) Failing after 54s

Implement manual domain expiry overrides, improve subdomain discovery via CT logs, and enhance the monitoring dashboard with favicons and configurable display options.

hub:
- allow manual expiry and creation date overrides in domain API when WHOIS lookup fails
- implement JSON parsing for crt.sh certificate transparency log searches in subdomain discovery
- update monitor API routes to use curly brace syntax for path parameters

site:
- add manual registration date and period inputs to domain dialog
- implement monitor favicon support using Google's favicon service
- add configurable display options (uptime pills, heartbeat dots) to monitors table
- update localization files to include new UI elements
This commit is contained in:
Tomas Dvorak
2026-05-10 10:24:28 +02:00
parent b6f40af67f
commit 0dd7db8a82
39 changed files with 641 additions and 218 deletions
+28
View File
@@ -128,6 +128,9 @@ func (h *APIHandler) createDomain(e *core.RequestEvent) error {
NotifyOnSSL bool `json:"notify_on_ssl_expiry"`
NotifyOnDNS bool `json:"notify_on_dns_change"`
NotifyOnReg bool `json:"notify_on_registrar_change"`
// Manual expiry override when WHOIS fails
ExpiryDate string `json:"expiry_date,omitempty"`
CreationDate string `json:"creation_date,omitempty"`
}
if err := json.NewDecoder(e.Request.Body).Decode(&req); err != nil {
return e.BadRequestError("invalid request body", err)
@@ -179,6 +182,7 @@ func (h *APIHandler) createDomain(e *core.RequestEvent) error {
record.Set("user", authRecord.Id)
// Auto-lookup if requested
lookupHadExpiry := false
if req.AutoLookup {
lookupSvc := whois.NewLookupService("")
ctx := e.Request.Context()
@@ -188,6 +192,7 @@ func (h *APIHandler) createDomain(e *core.RequestEvent) error {
// Calculate status based on lookup results
status := domain.DomainStatusUnknown
if domainData.ExpiryDate != nil {
lookupHadExpiry = true
daysUntil := int(time.Until(*domainData.ExpiryDate).Hours() / 24)
if daysUntil < 0 {
status = domain.DomainStatusExpired
@@ -204,6 +209,29 @@ func (h *APIHandler) createDomain(e *core.RequestEvent) error {
}
}
// Apply manual expiry/creation dates if WHOIS didn't find them
if !lookupHadExpiry {
if req.ExpiryDate != "" {
if t, err := time.Parse("2006-01-02", req.ExpiryDate); err == nil {
record.Set("expiry_date", t)
// Recalculate status with manual expiry
daysUntil := int(time.Until(t).Hours() / 24)
status := domain.DomainStatusActive
if daysUntil < 0 {
status = domain.DomainStatusExpired
} else if daysUntil <= req.AlertDaysBefore {
status = domain.DomainStatusExpiring
}
record.Set("status", status)
}
}
if req.CreationDate != "" {
if t, err := time.Parse("2006-01-02", req.CreationDate); err == nil {
record.Set("creation_date", t)
}
}
}
if err := h.app.Save(record); err != nil {
return e.InternalServerError("failed to create domain", err)
}
+58 -5
View File
@@ -3,6 +3,7 @@ package domains
import (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"log"
"net"
@@ -143,21 +144,73 @@ func (sd *SubdomainDiscovery) dnsBruteForce(ctx context.Context, domainName stri
wg.Wait()
}
// ctLogSearch searches certificate transparency logs
// ctLogSearch searches certificate transparency logs via crt.sh
func (sd *SubdomainDiscovery) ctLogSearch(ctx context.Context, domainName string, results chan<- DiscoveryResult) {
// Query crt.sh for certificates
url := fmt.Sprintf("https://crt.sh/?q=%%.%s&output=json", domainName)
resp, err := sd.client.Get(url)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
log.Printf("[subdomain-discovery] CT log search failed for %s: %v", domainName, err)
return
}
resp, err := sd.client.Do(req)
if err != nil {
log.Printf("[subdomain-discovery] CT log search failed for %s: %v", domainName, err)
return
}
defer resp.Body.Close()
// Parse response (simplified - in production would parse JSON)
// For now, just log that we attempted this
log.Printf("[subdomain-discovery] CT log search attempted for %s (status: %d)", domainName, resp.StatusCode)
if resp.StatusCode != http.StatusOK {
log.Printf("[subdomain-discovery] CT log search returned status %d for %s", resp.StatusCode, domainName)
return
}
// Parse crt.sh JSON response
var entries []struct {
NameValue string `json:"name_value"`
}
if err := json.NewDecoder(resp.Body).Decode(&entries); err != nil {
log.Printf("[subdomain-discovery] Failed to parse CT log response for %s: %v", domainName, err)
return
}
seen := make(map[string]bool)
for _, entry := range entries {
// crt.sh returns one name_value per line, may contain wildcards or multiple names
names := strings.Split(entry.NameValue, "\n")
for _, name := range names {
name = strings.TrimSpace(name)
if name == "" || name == domainName {
continue
}
// Remove wildcard prefix
name = strings.TrimPrefix(name, "*.")
// Only include subdomains of the target domain
if !strings.HasSuffix(name, "."+domainName) {
continue
}
subdomain := strings.TrimSuffix(name, "."+domainName)
if subdomain == "" || seen[subdomain] {
continue
}
seen[subdomain] = true
// Try to resolve IPs
ips, _ := net.LookupHost(name)
results <- DiscoveryResult{
Subdomain: subdomain,
FullDomain: name,
IPAddresses: ips,
Source: "certificate",
FoundAt: time.Now(),
}
}
}
log.Printf("[subdomain-discovery] CT log search found %d unique subdomains for %s", len(seen), domainName)
}
// patternEnumeration enumerates common subdomain patterns
+8 -8
View File
@@ -40,16 +40,16 @@ func (h *APIHandler) RegisterRoutes(se *core.ServeEvent) {
// CRUD endpoints
api.GET("", h.listMonitors)
api.POST("", h.createMonitor)
api.GET("/:id", h.getMonitor)
api.PATCH("/:id", h.updateMonitor)
api.DELETE("/:id", h.deleteMonitor)
api.GET("/{id}", h.getMonitor)
api.PATCH("/{id}", h.updateMonitor)
api.DELETE("/{id}", h.deleteMonitor)
// Action endpoints
api.POST("/:id/check", h.manualCheck)
api.POST("/:id/pause", h.pauseMonitor)
api.POST("/:id/resume", h.resumeMonitor)
api.GET("/:id/stats", h.getStats)
api.GET("/:id/heartbeats", h.getHeartbeats)
api.POST("/{id}/check", h.manualCheck)
api.POST("/{id}/pause", h.pauseMonitor)
api.POST("/{id}/resume", h.resumeMonitor)
api.GET("/{id}/stats", h.getStats)
api.GET("/{id}/heartbeats", h.getHeartbeats)
}
// HeartbeatSummary represents a minimal heartbeat for the monitor list