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.
247 lines
7.5 KiB
Go
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)
|
|
}
|