mirror of
https://github.com/Dvorinka/beszel.git
synced 2026-06-03 21:02:56 +00:00
feat(site): implement subdomain discovery and enhanced monitoring dashboard
This commit introduces a comprehensive subdomain discovery system and significantly upgrades the monitoring and domain management user interfaces.
Key changes include:
- **Subdomain Discovery**: Added a new service in the hub that performs advanced subdomain discovery using DNS brute forcing, Certificate Transparency (CT) log searches, pattern enumeration, and HTTP probing.
- **Enhanced Domain Management**:
- Added API endpoints for retrieving, discovering, and deleting subdomains.
- Implemented a new `SubdomainList` component in the UI to manage discovered subdomains.
- Improved WHOIS lookup robustness by supporting a wider range of registry field variations.
- **Advanced Monitoring UI**:
- Introduced `GroupedMonitorsTable` to organize monitors by root domain and their respective subdomains.
- Added visual uptime timelines (heartbeat dots) and response time statistics (Avg, Min, Max, P95, P99) to the monitor detail view.
- Implemented "Uptime Pills" for high-visibility status indicators in the monitors table.
- **Status Page Management**: Replaced the static status pages table with a full `StatusPageManager` capable of managing status pages and incidents.
- **Refactoring & Cleanup**:
- Cleaned up `.gitignore` and removed unused reference submodules.
- Improved domain extraction and grouping logic in the frontend.
- Enhanced the `SystemsTable` with better sorting and layout.
This commit is contained in:
@@ -43,6 +43,11 @@ func (h *APIHandler) RegisterRoutes(se *core.ServeEvent) {
|
||||
api.GET("/{id}/stats", h.getDomainStats)
|
||||
api.POST("/{id}/pause", h.pauseDomain)
|
||||
api.POST("/{id}/resume", h.resumeDomain)
|
||||
api.GET("/{id}/subdomains", h.getDomainSubdomains)
|
||||
api.POST("/{id}/discover-subdomains", h.discoverSubdomains)
|
||||
|
||||
// Subdomain routes
|
||||
api.DELETE("/subdomains/{subdomainId}", h.deleteSubdomain)
|
||||
}
|
||||
|
||||
// listDomains lists all domains for the authenticated user
|
||||
@@ -705,3 +710,104 @@ func cleanDomain(domain string) string {
|
||||
}
|
||||
return strings.ToLower(strings.TrimSpace(domain))
|
||||
}
|
||||
|
||||
// getDomainSubdomains returns all subdomains for a domain
|
||||
func (h *APIHandler) getDomainSubdomains(e *core.RequestEvent) error {
|
||||
authRecord := e.Auth
|
||||
if authRecord == nil {
|
||||
return e.UnauthorizedError("unauthorized", nil)
|
||||
}
|
||||
|
||||
domainID := e.Request.PathValue("id")
|
||||
|
||||
// Verify domain ownership
|
||||
domain, err := h.app.FindRecordById("domains", domainID)
|
||||
if err != nil {
|
||||
return e.NotFoundError("domain not found", err)
|
||||
}
|
||||
if domain.GetString("user") != authRecord.Id {
|
||||
return e.ForbiddenError("not authorized", nil)
|
||||
}
|
||||
|
||||
records, err := h.app.FindAllRecords("subdomains",
|
||||
dbx.NewExp("domain = {:domain}", dbx.Params{"domain": domainID}),
|
||||
)
|
||||
if err != nil {
|
||||
return e.InternalServerError("failed to fetch subdomains", err)
|
||||
}
|
||||
|
||||
subdomains := make([]map[string]interface{}, 0, len(records))
|
||||
for _, record := range records {
|
||||
subdomains = append(subdomains, map[string]interface{}{
|
||||
"id": record.Id,
|
||||
"domain": record.GetString("domain"),
|
||||
"subdomain_name": record.GetString("subdomain_name"),
|
||||
"full_domain": record.GetString("full_domain"),
|
||||
"status": record.GetString("status"),
|
||||
"ip_addresses": record.GetString("ip_addresses"),
|
||||
"http_status": record.GetInt("http_status"),
|
||||
"server_header": record.GetString("server_header"),
|
||||
"discovery_source": record.GetString("discovery_source"),
|
||||
"last_checked": record.GetDateTime("last_checked").Time(),
|
||||
"created": record.GetDateTime("created").Time(),
|
||||
"updated": record.GetDateTime("updated").Time(),
|
||||
})
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, subdomains)
|
||||
}
|
||||
|
||||
// discoverSubdomains triggers subdomain discovery for a domain
|
||||
func (h *APIHandler) discoverSubdomains(e *core.RequestEvent) error {
|
||||
authRecord := e.Auth
|
||||
if authRecord == nil {
|
||||
return e.UnauthorizedError("unauthorized", nil)
|
||||
}
|
||||
|
||||
domainID := e.Request.PathValue("id")
|
||||
|
||||
// Verify domain ownership
|
||||
domain, err := h.app.FindRecordById("domains", domainID)
|
||||
if err != nil {
|
||||
return e.NotFoundError("domain not found", err)
|
||||
}
|
||||
if domain.GetString("user") != authRecord.Id {
|
||||
return e.ForbiddenError("not authorized", nil)
|
||||
}
|
||||
|
||||
// Trigger discovery asynchronously
|
||||
go func() {
|
||||
domainName := domain.GetString("domain_name")
|
||||
userID := authRecord.Id
|
||||
h.scheduler.discoverSubdomainsEnhanced(domain, domainName, userID)
|
||||
}()
|
||||
|
||||
return e.JSON(http.StatusOK, map[string]string{"status": "discovery_started"})
|
||||
}
|
||||
|
||||
// deleteSubdomain deletes a subdomain
|
||||
func (h *APIHandler) deleteSubdomain(e *core.RequestEvent) error {
|
||||
authRecord := e.Auth
|
||||
if authRecord == nil {
|
||||
return e.UnauthorizedError("unauthorized", nil)
|
||||
}
|
||||
|
||||
subdomainID := e.Request.PathValue("subdomainId")
|
||||
|
||||
// Get subdomain
|
||||
subdomain, err := h.app.FindRecordById("subdomains", subdomainID)
|
||||
if err != nil {
|
||||
return e.NotFoundError("subdomain not found", err)
|
||||
}
|
||||
|
||||
// Verify ownership
|
||||
if subdomain.GetString("user") != authRecord.Id {
|
||||
return e.ForbiddenError("not authorized", nil)
|
||||
}
|
||||
|
||||
if err := h.app.Delete(subdomain); err != nil {
|
||||
return e.InternalServerError("failed to delete subdomain", err)
|
||||
}
|
||||
|
||||
return e.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -334,66 +334,38 @@ func (s *Scheduler) checkDomain(record *core.Record) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Discover and save subdomains
|
||||
s.discoverSubdomains(record, domainName, userID)
|
||||
// Discover and save subdomains using enhanced discovery
|
||||
s.discoverSubdomainsEnhanced(record, domainName, userID)
|
||||
|
||||
log.Printf("[domain-scheduler] Updated domain: %s (status: %s)", domainName, status)
|
||||
return nil
|
||||
}
|
||||
|
||||
// discoverSubdomains discovers and saves subdomains for a domain
|
||||
// discoverSubdomains discovers and saves subdomains for a domain (legacy method)
|
||||
func (s *Scheduler) discoverSubdomains(record *core.Record, domainName, userID string) {
|
||||
// Common subdomains to check
|
||||
commonSubdomains := []string{
|
||||
"www", "mail", "ftp", "api", "blog", "shop", "admin", "app", "cdn",
|
||||
"static", "dev", "staging", "test", "demo", "docs", "support", "help",
|
||||
"status", "monitor", "grafana", "prometheus", "db", "cache", "redis",
|
||||
"queue", "worker", "backup", "media", "assets", "download", "upload",
|
||||
"git", "gitlab", "github", "jenkins", "ci", "cd", "vpn", "ssh",
|
||||
"smtp", "imap", "mx", "webmail", "email", "analytics", "stats",
|
||||
"search", "login", "auth", "sso", "oauth", "account", "user",
|
||||
}
|
||||
// Deprecated: Use discoverSubdomainsEnhanced instead
|
||||
s.discoverSubdomainsEnhanced(record, domainName, userID)
|
||||
}
|
||||
|
||||
// Get existing subdomains to avoid duplicates
|
||||
existing, _ := s.app.FindAllRecords("subdomains",
|
||||
dbx.NewExp("domain = {:domain}", dbx.Params{"domain": record.Id}),
|
||||
)
|
||||
existingMap := make(map[string]bool)
|
||||
for _, sub := range existing {
|
||||
existingMap[sub.GetString("subdomain_name")] = true
|
||||
}
|
||||
// discoverSubdomainsEnhanced performs enhanced subdomain discovery
|
||||
func (s *Scheduler) discoverSubdomainsEnhanced(record *core.Record, domainName, userID string) {
|
||||
discovery := NewSubdomainDiscovery(s.app)
|
||||
|
||||
collection, err := s.app.FindCollectionByNameOrId("subdomains")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
results, err := discovery.Discover(ctx, domainName)
|
||||
if err != nil {
|
||||
log.Printf("[domain-scheduler] Subdomain discovery failed for %s: %v", domainName, err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, sub := range commonSubdomains {
|
||||
if existingMap[sub] {
|
||||
continue
|
||||
}
|
||||
|
||||
fullDomain := sub + "." + domainName
|
||||
ips, err := net.LookupHost(fullDomain)
|
||||
if err != nil || len(ips) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Found a valid subdomain
|
||||
subRecord := core.NewRecord(collection)
|
||||
subRecord.Set("domain", record.Id)
|
||||
subRecord.Set("subdomain_name", sub)
|
||||
subRecord.Set("status", "active")
|
||||
subRecord.Set("ip_addresses", strings.Join(ips, ","))
|
||||
subRecord.Set("last_checked", time.Now())
|
||||
subRecord.Set("user", userID)
|
||||
|
||||
if err := s.app.Save(subRecord); err != nil {
|
||||
log.Printf("[domain-scheduler] Failed to save subdomain %s: %v", fullDomain, err)
|
||||
} else {
|
||||
log.Printf("[domain-scheduler] Discovered subdomain: %s", fullDomain)
|
||||
}
|
||||
if err := discovery.SaveSubdomains(record, results, userID); err != nil {
|
||||
log.Printf("[domain-scheduler] Failed to save subdomains for %s: %v", domainName, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[domain-scheduler] Discovered %d subdomains for %s", len(results), domainName)
|
||||
}
|
||||
|
||||
// trackChanges compares old and new data and returns history entries
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
package domains
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
// SubdomainDiscovery handles advanced subdomain discovery
|
||||
type SubdomainDiscovery struct {
|
||||
app core.App
|
||||
client *http.Client
|
||||
wordlist []string
|
||||
}
|
||||
|
||||
// NewSubdomainDiscovery creates a new subdomain discovery service
|
||||
func NewSubdomainDiscovery(app core.App) *SubdomainDiscovery {
|
||||
return &SubdomainDiscovery{
|
||||
app: app,
|
||||
client: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
},
|
||||
},
|
||||
wordlist: getEnhancedWordlist(),
|
||||
}
|
||||
}
|
||||
|
||||
// DiscoveryResult represents a discovered subdomain
|
||||
type DiscoveryResult struct {
|
||||
Subdomain string `json:"subdomain"`
|
||||
FullDomain string `json:"full_domain"`
|
||||
IPAddresses []string `json:"ip_addresses"`
|
||||
StatusCode int `json:"status_code,omitempty"`
|
||||
Server string `json:"server,omitempty"`
|
||||
Source string `json:"source"` // dns, certificate, http, etc.
|
||||
FoundAt time.Time `json:"found_at"`
|
||||
}
|
||||
|
||||
// Discover performs comprehensive subdomain discovery
|
||||
func (sd *SubdomainDiscovery) Discover(ctx context.Context, domainName string) ([]DiscoveryResult, error) {
|
||||
var results []DiscoveryResult
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Create a channel for results
|
||||
resultChan := make(chan DiscoveryResult, 100)
|
||||
|
||||
// Start multiple discovery methods concurrently
|
||||
wg.Add(4)
|
||||
|
||||
// 1. DNS brute force with enhanced wordlist
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
sd.dnsBruteForce(ctx, domainName, resultChan)
|
||||
}()
|
||||
|
||||
// 2. Certificate transparency log search
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
sd.ctLogSearch(ctx, domainName, resultChan)
|
||||
}()
|
||||
|
||||
// 3. DNS enumeration via common patterns
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
sd.patternEnumeration(ctx, domainName, resultChan)
|
||||
}()
|
||||
|
||||
// 4. HTTP probe for common subdomains
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
sd.httpProbe(ctx, domainName, resultChan)
|
||||
}()
|
||||
|
||||
// Collect results in a separate goroutine
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(resultChan)
|
||||
}()
|
||||
|
||||
// Process results
|
||||
seen := make(map[string]bool)
|
||||
for result := range resultChan {
|
||||
if seen[result.Subdomain] {
|
||||
continue
|
||||
}
|
||||
seen[result.Subdomain] = true
|
||||
|
||||
mu.Lock()
|
||||
results = append(results, result)
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// dnsBruteForce performs DNS brute forcing with wordlist
|
||||
func (sd *SubdomainDiscovery) dnsBruteForce(ctx context.Context, domainName string, results chan<- DiscoveryResult) {
|
||||
semaphore := make(chan struct{}, 20) // Limit concurrency
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for _, word := range sd.wordlist {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
semaphore <- struct{}{}
|
||||
|
||||
go func(word string) {
|
||||
defer wg.Done()
|
||||
defer func() { <-semaphore }()
|
||||
|
||||
subdomain := word + "." + domainName
|
||||
ips, err := net.LookupHost(subdomain)
|
||||
if err != nil || len(ips) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
results <- DiscoveryResult{
|
||||
Subdomain: word,
|
||||
FullDomain: subdomain,
|
||||
IPAddresses: ips,
|
||||
Source: "dns",
|
||||
FoundAt: time.Now(),
|
||||
}
|
||||
}(word)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// ctLogSearch searches certificate transparency logs
|
||||
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)
|
||||
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)
|
||||
}
|
||||
|
||||
// patternEnumeration enumerates common subdomain patterns
|
||||
func (sd *SubdomainDiscovery) patternEnumeration(ctx context.Context, domainName string, results chan<- DiscoveryResult) {
|
||||
patterns := []string{
|
||||
"api-v1", "api-v2", "api-v3", "api-dev", "api-staging", "api-prod",
|
||||
"app-dev", "app-staging", "app-prod", "web-dev", "web-staging",
|
||||
"admin-dev", "admin-staging", "admin-prod",
|
||||
"portal-dev", "portal-staging", "portal-prod",
|
||||
"dashboard-dev", "dashboard-staging", "dashboard-prod",
|
||||
"service-1", "service-2", "service-3",
|
||||
"node-1", "node-2", "node-3",
|
||||
"server-1", "server-2", "server-3",
|
||||
"web-01", "web-02", "web-03",
|
||||
"app-01", "app-02", "app-03",
|
||||
"us-east", "us-west", "eu-west", "eu-central", "ap-south", "ap-northeast",
|
||||
}
|
||||
|
||||
semaphore := make(chan struct{}, 10)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for _, pattern := range patterns {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
semaphore <- struct{}{}
|
||||
|
||||
go func(pattern string) {
|
||||
defer wg.Done()
|
||||
defer func() { <-semaphore }()
|
||||
|
||||
subdomain := pattern + "." + domainName
|
||||
ips, err := net.LookupHost(subdomain)
|
||||
if err != nil || len(ips) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
results <- DiscoveryResult{
|
||||
Subdomain: pattern,
|
||||
FullDomain: subdomain,
|
||||
IPAddresses: ips,
|
||||
Source: "pattern",
|
||||
FoundAt: time.Now(),
|
||||
}
|
||||
}(pattern)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// httpProbe probes common subdomains via HTTP
|
||||
func (sd *SubdomainDiscovery) httpProbe(ctx context.Context, domainName string, results chan<- DiscoveryResult) {
|
||||
commonWebSubdomains := []string{"www", "app", "api", "admin", "dashboard", "portal", "web"}
|
||||
|
||||
semaphore := make(chan struct{}, 5)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for _, sub := range commonWebSubdomains {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
semaphore <- struct{}{}
|
||||
|
||||
go func(sub string) {
|
||||
defer wg.Done()
|
||||
defer func() { <-semaphore }()
|
||||
|
||||
// Try HTTPS first
|
||||
url := fmt.Sprintf("https://%s.%s", sub, domainName)
|
||||
resp, err := sd.client.Get(url)
|
||||
if err != nil {
|
||||
// Try HTTP
|
||||
url = fmt.Sprintf("http://%s.%s", sub, domainName)
|
||||
resp, err = sd.client.Get(url)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Get IP addresses
|
||||
subdomain := sub + "." + domainName
|
||||
ips, _ := net.LookupHost(subdomain)
|
||||
|
||||
results <- DiscoveryResult{
|
||||
Subdomain: sub,
|
||||
FullDomain: subdomain,
|
||||
IPAddresses: ips,
|
||||
StatusCode: resp.StatusCode,
|
||||
Server: resp.Header.Get("Server"),
|
||||
Source: "http",
|
||||
FoundAt: time.Now(),
|
||||
}
|
||||
}(sub)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// SaveSubdomains saves discovered subdomains to the database
|
||||
func (sd *SubdomainDiscovery) SaveSubdomains(domainRecord *core.Record, results []DiscoveryResult, userID string) error {
|
||||
collection, err := sd.app.FindCollectionByNameOrId("subdomains")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get existing subdomains to avoid duplicates
|
||||
existing, _ := sd.app.FindAllRecords("subdomains",
|
||||
dbx.NewExp("domain = {:domain}", dbx.Params{"domain": domainRecord.Id}),
|
||||
)
|
||||
existingMap := make(map[string]bool)
|
||||
for _, sub := range existing {
|
||||
existingMap[sub.GetString("subdomain_name")] = true
|
||||
}
|
||||
|
||||
for _, result := range results {
|
||||
if existingMap[result.Subdomain] {
|
||||
continue
|
||||
}
|
||||
|
||||
record := core.NewRecord(collection)
|
||||
record.Set("domain", domainRecord.Id)
|
||||
record.Set("subdomain_name", result.Subdomain)
|
||||
record.Set("full_domain", result.FullDomain)
|
||||
record.Set("status", "active")
|
||||
record.Set("ip_addresses", strings.Join(result.IPAddresses, ","))
|
||||
record.Set("discovery_source", result.Source)
|
||||
record.Set("last_checked", time.Now())
|
||||
record.Set("user", userID)
|
||||
|
||||
if result.StatusCode > 0 {
|
||||
record.Set("http_status", result.StatusCode)
|
||||
}
|
||||
if result.Server != "" {
|
||||
record.Set("server_header", result.Server)
|
||||
}
|
||||
|
||||
if err := sd.app.Save(record); err != nil {
|
||||
log.Printf("[subdomain-discovery] Failed to save subdomain %s: %v", result.FullDomain, err)
|
||||
} else {
|
||||
log.Printf("[subdomain-discovery] Saved subdomain: %s (source: %s)", result.FullDomain, result.Source)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getEnhancedWordlist returns an enhanced wordlist for subdomain discovery
|
||||
func getEnhancedWordlist() []string {
|
||||
// Comprehensive wordlist including common subdomains
|
||||
wordlist := []string{
|
||||
// Common web
|
||||
"www", "mail", "ftp", "localhost", "admin", "dashboard", "portal",
|
||||
"api", "app", "mobile", "dev", "test", "staging", "demo", "beta",
|
||||
"prod", "production", "live", "www2", "www1", "blog", "shop", "store",
|
||||
"support", "help", "docs", "documentation", "wiki", "forum", "community",
|
||||
"news", "media", "cdn", "static", "assets", "files", "download", "upload",
|
||||
"images", "img", "css", "js", "scripts", "resources", "public", "private",
|
||||
|
||||
// Services
|
||||
"api-v1", "api-v2", "api-v3", "api-dev", "api-staging", "api-prod",
|
||||
"graphql", "rest", "grpc", "websocket", "socket", "ws", "wss",
|
||||
"oauth", "auth", "login", "signin", "signup", "register", "sso",
|
||||
"saml", "oidc", "openid", "keycloak", "auth0", "cognito", "okta",
|
||||
|
||||
// Infrastructure
|
||||
"server", "server1", "server2", "server3", "srv", "srv1", "srv2",
|
||||
"node", "node1", "node2", "node3", "worker", "worker1", "worker2",
|
||||
"web", "web1", "web2", "web3", "app1", "app2", "app3",
|
||||
"db", "database", "mysql", "postgres", "mongodb", "redis", "elasticsearch",
|
||||
"cache", "queue", "rabbitmq", "kafka", "zookeeper", "etcd",
|
||||
|
||||
// Cloud & DevOps
|
||||
"k8s", "kubernetes", "docker", "swarm", "nomad", "consul", "vault",
|
||||
"terraform", "ansible", "puppet", "chef", "salt", "jenkins", "gitlab",
|
||||
"github", "bitbucket", "travis", "circleci", "drone", "argo", "spinnaker",
|
||||
"prometheus", "grafana", "alertmanager", "thanos", "loki", "tempo",
|
||||
"jaeger", "zipkin", "kibana", "elk", "efk", "splunk", "datadog",
|
||||
|
||||
// Security
|
||||
"vpn", "ipsec", "openvpn", "wireguard", "fortinet", "paloalto",
|
||||
"firewall", "waf", "ids", "ips", "siem", "soc", "nids",
|
||||
"scan", "scanner", "security", "sec", "pentest", "vulnerability",
|
||||
|
||||
// Monitoring & Logging
|
||||
"monitor", "monitoring", "status", "health", "healthcheck", "ping",
|
||||
"uptime", "metrics", "logs", "logging", "audit", "trace", "apm",
|
||||
"sentry", "bugsnag", "raygun", "rollbar", "airbrake", "honeybadger",
|
||||
"newrelic", "appdynamics", "dynatrace", "instana", "scout", "skylight",
|
||||
|
||||
// Communication
|
||||
"mail", "email", "smtp", "pop", "imap", "exchange", "webmail",
|
||||
"mailgun", "sendgrid", "mailchimp", " SES", "postmark", "sparkpost",
|
||||
"chat", "slack", "teams", "discord", "zoom", "meet", "webex",
|
||||
"jitsi", "mattermost", "rocket", "element", "matrix",
|
||||
|
||||
// Regions
|
||||
"us", "us-east", "us-west", "us-central", "us-south",
|
||||
"eu", "eu-west", "eu-central", "eu-east", "eu-north", "eu-south",
|
||||
"ap", "ap-south", "ap-northeast", "ap-southeast", "ap-east",
|
||||
"sa", "sa-east", "af", "af-south", "me", "me-south",
|
||||
|
||||
// Environments
|
||||
"sandbox", "playground", "lab", "labs", "experiment", "canary",
|
||||
"blue", "green", "a", "b", "alpha", "beta", "gamma", "delta",
|
||||
"rc", "release", "nightly", "stable", "latest", "edge", "lts",
|
||||
|
||||
// Corporate
|
||||
"corp", "corporate", "enterprise", "business", "company", "org",
|
||||
"intranet", "extranet", "partners", "vendors", "suppliers",
|
||||
"hr", "finance", "accounting", "legal", "compliance", "it",
|
||||
"sales", "marketing", "crm", "erp", "scm", "plm", "hrm",
|
||||
|
||||
// Development tools
|
||||
"git", "svn", "cvs", "mercurial", "hg", "bitkeeper",
|
||||
"repo", "repository", "repos", "source", "code", "src",
|
||||
"build", "ci", "cd", "deploy", "deployment", "release",
|
||||
"artifact", "artifacts", "package", "packages", "registry",
|
||||
|
||||
// Testing
|
||||
"qa", "qc", "test", "testing", "tests", "spec", "specs",
|
||||
"unit", "integration", "e2e", "acceptance", "regression",
|
||||
"mock", "stub", "fake", "dummy", "sandbox", "fixture",
|
||||
|
||||
// Archive & Backup
|
||||
"archive", "archives", "backup", "backups", "snapshot", "snapshots",
|
||||
"old", "legacy", "deprecated", "retired", "historical",
|
||||
|
||||
// Miscellaneous
|
||||
"search", "find", "lookup", "query", "browse", "explorer",
|
||||
"api-docs", "swagger", "openapi", "redoc", "postman", "graphql-playground",
|
||||
"status-page", "statuspage", "trust", "security", "compliance",
|
||||
"terms", "privacy", "legal", "about", "contact", "feedback",
|
||||
"careers", "jobs", "team", "people", "staff", "employees",
|
||||
"press", "media-kit", "brand", "assets", "styleguide", "design",
|
||||
"sitemap", "robots", "humans", "security", "pgp", "key",
|
||||
"invite", "join", "apply", "subscribe", "newsletter", "rss", "atom",
|
||||
"mobile", "m", "touch", "app", "ios", "android", "windows", "mac",
|
||||
"download", "downloads", "dl", "installer", "setup", "update", "upgrade",
|
||||
"payment", "pay", "billing", "invoice", "subscription", "checkout",
|
||||
"cart", "basket", "shop", "store", "buy", "purchase", "order",
|
||||
"account", "my", "profile", "user", "users", "member", "members",
|
||||
"session", "token", "auth", "verify", "validation", "confirm",
|
||||
"reset", "recovery", "forgot", "password", "pass", "pwd",
|
||||
"2fa", "mfa", "totp", "otp", "authenticator", "security-key",
|
||||
"webhook", "callback", "hook", "integration", "connector", "adapter",
|
||||
"plugin", "extension", "addon", "module", "component", "widget",
|
||||
"embed", "iframe", "frame", "proxy", "gateway", "ingress", "egress",
|
||||
"loadbalancer", "lb", "vip", "floating", "virtual", "ha", "failover",
|
||||
"primary", "secondary", "master", "slave", "leader", "follower",
|
||||
"active", "standby", "hot", "warm", "cold", "mirror", "replica",
|
||||
"shard", "shards", "partition", "partitions", "segment", "segments",
|
||||
}
|
||||
|
||||
return wordlist
|
||||
}
|
||||
@@ -438,11 +438,31 @@ func (s *LookupService) parseWHOISOutput(output, domainName string) (*domain.WHO
|
||||
}
|
||||
}
|
||||
|
||||
// Extract dates
|
||||
expiryDate := s.parseDate(data["registry_expiry_date"], data["registrar_registration_expiration_date"],
|
||||
data["expiry_date"], data["expiration_time"], data["expire"], data["paid_until"])
|
||||
creationDate := s.parseDate(data["creation_date"], data["created_date"], data["registration_time"])
|
||||
updatedDate := s.parseDate(data["updated_date"], data["last_updated"])
|
||||
// Extract dates - try many field name variations used by different registries
|
||||
expiryDate := s.parseDate(
|
||||
data["registry_expiry_date"],
|
||||
data["registrar_registration_expiration_date"],
|
||||
data["expiry_date"],
|
||||
data["expiration_time"],
|
||||
data["expire"],
|
||||
data["paid_until"],
|
||||
data["expire_date"],
|
||||
data["renewal_date"],
|
||||
data["valid_until"],
|
||||
)
|
||||
creationDate := s.parseDate(
|
||||
data["creation_date"],
|
||||
data["created_date"],
|
||||
data["registration_time"],
|
||||
data["registered_on"],
|
||||
data["domain_registered"],
|
||||
)
|
||||
updatedDate := s.parseDate(
|
||||
data["updated_date"],
|
||||
data["last_updated"],
|
||||
data["last_modified"],
|
||||
data["modified_date"],
|
||||
)
|
||||
|
||||
// Extract registrar - try multiple field names used by different WHOIS servers
|
||||
registrarName := data["registrar"]
|
||||
@@ -455,6 +475,15 @@ func (s *LookupService) parseWHOISOutput(output, domainName string) (*domain.WHO
|
||||
if registrarName == "" {
|
||||
registrarName = data["registrar_organization"]
|
||||
}
|
||||
if registrarName == "" {
|
||||
registrarName = data["registrant_organization"]
|
||||
}
|
||||
if registrarName == "" {
|
||||
registrarName = data["registrar_url"]
|
||||
}
|
||||
if registrarName == "" {
|
||||
registrarName = data["registrar_abuse_contact_email"]
|
||||
}
|
||||
if registrarName == "" {
|
||||
registrarName = "Unknown"
|
||||
}
|
||||
@@ -477,16 +506,31 @@ func (s *LookupService) parseWHOISOutput(output, domainName string) (*domain.WHO
|
||||
PostalCode: data["registrant_postal_code"],
|
||||
}
|
||||
|
||||
// Try alternate field names for registrant
|
||||
// Try alternate field names for registrant (.eu uses "holder", other variations)
|
||||
if registrant.Name == "" {
|
||||
registrant.Name = data["registrant"]
|
||||
}
|
||||
if registrant.Name == "" {
|
||||
registrant.Name = data["holder"]
|
||||
}
|
||||
if registrant.Name == "" {
|
||||
registrant.Name = data["domain_holder"]
|
||||
}
|
||||
if registrant.Organization == "" {
|
||||
registrant.Organization = data["org"]
|
||||
}
|
||||
if registrant.Organization == "" {
|
||||
registrant.Organization = data["organization"]
|
||||
}
|
||||
if registrant.Organization == "" {
|
||||
registrant.Organization = data["holder_org"]
|
||||
}
|
||||
if registrant.Country == "" {
|
||||
registrant.Country = data["country"]
|
||||
}
|
||||
if registrant.Country == "" {
|
||||
registrant.Country = data["holder_country"]
|
||||
}
|
||||
|
||||
// Parse DNSSEC more thoroughly
|
||||
dnssec := data["dnssec"]
|
||||
|
||||
@@ -52,6 +52,13 @@ func (h *APIHandler) RegisterRoutes(se *core.ServeEvent) {
|
||||
api.GET("/:id/heartbeats", h.getHeartbeats)
|
||||
}
|
||||
|
||||
// HeartbeatSummary represents a minimal heartbeat for the monitor list
|
||||
type HeartbeatSummary struct {
|
||||
Status string `json:"status"`
|
||||
Ping int `json:"ping"`
|
||||
Time time.Time `json:"time"`
|
||||
}
|
||||
|
||||
// MonitorResponse represents a monitor in API responses
|
||||
type MonitorResponse struct {
|
||||
ID string `json:"id"`
|
||||
@@ -69,6 +76,7 @@ type MonitorResponse struct {
|
||||
Description string `json:"description,omitempty"`
|
||||
LastCheck *time.Time `json:"last_check,omitempty"`
|
||||
UptimeStats map[string]float64 `json:"uptime_stats,omitempty"`
|
||||
RecentHeartbeats []HeartbeatSummary `json:"recent_heartbeats,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Keyword string `json:"keyword,omitempty"`
|
||||
JSONQuery string `json:"json_query,omitempty"`
|
||||
@@ -165,6 +173,35 @@ func (h *APIHandler) listMonitors(e *core.RequestEvent) error {
|
||||
})
|
||||
}
|
||||
|
||||
// getRecentHeartbeats fetches the last N heartbeats for a monitor
|
||||
func (h *APIHandler) getRecentHeartbeats(monitorID string, limit int) []HeartbeatSummary {
|
||||
records, err := h.app.FindRecordsByFilter(
|
||||
"monitor_heartbeats",
|
||||
"monitor = {:monitorId}",
|
||||
"-time",
|
||||
limit,
|
||||
0,
|
||||
map[string]any{"monitorId": monitorID},
|
||||
)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
heartbeats := make([]HeartbeatSummary, 0, len(records))
|
||||
for _, hb := range records {
|
||||
var t time.Time
|
||||
if ts := hb.GetDateTime("time"); !ts.IsZero() {
|
||||
t = ts.Time()
|
||||
}
|
||||
heartbeats = append(heartbeats, HeartbeatSummary{
|
||||
Status: hb.GetString("status"),
|
||||
Ping: hb.GetInt("ping"),
|
||||
Time: t,
|
||||
})
|
||||
}
|
||||
return heartbeats
|
||||
}
|
||||
|
||||
// getMonitor returns a single monitor by ID
|
||||
func (h *APIHandler) getMonitor(e *core.RequestEvent) error {
|
||||
id := e.Request.PathValue("id")
|
||||
@@ -182,7 +219,10 @@ func (h *APIHandler) getMonitor(e *core.RequestEvent) error {
|
||||
return e.ForbiddenError("Access denied", nil)
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, recordToResponse(record))
|
||||
resp := recordToResponse(record)
|
||||
resp.RecentHeartbeats = h.getRecentHeartbeats(id, 12)
|
||||
|
||||
return e.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// createMonitor creates a new monitor
|
||||
|
||||
Reference in New Issue
Block a user