mirror of
https://github.com/freedombox/FreedomBox.git
synced 2026-06-10 11:00:22 +00:00
coturn: New app to manage Coturn TURN/STUN server
- Shows URLs and shared secret that communication servers like matrix-synapse should be configured to. Later we will implement auto-configuring those servers. - Allow selecting domain for the sake of TLS/DTLS certificate installation. - Simplify systemd service file options. Drop log file and pid file support as they are not needed with systemd. Add security options. - Set custom configuration file by overriding systemd service file options so that we don't have a problem with conffile prompts. - Implement functional tests (and automatic diagnostics). - Custom icon selected from the Noun project as Coturn project does not have one. - Backup/restore configuration file and certificates. - Document some questions regarding configuration options. Tests performed: - App is not listed in the app page if 'advanced' flag is disabled. - App name, icon and short description shows up correctly in apps page. - App name, icon, short description, description, manual link, enable/disable button and diagnostics link show up currently in app page. - Verify that configuration used by coturn server is the FreedomBox configuration by checking the cert path in the log output. - PID file is not created in /var/run/turnserver/. It goes into /dev/null according to the log output. - No log file is created other than what is collected by systemd from command line. - systemctl show coturn.service shows all the intended restrictions such as NoNewPrivileges, Protect* options. - Run functional tests. - Ensure that backup of configuration file works by taking backup, changing the secret and restoring. During backup and restore coturn should be stopped and started as per logs. - Build Debian package. No warnings about the copyright file. - Enabling the app enables the service and runs it. - Disabling the app disables the service and stop it. - All diagnostics tests pass. - Diagnostic tests show firewall port coturn-freedombox for internal and external networks, service coturn, and each listening port for udp4, udp6, tcp4 and tcp6. - Information in the firewall page shows up properly. Enabling the app opens firewall ports, and disabling it closes them. - When the app is installed, if a cert domain is available, it will be used. When multiple domains are available, one of them is picked. - Status shows 4 URLs with the currently selected domain and secret key. - Changing domain to another domain succeeds and reflects in the status information. - When no domain is configured. Installing the app succeeds. No domain is shown in the list of domains. - When domain is changed, the certificates files in /etc/coturn/certs are overwritten. - Certificates have the ownership turnserver:turnserver. Public key is cert.pem has 644 permissions. Private is pkey.pem has 600 permissions. /etc/coturn/certs is owned by root:root. - Let's encrypt certificates are setup immediately after install. - Port forwarding information shows all ports except for relay ports. - Trying to create a user with username 'turnserver' throws an error. This happens even when coturn is not installed yet. - After installing coturn, the configuration file /etc/coturn/freedombox.conf is created with ownership root:turnserver and permissions 640. The directory /etc/coturn is created with ownership root:root and permissions 755. Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org> [jvalleroy: Fix copied form_valid comment] Signed-off-by: James Valleroy <jvalleroy@mailbox.org> Reviewed-by: James Valleroy <jvalleroy@mailbox.org>
This commit is contained in:
parent
efe84419b1
commit
b4e6c03bd7
116
actions/coturn
Executable file
116
actions/coturn
Executable file
@ -0,0 +1,116 @@
|
||||
#!/usr/bin/python3
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
"""
|
||||
Configuration helper for Coturn daemon.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import pathlib
|
||||
import random
|
||||
import shutil
|
||||
import string
|
||||
|
||||
import augeas
|
||||
|
||||
from plinth import action_utils
|
||||
|
||||
CONFIG_FILE = pathlib.Path('/etc/coturn/freedombox.conf')
|
||||
|
||||
|
||||
def parse_arguments():
|
||||
"""Return parsed command line arguments as dictionary."""
|
||||
parser = argparse.ArgumentParser()
|
||||
subparsers = parser.add_subparsers(dest='subcommand', help='Sub command')
|
||||
|
||||
subparsers.add_parser('setup', help='Setup Coturn server')
|
||||
subparsers.add_parser('get-config',
|
||||
help='Return the current configuration')
|
||||
subparser = subparsers.add_parser('set-domain', help='Set the TLS domain')
|
||||
subparser.add_argument('domain_name', help='TLS domain name to set')
|
||||
|
||||
subparsers.required = True
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def _key_path(key):
|
||||
"""Return the augeas path for a key."""
|
||||
return '/files' + str(CONFIG_FILE) + '/' + key
|
||||
|
||||
|
||||
def subcommand_setup(_):
|
||||
"""Setup Coturn server."""
|
||||
CONFIG_FILE.parent.mkdir(exist_ok=True)
|
||||
if not CONFIG_FILE.exists():
|
||||
CONFIG_FILE.touch(0o640)
|
||||
shutil.chown(CONFIG_FILE, group='turnserver')
|
||||
|
||||
action_utils.service_daemon_reload()
|
||||
action_utils.service_try_restart('coturn')
|
||||
|
||||
aug = augeas_load()
|
||||
|
||||
# XXX: Should we set listen, relay IP address to :: or dynamically
|
||||
# XXX: Should we set external-ip
|
||||
aug.set(_key_path('min-port'), '49152')
|
||||
aug.set(_key_path('max-port'), '50175')
|
||||
aug.set(_key_path('use-auth-secret'), 'true')
|
||||
if not aug.get(_key_path('static-auth-secret')):
|
||||
secret = ''.join(
|
||||
random.choice(string.ascii_letters + string.digits)
|
||||
for _ in range(64))
|
||||
aug.set(_key_path('static-auth-secret'), secret)
|
||||
|
||||
aug.set(_key_path('cert'), '/etc/coturn/certs/cert.pem')
|
||||
aug.set(_key_path('pkey'), '/etc/coturn/certs/pkey.pem')
|
||||
aug.set(_key_path('no-tlsv1'), 'true')
|
||||
aug.set(_key_path('no-tlsv1_1'), 'true')
|
||||
aug.set(_key_path('no-cli'), 'true')
|
||||
|
||||
aug.save()
|
||||
|
||||
|
||||
def subcommand_get_config(_):
|
||||
"""Return the current configuration in JSON format."""
|
||||
aug = augeas_load()
|
||||
config = {
|
||||
'static_auth_secret': aug.get(_key_path('static-auth-secret')),
|
||||
'realm': aug.get(_key_path('realm')),
|
||||
}
|
||||
print(json.dumps(config))
|
||||
|
||||
|
||||
def subcommand_set_domain(arguments):
|
||||
"""Set the TLS domain.
|
||||
|
||||
This value is usually not stored. So, set realm value even though it is not
|
||||
needed to set realm for REST API based authentication.
|
||||
|
||||
"""
|
||||
aug = augeas_load()
|
||||
aug.set(_key_path('realm'), arguments.domain_name)
|
||||
aug.save()
|
||||
|
||||
|
||||
def augeas_load():
|
||||
"""Initialize Augeas."""
|
||||
aug = augeas.Augeas(flags=augeas.Augeas.NO_LOAD +
|
||||
augeas.Augeas.NO_MODL_AUTOLOAD)
|
||||
aug.set('/augeas/load/Simplevars/lens', 'Simplevars.lns')
|
||||
aug.set('/augeas/load/Simplevars/incl[last() + 1]', str(CONFIG_FILE))
|
||||
aug.load()
|
||||
|
||||
return aug
|
||||
|
||||
|
||||
def main():
|
||||
"""Parse arguments and perform all duties."""
|
||||
arguments = parse_arguments()
|
||||
|
||||
subcommand = arguments.subcommand.replace('-', '_')
|
||||
subcommand_method = globals()['subcommand_' + subcommand]
|
||||
subcommand_method(arguments)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
6
debian/copyright
vendored
6
debian/copyright
vendored
@ -41,6 +41,12 @@ Copyright: Cockpit Authors (https://github.com/cockpit-project/cockpit/blob/mast
|
||||
Comment: https://github.com/cockpit-project/cockpit/blob/master/src/branding/default/logo.svg
|
||||
License: LGPL-2.1+
|
||||
|
||||
Files: static/themes/default/icons/coturn.png
|
||||
static/themes/default/icons/coturn.svg
|
||||
Copyright: Kmg Design (https://thenounproject.com/kmgdesignid/)
|
||||
Comment: Video Call by Kmg Design from the Noun Project https://thenounproject.com/term/video-call/3256092/
|
||||
License: CC-BY-3.0-US
|
||||
|
||||
Files: static/themes/default/icons/deluge.png
|
||||
Copyright: 2007 Andrew Wedderburn
|
||||
Comment: https://upload.wikimedia.org/wikipedia/commons/thumb/8/85//Deluge-Logo.svg/2000px-Deluge-Logo.svg.png
|
||||
|
||||
27
functional_tests/features/coturn.feature
Normal file
27
functional_tests/features/coturn.feature
Normal file
@ -0,0 +1,27 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
@apps @coturn @backups
|
||||
Feature: Coturn STUN/TURN Server
|
||||
Run the Coturn STUN/TURN server.
|
||||
|
||||
Background:
|
||||
Given I'm a logged in user
|
||||
And advanced mode is on
|
||||
And the coturn application is installed
|
||||
|
||||
Scenario: Enable coturn application
|
||||
Given the coturn application is disabled
|
||||
When I enable the coturn application
|
||||
Then the coturn service should be running
|
||||
|
||||
# TODO: Improve this by checking that secret and domain did not change
|
||||
Scenario: Backup and restore coturn
|
||||
Given the coturn application is enabled
|
||||
When I create a backup of the coturn app data
|
||||
And I restore the coturn app data backup
|
||||
Then the coturn service should be running
|
||||
|
||||
Scenario: Disable coturn application
|
||||
Given the coturn application is enabled
|
||||
When I disable the coturn application
|
||||
Then the coturn service should not be running
|
||||
156
plinth/modules/coturn/__init__.py
Normal file
156
plinth/modules/coturn/__init__.py
Normal file
@ -0,0 +1,156 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
"""
|
||||
FreedomBox app to configure Coturn server.
|
||||
"""
|
||||
|
||||
import json
|
||||
import pathlib
|
||||
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from plinth import actions
|
||||
from plinth import app as app_module
|
||||
from plinth import menu
|
||||
from plinth.daemon import Daemon
|
||||
from plinth.modules import names
|
||||
from plinth.modules.firewall.components import Firewall
|
||||
from plinth.modules.letsencrypt.components import LetsEncrypt
|
||||
from plinth.modules.users.components import UsersAndGroups
|
||||
|
||||
from .manifest import backup # noqa, pylint: disable=unused-import
|
||||
|
||||
version = 1
|
||||
|
||||
managed_services = ['coturn']
|
||||
|
||||
managed_packages = ['coturn']
|
||||
|
||||
managed_paths = [pathlib.Path('/etc/coturn/')]
|
||||
|
||||
_description = [
|
||||
_('Coturn is a server to facilitate audio/video calls and conferences by '
|
||||
'providing an implementation of TURN and STUN protocols. WebRTC, SIP '
|
||||
'and other communication servers can use it to establish a call between '
|
||||
'parties who are otherwise unable connect to each other.'),
|
||||
_('It is not meant to be used directly by users. Servers such as '
|
||||
'matrix-synapse need to be configured with the details provided here.'),
|
||||
]
|
||||
|
||||
port_forwarding_info = [
|
||||
('UDP', 3478),
|
||||
('TCP', 3478),
|
||||
('UDP', 3479),
|
||||
('TCP', 3479),
|
||||
('UDP', 5349),
|
||||
('TCP', 5349),
|
||||
('UDP', 5350),
|
||||
('TCP', 5350),
|
||||
# XXX: Add relay ports here
|
||||
]
|
||||
|
||||
app = None
|
||||
|
||||
|
||||
class CoturnApp(app_module.App):
|
||||
"""FreedomBox app for Coturn."""
|
||||
|
||||
app_id = 'coturn'
|
||||
|
||||
def __init__(self):
|
||||
"""Create components for the app."""
|
||||
super().__init__()
|
||||
|
||||
info = app_module.Info(app_id=self.app_id, version=version,
|
||||
name=_('Coturn'), icon_filename='coturn',
|
||||
short_description=_('VoIP Helper'),
|
||||
description=_description, manual_page='Coturn')
|
||||
self.add(info)
|
||||
|
||||
menu_item = menu.Menu('menu-coturn', info.name, info.short_description,
|
||||
info.icon_filename, 'coturn:index',
|
||||
parent_url_name='apps', advanced=True)
|
||||
self.add(menu_item)
|
||||
|
||||
firewall = Firewall('firewall-coturn', info.name,
|
||||
ports=['coturn-freedombox'], is_external=True)
|
||||
self.add(firewall)
|
||||
|
||||
letsencrypt = LetsEncrypt(
|
||||
'letsencrypt-coturn', domains=get_domains,
|
||||
daemons=managed_services, should_copy_certificates=True,
|
||||
private_key_path='/etc/coturn/certs/pkey.pem',
|
||||
certificate_path='/etc/coturn/certs/cert.pem',
|
||||
user_owner='turnserver', group_owner='turnserver',
|
||||
managing_app='coturn')
|
||||
self.add(letsencrypt)
|
||||
|
||||
daemon = Daemon(
|
||||
'daemon-coturn', managed_services[0],
|
||||
listen_ports=[(3478, 'udp4'), (3478, 'udp6'), (3478, 'tcp4'),
|
||||
(3478, 'tcp6'), (3479, 'udp4'), (3479, 'udp6'),
|
||||
(3479, 'tcp4'), (3479, 'tcp6'), (5349, 'udp4'),
|
||||
(5349, 'udp6'), (5349, 'tcp4'), (5349, 'tcp6'),
|
||||
(5350, 'udp4'), (5350, 'udp6'), (5350, 'tcp4'),
|
||||
(5350, 'tcp6')])
|
||||
self.add(daemon)
|
||||
|
||||
users_and_groups = UsersAndGroups('users-and-groups-coturn',
|
||||
reserved_usernames=['turnserver'])
|
||||
self.add(users_and_groups)
|
||||
|
||||
|
||||
def init():
|
||||
"""Initialize the Coturn module."""
|
||||
global app
|
||||
app = CoturnApp()
|
||||
|
||||
setup_helper = globals()['setup_helper']
|
||||
if setup_helper.get_state() != 'needs-setup' and app.is_enabled():
|
||||
app.set_enabled(True)
|
||||
|
||||
|
||||
def setup(helper, old_version=None):
|
||||
"""Install and configure the module."""
|
||||
helper.install(managed_packages)
|
||||
helper.call('post', actions.superuser_run, 'coturn', ['setup'])
|
||||
helper.call('post', app.enable)
|
||||
app.get_component('letsencrypt-coturn').setup_certificates()
|
||||
|
||||
|
||||
def get_available_domains():
|
||||
"""Return an iterator with all domains able to have a certificate."""
|
||||
return (domain.name for domain in names.components.DomainName.list()
|
||||
if domain.domain_type.can_have_certificate)
|
||||
|
||||
|
||||
def get_domain():
|
||||
"""Read TLS domain from config file select first available if none."""
|
||||
config = get_config()
|
||||
if config['realm']:
|
||||
return get_config()['realm']
|
||||
|
||||
domain = next(get_available_domains(), None)
|
||||
set_domain(domain)
|
||||
|
||||
return domain
|
||||
|
||||
|
||||
def get_domains():
|
||||
"""Return a list with the configured domains."""
|
||||
domain = get_domain()
|
||||
if domain:
|
||||
return [domain]
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def set_domain(domain):
|
||||
"""Set the TLS domain by writing a file to data directory."""
|
||||
if domain:
|
||||
actions.superuser_run('coturn', ['set-domain', domain])
|
||||
|
||||
|
||||
def get_config():
|
||||
"""Return the coturn server configuration."""
|
||||
output = actions.superuser_run('coturn', ['get-config'])
|
||||
return json.loads(output)
|
||||
@ -0,0 +1 @@
|
||||
plinth.modules.coturn
|
||||
@ -0,0 +1,20 @@
|
||||
[Service]
|
||||
ExecStart=
|
||||
ExecStart=/usr/bin/turnserver -c /etc/coturn/freedombox.conf --pidfile=/dev/null --log-file=-
|
||||
ExecStartPost=
|
||||
LockPersonality=yes
|
||||
NoNewPrivileges=yes
|
||||
PIDFile=
|
||||
PrivateDevices=yes
|
||||
PrivateMounts=yes
|
||||
PrivateTmp=yes
|
||||
ProtectControlGroups=yes
|
||||
ProtectHome=yes
|
||||
ProtectKernelLogs=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectSystem=strict
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
|
||||
RestrictRealtime=yes
|
||||
SystemCallArchitectures=native
|
||||
Type=simple
|
||||
@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<service>
|
||||
<short>Coturn STUN/TURN server</short>
|
||||
<description>Coturn is a server to facilitate audio/video calls and conferences by providing implementation of TURN and STUN protocols. WebRTC, SIP and other communication servers can use it to establish a call between parties who are otherwise unable connect with each other. Enable this if you are running the communications server and wish to perform audio/video calls.</description>
|
||||
<port protocol="tcp" port="3478"/>
|
||||
<port protocol="udp" port="3478"/>
|
||||
<port protocol="tcp" port="3479"/>
|
||||
<port protocol="udp" port="3479"/>
|
||||
<port protocol="tcp" port="5349"/>
|
||||
<port protocol="udp" port="5349"/>
|
||||
<port protocol="tcp" port="5350"/>
|
||||
<port protocol="udp" port="5350"/>
|
||||
<port protocol="tcp" port="49152-50175"/>
|
||||
<port protocol="udp" port="49152-50175"/>
|
||||
</service>
|
||||
27
plinth/modules/coturn/forms.py
Normal file
27
plinth/modules/coturn/forms.py
Normal file
@ -0,0 +1,27 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
"""
|
||||
Forms for Coturn app.
|
||||
"""
|
||||
|
||||
from django import forms
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from plinth.modules import coturn
|
||||
|
||||
|
||||
def get_domain_choices():
|
||||
"""Double domain entries for inclusion in the choice field."""
|
||||
return ((domain, domain) for domain in coturn.get_available_domains())
|
||||
|
||||
|
||||
class CoturnForm(forms.Form):
|
||||
"""Form to select a TLS domain for Coturn."""
|
||||
|
||||
domain = forms.ChoiceField(
|
||||
choices=get_domain_choices,
|
||||
label=_('TLS domain'),
|
||||
help_text=_(
|
||||
'Select a domain to use TLS with. If the list is empty, please '
|
||||
'configure at least one domain with certificates.'),
|
||||
required=False,
|
||||
)
|
||||
10
plinth/modules/coturn/manifest.py
Normal file
10
plinth/modules/coturn/manifest.py
Normal file
@ -0,0 +1,10 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
from plinth.modules.backups.api import validate as validate_backup
|
||||
|
||||
backup = validate_backup({
|
||||
'secrets': {
|
||||
'directories': ['/etc/coturn']
|
||||
},
|
||||
'services': ['coturn']
|
||||
})
|
||||
32
plinth/modules/coturn/templates/coturn.html
Normal file
32
plinth/modules/coturn/templates/coturn.html
Normal file
@ -0,0 +1,32 @@
|
||||
{% extends "app.html" %}
|
||||
{% comment %}
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
{% endcomment %}
|
||||
|
||||
{% load bootstrap %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block status %}
|
||||
{{ block.super }}
|
||||
|
||||
<h3>Status</h3>
|
||||
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
Use the following URLs to configure your communication server:
|
||||
{% endblocktrans %}
|
||||
|
||||
<pre>stun:{{ config.realm }}:3478?transport=udp
|
||||
stun:{{ config.realm }}:3478?transport=tcp
|
||||
turn:{{ config.realm }}:3478?transport=udp
|
||||
turn:{{ config.realm }}:3478?transport=tcp</pre>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
Use the following shared authentication secret:
|
||||
{% endblocktrans %}
|
||||
|
||||
<pre>{{ config.static_auth_secret }}</pre>
|
||||
</p>
|
||||
{% endblock %}
|
||||
0
plinth/modules/coturn/tests/__init__.py
Normal file
0
plinth/modules/coturn/tests/__init__.py
Normal file
12
plinth/modules/coturn/urls.py
Normal file
12
plinth/modules/coturn/urls.py
Normal file
@ -0,0 +1,12 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
"""
|
||||
URLs for the Coturn module.
|
||||
"""
|
||||
|
||||
from django.conf.urls import url
|
||||
|
||||
from .views import CoturnAppView
|
||||
|
||||
urlpatterns = [
|
||||
url(r'^apps/coturn/$', CoturnAppView.as_view(), name='index'),
|
||||
]
|
||||
42
plinth/modules/coturn/views.py
Normal file
42
plinth/modules/coturn/views.py
Normal file
@ -0,0 +1,42 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
"""
|
||||
Views for Coturn app.
|
||||
"""
|
||||
|
||||
from django.contrib import messages
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
import plinth.modules.coturn as coturn
|
||||
from plinth import views
|
||||
|
||||
from . import forms
|
||||
|
||||
|
||||
class CoturnAppView(views.AppView):
|
||||
"""Serve configuration page."""
|
||||
app_id = 'coturn'
|
||||
template_name = 'coturn.html'
|
||||
form_class = forms.CoturnForm
|
||||
port_forwarding_info = coturn.port_forwarding_info
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""Return additional context for rendering the template."""
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['config'] = coturn.get_config()
|
||||
return context
|
||||
|
||||
def get_initial(self):
|
||||
"""Return the values to fill in the form."""
|
||||
initial = super().get_initial()
|
||||
initial['domain'] = coturn.get_domain()
|
||||
return initial
|
||||
|
||||
def form_valid(self, form):
|
||||
"""Change the domain of Coturn service."""
|
||||
data = form.cleaned_data
|
||||
if coturn.get_domain() != data['domain']:
|
||||
coturn.set_domain(data['domain'])
|
||||
coturn.app.get_component('letsencrypt-coturn').setup_certificates()
|
||||
messages.success(self.request, _('Configuration updated'))
|
||||
|
||||
return super().form_valid(form)
|
||||
BIN
static/themes/default/icons/coturn.png
Normal file
BIN
static/themes/default/icons/coturn.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
67
static/themes/default/icons/coturn.svg
Normal file
67
static/themes/default/icons/coturn.svg
Normal file
@ -0,0 +1,67 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
viewBox="0 0 512 512"
|
||||
x="0px"
|
||||
y="0px"
|
||||
version="1.1"
|
||||
id="svg12"
|
||||
sodipodi:docname="coturn.svg"
|
||||
width="512"
|
||||
height="512"
|
||||
inkscape:version="0.92.4 (5da689c313, 2019-01-14)"
|
||||
inkscape:export-filename="/home/bunny/work/freedombox/plinth/static/themes/default/icons/coturn.png"
|
||||
inkscape:export-xdpi="48"
|
||||
inkscape:export-ydpi="48">
|
||||
<metadata
|
||||
id="metadata18">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title>Communication, video, call, camera, talk, record, media</dc:title>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<defs
|
||||
id="defs16" />
|
||||
<sodipodi:namedview
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1"
|
||||
objecttolerance="10"
|
||||
gridtolerance="10"
|
||||
guidetolerance="10"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:window-width="1782"
|
||||
inkscape:window-height="1122"
|
||||
id="namedview14"
|
||||
showgrid="false"
|
||||
inkscape:zoom="0.73750001"
|
||||
inkscape:cx="23.98607"
|
||||
inkscape:cy="48.884982"
|
||||
inkscape:window-x="1233"
|
||||
inkscape:window-y="454"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:current-layer="svg12" />
|
||||
<title
|
||||
id="title2">Communication, video, call, camera, talk, record, media</title>
|
||||
<g
|
||||
data-name="Video Call"
|
||||
id="g6"
|
||||
transform="matrix(10.24,0,0,10.24,-71.68,-71.68)">
|
||||
<path
|
||||
d="m 36.1963,35.02 -5,-1 a 0.9967,0.9967 0 0 0 -1.1025,0.5576 l -2.1484,4.6061 a 19.09,19.09 0 0 1 -9.1286,-9.1286 l 4.6061,-2.1483 a 1.0008,1.0008 0 0 0 0.5576,-1.1026 l -1,-5 A 1,1 0 0 0 22,21 h -6 a 1,1 0 0 0 -1,1 21.0508,21.0508 0 0 0 21,21 1,1 0 0 0 1,-1 V 36 A 1,1 0 0 0 36.1963,35.02 Z M 21.8652,26.4258 18.06,28.2009 A 16.9646,16.9646 0 0 1 17.0507,23 h 4.13 z m 9.709,9.709 L 35,36.82 v 4.13 A 17.4236,17.4236 0 0 1 29.8,39.9381 Z M 56.5254,20.1494 A 0.9978,0.9978 0 0 0 55.5527,20.1055 L 45,25.3818 V 19 A 3.0033,3.0033 0 0 0 42,16 H 10 a 3.0033,3.0033 0 0 0 -3,3 v 26 a 3.0033,3.0033 0 0 0 3,3 h 32 a 3.0033,3.0033 0 0 0 3,-3 v -6.3818 l 10.5527,5.2763 A 1,1 0 0 0 57,43 V 21 A 1,1 0 0 0 56.5254,20.1494 Z M 43,45 a 1.0009,1.0009 0 0 1 -1,1 H 10 A 1.0013,1.0013 0 0 1 9,45 V 19 a 1.0013,1.0013 0 0 1 1,-1 h 32 a 1.0009,1.0009 0 0 1 1,1 z m 12,-3.6182 -10,-5 v -8.7636 l 10,-5 z"
|
||||
id="path4"
|
||||
inkscape:connector-curvature="0" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
Loading…
x
Reference in New Issue
Block a user