Apply approved SIP snapshots through isolated Dispatcher and Agent channel
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
package asterisk
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.ipao.vip/rogee/go-sip/internal/configread"
|
||||
)
|
||||
|
||||
// Loader writes only the Agent-owned endpoint include. A reviewed static
|
||||
// pjsip.conf owns the transport, whose reload can interrupt live calls.
|
||||
type Loader struct {
|
||||
ConfigDir string
|
||||
Asterisk string
|
||||
LibraryDir string
|
||||
}
|
||||
|
||||
type loadedTrunk struct {
|
||||
ID string `json:"id"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
}
|
||||
|
||||
type appliedState struct {
|
||||
Revision int64 `json:"revision"`
|
||||
Hash string `json:"hash"`
|
||||
SnapshotHash string `json:"snapshot_hash"`
|
||||
Trunks []loadedTrunk `json:"trunks"`
|
||||
}
|
||||
|
||||
var endpointLine = regexp.MustCompile(`(?m)^\s*Endpoint:\s+(\S+)`)
|
||||
|
||||
func (l Loader) paths() (string, string, error) {
|
||||
if l.ConfigDir == "" || l.Asterisk == "" || l.LibraryDir == "" {
|
||||
return "", "", errors.New("native Asterisk executable, library and config paths are required")
|
||||
}
|
||||
base, err := os.ReadFile(filepath.Join(l.ConfigDir, "pjsip.conf"))
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("read management-owned PJSIP base: %w", err)
|
||||
}
|
||||
if !bytes.Contains(base, []byte("#tryinclude go-sip-managed.conf")) || !bytes.Contains(base, []byte("[go-sip-udp]")) {
|
||||
return "", "", errors.New("PJSIP base is missing the approved static transport and managed include")
|
||||
}
|
||||
return filepath.Join(l.ConfigDir, "go-sip-managed.conf"), filepath.Join(l.ConfigDir, "go-sip-applied.json"), nil
|
||||
}
|
||||
|
||||
func (l Loader) command(ctx context.Context, name string, args ...string) ([]byte, error) {
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
cmd.Env = append(os.Environ(), "LD_LIBRARY_PATH="+l.LibraryDir)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("native Asterisk command %q failed: %w (output_sha256=%x)", filepath.Base(name), err, sha256.Sum256(out))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (l Loader) cli(ctx context.Context, command string) ([]byte, error) {
|
||||
return l.command(ctx, l.Asterisk, "-C", filepath.Join(l.ConfigDir, "asterisk.conf"), "-rx", command)
|
||||
}
|
||||
|
||||
func (l Loader) observe(ctx context.Context, state appliedState) error {
|
||||
if state.Revision < 1 || state.SnapshotHash == "" || len(state.Trunks) == 0 {
|
||||
return errors.New("no verified active SIP trunks")
|
||||
}
|
||||
transport, err := l.cli(ctx, "pjsip show transports")
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect native UDP transport: %w", err)
|
||||
}
|
||||
approvedTransport := false
|
||||
for _, line := range strings.Split(string(transport), "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 6 && fields[0] == "Transport:" && fields[1] == "go-sip-udp" && fields[2] == "udp" && fields[len(fields)-1] == "0.0.0.0:5060" {
|
||||
approvedTransport = true
|
||||
}
|
||||
}
|
||||
if !approvedTransport {
|
||||
return errors.New("approved native UDP transport/address is not loaded")
|
||||
}
|
||||
all, err := l.cli(ctx, "pjsip show endpoints")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
expected := make(map[string]loadedTrunk, len(state.Trunks))
|
||||
for _, t := range state.Trunks {
|
||||
expected[t.ID] = t
|
||||
}
|
||||
seen := make(map[string]bool)
|
||||
for _, match := range endpointLine.FindAllSubmatch(all, -1) {
|
||||
id := string(match[1])
|
||||
if !trunkName.MatchString(id) { // skip Asterisk's <Endpoint/CID> heading
|
||||
continue
|
||||
}
|
||||
if _, ok := expected[id]; !ok {
|
||||
return fmt.Errorf("unapproved Asterisk endpoint %q is loaded", id)
|
||||
}
|
||||
seen[id] = true
|
||||
}
|
||||
if len(seen) != len(expected) {
|
||||
return errors.New("approved SIP endpoint set is not loaded")
|
||||
}
|
||||
for _, t := range state.Trunks {
|
||||
endpoint, err := l.cli(ctx, "pjsip show endpoint "+t.ID)
|
||||
if err != nil || !bytes.Contains(endpoint, []byte(t.ID+"-aor")) || !bytes.Contains(endpoint, []byte("alaw")) || !bytes.Contains(endpoint, []byte("go-sip-udp")) || !bytes.Contains(endpoint, []byte("go-sip-no-inbound")) {
|
||||
return fmt.Errorf("SIP endpoint %q did not load its approved AOR/codec", t.ID)
|
||||
}
|
||||
aor, err := l.cli(ctx, "pjsip show aor "+t.ID+"-aor")
|
||||
if err != nil || !bytes.Contains(aor, []byte(fmt.Sprintf("sip:%s:%d", t.Host, t.Port))) {
|
||||
return fmt.Errorf("SIP AOR %q did not load its approved contact", t.ID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadedSIP never trusts a persisted revision alone: it also checks the file
|
||||
// hash and live PJSIP objects after every Agent boot or Dispatcher query.
|
||||
func (l Loader) LoadedSIP(ctx context.Context) (map[string]int64, error) {
|
||||
config, statePath, err := l.paths()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := os.ReadFile(statePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read last verified SIP revision: %w", err)
|
||||
}
|
||||
var state appliedState
|
||||
if err := json.Unmarshal(raw, &state); err != nil {
|
||||
return nil, fmt.Errorf("decode last verified SIP revision: %w", err)
|
||||
}
|
||||
data, err := os.ReadFile(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hash := sha256.Sum256(data)
|
||||
if hex.EncodeToString(hash[:]) != state.Hash {
|
||||
return nil, errors.New("SIP config no longer matches last verified revision")
|
||||
}
|
||||
if err := l.observe(ctx, state); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
loaded := make(map[string]int64, len(state.Trunks))
|
||||
for _, t := range state.Trunks {
|
||||
loaded[t.ID] = state.Revision
|
||||
}
|
||||
return loaded, nil
|
||||
}
|
||||
|
||||
// Apply changes no transport, routes or existing call. It records a revision
|
||||
// only after the native module reload and readback succeed.
|
||||
func (l Loader) Apply(ctx context.Context, approvedJSON []byte) (map[string]int64, error) {
|
||||
config, statePath, err := l.paths()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var sip configread.SIP
|
||||
if err := json.Unmarshal(approvedJSON, &sip); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The same renderer is used by RPC validation and disk application.
|
||||
text, err := Render(sip)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var trunks []struct {
|
||||
ID string `json:"trunk_id"`
|
||||
Host string `json:"server_host"`
|
||||
Port int `json:"server_port"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := json.Unmarshal(sip.Trunks, &trunks); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
canonical, err := json.Marshal(sip)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
state := appliedState{Revision: sip.Revision, Hash: fmt.Sprintf("%x", sha256.Sum256([]byte(text))), SnapshotHash: fmt.Sprintf("%x", sha256.Sum256(canonical))}
|
||||
for _, t := range trunks {
|
||||
if t.Enabled {
|
||||
state.Trunks = append(state.Trunks, loadedTrunk{ID: t.ID, Host: t.Host, Port: t.Port})
|
||||
}
|
||||
}
|
||||
sort.Slice(state.Trunks, func(i, j int) bool { return state.Trunks[i].ID < state.Trunks[j].ID })
|
||||
recoverExisting := false
|
||||
if existing, err := os.ReadFile(statePath); err == nil {
|
||||
var previous appliedState
|
||||
if err := json.Unmarshal(existing, &previous); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if sip.Revision < previous.Revision || (sip.Revision == previous.Revision && (state.Hash != previous.Hash || state.SnapshotHash != previous.SnapshotHash)) {
|
||||
return nil, errors.New("approved SIP revision regressed or changed content")
|
||||
}
|
||||
if sip.Revision == previous.Revision {
|
||||
return l.LoadedSIP(ctx)
|
||||
}
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, err
|
||||
} else if current, err := os.ReadFile(config); err == nil {
|
||||
if sha256.Sum256(current) != sha256.Sum256([]byte(text)) {
|
||||
return nil, errors.New("unverified managed SIP config does not match the approved snapshot")
|
||||
}
|
||||
recoverExisting = true
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, err
|
||||
}
|
||||
if !recoverExisting {
|
||||
if err := writeAtomic(config, []byte(text)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := l.command(ctx, "systemctl", "--user", "reload", "go-sip-asterisk.service"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := l.waitObserved(ctx, state); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
encoded, err := json.Marshal(state)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := writeAtomic(statePath, encoded); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return l.LoadedSIP(ctx)
|
||||
}
|
||||
|
||||
func (l Loader) waitObserved(ctx context.Context, state appliedState) error {
|
||||
deadline := time.NewTimer(15 * time.Second)
|
||||
defer deadline.Stop()
|
||||
var last error
|
||||
for attempts := 1; ; attempts++ {
|
||||
if last = l.observe(ctx, state); last == nil {
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("native SIP readback cancelled after %d attempts: %w", attempts, ctx.Err())
|
||||
case <-deadline.C:
|
||||
return fmt.Errorf("native SIP readback timed out after %d attempts: %w", attempts, last)
|
||||
case <-time.After(250 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeAtomic(path string, data []byte) error {
|
||||
file, err := os.CreateTemp(filepath.Dir(path), ".go-sip-stage-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(file.Name())
|
||||
defer file.Close()
|
||||
if err := file.Chmod(0600); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := file.Write(data); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := file.Sync(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(file.Name(), path); err != nil {
|
||||
return err
|
||||
}
|
||||
dir, err := os.Open(filepath.Dir(path))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dir.Close()
|
||||
return dir.Sync()
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package asterisk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoaderPersistsOnlyVerifiedNativeReload(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "pjsip.conf"), []byte("[go-sip-udp]\ntype=transport\nprotocol=udp\nbind=0.0.0.0:5060\n#tryinclude go-sip-managed.conf\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fake := filepath.Join(dir, "asterisk")
|
||||
script := `#!/bin/sh
|
||||
case "$*" in
|
||||
*"pjsip show transports"*) echo 'Transport: go-sip-udp udp 0 0 0.0.0.0:5060' ;;
|
||||
*"pjsip show endpoints"*) echo 'Endpoint: <Endpoint/CID> <State> <Channels>'; echo 'Endpoint: trunk-shuqi Not in use' ;;
|
||||
*"pjsip show endpoint trunk-shuqi"*) echo 'Aor: trunk-shuqi-aor allow: alaw transport: go-sip-udp context: go-sip-no-inbound' ;;
|
||||
*"pjsip show aor trunk-shuqi-aor"*) echo 'Contact: sip:61.132.228.221:5060' ;;
|
||||
*) exit 9 ;;
|
||||
esac
|
||||
`
|
||||
if err := os.WriteFile(fake, []byte(script), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bin := filepath.Join(dir, "bin")
|
||||
if err := os.Mkdir(bin, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(bin, "systemctl"), []byte("#!/bin/sh\n[ \"$*\" = '--user reload go-sip-asterisk.service' ]\n"), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", bin+":"+os.Getenv("PATH"))
|
||||
loader := Loader{ConfigDir: dir, Asterisk: fake, LibraryDir: dir}
|
||||
sip := testSIP(t)
|
||||
body, err := json.Marshal(sip)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Recover an interrupted first apply only when the approved file and
|
||||
// native PJSIP objects both agree with the complete snapshot.
|
||||
rendered, err := Render(sip)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "go-sip-managed.conf"), []byte(rendered), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err := loader.Apply(context.Background(), body)
|
||||
if err != nil || loaded["trunk-shuqi"] != 9 {
|
||||
t.Fatalf("real command path did not verify SIP revision: %v %v", loaded, err)
|
||||
}
|
||||
if _, err := loader.Apply(context.Background(), body); err != nil {
|
||||
t.Fatalf("same revision should observe without rewriting: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(fake, []byte(strings.Replace(script, "Transport: go-sip-udp udp", "Transport: go-sip-udp tcp", 1)), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := loader.LoadedSIP(context.Background()); err == nil {
|
||||
t.Fatal("accepted native TCP transport as approved UDP")
|
||||
}
|
||||
if err := os.WriteFile(fake, []byte(script), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
changed := testSIP(t)
|
||||
changed.Trunks = []byte(strings.Replace(string(changed.Trunks), "BD93205882", "BD93205883", 1))
|
||||
changedBody, err := json.Marshal(changed)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := loader.Apply(context.Background(), changedBody); err == nil {
|
||||
t.Fatal("same revision changed non-rendered caller authorization")
|
||||
}
|
||||
p := filepath.Join(dir, "go-sip-managed.conf")
|
||||
if err := os.WriteFile(p, []byte("[tampered]"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := loader.LoadedSIP(context.Background()); err == nil || !strings.Contains(err.Error(), "no longer matches") {
|
||||
t.Fatalf("tampered config was accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoaderFailsClosedWithoutApprovedStaticTransport(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
loader := Loader{ConfigDir: dir, Asterisk: "/bin/true", LibraryDir: dir}
|
||||
body, err := json.Marshal(testSIP(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := loader.Apply(context.Background(), body); err == nil {
|
||||
t.Fatal("applied SIP without management-owned base transport")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "go-sip-managed.conf")); !os.IsNotExist(err) {
|
||||
t.Fatalf("mutated disk before static transport check: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -28,11 +28,14 @@ type AgentEnvironment struct {
|
||||
PeerFingerprints map[string]struct{}
|
||||
MockScenarioFile string
|
||||
MockAppliedSIPFile string
|
||||
AsteriskConfigDir string
|
||||
AsteriskBin string
|
||||
AsteriskLibraryDir string
|
||||
}
|
||||
|
||||
func LoadAgentEnvironment(mode string) (AgentEnvironment, error) {
|
||||
if mode != "mock" {
|
||||
return AgentEnvironment{}, errors.New("Agent accepts only isolated Mock mode")
|
||||
if mode != "mock" && mode != "sip-only" {
|
||||
return AgentEnvironment{}, errors.New("Agent accepts only isolated Mock or SIP-only mode")
|
||||
}
|
||||
get := func(name string) (string, error) {
|
||||
value := os.Getenv(name)
|
||||
@@ -54,8 +57,6 @@ func LoadAgentEnvironment(mode string) (AgentEnvironment, error) {
|
||||
{"DISPATCHER_GRPC_SERVER_NAME", &settings.DispatcherServerName},
|
||||
{"MTLS_CA_FILE", &settings.CAFile}, {"MTLS_CERT_FILE", &settings.CertFile},
|
||||
{"MTLS_KEY_FILE", &settings.KeyFile},
|
||||
{"AGENT_MOCK_SCENARIO_FILE", &settings.MockScenarioFile},
|
||||
{"AGENT_MOCK_APPLIED_SIP_FILE", &settings.MockAppliedSIPFile},
|
||||
} {
|
||||
value, err := get(field.name)
|
||||
if err != nil {
|
||||
@@ -63,6 +64,28 @@ func LoadAgentEnvironment(mode string) (AgentEnvironment, error) {
|
||||
}
|
||||
*field.value = value
|
||||
}
|
||||
var modeFields []struct {
|
||||
name string
|
||||
value *string
|
||||
}
|
||||
if mode == "mock" {
|
||||
modeFields = []struct {
|
||||
name string
|
||||
value *string
|
||||
}{{"AGENT_MOCK_SCENARIO_FILE", &settings.MockScenarioFile}, {"AGENT_MOCK_APPLIED_SIP_FILE", &settings.MockAppliedSIPFile}}
|
||||
} else {
|
||||
modeFields = []struct {
|
||||
name string
|
||||
value *string
|
||||
}{{"ASTERISK_CONFIG_DIR", &settings.AsteriskConfigDir}, {"ASTERISK_BIN", &settings.AsteriskBin}, {"ASTERISK_LIBRARY_DIR", &settings.AsteriskLibraryDir}}
|
||||
}
|
||||
for _, field := range modeFields {
|
||||
value, err := get(field.name)
|
||||
if err != nil {
|
||||
return AgentEnvironment{}, err
|
||||
}
|
||||
*field.value = value
|
||||
}
|
||||
if tenant.ValidateDispatcherID(settings.DispatcherID) != nil {
|
||||
return AgentEnvironment{}, errors.New("DISPATCHER_ID must be a canonical UUID v4")
|
||||
}
|
||||
|
||||
@@ -40,6 +40,23 @@ func TestLoadAgentEnvironmentRefusesNonMockBeforeResources(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSIPOnlyAgentEnvironmentDoesNotRequireMockCalls(t *testing.T) {
|
||||
setAgentEnvironment(t)
|
||||
t.Setenv("AGENT_MOCK_SCENARIO_FILE", "")
|
||||
t.Setenv("AGENT_MOCK_APPLIED_SIP_FILE", "")
|
||||
for name, value := range map[string]string{
|
||||
"ASTERISK_CONFIG_DIR": "/tmp/asterisk-config",
|
||||
"ASTERISK_BIN": "/tmp/asterisk",
|
||||
"ASTERISK_LIBRARY_DIR": "/tmp/asterisk-libraries",
|
||||
} {
|
||||
t.Setenv(name, value)
|
||||
}
|
||||
settings, err := LoadAgentEnvironment("sip-only")
|
||||
if err != nil || settings.AsteriskConfigDir != "/tmp/asterisk-config" || settings.MockScenarioFile != "" {
|
||||
t.Fatalf("SIP-only Agent requires real native paths, not Mock fixtures: %+v %v", settings, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAgentEnvironmentRequiresExplicitDeploymentValues(t *testing.T) {
|
||||
for _, name := range []string{
|
||||
"AGENT_ID", "CELL_ID", "AGENT_GRPC_LISTEN", "AGENT_SESSION_PATH", "AGENT_RECOVERY_ROOT",
|
||||
|
||||
@@ -37,9 +37,7 @@ func LoadDispatcherRuntimeEnvironment(mode string) (DispatcherRuntimeEnvironment
|
||||
name string
|
||||
value *string
|
||||
}{
|
||||
{"DISPATCHER_GRPC_LISTEN", &settings.Listen},
|
||||
{"DISPATCHER_AGENT_ENDPOINTS_FILE", &settings.AgentEndpointsFile},
|
||||
{"DISPATCHER_OSS_CONFIG_FILE", &settings.OSSConfigFile},
|
||||
{"MTLS_CA_FILE", &settings.CAFile},
|
||||
{"MTLS_CERT_FILE", &settings.CertFile},
|
||||
{"MTLS_KEY_FILE", &settings.KeyFile},
|
||||
@@ -50,6 +48,15 @@ func LoadDispatcherRuntimeEnvironment(mode string) (DispatcherRuntimeEnvironment
|
||||
}
|
||||
*field.value = value
|
||||
}
|
||||
if mode == "sip-only" {
|
||||
return settings, nil
|
||||
}
|
||||
if settings.Listen, err = get("DISPATCHER_GRPC_LISTEN"); err != nil {
|
||||
return DispatcherRuntimeEnvironment{}, err
|
||||
}
|
||||
if settings.OSSConfigFile, err = get("DISPATCHER_OSS_CONFIG_FILE"); err != nil {
|
||||
return DispatcherRuntimeEnvironment{}, err
|
||||
}
|
||||
if !localGRPCAddress(settings.Listen, true) {
|
||||
return DispatcherRuntimeEnvironment{}, errors.New("DISPATCHER_GRPC_LISTEN must be an isolated local Mock address")
|
||||
}
|
||||
|
||||
@@ -25,6 +25,17 @@ func setCurrentDispatcherRuntimeEnvironment(t *testing.T) string {
|
||||
return database
|
||||
}
|
||||
|
||||
func TestLoadSIPOnlyDispatcherRequiresNoRecordingOrCallListener(t *testing.T) {
|
||||
setCurrentDispatcherRuntimeEnvironment(t)
|
||||
t.Setenv("DISPATCHER_GRPC_LISTEN", "")
|
||||
t.Setenv("DISPATCHER_OSS_CONFIG_FILE", "")
|
||||
t.Setenv("MTLS_PEER_CERT_FINGERPRINTS", "")
|
||||
settings, err := LoadDispatcherRuntimeEnvironment("sip-only")
|
||||
if err != nil || settings.AgentEndpointsFile == "" || settings.OSSConfigFile != "" || settings.Listen != "" {
|
||||
t.Fatalf("SIP-only process inherited Mock business listeners: %+v %v", settings, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDispatcherRuntimeEnvironmentRequiresExplicitMockDeployment(t *testing.T) {
|
||||
database := setCurrentDispatcherRuntimeEnvironment(t)
|
||||
settings, err := LoadDispatcherRuntimeEnvironment("mock")
|
||||
|
||||
@@ -22,10 +22,10 @@ type DispatcherEnvironment struct {
|
||||
}
|
||||
|
||||
// LoadDispatcherEnvironment is pure inspection: it opens no database, queue or
|
||||
// network connection. The current executable accepts only isolated Mock mode.
|
||||
// network connection. Only isolated Mock or SIP-only mode may start.
|
||||
func LoadDispatcherEnvironment(mode string) (DispatcherEnvironment, error) {
|
||||
if mode != "mock" {
|
||||
return DispatcherEnvironment{}, errors.New("Dispatcher accepts only isolated Mock mode")
|
||||
if mode != "mock" && mode != "sip-only" {
|
||||
return DispatcherEnvironment{}, errors.New("Dispatcher accepts only isolated Mock or SIP-only mode")
|
||||
}
|
||||
get := func(name string) (string, error) {
|
||||
value := os.Getenv(name)
|
||||
|
||||
@@ -3,6 +3,7 @@ package dispatcher
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -18,6 +19,7 @@ import (
|
||||
// transport or an MQ execution fallback.
|
||||
type ApprovedAgentRPC interface {
|
||||
GetLoadedSIP(context.Context, *agentpb.GetLoadedSIPRequest, ...grpc.CallOption) (*agentpb.GetLoadedSIPResponse, error)
|
||||
ApplySIP(context.Context, *agentpb.ApplySIPRequest, ...grpc.CallOption) (*agentpb.ApplySIPResponse, error)
|
||||
ExecuteApproved(context.Context, *agentpb.ExecuteApprovedRequest, ...grpc.CallOption) (*agentpb.ExecuteApprovedResponse, error)
|
||||
ApplyApprovedTaskControl(context.Context, *agentpb.ApplyApprovedTaskControlRequest, ...grpc.CallOption) (*agentpb.ApplyApprovedTaskControlResponse, error)
|
||||
}
|
||||
@@ -68,6 +70,49 @@ func (o *ApprovedOriginator) LoadedTrunks(ctx context.Context) (map[string]int64
|
||||
return loaded, nil
|
||||
}
|
||||
|
||||
// ApplySIP sends the complete approved partition through the pinned, active
|
||||
// Agent session. The response must describe the exact revision and trunk set.
|
||||
func (o *ApprovedOriginator) ApplySIP(ctx context.Context, sip configread.SIP) error {
|
||||
if o == nil || sip.DispatcherID != o.DispatcherID || sip.Revision <= 0 {
|
||||
return errors.New("approved SIP snapshot does not belong to this Dispatcher")
|
||||
}
|
||||
meta, err := o.activeMeta(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := json.Marshal(sip)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode approved SIP snapshot: %w", err)
|
||||
}
|
||||
hash := sha256.Sum256(body)
|
||||
meta.OperationId = fmt.Sprintf("sip-%d", sip.Revision)
|
||||
meta.IdempotencyKey = fmt.Sprintf("sip-%d-%x", sip.Revision, hash[:8])
|
||||
response, err := o.Client.ApplySIP(ctx, &agentpb.ApplySIPRequest{Meta: meta, ApprovedSnapshotJson: body})
|
||||
if err != nil {
|
||||
return fmt.Errorf("Agent SIP apply revision %d is unconfirmed: %w", sip.Revision, err)
|
||||
}
|
||||
var trunks []struct {
|
||||
ID string `json:"trunk_id"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := json.Unmarshal(sip.Trunks, &trunks); err != nil {
|
||||
return err
|
||||
}
|
||||
expected := 0
|
||||
for _, trunk := range trunks {
|
||||
if trunk.Enabled {
|
||||
expected++
|
||||
if response == nil || response.TrunkRevision[trunk.ID] != sip.Revision {
|
||||
return fmt.Errorf("Agent did not confirm approved SIP trunk %q", trunk.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
if response == nil || len(response.TrunkRevision) != expected {
|
||||
return errors.New("Agent confirmed a different SIP trunk set")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// VerifySIP requires an exact match between the approved entire SIP partition
|
||||
// and the Agent's actual loaded set; a single matching trunk is insufficient.
|
||||
func (o *ApprovedOriginator) VerifySIP(ctx context.Context, sip configread.SIP) error {
|
||||
@@ -76,6 +121,7 @@ func (o *ApprovedOriginator) VerifySIP(ctx context.Context, sip configread.SIP)
|
||||
}
|
||||
var trunks []struct {
|
||||
TrunkID string `json:"trunk_id"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := json.Unmarshal(sip.Trunks, &trunks); err != nil || len(trunks) == 0 {
|
||||
return errors.New("approved SIP trunk list is invalid")
|
||||
@@ -84,14 +130,19 @@ func (o *ApprovedOriginator) VerifySIP(ctx context.Context, sip configread.SIP)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(loaded) != len(trunks) {
|
||||
return fmt.Errorf("Agent loaded %d trunks; approved snapshot has %d", len(loaded), len(trunks))
|
||||
}
|
||||
active := 0
|
||||
for _, trunk := range trunks {
|
||||
if !trunk.Enabled {
|
||||
continue
|
||||
}
|
||||
active++
|
||||
if trunk.TrunkID == "" || loaded[trunk.TrunkID] != sip.Revision {
|
||||
return fmt.Errorf("approved SIP trunk %q revision %d is not loaded", trunk.TrunkID, sip.Revision)
|
||||
}
|
||||
}
|
||||
if len(loaded) != active {
|
||||
return fmt.Errorf("Agent loaded %d trunks; approved snapshot enables %d", len(loaded), active)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,9 @@ type fakeApprovedAgentRPC struct {
|
||||
controlResponse *agentpb.ApplyApprovedTaskControlResponse
|
||||
controlErr error
|
||||
controlCalls int
|
||||
sipRequest *agentpb.ApplySIPRequest
|
||||
sipResponse *agentpb.ApplySIPResponse
|
||||
sipErr error
|
||||
}
|
||||
|
||||
func (f *fakeApprovedAgentRPC) ExecuteApproved(_ context.Context, req *agentpb.ExecuteApprovedRequest, _ ...grpc.CallOption) (*agentpb.ExecuteApprovedResponse, error) {
|
||||
@@ -45,6 +48,17 @@ func (f *fakeApprovedAgentRPC) ApplyApprovedTaskControl(_ context.Context, req *
|
||||
return &agentpb.ApplyApprovedTaskControlResponse{Accepted: true}, nil
|
||||
}
|
||||
|
||||
func (f *fakeApprovedAgentRPC) ApplySIP(_ context.Context, req *agentpb.ApplySIPRequest, _ ...grpc.CallOption) (*agentpb.ApplySIPResponse, error) {
|
||||
f.sipRequest = req
|
||||
if f.sipErr != nil {
|
||||
return nil, f.sipErr
|
||||
}
|
||||
if f.sipResponse != nil {
|
||||
return f.sipResponse, nil
|
||||
}
|
||||
return &agentpb.ApplySIPResponse{TrunkRevision: f.loaded}, nil
|
||||
}
|
||||
|
||||
func (f *fakeApprovedAgentRPC) GetLoadedSIP(_ context.Context, _ *agentpb.GetLoadedSIPRequest, _ ...grpc.CallOption) (*agentpb.GetLoadedSIPResponse, error) {
|
||||
if f.loadedErr != nil {
|
||||
return nil, f.loadedErr
|
||||
@@ -72,6 +86,49 @@ func approvedOriginatorFixture(t *testing.T, client *fakeApprovedAgentRPC) (*App
|
||||
return orig, spec
|
||||
}
|
||||
|
||||
func TestApprovedOriginatorAppliesFullSIPSnapshotBeforeReportingLoaded(t *testing.T) {
|
||||
fake := &fakeApprovedAgentRPC{loaded: map[string]int64{"trunk-mock": 8}}
|
||||
orig, spec := approvedOriginatorFixture(t, fake)
|
||||
if err := orig.ApplySIP(context.Background(), spec.Snapshot.SIP); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fake.sipRequest == nil || fake.sipRequest.Meta.IdempotencyKey == "" {
|
||||
t.Fatal("SIP apply omitted operation identity")
|
||||
}
|
||||
var sent configread.SIP
|
||||
if err := json.Unmarshal(fake.sipRequest.ApprovedSnapshotJson, &sent); err != nil || sent.Revision != 8 || sent.DispatcherID != orig.DispatcherID {
|
||||
t.Fatal("SIP apply lost the approved full snapshot")
|
||||
}
|
||||
fake.sipResponse = &agentpb.ApplySIPResponse{TrunkRevision: map[string]int64{"trunk-mock": 7}}
|
||||
if err := orig.ApplySIP(context.Background(), spec.Snapshot.SIP); err == nil {
|
||||
t.Fatal("accepted a stale Agent revision")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprovedOriginatorIgnoresDisabledTrunksDuringNativeReadback(t *testing.T) {
|
||||
fake := &fakeApprovedAgentRPC{loaded: map[string]int64{"trunk-mock": 8}}
|
||||
orig, spec := approvedOriginatorFixture(t, fake)
|
||||
var trunks []map[string]any
|
||||
if err := json.Unmarshal(spec.Snapshot.SIP.Trunks, &trunks); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
disabled := make(map[string]any)
|
||||
for key, value := range trunks[0] {
|
||||
disabled[key] = value
|
||||
}
|
||||
disabled["trunk_id"] = "disabled-line"
|
||||
disabled["enabled"] = false
|
||||
trunks = append(trunks, disabled)
|
||||
data, err := json.Marshal(trunks)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
spec.Snapshot.SIP.Trunks = data
|
||||
if err := orig.VerifySIP(context.Background(), spec.Snapshot.SIP); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprovedOriginatorSendsExactImmutableAgentInstruction(t *testing.T) {
|
||||
fake := &fakeApprovedAgentRPC{loaded: map[string]int64{"trunk-mock": 8}}
|
||||
orig, spec := approvedOriginatorFixture(t, fake)
|
||||
|
||||
@@ -16,6 +16,7 @@ type Bootstrap struct {
|
||||
Client *configread.Client
|
||||
Store *store.Store
|
||||
DispatcherID string
|
||||
ApplySIP func(context.Context, configread.SIP) error // nil for isolated Mock; real SIP-only must apply before verifying
|
||||
VerifySIP func(context.Context, configread.SIP) error
|
||||
DrainControls func(context.Context) error
|
||||
Cursor *string // memory-only; restart always starts from a full snapshot
|
||||
@@ -46,6 +47,11 @@ func (b Bootstrap) Run(ctx context.Context) error {
|
||||
if sip.DispatcherID != b.DispatcherID {
|
||||
return errors.New("SIP snapshot belongs to another Dispatcher")
|
||||
}
|
||||
if b.ApplySIP != nil {
|
||||
if err := b.ApplySIP(ctx, sip); err != nil {
|
||||
return fmt.Errorf("apply approved SIP revision %d on Agent/Asterisk: %w", sip.Revision, err)
|
||||
}
|
||||
}
|
||||
if err := b.VerifySIP(ctx, sip); err != nil {
|
||||
return fmt.Errorf("verify SIP revision %d loaded by Agent/Asterisk: %w", sip.Revision, err)
|
||||
}
|
||||
|
||||
@@ -61,13 +61,20 @@ func TestBootstrapRequiresSIPLoadingAndControlDrain(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
verifierCalled, drained := false, false
|
||||
applied, verifierCalled, drained := false, false, false
|
||||
var cursor string
|
||||
var approvedSIP configread.SIP
|
||||
bootstrap := Bootstrap{
|
||||
Client: client, Store: db, DispatcherID: id, Cursor: &cursor, SIP: &approvedSIP,
|
||||
ApplySIP: func(_ context.Context, sip configread.SIP) error {
|
||||
applied = sip.Revision == 8
|
||||
return nil
|
||||
},
|
||||
VerifySIP: func(_ context.Context, sip configread.SIP) error {
|
||||
verifierCalled = true
|
||||
if !applied {
|
||||
t.Fatal("verified Agent load before applying SIP")
|
||||
}
|
||||
if sip.Revision != 8 {
|
||||
t.Fatalf("verified wrong SIP revision %d", sip.Revision)
|
||||
}
|
||||
@@ -102,7 +109,7 @@ func TestBootstrapRequiresSIPLoadingAndControlDrain(t *testing.T) {
|
||||
|
||||
func TestBootstrapFailsClosedOnVerifierOrDrainError(t *testing.T) {
|
||||
id := "c046b893-8628-4589-ae50-619d049248a6"
|
||||
for _, failure := range []string{"verify", "drain"} {
|
||||
for _, failure := range []string{"apply", "verify", "drain"} {
|
||||
t.Run(failure, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var response []byte
|
||||
@@ -139,6 +146,12 @@ func TestBootstrapFailsClosedOnVerifierOrDrainError(t *testing.T) {
|
||||
defer db.Close()
|
||||
b := Bootstrap{
|
||||
Client: client, Store: db, DispatcherID: id,
|
||||
ApplySIP: func(context.Context, configread.SIP) error {
|
||||
if failure == "apply" {
|
||||
return errors.New("native SIP application rejected")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
VerifySIP: func(context.Context, configread.SIP) error {
|
||||
if failure == "verify" {
|
||||
return errors.New("applied SIP revision unavailable")
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package dispatcher
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"git.ipao.vip/rogee/go-sip/internal/configread"
|
||||
"git.ipao.vip/rogee/go-sip/internal/contract"
|
||||
"git.ipao.vip/rogee/go-sip/internal/store"
|
||||
)
|
||||
|
||||
// SIPOnly is a distinct update lane: it never discovers tasks or enables
|
||||
// call admission, even when native Asterisk confirms the entire snapshot.
|
||||
type SIPOnly struct {
|
||||
DispatcherID string
|
||||
Store *store.Store
|
||||
Client *configread.Client
|
||||
ApplySIP func(context.Context, configread.SIP) error
|
||||
VerifySIP func(context.Context, configread.SIP) error
|
||||
}
|
||||
|
||||
// HandleNotification persists the assigned SIP revision before the broker ACK.
|
||||
// Non-SIP controls must be requeued for a business Dispatcher, never swallowed.
|
||||
func (s SIPOnly) HandleNotification(_ context.Context, route string, body []byte) error {
|
||||
if s.Store == nil || s.DispatcherID == "" || route != "d."+s.DispatcherID+".control.in" {
|
||||
return errors.New("SIP-only notification has no approved queue owner")
|
||||
}
|
||||
if err := contract.ValidateCurrent("mq", body); err != nil {
|
||||
return fmt.Errorf("SIP-only notification violates current contract: %w", err)
|
||||
}
|
||||
var notice struct {
|
||||
EventType string `json:"event_type"`
|
||||
DispatcherID string `json:"dispatcher_id"`
|
||||
Payload struct {
|
||||
Revision int64 `json:"revision"`
|
||||
} `json:"payload"`
|
||||
}
|
||||
if err := json.Unmarshal(body, ¬ice); err != nil {
|
||||
return err
|
||||
}
|
||||
if notice.DispatcherID != s.DispatcherID || notice.EventType != "sip.config" || notice.Payload.Revision <= 0 {
|
||||
return errors.New("SIP-only cannot consume another Dispatcher or business control")
|
||||
}
|
||||
return s.Store.NoteSIPChange(s.DispatcherID, notice.Payload.Revision)
|
||||
}
|
||||
|
||||
// Sync fetches only the complete SIP snapshot; it never reads task, quota or
|
||||
// AI config. The Agent revision and actual native load must both agree before
|
||||
// the durable pending revision is cleared, while admission remains closed.
|
||||
func (s SIPOnly) Sync(ctx context.Context) error {
|
||||
if s.Store == nil || s.Client == nil || s.DispatcherID == "" || s.ApplySIP == nil || s.VerifySIP == nil {
|
||||
return errors.New("SIP-only sync requires a bound client, durable store and native Agent")
|
||||
}
|
||||
sip, err := s.Client.ReadSIP(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read approved SIP-only snapshot: %w", err)
|
||||
}
|
||||
if sip.DispatcherID != s.DispatcherID || sip.Revision <= 0 {
|
||||
return errors.New("SIP-only snapshot owner or revision is invalid")
|
||||
}
|
||||
applied, pending, err := s.Store.SIPState(s.DispatcherID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if sip.Revision < pending || sip.Revision < applied {
|
||||
return fmt.Errorf("approved SIP snapshot revision %d is behind durable applied=%d pending=%d", sip.Revision, applied, pending)
|
||||
}
|
||||
if err := s.Store.NoteSIPChange(s.DispatcherID, sip.Revision); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.ApplySIP(ctx, sip); err != nil {
|
||||
return fmt.Errorf("native SIP apply revision %d: %w", sip.Revision, err)
|
||||
}
|
||||
if err := s.VerifySIP(ctx, sip); err != nil {
|
||||
return fmt.Errorf("native SIP load revision %d: %w", sip.Revision, err)
|
||||
}
|
||||
if err := s.Store.MarkSIPOnlyVerified(s.DispatcherID, sip.Revision); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package dispatcher
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.ipao.vip/rogee/go-sip/internal/configread"
|
||||
"git.ipao.vip/rogee/go-sip/internal/store"
|
||||
)
|
||||
|
||||
func TestSIPOnlyNotificationCheckpointsNativeLoadWithoutOpeningBusiness(t *testing.T) {
|
||||
const id = "c046b893-8628-4589-ae50-619d049248a6"
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/internal/v1/dispatcher/sip" {
|
||||
t.Errorf("SIP-only fetched business config %s", r.URL.Path)
|
||||
w.WriteHeader(404)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(configExample(t, "config-read-sip"))
|
||||
}))
|
||||
defer server.Close()
|
||||
client, err := configread.NewClient(server.URL, id, "test-secret", server.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db, err := store.Open(filepath.Join(t.TempDir(), "sip-only.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
applied := false
|
||||
syncer := SIPOnly{DispatcherID: id, Store: db, Client: client,
|
||||
ApplySIP: func(_ context.Context, sip configread.SIP) error {
|
||||
applied = true
|
||||
if sip.Revision != 8 {
|
||||
t.Fatal("unexpected SIP revision")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
VerifySIP: func(_ context.Context, sip configread.SIP) error {
|
||||
if !applied {
|
||||
t.Fatal("verified before native apply")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
body := configExample(t, "mq-sip-change")
|
||||
if err := syncer.HandleNotification(context.Background(), "d."+id+".control.in", body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if appliedRev, pending, err := db.SIPState(id); err != nil || appliedRev != 0 || pending != 8 {
|
||||
t.Fatalf("notification was not durably fenced: %d %d %v", appliedRev, pending, err)
|
||||
}
|
||||
if err := syncer.Sync(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if appliedRev, pending, err := db.SIPState(id); err != nil || appliedRev != 8 || pending != 0 {
|
||||
t.Fatalf("verified readback was not recorded: %d %d %v", appliedRev, pending, err)
|
||||
}
|
||||
if err := syncer.HandleNotification(context.Background(), "d."+id+".control.in", configExample(t, "mq-control")); err == nil {
|
||||
t.Fatal("SIP-only consumed a business control event")
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,12 @@ func (r *Runtime) refreshSIP(ctx context.Context, approvedSIP *configread.SIP) e
|
||||
r.Logger.Debug("SaaS SIP snapshot is behind durable SIP notification; admission stays closed", "dispatcher_id", r.Bootstrap.DispatcherID, "pending_revision", pending, "available_revision", current.Revision)
|
||||
return nil
|
||||
}
|
||||
if r.Bootstrap.ApplySIP != nil {
|
||||
if err := r.Bootstrap.ApplySIP(ctx, current); err != nil {
|
||||
r.Logger.Warn("approved SIP update remains fenced until native reload confirms", "dispatcher_id", r.Bootstrap.DispatcherID, "revision", current.Revision, "error", err)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if err := r.Bootstrap.VerifySIP(ctx, current); err != nil {
|
||||
r.Logger.Debug("SIP reload waits for actual Agent/Asterisk revision", "dispatcher_id", r.Bootstrap.DispatcherID, "pending_revision", pending, "error", err)
|
||||
return nil
|
||||
|
||||
@@ -42,13 +42,27 @@ func TestSIPNotificationPersistsBarrierAndWaitsForLoadedFullSnapshot(t *testing.
|
||||
t.Fatal(err)
|
||||
}
|
||||
var loaded atomic.Bool
|
||||
var applied atomic.Int32
|
||||
verify := func(_ context.Context, sip configread.SIP) error {
|
||||
if applied.Load() == 0 {
|
||||
return errors.New("SIP load checked before native apply")
|
||||
}
|
||||
if sip.Revision != 9 || !loaded.Load() {
|
||||
return errors.New("Agent and Asterisk have not applied SIP revision 9")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
runtime := &Runtime{Bootstrap: Bootstrap{DispatcherID: executor.DispatcherID, Client: client, Store: s, VerifySIP: verify}, Logger: slog.Default()}
|
||||
runtime := &Runtime{Bootstrap: Bootstrap{DispatcherID: executor.DispatcherID, Client: client, Store: s,
|
||||
ApplySIP: func(_ context.Context, sip configread.SIP) error {
|
||||
if sip.Revision != 9 {
|
||||
return errors.New("wrong SIP revision sent to Agent")
|
||||
}
|
||||
applied.Add(1)
|
||||
if !loaded.Load() {
|
||||
return errors.New("native Asterisk SIP reload is still unavailable")
|
||||
}
|
||||
return nil
|
||||
}, VerifySIP: verify}, Logger: slog.Default()}
|
||||
|
||||
body := []byte(strings.Replace(string(configExample(t, "mq-sip-change")), `"revision":8`, `"revision":9`, 1))
|
||||
if err := runtime.handleControl(context.Background(), "", body); err != nil {
|
||||
@@ -66,6 +80,9 @@ func TestSIPNotificationPersistsBarrierAndWaitsForLoadedFullSnapshot(t *testing.
|
||||
if admitted, err := s.CanAdmit(executor.DispatcherID, 1001, "task-asr"); err != nil || admitted {
|
||||
t.Fatalf("unloaded SIP reopened admission: %v %v", admitted, err)
|
||||
}
|
||||
if applied.Load() == 0 {
|
||||
t.Fatal("notification never sent to Agent")
|
||||
}
|
||||
loaded.Store(true)
|
||||
if err := runtime.refreshSIP(context.Background(), &approved); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,28 @@ func (s *Store) NoteSIPChange(dispatcherID string, revision int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkSIPOnlyVerified checkpoints native Asterisk readback without ever
|
||||
// enabling business call admission. A newer durable notification wins races.
|
||||
func (s *Store) MarkSIPOnlyVerified(dispatcherID string, revision int64) error {
|
||||
if dispatcherID == "" || revision <= 0 {
|
||||
return errors.New("SIP-only checkpoint requires identity and positive verified revision")
|
||||
}
|
||||
result, err := s.db.Exec(`UPDATE dispatcher_state
|
||||
SET applied_sip_revision=?,pending_sip_revision=0,discovery_ready=0
|
||||
WHERE dispatcher_id=? AND applied_sip_revision<=? AND pending_sip_revision<=?`, revision, dispatcherID, revision, revision)
|
||||
if err != nil {
|
||||
return fmt.Errorf("checkpoint verified native SIP: %w", err)
|
||||
}
|
||||
count, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count != 1 {
|
||||
return errors.New("SIP-only checkpoint conflicts with a newer durable notification or loaded revision")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) SIPState(dispatcherID string) (applied, pending int64, err error) {
|
||||
err = s.db.QueryRow(`SELECT applied_sip_revision,pending_sip_revision FROM dispatcher_state WHERE dispatcher_id=?`, dispatcherID).Scan(&applied, &pending)
|
||||
if err != nil {
|
||||
|
||||
@@ -6,6 +6,33 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSIPOnlyVerifiedNeverOpensCallAdmission(t *testing.T) {
|
||||
s, err := Open(filepath.Join(t.TempDir(), "sip-only.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
if err := s.NoteSIPChange(currentDispatcherID, 9); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.MarkSIPOnlyVerified(currentDispatcherID, 9); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if applied, pending, err := s.SIPState(currentDispatcherID); err != nil || applied != 9 || pending != 0 {
|
||||
t.Fatalf("SIP-only checkpoint: %d %d %v", applied, pending, err)
|
||||
}
|
||||
var admitted int
|
||||
if err := s.db.QueryRow(`SELECT discovery_ready FROM dispatcher_state WHERE dispatcher_id=?`, currentDispatcherID).Scan(&admitted); err != nil || admitted != 0 {
|
||||
t.Fatalf("SIP-only opened call admission: %d %v", admitted, err)
|
||||
}
|
||||
if err := s.NoteSIPChange(currentDispatcherID, 10); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.MarkSIPOnlyVerified(currentDispatcherID, 9); err == nil {
|
||||
t.Fatal("stale native SIP confirmation discarded newer notification")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSIPNotificationRequiresFullDrainAndExactLoadedRevision(t *testing.T) {
|
||||
s := preparedCurrentCallStore(t)
|
||||
if err := s.MarkReadyForSIP(currentDispatcherID, 8); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user