mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-02-19 07:50:43 +01:00
Add FramePushNotification (0x0D) frame type and builder helpers for patrol finding, approval request, and fix completion notifications. Client.SendPushNotification() sends on channel 0 (control) with race-free sendCh access. Titles are sanitized via regex to strip infrastructure identifiers (IPs, hostnames, resource names) before payloads reach Apple/Google. Patrol hooks emit push on new critical/warning findings and investigation outcomes.
141 lines
4.4 KiB
Go
141 lines
4.4 KiB
Go
package relay
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
// Notification type constants
|
|
const (
|
|
PushTypePatrolFinding = "patrol_finding"
|
|
PushTypePatrolCritical = "patrol_critical"
|
|
PushTypeApprovalRequest = "approval_request"
|
|
PushTypeFixCompleted = "fix_completed"
|
|
)
|
|
|
|
// Priority constants
|
|
const (
|
|
PushPriorityNormal = "normal"
|
|
PushPriorityHigh = "high"
|
|
)
|
|
|
|
// Action type constants
|
|
const (
|
|
PushActionViewFinding = "view_finding"
|
|
PushActionApproveFix = "approve_fix"
|
|
PushActionViewFixResult = "view_fix_result"
|
|
)
|
|
|
|
// Title and body length limits for push payloads visible to Apple/Google.
|
|
const (
|
|
maxPushTitleLen = 100
|
|
maxPushBodyLen = 200
|
|
)
|
|
|
|
// truncate returns s truncated to maxLen, appending "..." if shortened.
|
|
func truncate(s string, maxLen int) string {
|
|
if len(s) <= maxLen {
|
|
return s
|
|
}
|
|
if maxLen <= 3 {
|
|
return s[:maxLen]
|
|
}
|
|
return s[:maxLen-3] + "..."
|
|
}
|
|
|
|
// ipv4Pattern matches IPv4 addresses regardless of surrounding punctuation
|
|
// (e.g. "192.168.1.10", "(192.168.1.10)", "[10.0.0.1]:8080").
|
|
// Each octet is constrained to 0-255.
|
|
var ipv4Pattern = regexp.MustCompile(
|
|
`(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)` +
|
|
`(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}` +
|
|
`(?::\d+)?`, // optional :port
|
|
)
|
|
|
|
// resourceNamePattern matches common Proxmox-style resource identifiers such as
|
|
// "node-1", "pve-cluster02", "vm-100", "ct-200", as well as hostnames and FQDNs
|
|
// that appear in patrol finding titles. These are replaced with a generic
|
|
// placeholder so push payloads (visible to Apple/Google) don't leak infra details.
|
|
var resourceNamePattern = regexp.MustCompile(
|
|
// Proxmox-style identifiers: node/pve/vm/ct/qemu/lxc followed by separator and ID
|
|
`\b(?:node|pve|vm|ct|qemu|lxc)[-_/]\S+` +
|
|
`|` +
|
|
// FQDN-like: word.word.tld (at least 3 dot-separated labels)
|
|
`\b[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?){2,}\b`,
|
|
)
|
|
|
|
// sanitizeTitle removes infrastructure identifiers (hostnames, IPs, resource
|
|
// names) from a title so the push payload doesn't leak infra context.
|
|
func sanitizeTitle(title string) string {
|
|
// Replace IPv4 addresses (handles any surrounding punctuation)
|
|
title = ipv4Pattern.ReplaceAllString(title, "[resource]")
|
|
|
|
// Replace resource name patterns
|
|
title = resourceNamePattern.ReplaceAllString(title, "[resource]")
|
|
|
|
// Collapse consecutive placeholders (possibly separated by whitespace only)
|
|
for strings.Contains(title, "[resource] [resource]") {
|
|
title = strings.ReplaceAll(title, "[resource] [resource]", "[resource]")
|
|
}
|
|
|
|
return title
|
|
}
|
|
|
|
// NewPatrolFindingNotification creates a push notification for a new patrol finding.
|
|
func NewPatrolFindingNotification(findingID, severity, category, title string) PushNotificationPayload {
|
|
notifType := PushTypePatrolFinding
|
|
priority := PushPriorityNormal
|
|
if severity == "critical" {
|
|
notifType = PushTypePatrolCritical
|
|
priority = PushPriorityHigh
|
|
}
|
|
|
|
body := fmt.Sprintf("New %s %s finding detected", severity, category)
|
|
|
|
return PushNotificationPayload{
|
|
Type: notifType,
|
|
Priority: priority,
|
|
Title: truncate(sanitizeTitle(title), maxPushTitleLen),
|
|
Body: truncate(body, maxPushBodyLen),
|
|
ActionType: PushActionViewFinding,
|
|
ActionID: findingID,
|
|
Category: category,
|
|
Severity: severity,
|
|
}
|
|
}
|
|
|
|
// NewApprovalRequestNotification creates a push notification for a fix needing approval.
|
|
func NewApprovalRequestNotification(approvalID, findingTitle, riskLevel string) PushNotificationPayload {
|
|
body := "A proposed fix requires your approval"
|
|
if riskLevel != "" {
|
|
body = fmt.Sprintf("A %s-risk fix requires your approval", riskLevel)
|
|
}
|
|
|
|
return PushNotificationPayload{
|
|
Type: PushTypeApprovalRequest,
|
|
Priority: PushPriorityHigh,
|
|
Title: truncate(sanitizeTitle(findingTitle), maxPushTitleLen),
|
|
Body: truncate(body, maxPushBodyLen),
|
|
ActionType: PushActionApproveFix,
|
|
ActionID: approvalID,
|
|
}
|
|
}
|
|
|
|
// NewFixCompletedNotification creates a push notification for a completed fix.
|
|
func NewFixCompletedNotification(findingID, title string, success bool) PushNotificationPayload {
|
|
body := "Fix applied successfully"
|
|
if !success {
|
|
body = "Fix attempt failed — review needed"
|
|
}
|
|
|
|
return PushNotificationPayload{
|
|
Type: PushTypeFixCompleted,
|
|
Priority: PushPriorityNormal,
|
|
Title: truncate(sanitizeTitle(title), maxPushTitleLen),
|
|
Body: truncate(body, maxPushBodyLen),
|
|
ActionType: PushActionViewFixResult,
|
|
ActionID: findingID,
|
|
}
|
|
}
|