From 71297988098bd81029b32604834d0eb3d1bb085f Mon Sep 17 00:00:00 2001 From: Micky <60691199+AliMickey@users.noreply.github.com> Date: Sun, 22 Dec 2024 21:33:36 +1100 Subject: [PATCH] initial upload --- app/__init__.py | 14 ++ app/functions/utils.py | 125 +++++++++++++++ app/requirements.txt | 5 + app/static/css/input.css | 3 + app/static/images/default-favicon.svg | 15 ++ app/static/images/default-logo-dark.svg | 5 + app/static/images/default-logo-light.svg | 5 + app/static/js/main.js | 168 ++++++++++++++++++++ app/tailwind.config.js | 35 ++++ app/templates/base.html | 50 ++++++ app/templates/footer.html | 194 +++++++++++++++++++++++ app/templates/index.html | 162 +++++++++++++++++++ app/views/main.py | 114 +++++++++++++ 13 files changed, 895 insertions(+) create mode 100644 app/__init__.py create mode 100644 app/functions/utils.py create mode 100644 app/requirements.txt create mode 100644 app/static/css/input.css create mode 100644 app/static/images/default-favicon.svg create mode 100644 app/static/images/default-logo-dark.svg create mode 100644 app/static/images/default-logo-light.svg create mode 100644 app/static/js/main.js create mode 100644 app/tailwind.config.js create mode 100644 app/templates/base.html create mode 100644 app/templates/footer.html create mode 100644 app/templates/index.html create mode 100644 app/views/main.py diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..77aa092 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,14 @@ +from flask import Flask +import logging, os + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +def create_app(): + app = Flask(__name__, instance_path="/instance") + + from app.views import main + app.register_blueprint(main.bp) + app.add_url_rule('/', endpoint='index') + + return app \ No newline at end of file diff --git a/app/functions/utils.py b/app/functions/utils.py new file mode 100644 index 0000000..f2a3834 --- /dev/null +++ b/app/functions/utils.py @@ -0,0 +1,125 @@ +import yaml +import os +import logging +from functools import wraps +from netmiko import ConnectHandler, NetmikoTimeoutException, NetmikoAuthenticationException + +logger = logging.getLogger(__name__) + +# Exception handler +def exception_handler(func): + @wraps(func) + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except Exception as e: + logging.exception(f"Exception occurred in {func.__name__}") + return "An error occurred", 500 + return wrapper + +@exception_handler +def load_yaml(filename): + if not filename.endswith('.yaml'): + filename = f"{filename}.yaml" + + config_path = os.path.join("/instance", filename) + + if not os.path.exists(config_path): + logger.error(f"Configuration file not found: {config_path}") + return {} + + try: + with open(config_path, 'r') as f: + data = yaml.safe_load(f) + if data is None: + logger.warning(f"Empty configuration file: {filename}") + return {} + return data + except yaml.YAMLError as e: + logger.error(f"YAML parsing error in {filename}: {e}") + return {} + except Exception as e: + logger.error(f"Unexpected error loading {filename}: {e}") + return {} + + +def establish_connection(device_config): + try: + return ConnectHandler(**device_config) + except Exception as e: + logger.error(f"Failed to establish connection to {device_config['host']}: {e}") + raise + +def execute_command(device, command_config, target): + device_config = { + 'device_type': device['type'], + 'host': device['host'], + 'username': device['username'], + 'password': device['password'], + 'port': device.get('port', 22), + 'timeout': device.get('timeout', 10), + 'session_timeout': device.get('session_timeout', 60), + 'conn_timeout': device.get('conn_timeout', 10), + 'auth_timeout': device.get('auth_timeout', 10), + } + + command_timeout = device.get('command_timeout', 30) + + try: + with establish_connection(device_config) as connection: + # Format the command + command = str(command_config.get('format').format(target=target).strip()) + + # Execute the command + output = connection.send_command( + command, + read_timeout=command_timeout, + strip_command=True, + strip_prompt=True, + max_loops=int(command_timeout * 10) + ) + + # Clean output + output = output.strip() if output else "" + logger.debug(f"Command output: {output}") + + if not output: + return { + 'raw_output': "No output received from device", + 'structured_data': None, + 'error': True, + 'error_type': 'no_output' + } + + return { + 'raw_output': output, + 'error': False + } + + except NetmikoTimeoutException as e: + error_msg = f"Timeout error on {device['host']}: {e}" + logger.error(error_msg) + return { + 'raw_output': error_msg, + 'structured_data': None, + 'error': True, + 'error_type': 'timeout' + } + except NetmikoAuthenticationException as e: + error_msg = f"Authentication failed for {device['host']}: {e}" + logger.error(error_msg) + return { + 'raw_output': error_msg, + 'structured_data': None, + 'error': True, + 'error_type': 'auth' + } + except Exception as e: + error_msg = f"An unexpected error occurred on {device['host']}: {e}" + logger.error(error_msg) + return { + 'raw_output': error_msg, + 'structured_data': None, + 'error': True, + 'error_type': 'connection' + } diff --git a/app/requirements.txt b/app/requirements.txt new file mode 100644 index 0000000..9e77704 --- /dev/null +++ b/app/requirements.txt @@ -0,0 +1,5 @@ +flask==3.1.0 +pyyaml==6.0.2 +netmiko==4.5.0 +werkzeug==3.1.3 +gunicorn==23.0.0 diff --git a/app/static/css/input.css b/app/static/css/input.css new file mode 100644 index 0000000..bd6213e --- /dev/null +++ b/app/static/css/input.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; \ No newline at end of file diff --git a/app/static/images/default-favicon.svg b/app/static/images/default-favicon.svg new file mode 100644 index 0000000..eb2d392 --- /dev/null +++ b/app/static/images/default-favicon.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/static/images/default-logo-dark.svg b/app/static/images/default-logo-dark.svg new file mode 100644 index 0000000..899ddac --- /dev/null +++ b/app/static/images/default-logo-dark.svg @@ -0,0 +1,5 @@ + + + photonglass + + diff --git a/app/static/images/default-logo-light.svg b/app/static/images/default-logo-light.svg new file mode 100644 index 0000000..6e2f655 --- /dev/null +++ b/app/static/images/default-logo-light.svg @@ -0,0 +1,5 @@ + + + photonglass + + diff --git a/app/static/js/main.js b/app/static/js/main.js new file mode 100644 index 0000000..23f65f2 --- /dev/null +++ b/app/static/js/main.js @@ -0,0 +1,168 @@ +const app = Vue.createApp({ + delimiters: ['${', '}'], + data() { + return { + selectedDevice: '', + selectedCommand: '', + targetIp: '', + isLoading: false, + isLoadingIp: false, + commandResult: '', + devices: window.initialData?.devices || [], + commands: window.initialData?.commands || [], + currentCommand: null, + showHelp: false, + showTerms: false, + isOpen: false, + isDark: localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches), + site_config: window.initialData?.site_config || {}, + peeringdbUrl: window.initialData?.site_config?.footer?.external_links?.peeringdb || '', + githubUrl: window.initialData?.site_config?.footer?.external_links?.github || '' + } + }, + mounted() { + this.updateThemeClass(); + // Make sure commands are loaded + if (window.initialData?.commands) { + console.log('Commands loaded:', this.commands); + } + }, + watch: { + selectedCommand: { + handler(newVal) { + this.currentCommand = this.commands.find(cmd => cmd.id === newVal); + console.log("Current command updated:", this.currentCommand); + }, + immediate: true + } + }, + computed: { + isValidInput() { + if (!this.currentCommand || !this.targetIp) return false; + if (this.currentCommand.field?.validation) { + const pattern = new RegExp(this.currentCommand.field.validation); + return pattern.test(this.targetIp); + } + return true; + } + }, + methods: { + async getMyIp() { + if (this.isLoadingIp) return; + this.isLoadingIp = true; + try { + const response = await fetch('/my-ip'); + const data = await response.json(); + if (response.ok && data.ip) { + this.targetIp = data.ip; + } else { + console.error('Failed to get IP address'); + } + } catch (error) { + console.error('Error fetching IP:', error); + } finally { + this.isLoadingIp = false; + } + }, + toggleTheme() { + this.isDark = !this.isDark; + localStorage.theme = this.isDark ? 'dark' : 'light'; + this.updateThemeClass(); + }, + updateThemeClass() { + if (this.isDark) { + document.documentElement.classList.add('dark'); + } else { + document.documentElement.classList.remove('dark'); + } + }, + toggleDevice(device) { + // Update the selected device state + const wasDeselected = this.selectedDevice === device; + this.selectedDevice = wasDeselected ? '' : device; + + // Only reset states if we're deselecting the device entirely + if (wasDeselected) { + this.selectedCommand = ''; + this.targetIp = ''; + this.commandResult = ''; + } + + // Force a re-render to ensure styling is updated + this.$nextTick(() => { + this.$forceUpdate(); + }); + }, + async executeCommand() { + if (!this.isValidInput) { + this.commandResult = '❌ Error: Please enter a valid input according to the command requirements'; + return; + } + + this.isLoading = true; + this.commandResult = ''; + + try { + const response = await fetch('/execute', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + device: this.selectedDevice, + command: this.selectedCommand, + target: this.targetIp + }) + }); + + const data = await response.json(); + + if (!response.ok) { + this.commandResult = `❌ Error: ${data.message || 'An unknown error occurred'}`; + return; + } + + if (data.error) { + // Handle error responses with specific error types + const errorPrefix = data.error_type === 'timeout' ? '🕒 Timeout Error: ' : + data.error_type === 'auth' ? '🔒 Authentication Error: ' : + data.error_type === 'connection' ? '🔌 Connection Error: ' : + data.error_type === 'no_output' ? '📭 No Output Error: ' : + '❌ Error: '; + this.commandResult = errorPrefix + data.message; + return; + } + + // Handle successful response + if (data.result) { + this.commandResult = data.result; + } else { + this.commandResult = '❌ Error: No output received from command'; + } + } catch (error) { + console.error('Command execution error:', error); + this.commandResult = '❌ Error: Failed to execute command. Please try again.'; + } finally { + this.isLoading = false; + } + }, + toggleDropdown(event) { + this.isOpen = !this.isOpen; + }, + closeDropdown(event) { + // Only close if clicking outside the dropdown + if (!event.target.closest('.relative')) { + this.isOpen = false; + } + }, + selectCommand(command) { + this.selectedCommand = command; + // Add a small delay before closing to ensure the selection is visible + setTimeout(() => { + this.isOpen = false; + }, 100); + } + } +}); + +app.mount('#app'); \ No newline at end of file diff --git a/app/tailwind.config.js b/app/tailwind.config.js new file mode 100644 index 0000000..e3c0101 --- /dev/null +++ b/app/tailwind.config.js @@ -0,0 +1,35 @@ +module.exports = { + content: [ + "./templates/**/*.html", + "./static/**/*.js" + ], + darkMode: 'class', + theme: { + extend: { + colors: { + primary: { + DEFAULT: '#4a5568' + }, + dark: { + DEFAULT: '#121212', + 100: '#1E1E1E', + 200: '#2D2D2D', + 300: '#3D3D3D', + 400: '#4D4D4D', + 500: '#5C5C5C' + } + }, + fontSize: { + 'base': '1.125rem', + 'sm': '1rem', + 'lg': '1.25rem', + 'xl': '1.375rem', + '2xl': '1.5rem', + '3xl': '1.875rem', + '4xl': '2.25rem', + '5xl': '3rem', + } + }, + }, + plugins: [], + } \ No newline at end of file diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..ec435bd --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,50 @@ + + + + + + + + {{ config.title }} + + + + + + + +
+
+ + +
+ {% block content %}{% endblock %} +
+
+ + {% include 'footer.html' %} + +
+ + + + diff --git a/app/templates/footer.html b/app/templates/footer.html new file mode 100644 index 0000000..657a6d8 --- /dev/null +++ b/app/templates/footer.html @@ -0,0 +1,194 @@ +{% block footer %} + + + + +
+
+
+

