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