Merge branch 'implement-cmd/saas-server-fiber-v3-queue-push' into HEAD
This commit is contained in:
@@ -79,6 +79,8 @@
|
||||
|
||||
## 当前范围与权威入口
|
||||
|
||||
- **SaaS Server v0.1(本地测试工具,不改变线上环境)**:`cmd/saas-server` 使用 Fiber/v3,从指定私有 JSON 文件只读加载五类配置;完整快照更新仅内存生效。HTTP/curl 显式发送单条外呼、start/resume/edit 和 SIP 刷新,不自动投递、重发或重启服务;源 `event_id` 原样保留。仅专用 `saas-mock-*` vhost,反馈文件同步保存后 ACK,查询只覆盖本次运行,无历史恢复或发送记录持久化。Dispatcher/Agent、共享业务代码、合同和现有部署不变;不授权部署、真实拨号或 AI/OSS。使用见 [`cmd/saas-server/README.md`](cmd/saas-server/README.md),本地检查见 [`docs/evidence/saas-server-v0.1-local.md`](docs/evidence/saas-server-v0.1-local.md)。
|
||||
|
||||
- **2026-10-12 接通时长字段已合入并部署登记测试机**:共享契约 `bfa3380` 已推送,本项目业务源码 `8340efd` 已快进合入、推送 `main` 并在 `server.sip` 固定部署。`call.execute.result` 增加 `answered_at` 与 `connected_duration_ms`,原 `duration_ms` 不变;未接通且确认结束为 0,原通道 Up/Destroyed 时间证据缺失为 null,SaaS 不应从 outcome 推断接通时长。重新部署未重置数据、不重启 Asterisk、不抓包/主动试拨;Agent/Dispatcher 均 Debug 开、Capture 关,Asterisk PID 777461 不变,零活动通道,服务均 enabled+active、零重启;实际制品 SHA-256 独立复现。SaaS 探测 `saas_pending`、`business_ready=false`,未观测新字段真实到达、接通通话或生产签收,仍需 SaaS 开发对接与确认。文档后续补记不改变已安装的业务源码版本。详见 [`docs/evidence/connected-call-duration-local.md`](docs/evidence/connected-call-duration-local.md)。
|
||||
|
||||
- **SQLite cleanup v0.1 首期(开发分支,本地验收)**:`feat/sqlite-cleanup-v0.1` 仅调整完成数据的本地保留周期。源 `event_id`(含 numberID)原样回传,不新增 `execution_id`,SaaS 如何处理重复结果不在范围;用户在复审中批准仅必要一句文字对齐,共享合同 `7b4448f` 已发布并固定本项目引用,限定首次事实保护为本地执行/回执仍保留期间;接口/Schema/类型/路由不变,不涉及 SaaS 重复处理或等待适配。Agent 不再读写永久 `.approved`;活动 TaskCalls 与 Dispatcher 未决/未知保护保留。已派发呼叫需终结/结果成功及派发回执、最终结果分别 MQ confirm;未发起拒绝只需拒绝确认;取消记录需 durable stopped 和成功 stop confirm。清理事务不删共享配置/任务停止状态,布局仍为 **4**,无迁移/重置。录音仅 delivered 且无未完成 `.results` 引用后删音频、信息、空叶;删除前在私有 `.recording-cleanup` 保存原 delivered 信息临时副本,精确空叶删除并同步父目录后才删副本。info/空叶删除之间硬退出也可按该依据续处理,不泛扫未知空目录、不留永久完成历史;同 OSS 目标在副本清完前拒绝新恢复写入,不再 PUT/报告;拒绝原件按完整回执/原身份/内容精确关联,先全部原件后回执。新原件文件名为回执 SHA-256 与完整记录 SHA-256,仅磁盘关联,不是业务编号;既有完整文件校验读取、不迁移,不能关联的损坏旧文件和潜在依据保留待人工。两角色独立 Asia/Shanghai 每日 24:00 补扫复用实时资格,无 12:00/年龄删除/VACUUM/永久清理历史,清理错误不关闭业务准入。仅测试目录验证 `.approved` 退役,没有实际主机退役、部署、真实数据/OSS 删除、拨号、抓包、AI/OSS 调用或 main 合入/推送;本地通过不代表真实服务验收。检查和逐项证据见 [`docs/evidence/sqlite-cleanup-v0.1-local.md`](docs/evidence/sqlite-cleanup-v0.1-local.md)。此条是开发分支状态,不改变下述线上事实。
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
# SaaS Server:最小外呼测试工具
|
||||
|
||||
使用 Fiber/v3,代替浏览器操作:提供 Dispatcher 所需配置,手动向测试队列发送一条外呼,查看回执和最终结果。
|
||||
|
||||
**不修改 Dispatcher/Agent,不绕过它们的业务检查。启动不自动发消息。仅支持专用 `saas-mock-*` vhost,不能和真实 SaaS 共用反馈队列。** 本工具不授权真实拨号或真实 AI/OSS 调用。
|
||||
|
||||
## 启动
|
||||
|
||||
```sh
|
||||
go build -o /tmp/saas-server ./cmd/saas-server
|
||||
/tmp/saas-server --config /absolute/path/server.json
|
||||
```
|
||||
|
||||
`server.json` 示例(全部为占位,须换成已批准的测试配置):
|
||||
|
||||
```json
|
||||
{
|
||||
"listen": "0.0.0.0:8089",
|
||||
"dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6",
|
||||
"dispatcher_secret": "TEST_DISPATCHER_SECRET",
|
||||
"broker_url": "amqp://TEST_USER:TEST_PASSWORD@127.0.0.1:5672/saas-mock-example",
|
||||
"snapshots": "snapshots",
|
||||
"feedback_dir": "feedback",
|
||||
"tls_cert": "dispatcher-test.crt",
|
||||
"tls_key": "dispatcher-test.key"
|
||||
}
|
||||
```
|
||||
|
||||
- 相对路径以 `server.json` 所在目录为基准。
|
||||
- `listen` 省略时为 `0.0.0.0:8089`。
|
||||
- TLS 两项可同时省略,服务改为 HTTP;需要遵循现有 Dispatcher 客户端的连接要求,不能为测试放松客户端校验。
|
||||
- 启动文件和各快照必须是私有普通文件(如 `0600`)。反馈目录独立且私有(如 `0700`),不能与输入配置目录交叠。
|
||||
- MQ 用户必须能够在这个专用 vhost 声明、绑定和使用现行拓扑。已有拓扑不匹配或已有独占反馈消费者时明确失败;不会清队列或自动恢复连接。
|
||||
|
||||
快照目录:
|
||||
|
||||
```text
|
||||
snapshots/
|
||||
sip.json
|
||||
providers.json
|
||||
quota.json
|
||||
tasks/
|
||||
<task_id>.json
|
||||
```
|
||||
|
||||
JSON 内容直接采用 [`../../contracts/schema/`](../../contracts/schema/README.md) 的现行配置响应格式,不使用旧 ENV 替代。所有资源必须属于启动文件中的 Dispatcher;首期仅一个租户,任务文件名与 task_id 一致。任务列表由详情生成,不另建列表文件。
|
||||
|
||||
AI 连接、模型、音色、参数、SIP 线路、额度和时段来自这些文件;合同示例只是结构参考,不保证线路具备执行参数,也不是拨号授权。
|
||||
|
||||
另行启动使用这套测试配置及专用 MQ 的 Dispatcher/Agent;本工具不启动、停止、重启或修改现有服务。五类正式配置接口校验现有 `X-DISPATCHER-id` 和 `X-DISPATCHER-SECRET-KEY` 请求头。
|
||||
|
||||
## 手动发送一条外呼
|
||||
|
||||
以下命令以本地 HTTP 为例;启用 TLS 时改用 HTTPS 并使用测试 CA 的 `--cacert`,不要关闭证书校验。
|
||||
|
||||
```sh
|
||||
BASE=http://127.0.0.1:8089
|
||||
# 任务、号码必须是本次获准的测试数据;123456 只是占位。
|
||||
curl --fail-with-body "$BASE/test/calls" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"event_id":"test-numberID-001","task_id":"1","callee":"123456"}'
|
||||
```
|
||||
|
||||
`event_id` 由调用者明确指定,原样保留。相同身份、相同请求在本次进程中不再发送;同身份不同内容报错。重启会清除发送记录,**不要通过重启、换 ID 或重复调用补拨**;Dispatcher 的既有重复消息规则不变。
|
||||
|
||||
响应中的 `status`:
|
||||
|
||||
| 值 | 含义 |
|
||||
| --- | --- |
|
||||
| `confirmed` | 队列已确认,不表示已拨号或已接通 |
|
||||
| `unroutable` | 消息被队列退回,没有对应路由 |
|
||||
| `rejected` | 队列明确拒绝 |
|
||||
| `not_sent` | 发送前失败 |
|
||||
| `unknown` | 是否发送成功不确定;禁止自动重发 |
|
||||
|
||||
非 `confirmed` 返回 HTTP 503,并保留本次状态。输入错误返回 HTTP 400。工具没有自动重试、批量、定时或换线功能。
|
||||
|
||||
## 查询和校验反馈
|
||||
|
||||
```sh
|
||||
curl --fail-with-body --get "$BASE/test/events" \
|
||||
--data-urlencode 'event_id=test-numberID-001'
|
||||
```
|
||||
|
||||
返回本次进程内的 `requests` 与 `feedback`。派发回执和最终结果分别记录;`validation` 为 `valid` 或 `invalid`,错误原因会明确显示。等待不到反馈不等于通话失败,只有拒绝的未发起请求不一定有通话最终结果。
|
||||
|
||||
查询只返回身份、状态、结果及校验摘要,不显示完整对话/音频等正文。收到的原消息保存在独立反馈目录的随机 `feedback-*.json` 文件中,文件保存并同步成功后才 ACK。保存失败停止消费并退出,不丢弃消息。重复或错误消息也保留证据,冲突不覆盖原记录。
|
||||
|
||||
**重启后不恢复历史查询;旧反馈文件仍保留,供人工检查。本工具不会回写配置,也不会保存内存修改。**
|
||||
|
||||
## 内存修改配置
|
||||
|
||||
使用完整配置响应,不使用 PATCH 或字段合并:
|
||||
|
||||
```sh
|
||||
curl --fail-with-body -X PUT "$BASE/test/config/sip" \
|
||||
-H 'Content-Type: application/json' --data-binary @/path/candidate-sip.json
|
||||
curl --fail-with-body -X PUT "$BASE/test/config/providers" \
|
||||
-H 'Content-Type: application/json' --data-binary @/path/candidate-providers.json
|
||||
curl --fail-with-body -X PUT "$BASE/test/config/quota" \
|
||||
-H 'Content-Type: application/json' --data-binary @/path/candidate-quota.json
|
||||
curl --fail-with-body -X PUT "$BASE/test/config/tasks/1" \
|
||||
-H 'Content-Type: application/json' --data-binary @/path/candidate-task.json
|
||||
```
|
||||
|
||||
校验成功后整体替换内存,失败不改变当前值。只能修改已加载资源,不能增删任务或更换租户/Dispatcher 身份。重启重新加载原文件。
|
||||
|
||||
修改配置不会自动通知 Dispatcher,需要显式操作:
|
||||
|
||||
```sh
|
||||
# action 只支持 start、resume、edit;每条指令使用明确的源 event_id。
|
||||
curl --fail-with-body "$BASE/test/task-controls" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"event_id":"test-edit-001","task_id":"1","action":"edit","reason":"test configuration change"}'
|
||||
|
||||
# change_type 按现行合同:enabled、disabled、removed、created;revision 为正整数。
|
||||
curl --fail-with-body "$BASE/test/sip-refresh" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"event_id":"test-sip-001","trunk_id":"trunk-mock","change_type":"enabled","revision":8}'
|
||||
```
|
||||
|
||||
任务/额度按现行 start/resume/edit 流程读取,edit 不解除暂停、不恢复已停止任务。SIP 通知触发现行加载流程;已经发出的通话不改变快照。AI providers 只在 Dispatcher 启动读取,修改后须另行安排重启;本工具不重启服务。
|
||||
|
||||
## 本地验证
|
||||
|
||||
```sh
|
||||
go test -race ./cmd/saas-server
|
||||
# 需 Docker 和已存在的 rabbitmq:4.1-management-alpine 镜像,不自动拉取。
|
||||
bash cmd/saas-server/check-integration.sh
|
||||
```
|
||||
|
||||
集成脚本只创建并清理自己的临时 RabbitMQ 容器,运行 Fiber HTTPS → RabbitMQ → 未修改的 Dispatcher → Mock Agent mTLS → 回执/最终结果测试。使用临时配置、数据库和合成音频,不拨号,不访问真实 AI/OSS。通过不代表真实服务或通话验收。
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/../.."
|
||||
image=rabbitmq:4.1-management-alpine
|
||||
command -v docker >/dev/null && docker info >/dev/null
|
||||
# Never pull images or connect to an existing broker implicitly.
|
||||
docker image inspect "$image" >/dev/null
|
||||
uid=$(docker run --rm --entrypoint id "$image" -u rabbitmq)
|
||||
gid=$(docker run --rm --entrypoint id "$image" -g rabbitmq)
|
||||
name="saas-server-test-$$"
|
||||
cleanup() { docker rm -f "$name" >/dev/null 2>&1; }
|
||||
trap cleanup EXIT
|
||||
docker run -d --name "$name" --tmpfs "/var/lib/rabbitmq:rw,uid=$uid,gid=$gid,mode=0700" \
|
||||
-p 127.0.0.1::5672 "$image" >/dev/null
|
||||
ready=0
|
||||
for _ in $(seq 1 60); do
|
||||
if docker exec -u rabbitmq "$name" rabbitmq-diagnostics -q check_running >/dev/null 2>&1; then ready=1; break; fi
|
||||
sleep 1
|
||||
done
|
||||
if [ "$ready" -ne 1 ]; then echo 'isolated RabbitMQ not ready' >&2; exit 1; fi
|
||||
password=$(openssl rand -hex 12)
|
||||
docker exec -u rabbitmq "$name" rabbitmqctl add_user saas_test "$password" >/dev/null
|
||||
docker exec -u rabbitmq "$name" rabbitmqctl add_vhost saas-mock-server-test >/dev/null
|
||||
docker exec -u rabbitmq "$name" rabbitmqctl set_permissions -p saas-mock-server-test saas_test '.*' '.*' '.*' >/dev/null
|
||||
port=$(docker port "$name" 5672/tcp | awk -F: 'NR==1{print $NF}')
|
||||
export SAAS_MOCK_TEST_BROKER_URL="amqp://saas_test:${password}@127.0.0.1:${port}/saas-mock-server-test"
|
||||
output=$(go test -race -tags=integration ./cmd/saas-server -run '^Test(SaaSServerIsolatedHTTPSMQAgentMTLS|StartupShutdownPreservesConfiguration)$' -count=1 -v 2>&1) || { printf '%s\n' "$output"; exit 1; }
|
||||
printf '%s\n' "$output"
|
||||
grep -Fq -- '--- PASS: TestSaaSServerIsolatedHTTPSMQAgentMTLS (' <<<"$output"
|
||||
grep -Fq -- '--- PASS: TestStartupShutdownPreservesConfiguration (' <<<"$output"
|
||||
@@ -0,0 +1,173 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
|
||||
"git.ipao.vip/rogee/go-sip/internal/configread"
|
||||
"git.ipao.vip/rogee/go-sip/internal/contract"
|
||||
)
|
||||
|
||||
type snapshots struct {
|
||||
dispatcherID string
|
||||
tenantID int64
|
||||
sip, providers, quota []byte
|
||||
tasks map[string][]byte
|
||||
}
|
||||
|
||||
func validateResource(raw []byte, resource, dispatcherID string) error {
|
||||
if err := contract.ValidateCurrent("config-read", raw); err != nil {
|
||||
// Existing contract diagnostics report field paths without private values.
|
||||
return fmt.Errorf("%s: %w", resource, err)
|
||||
}
|
||||
var identity struct {
|
||||
Resource string `json:"resource"`
|
||||
DispatcherID string `json:"dispatcher_id"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &identity); err != nil {
|
||||
return errors.New("invalid resource JSON")
|
||||
}
|
||||
if identity.Resource != resource || identity.DispatcherID != dispatcherID {
|
||||
return fmt.Errorf("%s has wrong owner or resource", resource)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadSnapshots(dir, id string) (snapshots, error) {
|
||||
d := snapshots{dispatcherID: id, tasks: make(map[string][]byte)}
|
||||
read := func(name, resource string) ([]byte, error) {
|
||||
path := filepath.Join(dir, name)
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read %s: %w", name, err)
|
||||
}
|
||||
if !info.Mode().IsRegular() || info.Mode().Perm()&0077 != 0 {
|
||||
return nil, fmt.Errorf("%s must be a private regular file", name)
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read %s: %w", name, err)
|
||||
}
|
||||
if err = validateResource(raw, resource, id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
var err error
|
||||
if d.sip, err = read("sip.json", "sip_config"); err != nil {
|
||||
return d, err
|
||||
}
|
||||
if d.providers, err = read("providers.json", "ai_providers"); err != nil {
|
||||
return d, err
|
||||
}
|
||||
if d.quota, err = read("quota.json", "tenant_quota"); err != nil {
|
||||
return d, err
|
||||
}
|
||||
var q configread.Quota
|
||||
if err = json.Unmarshal(d.quota, &q); err != nil {
|
||||
return d, errors.New("invalid quota")
|
||||
}
|
||||
d.tenantID = q.TenantID
|
||||
files, err := filepath.Glob(filepath.Join(dir, "tasks", "*.json"))
|
||||
if err != nil {
|
||||
return d, err
|
||||
}
|
||||
if len(files) == 0 {
|
||||
return d, errors.New("at least one task is required")
|
||||
}
|
||||
for _, path := range files {
|
||||
raw, err := read(filepath.Join("tasks", filepath.Base(path)), "task_config")
|
||||
if err != nil {
|
||||
return d, err
|
||||
}
|
||||
var task configread.Task
|
||||
if err = json.Unmarshal(raw, &task); err != nil {
|
||||
return d, errors.New("invalid task snapshot")
|
||||
}
|
||||
if filepath.Base(path) != task.TaskID+".json" || task.TenantID != d.tenantID {
|
||||
return d, errors.New("task filename or tenant mismatch")
|
||||
}
|
||||
d.tasks[task.TaskID] = raw
|
||||
}
|
||||
if err = d.validateReferences(); err != nil {
|
||||
return d, err
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (d snapshots) validateReferences() error {
|
||||
var sip struct {
|
||||
Trunks []struct {
|
||||
ID string `json:"trunk_id"`
|
||||
} `json:"trunks"`
|
||||
}
|
||||
var providers struct {
|
||||
Providers []configread.Provider `json:"providers"`
|
||||
}
|
||||
if err := json.Unmarshal(d.sip, &sip); err != nil {
|
||||
return errors.New("invalid SIP snapshot")
|
||||
}
|
||||
if err := json.Unmarshal(d.providers, &providers); err != nil {
|
||||
return errors.New("invalid providers snapshot")
|
||||
}
|
||||
trunks := make(map[string]bool)
|
||||
for _, t := range sip.Trunks {
|
||||
if trunks[t.ID] {
|
||||
return errors.New("duplicate trunk")
|
||||
}
|
||||
trunks[t.ID] = true
|
||||
}
|
||||
codes := make(map[string]bool)
|
||||
for _, p := range providers.Providers {
|
||||
if codes[p.Code] {
|
||||
return errors.New("duplicate provider code")
|
||||
}
|
||||
codes[p.Code] = true
|
||||
}
|
||||
for _, raw := range d.tasks {
|
||||
var task configread.Task
|
||||
if err := json.Unmarshal(raw, &task); err != nil {
|
||||
return errors.New("invalid task snapshot")
|
||||
}
|
||||
for _, t := range task.AllowedTrunks {
|
||||
if !trunks[t.TrunkID] {
|
||||
return errors.New("task references missing trunk")
|
||||
}
|
||||
}
|
||||
for _, ref := range []string{task.Agent.ASR.ProviderRef, task.Agent.LLM.ProviderRef, task.Agent.TTS.ProviderRef} {
|
||||
if ref != "" && !codes[ref] {
|
||||
return errors.New("task references missing provider")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d snapshots) page(after string) ([]byte, error) {
|
||||
if after != "" && after != "complete" {
|
||||
return nil, errors.New("unknown discovery cursor")
|
||||
}
|
||||
tasks := []configread.DiscoveredTask{}
|
||||
if after == "" {
|
||||
for _, raw := range d.tasks {
|
||||
var task configread.Task
|
||||
if err := json.Unmarshal(raw, &task); err != nil {
|
||||
return nil, errors.New("invalid task snapshot")
|
||||
}
|
||||
tasks = append(tasks, configread.DiscoveredTask{TaskID: task.TaskID, TenantID: task.TenantID, Status: task.Status, TaskRevision: task.TaskRevision})
|
||||
}
|
||||
}
|
||||
sort.Slice(tasks, func(i, j int) bool { return tasks[i].TaskID < tasks[j].TaskID })
|
||||
raw, err := json.Marshal(configread.TaskPage{DispatcherID: d.dispatcherID, Cursor: "complete", Tasks: tasks})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = contract.ValidateCurrent("task-discovery", raw); err != nil {
|
||||
return nil, errors.New("invalid discovery response")
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.ipao.vip/rogee/go-sip/internal/configread"
|
||||
)
|
||||
|
||||
func fixture(t *testing.T) (string, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(dir, "tasks"), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
names := map[string]string{"sip.json": "config-read-sip.json", "providers.json": "config-read-providers.json", "quota.json": "config-read-quota.json", "tasks/1.json": "config-read-task-full.json"}
|
||||
var id string
|
||||
for target, example := range names {
|
||||
raw, err := os.ReadFile(filepath.Join("../../contracts/schema/examples", example))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var fields map[string]json.RawMessage
|
||||
if err = json.Unmarshal(raw, &fields); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = json.Unmarshal(fields["dispatcher_id"], &id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if target == "tasks/1.json" {
|
||||
fields["task_id"] = json.RawMessage(`"1"`)
|
||||
raw, _ = json.Marshal(fields)
|
||||
}
|
||||
if err = os.WriteFile(filepath.Join(dir, target), raw, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return dir, id
|
||||
}
|
||||
|
||||
type appTransport struct{ app *service }
|
||||
|
||||
func (tr appTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return tr.app.app.Test(req)
|
||||
}
|
||||
|
||||
func TestConfigClientReadsAllResources(t *testing.T) {
|
||||
dir, id := fixture(t)
|
||||
data, err := loadSnapshots(dir, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := newService(data, "test-secret")
|
||||
client, err := configread.NewClient("http://saas.test", id, "test-secret", &http.Client{Transport: appTransport{svc}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
sip, err := client.ReadSIP(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
providers, err := client.ReadProviders(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tasks, _, err := client.ReadAllTasks(ctx)
|
||||
if err != nil || len(tasks) != 1 {
|
||||
t.Fatalf("tasks=%v err=%v", tasks, err)
|
||||
}
|
||||
if _, err = client.ReadTask(ctx, "1", tasks[0].TenantID, sip, providers); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, path := range []string{"/internal/v1/dispatcher/task/missing", "/internal/v1/dispatcher/tenant/0/quota"} {
|
||||
req, _ := http.NewRequest("GET", "http://saas.test"+path, nil)
|
||||
req.Header.Set("X-DISPATCHER-id", id)
|
||||
req.Header.Set("X-DISPATCHER-SECRET-KEY", "test-secret")
|
||||
resp, err := svc.app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != 404 {
|
||||
t.Fatalf("%s: %d", path, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
req, _ := http.NewRequest("GET", "http://saas.test/internal/v1/dispatcher/sip", nil)
|
||||
resp, err := svc.app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != 403 {
|
||||
t.Fatal(resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotErrorsAndReadOnly(t *testing.T) {
|
||||
dir, id := fixture(t)
|
||||
before := make(map[string][]byte)
|
||||
filepath.WalkDir(dir, func(path string, entry os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !entry.IsDir() {
|
||||
before[path], err = os.ReadFile(path)
|
||||
}
|
||||
return err
|
||||
})
|
||||
if _, err := loadSnapshots(dir, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for path, want := range before {
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil || string(got) != string(want) {
|
||||
t.Fatalf("input changed: %s", path)
|
||||
}
|
||||
}
|
||||
if _, err := loadSnapshots(dir, "wrong-owner"); err == nil {
|
||||
t.Fatal("accepted wrong owner")
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "sip.json"), []byte(`{"api_key":"must-not-leak"}`), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := loadSnapshots(dir, id); err == nil {
|
||||
t.Fatal("accepted invalid snapshot")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSchemaErrorsIdentifyFieldWithoutPrivateValue(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../contracts/schema/examples/config-read-providers.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var body map[string]any
|
||||
json.Unmarshal(raw, &body)
|
||||
provider := body["providers"].([]any)[0].(map[string]any)
|
||||
provider["provider_code"] = "PRIVATE_VALUE_MUST_NOT_APPEAR"
|
||||
provider["api_key"] = map[string]string{"secret": "PRIVATE_VALUE_MUST_NOT_APPEAR"}
|
||||
raw, _ = json.Marshal(body)
|
||||
err = validateResource(raw, "ai_providers", body["dispatcher_id"].(string))
|
||||
if err == nil || !strings.Contains(err.Error(), "api_key") || strings.Contains(err.Error(), "PRIVATE_VALUE_MUST_NOT_APPEAR") {
|
||||
t.Fatalf("unsafe or opaque diagnostic: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"git.ipao.vip/rogee/go-sip/internal/contract"
|
||||
)
|
||||
|
||||
type envelope struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
DispatcherID string `json:"dispatcher_id"`
|
||||
TenantID int64 `json:"tenant_id,omitempty"`
|
||||
IssuedAt string `json:"issued_at,omitempty"`
|
||||
Payload map[string]any `json:"payload"`
|
||||
}
|
||||
|
||||
type commandInput struct {
|
||||
EventID string `json:"event_id"`
|
||||
TaskID string `json:"task_id,omitempty"`
|
||||
Callee string `json:"callee,omitempty"`
|
||||
Action string `json:"action,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
TrunkID string `json:"trunk_id,omitempty"`
|
||||
ChangeType string `json:"change_type,omitempty"`
|
||||
Revision *int64 `json:"revision,omitempty"`
|
||||
}
|
||||
|
||||
type requestRecord struct {
|
||||
Input commandInput `json:"-"`
|
||||
Digest string `json:"-"`
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type feedbackRecord struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
DispatcherID string `json:"dispatcher_id"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Outcome string `json:"outcome,omitempty"`
|
||||
Validation string `json:"validation"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Duplicates int `json:"duplicates"`
|
||||
Digest string `json:"-"`
|
||||
}
|
||||
|
||||
func digest(raw []byte) string { sum := sha256.Sum256(raw); return hex.EncodeToString(sum[:]) }
|
||||
func messageKey(dispatcherID, eventType, eventID string) string {
|
||||
raw, _ := json.Marshal([]string{dispatcherID, eventType, eventID})
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func (s *service) send(ctx context.Context, kind string, input commandInput, now time.Time) (requestRecord, error) {
|
||||
s.eventMu.Lock()
|
||||
defer s.eventMu.Unlock()
|
||||
s.mu.RLock()
|
||||
data := s.data
|
||||
s.mu.RUnlock()
|
||||
msg := envelope{EventID: input.EventID, EventType: kind, DispatcherID: data.dispatcherID, Payload: make(map[string]any)}
|
||||
route := "d." + data.dispatcherID + ".control.in"
|
||||
switch kind {
|
||||
case "call.execute", "task.control":
|
||||
if _, ok := data.tasks[input.TaskID]; !ok {
|
||||
return requestRecord{}, errors.New("unknown task")
|
||||
}
|
||||
msg.TenantID = data.tenantID
|
||||
msg.IssuedAt = now.UTC().Format(time.RFC3339Nano)
|
||||
msg.Payload["task_id"] = input.TaskID
|
||||
if kind == "call.execute" {
|
||||
msg.Payload["callee"] = input.Callee
|
||||
route = "d." + data.dispatcherID + ".task." + input.TaskID + ".in"
|
||||
} else {
|
||||
if input.Action != "start" && input.Action != "resume" && input.Action != "edit" {
|
||||
return requestRecord{}, errors.New("only start, resume and edit are supported")
|
||||
}
|
||||
msg.Payload["action"] = input.Action
|
||||
msg.Payload["reason"] = input.Reason
|
||||
}
|
||||
case "sip.config":
|
||||
msg.Payload["trunk_id"] = input.TrunkID
|
||||
msg.Payload["change_type"] = input.ChangeType
|
||||
if input.Revision != nil {
|
||||
msg.Payload["revision"] = *input.Revision
|
||||
}
|
||||
default:
|
||||
return requestRecord{}, errors.New("unsupported command")
|
||||
}
|
||||
raw, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return requestRecord{}, err
|
||||
}
|
||||
if err = contract.ValidateCurrent("mq", raw); err != nil {
|
||||
return requestRecord{}, fmt.Errorf("command: %w", err)
|
||||
}
|
||||
key := messageKey(data.dispatcherID, kind, input.EventID)
|
||||
inputRaw, _ := json.Marshal(input)
|
||||
hash := digest(inputRaw)
|
||||
if previous, ok := s.requests[key]; ok {
|
||||
if previous.Digest != hash {
|
||||
return requestRecord{}, errors.New("event identity already used with different content")
|
||||
}
|
||||
return previous, nil
|
||||
}
|
||||
if s.publish == nil {
|
||||
return requestRecord{}, errors.New("MQ publisher unavailable")
|
||||
}
|
||||
record := requestRecord{Input: input, Digest: hash, EventID: input.EventID, EventType: kind, Status: "unknown"}
|
||||
s.requests[key] = record
|
||||
slog.Info("Test command publishing", "event_id", input.EventID, "event_type", kind, "stage", "publish_requested")
|
||||
status, publishErr := s.publish(ctx, route, raw)
|
||||
record.Status = status
|
||||
if publishErr != nil {
|
||||
record.Error = publishErr.Error()
|
||||
}
|
||||
s.requests[key] = record
|
||||
slog.Info("Test command publish outcome", "event_id", input.EventID, "event_type", kind, "status", record.Status, "error", record.Error)
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (s *service) receive(raw []byte, ack func() error) error {
|
||||
s.eventMu.Lock()
|
||||
defer s.eventMu.Unlock()
|
||||
if err := saveFeedback(s.feedbackDir, raw); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.RLock()
|
||||
dispatcherID, tenantID := s.data.dispatcherID, s.data.tenantID
|
||||
s.mu.RUnlock()
|
||||
var msg envelope
|
||||
record := feedbackRecord{Validation: "valid", Digest: digest(raw)}
|
||||
err := json.Unmarshal(raw, &msg)
|
||||
record.EventID = msg.EventID
|
||||
record.EventType = msg.EventType
|
||||
record.DispatcherID = msg.DispatcherID
|
||||
schemaErr := contract.ValidateCurrent("mq", raw)
|
||||
// The shared schema validates these enum fields; do not maintain another value list.
|
||||
if err == nil && schemaErr == nil {
|
||||
record.Status, _ = msg.Payload["status"].(string)
|
||||
record.Outcome, _ = msg.Payload["outcome"].(string)
|
||||
}
|
||||
invalid := func(reason string) { record.Validation = "invalid"; record.Error = reason }
|
||||
if err != nil || schemaErr != nil {
|
||||
invalid("feedback violates current MQ contract")
|
||||
} else if msg.DispatcherID != dispatcherID {
|
||||
invalid("feedback belongs to another dispatcher")
|
||||
} else {
|
||||
requestKind := msg.EventType
|
||||
if msg.EventType == "call.execute.result" {
|
||||
requestKind = "call.execute"
|
||||
} else if (msg.EventType != "call.execute" && msg.EventType != "task.control" && msg.EventType != "sip.config") || record.Status == "" {
|
||||
invalid("message is not an outbound receipt")
|
||||
}
|
||||
request, ok := s.requests[messageKey(msg.DispatcherID, requestKind, msg.EventID)]
|
||||
if !ok {
|
||||
invalid("feedback has no request in this process")
|
||||
} else {
|
||||
if msg.TenantID != 0 && msg.TenantID != tenantID {
|
||||
invalid("feedback tenant mismatch")
|
||||
}
|
||||
for field, want := range map[string]string{"task_id": request.Input.TaskID, "callee": request.Input.Callee, "action": request.Input.Action} {
|
||||
if value, exists := msg.Payload[field]; exists && value != want {
|
||||
invalid("feedback request association mismatch")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for i, previous := range s.feedback {
|
||||
if messageKey(previous.DispatcherID, previous.EventType, previous.EventID) != messageKey(record.DispatcherID, record.EventType, record.EventID) {
|
||||
continue
|
||||
}
|
||||
if previous.Digest == record.Digest {
|
||||
s.feedback[i].Duplicates++
|
||||
return ack()
|
||||
}
|
||||
invalid("conflicting feedback for same identity")
|
||||
}
|
||||
s.feedback = append(s.feedback, record)
|
||||
slog.Info("Test feedback saved and validated", "event_id", record.EventID, "event_type", record.EventType, "validation", record.Validation, "error", record.Error)
|
||||
return ack()
|
||||
}
|
||||
|
||||
func saveFeedback(dir string, raw []byte) error {
|
||||
f, err := os.CreateTemp(dir, "feedback-*.json")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create feedback evidence: %w", err)
|
||||
}
|
||||
if _, err = f.Write(raw); err == nil {
|
||||
err = f.Sync()
|
||||
}
|
||||
closeErr := f.Close()
|
||||
if err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("save feedback evidence: %w", err)
|
||||
}
|
||||
parent, err := os.Open(filepath.Dir(f.Name()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
syncErr := parent.Sync()
|
||||
closeErr = parent.Close()
|
||||
if syncErr != nil {
|
||||
return syncErr
|
||||
}
|
||||
return closeErr
|
||||
}
|
||||
|
||||
func (s *service) events(id string) []feedbackRecord {
|
||||
s.eventMu.Lock()
|
||||
defer s.eventMu.Unlock()
|
||||
records := []feedbackRecord{}
|
||||
for _, record := range s.feedback {
|
||||
if record.EventID == id {
|
||||
records = append(records, record)
|
||||
}
|
||||
}
|
||||
return records
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCommandIdentityDedupAndUnknown(t *testing.T) {
|
||||
dir, id := fixture(t)
|
||||
data, err := loadSnapshots(dir, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := newService(data, "secret")
|
||||
sends := 0
|
||||
svc.publish = func(_ context.Context, route string, raw []byte) (string, error) {
|
||||
sends++
|
||||
if !strings.HasSuffix(route, ".task.1.in") {
|
||||
t.Fatal(route)
|
||||
}
|
||||
var msg envelope
|
||||
if err := json.Unmarshal(raw, &msg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if msg.EventID != "source/numberID:42" {
|
||||
t.Fatal(msg.EventID)
|
||||
}
|
||||
return "unknown", errors.New("confirmation missing")
|
||||
}
|
||||
req := commandInput{EventID: "source/numberID:42", TaskID: "1", Callee: "123456"}
|
||||
first, err := svc.send(context.Background(), "call.execute", req, time.Now())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.Status != "unknown" {
|
||||
t.Fatal(first)
|
||||
}
|
||||
second, err := svc.send(context.Background(), "call.execute", req, time.Now())
|
||||
if err != nil || second.Status != "unknown" || sends != 1 {
|
||||
t.Fatalf("duplicate sends=%d result=%+v err=%v", sends, second, err)
|
||||
}
|
||||
req.Callee = "999"
|
||||
if _, err = svc.send(context.Background(), "call.execute", req, time.Now()); err == nil {
|
||||
t.Fatal("accepted conflicting source identity")
|
||||
}
|
||||
req.EventID = ""
|
||||
req.Callee = "123456"
|
||||
if _, err = svc.send(context.Background(), "call.execute", req, time.Now()); err == nil {
|
||||
t.Fatal("accepted empty source identity")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeedbackValidationAndSaveBeforeAck(t *testing.T) {
|
||||
dir, id := fixture(t)
|
||||
data, err := loadSnapshots(dir, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := newService(data, "secret")
|
||||
svc.publish = func(context.Context, string, []byte) (string, error) { return "confirmed", nil }
|
||||
if _, err = svc.send(context.Background(), "call.execute", commandInput{EventID: "call-example", TaskID: "1", Callee: "15003164745"}, time.Now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc.feedbackDir = t.TempDir()
|
||||
raw, err := os.ReadFile("../../contracts/schema/examples/mq-execute-ack.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ack := false
|
||||
if err = svc.receive(raw, func() error {
|
||||
files, e := os.ReadDir(svc.feedbackDir)
|
||||
if e != nil || len(files) != 1 {
|
||||
t.Fatalf("ACK before save: %v %v", files, e)
|
||||
}
|
||||
ack = true
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !ack {
|
||||
t.Fatal("no ACK")
|
||||
}
|
||||
records := svc.events("call-example")
|
||||
if len(records) != 1 || records[0].Validation != "valid" {
|
||||
t.Fatalf("%+v", records)
|
||||
}
|
||||
if err = svc.receive(raw, func() error { return nil }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := svc.events("call-example"); len(got) != 1 || got[0].Duplicates != 1 {
|
||||
t.Fatalf("duplicates %+v", got)
|
||||
}
|
||||
result, err := os.ReadFile("../../contracts/schema/examples/mq-result-no-recording.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var msg envelope
|
||||
json.Unmarshal(result, &msg)
|
||||
msg.Payload["task_id"] = "1"
|
||||
msg.Payload["outcome"] = "answered" // Regression: keep every schema-valid outcome visible.
|
||||
result, _ = json.Marshal(msg)
|
||||
if err = svc.receive(result, func() error { return nil }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if records := svc.events("call-example"); len(records) != 2 || records[1].Outcome != "answered" {
|
||||
t.Fatalf("receipt overwritten or valid outcome hidden: %+v", records)
|
||||
}
|
||||
msg.Payload["callee"] = "999"
|
||||
result, _ = json.Marshal(msg)
|
||||
if err = svc.receive(result, func() error { return nil }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := svc.events("call-example")
|
||||
if len(got) != 3 || got[2].Validation != "invalid" {
|
||||
t.Fatalf("conflict=%+v", got)
|
||||
}
|
||||
bad := []byte(`{"event_id":"bad","token":"must-not-leak"}`)
|
||||
if err = svc.receive(bad, func() error { return nil }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
encoded, _ := json.Marshal(svc.events("bad"))
|
||||
if strings.Contains(string(encoded), "must-not-leak") {
|
||||
t.Fatal("raw body in query")
|
||||
}
|
||||
// A regular file instead of a directory forces saving to fail before ACK.
|
||||
svc.feedbackDir = filepath.Join(svc.feedbackDir, "file")
|
||||
os.WriteFile(svc.feedbackDir, []byte("x"), 0600)
|
||||
ack = false
|
||||
if err = svc.receive(raw, func() error { ack = true; return nil }); err == nil || ack {
|
||||
t.Fatalf("save failure swallowed or ACKed: %v %v", err, ack)
|
||||
}
|
||||
fresh := newService(data, "secret")
|
||||
fresh.feedbackDir = svc.feedbackDir
|
||||
if len(fresh.events("call-example")) != 0 {
|
||||
t.Fatal("restored history")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
type service struct {
|
||||
app *fiber.App
|
||||
mu sync.RWMutex
|
||||
data snapshots
|
||||
eventMu sync.Mutex
|
||||
requests map[string]requestRecord
|
||||
feedback []feedbackRecord
|
||||
feedbackDir string
|
||||
publish func(context.Context, string, []byte) (string, error)
|
||||
}
|
||||
|
||||
func newService(data snapshots, secret string) *service {
|
||||
s := &service{data: data, app: fiber.New(fiber.Config{Immutable: true}), requests: make(map[string]requestRecord)}
|
||||
const prefix = "/internal/v1/dispatcher"
|
||||
s.app.Use(prefix, func(c fiber.Ctx) error {
|
||||
if subtle.ConstantTimeCompare([]byte(c.Get("X-DISPATCHER-id")), []byte(data.dispatcherID)) != 1 || subtle.ConstantTimeCompare([]byte(c.Get("X-DISPATCHER-SECRET-KEY")), []byte(secret)) != 1 {
|
||||
return c.Status(403).JSON(fiber.Map{"error": "unknown dispatcher"})
|
||||
}
|
||||
return c.Next()
|
||||
})
|
||||
serve := func(resource string) fiber.Handler {
|
||||
return func(c fiber.Ctx) error {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
var raw []byte
|
||||
switch resource {
|
||||
case "sip":
|
||||
raw = s.data.sip
|
||||
case "providers":
|
||||
raw = s.data.providers
|
||||
case "quota":
|
||||
id, err := strconv.ParseInt(c.Params("tenant_id"), 10, 64)
|
||||
if err != nil || id != s.data.tenantID {
|
||||
return c.Status(404).JSON(fiber.Map{"error": "unknown tenant"})
|
||||
}
|
||||
raw = s.data.quota
|
||||
case "task":
|
||||
var ok bool
|
||||
raw, ok = s.data.tasks[c.Params("task_id")]
|
||||
if !ok {
|
||||
return c.Status(404).JSON(fiber.Map{"error": "unknown task"})
|
||||
}
|
||||
case "tasks":
|
||||
var err error
|
||||
raw, err = s.data.page(c.Query("after"))
|
||||
if err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
c.Set("Content-Type", "application/json")
|
||||
return c.Send(raw)
|
||||
}
|
||||
}
|
||||
s.app.Get(prefix+"/sip", serve("sip"))
|
||||
s.app.Get(prefix+"/ai-providers", serve("providers"))
|
||||
s.app.Get(prefix+"/tenant/:tenant_id/quota", serve("quota"))
|
||||
s.app.Get(prefix+"/task/:task_id", serve("task"))
|
||||
s.app.Get(prefix+"/tasks", serve("tasks"))
|
||||
s.routes()
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
//go:build integration
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentpb "git.ipao.vip/rogee/go-sip/gen/agent"
|
||||
"git.ipao.vip/rogee/go-sip/internal/agent"
|
||||
"git.ipao.vip/rogee/go-sip/internal/ai"
|
||||
"git.ipao.vip/rogee/go-sip/internal/configread"
|
||||
"git.ipao.vip/rogee/go-sip/internal/dispatcher"
|
||||
"git.ipao.vip/rogee/go-sip/internal/mq"
|
||||
"git.ipao.vip/rogee/go-sip/internal/oss"
|
||||
"git.ipao.vip/rogee/go-sip/internal/rpc"
|
||||
"git.ipao.vip/rogee/go-sip/internal/store"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials"
|
||||
)
|
||||
|
||||
func localCertificates(t *testing.T) ([]byte, []byte, []byte, []byte, []byte, *x509.Certificate, *x509.Certificate) {
|
||||
t.Helper()
|
||||
makeKey := func() *ecdsa.PrivateKey {
|
||||
k, e := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
return k
|
||||
}
|
||||
caKey := makeKey()
|
||||
now := time.Now()
|
||||
ca := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "isolated-saas-test"}, NotBefore: now.Add(-time.Minute), NotAfter: now.Add(time.Hour), IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign}
|
||||
caDER, e := x509.CreateCertificate(rand.Reader, ca, ca, &caKey.PublicKey, caKey)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
caPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caDER})
|
||||
leaf := func(serial int64, name string) ([]byte, []byte, *x509.Certificate) {
|
||||
key := makeKey()
|
||||
cert := &x509.Certificate{SerialNumber: big.NewInt(serial), Subject: pkix.Name{CommonName: name}, DNSNames: []string{name}, IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, NotBefore: now.Add(-time.Minute), NotAfter: now.Add(time.Hour), KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}}
|
||||
raw, err := x509.CreateCertificate(rand.Reader, cert, ca, &key.PublicKey, caKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parsed, err := x509.ParseCertificate(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keyDER, err := x509.MarshalECPrivateKey(key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: raw}), pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}), parsed
|
||||
}
|
||||
dc, dk, dl := leaf(2, "dispatcher.local")
|
||||
ac, ak, al := leaf(3, "agent.local")
|
||||
return caPEM, dc, dk, ac, ak, dl, al
|
||||
}
|
||||
|
||||
func TestSaaSServerIsolatedHTTPSMQAgentMTLS(t *testing.T) {
|
||||
address := os.Getenv("SAAS_MOCK_TEST_BROKER_URL")
|
||||
if address == "" {
|
||||
t.Skip("requires dedicated isolated RabbitMQ vhost")
|
||||
}
|
||||
dir, id := fixture(t)
|
||||
// Synthetic test snapshots permit this Mock run at any time; no real configuration is read.
|
||||
task, err := os.ReadFile("../../contracts/schema/examples/config-read-task-asr.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var fields map[string]any
|
||||
json.Unmarshal(task, &fields)
|
||||
fields["task_id"] = "1"
|
||||
weekly := map[string]any{}
|
||||
for _, day := range []string{"monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"} {
|
||||
weekly[day] = []map[string]string{{"start": "00:00", "end": "24:00"}}
|
||||
}
|
||||
fields["schedule"] = map[string]any{"time_zone": "Asia/Shanghai", "starts_at": "2000-01-01T00:00:00+08:00", "weekly_windows": weekly}
|
||||
task, _ = json.Marshal(fields)
|
||||
if err = os.WriteFile(filepath.Join(dir, "tasks/1.json"), task, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sipRaw, err := os.ReadFile(filepath.Join(dir, "sip.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fields = nil // JSON decoding into an existing map retains unrelated task fields.
|
||||
json.Unmarshal(sipRaw, &fields)
|
||||
for _, item := range fields["trunks"].([]any) {
|
||||
trunk := item.(map[string]any)
|
||||
trunk["transport"] = "udp"
|
||||
trunk["auth_mode"] = "ip"
|
||||
trunk["registration_required"] = false
|
||||
trunk["max_concurrent_calls"] = float64(2)
|
||||
trunk["schedule"] = map[string]any{"time_zone": "Asia/Shanghai", "weekly_windows": weekly}
|
||||
}
|
||||
sipRaw, _ = json.Marshal(fields)
|
||||
if err = os.WriteFile(filepath.Join(dir, "sip.json"), sipRaw, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := map[string][]byte{}
|
||||
if err = filepath.WalkDir(dir, func(path string, d os.DirEntry, e error) error {
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if !d.IsDir() {
|
||||
raw, e := os.ReadFile(path)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
before[path] = raw
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := loadSnapshots(dir, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := connectBroker(address, data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer b.conn.Close()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
s := newService(data, "isolated-secret")
|
||||
s.feedbackDir = t.TempDir()
|
||||
s.publish = b.publish
|
||||
consumeDone := make(chan error, 1)
|
||||
go func() { consumeDone <- b.consume(ctx, s) }()
|
||||
defer func() {
|
||||
cancel()
|
||||
select {
|
||||
case e := <-consumeDone:
|
||||
if e != nil {
|
||||
t.Error(e)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Error("consumer did not stop")
|
||||
}
|
||||
}()
|
||||
ca, dc, dk, ac, ak, dl, al := localCertificates(t)
|
||||
roots := x509.NewCertPool()
|
||||
roots.AppendCertsFromPEM(ca)
|
||||
pair, err := tls.X509KeyPair(dc, dk)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
go s.app.Listener(tls.NewListener(listener, &tls.Config{Certificates: []tls.Certificate{pair}, MinVersion: tls.VersionTLS12}), fiber.ListenConfig{DisableStartupMessage: true})
|
||||
defer s.app.ShutdownWithTimeout(5 * time.Second)
|
||||
httpClient := &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{RootCAs: roots, MinVersion: tls.VersionTLS12}}, Timeout: 5 * time.Second}
|
||||
client, err := configread.NewClient("https://"+listener.Addr().String(), id, "isolated-secret", httpClient)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db, err := store.Open(filepath.Join(t.TempDir(), "dispatcher.sqlite"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
coordinator := dispatcher.NewAgentCoordinator(time.Now)
|
||||
signer, err := oss.NewClient(oss.Config{Endpoint: "https://127.0.0.1:1", Region: "cn-test", Bucket: "mock-bucket", AccessKeyID: "test-id", AccessKeySecret: "test-secret", MaxAssetBytes: 4096})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
feedbackRPC := &rpc.RecordingServer{Store: db, OSS: signer, DispatcherID: id, TrustedFingerprints: map[string]struct{}{rpc.CertificateFingerprint(al): {}}, AuthorizeSession: coordinator.AuthorizeInboundMeta}
|
||||
serveRPC := func(cert, key []byte, handler agentpb.AgentControlServiceServer) string {
|
||||
cfg, e := rpc.NewServerTLSConfig(ca, cert, key)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
l, e := net.Listen("tcp", "127.0.0.1:0")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
server := grpc.NewServer(grpc.Creds(credentials.NewTLS(cfg)))
|
||||
agentpb.RegisterAgentControlServiceServer(server, handler)
|
||||
go server.Serve(l)
|
||||
t.Cleanup(server.Stop)
|
||||
return l.Addr().String()
|
||||
}
|
||||
connectRPC := func(target, name string, cert, key []byte) agentpb.AgentControlServiceClient {
|
||||
cfg, e := rpc.NewClientTLSConfig(ca, cert, key, name)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
conn, e := grpc.NewClient(target, grpc.WithTransportCredentials(credentials.NewTLS(cfg)))
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
t.Cleanup(func() { conn.Close() })
|
||||
return agentpb.NewAgentControlServiceClient(conn)
|
||||
}
|
||||
agentToDispatcher := connectRPC(serveRPC(dc, dk, feedbackRPC), "dispatcher.local", ac, ak)
|
||||
workerFailure := make(chan error, 1)
|
||||
var handler *rpc.Server
|
||||
worker := &rpc.ApprovedCallWorker{Lifecycle: ctx, Calls: &agent.TaskCalls{}, Prepare: func(execution rpc.ApprovedExecution) (func(context.Context) error, error) {
|
||||
runner := &rpc.ApprovedRecordedMockCall{InboundPCM16: bytes.Repeat([]byte{1, 0}, 1600), Script: ai.ApprovedMockScript{Turns: []ai.ApprovedMockTurn{{Transcript: "synthetic ASR test"}}}, MaxWAVBytes: 4096, ExpectedRecording: false, Outcome: "answered", ReasonMessage: "isolated synthetic media", ReportTimeout: 5 * time.Second, Delivery: &agent.RecordingDelivery{Call: agent.RecordingClient{Client: agentToDispatcher, DispatcherID: execution.DispatcherID, TenantID: execution.TenantID, SourceEventID: execution.SourceEventID, Session: func(context.Context) (*agentpb.RequestMeta, error) { return handler.ActiveSessionMeta() }}}}
|
||||
return runner.Prepare(execution)
|
||||
}, OnFailure: func(_ rpc.ApprovedExecution, e error) error { workerFailure <- e; return nil }}
|
||||
handler, err = rpc.NewApprovedAgentServer(rpc.ServerOptions{Mode: "mock", StatePath: filepath.Join(t.TempDir(), "session.json"), ApprovedDispatcherID: id, Status: &agentpb.AgentStatus{AgentId: "agent-mock", CellId: "cell-mock", BootId: "boot-test", ProtocolVersion: "agent.v1"}, LoadedSIP: func(context.Context) (map[string]int64, error) { return map[string]int64{"trunk-mock": 8}, nil }, RequirePeerCertificate: true, PeerCertificateFingerprints: map[string]struct{}{rpc.CertificateFingerprint(dl): {}}}, worker)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
agentClient := connectRPC(serveRPC(ac, ak, handler), "agent.local", dc, dk)
|
||||
if err = coordinator.Register("agent-mock", agentClient); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
activateCtx, stop := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer stop()
|
||||
status, err := coordinator.Probe(activateCtx, "agent-mock", "cell-mock")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = coordinator.Activate(activateCtx, id, "agent-mock", "cell-mock", status.BootId, "test-epoch", 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
originator := &dispatcher.ApprovedOriginator{DispatcherID: id, Client: agentClient, Meta: func(ctx context.Context) (*agentpb.RequestMeta, error) {
|
||||
return coordinator.ApprovedMeta(ctx, "agent-mock")
|
||||
}}
|
||||
dispatcherBroker, err := mq.Open(address, id, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer dispatcherBroker.Close()
|
||||
runtime := &dispatcher.Runtime{Broker: dispatcherBroker, Bootstrap: dispatcher.Bootstrap{DispatcherID: id, Client: client, Store: db, VerifySIP: originator.VerifySIP}, Execute: dispatcher.ExecuteController{DispatcherID: id, Store: db, Originator: originator, Publisher: dispatcherBroker, Now: time.Now}, Control: dispatcher.ControlController{DispatcherID: id, Store: db, Client: client, Agent: originator, Now: time.Now}, PollInterval: 20 * time.Millisecond, Logger: slog.Default()}
|
||||
runtimeDone := make(chan error, 1)
|
||||
go func() { runtimeDone <- runtime.Serve(ctx) }()
|
||||
defer func() {
|
||||
cancel()
|
||||
select {
|
||||
case e := <-runtimeDone:
|
||||
if e != nil && !errors.Is(e, context.Canceled) {
|
||||
t.Error(e)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Error("Dispatcher did not stop")
|
||||
}
|
||||
}()
|
||||
sourceID := "numberID-integration/Original:001"
|
||||
// The first request may queue while bootstrap completes; no direct Agent call or forged feedback.
|
||||
code, body := request(t, s, "POST", "/test/calls", []byte(`{"event_id":"`+sourceID+`","task_id":"1","callee":"123456"}`))
|
||||
if code != 200 {
|
||||
t.Fatalf("publish %d %s", code, body)
|
||||
}
|
||||
until := time.NewTimer(15 * time.Second)
|
||||
defer until.Stop()
|
||||
for {
|
||||
records := s.events(sourceID)
|
||||
if len(records) >= 2 {
|
||||
seen := map[string]bool{}
|
||||
for _, record := range records {
|
||||
if record.Validation != "valid" {
|
||||
t.Fatalf("feedback: %+v", record)
|
||||
}
|
||||
seen[record.EventType] = true
|
||||
}
|
||||
if !seen["call.execute"] || !seen["call.execute.result"] {
|
||||
t.Fatalf("missing receipt or result: %+v", records)
|
||||
}
|
||||
break
|
||||
}
|
||||
select {
|
||||
case e := <-workerFailure:
|
||||
t.Fatal(e)
|
||||
case <-until.C:
|
||||
t.Fatalf("feedback timeout: %+v", records)
|
||||
case <-time.After(20 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
for path, want := range before {
|
||||
got, e := os.ReadFile(path)
|
||||
if e != nil || !bytes.Equal(want, got) {
|
||||
t.Fatalf("input changed: %s %v", path, e)
|
||||
}
|
||||
}
|
||||
files, err := os.ReadDir(s.feedbackDir)
|
||||
if err != nil || len(files) < 2 {
|
||||
t.Fatalf("feedback evidence missing: %v %v", files, err)
|
||||
}
|
||||
t.Log("Fiber HTTPS → RabbitMQ → unchanged Dispatcher → Mock Agent mTLS → original-ID receipt/result verified; no SIP or real providers")
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
//go:build integration
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestStartupShutdownPreservesConfiguration(t *testing.T) {
|
||||
address := os.Getenv("SAAS_MOCK_TEST_BROKER_URL")
|
||||
if address == "" {
|
||||
t.Skip("requires isolated test broker")
|
||||
}
|
||||
dir, id := fixture(t)
|
||||
reserve, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
listen := reserve.Addr().String()
|
||||
reserve.Close()
|
||||
config := startupConfig{Listen: listen, DispatcherID: id, DispatcherSecret: "test-secret", BrokerURL: address, Snapshots: ".", Feedback: filepath.Join(t.TempDir(), "feedback")}
|
||||
raw, _ := json.Marshal(config)
|
||||
path := filepath.Join(dir, "server.json")
|
||||
if err = os.WriteFile(path, raw, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := map[string][]byte{}
|
||||
if err = filepath.WalkDir(dir, func(p string, d os.DirEntry, e error) error {
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if !d.IsDir() {
|
||||
before[p], e = os.ReadFile(p)
|
||||
}
|
||||
return e
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- run(ctx, path) }()
|
||||
ready := false
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
client := &http.Client{Timeout: time.Second}
|
||||
for time.Now().Before(deadline) {
|
||||
req, _ := http.NewRequest("GET", "http://"+listen+"/internal/v1/dispatcher/sip", nil)
|
||||
req.Header.Set("X-DISPATCHER-id", id)
|
||||
req.Header.Set("X-DISPATCHER-SECRET-KEY", "test-secret")
|
||||
resp, e := client.Do(req)
|
||||
if e == nil {
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode == 200 {
|
||||
ready = true
|
||||
break
|
||||
}
|
||||
}
|
||||
select {
|
||||
case e = <-done:
|
||||
t.Fatalf("startup failed: %v", e)
|
||||
default:
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if !ready {
|
||||
t.Fatal("server did not become ready")
|
||||
}
|
||||
cancel()
|
||||
select {
|
||||
case err = <-done:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("shutdown timed out")
|
||||
}
|
||||
for p, want := range before {
|
||||
got, e := os.ReadFile(p)
|
||||
if e != nil || !bytes.Equal(got, want) {
|
||||
t.Fatalf("configuration changed on exit: %s %v", p, e)
|
||||
}
|
||||
}
|
||||
files, err := os.ReadDir(config.Feedback)
|
||||
if err != nil || len(files) != 0 {
|
||||
t.Fatalf("startup produced unsolicited feedback: %v %v", files, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
func run(ctx context.Context, path string) error {
|
||||
config, err := loadStartup(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := loadSnapshots(config.Snapshots, config.DispatcherID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
slog.Info("test configurations loaded", "dispatcher_id", data.dispatcherID, "tasks", len(data.tasks))
|
||||
if err = os.MkdirAll(config.Feedback, 0700); err != nil {
|
||||
return fmt.Errorf("create private feedback directory: %w", err)
|
||||
}
|
||||
info, err := os.Stat(config.Feedback)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() || info.Mode().Perm()&0077 != 0 {
|
||||
return errors.New("feedback directory must be private")
|
||||
}
|
||||
b, err := connectBroker(config.BrokerURL, data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer b.conn.Close()
|
||||
slog.Info("isolated MQ topology ready", "dispatcher_id", data.dispatcherID)
|
||||
listener, err := net.Listen("tcp", config.Listen)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen: %w", err)
|
||||
}
|
||||
defer listener.Close()
|
||||
if config.TLSCert != "" {
|
||||
pair, e := tls.LoadX509KeyPair(config.TLSCert, config.TLSKey)
|
||||
if e != nil {
|
||||
return fmt.Errorf("load configured TLS pair: %w", e)
|
||||
}
|
||||
listener = tls.NewListener(listener, &tls.Config{Certificates: []tls.Certificate{pair}, MinVersion: tls.VersionTLS12})
|
||||
}
|
||||
s := newService(data, config.DispatcherSecret)
|
||||
s.feedbackDir = config.Feedback
|
||||
s.publish = b.publish
|
||||
child, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
errs := make(chan error, 2)
|
||||
go func() { errs <- b.consume(child, s) }()
|
||||
go func() { errs <- s.app.Listener(listener, fiber.ListenConfig{DisableStartupMessage: true}) }()
|
||||
slog.Info("SaaS test server listening", "address", listener.Addr().String(), "tls", config.TLSCert != "", "automatic_commands", false)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
err = nil
|
||||
case err = <-errs:
|
||||
if err == nil && ctx.Err() == nil {
|
||||
err = errors.New("SaaS test server stopped unexpectedly")
|
||||
}
|
||||
}
|
||||
cancel()
|
||||
if shutdownErr := s.app.ShutdownWithTimeout(5 * time.Second); shutdownErr != nil {
|
||||
err = errors.Join(err, fmt.Errorf("HTTP shutdown: %w", shutdownErr))
|
||||
}
|
||||
// Closing the listener/connection unblocks both workers; do not leave consumers behind.
|
||||
listener.Close()
|
||||
b.conn.Close()
|
||||
slog.Info("SaaS test server stopped")
|
||||
return err
|
||||
}
|
||||
|
||||
func main() {
|
||||
var path string
|
||||
flag.StringVar(&path, "config", "", "private startup JSON (required)")
|
||||
flag.Parse()
|
||||
if path == "" || flag.NArg() != 0 {
|
||||
fmt.Fprintln(os.Stderr, "usage: saas-server --config /path/server.json")
|
||||
os.Exit(2)
|
||||
}
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
if err := run(ctx, path); err != nil {
|
||||
slog.Error("SaaS test server failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.ipao.vip/rogee/go-sip/internal/mq"
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
|
||||
const feedbackQueue = "agent-call.saas.events.v1"
|
||||
|
||||
type broker struct{ conn *amqp.Connection }
|
||||
|
||||
func connectBroker(address string, data snapshots) (*broker, error) {
|
||||
conn, err := amqp.DialConfig(address, amqp.Config{Dial: amqp.DefaultDial(5 * time.Second)})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect test MQ: %w", err)
|
||||
}
|
||||
b := &broker{conn: conn}
|
||||
if err = b.provision(data); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func (b *broker) provision(data snapshots) error {
|
||||
ch, err := b.conn.Channel()
|
||||
if err != nil {
|
||||
return fmt.Errorf("open topology channel: %w", err)
|
||||
}
|
||||
defer ch.Close()
|
||||
for _, exchange := range []string{mq.CommandsExchange, mq.ResultsExchange} {
|
||||
if err = ch.ExchangeDeclare(exchange, "topic", true, false, false, false, nil); err != nil {
|
||||
return fmt.Errorf("declare %s: %w", exchange, err)
|
||||
}
|
||||
}
|
||||
bind := func(queue, route, exchange string) error {
|
||||
if _, e := ch.QueueDeclare(queue, true, false, false, false, nil); e != nil {
|
||||
return fmt.Errorf("declare %s: %w", queue, e)
|
||||
}
|
||||
if e := ch.QueueBind(queue, route, exchange, false, nil); e != nil {
|
||||
return fmt.Errorf("bind %s: %w", queue, e)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err = bind(mq.ControlQueueName(data.dispatcherID), "d."+data.dispatcherID+".control.in", mq.CommandsExchange); err != nil {
|
||||
return err
|
||||
}
|
||||
for id := range data.tasks {
|
||||
if err = bind("agent-call.d."+data.dispatcherID+".task."+id+".v1", "d."+data.dispatcherID+".task."+id+".in", mq.CommandsExchange); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return bind(feedbackQueue, "d."+data.dispatcherID+".out", mq.ResultsExchange)
|
||||
}
|
||||
|
||||
func (b *broker) publish(ctx context.Context, route string, raw []byte) (string, error) {
|
||||
ch, err := b.conn.Channel()
|
||||
if err != nil {
|
||||
return "not_sent", fmt.Errorf("open publisher: %w", err)
|
||||
}
|
||||
defer ch.Close()
|
||||
if err = ch.Confirm(false); err != nil {
|
||||
return "not_sent", fmt.Errorf("enable confirmation: %w", err)
|
||||
}
|
||||
confirms := ch.NotifyPublish(make(chan amqp.Confirmation, 1))
|
||||
returns := ch.NotifyReturn(make(chan amqp.Return, 1))
|
||||
if err = ch.PublishWithContext(ctx, mq.CommandsExchange, route, true, false, amqp.Publishing{ContentType: "application/json", DeliveryMode: amqp.Persistent, Body: raw}); err != nil {
|
||||
return "unknown", fmt.Errorf("MQ publish outcome unknown: %w", err)
|
||||
}
|
||||
return awaitConfirm(ctx, confirms, returns)
|
||||
}
|
||||
|
||||
func awaitConfirm(ctx context.Context, confirms <-chan amqp.Confirmation, returns <-chan amqp.Return) (string, error) {
|
||||
returned := false
|
||||
for {
|
||||
select {
|
||||
case _, ok := <-returns:
|
||||
if !ok {
|
||||
returns = nil
|
||||
} else {
|
||||
returned = true
|
||||
}
|
||||
case confirm, ok := <-confirms:
|
||||
if !ok {
|
||||
return "unknown", errors.New("MQ confirmation stream closed")
|
||||
}
|
||||
// RabbitMQ emits return before confirmation; drain the buffered notification.
|
||||
select {
|
||||
case _, ok := <-returns:
|
||||
if ok {
|
||||
returned = true
|
||||
}
|
||||
default:
|
||||
}
|
||||
if returned {
|
||||
return "unroutable", errors.New("MQ returned unroutable message")
|
||||
}
|
||||
if !confirm.Ack {
|
||||
return "rejected", errors.New("MQ publisher NACK")
|
||||
}
|
||||
return "confirmed", nil
|
||||
case <-ctx.Done():
|
||||
return "unknown", errors.New("MQ confirmation deadline exceeded; do not resend")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *broker) consume(ctx context.Context, s *service) error {
|
||||
ch, err := b.conn.Channel()
|
||||
if err != nil {
|
||||
return fmt.Errorf("open feedback consumer: %w", err)
|
||||
}
|
||||
defer ch.Close()
|
||||
if err = ch.Qos(1, 0, false); err != nil {
|
||||
return fmt.Errorf("feedback prefetch: %w", err)
|
||||
}
|
||||
deliveries, err := ch.Consume(feedbackQueue, "", false, true, false, false, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("start exclusive feedback consumer: %w", err)
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case delivery, ok := <-deliveries:
|
||||
if !ok {
|
||||
return errors.New("MQ feedback stream closed")
|
||||
}
|
||||
if err = s.receive(delivery.Body, func() error { return delivery.Ack(false) }); err != nil {
|
||||
return fmt.Errorf("feedback save/ACK failed: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
|
||||
func TestPublishConfirmReturnAndUnknown(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, want string
|
||||
ack, returned, closed bool
|
||||
}{
|
||||
{name: "ack", want: "confirmed", ack: true}, {name: "return", want: "unroutable", ack: true, returned: true}, {name: "nack", want: "rejected"}, {name: "closed", want: "unknown", closed: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
confirms := make(chan amqp.Confirmation, 1)
|
||||
returns := make(chan amqp.Return, 1)
|
||||
if tc.returned {
|
||||
returns <- amqp.Return{ReplyCode: 312}
|
||||
}
|
||||
if tc.closed {
|
||||
close(confirms)
|
||||
} else {
|
||||
confirms <- amqp.Confirmation{Ack: tc.ack}
|
||||
}
|
||||
got, err := awaitConfirm(context.Background(), confirms, returns)
|
||||
if got != tc.want || (err == nil) != (got == "confirmed") {
|
||||
t.Fatalf("%s: %s %v", tc.name, got, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond)
|
||||
defer cancel()
|
||||
got, err := awaitConfirm(ctx, make(chan amqp.Confirmation), make(chan amqp.Return))
|
||||
if got != "unknown" || err == nil {
|
||||
t.Fatalf("timeout: %s %v", got, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"git.ipao.vip/rogee/go-sip/internal/configread"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
var errUnknownResource = errors.New("unknown resource")
|
||||
|
||||
func (s *service) replace(resource, id string, raw []byte) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
candidate := s.data
|
||||
candidate.tasks = make(map[string][]byte, len(s.data.tasks))
|
||||
for key, value := range s.data.tasks {
|
||||
candidate.tasks[key] = value
|
||||
}
|
||||
raw = bytes.Clone(raw)
|
||||
switch resource {
|
||||
case "sip":
|
||||
if err := validateResource(raw, "sip_config", candidate.dispatcherID); err != nil {
|
||||
return err
|
||||
}
|
||||
candidate.sip = raw
|
||||
case "providers":
|
||||
if err := validateResource(raw, "ai_providers", candidate.dispatcherID); err != nil {
|
||||
return err
|
||||
}
|
||||
candidate.providers = raw
|
||||
case "quota":
|
||||
if err := validateResource(raw, "tenant_quota", candidate.dispatcherID); err != nil {
|
||||
return err
|
||||
}
|
||||
var quota configread.Quota
|
||||
if err := json.Unmarshal(raw, "a); err != nil {
|
||||
return errors.New("invalid quota")
|
||||
}
|
||||
if quota.TenantID != candidate.tenantID {
|
||||
return errors.New("cannot change tenant identity")
|
||||
}
|
||||
candidate.quota = raw
|
||||
case "task":
|
||||
if _, ok := candidate.tasks[id]; !ok {
|
||||
return errUnknownResource
|
||||
}
|
||||
if err := validateResource(raw, "task_config", candidate.dispatcherID); err != nil {
|
||||
return err
|
||||
}
|
||||
var task configread.Task
|
||||
if err := json.Unmarshal(raw, &task); err != nil {
|
||||
return errors.New("invalid task")
|
||||
}
|
||||
if task.TaskID != id || task.TenantID != candidate.tenantID {
|
||||
return errors.New("cannot change task identity")
|
||||
}
|
||||
candidate.tasks[id] = raw
|
||||
default:
|
||||
return errUnknownResource
|
||||
}
|
||||
if err := candidate.validateReferences(); err != nil {
|
||||
return err
|
||||
}
|
||||
s.data = candidate
|
||||
slog.Info("Test configuration changed in memory", "resource", resource, "task_id", id, "files_modified", false)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *service) routes() {
|
||||
update := func(resource string) fiber.Handler {
|
||||
return func(c fiber.Ctx) error {
|
||||
if err := s.replace(resource, c.Params("task_id"), c.Body()); err != nil {
|
||||
status := 400
|
||||
if errors.Is(err, errUnknownResource) {
|
||||
status = 404
|
||||
}
|
||||
return c.Status(status).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
note := "memory only; send an explicit refresh command to apply"
|
||||
if resource == "providers" {
|
||||
note = "memory only; Dispatcher reads providers on startup; no service restart performed"
|
||||
}
|
||||
return c.JSON(fiber.Map{"status": "updated", "note": note})
|
||||
}
|
||||
}
|
||||
s.app.Put("/test/config/sip", update("sip"))
|
||||
s.app.Put("/test/config/providers", update("providers"))
|
||||
s.app.Put("/test/config/quota", update("quota"))
|
||||
s.app.Put("/test/config/tasks/:task_id", update("task"))
|
||||
command := func(kind string) fiber.Handler {
|
||||
return func(c fiber.Ctx) error {
|
||||
var input commandInput
|
||||
dec := json.NewDecoder(bytes.NewReader(c.Body()))
|
||||
dec.DisallowUnknownFields()
|
||||
if !json.Valid(c.Body()) || dec.Decode(&input) != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "invalid command JSON"})
|
||||
}
|
||||
// A single command has one purpose; do not silently accept fields from another command.
|
||||
if (kind == "call.execute" && (input.Action != "" || input.Reason != "" || input.TrunkID != "" || input.ChangeType != "" || input.Revision != nil)) || (kind == "task.control" && (input.Callee != "" || input.TrunkID != "" || input.ChangeType != "" || input.Revision != nil)) || (kind == "sip.config" && (input.TaskID != "" || input.Callee != "" || input.Action != "" || input.Reason != "")) {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "fields do not belong to this command"})
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(c.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
record, err := s.send(ctx, kind, input, time.Now())
|
||||
if err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
code := 200
|
||||
if record.Status != "confirmed" {
|
||||
code = 503
|
||||
}
|
||||
return c.Status(code).JSON(record)
|
||||
}
|
||||
}
|
||||
s.app.Post("/test/calls", command("call.execute"))
|
||||
s.app.Post("/test/task-controls", command("task.control"))
|
||||
s.app.Post("/test/sip-refresh", command("sip.config"))
|
||||
s.app.Get("/test/events", func(c fiber.Ctx) error {
|
||||
id := c.Query("event_id")
|
||||
if id == "" {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "event_id is required"})
|
||||
}
|
||||
feedback := s.events(id)
|
||||
requests := []requestRecord{}
|
||||
s.eventMu.Lock()
|
||||
for _, record := range s.requests {
|
||||
if record.EventID == id {
|
||||
requests = append(requests, record)
|
||||
}
|
||||
}
|
||||
s.eventMu.Unlock()
|
||||
return c.JSON(fiber.Map{"requests": requests, "feedback": feedback, "history": "current process only"})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func request(t *testing.T, s *service, method, path string, body []byte) (int, []byte) {
|
||||
t.Helper()
|
||||
req, _ := http.NewRequest(method, "http://test"+path, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := s.app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return resp.StatusCode, raw
|
||||
}
|
||||
|
||||
func TestMemoryUpdatesAndCommands(t *testing.T) {
|
||||
dir, id := fixture(t)
|
||||
data, err := loadSnapshots(dir, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := newService(data, "secret")
|
||||
var published []envelope
|
||||
s.publish = func(_ context.Context, _ string, raw []byte) (string, error) {
|
||||
var msg envelope
|
||||
json.Unmarshal(raw, &msg)
|
||||
published = append(published, msg)
|
||||
return "confirmed", nil
|
||||
}
|
||||
before, err := os.ReadFile(filepath.Join(dir, "tasks/1.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var task map[string]any
|
||||
json.Unmarshal(before, &task)
|
||||
task["task_revision"] = float64(123)
|
||||
updated, _ := json.Marshal(task)
|
||||
code, raw := request(t, s, "PUT", "/test/config/tasks/1", updated)
|
||||
if code != 200 {
|
||||
t.Fatalf("update: %d %s", code, raw)
|
||||
}
|
||||
if len(published) != 0 {
|
||||
t.Fatal("configuration update sent automatic command")
|
||||
}
|
||||
s.mu.RLock()
|
||||
got := s.data.tasks["1"]
|
||||
s.mu.RUnlock()
|
||||
if !bytes.Equal(got, updated) {
|
||||
t.Fatal("update not in memory")
|
||||
}
|
||||
code, _ = request(t, s, "PUT", "/test/config/tasks/1", []byte(`{"invalid":true}`))
|
||||
if code != 400 {
|
||||
t.Fatal(code)
|
||||
}
|
||||
task["task_id"] = "2"
|
||||
bad, _ := json.Marshal(task)
|
||||
code, _ = request(t, s, "PUT", "/test/config/tasks/1", bad)
|
||||
if code != 400 {
|
||||
t.Fatal(code)
|
||||
}
|
||||
code, _ = request(t, s, "PUT", "/test/config/tasks/2", updated)
|
||||
if code != 404 {
|
||||
t.Fatal(code)
|
||||
}
|
||||
for _, action := range []string{"start", "resume", "edit"} {
|
||||
code, raw = request(t, s, "POST", "/test/task-controls", []byte(`{"event_id":"ctrl-`+action+`","task_id":"1","action":"`+action+`","reason":"manual test"}`))
|
||||
if code != 200 {
|
||||
t.Fatalf("%s %d %s", action, code, raw)
|
||||
}
|
||||
}
|
||||
code, _ = request(t, s, "POST", "/test/task-controls", []byte(`{"event_id":"ctrl-stop","task_id":"1","action":"stop"}`))
|
||||
if code != 400 {
|
||||
t.Fatal(code)
|
||||
}
|
||||
code, raw = request(t, s, "POST", "/test/sip-refresh", []byte(`{"event_id":"sip-test","trunk_id":"trunk-mock","change_type":"enabled","revision":8}`))
|
||||
if code != 200 {
|
||||
t.Fatalf("sip %d %s", code, raw)
|
||||
}
|
||||
code, raw = request(t, s, "POST", "/test/calls", []byte(`{"event_id":"call-http","task_id":"1","callee":"123456"}`))
|
||||
if code != 200 {
|
||||
t.Fatalf("call %d %s", code, raw)
|
||||
}
|
||||
code, raw = request(t, s, "GET", "/test/events?event_id=call-http", nil)
|
||||
if code != 200 || !bytes.Contains(raw, []byte(`"confirmed"`)) {
|
||||
t.Fatalf("query: %d %s", code, raw)
|
||||
}
|
||||
code, _ = request(t, s, "POST", "/test/calls", []byte(`{"event_id":"bad","task_id":"1","callee":"123","unknown":true}`))
|
||||
if code != 400 {
|
||||
t.Fatal(code)
|
||||
}
|
||||
after, _ := os.ReadFile(filepath.Join(dir, "tasks/1.json"))
|
||||
if !bytes.Equal(before, after) {
|
||||
t.Fatal("file changed")
|
||||
}
|
||||
reloaded, err := loadSnapshots(dir, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(reloaded.tasks["1"], before) {
|
||||
t.Fatal("restart restored memory changes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentMemoryReadAndUpdate(t *testing.T) {
|
||||
dir, id := fixture(t)
|
||||
data, err := loadSnapshots(dir, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := newService(data, "secret")
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 8; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < 10; j++ {
|
||||
if err := s.replace("task", "1", data.tasks["1"]); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
s.mu.RLock()
|
||||
_, err := s.data.page("")
|
||||
s.mu.RUnlock()
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type startupConfig struct {
|
||||
Listen string `json:"listen"`
|
||||
DispatcherID string `json:"dispatcher_id"`
|
||||
DispatcherSecret string `json:"dispatcher_secret"`
|
||||
BrokerURL string `json:"broker_url"`
|
||||
Snapshots string `json:"snapshots"`
|
||||
Feedback string `json:"feedback_dir"`
|
||||
TLSCert string `json:"tls_cert,omitempty"`
|
||||
TLSKey string `json:"tls_key,omitempty"`
|
||||
}
|
||||
|
||||
func loadStartup(path string) (startupConfig, error) {
|
||||
var c startupConfig
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return c, fmt.Errorf("read startup config: %w", err)
|
||||
}
|
||||
if !info.Mode().IsRegular() || info.Mode().Perm()&0077 != 0 {
|
||||
return c, errors.New("startup config must be a private regular file")
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return c, fmt.Errorf("read startup config: %w", err)
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.DisallowUnknownFields()
|
||||
if err = dec.Decode(&c); err != nil || !json.Valid(raw) {
|
||||
return c, errors.New("invalid startup configuration")
|
||||
}
|
||||
if c.Listen == "" {
|
||||
c.Listen = "0.0.0.0:8089"
|
||||
}
|
||||
if _, _, err = net.SplitHostPort(c.Listen); err != nil {
|
||||
return c, errors.New("listen must specify host and port")
|
||||
}
|
||||
if c.DispatcherID == "" || c.DispatcherSecret == "" || strings.ContainsAny(c.DispatcherID+c.DispatcherSecret, "\r\n") || c.Snapshots == "" || c.Feedback == "" {
|
||||
return c, errors.New("dispatcher identity, secret, snapshots and feedback_dir are required")
|
||||
}
|
||||
address, err := url.Parse(c.BrokerURL)
|
||||
if err != nil || (address.Scheme != "amqp" && address.Scheme != "amqps") || address.Host == "" || !strings.HasPrefix(strings.TrimPrefix(address.Path, "/"), "saas-mock-") {
|
||||
return c, errors.New("broker_url must use a dedicated saas-mock-* vhost")
|
||||
}
|
||||
base, err := filepath.Abs(filepath.Dir(path))
|
||||
if err != nil {
|
||||
return c, err
|
||||
}
|
||||
resolve := func(p string) string {
|
||||
if p == "" {
|
||||
return ""
|
||||
}
|
||||
if !filepath.IsAbs(p) {
|
||||
p = filepath.Join(base, p)
|
||||
}
|
||||
return filepath.Clean(p)
|
||||
}
|
||||
c.Snapshots = resolve(c.Snapshots)
|
||||
c.Feedback = resolve(c.Feedback)
|
||||
c.TLSCert = resolve(c.TLSCert)
|
||||
c.TLSKey = resolve(c.TLSKey)
|
||||
// Resolve existing path components so aliases cannot place output in an input directory.
|
||||
realPath := func(p string) (string, error) {
|
||||
var suffix []string
|
||||
for {
|
||||
real, e := filepath.EvalSymlinks(p)
|
||||
if e == nil {
|
||||
for i := len(suffix) - 1; i >= 0; i-- {
|
||||
real = filepath.Join(real, suffix[i])
|
||||
}
|
||||
return real, nil
|
||||
}
|
||||
if !os.IsNotExist(e) {
|
||||
return "", e
|
||||
}
|
||||
parent := filepath.Dir(p)
|
||||
if parent == p {
|
||||
return "", e
|
||||
}
|
||||
suffix = append(suffix, filepath.Base(p))
|
||||
p = parent
|
||||
}
|
||||
}
|
||||
c.Snapshots, err = realPath(c.Snapshots)
|
||||
if err != nil {
|
||||
return c, fmt.Errorf("snapshot path: %w", err)
|
||||
}
|
||||
c.Feedback, err = realPath(c.Feedback)
|
||||
if err != nil {
|
||||
return c, fmt.Errorf("feedback path: %w", err)
|
||||
}
|
||||
overlaps := func(a, b string) bool {
|
||||
rel, e := filepath.Rel(a, b)
|
||||
return e == nil && (rel == "." || !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && rel != "..")
|
||||
}
|
||||
if overlaps(c.Snapshots, c.Feedback) || overlaps(c.Feedback, c.Snapshots) || overlaps(c.Feedback, resolve(filepath.Base(path))) {
|
||||
return c, errors.New("feedback directory must be separate from configuration inputs")
|
||||
}
|
||||
if (c.TLSCert == "") != (c.TLSKey == "") {
|
||||
return c, errors.New("tls_cert and tls_key must be provided together")
|
||||
}
|
||||
if c.TLSCert != "" && (overlaps(c.Feedback, c.TLSCert) || overlaps(c.Feedback, c.TLSKey)) {
|
||||
return c, errors.New("feedback directory overlaps TLS inputs")
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStartupPathsAndIsolation(t *testing.T) {
|
||||
dir, id := fixture(t)
|
||||
config := startupConfig{Listen: "0.0.0.0:8089", DispatcherID: id, DispatcherSecret: "secret", BrokerURL: "amqp://guest:guest@localhost/saas-mock-unit", Snapshots: ".", Feedback: "../feedback"}
|
||||
path := filepath.Join(dir, "server.json")
|
||||
raw, _ := json.Marshal(config)
|
||||
if err := os.WriteFile(path, raw, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := loadStartup(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Snapshots != dir || got.Feedback != filepath.Join(filepath.Dir(dir), "feedback") {
|
||||
t.Fatalf("paths not relative to config: %+v", got)
|
||||
}
|
||||
config.Feedback = "tasks"
|
||||
raw, _ = json.Marshal(config)
|
||||
os.WriteFile(path, raw, 0600)
|
||||
if _, err := loadStartup(path); err == nil {
|
||||
t.Fatal("accepted feedback inside config dataset")
|
||||
}
|
||||
config.Feedback = "../feedback"
|
||||
config.BrokerURL = "amqp://guest:guest@localhost/production"
|
||||
raw, _ = json.Marshal(config)
|
||||
os.WriteFile(path, raw, 0600)
|
||||
if _, err := loadStartup(path); err == nil {
|
||||
t.Fatal("accepted non-test vhost")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
# SaaS Server v0.1 本地验收
|
||||
|
||||
## 结论与范围
|
||||
|
||||
已按精简后的 [`../plan-saas-server-v0.1.md`](../plan-saas-server-v0.1.md) 完成 `cmd/saas-server`。使用见 [`../../cmd/saas-server/README.md`](../../cmd/saas-server/README.md)。
|
||||
|
||||
- 配置文件只读,五类配置接口、完整快照内存替换、单条外呼和必要通知、反馈保存/查询可用。
|
||||
- 源 `event_id` 原值保留;队列确认、未知、拒绝和最终结果分开处理,不自动重发。
|
||||
- 反馈可靠保存后 ACK,查询仅本次运行,重启不恢复历史或保存配置修改。
|
||||
- 无独立 CLI、批量、完整任务管理、发送记录持久化、历史索引或新增数据库。
|
||||
- 未修改 Dispatcher/Agent、共享业务代码、合同、现有数据库或部署配置。
|
||||
|
||||
源码提交:`61c1669`(配置服务)和 `d8f9e40`(推送、反馈、操作接口、测试及说明)。后续文档登记不改变业务源码。共享合同固定 `940d9224fd2270791bf2be8d28046f65f7a853f3`,`contracts-update` 校验通过,本次没有合同或子模块指针差异。
|
||||
|
||||
## 实际执行检查
|
||||
|
||||
| 检查 | 结果 |
|
||||
| --- | --- |
|
||||
| `go test -race ./cmd/saas-server` | 通过 |
|
||||
| `go test -cover ./cmd/saas-server` | 通过,新命令包覆盖率 **66.1%** |
|
||||
| `make check` | 最后一次完整执行通过;包括格式、合同/Proto、vet、全量 race、构建及既有隔离 Mock/MQ 检查 |
|
||||
| `make coverage` | 通过,业务单元覆盖率 **73.1%** |
|
||||
| `bash cmd/saas-server/check-integration.sh` | 两项实际隔离集成均通过,没有跳过 |
|
||||
| `make release-check-local` | 本地发布包及合同/队列检查通过,production approved=false;不是部署授权 |
|
||||
| `python3 -m unittest discover -s deploys/production -p 'test_*.py'` | **26** 项通过 |
|
||||
| 单独构建 `cmd/saas-server`,执行 `--help` | 通过 |
|
||||
| `git diff --check`、文档链接及最终变更范围 | 通过 |
|
||||
|
||||
既有 A01–A12/K01–K16 业务范围未改变:仍由现有合同和 Mock 验收门禁检查,新工具不增加旁路或覆盖其业务判断。以上不能代签其中需要真实 SaaS、线路、AI/OSS 或主机的验收项。
|
||||
|
||||
测试采用先失败再实现的方式,配置服务、启动参数、事件处理、确认状态、内存操作与私密诊断都有对应测试。发现的问题修复后重新执行检查,未用旧的成功日志替代最终结果。
|
||||
|
||||
## 新工具的端到端证据
|
||||
|
||||
`TestSaaSServerIsolatedHTTPSMQAgentMTLS`:
|
||||
|
||||
1. 新 Fiber 服务在实际 HTTPS 监听端口提供配置,现有 `configread.Client` 读取。
|
||||
2. 使用专用 vhost 的实际 RabbitMQ,不借用现有 SaaS/MQ。
|
||||
3. 现有 Dispatcher Runtime 接收测试外呼,经真实双向 TLS 调用 Mock Agent。
|
||||
4. Mock Agent 使用合成 PCM 和既有 Mock ASR,经过现有结束/结果反馈链路产生回执与最终结果;没有向反馈队列手工伪造成功消息。
|
||||
5. 两类反馈经 MQ 回到新工具,以带特殊字符的原源 ID `numberID-integration/Original:001` 关联并通过校验,原消息证据实际保存。
|
||||
6. 输入快照测试前后字节一致。
|
||||
|
||||
`TestStartupShutdownPreservesConfiguration`:实际启动与关闭新服务,五类配置中的 SIP 读取可用;退出后全部输入文件字节不变,没有自动产生反馈。
|
||||
|
||||
两项由脚本要求明确 PASS,未配置 broker 时的 SKIP 不算通过。脚本仅创建/移除自己的临时 RabbitMQ 容器,localhost 动态端口、临时存储和随机账号;不自动拉镜像。
|
||||
|
||||
## 关键回归
|
||||
|
||||
- 非法配置更新不部分生效,资源身份不可改变;并发读取/替换通过 race。
|
||||
- 重启重新加载文件,不恢复内存修改或历史在线查询。
|
||||
- 同身份相同请求只发一次,不同内容报错;确认超时/断线保留 unknown,不自动重发。
|
||||
- ACK 前实际写入、同步文件和目录;保存失败不 ACK。
|
||||
- 同源不同消息类型分别保留;重复消息统计,冲突不覆盖首条。
|
||||
- 校验异常明确显示;日志和查询不返回完整原正文或凭据。
|
||||
- 使用现有合同诊断保留字段位置,不输出私密值;移除了重复的结果枚举,合法 `answered` 不再被遗漏。
|
||||
|
||||
集成调试中发现的失败均在测试设置内:JSON map 复用残留任务字段、示例 SIP 缺执行参数、静音 PCM 无语音、反馈目录权限及结果测试数据不匹配。最终改用完整合成测试配置、有语音的合成 PCM 和私有输出目录;没有因此修改现有业务、降低校验或假造执行结果。
|
||||
|
||||
## 未执行与授权边界
|
||||
|
||||
没有合入 main、推送、部署、启动/重启现有业务服务、加载真实线路、拨号、抓包、访问真实 AI/OSS、清队列或删除现存业务数据。检查只覆盖本地隔离测试,不代表真实外呼或生产可用。
|
||||
@@ -0,0 +1,112 @@
|
||||
# SaaS Server v0.1 最小开发计划
|
||||
|
||||
## 1. 目标与边界
|
||||
|
||||
需求及精简范围已确认,最小实现和本地验收已完成。使用见 [`../cmd/saas-server/README.md`](../cmd/saas-server/README.md),检查记录见 [`evidence/saas-server-v0.1-local.md`](evidence/saas-server-v0.1-local.md)。本地通过不代表已部署或真实外呼验收。
|
||||
|
||||
使用 `Fiber/v3` 在 `cmd/saas-server` 实现独立测试服务,通过 HTTP 接口和 `curl` 替代 SaaS 浏览器/CDP 操作:
|
||||
|
||||
**指定文件加载配置 → Dispatcher 按现行接口读取 → 手动推送一条外呼 → 查看并校验反馈。**
|
||||
|
||||
硬性边界:
|
||||
|
||||
- 不修改 Dispatcher/Agent、共享业务代码、业务合同、数据库和现有部署配置;不为测试增加旁路、放宽校验或改变执行规则。
|
||||
- 只调用可直接复用的公共类型/函数,不提取或重构旧 Mock、Dispatcher/Agent 的代码。不能直接复用时,在新命令内补最小 SaaS 侧逻辑,不另造业务规则。
|
||||
- 不直接调用 Agent/Asterisk,不自行选线、判断业务额度、释放占用或编造结果。外呼及配置生效由现行 Dispatcher/Agent 决定。
|
||||
- 只使用明确隔离的测试 MQ vhost,不与现有 SaaS 争抢反馈、不改动现有服务。开发和本地验收不授权部署、真实拨号或 AI/OSS 调用。
|
||||
- 实现时新增源码、测试及说明限于 `cmd/saas-server/`;只允许为 Fiber 更新 `go.mod/go.sum`,并同步必要的项目文档。遇到需要改变现有业务才能接通的问题,停止并单独说明,不能顺手修改。
|
||||
|
||||
唯一业务依据为 [`../contracts/schema/`](../contracts/schema/README.md),不复制 Schema 或拓扑规范。调研/修改合同前遵循 [`contracts.md`](contracts.md);本功能不计划变更合同。
|
||||
|
||||
## 2. 只保留这些功能
|
||||
|
||||
### 2.1 从文件读取配置
|
||||
|
||||
启动形式:
|
||||
|
||||
```sh
|
||||
go run ./cmd/saas-server --config /absolute/path/server.json
|
||||
```
|
||||
|
||||
启动文件指定监听地址、Dispatcher 身份、MQ 连接参数、业务快照位置和独立反馈目录。监听 `0.0.0.0`,端口可配置;相对路径以启动文件目录为基准。
|
||||
|
||||
沿用现有快照组织:`sip.json`、`providers.json`、`quota.json`、`tasks/*.json`。任务列表从已加载任务生成,不再维护第二份列表。所有文件一次性读取并校验,缺失或无效即明确报错,不使用默认业务参数或旧配置兜底。
|
||||
|
||||
- 配置文件只读,不改写、补字段、格式化、监听变化或退出保存;不扫描真实私密配置。
|
||||
- 配置接口提供现行五类资源:SIP、任务详情、任务列表、租户额度和 AI providers;路径、响应及调用标识遵循现行合同。
|
||||
- 使用现有 `internal/contract`、`internal/configread` 可用的类型和校验;AMQP 使用已有依赖。参考 `deploys/test/saas-mock/` 的文件加载与拓扑行为,但不修改该工具。
|
||||
|
||||
### 2.2 仅在内存修改配置
|
||||
|
||||
按资源提交完整快照,校验成功后一次性替换内存;失败保留原值,并保证并发读取不会读到半份配置。任务列表与任务详情保持一致。
|
||||
|
||||
只修改已加载资源,不增删任务,不变更 Dispatcher/租户身份。重启重新读取原文件,临时修改消失。
|
||||
|
||||
修改配置不等于 Dispatcher 已采用:
|
||||
|
||||
- 任务及额度:通过显式 start/resume/edit 指令,按现行规则重新读取;不覆盖 Dispatcher 已有的暂停/停止状态。
|
||||
- SIP:通过显式 `sip.config` 通知触发现行加载流程。
|
||||
- AI providers:Dispatcher 只在启动读取,修改后需要由操作者另行安排重启;本工具不重启任何服务。
|
||||
|
||||
这些接口只发送标准指令,不在工具内另建任务状态机,不改变已发出通话的快照。
|
||||
|
||||
### 2.3 手动发送一条外呼
|
||||
|
||||
输入源 `event_id`、已加载的 `task_id` 和原始 `callee`;其余归属来自配置。按合同构造并校验消息,再向对应测试队列发布,不开放任意路由/原始消息投递。
|
||||
|
||||
- 源 `event_id` 原值保留,不哈希、清洗或在重试时另造 ID。
|
||||
- 本次进程内,相同消息身份重复请求不重复发布;内容不同则拒绝。只在内存记录,不保存发送日志或实现重启恢复。
|
||||
- 使用 persistent、mandatory、publisher confirm;未 return 且确认成功才报告队列已接收。断线或确认超时明确报告结果未知,不自动重发。
|
||||
- 重启后不承诺跨进程请求去重,操作者不能把重启当重试或换 ID 补拨;Dispatcher 原有重复消息处理规则不变。
|
||||
- 启动和退出不自动发指令。不做批量、循环、定时、重拨或换线。
|
||||
|
||||
测试拓扑由本工具在专用 vhost 中按现行规范预建;不 purge、删除队列或重置数据。MQ 故障明确报错并停止相关操作,不实现自动重连重发系统。
|
||||
|
||||
### 2.4 接收、查询和校验反馈
|
||||
|
||||
消费专用测试反馈队列,提供本次进程收到的反馈查询。仅校验合同、源身份、请求归属及反馈实际具备的关联字段,不复制 Dispatcher 的业务判断。
|
||||
|
||||
- 使用 `(dispatcher_id, event_type, event_id)` 区分派发回执、控制回执和最终结果;重复/冲突消息不覆盖原记录,校验失败明确显示原因。
|
||||
- 原消息写入独立反馈目录并确认保存成功后才 ACK;写入失败报错并停止消费,不 ACK、不丢弃。无效消息也保存并标明错误,避免无限重投。
|
||||
- 反馈文件仅用于人工检查,不扫描恢复历史、不建立持久索引。重启后在线查询从空集合开始,原文件保留且不覆盖;历史请求关联不在本次查询范围。
|
||||
- 队列已接收、已派发、被拒绝、最终结果分别展示。等待超时只是尚未收到,不能推断通话失败、接通或 AI 成功;未发起的拒绝不强求通话最终结果。
|
||||
- 反馈目录与配置来源分离,不能覆盖配置文件。日志与查询返回必要摘要,不输出凭据、完整对话、音频、签名 URL 或原始 SIP;原消息仅保存在受控文件。
|
||||
|
||||
## 3. 最小接口
|
||||
|
||||
以下为测试操作接口,不修改 SaaS↔Dispatcher 合同:
|
||||
|
||||
| 接口 | 用途 |
|
||||
| --- | --- |
|
||||
| `PUT /test/config/sip` | 替换内存 SIP 完整快照 |
|
||||
| `PUT /test/config/providers` | 替换内存 AI providers 完整快照 |
|
||||
| `PUT /test/config/quota` | 替换内存额度完整快照 |
|
||||
| `PUT /test/config/tasks/:task_id` | 替换已加载任务完整快照 |
|
||||
| `POST /test/calls` | 手动发送一条外呼 |
|
||||
| `POST /test/task-controls` | 仅发送 start/resume/edit,满足测试启动及配置读取需要 |
|
||||
| `POST /test/sip-refresh` | 显式发送 SIP 配置更新通知 |
|
||||
| `GET /test/events?event_id=...` | 查询本次运行收到的同源反馈和校验结论 |
|
||||
|
||||
源 ID 通过查询参数传递并正确 URL 编码,不改变原值。只提供 `curl` 示例,不开发独立 CLI、页面、实时推送或自动等待脚本。
|
||||
|
||||
## 4. 开发顺序与验证
|
||||
|
||||
单个命令包内按启动、配置、HTTP、MQ/反馈分文件即可,不新增服务层、仓储接口、插件或数据库。新增依赖只需 Fiber/v3;异步处理和内存状态不能引用已被 Fiber 回收的请求缓冲。
|
||||
|
||||
按 TDD 分三步实现:
|
||||
|
||||
1. **配置服务**:只读加载和五类配置接口;用现有配置客户端验证读取,覆盖错误文件、归属和任务列表一致性。
|
||||
2. **外呼与反馈**:单条发布、简单反馈文件、当前运行查询;覆盖 confirm/return、结果未知、重复请求、身份关联、非法反馈及保存失败不 ACK。
|
||||
3. **内存修改与使用说明**:完整快照替换、必要通知及 `curl` 示例;覆盖更新失败不改变当前值、重启恢复原配置、反馈查询不恢复历史。
|
||||
|
||||
完成条件:
|
||||
|
||||
- 用未修改的 Dispatcher/Agent 在隔离 Mock 模式和真实 RabbitMQ 中完成「读取配置 → 推送 → 收到回执/结果」测试,不仅手工伪造反馈。
|
||||
- 测试前后比对所有输入配置文件,启动、修改、失败和退出均不改变文件;并发操作通过 race 检查。
|
||||
- 格式、合同/Proto、`go vet ./...`、`go test -race ./...`、构建及项目既有检查通过,业务单元覆盖率 ≥65%;现有集成检查不因精简而跳过。
|
||||
- 检查最终变更范围:Dispatcher/Agent、共享业务代码、合同和现有部署配置无改动。
|
||||
- 本地检查与真实服务验收分别记录;未部署、未拨号,不声称真实通话已验证。
|
||||
|
||||
## 5. 明确不做
|
||||
|
||||
发送记录持久化、重启恢复、历史在线查询、持久索引、完整任务管理(pause/stop 等)、任务增删、独立 CLI、管理页面、批量/压测、自动重试、配置保存/导出、服务重启、业务代码重构、数据库、多租户/多 Dispatcher、生产部署。
|
||||
@@ -11,6 +11,7 @@ require (
|
||||
github.com/emiago/sipgo v1.6.0
|
||||
github.com/go-audio/audio v1.0.0
|
||||
github.com/go-audio/wav v1.1.0
|
||||
github.com/gofiber/fiber/v3 v3.6.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/hujm2023/go-mp3/v2 v2.0.1
|
||||
@@ -23,8 +24,8 @@ require (
|
||||
github.com/sirupsen/logrus v1.9.4
|
||||
github.com/spf13/cobra v1.10.1
|
||||
github.com/zaf/g711 v1.4.0
|
||||
golang.org/x/net v0.58.0
|
||||
golang.org/x/sys v0.47.0
|
||||
golang.org/x/net v0.60.0
|
||||
golang.org/x/sys v0.49.0
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa
|
||||
google.golang.org/grpc v1.83.2
|
||||
google.golang.org/protobuf v1.36.12
|
||||
@@ -40,14 +41,19 @@ require (
|
||||
github.com/gobwas/httphead v0.1.0 // indirect
|
||||
github.com/gobwas/pool v0.2.1 // indirect
|
||||
github.com/gobwas/ws v1.3.2 // indirect
|
||||
github.com/gofiber/schema v1.8.8 // indirect
|
||||
github.com/gofiber/utils/v2 v2.6.1 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/inconshreveable/log15 v0.0.0-20201112154412-8562bdadbbac // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/klauspost/compress v1.20.1 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.8 // indirect
|
||||
github.com/mattn/go-colorable v0.1.16 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/molecule-man/go-brrr v1.2.0 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/oklog/ulid v1.3.1 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/pion/randutil v0.1.0 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
@@ -57,11 +63,15 @@ require (
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tidwall/sjson v1.2.5 // indirect
|
||||
github.com/tinylib/msgp v1.6.5 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.16 // indirect
|
||||
github.com/tklauser/numcpus v0.11.0 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasthttp v1.75.0 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
golang.org/x/crypto v0.57.0 // indirect
|
||||
golang.org/x/sync v0.23.0 // indirect
|
||||
golang.org/x/text v0.42.0 // indirect
|
||||
golang.org/x/time v0.4.0 // indirect
|
||||
modernc.org/libc v1.75.7 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
|
||||
@@ -13,7 +13,6 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
|
||||
github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 h1:uX1JmpONuD549D73r6cgnxyUu18Zb7yHAy5AYU0Pm4Q=
|
||||
github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467/go.mod h1:uzvlm1mxhHkdfqitSA92i7Se+S9ksOn3a3qmv/kyOCw=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
@@ -23,6 +22,8 @@ github.com/ebitengine/purego v0.10.2 h1:W809HbnvzAxgdm+aOvlSekrM16wGCdT/e76+9tS7
|
||||
github.com/ebitengine/purego v0.10.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/emiago/sipgo v1.6.0 h1:6EuOP7c6f0VRatKYTPEYNezt4hslBEsaCzZZOhT2n3s=
|
||||
github.com/emiago/sipgo v1.6.0/go.mod h1:DuwAxBZhKMqIzQFPGZb1MVAGU6Wuxj64oTOhd5dx/FY=
|
||||
github.com/fxamacker/cbor/v2 v2.9.6 h1:NHZPxuUjJXQjX9itA785y/5NKSlI4HQ+S7noZsmI7hs=
|
||||
github.com/fxamacker/cbor/v2 v2.9.6/go.mod h1:4EaZp6ClVunETjQ4GHmGNzysDK91Lr+r6Yz6tJqzVCw=
|
||||
github.com/go-audio/audio v1.0.0 h1:zS9vebldgbQqktK4H0lUqWrG8P0NxCJVqcj7ZpNnwd4=
|
||||
github.com/go-audio/audio v1.0.0/go.mod h1:6uAu0+H2lHkwdGsAY+j2wHPNPpPoeg5AaEFh9FlA+Zs=
|
||||
github.com/go-audio/riff v1.0.0 h1:d8iCGbDvox9BfLagY94fBynxSPHO80LmZCaOsmKxokA=
|
||||
@@ -43,6 +44,12 @@ github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
||||
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||
github.com/gobwas/ws v1.3.2 h1:zlnbNHxumkRvfPWgfXu8RBwyNR1x8wh9cf5PTOCqs9Q=
|
||||
github.com/gobwas/ws v1.3.2/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
|
||||
github.com/gofiber/fiber/v3 v3.6.0 h1:dmu6zB1SSXINf1flhgMRVaIOQ08vaZaN8JYVSBJI3fI=
|
||||
github.com/gofiber/fiber/v3 v3.6.0/go.mod h1:ce/ZWPbiMom+nAEkPv4+NPBEx8b9+MH3g2VV6nvEpgo=
|
||||
github.com/gofiber/schema v1.8.8 h1:md5t65+Z9LttOuj7FdMcQ63D2+6GniT2ganLXwvTJM4=
|
||||
github.com/gofiber/schema v1.8.8/go.mod h1:Hzf0L/fTPP1IwOAwKipKflm9C2722aebnSKJCMOgb1I=
|
||||
github.com/gofiber/utils/v2 v2.6.1 h1:x3zsS/oUQz0ia2Zx8XXtG/7naR5a+/95FRO8mwQ5QQ4=
|
||||
github.com/gofiber/utils/v2 v2.6.1/go.mod h1:tc9KqdRAQZRD/u0mN2d1ZHROGheSZv+0FcC9cg5OEdw=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
@@ -66,16 +73,21 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
|
||||
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
||||
github.com/mattn/go-colorable v0.1.8 h1:c1ghPdyEDarC70ftn0y+A/Ee++9zz8ljHG1b13eJ0s8=
|
||||
github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
||||
github.com/mattn/go-colorable v0.1.16 h1:+2YjeAe7xP1OQbZW1jERPWWCDLlNqFNCvjiqGiAt84A=
|
||||
github.com/mattn/go-colorable v0.1.16/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/molecule-man/go-brrr v1.2.0 h1:dOJU45BC3Gc2WXoxAW0rgCAgVuu2gruAOGncfieA0Jw=
|
||||
github.com/molecule-man/go-brrr v1.2.0/go.mod h1:7ybW6/7gA3oKY45jOfVNjSJDtrr6ea4tzbsTkjmQDC4=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
@@ -83,13 +95,14 @@ github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4=
|
||||
github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
|
||||
github.com/openai/openai-go/v3 v3.62.0 h1:P3G1Ip9eOwCJxg2q1lPBFg7iuyDEJDuoqQinzZDaxJM=
|
||||
github.com/openai/openai-go/v3 v3.62.0/go.mod h1:dE39tezpSvL+SHpkZQm7XhSc8BOLDzu8CEh1mHc9MzU=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
|
||||
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
|
||||
github.com/pion/rtp v1.10.5 h1:ip0HhO/wYZqQ4bKS+R99KnZh/GRCmIT0jDXikub7vlE=
|
||||
github.com/pion/rtp v1.10.5/go.mod h1:Au8fc6cEByy8RLTwKTQTEeQqDB/SJDxwL4mZuxYA5Pk=
|
||||
github.com/pion/rtp/v2 v2.0.0 h1:8s4xPETm04IugKZaykpJnJ8LAGDLOQpsIpRXMzgM6Ow=
|
||||
github.com/pion/rtp/v2 v2.0.0/go.mod h1:Vj+rrFbJCT3yxqE/VSwaOo9DQ2pMKGPxuE7hplGOlOs=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
@@ -102,6 +115,8 @@ github.com/rotisserie/eris v0.4.1/go.mod h1:lODN/gtqebxPHRbCcWeCYOE350FC2M3V/oAP
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
||||
github.com/shamaton/msgpack/v3 v3.2.3 h1:0r0aZtrXDFOPpwWJTEOpFq+Ih/JmMLm0pqLN5mSilrw=
|
||||
github.com/shamaton/msgpack/v3 v3.2.3/go.mod h1:sgBYvEiyz8JR1NC3yGRoPVME9xXovpnh3l/plW1nfRo=
|
||||
github.com/shirou/gopsutil/v4 v4.26.8 h1:YQMTF/1J50B5+Y0vlo1eDRf5DoR7Gk69hY+8wjYkQeo=
|
||||
github.com/shirou/gopsutil/v4 v4.26.8/go.mod h1:5O9FjBiXoTDFatIWjZZosqj4pV0DRtLx598xGbBehzM=
|
||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||
@@ -114,8 +129,8 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
|
||||
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU=
|
||||
github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc=
|
||||
@@ -126,10 +141,18 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||
github.com/tinylib/msgp v1.6.5 h1:iAH6XTP7BpzErjBZlujJQXxA+GmRi2YWs6M4KdLtNbE=
|
||||
github.com/tinylib/msgp v1.6.5/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
|
||||
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
|
||||
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
|
||||
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.75.0 h1:W3xrvNJHG60j2ZCjxpP8kkB+oPG8kh9/g1n4+3xUwNA=
|
||||
github.com/valyala/fasthttp v1.75.0/go.mod h1:JiVkPRZU124PuPsreV/s9dZRgG2lRK7wNEikdQ0Rk4s=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
@@ -151,29 +174,33 @@ go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/
|
||||
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
|
||||
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU=
|
||||
golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -186,16 +213,16 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
||||
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||
golang.org/x/time v0.4.0 h1:Z81tqI5ddIoXDPvVQ7/7CC9TnLM7ubaFG2qXYd5BbYY=
|
||||
golang.org/x/time v0.4.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
@@ -203,8 +230,8 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
|
||||
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
@@ -221,7 +248,6 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8=
|
||||
modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
|
||||
Reference in New Issue
Block a user