mirror of
https://github.com/Dvorinka/Dash.git
synced 2026-10-28 15:51:27 +00:00
feat(api): phase 5 — alerts, agent depth, subdomains, auth, boards
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>
This commit is contained in:
1 parent
8973be3b41
commit
f3143af351
33 files changed
+2772
-126
No files matched your search
@@ -0,0 +1,128 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package domain
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestFilterCTNames(t *testing.T) {
|
||||
raw := []string{
|
||||
"api.example.com\nwww.example.com",
|
||||
"*.example.com",
|
||||
"example.com",
|
||||
"other.com",
|
||||
"api.example.com", // dup
|
||||
"sub.api.example.com",
|
||||
"evil name.example.com",
|
||||
"example.com.evil.com",
|
||||
}
|
||||
got := FilterCTNames(raw, "example.com")
|
||||
want := map[string]bool{
|
||||
"api.example.com": true, "www.example.com": true,
|
||||
"example.com": true, "sub.api.example.com": true,
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("got %v", got)
|
||||
}
|
||||
for _, n := range got {
|
||||
if !want[n] {
|
||||
t.Fatalf("unexpected name %q", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user