pax_global_header00006660000000000000000000000064144006246410014513gustar00rootroot0000000000000052 comment=2b1a7643fe7acd8229b8232ebf648c2bf882ff3c wsdd-0.7.1/000077500000000000000000000000001440062464100124615ustar00rootroot00000000000000wsdd-0.7.1/.github/000077500000000000000000000000001440062464100140215ustar00rootroot00000000000000wsdd-0.7.1/.github/workflows/000077500000000000000000000000001440062464100160565ustar00rootroot00000000000000wsdd-0.7.1/.github/workflows/codeql-analysis.yml000066400000000000000000000020521440062464100216700ustar00rootroot00000000000000name: "CodeQL" on: push: branches: ["master", "feat/workflows"] pull_request: # The branches below must be a subset of the branches above branches: ["master", "feat/workflows"] schedule: - cron: '0 0 * * */10' jobs: analyze: name: Analyze runs-on: ubuntu-latest strategy: fail-fast: false matrix: language: ['python'] # Learn more... # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection steps: - name: Checkout repository uses: actions/checkout@v2 with: # We must fetch at least the immediate parents so that if this is # a pull request then we can checkout the head. fetch-depth: 2 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL uses: github/codeql-action/init@v1 with: languages: ${{ matrix.language }} - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v1 wsdd-0.7.1/.github/workflows/static-analyses.yml000066400000000000000000000015611440062464100217100ustar00rootroot00000000000000name: "PEP8 and mypy" on: push: branches: ["master", "feat/workflows"] pull_request: branches: ["master", "feat/workflows"] schedule: - cron: '0 0 * * */10' jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v1 - name: Set up Python 3.7 uses: actions/setup-python@v1 with: python-version: 3.7 - name: Python 3.7 syntax check run: python -m py_compile src/wsdd.py - name: Lint with flake8 run: | pip install flake8 flake8 --count --show-source --statistics src - name: mypy type check run: | pip install mypy==0.910 mypy --python-version=3.7 src/wsdd.py mypy --python-version=3.8 src/wsdd.py mypy --python-version=3.9 src/wsdd.py mypy --python-version=3.10 src/wsdd.py mypy --python-version=3.11 src/wsdd.py wsdd-0.7.1/AUTHORS000066400000000000000000000000221440062464100135230ustar00rootroot00000000000000Steffen Christgau wsdd-0.7.1/CHANGELOG.md000066400000000000000000000067761440062464100143120ustar00rootroot00000000000000# Changelog ## Unreleased ## [0.7.1] - 2023-03-04 ### Added - GitHub workflow for static analyses added (syntax, format, and type checks are performed). - Added EnvironmentFile and according example for systemd-based distros. - Make wsdd work (again) on MacOS (#139). Thanks to Eugene Gershnik. - Application profile for UFW has been added (#169) ### Fixed - Use of implicitly present async I/O loop instead created one for API servers. Fixes regression due to changed API in Python 3.10 (see #162) ### Changed - Source code is spiced with type hints now. - man page moved to section 8. ## [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) wsdd-0.7.1/CONTRIBUTING.md000066400000000000000000000014071440062464100147140ustar00rootroot00000000000000# 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. * Use type hints. * 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. wsdd-0.7.1/LICENSE000066400000000000000000000020611440062464100134650ustar00rootroot00000000000000MIT 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. wsdd-0.7.1/README.md000066400000000000000000000431241440062464100137440ustar00rootroot00000000000000# 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, FreeBSD and MacOS. 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-Depending Instructions This section provides instructions how to install wsdd on different OS distributions. Sufficient privileges are assumed to be in effect, e.g. by being root or using sudo. ### 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](https://docs.fedoraproject.org/en-US/epel/), you can install wsdd like on Fedora where it is sufficient to issue ``` dnf install wsdd ``` ### Debian-based Distributions #### Debian There is currently no package for wsdd in the official Debian repositories. However, wsdd is considered to be part of the next Debian release, *Bookworm*, which in the testing phase. A wsdd package is also available in *unstable*. To install wsdd under Bullseye and earlier see the "Others" section below. #### Ubuntu/Mint Starting from Ubuntu 22.04 LTS (*Jammy Jellyfish*), wsdd has landed in the official *universe* package repository. Thus, it is sufficient to install it via ``` apt install wsdd ``` This also applies to Linux Mint, starting from version 21 (Vanessa). For older Ubuntu LTS or Linux Mint releases, see the "Others" section below. #### Others There are user-maintained packages for which you need to add the repository to the apt repo list and download the according GPG public key: ``` wget -O- https://pkg.ltec.ch/public/conf/ltec-ag.gpg.key | gpg --dearmour > /usr/share/keyrings/wsdd.gpg source /etc/os-release echo "deb [signed-by=/usr/share/keyrings/wsdd.gpg] https://pkg.ltec.ch/public/ ${UBUNTU_CODENAME:-${VERSION_CODENAME:-UNKNOWN}} main" > /etc/apt/sources.list.d/wsdd.list ``` Note that the repository only provides packages for Debian and Ubuntu LTS releases up to *Buster* and *Focal Fossa* (20.04), respectively. The `wsdd.list` file created by the command above should be checked to refer to an appropriate distro code name. After the GPG public key file and repository have been created, install wsdd via: ``` apt update apt install wsdd ``` ### FreeBSD The wsdd port can be installed via ``` pkg install py39-wsdd ``` ### Gentoo You can choose between two overlays: the GURU project and an [author-maintained dedicated overlay](https://github.com/christgau/wsdd-gentoo) which can be selected as follows ``` emerge eselect-repository eselect repository enable guru emerge --sync ``` After setting up one of them you can install wsdd with ``` 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, possibly dedicated, user for the service. 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. For UFW, an application profile can be found under `etc/ufw/applications.d`. Note that UFW profile only allow to grant the traffic on specific UDP and TCP ports, but a restriction on the IP range (like link local for IPv6) or the multicast traffic is not possible. ## 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 explicitly. Both modes can be used simultaneously. 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. wsdd-0.7.1/etc/000077500000000000000000000000001440062464100132345ustar00rootroot00000000000000wsdd-0.7.1/etc/openrc/000077500000000000000000000000001440062464100145225ustar00rootroot00000000000000wsdd-0.7.1/etc/openrc/conf.d/000077500000000000000000000000001440062464100156715ustar00rootroot00000000000000wsdd-0.7.1/etc/openrc/conf.d/wsdd000066400000000000000000000007041440062464100165560ustar00rootroot00000000000000# /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" wsdd-0.7.1/etc/openrc/init.d/000077500000000000000000000000001440062464100157075ustar00rootroot00000000000000wsdd-0.7.1/etc/openrc/init.d/wsdd000077500000000000000000000025471440062464100166060ustar00rootroot00000000000000#!/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 $? } wsdd-0.7.1/etc/rc.d/000077500000000000000000000000001440062464100140625ustar00rootroot00000000000000wsdd-0.7.1/etc/rc.d/wsdd000077500000000000000000000013331440062464100147510ustar00rootroot00000000000000#!/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" wsdd-0.7.1/etc/systemd/000077500000000000000000000000001440062464100147245ustar00rootroot00000000000000wsdd-0.7.1/etc/systemd/wsdd.defaults000066400000000000000000000002701440062464100174150ustar00rootroot00000000000000# Additional arguments for wsdd can be provided here. # Use, e.g., "-i eth0" to restrict operations to a specific interface # Refer to the wsdd(8) man page for details WSDD_PARAMS="" wsdd-0.7.1/etc/systemd/wsdd.service000066400000000000000000000014701440062464100172510ustar00rootroot00000000000000[Unit] Description=Web Services Dynamic Discovery host daemon Documentation=man:wsdd(8) ; 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. ; Thus, the next to lines are disabled and use BindsTo only. ; One may also add any of these services to After for stronger binding. ;BindsTo=smb.service ;BindsTo=samba.service [Service] Type=simple EnvironmentFile=/etc/default/wsdd ; 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 $WSDD_PARAMS DynamicUser=yes User=wsdd Group=wsdd RuntimeDirectory=wsdd AmbientCapabilities=CAP_SYS_CHROOT [Install] WantedBy=multi-user.target wsdd-0.7.1/etc/ufw/000077500000000000000000000000001440062464100140355ustar00rootroot00000000000000wsdd-0.7.1/etc/ufw/applications.d/000077500000000000000000000000001440062464100167455ustar00rootroot00000000000000wsdd-0.7.1/etc/ufw/applications.d/wsdd000066400000000000000000000001471440062464100176330ustar00rootroot00000000000000[Wsdd] title=Wsdd description=Web Service Discovery host daemon implementation ports=3702/udp|5357/tcp wsdd-0.7.1/man/000077500000000000000000000000001440062464100132345ustar00rootroot00000000000000wsdd-0.7.1/man/wsdd.8000066400000000000000000000270001440062464100142650ustar00rootroot00000000000000.TH wsdd 8 .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. wsdd-0.7.1/setup.cfg000066400000000000000000000002511440062464100143000ustar00rootroot00000000000000[flake8] max-line-length=120 # F401 - unused import (due to optional import of encoding package) # W503 - binary operators on newline (not end of line) ignore=F401,W503 wsdd-0.7.1/src/000077500000000000000000000000001440062464100132505ustar00rootroot00000000000000wsdd-0.7.1/src/wsdd.py000077500000000000000000002151641440062464100145770ustar00rootroot00000000000000#!/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 from typing import Any, Callable, ClassVar, Deque, Dict, List, Optional, Set, Union, Tuple, TYPE_CHECKING # try to load more secure XML module first, fallback to default if not present try: if not TYPE_CHECKING: from defusedxml.ElementTree import fromstring as ETfromString except ModuleNotFoundError: from xml.etree.ElementTree import fromstring as ETfromString WSDD_VERSION: str = '0.7.1' args: argparse.Namespace logger: logging.Logger class NetworkInterface: _name: str _index: int _scope: int def __init__(self, name: str, scope: int, index: int) -> None: self._name = name self._scope = scope if index is not None: self._index = index else: self._index = socket.if_nametoindex(self._name) @property def name(self) -> str: return self._name @property def scope(self) -> int: return self._scope @property def index(self) -> int: return self._index def __str__(self) -> str: return self._name def __eq__(self, other) -> bool: return self._name == other.name class NetworkAddress: _family: int _raw_address: bytes _address_str: str _interface: NetworkInterface def __init__(self, family: int, raw: Union[bytes, str], interface: NetworkInterface) -> None: self._family = family self._raw_address = raw if isinstance(raw, bytes) else socket.inet_pton(family, raw) self._interface = interface self._address_str = socket.inet_ntop(self._family, self._raw_address) @property def address_str(self): return self._address_str @property def family(self): return self._family @property def interface(self): return self._interface @property def is_multicastable(self): # 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) return ((self._family == socket.AF_INET) and (self._raw_address[0] == 127) or (self._family == socket.AF_INET6) and (self._raw_address[0:2] != b'\xfe\x80')) @property def raw(self): return self._raw_address @property def transport_str(self): """the string representation of the local address overridden in network setup (for IPv6)""" return self._address_str if self._family == socket.AF_INET else '[{}]'.format(self._address_str) def __str__(self) -> str: return '{}%{}'.format(self._address_str, self._interface.name) def __eq__(self, other) -> bool: return (self._family == other.family and self.raw == other.raw and self.interface == other.interface) class UdpAddress(NetworkAddress): _transport_address: Tuple _port: int def __init__(self, family, transport_address: Tuple, interface: NetworkInterface) -> None: if not (family == socket.AF_INET or family == socket.AF_INET6): raise RuntimeError('Unsupport address address family: {}.'.format(family)) self._transport_address = transport_address self._port = transport_address[1] super().__init__(family, transport_address[0], interface) @property def transport_address(self): return self._transport_address @property def port(self): return self._port def __eq__(self, other) -> bool: return self.transport_address == other.transport_address class INetworkPacketHandler: def handle_packet(self, msg: str, udp_src_address: UdpAddress) -> None: pass class MulticastHandler: """ A class for handling multicast traffic on a given interface for a given address family. It provides multicast sender and receiver sockets """ # base interface addressing information address: NetworkAddress # individual interface-bound sockets for: # - receiving multicast traffic # - sending multicast from a socket bound to WSD port # - sending unicast messages from a random port recv_socket: socket.socket mc_send_socket: socket.socket uc_send_socket: socket.socket # addresses used for communication and data multicast_address: UdpAddress listen_address: Tuple aio_loop: asyncio.AbstractEventLoop # dictionary that holds INetworkPacketHandlers instances for sockets created above message_handlers: Dict[socket.socket, List[INetworkPacketHandler]] def __init__(self, address: NetworkAddress, aio_loop: asyncio.AbstractEventLoop) -> None: self.address = address self.recv_socket = socket.socket(self.address.family, socket.SOCK_DGRAM) self.recv_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) self.mc_send_socket = socket.socket(self.address.family, socket.SOCK_DGRAM) self.uc_send_socket = socket.socket(self.address.family, socket.SOCK_DGRAM) self.uc_send_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) self.message_handlers = {} self.aio_loop = aio_loop if self.address.family == socket.AF_INET: self.init_v4() elif self.address.family == socket.AF_INET6: self.init_v6() logger.info('joined multicast group {0} on {1}'.format(self.multicast_address.transport_str, self.address)) logger.debug('transport address on {0} is {1}'.format(self.address.interface.name, self.address.transport_str)) 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.read_socket, self.recv_socket) self.aio_loop.add_reader(self.mc_send_socket.fileno(), self.read_socket, self.mc_send_socket) self.aio_loop.add_reader(self.uc_send_socket.fileno(), self.read_socket, self.uc_send_socket) def cleanup(self) -> None: 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_address(self, address: NetworkAddress) -> bool: return self.address == address def init_v6(self) -> None: idx = self.address.interface.index raw_mc_addr = (WSD_MCAST_GRP_V6, WSD_UDP_PORT, 0x575C, idx) self.multicast_address = UdpAddress(self.address.family, raw_mc_addr, self.address.interface) # v6: member_request = { multicast_addr, intf_idx } mreq = (socket.inet_pton(self.address.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((str(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.listen_address = (self.address.address_str, WSD_HTTP_PORT, 0, idx) def init_v4(self) -> None: idx = self.address.interface.index raw_mc_addr = (WSD_MCAST_GRP_V4, WSD_UDP_PORT) self.multicast_address = UdpAddress(self.address.family, raw_mc_addr, self.address.interface) # v4: member_request (ip_mreqn) = { multicast_addr, intf_addr, idx } mreq = (socket.inet_pton(self.address.family, WSD_MCAST_GRP_V4) + self.address.raw + 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.address_str, 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.address_str, WSD_HTTP_PORT) def add_handler(self, socket: socket.socket, handler: INetworkPacketHandler) -> None: # 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: socket.socket, handler) -> None: if socket in self.message_handlers: if handler in self.message_handlers[socket]: self.message_handlers[socket].remove(handler) def read_socket(self, key: socket.socket) -> None: # TODO: refactor this 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, raw_address = s.recvfrom(WSD_MAX_LEN) address = UdpAddress(self.address.family, raw_address, self.address.interface) if s in self.message_handlers: for handler in self.message_handlers[s]: handler.handle_packet(msg.decode('utf-8'), address) def send(self, msg: bytes, addr: UdpAddress): # 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.transport_address) else: self.uc_send_socket.sendto(msg, addr.transport_address) # constants for WSD XML/SOAP parsing WSA_URI: str = 'http://schemas.xmlsoap.org/ws/2004/08/addressing' WSD_URI: str = 'http://schemas.xmlsoap.org/ws/2005/04/discovery' WSDP_URI: str = 'http://schemas.xmlsoap.org/ws/2006/02/devprof' namespaces: Dict[str, str] = { '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: int = 10 WSD_PROBE: str = WSD_URI + '/Probe' WSD_PROBE_MATCH: str = WSD_URI + '/ProbeMatches' WSD_RESOLVE: str = WSD_URI + '/Resolve' WSD_RESOLVE_MATCH: str = WSD_URI + '/ResolveMatches' WSD_HELLO: str = WSD_URI + '/Hello' WSD_BYE: str = WSD_URI + '/Bye' WSD_GET: str = 'http://schemas.xmlsoap.org/ws/2004/09/transfer/Get' WSD_GET_RESPONSE: str = 'http://schemas.xmlsoap.org/ws/2004/09/transfer/GetResponse' WSD_TYPE_DEVICE: str = 'wsdp:Device' PUB_COMPUTER: str = 'pub:Computer' WSD_TYPE_DEVICE_COMPUTER: str = '{0} {1}'.format(WSD_TYPE_DEVICE, PUB_COMPUTER) WSD_MCAST_GRP_V4: str = '239.255.255.250' WSD_MCAST_GRP_V6: str = 'ff02::c' # link-local WSA_ANON: str = WSA_URI + '/role/anonymous' WSA_DISCOVERY: str = 'urn:schemas-xmlsoap-org:ws:2005:04:discovery' MIME_TYPE_SOAP_XML: str = 'application/soap+xml' # protocol assignments (WSD spec/Section 2.4) WSD_UDP_PORT: int = 3702 WSD_HTTP_PORT: int = 5357 WSD_MAX_LEN: int = 32767 WSDD_LISTEN_PORT = 5359 # SOAP/UDP transmission constants MULTICAST_UDP_REPEAT: int = 4 UNICAST_UDP_REPEAT: int = 2 UDP_MIN_DELAY: int = 50 UDP_MAX_DELAY: int = 250 UDP_UPPER_DELAY: int = 500 # servers must recond in 4 seconds after probe arrives PROBE_TIMEOUT: int = 4 MAX_STARTUP_PROBE_DELAY: int = 3 # some globals wsd_instance_id: int = int(time.time()) WSDMessage = Tuple[ElementTree.Element, str] MessageTypeHandler = Callable[[ElementTree.Element, ElementTree.Element], Optional[WSDMessage]] class WSDMessageHandler(INetworkPacketHandler): known_messages: Deque[str] = collections.deque([], WSD_MAX_KNOWN_MESSAGES) handlers: Dict[str, MessageTypeHandler] pending_tasks: List[asyncio.Task] def __init__(self) -> None: self.handlers = {} self.pending_tasks = [] def cleanup(self): pass # shortcuts for building WSD responses def add_endpoint_reference(self, parent: ElementTree.Element, endpoint: Optional[str] = None) -> 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: ElementTree.Element) -> None: meta_data = ElementTree.SubElement(parent, 'wsd:MetadataVersion') meta_data.text = '1' def add_types(self, parent: ElementTree.Element) -> None: dev_type = ElementTree.SubElement(parent, 'wsd:Types') dev_type.text = WSD_TYPE_DEVICE_COMPUTER def add_xaddr(self, parent: ElementTree.Element, transport_addr: str) -> None: 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: str, action_str: str, request_header: Optional[ElementTree.Element], response: ElementTree.Element) -> str: retval = self.xml_to_str(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: str, action_str: str, request_header: Optional[ElementTree.Element], body: Optional[ElementTree.Element]) -> Tuple[ElementTree.Element, str]: """ 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: ElementTree.Element, extra: Any) -> None: pass def handle_message(self, msg: str, src: Optional[UdpAddress] = None) -> Optional[str]: """ handle a WSD message """ 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 = str(msg_id_tag.text) # check for duplicates if 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: str = str(action_tag.text) _, _, action_method = action.rpartition('/') if src: logger.info('{}:{}({}) - - "{} {} UDP" - -'.format( src.transport_str, src.port, src.interface, 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: str) -> bool: """ 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_str(self, xml: ElementTree.Element) -> str: retval = '' retval = retval + ElementTree.tostring(xml, encoding='utf-8').decode('utf-8') return retval class WSDUDPMessageHandler(WSDMessageHandler): """ A message handler that handles traffic received via MutlicastHandler. """ mch: MulticastHandler tearing_down: bool def __init__(self, mch: MulticastHandler) -> None: super().__init__() self.mch = mch self.tearing_down = False def teardown(self): self.tearing_down = True def send_datagram(self, msg: str, dst: UdpAddress) -> None: try: self.mch.send(msg.encode('utf-8'), dst) except Exception as e: logger.error('error while sending packet on {}: {}'.format(self.mch.address.interface, e)) def enqueue_datagram(self, msg: str, address: UdpAddress, msg_type: Optional[str] = None) -> None: if msg_type: logger.info('scheduling {0} message via {1} to {2}'.format(msg_type, address.interface, 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: str, address: UdpAddress) -> None: """ 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: Dict[str, 'WSDDiscoveredDevice'] = {} addresses: Dict[str, Set[str]] props: Dict[str, str] display_name: str last_seen: float def __init__(self, xml_str: str, xaddr: str, interface: NetworkInterface) -> None: self.last_seen = 0.0 self.addresses = {} self.props = {} self.display_name = '' self.update(xml_str, xaddr, interface) def update(self, xml_str: str, xaddr: str, interface: NetworkInterface) -> None: 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.name not in self.addresses: self.addresses[interface.name] = set([addr]) else: if addr not in self.addresses[interface.name]: self.addresses[interface.name].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)) elif ('FriendlyName' in self.props) and (report): self.display_name = self.props['FriendlyName'] logger.info('discovered {} on {}'.format(self.display_name, addr)) logger.debug(str(self.props)) def extract_wsdp_props(self, root: ElementTree.Element, dialect: str) -> None: _, _, 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] = str(node.text) def extract_host_props(self, root: ElementTree.Element) -> None: types = root.findtext('wsdp:Types', '', namespaces) self.props['types'] = types.split(' ')[0] if types != PUB_COMPUTER: return comp = root.findtext(PUB_COMPUTER, '', namespaces) self.props['DisplayName'], _, self.props['BelongsTo'] = ( comp.partition('/')) class WSDClient(WSDUDPMessageHandler): instances: ClassVar[List['WSDClient']] = [] probes: Dict[str, float] def __init__(self, mch: MulticastHandler) -> None: 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) -> None: 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) -> None: """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_str(xml), self.mch.multicast_address, msg_type='Probe') self.probes[i] = time.time() def teardown(self) -> None: super().teardown() self.remove_outdated_probes() def handle_packet(self, msg: str, src: Optional[UdpAddress] = None) -> None: self.handle_message(msg, src) def handle_hello(self, header: ElementTree.Element, body: ElementTree.Element) -> Optional[WSDMessage]: 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(str(endpoint)) self.enqueue_datagram(msg, self.mch.multicast_address) return None xaddr = None for addr in xaddrs.strip().split(): if (self.mch.address.family == socket.AF_INET6) and ('//[fe80::' in addr): # use first link-local address for IPv6 xaddr = addr break elif self.mch.address.family == socket.AF_INET: # use first (and very likely the only) IPv4 address xaddr = addr break if xaddr is None: return None logger.info('Hello from {} on {}'.format(endpoint, xaddr)) self.perform_metadata_exchange(endpoint, xaddr) return None def handle_bye(self, header: ElementTree.Element, body: ElementTree.Element) -> Optional[WSDMessage]: 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] return None def handle_probe_match(self, header: ElementTree.Element, body: ElementTree.Element) -> Optional[WSDMessage]: # 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(str(endpoint)) self.enqueue_datagram(msg, self.mch.multicast_address) return None xaddr = xaddrs.strip() logger.debug('probe match for {} on {}'.format(endpoint, xaddr)) self.perform_metadata_exchange(endpoint, xaddr) return None def build_resolve_message(self, endpoint: str) -> str: 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: ElementTree.Element, body: ElementTree.Element) -> Optional[WSDMessage]: 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 None xaddr = xaddrs.strip() logger.debug('resolve match for {} on {}'.format(endpoint, xaddr)) self.perform_metadata_exchange(endpoint, xaddr) return None def extract_endpoint_metadata(self, body: ElementTree.Element, prefix: str) -> Tuple[Optional[str], Optional[str]]: 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: str): if not (xaddr.startswith('http://') or xaddr.startswith('https://')): logger.debug('invalid XAddr: {}'.format(xaddr)) return host = None url = xaddr if self.mch.address.family == socket.AF_INET6: host = '[{}]'.format(url.partition('[')[2].partition(']')[0]) url = url.replace(']', '%{}]'.format(self.mch.address.interface)) body = self.build_getmetadata_message(endpoint) request = urllib.request.Request(url, data=body.encode('utf-8'), 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) -> str: tree, _ = self.build_message_tree(endpoint, WSD_GET, None, None) return self.xml_to_str(tree) def handle_metadata(self, meta: str, endpoint: str, xaddr: str) -> None: device_uuid = str(uuid.UUID(endpoint)) if device_uuid in WSDDiscoveredDevice.instances: WSDDiscoveredDevice.instances[device_uuid].update(meta, xaddr, self.mch.address.interface) else: WSDDiscoveredDevice.instances[device_uuid] = WSDDiscoveredDevice(meta, xaddr, self.mch.address.interface) def remove_outdated_probes(self) -> None: cut = time.time() - PROBE_TIMEOUT * 2 self.probes = dict(filter(lambda x: x[1] > cut, self.probes.items())) def add_header_elements(self, header: ElementTree.Element, extra: Any) -> None: 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: ClassVar[int] = 0 instances: ClassVar[List['WSDHost']] = [] def __init__(self, mch: MulticastHandler) -> None: 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) -> None: super().cleanup() WSDHost.instances.remove(self) def teardown(self) -> None: super().teardown() self.send_bye() def handle_packet(self, msg: str, src: UdpAddress) -> None: reply = self.handle_message(msg, src) if reply: self.enqueue_datagram(reply, src) def send_hello(self) -> None: """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.address.transport_str) self.add_metadata_version(hello) msg = self.build_message(WSA_DISCOVERY, WSD_HELLO, None, hello) self.enqueue_datagram(msg, self.mch.multicast_address, msg_type='Hello') def send_bye(self) -> None: """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, self.mch.multicast_address, msg_type='Bye') def handle_probe(self, header: ElementTree.Element, body: ElementTree.Element) -> Optional[WSDMessage]: 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: ElementTree.Element, body: ElementTree.Element) -> Optional[WSDMessage]: 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.address.transport_str) self.add_metadata_version(match) return matches, WSD_RESOLVE_MATCH def add_header_elements(self, header: ElementTree.Element, extra: Any): 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) -> None: super().__init__() self.handlers[WSD_GET] = self.handle_get def handle_get(self, header: ElementTree.Element, body: ElementTree.Element) -> WSDMessage: # 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 """ mch: MulticastHandler aio_loop: asyncio.AbstractEventLoop wsd_handler: WSDHttpMessageHandler registered: bool def __init__(self, mch: MulticastHandler, aio_loop: asyncio.AbstractEventLoop): # hacky way to convince HTTP/SocketServer of the address family type(self).address_family = mch.address.family self.mch = mch self.aio_loop = aio_loop self.wsd_handler = WSDHttpMessageHandler() self.registered = False # WSDHttpRequestHandler is a BaseHTTPRequestHandler. Passing to the parent constructor is therefore safe and # we can ignore the type error reported by mypy super().__init__(mch.listen_address, WSDHttpRequestHandler) # type: ignore def server_bind(self) -> None: if self.mch.address.family == socket.AF_INET6: self.socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1) super().server_bind() def server_activate(self) -> None: super().server_activate() self.aio_loop.add_reader(self.fileno(), self.handle_request) self.registered = True def server_close(self) -> None: 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) -> None: logger.info("{} - - ".format(self.address_string()) + fmt % args) def do_POST(self) -> None: 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) # type: ignore if response: self.send_response(http.HTTPStatus.OK) self.send_header('Content-Type', MIME_TYPE_SOAP_XML) self.end_headers() self.wfile.write(response.encode('utf-8')) else: self.send_error(http.HTTPStatus.BAD_REQUEST) class ApiServer: address_monitor: 'NetworkAddressMonitor' def __init__(self, aio_loop: asyncio.AbstractEventLoop, listen_address: bytes, address_monitor: 'NetworkAddressMonitor') -> None: 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: asyncio.AbstractEventLoop, listen_address: Any) -> None: # It appears mypy is not able to check the argument to create_task and the return value of start_server # correctly. The docs say start_server returns a coroutine and the create_task takes a coro. And: It works. # Thus, we ignore type errors here. if isinstance(listen_address, int) or listen_address.isnumeric(): self.server = await aio_loop.create_task(asyncio.start_server( # type: ignore self.on_connect, host='localhost', port=int(listen_address), reuse_address=True, reuse_port=True)) else: self.server = await aio_loop.create_task(asyncio.start_unix_server( # type: ignore self.on_connect, path=listen_address)) async def on_connect(self, read_stream: asyncio.StreamReader, write_stream: asyncio.StreamWriter) -> None: 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: str, write_stream: asyncio.StreamWriter) -> None: 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: Optional[str]) -> List[WSDClient]: return [c for c in WSDClient.instances if c.mch.address.interface.name == interface or not interface] def get_list_reply(self) -> str: retval = '' for dev_uuid, dev in WSDDiscoveredDevice.instances.items(): addrs_str = [] for addrs in dev.addresses.items(): addrs_str.append(', '.join(['{}'.format(a) 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) -> None: # 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 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: ClassVar[object] = None interfaces: Dict[int, NetworkInterface] aio_loop: asyncio.AbstractEventLoop mchs: List[MulticastHandler] http_servers: List[WSDHttpServer] teardown_tasks: List[asyncio.Task] active: bool def __init__(self, aio_loop: asyncio.AbstractEventLoop) -> None: 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) -> None: """ 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) -> None: pass def handle_change(self) -> None: """ handle network change message """ pass def add_interface(self, interface: NetworkInterface) -> NetworkInterface: # TODO: Cleanup if interface.index in self.interfaces: pass # self.interfaces[idx].name = name else: self.interfaces[interface.index] = interface return self.interfaces[interface.index] def is_address_handled(self, address: NetworkAddress) -> bool: # 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 address.family != socket.AF_INET: return False if args.ipv6only and address.family != socket.AF_INET6: return False if address.is_multicastable: return False # Use interface only if it's in the list of user-provided interface names if ((args.interface) and (address.interface.name not in args.interface) and (address.address_str not in args.interface)): return False return True def handle_new_address(self, address: NetworkAddress) -> None: logger.debug('new address {}'.format(address)) if not self.is_address_handled(address): logger.debug('ignoring that address on {}'.format(address.interface)) 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_address(address): return logger.debug('handling traffic for {}'.format(address)) mch = MulticastHandler(address, 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, self.aio_loop)) if args.discovery: WSDClient(mch) def handle_deleted_address(self, address: NetworkAddress) -> None: logger.info('deleted address {}'.format(address)) if not self.is_address_handled(address): return mch: Optional[MulticastHandler] = self.get_mch_by_address(address) 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) -> None: 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) -> None: 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) -> None: self.teardown() def get_mch_by_address(self, address: NetworkAddress) -> Optional[MulticastHandler]: """ 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_address(address): return retval return None # from rtnetlink.h RTMGRP_LINK: int = 1 RTMGRP_IPV4_IFADDR: int = 0x10 RTMGRP_IPV6_IFADDR: int = 0x100 # from netlink.h (struct nlmsghdr) NLM_HDR_DEF: str = '@IHHII' NLM_F_REQUEST: int = 0x01 NLM_F_ROOT: int = 0x100 NLM_F_MATCH: int = 0x200 NLM_F_DUMP: int = NLM_F_ROOT | NLM_F_MATCH # self defines NLM_HDR_ALIGNTO: int = 4 # ifa flags IFA_F_DADFAILED: int = 0x08 IFA_F_HOMEADDRESS: int = 0x10 IFA_F_DEPRECATED: int = 0x20 IFA_F_TENTATIVE: int = 0x40 # from if_addr.h (struct ifaddrmsg) IFADDR_MSG_DEF: str = '@BBBBI' IFA_ADDRESS: int = 1 IFA_LOCAL: int = 2 IFA_LABEL: int = 3 IFA_FLAGS: int = 8 IFA_MSG_LEN: int = 8 RTA_ALIGNTO: int = 4 RTA_LEN: int = 4 def align_to(x: int, n: int) -> int: return ((x + n - 1) // n) * n class NetlinkAddressMonitor(NetworkAddressMonitor): """ Implementation of the AddressMonitor for Netlink sockets, i.e. Linux """ RTM_NEWADDR: int = 20 RTM_DELADDR: int = 21 RTM_GETADDR: int = 22 socket: socket.socket def __init__(self, aio_loop: asyncio.AbstractEventLoop) -> None: 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_change) self.NLM_HDR_LEN = struct.calcsize(NLM_HDR_DEF) def do_enumerate(self) -> None: super().do_enumerate() kernel = (0, 0) # Append an unsigned byte to the header for the request. req = struct.pack(NLM_HDR_DEF + 'B', self.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_change(self) -> None: super().handle_change() 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(NLM_HDR_DEF, buf, offset) offset += self.NLM_HDR_LEN msg_len = h_len - self.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 if_addr.h ifa_family, _, ifa_flags, ifa_scope, ifa_idx = struct.unpack_from(IFADDR_MSG_DEF, 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(NetworkInterface(name.decode(), ifa_scope, ifa_idx)) 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(NetworkInterface(if_name, ifa_scope, ifa_idx)) 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: address = NetworkAddress(ifa_family, addr, self.interfaces[ifa_idx]) if h_type == self.RTM_NEWADDR: self.handle_new_address(address) elif h_type == self.RTM_DELADDR: self.handle_deleted_address(address) else: logger.debug('unknown interface index: {}'.format(ifa_idx)) offset += align_to(msg_len, NLM_HDR_ALIGNTO) def cleanup(self) -> None: self.aio_loop.remove_reader(self.socket.fileno()) self.socket.close() super().cleanup() # from sys/net/route.h RTA_IFA: int = 0x20 # from sys/socket.h CTL_NET: int = 4 NET_RT_IFLIST: int = 3 # from sys/net/if.h IFF_LOOPBACK: int = 0x8 IFF_MULTICAST: int = 0x800 # sys/netinet6/in6_var.h IN6_IFF_TENTATIVE: int = 0x02 IN6_IFF_DUPLICATED: int = 0x04 IN6_IFF_NOTREADY: int = IN6_IFF_TENTATIVE | IN6_IFF_DUPLICATED SA_ALIGNTO: int = ctypes.sizeof(ctypes.c_long) if platform.system() != "Darwin" else ctypes.sizeof(ctypes.c_uint32) class RouteSocketAddressMonitor(NetworkAddressMonitor): """ Implementation of the AddressMonitor for FreeBSD and Darwin using route sockets """ # Common definition for beginning part of if(m?a)?_msghdr structs (see net/if.h). IF_COMMON_HDR_DEF: str = '@HBBii' RTM_NEWADDR: int = 0xC RTM_DELADDR: int = 0xD RTM_IFINFO: int = 0xE # from net/if.h (struct ifa_msghdr) IFA_MSGHDR_DEF: str = IF_COMMON_HDR_DEF + 'hi' IFA_MSGHDR_SIZE: int = struct.calcsize(IFA_MSGHDR_DEF) IF_MSGHDR_DEF_BASE: str = IF_COMMON_HDR_DEF + 'h' # The struct package does not allow to specify those, thus we hard code them as chars (x4). IF_DATA_DEFS: Dict[str, str] = { # if_data in if_msghdr is prepended with an u_short _ifm_spare1, thus the 'H' a the beginning) 'FreeBSD': 'H6c2c8c8c104c8c16c', # There are 8 bytes and 22 uint32_t in the if_data struct (22 x 4 Bytes + 8 = 96 Bytes) # It is also aligned on 4-byte boundary necessitating 2 bytes padding inside if_msghdr 'Darwin': '2c8c22I' } socket: socket.socket intf_blacklist: List[str] def __init__(self, aio_loop: asyncio.AbstractEventLoop) -> None: super().__init__(aio_loop) 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_change) self.IF_MSGHDR_SIZE = struct.calcsize(self.IF_MSGHDR_DEF_BASE + self.IF_DATA_DEFS[platform.system()]) def do_enumerate(self) -> None: super().do_enumerate() mib = [CTL_NET, socket.AF_ROUTE, 0, 0, NET_RT_IFLIST, 0] rt_mib = (ctypes.c_int * len(mib))() rt_mib[:] = [ctypes.c_int(m) for m in 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_change(self) -> None: super().handle_change() self.parse_route_socket_response(self.socket.recv(4096), False) def parse_route_socket_response(self, buf: bytes, keep_intf: bool) -> None: offset = 0 intf = None intf_flags = 0 while offset < len(buf): # unpack route message response rtm_len, _, rtm_type, addr_mask, flags = struct.unpack_from(self.IF_COMMON_HDR_DEF, buf, offset) if rtm_type not in [self.RTM_NEWADDR, self.RTM_DELADDR, self.RTM_IFINFO]: offset += rtm_len continue if rtm_type == self.RTM_IFINFO: intf_flags = flags sa_offset = offset + (self.IF_MSGHDR_SIZE if rtm_type == self.RTM_IFINFO else self.IFA_MSGHDR_SIZE) # 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 clear_addr_scope(self, raw_addr: bytes) -> bytes: addr: bytearray = bytearray(raw_addr) # adapted from in6_clearscope BSD/Mac kernel method (see scope6.c) if addr[0] == 0xfe and (addr[1] & 0xc0) == 0x80: addr[2] = 0 addr[3] = 0 return bytes(addr) def parse_addrs(self, buf: bytes, offset: int, limit: int, intf: Optional[NetworkInterface], addr_mask: int, rtm_type: int, flags: int) -> Optional[NetworkInterface]: addr_type_idx = 1 addr = None addr_family: int = socket.AF_UNSPEC 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 = 4 if sa_fam == socket.AF_INET else 16 addr_start = offset + addr_offset addr = buf[addr_start:addr_start + addr_length] if sa_fam == socket.AF_INET6: addr = self.clear_addr_scope(addr) 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(NetworkInterface(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.name 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 address = NetworkAddress(addr_family, addr, intf) if rtm_type == self.RTM_DELADDR: self.handle_deleted_address(address) 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(address) return intf def cleanup(self) -> None: self.aio_loop.remove_reader(self.socket.fileno()) self.socket.close() super().cleanup() def sigterm_handler() -> None: logger.info('received termination/interrupt signal, tearing down') # implictely raise SystemExit to cleanup properly sys.exit(0) def parse_args() -> None: 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: str) -> bool: """ 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: str) -> Tuple[int, int]: uid: int gid: int 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: raise RuntimeError('could not get uid/gid for {}: {}'.format(user_spec, e)) return (uid, gid) def drop_privileges(uid: int, gid: int) -> bool: 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 create_address_monitor(system: str, aio_loop: asyncio.AbstractEventLoop) -> NetworkAddressMonitor: if system == 'Linux': return NetlinkAddressMonitor(aio_loop) elif system in ['FreeBSD', 'Darwin']: return RouteSocketAddressMonitor(aio_loop) else: raise NotImplementedError('unsupported OS: ' + system) def main() -> int: global logger, args parse_args() if args.ipv4only and args.ipv6only: logger.error('Listening to no IP address family.') return 4 aio_loop = asyncio.new_event_loop() nm = create_address_monitor(platform.system(), aio_loop) 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()) wsdd-0.7.1/test/000077500000000000000000000000001440062464100134405ustar00rootroot00000000000000wsdd-0.7.1/test/linting/000077500000000000000000000000001440062464100151045ustar00rootroot00000000000000wsdd-0.7.1/test/linting/mypy.sh000077500000000000000000000003241440062464100164400ustar00rootroot00000000000000#!/bin/bash root_dir="$(realpath $(dirname $0)/../..)" for version in 3.7 3.8 3.9 3.10 3.11; do echo -n "checking for Python ${version}..." mypy --python-version=${version} ${root_dir}/src/wsdd.py echo done wsdd-0.7.1/test/netlink_monitor.py000077500000000000000000000055061440062464100172360ustar00rootroot00000000000000#!/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) wsdd-0.7.1/test/regressions/000077500000000000000000000000001440062464100160035ustar00rootroot00000000000000wsdd-0.7.1/test/regressions/01_asyncio_changes_python310/000077500000000000000000000000001440062464100232655ustar00rootroot00000000000000wsdd-0.7.1/test/regressions/01_asyncio_changes_python310/run_tcp.sh000077500000000000000000000021151440062464100252750ustar00rootroot00000000000000#!/bin/bash # Test if TCP server socket for API can be created with different Python versions # The API for ´start_server´ was schanged in 3.10. # @see related Github issue #162 python_versions=('3.7' '3.8' '3.9.' '3.10' '3.11') socket_port="3333" wsdd_script_args=("--no-autostart" "--no-http" "--discovery" "--listen" "${socket_port}") return_code=0 python_found=0 # Use netcat to connec to UNIX Domain Socket netcat="nc" netcat_args=("-N", "127.0.0.1:${socket_port}") for version in "${python_versions[@]}"; do python_ver_name="python${version}" if command -v "${python_ver_name}" >/dev/null 2>&1; then python_found=1 "${python_ver_name}" "${WSDD_SCRIPT}" "${wsdd_script_args[@]}" & wsdd_pid=$! # wait until socket should be ready sleep 2 # connect to socket and send a simple command ${netcat} "${netcat_args[@]}" <<< "list" # terminate and get exit status kill -INT ${wsdd_pid} wait ${wsdd_pid} status=$? if [ ! ${status} -eq 0 ]; then return_code=1 fi fi done if [ $python_found -eq 1 ]; then exit ${return_code} else exit 2 fi wsdd-0.7.1/test/regressions/01_asyncio_changes_python310/run_unix.sh000077500000000000000000000023311440062464100254720ustar00rootroot00000000000000#!/bin/bash # Test if Unix Domain Socket for API can be created with different Python versions # The API for ´start_unix_server´ was schanged in 3.10. # @see Github issue #162 python_versions=('3.7' '3.8' '3.9.' '3.10' '3.11') socket_dir="$(mktemp -d)" socket_filename="${socket_dir}/wsdd.sock" wsdd_script_args=("--no-autostart" "--no-http" "--discovery" "--listen" "${socket_filename}") return_code=0 python_found=0 # Use netcat to connec to UNIX Domain Socket netcat="nc" netcat_args=("-U" "${socket_filename}" "-N") for version in "${python_versions[@]}"; do python_ver_name="python${version}" if command -v "${python_ver_name}" >/dev/null 2>&1; then python_found=1 rm -f "${socket_filename}" "${python_ver_name}" "${WSDD_SCRIPT}" "${wsdd_script_args[@]}" & wsdd_pid=$! # wait until socket should be ready sleep 2 # connect to socket and send a simple command ${netcat} "${netcat_args[@]}" <<< "list" # terminate and get exit status kill -INT ${wsdd_pid} wait ${wsdd_pid} status=$? if [ ! ${status} -eq 0 ]; then return_code=1 fi rm -f "${socket_filename}" fi done find "${socket_dir}" -delete if [ $python_found -eq 1 ]; then exit ${return_code} else exit 2 fi wsdd-0.7.1/test/regressions/run-regressions.sh000077500000000000000000000021151440062464100215060ustar00rootroot00000000000000#!/bin/bash # Run regression tests found as executable .sh scripts in level-1 subdirectories. basedir="$(realpath "$(dirname "$0")")" export WSDD_ROOT_DIR="$(realpath "${basedir}/../..")" export WSDD_SCRIPT="${WSDD_ROOT_DIR}/src/wsdd.py" test_files=() for script in "${basedir}"/*/*.sh; do if [ -x ${script} ]; then test_files+=("${script}") fi done total_tests="${#test_files[@]}" [ ${total_tests} -eq 0 ] && exit 0 echo "Running ${total_tests} tests..." test_number=1 num_succeeded=0 num_failed=0 for test_case in "${test_files[@]}"; do # change later to capture test output log_target="/dev/null" echo -n "[${test_number}/${total_tests}] $(basename $(dirname "$test_case")) -> $(basename "${test_case}")... " if "${test_case}" > ${log_target} 2>&1; then echo "OK" num_succeeded=$((num_succeeded + 1)) else echo "FAILED" num_failed=$((num_failed + 1)) fi test_number=$(($test_number + 1)) done echo "------------------------------------------" echo "${num_succeeded} succeeded, ${num_failed} failed." exit $((${total_tests} - ${num_succeeded})) wsdd-0.7.1/test/routesocket_monitor.py000077500000000000000000000077451440062464100201500ustar00rootroot00000000000000#!/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): # mask(addrs) has same offset in if_msghdr and ifs_msghdr rtm_len, _, rtm_type, addr_mask, flags = struct.unpack_from( '@HBBii', buf, offset) msg_type = '' if rtm_type not in [RTM_NEWADDR, RTM_DELADDR, RTM_IFINFO]: offset += rtm_len continue # those offset may unfortunately be architecture dependent # (152 is FreeBSD-specific) 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)