Files
Pulse/internal/relay/push.go
rcourtman ce842cd983 feat(relay): Add push notification pipeline (client-side)
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.
2026-02-06 16:53:22 +00:00

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,
}
}