chore: initialize go-sip repository
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.ipao.vip/rogee/go-sip/contracts"
|
||||
agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1"
|
||||
"git.ipao.vip/rogee/go-sip/internal/ai"
|
||||
"git.ipao.vip/rogee/go-sip/internal/contract"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
func TestExecutionPermitEnforcesAIAuthorization(t *testing.T) {
|
||||
snapshotRaw, err := contracts.Read("examples/agent-version-asr-only.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snapshot, err := ai.ValidateForMode(snapshotRaw, ai.ModeASROnly)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
authorizationRaw, err := contracts.Read("examples/ai-authorization.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := NewServer(ServerOptions{
|
||||
Now: func() time.Time { return time.Date(2026, 9, 18, 0, 0, 30, 0, time.UTC) },
|
||||
AISnapshotRaw: snapshotRaw,
|
||||
AIAuthorizationRaw: authorizationRaw,
|
||||
AIEgressPoolID: "egress-mock",
|
||||
})
|
||||
activateTestServer(t, server)
|
||||
|
||||
raw, err := contracts.Read("examples/call.execute.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, payload, err := contract.DecodeExecute(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
binding := &agentv1.ExecutionBinding{
|
||||
TenantId: "tenant-1",
|
||||
TenantKey: "tenant-demo-key",
|
||||
ExecutionId: payload.ExecutionID,
|
||||
TaskId: payload.TaskID,
|
||||
TaskItemId: payload.TaskItemID,
|
||||
TaskRevision: payload.TaskRevision,
|
||||
AgentVersionId: snapshot.AgentVersionID,
|
||||
RoutePolicyId: payload.RoutePolicyID,
|
||||
CallerProfileId: payload.CallerProfileID,
|
||||
}
|
||||
response, err := server.GetExecutionPermit(context.Background(), &agentv1.GetExecutionPermitRequest{
|
||||
Meta: testMeta("permit-ai", "permit-ai-key", 1),
|
||||
Binding: binding,
|
||||
ResourceReservationId: "reservation-ai",
|
||||
ExpectedTaskRevision: payload.TaskRevision,
|
||||
ConfigSha256: snapshot.Digest,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.Permit == nil || response.Receipt == nil || response.Receipt.Result != agentv1.ResultCode_RESULT_CODE_APPLIED {
|
||||
t.Fatalf("unexpected authorized permit response: %+v", response)
|
||||
}
|
||||
|
||||
badDigest := &agentv1.GetExecutionPermitRequest{
|
||||
Meta: testMeta("permit-ai-bad-digest", "permit-ai-bad-digest-key", 1),
|
||||
Binding: binding,
|
||||
ResourceReservationId: "reservation-ai-2",
|
||||
ExpectedTaskRevision: payload.TaskRevision,
|
||||
ConfigSha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
}
|
||||
if _, err := server.GetExecutionPermit(context.Background(), badDigest); status.Code(err) != codes.FailedPrecondition {
|
||||
t.Fatalf("bad digest error=%v, code=%s", err, status.Code(err))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutionPermitRejectsRevokedAIAuthorization(t *testing.T) {
|
||||
snapshotRaw, err := contracts.Read("examples/agent-version-asr-only.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snapshot, err := ai.ValidateForMode(snapshotRaw, ai.ModeASROnly)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
authorizationRaw, err := contracts.Read("examples/invalid-ai-authorization-revoked.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := NewServer(ServerOptions{
|
||||
Now: func() time.Time { return time.Date(2026, 9, 18, 1, 0, 30, 0, time.UTC) },
|
||||
AISnapshotRaw: snapshotRaw,
|
||||
AIAuthorizationRaw: authorizationRaw,
|
||||
AIEgressPoolID: "egress-mock",
|
||||
})
|
||||
activateTestServer(t, server)
|
||||
response, err := server.GetExecutionPermit(context.Background(), &agentv1.GetExecutionPermitRequest{
|
||||
Meta: testMeta("permit-revoked", "permit-revoked-key", 1),
|
||||
Binding: &agentv1.ExecutionBinding{TenantId: "tenant-1", TenantKey: "tenant-demo-key", ExecutionId: "execution-revoked", AgentVersionId: snapshot.AgentVersionID},
|
||||
ResourceReservationId: "reservation-revoked",
|
||||
ConfigSha256: snapshot.Digest,
|
||||
})
|
||||
if err == nil || status.Code(err) != codes.PermissionDenied || response != nil {
|
||||
t.Fatalf("revoked authorization response=%+v err=%v code=%s", response, err, status.Code(err))
|
||||
}
|
||||
}
|
||||
|
||||
func activateTestServer(t *testing.T, server *Server) {
|
||||
t.Helper()
|
||||
_, err := server.ActivateAgent(context.Background(), &agentv1.ActivateAgentRequest{
|
||||
Meta: testMeta("activate-ai", "", 0),
|
||||
Binding: &agentv1.AgentBinding{AgentId: "agent-1", CellId: "cell-1", ExpectedBootId: "boot-1", DispatcherEpoch: "epoch-1", SessionGeneration: 1},
|
||||
ActivationOperationId: "activate-ai",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.ipao.vip/rogee/go-sip/contracts"
|
||||
agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1"
|
||||
"git.ipao.vip/rogee/go-sip/internal/calllog"
|
||||
"git.ipao.vip/rogee/go-sip/internal/contract"
|
||||
)
|
||||
|
||||
func TestServerWritesPhoneCorrelatedCallBusinessLog(t *testing.T) {
|
||||
now := time.Date(2026, 9, 18, 1, 0, 0, 0, time.UTC)
|
||||
logger, err := calllog.New(filepath.Join(t.TempDir(), "business", "calls.jsonl"), []byte("0123456789abcdef"), func() time.Time { return now })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := NewServer(ServerOptions{Now: func() time.Time { return now }, CallLogger: logger})
|
||||
meta := testMeta("activate-log", "", 0)
|
||||
if _, err := server.ActivateAgent(context.Background(), &agentv1.ActivateAgentRequest{
|
||||
Meta: meta,
|
||||
Binding: &agentv1.AgentBinding{AgentId: "agent-1", CellId: "cell-1", ExpectedBootId: "boot-1", DispatcherEpoch: "epoch-1", SessionGeneration: 1},
|
||||
ActivationOperationId: "activate-log",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := contracts.Read("examples/call.execute.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
envelope, payload, err := contract.DecodeExecute(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
binding := &agentv1.ExecutionBinding{
|
||||
TenantId: envelope.TenantID, TenantKey: envelope.TenantKey, ExecutionId: payload.ExecutionID,
|
||||
TaskId: payload.TaskID, TaskItemId: payload.TaskItemID, TaskRevision: payload.TaskRevision,
|
||||
AgentVersionId: payload.AgentVersionID, RoutePolicyId: payload.RoutePolicyID, CallerProfileId: payload.CallerProfileID,
|
||||
}
|
||||
if _, err := server.Execute(context.Background(), &agentv1.ExecuteRequest{
|
||||
Meta: testMeta("execute-log", "execute-log-key", 1), Binding: binding, CallExecuteJson: raw,
|
||||
ConfigSha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
statusPayload := []byte(`{"call_id":"call-1","execution_id":"execution-1","call_state":"answered","attempt_id":"attempt-1","attempt_state":"active","route_policy_id":"route-sip-first","caller_profile_id":"caller-sip-first","trunk_id":"provider-primary","cell_id":"cell-1","sip_stage":"invite","sip_status_code":183}`)
|
||||
if _, err := server.ReportExecutionEvent(context.Background(), &agentv1.ReportExecutionEventRequest{
|
||||
Meta: testMeta("fact-status", "fact-status-key", 1), Fact: &agentv1.ExecutionFact{
|
||||
FactId: "fact-status", ContentSha256: "digest-status", Binding: binding,
|
||||
Kind: agentv1.FactKind_FACT_KIND_CALL_STATUS, PayloadJson: statusPayload,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
finishedPayload := []byte(`{"call_id":"call-1","execution_id":"execution-1","outcome":"no_answer","duration_ms":3000,"reason_code":"provider_480","asset_state":"failed","attempt_summary":[{"attempt_id":"attempt-1","state":"ended","trunk_id":"provider-primary","cell_id":"cell-1","reason_code":"provider_480"}],"recording_id":"recording-1","recording_state":"failed"}`)
|
||||
if _, err := server.ReportExecutionEvent(context.Background(), &agentv1.ReportExecutionEventRequest{
|
||||
Meta: testMeta("fact-finished", "fact-finished-key", 1), Fact: &agentv1.ExecutionFact{
|
||||
FactId: "fact-finished", ContentSha256: "digest-finished", Binding: binding,
|
||||
Kind: agentv1.FactKind_FACT_KIND_CALL_FINISHED, PayloadJson: finishedPayload,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(logger.Path())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := string(data)
|
||||
if strings.Contains(text, payload.Callee) {
|
||||
t.Fatalf("business log contains original phone: %s", text)
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(text), "\n")
|
||||
if len(lines) != 4 {
|
||||
t.Fatalf("got %d business log lines, want prepared/status/finished/attempt: %s", len(lines), text)
|
||||
}
|
||||
var records []map[string]any
|
||||
for _, line := range lines {
|
||||
var record map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &record); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
records = append(records, record)
|
||||
}
|
||||
if records[0]["event_type"] != "execution.prepared" || records[1]["sip_status_code"] != float64(183) {
|
||||
t.Fatalf("prepared or SIP status record missing: %#v", records)
|
||||
}
|
||||
if records[2]["recording_id"] != "recording-1" || records[2]["result"] != "no_answer" || records[3]["trunk_id"] != "provider-primary" {
|
||||
t.Fatalf("finished or attempt record missing: %#v", records)
|
||||
}
|
||||
if records[0]["phone_ref"] != records[1]["phone_ref"] || records[1]["phone_ref"] != records[2]["phone_ref"] {
|
||||
t.Fatalf("phone correlation changed across events: %#v", records)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials"
|
||||
)
|
||||
|
||||
// Client is a thin generated-stub wrapper. Business retries and reconciliation
|
||||
// remain at the caller; this type never retries an originate automatically.
|
||||
type Client struct {
|
||||
Conn *grpc.ClientConn
|
||||
Agent agentv1.AgentControlServiceClient
|
||||
}
|
||||
|
||||
func Dial(endpoint string, tlsConfig *tls.Config) (*Client, error) {
|
||||
if endpoint == "" {
|
||||
return nil, fmt.Errorf("gRPC endpoint is required")
|
||||
}
|
||||
if tlsConfig == nil {
|
||||
return nil, fmt.Errorf("mTLS configuration is required")
|
||||
}
|
||||
conn, err := grpc.NewClient(endpoint, grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dial gRPC endpoint: %w", err)
|
||||
}
|
||||
return &Client{Conn: conn, Agent: agentv1.NewAgentControlServiceClient(conn)}, nil
|
||||
}
|
||||
|
||||
func DialFromFiles(endpoint, caFile, certFile, keyFile, serverName string) (*Client, error) {
|
||||
caPEM, err := readFile(caFile, "CA")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
certPEM, err := readFile(certFile, "certificate")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keyPEM, err := readFile(keyFile, "key")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tlsConfig, err := NewClientTLSConfig(caPEM, certPEM, keyPEM, serverName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Dial(endpoint, tlsConfig)
|
||||
}
|
||||
|
||||
func readFile(path, label string) ([]byte, error) {
|
||||
if path == "" {
|
||||
return nil, fmt.Errorf("%s file is required", label)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read %s file: %w", label, err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (c *Client) ReportExecutionEvent(ctx context.Context, request *agentv1.ReportExecutionEventRequest) (*agentv1.ReportExecutionEventResponse, error) {
|
||||
if c == nil || c.Agent == nil {
|
||||
return nil, fmt.Errorf("AgentControl client is not initialized")
|
||||
}
|
||||
return c.Agent.ReportExecutionEvent(ctx, request)
|
||||
}
|
||||
|
||||
func (c *Client) RequestUpload(ctx context.Context, request *agentv1.RequestUploadRequest) (*agentv1.RequestUploadResponse, error) {
|
||||
if c == nil || c.Agent == nil {
|
||||
return nil, fmt.Errorf("AgentControl client is not initialized")
|
||||
}
|
||||
return c.Agent.RequestUpload(ctx, request)
|
||||
}
|
||||
|
||||
func (c *Client) CompleteUpload(ctx context.Context, request *agentv1.CompleteUploadRequest) (*agentv1.CompleteUploadResponse, error) {
|
||||
if c == nil || c.Agent == nil {
|
||||
return nil, fmt.Errorf("AgentControl client is not initialized")
|
||||
}
|
||||
return c.Agent.CompleteUpload(ctx, request)
|
||||
}
|
||||
|
||||
func (c *Client) Close() error {
|
||||
if c == nil || c.Conn == nil {
|
||||
return nil
|
||||
}
|
||||
return c.Conn.Close()
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1"
|
||||
"git.ipao.vip/rogee/go-sip/internal/contract"
|
||||
"git.ipao.vip/rogee/go-sip/internal/store"
|
||||
"git.ipao.vip/rogee/go-sip/internal/tenant"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
)
|
||||
|
||||
type DispatcherEventServerOptions struct {
|
||||
RequirePeer bool
|
||||
PeerCertificateFingerprints map[string]struct{}
|
||||
AllowedAgentIDs map[string]struct{}
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// DispatcherEventServer owns the Dispatcher side of R11. It persists the
|
||||
// received fact and the derived authoritative MQ event through the same
|
||||
// SQLite transaction; it never accepts an Agent-chosen aggregate version.
|
||||
type DispatcherEventServer struct {
|
||||
store *store.Store
|
||||
now func() time.Time
|
||||
requirePeer bool
|
||||
peerCertificateFingerprints map[string]struct{}
|
||||
allowedAgentIDs map[string]struct{}
|
||||
}
|
||||
|
||||
func NewDispatcherEventServer(st *store.Store, options DispatcherEventServerOptions) (*DispatcherEventServer, error) {
|
||||
if st == nil {
|
||||
return nil, errors.New("Dispatcher event server requires store")
|
||||
}
|
||||
now := options.Now
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &DispatcherEventServer{
|
||||
store: st, now: now, requirePeer: options.RequirePeer,
|
||||
peerCertificateFingerprints: cloneStringSet(options.PeerCertificateFingerprints),
|
||||
allowedAgentIDs: cloneStringSet(options.AllowedAgentIDs),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *DispatcherEventServer) ReportExecutionEvent(ctx context.Context, req *agentv1.ReportExecutionEventRequest) (*agentv1.ReportExecutionEventResponse, error) {
|
||||
if req == nil || req.Meta == nil || req.Fact == nil || req.Fact.Binding == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "request metadata, fact and binding are required")
|
||||
}
|
||||
if err := validateDispatcherPeer(ctx, req.Meta, s.requirePeer, s.peerCertificateFingerprints, s.allowedAgentIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := requireIdempotency(req.Meta); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fact := req.Fact
|
||||
binding := fact.Binding
|
||||
if fact.FactId == "" || fact.ContentSha256 == "" || fact.SourceBootId == "" || fact.ObservedAtUnixMs <= 0 || len(fact.PayloadJson) == 0 {
|
||||
return nil, status.Error(codes.InvalidArgument, "fact ID, content digest, source boot, observed time and payload are required")
|
||||
}
|
||||
if binding.TenantId == "" || binding.TenantKey == "" || binding.ExecutionId == "" {
|
||||
return nil, status.Error(codes.InvalidArgument, "fact tenant and execution binding are required")
|
||||
}
|
||||
if err := contract.ValidateTenantKey(binding.TenantKey); err != nil {
|
||||
return nil, status.Error(codes.InvalidArgument, err.Error())
|
||||
}
|
||||
if req.Meta.AgentId == "" || req.Meta.CellId == "" || req.Meta.BootId == "" {
|
||||
return nil, status.Error(codes.InvalidArgument, "Agent, Cell and boot identity are required")
|
||||
}
|
||||
payload := make(map[string]any)
|
||||
if err := json.Unmarshal(fact.PayloadJson, &payload); err != nil || payload == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "fact payload must be a JSON object")
|
||||
}
|
||||
|
||||
eventType, aggregateType, aggregateID, eventPayload, err := deriveFactEvent(fact.Kind, binding, payload)
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.InvalidArgument, err.Error())
|
||||
}
|
||||
eventID := ""
|
||||
var eventBuilder store.FactEventBuilder
|
||||
if eventType != "" {
|
||||
eventID = "execution-fact-" + fact.FactId
|
||||
eventBuilder = func(aggregateVersion int64) ([]byte, error) {
|
||||
return (contract.EventBuilder{
|
||||
TenantID: binding.TenantId, TenantKey: binding.TenantKey, TraceID: req.Meta.TraceId,
|
||||
EventType: eventType, Aggregate: aggregateType, AggregateID: aggregateID,
|
||||
Version: aggregateVersion, Payload: eventPayload,
|
||||
}).Marshal(s.now(), eventID)
|
||||
}
|
||||
}
|
||||
bindingJSON, err := protojson.Marshal(binding)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "encode fact binding: %v", err)
|
||||
}
|
||||
record := store.ExecutionFactRecord{
|
||||
FactID: fact.FactId, TenantID: binding.TenantId, TenantKey: binding.TenantKey,
|
||||
ExecutionID: binding.ExecutionId, ContentSHA256: fact.ContentSha256, Kind: int32(fact.Kind),
|
||||
BindingJSON: bindingJSON, PayloadJSON: append([]byte(nil), fact.PayloadJson...),
|
||||
ObservedAt: time.UnixMilli(fact.ObservedAtUnixMs), SourceBootID: fact.SourceBootId,
|
||||
SourceSequence: fact.SourceSequence, EventID: eventID, EventType: eventType,
|
||||
AggregateType: aggregateType, AggregateID: aggregateID,
|
||||
}
|
||||
routingKey := ""
|
||||
if eventType != "" {
|
||||
routingKey = "agent-call." + eventType
|
||||
}
|
||||
result, err := s.store.RecordExecutionFact(record, tenant.EventExchange, routingKey, eventBuilder)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrFactConflict) {
|
||||
return &agentv1.ReportExecutionEventResponse{Receipt: dispatcherReceipt(req.Meta, s.now(), agentv1.ResultCode_RESULT_CODE_CONFLICT, agentv1.FailureCode_FAILURE_CODE_ABORTED, err.Error(), fact.FactId, fact.ContentSha256)}, nil
|
||||
}
|
||||
return nil, status.Errorf(codes.Internal, "persist execution fact: %v", err)
|
||||
}
|
||||
message := "fact accepted and event persisted"
|
||||
if result.Duplicate {
|
||||
message = "duplicate fact"
|
||||
}
|
||||
return &agentv1.ReportExecutionEventResponse{Receipt: dispatcherReceipt(req.Meta, s.now(), agentv1.ResultCode_RESULT_CODE_ACCEPTED, agentv1.FailureCode_FAILURE_CODE_UNSPECIFIED, message, fact.FactId, fact.ContentSha256)}, nil
|
||||
}
|
||||
|
||||
func deriveFactEvent(kind agentv1.FactKind, binding *agentv1.ExecutionBinding, payload map[string]any) (string, string, string, map[string]any, error) {
|
||||
var eventType, aggregateType string
|
||||
switch kind {
|
||||
case agentv1.FactKind_FACT_KIND_EXECUTION_ACCEPTED:
|
||||
eventType, aggregateType = "command.result", "command"
|
||||
case agentv1.FactKind_FACT_KIND_CALL_STATUS:
|
||||
eventType, aggregateType = "call.status", "call"
|
||||
case agentv1.FactKind_FACT_KIND_CALL_FINISHED:
|
||||
eventType, aggregateType = "call.finished", "call"
|
||||
case agentv1.FactKind_FACT_KIND_TRANSCRIPT_UPDATED:
|
||||
eventType, aggregateType = "transcript.updated", "transcript_segment"
|
||||
case agentv1.FactKind_FACT_KIND_TRANSCRIPT_FAILED:
|
||||
eventType, aggregateType = "transcript.failed", "transcript"
|
||||
case agentv1.FactKind_FACT_KIND_CONTACT_OPT_OUT:
|
||||
eventType, aggregateType = "contact.opt_out", "call"
|
||||
case agentv1.FactKind_FACT_KIND_RECORDING_PROGRESS:
|
||||
return "", "execution_fact", binding.ExecutionId, payload, nil
|
||||
default:
|
||||
return "", "", "", nil, fmt.Errorf("unsupported fact kind %s", kind.String())
|
||||
}
|
||||
|
||||
aggregateID := firstPayloadString(payload, "call_id", "segment_id", "command_id", "execution_id")
|
||||
if aggregateID == "" {
|
||||
aggregateID = binding.CallId
|
||||
}
|
||||
if aggregateID == "" {
|
||||
aggregateID = binding.ExecutionId
|
||||
}
|
||||
if aggregateID == "" {
|
||||
return "", "", "", nil, errors.New("fact payload or binding must provide aggregate ID")
|
||||
}
|
||||
return eventType, aggregateType, aggregateID, payload, nil
|
||||
}
|
||||
|
||||
func firstPayloadString(payload map[string]any, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value, ok := payload[key].(string); ok && value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func dispatcherReceipt(meta *agentv1.RequestMeta, now time.Time, result agentv1.ResultCode, failure agentv1.FailureCode, detail, factID, digest string) *agentv1.OperationReceipt {
|
||||
receipt := &agentv1.OperationReceipt{
|
||||
Meta: &agentv1.ResponseMeta{
|
||||
ProtocolVersion: meta.ProtocolVersion, RequestId: meta.RequestId, TraceId: meta.TraceId,
|
||||
OperationId: meta.OperationId, ObservedAtUnixMs: now.UnixMilli(),
|
||||
DispatcherEpoch: meta.DispatcherEpoch, AgentId: meta.AgentId, CellId: meta.CellId,
|
||||
BootId: meta.BootId, SessionGeneration: meta.SessionGeneration,
|
||||
},
|
||||
Result: result, FactId: factID, ContentSha256: digest, AcceptedAtUnixMs: now.UnixMilli(),
|
||||
}
|
||||
if failure != agentv1.FailureCode_FAILURE_CODE_UNSPECIFIED {
|
||||
receipt.Failure = &agentv1.Failure{Code: failure, Detail: detail}
|
||||
}
|
||||
return receipt
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.ipao.vip/rogee/go-sip/contracts"
|
||||
agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1"
|
||||
"git.ipao.vip/rogee/go-sip/internal/store"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
func TestDispatcherServerReportsFactAndEmitsOneAuthoritativeEvent(t *testing.T) {
|
||||
st, err := store.Open(filepath.Join(t.TempDir(), "dispatcher.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
clock := time.Date(2026, 9, 20, 12, 0, 0, 0, time.UTC)
|
||||
events, err := NewDispatcherEventServer(st, DispatcherEventServerOptions{Now: func() time.Time { return clock }})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
uploads, err := NewDispatcherUploadServer(st, nil, func() time.Time { return clock }, false)
|
||||
if err == nil || uploads != nil {
|
||||
t.Fatal("expected upload handler to reject nil OSS client")
|
||||
}
|
||||
server := NewDispatcherServer(nil, events)
|
||||
payload := eventPayload(t, "examples/event-call-status.json")
|
||||
var payloadObject map[string]any
|
||||
if err := json.Unmarshal(payload, &payloadObject); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payloadObject["call_version"] = 999
|
||||
payload, err = json.Marshal(payloadObject)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
meta := &agentv1.RequestMeta{
|
||||
ProtocolVersion: "agent.v1", RequestId: "request-fact-1", TraceId: "trace-fact-1",
|
||||
OperationId: "operation-fact-1", IdempotencyKey: "idempotency-fact-1", AgentId: "agent-cell-a",
|
||||
CellId: "cell-a", BootId: "boot-a",
|
||||
}
|
||||
binding := &agentv1.ExecutionBinding{
|
||||
TenantId: "tenant-1", TenantKey: "tenant-demo-key", ExecutionId: "execution-1",
|
||||
TaskId: "task-1", TaskItemId: "item-1", TaskRevision: 1, CallId: "call-1", AttemptId: "attempt-1",
|
||||
}
|
||||
fact := &agentv1.ExecutionFact{
|
||||
FactId: "fact-1", ContentSha256: "sha256-fact-1", Kind: agentv1.FactKind_FACT_KIND_CALL_STATUS,
|
||||
Binding: binding, PayloadJson: payload, ObservedAtUnixMs: clock.UnixMilli(), SourceBootId: "boot-a", SourceSequence: 1,
|
||||
}
|
||||
request := &agentv1.ReportExecutionEventRequest{Meta: meta, Fact: fact}
|
||||
response, err := server.ReportExecutionEvent(context.Background(), request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.Receipt == nil || response.Receipt.Result != agentv1.ResultCode_RESULT_CODE_ACCEPTED {
|
||||
t.Fatalf("unexpected receipt: %+v", response)
|
||||
}
|
||||
var body []byte
|
||||
if err := st.DB().QueryRow(`SELECT body FROM outbox WHERE event_id = ?`, "execution-fact-fact-1").Scan(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var eventEnvelope map[string]any
|
||||
if err := json.Unmarshal(body, &eventEnvelope); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if version, ok := eventEnvelope["aggregate_version"].(float64); !ok || version != 1 {
|
||||
t.Fatalf("Dispatcher did not allocate aggregate version 1: %#v", eventEnvelope["aggregate_version"])
|
||||
}
|
||||
var facts, outbox int
|
||||
if err := st.DB().QueryRow(`SELECT COUNT(*) FROM execution_facts`).Scan(&facts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.DB().QueryRow(`SELECT COUNT(*) FROM outbox WHERE event_id = ?`, "execution-fact-fact-1").Scan(&outbox); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if facts != 1 || outbox != 1 {
|
||||
t.Fatalf("facts=%d outbox=%d, want 1/1", facts, outbox)
|
||||
}
|
||||
|
||||
duplicate, err := server.ReportExecutionEvent(context.Background(), request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if duplicate.Receipt == nil || duplicate.Receipt.Result != agentv1.ResultCode_RESULT_CODE_ACCEPTED {
|
||||
t.Fatalf("duplicate was not accepted: %+v", duplicate)
|
||||
}
|
||||
if err := st.DB().QueryRow(`SELECT COUNT(*) FROM outbox WHERE event_id = ?`, "execution-fact-fact-1").Scan(&outbox); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if outbox != 1 {
|
||||
t.Fatalf("duplicate created %d outbox rows", outbox)
|
||||
}
|
||||
|
||||
secondFact := proto.Clone(fact).(*agentv1.ExecutionFact)
|
||||
secondFact.FactId = "fact-2"
|
||||
secondFact.ContentSha256 = "sha256-fact-2"
|
||||
secondFact.SourceSequence = 2
|
||||
second, err := server.ReportExecutionEvent(context.Background(), &agentv1.ReportExecutionEventRequest{Meta: meta, Fact: secondFact})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if second.Receipt == nil || second.Receipt.Result != agentv1.ResultCode_RESULT_CODE_ACCEPTED {
|
||||
t.Fatalf("second fact was not accepted: %+v", second)
|
||||
}
|
||||
if err := st.DB().QueryRow(`SELECT body FROM outbox WHERE event_id = ?`, "execution-fact-fact-2").Scan(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(body, &eventEnvelope); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if version, ok := eventEnvelope["aggregate_version"].(float64); !ok || version != 2 {
|
||||
t.Fatalf("Dispatcher did not allocate aggregate version 2: %#v", eventEnvelope["aggregate_version"])
|
||||
}
|
||||
|
||||
conflictFact := proto.Clone(fact).(*agentv1.ExecutionFact)
|
||||
conflictFact.ContentSha256 = "sha256-fact-conflict"
|
||||
conflict, err := server.ReportExecutionEvent(context.Background(), &agentv1.ReportExecutionEventRequest{Meta: meta, Fact: conflictFact})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if conflict.Receipt == nil || conflict.Receipt.Result != agentv1.ResultCode_RESULT_CODE_CONFLICT {
|
||||
t.Fatalf("digest conflict was not rejected: %+v", conflict)
|
||||
}
|
||||
}
|
||||
|
||||
func eventPayload(t *testing.T, name string) []byte {
|
||||
t.Helper()
|
||||
raw, err := contracts.Read(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var event struct {
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &event); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return event.Payload
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/peer"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// DispatcherServer is the single Dispatcher-side AgentControl gRPC service.
|
||||
// Upload RPCs and Agent-reported facts share this listener; the service is not
|
||||
// an upload-only endpoint.
|
||||
type dispatcherEventReporter interface {
|
||||
ReportExecutionEvent(context.Context, *agentv1.ReportExecutionEventRequest) (*agentv1.ReportExecutionEventResponse, error)
|
||||
}
|
||||
|
||||
type DispatcherServer struct {
|
||||
agentv1.UnimplementedAgentControlServiceServer
|
||||
uploads *DispatcherUploadServer
|
||||
events dispatcherEventReporter
|
||||
}
|
||||
|
||||
func NewDispatcherServer(uploads *DispatcherUploadServer, eventHandlers ...dispatcherEventReporter) *DispatcherServer {
|
||||
var events dispatcherEventReporter
|
||||
if len(eventHandlers) > 0 {
|
||||
events = eventHandlers[0]
|
||||
}
|
||||
return &DispatcherServer{uploads: uploads, events: events}
|
||||
}
|
||||
|
||||
func (s *DispatcherServer) RequestUpload(ctx context.Context, req *agentv1.RequestUploadRequest) (*agentv1.RequestUploadResponse, error) {
|
||||
if s.uploads == nil {
|
||||
return nil, status.Error(codes.Unimplemented, "Dispatcher upload handler is not configured")
|
||||
}
|
||||
return s.uploads.RequestUpload(ctx, req)
|
||||
}
|
||||
|
||||
func (s *DispatcherServer) CompleteUpload(ctx context.Context, req *agentv1.CompleteUploadRequest) (*agentv1.CompleteUploadResponse, error) {
|
||||
if s.uploads == nil {
|
||||
return nil, status.Error(codes.Unimplemented, "Dispatcher upload handler is not configured")
|
||||
}
|
||||
return s.uploads.CompleteUpload(ctx, req)
|
||||
}
|
||||
|
||||
func (s *DispatcherServer) ReportExecutionEvent(ctx context.Context, req *agentv1.ReportExecutionEventRequest) (*agentv1.ReportExecutionEventResponse, error) {
|
||||
if s.events == nil {
|
||||
return nil, status.Error(codes.Unimplemented, "Dispatcher execution-event receiver is not configured")
|
||||
}
|
||||
return s.events.ReportExecutionEvent(ctx, req)
|
||||
}
|
||||
|
||||
func validateDispatcherPeer(ctx context.Context, meta *agentv1.RequestMeta, requirePeer bool, fingerprints, allowedAgentIDs map[string]struct{}) error {
|
||||
if meta == nil {
|
||||
return status.Error(codes.InvalidArgument, "request metadata is required")
|
||||
}
|
||||
if len(allowedAgentIDs) > 0 {
|
||||
if _, ok := allowedAgentIDs[meta.AgentId]; !ok {
|
||||
return status.Error(codes.PermissionDenied, "Agent identity is not allowed for Dispatcher RPCs")
|
||||
}
|
||||
}
|
||||
if !requirePeer {
|
||||
return nil
|
||||
}
|
||||
p, ok := peer.FromContext(ctx)
|
||||
if !ok || p.AuthInfo == nil {
|
||||
return status.Error(codes.Unauthenticated, "verified mTLS peer is required")
|
||||
}
|
||||
tlsInfo, ok := p.AuthInfo.(credentials.TLSInfo)
|
||||
if !ok || len(tlsInfo.State.VerifiedChains) == 0 || len(tlsInfo.State.VerifiedChains[0]) == 0 {
|
||||
return status.Error(codes.Unauthenticated, "verified mTLS peer is required")
|
||||
}
|
||||
if len(fingerprints) == 0 {
|
||||
return status.Error(codes.PermissionDenied, "Dispatcher gRPC mTLS peer allowlist is not configured")
|
||||
}
|
||||
fingerprint := CertificateFingerprint(tlsInfo.State.VerifiedChains[0][0])
|
||||
if _, allowed := fingerprints[fingerprint]; !allowed {
|
||||
return status.Error(codes.PermissionDenied, "mTLS certificate is not allowed for Dispatcher RPCs")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1"
|
||||
"git.ipao.vip/rogee/go-sip/internal/agent"
|
||||
"git.ipao.vip/rogee/go-sip/internal/contract"
|
||||
ossclient "git.ipao.vip/rogee/go-sip/internal/oss"
|
||||
"git.ipao.vip/rogee/go-sip/internal/store"
|
||||
"git.ipao.vip/rogee/go-sip/internal/tenant"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
// DispatcherUploadServer is the Dispatcher-owned upload boundary. It keeps
|
||||
// credentials and durable upload state on the Dispatcher and gives Agents only
|
||||
// short-lived presigned PUT grants.
|
||||
type DispatcherUploadServer struct {
|
||||
agentv1.UnimplementedAgentControlServiceServer
|
||||
|
||||
store *store.Store
|
||||
oss *ossclient.Client
|
||||
now func() time.Time
|
||||
requirePeer bool
|
||||
maxAssetBytes int64
|
||||
peerCertificateFingerprints map[string]struct{}
|
||||
allowedAgentIDs map[string]struct{}
|
||||
}
|
||||
|
||||
type DispatcherUploadOptions struct {
|
||||
RequirePeer bool
|
||||
PeerCertificateFingerprints map[string]struct{}
|
||||
AllowedAgentIDs map[string]struct{}
|
||||
}
|
||||
|
||||
func NewDispatcherUploadServer(st *store.Store, client *ossclient.Client, now func() time.Time, requirePeer bool) (*DispatcherUploadServer, error) {
|
||||
return NewDispatcherUploadServerWithOptions(st, client, now, DispatcherUploadOptions{RequirePeer: requirePeer})
|
||||
}
|
||||
|
||||
func NewDispatcherUploadServerWithOptions(st *store.Store, client *ossclient.Client, now func() time.Time, options DispatcherUploadOptions) (*DispatcherUploadServer, error) {
|
||||
if st == nil {
|
||||
return nil, errors.New("upload server requires Dispatcher store")
|
||||
}
|
||||
if client == nil {
|
||||
return nil, errors.New("upload server requires OSS client")
|
||||
}
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &DispatcherUploadServer{
|
||||
store: st, oss: client, now: now, requirePeer: options.RequirePeer,
|
||||
maxAssetBytes: client.Config().MaxAssetBytes,
|
||||
peerCertificateFingerprints: cloneStringSet(options.PeerCertificateFingerprints),
|
||||
allowedAgentIDs: cloneStringSet(options.AllowedAgentIDs),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *DispatcherUploadServer) RequestUpload(ctx context.Context, req *agentv1.RequestUploadRequest) (*agentv1.RequestUploadResponse, error) {
|
||||
if err := s.validateRequest(ctx, req.GetMeta(), req.GetBinding(), req.GetAsset(), req.GetUploadId()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.Asset.SizeBytes <= 0 || req.Asset.ChecksumSha256 == "" {
|
||||
return s.requestUploadFailure(req.Meta, agentv1.FailureCode_FAILURE_CODE_INVALID_ARGUMENT, "asset size and SHA-256 are required", false), nil
|
||||
}
|
||||
if s.maxAssetBytes > 0 && req.Asset.SizeBytes > s.maxAssetBytes {
|
||||
return s.requestUploadFailure(req.Meta, agentv1.FailureCode_FAILURE_CODE_RESOURCE_EXHAUSTED, "asset exceeds Dispatcher OSS limit", false), nil
|
||||
}
|
||||
|
||||
record, err := s.store.LoadUpload(req.UploadId)
|
||||
if err == nil {
|
||||
binding, asset, grant, decodeErr := decodeUploadRecord(record)
|
||||
if decodeErr != nil {
|
||||
return nil, status.Errorf(codes.Internal, "decode durable upload %q: %v", req.UploadId, decodeErr)
|
||||
}
|
||||
if !proto.Equal(binding, req.Binding) || !proto.Equal(asset, req.Asset) {
|
||||
return s.requestUploadFailure(req.Meta, agentv1.FailureCode_FAILURE_CODE_ABORTED, "upload ID is bound to a different execution or asset", false), nil
|
||||
}
|
||||
detail := "duplicate upload request"
|
||||
if record.State == "granted" && grant.ExpiresAtUnixMs <= s.now().UnixMilli() {
|
||||
// This path is reached only when the caller explicitly requests a new
|
||||
// token after the previous one expired. Agent upload flow does not
|
||||
// renew or retry automatically.
|
||||
replacement, grantErr := s.oss.Grant(ctx, req.UploadId, record.ObjectKey, asset.ChecksumSha256, asset.SizeBytes, s.now())
|
||||
if grantErr != nil {
|
||||
return nil, status.Errorf(codes.Internal, "issue replacement OSS upload grant: %v", grantErr)
|
||||
}
|
||||
replacementRaw, marshalErr := proto.Marshal(replacement)
|
||||
if marshalErr != nil {
|
||||
return nil, status.Errorf(codes.Internal, "encode replacement upload grant: %v", marshalErr)
|
||||
}
|
||||
if replaceErr := s.store.ReplaceUploadGrant(req.UploadId, record.ObjectKey, replacementRaw); replaceErr != nil {
|
||||
return nil, status.Errorf(codes.Internal, "persist replacement upload grant: %v", replaceErr)
|
||||
}
|
||||
grant = replacement
|
||||
detail = "expired upload grant replaced after explicit request"
|
||||
}
|
||||
return &agentv1.RequestUploadResponse{
|
||||
Receipt: s.receipt(req.Meta, agentv1.ResultCode_RESULT_CODE_ACCEPTED, detail, false),
|
||||
Grant: proto.Clone(grant).(*agentv1.UploadGrant),
|
||||
State: uploadStateForRecord(record.State),
|
||||
}, nil
|
||||
} else if !errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, status.Errorf(codes.Internal, "load upload: %v", err)
|
||||
}
|
||||
|
||||
objectKey := s.objectKey(req.Binding, req.Asset)
|
||||
grant, err := s.oss.Grant(ctx, req.UploadId, objectKey, req.Asset.ChecksumSha256, req.Asset.SizeBytes, s.now())
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "create OSS upload grant: %v", err)
|
||||
}
|
||||
bindingRaw, err := proto.Marshal(req.Binding)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "encode upload binding: %v", err)
|
||||
}
|
||||
assetRaw, err := proto.Marshal(req.Asset)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "encode upload asset: %v", err)
|
||||
}
|
||||
grantRaw, err := proto.Marshal(grant)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "encode upload grant: %v", err)
|
||||
}
|
||||
if err := s.store.InsertUpload(store.UploadRecord{
|
||||
UploadID: req.UploadId,
|
||||
Binding: bindingRaw,
|
||||
Asset: assetRaw,
|
||||
Grant: grantRaw,
|
||||
ObjectKey: objectKey,
|
||||
State: "granted",
|
||||
CreatedAt: s.now().UTC(),
|
||||
}); err != nil {
|
||||
// A concurrent duplicate is safe to reconcile by reading the durable row.
|
||||
if existing, loadErr := s.store.LoadUpload(req.UploadId); loadErr == nil {
|
||||
binding, asset, grant, decodeErr := decodeUploadRecord(existing)
|
||||
if decodeErr == nil && proto.Equal(binding, req.Binding) && proto.Equal(asset, req.Asset) {
|
||||
return &agentv1.RequestUploadResponse{Receipt: s.receipt(req.Meta, agentv1.ResultCode_RESULT_CODE_ACCEPTED, "duplicate upload request", false), Grant: grant, State: uploadStateForRecord(existing.State)}, nil
|
||||
}
|
||||
}
|
||||
return nil, status.Errorf(codes.Internal, "persist upload grant: %v", err)
|
||||
}
|
||||
return &agentv1.RequestUploadResponse{
|
||||
Receipt: s.receipt(req.Meta, agentv1.ResultCode_RESULT_CODE_ACCEPTED, "upload grant issued", false),
|
||||
Grant: grant,
|
||||
State: agentv1.UploadState_UPLOAD_STATE_REQUESTED,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *DispatcherUploadServer) CompleteUpload(ctx context.Context, req *agentv1.CompleteUploadRequest) (*agentv1.CompleteUploadResponse, error) {
|
||||
if err := s.validateRequest(ctx, req.GetMeta(), req.GetBinding(), req.GetAsset(), req.GetUploadId()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.UploadedSizeBytes <= 0 || req.UploadedChecksumSha256 == "" {
|
||||
return s.completeUploadFailure(req.Meta, agentv1.FailureCode_FAILURE_CODE_INVALID_ARGUMENT, "uploaded size and SHA-256 are required", false), nil
|
||||
}
|
||||
record, err := s.store.LoadUpload(req.UploadId)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return s.completeUploadFailure(req.Meta, agentv1.FailureCode_FAILURE_CODE_NOT_FOUND, "upload not found", false), nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "load upload: %v", err)
|
||||
}
|
||||
binding, asset, grant, err := decodeUploadRecord(record)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "decode durable upload %q: %v", req.UploadId, err)
|
||||
}
|
||||
if !proto.Equal(binding, req.Binding) || !proto.Equal(asset, req.Asset) {
|
||||
return s.completeUploadFailure(req.Meta, agentv1.FailureCode_FAILURE_CODE_ABORTED, "upload completion binding does not match request", false), nil
|
||||
}
|
||||
if asset.SizeBytes != req.UploadedSizeBytes || !strings.EqualFold(asset.ChecksumSha256, req.UploadedChecksumSha256) || req.UploadedSizeBytes > grant.MaxBytes {
|
||||
return s.completeUploadFailure(req.Meta, agentv1.FailureCode_FAILURE_CODE_INVALID_ARGUMENT, "uploaded asset does not match grant", false), nil
|
||||
}
|
||||
if record.State == "completed" {
|
||||
if record.OSSID == "" {
|
||||
return nil, status.Error(codes.Internal, "completed upload has no OSS ID")
|
||||
}
|
||||
return &agentv1.CompleteUploadResponse{Receipt: s.receipt(req.Meta, agentv1.ResultCode_RESULT_CODE_ACCEPTED, "duplicate upload completion", false), State: agentv1.UploadState_UPLOAD_STATE_COMPLETED, OssId: record.OSSID}, nil
|
||||
}
|
||||
if grant.ExpiresAtUnixMs <= s.now().UnixMilli() {
|
||||
return s.completeUploadFailure(req.Meta, agentv1.FailureCode_FAILURE_CODE_FAILED_PRECONDITION, "upload grant has expired", true), nil
|
||||
}
|
||||
if err := s.oss.Verify(ctx, record.ObjectKey, req.UploadedChecksumSha256, req.UploadedSizeBytes); err != nil {
|
||||
return s.completeUploadFailure(req.Meta, agentv1.FailureCode_FAILURE_CODE_FAILED_PRECONDITION, fmt.Sprintf("verify OSS object: %v", err), true), nil
|
||||
}
|
||||
if asset.Kind != agentv1.AssetKind_ASSET_KIND_RECORDING {
|
||||
return s.completeUploadFailure(req.Meta, agentv1.FailureCode_FAILURE_CODE_INVALID_ARGUMENT, "only recording assets can be marked recording.ready", false), nil
|
||||
}
|
||||
ossID := s.oss.ObjectID(record.ObjectKey)
|
||||
completedAt := s.now().UTC()
|
||||
eventID := "recording-ready-" + req.UploadId
|
||||
event, err := (agent.EventWriter{
|
||||
TenantID: binding.TenantId, TenantKey: binding.TenantKey, TraceID: req.Meta.TraceId,
|
||||
}).RecordingReady(completedAt, eventID, asset.CallId, asset.AssetId, ossID, asset.Format, asset.Channels, asset.SampleRateHz, asset.DurationMs, asset.SizeBytes, asset.ChecksumSha256)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "build verified recording event: %v", err)
|
||||
}
|
||||
if err := s.store.CompleteUploadAndOutbox(req.UploadId, ossID, completedAt, eventID, binding.TenantKey, tenant.EventExchange, "agent-call.recording.ready", event); err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "persist completed upload and event: %v", err)
|
||||
}
|
||||
return &agentv1.CompleteUploadResponse{Receipt: s.receipt(req.Meta, agentv1.ResultCode_RESULT_CODE_ACCEPTED, "OSS upload verified and recording.ready persisted", false), State: agentv1.UploadState_UPLOAD_STATE_COMPLETED, OssId: ossID}, nil
|
||||
}
|
||||
|
||||
func (s *DispatcherUploadServer) validateRequest(ctx context.Context, meta *agentv1.RequestMeta, binding *agentv1.ExecutionBinding, asset *agentv1.AssetDescriptor, uploadID string) error {
|
||||
if meta == nil || binding == nil || asset == nil {
|
||||
return status.Error(codes.InvalidArgument, "request metadata, binding and asset are required")
|
||||
}
|
||||
if meta.AgentId == "" || meta.CellId == "" || meta.OperationId == "" || meta.IdempotencyKey == "" {
|
||||
return status.Error(codes.InvalidArgument, "agent, Cell, operation and idempotency metadata are required")
|
||||
}
|
||||
if binding.ExecutionId == "" || binding.TaskId == "" || binding.TenantId == "" || binding.TenantKey == "" {
|
||||
return status.Error(codes.InvalidArgument, "complete execution binding is required")
|
||||
}
|
||||
if err := contract.ValidateTenantKey(binding.TenantKey); err != nil {
|
||||
return status.Errorf(codes.InvalidArgument, "invalid tenant key: %v", err)
|
||||
}
|
||||
if asset.AssetId == "" || uploadID == "" {
|
||||
return status.Error(codes.InvalidArgument, "asset ID and upload ID are required")
|
||||
}
|
||||
return validateDispatcherPeer(ctx, meta, s.requirePeer, s.peerCertificateFingerprints, s.allowedAgentIDs)
|
||||
}
|
||||
|
||||
func cloneStringSet(source map[string]struct{}) map[string]struct{} {
|
||||
if len(source) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make(map[string]struct{}, len(source))
|
||||
for value := range source {
|
||||
result[value] = struct{}{}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *DispatcherUploadServer) objectKey(binding *agentv1.ExecutionBinding, asset *agentv1.AssetDescriptor) string {
|
||||
prefix := strings.Trim(s.oss.Config().KeyPrefix, "/")
|
||||
input := binding.TenantKey + "\x00" + binding.ExecutionId + "\x00" + asset.AssetId
|
||||
digest := sha256.Sum256([]byte(input))
|
||||
name := hex.EncodeToString(digest[:])
|
||||
if prefix == "" {
|
||||
return name
|
||||
}
|
||||
return prefix + "/" + name
|
||||
}
|
||||
|
||||
func decodeUploadRecord(record store.UploadRecord) (*agentv1.ExecutionBinding, *agentv1.AssetDescriptor, *agentv1.UploadGrant, error) {
|
||||
binding := &agentv1.ExecutionBinding{}
|
||||
asset := &agentv1.AssetDescriptor{}
|
||||
grant := &agentv1.UploadGrant{}
|
||||
if err := proto.Unmarshal(record.Binding, binding); err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("binding: %w", err)
|
||||
}
|
||||
if err := proto.Unmarshal(record.Asset, asset); err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("asset: %w", err)
|
||||
}
|
||||
if err := proto.Unmarshal(record.Grant, grant); err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("grant: %w", err)
|
||||
}
|
||||
return binding, asset, grant, nil
|
||||
}
|
||||
|
||||
func uploadStateForRecord(state string) agentv1.UploadState {
|
||||
if state == "completed" {
|
||||
return agentv1.UploadState_UPLOAD_STATE_COMPLETED
|
||||
}
|
||||
if state == "failed" {
|
||||
return agentv1.UploadState_UPLOAD_STATE_FAILED
|
||||
}
|
||||
return agentv1.UploadState_UPLOAD_STATE_REQUESTED
|
||||
}
|
||||
|
||||
func (s *DispatcherUploadServer) responseMeta(meta *agentv1.RequestMeta) *agentv1.ResponseMeta {
|
||||
return &agentv1.ResponseMeta{ProtocolVersion: meta.ProtocolVersion, RequestId: meta.RequestId, TraceId: meta.TraceId, OperationId: meta.OperationId, ObservedAtUnixMs: s.now().UnixMilli(), DispatcherEpoch: meta.DispatcherEpoch, AgentId: meta.AgentId, CellId: meta.CellId, BootId: meta.BootId, SessionGeneration: meta.SessionGeneration}
|
||||
}
|
||||
|
||||
func (s *DispatcherUploadServer) receipt(meta *agentv1.RequestMeta, result agentv1.ResultCode, detail string, retryable bool) *agentv1.OperationReceipt {
|
||||
receipt := &agentv1.OperationReceipt{Meta: s.responseMeta(meta), Result: result, AcceptedAtUnixMs: s.now().UnixMilli()}
|
||||
if detail != "" {
|
||||
receipt.Failure = &agentv1.Failure{Code: agentv1.FailureCode_FAILURE_CODE_UNSPECIFIED, Detail: detail, Retryable: retryable}
|
||||
}
|
||||
return receipt
|
||||
}
|
||||
|
||||
func (s *DispatcherUploadServer) requestUploadFailure(meta *agentv1.RequestMeta, code agentv1.FailureCode, detail string, retryable bool) *agentv1.RequestUploadResponse {
|
||||
return &agentv1.RequestUploadResponse{Receipt: &agentv1.OperationReceipt{Meta: s.responseMeta(meta), Result: agentv1.ResultCode_RESULT_CODE_REJECTED, Failure: &agentv1.Failure{Code: code, Detail: detail, Retryable: retryable}, AcceptedAtUnixMs: s.now().UnixMilli()}, State: agentv1.UploadState_UPLOAD_STATE_FAILED}
|
||||
}
|
||||
|
||||
func (s *DispatcherUploadServer) completeUploadFailure(meta *agentv1.RequestMeta, code agentv1.FailureCode, detail string, retryable bool) *agentv1.CompleteUploadResponse {
|
||||
return &agentv1.CompleteUploadResponse{Receipt: &agentv1.OperationReceipt{Meta: s.responseMeta(meta), Result: agentv1.ResultCode_RESULT_CODE_REJECTED, Failure: &agentv1.Failure{Code: code, Detail: detail, Retryable: retryable}, AcceptedAtUnixMs: s.now().UnixMilli()}, State: agentv1.UploadState_UPLOAD_STATE_FAILED}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1"
|
||||
"git.ipao.vip/rogee/go-sip/internal/agent"
|
||||
ossclient "git.ipao.vip/rogee/go-sip/internal/oss"
|
||||
"git.ipao.vip/rogee/go-sip/internal/store"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
func TestAlibabaOSSDispatcherUploadDurableIntegration(t *testing.T) {
|
||||
if os.Getenv("AGENT_CALL_OSS_INTEGRATION") != "1" {
|
||||
t.Skip("set AGENT_CALL_OSS_INTEGRATION=1 to use the authorized Alibaba OSS test bucket")
|
||||
}
|
||||
client, err := ossclient.NewClient(ossclient.Config{
|
||||
Endpoint: os.Getenv("DISPATCHER_OSS_ENDPOINT"),
|
||||
Region: os.Getenv("DISPATCHER_OSS_REGION"),
|
||||
Bucket: os.Getenv("DISPATCHER_OSS_BUCKET"),
|
||||
AccessKeyID: os.Getenv("DISPATCHER_OSS_ACCESS_KEY_ID"),
|
||||
AccessKeySecret: os.Getenv("DISPATCHER_OSS_ACCESS_KEY_SECRET"),
|
||||
KeyPrefix: "agent-call/rpc-integration-tests",
|
||||
GrantTTL: 15 * time.Minute,
|
||||
MaxAssetBytes: 1 << 20,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "recording.wav")
|
||||
payload := []byte(strings.Repeat("dispatcher-upload-integration\n", 1024))
|
||||
if sourcePath := os.Getenv("OSS_INTEGRATION_RECORDING_PATH"); sourcePath != "" {
|
||||
var readErr error
|
||||
payload, readErr = os.ReadFile(sourcePath)
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
if len(payload) == 0 {
|
||||
t.Fatal("OSS_INTEGRATION_RECORDING_PATH is empty")
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(path, payload, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
digest := sha256.Sum256(payload)
|
||||
checksum := hex.EncodeToString(digest[:])
|
||||
binding := &agentv1.ExecutionBinding{TenantId: "tenant-integration", TenantKey: "tenant-integration", ExecutionId: "exec-oss-integration", TaskId: "task-oss-integration", TaskItemId: "item-oss-integration", TaskRevision: 1, CallId: "call-oss-integration", AttemptId: "attempt-oss-integration"}
|
||||
asset := &agentv1.AssetDescriptor{Kind: agentv1.AssetKind_ASSET_KIND_RECORDING, AssetId: "recording-integration", CallId: binding.CallId, ExecutionId: binding.ExecutionId, Format: "wav", SizeBytes: int64(len(payload)), ChecksumSha256: checksum, Channels: 1, SampleRateHz: 16000, DurationMs: 1000}
|
||||
requestMeta := &agentv1.RequestMeta{ProtocolVersion: "agent.v1", RequestId: "request-oss-integration", TraceId: "trace-oss-integration", OperationId: "operation-oss-integration", IdempotencyKey: "idempotency-oss-integration", AgentId: "agent-integration", CellId: "cell-integration"}
|
||||
st, err := store.Open(filepath.Join(t.TempDir(), "dispatcher.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
clock := time.Now()
|
||||
server, err := NewDispatcherUploadServer(st, client, func() time.Time { return clock }, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
uploadID := "upload-oss-integration"
|
||||
grantResponse, err := server.RequestUpload(context.Background(), &agentv1.RequestUploadRequest{Meta: requestMeta, Binding: binding, Asset: asset, UploadId: uploadID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if grantResponse.Receipt.Result != agentv1.ResultCode_RESULT_CODE_ACCEPTED || grantResponse.Grant == nil {
|
||||
t.Fatalf("grant rejected: %+v", grantResponse.Receipt)
|
||||
}
|
||||
clock = clock.Add(16 * time.Minute)
|
||||
retryMeta := proto.Clone(requestMeta).(*agentv1.RequestMeta)
|
||||
retryMeta.RequestId = "request-oss-integration-retry"
|
||||
retryMeta.TraceId = "trace-oss-integration-retry"
|
||||
retryMeta.OperationId = "operation-oss-integration-retry"
|
||||
retryMeta.IdempotencyKey = "idempotency-oss-integration-retry"
|
||||
reissuedResponse, err := server.RequestUpload(context.Background(), &agentv1.RequestUploadRequest{Meta: retryMeta, Binding: binding, Asset: asset, UploadId: uploadID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reissuedResponse.Grant.ExpiresAtUnixMs <= grantResponse.Grant.ExpiresAtUnixMs {
|
||||
t.Fatal("explicit post-expiry request did not receive a new grant")
|
||||
}
|
||||
grantResponse = reissuedResponse
|
||||
parsed, err := url.Parse(grantResponse.Grant.TargetUrl)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
uploader := agent.UploadClient{Now: time.Now, AllowedHosts: map[string]struct{}{strings.ToLower(parsed.Host): {}}}
|
||||
result, err := uploader.UploadFile(context.Background(), grantResponse.Grant, path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
completeResponse, err := server.CompleteUpload(context.Background(), &agentv1.CompleteUploadRequest{Meta: requestMeta, Binding: binding, Asset: asset, UploadId: uploadID, UploadedSizeBytes: result.SizeBytes, UploadedChecksumSha256: result.SHA256})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if completeResponse.Receipt.Result != agentv1.ResultCode_RESULT_CODE_ACCEPTED || completeResponse.OssId == "" {
|
||||
t.Fatalf("completion rejected: %+v", completeResponse.Receipt)
|
||||
}
|
||||
var outboxCount int
|
||||
if err := st.DB().QueryRow(`SELECT COUNT(*) FROM outbox WHERE event_id = ?`, "recording-ready-"+uploadID).Scan(&outboxCount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if outboxCount != 1 {
|
||||
t.Fatalf("recording.ready outbox rows = %d, want 1", outboxCount)
|
||||
}
|
||||
|
||||
requestAgain, err := server.RequestUpload(context.Background(), &agentv1.RequestUploadRequest{Meta: requestMeta, Binding: binding, Asset: asset, UploadId: uploadID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if requestAgain.Grant.ObjectKey != grantResponse.Grant.ObjectKey || requestAgain.Grant.ExpiresAtUnixMs != grantResponse.Grant.ExpiresAtUnixMs {
|
||||
t.Fatal("duplicate request changed a completed upload grant")
|
||||
}
|
||||
completeAgain, err := server.CompleteUpload(context.Background(), &agentv1.CompleteUploadRequest{Meta: requestMeta, Binding: binding, Asset: asset, UploadId: uploadID, UploadedSizeBytes: result.SizeBytes, UploadedChecksumSha256: result.SHA256})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if completeAgain.OssId != completeResponse.OssId || completeAgain.State != agentv1.UploadState_UPLOAD_STATE_COMPLETED {
|
||||
t.Fatalf("duplicate completion changed result: %+v", completeAgain)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,258 @@
|
||||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.ipao.vip/rogee/go-sip/contracts"
|
||||
agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1"
|
||||
"git.ipao.vip/rogee/go-sip/internal/contract"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/peer"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/grpc/test/bufconn"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"reflect"
|
||||
)
|
||||
|
||||
func TestPeerCertificateAllowlist(t *testing.T) {
|
||||
_, allowedCert, _ := testCertificate(t, nil, nil, false, []string{"dispatcher.local"}, nil)
|
||||
fingerprint := CertificateFingerprint(allowedCert)
|
||||
server := NewServer(ServerOptions{RequirePeerCertificate: true, PeerCertificateFingerprints: map[string]struct{}{fingerprint: {}}})
|
||||
allowedContext := peer.NewContext(context.Background(), &peer.Peer{AuthInfo: credentials.TLSInfo{State: tls.ConnectionState{VerifiedChains: [][]*x509.Certificate{{allowedCert}}}}})
|
||||
if err := server.checkPeer(allowedContext, "agent-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, rejectedCert, _ := testCertificate(t, nil, nil, false, []string{"other-dispatcher.local"}, nil)
|
||||
rejectedContext := peer.NewContext(context.Background(), &peer.Peer{AuthInfo: credentials.TLSInfo{State: tls.ConnectionState{VerifiedChains: [][]*x509.Certificate{{rejectedCert}}}}})
|
||||
if err := server.checkPeer(rejectedContext, "agent-1"); status.Code(err) != codes.PermissionDenied {
|
||||
t.Fatalf("got %v, want PermissionDenied", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAgentStatusSupportsPreActivationProbe(t *testing.T) {
|
||||
now := time.Unix(100, 0)
|
||||
server := NewServer(ServerOptions{
|
||||
Now: func() time.Time { return now },
|
||||
Status: &agentv1.AgentStatus{
|
||||
AgentId: "agent-1",
|
||||
CellId: "cell-1",
|
||||
BootId: "boot-current",
|
||||
SoftwareVersion: "test",
|
||||
ProtocolVersion: "agent.v1",
|
||||
AdmissionState: agentv1.AdmissionState_ADMISSION_STATE_CLOSED,
|
||||
},
|
||||
})
|
||||
response, err := server.GetAgentStatus(context.Background(), &agentv1.GetAgentStatusRequest{
|
||||
Meta: &agentv1.RequestMeta{ProtocolVersion: "agent.v1", RequestId: "probe-request", TraceId: "probe-trace", OperationId: "probe-operation", AgentId: "agent-1", CellId: "cell-1"},
|
||||
Target: &agentv1.AgentBinding{AgentId: "agent-1", CellId: "cell-1"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "boot-current", response.Status.BootId)
|
||||
require.Equal(t, false, response.Status.SessionActive)
|
||||
require.Equal(t, false, response.Status.MtlsAuthenticated)
|
||||
|
||||
active := activatedServer(now, t)
|
||||
activeResponse, err := active.GetAgentStatus(context.Background(), &agentv1.GetAgentStatusRequest{Meta: testMeta("status-active", "status-active-key", 1), Target: &agentv1.AgentBinding{AgentId: "agent-1", CellId: "cell-1"}})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, true, activeResponse.Status.SessionActive)
|
||||
}
|
||||
|
||||
func TestAgentIdentityIsBoundToConfiguredEndpoint(t *testing.T) {
|
||||
server := NewServer(ServerOptions{Status: &agentv1.AgentStatus{AgentId: "agent-1", CellId: "cell-1"}})
|
||||
_, err := server.GetAgentStatus(context.Background(), &agentv1.GetAgentStatusRequest{
|
||||
Meta: &agentv1.RequestMeta{ProtocolVersion: "agent.v1", RequestId: "wrong-probe", TraceId: "wrong-probe", OperationId: "wrong-probe", AgentId: "agent-2", CellId: "cell-2"},
|
||||
Target: &agentv1.AgentBinding{AgentId: "agent-2", CellId: "cell-2"},
|
||||
})
|
||||
require.Equal(t, codes.PermissionDenied, status.Code(err))
|
||||
|
||||
_, err = server.ActivateAgent(context.Background(), &agentv1.ActivateAgentRequest{
|
||||
Meta: &agentv1.RequestMeta{ProtocolVersion: "agent.v1", RequestId: "wrong-activate", TraceId: "wrong-activate", OperationId: "wrong-activate", AgentId: "agent-2", CellId: "cell-2", BootId: "boot-2", DispatcherEpoch: "epoch-2"},
|
||||
Binding: &agentv1.AgentBinding{AgentId: "agent-2", CellId: "cell-2", ExpectedBootId: "boot-2", DispatcherEpoch: "epoch-2", SessionGeneration: 1},
|
||||
ActivationOperationId: "wrong-activate",
|
||||
})
|
||||
require.Equal(t, codes.PermissionDenied, status.Code(err))
|
||||
}
|
||||
|
||||
func TestSessionGenerationFencesOlderRequests(t *testing.T) {
|
||||
now := time.Unix(100, 0)
|
||||
server := NewServer(ServerOptions{Now: func() time.Time { return now }})
|
||||
firstMeta := testMeta("activate-1", "", 0)
|
||||
firstMeta.OperationId = "activate-1"
|
||||
_, err := server.ActivateAgent(context.Background(), &agentv1.ActivateAgentRequest{
|
||||
Meta: firstMeta,
|
||||
Binding: &agentv1.AgentBinding{AgentId: "agent-1", CellId: "cell-1", ExpectedBootId: "boot-1", DispatcherEpoch: "epoch-1", SessionGeneration: 1},
|
||||
ActivationOperationId: "activate-1",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
secondMeta := testMeta("activate-2", "", 0)
|
||||
secondMeta.BootId = "boot-2"
|
||||
secondMeta.OperationId = "activate-2"
|
||||
_, err = server.ActivateAgent(context.Background(), &agentv1.ActivateAgentRequest{
|
||||
Meta: secondMeta,
|
||||
Binding: &agentv1.AgentBinding{AgentId: "agent-1", CellId: "cell-1", ExpectedBootId: "boot-2", DispatcherEpoch: "epoch-2", SessionGeneration: 2},
|
||||
ActivationOperationId: "activate-2",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = server.GetBootstrap(context.Background(), &agentv1.GetBootstrapRequest{Meta: testMeta("old", "read-old", 1)})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, codes.Aborted, status.Code(err))
|
||||
fresh := testMeta("fresh", "read-fresh", 2)
|
||||
fresh.BootId = "boot-2"
|
||||
fresh.DispatcherEpoch = "epoch-2"
|
||||
_, err = server.GetBootstrap(context.Background(), &agentv1.GetBootstrapRequest{Meta: fresh})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestExecuteIdempotencyAndBinding(t *testing.T) {
|
||||
now := time.Date(2026, 9, 18, 1, 0, 0, 0, time.UTC)
|
||||
server := activatedServer(now, t)
|
||||
raw, err := contracts.Read("examples/call.execute.json")
|
||||
require.NoError(t, err)
|
||||
envelope, payload, err := contract.DecodeExecute(raw)
|
||||
require.NoError(t, err)
|
||||
meta := testMeta("execute-1", "execute-key", 1)
|
||||
req := &agentv1.ExecuteRequest{Meta: meta, Binding: &agentv1.ExecutionBinding{TenantId: envelope.TenantID, TenantKey: envelope.TenantKey, ExecutionId: payload.ExecutionID, TaskId: payload.TaskID, TaskItemId: payload.TaskItemID, TaskRevision: payload.TaskRevision, AgentVersionId: payload.AgentVersionID}, CallExecuteJson: raw, ConfigSha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
|
||||
first, err := server.Execute(context.Background(), req)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, agentv1.ResultCode_RESULT_CODE_ACCEPTED, first.Receipt.Result)
|
||||
replay, err := server.Execute(context.Background(), req)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, first.Receipt.Meta.OperationId, replay.Receipt.Meta.OperationId)
|
||||
conflictReq := proto.Clone(req).(*agentv1.ExecuteRequest)
|
||||
conflictReq.ConfigSha256 = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||
conflict, err := server.Execute(context.Background(), conflictReq)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, agentv1.ResultCode_RESULT_CODE_CONFLICT, conflict.Receipt.Result)
|
||||
}
|
||||
|
||||
func TestRealModeRejectsExecutionOutsideCallWindow(t *testing.T) {
|
||||
server := NewServer(ServerOptions{
|
||||
Mode: "mixed",
|
||||
Now: func() time.Time { return time.Date(2026, 9, 18, 12, 0, 0, 0, time.UTC) },
|
||||
})
|
||||
activateTestServer(t, server)
|
||||
response, err := server.GetExecutionPermit(context.Background(), &agentv1.GetExecutionPermitRequest{
|
||||
Meta: testMeta("permit-window", "permit-window-key", 1),
|
||||
Binding: &agentv1.ExecutionBinding{ExecutionId: "execution-window"},
|
||||
ResourceReservationId: "reservation-window",
|
||||
})
|
||||
if response != nil || status.Code(err) != codes.FailedPrecondition {
|
||||
t.Fatalf("response=%+v err=%v code=%s", response, err, status.Code(err))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealModeRejectsMockUploadDataPlane(t *testing.T) {
|
||||
server := NewServer(ServerOptions{Mode: "real"})
|
||||
response, err := server.RequestUpload(context.Background(), &agentv1.RequestUploadRequest{})
|
||||
if response != nil || status.Code(err) != codes.Unimplemented {
|
||||
t.Fatalf("response=%+v err=%v code=%s", response, err, status.Code(err))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdmissionAndControlCAS(t *testing.T) {
|
||||
server := activatedServer(time.Date(2026, 9, 18, 1, 0, 0, 0, time.UTC), t)
|
||||
meta := testMeta("admission-1", "admission-key", 1)
|
||||
admission, err := server.SetAdmissionState(context.Background(), &agentv1.SetAdmissionStateRequest{Meta: meta, Target: &agentv1.AgentBinding{AgentId: "agent-1", CellId: "cell-1"}, State: agentv1.AdmissionState_ADMISSION_STATE_OPEN})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, uint64(1), admission.AppliedAdmissionGeneration)
|
||||
conflict, err := server.SetAdmissionState(context.Background(), &agentv1.SetAdmissionStateRequest{Meta: testMeta("admission-2", "admission-key-2", 1), Target: &agentv1.AgentBinding{AgentId: "agent-1", CellId: "cell-1"}, State: agentv1.AdmissionState_ADMISSION_STATE_CLOSED})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, agentv1.ResultCode_RESULT_CODE_CONFLICT, conflict.Receipt.Result)
|
||||
|
||||
raw, err := contracts.Read("examples/call.execute.json")
|
||||
require.NoError(t, err)
|
||||
envelope, payload, err := contract.DecodeExecute(raw)
|
||||
require.NoError(t, err)
|
||||
executeMeta := testMeta("execute-control", "execute-control-key", 1)
|
||||
_, err = server.Execute(context.Background(), &agentv1.ExecuteRequest{Meta: executeMeta, Binding: &agentv1.ExecutionBinding{TenantId: envelope.TenantID, TenantKey: envelope.TenantKey, ExecutionId: payload.ExecutionID, TaskId: payload.TaskID, TaskItemId: payload.TaskItemID, TaskRevision: payload.TaskRevision, AgentVersionId: payload.AgentVersionID}, CallExecuteJson: raw, ConfigSha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"})
|
||||
require.NoError(t, err)
|
||||
controlMeta := testMeta("control-1", "control-key", 1)
|
||||
paused, err := server.ApplyTaskControl(context.Background(), &agentv1.ApplyTaskControlRequest{Meta: controlMeta, Binding: &agentv1.ExecutionBinding{ExecutionId: payload.ExecutionID, TaskRevision: payload.TaskRevision}, Action: agentv1.ControlAction_CONTROL_ACTION_PAUSE, ExpectedTaskRevision: payload.TaskRevision})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, agentv1.ResultCode_RESULT_CODE_APPLIED, paused.Receipt.Result)
|
||||
stopped, err := server.ApplyTaskControl(context.Background(), &agentv1.ApplyTaskControlRequest{Meta: testMeta("control-2", "control-key-2", 1), Binding: &agentv1.ExecutionBinding{ExecutionId: payload.ExecutionID, TaskRevision: paused.AppliedTaskRevision}, Action: agentv1.ControlAction_CONTROL_ACTION_STOP, ExpectedTaskRevision: paused.AppliedTaskRevision})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, agentv1.ExecutionState_EXECUTION_STATE_TERMINAL, stopped.State)
|
||||
resumed, err := server.ApplyTaskControl(context.Background(), &agentv1.ApplyTaskControlRequest{Meta: testMeta("control-3", "control-key-3", 1), Binding: &agentv1.ExecutionBinding{ExecutionId: payload.ExecutionID, TaskRevision: stopped.AppliedTaskRevision}, Action: agentv1.ControlAction_CONTROL_ACTION_RESUME, ExpectedTaskRevision: stopped.AppliedTaskRevision})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, agentv1.ResultCode_RESULT_CODE_REJECTED, resumed.Receipt.Result)
|
||||
}
|
||||
|
||||
func TestFactDeduplication(t *testing.T) {
|
||||
server := activatedServer(time.Unix(100, 0), t)
|
||||
fact := &agentv1.ExecutionFact{FactId: "fact-1", ContentSha256: "digest-a", Binding: &agentv1.ExecutionBinding{ExecutionId: "execution-1"}, Kind: agentv1.FactKind_FACT_KIND_CALL_STATUS}
|
||||
first, err := server.ReportExecutionEvent(context.Background(), &agentv1.ReportExecutionEventRequest{Meta: testMeta("fact-1", "fact-key-1", 1), Fact: fact})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, agentv1.ResultCode_RESULT_CODE_ACCEPTED, first.Receipt.Result)
|
||||
replay, err := server.ReportExecutionEvent(context.Background(), &agentv1.ReportExecutionEventRequest{Meta: testMeta("fact-2", "fact-key-2", 1), Fact: fact})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, agentv1.ResultCode_RESULT_CODE_ACCEPTED, replay.Receipt.Result)
|
||||
fact.ContentSha256 = "digest-b"
|
||||
conflict, err := server.ReportExecutionEvent(context.Background(), &agentv1.ReportExecutionEventRequest{Meta: testMeta("fact-3", "fact-key-3", 1), Fact: fact})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, agentv1.ResultCode_RESULT_CODE_CONFLICT, conflict.Receipt.Result)
|
||||
}
|
||||
|
||||
func TestGeneratedUnaryServiceWiring(t *testing.T) {
|
||||
server := NewServer(ServerOptions{Now: func() time.Time { return time.Unix(100, 0) }})
|
||||
listener := bufconn.Listen(1024 * 1024)
|
||||
grpcServer := grpc.NewServer()
|
||||
agentv1.RegisterAgentControlServiceServer(grpcServer, server)
|
||||
go func() { _ = grpcServer.Serve(listener) }()
|
||||
defer grpcServer.Stop()
|
||||
conn, err := grpc.NewClient("passthrough:///bufnet", grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { return listener.Dial() }), grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
require.NoError(t, err)
|
||||
defer conn.Close()
|
||||
client := agentv1.NewAgentControlServiceClient(conn)
|
||||
meta := testMeta("activate-rpc", "", 0)
|
||||
response, err := client.ActivateAgent(context.Background(), &agentv1.ActivateAgentRequest{Meta: meta, Binding: &agentv1.AgentBinding{AgentId: "agent-1", CellId: "cell-1", ExpectedBootId: "boot-1", DispatcherEpoch: "epoch-1", SessionGeneration: 1}, ActivationOperationId: "activate-rpc"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, agentv1.ActivationState_ACTIVATION_STATE_ACTIVE, response.State)
|
||||
}
|
||||
|
||||
func activatedServer(now time.Time, t *testing.T) *Server {
|
||||
t.Helper()
|
||||
server := NewServer(ServerOptions{Now: func() time.Time { return now }, UploadPolicy: &agentv1.UploadPolicy{Enabled: true, MaxAssetBytes: 16 << 20}})
|
||||
meta := testMeta("activate", "", 0)
|
||||
_, err := server.ActivateAgent(context.Background(), &agentv1.ActivateAgentRequest{Meta: meta, Binding: &agentv1.AgentBinding{AgentId: "agent-1", CellId: "cell-1", ExpectedBootId: "boot-1", DispatcherEpoch: "epoch-1", SessionGeneration: 1}, ActivationOperationId: "activate"})
|
||||
require.NoError(t, err)
|
||||
return server
|
||||
}
|
||||
|
||||
type testAssertions struct{}
|
||||
|
||||
var require testAssertions
|
||||
|
||||
func (testAssertions) NoError(t *testing.T, err error, _ ...any) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (testAssertions) Error(t *testing.T, err error, _ ...any) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func (testAssertions) Equal(t *testing.T, expected, actual any, _ ...any) {
|
||||
t.Helper()
|
||||
if !reflect.DeepEqual(expected, actual) {
|
||||
t.Fatalf("expected %#v, got %#v", expected, actual)
|
||||
}
|
||||
}
|
||||
|
||||
func testMeta(operationID, idempotencyKey string, generation uint64) *agentv1.RequestMeta {
|
||||
return &agentv1.RequestMeta{ProtocolVersion: "agent.v1", RequestId: operationID + "-request", TraceId: "trace-1", OperationId: operationID, IdempotencyKey: idempotencyKey, DispatcherEpoch: "epoch-1", AgentId: "agent-1", CellId: "cell-1", BootId: "boot-1", SessionGeneration: generation}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package rpc
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
func TestSessionRegistryPersistsGenerationAcrossRestart(t *testing.T) {
|
||||
path := t.TempDir() + "/rpc-session.json"
|
||||
first := NewSessionRegistry(path)
|
||||
_, _, err := first.Activate(&agentv1.AgentBinding{AgentId: "agent-1", CellId: "cell-1", ExpectedBootId: "boot-1", DispatcherEpoch: "epoch-1", SessionGeneration: 3}, "activate-1", "digest-1", time.Unix(100, 0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second := NewSessionRegistry(path)
|
||||
_, _, err = second.Activate(&agentv1.AgentBinding{AgentId: "agent-1", CellId: "cell-1", ExpectedBootId: "boot-2", DispatcherEpoch: "epoch-2", SessionGeneration: 2}, "activate-2", "digest-2", time.Unix(100, 0))
|
||||
if status.Code(err) != codes.Aborted {
|
||||
t.Fatalf("error = %v, want persisted generation fence", err)
|
||||
}
|
||||
if _, _, err := second.Activate(&agentv1.AgentBinding{AgentId: "agent-1", CellId: "cell-1", ExpectedBootId: "boot-2", DispatcherEpoch: "epoch-2", SessionGeneration: 4}, "activate-3", "digest-3", time.Unix(100, 0)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.ipao.vip/rogee/go-sip/contracts"
|
||||
agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1"
|
||||
"git.ipao.vip/rogee/go-sip/internal/contract"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
func TestActivationValidatesStaticCellArtifact(t *testing.T) {
|
||||
raw, err := contracts.Read("examples/static-cell-artifact.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := NewServer(ServerOptions{
|
||||
Now: func() time.Time { return time.Unix(100, 0) },
|
||||
StaticArtifactRaw: raw,
|
||||
StaticArtifactExpected: contract.StaticArtifactExpectation{
|
||||
CellID: "cell-a",
|
||||
Mode: "mock",
|
||||
SourceRelease: "management-snapshot-1",
|
||||
SourceDigest: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
ConfigSHA256: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
AllowedEgressPoolIDs: []string{"egress-mock"},
|
||||
RequiredTrunkIDs: []string{"trunk-mock"},
|
||||
},
|
||||
})
|
||||
meta := testMeta("activate-static", "", 0)
|
||||
meta.CellId = "cell-a"
|
||||
response, err := server.ActivateAgent(context.Background(), &agentv1.ActivateAgentRequest{
|
||||
Meta: meta,
|
||||
Binding: &agentv1.AgentBinding{AgentId: "agent-1", CellId: "cell-a", ExpectedBootId: "boot-1", DispatcherEpoch: "epoch-1", SessionGeneration: 1},
|
||||
ActivationOperationId: "activate-static",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.State != agentv1.ActivationState_ACTIVATION_STATE_ACTIVE {
|
||||
t.Fatalf("activation state=%s", response.State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivationRejectsInvalidStaticCellArtifact(t *testing.T) {
|
||||
raw, err := contracts.Read("examples/static-cell-artifact.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := NewServer(ServerOptions{
|
||||
Now: func() time.Time { return time.Unix(100, 0) },
|
||||
StaticArtifactRaw: raw,
|
||||
StaticArtifactExpected: contract.StaticArtifactExpectation{CellID: "cell-b", Mode: "mock"},
|
||||
})
|
||||
meta := testMeta("activate-invalid-static", "", 0)
|
||||
meta.CellId = "cell-a"
|
||||
_, err = server.ActivateAgent(context.Background(), &agentv1.ActivateAgentRequest{
|
||||
Meta: meta,
|
||||
Binding: &agentv1.AgentBinding{AgentId: "agent-1", CellId: "cell-a", ExpectedBootId: "boot-1", DispatcherEpoch: "epoch-1", SessionGeneration: 1},
|
||||
ActivationOperationId: "activate-invalid-static",
|
||||
})
|
||||
if err == nil || status.Code(err) != codes.FailedPrecondition {
|
||||
t.Fatalf("invalid artifact error=%v code=%s", err, status.Code(err))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package rpc
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// NewServerTLSConfig builds the mTLS configuration used by a Dispatcher or
|
||||
// Agent listener. ServerName is intentionally not used to disable verification;
|
||||
// callers still verify the peer certificate against the supplied CA.
|
||||
// LoadServerTLSConfig reads deployment-provided certificate files without
|
||||
// exposing their contents to logs or repository state.
|
||||
func LoadServerTLSConfig(caFile, certFile, keyFile string) (*tls.Config, error) {
|
||||
caPEM, err := os.ReadFile(caFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read CA file: %w", err)
|
||||
}
|
||||
certPEM, err := os.ReadFile(certFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read certificate file: %w", err)
|
||||
}
|
||||
keyPEM, err := os.ReadFile(keyFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read key file: %w", err)
|
||||
}
|
||||
return NewServerTLSConfig(caPEM, certPEM, keyPEM)
|
||||
}
|
||||
|
||||
func NewServerTLSConfig(caPEM, certPEM, keyPEM []byte) (*tls.Config, error) {
|
||||
pool, err := certPool(caPEM)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
certificate, err := tls.X509KeyPair(certPEM, keyPEM)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load server certificate: %w", err)
|
||||
}
|
||||
return &tls.Config{
|
||||
MinVersion: tls.VersionTLS13,
|
||||
Certificates: []tls.Certificate{certificate},
|
||||
ClientCAs: pool,
|
||||
ClientAuth: tls.RequireAndVerifyClientCert,
|
||||
NextProtos: []string{"h2"},
|
||||
VerifyConnection: func(state tls.ConnectionState) error {
|
||||
if len(state.VerifiedChains) == 0 || len(state.VerifiedChains[0]) == 0 {
|
||||
return fmt.Errorf("verified peer certificate is required")
|
||||
}
|
||||
leaf := state.VerifiedChains[0][0]
|
||||
if len(leaf.DNSNames) == 0 && len(leaf.URIs) == 0 && len(leaf.IPAddresses) == 0 {
|
||||
return fmt.Errorf("peer certificate has no SAN")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewClientTLSConfig builds a peer-verifying mTLS client configuration.
|
||||
func NewClientTLSConfig(caPEM, certPEM, keyPEM []byte, serverName string) (*tls.Config, error) {
|
||||
pool, err := certPool(caPEM)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
certificate, err := tls.X509KeyPair(certPEM, keyPEM)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load client certificate: %w", err)
|
||||
}
|
||||
if serverName == "" {
|
||||
return nil, fmt.Errorf("server name is required for mTLS peer verification")
|
||||
}
|
||||
return &tls.Config{
|
||||
MinVersion: tls.VersionTLS13,
|
||||
Certificates: []tls.Certificate{certificate},
|
||||
RootCAs: pool,
|
||||
ServerName: serverName,
|
||||
NextProtos: []string{"h2"},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func certPool(pemBytes []byte) (*x509.CertPool, error) {
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM(pemBytes) {
|
||||
return nil, fmt.Errorf("CA bundle contains no certificates")
|
||||
}
|
||||
return pool, nil
|
||||
}
|
||||
|
||||
// CertificateFingerprint returns a stable identifier for a verified leaf. It
|
||||
// is suitable for an allow-list lookup, not for logging certificate contents.
|
||||
func CertificateFingerprint(cert *x509.Certificate) string {
|
||||
if cert == nil {
|
||||
return ""
|
||||
}
|
||||
digest := sha256.Sum256(cert.Raw)
|
||||
return hex.EncodeToString(digest[:])
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package rpc
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTLSConfigsRequireVerifiedSANPeer(t *testing.T) {
|
||||
caPEM, caCert, caKey := testCertificate(t, nil, nil, true, nil, nil)
|
||||
serverPEM, _, _ := testCertificate(t, caCert, caKey, false, []string{"agent.local"}, nil)
|
||||
clientPEM, _, _ := testCertificate(t, caCert, caKey, false, []string{"dispatcher.local"}, nil)
|
||||
|
||||
serverConfig, err := NewServerTLSConfig(caPEM.certPEM, serverPEM.certPEM, serverPEM.keyPEM)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if serverConfig.MinVersion != 0x0304 {
|
||||
t.Fatalf("MinVersion = %v, want TLS 1.3", serverConfig.MinVersion)
|
||||
}
|
||||
if serverConfig.ClientAuth != 4 {
|
||||
t.Fatalf("ClientAuth = %v, want RequireAndVerifyClientCert", serverConfig.ClientAuth)
|
||||
}
|
||||
clientConfig, err := NewClientTLSConfig(caPEM.certPEM, clientPEM.certPEM, clientPEM.keyPEM, "agent.local")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if clientConfig.ServerName != "agent.local" || clientConfig.RootCAs == nil {
|
||||
t.Fatalf("client config does not verify the configured server name")
|
||||
}
|
||||
if _, err := NewClientTLSConfig(caPEM.certPEM, clientPEM.certPEM, clientPEM.keyPEM, ""); err == nil {
|
||||
t.Fatal("expected empty server name to be rejected")
|
||||
}
|
||||
if CertificateFingerprint(caCert) == "" {
|
||||
t.Fatal("expected certificate fingerprint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTLSConfigsCompleteMutualHandshake(t *testing.T) {
|
||||
caPEM, caCert, caKey := testCertificate(t, nil, nil, true, nil, nil)
|
||||
serverPEM, _, _ := testCertificate(t, caCert, caKey, false, []string{"agent.local"}, nil)
|
||||
clientPEM, _, _ := testCertificate(t, caCert, caKey, false, []string{"dispatcher.local"}, nil)
|
||||
serverConfig, err := NewServerTLSConfig(caPEM.certPEM, serverPEM.certPEM, serverPEM.keyPEM)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clientConfig, err := NewClientTLSConfig(caPEM.certPEM, clientPEM.certPEM, clientPEM.keyPEM, "agent.local")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
listener, err := tls.Listen("tcp", "127.0.0.1:0", serverConfig)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer listener.Close()
|
||||
serverDone := make(chan error, 1)
|
||||
go func() {
|
||||
conn, acceptErr := listener.Accept()
|
||||
if acceptErr != nil {
|
||||
serverDone <- acceptErr
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
serverDone <- conn.(*tls.Conn).Handshake()
|
||||
}()
|
||||
client, err := tls.Dial("tcp", listener.Addr().String(), clientConfig)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := client.Handshake(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = client.Close()
|
||||
if err := <-serverDone; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTLSConfigsRejectMissingOrUntrustedClient(t *testing.T) {
|
||||
caPEM, caCert, caKey := testCertificate(t, nil, nil, true, nil, nil)
|
||||
serverPEM, _, _ := testCertificate(t, caCert, caKey, false, []string{"agent.local"}, nil)
|
||||
serverConfig, err := NewServerTLSConfig(caPEM.certPEM, serverPEM.certPEM, serverPEM.keyPEM)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, rogueCACert, rogueCAKey := testCertificate(t, nil, nil, true, nil, nil)
|
||||
rogueClientPEM, _, _ := testCertificate(t, rogueCACert, rogueCAKey, false, []string{"dispatcher.local"}, nil)
|
||||
trustedServerPool, err := certPool(caPEM.certPEM)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clients := map[string]*tls.Config{
|
||||
"missing client certificate": {
|
||||
MinVersion: tls.VersionTLS13,
|
||||
RootCAs: trustedServerPool,
|
||||
ServerName: "agent.local",
|
||||
NextProtos: []string{"h2"},
|
||||
},
|
||||
"untrusted client certificate": func() *tls.Config {
|
||||
clientConfig, configErr := NewClientTLSConfig(caPEM.certPEM, rogueClientPEM.certPEM, rogueClientPEM.keyPEM, "agent.local")
|
||||
if configErr != nil {
|
||||
t.Fatal(configErr)
|
||||
}
|
||||
return clientConfig
|
||||
}(),
|
||||
}
|
||||
for name, clientConfig := range clients {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
listener, listenErr := tls.Listen("tcp", "127.0.0.1:0", serverConfig)
|
||||
if listenErr != nil {
|
||||
t.Fatal(listenErr)
|
||||
}
|
||||
defer listener.Close()
|
||||
serverDone := make(chan error, 1)
|
||||
go func() {
|
||||
conn, acceptErr := listener.Accept()
|
||||
if acceptErr != nil {
|
||||
serverDone <- acceptErr
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
serverDone <- conn.(*tls.Conn).Handshake()
|
||||
}()
|
||||
client, dialErr := tls.Dial("tcp", listener.Addr().String(), clientConfig)
|
||||
if client != nil {
|
||||
_ = client.Close()
|
||||
}
|
||||
_ = dialErr
|
||||
if serverErr := <-serverDone; serverErr == nil {
|
||||
t.Fatal("expected server handshake to reject the client")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTLSRotationRejectsPreviousClientCA(t *testing.T) {
|
||||
caOnePEM, caOneCert, caOneKey := testCertificate(t, nil, nil, true, nil, nil)
|
||||
serverOnePEM, _, _ := testCertificate(t, caOneCert, caOneKey, false, []string{"agent.local"}, nil)
|
||||
clientOnePEM, _, _ := testCertificate(t, caOneCert, caOneKey, false, []string{"dispatcher.local"}, nil)
|
||||
serverOneConfig, err := NewServerTLSConfig(caOnePEM.certPEM, serverOnePEM.certPEM, serverOnePEM.keyPEM)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clientOneConfig, err := NewClientTLSConfig(caOnePEM.certPEM, clientOnePEM.certPEM, clientOnePEM.keyPEM, "agent.local")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := runTLSHandshake(t, serverOneConfig, clientOneConfig); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
caTwoPEM, caTwoCert, caTwoKey := testCertificate(t, nil, nil, true, nil, nil)
|
||||
serverTwoPEM, _, _ := testCertificate(t, caTwoCert, caTwoKey, false, []string{"agent.local"}, nil)
|
||||
clientTwoPEM, _, _ := testCertificate(t, caTwoCert, caTwoKey, false, []string{"dispatcher.local"}, nil)
|
||||
serverTwoConfig, err := NewServerTLSConfig(caTwoPEM.certPEM, serverTwoPEM.certPEM, serverTwoPEM.keyPEM)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clientTwoConfig, err := NewClientTLSConfig(caTwoPEM.certPEM, clientTwoPEM.certPEM, clientTwoPEM.keyPEM, "agent.local")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := runTLSHandshake(t, serverTwoConfig, clientOneConfig); err == nil {
|
||||
t.Fatal("expected previous client CA to be rejected after rotation")
|
||||
}
|
||||
if err := runTLSHandshake(t, serverTwoConfig, clientTwoConfig); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialRequiresTLSConfig(t *testing.T) {
|
||||
if _, err := Dial("bufnet", nil); err == nil {
|
||||
t.Fatal("expected TLS configuration requirement")
|
||||
}
|
||||
}
|
||||
|
||||
func runTLSHandshake(t *testing.T, serverConfig, clientConfig *tls.Config) error {
|
||||
t.Helper()
|
||||
listener, err := tls.Listen("tcp", "127.0.0.1:0", serverConfig)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer listener.Close()
|
||||
serverDone := make(chan error, 1)
|
||||
go func() {
|
||||
conn, acceptErr := listener.Accept()
|
||||
if acceptErr != nil {
|
||||
serverDone <- acceptErr
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
serverDone <- conn.(*tls.Conn).Handshake()
|
||||
}()
|
||||
client, clientErr := tls.Dial("tcp", listener.Addr().String(), clientConfig)
|
||||
if client != nil {
|
||||
_ = client.Close()
|
||||
}
|
||||
serverErr := <-serverDone
|
||||
if clientErr != nil {
|
||||
return clientErr
|
||||
}
|
||||
return serverErr
|
||||
}
|
||||
|
||||
func testCertificate(t *testing.T, parent *x509.Certificate, parentKey *rsa.PrivateKey, isCA bool, dnsNames []string, ips []net.IP) (pemBundle, *x509.Certificate, *rsa.PrivateKey) {
|
||||
t.Helper()
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 120))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Now()
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{CommonName: "sip-go-agent-test"},
|
||||
NotBefore: now.Add(-time.Minute),
|
||||
NotAfter: now.Add(time.Hour),
|
||||
BasicConstraintsValid: true,
|
||||
IsCA: isCA,
|
||||
DNSNames: dnsNames,
|
||||
IPAddresses: ips,
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
}
|
||||
if isCA {
|
||||
template.KeyUsage |= x509.KeyUsageCertSign
|
||||
}
|
||||
if !isCA {
|
||||
template.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}
|
||||
}
|
||||
if parent == nil {
|
||||
parent = template
|
||||
parentKey = key
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, template, parent, &key.PublicKey, parentKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cert, err := x509.ParseCertificate(der)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return pemBundle{
|
||||
certPEM: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}),
|
||||
keyPEM: pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}),
|
||||
}, cert, key
|
||||
}
|
||||
|
||||
type pemBundle struct {
|
||||
certPEM []byte
|
||||
keyPEM []byte
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
func TestUploadBindingAndExpiryGuards(t *testing.T) {
|
||||
now := time.Unix(100, 0)
|
||||
server := NewServer(ServerOptions{
|
||||
Now: func() time.Time { return now },
|
||||
UploadPolicy: &agentv1.UploadPolicy{Enabled: true, MaxAssetBytes: 1024},
|
||||
})
|
||||
_, err := server.ActivateAgent(context.Background(), &agentv1.ActivateAgentRequest{
|
||||
Meta: testMeta("activate-upload", "", 0),
|
||||
Binding: &agentv1.AgentBinding{AgentId: "agent-1", CellId: "cell-1", ExpectedBootId: "boot-1", DispatcherEpoch: "epoch-1", SessionGeneration: 1},
|
||||
ActivationOperationId: "activate-upload",
|
||||
SessionExpiresAtUnixMs: now.Add(time.Hour).UnixMilli(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
binding := &agentv1.ExecutionBinding{TenantId: "tenant-1", TenantKey: "tenant-demo-key", ExecutionId: "execution-upload", TaskId: "task-upload", TaskItemId: "item-upload", TaskRevision: 1}
|
||||
asset := &agentv1.AssetDescriptor{Kind: agentv1.AssetKind_ASSET_KIND_RECORDING, AssetId: "recording-upload", ExecutionId: binding.ExecutionId, Format: "wav", SizeBytes: 4, ChecksumSha256: strings.Repeat("a", 64), Channels: 1, SampleRateHz: 16000, DurationMs: 1}
|
||||
request := &agentv1.RequestUploadRequest{Meta: testMeta("upload-request", "upload-request-key", 1), Binding: binding, Asset: asset, UploadId: "upload-binding"}
|
||||
created, err := server.RequestUpload(context.Background(), request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if created.Grant == nil || created.Grant.ExpiresAtUnixMs <= now.UnixMilli() {
|
||||
t.Fatalf("invalid grant: %+v", created.Grant)
|
||||
}
|
||||
|
||||
mismatched := proto.Clone(request).(*agentv1.RequestUploadRequest)
|
||||
mismatched.Meta = testMeta("upload-conflict", "upload-conflict-key", 1)
|
||||
mismatched.Binding = proto.Clone(binding).(*agentv1.ExecutionBinding)
|
||||
mismatched.Binding.ExecutionId = "other-execution"
|
||||
conflict, err := server.RequestUpload(context.Background(), mismatched)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if conflict.Receipt == nil || conflict.Receipt.Result != agentv1.ResultCode_RESULT_CODE_CONFLICT {
|
||||
t.Fatalf("unexpected upload conflict: %+v", conflict)
|
||||
}
|
||||
|
||||
now = now.Add(6 * time.Minute)
|
||||
completed, err := server.CompleteUpload(context.Background(), &agentv1.CompleteUploadRequest{
|
||||
Meta: testMeta("upload-complete-expired", "upload-complete-expired-key", 1),
|
||||
Binding: binding,
|
||||
Asset: asset,
|
||||
UploadId: request.UploadId,
|
||||
UploadedSizeBytes: asset.SizeBytes,
|
||||
UploadedChecksumSha256: asset.ChecksumSha256,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if completed.Receipt == nil || completed.Receipt.Result != agentv1.ResultCode_RESULT_CODE_REJECTED || completed.Receipt.Failure == nil || completed.Receipt.Failure.Code != agentv1.FailureCode_FAILURE_CODE_FAILED_PRECONDITION {
|
||||
t.Fatalf("unexpected expired completion: %+v", completed)
|
||||
}
|
||||
if status.Code(err) != codes.OK {
|
||||
t.Fatalf("unexpected status code: %s", status.Code(err))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user