remcohaszing / pywakeonlan

A small python module for wake on lan.
https://pywakeonlan.readthedocs.io
MIT License
281 stars 68 forks source link

Added 'interface' to send magic packet to specific NIC #22

Closed mrtnRitter closed 2 years ago

ericfrey commented 2 years ago

I have an ethernet USB adapter on a Raspberry Pi running Raspbian 10. I was the code in this pull request would let me send the magic packet out that interface, but it doesn't seem to work. I used the -n option with the IP address bound to that NIC. I tried as normal users (a member of the netdev group) and root. The command runs, but the system does not turn on. The call and command worked when I had only one interface. The etherwake command (from the etherwake package) works when I specify the interface on the command line, so it seems there must be a way to do it in principle.

DavidPratt512 commented 2 years ago

In addition to the other concern raised, three unit tests are failing in this PR. @mrtnRitter, since you added functionality, you will also have to

Separately, I have found this line of code to be troublesome on some Windows machines (although the details are fuzzy because it has been so long since I've ran into that issue).

mrtnRitter commented 2 years ago

Hi @DavidPratt512, thank you for reaching out. You are right, I just edited wakeonlan.py, and left test_wakeonlan.py untouched. Adding support for multiple NICs was my only aim, because I needed it for work. The script runs on my windows machine for years now, waking computers day by day.

I would like to know more about the unit test you did and the circumstances in which they are failing. Please provide any useful information, otherwise I cannot offer help. On the other hand, with only 2 lines pure python added and a some expanded by "interface", I'm concerned that there is not much to dig into.

My code might not be perfect, but being an expert for WOL by yourself, you are welcome to contribute on a better working solution.

mrtnRitter commented 2 years ago

Hello @ericfrey, sorry for replying late! I briefly looked at etherwake after you have written. Assuming we are talking about the same script, it isn't written in Python and kind of a different story, doing things differently in a different language (C or C++, if I remember correctly).

To get a starting point for a solution for you, can you try the wol-script from DavidPratt512? It supports interfaces natively. If it does the job, we know something is wrong with my code, if it doesn't work either, we know we have to dig deeper. https://github.com/DavidPratt512/wol

Thank you, I am curious about your result!

DavidPratt512 commented 2 years ago

Hey @mrtnRitter, I will try and retrace my steps about that particular issue I was facing and get back to you. I believe I had very similar code as the code you wrote in this PR in my own wol script that you linked to in your message to Eric - so hopefully that makes it a bit easier for me.

Also, I have a feeling we are creating a trap for ourselves the more socket/networking logic we write inside the send_magic_packet() function. From the perspective of a standalone command-line utility, it doesn't matter at all, but I believe some people are using this package in their python code since this package is on PyPI.

The reason why it feels like we would be falling into a trap is because of a few reasons:

What's funny is that I currently do these same things in my own script... but I'm working on fixing them... eventually :)

mrtnRitter commented 2 years ago

Hi @DavidPratt512, thank you for your answer.

Regarding the trap:

I think, the code is as clear as possible right now. The only think missing was the interface. If you remove all the comments, it is pretty short and easy to overlook. Additional functions would only make sense, if they can be used independently, like create_magic_packet. But I don't see such a use for something like create_socket in terms of this script.

Don't fix what isn't broken ;)

DavidPratt512 commented 2 years ago

Hey @mrtnRitter,

the function is not create_socket_and_send_magic_packet but just send_magic_packet.

That is basically my entire point summed up very nicely. I think since I've been reading about design patterns, I've been eager to jump on code that is coupled when it doesn't have to be. But let me argue against myself for a moment...

There are two scenarios I see:

  1. The send_magic_packet() function has unwanted functionality (creating a socket). One solution is to extract the socket creation logic out of the function and add a parameter that expects a socket. So, something like this:
    
    def send_magic_packet(*macs, ip_address=BROADCAST_IP, port=DEFAULT_PORT, sock=None):
    if sock is None:
        raise ValueError('expected socket')
    packets = [create_magic_packet(mac) for mac in macs]
    for packet in packets:
        sock.sendto(packet, (ip, port))

if name == 'main': parser = ... args = parser.parse_args()

with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
    sock.bind((args.n, 0))  # bind to interface
    send_magic_packet(*args.macs, ip_address=args.i, port=args.p, sock=sock)
But now if anyone is using this library in a script, their code is broken. 

