feat(nonprod): bind native Agent to signed calls and live capture

This commit is contained in:
2026-10-04 15:08:02 +08:00
parent 5f052c1db0
commit 0e9e6411be
22 changed files with 514 additions and 45 deletions
+3 -3
View File
@@ -80,8 +80,8 @@
- P01–P08 及 K01–K16 已完成**项目内隔离 Mock** 核验;本轮把分散的人类可读契约、文档与第三方对接合为唯一当前规范,不重审已确认规则。若未来另获启动开发/审查子 Agent 授权,必须按使用者指定的 `gpt-5.6-luna`、`max` 思考和 `fast: true` 逐项核验并显式配置,不静默换模型、降档或关闭 fast。
- 唯一现行 SaaS↔Dispatcher 业务规范是 [`docs/thirds/saas-dispatcher.md`](docs/thirds/saas-dispatcher.md);当前项目内 Schema、拓扑、正反例及来源/hash 在 [`contracts/local/`](contracts/local/);内部 Agent RPC 在 [`proto/agent/agent.proto`](proto/agent/agent.proto)。本地验收与外部缺口见 [`docs/evidence/saas-dispatcher-p08-acceptance.md`](docs/evidence/saas-dispatcher-p08-acceptance.md)。Markdown 不代替机器合同或外部签收,也不另外维护一份平行字段定义。
- 已由当前合同来源清单固定哈希的历史提案与计划保留**原字节**于 [`docs/archive/sources/`](docs/archive/sources/README.md),使用者原有未提交的两份旧对接文档也按原字节归档;旧上游 v1 在 [`docs/archive/upstream/`](docs/archive/upstream/README.md) 可离线校验,但不嵌入运行合同。旧 F/W 工作包、旧 MQ-only 合同及归档不作为当前运行入口。固定 MQ `v1`、HTTP `/internal/v1/dispatcher/...` 和业务 revision 是现行通信规则,不是自有实现代次;不得为历史路径新建兼容或回退。
- 当前业务范围仍仅**单节点、单 Dispatcher、单 Agent、单 Cell、单租户和隔离 Mock**。根命令只接受显式 `agent`/`dispatcher`;mixed/real 启动即拒绝。另有严格隔离的 `--mode sip-only`:只允许 Dispatcher 读完整 SIP、持久接纳归属 `sip.config`、经已激活双向 TLS Agent 会话将完整快照应用到原生 Asterisk,并从运行态核对版本;不发现任务、不启动业务呼叫、不开放准入、不处理其他业务控制。测试机已凭使用者批准,将三条历史登记地址以**测试快照显式声明的** UDP/IP 鉴权、无需 REGISTER 配置写入并核对 Asterisk 运行态;供应商尚未确认这些实际线路是否满足上述参数,无拨号,不证明线路可用或生产签收。没有真实 SaaS、management、真实通话或生产签收;真实百炼 LLM 与 OSS 已各做一次**不拨号、独立的最小连接/写入诊断**(见 [`docs/evidence/real-ai-oss-one-shot-20261003.md`](docs/evidence/real-ai-oss-one-shot-20261003.md)),这不是获批任务的 ASR/LLM/TTS、录音和上传全链路签收。生产发布包仍为 `production_approval=false`。任何本机 Mock 或 SIP-only 核验均不授权真实呼叫。
- 使用者批准独立的测试专用 `deploys/test/saas-mock/` 仅模拟缺失的 SaaS:从 0600 的静态快照提供五类正式 HTTP 配置,在专用 RabbitMQ vhost 中由模拟 SaaS 预建现行拓扑;不在 Dispatcher 内注入快照,不发布外呼消息,不替代 Agent/Asterisk/AI/OSS,也不解除当前 Mock-only 呼叫屏障。只有完成真实链路、线路鉴权和拨号前诊断,才可另行安排逐次试拨。此模拟不构成真实 SaaS 签收。
- 当前业务范围仍仅**单节点、单 Dispatcher、单 Agent、单 Cell、单租户**。根命令只接受显式 `agent`/`dispatcher`;`mixed` 和裸 `real` 仍拒绝;隔离 `mock`、只读 `sip-only` 和需完整非生产证据/正式获批指令的 `nonprod-real` 为彼此独立的入口。新增非生产真实入口尚未在测试机完成全链路核验或拨号;不得以编译/本机 Mock 通过宣称可用。另有严格隔离的 `--mode sip-only`:只允许 Dispatcher 读完整 SIP、持久接纳归属 `sip.config`、经已激活双向 TLS Agent 会话将完整快照应用到原生 Asterisk,并从运行态核对版本;不发现任务、不启动业务呼叫、不开放准入、不处理其他业务控制。测试机已凭使用者批准,将三条历史登记地址以**测试快照显式声明的** UDP/IP 鉴权、无需 REGISTER 配置写入并核对 Asterisk 运行态;供应商尚未确认这些实际线路是否满足上述参数,无拨号,不证明线路可用或生产签收。没有真实 SaaS、management、真实通话或生产签收;真实百炼 LLM 与 OSS 已各做一次**不拨号、独立的最小连接/写入诊断**(见 [`docs/evidence/real-ai-oss-one-shot-20261003.md`](docs/evidence/real-ai-oss-one-shot-20261003.md)),这不是获批任务的 ASR/LLM/TTS、录音和上传全链路签收。生产发布包仍为 `production_approval=false`。任何本机 Mock 或 SIP-only 核验均不授权真实呼叫。
- 使用者批准独立的测试专用 `deploys/test/saas-mock/` 仅模拟缺失的 SaaS:从 0600 的静态快照提供五类正式 HTTP 配置,在专用 RabbitMQ vhost 中由模拟 SaaS 预建现行拓扑;不在 Dispatcher 内注入快照;目前不发布外呼消息,也不替代 Agent/Asterisk/AI/OSS。测试用正式入口必须同时具备只读配置、专用 MQ、真实 Agent/Asterisk/AI/录音/OSS、逐通确认和拨号前活跃抓包证据,缺一项不得试拨。此模拟不构成真实 SaaS 签收。
- 开发按 TDD 分批,小步提交;不得覆盖使用者现存修改/未跟踪文件,不自动清理、迁移或覆盖任何现存 SQLite、spool、outbox 和 Agent 恢复文件。旧 `.executions` 及恢复根目录中旧 `.uploads`、`.upload-locks`、逐执行 `state.json` 的发现须只读失败关闭,现存未交付事实由使用者确认处置。真实云账号、EIP、线路、拨号、生产部署和共享数据操作分别需要明确授权。
## SaaS、Dispatcher 与 Agent 的现行边界
@@ -105,7 +105,7 @@
| 中鼎 | `60.171.24.90:5060` | `mbkq` | 无 |
| 百应 | `160.202.254.79:5060` | `KQ91526` | `mka755` |
- 全线路**只允许原始号码** `15003164745`、`15830461047`,但白名单和上述登记绝非真实拨号授权。已授权的真实/旧路径仅在 Asia/Shanghai 每日 `09:00`(含)至 `20:00`(不含)放行,每条 trunk 对每个原始号码每天最多 3 次,窗口外直接拒绝,不等候/自动延迟/自动重试/静默换线。每次真实试拨仍须使用者明确安排并受现行程序 Mock-only 启动屏障约束;不能拿 Mock 时段测试宣称 real 放行。
- 全线路**只允许原始号码** `15003164745`、`15830461047`,但白名单和上述登记绝非真实拨号授权。已授权的真实/旧路径仅在 Asia/Shanghai 每日 `09:00`(含)至 `20:00`(不含)放行,每条 trunk 对每个原始号码每天最多 3 次,窗口外直接拒绝,不等候/自动延迟/自动重试/静默换线。每次真实试拨仍须使用者明确安排,并由专用主机脚本在拨号前启用抓包和 PJSIP logger、签发与该通 `event_id`/trunk/原始号码绑定的短时有效活跃抓包凭证;Agent 拒绝缺失/失效/不匹配的凭证。不能拿 Mock 时段测试宣称真实放行。
- 任务按周一至周日多个时段与指定排除日期配置,线路只有每周允许时段(**没有线路排除日期**),Asia/Shanghai 左闭右开、跨日拆分;缺失或不确定 fail-closed,不自动重拨。由 Dispatcher 在持久接纳与实际发出指令前判定,并取任务/获批 AI 较小通话时限;Agent 仅校验会话和签发期限,不重算外呼策略。本地策略 Mock 与固定真实门禁必须分别报告。
- `BD` 等主叫原值不得清洗或当作 Digest 用户名;业务原始被叫号码不变,仅被选定数企 trunk 按规则构造 `7089<原号>`(其它线路使用自己的前缀),不重复加前缀。三条 trunk 独立,不能把同地址伪造为备用线路或换线重拨;服务商反馈 PCMA,对应 Asterisk `allow=alaw`,传输/注册/鉴权/并发仍待真实签收。不以 sipgo/diago 另造 Asterisk 替代架构。
+18 -11
View File
@@ -22,7 +22,7 @@ import (
func newAgentCommand() *cobra.Command {
var mode string
command := &cobra.Command{
Use: "agent", Short: "Run the isolated Agent", Args: cobra.NoArgs,
Use: "agent", Short: "Run the bound Agent", Args: cobra.NoArgs,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, _ []string) error {
settings, err := config.LoadAgentEnvironment(mode)
@@ -50,14 +50,16 @@ func newAgentCommand() *cobra.Command {
handler, err = newSIPOnlyAgentServer(settings)
} else {
var scenario approvedMockScenario
scenario, err = loadApprovedMockScenario(settings.MockScenarioFile)
if err != nil {
return err
}
var applied map[string]int64
applied, err = loadMockAppliedSIP(settings.MockAppliedSIPFile)
if err != nil {
return err
if mode == "mock" {
scenario, err = loadApprovedMockScenario(settings.MockScenarioFile)
if err != nil {
return err
}
applied, err = loadMockAppliedSIP(settings.MockAppliedSIPFile)
if err != nil {
return err
}
}
var clientTLS *tls.Config
clientTLS, err = rpc.NewClientTLSConfig(ca, cert, key, settings.DispatcherServerName)
@@ -69,7 +71,12 @@ func newAgentCommand() *cobra.Command {
return errors.New("Agent cannot create pinned Dispatcher connection")
}
defer connection.Close()
handler, err = newAgentServer(cmd.Context(), settings, scenario, applied, agentpb.NewAgentControlServiceClient(connection))
client := agentpb.NewAgentControlServiceClient(connection)
if mode == "mock" {
handler, err = newAgentServer(cmd.Context(), settings, scenario, applied, client)
} else {
handler, err = newRealAgentServer(cmd.Context(), settings, client)
}
}
if err != nil {
return err
@@ -100,12 +107,12 @@ func newAgentCommand() *cobra.Command {
return nil
},
}
command.Flags().StringVar(&mode, "mode", "mock", "isolated Mock or SIP-only mode")
command.Flags().StringVar(&mode, "mode", "mock", "isolated Mock, SIP-only or explicit nonprod-real mode")
return command
}
// This is an isolated deployment fixture, not SaaS SIP configuration or proof
// that Asterisk actually loaded the revisions. Real/mixed startup is rejected.
// that Asterisk actually loaded the revisions. Nonprod-real cannot load it.
func loadMockAppliedSIP(path string) (map[string]int64, error) {
if strings.TrimSpace(path) == "" {
return nil, errors.New("AGENT_MOCK_APPLIED_SIP_FILE is required")
+89
View File
@@ -0,0 +1,89 @@
package main
import (
"context"
"errors"
"log"
"os"
"path/filepath"
"reflect"
"strings"
"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/asterisk"
"git.ipao.vip/rogee/go-sip/internal/config"
"git.ipao.vip/rogee/go-sip/internal/rpc"
"git.ipao.vip/rogee/go-sip/internal/tenant"
"github.com/google/uuid"
)
// newRealAgentServer only exposes the signed Dispatcher execution path. There
// is no scenario, synthetic recording, local dial entry, or Mock upload host.
func newRealAgentServer(ctx context.Context, settings config.AgentEnvironment, dispatcher agentpb.AgentControlServiceClient) (*rpc.Server, error) {
if ctx == nil || ctx.Err() != nil || settings.Mode != "nonprod-real" || settings.AgentID == "" || settings.CellID == "" ||
settings.SessionPath == "" || settings.RecoveryRoot == "" || len(settings.PeerFingerprints) == 0 ||
settings.OSSAllowedHost == "" || dispatcher == nil || tenant.ValidateDispatcherID(settings.DispatcherID) != nil {
return nil, errors.New("real Agent requires approved nonproduction identity, pinned Dispatcher and private recovery")
}
value := reflect.ValueOf(dispatcher)
if value.Kind() == reflect.Pointer && value.IsNil() {
return nil, errors.New("real Agent requires a live pinned Dispatcher transport")
}
for _, path := range []string{settings.AsteriskConfigDir, settings.AsteriskBin, settings.AsteriskLibraryDir, settings.EvidenceRoot, settings.RecoveryRoot} {
if !filepath.IsAbs(path) {
return nil, errors.New("real Agent paths must be absolute")
}
}
if strings.ContainsAny(settings.OSSAllowedHost, "/@ ") || settings.MockScenarioFile != "" || settings.MockAppliedSIPFile != "" {
return nil, errors.New("real Agent must not use Mock assets or an invalid OSS host")
}
root, err := os.Stat(settings.RecoveryRoot)
if err != nil || !root.IsDir() || root.Mode().Perm() != 0700 {
return nil, errors.New("real Agent recovery directory must exist with mode 0700")
}
if err := rejectLegacyAgentSpool(settings.RecoveryRoot); err != nil {
return nil, err
}
if _, err := os.Lstat(filepath.Join(settings.RecoveryRoot, ".executions")); err == nil {
return nil, errors.New("legacy Agent execution state requires operator disposition")
} else if !errors.Is(err, os.ErrNotExist) {
return nil, err
}
loader := asterisk.Loader{ConfigDir: settings.AsteriskConfigDir, Asterisk: settings.AsteriskBin, LibraryDir: settings.AsteriskLibraryDir}
var handler *rpc.Server
worker := &rpc.ApprovedCallWorker{
Lifecycle: ctx, Calls: &agent.TaskCalls{},
Prepare: func(execution rpc.ApprovedExecution) (func(context.Context) error, error) {
if handler == nil {
return nil, errors.New("Agent session is unavailable")
}
delivery := &agent.RecordingDelivery{
Call: agent.RecordingClient{
Client: dispatcher, DispatcherID: execution.DispatcherID, TenantID: execution.TenantID,
SourceEventID: execution.SourceEventID, Session: func(context.Context) (*agentpb.RequestMeta, error) { return handler.ActiveSessionMeta() },
},
Recovery: &agent.RecordingRecovery{
Root: settings.RecoveryRoot,
Upload: agent.UploadClient{AllowedHosts: map[string]struct{}{strings.ToLower(settings.OSSAllowedHost): {}}},
},
}
return (&rpc.ApprovedRecordedRealCall{
Loader: loader, MediaPayloadType: 118, EvidenceRoot: settings.EvidenceRoot,
MaxWAVBytes: 64 << 20, ReportTimeout: 15 * time.Minute, Delivery: delivery,
}).Prepare(execution)
},
OnFailure: func(execution rpc.ApprovedExecution, cause error) error {
log.Printf("Agent real call requires inspection: event_id=%q task_id=%q cause_type=%T", execution.SourceEventID, execution.TaskID, cause)
return nil
},
}
handler, err = rpc.NewApprovedAgentServer(rpc.ServerOptions{
Mode: settings.Mode, StatePath: settings.SessionPath, ApprovedDispatcherID: settings.DispatcherID,
Status: &agentpb.AgentStatus{AgentId: settings.AgentID, CellId: settings.CellID, BootId: uuid.NewString(), ProtocolVersion: "agent.v1"},
RequirePeerCertificate: true, PeerCertificateFingerprints: settings.PeerFingerprints,
LoadedSIP: loader.LoadedSIP, ApplySIP: loader.Apply,
}, worker)
return handler, err
}
+116
View File
@@ -0,0 +1,116 @@
package main
import (
"context"
"io"
"net"
"os"
"path/filepath"
"testing"
"time"
agentpb "git.ipao.vip/rogee/go-sip/gen/agent"
"git.ipao.vip/rogee/go-sip/internal/rpc"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
)
func TestRealAgentCommandStartsPinnedServerWithoutMockFixturesOrDial(t *testing.T) {
ca, agentCert, agentKey, dispatcherCert, dispatcherKey, dispatcherLeaf := localCommandCertificates(t)
settings, _ := currentAgentSetupFixture(t)
for path, data := range map[string][]byte{settings.CAFile: ca, settings.CertFile: agentCert, settings.KeyFile: agentKey} {
if err := os.WriteFile(path, data, 0600); err != nil {
t.Fatal(err)
}
}
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
listen := listener.Addr().String()
listener.Close()
for name, value := range map[string]string{
"AGENT_ID": settings.AgentID, "CELL_ID": settings.CellID,
"AGENT_GRPC_LISTEN": listen, "AGENT_SESSION_PATH": settings.SessionPath,
"AGENT_RECOVERY_ROOT": settings.RecoveryRoot, "DISPATCHER_ID": settings.DispatcherID,
"DISPATCHER_GRPC_ENDPOINT": settings.DispatcherEndpoint, "DISPATCHER_GRPC_SERVER_NAME": settings.DispatcherServerName,
"MTLS_CA_FILE": settings.CAFile, "MTLS_CERT_FILE": settings.CertFile, "MTLS_KEY_FILE": settings.KeyFile,
"MTLS_PEER_CERT_FINGERPRINTS": rpc.CertificateFingerprint(dispatcherLeaf),
"ASTERISK_CONFIG_DIR": filepath.Join(settings.RecoveryRoot, "asterisk"),
"ASTERISK_BIN": "/usr/sbin/asterisk", "ASTERISK_LIBRARY_DIR": "/usr/lib/asterisk/modules",
"AGENT_EVIDENCE_ROOT": filepath.Join(settings.RecoveryRoot, "evidence"),
"AGENT_OSS_ALLOWED_HOST": "bucket.oss-cn-beijing.aliyuncs.com",
"AGENT_MOCK_SCENARIO_FILE": "", "AGENT_MOCK_APPLIED_SIP_FILE": "",
} {
t.Setenv(name, value)
}
ctx, cancel := context.WithCancel(context.Background())
cmd := newAgentCommand()
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetContext(ctx)
cmd.SetArgs([]string{"--mode", "nonprod-real"})
finished := make(chan error, 1)
go func() { finished <- cmd.Execute() }()
t.Cleanup(func() {
cancel()
select {
case err := <-finished:
if err != nil {
t.Errorf("real Agent shutdown failed: %v", err)
}
case <-time.After(5 * time.Second):
t.Error("real Agent did not stop after cancellation")
}
})
tlsConfig, err := rpc.NewClientTLSConfig(ca, dispatcherCert, dispatcherKey, "agent.local")
if err != nil {
t.Fatal(err)
}
dialCtx, stop := context.WithTimeout(context.Background(), 6*time.Second)
defer stop()
conn, err := grpc.DialContext(dialCtx, listen, grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig)), grpc.WithBlock())
if err != nil {
select {
case startupErr := <-finished:
t.Fatalf("nonprod-real startup failed: %v (dial: %v)", startupErr, err)
default:
t.Fatalf("nonprod-real pinned server unavailable: %v", err)
}
}
defer conn.Close()
status, err := agentpb.NewAgentControlServiceClient(conn).GetAgentStatus(dialCtx, &agentpb.GetAgentStatusRequest{
Meta: &agentpb.RequestMeta{ProtocolVersion: "agent.v1", RequestId: "status", TraceId: "status", OperationId: "status", AgentId: settings.AgentID, CellId: settings.CellID},
Target: &agentpb.AgentBinding{AgentId: settings.AgentID, CellId: settings.CellID},
})
if err != nil || status.GetStatus().GetAgentId() != settings.AgentID {
t.Fatalf("nonprod-real Agent did not expose its pinned status: status=%v err=%v", status, err)
}
}
func TestRealAgentServerRequiresBoundNativeSIPAndNoMock(t *testing.T) {
settings, _ := currentAgentSetupFixture(t)
settings.Mode = "nonprod-real"
settings.AsteriskConfigDir = filepath.Join(settings.RecoveryRoot, "asterisk")
settings.AsteriskBin = "/usr/sbin/asterisk"
settings.AsteriskLibraryDir = "/usr/lib/asterisk/modules"
settings.EvidenceRoot = filepath.Join(settings.RecoveryRoot, "evidence")
settings.OSSAllowedHost = "bucket.oss-cn-beijing.aliyuncs.com"
client := &isolatedAgentRecordingClient{}
server, err := newRealAgentServer(context.Background(), settings, client)
if err != nil || server == nil {
t.Fatalf("real Agent must use the native SIP and non-Mock delivery boundary: %v", err)
}
if _, err := os.Lstat(settings.SessionPath); !os.IsNotExist(err) {
t.Fatalf("server assembly must not touch the existing session: %v", err)
}
var typedNil *isolatedAgentRecordingClient
var nilClient agentpb.AgentControlServiceClient = typedNil
if server, err := newRealAgentServer(context.Background(), settings, nilClient); err == nil || server != nil {
t.Fatal("typed-nil Dispatcher must block real Agent startup")
}
settings.MockScenarioFile = "old-mock.json"
if server, err := newRealAgentServer(context.Background(), settings, client); err == nil || server != nil {
t.Fatal("real Agent must not accept isolated Mock fixtures")
}
}
+8 -4
View File
@@ -24,13 +24,13 @@ import (
func newDispatcherCommand() *cobra.Command {
var mode string
command := &cobra.Command{
Use: "dispatcher", Short: "Run the isolated Dispatcher", Args: cobra.NoArgs,
Use: "dispatcher", Short: "Run the bound Dispatcher", Args: cobra.NoArgs,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, _ []string) error {
return runDispatcher(cmd.Context(), mode)
},
}
command.Flags().StringVar(&mode, "mode", "mock", "isolated Mock or SIP-only mode")
command.Flags().StringVar(&mode, "mode", "mock", "isolated Mock, SIP-only or explicit nonprod-real mode")
return command
}
@@ -48,7 +48,11 @@ func runDispatcher(ctx context.Context, mode string) (result error) {
if err != nil {
return err
}
ossConfiguration, err := config.LoadOSSConfig(settings.OSSConfigFile, settings.DispatcherID)
loadOSS := config.LoadOSSConfig
if mode == "nonprod-real" {
loadOSS = config.LoadNonprodRealOSSConfig
}
ossConfiguration, err := loadOSS(settings.OSSConfigFile, settings.DispatcherID)
if err != nil {
return err
}
@@ -147,7 +151,7 @@ func runDispatcher(ctx context.Context, mode string) (result error) {
}
listener, err := net.Listen("tcp", settings.Listen)
if err != nil {
return fmt.Errorf("Dispatcher cannot listen on configured Mock address: %w", err)
return fmt.Errorf("Dispatcher cannot listen on configured local address: %w", err)
}
server := grpc.NewServer(grpc.Creds(credentials.NewTLS(serverTLS)))
agentpb.RegisterAgentControlServiceServer(server, &rpc.RecordingServer{
+10 -2
View File
@@ -17,7 +17,7 @@ systemd service or add it to a production host.
## Native Asterisk validation
`nonprod-call-evidence.sh` is the mandatory capture-first wrapper for
non-production `mock`, `mixed` and `real` call checks. It runs on a validation
non-production `mock`, `mixed` and explicit `nonprod-real` call checks. It runs on a validation
host with native Asterisk and required diagnostics; it is not an Asterisk or
Agent replacement and is not containerized. Run it explicitly with the current
call authorization and the approved target/trunk. It refuses production mode
@@ -29,7 +29,15 @@ of the installed binary and configuration. Missing facts fail the validation;
`--preflight-only` never authorizes a call. After capture, missing capture or
recording SHA-256, Asterisk journal, SIP summary, logger shutdown, timestamp, or
restricted evidence ownership is recorded in `diagnostic-errors.txt` and fails
the check; an already failed call keeps its nonzero result. Isolated tests
the check; an already failed call keeps its nonzero result. Once live tcpdump
and the PJSIP logger are running, the script creates a root-owned, group-readable
`<call-id>.active` capture arm under `--proof-root` (default
`/run/sip-go-agent/nonprod-armed`). The real Agent must set `AGENT_EVIDENCE_ROOT`
to this directory; it checks the exact approved event ID, trunk, raw callee,
recent arm and live capture PID before origination. The script removes the arm
before stopping capture. Run one approved call per invocation, with
`--call-id` equal to that call's MQ `event_id`; never reuse a stale arm.
Isolated tests
replace host tools with fakes: they do not prove a real host or supplier is ready.
For the **nonproduction user-level Asterisk service only**, pass
+21
View File
@@ -39,6 +39,9 @@ target=""
call_command=()
preflight_only=0
attempt_ledger="/var/lib/sip-go-agent/state/real-call-attempts.tsv"
proof_root="/run/sip-go-agent/nonprod-armed"
proof_file=""
proof_created=0
attempt_number=0
while (($#)); do
@@ -55,6 +58,7 @@ while (($#)); do
--rtp-end) [[ $# -ge 2 ]] || usage; rtp_end=$2; shift 2 ;;
--preflight-only) preflight_only=1; shift ;;
--attempt-ledger) [[ $# -ge 2 ]] || usage; attempt_ledger=$2; shift 2 ;;
--proof-root) [[ $# -ge 2 ]] || usage; proof_root=$2; shift 2 ;;
--trunk) [[ $# -ge 2 ]] || usage; trunk=$2; shift 2 ;;
--target) [[ $# -ge 2 ]] || usage; target=$2; shift 2 ;;
--) shift; call_command=("$@"); break ;;
@@ -73,6 +77,7 @@ esac
[[ "$trunk" =~ ^(provider-primary|provider-second|provider-third|trunk-[A-Za-z0-9._-]+)$ ]] || { echo 'trunk is not an approved non-production trunk id' >&2; exit 1; }
[[ "$target" =~ ^(15003164745|15830461047)$ ]] || { echo 'target is outside the approved outbound whitelist' >&2; exit 1; }
[[ "$attempt_ledger" =~ ^/[A-Za-z0-9._/-]+$ ]] || { echo 'invalid attempt ledger path' >&2; exit 1; }
[[ "$proof_root" =~ ^/[A-Za-z0-9._/-]+$ ]] || { echo 'invalid proof root path' >&2; exit 1; }
[[ ${#call_command[@]} -gt 0 ]] || { echo 'call command is required after --' >&2; exit 1; }
[[ "$interface" =~ ^[A-Za-z0-9_.:-]+$ ]] || { echo 'invalid capture interface' >&2; exit 1; }
[[ "$sip_port" =~ ^[0-9]+$ && "$rtp_start" =~ ^[0-9]+$ && "$rtp_end" =~ ^[0-9]+$ ]] || { echo 'invalid port' >&2; exit 1; }
@@ -294,6 +299,10 @@ cleanup() {
local call_exit=$? evidence_failed=0
trap - EXIT
set +e
if ((proof_created)) && ! rm -f -- "$proof_file"; then
printf 'capture arm removal failed\n' >>"$evidence_dir/diagnostic-errors.txt"
evidence_failed=1
fi
stop_capture
if ((logger_enabled)) && ! asterisk_cli "pjsip set logger off" >"$evidence_dir/pjsip-logger-off.txt" 2>&1; then
printf 'PJSIP logger stop failed\n' >>"$evidence_dir/diagnostic-errors.txt"
@@ -394,6 +403,18 @@ if ((preflight_only)); then
fi
require_call_window
# The Agent checks this root-owned, call-specific live capture arm before any
# originate. A stale arm is never overwritten; the trap removes it first.
install -d -o root -g "$run_as" -m 0750 -- "$proof_root"
proof_file="$proof_root/$call_id.active"
[[ ! -e "$proof_file" ]] || { echo 'stale or concurrent capture arm; fail-closed' >&2; exit 1; }
proof_tmp="$(mktemp --tmpdir="$proof_root" ".$call_id.XXXXXX")"
printf '%s\t%s\t%s\n' "$capture_pid" "$trunk" "$target" >"$proof_tmp"
chown "root:$run_as" "$proof_tmp"
chmod 0640 "$proof_tmp"
ln -- "$proof_tmp" "$proof_file" || { rm -f -- "$proof_tmp"; echo 'capture arm already exists; fail-closed' >&2; exit 1; }
proof_created=1
rm -f -- "$proof_tmp"
call_status=0
set +e
runuser -u "$run_as" -- "${call_command[@]}" >"$evidence_dir/call-output.private" 2>&1
+27 -5
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"net"
"os"
"path/filepath"
"strconv"
"strings"
@@ -14,6 +15,7 @@ import (
// AgentEnvironment is deployment-owned and is inspected without opening any
// durable file, media adapter, credential or network connection.
type AgentEnvironment struct {
Mode string
AgentID string
CellID string
DispatcherID string
@@ -31,11 +33,13 @@ type AgentEnvironment struct {
AsteriskConfigDir string
AsteriskBin string
AsteriskLibraryDir string
EvidenceRoot string
OSSAllowedHost string
}
func LoadAgentEnvironment(mode string) (AgentEnvironment, error) {
if mode != "mock" && mode != "sip-only" {
return AgentEnvironment{}, errors.New("Agent accepts only isolated Mock or SIP-only mode")
if mode != "mock" && mode != "sip-only" && mode != "nonprod-real" {
return AgentEnvironment{}, errors.New("Agent accepts only isolated Mock, SIP-only or explicit nonprod-real mode")
}
get := func(name string) (string, error) {
value := os.Getenv(name)
@@ -44,7 +48,7 @@ func LoadAgentEnvironment(mode string) (AgentEnvironment, error) {
}
return value, nil
}
var settings AgentEnvironment
settings := AgentEnvironment{Mode: mode}
for _, field := range []struct {
name string
value *string
@@ -67,13 +71,15 @@ func LoadAgentEnvironment(mode string) (AgentEnvironment, error) {
value *string
}
var err error
if mode == "mock" {
if mode == "mock" || mode == "nonprod-real" {
if settings.DispatcherEndpoint, err = get("DISPATCHER_GRPC_ENDPOINT"); err != nil {
return AgentEnvironment{}, err
}
if settings.DispatcherServerName, err = get("DISPATCHER_GRPC_SERVER_NAME"); err != nil {
return AgentEnvironment{}, err
}
}
if mode == "mock" {
modeFields = []struct {
name string
value *string
@@ -91,15 +97,31 @@ func LoadAgentEnvironment(mode string) (AgentEnvironment, error) {
}
*field.value = value
}
if mode == "nonprod-real" {
if settings.EvidenceRoot, err = get("AGENT_EVIDENCE_ROOT"); err != nil {
return AgentEnvironment{}, err
}
if settings.OSSAllowedHost, err = get("AGENT_OSS_ALLOWED_HOST"); err != nil {
return AgentEnvironment{}, err
}
}
if tenant.ValidateDispatcherID(settings.DispatcherID) != nil {
return AgentEnvironment{}, errors.New("DISPATCHER_ID must be a canonical UUID v4")
}
if !localGRPCAddress(settings.Listen, true) {
return AgentEnvironment{}, errors.New("AGENT_GRPC_LISTEN must be an isolated local Mock address")
}
if mode == "mock" && !localGRPCAddress(settings.DispatcherEndpoint, false) {
if mode != "sip-only" && !localGRPCAddress(settings.DispatcherEndpoint, false) {
return AgentEnvironment{}, errors.New("DISPATCHER_GRPC_ENDPOINT must be an isolated local Mock address")
}
if mode == "nonprod-real" {
if !filepath.IsAbs(settings.EvidenceRoot) {
return AgentEnvironment{}, errors.New("AGENT_EVIDENCE_ROOT must be absolute")
}
if os.Getenv("AGENT_MOCK_SCENARIO_FILE") != "" || os.Getenv("AGENT_MOCK_APPLIED_SIP_FILE") != "" {
return AgentEnvironment{}, errors.New("nonprod-real Agent cannot load isolated Mock fixtures")
}
}
rawFingerprints, err := get("MTLS_PEER_CERT_FINGERPRINTS")
if err != nil {
return AgentEnvironment{}, err
+35
View File
@@ -59,6 +59,41 @@ func TestLoadSIPOnlyAgentEnvironmentDoesNotRequireMockCalls(t *testing.T) {
}
}
func TestLoadNonprodRealAgentRequiresNativeCallAndEvidenceSettings(t *testing.T) {
state := setAgentEnvironment(t)
t.Setenv("AGENT_MOCK_SCENARIO_FILE", "")
t.Setenv("AGENT_MOCK_APPLIED_SIP_FILE", "")
for name, value := range map[string]string{
"ASTERISK_CONFIG_DIR": "/tmp/asterisk-config", "ASTERISK_BIN": "/tmp/asterisk",
"ASTERISK_LIBRARY_DIR": "/tmp/asterisk-libraries",
"AGENT_EVIDENCE_ROOT": "/tmp/nonprod-call-evidence",
"AGENT_OSS_ALLOWED_HOST": "test.oss-cn-beijing.aliyuncs.com",
} {
t.Setenv(name, value)
}
settings, err := LoadAgentEnvironment("nonprod-real")
if err != nil || settings.AsteriskConfigDir == "" || settings.DispatcherEndpoint == "" || settings.EvidenceRoot != "/tmp/nonprod-call-evidence" || settings.OSSAllowedHost != "test.oss-cn-beijing.aliyuncs.com" || settings.MockScenarioFile != "" {
t.Fatalf("nonproduction real mode must use explicit native, capture and OSS settings: %+v %v", settings, err)
}
if _, err := os.Stat(state); !os.IsNotExist(err) {
t.Fatalf("configuration inspection opened durable session: %v", err)
}
for _, name := range []string{"ASTERISK_CONFIG_DIR", "ASTERISK_BIN", "ASTERISK_LIBRARY_DIR", "AGENT_EVIDENCE_ROOT", "AGENT_OSS_ALLOWED_HOST", "DISPATCHER_GRPC_ENDPOINT", "DISPATCHER_GRPC_SERVER_NAME"} {
t.Run(name, func(t *testing.T) {
t.Setenv(name, "")
if _, err := LoadAgentEnvironment("nonprod-real"); err == nil || !strings.Contains(err.Error(), name) {
t.Fatalf("missing real-call setting must block startup: %v", err)
}
t.Setenv(name, map[string]string{
"ASTERISK_CONFIG_DIR": "/tmp/asterisk-config", "ASTERISK_BIN": "/tmp/asterisk",
"ASTERISK_LIBRARY_DIR": "/tmp/asterisk-libraries", "AGENT_EVIDENCE_ROOT": "/tmp/nonprod-call-evidence",
"AGENT_OSS_ALLOWED_HOST": "test.oss-cn-beijing.aliyuncs.com", "DISPATCHER_GRPC_ENDPOINT": "127.0.0.1:39443",
"DISPATCHER_GRPC_SERVER_NAME": "dispatcher.local",
}[name])
})
}
}
func TestLoadAgentEnvironmentRequiresExplicitDeploymentValues(t *testing.T) {
for _, name := range []string{
"AGENT_ID", "CELL_ID", "AGENT_GRPC_LISTEN", "AGENT_SESSION_PATH", "AGENT_RECOVERY_ROOT",
+15 -3
View File
@@ -20,6 +20,16 @@ import (
// Dispatcher. Credentials are resolved from explicit environment references;
// the file, its contents and secrets are never echoed in errors.
func LoadOSSConfig(filename, dispatcherID string) (oss.Config, error) {
return loadOSSConfig(filename, dispatcherID, false)
}
// LoadNonprodRealOSSConfig accepts only the approved Beijing HTTPS OSS endpoint.
// The old isolated Mock loader remains loopback-only.
func LoadNonprodRealOSSConfig(filename, dispatcherID string) (oss.Config, error) {
return loadOSSConfig(filename, dispatcherID, true)
}
func loadOSSConfig(filename, dispatcherID string, nonprodReal bool) (oss.Config, error) {
if strings.TrimSpace(filename) == "" || tenant.ValidateDispatcherID(dispatcherID) != nil {
return oss.Config{}, errors.New("DISPATCHER_OSS_CONFIG_FILE and canonical Dispatcher ID are required")
}
@@ -60,9 +70,11 @@ func LoadOSSConfig(filename, dispatcherID string) (oss.Config, error) {
return oss.Config{}, errors.New("OSS configuration is not assigned to this Dispatcher")
}
endpoint, err := url.Parse(input.OSS.Endpoint)
if err != nil || !localEndpoint(input.OSS.Endpoint, "https") || endpoint.User != nil || endpoint.RawQuery != "" || endpoint.Fragment != "" ||
(endpoint.Path != "" && endpoint.Path != "/") || endpoint.RawPath != "" || !localGRPCAddress(endpoint.Host, false) {
return oss.Config{}, errors.New("OSS Mock endpoint must be a local HTTPS service with an explicit port")
if err != nil || endpoint.Scheme != "https" || endpoint.User != nil || endpoint.RawQuery != "" || endpoint.Fragment != "" ||
(endpoint.Path != "" && endpoint.Path != "/") || endpoint.RawPath != "" ||
(!nonprodReal && (!localEndpoint(input.OSS.Endpoint, "https") || !localGRPCAddress(endpoint.Host, false))) ||
(nonprodReal && (endpoint.Host != "oss-cn-beijing.aliyuncs.com" || input.OSS.Region != "cn-beijing")) {
return oss.Config{}, errors.New("OSS endpoint does not match the selected isolated Mock or approved Beijing real deployment")
}
prefix := input.OSS.ObjectPrefix
if prefix == "" || strings.TrimSpace(prefix) != prefix || path.IsAbs(prefix) || path.Clean(prefix) != prefix ||
+22
View File
@@ -33,6 +33,28 @@ func TestLoadOSSConfigRequiresBoundLocalHTTPSAndExplicitCredentials(t *testing.T
}
}
func TestNonprodRealOSSOnlyAcceptsApprovedBeijingEndpoint(t *testing.T) {
t.Setenv("MOCK_OSS_KEY_ID", "isolated-id")
t.Setenv("MOCK_OSS_KEY_SECRET", "isolated-secret")
realBody := strings.Replace(strings.Replace(mockOSSDeployment, "https://127.0.0.1:19445", "https://oss-cn-beijing.aliyuncs.com", 1), `"region":"cn-mock"`, `"region":"cn-beijing"`, 1)
cfg, err := LoadNonprodRealOSSConfig(writeMockOSSDeployment(t, realBody), dispatcherFixtureID)
if err != nil || cfg.Endpoint != "https://oss-cn-beijing.aliyuncs.com" || cfg.Region != "cn-beijing" {
t.Fatalf("explicit Beijing OSS deployment must be accepted: err=%v", err)
}
if _, err := LoadOSSConfig(writeMockOSSDeployment(t, realBody), dispatcherFixtureID); err == nil {
t.Fatal("isolated Mock must not reach real OSS")
}
for _, body := range []string{
mockOSSDeployment,
strings.Replace(realBody, "oss-cn-beijing.aliyuncs.com", "oss-cn-shanghai.aliyuncs.com", 1),
strings.Replace(realBody, "https://oss-cn-beijing.aliyuncs.com", "http://oss-cn-beijing.aliyuncs.com", 1),
} {
if _, err := LoadNonprodRealOSSConfig(writeMockOSSDeployment(t, body), dispatcherFixtureID); err == nil {
t.Fatal("nonproduction real OSS admitted a Mock, insecure or wrong-region endpoint")
}
}
}
func TestLoadOSSConfigIgnoresLegacyOSSEnvironment(t *testing.T) {
for _, key := range []string{"DISPATCHER_OSS_REGION", "DISPATCHER_OSS_ENDPOINT", "DISPATCHER_OSS_BUCKET", "DISPATCHER_OSS_ACCESS_KEY_ID", "DISPATCHER_OSS_ACCESS_KEY_SECRET", "DISPATCHER_OSS_KEY_PREFIX", "DISPATCHER_OSS_GRANT_TTL_SECONDS"} {
t.Setenv(key, "legacy-value")
+4 -3
View File
@@ -22,10 +22,11 @@ type DispatcherEnvironment struct {
}
// LoadDispatcherEnvironment is pure inspection: it opens no database, queue or
// network connection. Only isolated Mock or SIP-only mode may start.
// network connection. Nonproduction real calls still use only the local SaaS
// simulator and predeclared local AMQP test queues.
func LoadDispatcherEnvironment(mode string) (DispatcherEnvironment, error) {
if mode != "mock" && mode != "sip-only" {
return DispatcherEnvironment{}, errors.New("Dispatcher accepts only isolated Mock or SIP-only mode")
if mode != "mock" && mode != "sip-only" && mode != "nonprod-real" {
return DispatcherEnvironment{}, errors.New("Dispatcher accepts only isolated Mock, SIP-only or explicit nonprod-real mode")
}
get := func(name string) (string, error) {
value := os.Getenv(name)
+15
View File
@@ -34,6 +34,21 @@ func TestLoadDispatcherEnvironmentRefusesNonMockBeforeResources(t *testing.T) {
}
}
func TestLoadDispatcherEnvironmentNonprodRealUsesOnlyLocalSaaSMockAndQueue(t *testing.T) {
path := setDispatcherEnvironment(t)
settings, err := LoadDispatcherEnvironment("nonprod-real")
if err != nil || settings.SQLitePath != path {
t.Fatalf("explicit nonprod-real should use the same bound SaaS HTTP and AMQP inputs: %v", err)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("real configuration inspection opened SQLite: %v", err)
}
t.Setenv("SAAS_BASE_URL", "https://saas.example.invalid")
if _, err := LoadDispatcherEnvironment("nonprod-real"); err == nil {
t.Fatal("external SaaS is not authorized for this nonproduction test")
}
}
func TestLoadDispatcherEnvironmentRequiresExplicitIdentityAndNoFallback(t *testing.T) {
for _, name := range []string{"DISPATCHER_ID", "DISPATCHER_SECRET_KEY", "SAAS_BASE_URL", "RABBITMQ_URL", "DISPATCHER_SQLITE_PATH"} {
t.Run(name, func(t *testing.T) {
+2 -2
View File
@@ -28,8 +28,8 @@ func (s *Server) ApplyApprovedTaskControl(ctx context.Context, req *agentpb.Appl
if req.DispatcherId != dispatcherID {
return nil, status.Error(codes.PermissionDenied, "Dispatcher does not own the active Agent session")
}
if s.mode != "mock" {
return nil, status.Error(codes.FailedPrecondition, "approved task control requires isolated Mock mode")
if s.mode != "mock" && s.mode != "nonprod-real" {
return nil, status.Error(codes.FailedPrecondition, "approved task control requires an approved call mode")
}
if req.TenantId <= 0 || strings.TrimSpace(req.TaskId) == "" {
return nil, status.Error(codes.InvalidArgument, "approved task control identity is incomplete")
+6
View File
@@ -53,6 +53,12 @@ func TestApprovedTaskControlRequiresCurrentSessionAndValidTaskPolicy(t *testing.
if response, err := server.ApplyApprovedTaskControl(context.Background(), request); err != nil || response == nil || !response.Accepted {
t.Fatalf("approved resume was not applied: response=%v err=%v", response, err)
}
server.mode = "nonprod-real"
request.Action = agentpb.ControlAction_CONTROL_ACTION_PAUSE
request.ActiveCallPolicy = agentpb.ActiveCallPolicy_ACTIVE_CALL_POLICY_HANGUP
if response, err := server.ApplyApprovedTaskControl(context.Background(), request); err != nil || response == nil || !response.Accepted {
t.Fatalf("nonprod-real task control must use the same authenticated entry: response=%v err=%v", response, err)
}
server.approvedTaskCalls = nil
if _, err := server.ApplyApprovedTaskControl(context.Background(), request); status.Code(err) != codes.FailedPrecondition {
t.Fatalf("missing Agent control adapter reported success: %v", err)
+53 -2
View File
@@ -4,8 +4,11 @@ import (
"context"
"errors"
"os"
"path/filepath"
"strconv"
"strings"
"sync/atomic"
"syscall"
"time"
"git.ipao.vip/rogee/go-sip/internal/agent"
@@ -20,6 +23,7 @@ import (
type ApprovedRecordedRealCall struct {
Loader asterisk.Loader
MediaPayloadType uint8
EvidenceRoot string
MaxWAVBytes int64
ReportTimeout time.Duration
Delivery *agent.RecordingDelivery
@@ -52,8 +56,13 @@ func (r *ApprovedRecordedRealCall) Prepare(approved ApprovedExecution) (func(con
if err != nil || !info.IsDir() || info.Mode().Perm() != 0700 {
return nil, errors.New("real recording recovery directory must exist with mode 0700")
}
if r.originator == nil && (r.Loader.ConfigDir == "" || r.Loader.Asterisk == "" || r.Loader.LibraryDir == "") {
return nil, errors.New("native Asterisk configuration required for real call")
if r.originator == nil {
if r.Loader.ConfigDir == "" || r.Loader.Asterisk == "" || r.Loader.LibraryDir == "" {
return nil, errors.New("native Asterisk configuration required for real call")
}
if err := verifyCallEvidence(r.EvidenceRoot, approved.SourceEventID, approved.SelectedTrunkID, approved.Callee); err != nil {
return nil, err
}
}
if _, err := ai.NewCall(approved.AI, func(context.Context) error { return nil }); err != nil {
return nil, err
@@ -81,6 +90,11 @@ func (r *ApprovedRecordedRealCall) Prepare(approved ApprovedExecution) (func(con
if ctx == nil || ctx.Err() != nil || !time.Now().Before(approved.DialBefore) {
return errors.New("real call instruction expired or cancelled before origination")
}
if r.originator == nil {
if err := verifyCallEvidence(r.EvidenceRoot, approved.SourceEventID, approved.SelectedTrunkID, approved.Callee); err != nil {
return err
}
}
call, err := originate(ctx, dial)
if err != nil {
return err // unknown origination is never retried or reported as a completed call
@@ -131,6 +145,43 @@ func (r *ApprovedRecordedRealCall) Prepare(approved ApprovedExecution) (func(con
}, nil
}
// verifyCallEvidence accepts only a current, live capture armed by the root
// nonproduction host script for this exact accepted call and trunk.
func verifyCallEvidence(root, id, trunk, target string) error {
if !filepath.IsAbs(root) || id == "" || id == "." || id == ".." || filepath.Base(id) != id || strings.ContainsAny(id, "/\\") {
return errors.New("real call evidence path or identity is invalid")
}
marker := filepath.Join(root, id+".active")
info, err := os.Lstat(marker)
if err != nil || !info.Mode().IsRegular() || info.Mode().Perm() != 0640 || time.Since(info.ModTime()) > 10*time.Minute {
return errors.New("real call capture is not armed")
}
proof, err := os.ReadFile(marker)
if err != nil || len(proof) > 160 {
return errors.New("real call capture proof is unreadable")
}
fields := strings.Split(strings.TrimSuffix(string(proof), "\n"), "\t")
if len(fields) != 3 || fields[1] != trunk || fields[2] != target {
return errors.New("real call capture does not match signed call identity")
}
pid, err := strconv.Atoi(fields[0])
if err != nil || pid <= 1 {
return errors.New("real call capture PID invalid")
}
if err := syscall.Kill(pid, 0); err != nil && !errors.Is(err, syscall.EPERM) {
return errors.New("real call capture process is not running")
}
state, err := os.ReadFile(filepath.Join("/proc", fields[0], "stat"))
if err != nil {
return errors.New("real call capture process state is unavailable")
}
end := strings.LastIndex(string(state), ") ")
if end == -1 || len(state) <= end+2 || state[end+2] == 'Z' || state[end+2] == 'X' {
return errors.New("real call capture process has ended")
}
return nil
}
func (r *ApprovedRecordedRealCall) Run(ctx context.Context, approved ApprovedExecution) error {
if ctx == nil {
return errors.New("real call requires a context")
@@ -4,7 +4,10 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -20,6 +23,33 @@ func (endedRealMedia) ReadPayload(context.Context) ([]byte, error) { return nil
func (endedRealMedia) SendPCM16(context.Context, []byte, int) error { return nil }
func (endedRealMedia) Stats() media.RTPStats { return media.RTPStats{} }
func TestRealCallRequiresLiveCaptureBoundToSignedIdentity(t *testing.T) {
root := t.TempDir()
id, trunk, target := "event-1", "trunk-1", "15003164745"
marker := filepath.Join(root, id+".active")
if err := verifyCallEvidence(root, id, trunk, target); err == nil {
t.Fatal("missing SIP/RTP evidence arm must block the call")
}
if err := os.WriteFile(marker, []byte(fmt.Sprintf("%d\t%s\t%s\n", os.Getpid(), trunk, target)), 0640); err != nil {
t.Fatal(err)
}
if err := verifyCallEvidence(root, id, trunk, target); err != nil {
t.Fatal(err)
}
if err := verifyCallEvidence(root, id, trunk, "15830461047"); err == nil {
t.Fatal("capture bound to a different callee cannot authorize a real call")
}
if err := verifyCallEvidence(root, "../event-1", trunk, target); err == nil {
t.Fatal("call ID cannot escape the evidence directory")
}
if err := os.WriteFile(marker, []byte(fmt.Sprintf("%d\t%s\t%s\n", 2147483647, trunk, target)), 0640); err != nil {
t.Fatal(err)
}
if err := verifyCallEvidence(root, id, trunk, target); err == nil {
t.Fatal("terminated tcpdump must block the call")
}
}
func TestApprovedRecordedRealCallReportsOnlyEndedObservedCall(t *testing.T) {
fixture, stub, puts, _, approved := recordedMockFixture(t, nil, time.Second)
approved.CallerID, approved.DialedCallee, approved.RingTimeout = "BD93205882", "7089"+approved.Callee, time.Second
+10 -6
View File
@@ -11,14 +11,14 @@ var ErrApprovedWorkerRequired = errors.New("approved call worker is required")
// NewApprovedAgentServer binds the control RPC and one-shot originate callback
// to the same task-call registry. The separate legacy hooks are not accepted
// on this current Mock-only entry: otherwise a control could confirm while a
// call started by a different adapter remains active.
// on either explicitly selected entry: otherwise a control could confirm while
// a call started by a different adapter remains active.
func NewApprovedAgentServer(options ServerOptions, worker *ApprovedCallWorker) (*Server, error) {
if worker == nil {
return nil, ErrApprovedWorkerRequired
}
if options.Mode != "mock" || options.StatePath == "" || options.Status == nil || options.Status.AgentId == "" || options.Status.CellId == "" || options.Status.BootId == "" || strings.TrimSpace(options.ApprovedDispatcherID) == "" || options.LoadedSIP == nil {
return nil, errors.New("approved Agent requires explicit Mock mode, durable state, Agent and Dispatcher identity, and observed SIP")
if (options.Mode != "mock" && options.Mode != "nonprod-real") || options.StatePath == "" || options.Status == nil || options.Status.AgentId == "" || options.Status.CellId == "" || options.Status.BootId == "" || strings.TrimSpace(options.ApprovedDispatcherID) == "" || options.LoadedSIP == nil {
return nil, errors.New("approved Agent requires an explicit supported mode, durable state, Agent and Dispatcher identity, and observed SIP")
}
if worker.Lifecycle == nil || worker.Calls == nil || worker.Prepare == nil || worker.OnFailure == nil {
return nil, errors.New("approved Agent requires a process lifecycle, task calls, runner and failure reporting")
@@ -28,13 +28,17 @@ func NewApprovedAgentServer(options ServerOptions, worker *ApprovedCallWorker) (
} else if !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("inspect legacy Agent execution state: %w", err)
}
if options.MockApprovedOriginate != nil || options.ApprovedTaskCalls != nil {
if options.MockApprovedOriginate != nil || options.NonprodRealOriginate != nil || options.ApprovedTaskCalls != nil {
return nil, errors.New("approved Agent cannot use competing call or control adapters")
}
// Copy the configuration so later mutation of worker fields cannot cause
// ExecuteApproved and task controls to observe different registries.
frozen := *worker
options.MockApprovedOriginate = frozen.Originate
if options.Mode == "mock" {
options.MockApprovedOriginate = frozen.Originate
} else {
options.NonprodRealOriginate = frozen.Originate
}
options.ApprovedTaskCalls = frozen.Calls
return NewServer(options), nil
}
+15
View File
@@ -175,6 +175,21 @@ func TestApprovedAgentServerRefusesMissingOrCompetingAdapters(t *testing.T) {
}
}
func TestApprovedAgentServerBindsOnlyRealWorkerInNonprodMode(t *testing.T) {
worker := &ApprovedCallWorker{Lifecycle: context.Background(), Calls: &agent.TaskCalls{},
Prepare: prepareWorkerRun(func(context.Context, ApprovedExecution) error { return nil }),
OnFailure: func(ApprovedExecution, error) error { return nil },
}
server, err := NewApprovedAgentServer(ServerOptions{
Mode: "nonprod-real", StatePath: filepath.Join(t.TempDir(), "agent-session.json"),
ApprovedDispatcherID: "dispatcher-1", Status: &agentpb.AgentStatus{AgentId: "agent-1", CellId: "cell-1", BootId: "boot-1"},
LoadedSIP: func(context.Context) (map[string]int64, error) { return map[string]int64{"trunk-1": 8}, nil },
}, worker)
if err != nil || server == nil || server.nonprodRealOriginate == nil || server.mockApprovedOriginate != nil {
t.Fatalf("nonproduction real mode must bind only its real worker: server=%v err=%v", server, err)
}
}
func TestApprovedAgentServerRefusesLegacyExecutionJournalWithoutMutation(t *testing.T) {
state := filepath.Join(t.TempDir(), "agent-session.json")
legacy := state + ".executions"
+3 -3
View File
@@ -12,8 +12,8 @@ import (
"google.golang.org/grpc/status"
)
// ApplySIP is deliberately unavailable on the Mock call server. The real
// SIP-only Agent accepts a complete Dispatcher-owned snapshot, then reports
// ApplySIP is deliberately unavailable on the Mock call server. A native
// Agent accepts a complete Dispatcher-owned snapshot, then reports
// only revisions it actually applied and inspected in native Asterisk.
func (s *Server) ApplySIP(ctx context.Context, req *agentpb.ApplySIPRequest) (*agentpb.ApplySIPResponse, error) {
if req == nil || req.Meta == nil || len(req.ApprovedSnapshotJson) == 0 || len(req.ApprovedSnapshotJson) > 1<<20 {
@@ -26,7 +26,7 @@ func (s *Server) ApplySIP(ctx context.Context, req *agentpb.ApplySIPRequest) (*a
if err != nil {
return nil, err
}
if s.mode != "sip-only" || s.applySIP == nil {
if (s.mode != "sip-only" && s.mode != "nonprod-real") || s.applySIP == nil {
return nil, status.Error(codes.FailedPrecondition, "real SIP apply is unavailable on this Agent")
}
var approved configread.SIP
+4
View File
@@ -68,4 +68,8 @@ func TestApplySIPOnlyAllowsActivatedSIPService(t *testing.T) {
if _, err := server.ApplySIP(context.Background(), req); status.Code(err) != codes.FailedPrecondition || attempts != 1 {
t.Fatalf("mock boundary allowed real SIP apply: attempts=%d err=%v", attempts, err)
}
server.mode, server.applySIP = "nonprod-real", apply
if _, err := server.ApplySIP(context.Background(), req); err != nil || attempts != 2 {
t.Fatalf("nonprod-real must observe native SIP through the same approved path: attempts=%d err=%v", attempts, err)
}
}
+8 -1
View File
@@ -88,11 +88,18 @@ for mode in mixed real; do
echo "release accepted unapproved $mode execution" >&2
exit 1
fi
if ! grep -Fq 'Dispatcher accepts only isolated Mock or SIP-only mode' "$WORK/blocked-$mode.log" || test -e "$blocked_db"; then
if ! grep -Fq 'Dispatcher accepts only isolated Mock, SIP-only or explicit nonprod-real mode' "$WORK/blocked-$mode.log" || test -e "$blocked_db"; then
echo "release reached resources before rejecting $mode execution" >&2
exit 1
fi
done
# The explicit nonproduction real path cannot open durable state unless every
# bound deployment input is present; this package test supplies none of them.
if DISPATCHER_SQLITE_PATH="$WORK/unconfigured-real.sqlite" "$WORK/release/sip-go-agent" dispatcher --mode nonprod-real > "$WORK/unconfigured-real.log" 2>&1 ||
test -e "$WORK/unconfigured-real.sqlite"; then
echo 'unconfigured real Dispatcher opened state or started' >&2
exit 1
fi
rm -- "$PACKAGE_RELEASE/sentinel"
rmdir -- "$PACKAGE_RELEASE"
"$ROOT/deploys/build-package.sh" "$PACKAGE_VERSION" > "$WORK/package.log" 2>&1 || {