def startup(self, sender, **kwargs): """ Try to get platform discovery info of all the remote platforms. If unsuccessful, setup events to try again later :param sender: caller :param kwargs: optional arguments :return: """ self._vip_socket = self.core.socket #If in setup mode, read the external_addresses.json to get web addresses to set up authorized connection with # external platforms. if self._setup_mode: if self._my_web_address is None: _log.error( "Web bind address is needed in multiplatform setup mode") return with self._ext_addresses_store_lock: try: self._ext_addresses_store = PersistentDict( filename=self._store_path, flag='c', format='json') except ValueError as exc: _log.error("Error in json format: {0}".format(exc)) #Delete the existing store. if self._ext_addresses_store: self._ext_addresses_store.clear() self._ext_addresses_store.async_sync() web_addresses = dict() #Read External web addresses file try: web_addresses = self._read_platform_address_file() try: web_addresses.remove(self._my_web_address) except ValueError: _log.debug( "My web address is not in the external bind web adress list" ) op = b'web-addresses' self._send_to_router(op, web_addresses) except IOError as exc: _log.error("Error in reading file: {}".format(exc)) return sec = random.random() * self.r + 10 delay = utils.get_aware_utc_now() + timedelta(seconds=sec) grnlt = self.core.schedule(delay, self._key_collection, web_addresses) else: #Use the existing store for platform discovery information with self._ext_addresses_store_lock: try: self._ext_addresses_store = PersistentDict( filename=self._store_path, flag='c', format='json') except ValueError as exc: _log.error("Error in json file format: {0}".format(exc)) for name, discovery_info in self._ext_addresses_store.items(): op = b'normalmode_platform_connection' self._send_to_router(op, discovery_info)
def configure(self, config_dict, registry_config_str): """ Interface configuration callback :param config_dict: Driver configuration dictionary :param registry_config_str: Driver registry configuration dictionary """ self.config_dict.update(config_dict) self.api_key = self.config_dict.get("API_KEY") self.ecobee_id = self.config_dict.get('DEVICE_ID') if not isinstance(self.ecobee_id, int): try: self.ecobee_id = int(self.ecobee_id) except ValueError: raise ValueError( f"Ecobee driver requires Ecobee device identifier as int, got: {self.ecobee_id}" ) self.cache = PersistentDict("ecobee_" + str(self.ecobee_id) + ".json", format='json') self.auth_config_path = AUTH_CONFIG_PATH.format(self.ecobee_id) self.parse_config(registry_config_str) # Fetch any stored configuration values to reuse self.authorization_stage = "UNAUTHORIZED" stored_auth_config = self.get_auth_config_from_store() # Do some minimal checks on auth if stored_auth_config: if stored_auth_config.get("AUTH_CODE"): self.authorization_code = stored_auth_config.get("AUTH_CODE") self.authorization_stage = "REQUEST_TOKENS" if stored_auth_config.get( "ACCESS_TOKEN") and stored_auth_config.get( "REFRESH_TOKEN"): self.access_token = stored_auth_config.get("ACCESS_TOKEN") self.refresh_token = stored_auth_config.get( "REFRESH_TOKEN") try: self.get_thermostat_data() self.authorization_stage = "AUTHORIZED" except HTTPError: _log.warning( "Ecobee request response contained HTTP Error, authorization code may be expired. " "Requesting new authorization code from Ecobee api" ) self.authorization_stage = "UNAUTHORIZED" if self.authorization_stage != "AUTHORIZED": # if this fails, our attempt to obtain new auth code and tokens was unsuccessful and the driver is in an # error state self.update_authorization() self.get_thermostat_data() if not self.poll_greenlet_thermostats: self.poll_greenlet_thermostats = self.core.periodic( 180, self.get_thermostat_data) _log.debug("Ecobee configuration complete.")
def startup(self, sender, **kwargs): """ Try to get platform discovery info of all the remote platforms. If unsuccessful, setup events to try again later :param sender: caller :param kwargs: optional arguments :return: """ self._vip_socket = self.core.socket #If in setup mode, read the external_addresses.json to get web addresses to set up authorized connection with # external platforms. if self._setup_mode: if self._my_web_address is None: _log.error("Web bind address is needed in multiplatform setup mode") return with self._ext_addresses_store_lock: try: self._ext_addresses_store = PersistentDict(filename=self._store_path, flag='c', format='json') except ValueError as exc: _log.error("Error in json format: {0}".format(exc)) #Delete the existing store. if self._ext_addresses_store: self._ext_addresses_store.clear() self._ext_addresses_store.async_sync() web_addresses = dict() #Read External web addresses file try: web_addresses = self._read_platform_address_file() try: web_addresses.remove(self._my_web_address) except ValueError: _log.debug("My web address is not in the external bind web adress list") op = b'web-addresses' self._send_to_router(op, web_addresses) except IOError as exc: _log.error("Error in reading file: {}".format(exc)) return sec = random.random() * self.r + 10 delay = utils.get_aware_utc_now() + timedelta(seconds=sec) grnlt = self.core.schedule(delay, self._key_collection, web_addresses) else: #Use the existing store for platform discovery information with self._ext_addresses_store_lock: try: self._ext_addresses_store = PersistentDict(filename=self._store_path, flag='c', format='json') except ValueError as exc: _log.error("Error in json file format: {0}".format(exc)) for name, discovery_info in self._ext_addresses_store.items(): op = b'normalmode_platform_connection' self._send_to_router(op, discovery_info)
def _setup(self, sender, **kwargs): _log.info("Initializing configuration store service.") try: os.makedirs(self.store_path) except OSError as e: if e.errno != errno.EEXIST: _log.critical( "Failed to create configuration store directory: " + str(e)) raise else: _log.debug("Configuration directory already exists.") config_store_iter = glob.iglob( os.path.join(self.store_path, "*" + store_ext)) for store_path in config_store_iter: root, ext = os.path.splitext(store_path) agent_identity = os.path.basename(root) _log.debug("Processing store for agent {}".format(agent_identity)) store = PersistentDict(filename=store_path, flag='c', format='json') parsed_configs, name_map = process_store(agent_identity, store) self.store[agent_identity] = { "configs": parsed_configs, "store": store, "name_map": name_map, "lock": Semaphore() }
def get_configs(self): """Called by an Agent at startup to trigger initial configuration state push.""" identity = bytes(self.vip.rpc.context.vip_message.peer) #We need to create store and lock if it doesn't exist in case someone tries to add # a configuration while we are sending the initial state. agent_store = self.store.get(identity) if agent_store is None: # Initialize a new store. store_path = os.path.join(self.store_path, identity + store_ext) store = PersistentDict(filename=store_path, flag='c', format='json') agent_store = {"configs": {}, "store": store, "name_map": {}, "lock": Semaphore()} self.store[identity] = agent_store agent_configs = agent_store["configs"] agent_disk_store = agent_store["store"] agent_store_lock = agent_store["lock"] with agent_store_lock: try: self.vip.rpc.call(identity, "config.initial_update", agent_configs).get(timeout=10.0) except errors.Unreachable: _log.debug("Agent {} not currently running. Configuration update not sent.".format(identity)) except RemoteError as e: _log.error("Agent {} failure when performing initial update: {}".format(identity, e)) except MethodNotFound as e: _log.error( "Agent {} failure when performing initial update: {}".format(identity, e)) # If the store is empty (and nothing jumped in and added to it while we were informing the agent) # then remove it from the global store. if not agent_disk_store: self.store.pop(identity, None)
def _add_config_to_store(self, identity, config_name, raw, parsed, config_type, trigger_callback=False, send_update=True): """Adds a processed configuration to the store.""" agent_store = self.store.get(identity) action = "UPDATE" if agent_store is None: #Initialize a new store. store_path = os.path.join(self.store_path, identity+ store_ext) store = PersistentDict(filename=store_path, flag='c', format='json') agent_store = {"configs": {}, "store": store, "name_map": {}, "lock": Semaphore()} self.store[identity] = agent_store agent_configs = agent_store["configs"] agent_disk_store = agent_store["store"] agent_store_lock = agent_store["lock"] agent_name_map = agent_store["name_map"] config_name = strip_config_name(config_name) config_name_lower = config_name.lower() if config_name_lower not in agent_name_map: action = "NEW" if check_for_recursion(config_name, parsed, agent_configs): raise ValueError("Recursive configuration references detected.") if config_name_lower in agent_name_map: old_config_name = agent_name_map[config_name_lower] del agent_configs[old_config_name] agent_configs[config_name] = parsed agent_name_map[config_name_lower] = config_name agent_disk_store[config_name] = {"type": config_type, "modified": format_timestamp(get_aware_utc_now()), "data": raw} agent_disk_store.async_sync() _log.debug("Agent {} config {} stored.".format(identity, config_name)) if send_update: with agent_store_lock: try: self.vip.rpc.call(identity, "config.update", action, config_name, contents=parsed, trigger_callback=trigger_callback).get(timeout=10.0) except errors.Unreachable: _log.debug("Agent {} not currently running. Configuration update not sent.".format(identity)) except RemoteError as e: _log.error("Agent {} failure when adding/updating configuration {}: {}".format(identity, config_name, e)) except MethodNotFound as e: _log.error( "Agent {} failure when adding/updating configuration {}: {}".format(identity, config_name, e))
def reload_userdict(self): webuserpath = os.path.join(get_home(), 'web-users.json') self._userdict = PersistentDict(webuserpath)
class AuthenticateEndpoints(object): def __init__(self, ssl_private_key): self._ssl_private_key = ssl_private_key self._userdict = None self.reload_userdict() self._observer = Observer() self._observer.schedule( FileReloader("web-users.json", self.reload_userdict), get_home() ) self._observer.start() def reload_userdict(self): webuserpath = os.path.join(get_home(), 'web-users.json') self._userdict = PersistentDict(webuserpath) def get_routes(self): """ Returns a list of tuples with the routes for authentication. Tuple should have the following: - regular expression for calling the endpoint - 'callable' keyword specifying that a method is being specified - the method that should be used to call when the regular expression matches code: return [ (re.compile('^/csr/request_new$'), 'callable', self._csr_request_new) ] :return: """ return [ (re.compile('^/authenticate'), 'callable', self.get_auth_token) ] def get_auth_token(self, env, data): """ Creates an authentication token to be returned to the caller. The response will be a text/plain encoded user :param env: :param data: :return: """ if env.get('REQUEST_METHOD') != 'POST': _log.warning("Authentication must use POST request.") return Response('', status='401 Unauthorized') assert len(self._userdict) > 0, "No users in user dictionary, set the master password first!" if not isinstance(data, dict): _log.debug("data is not a dict, decoding") decoded = dict((k, v if len(v) > 1 else v[0]) for k, v in urlparse.parse_qs(data).iteritems()) username = decoded.get('username') password = decoded.get('password') else: username = data.get('username') password = data.get('password') _log.debug("Username is: {}".format(username)) error = "" if username is None: error += "Invalid username passed" if not password: error += "Invalid password passed" if error: _log.error("Invalid parameters passed: {}".format(error)) return Response(error, status='401') user = self.__get_user(username, password) if user is None: _log.error("No matching user for passed username: {}".format(username)) return Response('', status='401') encoded = jwt.encode(user, self._ssl_private_key, algorithm='RS256').encode('utf-8') return Response(encoded, '200 OK', content_type='text/plain') def __get_user(self, username, password): """ Retrieve user from the user store based upon username/password The hashed_password will not be returned with the value in the user object. If there is not a username/password that match return None. :param username: :param password: :return: """ user = self._userdict.get(username) if user is not None: hashed_pass = user.get('hashed_password') if hashed_pass and argon2.verify(password, hashed_pass): usr_cpy = user.copy() del usr_cpy['hashed_password'] return usr_cpy return None
class AdminEndpoints(object): def __init__(self, rmq_mgmt, ssl_public_key): self._rmq_mgmt = rmq_mgmt self._ssl_public_key = ssl_public_key self._userdict = None self.reload_userdict() self._observer = Observer() self._observer.schedule( FileReloader("web-users.json", self.reload_userdict), get_home()) self._observer.start() self._certs = Certs() def reload_userdict(self): webuserpath = os.path.join(get_home(), 'web-users.json') self._userdict = PersistentDict(webuserpath) def get_routes(self): """ Returns a list of tuples with the routes for the adminstration endpoints available in it. :return: """ return [(re.compile('^/admin.*'), 'callable', self.admin)] def admin(self, env, data): if len(self._userdict) == 0: if env.get('REQUEST_METHOD') == 'POST': decoded = dict((k, v if len(v) > 1 else v[0]) for k, v in urlparse.parse_qs(data).iteritems()) username = decoded.get('username') pass1 = decoded.get('password1') pass2 = decoded.get('password2') if pass1 == pass2 and pass1 is not None: _log.debug("Setting master password") self.add_user(username, pass1, groups=['admin']) return Response('', status='302', headers={'Location': '/admin/login.html'}) template = template_env(env).get_template('first.html') return Response(template.render()) if 'login.html' in env.get('PATH_INFO') or '/admin/' == env.get( 'PATH_INFO'): template = template_env(env).get_template('login.html') return Response(template.render()) return self.verify_and_dispatch(env, data) def verify_and_dispatch(self, env, data): """ Verify that the user is an admin and dispatch :param env: web environment :param data: data associated with a web form or json/xml request data :return: Response object. """ from volttron.platform.web import get_user_claims, NotAuthorized try: claims = get_user_claims(env) except NotAuthorized: _log.error("Unauthorized user attempted to connect to {}".format( env.get('PATH_INFO'))) return Response('<h1>Unauthorized User</h1>', status="401 Unauthorized") # Make sure we have only admins for viewing this. if 'admin' not in claims.get('groups'): return Response('<h1>Unauthorized User</h1>', status="401 Unauthorized") # Make sure we have only admins for viewing this. if 'admin' not in claims.get('groups'): return Response('<h1>Unauthorized User</h1>', status="401 Unauthorized") path_info = env.get('PATH_INFO') if path_info.startswith('/admin/api/'): return self.__api_endpoint(path_info[len('/admin/api/'):], data) if path_info.endswith('html'): page = path_info.split('/')[-1] try: template = template_env(env).get_template(page) except TemplateNotFound: return Response("<h1>404 Not Found</h1>", status="404 Not Found") if page == 'list_certs.html': html = template.render( certs=self._certs.get_all_cert_subjects()) elif page == 'pending_csrs.html': html = template.render( csrs=self._certs.get_pending_csr_requests()) else: # A template with no params. html = template.render() return Response(html) template = template_env(env).get_template('index.html') resp = template.render() return Response(resp) def __api_endpoint(self, endpoint, data): _log.debug("Doing admin endpoint {}".format(endpoint)) if endpoint == 'certs': response = self.__cert_list_api() elif endpoint == 'pending_csrs': response = self.__pending_csrs_api() elif endpoint.startswith('approve_csr/'): response = self.__approve_csr_api(endpoint.split('/')[1]) elif endpoint.startswith('deny_csr/'): response = self.__deny_csr_api(endpoint.split('/')[1]) elif endpoint.startswith('delete_csr/'): response = self.__delete_csr_api(endpoint.split('/')[1]) else: response = Response( '{"status": "Unknown endpoint {}"}'.format(endpoint), content_type="application/json") return response def __approve_csr_api(self, common_name): try: _log.debug("Creating cert and permissions for user: {}".format( common_name)) self._certs.approve_csr(common_name) permissions = self._rmq_mgmt.get_default_permissions(common_name) self._rmq_mgmt.create_user_with_permissions( common_name, permissions, True) data = dict(status=self._certs.get_csr_status(common_name), cert=self._certs.get_cert_from_csr(common_name)) except ValueError as e: data = dict(status="ERROR", message=e.message) return Response(json.dumps(data), content_type="application/json") def __deny_csr_api(self, common_name): try: self._certs.deny_csr(common_name) data = dict(status="DENIED", message="The administrator has denied the request") except ValueError as e: data = dict(status="ERROR", message=e.message) return Response(json.dumps(data), content_type="application/json") def __delete_csr_api(self, common_name): try: self._certs.delete_csr(common_name) data = dict(status="DELETED", message="The administrator has denied the request") except ValueError as e: data = dict(status="ERROR", message=e.message) return Response(json.dumps(data), content_type="application/json") def __pending_csrs_api(self): csrs = [c for c in self._certs.get_pending_csr_requests()] return Response(json.dumps(csrs), content_type="application/json") def __cert_list_api(self): subjects = [ dict(common_name=x.common_name) for x in self._certs.get_all_cert_subjects() ] return Response(json.dumps(subjects), content_type="application/json") def add_user(self, username, unencrypted_pw, groups=[], overwrite=False): if self._userdict.get(username): raise ValueError("Already exists!") if groups is None: groups = [] hashed_pass = argon2.hash(unencrypted_pw) self._userdict[username] = dict(hashed_password=hashed_pass, groups=groups) self._userdict.async_sync()
class KeyDiscoveryAgent(Agent): """ Class to get server key, instance name and vip address of external/remote platforms """ def __init__(self, address, serverkey, identity, external_address_config, setup_mode, bind_web_address, *args, **kwargs): super(KeyDiscoveryAgent, self).__init__(identity, address, **kwargs) self._external_address_file = external_address_config self._ext_addresses = dict() self._grnlets = dict() self._vip_socket = None self._my_web_address = bind_web_address self.r = random.random() self._setup_mode = setup_mode if self._setup_mode: _log.debug("RUNNING IN MULTI-PLATFORM SETUP MODE") self._store_path = os.path.join(os.environ['VOLTTRON_HOME'], 'external_platform_discovery.json') self._ext_addresses_store = dict() self._ext_addresses_store_lock = Semaphore() @Core.receiver('onstart') def startup(self, sender, **kwargs): """ Try to get platform discovery info of all the remote platforms. If unsuccessful, setup events to try again later :param sender: caller :param kwargs: optional arguments :return: """ self._vip_socket = self.core.socket #If in setup mode, read the external_addresses.json to get web addresses to set up authorized connection with # external platforms. if self._setup_mode: if self._my_web_address is None: _log.error( "Web bind address is needed in multiplatform setup mode") return with self._ext_addresses_store_lock: try: self._ext_addresses_store = PersistentDict( filename=self._store_path, flag='c', format='json') except ValueError as exc: _log.error("Error in json format: {0}".format(exc)) #Delete the existing store. if self._ext_addresses_store: self._ext_addresses_store.clear() self._ext_addresses_store.async_sync() web_addresses = dict() #Read External web addresses file try: web_addresses = self._read_platform_address_file() try: web_addresses.remove(self._my_web_address) except ValueError: _log.debug( "My web address is not in the external bind web adress list" ) op = b'web-addresses' self._send_to_router(op, web_addresses) except IOError as exc: _log.error("Error in reading file: {}".format(exc)) return sec = random.random() * self.r + 10 delay = utils.get_aware_utc_now() + timedelta(seconds=sec) grnlt = self.core.schedule(delay, self._key_collection, web_addresses) else: #Use the existing store for platform discovery information with self._ext_addresses_store_lock: try: self._ext_addresses_store = PersistentDict( filename=self._store_path, flag='c', format='json') except ValueError as exc: _log.error("Error in json file format: {0}".format(exc)) for name, discovery_info in self._ext_addresses_store.items(): op = b'normalmode_platform_connection' self._send_to_router(op, discovery_info) def _key_collection(self, web_addresses): """ Collect platform discovery information (server key, instance name, vip-address) for all platforms. :param web_addresses: List of web addresses to get discovery info :return: """ for web_address in web_addresses: if web_address not in self._my_web_address: self._collect_key(web_address) def _collect_key(self, web_address): """ Try to get (server key, instance name, vip-address) of remote instance and send it to RoutingService to connect to the remote instance. If unsuccessful, try again later. :param name: instance name :param web_address: web address of remote instance :return: """ platform_info = dict() try: platform_info = self._get_platform_discovery(web_address) with self._ext_addresses_store_lock: _log.debug("Platform discovery info: {}".format(platform_info)) name = platform_info['instance-name'] self._ext_addresses_store[name] = platform_info self._ext_addresses_store.async_sync() except KeyError as exc: _log.error( "Discovery info does not contain instance name {}".format(exc)) except DiscoveryError: # If discovery error, try again later sec = random.random() * self.r + 30 delay = utils.get_aware_utc_now() + timedelta(seconds=sec) grnlet = self.core.schedule(delay, self._collect_key, web_address) except ConnectionError as e: _log.error("HTTP connection error {}".format(e)) #If platform discovery is successful, send the info to RoutingService #to establish connection with remote platform. if platform_info: op = b'setupmode_platform_connection' connection_settings = dict(platform_info) connection_settings['web-address'] = web_address self._send_to_router(op, connection_settings) def _send_to_router(self, op, platform_info): """ Send the platform discovery stats to the router to establish new connection :param platform_info: platform discovery stats :return: """ address = jsonapi.dumps(platform_info) frames = [op, address] try: self._vip_socket.send_vip(b'', 'routing_table', frames, copy=False) except ZMQError as ex: # Try sending later _log.error( "ZMQ error while sending external platform info to router: {}". format(ex)) def _read_platform_address_file(self): """ Read the external addresses file :return: """ try: with open(self._external_address_file) as fil: # Use gevent FileObject to avoid blocking the thread data = FileObject(fil, close=False).read() web_addresses = jsonapi.loads(data) if data else {} return web_addresses except IOError as e: _log.error("Error opening file {}".format( self._external_address_file)) raise except Exception: _log.exception('error loading %s', self._external_address_file) raise def _get_platform_discovery(self, web_address): """ Use http discovery call to get serverkey, instance name and vip-address of remote instance :param web_address: web address of remote instance :return: """ r = {} try: parsed = urlparse(web_address) assert parsed.scheme assert not parsed.path real_url = urljoin(web_address, "/discovery/") req = grequests.get(real_url) responses = grequests.map([req]) responses[0].raise_for_status() r = responses[0].json() return r except requests.exceptions.HTTPError: raise DiscoveryError( "Invalid discovery response from {}".format(real_url)) except requests.exceptions.Timeout: raise DiscoveryError("Timeout error from {}".format(real_url)) except AttributeError as e: raise DiscoveryError( "Invalid web_address passed {}".format(web_address)) except (ConnectionError, NewConnectionError) as e: raise DiscoveryError( "Connection to {} not available".format(real_url)) except Exception as e: raise DiscoveryError("Unknown Exception: {}".format(str(e)))
class AuthenticateEndpoints(object): def __init__(self, tls_private_key=None, tls_public_key=None, web_secret_key=None): self.refresh_token_timeout = 240 # minutes before token expires. TODO: Should this be a setting somewhere? self.access_token_timeout = 15 # minutes before token expires. TODO: Should this be a setting somewhere? self._tls_private_key = tls_private_key self._tls_public_key = tls_public_key self._web_secret_key = web_secret_key if self._tls_private_key is None and self._web_secret_key is None: raise ValueError( "Must have either ssl_private_key or web_secret_key specified!" ) if self._tls_private_key is not None and self._web_secret_key is not None: raise ValueError( "Must use either ssl_private_key or web_secret_key not both!") self._userdict = None self.reload_userdict() self._observer = Observer() self._observer.schedule( VolttronHomeFileReloader("web-users.json", self.reload_userdict), get_home()) self._observer.start() def reload_userdict(self): webuserpath = os.path.join(get_home(), 'web-users.json') self._userdict = PersistentDict(webuserpath) def get_routes(self): """ Returns a list of tuples with the routes for authentication. Tuple should have the following: - regular expression for calling the endpoint - 'callable' keyword specifying that a method is being specified - the method that should be used to call when the regular expression matches code: return [ (re.compile('^/csr/request_new$'), 'callable', self._csr_request_new) ] :return: """ return [(re.compile('^/authenticate'), 'callable', self.handle_authenticate)] def handle_authenticate(self, env, data): """ Callback for /authenticate endpoint. Routes request based on HTTP method and returns a text/plain encoded token or error. :param env: :param data: :return: Response """ method = env.get('REQUEST_METHOD') if method == 'POST': response = self.get_auth_tokens(env, data) elif method == 'PUT': response = self.renew_auth_token(env, data) elif method == 'DELETE': response = self.revoke_auth_token(env, data) else: error = f"/authenticate endpoint accepts only POST, PUT, or DELETE methods. Received: {method}" _log.warning(error) return Response(error, status='405 Method Not Allowed', content_type='text/plain') return response def get_auth_tokens(self, env, data): """ Creates an authentication refresh and acccss tokens to be returned to the caller. The response will be a text/plain encoded user. Data should contain: { "username": "******", "password": "******" } :param env: :param data: :return: """ assert len( self._userdict ) > 0, "No users in user dictionary, set the administrator password first!" if not isinstance(data, dict): _log.debug("data is not a dict, decoding") decoded = dict((k, v if len(v) > 1 else v[0]) for k, v in parse_qs(data).items()) username = decoded.get('username') password = decoded.get('password') else: username = data.get('username') password = data.get('password') _log.debug("Username is: {}".format(username)) error = "" if username is None: error += "Invalid username passed" if not password: error += "Invalid password passed" if error: _log.error("Invalid parameters passed: {}".format(error)) return Response(error, status='401') user = self.__get_user(username, password) if user is None: _log.error( "No matching user for passed username: {}".format(username)) return Response('', status='401') access_token, refresh_token = self._get_tokens(user) response = Response(json.dumps({ "refresh_token": refresh_token, "access_token": access_token }), content_type="application/json") return response def _get_tokens(self, claims): now = datetime.utcnow() claims['iat'] = now claims['nbf'] = now claims['exp'] = now + timedelta(minutes=self.access_token_timeout) claims['grant_type'] = 'access_token' algorithm = 'RS256' if self._tls_private_key is not None else 'HS256' encode_key = self._tls_private_key if algorithm == 'RS256' else self._web_secret_key access_token = jwt.encode(claims, encode_key, algorithm=algorithm) claims['exp'] = now + timedelta(minutes=self.refresh_token_timeout) claims['grant_type'] = 'refresh_token' refresh_token = jwt.encode(claims, encode_key, algorithm=algorithm) return access_token.decode('utf-8'), refresh_token.decode('utf8') def renew_auth_token(self, env, data): """ Creates a new authentication access token to be returned to the caller. The response will be a text/plain encoded user. Request should contain: • Content Type: application/json • Authorization: BEARER <jwt_refresh_token> • Body (optional): { "current_access_token": "<jwt_access_token>" } :param env: :param data: :return: """ current_access_token = data.get('current_access_token') from volttron.platform.web import get_bearer, get_user_claim_from_bearer, NotAuthorized try: current_refresh_token = get_bearer(env) claims = get_user_claim_from_bearer( current_refresh_token, web_secret_key=self._web_secret_key, tls_public_key=self._tls_public_key) except NotAuthorized: _log.error("Unauthorized user attempted to connect to {}".format( env.get('PATH_INFO'))) return Response('Unauthorized User', status="401 Unauthorized") except jwt.ExpiredSignatureError: _log.error( "User attempted to connect to {} with an expired signature". format(env.get('PATH_INFO'))) return Response('Unauthorized User', status="401 Unauthorized") if claims.get('grant_type') != 'refresh_token' or not claims.get( 'groups'): return Response('Invalid refresh token.', status="401 Unauthorized") else: # TODO: Consider blacklisting and reissuing refresh tokens also when used. new_access_token, _ = self._get_tokens(claims) if current_access_token: pass # TODO: keep current subscriptions? blacklist old token? return Response(json.dumps({"access_token": new_access_token}), content_type="application/json") def revoke_auth_token(self, env, data): # TODO: Blacklist old token? Immediately close websockets? return Response('DELETE /authenticate is not yet implemented', status='501 Not Implemented', content_type='text/plain') def __get_user(self, username, password): """ Retrieve user from the user store based upon username/password The hashed_password will not be returned with the value in the user object. If there is not a username/password that match return None. :param username: :param password: :return: """ user = self._userdict.get(username) if user is not None: hashed_pass = user.get('hashed_password') if hashed_pass and argon2.verify(password, hashed_pass): usr_cpy = user.copy() del usr_cpy['hashed_password'] return usr_cpy return None
class KeyDiscoveryAgent(Agent): """ Class to get server key, instance name and vip address of external/remote platforms """ def __init__(self, address, serverkey, identity, external_address_config, setup_mode, bind_web_address, *args, **kwargs): super(KeyDiscoveryAgent, self).__init__(identity, address, **kwargs) self._external_address_file = external_address_config self._ext_addresses = dict() self._grnlets = dict() self._vip_socket = None self._my_web_address = bind_web_address self.r = random.random() self._setup_mode = setup_mode if self._setup_mode: _log.debug("RUNNING IN MULTI-PLATFORM SETUP MODE") self._store_path = os.path.join(os.environ['VOLTTRON_HOME'], 'external_platform_discovery.json') self._ext_addresses_store = dict() self._ext_addresses_store_lock = Semaphore() @Core.receiver('onstart') def startup(self, sender, **kwargs): """ Try to get platform discovery info of all the remote platforms. If unsuccessful, setup events to try again later :param sender: caller :param kwargs: optional arguments :return: """ self._vip_socket = self.core.socket #If in setup mode, read the external_addresses.json to get web addresses to set up authorized connection with # external platforms. if self._setup_mode: if self._my_web_address is None: _log.error("Web bind address is needed in multiplatform setup mode") return with self._ext_addresses_store_lock: try: self._ext_addresses_store = PersistentDict(filename=self._store_path, flag='c', format='json') except ValueError as exc: _log.error("Error in json format: {0}".format(exc)) #Delete the existing store. if self._ext_addresses_store: self._ext_addresses_store.clear() self._ext_addresses_store.async_sync() web_addresses = dict() #Read External web addresses file try: web_addresses = self._read_platform_address_file() try: web_addresses.remove(self._my_web_address) except ValueError: _log.debug("My web address is not in the external bind web adress list") op = b'web-addresses' self._send_to_router(op, web_addresses) except IOError as exc: _log.error("Error in reading file: {}".format(exc)) return sec = random.random() * self.r + 10 delay = utils.get_aware_utc_now() + timedelta(seconds=sec) grnlt = self.core.schedule(delay, self._key_collection, web_addresses) else: #Use the existing store for platform discovery information with self._ext_addresses_store_lock: try: self._ext_addresses_store = PersistentDict(filename=self._store_path, flag='c', format='json') except ValueError as exc: _log.error("Error in json file format: {0}".format(exc)) for name, discovery_info in self._ext_addresses_store.items(): op = b'normalmode_platform_connection' self._send_to_router(op, discovery_info) def _key_collection(self, web_addresses): """ Collect platform discovery information (server key, instance name, vip-address) for all platforms. :param web_addresses: List of web addresses to get discovery info :return: """ for web_address in web_addresses: if web_address not in self._my_web_address: self._collect_key(web_address) def _collect_key(self, web_address): """ Try to get (server key, instance name, vip-address) of remote instance and send it to RoutingService to connect to the remote instance. If unsuccessful, try again later. :param name: instance name :param web_address: web address of remote instance :return: """ platform_info = dict() try: platform_info = self._get_platform_discovery(web_address) with self._ext_addresses_store_lock: _log.debug("Platform discovery info: {}".format(platform_info)) name = platform_info['instance-name'] self._ext_addresses_store[name] = platform_info self._ext_addresses_store.async_sync() except KeyError as exc: _log.error("Discovery info does not contain instance name {}".format(exc)) except DiscoveryError: # If discovery error, try again later sec = random.random() * self.r + 30 delay = utils.get_aware_utc_now() + timedelta(seconds=sec) grnlet = self.core.schedule(delay, self._collect_key, web_address) except ConnectionError as e: _log.error("HTTP connection error {}".format(e)) #If platform discovery is successful, send the info to RoutingService #to establish connection with remote platform. if platform_info: op = b'setupmode_platform_connection' connection_settings = dict(platform_info) connection_settings['web-address'] = web_address self._send_to_router(op, connection_settings) def _send_to_router(self, op, platform_info): """ Send the platform discovery stats to the router to establish new connection :param platform_info: platform discovery stats :return: """ address = jsonapi.dumps(platform_info) frames = [op, address] try: self._vip_socket.send_vip(b'', 'routing_table', frames, copy=False) except ZMQError as ex: # Try sending later _log.error("ZMQ error while sending external platform info to router: {}".format(ex)) def _read_platform_address_file(self): """ Read the external addresses file :return: """ try: with open(self._external_address_file) as fil: # Use gevent FileObject to avoid blocking the thread data = FileObject(fil, close=False).read() web_addresses = jsonapi.loads(data) if data else {} return web_addresses except IOError as e: _log.error("Error opening file {}".format(self._external_address_file)) raise except Exception: _log.exception('error loading %s', self._external_address_file) raise def _get_platform_discovery(self, web_address): """ Use http discovery call to get serverkey, instance name and vip-address of remote instance :param web_address: web address of remote instance :return: """ r = {} try: parsed = urlparse(web_address) assert parsed.scheme assert not parsed.path real_url = urljoin(web_address, "/discovery/") req = grequests.get(real_url) responses = grequests.map([req]) responses[0].raise_for_status() r = responses[0].json() return r except requests.exceptions.HTTPError: raise DiscoveryError( "Invalid discovery response from {}".format(real_url) ) except requests.exceptions.Timeout: raise DiscoveryError( "Timeout error from {}".format(real_url) ) except AttributeError as e: raise DiscoveryError( "Invalid web_address passed {}" .format(web_address) ) except (ConnectionError, NewConnectionError) as e: raise DiscoveryError( "Connection to {} not available".format(real_url) ) except Exception as e: raise DiscoveryError( "Unknown Exception".format(e.message) )
class Interface(BasicRevert, BaseInterface): """ Interface implementation for wrapping around the Ecobee thermostat API """ def __init__(self, **kwargs): super(Interface, self).__init__(**kwargs) # Configuration value defaults self.config_dict = {} self.api_key = "" self.ecobee_id = -1 # which agent is being used as the caching agent self.cache = None # Authorization tokens self.refresh_token = None self.access_token = None self.authorization_code = None self.authorization_stage = "UNAUTHORIZED" # Config path for storing Ecobee auth information in config store, not user facing self.auth_config_path = "" # Un-initialized data response from Driver Cache agent self.thermostat_data = None # Un-initialized greenlet for querying cache agent self.poll_greenlet_thermostats = None def configure(self, config_dict, registry_config_str): """ Interface configuration callback :param config_dict: Driver configuration dictionary :param registry_config_str: Driver registry configuration dictionary """ self.config_dict.update(config_dict) self.api_key = self.config_dict.get("API_KEY") self.ecobee_id = self.config_dict.get('DEVICE_ID') if not isinstance(self.ecobee_id, int): try: self.ecobee_id = int(self.ecobee_id) except ValueError: raise ValueError( f"Ecobee driver requires Ecobee device identifier as int, got: {self.ecobee_id}") self.cache = PersistentDict("ecobee_" + str(self.ecobee_id) + ".json", format='json') self.auth_config_path = AUTH_CONFIG_PATH.format(self.ecobee_id) self.parse_config(registry_config_str) # Fetch any stored configuration values to reuse self.authorization_stage = "UNAUTHORIZED" stored_auth_config = self.get_auth_config_from_store() # Do some minimal checks on auth if stored_auth_config: if stored_auth_config.get("AUTH_CODE"): self.authorization_code = stored_auth_config.get("AUTH_CODE") self.authorization_stage = "REQUEST_TOKENS" if stored_auth_config.get("ACCESS_TOKEN") and stored_auth_config.get("REFRESH_TOKEN"): self.access_token = stored_auth_config.get("ACCESS_TOKEN") self.refresh_token = stored_auth_config.get("REFRESH_TOKEN") try: self.get_thermostat_data() self.authorization_stage = "AUTHORIZED" except HTTPError: _log.warning("Ecobee request response contained HTTP Error, authorization code may be expired. " "Requesting new authorization code from Ecobee api") self.authorization_stage = "UNAUTHORIZED" if self.authorization_stage != "AUTHORIZED": # if this fails, our attempt to obtain new auth code and tokens was unsuccessful and the driver is in an # error state self.update_authorization() self.get_thermostat_data() if not self.poll_greenlet_thermostats: self.poll_greenlet_thermostats = self.core.periodic(180, self.get_thermostat_data) _log.debug("Ecobee configuration complete.") def parse_config(self, config_dict): """ Parse driver registry configuration and create device registers :param config_dict: Registry configuration in dictionary representation """ first_hold = True _log.debug("Parsing Ecobee registry configuration.") if not config_dict: return # Parse configuration file for registry parameters, then add new register to the interface for index, regDef in enumerate(config_dict): point_name = regDef.get("Point Name") if not point_name: _log.warning(f"Registry configuration contained entry without a point name: {regDef}") continue read_only = regDef.get('Writable', "").lower() != 'true' readable = regDef.get('Readable', "").lower() == 'true' volttron_point_name = regDef.get('Volttron Point Name') if not volttron_point_name: volttron_point_name = point_name description = regDef.get('Notes', '') units = regDef.get('Units', None) default_value = regDef.get("Default Value", "").strip() # Truncate empty string or 0 values to None if not default_value: default_value = None type_name = regDef.get("Type", 'string') # Create an instance of the register class based on the register type if type_name.lower().startswith("setting"): register = Setting(self.ecobee_id, read_only, readable, volttron_point_name, point_name, units, description=description) elif type_name.lower() == "hold": if first_hold: _log.warning("Hold registers' set_point requires dictionary value, for best practices, visit " "https://www.ecobee.com/home/developer/api/documentation/v1/functions/SetHold.shtml") first_hold = False register = Hold(self.ecobee_id, read_only, readable, volttron_point_name, point_name, units, description=description) else: _log.warning(f"Unsupported register type {type_name} in Ecobee registry configuration") continue if default_value is not None: self.set_default(point_name, default_value) # Add the register instance to our list of registers self.insert_register(register) # Each Ecobee thermostat has one Status reporting "register", one programs register and one vacation "register # Status is a static point which reports a list of running HVAC systems reporting to the thermostat status_register = Status(self.ecobee_id) self.insert_register(status_register) # Vacation can be used to manage all Vacation programs for the thermostat vacation_register = Vacation(self.ecobee_id) self.insert_register(vacation_register) # Add a register for listing events and resuming programs program_register = Program(self.ecobee_id) self.insert_register(program_register) def update_authorization(self): if self.authorization_stage == "UNAUTHORIZED": self.authorize_application() if self.authorization_stage == "REQUEST_TOKENS": self.request_tokens() if self.authorization_stage == "REFRESH_TOKENS": self.refresh_tokens() self.update_auth_config() def authorize_application(self): auth_url = "https://api.ecobee.com/authorize" params = { "response_type": "ecobeePin", "client_id": self.api_key, "scope": "smartWrite" } try: response = make_ecobee_request("GET", auth_url, params=params) except (ConnectionError, NewConnectionError) as re: _log.error(re) _log.warning("Error connecting to Ecobee, Could not request pin.") return for auth_item in ['code', 'ecobeePin']: if auth_item not in response: raise RuntimeError(f"Ecobee authorization response was missing required item: {auth_item}, response " "contained {response}") self.authorization_code = response.get('code') pin = response.get('ecobeePin') _log.warning("***********************************************************") _log.warning( f'Please authorize your Ecobee developer app with PIN code {pin}.\nGo to ' 'https://www.ecobee.com/consumerportal /index.html, click My Apps, Add application, Enter Pin and click ' 'Authorize.') _log.warning("***********************************************************") self.authorization_stage = "REQUEST_TOKENS" gevent.sleep(60) def request_tokens(self): """ Request up to date Auth tokens from Ecobee using API key and authorization code """ # Generate auth request and extract returned value _log.debug("Requesting new auth tokens from Ecobee.") url = 'https://api.ecobee.com/token' params = { 'grant_type': 'ecobeePin', 'code': self.authorization_code, 'client_id': self.api_key } response = make_ecobee_request("POST", url, data=params) for token in ["access_token", "refresh_token"]: if token not in response: raise RuntimeError(f"Request tokens response did not contain {token}: {response}") self.access_token = response.get('access_token') self.refresh_token = response.get('refresh_token') self.authorization_stage = "AUTHORIZED" def refresh_tokens(self): """ Refresh Ecobee API authentication tokens via API endpoint - asks Ecobee to reset tokens then updates config with new tokens from Ecobee """ _log.info('Refreshing Ecobee auth tokens.') url = 'https://api.ecobee.com/token' params = { 'grant_type': 'refresh_token', 'refresh_token': self.refresh_token, 'client_id': self.api_key } # Generate auth request and extract returned value response = make_ecobee_request("POST", url, data=params) for token in 'access_token', 'refresh_token': if token not in response: raise RuntimeError(f"Ecobee response did not contain token {token}:, response was {response}") self.access_token = response['access_token'] self.refresh_token = response['refresh_token'] self.authorization_stage = "AUTHORIZED" def update_auth_config(self): """ Update the platform driver configuration for this device with new values from auth functions """ auth_config = {"AUTH_CODE": self.authorization_code, "ACCESS_TOKEN": self.access_token, "REFRESH_TOKEN": self.refresh_token} _log.debug("Updating Ecobee auth configuration with new tokens.") self.vip.rpc.call(CONFIGURATION_STORE, "set_config", self.auth_config_path, auth_config, trigger_callback=False, send_update=False).get(timeout=3) def get_auth_config_from_store(self): """ :return: Fetch currently stored auth configuration info from config store, returns empty dict if none is present """ configs = self.vip.rpc.call(CONFIGURATION_STORE, "manage_list_configs", PLATFORM_DRIVER).get(timeout=3) if self.auth_config_path in configs: return jsonapi.loads(self.vip.rpc.call( CONFIGURATION_STORE, "manage_get", PLATFORM_DRIVER, self.auth_config_path).get(timeout=3)) else: _log.warning("No Ecobee auth file found in config store") return {} def get_thermostat_data(self, refresh=False): """ Collects most up to date thermostat object data for the configured Ecobee thermostat ID :param refresh: whether or not to force obtaining new data from the remote Ecobee API """ params = { "json": jsonapi.dumps({ "selection": { "selectionType": "thermostats", "selectionMatch": self.ecobee_id, "includeSensors": True, "includeRuntime": True, "includeEvents": True, "includeEquipmentStatus": True, "includeSettings": True } }) } headers = populate_thermostat_headers(self.access_token) self.thermostat_data = self.get_ecobee_data("GET", THERMOSTAT_URL, 180, refresh=refresh, headers=headers, params=params) def get_ecobee_data(self, request_type, url, update_frequency, refresh=False, **kwargs): """ Checks cache for up to date Ecobee data. If none is available for the URL, makes a request to remote Ecobee API. :param refresh: force Ecobee data to be obtained from the remote API rather than cache :param request_type: HTTP request type for request sent to remote :param url: URL of remote Ecobee API endpoint :param update_frequency: period for which cached data is considered up to date :param kwargs: HTTP request arguments :return: Up to date Ecobee data for URL """ cache_data = self.get_data_cache(url, update_frequency) if refresh or not (isinstance(cache_data, dict) and len(cache_data)): try: response = self.get_data_remote(request_type, url, **kwargs) except HTTPError as he: self.store_remote_data(url, None) raise he self.store_remote_data(url, response) return response else: return cache_data def get_data_remote(self, request_type, url, **kwargs): """ Make request to Ecobee remote API for "register" data, updating authorization tokens as necessary :param request_type: HTTP request type for making request :param url: URL corresponding to "register" data :param kwargs: HTTP request arguments :return: remote API response body """ try: response = make_ecobee_request(request_type, url, **kwargs) self.authorization_stage = "AUTHORIZED" return response except HTTPError: _log.warning(f"HTTPError occurred while fetching data from Ecobee API url: {url}") # The request to the remote failed, try refreshing the tokens and trying again using the refresh token self.authorization_stage = "REFRESH_TOKENS" try: self.update_authorization() except HTTPError: _log.warning("HTTPError occurred while refreshing Ecobee API tokens") # if tokens could not be refreshed, try obtaining new tokens using the existing authorization key self.authorization_stage = "REQUEST_TOKENS" # if we fail to request new tokens, the authorization key is no longer valid, the driver will need # to be restarted self.update_authorization() response = make_ecobee_request(request_type, url, **kwargs) self.authorization_stage = "AUTHORIZED" return response def get_data_cache(self, url, update_frequency): """ Fetches data from cache dict if it is up to date :param url: URL to use to use as lookup value in cache dict :param update_frequency: duration in seconds for which data in cache is considered up to date :return: Data stored in cache if up to date, otherwise None """ url_data = self.cache.get(url) if url_data: timestamp = utils.parse_timestamp_string(url_data.get("request_timestamp")) if (datetime.datetime.now() - timestamp).total_seconds() < update_frequency: return url_data.get("request_response") else: _log.info("Cached Ecobee data out of date.") return None def store_remote_data(self, url, response): """ Store response body with a timestamp for a given URL :param url: url to use to use as lookup value in cache dict :param response: request response body to store in cache """ timestamp = utils.format_timestamp(datetime.datetime.now()) self.cache.update({ url: { "request_timestamp": timestamp, "request_response": response } }) _log.info(f"Last Ecobee update occurred at {timestamp}") self.cache.sync() def get_point(self, point_name, **kwargs): """ Return a point's most recent stored value from remote API :param point_name: The name of the point corresponding to a register to get the state of :return: register's most recent state from remote API response """ # Find the named register and get its current state from the periodic Ecobee API data register = self.get_register_by_name(point_name) try: return register.get_state(self.thermostat_data) except (ValueError, KeyError, TypeError): self.get_thermostat_data(refresh=True) return register.get_state(self.thermostat_data) def _set_point(self, point_name, value, **kwargs): """ Send request to remote API to update a point based on provided parameters :param point_name: Name of the point to update :param value: Intended update value :return: Updated state from remote API """ # Find the correct register by name, set its state, then fetch the new state based on the register's type register = self.get_register_by_name(point_name) if register.read_only: raise IOError(f"Trying to write to a point configured read only: {point_name}") try: if isinstance(register, Setting) or isinstance(register, Hold): register.set_state(value, self.access_token) elif isinstance(register, Vacation) or isinstance(register, Program): register.set_state(value, self.access_token, **kwargs) except HTTPError: self.refresh_tokens() if isinstance(register, Setting) or isinstance(register, Hold): register.set_state(value, self.access_token) elif isinstance(register, Vacation) or isinstance(register, Program): register.set_state(value, self.access_token, **kwargs) self.get_thermostat_data(refresh=True) if register.readable: return register.get_state(self.thermostat_data) def _scrape_all(self): """ Fetch point data for all configured points :return: dictionary of most recent data for all points configured for the driver """ result = {} byte_registers = self.get_registers_by_type("byte", True) + self.get_registers_by_type("byte", False) registers = [register for register in byte_registers if register.readable] refresh = True # Add data for all holds and settings to our results for register in registers: try: register_data = register.get_state(self.thermostat_data) if isinstance(register_data, dict): result.update(register_data) else: result[register.point_name] = register_data except ValueError: if refresh is True: # refresh data, but don't create a non-deterministic loop of refreshes self.get_thermostat_data(refresh=refresh) refresh = False register_data = register.get_state(self.thermostat_data) if isinstance(register_data, dict): result.update(register_data) else: result[register.point_name] = register_data return result