Files
go-sip/internal/configread/client.go
T

361 lines
13 KiB
Go

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, &quota); 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"`
}