From 51b141133add96d0519f1258c0be8e0ac2c5afb4 Mon Sep 17 00:00:00 2001 From: Alessandro Maggio Date: Wed, 30 Aug 2023 14:46:26 +0200 Subject: [PATCH] Remove ssh playground, start with code refactoring create class handler for SSH and Config (just a friendly rename) --- handlers/Config.py | 190 +++++++++++++++++++++++++++++++++++++++++++ handlers/SSH.py | 78 ++++++++++++++++++ handlers/__init__.py | 0 main.py | 53 +++++------- ssh-playground.py | 56 ------------- utils.py | 61 +------------- 6 files changed, 291 insertions(+), 147 deletions(-) create mode 100644 handlers/Config.py create mode 100644 handlers/SSH.py create mode 100644 handlers/__init__.py delete mode 100644 ssh-playground.py diff --git a/handlers/Config.py b/handlers/Config.py new file mode 100644 index 0000000..3f4c314 --- /dev/null +++ b/handlers/Config.py @@ -0,0 +1,190 @@ +import copy + +class Config: + # https://trinityvalidator.com/docs/node/node-config + # https://github.com/sentinel-official/dvpn-node/blob/development/types/config.go + + node = { + "chain": { + "gas": {"value": 200000, "description": "Gas limit to set per transaction"}, + "gas_adjustment": {"value": 1.05, "description": "Gas adjustment factor"}, + "gas_prices": { + "value": "0.1udvpn", + "description": "Gas prices to determine the transaction fee", + }, + "id": {"value": "sentinelhub-2", "description": "The network chain ID"}, + "rpc_addresses": { + "value": "https://rpc.sentinel.co:443", + "description": "Comma separated Tendermint RPC addresses for the chain", + }, + "rpc_query_timeout": { + "value": 10, + "description": "Timeout seconds for querying the data from the RPC server", + }, + "rpc_tx_timeout": { + "value": 30, + "description": "Timeout seconds for broadcasting the transaction through RPC server", + }, + "simulate_and_execute": { + "value": True, + "description": "Calculate the transaction fee by simulating it", + "options": [True, False], + }, + }, + "handshake": { + "enable": { + "value": True, + "description": "Enable Handshake DNS resolver (if you use v2ray set enable = false)", + "options": [True, False], + }, + "peers": {"value": 8, "description": "Number of peers"}, + }, + "keyring": { + "backend": { + "value": "file", + "description": "Underlying storage mechanism for keys", + }, + "from": { + "value": "operator", + "description": "Name of the key with which to sign", + }, + }, + "node": { + "interval_set_sessions": { + "value": "10s", + "description": "Time interval between each set_sessions operation", + }, + "interval_update_sessions": { + "value": "1h55m0s", + "description": "Time interval between each update_sessions transaction", + }, + "interval_update_status": { + "value": "55m0s", + "description": "Time interval between each set_status transaction", + }, + "ipv4_address": { + "value": "", + "description": "IPv4 address to replace the public IPv4 address with", + }, + "listen_on": { + "value": "0.0.0.0:", + "description": "API listen-address (tcp port)", + }, + "moniker": {"value": "your_node_name", "description": "Name of the node"}, + "gigabyte_prices": { + "value": "29000000udvpn,39000ibc/A8C2D23A1E6F95DA4E48BA349667E322BD7A6C996D8A4AAE8BA72E190F3D1477,525000ibc/ED07A3391A112B175915CD8FAF43A2DA8E4790EDE12566649D0C2F97716B8518,700000ibc/31FEE1A2A9F9C01113F90BD0BBCCE8FD6BBB8585FAF109A2101827DD1D5B95B8,52500000ibc/B1C0DDB14F25279A2026BC8794E12B259F8BDA546A3C5132CCAEE4431CE36783", + "description": "Prices for one gigabyte of bandwidth provided", + }, + "hourly_prices": { + "value": "4900000udvpn", + "description": "Prices for one hour", + }, + "remote_url": { + "value": "https://:", + "description": "Public URL of the node", + }, + "type": { + "value": "wireguard", + "description": "Type of node (you can choose between wireguard and v2ray)", + "options": ["wireguard", "v2ray"], + }, + }, + "qos": { + "max_peers": { + "value": 250, + "description": "Limit max number of concurrent peers", + }, + }, + "extras": { + "udp_port": { + "value": 0, + "description": "UDP port used as listen_port for wireguard or v2ray", + }, + "node_folder": { + "value": None, + "description": "Absolute path, where to save the node configuration", + }, + "wallet_password": { + "value": "", + "description": "Wallet password (only used for keyring = file)" + }, + "wallet_mnemonic": { + "value": "", + "description": "Wallet bip mnemonic (leave empty for create a new wallet)" + } + } + } + + v2ray = { + "vmess": { + "listen_port": { + "value": 0, + "description": "Port number to accept the incoming connections", + }, + "transport": { + "value": "grpc", + "description": "Name of the transport protocol", + }, + } + } + + wireguard = { + "interface": {"value": "wg0", "description": "Name of the network interface"}, + "listen_port": { + "value": "", + "description": "Port number to accept the incoming connections", + }, + "private_key": {"value": None, "description": "Server private key"}, + } + + def validate_config(node_config: dict) -> str | bool: + allowed_empty = ["ipv4_address"] + remote_url = node_config["node"]["remote_url"]["value"] + listen_on = node_config["node"]["listen_on"]["value"] + if remote_url.split(":")[-1].strip() != listen_on.split(":")[-1].strip(): + return "TCP port must be equal" + + for group in node_config: + for key in node_config[group]: + if key not in allowed_empty and node_config[group][key]["value"] == "": + return f"{group}.{key} cannot be empty" + + if node_config["node"]["type"]["value"] == "v2ray": + if node_config["handshake"]["enable"] is True: + return f"{group}.{key} cannot be True" + + return True + + def __handle_type(value): + if value in ["True", "False"]: + return value.lower() + elif value.isdigit(): + return value + else: + return f'"{value}"' + + def tomlize(node_config: dict) -> str: + ignore = ["extras"] + raw = "" + for group in node_config: + if group not in ignore: + # check if is a 'group' + keys = list(node_config[group].keys()) + if "value" not in keys and "description" not in keys: + raw += f"\n[{group}]\n" + for key in keys: + raw += f"\n# {node_config[group][key]['description']}\n" + raw += f"{key} = {__handle_type(node_config[group][key]['value'])}\n" + else: + raw += f"{group} = {__handle_type(node_config[group]['value'])}\n" + return raw + + + def node_toml2wellknow(node_config: dict) -> dict: + default_values = copy.deepcopy(Config.node) + for group in node_config: + if group in default_values: + for key in node_config[group]: + if key in default_values[group]: + default_values[group][key]["value"] = node_config[group][key] + return default_values \ No newline at end of file diff --git a/handlers/SSH.py b/handlers/SSH.py new file mode 100644 index 0000000..5ebd37f --- /dev/null +++ b/handlers/SSH.py @@ -0,0 +1,78 @@ +import urllib.request +import paramiko + +from docker import APIClient +from docker.transport import SSHHTTPAdapter + +class SSHAdapterPassword(SSHHTTPAdapter): + def __init__(self, base_url: str, password: str): + self.password = password + super().__init__(base_url) + def _connect(self): + if self.ssh_client: + self.ssh_params["password"] = self.password + self.ssh_client.connect(**self.ssh_params) + +class SSH(): + def __init__(self, host: str, username: str, password: str = None, port: int = 22): + self.host + self.username + self.password + self.port + + self.client = paramiko.SSHClient() + + self.client.load_system_host_keys() + self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + self.client.connect(host, username=username, password=password, port=port, look_for_keys=True) + + """ + k = paramiko.RSAKey.from_private_key_file(keyfilename) + # OR k = paramiko.DSSKey.from_private_key_file(keyfilename) + + self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + self.client.connect(hostname=host, username=user, pkey=k) + """ + + def sudo_exec_command(self, cmd: str): + ssh_stdin, ssh_stdout, ssh_stderr = self.client.exec_command(cmd, get_pty=True) + if ssh_stdin.closed is False and password is not None and "sudo" in cmd: + ssh_stdin.write(self.password + '\n') + ssh_stdin.flush() + return ssh_stdin, ssh_stdout, ssh_stderr + + def read_file(self, fpath: str) -> str: + sftp = self.client.open_sftp() + rfile = sftp.open(fpath) + content = "" + for line in rfile: + content += line + rfile.close() + sftp.close() + return content + + def get_home(self) -> str: + ssh_stdin, ssh_stdout, ssh_stderr = self.client.exec_command("echo ${HOME}") + return ssh_stdout.read().decode("utf-8").strip() + + def put_file(self, fpath: str) -> bool: + home_directory = ssh_get_home(ssh) + ftp = self.client.open_sftp() + fname = os.path.basename(fpath) + ftp.put(fpath, os.path.join(home_directory, fname)) + ftp.close() + return True + + def close(self): # :) + self.client.close() + + def exec_command(self, cmd: str): # :) + return self.client.exec_command(cmd) + + def docker(self, docker_api_version: str): + client = APIClient(f'ssh://{self.host}:{self.port}', use_ssh_client=True, version=docker_api_version) + ssh_adapter = SSHAdapterPassword(f'ssh://{self.username}@{self.host}:{self.port}', password=self.password) + client.mount('http+docker://ssh', ssh_adapter) + if client.version(api_version=False)["ApiVersion"] == docker_api_version: + return client + return None \ No newline at end of file diff --git a/handlers/__init__.py b/handlers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/main.py b/main.py index 808d081..8bd916d 100644 --- a/main.py +++ b/main.py @@ -14,9 +14,10 @@ from flask import Flask, render_template, request, jsonify from flask_sqlalchemy import SQLAlchemy -from utils import ifconfig, parse_input, ssh_connection, sudo_exec_command, ssh_docker, node_status, ssh_read_file, ssh_put_file, ssh_get_home +from utils import ifconfig, parse_input, ssh_docker, node_status -from ConfigHandler import ConfigHandler +from handlers.Config import Config +from handlers.SSH import SSH app = Flask(__name__) app.config ['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///nodes.sqlite3' @@ -72,13 +73,7 @@ def post_container(node_id: int, container_id: str): password=node.password, port=node.port ) - docker_client = ssh_docker( - host=node.host, - username=node.username, - docker_api_version=docker_api_version, - password=node.password, - port=node.port - ) + docker_client = ssh.docker(docker_api_version) if action == "stop": docker_client.stop(container_id) elif action == "remove_container": @@ -92,7 +87,7 @@ def post_container(node_id: int, container_id: str): @app.route("/node/", methods=["GET", "POST"]) def get_node(node_id: int): node = db.session.get(Nodes, node_id) - ssh = ssh_connection( + ssh = SSH( host=node.host, username=node.username, password=node.password, @@ -103,8 +98,8 @@ def get_node(node_id: int): json_request = request.get_json() action = json_request.get("action", None) if action == "install": - if ssh_put_file(ssh, os.path.join(os.getcwd(), "docker-install.sh")) is True: - ssh_stdin, ssh_stdout, ssh_stderr = sudo_exec_command(ssh, "sudo bash ${HOME}/docker-install.sh", password=node.password) + if ssh.put_file(os.path.join(os.getcwd(), "docker-install.sh")) is True: + ssh_stdin, ssh_stdout, ssh_stderr = ssh.sudo_exec_command("sudo bash ${HOME}/docker-install.sh", password=node.password) output = ssh_stdout.read().decode("utf-8") output.replace(node.password, "*" * len(node.password)) ssh.close() @@ -127,7 +122,7 @@ def get_node(node_id: int): ssh.close() return docker_client.pull(repository, tag=None) - ssh_stdin, ssh_stdout, ssh_stderr = sudo_exec_command(ssh, "sudo whoami", password=node.password) + ssh_stdin, ssh_stdout, ssh_stderr = ssh.sudo_exec_command("sudo whoami", password=node.password) sudoers_permission = ssh_stdout.readlines()[-1].strip() == "root" """ @@ -146,13 +141,7 @@ def get_node(node_id: int): docker_images = [] containers = [] if docker_installed is True: - docker_client = ssh_docker( - host=node.host, - username=node.username, - docker_api_version=docker_api_version, - password=node.password, - port=node.port - ) + docker_client = ssh.docker(docker_api_version) if docker_client is not None: for image in docker_client.images(): docker_images += image["RepoTags"] @@ -175,13 +164,13 @@ def get_node(node_id: int): for mount in container["Mounts"]: if mount['Type'] == 'bind' and mount["Source"] != "/lib/modules": node_config_fpath = os.path.join(mount["Source"], "config.toml") - node_config = ssh_read_file(ssh, node_config_fpath) + node_config = ssh.read_file(node_config_fpath) node_config = tomllib.loads(node_config) - node_config = ConfigHandler.node_toml2wellknow(node_config) + node_config = Config.node_toml2wellknow(node_config) node_config["extras"]["node_folder"]["value"] = mount["Source"] service_type = node_config["node"]["type"]["value"] - service_config = ssh_read_file(ssh, os.path.join(mount["Source"], f"{service_type}.toml")) + service_config = ssh.read_file(os.path.join(mount["Source"], f"{service_type}.toml")) service_config = tomllib.loads(service_config) node_config["extras"]["udp_port"]["value"] = service_config["vmess"]["listen_port"] if service_type == "v2ray" else service_config["listen_port"] @@ -200,7 +189,7 @@ def get_node(node_id: int): "docker_images": docker_images }) - default_node_config = copy.deepcopy(ConfigHandler.node) + default_node_config = copy.deepcopy(Config.node) if containers == []: tcp_port = secrets.SystemRandom().randrange(1000, 9000) name = randomname.get_name() @@ -210,7 +199,7 @@ def get_node(node_id: int): default_node_config["extras"]["udp_port"]["value"] = secrets.SystemRandom().randrange( 1000, 9000 ) - home_directory = ssh_get_home(ssh) + home_directory = ssh.get_home() default_node_config["extras"]["node_folder"]["value"] = os.path.join( home_directory, f".sentinel-node-{name}" ) @@ -221,34 +210,34 @@ def get_node(node_id: int): @app.route("/create", methods=("GET", "POST")) def create_config(): - node_config = copy.deepcopy(ConfigHandler.node) + node_config = copy.deepcopy(Config.node) if request.method == "POST": form = request.form.to_dict() for conf in form: group, key = conf.split(".") node_config[group][key]["value"] = form[conf] - validated = ConfigHandler.validate_config(node_config) + validated = Config.validate_config(node_config) if type(validated) == bool and validated == True: node_folder = node_config["extras"]["node_folder"]["value"] os.makedirs(node_folder, exist_ok=True) with open(os.path.join(node_folder, "config.toml"), "w") as f: - f.write(ConfigHandler.tomlize(node_config)) + f.write(Config.tomlize(node_config)) node_type = node_config["node"]["type"]["value"] if node_type == "wireguard": - wireguard_config = copy.deepcopy(ConfigHandler.wireguard) + wireguard_config = copy.deepcopy(Config.wireguard) wireguard_config["listen_port"]["value"] = node_config["extras"][ "udp_port" ]["value"] wireguard_config["private_key"]["value"] = WgPsk().key with open(os.path.join(node_folder, "wireguard.toml"), "w") as f: - f.write(ConfigHandler.tomlize(wireguard_config)) + f.write(Config.tomlize(wireguard_config)) elif node_type == "v2ray": - v2ray_config = copy.deepcopy(ConfigHandler.v2ray) + v2ray_config = copy.deepcopy(Config.v2ray) v2ray_config["vmess"]["listen_port"]["value"] = node_config["extras"][ "udp_port" ]["value"] with open(os.path.join(node_folder, "v2ray.toml"), "w") as f: - f.write(ConfigHandler.tomlize(v2ray_config)) + f.write(Config.tomlize(v2ray_config)) return render_template( "create.html", diff --git a/ssh-playground.py b/ssh-playground.py deleted file mode 100644 index 1a5535b..0000000 --- a/ssh-playground.py +++ /dev/null @@ -1,56 +0,0 @@ -import paramiko -from docker import APIClient -from docker.transport import SSHHTTPAdapter - -host = "" -username = "" -password = "" -port = 22 - -ssh = paramiko.SSHClient() - -ssh.load_system_host_keys() -ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) -ssh.connect(host, username=username, password=password, port=port, look_for_keys=True) - -""" -k = paramiko.RSAKey.from_private_key_file(keyfilename) -# OR k = paramiko.DSSKey.from_private_key_file(keyfilename) - -ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) -ssh.connect(hostname=host, username=user, pkey=k) -""" - - -cmd = "docke33r version --format '{{.Client.APIVersion}}'" -ssh_stdin, ssh_stdout, ssh_stderr = sudo_exec_command(ssh, cmd) -output_error = ssh_stderr.read().decode("utf-8").strip() -if output_error.endswith("command not found"): - ssh_stdin, ssh_stdout, ssh_stderr = sudo_exec_command(ssh, "echo ${HOME}") - home_directory = ssh_stdout.read().decode("utf-8").strip() - ftp = ssh.open_sftp() - docker_install_fname = "docker-install.sh" - ftp.put(os.path.join(os.getcwd(), docker_install_fname), os.path.join(home_directory, docker_install_fname)) - ftp.close() -else: - docker_api_version = ssh_stdout.read().decode("utf-8").strip() -ssh.close() - - -def sudo_exec_command(ssh: paramiko.SSHClient, cmd: str, password: str = None): - ssh_stdin, ssh_stdout, ssh_stderr = ssh.exec_command(cmd, get_pty=True) - if ssh_stdin.closed is False and password is not None: - ssh_stdin.write(password + '\n') - ssh_stdin.flush() - return ssh_stdin, ssh_stdout, ssh_stderr - -ssh_stdin, ssh_stdout, ssh_stderr = sudo_exec_command(ssh, f"sudo whoami", password=password) -assert ssh_stdout.readlines()[-1].strip() == "root" - - - - -client = APIClient(f'ssh://{host}:{port}', use_ssh_client=True, version=docker_api_version) -ssh_adapter = SSHAdapterPassword(f'ssh://{username}@{host}:{port}', password=password) -client.mount('http+docker://ssh', ssh_adapter) -assert client.version(api_version=False)["ApiVersion"] == docker_api_version \ No newline at end of file diff --git a/utils.py b/utils.py index 71e3994..d96d3b5 100644 --- a/utils.py +++ b/utils.py @@ -1,14 +1,12 @@ -import urllib.request -import paramiko + import json import ssl import os import time import platform -from docker import APIClient -from docker.transport import SSHHTTPAdapter + def parse_input(message: str) -> bool: answer = input(f"{message} [Y/n] ") @@ -28,30 +26,6 @@ def node_status(host: str, port: int) -> dict: return json.load(f) -def ssh_connection(host: str, username: str, password: str = None, port: int = 22) -> paramiko.SSHClient: - ssh = paramiko.SSHClient() - - ssh.load_system_host_keys() - ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) - ssh.connect(host, username=username, password=password, port=port, look_for_keys=True) - - """ - k = paramiko.RSAKey.from_private_key_file(keyfilename) - # OR k = paramiko.DSSKey.from_private_key_file(keyfilename) - - ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) - ssh.connect(hostname=host, username=user, pkey=k) - """ - - return ssh - -def sudo_exec_command(ssh: paramiko.SSHClient, cmd: str, password: str = None): - ssh_stdin, ssh_stdout, ssh_stderr = ssh.exec_command(cmd, get_pty=True) - if ssh_stdin.closed is False and password is not None: - ssh_stdin.write(password + '\n') - ssh_stdin.flush() - return ssh_stdin, ssh_stdout, ssh_stderr - class SSHAdapterPassword(SSHHTTPAdapter): def __init__(self, base_url: str, password: str): @@ -63,37 +37,6 @@ def _connect(self): self.ssh_client.connect(**self.ssh_params) -def ssh_docker(host: str, username: str, docker_api_version: str, password: str = None, port:int = 22) -> APIClient | None: - client = APIClient(f'ssh://{host}:{port}', use_ssh_client=True, version=docker_api_version) - ssh_adapter = SSHAdapterPassword(f'ssh://{username}@{host}:{port}', password=password) - client.mount('http+docker://ssh', ssh_adapter) - if client.version(api_version=False)["ApiVersion"] == docker_api_version: - return client - return None - -def ssh_read_file(ssh: paramiko.SSHClient, fpath: str) -> str: - sftp = ssh.open_sftp() - rfile = sftp.open(fpath) - content = "" - for line in rfile: - content += line - rfile.close() - sftp.close() - return content - -def ssh_get_home(ssh: paramiko.SSHClient) -> str: - ssh_stdin, ssh_stdout, ssh_stderr = ssh.exec_command("echo ${HOME}") - return ssh_stdout.read().decode("utf-8").strip() - -def ssh_put_file(ssh: paramiko.SSHClient, fpath: str) -> bool: - home_directory = ssh_get_home(ssh) - ftp = ssh.open_sftp() - fname = os.path.basename(fpath) - ftp.put(fpath, os.path.join(home_directory, fname)) - ftp.close() - return True - - def retrive_sentinelcli(): # Linux: Linux # Mac: Darwin