Extension Management
Ominis Cluster Manager provides comprehensive SIP extension management with PostgreSQL-backed storage, auto-reload mechanisms, and full PBX features including call forwarding, DND, voicemail, and call recording.
Overview
Extensions are SIP user accounts that enable authentication and call routing within the FreeSWITCH infrastructure. Unlike queue containers which are ephemeral, extensions are persistent entities stored in PostgreSQL and accessed via mod_xml_curl for dynamic authentication.
Key Features
- PostgreSQL-Backed Storage: Centralized, persistent extension storage
- Dynamic Authentication: FreeSWITCH mod_xml_curl integration for real-time lookups
- Auto-Reload Mechanism: Profile reloads automatically trigger on extension changes
- Hybrid Authentication:
- Cluster IPs (10.42.x.x): Blind registration (no auth required)
- External IPs: mod_xml_curl → API → PostgreSQL authentication
- Rich PBX Features:
- Call forwarding (unconditional, busy, no-answer, unavailable)
- Do Not Disturb (DND)
- Voicemail with email notifications
- Call waiting, transfer, and 3-way calling
- Call recording
- Music on hold
- Bulk Operations: Create multiple extensions in a single request
- Registration Tracking: Query active SIP registrations across all FreeSWITCH instances
Architecture
Storage Backend
Extensions are stored in PostgreSQL with JSONB columns for structured data:
CREATE TABLE extensions (
number VARCHAR(20) PRIMARY KEY,
password VARCHAR(255) NOT NULL,
display_name VARCHAR(255),
email VARCHAR(255),
department VARCHAR(255),
status VARCHAR(20) DEFAULT 'active',
call_forwarding_json JSONB,
voicemail_json JSONB,
features_json JSONB,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
Why PostgreSQL over XML files?
See ADR: Database vs XML Files for the architectural decision.
Authentication Flow
Auto-Reload Mechanism
When extensions are created, updated, or deleted, the API automatically reloads FreeSWITCH profiles to pick up changes:
Key Points:
- Profile reload is non-blocking (uses
await directory.reload_profile("internal")) - Errors during reload are logged but don't fail the operation
- Reloads happen for create, update, and delete operations
- Ensures FreeSWITCH always has the latest extension data
Extension Model
Core Fields
class Extension(BaseModel):
number: str # Extension number (e.g., "1001")
password: str # SIP authentication password
display_name: str # Display name for caller ID
status: ExtensionStatus # active, disabled, suspended
email: Optional[str] # Email address
department: Optional[str] # Department
call_forwarding: List[CallForwarding] # Call forwarding rules
voicemail: VoicemailSettings # Voicemail configuration
features: ExtensionFeatures # PBX features
codec_preferences: List[str] # Preferred codecs
max_concurrent_calls: int # Max concurrent calls
caller_id_number: Optional[str] # Outbound caller ID
caller_id_name: Optional[str] # Outbound caller ID name
Extension Status
| Status | Description |
|---|---|
active | Extension is active and can register/receive calls |
disabled | Extension is disabled (cannot register) |
suspended | Extension is temporarily suspended |
Call Forwarding
Call forwarding supports multiple rules with different trigger conditions:
class CallForwarding(BaseModel):
type: CallForwardType # unconditional, busy, no_answer, unavailable
destination: str # Destination number or SIP URI
enabled: bool # Whether forwarding is enabled
timeout: Optional[int] # Timeout in seconds for no-answer
Forwarding Types:
- unconditional: All calls forwarded immediately
- busy: Forward when extension is busy
- no_answer: Forward after timeout (default: 20 seconds)
- unavailable: Forward when extension is unreachable
Voicemail Settings
class VoicemailSettings(BaseModel):
enabled: bool # Enable voicemail
pin: Optional[str] # Voicemail PIN
email: Optional[str] # Email for notifications
attach_audio: bool # Attach audio file to email
delete_after_email: bool # Delete voicemail after emailing
Extension Features
class ExtensionFeatures(BaseModel):
call_waiting: bool # Enable call waiting
call_transfer: bool # Enable call transfer
three_way_calling: bool # Enable 3-way calling
do_not_disturb: bool # Do not disturb mode
call_recording: bool # Enable call recording
music_on_hold: bool # Enable music on hold
API Endpoints
List Extensions
Get all SIP extensions with their configuration.
GET /v1/extensions
X-API-Key: your-api-key
Response:
{
"extensions": [
{
"number": "1001",
"password": "secure123",
"display_name": "John Doe",
"status": "active",
"email": "john@company.com",
"department": "Sales",
"call_forwarding": [],
"voicemail": {
"enabled": true,
"pin": "1234",
"email": "john@company.com",
"attach_audio": false,
"delete_after_email": false
},
"features": {
"call_waiting": true,
"call_transfer": true,
"three_way_calling": true,
"do_not_disturb": false,
"call_recording": false,
"music_on_hold": true
},
"codec_preferences": ["PCMU", "PCMA", "G729"],
"max_concurrent_calls": 1,
"caller_id_number": null,
"caller_id_name": null
}
],
"total": 1
}
Create Extension
Create a new SIP extension with authentication credentials and PBX features.
POST /v1/extensions
X-API-Key: your-api-key
Content-Type: application/json
{
"number": "1001",
"password": "secure123",
"display_name": "John Doe",
"email": "john@company.com",
"department": "Sales"
}
Response: 201 Created
{
"extension": {
"number": "1001",
"password": "secure123",
"display_name": "John Doe",
"status": "active",
"email": "john@company.com",
"department": "Sales",
"call_forwarding": [],
"voicemail": {
"enabled": true,
"pin": null,
"email": null,
"attach_audio": false,
"delete_after_email": false
},
"features": {
"call_waiting": true,
"call_transfer": true,
"three_way_calling": true,
"do_not_disturb": false,
"call_recording": false,
"music_on_hold": true
}
}
}
What Happens:
- Checks if extension already exists (returns 409 if duplicate)
- Stores extension in PostgreSQL
- Registers with Kamailio (if enabled)
- Reloads FreeSWITCH profile to pick up new user
- Updates Prometheus metrics
- Returns created extension
Error Codes:
409 Conflict: Extension already exists500 Internal Server Error: Failed to create extension
Get Extension
Get a specific SIP extension by number.
GET /v1/extensions/1001
X-API-Key: your-api-key
Response:
{
"extension": {
"number": "1001",
"password": "secure123",
"display_name": "John Doe",
"status": "active",
"email": "john@company.com",
"department": "Sales"
}
}
Error Codes:
404 Not Found: Extension not found
Update Extension
Update extension settings including password, display name, and features.
PUT /v1/extensions/1001
X-API-Key: your-api-key
Content-Type: application/json
{
"display_name": "John Smith",
"email": "john.smith@company.com",
"features": {
"do_not_disturb": true,
"call_recording": true
}
}
Response:
{
"extension": {
"number": "1001",
"password": "secure123",
"display_name": "John Smith",
"status": "active",
"email": "john.smith@company.com",
"department": "Sales",
"features": {
"call_waiting": true,
"call_transfer": true,
"three_way_calling": true,
"do_not_disturb": true,
"call_recording": true,
"music_on_hold": true
}
}
}
What Happens:
- Fetches current extension from PostgreSQL
- Applies partial updates (only provided fields)
- Re-registers with Kamailio if password or status changed
- Reloads FreeSWITCH profile to pick up changes
- Updates Prometheus metrics
- Returns updated extension
Error Codes:
404 Not Found: Extension not found500 Internal Server Error: Failed to update extension
Delete Extension
Remove the extension and clear any active registrations.
DELETE /v1/extensions/1001
X-API-Key: your-api-key
Response: 204 No Content
What Happens:
- Checks extension exists (returns 404 if not found)
- Removes from PostgreSQL
- Best-effort clear of SIP registrations via CLI (kamctl/kamcmd/opensips-cli)
- Reloads FreeSWITCH profile to remove user
- Updates Prometheus metrics
Error Codes:
404 Not Found: Extension not found500 Internal Server Error: Failed to delete extension
Enable Extension
Enable a disabled SIP extension.
POST /v1/extensions/1001/enable
X-API-Key: your-api-key
Response:
{
"message": "Extension enabled successfully"
}
Error Codes:
404 Not Found: Extension not found500 Internal Server Error: Failed to enable extension
Disable Extension
Disable a SIP extension (prevents registration).
POST /v1/extensions/1001/disable
X-API-Key: your-api-key
Response:
{
"message": "Extension disabled successfully"
}
What Happens:
- Updates extension status to
disabled - Best-effort clear of any active registrations
- Updates Prometheus metrics
Error Codes:
404 Not Found: Extension not found500 Internal Server Error: Failed to disable extension
Change Password
Update the SIP authentication password for an extension.
POST /v1/extensions/1001/password
X-API-Key: your-api-key
Content-Type: application/json
{
"password": "new-secure-password"
}
Response:
{
"message": "Password updated successfully"
}
Error Codes:
404 Not Found: Extension not found400 Bad Request: Password is required500 Internal Server Error: Failed to change password
Set Call Forwarding
Configure call forwarding rules for an extension.
POST /v1/extensions/1001/call-forwarding
X-API-Key: your-api-key
Content-Type: application/json
{
"forwarding_rules": [
{
"type": "unconditional",
"destination": "1002",
"enabled": true
},
{
"type": "no_answer",
"destination": "voicemail",
"enabled": true,
"timeout": 30
}
]
}
Response:
{
"message": "Call forwarding updated successfully"
}
Forwarding Types:
unconditional: All calls forwarded immediatelybusy: Forward when extension is busyno_answer: Forward after timeoutunavailable: Forward when extension is unreachable
Error Codes:
404 Not Found: Extension not found500 Internal Server Error: Failed to set call forwarding
Get Call Forwarding
Get call forwarding configuration for an extension.
GET /v1/extensions/1001/call-forwarding
X-API-Key: your-api-key
Response:
{
"call_forwarding": [
{
"type": "unconditional",
"destination": "1002",
"enabled": true,
"timeout": 20
}
]
}
Error Codes:
404 Not Found: Extension not found
Create Bulk Extensions
Create multiple extensions in a single request.
POST /v1/extensions/bulk
X-API-Key: your-api-key
Content-Type: application/json
[
{
"number": "1001",
"password": "pass1",
"display_name": "John Doe",
"email": "john@company.com",
"department": "Sales"
},
{
"number": "1002",
"password": "pass2",
"display_name": "Jane Smith",
"email": "jane@company.com",
"department": "Support"
},
{
"number": "1003",
"password": "pass3",
"display_name": "Bob Wilson",
"email": "bob@company.com",
"department": "Engineering"
}
]
Response:
{
"created": 3,
"total_requested": 3,
"extensions": [
{
"number": "1001",
"display_name": "John Doe",
"status": "active"
},
{
"number": "1002",
"display_name": "Jane Smith",
"status": "active"
},
{
"number": "1003",
"display_name": "Bob Wilson",
"status": "active"
}
],
"errors": []
}
Partial Success:
If some extensions fail to create (e.g., duplicates), the response includes an errors array:
{
"created": 2,
"total_requested": 3,
"extensions": [...],
"errors": [
"Extension 1001 already exists"
]
}
Get Active Registrations
Get all active SIP registrations across all FreeSWITCH instances.
GET /v1/registrations
X-API-Key: your-api-key
Response:
{
"registrations": [
{
"number": "1001",
"contact": "sip:1001@192.168.1.100:5060",
"expires": 3600,
"user_agent": null,
"last_registration": null
},
{
"number": "agent-10720",
"contact": "sip:agent-10720@10.42.1.5:5060",
"expires": 3600,
"user_agent": null,
"last_registration": null
}
],
"total": 2
}
Performance Note: This endpoint uses a fast database query to retrieve registrations. FreeSWITCH stores registrations in PostgreSQL, so we query directly rather than using XML-RPC for optimal speed.
Error Codes:
500 Internal Server Error: Failed to get registrations from database
Database Schema
Extensions Table
CREATE TABLE extensions (
-- Primary key
number VARCHAR(20) PRIMARY KEY,
-- Authentication
password VARCHAR(255) NOT NULL,
-- User information
display_name VARCHAR(255),
email VARCHAR(255),
department VARCHAR(255),
-- Status
status VARCHAR(20) DEFAULT 'active'
CHECK (status IN ('active', 'inactive', 'suspended')),
-- JSONB columns for structured data
call_forwarding_json JSONB,
voicemail_json JSONB,
features_json JSONB,
-- Timestamps
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Indexes for common queries
CREATE INDEX idx_extensions_status ON extensions(status);
CREATE INDEX idx_extensions_department ON extensions(department);
CREATE INDEX idx_extensions_email ON extensions(email);
-- Trigger to update updated_at timestamp
CREATE TRIGGER trigger_extensions_updated_at
BEFORE UPDATE ON extensions
FOR EACH ROW
EXECUTE FUNCTION update_extensions_updated_at();
JSONB Column Structure
call_forwarding_json:
[
{
"type": "unconditional",
"destination": "1002",
"enabled": true,
"timeout": 20
}
]
voicemail_json:
{
"enabled": true,
"pin": "1234",
"email": "user@company.com",
"attach_audio": false,
"delete_after_email": false
}
features_json:
{
"call_waiting": true,
"call_transfer": true,
"three_way_calling": true,
"do_not_disturb": false,
"call_recording": false,
"music_on_hold": true
}
Directory Service Integration
mod_xml_curl Configuration
FreeSWITCH queries the API for directory lookups via mod_xml_curl:
FreeSWITCH Configuration:
<configuration name="xml_curl.conf" description="XML CURL Gateway">
<bindings>
<binding name="directory">
<param name="gateway-url"
value="http://cluster-manager-api:8000/v1/freeswitch/directory"
bindings="directory"/>
<param name="gateway-credentials" value="X-FreeSWITCH-Token: your-token"/>
<param name="disable-100-continue" value="true"/>
</binding>
</bindings>
</configuration>
Directory Lookup Endpoint
Endpoint: POST /v1/freeswitch/directory
Called by FreeSWITCH when:
- External IP attempts to register (not in cluster ACL)
- External call requires authentication
- Directory lookup for routing decisions
Request Format:
section=directory
tag_name=domain
key_name=name
key_value=domain.com
user=1001
domain=domain.com
sip_auth_username=1001
sip_auth_realm=domain.com
Response Format:
<document type="freeswitch/xml">
<section name="directory">
<domain name="domain.com">
<user id="1001">
<params>
<param name="password" value="secure123"/>
<param name="dial-string" value="{...}"/>
</params>
<variables>
<variable name="user_context" value="default"/>
<variable name="effective_caller_id_name" value="John Doe"/>
<variable name="effective_caller_id_number" value="1001"/>
</variables>
</user>
</domain>
</section>
</document>
Authentication Security
Token Authentication:
- Public IPs: Require
X-FreeSWITCH-Tokenheader matchingFREESWITCH_XMLCURL_TOKEN - Private IPs (10.x.x.x, 192.168.x.x, 127.0.0.1): Token validation skipped
- Invalid token: Returns
418 I'm a teapot(security through obscurity)
Hybrid Authentication Model:
- Cluster IPs (10.42.x.x): Blind registration (no authentication required)
- Configured in FreeSWITCH Sofia profile:
accept-blind-reg=true - Trusted network ACL allows cluster network
- Configured in FreeSWITCH Sofia profile:
- External IPs: mod_xml_curl → API → PostgreSQL authentication
- FreeSWITCH queries API for every authentication attempt
- API validates against PostgreSQL extensions table
Registration Flow
External SIP Client Registration
Cluster Pod Registration
Extension Repository
The ExtensionRepository class provides the data access layer for extensions.
Key Methods
class ExtensionRepository:
async def get_all() -> List[Extension]
async def get_by_number(extension_number: str) -> Optional[Extension]
async def create(extension: Extension) -> Extension
async def update(extension_number: str, update_data: ExtensionUpdate) -> Optional[Extension]
async def delete(extension_number: str) -> bool
async def count() -> int
JSONB Handling
The repository automatically serializes/deserializes JSONB columns:
Create:
row = await conn.fetchrow(
"""
INSERT INTO extensions (
number, password, display_name, email, department, status,
call_forwarding_json, voicemail_json, features_json
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING *
""",
extension.number,
extension.password,
extension.display_name,
extension.email,
extension.department,
extension.status.value,
json.dumps([rule.dict() for rule in extension.call_forwarding]),
json.dumps(extension.voicemail.dict()),
json.dumps(extension.features.dict()),
)
Update:
# Fetch current extension
row = await conn.fetchrow(
"SELECT * FROM extensions WHERE number = $1",
extension_number
)
extension = self._to_extension(row)
# Apply partial updates
update_dict = update_data.dict(exclude_unset=True)
for key, value in update_dict.items():
setattr(extension, key, value)
# Save back to database
row = await conn.fetchrow(
"""
UPDATE extensions
SET password = $1, display_name = $2, ...,
call_forwarding_json = $6, voicemail_json = $7, features_json = $8
WHERE number = $9
RETURNING *
""",
...
)
Metrics
Extension operations expose Prometheus metrics for monitoring:
Counter: extensions_operations_total
Tracks extension operations with labels:
operation: create, update, delete, enable, disable, change_password, set_call_forwarding, bulk_createextension: Extension number or "multiple" for bulk operationsstatus: success, error, partial (for bulk operations)
Example:
extensions_operations_total.labels(
operation="create",
extension="1001",
status="success"
).inc()
Gauge: active_extensions_gauge
Tracks total number of active extensions:
Example:
total = await extension_repo.count()
active_extensions_gauge.set(total)
ADR: Database vs XML Files for Extensions
Context:
FreeSWITCH traditionally stores user directory in XML files (conf/directory/default/*.xml). For dynamic environments with frequent extension changes, XML files present challenges:
Problems with XML Files:
- No Concurrency: File writes must be serialized to avoid corruption
- No Atomicity: Partial writes during crashes leave invalid XML
- No Transactions: Can't atomically update multiple extensions
- No Query Support: Must parse entire directory for simple lookups
- Pod Scaling: Each pod needs its own copy, synchronization is complex
- Version Control: Difficult to track changes over time
Decision:
Use PostgreSQL with mod_xml_curl for dynamic user authentication:
| Feature | XML Files | PostgreSQL + mod_xml_curl |
|---|---|---|
| Concurrency | ❌ Serial writes | ✅ MVCC transactions |
| Atomicity | ❌ Partial writes | ✅ ACID guarantees |
| Transactions | ❌ No support | ✅ Full ACID |
| Queries | ❌ Full XML parse | ✅ Fast SQL queries |
| Scalability | ❌ File sync | ✅ Centralized storage |
| History | ❌ No audit log | ✅ Timestamps, triggers |
| JSONB | ❌ No structured data | ✅ JSONB queries |
Implementation:
- Storage: Extensions stored in PostgreSQL with JSONB for structured features
- Authentication: FreeSWITCH queries API via mod_xml_curl for each registration
- Performance: Database queries are fast (< 5ms for typical extension lookups)
- Caching: Consider Redis caching for high-volume scenarios (not implemented)
Trade-offs:
Pros:
- ✅ Centralized, consistent storage
- ✅ Fast queries and transactions
- ✅ Audit trail via timestamps
- ✅ Structured data with JSONB
- ✅ Pod-agnostic (no file sync needed)
Cons:
- ❌ Dependency on API and PostgreSQL availability
- ❌ Network latency for each auth request (mitigated by LAN speed)
- ❌ More complex than flat XML files
Consequences:
- Extensions are database-only (no XML files)
- FreeSWITCH uses mod_xml_curl for every external authentication
- Cluster pods use blind registration (no authentication overhead)
- API must be highly available (database failure = no external registrations)