pax_global_header 0000666 0000000 0000000 00000000064 14602102110 0014475 g ustar 00root root 0000000 0000000 52 comment=486e054ec73bc12a79d465d1fbd0c11c12b0f014
wsdd-0.8/ 0000775 0000000 0000000 00000000000 14602102110 0012305 5 ustar 00root root 0000000 0000000 wsdd-0.8/.github/ 0000775 0000000 0000000 00000000000 14602102110 0013645 5 ustar 00root root 0000000 0000000 wsdd-0.8/.github/workflows/ 0000775 0000000 0000000 00000000000 14602102110 0015702 5 ustar 00root root 0000000 0000000 wsdd-0.8/.github/workflows/codeql-analysis.yml 0000664 0000000 0000000 00000002052 14602102110 0021514 0 ustar 00root root 0000000 0000000 name: "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.8/.github/workflows/static-analyses.yml 0000664 0000000 0000000 00000001640 14602102110 0021532 0 ustar 00root root 0000000 0000000 name: "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
mypy --python-version=3.12 src/wsdd.py
wsdd-0.8/AUTHORS 0000664 0000000 0000000 00000000022 14602102110 0013347 0 ustar 00root root 0000000 0000000 Steffen Christgau
wsdd-0.8/CHANGELOG.md 0000664 0000000 0000000 00000010220 14602102110 0014111 0 ustar 00root root 0000000 0000000 # Changelog
## [0.8] -- 2024-03-30
### Added
- Support for OpenBSD (tested on riscv64 with OpenBSD 7.4)
- Configuration files for firewalld (#186). Thanks to Ondrej Holy.
- Show device type and allow filtering in API's `list` command (#189). Thanks to Ondrej Holy.
- Add option `--metadata-timeout` to set the timeout for the HTTP-based metadata exchange (closes #83)
### Changed
- The employed UUID is now read from `/etc/{machine-id,hostid}` before falling by back to the UUID derivation from the host name.
### Fixed
- Handle addresses with zone id by ignoring the interface part (#184)
- Do not crash with asyncio future error when non-existing interface is provided (#201)
## [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.8/CONTRIBUTING.md 0000664 0000000 0000000 00000001407 14602102110 0014540 0 ustar 00root root 0000000 0000000 # 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.8/LICENSE 0000664 0000000 0000000 00000002061 14602102110 0013311 0 ustar 00root root 0000000 0000000 MIT 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.8/README.md 0000664 0000000 0000000 00000040153 14602102110 0013567 0 ustar 00root root 0000000 0000000 # 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, OpenBSD and MacOS.
Other Unixes, such as 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, Ubuntu, Mint, ...)
Wsdd is included in the official package repositories of Debian and Ubuntu
(*universe*) since versions 12 (*Bookworm*) and 22.04 LTS (*Jammy Jellyfish*),
respectively. This also applies to Linux Mint, starting from version 21
(Vanessa). Thus, it is sufficient to install it via
```
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, e.g. FreeBSD's rc.d, Gentoo's openrc, and systemd
which is used in most contemporary Linux distros. Those files may be used as
templates. 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 and firewald, application/service profiles can be found in the respective directories.
Note that UFW profiles 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.
* `--metadata-timeout TIMEOUT`
Set the timeout for HTTP-based metadata exchange. Default is 2.0 seconds.
* `-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
attempts to read the machine ID from `/etc/machine-id` and `/etc/hostid`
(in that order) before potentially chrooting in another environment. If
reading the machine ID fails, wsdd falls back to a version 5 UUID 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 the 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 host's 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 to drop privileges and chroot.
## 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. 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.
## Other stuff
* 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.
wsdd-0.8/etc/ 0000775 0000000 0000000 00000000000 14602102110 0013060 5 ustar 00root root 0000000 0000000 wsdd-0.8/etc/firewalld/ 0000775 0000000 0000000 00000000000 14602102110 0015031 5 ustar 00root root 0000000 0000000 wsdd-0.8/etc/firewalld/services/ 0000775 0000000 0000000 00000000000 14602102110 0016654 5 ustar 00root root 0000000 0000000 wsdd-0.8/etc/firewalld/services/wsdd-http.xml 0000664 0000000 0000000 00000000563 14602102110 0021320 0 ustar 00root root 0000000 0000000
Web Services Dynamic Discovery host daemon (HTTP Interface)
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.
wsdd-0.8/etc/firewalld/services/wsdd.xml 0000664 0000000 0000000 00000000672 14602102110 0020344 0 ustar 00root root 0000000 0000000
Web Services Dynamic Discovery host daemon
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.
wsdd-0.8/etc/openrc/ 0000775 0000000 0000000 00000000000 14602102110 0014346 5 ustar 00root root 0000000 0000000 wsdd-0.8/etc/openrc/conf.d/ 0000775 0000000 0000000 00000000000 14602102110 0015515 5 ustar 00root root 0000000 0000000 wsdd-0.8/etc/openrc/conf.d/wsdd 0000664 0000000 0000000 00000000704 14602102110 0016402 0 ustar 00root root 0000000 0000000 # /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.8/etc/openrc/init.d/ 0000775 0000000 0000000 00000000000 14602102110 0015533 5 ustar 00root root 0000000 0000000 wsdd-0.8/etc/openrc/init.d/wsdd 0000775 0000000 0000000 00000002547 14602102110 0016432 0 ustar 00root root 0000000 0000000 #!/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.8/etc/rc.d/ 0000775 0000000 0000000 00000000000 14602102110 0013706 5 ustar 00root root 0000000 0000000 wsdd-0.8/etc/rc.d/wsdd.freebsd 0000775 0000000 0000000 00000001333 14602102110 0016206 0 ustar 00root root 0000000 0000000 #!/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.8/etc/rc.d/wsdd.openbsd 0000775 0000000 0000000 00000001135 14602102110 0016226 0 ustar 00root root 0000000 0000000 #!/bin/ksh
workgroup=$(/usr/local/bin/testparm -s --parameter-name workgroup 2>/dev/null)
samba_config_file="/etc/samba/smb.conf"
if [ -z ${workgroup} ] && [ -r "${samba_config_file}" ]; then
workgroup=$(grep -i -E '^[[:space:]]*workgroup' "${samba_config_file}" | cut -f2 -d= | tr -d '[:blank:]')
fi
if [ -n "$workgroup" ]; then
daemon_flags="-w ${workgroup}"
fi
daemon="python3 /usr/local/bin/wsdd"
# create user with
# doas useradd -g =uid -c "WSD Daemon" -L daemon -s /sbin/nologin -d /var/empty -r 100..999 _wsdd
daemon_user="_wsdd"
. /etc/rc.d/rc.subr
rc_bg="YES"
rc_reload="NO"
rc_cmd $1
wsdd-0.8/etc/systemd/ 0000775 0000000 0000000 00000000000 14602102110 0014550 5 ustar 00root root 0000000 0000000 wsdd-0.8/etc/systemd/wsdd.defaults 0000664 0000000 0000000 00000000270 14602102110 0017241 0 ustar 00root root 0000000 0000000 # 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.8/etc/systemd/wsdd.service 0000664 0000000 0000000 00000001470 14602102110 0017075 0 ustar 00root root 0000000 0000000 [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.8/etc/sysvinit/ 0000775 0000000 0000000 00000000000 14602102110 0014750 5 ustar 00root root 0000000 0000000 wsdd-0.8/etc/sysvinit/default/ 0000775 0000000 0000000 00000000000 14602102110 0016374 5 ustar 00root root 0000000 0000000 wsdd-0.8/etc/sysvinit/default/wsdd 0000664 0000000 0000000 00000001307 14602102110 0017261 0 ustar 00root root 0000000 0000000 # Defaults file for wsdd
#
#######################################################
# these options only apply to the SysVinit start script
#######################################################
# Override the default user under which wsdd runs.
#WSDD_USER="daemon"
# Specify alternative log file location.
#WSDD_LOG_FILE="/var/log/wsdd.log"
# Disable automatic detection of the workgroup from samba configuration.
#WSDD_WORKGROUP="WORKGROUP"
########################################################
# general options
########################################################
# Additional options for the daemon, e.g. to listen on interface eth0 only.
# Refer to wsdd(1) for details.
#WSDD_PARAMS="-i eth0"
wsdd-0.8/etc/sysvinit/init.d/ 0000775 0000000 0000000 00000000000 14602102110 0016135 5 ustar 00root root 0000000 0000000 wsdd-0.8/etc/sysvinit/init.d/wsdd 0000664 0000000 0000000 00000007371 14602102110 0017031 0 ustar 00root root 0000000 0000000 #!/bin/bash
### BEGIN INIT INFO
# Provides: wsdd
# Required-Start: $syslog $local_fs $remote_fs $network $named $time samba-ad-dc
# Required-Stop: $syslog $local_fs $remote_fs $network $named $time samba-ad-dc
# Default-Start: 2 3 4 5
# Default-Stop: 0 1 6
# Short-Description: Web Services Dynamic Discovery host daemon
# Description: Web Services Dynamic Discovery (WSDD) host daemon
### END INIT INFO
PATH=/sbin:/usr/sbin:/bin:/usr/bin
DAEMON_NAME=wsdd
SMB_CONFIG_FILE=/etc/samba/smb.conf
LOG_FILE=/var/log/$DAEMON_NAME.log
WSDD_EXEC=/usr/sbin/$DAEMON_NAME
RUN_AS_USER=daemon
DESC="Web Services Dynamic Discovery host daemon"
PROCESSDIR=/var/run/$DAEMON_NAME
PIDFILE=/var/run/wsdd.pid
# get defaults file; edit that file to configure this script.
if test -e /etc/default/$DAEMON_NAME ; then
. /etc/default/$DAEMON_NAME
fi
# Exit if the daemon is not installed
[ -x $WSDD_EXEC ] || exit 0
# load init-functions
[ -f /lib/init/vars.sh ] && . /lib/init/vars.sh
[ -f /lib/lsb/init-functions ] && . /lib/lsb/init-functions
# start command
do_start() {
log_daemon_msg "Starting $DESC" "$DAEMON_NAME"
OPTS="${WSDD_PARAMS} --chroot ${PROCESSDIR} --shortlog"
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
# change owner of log file to user running wsdd - commented out for now since wsdd itself does not log anything
# chown ${RUN_AS_USER} "${LOG_FILE}"
# Ensure PROCESSDIR exists and is accessible
install -o root -g root -m 755 -d $PROCESSDIR
start-stop-daemon --start --background --user ${RUN_AS_USER} --make-pidfile --pidfile $PIDFILE --exec ${WSDD_EXEC} -- ${OPTS}
# direct logging of wsdd output does not work due to the "--background" option which seems needed since the program does not detach itself. A log file other than stdout cannot be defined either :-(
# crude replacement to log at least something...:
RETVAL="$?"
CURRENTDATE=`date +"%F %T,%N"`
if [ $RETVAL -eq 0 ]; then
echo "$CURRENTDATE wsdd started successfully, option flags $OPTS" >> $LOG_FILE 2>&1
exit 0
else
echo "$CURRENTDATE wsdd start error with option flags $OPTS, error code $RETVAL" >> $LOG_FILE 2>&1
log_end_msg 1
exit 1
fi
log_end_msg 0
}
# stop command
do_stop() {
log_daemon_msg "Stopping $DESC" "$DAEMON_NAME"
start-stop-daemon --stop --retry 2 --pidfile $PIDFILE
# same log surrogate as above for stopping
RETVAL="$?"
CURRENTDATE=`date +"%F %T,%N"`
if [ $RETVAL -eq 0 ]; then
echo "$CURRENTDATE wsdd stopped successfully" >> $LOG_FILE 2>&1
else
echo "$CURRENTDATE wsdd stop error code $RETVAL" >> $LOG_FILE 2>&1
fi
# Wait a little and remove stale PID file
sleep 1
if [ -f $PIDFILE ] && ! ps h `cat $PIDFILE` > /dev/null
then
rm -f $PIDFILE
fi
log_end_msg 0
#return "$RETVAL"
}
case "$1" in
start)
do_${1}
;;
stop)
do_${1}
log_end_msg 0
;;
reload)
do_stop
do_start
log_end_msg 0
;;
restart|force-reload)
do_stop
sleep 1
do_start
;;
status)
status_of_proc "$WSDD_EXEC" "$DAEMON_NAME"
exit $?
;;
*)
echo "Usage: /etc/init.d/$DAEMON_NAME {start|stop|restart|status}"
exit 1
;;
esac
exit 0
wsdd-0.8/etc/ufw/ 0000775 0000000 0000000 00000000000 14602102110 0013661 5 ustar 00root root 0000000 0000000 wsdd-0.8/etc/ufw/applications.d/ 0000775 0000000 0000000 00000000000 14602102110 0016571 5 ustar 00root root 0000000 0000000 wsdd-0.8/etc/ufw/applications.d/wsdd 0000664 0000000 0000000 00000000147 14602102110 0017457 0 ustar 00root root 0000000 0000000 [Wsdd]
title=Wsdd
description=Web Service Discovery host daemon implementation
ports=3702/udp|5357/tcp
wsdd-0.8/man/ 0000775 0000000 0000000 00000000000 14602102110 0013060 5 ustar 00root root 0000000 0000000 wsdd-0.8/man/wsdd.8 0000664 0000000 0000000 00000027731 14602102110 0014124 0 ustar 00root root 0000000 0000000 .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\-\-metadata-timeout\ \fITIMEOUT\fR
Set the timeout for HTTP-based metadata exchange. Default is 2.0 seconds.
.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 attempts to read the
machine ID from /etc/machine-id and /etc/hostid (in that order) before
potentially chrooting in another environment. If reading the machine ID fails,
wsdd falls back to a version 5 UUID 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 \fI[TYPE]\fR - List discovered devices
Returns a tab-separated list of discovered devices of the provided TYPE (e.g.
"pub:Computer") with the following information. If no type is provided, all
discovered devices are listed. 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.
.TP
types
Types of the detected device, delimited by commas.
.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.8/setup.cfg 0000664 0000000 0000000 00000000251 14602102110 0014124 0 ustar 00root root 0000000 0000000 [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.8/src/ 0000775 0000000 0000000 00000000000 14602102110 0013074 5 ustar 00root root 0000000 0000000 wsdd-0.8/src/wsdd.py 0000775 0000000 0000000 00000221652 14602102110 0014422 0 ustar 00root root 0000000 0000000 #!/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-2024
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.8'
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.partition('%')[0])
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)
# OpenBSD requires the optlen to be sizeof(char) for LOOP and TTL options
# (see also https://github.com/python/cpython/issues/67316)
self.mc_send_socket.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_LOOP, struct.pack('B', 0))
self.mc_send_socket.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, struct.pack('B', 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 is not None:
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
types: Set[str]
def __init__(self, xml_str: str, xaddr: str, interface: NetworkInterface) -> None:
self.last_seen = 0.0
self.addresses = {}
self.props = {}
self.display_name = ''
self.types = set()
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:
self.types = set(root.findtext('wsdp:Types', '', namespaces).split(' '))
if PUB_COMPUTER not in self.types:
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 __extract_xaddr(self, xaddrs: str) -> Optional[str]:
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
return addr
elif self.mch.address.family == socket.AF_INET:
# use first (and very likely the only) IPv4 address
return addr
return None
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 = self.__extract_xaddr(xaddrs)
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 = self.__extract_xaddr(xaddrs)
if xaddr is None:
return None
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 = self.__extract_xaddr(xaddrs)
if xaddr is None:
return None
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, args.metadata_timeout) 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:
wsd_type = command_args[0] if command_args else None
write_stream.write(bytes(self.get_list_reply(wsd_type), '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, wsd_type: Optional[str]) -> str:
retval = ''
for dev_uuid, dev in WSDDiscoveredDevice.instances.items():
if wsd_type and (wsd_type not in dev.types):
continue
addrs_str = []
for addrs in dev.addresses.items():
addrs_str.append(', '.join(['{}'.format(a) for a in addrs]))
retval = retval + '{}\t{}\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),
','.join(dev.types))
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.teardown_tasks:
return
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 if platform.system() != 'OpenBSD' else 0x8000
# 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/man 4 route).
IF_COMMON_HDR_DEF = '@HBBii' if platform.system() != 'OpenBSD' else '@HBBHHHBBiii'
# from net/if.h
RTM_NEWADDR: int = 0xC
RTM_DELADDR: int = 0xD
# not tested for OpenBSD
RTM_IFINFO: int = 0xE
# from route.h (value equals for FreeBSD, Darwin and OpenBSD)
RTM_VERSION: int = 0x5
# from net/if.h (struct ifa_msghdr)
IFA_MSGHDR_DEF: str = IF_COMMON_HDR_DEF + ('hi' if platform.system() != 'OpenBSD' else '')
IFA_MSGHDR_SIZE: int = struct.calcsize(IFA_MSGHDR_DEF)
# The struct package does not allow to specify those, thus we hard code them as chars (x4).
IF_MSG_DEFS: Dict[str, str] = {
# if_data in if_msghdr is prepended with an u_short _ifm_spare1, thus the 'H' a the beginning)
'FreeBSD': 'hH6c2c8c8c104c8c16c',
# 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': 'h2c8c22I',
# struct if_data from /src/sys/net/if.h for if_msghdr
# (includes struct timeval which is a int64 + long
'OpenBSD': '4c3I13Q1Iql'
}
socket: socket.socket
intf_blacklist: List[str]
is_openbsd: bool = False
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_COMMON_HDR_DEF + self.IF_MSG_DEFS[platform.system()])
self.is_openbsd = platform.system() == 'OpenBSD'
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
if not self.is_openbsd:
rtm_len, rtm_version, rtm_type, addr_mask, flags = struct.unpack_from(
self.IF_COMMON_HDR_DEF, buf, offset)
else:
rtm_len, rtm_version, rtm_type, ifa_hdr_len, _, _, _, _, addr_mask, flags, _ = struct.unpack_from(
self.IF_COMMON_HDR_DEF, buf, offset)
# exit condition for OpenBSD where always the complete buffer (ie 4096 bytes) is returned
if rtm_len == 0:
break
# skip over non-understood packets and versions
if (rtm_type not in [self.RTM_NEWADDR, self.RTM_DELADDR, self.RTM_IFINFO]) or (
rtm_version != self.RTM_VERSION):
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')
parser.add_argument(
'--metadata-timeout',
help='set timeout for HTTP-based metadata exchange',
default=2.0)
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:
def read_uuid_from_file(fn: str) -> Union[None, uuid.UUID]:
try:
with open(fn) as f:
s: str = f.readline().strip()
return uuid.UUID(s)
except Exception:
return None
# machine uuid: try machine-id file first but also check for hostid (FreeBSD)
args.uuid = read_uuid_from_file('/etc/machine-id') or \
read_uuid_from_file('/etc/hostid') or \
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', 'OpenBSD']:
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.8/test/ 0000775 0000000 0000000 00000000000 14602102110 0013264 5 ustar 00root root 0000000 0000000 wsdd-0.8/test/linting/ 0000775 0000000 0000000 00000000000 14602102110 0014730 5 ustar 00root root 0000000 0000000 wsdd-0.8/test/linting/mypy.sh 0000775 0000000 0000000 00000000324 14602102110 0016264 0 ustar 00root root 0000000 0000000 #!/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.8/test/netlink_monitor.py 0000775 0000000 0000000 00000005506 14602102110 0017062 0 ustar 00root root 0000000 0000000 #!/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.8/test/regressions/ 0000775 0000000 0000000 00000000000 14602102110 0015627 5 ustar 00root root 0000000 0000000 wsdd-0.8/test/regressions/01_asyncio_changes_python310/ 0000775 0000000 0000000 00000000000 14602102110 0023111 5 ustar 00root root 0000000 0000000 wsdd-0.8/test/regressions/01_asyncio_changes_python310/run_tcp.sh 0000775 0000000 0000000 00000002115 14602102110 0025121 0 ustar 00root root 0000000 0000000 #!/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.8/test/regressions/01_asyncio_changes_python310/run_unix.sh 0000775 0000000 0000000 00000002331 14602102110 0025316 0 ustar 00root root 0000000 0000000 #!/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.8/test/regressions/02_non_existing_interface/ 0000775 0000000 0000000 00000000000 14602102110 0022654 5 ustar 00root root 0000000 0000000 wsdd-0.8/test/regressions/02_non_existing_interface/run.sh 0000775 0000000 0000000 00000000772 14602102110 0024025 0 ustar 00root root 0000000 0000000 #!/bin/bash
# see #201 (https://github.com/christgau/wsdd/issues/201)
outfile="$(mktemp)"
python3 ${WSDD_SCRIPT} -i xzy.non-existing > "${outfile}" 2>&1 &
wsdd_pid=$!
# wait for process startup
sleep 3
# send sigterm twice, shortly after another
kill ${wsdd_pid}
kill ${wsdd_pid}
# wait for exception to be dumped
wait
msg="The future belongs to a different loop than the one specified as the loop argument"
! grep -q "${msg}" "${outfile}"
retval=$?
cat "${outfile}"
rm "${outfile}"
exit ${retval}
wsdd-0.8/test/regressions/run-regressions.sh 0000775 0000000 0000000 00000002126 14602102110 0021334 0 ustar 00root root 0000000 0000000 #!/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
log_target="$(mktemp)"
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
cat "${log_target}"
echo "FAILED"
num_failed=$((num_failed + 1))
fi
rm -f "${log_target}"
test_number=$(($test_number + 1))
done
echo "------------------------------------------"
echo "${num_succeeded} succeeded, ${num_failed} failed."
exit $((${total_tests} - ${num_succeeded}))
wsdd-0.8/test/routesocket_monitor.py 0000775 0000000 0000000 00000010760 14602102110 0017763 0 ustar 00root root 0000000 0000000 #!/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
import platform
# 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 if platform.system() != 'OpenBSD' else 0x8000
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):
if platform.system() != 'OpenBSD':
# mask(addrs) has same offset in if_msghdr and ifs_msghdr
rtm_len, _, rtm_type, addr_mask, flags = struct.unpack_from(
'@HBBii', buf, offset)
else:
rtm_len, _, rtm_type, hdr_len, if_idx, ignore_table, ignore_prio, ignore_mlps, addr_mask, flags, change_mask = struct.unpack_from(
'@HBBHHHBBiii', 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
if platform.system() != 'OpenBSD':
# (152 is FreeBSD-specific)
sa_offset = offset + ((16 + 152) if rtm_type == RTM_IFINFO else 20)
else:
# RTM_IFINFO not tested, offset might be wrong
sa_offset = offset + ((16 + 152) if rtm_type == RTM_IFINFO else 24)
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)