Apply approved SIP snapshots through isolated Dispatcher and Agent channel
This commit is contained in:
@@ -32,8 +32,10 @@ type ServerOptions struct {
|
||||
PeerAgentIDs map[string]string
|
||||
PeerCertificateFingerprints map[string]struct{}
|
||||
StatePath string
|
||||
// LoadedSIP reports the revision the mock Agent actually loaded; nil fails closed.
|
||||
// LoadedSIP reports the revision the Agent actually observed in Asterisk; nil fails closed.
|
||||
LoadedSIP func(context.Context) (map[string]int64, error)
|
||||
// ApplySIP writes and reloads only a validated, Dispatcher-approved full snapshot.
|
||||
ApplySIP func(context.Context, []byte) (map[string]int64, error)
|
||||
// The mock may have issued a call even if its outcome is unknown.
|
||||
MockApprovedOriginate func(context.Context, ApprovedExecution) error
|
||||
// ApprovedTaskCalls is shared with the approved call runner; nil rejects task controls.
|
||||
@@ -53,6 +55,8 @@ type Server struct {
|
||||
peerAgentIDs map[string]string
|
||||
peerCertificateFingerprints map[string]struct{}
|
||||
loadedSIP func(context.Context) (map[string]int64, error)
|
||||
applySIP func(context.Context, []byte) (map[string]int64, error)
|
||||
sipMu sync.Mutex
|
||||
mockApprovedOriginate func(context.Context, ApprovedExecution) error
|
||||
approvedTaskCalls *agent.TaskCalls
|
||||
sessions *SessionRegistry
|
||||
@@ -87,6 +91,7 @@ func NewServer(options ServerOptions) *Server {
|
||||
peerAgentIDs: cloneStringMap(options.PeerAgentIDs),
|
||||
peerCertificateFingerprints: cloneSet(options.PeerCertificateFingerprints),
|
||||
loadedSIP: options.LoadedSIP,
|
||||
applySIP: options.ApplySIP,
|
||||
mockApprovedOriginate: options.MockApprovedOriginate,
|
||||
approvedTaskCalls: options.ApprovedTaskCalls,
|
||||
sessions: NewSessionRegistry(options.StatePath),
|
||||
|
||||
@@ -15,7 +15,7 @@ func TestAgentControlServiceOnlyExposesApprovedMethods(t *testing.T) {
|
||||
}
|
||||
want := []string{
|
||||
"GetAgentStatus", "ActivateAgent",
|
||||
"ExecuteApproved", "ApplyApprovedTaskControl", "GetLoadedSIP",
|
||||
"ExecuteApproved", "ApplyApprovedTaskControl", "GetLoadedSIP", "ApplySIP",
|
||||
"RequestRecordingUpload", "ReportCallEnded", "ReportCallResult",
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
|
||||
agentpb "git.ipao.vip/rogee/go-sip/gen/agent"
|
||||
"git.ipao.vip/rogee/go-sip/internal/asterisk"
|
||||
"git.ipao.vip/rogee/go-sip/internal/configread"
|
||||
"google.golang.org/grpc/codes"
|
||||
"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
|
||||
// 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 {
|
||||
return nil, status.Error(codes.InvalidArgument, "approved SIP request or bounded snapshot is required")
|
||||
}
|
||||
if err := s.authorize(ctx, req.Meta); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dispatcherID, err := s.sessions.ApprovedDispatcher(req.Meta, s.now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.mode != "sip-only" || s.applySIP == nil {
|
||||
return nil, status.Error(codes.FailedPrecondition, "real SIP apply is unavailable on this Agent")
|
||||
}
|
||||
var approved configread.SIP
|
||||
if err := json.Unmarshal(req.ApprovedSnapshotJson, &approved); err != nil || approved.DispatcherID != dispatcherID || approved.Revision <= 0 {
|
||||
return nil, status.Error(codes.InvalidArgument, "SIP snapshot owner or content is invalid")
|
||||
}
|
||||
if _, err := asterisk.Render(approved); err != nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "SIP snapshot is not approved for native reload")
|
||||
}
|
||||
var trunks []struct {
|
||||
ID string `json:"trunk_id"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := json.Unmarshal(approved.Trunks, &trunks); err != nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "SIP trunk list is invalid")
|
||||
}
|
||||
expected := make(map[string]bool)
|
||||
for _, trunk := range trunks {
|
||||
if trunk.Enabled {
|
||||
expected[trunk.ID] = true
|
||||
}
|
||||
}
|
||||
s.sipMu.Lock()
|
||||
defer s.sipMu.Unlock()
|
||||
loaded, err := s.applySIP(ctx, req.ApprovedSnapshotJson)
|
||||
if err != nil {
|
||||
slog.Error("native SIP apply failed", "dispatcher_id", dispatcherID, "revision", approved.Revision, "error", err)
|
||||
return nil, status.Error(codes.Unavailable, "native Asterisk SIP reload failed")
|
||||
}
|
||||
if len(loaded) != len(expected) {
|
||||
slog.Error("native SIP loaded-set mismatch", "dispatcher_id", dispatcherID, "revision", approved.Revision, "expected_count", len(expected), "loaded_count", len(loaded))
|
||||
return nil, status.Error(codes.Unavailable, "native Asterisk SIP loaded-set mismatch")
|
||||
}
|
||||
for trunk, revision := range loaded {
|
||||
if !expected[trunk] || revision != approved.Revision {
|
||||
slog.Error("native SIP loaded-revision mismatch", "dispatcher_id", dispatcherID, "revision", approved.Revision, "trunk_id", trunk, "observed_revision", revision)
|
||||
return nil, status.Error(codes.Unavailable, "native Asterisk SIP loaded-revision mismatch")
|
||||
}
|
||||
}
|
||||
return &agentpb.ApplySIPResponse{TrunkRevision: loaded}, nil
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentpb "git.ipao.vip/rogee/go-sip/gen/agent"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
func TestApplySIPOnlyAllowsActivatedSIPService(t *testing.T) {
|
||||
now := time.Date(2026, 10, 3, 9, 0, 0, 0, time.UTC)
|
||||
const id = "c046b893-8628-4589-ae50-619d049248a6"
|
||||
attempts := 0
|
||||
apply := func(_ context.Context, _ []byte) (map[string]int64, error) {
|
||||
attempts++
|
||||
return map[string]int64{"trunk-mock": 8}, nil
|
||||
}
|
||||
server := NewServer(ServerOptions{
|
||||
Mode: "sip-only", Now: func() time.Time { return now },
|
||||
Status: &agentpb.AgentStatus{AgentId: "agent-1", CellId: "cell-1", BootId: "boot-1"},
|
||||
ApplySIP: apply,
|
||||
})
|
||||
_, err := server.ActivateAgent(context.Background(), &agentpb.ActivateAgentRequest{
|
||||
Meta: testMeta("activate-sip", "", 0),
|
||||
Binding: &agentpb.AgentBinding{AgentId: "agent-1", CellId: "cell-1", ExpectedBootId: "boot-1", DispatcherEpoch: "epoch-1", SessionGeneration: 1, DispatcherId: id},
|
||||
ActivationOperationId: "activate-sip",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := os.ReadFile("../../contracts/local/examples/config-read-sip.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body = []byte(strings.ReplaceAll(string(body), `"transport":null`, `"transport":"udp"`))
|
||||
body = []byte(strings.ReplaceAll(string(body), `"auth_mode":null`, `"auth_mode":"ip"`))
|
||||
body = []byte(strings.ReplaceAll(string(body), `"registration_required":null`, `"registration_required":false`))
|
||||
body = []byte(strings.ReplaceAll(string(body), `"server_host":"sip.example.invalid"`, `"server_host":"127.0.0.1"`))
|
||||
req := &agentpb.ApplySIPRequest{Meta: testMeta("apply-sip", "apply-sip", 1), ApprovedSnapshotJson: body}
|
||||
if _, err := server.ApplySIP(context.Background(), req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if attempts != 1 {
|
||||
t.Fatalf("apply attempts = %d", attempts)
|
||||
}
|
||||
server.mode = "mock"
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user