diff --git a/data/etc/revpipyload/revpipyload.conf b/data/etc/revpipyload/revpipyload.conf index 52f0d8f..e38ce76 100644 --- a/data/etc/revpipyload/revpipyload.conf +++ b/data/etc/revpipyload/revpipyload.conf @@ -7,7 +7,7 @@ plcworkdir_set_uid = 1 plcprogram = program.py plcprogram_stop_timeout = 5 plcprogram_watchdog = 0 -plcarguments = +plcarguments = plcuid = 1000 plcgid = 1000 pythonversion = 3 @@ -27,7 +27,8 @@ watchdog = 1 [XMLRPC] xmlrpc = 1 aclfile = /etc/revpipyload/aclxmlrpc.conf -bindip = 127.0.0.1 +bindip = socket +unixgroup = picontrol [MQTT] mqtt = 0 @@ -38,6 +39,6 @@ write_outputs = 0 broker_address = localhost port = 1883 tls_set = 0 -username = -password = -client_id = +username = +password = +client_id = diff --git a/src/revpipyload/__init__.py b/src/revpipyload/__init__.py index 743751b..afe436f 100644 --- a/src/revpipyload/__init__.py +++ b/src/revpipyload/__init__.py @@ -4,4 +4,4 @@ __author__ = "Sven Sager" __copyright__ = "Copyright (C) 2023 Sven Sager" __license__ = "GPLv2" __package__ = "revpipyload" -__version__ = "0.11.0rc2" +__version__ = "0.12.0" diff --git a/src/revpipyload/proginit.py b/src/revpipyload/proginit.py index d508627..71f3679 100644 --- a/src/revpipyload/proginit.py +++ b/src/revpipyload/proginit.py @@ -9,6 +9,7 @@ import os import sys from argparse import ArgumentParser from configparser import ConfigParser +from os import getpid from . import __version__ @@ -55,7 +56,7 @@ def configure(): ) parser.add_argument( "-c", "--conffile", dest="conffile", - default="revpipyload.conf", + default="/etc/revpipyload/revpipyload.conf", help="Application configuration file" ) parser.add_argument( @@ -186,3 +187,6 @@ def configure(): else: loglevel = logging.DEBUG logger.setLevel(loglevel) + + # Log PID for development purposes + logger.debug("Running with PID {}".format(getpid())) diff --git a/src/revpipyload/revpipyload.py b/src/revpipyload/revpipyload.py index 87aac87..74ab252 100644 --- a/src/revpipyload/revpipyload.py +++ b/src/revpipyload/revpipyload.py @@ -31,6 +31,7 @@ __license__ = "GPLv2" import gzip import os import signal +import socket import tarfile import zipfile from configparser import ConfigParser @@ -51,7 +52,7 @@ from . import proginit from .helper import get_revpiled_address, pi_control_reset, refullmatch from .shared.ipaclmanager import IpAclManager from .watchdogs import ResetDriverWatchdog -from .xrpcserver import SaveXMLRPCServer +from .xrpcserver import SaveXMLRPCServer, UnixStreamXMLRPCServer min_revpimodio = "2.5.0" @@ -69,7 +70,8 @@ class RevPiPyLoad: proginit.logger.debug("enter RevPiPyLoad.__init__()") # Klassenattribute - self._exit = True + self._evt_exit = Event() + self._evt_exit.set() self.evt_loadconfig = Event() self.globalconfig = ConfigParser() proginit.conf = self.globalconfig @@ -209,7 +211,9 @@ class RevPiPyLoad: proginit.logger.info( "loading config file: {0}".format(proginit.globalconffile) ) - self.globalconfig.read(proginit.globalconffile) + if not self.globalconfig.read(proginit.globalconffile): + raise RuntimeError("can not access config file '{0}'".format(proginit.globalconffile)) + self.__translate_config() proginit.conf = self.globalconfig @@ -313,12 +317,23 @@ class RevPiPyLoad: # Bind IP lesen und anpassen self.xmlrpcbindip = \ self.globalconfig.get("XMLRPC", "bindip", fallback="127.0.0.1") - if self.xmlrpcbindip == "*": - self.xmlrpcbindip = "" - elif self.xmlrpcbindip == "": - self.xmlrpcbindip = "127.0.0.1" + + if self.xmlrpcbindip.lower() == "socket": + # Unix Domain Socket mit festem Pfad + self.xmlrpcbindip = "/run/revpipyload/xmlrpc.socket" + self.xmlrpcisunix = True + elif self.xmlrpcbindip.startswith("/"): + # Unix Domain Socket + self.xmlrpcisunix = True + else: + self.xmlrpcisunix = False + if self.xmlrpcbindip == "*": + self.xmlrpcbindip = "" + elif self.xmlrpcbindip == "": + self.xmlrpcbindip = "127.0.0.1" self.xmlrpcport = self.globalconfig.getint("XMLRPC", "port", fallback=55123) + self.xmlrpcunixgroup = self.globalconfig.get("XMLRPC", "unixgroup", fallback="picontrol") # Workdirectory wechseln if not os.access(self.plcworkdir, os.R_OK | os.W_OK | os.X_OK): @@ -343,7 +358,7 @@ class RevPiPyLoad: self.stop_plcmqtt() self.th_plcmqtt = self._plcmqtt() - if not self._exit and self.th_plcmqtt is not None: + if not self._evt_exit.is_set() and self.th_plcmqtt is not None: proginit.logger.info("restart mqtt publisher after reload") self.th_plcmqtt.start() @@ -352,7 +367,7 @@ class RevPiPyLoad: self.stop_plcprogram() self.plc = self._plcthread() - if not self._exit and self.plc is not None and self.autostart: + if not self._evt_exit.is_set() and self.plc is not None and self.autostart: proginit.logger.info("restart plc program after reload") self.plc.start() @@ -372,7 +387,7 @@ class RevPiPyLoad: self.stop_plcserver() self.th_plcserver = self._plcserver() - if not self._exit and self.th_plcserver is not None: + if not self._evt_exit.is_set() and self.th_plcserver is not None: proginit.logger.info("restart plc server after reload") self.th_plcserver.start() @@ -387,12 +402,35 @@ class RevPiPyLoad: self.xsrv = None else: proginit.logger.debug("create xmlrpc server") - self.xsrv = SaveXMLRPCServer( - (self.xmlrpcbindip, self.xmlrpcport), - logRequests=False, - allow_none=True, - ipacl=self.xmlrpcacl - ) + + if self.xmlrpcisunix: + # Unix Domain Socket Server + proginit.logger.info( + "starting xmlrpc unix server on {0}".format(self.xmlrpcbindip) + ) + + # Vorherige Socket-Datei löschen + try: + os.unlink(self.xmlrpcbindip) + except FileNotFoundError: + pass + + self.xsrv = UnixStreamXMLRPCServer( + self.xmlrpcbindip, + logRequests=False, + allow_none=True, + unixgroup=self.xmlrpcunixgroup + ) + + else: + # Standard IP Server + self.xsrv = SaveXMLRPCServer( + (self.xmlrpcbindip, self.xmlrpcport), + logRequests=False, + allow_none=True, + ipacl=self.xmlrpcacl + ) + self.xsrv.register_introspection_functions() self.xsrv.register_multicall_functions() @@ -485,7 +523,7 @@ class RevPiPyLoad: proginit.logger.debug("created xmlrpc server") # Neustart bei reload - if not self._exit: + if not self._evt_exit.is_set(): proginit.logger.info("bind xmlrpc-server") self.xsrv.server_bind() self.xsrv.server_activate() @@ -762,7 +800,7 @@ class RevPiPyLoad: proginit.logger.debug("enter RevPiPyLoad.start()") proginit.logger.info("starting revpipyload") - self._exit = False + self._evt_exit.clear() if self.xmlrpc and self.xsrv is not None: proginit.logger.info("bind xmlrpc-server") @@ -786,7 +824,7 @@ class RevPiPyLoad: pictory_reset_driver.register_call(self.xml_psstop) # mainloop - while not self._exit: + while not self._evt_exit.is_set(): # Neue Konfiguration laden if self.evt_loadconfig.is_set(): proginit.logger.info("got reqeust to reload config") @@ -887,7 +925,7 @@ class RevPiPyLoad: def stop(self): """Stop revpipyload.""" proginit.logger.debug("enter RevPiPyLoad.stop()") - self._exit = True + self._evt_exit.set() proginit.logger.debug("leave RevPiPyLoad.stop()") def stop_plcmqtt(self): @@ -937,6 +975,14 @@ class RevPiPyLoad: proginit.logger.info("close xmlrpc-server") self.xsrv.server_close() + # Unix Socket aufräumen + if hasattr(self.xsrv, "address_family") and \ + self.xsrv.address_family == socket.AF_UNIX: + try: + os.unlink(self.xsrv.server_address) + except (FileNotFoundError, AttributeError): + pass + proginit.logger.debug("leave RevPiPyLoad.stop_xmlrpcserver()") def xml_getconfig(self): @@ -1064,11 +1110,19 @@ class RevPiPyLoad: :param file_name: File with full path relative to work directory :return: True on success """ - file_name = os.path.join(self.plcworkdir, file_name) - if os.path.exists(file_name): - os.remove(file_name) - dirname = os.path.dirname(file_name) - if dirname != self.plcworkdir: + plcworkdir = os.path.realpath(self.plcworkdir) + file_path = os.path.realpath(os.path.join(plcworkdir, file_name)) + + if os.path.commonpath([plcworkdir, file_path]) != plcworkdir: + proginit.logger.warning( + "file path is not in plc working directory" + ) + return False + + if os.path.exists(file_path): + os.remove(file_path) + dirname = os.path.dirname(file_path) + if dirname != plcworkdir: try: # Try to remove directory, which will work if it is empty os.rmdir(dirname) @@ -1104,9 +1158,17 @@ class RevPiPyLoad: :param file_name: File with full path relative to work directory :return: Binary object in gzip format """ - file_name = os.path.join(self.plcworkdir, file_name) - if os.path.exists(file_name): - with open(file_name, "rb") as fh: + plcworkdir = os.path.realpath(self.plcworkdir) + file_path = os.path.realpath(os.path.join(plcworkdir, file_name)) + + if os.path.commonpath([plcworkdir, file_path]) != plcworkdir: + proginit.logger.warning( + "file path is not in plc working directory" + ) + return Binary() + + if os.path.exists(file_path): + with open(file_path, "rb") as fh: xmldata = Binary(gzip.compress(fh.read())) return xmldata return Binary() @@ -1169,7 +1231,7 @@ class RevPiPyLoad: else: return -1 - def xml_plcupload(self, filedata, filename): + def xml_plcupload(self, filedata: Binary, filename:str): """Empfaengt Dateien fuer das PLC Programm einzeln. @param filedata GZIP Binary data der Datei @@ -1185,14 +1247,18 @@ class RevPiPyLoad: # Windowszeichen prüfen filename = filename.replace("\\", "/") - # Build absolut path, join will return last element, if absolute - dirname = os.path.join(self.plcworkdir, os.path.dirname(filename)) - if os.path.abspath(dirname).find(self.plcworkdir) != 0: + + plcworkdir = os.path.realpath(self.plcworkdir) + file_path = os.path.realpath(os.path.join(plcworkdir, filename)) + + if os.path.commonpath([plcworkdir, file_path]) != plcworkdir: proginit.logger.warning( "file path is not in plc working directory" ) return False + dirname = os.path.dirname(file_path) + set_uid = self.plcuid if self.plcworkdir_set_uid else 0 set_gid = self.plcgid if self.plcworkdir_set_uid else 0 @@ -1210,9 +1276,9 @@ class RevPiPyLoad: # Datei erzeugen try: - with open(filename, "wb") as fh: + with open(file_path, "wb") as fh: fh.write(gzip.decompress(filedata.data)) - os.chown(filename, set_uid, set_gid) + os.chown(file_path, set_uid, set_gid) return True except Exception: return False diff --git a/src/revpipyload/secure_installation.py b/src/revpipyload/secure_installation.py index 070650c..35e185c 100644 --- a/src/revpipyload/secure_installation.py +++ b/src/revpipyload/secure_installation.py @@ -18,6 +18,7 @@ def main() -> int: """Secure installation script to use on Revolution Pi.""" from configparser import ConfigParser from os import R_OK, access, getuid, system + from shutil import which from re import match from sys import stderr, stdout @@ -42,7 +43,9 @@ def main() -> int: # Prepare variables xmlrpcacl = IpAclManager(minlevel=0, maxlevel=4) xmlrpcacl.loadaclfile(aclxmlrpc) - xmlrpc_only_localhost = xmlrpcbindip.find("127.") == 0 or xmlrpcbindip == "" + xmlrpc_unix_socket = xmlrpcbindip == "socket" or xmlrpcbindip.startswith("/") + xmlrpc_only_localhost = xmlrpcbindip.find("127.") == 0 or xmlrpcbindip == "" or \ + xmlrpc_unix_socket # ----- Print summary of actual configuration stdout.write(""" @@ -58,7 +61,8 @@ def main() -> int: aclxmlrpc=aclxmlrpc, xmlrpc="" if xmlrpc else "NOT ", source="" if not xmlrpc - else " from this computer only (localhost)." if xmlrpc_only_localhost + else " from this computer / SSH only (unix socket)." if xmlrpcbindip == "socket" or xmlrpcbindip.startswith("/") + else " from this computer / SSH only (localhost)." if xmlrpc_only_localhost else " from ACL listed remote computers!" )) @@ -81,7 +85,7 @@ def main() -> int: stdout.write("{0:15} - Level: {1:2}".format(ip, level)) counter += 1 stdout.write("\n") - else: + elif not xmlrpc_unix_socket: stderr.write( "\nWARNING: NO IP addresses defined in ACL!\n You will " "not be able to connect with RevPiPyControl at this moment!\n" @@ -99,8 +103,14 @@ def main() -> int: stderr.write("\nYou need root permissions to change values (sudo).\n") return 4 - cmd = input("\nDo you want to allow connections from remote hosts? (y/N) ").lower() + cmd = input("\nDo you want to allow connections ONLY via SSH/Unix-Socket? (y/N) ").lower() if cmd == "y": + conf.set("XMLRPC", "xmlrpc", "1") + conf.set("XMLRPC", "bindip", "socket") + xmlrpcacl.acl = "" + save_xmlrpcacls() + + elif input("\nDo you want to allow connections from remote hosts? (y/N) ").lower() == "y": conf.set("XMLRPC", "xmlrpc", "1") conf.set("XMLRPC", "bindip", "*") @@ -169,15 +179,25 @@ def main() -> int: stdout.write("\n\nWe did no changes!\n") return 2 - if system("/etc/init.d/revpipyload status > /dev/null") == 0: + # Check for service manager + if which("systemctl"): + cmd_status = "systemctl status revpipyload > /dev/null" + cmd_reload = "systemctl reload revpipyload" + hint_reload = "sudo systemctl reload revpipyload" + else: + cmd_status = "/etc/init.d/revpipyload status > /dev/null" + cmd_reload = "/etc/init.d/revpipyload reload" + hint_reload = "sudo /etc/init.d/revpipyload reload" + + if system(cmd_status) == 0: try: cmd = input("\nDo you want to apply the new settings now? (Y/n) ").lower() if cmd in ("", "y"): - system("/etc/init.d/revpipyload reload") + system(cmd_reload) else: stderr.write( "\nYou have to activate the new settings for RevPiPyLoad!\n" - " sudo /etc/init.d/revpipyload reload\n" + " {0}\n".format(hint_reload) ) except KeyboardInterrupt: pass diff --git a/src/revpipyload/xrpcserver.py b/src/revpipyload/xrpcserver.py index 9f9a616..7d51ce7 100644 --- a/src/revpipyload/xrpcserver.py +++ b/src/revpipyload/xrpcserver.py @@ -4,6 +4,9 @@ __author__ = "Sven Sager" __copyright__ = "Copyright (C) 2023 Sven Sager" __license__ = "GPLv2" +import grp +import os +import socket from xmlrpc.server import SimpleXMLRPCRequestHandler, SimpleXMLRPCServer from . import proginit @@ -102,3 +105,92 @@ class SaveXMLRPCRequestHandler(SimpleXMLRPCRequestHandler): ) return False + + +class UnixStreamXMLRPCRequestHandler(SimpleXMLRPCRequestHandler): + """XML-RPC Request-Handler fuer Unix Domain Sockets.""" + + timeout = 2.0 + + def setup(self): + """Initialisiert die Verbindung ohne TCP-spezifische Socket-Optionen.""" + self.connection = self.request + if self.timeout is not None: + self.connection.settimeout(self.timeout) + self.rfile = self.connection.makefile("rb", self.rbufsize) + self.wfile = self.connection.makefile("wb", self.wbufsize) + + def address_string(self): + """Liefert einen Namen fuer Logging bei Unix Domain Sockets.""" + return "localhost" + + +class UnixStreamXMLRPCServer(SimpleXMLRPCServer): + """XML-RPC Server fuer Unix Domain Sockets.""" + address_family = socket.AF_UNIX + + def __init__( + self, addr, logRequests=True, allow_none=False, unixgroup="picontrol"): + """Init UnixStreamXMLRPCServer class.""" + proginit.logger.debug("enter UnixStreamXMLRPCServer.__init__()") + + self.timeout = 0.5 + self.unixgroup = unixgroup + + # Create subdirectories for Unix Domain Socket + socket_dir = os.path.dirname(addr) + if socket_dir: + os.makedirs(socket_dir, exist_ok=True) + + super().__init__( + addr=addr, + requestHandler=UnixStreamXMLRPCRequestHandler, + logRequests=logRequests, + allow_none=allow_none, + encoding="utf-8", + bind_and_activate=False, + ) + + proginit.logger.debug("leave UnixStreamXMLRPCServer.__init__()") + + def server_bind(self): + """Ueberschreibt server_bind um Berechtigungen zu setzen.""" + super().server_bind() + + # Gruppe setzen + try: + gid = grp.getgrnam(self.unixgroup).gr_gid + # -1 will leave the owner unchanged + os.chown(self.server_address, -1, gid) + except (KeyError, PermissionError): + proginit.logger.warning( + "can not set group of socket {0} to '{1}'" + "".format(self.server_address, self.unixgroup) + ) + + # Berechtigungen fuer restliche Benutzer entziehen + try: + os.chmod(self.server_address, 0o660) + except PermissionError: + proginit.logger.warning( + "can not set permissions of socket {0}" + "".format(self.server_address) + ) + + def _dispatch(self, method, params): + """Prueft ACL Level fuer angeforderte Methode. + + @param method Angeforderte Methode + @param params Argumente fuer Methode + @return Dispatched data + + """ + # ACL on a socket is always max level + if method == "xmlmodus": + params = (4,) + + return super()._dispatch(method, params) + + def register_function(self, acl_level, function, name=None): + """Override register_function to ignore acl_level for unix sockets.""" + return super().register_function(function, name)