361 lines
13 KiB
Go
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, "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"`
|
|
}
|