Skip to content

Commit 7404a92

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.
1 parent 0bc767a commit 7404a92

File tree

28 files changed

+676
-9
lines changed

28 files changed

+676
-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/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://localhost: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 = 500
21+
reconnect_interval = 1000
22+
reconnect_attempts = 5
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,123 @@
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)
57+
58+
def on_reactor_timeout(self):
59+
LOGGER.info("on_reactor_timeout")
60+
self.passed = False
61+
self.stop_reactor()
62+
63+
def on_ws_connect(self, request):
64+
self.connection_count +=1
65+
LOGGER.info("WebSocket connection %d from %s attempted" % (self.connection_count, request.peer))
66+
self.peer = request.peer
67+
"""
68+
Normally authentication would be done by the web server
69+
but trying to integrate that here would be too complicated.
70+
Instead we simply check the raw value of the Authorization
71+
header.
72+
"""
73+
if 'authorization' in request.headers:
74+
auth_header = request.headers['authorization']
75+
# base64(myuser:mypass) = bXl1c2VyOm15cGFzcw==
76+
if auth_header != "Basic bXl1c2VyOm15cGFzcw==":
77+
LOGGER.error("Authentication failed")
78+
self.auth_failed = True
79+
return "ari"
80+
81+
def on_ws_open(self, protocol):
82+
LOGGER.info("WebSocket connection %d from %s opened" % (self.connection_count, self.peer))
83+
self.protocol = protocol
84+
85+
def on_ws_closed(self, protocol):
86+
LOGGER.info("WebSocket connection %d from %s closed. Stopping reactor" % (self.connection_count, self.peer))
87+
self.stop_reactor()
88+
89+
def on_ws_event(self, message):
90+
if self.auth_failed:
91+
self.passed = False
92+
self.ast[0].cli_exec("ari shut sessions")
93+
return
94+
msg_type = message.get('type')
95+
LOGGER.info("Received message: %s" % json.dumps(message))
96+
97+
def closer():
98+
self.protocol.sendClose()
99+
100+
if msg_type == "StasisStart":
101+
self.channel_id = message['channel']['id']
102+
self.protocol.sendRequest("POST", "channels/%s/answer" % self.channel_id)
103+
elif msg_type == "RESTResponse":
104+
if message['status_code'] == 403:
105+
LOGGER.info("Received '403 Forbidden' (good)")
106+
self.passed = True
107+
else:
108+
LOGGER.error("Received '%d %s' instead of '403 Forbidden'" % (message['status_code'], message['reason_phrase']))
109+
self.passed = False
110+
LOGGER.info("Hanging up channel")
111+
self.ast[0].cli_exec("channel request hangup all")
112+
113+
def main():
114+
test = PerCallWebsocketTest()
115+
reactor.run()
116+
117+
if not test.passed:
118+
return 1
119+
120+
return 0
121+
122+
if __name__ == "__main__":
123+
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://localhost: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 = 500
20+
reconnect_interval = 1000
21+
reconnect_attempts = 5
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)