├── .gitignore ├── LICENSE ├── README.md ├── config.toml ├── mxvoiptestd ├── __init__.py ├── __main__.py ├── datachannel_test.py ├── sdp_doctor.py ├── static │ ├── adapter.min.0.15.4.js │ ├── hello.txt │ ├── tester.css │ ├── tester.html │ ├── tester_abstract.js │ └── tester_builtin.js └── webapi.py ├── scss ├── _normalize.scss ├── _skeleton.scss └── tester.scss ├── setup.cfg └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | .sass-cache 3 | /matrix_voip_tester.egg-info 4 | /mxvoiptestd/__pycache__ 5 | /.private 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This is a work-in-progress VoIP test utility for Matrix. 2 | 3 | There is the opportunity to build on it programatically, but the project also 4 | includes a web frontend that runs in-browser. 5 | 6 | It tests your homeserver's STUN/TURN servers and generates a report and score. 7 | 8 | 9 | ## mxvoiptestd (test daemon) 10 | 11 | ### How it's used 12 | 13 | The user accesses the test utility page in their web browser. (Their web browser 14 | must support WebRTC.) 15 | 16 | The user logs in to an account on their homeserver and their web browser requests 17 | the homeserver's ICE (STUN/TURN) server details. 18 | 19 | The browser contacts the TURN servers and gathers ICE candidates. Once this is 20 | done, the browser offers the candidates to the `mxvoiptestd` server and waits for 21 | an answer. 22 | 23 | `mxvoiptestd` begins gathering its own candidates and answers. 24 | 25 | The browser and the server connect to each other and exchange a greeting over an 26 | `RTCDataChannel`. 27 | 28 | This process is repeated for every TURN URI and in both IPv4 and IPv6 to build up 29 | a report. 30 | 31 | 32 | #### Current scoring system 33 | 34 | **Overall scores for IPv4 and IPv6 support**: 35 | 36 | * Fail: No STUN or TURN support whatsoever. 37 | * Poor: Either only STUN or only TURN. 38 | * Good: STUN and TURN supported over at least one protocol. 39 | * Great: STUN and TURN over both TCP and UDP, both secure and insecure. 40 | * Excellent: In addition to great, there is a secure TURN service over TCP port 41 | 443 (which looks like HTTPS traffic and will likely get through more firewalls). 42 | 43 | Note: The requirement to have insecure transports to get a 'Great' score is 44 | likely to go away. 45 | 46 | **Scores for individual TURN URIs:** 47 | 48 | * Fail: Neither STUN candidates nor (working) TURN candidates found. 49 | * Poor: Either only STUN candidates found, or only (working) TURN candidates found. 50 | * Excellent: Both STUN candidates and TURN candidates found — and the TURN candidate 51 | was tested to work. 52 | 53 | Note: Currently, only unencrypted UDP TURN URIs seem to generate STUN 54 | URIs and thus other TURN URIs fail to achieve Excellent — even on reputable 55 | servers. This is being investigated and may be a bug in the tester. 56 | 57 | 58 | ### Warning 59 | 60 | This utility is not yet finished and is not known to behave or report correctly; 61 | please see the list of issues for points of needed development and investigation. 62 | 63 | Notably, there is doubt about the grades assigned by this tool, so there is not 64 | necessarily anything wrong if you receive *poor* and only *good* scores on some 65 | aspects. 66 | 67 | 68 | ### Thoughts on further directions 69 | 70 | The browser WebRTC API is quite restrictive; e.g. it does not allow changing the 71 | candidates in use (even by attempting to alter the SDP). 72 | 73 | We also can't access any of the information needed to deeply diagnose STUN and 74 | TURN errors. 75 | 76 | Ultimately, I would loved to have taken this further but I think that needs to 77 | go out of the browser. 78 | 79 | (With the user's consent, the TURN credentials could be shared with the server 80 | to do this testing and have the results show up in the report.) 81 | 82 | 83 | ### Notes on deployment 84 | 85 | Install into a venv and run with `hypercorn`: 86 | 87 | `VOIPTEST_CONFIG=/path/to/config.toml hypercorn mxvoiptestd.webapi:app --log-level info --error-logfile -` 88 | 89 | Note that a configuration file is optional — but you will need one if you need 90 | to use a TURN server for your `mxvoiptestd` (such as if `mxvoiptestd` is behind NAT). 91 | 92 | 93 | ## Development instructions 94 | 95 | ### Set up 96 | 97 | You may need some dependencies which can be installed on Debian and Ubuntu with: 98 | 99 | `sudo apt install libavdevice-dev libavfilter-dev libopus-dev libvpx-dev pkg-config` 100 | 101 | These dependencies may be required for `aiortc`, which has instructions for other 102 | operating systems [here](https://github.com/aiortc/aiortc#installing). 103 | 104 | (In some cases, binary releases are available for `aiortc` so you may not need 105 | to install these manually.) 106 | 107 | Create a venv for this project and `pip install -e /path/to/mxvoiptestd` whilst 108 | having the venv activated. 109 | 110 | 111 | ### Compiling SCSS 112 | 113 | ``` 114 | scss scss/tester.scss -t compressed > mxvoiptestd/static/tester.css 115 | ``` 116 | 117 | Currently, to avoid hassle, the resultant `tester.css` should be committed in 118 | this repository whenever `tester.scss` is updated. 119 | -------------------------------------------------------------------------------- /config.toml: -------------------------------------------------------------------------------- 1 | [TURN] 2 | # optional, but may be necessary if operating the tester locally for development 3 | uri = "turn:turn.example.org:3478?transport=udp" 4 | username = "example" 5 | password = "secret" 6 | -------------------------------------------------------------------------------- /mxvoiptestd/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matrix-org/voip-tester/c6ec4ff0ef6b9d38d4d7c05e21a8083bd0a5b8b2/mxvoiptestd/__init__.py -------------------------------------------------------------------------------- /mxvoiptestd/__main__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from mxvoiptestd import webapi 4 | 5 | if __name__ == "__main__": 6 | # intended for development. In production, use an ASGI runner. 7 | logging.basicConfig(level=logging.DEBUG, force=True) 8 | logging.warning("Running in debug mode. Do not use in production.") 9 | webapi.app.run(debug=True) 10 | -------------------------------------------------------------------------------- /mxvoiptestd/datachannel_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright 2019-2020 The Matrix.org Foundation C.I.C. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | 16 | import asyncio 17 | import logging 18 | 19 | from aiortc import RTCSessionDescription, RTCPeerConnection, RTCDataChannel 20 | from aiortc.sdp import SessionDescription 21 | 22 | MAGIC_QUESTION = "Hello? Is this on?" 23 | MAGIC_ANSWER = "Yes; yes, it is! :^)" 24 | 25 | TIMEOUT_TIME = 300 26 | 27 | logger = logging.getLogger(__name__) 28 | 29 | 30 | async def setup_test(connection: RTCPeerConnection, offer) -> RTCSessionDescription: 31 | description = RTCSessionDescription(offer["sdp"], offer["type"]) 32 | 33 | session_description = SessionDescription.parse(offer["sdp"]) 34 | if len(session_description.media) != 1: 35 | raise ValueError("Only one media channel accepted.") 36 | 37 | media = session_description.media[0] 38 | if len(media.ice_candidates) != 1: 39 | raise ValueError("Only one ICE candidate accepted.") 40 | 41 | if not media.ice_candidates_complete: 42 | raise ValueError("ICE candidates must be completed") 43 | 44 | candidate = media.ice_candidates[0] 45 | 46 | await connection.setRemoteDescription(description) 47 | await connection.setLocalDescription(await connection.createAnswer()) 48 | 49 | logger.debug(f"[{candidate}] Beginning test with this candidate…") 50 | 51 | @connection.on("datachannel") 52 | def on_datachannel(channel: RTCDataChannel): 53 | logger.debug(f"[{candidate}] Established an RTCDataChannel") 54 | 55 | @channel.on("message") 56 | def on_message(message): 57 | logger.debug(f"[{candidate}] Received a message") 58 | 59 | if message == MAGIC_QUESTION: 60 | channel.send(MAGIC_ANSWER) 61 | else: 62 | channel.close() 63 | asyncio.ensure_future(connection.close()) 64 | 65 | # Make the connection time out 66 | asyncio.get_event_loop().call_later( 67 | TIMEOUT_TIME, asyncio.ensure_future, connection.close() 68 | ) 69 | 70 | # this is the answer to return 71 | return connection.localDescription 72 | -------------------------------------------------------------------------------- /mxvoiptestd/sdp_doctor.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright 2019-2020 The Matrix.org Foundation C.I.C. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | 16 | from aiortc.sdp import SessionDescription 17 | 18 | 19 | def doctor_sdp(original_sdp, sole_wanted_candidate): 20 | # filter out irrelevant candidates from the SDP directly 21 | session_description = SessionDescription.parse(original_sdp) 22 | 23 | for media in session_description.media: 24 | print("media", media) 25 | # note that this could cause problems with multiple media 26 | # as I guess you should only use each candidate once… 27 | media.ice_candidates = [sole_wanted_candidate] 28 | media.ice_candidates_complete = True 29 | 30 | # remove a=ice-options: to prevent Trickle ICE, as we need to 31 | # generate all the candidates at once as we only get one chance to 32 | # answer over a REST call. 33 | media.ice_options = None 34 | 35 | return str(session_description) 36 | 37 | 38 | def remove_all_candidates(original_sdp): 39 | # filter out all candidates from the SDP directly 40 | session_description = SessionDescription.parse(original_sdp) 41 | 42 | for media in session_description.media: 43 | print("media", media) 44 | media.ice_candidates = [] 45 | media.ice_candidates_complete = True 46 | 47 | # remove a=ice-options: to prevent Trickle ICE, as we need to 48 | # generate all the candidates at once as we only get one chance to 49 | # answer over a REST call. 50 | media.ice_options = None 51 | 52 | return str(session_description) 53 | -------------------------------------------------------------------------------- /mxvoiptestd/static/hello.txt: -------------------------------------------------------------------------------- 1 | Hullo! -------------------------------------------------------------------------------- /mxvoiptestd/static/tester.css: -------------------------------------------------------------------------------- 1 | /*! normalize.css v3.0.2 | MIT License | git.io/normalize */html{font-family:sans-serif;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}article,aside,details,figcaption,figure,footer,header,hgroup,main,menu,nav,section,summary{display:block}audio,canvas,progress,video{display:inline-block;vertical-align:baseline}audio:not([controls]){display:none;height:0}[hidden],template{display:none}a{background-color:transparent}a:active,a:hover{outline:0}abbr[title]{border-bottom:1px dotted}b,strong{font-weight:bold}dfn{font-style:italic}h1{font-size:2em;margin:0.67em 0}mark{background:#ff0;color:#000}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sup{top:-0.5em}sub{bottom:-0.25em}img{border:0}svg:not(:root){overflow:hidden}figure{margin:1em 40px}hr{-moz-box-sizing:content-box;box-sizing:content-box;height:0}pre{overflow:auto}code,kbd,pre,samp{font-family:monospace, monospace;font-size:1em}button,input,optgroup,select,textarea{color:inherit;font:inherit;margin:0}button{overflow:visible}button,select{text-transform:none}button,html input[type="button"],input[type="reset"],input[type="submit"]{-webkit-appearance:button;cursor:pointer}button[disabled],html input[disabled]{cursor:default}button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0}input{line-height:normal}input[type="checkbox"],input[type="radio"]{box-sizing:border-box;padding:0}input[type="number"]::-webkit-inner-spin-button,input[type="number"]::-webkit-outer-spin-button{height:auto}input[type="search"]{-webkit-appearance:textfield;-moz-box-sizing:content-box;-webkit-box-sizing:content-box;box-sizing:content-box}input[type="search"]::-webkit-search-cancel-button,input[type="search"]::-webkit-search-decoration{-webkit-appearance:none}fieldset{border:1px solid #c0c0c0;margin:0 2px;padding:0.35em 0.625em 0.75em}legend{border:0;padding:0}textarea{overflow:auto}optgroup{font-weight:bold}table{border-collapse:collapse;border-spacing:0}td,th{padding:0}.container{position:relative;width:100%;max-width:960px;margin:0 auto;padding:0 20px;box-sizing:border-box}.column,.columns{width:100%;float:left;box-sizing:border-box}@media (min-width: 400px){.container{width:85%;padding:0}}@media (min-width: 550px){.container{width:80%}.column,.columns{margin-left:4%}.column:first-child,.columns:first-child{margin-left:0}.one.column,.one.columns{width:4.66666666667%}.two.columns{width:13.3333333333%}.three.columns{width:22%}.four.columns{width:30.6666666667%}.five.columns{width:39.3333333333%}.six.columns{width:48%}.seven.columns{width:56.6666666667%}.eight.columns{width:65.3333333333%}.nine.columns{width:74.0%}.ten.columns{width:82.6666666667%}.eleven.columns{width:91.3333333333%}.twelve.columns{width:100%;margin-left:0}.one-third.column{width:30.6666666667%}.two-thirds.column{width:65.3333333333%}.one-half.column{width:48%}.offset-by-one.column,.offset-by-one.columns{margin-left:8.66666666667%}.offset-by-two.column,.offset-by-two.columns{margin-left:17.3333333333%}.offset-by-three.column,.offset-by-three.columns{margin-left:26%}.offset-by-four.column,.offset-by-four.columns{margin-left:34.6666666667%}.offset-by-five.column,.offset-by-five.columns{margin-left:43.3333333333%}.offset-by-six.column,.offset-by-six.columns{margin-left:52%}.offset-by-seven.column,.offset-by-seven.columns{margin-left:60.6666666667%}.offset-by-eight.column,.offset-by-eight.columns{margin-left:69.3333333333%}.offset-by-nine.column,.offset-by-nine.columns{margin-left:78.0%}.offset-by-ten.column,.offset-by-ten.columns{margin-left:86.6666666667%}.offset-by-eleven.column,.offset-by-eleven.columns{margin-left:95.3333333333%}.offset-by-one-third.column,.offset-by-one-third.columns{margin-left:34.6666666667%}.offset-by-two-thirds.column,.offset-by-two-thirds.columns{margin-left:69.3333333333%}.offset-by-one-half.column,.offset-by-one-half.columns{margin-left:52%}}html{font-size:62.5%}body{font-size:1.5em;line-height:1.6;font-weight:400;font-family:"Raleway", "HelveticaNeue", "Helvetica Neue", Helvetica, Arial, sans-serif;color:#222}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:2rem;font-weight:300}h1{font-size:4.0rem;line-height:1.2;letter-spacing:-.1rem}h2{font-size:3.6rem;line-height:1.25;letter-spacing:-.1rem}h3{font-size:3.0rem;line-height:1.3;letter-spacing:-.1rem}h4{font-size:2.4rem;line-height:1.35;letter-spacing:-.08rem}h5{font-size:1.8rem;line-height:1.5;letter-spacing:-.05rem}h6{font-size:1.5rem;line-height:1.6;letter-spacing:0}@media (min-width: 550px){h1{font-size:5.0rem}h2{font-size:4.2rem}h3{font-size:3.6rem}h4{font-size:3.0rem}h5{font-size:2.4rem}h6{font-size:1.5rem}}p{margin-top:0}a{color:#1EAEDB}a:hover{color:#0FA0CE}.button,button,input[type="submit"],input[type="reset"],input[type="button"]{display:inline-block;height:38px;padding:0 30px;color:#555;text-align:center;font-size:11px;font-weight:600;line-height:38px;letter-spacing:.1rem;text-transform:uppercase;text-decoration:none;white-space:nowrap;background-color:transparent;border-radius:4px;border:1px solid #bbb;cursor:pointer;box-sizing:border-box}.button:hover,button:hover,input[type="submit"]:hover,input[type="reset"]:hover,input[type="button"]:hover,.button:focus,button:focus,input[type="submit"]:focus,input[type="reset"]:focus,input[type="button"]:focus{color:#333;border-color:#888;outline:0}.button.button-primary,button.button-primary,input[type="submit"].button-primary,input[type="reset"].button-primary,input[type="button"].button-primary{color:#FFF;background-color:#33C3F0;border-color:#33C3F0}.button.button-primary:hover,button.button-primary:hover,input[type="submit"].button-primary:hover,input[type="reset"].button-primary:hover,input[type="button"].button-primary:hover,.button.button-primary:focus,button.button-primary:focus,input[type="submit"].button-primary:focus,input[type="reset"].button-primary:focus,input[type="button"].button-primary:focus{color:#FFF;background-color:#1EAEDB;border-color:#1EAEDB}input[type="email"],input[type="number"],input[type="search"],input[type="text"],input[type="tel"],input[type="url"],input[type="password"],textarea,select{height:38px;padding:6px 10px;background-color:#fff;border:1px solid #D1D1D1;border-radius:4px;box-shadow:none;box-sizing:border-box}input[type="email"],input[type="number"],input[type="search"],input[type="text"],input[type="tel"],input[type="url"],input[type="password"],textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none}textarea{min-height:65px;padding-top:6px;padding-bottom:6px}input[type="email"]:focus,input[type="number"]:focus,input[type="search"]:focus,input[type="text"]:focus,input[type="tel"]:focus,input[type="url"]:focus,input[type="password"]:focus,textarea:focus,select:focus{border:1px solid #33C3F0;outline:0}label,legend{display:block;margin-bottom:.5rem;font-weight:600}fieldset{padding:0;border-width:0}input[type="checkbox"],input[type="radio"]{display:inline}label>.label-body{display:inline-block;margin-left:.5rem;font-weight:normal}ul{list-style:circle inside}ol{list-style:decimal inside}ol,ul{padding-left:0;margin-top:0}ul ul,ul ol,ol ol,ol ul{margin:1.5rem 0 1.5rem 3rem;font-size:90%}li{margin-bottom:1rem}code{padding:.2rem .5rem;margin:0 .2rem;font-size:90%;white-space:nowrap;background:#F1F1F1;border:1px solid #E1E1E1;border-radius:4px}pre>code{display:block;padding:1rem 1.5rem;white-space:pre}th,td{padding:12px 15px;text-align:left;border-bottom:1px solid #E1E1E1}th:first-child,td:first-child{padding-left:0}th:last-child,td:last-child{padding-right:0}button,.button{margin-bottom:1rem}input,textarea,select,fieldset{margin-bottom:1.5rem}pre,blockquote,dl,figure,table,p,ul,ol,form{margin-bottom:2.5rem}.u-full-width{width:100%;box-sizing:border-box}.u-max-full-width{max-width:100%;box-sizing:border-box}.u-pull-right{float:right}.u-pull-left{float:left}hr{margin-top:3rem;margin-bottom:3.5rem;border-width:0;border-top:1px solid #E1E1E1}.container:after,.row:after,.u-cf{content:"";display:table;clear:both}html,body{margin:0;padding:0;background:#203040;color:#eee;font-family:sans-serif}hr{background-color:#d5d5d5}.container{margin-top:2em}input{background-color:#222 !important}#progresslist li{list-style-type:none}#progresslist progress{width:10em;height:1em;display:inline;margin-right:1em}.report-node{display:flex;flex-direction:row;align-items:center;margin:1em;background-color:#456}.report-node.expandable{cursor:pointer}.report-node.expandable::before{padding:1em;content:'+'}.report-node.expandable.expanded::before{content:'−'}.report-node .title{flex:1 0;margin:1em}.report-node .subtitle{margin:1em}.verdict{padding:1em}.verdict.fail{background-color:#bb0000}.verdict.poor{background-color:#bb5e00}.verdict.good{background-color:#bbbb00}.verdict.great{background-color:#9ebb00}.verdict.excellent{background-color:#3dbb00}.report-subtree{margin-left:1em}.report-info-node{margin:1em;margin-right:0} 2 | -------------------------------------------------------------------------------- /mxvoiptestd/static/tester.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Matrix VoIP Tester 11 | 12 | 13 |
14 |

Matrix VoIP Tester

15 | 16 |
17 |

18 | This utility will try to test your homeserver's STUN/TURN configuration. 19 | A working STUN and TURN configuration is crucial for reliable VoIP (e.g. voice/video calls) in the vast majority of circumstances. 20 |

21 |

Homeserver Details

22 |
23 |
24 | 25 | 26 |
27 |
28 | Authentication Method 29 | 33 | 37 |
38 |
39 |
40 |
41 | 42 | 43 |
44 |
45 | 46 | 47 |
48 |
49 |
50 | 51 | 52 |
53 | 54 | 55 |
56 | 57 | 64 | 65 | 85 |
86 | 87 | 88 | -------------------------------------------------------------------------------- /mxvoiptestd/static/tester_abstract.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019-2020 The Matrix.org Foundation C.I.C. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | class VoIPTesterError extends Error { 18 | constructor(errcode, message, cause) { 19 | super(message); 20 | this.errcode = errcode; 21 | this.cause = cause; 22 | 23 | // https://stackoverflow.com/a/500531 24 | this.name = this.constructor.name; 25 | if (typeof Error.captureStackTrace === 'function') { 26 | Error.captureStackTrace(this, this.constructor); 27 | } else { 28 | this.stack = (new Error(message)).stack; 29 | } 30 | } 31 | 32 | toString() { 33 | return super.toString() + " [" + this.errcode + "]"; 34 | } 35 | } 36 | 37 | let VoIPTesterErrors = { 38 | BAD_ACCESS_TOKEN: "Bad access token", 39 | FAILED_HOMESERVER_CONNECTION: "Failed homeserver connection", 40 | BAD_LOGIN_CREDENTIALS: "Bad credentials", 41 | LOGIN_FAILURE: "Login method not supported. Supply an access token manually instead.", 42 | BAD_TURN_CREDENTIALS: "Bad TURN credentials", 43 | CANNOT_GET_TURN_CREDENTIALS: "Can't get TURN credentials from homeserver", 44 | NO_TURN_SERVERS: "No TURN servers", 45 | UNKNOWN: "Unknown", 46 | 47 | INTERACTIVE_TEST_FAIL: "Interactive test failed", 48 | NO_RELAY_CANDIDATES: "No relay candidates available to perform relay test", 49 | 50 | FAILED_SERVICE_REQUEST: "Failed testing service request", 51 | FAILED_DOCTOR_SDP: "Failed to doctor the SDP", 52 | }; 53 | 54 | const MAGIC_QUESTION = "Hello? Is this on?"; 55 | const MAGIC_ANSWER = "Yes; yes, it is! :^)"; 56 | const MAGIC_QA_TIMEOUT = 150000; // 150 seconds 57 | 58 | function getIpVersion(ipAddress) { 59 | return /^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$/.test(ipAddress) 60 | ? 'IPv4' : 'IPv6'; 61 | } 62 | 63 | function parseTurnUri(uri) { 64 | // yucky roll-your-own in the interest of time 65 | let [uriPart, argsStr] = uri.split('?'); 66 | let [protocol, host, port] = uriPart.split(':'); 67 | 68 | let params = {transport: 'udp'}; 69 | 70 | let argPairStrs = argsStr.split('&'); 71 | for (let argPairStr of argPairStrs) { 72 | let [key, value] = argPairStr.split('='); 73 | params[key] = value; 74 | } 75 | 76 | return { 77 | protocol: protocol, 78 | host: host, 79 | port: port, 80 | params: params 81 | }; 82 | } 83 | 84 | class VoIPTester { 85 | constructor(homeserverUrl, remoteTestServiceUrl) { 86 | this.homeserver = homeserverUrl; 87 | this.remoteTestServiceUrl = remoteTestServiceUrl; 88 | this.accessToken = null; 89 | } 90 | 91 | _fetchClient(path, method, body) { 92 | let extra = { 93 | headers: { 94 | 'Authorization': 'Bearer ' + this.accessToken, 95 | }, 96 | method: method, 97 | }; 98 | 99 | if (method == 'PUT' || method == 'POST') { 100 | extra.headers['Content-Type'] = 'application/json'; 101 | extra.body = JSON.stringify(body); 102 | } 103 | 104 | return fetch(this.homeserver + "/_matrix/client/r0" + path, extra); 105 | } 106 | 107 | async loginWithUserIdAndPassword(userId, password) { 108 | const resp = await this._fetchClient("/login", "POST", { 109 | type: "m.login.password", 110 | identifier: { 111 | type: "m.id.user", 112 | user: userId, 113 | }, 114 | password: password, 115 | initial_device_display_name: "VoIP Tester", 116 | }); 117 | 118 | const resp_obj = await resp.json(); 119 | 120 | if (resp.status == 200) { 121 | this.accessToken = resp_obj.access_token; 122 | } else if (resp.status == 403) { 123 | throw new VoIPTesterError( 124 | VoIPTesterErrors.BAD_CREDENTIALS, 125 | resp_obj.error || "403 on /login" 126 | ); 127 | } else if (resp.status == 400) { 128 | throw new VoIPTesterError( 129 | VoIPTesterErrors.LOGIN_FAILURE, 130 | resp_obj.error || "400 on /login" 131 | ); 132 | } else { 133 | const errStr = resp_obj.errcode + " " + resp_obj.error + " " + resp_obj.status; 134 | throw new VoIPTesterError( 135 | VoIPTesterErrors.UNKNOWN, 136 | errStr 137 | ); 138 | } 139 | } 140 | 141 | async loginWithAccessToken(accessToken) { 142 | this.accessToken = accessToken; 143 | try { 144 | const response = await this._fetchClient("/account/whoami", "GET"); 145 | console.log("then", response); 146 | } catch (error) { 147 | throw new VoIPTesterError( 148 | VoIPTesterErrors.FAILED_HOMESERVER_CONNECTION, 149 | "Failed to connect for /account/whoami", 150 | error 151 | ); 152 | } 153 | } 154 | 155 | async gatherTurnConfig() { 156 | const resp = await this._fetchClient("/voip/turnServer", "GET"); 157 | const resp_obj = await resp.json(); 158 | 159 | if (resp.status != 200) { 160 | const errStr = resp_obj.errcode + " " + resp_obj.error + " " + resp_obj.status; 161 | 162 | throw new VoIPTesterError( 163 | VoIPTesterErrors.CANNOT_GET_TURN_CREDENTIALS, 164 | errStr 165 | ); 166 | } 167 | if (resp_obj.uris.length < 1) { 168 | throw new VoIPTesterError( 169 | VoIPTesterErrors.NO_TURN_SERVERS, 170 | "Empty `uris` list from /voip/turnServer" 171 | ); 172 | } 173 | return resp_obj; 174 | } 175 | 176 | gatherCandidatesForIceServer(turnUri, turnUsername, turnPassword, turnOnly) { 177 | const candidates = []; 178 | 179 | const conn = new RTCPeerConnection({ 180 | iceServers: [ 181 | { 182 | urls: turnUri, 183 | username: turnUsername, 184 | credential: turnPassword 185 | } 186 | ], 187 | // using 'relay' mode is an experiment, seems to prevent a lot of 188 | // the hassle of the client generating peer-reflexive candidates by 189 | // contacting the server directly... 190 | iceTransportPolicy: turnOnly ? 'relay' : 'all' 191 | }); 192 | 193 | const dataChannel = conn.createDataChannel("voiptest"); 194 | 195 | // TODO debug 196 | window.conn = conn; 197 | window.dataChannel = dataChannel; 198 | window.candidates = candidates; 199 | 200 | return new Promise(resolve => { 201 | conn.onicecandidate = function (evt) { 202 | console.log("ICE Candidate", evt); 203 | 204 | if (evt.candidate === null) { 205 | // this is the end-of-candidates marker 206 | console.log("End of candidates"); 207 | console.log("Local description:", conn.localDescription); 208 | resolve({ 209 | peerConnection: conn, 210 | dataChannel: dataChannel, 211 | candidates: candidates, 212 | }); 213 | } else { 214 | candidates.push(evt.candidate); 215 | } 216 | 217 | evt.preventDefault(); // TODO what was this doing? is it needed? suspect it was for an experiment last year. 218 | }; 219 | 220 | console.log("Waiting for negotiationneeded"); 221 | conn.addEventListener("negotiationneeded", ev => { 222 | console.log("negotionneeded fired; Creating offer"); 223 | conn.createOffer().then(offer => { 224 | console.log("Offer created:", offer); 225 | conn.setLocalDescription(offer) 226 | }); 227 | }); 228 | }); 229 | } 230 | 231 | /** 232 | * Doctors (modifies) some SDP to remove candidates that wouldn't 233 | * exercise the candidate we want to test. 234 | * 235 | * Beware that this probably won't behave for more than one media track. 236 | */ 237 | doctorOfferSdp(offerSdp, soleWantedCandidate) { 238 | console.log("in"); 239 | console.log(offerSdp); 240 | const sdpLines = offerSdp.split(/\r?\n/g); 241 | 242 | let foundPreservedCandidate = false; 243 | 244 | // .candidate gives the candidate SDP for it. 245 | const checkingFor = "a=" + soleWantedCandidate.candidate; 246 | 247 | for (let i = sdpLines.length - 1; i >= 0; --i) { 248 | if (sdpLines[i].startsWith("a=candidate:")) { 249 | // this is a candidate line 250 | if (sdpLines[i] == checkingFor) { 251 | foundPreservedCandidate = true; 252 | // make it candidate 0 253 | sdpLines[i] = sdpLines[i].replace(/candidate:[0-9]+/, "candidate:0"); 254 | } else { 255 | // remove index i – not a wanted candidate 256 | sdpLines.splice(i, 1); 257 | } 258 | } 259 | } 260 | 261 | if (! foundPreservedCandidate) { 262 | throw new VoIPTesterError( 263 | VoIPTesterErrors.FAILED_DOCTOR_SDP, 264 | "Failed to find wanted candidate in offer SDP." 265 | ); 266 | } 267 | 268 | console.log("out"); 269 | console.log(sdpLines.join("\r\n")); 270 | return sdpLines.join("\r\n"); 271 | } 272 | 273 | async testTurnRelaying(ipVersion, candidateResult) { 274 | // select a relay candidate of the appropriate IP version 275 | 276 | let candidate = null; 277 | for (let i = 0; i < candidateResult.candidates.length; ++i) { 278 | let potentialCandidate = candidateResult.candidates[i]; 279 | if (potentialCandidate.type == 'relay' && 280 | getIpVersion(potentialCandidate.ip) == ipVersion) { 281 | candidate = potentialCandidate; 282 | break; 283 | } 284 | } 285 | 286 | if (candidate === null) { 287 | throw new VoIPTesterError(VoIPTesterErrors.NO_RELAY_CANDIDATES); 288 | } 289 | 290 | let connection = candidateResult.peerConnection; 291 | let dataChannel = candidateResult.dataChannel; 292 | 293 | let doctoredSdp = this.doctorOfferSdp(connection.localDescription.sdp, candidate); 294 | 295 | // we can't set the doctored SDP on ourselves — the browser won't allow 296 | // it. 297 | //await connection.setLocalDescription(connection.localDescription); 298 | 299 | const resp = await fetch(this.remoteTestServiceUrl, { 300 | method: 'POST', 301 | headers: {'Content-Type': 'application/json'}, 302 | body: JSON.stringify({ 303 | offer: { 304 | sdp: doctoredSdp, 305 | type: connection.localDescription.type 306 | } 307 | }) 308 | }); 309 | 310 | if (resp.status != 200) { 311 | throw new VoIPTesterError( 312 | VoIPTesterErrors.FAILED_SERVICE_REQUEST, 313 | resp.status + " when contacting testing service" 314 | ); 315 | } 316 | 317 | let intentionalClose = false; 318 | 319 | const finishedPromise = new Promise((resolve, reject) => { 320 | const timer = window.setTimeout(function() { 321 | intentionalClose = true; 322 | dataChannel.close(); 323 | connection.close(); 324 | reject(new VoIPTesterError( 325 | VoIPTesterErrors.INTERACTIVE_TEST_FAIL, 326 | "Echo test timed out." 327 | )); 328 | }, MAGIC_QA_TIMEOUT); 329 | 330 | dataChannel.onmessage = function (event) { 331 | console.log("received: " + event.data); 332 | if (event.data == MAGIC_ANSWER) { 333 | console.log("senders", connection.getSenders()); 334 | console.log("receivers", connection.getReceivers()); 335 | console.log("got the magic answer!"); 336 | // stop the timeout timer 337 | window.clearTimeout(timer); 338 | intentionalClose = true; 339 | dataChannel.close(); 340 | connection.close(); 341 | resolve(); 342 | } 343 | }; 344 | 345 | dataChannel.onopen = function () { 346 | console.log("datachannel open, sending magic question"); 347 | dataChannel.send(MAGIC_QUESTION); 348 | }; 349 | 350 | dataChannel.onclose = function () { 351 | console.log("datachannel close"); 352 | if (! intentionalClose) { 353 | reject(new VoIPTesterError( 354 | VoIPTesterErrors.INTERACTIVE_TEST_FAIL, 355 | "Data channel closed unexpectedly." 356 | )); 357 | } 358 | }; 359 | 360 | dataChannel.onerror = function (event) { 361 | console.error("datachannel error", event.message, event.filename, event.lineno, event.colno); 362 | reject(new VoIPTesterError( 363 | VoIPTesterErrors.INTERACTIVE_TEST_FAIL, 364 | "Data channel error: " + event.message 365 | )); 366 | intentionalClose = true; 367 | } 368 | }); 369 | 370 | 371 | const serviceResponse = await resp.json(); 372 | console.log("ANSWER", serviceResponse.answer); 373 | connection.setRemoteDescription(serviceResponse.answer); // TODO await?? 374 | 375 | // wait for things to happen, or time out with failure 376 | try { 377 | await finishedPromise; 378 | } catch (error) { 379 | console.error("during TURN test", error); 380 | return { 381 | success: false, 382 | error: error 383 | }; 384 | } 385 | 386 | return { 387 | success: true, 388 | // TODO can we store the active candidate here? Even if we must ask 389 | // the server for it... 390 | }; 391 | } 392 | 393 | /** 394 | * Runs the test against either IPv4 or IPv6 (specify 'IPv4' or 'IPv6'). 395 | * 396 | * Note that the IP version refers to that of the candidates – not that 397 | * of the STUN/TURN protocol itself (as we cannot control that really). 398 | */ 399 | async runIpVersionedTest(ipVersion, testReport, turnConfig) { 400 | let numUris = turnConfig.uris.length; 401 | let numTested = 0; 402 | let candidateResults = []; 403 | 404 | let testPassReport = testReport.passes[ipVersion]; 405 | 406 | for (let i = 0; i < numUris; ++i) { 407 | const turnUri = turnConfig.uris[i]; 408 | testPassReport[turnUri] = {}; 409 | 410 | this.onProgress(2, i, numUris, "TURN URI: " + turnUri); 411 | 412 | this.onProgress(3, 0, 3, "Gathering candidates"); 413 | 414 | const candidateResult = await this.gatherCandidatesForIceServer(turnUri, turnConfig.username, turnConfig.password, false); 415 | testPassReport[turnUri].candidates = this.summariseCandidateGathering(candidateResult, ipVersion); 416 | 417 | this.onProgress(3, 1, 3, "Gathering relay candidates for a TURN test"); 418 | 419 | // annoyingly, setting the ICE policy to relay seems to make a useful change 420 | // for making the TURN test actually consistent. 421 | // Unfortunately, it doesn't exercise STUN functionality by doing that, so 422 | const turnCandidateResult = await this.gatherCandidatesForIceServer(turnUri, turnConfig.username, turnConfig.password, true); 423 | 424 | this.onProgress(3, 2, 3, "Testing TURN relaying"); 425 | 426 | let turnRelayResult; 427 | 428 | try { 429 | turnRelayResult = await this.testTurnRelaying(ipVersion, turnCandidateResult); 430 | } catch (error) { 431 | console.error("during testTurnRelaying", error); 432 | turnRelayResult = { 433 | success: false, 434 | error: error 435 | }; 436 | } 437 | 438 | testPassReport[turnUri].turnRelayResult = turnRelayResult; 439 | 440 | testPassReport[turnUri].report = this.summariseTurnUriReport(ipVersion, turnUri, testPassReport[turnUri]); 441 | 442 | this.onProgress(3, 3, 3, "TURN relay test attempted"); 443 | } 444 | 445 | this.onProgress(2, numUris, numUris, "All TURN URIs attempted"); 446 | 447 | return testPassReport; 448 | } 449 | 450 | async runTest() { 451 | this.onProgress(1, 0, 3, "Requesting TURN details from homeserver"); 452 | 453 | let testReport = { 454 | passes: { 455 | 'IPv4': {}, 456 | 'IPv6': {} 457 | } 458 | }; 459 | 460 | window.testReport = testReport; 461 | 462 | const turnConfig = await this.gatherTurnConfig(); 463 | 464 | this.onProgress(1, 1, 3, "Testing (IPv4 candidates)"); 465 | 466 | testReport.turnConfig = turnConfig; 467 | 468 | const ipv4 = await this.runIpVersionedTest('IPv4', testReport, testReport.turnConfig); 469 | testReport.passes.IPv4 = ipv4; 470 | this.onProgress(1, 2, 3, "Testing (IPv6 candidates)"); 471 | 472 | const ipv6 = await this.runIpVersionedTest('IPv6', testReport, testReport.turnConfig); 473 | testReport.passes.IPv6 = ipv6; 474 | 475 | this.onProgress(1, 3, 3, "Test complete"); 476 | 477 | // TODO finalise and process report 478 | 479 | return testReport; 480 | } 481 | 482 | 483 | summariseCandidateGathering(candidateResult, ipVersion) { 484 | let candidates = []; 485 | 486 | let seenType = { 487 | srflx: false, 488 | relay: false 489 | }; 490 | 491 | for (let candidate of candidateResult.candidates) { 492 | if (getIpVersion(candidate.ip) !== ipVersion) { 493 | continue; 494 | } 495 | if (candidate.type == 'srflx' || candidate.type == 'relay') { 496 | seenType[candidate.type] = true; 497 | candidates.push({ 498 | proto: candidate.protocol, 499 | type: candidate.type, 500 | ip: candidate.ip, 501 | port: candidate.port 502 | }); 503 | } 504 | } 505 | 506 | return { 507 | details: candidates, 508 | stun: seenType.srflx, 509 | turn: seenType.relay, 510 | }; 511 | } 512 | 513 | summariseTurnUriReport(ipVersion, uri, turnUriReport) { 514 | let flags = []; 515 | const parsedUri = parseTurnUri(uri); 516 | 517 | const transport = parsedUri.params.transport || 'udp'; 518 | if (transport == 'udp') { 519 | switch (parsedUri.protocol) { 520 | case 'turn': 521 | flags.push('udp-turn'); 522 | break; 523 | case 'turns': 524 | flags.push('udp-turns'); 525 | break; 526 | default: 527 | console.warn("unknown protocol", parsedUri.protocol); 528 | } 529 | } else if (transport == 'tcp') { 530 | switch (parsedUri.protocol) { 531 | case 'turn': 532 | flags.push('tcp-turn'); 533 | break; 534 | case 'turns': 535 | flags.push('tcp-turns'); 536 | if (parsedUri.port === 443) { 537 | flags.push('tcp-turns-443'); 538 | } 539 | break; 540 | default: 541 | console.warn("unknown protocol", parsedUri.protocol); 542 | } 543 | } else { 544 | console.warn("unknown transport", parsedUri.params.transport); 545 | } 546 | 547 | let result = { 548 | flags: flags, 549 | stun: turnUriReport.candidates.stun, // TODO test stun gives the correct answer... 550 | turn: turnUriReport.candidates.turn && turnUriReport.turnRelayResult.success, 551 | }; 552 | 553 | if (turnUriReport.turnRelayResult.error !== undefined) { 554 | result.relayingError = turnUriReport.turnRelayResult.error; 555 | } 556 | 557 | // TODO do we want to report the time taken for anything? 558 | 559 | return result; 560 | } 561 | 562 | 563 | // The following methods are intended to be overridden 564 | 565 | /** 566 | * Called when we have information about progress. 567 | * Progress levels can be thought of as a tree / hierarchy. 568 | * Progress at level `i` must be completed to make progress in level `i - 1`. 569 | * level will never be more than 1 greater than the previously-called level. 570 | * 571 | * Args: 572 | * - level (int): The depth of the hierarchy; starting at 1 for the root task. 573 | * - currentValue (int): Amount of completed progress at this level. 574 | * - maxValue (int): Maximum amount of doable progress at this level. 575 | * - statusLine (String): Terse human-readable summary of progress at this level. 576 | */ 577 | onProgress(level, currentValue, maxValue, statusLine) { 578 | let prefix = "• ".repeat(level); 579 | console.log(prefix + "[" + currentValue + "/" + maxValue + "] " + statusLine); 580 | } 581 | } 582 | 583 | -------------------------------------------------------------------------------- /mxvoiptestd/static/tester_builtin.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019-2020 The Matrix.org Foundation C.I.C. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | let elements = {}; 18 | let isCurrentlyTesting = false; 19 | 20 | class BuiltinVoIPTester extends VoIPTester { 21 | constructor(homeserverUrl, remoteTestServiceUrl, progressContainer) { 22 | super(homeserverUrl, remoteTestServiceUrl); 23 | 24 | this.progresses = []; 25 | this.progressContainer = progressContainer; 26 | } 27 | 28 | onProgress(level, currentValue, maxValue, statusLine) { 29 | super.onProgress(level, currentValue, maxValue, statusLine); 30 | 31 | // remove obsolete progress levels if required 32 | for (let i = level; i < this.progresses.length; ++i) { 33 | let toRemove = this.progresses.pop().holder; 34 | toRemove.parentNode.removeChild(toRemove); 35 | } 36 | 37 | if (level == this.progresses.length + 1) { 38 | let newHolder = document.createElement('li'); 39 | let newBar = document.createElement('progress'); 40 | let newStatusLine = document.createElement('span'); 41 | 42 | newHolder.appendChild(newBar); 43 | newHolder.appendChild(newStatusLine); 44 | 45 | this.progresses.push({ 46 | holder: newHolder, 47 | bar: newBar, 48 | statusLine: newStatusLine 49 | }); 50 | 51 | this.progressContainer.appendChild(newHolder); 52 | } else if (level > this.progresses.length) { 53 | console.error("Unhandled progress length difference."); 54 | return; 55 | } 56 | 57 | let thisLevel = this.progresses[level - 1]; 58 | thisLevel.bar.value = currentValue; 59 | thisLevel.bar.max = maxValue; 60 | thisLevel.statusLine.textContent = statusLine; 61 | } 62 | } 63 | 64 | function newReportNode(parent, title, subtitle, verdict, hasChildren) { 65 | const reportNode = document.createElement('div'); 66 | parent.appendChild(reportNode); 67 | reportNode.classList.add('report-node'); 68 | 69 | const titleEle = document.createElement('div'); 70 | reportNode.appendChild(titleEle); 71 | titleEle.classList.add('title'); 72 | titleEle.textContent = title; 73 | 74 | if (subtitle !== null) { 75 | const subtitleEle = document.createElement('div'); 76 | reportNode.appendChild(subtitleEle); 77 | subtitleEle.classList.add('subtitle'); 78 | subtitleEle.textContent = subtitle; 79 | } 80 | 81 | if (verdict !== null) { 82 | const verdictEle = document.createElement('div'); 83 | reportNode.appendChild(verdictEle); 84 | verdictEle.classList.add('verdict'); 85 | verdictEle.classList.add(verdict.toLowerCase()); 86 | verdictEle.textContent = verdict; 87 | } 88 | 89 | if (hasChildren) { 90 | const childContainer = document.createElement('div'); 91 | parent.appendChild(childContainer); 92 | 93 | childContainer.classList.add('report-subtree'); 94 | 95 | childContainer.hidden = true; 96 | 97 | reportNode.classList.add('expandable'); 98 | reportNode.addEventListener('click', () => { 99 | childContainer.hidden = !childContainer.hidden; 100 | if (childContainer.hidden) { 101 | reportNode.classList.remove('expanded'); 102 | } else { 103 | reportNode.classList.add('expanded'); 104 | } 105 | }); 106 | return childContainer; 107 | } 108 | } 109 | 110 | function escapeHtml(str) { 111 | const p = document.createElement('p'); 112 | p.textContent = str; 113 | return p.innerHTML; 114 | } 115 | 116 | function newInfoNode(parent, text) { 117 | const infoNode = document.createElement('div'); 118 | parent.appendChild(infoNode); 119 | infoNode.classList.add('report-info-node'); 120 | infoNode.textContent = text; 121 | return infoNode; 122 | } 123 | 124 | function displayVersionedReport(report, version, rootNode) { 125 | const turnUrisSorted = Object.keys(report); 126 | turnUrisSorted.sort(); 127 | 128 | let stunSupported = false; 129 | let turnSupported = false; 130 | 131 | let flags = { 132 | 'tcp-turn': false, 133 | 'tcp-turns': false, 134 | 'tcp-turns-443': false, 135 | 'udp-turn': false, 136 | 'udp-turns': false, 137 | }; 138 | 139 | for (let server of turnUrisSorted) { 140 | let reportForServer = report[server]; 141 | 142 | if (reportForServer.report.stun) { 143 | stunSupported = true; 144 | } 145 | if (reportForServer.report.turn) { 146 | turnSupported = true; 147 | } 148 | 149 | for (let flag of reportForServer.report.flags) { 150 | flags[flag] = true; 151 | } 152 | } 153 | 154 | const combo = (stunSupported ? 'S' : '') + (turnSupported ? 'T' : ''); 155 | 156 | let verSummary; 157 | let verVerdict; 158 | 159 | switch (combo) { 160 | case 'S': 161 | verSummary = 'STUN only (No TURN)'; 162 | verVerdict = 'Poor'; 163 | break; 164 | case 'T': 165 | verSummary = 'TURN only (No STUN)'; 166 | verVerdict = 'Poor'; 167 | break; 168 | case 'ST': 169 | verSummary = 'STUN & TURN'; 170 | // TODO we may want to not mark people down for not supplying insecure TURN protocols...? 171 | if (flags['tcp-turn'] && flags['tcp-turns'] && flags['tcp-turns-443'] 172 | && flags['udp-turn'] && flags['udp-turns']) { 173 | verVerdict = 'Excellent'; 174 | } else if (flags['tcp-turn'] && flags['tcp-turns'] 175 | && flags['udp-turn'] && flags['udp-turns']) { 176 | verVerdict = 'Great'; 177 | } else { 178 | verVerdict = 'Good'; 179 | } 180 | break; 181 | case '': 182 | verSummary = 'No support'; 183 | verVerdict = 'Fail'; 184 | break; 185 | default: 186 | verSummary = '???'; 187 | verVerdict = '???'; 188 | } 189 | 190 | const verNode = newReportNode(rootNode, 'Test servers (' + version + ')', verSummary, verVerdict, true); 191 | 192 | 193 | for (let server of turnUrisSorted) { 194 | let reportForServer = report[server]; 195 | 196 | const scombo = (reportForServer.report.stun ? 'S' : '') + (reportForServer.report.turn ? 'T' : ''); 197 | 198 | let serverSummary; 199 | let serverVerdict; 200 | 201 | switch (scombo) { 202 | case 'S': 203 | serverSummary = 'STUN only'; 204 | serverVerdict = 'Poor'; 205 | break; 206 | case 'T': 207 | serverSummary = 'TURN only'; 208 | serverVerdict = 'Poor'; 209 | break; 210 | case 'ST': 211 | serverSummary = 'STUN & TURN'; 212 | serverVerdict = 'Excellent'; 213 | break; 214 | case '': 215 | serverSummary = "Didn't work"; 216 | serverVerdict = 'Fail'; 217 | break; 218 | default: 219 | serverSummary = '???'; 220 | serverVerdict = '???'; 221 | } 222 | 223 | const serverNode = newReportNode(verNode, server, serverSummary, serverVerdict, true); 224 | 225 | const infoNode = newInfoNode(serverNode, ''); 226 | 227 | let para = document.createElement('p'); 228 | infoNode.appendChild(para); 229 | 230 | if (reportForServer.turnRelayResult.success) { 231 | para.textContent = 'Succeeded the relaying test.'; 232 | } else { 233 | para.textContent = 'Failed the relaying test: ' + reportForServer.turnRelayResult.error; 234 | } 235 | 236 | // again, not a fan of this approach of building DOM trees but no time 237 | // to research anything better, really. 238 | 239 | let candidateListHeading = document.createElement('strong'); 240 | infoNode.appendChild(candidateListHeading); 241 | candidateListHeading.textContent = 'Candidates:'; 242 | 243 | let candidateList = document.createElement('ul'); 244 | infoNode.appendChild(candidateList); 245 | 246 | for (let candidate of reportForServer.candidates.details) { 247 | let candidateEle = document.createElement('li'); 248 | candidateList.appendChild(candidateEle); 249 | 250 | let typeString = candidate.type; 251 | if (candidate.type == 'srflx') { 252 | typeString = 'server-reflexive (STUN)'; 253 | } else if (candidate.type == 'relay') { 254 | typeString = 'relay (TURN)'; 255 | } 256 | 257 | candidateEle.textContent = candidate.ip + ' port ' + candidate.port + '/' + candidate.proto + ' (' + typeString + ')'; 258 | } 259 | } 260 | 261 | 262 | } 263 | 264 | function displayReport(report, rootNode) { 265 | // clear it! Could be confusing to have multiple resultsets on the page. 266 | rootNode.innerHTML = ''; 267 | 268 | const numTurnServers = report.turnConfig.uris.length; 269 | 270 | const csApiNode = newReportNode(rootNode, 'Asked homeserver for TURN servers', numTurnServers + ' URIs received.', numTurnServers > 0 ? 'Excellent' : 'Fail', true); 271 | 272 | if (numTurnServers > 0) { 273 | const turnServerList = newInfoNode(csApiNode, 'GET /_matrix/client/r0/voip/turnServer yielded the following information:'); 274 | 275 | // TODO not a fan of the lists 276 | let list = document.createElement('ul'); 277 | turnServerList.appendChild(list); 278 | 279 | let listEle = document.createElement('li'); 280 | listEle.textContent = 'Username: ' + report.turnConfig.username; 281 | list.appendChild(listEle); 282 | 283 | listEle = document.createElement('li'); 284 | listEle.textContent = 'Password: ' + report.turnConfig.password; 285 | list.appendChild(listEle); 286 | 287 | listEle = document.createElement('li'); 288 | listEle.textContent = 'Server URIs:'; 289 | list.appendChild(listEle); 290 | 291 | let uriList = document.createElement('ul'); 292 | listEle.appendChild(uriList); 293 | 294 | for (let turnServerUri of report.turnConfig.uris) { 295 | listEle = document.createElement('li'); 296 | listEle.textContent = turnServerUri; 297 | uriList.appendChild(listEle); 298 | } 299 | } else { 300 | newInfoNode(csApiNode, 'GET /_matrix/client/r0/voip/turnServer did not yield any TURN servers. Check your homeserver configuration.'); 301 | } 302 | 303 | displayVersionedReport(report.passes['IPv4'], 'IPv4', rootNode); 304 | displayVersionedReport(report.passes['IPv6'], 'IPv6', rootNode); 305 | } 306 | 307 | async function performTest() { 308 | const tester = new BuiltinVoIPTester(elements.homeserver.value, 'v1/test_me', elements.progresslist); 309 | 310 | // unhide progress display 311 | elements.progress.hidden = false; 312 | 313 | if (elements.authmeth_userpass.checked) { 314 | await tester.loginWithUserIdAndPassword(elements.userid.value, elements.password.value); 315 | } else if (elements.authmeth_accesstoken.checked) { 316 | await tester.loginWithAccessToken(elements.accesstoken.value); 317 | } else { 318 | alert("What authentication method are you using?"); 319 | return; 320 | } 321 | 322 | console.log("Logged in — running test now."); 323 | return await tester.runTest(); 324 | } 325 | 326 | (function(){ 327 | 328 | function radioChangeListener(_e) { 329 | elements.auth_userpass.hidden = ! elements.authmeth_userpass.checked; 330 | elements.auth_accesstoken.hidden = ! elements.authmeth_accesstoken.checked; 331 | } 332 | 333 | window.addEventListener('DOMContentLoaded', (event) => { 334 | console.log('DOM fully loaded and parsed', VoIPTester); 335 | 336 | wantedEles = [ 337 | "authmeth_userpass", 338 | "auth_userpass", 339 | "authmeth_accesstoken", 340 | "auth_accesstoken", 341 | "homeserver", 342 | "userid", 343 | "password", 344 | "accesstoken", 345 | "testform", 346 | "progresslist", 347 | "progress", 348 | "results", 349 | "result_container" 350 | ]; 351 | 352 | for (let i = 0; i < wantedEles.length; ++i) { 353 | wantedEle = wantedEles[i]; 354 | elements[wantedEle] = document.getElementById(wantedEle); 355 | } 356 | 357 | elements.authmeth_userpass.addEventListener('change', radioChangeListener); 358 | elements.authmeth_accesstoken.addEventListener('change', radioChangeListener); 359 | 360 | elements.testform.addEventListener('submit', (event) => { 361 | event.preventDefault(); 362 | 363 | if (! isCurrentlyTesting) { 364 | isCurrentlyTesting = true; 365 | elements.testform.hidden = true; 366 | performTest() 367 | .then(report => { 368 | console.log("Test report", report); 369 | 370 | elements.results.hidden = false; 371 | displayReport(report, elements.result_container); 372 | }) 373 | .catch(exc => { 374 | // TODO handle this better 375 | alert("Error occurred during test: " + exc); 376 | console.error("Test erred", exc); 377 | }) 378 | .finally(() => { 379 | isCurrentlyTesting = false; 380 | elements.progress.hidden = true; 381 | elements.testform.hidden = false; 382 | }); 383 | } 384 | }); 385 | 386 | radioChangeListener(null); 387 | }); 388 | 389 | 390 | })(); 391 | -------------------------------------------------------------------------------- /mxvoiptestd/webapi.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright 2019-2020 The Matrix.org Foundation C.I.C. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | 16 | import logging 17 | import os 18 | 19 | import toml 20 | from aiortc import ( 21 | RTCPeerConnection, 22 | RTCConfiguration, 23 | sdp, RTCIceServer, 24 | ) 25 | from quart import Quart, request, jsonify 26 | 27 | from mxvoiptestd import datachannel_test 28 | 29 | # FIXME don't know how to do this properly/nicely. 30 | logging.basicConfig(level=logging.INFO) 31 | 32 | app = Quart(__name__) 33 | logger = app.logger 34 | # XXX without this I get double logging… don't have time to look into it now, sorry :| 35 | logger.propagate = False 36 | 37 | 38 | def load_config(): 39 | # set defaults 40 | # urgh: vars have to be uppercase. I won't object if anyone sorts this out 41 | # one day. 42 | app.config.from_mapping({ 43 | 'TURN': None, 44 | }) 45 | 46 | # set from file if present. Again: they have to be uppercase in this file. 47 | config_path = os.getenv("VOIPTEST_CONFIG", None) 48 | if config_path: 49 | logger.info("Found VOIPTEST_CONFIG env var; loading %s", config_path) 50 | app.config.from_file(config_path, toml.load) 51 | else: 52 | logger.info("No VOIPTEST_CONFIG env var — not configuring") 53 | 54 | logger.info("TURN server %s", "configured" if app.config['TURN'] else "not configured") 55 | 56 | 57 | load_config() 58 | 59 | 60 | @app.before_first_request 61 | async def startup(): 62 | # use the hypercorn logger if it is set; FIXME make this runner-neutral 63 | # unfortunately we can't use it until startup... :/. 64 | # perhaps switch to --log-config in hypercorn... 65 | hypercorn_logger = logging.getLogger('hypercorn.error') 66 | if hypercorn_logger.hasHandlers(): 67 | # we are running under hypercorn 68 | assert not hypercorn_logger.propagate, "hypercorn shouldn't propagate." 69 | logging.root.handlers = hypercorn_logger.handlers 70 | logging.root.setLevel(hypercorn_logger.level) 71 | 72 | 73 | @app.route("/") 74 | async def root(): 75 | return await app.send_static_file("tester.html") 76 | 77 | 78 | @app.route("/health") 79 | def health(): 80 | return "I'm all right! :^)" 81 | 82 | 83 | @app.route("/v1/test_me", methods=["POST"]) 84 | async def test_me(): 85 | inbound_args = await request.json 86 | 87 | if inbound_args is None: 88 | return "Must send request JSON", 400 89 | 90 | if "offer" not in inbound_args: 91 | return "Missing offer in request JSON", 400 92 | 93 | ice_servers = [] 94 | 95 | turn_config = app.config['TURN'] 96 | if turn_config: 97 | ice_servers.append(RTCIceServer( 98 | turn_config['uri'], 99 | turn_config['username'], 100 | turn_config['password'], 101 | )) 102 | 103 | connection = RTCPeerConnection( 104 | RTCConfiguration(iceServers=ice_servers) 105 | ) 106 | offer = inbound_args["offer"] 107 | 108 | # wanted_candidate_sdp = inbound_args["candidate"] 109 | # if wanted_candidate_sdp.startswith("candidate:"): 110 | # wanted_candidate_sdp = wanted_candidate_sdp[len("candidate:"):] 111 | # 112 | # wanted_candidate = sdp.candidate_from_sdp(wanted_candidate_sdp) 113 | # 114 | # doctored_sdp = sdp_doctor.doctor_sdp(offer["sdp"], wanted_candidate) 115 | # 116 | # print("orig SDP", offer["sdp"]) 117 | # print("doctored SDP", doctored_sdp) 118 | 119 | offer = {"sdp": offer["sdp"], "type": offer["type"]} 120 | 121 | sdp.parameters_from_sdp(offer["sdp"]) 122 | 123 | answer = await datachannel_test.setup_test(connection, offer) 124 | 125 | return jsonify({"answer": {"sdp": answer.sdp, "type": answer.type}}) 126 | 127 | # doctored_sdp = sdp_doctor.remove_all_candidates(answer.sdp) 128 | # return jsonify({"answer": {"sdp": doctored_sdp, "type": answer.type}}) 129 | 130 | # doctored_sdp = sdp_doctor.doctor_sdp(answer.sdp, RTCIceCandidate(1, "42432452", "20.10.10.10", 4242, 1, "udp", "host")) 131 | # return jsonify({"answer": {"sdp": doctored_sdp, "type": answer.type}}) 132 | 133 | 134 | @app.route("/v1/test_me", methods=["OPTIONS"]) 135 | async def test_me_options(): 136 | # TODO CORS headers? 137 | pass 138 | 139 | 140 | # @app.websocket('/ws') 141 | # async def ws(): 142 | # while True: 143 | # await websocket.send('hello') 144 | -------------------------------------------------------------------------------- /scss/_normalize.scss: -------------------------------------------------------------------------------- 1 | /*! normalize.css v3.0.2 | MIT License | git.io/normalize */ 2 | 3 | /** 4 | * 1. Set default font family to sans-serif. 5 | * 2. Prevent iOS text size adjust after orientation change, without disabling 6 | * user zoom. 7 | */ 8 | 9 | html { 10 | font-family: sans-serif; /* 1 */ 11 | -ms-text-size-adjust: 100%; /* 2 */ 12 | -webkit-text-size-adjust: 100%; /* 2 */ 13 | } 14 | 15 | /** 16 | * Remove default margin. 17 | */ 18 | 19 | body { 20 | margin: 0; 21 | } 22 | 23 | /* HTML5 display definitions 24 | ========================================================================== */ 25 | 26 | /** 27 | * Correct `block` display not defined for any HTML5 element in IE 8/9. 28 | * Correct `block` display not defined for `details` or `summary` in IE 10/11 29 | * and Firefox. 30 | * Correct `block` display not defined for `main` in IE 11. 31 | */ 32 | 33 | article, 34 | aside, 35 | details, 36 | figcaption, 37 | figure, 38 | footer, 39 | header, 40 | hgroup, 41 | main, 42 | menu, 43 | nav, 44 | section, 45 | summary { 46 | display: block; 47 | } 48 | 49 | /** 50 | * 1. Correct `inline-block` display not defined in IE 8/9. 51 | * 2. Normalize vertical alignment of `progress` in Chrome, Firefox, and Opera. 52 | */ 53 | 54 | audio, 55 | canvas, 56 | progress, 57 | video { 58 | display: inline-block; /* 1 */ 59 | vertical-align: baseline; /* 2 */ 60 | } 61 | 62 | /** 63 | * Prevent modern browsers from displaying `audio` without controls. 64 | * Remove excess height in iOS 5 devices. 65 | */ 66 | 67 | audio:not([controls]) { 68 | display: none; 69 | height: 0; 70 | } 71 | 72 | /** 73 | * Address `[hidden]` styling not present in IE 8/9/10. 74 | * Hide the `template` element in IE 8/9/11, Safari, and Firefox < 22. 75 | */ 76 | 77 | [hidden], 78 | template { 79 | display: none; 80 | } 81 | 82 | /* Links 83 | ========================================================================== */ 84 | 85 | /** 86 | * Remove the gray background color from active links in IE 10. 87 | */ 88 | 89 | a { 90 | background-color: transparent; 91 | } 92 | 93 | /** 94 | * Improve readability when focused and also mouse hovered in all browsers. 95 | */ 96 | 97 | a:active, 98 | a:hover { 99 | outline: 0; 100 | } 101 | 102 | /* Text-level semantics 103 | ========================================================================== */ 104 | 105 | /** 106 | * Address styling not present in IE 8/9/10/11, Safari, and Chrome. 107 | */ 108 | 109 | abbr[title] { 110 | border-bottom: 1px dotted; 111 | } 112 | 113 | /** 114 | * Address style set to `bolder` in Firefox 4+, Safari, and Chrome. 115 | */ 116 | 117 | b, 118 | strong { 119 | font-weight: bold; 120 | } 121 | 122 | /** 123 | * Address styling not present in Safari and Chrome. 124 | */ 125 | 126 | dfn { 127 | font-style: italic; 128 | } 129 | 130 | /** 131 | * Address variable `h1` font-size and margin within `section` and `article` 132 | * contexts in Firefox 4+, Safari, and Chrome. 133 | */ 134 | 135 | h1 { 136 | font-size: 2em; 137 | margin: 0.67em 0; 138 | } 139 | 140 | /** 141 | * Address styling not present in IE 8/9. 142 | */ 143 | 144 | mark { 145 | background: #ff0; 146 | color: #000; 147 | } 148 | 149 | /** 150 | * Address inconsistent and variable font size in all browsers. 151 | */ 152 | 153 | small { 154 | font-size: 80%; 155 | } 156 | 157 | /** 158 | * Prevent `sub` and `sup` affecting `line-height` in all browsers. 159 | */ 160 | 161 | sub, 162 | sup { 163 | font-size: 75%; 164 | line-height: 0; 165 | position: relative; 166 | vertical-align: baseline; 167 | } 168 | 169 | sup { 170 | top: -0.5em; 171 | } 172 | 173 | sub { 174 | bottom: -0.25em; 175 | } 176 | 177 | /* Embedded content 178 | ========================================================================== */ 179 | 180 | /** 181 | * Remove border when inside `a` element in IE 8/9/10. 182 | */ 183 | 184 | img { 185 | border: 0; 186 | } 187 | 188 | /** 189 | * Correct overflow not hidden in IE 9/10/11. 190 | */ 191 | 192 | svg:not(:root) { 193 | overflow: hidden; 194 | } 195 | 196 | /* Grouping content 197 | ========================================================================== */ 198 | 199 | /** 200 | * Address margin not present in IE 8/9 and Safari. 201 | */ 202 | 203 | figure { 204 | margin: 1em 40px; 205 | } 206 | 207 | /** 208 | * Address differences between Firefox and other browsers. 209 | */ 210 | 211 | hr { 212 | -moz-box-sizing: content-box; 213 | box-sizing: content-box; 214 | height: 0; 215 | } 216 | 217 | /** 218 | * Contain overflow in all browsers. 219 | */ 220 | 221 | pre { 222 | overflow: auto; 223 | } 224 | 225 | /** 226 | * Address odd `em`-unit font size rendering in all browsers. 227 | */ 228 | 229 | code, 230 | kbd, 231 | pre, 232 | samp { 233 | font-family: monospace, monospace; 234 | font-size: 1em; 235 | } 236 | 237 | /* Forms 238 | ========================================================================== */ 239 | 240 | /** 241 | * Known limitation: by default, Chrome and Safari on OS X allow very limited 242 | * styling of `select`, unless a `border` property is set. 243 | */ 244 | 245 | /** 246 | * 1. Correct color not being inherited. 247 | * Known issue: affects color of disabled elements. 248 | * 2. Correct font properties not being inherited. 249 | * 3. Address margins set differently in Firefox 4+, Safari, and Chrome. 250 | */ 251 | 252 | button, 253 | input, 254 | optgroup, 255 | select, 256 | textarea { 257 | color: inherit; /* 1 */ 258 | font: inherit; /* 2 */ 259 | margin: 0; /* 3 */ 260 | } 261 | 262 | /** 263 | * Address `overflow` set to `hidden` in IE 8/9/10/11. 264 | */ 265 | 266 | button { 267 | overflow: visible; 268 | } 269 | 270 | /** 271 | * Address inconsistent `text-transform` inheritance for `button` and `select`. 272 | * All other form control elements do not inherit `text-transform` values. 273 | * Correct `button` style inheritance in Firefox, IE 8/9/10/11, and Opera. 274 | * Correct `select` style inheritance in Firefox. 275 | */ 276 | 277 | button, 278 | select { 279 | text-transform: none; 280 | } 281 | 282 | /** 283 | * 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio` 284 | * and `video` controls. 285 | * 2. Correct inability to style clickable `input` types in iOS. 286 | * 3. Improve usability and consistency of cursor style between image-type 287 | * `input` and others. 288 | */ 289 | 290 | button, 291 | html input[type="button"], /* 1 */ 292 | input[type="reset"], 293 | input[type="submit"] { 294 | -webkit-appearance: button; /* 2 */ 295 | cursor: pointer; /* 3 */ 296 | } 297 | 298 | /** 299 | * Re-set default cursor for disabled elements. 300 | */ 301 | 302 | button[disabled], 303 | html input[disabled] { 304 | cursor: default; 305 | } 306 | 307 | /** 308 | * Remove inner padding and border in Firefox 4+. 309 | */ 310 | 311 | button::-moz-focus-inner, 312 | input::-moz-focus-inner { 313 | border: 0; 314 | padding: 0; 315 | } 316 | 317 | /** 318 | * Address Firefox 4+ setting `line-height` on `input` using `!important` in 319 | * the UA stylesheet. 320 | */ 321 | 322 | input { 323 | line-height: normal; 324 | } 325 | 326 | /** 327 | * It's recommended that you don't attempt to style these elements. 328 | * Firefox's implementation doesn't respect box-sizing, padding, or width. 329 | * 330 | * 1. Address box sizing set to `content-box` in IE 8/9/10. 331 | * 2. Remove excess padding in IE 8/9/10. 332 | */ 333 | 334 | input[type="checkbox"], 335 | input[type="radio"] { 336 | box-sizing: border-box; /* 1 */ 337 | padding: 0; /* 2 */ 338 | } 339 | 340 | /** 341 | * Fix the cursor style for Chrome's increment/decrement buttons. For certain 342 | * `font-size` values of the `input`, it causes the cursor style of the 343 | * decrement button to change from `default` to `text`. 344 | */ 345 | 346 | input[type="number"]::-webkit-inner-spin-button, 347 | input[type="number"]::-webkit-outer-spin-button { 348 | height: auto; 349 | } 350 | 351 | /** 352 | * 1. Address `appearance` set to `searchfield` in Safari and Chrome. 353 | * 2. Address `box-sizing` set to `border-box` in Safari and Chrome 354 | * (include `-moz` to future-proof). 355 | */ 356 | 357 | input[type="search"] { 358 | -webkit-appearance: textfield; /* 1 */ 359 | -moz-box-sizing: content-box; 360 | -webkit-box-sizing: content-box; /* 2 */ 361 | box-sizing: content-box; 362 | } 363 | 364 | /** 365 | * Remove inner padding and search cancel button in Safari and Chrome on OS X. 366 | * Safari (but not Chrome) clips the cancel button when the search input has 367 | * padding (and `textfield` appearance). 368 | */ 369 | 370 | input[type="search"]::-webkit-search-cancel-button, 371 | input[type="search"]::-webkit-search-decoration { 372 | -webkit-appearance: none; 373 | } 374 | 375 | /** 376 | * Define consistent border, margin, and padding. 377 | */ 378 | 379 | fieldset { 380 | border: 1px solid #c0c0c0; 381 | margin: 0 2px; 382 | padding: 0.35em 0.625em 0.75em; 383 | } 384 | 385 | /** 386 | * 1. Correct `color` not being inherited in IE 8/9/10/11. 387 | * 2. Remove padding so people aren't caught out if they zero out fieldsets. 388 | */ 389 | 390 | legend { 391 | border: 0; /* 1 */ 392 | padding: 0; /* 2 */ 393 | } 394 | 395 | /** 396 | * Remove default vertical scrollbar in IE 8/9/10/11. 397 | */ 398 | 399 | textarea { 400 | overflow: auto; 401 | } 402 | 403 | /** 404 | * Don't inherit the `font-weight` (applied by a rule above). 405 | * NOTE: the default cannot safely be changed in Chrome and Safari on OS X. 406 | */ 407 | 408 | optgroup { 409 | font-weight: bold; 410 | } 411 | 412 | /* Tables 413 | ========================================================================== */ 414 | 415 | /** 416 | * Remove most spacing between table cells. 417 | */ 418 | 419 | table { 420 | border-collapse: collapse; 421 | border-spacing: 0; 422 | } 423 | 424 | td, 425 | th { 426 | padding: 0; 427 | } -------------------------------------------------------------------------------- /scss/_skeleton.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * Skeleton V2.0.4 3 | * Copyright 2014, Dave Gamache 4 | * www.getskeleton.com 5 | * Free to use under the MIT license. 6 | * http://www.opensource.org/licenses/mit-license.php 7 | * 12/29/2014 8 | */ 9 | 10 | 11 | /* Table of contents 12 | –––––––––––––––––––––––––––––––––––––––––––––––––– 13 | - Grid 14 | - Base Styles 15 | - Typography 16 | - Links 17 | - Buttons 18 | - Forms 19 | - Lists 20 | - Code 21 | - Tables 22 | - Spacing 23 | - Utilities 24 | - Clearing 25 | - Media Queries 26 | */ 27 | 28 | 29 | /* Grid 30 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 31 | .container { 32 | position: relative; 33 | width: 100%; 34 | max-width: 960px; 35 | margin: 0 auto; 36 | padding: 0 20px; 37 | box-sizing: border-box; } 38 | .column, 39 | .columns { 40 | width: 100%; 41 | float: left; 42 | box-sizing: border-box; } 43 | 44 | /* For devices larger than 400px */ 45 | @media (min-width: 400px) { 46 | .container { 47 | width: 85%; 48 | padding: 0; } 49 | } 50 | 51 | /* For devices larger than 550px */ 52 | @media (min-width: 550px) { 53 | .container { 54 | width: 80%; } 55 | .column, 56 | .columns { 57 | margin-left: 4%; } 58 | .column:first-child, 59 | .columns:first-child { 60 | margin-left: 0; } 61 | 62 | .one.column, 63 | .one.columns { width: 4.66666666667%; } 64 | .two.columns { width: 13.3333333333%; } 65 | .three.columns { width: 22%; } 66 | .four.columns { width: 30.6666666667%; } 67 | .five.columns { width: 39.3333333333%; } 68 | .six.columns { width: 48%; } 69 | .seven.columns { width: 56.6666666667%; } 70 | .eight.columns { width: 65.3333333333%; } 71 | .nine.columns { width: 74.0%; } 72 | .ten.columns { width: 82.6666666667%; } 73 | .eleven.columns { width: 91.3333333333%; } 74 | .twelve.columns { width: 100%; margin-left: 0; } 75 | 76 | .one-third.column { width: 30.6666666667%; } 77 | .two-thirds.column { width: 65.3333333333%; } 78 | 79 | .one-half.column { width: 48%; } 80 | 81 | /* Offsets */ 82 | .offset-by-one.column, 83 | .offset-by-one.columns { margin-left: 8.66666666667%; } 84 | .offset-by-two.column, 85 | .offset-by-two.columns { margin-left: 17.3333333333%; } 86 | .offset-by-three.column, 87 | .offset-by-three.columns { margin-left: 26%; } 88 | .offset-by-four.column, 89 | .offset-by-four.columns { margin-left: 34.6666666667%; } 90 | .offset-by-five.column, 91 | .offset-by-five.columns { margin-left: 43.3333333333%; } 92 | .offset-by-six.column, 93 | .offset-by-six.columns { margin-left: 52%; } 94 | .offset-by-seven.column, 95 | .offset-by-seven.columns { margin-left: 60.6666666667%; } 96 | .offset-by-eight.column, 97 | .offset-by-eight.columns { margin-left: 69.3333333333%; } 98 | .offset-by-nine.column, 99 | .offset-by-nine.columns { margin-left: 78.0%; } 100 | .offset-by-ten.column, 101 | .offset-by-ten.columns { margin-left: 86.6666666667%; } 102 | .offset-by-eleven.column, 103 | .offset-by-eleven.columns { margin-left: 95.3333333333%; } 104 | 105 | .offset-by-one-third.column, 106 | .offset-by-one-third.columns { margin-left: 34.6666666667%; } 107 | .offset-by-two-thirds.column, 108 | .offset-by-two-thirds.columns { margin-left: 69.3333333333%; } 109 | 110 | .offset-by-one-half.column, 111 | .offset-by-one-half.columns { margin-left: 52%; } 112 | 113 | } 114 | 115 | 116 | /* Base Styles 117 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 118 | /* NOTE 119 | html is set to 62.5% so that all the REM measurements throughout Skeleton 120 | are based on 10px sizing. So basically 1.5rem = 15px :) */ 121 | html { 122 | font-size: 62.5%; } 123 | body { 124 | font-size: 1.5em; /* currently ems cause chrome bug misinterpreting rems on body element */ 125 | line-height: 1.6; 126 | font-weight: 400; 127 | font-family: "Raleway", "HelveticaNeue", "Helvetica Neue", Helvetica, Arial, sans-serif; 128 | color: #222; } 129 | 130 | 131 | /* Typography 132 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 133 | h1, h2, h3, h4, h5, h6 { 134 | margin-top: 0; 135 | margin-bottom: 2rem; 136 | font-weight: 300; } 137 | h1 { font-size: 4.0rem; line-height: 1.2; letter-spacing: -.1rem;} 138 | h2 { font-size: 3.6rem; line-height: 1.25; letter-spacing: -.1rem; } 139 | h3 { font-size: 3.0rem; line-height: 1.3; letter-spacing: -.1rem; } 140 | h4 { font-size: 2.4rem; line-height: 1.35; letter-spacing: -.08rem; } 141 | h5 { font-size: 1.8rem; line-height: 1.5; letter-spacing: -.05rem; } 142 | h6 { font-size: 1.5rem; line-height: 1.6; letter-spacing: 0; } 143 | 144 | /* Larger than phablet */ 145 | @media (min-width: 550px) { 146 | h1 { font-size: 5.0rem; } 147 | h2 { font-size: 4.2rem; } 148 | h3 { font-size: 3.6rem; } 149 | h4 { font-size: 3.0rem; } 150 | h5 { font-size: 2.4rem; } 151 | h6 { font-size: 1.5rem; } 152 | } 153 | 154 | p { 155 | margin-top: 0; } 156 | 157 | 158 | /* Links 159 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 160 | a { 161 | color: #1EAEDB; } 162 | a:hover { 163 | color: #0FA0CE; } 164 | 165 | 166 | /* Buttons 167 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 168 | .button, 169 | button, 170 | input[type="submit"], 171 | input[type="reset"], 172 | input[type="button"] { 173 | display: inline-block; 174 | height: 38px; 175 | padding: 0 30px; 176 | color: #555; 177 | text-align: center; 178 | font-size: 11px; 179 | font-weight: 600; 180 | line-height: 38px; 181 | letter-spacing: .1rem; 182 | text-transform: uppercase; 183 | text-decoration: none; 184 | white-space: nowrap; 185 | background-color: transparent; 186 | border-radius: 4px; 187 | border: 1px solid #bbb; 188 | cursor: pointer; 189 | box-sizing: border-box; } 190 | .button:hover, 191 | button:hover, 192 | input[type="submit"]:hover, 193 | input[type="reset"]:hover, 194 | input[type="button"]:hover, 195 | .button:focus, 196 | button:focus, 197 | input[type="submit"]:focus, 198 | input[type="reset"]:focus, 199 | input[type="button"]:focus { 200 | color: #333; 201 | border-color: #888; 202 | outline: 0; } 203 | .button.button-primary, 204 | button.button-primary, 205 | input[type="submit"].button-primary, 206 | input[type="reset"].button-primary, 207 | input[type="button"].button-primary { 208 | color: #FFF; 209 | background-color: #33C3F0; 210 | border-color: #33C3F0; } 211 | .button.button-primary:hover, 212 | button.button-primary:hover, 213 | input[type="submit"].button-primary:hover, 214 | input[type="reset"].button-primary:hover, 215 | input[type="button"].button-primary:hover, 216 | .button.button-primary:focus, 217 | button.button-primary:focus, 218 | input[type="submit"].button-primary:focus, 219 | input[type="reset"].button-primary:focus, 220 | input[type="button"].button-primary:focus { 221 | color: #FFF; 222 | background-color: #1EAEDB; 223 | border-color: #1EAEDB; } 224 | 225 | 226 | /* Forms 227 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 228 | input[type="email"], 229 | input[type="number"], 230 | input[type="search"], 231 | input[type="text"], 232 | input[type="tel"], 233 | input[type="url"], 234 | input[type="password"], 235 | textarea, 236 | select { 237 | height: 38px; 238 | padding: 6px 10px; /* The 6px vertically centers text on FF, ignored by Webkit */ 239 | background-color: #fff; 240 | border: 1px solid #D1D1D1; 241 | border-radius: 4px; 242 | box-shadow: none; 243 | box-sizing: border-box; } 244 | /* Removes awkward default styles on some inputs for iOS */ 245 | input[type="email"], 246 | input[type="number"], 247 | input[type="search"], 248 | input[type="text"], 249 | input[type="tel"], 250 | input[type="url"], 251 | input[type="password"], 252 | textarea { 253 | -webkit-appearance: none; 254 | -moz-appearance: none; 255 | appearance: none; } 256 | textarea { 257 | min-height: 65px; 258 | padding-top: 6px; 259 | padding-bottom: 6px; } 260 | input[type="email"]:focus, 261 | input[type="number"]:focus, 262 | input[type="search"]:focus, 263 | input[type="text"]:focus, 264 | input[type="tel"]:focus, 265 | input[type="url"]:focus, 266 | input[type="password"]:focus, 267 | textarea:focus, 268 | select:focus { 269 | border: 1px solid #33C3F0; 270 | outline: 0; } 271 | label, 272 | legend { 273 | display: block; 274 | margin-bottom: .5rem; 275 | font-weight: 600; } 276 | fieldset { 277 | padding: 0; 278 | border-width: 0; } 279 | input[type="checkbox"], 280 | input[type="radio"] { 281 | display: inline; } 282 | label > .label-body { 283 | display: inline-block; 284 | margin-left: .5rem; 285 | font-weight: normal; } 286 | 287 | 288 | /* Lists 289 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 290 | ul { 291 | list-style: circle inside; } 292 | ol { 293 | list-style: decimal inside; } 294 | ol, ul { 295 | padding-left: 0; 296 | margin-top: 0; } 297 | ul ul, 298 | ul ol, 299 | ol ol, 300 | ol ul { 301 | margin: 1.5rem 0 1.5rem 3rem; 302 | font-size: 90%; } 303 | li { 304 | margin-bottom: 1rem; } 305 | 306 | 307 | /* Code 308 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 309 | code { 310 | padding: .2rem .5rem; 311 | margin: 0 .2rem; 312 | font-size: 90%; 313 | white-space: nowrap; 314 | background: #F1F1F1; 315 | border: 1px solid #E1E1E1; 316 | border-radius: 4px; } 317 | pre > code { 318 | display: block; 319 | padding: 1rem 1.5rem; 320 | white-space: pre; } 321 | 322 | 323 | /* Tables 324 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 325 | th, 326 | td { 327 | padding: 12px 15px; 328 | text-align: left; 329 | border-bottom: 1px solid #E1E1E1; } 330 | th:first-child, 331 | td:first-child { 332 | padding-left: 0; } 333 | th:last-child, 334 | td:last-child { 335 | padding-right: 0; } 336 | 337 | 338 | /* Spacing 339 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 340 | button, 341 | .button { 342 | margin-bottom: 1rem; } 343 | input, 344 | textarea, 345 | select, 346 | fieldset { 347 | margin-bottom: 1.5rem; } 348 | pre, 349 | blockquote, 350 | dl, 351 | figure, 352 | table, 353 | p, 354 | ul, 355 | ol, 356 | form { 357 | margin-bottom: 2.5rem; } 358 | 359 | 360 | /* Utilities 361 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 362 | .u-full-width { 363 | width: 100%; 364 | box-sizing: border-box; } 365 | .u-max-full-width { 366 | max-width: 100%; 367 | box-sizing: border-box; } 368 | .u-pull-right { 369 | float: right; } 370 | .u-pull-left { 371 | float: left; } 372 | 373 | 374 | /* Misc 375 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 376 | hr { 377 | margin-top: 3rem; 378 | margin-bottom: 3.5rem; 379 | border-width: 0; 380 | border-top: 1px solid #E1E1E1; } 381 | 382 | 383 | /* Clearing 384 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 385 | 386 | /* Self Clearing Goodness */ 387 | .container:after, 388 | .row:after, 389 | .u-cf { 390 | content: ""; 391 | display: table; 392 | clear: both; } 393 | 394 | 395 | /* Media Queries 396 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 397 | /* 398 | Note: The best way to structure the use of media queries is to create the queries 399 | near the relevant code. For example, if you wanted to change the styles for buttons 400 | on small devices, paste the mobile query code up in the buttons section and style it 401 | there. 402 | */ 403 | 404 | 405 | /* Larger than mobile */ 406 | @media (min-width: 400px) {} 407 | 408 | /* Larger than phablet (also point when grid becomes active) */ 409 | @media (min-width: 550px) {} 410 | 411 | /* Larger than tablet */ 412 | @media (min-width: 750px) {} 413 | 414 | /* Larger than desktop */ 415 | @media (min-width: 1000px) {} 416 | 417 | /* Larger than Desktop HD */ 418 | @media (min-width: 1200px) {} 419 | -------------------------------------------------------------------------------- /scss/tester.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019-2020 The Matrix.org Foundation C.I.C. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | @import "normalize"; 18 | @import "skeleton"; 19 | 20 | $backgroundColour: #203040; 21 | $inputBackgroundColour: #222; 22 | $foregroundColour: #eee; 23 | 24 | $reportNodeBackgroundColour: #456; 25 | 26 | 27 | html, body { 28 | margin: 0; 29 | padding: 0; 30 | background: $backgroundColour; 31 | color: $foregroundColour; 32 | font-family: sans-serif; 33 | } 34 | 35 | hr { 36 | background-color: darken($foregroundColour, 10%); 37 | } 38 | 39 | .container { 40 | margin-top: 2em; 41 | } 42 | 43 | input { 44 | background-color: $inputBackgroundColour !important; 45 | } 46 | 47 | // #progresslist is our list of hierarchical progress bars 48 | #progresslist li { 49 | // hide the bullet points 50 | list-style-type: none; 51 | } 52 | 53 | #progresslist progress { 54 | width: 10em; 55 | height: 1em; 56 | display: inline; 57 | margin-right: 1em; 58 | } 59 | 60 | 61 | // .report-node is a node in our report tree 62 | // it can potentially be expanded 63 | // it can bear a title, subtitle and a coloured 'verdict' 64 | // see: 65 | // expander title subtitle verdict 66 | // |---|-------------------------------------------|------| 67 | // | > | IPv4 Test No STUN | Poor | 68 | // |---|-------------------------------------------|------| 69 | 70 | .report-node { 71 | display: flex; 72 | flex-direction: row; 73 | align-items: center; 74 | margin: 1em; 75 | background-color: $reportNodeBackgroundColour; 76 | } 77 | 78 | .report-node.expandable { 79 | cursor: pointer; 80 | } 81 | 82 | .report-node.expandable::before { 83 | padding: 1em; 84 | content: '+'; 85 | } 86 | 87 | .report-node.expandable.expanded::before { 88 | content: '−'; 89 | } 90 | 91 | .report-node .title { 92 | // make this one grow to fill the space 93 | flex: 1 0; 94 | margin: 1em; 95 | } 96 | 97 | .report-node .subtitle { 98 | margin: 1em; 99 | } 100 | 101 | .verdict { 102 | padding: 1em; 103 | } 104 | 105 | .verdict.fail { 106 | background-color: #bb0000; 107 | } 108 | 109 | .verdict.poor { 110 | background-color: #bb5e00; 111 | } 112 | 113 | .verdict.good { 114 | background-color: #bbbb00; 115 | } 116 | 117 | .verdict.great { 118 | background-color: #9ebb00; 119 | } 120 | 121 | .verdict.excellent { 122 | background-color: #3dbb00; 123 | } 124 | 125 | // container for the child nodes 126 | .report-subtree { 127 | margin-left: 1em; 128 | } 129 | 130 | // textual help within the results 131 | .report-info-node { 132 | margin: 1em; 133 | margin-right: 0; 134 | } 135 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | # line length defaulted to by black 3 | max-line-length = 88 4 | 5 | # see https://pycodestyle.readthedocs.io/en/latest/intro.html#error-codes 6 | # for error codes. The ones we ignore are: 7 | # W503: line break before binary operator 8 | # W504: line break after binary operator 9 | # E203: whitespace before ':' (which is contrary to pep8?) 10 | # (this is a subset of those ignored in Synapse) 11 | ignore=W503,W504,E203 12 | 13 | [isort] 14 | line_length = 80 15 | not_skip = __init__.py 16 | sections=FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,TESTS,LOCALFOLDER 17 | default_section=THIRDPARTY 18 | known_first_party=sygnal 19 | known_tests=tests 20 | multi_line_output=3 21 | include_trailing_comma=true 22 | combine_as_imports=true 23 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | name="mxvoiptestd", 5 | version="0.1.0", 6 | packages=["mxvoiptestd"], 7 | url="https://github.com/matrix-org/matrix_voip_tester", 8 | license="Apache License 2.0", 9 | author="Olivier Wilkinson ('reivilibre')", 10 | author_email="oliverw@matrix.org", 11 | description="Tests VoIP functionality of a Matrix homeserver", 12 | install_requires=["aiortc>=0.9.21", "Quart>=0.9.1"], 13 | ) 14 | --------------------------------------------------------------------------------