#!/usr/bin/python3
#
# Univention Management Console
# UMC server
#
# SPDX-FileCopyrightText: 2006-2025 Univention GmbH
# SPDX-License-Identifier: AGPL-3.0-only
import io
import json
import logging
import logging.handlers
import os
import resource
import signal
from argparse import ArgumentParser
import atexit
import tornado
from concurrent.futures import ThreadPoolExecutor
from sdnotify import SystemdNotifier
from tornado.httpserver import HTTPServer
from tornado.netutil import bind_sockets
from tornado.web import Application as TApplication, url
import univention.logging as ul
from univention.management.console import saml
from univention.management.console.config import env_to_settings, ucr
from univention.management.console.log import CORE, init_request_context_logging, log_init, log_reopen
from univention.management.console.oidc import (
OIDCBackchannelLogout, OIDCFrontchannelLogout, OIDCLogin, OIDCLogout, OIDCLogoutFinished, OIDCMetadata,
)
from univention.management.console.resources import (
UCR, Auth, Categories, Command, GetIPAddress, Hosts, Index, Info, Logout, Meta, Modules, NewSession, Nothing,
SessionInfo, Set, SetLocale, SetPassword, SetUserPreferences, SSELogoutNotifer, Upload, UserPreferences,
)
from univention.management.console.saml import SamlACS, SamlIframeACS, SamlLogout, SamlMetadata, SamlSingleLogout
from univention.management.console.session import categoryManager, moduleManager
from univention.management.console.shared_memory import shared_memory
from univention.udm import UDM
try:
from multiprocessing.util import _exit_function
except ImportError:
_exit_function = None
pool = ThreadPoolExecutor(max_workers=ucr.get_int('umc/http/maxthreads', 35))
[docs]
class Application(TApplication):
"""The tornado application with all UMC resources"""
def __init__(self, **settings):
tornado.locale.load_gettext_translations('/usr/share/locale', 'univention-management-console')
super().__init__([
url(r'/', Index, name='index'),
(r'/auth/?', Auth),
(r'/upload/?', Upload),
(r'/(upload)/(.+)', Command),
(r'/(command)/(.+)', Command),
(r'/get/session-info', SessionInfo),
(r'/get/ipaddress', GetIPAddress),
(r'/get/ucr', UCR),
(r'/get/meta', Meta),
(r'/get/info', Info),
(r'/get/newsession', NewSession),
(r'/get/modules', Modules),
(r'/get/categories', Categories),
(r'/get/user/preferences', UserPreferences),
(r'/get/hosts', Hosts),
(r'/set/?', Set),
(r'/set/password', SetPassword),
(r'/set/locale', SetLocale),
(r'/set/user/preferences', SetUserPreferences),
(r'/saml/', SamlACS),
(r'/saml/metadata', SamlMetadata),
(r'/saml/slo/?', SamlSingleLogout),
(r'/saml/logout/?', SamlLogout),
(r'/saml/iframe/?', SamlIframeACS),
url(r'/oidc/', OIDCLogin, name='oidc-login'),
url(r'/oidc/logout', OIDCLogout, name='oidc-logout'),
url(r'/oidc/frontchannel-logout', OIDCFrontchannelLogout, name='frontchannel-logout'),
url(r'/oidc/backchannel-logout', OIDCBackchannelLogout, name='backchannel-logout'),
url(r'/oidc/logout-done', OIDCLogoutFinished, name='oidc-logout-done'),
url(r'/oidc/.well-known/oauth-client', OIDCMetadata),
url(r'/logout-sse', SSELogoutNotifer),
(r'/logout/?', Logout),
(r'()/(.+)', Command),
], default_handler_class=Nothing, **settings)
SamlACS.reload()
[docs]
def tornado_log_reopen():
for logname in ('tornado', 'tornado.access', 'tornado.application', 'tornado.general', 'tornado.curl_httpclient'):
logger = logging.getLogger(logname)
for handler in logger.handlers:
if isinstance(handler, logging.handlers.RotatingFileHandler):
handler.doRollover()
[docs]
class Server:
"""univention-management-console-server"""
def __init__(self):
self.parser = ArgumentParser()
self.parser.add_argument(
'-d', '--debug', type=int, default=ucr.get_int('umc/server/debug/level', 1),
help='if given then debugging is activated and set to the specified level [default: %(default)s]',
)
self.parser.add_argument(
'-L', '--log-file', default='/var/log/univention/management-console-server.log',
help='specifies an alternative log file [default: %(default)s]',
)
self.parser.add_argument(
'-p', '--port', default=ucr.get_int('umc/http/port', 8090), type=int,
help='defines an alternative port number [default %(default)s]',
)
self.parser.add_argument(
'-c', '--processes', type=int, default=1, # ucr.get_int('umc/http/processes', 1),
help='How many processes to fork. 0 means auto detection [default: %(default)s].',
)
self.parser.add_argument(
'--no-daemonize-module-processes', action='store_true', help='starts modules in foreground so that logs go to stdout',
)
self.options = self.parser.parse_args()
saml.PORT = self.options.port
self._child_number = None
# TODO: not really?
# os.environ['LANG'] = locale.normalize(self.options.language)
# init logging
log_init(self.options.log_file, self.options.debug, self.options.processes > 1, use_structured_logging=ucr.is_true('umc/server/debug/structured-logging'))
[docs]
def signal_handler_hup(self, signo, frame):
"""Handler for the postrotate action"""
CORE.process('Got SIGHUP')
ucr.load()
log_reopen()
tornado_log_reopen()
self._inform_childs(signal)
[docs]
def signal_handler_sigusr2(self, signo, frame):
"""Handler for SIGUSR2 for debugging e.g. memory analysis"""
self.analyse_memory()
[docs]
def signal_handler_reload(self, signo, frame):
"""Handler for the reload action"""
CORE.process('Got SIGUSR1')
log_reopen()
tornado_log_reopen()
SamlACS.reload()
self.reload()
self._inform_childs(signal)
[docs]
def signal_handler_stop(self, signo, frame):
CORE.warning('Shutting down all open connections')
self._inform_childs(signal)
raise SystemExit(0)
[docs]
@classmethod
def reload(cls):
CORE.info('Reloading resources: UCR, modules, categories')
ucr.load()
moduleManager.load()
categoryManager.load()
def _inform_childs(self, signal):
if self._child_number is not None:
return # we are the child process
try:
children = list(shared_memory.children.items())
except OSError:
children = []
for _child, pid in children:
try:
os.kill(pid, signal)
except OSError as exc:
CORE.process('Failed sending signal %d to process %d: %s', signal, pid, exc)
[docs]
def run(self):
n = SystemdNotifier()
signal.signal(signal.SIGHUP, self.signal_handler_hup)
signal.signal(signal.SIGUSR1, self.signal_handler_reload)
signal.signal(signal.SIGUSR2, self.signal_handler_sigusr2)
signal.signal(signal.SIGTERM, self.signal_handler_stop)
tornado.httpclient.AsyncHTTPClient(defaults={
"request_timeout": ucr.get_int('umc/server/http-client/request-timeout', 180),
"connect_timeout": ucr.get_int('umc/server/http-client/connect-timeout', 180),
})
tornado.httpclient.AsyncHTTPClient.configure('tornado.curl_httpclient.CurlAsyncHTTPClient')
try:
fd_limit = ucr.get_int('umc/http/max-open-file-descriptors', 65535)
resource.setrlimit(resource.RLIMIT_NOFILE, (fd_limit, fd_limit))
except (OSError, ValueError) as exc:
CORE.error('Could not raise NOFILE resource limits: %s', exc)
# bind sockets
sockets = bind_sockets(self.options.port, ucr.get('umc/http/interface', '127.0.0.1'), backlog=ucr.get_int('umc/http/requestqueuesize', 100), reuse_port=True)
try:
settings_data_mod = UDM.machine().version(3).get('settings/data')
umc_settings_position = f"cn=umc,cn=data,cn=univention,{ucr.get('ldap/base')}"
umc_settings_obj = settings_data_mod.get(umc_settings_position)
settings_obj = json.loads(umc_settings_obj.props.data.raw.decode('utf-8'))
except Exception as exc:
CORE.info('Could not read from umc settings/data object. Continuing without shared db session %s', exc)
else:
for env_var, (setting_name, default_setting_value) in env_to_settings.items():
if os.environ.get(env_var, None) is None:
if setting_name in settings_obj:
os.environ[env_var] = settings_obj[setting_name]
elif default_setting_value is not None:
os.environ[env_var] = default_setting_value
CORE.info('Could not read %s from umc settings/data object. Continuing with default value %s', setting_name, default_setting_value)
# start sub worker processes
if self.options.processes != 1:
# start sharing memory (before fork, before first usage, after import)
shared_memory.start()
# stop conflicting exit function of shared_memory in this main process
if _exit_function is not None:
atexit.unregister(_exit_function)
CORE.process('Starting with %r processes', self.options.processes)
n.notify("READY=1")
try:
self._child_number = tornado.process.fork_processes(self.options.processes, 0)
except RuntimeError as exc:
CORE.warning('Child process died: %s', exc)
os.kill(os.getpid(), signal.SIGTERM)
raise SystemExit(str(exc))
except KeyboardInterrupt:
raise SystemExit(0)
if self._child_number is not None:
shared_memory.children[self._child_number] = os.getpid()
with open('/usr/share/univention-management-console/oidc/oidc.json') as fd:
config = json.load(fd)
oidc = config.get('oidc', {})
for setting in oidc.values():
with open(setting['openid_configuration']) as fd:
setting["op"] = json.loads(fd.read())
with open(setting['openid_certs']) as fd:
setting["jwks"] = json.loads(fd.read())
with open(setting['client_secret_file']) as fd:
setting['client_secret'] = fd.read().strip()
settings = {
'oidc': oidc,
'default_authorization_server': config.get('default_authorization_server'),
'umc_oidc_rp_server': ucr.get('umc/oidc/rp/server', None),
}
application = Application(
serve_traceback=ucr.is_true('umc/http/show_tracebacks', True),
no_daemonize_module_processes=self.options.no_daemonize_module_processes,
**settings,
)
server = HTTPServer(
application,
idle_connection_timeout=ucr.get_int('umc/http/response-timeout', 310), # TODO: is this correct? should be internal response timeout
max_body_size=ucr.get_int('umc/http/max_request_body_size', 104857600),
)
self.server = server
server.add_sockets(sockets)
ul.extendLogger('tornado', univention_debug_category='NETWORK')
handler = logging.getLogger('NETWORK')
handler.set_ud_level(ucr.get_int('umc/server/tornado-debug/level', 0))
init_request_context_logging('server')
self.reload()
n.notify("READY=1")
ioloop = tornado.ioloop.IOLoop.current()
try:
ioloop.start()
except Exception:
CORE.exception('Error during server loop')
ioloop.stop()
pool.shutdown(False)
raise
except (KeyboardInterrupt, SystemExit):
ioloop.stop()
pool.shutdown(False)
[docs]
@staticmethod
def analyse_memory() -> None:
"""Print the number of living UMC objects. Helpful when analysing memory leaks."""
components = (
'session.Session', 'session.User', 'session.IACLs', 'session.Processes',
'server.Server',
'acl.ACLs', 'acl.LDAP_ACLs', 'acl.Rule', 'auth.AuthHandler',
'base.Base',
'category.Manager', 'category.XML_Definition',
'ldap.LDAP',
'locales.I18N', 'locales.I18N_Manager',
'module.Manager', 'module.Module', 'module.Flavor',
'resources.ModuleProcess', 'resources.SessionInfo', 'resources.Command',
'saml.SAMLUser', 'saml.SamlACS', 'saml.SamlIframeACS', 'saml.SamlLogout', 'saml.SamlSingleLogout',
)
try:
import objgraph
except ImportError:
return
CORE.warning('### MEMORY')
s = io.StringIO()
objgraph.show_most_common_types(30, shortnames=False, file=s, filter=lambda o: type(o).__module__.startswith('univention.'))
CORE.warning('%s', s.getvalue())
CORE.warning('univention.admin.uldap.access: %d', objgraph.count('univention.admin.uldap.access'))
CORE.warning('univention.uldap.access: %d', objgraph.count('univention.uldap.access'))
for component in components:
CORE.warning('%s: %d', component, objgraph.count('univention.management.console.%s' % (component,)))
# objgraph.show_backrefs(objgraph.by_type('univention.uldap.access')[0])
[docs]
def main():
Server().run()
if __name__ == '__main__':
main()