source: ogAgent-Git/src/opengnsys/modules/server/OpenGnSys/__init__.py

main 6.1.1
Last change on this file was 22f7ce0, checked in by Natalia Serrano <natalia.serrano@…>, 3 weeks ago

refs #2257 rename some endpoints

  • Property mode set to 100644
File size: 18.0 KB
RevLine 
[53e7d45]1#!/usr/bin/env python3
[11f7a07]2# -*- coding: utf-8 -*-
3#
4# Copyright (c) 2014 Virtual Cable S.L.
5# All rights reserved.
6#
7# Redistribution and use in source and binary forms, with or without modification,
8# are permitted provided that the following conditions are met:
9#
10#    * Redistributions of source code must retain the above copyright notice,
11#      this list of conditions and the following disclaimer.
12#    * Redistributions in binary form must reproduce the above copyright notice,
13#      this list of conditions and the following disclaimer in the documentation
14#      and/or other materials provided with the distribution.
15#    * Neither the name of Virtual Cable S.L. nor the names of its contributors
16#      may be used to endorse or promote products derived from this software
17#      without specific prior written permission.
18#
19# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
[ed55bec]29"""
[08ecf23]30@author: Ramón M. Gómez, ramongomez at us dot es
[ed55bec]31"""
[53e7d45]32
[683d8d4]33import base64
[11f7a07]34import os
[5d68449]35import random
[bedce23]36import shutil
[5d68449]37import string
[a850bd1]38import threading
[0440c7c]39import time
[53e7d45]40import urllib.error
41import urllib.parse
42import urllib.request
[11f7a07]43
[68c4c91]44from configparser import NoOptionError
[53e7d45]45from opengnsys import REST, operations, VERSION
[90e5c2d]46from opengnsys.log import logger
[8c6a652]47from opengnsys.jobmgr import JobMgr
[53e7d45]48from opengnsys.workers import ServerWorker
[90e5c2d]49
[03a1cb2]50
[ed55bec]51# Check authorization header decorator
52def check_secret(fnc):
53    """
54    Decorator to check for received secret key and raise exception if it isn't valid.
55    """
56    def wrapper(*args, **kwargs):
57        try:
[de4289a]58            this, path, get_params, post_params, server = args
[3a3b642]59            # Accept "status" operation with no arguments or any function with Authorization header
60            if fnc.__name__ == 'process_status' and not get_params:
61                return fnc(*args, **kwargs)
62            elif this.random == server.headers['Authorization']:
63                return fnc(*args, **kwargs)
[ed55bec]64            else:
65                raise Exception('Unauthorized operation')
66        except Exception as e:
[1fdeb2a]67            logger.debug (str(e))
[ed55bec]68            raise Exception(e)
69
70    return wrapper
71
72
[0440c7c]73# Check if operation is permitted
74def execution_level(level):
75    def check_permitted(fnc):
76        def wrapper(*args, **kwargs):
77            levels = ['status', 'halt', 'full']
78            this = args[0]
79            try:
80                if levels.index(level) <= levels.index(this.exec_level):
81                    return fnc(*args, **kwargs)
82                else:
83                    raise Exception('Unauthorized operation')
84            except Exception as e:
[1fdeb2a]85                logger.debug (str(e))
[0440c7c]86                raise Exception(e)
87
88        return wrapper
89
90    return check_permitted
91
92
[11f7a07]93class OpenGnSysWorker(ServerWorker):
[4aa86de]94    name = 'opengnsys'  # Module name
[03a1cb2]95    interface = None  # Bound interface for OpenGnsys
[ed55bec]96    REST = None  # REST object
[e298c49]97    user = []  # User sessions
[be263c6]98    session_type = ''  # User session type
[dab4e35]99    random = None  # Random string for secure connections
100    length = 32  # Random string length
[0440c7c]101    exec_level = None  # Execution level (permitted operations)
[8c6a652]102    jobmgr = JobMgr()
[90e5c2d]103
[16554e5]104    ## pings ogcore
105    def mon (self):
106        n = 0
107        while True:
108            time.sleep (1)
109            n += 1
110            if not n % 10:
111                body = {
112                    "iph": self.interface.ip,
113                    "timestamp": int (time.time()),
114                }
[22f7ce0]115                #logger.debug (f'about to send ping ({body})')
[16554e5]116                self.REST.sendMessage ('clients/status/webhook', body)
117
[11f7a07]118    def onActivation(self):
[bedce23]119        """
[44e1e4c]120        Sends OGAgent activation notification to OpenGnsys server
[bedce23]121        """
[a5d0da2]122        if os.path.exists ('/scripts/oginit'):
123            ## estamos en oglive, este modulo no debe cargarse
124            ## esta lógica la saco de src/opengnsys/linux/operations.py, donde hay un if similar
125            raise Exception ('Refusing to load within an ogLive image')
126
[68c4c91]127        e = None  # Error info
128        t = 0  # Count of time
[feb481a]129        # Generate random secret to send on activation
130        self.random = ''.join(random.choice(string.ascii_lowercase + string.digits) for _ in range(self.length))
[11f7a07]131        # Ensure cfg has required configuration variables or an exception will be thrown
[68c4c91]132        try:
[16d24f3]133            url      = self.service.config.get(self.name, 'remote')
134            ca_file  = self.service.config.get(self.name, 'ca')
135            crt_file = self.service.config.get(self.name, 'crt')
136            key_file = self.service.config.get(self.name, 'key')
[68c4c91]137        except NoOptionError as e:
138            logger.error("Configuration error: {}".format(e))
139            raise e
[16d24f3]140        self.REST = REST (url, ca_file=ca_file, crt_file=crt_file, key_file=key_file)
[0440c7c]141        # Execution level ('full' by default)
142        try:
[a5d0da2]143            self.exec_level = self.service.config.get(self.name, 'level')
[68c4c91]144        except NoOptionError:
[0440c7c]145            self.exec_level = 'full'
[2e072d2]146        # Get network interfaces until they are active or timeout (5 minutes)
147        for t in range(0, 300):
[804c389]148            try:
[68c4c91]149                # Get the first network interface
[1a38999]150                nets = list (operations.getNetworkInfo())
151                if 0 == len (nets):
152                    logger.error ('No network interfaces found')
153                    raise Exception ('No network interfaces found')
154                self.interface = nets[0]
[804c389]155            except Exception as e:
156                # Wait 1 sec. and retry
[b84ab33]157                logger.warn (e)
[02399e9]158                time.sleep(1)
[804c389]159            finally:
160                # Exit loop if interface is active
161                if self.interface:
162                    if t > 0:
163                        logger.debug("Fetch connection data after {} tries".format(t))
164                    break
165        # Raise error after timeout
166        if not self.interface:
[f69d3ab]167            ## UnboundLocalError: cannot access local variable 'e' where it is not associated with a value
[804c389]168            raise e
[6a01818]169
[feb481a]170        # Loop to send initialization message
[6a01818]171        init_retries = 100
172        for t in range(0, init_retries):
[feb481a]173            try:
174                try:
175                    self.REST.sendMessage('ogagent/started', {'mac': self.interface.mac, 'ip': self.interface.ip,
[ed55bec]176                                                              'secret': self.random, 'ostype': operations.os_type,
[a850bd1]177                                                              'osversion': operations.os_version,
178                                                              'agent_version': VERSION})
[feb481a]179                    break
[6a01818]180                except Exception as e:
181                    logger.warn (str (e))
[feb481a]182                    # Trying to initialize on alternative server, if defined
183                    # (used in "exam mode" from the University of Seville)
[16d24f3]184                    self.REST = REST(self.service.config.get(self.name, 'altremote'), ca_file=ca_file, crt_file=crt_file, key_file=key_file)
[feb481a]185                    self.REST.sendMessage('ogagent/started', {'mac': self.interface.mac, 'ip': self.interface.ip,
[ed55bec]186                                                              'secret': self.random, 'ostype': operations.os_type,
[a850bd1]187                                                              'osversion': operations.os_version, 'alt_url': True,
188                                                              'agent_version': VERSION})
[feb481a]189                    break
[6a01818]190            except Exception as e:
191                logger.warn (str (e))
[feb481a]192                time.sleep(3)
[ed55bec]193        # Raise error after timeout
[6a01818]194        if t < init_retries-1:
[ed55bec]195            logger.debug('Successful connection after {} tries'.format(t))
[6a01818]196        elif t == init_retries-1:
[feb481a]197            raise Exception('Initialization error: Cannot connect to remote server')
[6a01818]198
[6c8f1c2]199        # Delete marking files
200        for f in ['ogboot.me', 'ogboot.firstboot', 'ogboot.secondboot']:
201            try:
202                os.remove(os.sep + f)
203            except OSError:
204                pass
[bedce23]205        # Copy file "HostsFile.FirstOctetOfIPAddress" to "HostsFile", if it exists
[03a1cb2]206        # (used in "exam mode" from the University of Seville)
[ed55bec]207        hosts_file = os.path.join(operations.get_etc_path(), 'hosts')
208        new_hosts_file = hosts_file + '.' + self.interface.ip.split('.')[0]
209        if os.path.isfile(new_hosts_file):
210            shutil.copyfile(new_hosts_file, hosts_file)
[90e5c2d]211
[16554e5]212        threading.Thread (name='monitoring_thread', target=self.mon, daemon=True).start()
213
[bf09933]214        logger.debug ('onActivation ok')
[a67669b]215
[11f7a07]216    def onDeactivation(self):
[bedce23]217        """
[44e1e4c]218        Sends OGAgent stopping notification to OpenGnsys server
[bedce23]219        """
[d231816]220        now = time.time()
221        for elem in self.user:
222            sess_len = now - elem['login_ts']
223            logger.debug ('Session of logged in user {} took {} seconds'.format (elem['username'], int (sess_len)))
[11f7a07]224        logger.debug('onDeactivation')
[03a1cb2]225        self.REST.sendMessage('ogagent/stopped', {'mac': self.interface.mac, 'ip': self.interface.ip,
[ed55bec]226                                                  'ostype': operations.os_type, 'osversion': operations.os_version})
[90e5c2d]227
[bedce23]228    def onLogin(self, data):
229        """
[44e1e4c]230        Sends session login notification to OpenGnsys server
[bedce23]231        """
[be263c6]232        user, language, self.session_type = tuple(data.split(','))
233        logger.debug('Received login for {0} using {2} with language {1}'.format(user, language, self.session_type))
[d231816]234        self.user.append ({'username': user, 'login_ts': time.time() })
[03a1cb2]235        self.REST.sendMessage('ogagent/loggedin', {'ip': self.interface.ip, 'user': user, 'language': language,
[be263c6]236                                                   'session': self.session_type,
[ed55bec]237                                                   'ostype': operations.os_type, 'osversion': operations.os_version})
[5d68449]238
[11f7a07]239    def onLogout(self, user):
[bedce23]240        """
[44e1e4c]241        Sends session logout notification to OpenGnsys server
[bedce23]242        """
[d231816]243        sess_len = 0
244        for elem in self.user:
245            if user != elem['username']: continue
246            sess_len = time.time() - elem['login_ts']
247        logger.debug ('Received logout for {}, session length {} seconds'.format (user, int (sess_len)))
[e298c49]248        try:
249            self.user.pop()
250        except IndexError:
251            pass
[44e1e4c]252        self.REST.sendMessage('ogagent/loggedout', {'ip': self.interface.ip, 'user': user})
[11f7a07]253
[ed55bec]254    def process_ogclient(self, path, get_params, post_params, server):
[bedce23]255        """
256        This method can be overridden to provide your own message processor, or better you can
257        implement a method that is called exactly as "process_" + path[0] (module name has been removed from path
258        array) and this default processMessage will invoke it
[11f7a07]259        * Example:
260            Imagine this invocation url (no matter if GET or POST): http://example.com:9999/Sample/mazinger/Z
261            The HTTP Server will remove "Sample" from path, parse arguments and invoke this method as this:
[ed55bec]262            module.processMessage(["mazinger","Z"], get_params, post_params)
[90e5c2d]263
[bedce23]264            This method will process "mazinger", and look for a "self" method that is called "process_mazinger",
265            and invoke it this way:
[ed55bec]266               return self.process_mazinger(["Z"], get_params, post_params)
[90e5c2d]267
[bedce23]268            In the case path is empty (that is, the path is composed only by the module name, like in
269            "http://example.com/Sample", the "process" method will be invoked directly
[90e5c2d]270
[bedce23]271            The methods must return data that can be serialized to json (i.e. Objects are not serializable to json,
272            basic type are)
273        """
[90e5c2d]274        if not path:
[11f7a07]275            return "ok"
276        try:
277            operation = getattr(self, 'ogclient_' + path[0])
278        except Exception:
279            raise Exception('Message processor for "{}" not found'.format(path[0]))
[ed55bec]280        return operation(path[1:], get_params, post_params)
[90e5c2d]281
[de4289a]282    # Warning: the order of the decorators matters
[0440c7c]283    @execution_level('status')
[de4289a]284    @check_secret
[ed55bec]285    def process_status(self, path, get_params, post_params, server):
[bedce23]286        """
[ed55bec]287        Returns client status (OS type or execution status) and login status
288        :param path:
[3a3b642]289        :param get_params: optional parameter "detail" to show extended status
[ed55bec]290        :param post_params:
291        :param server:
[3a3b642]292        :return: JSON object {"status": "status_code", "loggedin": boolean, ...}
[bedce23]293        """
[4aa86de]294        st = {'linux': 'LNX', 'macos': 'OSX', 'windows': 'WIN'}
295        try:
[3a3b642]296            # Standard status
[be263c6]297            res = {'status': st[operations.os_type.lower()], 'loggedin': len(self.user) > 0,
298                   'session': self.session_type}
[3a3b642]299            # Detailed status
300            if get_params.get('detail', 'false') == 'true':
301                res.update({'agent_version': VERSION, 'os_version': operations.os_version, 'sys_load': os.getloadavg()})
302                if res['loggedin']:
[d231816]303                    res.update({'sessions': len(self.user), 'current_user': self.user[-1]['username']})
[4aa86de]304        except KeyError:
[e298c49]305            # Unknown operating system
306            res = {'status': 'UNK'}
[11f7a07]307        return res
[90e5c2d]308
[0440c7c]309    @execution_level('halt')
[de4289a]310    @check_secret
[22f7ce0]311    def process_Reiniciar(self, path, get_params, post_params, server):
[bedce23]312        """
[ed55bec]313        Launches a system reboot operation
314        :param path:
315        :param get_params:
316        :param post_params:
317        :param server: authorization header
318        :return: JSON object {"op": "launched"}
[bedce23]319        """
[11f7a07]320        logger.debug('Received reboot operation')
[03a1cb2]321
[ed55bec]322        # Rebooting thread
[11f7a07]323        def rebt():
324            operations.reboot()
325        threading.Thread(target=rebt).start()
326        return {'op': 'launched'}
327
[0440c7c]328    @execution_level('halt')
[de4289a]329    @check_secret
[22f7ce0]330    def process_Apagar(self, path, get_params, post_params, server):
[bedce23]331        """
[ed55bec]332        Launches a system power off operation
333        :param path:
334        :param get_params:
335        :param post_params:
336        :param server: authorization header
337        :return: JSON object {"op": "launched"}
[bedce23]338        """
[11f7a07]339        logger.debug('Received poweroff operation')
[03a1cb2]340
[ed55bec]341        # Powering off thread
[11f7a07]342        def pwoff():
343            time.sleep(2)
344            operations.poweroff()
345        threading.Thread(target=pwoff).start()
346        return {'op': 'launched'}
347
[0440c7c]348    @execution_level('full')
[de4289a]349    @check_secret
[22f7ce0]350    def process_EjecutarScript(self, path, get_params, post_params, server):
[bedce23]351        """
[937c21f]352        Processes an script execution (script should be encoded in base64)
[ed55bec]353        :param path:
354        :param get_params:
355        :param post_params: JSON object {"script": "commands"}
356        :param server: authorization header
357        :return: JSON object {"op": "launched"}
[bedce23]358        """
[937c21f]359        logger.debug('Processing script request')
[f69d3ab]360        # Decoding script
[250de7a]361        param_script = post_params.get('script')
362        if not param_script:
363            return {'op': 'error', 'err': 'Required parameter "script" is missing or empty'}
364        try:
365            b64decoded = base64.b64decode (param_script)
366        except Exception as e:
367            return {'op': 'error', 'err': f'Failed to decode base64: {e}'}
368        script = urllib.parse.unquote (b64decoded.decode ('utf-8'))
[8c6a652]369        logger.debug('received script "{}"'.format(script))
370
[ed55bec]371        if post_params.get('client', 'false') == 'false':
[8c6a652]372            jobid = self.jobmgr.launch_job (script, False)
373            return {'op': 'launched', 'jobid': jobid}
374
375        else:   ## post_params.get('client') is not 'false'
376            ## send script as-is
[08ecf23]377            self.sendClientMessage('script', {'code': script})
[8c6a652]378            #return {'op': 'launched', 'jobid': jobid}       ## TODO obtain jobid generated at the client (can it be done?)
379            return {'op': 'launched'}
380
381    @execution_level('full')
382    @check_secret
[d4e21da]383    def process_terminatescript(self, path, get_params, post_params, server):
384        jobid = post_params.get('jobid', None)
385        logger.debug('Processing terminate_script request, jobid "{}"'.format (jobid))
386        if jobid is None:
387            return {}
388        self.sendClientMessage('terminatescript', {'jobid': jobid})
389        self.jobmgr.terminate_job (jobid)
390        return {}
391
392    @execution_level('full')
393    @check_secret
[8c6a652]394    def process_preparescripts(self, path, get_params, post_params, server):
395        logger.debug('Processing preparescripts request')
396        self.st = self.jobmgr.prepare_jobs()
397        logger.debug('Sending preparescripts to client')
398        self.sendClientMessage('preparescripts', None)
399        return {}
400
401    def process_client_preparescripts(self, params):
402        logger.debug('Processing preparescripts message from client')
403        for p in params:
404            #logger.debug ('p "{}"'.format(p))
[d4e21da]405            self.st.append (p)
[8c6a652]406
407    @execution_level('full')
408    @check_secret
409    def process_getscripts(self, path, get_params, post_params, server):
410        logger.debug('Processing getscripts request')
411        return self.st
[90e5c2d]412
[0440c7c]413    @execution_level('full')
[de4289a]414    @check_secret
[ed55bec]415    def process_logoff(self, path, get_params, post_params, server):
[bedce23]416        """
[ed55bec]417        Closes user session
[bedce23]418        """
[11f7a07]419        logger.debug('Received logoff operation')
[ed55bec]420        # Sending log off message to OGAgent client
[11f7a07]421        self.sendClientMessage('logoff', {})
[bedce23]422        return {'op': 'sent to client'}
[11f7a07]423
[0440c7c]424    @execution_level('full')
[de4289a]425    @check_secret
[ed55bec]426    def process_popup(self, path, get_params, post_params, server):
[bedce23]427        """
[ed55bec]428        Shows a message popup on the user's session
[bedce23]429        """
[1deb0d1]430        logger.debug('Received message operation')
[ed55bec]431        # Sending popup message to OGAgent client
432        self.sendClientMessage('popup', post_params)
[1deb0d1]433        return {'op': 'launched'}
434
[90e5c2d]435    def process_client_popup(self, params):
[1deb0d1]436        self.REST.sendMessage('popup_done', params)
Note: See TracBrowser for help on using the repository browser.