diff --git a/src/cfg/ogagent.cfg b/src/cfg/ogagent.cfg index 0291e88..38f8d86 100644 --- a/src/cfg/ogagent.cfg +++ b/src/cfg/ogagent.cfg @@ -20,7 +20,14 @@ log=DEBUG # Module specific # The sections must match the module name # This section will be passes on activation to module -#[Sample1] -#value1=Mariete -#value2=Yo -#remote=https://172.27.0.1:9999/rest +[ogAdmClient] +#path=test_modules/server +## this URL will probably be left equal to the other one, but let's see +remote=https://192.168.1.249/opengnsys/rest/__ogAdmClient +log=DEBUG + +#servidorAdm=192.168.2.1 +#puerto=2008 +pathinterface=/opt/opengnsys/interfaceAdm +urlMenu=https://192.168.2.1/opengnsys/varios/menubrowser.php +urlMsg=http://localhost/cgi-bin/httpd-log.sh diff --git a/src/opengnsys/linux/log.py b/src/opengnsys/linux/log.py index 396a2db..393f5e8 100644 --- a/src/opengnsys/linux/log.py +++ b/src/opengnsys/linux/log.py @@ -52,7 +52,7 @@ class LocalLogger(object): logging.basicConfig( filename=fname, filemode='a', - format='%(levelname)s %(asctime)s %(message)s', + format='%(levelname)s %(asctime)s (%(threadName)s) (%(funcName)s) %(message)s', level=logging.DEBUG ) self.logger = logging.getLogger('opengnsys') @@ -69,7 +69,7 @@ class LocalLogger(object): # our loglevels are 10000 (other), 20000 (debug), .... # logging levels are 10 (debug), 20 (info) # OTHER = logging.NOTSET - self.logger.log(int(level / 1000) - 10, message) + self.logger.log(int(level / 1000) - 10, message, stacklevel=4) def isWindows(self): return False diff --git a/src/opengnsys/linux/ogAdmClient.py b/src/opengnsys/linux/ogAdmClient.py new file mode 100755 index 0000000..831dcfe --- /dev/null +++ b/src/opengnsys/linux/ogAdmClient.py @@ -0,0 +1,114 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2014 Virtual Cable S.L. +# Copyright (c) 2024 Qindel Formación y Servicios S.L. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without modification, +# are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of Virtual Cable S.L. nor the names of its contributors +# may be used to endorse or promote products derived from this software +# without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +""" +@author: Adolfo Gómez, dkmaster at dkmon dot com +@author: Natalia Serrano, nserrano at qindel dot com +""" + + +from opengnsys.service import CommonService +from opengnsys.log import logger +from opengnsys.linux.daemon import Daemon + +import sys +import signal +import json + +try: + from prctl import set_proctitle # @UnresolvedImport +except ImportError: + def set_proctitle(_): + pass + + +class OGAgentSvc(Daemon, CommonService): + def __init__(self, args=None): + Daemon.__init__(self, '/var/run/opengnsys-agent.pid') + CommonService.__init__(self) + + def run(self): + logger.debug('** Running Daemon **') + set_proctitle('ogAdmClient') + + self.initialize() + + # Call modules initialization + # They are called in sequence, no threading is done at this point, so ensure modules onActivate always returns + + # ********************* + # * Main Service loop * + # ********************* + # Counter used to check ip changes only once every 10 seconds, for + # example + try: + while self.isAlive: + self.doWait(1000) + except (KeyboardInterrupt, SystemExit) as e: + logger.error('Requested exit of main loop') + except Exception as e: + logger.exception() + logger.error('Caught exception on main loop: {}'.format(e)) + + self.terminate() + self.notifyStop() + + def signal_handler(self, signal, frame): + self.isAlive = False + sys.stderr.write("signal handler: {}".format(signal)) + + +def usage(): + sys.stderr.write("usage: {} start|stop|restart|fg\n".format(sys.argv[0])) + sys.exit(2) + + +if __name__ == '__main__': + logger.setLevel('DEBUG') + + logger.debug('Executing actor') + daemon = OGAgentSvc() + + signal.signal(signal.SIGTERM, daemon.signal_handler) + signal.signal(signal.SIGINT, daemon.signal_handler) + + if len(sys.argv) == 2: + if 'start' == sys.argv[1]: + daemon.start() + elif 'stop' == sys.argv[1]: + daemon.stop() + elif 'restart' == sys.argv[1]: + daemon.restart() + elif 'fg' == sys.argv[1]: + daemon.run() + else: + usage() + sys.exit(0) + else: + usage() diff --git a/src/opengnsys/loader.py b/src/opengnsys/loader.py index ca053b6..577fcf7 100644 --- a/src/opengnsys/loader.py +++ b/src/opengnsys/loader.py @@ -100,12 +100,12 @@ def loadModules(controller, client=False): # paths += (os.path.dirname(sys.modules[modPath].__file__),) - logger.debug('Loading modules from {}'.format(paths)) - # Load modules + logger.debug('Loading modules from {}'.format(paths)) doLoad(paths) # Add to list of available modules + logger.debug('Adding {} classes'.format('server' if modType == ServerWorker else 'client')) recursiveAdd(modType) return ogModules diff --git a/src/opengnsys/windows/log.py b/src/opengnsys/windows/log.py index 7b2bec2..ea875fd 100644 --- a/src/opengnsys/windows/log.py +++ b/src/opengnsys/windows/log.py @@ -47,7 +47,7 @@ class LocalLogger(object): logging.basicConfig( filename=os.path.join(tempfile.gettempdir(), 'opengnsys.log'), filemode='a', - format='%(levelname)s %(asctime)s (%(threadName)s) %(message)s', + format='%(levelname)s %(asctime)s (%(threadName)s) (%(funcName)s) %(message)s', level=logging.DEBUG ) self.logger = logging.getLogger('opengnsys') @@ -58,7 +58,7 @@ class LocalLogger(object): # our loglevels are 10000 (other), 20000 (debug), .... # logging levels are 10 (debug), 20 (info) # OTHER = logging.NOTSET - self.logger.log(int(level / 1000 - 10), message) + self.logger.log(int(level / 1000 - 10), message, stacklevel=4) if level < INFO or self.serviceLogger is False: # Only information and above will be on event log return diff --git a/src/test_modules/server/ogAdmClient/__init__.py b/src/test_modules/server/ogAdmClient/__init__.py new file mode 100644 index 0000000..55aa49c --- /dev/null +++ b/src/test_modules/server/ogAdmClient/__init__.py @@ -0,0 +1,385 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# Copyright (c) 2014 Virtual Cable S.L. +# Copyright (c) 2024 Qindel Formación y Servicios S.L. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without modification, +# are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of Virtual Cable S.L. nor the names of its contributors +# may be used to endorse or promote products derived from this software +# without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +""" +@author: Ramón M. Gómez, ramongomez at us dot es +@author: Natalia Serrano, nserrano at qindel dot com +""" + + +import base64 +import os +import random +import shutil +import string +import threading +import time +import subprocess +import urllib.error +import urllib.parse +import urllib.request + +from configparser import NoOptionError +from opengnsys import REST, operations, VERSION +from opengnsys.log import logger +from opengnsys.scriptThread import ScriptExecutorThread +from opengnsys.workers import ServerWorker + +pathinterface = None + +# Check authorization header decorator +def check_secret(fnc): + """ + Decorator to check for received secret key and raise exception if it isn't valid. + """ + def wrapper(*args, **kwargs): + return fnc(*args, **kwargs) + #try: + # this, path, get_params, post_params, server = args + # # Accept "status" operation with no arguments or any function with Authorization header + # if fnc.__name__ == 'process_status' and not get_params: + # return fnc(*args, **kwargs) + # elif this.random == server.headers['Authorization']: + # return fnc(*args, **kwargs) + # else: + # raise Exception('Unauthorized operation') + #except Exception as e: + # logger.error(str(e)) + # raise Exception(e) + + return wrapper + +def interfaceAdmin (exe, parametros=[]): + devel_bash_prefix = ''' + PATH=/opt/opengnsys/scripts/:$PATH; + for I in /opt/opengnsys/lib/engine/bin/*.lib; do source $I; done; + for i in $(declare -F |cut -f3 -d" "); do export -f $i; done; + ''' + if 'nati' == os.environ['USER'] or 'nati' == os.environ['SUDO_USER']: ## DO NOT COMMIT + devel_bash_prefix = ''' + PATH=/home/nati/Downloads/work/opengnsys/opengnsys/client/shared/scripts:$PATH; + for I in /home/nati/Downloads/work/opengnsys/opengnsys/client/engine/*.lib; do source $I; done; + for i in $(declare -F |cut -f3 -d" "); do export -f $i; done; + ''' + if parametros: + proc = ['bash', '-c', '{} {} {}'.format (devel_bash_prefix, exe, ' '.join (parametros))] + else: + proc = ['bash', '-c', '{} {}'.format (devel_bash_prefix, exe)] + logger.debug ('subprocess.run ("{}", capture_output=True)'.format (proc)) + return subprocess.run (proc, capture_output=True).stdout.strip().decode ('utf-8') + +def tomaIPlocal(): + logger.debug (__name__) + interface = '{}/getIpAddress'.format (pathinterface) + try: + IPlocal = interfaceAdmin (interface); + logger.info (IPlocal) + except Exception as e: + logger.error (e) + logger.error ('No se ha podido recuperar la dirección IP del cliente') + return False + return True + + +class ogAdmClientWorker(ServerWorker): + name = 'ogAdmClient' # Module name + interface = None # Bound interface for OpenGnsys + REST = None # REST object + #user = [] # User sessions + #session_type = '' # User session type + random = None # Random string for secure connections + length = 32 # Random string length + + def onActivation(self): + """ + Sends OGAgent activation notification to OpenGnsys server + """ + t = 0 # Count of time + # Generate random secret to send on activation + self.random = ''.join(random.choice(string.ascii_lowercase + string.digits) for _ in range(self.length)) + # Ensure cfg has required configuration variables or an exception will be thrown + try: + global pathinterface + url = self.service.config.get('ogAdmClient', 'remote') + loglevel = self.service.config.get('ogAdmClient', 'log') + #servidorAdm = self.service.config.get('ogAdmClient', 'servidorAdm') + #puerto = self.service.config.get('ogAdmClient', 'puerto') + pathinterface = self.service.config.get('ogAdmClient', 'pathinterface') + urlMenu = self.service.config.get('ogAdmClient', 'urlMenu') + urlMsg = self.service.config.get('ogAdmClient', 'urlMsg') + logger.setLevel(loglevel) + except NoOptionError as e: + logger.error("Configuration error: {}".format(e)) + raise e + self.REST = REST(url) + + # Get network interfaces until they are active or timeout (5 minutes) + for t in range(0, 300): + try: + # Get the first network interface + self.interface = list(operations.getNetworkInfo())[0] + except Exception as e: + # Wait 1 sec. and retry + logger.warn (e) + time.sleep(1) + finally: + # Exit loop if interface is active + if self.interface: + if t > 0: + logger.debug("Fetch connection data after {} tries".format(t)) + break + # Raise error after timeout + if not self.interface: + raise Exception ('not self.interface') + + # Loop to send initialization message + init_retries = 100 + for t in range(0, init_retries): + try: + self.REST.sendMessage('ogagent/started', {'mac': self.interface.mac, 'ip': self.interface.ip, + 'secret': self.random, 'ostype': operations.os_type, + 'osversion': operations.os_version, + 'agent_version': VERSION}) + break + except Exception as e: + logger.warn (str (e)) + time.sleep(3) + # Raise error after timeout + if t < init_retries-1: + logger.debug('Successful connection after {} tries'.format(t)) + elif t == init_retries-1: + raise Exception('Initialization error: Cannot connect to remote server') + + tomaIPlocal() + + def onDeactivation(self): + """ + Sends OGAgent stopping notification to OpenGnsys server + """ + logger.debug('onDeactivation') + self.REST.sendMessage('ogagent/stopped', {'mac': self.interface.mac, 'ip': self.interface.ip, + 'ostype': operations.os_type, 'osversion': operations.os_version}) + + def processClientMessage(self, message, data): + logger.debug('Got OpenGnsys message from client: {}, data {}'.format(message, data)) + + def onLogin(self, data): + logger.warn('in onLogin, should not happen') + + def onLogout(self, user): + logger.warn('in onLogout, should not happen') + + def process_ogclient(self, path, get_params, post_params, server): + """ + This method can be overridden to provide your own message processor, or better you can + implement a method that is called exactly as "process_" + path[0] (module name has been removed from path + array) and this default processMessage will invoke it + * Example: + Imagine this invocation url (no matter if GET or POST): http://example.com:9999/Sample/mazinger/Z + The HTTP Server will remove "Sample" from path, parse arguments and invoke this method as this: + module.processMessage(["mazinger","Z"], get_params, post_params) + + This method will process "mazinger", and look for a "self" method that is called "process_mazinger", + and invoke it this way: + return self.process_mazinger(["Z"], get_params, post_params) + + In the case path is empty (that is, the path is composed only by the module name, like in + "http://example.com/Sample", the "process" method will be invoked directly + + The methods must return data that can be serialized to json (i.e. Objects are not serializable to json, + basic type are) + """ + if not path: + return "ok" + try: + operation = getattr(self, 'ogclient_' + path[0]) + except Exception: + raise Exception('Message processor for "{}" not found'.format(path[0])) + return operation(path[1:], get_params, post_params) + + @check_secret + def process_status(self, path, get_params, post_params, server): + return {'ogAdmClient': 'in process_status'} + + @check_secret + def process_reboot(self, path, get_params, post_params, server): + """ + Launches a system reboot operation + :param path: + :param get_params: + :param post_params: + :param server: authorization header + :return: JSON object {"op": "launched"} + """ + logger.debug('Received reboot operation') + + # Rebooting thread + def rebt(): + operations.reboot() + threading.Thread(target=rebt).start() + return {'op': 'launched'} + + @check_secret + def process_poweroff(self, path, get_params, post_params, server): + """ + Launches a system power off operation + :param path: + :param get_params: + :param post_params: + :param server: authorization header + :return: JSON object {"op": "launched"} + """ + logger.debug('Received poweroff operation') + + # Powering off thread + def pwoff(): + time.sleep(2) + operations.poweroff() + threading.Thread(target=pwoff).start() + return {'op': 'launched'} + + @check_secret + def process_script(self, path, get_params, post_params, server): + """ + Processes an script execution (script should be encoded in base64) + :param path: + :param get_params: + :param post_params: JSON object {"script": "commands"} + :param server: authorization header + :return: JSON object {"op": "launched"} + """ + logger.debug('Processing script request') + # Decoding script + script = urllib.parse.unquote(base64.b64decode(post_params.get('script')).decode('utf-8')) + logger.debug('received script {}'.format(script)) + if operations.os_type == 'Windows': + ## for windows, we turn the script into utf16le, then to b64 again, and feed the blob to powershell + u16 = script.encode ('utf-16le') ## utf16 + b64 = base64.b64encode (u16).decode ('utf-8') ## b64 (which returns bytes, so we need an additional decode(utf8)) + script = """ +import os +import tempfile +import subprocess +cp = subprocess.run ("powershell -WindowStyle Hidden -EncodedCommand {}", capture_output=True) +subprocs_log = os.path.join (tempfile.gettempdir(), 'opengnsys-subprocs.log') +with open (subprocs_log, 'ab') as fd: ## TODO improve this logging + fd.write (cp.stdout) + fd.write (cp.stderr) +""".format (b64) + else: + script = 'import subprocess; subprocess.check_output("""{0}""",shell=True)'.format(script) + # Executing script. + if post_params.get('client', 'false') == 'false': + thr = ScriptExecutorThread(script) + thr.start() + else: + self.sendClientMessage('script', {'code': script}) + return {'op': 'launched'} + + @check_secret + def process_logoff(self, path, get_params, post_params, server): + logger.warn('in process_logoff, should not happen') + + @check_secret + def process_popup(self, path, get_params, post_params, server): + logger.warn('in process_popup, should not happen') + + #def process_client_popup(self, params): + # logger.warn('in process_client_popup') + + + ## process_* are invoked from opengnsys/httpserver.py:99 "data = module.processServerMessage(path, get_params, post_params, self)" (via opengnsys/workers/server_worker.py) + ## process_client_* are invoked from opengnsys/service.py:123 "v.processClientMessage(message, json.loads(data))" (via opengnsys/workers/server_worker.py) + + + def process_RESPUESTA_AutoexecCliente(self, path, get_params, post_params, server): + logger.warn('in process_RESPUESTA_AutoexecCliente') + + def process_RESPUESTA_InclusionCliente(self, path, get_params, post_params, server): + logger.warn('in process_RESPUESTA_InclusionCliente') + + def process_NoComandosPtes(self, path, get_params, post_params, server): + logger.warn('in process_NoComandosPtes') + + def process_Actualizar(self, path, get_params, post_params, server): + logger.warn('in process_Actualizar') + + def process_Purgar(self, path, get_params, post_params, server): + logger.warn('in process_Purgar') + + def process_ConsolaRemota(self, path, get_params, post_params, server): + logger.warn('in process_ConsolaRemota') + + def process_Sondeo(self, path, get_params, post_params, server): + logger.warn('in process_Sondeo') + + def process_Arrancar(self, path, get_params, post_params, server): + logger.warn('in process_Arrancar') + + def process_Apagar(self, path, get_params, post_params, server): + logger.warn('in process_Apagar') + + def process_Reiniciar(self, path, get_params, post_params, server): + logger.warn('in process_Reiniciar') + + def process_IniciarSesion(self, path, get_params, post_params, server): + logger.warn('in process_IniciarSesion') + + def process_CrearImagen(self, path, get_params, post_params, server): + logger.warn('in process_CrearImagen') + + def process_CrearImagenBasica(self, path, get_params, post_params, server): + logger.warn('in process_CrearImagenBasica') + + def process_CrearSoftIncremental(self, path, get_params, post_params, server): + logger.warn('in process_CrearSoftIncremental') + + def process_RestaurarImagen(self, path, get_params, post_params, server): + logger.warn('in process_RestaurarImagen') + + def process_RestaurarImagenBasica(self, path, get_params, post_params, server): + logger.warn('in process_RestaurarImagenBasica') + + def process_RestaurarSoftIncremental(self, path, get_params, post_params, server): + logger.warn('in process_RestaurarSoftIncremental') + + def process_Configurar(self, path, get_params, post_params, server): + logger.warn('in process_Configurar') + + def process_EjecutarScript(self, path, get_params, post_params, server): + logger.warn('in process_EjecutarScript') + + def process_InventarioHardware(self, path, get_params, post_params, server): + logger.warn('in process_InventarioHardware') + + def process_InventarioSoftware(self, path, get_params, post_params, server): + logger.warn('in process_InventarioSoftware') + + def process_EjecutaComandosPendientes(self, path, get_params, post_params, server): + logger.warn('in process_EjecutaComandosPendientes')