Files
Beszel/internal/hub/domains/subdomain_discovery.go
T
Tomas Dvorak 7ea9a069f9 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.
2026-05-05 16:14:45 +02:00

425 lines
13 KiB
Go

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
}