diff --git a/CHANGELOG b/CHANGELOG index 530eea5b21..9faa95ab9f 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,4 @@ +* Switch to WSGI API * Limit the login size in LoginAttempt * Remove LocalDict from tools * Add LRUDictTransaction diff --git a/bin/trytond b/bin/trytond index 89d7b31275..11b38ae55b 100755 --- a/bin/trytond +++ b/bin/trytond @@ -1,80 +1,50 @@ #!/usr/bin/env python -#This file is part of Tryton. The COPYRIGHT file at the top level of -#this repository contains the full copyright notices and license terms. +# This file is part of Tryton. The COPYRIGHT file at the top level of +# this repository contains the full copyright notices and license terms. import sys import os -import argparse +import glob + +from werkzeug.serving import run_simple DIR = os.path.abspath(os.path.normpath(os.path.join(__file__, '..', '..', 'trytond'))) if os.path.isdir(DIR): sys.path.insert(0, os.path.dirname(DIR)) -from trytond import __version__ -from trytond import server +import trytond.commandline as commandline +from trytond.config import config, split_netloc +from trytond.application import app +from trytond.pool import Pool +from trytond.modules import get_module_list, get_module_info -def parse_commandline(): - options = {} +parser = commandline.get_parser_daemon() +options = parser.parse_args() +commandline.config_log(options) +config.update_etc(options.configfile) - parser = argparse.ArgumentParser(prog='trytond') - - parser.add_argument('--version', action='version', - version='%(prog)s ' + __version__) - parser.add_argument("-c", "--config", dest="configfile", metavar='FILE', - default=os.environ.get('TRYTOND_CONFIG'), help="specify config file") - parser.add_argument('--dev', dest='dev', action='store_true', - help='enable development mode') - parser.add_argument("-v", "--verbose", action="store_true", - dest="verbose", help="enable verbose mode") - - parser.add_argument("-d", "--database", dest="database_names", nargs='+', - default=[], metavar='DATABASE', help="specify the database name") - parser.add_argument("-u", "--update", dest="update", nargs='+', default=[], - metavar='MODULE', help="update a module") - parser.add_argument("--all", dest="update", action="append_const", - const="ir", help="update all installed modules") - - parser.add_argument("--pidfile", dest="pidfile", metavar='FILE', - help="file where the server pid will be stored") - parser.add_argument("--logconf", dest="logconf", metavar='FILE', - help="logging configuration file (ConfigParser format)") - parser.add_argument("--cron", dest="cron", action="store_true", - help="enable cron") - - parser.epilog = ('The first time a database is initialized admin ' - 'password is read from file defined by TRYTONPASSFILE ' - 'environment variable or interactively ask user.\n' - 'The config file can be specified in the TRYTOND_CONFIG ' - 'environment variable.\n' - 'The database URI can be specified in the TRYTOND_DATABASE_URI ' - 'environment variable.') - - options = parser.parse_args() - - if not options.database_names and options.update: - parser.error('Missing database option') - return options - - -if '--profile' in sys.argv: - import profile - import pstats - import tempfile - sys.argv.remove('--profile') - - options = parse_commandline() - statfile = tempfile.mkstemp(".stat", "trytond-")[1] - profile.run('server.TrytonServer(options).run()', statfile) - s = pstats.Stats(statfile) - s.sort_stats('cumulative').print_stats() - s.sort_stats('call').print_stats() - s.sort_stats('time').print_stats() - s.sort_stats('time') - s.print_callers() - s.print_callees() - - os.remove(statfile) -else: - options = parse_commandline() - server.TrytonServer(options).run() +with commandline.pidfile(options): + for name in options.database_names: + Pool(name).init() + hostname, port = split_netloc(config.get('web', 'listen')) + static_files = { + '/': config.get('web', 'root'), + } + certificate = config.get('ssl', 'certificate') + privatekey = config.get('ssl', 'privatekey') + if certificate or privatekey: + ssl_context = (certificate, privatekey) + else: + ssl_context = None + extra_files = [options.configfile] + for module in get_module_list(): + info = get_module_info(module) + path = os.path.join(info['directory'], 'view', '*.xml') + extra_files.extend(glob.glob(path)) + run_simple(hostname, port, app, + threaded=True, + extra_files=extra_files, + static_files=static_files, + ssl_context=ssl_context, + use_reloader=options.dev) diff --git a/bin/trytond-admin b/bin/trytond-admin new file mode 100755 index 0000000000..075fe1c85d --- /dev/null +++ b/bin/trytond-admin @@ -0,0 +1,21 @@ +#!/usr/bin/env python +# This file is part of Tryton. The COPYRIGHT file at the top level of +# this repository contains the full copyright notices and license terms. +import sys +import os + +DIR = os.path.abspath(os.path.normpath(os.path.join(__file__, + '..', '..', 'trytond'))) +if os.path.isdir(DIR): + sys.path.insert(0, os.path.dirname(DIR)) + +import trytond.commandline as commandline +from trytond.config import config +import trytond.admin as admin + +parser = commandline.get_parser_admin() +options = parser.parse_args() +config.update_etc(options.configfile) +commandline.config_log(options) + +admin.run(options) diff --git a/bin/trytond-cron b/bin/trytond-cron new file mode 100755 index 0000000000..e855d7745d --- /dev/null +++ b/bin/trytond-cron @@ -0,0 +1,25 @@ +#!/usr/bin/env python +# This file is part of Tryton. The COPYRIGHT file at the top level of +# this repository contains the full copyright notices and license terms. +import sys +import os +import time + +DIR = os.path.abspath(os.path.normpath(os.path.join(__file__, + '..', '..', 'trytond'))) +if os.path.isdir(DIR): + sys.path.insert(0, os.path.dirname(DIR)) + +import trytond.commandline as commandline +from trytond.config import config +import trytond.cron as cron + +parser = commandline.get_parser_daemon() +options = parser.parse_args() +config.update_etc(options.configfile) +commandline.config_log(options) + +with commandline.pidfile(options): + while True: + cron.run(options) + time.sleep(60) diff --git a/doc/index.rst b/doc/index.rst index 5ee2f0d153..6621f4c04d 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -27,6 +27,7 @@ First steps :ref:`Installation ` | :ref:`Configuration ` | :ref:`Setup a database ` + :ref:`Start the server ` The model layer =============== diff --git a/doc/topics/configuration.rst b/doc/topics/configuration.rst index 70799765ef..f02029fa19 100644 --- a/doc/topics/configuration.rst +++ b/doc/topics/configuration.rst @@ -29,47 +29,31 @@ possible values. Some modules could request the usage of other sections for which the guideline asks them to be named like their module. -jsonrpc -------- +web +--- -Defines the behavior of the JSON-RPC_ network interface. +Defines the behavior of the web interface. listen ~~~~~~ -Defines a comma separated list of couples of host (or IP address) and port -number separated by a colon to listen on. +Defines the couple of host (or IP address) and port number separated by a colon +to listen on. Default `localhost:8000` hostname ~~~~~~~~ -Defines the hostname for this network interface. +Defines the hostname. -data +root ~~~~ -Defines the root path to retrieve data for `GET` requests. +Defines the root path served by `GET` requests. Default: `/var/www/localhost/tryton` -xmlrpc ------- - -Defines the behavior of the XML-RPC_ network interface. - -listen -~~~~~~ - -Same as for `jsonrpc` except it has no default value. - - -listen -~~~~~~ - -Same as for `jsonrpc` except it has no default value. - database -------- diff --git a/doc/topics/index.rst b/doc/topics/index.rst index c19ba8a467..6560776ff7 100644 --- a/doc/topics/index.rst +++ b/doc/topics/index.rst @@ -13,6 +13,7 @@ Introduction to all the key parts of trytond: configuration setup_database logs + start_server models/index models/fields_default_value models/fields_on_change diff --git a/doc/topics/install.rst b/doc/topics/install.rst index ea07574ed3..4a08e9874d 100644 --- a/doc/topics/install.rst +++ b/doc/topics/install.rst @@ -8,6 +8,8 @@ Prerequisites ============= * Python 2.7 or later (http://www.python.org/) + * Werkzeug (http://werkzeug.pocoo.org/) + * wrapt (https://github.com/GrahamDumpleton/wrapt) * lxml 2.0 or later (http://lxml.de/) * relatorio 0.2.0 or later (http://code.google.com/p/python-relatorio/) * Genshi (http://genshi.edgewall.org/) diff --git a/doc/topics/setup_database.rst b/doc/topics/setup_database.rst index fe5ed914d4..977602d42e 100644 --- a/doc/topics/setup_database.rst +++ b/doc/topics/setup_database.rst @@ -20,7 +20,7 @@ Initialize a database A database can be initialized using this command line:: - trytond -c -d --all + trytond-admin -c -d --all -At the end of the process, `trytond` will ask to set the password for the +At the end of the process, `trytond-admin` will ask to set the password for the `admin` user. diff --git a/doc/topics/start_server.rst b/doc/topics/start_server.rst new file mode 100644 index 0000000000..71853ba4d9 --- /dev/null +++ b/doc/topics/start_server.rst @@ -0,0 +1,41 @@ +.. _topics-start-server: + +======================= +How to start the server +======================= + +Web service +=========== + +You can start the default web server bundled in Tryton with this command line:: + + trytond -c + +The server will wait for client connections on the interface defined in the +`web` section of the :ref:`configuration `. + +WSGI server +----------- + +If you prefer to run Tryton inside your own WSGI server instead of the simple +server of Werkzeug, you can use the application `trytond.application.app` and +set the environment variable `TRYTOND_CONFIG` to point to the +:ref:`configuration `. + +.. warning:: You must manage to serve the static files from the web root. + +Cron service +============ + +If you want to run some scheduled actions, you must also run the cron server +with this command line:: + + trytond-cron -c -d + +The server will wake up every minutes and preform the scheduled actions defined +in the `database`. + +Services options +================ + +You will find more options for those services by using `--help` arguments. diff --git a/setup.py b/setup.py index a1c6d7524e..b20ca8ea88 100644 --- a/setup.py +++ b/setup.py @@ -59,7 +59,7 @@ setup(name=name, 'trytond.res': ['tryton.cfg', '*.xml', 'view/*.xml', 'locale/*.po'], 'trytond.tests': ['tryton.cfg', '*.xml'], }, - scripts=['bin/trytond'], + scripts=['bin/trytond', 'bin/trytond-admin', 'bin/trytond-cron'], classifiers=[ 'Development Status :: 5 - Production/Stable', 'Environment :: No Input/Output (Daemon)', @@ -97,6 +97,8 @@ setup(name=name, 'python-dateutil', 'polib', 'python-sql >= 0.4', + 'werkzeug', + 'wrapt', ], extras_require={ 'PostgreSQL': pg_require, diff --git a/trytond/__init__.py b/trytond/__init__.py index df6b5ed99d..cc8c45be19 100644 --- a/trytond/__init__.py +++ b/trytond/__init__.py @@ -2,13 +2,18 @@ # this repository contains the full copyright notices and license terms. import os import time +import logging from email import charset __version__ = "3.9.0" +logger = logging.getLogger(__name__) os.environ['TZ'] = 'UTC' if hasattr(time, 'tzset'): time.tzset() +if time.tzname[0] != 'UTC': + logger.error('Timezone must be set to UTC instead of %s', time.tzname[0]) + # set email encoding for utf-8 to 'quoted-printable' charset.add_charset('utf-8', charset.QP, charset.QP) diff --git a/trytond/admin.py b/trytond/admin.py new file mode 100644 index 0000000000..aa5374f1b4 --- /dev/null +++ b/trytond/admin.py @@ -0,0 +1,83 @@ +# This file is part of Tryton. The COPYRIGHT file at the top level of +# this repository contains the full copyright notices and license terms. +import sys +import os +import logging +from getpass import getpass + +from sql import Table + +from trytond.transaction import Transaction +from trytond import backend +from trytond.pool import Pool + +__all__ = ['run'] +logger = logging.getLogger(__name__) + + +def run(options): + Database = backend.get('Database') + init = {} + for db_name in options.database_names: + init[db_name] = False + with Transaction().start(db_name, 0): + database = Database(db_name) + database.connect() + if options.update: + if not database.test(): + logger.info("init db") + database.init() + init[db_name] = True + elif not database.test(): + raise Exception("'%s' is not a Tryton database!" % db_name) + + for db_name in options.database_names: + if options.update: + with Transaction().start(db_name, 0) as transaction,\ + transaction.connection.cursor() as cursor: + database = Database(db_name) + database.connect() + if not database.test(): + raise Exception("'%s' is not a Tryton database!" % db_name) + lang = Table('ir_lang') + cursor.execute(*lang.select(lang.code, + where=lang.translatable == True)) + lang = [x[0] for x in cursor.fetchall()] + else: + lang = None + Pool(db_name).init(update=options.update, lang=lang) + + for db_name in options.database_names: + if init[db_name]: + # try to read password from environment variable + # TRYTONPASSFILE, empty TRYTONPASSFILE ignored + passpath = os.getenv('TRYTONPASSFILE') + password = '' + if passpath: + try: + with open(passpath) as passfile: + password = passfile.readline()[:-1] + except Exception, err: + sys.stderr.write('Can not read password ' + 'from "%s": "%s"\n' % (passpath, err)) + + if not password: + while True: + password = getpass('Admin Password for %s: ' % db_name) + password2 = getpass('Admin Password Confirmation: ') + if password != password2: + sys.stderr.write('Admin Password Confirmation ' + 'doesn\'t match Admin Password!\n') + continue + if not password: + sys.stderr.write('Admin Password is required!\n') + continue + break + + with Transaction().start(db_name, 0) as transaction: + pool = Pool() + User = pool.get('res.user') + admin, = User.search([('login', '=', 'admin')]) + User.write([admin], { + 'password': password, + }) diff --git a/trytond/application.py b/trytond/application.py new file mode 100644 index 0000000000..ef3b73b43d --- /dev/null +++ b/trytond/application.py @@ -0,0 +1,9 @@ +# This file is part of Tryton. The COPYRIGHT file at the top level of +# this repository contains the full copyright notices and license terms. +from trytond.pool import Pool +from trytond.wsgi import app + +__all__ = ['app'] + +Pool.start() +import trytond.protocols.dispatcher diff --git a/trytond/commandline.py b/trytond/commandline.py new file mode 100644 index 0000000000..45bb900710 --- /dev/null +++ b/trytond/commandline.py @@ -0,0 +1,88 @@ +# This file is part of Tryton. The COPYRIGHT file at the top level of +# this repository contains the full copyright notices and license terms. +import argparse +import os +import logging +import logging.config +import logging.handlers +from contextlib import contextmanager + +from trytond import __version__ + +logger = logging.getLogger(__name__) + + +def get_parser(): + parser = argparse.ArgumentParser() + + parser.add_argument('--version', action='version', + version='%(prog)s ' + __version__) + parser.add_argument("-c", "--config", dest="configfile", metavar='FILE', + default=os.environ.get('TRYTOND_CONFIG'), help="specify config file") + parser.add_argument("-v", "--verbose", action="store_true", + dest="verbose", help="enable verbose mode") + parser.add_argument('--dev', dest='dev', action='store_true', + help='enable development mode') + + parser.add_argument("-d", "--database", dest="database_names", nargs='+', + default=[], metavar='DATABASE', help="specify the database name") + parser.add_argument("--logconf", dest="logconf", metavar='FILE', + help="logging configuration file (ConfigParser format)") + + return parser + + +def get_parser_daemon(): + parser = get_parser() + parser.add_argument("--pidfile", dest="pidfile", metavar='FILE', + help="file where the server pid will be stored") + return parser + + +def get_parser_admin(): + parser = get_parser() + + parser.add_argument("-u", "--update", dest="update", nargs='+', default=[], + metavar='MODULE', help="update a module") + parser.add_argument("--all", dest="update", action="append_const", + const="ir", help="update all installed modules") + + parser.epilog = ('The first time a database is initialized admin ' + 'password is read from file defined by TRYTONPASSFILE ' + 'environment variable or interactively ask user.\n' + 'The config file can be specified in the TRYTOND_CONFIG ' + 'environment variable.\n' + 'The database URI can be specified in the TRYTOND_DATABASE_URI ' + 'environment variable.') + return parser + + +def config_log(options): + if options.logconf: + logging.config.fileConfig(options.logconf) + logging.getLogger('server').info('using %s as logging ' + 'configuration file', options.logconf) + else: + logformat = ('%(process)s %(thread)s [%(asctime)s] ' + '%(levelname)s %(name)s %(message)s') + if options.verbose: + if options.dev: + level = logging.DEBUG + else: + level = logging.INFO + else: + level = logging.ERROR + logging.basicConfig(level=level, format=logformat) + logging.captureWarnings(True) + + +@contextmanager +def pidfile(options): + path = options.pidfile + if not path: + yield + else: + with open(path, 'w') as fd: + fd.write('%d' % os.getpid()) + yield + os.unlink(path) diff --git a/trytond/config.py b/trytond/config.py index c309ec7f68..44ceaf9576 100644 --- a/trytond/config.py +++ b/trytond/config.py @@ -3,9 +3,11 @@ import os import ConfigParser import urlparse +import logging __all__ = ['config', 'get_hostname', 'get_port', 'split_netloc', 'parse_listen', 'parse_uri'] +logger = logging.getLogger(__name__) def get_hostname(netloc): @@ -39,10 +41,9 @@ class TrytonConfigParser(ConfigParser.RawConfigParser): def __init__(self): ConfigParser.RawConfigParser.__init__(self) - self.add_section('jsonrpc') - self.set('jsonrpc', 'listen', 'localhost:8000') - self.set('jsonrpc', 'data', '/var/www/localhost/tryton') - self.add_section('xmlrpc') + self.add_section('web') + self.set('web', 'listen', 'localhost:8000') + self.set('web', 'root', '/var/www/localhost/tryton') self.add_section('database') self.set('database', 'uri', os.environ.get('TRYTOND_DATABASE_URI', 'sqlite://')) @@ -62,10 +63,12 @@ class TrytonConfigParser(ConfigParser.RawConfigParser): self.add_section('report') self.set('report', 'unoconv', 'pipe,name=trytond;urp;StarOffice.ComponentContext') + self.update_etc() def update_etc(self, configfile=os.environ.get('TRYTOND_CONFIG')): if not configfile: return + logger.info('using %s as configuration file', configfile) self.read(configfile) def get(self, section, option, *args, **kwargs): diff --git a/trytond/cron.py b/trytond/cron.py new file mode 100644 index 0000000000..f327b04f13 --- /dev/null +++ b/trytond/cron.py @@ -0,0 +1,43 @@ +# This file is part of Tryton. The COPYRIGHT file at the top level of +# this repository contains the full copyright notices and license terms. +import threading +import logging + +from trytond.pool import Pool +from trytond.transaction import Transaction + +__all__ = ['run'] +logger = logging.getLogger(__name__) + + +def run(options): + database_list = Pool.database_list() + for dbname in options.database_names: + thread = _threads.get(dbname) + if thread and thread.is_alive(): + logger.info('skip "%s" as previous cron still running', dbname) + continue + pool = Pool(dbname) + if dbname not in database_list: + with Transaction().start(dbname, 0, readonly=True): + pool.init() + if not pool.lock.acquire(0): + logger.warning('can not acquire lock on "%s"', dbname) + continue + try: + try: + Cron = pool.get('ir.cron') + except KeyError: + logger.error( + 'missing "ir.cron" on "%s"', dbname, exc_info=True) + continue + finally: + pool.lock.release() + thread = threading.Thread( + target=Cron.run, + args=(dbname,), kwargs={}) + logger.info('start thread for "%s"', dbname) + thread.start() + _threads[dbname] = thread +_threads = {} +Pool.start() diff --git a/trytond/exceptions.py b/trytond/exceptions.py index dd086b1409..ff1a61ae30 100644 --- a/trytond/exceptions.py +++ b/trytond/exceptions.py @@ -2,7 +2,11 @@ # this repository contains the full copyright notices and license terms. -class UserError(Exception): +class TrytonException(Exception): + pass + + +class UserError(TrytonException): def __init__(self, message, description=''): super(UserError, self).__init__('UserError', (message, description)) @@ -11,7 +15,7 @@ class UserError(Exception): self.code = 1 -class UserWarning(Exception): +class UserWarning(TrytonException): def __init__(self, name, message, description=''): super(UserWarning, self).__init__('UserWarning', (name, message, @@ -22,14 +26,14 @@ class UserWarning(Exception): self.code = 2 -class NotLogged(Exception): +class NotLogged(TrytonException): def __init__(self): super(NotLogged, self).__init__('NotLogged') self.code = 3 -class ConcurrencyException(Exception): +class ConcurrencyException(TrytonException): def __init__(self, message): super(ConcurrencyException, self).__init__('ConcurrencyException', diff --git a/trytond/monitor.py b/trytond/monitor.py deleted file mode 100644 index 866d594230..0000000000 --- a/trytond/monitor.py +++ /dev/null @@ -1,87 +0,0 @@ -# This file is part of Tryton. The COPYRIGHT file at the top level of -# this repository contains the full copyright notices and license terms. -import sys -import os -import subprocess -from threading import Lock -from trytond.modules import get_module_list - -_LOCK = Lock() -_TIMES = {} -_MODULES = None - - -def _modified(path): - _LOCK.acquire() - try: - try: - if not os.path.isfile(path): - return path in _TIMES - - mtime = os.stat(path).st_mtime - if path not in _TIMES: - _TIMES[path] = mtime - - if mtime != _TIMES[path]: - _TIMES[path] = mtime - return True - except Exception: - return True - finally: - _LOCK.release() - return False - - -def monitor(files): - ''' - Monitor files and module files for change - - :return: True if at least one file has changed - ''' - global _MODULES - modified = False - for file_ in files: - if _modified(file_): - modified = True - directories = set() - for module in sys.modules.keys(): - if not module.startswith('trytond.'): - continue - if not hasattr(sys.modules[module], '__file__'): - continue - path = getattr(sys.modules[module], '__file__') - if not path: - continue - if os.path.splitext(path)[1] in ['.pyc', '.pyo', '.pyd']: - path = path[:-1] - if _modified(path): - if subprocess.call((sys.executable, '-c', 'import %s' % module), - cwd=os.path.dirname(os.path.abspath(os.path.normpath( - os.path.join(__file__, '..'))))): - modified = False - break - modified = True - - # Check view XML - directory = os.path.dirname(path) - if directory not in directories: - directories.add(directory) - view_dir = os.path.join(directory, 'view') - if os.path.isdir(view_dir): - for view in os.listdir(view_dir): - view = os.path.join(view_dir, view) - if os.path.splitext(view)[1] == '.xml': - if _modified(view): - modified = True - - modules = set(get_module_list()) - if _MODULES is None: - _MODULES = modules - for module in modules.difference(_MODULES): - if subprocess.call((sys.executable, '-c', - 'import trytond.modules.%s' % module)): - modified = False - break - modified = True - _MODULES = modules - return modified diff --git a/trytond/protocols/common.py b/trytond/protocols/common.py deleted file mode 100644 index c38ac17ae7..0000000000 --- a/trytond/protocols/common.py +++ /dev/null @@ -1,53 +0,0 @@ -# This file is part of Tryton. The COPYRIGHT file at the top level of -# this repository contains the full copyright notices and license terms. -import errno -import os -import socket -import threading -from SocketServer import StreamRequestHandler - - -def endsocket(sock): - if os.name != 'nt': - try: - sock.shutdown(getattr(socket, 'SHUT_RDWR', 2)) - except socket.error, e: - if e.errno != errno.ENOTCONN: - raise - sock.close() - - -class daemon(threading.Thread): - def __init__(self, interface, port, secure, name=None): - threading.Thread.__init__(self, name=name) - self.secure = secure - self.ipv6 = False - for family, _, _, _, _ in socket.getaddrinfo(interface or None, port, - socket.AF_UNSPEC, socket.SOCK_STREAM): - if family == socket.AF_INET6: - self.ipv6 = True - break - - def stop(self): - self.server.shutdown() - self.server.socket.shutdown(socket.SHUT_RDWR) - self.server.server_close() - return - - def run(self): - self.server.serve_forever() - return True - - -class RegisterHandlerMixin: - - def setup(self): - self.server.handlers.add(self) - StreamRequestHandler.setup(self) - - def finish(self): - StreamRequestHandler.finish(self) - try: - self.server.handlers.remove(self) - except KeyError: - pass diff --git a/trytond/protocols/dispatcher.py b/trytond/protocols/dispatcher.py index cf7acc91a4..49528399ca 100644 --- a/trytond/protocols/dispatcher.py +++ b/trytond/protocols/dispatcher.py @@ -4,7 +4,9 @@ import logging import time import pydoc +from functools import wraps +from werkzeug.utils import redirect from sql import Table from trytond.pool import Pool @@ -17,6 +19,7 @@ from trytond.cache import Cache from trytond.exceptions import UserError, UserWarning, NotLogged, \ ConcurrencyException from trytond.tools import is_instance_method +from trytond.wsgi import app logger = logging.getLogger(__name__) @@ -26,131 +29,169 @@ ir_module = Table('ir_module') res_user = Table('res_user') -def dispatch(host, port, protocol, database_name, user, session, object_type, - object_name, method, *args, **kwargs): - Database = backend.get('Database') - DatabaseOperationalError = backend.get('DatabaseOperationalError') - if object_type == 'common': - if method == 'login': - try: - database = Database(database_name).connect() - cursor = database.get_connection().cursor() - cursor.close() - except Exception: - return False - res = security.login(database_name, user, session) - with Transaction().start(database_name, 0): - Cache.clean(database_name) - Cache.resets(database_name) - msg = res and 'successful login' or 'bad login or password' - logger.info('%s \'%s\' from %s:%d using %s on database \'%s\'', - msg, user, host, port, protocol, database_name) - return res or False - elif method == 'logout': - name = security.logout(database_name, user, session) - logger.info('logout \'%s\' from %s:%d ' - 'using %s on database \'%s\'', - name, host, port, protocol, database_name) - return True - elif method == 'version': - return __version__ - elif method == 'list_lang': - return [ - ('bg_BG', 'Български'), - ('ca_ES', 'Català'), - ('cs_CZ', 'Čeština'), - ('de_DE', 'Deutsch'), - ('en_US', 'English'), - ('es_AR', 'Español (Argentina)'), - ('es_EC', 'Español (Ecuador)'), - ('es_ES', 'Español (España)'), - ('es_CO', 'Español (Colombia)'), - ('es_MX', 'Español (México)'), - ('fr_FR', 'Français'), - ('hu_HU', 'Magyar'), - ('it_IT', 'Italiano'), - ('lt_LT', 'Lietuvių'), - ('nl_NL', 'Nederlands'), - ('pt_BR', 'Português (Brasil)'), - ('ru_RU', 'Russian'), - ('sl_SI', 'Slovenščina'), - ] - elif method == 'db_exist': - try: - database = Database(*args, **kwargs).connect() - return True - except Exception: - return False - elif method == 'list': - if not config.getboolean('database', 'list'): - raise Exception('AccessDenied') - with Transaction().start(None, 0, close=True) as transaction: - return transaction.database.list() - elif method == 'create': - return create(*args, **kwargs) - elif method == 'restore': - return restore(*args, **kwargs) - elif method == 'drop': - return drop(*args, **kwargs) - elif method == 'dump': - return dump(*args, **kwargs) - return - elif object_type == 'system': - database = Database(database_name).connect() +def with_pool(func): + @wraps(func) + def wrapper(request, database_name, *args, **kwargs): database_list = Pool.database_list() pool = Pool(database_name) if database_name not in database_list: - pool.init() - if method == 'listMethods': - res = [] - for type in ('model', 'wizard', 'report'): - for object_name, obj in pool.iterobject(type=type): - for method in obj.__rpc__: - res.append(type + '.' + object_name + '.' + method) - return res - elif method == 'methodSignature': - return 'signatures not supported' - elif method == 'methodHelp': - res = [] - args_list = args[0].split('.') - object_type = args_list[0] - object_name = '.'.join(args_list[1:-1]) - method = args_list[-1] - obj = pool.get(object_name, type=object_type) - return pydoc.getdoc(getattr(obj, method)) + with Transaction().start( + database_name, request.user_id, readonly=True): + pool.init() + return func(request, pool, *args, **kwargs) + return wrapper - for count in range(config.getint('database', 'retry'), -1, -1): - try: - user = security.check(database_name, user, session) - except DatabaseOperationalError: - if count: - continue - raise - break - database_list = Pool.database_list() - pool = Pool(database_name) - if database_name not in database_list: - with Transaction().start(database_name, user, - readonly=True) as transaction: - pool.init() - obj = pool.get(object_name, type=object_type) +@app.route('//', methods=['POST']) +def rpc(request, database_name): + methods = { + 'common.db.login': login, + 'common.db.logout': logout, + 'common.db.db_exist': db_exist, + 'common.db.create': create, + 'common.db.restore': restore, + 'common.db.drop': drop, + 'common.db.dump': dump, + 'system.listMethods': list_method, + 'system.methodHelp': help_method, + 'system.methodSignature': lambda *a: 'signatures not supported', + } + return methods.get(request.method, _dispatch)( + request, database_name, *request.params) + +def login(request, database_name, user, password): + Database = backend.get('Database') + try: + database = Database(database_name).connect() + cursor = database.get_connection().cursor() + cursor.close() + except Exception: + logger.error('fail to connect to %s', database_name, exc_info=True) + return False + session = security.login(database_name, user, password) + with Transaction().start(database_name, 0): + Cache.clean(database_name) + Cache.resets(database_name) + msg = 'successful login' if session else 'bad login or password' + logger.info('%s \'%s\' from %s using %s on database \'%s\'', + msg, user, request.remote_addr, request.scheme, database_name) + return session + + +@app.auth_required +def logout(request, database_name): + auth = request.authorization + name = security.logout( + database_name, auth.get('userid'), auth.get('session')) + logger.info('logout \'%s\' from %s using %s on database \'%s\'', + name, request.remote_addr, request.scheme, database_name) + return True + + +@app.route('/', methods=['POST']) +def root(request, *args): + methods = { + 'common.server.version': lambda *a: __version__, + 'common.db.list_lang': list_lang, + 'common.db.list': db_list, + } + return methods[request.method](request, *request.params) + + +@app.route('/', methods=['GET']) +def home(request): + return redirect('/index.html') # XXX find a better way + + +def list_lang(*args): + return [ + ('bg_BG', 'Български'), + ('ca_ES', 'Català'), + ('cs_CZ', 'Čeština'), + ('de_DE', 'Deutsch'), + ('en_US', 'English'), + ('es_AR', 'Español (Argentina)'), + ('es_EC', 'Español (Ecuador)'), + ('es_ES', 'Español (España)'), + ('es_CO', 'Español (Colombia)'), + ('es_MX', 'Español (México)'), + ('fr_FR', 'Français'), + ('hu_HU', 'Magyar'), + ('it_IT', 'Italiano'), + ('lt_LT', 'Lietuvių'), + ('nl_NL', 'Nederlands'), + ('pt_BR', 'Português (Brasil)'), + ('ru_RU', 'Russian'), + ('sl_SI', 'Slovenščina'), + ] + + +def db_exist(request, database_name): + Database = backend.get('Database') + try: + Database(database_name).connect() + return True + except Exception: + return False + + +def db_list(*args): + if not config.getboolean('database', 'list'): + raise Exception('AccessDenied') + with Transaction().start(None, 0, close=True) as transaction: + return transaction.database.list() + + +@app.auth_required +@with_pool +def list_method(request, pool): + methods = [] + for type in ('model', 'wizard', 'report'): + for object_name, obj in pool.iterobject(type=type): + for method in obj.__rpc__: + methods.append(type + '.' + object_name + '.' + method) + return methods + + +def get_object_method(request, pool): + method = request.method + type, _ = method.split('.', 1) + name = '.'.join(method.split('.')[1:-1]) + method = method.split('.')[-1] + return pool.get(name, type=type), method + + +@app.auth_required +@with_pool +def help_method(request, pool): + obj, method = get_object_method(request, pool) + return pydoc.getdoc(getattr(obj, method)) + + +@app.auth_required +@with_pool +def _dispatch(request, pool, *args, **kwargs): + DatabaseOperationalError = backend.get('DatabaseOperationalError') + + obj, method = get_object_method(request, pool) if method in obj.__rpc__: rpc = obj.__rpc__[method] else: - raise UserError('Calling method %s on %s %s is not allowed!' - % (method, object_type, object_name)) - - log_message = '%s.%s.%s(*%s, **%s) from %s@%s:%d/%s' - log_args = (object_type, object_name, method, args, kwargs, - user, host, port, database_name) + raise UserError('Calling method %s on %s is not allowed' + % (method, obj)) + log_message = '%s.%s(*%s, **%s) from %s@%s/%s' + log_args = (obj, method, args, kwargs, + request.authorization.username, request.remote_addr, request.path) logger.info(log_message, *log_args) + + user = request.user_id + for count in range(config.getint('database', 'retry'), -1, -1): - with Transaction().start(database_name, user, + with Transaction().start(pool.database_name, user, readonly=rpc.readonly) as transaction: - Cache.clean(database_name) + Cache.clean(pool.database_name) try: c_args, c_kwargs, transaction.context, transaction.timestamp \ = rpc.convert(obj, *args, **kwargs) @@ -177,19 +218,19 @@ def dispatch(host, port, protocol, database_name, user, session, object_type, except Exception: logger.error(log_message, *log_args, exc_info=True) raise - Cache.resets(database_name) - try: - with Transaction().start(database_name, 0) as transaction: - pool = Pool(database_name) - Session = pool.get('ir.session') - Session.reset(session) - except DatabaseOperationalError: - logger.debug('Reset session failed', exc_info=True) + Cache.resets(pool.database_name) + if request.authorization.type == 'session': + try: + with Transaction().start(pool.database_name, 0) as transaction: + Session = pool.get('ir.session') + Session.reset(request.authorization.get('session')) + except DatabaseOperationalError: + logger.debug('Reset session failed', exc_info=True) logger.debug('Result: %s', result) return result -def create(database_name, password, lang, admin_password): +def create(request, database_name, password, lang, admin_password): ''' Create a database @@ -242,7 +283,7 @@ def create(database_name, password, lang, admin_password): return res -def drop(database_name, password): +def drop(request, database_name, password): Database = backend.get('Database') security.check_super(password) database = Database(database_name) @@ -264,7 +305,7 @@ def drop(database_name, password): return True -def dump(database_name, password): +def dump(request, database_name, password): Database = backend.get('Database') security.check_super(password) Database(database_name).close() @@ -279,7 +320,7 @@ def dump(database_name, password): return bytes(data) -def restore(database_name, password, data, update=False): +def restore(request, database_name, password, data, update=False): Database = backend.get('Database') security.check_super(password) try: diff --git a/trytond/protocols/jsonrpc.py b/trytond/protocols/jsonrpc.py index edaa35571c..69298efdd4 100644 --- a/trytond/protocols/jsonrpc.py +++ b/trytond/protocols/jsonrpc.py @@ -1,25 +1,7 @@ # This file is part of Tryton. The COPYRIGHT file at the top level of # this repository contains the full copyright notices and license terms. -from trytond.protocols.sslsocket import SSLSocket -from trytond.protocols.dispatcher import dispatch -from trytond.config import config -from trytond.protocols.common import daemon, RegisterHandlerMixin -from trytond.exceptions import UserError, UserWarning, NotLogged, \ - ConcurrencyException - -import SimpleXMLRPCServer -import SimpleHTTPServer -import SocketServer import traceback -import socket import sys -import os -try: - import fcntl -except ImportError: - fcntl = None -import posixpath -import urllib import datetime from decimal import Decimal try: @@ -27,8 +9,13 @@ try: except ImportError: import json import base64 -import encodings -from io import BytesIO + +from werkzeug.wrappers import Response +from werkzeug.utils import cached_property +from werkzeug.exceptions import BadRequest + +from trytond.protocols.wrappers import Request +from trytond.exceptions import TrytonException class JSONDecoder(object): @@ -127,269 +114,50 @@ JSONEncoder.register(Decimal, }) -class SimpleJSONRPCDispatcher(SimpleXMLRPCServer.SimpleXMLRPCDispatcher): - """Mix-in class that dispatches JSON-RPC requests. +class JSONRequest(Request): + parsed_content_type = 'json' - This class is used to register JSON-RPC method handlers - and then to dispatch them. There should never be any - reason to instantiate this class directly. - """ + @cached_property + def parsed_data(self): + if self.parsed_content_type in self.environ.get('CONTENT_TYPE', ''): + try: + return json.loads(self.decoded_data, object_hook=JSONDecoder()) + except Exception: + raise BadRequest('Unable to read JSON request') + else: + raise BadRequest('Not a JSON request') - def _marshaled_dispatch(self, data, dispatch_method=None, path=None): - """Dispatches an JSON-RPC method from marshalled (JSON) data. + @cached_property + def method(self): + return self.parsed_data['method'] - JSON-RPC methods are dispatched from the marshalled (JSON) data - using the _dispatch method and the result is returned as - marshalled data. For backwards compatibility, a dispatch - function can be provided as an argument (see comment in - SimpleJSONRPCRequestHandler.do_POST) but overriding the - existing method through subclassing is the prefered means - of changing method dispatch behavior. - """ - rawreq = json.loads(data.decode('utf-8'), object_hook=JSONDecoder()) + @cached_property + def params(self): + return self.parsed_data['params'] - req_id = rawreq.get('id', 0) - method = rawreq['method'] - params = rawreq.get('params', []) - response = {'id': req_id} +class JSONProtocol: + content_type = 'json' - try: - # generate response - if dispatch_method is not None: - response['result'] = dispatch_method(method, params) - else: - response['result'] = self._dispatch(method, params) - except (UserError, UserWarning, NotLogged, - ConcurrencyException), exception: - response['error'] = exception.args - except Exception: + @classmethod + def request(cls, environ): + return JSONRequest(environ) + + @classmethod + def response(cls, data, request): + if isinstance(request, JSONRequest): + response = {'id': request.parsed_data.get('id', 0)} + else: + response = {} + if isinstance(data, TrytonException): + response['error'] = data.args + elif isinstance(data, Exception): tb_s = ''.join(traceback.format_exception(*sys.exc_info())) for path in sys.path: tb_s = tb_s.replace(path, '') # report exception back to server - response['error'] = (str(sys.exc_value), tb_s) - - return json.dumps(response, cls=JSONEncoder).encode('utf-8') - - -class GenericJSONRPCRequestHandler: - - def _dispatch(self, method, params): - host, port = self.client_address[:2] - database_name = self.path[1:] - if database_name.startswith('sao/'): - database_name = database_name[4:] - method_list = method.split('.') - object_type = method_list[0] - object_name = '.'.join(method_list[1:-1]) - method = method_list[-1] - args = (host, port, 'JSON-RPC', database_name, params[0], params[1], - object_type, object_name, method) + tuple(params[2:]) - res = dispatch(*args) - return res - - -class SimpleJSONRPCRequestHandler(RegisterHandlerMixin, - GenericJSONRPCRequestHandler, - SimpleXMLRPCServer.SimpleXMLRPCRequestHandler, - SimpleHTTPServer.SimpleHTTPRequestHandler): - """Simple JSON-RPC request handler class. - - Handles all HTTP POST requests and attempts to decode them as - JSON-RPC requests. - """ - protocol_version = "HTTP/1.1" - rpc_paths = None - encode_threshold = 1400 # common MTU - - def send_header(self, keyword, value): - if keyword == 'Content-type' and value == 'text/xml': - value = 'application/json-rpc' - SimpleXMLRPCServer.SimpleXMLRPCRequestHandler.send_header(self, - keyword, value) - - def do_GET(self): - if self.is_tryton_url(self.path): - self.send_tryton_url(self.path) - return - SimpleHTTPServer.SimpleHTTPRequestHandler.do_GET(self) - - def do_HEAD(self): - if self.is_tryton_url(self.path): - self.send_tryton_url(self.path) - return - SimpleHTTPServer.SimpleHTTPRequestHandler.do_HEAD(self) - - def translate_path(self, path): - """Translate a /-separated PATH to the local filename syntax. - - Components that mean special things to the local file system - (e.g. drive or directory names) are ignored. (XXX They should - probably be diagnosed.) - - """ - # abandon query parameters - path = path.split('?', 1)[0] - path = path.split('#', 1)[0] - path = posixpath.normpath(urllib.unquote(path)) - words = path.split('/') - words = filter(None, words) - path = config.get('jsonrpc', 'data') - for word in words: - drive, word = os.path.splitdrive(word) - head, word = os.path.split(word) - if word in (os.curdir, os.pardir): - continue - path = os.path.join(path, word) - return path - - def is_tryton_url(self, path): - words = path.split('/') - try: - return words[2] in ('model', 'wizard', 'report') - except IndexError: - return False - - def send_tryton_url(self, path): - self.send_response(300) - hostname = (config.get('jsonrpc', 'hostname') - or unicode(socket.getfqdn(), 'utf8')) - hostname = '.'.join(encodings.idna.ToASCII(part) for part in - hostname.split('.')) - values = { - 'hostname': hostname, - 'path': path, - } - content = BytesIO() - - def write(str_): - content.write(str_.encode('utf-8')) - write('') - write('' % values) - write('Moved') - write('') - write('') - write('

