package configread import ( "context" "encoding/json" "errors" "fmt" "io" "mime" "net/http" "net/url" "strconv" "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" ) type Client struct { baseURL string dispatcherID string secret string httpClient *http.Client } type Snapshot struct { TaskID string TenantID string TenantKey string 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 FetchedAt time.Time ExpiresAt time.Time SIP json.RawMessage Task json.RawMessage TenantQuota json.RawMessage } type TaskDiscovery struct { DispatcherID string FromCursor string NextCursor string Tasks []DiscoveredTask Body json.RawMessage } 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 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") } 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 the request") } 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, 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, SIP: sipBody, Task: taskBody, 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 } func parseEventCursor(cursor string) (uint64, error) { value, err := strconv.ParseUint(cursor, 10, 64) if err != nil || strconv.FormatUint(value, 10) != cursor { return 0, errors.New("event cursor must be a canonical decimal uint64") } return value, 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) 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 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"` }