Skip to content

Commit 8526208

Browse files
committed
rest_api: Add tests for outbound websocket.
* Added AriServerFactory and AriServerProtocol to lib/python/asterisk/ari.py. * Created tests/rest_api/websocket. * Moved existing tests/rest_api/websocket_requests/* tests to tests/rest_api/websocket/rest_over_websocket. * Updated the rest_over_websocket tests to use reactor.run() properly. * Added test outbound_websocket/persistent. * Added test outbound_websocket/per_call. * Added test outbound_websocket/local_ari_user_read_only. * Updated rest_api/asterisk/modules/get_modules to load res_sorcery_config.so
1 parent 0bc767a commit 8526208

File tree

30 files changed

+680
-9
lines changed

30 files changed

+680
-9
lines changed

lib/python/asterisk/ari.py

+97-2
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,12 @@
2626
from twisted.internet import reactor
2727
try:
2828
from autobahn.websocket import WebSocketClientFactory, \
29-
WebSocketClientProtocol, connectWS
29+
WebSocketClientProtocol, connectWS, WebSocketServerFactory, \
30+
WebSocketServerProtocol
3031
except:
3132
from autobahn.twisted.websocket import WebSocketClientFactory, \
32-
WebSocketClientProtocol, connectWS
33+
WebSocketClientProtocol, connectWS, WebSocketServerFactory, \
34+
WebSocketServerProtocol
3335

3436
LOGGER = logging.getLogger(__name__)
3537

@@ -444,6 +446,99 @@ def sendRequest(self, method, uri, **kwargs):
444446
self.sendMessage(msg.encode('utf-8'))
445447
return uuidstr
446448

449+
class AriServerFactory(WebSocketServerFactory):
450+
"""Twisted protocol factory for building ARI WebSocket clients."""
451+
452+
def __init__(self, receiver, uri, protocols, server_name, reactor):
453+
"""Constructor
454+
455+
:param receiver The object that will receive events from the protocol
456+
:param uri: URI to be served.
457+
:param protocols: List of protocols to accept.
458+
:param reactor: The twisted reactor.
459+
"""
460+
try:
461+
WebSocketServerFactory.__init__(self, uri, protocols, server_name,
462+
reactor=reactor)
463+
except TypeError:
464+
WebSocketServerFactory.__init__(self, uri, protocols=['ari'])
465+
self.attempts = 0
466+
self.start = None
467+
self.receiver = receiver
468+
469+
def buildProtocol(self, addr):
470+
"""Make the protocol"""
471+
return AriServerProtocol(self.receiver, self)
472+
473+
class AriServerProtocol(WebSocketServerProtocol):
474+
"""Twisted protocol for handling a ARI WebSocket connection."""
475+
476+
def __init__(self, receiver, factory):
477+
"""Constructor.
478+
479+
:param receiver The event receiver
480+
"""
481+
try:
482+
super(AriServerProtocol, self).__init__()
483+
except TypeError as te:
484+
# Older versions of Autobahn use old style classes with no initializer.
485+
# Newer versions must have their initializer called by derived
486+
# implementations.
487+
LOGGER.debug("AriServerProtocol: TypeError thrown in init: {0}".format(te))
488+
LOGGER.debug("Made me a client protocol!")
489+
self.receiver = receiver
490+
self.factory = factory
491+
492+
def onConnect(self, request):
493+
"""Called back when connection is open."""
494+
LOGGER.debug("New WebSocket Connected")
495+
self.receiver.on_ws_connect(request)
496+
497+
def onOpen(self):
498+
"""Called back when connection is open."""
499+
LOGGER.debug("WebSocket Open")
500+
self.receiver.on_ws_open(self)
501+
502+
def onClose(self, wasClean, code, reason):
503+
"""Called back when connection is closed."""
504+
LOGGER.debug("WebSocket closed(%r, %d, %s)", wasClean, code, reason)
505+
self.receiver.on_ws_closed(self)
506+
507+
def onMessage(self, msg, binary):
508+
"""Called back when message is received.
509+
510+
:param msg: Received text message.
511+
"""
512+
LOGGER.debug("rxed: %s", msg)
513+
msg = json.loads(msg)
514+
self.receiver.on_ws_event(msg)
515+
516+
def sendRequest(self, method, uri, **kwargs):
517+
"""Send a REST Request over Websocket.
518+
519+
:param method: Method.
520+
:param path: Resource URI without query string.
521+
:param kwargs: Additional request parameters
522+
:returns: Request UUID
523+
"""
524+
uuidstr = kwargs.pop('request_id', str(uuid.uuid4()))
525+
req = {
526+
'type': 'RESTRequest',
527+
'request_id': uuidstr,
528+
'method': method,
529+
'uri': uri
530+
}
531+
532+
for k,v in kwargs.items():
533+
req[k] = v
534+
535+
msg = json.dumps(req)
536+
LOGGER.info("Sending request message: %s", msg)
537+
self.sendMessage(msg.encode('utf-8'))
538+
return uuidstr
539+
540+
541+
447542
class ARI(object):
448543
"""Bare bones object for an ARI interface."""
449544

tests/rest_api/asterisk/modules/get_modules/configs/ast1/modules.conf

+1
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ load => res_ari_recordings.so
1717
load => res_ari.so
1818
load => res_ari_sounds.so
1919
load => res_http_websocket.so
20+
load => res_sorcery_config.so
2021
load => res_stasis_answer.so
2122
load => res_stasis_device_state.so
2223
load => res_stasis_playback.so

tests/rest_api/asterisk/modules/get_modules/test-config.yaml

+8
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,13 @@ ari-config:
240240
"status": "Running",
241241
"support_level": "core"
242242
}
243+
-
244+
{
245+
"name": "res_sorcery_config.so",
246+
"description": "Sorcery Configuration File Object Wizard",
247+
"status": "Running",
248+
"support_level": "core"
249+
}
243250
-
244251
{
245252
"name": "res_stasis.so",
@@ -324,6 +331,7 @@ properties:
324331
- asterisk: res_ari_recordings
325332
- asterisk: res_ari_sounds
326333
- asterisk: res_http_websocket
334+
- asterisk: res_sorcery_config
327335
- asterisk: res_stasis
328336
- asterisk: res_stasis_answer
329337
- asterisk: res_stasis_device_state

tests/rest_api/tests.yaml

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Enter tests here in the order they should be considered for execution:
22
tests:
3-
- dir: 'websocket_requests'
3+
- dir: 'websocket'
44
- test: 'continue'
55
- test: 'authentication'
66
- test: 'CORS'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
[general]
2+
enabled = yes
3+
4+
[user1]
5+
type = user
6+
password = userpass
7+
read_only = yes
8+
9+
[connection1]
10+
type = outbound_websocket
11+
uri = ws://127.0.0.1:9000/ari
12+
apps = testapp
13+
subscribe_all = no
14+
protocols = ari
15+
; base64(myuser:mypass) = bXl1c2VyOm15cGFzcw==
16+
username = myuser
17+
password = mypass
18+
local_ari_user = user1
19+
connection_type = per_call_config
20+
connection_timeout = 1000
21+
reconnect_interval = 1000
22+
reconnect_attempts = 10
23+
tls_enabled = no
24+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
[default]
2+
exten = echo,1,NoOp(Echo test)
3+
same = n,Answer()
4+
same = n,Echo()
5+
6+
exten = stasis,1,NoOp(Stasis test)
7+
same = n,Stasis(testapp)
8+
same = n,Verbose(Return from stasis: ${STASISSTATUS})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
#!/usr/bin/env python
2+
3+
"""A test that creates a websocket server and waits for events
4+
5+
Copyright (C) 2025, Sangoma Technologies Corporation
6+
George Joseph <[email protected]>
7+
8+
This program is free software, distributed under the terms of
9+
the GNU General Public License Version 2.
10+
"""
11+
12+
import sys
13+
import logging
14+
import json
15+
import os
16+
import time
17+
from twisted.internet import reactor
18+
19+
sys.path.append("lib/python")
20+
21+
from asterisk.test_case import TestCase
22+
from asterisk.ari import AriServerFactory
23+
24+
LOGGER = logging.getLogger(__name__)
25+
26+
class PerCallWebsocketTest(TestCase):
27+
28+
def __init__(self):
29+
TestCase.__init__(self)
30+
self.protocol = None
31+
self.connection_count = 0
32+
self.peer = ""
33+
self.auth_header = ""
34+
self.auth_failed = False
35+
self.channel_id = ""
36+
37+
self.reactor_timeout = 10
38+
if self.test_config.config and 'test-object-config' in self.test_config.config:
39+
self.reactor_timeout = self.test_config.config['test-object-config'].get('reactor-timeout', 10)
40+
41+
self.factory = AriServerFactory(self, "ws://localhost:9000/ari",
42+
['ari'], "localhost", reactor)
43+
def on_startup(ast):
44+
LOGGER.info("Asterisk started")
45+
# We want the stasis extension to answer first
46+
self.ast[0].cli_exec("channel originate Local/stasis@default extension echo@default")
47+
48+
LOGGER.info("Registering startup observer")
49+
self.register_start_observer(on_startup)
50+
self.create_asterisk()
51+
52+
def run(self):
53+
"""Entry point for the twisted reactor"""
54+
super(PerCallWebsocketTest, self).run()
55+
LOGGER.info("Binding websocket server")
56+
reactor.listenTCP(9000, self.factory, 10, "127.0.0.1")
57+
58+
def on_reactor_timeout(self):
59+
LOGGER.info("on_reactor_timeout")
60+
self.passed = False
61+
62+
def on_ws_connect(self, request):
63+
self.connection_count +=1
64+
LOGGER.info("WebSocket connection %d from %s attempted" % (self.connection_count, request.peer))
65+
self.peer = request.peer
66+
"""
67+
Normally authentication would be done by the web server
68+
but trying to integrate that here would be too complicated.
69+
Instead we simply check the raw value of the Authorization
70+
header.
71+
"""
72+
if 'authorization' in request.headers:
73+
auth_header = request.headers['authorization']
74+
# base64(myuser:mypass) = bXl1c2VyOm15cGFzcw==
75+
if auth_header != "Basic bXl1c2VyOm15cGFzcw==":
76+
LOGGER.error("Authentication failed")
77+
self.auth_failed = True
78+
return "ari"
79+
80+
def on_ws_open(self, protocol):
81+
LOGGER.info("WebSocket connection %d from %s opened" % (self.connection_count, self.peer))
82+
self.protocol = protocol
83+
84+
def on_ws_closed(self, protocol):
85+
LOGGER.info("WebSocket connection %d from %s closed. Stopping reactor" % (self.connection_count, self.peer))
86+
self.stop_reactor()
87+
88+
def on_ws_event(self, message):
89+
if self.auth_failed:
90+
self.passed = False
91+
self.ast[0].cli_exec("ari shutdown websocket sessions")
92+
return
93+
msg_type = message.get('type')
94+
LOGGER.info("Received message: %s" % json.dumps(message))
95+
96+
def closer():
97+
self.protocol.sendClose()
98+
99+
if msg_type == "StasisStart":
100+
self.channel_id = message['channel']['id']
101+
self.protocol.sendRequest("POST", "channels/%s/answer" % self.channel_id)
102+
elif msg_type == "RESTResponse":
103+
if message['status_code'] == 403:
104+
LOGGER.info("Received '403 Forbidden' (good)")
105+
self.passed = True
106+
else:
107+
LOGGER.error("Received '%d %s' instead of '403 Forbidden'" % (message['status_code'], message['reason_phrase']))
108+
self.passed = False
109+
LOGGER.info("Hanging up channel")
110+
self.ast[0].cli_exec("channel request hangup all")
111+
112+
def main():
113+
test = PerCallWebsocketTest()
114+
reactor.run()
115+
116+
if not test.passed:
117+
return 1
118+
119+
return 0
120+
121+
if __name__ == "__main__":
122+
sys.exit(main() or 0)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
testinfo:
2+
summary: Test that local_ari_user read-only is respected
3+
description: |
4+
Test Steps
5+
* The test script originates a call between the 'stasis' and 'echo'
6+
extensions. This causes asterisk to open a websocket connection to the
7+
test script and send ApplicationRegistered and StasisStart.
8+
* The test script verifies that the Authorization header is present and correct.
9+
* The test script detects the first ApplicationRegistered which it ignores.
10+
* The test script detects StasisStart and answers the call using a
11+
REST over Websocket request.
12+
* Asterisk determines that the configured local_ari_user has only read-only
13+
permissions and responds with a 403 Forbidden in a RESTResponse.
14+
* The test script detects the 403 and marks the test as passed then issues
15+
a 'channel request hangup all' CLI command.
16+
* Asterisk hangs up the call, sends a StasisEnd and closes teh websocket.
17+
* The test script detects the websocket close and stops the reactor.
18+
19+
test-object-config:
20+
reactor-timeout: 10
21+
22+
properties:
23+
dependencies:
24+
- python : autobahn
25+
- python : requests
26+
- python : twisted
27+
- asterisk : res_ari
28+
- asterisk : res_http_websocket
29+
- asterisk : res_ari_events
30+
- asterisk : res_ari_asterisk
31+
- asterisk : chan_pjsip
32+
tags:
33+
- ARI
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
[general]
2+
enabled = yes
3+
4+
[user1]
5+
type = user
6+
password = userpass
7+
8+
[connection1]
9+
type = outbound_websocket
10+
uri = ws://127.0.0.1:9000/ari
11+
apps = testapp
12+
subscribe_all = no
13+
protocols = ari
14+
; base64(myuser:mypass) = bXl1c2VyOm15cGFzcw==
15+
username = myuser
16+
password = mypass
17+
local_ari_user = user1
18+
connection_type = per_call_config
19+
connection_timeout = 1000
20+
reconnect_interval = 1000
21+
reconnect_attempts = 10
22+
tls_enabled = no
23+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
[default]
2+
exten = echo,1,NoOp(Echo test)
3+
same = n,Answer()
4+
same = n,Echo()
5+
6+
exten = stasis,1,NoOp(Stasis test)
7+
same = n,Stasis(testapp)
8+
same = n,Verbose(Return from stasis: ${STASISSTATUS})

0 commit comments

Comments
 (0)