Moved

') - write('

This page has moved to ' - '' - 'tryton://%(hostname)s%(path)s.

' % values) - write('') - write('') - length = content.tell() - content.seek(0) - self.send_header('Location', 'tryton://%(hostname)s%(path)s' % values) - self.send_header('Content-type', 'text/html') - self.send_header('Content-Length', str(length)) - self.end_headers() - self.copyfile(content, self.wfile) - content.close() - -SimpleJSONRPCRequestHandler.extensions_map.update({ - '.svg': 'image/svg+xml', - }) - - -class SecureJSONRPCRequestHandler(SimpleJSONRPCRequestHandler): - - def setup(self): - self.request = SSLSocket(self.request) - SimpleJSONRPCRequestHandler.setup(self) - - -class SimpleJSONRPCServer(SocketServer.TCPServer, - SimpleJSONRPCDispatcher): - """Simple JSON-RPC server. - - Simple JSON-RPC server that allows functions and a single instance - to be installed to handle requests. The default implementation - attempts to dispatch JSON-RPC calls to the functions or instance - installed in the server. Override the _dispatch method inhereted - from SimpleJSONRPCDispatcher to change this behavior. - """ - - allow_reuse_address = True - - # Warning: this is for debugging purposes only! Never set this to True in - # production code, as will be sending out sensitive information (exception - # and stack trace details) when exceptions are raised inside - # SimpleJSONRPCRequestHandler.do_POST - _send_traceback_header = False - - def __init__(self, addr, requestHandler=SimpleJSONRPCRequestHandler, - logRequests=True, allow_none=False, encoding=None, - bind_and_activate=True): - self.handlers = set() - self.logRequests = logRequests - - SimpleJSONRPCDispatcher.__init__(self, allow_none, encoding) - try: - SocketServer.TCPServer.__init__(self, addr, requestHandler, - bind_and_activate) - except TypeError: - SocketServer.TCPServer.__init__(self, addr, requestHandler) - - # [Bug #1222790] If possible, set close-on-exec flag; if a - # method spawns a subprocess, the subprocess shouldn't have - # the listening socket open. - if fcntl is not None and hasattr(fcntl, 'FD_CLOEXEC'): - flags = fcntl.fcntl(self.fileno(), fcntl.F_GETFD) - flags |= fcntl.FD_CLOEXEC - fcntl.fcntl(self.fileno(), fcntl.F_SETFD, flags) - - def server_close(self): - SocketServer.TCPServer.server_close(self) - for handler in self.handlers.copy(): - self.shutdown_request(handler.request) - - -class SimpleThreadedJSONRPCServer(SocketServer.ThreadingMixIn, - SimpleJSONRPCServer): - timeout = 1 - daemon_threads = True - disable_nagle_algorithm = True - - def server_bind(self): - self.socket.setsockopt(socket.SOL_SOCKET, - socket.SO_REUSEADDR, 1) - self.socket.setsockopt(socket.SOL_SOCKET, - socket.SO_KEEPALIVE, 1) - SimpleJSONRPCServer.server_bind(self) - - -class SimpleThreadedJSONRPCServer6(SimpleThreadedJSONRPCServer): - address_family = socket.AF_INET6 - - -class SecureThreadedJSONRPCServer(SimpleThreadedJSONRPCServer): - - def __init__(self, server_address, HandlerClass, logRequests=1): - SimpleThreadedJSONRPCServer.__init__(self, server_address, - HandlerClass, logRequests) - self.socket = socket.socket(self.address_family, - self.socket_type) - self.server_bind() - self.server_activate() - - -class SecureThreadedJSONRPCServer6(SecureThreadedJSONRPCServer): - address_family = socket.AF_INET6 - - -class JSONRPCDaemon(daemon): - - def __init__(self, interface, port, secure=False): - daemon.__init__(self, interface, port, secure, name='JSONRPCDaemon') - if self.secure: - handler_class = SecureJSONRPCRequestHandler - server_class = SecureThreadedJSONRPCServer - if self.ipv6: - server_class = SecureThreadedJSONRPCServer6 + response['error'] = (str(data), tb_s) else: - handler_class = SimpleJSONRPCRequestHandler - server_class = SimpleThreadedJSONRPCServer - if self.ipv6: - server_class = SimpleThreadedJSONRPCServer6 - self.server = server_class((interface, port), handler_class, 0) + response['result'] = data + return Response(json.dumps(response, cls=JSONEncoder), + content_type='application/json') diff --git a/trytond/protocols/sslsocket.py b/trytond/protocols/sslsocket.py deleted file mode 100644 index 4d12720719..0000000000 --- a/trytond/protocols/sslsocket.py +++ /dev/null @@ -1,13 +0,0 @@ -# This file is part of Tryton. The COPYRIGHT file at the top level of -# this repository contains the full copyright notices and license terms. -from trytond.config import config - - -def SSLSocket(socket): - # Let the import error raise only when used - import ssl - return ssl.wrap_socket(socket, - server_side=True, - certfile=config.get('ssl', 'certificate'), - keyfile=config.get('ssl', 'privatekey'), - ssl_version=ssl.PROTOCOL_SSLv23) diff --git a/trytond/protocols/wrappers.py b/trytond/protocols/wrappers.py new file mode 100644 index 0000000000..bf53950ea0 --- /dev/null +++ b/trytond/protocols/wrappers.py @@ -0,0 +1,87 @@ +# This file is part of Tryton. The COPYRIGHT file at the top level of +# this repository contains the full copyright notices and license terms. +import base64 +import gzip +from io import BytesIO + +from werkzeug.wrappers import Request as _Request +from werkzeug.utils import cached_property +from werkzeug.http import wsgi_to_bytes, bytes_to_wsgi +from werkzeug.datastructures import Authorization +from werkzeug.exceptions import abort + +from trytond import security + + +class Request(_Request): + + view_args = None + + @property + def decoded_data(self): + if self.content_encoding == 'gzip': + zipfile = gzip.GzipFile(fileobj=BytesIO(self.data), mode='rb') + return zipfile.read() + else: + return self.data + + @property + def parsed_data(self): + return self.data + + @property + def method(self): + return + + @property + def params(self): + return + + @cached_property + def authorization(self): + authorization = super(Request, self).authorization + if authorization is None: + header = self.environ.get('HTTP_AUTHORIZATION') + return parse_authorization_header(header) + return authorization + + @cached_property + def user_id(self): + database_name = self.view_args['database_name'] + auth = self.authorization + if not auth: + abort(401) + if auth.type == 'session': + user_id = security.check( + database_name, auth.get('userid'), auth.get('session')) + if not user_id: + abort(403) + else: + user_id = security.login( + database_name, auth.username, auth.password, cache=False) + if not user_id: + abort(401) + return user_id + + +def parse_authorization_header(value): + if not value: + return + value = wsgi_to_bytes(value) + try: + auth_type, auth_info = value.split(None, 1) + auth_type = auth_type.lower() + except ValueError: + return + if auth_type == b'session': + try: + username, userid, session = base64.b64decode(auth_info).split( + b':', 3) + userid = int(userid) + except Exception: + return + return Authorization('session', { + 'username': bytes_to_wsgi(username), + 'userid': userid, + 'session': bytes_to_wsgi(session), + }) diff --git a/trytond/protocols/xmlrpc.py b/trytond/protocols/xmlrpc.py index 492e516ac4..502f226a20 100644 --- a/trytond/protocols/xmlrpc.py +++ b/trytond/protocols/xmlrpc.py @@ -1,22 +1,19 @@ # This file is part of Tryton. The COPYRIGHT file at the top level of # this repository contains the full copyright notices and license terms. -from trytond.protocols.sslsocket import SSLSocket -from trytond.protocols.dispatcher import dispatch -from trytond.protocols.common import daemon, RegisterHandlerMixin -from trytond.exceptions import UserError, UserWarning, NotLogged, \ - ConcurrencyException -from trytond import security -import SimpleXMLRPCServer -import SocketServer import xmlrpclib as client -import socket -import base64 import datetime import logging # convert decimal to float before marshalling: from decimal import Decimal +from werkzeug.wrappers import Response +from werkzeug.utils import cached_property +from werkzeug.exceptions import BadRequest + +from trytond.protocols.wrappers import Request +from trytond.exceptions import TrytonException + logger = logging.getLogger(__name__) @@ -138,134 +135,44 @@ if bytes == str: client.Unmarshaller.dispatch['base64'] = _end_base64 -class GenericXMLRPCRequestHandler: +class XMLRequest(Request): + parsed_content_type = 'xml' - def _dispatch(self, method, params): - host, port = self.client_address[:2] - database_name = self.path[1:] - user = self.tryton['user'] - session = self.tryton['session'] - exception_message = 'Exception calling %s%s' % (method, params) - try: + @cached_property + def parsed_data(self): + if self.parsed_content_type in self.environ.get('CONTENT_TYPE', ''): try: - method_list = method.split('.') - object_type = method_list[0] - object_name = '.'.join(method_list[1:-1]) - method = method_list[-1] - if object_type == 'system' and method == 'getCapabilities': - return { - 'introspect': { - 'specUrl': ('http://xmlrpc-c.sourceforge.net/' - 'xmlrpc-c/introspection.html'), - 'specVersion': 1, - }, - } - return dispatch(host, port, 'XML-RPC', database_name, user, - session, object_type, object_name, method, *params) - except (NotLogged, ConcurrencyException), exception: - logger.debug(exception_message, exc_info=True) - raise client.Fault(exception.code, str(exception)) - except (UserError, UserWarning), exception: - logger.debug(exception_message, exc_info=True) - error, description = exception.args - raise client.Fault(exception.code, str(exception)) - except Exception, exception: - logger.error(exception_message, exc_info=True) - raise client.Fault(255, str(exception)) - finally: - security.logout(database_name, user, session) - - -class SimpleXMLRPCRequestHandler(RegisterHandlerMixin, - GenericXMLRPCRequestHandler, - SimpleXMLRPCServer.SimpleXMLRPCRequestHandler): - protocol_version = "HTTP/1.1" - rpc_paths = None - encode_threshold = 1400 # common MTU - - def parse_request(self): - res = SimpleXMLRPCServer.SimpleXMLRPCRequestHandler.parse_request(self) - if not res: - return res - database_name = self.path[1:] - if not database_name: - self.tryton = {'user': None, 'session': None} - return res - try: - method, up64 = self.headers['Authorization'].split(None, 1) - if method.strip().lower() == 'basic': - user, password = base64.decodestring(up64).split(':', 1) - user_id, session = security.login(database_name, user, - password) - self.tryton = {'user': user_id, 'session': session} - return res - except Exception: - pass - self.send_error(401, 'Unauthorized') - self.send_header("WWW-Authenticate", 'Basic realm="Tryton"') - return False - - -class SecureXMLRPCRequestHandler(SimpleXMLRPCRequestHandler): - - def setup(self): - self.request = SSLSocket(self.request) - SimpleXMLRPCRequestHandler.setup(self) - - -class SimpleThreadedXMLRPCServer(SocketServer.ThreadingMixIn, - SimpleXMLRPCServer.SimpleXMLRPCServer): - timeout = 1 - daemon_threads = True - - def __init__(self, server_address, HandlerClass, logRequests=1): - self.handlers = set() - SimpleXMLRPCServer.SimpleXMLRPCServer.__init__(self, server_address, - HandlerClass, logRequests) - - def server_bind(self): - self.socket.setsockopt(socket.SOL_SOCKET, - socket.SO_REUSEADDR, 1) - self.socket.setsockopt(socket.SOL_SOCKET, - socket.SO_KEEPALIVE, 1) - SimpleXMLRPCServer.SimpleXMLRPCServer.server_bind(self) - - def server_close(self): - SimpleXMLRPCServer.SimpleXMLRPCServer.server_close(self) - for handler in self.handlers.copy(): - self.shutdown_request(handler.request) - - -class SimpleThreadedXMLRPCServer6(SimpleThreadedXMLRPCServer): - address_family = socket.AF_INET6 - - -class SecureThreadedXMLRPCServer(SimpleThreadedXMLRPCServer): - - def __init__(self, server_address, HandlerClass, logRequests=1): - SimpleThreadedXMLRPCServer.__init__(self, server_address, HandlerClass, - logRequests) - self.socket = socket.socket(self.address_family, self.socket_type) - self.server_bind() - self.server_activate() - - -class SecureThreadedXMLRPCServer6(SecureThreadedXMLRPCServer): - address_family = socket.AF_INET6 - - -class XMLRPCDaemon(daemon): - - def __init__(self, interface, port, secure=False): - daemon.__init__(self, interface, port, secure, name='XMLRPCDaemon') - if self.secure: - handler_class = SecureXMLRPCRequestHandler - server_class = SecureThreadedXMLRPCServer - if self.ipv6: - server_class = SecureThreadedXMLRPCServer6 + # TODO replace by own loads + return client.loads(self.decoded_data) + except Exception: + raise BadRequest('Unable to read XMl request') else: - handler_class = SimpleXMLRPCRequestHandler - server_class = SimpleThreadedXMLRPCServer - if self.ipv6: - server_class = SimpleThreadedXMLRPCServer6 - self.server = server_class((interface, port), handler_class, 0) + raise BadRequest('Not an XML request') + + @property + def method(self): + return self.parsed_data[1] + + @property + def params(self): + return self.parsed_data[0] + + +class XMLProtocol: + content_type = 'xml' + + @classmethod + def request(cls, environ): + return XMLRequest(environ) + + @classmethod + def response(cls, data, request): + if isinstance(data, TrytonException): + data = client.Fault(data.code, str(data)) + elif isinstance(data, Exception): + data = client.Fault(255, str(data)) + else: + data = (data,) + return Response(client.dumps( + data, methodresponse=True, allow_none=True), + content_type='text/xml') diff --git a/trytond/security.py b/trytond/security.py index 40baad64b9..c874c7cb53 100644 --- a/trytond/security.py +++ b/trytond/security.py @@ -8,7 +8,7 @@ except ImportError: from trytond.pool import Pool from trytond.config import config from trytond.transaction import Transaction -from trytond.exceptions import NotLogged +from trytond import backend def _get_pool(dbname): @@ -31,7 +31,7 @@ def login(dbname, loginname, password, cache=True): Session = pool.get('ir.session') session, = Session.create([{}]) return user_id, session.key - return False + return def logout(dbname, user, session): @@ -57,17 +57,19 @@ def check_super(passwd): def check(dbname, user, session): - if user == 0: - raise Exception('AccessDenied') - if not user: - raise NotLogged() - with Transaction().start(dbname, user) as transaction: - pool = _get_pool(dbname) - Session = pool.get('ir.session') - try: - if not Session.check(user, session): - raise NotLogged() - else: - return user - finally: - transaction.commit() + DatabaseOperationalError = backend.get('DatabaseOperationalError') + for count in range(config.getint('database', 'retry'), -1, -1): + with Transaction().start(dbname, user) as transaction: + pool = _get_pool(dbname) + Session = pool.get('ir.session') + try: + if not Session.check(user, session): + return + else: + return user + except DatabaseOperationalError: + if count: + continue + raise + finally: + transaction.commit() diff --git a/trytond/server.py b/trytond/server.py deleted file mode 100644 index 60844e09db..0000000000 --- a/trytond/server.py +++ /dev/null @@ -1,229 +0,0 @@ -# This file is part of Tryton. The COPYRIGHT file at the top level of -# this repository contains the full copyright notices and license terms. -""" -%prog [options] -""" -import logging -import logging.config -import logging.handlers -import sys -import os -import signal -import time -from getpass import getpass -import threading - -from sql import Table - -from trytond.config import config, parse_listen -from trytond import backend -from trytond.pool import Pool -from trytond.monitor import monitor -from .transaction import Transaction - - -class TrytonServer(object): - - def __init__(self, options): - - config.update_etc(options.configfile) - - if options.logconf: - logging.config.fileConfig(options.logconf) - logging.getLogger('server').info('using %s as logging ' - 'configuration file', options.logconf) - else: - logformat = ('%(process)s %(thread)s [%(asctime)s] ' - '%(levelname)s %(name)s %(message)s') - if options.verbose: - if options.dev: - level = logging.DEBUG - else: - level = logging.INFO - else: - level = logging.ERROR - logging.basicConfig(level=level, format=logformat) - logging.captureWarnings(True) - - self.logger = logging.getLogger(__name__) - - if options.configfile: - self.logger.info('using %s as configuration file', - options.configfile) - else: - self.logger.info('using default configuration') - self.logger.info('initialising distributed objects services') - self.xmlrpcd = [] - self.jsonrpcd = [] - self.options = options - - if time.tzname[0] != 'UTC': - self.logger.error('timezone is not set to UTC') - - def run(self): - "Run the server and never return" - init = {} - - signal.signal(signal.SIGINT, lambda *a: self.stop()) - signal.signal(signal.SIGTERM, lambda *a: self.stop()) - if hasattr(signal, 'SIGQUIT'): - signal.signal(signal.SIGQUIT, lambda *a: self.stop()) - if hasattr(signal, 'SIGUSR1'): - signal.signal(signal.SIGUSR1, lambda *a: self.restart()) - - if self.options.pidfile: - with open(self.options.pidfile, 'w') as fd_pid: - fd_pid.write("%d" % (os.getpid())) - - if not self.options.update: - self.start_servers() - - Database = backend.get('Database') - for db_name in self.options.database_names: - init[db_name] = False - try: - with Transaction().start(db_name, 0) as transaction: - database = Database(db_name) - database.connect() - if self.options.update: - if not database.test(): - self.logger.info("init db") - database.init() - init[db_name] = True - elif not database.test(): - raise Exception("'%s' is not a Tryton database!" % - db_name) - except Exception: - self.stop(False) - raise - - for db_name in self.options.database_names: - if self.options.update: - with Transaction().start(db_name, 0) as transaction,\ - transaction.connection.cursor() as cursor: - database = Database(db_name) - database.connect() - if not database.test(): - raise Exception("'%s' is not a Tryton database!" - % db_name) - lang = Table('ir_lang') - cursor.execute(*lang.select(lang.code, - where=lang.translatable == True)) - lang = [x[0] for x in cursor.fetchall()] - else: - lang = None - Pool(db_name).init(update=self.options.update, lang=lang) - - for db_name in self.options.database_names: - if init[db_name]: - # try to read password from environment variable - # TRYTONPASSFILE, empty TRYTONPASSFILE ignored - passpath = os.getenv('TRYTONPASSFILE') - password = '' - if passpath: - try: - with open(passpath) as passfile: - password = passfile.readline()[:-1] - except Exception, err: - sys.stderr.write('Can not read password ' - 'from "%s": "%s"\n' % (passpath, err)) - - if not password: - while True: - password = getpass('Admin Password for %s: ' % db_name) - password2 = getpass('Admin Password Confirmation: ') - if password != password2: - sys.stderr.write('Admin Password Confirmation ' - 'doesn\'t match Admin Password!\n') - continue - if not password: - sys.stderr.write('Admin Password is required!\n') - continue - break - - with Transaction().start(db_name, 0) as transaction: - pool = Pool() - User = pool.get('res.user') - admin, = User.search([('login', '=', 'admin')]) - User.write([admin], { - 'password': password, - }) - - if self.options.update: - self.logger.info('Update/Init succeed!') - logging.shutdown() - sys.exit(0) - - threads = {} - while True: - if self.options.cron: - for dbname in Pool.database_list(): - thread = threads.get(dbname) - if thread and thread.is_alive(): - continue - pool = Pool(dbname) - if not pool.lock.acquire(0): - continue - try: - try: - Cron = pool.get('ir.cron') - except KeyError: - continue - finally: - pool.lock.release() - thread = threading.Thread( - target=Cron.run, - args=(dbname,), kwargs={}) - thread.start() - threads[dbname] = thread - if self.options.dev: - for _ in range(60): - if monitor([self.options.configfile] - if self.options.configfile else []): - self.restart() - time.sleep(1) - else: - time.sleep(60) - - def start_servers(self): - ssl = config.get('ssl', 'privatekey') - # Launch Server - if config.get('jsonrpc', 'listen'): - from trytond.protocols.jsonrpc import JSONRPCDaemon - for hostname, port in parse_listen( - config.get('jsonrpc', 'listen')): - self.jsonrpcd.append(JSONRPCDaemon(hostname, port, ssl)) - self.logger.info("starting JSON-RPC%s protocol on %s:%d", - ssl and ' SSL' or '', hostname or '*', port) - - if config.get('xmlrpc', 'listen'): - from trytond.protocols.xmlrpc import XMLRPCDaemon - for hostname, port in parse_listen( - config.get('xmlrpc', 'listen')): - self.xmlrpcd.append(XMLRPCDaemon(hostname, port, ssl)) - self.logger.info("starting XML-RPC%s protocol on %s:%d", - ssl and ' SSL' or '', hostname or '*', port) - - for servers in (self.xmlrpcd, self.jsonrpcd): - for server in servers: - server.start() - - def stop(self, exit=True): - for servers in (self.xmlrpcd, self.jsonrpcd): - for server in servers: - server.stop() - server.join() - if exit: - if self.options.pidfile: - os.unlink(self.options.pidfile) - logging.getLogger('server').info('stopped') - logging.shutdown() - sys.exit(0) - - def restart(self): - self.stop(False) - args = ([sys.executable] + ['-W%s' % o for o in sys.warnoptions] - + sys.argv) - if sys.platform == "win32": - args = ['"%s"' % arg for arg in args] - os.execv(sys.executable, args) diff --git a/trytond/tests/test_tryton.py b/trytond/tests/test_tryton.py index e5647bc358..1e48a9723e 100644 --- a/trytond/tests/test_tryton.py +++ b/trytond/tests/test_tryton.py @@ -280,12 +280,12 @@ def db_exist(): def create_db(): if not db_exist(): - create(DB_NAME, None, 'en_US', USER_PASSWORD) + create(None, DB_NAME, None, 'en_US', USER_PASSWORD) def drop_db(): if db_exist(): - drop(DB_NAME, None) + drop(None, DB_NAME, None) def drop_create(): diff --git a/trytond/transaction.py b/trytond/transaction.py index 421879c6d8..9a92305de3 100644 --- a/trytond/transaction.py +++ b/trytond/transaction.py @@ -120,7 +120,7 @@ class Transaction(object): self.delete = None self.timestamp = None current_instance = transactions.pop() - assert current_instance is self + assert current_instance is self, transactions def set_context(self, context=None, **kwargs): if context is None: diff --git a/trytond/url.py b/trytond/url.py index 865558777d..80f445ee1c 100644 --- a/trytond/url.py +++ b/trytond/url.py @@ -10,7 +10,7 @@ from trytond.transaction import Transaction __all__ = ['URLMixin'] -HOSTNAME = (config.get('jsonrpc', 'hostname') +HOSTNAME = (config.get('web', 'hostname') or socket.getfqdn()) HOSTNAME = '.'.join(encodings.idna.ToASCII(part).decode('ascii') if part else '' for part in HOSTNAME.split('.')) diff --git a/trytond/wsgi.py b/trytond/wsgi.py new file mode 100644 index 0000000000..30deb51bbd --- /dev/null +++ b/trytond/wsgi.py @@ -0,0 +1,86 @@ +# This file is part of Tryton. The COPYRIGHT file at the top level of +# this repository contains the full copyright notices and license terms. +import logging + +from werkzeug.wrappers import Response +from werkzeug.routing import Map, Rule +from werkzeug.exceptions import abort + +import wrapt + +from trytond.protocols.wrappers import Request +from trytond.protocols.jsonrpc import JSONProtocol +from trytond.protocols.xmlrpc import XMLProtocol + +__all__ = ['TrytondWSGI', 'app'] + +logger = logging.getLogger(__name__) + + +class TrytondWSGI(object): + + def __init__(self): + self.url_map = Map([]) + self.protocols = [JSONProtocol, XMLProtocol] + self.error_handlers = [] + + def route(self, string, methods=None): + def decorator(func): + self.url_map.add(Rule(string, endpoint=func, methods=methods)) + return func + return decorator + + @wrapt.decorator + def auth_required(self, wrapped, instance, args, kwargs): + request = args[0] + if request.user_id: + return wrapped(*args, **kwargs) + else: + abort(303) + + def dispatch_request(self, request): + adapter = self.url_map.bind_to_environ(request.environ) + try: + endpoint, request.view_args = adapter.match() + return endpoint(request, **request.view_args) + except Exception, e: + response = e + for error_handler in self.error_handlers: + rv = error_handler(e) + if isinstance(rv, Response): + response = rv + return response + + def wsgi_app(self, environ, start_response): + for cls in self.protocols: + if cls.content_type in environ.get('CONTENT_TYPE', ''): + request = cls.request(environ) + break + else: + request = Request(environ) + data = self.dispatch_request(request) + if not isinstance(data, Response): + for cls in self.protocols: + for mimetype in request.accept_mimetypes: + if cls.content_type in mimetype: + response = cls.response(data, request) + break + else: + continue + break + else: + for cls in self.protocols: + if cls.content_type in environ.get('CONTENT_TYPE', ''): + response = cls.response(data, request) + break + else: + response = Response(data) + else: + response = data + # TODO custom process response + return response(environ, start_response) + + def __call__(self, environ, start_response): + return self.wsgi_app(environ, start_response) + +app = TrytondWSGI()