From 78a3216b51e20c75755e05e4db3870d6eaaef183 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adolfo=20G=C3=B3mez=20Garc=C3=ADa?= Date: Sun, 7 Aug 2022 13:24:33 +0200 Subject: [PATCH] adding initial tracking of individial sessions on user services --- actor/src/udsactor/client.py | 2 +- actor/src/udsactor/http/client.py | 2 +- actor/src/udsactor/http/clients_pool.py | 123 +++++++--- actor/src/udsactor/http/local.py | 10 +- actor/src/udsactor/rest.py | 58 +++-- actor/src/udsactor/service.py | 87 ++++--- actor/src/udsactor/tools.py | 20 ++ actor/src/udsactor/types.py | 6 +- server/src/uds/REST/methods/actor_v3.py | 53 ++++- server/src/uds/core/managers/crypto.py | 23 +- ...ion_notifier_servicetokenalias_and_more.py | 223 +++++++++++++----- server/src/uds/models/__init__.py | 2 + server/src/uds/models/user_service.py | 56 +++-- server/src/uds/models/user_service_session.py | 91 +++++++ 14 files changed, 562 insertions(+), 194 deletions(-) create mode 100644 server/src/uds/models/user_service_session.py diff --git a/actor/src/udsactor/client.py b/actor/src/udsactor/client.py index 544549bee..8adf306d1 100644 --- a/actor/src/udsactor/client.py +++ b/actor/src/udsactor/client.py @@ -196,7 +196,7 @@ class UDSActorClient(threading.Thread): # pylint: disable=too-many-instance-att self.checkIdle() self.checkDeadLine() - time.sleep(1.3) # Sleeps between loop iterations + time.sleep(1.22) # Sleeps between loop iterations # If login was recognized... if self._loginInfo.logged_in: diff --git a/actor/src/udsactor/http/client.py b/actor/src/udsactor/http/client.py index c31e94b00..d343174de 100644 --- a/actor/src/udsactor/http/client.py +++ b/actor/src/udsactor/http/client.py @@ -132,7 +132,7 @@ class HTTPServerThread(threading.Thread): self._app = app self.port = -1 - self.id = secrets.token_urlsafe(16) + self.id = secrets.token_urlsafe(24) @property def url(self) -> str: diff --git a/actor/src/udsactor/http/clients_pool.py b/actor/src/udsactor/http/clients_pool.py index 14e419065..b5787ad55 100644 --- a/actor/src/udsactor/http/clients_pool.py +++ b/actor/src/udsactor/http/clients_pool.py @@ -33,8 +33,8 @@ import json import typing import requests - -from ..log import logger +from udsactor import tools, types +from udsactor.log import logger # For avoid proxy on localhost connections NO_PROXY = { @@ -42,55 +42,108 @@ NO_PROXY = { 'https': None, } -class UDSActorClientPool: - _clientUrl: typing.List[str] + +class UDSActorClientPool(metaclass=tools.Singleton): + _clients: typing.List[types.ClientInfo] def __init__(self) -> None: - self._clientUrl = [] + self._clients = [] - def _post(self, method: str, data: typing.MutableMapping[str, str], timeout=2) -> typing.List[requests.Response]: - removables: typing.List[str] = [] - result: typing.List[typing.Any] = [] - for clientUrl in self._clientUrl: + def _post( + self, + session_id: typing.Optional[str], + method: str, + data: typing.MutableMapping[str, str], + timeout: int = 2, + ) -> typing.List[ + typing.Tuple[types.ClientInfo, typing.Optional[requests.Response]] + ]: + result: typing.List[ + typing.Tuple[types.ClientInfo, typing.Optional[requests.Response]] + ] = [] + for client in self._clients: + # Skip if session id is provided but does not match + if session_id and client.session_id != session_id: + continue + clientUrl = client.url try: - result.append(requests.post(clientUrl + '/' + method, data=json.dumps(data), verify=False, timeout=timeout, proxies=NO_PROXY)) + result.append( + ( + client, + requests.post( + clientUrl + '/' + method, + data=json.dumps(data), + verify=False, + timeout=timeout, + proxies=NO_PROXY, # type: ignore + ), + ) + ) except Exception as e: - # If cannot request to a clientUrl, remove it from list - logger.info('Could not connect with client %s: %s. Removed from registry.', e, clientUrl) - removables.append(clientUrl) - - # Remove failed connections - for clientUrl in removables: - self.unregister(clientUrl) + logger.info( + 'Could not connect with client %s: %s. ', + e, + clientUrl, + ) + result.append((client, None)) return result - def register(self, clientUrl: str) -> None: + @property + def clients(self) -> typing.List[types.ClientInfo]: + return self._clients + + def register(self, client_url: str) -> None: # Remove first if exists, to avoid duplicates - self.unregister(clientUrl) + self.unregister(client_url) # And add it again - self._clientUrl.append(clientUrl) + self._clients.append(types.ClientInfo(client_url, '')) - def unregister(self, clientUrl: str) -> None: - self._clientUrl = list((i for i in self._clientUrl if i != clientUrl)) + def set_session_id(self, client_url: str, session_id: typing.Optional[str]) -> None: + """Set the session id for a client - def executeScript(self, script: str) -> None: - self._post('script', {'script': script}, timeout=30) + Args: + clientUrl (str): _description_ + session_id (str): _description_ + """ + for client in self._clients: + if client.url == client_url: + # remove existing client from list, create a new one and insert it + self._clients.remove(client) + self._clients.append(types.ClientInfo(client_url, session_id or '')) + break - def logout(self) -> None: - self._post('logout', {}) + def unregister(self, client_url: str) -> None: + # remove client url from array if found + for i, client in enumerate(self._clients): + if client.url == client_url: + self._clients.pop(i) + return - def message(self, message: str) -> None: - self._post('message', {'message': message}) + def executeScript(self, session_id: typing.Optional[str], script: str) -> None: + self._post(session_id, 'script', {'script': script}, timeout=30) - def ping(self) -> bool: - if not self._clientUrl: - return True # No clients, ping ok - self._post('ping', {}, timeout=1) - return bool(self._clientUrl) # There was clients, but they are now lost!!! + def logout(self, session_id: typing.Optional[str]) -> None: + self._post(session_id, 'logout', {}) - def screenshot(self) -> typing.Optional[str]: # Screenshot are returned as base64 - for r in self._post('screenshot', {}, timeout=3): + def message(self, session_id: typing.Optional[str], message: str) -> None: + self._post(session_id, 'message', {'message': message}) + + def lost_clients( + self, + session_id: typing.Optional[str] = None, + ) -> typing.Iterable[types.ClientInfo]: # returns the list of "lost" clients + # Port ping to every client + for i in self._post(session_id, 'ping', {}, timeout=1): + if i[1] is None: + yield i[0] + + def screenshot( + self, session_id: typing.Optional[str] + ) -> typing.Optional[str]: # Screenshot are returned as base64 + for client, r in self._post(session_id, 'screenshot', {}, timeout=3): + if not r: + continue # Missing client, so we ignore it try: return r.json()['result'] except Exception: diff --git a/actor/src/udsactor/http/local.py b/actor/src/udsactor/http/local.py index 77a023b63..760fcf19f 100644 --- a/actor/src/udsactor/http/local.py +++ b/actor/src/udsactor/http/local.py @@ -30,19 +30,23 @@ ''' import typing -from . import handler +from udsactor.http import handler, clients_pool if typing.TYPE_CHECKING: - from ..service import CommonService + from udsactor.service import CommonService class LocalProvider(handler.Handler): def post_login(self) -> typing.Any: result = self._service.login(self._params['username'], self._params['session_type']) + # if callback_url is provided, record it in the clients pool + if 'callback_url' in self._params and result.session_id: + # If no session id is returned, then no login is acounted for + clients_pool.UDSActorClientPool().set_session_id(self._params['callback_url'], result.session_id) return result._asdict() def post_logout(self) -> typing.Any: - self._service.logout(self._params['username']) + self._service.logout(self._params['username'], self._params['session_type'], self._params['session_id']) return 'ok' def post_ping(self) -> typing.Any: diff --git a/actor/src/udsactor/rest.py b/actor/src/udsactor/rest.py index 446c974b0..e345dc419 100644 --- a/actor/src/udsactor/rest.py +++ b/actor/src/udsactor/rest.py @@ -36,8 +36,8 @@ import typing import requests -from . import types -from .version import VERSION +from udsactor import types, tools +from udsactor.version import VERSION # Default public listen port LISTEN_PORT = 43910 @@ -90,9 +90,9 @@ class UDSApi: # pylint: disable=too-few-public-methods Base for remote api accesses """ - _host: str - _validateCert: bool - _url: str + _host: str = '' + _validateCert: bool = True + _url: str = '' def __init__(self, host: str, validateCert: bool) -> None: self._host = host @@ -113,7 +113,7 @@ class UDSApi: # pylint: disable=too-few-public-methods 'User-Agent': 'UDS Actor v{}'.format(VERSION), } - def _apiURL(self, method: str) -> str: + def _api_url(self, method: str) -> str: raise NotImplementedError def _doPost( @@ -126,7 +126,7 @@ class UDSApi: # pylint: disable=too-few-public-methods headers = headers or self._headers try: result = requests.post( - self._apiURL(method), + self._api_url(method), data=json.dumps(payLoad), headers=headers, verify=self._validateCert, @@ -157,7 +157,7 @@ class UDSApi: # pylint: disable=too-few-public-methods # UDS Broker API access # class UDSServerApi(UDSApi): - def _apiURL(self, method: str) -> str: + def _api_url(self, method: str) -> str: return self._url + 'actor/v3/' + method def enumerateAuthenticators(self) -> typing.Iterable[types.AuthenticatorType]: @@ -225,7 +225,7 @@ class UDSServerApi(UDSApi): headers['X-Auth-Token'] = result.json()['token'] result = requests.post( - self._apiURL('register'), + self._api_url('register'), data=json.dumps(data), headers=headers, verify=self._validateCert, @@ -323,7 +323,7 @@ class UDSServerApi(UDSApi): actor_type: typing.Optional[str], token: str, username: str, - sessionType: str, + session_type: str, interfaces: typing.Iterable[types.InterfaceInfoType], secret: typing.Optional[str], ) -> types.LoginResultInfoType: @@ -336,7 +336,7 @@ class UDSServerApi(UDSApi): 'id': [{'mac': i.mac, 'ip': i.ip} for i in interfaces], 'token': token, 'username': username, - 'session_type': sessionType, + 'session_type': session_type, 'secret': secret or '', } result = self._doPost('login', payload) @@ -345,7 +345,7 @@ class UDSServerApi(UDSApi): hostname=result['hostname'], dead_line=result['dead_line'], max_idle=result['max_idle'], - session_id=result['session_id'], + session_id=result.get('session_id', ''), ) def logout( @@ -353,7 +353,7 @@ class UDSServerApi(UDSApi): actor_type: typing.Optional[str], token: str, username: str, - session_id: typing.Optional[str], + session_id: str, session_type: str, interfaces: typing.Iterable[types.InterfaceInfoType], secret: typing.Optional[str], @@ -366,7 +366,7 @@ class UDSServerApi(UDSApi): 'token': token, 'username': username, 'session_type': session_type, - 'session_id': session_id or '', + 'session_id': session_id, 'secret': secret or '', } return self._doPost('logout', payload) # Can be 'ok' or 'notified' @@ -385,13 +385,17 @@ class UDSServerApi(UDSApi): return self._doPost('test', payLoad) == 'ok' -class UDSClientApi(UDSApi): +class UDSClientApi(UDSApi, metaclass=tools.Singleton): + _session_id: str = '' + _callback_url: str = '' + def __init__(self) -> None: super().__init__('127.0.0.1:{}'.format(LISTEN_PORT), False) - # Override base url + + # Replace base url self._url = "https://{}/ui/".format(self._host) - def _apiURL(self, method: str) -> str: + def _api_url(self, method: str) -> str: return self._url + method def post( @@ -401,13 +405,15 @@ class UDSClientApi(UDSApi): ) -> typing.Any: return self._doPost(method=method, payLoad=payLoad, disableProxy=True) - def register(self, callbackUrl: str) -> None: - payLoad = {'callback_url': callbackUrl} + def register(self, callback_url: str) -> None: + self._callback_url = callback_url + payLoad = {'callback_url': callback_url} self.post('register', payLoad) - def unregister(self, callbackUrl: str) -> None: - payLoad = {'callback_url': callbackUrl} + def unregister(self, callback_url: str) -> None: + payLoad = {'callback_url': callback_url} self.post('unregister', payLoad) + self._callback_url = '' def login( self, username: str, sessionType: typing.Optional[str] = None @@ -415,20 +421,26 @@ class UDSClientApi(UDSApi): payLoad = { 'username': username, 'session_type': sessionType or UNKNOWN, + 'callback_url': self._callback_url, # So we identify ourselves } result = self.post('login', payLoad) - return types.LoginResultInfoType( + res = types.LoginResultInfoType( ip=result['ip'], hostname=result['hostname'], dead_line=result['dead_line'], max_idle=result['max_idle'], session_id=result['session_id'], ) + # Store session id for future use + self._session_id = res.session_id or '' + return res def logout(self, username: str, sessionType: typing.Optional[str]) -> None: payLoad = { 'username': username, - 'session_type': sessionType or UNKNOWN + 'session_type': sessionType or UNKNOWN, + 'callback_url': self._callback_url, # So we identify ourselves + 'session_id': self._session_id, # We now know the session id, provided on login } self.post('logout', payLoad) diff --git a/actor/src/udsactor/service.py b/actor/src/udsactor/service.py index affa6f5fc..f1f2a80a9 100644 --- a/actor/src/udsactor/service.py +++ b/actor/src/udsactor/service.py @@ -36,13 +36,13 @@ import secrets import subprocess import typing -from . import platform -from . import rest -from . import types -from . import tools +from udsactor import platform +from udsactor import rest +from udsactor import types +from udsactor import tools -from .log import logger, DEBUG, INFO, ERROR, FATAL -from .http import clients_pool, server, cert +from udsactor.log import logger, DEBUG, INFO, ERROR, FATAL +from udsactor.http import clients_pool, server, cert # def setup() -> None: # cfg = platform.store.readConfig() @@ -60,15 +60,12 @@ from .http import clients_pool, server, cert class CommonService: # pylint: disable=too-many-instance-attributes _isAlive: bool = True _rebootRequested: bool = False - _loggedIn: bool = False _initialized: bool = False - _cfg: types.ActorConfigurationType _api: rest.UDSServerApi _interfaces: typing.List[types.InterfaceInfoType] _secret: str _certificate: types.CertificateInfoType - _clientsPool: clients_pool.UDSActorClientPool _http: typing.Optional[server.HTTPServerThread] @staticmethod @@ -324,7 +321,11 @@ class CommonService: # pylint: disable=too-many-instance-attributes # Only removes master token for managed machines (will need it on next client execution) # For unmanaged, if alias is present, replace master token with it - master_token = None if self.isManaged() else (initResult.alias_token or self._cfg.master_token) + master_token = ( + None + if self.isManaged() + else (initResult.alias_token or self._cfg.master_token) + ) self._cfg = self._cfg._replace( master_token=master_token, own_token=initResult.own_token, @@ -372,19 +373,23 @@ class CommonService: # pylint: disable=too-many-instance-attributes self._http.stop() # If logged in, notify UDS of logout (daemon stoped = no control = logout) - if self._loggedIn and self._cfg.own_token: - self._loggedIn = False - try: - self._api.logout( - self._cfg.actorType, - self._cfg.own_token, - '', - '', - self._interfaces, - self._secret, - ) - except Exception as e: - logger.error('Error notifying final logout to UDS: %s', e) + # For every connected client... + if self._cfg.own_token: + for client in clients_pool.UDSActorClientPool().clients: + if client.session_id: + try: + self._api.logout( + self._cfg.actorType, + self._cfg.own_token, + '', + client.session_id + or 'stop', # If no session id, pass "stop" + '', + self._interfaces, + self._secret, + ) + except Exception as e: + logger.error('Error notifying final logout to UDS: %s', e) self.notifyStop() @@ -466,8 +471,9 @@ class CommonService: # pylint: disable=too-many-instance-attributes self.checkIpsChanged() # Now check if every registered client is already there (if logged in OFC) - if self._loggedIn and not self._clientsPool.ping(): - self.logout('client_unavailable') + for lost_client in clients_pool.UDSActorClientPool().lost_clients(): + logger.info('Lost client: {}'.format(lost_client)) + self.logout('client_unavailable', '', lost_client.session_id or '') except Exception as e: logger.error('Exception on main service loop: %s', e) @@ -488,10 +494,8 @@ class CommonService: # pylint: disable=too-many-instance-attributes self, username: str, sessionType: typing.Optional[str] = None ) -> types.LoginResultInfoType: result = types.LoginResultInfoType( - ip='', hostname='', dead_line=None, max_idle=None + ip='', hostname='', dead_line=None, max_idle=None, session_id=None ) - self._loggedIn = True - master_token = None secret = None # If unmanaged, do initialization now, because we don't know before this @@ -517,16 +521,22 @@ class CommonService: # pylint: disable=too-many-instance-attributes secret, ) - script = platform.store.invokeScriptOnLogin() - if script: - script += f'{username} {sessionType or "unknown"} {self._cfg.actorType}' - self.execute(script, 'Logon') + if ( + result.session_id + ): # If logged in, process it. client_pool will take account of login response to client and session + script = platform.store.invokeScriptOnLogin() + if script: + script += f'{username} {sessionType or "unknown"} {self._cfg.actorType}' + self.execute(script, 'Logon') return result - def logout(self, username: str, sessionType: typing.Optional[str] = None) -> None: - self._loggedIn = False - + def logout( + self, + username: str, + session_type: typing.Optional[str], + session_id: typing.Optional[str], + ) -> None: master_token = self._cfg.master_token # Own token will not be set if UDS did not assigned the initialized VM to an user @@ -539,13 +549,16 @@ class CommonService: # pylint: disable=too-many-instance-attributes self._cfg.actorType, token, username, - sessionType or '', + session_id or '', + session_type or '', self._interfaces, self._secret, ) != 'ok' ): - logger.info('Logout from %s ignored as required by uds broker', username) + logger.info( + 'Logout from %s ignored as required by uds broker', username + ) return self.onLogout(username) diff --git a/actor/src/udsactor/tools.py b/actor/src/udsactor/tools.py index 18502d455..397b09430 100644 --- a/actor/src/udsactor/tools.py +++ b/actor/src/udsactor/tools.py @@ -52,6 +52,26 @@ class ScriptExecutorThread(threading.Thread): logger.error('Error executing script: {}'.format(e)) logger.exception() +class Singleton(type): + ''' + Metaclass for singleton pattern + Usage: + + class MyClass(metaclass=Singleton): + ... + ''' + _instance: typing.Optional[typing.Any] + + # We use __init__ so we customise the created class from this metaclass + def __init__(self, *args, **kwargs) -> None: + self._instance = None + super().__init__(*args, **kwargs) + + def __call__(self, *args, **kwargs) -> typing.Any: + if self._instance is None: + self._instance = super().__call__(*args, **kwargs) + return self._instance + # Convert "X.X.X.X/X" to ipaddress.IPv4Network def strToNoIPV4Network(net: typing.Optional[str]) -> typing.Optional[ipaddress.IPv4Network]: diff --git a/actor/src/udsactor/types.py b/actor/src/udsactor/types.py index 47e3f80f7..ca41c5f9c 100644 --- a/actor/src/udsactor/types.py +++ b/actor/src/udsactor/types.py @@ -62,7 +62,11 @@ class LoginResultInfoType(typing.NamedTuple): @property def logged_in(self) -> bool: - return self.hostname != '' or self.ip != '' + return bool(self.session_id) + +class ClientInfo(typing.NamedTuple): + url: str + session_id: str class CertificateInfoType(typing.NamedTuple): private_key: str diff --git a/server/src/uds/REST/methods/actor_v3.py b/server/src/uds/REST/methods/actor_v3.py index 1a680f642..37d3881a3 100644 --- a/server/src/uds/REST/methods/actor_v3.py +++ b/server/src/uds/REST/methods/actor_v3.py @@ -253,10 +253,14 @@ class Initialize(ActorV3Action): # Retrieve real service from token alias service = ServiceTokenAlias.objects.get(alias=token).service # If not found, try to locate on service table - if service is None: # Not on alias token, try to locate on Service table + if ( + service is None + ): # Not on alias token, try to locate on Service table service = Service.objects.get(token=token) # And create a new alias for it, and save - alias_token = cryptoManager().randomString() # fix alias with new token + alias_token = ( + cryptoManager().randomString() + ) # fix alias with new token service.aliases.create(alias=alias_token) # Locate an userService that belongs to this service and which @@ -288,7 +292,13 @@ class Initialize(ActorV3Action): except Exception as e: logger.info('Unmanaged host request: %s, %s', self._params, e) return ActorV3Action.actorResult( - {'own_token': None, 'max_idle': None, 'unique_id': None, 'os': None, 'alias': None} + { + 'own_token': None, + 'max_idle': None, + 'unique_id': None, + 'os': None, + 'alias': None, + } ) # Managed by UDS, get initialization data from osmanager and return it @@ -508,6 +518,7 @@ class Login(LoginLogout): isManaged = self._params.get('type') != UNMANAGED ip = hostname = '' deadLine = maxIdle = None + session_id = '' logger.debug('Login Args: %s, Params: %s', self._args, self._params) @@ -522,6 +533,9 @@ class Login(LoginLogout): logger.debug('Max idle: %s', maxIdle) ip, hostname = userService.getConnectionSource() + session_id = ( + userService.initSession() + ) # creates a session for every login requested if osManager: # For os managed services, let's check if we honor deadline if osManager.ignoreDeadLine(): @@ -537,7 +551,13 @@ class Login(LoginLogout): self.notifyService(isLogin=True) return ActorV3Action.actorResult( - {'ip': ip, 'hostname': hostname, 'dead_line': deadLine, 'max_idle': maxIdle} + { + 'ip': ip, + 'hostname': hostname, + 'dead_line': deadLine, + 'max_idle': maxIdle, + 'session_id': session_id, + } ) @@ -549,13 +569,20 @@ class Logout(LoginLogout): name = 'logout' @staticmethod - def process_logout(userService: UserService, username: str) -> None: + def process_logout( + userService: UserService, username: str, session_id: str + ) -> None: """ This method is static so can be invoked from elsewhere """ osManager: typing.Optional[ osmanagers.OSManager ] = userService.getOsManagerInstance() + + # Close session + # For compat, we have taken '' as "all sessions" + userService.closeSession(session_id) + if ( userService.in_use ): # If already logged out, do not add a second logout (windows does this i.e.) @@ -572,13 +599,21 @@ class Logout(LoginLogout): logger.debug('Args: %s, Params: %s', self._args, self._params) try: - userService: UserService = self.getUserService() - Logout.process_logout(userService, self._params.get('username') or '') + userService: UserService = ( + self.getUserService() + ) # if not exists, will raise an error + Logout.process_logout( + userService, + self._params.get('username') or '', + self._params.get('session_id') or '', + ) except Exception: # If unamanaged host, lest do a bit more work looking for a service with the provided parameters... if isManaged: raise self.notifyService(isLogin=False) # Logout notification - return ActorV3Action.actorResult('notified') # Result is that we have not processed the logout in fact, but notified the service + return ActorV3Action.actorResult( + 'notified' + ) # Result is that we have not processed the logout in fact, but notified the service return ActorV3Action.actorResult('ok') @@ -707,7 +742,7 @@ class Unmanaged(ActorV3Action): if validId: # If id is assigned to an user service, notify "logout" to it if userService: - Logout.process_logout(userService, 'init') + Logout.process_logout(userService, 'init', '') else: # If it is not assgined to an user service, notify service service.notifyInitialization(validId) diff --git a/server/src/uds/core/managers/crypto.py b/server/src/uds/core/managers/crypto.py index 22feab948..ce549384a 100644 --- a/server/src/uds/core/managers/crypto.py +++ b/server/src/uds/core/managers/crypto.py @@ -33,6 +33,7 @@ import hashlib import array import uuid import codecs +import datetime import struct import re import random @@ -162,7 +163,9 @@ class CryptoManager(metaclass=singleton.Singleton): toDecode = decryptor.update(text) + decryptor.finalize() return toDecode[4 : 4 + struct.unpack('>i', toDecode[:4])[0]] - def xor(self, value: typing.Union[str, bytes], key: typing.Union[str, bytes]) -> bytes: + def xor( + self, value: typing.Union[str, bytes], key: typing.Union[str, bytes] + ) -> bytes: if not key: return b'' # Protect against division by cero @@ -172,9 +175,13 @@ class CryptoManager(metaclass=singleton.Singleton): key = key.encode('utf-8') mult = len(value) // len(key) + 1 value_array = array.array('B', value) - key_array = array.array('B', key * mult) # Ensure key array is at least as long as value_array + key_array = array.array( + 'B', key * mult + ) # Ensure key array is at least as long as value_array # We must return binary in xor, because result is in fact binary - return array.array('B', (value_array[i] ^ key_array[i] for i in range(len(value_array)))).tobytes() + return array.array( + 'B', (value_array[i] ^ key_array[i] for i in range(len(value_array))) + ).tobytes() def symCrypt( self, text: typing.Union[str, bytes], key: typing.Union[str, bytes] @@ -248,7 +255,7 @@ class CryptoManager(metaclass=singleton.Singleton): return not hash if hash[:8] == '{SHA256}': - return str(hashlib.sha3_256(value).hexdigest()) == hash[8:] + return hashlib.sha3_256(value).hexdigest() == hash[8:] else: # Old sha1 return hash == str(hashlib.sha1(value).hexdigest()) @@ -272,3 +279,11 @@ class CryptoManager(metaclass=singleton.Singleton): def randomString(self, length: int = 40, digits: bool = True) -> str: base = string.ascii_letters + (string.digits if digits else '') return ''.join(random.SystemRandom().choices(base, k=length)) + + def unique(self) -> str: + return hashlib.sha3_256( + ( + self.randomString(24, True) + + datetime.datetime.now().strftime('%H%M%S%f') + ).encode() + ).hexdigest() diff --git a/server/src/uds/migrations/0044_notification_notifier_servicetokenalias_and_more.py b/server/src/uds/migrations/0044_notification_notifier_servicetokenalias_and_more.py index 8e80cc744..5349b72d2 100644 --- a/server/src/uds/migrations/0044_notification_notifier_servicetokenalias_and_more.py +++ b/server/src/uds/migrations/0044_notification_notifier_servicetokenalias_and_more.py @@ -1,129 +1,222 @@ -# Generated by Django 4.0.3 on 2022-07-05 12:43 +# Generated by Django 4.1 on 2022-08-07 13:12 from django.db import migrations, models import django.db.models.deletion import uds.models.notifications +import uds.models.user_service_session +import uds.models.util class Migration(migrations.Migration): dependencies = [ - ('uds', '0043_auto_20220704_2120'), + ("uds", "0043_auto_20220704_2120"), ] operations = [ migrations.CreateModel( - name='Notification', + name="Notification", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('stamp', models.DateTimeField(auto_now_add=True)), - ('group', models.CharField(db_index=True, max_length=128)), - ('identificator', models.CharField(db_index=True, max_length=128)), - ('level', models.PositiveSmallIntegerField()), - ('message', models.TextField()), - ('processed', models.BooleanField(default=False)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("stamp", models.DateTimeField(auto_now_add=True)), + ("group", models.CharField(db_index=True, max_length=128)), + ("identificator", models.CharField(db_index=True, max_length=128)), + ("level", models.PositiveSmallIntegerField()), + ("message", models.TextField()), + ("processed", models.BooleanField(default=False)), ], options={ - 'db_table': 'uds_notification', + "db_table": "uds_notification", }, ), migrations.CreateModel( - name='Notifier', + name="Notifier", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('uuid', models.CharField(default=None, max_length=50, null=True, unique=True)), - ('data_type', models.CharField(max_length=128)), - ('data', models.TextField(default='')), - ('name', models.CharField(default='', max_length=128)), - ('comments', models.CharField(default='', max_length=256)), - ('enabled', models.BooleanField(default=True)), - ('level', models.PositiveSmallIntegerField(default=uds.models.notifications.NotificationLevel['ERROR'])), - ('tags', models.ManyToManyField(to='uds.tag')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "uuid", + models.CharField( + default=None, max_length=50, null=True, unique=True + ), + ), + ("data_type", models.CharField(max_length=128)), + ("data", models.TextField(default="")), + ("name", models.CharField(default="", max_length=128)), + ("comments", models.CharField(default="", max_length=256)), + ("enabled", models.BooleanField(default=True)), + ( + "level", + models.PositiveSmallIntegerField( + default=uds.models.notifications.NotificationLevel["ERROR"] + ), + ), + ("tags", models.ManyToManyField(to="uds.tag")), ], options={ - 'db_table': 'uds_notify_prov', + "db_table": "uds_notify_prov", }, ), migrations.CreateModel( - name='ServiceTokenAlias', + name="ServiceTokenAlias", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('alias', models.CharField(max_length=64, unique=True)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("alias", models.CharField(max_length=64, unique=True)), ], ), - migrations.RemoveField( - model_name='authenticator', - name='visible', + migrations.CreateModel( + name="UserServiceSession", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "session_id", + models.CharField( + blank=True, + db_index=True, + default=uds.models.user_service_session._session_id_generator, + max_length=128, + ), + ), + ("start", models.DateTimeField(default=uds.models.util.getSqlDatetime)), + ("end", models.DateTimeField(blank=True, null=True)), + ], + options={ + "db_table": "uds__user_service_session", + }, ), migrations.RemoveField( - model_name='service', - name='proxy', + model_name="authenticator", + name="visible", ), migrations.RemoveField( - model_name='transport', - name='nets_positive', + model_name="service", + name="proxy", + ), + migrations.RemoveField( + model_name="transport", + name="nets_positive", + ), + migrations.RemoveField( + model_name="userservice", + name="cluster_node", ), migrations.AddField( - model_name='authenticator', - name='net_filtering', - field=models.CharField(db_index=True, default='n', max_length=1), + model_name="authenticator", + name="net_filtering", + field=models.CharField(db_index=True, default="n", max_length=1), ), migrations.AddField( - model_name='authenticator', - name='state', - field=models.CharField(db_index=True, default='v', max_length=1), + model_name="authenticator", + name="state", + field=models.CharField(db_index=True, default="v", max_length=1), ), migrations.AddField( - model_name='config', - name='help', - field=models.CharField(default='', max_length=256), + model_name="config", + name="help", + field=models.CharField(default="", max_length=256), ), migrations.AddField( - model_name='metapool', - name='ha_policy', + model_name="metapool", + name="ha_policy", field=models.SmallIntegerField(default=0), ), migrations.AddField( - model_name='network', - name='authenticators', - field=models.ManyToManyField(db_table='uds_net_auths', related_name='networks', to='uds.authenticator'), + model_name="network", + name="authenticators", + field=models.ManyToManyField( + db_table="uds_net_auths", + related_name="networks", + to="uds.authenticator", + ), ), migrations.AddField( - model_name='service', - name='max_services_count_type', + model_name="service", + name="max_services_count_type", field=models.PositiveIntegerField(default=0), ), migrations.AddField( - model_name='transport', - name='net_filtering', - field=models.CharField(db_index=True, default='n', max_length=1), + model_name="transport", + name="net_filtering", + field=models.CharField(db_index=True, default="n", max_length=1), ), migrations.AlterField( - model_name='service', - name='token', - field=models.CharField(blank=True, default=None, max_length=64, null=True, unique=True), + model_name="service", + name="token", + field=models.CharField( + blank=True, default=None, max_length=64, null=True, unique=True + ), ), migrations.AlterField( - model_name='tunneltoken', - name='ip', + model_name="tunneltoken", + name="ip", field=models.CharField(max_length=128), ), migrations.AlterField( - model_name='tunneltoken', - name='ip_from', + model_name="tunneltoken", + name="ip_from", field=models.CharField(max_length=128), ), migrations.AlterField( - model_name='userservice', - name='src_ip', - field=models.CharField(default='', max_length=128), + model_name="userservice", + name="src_ip", + field=models.CharField(default="", max_length=128), ), migrations.DeleteModel( - name='Proxy', + name="Proxy", ), migrations.AddField( - model_name='servicetokenalias', - name='service', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='aliases', to='uds.service'), + model_name="userservicesession", + name="user_service", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="sessions", + to="uds.userservice", + ), + ), + migrations.AddField( + model_name="servicetokenalias", + name="service", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="aliases", + to="uds.service", + ), + ), + migrations.AddConstraint( + model_name="userservicesession", + constraint=models.UniqueConstraint( + fields=("session_id", "user_service"), name="u_session_userservice" + ), ), ] diff --git a/server/src/uds/models/__init__.py b/server/src/uds/models/__init__.py index e855894db..c8a985cd6 100644 --- a/server/src/uds/models/__init__.py +++ b/server/src/uds/models/__init__.py @@ -63,8 +63,10 @@ from .service_pool import ServicePool # New name from .meta_pool import MetaPool, MetaPoolMember from .service_pool_group import ServicePoolGroup from .service_pool_publication import ServicePoolPublication, ServicePoolPublicationChangelog + from .user_service import UserService from .user_service_property import UserServiceProperty +from .user_service_session import UserServiceSession # Especific log information for an user service from .log import Log diff --git a/server/src/uds/models/user_service.py b/server/src/uds/models/user_service.py index 7483d9798..a0540142b 100644 --- a/server/src/uds/models/user_service.py +++ b/server/src/uds/models/user_service.py @@ -36,17 +36,17 @@ import typing from django.db import models from django.db.models import signals +from uds.core.managers import cryptoManager from uds.core.environment import Environment -from uds.core.util import log -from uds.core.util import unique +from uds.core.util import log, unique from uds.core.util.state import State -from .uuid_model import UUIDModel -from .service_pool import ServicePool -from .service_pool_publication import ServicePoolPublication -from .user import User -from .util import NEVER -from .util import getSqlDatetime +from uds.models.uuid_model import UUIDModel +from uds.models.service_pool import ServicePool +from uds.models.service_pool_publication import ServicePoolPublication +from uds.models.user import User +from uds.models.util import NEVER +from uds.models.util import getSqlDatetime # Not imported at runtime, just for type checking if typing.TYPE_CHECKING: @@ -57,12 +57,12 @@ if typing.TYPE_CHECKING: ServicePool, ServicePoolPublication, UserServiceProperty, + UserServiceSession, AccountUsage, ) logger = logging.getLogger(__name__) - class UserService(UUIDModel): # pylint: disable=too-many-public-methods """ This is the base model for assigned user service and cached user services. @@ -115,13 +115,10 @@ class UserService(UUIDModel): # pylint: disable=too-many-public-methods src_hostname = models.CharField(max_length=64, default='') src_ip = models.CharField(max_length=128, default='') - cluster_node = models.CharField( - max_length=128, default=None, blank=True, null=True, db_index=True - ) - # "fake" declarations for type checking - objects: 'models.manager.Manager[UserService]' + objects: 'models.manager.Manager["UserService"]' properties: 'models.manager.RelatedManager[UserServiceProperty]' + sessions: 'models.manager.RelatedManager[UserServiceSession]' accounting: 'AccountUsage' class Meta(UUIDModel.Meta): @@ -453,6 +450,30 @@ class UserService(UUIDModel): # pylint: disable=too-many-public-methods self.deployed_service.account.stopUsageAccounting(self) + def initSession(self) -> str: + """ + Starts a new session for this user deployed service. + Returns the session id + """ + session = self.sessions.create() + return session.session_id + + def closeSession(self, sessionId: str) -> None: + if sessionId == '': + # Close all sessions + for session in self.sessions.all(): + session.close() + else: + # Close a specific session + try: + session = self.sessions.get(session_id=sessionId) + session.close() + except Exception: # Does not exists, log it and ignore it + logger.warning( + 'Session %s does not exists for user deployed service %s' + % (sessionId, self.id) + ) + def isUsable(self) -> bool: """ Returns if this service is usable @@ -599,8 +620,13 @@ class UserService(UUIDModel): # pylint: disable=too-many-public-methods :note: If destroy raises an exception, the deletion is not taken. """ - toDelete = kwargs['instance'] + toDelete: 'UserService' = kwargs['instance'] + # Clear environment toDelete.getEnvironment().clearRelatedData() + # Ensure all sessions are closed (invoke with '' to close all sessions) + # In fact, sessions are going to be deleted also, but we give then + # the oportunity to execute some code before deleting them + toDelete.closeSession('') # Clear related logs to this user service log.clearLogs(toDelete) diff --git a/server/src/uds/models/user_service_session.py b/server/src/uds/models/user_service_session.py new file mode 100644 index 000000000..2bfc09e1f --- /dev/null +++ b/server/src/uds/models/user_service_session.py @@ -0,0 +1,91 @@ +# -*- coding: utf-8 -*- + +# +# Copyright (c) 2012-2022 Virtual Cable S.L.U. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without modification, +# are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of Virtual Cable S.L. nor the names of its contributors +# may be used to endorse or promote products derived from this software +# without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +""" +.. moduleauthor:: Adolfo Gómez, dkmaster at dkmon dot com +""" +import logging + +from django.db import models + +from uds.core.managers import cryptoManager +from .user_service import UserService +from .util import getSqlDatetime + + +logger = logging.getLogger(__name__) + +def _session_id_generator() -> str: + """ + Generates a new session id + """ + return cryptoManager().unique() + + +class UserServiceSession(models.Model): # pylint: disable=too-many-public-methods + """ + Properties for User Service. + The value field is a Text field, so we can put whatever we want in it + """ + + session_id = models.CharField(max_length=128, db_index=True, default=_session_id_generator, blank=True) + start = models.DateTimeField(default=getSqlDatetime) + end = models.DateTimeField(null=True, blank=True) + + user_service = models.ForeignKey( + UserService, on_delete=models.CASCADE, related_name='sessions' + ) + + # "fake" declarations for type checking + objects: 'models.manager.Manager["UserServiceSession"]' + + class Meta: + """ + Meta class to declare default order and unique multiple field index + """ + + db_table = 'uds__user_service_session' + app_label = 'uds' + constraints = [ + models.UniqueConstraint( + fields=['session_id', 'user_service'], name='u_session_userservice' + ) + ] + + def __str__(self) -> str: + return 'Session {}. ({} to {}'.format( + self.session_id, self.start, self.end + ) + + def close(self) -> None: + """ + Ends the session + """ + self.end = getSqlDatetime() + self.save(update_fields=['end'])