diff --git a/actions/pagekite b/actions/pagekite new file mode 100755 index 000000000..71d29e734 --- /dev/null +++ b/actions/pagekite @@ -0,0 +1,257 @@ +#!/usr/bin/python2 +# -*- mode: python -*- +# +# This file is part of Plinth. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# + +""" +Configuration helper for Plinth PageKite interface + +Unfortunately there is no python3 package for augeas yet +""" + +import argparse +import augeas +import json +import os +import subprocess + +import util +from pagekite_util import SERVICE_PARAMS, convert_service_to_string, \ + get_augeas_servicefile_path, load_service, CONF_PATH + +aug = augeas.Augeas() + +PATHS = { + 'service_on': os.path.join(CONF_PATH, '*', 'service_on', '*'), + 'kitename': os.path.join(CONF_PATH, '10_account.rc', 'kitename'), + 'kitesecret': os.path.join(CONF_PATH, '10_account.rc', 'kitesecret'), + 'abort_not_configured': os.path.join(CONF_PATH, '10_account.rc', + 'abort_not_configured'), + 'defaults': os.path.join(CONF_PATH, '20_frontends.rc', 'defaults'), + 'frontend': os.path.join(CONF_PATH, '20_frontends.rc', 'frontend'), +} + + +def parse_arguments(): + """Return parsed command line arguments as dictionary""" + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(dest='subcommand', help='Sub command') + + # Enable/disable the pagekite service + subparsers.add_parser('is-running', help='Get whether PakeKite is running') + subparsers.add_parser('start-and-enable', help='Enable PageKite service') + subparsers.add_parser('stop-and-disable', help='Disable PageKite service') + subparsers.add_parser('restart', help='Restart PageKite service') + + # Frontend + subparsers.add_parser('get-frontend', help='Get pagekite frontend') + set_frontend = subparsers.add_parser('set-frontend', + help='Set pagekite frontend') + set_frontend.add_argument('url', help='frontend url') + + # Kite details (name + secret) + subparsers.add_parser('get-kite', + help='Get configured kite name and secret') + set_kite = subparsers.add_parser('set-kite', + help='Configure kite name and its secret') + set_kite.add_argument('--kite-name', + help='Name of the kite (eg: mybox.pagekite.me)') + set_kite.add_argument('--kite-secret', help='Secret for the kite') + + # Add/remove pagekite services (service_on entries) + subparsers.add_parser('get-services', help='Get list of enabled services') + add_service = subparsers.add_parser('add-service', + help='Add a pagekite service') + add_service.add_argument('--service', help='json service dictionary') + remove_service = subparsers.add_parser('remove-service', + help='Remove a pagekite service') + remove_service.add_argument('--service', help='json service dictionary') + + return parser.parse_args() + + +def _service(action): + """Start/stop/restart the pagekite service""" + error = subprocess.call(['service', 'pagekite', action]) + if error: + raise Exception('Unable to %s PageKite server' % action) + + +def subcommand_is_running(_): + """Print whether pagekite is enabled (yes or no)""" + print 'yes' if util.service_is_running('pagekite') else 'no' + + +def subcommand_restart(_): + """Restart the pagekite service""" + _service('restart') + print 'restarted' + + +def subcommand_start_and_enable(_): + aug.remove(PATHS['abort_not_configured']) + aug.save() + # 'start' alone sometimes fails, even if the service is not running + _service('restart') + print 'enabled' + + +def subcommand_stop_and_disable(_): + _service('stop') + aug.set(PATHS['abort_not_configured'], '') + aug.save() + print 'disabled' + + +def subcommand_get_frontend(_): + """Get pagekite frontend url""" + if aug.match(PATHS['defaults']): + print "pagekite.net" + else: + url = aug.get(PATHS['frontend']) + print url if url else "" + + +def subcommand_set_frontend(arguments): + """Set pagekite frontend url, taking care of defaults and pagekite.net""" + if arguments.url in ('pagekite.net', 'defaults', 'default'): + enable_pagekitenet_frontend() + else: + aug.remove(PATHS['defaults']) + aug.set(PATHS['frontend'], arguments.url) + aug.save() + + +def enable_pagekitenet_frontend(): + """Enable using default pageket.net frontend + + This disables any other frontends. + """ + aug.set(PATHS['defaults'], '') + aug.remove(PATHS['frontend']) + aug.save() + print "enabled" + + +def subcommand_get_services(arguments): + """ lists all available (enabled) services """ + for match in aug.match(PATHS['service_on']): + service = dict([(param, aug.get(os.path.join(match, param))) + for param in SERVICE_PARAMS]) + print json.dumps(service) + + +def subcommand_remove_service(arguments): + """Searches and removes the service(s) that match all given parameters""" + service = load_service(arguments.service) + paths = get_existing_service_paths(service) + # TODO: theoretically, everything to do here is: + # [aug.remove(path) for path in paths] + # but augeas won't let you save the changed files and doesn't say why + for path in paths: + filepath = convert_augeas_path_to_filepath(path) + service_found = False + with open(filepath, 'r') as file: + lines = file.readlines() + for i, line in enumerate(lines): + if line.startswith('service_on') and \ + all(param in line for param in service.values()): + lines[i] = "" + service_found = True + break + if service_found: + with open(filepath, 'w') as file: + file.writelines(lines) + # abort to only allow deleting one service + break + _service('restart') + + +def get_existing_service_paths(service): + """Return paths of existing services that match the given service params""" + # construct an augeas query path with patterns like: + # */service_on/*[protocol='http'] + path = PATHS['service_on'] + for param, value in service.items(): + path += "[%s='%s']" % (param, value) + return aug.match(path) + + +def subcommand_add_service(arguments): + """Add one service""" + service = load_service(arguments.service) + if get_existing_service_paths(service): + msg = "Service with the parameters %s already exists" + raise RuntimeError(msg % service) + + root = get_new_service_path(service['protocol']) + # TODO: after adding a service, augeas fails writing the config; + # so add the service_on entry manually instead + path = convert_augeas_path_to_filepath(root) + with open(path, 'a') as servicefile: + line = "\nservice_on = %s\n" % convert_service_to_string(service) + servicefile.write(line) + _service('restart') + + +def convert_augeas_path_to_filepath(augpath, prefix='/files', + suffix='service_on'): + """Convert an augeas service_on path to the actual file path""" + if augpath.startswith(prefix): + augpath = augpath.replace(prefix, "", 1) + + index = augpath.rfind(suffix) + if index: + augpath = augpath[:index] + return augpath.rstrip('/') + + +def get_new_service_path(protocol): + """Get the augeas path of a new service for a protocol + + This takes care of existing services using a /service_on/*/ query""" + root = get_augeas_servicefile_path(protocol) + new_index = len(aug.match(root + '/*')) + 1 + return os.path.join(root, str(new_index)) + + +def subcommand_get_kite(_): + """Print details of the currently configured kite""" + kitename = aug.get(PATHS['kitename']) + kitesecret = aug.get(PATHS['kitesecret']) + print kitename if kitename else '' + print kitesecret if kitesecret else '' + + +def subcommand_set_kite(arguments): + """Set details of the kite""" + aug.set(PATHS['kitename'], arguments.kite_name) + aug.set(PATHS['kitesecret'], arguments.kite_secret) + aug.save() + + +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() diff --git a/actions/pagekite-configure b/actions/pagekite-configure deleted file mode 100755 index a5af96fb0..000000000 --- a/actions/pagekite-configure +++ /dev/null @@ -1,290 +0,0 @@ -#!/usr/bin/python3 -# -*- mode: python -*- -# -# This file is part of Plinth. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as -# published by the Free Software Foundation, either version 3 of the -# License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -# - -""" -Configuration helper for Plint PageKite inteface - -TODO: Use augeas for manipulating /etc/pagekite.d/* files -""" - -# Disable warning about invalid module name # pylint: disable-msg=C0103 - -import argparse -import os -import re -import subprocess - -CONFIG_DIR = '/etc/pagekite.d' - -SERVICE_FILE_MAP = { - 'http': { - 'file': '80_httpd.rc', - 'match': 'http:', - 'line': 'service_on = http:@kitename : localhost:80 : @kitesecret'}, - 'ssh': { - 'file': '80_sshd.rc', - 'match': 'raw/22:', - 'line': 'service_on = raw/22:@kitename : localhost:22 : @kitesecret'}} - - -def parse_arguments(): - """Return parsed command line arguments as dictionary""" - parser = argparse.ArgumentParser() - subparsers = parser.add_subparsers(dest='subcommand', help='Sub command') - - # Start PageKite - subparsers.add_parser('start', help='Start PageKite service') - - # Stop PageKite - subparsers.add_parser('stop', help='Stop PageKite service') - - # Get status - subparsers.add_parser('get-status', help='Get whether PakeKite is enabled') - - # Set status - set_status = subparsers.add_parser('set-status', - help='Enable/disable PageKite') - set_status.add_argument('enable', choices=['enable', 'disable']) - - # Get kite details - subparsers.add_parser('get-kite', - help='Get configured kite name and secret') - - # Set kite details - set_kite = subparsers.add_parser('set-kite', - help='Configure kite name and its secret') - set_kite.add_argument('--kite-name', - help='Name of the kite (eg: mybox.pagekite.me)') - set_kite.add_argument('--kite-secret', help='Secret for the kite') - - # Get service status - get_service = subparsers.add_parser('get-service-status', - help='Get whether service is enabled') - get_service.add_argument('service', choices=['http', 'ssh']) - - # Set service status - set_service = subparsers.add_parser('set-service-status', - help='Enable/disable a service') - set_service.add_argument('service', choices=['http', 'ssh']) - set_service.add_argument('enable', choices=['enable', 'disable']) - - return parser.parse_args() - - -def subcommand_start(_): - """Start PageKite service""" - status = subprocess.call(['service', 'pagekite', 'start']) - if status: - raise Exception('Unable to start PageKite server') - - -def subcommand_stop(_): - """Stop PageKite service""" - status = subprocess.call(['service', 'pagekite', 'stop']) - if status: - raise Exception('Unable to stop PageKite server') - - -def subcommand_get_status(_): - """Print status of the pagekite service""" - is_enabled = is_pagekite_enabled() - print('enabled' if is_enabled else 'disabled') - - -def is_pagekite_enabled(): - """Return if pagekite is enabled""" - service_file_path = os.path.join(CONFIG_DIR, '10_account.rc') - try: - with open(service_file_path, 'r') as file_object: - for line in file_object: - regex = r'^[ \t]*abort_not_configured' - if re.match(regex, line): - return False - except Exception: - return True - - return True - - -def subcommand_set_status(arguments): - """Enable/disable the pagekite service""" - enable = arguments.enable == 'enable' - is_enabled = is_pagekite_enabled() - if enable and is_enabled: - print('already enabled') - return - - if not enable and not is_enabled: - print('already disabled') - return - - if enable: - pagekite_enable() - print('enabled') - else: - pagekite_disable() - print('disabled') - - -def pagekite_enable(): - """Enable the pagekite daemon""" - file_path = os.path.join(CONFIG_DIR, '10_account.rc') - file_path_new = os.path.join(CONFIG_DIR, '10_account.rc.new') - with open(file_path, 'r') as read_file_object, \ - open(file_path_new, 'w') as write_file_object: - for line in read_file_object: - if not re.match('^[ \t]*abort_not_configured', line): - write_file_object.write(line) - - os.rename(file_path_new, file_path) - - -def pagekite_disable(): - """Disable the pagekite daemon""" - file_path = os.path.join(CONFIG_DIR, '10_account.rc') - with open(file_path, 'a') as file_object: - file_object.write('abort_not_configured\n') - - -def subcommand_get_kite(_): - """Print details of the currently configure kite""" - kite_name = '' - kite_secret = '' - - file_path = os.path.join(CONFIG_DIR, '10_account.rc') - with open(file_path, 'r') as file_object: - for line in file_object: - match = re.match(r'[ \t]*kitename[ \t]*=[ \t]*(.*)', line) - if match: - kite_name = match.group(1) - continue - - match = re.match(r'[ \t]*kitesecret[ \t]*=[ \t]*(.*)', line) - if match: - kite_secret = match.group(1) - continue - - print(kite_name) - print(kite_secret) - - -def subcommand_set_kite(arguments): - """Set details of the kite""" - kite_name = arguments.kite_name - kite_secret = arguments.kite_secret - - file_path = os.path.join(CONFIG_DIR, '10_account.rc') - file_path_new = os.path.join(CONFIG_DIR, '10_account.rc.new') - with open(file_path, 'r') as read_file_object, \ - os.fdopen(os.open(file_path_new, os.O_WRONLY | os.O_CREAT, - 0o400), 'w') as write_file_object: - for line in read_file_object: - if re.match(r'[ \t]*kitename[ \t]*=.*', line): - write_file_object.write( - 'kitename = {kite_name}\n'.format(kite_name=kite_name)) - continue - - if re.match(r'[ \t]*kitesecret[ \t]*=.*', line): - write_file_object.write('kitesecret = {kite_secret}\n' - .format(kite_secret=kite_secret)) - continue - - write_file_object.write(line) - - os.rename(file_path_new, file_path) - - -def subcommand_get_service_status(arguments): - """Print status of the pagekite service""" - is_enabled = is_service_enabled(arguments.service) - print('enabled' if is_enabled else 'disabled') - - -def is_service_enabled(service): - """Return if a service is enabled""" - service = SERVICE_FILE_MAP[service] - service_file_path = os.path.join(CONFIG_DIR, service['file']) - if not os.path.isfile(service_file_path): - return False - - try: - with open(service_file_path, 'r') as file_object: - for line in file_object: - regex = '[ \t]*service_on[ \t]*=[ \t]*{match}' - regex = regex.format(match=service['match']) - if re.match(regex, line): - return True - except Exception: - return False - - return False - - -def subcommand_set_service_status(arguments): - """Enable/disable a pagekite service""" - enable = arguments.enable == 'enable' - is_enabled = is_service_enabled(arguments.service) - if enable and is_enabled: - print('already enabled') - return - - if not enable and not is_enabled: - print('already disabled') - return - - if enable: - service_enable(arguments.service) - print('enabled') - else: - service_disable(arguments.service) - print('disabled') - - -def service_enable(service_name): - """Enable a service""" - service = SERVICE_FILE_MAP[service_name] - service_file_path = os.path.join(CONFIG_DIR, service['file']) - with open(service_file_path, 'w') as file_object: - file_object.write(''' -# Expose the local {service_name} daemon -# File auto-generated by Plinth - -{line} -'''.format(service_name=service_name, line=service['line'])) - - -def service_disable(service_name): - """Disable a service""" - service = SERVICE_FILE_MAP[service_name] - service_file_path = os.path.join(CONFIG_DIR, service['file']) - service_file_path_new = os.path.join(CONFIG_DIR, - service['file'] + '.plinthbak') - os.rename(service_file_path, service_file_path_new) - - -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() diff --git a/actions/pagekite_util.py b/actions/pagekite_util.py new file mode 100644 index 000000000..26b8c757f --- /dev/null +++ b/actions/pagekite_util.py @@ -0,0 +1,113 @@ +#!/usr/bin/python2 +# -*- mode: python -*- +# +# This file is part of Plinth. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# + +""" +Utilities for configuring PageKite. +""" +# TODO: +# Once python-augeas is available for python3 import the following things +# from plinth.modules.pagekite.util (instead of having a copy in here): +# +# SERVICE_PARAMS, convert_service_to_string +# +# until then, this file is python2 and python3 compatible for the unittests + +import os +import json + +CONF_PATH = '/files/etc/pagekite.d' + +# parameters that get stored in configuration service_on entries +SERVICE_PARAMS = ['protocol', 'kitename', 'backend_host', 'backend_port', + 'secret'] + + +def convert_service_to_string(service): + """ Convert service dict into a ":"-separated parameter string + + >>> convert_service_to_string({'kitename': '@kitename', \ +'backend_host': 'localhost', 'secret': '@kitesecret', \ +'protocol': 'https/443', 'backend_port': '443'}) + 'https/443:@kitename:localhost:443:@kitesecret' + """ + try: + service_string = ":".join([service[param] for param in SERVICE_PARAMS]) + except KeyError: + raise ValueError("Could not parse params: %s " % service) + return service_string + + +def load_service(json_service): + """ create a service out of json command-line argument + + 1) parse json + 2) only use the parameters that we need (SERVICE_PARAMS) + 3) convert unicode to strings + """ + service = json.loads(json_service) + return dict((str(key), str(service[key])) for key in SERVICE_PARAMS) + + +def get_augeas_servicefile_path(protocol): + """Get the augeas path where a service for a protocol should be stored + + TODO: Once we use python3 switch from doctests to unittests + + >>> get_augeas_servicefile_path('http') + '/files/etc/pagekite.d/80_http.rc/service_on' + + >>> get_augeas_servicefile_path('https') + '/files/etc/pagekite.d/443_https.rc/service_on' + + >>> get_augeas_servicefile_path('http/80') + '/files/etc/pagekite.d/80_http.rc/service_on' + + >>> get_augeas_servicefile_path('http/8080') + '/files/etc/pagekite.d/8080_http.rc/service_on' + + >>> get_augeas_servicefile_path('raw/22') + '/files/etc/pagekite.d/22_raw.rc/service_on' + + >>> get_augeas_servicefile_path('xmpp') + Traceback (most recent call last): + ... + ValueError: Unsupported protocol: xmpp + + """ + if not protocol.startswith(("http", "https", "raw")): + raise ValueError('Unsupported protocol: %s' % protocol) + + try: + _protocol, port = protocol.split('/') + except ValueError: + if protocol == 'http': + relpath = '80_http.rc' + elif protocol == 'https': + relpath = '443_https.rc' + else: + raise ValueError('Unsupported protocol: %s' % protocol) + else: + relpath = '%s_%s.rc' % (port, _protocol) + + return os.path.join(CONF_PATH, relpath, 'service_on') + + +if __name__ == "__main__": + import doctest + doctest.testmod() diff --git a/actions/tor b/actions/tor index f72c7fd5e..5a546fd4b 100755 --- a/actions/tor +++ b/actions/tor @@ -25,6 +25,8 @@ import argparse import os import subprocess +import util + SERVICE_CONFIG = '/etc/default/tor' TOR_CONFIG = '/etc/tor/torrc' @@ -57,20 +59,7 @@ def parse_arguments(): def subcommand_is_running(_): """Get whether Tor is running""" - try: - output = subprocess.check_output(['service', 'tor', 'status']) - except subprocess.CalledProcessError: - # If Tor is not running we get a status code != 0 and a - # CalledProcessError - print('no') - else: - running = False - for line in output.decode().split('\n'): - if 'Active' in line and 'running' in line: - running = True - break - - print('yes' if running else 'no') + print('yes' if util.service_is_running('tor') else 'no') def subcommand_start(_): diff --git a/actions/util.py b/actions/util.py new file mode 100644 index 000000000..1f097f09d --- /dev/null +++ b/actions/util.py @@ -0,0 +1,41 @@ +#!/usr/bin/python3 +# -*- mode: python -*- +# +# This file is part of Plinth. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# + +""" +Python action utility functions +""" + +import subprocess + + +def service_is_running(servicename): + """Evaluates whether a service is currently running. Returns boolean""" + try: + output = subprocess.check_output(['service', servicename, 'status']) + except subprocess.CalledProcessError: + # Usually if a service is not running we get a status code != 0 and + # thus a CalledProcessError + return False + else: + running = False # default value + for line in output.decode('utf-8').split('\n'): + if 'Active' in line and 'running' in line: + running = True + break + return running diff --git a/data/usr/share/augeas/lenses/pagekite.aug b/data/usr/share/augeas/lenses/pagekite.aug new file mode 100644 index 000000000..83ced80bd --- /dev/null +++ b/data/usr/share/augeas/lenses/pagekite.aug @@ -0,0 +1,79 @@ +(* +Module: Pagekite + Parses /etc/pagekite.d/ + +Author: Michael Pimmer + +About: License + This file is licenced under the LGPL v2+, like the rest of Augeas. +*) + +module Pagekite = +autoload xfm + +(* View: lns *) + +(* Variables *) +let equals = del /[ \t]*=[ \t]*/ "=" +let neg2 = /[^# \n\t]+/ +let neg3 = /[^# \:\n\t]+/ +let eol = del /\n/ "\n" +(* Match everything from here to eol, cropping whitespace at both ends *) +let to_eol = /[^ \t\n](.*[^ \t\n])?/ + +(* A key followed by comma-separated values + k: name of the key + key_sep: separator between key and values + value_sep: separator between values + sto: store for values +*) +let key_csv_line (k:string) (key_sep:lens) (value_sep:lens) (sto:lens) = + [ key k . key_sep . [ seq k . sto ] . + [ seq k . value_sep . sto ]* . Util.eol ] + +(* entries for pagekite.d/10_account.rc *) +let domain = [ key "domain" . equals . store neg2 . Util.comment_or_eol ] +let frontend = Build.key_value_line ("frontend" | "frontends") + equals (store Rx.neg1) +let host = Build.key_value_line "host" equals (store Rx.ip) +let ports = key_csv_line "ports" equals Sep.comma (store Rx.integer) +let protos = key_csv_line "protos" equals Sep.comma (store Rx.word) + +(* entries for pagekite.d/20_frontends.rc *) +let kitesecret = Build.key_value_line "kitesecret" equals (store Rx.space_in) +let kv_frontend = Build.key_value_line ( "kitename" | "fe_certname" | + "ca_certs" | "tls_endpoint" ) + equals (store Rx.neg1) + +(* entries for services like 80_httpd.rc *) +let service_colon = del /[ \t]*:[ \t]*/ " : " +let service_on = [ key "service_on" . [ seq "service_on" . equals . + [ label "protocol" . store neg3 ] . service_colon . + [ label "kitename" . (store neg3) ] . service_colon . + [ label "backend_host" . (store neg3) ] . service_colon . + [ label "backend_port" . (store neg3) ] . service_colon . ( + [ label "secret" . (store Rx.no_spaces) . Util.eol ] | eol + ) ] ] + +let service_cfg = [ key "service_cfg" . equals . store to_eol . eol ] + +let flags = ( "defaults" | "isfrontend" | "abort_not_configured" | "insecure" ) + +let entries = Build.flag_line flags + | domain + | frontend + | host + | ports + | protos + | kv_frontend + | kitesecret + | service_on + | service_cfg + +let lns = ( entries | Util.empty | Util.comment )* + +(* View: filter *) +let filter = incl "/etc/pagekite.d/*.rc" + . Util.stdexcl + +let xfm = transform lns filter diff --git a/data/usr/share/augeas/lenses/tests/test_pagekite.aug b/data/usr/share/augeas/lenses/tests/test_pagekite.aug new file mode 100644 index 000000000..d32bc4f48 --- /dev/null +++ b/data/usr/share/augeas/lenses/tests/test_pagekite.aug @@ -0,0 +1,111 @@ +module Test_Pagekite = + +let conf1 = "# Use the pagekite.net service defaults. +defaults +" +test Pagekite.lns get conf1 = + { "#comment" = "Use the pagekite.net service defaults." } + { "defaults" } + + +let conf2 =" +frontends = pagekite.freedombox.me +ports=80,81 +" +test Pagekite.lns get conf2 = + { } + { "frontends" = "pagekite.freedombox.me" } + { "ports" + { "1" = "80" } + { "2" = "81" } } + + +let conf3 = "frontend=pagekite.freedombox.me +host=192.168.0.3 +" +test Pagekite.lns get conf3 = + { "frontend" = "pagekite.freedombox.me" } + { "host" = "192.168.0.3" } + + +let conf4 = "isfrontend +ports=80,443 +protos=http,https +domain=http,https:*.your.domain:MakeUpAPasswordHere +" +test Pagekite.lns get conf4 = + { "isfrontend" } + { "ports" + { "1" = "80" } + { "2" = "443" } } + { "protos" + { "1" = "http" } + { "2" = "https" } } + { "domain" = "http,https:*.your.domain:MakeUpAPasswordHere" } + +let conf_account = "kitename = my.freedombox.me +kitesecret = 0420 +# Delete this line! +abort_not_configured +" +test Pagekite.lns get conf_account = + { "kitename" = "my.freedombox.me" } + { "kitesecret" = "0420" } + { "#comment" = "Delete this line!" } + { "abort_not_configured" } + + +let conf_service = " +service_on = raw/22:@kitename : localhost:22 : @kitesecret +service_on=http:192.168.0.1:127.0.0.1:80: +service_on=https:yourhostname,fqdn:127.0.0.1:443: +" +test Pagekite.lns get conf_service = + { } + { "service_on" + { "1" + { "protocol" = "raw/22" } + { "kitename" = "@kitename" } + { "backend_host" = "localhost" } + { "backend_port" = "22" } + { "secret" = "@kitesecret" } + } + } + { "service_on" + { "2" + { "protocol" = "http" } + { "kitename" = "192.168.0.1" } + { "backend_host" = "127.0.0.1" } + { "backend_port" = "80" } + } + } + { "service_on" + { "3" + { "protocol" = "https" } + { "kitename" = "yourhostname,fqdn" } + { "backend_host" = "127.0.0.1" } + { "backend_port" = "443" } + } + } + + +let conf_encryption = " +frontend=frontend.your.domain:443 +fe_certname=frontend.your/domain +ca_certs=/etc/pagekite.d/site-cert.pem +tls_endpoint=frontend.your.domain:/path/to/frontend.pem +" +test Pagekite.lns get conf_encryption = + { } + { "frontend" = "frontend.your.domain:443" } + { "fe_certname" = "frontend.your/domain" } + { "ca_certs" = "/etc/pagekite.d/site-cert.pem" } + { "tls_endpoint" = "frontend.your.domain:/path/to/frontend.pem" } + + +let conf_service_cfg = "insecure +service_cfg = KITENAME.pagekite.me/80 : insecure : True +" +test Pagekite.lns get conf_service_cfg = + { "insecure" } + { "service_cfg" = "KITENAME.pagekite.me/80 : insecure : True" } diff --git a/plinth/modules/__init__.py b/plinth/modules/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/plinth/modules/pagekite/__init__.py b/plinth/modules/pagekite/__init__.py index 0d836a04f..18fcccbcb 100644 --- a/plinth/modules/pagekite/__init__.py +++ b/plinth/modules/pagekite/__init__.py @@ -19,9 +19,16 @@ Plinth module to configure PageKite """ -from . import pagekite -from .pagekite import init +from gettext import gettext as _ +from plinth import cfg -__all__ = ['pagekite', 'init'] +__all__ = ['init'] depends = ['plinth.modules.apps'] + + +def init(): + """Intialize the PageKite module""" + menu = cfg.main_menu.get('apps:index') + menu.add_urlname(_('Public Visibility (PageKite)'), + 'glyphicon-flag', 'pagekite:index', 50) diff --git a/plinth/modules/pagekite/forms.py b/plinth/modules/pagekite/forms.py new file mode 100644 index 000000000..ea746f0eb --- /dev/null +++ b/plinth/modules/pagekite/forms.py @@ -0,0 +1,224 @@ +# +# This file is part of Plinth. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# + +import copy +from gettext import gettext as _ +import json +import logging + +from django import forms +from django.contrib import messages +from django.core import validators + +from plinth.errors import ActionError +from .util import _run, get_kite_details, BACKEND_HOST, KITE_NAME, \ + KITE_SECRET, PREDEFINED_SERVICES + +LOGGER = logging.getLogger(__name__) + + +class TrimmedCharField(forms.CharField): + """Trim the contents of a CharField""" + def clean(self, value): + """Clean and validate the field value""" + if value: + value = value.strip() + + return super(TrimmedCharField, self).clean(value) + + +class ConfigurationForm(forms.Form): + """Configure PageKite credentials and frontend""" + + enabled = forms.BooleanField(label=_('Enable PageKite'), required=False) + + server = forms.CharField( + label=_('Server'), required=False, + help_text=_('Select your pagekite.net server. Set "pagekite.net" to ' + 'use the default pagekite.net server'), + widget=forms.TextInput()) + + kite_name = TrimmedCharField( + label=_('Kite name'), + help_text=_('Example: mybox.pagekite.me'), + validators=[ + validators.RegexValidator(r'^[\w-]{1,63}(\.[\w-]{1,63})*$', + _('Invalid kite name'))]) + + kite_secret = TrimmedCharField( + label=_('Kite secret'), + help_text=_('A secret associated with the kite or the default secret \ +for your account if no secret is set on the kite')) + + def save(self, request): + old = self.initial + new = self.cleaned_data + LOGGER.info('New status is - %s', new) + + if old != new: + + config_changed = False + + if old['kite_name'] != new['kite_name'] or \ + old['kite_secret'] != new['kite_secret']: + _run(['set-kite', '--kite-name', new['kite_name'], + '--kite-secret', new['kite_secret']]) + messages.success(request, _('Kite details set')) + config_changed = True + + if old['server'] != new['server']: + _run(['set-frontend', new['server']]) + messages.success(request, _('Pagekite server set')) + config_changed = True + + if old['enabled'] != new['enabled']: + if new['enabled']: + _run(['start-and-enable']) + messages.success(request, _('PageKite enabled')) + else: + _run(['stop-and-disable']) + messages.success(request, _('PageKite disabled')) + + # Restart the service if the config was changed while the service + # was running, so changes take effect immediately. + elif config_changed and new['enabled']: + _run(['restart']) + + +class StandardServiceForm(forms.Form): + """Creates a form out of PREDEFINED_SERVICES""" + + def __init__(self, *args, **kwargs): + """Add the fields from PREDEFINED_SERVICES""" + super(StandardServiceForm, self).__init__(*args, **kwargs) + kite = get_kite_details() + for name, service in PREDEFINED_SERVICES.items(): + if name in ('http', 'https'): + help_text = service['help_text'].format(kite['kite_name']) + else: + help_text = service['help_text'] + self.fields[name] = forms.BooleanField(label=service['label'], + help_text=help_text, + required=False) + + def save(self, request): + formdata = self.cleaned_data + for service_name in PREDEFINED_SERVICES.keys(): + if self.initial[service_name] != formdata[service_name]: + service = PREDEFINED_SERVICES[service_name]['params'] + service = json.dumps(service) + if formdata[service_name]: + _run(['add-service', '--service', service]) + messages.success(request, _('Service enabled: {name}') + .format(name=service_name)) + else: + _run(['remove-service', '--service', service]) + messages.success(request, _('Service disabled: {name}') + .format(name=service_name)) + + +class BaseCustomServiceForm(forms.Form): + """Basic form functionality to handle a custom service""" + choices = [("http", "http"), ("https", "https"), ("raw", "raw")] + protocol = forms.ChoiceField(choices=choices, label="protocol") + frontend_port = forms.IntegerField(min_value=0, max_value=65535, + label="external (frontend) port", + required=True) + backend_port = forms.IntegerField(min_value=0, max_value=65535, + label="internal (freedombox) port") + subdomains = forms.BooleanField(label="Enable Subdomains", required=False) + + def convert_formdata_to_service(self, formdata): + """Add information to make a service out of the form data""" + # convert integers to str (to compare values with DEFAULT_SERVICES) + for field in ('frontend_port', 'backend_port'): + formdata[field] = str(formdata[field]) + + # set kitename and kitesecret if not already set + if 'kitename' not in formdata: + if 'subdomains' in formdata and formdata['subdomains']: + formdata['kitename'] = "*.%s" % KITE_NAME + else: + formdata['kitename'] = KITE_NAME + if 'secret' not in formdata: + formdata['secret'] = KITE_SECRET + + # merge protocol and frontend_port back to one entry (protocol) + if 'frontend_port' in formdata: + if formdata['frontend_port'] not in formdata['protocol']: + formdata['protocol'] = "%s/%s" % (formdata['protocol'], + formdata['frontend_port']) + if 'backend_host' not in formdata: + formdata['backend_host'] = BACKEND_HOST + + return formdata + + +class DeleteCustomServiceForm(BaseCustomServiceForm): + + def delete(self, request): + service = self.convert_formdata_to_service(self.cleaned_data) + _run(['remove-service', '--service', json.dumps(service)]) + messages.success(request, _('Deleted custom service')) + + +class AddCustomServiceForm(BaseCustomServiceForm): + """Adds the save() method and validation to not add predefined services""" + + def matches_predefined_service(self, formdata): + """Returns whether the user input matches a predefined service""" + service = self.convert_formdata_to_service(formdata) + match_found = False + for predefined_service_obj in PREDEFINED_SERVICES.values(): + # manually add the port to compare predefined with custom services + # that's due to the (sometimes) implicit port in the configuration + predefined_service = copy.copy(predefined_service_obj['params']) + if predefined_service['protocol'] == 'http': + predefined_service['protocol'] = 'http/80' + elif predefined_service['protocol'] == 'https': + predefined_service['protocol'] = 'https/443' + + # The formdata service has additional keys, so we can't compare + # the dicts directly. + # instead look whether predefined_service is a subset of service + if all(service[k] == v for k, v in predefined_service.items()): + match_found = True + break + return match_found + + def clean(self): + cleaned_data = super(AddCustomServiceForm, self).clean() + try: + is_predefined = self.matches_predefined_service(cleaned_data) + except KeyError: + is_predefined = False + if is_predefined: + msg = _("""This service is available as a standard service. Please + use the 'Standard Services' page to enable it.""") + raise forms.ValidationError(msg) + return cleaned_data + + def save(self, request): + service = self.convert_formdata_to_service(self.cleaned_data) + try: + _run(['add-service', '--service', json.dumps(service)]) + messages.success(request, _('Added custom service')) + except ActionError as exception: + if "already exists" in str(exception): + messages.error(request, _('This service already exists')) + else: + raise diff --git a/plinth/modules/pagekite/pagekite.py b/plinth/modules/pagekite/pagekite.py deleted file mode 100644 index 132d7fff7..000000000 --- a/plinth/modules/pagekite/pagekite.py +++ /dev/null @@ -1,199 +0,0 @@ -# -# This file is part of Plinth. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as -# published by the Free Software Foundation, either version 3 of the -# License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -# - -""" -Plinth module for configuring PageKite service -""" - -from django import forms -from django.contrib import messages -from django.contrib.auth.decorators import login_required -from django.core import validators -from django.core.urlresolvers import reverse_lazy -from django.template.response import TemplateResponse -from gettext import gettext as _ -import logging - -from plinth import actions -from plinth import cfg -from plinth import package - - -LOGGER = logging.getLogger(__name__) - -subsubmenu = [{'url': reverse_lazy('pagekite:index'), - 'text': _('About PageKite')}, - {'url': reverse_lazy('pagekite:configure'), - 'text': _('Configure PageKite')}] - - -def init(): - """Intialize the PageKite module""" - menu = cfg.main_menu.get('apps:index') - menu.add_urlname(_('Public Visibility (PageKite)'), - 'glyphicon-flag', 'pagekite:index', 50) - - -@login_required -def index(request): - """Serve introduction page""" - return TemplateResponse(request, 'pagekite_introduction.html', - {'title': _('Public Visibility (PageKite)'), - 'subsubmenu': subsubmenu}) - - -class TrimmedCharField(forms.CharField): - """Trim the contents of a CharField""" - def clean(self, value): - """Clean and validate the field value""" - if value: - value = value.strip() - - return super(TrimmedCharField, self).clean(value) - - -class ConfigureForm(forms.Form): # pylint: disable-msg=W0232 - """Form to configure PageKite""" - enabled = forms.BooleanField(label=_('Enable PageKite'), - required=False) - - server = forms.CharField( - label=_('Server'), required=False, - help_text=_('Currently only pagekite.net server is supported'), - widget=forms.TextInput(attrs={'placeholder': 'pagekite.net', - 'disabled': 'disabled'})) - - kite_name = TrimmedCharField( - label=_('Kite name'), - help_text=_('Example: mybox1-myacc.pagekite.me'), - validators=[ - validators.RegexValidator(r'^[\w-]{1,63}(\.[\w-]{1,63})*$', - _('Invalid kite name'))]) - - kite_secret = TrimmedCharField( - label=_('Kite secret'), - help_text=_('A secret associated with the kite or the default secret \ -for your account if no secret is set on the kite')) - - http_enabled = forms.BooleanField( - label=_('Web Server (HTTP)'), required=False, - help_text=_('Site will be available at \ -http://mybox1-myacc.pagekite.me \ -')) - - ssh_enabled = forms.BooleanField( - label=_('Secure Shell (SSH)'), required=False, - help_text=_('See SSH client setup instructions')) - - -@login_required -@package.required(['pagekite']) -def configure(request): - """Serve the configuration form""" - status = get_status() - - form = None - - if request.method == 'POST': - form = ConfigureForm(request.POST, prefix='pagekite') - # pylint: disable-msg=E1101 - if form.is_valid(): - _apply_changes(request, status, form.cleaned_data) - status = get_status() - form = ConfigureForm(initial=status, prefix='pagekite') - else: - form = ConfigureForm(initial=status, prefix='pagekite') - - return TemplateResponse(request, 'pagekite_configure.html', - {'title': _('Configure PageKite'), - 'status': status, - 'form': form, - 'subsubmenu': subsubmenu}) - - -def get_status(): - """ - Return the current status of PageKite configuration by - executing various actions. - """ - status = {} - - # PageKite service enabled/disabled - output = _run(['get-status']) - status['enabled'] = (output.split()[0] == 'enabled') - - # PageKite kite details - output = _run(['get-kite']) - kite_details = output.split() - status['kite_name'] = kite_details[0] - status['kite_secret'] = kite_details[1] - - # Service status - status['service'] = {} - for service in ('http', 'ssh'): - output = _run(['get-service-status', service]) - status[service + '_enabled'] = (output.split()[0] == 'enabled') - - return status - - -def _apply_changes(request, old_status, new_status): - """Apply the changes to PageKite configuration""" - LOGGER.info('New status is - %s', new_status) - - if old_status != new_status: - _run(['stop']) - - if old_status['enabled'] != new_status['enabled']: - if new_status['enabled']: - _run(['set-status', 'enable']) - messages.success(request, _('PageKite enabled')) - else: - _run(['set-status', 'disable']) - messages.success(request, _('PageKite disabled')) - - if old_status['kite_name'] != new_status['kite_name'] or \ - old_status['kite_secret'] != new_status['kite_secret']: - _run(['set-kite', '--kite-name', new_status['kite_name'], - '--kite-secret', new_status['kite_secret']]) - messages.success(request, _('Kite details set')) - - for service in ['http', 'ssh']: - if old_status[service + '_enabled'] != \ - new_status[service + '_enabled']: - if new_status[service + '_enabled']: - _run(['set-service-status', service, 'enable']) - messages.success(request, _('Service enabled: {service}') - .format(service=service)) - else: - _run(['set-service-status', service, 'disable']) - messages.success(request, _('Service disabled: {service}') - .format(service=service)) - - if old_status != new_status: - _run(['start']) - - -def _run(arguments, superuser=True): - """Run a given command and raise exception if there was an error""" - command = 'pagekite-configure' - - if superuser: - return actions.superuser_run(command, arguments) - else: - return actions.run(command, arguments) diff --git a/plinth/modules/pagekite/templates/pagekite_configure.html b/plinth/modules/pagekite/templates/pagekite_configure.html index 0d737ba9b..8276a8994 100644 --- a/plinth/modules/pagekite/templates/pagekite_configure.html +++ b/plinth/modules/pagekite/templates/pagekite_configure.html @@ -30,17 +30,12 @@

