Files
Pulse/internal/relay/protocol.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

247 lines
7.5 KiB
Go

// Package relay implements the Pulse relay client for mobile remote access.
//
// PROTOCOL SYNC: The types and constants in protocol.go are copied from
// pulse-pro/relay-server/protocol.go and MUST stay wire-compatible.
// After changing either copy, run: go test ./internal/relay/ -run TestProtocolDriftGuardrail
package relay
import (
"encoding/binary"
"encoding/json"
"errors"
"fmt"
)
// Protocol version
const ProtocolVersion = 0x01
// Maximum payload size (64KB)
const MaxPayloadSize = 64 * 1024
// Header size: 1 (version) + 1 (type) + 4 (channel) = 6 bytes
const HeaderSize = 6
// Frame types
const (
FrameRegister = 0x01
FrameRegisterAck = 0x02
FrameConnect = 0x03
FrameConnectAck = 0x04
FrameChannelOpen = 0x05
FrameChannelClose = 0x06
FrameData = 0x07
FramePing = 0x08
FramePong = 0x09
FrameError = 0x0A
FrameDrain = 0x0B
FrameKeyExchange = 0x0C
FramePushNotification = 0x0D
)
// Error codes sent in ERROR frames
const (
ErrCodeInternal = "INTERNAL_ERROR"
ErrCodeNotFound = "INSTANCE_NOT_FOUND"
ErrCodeAuthFailed = "AUTH_FAILED"
ErrCodeLicenseInvalid = "LICENSE_INVALID"
ErrCodeLicenseExpired = "LICENSE_EXPIRED"
ErrCodeRateLimited = "RATE_LIMITED"
ErrCodeDuplicate = "DUPLICATE_INSTANCE"
ErrCodeChannelFull = "CHANNEL_LIMIT_REACHED"
ErrCodeDraining = "SERVER_DRAINING"
)
var (
ErrFrameTooShort = errors.New("frame too short: need at least 6 bytes")
ErrUnsupportedVersion = errors.New("unsupported protocol version")
ErrUnknownFrameType = errors.New("unknown frame type")
ErrPayloadTooLarge = errors.New("payload exceeds maximum size")
)
// frameTypeName maps type bytes to names for debugging.
var frameTypeName = map[byte]string{
FrameRegister: "REGISTER",
FrameRegisterAck: "REGISTER_ACK",
FrameConnect: "CONNECT",
FrameConnectAck: "CONNECT_ACK",
FrameChannelOpen: "CHANNEL_OPEN",
FrameChannelClose: "CHANNEL_CLOSE",
FrameData: "DATA",
FramePing: "PING",
FramePong: "PONG",
FrameError: "ERROR",
FrameDrain: "DRAIN",
FrameKeyExchange: "KEY_EXCHANGE",
FramePushNotification: "PUSH_NOTIFICATION",
}
// FrameTypeName returns the human-readable name of a frame type.
func FrameTypeName(t byte) string {
if name, ok := frameTypeName[t]; ok {
return name
}
return fmt.Sprintf("UNKNOWN(0x%02X)", t)
}
// Frame represents a relay protocol frame.
type Frame struct {
Version byte
Type byte
Channel uint32
Payload []byte
}
// EncodeFrame serializes a frame into bytes.
func EncodeFrame(f Frame) ([]byte, error) {
if len(f.Payload) > MaxPayloadSize {
return nil, ErrPayloadTooLarge
}
buf := make([]byte, HeaderSize+len(f.Payload))
buf[0] = f.Version
buf[1] = f.Type
binary.BigEndian.PutUint32(buf[2:6], f.Channel)
copy(buf[HeaderSize:], f.Payload)
return buf, nil
}
// DecodeFrame deserializes bytes into a frame.
func DecodeFrame(data []byte) (Frame, error) {
if len(data) < HeaderSize {
return Frame{}, ErrFrameTooShort
}
version := data[0]
if version != ProtocolVersion {
return Frame{}, fmt.Errorf("%w: got %d, want %d", ErrUnsupportedVersion, version, ProtocolVersion)
}
frameType := data[1]
if _, ok := frameTypeName[frameType]; !ok {
return Frame{}, fmt.Errorf("%w: 0x%02X", ErrUnknownFrameType, frameType)
}
payload := data[HeaderSize:]
if len(payload) > MaxPayloadSize {
return Frame{}, ErrPayloadTooLarge
}
return Frame{
Version: version,
Type: frameType,
Channel: binary.BigEndian.Uint32(data[2:6]),
Payload: payload,
}, nil
}
// --- Control frame JSON payloads ---
// RegisterPayload is sent by the instance in REGISTER frames.
type RegisterPayload struct {
LicenseToken string `json:"license_token"`
SessionToken string `json:"session_token,omitempty"`
InstanceHint string `json:"instance_hint,omitempty"`
ClientVersion string `json:"client_version,omitempty"`
IdentityPubKey string `json:"identity_pub_key,omitempty"`
}
// RegisterAckPayload is sent by the relay in REGISTER_ACK frames.
type RegisterAckPayload struct {
InstanceID string `json:"instance_id"`
SessionToken string `json:"session_token"`
ExpiresAt int64 `json:"expires_at"`
}
// ConnectPayload is sent by the app in CONNECT frames.
type ConnectPayload struct {
InstanceID string `json:"instance_id"`
AuthToken string `json:"auth_token"`
}
// ConnectAckPayload is sent by the relay in CONNECT_ACK frames.
type ConnectAckPayload struct {
ChannelID uint32 `json:"channel_id"`
InstanceID string `json:"instance_id"`
}
// ChannelOpenPayload is sent by the relay to the instance in CHANNEL_OPEN frames.
type ChannelOpenPayload struct {
ChannelID uint32 `json:"channel_id"`
AuthToken string `json:"auth_token"`
}
// ChannelClosePayload is sent in CHANNEL_CLOSE frames.
type ChannelClosePayload struct {
ChannelID uint32 `json:"channel_id"`
Reason string `json:"reason,omitempty"`
}
// ErrorPayload is sent in ERROR frames.
type ErrorPayload struct {
Code string `json:"code"`
Message string `json:"message"`
}
// DrainPayload is sent in DRAIN frames.
type DrainPayload struct {
Reason string `json:"reason,omitempty"`
ReconnURL string `json:"reconn_url,omitempty"`
}
// PushNotificationPayload is sent in PUSH_NOTIFICATION frames.
// Push payloads are visible to Apple/Google — they must NOT contain
// API keys, IP addresses, node names, detailed metrics, or anything
// that would expose infrastructure details.
type PushNotificationPayload struct {
Type string `json:"type"` // "patrol_finding", "patrol_critical", "approval_request", "fix_completed"
Priority string `json:"priority"` // "normal", "high"
Title string `json:"title"` // Short title (≤100 chars)
Body string `json:"body"` // Body text (≤200 chars)
ActionType string `json:"action_type,omitempty"` // "view_finding", "approve_fix", "view_fix_result"
ActionID string `json:"action_id,omitempty"` // Finding ID or Approval ID
Category string `json:"category,omitempty"` // Finding category (performance, capacity, etc.)
Severity string `json:"severity,omitempty"` // Finding severity
}
// MarshalControlPayload encodes a control frame payload as JSON bytes.
func MarshalControlPayload(v any) ([]byte, error) {
return json.Marshal(v)
}
// UnmarshalControlPayload decodes a JSON payload into the target struct.
func UnmarshalControlPayload(data []byte, v any) error {
return json.Unmarshal(data, v)
}
// NewFrame creates a frame with the current protocol version.
func NewFrame(frameType byte, channel uint32, payload []byte) Frame {
return Frame{
Version: ProtocolVersion,
Type: frameType,
Channel: channel,
Payload: payload,
}
}
// NewControlFrame creates a frame with a JSON-encoded control payload.
func NewControlFrame(frameType byte, channel uint32, payload any) (Frame, error) {
data, err := MarshalControlPayload(payload)
if err != nil {
return Frame{}, fmt.Errorf("marshal control payload: %w", err)
}
return NewFrame(frameType, channel, data), nil
}
// NewErrorFrame creates an ERROR frame with the given code and message.
func NewErrorFrame(channel uint32, code, message string) (Frame, error) {
return NewControlFrame(FrameError, channel, ErrorPayload{
Code: code,
Message: message,
})
}
// NewPingFrame creates a PING frame.
func NewPingFrame() Frame {
return NewFrame(FramePing, 0, nil)
}
// NewPongFrame creates a PONG frame.
func NewPongFrame() Frame {
return NewFrame(FramePong, 0, nil)
}