271 lines
8.9 KiB
Go
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
|
|
}
|