From 54d348c99f22e03a9d090974966b493421494b2d Mon Sep 17 00:00:00 2001 From: VetheonGames Date: Fri, 7 Jul 2023 11:25:08 -0600 Subject: [PATCH] Enhancements to Command Execution and Logging Mechanisms This commit introduces several significant enhancements to the way commands are executed and logged in the application. The changes are primarily focused on improving the robustness, reliability, and transparency of the command execution process, as well as enhancing the quality and usefulness of the log output. 1. Command Execution Enhancements: The use_sudo method has been refactored to handle commands that do not return any output. Previously, the method was designed to capture and return the output of the command being executed. However, some commands (such as modprobe) do not return any output, which caused issues with the previous implementation. The method now checks the exit status of the command to determine whether it was successful or not, and returns a success or failure message accordingly. This change improves the robustness of the command execution process and ensures that it can handle a wider range of commands. 2. Error Handling Improvements: The use_sudo method now includes more comprehensive error handling. If a command fails to execute within a specified timeout period, an error message is logged and the method returns a failure message. Additionally, if a command fails to execute for any other reason, the method logs the error and returns a failure message with the command's exit status. These changes make it easier to identify and troubleshoot issues with command execution. 3. Logging Enhancements: The logging mechanism has been enhanced to provide more detailed and useful information. The use_sudo method now logs the command being executed and its outcome (success or failure). If a command fails, the method logs the command's exit status. These changes improve the transparency of the command execution process and make it easier to identify and troubleshoot issues. 4. Code Refactoring: Several methods have been refactored for improved readability and maintainability. The use_sudo method has been refactored to reduce its complexity and improve its readability. The first_run_setup method has been refactored to ensure that the main interface name and the dummy interface name are properly passed to the setup_traffic_mirroring method. 5. Bug Fixes: A bug in the create_dummy_interface method that caused it to return an array of Alert objects instead of the dummy interface name has been fixed. The method now correctly returns the dummy interface name. These changes represent a significant improvement to the command execution and logging mechanisms in the application, and lay the groundwork for further enhancements in the future. --- bin/NETRAVE | 138 +++++++++++++++++-------------- lib/Gemfile | 2 - lib/Gemfile.lock | 64 +++++++------- lib/utils/alert_queue_manager.rb | 2 +- lib/utils/first_run_init.rb | 30 ++++--- lib/utils/logg_man.rb | 11 +++ lib/utils/networking_genie.rb | 24 ++++-- lib/utils/utilities.rb | 104 ++++++++++++++++++++--- 8 files changed, 246 insertions(+), 129 deletions(-) diff --git a/bin/NETRAVE b/bin/NETRAVE index 9bddb4e..ad78a4d 100644 --- a/bin/NETRAVE +++ b/bin/NETRAVE @@ -15,76 +15,88 @@ include Utilities # rubocop:disable Style/MixinUsage # binding.b(do: 'irb') @loggman = LoggMan.new -# Create .env file if it doesn't exist -File.open('.env', 'w') {} unless File.exist?('.env') +begin + # Create .env file if it doesn't exist + File.open('.env', 'w') {} unless File.exist?('.env') -# Load environment variables from .env file -Dotenv.load - -# Initialize AlertQueueManager, RingBuffer, and AlertManager -@alert_queue_manager = AlertQueueManager.new(@loggman) - -# Initialize DatabaseManager -db_manager = DatabaseManager.new(@loggman, @alert_queue_manager) - -# Get database details from environment variables -db_details = { - username: ENV['DB_USERNAME'], - password: ENV['DB_PASSWORD'], - key: ENV['DB_SECRET_KEY'], - database: ENV['DB_DATABASE'] -} - -# Decrypt password -dec_pass = decrypt_string_chacha20(db_details[:password], db_details[:key]) - -# If any of the necessary details are missing, run the first run setup -if db_details.values.any?(&:nil?) - @loggman.log_warn('Missing or incomplete configuration. Running first run setup.') - first_run_init = FirstRunInit.new(@loggman, @alert_queue_manager, db_manager) - first_run_init.run - # Reload environment variables after first run setup + # Load environment variables from .env file Dotenv.load + + # Initialize AlertQueueManager, RingBuffer, and AlertManager + @alert_queue_manager = AlertQueueManager.new(@loggman) + + # Initialize DatabaseManager + db_manager = DatabaseManager.new(@loggman, @alert_queue_manager) + + # Get database details from environment variables db_details = { username: ENV['DB_USERNAME'], password: ENV['DB_PASSWORD'], key: ENV['DB_SECRET_KEY'], database: ENV['DB_DATABASE'] } - # Decrypt password again after potentially updating config + + # Decrypt password dec_pass = decrypt_string_chacha20(db_details[:password], db_details[:key]) + + # If any of the necessary details are missing, run the first run setup + if db_details.values.any?(&:nil?) + @loggman.log_warn('Missing or incomplete configuration. Running first run setup.') + first_run_init = FirstRunInit.new(@loggman, @alert_queue_manager, db_manager) + first_run_init.run + # Reload environment variables after first run setup + Dotenv.load + db_details = { + username: ENV['DB_USERNAME'], + password: ENV['DB_PASSWORD'], + key: ENV['DB_SECRET_KEY'], + database: ENV['DB_DATABASE'] + } + # Decrypt password again after potentially updating config + dec_pass = decrypt_string_chacha20(db_details[:password], db_details[:key]) + end + + # Test connection + unless db_manager.test_db_connection(db_details[:username], dec_pass, db_details[:database]) + @loggman.log_warn('Failed to connect to the database with existing configuration. Please re-enter your details.') + first_run_init = FirstRunInit.new(@loggman, @alert_queue_manager, db_manager) + first_run_init.run + # Reload environment variables after potentially updating config + Dotenv.load + db_details = { + username: ENV['DB_USERNAME'], + password: ENV['DB_PASSWORD'], + key: ENV['DB_SECRET_KEY'], + database: ENV['DB_DATABASE'] + } + # Decrypt password again after potentially updating config + dec_pass = decrypt_string_chacha20(db_details[:password], db_details[:key]) + end + + # Test connection again after potentially updating config + if db_manager.test_db_connection(db_details[:username], dec_pass, db_details[:database]) + @loggman.log_info('Successfully connected to the database.') + else + @loggman.log_error('Failed to connect to the database. Please check your configuration.') + exit 1 + end + + @loggman.log_warn('Program successfully ran with no errors') + + # TODO: Add the rest of application logic here + + # End of the program + + # wait for the alert_queue_manager to block before we exit. + @alert_queue_manager.shutdown + + # Shush Rubocop. I know I shouldn't rescue an exception. I am just using it to log exceptions so the + # program doesn't crash silently +rescue Exception => e # rubocop:disable Lint/RescueException + # Log the exception + @loggman.log_error("An unhandled exception has occurred: #{e.class}: #{e.message}") + @loggman.log_error(e.backtrace.join("\n")) + + # Re-raise the original exception + raise end - -# Test connection -unless db_manager.test_db_connection(db_details[:username], dec_pass, db_details[:database]) - @loggman.log_warn('Failed to connect to the database with existing configuration. Please re-enter your details.') - first_run_init = FirstRunInit.new(@loggman, @alert_queue_manager, db_manager) - first_run_init.run - # Reload environment variables after potentially updating config - Dotenv.load - db_details = { - username: ENV['DB_USERNAME'], - password: ENV['DB_PASSWORD'], - key: ENV['DB_SECRET_KEY'], - database: ENV['DB_DATABASE'] - } - # Decrypt password again after potentially updating config - dec_pass = decrypt_string_chacha20(db_details[:password], db_details[:key]) -end - -# Test connection again after potentially updating config -if db_manager.test_db_connection(db_details[:username], dec_pass, db_details[:database]) - @loggman.log_info('Successfully connected to the database.') -else - @loggman.log_error('Failed to connect to the database. Please check your configuration.') - exit 1 -end - -@loggman.log_warn('Program successfully ran with no errors') - -# TODO: Add the rest of application logic here - -# End of the program - -# wait for the alert_queue_manager to block before we exit. -@alert_queue_manager.shutdown diff --git a/lib/Gemfile b/lib/Gemfile index 4a0537e..d00ac42 100644 --- a/lib/Gemfile +++ b/lib/Gemfile @@ -38,5 +38,3 @@ gem 'flay', '~> 2.13' gem 'pcaprub', '~> 0.13.1' gem 'packetfu', '~> 2.0' - -gem 'sudo', '~> 0.2.0' diff --git a/lib/Gemfile.lock b/lib/Gemfile.lock index d286094..19b0af1 100644 --- a/lib/Gemfile.lock +++ b/lib/Gemfile.lock @@ -2,18 +2,18 @@ GEM remote: https://rubygems.org/ specs: ast (2.4.2) - atk (4.1.7) - glib2 (= 4.1.7) + atk (4.1.8) + glib2 (= 4.1.8) backport (1.2.0) base64 (0.1.1) benchmark (0.2.1) - cairo (1.17.8) + cairo (1.17.12) native-package-installer (>= 1.0.3) pkg-config (>= 1.2.2) red-colors - cairo-gobject (4.1.7) + cairo-gobject (4.1.8) cairo (>= 1.16.2) - glib2 (= 4.1.7) + glib2 (= 4.1.8) console (1.17.2) fiber-annotation fiber-local @@ -32,23 +32,23 @@ GEM path_expander (~> 1.0) ruby_parser (~> 3.0) sexp_processor (~> 4.0) - gdk3 (4.1.7) - cairo-gobject (= 4.1.7) - gdk_pixbuf2 (= 4.1.7) - pango (= 4.1.7) - gdk_pixbuf2 (4.1.7) - gio2 (= 4.1.7) - gio2 (4.1.7) + gdk3 (4.1.8) + cairo-gobject (= 4.1.8) + gdk_pixbuf2 (= 4.1.8) + pango (= 4.1.8) + gdk_pixbuf2 (4.1.8) + gio2 (= 4.1.8) + gio2 (4.1.8) fiddle - gobject-introspection (= 4.1.7) - glib2 (4.1.7) + gobject-introspection (= 4.1.8) + glib2 (4.1.8) native-package-installer (>= 1.0.3) pkg-config (>= 1.3.5) - gobject-introspection (4.1.7) - glib2 (= 4.1.7) - gtk3 (4.1.7) - atk (= 4.1.7) - gdk3 (= 4.1.7) + gobject-introspection (4.1.8) + glib2 (= 4.1.8) + gtk3 (4.1.8) + atk (= 4.1.8) + gdk3 (= 4.1.8) jaro_winkler (1.5.6) json (2.6.3) kramdown (2.4.0) @@ -56,25 +56,26 @@ GEM kramdown-parser-gfm (1.1.0) kramdown (~> 2.0) kwalify (0.7.2) + language_server-protocol (3.17.0.3) matrix (0.4.2) mysql2 (0.5.5) - native-package-installer (1.1.5) - nokogiri (1.15.2-x86_64-linux) + native-package-installer (1.1.8) + nokogiri (1.15.3-x86_64-linux) racc (~> 1.4) openssl (3.1.0) packetfu (2.0.0) pcaprub (~> 0.13.1) - pango (4.1.7) - cairo-gobject (= 4.1.7) - gobject-introspection (= 4.1.7) + pango (4.1.8) + cairo-gobject (= 4.1.8) + gobject-introspection (= 4.1.8) parallel (1.23.0) parser (3.2.2.3) ast (~> 2.4.1) racc path_expander (1.1.1) pcaprub (0.13.1) - pkg-config (1.5.1) - racc (1.7.0) + pkg-config (1.5.2) + racc (1.7.1) rainbow (3.1.1) rbs (2.8.4) red-colors (0.3.0) @@ -83,14 +84,15 @@ GEM kwalify (~> 0.7.0) parser (~> 3.2.0) rainbow (>= 2.0, < 4.0) - regexp_parser (2.8.0) + regexp_parser (2.8.1) reverse_markdown (2.1.1) nokogiri rexml (3.2.5) - rubocop (1.52.0) + rubocop (1.54.1) json (~> 2.3) + language_server-protocol (>= 3.17.0) parallel (~> 1.10) - parser (>= 3.2.0.0) + parser (>= 3.2.2.3) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 1.8, < 3.0) rexml (>= 3.2.5, < 4.0) @@ -103,7 +105,7 @@ GEM ruby_parser (3.20.2) sexp_processor (~> 4.16) securerandom (0.2.2) - sequel (5.69.0) + sequel (5.70.0) sexp_processor (4.17.0) solargraph (0.49.0) backport (~> 1.2) @@ -121,7 +123,6 @@ GEM thor (~> 1.0) tilt (~> 2.0) yard (~> 0.9, >= 0.9.24) - sudo (0.2.0) thor (1.2.2) tilt (2.2.0) tracer (0.2.2) @@ -149,7 +150,6 @@ DEPENDENCIES securerandom (~> 0.2.2) sequel (~> 5.69) solargraph (~> 0.49.0) - sudo (~> 0.2.0) tracer (~> 0.2.2) yaml (~> 0.2.1) diff --git a/lib/utils/alert_queue_manager.rb b/lib/utils/alert_queue_manager.rb index 6af23a3..36e8cb6 100644 --- a/lib/utils/alert_queue_manager.rb +++ b/lib/utils/alert_queue_manager.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require_relative 'alert' +require_relative 'alert_manager' require_relative 'logg_man' # Class for managing the queue of alerts. This class also manages a little bit of concurrency diff --git a/lib/utils/first_run_init.rb b/lib/utils/first_run_init.rb index 958f162..a424bd2 100644 --- a/lib/utils/first_run_init.rb +++ b/lib/utils/first_run_init.rb @@ -7,6 +7,7 @@ require_relative 'database_manager' require_relative 'system_information_gather' require_relative 'utilities' require_relative 'alert_manager' +require_relative 'networking_genie' # first run class class FirstRunInit @@ -25,18 +26,18 @@ class FirstRunInit first_run_setup end - def first_run_setup # rubocop:disable Metrics/MethodLength, Metrics/AbcSize - db_details = ask_for_db_details + def first_run_setup # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + ask_for_db_details + dec_pass = decrypt_string_chacha20(ENV['DB_PASSWORD'], ENV['DB_SECRET_KEY']) + connection_established = @db_manager.test_db_connection(ENV['DB_USERNAME'], dec_pass.to_s, ENV['DB_DATABASE']) - enc_pass = ENV['DB_PASSWORD'] - enc_key = ENV['DB_SECRET_KEY'] - dec_pass = decrypt_string_chacha20(enc_pass, enc_key) - until @db_manager.test_db_connection(ENV['DB_USERNAME'], dec_pass.to_s, ENV['DB_DATABASE']) + until connection_established Curses.setpos(4, 0) - Curses.addstr("Whoops! We couldn't connect to the database with the details you provided. Please try again!") + alert = Alert.new("We couldn't connect to the database with the details you provided Please try again!", :warning) + @alert_queue_manager.enqueue_alert(alert) Curses.refresh - new_db_details = ask_for_db_details - db_details.merge!(new_db_details) # Update db_details with new details + ask_for_db_details + connection_established = @db_manager.test_db_connection(ENV['DB_USERNAME'], dec_pass.to_s, ENV['DB_DATABASE']) end @db_manager.create_system_info_table @@ -55,6 +56,15 @@ class FirstRunInit @db_manager.store_system_info(system_info) @db_manager.store_services(services) + + # ask for sudo permissions to setup the networking stuff we need + ask_for_sudo(@loggman) + + # Set up networking + networking_genie = NetworkingGenie.new(@loggman, @alert_queue_manager) + main_interface = networking_genie.find_main_interface + dummy_interface = networking_genie.create_dummy_interface('netrave0') + networking_genie.setup_traffic_mirroring(main_interface, dummy_interface) end def ask_for_db_details # rubocop:disable Metrics/MethodLength, Metrics/AbcSize @@ -64,8 +74,6 @@ class FirstRunInit Curses.setpos(1, 0) Curses.addstr('Please enter your database username: ') Curses.refresh - alert = Alert.new('This is a test alert', :error) - @alert_queue_manager.enqueue_alert(alert) username = DCI.catch_input(true) @loggman.log_info('Database Username entered!') diff --git a/lib/utils/logg_man.rb b/lib/utils/logg_man.rb index af35426..093041f 100644 --- a/lib/utils/logg_man.rb +++ b/lib/utils/logg_man.rb @@ -3,9 +3,20 @@ require 'logger' # LoggMan class for handling logs +# Yes I am aware that this is a strange thing to do, wrap the stdlib Logger like this... +# I like to call my logger with LoggMan, don't judge me. class LoggMan def initialize @logger = Logger.new('netrave.log') + @logger.formatter = proc do |severity, datetime, _progname, msg| + date_format = datetime.strftime('%Y-%m-%d %H:%M:%S') + if msg.is_a?(Exception) + backtrace = msg.backtrace.map { |line| "\n\t#{line}" }.join + "[#{severity}] (#{date_format}) #{msg.message} (#{msg.class})#{backtrace}" + else + "[#{severity}] (#{date_format}) #{msg}\n" + end + end end def log_info(message) diff --git a/lib/utils/networking_genie.rb b/lib/utils/networking_genie.rb index 9597410..ea0e1bf 100644 --- a/lib/utils/networking_genie.rb +++ b/lib/utils/networking_genie.rb @@ -3,12 +3,13 @@ require 'English' require 'socket' require_relative 'logg_man' -require_relative 'alert' -require_relative 'alert_queue_manager' +require_relative 'alert_manager' # The class for setting up all the necessary system networking stuff for NETRAVE to work with without # interferring with the rest of the system class NetworkingGenie + attr_accessor :main_interface, :dummy_interface + include Utilities def initialize(logger, alert_queue_manager) @@ -17,12 +18,16 @@ class NetworkingGenie end def find_main_interface # rubocop:disable Metrics/MethodLength + alert = Alert.new('Identifying the main network interface...', :info) + @alert_queue_manager.enqueue_alert(alert) @loggman.log_info('Identifying main network interface...') route_info = `routel`.split("\n") default_route = route_info.find { |line| line.include?('default') } if default_route main_interface = default_route.split.last - @loggman.log_info("Main network interface identified: #{main_interface}") + @loggman.log_info("Main network interface identified as: #{main_interface}") + alert = Alert.new("Main network interface identified as: #{main_interface}", :info) + @alert_queue_manager.enqueue_alert(alert) main_interface else @loggman.log_error('Failed to identify main network interface.') @@ -33,7 +38,9 @@ class NetworkingGenie nil end - def create_dummy_interface(interface_name = 'dummy0') + def create_dummy_interface(interface_name = 'netrave0') # rubocop:disable Metrics/MethodLength + alert = Alert.new('Creating the NETRAVE dummy interface...', :info) + @alert_queue_manager.enqueue_alert(alert) # Check if the dummy module is loaded use_sudo('modprobe dummy') @@ -41,7 +48,7 @@ class NetworkingGenie if `ip link show #{interface_name}`.empty? # Create the dummy interface use_sudo("ip link add #{interface_name} type dummy") - + @dummy_interface = interface_name # Set the interface up use_sudo("ip link set #{interface_name} up") else @@ -49,14 +56,17 @@ class NetworkingGenie alert = Alert.new("Interface #{interface_name} already exists.", :info) @alert_queue_manager.enqueue_alert(alert) end + + # Return the name of the dummy interface + interface_name end def setup_traffic_mirroring(main_interface, dummy_interface) # rubocop:disable Metrics/MethodLength commands = [ "tc qdisc del dev #{main_interface} ingress", "tc qdisc add dev #{main_interface} handle ffff: ingress", - "tc filter add dev #{main_interface} parent ffff: u32 match - u32 0 0 action mirred egress mirror dev #{dummy_interface}" + "tc filter add dev #{main_interface} parent ffff: u32 match " \ + "u32 0 0 action mirred egress mirror dev #{dummy_interface}" ] begin diff --git a/lib/utils/utilities.rb b/lib/utils/utilities.rb index c205b46..6362490 100644 --- a/lib/utils/utilities.rb +++ b/lib/utils/utilities.rb @@ -1,13 +1,15 @@ # frozen_string_literal: true +require 'English' require 'securerandom' require 'digest' require 'base64' require 'openssl' -require 'sudo' +require 'pty' +require 'expect' # Utiltiies Module -module Utilities +module Utilities # rubocop:disable Metrics/ModuleLength # Converts speed from Gbps to Mbps if necessary def convert_speed_to_mbps(speed) return nil unless speed.is_a?(String) && speed.downcase.match?(/\A\d+(gbps|mbps)\z/i) @@ -75,17 +77,53 @@ module Utilities nil end - def ask_for_sudo(logger) + def ask_for_sudo(logger) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize @loggman = logger - @loggman.log_info('Asking for sudo password... (This log entry will be removed)') - Curses.addstr('Please enter your sudo password: ') + @loggman.log_info('Asking for sudo and explaining why...') + lines = [ + 'We require sudo permissions to complete certain steps.', + 'Granting a program sudo access is a significant decision.', + 'We treat your sudo password with the utmost care.', + 'We handle it "As a Bomb".', + '', + 'As soon as we receive your sudo password, it is encrypted.', + 'The unencrypted password is then wiped from the system.', + 'This includes the sudo cache.', + 'When we need to use your sudo password, we decrypt it.', + 'We use it, and then immediately wipe it again.', + 'We only ever store the encrypted version of your password.', + 'We delete even that as soon as we finish the operations.', + 'However, even with these precautions, there is always a risk.', + 'If an attacker were to gain access to this program,', + 'they could potentially decrypt your password.', + 'Therefore, you should only enter your sudo password', + 'if you fully understand and accept these risks.', + '', + '', + 'Please enter your sudo password ONLY if you understand', + 'and accept the risks described above: ' + ] + Curses.clear + lines.each_with_index do |line, index| + Curses.setpos(index, 0) # Move the cursor to the beginning of the next line + Curses.addstr(line) + end # use the dynamic curses input gem in secure mode to collect the sudo password sudo_password = DCI.catch_input(false) @loggman.log_info('Sudo password received. (This log entry will be removed)') + @loggman.log_info("Entered sudo password: #{sudo_password}") + + # Generate a new secret key + key = SecureRandom.random_bytes(32) # generates a random string of 32 bytes + + # encode the key for storage + @secret_key = Base64.encode64(key) # Encrypt the sudo password right away and store it in an environment variable encrypted_sudo_password = encrypt_string_chacha20(sudo_password, @secret_key) + @loggman.log_info("Encrypted sudo password: #{encrypted_sudo_password}") ENV['SPW'] = encrypted_sudo_password + ENV['SDSECRET_KEY'] = @secret_key # Clear the unencrypted sudo password from memory sudo_password.replace(' ' * sudo_password.length) @@ -96,7 +134,7 @@ module Utilities use_sudo('ls') true - rescue Sudo::Wrapper::InvalidPassword + rescue PTY::ChildExited false end @@ -104,32 +142,72 @@ module Utilities # Retrieve the encrypted sudo password from the environment variable encrypted_sudo_password = ENV['SPW'] + # Retrieve the secret key from the environment variable + @secret_key = ENV['SDSECRET_KEY'] + # Decrypt the sudo password sudo_password = decrypt_string_chacha20(encrypted_sudo_password, @secret_key) # Invalidate the user's cached credentials - Sudo::Wrapper.run('sudo -k', password: sudo_password) + PTY.spawn('sudo -S -k') do |r, w, _pid| + w.sync = true + r.expect(/password/) { w.puts sudo_password } + end # Clear the sudo password from memory sudo_password.replace(' ' * sudo_password.length) - # Remove the encrypted sudo password from the environment variables + # Remove the encrypted sudo password and the secret key from the environment variables ENV.delete('SPW') + ENV.delete('SECRET_KEY') end - def use_sudo(command) + def use_sudo(command) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize + @secret_key = ENV['SDSECRET_KEY'] # Retrieve the encrypted sudo password from the environment variable encrypted_sudo_password = ENV['SPW'] # Decrypt the sudo password sudo_password = decrypt_string_chacha20(encrypted_sudo_password, @secret_key) + # this is only here for debugging + @loggman.log_info("Decrypted sudo password: #{sudo_password}") + + # Log the command + @loggman.log_info("Running command: #{command}") + # Use the sudo password to run the command - result = Sudo::Wrapper.run(command, password: sudo_password) + exit_status = nil + begin + Timeout.timeout(60) do + PTY.spawn("sudo -S #{command} 2>&1") do |r, w, pid| + w.sync = true + r.expect(/password/) { w.puts sudo_password } + while IO.select([r], nil, nil, 0.1) + output = r.read_nonblock(1000) + output.each_line { |line| @loggman.log_info("Command output: #{line.strip}") } + end + begin + Process.wait(pid) + exit_status = $CHILD_STATUS.exitstatus + ensure + # Clear the sudo password from memory + sudo_password.replace(' ' * sudo_password.length) + end + end + end + rescue Timeout::Error + @loggman.log_error("Command '#{command}' timed out") + rescue Errno::EIO + # This error is expected when the process has finished + end - # Clear the sudo password from memory - sudo_password.replace(' ' * sudo_password.length) + if exit_status&.zero? + @loggman.log_info("Command '#{command}' completed successfully") + else + @loggman.log_error("Command '#{command}' failed with exit status #{exit_status}") + end - result + exit_status end end