import bottle import subprocess import socket import logging import traceback import os import optparse import zipfile import datetime import fnmatch import tempfile import shutil import netifaces import json from ethoscope_node.utils.device_scanner import EthoscopeScanner, SensorScanner from ethoscope_node.utils.configuration import EthoscopeConfiguration from ethoscope_node.utils.backups_helpers import GenericBackupWrapper, BackupClass from ethoscope_node.utils.etho_db import ExperimentalDB app = bottle.Bottle() STATIC_DIR = "../static" #names of the backup services SYSTEM_DAEMONS = {"ethoscope_node": {'description' : 'The main Ethoscope node server interface. It is used to control the ethoscopes.'}, "ethoscope_backup" : {'description' : 'The service that collects data from the ethoscopes and syncs them with the node.'}, "ethoscope_video_backup" : {'description' : 'The service that collects VIDEOs from the ethoscopes and syncs them with the node'}, "ethoscope_update_node" : {'description' : 'The service used to update the nodes and the ethoscopes.'}, "git-daemon.socket" : {'description' : 'The GIT server that handles git updates for the node and ethoscopes.'}, "ntpd" : {'description': 'The NTPd service is syncing time with the ethoscopes.'}, "sshd" : {'description': 'The SSH daemon allows power users to access the node terminal from remote.'} } def error_decorator(func): """ A simple decorator to return an error dict so we can display it in the webUI """ def func_wrapper(*args, **kwargs): try: return func(*args, **kwargs) except Exception as e: logging.error(traceback.format_exc()) return {'error': traceback.format_exc()} return func_wrapper def warning_decorator(func): """ A simple decorator to return an error dict so we can display it in the webUI Less verbose than error """ def func_wrapper(*args, **kwargs): try: return func(*args, **kwargs) except Exception as e: logging.error(traceback.format_exc()) return {'error': str(e)} return func_wrapper @app.route('/static/<filepath:path>') def server_static(filepath): return bottle.static_file(filepath, root=STATIC_DIR) @app.route('/tmp_static/<filepath:path>') def server_tmp_static(filepath): return bottle.static_file(filepath, root=tmp_imgs_dir) @app.route('/download/<filepath:path>') def server_download(filepath): return bottle.static_file(filepath, root="/", download=filepath) @app.route('/') def index(): return bottle.static_file('index.html', root=STATIC_DIR) @app.hook('after_request') def enable_cors(): """ You need to add some headers to each request. Don't use the wildcard '*' for Access-Control-Allow-Origin in production. """ #bottle.response.headers['Access-Control-Allow-Origin'] = 'http://localhost:8888' bottle.response.headers['Access-Control-Allow-Origin'] = '*' # Allowing CORS in development bottle.response.headers['Access-Control-Allow-Methods'] = 'PUT, GET, POST, DELETE, OPTIONS' bottle.response.headers['Access-Control-Allow-Headers'] = 'Origin, Accept, Content-Type, X-Requested-With, X-CSRF-Token' ################################# # API to connect with ethoscopes ################################# """ /devices GET returns info about devices /device/<id>/data GET /device/<id>/machineinfo GET, POST /device/<id>/user_options GET /device/<id>/videofiles GET /device/<id>/last_img GET /device/<id>/dbg_img GET /device/<id>/stream GET /device/<id>/controls/<instruction> POST /device/<id>/log GET # RESOURCES ON NODE /results_file /browse/<folder:path> /request-download /node/<req> /node-actions /remove_files /list/<type> /more /experiments /ethoscope/<id> /device/<id>/ip /more/<action> """ @app.get('/favicon.ico') def get_favicon(): return server_static(STATIC_DIR+'/img/favicon.ico') @app.get('/runs_list') @error_decorator def runs_list(): #bottle.response.content_type = 'application/json' return json.dumps( edb.getRun('all', asdict=True) ) @app.get('/experiments_list') @error_decorator def experiments_list(): #response.content_type = 'application/json' return json.dumps( edb.getExperiment('all', asdict=True) ) @app.get('/devices') @error_decorator def devices(): return device_scanner.get_all_devices_info() @app.get('/devices_list') def get_devices_list(): devices() @app.get('/sensors') @error_decorator def sensors(): return sensor_scanner.get_all_devices_info() #Get the information of one device @app.get('/device/<id>/data') @warning_decorator def get_device_info(id): device = device_scanner.get_device(id) # if we fail to access directly the device, we try the old info map if not device: try: return device_scanner.get_all_devices_info()[id] except: raise Exception("A device with ID %s is unknown to the system" % id) return device.info() #Get the private machine information of one device @app.get('/device/<id>/machineinfo') @error_decorator def get_device_machine_info(id): device = device_scanner.get_device(id) # if we fail to access directly the device, we have the old info map if not device: return device_scanner.get_all_devices_info()[id] return device.machine_info() @app.post('/device/<id>/machineinfo') @error_decorator def set_device_machine_info(id): post_data = bottle.request.body.read() device = device_scanner.get_device(id) response = device.send_settings(post_data) return {**device.machine_info(), "haschanged" : response['haschanged']} @app.get('/device/<id>/user_options') @error_decorator def get_device_options(id): try: device = device_scanner.get_device(id) return device.user_options() except: return @app.get('/device/<id>/videofiles') @error_decorator def get_device_videofiles(id): device = device_scanner.get_device(id) return device.videofiles() #Get the information of one Sleep Monitor @app.get('/device/<id>/last_img') @error_decorator def get_device_last_img(id): device = device_scanner.get_device(id) if "status" not in list(device.info().keys()) or device.info()["status"] == "not_in use": raise Exception("Device %s is not in use, no image" % id ) file_like = device.last_image() if not file_like: raise Exception("No image for %s" % id) basename = os.path.join(tmp_imgs_dir, id + "_last_img.jpg") return cache_img(file_like, basename) @app.get('/device/<id>/dbg_img') @error_decorator def get_device_dbg_img(id): device = device_scanner.get_device(id) file_like = device.dbg_img() basename = os.path.join(tmp_imgs_dir, id + "_debug.png") return cache_img(file_like, basename) @app.get('/device/<id>/stream') @error_decorator def get_device_stream(id): device = device_scanner.get_device(id) bottle.response.set_header('Content-type', 'multipart/x-mixed-replace; boundary=frame') return device.relay_stream() @app.post('/device/<id>/backup') @error_decorator def force_device_backup(id): ''' Forces backup on device with specified id ''' results_dir = CFG.content['folders']['results']['path'] device_info = get_device_info(id) try: logging.info("Initiating backup for device %s" % device_info["id"]) backup_job = BackupClass(device_info, results_dir=results_dir) logging.info("Running backup for device %s" % device_info["id"]) backup_job.run() logging.info("Backup done for for device %s" % device_info["id"]) except Exception as e: logging.error("Unexpected error in backup. args are: %s" % str(args)) logging.error(traceback.format_exc()) @app.get('/device/<id>/retire') @error_decorator def retire_device(id): ''' Changes the status of the device to inactive in the device database ''' return device_scanner.retire_device(id) def cache_img(file_like, basename): if not file_like: #TODO return link to "broken img" return "" local_file = os.path.join(tmp_imgs_dir, basename) tmp_file = tempfile.mktemp(prefix="ethoscope_", suffix=".jpg") with open(tmp_file , "wb") as lf: lf.write(file_like.read()) shutil.move(tmp_file, local_file) return server_tmp_static(os.path.basename(local_file)) @app.post('/device/<id>/controls/<instruction>') @error_decorator def post_device_instructions(id, instruction): post_data = bottle.request.body.read() device = device_scanner.get_device(id) device.send_instruction(instruction, post_data) return get_device_info(id) @app.post('/device/<id>/log') @error_decorator def get_log(id): device = device_scanner.get_device(id) return device.get_log() ################################# # NODE Functions ################################# #Browse, delete and download files from node @app.get('/result_files/<type>') @error_decorator def result_file(type): """ :param type:'all', 'db' or 'txt' :return: a dict with a single key: "files" which maps a list of matching result files (absolute path) """ type="txt" if type == "all": pattern = '*' else: pattern = '*.'+type matches = [] for root, dirnames, filenames in os.walk(RESULTS_DIR): for f in fnmatch.filter(filenames, pattern): matches.append(os.path.join(root, f)) return {"files": matches} @app.get('/browse/<folder:path>') @error_decorator def browse(folder): if folder == 'null': directory = RESULTS_DIR else: directory = '/'+folder files = {} for (dirpath, dirnames, filenames) in os.walk(directory): for name in filenames: abs_path = os.path.join(dirpath,name) size = os.path.getsize(abs_path) mtime = os.path.getmtime(abs_path) #rel_path = os.path.relpath(abs_path,directory) files[name] = {'abs_path':abs_path, 'size':size, 'mtime': mtime} return {'files': files} @app.post('/request_download/<what>') @error_decorator def download(what): # zip the files and provide a link to download it if what == 'files': req_files = bottle.request.json t = datetime.datetime.now() #FIXME change the route for this? and old zips need to be erased zip_file_name = os.path.join(RESULTS_DIR,'results_'+t.strftime("%y%m%d_%H%M%S")+'.zip') zf = zipfile.ZipFile(zip_file_name, mode='a') logging.info("Saving files : %s in %s" % (str(req_files['files']), zip_file_name) ) for f in req_files['files']: zf.write(f['url']) zf.close() return {'url':zip_file_name} else: raise NotImplementedError() @app.get('/node/<req>') @error_decorator def node_info(req):#, device): if req == 'info': with os.popen('df %s -h' % RESULTS_DIR) as df: disk_free = df.read() disk_usage = RESULTS_DIR+" Not Found on disk" CARDS = {} IPs = [] CFG.load() try: disk_usage = disk_free.split("\n")[1].split() #the following returns something like this: [['eno1', 'ec:b1:d7:66:2e:3a', '192.168.1.1'], ['enp0s20u12', '74:da:38:49:f8:2a', '155.198.232.206']] adapters_list = [ [i, netifaces.ifaddresses(i)[17][0]['addr'], netifaces.ifaddresses(i)[2][0]['addr']] for i in netifaces.interfaces() if 17 in netifaces.ifaddresses(i) and 2 in netifaces.ifaddresses(i) and netifaces.ifaddresses(i)[17][0]['addr'] != '00:00:00:00:00:00' ] for ad in adapters_list: CARDS [ ad[0] ] = {'MAC' : ad[1], 'IP' : ad[2]} IPs.append (ad[2]) with os.popen('git rev-parse --abbrev-ref HEAD') as df: GIT_BRANCH = df.read() or "Not detected" #df = subprocess.Popen(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], stdout=subprocess.PIPE) #GIT_BRANCH = df.communicate()[0].decode('utf-8') with os.popen('git status -s -uno') as df: NEEDS_UPDATE = df.read() != "" #df = subprocess.Popen(['git', 'status', '-s', '-uno'], stdout=subprocess.PIPE) #NEEDS_UPDATE = df.communicate()[0].decode('utf-8') != "" with os.popen('systemctl status ethoscope_node.service') as df: try: ACTIVE_SINCE = df.read().split("\n")[2] except: ACTIVE_SINCE = "Not running through systemd" except Exception as e: logging.error(e) return {'active_since': ACTIVE_SINCE, 'disk_usage': disk_usage, 'IPs' : IPs , 'CARDS': CARDS, 'GIT_BRANCH': GIT_BRANCH, 'NEEDS_UPDATE': NEEDS_UPDATE} elif req == 'time': return {'time':datetime.datetime.now().isoformat()} elif req == 'timestamp': return {'timestamp': datetime.datetime.now().timestamp() } elif req == 'log': with os.popen("journalctl -u ethoscope_node -rb") as log: l = log.read() return {'log': l} elif req == 'daemons': #returns active or inactive for daemon_name in SYSTEM_DAEMONS.keys(): with os.popen("systemctl is-active %s" % daemon_name) as df: SYSTEM_DAEMONS[daemon_name]['active'] = df.read().strip() return SYSTEM_DAEMONS elif req == 'folders': return CFG.content['folders'] elif req == 'users': return CFG.content['users'] elif req == 'incubators': return CFG.content['incubators'] elif req == 'sensors': return sensor_scanner.get_all_devices_info() else: raise NotImplementedError() @app.post('/node-actions') @error_decorator def node_actions(): action = bottle.request.json if action['action'] == 'restart': logging.info('User requested a service restart.') with os.popen("sleep 1; systemctl restart ethoscope_node.service") as po: r = po.read() return r elif action['action'] == 'close': close() elif action['action'] == 'adduser': return CFG.addUser(action['userdata']) elif action['action'] == 'addincubator': return CFG.addIncubator(action['incubatordata']) elif action['action'] == 'addsensor': return CFG.addSensor(action['sensordata']) elif action['action'] == 'updatefolders': for folder in action['folders'].keys(): if os.path.exists(action['folders'][folder]['path']): CFG.content['folders'][folder]['path'] = action['folders'][folder]['path'] CFG.save() return CFG.content['folders'] elif action['action'] == 'toggledaemon': if action['status'] == True: cmd = "systemctl start %s" % action['daemon_name'] logging.info ("Starting daemon %s" % action['daemon_name']) elif action['status'] == False: cmd = "systemctl stop %s" % action['daemon_name'] logging.info ("Stopping daemon %s" % action['daemon_name']) with os.popen(cmd) as po: r = po.read() return r else: raise NotImplementedError() @app.post('/remove_files') @error_decorator def remove_files(): req = bottle.request.json res = [] for f in req['files']: rm = subprocess.Popen(['rm', f['url']], stdout=subprocess.PIPE, stderr=subprocess.PIPE) out, err = rm.communicate() logging.info(out) logging.error(err) res.append(f['url']) return {'result': res} @app.get('/list/<type>') def redirection_to_list(type): return bottle.redirect('/#/list/'+type) #@app.get('/more') #def redirection_to_more(): # return bottle.redirect('/#/more/') @app.get('/ethoscope/<id>') def redirection_to_ethoscope(id): return bottle.redirect('/#/ethoscope/'+id) @app.get('/more/<action>') def redirection_to_more(action): return bottle.redirect('/#/more/'+action) @app.get('/experiments') def redirection_to_experiments(): return bottle.redirect('/#/experiments') @app.get('/resources') def redirection_to_resources(): return bottle.redirect('/#/resources') def close(exit_status=0): logging.info("Closing server") os._exit(exit_status) #======================================================================================================================# ############# ### CLASSS TO BE REMOVED IF BOTTLE CHANGES TO 0.13 ############ class CherootServer(bottle.ServerAdapter): def run(self, handler): # pragma: no cover from cheroot import wsgi from cheroot.ssl import builtin self.options['bind_addr'] = (self.host, self.port) self.options['wsgi_app'] = handler certfile = self.options.pop('certfile', None) keyfile = self.options.pop('keyfile', None) chainfile = self.options.pop('chainfile', None) server = wsgi.Server(**self.options) if certfile and keyfile: server.ssl_adapter = builtin.BuiltinSSLAdapter( certfile, keyfile, chainfile) try: server.start() finally: server.stop() ############# if __name__ == '__main__': CFG = EthoscopeConfiguration() logging.getLogger().setLevel(logging.INFO) parser = optparse.OptionParser() parser.add_option("-D", "--debug", dest="debug", default=False,help="Set DEBUG mode ON", action="store_true") parser.add_option("-p", "--port", dest="port", default=80, help="port") parser.add_option("-e", "--temporary-results-dir", dest="temp_results_dir", help="Where temporary result files are stored") (options, args) = parser.parse_args() option_dict = vars(options) PORT = option_dict["port"] DEBUG = option_dict["debug"] RESULTS_DIR = option_dict["temp_results_dir"] or CFG.content['folders']['temporary']['path'] if DEBUG: logging.basicConfig() logging.getLogger().setLevel(logging.DEBUG) logging.info("Logging using DEBUG SETTINGS") tmp_imgs_dir = tempfile.mkdtemp(prefix="ethoscope_node_imgs") device_scanner = None try: device_scanner = EthoscopeScanner(results_dir=RESULTS_DIR) device_scanner.start() sensor_scanner = SensorScanner() sensor_scanner.start() edb = ExperimentalDB() # #manually adds the sensors saved in the configuration file # for sensor in CFG.content['sensors']: # if CFG.content['sensors'][sensor]['active']: # sensor_scanner.add(CFG.content['sensors'][sensor]['name'], CFG.content['sensors'][sensor]['URL']) #######TO be remove when bottle changes to version 0.13 server = "cherrypy" try: from bottle.cherrypy import wsgiserver except: #Trick bottle into thinking that cheroot is cherrypy bottle.server_names["cherrypy"]=CherootServer(host='0.0.0.0', port=PORT) logging.warning("Cherrypy version is bigger than 9, we have to change to cheroot server") pass ######### bottle.run(app, host='0.0.0.0', port=PORT, debug=DEBUG, server='cherrypy') except KeyboardInterrupt: logging.info("Stopping server cleanly") pass except socket.error as e: logging.error(traceback.format_exc()) logging.error("Port %i is probably not accessible for you. Maybe use another one e.g.`-p 8000`" % PORT) except Exception as e: logging.error(traceback.format_exc()) close(1) finally: device_scanner.stop() sensor_scanner.stop() shutil.rmtree(tmp_imgs_dir) close()