package configread import ( "context" "encoding/json" "errors" "fmt" "io" "mime" "net/http" "net/url" "strings" "time" "git.ipao.vip/rogee/go-sip/internal/contract" ) const dispatcherIDHeader = "X-DISPATCHER-id" const dispatcherSecretHeader = "X-DISPATCHER-SECRET-KEY" const configReadPath = "/internal/v1/dispatcher" const ( resourceSIPConfig = "sip_config" resourceTaskConfig = "task_config" resourceTenantQuota = "tenant_quota" taskDiscoverySnapshot = "snapshot" ) type Client struct { baseURL string dispatcherID string secret string httpClient *http.Client } type Snapshot struct { TaskID string TenantID string TenantKey string Discovery TaskDiscovery SIPRevision int64 SIPSnapshotSHA256 string SIPCellID string SIPArtifactRevision int64 SIPArtifactConfigSHA256 string TaskRevision int64 TaskStatus string TaskMaxConcurrentCalls int64 TaskRingTimeoutMS int64 TaskMaxCallDurationMS int64 TaskRoutePolicyID string TaskCallerProfileID string TaskAllowedTrunkIDs []string AgentVersionID string AgentAuthorizationID string AgentAuthorizationExpiresAt time.Time QuotaRevision int64 TenantMaxConcurrentCalls int64 QuotaValidUntil time.Time DiscoveryCursor string FetchedAt time.Time ExpiresAt time.Time SIP json.RawMessage Task json.RawMessage Tasks json.RawMessage TenantQuota json.RawMessage } type TaskDiscovery struct { DispatcherID string Mode string Cursor string FromCursor string NextCursor string Tasks []DiscoveredTask Changes []TaskDiscoveryChange Body json.RawMessage Recovered bool } type DiscoveredTask struct { TaskID string `json:"task_id"` TenantID string `json:"tenant_id"` TenantKey string `json:"tenant_key"` Status string `json:"status"` TaskRevision int64 `json:"task_revision"` } type TaskDiscoveryChange struct { Cursor string `json:"cursor"` Operation string `json:"operation"` TaskID string `json:"task_id"` TenantID string `json:"tenant_id"` TenantKey string `json:"tenant_key"` Status string `json:"status"` TaskRevision int64 `json:"task_revision"` } type HTTPError struct { StatusCode int Code string } func (e *HTTPError) Error() string { if e.Code == "" { return fmt.Sprintf("configuration service returned HTTP %d", e.StatusCode) } return fmt.Sprintf("configuration service returned HTTP %d (%s)", e.StatusCode, e.Code) } func NewClient(baseURL, dispatcherID, secret string, httpClient *http.Client) (*Client, error) { parsed, err := url.ParseRequestURI(baseURL) if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { return nil, errors.New("configuration service URL must be an absolute HTTP(S) URL without credentials, query, or fragment") } if strings.TrimSpace(dispatcherID) == "" || strings.TrimSpace(secret) == "" { return nil, errors.New("dispatcher ID and secret are required") } if strings.ContainsAny(dispatcherID+secret, "\r\n") { return nil, errors.New("dispatcher credentials contain invalid header characters") } if httpClient == nil { httpClient = http.DefaultClient } return &Client{baseURL: strings.TrimRight(baseURL, "/"), dispatcherID: dispatcherID, secret: secret, httpClient: httpClient}, nil } func (c *Client) DispatcherID() string { return c.dispatcherID } func (c *Client) ReadTask(ctx context.Context, taskID, tenantID string) (Snapshot, error) { if taskID == "" || tenantID == "" { return Snapshot{}, errors.New("task ID and tenant ID are required") } sipBody, err := c.getConfig(ctx, configReadPath+"/sip") if err != nil { return Snapshot{}, err } var sip sipConfigResponse if err := json.Unmarshal(sipBody, &sip); err != nil { return Snapshot{}, fmt.Errorf("decode SIP configuration identity: %w", err) } if sip.Resource != resourceSIPConfig || sip.DispatcherID != c.dispatcherID || sip.Revision <= 0 || sip.SnapshotSHA256 == "" || sip.Artifact.CellID == "" || sip.Artifact.Revision <= 0 || sip.Artifact.ConfigSHA256 == "" { return Snapshot{}, errors.New("SIP configuration identity or revision does not match the request") } discovery, err := c.ReadTaskDiscovery(ctx, "") if err != nil { return Snapshot{}, err } if discovery.DispatcherID != c.dispatcherID || discovery.Mode != taskDiscoverySnapshot || discovery.Cursor == "" { return Snapshot{}, errors.New("task discovery snapshot identity or mode does not match the request") } var discovered *DiscoveredTask for i := range discovery.Tasks { if discovery.Tasks[i].TaskID == taskID { if discovered != nil { return Snapshot{}, errors.New("task discovery contains duplicate task IDs") } discovered = &discovery.Tasks[i] } } if discovered == nil || discovered.TenantID != tenantID { return Snapshot{}, errors.New("task discovery does not bind the requested task to the requested tenant") } tasksBody := append(json.RawMessage(nil), discovery.Body...) taskPath := configReadPath + "/task/" + url.PathEscape(taskID) taskBody, err := c.getConfig(ctx, taskPath) if err != nil { return Snapshot{}, err } var task taskConfigResponse if err := json.Unmarshal(taskBody, &task); err != nil { return Snapshot{}, fmt.Errorf("decode task configuration identity: %w", err) } if task.Resource != resourceTaskConfig || task.DispatcherID != c.dispatcherID || task.TaskID != taskID || task.TenantID != tenantID || task.TenantKey == "" || task.TaskRevision <= 0 || task.MaxConcurrentCalls < 0 || task.RingTimeoutMS <= 0 || task.MaxCallDurationMS <= 0 || task.Agent.AgentVersionID == "" || task.Agent.AgentVersionID != task.Agent.Config.AgentVersionID { return Snapshot{}, errors.New("task configuration identity or limits do not match discovery") } if discovered.TenantKey != task.TenantKey || discovered.TaskRevision != task.TaskRevision || discovered.Status != task.Status { return Snapshot{}, errors.New("task discovery and task configuration snapshots disagree") } authorizationExpiresAt, err := time.Parse(time.RFC3339, task.Agent.AuthorizationExpiresAt) if err != nil { return Snapshot{}, errors.New("task configuration has an invalid Agent authorization expiry") } quotaPath := configReadPath + "/tenant/" + url.PathEscape(tenantID) + "/quota" quotaBody, err := c.getConfig(ctx, quotaPath) if err != nil { return Snapshot{}, err } var quota tenantQuotaResponse if err := json.Unmarshal(quotaBody, "a); err != nil { return Snapshot{}, fmt.Errorf("decode tenant quota identity: %w", err) } if quota.Resource != resourceTenantQuota || quota.DispatcherID != c.dispatcherID || quota.TenantID != tenantID || quota.TenantKey != task.TenantKey || quota.QuotaRevision <= 0 { return Snapshot{}, errors.New("tenant quota identity does not match task configuration") } quotaValidUntil, err := time.Parse(time.RFC3339, quota.ValidUntil) if err != nil { return Snapshot{}, errors.New("tenant quota has an invalid validity deadline") } return Snapshot{ TaskID: taskID, TenantID: tenantID, TenantKey: task.TenantKey, Discovery: discovery, SIPRevision: sip.Revision, SIPSnapshotSHA256: sip.SnapshotSHA256, SIPCellID: sip.Artifact.CellID, SIPArtifactRevision: sip.Artifact.Revision, SIPArtifactConfigSHA256: sip.Artifact.ConfigSHA256, TaskRevision: task.TaskRevision, TaskStatus: task.Status, TaskMaxConcurrentCalls: int64(task.MaxConcurrentCalls), TaskRingTimeoutMS: int64(task.RingTimeoutMS), TaskMaxCallDurationMS: int64(task.MaxCallDurationMS), TaskRoutePolicyID: task.RoutePolicyID, TaskCallerProfileID: task.CallerProfileID, TaskAllowedTrunkIDs: append([]string(nil), task.AllowedTrunkIDs...), AgentVersionID: task.Agent.AgentVersionID, AgentAuthorizationID: task.Agent.AuthorizationID, AgentAuthorizationExpiresAt: authorizationExpiresAt, QuotaRevision: quota.QuotaRevision, TenantMaxConcurrentCalls: int64(quota.MaxConcurrentCalls), QuotaValidUntil: quotaValidUntil, DiscoveryCursor: discovery.Cursor, SIP: sipBody, Task: taskBody, Tasks: tasksBody, TenantQuota: quotaBody, }, nil } // TaskStatus is the authoritative task identity and control status read from // the task resource without requiring unrelated SIP or quota resources. type TaskStatus struct { DispatcherID string TaskID string TenantID string TenantKey string Status string TaskRevision int64 } // ReadTaskStatus fetches only one task resource. Control handling uses this // read even when execution configuration or quota is unavailable. func (c *Client) ReadTaskStatus(ctx context.Context, taskID, tenantID, tenantKey string) (TaskStatus, error) { if taskID == "" || tenantID == "" || tenantKey == "" { return TaskStatus{}, errors.New("task ID, tenant ID, and tenant key are required") } path := configReadPath + "/task/" + url.PathEscape(taskID) body, err := c.getConfig(ctx, path) if err != nil { return TaskStatus{}, err } var task taskConfigResponse if err := json.Unmarshal(body, &task); err != nil { return TaskStatus{}, fmt.Errorf("decode task control status: %w", err) } if task.Resource != resourceTaskConfig || task.DispatcherID != c.dispatcherID || task.TaskID != taskID || task.TenantID != tenantID || task.TenantKey != tenantKey || task.TaskRevision <= 0 { return TaskStatus{}, errors.New("task control status identity does not match the request") } switch task.Status { case "running", "paused", "stopped", "finished": default: return TaskStatus{}, fmt.Errorf("unsupported authoritative task status %q", task.Status) } return TaskStatus{ DispatcherID: task.DispatcherID, TaskID: task.TaskID, TenantID: task.TenantID, TenantKey: task.TenantKey, Status: task.Status, TaskRevision: task.TaskRevision, }, nil } // ReadTaskDiscovery accepts exactly one complete snapshot or change response. // On HTTP 410 cursor_expired it fetches one full snapshot instead of advancing. func (c *Client) ReadTaskDiscovery(ctx context.Context, after string) (TaskDiscovery, error) { return c.readTaskDiscovery(ctx, after, false) } func (c *Client) readTaskDiscovery(ctx context.Context, after string, recovered bool) (TaskDiscovery, error) { path := configReadPath + "/tasks" if after != "" { path += "?" + url.Values{"after": {after}}.Encode() } body, err := c.getTaskDiscovery(ctx, path) if err != nil { var statusErr *HTTPError if !recovered && after != "" && errors.As(err, &statusErr) && statusErr.StatusCode == http.StatusGone && statusErr.Code == "cursor_expired" { full, recoveryErr := c.readTaskDiscovery(ctx, "", true) if recoveryErr != nil { return TaskDiscovery{}, fmt.Errorf("recover expired task-discovery cursor with full snapshot: %w", recoveryErr) } return full, nil } return TaskDiscovery{}, err } var response taskDiscoveryResponse if err := json.Unmarshal(body, &response); err != nil { return TaskDiscovery{}, fmt.Errorf("decode task discovery: %w", err) } if response.DispatcherID != c.dispatcherID { return TaskDiscovery{}, errors.New("task-discovery dispatcher identity does not match the request") } result := TaskDiscovery{DispatcherID: response.DispatcherID, FromCursor: after, Body: append(json.RawMessage(nil), body...), Recovered: recovered} tenantByID, idByTenant := make(map[string]string), make(map[string]string) if after == "" { result.Mode, result.Cursor, result.Tasks = taskDiscoverySnapshot, response.Cursor, response.Tasks seen := make(map[string]struct{}, len(result.Tasks)) for _, task := range result.Tasks { if _, duplicate := seen[task.TaskID]; duplicate { return TaskDiscovery{}, fmt.Errorf("task discovery contains duplicate task ID %q", task.TaskID) } seen[task.TaskID] = struct{}{} if err := validateTenantBinding(tenantByID, idByTenant, task.TenantID, task.TenantKey); err != nil { return TaskDiscovery{}, err } } } else { result.Mode, result.NextCursor, result.Changes = "changes", response.NextCursor, response.Changes if (len(result.Changes) == 0 && result.NextCursor != after) || (len(result.Changes) != 0 && result.NextCursor == after) { return TaskDiscovery{}, errors.New("task discovery change cursor does not match the complete response") } for _, change := range result.Changes { if err := validateTenantBinding(tenantByID, idByTenant, change.TenantID, change.TenantKey); err != nil { return TaskDiscovery{}, err } } } return result, nil } func validateTenantBinding(tenantByID, idByTenant map[string]string, tenantID, tenantKey string) error { if len([]byte(tenantKey)) > 196 { return errors.New("task discovery tenant_key exceeds the 196-byte limit") } if existing, ok := tenantByID[tenantID]; ok && existing != tenantKey { return errors.New("task discovery changes the tenant_key bound to a tenant_id") } if existing, ok := idByTenant[tenantKey]; ok && existing != tenantID { return errors.New("task discovery binds one tenant_key to multiple tenant IDs") } tenantByID[tenantID] = tenantKey idByTenant[tenantKey] = tenantID return nil } func (c *Client) getConfig(ctx context.Context, path string) (json.RawMessage, error) { body, err := c.get(ctx, path) if err != nil { return nil, err } if err := contract.ValidateLocalConfigRead(body); err != nil { return nil, fmt.Errorf("validate configuration response: %w", err) } return body, nil } func (c *Client) getTaskDiscovery(ctx context.Context, path string) (json.RawMessage, error) { body, err := c.get(ctx, path) if err != nil { return nil, err } if err := contract.ValidateLocalTaskDiscovery(body); err != nil { return nil, fmt.Errorf("validate task-discovery response: %w", err) } return body, nil } func (c *Client) get(ctx context.Context, path string) (json.RawMessage, error) { relative, err := url.Parse(path) if err != nil { return nil, fmt.Errorf("parse configuration path: %w", err) } base, err := url.Parse(c.baseURL) if err != nil { return nil, fmt.Errorf("parse configuration base URL: %w", err) } base = base.JoinPath(relative.Path) base.RawQuery = relative.RawQuery request, err := http.NewRequestWithContext(ctx, http.MethodGet, base.String(), nil) if err != nil { return nil, fmt.Errorf("build configuration request: %w", err) } request.Header.Set("Accept", "application/json") request.Header.Set(dispatcherIDHeader, c.dispatcherID) request.Header.Set(dispatcherSecretHeader, c.secret) response, err := c.httpClient.Do(request) if err != nil { return nil, fmt.Errorf("request configuration service: %w", err) } defer response.Body.Close() body, err := io.ReadAll(response.Body) if err != nil { return nil, fmt.Errorf("read configuration response: %w", err) } if response.StatusCode != http.StatusOK { return nil, &HTTPError{StatusCode: response.StatusCode, Code: responseErrorCode(body)} } mediaType, _, err := mime.ParseMediaType(response.Header.Get("Content-Type")) if err != nil || mediaType != "application/json" { return nil, errors.New("configuration service response must use application/json") } if !json.Valid(body) { return nil, errors.New("configuration service returned invalid JSON") } return json.RawMessage(body), nil } func responseErrorCode(body []byte) string { var response struct { Error struct { Code string `json:"code"` } `json:"error"` } if err := json.Unmarshal(body, &response); err != nil { return "" } return response.Error.Code } type sipConfigResponse struct { Resource string `json:"resource"` DispatcherID string `json:"dispatcher_id"` Revision int64 `json:"revision"` SnapshotSHA256 string `json:"snapshot_sha256"` Artifact struct { CellID string `json:"cell_id"` Revision int64 `json:"revision"` ConfigSHA256 string `json:"config_sha256"` } `json:"artifact"` } type taskDiscoveryResponse struct { DispatcherID string `json:"dispatcher_id"` Cursor string `json:"cursor"` NextCursor string `json:"next_cursor"` Tasks []DiscoveredTask `json:"tasks"` Changes []TaskDiscoveryChange `json:"changes"` } type taskConfigResponse struct { Resource string `json:"resource"` DispatcherID string `json:"dispatcher_id"` TenantID string `json:"tenant_id"` TenantKey string `json:"tenant_key"` TaskID string `json:"task_id"` TaskRevision int64 `json:"task_revision"` Status string `json:"status"` MaxConcurrentCalls int `json:"max_concurrent_calls"` RingTimeoutMS int `json:"ring_timeout_ms"` MaxCallDurationMS int `json:"max_call_duration_ms"` RoutePolicyID string `json:"route_policy_id"` CallerProfileID string `json:"caller_profile_id"` AllowedTrunkIDs []string `json:"allowed_trunk_ids"` Agent struct { AgentVersionID string `json:"agent_version_id"` AuthorizationID string `json:"authorization_id"` AuthorizationExpiresAt string `json:"authorization_expires_at"` Config struct { AgentVersionID string `json:"agent_version_id"` } `json:"config"` } `json:"agent"` } type tenantQuotaResponse struct { Resource string `json:"resource"` DispatcherID string `json:"dispatcher_id"` TenantID string `json:"tenant_id"` TenantKey string `json:"tenant_key"` QuotaRevision int64 `json:"quota_revision"` MaxConcurrentCalls int `json:"max_concurrent_calls"` ValidUntil string `json:"valid_until"` }