From 581e901fb9e28ef1fe2a9c43ad3f43f5d9728caf Mon Sep 17 00:00:00 2001 From: Tomas Kopecek Date: Apr 11 2017 14:28:45 +0000 Subject: web plugins --- diff --git a/docs/source/writing_a_plugin.rst b/docs/source/writing_a_plugin.rst index 3611302..af05ba3 100644 --- a/docs/source/writing_a_plugin.rst +++ b/docs/source/writing_a_plugin.rst @@ -188,3 +188,69 @@ tagging a build: :: $ koji tag-build mytag mypkg-1.0-1 + +Web interface plugins +--------------------- + +There is a few things in web interface which can be modified in this way. +When you are creating new task type, you would like to see: + + * Listed it in filter box on taskinfo page (configurable without writing plugin) + * Better crafted taskinfo page + * New specialized page / handler. + +How to modify task filter +~~~~~~~~~~~~~~~~~~~~~~~~~ + +It is not a real plugin, but three web configuration items are used. `Tasks` +item is used to mark additional tasks which should be listed here. +`ToplevelTasks` and `ParentTasks` are for displaying these types of +tasks with correct hierarchy. + +:: + + Tasks = runroot + ToplevelTasks = runroot + ParentTasks = + +Web plugin +~~~~~~~~~~ + +For handling special taskinfo page or writing your own handler you've to +write your own plugin. + +Location of plugin is handled via `PluginPaths` option and active plugins +must be listed in `Plugins` field. Firstly, system-level plugin paths are +searched, and after them these user-defined paths, so user plugins can +override system behaviour. Order is important as plugins are always run in +it. + +:: + + PluginPaths = /anywhere/my_plugins + Plugins = my_plugin + + +Each plugin is python class derived from `WWWPlugin` in `kojiweb.util`. +It needs to implement at least one of following methods: + + * `handler_xyz(self, environ)` - all methods prefixed with `handler_` + will be exported to web interface, so this one will be located at + `http://mykojidomain/koji/xyz`. You can do whatever you want inside this + handler. Lot of useful stuff is available in `kojiweb.util` module. + `_assertLogin` is one example. + * `values_xyz` and `template_xyz` are prepared for use also on other pages + if anybody find it usable. For now, only `taskinfo` page is supported. + You've to define `methods = ['runroot']` for our example, so web + interface will pick these methods instead of default one. + * `values_taskinfo(self, server, values, environ)` - method returning + dictionary of values which can be used in taskinfo page template + * `template_taskinfo` - returns path to template which will be used for + displaying `Parameters` section in taskinfo page. It is written in + Cheetah and should wrap its content in conditional based on task method: + +:: + + #if $task.method == 'runroot' + Hey, you\'ve used runroot in tag $params[0]! + #endif diff --git a/plugins/www/runroot.py b/plugins/www/runroot.py new file mode 100644 index 0000000..3ecde04 --- /dev/null +++ b/plugins/www/runroot.py @@ -0,0 +1,7 @@ +from kojiweb.util import WWWPlugin + +class RunrootWWWPlugin(WWWPlugin): + methods = ['runroot'] + + def template_taskinfo(self): + return '/usr/lib/koji-web-plugins/runroot_taskinfo.chtml' diff --git a/plugins/www/runroot_taskinfo.chtml b/plugins/www/runroot_taskinfo.chtml new file mode 100644 index 0000000..c3af413 --- /dev/null +++ b/plugins/www/runroot_taskinfo.chtml @@ -0,0 +1,6 @@ +#if $task.method == 'runroot' + Build Tag: $params[0]
+ Arch: $params[1]
+ $printOpts($params[3]) + Commands: $params[2]
+#end if diff --git a/www/conf/web.conf b/www/conf/web.conf index db73a1e..0082083 100644 --- a/www/conf/web.conf +++ b/www/conf/web.conf @@ -42,6 +42,9 @@ LiteralFooter = True # ToplevelTasks = # Tasks that can have children # ParentTasks = +# +# PluginPaths = /usr/share/koji-web-plugins +# Plugins = runroot # Uncommenting this will show python tracebacks in the webUI, but they are the # same as what you will see in apache's error_log. diff --git a/www/kojiweb/index.py b/www/kojiweb/index.py index 49ea24b..92d2230 100644 --- a/www/kojiweb/index.py +++ b/www/kojiweb/index.py @@ -25,17 +25,17 @@ import os.path import re import sys import mimetypes -import Cookie import datetime import logging import time +import types import koji +import koji.plugin import kojiweb.util -from koji.server import ServerRedirect -from kojiweb.util import _initValues -from kojiweb.util import _genHTML -from kojiweb.util import _getValidTokens -from koji.util import sha1_constructor +from kojiweb.util import _initValues, _genHTML, _getServer, _redirectBack, \ + _sslLogin, _krbLogin, _setUserCookie, \ + _getUserCookie, _clearUserCookie, _assertLogin, \ + _redirect, _getBaseURL, WWWPlugin # Convenience definition of a commonly-used sort function _sortbyname = kojiweb.util.sortByKeyFunc('name') @@ -43,177 +43,77 @@ _sortbyname = kojiweb.util.sortByKeyFunc('name') #loggers authlogger = logging.getLogger('koji.auth') -def _setUserCookie(environ, user): - options = environ['koji.options'] - # include the current time in the cookie so we can verify that - # someone is not using an expired cookie - value = user + ':' + str(int(time.time())) - if not options['Secret'].value: - raise koji.AuthError('Unable to authenticate, server secret not configured') - shasum = sha1_constructor(value) - shasum.update(options['Secret'].value) - value = "%s:%s" % (shasum.hexdigest(), value) - cookies = Cookie.SimpleCookie() - cookies['user'] = value - c = cookies['user'] #morsel instance - c['secure'] = True - c['path'] = os.path.dirname(environ['SCRIPT_NAME']) - # the Cookie module treats integer expire times as relative seconds - c['expires'] = int(options['LoginTimeout']) * 60 * 60 - out = c.OutputString() - out += '; HttpOnly' - environ['koji.headers'].append(['Set-Cookie', out]) - environ['koji.headers'].append(['Cache-Control', 'no-cache="set-cookie"']) - -def _clearUserCookie(environ): - cookies = Cookie.SimpleCookie() - cookies['user'] = '' - c = cookies['user'] #morsel instance - c['path'] = os.path.dirname(environ['SCRIPT_NAME']) - c['expires'] = 0 - out = c.OutputString() - environ['koji.headers'].append(['Set-Cookie', out]) - -def _getUserCookie(environ): - options = environ['koji.options'] - cookies = Cookie.SimpleCookie(environ.get('HTTP_COOKIE', '')) - if 'user' not in cookies: - return None - value = cookies['user'].value - parts = value.split(":", 1) - if len(parts) != 2: - authlogger.warn('malformed user cookie: %s' % value) - return None - sig, value = parts - if not options['Secret'].value: - raise koji.AuthError('Unable to authenticate, server secret not configured') - shasum = sha1_constructor(value) - shasum.update(options['Secret'].value) - if shasum.hexdigest() != sig: - authlogger.warn('invalid user cookie: %s:%s', sig, value) - return None - parts = value.split(":", 1) - if len(parts) != 2: - authlogger.warn('invalid signed user cookie: %s:%s', sig, value) - # no embedded timestamp - return None - user, timestamp = parts - try: - timestamp = float(timestamp) - except ValueError: - authlogger.warn('invalid time in signed user cookie: %s:%s', sig, value) - return None - if (time.time() - timestamp) > (int(options['LoginTimeout']) * 60 * 60): - authlogger.info('expired user cookie: %s', value) - return None - # Otherwise, cookie is valid and current - return user - -def _krbLogin(environ, session, principal): - options = environ['koji.options'] - wprinc = options['WebPrincipal'] - keytab = options['WebKeytab'] - ccache = options['WebCCache'] - return session.krb_login(principal=wprinc, keytab=keytab, - ccache=ccache, proxyuser=principal) - -def _sslLogin(environ, session, username): - options = environ['koji.options'] - client_cert = options['WebCert'] - server_ca = options['KojiHubCA'] - - return session.ssl_login(client_cert, None, server_ca, - proxyuser=username) - -def _assertLogin(environ): - session = environ['koji.session'] - options = environ['koji.options'] - if 'koji.currentLogin' not in environ or 'koji.currentUser' not in environ: - raise Exception('_getServer() must be called before _assertLogin()') - elif environ['koji.currentLogin'] and environ['koji.currentUser']: - if options['WebCert']: - if not _sslLogin(environ, session, environ['koji.currentLogin']): - raise koji.AuthError('could not login %s via SSL' % environ['koji.currentLogin']) - elif options['WebPrincipal']: - if not _krbLogin(environ, environ['koji.session'], environ['koji.currentLogin']): - raise koji.AuthError('could not login using principal: %s' % environ['koji.currentLogin']) - else: - raise koji.AuthError('KojiWeb is incorrectly configured for authentication, contact the system administrator') - - # verify a valid authToken was passed in to avoid CSRF - authToken = environ['koji.form'].getfirst('a', '') - validTokens = _getValidTokens(environ) - if authToken and authToken in validTokens: - # we have a token and it's valid - pass - else: - # their authToken is likely expired - # send them back to the page that brought them here so they - # can re-click the link with a valid authToken - _redirectBack(environ, page=None, forceSSL=(_getBaseURL(environ).startswith('https://'))) - assert False # pragma: no cover - else: - _redirect(environ, 'login') - assert False # pragma: no cover - -def _getServer(environ): - opts = environ['koji.options'] - session = koji.ClientSession(opts['KojiHubURL'], - opts={'krbservice': opts['KrbService'], - 'krb_rdns': opts['KrbRDNS']}) - - environ['koji.currentLogin'] = _getUserCookie(environ) - if environ['koji.currentLogin']: - environ['koji.currentUser'] = session.getUser(environ['koji.currentLogin']) - if not environ['koji.currentUser']: - raise koji.AuthError('could not get user for principal: %s' % environ['koji.currentLogin']) - _setUserCookie(environ, environ['koji.currentLogin']) - else: - environ['koji.currentUser'] = None - - environ['koji.session'] = session - return session - -def _construct_url(environ, page): - port = environ['SERVER_PORT'] - host = environ['SERVER_NAME'] - url_scheme = environ['wsgi.url_scheme'] - if (url_scheme == 'https' and port == '443') or \ - (url_scheme == 'http' and port == '80'): - return "%s://%s%s" % (url_scheme, host, page) - return "%s://%s:%s%s" % (url_scheme, host, port, page) - -def _getBaseURL(environ): - base = environ['SCRIPT_NAME'] - return _construct_url(environ, base) - -def _redirect(environ, location): - environ['koji.redirect'] = location - raise ServerRedirect - -def _redirectBack(environ, page, forceSSL): - if page: - # We'll work with the page we were given - pass - elif 'HTTP_REFERER' in environ: - page = environ['HTTP_REFERER'] - else: - page = 'index' - - # Modify the scheme if necessary - if page.startswith('http'): - pass - elif page.startswith('/'): - page = _construct_url(environ, page) - else: - page = _getBaseURL(environ) + '/' + page - if forceSSL: - page = page.replace('http:', 'https:') - else: - page = page.replace('https:', 'http:') +class Plugins(object): + def __init__(self): + self._plugins = [] + self.methods = set() + self.loaded = False + self.logger = logging.getLogger('koji.plugins') + + def register_plugin(self, plugin): + """Scan a given plugin for handlers + + Handlers are functions marked with one of the decorators defined in koji.plugin + """ + for v in vars(plugin).itervalues(): + if isinstance(v, (types.ClassType, types.TypeType)) and issubclass(v, WWWPlugin): + plugin = v() + self.methods |= set(plugin.methods) + self._plugins.append(plugin) + + def load_plugins(self, options): + """Load plugins specified by our configuration plus system plugins. Order + is that system plugins are first, so they can be overriden by + user-specified ones with same name.""" + syspath = '/usr/lib/koji-web-plugins' + pluginpaths = [syspath] + options['PluginPaths'] + tracker = koji.plugin.PluginTracker(path=pluginpaths) + for name in options['Plugins']: + self.logger.info('Loading plugin: %s' % name) + tracker.load(name) + self.register_plugin(tracker.get(name)) + self.loaded = True + + def supports(self, method): + """Returns True if at least one plugin supports given method.""" + return method in self.methods + + def templates_taskinfo(self, method): + """Returns list of templates which will be added as handlers to + taskinfo page.""" + templates = [] + for plugin in self._plugins: + if plugin.supports(method) and hasattr(plugin, 'template_taskinfo'): + t = plugin.template_taskinfo() + if os.path.exists(t): + templates.append(t) + else: + self.logger.warn("Template doesn't exist: %s" % t) + return templates + + def values_taskinfo(self, server, values, environ): + values = {} + for plugin in self.get_plugins(environ['koji.options']): + if hasattr(plugin, 'values_taskinfo'): + values.update(plugin.values_taskinfo(server, values, environ)) + return values + + def get_plugins(self, options): + """Return all plugins""" + if not self.loaded: + self.load_plugins(options) + return self._plugins + + def get_handlers(self, options): + """Return all handler_* methods here""" + handlers = [] + for plugin in self.get_plugins(options): + for name in dir(plugin): + if name.startswith('handler_') and callable(getattr(plugin, name)): + handlers.append(getattr(plugin, name)) + return handlers +PLUGINS = Plugins() - # and redirect to the page - _redirect(environ, page) def login(environ, page=None): session = _getServer(environ) @@ -687,6 +587,9 @@ def taskinfo(environ, taskID): else: values['perms'] = [] + values['PLUGINS'] = PLUGINS + values.update(PLUGINS.values_taskinfo(server, values, environ)) + return _genHTML(environ, 'taskinfo.chtml') def taskstatus(environ, taskID): diff --git a/www/kojiweb/taskinfo.chtml b/www/kojiweb/taskinfo.chtml index a4e050f..05c9886 100644 --- a/www/kojiweb/taskinfo.chtml +++ b/www/kojiweb/taskinfo.chtml @@ -285,11 +285,10 @@ $value Host: $params[1].name
Restart Task: $koji.taskLabel($rtask)
- #elif $task.method == 'runroot' - Build Tag: $params[0]
- Arch: $params[1]
- $printOpts($params[3]) - Commands: $params[2]
+ #elif $PLUGINS.supports($task.method) + #for $template in $PLUGINS.templates_taskinfo($task.method) + #include $template + #end for #else $params #end if diff --git a/www/kojiweb/wsgi_publisher.py b/www/kojiweb/wsgi_publisher.py index e2d427b..c877c4f 100644 --- a/www/kojiweb/wsgi_publisher.py +++ b/www/kojiweb/wsgi_publisher.py @@ -97,6 +97,9 @@ class Dispatcher(object): ['ToplevelTasks', 'list', []], ['ParentTasks', 'list', []], + ['PluginPaths', 'list', []], + ['Plugins', 'list', []], + ['RLIMIT_AS', 'string', None], ['RLIMIT_CORE', 'string', None], ['RLIMIT_CPU', 'string', None], @@ -220,7 +223,7 @@ class Dispatcher(object): self.formatter.environ = environ self.log_handler.setFormatter(self.formatter) - def find_handlers(self): + def find_handlers(self, options): for name in vars(kojiweb_handlers): if name.startswith('_'): continue @@ -236,6 +239,9 @@ class Dispatcher(object): tb_str = ''.join(traceback.format_exception(*sys.exc_info())) self.logger.error(tb_str) self.handler_index[name] = val + for plugin in kojiweb_handlers.PLUGINS.get_handlers(options): + # name is handler_realname, so [8:] + self.handler_index[plugin.__name__[8:]] = plugin def prep_handler(self, environ): path_info = environ['PATH_INFO'] @@ -288,7 +294,7 @@ class Dispatcher(object): sys.path.insert(0, scriptsdir) import index as kojiweb_handlers import kojiweb - self.find_handlers() + self.find_handlers(options) self.setup_logging2(environ) koji.util.setup_rlimits(options) # TODO - plugins? diff --git a/www/lib/kojiweb/util.py b/www/lib/kojiweb/util.py index 6cdcecc..28c081c 100644 --- a/www/lib/kojiweb/util.py +++ b/www/lib/kojiweb/util.py @@ -21,11 +21,15 @@ # Mike McLean import Cheetah.Template +import Cookie import datetime import koji -from koji.util import md5_constructor +from koji.util import md5_constructor, sha1_constructor +from koji.server import ServerRedirect +import logging import os import stat +import time #a bunch of exception classes that explainError needs from socket import error as socket_error from socket import sslerror as socket_sslerror @@ -42,6 +46,8 @@ try: except: SSL_Error = NoSuchException +#loggers +authlogger = logging.getLogger('koji.auth') themeInfo = {} themeCache = {} @@ -849,3 +855,197 @@ def task_result_to_html(result=None, exc_class=None, total_abbr_len += line_len return full_ret_str, abbr_ret_str + + +def _setUserCookie(environ, user): + options = environ['koji.options'] + # include the current time in the cookie so we can verify that + # someone is not using an expired cookie + value = user + ':' + str(int(time.time())) + if not options['Secret'].value: + raise koji.AuthError('Unable to authenticate, server secret not configured') + shasum = sha1_constructor(value) + shasum.update(options['Secret'].value) + value = "%s:%s" % (shasum.hexdigest(), value) + cookies = Cookie.SimpleCookie() + cookies['user'] = value + c = cookies['user'] #morsel instance + c['secure'] = True + c['path'] = os.path.dirname(environ['SCRIPT_NAME']) + # the Cookie module treats integer expire times as relative seconds + c['expires'] = int(options['LoginTimeout']) * 60 * 60 + out = c.OutputString() + out += '; HttpOnly' + environ['koji.headers'].append(['Set-Cookie', out]) + environ['koji.headers'].append(['Cache-Control', 'no-cache="set-cookie"']) + +def _clearUserCookie(environ): + cookies = Cookie.SimpleCookie() + cookies['user'] = '' + c = cookies['user'] #morsel instance + c['path'] = os.path.dirname(environ['SCRIPT_NAME']) + c['expires'] = 0 + out = c.OutputString() + environ['koji.headers'].append(['Set-Cookie', out]) + +def _getUserCookie(environ): + options = environ['koji.options'] + cookies = Cookie.SimpleCookie(environ.get('HTTP_COOKIE', '')) + if 'user' not in cookies: + return None + value = cookies['user'].value + parts = value.split(":", 1) + if len(parts) != 2: + authlogger.warn('malformed user cookie: %s' % value) + return None + sig, value = parts + if not options['Secret'].value: + raise koji.AuthError('Unable to authenticate, server secret not configured') + shasum = sha1_constructor(value) + shasum.update(options['Secret'].value) + if shasum.hexdigest() != sig: + authlogger.warn('invalid user cookie: %s:%s', sig, value) + return None + parts = value.split(":", 1) + if len(parts) != 2: + authlogger.warn('invalid signed user cookie: %s:%s', sig, value) + # no embedded timestamp + return None + user, timestamp = parts + try: + timestamp = float(timestamp) + except ValueError: + authlogger.warn('invalid time in signed user cookie: %s:%s', sig, value) + return None + if (time.time() - timestamp) > (int(options['LoginTimeout']) * 60 * 60): + authlogger.info('expired user cookie: %s', value) + return None + # Otherwise, cookie is valid and current + return user + +def _krbLogin(environ, session, principal): + options = environ['koji.options'] + wprinc = options['WebPrincipal'] + keytab = options['WebKeytab'] + ccache = options['WebCCache'] + return session.krb_login(principal=wprinc, keytab=keytab, + ccache=ccache, proxyuser=principal) + +def _sslLogin(environ, session, username): + options = environ['koji.options'] + client_cert = options['WebCert'] + server_ca = options['KojiHubCA'] + + return session.ssl_login(client_cert, None, server_ca, + proxyuser=username) + +def _assertLogin(environ): + session = environ['koji.session'] + options = environ['koji.options'] + if 'koji.currentLogin' not in environ or 'koji.currentUser' not in environ: + raise Exception('_getServer() must be called before _assertLogin()') + elif environ['koji.currentLogin'] and environ['koji.currentUser']: + if options['WebCert']: + if not _sslLogin(environ, session, environ['koji.currentLogin']): + raise koji.AuthError('could not login %s via SSL' % environ['koji.currentLogin']) + elif options['WebPrincipal']: + if not _krbLogin(environ, environ['koji.session'], environ['koji.currentLogin']): + raise koji.AuthError('could not login using principal: %s' % environ['koji.currentLogin']) + else: + raise koji.AuthError('KojiWeb is incorrectly configured for authentication, contact the system administrator') + + # verify a valid authToken was passed in to avoid CSRF + authToken = environ['koji.form'].getfirst('a', '') + validTokens = _getValidTokens(environ) + if authToken and authToken in validTokens: + # we have a token and it's valid + pass + else: + # their authToken is likely expired + # send them back to the page that brought them here so they + # can re-click the link with a valid authToken + _redirectBack(environ, page=None, forceSSL=(_getBaseURL(environ).startswith('https://'))) + assert False # pragma: no cover + else: + _redirect(environ, 'login') + assert False # pragma: no cover + +def _getServer(environ): + opts = environ['koji.options'] + session = koji.ClientSession(opts['KojiHubURL'], + opts={'krbservice': opts['KrbService'], + 'krb_rdns': opts['KrbRDNS']}) + + environ['koji.currentLogin'] = _getUserCookie(environ) + if environ['koji.currentLogin']: + environ['koji.currentUser'] = session.getUser(environ['koji.currentLogin']) + if not environ['koji.currentUser']: + raise koji.AuthError('could not get user for principal: %s' % environ['koji.currentLogin']) + _setUserCookie(environ, environ['koji.currentLogin']) + else: + environ['koji.currentUser'] = None + + environ['koji.session'] = session + return session + +def _construct_url(environ, page): + port = environ['SERVER_PORT'] + host = environ['SERVER_NAME'] + url_scheme = environ['wsgi.url_scheme'] + if (url_scheme == 'https' and port == '443') or \ + (url_scheme == 'http' and port == '80'): + return "%s://%s%s" % (url_scheme, host, page) + return "%s://%s:%s%s" % (url_scheme, host, port, page) + +def _getBaseURL(environ): + base = environ['SCRIPT_NAME'] + return _construct_url(environ, base) + +def _redirect(environ, location): + environ['koji.redirect'] = location + raise ServerRedirect + +def _redirectBack(environ, page, forceSSL): + if page: + # We'll work with the page we were given + pass + elif 'HTTP_REFERER' in environ: + page = environ['HTTP_REFERER'] + else: + page = 'index' + + # Modify the scheme if necessary + if page.startswith('http'): + pass + elif page.startswith('/'): + page = _construct_url(environ, page) + else: + page = _getBaseURL(environ) + '/' + page + if forceSSL: + page = page.replace('http:', 'https:') + else: + page = page.replace('https:', 'http:') + + # and redirect to the page + _redirect(environ, page) + +class WWWPlugin(object): + """Base class for web plugins + + Following methods can be defined in child classes. For more details see + docs/source/writing_a_plugins.rst. + + def values_taskinfo(self, server, values, environ): + return {} + + def template_taskinfo(self): + return ' + + def handler_xyz(self, environ): + return 'happy string' + """ + + methods = [] + + def supports(self, method): + return method in self.methods