PageKite Account

- {% include 'bootstrapform/field.html' with field=form.server %} - {% include 'bootstrapform/field.html' with field=form.kite_name %} - {% include 'bootstrapform/field.html' with field=form.kite_secret %} + {{ form.server|bootstrap_horizontal }} + {{ form.kite_name|bootstrap_horizontal }} + {{ form.kite_secret|bootstrap_horizontal }} -

Services

- {% include 'bootstrapform/field.html' with field=form.http_enabled %} - {% include 'bootstrapform/field.html' with field=form.ssh_enabled %}
- - - + {% endblock %} diff --git a/plinth/modules/pagekite/templates/pagekite_custom_services.html b/plinth/modules/pagekite/templates/pagekite_custom_services.html new file mode 100644 index 000000000..5efec6ef8 --- /dev/null +++ b/plinth/modules/pagekite/templates/pagekite_custom_services.html @@ -0,0 +1,104 @@ +{% extends "base.html" %} +{% comment %} +# +# This file is part of Plinth. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +{% endcomment %} + +{% load bootstrap %} + +{% load pagekite_extras %} + +{% block page_head %} + +{% endblock %} + +{% block content %} + + + +
+ +
+
+

Create a custom service

+ {{ form|bootstrap_horizontal:'col-lg-6' }} + {% csrf_token %} +
+
+ +
+
+
+
+ +
+

