Files
go-sip/internal/calllog/log.go
T

271 lines
8.9 KiB
Go

// Package calllog writes redacted, durable business facts for outbound calls.
// It never writes the original phone number, credentials, audio, prompts, or
// provider URLs. The phone reference is a keyed digest so one number can be
// correlated across tasks without making the log a contact database.
package calllog
import (
"crypto/hmac"
cryptorand "crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
)
var phonePattern = regexp.MustCompile(`^[0-9]{3,32}$`)
var eventTypePattern = regexp.MustCompile(`^[a-z][a-z0-9_.-]{0,63}$`)
// Identity is the only phone identity exposed to log records.
type Identity struct {
Ref string
Mask string
}
// Event is the allow-listed input to Logger.Append. Phone is consumed to make
// a keyed identity and is never serialized.
type Event struct {
EventType string
Phone string
PhoneRef string
PhoneMask string
EventID string
OccurredAt time.Time
TenantID string
TraceID string
ExecutionID string
TaskID string
TaskItemID string
TaskRevision int64
AttemptID string
CallID string
AgentID string
CellID string
RoutePolicyID string
CallerProfileID string
TrunkID string
CallState string
AttemptState string
SIPStage string
SIPStatusCode int
SIPReason string
Status string
Result string
ReasonCode string
RecordingID string
RecordingState string
RecordingSize int64
RecordingDuration int64
RecordingSHA256 string
DurationMS int64
}
type record struct {
SchemaVersion string `json:"schema_version"`
EventID string `json:"event_id"`
OccurredAt string `json:"occurred_at"`
EventType string `json:"event_type"`
PhoneRef string `json:"phone_ref"`
PhoneMask string `json:"phone_mask"`
TenantID string `json:"tenant_id,omitempty"`
TraceID string `json:"trace_id,omitempty"`
ExecutionID string `json:"execution_id,omitempty"`
TaskID string `json:"task_id,omitempty"`
TaskItemID string `json:"task_item_id,omitempty"`
TaskRevision int64 `json:"task_revision,omitempty"`
AttemptID string `json:"attempt_id,omitempty"`
CallID string `json:"call_id,omitempty"`
AgentID string `json:"agent_id,omitempty"`
CellID string `json:"cell_id,omitempty"`
RoutePolicyID string `json:"route_policy_id,omitempty"`
CallerProfileID string `json:"caller_profile_id,omitempty"`
TrunkID string `json:"trunk_id,omitempty"`
CallState string `json:"call_state,omitempty"`
AttemptState string `json:"attempt_state,omitempty"`
SIPStage string `json:"sip_stage,omitempty"`
SIPStatusCode int `json:"sip_status_code,omitempty"`
SIPReason string `json:"sip_reason,omitempty"`
Status string `json:"status,omitempty"`
Result string `json:"result,omitempty"`
ReasonCode string `json:"reason_code,omitempty"`
RecordingID string `json:"recording_id,omitempty"`
RecordingState string `json:"recording_state,omitempty"`
RecordingSize int64 `json:"recording_size_bytes,omitempty"`
RecordingDuration int64 `json:"recording_duration_ms,omitempty"`
RecordingSHA256 string `json:"recording_sha256,omitempty"`
DurationMS int64 `json:"duration_ms,omitempty"`
}
// Logger appends one JSON object per line. A mutex and Sync keep concurrent
// call updates from interleaving and make a successful append durable enough
// for the Agent's file-backed recovery boundary.
type Logger struct {
path string
key []byte
now func() time.Time
mu sync.Mutex
}
func New(path string, key []byte, now func() time.Time) (*Logger, error) {
if strings.TrimSpace(path) == "" {
return nil, errors.New("call log path is required")
}
if len(key) < 16 {
return nil, errors.New("call log phone key must contain at least 16 bytes")
}
if now == nil {
now = time.Now
}
return &Logger{path: path, key: append([]byte(nil), key...), now: now}, nil
}
func (l *Logger) Path() string { return l.path }
// Identity returns the stable, redacted reference for a contract callee.
func (l *Logger) Identity(phone string) (Identity, error) {
if err := validatePhone(phone); err != nil {
return Identity{}, err
}
mac := hmac.New(sha256.New, l.key)
_, _ = mac.Write([]byte(phone))
return Identity{Ref: "hmac-sha256:" + hex.EncodeToString(mac.Sum(nil)), Mask: maskPhone(phone)}, nil
}
func (l *Logger) Append(event Event) error {
if l == nil {
return errors.New("call logger is nil")
}
if !eventTypePattern.MatchString(event.EventType) {
return errors.New("event type is invalid")
}
identity := Identity{Ref: event.PhoneRef, Mask: event.PhoneMask}
if event.Phone != "" {
computed, err := l.Identity(event.Phone)
if err != nil {
return err
}
if identity.Ref != "" && identity.Ref != computed.Ref {
return errors.New("phone reference does not match phone")
}
identity = computed
}
if err := validateIdentity(identity); err != nil {
return err
}
if event.SIPStatusCode != 0 && (event.SIPStatusCode < 100 || event.SIPStatusCode > 699) {
return errors.New("SIP status code is invalid")
}
if event.TaskRevision < 0 || event.DurationMS < 0 || event.RecordingSize < 0 || event.RecordingDuration < 0 {
return errors.New("negative business log value")
}
if len(event.RecordingSHA256) > 0 && (len(event.RecordingSHA256) != 64 || !isLowerHex(event.RecordingSHA256)) {
return errors.New("recording SHA-256 is invalid")
}
occurredAt := event.OccurredAt
if occurredAt.IsZero() {
occurredAt = l.now()
}
eventID := event.EventID
if eventID == "" {
var random [16]byte
if _, err := cryptorand.Read(random[:]); err != nil {
return fmt.Errorf("generate call log event ID: %w", err)
}
eventID = hex.EncodeToString(random[:])
}
value := record{
SchemaVersion: "1.0", EventID: eventID, OccurredAt: occurredAt.UTC().Format(time.RFC3339Nano),
EventType: event.EventType, PhoneRef: identity.Ref, PhoneMask: identity.Mask,
TenantID: event.TenantID, TraceID: event.TraceID, ExecutionID: event.ExecutionID,
TaskID: event.TaskID, TaskItemID: event.TaskItemID, TaskRevision: event.TaskRevision,
AttemptID: event.AttemptID, CallID: event.CallID, AgentID: event.AgentID, CellID: event.CellID,
RoutePolicyID: event.RoutePolicyID, CallerProfileID: event.CallerProfileID, TrunkID: event.TrunkID,
CallState: event.CallState, AttemptState: event.AttemptState, SIPStage: event.SIPStage,
SIPStatusCode: event.SIPStatusCode, SIPReason: event.SIPReason, Status: event.Status,
Result: event.Result, ReasonCode: event.ReasonCode, RecordingID: event.RecordingID,
RecordingState: event.RecordingState, RecordingSize: event.RecordingSize,
RecordingDuration: event.RecordingDuration, RecordingSHA256: event.RecordingSHA256,
DurationMS: event.DurationMS,
}
data, err := json.Marshal(value)
if err != nil {
return fmt.Errorf("encode call log event: %w", err)
}
l.mu.Lock()
defer l.mu.Unlock()
if err := os.MkdirAll(filepath.Dir(l.path), 0o700); err != nil {
return fmt.Errorf("create call log directory: %w", err)
}
file, err := os.OpenFile(l.path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o600)
if err != nil {
return fmt.Errorf("open call log: %w", err)
}
if err := file.Chmod(0o600); err != nil {
_ = file.Close()
return fmt.Errorf("protect call log: %w", err)
}
if _, err := file.Write(append(data, '\n')); err != nil {
_ = file.Close()
return fmt.Errorf("append call log: %w", err)
}
if err := file.Sync(); err != nil {
_ = file.Close()
return fmt.Errorf("sync call log: %w", err)
}
if err := file.Close(); err != nil {
return fmt.Errorf("close call log: %w", err)
}
return nil
}
func validateIdentity(identity Identity) error {
if !strings.HasPrefix(identity.Ref, "hmac-sha256:") || len(identity.Ref) != len("hmac-sha256:")+64 || !isLowerHex(strings.TrimPrefix(identity.Ref, "hmac-sha256:")) {
return errors.New("redacted phone reference is invalid")
}
if len(identity.Mask) < 3 || strings.Contains(identity.Mask, " ") || strings.ContainsAny(identity.Mask, "\r\n") {
return errors.New("redacted phone mask is invalid")
}
for _, char := range identity.Mask {
if char != '*' && (char < '0' || char > '9') {
return errors.New("redacted phone mask is invalid")
}
}
if !strings.Contains(identity.Mask, "*") {
return errors.New("redacted phone mask must hide digits")
}
return nil
}
func validatePhone(phone string) error {
if !phonePattern.MatchString(phone) {
return errors.New("phone must be the original 3-32 digit callee")
}
return nil
}
func maskPhone(phone string) string {
if len(phone) <= 4 {
return strings.Repeat("*", len(phone))
}
return strings.Repeat("*", len(phone)-4) + phone[len(phone)-4:]
}
func isLowerHex(value string) bool {
for _, char := range value {
if !((char >= '0' && char <= '9') || (char >= 'a' && char <= 'f')) {
return false
}
}
return true
}