+ Terms of Service +

+ +
+
+

+ By using this Looking Glass service, you agree + to: +

+
    +
  • + Use the service responsibly and not for any + malicious purposes +
  • +
  • + Not attempt to overload or disrupt the + service +
  • +
  • + Respect the privacy and security measures in + place +
  • +
+
+
+
+
+ + + +
+
+ +
+

+ Available Commands +

+ +
+ +
+

+ The following network diagnostic commands are + available: +

+
+

+ +

+
+

+
+
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..29069f5 --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,162 @@ +{% extends "base.html" %} + +{% block content %} +
+ +
+
+
+ {% for device in devices %} + + {% endfor %} +
+
+ + + +
+
+
+ +
+ + +
+ {% for command in commands %} +
+
+ {{ command.display_name }} +
+ + + + + +
+ {% endfor %} +
+
+
+ + + +
+ + +
+ + +
+ + + + + +
+
+
+
+
+ + +
+

Results

+
${commandResult}
+
+
+{% endblock %} \ No newline at end of file diff --git a/app/views/main.py b/app/views/main.py new file mode 100644 index 0000000..dc6a353 --- /dev/null +++ b/app/views/main.py @@ -0,0 +1,114 @@ +from flask import ( + Blueprint, request, jsonify, render_template +) +import logging + +from app.functions.utils import load_yaml, execute_command, exception_handler + +logger = logging.getLogger(__name__) + +bp = Blueprint('main', __name__) + +@bp.route('/') +@exception_handler +def index(): + config = load_yaml('config') + devices = load_yaml('devices') + commands = load_yaml('commands') + + for device in devices: + device.pop('username', None) + device.pop('password', None) + device.pop('host', None) + device.pop('port', None) + + return render_template('index.html', config=config, devices=devices, commands=commands) + + +@bp.route('/my-ip', methods=['GET']) +@exception_handler +def get_my_ip(): + # Check headers in order of reliability + client_ip = None + + forwarded_for = request.headers.get('X-Forwarded-For') + if forwarded_for: + client_ip = forwarded_for.split(',')[0].strip() + + if not client_ip: + real_ip = request.headers.get('X-Real-IP') + if real_ip: + client_ip = real_ip + + if not client_ip: + client_ip = request.remote_addr + + return jsonify({'ip': client_ip}) + + +@bp.route('/execute', methods=['POST']) +@exception_handler +def execute(): + data = request.get_json() + device = data.get('device') + command = data.get('command') + target = data.get('target') + + if not all([device, command, target]): + raise Exception("Missing required parameters") + + # Load configurations + devices = load_yaml('devices') + commands = load_yaml('commands') + + # Find device and command configurations + device = next((d for d in devices if d['id'] == device), None) + command = next((c for c in commands if c['id'] == command), None) + + if not device or not command: + raise Exception("Device or command not found") + + # Verify command is allowed for this device + if command['id'] not in device.get('commands', []): + raise Exception("Command not allowed for this device") + + try: + # Execute the command using network_utils + result = execute_command(device, command, target) + + if not result: + error_msg = 'No response from command execution' + logger.error(error_msg) + return jsonify({ + 'error': True, + 'message': error_msg, + 'error_type': 'no_response' + }) + + # Check for error state + if result.get('error', False): + error_msg = result['raw_output'] + logger.error(f"Command execution failed: {error_msg}") + return jsonify({ + 'error': True, + 'message': error_msg, + 'error_type': result.get('error_type', 'general') + }) + + # Log successful execution + logger.info(f"Successfully executed command {command} on {device}") + + # Return successful result + return jsonify({ + 'error': False, + 'result': result['raw_output'], + 'structured_data': result.get('structured_data') + }) + + except Exception as e: + logger.error(f"Unexpected error during command execution: {str(e)}") + return jsonify({ + 'error': True, + 'message': f"An unexpected error occurred: {str(e)}", + 'error_type': 'general' + })