mirror of
https://github.com/freedombox/FreedomBox.git
synced 2026-05-20 10:34:30 +00:00
monkeysphere: Drop app as it is not being used
Closes #2157. Signed-off-by: Joseph Nuthalapati <njoseph@riseup.net> [sunil: Split diaspora and tahoe-lafs into separate commits] [sunil: Remove monkeysphere from help/tests/test_views.py] [sunil: Add to configuration file removal in Debian package and setup.py] Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org> Reviewed-by: Sunil Mohan Adapa <sunil@medhas.org>
This commit is contained in:
parent
f72505d300
commit
ce5274d9ee
@ -1,318 +0,0 @@
|
|||||||
#!/usr/bin/python3
|
|
||||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
"""
|
|
||||||
Configuration helper for monkeysphere.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import contextlib
|
|
||||||
import copy
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import shutil
|
|
||||||
import signal
|
|
||||||
import subprocess
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
import augeas
|
|
||||||
import psutil
|
|
||||||
|
|
||||||
|
|
||||||
def parse_arguments():
|
|
||||||
"""Return parsed command line arguments as dictionary."""
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
subparsers = parser.add_subparsers(dest='subcommand', help='Sub command')
|
|
||||||
|
|
||||||
host_show_keys = subparsers.add_parser(
|
|
||||||
'host-show-keys', help='Show imported/importable keys')
|
|
||||||
host_show_keys.add_argument('key_id', nargs='?',
|
|
||||||
help='Optional KEYID to retrieve details for')
|
|
||||||
|
|
||||||
host_import_key = subparsers.add_parser(
|
|
||||||
'host-import-key', help='Import a key into monkeysphere')
|
|
||||||
host_import_key.add_argument('ssh_fingerprint',
|
|
||||||
help='SSH fingerprint of the key to import')
|
|
||||||
host_import_key.add_argument('domains', nargs='*',
|
|
||||||
help='List of available domains')
|
|
||||||
|
|
||||||
host_publish_key = subparsers.add_parser('host-publish-key',
|
|
||||||
help='Push host key to keyserver')
|
|
||||||
host_publish_key.add_argument('key_ids', nargs='*',
|
|
||||||
help='Optional list of KEYIDs')
|
|
||||||
|
|
||||||
host_cancel_publish = subparsers.add_parser(
|
|
||||||
'host-cancel-publish', help='Cancel a running publish operation')
|
|
||||||
host_cancel_publish.add_argument('pid', help='PID of the publish process')
|
|
||||||
|
|
||||||
subparsers.required = True
|
|
||||||
return parser.parse_args()
|
|
||||||
|
|
||||||
|
|
||||||
def get_ssh_keys(fingerprint_hash):
|
|
||||||
"""Return all SSH keys."""
|
|
||||||
keys = {}
|
|
||||||
|
|
||||||
key_files = ['/etc/ssh/ssh_host_rsa_key']
|
|
||||||
for key_file in key_files:
|
|
||||||
output = subprocess.check_output(
|
|
||||||
['ssh-keygen', '-l', '-E', fingerprint_hash, '-f', key_file])
|
|
||||||
fingerprint = output.decode().split()[1]
|
|
||||||
keys[fingerprint] = {
|
|
||||||
'ssh_fingerprint': fingerprint,
|
|
||||||
'service': 'ssh',
|
|
||||||
'key_file': key_file,
|
|
||||||
'available_domains': ['*']
|
|
||||||
}
|
|
||||||
|
|
||||||
return keys
|
|
||||||
|
|
||||||
|
|
||||||
def get_pem_ssh_fingerprint(pem_file, fingerprint_hash):
|
|
||||||
"""Return the SSH fingerprint of a PEM file."""
|
|
||||||
public_key = subprocess.check_output(
|
|
||||||
['openssl', 'rsa', '-in', pem_file, '-pubout'],
|
|
||||||
stderr=subprocess.DEVNULL)
|
|
||||||
ssh_public_key = subprocess.check_output(
|
|
||||||
['ssh-keygen', '-i', '-m', 'PKCS8', '-f', '/dev/stdin'],
|
|
||||||
input=public_key)
|
|
||||||
fingerprint = subprocess.check_output(
|
|
||||||
['ssh-keygen', '-l', '-E', fingerprint_hash, '-f', '/dev/stdin'],
|
|
||||||
input=ssh_public_key)
|
|
||||||
|
|
||||||
return fingerprint.decode().split()[1]
|
|
||||||
|
|
||||||
|
|
||||||
def get_https_keys(fingerprint_hash):
|
|
||||||
"""Return all HTTPS keys."""
|
|
||||||
aug = augeas.Augeas(flags=augeas.Augeas.NO_LOAD +
|
|
||||||
augeas.Augeas.NO_MODL_AUTOLOAD)
|
|
||||||
aug.set('/augeas/load/Httpd/lens', 'Httpd.lns')
|
|
||||||
aug.set('/augeas/load/Httpd/incl[last() + 1]',
|
|
||||||
'/etc/apache2/sites-available/*')
|
|
||||||
aug.set('/augeas/load/Httpd/incl[last() + 1]',
|
|
||||||
'/etc/apache2/conf-available/*')
|
|
||||||
aug.load()
|
|
||||||
|
|
||||||
# Read from default-tls.conf and default-ssl.conf
|
|
||||||
keys = {}
|
|
||||||
path = '/files/etc/apache2/sites-available//VirtualHost'
|
|
||||||
for match in aug.match(path):
|
|
||||||
host = {'available_domains': ['*'], 'service': 'https'}
|
|
||||||
for directive in aug.match(match + '/directive'):
|
|
||||||
name = aug.get(directive)
|
|
||||||
if name == 'ServerName':
|
|
||||||
host['available_domains'] = [aug.get(directive + '/arg')]
|
|
||||||
elif name in ('GnuTLSKeyFile', 'SSLCertificateKeyFile'):
|
|
||||||
host['key_file'] = aug.get(directive + '/arg')
|
|
||||||
|
|
||||||
if 'key_file' in host:
|
|
||||||
host['ssh_fingerprint'] = get_pem_ssh_fingerprint(
|
|
||||||
host['key_file'], fingerprint_hash)
|
|
||||||
keys[host['ssh_fingerprint']] = host
|
|
||||||
|
|
||||||
# Read from FreedomBox configured domains with proper SSL certs.
|
|
||||||
path = "/files/etc/apache2/sites-available//" \
|
|
||||||
"directive[. = 'Use'][arg[1] = 'FreedomBoxTLSSiteMacro']"
|
|
||||||
key_file = ("/files/etc/apache2//Macro[arg[1] = 'FreedomBoxTLSSiteMacro']"
|
|
||||||
"//VirtualHost/directive[. = 'GnuTLSKeyFile']/arg")
|
|
||||||
key_file = aug.get(key_file)
|
|
||||||
for match in aug.match(path):
|
|
||||||
domain = aug.get(match + '/arg[2]')
|
|
||||||
host = {
|
|
||||||
'available_domains': [domain],
|
|
||||||
'service': 'https',
|
|
||||||
'key_file': key_file.replace('$domain', domain)
|
|
||||||
}
|
|
||||||
host['ssh_fingerprint'] = get_pem_ssh_fingerprint(
|
|
||||||
host['key_file'], fingerprint_hash)
|
|
||||||
keys[host['ssh_fingerprint']] = host
|
|
||||||
|
|
||||||
return keys
|
|
||||||
|
|
||||||
|
|
||||||
def get_monkeysphere_keys(key_id=None):
|
|
||||||
"""Return the list of keys imported into monkeysphere."""
|
|
||||||
try:
|
|
||||||
key_ids = [] if not key_id else [key_id]
|
|
||||||
output = subprocess.check_output(['monkeysphere-host', 'show-keys'] +
|
|
||||||
key_ids, stderr=subprocess.DEVNULL)
|
|
||||||
except subprocess.CalledProcessError:
|
|
||||||
# no keys available
|
|
||||||
return {}
|
|
||||||
|
|
||||||
# parse output
|
|
||||||
default_dict = {'imported_domains': [], 'available_domains': []}
|
|
||||||
keys = [copy.deepcopy(default_dict)]
|
|
||||||
lines = output.decode().strip().split('\n')
|
|
||||||
for line in lines:
|
|
||||||
if line.startswith('pub'):
|
|
||||||
data = line.lstrip('pub').split()
|
|
||||||
keys[-1]['pub'] = data[0]
|
|
||||||
keys[-1]['date'] = data[1]
|
|
||||||
elif line.startswith('uid'):
|
|
||||||
uid = line.lstrip('uid').strip()
|
|
||||||
if uid.startswith('['):
|
|
||||||
uid = uid[uid.index(']') + 1:].strip()
|
|
||||||
|
|
||||||
keys[-1].setdefault('uids', []).append(uid)
|
|
||||||
matches = re.match(r'([a-zA-Z]*)://(.*)(:\d*)?', uid)
|
|
||||||
keys[-1]['service'] = matches.group(1)
|
|
||||||
keys[-1]['imported_domains'].append(matches.group(2))
|
|
||||||
elif line.startswith('OpenPGP fingerprint:'):
|
|
||||||
keys[-1]['openpgp_fingerprint'] = \
|
|
||||||
line.lstrip('OpenPGP fingerprint:')
|
|
||||||
elif line.startswith('ssh fingerprint:'):
|
|
||||||
data = line.lstrip('ssh fingerprint:').split()
|
|
||||||
keys[-1]['ssh_key_size'] = data[0]
|
|
||||||
keys[-1]['ssh_fingerprint'] = data[1]
|
|
||||||
keys[-1]['ssh_key_type'] = data[2].strip('()')
|
|
||||||
elif line == '':
|
|
||||||
keys.append(copy.deepcopy(default_dict))
|
|
||||||
|
|
||||||
return {key['ssh_fingerprint']: key for key in keys}
|
|
||||||
|
|
||||||
|
|
||||||
def get_merged_keys(key_id=None):
|
|
||||||
"""Return merged list of system and monkeysphere keys."""
|
|
||||||
keys = get_monkeysphere_keys(key_id)
|
|
||||||
|
|
||||||
# Monkeysphere used use MD5 for fingerprint hash and recently
|
|
||||||
# changed to SHA256. In case of SHA256 the string 'SHA256:' is
|
|
||||||
# being prepended to the fingerprint. Hoping that such a prefix
|
|
||||||
# will be available in all future changes, extract it from one key
|
|
||||||
# (assuming all the others will be the same) and use it.
|
|
||||||
fingerprint_hash = 'SHA256'
|
|
||||||
if keys:
|
|
||||||
fingerprint_hash = list(keys.keys())[0].split(':')[0]
|
|
||||||
|
|
||||||
system_keys = list(get_ssh_keys(fingerprint_hash).items()) + \
|
|
||||||
list(get_https_keys(fingerprint_hash).items())
|
|
||||||
for ssh_fingerprint, key in system_keys:
|
|
||||||
if key_id and ssh_fingerprint not in keys:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if ssh_fingerprint in keys:
|
|
||||||
keys[ssh_fingerprint].update({
|
|
||||||
'available_domains': key['available_domains'],
|
|
||||||
'key_file': key['key_file']
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
keys[ssh_fingerprint] = key
|
|
||||||
|
|
||||||
return keys
|
|
||||||
|
|
||||||
|
|
||||||
@contextlib.contextmanager
|
|
||||||
def _get_ssh_key_file_for_import(original_key_file, service):
|
|
||||||
"""Return an SSH key file that can be imported into monkeysphere.
|
|
||||||
|
|
||||||
If the key file is in PEM format, the key file can be used as it is.
|
|
||||||
Otherwise, if the file is in the OpenSSH key format, which is default since
|
|
||||||
7.8, then convert it to PEM format.
|
|
||||||
|
|
||||||
"""
|
|
||||||
if service != 'ssh':
|
|
||||||
yield original_key_file
|
|
||||||
return
|
|
||||||
|
|
||||||
first_line = open(original_key_file, 'r').readline()
|
|
||||||
if '--BEGIN OPENSSH PRIVATE KEY--' not in first_line:
|
|
||||||
yield original_key_file
|
|
||||||
return
|
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as temp_directory:
|
|
||||||
key_file = os.path.join(temp_directory, 'ssh_key_file')
|
|
||||||
shutil.copy2(original_key_file, key_file)
|
|
||||||
# Convert OpenSSH format to PEM
|
|
||||||
subprocess.run(
|
|
||||||
['ssh-keygen', '-p', '-N', '', '-m', 'PEM', '-f', key_file],
|
|
||||||
check=True)
|
|
||||||
yield key_file
|
|
||||||
|
|
||||||
|
|
||||||
def subcommand_host_show_keys(arguments):
|
|
||||||
"""Show host key fingerprints."""
|
|
||||||
print(json.dumps({'keys': get_merged_keys(arguments.key_id)}))
|
|
||||||
|
|
||||||
|
|
||||||
def subcommand_host_import_key(arguments, second_run=False):
|
|
||||||
"""Import host SSH key."""
|
|
||||||
keys = get_merged_keys()
|
|
||||||
if arguments.ssh_fingerprint not in keys:
|
|
||||||
raise Exception('Unknown SSH fingerprint')
|
|
||||||
|
|
||||||
key = keys[arguments.ssh_fingerprint]
|
|
||||||
if '*' in key['available_domains']:
|
|
||||||
key['available_domains'] = arguments.domains
|
|
||||||
|
|
||||||
if 'openpgp_fingerprint' not in key and not second_run:
|
|
||||||
env = dict(os.environ, MONKEYSPHERE_PROMPT='false')
|
|
||||||
with _get_ssh_key_file_for_import(key['key_file'],
|
|
||||||
key['service']) as key_file:
|
|
||||||
subprocess.check_call([
|
|
||||||
'monkeysphere-host', 'import-key', key_file,
|
|
||||||
key['service'] + '://' + key['available_domains'][0]
|
|
||||||
], env=env)
|
|
||||||
|
|
||||||
subcommand_host_import_key(arguments, second_run=True)
|
|
||||||
else:
|
|
||||||
for domain in key['available_domains']:
|
|
||||||
if domain in key['imported_domains']:
|
|
||||||
continue
|
|
||||||
|
|
||||||
env = dict(os.environ, MONKEYSPHERE_PROMPT='false')
|
|
||||||
subprocess.check_call([
|
|
||||||
'monkeysphere-host', 'add-servicename',
|
|
||||||
key['service'] + '://' + domain, key['openpgp_fingerprint']
|
|
||||||
], env=env)
|
|
||||||
|
|
||||||
|
|
||||||
def subcommand_host_publish_key(arguments):
|
|
||||||
"""Push host key to keyserver."""
|
|
||||||
# setting TMPDIR as workaround for Debian bug #656750
|
|
||||||
proc = subprocess.Popen(
|
|
||||||
['monkeysphere-host', 'publish-keys'] + arguments.key_ids,
|
|
||||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
||||||
env=dict(os.environ,
|
|
||||||
TMPDIR='/var/lib/monkeysphere/authentication/tmp/',
|
|
||||||
MONKEYSPHERE_PROMPT='false'))
|
|
||||||
output, error = proc.communicate()
|
|
||||||
output, error = output.decode(), error.decode()
|
|
||||||
if proc.returncode != 0:
|
|
||||||
raise Exception(output, error)
|
|
||||||
|
|
||||||
print(output)
|
|
||||||
|
|
||||||
|
|
||||||
def subcommand_host_cancel_publish(arguments):
|
|
||||||
"""Kill a running publish process."""
|
|
||||||
process = psutil.Process(int(arguments.pid))
|
|
||||||
|
|
||||||
# Perform tight checks on the process before killing for security.
|
|
||||||
arguments = process.cmdline()
|
|
||||||
if not arguments:
|
|
||||||
# Process already completed
|
|
||||||
return
|
|
||||||
|
|
||||||
# Remove the sudo prefix if present
|
|
||||||
while arguments[0] == 'sudo' or arguments[0].startswith('-'):
|
|
||||||
arguments = arguments[1:]
|
|
||||||
|
|
||||||
if len(arguments) >= 2 and \
|
|
||||||
arguments[0].split('/')[-1] == 'monkeysphere' and \
|
|
||||||
arguments[1] == 'host-publish-key':
|
|
||||||
process.send_signal(signal.SIGTERM)
|
|
||||||
|
|
||||||
|
|
||||||
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()
|
|
||||||
1
debian/freedombox.maintscript
vendored
1
debian/freedombox.maintscript
vendored
@ -13,3 +13,4 @@ rm_conffile /etc/apt/preferences.d/50freedombox3.pref 20.5~
|
|||||||
rm_conffile /etc/plinth/plinth.config 20.12~
|
rm_conffile /etc/plinth/plinth.config 20.12~
|
||||||
rm_conffile /etc/plinth/custom-shortcuts.json 20.12~
|
rm_conffile /etc/plinth/custom-shortcuts.json 20.12~
|
||||||
rm_conffile /etc/plinth/modules-enabled/coquelicot 20.14~
|
rm_conffile /etc/plinth/modules-enabled/coquelicot 20.14~
|
||||||
|
rm_conffile /etc/plinth/modules-enabled/monkeysphere 21.16~
|
||||||
|
|||||||
@ -109,17 +109,16 @@ MANUAL_PAGES = ('Apache_userdir', 'APU', 'Backups', 'BananaPro', 'BeagleBone',
|
|||||||
'freedombox-manual', 'GettingHelp', 'GitWeb', 'Hardware',
|
'freedombox-manual', 'GettingHelp', 'GitWeb', 'Hardware',
|
||||||
'I2P', 'Ikiwiki', 'Infinoted', 'Introduction', 'JSXC',
|
'I2P', 'Ikiwiki', 'Infinoted', 'Introduction', 'JSXC',
|
||||||
'LetsEncrypt', 'Maker', 'MatrixSynapse', 'MediaWiki',
|
'LetsEncrypt', 'Maker', 'MatrixSynapse', 'MediaWiki',
|
||||||
'Minetest', 'MiniDLNA', 'MLDonkey', 'Monkeysphere', 'Mumble',
|
'Minetest', 'MiniDLNA', 'MLDonkey', 'Mumble', 'NameServices',
|
||||||
'NameServices', 'Networks', 'OpenVPN', 'OrangePiZero',
|
'Networks', 'OpenVPN', 'OrangePiZero', 'PageKite', 'pcDuino3',
|
||||||
'PageKite', 'pcDuino3', 'Performance', 'PineA64+',
|
'Performance', 'PineA64+', 'PioneerEdition', 'Plinth', 'Power',
|
||||||
'PioneerEdition', 'Plinth', 'Power', 'Privoxy', 'Quassel',
|
'Privoxy', 'Quassel', 'QuickStart', 'Radicale', 'RaspberryPi2',
|
||||||
'QuickStart', 'Radicale', 'RaspberryPi2', 'RaspberryPi3B+',
|
'RaspberryPi3B+', 'RaspberryPi3B', 'RaspberryPi4B',
|
||||||
'RaspberryPi3B', 'RaspberryPi4B', 'ReleaseNotes', 'Rock64',
|
'ReleaseNotes', 'Rock64', 'RockPro64', 'Roundcube', 'Samba',
|
||||||
'RockPro64', 'Roundcube', 'Samba', 'Searx', 'SecureShell',
|
'Searx', 'SecureShell', 'Security', 'ServiceDiscovery',
|
||||||
'Security', 'ServiceDiscovery', 'Shadowsocks', 'Sharing',
|
'Shadowsocks', 'Sharing', 'Snapshots', 'Storage', 'Syncthing',
|
||||||
'Snapshots', 'Storage', 'Syncthing', 'TinyTinyRSS', 'Tor',
|
'TinyTinyRSS', 'Tor', 'Transmission', 'Upgrades', 'USBWiFi',
|
||||||
'Transmission', 'Upgrades', 'USBWiFi', 'Users', 'VirtualBox',
|
'Users', 'VirtualBox', 'WireGuard')
|
||||||
'WireGuard')
|
|
||||||
_restricted_reason = ('Needs installed manual. '
|
_restricted_reason = ('Needs installed manual. '
|
||||||
'CI speed-optimized workspace does not provide it.')
|
'CI speed-optimized workspace does not provide it.')
|
||||||
not_restricted_environment = pytest.mark.skipif(not canary.exists(),
|
not_restricted_environment = pytest.mark.skipif(not canary.exists(),
|
||||||
|
|||||||
@ -1,74 +0,0 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
"""
|
|
||||||
FreedomBox app for monkeysphere.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
|
||||||
|
|
||||||
from plinth import app as app_module
|
|
||||||
from plinth import menu
|
|
||||||
from plinth.modules.backups.components import BackupRestore
|
|
||||||
from plinth.modules.users.components import UsersAndGroups
|
|
||||||
from plinth.package import Packages
|
|
||||||
|
|
||||||
from . import manifest
|
|
||||||
|
|
||||||
_description = [
|
|
||||||
_('With Monkeysphere, an OpenPGP key can be generated for each configured '
|
|
||||||
'domain serving SSH. The OpenPGP public key can then be uploaded to the '
|
|
||||||
'OpenPGP keyservers. Users connecting to this machine through SSH can '
|
|
||||||
'verify that they are connecting to the correct host. For users to '
|
|
||||||
'trust the key, at least one person (usually the machine owner) must '
|
|
||||||
'sign the key using the regular OpenPGP key signing process. See the '
|
|
||||||
'<a href="http://web.monkeysphere.info/getting-started-ssh/"> '
|
|
||||||
'Monkeysphere SSH documentation</a> for more details.'),
|
|
||||||
_('Monkeysphere can also generate an OpenPGP key for each Secure Web '
|
|
||||||
'Server (HTTPS) certificate installed on this machine. The OpenPGP '
|
|
||||||
'public key can then be uploaded to the OpenPGP keyservers. Users '
|
|
||||||
'accessing the web server through HTTPS can verify that they are '
|
|
||||||
'connecting to the correct host. To validate the certificate, the user '
|
|
||||||
'will need to install some software that is available on the '
|
|
||||||
'<a href="https://web.monkeysphere.info/download/"> Monkeysphere '
|
|
||||||
'website</a>.')
|
|
||||||
]
|
|
||||||
|
|
||||||
app = None
|
|
||||||
|
|
||||||
|
|
||||||
class MonkeysphereApp(app_module.App):
|
|
||||||
"""FreedomBox app for Monkeysphere."""
|
|
||||||
|
|
||||||
app_id = 'monkeysphere'
|
|
||||||
|
|
||||||
_version = 1
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
"""Create components for the app."""
|
|
||||||
super().__init__()
|
|
||||||
|
|
||||||
info = app_module.Info(app_id=self.app_id, version=self._version,
|
|
||||||
name=_('Monkeysphere'), icon='fa-certificate',
|
|
||||||
description=_description,
|
|
||||||
manual_page='Monkeysphere')
|
|
||||||
self.add(info)
|
|
||||||
|
|
||||||
menu_item = menu.Menu('menu-monkeysphere', info.name, None, info.icon,
|
|
||||||
'monkeysphere:index', parent_url_name='system',
|
|
||||||
advanced=True)
|
|
||||||
self.add(menu_item)
|
|
||||||
|
|
||||||
packages = Packages('packages-monkeysphere', ['monkeysphere'])
|
|
||||||
self.add(packages)
|
|
||||||
|
|
||||||
users_and_groups = UsersAndGroups('users-and-groups-monkeysphere',
|
|
||||||
reserved_usernames=['monkeysphere'])
|
|
||||||
self.add(users_and_groups)
|
|
||||||
|
|
||||||
backup_restore = BackupRestore('backup-restore-monkeysphere',
|
|
||||||
**manifest.backup)
|
|
||||||
self.add(backup_restore)
|
|
||||||
|
|
||||||
|
|
||||||
def setup(helper, old_version=None):
|
|
||||||
"""Install and configure the module."""
|
|
||||||
app.setup(old_version)
|
|
||||||
@ -1 +0,0 @@
|
|||||||
plinth.modules.monkeysphere
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
"""
|
|
||||||
Application manfiest for monkeysphere.
|
|
||||||
"""
|
|
||||||
|
|
||||||
backup = {
|
|
||||||
'config': {
|
|
||||||
'directories': ['/etc/monkeysphere/']
|
|
||||||
},
|
|
||||||
'secrets': {
|
|
||||||
'directories': ['/var/lib/monkeysphere/']
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
/*
|
|
||||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
*/
|
|
||||||
|
|
||||||
td li {
|
|
||||||
list-style: none;
|
|
||||||
line-height: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
td ul {
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-action.pull-right {
|
|
||||||
margin-right: 0.625rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-action button {
|
|
||||||
width: 7.875rem;
|
|
||||||
}
|
|
||||||
@ -1,136 +0,0 @@
|
|||||||
{% extends "app.html" %}
|
|
||||||
{% comment %}
|
|
||||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
{% endcomment %}
|
|
||||||
|
|
||||||
{% load bootstrap %}
|
|
||||||
{% load i18n %}
|
|
||||||
{% load static %}
|
|
||||||
|
|
||||||
{% block page_head %}
|
|
||||||
<link type="text/css" rel="stylesheet"
|
|
||||||
href="{% static 'monkeysphere/monkeysphere.css' %}"/>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block configuration %}
|
|
||||||
|
|
||||||
{% if running %}
|
|
||||||
<p class="running-status-parent">
|
|
||||||
<span class="running-status loading"></span>
|
|
||||||
{% trans "Publishing key to keyserver..." %}
|
|
||||||
|
|
||||||
<form class="form" method="post"
|
|
||||||
action="{% url 'monkeysphere:cancel' %}">
|
|
||||||
{% csrf_token %}
|
|
||||||
|
|
||||||
<button type="submit" class="btn btn-warning btn-sm">
|
|
||||||
{% trans "Cancel" %}</button>
|
|
||||||
</form>
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>{% trans "Service" %}</th>
|
|
||||||
<th>{% trans "Domains" %}</th>
|
|
||||||
<th>{% trans "OpenPGP Fingerprint" %}</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for key in status.keys.values|dictsort:"ssh_fingerprint" %}
|
|
||||||
<tr class="monkeysphere-service-{{ key.service }}">
|
|
||||||
<td>
|
|
||||||
{% if key.service == 'ssh' %}
|
|
||||||
{% trans "Secure Shell" %}
|
|
||||||
{% elif key.service == 'https' %}
|
|
||||||
{% trans "Web Server" %}
|
|
||||||
{% else %}
|
|
||||||
{% trans "Other" %}
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<ul>
|
|
||||||
{% for domain in key.all_domains %}
|
|
||||||
{% if domain not in key.imported_domains %}
|
|
||||||
<li>
|
|
||||||
<span class="badge badge-secondary"
|
|
||||||
><span class="fa fa-times"
|
|
||||||
aria-hidden="true"></span></span>
|
|
||||||
<span class="monkeysphere-importable-domain"
|
|
||||||
>{{ domain }}</span>
|
|
||||||
</li>
|
|
||||||
{% elif domain not in key.available_domains %}
|
|
||||||
<li>
|
|
||||||
<span class="badge badge-warning"
|
|
||||||
><span class="fa fa-exclamation-triangle"
|
|
||||||
aria-hidden="true"></span></span>
|
|
||||||
<span class="monkeysphere-unavailable-domain"
|
|
||||||
>{{ domain }}</span>
|
|
||||||
</li>
|
|
||||||
{% else %}
|
|
||||||
<li>
|
|
||||||
<span class="badge badge-success"
|
|
||||||
><span class="fa fa-check"
|
|
||||||
aria-hidden="true"></span></span>
|
|
||||||
<span class="monkeysphere-imported-domain"
|
|
||||||
>{{ domain }}</span>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{% if key.openpgp_fingerprint %}
|
|
||||||
<a href="{% url 'monkeysphere:details' key.openpgp_fingerprint %}"
|
|
||||||
title="{% blocktrans trimmed with fingerprint=key.openpgp_fingerprint %}
|
|
||||||
Show details for key {{ fingerprint }}
|
|
||||||
{% endblocktrans %}">
|
|
||||||
{{ key.openpgp_fingerprint }}
|
|
||||||
</a>
|
|
||||||
{% else %}
|
|
||||||
{% trans "-" %}
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{% if not key.openpgp_fingerprint %}
|
|
||||||
<form class="form pull-right form-action form-import"
|
|
||||||
method="post"
|
|
||||||
action="{% url 'monkeysphere:import' key.ssh_fingerprint %}">
|
|
||||||
{% csrf_token %}
|
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary btn-sm pull-right button-import">
|
|
||||||
{% trans "Import Key" %}</button>
|
|
||||||
</form>
|
|
||||||
{% else %}
|
|
||||||
{% if not running %}
|
|
||||||
<form class="form pull-right form-action form-publish"
|
|
||||||
method="post"
|
|
||||||
action="{% url 'monkeysphere:publish' key.openpgp_fingerprint %}">
|
|
||||||
{% csrf_token %}
|
|
||||||
|
|
||||||
<button type="submit" class="btn btn-warning btn-sm pull-right button-publish">
|
|
||||||
{% trans "Publish Key" %}</button>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
{% if key.importable_domains %}
|
|
||||||
<form class="form pull-right form-action form-add-domain"
|
|
||||||
method="post"
|
|
||||||
action="{% url 'monkeysphere:import' key.ssh_fingerprint %}">
|
|
||||||
{% csrf_token %}
|
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary btn-sm pull-right button-add-domains">
|
|
||||||
{% trans "Add Domains" %}</button>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
@ -1,85 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% comment %}
|
|
||||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
{% endcomment %}
|
|
||||||
|
|
||||||
{% load bootstrap %}
|
|
||||||
{% load i18n %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
|
|
||||||
<h2>{% trans "Monkeysphere" %}</h2>
|
|
||||||
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table">
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td>{% trans "OpenPGP Fingerprint" %}</td>
|
|
||||||
<td>{{ key.openpgp_fingerprint }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>{% trans "OpenPGP User IDs" %}</td>
|
|
||||||
<td>{{ key.uids|join:', ' }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>{% trans "Key Import Date" %}</td>
|
|
||||||
<td>{{ key.date }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>{% trans "SSH Key Type" %}</td>
|
|
||||||
<td>{{ key.ssh_key_type }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>{% trans "SSH Key Size" %}</td>
|
|
||||||
<td>{{ key.ssh_key_size }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>{% trans "SSH Fingerprint" %}</td>
|
|
||||||
<td>{{ key.ssh_fingerprint }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>{% trans "Service" %}</td>
|
|
||||||
<td>
|
|
||||||
{% if key.service == 'ssh' %}
|
|
||||||
{% trans "Secure Shell" %}
|
|
||||||
{% elif key.service == 'https' %}
|
|
||||||
{% trans "Web Server" %}
|
|
||||||
{% else %}
|
|
||||||
{% trans "Other" %}
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>{% trans "Key File" %}</td>
|
|
||||||
<td>{{ key.key_file }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>{% trans "Available Domains" %}</td>
|
|
||||||
<td>{{ key.available_domains|join:', ' }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>{% trans "Added Domains" %}</td>
|
|
||||||
<td>{{ key.imported_domains|join:', ' }}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
{% blocktrans trimmed %}
|
|
||||||
After this key is published to the keyservers, it can be signed using
|
|
||||||
<a href="https://www.gnupg.org/">GnuPG</a> with the following commands:
|
|
||||||
{% endblocktrans %}
|
|
||||||
</p>
|
|
||||||
<pre>
|
|
||||||
# {% trans "Download the key" %}
|
|
||||||
gpg --recv-key {{ key.openpgp_fingerprint }}
|
|
||||||
|
|
||||||
# {% trans "Sign the key" %}
|
|
||||||
gpg --sign-key {{ key.openpgp_fingerprint }}
|
|
||||||
|
|
||||||
# {% trans "Send the key back to the keyservers" %}
|
|
||||||
gpg --send-key {{ key.openpgp_fingerprint }}
|
|
||||||
</pre>
|
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
@ -1,100 +0,0 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
"""
|
|
||||||
Functional, browser based tests for monkeysphere app.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from plinth.tests import functional
|
|
||||||
|
|
||||||
pytestmark = [pytest.mark.system, pytest.mark.monkeysphere]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope='module', autouse=True)
|
|
||||||
def fixture_background(session_browser):
|
|
||||||
"""Login and install the app."""
|
|
||||||
functional.login(session_browser)
|
|
||||||
functional.set_advanced_mode(session_browser, True)
|
|
||||||
functional.install(session_browser, 'monkeysphere')
|
|
||||||
functional.set_domain_name(session_browser, 'mydomain.example')
|
|
||||||
yield
|
|
||||||
|
|
||||||
|
|
||||||
def test_import_ssh_keys(session_browser):
|
|
||||||
"""Test importing SSH keys."""
|
|
||||||
_import_key(session_browser, 'ssh', 'mydomain.example')
|
|
||||||
_assert_imported_key(session_browser, 'ssh', 'mydomain.example')
|
|
||||||
|
|
||||||
|
|
||||||
def test_import_https_keys(session_browser):
|
|
||||||
"""Test importing HTTPS keys."""
|
|
||||||
_import_key(session_browser, 'https', 'mydomain.example')
|
|
||||||
_assert_imported_key(session_browser, 'https', 'mydomain.example')
|
|
||||||
|
|
||||||
|
|
||||||
def test_publish_ssh_keys(session_browser):
|
|
||||||
"""Test publishing SSH keys."""
|
|
||||||
_import_key(session_browser, 'ssh', 'mydomain.example')
|
|
||||||
_publish_key(session_browser, 'ssh', 'mydomain.example')
|
|
||||||
|
|
||||||
|
|
||||||
def test_publish_https_keys(session_browser):
|
|
||||||
"""Test publishing HTTPS keys."""
|
|
||||||
_import_key(session_browser, 'https', 'mydomain.example')
|
|
||||||
_publish_key(session_browser, 'https', 'mydomain.example')
|
|
||||||
|
|
||||||
|
|
||||||
def test_backup_restore(session_browser):
|
|
||||||
"""Test backup and restore of keys"""
|
|
||||||
_import_key(session_browser, 'ssh', 'mydomain.example')
|
|
||||||
_import_key(session_browser, 'https', 'mydomain.example')
|
|
||||||
functional.backup_create(session_browser, 'monkeysphere',
|
|
||||||
'test_monkeysphere')
|
|
||||||
|
|
||||||
functional.backup_restore(session_browser, 'monkeysphere',
|
|
||||||
'test_monkeysphere')
|
|
||||||
_assert_imported_key(session_browser, 'ssh', 'mydomain.example')
|
|
||||||
_assert_imported_key(session_browser, 'https', 'mydomain.example')
|
|
||||||
|
|
||||||
|
|
||||||
def _find_domain(browser, key_type, domain_type, domain):
|
|
||||||
"""Iterate every domain of a given type which given key type."""
|
|
||||||
keys_of_type = browser.find_by_css(
|
|
||||||
'.monkeysphere-service-{}'.format(key_type))
|
|
||||||
for key_of_type in keys_of_type:
|
|
||||||
search_domains = key_of_type.find_by_css(
|
|
||||||
'.monkeysphere-{}-domain'.format(domain_type))
|
|
||||||
for search_domain in search_domains:
|
|
||||||
if search_domain.text == domain:
|
|
||||||
return key_of_type, search_domain
|
|
||||||
|
|
||||||
raise IndexError('Domain not found')
|
|
||||||
|
|
||||||
|
|
||||||
def _import_key(browser, key_type, domain):
|
|
||||||
"""Import a key of specified type for given domain into monkeysphere."""
|
|
||||||
try:
|
|
||||||
_assert_imported_key(browser, key_type, domain)
|
|
||||||
except IndexError:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
return
|
|
||||||
|
|
||||||
key, _ = _find_domain(browser, key_type, 'importable', domain)
|
|
||||||
with functional.wait_for_page_update(browser):
|
|
||||||
key.find_by_css('.button-import').click()
|
|
||||||
|
|
||||||
|
|
||||||
def _assert_imported_key(browser, key_type, domain):
|
|
||||||
"""Assert that a key of specified type for given domain was imported.."""
|
|
||||||
functional.nav_to_module(browser, 'monkeysphere')
|
|
||||||
return _find_domain(browser, key_type, 'imported', domain)
|
|
||||||
|
|
||||||
|
|
||||||
def _publish_key(browser, key_type, domain):
|
|
||||||
"""Publish a key of specified type for given domain from monkeysphere."""
|
|
||||||
functional.nav_to_module(browser, 'monkeysphere')
|
|
||||||
key, _ = _find_domain(browser, key_type, 'imported', domain)
|
|
||||||
with functional.wait_for_page_update(browser):
|
|
||||||
key.find_by_css('.button-publish').click()
|
|
||||||
|
|
||||||
functional.wait_for_config_update(browser, 'monkeysphere')
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
"""
|
|
||||||
URLs for the monkeysphere module.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from django.urls import re_path
|
|
||||||
|
|
||||||
from . import views
|
|
||||||
|
|
||||||
urlpatterns = [
|
|
||||||
re_path(r'^sys/monkeysphere/$', views.index, name='index'),
|
|
||||||
re_path(r'^sys/monkeysphere/(?P<ssh_fingerprint>[0-9A-Za-z:+/]+)/import/$',
|
|
||||||
views.import_key, name='import'),
|
|
||||||
re_path(r'^sys/monkeysphere/(?P<fingerprint>[0-9A-Fa-f]+)/details/$',
|
|
||||||
views.details, name='details'),
|
|
||||||
re_path(r'^sys/monkeysphere/(?P<fingerprint>[0-9A-Fa-f]+)/publish/$',
|
|
||||||
views.publish, name='publish'),
|
|
||||||
re_path(r'^sys/monkeysphere/cancel/$', views.cancel, name='cancel'),
|
|
||||||
]
|
|
||||||
@ -1,135 +0,0 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
"""
|
|
||||||
Views for the monkeysphere module.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
|
|
||||||
from django.contrib import messages
|
|
||||||
from django.shortcuts import redirect
|
|
||||||
from django.template.response import TemplateResponse
|
|
||||||
from django.urls import reverse_lazy
|
|
||||||
from django.utils.translation import gettext as _
|
|
||||||
from django.views.decorators.http import require_POST
|
|
||||||
|
|
||||||
from plinth import actions
|
|
||||||
from plinth.modules import monkeysphere, names
|
|
||||||
|
|
||||||
publish_process = None
|
|
||||||
|
|
||||||
|
|
||||||
def index(request):
|
|
||||||
"""Serve configuration page."""
|
|
||||||
_collect_publish_result(request)
|
|
||||||
status = get_status()
|
|
||||||
return TemplateResponse(
|
|
||||||
request, 'monkeysphere.html', {
|
|
||||||
'app_info': monkeysphere.app.info,
|
|
||||||
'title': monkeysphere.app.info.name,
|
|
||||||
'status': status,
|
|
||||||
'running': bool(publish_process),
|
|
||||||
'refresh_page_sec': 3 if bool(publish_process) else None,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@require_POST
|
|
||||||
def import_key(request, ssh_fingerprint):
|
|
||||||
"""Import a key into monkeysphere."""
|
|
||||||
keys = get_keys()
|
|
||||||
available_domains = keys[ssh_fingerprint]['available_domains']
|
|
||||||
try:
|
|
||||||
actions.superuser_run('monkeysphere',
|
|
||||||
['host-import-key', ssh_fingerprint] +
|
|
||||||
list(available_domains))
|
|
||||||
messages.success(request, _('Imported key.'))
|
|
||||||
except actions.ActionError as exception:
|
|
||||||
messages.error(request, str(exception))
|
|
||||||
|
|
||||||
return redirect(reverse_lazy('monkeysphere:index'))
|
|
||||||
|
|
||||||
|
|
||||||
def details(request, fingerprint):
|
|
||||||
"""Get details for an OpenPGP key."""
|
|
||||||
return TemplateResponse(request, 'monkeysphere_details.html', {
|
|
||||||
'title': monkeysphere.app.info.name,
|
|
||||||
'key': get_key(fingerprint)
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@require_POST
|
|
||||||
def publish(request, fingerprint):
|
|
||||||
"""Publish OpenPGP key for SSH service."""
|
|
||||||
global publish_process
|
|
||||||
if not publish_process:
|
|
||||||
publish_process = actions.superuser_run(
|
|
||||||
'monkeysphere', ['host-publish-key', fingerprint],
|
|
||||||
run_in_background=True)
|
|
||||||
|
|
||||||
return redirect(reverse_lazy('monkeysphere:index'))
|
|
||||||
|
|
||||||
|
|
||||||
@require_POST
|
|
||||||
def cancel(request):
|
|
||||||
"""Cancel ongoing process."""
|
|
||||||
global publish_process
|
|
||||||
if publish_process:
|
|
||||||
actions.superuser_run(
|
|
||||||
'monkeysphere', ['host-cancel-publish',
|
|
||||||
str(publish_process.pid)])
|
|
||||||
publish_process = None
|
|
||||||
messages.info(request, _('Cancelled key publishing.'))
|
|
||||||
|
|
||||||
return redirect(reverse_lazy('monkeysphere:index'))
|
|
||||||
|
|
||||||
|
|
||||||
def get_status():
|
|
||||||
"""Get the current status."""
|
|
||||||
return {'keys': get_keys()}
|
|
||||||
|
|
||||||
|
|
||||||
def get_keys(fingerprint=None):
|
|
||||||
"""Get keys."""
|
|
||||||
fingerprint = [fingerprint] if fingerprint else []
|
|
||||||
output = actions.superuser_run('monkeysphere',
|
|
||||||
['host-show-keys'] + fingerprint)
|
|
||||||
keys = json.loads(output)['keys']
|
|
||||||
|
|
||||||
domains = names.components.DomainName.list_names()
|
|
||||||
for key in keys.values():
|
|
||||||
key['imported_domains'] = set(key.get('imported_domains', []))
|
|
||||||
key['available_domains'] = set(key.get('available_domains', []))
|
|
||||||
if '*' in key['available_domains']:
|
|
||||||
key['available_domains'] = set(domains)
|
|
||||||
|
|
||||||
key['all_domains'] = sorted(key['available_domains'].union(
|
|
||||||
key['imported_domains']))
|
|
||||||
key['importable_domains'] = key['available_domains'] \
|
|
||||||
.difference(key['imported_domains'])
|
|
||||||
|
|
||||||
return keys
|
|
||||||
|
|
||||||
|
|
||||||
def get_key(fingerprint):
|
|
||||||
"""Get key by fingerprint."""
|
|
||||||
keys = get_keys(fingerprint)
|
|
||||||
return list(keys.values())[0] if len(keys) else None
|
|
||||||
|
|
||||||
|
|
||||||
def _collect_publish_result(request):
|
|
||||||
"""Handle publish process completion."""
|
|
||||||
global publish_process
|
|
||||||
if not publish_process:
|
|
||||||
return
|
|
||||||
|
|
||||||
return_code = publish_process.poll()
|
|
||||||
|
|
||||||
# Publish process is not complete yet
|
|
||||||
if return_code is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
if not return_code:
|
|
||||||
messages.success(request, _('Published key to keyserver.'))
|
|
||||||
else:
|
|
||||||
messages.error(request, _('Error occurred while publishing key.'))
|
|
||||||
|
|
||||||
publish_process = None
|
|
||||||
@ -50,9 +50,9 @@ _site_url = {
|
|||||||
|
|
||||||
_sys_modules = [
|
_sys_modules = [
|
||||||
'avahi', 'backups', 'bind', 'cockpit', 'config', 'datetime', 'diagnostics',
|
'avahi', 'backups', 'bind', 'cockpit', 'config', 'datetime', 'diagnostics',
|
||||||
'dynamicdns', 'firewall', 'letsencrypt', 'monkeysphere', 'names',
|
'dynamicdns', 'firewall', 'letsencrypt', 'names', 'networks', 'pagekite',
|
||||||
'networks', 'pagekite', 'performance', 'power', 'security', 'snapshot',
|
'performance', 'power', 'security', 'snapshot', 'ssh', 'storage',
|
||||||
'ssh', 'storage', 'upgrades', 'users'
|
'upgrades', 'users'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -40,7 +40,6 @@ markers = [
|
|||||||
"minetest",
|
"minetest",
|
||||||
"minidlna",
|
"minidlna",
|
||||||
"mldonkey",
|
"mldonkey",
|
||||||
"monkeysphere",
|
|
||||||
"mumble",
|
"mumble",
|
||||||
"openvpn",
|
"openvpn",
|
||||||
"pagekite",
|
"pagekite",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user