diff --git a/api/blueprints/software/auth.py b/api/blueprints/software/auth.py index 8bafcf1..c1664f6 100644 --- a/api/blueprints/software/auth.py +++ b/api/blueprints/software/auth.py @@ -215,7 +215,7 @@ async def authorize_software_client( # Note down the results: success = response.success - message = response.message + message = f"Action Chain '{response.actionChain}': {response.message}" # ┏┓ ┳┳┓•┓ ┏┳┓•┓ ┓┏ ┓┏┓┏┓┏┓ # ┣ ┏┓┏┓ ┃┃┃┓┃┏┏┓┏┓ ┃ ┓┃┏ ┣┫┏┓╋┏┏┓┏┓╋ ┃┃┫┃┫┃┫ diff --git a/controllers_v2/software/mikrotik/all_mikrotik.py b/controllers_v2/software/mikrotik/all_mikrotik.py index 67a9eb2..33d72dd 100644 --- a/controllers_v2/software/mikrotik/all_mikrotik.py +++ b/controllers_v2/software/mikrotik/all_mikrotik.py @@ -36,6 +36,7 @@ sys.path.append(".") sys.path.append("..") # My async utils: +from utils_v2.mikrotik.controllers.async_mikrotik import AsyncMikroTik from utils_v2.database.async_mysql_v2 import AsyncMySQL from utils_v2.database.async_mongo_v2 import AsyncMongo from utils_v2.cache.async_redis_cache_v2 import AsyncRedisCache @@ -181,11 +182,13 @@ class AllMikroTikController(MikroTikController): async def roll_back( self, + mikrotik_client: AsyncMikroTik, mikrotik_auth: MikroTikPPPoE1000Auth | MikroTikHotspot1000Auth ) -> MikroTikRollBackAttemptResponse: """ The rolling-back to the original state (as best as possible) in case the configurations fails midway after completing some no. of steps. + :param mikrotik_client: The client to use. :param mikrotik_auth: The set of credentials as received from the UI/API. :return: A structured response to indicate what happened during the configuration attempt. """ @@ -194,10 +197,12 @@ class AllMikroTikController(MikroTikController): async def configure( self, + mikrotik_client: AsyncMikroTik, mikrotik_auth: MikroTikPPPoE1000Auth | MikroTikHotspot1000Auth ) -> MikroTikConfigAttemptResponse: """ Run the configuration steps for the system. + :param mikrotik_client: The client to use. :param mikrotik_auth: The set of credentials as received from the UI/API. :return: A structured response to indicate what happened during the configuration attempt. """ diff --git a/controllers_v2/software/mikrotik/base.py b/controllers_v2/software/mikrotik/base.py index ce58416..5dc3d20 100644 --- a/controllers_v2/software/mikrotik/base.py +++ b/controllers_v2/software/mikrotik/base.py @@ -37,6 +37,7 @@ sys.path.append("..") # My async utils: from utils_v2.string import json from utils_v2.date_time import date_time +from utils_v2.mikrotik.controllers.async_mikrotik import AsyncMikroTik from utils_v2.database.async_mysql_v2 import AsyncMySQL from utils_v2.database.async_mongo_v2 import AsyncMongo from utils_v2.cache.async_redis_cache_v2 import AsyncRedisCache @@ -179,416 +180,6 @@ class MikroTikController(CoreSoftwareController, ABC): debug_only_errors = debug_only_errors ) - # ┓┏ ┓ - # ┣┫┏┓┃┏┓┏┓┏┓┏ - # ┛┗┗ ┗┣┛┗ ┛ ┛ - # ┛ - - @staticmethod - def get_mikrotik_url( - mikrotik_ip: str, - path: str, - port_no: int | str = None, - use_https: bool = True - ) -> str: - - """ - Simply creates the base URL for hitting the MikroTik server. - :param mikrotik_ip: The IP address of the MikroTik device. - :param path: The path of the REST API to hit. - :param port_no: The port no. to hit the MikroTik device on. - :param use_https: Whether to use HTTPS, or HTTP. - :return: The Base URL string. - """ - - base_url = r"https://" if use_https else r"http://" - base_url += mikrotik_ip - if port_no is not None: base_url += f":{port_no}" - base_url += "/rest" - if not path.startswith("/"): path = "/" + path - return base_url + path - - @staticmethod - def split_ipv4_range_among_targets( - start_ip: ipaddress.IPv4Address | str, - end_ip: ipaddress.IPv4Address | str, - targets: List - ) -> List[dict]: - - """ - Divides the IPv4 range over the list of VLAN ids. - :param start_ip: The first IP in the full pool (range). - :param end_ip: The last IP in the full pool (range). - :param targets: The list of targets (need not be sequential or ordered) like VLAN ids. - :return: A list of dicts that describes the subnet for each target. - """ - - # Parse the inputs: - start_ip_obj = start_ip if isinstance(start_ip, ipaddress.IPv4Address) else ipaddress.IPv4Address(start_ip) - end_ip_obj = end_ip if isinstance(end_ip, ipaddress.IPv4Address) else ipaddress.IPv4Address(end_ip) - target_count = len(targets) - - # Calculate the total number of IPs in the range: - total_ips = int(end_ip_obj) - int(start_ip_obj) + 1 - - # Calculate the no. of IPs each VLAN gets, - # and add three because we need IPs for network, gateway and broadcast: - ips_per_target = math.ceil(total_ips / target_count) + 3 - - # Generate subnets for each VLAN: - subnets = [] - current_ip = start_ip_obj - for target in targets: - - # Calculate the network address for the current VLAN: - subnet_network = ipaddress.IPv4Network(f"{current_ip}/{32 - ips_per_target.bit_length()}", strict = False) - - # Check for overlap with the parent network: - subnet_start_ip_obj = subnet_network.network_address - subnet_end_ip_obj = subnet_network.broadcast_address - if subnet_start_ip_obj <= end_ip_obj: - - # Add the calculated subnet to the list: - subnets.append({ - "target": target, - "network": str(subnet_network), - "size": int(2 ** (32 - subnet_network.prefixlen)), - "startIp": str(subnet_start_ip_obj), - "endIp": str(subnet_end_ip_obj) - }) - - # Update the current IP for the next subnet - current_ip = ipaddress.IPv4Address(int(subnet_network.broadcast_address) + 1) - - # Done here: - return subnets - - @staticmethod - def create_comment_json( - created_by: str, - created_ts: datetime.datetime = None, - roll_back_config: dict = None - ) -> str: - - """ - To create a JSON string that can be put as a comment in any step to later identify the work that was done and - have some hint about how to roll it back if needed. - :param created_by: A hint to put to identify which process/tool created this comment/config/process. - :param created_ts: The datetime (preferably UTC) when this comment was created. - :param roll_back_config: The original config that can be used when rolling back. - :return: The comment string to be used. - """ - - # Create the JSON: - comment_json = { - "createdBy": created_by, - "createdTs": (created_ts or date_time.get_current_utc_date_time(as_string = False)).timestamp(), - "rollbackConfig": roll_back_config - } - - # Return a string: - return json.to_string(comment_json, no_space = True) - - @staticmethod - def parse_comment_json(comment: str) -> dict | list | None: - - """ - Tries to parse a comment as if it were a JSON string. - :param comment: The comment string. - :return: The dict/list parsed form the JSON, or a null value of the input was not a valid JSON string. - """ - - # Start with a null value: - parsed = None - - # Try to parse the comment as if it were a JSON string: - try: parsed = json.from_string(comment) - except: parsed = None - - # Done here: - return parsed - - # ┏┓ - # ┗┓┓┏┏╋┏┓┏┳┓ - # ┗┛┗┫┛┗┗ ┛┗┗ - # ┛ - - async def get_system_resource( - self, - mikrotik_ip: str, - username: str, - password: str, - port_no: int | str = None, - use_https: bool = True - ) -> ApiResponse: - - """ - To get a summary of the hardware resources available in the MikroTik device. This also becomes a great way to - quickly check if any given device is valid, and up and running. - :param mikrotik_ip: The IP address of the MikroTik device. - :param username: The username to get access to the MikroTik device. - :param password: The password to get access to the MikroTik device. - :param port_no: The port no. to hit the MikroTik device on. - :param use_https: Whether to use HTTPS, or HTTP. - :return: A structured API response. - """ - - # Make the API call: - api_response = await self._rest.get( - url = self.get_mikrotik_url( - mikrotik_ip = mikrotik_ip, - path = r"/system/resource", - port_no = port_no, - use_https = use_https - ), - auth = httpx.BasicAuth( - username = username, - password = password - ) - ) - - # Done here: - return api_response - - # ┳ ┏ - # ┃┏┓╋┏┓┏┓╋┏┓┏┏┓┏ - # ┻┛┗┗┗ ┛ ┛┗┻┗┗ ┛ - - async def list_interfaces( - self, - mikrotik_ip: str, - username: str, - password: str, - port_no: int | str = None, - use_https: bool = True - ) -> ApiResponse: - - """ - To enlist all the physical connectivity interfaces available on the MikroTik device. - :param mikrotik_ip: The IP address of the MikroTik device. - :param username: The username to get access to the MikroTik device. - :param password: The password to get access to the MikroTik device. - :param port_no: The port no. to hit the MikroTik device on. - :param use_https: Whether to use HTTPS, or HTTP. - :return: A structured API response. - """ - - # Make the API call: - api_response = await self._rest.get( - url = self.get_mikrotik_url( - mikrotik_ip = mikrotik_ip, - path = r"/interface", - port_no = port_no, - use_https = use_https - ), - auth = httpx.BasicAuth( - username = username, - password = password - ) - ) - - # Done here: - return api_response - - async def update_interface( - self, - mikrotik_ip: str, - username: str, - password: str, - dot_id: str, - json_payload: dict, - port_no: int | str = None, - use_https: bool = True - ) -> ApiResponse: - - """ - To update an interface's configuration. - :param mikrotik_ip: The IP address of the MikroTik device. - :param username: The username to get access to the MikroTik device. - :param password: The password to get access to the MikroTik device. - :param dot_id: The value of the '.id' field in the list. - :param port_no: The port no. to hit the MikroTik device on. - :param use_https: Whether to use HTTPS, or HTTP. - :param json_payload: The JSON to send in the body pf the request. - :return: A structured API response. - """ - - # Make the API call: - api_response = await self._rest.patch( - url = self.get_mikrotik_url( - mikrotik_ip = mikrotik_ip, - path = f"/interface/{dot_id}", - port_no = port_no, - use_https = use_https - ), - auth = httpx.BasicAuth( - username = username, - password = password - ), - json = json_payload - ) - - # Done here: - return api_response - - # ┓┏┓ ┏┓┳┓ - # ┃┃┃ ┣┫┃┃┏ - # ┗┛┗┛┛┗┛┗┛ - - async def list_vlans( - self, - mikrotik_ip: str, - username: str, - password: str, - port_no: int | str = None, - use_https: bool = True - ) -> ApiResponse: - - """ - To enlist all the physical connectivity interfaces available on the MikroTik device. - :param mikrotik_ip: The IP address of the MikroTik device. - :param username: The username to get access to the MikroTik device. - :param password: The password to get access to the MikroTik device. - :param port_no: The port no. to hit the MikroTik device on. - :param use_https: Whether to use HTTPS, or HTTP. - :return: A structured API response. - """ - - # Make the API call: - api_response = await self._rest.get( - url = self.get_mikrotik_url( - mikrotik_ip = mikrotik_ip, - path = r"/interface/vlan", - port_no = port_no, - use_https = use_https - ), - auth = httpx.BasicAuth( - username = username, - password = password - ) - ) - - # Done here: - return api_response - - async def add_vlan( - self, - mikrotik_ip: str, - username: str, - password: str, - json_payload: dict, - port_no: int | str = None, - use_https: bool = True - ) -> ApiResponse: - - """ - To add a new VLAN to the MikroTik's configuration. - :param mikrotik_ip: The IP address of the MikroTik device. - :param username: The username to get access to the MikroTik device. - :param password: The password to get access to the MikroTik device. - :param json_payload: The JSON to send in the body pf the request. - :param port_no: The port no. to hit the MikroTik device on. - :param use_https: Whether to use HTTPS, or HTTP. - :return: A structured API response. - """ - - # Make the API call: - api_response = await self._rest.put( - url = self.get_mikrotik_url( - mikrotik_ip = mikrotik_ip, - path = r"/interface/vlan", - port_no = port_no, - use_https = use_https - ), - auth = httpx.BasicAuth( - username = username, - password = password - ), - json = json_payload - ) - - # Done here: - return api_response - - async def update_vlan( - self, - mikrotik_ip: str, - username: str, - password: str, - dot_id: str, - json_payload: dict, - port_no: int | str = None, - use_https: bool = True, - ) -> ApiResponse: - - """ - To update an existing VLAN in the MikroTik's configuration. - :param mikrotik_ip: The IP address of the MikroTik device. - :param username: The username to get access to the MikroTik device. - :param password: The password to get access to the MikroTik device. - :param dot_id: The value of the '.id' field in the list. - :param json_payload: The JSON to send in the body pf the request. - :param port_no: The port no. to hit the MikroTik device on. - :param use_https: Whether to use HTTPS, or HTTP. - :return: A structured API response. - """ - - # Make the API call: - api_response = await self._rest.patch( - url = self.get_mikrotik_url( - mikrotik_ip = mikrotik_ip, - path = f"/interface/vlan/{dot_id}", - port_no = port_no, - use_https = use_https - ), - auth = httpx.BasicAuth( - username = username, - password = password - ), - json = json_payload - ) - - # Done here: - return api_response - - async def remove_vlan( - self, - mikrotik_ip: str, - username: str, - password: str, - dot_id: str, - port_no: int | str = None, - use_https: bool = True, - ): - - """ - To delete an existing VLAN from the MikroTik's configuration. - :param mikrotik_ip: The IP address of the MikroTik device. - :param username: The username to get access to the MikroTik device. - :param password: The password to get access to the MikroTik device. - :param dot_id: The value of the '.id' field in the list. - :param port_no: The port no. to hit the MikroTik device on. - :param use_https: Whether to use HTTPS, or HTTP. - :return: A structured API response. - """ - - # Make the API call: - api_response = await self._rest.delete( - url = self.get_mikrotik_url( - mikrotik_ip = mikrotik_ip, - path = f"/interface/vlan/{dot_id}", - port_no = port_no, - use_https = use_https - ), - auth = httpx.BasicAuth( - username = username, - password = password - ) - ) - - # Done here: - return api_response - # ┏┓ ┓ # ┣┫┓┏╋┣┓ # ┛┗┗┻┗┛┗ @@ -620,12 +211,14 @@ class MikroTikController(CoreSoftwareController, ABC): @abstractmethod async def roll_back( self, + mikrotik_client: AsyncMikroTik, mikrotik_auth: MikroTikPPPoE1000Auth | MikroTikHotspot1000Auth ) -> MikroTikRollBackAttemptResponse: """ The rolling-back to the original state (as best as possible) in case the configurations fails midway after completing some no. of steps. + :param mikrotik_client: The client to use. :param mikrotik_auth: The set of credentials as received from the UI/API. :return: A structured response to indicate what happened during the configuration attempt. """ @@ -635,11 +228,13 @@ class MikroTikController(CoreSoftwareController, ABC): @abstractmethod async def configure( self, + mikrotik_client: AsyncMikroTik, mikrotik_auth: MikroTikPPPoE1000Auth | MikroTikHotspot1000Auth ) -> MikroTikConfigAttemptResponse: """ Run the configuration steps for the system. + :param mikrotik_client: The client to use. :param mikrotik_auth: The set of credentials as received from the UI/API. :return: A structured response to indicate what happened during the configuration attempt. """ diff --git a/controllers_v2/software/mikrotik/mikrotik_pppoe_1000.py b/controllers_v2/software/mikrotik/mikrotik_pppoe_1000.py index fc73e86..5689952 100644 --- a/controllers_v2/software/mikrotik/mikrotik_pppoe_1000.py +++ b/controllers_v2/software/mikrotik/mikrotik_pppoe_1000.py @@ -21,7 +21,8 @@ N/A """ -import asyncio + + # ***************************************************************************************************************** # ***** **** # *** IMPORT *** @@ -37,6 +38,7 @@ sys.path.append("..") # My async utils: from utils_v2.string import json from utils_v2.date_time import date_time +from utils_v2.mikrotik.controllers.async_mikrotik import AsyncMikroTik from utils_v2.database.async_mysql_v2 import AsyncMySQL from utils_v2.database.async_mongo_v2 import AsyncMongo from utils_v2.cache.async_redis_cache_v2 import AsyncRedisCache @@ -54,10 +56,7 @@ from models.software.mikrotik.auth import ( MikroTikHotspot1000Auth, MikroTikAuthResponse ) -from models.software.mikrotik.configure import ( - MikroTikConfigAttemptResponse, - MikroTikRollBackAttemptResponse -) +from models.software.mikrotik.configure import MikroTikConfigAttemptResponse # To work with datatypes: from typing import List, Any @@ -71,6 +70,9 @@ from bson.objectid import ObjectId # To make abstract classes: from abc import ABC, abstractmethod +# For asynchronous activities: +import asyncio + # ***************************************************************************************************************** # ***** **** @@ -118,9 +120,16 @@ class MikroTikPPPoE1000Controller(MikroTikController): CLIENT_NAME = "mikrotikPPPoE1000" # For automated configuration, and identification of automated configuration: - CREATED_BY_NAME = "easyfi" - HW_INTERFACE_NAME = "easyfi-pppoe" - VLAN_NAME = "easyfi-vlan-{}" # ... Substitute the VLAN's id here. + NAME_PREFIX = "easyfi" + CREATED_BY_NAME = NAME_PREFIX + "-pppoe-1000" + HW_INTERFACE_NAME = NAME_PREFIX + "-pppoe" + VLAN_NAME = NAME_PREFIX + "-vlan-{}" # .................... Substitute the VLAN's id here. + PRIVATE_IP_POOL_NAME = NAME_PREFIX + "-pppoe-pool-{}" # ... Substitute the VLAN's id here. + PPP_PROFILE_NAME = NAME_PREFIX + "-pppoe-prf-{}" # ........ Substitute the VLAN's id here. + PPP_SERVER_NAME = NAME_PREFIX + "-pppoe-srv-{}" # ......... Substitute the VLAN's id here. + RADIUS_SERVER_NAME = NAME_PREFIX + "-radius" # ............ Substitute the VLAN's id here. + NAT_RULE_NAME = NAME_PREFIX + "-pppoe-nat-{}" # ........... Substitute rule no. here. + SNMP_COMMUNITY_NAME = NAME_PREFIX + "-snmp" # ┏┓ # ┃ ┏┓┏┓┏╋┏┓┓┏┏╋┏┓┏┓ @@ -162,46 +171,21 @@ class MikroTikPPPoE1000Controller(MikroTikController): # Init a variable in a parent: self._client = self.CLIENT_NAME - # ┓┏ ┓ - # ┣┫┏┓┃┏┓┏┓┏┓┏ - # ┛┗┗ ┗┣┛┗ ┛ ┛ - # ┛ - - def created_by_easyfi( - self, - resource_json: dict = None - ) -> bool: - - """ - Checks if the resource was created/modified by this class. - :param resource_json; The JSON of the resource that needs to be checked. - :return: True if created by this class, else False. - """ - - # Extract values: - resource_name = resource_json.get("name") or "" - comment_json = self.parse_comment_json(resource_json.get("comment")) or {} - - # Perform checks: - if ( - comment_json.get("createdBy", "???") == self.CREATED_BY_NAME or - resource_name.lower().find("easyfi") >= 0 - ): return True - else: return False - # ┳ ┏ # ┃┏┓╋┏┓┏┓╋┏┓┏┏┓┏ # ┻┛┗┗┗ ┛ ┛┗┻┗┗ ┛ async def set_up_interface( self, - mikrotik_auth: MikroTikPPPoE1000Auth, + mikrotik_client: AsyncMikroTik, + dot_id: str = None, use_https: bool = True ) -> MikroTikConfigAttemptResponse: """ To find the first available interface and set it up for use. - :param mikrotik_auth: The set of credentials as received from the UI/API. + :param mikrotik_client: The client to use. + :param dot_id: If you already have an interface in mind, send its id here. :param use_https: Whether to use HTTPS, or HTTP. :return: A structured response to indicate what happened during the process. """ @@ -210,13 +194,7 @@ class MikroTikPPPoE1000Controller(MikroTikController): step_response = MikroTikConfigAttemptResponse() # Enlist all the interfaces: - api_response = await self.list_interfaces( - mikrotik_ip = mikrotik_auth.nasIp, - port_no = mikrotik_auth.nasPort, - username = mikrotik_auth.username, - password = mikrotik_auth.password, - use_https = use_https - ) + api_response = await mikrotik_client.list_interfaces(use_https = use_https) # If the listing failed: if not api_response.success: @@ -225,47 +203,51 @@ class MikroTikPPPoE1000Controller(MikroTikController): step_response.exception = step_response.exception return step_response - # We find the first available interface: - unused_if_json = None - for if_json in await api_response.get_json(): - if str(if_json.get("running")).lower() == "false": - unused_if_json = if_json + # We find/select an interface: + selected_if_json = None + for if_json in api_response.data: + if ( + str(if_json.get("type")).lower() == "ether" and + ( + ( + dot_id is not None and + str(if_json.get(".id")).lower() == str(dot_id) + ) or + ( + dot_id is None and + str(if_json.get("running")).lower() == "false" + ) + ) + ): + selected_if_json = if_json break # If we found no available interface: - if unused_if_json is None: + if selected_if_json is None: step_response.success = False - step_response.message = "Failed to find an idle h/w interface." + step_response.message = "Failed to select a h/w interface." step_response.exception = None return step_response # Reconfigure the idle interface: - api_response = await self.update_interface( - mikrotik_ip = mikrotik_auth.nasIp, - username = mikrotik_auth.username, - password = mikrotik_auth.password, - dot_id = unused_if_json.get(".id"), - port_no = mikrotik_auth.nasPort, - use_https = use_https, + api_response = await mikrotik_client.update_interface( + dot_id = selected_if_json.get(".id"), json_payload = { "name": self.HW_INTERFACE_NAME, "disabled": "false", - "comment": self.create_comment_json( - created_by = self.CREATED_BY_NAME, - created_ts = date_time.get_current_utc_date_time(as_string = False), - roll_back_config = unused_if_json - ) - } + "comment": mikrotik_client.create_comment_json(roll_back_config = selected_if_json) + }, + use_https = use_https, ) # Check if the attempt was successful: if api_response.success: step_response.success = True - step_response.message = f"H/w interface ('{unused_if_json.get('.id')}') configured." + step_response.message = f"H/w interface ('{selected_if_json.get('.id')}') configured." step_response.exception = None else: step_response.success = False - step_response.message = f"H/w interface ('{unused_if_json.get('.id')}') found, but could NOT be configured." + step_response.message = f"H/w interface ('{selected_if_json.get('.id')}') found, but could NOT be configured." step_response.exception = api_response.exception # Done here: @@ -273,13 +255,13 @@ class MikroTikPPPoE1000Controller(MikroTikController): async def roll_back_interface( self, - mikrotik_auth: MikroTikPPPoE1000Auth, + mikrotik_client: AsyncMikroTik, use_https: bool = True - ) -> MikroTikRollBackAttemptResponse: + ) -> MikroTikConfigAttemptResponse: """ To find the first available interface. - :param mikrotik_auth: The set of credentials as received from the UI/API. + :param mikrotik_client: The client to use. :param use_https: Whether to use HTTPS, or HTTP. :return: A structured response to indicate what happened during the process. """ @@ -290,13 +272,7 @@ class MikroTikPPPoE1000Controller(MikroTikController): step_response = MikroTikConfigAttemptResponse() # Enlist all the interfaces: - api_response = await self.list_interfaces( - mikrotik_ip = mikrotik_auth.nasIp, - port_no = mikrotik_auth.nasPort, - username = mikrotik_auth.username, - password = mikrotik_auth.password, - use_https = use_https - ) + api_response = await mikrotik_client.list_interfaces(use_https = use_https) # If the listing failed: if not api_response.success: @@ -308,35 +284,29 @@ class MikroTikPPPoE1000Controller(MikroTikController): # Loop through all the interfaces, # identify which ones were configured by this class, # and perform rollback on those: - for if_json in await api_response.get_json(): + for if_json in api_response.data: - # Check if the configuration was made by this class: - created_by = None - original_config = None - comment_json = self.parse_comment_json(if_json.get("comment")) - if comment_json is not None: - created_by = comment_json.get("createdBy") - original_config = comment_json.get("rollbackConfig") + # Extract the needed values: + comment_json = mikrotik_client.parse_comment_json(if_json.get("comment")) + original_config = mikrotik_client.get_original_config(comment_json) # If this was indeed set up by this class, # and if rollback data is available: - if created_by == self.CREATED_BY_NAME and original_config is not None: - api_response = await self.update_interface( - mikrotik_ip = mikrotik_auth.nasIp, - username = mikrotik_auth.username, - password = mikrotik_auth.password, + if mikrotik_client.is_my_config( + config_json = if_json, + name_substring = self.NAME_PREFIX + ): + api_response = await mikrotik_client.update_interface( dot_id = if_json.get(".id"), - port_no = mikrotik_auth.nasPort, - use_https = use_https, - json_payload={ - "name": original_config.get("name", self.HW_INTERFACE_NAME), + json_payload = { + "name": original_config.get("name"), "disabled": "false", - "comment": original_config.get("comment", "") - } + "comment": original_config.get("comment") + }, + use_https = use_https, ) total_count += 1 if api_response.success: rolled_back_count += 1 - print("RB API RESP:", api_response) # Construct the final response: step_response.message = f"Rolled back {rolled_back_count}/{total_count} h/w interface configuration(s)." @@ -351,16 +321,16 @@ class MikroTikPPPoE1000Controller(MikroTikController): async def set_up_one_vlan( self, - mikrotik_auth: MikroTikPPPoE1000Auth, + mikrotik_client: AsyncMikroTik, vlan_id: int, vlan_list: List[dict], use_https: bool = True - ): + ) -> MikroTikConfigAttemptResponse: """ To set up just one of the needed VLANs. If the VLAN id is not taken, a new VLAN will be created, otherwise the existing one will be updated. - :param mikrotik_auth: The set of credentials as received from the UI/API. + :param mikrotik_client: The client to use. :param vlan_id: The id of the VLAN you want to create. :param vlan_list: The list of existing VLANs already configured in the MikroTik device. Helps us decide between the use of PUT and PATCH methods. @@ -386,22 +356,14 @@ class MikroTikPPPoE1000Controller(MikroTikController): if existing_dot_id is None: # We add the VLAN: - api_response = await self.add_vlan( - mikrotik_ip = mikrotik_auth.nasIp, - username = mikrotik_auth.username, - password = mikrotik_auth.password, + api_response = await mikrotik_client.add_vlan( json_payload = { "name": self.VLAN_NAME.format(vlan_id), "vlan-id": str(vlan_id), "interface": self.HW_INTERFACE_NAME, "disabled": "false", - "comment": self.create_comment_json( - created_by = self.CREATED_BY_NAME, - created_ts = date_time.get_current_utc_date_time(as_string = False), - roll_back_config = existing_vlan_json - ) + "comment": mikrotik_client.create_comment_json(roll_back_config = existing_vlan_json) }, - port_no = mikrotik_auth.nasPort, use_https = use_https ) @@ -419,22 +381,14 @@ class MikroTikPPPoE1000Controller(MikroTikController): else: # We update it: - api_response = await self.update_vlan( - mikrotik_ip = mikrotik_auth.nasIp, - username = mikrotik_auth.username, - password = mikrotik_auth.password, + api_response = await mikrotik_client.update_vlan( dot_id = existing_dot_id, json_payload = { "name": self.VLAN_NAME.format(vlan_id), "interface": self.HW_INTERFACE_NAME, "disabled": "false", - "comment": self.create_comment_json( - created_by = self.CREATED_BY_NAME, - created_ts = date_time.get_current_utc_date_time(as_string = False), - roll_back_config = existing_vlan_json - ) + "comment": mikrotik_client.create_comment_json(roll_back_config = existing_vlan_json) }, - port_no = mikrotik_auth.nasPort, use_https = use_https ) @@ -453,12 +407,14 @@ class MikroTikPPPoE1000Controller(MikroTikController): async def set_up_vlans( self, + mikrotik_client: AsyncMikroTik, mikrotik_auth: MikroTikPPPoE1000Auth, use_https: bool = True - ) -> ApiResponse: + ) -> MikroTikConfigAttemptResponse: """ To set up all the needed VLANs: + :param mikrotik_client: The client to use. :param mikrotik_auth: The set of credentials as received from the UI/API. :param use_https: Whether to use HTTPS, or HTTP. :return: A structured response to indicate what happened during the process. @@ -468,13 +424,7 @@ class MikroTikPPPoE1000Controller(MikroTikController): step_response = MikroTikConfigAttemptResponse() # Enlist all the existing VLANs: - api_response = await self.list_vlans( - mikrotik_ip = mikrotik_auth.nasIp, - port_no = mikrotik_auth.nasPort, - username = mikrotik_auth.username, - password = mikrotik_auth.password, - use_https = use_https - ) + api_response = await mikrotik_client.list_vlans(use_https = use_https) # If the listing failed: if not api_response.success: @@ -484,28 +434,26 @@ class MikroTikPPPoE1000Controller(MikroTikController): return step_response # Extract the list here: - vlan_list = await api_response.get_json() + vlan_list = api_response.data # Create a task for each VLAN that you need to set up, # then fire them all asynchronously: tasks = [ self.set_up_one_vlan( - mikrotik_auth = mikrotik_auth, + mikrotik_client = mikrotik_client, vlan_id = vlan_id, vlan_list = vlan_list, use_https = use_https - ) for vlan_id in mikrotik_auth.vlanRange + ) for vlan_id in mikrotik_auth.vlanIds ] results = await asyncio.gather(*tasks) # Assess the results: total_count = len(results) success_count = 0 - all_messages = [] for result in results: if result.success: success_count += 1 - all_messages.append(result.message) - step_response.message = " -> ".join(all_messages) + step_response.message = f"{success_count}/{total_count} VLAN(s) configured." step_response.success = True if success_count == total_count else False # Done here: @@ -513,13 +461,13 @@ class MikroTikPPPoE1000Controller(MikroTikController): async def roll_back_vlans( self, - mikrotik_auth: MikroTikPPPoE1000Auth, + mikrotik_client: AsyncMikroTik, use_https: bool = True - ) -> MikroTikRollBackAttemptResponse: + ) -> MikroTikConfigAttemptResponse: """ To reset VLANs to their original state (if they already existed) or remove them if this class added them. - :param mikrotik_auth: The set of credentials as received from the UI/API. + :param mikrotik_client: The client to use. :param use_https: Whether to use HTTPS, or HTTP. :return: A structured response to indicate what happened during the process. """ @@ -531,13 +479,7 @@ class MikroTikPPPoE1000Controller(MikroTikController): step_response = MikroTikConfigAttemptResponse() # Enlist all the existing VLANs: - api_response = await self.list_vlans( - mikrotik_ip = mikrotik_auth.nasIp, - port_no = mikrotik_auth.nasPort, - username = mikrotik_auth.username, - password = mikrotik_auth.password, - use_https = use_https - ) + api_response = await mikrotik_client.list_vlans(use_https = use_https) # If the listing failed: if not api_response.success: @@ -549,14 +491,17 @@ class MikroTikPPPoE1000Controller(MikroTikController): # Loop through all the VLANs, # and either reset them to their original state (if they already existed), # or remove them entirely if they didn't exist before we came: - for vlan_json in await api_response.get_json(): + for vlan_json in api_response.data: # Check if the VLAN was created by this class: - if not self.created_by_easyfi(vlan_json): continue + if not mikrotik_client.is_my_config( + config_json = vlan_json, + name_substring = self.NAME_PREFIX + ): continue # Extract the rollback information: - comment = self.parse_comment_json(vlan_json.get("comment")) or {} - roll_back_json = comment.get("rollbackConfig") + comment_json = mikrotik_client.parse_comment_json(vlan_json.get("comment")) + roll_back_json = mikrotik_client.get_original_config(comment_json) vlan_id = vlan_json.get("vlan-id") dot_id = vlan_json.get(".id") @@ -564,14 +509,7 @@ class MikroTikPPPoE1000Controller(MikroTikController): if roll_back_json is None: # We delete the resource: - await self.remove_vlan( - mikrotik_ip = mikrotik_auth.nasIp, - username = mikrotik_auth.username, - password = mikrotik_auth.password, - dot_id = dot_id, - port_no = mikrotik_auth.nasPort, - use_https = use_https - ) + await mikrotik_client.remove_vlan(dot_id = dot_id, use_https = use_https) # Assess the result: total_count += 1 @@ -584,10 +522,7 @@ class MikroTikPPPoE1000Controller(MikroTikController): else: # We roll back to the previous configuration: - await self.update_vlan( - mikrotik_ip = mikrotik_auth.nasIp, - username = mikrotik_auth.username, - password = mikrotik_auth.password, + await mikrotik_client.update_vlan( dot_id = dot_id, json_payload = { "name": roll_back_json.get("name"), @@ -595,7 +530,6 @@ class MikroTikPPPoE1000Controller(MikroTikController): "disabled": roll_back_json.get("disabled"), "comment": "" }, - port_no = mikrotik_auth.nasPort, use_https = use_https ) @@ -607,9 +541,235 @@ class MikroTikPPPoE1000Controller(MikroTikController): else: all_messages.append(f"Failed to roll back VLAN with id {vlan_id}") # Assess the overall results: - all_messages.append(f"Removed/rolled-back {success_count}/{total_count} VLANs.") if success_count == total_count: step_response.success = True - step_response.message = " -> ".join(all_messages) + step_response.message = f"Removed/rolled-back {success_count}/{total_count} VLAN(s)." + + # Done here: + return step_response + + # ┳┏┓ ┏┓ ┓ + # ┃┃┃ ┃┃┏┓┏┓┃┏ + # ┻┣┛ ┣┛┗┛┗┛┗┛ + + async def set_up_one_ip_pool( + self, + mikrotik_client: AsyncMikroTik, + ip_pool: dict, + existing_ip_pools: List[dict], + use_https: bool = True + ) -> MikroTikConfigAttemptResponse: + + """ + To set up one of the needed IP pools. + :param mikrotik_client: The client to use. + :param ip_pool: The IP pool you want configured by the end of the process. + :param existing_ip_pools: A list of the existing IP pools. Helps to decide between 'PUT' and 'PATCH' methods. + :param use_https: Whether to use HTTPS, or HTTP. + :return: A structured response to indicate what happened during the process. + """ + + # Start by assuming failure: + step_response = MikroTikConfigAttemptResponse() + + # Check if the configuration already exists: + existing_dot_id = None + existing_ip_pool_json = None + for existing_ip_pool in existing_ip_pools: + if existing_ip_pool.get("name", "???") == ip_pool["name"]: + existing_dot_id = existing_ip_pool[".id"] + existing_ip_pool_json = existing_ip_pool + break + + # If the IP pool doesn't already exist: + if existing_dot_id is None: + + # Add the configuration: + api_response = await mikrotik_client.add_ip_pool( + json_payload = { + "name": ip_pool["name"], + "ranges": ip_pool["network"], + "comment": mikrotik_client.create_comment_json() + }, + use_https = use_https + ) + + # And assess the result: + if api_response.success: + step_response.success = True + step_response.message = f"Added new IP pool '{ip_pool['name']}'." + step_response.exception = None + else: + step_response.success = False + step_response.message = f"Failed to add new IP pool '{ip_pool['name']}'." + step_response.exception = api_response.exception + + # If the IP pool already exists: + else: + + # Update the configuration: + api_response = await mikrotik_client.update_ip_pool( + dot_id = existing_dot_id, + json_payload = { + "name": ip_pool["name"], + "ranges": ip_pool["network"], + "comment": mikrotik_client.create_comment_json(roll_back_config = existing_ip_pool_json) + }, + use_https = use_https + ) + + # And assess the result: + if api_response.success: + step_response.success = True + step_response.message = f"Updated existing IP pool '{ip_pool['name']}'." + step_response.exception = None + else: + step_response.success = False + step_response.message = f"Failed to update existing IP pool '{ip_pool['name']}'." + step_response.exception = api_response.exception + + # Done here: + return step_response + + async def set_up_ip_pools( + self, + mikrotik_client: AsyncMikroTik, + mikrotik_auth: MikroTikPPPoE1000Auth, + use_https: bool = True + ) -> MikroTikConfigAttemptResponse: + + """ + To set up ALL the needed IP pools. + :param mikrotik_client: The client to use. + :param mikrotik_auth: The set of credentials as received from the UI/API. + :param use_https: Whether to use HTTPS, or HTTP. + :return: A structured response to indicate what happened during the process. + """ + + # Start by assuming failure: + step_response = MikroTikConfigAttemptResponse() + + # Enlist all the existing VLANs: + api_response = await mikrotik_client.list_ip_pools(use_https = use_https) + + # If the listing failed: + if not api_response.success: + step_response.success = False + step_response.message = "Failed to enlist existing IP pools during configuration." + step_response.exception = step_response.exception + return step_response + + # Extract the list here: + existing_ip_pools = api_response.data + + # First we split the whole private IP range + # into the no. of VLANs we had been asked to make: + ip_pools = mikrotik_client.split_ipv4_range_equally( + start_ip = mikrotik_auth.firstPrivateIp, + end_ip = mikrotik_auth.lastPrivateIp, + targets = mikrotik_auth.vlanIds + ) + + # Create tasks to create IP Pools, + # and fire them asynchronously: + tasks = [ + self.set_up_one_ip_pool( + mikrotik_client = mikrotik_client, + ip_pool = { + "name": self.PRIVATE_IP_POOL_NAME.format(ip_pool["target"]), + "network": ip_pool["network"], + }, + existing_ip_pools = existing_ip_pools, + use_https = use_https + ) for ip_pool in ip_pools + ] + results = await asyncio.gather(*tasks) + + # Assess the results: + total_count = len(results) + success_count = 0 + for result in results: + if result.success: success_count += 1 + step_response.message = f"{success_count}/{total_count} IP pool(s) configured." + step_response.success = True if success_count == total_count else False + + # Done here: + return step_response + + async def roll_back_ip_pools( + self, + mikrotik_client: AsyncMikroTik, + use_https: bool = True + ) -> MikroTikConfigAttemptResponse: + + """ + To reset IP pools to their original state (if they already existed) or remove them if this class added them. + :param mikrotik_client: The client to use. + :param use_https: Whether to use HTTPS, or HTTP. + :return: A structured response to indicate what happened during the process. + """ + + # Start by assuming failure: + total_count = 0 + success_count = 0 + step_response = MikroTikConfigAttemptResponse() + + # Enlist all the existing VLANs: + api_response = await mikrotik_client.list_ip_pools(use_https = use_https) + + # If the listing failed: + if not api_response.success: + step_response.success = False + step_response.message = "Failed to enlist existing IP pools during rollback." + step_response.exception = step_response.exception + return step_response + + # Loop through all the VLANs, + # and either reset them to their original state (if they already existed), + # or remove them entirely if they didn't exist before we came: + for resource_json in api_response.data: + + # Check if the VLAN was created by this class: + if not mikrotik_client.is_my_config( + config_json = resource_json, + name_substring = self.NAME_PREFIX + ): continue + + # Extract the rollback information: + comment_json = mikrotik_client.parse_comment_json(resource_json.get("comment")) + roll_back_json = mikrotik_client.get_original_config(comment_json) + dot_id = resource_json.get(".id") + + # If the resource has no rollback information: + if roll_back_json is None: + + # We delete the resource: + await mikrotik_client.remove_ip_pool(dot_id = dot_id, use_https = use_https) + + # Assess the result: + total_count += 1 + if api_response.success: success_count += 1 + + # If the resource has rollback information: + else: + + # We roll back to the previous configuration: + await mikrotik_client.update_ip_pool( + dot_id = dot_id, + json_payload = { + "name": roll_back_json.get("name"), + "ranges": roll_back_json.get("ranges"), + "comment": "" + }, + use_https = use_https + ) + + # Assess the result: + total_count += 1 + if api_response.success: success_count += 1 + + # Assess the overall results: + if success_count == total_count: step_response.success = True + step_response.message = f"Removed/rolled-back {success_count}/{total_count} IP pool(s)." # Done here: return step_response @@ -634,31 +794,34 @@ class MikroTikPPPoE1000Controller(MikroTikController): :return: A structured response to indicate what happened during authorization. """ - # # print("IN-AUTH:", json.to_string(mikrotik_auth.model_dump(), default = str)) - # print("VLAN SUBNETS:", json.to_string( - # self.split_ipv4_range_among_targets( - # start_ip = mikrotik_auth.firstPrivateIp, - # end_ip = mikrotik_auth.lastPrivateIp, - # targets = mikrotik_auth.vlanRange - # ), - # default = str - # )) - # Start with a blank response: auth_response = MikroTikAuthResponse() - # Try to configure the system: - config_response = await self.configure(mikrotik_auth) - print("CONFIG RESPONSE:", config_response) - auth_response = config_response + # Create a MikroTik client: + mikrotik_client = AsyncMikroTik( + config_by = self.CREATED_BY_NAME, + mikrotik_ip = mikrotik_auth.nasIp, + username = mikrotik_auth.username, + password = mikrotik_auth.password, + port = mikrotik_auth.nasPort, + use_https = True, + http_client = None, + action_log_conn = mongo_data_conn + ) - await asyncio.sleep(10.0) + # Try to configure the system: + config_response = await self.configure(mikrotik_client, mikrotik_auth) + print("CONFIG RESPONSE:", config_response) + + await asyncio.sleep(2.0) # Try to roll all configuration back: - roll_back_response = await self.roll_back(mikrotik_auth) + roll_back_response = await self.roll_back(mikrotik_client, mikrotik_auth) print("ROLL-BACK RESPONSE:", roll_back_response) + config_response += roll_back_response # Done here: + auth_response = config_response return auth_response # ┏┓ ┏• @@ -668,30 +831,38 @@ class MikroTikPPPoE1000Controller(MikroTikController): async def roll_back( self, - mikrotik_auth: MikroTikPPPoE1000Auth | MikroTikHotspot1000Auth - ) -> MikroTikRollBackAttemptResponse: + mikrotik_client: AsyncMikroTik, + mikrotik_auth: MikroTikPPPoE1000Auth + ) -> MikroTikConfigAttemptResponse: """ The rolling-back to the original state (as best as possible) in case the configurations fails midway after completing some no. of steps. + :param mikrotik_client: The client to use. :param mikrotik_auth: The set of credentials as received from the UI/API. :return: A structured response to indicate what happened during the configuration attempt. """ # Start with some variables: keep_going = True - all_messages = [] + all_messages = ["STARTING ROLLBACK."] roll_back_response = MikroTikConfigAttemptResponse() - # Next we remove all the VLANs: + # Next, we roll back all the IP pools: if keep_going: - step_response = await self.roll_back_vlans(mikrotik_auth, use_https = False) + step_response = await self.roll_back_ip_pools(mikrotik_client, use_https = False) + all_messages.append(step_response.message) + keep_going = step_response.success + + # Next we roll back all the VLANs: + if keep_going: + step_response = await self.roll_back_vlans(mikrotik_client, use_https = False) all_messages.append(step_response.message) keep_going = step_response.success # Next, we free-up the interface: if keep_going: - step_response = await self.roll_back_interface(mikrotik_auth, use_https = False) + step_response = await self.roll_back_interface(mikrotik_client, use_https = False) all_messages.append(step_response.message) keep_going = step_response.success @@ -702,30 +873,39 @@ class MikroTikPPPoE1000Controller(MikroTikController): async def configure( self, - mikrotik_auth: MikroTikPPPoE1000Auth | MikroTikHotspot1000Auth + mikrotik_client: AsyncMikroTik, + mikrotik_auth: MikroTikPPPoE1000Auth ) -> MikroTikConfigAttemptResponse: """ Run the configuration steps for the system. + :param mikrotik_client: The client to use. :param mikrotik_auth: The set of credentials as received from the UI/API. :return: A structured response to indicate what happened during the configuration attempt. """ # Start with some variables: keep_going = True - all_messages = [] - config_response = MikroTikConfigAttemptResponse() + all_messages = ["STARTING CONFIG."] + config_response = MikroTikConfigAttemptResponse(actionChain = mikrotik_client.action_chain) # First, we arrange an interface: if keep_going: - step_response = await self.set_up_interface(mikrotik_auth, use_https = False) + step_response = await self.set_up_interface(mikrotik_client, dot_id = "*3", use_https = False) all_messages.append(step_response.message) keep_going = step_response.success # Next, we create the needed VLANs: if keep_going: await asyncio.sleep(0.25) - step_response = await self.set_up_vlans(mikrotik_auth, use_https = False) + step_response = await self.set_up_vlans(mikrotik_client, mikrotik_auth, use_https = False) + all_messages.append(step_response.message) + keep_going = step_response.success + + # Next, we create the Private IP Pools: + if keep_going: + await asyncio.sleep(0.25) + step_response = await self.set_up_ip_pools(mikrotik_client, mikrotik_auth, use_https = False) all_messages.append(step_response.message) keep_going = step_response.success diff --git a/models/software/mikrotik/auth.py b/models/software/mikrotik/auth.py index 3d96a1c..06e678f 100644 --- a/models/software/mikrotik/auth.py +++ b/models/software/mikrotik/auth.py @@ -172,7 +172,7 @@ class MikroTikPPPoE1000Auth(BaseModel): frozen = True ) - vlanRange: List[int] = Field( + vlanIds: List[int] = Field( description = "Don't know, and don't want to know.", min_length = 1, frozen = True @@ -234,7 +234,7 @@ class MikroTikPPPoE1000Auth(BaseModel): def validate_ip_range(cls, value): return ip.to_hyphen_notation(value) - @field_validator("vlanRange", mode = "before") + @field_validator("vlanIds", mode = "before") def validate_vlans(cls, value): return parse_vlans(value) diff --git a/models/software/mikrotik/configure.py b/models/software/mikrotik/configure.py index d2f3f59..382bc6f 100644 --- a/models/software/mikrotik/configure.py +++ b/models/software/mikrotik/configure.py @@ -77,6 +77,12 @@ REGEX_SESSION_TOKEN = r"^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9] class MikroTikConfigAttemptResponse(BaseModel): + actionChain: str | int = Field( + description = "To be able to link all the actions of one process", + frozen = False, + default = None + ) + success: bool = Field( description = "To indicate whether or not, the action was a success", frozen = False, @@ -107,7 +113,14 @@ class MikroTikConfigAttemptResponse(BaseModel): # ┃ ┓┏┏╋┏┓┏┳┓ ┣ ┓┏┏┓┏┏ # ┗┛┗┻┛┗┗┛┛┗┗ ┻ ┗┻┛┗┗┛ - pass + def __add__(self, other): + + return MikroTikConfigAttemptResponse( + actionChain = other.actionChain or self.actionChain, + success = self.success and other.success, + message = self.message + " -> " + other.message, + exception = other.exception or self.exception + ) # ┓┏ ┓• ┓ • # ┃┃┏┓┃┓┏┫┏┓╋┓┏┓┏┓ @@ -121,6 +134,12 @@ class MikroTikConfigAttemptResponse(BaseModel): class MikroTikRollBackAttemptResponse(BaseModel): + actionChain: str | int = Field( + description = "To be able to link all the actions of one process", + frozen = False, + default = None + ) + success: bool = Field( description = "To indicate whether or not, the action was a success", frozen = False, diff --git a/readme/MikroTik-PPPoE-1000.md b/readme/MikroTik-PPPoE-1000.md index c24697c..30ab34d 100644 --- a/readme/MikroTik-PPPoE-1000.md +++ b/readme/MikroTik-PPPoE-1000.md @@ -69,6 +69,9 @@ configuration, simply delete your record. You will receive the Private IP range in either CIDR notation or as a hyphen-separated string. You need to cut up this larger IP pool into subnets that you will eventually assign to each VLAN (by way of creating PPPoE servers). +**WARNING:** It is possible to have IP pools in a MikroTik device with overlapping IP addresses. It is not recommended, +though, because it may lead to conflicts that could be hard to trace back. + Use `PUT` or `PATCH` method on the path `/ip/pool`. Consider to the following example JSON: ```json { @@ -80,8 +83,8 @@ Use `PUT` or `PATCH` method on the path `/ip/pool`. Consider to the following ex **NOTE:** Looping needed. -**ROLL-BACK:** Save the original configuration as a JSON string in the `comment` field. If there was no original -configuration, simply delete your record. +**ROLL-BACK:** Identify your entry from the `name` field (contains 'easyfi') or from the contents of the `comment` field +and remove it. ### 4. Create PPPoE Profiles @@ -115,7 +118,7 @@ up servers that use one VLAN and one private IP sub-pool to actually handle the Use `PUT` or `PATCH` method on the path `/interface/pppoe-server/server`. Consider to the following example JSON: ```json { - "interface": "vlan10", // ..................... The VLAN interface you had created earlier. + "interface": "easyfi-vlan-2001", // ........... The VLAN interface you had created earlier. "profile": "easyfi-pppoe-prf-2001", // ........ The PPPoE profile you created earlier. "service-name": "easyfi-pppoe-srv-2001", // ... Use the VLAN id in the name. "comment": "{...}" // ......................... The JSON string to indicate automated config. diff --git a/ssh_server.sh b/ssh_server.sh index 16bdc4e..4765276 100644 --- a/ssh_server.sh +++ b/ssh_server.sh @@ -20,8 +20,8 @@ if [[ $SELECTION -gt 0 && $SELECTION -le ${#SERVERS[@]} ]]; then SELECTED_SERVER=${SERVERS[$((SELECTION - 1))]} # Ask the username and target port no. on the server:: - read -rp "Your username on the server ..... : " USER read -rp "The target port no. ............. : " PORT + read -rp "Your username on the server ..... : " USER # Run the command: ssh -p "$PORT" "$USER@$SELECTED_SERVER"