From b9a404755f3acc19b351d1ed226acf38e2c64238 Mon Sep 17 00:00:00 2001 From: Marek Hulan Date: Fri, 4 Jul 2025 16:53:44 +0200 Subject: [PATCH] Fixes #2736 - Implement Wake on LAN (WOL) feature This patch introduces support for Wake on LAN. It introduces a new authenticated API endpoint, that requires MAC address. It validates the MAC address and if everything is correct, it creates and sends the magic UDP broadcast packet, instructing the machine with this MAC address to wake up. While it is likely that the WOL functionality will live in the same network where DHCP feature is, I didn't want to create a dependency between the two. WOL should be possible even with DHCP feature disabled. I also didn't want mix it into BMC module. While both modules work with power state, BMC can do the full cycle (power on and off), WOL can only wake up suspended machines, it's not full baseboard management controller. --- config/settings.d/wol.yml.example | 7 + lib/smart_proxy_main.rb | 1 + modules/wol/http_config.ru | 5 + modules/wol/wol.rb | 1 + modules/wol/wol_api.rb | 41 +++++ modules/wol/wol_packet_sender.rb | 31 ++++ modules/wol/wol_plugin.rb | 10 ++ test/wol/wol_api_test.rb | 251 +++++++++++++++++++++++++++++ test/wol/wol_packet_sender_test.rb | 133 +++++++++++++++ 9 files changed, 480 insertions(+) create mode 100644 config/settings.d/wol.yml.example create mode 100644 modules/wol/http_config.ru create mode 100644 modules/wol/wol.rb create mode 100644 modules/wol/wol_api.rb create mode 100644 modules/wol/wol_packet_sender.rb create mode 100644 modules/wol/wol_plugin.rb create mode 100644 test/wol/wol_api_test.rb create mode 100644 test/wol/wol_packet_sender_test.rb diff --git a/config/settings.d/wol.yml.example b/config/settings.d/wol.yml.example new file mode 100644 index 000000000..1a88ec34d --- /dev/null +++ b/config/settings.d/wol.yml.example @@ -0,0 +1,7 @@ +--- +# Can be true, false, or http/https to enable just one of the protocols +:enabled: false + +# Wake-on-LAN configuration +# The module sends standard WoL magic packets via UDP broadcast on port 9 +# No additional configuration is required as it uses the standard protocol diff --git a/lib/smart_proxy_main.rb b/lib/smart_proxy_main.rb index 581a07c6e..79f268bde 100644 --- a/lib/smart_proxy_main.rb +++ b/lib/smart_proxy_main.rb @@ -74,6 +74,7 @@ module Proxy require 'logs/logs' require 'httpboot/httpboot' require 'registration/registration' + require 'wol/wol' def self.version {:version => VERSION} diff --git a/modules/wol/http_config.ru b/modules/wol/http_config.ru new file mode 100644 index 000000000..98384bbc0 --- /dev/null +++ b/modules/wol/http_config.ru @@ -0,0 +1,5 @@ +require 'wol/wol_api' + +map "/wol" do + run Proxy::WolApi +end diff --git a/modules/wol/wol.rb b/modules/wol/wol.rb new file mode 100644 index 000000000..4cbe98cd8 --- /dev/null +++ b/modules/wol/wol.rb @@ -0,0 +1 @@ +require 'wol/wol_plugin' diff --git a/modules/wol/wol_api.rb b/modules/wol/wol_api.rb new file mode 100644 index 000000000..677490428 --- /dev/null +++ b/modules/wol/wol_api.rb @@ -0,0 +1,41 @@ +require 'proxy/validations' +require 'wol/wol_packet_sender' + +class Proxy::WolApi < Sinatra::Base + include Proxy::Validations + helpers ::Proxy::Helpers + authorize_with_trusted_hosts + authorize_with_ssl_client + + post "/" do + content_type :json + + # Parse JSON body and merge with URL parameters + body_params = parse_json_body + all_params = params.merge(body_params) + + # Get MAC address from either URL params or JSON body + mac_address = all_params[:mac_address] || all_params['mac_address'] + + logger.debug "WoL API - Final MAC address: #{mac_address}" + + begin + mac_address = validate_mac(mac_address) + rescue Proxy::Validations::InvalidMACAddress => e + log_halt 400, "Invalid MAC address provided: #{e.message}" + end + + # Send Wake-on-LAN magic packet + begin + Proxy::Wol::WolPacketSender.send_magic_packet(mac_address) + + # Log the attempt + logger.info "Wake-on-LAN packet sent to MAC address: #{mac_address}" + + { :status => "success", :message => "Wake-on-LAN packet sent successfully", :mac_address => mac_address }.to_json + rescue => e + logger.error "Failed to send Wake-on-LAN packet to #{mac_address}: #{e.message}" + log_halt 500, "Failed to send Wake-on-LAN packet: #{e.message}" + end + end +end diff --git a/modules/wol/wol_packet_sender.rb b/modules/wol/wol_packet_sender.rb new file mode 100644 index 000000000..0bf70796f --- /dev/null +++ b/modules/wol/wol_packet_sender.rb @@ -0,0 +1,31 @@ +require 'socket' + +module Proxy + module Wol + class WolPacketSender + # Sends a Wake-on-LAN magic packet to the specified MAC address + def self.send_magic_packet(mac_address) + # Create magic packet using the existing method + packet = create_magic_packet(mac_address) + + # Send UDP broadcast on port 9 (WoL standard port) + socket = UDPSocket.new + socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_BROADCAST, true) + socket.send(packet, 0, '255.255.255.255', 9) + socket.close + end + + # Creates a magic packet for the given MAC address (useful for testing) + def self.create_magic_packet(mac_address) + # Clean up MAC address and convert to binary + mac_bytes = mac_address.gsub(/[:-]/, '').scan(/../).map { |hex| hex.to_i(16) } + + # Create magic packet: 6 bytes of 0xFF followed by 16 repetitions of MAC address + magic_packet = [0xFF] * 6 + mac_bytes * 16 + + # Convert to binary string + magic_packet.pack('C*') + end + end + end +end diff --git a/modules/wol/wol_plugin.rb b/modules/wol/wol_plugin.rb new file mode 100644 index 000000000..e6f076f15 --- /dev/null +++ b/modules/wol/wol_plugin.rb @@ -0,0 +1,10 @@ +class ::Proxy::WolPlugin < ::Proxy::Plugin + rackup_path File.expand_path("http_config.ru", __dir__) + + plugin :wol, ::Proxy::VERSION + default_settings :enabled => true + + after_activation do + logger.debug "Wake-on-LAN plugin initialized" + end +end diff --git a/test/wol/wol_api_test.rb b/test/wol/wol_api_test.rb new file mode 100644 index 000000000..ca2e2ab4b --- /dev/null +++ b/test/wol/wol_api_test.rb @@ -0,0 +1,251 @@ +require File.join(__dir__, '..', 'test_helper') +require 'json' +require 'wol/wol_api' + +ENV['RACK_ENV'] = 'test' + +class WolApiTest < Test::Unit::TestCase + include Rack::Test::Methods + + def app + Proxy::WolApi.new + end + + def setup + # Mock UDPSocket to prevent actual network packets during tests + @mock_socket = mock('UDPSocket') + UDPSocket.stubs(:new).returns(@mock_socket) + @mock_socket.stubs(:setsockopt) + @mock_socket.stubs(:send) + @mock_socket.stubs(:close) + + # By default, stub the packet sender to prevent actual network calls + # Individual tests can override this if they need to test socket operations + stub_packet_sender + end + + def test_valid_mac_address_with_colons + mac = "54:ee:75:87:1f:fb" + + post "/", :mac_address => mac + + assert last_response.ok?, "Last response was not ok: #{last_response.status} #{last_response.body}" + data = JSON.parse(last_response.body) + assert_equal "success", data["status"] + assert_equal "Wake-on-LAN packet sent successfully", data["message"] + assert_equal mac, data["mac_address"] + end + + def test_valid_mac_address_uppercase + mac = "AA:BB:CC:DD:EE:FF" + + post "/", :mac_address => mac + + assert last_response.ok?, "Last response was not ok: #{last_response.status} #{last_response.body}" + data = JSON.parse(last_response.body) + assert_equal "success", data["status"] + # The validation system normalizes MAC addresses to lowercase + assert_equal mac.downcase, data["mac_address"] + end + + def test_valid_mac_address_lowercase + mac = "aa:bb:cc:dd:ee:ff" + + post "/", :mac_address => mac + + assert last_response.ok?, "Last response was not ok: #{last_response.status} #{last_response.body}" + data = JSON.parse(last_response.body) + assert_equal "success", data["status"] + assert_equal mac, data["mac_address"] + end + + def test_valid_mac_address_mixed_case + mac = "Ab:Cd:Ef:12:34:56" + + post "/", :mac_address => mac + + assert last_response.ok?, "Last response was not ok: #{last_response.status} #{last_response.body}" + data = JSON.parse(last_response.body) + assert_equal "success", data["status"] + # The validation system normalizes MAC addresses to lowercase + assert_equal mac.downcase, data["mac_address"] + end + + def test_json_request_with_valid_mac + mac = "54:ee:75:87:1f:fb" + + post "/", { mac_address: mac }.to_json, "CONTENT_TYPE" => "application/json" + + assert last_response.ok?, "Last response was not ok: #{last_response.status} #{last_response.body}" + data = JSON.parse(last_response.body) + assert_equal "success", data["status"] + # MAC addresses are normalized to lowercase + assert_equal mac.downcase, data["mac_address"] + end + + def test_json_request_with_charset + mac = "54:ee:75:87:1f:fb" + + post "/", { mac_address: mac }.to_json, "CONTENT_TYPE" => "application/json; charset=utf-8" + + assert last_response.ok?, "Last response was not ok: #{last_response.status} #{last_response.body}" + data = JSON.parse(last_response.body) + assert_equal "success", data["status"] + # MAC addresses are normalized to lowercase + assert_equal mac.downcase, data["mac_address"] + end + + def test_missing_mac_address + post "/" + + assert_equal 400, last_response.status + assert_match(/Invalid MAC address provided/, last_response.body) + end + + def test_empty_mac_address + post "/", :mac_address => "" + + assert_equal 400, last_response.status + assert_match(/Invalid MAC address provided/, last_response.body) + end + + def test_nil_mac_address + post "/", :mac_address => nil + + assert_equal 400, last_response.status + assert_match(/Invalid MAC address provided/, last_response.body) + end + + def test_invalid_mac_address_too_short + post "/", :mac_address => "54:ee:75:87:1f" + + assert_equal 400, last_response.status + assert_match(/Invalid MAC address provided/, last_response.body) + end + + def test_invalid_mac_address_too_long + post "/", :mac_address => "54:ee:75:87:1f:fb:00" + + assert_equal 400, last_response.status + assert_match(/Invalid MAC address provided/, last_response.body) + end + + def test_invalid_mac_address_invalid_characters + post "/", :mac_address => "54:ee:75:87:1g:fb" + + assert_equal 400, last_response.status + assert_match(/Invalid MAC address provided/, last_response.body) + end + + def test_invalid_mac_address_wrong_format + post "/", :mac_address => "54ee75871ffb" + + assert_equal 400, last_response.status + assert_match(/Invalid MAC address provided/, last_response.body) + end + + def test_invalid_json_body + post "/", "{ invalid json", "CONTENT_TYPE" => "application/json" + + assert_equal 415, last_response.status + assert_match(/Invalid JSON content in body of request/, last_response.body) + end + + def test_json_with_invalid_data_type + post "/", "\"not a hash\"", "CONTENT_TYPE" => "application/json" + + assert_equal 415, last_response.status + assert_match(/Invalid JSON content in body of request/, last_response.body) + end + + def test_empty_json_body + post "/", "", "CONTENT_TYPE" => "application/json" + + assert_equal 415, last_response.status + assert_match(/Invalid JSON content in body of request/, last_response.body) + end + + def test_socket_creation_error + unstub_packet_sender + Proxy::Wol::WolPacketSender.stubs(:send_magic_packet).raises(StandardError.new("Socket creation failed")) + + post "/", :mac_address => "54:ee:75:87:1f:fb" + + assert_equal 500, last_response.status + assert_match(/Failed to send Wake-on-LAN packet/, last_response.body) + end + + def test_socket_send_error + unstub_packet_sender + Proxy::Wol::WolPacketSender.stubs(:send_magic_packet).raises(StandardError.new("Network unreachable")) + + post "/", :mac_address => "54:ee:75:87:1f:fb" + + assert_equal 500, last_response.status + assert_match(/Failed to send Wake-on-LAN packet/, last_response.body) + end + + def test_response_content_type_is_json + post "/", :mac_address => "54:ee:75:87:1f:fb" + + assert last_response.ok? + assert_match(%r{application/json}, last_response.content_type) + end + + def test_magic_packet_creation + unstub_packet_sender + + mac = "54:ee:75:87:1f:fb" + expected_mac_bytes = [0x54, 0xee, 0x75, 0x87, 0x1f, 0xfb] + expected_magic_packet = [0xFF] * 6 + expected_mac_bytes * 16 + expected_packet = expected_magic_packet.pack('C*') + + @mock_socket.expects(:send).with(expected_packet, 0, '255.255.255.255', 9) + + post "/", :mac_address => mac + + assert last_response.ok? + end + + def test_socket_broadcast_option + unstub_packet_sender + + @mock_socket.expects(:setsockopt).with(Socket::SOL_SOCKET, Socket::SO_BROADCAST, true) + + post "/", :mac_address => "54:ee:75:87:1f:fb" + + assert last_response.ok? + end + + def test_socket_close_called + unstub_packet_sender + + @mock_socket.expects(:close) + + post "/", :mac_address => "54:ee:75:87:1f:fb" + + assert last_response.ok? + end + + def test_mac_address_with_whitespace + mac = " 54:ee:75:87:1f:fb " + + # Since the current implementation doesn't strip whitespace, + # this should fail. If whitespace handling is added later, + # this test should be updated to expect success. + post "/", :mac_address => mac + + assert_equal 400, last_response.status + assert_match(/Invalid MAC address provided/, last_response.body) + end + + private + + def stub_packet_sender + Proxy::Wol::WolPacketSender.stubs(:send_magic_packet) + end + + def unstub_packet_sender + Proxy::Wol::WolPacketSender.unstub(:send_magic_packet) + end +end diff --git a/test/wol/wol_packet_sender_test.rb b/test/wol/wol_packet_sender_test.rb new file mode 100644 index 000000000..f3d8422df --- /dev/null +++ b/test/wol/wol_packet_sender_test.rb @@ -0,0 +1,133 @@ +require 'test_helper' +require 'wol/wol_packet_sender' + +class WolPacketSenderTest < Test::Unit::TestCase + def setup + # Mock UDPSocket to prevent actual network packets during tests + @mock_socket = mock('UDPSocket') + UDPSocket.stubs(:new).returns(@mock_socket) + @mock_socket.stubs(:setsockopt) + @mock_socket.stubs(:send) + @mock_socket.stubs(:close) + end + + def test_create_magic_packet_structure + mac = "54:ee:75:87:1f:fb" + packet = Proxy::Wol::WolPacketSender.create_magic_packet(mac) + + # Expected packet structure: 6 bytes of 0xFF followed by 16 repetitions of MAC + expected_mac_bytes = [0x54, 0xee, 0x75, 0x87, 0x1f, 0xfb] + expected_magic_packet = [0xFF] * 6 + expected_mac_bytes * 16 + expected_packet = expected_magic_packet.pack('C*') + + assert_equal expected_packet, packet + end + + def test_magic_packet_length + # A WoL magic packet should be exactly 102 bytes + # 6 bytes of 0xFF + (6 bytes MAC × 16 repetitions) = 6 + 96 = 102 bytes + mac = "54:ee:75:87:1f:fb" + packet = Proxy::Wol::WolPacketSender.create_magic_packet(mac) + + assert_equal 102, packet.length + end + + def test_magic_packet_starts_with_sync_bytes + mac = "AA:BB:CC:DD:EE:FF" + packet = Proxy::Wol::WolPacketSender.create_magic_packet(mac) + + # First 6 bytes should all be 0xFF + sync_bytes = packet[0..5].unpack('C*') + assert_equal [0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF], sync_bytes + end + + def test_magic_packet_contains_mac_repetitions + mac = "12:34:56:78:9A:BC" + packet = Proxy::Wol::WolPacketSender.create_magic_packet(mac) + + # Extract MAC repetitions (skip first 6 sync bytes) + mac_section = packet[6..] + mac_bytes = mac_section.unpack('C*') + + # Should contain exactly 16 repetitions of the MAC address + expected_mac = [0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC] + expected_repetitions = expected_mac * 16 + + assert_equal expected_repetitions, mac_bytes + end + + def test_magic_packet_different_macs_produce_different_packets + mac1 = "AA:BB:CC:DD:EE:FF" + mac2 = "11:22:33:44:55:66" + + packet1 = Proxy::Wol::WolPacketSender.create_magic_packet(mac1) + packet2 = Proxy::Wol::WolPacketSender.create_magic_packet(mac2) + + refute_equal packet1, packet2 + end + + def test_send_magic_packet_uses_correct_socket_options + mac = "54:ee:75:87:1f:fb" + + # Verify that broadcast is enabled on the socket + @mock_socket.expects(:setsockopt).with(Socket::SOL_SOCKET, Socket::SO_BROADCAST, true) + + Proxy::Wol::WolPacketSender.send_magic_packet(mac) + end + + def test_send_magic_packet_sends_to_broadcast_address + mac = "54:ee:75:87:1f:fb" + + # Verify that packet is sent to broadcast address on port 9 + @mock_socket.expects(:send).with(anything, 0, '255.255.255.255', 9) + + Proxy::Wol::WolPacketSender.send_magic_packet(mac) + end + + def test_send_magic_packet_sends_correct_packet + mac = "54:ee:75:87:1f:fb" + expected_packet = Proxy::Wol::WolPacketSender.create_magic_packet(mac) + + @mock_socket.expects(:send).with(expected_packet, 0, '255.255.255.255', 9) + + Proxy::Wol::WolPacketSender.send_magic_packet(mac) + end + + def test_send_magic_packet_closes_socket + mac = "54:ee:75:87:1f:fb" + + @mock_socket.expects(:close) + + Proxy::Wol::WolPacketSender.send_magic_packet(mac) + end + + def test_send_magic_packet_handles_send_error + @mock_socket.stubs(:send).raises(StandardError.new("Network unreachable")) + + assert_raises(StandardError) do + Proxy::Wol::WolPacketSender.send_magic_packet("54:ee:75:87:1f:fb") + end + end + + def test_create_magic_packet_uppercase_mac + mac = "AA:BB:CC:DD:EE:FF" + packet = Proxy::Wol::WolPacketSender.create_magic_packet(mac) + + # Extract the first MAC repetition after sync bytes + first_mac = packet[6..11].unpack('C*') + expected_mac = [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF] + + assert_equal expected_mac, first_mac + end + + def test_create_magic_packet_lowercase_mac + mac = "aa:bb:cc:dd:ee:ff" + packet = Proxy::Wol::WolPacketSender.create_magic_packet(mac) + + # Extract the first MAC repetition after sync bytes + first_mac = packet[6..11].unpack('C*') + expected_mac = [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF] + + assert_equal expected_mac, first_mac + end +end