diff --git a/.github/workflows/manual.yml b/.github/workflows/manual.yml index 9ed43867..d1894e6f 100644 --- a/.github/workflows/manual.yml +++ b/.github/workflows/manual.yml @@ -3,6 +3,8 @@ on: push: branches: - master + tags: + - v* pull_request: branches: - master @@ -35,13 +37,21 @@ jobs: chmod +x ./.github/workflows/builds.sh ./.github/workflows/builds.sh shell: bash - - name: Run make build and clean and commit newly built apps to github - if: github.event_name != 'pull_request' + - name: Tag + if: github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/v') run: | git config --global user.name 'Github actions auto build packages' git config --global user.email 'Github_actions_auto_build_packages@users.noreply.github.com' chmod +x ./.github/workflows/builds.sh ./.github/workflows/builds.sh - git add built_apps/* - git commit -m "Rebuilt apps" - git push + git tag -a v1 -m "v1" + git push origin --tags + shell: bash + - name: Publish + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + uses: softprops/action-gh-release@v1 + # TODO: if any of the build step fails, the release should be deleted. + with: + files: 'built_apps/*' + env: + GITHUB_TOKEN: ${{ secrets.TOKEN }} \ No newline at end of file diff --git a/README.md b/README.md index a75c6a9a..7c48a87e 100755 --- a/README.md +++ b/README.md @@ -17,6 +17,10 @@ The Application Developmers Guide is the best document to read first. - **5GSpeed** - Run Ookla speedtests via NCM API. Results are put in asset_id field (configurable in SDK Data). Clearing the results starts a new test. This can be done easily via NCM API v2 /routers/ endpoint. +- **app_template_csclient** + - A template for the creation of a new application utilizing the csclient library. +- **app_holder** + - Just a holder for dynamic_app. See dynamic_app. - **Autoinstall** - Automatically choose fastest SIM on install. On bootup, AutoInstall detects SIMs, and ensures (clones) they have unique WAN profiles for prioritization. Then the app collects diagnostics and runs Ookla speedtests on each SIM. Then the app prioritizes the SIMs WAN Profiles by TCP download speed. Results are written to the log, set as the description field, and sent as a custom alert. The app can be manually triggered again by clearing out the description field in NCM. - **Installer_UI** @@ -31,6 +35,8 @@ The Application Developmers Guide is the best document to read first. - Includes csterm module that enables access to local CLI to send commands and return output. - **ipverify_custom_action** - Create a custom action in a function to be called when an IPverify test status changes. +- **dynamic_app** + - Downloads apps from a self hosted url and install into app_holder app. Overcome limitates with dev_mode and app size limits. - **cpu_usage** - Gets cpu and memory usage information from the router every 30 seconds and writes a csv file to a usb stick formatted in fat32. - **ftp_client** diff --git a/app_holder/package.ini b/app_holder/package.ini new file mode 100755 index 00000000..a8da61ae --- /dev/null +++ b/app_holder/package.ini @@ -0,0 +1,14 @@ +[app_holder] +uuid = 35ac6371-e78d-4016-a45b-42f639ca0c11 +vendor = Cradlepoint +notes = App is dynamically populated, this is just a placeholder +version_major = 1 +version_minor = 0 +version_patch = 3 +auto_start = true +restart = true +reboot = true +firmware_major = 7 +firmware_minor = 23 +firmware_patch = 20 + diff --git a/app_holder/readme.txt b/app_holder/readme.txt new file mode 100644 index 00000000..47e13c2f --- /dev/null +++ b/app_holder/readme.txt @@ -0,0 +1,2 @@ +This app is just a holder app for use with dynamic_app. Simply build and install both apps and upload to NCM. +see dynamic_app/readme.txt for more details diff --git a/app_holder/start.sh b/app_holder/start.sh new file mode 100755 index 00000000..189a0594 --- /dev/null +++ b/app_holder/start.sh @@ -0,0 +1,2 @@ +#!/bin/bash +cd app && ./start.sh diff --git a/dynamic_app/csclient.py b/dynamic_app/csclient.py new file mode 100644 index 00000000..5423c804 --- /dev/null +++ b/dynamic_app/csclient.py @@ -0,0 +1,608 @@ +""" +NCOS communication module for SDK applications. + +Copyright (c) 2022 Cradlepoint, Inc. . All rights reserved. + +This file contains confidential information of CradlePoint, Inc. and your use of +this file is subject to the CradlePoint Software License Agreement distributed with +this file. Unauthorized reproduction or distribution of this file is subject to civil and +criminal penalties. +""" + + +import json +import os +import re +import select +import socket +import threading +import logging.handlers +import signal +import sys + +try: + import traceback +except ImportError: + traceback = None + + +class SdkCSException(Exception): + pass + + +class CSClient(object): + """ + The CSClient class is the NCOS SDK mechanism for communication between apps and the router tree/config store. + Instances of this class communicate with the router using either an explicit socket or with http method calls. + + Apps running locally on the router use a socket on the router to send commands from the app to the router tree + and to receive data (JSON) from the router tree. + + Apps running remotely use the requests library to send HTTP method calls to the router and to receive data from + the router tree. This allows one to use an IDE to run and debug the application on a the computer. Although, + there are limitations with respect to the device hardware access (i.e. serial, USB, etc.). + """ + END_OF_HEADER = b"\r\n\r\n" + STATUS_HEADER_RE = re.compile(b"status: \w*") + CONTENT_LENGTH_HEADER_RE = re.compile(b"content-length: \w*") + MAX_PACKET_SIZE = 8192 + RECV_TIMEOUT = 2.0 + + _instances = {} + + @classmethod + def is_initialized(cls): + return cls in cls._instances + + def __new__(cls, *na, **kwna): + """ Singleton factory (with subclassing support) """ + if not cls.is_initialized(): + cls._instances[cls] = super().__new__(cls) + return cls._instances[cls] + + def __init__(self, app_name, init=False): + self.app_name = app_name + self.ncos = '/var/mnt/sdk' in os.getcwd() # Running in NCOS + handlers = [logging.StreamHandler()] + if 'linux' in sys.platform: + handlers.append(logging.handlers.SysLogHandler(address='/dev/log')) + logging.basicConfig(level=logging.DEBUG, format='%(asctime)s %(name)s: %(message)s', datefmt='%b %d %H:%M:%S', + handlers=handlers) + self.logger = logging.getLogger(app_name) + if not init: + return + + def get(self, base, query='', tree=0): + """ + Constructs and sends a get request to retrieve specified data from a device. + + The behavior of this method is contextual: + - If the app is installed on (and executed from) a device, it directly queries the router tree to retrieve the + specified data. + - If the app running remotely from a computer it calls the HTTP GET method to retrieve the specified data. + + Args: + base: String representing a path to a resource on a router tree, + (i.e. '/config/system/logging/level'). + value: Not required. + query: Not required. + tree: Not required. + + Returns: + A dictionary containing the response (i.e. {"success": True, "data:": {}} + + """ + if 'linux' in sys.platform: + cmd = "get\n{}\n{}\n{}\n".format(base, query, tree) + return self._dispatch(cmd).get('data') + else: + # Running in a computer so use http to send the get to the device. + import requests + device_ip, username, password = self._get_device_access_info() + device_api = 'http://{}/api/{}/{}'.format(device_ip, base, query) + + try: + response = requests.get(device_api, auth=self._get_auth(device_ip, username, password)) + + except (requests.exceptions.Timeout, + requests.exceptions.ConnectionError): + print("Timeout: device at {} did not respond.".format(device_ip)) + return None + + return json.loads(response.text).get('data') + + def decrypt(self, base, query='', tree=0): + """ + Constructs and sends a decrypt/get request to retrieve specified data from a device. + + The behavior of this method is contextual: + - If the app is installed on (and executed from) a device, it directly queries the router tree to retrieve the + specified data. + - If the app running remotely from a computer it calls the HTTP GET method to retrieve the specified data. + + Args: + base: String representing a path to a resource on a router tree, + (i.e. '/config/system/logging/level'). + value: Not required. + query: Not required. + tree: Not required. + + Returns: + A dictionary containing the response (i.e. {"success": True, "data:": {}} + + """ + if 'linux' in sys.platform: + cmd = "decrypt\n{}\n{}\n{}\n".format(base, query, tree) + return self._dispatch(cmd).get('data') + else: + # Running in a computer and can't actually send the alert. + print('Decrypt is only available when running the app in NCOS.') + + def put(self, base, value='', query='', tree=0): + """ + Constructs and sends a put request to update or add specified data to the device router tree. + + The behavior of this method is contextual: + - If the app is installed on(and executed from) a device, it directly updates or adds the specified data to + the router tree. + - If the app running remotely from a computer it calls the HTTP PUT method to update or add the specified + data. + + + Args: + base: String representing a path to a resource on a router tree, + (i.e. '/config/system/logging/level'). + value: Not required. + query: Not required. + tree: Not required. + + Returns: + A dictionary containing the response (i.e. {"success": True, "data:": {}} + """ + value = json.dumps(value) + if 'linux' in sys.platform: + cmd = "put\n{}\n{}\n{}\n{}\n".format(base, query, tree, value) + return self._dispatch(cmd) + else: + # Running in a computer so use http to send the put to the device. + import requests + device_ip, username, password = self._get_device_access_info() + device_api = 'http://{}/api/{}/{}'.format(device_ip, base, query) + + try: + response = requests.put(device_api, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + auth=self._get_auth(device_ip, username, password), + data={"data": '{}'.format(value)}) + except (requests.exceptions.Timeout, + requests.exceptions.ConnectionError): + print("Timeout: device at {} did not respond.".format(device_ip)) + return None + + return json.loads(response.text) + + def post(self, base, value='', query=''): + """ + Constructs and sends a post request to update or add specified data to the device router tree. + + The behavior of this method is contextual: + - If the app is installed on(and executed from) a device, it directly updates or adds the specified data to + the router tree. + - If the app running remotely from a computer it calls the HTTP POST method to update or add the specified + data. + + + Args: + base: String representing a path to a resource on a router tree, + (i.e. '/config/system/logging/level'). + value: Not required. + query: Not required. + + Returns: + A dictionary containing the response (i.e. {"success": True, "data:": {}} + """ + value = json.dumps(value) + if 'linux' in sys.platform: + cmd = f"post\n{base}\n{query}\n{value}\n" + return self._dispatch(cmd) + else: + # Running in a computer so use http to send the post to the device. + import requests + device_ip, username, password = self._get_device_access_info() + device_api = 'http://{}/api/{}/{}'.format(device_ip, base, query) + + try: + response = requests.post(device_api, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + auth=self._get_auth(device_ip, username, password), + data={"data": '{}'.format(value)}) + except (requests.exceptions.Timeout, + requests.exceptions.ConnectionError): + print("Timeout: device at {} did not respond.".format(device_ip)) + return None + + return json.loads(response.text) + + def patch(self, value): + """ + Constructs and sends a patch request to update or add specified data to the device router tree. + + The behavior of this method is contextual: + - If the app is installed on(and executed from) a device, it directly updates or adds the specified data to + the router tree. + - If the app running remotely from a computer it calls the HTTP PUT method to update or add the specified + data. + + Args: + value: list containing dict of add/changes, and list of removals: [{add},[remove]] + + Returns: + A dictionary containing the response (i.e. {"success": True, "data:": {}} + """ + + if 'linux' in sys.platform: + if value[0].get("config"): + adds = value[0] + else: + adds = {"config": value[0]} + adds = json.dumps(adds) + removals = json.dumps(value[1]) + cmd = f"patch\n{adds}\n{removals}\n" + return self._dispatch(cmd) + else: + # Running in a computer so use http to send the put to the device. + import requests + device_ip, username, password = self._get_device_access_info() + device_api = 'http://{}/api/'.format(device_ip) + + try: + response = requests.patch(device_api, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + auth=self._get_auth(device_ip, username, password), + data={"data": '{}'.format(json.dumps(value))}) + except (requests.exceptions.Timeout, + requests.exceptions.ConnectionError): + print("Timeout: device at {} did not respond.".format(device_ip)) + return None + + return json.loads(response.text) + + def delete(self, base, query=''): + """ + Constructs and sends a delete request to delete specified data to the device router tree. + + The behavior of this method is contextual: + - If the app is installed on(and executed from) a device, it directly deletes the specified data to + the router tree. + - If the app running remotely from a computer it calls the HTTP DELETE method to update or add the specified + data. + + + Args: + base: String representing a path to a resource on a router tree, + (i.e. '/config/system/logging/level'). + query: Not required. + + Returns: + A dictionary containing the response (i.e. {"success": True, "data:": {}} + """ + if 'linux' in sys.platform: + cmd = "delete\n{}\n{}\n".format(base, query) + return self._dispatch(cmd) + else: + # Running in a computer so use http to send the delete to the device. + import requests + device_ip, username, password = self._get_device_access_info() + device_api = 'http://{}/api/{}/{}'.format(device_ip, base, query) + + try: + response = requests.delete(device_api, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + auth=self._get_auth(device_ip, username, password), + data={"data": '{}'.format(base)}) + except (requests.exceptions.Timeout, + requests.exceptions.ConnectionError): + print("Timeout: device at {} did not respond.".format(device_ip)) + return None + + return json.loads(response.text) + + def alert(self, value=''): + """ + Constructs and sends a custom alert to NCM for the device. Apps calling this method must be running + on the target device to send the alert. If invoked while running on a computer, then only a log is output. + + Args: + + app_name: String name of your application. + value: String to displayed for the alert. + + Returns: + Success: None + Failure: An error + """ + if 'linux' in sys.platform: + cmd = "alert\n{}\n{}\n".format(self.app_name, value) + return self._dispatch(cmd) + else: + # Running in a computer and can't actually send the alert. + print('Alert is only available when running the app in NCOS.') + print('Alert Text: {}'.format(value)) + + def log(self, value=''): + """ + Adds an INFO log to the device SYSLOG. + + Args: + value: String text for the log. + + Returns: + None + """ + if self.ncos: + # Running in NCOS so write to the logger + self.logger.info(value) + elif 'linux' in sys.platform: + # Running in Linux (container?) so write to stdout + with open('/dev/stdout', 'w') as log: + log.write(f'{self.app_name}: {value}\n') + else: + # Running in a computer so just use print for the log. + print(value) + + + def _get_auth(self, device_ip, username, password): + # This is only needed when the app is running in a computer. + # Returns the proper HTTP Auth for the global username and password. + # Digest Auth is used for NCOS 6.4 and below while Basic Auth is + # used for NCOS 6.5 and up. + import requests + from http import HTTPStatus + + use_basic = False + device_api = 'http://{}/api/status/product_info'.format(device_ip) + + try: + response = requests.get(device_api, auth=requests.auth.HTTPBasicAuth(username, password)) + if response.status_code == HTTPStatus.OK: + use_basic = True + + except: + use_basic = False + + if use_basic: + return requests.auth.HTTPBasicAuth(username, password) + else: + return requests.auth.HTTPDigestAuth(username, password) + + @staticmethod + def _get_device_access_info(): + # Should only be called when running in a computer. It will return the + # dev_client_ip, dev_client_username, and dev_client_password as defined in + # the sdk section of the sdk_settings.ini file. + device_ip = '' + device_username = '' + device_password = '' + + if 'linux' not in sys.platform: + import os + import configparser + + settings_file = os.path.join(os.path.dirname(os.getcwd()), 'sdk_settings.ini') + config = configparser.ConfigParser() + config.read(settings_file) + + # Keys in sdk_settings.ini + sdk_key = 'sdk' + ip_key = 'dev_client_ip' + username_key = 'dev_client_username' + password_key = 'dev_client_password' + + if sdk_key in config: + if ip_key in config[sdk_key]: + device_ip = config[sdk_key][ip_key] + else: + print('ERROR 1: The {} key does not exist in {}'.format(ip_key, settings_file)) + + if username_key in config[sdk_key]: + device_username = config[sdk_key][username_key] + else: + print('ERROR 2: The {} key does not exist in {}'.format(username_key, settings_file)) + + if password_key in config[sdk_key]: + device_password = config[sdk_key][password_key] + else: + print('ERROR 3: The {} key does not exist in {}'.format(password_key, settings_file)) + else: + print('ERROR 4: The {} section does not exist in {}'.format(sdk_key, settings_file)) + + return device_ip, device_username, device_password + + def _safe_dispatch(self, cmd): + """Send the command and return the response.""" + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock: + sock.connect('/var/tmp/cs.sock') + sock.sendall(bytes(cmd, 'ascii')) + return self._receive(sock) + + def _dispatch(self, cmd): + errmsg = None + result = "" + try: + result = self._safe_dispatch(cmd) + except Exception as err: + # ignore the command error, continue on to next command + errmsg = "dispatch failed with exception={} err={}".format(type(err), str(err)) + if errmsg is not None: + self.log(errmsg) + pass + return result + + def _safe_receive(self, sock): + sock.settimeout(self.RECV_TIMEOUT) + data = b"" + eoh = -1 + while eoh < 0: + # In the event that the config store times out in returning data, lib returns + # an empty result. Then again, if the config store hangs for 2+ seconds, + # the app's behavior is the least of our worries. + try: + buf = sock.recv(self.MAX_PACKET_SIZE) + except socket.timeout: + return {"status": "timeout", "data": None} + if len(buf) == 0: + break + data += buf + eoh = data.find(self.END_OF_HEADER) + + status_hdr = self.STATUS_HEADER_RE.search(data).group(0)[8:] + content_len = self.CONTENT_LENGTH_HEADER_RE.search(data).group(0)[16:] + remaining = int(content_len) - (len(data) - eoh - len(self.END_OF_HEADER)) + + # body sent from csevent_xxx.sock will have id, action, path, & cfg + while remaining > 0: + buf = sock.recv(self.MAX_PACKET_SIZE) # TODO: This will hang things as well. + if len(buf) == 0: + break + data += buf + remaining -= len(buf) + body = data[eoh:].decode() + try: + result = json.loads(body) + except json.JSONDecodeError as e: + # config store receiver doesn't give back + # proper json for 'put' ops, body + # contains verbose error message + # so putting the error msg in result + result = body.strip() + return {"status": status_hdr.decode(), "data": result} + + def _receive(self, sock): + errmsg = None + result = "" + try: + result = self._safe_receive(sock) + except Exception as err: + # ignore the command error, continue on to next command + errmsg = "_receive failed with exception={} err={}".format(type(err), str(err)) + if errmsg is not None: + self.log(errmsg) + return result + + +class EventingCSClient(CSClient): + running = False + registry = {} + eids = 1 + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.on = self.register + self.un = self.unregister + + def start(self): + if self.running: + self.log(f"Eventing Config Store {self.pid} already running") + return + self.running = True + self.pid = os.getpid() + self.f = '/var/tmp/csevent_%d.sock' % self.pid + try: + os.unlink(self.f) + except FileNotFoundError: + pass + self.event_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.event_sock.bind(self.f) + self.event_sock.listen() # backlog is optional. already set on value found in /proc + self.event_sock.setblocking(False) + self.eloop = threading.Thread(target=self._handle_events) + self.eloop.start() + + def stop(self): + if not self.running: + return + self.log(f"Stopping") + for k in list(self.registry.keys()): + self.unregister(k) + self.event_sock.close() + os.unlink(self.f) + self.running = False + + def _handle_events(self): + poller = select.poll() + poller.register(self.event_sock, + select.POLLIN | select.POLLERR | select.POLLHUP) # I don't unregsiter this in cleaning up! + while self.running: + try: + events = poller.poll(1000) + for f, ev in events: + if ev & (select.POLLERR | select.POLLHUP): + self.log("Hangup/error received. Stopping") + self.stop() # TODO: restart w/ cached registrations. Will no longer be an error case + + if ev & select.POLLIN: + conn, addr = self.event_sock.accept() + result = self._receive(conn) + eid = int(result['data']['id']) + try: + cb = self.registry[eid]['cb'] + args = self.registry[eid]['args'] + try: + # PUTting just a string to config store results in a json encoded string returned. + # e.g. set /config/system/logging/level "debug", result['data']['cfg'] is '"debug"' + cfg = json.loads(result['data']['cfg']) + except TypeError as e: + # Non-string path + cfg = result['data']['cfg'] + try: + cb_return = cb(result['data']['path'], cfg, args) + except: + if traceback: + traceback.print_exc() + self.log(f"Exception during callback for {str(self.registry[eid])}") + if result['data']['action'] == 'get': # We've something to send back. + # config_store_receiver expects json + cb_return = json.JSONEncoder().encode(cb_return) + conn.sendall( + cb_return.encode()) # No dispatch. Config store receiver will put to config store. + except (NameError, ValueError) as e: + self.log(f"Could not find register data for eid {eid}") + except OSError as e: + self.log(f"OSError: {e}") + raise + + def register(self, action: object, path: object, callback: object, *args: object) -> object: + if not self.running: + self.start() + # what about multiple registration? + eid = self.eids + self.eids += 1 + self.registry[eid] = {'cb': callback, 'action': action, 'path': path, 'args': args} + cmd = "register\n{}\n{}\n{}\n{}\n".format(self.pid, eid, action, path) + return self._dispatch(cmd) + + def unregister(self, eid): + ret = "" + try: + e = self.registry[eid] + except KeyError: + pass + else: + if self.running: + cmd = "unregister\n{}\n{}\n{}\n{}\n".format(self.pid, eid, e['action'], e['path']) + ret = self._dispatch(cmd) + del self.registry[eid] + return ret + + +def clean_up_reg(signal, frame): + """ + When 'cppython remote_port_forward.py' gets a SIGTERM, config_store_receiver.py doesn't + clean up registrations. Even if it did, the comm module can't rely on an external service + to clean up. + """ + EventingCSClient('CSClient').stop() + sys.exit(0) + + +signal.signal(signal.SIGTERM, clean_up_reg) diff --git a/dynamic_app/download.py b/dynamic_app/download.py new file mode 100644 index 00000000..6849fc30 --- /dev/null +++ b/dynamic_app/download.py @@ -0,0 +1,20 @@ +import requests + +def download_file(url, session=None, timeout=15): + local_filename = url.split('/')[-1] + req = session or requests + # NOTE the stream=True parameter below + with req.get(url, stream=True, timeout=timeout) as r: + r.raise_for_status() + with open(local_filename, 'wb') as f: + for chunk in r.iter_content(chunk_size=8192): + # If you have chunk encoded response uncomment if + # and set chunk_size parameter to None. + #if chunk: + f.write(chunk) + return local_filename + +if __name__ == "__main__": + import sys + url = sys.argv[1] + print("File downloaded:", download_file(url)) diff --git a/dynamic_app/dynamic.py b/dynamic_app/dynamic.py new file mode 100644 index 00000000..8add59bf --- /dev/null +++ b/dynamic_app/dynamic.py @@ -0,0 +1,99 @@ +import os +import time +import requests +from csclient import EventingCSClient +from download import download_file +from untar import extract +import shutil + + +class DynamicApp: + + @property + def settings(self): + if not self._settings: + self._settings = self.get_settings() + return self._settings + + @property + def session(self): + if not self._session: + self._session = requests.session() + if self.settings.get('auth'): + self._session.auth = tuple(self.settings['auth'].split(':')) + return self._session + + def __init__(self, c): + self._settings = {} + self._session = None + self.c = c + self.current_version = None + + def on_update_config(self, *args, **kwargs): + self.c.log("update config") + settings = self.get_settings() + self.c.log('settings: %s' % settings) + if settings != self._settings: + self._settings = settings + self._session = None + self.update_app() + + def update_app(self): + self.install_app(self.settings['url'], self.settings['name']) + + def install_app(self,url, name): + uuid = self.get_app_holder_uuid() + if not uuid: + self.c.log("ERROR: app_holder app not found") + return + else: + self.c.log(f"app_holder app uuid is {uuid}") + + # try to stop the app cleanly + self.c.log("Stopping app_holder") + self.c.put("/control/system/sdk/action", "stop %s" % uuid) + + filename = name + '.tar.gz' + download = url + '/' + filename + self.c.log("downloading %s" % download) + download_file(download, self.session) + extract(filename, '/var/mnt/sdk/%s/app_holder' % uuid) + try: + shutil.rmtree('/var/mnt/sdk/%s/app_holder/app' % uuid) + except FileNotFoundError: + pass + shutil.move('/var/mnt/sdk/%s/app_holder/%s' % (uuid, name), '/var/mnt/sdk/%s/app_holder/app' % uuid) + self.c.log('app %s installed' % name) + + self.c.log("Wait 3 seconds before restarting") + time.sleep(3) + self.c.put('/control/system/sdk/action', "restart %s" % uuid) + + def get_app_holder_uuid(self): + for root, dirs, files in os.walk("/var/mnt/sdk"): + if "app_holder" in dirs: + return root.split("/")[-1] + + def get_settings(self): + """settings names are dynamic.some_name from sdk.appdata""" + sdk_data = self.c.get('/config/system/sdk/appdata') + sdk_data = {v['name'].split(".")[1]: v['value'] for v in sdk_data if v['name'].startswith('dynamic')} + return sdk_data + + def run(self): + self.c.on('put', '/config/system/sdk/appdata', self.on_update_config) + self.on_update_config() + while True: + time.sleep(60) + +def main(): + c = EventingCSClient("dynamic_app") + c.log("STARTING APPLICATION %s" % c.app_name) + d = DynamicApp(c) + try: + d.run() + except Exception as e: + c.logger.exception(e) + +if __name__ == "__main__": + main() diff --git a/dynamic_app/package.ini b/dynamic_app/package.ini new file mode 100644 index 00000000..37b3ec48 --- /dev/null +++ b/dynamic_app/package.ini @@ -0,0 +1,14 @@ +[dynamic_app] +uuid = 9819fade-3301-49bd-89eb-05bacef85a34 +vendor = Cradlepoint +notes = driver for populating app holder with downloaded application +firmware_major = 7 +firmware_minor = 23 +firmware_patch = 20 +restart = true +reboot = true +auto_start = true +version_major = 1 +version_minor = 0 +version_patch = 3 + diff --git a/dynamic_app/readme.txt b/dynamic_app/readme.txt new file mode 100644 index 00000000..a35854a5 --- /dev/null +++ b/dynamic_app/readme.txt @@ -0,0 +1,19 @@ +# Dynamic App +This SDK app drives the downloading of an SDK from a specified URL into the app_holder app. This overcomes the size limitation of NCM, as well as makes it easier to build and test SDK apps without having the router in DEV mode. + +# Usage +1) Install both this application as well as the app_holder app onto the router through NCM. +2) Develop the target app as desired and package it through the normal process so you have a tar.gz file of the app (as if to upload to NCM). +3) Host the tar.gz file via http (for example using miniserve https://github.com/svenstaro/miniserve). +4) Configure the dynamic app using System -> SDK Data in group/indi router configuration using these name value pairs: + +| Name | Value | +| --------------- | --------------------------------------------------------- | +| dynamic.url | The url the app is hosted at e.g. http://192.168.0.5:8080 | +| dynamic.name | The name of the app | +| dynamic.version | Optional, but allows for easy triggering of app updates | + +5) Check the logs to make sure your app is running correctly. + +# Notes +This app automatically downloads from the dynamic.url + dynamic.name + .tar.gz. The name of the .tar.gz file must match the name of the app folder it extracts to. This is typically the default when building apps using make.py. diff --git a/dynamic_app/start.sh b/dynamic_app/start.sh new file mode 100755 index 00000000..19fa7ee0 --- /dev/null +++ b/dynamic_app/start.sh @@ -0,0 +1,2 @@ +#!/bin/bash +cppython dynamic.py \ No newline at end of file diff --git a/dynamic_app/untar.py b/dynamic_app/untar.py new file mode 100644 index 00000000..cfde9717 --- /dev/null +++ b/dynamic_app/untar.py @@ -0,0 +1,10 @@ +import tarfile + +def extract(filename, destination): + with tarfile.open(filename) as gz: + gz.extractall(destination) + +if __name__ == "__main__": + import sys + filename, destination = sys.argv[1:] + extract(filename, destination) \ No newline at end of file