feat: implement Trunks with real FreeSWITCH gateway sync
- trunks table (tenant-scoped, RLS): host/proxy/realm, register, username/password_enc (AES-256-GCM), dtmf_mode, ping, transport, and a status/status_updated_at pair meant to be driven by FreeSWITCH events - apps/api/src/trunks: CRUD (POST/GET/GET:id/DELETE), same RBAC/tenant pattern as Extensions, password never exposed in any GET - packages/telephony: buildGatewayXml() generates a Sofia gateway XML file - b2bcall-fs-config now writes sip_profiles/external/<trunk_id>.xml (shared Docker volume with FreeSWITCH -- the vanilla external profile already includes external/*.xml) and runs 'sofia profile external rescan' over ESL; syncs on boot and on demand via Redis pub/sub (b2bcall:trunks:sync), since apps/api runs on the host and fs-config has no port published to reach directly - added FreeSwitchTelephonyProvider.waitUntilConnected() to fix a startup race: the first sync ran before the ESL connection had settled, logging a harmless but noisy error - verified end-to-end with a fake host: create trunk -> gateway file written -> FreeSWITCH shows the real gateway (FAIL_WAIT, expected) -> delete -> file removed (cleanup also correctly swept the stale 'example.com' gateway that had been copied into the volume from the vanilla image) - apps/freeswitch-events/src/trunk-status.ts: written to update Trunk.status from sofia::gateway_state events, using the same normalizeEslEvent path already proven for CHANNEL_* events - KNOWN GAP, documented rather than glossed over: monitored fs-events for ~90s while the gateway visibly transitioned states in FreeSWITCH (FAIL_WAIT/DOWN) and no sofia::gateway_state event was observed arriving. CUSTOM/sofia::* events have not actually been proven working end-to-end in this session -- only CHANNEL_* events have been. Needs verification against a real SIP target before the status auto-update can be trusted in production. See docs/TRUNKS.md and TODO.md. - docs/TRUNKS.md
This commit is contained in:
@@ -35,6 +35,7 @@ model Tenant {
|
||||
memberships TenantMembership[]
|
||||
userRoles UserRole[]
|
||||
extensions Extension[]
|
||||
trunks Trunk[]
|
||||
|
||||
@@map("tenants")
|
||||
}
|
||||
@@ -217,3 +218,96 @@ model Extension {
|
||||
@@index([tenantId])
|
||||
@@map("extensions")
|
||||
}
|
||||
|
||||
enum TrunkStatus {
|
||||
UP
|
||||
DOWN
|
||||
REGISTERED
|
||||
TRYING
|
||||
FAILED
|
||||
UNREGISTERED
|
||||
UNKNOWN
|
||||
|
||||
@@map("trunk_status")
|
||||
}
|
||||
|
||||
enum DtmfMode {
|
||||
RFC2833
|
||||
INFO
|
||||
INBAND
|
||||
|
||||
@@map("dtmf_mode")
|
||||
}
|
||||
|
||||
enum SipTransport {
|
||||
UDP
|
||||
TCP
|
||||
TLS
|
||||
|
||||
@@map("sip_transport")
|
||||
}
|
||||
|
||||
// Tabela tenant-scoped protegida por Row Level Security. passwordEnc guarda
|
||||
// a senha do tronco cifrada (AES-256-GCM), como sipPasswordEnc em Extension
|
||||
// (agente.md secao 41, 178).
|
||||
model Trunk {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
tenantId String @map("tenant_id") @db.Uuid
|
||||
|
||||
name String
|
||||
description String?
|
||||
|
||||
sofiaProfile String @default("external") @map("sofia_profile")
|
||||
|
||||
host String
|
||||
proxy String?
|
||||
realm String?
|
||||
|
||||
register Boolean @default(true)
|
||||
|
||||
username String?
|
||||
passwordEnc String? @map("password_enc")
|
||||
|
||||
fromUser String? @map("from_user")
|
||||
fromDomain String? @map("from_domain")
|
||||
|
||||
registerProxy String? @map("register_proxy")
|
||||
outboundProxy String? @map("outbound_proxy")
|
||||
|
||||
expireSeconds Int @default(3600) @map("expire_seconds")
|
||||
retrySeconds Int @default(30) @map("retry_seconds")
|
||||
|
||||
callerIdName String? @map("caller_id_name")
|
||||
callerIdNumber String? @map("caller_id_number")
|
||||
|
||||
codecs String @default("PCMU,PCMA,OPUS")
|
||||
|
||||
dtmfMode DtmfMode @default(RFC2833) @map("dtmf_mode")
|
||||
|
||||
ping Boolean @default(true)
|
||||
pingFrequency Int @default(30) @map("ping_frequency")
|
||||
|
||||
transport SipTransport @default(UDP)
|
||||
|
||||
inboundContext String @default("default") @map("inbound_context")
|
||||
|
||||
maxCps Int? @map("max_cps")
|
||||
maxChannels Int? @map("max_channels")
|
||||
|
||||
enabled Boolean @default(true)
|
||||
|
||||
// Refletido pelos eventos sofia::gateway_state (agente.md secao 42) —
|
||||
// b2bcall-fs-events atualiza isso, nunca escrito manualmente pela API.
|
||||
status TrunkStatus @default(UNKNOWN)
|
||||
statusUpdatedAt DateTime? @map("status_updated_at")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
|
||||
@@unique([tenantId, name])
|
||||
@@index([tenantId])
|
||||
@@map("trunks")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user