feat: fleet ops, KEV scan, tunnels, beacon fallback, persistence
Extend owned-fleet control with scheduled tasks, audit log, file browser, HTTPS beacon when WS drops, protocol tunnels, registry/autostart forge options, KEV exposure in full sys check with Telegram alerts, and UI/tests.
This commit is contained in:
@@ -182,7 +182,7 @@ func (e *Evaluator) fire(ev AlertEvent, cooldownKey string) {
|
||||
log.Printf("[Alert] %s: %s", ev.Type, ev.Message)
|
||||
s := e.settings()
|
||||
if s.EnabledForAlertType(ev.Type) {
|
||||
NotifyAll(s.NotifyConfig, "AetherForge "+ev.Type, ev.Message)
|
||||
NotifyAllEvent(s.NotifyConfig, ev.Type, "AetherForge "+ev.Type, ev.Message)
|
||||
}
|
||||
if e.broadcast != nil {
|
||||
e.broadcast(ev)
|
||||
|
||||
56
server/internal/alerts/kev_notify.go
Normal file
56
server/internal/alerts/kev_notify.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package alerts
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// kevExposurePayload mirrors agent KEVScanReport JSON.
|
||||
type kevExposurePayload struct {
|
||||
ExposedCount int `json:"exposed_count"`
|
||||
CriticalCount int `json:"critical_count"`
|
||||
LikelyCount int `json:"likely_count"`
|
||||
RiskScore int `json:"risk_score"`
|
||||
Summary string `json:"summary"`
|
||||
Findings []struct {
|
||||
CVE string `json:"cve"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Severity string `json:"severity"`
|
||||
Detail string `json:"detail"`
|
||||
} `json:"findings"`
|
||||
}
|
||||
|
||||
const EventKEVExposure = "kev_exposure"
|
||||
|
||||
// NotifyKEVFromSysCheck parses a full_sys_check message and sends Telegram if enabled.
|
||||
func NotifyKEVFromSysCheck(n *Notifier, agentName, message string) {
|
||||
if n == nil || strings.TrimSpace(message) == "" {
|
||||
return
|
||||
}
|
||||
var report struct {
|
||||
KEV *kevExposurePayload `json:"kev_exposure"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(message), &report); err != nil || report.KEV == nil {
|
||||
return
|
||||
}
|
||||
k := report.KEV
|
||||
if k.ExposedCount == 0 && k.CriticalCount == 0 {
|
||||
return
|
||||
}
|
||||
s := n.settings()
|
||||
if !s.Events.KEVExposure {
|
||||
return
|
||||
}
|
||||
body := agentName + ": " + k.Summary
|
||||
if body == agentName+": " {
|
||||
body = agentName + ": KEV exposure indicators — exposed=" + strconv.Itoa(k.ExposedCount) + " critical=" + strconv.Itoa(k.CriticalCount)
|
||||
}
|
||||
for _, f := range k.Findings {
|
||||
if f.Status == "exposed" && f.Severity == "critical" {
|
||||
body += "\n• " + f.CVE + " " + f.Name
|
||||
}
|
||||
}
|
||||
n.Emit(EventKEVExposure, "AetherForge KEV alert", body)
|
||||
}
|
||||
18
server/internal/alerts/kev_notify_test.go
Normal file
18
server/internal/alerts/kev_notify_test.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package alerts
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNotifyKEVFromSysCheckNoPanic(t *testing.T) {
|
||||
n := NewNotifier(func() Settings {
|
||||
return NewSettings(NotifyConfig{}, EventToggles{KEVExposure: true})
|
||||
})
|
||||
msg := `{"kev_exposure":{"exposed_count":1,"critical_count":1,"summary":"test","findings":[{"cve":"CVE-2021-26855","name":"ProxyLogon","status":"exposed","severity":"critical"}]}}`
|
||||
NotifyKEVFromSysCheck(n, "worker-1", msg)
|
||||
}
|
||||
|
||||
func TestNotifyKEVSkipsWhenClear(t *testing.T) {
|
||||
n := NewNotifier(func() Settings {
|
||||
return NewSettings(NotifyConfig{TelegramBotToken: "x", TelegramChatID: "1"}, EventToggles{KEVExposure: true})
|
||||
})
|
||||
NotifyKEVFromSysCheck(n, "w", `{"kev_exposure":{"exposed_count":0,"critical_count":0}}`)
|
||||
}
|
||||
@@ -19,11 +19,11 @@ func (n *Notifier) Emit(event string, title, body string) {
|
||||
if !n.eventEnabled(s, event) {
|
||||
return
|
||||
}
|
||||
if s.TelegramBotToken == "" && s.TelegramChatID == "" && !s.EmailEnabled {
|
||||
if s.TelegramBotToken == "" && s.TelegramChatID == "" && !s.EmailEnabled && s.WebhookURL == "" {
|
||||
return
|
||||
}
|
||||
log.Printf("[Notify] %s: %s", event, body)
|
||||
NotifyAll(s.NotifyConfig, title, body)
|
||||
NotifyAllEvent(s.NotifyConfig, event, title, body)
|
||||
}
|
||||
|
||||
func (n *Notifier) eventEnabled(s Settings, event string) bool {
|
||||
@@ -34,6 +34,8 @@ func (n *Notifier) eventEnabled(s Settings, event string) bool {
|
||||
return s.Events.AgentReconnect
|
||||
case EventBuildComplete:
|
||||
return s.Events.BuildComplete
|
||||
case EventKEVExposure:
|
||||
return s.Events.KEVExposure
|
||||
case "offline":
|
||||
return s.Events.AgentOffline
|
||||
case "hashrate_drop":
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
type NotifyConfig struct {
|
||||
TelegramBotToken string
|
||||
TelegramChatID string
|
||||
WebhookURL string
|
||||
EmailEnabled bool
|
||||
SMTPHost string
|
||||
SMTPPort int
|
||||
@@ -89,7 +90,41 @@ func SendEmail(cfg NotifyConfig, subject, body string) error {
|
||||
return smtp.SendMail(addr, auth, from, []string{cfg.EmailTo}, []byte(msg))
|
||||
}
|
||||
|
||||
func SendWebhook(cfg NotifyConfig, event, subject, text string) error {
|
||||
if cfg.WebhookURL == "" {
|
||||
return nil
|
||||
}
|
||||
payload, _ := json.Marshal(map[string]string{
|
||||
"event": event,
|
||||
"title": subject,
|
||||
"message": text,
|
||||
})
|
||||
req, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("webhook status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NotifyAll(cfg NotifyConfig, subject, text string) {
|
||||
_ = SendTelegram(cfg, subject+": "+text)
|
||||
_ = SendEmail(cfg, subject, text)
|
||||
}
|
||||
|
||||
// NotifyAllEvent sends to Telegram, email, and optional operator webhook.
|
||||
func NotifyAllEvent(cfg NotifyConfig, event, subject, text string) {
|
||||
_ = SendTelegram(cfg, subject+": "+text)
|
||||
_ = SendEmail(cfg, subject, text)
|
||||
_ = SendWebhook(cfg, event, subject, text)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ type EventToggles struct {
|
||||
HashrateDrop bool
|
||||
RejectionRate bool
|
||||
BuildComplete bool
|
||||
KEVExposure bool
|
||||
}
|
||||
|
||||
// Settings combines delivery credentials with per-event toggles.
|
||||
|
||||
Reference in New Issue
Block a user