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 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 { return Snapshot{}, errors.New("SIP configuration identity or revision does not match the request") } seenTrunks := make(map[string]bool, len(sip.Trunks)) for _, trunk := range sip.Trunks { if seenTrunks[trunk.TrunkID] { return Snapshot{}, fmt.Errorf("SIP configuration repeats trunk %q", trunk.TrunkID) } seenTrunks[trunk.TrunkID] = true seenCallers := make(map[string]bool, len(trunk.CallerProfiles)) for _, caller := range trunk.CallerProfiles { if seenCallers[caller.CallerProfileID] { return Snapshot{}, fmt.Errorf("SIP trunk %q repeats caller profile %q", trunk.TrunkID, caller.CallerProfileID) } seenCallers[caller.CallerProfileID] = true } } 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, 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"` Trunks []struct { TrunkID string `json:"trunk_id"` CallerProfiles []struct { CallerProfileID string `json:"caller_profile_id"` } `json:"caller_profiles"` } `json:"trunks"` } 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"` }