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:
Tomas DvorakandDevin committed 2026-09-22 12:49:44 +02:00
1 parent 8973be3b41
commit f3143af351
33 files changed
+2772 -126

No files matched your search

+128
View File
@@ -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)
}
}
}