diff --git a/action_map.yml b/action_map.yml index 4bdd34d8..15c86191 100644 --- a/action_map.yml +++ b/action_map.yml @@ -51,6 +51,7 @@ user: ### user_list() list: action_help: List users + api: GET /user/list arguments: --fields: help: fields to fetch @@ -68,6 +69,7 @@ user: ### user_create() create: action_help: Create user + api: POST /user arguments: -u: full: --username @@ -93,6 +95,7 @@ user: ### user_delete() delete: action_help: Delete user + api: DELETE /user arguments: -u: full: --users @@ -106,6 +109,7 @@ user: ### user_update() update: action_help: Update user informations + api: PUT /user arguments: username: help: Username of user to update @@ -139,6 +143,7 @@ user: ### user_info() info: action_help: Get user informations + api: GET /user arguments: user-or-mail: help: Username or mail to get informations @@ -154,6 +159,7 @@ domain: ### domain_list() list: action_help: List domains + api: GET /domain/list arguments: -f: full: --filter @@ -168,6 +174,7 @@ domain: ### domain_add() add: action_help: Create a custom domain + api: POST /domain arguments: domains: help: Domain name to add @@ -181,6 +188,7 @@ domain: ### domain_remove() remove: action_help: Delete domains + api: DELETE /domain arguments: domains: help: Domain(s) to delete @@ -190,18 +198,12 @@ domain: ### domain_info() info: action_help: Get domain informations + api: GET /domain arguments: domain: help: "" pattern: '^([a-zA-Z0-9]{1}([a-zA-Z0-9\-]*[a-zA-Z0-9])*)(\.[a-zA-Z0-9]{1}([a-zA-Z0-9\-]*[a-zA-Z0-9])*)*(\.[a-zA-Z]{1}([a-zA-Z0-9\-]*[a-zA-Z0-9])*)$' - ### domain_renewcert() - renewcert: - action_help: Renew domain certificate - arguments: - domain: - help: "" - pattern: '^([a-zA-Z0-9]{1}([a-zA-Z0-9\-]*[a-zA-Z0-9])*)(\.[a-zA-Z0-9]{1}([a-zA-Z0-9\-]*[a-zA-Z0-9])*)*(\.[a-zA-Z]{1}([a-zA-Z0-9\-]*[a-zA-Z0-9])*)$' ############################# # App # @@ -213,6 +215,7 @@ app: ### app_fetchlist() fetchlist: action_help: Fetch application list from app server + api: PUT /app/lists arguments: -u: full: --url @@ -224,10 +227,12 @@ app: ### app_listlists() listlists: action_help: List fetched lists + api: GET /app/lists ### app_removelist() removelist: action_help: Remove list from the repositories + api: DELETE /app/lists arguments: -n: full: --name @@ -238,6 +243,7 @@ app: ### app_list() list: action_help: List apps + api: GET /app/list arguments: -l: full: --limit @@ -256,6 +262,7 @@ app: ### app_map() map: action_help: List apps by domain + api: GET /app/map arguments: -a: full: --app @@ -268,6 +275,7 @@ app: ### app_install() TODO: Write help install: action_help: Install apps + api: POST /app arguments: app: help: App to install @@ -289,6 +297,7 @@ app: ### app_remove() TODO: Write help remove: action_help: Remove app + api: DELETE /app arguments: app: help: App(s) to delete @@ -300,6 +309,7 @@ app: ### app_upgrade() upgrade: action_help: Upgrade app + api: PUT /app arguments: app: help: App(s) to upgrade (default all) @@ -318,6 +328,7 @@ app: ### app_info() TODO: Write help info: action_help: Get app informations + api: GET /app arguments: app: help: App ID @@ -333,6 +344,7 @@ app: ### app_addaccess() TODO: Write help addaccess: action_help: Grant access right to users (everyone by default) + api: PUT /app/access arguments: apps: nargs: "+" @@ -343,6 +355,7 @@ app: ### app_removeaccess() TODO: Write help removeaccess: action_help: Revoke access right to users (everyone by default) + api: DELETE /app/access arguments: apps: nargs: "+" @@ -352,46 +365,20 @@ app: ############################# -# Repository # +# Backup # ############################# -repo: - category_help: Manage app repositories +backup: + category_help: Manage backups actions: - ### repo_list() - list: - action_help: List repositories + ### backup_init() + init: + action_help: Init Tahoe-LAFS configuration + api: POST /backup/init arguments: - -f: - full: --filter - help: LDAP filter used to search - -l: - full: --limit - help: Maximum number of repository fetched - -o: - full: --offset - help: Starting number for repository fetching - - ### repo_add() - add: - action_help: Add app repository - arguments: - url: - help: URL of the repository - -n: - full: --name - help: Unique name of the repository - - ### repo_remove() - remove: - action_help: Remove repository - arguments: - repo: - help: Name or URL of the repository - - ### repo_update() - update: - action_help: Update app list from the repositories + --helper: + help: Init as a helper node rather than a "helped" one + action: store_true ############################# @@ -466,10 +453,12 @@ firewall: ### firewall_list() list: action_help: List all firewall rules + api: GET /firewall/list ### firewall_reload() reload: - action_help: Reload all firewall rules + action_help: Reload all firewall rules + api: PUT /firewall/list arguments: -u: full: --upnp @@ -478,6 +467,7 @@ firewall: ### firewall_allow() allow: action_help: Allow connection port/protocol + api: POST /firewall/port arguments: port: help: Port to open @@ -500,6 +490,7 @@ firewall: ### firewall_disallow() disallow: action_help: Disallow connection + api: DELETE /firewall/port arguments: port: help: Port to open @@ -522,21 +513,25 @@ firewall: ### firewall_installupnp() installupnp: action_help: Add upnp cron + api: POST /firewall/upnp ### firewall_removeupnp() removeupnp: action_help: Remove upnp cron + api: DELETE /firewall/upnp ### firewall_stop() stop: action_help: Stop iptables and ip6tables + api: DELETE /firewall ### firewall_checkupnp() checkupnp: action_help: check if UPNP is install or not (0 yes 1 no) + api: GET /firewall/upnp ############################# @@ -549,6 +544,7 @@ dyndns: ### dyndns_subscribe() subscribe: action_help: Subscribe to a DynDNS service + api: POST /dyndns arguments: --subscribe-host: help: Dynette HTTP API to subscribe to @@ -563,6 +559,7 @@ dyndns: ### dyndns_update() update: action_help: Update IP on DynDNS platform + api: PUT /dyndns arguments: --dyn-host: help: Dynette DNS server to inform @@ -580,10 +577,12 @@ dyndns: ### dyndns_installcron() installcron: action_help: Install IP update cron + api: POST /dyndns/cron ### dyndns_removecron() removecron: action_help: Remove IP update cron + api: DELETE /dyndns/cron ############################# @@ -596,10 +595,12 @@ tools: ### tools_ldapinit() ldapinit: action_help: YunoHost LDAP initialization + api: POST /ldap ### tools_adminpw() adminpw: action_help: Change admin password + api: PUT /adminpw arguments: -o: full: --old-password @@ -613,6 +614,7 @@ tools: ### tools_maindomain() maindomain: action_help: Main domain change tool + api: PUT /domain/main arguments: -o: full: --old-domain @@ -625,6 +627,7 @@ tools: ### tools_postinstall() postinstall: action_help: YunoHost post-install + api: POST /postinstall arguments: -d: full: --domain diff --git a/ldap_scheme.yml b/ldap_scheme.yml index d5c5a7d7..adcd46b7 100644 --- a/ldap_scheme.yml +++ b/ldap_scheme.yml @@ -23,7 +23,7 @@ parents: - organizationalUnit - top -childs: +children: cn=admins,ou=groups: cn: admins gidNumber: "4001" diff --git a/sudo_ldap_scheme.yml b/sudo_ldap_scheme.yml new file mode 100644 index 00000000..546e03f5 --- /dev/null +++ b/sudo_ldap_scheme.yml @@ -0,0 +1,26 @@ +parents: + ou=sudo: + ou: sudo + objectClass: + - organizationalUnit + - top +children: + cn=admin,ou=sudo: + cn: admin + sudoUser: admin + sudoHost: ALL + sudoCommand: ALL + sudoOption: "!authenticate" + objectClass: + - sudoRole + - top + + cn=yunohost-admin,ou=sudo: + cn: yunohost-admin + sudoUser: yunohost-admin + sudoHost: ALL + sudoCommand: /usr/bin/yunohost + sudoOption: "!authenticate" + objectClass: + - sudoRole + - top diff --git a/txrestapi/.gitignore b/txrestapi/.gitignore new file mode 100644 index 00000000..2193e643 --- /dev/null +++ b/txrestapi/.gitignore @@ -0,0 +1,3 @@ +_trial_temp +txrestapi.egg-info +txrestapi/_trial_temp diff --git a/txrestapi/README.rst b/txrestapi/README.rst new file mode 100644 index 00000000..caf2cf32 --- /dev/null +++ b/txrestapi/README.rst @@ -0,0 +1,146 @@ +============ +Introduction +============ + +``txrestapi`` makes it easier to create Twisted REST API services. Normally, one +would create ``Resource`` subclasses defining each segment of a path; this is +cubersome to implement and results in output that isn't very readable. +``txrestapi`` provides an ``APIResource`` class allowing complex mapping of path to +callback (a la Django) with a readable decorator. + +=============================== +Basic URL callback registration +=============================== + +First, let's create a bare API service:: + + >>> from txrestapi.resource import APIResource + >>> api = APIResource() + +and a web server to serve it:: + + >>> from twisted.web.server import Site + >>> from twisted.internet import reactor + >>> site = Site(api, timeout=None) + +and a function to make it easy for us to make requests (only for doctest +purposes; normally you would of course use ``reactor.listenTCP(8080, site)``):: + + >>> from twisted.web.server import Request + >>> class FakeChannel(object): + ... transport = None + >>> def makeRequest(method, path): + ... req = Request(FakeChannel(), None) + ... req.prepath = req.postpath = None + ... req.method = method; req.path = path + ... resource = site.getChildWithDefault(path, req) + ... return resource.render(req) + +We can now register callbacks for paths we care about. We can provide different +callbacks for different methods; they must accept ``request`` as the first +argument:: + + >>> def get_callback(request): return 'GET callback' + >>> api.register('GET', '^/path/to/method', get_callback) + >>> def post_callback(request): return 'POST callback' + >>> api.register('POST', '^/path/to/method', post_callback) + +Then, when we make a call, the request is routed to the proper callback:: + + >>> print makeRequest('GET', '/path/to/method') + GET callback + >>> print makeRequest('POST', '/path/to/method') + POST callback + +We can register multiple callbacks for different requests; the first one that +matches wins:: + + >>> def default_callback(request): + ... return 'Default callback' + >>> api.register('GET', '^/.*$', default_callback) # Matches everything + >>> print makeRequest('GET', '/path/to/method') + GET callback + >>> print makeRequest('GET', '/path/to/different/method') + Default callback + +Our default callback, however, will only match GET requests. For a true default +callback, we can either register callbacks for each method individually, or we +can use ALL:: + + >>> api.register('ALL', '^/.*$', default_callback) + >>> print makeRequest('PUT', '/path/to/method') + Default callback + >>> print makeRequest('DELETE', '/path/to/method') + Default callback + >>> print makeRequest('GET', '/path/to/method') + GET callback + +Let's unregister all references to the default callback so it doesn't interfere +with later tests (default callbacks should, of course, always be registered +last, so they don't get called before other callbacks):: + + >>> api.unregister(callback=default_callback) + +============= +URL Arguments +============= + +Since callbacks accept ``request``, they have access to POST data or query +arguments, but we can also pull arguments out of the URL by using named groups +in the regular expression (similar to Django). These will be passed into the +callback as keyword arguments:: + + >>> def get_info(request, id): + ... return 'Information for id %s' % id + >>> api.register('GET', '/(?P[^/]+)/info$', get_info) + >>> print makeRequest('GET', '/someid/info') + Information for id someid + +Bear in mind all arguments will come in as strings, so code should be +accordingly defensive. + +================ +Decorator syntax +================ + +Registration via the ``register()`` method is somewhat awkward, so decorators +are provided making it much more straightforward. :: + + >>> from txrestapi.methods import GET, POST, PUT, ALL + >>> class MyResource(APIResource): + ... + ... @GET('^/(?P[^/]+)/info') + ... def get_info(self, request, id): + ... return 'Info for id %s' % id + ... + ... @PUT('^/(?P[^/]+)/update') + ... @POST('^/(?P[^/]+)/update') + ... def set_info(self, request, id): + ... return "Setting info for id %s" % id + ... + ... @ALL('^/') + ... def default_view(self, request): + ... return "I match any URL" + +Again, registrations occur top to bottom, so methods should be written from +most specific to least. Also notice that one can use the decorator syntax as +one would expect to register a method as the target for two URLs :: + + >>> site = Site(MyResource(), timeout=None) + >>> print makeRequest('GET', '/anid/info') + Info for id anid + >>> print makeRequest('PUT', '/anid/update') + Setting info for id anid + >>> print makeRequest('POST', '/anid/update') + Setting info for id anid + >>> print makeRequest('DELETE', '/anid/delete') + I match any URL + +====================== +Callback return values +====================== + +You can return Resource objects from a callback if you wish, allowing you to +have APIs that send you to other kinds of resources, or even other APIs. +Normally, however, you'll most likely want to return strings, which will be +wrapped in a Resource object for convenience. diff --git a/txrestapi/setup.cfg b/txrestapi/setup.cfg new file mode 100644 index 00000000..fa9fe18f --- /dev/null +++ b/txrestapi/setup.cfg @@ -0,0 +1 @@ +[egg_info] diff --git a/txrestapi/setup.py b/txrestapi/setup.py new file mode 100644 index 00000000..1ca9045a --- /dev/null +++ b/txrestapi/setup.py @@ -0,0 +1,26 @@ +from setuptools import setup, find_packages +import sys, os + +version = '0.1' + +setup(name='txrestapi', + version=version, + description="Easing the creation of REST API services in Python", + long_description="""\ +""", + classifiers=[], # Get strings from http://pypi.python.org/pypi?%3Aaction=list_classifiers + keywords='', + author='Ian McCracken', + author_email='ian.mccracken@gmail.com', + url='http://github.com/iancmcc/txrestapi', + license='MIT', + packages=find_packages(exclude=['ez_setup', 'examples', 'tests']), + include_package_data=True, + zip_safe=False, + install_requires=[ + # -*- Extra requirements: -*- + ], + entry_points=""" + # -*- Entry points: -*- + """, + ) diff --git a/txrestapi/txrestapi/__init__.py b/txrestapi/txrestapi/__init__.py new file mode 100644 index 00000000..792d6005 --- /dev/null +++ b/txrestapi/txrestapi/__init__.py @@ -0,0 +1 @@ +# diff --git a/txrestapi/txrestapi/methods.py b/txrestapi/txrestapi/methods.py new file mode 100644 index 00000000..8d5a89d9 --- /dev/null +++ b/txrestapi/txrestapi/methods.py @@ -0,0 +1,29 @@ +from zope.interface.advice import addClassAdvisor + +def method_factory_factory(method): + def factory(regex): + _f = {} + def decorator(f): + _f[f.__name__] = f + return f + def advisor(cls): + def wrapped(f): + def __init__(self, *args, **kwargs): + f(self, *args, **kwargs) + for func_name in _f: + orig = _f[func_name] + func = getattr(self, func_name) + if func.im_func==orig: + self.register(method, regex, func) + return __init__ + cls.__init__ = wrapped(cls.__init__) + return cls + addClassAdvisor(advisor) + return decorator + return factory + +ALL = method_factory_factory('ALL') +GET = method_factory_factory('GET') +POST = method_factory_factory('POST') +PUT = method_factory_factory('PUT') +DELETE = method_factory_factory('DELETE') diff --git a/txrestapi/txrestapi/resource.py b/txrestapi/txrestapi/resource.py new file mode 100644 index 00000000..322acd5f --- /dev/null +++ b/txrestapi/txrestapi/resource.py @@ -0,0 +1,65 @@ +import re +from itertools import ifilter +from functools import wraps +from twisted.web.resource import Resource, NoResource + +class _FakeResource(Resource): + _result = '' + isLeaf = True + def __init__(self, result): + Resource.__init__(self) + self._result = result + def render(self, request): + return self._result + + +def maybeResource(f): + @wraps(f) + def inner(*args, **kwargs): + result = f(*args, **kwargs) + if not isinstance(result, Resource): + result = _FakeResource(result) + return result + return inner + + +class APIResource(Resource): + + _registry = None + + def __init__(self, *args, **kwargs): + Resource.__init__(self, *args, **kwargs) + self._registry = [] + + def _get_callback(self, request): + filterf = lambda t:t[0] in (request.method, 'ALL') + path_to_check = getattr(request, '_remaining_path', request.path) + for m, r, cb in ifilter(filterf, self._registry): + result = r.search(path_to_check) + if result: + request._remaining_path = path_to_check[result.span()[1]:] + return cb, result.groupdict() + return None, None + + def register(self, method, regex, callback): + self._registry.append((method, re.compile(regex), callback)) + + def unregister(self, method=None, regex=None, callback=None): + if regex is not None: regex = re.compile(regex) + for m, r, cb in self._registry[:]: + if not method or (method and m==method): + if not regex or (regex and r==regex): + if not callback or (callback and cb==callback): + self._registry.remove((m, r, cb)) + + def getChild(self, name, request): + r = self.children.get(name, None) + if r is None: + # Go into the thing + callback, args = self._get_callback(request) + if callback is None: + return NoResource() + else: + return maybeResource(callback)(request, **args) + else: + return r diff --git a/txrestapi/txrestapi/service.py b/txrestapi/txrestapi/service.py new file mode 100644 index 00000000..78d031a8 --- /dev/null +++ b/txrestapi/txrestapi/service.py @@ -0,0 +1,7 @@ +from twisted.web.server import Site +from .resource import APIResource + + +class RESTfulService(Site): + def __init__(self, port=8080): + self.root = APIResource() diff --git a/txrestapi/txrestapi/tests.py b/txrestapi/txrestapi/tests.py new file mode 100644 index 00000000..3ee1b53c --- /dev/null +++ b/txrestapi/txrestapi/tests.py @@ -0,0 +1,194 @@ +import txrestapi +__package__="txrestapi" +import re +import os.path +from twisted.internet import reactor +from twisted.internet.defer import inlineCallbacks +from twisted.web.resource import Resource, NoResource +from twisted.web.server import Request, Site +from twisted.web.client import getPage +from twisted.trial import unittest +from .resource import APIResource +from .methods import GET, PUT + +class FakeChannel(object): + transport = None + +def getRequest(method, url): + req = Request(FakeChannel(), None) + req.method = method + req.path = url + return req + +class APIResourceTest(unittest.TestCase): + + def test_returns_normal_resources(self): + r = APIResource() + a = Resource() + r.putChild('a', a) + req = Request(FakeChannel(), None) + a_ = r.getChild('a', req) + self.assertEqual(a, a_) + + def test_registry(self): + compiled = re.compile('regex') + r = APIResource() + r.register('GET', 'regex', None) + self.assertEqual([x[0] for x in r._registry], ['GET']) + self.assertEqual(r._registry[0], ('GET', compiled, None)) + + def test_method_matching(self): + r = APIResource() + r.register('GET', 'regex', 1) + r.register('PUT', 'regex', 2) + r.register('GET', 'another', 3) + + req = getRequest('GET', 'regex') + result = r._get_callback(req) + self.assert_(result) + self.assertEqual(result[0], 1) + + req = getRequest('PUT', 'regex') + result = r._get_callback(req) + self.assert_(result) + self.assertEqual(result[0], 2) + + req = getRequest('GET', 'another') + result = r._get_callback(req) + self.assert_(result) + self.assertEqual(result[0], 3) + + req = getRequest('PUT', 'another') + result = r._get_callback(req) + self.assertEqual(result, (None, None)) + + def test_callback(self): + marker = object() + def cb(request): + return marker + r = APIResource() + r.register('GET', 'regex', cb) + req = getRequest('GET', 'regex') + result = r.getChild('regex', req) + self.assertEqual(result.render(req), marker) + + def test_longerpath(self): + marker = object() + r = APIResource() + def cb(request): + return marker + r.register('GET', '/regex/a/b/c', cb) + req = getRequest('GET', '/regex/a/b/c') + result = r.getChild('regex', req) + self.assertEqual(result.render(req), marker) + + def test_args(self): + r = APIResource() + def cb(request, **kwargs): + return kwargs + r.register('GET', '/(?P[^/]*)/a/(?P[^/]*)/c', cb) + req = getRequest('GET', '/regex/a/b/c') + result = r.getChild('regex', req) + self.assertEqual(sorted(result.render(req).keys()), ['a', 'b']) + + def test_order(self): + r = APIResource() + def cb1(request, **kwargs): + kwargs.update({'cb1':True}) + return kwargs + def cb(request, **kwargs): + return kwargs + # Register two regexes that will match + r.register('GET', '/(?P[^/]*)/a/(?P[^/]*)/c', cb1) + r.register('GET', '/(?P[^/]*)/a/(?P[^/]*)', cb) + req = getRequest('GET', '/regex/a/b/c') + result = r.getChild('regex', req) + # Make sure the first one got it + self.assert_('cb1' in result.render(req)) + + def test_no_resource(self): + r = APIResource() + r.register('GET', '^/(?P[^/]*)/a/(?P[^/]*)$', None) + req = getRequest('GET', '/definitely/not/a/match') + result = r.getChild('regex', req) + self.assert_(isinstance(result, NoResource)) + + def test_all(self): + r = APIResource() + def get_cb(r): return 'GET' + def put_cb(r): return 'PUT' + def all_cb(r): return 'ALL' + r.register('GET', '^path', get_cb) + r.register('ALL', '^path', all_cb) + r.register('PUT', '^path', put_cb) + # Test that the ALL registration picks it up before the PUT one + for method in ('GET', 'PUT', 'ALL'): + req = getRequest(method, 'path') + result = r.getChild('path', req) + self.assertEqual(result.render(req), 'ALL' if method=='PUT' else method) + + +class TestResource(Resource): + isLeaf = True + def render(self, request): + return 'aresource' + + +class TestAPI(APIResource): + + @GET('^/(?Ptest[^/]*)/?') + def _on_test_get(self, request, a): + return 'GET %s' % a + + @PUT('^/(?Ptest[^/]*)/?') + def _on_test_put(self, request, a): + return 'PUT %s' % a + + @GET('^/gettest') + def _on_gettest(self, request): + return TestResource() + + +class DecoratorsTest(unittest.TestCase): + def _listen(self, site): + return reactor.listenTCP(0, site, interface="127.0.0.1") + + def setUp(self): + r = TestAPI() + site = Site(r, timeout=None) + self.port = self._listen(site) + self.portno = self.port.getHost().port + + def tearDown(self): + return self.port.stopListening() + + def getURL(self, path): + return "http://127.0.0.1:%d/%s" % (self.portno, path) + + @inlineCallbacks + def test_get(self): + url = self.getURL('test_thing/') + result = yield getPage(url, method='GET') + self.assertEqual(result, 'GET test_thing') + + @inlineCallbacks + def test_put(self): + url = self.getURL('test_thing/') + result = yield getPage(url, method='PUT') + self.assertEqual(result, 'PUT test_thing') + + @inlineCallbacks + def test_resource_wrapper(self): + url = self.getURL('gettest') + result = yield getPage(url, method='GET') + self.assertEqual(result, 'aresource') + + +def test_suite(): + import unittest as ut + suite = unittest.TestSuite() + suite.addTest(ut.makeSuite(DecoratorsTest)) + suite.addTest(ut.makeSuite(APIResourceTest)) + suite.addTest(unittest.doctest.DocFileSuite(os.path.join('..', 'README.rst'))) + return suite + diff --git a/yunohost b/yunohost index 3244e31e..cc86f78f 100755 --- a/yunohost +++ b/yunohost @@ -38,121 +38,12 @@ if not __debug__: gettext.install('YunoHost') try: - from yunohost import YunoHostError, YunoHostLDAP, str_to_func, colorize, pretty_print_dict, display_error, validate, win + from yunohost import YunoHostError, YunoHostLDAP, str_to_func, colorize, pretty_print_dict, display_error, validate, win, parse_dict except ImportError: sys.stderr.write('Error: Yunohost CLI Require YunoHost lib\n') sys.exit(1) -def parse_dict(action_map): - """ - Turn action dictionnary to parser, subparsers and arguments - - Keyword arguments: - action_map -- Multi-level dictionnary of categories/actions/arguments list - - Returns: - Namespace of args - - """ - # Intialize parsers - parsers = subparsers_category = subparsers_action = {} - parsers['general'] = argparse.ArgumentParser() - subparsers = parsers['general'].add_subparsers() - new_args = [] - patterns = {} - - # Add general arguments - for arg_name, arg_params in action_map['general_arguments'].items(): - if 'full' in arg_params: - arg_names = [arg_name, arg_params['full']] - arg_fullname = arg_params['full'] - del arg_params['full'] - else: arg_names = [arg_name] - parsers['general'].add_argument(*arg_names, **arg_params) - - del action_map['general_arguments'] - - # Split categories into subparsers - for category, category_params in action_map.items(): - if 'category_help' not in category_params: category_params['category_help'] = '' - subparsers_category[category] = subparsers.add_parser(category, help=category_params['category_help']) - subparsers_action[category] = subparsers_category[category].add_subparsers() - # Split actions - if 'actions' in category_params: - for action, action_params in category_params['actions'].items(): - if 'action_help' not in action_params: action_params['action_help'] = '' - parsers[category + '_' + action] = subparsers_action[category].add_parser(action, help=action_params['action_help']) - # Set the action s related function - parsers[category + '_' + action].set_defaults( - func=str_to_func('yunohost_' + category + '.' - + category + '_' + action)) - # Add arguments - if 'arguments' in action_params: - for arg_name, arg_params in action_params['arguments'].items(): - arg_fullname = False - - if 'password' in arg_params: - if arg_params['password']: is_password = True - del arg_params['password'] - else: is_password = False - - if 'full' in arg_params: - arg_names = [arg_name, arg_params['full']] - arg_fullname = arg_params['full'] - del arg_params['full'] - else: arg_names = [arg_name] - - if 'ask' in arg_params: - require_input = True - if '-h' in sys.argv or '--help' in sys.argv: - require_input = False - if (category != sys.argv[1]) or (action != sys.argv[2]): - require_input = False - for name in arg_names: - if name in sys.argv[2:]: require_input = False - - if require_input: - if is_password: - if os.isatty(1): - pwd1 = getpass.getpass(colorize(arg_params['ask'] + ': ', 'cyan')) - pwd2 = getpass.getpass(colorize('Retype ' + arg_params['ask'][0].lower() + arg_params['ask'][1:] + ': ', 'cyan')) - if pwd1 != pwd2: - raise YunoHostError(22, _("Passwords don't match")) - sys.exit(1) - else: - raise YunoHostError(22, _("Missing arguments") + ': ' + arg_name) - if arg_name[0] == '-': arg_extend = [arg_name, pwd1] - else: arg_extend = [pwd1] - else: - if os.isatty(1): - arg_value = raw_input(colorize(arg_params['ask'] + ': ', 'cyan')) - else: - raise YunoHostError(22, _("Missing arguments") + ': ' + arg_name) - if arg_name[0] == '-': arg_extend = [arg_name, arg_value] - else: arg_extend = [arg_value] - new_args.extend(arg_extend) - del arg_params['ask'] - - if 'pattern' in arg_params: - if (category == sys.argv[1]) and (action == sys.argv[2]): - if 'dest' in arg_params: name = arg_params['dest'] - elif arg_fullname: name = arg_fullname[2:] - else: name = arg_name - name = name.replace('-', '_') - patterns[name] = arg_params['pattern'] - del arg_params['pattern'] - - parsers[category + '_' + action].add_argument(*arg_names, **arg_params) - - args = parsers['general'].parse_args(sys.argv.extend(new_args)) - args_dict = vars(args) - for key, value in patterns.items(): - validate(value, args_dict[key]) - - return args - - def main(): """ Main instructions diff --git a/yunohost.py b/yunohost.py index 2fc0a4b9..fbaf00ec 100644 --- a/yunohost.py +++ b/yunohost.py @@ -9,11 +9,15 @@ except ImportError: sys.stderr.write('apt-get install python-ldap\n') sys.exit(1) import ldap.modlist as modlist +import yaml import json import re import getpass import random import string +import argparse +import gettext +import getpass if not __debug__: import traceback @@ -93,8 +97,8 @@ def win_msg(astr): global win if os.isatty(1): print('\n' + colorize(_("Success: "), 'green') + astr + '\n') - else: - win.append(astr) + + win.append(astr) @@ -444,3 +448,112 @@ class YunoHostLDAP(Singleton): else: raise YunoHostError(17, _('Attribute already exists') + ' "' + attr + '=' + value + '"') return True + + +def parse_dict(action_map): + """ + Turn action dictionnary to parser, subparsers and arguments + + Keyword arguments: + action_map -- Multi-level dictionnary of categories/actions/arguments list + + Returns: + Namespace of args + + """ + # Intialize parsers + parsers = subparsers_category = subparsers_action = {} + parsers['general'] = argparse.ArgumentParser() + subparsers = parsers['general'].add_subparsers() + new_args = [] + patterns = {} + + # Add general arguments + for arg_name, arg_params in action_map['general_arguments'].items(): + if 'full' in arg_params: + arg_names = [arg_name, arg_params['full']] + arg_fullname = arg_params['full'] + del arg_params['full'] + else: arg_names = [arg_name] + parsers['general'].add_argument(*arg_names, **arg_params) + + del action_map['general_arguments'] + + # Split categories into subparsers + for category, category_params in action_map.items(): + if 'category_help' not in category_params: category_params['category_help'] = '' + subparsers_category[category] = subparsers.add_parser(category, help=category_params['category_help']) + subparsers_action[category] = subparsers_category[category].add_subparsers() + # Split actions + if 'actions' in category_params: + for action, action_params in category_params['actions'].items(): + if 'action_help' not in action_params: action_params['action_help'] = '' + parsers[category + '_' + action] = subparsers_action[category].add_parser(action, help=action_params['action_help']) + # Set the action s related function + parsers[category + '_' + action].set_defaults( + func=str_to_func('yunohost_' + category + '.' + + category + '_' + action)) + # Add arguments + if 'arguments' in action_params: + for arg_name, arg_params in action_params['arguments'].items(): + arg_fullname = False + + if 'password' in arg_params: + if arg_params['password']: is_password = True + del arg_params['password'] + else: is_password = False + + if 'full' in arg_params: + arg_names = [arg_name, arg_params['full']] + arg_fullname = arg_params['full'] + del arg_params['full'] + else: arg_names = [arg_name] + + if 'ask' in arg_params: + require_input = True + if '-h' in sys.argv or '--help' in sys.argv: + require_input = False + if (category != sys.argv[1]) or (action != sys.argv[2]): + require_input = False + for name in arg_names: + if name in sys.argv[2:]: require_input = False + + if require_input: + if is_password: + if os.isatty(1): + pwd1 = getpass.getpass(colorize(arg_params['ask'] + ': ', 'cyan')) + pwd2 = getpass.getpass(colorize('Retype ' + arg_params['ask'][0].lower() + arg_params['ask'][1:] + ': ', 'cyan')) + if pwd1 != pwd2: + raise YunoHostError(22, _("Passwords don't match")) + sys.exit(1) + else: + raise YunoHostError(22, _("Missing arguments") + ': ' + arg_name) + if arg_name[0] == '-': arg_extend = [arg_name, pwd1] + else: arg_extend = [pwd1] + else: + if os.isatty(1): + arg_value = raw_input(colorize(arg_params['ask'] + ': ', 'cyan')) + else: + raise YunoHostError(22, _("Missing arguments") + ': ' + arg_name) + if arg_name[0] == '-': arg_extend = [arg_name, arg_value] + else: arg_extend = [arg_value] + new_args.extend(arg_extend) + del arg_params['ask'] + + if 'pattern' in arg_params: + if (category == sys.argv[1]) and (action == sys.argv[2]): + if 'dest' in arg_params: name = arg_params['dest'] + elif arg_fullname: name = arg_fullname[2:] + else: name = arg_name + name = name.replace('-', '_') + patterns[name] = arg_params['pattern'] + del arg_params['pattern'] + + parsers[category + '_' + action].add_argument(*arg_names, **arg_params) + + args = parsers['general'].parse_args(sys.argv.extend(new_args)) + args_dict = vars(args) + for key, value in patterns.items(): + validate(value, args_dict[key]) + + return args diff --git a/yunohost.tac b/yunohost.tac new file mode 100755 index 00000000..def54878 --- /dev/null +++ b/yunohost.tac @@ -0,0 +1,155 @@ +# -*- mode: python -*- +import os +import sys +import gettext +import ldap +import yaml +import json +from twisted.python import log +from twisted.web.server import Site +from twisted.web.resource import IResource +from twisted.web.guard import HTTPAuthSessionWrapper, BasicCredentialFactory +from twisted.internet import reactor, defer +from twisted.cred.portal import IRealm, Portal +from twisted.cred.checkers import ICredentialsChecker +from twisted.cred.credentials import IUsernamePassword +from twisted.cred.error import UnauthorizedLogin +from zope.interface import implements +from txrestapi.resource import APIResource +from yunohost import YunoHostError, YunoHostLDAP, str_to_func, colorize, pretty_print_dict, display_error, validate, win, parse_dict + +if not __debug__: + import traceback + +gettext.install('YunoHost') + +class LDAPHTTPAuth(): + implements (ICredentialsChecker) + + credentialInterfaces = IUsernamePassword, + + def requestAvatarId(self, credentials): + try: + if credentials.username != "admin": + raise YunoHostError(22, _("Invalid username") + ': ' + credentials.username) + YunoHostLDAP(password=credentials.password) + return credentials.username + + except Exception as e: + return defer.fail(UnauthorizedLogin()) + + +class SimpleRealm(object): + implements(IRealm) + + _api = None + + def __init__(self, api): + self._api = api + + def requestAvatar(self, avatarId, mind, *interfaces): + if IResource in interfaces: + return IResource, self._api, lambda: None + raise NotImplementedError() + +action_dict = {} + +def http_exec(request): + global win + dict = action_dict[request.method+' '+request.path] + if 'arguments' in dict: args = dict['arguments'] + else: args = {} + for arg, params in args.items(): + sanitized_key = arg.replace('-', '_') + if sanitized_key is not arg: + args[sanitized_key] = args[arg] + del args[arg] + arg = sanitized_key + if arg[0] == '_': + if 'nargs' not in params: + args[arg]['nargs'] = '*' + if 'full' in params: + new_key = params['full'][2:] + else: + new_key = arg[2:] + args[new_key] = args[arg] + del args[arg] + + try: + validated_args = {} + for key, value in request.args.items(): + if key in args: + # Validate args + if 'pattern' in args[key]: validate(args[key]['pattern'], value) + if 'nargs' not in args[key] or ('nargs' != '*' and 'nargs' != '+'): value = value[0] + if 'action' in args[key] and args[key]['action'] == 'store_true': + yes = ['true', 'True', 'yes', 'Yes'] + value = value in yes + validated_args[key] = value + + func = str_to_func(dict['function']) + with YunoHostLDAP(password=request.getPassword()): + result = func(**validated_args) + if result is None: + result = {} + if win: + result['win'] = win + win = [] + if request.method == 'POST': + request.setResponseCode(201, 'Created') + elif request.method == 'DELETE': + request.setResponseCode(204, 'No Content') + else: + request.setResponseCode(200, 'OK') + + except YunoHostError, error: + server_errors = [1, 111, 169] + client_errors = [13, 17, 22, 87, 122, 125, 167, 168] + if error.code in client_errors: + request.setResponseCode(400, 'Bad Request') + else: + request.setResponseCode(500, 'Internal Server Error') + result = { 'error' : error.message } + + request.setHeader('Content-Type', 'application/json') + return json.dumps(result) + + +def main(): + global action_dict + log.startLogging(sys.stdout) + api = APIResource() + + with open('action_map.yml') as f: + action_map = yaml.load(f) + + del action_map['general_arguments'] + for category, category_params in action_map.items(): + for action, action_params in category_params['actions'].items(): + if 'help' not in action_params: + action_params['help'] = '' + if 'api' not in action_params: + action_params['api'] = 'GET /'+ category +'/'+ action + method, path = action_params['api'].split(' ') + api.register(method, path, http_exec) + action_dict[action_params['api']] = { + 'function': 'yunohost_'+ category +'.'+ category +'_'+ action, + 'help' : action_params['help'] + } + if 'arguments' in action_params: + action_dict[action_params['api']]['arguments'] = action_params['arguments'] + + ldap_auth = LDAPHTTPAuth() + credentialFactory = BasicCredentialFactory("Restricted Area") + resource = HTTPAuthSessionWrapper(Portal(SimpleRealm(api), [ldap_auth]), [credentialFactory]) + try: + with open('/etc/yunohost/installed') as f: pass + except IOError: + resource = APIResource() + resource.register('POST', '/postinstall', http_exec) + reactor.listenTCP(6767, Site(resource, timeout=None)) + reactor.run() + + +if __name__ == '__main__': + main() diff --git a/yunohost_backup.py b/yunohost_backup.py new file mode 100644 index 00000000..c070be82 --- /dev/null +++ b/yunohost_backup.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- + +import os +import sys +import json +import yaml +import glob +from yunohost import YunoHostError, YunoHostLDAP, validate, colorize, win_msg + +def backup_init(helper=False): + """ + Init Tahoe-LAFS configuration + + Keyword arguments: + helper -- Create a helper node rather than a "helped" one + + Returns: + Win | Fail + + """ + pass diff --git a/yunohost_tools.py b/yunohost_tools.py index 67462d0c..77e9f9db 100644 --- a/yunohost_tools.py +++ b/yunohost_tools.py @@ -25,9 +25,21 @@ def tools_ldapinit(): for rdn, attr_dict in ldap_map['parents'].items(): yldap.add(rdn, attr_dict) - for rdn, attr_dict in ldap_map['childs'].items(): + for rdn, attr_dict in ldap_map['children'].items(): yldap.add(rdn, attr_dict) + try: + with open('/etc/yunohost/from_script') as f: pass + except IOError: + with open('sudo_ldap_scheme.yml') as f: + ldap_map = yaml.load(f) + + for rdn, attr_dict in ldap_map['parents'].items(): + yldap.add(rdn, attr_dict) + + for rdn, attr_dict in ldap_map['children'].items(): + yldap.add(rdn, attr_dict) + admin_dict = { 'cn': 'admin',