├── .github └── workflows │ ├── codeql-analysis.yml │ └── static-analyses.yml ├── .gitignore ├── AUTHORS ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── debian ├── .gitignore ├── changelog ├── compat ├── control ├── copyright ├── rules ├── source │ └── format ├── wsdd.default ├── wsdd.install └── wsdd.service ├── etc ├── openrc │ ├── conf.d │ │ └── wsdd │ └── init.d │ │ └── wsdd ├── rc.d │ └── wsdd └── systemd │ ├── wsdd.defaults │ └── wsdd.service ├── man └── wsdd.8 ├── setup.cfg ├── src └── wsdd.py └── test ├── linting └── mypy.sh ├── netlink_monitor.py └── routesocket_monitor.py /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: ["master", "feat/workflows"] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: ["master", "feat/workflows"] 9 | schedule: 10 | - cron: '0 0 * * */10' 11 | 12 | jobs: 13 | analyze: 14 | name: Analyze 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | language: ['python'] 21 | # Learn more... 22 | # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection 23 | 24 | steps: 25 | - name: Checkout repository 26 | uses: actions/checkout@v2 27 | with: 28 | # We must fetch at least the immediate parents so that if this is 29 | # a pull request then we can checkout the head. 30 | fetch-depth: 2 31 | 32 | # Initializes the CodeQL tools for scanning. 33 | - name: Initialize CodeQL 34 | uses: github/codeql-action/init@v1 35 | with: 36 | languages: ${{ matrix.language }} 37 | 38 | - name: Perform CodeQL Analysis 39 | uses: github/codeql-action/analyze@v1 40 | -------------------------------------------------------------------------------- /.github/workflows/static-analyses.yml: -------------------------------------------------------------------------------- 1 | name: "PEP8 and mypy" 2 | 3 | on: 4 | push: 5 | branches: ["master", "feat/workflows"] 6 | pull_request: 7 | branches: ["master", "feat/workflows"] 8 | schedule: 9 | - cron: '0 0 * * */10' 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v1 17 | 18 | - name: Set up Python 3.7 19 | uses: actions/setup-python@v1 20 | with: 21 | python-version: 3.7 22 | 23 | - name: Python 3.7 syntax check 24 | run: python -m py_compile src/wsdd.py 25 | 26 | - name: Lint with flake8 27 | run: | 28 | pip install flake8 29 | flake8 --count --show-source --statistics src 30 | 31 | - name: mypy type check 32 | run: | 33 | pip install mypy==0.910 34 | mypy --python-version=3.7 src/wsdd.py 35 | mypy --python-version=3.8 src/wsdd.py 36 | mypy --python-version=3.9 src/wsdd.py 37 | mypy --python-version=3.10 src/wsdd.py 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea/ 2 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Steffen Christgau 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Unreleased 4 | 5 | ### Added 6 | 7 | - GitHub workflow for static analyses added (syntax, format, and type checks are performed). 8 | - Added EnvironmentFile and according example for systemd-based distros. 9 | 10 | ### Changed 11 | 12 | - Source code is spiced with type hints now. 13 | - man page moved to section 8. 14 | 15 | ## [0.7.0] - 2021-11-20 16 | 17 | ### Added 18 | 19 | - Using the server interface it is now possible to start and stop the host functionality (discoverable device) without terminating and restarting the daemon. 20 | 21 | ### Fixed 22 | 23 | - Support multiple IP addresses in 'hello' messages from other hosts (#89) 24 | - Support interfaces with IPv6-only configuration (#94) 25 | - Re-enable 'probe' command of API (#116) 26 | - Removed code marked as deprecated starting with Python 3.10. 27 | 28 | ### Changed 29 | 30 | - The example systemd unit file now uses `DynamicUser` instead of the unsafe nobody:nobody combination. 31 | It also employs the rundir as chroot directory. 32 | - Code changed to use asyncio instead of selector-based 33 | - The server interface does not close connections after each command anymore. 34 | - For the 'list' command of the server interface, the list of discovered devices is terminated with a line containing only a single dot ('.') 35 | - Log device discovery only once per address and interface 36 | 37 | ## [0.6.4] - 2021-02-06 38 | 39 | ### Added 40 | 41 | - Introduce `-v`/`--version` command line switch to show program version. 42 | 43 | ### Fixed 44 | 45 | - HTTP status code 404 is sent in case of an non-existing path (#79). 46 | - Data is now sent correctly again on FreeBSD as well as on Linux (#80). 47 | 48 | ### Changed 49 | 50 | - Send HTTP 400 in case of wrong content type. 51 | 52 | ## [0.6.3] - 2021-01-10 53 | 54 | ### Added 55 | 56 | - Include instructions for adding repository keys under Debian/Ubuntu in README. 57 | 58 | ### Fixed 59 | 60 | - Skip Netlink messages smaller than 4 bytes correctly (#77, and maybe #59) 61 | - Messages are sent via the correct socket to comply with the intended/specified message flow. This also eases the firewall configuration (#72). 62 | 63 | ## [0.6.2] - 2020-10-18 64 | 65 | ### Changed 66 | 67 | - Lowered priority of non-essential, protocol-related and internal log messages (#53). 68 | 69 | ### Fixed 70 | 71 | - Do not use PID in Netlink sockets in order to avoid issues with duplicated PIDs, e.g., when Docker is used. 72 | - Prevent exceptions due to invalid incoming messages. 73 | - HTTP server address family wrong when interface address is added (#62) 74 | - Error when interface address is removed (#62) 75 | 76 | ## [0.6.1] - 2020-06-28 77 | 78 | ### Fixed 79 | 80 | - Error when unknown interface index is received from Netlink socket on Linux (#45) 81 | - HTTP requests not passed to wsdd, preventing hosts to be discovered (#49) 82 | 83 | ## [0.6] - 2020-06-06 84 | 85 | ### Added 86 | 87 | - Discovery mode to search for other hosts with Windows, wsdd, or compatible services 88 | - Socket-based API to query and manipulate the discovered hosts 89 | - Documentation on installation for some distros. 90 | 91 | ### Changed 92 | 93 | - Addresses are not only enumerated on startup, but changes to addresses are also dynamically handled 94 | - The program does not stop anymore when no IP address is available (see Fixes as well) 95 | - Code significantly refactored 96 | 97 | ### Fixed 98 | 99 | - Running at system startup without IP address does not cause wsdd to terminate anymore 100 | - Support international domain names when `chroot`ing (#44) 101 | - Skip empty routing attribute returned from Netlink socket (#42) 102 | - Correct handling of invalid messages (#43) 103 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Coding Conventions 2 | 3 | * Use 4 spaces for indentation. 4 | * 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. 5 | * Avoid inline comments, prefer (short) block comments. 6 | * Add documentation strings to function if required. 7 | * Use type hints. 8 | * If you want, add yourself to the AUTHORS file. 9 | 10 | # Commit Conventions 11 | 12 | * Follow [conventional commit](https://www.conventionalcommits.org) message guidelines. 13 | * Scopes for commit messages are `etc`, `src` and file names from the root directory. Take a look at git log to get an impression. 14 | * You can ignore the 50 chars limit for the first line of a commit message and obey to a hard limit of 72 chars. 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT Licence 2 | Copyright (c) 2017 Steffen Christgau 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # wsdd 2 | 3 | wsdd implements a Web Service Discovery host daemon. This enables (Samba) 4 | hosts, like your local NAS device, to be found by Web Service Discovery Clients 5 | like Windows. 6 | 7 | It also implements the client side of the discovery protocol which allows to 8 | search for Windows machines and other devices implementing WSD. This mode of 9 | operation is called discovery mode. 10 | 11 | # Purpose 12 | 13 | Since NetBIOS discovery is not supported by Windows anymore, wsdd makes hosts 14 | to appear in Windows again using the Web Service Discovery method. This is 15 | beneficial for devices running Samba, like NAS or file sharing servers on your 16 | local network. The discovery mode searches for other WSD servers in the local 17 | subnet. 18 | 19 | ## Background 20 | 21 | With Windows 10 version 1511, support for SMBv1 and thus NetBIOS device discovery 22 | was disabled by default. Depending on the actual edition, later versions of 23 | Windows starting from version 1709 ("Fall Creators Update") do not allow the 24 | installation of the SMBv1 client anymore. This causes hosts running Samba not 25 | to be listed in the Explorer's "Network (Neighborhood)" views. While there is no 26 | connectivity problem and Samba will still run fine, users might want to have 27 | their Samba hosts to be listed by Windows automatically. 28 | 29 | You may ask: What about Samba itself, shouldn't this functionality be included 30 | in Samba!? Yes, maybe. However, using Samba as file sharing service is still 31 | possible even if the host running Samba is not listed in the Network 32 | Neighborhood. You can still connect using the host name (given that name 33 | resolution works) or IP address. So you can have network drives and use shared 34 | folders as well. In addition, there is a patch lurking around in the Samba 35 | bug tracker since 2015. So it may happen that this feature gets integrated into 36 | Samba at some time in the future. 37 | 38 | # Requirements 39 | 40 | wsdd requires Python 3.7 and later only. It runs on Linux and FreeBSD. Other Unixes, such 41 | as OpenBSD or NetBSD, might work as well but were not tested. 42 | 43 | Although Samba is not strictly required by wsdd itself, it makes sense to run 44 | wsdd only on hosts with a running Samba daemon. Note that the OpenRC/Gentoo 45 | init script depends on the Samba service. 46 | 47 | # Installation 48 | 49 | ## Operating System and Distribution-Dependant Instructions 50 | 51 | ### Arch Linux 52 | 53 | Install wsdd from the [AUR package](https://aur.archlinux.org/wsdd.git). 54 | 55 | ### CentOS, Fedora, RHEL 56 | 57 | wsdd is included in RedHat/CentOS' EPEL repository. After setting that up, you 58 | can install wsdd like on Fedora where it is sufficient to issue 59 | 60 | `dnf install wsdd` 61 | 62 | ### Debian/Ubuntu 63 | 64 | There are user-maintained packages for which you need to add the repository to 65 | `/etc/apt/sources.list.d` with a file containing the following line 66 | 67 | `deb https://pkg.ltec.ch/public/ distro main` 68 | 69 | Replace `distro` with the name of your distro, e.g. `buster` or `xenial` (issue 70 | `lsb_release -cs` if unsure). After an `apt update` you can install wsdd with 71 | `apt install wsdd`. 72 | 73 | You also need to import the public key of the repository like this `apt-key adv --fetch-keys https://pkg.ltec.ch/public/conf/ltec-ag.gpg.key`. 74 | 75 | ### Gentoo 76 | 77 | You can choose between two overlays: the GURU project and an [author-maintained 78 | dedicated overlay](https://github.com/christgau/wsdd-gentoo). After setting up 79 | one of them you can install wsdd with 80 | 81 | ``` 82 | emerge eselect-repository 83 | eselect repository enable guru 84 | emerge --sync 85 | emerge wsdd 86 | ``` 87 | 88 | ## Generic Installation Instructions 89 | 90 | No installation steps are required. Just place the wsdd.py file anywhere you 91 | want to, rename it to wsdd, and run it from there. The init scripts/unit files 92 | assume that wsdd is installed under `/usr/bin/wsdd` or `/usr/local/bin/wsdd` in 93 | case of FreeBSD. There are no configuration files. No special privileges are 94 | required to run wsdd, so it is advisable to run the service as an unprivileged 95 | user such as _nobody_. 96 | 97 | The `etc` directory of the repo contains sample configuration files for 98 | different init(1) systems, namely FreeBSD's rc.d, Gentoo's openrc, and systemd 99 | which is used in most contemporary Linux distros. Those files may be used as 100 | templates for their actual usage. They are likely to require adjustments to the 101 | actual distribution/installation where they are to be used. 102 | 103 | # Usage 104 | 105 | ## Firewall Setup 106 | 107 | Traffic for the following ports, directions and addresses must be allowed. 108 | 109 | * incoming and outgoing traffic to udp/3702 with multicast destination: 110 | - `239.255.255.250` for IPv4 111 | - `ff02::c` for IPv6 112 | * outgoing unicast traffic from udp/3702 113 | * incoming to tcp/5357 114 | 115 | You should further restrict the traffic to the (link-)local subnet, e.g. by 116 | using the `fe80::/10` address space for IPv6. Please note that IGMP traffic 117 | must be enabled in order to get IPv4 multicast traffic working. 118 | 119 | ## Options 120 | 121 | By default wsdd runs in host mode and binds to all interfaces with only 122 | warnings and error messages enabled. In this configuration the host running 123 | wsdd is discovered with its configured hostname and belong to a default 124 | workgroup. The discovery mode, which allows to search for other WSD-compatible 125 | devices must be enabled explicitely. Both modes can be used simultanously. See 126 | below for details. 127 | 128 | ### General options 129 | 130 | * `-4`, `--ipv4only` (see below) 131 | * `-6`, `--ipv6only` 132 | 133 | Restrict to the given address family. If both options are specified no 134 | addreses will be available and wsdd will exit. 135 | 136 | * `-A`, `--no-autostart` 137 | Do not start networking activities automatically when the program is started. 138 | The API interface (see man page) can be used to start and stop the 139 | networking activities while the application is running. 140 | 141 | * `-c DIRECTORY`, `--chroot DIRECTORY` 142 | 143 | Chroot into a separate directory to prevent access to other directories of 144 | the system. This increases security in case of a vulnerability in wsdd. 145 | Consider setting the user and group under which wssd is running by using 146 | the `-u` option. 147 | 148 | * `-H HOPLIMIT`, `--hoplimit HOPLIMIT` 149 | 150 | Set the hop limit for multicast packets. The default is 1 which should 151 | prevent packets from leaving the local network segment. 152 | 153 | * `-i INTERFACE/ADDRESS`, `--interface INTERFACE/ADDRESS` 154 | 155 | Specify on which interfaces wsdd will be listening on. If no interfaces are 156 | specified, all interfaces are used. The loop-back interface is never used, 157 | even when it was explicitly specified. For interfaces with IPv6 addresses, 158 | only link-local addresses will be used for announcing the host on the 159 | network. This option can be provided multiple times in order to use more 160 | than one interface. 161 | 162 | This option also accepts IP addresses that the service should bind to. 163 | For IPv6, only link local addresses are actually considered as noted above. 164 | 165 | * `-l PATH/PORT`, `--listen PATH/PORT` 166 | Enable the API server on the with a Unix domain socket on the given PATH 167 | or a local TCP socket bound to the given PORT. Refer to the man page for 168 | details on the API. 169 | 170 | * `-s`, `--shortlog` 171 | 172 | Use a shorter logging format that only includes the level and message. 173 | This is useful in cases where the logging mechanism, like systemd on Linux, 174 | automatically prepend a date and process name plus ID to the log message. 175 | 176 | * `-u USER[:GROUP]`, `--user USER[:GROUP]` 177 | 178 | Change user (and group) when running before handling network packets. 179 | Together with `-c` this option can be used to increase security if the 180 | execution environment, like the init system, cannot ensure this in 181 | another way. 182 | 183 | * `-U UUID`, `--uuid UUID` 184 | 185 | The WSD specification requires a device to have a unique address that is 186 | stable across reboots or changes in networks. In the context of the 187 | standard, it is assumed that this is something like a serial number. wsdd 188 | uses the UUID version 5 with the DNS namespace and the host name of the 189 | local machine as inputs. Thus, the host name should be stable and not be 190 | modified, e.g. by DHCP. However, if you want wsdd to use a specific UUID 191 | you can use this option. 192 | 193 | * `-v`, `--verbose` 194 | 195 | Additively increase verbosity of the log output. A single occurrence of 196 | -v/--verbose sets the log level to INFO. More -v options set the log level 197 | to DEBUG. 198 | 199 | * `-V`, `--version` 200 | 201 | Show the version number and exit. 202 | 203 | ### Host Operation Mode 204 | 205 | In host mode, the device running wsdd can be discovered by Windows. 206 | 207 | * `-d DOMAIN`, `--domain DOMAIN` 208 | 209 | Assume that the host running wsdd joined an ADS domain. This will make 210 | wsdd report the host being a domain member. It disables workgroup 211 | membership reporting. The (provided) hostname is automatically converted 212 | to lower case. Use the `-p` option to change this behavior. 213 | 214 | * `-n HOSTNAME`, `--hostname HOSTNAME` 215 | 216 | Override the host name wsdd uses during discovery. By default the machine's 217 | host name is used (look at hostname(1)). Only the host name part of a 218 | possible FQDN will be used in the default case. 219 | 220 | * `-o`, `--no-server` 221 | 222 | Disable host operation mode which is enabled by default. The host will 223 | not be discovered by WSD clients when this flag is provided. 224 | 225 | * `-p`, `--preserve-case` 226 | 227 | Preserve the hostname as it is. Without this option, the hostname is 228 | converted as follows. For workgroup environments (see `-w`) the hostname 229 | is made upper case by default. Vice versa it is made lower case for usage 230 | in domains (see `-d`). 231 | 232 | * `-t`, `--nohttp` 233 | 234 | Do not service http requests of the WSD protocol. This option is intended 235 | for debugging purposes where another process may handle the Get messages. 236 | 237 | * `-w WORKGROUP`, `--workgroup WORKGROUP` 238 | 239 | By default wsdd reports the host is a member of a workgroup rather than a 240 | domain (use the -d/--domain option to override this). With -w/--workgroup 241 | the default workgroup name can be changed. The default work group name is 242 | WORKGROUP. The (provided) hostname is automatically converted to upper 243 | case. Use the `-p` option to change this behavior. 244 | 245 | ### Client / Discovery Operation Mode 246 | 247 | This mode allows to search for other WSD-compatible devices. 248 | 249 | * `-D`, `--discovery` 250 | 251 | Enable discovery mode to search for other WSD hosts/servers. Found servers 252 | are printed to stdout with INFO priority. The server interface (see `-l` 253 | option) can be used for a programatic interface. Refer to the man page for 254 | details of the API. 255 | 256 | 257 | ## Example Usage 258 | 259 | * handle traffic on eth0 only, but only with IPv6 addresses 260 | 261 | `wsdd -i eth0 -6` 262 | 263 | or 264 | 265 | `wsdd --interface eth0 --ipv6only` 266 | 267 | * set the Workgroup according to smb.conf and be verbose 268 | 269 | `SMB_GROUP=$(grep -i '^\s*workgroup\s*=' smb.conf | cut -f2 -d= | tr -d '[:blank:]')` 270 | 271 | `wsdd -v -w $SMB_GROUP` 272 | 273 | ## Technical Description 274 | 275 | (Read the source for more details) 276 | 277 | For each specified (or all) network interfaces, except for loopback, an UDP 278 | multicast socket for message reception, two UDP sockets for replying using 279 | unicast as well as sending multicast traffic, and a listening TCP socket are created. This is done for 280 | both the IPv4 and the IPv6 address family if not configured otherwise by the 281 | command line arguments (see above). Upon startup a _Hello_ message is sent. 282 | When wsdd terminates due to a SIGTERM signal or keyboard interrupt, a graceful 283 | shutdown is performed by sending a _Bye_ message. I/O multiplexing is used to 284 | handle network traffic of the different sockets within a single process. 285 | 286 | # Known Issues 287 | 288 | ## Security 289 | 290 | wsdd does not implement any security feature, e.g. by using TLS for the http 291 | service. This is because wsdd's intended usage is within private, i.e. home, 292 | LANs. The _Hello_ message contains the hosts transport address, i.e. the IP 293 | address which speeds up discovery (avoids _Resolve_ message). 294 | 295 | In order to increase the security, use the capabilities of the init system or 296 | consider the `-u` and `-c` options. 297 | 298 | ## Using only IPv6 on FreeBSD 299 | 300 | If wsdd is running on FreeBSD using IPv6 only, the host running wsdd may not be 301 | reliably discovered. The reason appears to be that Windows is not always able 302 | to connect to the HTTP service for unknown reasons. As a workaround, run wsdd 303 | with IPv4 only. 304 | 305 | ## Usage with NATs 306 | 307 | Do not use wssd on interfaces that are affected by NAT. According to the 308 | standard, the _ResolveMatch_ messages emitted by wsdd, contain the IP address 309 | ("transport address" in standard parlance) of the interface(s) the application 310 | has been bound to into. When such messages are retrieved by a client (Windows 311 | hosts, e.g.) they are unlikely to be able to connect to the provided address 312 | which has been subject to NAT. To avoid this issue, use the `-i/--interface` 313 | option to bind wsdd to interfaces not affected by NAT. 314 | 315 | ## Tunnel/Bridge Interface 316 | 317 | If tunnel/bridge interfaces like those created by OpenVPN or Docker exist, they 318 | may interfere with wsdd if executed without providing an interface that it 319 | should bind to (so it binds to all). In such cases, the wsdd hosts appears after 320 | wsdd has been started but it disappears when an update of the Network view in 321 | Windows Explorer is forced, either by refreshing the view or by a reboot of the 322 | Windows machine. To solve this issue, the interface that is connected to the 323 | network on which the host should be announced needs to be specified with the 324 | `-i/--interface` option. This prevents the usage of the tunnel/bridge 325 | interfaces. 326 | 327 | Background: Tunnel/bridge interfaces may cause Resolve requests from Windows 328 | hosts to be delivered to wsdd multiple times,´i.e. duplicates of such request 329 | are created. If wsdd receives such a request first from a tunnel/bridge it uses 330 | the transport address (IP address) of that interface and sends the response via 331 | unicast. Further duplicates are not processed due to the duplicate message 332 | detection which is based on message UUIDs. The Windows host which receives the 333 | response appears to detect a mismatch between the transport address in the 334 | ResolveMatch message (which is the tunnel/bridge address) and the IP of the 335 | sending host/interface (LAN IP, e.g.). Subsequently, the wsdd host is ignored by 336 | Windows. 337 | 338 | # Contributing 339 | 340 | Contributions are welcome. Please ensure PEP8 compliance when submitting 341 | patches or pull requests. Opposite to PEP8, the maximum number of characters per 342 | line is increased to 120. 343 | 344 | # Licence 345 | 346 | The code is licensed under the [MIT license](https://opensource.org/licenses/MIT). 347 | 348 | # Acknowledgements 349 | 350 | Thanks to Jose M. Prieto and his colleague Tobias Waldvogel who wrote the 351 | mentioned patch for Samba to provide WSD and LLMNR support. A look at their 352 | patch set made cross-checking the WSD messages easier. 353 | 354 | # References and Further Reading 355 | 356 | ## Technical Specification 357 | 358 | * [Web Services Dynamic Discovery](http://specs.xmlsoap.org/ws/2005/04/discovery/ws-discovery.pdf) 359 | 360 | * [SOAP-over-UDP (used during multicast)](http://specs.xmlsoap.org/ws/2004/09/soap-over-udp/soap-over-udp.pdf) 361 | 362 | * [MSDN Documentation on Publication Services Data Structure](https://msdn.microsoft.com/en-us/library/hh442048.aspx) 363 | 364 | * [MSDN on Windows WSD Compliance](https://msdn.microsoft.com/en-us/library/windows/desktop/bb736564.aspx) 365 | 366 | * ...and the standards referenced within the above. 367 | 368 | ## Documentation and Discussion on Windows/WSD 369 | 370 | * [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) 371 | 372 | * [Samba WSD and LLMNR support (Samba Bug #11473)](https://bugzilla.samba.org/show_bug.cgi?id=11473) 373 | 374 | * [Discussion at tenforums.com about missing hosts in network](https://www.tenforums.com/network-sharing/31221-windows-10-1511-network-browsing-issue.html) 375 | Note: Solutions suggest to go back to SMBv1 protocol which is deprecated! Do not follow this advice. 376 | 377 | * [Discussion in Synology Community Forum](https://forum.synology.com/enu/viewtopic.php?f=49&t=106924) 378 | 379 | ## Other stuff 380 | 381 | * Meanwhile, there is a [C implementation of a WSD daemon](https://github.com/Andy2244/wsdd2), named wsdd2. 382 | This one also includes LLMNR which wsdd lacks. However, LLMNR may not be required depending on the actual 383 | network/name resolution setup. 384 | 385 | * [OpenWRT includes](https://github.com/openwrt/packages/pull/5563) the above C implementation. 386 | So OpenWRT users are unlikely to need an installation of wsdd. 387 | 388 | * [FreeNAS](https://redmine.ixsystems.com/issues/72099) appears to have wsdd included in the distribution. 389 | -------------------------------------------------------------------------------- /debian/.gitignore: -------------------------------------------------------------------------------- 1 | /wsdd/ 2 | /.debhelper/ 3 | /files 4 | /*.debhelper.log 5 | /*.debhelper 6 | /*.substvars 7 | /*-stamp 8 | -------------------------------------------------------------------------------- /debian/changelog: -------------------------------------------------------------------------------- 1 | wsdd (0.7+gitc87819b) stable; urgency=low 2 | 3 | * Rebase to latest upstream version. 4 | 5 | -- Volker Theile Wed, 16 Mar 2022 17:25:06 +0100 6 | 7 | wsdd (0.6.4+git6d5922e2-1) stable; urgency=low 8 | 9 | * Rebase to latest upstream version. 10 | 11 | -- Volker Theile Fri, 14 May 2021 14:56:21 +0200 12 | 13 | wsdd (0.6.2-1) stable; urgency=low 14 | 15 | * Rebase to latest upstream version. 16 | 17 | -- Volker Theile Thu, 17 Dec 2020 10:29:52 +0100 18 | 19 | wsdd (0.5-1) stable; urgency=low 20 | 21 | * Rebase to latest upstream version. 22 | 23 | -- Volker Theile Sun, 22 Dec 2019 10:41:43 +0100 24 | 25 | wsdd (0.3-4) stable; urgency=low 26 | 27 | * Start daemon after network is up. 28 | 29 | -- Volker Theile Sun, 22 Dec 2019 10:27:03 +0100 30 | 31 | wsdd (0.3-3) stable; urgency=low 32 | 33 | * Issue #11: Error in main loop 34 | 35 | -- Volker Theile Mon, 15 Apr 2019 14:13:16 +0200 36 | 37 | wsdd (0.3-2) stable; urgency=low 38 | 39 | * Rebase to latest upstream version. 40 | * Introduce /etc/default/wsdd configuration file. 41 | 42 | -- Volker Theile Thu, 28 Mar 2019 12:12:09 +0100 43 | 44 | wsdd (0.3-1) stable; urgency=low 45 | 46 | * Initial release. 47 | 48 | -- Volker Theile Wed, 06 Mar 2019 16:45:37 +0100 49 | -------------------------------------------------------------------------------- /debian/compat: -------------------------------------------------------------------------------- 1 | 12 2 | -------------------------------------------------------------------------------- /debian/control: -------------------------------------------------------------------------------- 1 | Source: wsdd 2 | Section: net 3 | Priority: optional 4 | Maintainer: Volker Theile 5 | Build-Depends: debhelper (>= 12) 6 | Standards-Version: 4.5.0 7 | 8 | Package: wsdd 9 | Architecture: all 10 | Depends: ${misc:Depends}, python3, samba-common-bin 11 | Description: Web Services on Devices (WSD) daemon 12 | Web Services on Devices (WSD) is a Microsoft API to simplify programming 13 | connections to web service enabled devices, such as Printers, Scanners 14 | and File Shares. 15 | . 16 | This daemon advertises and responds to probe requests from Windows 17 | clients looking for File Shares. 18 | -------------------------------------------------------------------------------- /debian/copyright: -------------------------------------------------------------------------------- 1 | Format: http://dep.debian.net/deps/dep5 2 | Upstream-Contact: Steffen Christgau 3 | Source: https://github.com/christgau/wsdd/blob/master/LICENCE 4 | 5 | Files: * 6 | Copyright: 2017 Steffen Christgau 7 | License: MIT-License 8 | 9 | Files: debian/* 10 | Copyright: 2019 Volker Theile 11 | License: GPL-3 12 | -------------------------------------------------------------------------------- /debian/rules: -------------------------------------------------------------------------------- 1 | #!/usr/bin/make -f 2 | 3 | # Uncomment this to turn on verbose mode. 4 | #export DH_VERBOSE=1 5 | 6 | %: 7 | dh $@ 8 | 9 | override_dh_installinit: 10 | # Install the /etc/default/wsdd file. 11 | dh_installinit --noscripts 12 | -------------------------------------------------------------------------------- /debian/source/format: -------------------------------------------------------------------------------- 1 | 3.0 (native) -------------------------------------------------------------------------------- /debian/wsdd.default: -------------------------------------------------------------------------------- 1 | WSDD_OPTIONS="--shortlog" 2 | -------------------------------------------------------------------------------- /debian/wsdd.install: -------------------------------------------------------------------------------- 1 | src/wsdd.py usr/sbin/ 2 | -------------------------------------------------------------------------------- /debian/wsdd.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Web Services Dynamic Discovery host daemon 3 | ; Start after the network has been configured. 4 | After=network-online.target 5 | Wants=network-online.target 6 | ; If the unit bound to is stopped, this unit will be stopped too. 7 | BindsTo=smbd.service 8 | 9 | [Service] 10 | EnvironmentFile=-/etc/default/wsdd 11 | ExecStart=/usr/sbin/wsdd.py --chroot=/run/wsdd $WSDD_OPTIONS 12 | ExecReload=/bin/kill -HUP $MAINPID 13 | Restart=on-failure 14 | DynamicUser=yes 15 | RuntimeDirectory=wsdd 16 | AmbientCapabilities=CAP_SYS_CHROOT 17 | 18 | [Install] 19 | WantedBy=multi-user.target 20 | -------------------------------------------------------------------------------- /etc/openrc/conf.d/wsdd: -------------------------------------------------------------------------------- 1 | # /etc/conf.d/wsdd 2 | 3 | # Override the default user/group under which wsdd runs. 4 | # Must follow the user[:group] notation. 5 | #WSDD_USER="daemon:daemon" 6 | 7 | # Specify alternative log file location. 8 | #WSDD_LOG_FILE="/var/log/wsdd.log" 9 | 10 | # Disable automatic detection of the workgroup from samba configuration. 11 | #WSDD_WORKGROUP="MYGROUP" 12 | 13 | # Additional options for the daemon, e.g. to listen on interface eth0 only. 14 | # Refer to wsdd(1) for details. 15 | #WSDD_OPTS="-i eth0" 16 | -------------------------------------------------------------------------------- /etc/openrc/init.d/wsdd: -------------------------------------------------------------------------------- 1 | #!/sbin/openrc-run 2 | # Copyright 2017 Steffen Christgau 3 | # Distributed under the terms of the MIT licence 4 | 5 | depend() { 6 | need net 7 | need samba 8 | } 9 | 10 | SMB_CONFIG_FILE="/etc/samba/smb.conf" 11 | LOG_FILE="${WSDD_LOG_FILE:-/var/log/wsdd.log}" 12 | WSDD_EXEC="/usr/bin/wsdd" 13 | RUN_AS_USER="${WSDD_USER:-daemon:daemon}" 14 | 15 | start() { 16 | ebegin "Starting ${RC_SVCNAME} daemon" 17 | 18 | OPTS="${WSDD_OPTS}" 19 | 20 | if [ -z "$WSDD_WORKGROUP" ]; then 21 | # try to extract workgroup with Samba's testparm 22 | if which testparm >/dev/null 2>/dev/null; then 23 | GROUP="$(testparm -s --parameter-name workgroup 2>/dev/null)" 24 | fi 25 | 26 | # fallback to poor man's approach if testparm is unavailable or failed for some reason 27 | if [ -z "$GROUP" ] && [ -r "${SMB_CONFIG_FILE}" ]; then 28 | GROUP=`grep -i '^\s*workgroup\s*=' ${SMB_CONFIG_FILE} | cut -f2 -d= | tr -d '[:blank:]'` 29 | fi 30 | 31 | if [ -n "${GROUP}" ]; then 32 | OPTS="-w ${GROUP} ${OPTS}" 33 | fi 34 | else 35 | OPTS="-w ${WSDD_WORKGROUP} ${OPTS}" 36 | fi 37 | 38 | if [ ! -r "${LOG_FILE}" ]; then 39 | touch "${LOG_FILE}" 40 | fi 41 | chown ${RUN_AS_USER} "${LOG_FILE}" 42 | 43 | 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} 44 | eend $? 45 | } 46 | 47 | stop() { 48 | ebegin "Stopping ${RC_SVCNAME} daemon" 49 | start-stop-daemon --stop --retry 2 --pidfile /var/run/${RC_SVCNAME}.pid --exec ${WSDD_EXEC} 50 | eend $? 51 | } 52 | -------------------------------------------------------------------------------- /etc/rc.d/wsdd: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # PROVIDE: wsdd 4 | # REQUIRE: DAEMON samba_server 5 | # BEFORE: login 6 | # KEYWORD: shutdown 7 | 8 | . /etc/rc.subr 9 | 10 | name=wsdd 11 | rcvar=wsdd_enable 12 | wsdd_group=$(/usr/local/bin/testparm -s --parameter-name workgroup 2>/dev/null) 13 | 14 | : ${wsdd_smb_config_file="/usr/local/etc/smb4.conf"} 15 | 16 | # try to manually extract workgroup from samba configuration if testparm failed 17 | if [ -z "$wsdd_group" ] && [ -r $wsdd_smb_config_file ]; then 18 | wsdd_group="$(grep -i '^[[:space:]]*workgroup[[:space:]]*=' $wsdd_smb_config_file | cut -f2 -d= | tr -d '[:blank:]')" 19 | fi 20 | 21 | if [ -n "$wsdd_group" ]; then 22 | wsdd_opts="-w ${wsdd_group}" 23 | fi 24 | 25 | command="/usr/sbin/daemon" 26 | command_args="-u daemon -S /usr/local/bin/wsdd $wsdd_opts" 27 | 28 | load_rc_config $name 29 | run_rc_command "$1" 30 | -------------------------------------------------------------------------------- /etc/systemd/wsdd.defaults: -------------------------------------------------------------------------------- 1 | # Additional arguments for wsdd can be provided here. 2 | # Use, e.g., "-i eth0" to restrict operations to a specific interface 3 | # Refer to the wsdd(8) man page for details 4 | 5 | WSDD_PARAMS="" 6 | -------------------------------------------------------------------------------- /etc/systemd/wsdd.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Web Services Dynamic Discovery host daemon 3 | Documentation=man:wsdd(8) 4 | ; Start after the network has been configured 5 | After=network-online.target 6 | Wants=network-online.target 7 | ; It makes sense to have Samba running when wsdd starts, but is not required. 8 | ; Thus, the next to lines are disabled and use BindsTo only. 9 | ; One may also add any of these services to After for stronger binding. 10 | ;BindsTo=smb.service 11 | ;BindsTo=samba.service 12 | 13 | [Service] 14 | Type=simple 15 | EnvironmentFile=/etc/default/wsdd 16 | ; The service is put into an empty runtime directory chroot, 17 | ; i.e. the runtime directory which usually resides under /run 18 | ExecStart=/usr/bin/wsdd --shortlog --chroot=/run/wsdd $WSDD_PARAMS 19 | DynamicUser=yes 20 | User=wsdd 21 | Group=wsdd 22 | RuntimeDirectory=wsdd 23 | AmbientCapabilities=CAP_SYS_CHROOT 24 | 25 | [Install] 26 | WantedBy=multi-user.target 27 | -------------------------------------------------------------------------------- /man/wsdd.8: -------------------------------------------------------------------------------- 1 | .TH wsdd 8 2 | .SH NAME 3 | wsdd \- A Web Service Discovery host and client daemon. 4 | .SH SYNOPSIS 5 | .B wsdd [\fBoptions\fR] 6 | .SH DESCRIPTION 7 | .PP 8 | .B wsdd 9 | implements both a Web Service Discovery (WSD) host and a WSD client daemon. The 10 | host implementation enables (Samba) hosts, like your local NAS device, to be 11 | found by Web Service Discovery clients like Windows. The client mode allows 12 | searching for other WSD hosts on the local network. 13 | .PP 14 | The default mode of operation is the host mode. The client or discovery mode 15 | must be enabled explictely. Unless configured otherwise, the host mode is always 16 | active. Both modes can be used at the same time. 17 | .SH OPTIONS 18 | .SS General options 19 | .TP 20 | \fB\-4\fR, \fB\-\-ipv4only\fR 21 | See below. 22 | .TP 23 | \fB\-6\fR, \fB\-\-ipv6only\fR 24 | Restrict to the given address family. If both options are specified no 25 | addreses will be available and wsdd will exit. 26 | .TP 27 | \fB\-A\fR, \fB\-\-no-autostart\fR 28 | Do not start networking activities automatically when the program is started. 29 | The API interface (see below) can be used to start and stop the networking 30 | activities while the application is running. 31 | .TP 32 | \fB\-c \fIDIRECTORY\fR, \fB\-\-chroot \fIDIRECTORY\fR 33 | chroot into the given \fIDIRECTORY\fR after initialization has been performed 34 | and right before the handling of incoming messages starts. The new root directory 35 | can be empty. Consider using the \fB-u\fR option as well. 36 | .TP 37 | \fB\-h\fR, \fB\-\-help\fR 38 | Show help and exit. 39 | .TP 40 | \fB\-H \fIHOPLIMIT\fR, \fB\-\-hoplimit \fIHOPLIMIT\fR 41 | Set the hop limit for multicast packets. The default is 1 which should 42 | prevent packets from leaving the local network segment. 43 | .TP 44 | \fB\-i \fIINTERFACE/ADDRESS\fR, \fB\-\-interface \fIINTERFACE/ADDRESS\fR 45 | Specify on which interfaces wsdd will be listening on. If no interfaces are 46 | specified, all interfaces are used. Loop-back interfaces are never used, 47 | even when explicitly specified. For interfaces with IPv6 addresses, 48 | only link-local addresses will be used for announcing the host on the 49 | network. This option can be provided multiple times in order to restrict 50 | traffic to more than one interface. 51 | This option also accepts IP addresses that the service should bind to. 52 | For IPv6, only link local addresses are actually considered as noted above. 53 | .TP 54 | \fB\-s\fR, \fB\-\-shortlog\fR 55 | Use a shorter logging format that only includes the level and message. 56 | This is useful in cases where the logging mechanism, like systemd on Linux, 57 | automatically prepends a date and process name plus ID to the log message. 58 | .TP 59 | \fB\-u \fIUSER[:GROUP]\fR, \fB\-\-user \fIUSER[:GROUP]\fR 60 | Change user (and group) when running before handling network packets. 61 | Together with \fB\-c\fR this option can be used to increase security 62 | if the execution environment, like the init system, cannot ensure this in 63 | another way. 64 | .TP 65 | \fB\-U \fIUUID\fR, \fB\-\-uuid \fIUUID\fR 66 | The WSD specification requires a device to have a unique address that is 67 | stable across reboots or changes in networks. In the context of the 68 | standard, it is assumed that this is something like a serial number. wsdd 69 | uses the UUID version 5 with the DNS namespace and the host name of the 70 | local machine as inputs. Thus, the host name should be stable and not be 71 | modified, e.g. by DHCP. However, if you want wsdd to use a specific UUID 72 | you can use this option. 73 | .TP 74 | \fB\-v\fR, \fB\-\-verbose\fR 75 | Additively increase verbosity of the log output. A single occurrence of 76 | -v/--verbose sets the log level to INFO. More -v options set the log level 77 | to DEBUG. 78 | .TP 79 | \fB\-V\fR, \fB\-\-version\fR 80 | Show the version number and exit. 81 | .SS Host Mode Options 82 | .TP 83 | \fB\-d \fIDOMAIN\fR, \fB\-\-domain \fIDOMAIN\fR 84 | Assume that the host running wsdd joined an ADS domain. This will make 85 | wsdd report the host being a domain member. It disables workgroup 86 | membership reporting. The (provided) hostname is automatically converted 87 | to lower case. Use the `-p` option to change this behavior. 88 | .TP 89 | \fB\-n \fIHOSTNAME\fR, \fB\-\-hostname \fIHOSTNAME\fR 90 | Override the host name wsdd uses during discovery. By default the machine's 91 | host name is used (look at hostname(1)). Only the host name part of a 92 | possible FQDN will be used in the default case. 93 | .TP 94 | \fB\-o\fR, \fB\-\-no-host\fR 95 | Disable host operation mode. If this option is provided, the host cannot be 96 | discovered by other (Windows) hosts. It can be useful when client/discovery 97 | mode is used and no announcement of the host that runs wsdd should be made. 98 | .TP 99 | \fB\-p\fR, \fB\-\-preserve-case\fR 100 | Preserve the hostname as it is. Without this option, the hostname is 101 | converted as follows. For workgroup environments (see -w) the hostname 102 | is made upper case by default. Vice versa it is made lower case for usage 103 | in domains (see -d). 104 | .TP 105 | \fB\-t\fR, \fB\-\-no-http\fR 106 | Do not service HTTP requests of the WSD protocol. This option is intended 107 | for debugging purposes where another process may handle the Get messages. 108 | .TP 109 | \fB\-w \fIWORKGROUP\fR, \fB\-\-workgroup \fIWORKGROUP\fR 110 | By default wsdd reports the host is a member of a workgroup rather than a 111 | domain (use the -d/--domain option to override this). With -w/--workgroup 112 | the default workgroup name can be changed. The default work group name is 113 | WORKGROUP. The (provided) hostname is automatically converted to upper 114 | case. Use the `-p` option to change this behavior. 115 | .SS Client/Discovery Mode Options 116 | .TP 117 | \fB\-D\fR, \fB\-\-discovery\fR 118 | Enable discovery mode to search for other WSD hosts/servers. Found hosts 119 | are logged with INFO priority. The server interface (see below) 120 | can be used for a programatic interface. Refer to the man page for 121 | details of the server interface. 122 | .TP 123 | \fB\-l \fIPATH/PORT\fR, \fB\-\-listen \fIPATH/PORT\fR 124 | Specify the location of the socket for the server programming interface. 125 | If the option value is numeric, it is interpreted as numeric port for a 126 | TCP server port. Then, the server socket is bound to the localhost only. 127 | If the option value is non-numeric, it is assumed to be a path to UNIX 128 | domain socket to which a client can connect to. 129 | 130 | .SH EXAMPLE USAGE 131 | .SS Handle traffic on eth0 and eth2 only, but only with IPv6 addresses 132 | 133 | wsdd \-i eth0 \-i eth2 \-6 134 | 135 | or 136 | 137 | wsdd \-\-interface eth0 \-\-interface eth2 \-\-ipv6only 138 | .SS Set the Workgroup according to smb.conf, be verbose, run with dropped privileges, and change the root directory to an (empty) directory 139 | 140 | SMB_GROUP=$(grep \-i '^\s*workgroup\s*=' smb.conf | cut \-f2 \-d= | tr \-d '[:blank:]') 141 | 142 | wsdd \-v \-w $SMB_GROUP -u daemon:daemon -c /var/run/wsdd/chroot 143 | .SH FIREWALL SETUP 144 | .PP 145 | Traffic for the following ports, directions and addresses must be allowed: 146 | .TP 147 | - Incoming and outgoing traffic to udp/3702 with multicast destination: 239.255.255.250 for IPv4 and ff02::c for IPv6 148 | .TP 149 | - Outgoing unicast traffic from udp/3702 150 | .TP 151 | - Incoming traffic to tcp/5357 152 | .PP 153 | You should further restrict the traffic to the (link-)local subnet, e.g. by 154 | using the `fe80::/10` address space for IPv6. Please note that IGMP traffic 155 | must be enabled in order to get IPv4 multicast traffic working. 156 | .SH API INTERFACE 157 | Wsdd provides a text-based, line-oriented API interface to query information 158 | and trigger actions. The interface can be used with TCP and UNIX domain sockets 159 | (see \fB\-l/\-\-listen\fR option). The TCP socket is bound to the local host 160 | only. The following commands can be issued: 161 | .SS \fBclear\fR - Clear list of discovered devices 162 | Clears the list of all discovered devices. Use the \fBprobe\fR command to 163 | search for devices again. This command does not return any data and is only 164 | available in discover mode. 165 | .SS \fBlist\fR - List discovered devices 166 | Returns a tab-separated list of discovered devices with the following information. 167 | The possibly empty list of detected hosts is always terminated with a single 168 | dot ('.') in an otherwise empty line. This command is only available in discover mode. 169 | .TP 170 | UUID 171 | UUID of the discovered device. Note that a multi-homed device should appear 172 | only once but with multiple addresses (see below) 173 | .TP 174 | name 175 | The name reported by the device. For discovered Windows hosts, it is the 176 | configured computer name that is reported here. 177 | .TP 178 | association 179 | Specifies the domain or workgroup to which the discovered host belongs to. The 180 | type of the association (workgroup or domain) is separated from its value by a 181 | colon. 182 | .TP 183 | last_seen 184 | The date and time the device was last seen as a consequence of Probe/Hello 185 | messages provided in ISO8601 with seconds. 186 | .TP 187 | addresses 188 | List of all transport addresses that were collected during the discovery 189 | process delimited by commas. Addresses are provided along with the interface 190 | (separated by '%') on which they were discovered. IPv6 addresses are reported 191 | on square brackets. Note that the reported addresses may not match the actual 192 | device on which the device may be reached. 193 | .SS \fBprobe \fI[INTERFACE]\fR - Search for devices 194 | Triggers a Probe message on the provided INTERFACE (eth0, e.g.) to search for 195 | WSD hosts. If no interface is provided, all interfaces wsdd listens on are probed. 196 | A Probe messages initiates the discovery message flow. It may take some time for 197 | hosts to be actually discovered. This command does not return any data and is 198 | only available in discovery mode. 199 | .SS \fBstart\fR - Start networking activities 200 | This command starts the networking acitivies of wsdd if they haven't been 201 | started yet. "Hello" messages are emitted and the host is announced on the 202 | network(s) when the host mode is active. If the discovery mode is enabled a 203 | probe process is also started. 204 | 205 | .SS \fBstop\fR - Stop networking activities 206 | This is the reverse operation to start. When this command is received, "Bye" 207 | messages are sent in order to notify hosts in the network about the host's 208 | disappearance. All networking sockets used for the WSD protocol are closed as 209 | well. Activities can be restarted with the start operation. 210 | 211 | .SH SECURITY 212 | .PP 213 | wsdd does not implement any security feature, e.g. by using TLS for the http 214 | service. This is because wsdd's intended usage is within private, i.e. home, 215 | LANs. The \fIHello\fR message contains the hosts transport address, i.e. the IP 216 | address which speeds up discovery (avoids \fIResolve\fR message). 217 | .SH KNOWN ISSUES 218 | .SS Using only IPv6 on FreeBSD 219 | If wsdd is running on FreeBSD using IPv6 only, the host running wsdd may not be 220 | reliably discovered. The reason appears to be that Windows is not always able 221 | to connect to the HTTP service for unknown reasons. As a workaround, run wsdd 222 | with IPv4 only. 223 | .SS Tunnel/Bridge Interface 224 | .PP 225 | If tunnel/bridge interfaces like those created by OpenVPN or Docker exist, they 226 | may interfere with wsdd if executed without providing an interface that it 227 | should bind to (so it binds to all). In such cases, the wsdd hosts appears after 228 | wsdd has been started but it disappears when an update of the Network view in 229 | Windows Explorer is forced, either by refreshing the view or by a reboot of the 230 | Windows machine. To solve this issue, the interface that is connected to the 231 | network on which the host should be announced needs to be specified with the 232 | -i/--interface option. This prevents the usage of the tunnel/bridge 233 | interfaces. 234 | .PP 235 | Background: Tunnel/bridge interfaces may cause \fIResolve\fR requests from Windows 236 | hosts to be delivered to wsdd multiple times, i.e. duplicates of such request 237 | are created. If wsdd receives such a request first from a tunnel/bridge it uses 238 | the transport address (IP address) of that interface and sends the response via 239 | unicast. Further duplicates are not processed due to the duplicate message 240 | detection which is based on message UUIDs. The Windows host which receives the 241 | response appears to detect a mismatch between the transport address in the 242 | \fIResolveMatch\fR message (which is the tunnel/bridge address) and the IP of the 243 | sending host/interface (LAN IP, e.g.). Subsequently, the wsdd host is ignored by 244 | Windows. 245 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length=120 3 | # F401 - unused import (due to optional import of encoding package) 4 | # W503 - binary operators on newline (not end of line) 5 | ignore=F401,W503 6 | -------------------------------------------------------------------------------- /src/wsdd.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # Implements a target service according to the Web Service Discovery 4 | # specification. 5 | # 6 | # The purpose is to enable non-Windows devices to be found by the 'Network 7 | # (Neighborhood)' from Windows machines. 8 | # 9 | # see http://specs.xmlsoap.org/ws/2005/04/discovery/ws-discovery.pdf and 10 | # related documents for details (look at README for more references) 11 | # 12 | # (c) Steffen Christgau, 2017-2021 13 | 14 | import sys 15 | import signal 16 | import socket 17 | import asyncio 18 | import struct 19 | import argparse 20 | import uuid 21 | import time 22 | import random 23 | import logging 24 | import platform 25 | import ctypes.util 26 | import collections 27 | import xml.etree.ElementTree as ElementTree 28 | import http 29 | import http.server 30 | import urllib.request 31 | import urllib.parse 32 | import os 33 | import pwd 34 | import grp 35 | import datetime 36 | 37 | from typing import Any, Callable, ClassVar, Deque, Dict, List, Optional, Set, Union, Tuple, TYPE_CHECKING 38 | 39 | # try to load more secure XML module first, fallback to default if not present 40 | try: 41 | if not TYPE_CHECKING: 42 | from defusedxml.ElementTree import fromstring as ETfromString 43 | except ModuleNotFoundError: 44 | from xml.etree.ElementTree import fromstring as ETfromString 45 | 46 | 47 | WSDD_VERSION: str = '0.7.0' 48 | 49 | 50 | args: argparse.Namespace 51 | logger: logging.Logger 52 | 53 | 54 | class NetworkInterface: 55 | 56 | _name: str 57 | _index: int 58 | _scope: int 59 | 60 | def __init__(self, name: str, scope: int, index: int = None) -> None: 61 | self._name = name 62 | self._scope = scope 63 | if index is not None: 64 | self._index = index 65 | else: 66 | self._index = socket.if_nametoindex(self._name) 67 | 68 | @property 69 | def name(self) -> str: 70 | return self._name 71 | 72 | @property 73 | def scope(self) -> int: 74 | return self._scope 75 | 76 | @property 77 | def index(self) -> int: 78 | return self._index 79 | 80 | def __str__(self) -> str: 81 | return self._name 82 | 83 | def __eq__(self, other) -> bool: 84 | return self._name == other.name 85 | 86 | 87 | class NetworkAddress: 88 | 89 | _family: int 90 | _raw_address: bytes 91 | _address_str: str 92 | _interface: NetworkInterface 93 | 94 | def __init__(self, family: int, raw: Union[bytes, str], interface: NetworkInterface) -> None: 95 | self._family = family 96 | self._raw_address = raw if isinstance(raw, bytes) else socket.inet_pton(family, raw) 97 | self._interface = interface 98 | 99 | self._address_str = socket.inet_ntop(self._family, self._raw_address) 100 | 101 | @property 102 | def address_str(self): 103 | return self._address_str 104 | 105 | @property 106 | def family(self): 107 | return self._family 108 | 109 | @property 110 | def interface(self): 111 | return self._interface 112 | 113 | @property 114 | def is_multicastable(self): 115 | # Nah, this check is not optimal but there are no local flags for 116 | # addresses, but it should be safe for IPv4 anyways 117 | # (https://tools.ietf.org/html/rfc5735#page-3) 118 | return ((self._family == socket.AF_INET) and (self._raw_address[0] == 127) 119 | or (self._family == socket.AF_INET6) and (self._raw_address[0:2] != b'\xfe\x80')) 120 | 121 | @property 122 | def raw(self): 123 | return self._raw_address 124 | 125 | @property 126 | def transport_str(self): 127 | """the string representation of the local address overridden in network setup (for IPv6)""" 128 | return self._address_str if self._family == socket.AF_INET else '[{}]'.format(self._address_str) 129 | 130 | def __str__(self) -> str: 131 | return '{}%{}'.format(self._address_str, self._interface.name) 132 | 133 | def __eq__(self, other) -> bool: 134 | return (self._family == other.family and self.raw == other.raw and self.interface == other.interface) 135 | 136 | 137 | class UdpAddress(NetworkAddress): 138 | 139 | _transport_address: Tuple 140 | _port: int 141 | 142 | def __init__(self, family, transport_address: Tuple, interface: NetworkInterface) -> None: 143 | 144 | if not (family == socket.AF_INET or family == socket.AF_INET6): 145 | raise RuntimeError('Unsupport address address family: {}.'.format(family)) 146 | 147 | self._transport_address = transport_address 148 | self._port = transport_address[1] 149 | 150 | super().__init__(family, transport_address[0], interface) 151 | 152 | @property 153 | def transport_address(self): 154 | return self._transport_address 155 | 156 | @property 157 | def port(self): 158 | return self._port 159 | 160 | def __eq__(self, other) -> bool: 161 | return self.transport_address == other.transport_address 162 | 163 | 164 | class INetworkPacketHandler: 165 | 166 | def handle_packet(self, msg: str, udp_src_address: UdpAddress) -> None: 167 | pass 168 | 169 | 170 | class MulticastHandler: 171 | """ 172 | A class for handling multicast traffic on a given interface for a 173 | given address family. It provides multicast sender and receiver sockets 174 | """ 175 | 176 | # base interface addressing information 177 | address: NetworkAddress 178 | 179 | # individual interface-bound sockets for: 180 | # - receiving multicast traffic 181 | # - sending multicast from a socket bound to WSD port 182 | # - sending unicast messages from a random port 183 | recv_socket: socket.socket 184 | mc_send_socket: socket.socket 185 | uc_send_socket: socket.socket 186 | 187 | # addresses used for communication and data 188 | multicast_address: UdpAddress 189 | listen_address: Tuple 190 | aio_loop: asyncio.AbstractEventLoop 191 | 192 | # dictionary that holds INetworkPacketHandlers instances for sockets created above 193 | message_handlers: Dict[socket.socket, List[INetworkPacketHandler]] 194 | 195 | def __init__(self, address: NetworkAddress, aio_loop: asyncio.AbstractEventLoop) -> None: 196 | self.address = address 197 | 198 | self.recv_socket = socket.socket(self.address.family, socket.SOCK_DGRAM) 199 | self.recv_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 200 | self.mc_send_socket = socket.socket(self.address.family, socket.SOCK_DGRAM) 201 | self.uc_send_socket = socket.socket(self.address.family, socket.SOCK_DGRAM) 202 | self.uc_send_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 203 | 204 | self.message_handlers = {} 205 | self.aio_loop = aio_loop 206 | 207 | if self.address.family == socket.AF_INET: 208 | self.init_v4() 209 | elif self.address.family == socket.AF_INET6: 210 | self.init_v6() 211 | 212 | logger.info('joined multicast group {0} on {1}'.format(self.multicast_address.transport_str, self.address)) 213 | logger.debug('transport address on {0} is {1}'.format(self.address.interface.name, self.address.transport_str)) 214 | logger.debug('will listen for HTTP traffic on address {0}'.format(self.listen_address)) 215 | 216 | # register calbacks for incoming data (also for mc) 217 | self.aio_loop.add_reader(self.recv_socket.fileno(), self.read_socket, self.recv_socket) 218 | self.aio_loop.add_reader(self.mc_send_socket.fileno(), self.read_socket, self.mc_send_socket) 219 | self.aio_loop.add_reader(self.uc_send_socket.fileno(), self.read_socket, self.uc_send_socket) 220 | 221 | def cleanup(self) -> None: 222 | self.aio_loop.remove_reader(self.recv_socket) 223 | self.aio_loop.remove_reader(self.mc_send_socket) 224 | self.aio_loop.remove_reader(self.uc_send_socket) 225 | 226 | self.recv_socket.close() 227 | self.mc_send_socket.close() 228 | self.uc_send_socket.close() 229 | 230 | def handles_address(self, address: NetworkAddress) -> bool: 231 | return self.address == address 232 | 233 | def init_v6(self) -> None: 234 | idx = self.address.interface.index 235 | raw_mc_addr = (WSD_MCAST_GRP_V6, WSD_UDP_PORT, 0x575C, idx) 236 | self.multicast_address = UdpAddress(self.address.family, raw_mc_addr, self.address.interface) 237 | 238 | # v6: member_request = { multicast_addr, intf_idx } 239 | mreq = (socket.inet_pton(self.address.family, WSD_MCAST_GRP_V6) + struct.pack('@I', idx)) 240 | self.recv_socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_JOIN_GROUP, mreq) 241 | self.recv_socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1) 242 | 243 | # Could anyone ask the Linux folks for the rationale for this!? 244 | if platform.system() == 'Linux': 245 | try: 246 | # supported starting from Linux 4.20 247 | IPV6_MULTICAST_ALL = 29 248 | self.recv_socket.setsockopt(socket.IPPROTO_IPV6, IPV6_MULTICAST_ALL, 0) 249 | except OSError as e: 250 | logger.warning('cannot unset all_multicast: {}'.format(e)) 251 | 252 | # bind to network interface, i.e. scope and handle OS differences, 253 | # see Stevens: Unix Network Programming, Section 21.6, last paragraph 254 | try: 255 | self.recv_socket.bind((WSD_MCAST_GRP_V6, WSD_UDP_PORT, 0, idx)) 256 | except OSError: 257 | self.recv_socket.bind(('::', 0, 0, idx)) 258 | 259 | # bind unicast socket to interface address and WSD's udp port 260 | self.uc_send_socket.bind((str(self.address), WSD_UDP_PORT, 0, idx)) 261 | 262 | self.mc_send_socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_MULTICAST_LOOP, 0) 263 | self.mc_send_socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_MULTICAST_HOPS, args.hoplimit) 264 | self.mc_send_socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_MULTICAST_IF, idx) 265 | 266 | self.listen_address = (self.address.address_str, WSD_HTTP_PORT, 0, idx) 267 | 268 | def init_v4(self) -> None: 269 | idx = self.address.interface.index 270 | raw_mc_addr = (WSD_MCAST_GRP_V4, WSD_UDP_PORT) 271 | self.multicast_address = UdpAddress(self.address.family, raw_mc_addr, self.address.interface) 272 | 273 | # v4: member_request (ip_mreqn) = { multicast_addr, intf_addr, idx } 274 | mreq = (socket.inet_pton(self.address.family, WSD_MCAST_GRP_V4) + self.address.raw + struct.pack('@I', idx)) 275 | self.recv_socket.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq) 276 | 277 | if platform.system() == 'Linux': 278 | IP_MULTICAST_ALL = 49 279 | self.recv_socket.setsockopt(socket.IPPROTO_IP, IP_MULTICAST_ALL, 0) 280 | 281 | try: 282 | self.recv_socket.bind((WSD_MCAST_GRP_V4, WSD_UDP_PORT)) 283 | except OSError: 284 | self.recv_socket.bind(('', WSD_UDP_PORT)) 285 | 286 | # bind unicast socket to interface address and WSD's udp port 287 | self.uc_send_socket.bind((self.address.address_str, WSD_UDP_PORT)) 288 | 289 | self.mc_send_socket.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_IF, mreq) 290 | self.mc_send_socket.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_LOOP, 0) 291 | self.mc_send_socket.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, args.hoplimit) 292 | 293 | self.listen_address = (self.address.address_str, WSD_HTTP_PORT) 294 | 295 | def add_handler(self, socket: socket.socket, handler: INetworkPacketHandler) -> None: 296 | # try: 297 | # self.selector.register(socket, selectors.EVENT_READ, self) 298 | # except KeyError: 299 | # # accept attempts of multiple registrations 300 | # pass 301 | 302 | if socket in self.message_handlers: 303 | self.message_handlers[socket].append(handler) 304 | else: 305 | self.message_handlers[socket] = [handler] 306 | 307 | def remove_handler(self, socket: socket.socket, handler) -> None: 308 | if socket in self.message_handlers: 309 | if handler in self.message_handlers[socket]: 310 | self.message_handlers[socket].remove(handler) 311 | 312 | def read_socket(self, key: socket.socket) -> None: 313 | # TODO: refactor this 314 | s = None 315 | if key == self.uc_send_socket: 316 | s = self.uc_send_socket 317 | elif key == self.mc_send_socket: 318 | s = self.mc_send_socket 319 | elif key == self.recv_socket: 320 | s = self.recv_socket 321 | else: 322 | raise ValueError("Unknown socket passed as key.") 323 | 324 | msg, raw_address = s.recvfrom(WSD_MAX_LEN) 325 | address = UdpAddress(self.address.family, raw_address, self.address.interface) 326 | if s in self.message_handlers: 327 | for handler in self.message_handlers[s]: 328 | handler.handle_packet(msg.decode('utf-8'), address) 329 | 330 | def send(self, msg: bytes, addr: UdpAddress): 331 | # Request from a client must be answered from a socket that is bound 332 | # to the WSD port, i.e. the recv_socket. Messages to multicast 333 | # addresses are sent over the dedicated send socket. 334 | if addr == self.multicast_address: 335 | self.mc_send_socket.sendto(msg, addr.transport_address) 336 | else: 337 | self.uc_send_socket.sendto(msg, addr.transport_address) 338 | 339 | 340 | # constants for WSD XML/SOAP parsing 341 | WSA_URI: str = 'http://schemas.xmlsoap.org/ws/2004/08/addressing' 342 | WSD_URI: str = 'http://schemas.xmlsoap.org/ws/2005/04/discovery' 343 | WSDP_URI: str = 'http://schemas.xmlsoap.org/ws/2006/02/devprof' 344 | 345 | namespaces: Dict[str, str] = { 346 | 'soap': 'http://www.w3.org/2003/05/soap-envelope', 347 | 'wsa': WSA_URI, 348 | 'wsd': WSD_URI, 349 | 'wsx': 'http://schemas.xmlsoap.org/ws/2004/09/mex', 350 | 'wsdp': WSDP_URI, 351 | 'pnpx': 'http://schemas.microsoft.com/windows/pnpx/2005/10', 352 | 'pub': 'http://schemas.microsoft.com/windows/pub/2005/07' 353 | } 354 | 355 | WSD_MAX_KNOWN_MESSAGES: int = 10 356 | 357 | WSD_PROBE: str = WSD_URI + '/Probe' 358 | WSD_PROBE_MATCH: str = WSD_URI + '/ProbeMatches' 359 | WSD_RESOLVE: str = WSD_URI + '/Resolve' 360 | WSD_RESOLVE_MATCH: str = WSD_URI + '/ResolveMatches' 361 | WSD_HELLO: str = WSD_URI + '/Hello' 362 | WSD_BYE: str = WSD_URI + '/Bye' 363 | WSD_GET: str = 'http://schemas.xmlsoap.org/ws/2004/09/transfer/Get' 364 | WSD_GET_RESPONSE: str = 'http://schemas.xmlsoap.org/ws/2004/09/transfer/GetResponse' 365 | 366 | WSD_TYPE_DEVICE: str = 'wsdp:Device' 367 | PUB_COMPUTER: str = 'pub:Computer' 368 | WSD_TYPE_DEVICE_COMPUTER: str = '{0} {1}'.format(WSD_TYPE_DEVICE, PUB_COMPUTER) 369 | 370 | WSD_MCAST_GRP_V4: str = '239.255.255.250' 371 | WSD_MCAST_GRP_V6: str = 'ff02::c' # link-local 372 | 373 | WSA_ANON: str = WSA_URI + '/role/anonymous' 374 | WSA_DISCOVERY: str = 'urn:schemas-xmlsoap-org:ws:2005:04:discovery' 375 | 376 | MIME_TYPE_SOAP_XML: str = 'application/soap+xml' 377 | 378 | # protocol assignments (WSD spec/Section 2.4) 379 | WSD_UDP_PORT: int = 3702 380 | WSD_HTTP_PORT: int = 5357 381 | WSD_MAX_LEN: int = 32767 382 | 383 | WSDD_LISTEN_PORT = 5359 384 | 385 | # SOAP/UDP transmission constants 386 | MULTICAST_UDP_REPEAT: int = 4 387 | UNICAST_UDP_REPEAT: int = 2 388 | UDP_MIN_DELAY: int = 50 389 | UDP_MAX_DELAY: int = 250 390 | UDP_UPPER_DELAY: int = 500 391 | 392 | # servers must recond in 4 seconds after probe arrives 393 | PROBE_TIMEOUT: int = 4 394 | MAX_STARTUP_PROBE_DELAY: int = 3 395 | 396 | # some globals 397 | wsd_instance_id: int = int(time.time()) 398 | 399 | WSDMessage = Tuple[ElementTree.Element, str] 400 | MessageTypeHandler = Callable[[ElementTree.Element, ElementTree.Element], Optional[WSDMessage]] 401 | 402 | 403 | class WSDMessageHandler(INetworkPacketHandler): 404 | known_messages: Deque[str] = collections.deque([], WSD_MAX_KNOWN_MESSAGES) 405 | 406 | handlers: Dict[str, MessageTypeHandler] 407 | pending_tasks: List[asyncio.Task] 408 | 409 | def __init__(self) -> None: 410 | self.handlers = {} 411 | self.pending_tasks = [] 412 | 413 | def cleanup(self): 414 | pass 415 | 416 | # shortcuts for building WSD responses 417 | def add_endpoint_reference(self, parent: ElementTree.Element, endpoint: str = None) -> None: 418 | epr = ElementTree.SubElement(parent, 'wsa:EndpointReference') 419 | address = ElementTree.SubElement(epr, 'wsa:Address') 420 | if endpoint is None: 421 | address.text = args.uuid.urn 422 | else: 423 | address.text = endpoint 424 | 425 | def add_metadata_version(self, parent: ElementTree.Element) -> None: 426 | meta_data = ElementTree.SubElement(parent, 'wsd:MetadataVersion') 427 | meta_data.text = '1' 428 | 429 | def add_types(self, parent: ElementTree.Element) -> None: 430 | dev_type = ElementTree.SubElement(parent, 'wsd:Types') 431 | dev_type.text = WSD_TYPE_DEVICE_COMPUTER 432 | 433 | def add_xaddr(self, parent: ElementTree.Element, transport_addr: str) -> None: 434 | if transport_addr: 435 | item = ElementTree.SubElement(parent, 'wsd:XAddrs') 436 | item.text = 'http://{0}:{1}/{2}'.format(transport_addr, WSD_HTTP_PORT, args.uuid) 437 | 438 | def build_message(self, to_addr: str, action_str: str, request_header: Optional[ElementTree.Element], 439 | response: ElementTree.Element) -> str: 440 | retval = self.xml_to_str(self.build_message_tree(to_addr, action_str, request_header, response)[0]) 441 | 442 | logger.debug('constructed xml for WSD message: {0}'.format(retval)) 443 | 444 | return retval 445 | 446 | def build_message_tree(self, to_addr: str, action_str: str, request_header: Optional[ElementTree.Element], 447 | body: Optional[ElementTree.Element]) -> Tuple[ElementTree.Element, str]: 448 | """ 449 | Build a WSD message with a given action string including SOAP header. 450 | 451 | The message can be constructed based on a response to another 452 | message (given by its header) and with a optional response that 453 | serves as the message's body 454 | """ 455 | root = ElementTree.Element('soap:Envelope') 456 | header = ElementTree.SubElement(root, 'soap:Header') 457 | 458 | to = ElementTree.SubElement(header, 'wsa:To') 459 | to.text = to_addr 460 | 461 | action = ElementTree.SubElement(header, 'wsa:Action') 462 | action.text = action_str 463 | 464 | msg_id = ElementTree.SubElement(header, 'wsa:MessageID') 465 | msg_id.text = uuid.uuid1().urn 466 | 467 | if request_header: 468 | req_msg_id = request_header.find('./wsa:MessageID', namespaces) 469 | if req_msg_id is not None: 470 | relates_to = ElementTree.SubElement(header, 'wsa:RelatesTo') 471 | relates_to.text = req_msg_id.text 472 | 473 | self.add_header_elements(header, action_str) 474 | 475 | body_root = ElementTree.SubElement(root, 'soap:Body') 476 | if body is not None: 477 | body_root.append(body) 478 | 479 | for prefix, uri in namespaces.items(): 480 | root.attrib['xmlns:' + prefix] = uri 481 | 482 | return root, msg_id.text 483 | 484 | def add_header_elements(self, header: ElementTree.Element, extra: Any) -> None: 485 | pass 486 | 487 | def handle_message(self, msg: str, src: Optional[UdpAddress] = None) -> Optional[str]: 488 | """ 489 | handle a WSD message 490 | """ 491 | try: 492 | tree = ETfromString(msg) 493 | except ElementTree.ParseError: 494 | return None 495 | 496 | header = tree.find('./soap:Header', namespaces) 497 | if header is None: 498 | return None 499 | 500 | msg_id_tag = header.find('./wsa:MessageID', namespaces) 501 | if msg_id_tag is None: 502 | return None 503 | 504 | msg_id = str(msg_id_tag.text) 505 | 506 | # check for duplicates 507 | if self.is_duplicated_msg(msg_id): 508 | logger.debug('known message ({0}): dropping it'.format(msg_id)) 509 | return None 510 | 511 | action_tag = header.find('./wsa:Action', namespaces) 512 | if action_tag is None: 513 | return None 514 | 515 | action: str = str(action_tag.text) 516 | _, _, action_method = action.rpartition('/') 517 | 518 | if src: 519 | logger.info('{}:{}({}) - - "{} {} UDP" - -'.format( 520 | src.transport_str, src.port, src.interface, action_method, msg_id)) 521 | else: 522 | # http logging is already done by according server 523 | logger.debug('processing WSD {} message ({})'.format(action_method, msg_id)) 524 | 525 | body = tree.find('./soap:Body', namespaces) 526 | if body is None: 527 | return None 528 | 529 | logger.debug('incoming message content is {0}'.format(msg)) 530 | if action in self.handlers: 531 | handler = self.handlers[action] 532 | retval = handler(header, body) 533 | if retval is not None: 534 | response, response_type = retval 535 | return self.build_message(WSA_ANON, response_type, header, response) 536 | else: 537 | logger.debug('unhandled action {0}/{1}'.format(action, msg_id)) 538 | 539 | return None 540 | 541 | def is_duplicated_msg(self, msg_id: str) -> bool: 542 | """ 543 | Check for a duplicated message. 544 | 545 | Implements SOAP-over-UDP Appendix II Item 2 546 | """ 547 | if msg_id in type(self).known_messages: 548 | return True 549 | 550 | type(self).known_messages.append(msg_id) 551 | 552 | return False 553 | 554 | def xml_to_str(self, xml: ElementTree.Element) -> str: 555 | retval = '' 556 | retval = retval + ElementTree.tostring(xml, encoding='utf-8').decode('utf-8') 557 | 558 | return retval 559 | 560 | 561 | class WSDUDPMessageHandler(WSDMessageHandler): 562 | """ 563 | A message handler that handles traffic received via MutlicastHandler. 564 | """ 565 | 566 | mch: MulticastHandler 567 | tearing_down: bool 568 | 569 | def __init__(self, mch: MulticastHandler) -> None: 570 | super().__init__() 571 | 572 | self.mch = mch 573 | self.tearing_down = False 574 | 575 | def teardown(self): 576 | self.tearing_down = True 577 | 578 | def send_datagram(self, msg: str, dst: UdpAddress) -> None: 579 | try: 580 | self.mch.send(msg.encode('utf-8'), dst) 581 | except Exception as e: 582 | logger.error('error while sending packet on {}: {}'.format(self.mch.address.interface, e)) 583 | 584 | def enqueue_datagram(self, msg: str, address: UdpAddress, msg_type: Optional[str] = None) -> None: 585 | if msg_type: 586 | logger.info('scheduling {0} message via {1} to {2}'.format(msg_type, address.interface, address)) 587 | 588 | schedule_task = self.mch.aio_loop.create_task(self.schedule_datagram(msg, address)) 589 | # Add this task to the pending list during teardown to wait during shutdown 590 | if self.tearing_down: 591 | self.pending_tasks.append(schedule_task) 592 | 593 | async def schedule_datagram(self, msg: str, address: UdpAddress) -> None: 594 | """ 595 | Schedule to send the given message to the given address. 596 | 597 | Implements SOAP over UDP, Appendix I. 598 | """ 599 | 600 | self.send_datagram(msg, address) 601 | 602 | delta = 0 603 | msg_count = MULTICAST_UDP_REPEAT if address == self.mch.multicast_address else UNICAST_UDP_REPEAT 604 | delta = random.randint(UDP_MIN_DELAY, UDP_MAX_DELAY) 605 | for i in range(msg_count - 1): 606 | await asyncio.sleep(delta / 1000.0) 607 | self.send_datagram(msg, address) 608 | delta = min(delta * 2, UDP_UPPER_DELAY) 609 | 610 | 611 | class WSDDiscoveredDevice: 612 | 613 | # a dict of discovered devices with their UUID as key 614 | instances: Dict[str, 'WSDDiscoveredDevice'] = {} 615 | 616 | addresses: Dict[str, Set[str]] 617 | props: Dict[str, str] 618 | display_name: str 619 | last_seen: float 620 | 621 | def __init__(self, xml_str: str, xaddr: str, interface: NetworkInterface) -> None: 622 | self.last_seen = 0.0 623 | self.addresses = {} 624 | self.props = {} 625 | self.display_name = '' 626 | 627 | self.update(xml_str, xaddr, interface) 628 | 629 | def update(self, xml_str: str, xaddr: str, interface: NetworkInterface) -> None: 630 | try: 631 | tree = ETfromString(xml_str) 632 | except ElementTree.ParseError: 633 | return None 634 | mds_path = 'soap:Body/wsx:Metadata/wsx:MetadataSection' 635 | sections = tree.findall(mds_path, namespaces) 636 | for section in sections: 637 | dialect = section.attrib['Dialect'] 638 | if dialect == WSDP_URI + '/ThisDevice': 639 | self.extract_wsdp_props(section, dialect) 640 | elif dialect == WSDP_URI + '/ThisModel': 641 | self.extract_wsdp_props(section, dialect) 642 | elif dialect == WSDP_URI + '/Relationship': 643 | host_xpath = 'wsdp:Relationship[@Type="{}/host"]/wsdp:Host'.format(WSDP_URI) 644 | host_sec = section.find(host_xpath, namespaces) 645 | if (host_sec): 646 | self.extract_host_props(host_sec) 647 | else: 648 | logger.debug('unknown metadata dialect ({})'.format(dialect)) 649 | 650 | url = urllib.parse.urlparse(xaddr) 651 | addr, _, _ = url.netloc.rpartition(':') 652 | report = True 653 | if interface.name not in self.addresses: 654 | self.addresses[interface.name] = set([addr]) 655 | else: 656 | if addr not in self.addresses[interface.name]: 657 | self.addresses[interface.name].add(addr) 658 | else: 659 | report = False 660 | 661 | self.last_seen = time.time() 662 | if ('DisplayName' in self.props) and ('BelongsTo' in self.props) and (report): 663 | self.display_name = self.props['DisplayName'] 664 | logger.info('discovered {} in {} on {}'.format(self.display_name, self.props['BelongsTo'], addr)) 665 | elif ('FriendlyName' in self.props) and (report): 666 | self.display_name = self.props['FriendlyName'] 667 | logger.info('discovered {} on {}'.format(self.display_name, addr)) 668 | 669 | logger.debug(str(self.props)) 670 | 671 | def extract_wsdp_props(self, root: ElementTree.Element, dialect: str) -> None: 672 | _, _, propsRoot = dialect.rpartition('/') 673 | # XPath support is limited, so filter by namespace on our own 674 | nodes = root.findall('./wsdp:{0}/*'.format(propsRoot), namespaces) 675 | ns_prefix = '{{{}}}'.format(WSDP_URI) 676 | prop_nodes = [n for n in nodes if n.tag.startswith(ns_prefix)] 677 | for node in prop_nodes: 678 | tag_name = node.tag[len(ns_prefix):] 679 | self.props[tag_name] = str(node.text) 680 | 681 | def extract_host_props(self, root: ElementTree.Element) -> None: 682 | types = root.findtext('wsdp:Types', '', namespaces) 683 | self.props['types'] = types.split(' ')[0] 684 | if types != PUB_COMPUTER: 685 | return 686 | 687 | comp = root.findtext(PUB_COMPUTER, '', namespaces) 688 | self.props['DisplayName'], _, self.props['BelongsTo'] = ( 689 | comp.partition('/')) 690 | 691 | 692 | class WSDClient(WSDUDPMessageHandler): 693 | 694 | instances: ClassVar[List['WSDClient']] = [] 695 | probes: Dict[str, float] 696 | 697 | def __init__(self, mch: MulticastHandler) -> None: 698 | super().__init__(mch) 699 | 700 | WSDClient.instances.append(self) 701 | 702 | self.mch.add_handler(self.mch.mc_send_socket, self) 703 | self.mch.add_handler(self.mch.recv_socket, self) 704 | 705 | self.probes = {} 706 | 707 | self.handlers[WSD_HELLO] = self.handle_hello 708 | self.handlers[WSD_BYE] = self.handle_bye 709 | self.handlers[WSD_PROBE_MATCH] = self.handle_probe_match 710 | self.handlers[WSD_RESOLVE_MATCH] = self.handle_resolve_match 711 | 712 | # avoid packet storm when hosts come up by delaying initial probe 713 | time.sleep(random.randint(0, MAX_STARTUP_PROBE_DELAY)) 714 | self.send_probe() 715 | 716 | def cleanup(self) -> None: 717 | super().cleanup() 718 | WSDClient.instances.remove(self) 719 | 720 | self.mch.remove_handler(self.mch.mc_send_socket, self) 721 | self.mch.remove_handler(self.mch.recv_socket, self) 722 | 723 | def send_probe(self) -> None: 724 | """WS-Discovery, Section 4.3, Probe message""" 725 | self.remove_outdated_probes() 726 | 727 | probe = ElementTree.Element('wsd:Probe') 728 | ElementTree.SubElement(probe, 'wsd:Types').text = WSD_TYPE_DEVICE 729 | 730 | xml, i = self.build_message_tree(WSA_DISCOVERY, WSD_PROBE, None, probe) 731 | self.enqueue_datagram(self.xml_to_str(xml), self.mch.multicast_address, msg_type='Probe') 732 | self.probes[i] = time.time() 733 | 734 | def teardown(self) -> None: 735 | super().teardown() 736 | self.remove_outdated_probes() 737 | 738 | def handle_packet(self, msg: str, src: UdpAddress = None) -> None: 739 | self.handle_message(msg, src) 740 | 741 | def handle_hello(self, header: ElementTree.Element, body: ElementTree.Element) -> Optional[WSDMessage]: 742 | pm_path = 'wsd:Hello' 743 | endpoint, xaddrs = self.extract_endpoint_metadata(body, pm_path) 744 | if not xaddrs: 745 | logger.info('Hello without XAddrs, sending resolve') 746 | msg = self.build_resolve_message(str(endpoint)) 747 | self.enqueue_datagram(msg, self.mch.multicast_address) 748 | return None 749 | 750 | xaddr = None 751 | for addr in xaddrs.strip().split(): 752 | if (self.mch.address.family == socket.AF_INET6) and ('//[fe80::' in addr): 753 | # use first link-local address for IPv6 754 | xaddr = addr 755 | break 756 | elif self.mch.address.family == socket.AF_INET: 757 | # use first (and very likely the only) IPv4 address 758 | xaddr = addr 759 | break 760 | 761 | if xaddr is None: 762 | return None 763 | 764 | logger.info('Hello from {} on {}'.format(endpoint, xaddr)) 765 | self.perform_metadata_exchange(endpoint, xaddr) 766 | return None 767 | 768 | def handle_bye(self, header: ElementTree.Element, body: ElementTree.Element) -> Optional[WSDMessage]: 769 | bye_path = 'wsd:Bye' 770 | endpoint, _ = self.extract_endpoint_metadata(body, bye_path) 771 | device_uuid = str(uuid.UUID(endpoint)) 772 | if device_uuid in WSDDiscoveredDevice.instances: 773 | del(WSDDiscoveredDevice.instances[device_uuid]) 774 | 775 | return None 776 | 777 | def handle_probe_match(self, header: ElementTree.Element, body: ElementTree.Element) -> Optional[WSDMessage]: 778 | # do not handle to probematches issued not sent by ourself 779 | rel_msg = header.findtext('wsa:RelatesTo', None, namespaces) 780 | if rel_msg not in self.probes: 781 | logger.debug("unknown probe {}".format(rel_msg)) 782 | return None 783 | 784 | # if XAddrs are missing, issue resolve request 785 | pm_path = 'wsd:ProbeMatches/wsd:ProbeMatch' 786 | endpoint, xaddrs = self.extract_endpoint_metadata(body, pm_path) 787 | if not xaddrs: 788 | logger.debug('probe match without XAddrs, sending resolve') 789 | msg = self.build_resolve_message(str(endpoint)) 790 | self.enqueue_datagram(msg, self.mch.multicast_address) 791 | return None 792 | 793 | xaddr = xaddrs.strip() 794 | logger.debug('probe match for {} on {}'.format(endpoint, xaddr)) 795 | self.perform_metadata_exchange(endpoint, xaddr) 796 | 797 | return None 798 | 799 | def build_resolve_message(self, endpoint: str) -> str: 800 | resolve = ElementTree.Element('wsd:Resolve') 801 | self.add_endpoint_reference(resolve, endpoint) 802 | 803 | return self.build_message(WSA_DISCOVERY, WSD_RESOLVE, None, resolve) 804 | 805 | def handle_resolve_match(self, header: ElementTree.Element, body: ElementTree.Element) -> Optional[WSDMessage]: 806 | rm_path = 'wsd:ResolveMatches/wsd:ResolveMatch' 807 | endpoint, xaddrs = self.extract_endpoint_metadata(body, rm_path) 808 | if not endpoint or not xaddrs: 809 | logger.debug('resolve match without endpoint/xaddr') 810 | return None 811 | 812 | xaddr = xaddrs.strip() 813 | logger.debug('resolve match for {} on {}'.format(endpoint, xaddr)) 814 | self.perform_metadata_exchange(endpoint, xaddr) 815 | 816 | return None 817 | 818 | def extract_endpoint_metadata(self, body: ElementTree.Element, prefix: str) -> Tuple[Optional[str], Optional[str]]: 819 | prefix = prefix + '/' 820 | addr_path = 'wsa:EndpointReference/wsa:Address' 821 | 822 | endpoint = body.findtext(prefix + addr_path, namespaces=namespaces) 823 | xaddrs = body.findtext(prefix + 'wsd:XAddrs', namespaces=namespaces) 824 | 825 | return endpoint, xaddrs 826 | 827 | def perform_metadata_exchange(self, endpoint, xaddr: str): 828 | if not (xaddr.startswith('http://') or xaddr.startswith('https://')): 829 | logger.debug('invalid XAddr: {}'.format(xaddr)) 830 | return 831 | 832 | host = None 833 | url = xaddr 834 | if self.mch.address.family == socket.AF_INET6: 835 | host = '[{}]'.format(url.partition('[')[2].partition(']')[0]) 836 | url = url.replace(']', '%{}]'.format(self.mch.address.interface)) 837 | 838 | body = self.build_getmetadata_message(endpoint) 839 | request = urllib.request.Request(url, data=body.encode('utf-8'), method='POST') 840 | request.add_header('Content-Type', 'application/soap+xml') 841 | request.add_header('User-Agent', 'wsdd') 842 | if host is not None: 843 | request.add_header('Host', host) 844 | 845 | try: 846 | with urllib.request.urlopen(request, None, 2.0) as stream: 847 | self.handle_metadata(stream.read(), endpoint, xaddr) 848 | except urllib.error.URLError as e: 849 | logger.warning('could not fetch metadata from: {} {}'.format(url, e)) 850 | 851 | def build_getmetadata_message(self, endpoint) -> str: 852 | tree, _ = self.build_message_tree(endpoint, WSD_GET, None, None) 853 | return self.xml_to_str(tree) 854 | 855 | def handle_metadata(self, meta: str, endpoint: str, xaddr: str) -> None: 856 | device_uuid = str(uuid.UUID(endpoint)) 857 | if device_uuid in WSDDiscoveredDevice.instances: 858 | WSDDiscoveredDevice.instances[device_uuid].update(meta, xaddr, self.mch.address.interface) 859 | else: 860 | WSDDiscoveredDevice.instances[device_uuid] = WSDDiscoveredDevice(meta, xaddr, self.mch.address.interface) 861 | 862 | def remove_outdated_probes(self) -> None: 863 | cut = time.time() - PROBE_TIMEOUT * 2 864 | self.probes = dict(filter(lambda x: x[1] > cut, self.probes.items())) 865 | 866 | def add_header_elements(self, header: ElementTree.Element, extra: Any) -> None: 867 | action_str = extra 868 | if action_str == WSD_GET: 869 | reply_to = ElementTree.SubElement(header, 'wsa:ReplyTo') 870 | addr = ElementTree.SubElement(reply_to, 'wsa:Address') 871 | addr.text = WSA_ANON 872 | 873 | wsa_from = ElementTree.SubElement(header, 'wsa:From') 874 | addr = ElementTree.SubElement(wsa_from, 'wsa:Address') 875 | addr.text = args.uuid.urn 876 | 877 | 878 | class WSDHost(WSDUDPMessageHandler): 879 | """Class for handling WSD requests coming from UDP datagrams.""" 880 | 881 | message_number: ClassVar[int] = 0 882 | instances: ClassVar[List['WSDHost']] = [] 883 | 884 | def __init__(self, mch: MulticastHandler) -> None: 885 | super().__init__(mch) 886 | 887 | WSDHost.instances.append(self) 888 | 889 | self.mch.add_handler(self.mch.recv_socket, self) 890 | 891 | self.handlers[WSD_PROBE] = self.handle_probe 892 | self.handlers[WSD_RESOLVE] = self.handle_resolve 893 | 894 | self.send_hello() 895 | 896 | def cleanup(self) -> None: 897 | super().cleanup() 898 | WSDHost.instances.remove(self) 899 | 900 | def teardown(self) -> None: 901 | super().teardown() 902 | self.send_bye() 903 | 904 | def handle_packet(self, msg: str, src: UdpAddress) -> None: 905 | reply = self.handle_message(msg, src) 906 | if reply: 907 | self.enqueue_datagram(reply, src) 908 | 909 | def send_hello(self) -> None: 910 | """WS-Discovery, Section 4.1, Hello message""" 911 | hello = ElementTree.Element('wsd:Hello') 912 | self.add_endpoint_reference(hello) 913 | # THINK: Microsoft does not send the transport address here due to privacy reasons. Could make this optional. 914 | self.add_xaddr(hello, self.mch.address.transport_str) 915 | self.add_metadata_version(hello) 916 | 917 | msg = self.build_message(WSA_DISCOVERY, WSD_HELLO, None, hello) 918 | self.enqueue_datagram(msg, self.mch.multicast_address, msg_type='Hello') 919 | 920 | def send_bye(self) -> None: 921 | """WS-Discovery, Section 4.2, Bye message""" 922 | bye = ElementTree.Element('wsd:Bye') 923 | self.add_endpoint_reference(bye) 924 | 925 | msg = self.build_message(WSA_DISCOVERY, WSD_BYE, None, bye) 926 | self.enqueue_datagram(msg, self.mch.multicast_address, msg_type='Bye') 927 | 928 | def handle_probe(self, header: ElementTree.Element, body: ElementTree.Element) -> Optional[WSDMessage]: 929 | probe = body.find('./wsd:Probe', namespaces) 930 | if probe is None: 931 | return None 932 | 933 | scopes = probe.find('./wsd:Scopes', namespaces) 934 | 935 | if scopes: 936 | # THINK: send fault message (see p. 21 in WSD) 937 | logger.debug('scopes ({}) unsupported but probed'.format(scopes)) 938 | return None 939 | 940 | types_elem = probe.find('./wsd:Types', namespaces) 941 | if types_elem is None: 942 | logger.debug('Probe message lacks wsd:Types element. Ignored.') 943 | return None 944 | 945 | types = types_elem.text 946 | if not types == WSD_TYPE_DEVICE: 947 | logger.debug('unknown discovery type ({}) for probe'.format(types)) 948 | return None 949 | 950 | matches = ElementTree.Element('wsd:ProbeMatches') 951 | match = ElementTree.SubElement(matches, 'wsd:ProbeMatch') 952 | self.add_endpoint_reference(match) 953 | self.add_types(match) 954 | self.add_metadata_version(match) 955 | 956 | return matches, WSD_PROBE_MATCH 957 | 958 | def handle_resolve(self, header: ElementTree.Element, body: ElementTree.Element) -> Optional[WSDMessage]: 959 | resolve = body.find('./wsd:Resolve', namespaces) 960 | if resolve is None: 961 | return None 962 | 963 | addr = resolve.find('./wsa:EndpointReference/wsa:Address', namespaces) 964 | if addr is None: 965 | logger.debug('invalid resolve request: missing endpoint address') 966 | return None 967 | 968 | if not addr.text == args.uuid.urn: 969 | logger.debug('invalid resolve request: address ({}) does not match own one ({})'.format( 970 | addr.text, args.uuid.urn)) 971 | return None 972 | 973 | matches = ElementTree.Element('wsd:ResolveMatches') 974 | match = ElementTree.SubElement(matches, 'wsd:ResolveMatch') 975 | self.add_endpoint_reference(match) 976 | self.add_types(match) 977 | self.add_xaddr(match, self.mch.address.transport_str) 978 | self.add_metadata_version(match) 979 | 980 | return matches, WSD_RESOLVE_MATCH 981 | 982 | def add_header_elements(self, header: ElementTree.Element, extra: Any): 983 | ElementTree.SubElement(header, 'wsd:AppSequence', { 984 | 'InstanceId': str(wsd_instance_id), 985 | 'SequenceId': uuid.uuid1().urn, 986 | 'MessageNumber': str(type(self).message_number)}) 987 | 988 | type(self).message_number += 1 989 | 990 | 991 | class WSDHttpMessageHandler(WSDMessageHandler): 992 | 993 | def __init__(self) -> None: 994 | super().__init__() 995 | 996 | self.handlers[WSD_GET] = self.handle_get 997 | 998 | def handle_get(self, header: ElementTree.Element, body: ElementTree.Element) -> WSDMessage: 999 | # see https://msdn.microsoft.com/en-us/library/hh441784.aspx for an 1000 | # example. Some of the properties below might be made configurable 1001 | # in future releases. 1002 | metadata = ElementTree.Element('wsx:Metadata') 1003 | section = ElementTree.SubElement(metadata, 'wsx:MetadataSection', {'Dialect': WSDP_URI + '/ThisDevice'}) 1004 | device = ElementTree.SubElement(section, 'wsdp:ThisDevice') 1005 | ElementTree.SubElement(device, 'wsdp:FriendlyName').text = ('WSD Device {0}'.format(args.hostname)) 1006 | ElementTree.SubElement(device, 'wsdp:FirmwareVersion').text = '1.0' 1007 | ElementTree.SubElement(device, 'wsdp:SerialNumber').text = '1' 1008 | 1009 | section = ElementTree.SubElement(metadata, 'wsx:MetadataSection', {'Dialect': WSDP_URI + '/ThisModel'}) 1010 | model = ElementTree.SubElement(section, 'wsdp:ThisModel') 1011 | ElementTree.SubElement(model, 'wsdp:Manufacturer').text = 'wsdd' 1012 | ElementTree.SubElement(model, 'wsdp:ModelName').text = 'wsdd' 1013 | ElementTree.SubElement(model, 'pnpx:DeviceCategory').text = 'Computers' 1014 | 1015 | section = ElementTree.SubElement(metadata, 'wsx:MetadataSection', {'Dialect': WSDP_URI + '/Relationship'}) 1016 | rel = ElementTree.SubElement(section, 'wsdp:Relationship', {'Type': WSDP_URI + '/host'}) 1017 | host = ElementTree.SubElement(rel, 'wsdp:Host') 1018 | self.add_endpoint_reference(host) 1019 | ElementTree.SubElement(host, 'wsdp:Types').text = PUB_COMPUTER 1020 | ElementTree.SubElement(host, 'wsdp:ServiceId').text = args.uuid.urn 1021 | 1022 | fmt = '{0}/Domain:{1}' if args.domain else '{0}/Workgroup:{1}' 1023 | value = args.domain if args.domain else args.workgroup.upper() 1024 | if args.domain: 1025 | dh = args.hostname if args.preserve_case else args.hostname.lower() 1026 | else: 1027 | dh = args.hostname if args.preserve_case else args.hostname.upper() 1028 | 1029 | ElementTree.SubElement(host, PUB_COMPUTER).text = fmt.format(dh, value) 1030 | 1031 | return metadata, WSD_GET_RESPONSE 1032 | 1033 | 1034 | class WSDHttpServer(http.server.HTTPServer): 1035 | """ HTTP server both with IPv6 support and WSD handling """ 1036 | 1037 | mch: MulticastHandler 1038 | aio_loop: asyncio.AbstractEventLoop 1039 | wsd_handler: WSDHttpMessageHandler 1040 | registered: bool 1041 | 1042 | def __init__(self, mch: MulticastHandler, aio_loop: asyncio.AbstractEventLoop): 1043 | # hacky way to convince HTTP/SocketServer of the address family 1044 | type(self).address_family = mch.address.family 1045 | 1046 | self.mch = mch 1047 | self.aio_loop = aio_loop 1048 | self.wsd_handler = WSDHttpMessageHandler() 1049 | self.registered = False 1050 | 1051 | # WSDHttpRequestHandler is a BaseHTTPRequestHandler. Passing to the parent constructor is therefore safe and 1052 | # we can ignore the type error reported by mypy 1053 | super().__init__(mch.listen_address, WSDHttpRequestHandler) # type: ignore 1054 | 1055 | def server_bind(self) -> None: 1056 | if self.mch.address.family == socket.AF_INET6: 1057 | self.socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1) 1058 | 1059 | super().server_bind() 1060 | 1061 | def server_activate(self) -> None: 1062 | super().server_activate() 1063 | self.aio_loop.add_reader(self.fileno(), self.handle_request) 1064 | self.registered = True 1065 | 1066 | def server_close(self) -> None: 1067 | if self.registered: 1068 | self.aio_loop.remove_reader(self.fileno()) 1069 | super().server_close() 1070 | 1071 | 1072 | class WSDHttpRequestHandler(http.server.BaseHTTPRequestHandler): 1073 | """Class for handling WSD requests coming over HTTP""" 1074 | 1075 | def log_message(self, fmt, *args) -> None: 1076 | logger.info("{} - - ".format(self.address_string()) + fmt % args) 1077 | 1078 | def do_POST(self) -> None: 1079 | if self.path != '/' + str(args.uuid): 1080 | self.send_error(http.HTTPStatus.NOT_FOUND) 1081 | 1082 | ct = self.headers['Content-Type'] 1083 | if ct is None or not ct.startswith(MIME_TYPE_SOAP_XML): 1084 | self.send_error(http.HTTPStatus.BAD_REQUEST, 'Invalid Content-Type') 1085 | 1086 | content_length = int(self.headers['Content-Length']) 1087 | body = self.rfile.read(content_length) 1088 | 1089 | response = self.server.wsd_handler.handle_message(body) # type: ignore 1090 | if response: 1091 | self.send_response(http.HTTPStatus.OK) 1092 | self.send_header('Content-Type', MIME_TYPE_SOAP_XML) 1093 | self.end_headers() 1094 | self.wfile.write(response.encode('utf-8')) 1095 | else: 1096 | self.send_error(http.HTTPStatus.BAD_REQUEST) 1097 | 1098 | 1099 | class ApiServer: 1100 | 1101 | address_monitor: 'NetworkAddressMonitor' 1102 | 1103 | def __init__(self, aio_loop: asyncio.AbstractEventLoop, listen_address: bytes, 1104 | address_monitor: 'NetworkAddressMonitor') -> None: 1105 | self.server = None 1106 | self.address_monitor = address_monitor 1107 | 1108 | # defer server creation 1109 | self.create_task = aio_loop.create_task(self.create_server(aio_loop, listen_address)) 1110 | 1111 | async def create_server(self, aio_loop: asyncio.AbstractEventLoop, listen_address: Any) -> None: 1112 | 1113 | # It appears mypy is not able to check the argument to create_task and the return value of start_server 1114 | # correctly. The docs say start_server returns a coroutine and the create_task takes a coro. And: It works. 1115 | # Thus, we ignore type errors here. 1116 | if isinstance(listen_address, int) or listen_address.isnumeric(): 1117 | self.server = await aio_loop.create_task(asyncio.start_server( # type: ignore 1118 | self.on_connect, host='localhost', port=int(listen_address), loop=aio_loop, reuse_address=True, 1119 | reuse_port=True)) 1120 | else: 1121 | self.server = await aio_loop.create_task(asyncio.start_unix_server( # type: ignore 1122 | self.on_connect, path=listen_address, loop=aio_loop)) 1123 | 1124 | async def on_connect(self, read_stream: asyncio.StreamReader, write_stream: asyncio.StreamWriter) -> None: 1125 | while True: 1126 | try: 1127 | line = await read_stream.readline() 1128 | if line: 1129 | self.handle_command(str(line.strip(), 'utf-8'), write_stream) 1130 | if not write_stream.is_closing(): 1131 | await write_stream.drain() 1132 | else: 1133 | write_stream.close() 1134 | return 1135 | except UnicodeDecodeError as e: 1136 | logger.debug('invalid input utf8', e) 1137 | except Exception as e: 1138 | logger.warning('exception in API client', e) 1139 | write_stream.close() 1140 | return 1141 | 1142 | def handle_command(self, line: str, write_stream: asyncio.StreamWriter) -> None: 1143 | words = line.split() 1144 | if len(words) == 0: 1145 | return 1146 | 1147 | command = words[0] 1148 | command_args = words[1:] 1149 | if command == 'probe' and args.discovery: 1150 | intf = command_args[0] if command_args else None 1151 | logger.debug('probing devices on {} upon request'.format(intf)) 1152 | for client in self.get_clients_by_interface(intf): 1153 | client.send_probe() 1154 | elif command == 'clear' and args.discovery: 1155 | logger.debug('clearing list of known devices') 1156 | WSDDiscoveredDevice.instances.clear() 1157 | elif command == 'list' and args.discovery: 1158 | write_stream.write(bytes(self.get_list_reply(), 'utf-8')) 1159 | elif command == 'quit': 1160 | write_stream.close() 1161 | elif command == 'start': 1162 | self.address_monitor.enumerate() 1163 | elif command == 'stop': 1164 | self.address_monitor.teardown() 1165 | else: 1166 | logger.debug('could not handle API request: {}'.format(line)) 1167 | 1168 | def get_clients_by_interface(self, interface: Optional[str]) -> List[WSDClient]: 1169 | return [c for c in WSDClient.instances if c.mch.address.interface.name == interface or not interface] 1170 | 1171 | def get_list_reply(self) -> str: 1172 | retval = '' 1173 | for dev_uuid, dev in WSDDiscoveredDevice.instances.items(): 1174 | addrs_str = [] 1175 | for addrs in dev.addresses.items(): 1176 | addrs_str.append(', '.join(['{}'.format(a) for a in addrs])) 1177 | 1178 | retval = retval + '{}\t{}\t{}\t{}\t{}\n'.format( 1179 | dev_uuid, 1180 | dev.display_name, 1181 | dev.props['BelongsTo'] if 'BelongsTo' in dev.props else '', 1182 | datetime.datetime.fromtimestamp(dev.last_seen).isoformat('T', 'seconds'), 1183 | ','.join(addrs_str)) 1184 | 1185 | retval += '.\n' 1186 | return retval 1187 | 1188 | async def cleanup(self) -> None: 1189 | # ensure the server is not created after we have teared down 1190 | await self.create_task 1191 | if self.server: 1192 | self.server.close() 1193 | await self.server.wait_closed() 1194 | 1195 | 1196 | class MetaEnumAfterInit(type): 1197 | 1198 | def __call__(cls, *cargs, **kwargs): 1199 | obj = super().__call__(*cargs, **kwargs) 1200 | if not args.no_autostart: 1201 | obj.enumerate() 1202 | return obj 1203 | 1204 | 1205 | class NetworkAddressMonitor(metaclass=MetaEnumAfterInit): 1206 | """ 1207 | Observes changes of network addresses, handles addition and removal of 1208 | network addresses, and filters for addresses/interfaces that are or are not 1209 | handled. The actual OS-specific implementation that detects the changes is 1210 | done in subclasses. This class is used as a singleton 1211 | """ 1212 | 1213 | instance: ClassVar[object] = None 1214 | 1215 | interfaces: Dict[int, NetworkInterface] 1216 | aio_loop: asyncio.AbstractEventLoop 1217 | mchs: List[MulticastHandler] 1218 | http_servers: List[WSDHttpServer] 1219 | teardown_tasks: List[asyncio.Task] 1220 | active: bool 1221 | 1222 | def __init__(self, aio_loop: asyncio.AbstractEventLoop) -> None: 1223 | 1224 | if NetworkAddressMonitor.instance is not None: 1225 | raise RuntimeError('Instance of NetworkAddressMonitor already created') 1226 | 1227 | NetworkAddressMonitor.instance = self 1228 | 1229 | self.interfaces = {} 1230 | self.aio_loop = aio_loop 1231 | 1232 | self.mchs = [] 1233 | self.http_servers = [] 1234 | self.teardown_tasks = [] 1235 | 1236 | self.active = False 1237 | 1238 | def enumerate(self) -> None: 1239 | """ 1240 | Performs an initial enumeration of addresses and sets up everything 1241 | for observing future changes. 1242 | """ 1243 | if self.active: 1244 | return 1245 | 1246 | self.active = True 1247 | self.do_enumerate() 1248 | 1249 | def do_enumerate(self) -> None: 1250 | pass 1251 | 1252 | def handle_change(self) -> None: 1253 | """ handle network change message """ 1254 | pass 1255 | 1256 | def add_interface(self, interface: NetworkInterface) -> NetworkInterface: 1257 | # TODO: Cleanup 1258 | if interface.index in self.interfaces: 1259 | pass 1260 | # self.interfaces[idx].name = name 1261 | else: 1262 | self.interfaces[interface.index] = interface 1263 | 1264 | return self.interfaces[interface.index] 1265 | 1266 | def is_address_handled(self, address: NetworkAddress) -> bool: 1267 | # do not handle anything when we are not active 1268 | if not self.active: 1269 | return False 1270 | 1271 | # filter out address families we are not interested in 1272 | if args.ipv4only and address.family != socket.AF_INET: 1273 | return False 1274 | if args.ipv6only and address.family != socket.AF_INET6: 1275 | return False 1276 | 1277 | if address.is_multicastable: 1278 | return False 1279 | 1280 | # Use interface only if it's in the list of user-provided interface names 1281 | if ((args.interface) and (address.interface.name not in args.interface) 1282 | and (address.address_str not in args.interface)): 1283 | return False 1284 | 1285 | return True 1286 | 1287 | def handle_new_address(self, address: NetworkAddress) -> None: 1288 | logger.debug('new address {}'.format(address)) 1289 | 1290 | if not self.is_address_handled(address): 1291 | logger.debug('ignoring that address on {}'.format(address.interface)) 1292 | return 1293 | 1294 | # filter out what is not wanted 1295 | # Ignore addresses or interfaces we already handle. There can only be 1296 | # one multicast handler per address family and network interface 1297 | for mch in self.mchs: 1298 | if mch.handles_address(address): 1299 | return 1300 | 1301 | logger.debug('handling traffic for {}'.format(address)) 1302 | mch = MulticastHandler(address, self.aio_loop) 1303 | self.mchs.append(mch) 1304 | 1305 | if not args.no_host: 1306 | WSDHost(mch) 1307 | if not args.no_http: 1308 | self.http_servers.append(WSDHttpServer(mch, self.aio_loop)) 1309 | 1310 | if args.discovery: 1311 | WSDClient(mch) 1312 | 1313 | def handle_deleted_address(self, address: NetworkAddress) -> None: 1314 | logger.info('deleted address {}'.format(address)) 1315 | 1316 | if not self.is_address_handled(address): 1317 | return 1318 | 1319 | mch: Optional[MulticastHandler] = self.get_mch_by_address(address) 1320 | if mch is None: 1321 | return 1322 | 1323 | # Do not tear the client/hosts down. Saying goodbye does not work 1324 | # because the address is already gone (at least on Linux). 1325 | for c in WSDClient.instances: 1326 | if c.mch == mch: 1327 | c.cleanup() 1328 | break 1329 | for h in WSDHost.instances: 1330 | if h.mch == mch: 1331 | h.cleanup() 1332 | break 1333 | for s in self.http_servers: 1334 | if s.mch == mch: 1335 | s.server_close() 1336 | self.http_servers.remove(s) 1337 | 1338 | mch.cleanup() 1339 | self.mchs.remove(mch) 1340 | 1341 | def teardown(self) -> None: 1342 | if not self.active: 1343 | return 1344 | 1345 | self.active = False 1346 | 1347 | # return if we are still in tear down process 1348 | if len(self.teardown_tasks) > 0: 1349 | return 1350 | 1351 | for h in WSDHost.instances: 1352 | h.teardown() 1353 | h.cleanup() 1354 | self.teardown_tasks.extend(h.pending_tasks) 1355 | 1356 | for c in WSDClient.instances: 1357 | c.teardown() 1358 | c.cleanup() 1359 | self.teardown_tasks.extend(c.pending_tasks) 1360 | 1361 | for s in self.http_servers: 1362 | s.server_close() 1363 | 1364 | self.http_servers.clear() 1365 | 1366 | if not self.aio_loop.is_running(): 1367 | # Wait here for all pending tasks so that the main loop can be finished on termination. 1368 | self.aio_loop.run_until_complete(asyncio.gather(*self.teardown_tasks)) 1369 | else: 1370 | for t in self.teardown_tasks: 1371 | t.add_done_callback(self.mch_teardown) 1372 | 1373 | def mch_teardown(self, task) -> None: 1374 | if any([not t.done() for t in self.teardown_tasks]): 1375 | return 1376 | 1377 | self.teardown_tasks.clear() 1378 | 1379 | for mch in self.mchs: 1380 | mch.cleanup() 1381 | self.mchs.clear() 1382 | 1383 | def cleanup(self) -> None: 1384 | self.teardown() 1385 | 1386 | def get_mch_by_address(self, address: NetworkAddress) -> Optional[MulticastHandler]: 1387 | """ 1388 | Get the MCI for the address, its family and the interface. 1389 | adress must be given as a string. 1390 | """ 1391 | for retval in self.mchs: 1392 | if retval.handles_address(address): 1393 | return retval 1394 | 1395 | return None 1396 | 1397 | 1398 | # from rtnetlink.h 1399 | RTMGRP_LINK: int = 1 1400 | RTMGRP_IPV4_IFADDR: int = 0x10 1401 | RTMGRP_IPV6_IFADDR: int = 0x100 1402 | 1403 | # from netlink.h 1404 | NLM_HDR_LEN: int = 16 1405 | 1406 | NLM_F_REQUEST: int = 0x01 1407 | NLM_F_ROOT: int = 0x100 1408 | NLM_F_MATCH: int = 0x200 1409 | NLM_F_DUMP: int = NLM_F_ROOT | NLM_F_MATCH 1410 | 1411 | # self defines 1412 | NLM_HDR_ALIGNTO: int = 4 1413 | 1414 | # ifa flags 1415 | IFA_F_DADFAILED: int = 0x08 1416 | IFA_F_HOMEADDRESS: int = 0x10 1417 | IFA_F_DEPRECATED: int = 0x20 1418 | IFA_F_TENTATIVE: int = 0x40 1419 | 1420 | # from if_addr.h 1421 | IFA_ADDRESS: int = 1 1422 | IFA_LOCAL: int = 2 1423 | IFA_LABEL: int = 3 1424 | IFA_FLAGS: int = 8 1425 | IFA_MSG_LEN: int = 8 1426 | 1427 | RTA_ALIGNTO: int = 4 1428 | RTA_LEN: int = 4 1429 | 1430 | 1431 | def align_to(x: int, n: int) -> int: 1432 | return ((x + n - 1) // n) * n 1433 | 1434 | 1435 | class NetlinkAddressMonitor(NetworkAddressMonitor): 1436 | """ 1437 | Implementation of the AddressMonitor for Netlink sockets, i.e. Linux 1438 | """ 1439 | 1440 | RTM_NEWADDR: int = 20 1441 | RTM_DELADDR: int = 21 1442 | RTM_GETADDR: int = 22 1443 | 1444 | socket: socket.socket 1445 | 1446 | def __init__(self, aio_loop: asyncio.AbstractEventLoop) -> None: 1447 | super().__init__(aio_loop) 1448 | 1449 | rtm_groups = RTMGRP_LINK 1450 | if not args.ipv4only: 1451 | rtm_groups = rtm_groups | RTMGRP_IPV6_IFADDR 1452 | if not args.ipv6only: 1453 | rtm_groups = rtm_groups | RTMGRP_IPV4_IFADDR 1454 | 1455 | self.socket = socket.socket(socket.AF_NETLINK, socket.SOCK_RAW, socket.NETLINK_ROUTE) 1456 | self.socket.bind((0, rtm_groups)) 1457 | self.aio_loop.add_reader(self.socket.fileno(), self.handle_change) 1458 | 1459 | def do_enumerate(self) -> None: 1460 | super().do_enumerate() 1461 | 1462 | kernel = (0, 0) 1463 | req = struct.pack('@IHHIIB', NLM_HDR_LEN + 1, self.RTM_GETADDR, 1464 | NLM_F_REQUEST | NLM_F_DUMP, 1, 0, socket.AF_PACKET) 1465 | self.socket.sendto(req, kernel) 1466 | 1467 | def handle_change(self) -> None: 1468 | super().handle_change() 1469 | 1470 | buf, src = self.socket.recvfrom(4096) 1471 | logger.debug('netlink message with {} bytes'.format(len(buf))) 1472 | 1473 | offset = 0 1474 | while offset < len(buf): 1475 | h_len, h_type, _, _, _ = struct.unpack_from('@IHHII', buf, offset) 1476 | offset += NLM_HDR_LEN 1477 | 1478 | msg_len = h_len - NLM_HDR_LEN 1479 | if msg_len < 0: 1480 | break 1481 | 1482 | if h_type != self.RTM_NEWADDR and h_type != self.RTM_DELADDR: 1483 | logger.debug('invalid rtm_message type {}'.format(h_type)) 1484 | offset += align_to(msg_len, NLM_HDR_ALIGNTO) 1485 | continue 1486 | 1487 | # decode ifaddrmsg as in rtnetlink.h 1488 | ifa_family, _, ifa_flags, ifa_scope, ifa_idx = struct.unpack_from('@BBBBI', buf, offset) 1489 | if ((ifa_flags & IFA_F_DADFAILED) or (ifa_flags & IFA_F_HOMEADDRESS) 1490 | or (ifa_flags & IFA_F_DEPRECATED) or (ifa_flags & IFA_F_TENTATIVE)): 1491 | logger.debug('ignore address with invalid state {}'.format(hex(ifa_flags))) 1492 | offset += align_to(msg_len, NLM_HDR_ALIGNTO) 1493 | continue 1494 | 1495 | logger.debug('RTM new/del addr family: {} flags: {} scope: {} idx: {}'.format( 1496 | ifa_family, ifa_flags, ifa_scope, ifa_idx)) 1497 | addr = None 1498 | i = offset + IFA_MSG_LEN 1499 | while i - offset < msg_len: 1500 | attr_len, attr_type = struct.unpack_from('HH', buf, i) 1501 | logger.debug('rt_attr {} {}'.format(attr_len, attr_type)) 1502 | 1503 | if attr_len < RTA_LEN: 1504 | logger.debug('invalid rtm_attr_len. skipping remainder') 1505 | break 1506 | 1507 | if attr_type == IFA_LABEL: 1508 | name, = struct.unpack_from(str(attr_len - 4 - 1) + 's', buf, i + 4) 1509 | self.add_interface(NetworkInterface(name.decode(), ifa_scope, ifa_idx)) 1510 | elif attr_type == IFA_LOCAL and ifa_family == socket.AF_INET: 1511 | addr = buf[i + 4:i + 4 + 4] 1512 | elif attr_type == IFA_ADDRESS and ifa_family == socket.AF_INET6: 1513 | addr = buf[i + 4:i + 4 + 16] 1514 | elif attr_type == IFA_FLAGS: 1515 | _, ifa_flags = struct.unpack_from('HI', buf, i) 1516 | i += align_to(attr_len, RTA_ALIGNTO) 1517 | 1518 | if addr is None: 1519 | logger.debug('no address in RTM message') 1520 | offset += align_to(msg_len, NLM_HDR_ALIGNTO) 1521 | continue 1522 | 1523 | # In case of IPv6 only addresses, there appears to be no IFA_LABEL 1524 | # message. Therefore, the name is requested by other means (#94) 1525 | if ifa_idx not in self.interfaces: 1526 | try: 1527 | logger.debug('unknown interface name for idx {}. resolving manually'.format(ifa_idx)) 1528 | if_name = socket.if_indextoname(ifa_idx) 1529 | self.add_interface(NetworkInterface(if_name, ifa_scope, ifa_idx)) 1530 | except OSError: 1531 | logger.exception('interface detection failed') 1532 | # accept this exception (which should not occur) 1533 | pass 1534 | 1535 | # In case really strange things happen and we could not find out the 1536 | # interface name for the returned ifa_idx, we... log a message. 1537 | if ifa_idx in self.interfaces: 1538 | address = NetworkAddress(ifa_family, addr, self.interfaces[ifa_idx]) 1539 | if h_type == self.RTM_NEWADDR: 1540 | self.handle_new_address(address) 1541 | elif h_type == self.RTM_DELADDR: 1542 | self.handle_deleted_address(address) 1543 | else: 1544 | logger.debug('unknown interface index: {}'.format(ifa_idx)) 1545 | 1546 | offset += align_to(msg_len, NLM_HDR_ALIGNTO) 1547 | 1548 | def cleanup(self) -> None: 1549 | self.aio_loop.remove_reader(self.socket.fileno()) 1550 | self.socket.close() 1551 | super().cleanup() 1552 | 1553 | 1554 | # from sys/net/route.h 1555 | RTA_IFA: int = 0x20 1556 | 1557 | # from sys/socket.h 1558 | CTL_NET: int = 4 1559 | NET_RT_IFLIST: int = 3 1560 | 1561 | # from sys/net/if.h 1562 | IFF_LOOPBACK: int = 0x8 1563 | IFF_MULTICAST: int = 0x800 1564 | 1565 | # sys/netinet6/in6_var.h 1566 | IN6_IFF_TENTATIVE: int = 0x02 1567 | IN6_IFF_DUPLICATED: int = 0x04 1568 | IN6_IFF_NOTREADY: int = IN6_IFF_TENTATIVE | IN6_IFF_DUPLICATED 1569 | 1570 | SA_ALIGNTO: int = ctypes.sizeof(ctypes.c_long) 1571 | 1572 | 1573 | class RouteSocketAddressMonitor(NetworkAddressMonitor): 1574 | """ 1575 | Implementation of the AddressMonitor for FreeBSD using route sockets 1576 | """ 1577 | 1578 | # from sys/net/route.h 1579 | RTM_NEWADDR: int = 0xC 1580 | RTM_DELADDR: int = 0xD 1581 | RTM_IFINFO: int = 0xE 1582 | 1583 | socket: socket.socket 1584 | intf_blacklist: List[str] 1585 | 1586 | def __init__(self, aio_loop: asyncio.AbstractEventLoop) -> None: 1587 | super().__init__(aio_loop) 1588 | self.intf_blacklist = [] 1589 | 1590 | # Create routing socket to get notified about future changes. 1591 | # Do this before fetching the current routing information to avoid 1592 | # race condition. 1593 | self.socket = socket.socket(socket.AF_ROUTE, socket.SOCK_RAW, socket.AF_UNSPEC) 1594 | self.aio_loop.add_reader(self.socket.fileno(), self.handle_change) 1595 | 1596 | def do_enumerate(self) -> None: 1597 | super().do_enumerate() 1598 | mib = [CTL_NET, socket.AF_ROUTE, 0, 0, NET_RT_IFLIST, 0] 1599 | rt_mib = (ctypes.c_int * len(mib))() 1600 | rt_mib[:] = [ctypes.c_int(m) for m in mib] 1601 | 1602 | libc = ctypes.CDLL(ctypes.util.find_library('c'), use_errno=True) 1603 | 1604 | # Ask kernel for routing table size first. 1605 | rt_size = ctypes.c_size_t() 1606 | if libc.sysctl(ctypes.byref(rt_mib), ctypes.c_size_t(len(rt_mib)), 0, ctypes.byref(rt_size), 0, 0): 1607 | raise OSError(ctypes.get_errno(), os.strerror(ctypes.get_errno())) 1608 | 1609 | # Get the initial routing (interface list) data. 1610 | rt_buf = ctypes.create_string_buffer(rt_size.value) 1611 | if libc.sysctl(ctypes.byref(rt_mib), ctypes.c_size_t(len(rt_mib)), rt_buf, ctypes.byref(rt_size), 0, 0): 1612 | raise OSError(ctypes.get_errno(), os.strerror(ctypes.get_errno())) 1613 | 1614 | self.parse_route_socket_response(rt_buf.raw, True) 1615 | 1616 | def handle_change(self) -> None: 1617 | super().handle_change() 1618 | 1619 | self.parse_route_socket_response(self.socket.recv(4096), False) 1620 | 1621 | def parse_route_socket_response(self, buf: bytes, keep_intf: bool) -> None: 1622 | offset = 0 1623 | 1624 | intf = None 1625 | intf_flags = 0 1626 | while offset < len(buf): 1627 | rtm_len, _, rtm_type = struct.unpack_from('@HBB', buf, offset) 1628 | # addr_mask has same offset in if_msghdr and ifs_msghdr 1629 | addr_mask, flags = struct.unpack_from('ii', buf, offset + 4) 1630 | 1631 | msg_types = [self.RTM_NEWADDR, self.RTM_DELADDR, self.RTM_IFINFO] 1632 | if rtm_type not in msg_types: 1633 | offset += rtm_len 1634 | continue 1635 | 1636 | if rtm_type == self.RTM_IFINFO: 1637 | intf_flags = flags 1638 | 1639 | # those offset may unfortunately be architecture dependent 1640 | sa_offset = offset + ((16 + 152) if rtm_type == self.RTM_IFINFO else 20) 1641 | 1642 | # For a route socket message, and different to a sysctl response, 1643 | # the link info is stored inside the same rtm message, so it has to 1644 | # survive multiple rtm messages in such cases 1645 | if not keep_intf: 1646 | intf = None 1647 | 1648 | new_intf = self.parse_addrs(buf, sa_offset, offset + rtm_len, intf, addr_mask, rtm_type, intf_flags) 1649 | intf = new_intf if new_intf else intf 1650 | 1651 | offset += rtm_len 1652 | 1653 | def parse_addrs(self, buf: bytes, offset: int, limit: int, intf: Optional[NetworkInterface], addr_mask: int, 1654 | rtm_type: int, flags: int) -> Optional[NetworkInterface]: 1655 | addr_type_idx = 1 1656 | addr = None 1657 | addr_family: int = socket.AF_UNSPEC 1658 | while offset < limit: 1659 | while not (addr_type_idx & addr_mask) and (addr_type_idx <= addr_mask): 1660 | addr_type_idx = addr_type_idx << 1 1661 | 1662 | sa_len, sa_fam = struct.unpack_from('@BB', buf, offset) 1663 | if sa_fam in [socket.AF_INET, socket.AF_INET6] and addr_type_idx == RTA_IFA: 1664 | addr_family = sa_fam 1665 | addr_offset = 4 if sa_fam == socket.AF_INET else 8 1666 | addr_length = 16 if sa_fam == socket.AF_INET6 else 4 1667 | addr_start = offset + addr_offset 1668 | addr = buf[addr_start:addr_start + addr_length] 1669 | elif sa_fam == socket.AF_LINK: 1670 | idx, _, name_len = struct.unpack_from('@HBB', buf, offset + 2) 1671 | if idx > 0: 1672 | off_name = offset + 8 1673 | if_name = (buf[off_name:off_name + name_len]).decode() 1674 | intf = self.add_interface(NetworkInterface(if_name, idx, idx)) 1675 | 1676 | offset += align_to(sa_len, SA_ALIGNTO) if sa_len > 0 else SA_ALIGNTO 1677 | addr_type_idx = addr_type_idx << 1 1678 | 1679 | if rtm_type == self.RTM_IFINFO and intf is not None: 1680 | if flags & IFF_LOOPBACK or not flags & IFF_MULTICAST: 1681 | self.intf_blacklist.append(intf.name) 1682 | elif intf in self.intf_blacklist: 1683 | self.intf_blacklist.remove(intf.name) 1684 | 1685 | if intf is None or intf.name in self.intf_blacklist or addr is None: 1686 | return intf 1687 | 1688 | address = NetworkAddress(addr_family, addr, intf) 1689 | if rtm_type == self.RTM_DELADDR: 1690 | self.handle_deleted_address(address) 1691 | else: 1692 | # Too bad, the address may be unuseable (tentative, e.g.) here 1693 | # but we won't get any further notifcation about the address being 1694 | # available for use. Thus, we try and may fail here 1695 | self.handle_new_address(address) 1696 | 1697 | return intf 1698 | 1699 | def cleanup(self) -> None: 1700 | self.aio_loop.remove_reader(self.socket.fileno()) 1701 | self.socket.close() 1702 | super().cleanup() 1703 | 1704 | 1705 | def sigterm_handler() -> None: 1706 | logger.info('received termination/interrupt signal, tearing down') 1707 | # implictely raise SystemExit to cleanup properly 1708 | sys.exit(0) 1709 | 1710 | 1711 | def parse_args() -> None: 1712 | global args, logger 1713 | 1714 | parser = argparse.ArgumentParser() 1715 | 1716 | parser.add_argument( 1717 | '-i', '--interface', 1718 | help='interface or address to use', 1719 | action='append', default=[]) 1720 | parser.add_argument( 1721 | '-H', '--hoplimit', 1722 | help='hop limit for multicast packets (default = 1)', type=int, 1723 | default=1) 1724 | parser.add_argument( 1725 | '-U', '--uuid', 1726 | help='UUID for the target device', 1727 | default=None) 1728 | parser.add_argument( 1729 | '-v', '--verbose', 1730 | help='increase verbosity', 1731 | action='count', default=0) 1732 | parser.add_argument( 1733 | '-d', '--domain', 1734 | help='set domain name (disables workgroup)', 1735 | default=None) 1736 | parser.add_argument( 1737 | '-n', '--hostname', 1738 | help='override (NetBIOS) hostname to be used (default hostname)', 1739 | # use only the local part of a possible FQDN 1740 | default=socket.gethostname().partition('.')[0]) 1741 | parser.add_argument( 1742 | '-w', '--workgroup', 1743 | help='set workgroup name (default WORKGROUP)', 1744 | default='WORKGROUP') 1745 | parser.add_argument( 1746 | '-A', '--no-autostart', 1747 | help='do not start networking after launch', 1748 | action='store_true') 1749 | parser.add_argument( 1750 | '-t', '--no-http', 1751 | help='disable http service (for debugging, e.g.)', 1752 | action='store_true') 1753 | parser.add_argument( 1754 | '-4', '--ipv4only', 1755 | help='use only IPv4 (default = off)', 1756 | action='store_true') 1757 | parser.add_argument( 1758 | '-6', '--ipv6only', 1759 | help='use IPv6 (default = off)', 1760 | action='store_true') 1761 | parser.add_argument( 1762 | '-s', '--shortlog', 1763 | help='log only level and message', 1764 | action='store_true') 1765 | parser.add_argument( 1766 | '-p', '--preserve-case', 1767 | help='preserve case of the provided/detected hostname', 1768 | action='store_true') 1769 | parser.add_argument( 1770 | '-c', '--chroot', 1771 | help='directory to chroot into', 1772 | default=None) 1773 | parser.add_argument( 1774 | '-u', '--user', 1775 | help='drop privileges to user:group', 1776 | default=None) 1777 | parser.add_argument( 1778 | '-D', '--discovery', 1779 | help='enable discovery operation mode', 1780 | action='store_true') 1781 | parser.add_argument( 1782 | '-l', '--listen', 1783 | help='listen on path or localhost port in discovery mode', 1784 | default=None) 1785 | parser.add_argument( 1786 | '-o', '--no-host', 1787 | help='disable server mode operation (host will be undiscoverable)', 1788 | action='store_true') 1789 | parser.add_argument( 1790 | '-V', '--version', 1791 | help='show version number and exit', 1792 | action='store_true') 1793 | 1794 | args = parser.parse_args(sys.argv[1:]) 1795 | 1796 | if args.version: 1797 | print('wsdd - Web Service Discovery Daemon, v{}'.format(WSDD_VERSION)) 1798 | sys.exit(0) 1799 | 1800 | if args.verbose == 1: 1801 | log_level = logging.INFO 1802 | elif args.verbose > 1: 1803 | log_level = logging.DEBUG 1804 | asyncio.get_event_loop().set_debug(True) 1805 | logging.getLogger("asyncio").setLevel(logging.DEBUG) 1806 | else: 1807 | log_level = logging.WARNING 1808 | 1809 | if args.shortlog: 1810 | fmt = '%(levelname)s: %(message)s' 1811 | else: 1812 | fmt = '%(asctime)s:%(name)s %(levelname)s(pid %(process)d): %(message)s' 1813 | 1814 | logging.basicConfig(level=log_level, format=fmt) 1815 | logger = logging.getLogger('wsdd') 1816 | 1817 | if not args.interface: 1818 | logger.warning('no interface given, using all interfaces') 1819 | 1820 | if not args.uuid: 1821 | args.uuid = uuid.uuid5(uuid.NAMESPACE_DNS, socket.gethostname()) 1822 | logger.info('using pre-defined UUID {0}'.format(str(args.uuid))) 1823 | else: 1824 | args.uuid = uuid.UUID(args.uuid) 1825 | logger.info('user-supplied device UUID is {0}'.format(str(args.uuid))) 1826 | 1827 | for prefix, uri in namespaces.items(): 1828 | ElementTree.register_namespace(prefix, uri) 1829 | 1830 | 1831 | def chroot(root: str) -> bool: 1832 | """ 1833 | Chroot into a separate directory to isolate ourself for increased security. 1834 | """ 1835 | # preload for socket.gethostbyaddr() 1836 | import encodings.idna 1837 | 1838 | try: 1839 | os.chroot(root) 1840 | os.chdir('/') 1841 | logger.info('chrooted successfully to {}'.format(root)) 1842 | except Exception as e: 1843 | logger.error('could not chroot to {}: {}'.format(root, e)) 1844 | return False 1845 | 1846 | return True 1847 | 1848 | 1849 | def get_ids_from_userspec(user_spec: str) -> Tuple[int, int]: 1850 | uid: int 1851 | gid: int 1852 | try: 1853 | user, _, group = user_spec.partition(':') 1854 | 1855 | if user: 1856 | uid = pwd.getpwnam(user).pw_uid 1857 | 1858 | if group: 1859 | gid = grp.getgrnam(group).gr_gid 1860 | except Exception as e: 1861 | raise RuntimeError('could not get uid/gid for {}: {}'.format(user_spec, e)) 1862 | 1863 | return (uid, gid) 1864 | 1865 | 1866 | def drop_privileges(uid: int, gid: int) -> bool: 1867 | try: 1868 | if gid is not None: 1869 | os.setgid(gid) 1870 | os.setegid(gid) 1871 | logger.debug('switched uid to {}'.format(uid)) 1872 | 1873 | if uid is not None: 1874 | os.setuid(uid) 1875 | os.seteuid(uid) 1876 | logger.debug('switched gid to {}'.format(gid)) 1877 | 1878 | logger.info('running as {} ({}:{})'.format(args.user, uid, gid)) 1879 | except Exception as e: 1880 | logger.error('dropping privileges failed: {}'.format(e)) 1881 | return False 1882 | 1883 | return True 1884 | 1885 | 1886 | def create_address_monitor(system: str, aio_loop: asyncio.AbstractEventLoop) -> NetworkAddressMonitor: 1887 | if system == 'Linux': 1888 | return NetlinkAddressMonitor(aio_loop) 1889 | elif system == 'FreeBSD': 1890 | return RouteSocketAddressMonitor(aio_loop) 1891 | else: 1892 | raise NotImplementedError('unsupported OS') 1893 | 1894 | 1895 | def main() -> int: 1896 | global logger, args 1897 | 1898 | parse_args() 1899 | 1900 | if args.ipv4only and args.ipv6only: 1901 | logger.error('Listening to no IP address family.') 1902 | return 4 1903 | 1904 | aio_loop = asyncio.new_event_loop() 1905 | nm = create_address_monitor(platform.system(), aio_loop) 1906 | 1907 | api_server = None 1908 | if args.listen: 1909 | api_server = ApiServer(aio_loop, args.listen, nm) 1910 | 1911 | # get uid:gid before potential chroot'ing 1912 | if args.user is not None: 1913 | ids = get_ids_from_userspec(args.user) 1914 | if not ids: 1915 | return 3 1916 | 1917 | if args.chroot is not None: 1918 | if not chroot(args.chroot): 1919 | return 2 1920 | 1921 | if args.user is not None: 1922 | if not drop_privileges(ids[0], ids[1]): 1923 | return 3 1924 | 1925 | if args.chroot and (os.getuid() == 0 or os.getgid() == 0): 1926 | logger.warning('chrooted but running as root, consider -u option') 1927 | 1928 | # main loop, serve requests coming from any outbound socket 1929 | aio_loop.add_signal_handler(signal.SIGINT, sigterm_handler) 1930 | aio_loop.add_signal_handler(signal.SIGTERM, sigterm_handler) 1931 | try: 1932 | aio_loop.run_forever() 1933 | except (SystemExit, KeyboardInterrupt): 1934 | logger.info('shutting down gracefully...') 1935 | if api_server is not None: 1936 | aio_loop.run_until_complete(api_server.cleanup()) 1937 | 1938 | nm.cleanup() 1939 | aio_loop.stop() 1940 | except Exception: 1941 | logger.exception('error in main loop') 1942 | 1943 | logger.info('Done.') 1944 | return 0 1945 | 1946 | 1947 | if __name__ == '__main__': 1948 | sys.exit(main()) 1949 | -------------------------------------------------------------------------------- /test/linting/mypy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | root_dir="$(realpath $(dirname $0)/../..)" 4 | 5 | for version in 3.7 3.8 3.9 3.10; do 6 | echo -n "checking for Python ${version}..." 7 | mypy --python-version=${version} ${root_dir}/src/wsdd.py 8 | echo 9 | done 10 | -------------------------------------------------------------------------------- /test/netlink_monitor.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | # Not really a test case, but a PoC for getting notified about changes in 4 | # network addreses on Linux using netlink sockets. 5 | 6 | import socket 7 | import struct 8 | 9 | # from rtnetlink.h 10 | RTMGRP_LINK = 1 11 | RTMGRP_IPV4_IFADDR = 0x10 12 | RTMGRP_IPV6_IFADDR = 0x100 13 | 14 | RTM_NEWADDR = 20 15 | RTM_DELADDR = 21 16 | RTM_GETADDR = 22 17 | 18 | # from netlink.h 19 | NLM_F_REQUEST = 0x01 20 | 21 | NLM_F_ROOT = 0x100 22 | NLM_F_MATCH = 0x200 23 | NLM_F_DUMP = NLM_F_ROOT | NLM_F_MATCH 24 | 25 | # from if_addr.h 26 | IFA_ADDRESS = 1 27 | IFA_LOCAL = 2 28 | IFA_LABEL = 3 29 | IFA_FLAGS = 8 30 | 31 | # self_defines 32 | 33 | NLM_HDR_LEN = 16 34 | NLM_HDR_ALIGNTO = 4 35 | 36 | IFA_MSG_LEN = 8 37 | 38 | # hardcoded as 4 in rtnetlink.h 39 | RTA_ALIGNTO = 4 40 | 41 | 42 | def align_to(x, n): 43 | return ((x + n - 1) // n) * n 44 | 45 | 46 | s = socket.socket(socket.AF_NETLINK, socket.SOCK_RAW, socket.NETLINK_ROUTE) 47 | s.bind((0, RTMGRP_LINK | RTMGRP_IPV4_IFADDR | RTMGRP_IPV6_IFADDR)) 48 | 49 | kernel = (0, 0) 50 | req = struct.pack('@IHHIIB', NLM_HDR_LEN + 1, RTM_GETADDR, 51 | NLM_F_REQUEST | NLM_F_DUMP, 1, 0, socket.AF_PACKET) 52 | 53 | s.sendto(req, kernel) 54 | 55 | while True: 56 | buf, src = s.recvfrom(4096) 57 | 58 | offset = 0 59 | while offset < len(buf): 60 | (h_len, h_type, h_flags, _, _) = struct.unpack_from( 61 | '@IHHII', buf, offset) 62 | 63 | msg_len = h_len - NLM_HDR_LEN 64 | if msg_len < 0: 65 | # print('invalid message size') 66 | break 67 | 68 | if h_type != RTM_NEWADDR and h_type != RTM_DELADDR: 69 | offset += align_to(msg_len, NLM_HDR_ALIGNTO) 70 | # print('not interested in message type ', h_type) 71 | # print('new offset: ', offset) 72 | continue 73 | 74 | offset += NLM_HDR_LEN 75 | # decode ifaddrmsg as in rtnetlink.h 76 | ifa_family, _, ifa_flags, ifa_scope, ifa_idx = struct.unpack_from( 77 | '@BBBBI', buf, offset) 78 | 79 | ifa_name = '' 80 | addr = '' 81 | # look for some details in attributes 82 | i = offset + IFA_MSG_LEN 83 | while i - offset < msg_len: 84 | attr_len, attr_type = struct.unpack_from('HH', buf, i) 85 | if attr_type == IFA_LABEL: 86 | ifa_name, = struct.unpack_from(str(attr_len - 4 - 1) + 's', 87 | buf, i + 4) 88 | elif attr_type == IFA_LOCAL and ifa_family == socket.AF_INET: 89 | b = buf[i + 4:i + 4 + 4] 90 | addr = socket.inet_ntop(socket.AF_INET, b) 91 | elif attr_type == IFA_ADDRESS and ifa_family == socket.AF_INET6: 92 | b = buf[i + 4:i + 4 + 16] 93 | addr = socket.inet_ntop(socket.AF_INET6, b) 94 | elif attr_type == IFA_FLAGS: 95 | _, ifa_flags = struct.unpack_from('HI', buf, i) 96 | i += align_to(attr_len, RTA_ALIGNTO) 97 | 98 | msg_type = 'NEW' if h_type == RTM_NEWADDR else 'DEL' 99 | print('{} addr on interface {} {} [{}]: {}'.format(msg_type, ifa_name, 100 | ifa_idx, hex(ifa_flags), addr)) 101 | 102 | offset += align_to(msg_len, NLM_HDR_ALIGNTO) 103 | -------------------------------------------------------------------------------- /test/routesocket_monitor.py: -------------------------------------------------------------------------------- 1 | #!/usr/local/bin/python3 2 | 3 | # Not really a test case, but a PoC for getting notified about changes in 4 | # network addreses on FreeBSD using route sockets. 5 | 6 | import socket 7 | import struct 8 | import ctypes.util 9 | 10 | # from sys/net/route.h 11 | RTM_NEWADDR = 0xC 12 | RTM_DELADDR = 0xD 13 | RTM_IFINFO = 0xE 14 | 15 | RTA_IFA = 0x20 16 | 17 | # from sys/socket.h 18 | CTL_NET = 4 19 | NET_RT_IFLIST = 3 20 | 21 | # from sys/net/if.h 22 | IFF_LOOPBACK = 0x8 23 | IFF_MULTICAST = 0x800 24 | 25 | SA_ALIGNTO = ctypes.sizeof(ctypes.c_long) 26 | 27 | # global 28 | link_blacklist = [] 29 | 30 | 31 | def parse_route_socket_response(buf, keep_link): 32 | offset = 0 33 | 34 | link = None 35 | print(len(buf)) 36 | while offset < len(buf): 37 | rtm_len, _, rtm_type = struct.unpack_from('@HBB', buf, offset) 38 | # mask(addrs) has same offset in if_msghdr and ifs_msghdr 39 | addr_mask, flags = struct.unpack_from('ii', buf, offset + 4) 40 | 41 | msg_type = '' 42 | if rtm_type not in [RTM_NEWADDR, RTM_DELADDR, RTM_IFINFO]: 43 | offset += rtm_len 44 | continue 45 | 46 | # those offset may unfortunately be architecture dependent 47 | sa_offset = offset + ((16 + 152) if rtm_type == RTM_IFINFO else 20) 48 | 49 | if rtm_type in [RTM_NEWADDR, RTM_IFINFO]: 50 | msg_type = 'NEW' 51 | elif rtm_type == RTM_DELADDR: 52 | msg_type = 'DEL' 53 | 54 | # For a route socket message, and different to a sysctl response, the 55 | # link info is stored inside the same rtm message, so it has to 56 | # survive multiple rtm messages in such cases 57 | if not keep_link: 58 | link = None 59 | 60 | addr_type_idx = 1 61 | addr = None 62 | while sa_offset < offset + rtm_len: 63 | while (not (addr_type_idx & addr_mask) 64 | and (addr_type_idx <= addr_mask)): 65 | addr_type_idx = addr_type_idx << 1 66 | 67 | sa_len, sa_fam = struct.unpack_from('@BB', buf, sa_offset) 68 | if (sa_fam in [socket.AF_INET, socket.AF_INET6] 69 | and addr_type_idx == RTA_IFA): 70 | addr_offset = 4 if sa_fam == socket.AF_INET else 8 71 | addr_length = 16 if sa_fam == socket.AF_INET6 else 4 72 | addr = socket.inet_ntop(sa_fam, buf[(sa_offset + addr_offset):( 73 | sa_offset + addr_offset + addr_length)]) 74 | elif sa_fam == socket.AF_LINK: 75 | if_idx, if_type, name_len = struct.unpack_from( 76 | '@HBB', buf, sa_offset + 2) 77 | if if_idx > 0: 78 | name_start = sa_offset + 8 79 | name = (buf[name_start:name_start + name_len]).decode() 80 | link = '{} {}'.format(name, if_idx) 81 | else: 82 | link = 'system link' 83 | 84 | jump = (((sa_len + SA_ALIGNTO - 1) // SA_ALIGNTO) * SA_ALIGNTO 85 | if sa_len > 0 else SA_ALIGNTO) 86 | sa_offset += jump 87 | addr_type_idx = addr_type_idx << 1 88 | 89 | if link is not None and rtm_type == RTM_IFINFO and ( 90 | (flags & IFF_LOOPBACK) or not (flags & IFF_MULTICAST)): 91 | link_blacklist.append(link) 92 | 93 | if (link is not None and link not in link_blacklist) and ( 94 | addr is not None): 95 | print('{} addr on interface {}: {}'.format(msg_type, link, addr)) 96 | 97 | offset += rtm_len 98 | 99 | 100 | mib = [CTL_NET, socket.AF_ROUTE, 0, 0, NET_RT_IFLIST, 0] 101 | rt_mib = (ctypes.c_int * len(mib))() 102 | rt_mib[:] = mib[:] 103 | 104 | libc = ctypes.CDLL(ctypes.util.find_library('c'), use_errno=True) 105 | rt_size = ctypes.c_size_t() 106 | r = libc.sysctl(ctypes.byref(rt_mib), ctypes.c_size_t(len(rt_mib)), 0, 107 | ctypes.byref(rt_size), 0, 0) 108 | if r: 109 | print('unable to fetch routing table data') 110 | 111 | rt_buf = ctypes.create_string_buffer(rt_size.value) 112 | r = libc.sysctl(ctypes.byref(rt_mib), ctypes.c_size_t(len(rt_mib)), rt_buf, 113 | ctypes.byref(rt_size), 0, 0) 114 | if r: 115 | print('unable to fetch routing table data') 116 | 117 | parse_route_socket_response(rt_buf.raw, True) 118 | 119 | # get further notifications from the kernel 120 | s = socket.socket(socket.AF_ROUTE, socket.SOCK_RAW, socket.AF_UNSPEC) 121 | 122 | while True: 123 | buf = s.recv(4096) 124 | parse_route_socket_response(buf, False) 125 | --------------------------------------------------------------------------------