Existing custom services

+ {% if not custom_services %} + You don't have any Custom Services enabled + {% endif %} +
+ {% for service in custom_services %} + {% create_pagekite_service_url service kite_name as service_url %} +
+ + + {% if service_url|slice:":4" == "http" %} + {{ service_url }} + {% else %} + {{ service_url }} + {% endif %} +
+ connected to {{ service.backend_host }}:{{ service.backend_port }} +
+
+
+
+ {% csrf_token %} + {{ service.form.as_p }} +
+ +
+
+ {% endfor %} +
+
+ +
+ +{% endblock %} + + diff --git a/plinth/modules/pagekite/templates/pagekite_introduction.html b/plinth/modules/pagekite/templates/pagekite_introduction.html index 211ff20c7..2e3781b5c 100644 --- a/plinth/modules/pagekite/templates/pagekite_introduction.html +++ b/plinth/modules/pagekite/templates/pagekite_introduction.html @@ -22,7 +22,7 @@

PageKite is a system for exposing {{ cfg.box_name }} services when you don't have a direct connection to the Internet. You only need this -service if your {{ cfg.box_name }} services are unreachable from the +if your {{ cfg.box_name }} services are unreachable from the rest of the Internet. This includes the following situations:

    @@ -41,11 +41,10 @@ rest of the Internet. This includes the following situations:

