pax_global_header00006660000000000000000000000064141462531610014515gustar00rootroot0000000000000052 comment=c9cffc5ecadf991e111b7f2b4bb4b3151d3a5635 AUTHORS000066400000000000000000000000221414625316100121230ustar00rootroot00000000000000Steffen Christgau CHANGELOG.md000066400000000000000000000056671414625316100127100ustar00rootroot00000000000000# Changelog ## [0.7.0] - 2021-11-20 ### Added - Using the server interface it is now possible to start and stop the host functionality (discoverable device) without terminating and restarting the daemon. ### Fixed - Support multiple IP addresses in 'hello' messages from other hosts (#89) - Support interfaces with IPv6-only configuration (#94) - Re-enable 'probe' command of API (#116) - Removed code marked as deprecated starting with Python 3.10. ### Changed - The example systemd unit file now uses `DynamicUser` instead of the unsafe nobody:nobody combination. It also employs the rundir as chroot directory. - Code changed to use asyncio instead of selector-based - The server interface does not close connections after each command anymore. - For the 'list' command of the server interface, the list of discovered devices is terminated with a line containing only a single dot ('.') - Log device discovery only once per address and interface ## [0.6.4] - 2021-02-06 ### Added - Introduce `-v`/`--version` command line switch to show program version. ### Fixed - HTTP status code 404 is sent in case of an non-existing path (#79). - Data is now sent correctly again on FreeBSD as well as on Linux (#80). ### Changed - Send HTTP 400 in case of wrong content type. ## [0.6.3] - 2021-01-10 ### Added - Include instructions for adding repository keys under Debian/Ubuntu in README. ### Fixed - Skip Netlink messages smaller than 4 bytes correctly (#77, and maybe #59) - Messages are sent via the correct socket to comply with the intended/specified message flow. This also eases the firewall configuration (#72). ## [0.6.2] - 2020-10-18 ### Changed - Lowered priority of non-essential, protocol-related and internal log messages (#53). ### Fixed - Do not use PID in Netlink sockets in order to avoid issues with duplicated PIDs, e.g., when Docker is used. - Prevent exceptions due to invalid incoming messages. - HTTP server address family wrong when interface address is added (#62) - Error when interface address is removed (#62) ## [0.6.1] - 2020-06-28 ### Fixed - Error when unknown interface index is received from Netlink socket on Linux (#45) - HTTP requests not passed to wsdd, preventing hosts to be discovered (#49) ## [0.6] - 2020-06-06 ### Added - Discovery mode to search for other hosts with Windows, wsdd, or compatible services - Socket-based API to query and manipulate the discovered hosts - Documentation on installation for some distros. ### Changed - Addresses are not only enumerated on startup, but changes to addresses are also dynamically handled - The program does not stop anymore when no IP address is available (see Fixes as well) - Code significantly refactored ### Fixed - Running at system startup without IP address does not cause wsdd to terminate anymore - Support international domain names when `chroot`ing (#44) - Skip empty routing attribute returned from Netlink socket (#42) - Correct handling of invalid messages (#43) CONTRIBUTING.md000066400000000000000000000013651414625316100133170ustar00rootroot00000000000000# Coding Conventions * Use 4 spaces for indentation. * Code conforms to PEP8. Use pep8 or pycodestyle with their default settings to check for compliance. Consider using a pre-commit hook to prevent non-conforming code from entering the repo. * Avoid inline comments, prefer (short) block comments. * Add documentation strings to function if required. * If you want, add yourself to the AUTHORS file. # Commit Conventions * Follow [conventional commit](https://www.conventionalcommits.org) message guidelines. * Scopes for commit messages are `etc`, `src` and file names from the root directory. Take a look at git log to get an impression. * You can ignore the 50 chars limit for the first line of a commit message and obey to a hard limit of 72 chars. LICENSE000066400000000000000000000020611414625316100120650ustar00rootroot00000000000000MIT Licence Copyright (c) 2017 Steffen Christgau Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. README.md000066400000000000000000000377551414625316100123610ustar00rootroot00000000000000# wsdd wsdd implements a Web Service Discovery host daemon. This enables (Samba) hosts, like your local NAS device, to be found by Web Service Discovery Clients like Windows. It also implements the client side of the discovery protocol which allows to search for Windows machines and other devices implementing WSD. This mode of operation is called discovery mode. # Purpose Since NetBIOS discovery is not supported by Windows anymore, wsdd makes hosts to appear in Windows again using the Web Service Discovery method. This is beneficial for devices running Samba, like NAS or file sharing servers on your local network. The discovery mode searches for other WSD servers in the local subnet. ## Background With Windows 10 version 1511, support for SMBv1 and thus NetBIOS device discovery was disabled by default. Depending on the actual edition, later versions of Windows starting from version 1709 ("Fall Creators Update") do not allow the installation of the SMBv1 client anymore. This causes hosts running Samba not to be listed in the Explorer's "Network (Neighborhood)" views. While there is no connectivity problem and Samba will still run fine, users might want to have their Samba hosts to be listed by Windows automatically. You may ask: What about Samba itself, shouldn't this functionality be included in Samba!? Yes, maybe. However, using Samba as file sharing service is still possible even if the host running Samba is not listed in the Network Neighborhood. You can still connect using the host name (given that name resolution works) or IP address. So you can have network drives and use shared folders as well. In addition, there is a patch lurking around in the Samba bug tracker since 2015. So it may happen that this feature gets integrated into Samba at some time in the future. # Requirements wsdd requires Python 3.7 and later only. It runs on Linux and FreeBSD. Other Unixes, such as OpenBSD or NetBSD, might work as well but were not tested. Although Samba is not strictly required by wsdd itself, it makes sense to run wsdd only on hosts with a running Samba daemon. Note that the OpenRC/Gentoo init script depends on the Samba service. # Installation ## Operating System and Distribution-Dependant Instructions ### Arch Linux Install wsdd from the [AUR package](https://aur.archlinux.org/wsdd.git). ### CentOS, Fedora, RHEL wsdd is included in RedHat/CentOS' EPEL repository. After setting that up, you can install wsdd like on Fedora where it is sufficient to issue `dnf install wsdd` ### Debian/Ubuntu There are user-maintained packages for which you need to add the repository to `/etc/apt/sources.list.d` with a file containing the following line `deb https://pkg.ltec.ch/public/ distro main` Replace `distro` with the name of your distro, e.g. `buster` or `xenial` (issue `lsb_release -cs` if unsure). After an `apt update` you can install wsdd with `apt install wsdd`. You also need to import the public key of the repository like this `apt-key adv --fetch-keys https://pkg.ltec.ch/public/conf/ltec-ag.gpg.key`. ### Gentoo You can choose between two overlays: the GURU project and an [author-maintained dedicated overlay](https://github.com/christgau/wsdd-gentoo). After setting up one of them you can install wsdd with ``` emerge eselect-repository eselect repository enable guru emerge --sync emerge wsdd ``` ## Generic Installation Instructions No installation steps are required. Just place the wsdd.py file anywhere you want to, rename it to wsdd, and run it from there. The init scripts/unit files assume that wsdd is installed under `/usr/bin/wsdd` or `/usr/local/bin/wsdd` in case of FreeBSD. There are no configuration files. No special privileges are required to run wsdd, so it is advisable to run the service as an unprivileged user such as _nobody_. The `etc` directory of the repo contains sample configuration files for different init(1) systems, namely FreeBSD's rc.d, Gentoo's openrc, and systemd which is used in most contemporary Linux distros. Those files may be used as templates for their actual usage. They are likely to require adjustments to the actual distribution/installation where they are to be used. # Usage ## Firewall Setup Traffic for the following ports, directions and addresses must be allowed. * incoming and outgoing traffic to udp/3702 with multicast destination: - `239.255.255.250` for IPv4 - `ff02::c` for IPv6 * outgoing unicast traffic from udp/3702 * incoming to tcp/5357 You should further restrict the traffic to the (link-)local subnet, e.g. by using the `fe80::/10` address space for IPv6. Please note that IGMP traffic must be enabled in order to get IPv4 multicast traffic working. ## Options By default wsdd runs in host mode and binds to all interfaces with only warnings and error messages enabled. In this configuration the host running wsdd is discovered with its configured hostname and belong to a default workgroup. The discovery mode, which allows to search for other WSD-compatible devices must be enabled explicitely. Both modes can be used simultanously. See below for details. ### General options * `-4`, `--ipv4only` (see below) * `-6`, `--ipv6only` Restrict to the given address family. If both options are specified no addreses will be available and wsdd will exit. * `-A`, `--no-autostart` Do not start networking activities automatically when the program is started. The API interface (see man page) can be used to start and stop the networking activities while the application is running. * `-c DIRECTORY`, `--chroot DIRECTORY` Chroot into a separate directory to prevent access to other directories of the system. This increases security in case of a vulnerability in wsdd. Consider setting the user and group under which wssd is running by using the `-u` option. * `-H HOPLIMIT`, `--hoplimit HOPLIMIT` Set the hop limit for multicast packets. The default is 1 which should prevent packets from leaving the local network segment. * `-i INTERFACE/ADDRESS`, `--interface INTERFACE/ADDRESS` Specify on which interfaces wsdd will be listening on. If no interfaces are specified, all interfaces are used. The loop-back interface is never used, even when it was explicitly specified. For interfaces with IPv6 addresses, only link-local addresses will be used for announcing the host on the network. This option can be provided multiple times in order to use more than one interface. This option also accepts IP addresses that the service should bind to. For IPv6, only link local addresses are actually considered as noted above. * `-l PATH/PORT`, `--listen PATH/PORT` Enable the API server on the with a Unix domain socket on the given PATH or a local TCP socket bound to the given PORT. Refer to the man page for details on the API. * `-s`, `--shortlog` Use a shorter logging format that only includes the level and message. This is useful in cases where the logging mechanism, like systemd on Linux, automatically prepend a date and process name plus ID to the log message. * `-u USER[:GROUP]`, `--user USER[:GROUP]` Change user (and group) when running before handling network packets. Together with `-c` this option can be used to increase security if the execution environment, like the init system, cannot ensure this in another way. * `-U UUID`, `--uuid UUID` The WSD specification requires a device to have a unique address that is stable across reboots or changes in networks. In the context of the standard, it is assumed that this is something like a serial number. wsdd uses the UUID version 5 with the DNS namespace and the host name of the local machine as inputs. Thus, the host name should be stable and not be modified, e.g. by DHCP. However, if you want wsdd to use a specific UUID you can use this option. * `-v`, `--verbose` Additively increase verbosity of the log output. A single occurrence of -v/--verbose sets the log level to INFO. More -v options set the log level to DEBUG. * `-V`, `--version` Show the version number and exit. ### Host Operation Mode In host mode, the device running wsdd can be discovered by Windows. * `-d DOMAIN`, `--domain DOMAIN` Assume that the host running wsdd joined an ADS domain. This will make wsdd report the host being a domain member. It disables workgroup membership reporting. The (provided) hostname is automatically converted to lower case. Use the `-p` option to change this behavior. * `-n HOSTNAME`, `--hostname HOSTNAME` Override the host name wsdd uses during discovery. By default the machine's host name is used (look at hostname(1)). Only the host name part of a possible FQDN will be used in the default case. * `-o`, `--no-server` Disable host operation mode which is enabled by default. The host will not be discovered by WSD clients when this flag is provided. * `-p`, `--preserve-case` Preserve the hostname as it is. Without this option, the hostname is converted as follows. For workgroup environments (see `-w`) the hostname is made upper case by default. Vice versa it is made lower case for usage in domains (see `-d`). * `-t`, `--nohttp` Do not service http requests of the WSD protocol. This option is intended for debugging purposes where another process may handle the Get messages. * `-w WORKGROUP`, `--workgroup WORKGROUP` By default wsdd reports the host is a member of a workgroup rather than a domain (use the -d/--domain option to override this). With -w/--workgroup the default workgroup name can be changed. The default work group name is WORKGROUP. The (provided) hostname is automatically converted to upper case. Use the `-p` option to change this behavior. ### Client / Discovery Operation Mode This mode allows to search for other WSD-compatible devices. * `-D`, `--discovery` Enable discovery mode to search for other WSD hosts/servers. Found servers are printed to stdout with INFO priority. The server interface (see `-l` option) can be used for a programatic interface. Refer to the man page for details of the API. ## Example Usage * handle traffic on eth0 only, but only with IPv6 addresses `wsdd -i eth0 -6` or `wsdd --interface eth0 --ipv6only` * set the Workgroup according to smb.conf and be verbose `SMB_GROUP=$(grep -i '^\s*workgroup\s*=' smb.conf | cut -f2 -d= | tr -d '[:blank:]')` `wsdd -v -w $SMB_GROUP` ## Technical Description (Read the source for more details) For each specified (or all) network interfaces, except for loopback, an UDP multicast socket for message reception, two UDP sockets for replying using unicast as well as sending multicast traffic, and a listening TCP socket are created. This is done for both the IPv4 and the IPv6 address family if not configured otherwise by the command line arguments (see above). Upon startup a _Hello_ message is sent. When wsdd terminates due to a SIGTERM signal or keyboard interrupt, a graceful shutdown is performed by sending a _Bye_ message. I/O multiplexing is used to handle network traffic of the different sockets within a single process. # Known Issues ## Security wsdd does not implement any security feature, e.g. by using TLS for the http service. This is because wsdd's intended usage is within private, i.e. home, LANs. The _Hello_ message contains the hosts transport address, i.e. the IP address which speeds up discovery (avoids _Resolve_ message). In order to increase the security, use the capabilities of the init system or consider the `-u` and `-c` options. ## Using only IPv6 on FreeBSD If wsdd is running on FreeBSD using IPv6 only, the host running wsdd may not be reliably discovered. The reason appears to be that Windows is not always able to connect to the HTTP service for unknown reasons. As a workaround, run wsdd with IPv4 only. ## Usage with NATs Do not use wssd on interfaces that are affected by NAT. According to the standard, the _ResolveMatch_ messages emitted by wsdd, contain the IP address ("transport address" in standard parlance) of the interface(s) the application has been bound to into. When such messages are retrieved by a client (Windows hosts, e.g.) they are unlikely to be able to connect to the provided address which has been subject to NAT. To avoid this issue, use the `-i/--interface` option to bind wsdd to interfaces not affected by NAT. ## Tunnel/Bridge Interface If tunnel/bridge interfaces like those created by OpenVPN or Docker exist, they may interfere with wsdd if executed without providing an interface that it should bind to (so it binds to all). In such cases, the wsdd hosts appears after wsdd has been started but it disappears when an update of the Network view in Windows Explorer is forced, either by refreshing the view or by a reboot of the Windows machine. To solve this issue, the interface that is connected to the network on which the host should be announced needs to be specified with the `-i/--interface` option. This prevents the usage of the tunnel/bridge interfaces. Background: Tunnel/bridge interfaces may cause Resolve requests from Windows hosts to be delivered to wsdd multiple times,´i.e. duplicates of such request are created. If wsdd receives such a request first from a tunnel/bridge it uses the transport address (IP address) of that interface and sends the response via unicast. Further duplicates are not processed due to the duplicate message detection which is based on message UUIDs. The Windows host which receives the response appears to detect a mismatch between the transport address in the ResolveMatch message (which is the tunnel/bridge address) and the IP of the sending host/interface (LAN IP, e.g.). Subsequently, the wsdd host is ignored by Windows. # Contributing Contributions are welcome. Please ensure PEP8 compliance when submitting patches or pull requests. Opposite to PEP8, the maximum number of characters per line is increased to 120. # Licence The code is licensed under the [MIT license](https://opensource.org/licenses/MIT). # Acknowledgements Thanks to Jose M. Prieto and his colleague Tobias Waldvogel who wrote the mentioned patch for Samba to provide WSD and LLMNR support. A look at their patch set made cross-checking the WSD messages easier. # References and Further Reading ## Technical Specification * [Web Services Dynamic Discovery](http://specs.xmlsoap.org/ws/2005/04/discovery/ws-discovery.pdf) * [SOAP-over-UDP (used during multicast)](http://specs.xmlsoap.org/ws/2004/09/soap-over-udp/soap-over-udp.pdf) * [MSDN Documentation on Publication Services Data Structure](https://msdn.microsoft.com/en-us/library/hh442048.aspx) * [MSDN on Windows WSD Compliance](https://msdn.microsoft.com/en-us/library/windows/desktop/bb736564.aspx) * ...and the standards referenced within the above. ## Documentation and Discussion on Windows/WSD * [Microsoft help entry on SMBv1 is not installed by default in Windows 10 Fall Creators Update and Windows Server, version 1709](https://support.microsoft.com/en-us/help/4034314/smbv1-is-not-installed-windows-10-and-windows-server-version-1709) * [Samba WSD and LLMNR support (Samba Bug #11473)](https://bugzilla.samba.org/show_bug.cgi?id=11473) * [Discussion at tenforums.com about missing hosts in network](https://www.tenforums.com/network-sharing/31221-windows-10-1511-network-browsing-issue.html) Note: Solutions suggest to go back to SMBv1 protocol which is deprecated! Do not follow this advice. * [Discussion in Synology Community Forum](https://forum.synology.com/enu/viewtopic.php?f=49&t=106924) ## Other stuff * Meanwhile, there is a [C implementation of a WSD daemon](https://github.com/Andy2244/wsdd2), named wsdd2. This one also includes LLMNR which wsdd lacks. However, LLMNR may not be required depending on the actual network/name resolution setup. * [OpenWRT includes](https://github.com/openwrt/packages/pull/5563) the above C implementation. So OpenWRT users are unlikely to need an installation of wsdd. * [FreeNAS](https://redmine.ixsystems.com/issues/72099) appears to have wsdd included in the distribution. etc/000077500000000000000000000000001414625316100116345ustar00rootroot00000000000000etc/openrc/000077500000000000000000000000001414625316100131225ustar00rootroot00000000000000etc/openrc/conf.d/000077500000000000000000000000001414625316100142715ustar00rootroot00000000000000etc/openrc/conf.d/wsdd000066400000000000000000000007041414625316100151560ustar00rootroot00000000000000# /etc/conf.d/wsdd # Override the default user/group under which wsdd runs. # Must follow the user[:group] notation. #WSDD_USER="daemon:daemon" # Specify alternative log file location. #WSDD_LOG_FILE="/var/log/wsdd.log" # Disable automatic detection of the workgroup from samba configuration. #WSDD_WORKGROUP="MYGROUP" # Additional options for the daemon, e.g. to listen on interface eth0 only. # Refer to wsdd(1) for details. #WSDD_OPTS="-i eth0" etc/openrc/init.d/000077500000000000000000000000001414625316100143075ustar00rootroot00000000000000etc/openrc/init.d/wsdd000077500000000000000000000025471414625316100152060ustar00rootroot00000000000000#!/sbin/openrc-run # Copyright 2017 Steffen Christgau # Distributed under the terms of the MIT licence depend() { need net need samba } SMB_CONFIG_FILE="/etc/samba/smb.conf" LOG_FILE="${WSDD_LOG_FILE:-/var/log/wsdd.log}" WSDD_EXEC="/usr/bin/wsdd" RUN_AS_USER="${WSDD_USER:-daemon:daemon}" start() { ebegin "Starting ${RC_SVCNAME} daemon" OPTS="${WSDD_OPTS}" if [ -z "$WSDD_WORKGROUP" ]; then # try to extract workgroup with Samba's testparm if which testparm >/dev/null 2>/dev/null; then GROUP="$(testparm -s --parameter-name workgroup 2>/dev/null)" fi # fallback to poor man's approach if testparm is unavailable or failed for some reason if [ -z "$GROUP" ] && [ -r "${SMB_CONFIG_FILE}" ]; then GROUP=`grep -i '^\s*workgroup\s*=' ${SMB_CONFIG_FILE} | cut -f2 -d= | tr -d '[:blank:]'` fi if [ -n "${GROUP}" ]; then OPTS="-w ${GROUP} ${OPTS}" fi else OPTS="-w ${WSDD_WORKGROUP} ${OPTS}" fi if [ ! -r "${LOG_FILE}" ]; then touch "${LOG_FILE}" fi chown ${RUN_AS_USER} "${LOG_FILE}" start-stop-daemon --start --background --user ${RUN_AS_USER} --make-pidfile --pidfile /var/run/${RC_SVCNAME}.pid --stdout "${LOG_FILE}" --stderr "${LOG_FILE}" --exec ${WSDD_EXEC} -- ${OPTS} eend $? } stop() { ebegin "Stopping ${RC_SVCNAME} daemon" start-stop-daemon --stop --retry 2 --pidfile /var/run/${RC_SVCNAME}.pid --exec ${WSDD_EXEC} eend $? } etc/rc.d/000077500000000000000000000000001414625316100124625ustar00rootroot00000000000000etc/rc.d/wsdd000077500000000000000000000013331414625316100133510ustar00rootroot00000000000000#!/bin/sh # PROVIDE: wsdd # REQUIRE: DAEMON samba_server # BEFORE: login # KEYWORD: shutdown . /etc/rc.subr name=wsdd rcvar=wsdd_enable wsdd_group=$(/usr/local/bin/testparm -s --parameter-name workgroup 2>/dev/null) : ${wsdd_smb_config_file="/usr/local/etc/smb4.conf"} # try to manually extract workgroup from samba configuration if testparm failed if [ -z "$wsdd_group" ] && [ -r $wsdd_smb_config_file ]; then wsdd_group="$(grep -i '^[[:space:]]*workgroup[[:space:]]*=' $wsdd_smb_config_file | cut -f2 -d= | tr -d '[:blank:]')" fi if [ -n "$wsdd_group" ]; then wsdd_opts="-w ${wsdd_group}" fi command="/usr/sbin/daemon" command_args="-u daemon -S /usr/local/bin/wsdd $wsdd_opts" load_rc_config $name run_rc_command "$1" etc/systemd/000077500000000000000000000000001414625316100133245ustar00rootroot00000000000000etc/systemd/wsdd.service000066400000000000000000000011201414625316100156410ustar00rootroot00000000000000[Unit] Description=Web Services Dynamic Discovery host daemon ; Start after the network has been configured After=network-online.target Wants=network-online.target ; It makes sense to have Samba running when wsdd starts, but is not required ;Wants=smb.service [Service] Type=simple ; The service is put into an empty runtime directory chroot, ; i.e. the runtime directory which usually resides under /run ExecStart=/usr/bin/wsdd --shortlog --chroot=/run/wsdd DynamicUser=yes User=wsdd Group=wsdd RuntimeDirectory=wsdd AmbientCapabilities=CAP_SYS_CHROOT [Install] WantedBy=multi-user.target man/000077500000000000000000000000001414625316100116345ustar00rootroot00000000000000man/wsdd.1000066400000000000000000000270001414625316100126560ustar00rootroot00000000000000.TH wsdd 1 .SH NAME wsdd \- A Web Service Discovery host and client daemon. .SH SYNOPSIS .B wsdd [\fBoptions\fR] .SH DESCRIPTION .PP .B wsdd implements both a Web Service Discovery (WSD) host and a WSD client daemon. The host implementation enables (Samba) hosts, like your local NAS device, to be found by Web Service Discovery clients like Windows. The client mode allows searching for other WSD hosts on the local network. .PP The default mode of operation is the host mode. The client or discovery mode must be enabled explictely. Unless configured otherwise, the host mode is always active. Both modes can be used at the same time. .SH OPTIONS .SS General options .TP \fB\-4\fR, \fB\-\-ipv4only\fR See below. .TP \fB\-6\fR, \fB\-\-ipv6only\fR Restrict to the given address family. If both options are specified no addreses will be available and wsdd will exit. .TP \fB\-A\fR, \fB\-\-no-autostart\fR Do not start networking activities automatically when the program is started. The API interface (see below) can be used to start and stop the networking activities while the application is running. .TP \fB\-c \fIDIRECTORY\fR, \fB\-\-chroot \fIDIRECTORY\fR chroot into the given \fIDIRECTORY\fR after initialization has been performed and right before the handling of incoming messages starts. The new root directory can be empty. Consider using the \fB-u\fR option as well. .TP \fB\-h\fR, \fB\-\-help\fR Show help and exit. .TP \fB\-H \fIHOPLIMIT\fR, \fB\-\-hoplimit \fIHOPLIMIT\fR Set the hop limit for multicast packets. The default is 1 which should prevent packets from leaving the local network segment. .TP \fB\-i \fIINTERFACE/ADDRESS\fR, \fB\-\-interface \fIINTERFACE/ADDRESS\fR Specify on which interfaces wsdd will be listening on. If no interfaces are specified, all interfaces are used. Loop-back interfaces are never used, even when explicitly specified. For interfaces with IPv6 addresses, only link-local addresses will be used for announcing the host on the network. This option can be provided multiple times in order to restrict traffic to more than one interface. This option also accepts IP addresses that the service should bind to. For IPv6, only link local addresses are actually considered as noted above. .TP \fB\-s\fR, \fB\-\-shortlog\fR Use a shorter logging format that only includes the level and message. This is useful in cases where the logging mechanism, like systemd on Linux, automatically prepends a date and process name plus ID to the log message. .TP \fB\-u \fIUSER[:GROUP]\fR, \fB\-\-user \fIUSER[:GROUP]\fR Change user (and group) when running before handling network packets. Together with \fB\-c\fR this option can be used to increase security if the execution environment, like the init system, cannot ensure this in another way. .TP \fB\-U \fIUUID\fR, \fB\-\-uuid \fIUUID\fR The WSD specification requires a device to have a unique address that is stable across reboots or changes in networks. In the context of the standard, it is assumed that this is something like a serial number. wsdd uses the UUID version 5 with the DNS namespace and the host name of the local machine as inputs. Thus, the host name should be stable and not be modified, e.g. by DHCP. However, if you want wsdd to use a specific UUID you can use this option. .TP \fB\-v\fR, \fB\-\-verbose\fR Additively increase verbosity of the log output. A single occurrence of -v/--verbose sets the log level to INFO. More -v options set the log level to DEBUG. .TP \fB\-V\fR, \fB\-\-version\fR Show the version number and exit. .SS Host Mode Options .TP \fB\-d \fIDOMAIN\fR, \fB\-\-domain \fIDOMAIN\fR Assume that the host running wsdd joined an ADS domain. This will make wsdd report the host being a domain member. It disables workgroup membership reporting. The (provided) hostname is automatically converted to lower case. Use the `-p` option to change this behavior. .TP \fB\-n \fIHOSTNAME\fR, \fB\-\-hostname \fIHOSTNAME\fR Override the host name wsdd uses during discovery. By default the machine's host name is used (look at hostname(1)). Only the host name part of a possible FQDN will be used in the default case. .TP \fB\-o\fR, \fB\-\-no-host\fR Disable host operation mode. If this option is provided, the host cannot be discovered by other (Windows) hosts. It can be useful when client/discovery mode is used and no announcement of the host that runs wsdd should be made. .TP \fB\-p\fR, \fB\-\-preserve-case\fR Preserve the hostname as it is. Without this option, the hostname is converted as follows. For workgroup environments (see -w) the hostname is made upper case by default. Vice versa it is made lower case for usage in domains (see -d). .TP \fB\-t\fR, \fB\-\-no-http\fR Do not service HTTP requests of the WSD protocol. This option is intended for debugging purposes where another process may handle the Get messages. .TP \fB\-w \fIWORKGROUP\fR, \fB\-\-workgroup \fIWORKGROUP\fR By default wsdd reports the host is a member of a workgroup rather than a domain (use the -d/--domain option to override this). With -w/--workgroup the default workgroup name can be changed. The default work group name is WORKGROUP. The (provided) hostname is automatically converted to upper case. Use the `-p` option to change this behavior. .SS Client/Discovery Mode Options .TP \fB\-D\fR, \fB\-\-discovery\fR Enable discovery mode to search for other WSD hosts/servers. Found hosts are logged with INFO priority. The server interface (see below) can be used for a programatic interface. Refer to the man page for details of the server interface. .TP \fB\-l \fIPATH/PORT\fR, \fB\-\-listen \fIPATH/PORT\fR Specify the location of the socket for the server programming interface. If the option value is numeric, it is interpreted as numeric port for a TCP server port. Then, the server socket is bound to the localhost only. If the option value is non-numeric, it is assumed to be a path to UNIX domain socket to which a client can connect to. .SH EXAMPLE USAGE .SS Handle traffic on eth0 and eth2 only, but only with IPv6 addresses wsdd \-i eth0 \-i eth2 \-6 or wsdd \-\-interface eth0 \-\-interface eth2 \-\-ipv6only .SS Set the Workgroup according to smb.conf, be verbose, run with dropped privileges, and change the root directory to an (empty) directory SMB_GROUP=$(grep \-i '^\s*workgroup\s*=' smb.conf | cut \-f2 \-d= | tr \-d '[:blank:]') wsdd \-v \-w $SMB_GROUP -u daemon:daemon -c /var/run/wsdd/chroot .SH FIREWALL SETUP .PP Traffic for the following ports, directions and addresses must be allowed: .TP - Incoming and outgoing traffic to udp/3702 with multicast destination: 239.255.255.250 for IPv4 and ff02::c for IPv6 .TP - Outgoing unicast traffic from udp/3702 .TP - Incoming traffic to tcp/5357 .PP You should further restrict the traffic to the (link-)local subnet, e.g. by using the `fe80::/10` address space for IPv6. Please note that IGMP traffic must be enabled in order to get IPv4 multicast traffic working. .SH API INTERFACE Wsdd provides a text-based, line-oriented API interface to query information and trigger actions. The interface can be used with TCP and UNIX domain sockets (see \fB\-l/\-\-listen\fR option). The TCP socket is bound to the local host only. The following commands can be issued: .SS \fBclear\fR - Clear list of discovered devices Clears the list of all discovered devices. Use the \fBprobe\fR command to search for devices again. This command does not return any data and is only available in discover mode. .SS \fBlist\fR - List discovered devices Returns a tab-separated list of discovered devices with the following information. The possibly empty list of detected hosts is always terminated with a single dot ('.') in an otherwise empty line. This command is only available in discover mode. .TP UUID UUID of the discovered device. Note that a multi-homed device should appear only once but with multiple addresses (see below) .TP name The name reported by the device. For discovered Windows hosts, it is the configured computer name that is reported here. .TP association Specifies the domain or workgroup to which the discovered host belongs to. The type of the association (workgroup or domain) is separated from its value by a colon. .TP last_seen The date and time the device was last seen as a consequence of Probe/Hello messages provided in ISO8601 with seconds. .TP addresses List of all transport addresses that were collected during the discovery process delimited by commas. Addresses are provided along with the interface (separated by '%') on which they were discovered. IPv6 addresses are reported on square brackets. Note that the reported addresses may not match the actual device on which the device may be reached. .SS \fBprobe \fI[INTERFACE]\fR - Search for devices Triggers a Probe message on the provided INTERFACE (eth0, e.g.) to search for WSD hosts. If no interface is provided, all interfaces wsdd listens on are probed. A Probe messages initiates the discovery message flow. It may take some time for hosts to be actually discovered. This command does not return any data and is only available in discovery mode. .SS \fBstart\fR - Start networking activities This command starts the networking acitivies of wsdd if they haven't been started yet. "Hello" messages are emitted and the host is announced on the network(s) when the host mode is active. If the discovery mode is enabled a probe process is also started. .SS \fBstop\fR - Stop networking activities This is the reverse operation to start. When this command is received, "Bye" messages are sent in order to notify hosts in the network about the host's disappearance. All networking sockets used for the WSD protocol are closed as well. Activities can be restarted with the start operation. .SH SECURITY .PP wsdd does not implement any security feature, e.g. by using TLS for the http service. This is because wsdd's intended usage is within private, i.e. home, LANs. The \fIHello\fR message contains the hosts transport address, i.e. the IP address which speeds up discovery (avoids \fIResolve\fR message). .SH KNOWN ISSUES .SS Using only IPv6 on FreeBSD If wsdd is running on FreeBSD using IPv6 only, the host running wsdd may not be reliably discovered. The reason appears to be that Windows is not always able to connect to the HTTP service for unknown reasons. As a workaround, run wsdd with IPv4 only. .SS Tunnel/Bridge Interface .PP If tunnel/bridge interfaces like those created by OpenVPN or Docker exist, they may interfere with wsdd if executed without providing an interface that it should bind to (so it binds to all). In such cases, the wsdd hosts appears after wsdd has been started but it disappears when an update of the Network view in Windows Explorer is forced, either by refreshing the view or by a reboot of the Windows machine. To solve this issue, the interface that is connected to the network on which the host should be announced needs to be specified with the -i/--interface option. This prevents the usage of the tunnel/bridge interfaces. .PP Background: Tunnel/bridge interfaces may cause \fIResolve\fR requests from Windows hosts to be delivered to wsdd multiple times, i.e. duplicates of such request are created. If wsdd receives such a request first from a tunnel/bridge it uses the transport address (IP address) of that interface and sends the response via unicast. Further duplicates are not processed due to the duplicate message detection which is based on message UUIDs. The Windows host which receives the response appears to detect a mismatch between the transport address in the \fIResolveMatch\fR message (which is the tunnel/bridge address) and the IP of the sending host/interface (LAN IP, e.g.). Subsequently, the wsdd host is ignored by Windows. setup.cfg000066400000000000000000000000561414625316100127030ustar00rootroot00000000000000[flake8] max-line-length=120 ignore=F401,W503 src/000077500000000000000000000000001414625316100116505ustar00rootroot00000000000000src/wsdd.py000077500000000000000000001742111414625316100131740ustar00rootroot00000000000000#!/usr/bin/env python3 # Implements a target service according to the Web Service Discovery # specification. # # The purpose is to enable non-Windows devices to be found by the 'Network # (Neighborhood)' from Windows machines. # # see http://specs.xmlsoap.org/ws/2005/04/discovery/ws-discovery.pdf and # related documents for details (look at README for more references) # # (c) Steffen Christgau, 2017-2021 import sys import signal import socket import asyncio import struct import argparse import uuid import time import random import logging import platform import ctypes.util import collections import xml.etree.ElementTree as ElementTree import http import http.server import urllib.request import urllib.parse import os import pwd import grp import datetime # try to load more secure XML module first, fallback to default if not present try: from defusedxml.ElementTree import fromstring as ETfromString except ModuleNotFoundError: from xml.etree.ElementTree import fromstring as ETfromString WSDD_VERSION = '0.7.0' class MulticastHandler: """ A class for handling multicast traffic on a given interface for a given address family. It provides multicast sender and receiver sockets """ # TODO: this one needs some cleanup def __init__(self, family, address, interface, aio_loop): # network address, family, and interface name self.address = address self.family = family self.interface = interface # create individual interface-bound sockets for: # - receiving multicast traffic # - sending multicast from a socket bound to WSD port # - sending unicast messages from a random port self.recv_socket = socket.socket(self.family, socket.SOCK_DGRAM) self.recv_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) self.mc_send_socket = socket.socket(self.family, socket.SOCK_DGRAM) self.uc_send_socket = socket.socket(self.family, socket.SOCK_DGRAM) self.uc_send_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # the string representation of the local address # overridden in network setup (for IPv6) self.transport_address = address # family-specific multicast address and local HTTP server (tuples) self.multicast_address = None self.listen_address = None # dictionary that holds objects with a handle_request method for the # sockets created above self.message_handlers = {} self.aio_loop = aio_loop if family == socket.AF_INET: self.init_v4() elif family == socket.AF_INET6: self.init_v6() logger.info('joined multicast group {0} on {2}%{1}'.format(self.multicast_address, self.interface.name, self.address)) logger.debug('transport address on {0} is {1}'.format(self.interface.name, self.transport_address)) logger.debug('will listen for HTTP traffic on address {0}'.format(self.listen_address)) # register calbacks for incoming data (also for mc) self.aio_loop.add_reader(self.recv_socket.fileno(), self.handle_request, self.recv_socket) self.aio_loop.add_reader(self.mc_send_socket.fileno(), self.handle_request, self.mc_send_socket) self.aio_loop.add_reader(self.uc_send_socket.fileno(), self.handle_request, self.uc_send_socket) def cleanup(self): self.aio_loop.remove_reader(self.recv_socket) self.aio_loop.remove_reader(self.mc_send_socket) self.aio_loop.remove_reader(self.uc_send_socket) self.recv_socket.close() self.mc_send_socket.close() self.uc_send_socket.close() def handles(self, family, addr, interface): return self.family == family and self.address == addr and self.interface.name == interface.name def init_v6(self): idx = socket.if_nametoindex(self.interface.name) self.multicast_address = (WSD_MCAST_GRP_V6, WSD_UDP_PORT, 0x575C, idx) # v6: member_request = { multicast_addr, intf_idx } mreq = (socket.inet_pton(self.family, WSD_MCAST_GRP_V6) + struct.pack('@I', idx)) self.recv_socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_JOIN_GROUP, mreq) self.recv_socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1) # Could anyone ask the Linux folks for the rationale for this!? if platform.system() == 'Linux': try: # supported starting from Linux 4.20 IPV6_MULTICAST_ALL = 29 self.recv_socket.setsockopt(socket.IPPROTO_IPV6, IPV6_MULTICAST_ALL, 0) except OSError as e: logger.warning('cannot unset all_multicast: {}'.format(e)) # bind to network interface, i.e. scope and handle OS differences, # see Stevens: Unix Network Programming, Section 21.6, last paragraph try: self.recv_socket.bind((WSD_MCAST_GRP_V6, WSD_UDP_PORT, 0, idx)) except OSError: self.recv_socket.bind(('::', 0, 0, idx)) # bind unicast socket to interface address and WSD's udp port self.uc_send_socket.bind((self.address, WSD_UDP_PORT, 0, idx)) self.mc_send_socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_MULTICAST_LOOP, 0) self.mc_send_socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_MULTICAST_HOPS, args.hoplimit) self.mc_send_socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_MULTICAST_IF, idx) self.transport_address = '[{0}]'.format(self.address) self.listen_address = (self.address, WSD_HTTP_PORT, 0, idx) def init_v4(self): idx = socket.if_nametoindex(self.interface.name) self.multicast_address = (WSD_MCAST_GRP_V4, WSD_UDP_PORT) # v4: member_request (ip_mreqn) = { multicast_addr, intf_addr, idx } mreq = (socket.inet_pton(self.family, WSD_MCAST_GRP_V4) + socket.inet_pton(self.family, self.address) + struct.pack('@I', idx)) self.recv_socket.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq) if platform.system() == 'Linux': IP_MULTICAST_ALL = 49 self.recv_socket.setsockopt(socket.IPPROTO_IP, IP_MULTICAST_ALL, 0) try: self.recv_socket.bind((WSD_MCAST_GRP_V4, WSD_UDP_PORT)) except OSError: self.recv_socket.bind(('', WSD_UDP_PORT)) # bind unicast socket to interface address and WSD's udp port self.uc_send_socket.bind((self.address, WSD_UDP_PORT)) self.mc_send_socket.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_IF, mreq) self.mc_send_socket.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_LOOP, 0) self.mc_send_socket.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, args.hoplimit) self.listen_address = (self.address, WSD_HTTP_PORT) def add_handler(self, socket, handler): # try: # self.selector.register(socket, selectors.EVENT_READ, self) # except KeyError: # # accept attempts of multiple registrations # pass if socket in self.message_handlers: self.message_handlers[socket].append(handler) else: self.message_handlers[socket] = [handler] def remove_handler(self, socket, handler): if socket in self.message_handlers: if handler in self.message_handlers[socket]: self.message_handlers[socket].remove(handler) def handle_request(self, key): s = None if key == self.uc_send_socket: s = self.uc_send_socket elif key == self.mc_send_socket: s = self.mc_send_socket elif key == self.recv_socket: s = self.recv_socket else: raise ValueError("Unknown socket passed as key.") msg, address = s.recvfrom(WSD_MAX_LEN) if s in self.message_handlers: for handler in self.message_handlers[s]: handler.handle_request(msg, address) def send(self, msg, addr): # Request from a client must be answered from a socket that is bound # to the WSD port, i.e. the recv_socket. Messages to multicast # addresses are sent over the dedicated send socket. if addr == self.multicast_address: self.mc_send_socket.sendto(msg, addr) else: self.uc_send_socket.sendto(msg, addr) # constants for WSD XML/SOAP parsing WSA_URI = 'http://schemas.xmlsoap.org/ws/2004/08/addressing' WSD_URI = 'http://schemas.xmlsoap.org/ws/2005/04/discovery' WSDP_URI = 'http://schemas.xmlsoap.org/ws/2006/02/devprof' namespaces = { 'soap': 'http://www.w3.org/2003/05/soap-envelope', 'wsa': WSA_URI, 'wsd': WSD_URI, 'wsx': 'http://schemas.xmlsoap.org/ws/2004/09/mex', 'wsdp': WSDP_URI, 'pnpx': 'http://schemas.microsoft.com/windows/pnpx/2005/10', 'pub': 'http://schemas.microsoft.com/windows/pub/2005/07' } WSD_MAX_KNOWN_MESSAGES = 10 WSD_PROBE = WSD_URI + '/Probe' WSD_PROBE_MATCH = WSD_URI + '/ProbeMatches' WSD_RESOLVE = WSD_URI + '/Resolve' WSD_RESOLVE_MATCH = WSD_URI + '/ResolveMatches' WSD_HELLO = WSD_URI + '/Hello' WSD_BYE = WSD_URI + '/Bye' WSD_GET = 'http://schemas.xmlsoap.org/ws/2004/09/transfer/Get' WSD_GET_RESPONSE = 'http://schemas.xmlsoap.org/ws/2004/09/transfer/GetResponse' WSD_TYPE_DEVICE = 'wsdp:Device' PUB_COMPUTER = 'pub:Computer' WSD_TYPE_DEVICE_COMPUTER = '{0} {1}'.format(WSD_TYPE_DEVICE, PUB_COMPUTER) WSD_MCAST_GRP_V4 = '239.255.255.250' WSD_MCAST_GRP_V6 = 'ff02::c' # link-local WSA_ANON = WSA_URI + '/role/anonymous' WSA_DISCOVERY = 'urn:schemas-xmlsoap-org:ws:2005:04:discovery' MIME_TYPE_SOAP_XML = 'application/soap+xml' # protocol assignments (WSD spec/Section 2.4) WSD_UDP_PORT = 3702 WSD_HTTP_PORT = 5357 WSD_MAX_LEN = 32767 WSDD_LISTEN_PORT = 5359 # SOAP/UDP transmission constants MULTICAST_UDP_REPEAT = 4 UNICAST_UDP_REPEAT = 2 UDP_MIN_DELAY = 50 UDP_MAX_DELAY = 250 UDP_UPPER_DELAY = 500 # servers must recond in 4 seconds after probe arrives PROBE_TIMEOUT = 4 MAX_STARTUP_PROBE_DELAY = 3 # some globals wsd_instance_id = int(time.time()) send_queue = [] args = None logger = None class WSDMessageHandler: known_messages = collections.deque([], WSD_MAX_KNOWN_MESSAGES) def __init__(self): self.handlers = {} self.pending_tasks = [] def cleanup(self): pass # shortcuts for building WSD responses def add_endpoint_reference(self, parent, endpoint=None): epr = ElementTree.SubElement(parent, 'wsa:EndpointReference') address = ElementTree.SubElement(epr, 'wsa:Address') if endpoint is None: address.text = args.uuid.urn else: address.text = endpoint def add_metadata_version(self, parent): meta_data = ElementTree.SubElement(parent, 'wsd:MetadataVersion') meta_data.text = '1' def add_types(self, parent): dev_type = ElementTree.SubElement(parent, 'wsd:Types') dev_type.text = WSD_TYPE_DEVICE_COMPUTER def add_xaddr(self, parent, transport_addr): if transport_addr: item = ElementTree.SubElement(parent, 'wsd:XAddrs') item.text = 'http://{0}:{1}/{2}'.format(transport_addr, WSD_HTTP_PORT, args.uuid) def build_message(self, to_addr, action_str, request_header, response): retval = self.xml_to_buffer(self.build_message_tree(to_addr, action_str, request_header, response)[0]) logger.debug('constructed xml for WSD message: {0}'.format(retval)) return retval def build_message_tree(self, to_addr, action_str, request_header, body): """ Build a WSD message with a given action string including SOAP header. The message can be constructed based on a response to another message (given by its header) and with a optional response that serves as the message's body """ root = ElementTree.Element('soap:Envelope') header = ElementTree.SubElement(root, 'soap:Header') to = ElementTree.SubElement(header, 'wsa:To') to.text = to_addr action = ElementTree.SubElement(header, 'wsa:Action') action.text = action_str msg_id = ElementTree.SubElement(header, 'wsa:MessageID') msg_id.text = uuid.uuid1().urn if request_header: req_msg_id = request_header.find('./wsa:MessageID', namespaces) if req_msg_id is not None: relates_to = ElementTree.SubElement(header, 'wsa:RelatesTo') relates_to.text = req_msg_id.text self.add_header_elements(header, action_str) body_root = ElementTree.SubElement(root, 'soap:Body') if body is not None: body_root.append(body) for prefix, uri in namespaces.items(): root.attrib['xmlns:' + prefix] = uri return root, msg_id.text def add_header_elements(self, header, extra): pass def handle_message(self, msg, mch, src_address): """ handle a WSD message that might be received by a MulticastHandler """ try: tree = ETfromString(msg) except ElementTree.ParseError: return None header = tree.find('./soap:Header', namespaces) if header is None: return None msg_id_tag = header.find('./wsa:MessageID', namespaces) if msg_id_tag is None: return None msg_id = msg_id_tag.text # if message came over a MulticastHandler, check for duplicates if mch and self.is_duplicated_msg(msg_id): logger.debug('known message ({0}): dropping it'.format(msg_id)) return None action_tag = header.find('./wsa:Action', namespaces) if action_tag is None: return None action = action_tag.text _, _, action_method = action.rpartition('/') if mch: logger.info('{}:{}({}) - - "{} {} UDP" - -'.format( src_address[0], src_address[1], mch.interface.name, action_method, msg_id)) else: # http logging is already done by according server logger.debug('processing WSD {} message ({})'.format(action_method, msg_id)) body = tree.find('./soap:Body', namespaces) if body is None: return None logger.debug('incoming message content is {0}'.format(msg)) if action in self.handlers: handler = self.handlers[action] retval = handler(header, body) if retval is not None: response, response_type = retval return self.build_message( WSA_ANON, response_type, header, response) else: logger.debug('unhandled action {0}/{1}'.format(action, msg_id)) return None def is_duplicated_msg(self, msg_id): """ Check for a duplicated message. Implements SOAP-over-UDP Appendix II Item 2 """ if msg_id in type(self).known_messages: return True type(self).known_messages.append(msg_id) return False def xml_to_buffer(self, xml): retval = b'' retval = retval + ElementTree.tostring(xml, encoding='utf-8') return retval class WSDUDPMessageHandler(WSDMessageHandler): """ A message handler that handles traffic received via MutlicastHandler. """ def __init__(self, mch): super().__init__() self.mch = mch self.tearing_down = False def teardown(self): self.tearing_down = True def send_datagram(self, msg, dst): try: self.mch.send(msg, dst) except Exception as e: logger.error('error while sending packet on {}: {}'.format(self.mch.interface.name, e)) def enqueue_datagram(self, msg, address=None, msg_type=None): if not address: address = self.mch.multicast_address if msg_type: logger.info('scheduling {0} message via {1} to {2}'.format(msg_type, self.mch.interface.name, address)) schedule_task = self.mch.aio_loop.create_task(self.schedule_datagram(msg, address)) # Add this task to the pending list during teardown to wait during shutdown if self.tearing_down: self.pending_tasks.append(schedule_task) async def schedule_datagram(self, msg, address): """ Schedule to send the given message to the given address. Implements SOAP over UDP, Appendix I. """ self.send_datagram(msg, address) delta = 0 msg_count = MULTICAST_UDP_REPEAT if address == self.mch.multicast_address else UNICAST_UDP_REPEAT delta = random.randint(UDP_MIN_DELAY, UDP_MAX_DELAY) for i in range(msg_count - 1): await asyncio.sleep(delta / 1000.0) self.send_datagram(msg, address) delta = min(delta * 2, UDP_UPPER_DELAY) class WSDDiscoveredDevice: # a dict of discovered devices with their UUID as key instances = {} def __init__(self, xml_str, xaddr, interface): self.last_seen = None self.addresses = {} self.props = {} self.display_name = '' self.update(xml_str, xaddr, interface) def update(self, xml_str, xaddr, interface): try: tree = ETfromString(xml_str) except ElementTree.ParseError: return None mds_path = 'soap:Body/wsx:Metadata/wsx:MetadataSection' sections = tree.findall(mds_path, namespaces) for section in sections: dialect = section.attrib['Dialect'] if dialect == WSDP_URI + '/ThisDevice': self.extract_wsdp_props(section, dialect) elif dialect == WSDP_URI + '/ThisModel': self.extract_wsdp_props(section, dialect) elif dialect == WSDP_URI + '/Relationship': host_xpath = 'wsdp:Relationship[@Type="{}/host"]/wsdp:Host'.format(WSDP_URI) host_sec = section.find(host_xpath, namespaces) if (host_sec): self.extract_host_props(host_sec) else: logger.debug('unknown metadata dialect ({})'.format(dialect)) url = urllib.parse.urlparse(xaddr) addr, _, _ = url.netloc.rpartition(':') report = True if interface not in self.addresses: self.addresses[interface] = set([addr]) else: if addr not in self.addresses[interface]: self.addresses[interface].add(addr) else: report = False self.last_seen = time.time() if ('DisplayName' in self.props) and ('BelongsTo' in self.props) and (report): self.display_name = self.props['DisplayName'] logger.info('discovered {} in {} on {}%{}'.format(self.display_name, self.props['BelongsTo'], addr, interface.interface.name)) elif ('FriendlyName' in self.props) and (report): self.display_name = self.props['FriendlyName'] logger.info('discovered {} on {}%{}'.format(self.display_name, addr, interface.interface.name)) logger.debug(str(self.props)) def extract_wsdp_props(self, root, dialect): _, _, propsRoot = dialect.rpartition('/') # XPath support is limited, so filter by namespace on our own nodes = root.findall('./wsdp:{0}/*'.format(propsRoot), namespaces) ns_prefix = '{{{}}}'.format(WSDP_URI) prop_nodes = [n for n in nodes if n.tag.startswith(ns_prefix)] for node in prop_nodes: tag_name = node.tag[len(ns_prefix):] self.props[tag_name] = node.text def extract_host_props(self, root): types = root.findtext('wsdp:Types', '', namespaces) self.props['types'] = types.split(' ') if types != PUB_COMPUTER: return comp = root.findtext(PUB_COMPUTER, '', namespaces) self.props['DisplayName'], _, self.props['BelongsTo'] = ( comp.partition('/')) class WSDClient(WSDUDPMessageHandler): instances = [] def __init__(self, mch): super().__init__(mch) WSDClient.instances.append(self) self.mch.add_handler(self.mch.mc_send_socket, self) self.mch.add_handler(self.mch.recv_socket, self) self.probes = {} self.handlers[WSD_HELLO] = self.handle_hello self.handlers[WSD_BYE] = self.handle_bye self.handlers[WSD_PROBE_MATCH] = self.handle_probe_match self.handlers[WSD_RESOLVE_MATCH] = self.handle_resolve_match # avoid packet storm when hosts come up by delaying initial probe time.sleep(random.randint(0, MAX_STARTUP_PROBE_DELAY)) self.send_probe() def cleanup(self): super().cleanup() WSDClient.instances.remove(self) self.mch.remove_handler(self.mch.mc_send_socket, self) self.mch.remove_handler(self.mch.recv_socket, self) def send_probe(self): """WS-Discovery, Section 4.3, Probe message""" self.remove_outdated_probes() probe = ElementTree.Element('wsd:Probe') ElementTree.SubElement(probe, 'wsd:Types').text = WSD_TYPE_DEVICE xml, i = self.build_message_tree(WSA_DISCOVERY, WSD_PROBE, None, probe) self.enqueue_datagram(self.xml_to_buffer(xml), msg_type='Probe') self.probes[i] = time.time() def teardown(self): super().teardown() self.remove_outdated_probes() def handle_request(self, msg, address): self.handle_message(msg, self.mch, address) def handle_hello(self, header, body): pm_path = 'wsd:Hello' endpoint, xaddrs = self.extract_endpoint_metadata(body, pm_path) if not xaddrs: logger.info('Hello without XAddrs, sending resolve') msg = self.build_resolve_message(endpoint) self.enqueue_datagram(msg) return xaddr = None for addr in xaddrs.strip().split(): if (self.mch.family == socket.AF_INET6) and ('//[fe80::' in addr): # use first link-local address for IPv6 xaddr = addr break elif self.mch.family == socket.AF_INET: # use first (and very likely the only) IPv4 address xaddr = addr break if xaddr is None: return logger.info('Hello from {} on {}'.format(endpoint, xaddr)) self.perform_metadata_exchange(endpoint, xaddr) def handle_bye(self, header, body): bye_path = 'wsd:Bye' endpoint, _ = self.extract_endpoint_metadata(body, bye_path) device_uuid = str(uuid.UUID(endpoint)) if device_uuid in WSDDiscoveredDevice.instances: del(WSDDiscoveredDevice.instances[device_uuid]) def handle_probe_match(self, header, body): # do not handle to probematches issued not sent by ourself rel_msg = header.findtext('wsa:RelatesTo', None, namespaces) if rel_msg not in self.probes: logger.debug("unknown probe {}".format(rel_msg)) return None # if XAddrs are missing, issue resolve request pm_path = 'wsd:ProbeMatches/wsd:ProbeMatch' endpoint, xaddrs = self.extract_endpoint_metadata(body, pm_path) if not xaddrs: logger.debug('probe match without XAddrs, sending resolve') msg = self.build_resolve_message(endpoint) self.enqueue_datagram(msg) return xaddr = xaddrs.strip() logger.debug('probe match for {} on {}'.format(endpoint, xaddr)) self.perform_metadata_exchange(endpoint, xaddr) def build_resolve_message(self, endpoint): resolve = ElementTree.Element('wsd:Resolve') self.add_endpoint_reference(resolve, endpoint) return self.build_message(WSA_DISCOVERY, WSD_RESOLVE, None, resolve) def handle_resolve_match(self, header, body): rm_path = 'wsd:ResolveMatches/wsd:ResolveMatch' endpoint, xaddrs = self.extract_endpoint_metadata(body, rm_path) if not endpoint or not xaddrs: logger.debug('resolve match without endpoint/xaddr') return xaddr = xaddrs.strip() logger.debug('resolve match for {} on {}'.format(endpoint, xaddr)) self.perform_metadata_exchange(endpoint, xaddr) def extract_endpoint_metadata(self, body, prefix): prefix = prefix + '/' addr_path = 'wsa:EndpointReference/wsa:Address' endpoint = body.findtext(prefix + addr_path, namespaces=namespaces) xaddrs = body.findtext(prefix + 'wsd:XAddrs', namespaces=namespaces) return endpoint, xaddrs def perform_metadata_exchange(self, endpoint, xaddr): if not (xaddr.startswith('http://') or xaddr.startswith('https://')): logger.debug('invalid XAddr: {}'.format(xaddr)) return host = None url = xaddr if self.mch.family == socket.AF_INET6: host = '[{}]'.format(url.partition('[')[2].partition(']')[0]) url = url.replace(']', '%{}]'.format(self.mch.interface.name)) body = self.build_getmetadata_message(endpoint) request = urllib.request.Request(url, data=body, method='POST') request.add_header('Content-Type', 'application/soap+xml') request.add_header('User-Agent', 'wsdd') if host is not None: request.add_header('Host', host) try: with urllib.request.urlopen(request, None, 2.0) as stream: self.handle_metadata(stream.read(), endpoint, xaddr) except urllib.error.URLError as e: logger.warning('could not fetch metadata from: {} {}'.format(url, e)) def build_getmetadata_message(self, endpoint): tree, _ = self.build_message_tree(endpoint, WSD_GET, None, None) return self.xml_to_buffer(tree) def handle_metadata(self, meta, endpoint, xaddr): device_uuid = str(uuid.UUID(endpoint)) if device_uuid in WSDDiscoveredDevice.instances: WSDDiscoveredDevice.instances[device_uuid].update(meta, xaddr, self.mch) else: WSDDiscoveredDevice.instances[device_uuid] = WSDDiscoveredDevice(meta, xaddr, self.mch) def remove_outdated_probes(self): cut = time.time() - PROBE_TIMEOUT * 2 self.probes = dict(filter(lambda x: x[1] > cut, self.probes.items())) def add_header_elements(self, header, extra): action_str = extra if action_str == WSD_GET: reply_to = ElementTree.SubElement(header, 'wsa:ReplyTo') addr = ElementTree.SubElement(reply_to, 'wsa:Address') addr.text = WSA_ANON wsa_from = ElementTree.SubElement(header, 'wsa:From') addr = ElementTree.SubElement(wsa_from, 'wsa:Address') addr.text = args.uuid.urn class WSDHost(WSDUDPMessageHandler): """Class for handling WSD requests coming from UDP datagrams.""" message_number = 0 instances = [] def __init__(self, mch): super().__init__(mch) WSDHost.instances.append(self) self.mch.add_handler(self.mch.recv_socket, self) self.handlers[WSD_PROBE] = self.handle_probe self.handlers[WSD_RESOLVE] = self.handle_resolve self.send_hello() def cleanup(self): super().cleanup() WSDHost.instances.remove(self) def teardown(self): super().teardown() self.send_bye() def handle_request(self, msg, address): reply = self.handle_message(msg, self.mch, address) if reply: self.enqueue_datagram(reply, address=address) def send_hello(self): """WS-Discovery, Section 4.1, Hello message""" hello = ElementTree.Element('wsd:Hello') self.add_endpoint_reference(hello) # THINK: Microsoft does not send the transport address here due # to privacy reasons. Could make this optional. self.add_xaddr(hello, self.mch.transport_address) self.add_metadata_version(hello) msg = self.build_message(WSA_DISCOVERY, WSD_HELLO, None, hello) self.enqueue_datagram(msg, msg_type='Hello') def send_bye(self): """WS-Discovery, Section 4.2, Bye message""" bye = ElementTree.Element('wsd:Bye') self.add_endpoint_reference(bye) msg = self.build_message(WSA_DISCOVERY, WSD_BYE, None, bye) self.enqueue_datagram(msg, msg_type='Bye') def handle_probe(self, header, body): probe = body.find('./wsd:Probe', namespaces) if probe is None: return None scopes = probe.find('./wsd:Scopes', namespaces) if scopes: # THINK: send fault message (see p. 21 in WSD) logger.debug('scopes ({}) unsupported but probed'.format(scopes)) return None types_elem = probe.find('./wsd:Types', namespaces) if types_elem is None: logger.debug('Probe message lacks wsd:Types element. Ignored.') return None types = types_elem.text if not types == WSD_TYPE_DEVICE: logger.debug('unknown discovery type ({}) for probe'.format(types)) return None matches = ElementTree.Element('wsd:ProbeMatches') match = ElementTree.SubElement(matches, 'wsd:ProbeMatch') self.add_endpoint_reference(match) self.add_types(match) self.add_metadata_version(match) return matches, WSD_PROBE_MATCH def handle_resolve(self, header, body): resolve = body.find('./wsd:Resolve', namespaces) if resolve is None: return None addr = resolve.find('./wsa:EndpointReference/wsa:Address', namespaces) if addr is None: logger.debug('invalid resolve request: missing endpoint address') return None if not addr.text == args.uuid.urn: logger.debug('invalid resolve request: address ({}) does not match own one ({})'.format( addr.text, args.uuid.urn)) return None matches = ElementTree.Element('wsd:ResolveMatches') match = ElementTree.SubElement(matches, 'wsd:ResolveMatch') self.add_endpoint_reference(match) self.add_types(match) self.add_xaddr(match, self.mch.transport_address) self.add_metadata_version(match) return matches, WSD_RESOLVE_MATCH def add_header_elements(self, header, extra): ElementTree.SubElement(header, 'wsd:AppSequence', { 'InstanceId': str(wsd_instance_id), 'SequenceId': uuid.uuid1().urn, 'MessageNumber': str(type(self).message_number)}) type(self).message_number += 1 class WSDHttpMessageHandler(WSDMessageHandler): def __init__(self): super().__init__() self.handlers[WSD_GET] = self.handle_get def handle_get(self, header, body): # see https://msdn.microsoft.com/en-us/library/hh441784.aspx for an # example. Some of the properties below might be made configurable # in future releases. metadata = ElementTree.Element('wsx:Metadata') section = ElementTree.SubElement(metadata, 'wsx:MetadataSection', {'Dialect': WSDP_URI + '/ThisDevice'}) device = ElementTree.SubElement(section, 'wsdp:ThisDevice') ElementTree.SubElement(device, 'wsdp:FriendlyName').text = ('WSD Device {0}'.format(args.hostname)) ElementTree.SubElement(device, 'wsdp:FirmwareVersion').text = '1.0' ElementTree.SubElement(device, 'wsdp:SerialNumber').text = '1' section = ElementTree.SubElement(metadata, 'wsx:MetadataSection', {'Dialect': WSDP_URI + '/ThisModel'}) model = ElementTree.SubElement(section, 'wsdp:ThisModel') ElementTree.SubElement(model, 'wsdp:Manufacturer').text = 'wsdd' ElementTree.SubElement(model, 'wsdp:ModelName').text = 'wsdd' ElementTree.SubElement(model, 'pnpx:DeviceCategory').text = 'Computers' section = ElementTree.SubElement(metadata, 'wsx:MetadataSection', {'Dialect': WSDP_URI + '/Relationship'}) rel = ElementTree.SubElement(section, 'wsdp:Relationship', {'Type': WSDP_URI + '/host'}) host = ElementTree.SubElement(rel, 'wsdp:Host') self.add_endpoint_reference(host) ElementTree.SubElement(host, 'wsdp:Types').text = PUB_COMPUTER ElementTree.SubElement(host, 'wsdp:ServiceId').text = args.uuid.urn fmt = '{0}/Domain:{1}' if args.domain else '{0}/Workgroup:{1}' value = args.domain if args.domain else args.workgroup.upper() if args.domain: dh = args.hostname if args.preserve_case else args.hostname.lower() else: dh = args.hostname if args.preserve_case else args.hostname.upper() ElementTree.SubElement(host, PUB_COMPUTER).text = fmt.format(dh, value) return metadata, WSD_GET_RESPONSE class WSDHttpServer(http.server.HTTPServer): """ HTTP server both with IPv6 support and WSD handling """ def __init__(self, mch, RequestHandlerClass, addr_family, aio_loop): # hacky way to convince HTTP/SocketServer of the address family type(self).address_family = addr_family # remember actual address family used by the server instance self.addr_family = addr_family self.mch = mch self.aio_loop = aio_loop self.wsd_handler = WSDHttpMessageHandler() self.registered = False super().__init__(mch.listen_address, RequestHandlerClass) def server_bind(self): if self.address_family == socket.AF_INET6: self.socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1) super().server_bind() def server_activate(self): super().server_activate() self.aio_loop.add_reader(self.fileno(), self.handle_request) self.registered = True def server_close(self): if self.registered: self.aio_loop.remove_reader(self.fileno()) super().server_close() class WSDHttpRequestHandler(http.server.BaseHTTPRequestHandler): """Class for handling WSD requests coming over HTTP""" def log_message(self, fmt, *args): logger.info("{} - - ".format(self.address_string()) + fmt % args) def do_POST(self): if self.path != '/' + str(args.uuid): self.send_error(http.HTTPStatus.NOT_FOUND) ct = self.headers['Content-Type'] if ct is None or not ct.startswith(MIME_TYPE_SOAP_XML): self.send_error(http.HTTPStatus.BAD_REQUEST, 'Invalid Content-Type') content_length = int(self.headers['Content-Length']) body = self.rfile.read(content_length) response = self.server.wsd_handler.handle_message(body, None, None) if response: self.send_response(http.HTTPStatus.OK) self.send_header('Content-Type', MIME_TYPE_SOAP_XML) self.end_headers() self.wfile.write(response) else: self.send_error(http.HTTPStatus.BAD_REQUEST) class ApiServer: def __init__(self, aio_loop, listen_address, address_monitor): self.server = None self.address_monitor = address_monitor # defer server creation self.create_task = aio_loop.create_task(self.create_server(aio_loop, listen_address)) async def create_server(self, aio_loop, listen_address): if isinstance(listen_address, int) or listen_address.isnumeric(): self.server = await aio_loop.create_task(asyncio.start_server(self.on_connect, host='localhost', port=int(listen_address), loop=aio_loop, reuse_address=True, reuse_port=True)) else: self.server = await aio_loop.create_task(asyncio.start_unix_server(self.on_connect, path=listen_address, loop=aio_loop)) async def on_connect(self, read_stream, write_stream): while True: try: line = await read_stream.readline() if line: self.handle_command(str(line.strip(), 'utf-8'), write_stream) if not write_stream.is_closing(): await write_stream.drain() else: write_stream.close() return except UnicodeDecodeError as e: logger.debug('invalid input utf8', e) except Exception as e: logger.warning('exception in API client', e) write_stream.close() return def handle_command(self, line, write_stream): words = line.split() if len(words) == 0: return command = words[0] command_args = words[1:] if command == 'probe' and args.discovery: intf = command_args[0] if command_args else None logger.debug('probing devices on {} upon request'.format(intf)) for client in self.get_clients_by_interface(intf): client.send_probe() elif command == 'clear' and args.discovery: logger.debug('clearing list of known devices') WSDDiscoveredDevice.instances.clear() elif command == 'list' and args.discovery: write_stream.write(bytes(self.get_list_reply(), 'utf-8')) elif command == 'quit': write_stream.close() elif command == 'start': self.address_monitor.enumerate() elif command == 'stop': self.address_monitor.teardown() else: logger.debug('could not handle API request: {}'.format(line)) def get_clients_by_interface(self, interface): return [c for c in WSDClient.instances if c.mch.interface.name == interface or not interface] def get_list_reply(self): retval = '' for dev_uuid, dev in WSDDiscoveredDevice.instances.items(): addrs_str = [] for mci, addrs in dev.addresses.items(): addrs_str.append(', '.join(['{}%{}'.format(a, mci.interface.name) for a in addrs])) retval = retval + '{}\t{}\t{}\t{}\t{}\n'.format( dev_uuid, dev.display_name, dev.props['BelongsTo'] if 'BelongsTo' in dev.props else '', datetime.datetime.fromtimestamp(dev.last_seen).isoformat('T', 'seconds'), ','.join(addrs_str)) retval += '.\n' return retval async def cleanup(self): # ensure the server is not created after we have teared down await self.create_task if self.server: self.server.close() await self.server.wait_closed() class NetworkInterface: def __init__(self, name, scope): self.name = name self.scope = scope class MetaEnumAfterInit(type): def __call__(cls, *cargs, **kwargs): obj = super().__call__(*cargs, **kwargs) if not args.no_autostart: obj.enumerate() return obj class NetworkAddressMonitor(metaclass=MetaEnumAfterInit): """ Observes changes of network addresses, handles addition and removal of network addresses, and filters for addresses/interfaces that are or are not handled. The actual OS-specific implementation that detects the changes is done in subclasses. This class is used as a singleton """ instance = None def __init__(self, aio_loop): if NetworkAddressMonitor.instance is not None: raise RuntimeError('Instance of NetworkAddressMonitor already created') NetworkAddressMonitor.instance = self self.interfaces = {} self.aio_loop = aio_loop self.mchs = [] self.http_servers = [] self.teardown_tasks = [] self.active = False def enumerate(self): """ Performs an initial enumeration of addresses and sets up everything for observing future changes. """ if self.active: return self.active = True self.do_enumerate() def do_enumerate(self): pass def handle_request(self): """ handle network change message """ pass def add_interface(self, name, idx, scope): if idx in self.interfaces: self.interfaces[idx].name = name else: self.interfaces[idx] = NetworkInterface(name, scope) return self.interfaces[idx] def is_address_handled(self, addr, addr_family, interface): """ Check if we should handle that address. Address must be provided as raw address, i.e. byte array """ # do not handle anything when we are not active if not self.active: return False # filter out address families we are not interested in if args.ipv4only and addr_family != socket.AF_INET: return False if args.ipv6only and addr_family != socket.AF_INET6: return False # Nah, this check is not optimal but there are no local flags for # addresses, but it should be safe for IPv4 anyways # (https://tools.ietf.org/html/rfc5735#page-3) if (addr_family == socket.AF_INET) and (addr[0] == 127): return False if (addr_family == socket.AF_INET6) and (addr[0:2] != b'\xfe\x80'): return False addr_str = socket.inet_ntop(addr_family, addr) if (args.interface) and (interface.name not in args.interface) and (addr_str not in args.interface): return False return True def handle_new_address(self, raw_addr, addr_family, interface): addr = socket.inet_ntop(addr_family, raw_addr) logger.debug('new address {} on {}'.format(addr, interface.name)) if not self.is_address_handled(raw_addr, addr_family, interface): logger.debug('ignoring that address on {}'.format(interface.name)) return # filter out what is not wanted # Ignore addresses or interfaces we already handle. There can only be # one multicast handler per address family and network interface for mch in self.mchs: if mch.handles(addr_family, addr, interface): return logger.debug('handling traffic for {} on {}'.format( addr, interface.name)) mch = MulticastHandler(addr_family, addr, interface, self.aio_loop) self.mchs.append(mch) if not args.no_host: WSDHost(mch) if not args.no_http: self.http_servers.append(WSDHttpServer( mch, WSDHttpRequestHandler, mch.family, self.aio_loop)) if args.discovery: WSDClient(mch) def handle_deleted_address(self, raw_addr, addr_family, interface): addr = socket.inet_ntop(addr_family, raw_addr) logger.info('deleted address {} on {}'.format(addr, interface.name)) if not self.is_address_handled(raw_addr, addr_family, interface): return mch = self.get_mch_by_address(addr_family, addr, interface) if mch is None: return # Do not tear the client/hosts down. Saying goodbye does not work # because the address is already gone (at least on Linux). for c in WSDClient.instances: if c.mch == mch: c.cleanup() break for h in WSDHost.instances: if h.mch == mch: h.cleanup() break for s in self.http_servers: if s.mch == mch: s.server_close() self.http_servers.remove(s) mch.cleanup() self.mchs.remove(mch) def teardown(self): if not self.active: return self.active = False # return if we are still in tear down process if len(self.teardown_tasks) > 0: return for h in WSDHost.instances: h.teardown() h.cleanup() self.teardown_tasks.extend(h.pending_tasks) for c in WSDClient.instances: c.teardown() c.cleanup() self.teardown_tasks.extend(c.pending_tasks) for s in self.http_servers: s.server_close() self.http_servers.clear() if not self.aio_loop.is_running(): # Wait here for all pending tasks so that the main loop can be finished on termination. self.aio_loop.run_until_complete(asyncio.gather(*self.teardown_tasks)) else: for t in self.teardown_tasks: t.add_done_callback(self.mch_teardown) def mch_teardown(self, task): if any([not t.done() for t in self.teardown_tasks]): return self.teardown_tasks.clear() for mch in self.mchs: mch.cleanup() self.mchs.clear() def cleanup(self): self.teardown() def get_mch_by_address(self, family, address, interface): """ Get the MCI for the address, its family and the interface. adress must be given as a string. """ for retval in self.mchs: if retval.handles(family, address, interface): return retval return None # from rtnetlink.h RTMGRP_LINK = 1 RTMGRP_IPV4_IFADDR = 0x10 RTMGRP_IPV6_IFADDR = 0x100 # from netlink.h NLM_HDR_LEN = 16 NLM_F_REQUEST = 0x01 NLM_F_ROOT = 0x100 NLM_F_MATCH = 0x200 NLM_F_DUMP = NLM_F_ROOT | NLM_F_MATCH # self defines NLM_HDR_LEN = 16 NLM_HDR_ALIGNTO = 4 # ifa flags IFA_F_DADFAILED = 0x08 IFA_F_HOMEADDRESS = 0x10 IFA_F_DEPRECATED = 0x20 IFA_F_TENTATIVE = 0x40 # from if_addr.h IFA_ADDRESS = 1 IFA_LOCAL = 2 IFA_LABEL = 3 IFA_FLAGS = 8 IFA_MSG_LEN = 8 RTA_ALIGNTO = 4 RTA_LEN = 4 def align_to(x, n): return ((x + n - 1) // n) * n class NetlinkAddressMonitor(NetworkAddressMonitor): """ Implementation of the AddressMonitor for Netlink sockets, i.e. Linux """ RTM_NEWADDR = 20 RTM_DELADDR = 21 RTM_GETADDR = 22 def __init__(self, aio_loop): super().__init__(aio_loop) rtm_groups = RTMGRP_LINK if not args.ipv4only: rtm_groups = rtm_groups | RTMGRP_IPV6_IFADDR if not args.ipv6only: rtm_groups = rtm_groups | RTMGRP_IPV4_IFADDR self.socket = socket.socket(socket.AF_NETLINK, socket.SOCK_RAW, socket.NETLINK_ROUTE) self.socket.bind((0, rtm_groups)) self.aio_loop.add_reader(self.socket.fileno(), self.handle_request) def do_enumerate(self): super().do_enumerate() kernel = (0, 0) req = struct.pack('@IHHIIB', NLM_HDR_LEN + 1, self.RTM_GETADDR, NLM_F_REQUEST | NLM_F_DUMP, 1, 0, socket.AF_PACKET) self.socket.sendto(req, kernel) def handle_request(self): super().handle_request() buf, src = self.socket.recvfrom(4096) logger.debug('netlink message with {} bytes'.format(len(buf))) offset = 0 while offset < len(buf): h_len, h_type, _, _, _ = struct.unpack_from('@IHHII', buf, offset) offset += NLM_HDR_LEN msg_len = h_len - NLM_HDR_LEN if msg_len < 0: break if h_type != self.RTM_NEWADDR and h_type != self.RTM_DELADDR: logger.debug('invalid rtm_message type {}'.format(h_type)) offset += align_to(msg_len, NLM_HDR_ALIGNTO) continue # decode ifaddrmsg as in rtnetlink.h ifa_family, _, ifa_flags, ifa_scope, ifa_idx = struct.unpack_from('@BBBBI', buf, offset) if ((ifa_flags & IFA_F_DADFAILED) or (ifa_flags & IFA_F_HOMEADDRESS) or (ifa_flags & IFA_F_DEPRECATED) or (ifa_flags & IFA_F_TENTATIVE)): logger.debug('ignore address with invalid state {}'.format(hex(ifa_flags))) offset += align_to(msg_len, NLM_HDR_ALIGNTO) continue logger.debug('RTM new/del addr family: {} flags: {} scope: {} idx: {}'.format( ifa_family, ifa_flags, ifa_scope, ifa_idx)) addr = None i = offset + IFA_MSG_LEN while i - offset < msg_len: attr_len, attr_type = struct.unpack_from('HH', buf, i) logger.debug('rt_attr {} {}'.format(attr_len, attr_type)) if attr_len < RTA_LEN: logger.debug('invalid rtm_attr_len. skipping remainder') break if attr_type == IFA_LABEL: name, = struct.unpack_from(str(attr_len - 4 - 1) + 's', buf, i + 4) self.add_interface(name.decode(), ifa_idx, ifa_scope) elif attr_type == IFA_LOCAL and ifa_family == socket.AF_INET: addr = buf[i + 4:i + 4 + 4] elif attr_type == IFA_ADDRESS and ifa_family == socket.AF_INET6: addr = buf[i + 4:i + 4 + 16] elif attr_type == IFA_FLAGS: _, ifa_flags = struct.unpack_from('HI', buf, i) i += align_to(attr_len, RTA_ALIGNTO) if addr is None: logger.debug('no address in RTM message') offset += align_to(msg_len, NLM_HDR_ALIGNTO) continue # In case of IPv6 only addresses, there appears to be no IFA_LABEL # message. Therefore, the name is requested by other means (#94) if ifa_idx not in self.interfaces: try: logger.debug('unknown interface name for idx {}. resolving manually'.format(ifa_idx)) if_name = socket.if_indextoname(ifa_idx) self.add_interface(if_name, ifa_idx, ifa_scope) except OSError: logger.exception('interface detection failed') # accept this exception (which should not occur) pass # In case really strange things happen and we could not find out the # interface name for the returned ifa_idx, we... log a message. if ifa_idx in self.interfaces: iface = self.interfaces[ifa_idx] if h_type == self.RTM_NEWADDR: self.handle_new_address(addr, ifa_family, iface) elif h_type == self.RTM_DELADDR: self.handle_deleted_address(addr, ifa_family, iface) else: logger.debug('unknown interface index: {}'.format(ifa_idx)) offset += align_to(msg_len, NLM_HDR_ALIGNTO) def cleanup(self): self.aio_loop.remove_reader(self.socket.fileno()) self.socket.close() super().cleanup() # from sys/net/route.h RTA_IFA = 0x20 # from sys/socket.h CTL_NET = 4 NET_RT_IFLIST = 3 # from sys/net/if.h IFF_LOOPBACK = 0x8 IFF_MULTICAST = 0x800 # sys/netinet6/in6_var.h IN6_IFF_TENTATIVE = 0x02 IN6_IFF_DUPLICATED = 0x04 IN6_IFF_NOTREADY = IN6_IFF_TENTATIVE | IN6_IFF_DUPLICATED SA_ALIGNTO = ctypes.sizeof(ctypes.c_long) class RouteSocketAddressMonitor(NetworkAddressMonitor): """ Implementation of the AddressMonitor for FreeBSD using route sockets """ # from sys/net/route.h RTM_NEWADDR = 0xC RTM_DELADDR = 0xD RTM_IFINFO = 0xE def __init__(self, selector): super().__init__(selector) self.intf_blacklist = [] # Create routing socket to get notified about future changes. # Do this before fetching the current routing information to avoid # race condition. self.socket = socket.socket(socket.AF_ROUTE, socket.SOCK_RAW, socket.AF_UNSPEC) self.aio_loop.add_reader(self.socket.fileno(), self.handle_request) def do_enumerate(self): super().do_enumerate() mib = [CTL_NET, socket.AF_ROUTE, 0, 0, NET_RT_IFLIST, 0] rt_mib = (ctypes.c_int * len(mib))() rt_mib[:] = mib[:] libc = ctypes.CDLL(ctypes.util.find_library('c'), use_errno=True) # Ask kernel for routing table size first. rt_size = ctypes.c_size_t() if libc.sysctl(ctypes.byref(rt_mib), ctypes.c_size_t(len(rt_mib)), 0, ctypes.byref(rt_size), 0, 0): raise OSError(ctypes.get_errno(), os.strerror(ctypes.get_errno())) # Get the initial routing (interface list) data. rt_buf = ctypes.create_string_buffer(rt_size.value) if libc.sysctl(ctypes.byref(rt_mib), ctypes.c_size_t(len(rt_mib)), rt_buf, ctypes.byref(rt_size), 0, 0): raise OSError(ctypes.get_errno(), os.strerror(ctypes.get_errno())) self.parse_route_socket_response(rt_buf.raw, True) def handle_request(self): super().handle_request() self.parse_route_socket_response(self.socket.recv(4096), False) def parse_route_socket_response(self, buf, keep_intf): offset = 0 intf = None intf_flags = 0 while offset < len(buf): rtm_len, _, rtm_type = struct.unpack_from('@HBB', buf, offset) # addr_mask has same offset in if_msghdr and ifs_msghdr addr_mask, flags = struct.unpack_from('ii', buf, offset + 4) msg_types = [self.RTM_NEWADDR, self.RTM_DELADDR, self.RTM_IFINFO] if rtm_type not in msg_types: offset += rtm_len continue if rtm_type == self.RTM_IFINFO: intf_flags = flags # those offset may unfortunately be architecture dependent sa_offset = offset + ((16 + 152) if rtm_type == self.RTM_IFINFO else 20) # For a route socket message, and different to a sysctl response, # the link info is stored inside the same rtm message, so it has to # survive multiple rtm messages in such cases if not keep_intf: intf = None new_intf = self.parse_addrs(buf, sa_offset, offset + rtm_len, intf, addr_mask, rtm_type, intf_flags) intf = new_intf if new_intf else intf offset += rtm_len def parse_addrs(self, buf, offset, limit, intf, addr_mask, rtm_type, flags): addr_type_idx = 1 addr = None addr_family = None while offset < limit: while not (addr_type_idx & addr_mask) and (addr_type_idx <= addr_mask): addr_type_idx = addr_type_idx << 1 sa_len, sa_fam = struct.unpack_from('@BB', buf, offset) if sa_fam in [socket.AF_INET, socket.AF_INET6] and addr_type_idx == RTA_IFA: addr_family = sa_fam addr_offset = 4 if sa_fam == socket.AF_INET else 8 addr_length = 16 if sa_fam == socket.AF_INET6 else 4 addr_start = offset + addr_offset addr = buf[addr_start:addr_start + addr_length] elif sa_fam == socket.AF_LINK: idx, _, name_len = struct.unpack_from('@HBB', buf, offset + 2) if idx > 0: off_name = offset + 8 if_name = (buf[off_name:off_name + name_len]).decode() intf = self.add_interface(if_name, idx, idx) offset += align_to(sa_len, SA_ALIGNTO) if sa_len > 0 else SA_ALIGNTO addr_type_idx = addr_type_idx << 1 if rtm_type == self.RTM_IFINFO and intf is not None: if flags & IFF_LOOPBACK or not flags & IFF_MULTICAST: self.intf_blacklist.append(intf.name) elif intf in self.intf_blacklist: self.intf_blacklist.remove(intf.name) if intf is None or intf.name in self.intf_blacklist or addr is None: return intf if rtm_type == self.RTM_DELADDR: self.handle_deleted_address(addr, addr_family, intf) else: # Too bad, the address may be unuseable (tentative, e.g.) here # but we won't get any further notifcation about the address being # available for use. Thus, we try and may fail here self.handle_new_address(addr, addr_family, intf) return intf def cleanup(self): self.aio_loop.remove_reader(self.socket.fileno()) self.socket.close() super().cleanup() def sigterm_handler(): logger.info('received termination/interrupt signal, tearing down') # implictely raise SystemExit to cleanup properly sys.exit(0) def parse_args(): global args, logger parser = argparse.ArgumentParser() parser.add_argument( '-i', '--interface', help='interface or address to use', action='append', default=[]) parser.add_argument( '-H', '--hoplimit', help='hop limit for multicast packets (default = 1)', type=int, default=1) parser.add_argument( '-U', '--uuid', help='UUID for the target device', default=None) parser.add_argument( '-v', '--verbose', help='increase verbosity', action='count', default=0) parser.add_argument( '-d', '--domain', help='set domain name (disables workgroup)', default=None) parser.add_argument( '-n', '--hostname', help='override (NetBIOS) hostname to be used (default hostname)', # use only the local part of a possible FQDN default=socket.gethostname().partition('.')[0]) parser.add_argument( '-w', '--workgroup', help='set workgroup name (default WORKGROUP)', default='WORKGROUP') parser.add_argument( '-A', '--no-autostart', help='do not start networking after launch', action='store_true') parser.add_argument( '-t', '--no-http', help='disable http service (for debugging, e.g.)', action='store_true') parser.add_argument( '-4', '--ipv4only', help='use only IPv4 (default = off)', action='store_true') parser.add_argument( '-6', '--ipv6only', help='use IPv6 (default = off)', action='store_true') parser.add_argument( '-s', '--shortlog', help='log only level and message', action='store_true') parser.add_argument( '-p', '--preserve-case', help='preserve case of the provided/detected hostname', action='store_true') parser.add_argument( '-c', '--chroot', help='directory to chroot into', default=None) parser.add_argument( '-u', '--user', help='drop privileges to user:group', default=None) parser.add_argument( '-D', '--discovery', help='enable discovery operation mode', action='store_true') parser.add_argument( '-l', '--listen', help='listen on path or localhost port in discovery mode', default=None) parser.add_argument( '-o', '--no-host', help='disable server mode operation (host will be undiscoverable)', action='store_true') parser.add_argument( '-V', '--version', help='show version number and exit', action='store_true') args = parser.parse_args(sys.argv[1:]) if args.version: print('wsdd - Web Service Discovery Daemon, v{}'.format(WSDD_VERSION)) sys.exit(0) if args.verbose == 1: log_level = logging.INFO elif args.verbose > 1: log_level = logging.DEBUG asyncio.get_event_loop().set_debug(True) logging.getLogger("asyncio").setLevel(logging.DEBUG) else: log_level = logging.WARNING if args.shortlog: fmt = '%(levelname)s: %(message)s' else: fmt = '%(asctime)s:%(name)s %(levelname)s(pid %(process)d): %(message)s' logging.basicConfig(level=log_level, format=fmt) logger = logging.getLogger('wsdd') if not args.interface: logger.warning('no interface given, using all interfaces') if not args.uuid: args.uuid = uuid.uuid5(uuid.NAMESPACE_DNS, socket.gethostname()) logger.info('using pre-defined UUID {0}'.format(str(args.uuid))) else: args.uuid = uuid.UUID(args.uuid) logger.info('user-supplied device UUID is {0}'.format(str(args.uuid))) for prefix, uri in namespaces.items(): ElementTree.register_namespace(prefix, uri) def chroot(root): """ Chroot into a separate directory to isolate ourself for increased security. """ # preload for socket.gethostbyaddr() import encodings.idna try: os.chroot(root) os.chdir('/') logger.info('chrooted successfully to {}'.format(root)) except Exception as e: logger.error('could not chroot to {}: {}'.format(root, e)) return False return True def get_ids_from_userspec(user_spec): uid = None gid = None try: user, _, group = user_spec.partition(':') if user: uid = pwd.getpwnam(user).pw_uid if group: gid = grp.getgrnam(group).gr_gid except Exception as e: logger.error('could not get uid/gid for {}: {}'.format(user_spec, e)) return False return (uid, gid) def drop_privileges(uid, gid): try: if gid is not None: os.setgid(gid) os.setegid(gid) logger.debug('switched uid to {}'.format(uid)) if uid is not None: os.setuid(uid) os.seteuid(uid) logger.debug('switched gid to {}'.format(gid)) logger.info('running as {} ({}:{})'.format(args.user, uid, gid)) except Exception as e: logger.error('dropping privileges failed: {}'.format(e)) return False return True def main(): parse_args() if args.ipv4only and args.ipv6only: logger.error('Listening to no IP address family.') return 4 aio_loop = asyncio.new_event_loop() if platform.system() == 'Linux': nm = NetlinkAddressMonitor(aio_loop) elif platform.system() == 'FreeBSD': nm = RouteSocketAddressMonitor(aio_loop) else: raise NotImplementedError('unsupported OS') api_server = None if args.listen: api_server = ApiServer(aio_loop, args.listen, nm) # get uid:gid before potential chroot'ing if args.user is not None: ids = get_ids_from_userspec(args.user) if not ids: return 3 if args.chroot is not None: if not chroot(args.chroot): return 2 if args.user is not None: if not drop_privileges(ids[0], ids[1]): return 3 if args.chroot and (os.getuid() == 0 or os.getgid() == 0): logger.warning('chrooted but running as root, consider -u option') # main loop, serve requests coming from any outbound socket aio_loop.add_signal_handler(signal.SIGINT, sigterm_handler) aio_loop.add_signal_handler(signal.SIGTERM, sigterm_handler) try: aio_loop.run_forever() except (SystemExit, KeyboardInterrupt): logger.info('shutting down gracefully...') if api_server is not None: aio_loop.run_until_complete(api_server.cleanup()) nm.cleanup() aio_loop.stop() except Exception: logger.exception('error in main loop') logger.info('Done.') return 0 if __name__ == '__main__': sys.exit(main()) test/000077500000000000000000000000001414625316100120405ustar00rootroot00000000000000test/netlink_monitor.py000077500000000000000000000055061414625316100156360ustar00rootroot00000000000000#!/usr/bin/python3 # Not really a test case, but a PoC for getting notified about changes in # network addreses on Linux using netlink sockets. import socket import struct # from rtnetlink.h RTMGRP_LINK = 1 RTMGRP_IPV4_IFADDR = 0x10 RTMGRP_IPV6_IFADDR = 0x100 RTM_NEWADDR = 20 RTM_DELADDR = 21 RTM_GETADDR = 22 # from netlink.h NLM_F_REQUEST = 0x01 NLM_F_ROOT = 0x100 NLM_F_MATCH = 0x200 NLM_F_DUMP = NLM_F_ROOT | NLM_F_MATCH # from if_addr.h IFA_ADDRESS = 1 IFA_LOCAL = 2 IFA_LABEL = 3 IFA_FLAGS = 8 # self_defines NLM_HDR_LEN = 16 NLM_HDR_ALIGNTO = 4 IFA_MSG_LEN = 8 # hardcoded as 4 in rtnetlink.h RTA_ALIGNTO = 4 def align_to(x, n): return ((x + n - 1) // n) * n s = socket.socket(socket.AF_NETLINK, socket.SOCK_RAW, socket.NETLINK_ROUTE) s.bind((0, RTMGRP_LINK | RTMGRP_IPV4_IFADDR | RTMGRP_IPV6_IFADDR)) kernel = (0, 0) req = struct.pack('@IHHIIB', NLM_HDR_LEN + 1, RTM_GETADDR, NLM_F_REQUEST | NLM_F_DUMP, 1, 0, socket.AF_PACKET) s.sendto(req, kernel) while True: buf, src = s.recvfrom(4096) offset = 0 while offset < len(buf): (h_len, h_type, h_flags, _, _) = struct.unpack_from( '@IHHII', buf, offset) msg_len = h_len - NLM_HDR_LEN if msg_len < 0: # print('invalid message size') break if h_type != RTM_NEWADDR and h_type != RTM_DELADDR: offset += align_to(msg_len, NLM_HDR_ALIGNTO) # print('not interested in message type ', h_type) # print('new offset: ', offset) continue offset += NLM_HDR_LEN # decode ifaddrmsg as in rtnetlink.h ifa_family, _, ifa_flags, ifa_scope, ifa_idx = struct.unpack_from( '@BBBBI', buf, offset) ifa_name = '' addr = '' # look for some details in attributes i = offset + IFA_MSG_LEN while i - offset < msg_len: attr_len, attr_type = struct.unpack_from('HH', buf, i) if attr_type == IFA_LABEL: ifa_name, = struct.unpack_from(str(attr_len - 4 - 1) + 's', buf, i + 4) elif attr_type == IFA_LOCAL and ifa_family == socket.AF_INET: b = buf[i + 4:i + 4 + 4] addr = socket.inet_ntop(socket.AF_INET, b) elif attr_type == IFA_ADDRESS and ifa_family == socket.AF_INET6: b = buf[i + 4:i + 4 + 16] addr = socket.inet_ntop(socket.AF_INET6, b) elif attr_type == IFA_FLAGS: _, ifa_flags = struct.unpack_from('HI', buf, i) i += align_to(attr_len, RTA_ALIGNTO) msg_type = 'NEW' if h_type == RTM_NEWADDR else 'DEL' print('{} addr on interface {} {} [{}]: {}'.format(msg_type, ifa_name, ifa_idx, hex(ifa_flags), addr)) offset += align_to(msg_len, NLM_HDR_ALIGNTO) test/routesocket_monitor.py000077500000000000000000000077451414625316100165500ustar00rootroot00000000000000#!/usr/local/bin/python3 # Not really a test case, but a PoC for getting notified about changes in # network addreses on FreeBSD using route sockets. import socket import struct import ctypes.util # from sys/net/route.h RTM_NEWADDR = 0xC RTM_DELADDR = 0xD RTM_IFINFO = 0xE RTA_IFA = 0x20 # from sys/socket.h CTL_NET = 4 NET_RT_IFLIST = 3 # from sys/net/if.h IFF_LOOPBACK = 0x8 IFF_MULTICAST = 0x800 SA_ALIGNTO = ctypes.sizeof(ctypes.c_long) # global link_blacklist = [] def parse_route_socket_response(buf, keep_link): offset = 0 link = None print(len(buf)) while offset < len(buf): rtm_len, _, rtm_type = struct.unpack_from('@HBB', buf, offset) # mask(addrs) has same offset in if_msghdr and ifs_msghdr addr_mask, flags = struct.unpack_from('ii', buf, offset + 4) msg_type = '' if rtm_type not in [RTM_NEWADDR, RTM_DELADDR, RTM_IFINFO]: offset += rtm_len continue # those offset may unfortunately be architecture dependent sa_offset = offset + ((16 + 152) if rtm_type == RTM_IFINFO else 20) if rtm_type in [RTM_NEWADDR, RTM_IFINFO]: msg_type = 'NEW' elif rtm_type == RTM_DELADDR: msg_type = 'DEL' # For a route socket message, and different to a sysctl response, the # link info is stored inside the same rtm message, so it has to # survive multiple rtm messages in such cases if not keep_link: link = None addr_type_idx = 1 addr = None while sa_offset < offset + rtm_len: while (not (addr_type_idx & addr_mask) and (addr_type_idx <= addr_mask)): addr_type_idx = addr_type_idx << 1 sa_len, sa_fam = struct.unpack_from('@BB', buf, sa_offset) if (sa_fam in [socket.AF_INET, socket.AF_INET6] and addr_type_idx == RTA_IFA): addr_offset = 4 if sa_fam == socket.AF_INET else 8 addr_length = 16 if sa_fam == socket.AF_INET6 else 4 addr = socket.inet_ntop(sa_fam, buf[(sa_offset + addr_offset):( sa_offset + addr_offset + addr_length)]) elif sa_fam == socket.AF_LINK: if_idx, if_type, name_len = struct.unpack_from( '@HBB', buf, sa_offset + 2) if if_idx > 0: name_start = sa_offset + 8 name = (buf[name_start:name_start + name_len]).decode() link = '{} {}'.format(name, if_idx) else: link = 'system link' jump = (((sa_len + SA_ALIGNTO - 1) // SA_ALIGNTO) * SA_ALIGNTO if sa_len > 0 else SA_ALIGNTO) sa_offset += jump addr_type_idx = addr_type_idx << 1 if link is not None and rtm_type == RTM_IFINFO and ( (flags & IFF_LOOPBACK) or not (flags & IFF_MULTICAST)): link_blacklist.append(link) if (link is not None and link not in link_blacklist) and ( addr is not None): print('{} addr on interface {}: {}'.format(msg_type, link, addr)) offset += rtm_len mib = [CTL_NET, socket.AF_ROUTE, 0, 0, NET_RT_IFLIST, 0] rt_mib = (ctypes.c_int * len(mib))() rt_mib[:] = mib[:] libc = ctypes.CDLL(ctypes.util.find_library('c'), use_errno=True) rt_size = ctypes.c_size_t() r = libc.sysctl(ctypes.byref(rt_mib), ctypes.c_size_t(len(rt_mib)), 0, ctypes.byref(rt_size), 0, 0) if r: print('unable to fetch routing table data') rt_buf = ctypes.create_string_buffer(rt_size.value) r = libc.sysctl(ctypes.byref(rt_mib), ctypes.c_size_t(len(rt_mib)), rt_buf, ctypes.byref(rt_size), 0, 0) if r: print('unable to fetch routing table data') parse_route_socket_response(rt_buf.raw, True) # get further notifications from the kernel s = socket.socket(socket.AF_ROUTE, socket.SOCK_RAW, socket.AF_UNSPEC) while True: buf = s.recv(4096) parse_route_socket_response(buf, False)