2. The second scenario tries to un-break their code. Just change the `send_magic_packet()` function to this:
```python
def send_magic_packet(*macs, ip_address=BROADCAST_IP, port=DEFAULT_PORT, sock=None):
    packets = [create_magic_packet(mac) for mac in macs]
    with sock or create_socket() as s:  # create socket here
        for packet in packets:
            s.sendto(packet, (ip, port))

def create_socket(interface=None):
    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
    if interface is not None:
        sock.bind((interface, 0))
    return sock

But now we're back to square one. So extracting socket creation logic out of the function isn't a good idea (because it either breaks existing code or leads to that same logic being present in the function - and nobody is asking for this anyway).

You're exactly right when you say that someone can just use the create_magic_packet() function and send that packet however they wish and I was silly to not see that before.

Okay, so with that idea left behind, let's talk about interfaces :)

I've tested the current code in the main branch as well as in your pull request across a few systems. I set up my desktop and laptop with Wireshark to listen for WOL packets. Success was defined if both the desktop and laptop received the broadcasted magic packet. Here are the results:

I'm not sure exactly why the laptop and VM are failing to send the magic packet, but it obviously has something to do with the sock.bind((interface, 0)) line of code. A way around this is to only use that line if the function was passed the interface argument:

def send_magic_packet(*macs, ip_address=BROADCAST_IP, port=DEFAULT_PORT, interface=None):
    packets = [create_magic_packet(mac) for mac in macs]

    with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
        if interface is not None:
            sock.bind((interface, 0))
        sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
        sock.connect((ip_address, port))
        for packet in packets:
            sock.send(packet)

With this code, the behavior remains the same for anyone currently using this library and allows users to specify an interface to bind to if they so desire. This is probably the least intrusive change while still adding this feature. What are your thoughts?

Also, if I have some spare time, I'll try and find out exactly why the code in this PR is failing on those particular setups I listed above.

mrtnRitter commented 2 years ago

Hi @DavidPratt512 , I did some research and it seems, that this line DEFAULT_INTERFACE = socket.gethostbyname(socket.gethostname()) isn't returning the host IP such reliable as I thought. Especially on Linux-like systems this often just returns the localhost IP.

The script is edited following your suggestion. It's not that elegant now, but a reliable way to get the host IP across all systems isn't either, so this is still the best option.

Thank you very much for your improvements and testing!

DavidPratt512 commented 2 years ago

Hey @mrtnRitter,

I am also still not entirely sure why socket.gethostbyname(socket.gethostname()) isn't terribly consistent across different systems. Maybe there is some neat trick to get consistent behavior, but I suppose that's by the by.

I do have some nit-picky feedback on the code changes in this PR.

README.rst

The first clause on line 68 should probably reference a NIC as being a network interface controller, as opposed to a network adapter, because we are using the abbreviation NIC.

On a host with more than one **network interface controller** (NIC), ...

Also the next line of the README says

The default interface is the first NIC registered in system.

What exactly does this mean? And is it accurate now that we've made a change to the code? Also should it read in **the** system.?

On line 71 of the README, the ip_address keyword argument is misspelled. Also there is no closing parenthesis on the function call. Additionally, is 0.0.0.1 a common address for an interface? I'm asking because I honestly don't know.

On line 94 of the README, "address" is misspelled as adresse; "through" is misspelled as thrue. Also at the beginning of the help sentence, we see nic, and by the end of the sentence we see NIC. I think we should stick to a single capitalization scheme for the abbreviation.

wakeonlan.py

On line 51, "send" should be "sent"; "through" may make more sense as "from"; there needs to be a period to close the line.

Line 57 should be

if interface is not None:

(All comparisons to None should be done by identity, not equality.)

Line 97 help text should match the (adjusted) text from line 51 (following the pattern of the other script-level arguments).

test_wakeonlan.py

The test test_main is failing because it is expecting only the keyword arguments ip_address and port for the send_magic_packet() function. Simply add interface=None to the list of parameters on line 219.

Or you could change the test to this to make sure we are testing the main() function with the new interface parameter:

@patch("wakeonlan.send_magic_packet")
def test_main(send_magic_packet: Mock) -> None:
    """
    Test if processed arguments are passed to send_magic_packet.

    """
    main(["00:11:22:33:44:55", "-i", "host.example", "-p", "1337"])
    main(["00:11:22:33:44:55", "-i", "host.example", "-p", "1337", "-n", "0.0.0.1"])
    assert send_magic_packet.mock_calls == [
        call("00:11:22:33:44:55", ip_address="host.example", port=1337, interface=None),
        call("00:11:22:33:44:55", ip_address="host.example", port=1337, interface="0.0.0.1"),
    ]

Now we've "fixed" the broken tests, another test should probably be added mimicking the test test_send_magic_packet. You could just copy that entire existing test, add interface="0.0.0.1" to the call to send_magic_packet(), and finally add the line call().__enter__().bind(("0.0.0.1", 0)) after the first call().__enter__() line. At this stage, I see 7 passing tests.

Also, be sure to run black on any changes to python code since this repository uses that code format :)

Let me know if you have thoughts on any of the changes I'm suggesting.

mrtnRitter commented 2 years ago

Hi @DavidPratt512, thank you for your nit-picky feedback! I take it as a valuable lesson :)

From what I found at stackoverflow, socket.gethostbyname(socket.gethostname()) uses DNS to resolve the host IP address. However, DNS may not having a corresponding entry and so it fails. I don't know if it works differently on windows machines since it seems to work more consistent there. Unfortunately there is no trick. The suggested solutions all include to check the returned IPs for plausibility.

I corrected the misspellings in the README.rst (sorry, I'm not a native speaker and git editor don't show spelling errors - should check the readme files with word or something in the future) and got rid of the term NIC in favor of network adapter. I changed the example IP address to a more realistic one.

Thank you especially for your suggestions regarding test_wakeonlan.py. I ignored this file all the time, because I didn't quite know what to do with it.

I hope this PR is now more consistent to the main branch.

DavidPratt512 commented 2 years ago

Hey @mrtnRitter,

No problem :) Thanks for making those changes.

I retested this PR on all of the machines again, with much better results this time around:

Running pytest yields seven passing tests. Also, the script successfully wakes my desktop PC.

I only have a few minor nit-picks left.

  1. Since you changed the wording in the README for the MAC address help text (now line 85 in the README), that change should also be reflected in the wakeonlan.py file in line 78.
  2. That section of the README (lines 80 to 91) should be copied from the output of python wakeonlan.py -h. The only difference is that the .py suffix in the first line is removed.
  3. Run black on all the python files. You can do this with black .. And if you need to install black, you can do that with pip install black.
  4. Update the version number in line 3 of pyproject.toml to 2.1.0. (You could also do this with poetry version minor.)
  5. Last, after you make and commit all these changes, could you squash all of the commits in this PR? You should be able to do that with

    git reset --soft 9ba45a9  # latest commit before your changes
    git commit -a
    git push -f

I think that will be the end of my nit-picks :) Thanks for making all the changes previously.

Once we get this squared away, I think we can ping the owner of the repo for a review.

mrtnRitter commented 2 years ago

Hey @DavidPratt512,

I did as you said and we have now a nice PR following all the rules. Thank you very much for taking the time and giving feedback!

mrtnRitter commented 2 years ago

Hi @remcohaszing!

Good idea removing the exposed DEFAULT_INTERFACE. The code does even make more sense now. I edited everything as requested and looking forward to finally see the interface option in the main branch.

remcohaszing commented 2 years ago

Awesome! I’ll wait for the weekend for a response on #21, so both can be released as 2.1.0, but if that takes longer, I’ll release this sunday or monday.

mrtnRitter commented 2 years ago

Sorry, I missed to check all usages of DEFAULT_INTERFACE.

Should work now.

codecov[bot] commented 2 years ago

Codecov Report

Merging #22 (01c8c47) into main (9ba45a9) will increase coverage by 0.74%. The diff coverage is 100.00%.

Impacted file tree graph

@@            Coverage Diff             @@
##             main      #22      +/-   ##
==========================================
+ Coverage   92.59%   93.33%   +0.74%     
==========================================
  Files           1        1              
  Lines          27       30       +3     
  Branches        4        5       +1     
==========================================
+ Hits           25       28       +3     
  Misses          1        1              
  Partials        1        1              
Impacted Files Coverage Δ
wakeonlan.py 93.33% <100.00%> (+0.74%) :arrow_up:

Continue to review full report at Codecov.

Legend - Click here to learn more Δ = absolute <relative> (impact), ø = not affected, ? = missing data Powered by Codecov. Last update 9ba45a9...01c8c47. Read the comment docs.

remcohaszing commented 2 years ago

Aaand released as 2.1.0! :tada: