remove iq auth
Remove old iq auth that is replaced by sasl
This commit is contained in:
parent
9d95cbd606
commit
3060e7db20
2 changed files with 155 additions and 103 deletions
|
|
@ -402,22 +402,20 @@ class XMPPAsyncClient:
|
||||||
if self.TLSUpgraded == False:
|
if self.TLSUpgraded == False:
|
||||||
# With STARTTLS #https://xmpp.org/rfcs/rfc3920.html
|
# With STARTTLS #https://xmpp.org/rfcs/rfc3920.html
|
||||||
self.send(
|
self.send(
|
||||||
'<stream:features><starttls xmlns="urn:ietf:params:xml:ns:xmpp-tls"><required/></starttls><auth xmlns="http://jabber.org/features/iq-auth"/><mechanisms xmlns="urn:ietf:params:xml:ns:xmpp-sasl"><mechanism>PLAIN</mechanism></mechanisms></stream:features>'
|
'<stream:features><starttls xmlns="urn:ietf:params:xml:ns:xmpp-tls"><required/></starttls><mechanisms xmlns="urn:ietf:params:xml:ns:xmpp-sasl"><mechanism>PLAIN</mechanism></mechanisms></stream:features>'
|
||||||
)
|
)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Already using TLS send authentication support for iq-auth (fallback) and SASL
|
# Already using TLS send authentication support for SASL
|
||||||
self.send(
|
self.send(
|
||||||
'<stream:features><auth xmlns="http://jabber.org/features/iq-auth"/><mechanisms xmlns="urn:ietf:params:xml:ns:xmpp-sasl"><mechanism>PLAIN</mechanism></mechanisms></stream:features>'
|
'<stream:features><mechanisms xmlns="urn:ietf:params:xml:ns:xmpp-sasl"><mechanism>PLAIN</mechanism></mechanisms></stream:features>'
|
||||||
)
|
)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
self.send("</stream>")
|
self.send("</stream>")
|
||||||
|
|
||||||
else:
|
else:
|
||||||
if "jabber:iq:auth" in xml.tag: # Handle iq-auth
|
if (
|
||||||
self._handle_iq_auth(xml)
|
|
||||||
elif (
|
|
||||||
"urn:ietf:params:xml:ns:xmpp-sasl" in xml.tag
|
"urn:ietf:params:xml:ns:xmpp-sasl" in xml.tag
|
||||||
): # Handle SASL Auth
|
): # Handle SASL Auth
|
||||||
self._handle_sasl_auth(xml)
|
self._handle_sasl_auth(xml)
|
||||||
|
|
@ -486,98 +484,6 @@ class XMPPAsyncClient:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
xmppserverlog.exception("{}".format(e))
|
xmppserverlog.exception("{}".format(e))
|
||||||
|
|
||||||
def _handle_iq_auth(self, data):
|
|
||||||
try:
|
|
||||||
xml = ET.fromstring(data.decode("utf-8"))
|
|
||||||
ctl = xml[0][0]
|
|
||||||
xmppserverlog.info("IQ AUTH XML: {}".format(xml))
|
|
||||||
# Received username and auth tag, send username/password requirement
|
|
||||||
if (
|
|
||||||
xml.get("type") == "get"
|
|
||||||
and "auth}username" in ctl.tag
|
|
||||||
and self.type == self.UNKNOWN
|
|
||||||
):
|
|
||||||
self.send(
|
|
||||||
'<iq type="result" id="{}"><query xmlns="jabber:iq:auth"><username/><password/></query></iq>'.format(
|
|
||||||
xml.get("id")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Received username, password, resource - Handle auth here and return pass or fail
|
|
||||||
if (
|
|
||||||
xml.get("type") == "set"
|
|
||||||
and "auth}username" in ctl.tag
|
|
||||||
and self.type == self.UNKNOWN
|
|
||||||
):
|
|
||||||
xmlauth = xml[0].getchildren()
|
|
||||||
# uid = ""
|
|
||||||
password = ""
|
|
||||||
authcode = ""
|
|
||||||
resource = ""
|
|
||||||
for aitem in xmlauth:
|
|
||||||
if "username" in aitem.tag:
|
|
||||||
self.uid = aitem.text
|
|
||||||
|
|
||||||
elif "password" in aitem.tag:
|
|
||||||
password = aitem.text.split("/")[2]
|
|
||||||
authcode = password
|
|
||||||
|
|
||||||
elif "resource" in aitem.tag:
|
|
||||||
self.clientresource = aitem.text
|
|
||||||
resource = self.clientresource
|
|
||||||
|
|
||||||
if self.devclass: # if there is a devclass it is a bot
|
|
||||||
bumper.bot_add("", self.uid, "", resource, "eco-legacy")
|
|
||||||
xmppserverlog.debug("bot authenticated {}".format(self.uid))
|
|
||||||
|
|
||||||
# Client authenticated, move to next state
|
|
||||||
self._set_state("INIT")
|
|
||||||
|
|
||||||
# Successful auth
|
|
||||||
self.send('<iq type="result" id="{}"/>'.format(xml.get("id")))
|
|
||||||
|
|
||||||
else:
|
|
||||||
auth = False
|
|
||||||
if bumper.check_authcode(self.uid, authcode):
|
|
||||||
auth = True
|
|
||||||
elif bumper.use_auth == False:
|
|
||||||
auth = True
|
|
||||||
|
|
||||||
if auth:
|
|
||||||
bumper.client_add(self.uid, "bumper", self.clientresource)
|
|
||||||
xmppserverlog.debug("client authenticated {}".format(self.uid))
|
|
||||||
|
|
||||||
# Client authenticated, move to next state
|
|
||||||
self._set_state("INIT")
|
|
||||||
|
|
||||||
# Successful auth
|
|
||||||
self.send('<iq type="result" id="{}"/>'.format(xml.get("id")))
|
|
||||||
|
|
||||||
else:
|
|
||||||
# Failed auth
|
|
||||||
self.send(
|
|
||||||
'<iq type="error" id="{}"><error code="401" type="auth"><not-authorized xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/></error></iq>'.format(
|
|
||||||
xml.get("id")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
except ET.ParseError as e:
|
|
||||||
if "no element found" in e.msg:
|
|
||||||
xmppserverlog.debug(
|
|
||||||
"xml parse error - {} - {}".format(data.decode("utf-8"), e)
|
|
||||||
)
|
|
||||||
elif "not well-formed (invalid token)" in e.msg:
|
|
||||||
xmppserverlog.debug(
|
|
||||||
"xml parse error - {} - {}".format(data.decode("utf-8"), e)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
xmppserverlog.debug(
|
|
||||||
"xml parse error - {} - {}".format(data.decode("utf-8"), e)
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
xmppserverlog.exception("{}".format(e))
|
|
||||||
|
|
||||||
def _handle_sasl_auth(self, xml):
|
def _handle_sasl_auth(self, xml):
|
||||||
try:
|
try:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ async def test_client_connect_no_starttls(*args, **kwargs):
|
||||||
# Server tells client available features
|
# Server tells client available features
|
||||||
assert (
|
assert (
|
||||||
mock_send.mock_calls[1].args[0]
|
mock_send.mock_calls[1].args[0]
|
||||||
== '<stream:features><starttls xmlns="urn:ietf:params:xml:ns:xmpp-tls"><required/></starttls><auth xmlns="http://jabber.org/features/iq-auth"/><mechanisms xmlns="urn:ietf:params:xml:ns:xmpp-sasl"><mechanism>PLAIN</mechanism></mechanisms></stream:features>'
|
== '<stream:features><starttls xmlns="urn:ietf:params:xml:ns:xmpp-tls"><required/></starttls><mechanisms xmlns="urn:ietf:params:xml:ns:xmpp-sasl"><mechanism>PLAIN</mechanism></mechanisms></stream:features>'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Reset mock calls
|
# Reset mock calls
|
||||||
|
|
@ -60,6 +60,38 @@ async def test_client_connect_no_starttls(*args, **kwargs):
|
||||||
assert xmppclient.state == xmppclient.INIT # Client moved to INIT state
|
assert xmppclient.state == xmppclient.INIT # Client moved to INIT state
|
||||||
|
|
||||||
|
|
||||||
|
async def test_client_end_stream(*args, **kwargs):
|
||||||
|
test_transport = asyncio.Transport()
|
||||||
|
test_transport.get_extra_info = mock.Mock(return_value=mock_transport_extra_info())
|
||||||
|
test_transport.write = mock.Mock(return_value=return_send_data)
|
||||||
|
xmppclient = bumper.xmppserver.XMPPAsyncClient(test_transport)
|
||||||
|
xmppclient.state = xmppclient.CONNECT # Set client state to CONNECT
|
||||||
|
mock_send = xmppclient.send = mock.Mock(side_effect=return_send_data)
|
||||||
|
|
||||||
|
# Send end stream from "client"
|
||||||
|
test_data = "</stream:stream>".encode("utf-8")
|
||||||
|
xmppclient._parse_data(test_data)
|
||||||
|
|
||||||
|
# Expect 2 calls to send
|
||||||
|
assert mock_send.call_count == 1
|
||||||
|
# Server opens stream
|
||||||
|
assert mock_send.mock_calls[0].args[0] == "</stream:stream>"
|
||||||
|
|
||||||
|
# Reset mock calls
|
||||||
|
mock_send.reset_mock()
|
||||||
|
|
||||||
|
# Send abnormal stream from "client"
|
||||||
|
test_data = "<badstr />".encode("utf-8")
|
||||||
|
xmppclient._parse_data(test_data)
|
||||||
|
|
||||||
|
# Reset mock calls
|
||||||
|
mock_send.reset_mock()
|
||||||
|
|
||||||
|
# Send blank from "client"
|
||||||
|
test_data = "".encode("utf-8")
|
||||||
|
xmppclient._parse_data(test_data)
|
||||||
|
|
||||||
|
|
||||||
async def test_client_connect_starttls_called(*args, **kwargs):
|
async def test_client_connect_starttls_called(*args, **kwargs):
|
||||||
test_transport = asyncio.Transport()
|
test_transport = asyncio.Transport()
|
||||||
test_transport.get_extra_info = mock.Mock(return_value=mock_transport_extra_info())
|
test_transport.get_extra_info = mock.Mock(return_value=mock_transport_extra_info())
|
||||||
|
|
@ -84,7 +116,7 @@ async def test_client_connect_starttls_called(*args, **kwargs):
|
||||||
# Server tells client available features
|
# Server tells client available features
|
||||||
assert (
|
assert (
|
||||||
mock_send.mock_calls[1].args[0]
|
mock_send.mock_calls[1].args[0]
|
||||||
== '<stream:features><starttls xmlns="urn:ietf:params:xml:ns:xmpp-tls"><required/></starttls><auth xmlns="http://jabber.org/features/iq-auth"/><mechanisms xmlns="urn:ietf:params:xml:ns:xmpp-sasl"><mechanism>PLAIN</mechanism></mechanisms></stream:features>'
|
== '<stream:features><starttls xmlns="urn:ietf:params:xml:ns:xmpp-tls"><required/></starttls><mechanisms xmlns="urn:ietf:params:xml:ns:xmpp-sasl"><mechanism>PLAIN</mechanism></mechanisms></stream:features>'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Reset mock calls
|
# Reset mock calls
|
||||||
|
|
@ -117,7 +149,7 @@ async def test_client_connect_starttls_called(*args, **kwargs):
|
||||||
# Server tells client available features (without STARTTLS)
|
# Server tells client available features (without STARTTLS)
|
||||||
assert (
|
assert (
|
||||||
mock_send.mock_calls[1].args[0]
|
mock_send.mock_calls[1].args[0]
|
||||||
== '<stream:features><auth xmlns="http://jabber.org/features/iq-auth"/><mechanisms xmlns="urn:ietf:params:xml:ns:xmpp-sasl"><mechanism>PLAIN</mechanism></mechanisms></stream:features>'
|
== '<stream:features><mechanisms xmlns="urn:ietf:params:xml:ns:xmpp-sasl"><mechanism>PLAIN</mechanism></mechanisms></stream:features>'
|
||||||
)
|
)
|
||||||
# Reset mock calls
|
# Reset mock calls
|
||||||
mock_send.reset_mock()
|
mock_send.reset_mock()
|
||||||
|
|
@ -135,6 +167,80 @@ async def test_client_connect_starttls_called(*args, **kwargs):
|
||||||
assert xmppclient.state == xmppclient.INIT # Client moved to INIT state
|
assert xmppclient.state == xmppclient.INIT # Client moved to INIT state
|
||||||
|
|
||||||
|
|
||||||
|
async def test_client_init(*args, **kwargs):
|
||||||
|
test_transport = asyncio.Transport()
|
||||||
|
test_transport.get_extra_info = mock.Mock(return_value=mock_transport_extra_info())
|
||||||
|
test_transport.write = mock.Mock(return_value=return_send_data)
|
||||||
|
xmppclient = bumper.xmppserver.XMPPAsyncClient(test_transport)
|
||||||
|
xmppclient.state = xmppclient.INIT # Set client state to INIT
|
||||||
|
xmppclient.uid = "fuid_tmpuser"
|
||||||
|
xmppclient.resource = "IOSF53D07BA"
|
||||||
|
xmppclient.bumper_jid = "fuid_tmpuser@ecouser.net/IOSF53D07BA"
|
||||||
|
xmppclient.type = xmppclient.CONTROLLER
|
||||||
|
mock_send = xmppclient.send = mock.Mock(side_effect=return_send_data)
|
||||||
|
|
||||||
|
# Send connect stream from "client"
|
||||||
|
test_data = "<stream:stream xmlns='jabber:client' xmlns:stream='http://etherx.jabber.org/streams' version='1.0' to='ecouser.net'>".encode(
|
||||||
|
"utf-8"
|
||||||
|
)
|
||||||
|
xmppclient._parse_data(test_data)
|
||||||
|
|
||||||
|
# Expect 2 calls to send
|
||||||
|
assert mock_send.call_count == 2
|
||||||
|
# Server opens stream
|
||||||
|
assert (
|
||||||
|
mock_send.mock_calls[0].args[0]
|
||||||
|
== '<stream:stream xmlns:stream="http://etherx.jabber.org/streams" xmlns="jabber:client" version="1.0" id="1" from="ecouser.net">'
|
||||||
|
)
|
||||||
|
# Server tells client binds
|
||||||
|
assert (
|
||||||
|
mock_send.mock_calls[1].args[0]
|
||||||
|
== '<stream:features><bind xmlns="urn:ietf:params:xml:ns:xmpp-bind"/><session xmlns="urn:ietf:params:xml:ns:xmpp-session"/></stream:features>'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Reset mock calls
|
||||||
|
mock_send.reset_mock()
|
||||||
|
|
||||||
|
# Send bind from "client"
|
||||||
|
test_data = '<iq type="set" id="5E9872D5-547E-49AF-AE51-9EFAA282F952"><bind xmlns="urn:ietf:params:xml:ns:xmpp-bind"><resource>IOSF53D07BA</resource></bind></iq>'.encode(
|
||||||
|
"utf-8"
|
||||||
|
)
|
||||||
|
xmppclient._parse_data(test_data)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
mock_send.mock_calls[0].args[0]
|
||||||
|
== '<iq type="result" id="5E9872D5-547E-49AF-AE51-9EFAA282F952"><bind xmlns="urn:ietf:params:xml:ns:xmpp-bind"><jid>fuid_tmpuser@ecouser.net/IOSF53D07BA</jid></bind></iq>'
|
||||||
|
) # client successfully binded
|
||||||
|
assert xmppclient.state == xmppclient.BIND # client moved to BIND state
|
||||||
|
|
||||||
|
# Reset mock calls
|
||||||
|
mock_send.reset_mock()
|
||||||
|
|
||||||
|
# Send set session from client
|
||||||
|
test_data = '<iq type="set" id="FA1041E7-AA27-43DD-BAA3-64DE2DE56AA3"><session xmlns="urn:ietf:params:xml:ns:xmpp-session"/></iq>'.encode(
|
||||||
|
"utf-8"
|
||||||
|
)
|
||||||
|
xmppclient._parse_data(test_data)
|
||||||
|
|
||||||
|
assert xmppclient.state == xmppclient.READY # client moved to READY state
|
||||||
|
assert (
|
||||||
|
mock_send.mock_calls[0].args[0]
|
||||||
|
== '<iq type="result" id="FA1041E7-AA27-43DD-BAA3-64DE2DE56AA3" />'
|
||||||
|
) # client ready
|
||||||
|
|
||||||
|
# Reset mock calls
|
||||||
|
mock_send.reset_mock()
|
||||||
|
|
||||||
|
# Send presense from client
|
||||||
|
test_data = '<presence type="available"/>'.encode("utf-8")
|
||||||
|
xmppclient._parse_data(test_data)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
mock_send.mock_calls[0].args[0]
|
||||||
|
== '<presence to="fuid_tmpuser@ecouser.net/IOSF53D07BA"> dummy </presence>'
|
||||||
|
) # client presence - dummy response
|
||||||
|
|
||||||
|
|
||||||
async def test_bot_connect(*args, **kwargs):
|
async def test_bot_connect(*args, **kwargs):
|
||||||
test_transport = asyncio.Transport()
|
test_transport = asyncio.Transport()
|
||||||
test_transport.get_extra_info = mock.Mock(return_value=mock_transport_extra_info())
|
test_transport.get_extra_info = mock.Mock(return_value=mock_transport_extra_info())
|
||||||
|
|
@ -159,7 +265,7 @@ async def test_bot_connect(*args, **kwargs):
|
||||||
# Server tells client available features
|
# Server tells client available features
|
||||||
assert (
|
assert (
|
||||||
mock_send.mock_calls[1].args[0]
|
mock_send.mock_calls[1].args[0]
|
||||||
== '<stream:features><starttls xmlns="urn:ietf:params:xml:ns:xmpp-tls"><required/></starttls><auth xmlns="http://jabber.org/features/iq-auth"/><mechanisms xmlns="urn:ietf:params:xml:ns:xmpp-sasl"><mechanism>PLAIN</mechanism></mechanisms></stream:features>'
|
== '<stream:features><starttls xmlns="urn:ietf:params:xml:ns:xmpp-tls"><required/></starttls><mechanisms xmlns="urn:ietf:params:xml:ns:xmpp-sasl"><mechanism>PLAIN</mechanism></mechanisms></stream:features>'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Reset mock calls
|
# Reset mock calls
|
||||||
|
|
@ -187,6 +293,7 @@ async def test_bot_init(*args, **kwargs):
|
||||||
xmppclient.state = xmppclient.INIT # Set client state to INIT
|
xmppclient.state = xmppclient.INIT # Set client state to INIT
|
||||||
xmppclient.uid = "E0000000000000001234"
|
xmppclient.uid = "E0000000000000001234"
|
||||||
xmppclient.devclass = "159"
|
xmppclient.devclass = "159"
|
||||||
|
xmppclient.type = xmppclient.BOT
|
||||||
mock_send = xmppclient.send = mock.Mock(side_effect=return_send_data)
|
mock_send = xmppclient.send = mock.Mock(side_effect=return_send_data)
|
||||||
|
|
||||||
# Send connect stream from "bot"
|
# Send connect stream from "bot"
|
||||||
|
|
@ -302,12 +409,22 @@ async def test_ping_client_to_client(*args, **kwargs):
|
||||||
)
|
)
|
||||||
xmppclient._parse_data(test_data)
|
xmppclient._parse_data(test_data)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
mock_send2.mock_calls[0].args[0]
|
||||||
|
== '<iq from="E0000000000000001234@159.ecorobot.net/atom" id="104934615" to="fuid_tmpuser@ecouser.net/IOSF53D07BA" type="get"><ping xmlns="urn:xmpp:ping" /></iq>'
|
||||||
|
) # ping response
|
||||||
|
|
||||||
# Ping response from bot to user
|
# Ping response from bot to user
|
||||||
test_data = "<iq type='result' to='E0000000000000001234@159.ecorobot.net/atom' id='104934615'/>".encode(
|
test_data = "<iq type='result' to='E0000000000000001234@159.ecorobot.net/atom' id='104934615'/>".encode(
|
||||||
"utf-8"
|
"utf-8"
|
||||||
)
|
)
|
||||||
xmppclient2._parse_data(test_data)
|
xmppclient2._parse_data(test_data)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
mock_send.mock_calls[0].args[0]
|
||||||
|
== '<iq from="fuid_tmpuser@ecouser.net/IOSF53D07BA" id="104934615" to="E0000000000000001234@159.ecorobot.net/atom" type="result" />'
|
||||||
|
) # ping response
|
||||||
|
|
||||||
|
|
||||||
async def test_client_send_iq(*args, **kwargs):
|
async def test_client_send_iq(*args, **kwargs):
|
||||||
test_transport = asyncio.Transport()
|
test_transport = asyncio.Transport()
|
||||||
|
|
@ -341,7 +458,7 @@ async def test_client_send_iq(*args, **kwargs):
|
||||||
assert (
|
assert (
|
||||||
mock_send.mock_calls[0].args[0]
|
mock_send.mock_calls[0].args[0]
|
||||||
== '<iq type="error" id="EE0XQ-2"><error type="cancel" code="501"><feature-not-implemented xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/></error></iq>'
|
== '<iq type="error" id="EE0XQ-2"><error type="cancel" code="501"><feature-not-implemented xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/></error></iq>'
|
||||||
) # ping response
|
) # feature not implemented response
|
||||||
|
|
||||||
# Reset mock calls
|
# Reset mock calls
|
||||||
mock_send.reset_mock()
|
mock_send.reset_mock()
|
||||||
|
|
@ -373,3 +490,32 @@ async def test_client_send_iq(*args, **kwargs):
|
||||||
|
|
||||||
# Reset mock calls
|
# Reset mock calls
|
||||||
mock_send.reset_mock()
|
mock_send.reset_mock()
|
||||||
|
|
||||||
|
# Bot result
|
||||||
|
test_data = "<iq type='result' from='E0000000000000001234@159.ecorobot.net/atom' to='ecouser.net' id='s2c1'/>".encode(
|
||||||
|
"utf-8"
|
||||||
|
)
|
||||||
|
xmppclient2._parse_data(test_data)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
mock_send.mock_calls[0].args[0]
|
||||||
|
== '<iq from="E0000000000000001234@159.ecorobot.net/atom" id="s2c1" to="ecouser.net" type="result" />'
|
||||||
|
) # result sent to ecouser.net
|
||||||
|
|
||||||
|
# Reset mock calls
|
||||||
|
mock_send.reset_mock()
|
||||||
|
|
||||||
|
# Bot iq set
|
||||||
|
test_data = "<iq to='fuid_tmpuser@ecouser.net/IOSF53D07BA' type='set' id='2700'><query xmlns='com:ctl'><ctl td='BatteryInfo'><battery power='100'/></ctl></query></iq>".encode(
|
||||||
|
"utf-8"
|
||||||
|
)
|
||||||
|
xmppclient2._parse_data(test_data)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
mock_send.mock_calls[0].args[0]
|
||||||
|
== '<iq from="E0000000000000001234@159.ecorobot.net/atom" id="2700" to="fuid_tmpuser@ecouser.net/IOSF53D07BA" type="set"><query xmlns="com:ctl"><ctl td="BatteryInfo"><battery power="100" /></ctl></query></iq>'
|
||||||
|
) # result sent to ecouser.net
|
||||||
|
|
||||||
|
# Reset mock calls
|
||||||
|
mock_send.reset_mock()
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue