mirror of
https://github.com/Dvorinka/Dash.git
synced 2026-10-28 15:51:27 +00:00
P5b: notifier transport registry (webhook/slack/discord/telegram/ gotify/ntfy/smtp via net/smtp), per-monitor+domain alert rules (failsBeforeDown, latencyWarn, certDays, mute). P5c: per-container cpu/mem, optional SMART/ZFS/GPU exec-or-skip collectors, crt.sh subdomain discovery with daily sweep. P5d: opt-in auth (bcrypt, sqlite sessions, 7d rotating cookie, login rate limit, public-route bypass) and multiple boards (boards table, sections.board_id, /api/boards CRUD, slug routing). Authored By: TDvorak <[email protected]> Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
129 lines
3.5 KiB
Go
129 lines
3.5 KiB
Go
package domain
|
|
|
|
// Subdomain discovery: certificate-transparency names from crt.sh, filtered
|
|
// to the owned suffix, then resolved via A/AAAA. Both network steps are
|
|
// injected seams so tests never leave the process.
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Subdomain is one discovered hostname and its resolved addresses.
|
|
type Subdomain struct {
|
|
Name string `json:"name"`
|
|
IPs []string `json:"ips"`
|
|
}
|
|
|
|
// CTNames returns candidate hostnames for domain from crt.sh.
|
|
// Seam: tests substitute a stub.
|
|
var CTNames = func(ctx context.Context, domain string) ([]string, error) {
|
|
url := "https://crt.sh/?q=%25." + domain + "&output=json"
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err := (&http.Client{Timeout: 20 * time.Second}).Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("crt.sh unreachable")
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("crt.sh returned %d", resp.StatusCode)
|
|
}
|
|
// Bound the response — popular domains can return megabytes.
|
|
var rows []struct {
|
|
NameValue string `json:"name_value"`
|
|
CommonName string `json:"common_name"`
|
|
}
|
|
if err := json.NewDecoder(io.LimitReader(resp.Body, 8<<20)).Decode(&rows); err != nil {
|
|
return nil, fmt.Errorf("crt.sh decode failed")
|
|
}
|
|
var out []string
|
|
for _, r := range rows {
|
|
out = append(out, r.NameValue, r.CommonName)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// ResolveIPs returns A/AAAA addresses for a hostname. Seam: tests stub it.
|
|
var ResolveIPs = func(ctx context.Context, name string) ([]string, error) {
|
|
addrs, err := net.DefaultResolver.LookupIP(ctx, "ip", name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]string, 0, len(addrs))
|
|
for _, a := range addrs {
|
|
out = append(out, a.String())
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// FilterCTNames reduces raw cert names to owned, canonical hostnames:
|
|
// lowercase, wildcard prefix stripped, must equal domain or end in .domain.
|
|
// CT names are untrusted text — stored and rendered as text only.
|
|
func FilterCTNames(raw []string, domain string) []string {
|
|
suffix := "." + domain
|
|
seen := map[string]bool{}
|
|
out := make([]string, 0, len(raw))
|
|
for _, r := range raw {
|
|
for _, name := range strings.Split(r, "\n") {
|
|
name = strings.ToLower(strings.TrimSpace(name))
|
|
name = strings.TrimPrefix(name, "*.")
|
|
if name == "" || (!strings.HasSuffix(name, suffix) && name != domain) {
|
|
continue
|
|
}
|
|
if strings.ContainsAny(name, " \t/\\\"'<>@") {
|
|
continue // cert names are untrusted; drop malformed entries
|
|
}
|
|
if !seen[name] {
|
|
seen[name] = true
|
|
out = append(out, name)
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// DiscoverSubdomains queries CT and resolves each surviving name, bounded:
|
|
// at most 500 candidates considered, 100 resolved, 8 concurrent lookups.
|
|
func DiscoverSubdomains(ctx context.Context, domain string) ([]Subdomain, error) {
|
|
raw, err := CTNames(ctx, domain)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
names := FilterCTNames(raw, domain)
|
|
if len(names) > 500 {
|
|
names = names[:500]
|
|
}
|
|
if len(names) > 100 {
|
|
names = names[:100]
|
|
}
|
|
out := make([]Subdomain, len(names))
|
|
var wg sync.WaitGroup
|
|
sem := make(chan struct{}, 8)
|
|
for i, name := range names {
|
|
out[i].Name = name
|
|
wg.Add(1)
|
|
sem <- struct{}{}
|
|
go func(i int, name string) {
|
|
defer wg.Done()
|
|
defer func() { <-sem }()
|
|
rctx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
|
defer cancel()
|
|
if ips, err := ResolveIPs(rctx, name); err == nil {
|
|
out[i].IPs = ips
|
|
}
|
|
}(i, name)
|
|
}
|
|
wg.Wait()
|
|
return out, nil
|
|
}
|