# # Copyright (c) 2024 YunoHost Contributors # # This file is part of YunoHost (see https://yunohost.org) # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as # published by the Free Software Foundation, either version 3 of the # License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . # import re import os import time import yaml import subprocess from glob import glob from datetime import datetime from moulinette import m18n from yunohost.utils.error import YunohostError, YunohostValidationError from moulinette.utils.process import check_output from moulinette.utils.log import getActionLogger from moulinette.utils.filesystem import ( read_file, append_to_file, write_to_file, read_yaml, write_to_yaml, ) MOULINETTE_LOCK = "/var/run/moulinette_yunohost.lock" SERVICES_CONF = "/etc/yunohost/services.yml" SERVICES_CONF_BASE = "/usr/share/yunohost/conf/yunohost/services.yml" logger = getActionLogger("yunohost.service") def service_add( name, description=None, log=None, test_status=None, test_conf=None, needs_exposed_ports=None, need_lock=False, ): """ Add a custom service Keyword argument: name -- Service name to add description -- description of the service log -- Absolute path to log file to display test_status -- Specify a custom bash command to check the status of the service. N.B. : it only makes sense to specify this if the corresponding systemd service does not return the proper information. test_conf -- Specify a custom bash command to check if the configuration of the service is valid or broken, similar to nginx -t. needs_exposed_ports -- A list of ports that needs to be publicly exposed for the service to work as intended. need_lock -- Use this option to prevent deadlocks if the service does invoke yunohost commands. """ services = _get_services() services[name] = service = {} if log is not None: if not isinstance(log, list): log = [log] service["log"] = log if not description: # Try to get the description from systemd service unit, _ = _get_service_information_from_systemd(name) description = str(unit.get("Description", "")) if unit is not None else "" # If the service does not yet exists or if the description is empty, # systemd will anyway return foo.service as default value, so we wanna # make sure there's actually something here. if description == name + ".service": description = "" if description: service["description"] = description else: logger.warning( "/!\\ Packagers! You added a custom service without specifying a description. Please add a proper Description in the systemd configuration, or use --description to explain what the service does in a similar fashion to existing services." ) if need_lock: service["need_lock"] = True if test_status: service["test_status"] = test_status else: # Try to get the description from systemd service _, systemd_info = _get_service_information_from_systemd(name) type_ = systemd_info.get("Type") if systemd_info is not None else "" if type_ == "oneshot": logger.warning( "/!\\ Packagers! Please provide a --test_status when adding oneshot-type services in Yunohost, such that it has a reliable way to check if the service is running or not." ) if test_conf: service["test_conf"] = test_conf if needs_exposed_ports: service["needs_exposed_ports"] = needs_exposed_ports try: _save_services(services) except Exception as e: logger.warning(e) # we'll get a logger.warning with more details in _save_services raise YunohostError("service_add_failed", service=name) logger.success(m18n.n("service_added", service=name)) def service_remove(name): """ Remove a custom service Keyword argument: name -- Service name to remove """ services = _get_services() if name not in services: raise YunohostValidationError("service_unknown", service=name) del services[name] try: _save_services(services) except Exception: # we'll get a logger.warning with more details in _save_services raise YunohostError("service_remove_failed", service=name) logger.success(m18n.n("service_removed", service=name)) def service_start(names): """ Start one or more services Keyword argument: names -- Services name to start """ if isinstance(names, str): names = [names] for name in names: if _run_service_command("start", name): logger.success(m18n.n("service_started", service=name)) else: if service_status(name)["status"] != "running": raise YunohostError( "service_start_failed", service=name, logs=_get_journalctl_logs(name), ) logger.debug(m18n.n("service_already_started", service=name)) def service_stop(names): """ Stop one or more services Keyword argument: name -- Services name to stop """ if isinstance(names, str): names = [names] for name in names: if _run_service_command("stop", name): logger.success(m18n.n("service_stopped", service=name)) else: if service_status(name)["status"] != "inactive": raise YunohostError( "service_stop_failed", service=name, logs=_get_journalctl_logs(name) ) logger.debug(m18n.n("service_already_stopped", service=name)) def service_reload(names): """ Reload one or more services Keyword argument: name -- Services name to reload """ if isinstance(names, str): names = [names] for name in names: if _run_service_command("reload", name): logger.success(m18n.n("service_reloaded", service=name)) else: if service_status(name)["status"] != "inactive": raise YunohostError( "service_reload_failed", service=name, logs=_get_journalctl_logs(name), ) def service_restart(names): """ Restart one or more services. If the services are not running yet, they will be started. Keyword argument: name -- Services name to restart """ if isinstance(names, str): names = [names] for name in names: if _run_service_command("restart", name): logger.success(m18n.n("service_restarted", service=name)) else: if service_status(name)["status"] != "inactive": raise YunohostError( "service_restart_failed", service=name, logs=_get_journalctl_logs(name), ) def service_reload_or_restart(names, test_conf=True): """ Reload one or more services if they support it. If not, restart them instead. If the services are not running yet, they will be started. Keyword argument: name -- Services name to reload or restart """ if isinstance(names, str): names = [names] services = _get_services() for name in names: logger.debug(f"Reloading service {name}") test_conf_cmd = services.get(name, {}).get("test_conf") if test_conf and test_conf_cmd: p = subprocess.Popen( test_conf_cmd, shell=True, executable="/bin/bash", stdout=subprocess.PIPE, stderr=subprocess.STDOUT, ) out, _ = p.communicate() if p.returncode != 0: errors = out.decode().strip().split("\n") logger.error( m18n.n( "service_not_reloading_because_conf_broken", name=name, errors=errors, ) ) continue if _run_service_command("reload-or-restart", name): logger.success(m18n.n("service_reloaded_or_restarted", service=name)) else: if service_status(name)["status"] != "inactive": raise YunohostError( "service_reload_or_restart_failed", service=name, logs=_get_journalctl_logs(name), ) def service_enable(names): """ Enable one or more services Keyword argument: names -- Services name to enable """ if isinstance(names, str): names = [names] for name in names: if _run_service_command("enable", name): logger.success(m18n.n("service_enabled", service=name)) else: raise YunohostError( "service_enable_failed", service=name, logs=_get_journalctl_logs(name) ) def service_disable(names): """ Disable one or more services Keyword argument: names -- Services name to disable """ if isinstance(names, str): names = [names] for name in names: if _run_service_command("disable", name): logger.success(m18n.n("service_disabled", service=name)) else: raise YunohostError( "service_disable_failed", service=name, logs=_get_journalctl_logs(name) ) def service_status(names=[]): """ Show status information about one or more services (all by default) Keyword argument: names -- Services name to show """ services = _get_services() # If function was called with a specific list of service if names != []: # If user wanna check the status of a single service if isinstance(names, str): names = [names] # Validate service names requested for name in names: if name not in services.keys(): raise YunohostValidationError("service_unknown", service=name) # Filter only requested servivces services = {k: v for k, v in services.items() if k in names} # Remove services that aren't "real" services # # the historical reason is because regenconf has been hacked into the # service part of YunoHost will in some situation we need to regenconf # for things that aren't services # the hack was to add fake services... services = {k: v for k, v in services.items() if v.get("status", "") is not None} output = { s: _get_and_format_service_status(s, infos) for s, infos in services.items() } if len(names) == 1: return output[names[0]] return output def _get_service_information_from_systemd(service): "this is the equivalent of 'systemctl status $service'" import dbus d = dbus.SystemBus() systemd = d.get_object("org.freedesktop.systemd1", "/org/freedesktop/systemd1") manager = dbus.Interface(systemd, "org.freedesktop.systemd1.Manager") # c.f. https://zignar.net/2014/09/08/getting-started-with-dbus-python-systemd/ # Very interface, much intuitive, wow service_unit = manager.LoadUnit(service + ".service") service_proxy = d.get_object("org.freedesktop.systemd1", str(service_unit)) properties_interface = dbus.Interface( service_proxy, "org.freedesktop.DBus.Properties" ) unit = properties_interface.GetAll("org.freedesktop.systemd1.Unit") service = properties_interface.GetAll("org.freedesktop.systemd1.Service") if unit.get("LoadState", "not-found") == "not-found": # Service doesn't really exist return (None, None) else: return (unit, service) def _get_and_format_service_status(service, infos): systemd_service = infos.get("actual_systemd_service", service) raw_status, raw_service = _get_service_information_from_systemd(systemd_service) if raw_status is None: logger.error( f"Failed to get status information via dbus for service {systemd_service}, systemctl didn't recognize this service ('NoSuchUnit')." ) return { "status": "unknown", "start_on_boot": "unknown", "last_state_change": "unknown", "description": "Error: failed to get information for this service, it doesn't exists for systemd", "configuration": "unknown", } # Try to get description directly from services.yml description = infos.get("description") # If no description was there, try to get it from the .json locales if not description: translation_key = f"service_description_{service}" if m18n.key_exists(translation_key): description = m18n.n(translation_key) else: description = str(raw_status.get("Description", "")) output = { "status": str(raw_status.get("SubState", "unknown")), "start_on_boot": str(raw_status.get("UnitFileState", "unknown")), "last_state_change": "unknown", "description": description, "configuration": "unknown", } # Fun stuff™ : to obtain the enabled/disabled status for sysv services, # gotta do this ... cf code of /lib/systemd/systemd-sysv-install if output["start_on_boot"] == "generated": output["start_on_boot"] = ( "enabled" if glob("/etc/rc[S5].d/S??" + service) else "disabled" ) elif os.path.exists( f"/etc/systemd/system/multi-user.target.wants/{service}.service" ): output["start_on_boot"] = "enabled" if "StateChangeTimestamp" in raw_status: output["last_state_change"] = datetime.utcfromtimestamp( raw_status["StateChangeTimestamp"] / 1000000 ) # 'test_status' is an optional field to test the status of the service using a custom command if "test_status" in infos: p = subprocess.Popen( infos["test_status"], shell=True, executable="/bin/bash", stdout=subprocess.PIPE, stderr=subprocess.STDOUT, ) p.communicate() output["status"] = "running" if p.returncode == 0 else "failed" elif ( raw_service.get("Type", "").lower() == "oneshot" and output["status"] == "exited" ): # These are services like yunohost-firewall, hotspot, vpnclient, # ... they will be "exited" why doesn't provide any info about # the real state of the service (unless they did provide a # test_status, c.f. previous condition) output["status"] = "unknown" # 'test_status' is an optional field to test the status of the service using a custom command if "test_conf" in infos: p = subprocess.Popen( infos["test_conf"], shell=True, executable="/bin/bash", stdout=subprocess.PIPE, stderr=subprocess.STDOUT, ) out, _ = p.communicate() if p.returncode == 0: output["configuration"] = "valid" else: out = out.decode() output["configuration"] = "broken" output["configuration-details"] = out.strip().split("\n") return output def service_log(name, number=50): """ Log every log files of a service Keyword argument: name -- Service name to log number -- Number of lines to display """ services = _get_services() number = int(number) if name not in services.keys(): raise YunohostValidationError("service_unknown", service=name) log_list = services[name].get("log", []) if not isinstance(log_list, list): log_list = [log_list] # Legacy stuff related to --log_type where we'll typically have the service # name in the log list but it's not an actual logfile. Nowadays journalctl # is automatically fetch as well as regular log files. if name in log_list: log_list.remove(name) result = {} # First we always add the logs from journalctl / systemd result["journalctl"] = _get_journalctl_logs(name, number).splitlines() for log_path in log_list: if not os.path.exists(log_path): continue # Make sure to resolve symlinks log_path = os.path.realpath(log_path) # log is a file, read it if os.path.isfile(log_path): result[log_path] = _tail(log_path, number) continue elif not os.path.isdir(log_path): result[log_path] = [] continue for log_file in os.listdir(log_path): log_file_path = os.path.join(log_path, log_file) # not a file : skip if not os.path.isfile(log_file_path): continue if not log_file.endswith(".log"): continue result[log_file_path] = ( _tail(log_file_path, number) if os.path.exists(log_file_path) else [] ) return result def _run_service_command(action, service): """ Run services management command (start, stop, enable, disable, restart, reload) Keyword argument: action -- Action to perform service -- Service name """ services = _get_services() if service not in services.keys(): raise YunohostValidationError("service_unknown", service=service) possible_actions = [ "start", "stop", "restart", "reload", "reload-or-restart", "enable", "disable", ] if action not in possible_actions: raise ValueError( f"Unknown action '{action}', available actions are: {', '.join(possible_actions)}" ) cmd = f"systemctl {action} {service}" need_lock = services[service].get("need_lock", False) and action in [ "start", "stop", "restart", "reload", "reload-or-restart", ] if action in ["enable", "disable"]: cmd += " --quiet" try: # Launch the command logger.debug(f"Running '{cmd}'") p = subprocess.Popen(cmd.split(), stderr=subprocess.STDOUT) # If this command needs a lock (because the service uses yunohost # commands inside), find the PID and add a lock for it if need_lock: PID = _give_lock(action, service, p) # Wait for the command to complete p.communicate() if p.returncode != 0: logger.warning(m18n.n("service_cmd_exec_failed", command=cmd)) return False except Exception as e: logger.warning(m18n.n("unexpected_error", error=str(e))) return False finally: # Remove the lock if one was given if need_lock and PID != 0: _remove_lock(PID) return True def _give_lock(action, service, p): # Depending of the action, systemctl calls the PID differently :/ if action == "start" or action == "restart": systemctl_PID_name = "MainPID" else: systemctl_PID_name = "ControlPID" cmd_get_son_PID = f"systemctl show {service} -p {systemctl_PID_name}" son_PID = 0 # As long as we did not found the PID and that the command is still running while son_PID == 0 and p.poll() is None: # Call systemctl to get the PID # Output of the command is e.g. ControlPID=1234 son_PID = check_output(cmd_get_son_PID).split("=")[1] son_PID = int(son_PID) time.sleep(1) # If we found a PID if son_PID != 0: # Append the PID to the lock file logger.debug(f"Giving a lock to PID {son_PID} for service {service} !") append_to_file(MOULINETTE_LOCK, f"\n{son_PID}") return son_PID def _remove_lock(PID_to_remove): # FIXME ironically not concurrency safe because it's not atomic... PIDs = read_file(MOULINETTE_LOCK).split("\n") PIDs_to_keep = [PID for PID in PIDs if int(PID) != PID_to_remove] write_to_file(MOULINETTE_LOCK, "\n".join(PIDs_to_keep)) def _get_services(): """ Get a dict of managed services with their parameters """ try: services = read_yaml(SERVICES_CONF_BASE) or {} # These are keys flagged 'null' in the base conf legacy_keys_to_delete = [k for k, v in services.items() if v is None] services.update(read_yaml(SERVICES_CONF) or {}) services = { name: infos for name, infos in services.items() if name not in legacy_keys_to_delete } except Exception: return {} # Dirty hack to automatically find custom SSH port ... ssh_port_line = re.findall( r"\bPort *([0-9]{2,5})\b", read_file("/etc/ssh/sshd_config") ) if len(ssh_port_line) == 1: services["ssh"]["needs_exposed_ports"] = [int(ssh_port_line[0])] # Dirty hack to check the status of ynh-vpnclient if "ynh-vpnclient" in services: if "log" not in services["ynh-vpnclient"]: services["ynh-vpnclient"]["log"] = ["/var/log/ynh-vpnclient.log"] services_with_package_condition = [ name for name, infos in services.items() if infos.get("ignore_if_package_is_not_installed") ] for name in services_with_package_condition: package = services[name]["ignore_if_package_is_not_installed"] if os.system(f"dpkg --list | grep -q 'ii *{package}'") != 0: del services[name] php_fpm_versions = check_output( r"dpkg --list | grep -P 'ii php\d.\d-fpm' | awk '{print $2}' | grep -o -P '\d.\d' || true" ) php_fpm_versions = [v for v in php_fpm_versions.split("\n") if v.strip()] for version in php_fpm_versions: # Skip php 7.3 which is most likely dead after buster->bullseye migration # because users get spooked if version == "7.3": continue services[f"php{version}-fpm"] = { "log": f"/var/log/php{version}-fpm.log", "test_conf": f"php-fpm{version} --test", # ofc the service is phpx.y-fpm but the program is php-fpmx.y because why not ... "category": "web", } # Ignore metronome entirely if XMPP was disabled on all domains if "metronome" in services and not glob("/etc/metronome/conf.d/*.cfg.lua"): del services["metronome"] # Remove legacy /var/log/daemon.log and /var/log/syslog from log entries # because they are too general. Instead, now the journalctl log is # returned by default which is more relevant. for infos in services.values(): if infos.get("log") in ["/var/log/syslog", "/var/log/daemon.log"]: del infos["log"] return services def _save_services(services): """ Save managed services to files Keyword argument: services -- A dict of managed services with their parameters """ # Compute the diff with the base file # such that /etc/yunohost/services.yml contains the minimal # changes with respect to the base conf conf_base = yaml.safe_load(open(SERVICES_CONF_BASE)) or {} diff = {} for service_name, service_infos in services.items(): # Ignore php-fpm services, they are to be added dynamically by the core, # but not actually saved if service_name.startswith("php") and service_name.endswith("-fpm"): continue service_conf_base = conf_base.get(service_name, {}) or {} diff[service_name] = {} for key, value in service_infos.items(): if service_conf_base.get(key) != value: diff[service_name][key] = value diff = { name: infos for name, infos in diff.items() if infos or name not in conf_base } write_to_yaml(SERVICES_CONF, diff) def _tail(file, n): """ Reads a n lines from f with an offset of offset lines. The return value is a tuple in the form ``(lines, has_more)`` where `has_more` is an indicator that is `True` if there are more lines in the file. This function works even with splitted logs (gz compression, log rotate...) """ avg_line_length = 74 to_read = n try: if file.endswith(".gz"): import gzip f = gzip.open(file) lines = f.read().splitlines() else: f = open(file, errors="replace") pos = 1 lines = [] while len(lines) < to_read and pos > 0: try: f.seek(-(avg_line_length * to_read), 2) except IOError: # woops. apparently file is smaller than what we want # to step back, go to the beginning instead f.seek(0) pos = f.tell() lines = f.read().splitlines() if len(lines) >= to_read: return lines[-to_read:] avg_line_length *= 1.3 f.close() except IOError as e: logger.warning("Error while tailing file '%s': %s", file, e, exc_info=1) return [] if len(lines) < to_read: previous_log_file = _find_previous_log_file(file) if previous_log_file is not None: lines = _tail(previous_log_file, to_read - len(lines)) + lines return lines def _find_previous_log_file(file): """ Find the previous log file """ splitext = os.path.splitext(file) if splitext[1] == ".gz": file = splitext[0] splitext = os.path.splitext(file) ext = splitext[1] i = re.findall(r"\.(\d+)", ext) i = int(i[0]) + 1 if len(i) > 0 else 1 previous_file = file if i == 1 else splitext[0] previous_file = previous_file + f".{i}" if os.path.exists(previous_file): return previous_file previous_file = previous_file + ".gz" if os.path.exists(previous_file): return previous_file return None def _get_journalctl_logs(service, number="all"): services = _get_services() systemd_service = services.get(service, {}).get("actual_systemd_service", service) try: return check_output( f"journalctl --no-hostname --no-pager -u {systemd_service} -n{number}" ) except Exception: import traceback trace_ = traceback.format_exc() return f"error while get services logs from journalctl:\n{trace_}"