PageKite works around NAT, firewalls and IP-address limitations by -using a combination of tunnels and reverse proxies. Currently, -exposing web server and SSH server are supported. An intermediary -server with direct Internet access is required. Currently, only -pagekite.net server is supported and you will need an account -there. In future, it might be possible to use your buddy's +using a combination of tunnels and reverse proxies. You can use any +pagekite service provider, for example +pagekite.net. +In future it might be possible to use your buddy's {{ cfg.box_name }} for this.

diff --git a/plinth/modules/pagekite/templates/pagekite_standard_services.html b/plinth/modules/pagekite/templates/pagekite_standard_services.html new file mode 100644 index 000000000..53fbd99e4 --- /dev/null +++ b/plinth/modules/pagekite/templates/pagekite_standard_services.html @@ -0,0 +1,52 @@ +{% extends "base.html" %} +{% comment %} +# +# This file is part of Plinth. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +{% endcomment %} + +{% load bootstrap %} + +{% block page_head %} + +{% endblock %} + +{% block content %} + +

+ +
+ {{ form.http|bootstrap_horizontal:'col-lg-0' }} + {{ form.https|bootstrap_horizontal:'col-lg-0' }} + {{ form.ssh|bootstrap_horizontal:'col-lg-0' }} + {% csrf_token %} + +
+ +{% endblock %} + diff --git a/plinth/modules/pagekite/templatetags/pagekite_extras.py b/plinth/modules/pagekite/templatetags/pagekite_extras.py new file mode 100644 index 000000000..c6ce5ffcd --- /dev/null +++ b/plinth/modules/pagekite/templatetags/pagekite_extras.py @@ -0,0 +1,44 @@ +# +# This file is part of Plinth. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# + +from django import template +from plinth.modules.pagekite.util import prepare_service_for_display + +register = template.Library() + + +@register.assignment_tag +def create_pagekite_service_url(service, kite_name): + """Create a URL out of a pagekite service + + Parameters: - service: the service params dictionary + - kite_name: kite name from the pagekite configuration, not + from the service params + """ + # add extra information if it's missing + if 'subdomains' not in service: + service = prepare_service_for_display(service) + + urlparams = {'protocol': service['protocol']} + if service['subdomains']: + urlparams['kite_name'] = "*.%s" % kite_name + else: + urlparams['kite_name'] = kite_name + url = "{protocol}://{kite_name}".format(**urlparams) + if 'frontend_port' in service and service['frontend_port']: + url = "%s:%s" % (url, service['frontend_port']) + return url diff --git a/plinth/modules/pagekite/urls.py b/plinth/modules/pagekite/urls.py index 7fa6c9918..32c13548d 100644 --- a/plinth/modules/pagekite/urls.py +++ b/plinth/modules/pagekite/urls.py @@ -20,10 +20,23 @@ URLs for the PageKite module """ from django.conf.urls import patterns, url +from django.contrib.auth.decorators import login_required + +from .views import StandardServiceView, CustomServiceView, ConfigurationView, \ + DeleteServiceView, index urlpatterns = patterns( # pylint: disable-msg=C0103 - 'plinth.modules.pagekite.pagekite', - url(r'^apps/pagekite/$', 'index', name='index'), - url(r'^apps/pagekite/configure/$', 'configure', name='configure'), + 'plinth.modules.pagekite.views', + url(r'^apps/pagekite/$', login_required(index), name='index'), + url(r'^apps/pagekite/configure/$', + login_required(ConfigurationView.as_view()), name='configure'), + url(r'^apps/pagekite/services/standard$', + login_required(StandardServiceView.as_view()), + name='standard-services'), + url(r'^apps/pagekite/services/custom$', + login_required(CustomServiceView.as_view()), name='custom-services'), + url(r'^apps/pagekite/services/custom/delete$', + login_required(DeleteServiceView.as_view()), + name='delete-custom-service'), ) diff --git a/plinth/modules/pagekite/util.py b/plinth/modules/pagekite/util.py new file mode 100644 index 000000000..9dd01c834 --- /dev/null +++ b/plinth/modules/pagekite/util.py @@ -0,0 +1,147 @@ +# +# This file is part of Plinth. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# + +from gettext import gettext as _ +import json +import logging + +from plinth import actions + +LOGGER = logging.getLogger(__name__) + +# defaults for the credentials; @kitename acts as a placeholder and is +# understood (and replaced with the actual kitename) by pagekite. +BACKEND_HOST = 'localhost' +KITE_NAME = '@kitename' +KITE_SECRET = '@kitesecret' + +SERVICE_PARAMS = ['protocol', 'kitename', 'backend_host', 'backend_port', + 'secret'] + +# Predefined services are used to build the PredefinedServiceForm +# +# ATTENTION: When changing the params, make sure that the AddCustomServiceForm +# still recognizes when you try to add a service equal to a predefined one +PREDEFINED_SERVICES = { + 'http': { + 'params': {'protocol': 'http', + 'kitename': KITE_NAME, + 'backend_port': '80', + 'backend_host': BACKEND_HOST, + 'secret': KITE_SECRET}, + 'label': _("Web Server (HTTP)"), + 'help_text': _("Site will be available at " + "http://{0}"), + }, + 'https': { + 'params': {'protocol': 'https', + 'kitename': KITE_NAME, + 'backend_port': '443', + 'backend_host': BACKEND_HOST, + 'secret': KITE_SECRET}, + 'label': _("Web Server (HTTPS)"), + 'help_text': _("Site will be available at " + "https://{0}"), + }, + 'ssh': { + 'params': {'protocol': 'raw/22', + 'kitename': KITE_NAME, + 'backend_port': '22', + 'backend_host': BACKEND_HOST, + 'secret': KITE_SECRET}, + 'label': _("Secure Shell (SSH)"), + 'help_text': _("See SSH client setup " + "instructions") + }, +} + + +def get_kite_details(): + output = _run(['get-kite']) + kite_details = output.split() + return {'kite_name': kite_details[0], + 'kite_secret': kite_details[1]} + + +def get_pagekite_config(): + """ + Return the current PageKite configuration by executing various actions. + """ + status = {} + + # PageKite service enabled/disabled + # This assumes that if pagekite is running it's also enabled as a service + output = _run(['is-running']) + status['enabled'] = (output.split()[0] == 'yes') + + # PageKite kite details + status.update(get_kite_details()) + + # PageKite frontend server + server = _run(['get-frontend']) + status['server'] = server.replace('\n', '') + + return status + + +def get_pagekite_services(): + """Get enabled services. Returns two values: + + 1. predefined services: {'http': False, 'ssh': True, 'https': True} + 2. custom services: [{'protocol': 'http', 'secret' 'nono', ..}, [..]} + """ + custom = [] + predefined = {} + # set all predefined services to 'disabled' by default + [predefined.update({proto: False}) for proto in PREDEFINED_SERVICES.keys()] + # now, search for the enabled ones + for serviceline in _run(['get-services']).split('\n'): + if not serviceline: # skip empty lines + continue + + service = json.loads(serviceline) + for name, predefined_service in PREDEFINED_SERVICES.items(): + if service == predefined_service['params']: + predefined[name] = True + break + else: + custom.append(service) + return predefined, custom + + +def prepare_service_for_display(service): + """ Add extra information that is used when displaying a service + + - protocol is split into 'protocol' and 'frontend_port' + - detect whether 'subdomains' are supported (as boolean) + """ + protocol = service['protocol'] + if '/' in protocol: + service['protocol'], service['frontend_port'] = protocol.split('/') + service['subdomains'] = service['kitename'].startswith('*.') + return service + + +def _run(arguments, superuser=True): + """Run a given command and raise exception if there was an error""" + command = 'pagekite' + + if superuser: + return actions.superuser_run(command, arguments) + else: + return actions.run(command, arguments) diff --git a/plinth/modules/pagekite/views.py b/plinth/modules/pagekite/views.py new file mode 100644 index 000000000..185ed391d --- /dev/null +++ b/plinth/modules/pagekite/views.py @@ -0,0 +1,133 @@ +# +# This file is part of Plinth. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# + +from gettext import gettext as _ +from django.core.urlresolvers import reverse, reverse_lazy +from django.http.response import HttpResponseRedirect +from django.template.response import TemplateResponse +from django.utils.decorators import method_decorator +from django.views.generic import View, TemplateView +from django.views.generic.edit import FormView + +from plinth import package +from .util import get_pagekite_config, get_pagekite_services, \ + get_kite_details, prepare_service_for_display +from .forms import ConfigurationForm, StandardServiceForm, \ + AddCustomServiceForm, DeleteCustomServiceForm + + +required_packages = ('pagekite', 'augeas-tools', 'python-augeas') +subsubmenu = [{'url': reverse_lazy('pagekite:index'), + 'text': _('About PageKite')}, + {'url': reverse_lazy('pagekite:configure'), + 'text': _('Configure PageKite')}, + {'url': reverse_lazy('pagekite:standard-services'), + 'text': _('Standard Services')}, + {'url': reverse_lazy('pagekite:custom-services'), + 'text': _('Custom Services')}] + + +def index(request): + """Serve introduction page""" + return TemplateResponse(request, 'pagekite_introduction.html', + {'title': _('Public Visibility (PageKite)'), + 'subsubmenu': subsubmenu}) + + +class ContextMixin(object): + """Mixin to add 'subsubmenu' and 'title' to the context. + + Also adds the requirement of all necessary packages to be installed + """ + def get_context_data(self, **kwargs): + """Use self.title and the module-level subsubmenu""" + context = super(ContextMixin, self).get_context_data(**kwargs) + context['title'] = getattr(self, 'title', '') + context['subsubmenu'] = subsubmenu + return context + + @method_decorator(package.required(required_packages)) + def dispatch(self, *args, **kwargs): + return super(ContextMixin, self).dispatch(*args, **kwargs) + + +class DeleteServiceView(ContextMixin, View): + def post(self, request): + form = DeleteCustomServiceForm(request.POST) + if form.is_valid(): + form.delete(request) + return HttpResponseRedirect(reverse('pagekite:custom-services')) + + +class CustomServiceView(ContextMixin, TemplateView): + template_name = 'pagekite_custom_services.html' + + def get_context_data(self, *args, **kwargs): + context = super(CustomServiceView, self).get_context_data(*args, + **kwargs) + unused, custom_services = get_pagekite_services() + for service in custom_services: + service['form'] = AddCustomServiceForm(initial=service) + context['custom_services'] = [prepare_service_for_display(service) + for service in custom_services] + context.update(get_kite_details()) + return context + + def get(self, request, *args, **kwargs): + context = self.get_context_data(**kwargs) + form = AddCustomServiceForm(prefix="custom") + context['form'] = form + return self.render_to_response(context) + + def post(self, request): + form = AddCustomServiceForm(request.POST, prefix="custom") + if form.is_valid(): + form.save(request) + form = AddCustomServiceForm(prefix="custom") + + context = self.get_context_data() + context['form'] = form + + return self.render_to_response(context) + + +class StandardServiceView(ContextMixin, FormView): + template_name = 'pagekite_standard_services.html' + title = 'PageKite Standard Services' + form_class = StandardServiceForm + success_url = reverse_lazy('pagekite:standard-services') + + def get_initial(self): + return get_pagekite_services()[0] + + def form_valid(self, form): + form.save(self.request) + return super(StandardServiceView, self).form_valid(form) + + +class ConfigurationView(ContextMixin, FormView): + template_name = 'pagekite_configure.html' + form_class = ConfigurationForm + prefix = 'pagekite' + success_url = reverse_lazy('pagekite:configure') + + def get_initial(self): + return get_pagekite_config() + + def form_valid(self, form): + form.save(self.request) + return super(ConfigurationView, self).form_valid(form) diff --git a/plinth/tests/test_pagekite.py b/plinth/tests/test_pagekite.py new file mode 100644 index 000000000..7463f25f5 --- /dev/null +++ b/plinth/tests/test_pagekite.py @@ -0,0 +1,55 @@ +#!/usr/bin/python3 +# +# This file is part of Plinth. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# + +import unittest + + +class TestPagekiteActions(unittest.TestCase): + """Test-cases for the pagekite action utils""" + _tests = [ + { + 'line': 'https/8080:*.@kitename:localhost:8080:@kitesecret', + 'params': {'kitename': '*.@kitename', 'backend_host': 'localhost', + 'secret': '@kitesecret', 'protocol': 'https/8080', + 'backend_port': '8080'} + }, + { + 'line': 'https:*.@kitename:localhost:80:@kitesecret', + 'params': {'protocol': 'https', + 'kitename': '*.@kitename', + 'backend_port': '80', + 'backend_host': 'localhost', + 'secret': '@kitesecret'} + }, + { + 'line': 'raw/22:@kitename:localhost:22:@kitesecret', + 'params': {'protocol': 'raw/22', + 'kitename': '@kitename', + 'backend_port': '22', + 'backend_host': 'localhost', + 'secret': '@kitesecret'} + }, + ] + + @unittest.skip('Use this test once the function is in the pagekite module ' + 'instead of actions/pagekite_util.py') + def test_convert_service_to_string(self): + """ Test deconstructing parameter dictionaries into strings """ + for test in self._tests: + service_string = convert_service_to_string(test['params']) + self.assertEqual(test['line'], service_string) diff --git a/setup.py b/setup.py index 537b438f4..f71dcbc81 100755 --- a/setup.py +++ b/setup.py @@ -140,6 +140,10 @@ setuptools.setup( glob.glob(os.path.join('actions', '*'))), ('/usr/share/man/man1', ['doc/plinth.1']), ('/etc/plinth', ['data/etc/plinth/plinth.config']), + ('/usr/share/augeas/lenses', + ['data/usr/share/augeas/lenses/pagekite.aug']), + ('/usr/share/augeas/lenses/tests', + ['data/usr/share/augeas/lenses/tests/test_pagekite.aug']), ('/etc/plinth/modules-enabled', glob.glob(os.path.join('data/etc/plinth/modules-enabled', '*')))],