├── .circleci └── config.yml ├── .credo.exs ├── .formatter.exs ├── .github └── dependabot.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSES ├── Apache-2.0.txt ├── CC-BY-4.0.txt └── CC0-1.0.txt ├── Makefile ├── NOTICE ├── README.md ├── REUSE.toml ├── assets └── logo.png ├── c_src ├── force_ap_scan.c ├── mesh_mode.c ├── mesh_param.c └── test-c99.sh ├── config └── config.exs ├── lib ├── vintage_net_wifi.ex └── vintage_net_wifi │ ├── access_point.ex │ ├── bssid_requester.ex │ ├── cookbook.ex │ ├── event.ex │ ├── mesh_peer.ex │ ├── mesh_peer │ ├── capabilities.ex │ └── formation_information.ex │ ├── signal_info.ex │ ├── utils.ex │ ├── wpa2.ex │ ├── wpa_supplicant.ex │ ├── wpa_supplicant_decoder.ex │ ├── wpa_supplicant_ll.ex │ └── wps_data.ex ├── mix.exs ├── mix.lock └── test ├── fixtures └── root │ └── bin │ └── ip ├── support ├── mock_wpa_supplicant.ex └── utils.ex ├── test_helper.exs ├── vintage_net_wifi ├── cookbook_test.exs ├── event_test.exs ├── utils_test.exs ├── wpa2_test.exs ├── wpa_supplicant_decoder_test.exs ├── wpa_supplicant_ll_test.exs ├── wpa_supplicant_test.exs └── wps_data_test.exs └── vintage_net_wifi_test.exs /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | latest: &latest 4 | pattern: "^1.18.*-erlang-27.*$" 5 | 6 | tags: &tags 7 | [ 8 | 1.18.3-erlang-27.3.3-alpine-3.21.3, 9 | 1.17.0-erlang-27.0-alpine-3.20.0, 10 | 1.16.2-erlang-26.2.3-alpine-3.19.1, 11 | 1.15.7-erlang-26.1.2-alpine-3.18.4, 12 | 1.14.5-erlang-25.3.2-alpine-3.18.0 13 | ] 14 | 15 | jobs: 16 | check-license: 17 | docker: 18 | - image: fsfe/reuse:latest 19 | steps: 20 | - checkout 21 | - run: reuse lint 22 | 23 | build-test: 24 | parameters: 25 | tag: 26 | type: string 27 | docker: 28 | - image: hexpm/elixir:<< parameters.tag >> 29 | working_directory: ~/repo 30 | environment: 31 | LC_ALL: C.UTF-8 32 | steps: 33 | - run: 34 | name: Install system dependencies 35 | command: apk add --no-cache build-base linux-headers libmnl-dev libnl3-dev git 36 | - checkout 37 | - run: 38 | name: Install hex and rebar 39 | command: | 40 | mix local.hex --force 41 | mix local.rebar --force 42 | - restore_cache: 43 | keys: 44 | - v1-mix-cache-<< parameters.tag >>-{{ checksum "mix.lock" }} 45 | - run: mix deps.get 46 | - run: mix test || mix test --failed 47 | - when: 48 | condition: 49 | matches: { <<: *latest, value: << parameters.tag >> } 50 | steps: 51 | - run: mix format --check-formatted 52 | - run: mix deps.unlock --check-unused 53 | - run: mix compile --warnings-as-errors 54 | - run: mix docs 55 | - run: mix hex.build 56 | - run: mix credo -a --strict 57 | - run: mix dialyzer 58 | - save_cache: 59 | key: v1-mix-cache-<< parameters.tag >>-{{ checksum "mix.lock" }} 60 | paths: 61 | - _build 62 | - deps 63 | 64 | automerge: 65 | docker: 66 | - image: alpine:3.21.3 67 | steps: 68 | - run: 69 | name: Install GitHub CLI 70 | command: apk add --no-cache build-base github-cli 71 | - run: 72 | name: Attempt PR automerge 73 | command: | 74 | author=$(gh pr view "${CIRCLE_PULL_REQUEST}" --json author --jq '.author.login' || true) 75 | if [ "$author" = "app/dependabot" ]; then 76 | gh pr merge "${CIRCLE_PULL_REQUEST}" --auto --rebase || echo "Failed trying to set automerge" 77 | else 78 | echo "Not a dependabot PR, skipping automerge" 79 | fi 80 | 81 | workflows: 82 | checks: 83 | jobs: 84 | - check-license: 85 | filters: 86 | tags: 87 | only: /.*/ 88 | - build-test: 89 | name: << matrix.tag >> 90 | matrix: 91 | parameters: 92 | tag: *tags 93 | 94 | - automerge: 95 | requires: *tags 96 | context: org-global 97 | filters: 98 | branches: 99 | only: /^dependabot.*/ 100 | -------------------------------------------------------------------------------- /.credo.exs: -------------------------------------------------------------------------------- 1 | # .credo.exs 2 | %{ 3 | configs: [ 4 | %{ 5 | name: "default", 6 | strict: true, 7 | checks: [ 8 | {CredoBinaryPatterns.Check.Consistency.Pattern}, 9 | {Credo.Check.Refactor.MapInto, false}, 10 | {Credo.Check.Warning.LazyLogging, false}, 11 | {Credo.Check.Design.TagFIXME, false}, 12 | {Credo.Check.Design.TagTODO, false}, 13 | {Credo.Check.Readability.LargeNumbers, only_greater_than: 86400}, 14 | {Credo.Check.Readability.ParenthesesOnZeroArityDefs, parens: true}, 15 | {Credo.Check.Readability.Specs, tags: []}, 16 | {Credo.Check.Readability.StrictModuleLayout, tags: []} 17 | ] 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: mix 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | vintage_net_wifi-*.tar 24 | 25 | # Don't put anything in the /priv directory. The Makefile "owns" it. 26 | /priv 27 | 28 | # Ignore temporary directory used for unit tests. 29 | /test_tmp 30 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v0.12.6 - 2025-01-06 4 | 5 | * Changes 6 | * Remove support for upgrading 5+ year old configurations. These probably 7 | weren't used much at all given how early in VintageNetWiFi's history they 8 | were around. If you're upgrading a device that has not been updated in 5 9 | years, please review differences or you may lose an existing WiFi config. 10 | * Add docs on forcing the WiFi frequency used when configuring AP mode. Thanks 11 | to Ray Chang for this update. 12 | 13 | ## v0.12.5 - 2024-04-07 14 | 15 | * Changes 16 | * Revert support for WPA3 with `VintageNetWiFi.quick_configure/2`. It caused 17 | way to many issues on some Nerves devices. The default is WPA2 like old 18 | times. Raspberry Pis, BBBs, and GRiSP2 don't support WPA3 with their 19 | built-in WiFi modules so this may not impact you. 20 | * Support use of WPA3 via an application environment option. See 21 | `VintageNetWiFi.quick_configure/2` for details. 22 | 23 | ## v0.12.4 - 2024-03-31 24 | 25 | * Changes 26 | * Added `VintageNetWiFi.network_configured?/1` helper function for checking 27 | whether a WiFi connection to another computer is possible or just scanning 28 | for access points. 29 | * Added `VintageNetWiFi.qr_string/3` to create QR Code-encodable strings for 30 | easily sharing network credentials. 31 | * Added experimental `VintageNetWiFi.capabilities/1` to query WiFi driver and 32 | `wpa_supplicant` capabilities. This can be used to check WPA3 compatibility, 33 | support for 5 GHz channels and more. It's experimental since the information 34 | is currently very raw. 35 | 36 | ## v0.12.3 - 2024-02-13 37 | 38 | * Fixed 39 | * Relaxed frame protection requirement in generic WiFi configuration to work 40 | with more access points. This fixes an issue with connecting to hotspot mode 41 | a Samsung phone and probably other devices. The generic configuration works 42 | all WPA2 PSK and WPA3 SAE access points tested so far. 43 | 44 | ## v0.12.2 - 2024-02-02 45 | 46 | * Changes 47 | * Handle `update_current_access_point` crashes to handle attempts to associate 48 | with mesh endpoints. 49 | 50 | ## v0.12.1 - 2024-01-16 51 | 52 | This release adds support for creating generic WiFi configurations that work 53 | with both WPA2 and WPA3 access points. It's implemented to be backwards 54 | compatible if you role out firmware with this version and revert to a firmware 55 | with the previous version. 56 | 57 | * Changes 58 | * Added `VintageNetWiFi.Cookbook.generic/2` for easily creating WiFi 59 | configurations that will connect to WPA2-only, WPA2/WPA3-transitional, and 60 | WPA3-only access points. This works with WPA2-only WiFi modules like what's 61 | currently on Raspberry Pis and modules that support WPA3 like on the 62 | BeagleBone Green WiFi and custom hardware. 63 | * Updated `VintageNetWiFi.quick_connect/2` to create generic WiFi 64 | configurations. It previously generated WPA2-only ones. 65 | * Updated `:key_mgmt` to support lists so that multiple key management types 66 | could be allowed. To make configs work with earlier versions of 67 | VintageNetWiFi, these are normalized to store the list in the 68 | `:allowed_key_mgmt` field with the first option in `:key_mgmt`. This means 69 | that if you revert firmware to an earlier VintageNetWiFi version, you'll get 70 | a WPA2-only config if you're using the new `generic/2` helper. 71 | 72 | * Fixed 73 | * Fixed specification of WPA2-PSK configurations that use SHA256 hashing. This 74 | not a common configuration to my knowledge and it would have failed due to a 75 | typo previously. 76 | 77 | ## v0.12.0 - 2023-12-11 78 | 79 | * Changes 80 | * Added `VintageNetWiFi.summarize_access_points/1` to centralize filtering and 81 | sorting access point lists for presentation to users. (Thanks 82 | @grace-in-wonderland) 83 | * Change `VintageNetWiFi.quick_scan/1` to call `summarize_access_points/1`. 84 | This should make it much easier to find SSIDs at the IEx prompt. It's 85 | technically an API change. See the function's hexdocs for details. 86 | 87 | ## v0.11.7 - 2023-10-04 88 | 89 | * Fixed 90 | * Workaround issue passing SSIDs that contain a lot of escaped characters. 91 | These were probably invalid anyway, but this prevents needless retries. 92 | 93 | * Changes 94 | * Lowered log priority (warning -> debug) of several messages that occur a lot 95 | and aren't really problems. 96 | 97 | ## v0.11.6 - 2023-03-08 98 | 99 | * Fixed 100 | * Support passing SSIDs with all NULL characters to `wpa_supplicant`. This 101 | also fixes other SSIDs with nonprintable characters. 102 | 103 | ## v0.11.5 - 2023-03-08 104 | 105 | * Fixed 106 | * Support SAE H2E and PK flags in AP advertisements 107 | 108 | ## v0.11.4 - 2023-02-12 109 | 110 | * Fixed 111 | * Fix Elixir 1.15 deprecation warnings 112 | 113 | ## v0.11.3 - 2023-01-23 114 | 115 | * Changed 116 | * Allow VintageNet v0.13.0 to be used 117 | 118 | ## v0.11.2 - 2023-01-16 119 | 120 | * Fixed 121 | * Fix cipher flag parsing from some access points. For example, if an access 122 | point advertised `[WPA2-PSK+PSK-SHA256-CCMP][ESS]`, it would fail to parse 123 | due to "PSK" being greedily selected as the cipher instead of "PSDK-SHA256". 124 | 125 | ## v0.11.1 - 2022-07-27 126 | 127 | * Changed 128 | * Added support for handling WiFi events. Currently events associated with 129 | WiFi AP associations are reported since they can be helpful when creating 130 | WiFi configuration user interfaces. More could be supported in the future. 131 | Thanks to @dognotdog, @THE9rtyt, and @ConnorRigby for this feature. 132 | 133 | * Fixed 134 | * Remove mesh peers from reported access point lists. Mesh peers are reported 135 | separately and mixing them with access points was unexpected. Thanks to 136 | @mattludwigs for identifying and fixing the issue. 137 | 138 | ## v0.11.0 - 2022-04-30 139 | 140 | This release requires VintageNet v0.12.0 and Elixir 1.11 or later. No external 141 | API changes or fixes were made. Other than the new version requirements, 142 | everything should work the same as v0.10.9. 143 | 144 | ## v0.10.9 145 | 146 | * Changed 147 | * Increase `wpa_supplicant` timeout from 1 second to 4 seconds. Normally 148 | responses come in quickly. On GRiSP 2, initialization takes >1 second. 149 | This prevents an unnecessary `wpa_supplicant` restart and improves boot 150 | time. 151 | 152 | ## v0.10.8 153 | 154 | * Added 155 | * Fall back to the wext WiFi driver interface if nl80211 doesn't work. This 156 | makes it possible to support the WiFi module on GRiSPv2 boards. 157 | 158 | ## v0.10.7 159 | 160 | * Bug fixes 161 | * Fix crash when scanning for WiFi networks and near an Eero mesh WiFi system. 162 | 163 | ## v0.10.6 164 | 165 | * Bug fixes 166 | * Fully decode WiFi flags based on inspecting the `wpa_supplicant` source 167 | code. This should, hopefully, fix the recurring issue with new flags being 168 | discovered. The flags are now decomposed into their constituent parts. The 169 | original flags are still present, but the new ones should be easier to 170 | reason about. E.g., `[:wpa2_psk_ccmp]` is now `[:wpa2_psk_ccmp, :wpa2, :psk, :ccmp]`. 171 | 172 | ## v0.10.5 173 | 174 | * Added 175 | * Decode network flags that advertise WEP. Thanks to Ryota Kinukawa for this 176 | change. 177 | 178 | ## v0.10.4 179 | 180 | This release only contains a build system update. It doesn't change any code and 181 | is a safe update. 182 | 183 | ## v0.10.3 184 | 185 | * New features 186 | * Support WPS PBS for connecting to access points. This is the feature where 187 | you press a button on the AP and "press a button" on the device to connect. 188 | See `VintageNetWiFi.quick_wps/1`. Thanks to @labno for this feature. 189 | 190 | * Bug fixes 191 | * Added missing PSK WiFi type. Thanks again to Dömötör Gulyás for these fixes. 192 | * Improved handling of AP information gathering from the `wpa_supplicant`. 193 | This works around a rare issue seen when the `wpa_supplicant` doesn't 194 | respond to a BSS information request, by 1. not sending the request when the 195 | information is known and 2. moving info requests out of the main process to 196 | avoid stalling more important requests when lots of APs are around. 197 | 198 | ## v0.10.2 199 | 200 | * Bug fixes 201 | * Added missing EAP WiFi types. Thanks to Dömötör Gulyás for this fix. 202 | 203 | ## v0.10.1 204 | 205 | * New features 206 | * It's now possible to specify arbitrary `wpa_supplicant.conf` text. 207 | VintageNetWiFi normally tries to validate everything going into the config 208 | file, but this gets in the way of advanced users especially when a feature 209 | is not available in VintageNetWiFi yet. This is the escape hatch. Specify 210 | the `:wpa_supplicant_conf` key in the config and you have total control. 211 | * Initial support for WPA3 has been added. See the `README.md` for 212 | configuration details. Note that many WiFi modules and their drivers don't 213 | support WPA3 yet, and WPA3 support isn't enabled at the time of this release 214 | in all official Nerves systems. 215 | 216 | ## v0.10.0 217 | 218 | This release is backwards compatible with v0.9.2. No changes are needed to 219 | existing code. 220 | 221 | * Bug fixes 222 | * OTP 24 is supported now. This release updates to the old crypto API that has 223 | been removed in OTP 24. 224 | * Fix a GenServer crash when requesting BSSID information. This issue seemed 225 | to occur more frequently in high density WiFi environments. OTP supervision 226 | recovered it, but it had a side effect of making VintageNet send out 227 | notifications that would make it look like the interface bounced. 228 | * Fix a crash due to invalid AP flags being reported. Thanks to Rick Carlino 229 | for reporting that this happens. 230 | 231 | ## v0.9.2 232 | 233 | This release introduces helper functions for configuring the most common types 234 | of networks: 235 | 236 | * `VintageNetWiFi.quick_configure("ssid", "password")` - connect to a WPA PSK 237 | network on `"wlan0"` 238 | * `VintageNetWiFi.quick_scan()` - scan and return access points in one call 239 | 240 | Additionally, there's now a `VintageNetWiFi.Cookbook` module with functions for 241 | creating the configs for various kinds of networks. 242 | 243 | ## v0.9.1 244 | 245 | * Bug fixes 246 | * Fix warnings when building with Elixir 1.11. 247 | 248 | ## v0.9.0 249 | 250 | * New features 251 | * Initial support for 802.11s mesh networking. Please see the docs and the 252 | cookbook for using this since it requires compatible WiFi modules and more 253 | configuration than normal WiFi options. 254 | * Synchronize with vintage_net v0.9.0's networking program path API update 255 | 256 | ## v0.8.0 257 | 258 | * New features 259 | * Add a WiFi signal strength polling feature. This works when connected to a 260 | WiFi access point. 261 | * Support vintage_net v0.8.0's `required_ifnames` API update 262 | 263 | ## v0.7.0 264 | 265 | Initial `vintage_net_wifi` release. See the [`vintage_net v0.7.0` release 266 | notes](https://github.com/nerves-networking/vintage_net/releases/tag/v0.7.0) 267 | for upgrade instructions if you are a `vintage_net v0.6.x` user. 268 | 269 | -------------------------------------------------------------------------------- /LICENSES/Apache-2.0.txt: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. 10 | 11 | "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. 12 | 13 | "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. 14 | 15 | "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. 16 | 17 | "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. 18 | 19 | "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. 20 | 21 | "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). 22 | 23 | "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. 24 | 25 | "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." 26 | 27 | "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 28 | 29 | 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 30 | 31 | 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 32 | 33 | 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: 34 | 35 | (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and 36 | 37 | (b) You must cause any modified files to carry prominent notices stating that You changed the files; and 38 | 39 | (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and 40 | 41 | (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. 42 | 43 | You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 44 | 45 | 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 46 | 47 | 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 48 | 49 | 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 50 | 51 | 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 52 | 53 | 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. 54 | 55 | END OF TERMS AND CONDITIONS 56 | 57 | APPENDIX: How to apply the Apache License to your work. 58 | 59 | To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. 60 | 61 | Copyright [yyyy] [name of copyright owner] 62 | 63 | Licensed under the Apache License, Version 2.0 (the "License"); 64 | you may not use this file except in compliance with the License. 65 | You may obtain a copy of the License at 66 | 67 | http://www.apache.org/licenses/LICENSE-2.0 68 | 69 | Unless required by applicable law or agreed to in writing, software 70 | distributed under the License is distributed on an "AS IS" BASIS, 71 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 72 | See the License for the specific language governing permissions and 73 | limitations under the License. 74 | -------------------------------------------------------------------------------- /LICENSES/CC-BY-4.0.txt: -------------------------------------------------------------------------------- 1 | Creative Commons Attribution 4.0 International 2 | 3 | Creative Commons Corporation (“Creative Commons”) is not a law firm and does not provide legal services or legal advice. Distribution of Creative Commons public licenses does not create a lawyer-client or other relationship. Creative Commons makes its licenses and related information available on an “as-is” basis. Creative Commons gives no warranties regarding its licenses, any material licensed under their terms and conditions, or any related information. Creative Commons disclaims all liability for damages resulting from their use to the fullest extent possible. 4 | 5 | Using Creative Commons Public Licenses 6 | 7 | Creative Commons public licenses provide a standard set of terms and conditions that creators and other rights holders may use to share original works of authorship and other material subject to copyright and certain other rights specified in the public license below. The following considerations are for informational purposes only, are not exhaustive, and do not form part of our licenses. 8 | 9 | Considerations for licensors: Our public licenses are intended for use by those authorized to give the public permission to use material in ways otherwise restricted by copyright and certain other rights. Our licenses are irrevocable. Licensors should read and understand the terms and conditions of the license they choose before applying it. Licensors should also secure all rights necessary before applying our licenses so that the public can reuse the material as expected. Licensors should clearly mark any material not subject to the license. This includes other CC-licensed material, or material used under an exception or limitation to copyright. More considerations for licensors. 10 | 11 | Considerations for the public: By using one of our public licenses, a licensor grants the public permission to use the licensed material under specified terms and conditions. If the licensor’s permission is not necessary for any reason–for example, because of any applicable exception or limitation to copyright–then that use is not regulated by the license. Our licenses grant only permissions under copyright and certain other rights that a licensor has authority to grant. Use of the licensed material may still be restricted for other reasons, including because others have copyright or other rights in the material. A licensor may make special requests, such as asking that all changes be marked or described. Although not required by our licenses, you are encouraged to respect those requests where reasonable. More considerations for the public. 12 | 13 | Creative Commons Attribution 4.0 International Public License 14 | 15 | By exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution 4.0 International Public License ("Public License"). To the extent this Public License may be interpreted as a contract, You are granted the Licensed Rights in consideration of Your acceptance of these terms and conditions, and the Licensor grants You such rights in consideration of benefits the Licensor receives from making the Licensed Material available under these terms and conditions. 16 | 17 | Section 1 – Definitions. 18 | 19 | a. Adapted Material means material subject to Copyright and Similar Rights that is derived from or based upon the Licensed Material and in which the Licensed Material is translated, altered, arranged, transformed, or otherwise modified in a manner requiring permission under the Copyright and Similar Rights held by the Licensor. For purposes of this Public License, where the Licensed Material is a musical work, performance, or sound recording, Adapted Material is always produced where the Licensed Material is synched in timed relation with a moving image. 20 | 21 | b. Adapter's License means the license You apply to Your Copyright and Similar Rights in Your contributions to Adapted Material in accordance with the terms and conditions of this Public License. 22 | 23 | c. Copyright and Similar Rights means copyright and/or similar rights closely related to copyright including, without limitation, performance, broadcast, sound recording, and Sui Generis Database Rights, without regard to how the rights are labeled or categorized. For purposes of this Public License, the rights specified in Section 2(b)(1)-(2) are not Copyright and Similar Rights. 24 | 25 | d. Effective Technological Measures means those measures that, in the absence of proper authority, may not be circumvented under laws fulfilling obligations under Article 11 of the WIPO Copyright Treaty adopted on December 20, 1996, and/or similar international agreements. 26 | 27 | e. Exceptions and Limitations means fair use, fair dealing, and/or any other exception or limitation to Copyright and Similar Rights that applies to Your use of the Licensed Material. 28 | 29 | f. Licensed Material means the artistic or literary work, database, or other material to which the Licensor applied this Public License. 30 | 31 | g. Licensed Rights means the rights granted to You subject to the terms and conditions of this Public License, which are limited to all Copyright and Similar Rights that apply to Your use of the Licensed Material and that the Licensor has authority to license. 32 | 33 | h. Licensor means the individual(s) or entity(ies) granting rights under this Public License. 34 | 35 | i. Share means to provide material to the public by any means or process that requires permission under the Licensed Rights, such as reproduction, public display, public performance, distribution, dissemination, communication, or importation, and to make material available to the public including in ways that members of the public may access the material from a place and at a time individually chosen by them. 36 | 37 | j. Sui Generis Database Rights means rights other than copyright resulting from Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, as amended and/or succeeded, as well as other essentially equivalent rights anywhere in the world. 38 | 39 | k. You means the individual or entity exercising the Licensed Rights under this Public License. Your has a corresponding meaning. 40 | 41 | Section 2 – Scope. 42 | 43 | a. License grant. 44 | 45 | 1. Subject to the terms and conditions of this Public License, the Licensor hereby grants You a worldwide, royalty-free, non-sublicensable, non-exclusive, irrevocable license to exercise the Licensed Rights in the Licensed Material to: 46 | 47 | A. reproduce and Share the Licensed Material, in whole or in part; and 48 | 49 | B. produce, reproduce, and Share Adapted Material. 50 | 51 | 2. Exceptions and Limitations. For the avoidance of doubt, where Exceptions and Limitations apply to Your use, this Public License does not apply, and You do not need to comply with its terms and conditions. 52 | 53 | 3. Term. The term of this Public License is specified in Section 6(a). 54 | 55 | 4. Media and formats; technical modifications allowed. The Licensor authorizes You to exercise the Licensed Rights in all media and formats whether now known or hereafter created, and to make technical modifications necessary to do so. The Licensor waives and/or agrees not to assert any right or authority to forbid You from making technical modifications necessary to exercise the Licensed Rights, including technical modifications necessary to circumvent Effective Technological Measures. For purposes of this Public License, simply making modifications authorized by this Section 2(a)(4) never produces Adapted Material. 56 | 57 | 5. Downstream recipients. 58 | 59 | A. Offer from the Licensor – Licensed Material. Every recipient of the Licensed Material automatically receives an offer from the Licensor to exercise the Licensed Rights under the terms and conditions of this Public License. 60 | 61 | B. No downstream restrictions. You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, the Licensed Material if doing so restricts exercise of the Licensed Rights by any recipient of the Licensed Material. 62 | 63 | 6. No endorsement. Nothing in this Public License constitutes or may be construed as permission to assert or imply that You are, or that Your use of the Licensed Material is, connected with, or sponsored, endorsed, or granted official status by, the Licensor or others designated to receive attribution as provided in Section 3(a)(1)(A)(i). 64 | 65 | b. Other rights. 66 | 67 | 1. Moral rights, such as the right of integrity, are not licensed under this Public License, nor are publicity, privacy, and/or other similar personality rights; however, to the extent possible, the Licensor waives and/or agrees not to assert any such rights held by the Licensor to the limited extent necessary to allow You to exercise the Licensed Rights, but not otherwise. 68 | 69 | 2. Patent and trademark rights are not licensed under this Public License. 70 | 71 | 3. To the extent possible, the Licensor waives any right to collect royalties from You for the exercise of the Licensed Rights, whether directly or through a collecting society under any voluntary or waivable statutory or compulsory licensing scheme. In all other cases the Licensor expressly reserves any right to collect such royalties. 72 | 73 | Section 3 – License Conditions. 74 | 75 | Your exercise of the Licensed Rights is expressly made subject to the following conditions. 76 | 77 | a. Attribution. 78 | 79 | 1. If You Share the Licensed Material (including in modified form), You must: 80 | 81 | A. retain the following if it is supplied by the Licensor with the Licensed Material: 82 | 83 | i. identification of the creator(s) of the Licensed Material and any others designated to receive attribution, in any reasonable manner requested by the Licensor (including by pseudonym if designated); 84 | 85 | ii. a copyright notice; 86 | 87 | iii. a notice that refers to this Public License; 88 | 89 | iv. a notice that refers to the disclaimer of warranties; 90 | 91 | v. a URI or hyperlink to the Licensed Material to the extent reasonably practicable; 92 | 93 | B. indicate if You modified the Licensed Material and retain an indication of any previous modifications; and 94 | 95 | C. indicate the Licensed Material is licensed under this Public License, and include the text of, or the URI or hyperlink to, this Public License. 96 | 97 | 2. You may satisfy the conditions in Section 3(a)(1) in any reasonable manner based on the medium, means, and context in which You Share the Licensed Material. For example, it may be reasonable to satisfy the conditions by providing a URI or hyperlink to a resource that includes the required information. 98 | 99 | 3. If requested by the Licensor, You must remove any of the information required by Section 3(a)(1)(A) to the extent reasonably practicable. 100 | 101 | 4. If You Share Adapted Material You produce, the Adapter's License You apply must not prevent recipients of the Adapted Material from complying with this Public License. 102 | 103 | Section 4 – Sui Generis Database Rights. 104 | 105 | Where the Licensed Rights include Sui Generis Database Rights that apply to Your use of the Licensed Material: 106 | 107 | a. for the avoidance of doubt, Section 2(a)(1) grants You the right to extract, reuse, reproduce, and Share all or a substantial portion of the contents of the database; 108 | 109 | b. if You include all or a substantial portion of the database contents in a database in which You have Sui Generis Database Rights, then the database in which You have Sui Generis Database Rights (but not its individual contents) is Adapted Material; and 110 | 111 | c. You must comply with the conditions in Section 3(a) if You Share all or a substantial portion of the contents of the database. 112 | For the avoidance of doubt, this Section 4 supplements and does not replace Your obligations under this Public License where the Licensed Rights include other Copyright and Similar Rights. 113 | 114 | Section 5 – Disclaimer of Warranties and Limitation of Liability. 115 | 116 | a. Unless otherwise separately undertaken by the Licensor, to the extent possible, the Licensor offers the Licensed Material as-is and as-available, and makes no representations or warranties of any kind concerning the Licensed Material, whether express, implied, statutory, or other. This includes, without limitation, warranties of title, merchantability, fitness for a particular purpose, non-infringement, absence of latent or other defects, accuracy, or the presence or absence of errors, whether or not known or discoverable. Where disclaimers of warranties are not allowed in full or in part, this disclaimer may not apply to You. 117 | 118 | b. To the extent possible, in no event will the Licensor be liable to You on any legal theory (including, without limitation, negligence) or otherwise for any direct, special, indirect, incidental, consequential, punitive, exemplary, or other losses, costs, expenses, or damages arising out of this Public License or use of the Licensed Material, even if the Licensor has been advised of the possibility of such losses, costs, expenses, or damages. Where a limitation of liability is not allowed in full or in part, this limitation may not apply to You. 119 | 120 | c. The disclaimer of warranties and limitation of liability provided above shall be interpreted in a manner that, to the extent possible, most closely approximates an absolute disclaimer and waiver of all liability. 121 | 122 | Section 6 – Term and Termination. 123 | 124 | a. This Public License applies for the term of the Copyright and Similar Rights licensed here. However, if You fail to comply with this Public License, then Your rights under this Public License terminate automatically. 125 | 126 | b. Where Your right to use the Licensed Material has terminated under Section 6(a), it reinstates: 127 | 128 | 1. automatically as of the date the violation is cured, provided it is cured within 30 days of Your discovery of the violation; or 129 | 130 | 2. upon express reinstatement by the Licensor. 131 | 132 | c. For the avoidance of doubt, this Section 6(b) does not affect any right the Licensor may have to seek remedies for Your violations of this Public License. 133 | 134 | d. For the avoidance of doubt, the Licensor may also offer the Licensed Material under separate terms or conditions or stop distributing the Licensed Material at any time; however, doing so will not terminate this Public License. 135 | 136 | e. Sections 1, 5, 6, 7, and 8 survive termination of this Public License. 137 | 138 | Section 7 – Other Terms and Conditions. 139 | 140 | a. The Licensor shall not be bound by any additional or different terms or conditions communicated by You unless expressly agreed. 141 | 142 | b. Any arrangements, understandings, or agreements regarding the Licensed Material not stated herein are separate from and independent of the terms and conditions of this Public License. 143 | 144 | Section 8 – Interpretation. 145 | 146 | a. For the avoidance of doubt, this Public License does not, and shall not be interpreted to, reduce, limit, restrict, or impose conditions on any use of the Licensed Material that could lawfully be made without permission under this Public License. 147 | 148 | b. To the extent possible, if any provision of this Public License is deemed unenforceable, it shall be automatically reformed to the minimum extent necessary to make it enforceable. If the provision cannot be reformed, it shall be severed from this Public License without affecting the enforceability of the remaining terms and conditions. 149 | 150 | c. No term or condition of this Public License will be waived and no failure to comply consented to unless expressly agreed to by the Licensor. 151 | 152 | d. Nothing in this Public License constitutes or may be interpreted as a limitation upon, or waiver of, any privileges and immunities that apply to the Licensor or You, including from the legal processes of any jurisdiction or authority. 153 | 154 | Creative Commons is not a party to its public licenses. Notwithstanding, Creative Commons may elect to apply one of its public licenses to material it publishes and in those instances will be considered the “Licensor.” Except for the limited purpose of indicating that material is shared under a Creative Commons public license or as otherwise permitted by the Creative Commons policies published at creativecommons.org/policies, Creative Commons does not authorize the use of the trademark “Creative Commons” or any other trademark or logo of Creative Commons without its prior written consent including, without limitation, in connection with any unauthorized modifications to any of its public licenses or any other arrangements, understandings, or agreements concerning use of licensed material. For the avoidance of doubt, this paragraph does not form part of the public licenses. 155 | 156 | Creative Commons may be contacted at creativecommons.org. 157 | -------------------------------------------------------------------------------- /LICENSES/CC0-1.0.txt: -------------------------------------------------------------------------------- 1 | Creative Commons Legal Code 2 | 3 | CC0 1.0 Universal 4 | 5 | CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE 6 | LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN 7 | ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS 8 | INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES 9 | REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS 10 | PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM 11 | THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED 12 | HEREUNDER. 13 | 14 | Statement of Purpose 15 | 16 | The laws of most jurisdictions throughout the world automatically confer 17 | exclusive Copyright and Related Rights (defined below) upon the creator 18 | and subsequent owner(s) (each and all, an "owner") of an original work of 19 | authorship and/or a database (each, a "Work"). 20 | 21 | Certain owners wish to permanently relinquish those rights to a Work for 22 | the purpose of contributing to a commons of creative, cultural and 23 | scientific works ("Commons") that the public can reliably and without fear 24 | of later claims of infringement build upon, modify, incorporate in other 25 | works, reuse and redistribute as freely as possible in any form whatsoever 26 | and for any purposes, including without limitation commercial purposes. 27 | These owners may contribute to the Commons to promote the ideal of a free 28 | culture and the further production of creative, cultural and scientific 29 | works, or to gain reputation or greater distribution for their Work in 30 | part through the use and efforts of others. 31 | 32 | For these and/or other purposes and motivations, and without any 33 | expectation of additional consideration or compensation, the person 34 | associating CC0 with a Work (the "Affirmer"), to the extent that he or she 35 | is an owner of Copyright and Related Rights in the Work, voluntarily 36 | elects to apply CC0 to the Work and publicly distribute the Work under its 37 | terms, with knowledge of his or her Copyright and Related Rights in the 38 | Work and the meaning and intended legal effect of CC0 on those rights. 39 | 40 | 1. Copyright and Related Rights. A Work made available under CC0 may be 41 | protected by copyright and related or neighboring rights ("Copyright and 42 | Related Rights"). Copyright and Related Rights include, but are not 43 | limited to, the following: 44 | 45 | i. the right to reproduce, adapt, distribute, perform, display, 46 | communicate, and translate a Work; 47 | ii. moral rights retained by the original author(s) and/or performer(s); 48 | iii. publicity and privacy rights pertaining to a person's image or 49 | likeness depicted in a Work; 50 | iv. rights protecting against unfair competition in regards to a Work, 51 | subject to the limitations in paragraph 4(a), below; 52 | v. rights protecting the extraction, dissemination, use and reuse of data 53 | in a Work; 54 | vi. database rights (such as those arising under Directive 96/9/EC of the 55 | European Parliament and of the Council of 11 March 1996 on the legal 56 | protection of databases, and under any national implementation 57 | thereof, including any amended or successor version of such 58 | directive); and 59 | vii. other similar, equivalent or corresponding rights throughout the 60 | world based on applicable law or treaty, and any national 61 | implementations thereof. 62 | 63 | 2. Waiver. To the greatest extent permitted by, but not in contravention 64 | of, applicable law, Affirmer hereby overtly, fully, permanently, 65 | irrevocably and unconditionally waives, abandons, and surrenders all of 66 | Affirmer's Copyright and Related Rights and associated claims and causes 67 | of action, whether now known or unknown (including existing as well as 68 | future claims and causes of action), in the Work (i) in all territories 69 | worldwide, (ii) for the maximum duration provided by applicable law or 70 | treaty (including future time extensions), (iii) in any current or future 71 | medium and for any number of copies, and (iv) for any purpose whatsoever, 72 | including without limitation commercial, advertising or promotional 73 | purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each 74 | member of the public at large and to the detriment of Affirmer's heirs and 75 | successors, fully intending that such Waiver shall not be subject to 76 | revocation, rescission, cancellation, termination, or any other legal or 77 | equitable action to disrupt the quiet enjoyment of the Work by the public 78 | as contemplated by Affirmer's express Statement of Purpose. 79 | 80 | 3. Public License Fallback. Should any part of the Waiver for any reason 81 | be judged legally invalid or ineffective under applicable law, then the 82 | Waiver shall be preserved to the maximum extent permitted taking into 83 | account Affirmer's express Statement of Purpose. In addition, to the 84 | extent the Waiver is so judged Affirmer hereby grants to each affected 85 | person a royalty-free, non transferable, non sublicensable, non exclusive, 86 | irrevocable and unconditional license to exercise Affirmer's Copyright and 87 | Related Rights in the Work (i) in all territories worldwide, (ii) for the 88 | maximum duration provided by applicable law or treaty (including future 89 | time extensions), (iii) in any current or future medium and for any number 90 | of copies, and (iv) for any purpose whatsoever, including without 91 | limitation commercial, advertising or promotional purposes (the 92 | "License"). The License shall be deemed effective as of the date CC0 was 93 | applied by Affirmer to the Work. Should any part of the License for any 94 | reason be judged legally invalid or ineffective under applicable law, such 95 | partial invalidity or ineffectiveness shall not invalidate the remainder 96 | of the License, and in such case Affirmer hereby affirms that he or she 97 | will not (i) exercise any of his or her remaining Copyright and Related 98 | Rights in the Work or (ii) assert any associated claims and causes of 99 | action with respect to the Work, in either case contrary to Affirmer's 100 | express Statement of Purpose. 101 | 102 | 4. Limitations and Disclaimers. 103 | 104 | a. No trademark or patent rights held by Affirmer are waived, abandoned, 105 | surrendered, licensed or otherwise affected by this document. 106 | b. Affirmer offers the Work as-is and makes no representations or 107 | warranties of any kind concerning the Work, express, implied, 108 | statutory or otherwise, including without limitation warranties of 109 | title, merchantability, fitness for a particular purpose, non 110 | infringement, or the absence of latent or other defects, accuracy, or 111 | the present or absence of errors, whether or not discoverable, all to 112 | the greatest extent permissible under applicable law. 113 | c. Affirmer disclaims responsibility for clearing rights of other persons 114 | that may apply to the Work or any use thereof, including without 115 | limitation any person's Copyright and Related Rights in the Work. 116 | Further, Affirmer disclaims responsibility for obtaining any necessary 117 | consents, permissions or other rights required for any use of the 118 | Work. 119 | d. Affirmer understands and acknowledges that Creative Commons is not a 120 | party to this document and has no duty or obligation with respect to 121 | this CC0 or use of the Work. 122 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2019 Frank Hunleth 2 | # SPDX-FileCopyrightText: 2020 Connor Rigby 3 | # 4 | # SPDX-License-Identifier: Apache-2.0 5 | # 6 | # Makefile for building port binaries 7 | # 8 | # Makefile targets: 9 | # 10 | # all/install build and install the port binary 11 | # clean clean build products and intermediates 12 | # 13 | # Variables to override: 14 | # 15 | # MIX_APP_PATH path to the build directory 16 | # 17 | # CC C compiler 18 | # CROSSCOMPILE crosscompiler prefix, if any 19 | # CFLAGS compiler flags for compiling all C files 20 | # LDFLAGS linker flags for linking all binaries 21 | # PKG_CONFIG_SYSROOT_DIR sysroot for pkg-config (for finding libnl-3) 22 | # PKG_CONFIG_PATH pkg-config metadata 23 | # 24 | ifeq ($(MIX_APP_PATH),) 25 | calling_from_make: 26 | mix compile 27 | endif 28 | 29 | PREFIX = $(MIX_APP_PATH)/priv 30 | BUILD = $(MIX_APP_PATH)/obj 31 | 32 | CFLAGS ?= -O2 -Wall -Wextra -Wno-unused-parameter -pedantic 33 | 34 | # Check that we're on a supported build platform 35 | ifeq ($(CROSSCOMPILE),) 36 | # Not crosscompiling, so check that we're on Linux. 37 | ifeq ($(shell uname -s),Linux) 38 | CFLAGS += $(shell pkg-config --cflags libnl-genl-3.0) 39 | else 40 | $(warning vintage_net_wifi only works on Linux, but crosscompilation) 41 | $(warning is supported by defining $$CROSSCOMPILE.) 42 | $(warning See Makefile for details. If using Nerves,) 43 | $(warning this should be done automatically.) 44 | $(warning .) 45 | $(warning Skipping C compilation unless targets explicitly passed to make.) 46 | DEFAULT_TARGETS ?= $(PREFIX) 47 | endif 48 | else 49 | # Crosscompiling 50 | ifeq ($(PKG_CONFIG_SYSROOT_DIR),) 51 | # If pkg-config sysroot isn't set, then assume Nerves 52 | CFLAGS += -I$(NERVES_SDK_SYSROOT)/usr/include/libnl3 53 | else 54 | 55 | # Use pkg-config to find libnl 56 | PKG_CONFIG = $(shell which pkg-config) 57 | ifeq ($(PKG_CONFIG),) 58 | $(error pkg-config required to build. Install by running "brew install pkg-config") 59 | endif 60 | 61 | CFLAGS += $(shell $(PKG_CONFIG) --cflags libnl-genl-3.0) 62 | endif 63 | endif 64 | DEFAULT_TARGETS ?= $(PREFIX) \ 65 | $(PREFIX)/force_ap_scan \ 66 | $(PREFIX)/mesh_mode \ 67 | $(PREFIX)/mesh_param 68 | 69 | # Enable for debug messages 70 | # CFLAGS += -DDEBUG 71 | 72 | # Unfortunately, depending on the system we're on, we need 73 | # to specify -std=c99 or -std=gnu99. The later is more correct, 74 | # but it fails to build on many setups. 75 | # NOTE: Need to call sh here since file permissions are not preserved 76 | # in hex packages. 77 | ifeq ($(shell CC=$(CC) sh c_src/test-c99.sh),yes) 78 | CFLAGS += -std=c99 -D_XOPEN_SOURCE=600 79 | else 80 | CFLAGS += -std=gnu99 81 | endif 82 | 83 | all: install 84 | 85 | install: $(BUILD) $(PREFIX) $(DEFAULT_TARGETS) 86 | 87 | $(BUILD)/%.o: c_src/%.c 88 | @echo " CC $(notdir $@)" 89 | $(CC) -c $(CFLAGS) -o $@ $< 90 | 91 | $(PREFIX)/force_ap_scan: $(BUILD)/force_ap_scan.o 92 | @echo " LD $(notdir $@)" 93 | $(CC) $^ $(LDFLAGS) -lnl-3 -lnl-genl-3 -o $@ 94 | 95 | $(PREFIX)/mesh_mode: $(BUILD)/mesh_mode.o 96 | @echo " LD $(notdir $@)" 97 | $(CC) $^ $(LDFLAGS) -lnl-3 -lnl-genl-3 -o $@ 98 | 99 | $(PREFIX)/mesh_param: $(BUILD)/mesh_param.o 100 | @echo " LD $(notdir $@)" 101 | $(CC) $^ $(LDFLAGS) -lnl-3 -lnl-genl-3 -o $@ 102 | 103 | $(PREFIX) $(BUILD): 104 | mkdir -p $@ 105 | 106 | mix_clean: 107 | $(RM) $(PREFIX)/force_ap_scan \ 108 | $(PREFIX)/mesh_mode \ 109 | $(PREFIX)/mesh_param \ 110 | $(BUILD)/*.o 111 | clean: 112 | mix clean 113 | 114 | format: 115 | astyle \ 116 | --style=kr \ 117 | --indent=spaces=4 \ 118 | --align-pointer=name \ 119 | --align-reference=name \ 120 | --convert-tabs \ 121 | --attach-namespaces \ 122 | --max-code-length=100 \ 123 | --max-instatement-indent=120 \ 124 | --pad-header \ 125 | --pad-oper \ 126 | src/*.c 127 | 128 | .PHONY: all clean mix_clean calling_from_make install format 129 | 130 | # Don't echo commands unless the caller exports "V=1" 131 | ${V}.SILENT: 132 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | VintageNetWifi is open-source software licensed under the Apache License, 2 | Version 2.0. 3 | 4 | Copyright holders include Frank Hunleth, Connor Rigby, Matt Ludwigs, Pavel 5 | Sorejs, Dömötör Gulyás, WN, Jon Carstens, Masatoshi Nishiguchi, Ace Yanagida 6 | and Thomas Jack. 7 | 8 | Authoritative REUSE-compliant copyright and license metadata available at 9 | https://hex.pm/packages/vintage_net_wifi. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![vintage net logo](assets/logo.png) 2 | 3 | [![Hex version](https://img.shields.io/hexpm/v/vintage_net_wifi.svg "Hex version")](https://hex.pm/packages/vintage_net_wifi) 4 | [![API docs](https://img.shields.io/hexpm/v/vintage_net_wifi.svg?label=hexdocs "API docs")](https://hexdocs.pm/vintage_net_wifi/VintageNetWiFi.html) 5 | [![CircleCI](https://dl.circleci.com/status-badge/img/gh/nerves-networking/vintage_net_wifi/tree/main.svg?style=svg)](https://dl.circleci.com/status-badge/redirect/gh/nerves-networking/vintage_net_wifi/tree/main) 6 | [![Coverage Status](https://coveralls.io/repos/github/nerves-networking/vintage_net_wifi/badge.svg)](https://coveralls.io/github/nerves-networking/vintage_net_wifi) 7 | [![REUSE status](https://api.reuse.software/badge/github.com/nerves-networking/vintage_net_wifi)](https://api.reuse.software/info/github.com/nerves-networking/vintage_net_wifi) 8 | 9 | `VintageNetWiFi` makes it easy to add WiFi support for your device. This can be 10 | as simple as connecting to a WiFi access point or starting a WiFi access point 11 | so that other computers can connect directly. 12 | 13 | You will need a WiFi module to use this library. If you're using Nerves, the 14 | official Raspberry Pi and Beaglebone systems contain WiFi drivers for built-in 15 | modules. If you are using a USB WiFi module, make sure that the Linux device 16 | driver for that module is loaded and any required firmware is available. 17 | 18 | Once that's done, all that you need to do is add `:vintage_net_wifi` to your 19 | `mix` dependencies like this: 20 | 21 | ```elixir 22 | def deps do 23 | [ 24 | {:vintage_net_wifi, "~> 0.12.0", targets: @all_targets} 25 | ] 26 | end 27 | ``` 28 | 29 | > VintageNetWiFi also requires that the `wpa_supplicant` package and necessary 30 | > WiFi kernel modules are included in the system. All officially supported 31 | > Nerves systems that run on hardware with WiFI should work. 32 | > 33 | > In Buildroot, check that `BR2_PACKAGE_WPA_SUPPLICANT` is enabled. Even if you 34 | > don't plan to use WPA3, enable `BR2_PACKAGE_WPA_SUPPLICANT_WPA3` as well so 35 | > that the generic WiFi configurations don't fail due to parse errors. 36 | > 37 | > If you are using access point mode, check that `CONFIG_UDHCPD` is enabled 38 | > in Busybox and `BR2_PACKAGE_WPA_SUPPLICANT_HOTSPOT` is enabled in Buildroot. 39 | 40 | ## Usage 41 | 42 | The easiest way to configure WiFi is to using 43 | `VintageNetWiFi.quick_configure/2`. For example: 44 | 45 | ```elixir 46 | iex> VintageNetWiFi.quick_configure("my_access_point", "secret_passphrase") 47 | :ok 48 | ``` 49 | 50 | Using `VintageNet.info` to check whether you're connected. If there's no 51 | connection and you think there should be one, try watching the logs. On Nerves, 52 | the normal ways are to run `RingLogger.next`, `RingLogger.viewer` or 53 | `log_attach`/`log_detach` from an IEx prompt. (Hopefully the console or a wired 54 | network interface works) 55 | 56 | The second easiest way to create WiFi configurations is to use the helper 57 | functions in `VintageNetWiFi.Cookbook`. Check out the module documentation for 58 | the various configurations. 59 | 60 | See the `VintageNetWiFi.quick_configure/2` documentation for details on WPA3 61 | support. 62 | 63 | ## Advanced usage 64 | 65 | WiFi network interfaces typically have names like `"wlan0"` or `"wlan1"` when 66 | using Nerves. Most of the time, there's only one WiFi interface and its 67 | `"wlan0"`. Some WiFi adapters expose separate interfaces for 2.4 GHz and 5 GHz 68 | and they can be configured independently. 69 | 70 | An example WiFi configuration looks like this: 71 | 72 | ```elixir 73 | config :vintage_net, 74 | config: [ 75 | {"wlan0", 76 | %{ 77 | type: VintageNetWiFi, 78 | vintage_net_wifi: %{ 79 | networks: [ 80 | %{ 81 | key_mgmt: :wpa_psk, 82 | ssid: "my_network_ssid", 83 | psk: "a_passphrase_or_psk", 84 | } 85 | ] 86 | }, 87 | ipv4: %{method: :dhcp}, 88 | } 89 | } 90 | ] 91 | ``` 92 | 93 | The `:ipv4` key is handled by `vintage_net` to set the IP address on the 94 | connection. Most of the time, you'll want to use DHCP to dynamically get an IP 95 | address. 96 | 97 | The `:vintage_net_wifi` key has the following common fields: 98 | 99 | * `:ap_scan` - See `wpa_supplicant` documentation. The default for this, 1, 100 | should work for nearly all users. 101 | * `:bgscan` - Periodic background scanning to support roaming within an ESS. 102 | * `:simple` 103 | * `{:simple, args}` - args is a string to be passed to the `simple` wpa module 104 | * `:learn` 105 | * `{:learn, args}` args is a string to be passed to the `learn` wpa module 106 | * `:passive_scan` 107 | * 0: Do normal scans (allow active scans) (default) 108 | * 1: Do passive scans. 109 | * `:regulatory_domain`: Two character country code. Technology configuration 110 | will take priority over Application configuration 111 | * `:networks` - A list of Wi-Fi networks to configure. In client mode, 112 | VintageNet connects to the first available network in the list. In host mode, 113 | the list should have one entry with SSID and password information. 114 | * `:mode` - 115 | * `:infrastructure` (default) - Normal operation. Associate with an AP 116 | * `:ap` - access point mode 117 | * `:ibss` - peer to peer mode (not supported) 118 | * `:p2p_go` - P2P Go mode (not supported) 119 | * `:p2p_group_formation` - P2P Group Formation mode (not supported) 120 | * `:mesh` - mesh mode 121 | * `:ssid` - The SSID for the network 122 | * `:key_mgmt` - WiFi security mode (`:wpa_psk` for WPA2, `:none` for no 123 | password or WEP, `:sae` for pure WPA3, or `:wpa_psk_sha256` for WPA2 with 124 | SHA256). Not used if `:allowed_key_mgmt` is set. 125 | * `:allowed_key_mgmt` - A list of allowed WiFi security modes. See `:key_mgmt` 126 | for options. Supported in v0.12.1+. VintageNetWiFi's configuration 127 | normalizer automatically sets `:key_mgmt` to the first option in the list 128 | for backwards compatibility with v0.12.0 and earlier. 129 | * `:psk` - A WPA2 passphrase or the raw PSK. If a passphrase is passed in, it 130 | will be converted to a PSK and discarded. 131 | * `:sae_password` - A password for use with SAE authentication. This is 132 | similar to a passphrase that you could supply to `:psk`, but it has less 133 | length restrictions. 134 | * `:priority` - The priority to set for a network if you are using multiple 135 | network configurations 136 | * `:scan_ssid` - Scan with SSID-specific Probe Request frames (this can be 137 | used to find APs that do not accept broadcast SSID or use multiple SSIDs; 138 | this will add latency to scanning, so enable this only when needed) 139 | * `:frequency` - When in `:ibss` or `:ap` mode, use this channel frequency 140 | (in MHz). For example, specify 2412 for channel 1. 141 | * `:ieee80211w` - Whether management frame protection is enabled. Set to `0`, 142 | `1`, `2` or `:disabled`, `:optional`, `:required`. 143 | 144 | These keys fairly directly map to the keys in the [official 145 | docs](https://w1.fi/cgit/hostap/plain/wpa_supplicant/wpa_supplicant.conf). 146 | `VintageNetWiFi` performs some checks on the keys to avoid typos and other easy 147 | mistakes from breaking the `wpa_supplicant.conf` file. To inspect the generated 148 | configuration, run `File.read("/tmp/vintage_net/wpa_supplicant.conf.wlan0")`. 149 | 150 | If you do not want VintageNetWiFi to generate a `wpa_supplicant.conf` file for 151 | you, you can specify the contents for yourself by using the 152 | `:wpa_supplicant_conf` key. For example, 153 | 154 | ```elixir 155 | iex> VintageNet.configure("wlan0", %{ 156 | type: VintageNetWiFi, 157 | vintage_net_wifi: %{ 158 | wpa_supplicant_conf: """ 159 | network={ 160 | ssid="home" 161 | key_mgmt=WPA-PSK 162 | psk="very secret passphrase" 163 | } 164 | """ 165 | }, 166 | ipv4: %{method: :dhcp} 167 | }) 168 | ``` 169 | 170 | Please note that the syntax of the `:wpa_supplicant_conf` key is **NOT** 171 | validated by VintageNet and we do not recommend them method unless you are 172 | troubleshooting the `wpa_supplicant` or are working on a new feature. 173 | 174 | WPA PSK example: 175 | 176 | ```elixir 177 | iex> VintageNet.configure("wlan0", %{ 178 | type: VintageNetWiFi, 179 | vintage_net_wifi: %{ 180 | networks: [ 181 | %{ 182 | key_mgmt: :wpa_psk, 183 | psk: "a_passphrase_or_psk", 184 | ssid: "my_network_ssid" 185 | } 186 | ] 187 | }, 188 | ipv4: %{method: :dhcp} 189 | }) 190 | ``` 191 | 192 | WEP example: 193 | 194 | ```elixir 195 | iex> VintageNet.configure("wlan0", %{ 196 | type: VintageNetWiFi, 197 | vintage_net_wifi: %{ 198 | networks: [ 199 | %{ 200 | ssid: "my_network_ssid", 201 | wep_key0: "42FEEDDEAFBABEDEAFBEEFAA55", 202 | key_mgmt: :none, 203 | wep_tx_keyidx: 0 204 | } 205 | ] 206 | }, 207 | ipv4: %{method: :dhcp} 208 | }) 209 | ``` 210 | 211 | WPA3-only example: 212 | 213 | ```elixir 214 | iex> VintageNet.configure("wlan0", %{ 215 | type: VintageNetWiFi, 216 | ipv4: %{method: :dhcp}, 217 | vintage_net_wifi: %{ 218 | networks: [ 219 | %{ 220 | key_mgmt: :sae, 221 | ssid: "my_network_ssid", 222 | sae_password: "a_password", 223 | ieee80211w: 2 224 | } 225 | ] 226 | } 227 | }) 228 | ``` 229 | 230 | WPA2 w/ SHA256 example: 231 | 232 | ```elixir 233 | iex> VintageNet.configure("wlan0", %{ 234 | type: VintageNetWiFi, 235 | ipv4: %{method: :dhcp}, 236 | vintage_net_wifi: %{ 237 | networks: [ 238 | %{ 239 | key_mgmt: :wpa_psk_sha256, 240 | ssid: "my_network_ssid", 241 | psk: "a_password", 242 | ieee80211w: 2 243 | } 244 | ] 245 | } 246 | }) 247 | ``` 248 | 249 | Enterprise Wi-Fi (WPA-EAP) support mostly passes through to the 250 | `wpa_supplicant`. Instructions for enterprise network for Linux should map. For 251 | example: 252 | 253 | ```elixir 254 | iex> VintageNet.configure("wlan0", %{ 255 | type: VintageNetWiFi, 256 | vintage_net_wifi: %{ 257 | networks: [ 258 | %{ 259 | ssid: "testing", 260 | key_mgmt: :wpa_eap, 261 | pairwise: "CCMP TKIP", 262 | group: "CCMP TKIP", 263 | eap: "PEAP", 264 | identity: "user1", 265 | password: "supersecret", 266 | phase1: "peapver=auto", 267 | phase2: "MSCHAPV2" 268 | } 269 | ] 270 | }, 271 | ipv4: %{method: :dhcp} 272 | }) 273 | ``` 274 | 275 | Network adapters that can run as an Access Point can be configured as follows: 276 | 277 | ```elixir 278 | iex> VintageNet.configure("wlan0", %{ 279 | type: VintageNetWiFi, 280 | vintage_net_wifi: %{ 281 | networks: [ 282 | %{ 283 | mode: :ap, 284 | ssid: "test ssid", 285 | key_mgmt: :none 286 | } 287 | ] 288 | }, 289 | ipv4: %{ 290 | method: :static, 291 | address: "192.168.24.1", 292 | netmask: "255.255.255.0" 293 | }, 294 | dhcpd: %{ 295 | start: "192.168.24.2", 296 | end: "192.168.24.10", 297 | options: %{ 298 | dns: ["1.1.1.1", "1.0.0.1"], 299 | subnet: "255.255.255.0", 300 | router: ["192.168.24.1"] 301 | } 302 | } 303 | }) 304 | ``` 305 | 306 | An example of configuring the Access Point's frequency is as follows. Consult 307 | [WLAN Channels](https://en.wikipedia.org/wiki/List_of_WLAN_channels) for the 308 | appropriate channels for your device. 309 | 310 | ```elixir 311 | iex> VintageNet.configure("wlan0", %{ 312 | type: VintageNetWiFi, 313 | vintage_net_wifi: %{ 314 | networks: [ 315 | %{ 316 | mode: :ap, 317 | ssid: "test ssid", 318 | frequency: 5180, # Creates a 5 GHz wifi network if supported by device 319 | key_mgmt: :none 320 | } 321 | ] 322 | }, 323 | ipv4: %{ 324 | method: :static, 325 | address: "192.168.24.1", 326 | netmask: "255.255.255.0" 327 | }, 328 | dhcpd: %{ 329 | start: "192.168.24.2", 330 | end: "192.168.24.10", 331 | options: %{ 332 | dns: ["1.1.1.1", "1.0.0.1"], 333 | subnet: "255.255.255.0", 334 | router: ["192.168.24.1"] 335 | } 336 | } 337 | }) 338 | ``` 339 | 340 | If your device may be installed in different countries, you should override the 341 | default regulatory domain to the desired country at runtime. VintageNet uses 342 | the global domain by default and that will restrict the set of available WiFi 343 | frequencies in some countries. For example: 344 | 345 | ```elixir 346 | iex> VintageNet.configure("wlan0", %{ 347 | type: VintageNetWiFi, 348 | vintage_net_wifi: %{ 349 | regulatory_domain: "US", 350 | networks: [ 351 | %{ 352 | ssid: "testing", 353 | key_mgmt: :wpa_psk, 354 | psk: "super secret" 355 | } 356 | ] 357 | }, 358 | ipv4: %{method: :dhcp} 359 | }) 360 | ``` 361 | 362 | Network adapters that can be configured to support 80211s mesh networking can be 363 | configured as follows: 364 | 365 | (Raspberry Pi internal WiFi modules do **not** support 80211s meshing) 366 | 367 | ```elixir 368 | VintageNet.configure("mesh0", %{ 369 | type: VintageNetWiFi, 370 | vintage_net_wifi: %{ 371 | user_mpm: 1, 372 | networks: [ 373 | %{ 374 | ssid: "my-mesh", 375 | key_mgmt: :none, 376 | mode: :mesh 377 | } 378 | ] 379 | } 380 | }) 381 | ``` 382 | 383 | Mesh nodes connected to external networks can set so called "meshgate" params. 384 | See [this document](https://github.com/o11s/open80211s/wiki/HOWTO#mesh-gate) for 385 | more information 386 | 387 | ```elixir 388 | VintageNet.configure("mesh0", %{ 389 | type: VintageNetWiFi, 390 | vintage_net_wifi: %{ 391 | user_mpm: 1, 392 | networks: [ 393 | %{ 394 | ssid: mesh_id, 395 | key_mgmt: :none, 396 | mode: :mesh, 397 | mesh_hwmp_rootmode: 4, 398 | mesh_gate_announcements: 1 399 | } 400 | ] 401 | } 402 | }) 403 | ``` 404 | 405 | Note that the example mesh configuration does not contain IP address settings. 406 | All standard IP schemes are acceptable, but which one to use depends on the 407 | network configuration. The simplest way to test the mesh network is to have 408 | every node configure a static predictable IP address. DHCP will also work, but 409 | this forces a "client/server" configuration meaning that nodes joining the 410 | network will need to decide if they should be a DHCP server or client. 411 | 412 | ## Properties 413 | 414 | In addition to the common `vintage_net` properties for all interface types, this 415 | technology reports the following: 416 | 417 | Property | Values | Description 418 | -------------- | ---------------- | ----------- 419 | `access_points` | [%AccessPoint{}] | A list of access points as found by the most recent scan 420 | `clients` | ["11:22:33:44:55:66"] | A list of clients connected to the access point when using `mode: :ap` 421 | `current_ap` | %AccessPoint{} | The currently associated access point 422 | `peers` | [%MeshPeer{}] | a list of mesh peers that the current node knows about when using `mode: :mesh` 423 | `event` | %Event{} | WiFi control events not otherwise handled 424 | 425 | Access points are identified by their BSSID. Information about an access point 426 | has the following form: 427 | 428 | ```elixir 429 | %VintageNetWiFi.AccessPoint{ 430 | band: :wifi_5_ghz, 431 | bssid: "8a:8a:20:88:7a:50", 432 | channel: 149, 433 | flags: [:wpa2_psk_ccmp, :ess], 434 | frequency: 5745, 435 | signal_dbm: -76, 436 | signal_percent: 57, 437 | ssid: "MyNetwork" 438 | } 439 | ``` 440 | 441 | Mesh peers are identified by their BSSID. Information about a peer has the following form: 442 | 443 | ```elixir 444 | %VintageNetWiFi.MeshPeer{ 445 | active_path_selection_metric_id: 1, 446 | active_path_selection_protocol_id: 1, 447 | age: 2339, 448 | authentication_protocol_id: 0, 449 | band: :wifi_2_4_ghz, 450 | beacon_int: 1000, 451 | bss_basic_rate_set: "10 20 55 110 60 120 240", 452 | bssid: "f8:a2:d6:b5:d4:07", 453 | capabilities: 0, 454 | channel: 5, 455 | congestion_control_mode_id: 0, 456 | est_throughput: 65000, 457 | flags: [:mesh], 458 | frequency: 2432, 459 | id: 7, 460 | mesh_capability: 9, 461 | mesh_formation_info: 2, 462 | mesh_id: "my-mesh", 463 | noise_dbm: -89, 464 | quality: 0, 465 | signal_dbm: -27, 466 | signal_percent: 97, 467 | snr: 62, 468 | ssid: "my-mesh", 469 | synchronization_method_id: 1 470 | } 471 | ``` 472 | 473 | Applications can scan for access points in a couple ways. The first is to call 474 | `VintageNet.scan("wlan0")`, wait for a second, and then call 475 | `VintageNet.get(["interface", "wlan0", "wifi", "access_points"])`. This works 476 | for scanning networks once or twice. A better way is to subscribe to the 477 | `"access_points"` property and then call `VintageNet.scan("wlan0")` on a timer. 478 | The `"access_points"` property updates as soon as the WiFi module notifies that 479 | it is complete so applications don't need to guess how long to wait. 480 | 481 | If you're using `RingLogger` (which is the default for Nerves) then you probably 482 | also want to call `RingLogger.attach` to receive any logs in your terminal which 483 | may include information about the wifi connection. 484 | 485 | ### Events 486 | 487 | Some `wpa_supplicant` events like `CTRL-EVENT-ASSOC-REJECT` are passed on 488 | through the "event" property to be handled outside `VintageNetWiFi`. These 489 | events might be useful, but optional. 490 | 491 | ## Signal quality info in STA (client) mode 492 | 493 | You can send `ioctl` command to get information about signal level, quality and 494 | other info when connected to network in STA mode. Run: 495 | 496 | ```elixir 497 | VintageNet.ioctl("wlan0", :signal_poll) 498 | ``` 499 | 500 | Example output: 501 | 502 | ```elixir 503 | {:ok, %VintageNetWiFi.SignalInfo{ 504 | center_frequency1: 2462, 505 | center_frequency2: 0, 506 | frequency: 2472, 507 | linkspeed: 300, 508 | signal_dbm: -32, 509 | signal_percent: 94, 510 | width: "40 MHz" 511 | }} 512 | ``` 513 | 514 | ## Debugging 515 | 516 | Unfortunately, when you're getting started for the very first time, WiFi can be 517 | quite frustrating. Error messages and logs are not all that helpful. The first 518 | debugging step is to connect to your device (over a UART or USB Gadget or maybe 519 | a wired Ethernet connection). Run: 520 | 521 | ```elixir 522 | iex> VintageNet.info 523 | ``` 524 | 525 | Double check that all of your parameters are set correctly. The `:psk` cannot be 526 | checked here, so if you suspect that's wrong, double check your `config.exs`. 527 | The next step is to look at log messages for connection errors. On Nerves 528 | devices, run `RingLogger.next` at the `IEx` prompt. 529 | -------------------------------------------------------------------------------- /REUSE.toml: -------------------------------------------------------------------------------- 1 | version = 1 2 | 3 | [[annotations]] 4 | path = [ 5 | ".circleci/config.yml", 6 | ".credo.exs", 7 | ".formatter.exs", 8 | ".github/dependabot.yml", 9 | ".gitignore", 10 | "CHANGELOG.md", 11 | "NOTICE", 12 | "REUSE.toml", 13 | "mix.exs", 14 | "mix.lock" 15 | ] 16 | precedence = "aggregate" 17 | SPDX-FileCopyrightText = "None" 18 | SPDX-License-Identifier = "CC0-1.0" 19 | 20 | [[annotations]] 21 | path = [ 22 | "README.md" 23 | ] 24 | precedence = "aggregate" 25 | SPDX-FileCopyrightText = "2019 Frank Hunleth" 26 | SPDX-License-Identifier = "CC-BY-4.0" 27 | 28 | [[annotations]] 29 | path = [ 30 | "assets/logo.png", 31 | ] 32 | precedence = "aggregate" 33 | SPDX-FileCopyrightText = "None" 34 | SPDX-License-Identifier = "CC0-1.0" 35 | -------------------------------------------------------------------------------- /assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nerves-networking/vintage_net_wifi/8cf1e5287103bdcffb2335d02b7dc3eda242ab6a/assets/logo.png -------------------------------------------------------------------------------- /c_src/force_ap_scan.c: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2019 Frank Hunleth 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | // 5 | /* 6 | * Licensed under the Apache License, Version 2.0 (the "License"); 7 | * you may not use this file except in compliance with the License. 8 | * You may obtain a copy of the License at 9 | * 10 | * http://www.apache.org/licenses/LICENSE-2.0 11 | * 12 | * Unless required by applicable law or agreed to in writing, software 13 | * distributed under the License is distributed on an "AS IS" BASIS, 14 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | * See the License for the specific language governing permissions and 16 | * limitations under the License. 17 | */ 18 | 19 | #include 20 | #include 21 | #include 22 | 23 | #include 24 | #include 25 | #include 26 | 27 | #include 28 | 29 | /** 30 | * Initiate a WiFi access point scan even when an adapter is in AP mode 31 | * 32 | * wpa_supplicant doesn't support setting the flag that makes this possible, 33 | * so this is a short utility to do it. 34 | */ 35 | int main(int argc, char **argv) 36 | { 37 | if (argc != 2) 38 | errx(EXIT_FAILURE, "Specify a WiFi network device"); 39 | 40 | uint32_t ifindex = if_nametoindex(argv[1]); 41 | if (ifindex == 0) 42 | errx(EXIT_FAILURE, "Specify a WiFi device that works: %s", argv[1]); 43 | 44 | struct nl_sock *nl_sock = nl_socket_alloc(); 45 | if (!nl_sock) 46 | err(EXIT_FAILURE, "nl_socket_alloc"); 47 | 48 | if (genl_connect(nl_sock)) 49 | err(EXIT_FAILURE, "genl_connect"); 50 | 51 | int nl80211_id = genl_ctrl_resolve(nl_sock, "nl80211"); 52 | if (nl80211_id < 0) 53 | err(EXIT_FAILURE, "genl_ctrl_resolve(nl80211)"); 54 | 55 | struct nl_msg *msg = nlmsg_alloc(); 56 | if (!msg) 57 | err(EXIT_FAILURE, "nlmsg_alloc"); 58 | 59 | // msg, port, seq, family, hdrlen, flags, cmd, version 60 | genlmsg_put(msg, 0, 0, nl80211_id, 0, 0, NL80211_CMD_TRIGGER_SCAN, 0); 61 | 62 | uint32_t data; 63 | data = ifindex; 64 | nla_put(msg, NL80211_ATTR_IFINDEX, sizeof(data), &data); 65 | 66 | data = NL80211_SCAN_FLAG_AP; 67 | nla_put(msg, NL80211_ATTR_SCAN_FLAGS, sizeof(data), &data); 68 | 69 | if (nl_send_auto(nl_sock, msg) < 0) 70 | err(EXIT_FAILURE, "nl_send_auto"); 71 | 72 | nlmsg_free(msg); 73 | nl_socket_free(nl_sock); 74 | return 0; 75 | } 76 | -------------------------------------------------------------------------------- /c_src/mesh_mode.c: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Connor Rigby 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | // 5 | /* 6 | * Licensed under the Apache License, Version 2.0 (the "License"); 7 | * you may not use this file except in compliance with the License. 8 | * You may obtain a copy of the License at 9 | * 10 | * http://www.apache.org/licenses/LICENSE-2.0 11 | * 12 | * Unless required by applicable law or agreed to in writing, software 13 | * distributed under the License is distributed on an "AS IS" BASIS, 14 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | * See the License for the specific language governing permissions and 16 | * limitations under the License. 17 | */ 18 | 19 | #include 20 | #include 21 | #include 22 | 23 | #include 24 | #include 25 | #include 26 | 27 | #include 28 | 29 | /** 30 | * Create a virtual mesh interface from a real interface. 31 | * 32 | */ 33 | int main(int argc, char **argv) 34 | { 35 | if (argc != 4) 36 | errx(EXIT_FAILURE, "Specify a WiFi network device and the name of the mesh interface"); 37 | 38 | uint32_t ifindex = if_nametoindex(argv[1]); 39 | if (ifindex == 0) 40 | errx(EXIT_FAILURE, "Specify a WiFi device that works: %s", argv[1]); 41 | 42 | char *name = argv[2]; 43 | char *cmd = argv[3]; 44 | 45 | struct nl_sock *nl_sock = nl_socket_alloc(); 46 | if (!nl_sock) 47 | err(EXIT_FAILURE, "nl_socket_alloc"); 48 | 49 | if (genl_connect(nl_sock)) 50 | err(EXIT_FAILURE, "genl_connect"); 51 | 52 | int nl80211_id = genl_ctrl_resolve(nl_sock, "nl80211"); 53 | if (nl80211_id < 0) 54 | err(EXIT_FAILURE, "genl_ctrl_resolve(nl80211)"); 55 | 56 | struct nl_msg *msg = nlmsg_alloc(); 57 | if (!msg) 58 | err(EXIT_FAILURE, "nlmsg_alloc"); 59 | 60 | uint32_t data; 61 | 62 | // msg, port, seq, family, hdrlen, flags, cmd, version 63 | if(strcmp(cmd, "add") == 0) { 64 | data = ifindex; 65 | genlmsg_put(msg, 0, 0, nl80211_id, 0, 0, NL80211_CMD_NEW_INTERFACE, 0); 66 | } else if(strcmp(cmd, "del") == 0) { 67 | data = if_nametoindex(name); 68 | genlmsg_put(msg, 0, 0, nl80211_id, 0, 0, NL80211_CMD_DEL_INTERFACE, 0); 69 | } else { 70 | err(EXIT_FAILURE, "unknown cmd %s", cmd); 71 | } 72 | 73 | nla_put(msg, NL80211_ATTR_IFINDEX, sizeof(data), &data); 74 | 75 | nla_put_string(msg, NL80211_ATTR_IFNAME, name); 76 | 77 | data = NL80211_IFTYPE_MESH_POINT; 78 | nla_put(msg, NL80211_ATTR_IFTYPE, sizeof(data), &data); 79 | 80 | if (nl_send_auto(nl_sock, msg) < 0) 81 | err(EXIT_FAILURE, "nl_send_auto"); 82 | 83 | nlmsg_free(msg); 84 | nl_socket_free(nl_sock); 85 | return 0; 86 | } 87 | -------------------------------------------------------------------------------- /c_src/mesh_param.c: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Connor Rigby 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | // 5 | /* 6 | * Licensed under the Apache License, Version 2.0 (the "License"); 7 | * you may not use this file except in compliance with the License. 8 | * You may obtain a copy of the License at 9 | * 10 | * http://www.apache.org/licenses/LICENSE-2.0 11 | * 12 | * Unless required by applicable law or agreed to in writing, software 13 | * distributed under the License is distributed on an "AS IS" BASIS, 14 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | * See the License for the specific language governing permissions and 16 | * limitations under the License. 17 | */ 18 | 19 | #include 20 | #include 21 | #include 22 | 23 | #include 24 | #include 25 | #include 26 | 27 | #include 28 | 29 | /** 30 | * Create a virtual mesh interface from a real interface. 31 | * useage: mesh_param meshifname param value 32 | * 33 | */ 34 | int main(int argc, char **argv) 35 | { 36 | if (argc != 4) 37 | errx(EXIT_FAILURE, "Specify a WiFi network device, a param and a value"); 38 | 39 | uint32_t ifindex = if_nametoindex(argv[1]); 40 | if (ifindex == 0) 41 | errx(EXIT_FAILURE, "Specify a WiFi device that works: %s", argv[1]); 42 | 43 | struct nl_sock *nl_sock = nl_socket_alloc(); 44 | if (!nl_sock) 45 | err(EXIT_FAILURE, "nl_socket_alloc"); 46 | 47 | if (genl_connect(nl_sock)) 48 | err(EXIT_FAILURE, "genl_connect"); 49 | 50 | int nl80211_id = genl_ctrl_resolve(nl_sock, "nl80211"); 51 | if (nl80211_id < 0) 52 | err(EXIT_FAILURE, "genl_ctrl_resolve(nl80211)"); 53 | 54 | struct nl_msg *msg = nlmsg_alloc(); 55 | if (!msg) 56 | err(EXIT_FAILURE, "nlmsg_alloc"); 57 | 58 | char *param = argv[2]; 59 | char *param_value = argv[3]; 60 | 61 | uint8_t data; 62 | struct nlattr *container; 63 | uint32_t ret; 64 | enum nl80211_meshconf_params cmd; 65 | 66 | if(strcmp(param, "mesh_hwmp_rootmode") == 0) { 67 | cmd = NL80211_MESHCONF_HWMP_ROOTMODE; 68 | data = strtol(param_value, NULL, 10); 69 | 70 | } else if(strcmp(param, "mesh_gate_announcements") == 0) { 71 | cmd = NL80211_MESHCONF_GATE_ANNOUNCEMENTS; 72 | data = strtol(param_value, NULL, 10); 73 | 74 | } else { 75 | err(EXIT_FAILURE, "unknown mesh param %s", param); 76 | } 77 | 78 | // msg, port, seq, family, hdrlen, flags, cmd, version 79 | genlmsg_put(msg, 0, 0, nl80211_id, 0, 0, NL80211_CMD_SET_MESH_PARAMS, 0); 80 | 81 | // Tell nl which interface we are using 82 | nla_put(msg, NL80211_ATTR_IFINDEX, sizeof(ifindex), &ifindex); 83 | 84 | // container for nested attrs 85 | container = nla_nest_start(msg, NL80211_ATTR_MESH_PARAMS); 86 | if (!container) 87 | err(EXIT_FAILURE, "nla_nest_start"); 88 | 89 | ret = nla_put(msg, cmd, sizeof(uint8_t), &data); 90 | if(ret) 91 | err(EXIT_FAILURE, "nla_put"); 92 | 93 | nla_nest_end(msg, container); 94 | 95 | if (nl_send_auto(nl_sock, msg) < 0) 96 | err(EXIT_FAILURE, "nl_send_auto"); 97 | 98 | nlmsg_free(msg); 99 | nl_socket_free(nl_sock); 100 | return 0; 101 | } 102 | -------------------------------------------------------------------------------- /c_src/test-c99.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # SPDX-FileCopyrightText: 2019 Frank Hunleth 3 | # 4 | # SPDX-License-Identifier: Apache-2.0 5 | # 6 | 7 | if [ -z $CC ]; then 8 | CC=cc 9 | fi 10 | 11 | # See Makefile 12 | $CC $CFLAGS -std=c99 -D_XOPEN_SOURCE=600 -o /dev/null -xc - 2>/dev/null < 16 | #include 17 | #include 18 | #include 19 | #include 20 | #include 21 | #include 22 | 23 | #include 24 | #include 25 | #include 26 | #include 27 | #include 28 | #include 29 | #include 30 | #include 31 | 32 | int main(int argc,char *argv[]) { 33 | return IFF_UP; 34 | } 35 | EOF 36 | if [ "$?" = "0" ]; then 37 | printf "yes" 38 | fi 39 | 40 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2019 Frank Hunleth 2 | # SPDX-FileCopyrightText: 2020 Matt Ludwigs 3 | # 4 | # SPDX-License-Identifier: Apache-2.0 5 | # 6 | import Config 7 | 8 | # Overrides for unit tests: 9 | # 10 | # * resolvconf: don't update the real resolv.conf 11 | # * persistence_dir: use the current directory 12 | config :vintage_net, 13 | resolvconf: "/dev/null", 14 | persistence_dir: "./test_tmp/persistence", 15 | path: "#{File.cwd!()}/test/fixtures/root/bin" 16 | -------------------------------------------------------------------------------- /lib/vintage_net_wifi/access_point.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2019 Frank Hunleth 2 | # SPDX-FileCopyrightText: 2021 Connor Rigby 3 | # SPDX-FileCopyrightText: 2021 Dömötör Gulyás 4 | # 5 | # SPDX-License-Identifier: Apache-2.0 6 | # 7 | defmodule VintageNetWiFi.AccessPoint do 8 | @moduledoc """ 9 | Information about a WiFi access point 10 | 11 | * `:bssid` - a unique address for the access point 12 | * `:flags` - a list of flags describing properties on the access point 13 | * `:frequency` - the access point's frequency in MHz 14 | * `:signal_dbm` - the signal strength in dBm 15 | * `:ssid` - the access point's name 16 | """ 17 | alias VintageNetWiFi.Utils 18 | 19 | @typedoc """ 20 | Access point flags 21 | 22 | These flags generally describe the security supported by an access point, but do contain some other details. They're 23 | directly passed on from `wpa_supplicant` reports. 24 | """ 25 | @type flag :: 26 | :ccmp 27 | | :eap 28 | | :ess 29 | | :ibss 30 | | :mesh 31 | | :p2p 32 | | :psk 33 | | :rsn_ccmp 34 | | :sae 35 | | :tkip 36 | | :wep 37 | | :wpa 38 | | :wpa2 39 | | :wps 40 | | old_flag() 41 | 42 | @typedoc """ 43 | Old-style access point flags 44 | 45 | Early on with `vintage_net_wifi`, flags were not broken up and returned in a list. The WPA3 support made this approach unmaintainable, but these are 46 | kept to avoid breaking code. New code should avoid using these. 47 | """ 48 | @type old_flag :: 49 | :wpa2_psk_ccmp 50 | | :wpa2_eap_ccmp 51 | | :wpa2_eap_ccmp_tkip 52 | | :wpa2_psk_ccmp_tkip 53 | | :wpa2_psk_sae_ccmp 54 | | :wpa2_sae_ccmp 55 | | :wpa2_ccmp 56 | | :wpa_psk_ccmp 57 | | :wpa_psk_ccmp_tkip 58 | | :wpa_eap_ccmp 59 | | :wpa_eap_ccmp_tkip 60 | 61 | @type band :: :wifi_2_4_ghz | :wifi_5_ghz | :unknown 62 | 63 | defstruct [:bssid, :frequency, :band, :channel, :signal_dbm, :signal_percent, :flags, :ssid] 64 | 65 | @type t :: %__MODULE__{ 66 | bssid: String.t(), 67 | frequency: non_neg_integer(), 68 | band: band(), 69 | channel: non_neg_integer(), 70 | signal_dbm: integer(), 71 | signal_percent: 0..100, 72 | flags: [flag()], 73 | ssid: String.t() 74 | } 75 | 76 | @doc """ 77 | Create an AccessPoint when only the BSSID is known 78 | """ 79 | @spec new(any) :: VintageNetWiFi.AccessPoint.t() 80 | def new(bssid) do 81 | %__MODULE__{ 82 | bssid: bssid, 83 | frequency: 0, 84 | band: :unknown, 85 | channel: 0, 86 | signal_dbm: -99, 87 | signal_percent: 0, 88 | flags: [], 89 | ssid: "" 90 | } 91 | end 92 | 93 | @doc """ 94 | Create a new AccessPoint with all of the information 95 | """ 96 | @spec new(String.t(), String.t(), non_neg_integer(), integer(), [flag()]) :: 97 | VintageNetWiFi.AccessPoint.t() 98 | def new(bssid, ssid, frequency, signal_dbm, flags) do 99 | info = Utils.frequency_info(frequency) 100 | 101 | %__MODULE__{ 102 | bssid: bssid, 103 | frequency: frequency, 104 | band: info.band, 105 | channel: info.channel, 106 | signal_dbm: signal_dbm, 107 | signal_percent: info.dbm_to_percent.(signal_dbm), 108 | flags: flags, 109 | ssid: ssid 110 | } 111 | end 112 | end 113 | -------------------------------------------------------------------------------- /lib/vintage_net_wifi/bssid_requester.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2021 Frank Hunleth 2 | # 3 | # SPDX-License-Identifier: Apache-2.0 4 | # 5 | defmodule VintageNetWiFi.BSSIDRequester do 6 | @moduledoc """ 7 | Request access point information asynchronously 8 | 9 | Getting access point information is important, but it's easy to fall 10 | behind and start blocking more important requests. This GenServer 11 | handles this separate from the main WPASupplicant GenServer. 12 | """ 13 | use GenServer 14 | 15 | alias VintageNetWiFi.{WPASupplicantDecoder, WPASupplicantLL} 16 | require Logger 17 | 18 | @doc """ 19 | Start a GenServer 20 | 21 | Arguments: 22 | 23 | * `:ll` - the WPASupplicantLL GenServer pid 24 | * `:notification_pid` - where to send response messages 25 | """ 26 | @spec start_link(keyword()) :: GenServer.on_start() 27 | def start_link(init_args) do 28 | GenServer.start_link(__MODULE__, init_args) 29 | end 30 | 31 | @doc """ 32 | Get info on all known access points 33 | 34 | This is the get everything all at once call. Everything is sent back. 35 | If it's not known, then it's not known. 36 | """ 37 | @spec get_all_access_points(GenServer.server(), any()) :: :ok 38 | def get_all_access_points(server, cookie) when not is_nil(server) do 39 | GenServer.cast(server, {:get_all_access_points, cookie}) 40 | end 41 | 42 | @doc """ 43 | Request information on a BSSID or an access point index 44 | 45 | The response comes back to the process that started this GenServer with the 46 | details. 47 | """ 48 | @spec get_access_point_info(GenServer.server(), String.t() | non_neg_integer(), any()) :: :ok 49 | def get_access_point_info(server, index_or_bssid, cookie) when not is_nil(server) do 50 | GenServer.cast(server, {:get_access_point_info, index_or_bssid, cookie}) 51 | end 52 | 53 | @doc """ 54 | Don't bother looking up AP info 55 | 56 | This request doesn't do anything but send back a message to remove an access point. 57 | It's needed for flushing out data returned asynchronously from `get_access_point_info/2` 58 | calls 59 | """ 60 | @spec forget_access_point_info(GenServer.server(), String.t() | non_neg_integer(), any()) :: :ok 61 | def forget_access_point_info(server, index_or_bssid, cookie) when not is_nil(server) do 62 | GenServer.cast(server, {:forget_access_point_info, index_or_bssid, cookie}) 63 | end 64 | 65 | @impl GenServer 66 | def init(init_args) do 67 | state = %{ 68 | ll: Keyword.fetch!(init_args, :ll), 69 | notification_pid: Keyword.fetch!(init_args, :notification_pid) 70 | } 71 | 72 | {:ok, state} 73 | end 74 | 75 | @impl GenServer 76 | def handle_cast({:get_all_access_points, cookie}, state) do 77 | all_bss = get_all_bss(state) 78 | send_result(state, all_bss, cookie) 79 | {:noreply, state} 80 | end 81 | 82 | def handle_cast({:get_access_point_info, index_or_bssid, cookie}, state) do 83 | case make_bss_request(state, index_or_bssid) do 84 | {:ok, ap_or_peer} -> 85 | send_result(state, ap_or_peer, cookie) 86 | 87 | {:error, reason} -> 88 | Logger.warning( 89 | "Ignoring error getting info on BSSID #{inspect(index_or_bssid)}: #{inspect(reason)}" 90 | ) 91 | end 92 | 93 | {:noreply, state} 94 | end 95 | 96 | def handle_cast({:forget_access_point_info, index_or_bssid, cookie}, state) do 97 | send_result(state, index_or_bssid, cookie) 98 | {:noreply, state} 99 | end 100 | 101 | defp send_result(state, result, cookie) do 102 | send(state.notification_pid, {:bssid_result, result, cookie}) 103 | end 104 | 105 | defp get_all_bss(state) do 106 | get_all_bss(state, 0, %{}) 107 | end 108 | 109 | defp get_all_bss(state, index, acc) do 110 | case make_bss_request(state, index) do 111 | {:ok, ap} -> 112 | get_all_bss(state, index + 1, Map.put(acc, ap.bssid, ap)) 113 | 114 | _error -> 115 | acc 116 | end 117 | end 118 | 119 | defp make_bss_request(state, index_or_bssid) do 120 | case WPASupplicantLL.control_request(state.ll, "BSS #{index_or_bssid}") do 121 | {:ok, raw_response} -> 122 | raw_response 123 | |> WPASupplicantDecoder.decode_kv_response() 124 | |> decode_bss_response() 125 | 126 | error -> 127 | error 128 | end 129 | end 130 | 131 | defp decode_bss_response(%{"mesh_id" => _} = mesh_response) do 132 | {:ok, VintageNetWiFi.MeshPeer.new(mesh_response)} 133 | end 134 | 135 | defp decode_bss_response(%{ 136 | "freq" => frequency_string, 137 | "level" => level_string, 138 | "flags" => flags_string, 139 | "ssid" => ssid, 140 | "bssid" => bssid 141 | }) do 142 | frequency = String.to_integer(frequency_string) 143 | flags = WPASupplicantDecoder.parse_flags(flags_string) 144 | signal_dbm = String.to_integer(level_string) 145 | 146 | {:ok, VintageNetWiFi.AccessPoint.new(bssid, ssid, frequency, signal_dbm, flags)} 147 | end 148 | 149 | defp decode_bss_response(_other) do 150 | {:error, :unknown} 151 | end 152 | end 153 | -------------------------------------------------------------------------------- /lib/vintage_net_wifi/cookbook.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2021 Frank Hunleth 2 | # 3 | # SPDX-License-Identifier: Apache-2.0 4 | # 5 | defmodule VintageNetWiFi.Cookbook do 6 | @moduledoc """ 7 | Recipes for common WiFi network configurations 8 | 9 | For example, if you want the standard configuration for the most common type of WiFi 10 | network (WPA2 Preshared Key networks), pass the SSID and password to `wpa_psk/2` 11 | """ 12 | 13 | alias VintageNetWiFi.WPA2 14 | 15 | @doc """ 16 | Return a generic configuration for connecting to preshared-key networks 17 | 18 | The returned configuration should be able to connect to an access point 19 | configured to use WPA3-only, WPA2/3 transitional or WPA2. The WiFi module 20 | must also support WPA3 for this to work. 21 | 22 | Pass an SSID and passphrase. If the SSID and passphrase are ok, you'll get an 23 | `:ok` tuple with the configuration. If there's a problem, you'll get an error 24 | tuple with a reason. 25 | """ 26 | @spec generic(String.t(), String.t()) :: 27 | {:ok, map()} | {:error, WPA2.invalid_ssid_error() | WPA2.invalid_passphrase_error()} 28 | def generic(ssid, passphrase) when is_binary(ssid) and is_binary(passphrase) do 29 | with :ok <- WPA2.validate_ssid(ssid), 30 | :ok <- WPA2.validate_passphrase(passphrase) do 31 | {:ok, 32 | %{ 33 | type: VintageNetWiFi, 34 | vintage_net_wifi: %{ 35 | networks: [ 36 | %{ 37 | ssid: ssid, 38 | psk: passphrase, 39 | sae_password: passphrase, 40 | key_mgmt: [:wpa_psk, :wpa_psk_sha256, :sae], 41 | ieee80211w: 1 42 | } 43 | ] 44 | }, 45 | ipv4: %{method: :dhcp} 46 | }} 47 | end 48 | end 49 | 50 | @doc """ 51 | Return a configuration for connecting to open WiFi network 52 | 53 | Pass an SSID and passphrase. If the SSID and passphrase are ok, you'll get an 54 | `:ok` tuple with the configuration. If there's a problem, you'll get an error 55 | tuple with a reason. 56 | """ 57 | @spec open_wifi(String.t()) :: {:ok, map()} | {:error, WPA2.invalid_ssid_error()} 58 | def open_wifi(ssid) when is_binary(ssid) do 59 | with :ok <- WPA2.validate_ssid(ssid) do 60 | {:ok, 61 | %{ 62 | type: VintageNetWiFi, 63 | vintage_net_wifi: %{ 64 | networks: [ 65 | %{ 66 | key_mgmt: :none, 67 | ssid: ssid 68 | } 69 | ] 70 | }, 71 | ipv4: %{method: :dhcp} 72 | }} 73 | end 74 | end 75 | 76 | @doc """ 77 | Return a configuration for connecting to a WPA-PSK network 78 | 79 | Pass an SSID and passphrase. If the SSID and passphrase are ok, you'll get an 80 | `:ok` tuple with the configuration. If there's a problem, you'll get an error 81 | tuple with a reason. 82 | """ 83 | @spec wpa_psk(String.t(), String.t()) :: 84 | {:ok, map()} | {:error, WPA2.invalid_ssid_error() | WPA2.invalid_passphrase_error()} 85 | def wpa_psk(ssid, passphrase) when is_binary(ssid) and is_binary(passphrase) do 86 | with :ok <- WPA2.validate_ssid(ssid), 87 | :ok <- WPA2.validate_passphrase(passphrase) do 88 | {:ok, 89 | %{ 90 | type: VintageNetWiFi, 91 | vintage_net_wifi: %{ 92 | networks: [ 93 | %{ 94 | key_mgmt: :wpa_psk, 95 | ssid: ssid, 96 | psk: passphrase 97 | } 98 | ] 99 | }, 100 | ipv4: %{method: :dhcp} 101 | }} 102 | end 103 | end 104 | 105 | @doc """ 106 | Return a configuration for connecting to a WPA3 network 107 | 108 | Pass an SSID and passphrase. If the SSID and passphrase are ok, you'll get an 109 | `:ok` tuple with the configuration. If there's a problem, you'll get an error 110 | tuple with a reason. 111 | """ 112 | @spec wpa3_sae(String.t(), String.t()) :: 113 | {:ok, map()} | {:error, WPA2.invalid_ssid_error() | WPA2.invalid_passphrase_error()} 114 | def wpa3_sae(ssid, passphrase) when is_binary(ssid) and is_binary(passphrase) do 115 | with :ok <- WPA2.validate_ssid(ssid), 116 | :ok <- WPA2.validate_passphrase(passphrase) do 117 | {:ok, 118 | %{ 119 | type: VintageNetWiFi, 120 | vintage_net_wifi: %{ 121 | networks: [ 122 | %{ 123 | key_mgmt: :sae, 124 | ieee80211w: 2, 125 | sae_password: passphrase, 126 | ssid: ssid 127 | } 128 | ] 129 | }, 130 | ipv4: %{method: :dhcp} 131 | }} 132 | end 133 | end 134 | 135 | @doc """ 136 | Return a configuration for connecting to a WPA-EAP PEAP network 137 | 138 | Pass an SSID and login credentials. If valid, you'll get an 139 | `:ok` tuple with the configuration. If there's a problem, you'll get an error 140 | tuple with a reason. 141 | """ 142 | @spec wpa_eap_peap(String.t(), String.t(), String.t()) :: 143 | {:ok, map()} | {:error, WPA2.invalid_ssid_error()} 144 | def wpa_eap_peap(ssid, username, passphrase) 145 | when is_binary(ssid) and is_binary(username) and is_binary(passphrase) do 146 | with :ok <- WPA2.validate_ssid(ssid) do 147 | {:ok, 148 | %{ 149 | type: VintageNetWiFi, 150 | vintage_net_wifi: %{ 151 | networks: [ 152 | %{ 153 | key_mgmt: :wpa_eap, 154 | ssid: ssid, 155 | identity: username, 156 | password: passphrase, 157 | eap: "PEAP", 158 | phase2: "auth=MSCHAPV2" 159 | } 160 | ] 161 | }, 162 | ipv4: %{method: :dhcp} 163 | }} 164 | end 165 | end 166 | 167 | @doc """ 168 | Return a configuration for creating an open access point 169 | 170 | Pass an SSID and an optional IPv4 class C network. 171 | """ 172 | @spec open_access_point(String.t(), VintageNet.any_ip_address()) :: 173 | {:ok, map()} | {:error, term()} 174 | def open_access_point(ssid, ipv4_subnet \\ "192.168.24.0") do 175 | with :ok <- WPA2.validate_ssid(ssid), 176 | {:ok, {a, b, c, _d}} <- VintageNet.IP.ip_to_tuple(ipv4_subnet) do 177 | our_address = {a, b, c, 1} 178 | dhcp_start = {a, b, c, 10} 179 | dhcp_end = {a, b, c, 250} 180 | 181 | {:ok, 182 | %{ 183 | type: VintageNetWiFi, 184 | vintage_net_wifi: %{ 185 | networks: [ 186 | %{ 187 | mode: :ap, 188 | ssid: ssid, 189 | key_mgmt: :none 190 | } 191 | ] 192 | }, 193 | ipv4: %{ 194 | method: :static, 195 | address: our_address, 196 | netmask: {255, 255, 255, 0} 197 | }, 198 | dhcpd: %{ 199 | start: dhcp_start, 200 | end: dhcp_end 201 | } 202 | }} 203 | end 204 | end 205 | end 206 | -------------------------------------------------------------------------------- /lib/vintage_net_wifi/event.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2021 Dömötör Gulyás 2 | # SPDX-FileCopyrightText: 2022 Connor Rigby 3 | # 4 | # SPDX-License-Identifier: Apache-2.0 5 | # 6 | defmodule VintageNetWiFi.Event do 7 | @moduledoc ~S""" 8 | WiFi events. 9 | 10 | Currently supported: 11 | * `CTRL-EVENT-ASSOC-REJECT` - occurs when authentication fails 12 | * `CTRL-EVENT-SSID-TEMP-DISABLED` - association with SSID is temporarily blocked by `wpa_supplicant` in some cases. 13 | * `CTRL-EVENT-SSID-REENABLED` - matching event when SSID is re-enabled. 14 | 15 | All events have a `:name`, and the other fields are optional. 16 | * `CTRL-EVENT-ASSOC-REJECT` 17 | * `:bssid` - a unique address for the access point 18 | * `:status_code` - status code of the event 19 | * `CTRL-EVENT-SSID-TEMP-DISABLED` 20 | * `:id` - event identifier? 21 | * `:ssid` - the access point's name 22 | * `:auth_failures` - how many failures occured to lead to disabling 23 | * `:duration` - time of block in seconds 24 | * `:reason` - why the SSID is disabled 25 | * `CTRL-EVENT-SSID-REENABLED` 26 | * `:id` - event identifier? 27 | * `:ssid` - the access point's name 28 | 29 | ## Examples: 30 | 31 | iex> VintageNetWiFi.Event.new("CTRL-EVENT-ASSOC-REJECT", %{"bssid" => "ab:cd:ef:01:02:03", "status_code" => "1"}) 32 | %VintageNetWiFi.Event{ 33 | name: "CTRL-EVENT-ASSOC-REJECT", 34 | bssid: "ab:cd:ef:01:02:03", 35 | status_code: 1 36 | } 37 | """ 38 | 39 | @enforce_keys [:name] 40 | 41 | defstruct [:name, :bssid, :status_code, :id, :ssid, :auth_failures, :duration, :reason] 42 | 43 | @known_params [ 44 | "name", 45 | "bssid", 46 | "status_code", 47 | "id", 48 | "ssid", 49 | "auth_failures", 50 | "duration", 51 | "reason" 52 | ] 53 | 54 | @typedoc """ 55 | WiFi event structure. 56 | """ 57 | @type t :: %__MODULE__{ 58 | name: nil | String.t(), 59 | bssid: nil | String.t(), 60 | status_code: nil | non_neg_integer(), 61 | id: nil | non_neg_integer(), 62 | ssid: String.t(), 63 | auth_failures: nil | non_neg_integer(), 64 | duration: nil | non_neg_integer(), 65 | reason: nil | String.t() 66 | } 67 | 68 | @doc """ 69 | Create an event with the appropriate fields 70 | """ 71 | @spec new(String.t(), %{optional(String.t()) => String.t() | non_neg_integer()}) :: 72 | VintageNetWiFi.Event.t() 73 | def new(name, params) 74 | 75 | def new("CTRL-EVENT-ASSOC-REJECT" = name, params) do 76 | params = sanitize_params(params) 77 | event = struct(__MODULE__, params) 78 | 79 | %__MODULE__{event | name: name} 80 | end 81 | 82 | def new("CTRL-EVENT-SSID-REENABLED" = name, params) do 83 | params = sanitize_params(params) 84 | event = struct(__MODULE__, params) 85 | 86 | %__MODULE__{event | name: name} 87 | end 88 | 89 | def new("CTRL-EVENT-SSID-TEMP-DISABLED" = name, params) do 90 | params = sanitize_params(params) 91 | event = struct(__MODULE__, params) 92 | 93 | %__MODULE__{event | name: name} 94 | end 95 | 96 | def new("CTRL-EVENT-NETWORK-NOT-FOUND" = name, _params) do 97 | %__MODULE__{name: name} 98 | end 99 | 100 | @integer_keys [ 101 | "status_code", 102 | "id", 103 | "auth_failures", 104 | "duration" 105 | ] 106 | 107 | defp sanitize_params(params) when is_map(params) do 108 | params 109 | |> Map.take(@known_params) 110 | |> Map.new(fn 111 | {key, value} when key in @integer_keys -> 112 | {String.to_existing_atom(key), String.to_integer(value)} 113 | 114 | {key, value} -> 115 | {String.to_existing_atom(key), value} 116 | end) 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /lib/vintage_net_wifi/mesh_peer.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 Connor Rigby 2 | # SPDX-FileCopyrightText: 2022 Frank Hunleth 3 | # 4 | # SPDX-License-Identifier: Apache-2.0 5 | # 6 | defmodule VintageNetWiFi.MeshPeer do 7 | @moduledoc """ 8 | Information about a WiFi mesh peer 9 | 10 | This is a superset of the fields available on VintageNetWiFi.AccessPoint. 11 | """ 12 | alias VintageNetWiFi.{AccessPoint, Utils, WPASupplicantDecoder} 13 | alias VintageNetWiFi.MeshPeer.{Capabilities, FormationInformation} 14 | 15 | defstruct [ 16 | :bssid, 17 | :frequency, 18 | :band, 19 | :channel, 20 | :signal_dbm, 21 | :signal_percent, 22 | :flags, 23 | :ssid, 24 | :active_path_selection_metric_id, 25 | :active_path_selection_protocol_id, 26 | :age, 27 | :authentication_protocol_id, 28 | :beacon_int, 29 | :bss_basic_rate_set, 30 | :capabilities, 31 | :congestion_control_mode_id, 32 | :est_throughput, 33 | :id, 34 | :mesh_capability, 35 | :mesh_formation_info, 36 | :mesh_id, 37 | :noise_dbm, 38 | :quality, 39 | :snr, 40 | :synchronization_method_id 41 | ] 42 | 43 | @type t :: %__MODULE__{ 44 | active_path_selection_metric_id: non_neg_integer(), 45 | active_path_selection_protocol_id: non_neg_integer(), 46 | age: non_neg_integer(), 47 | authentication_protocol_id: non_neg_integer(), 48 | band: AccessPoint.band(), 49 | beacon_int: non_neg_integer(), 50 | bss_basic_rate_set: String.t(), 51 | bssid: String.t(), 52 | capabilities: integer(), 53 | channel: non_neg_integer(), 54 | congestion_control_mode_id: non_neg_integer(), 55 | est_throughput: non_neg_integer(), 56 | flags: [AccessPoint.flag()], 57 | frequency: non_neg_integer(), 58 | id: non_neg_integer(), 59 | mesh_capability: Capabilities.t(), 60 | mesh_formation_info: FormationInformation.t(), 61 | mesh_id: String.t(), 62 | noise_dbm: integer(), 63 | quality: integer(), 64 | signal_dbm: integer(), 65 | signal_percent: 0..100, 66 | snr: non_neg_integer(), 67 | ssid: String.t(), 68 | synchronization_method_id: non_neg_integer() 69 | } 70 | 71 | @doc """ 72 | Create a new MeshPeer struct 73 | """ 74 | @spec new(keyword() | map()) :: t() 75 | def new(peer) do 76 | frequency = string_to_integer(peer["freq"]) 77 | signal_dbm = string_to_integer(peer["level"]) 78 | flags = WPASupplicantDecoder.parse_flags(peer["flags"]) 79 | ssid = peer["ssid"] 80 | bssid = peer["bssid"] 81 | info = Utils.frequency_info(frequency) 82 | 83 | active_path_selection_metric_id = string_to_integer(peer["active_path_selection_metric_id"]) 84 | 85 | active_path_selection_protocol_id = 86 | string_to_integer(peer["active_path_selection_protocol_id"]) 87 | 88 | age = string_to_integer(peer["age"]) 89 | authentication_protocol_id = string_to_integer(peer["authentication_protocol_id"]) 90 | beacon_int = string_to_integer(peer["beacon_int"]) 91 | bss_basic_rate_set = peer["bss_basic_rate_set"] 92 | capabilities = string_to_integer(peer["capabilities"]) 93 | congestion_control_mode_id = string_to_integer(peer["congestion_control_mode_id"]) 94 | est_throughput = string_to_integer(peer["est_throughput"]) 95 | id = string_to_integer(peer["id"]) 96 | 97 | mesh_capability = 98 | Capabilities.decode_capabilities(<>) 99 | 100 | mesh_formation_info = 101 | FormationInformation.decode_formation_information( 102 | <> 103 | ) 104 | 105 | mesh_id = peer["mesh_id"] 106 | noise_dbm = string_to_integer(peer["noise"]) 107 | quality = string_to_integer(peer["qual"]) 108 | snr = string_to_integer(peer["snr"]) 109 | synchronization_method_id = string_to_integer(peer["synchronization_method_id"]) 110 | 111 | %__MODULE__{ 112 | bssid: bssid, 113 | frequency: frequency, 114 | band: info.band, 115 | channel: info.channel, 116 | signal_dbm: signal_dbm, 117 | signal_percent: info.dbm_to_percent.(signal_dbm), 118 | flags: flags, 119 | ssid: ssid, 120 | active_path_selection_metric_id: active_path_selection_metric_id, 121 | active_path_selection_protocol_id: active_path_selection_protocol_id, 122 | age: age, 123 | authentication_protocol_id: authentication_protocol_id, 124 | beacon_int: beacon_int, 125 | bss_basic_rate_set: bss_basic_rate_set, 126 | capabilities: capabilities, 127 | congestion_control_mode_id: congestion_control_mode_id, 128 | est_throughput: est_throughput, 129 | id: id, 130 | mesh_capability: mesh_capability, 131 | mesh_formation_info: mesh_formation_info, 132 | mesh_id: mesh_id, 133 | noise_dbm: noise_dbm, 134 | quality: quality, 135 | snr: snr, 136 | synchronization_method_id: synchronization_method_id 137 | } 138 | end 139 | 140 | defp string_to_integer("0x" <> str) do 141 | String.to_integer(str, 16) 142 | end 143 | 144 | defp string_to_integer(str) do 145 | String.to_integer(str) 146 | end 147 | end 148 | -------------------------------------------------------------------------------- /lib/vintage_net_wifi/mesh_peer/capabilities.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 Connor Rigby 2 | # SPDX-FileCopyrightText: 2022 Frank Hunleth 3 | # 4 | # SPDX-License-Identifier: Apache-2.0 5 | # 6 | defmodule VintageNetWiFi.MeshPeer.Capabilities do 7 | @moduledoc """ 8 | Capabilities supported by a mesh node 9 | 10 | * `power_slave_level`: 11 | true if at least one of the peer-specific mesh 12 | power management modes is deep sleep mode 13 | * `tbtt_adjusting`: 14 | true while the TBBT adjustment procedure is ongoing. 15 | * `mbca_enabled`: 16 | true if the station is using MBCA 17 | * `forwarding`: 18 | true if the station forwards MSDUs 19 | * `mcca_enabled`: 20 | true if the station uses MCCA 21 | * `mcca_supported`: 22 | true if the station implements MCCA 23 | """ 24 | alias VintageNetWiFi.Utils 25 | 26 | @type t() :: %__MODULE__{ 27 | power_slave_level: boolean(), 28 | tbtt_adjusting: boolean(), 29 | mbca_enabled: boolean(), 30 | forwarding: boolean(), 31 | mcca_enabled: boolean(), 32 | mcca_supported: boolean(), 33 | accepting_peerings: boolean() 34 | } 35 | 36 | defstruct [ 37 | :power_slave_level, 38 | :tbtt_adjusting, 39 | :mbca_enabled, 40 | :forwarding, 41 | :mcca_enabled, 42 | :mcca_supported, 43 | :accepting_peerings 44 | ] 45 | 46 | @spec decode_capabilities(<<_::8>>) :: t() 47 | def decode_capabilities(<< 48 | _reserved::1, 49 | power_slave_level::1, 50 | tbtt_adjusting::1, 51 | mbca_enabled::1, 52 | forwarding::1, 53 | mcca_enabled::1, 54 | mcca_supported::1, 55 | accepting_peerings::1 56 | >>) do 57 | %__MODULE__{ 58 | power_slave_level: Utils.bit_to_boolean(power_slave_level), 59 | tbtt_adjusting: Utils.bit_to_boolean(tbtt_adjusting), 60 | mbca_enabled: Utils.bit_to_boolean(mbca_enabled), 61 | forwarding: Utils.bit_to_boolean(forwarding), 62 | mcca_enabled: Utils.bit_to_boolean(mcca_enabled), 63 | mcca_supported: Utils.bit_to_boolean(mcca_supported), 64 | accepting_peerings: Utils.bit_to_boolean(accepting_peerings) 65 | } 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /lib/vintage_net_wifi/mesh_peer/formation_information.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 Connor Rigby 2 | # SPDX-FileCopyrightText: 2022 Frank Hunleth 3 | # 4 | # SPDX-License-Identifier: Apache-2.0 5 | # 6 | defmodule VintageNetWiFi.MeshPeer.FormationInformation do 7 | @moduledoc """ 8 | * `connected_to_as`: 9 | true if the Authentication Protocol Identifier is set to 2. 10 | (indicating IEEE 802.1X authentication) and the station has an 11 | active connection to an AS 12 | * `number_of_peerings`: 13 | indicates the mnumber of mesh peerings currently maintained 14 | but the station or 63, whichever is smaller 15 | * `connected_to_mesh_gate`: 16 | true if the station has a mesh path to the mesh gate that announces 17 | it's presence using GANN, RANN or PREQ elements 18 | """ 19 | alias VintageNetWiFi.Utils 20 | defstruct [:connected_to_as, :number_of_peerings, :connected_to_mesh_gate] 21 | 22 | @type t() :: %__MODULE__{ 23 | connected_to_as: boolean(), 24 | number_of_peerings: 0..63, 25 | connected_to_mesh_gate: boolean() 26 | } 27 | 28 | @spec decode_formation_information(<<_::8>>) :: t() 29 | def decode_formation_information(<< 30 | connected_to_as::1, 31 | number_of_peerings::6, 32 | connected_to_mesh_gate::1 33 | >>) do 34 | %__MODULE__{ 35 | connected_to_as: Utils.bit_to_boolean(connected_to_as), 36 | number_of_peerings: number_of_peerings, 37 | connected_to_mesh_gate: Utils.bit_to_boolean(connected_to_mesh_gate) 38 | } 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/vintage_net_wifi/signal_info.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 Frank Hunleth 2 | # SPDX-FileCopyrightText: 2020 Pavel Sorejs 3 | # 4 | # SPDX-License-Identifier: Apache-2.0 5 | # 6 | defmodule VintageNetWiFi.SignalInfo do 7 | @moduledoc """ 8 | Information about active connection signal levels 9 | 10 | * `:center_frequency1` - center frequency for the first segment 11 | * `:center_frequency2` - center frequency for the second segment (if relevant) 12 | * `:frequency` - control frequency 13 | * `:linkspeed` - current TX rate 14 | * `:signal_dbm` - current signal in dBm (RSSI) 15 | * `:signal_percent` - signal quality in percent 16 | * `:width` - channel width 17 | """ 18 | alias VintageNetWiFi.Utils 19 | 20 | defstruct [ 21 | :center_frequency1, 22 | :center_frequency2, 23 | :frequency, 24 | :linkspeed, 25 | :signal_dbm, 26 | :signal_percent, 27 | :width 28 | ] 29 | 30 | @type t :: %__MODULE__{ 31 | center_frequency1: non_neg_integer(), 32 | center_frequency2: non_neg_integer(), 33 | frequency: non_neg_integer(), 34 | linkspeed: non_neg_integer(), 35 | signal_dbm: integer(), 36 | signal_percent: 0..100, 37 | width: String.t() 38 | } 39 | 40 | @doc """ 41 | Create a new SignalInfo struct 42 | """ 43 | @spec new( 44 | non_neg_integer(), 45 | non_neg_integer(), 46 | non_neg_integer(), 47 | non_neg_integer(), 48 | integer(), 49 | String.t() 50 | ) :: 51 | VintageNetWiFi.SignalInfo.t() 52 | def new(center_frequency1, center_frequency2, frequency, linkspeed, signal_dbm, width) do 53 | info = Utils.frequency_info(frequency) 54 | 55 | %__MODULE__{ 56 | center_frequency1: center_frequency1, 57 | center_frequency2: center_frequency2, 58 | frequency: frequency, 59 | linkspeed: linkspeed, 60 | signal_dbm: signal_dbm, 61 | signal_percent: info.dbm_to_percent.(signal_dbm), 62 | width: width 63 | } 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/vintage_net_wifi/utils.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2019 Frank Hunleth 2 | # SPDX-FileCopyrightText: 2020 Connor Rigby 3 | # 4 | # SPDX-License-Identifier: Apache-2.0 5 | # 6 | defmodule VintageNetWiFi.Utils do 7 | @moduledoc """ 8 | Various utility functions for handling WiFi information 9 | """ 10 | 11 | @doc "Converts 1 to true, 0 to false" 12 | @spec bit_to_boolean(0 | 1) :: boolean() 13 | def bit_to_boolean(1), do: true 14 | def bit_to_boolean(0), do: false 15 | 16 | @type frequency_info() :: %{ 17 | band: VintageNetWiFi.AccessPoint.band(), 18 | channel: non_neg_integer(), 19 | dbm_to_percent: function() 20 | } 21 | 22 | @doc """ 23 | Convert power in dBm to a percent 24 | 25 | The returned percentage is intended to shown to users 26 | like to show a number of bars or some kind of signal 27 | strength. 28 | 29 | See [Displaying Associated and Scanned Signal 30 | Levels](https://web.archive.org/web/20141222024740/http://www.ces.clemson.edu/linux/nm-ipw2200.shtml). 31 | """ 32 | @spec dbm_to_percent(number(), number(), number()) :: 1..100 33 | def dbm_to_percent(dbm, best_dbm, _worst_dbm) when dbm >= best_dbm do 34 | 100 35 | end 36 | 37 | def dbm_to_percent(dbm, best_dbm, worst_dbm) do 38 | delta = best_dbm - worst_dbm 39 | delta2 = delta * delta 40 | 41 | percent = 42 | 100 - 43 | (best_dbm - dbm) * (15 * delta + 62 * (best_dbm - dbm)) / 44 | delta2 45 | 46 | # Constrain the percent to integers and never go to 0 47 | # (Kernel.floor/1 was added to Elixir 1.8, so don't use it) 48 | max(:erlang.floor(percent), 1) 49 | end 50 | 51 | @doc """ 52 | Get information about a WiFi frequency 53 | 54 | The frequency should be pass in MHz. The result is more 55 | information about the frequency that may be helpful to 56 | users. 57 | """ 58 | @spec frequency_info(non_neg_integer()) :: frequency_info() 59 | def(frequency_info(2412), do: band2_4(1)) 60 | def frequency_info(2417), do: band2_4(2) 61 | def frequency_info(2422), do: band2_4(3) 62 | def frequency_info(2427), do: band2_4(4) 63 | def frequency_info(2432), do: band2_4(5) 64 | def frequency_info(2437), do: band2_4(6) 65 | def frequency_info(2442), do: band2_4(7) 66 | def frequency_info(2447), do: band2_4(8) 67 | def frequency_info(2452), do: band2_4(9) 68 | def frequency_info(2457), do: band2_4(10) 69 | def frequency_info(2462), do: band2_4(11) 70 | def frequency_info(2467), do: band2_4(12) 71 | def frequency_info(2472), do: band2_4(13) 72 | def frequency_info(2484), do: band2_4(14) 73 | 74 | def frequency_info(5035), do: band5(7) 75 | def frequency_info(5040), do: band5(8) 76 | def frequency_info(5045), do: band5(9) 77 | def frequency_info(5055), do: band5(11) 78 | def frequency_info(5060), do: band5(12) 79 | def frequency_info(5080), do: band5(16) 80 | def frequency_info(5160), do: band5(32) 81 | def frequency_info(5170), do: band5(34) 82 | def frequency_info(5180), do: band5(36) 83 | def frequency_info(5190), do: band5(38) 84 | def frequency_info(5200), do: band5(40) 85 | def frequency_info(5210), do: band5(42) 86 | def frequency_info(5220), do: band5(44) 87 | def frequency_info(5230), do: band5(46) 88 | def frequency_info(5240), do: band5(48) 89 | def frequency_info(5250), do: band5(50) 90 | def frequency_info(5260), do: band5(52) 91 | def frequency_info(5270), do: band5(54) 92 | def frequency_info(5280), do: band5(56) 93 | def frequency_info(5290), do: band5(58) 94 | def frequency_info(5300), do: band5(60) 95 | def frequency_info(5310), do: band5(62) 96 | def frequency_info(5320), do: band5(64) 97 | def frequency_info(5340), do: band5(68) 98 | def frequency_info(5480), do: band5(96) 99 | def frequency_info(5500), do: band5(100) 100 | def frequency_info(5510), do: band5(102) 101 | def frequency_info(5520), do: band5(104) 102 | def frequency_info(5530), do: band5(106) 103 | def frequency_info(5540), do: band5(108) 104 | def frequency_info(5550), do: band5(110) 105 | def frequency_info(5560), do: band5(112) 106 | def frequency_info(5570), do: band5(114) 107 | def frequency_info(5580), do: band5(116) 108 | def frequency_info(5590), do: band5(118) 109 | def frequency_info(5600), do: band5(120) 110 | def frequency_info(5610), do: band5(122) 111 | def frequency_info(5620), do: band5(124) 112 | def frequency_info(5630), do: band5(126) 113 | def frequency_info(5640), do: band5(128) 114 | def frequency_info(5660), do: band5(132) 115 | def frequency_info(5670), do: band5(134) 116 | def frequency_info(5680), do: band5(136) 117 | def frequency_info(5690), do: band5(138) 118 | def frequency_info(5700), do: band5(140) 119 | def frequency_info(5710), do: band5(142) 120 | def frequency_info(5720), do: band5(144) 121 | def frequency_info(5745), do: band5(149) 122 | def frequency_info(5755), do: band5(151) 123 | def frequency_info(5765), do: band5(153) 124 | def frequency_info(5775), do: band5(155) 125 | def frequency_info(5785), do: band5(157) 126 | def frequency_info(5795), do: band5(159) 127 | def frequency_info(5805), do: band5(161) 128 | def frequency_info(5825), do: band5(165) 129 | def frequency_info(5845), do: band5(169) 130 | def frequency_info(5865), do: band5(173) 131 | def frequency_info(4915), do: band5(183) 132 | def frequency_info(4920), do: band5(184) 133 | def frequency_info(4925), do: band5(185) 134 | def frequency_info(4935), do: band5(187) 135 | def frequency_info(4940), do: band5(188) 136 | def frequency_info(4945), do: band5(189) 137 | def frequency_info(4960), do: band5(192) 138 | def frequency_info(4980), do: band5(196) 139 | 140 | def frequency_info(_unknown) do 141 | %{band: :unknown, channel: 0, dbm_to_percent: fn dbm -> dbm_to_percent(dbm, -20, -83.7) end} 142 | end 143 | 144 | defp band2_4(channel) do 145 | %{ 146 | band: :wifi_2_4_ghz, 147 | channel: channel, 148 | dbm_to_percent: fn dbm -> dbm_to_percent(dbm, -20, -83.7) end 149 | } 150 | end 151 | 152 | defp band5(channel) do 153 | %{ 154 | band: :wifi_5_ghz, 155 | channel: channel, 156 | dbm_to_percent: fn dbm -> dbm_to_percent(dbm, -44, -89) end 157 | } 158 | end 159 | end 160 | -------------------------------------------------------------------------------- /lib/vintage_net_wifi/wpa2.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2019 Frank Hunleth 2 | # 3 | # SPDX-License-Identifier: Apache-2.0 4 | # 5 | defmodule VintageNetWiFi.WPA2 do 6 | @moduledoc """ 7 | WPA2 preshared key calculations 8 | 9 | WPA2 doesn't use passphrases directly, but instead hashes them with the 10 | SSID and uses the result for the network key. The algorithm that runs 11 | the hash takes some time so it's useful to compute the PSK from the 12 | passphrase once rather than specifying it each time. 13 | """ 14 | 15 | @typedoc "A WPA2 preshared key" 16 | @type psk :: <<_::512>> 17 | 18 | @type invalid_passphrase_error :: :password_too_short | :password_too_long | :invalid_characters 19 | @type invalid_ssid_error :: :ssid_too_short | :ssid_too_long 20 | 21 | @doc """ 22 | Convert a WiFi WPA2 passphrase into a PSK 23 | 24 | If a passphrase looks like a PSK, then it's assumed that it already is a PSK 25 | and is passed through. 26 | 27 | See IEEE Std 802.11i-2004 Appendix H.4 for the algorithm. 28 | """ 29 | @spec to_psk(String.t(), psk() | String.t()) :: 30 | {:ok, psk()} 31 | | {:error, invalid_ssid_error() | invalid_passphrase_error()} 32 | def to_psk(ssid, psk) when is_binary(ssid) and byte_size(psk) == 64 do 33 | with :ok <- validate_psk(psk), 34 | :ok <- validate_ssid(ssid) do 35 | {:ok, psk} 36 | end 37 | end 38 | 39 | def to_psk(ssid, passphrase) when is_binary(ssid) and is_binary(passphrase) do 40 | with :ok <- validate_passphrase(passphrase), 41 | :ok <- validate_ssid(ssid) do 42 | {:ok, compute_psk(ssid, passphrase)} 43 | end 44 | end 45 | 46 | @doc """ 47 | Validate the length and characters of a passphrase 48 | 49 | A valid passphrase is between 8 and 63 characters long, and 50 | only contains ASCII characters (values between 32 and 126, inclusive). 51 | """ 52 | @spec validate_passphrase(String.t()) :: :ok | {:error, invalid_passphrase_error()} 53 | def validate_passphrase(password) when byte_size(password) < 8 do 54 | {:error, :password_too_short} 55 | end 56 | 57 | def validate_passphrase(password) when byte_size(password) >= 64 do 58 | {:error, :password_too_long} 59 | end 60 | 61 | def validate_passphrase(password) do 62 | all_ascii(password) 63 | end 64 | 65 | defp compute_psk(ssid, passphrase) do 66 | result = f(ssid, passphrase, 4096, 1) <> f(ssid, passphrase, 4096, 2) 67 | <> = result 68 | 69 | result256 70 | |> Integer.to_string(16) 71 | |> String.pad_leading(64, "0") 72 | end 73 | 74 | # F(P, S, c, i) = U1 xor U2 xor ... Uc 75 | # U1 = PRF(P, S || Int(i)) 76 | # U2 = PRF(P, U1) 77 | # Uc = PRF(P, Uc-1) 78 | defp f(ssid, password, iterations, count) do 79 | digest = <> 80 | digest1 = sha1_hmac(digest, password) 81 | 82 | iterate(digest1, digest1, password, iterations - 1) 83 | end 84 | 85 | defp iterate(acc, _previous_digest, _password, 0) do 86 | acc 87 | end 88 | 89 | defp iterate(acc, previous_digest, password, n) do 90 | digest = sha1_hmac(previous_digest, password) 91 | iterate(xor160(acc, digest), digest, password, n - 1) 92 | end 93 | 94 | defp xor160(<>, <>) do 95 | <<:erlang.bxor(a, b)::160>> 96 | end 97 | 98 | if :erlang.system_info(:otp_release) == ~c"21" do 99 | # TODO: Remove when OTP 21 is no longer supported. 100 | defp sha1_hmac(digest, password) do 101 | :crypto.hmac(:sha, password, digest) 102 | end 103 | else 104 | defp sha1_hmac(digest, password) do 105 | :crypto.mac(:hmac, :sha, password, digest) 106 | end 107 | end 108 | 109 | defp validate_psk(psk) when byte_size(psk) == 64 do 110 | all_hex_digits(psk) 111 | end 112 | 113 | @doc """ 114 | Validate the length of the SSID 115 | 116 | A valid SSID is between 1 and 32 characters long. 117 | """ 118 | @spec validate_ssid(String.t()) :: :ok | {:error, invalid_ssid_error()} 119 | def validate_ssid(ssid) when byte_size(ssid) == 0, do: {:error, :ssid_too_short} 120 | def validate_ssid(ssid) when byte_size(ssid) <= 32, do: :ok 121 | def validate_ssid(ssid) when is_binary(ssid), do: {:error, :ssid_too_long} 122 | 123 | defp all_ascii(<>) when c >= 32 and c <= 126 do 124 | all_ascii(rest) 125 | end 126 | 127 | defp all_ascii(<<>>), do: :ok 128 | 129 | defp all_ascii(_other), do: {:error, :invalid_characters} 130 | 131 | defp all_hex_digits(<>) 132 | when (c >= ?0 and c <= ?9) or (c >= ?a and c <= ?f) or (c >= ?A and c <= ?F) do 133 | all_hex_digits(rest) 134 | end 135 | 136 | defp all_hex_digits(<<>>), do: :ok 137 | 138 | defp all_hex_digits(_other), do: {:error, :invalid_characters} 139 | end 140 | -------------------------------------------------------------------------------- /lib/vintage_net_wifi/wpa_supplicant.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2019 Frank Hunleth 2 | # SPDX-FileCopyrightText: 2020 Connor Rigby 3 | # SPDX-FileCopyrightText: 2020 Pavel Sorejs 4 | # SPDX-FileCopyrightText: 2021 Dömötör Gulyás 5 | # SPDX-FileCopyrightText: 2021 WN 6 | # SPDX-FileCopyrightText: 2022 Jon Carstens 7 | # SPDX-FileCopyrightText: 2022 Matt Ludwigs 8 | # 9 | # SPDX-License-Identifier: Apache-2.0 10 | # 11 | defmodule VintageNetWiFi.WPASupplicant do 12 | @moduledoc """ 13 | Control a wpa_supplicant instance for an interface. 14 | """ 15 | 16 | use GenServer 17 | 18 | alias VintageNet.Interface.EAPStatus 19 | alias VintageNetWiFi.{BSSIDRequester, WPASupplicantDecoder, WPASupplicantLL} 20 | require Logger 21 | 22 | @doc """ 23 | Start a GenServer to manage communication with a wpa_supplicant 24 | 25 | Arguments: 26 | 27 | * `:wpa_supplicant - the path to the wpa_supplicant binary 28 | * `:wpa_supplicant_conf_path - the path to the supplicant's conf file 29 | * `:ifname` - the network interface 30 | * `:control_path` - the path to the wpa_supplicant control file 31 | * `:keep_alive_interval` - how often to ping the wpa_supplicant to 32 | make sure it's still alive (defaults to 60,000 seconds) 33 | * `:ap_mode` - true if the WiFi module and wpa_supplicant are 34 | in access point mode 35 | """ 36 | @spec start_link(keyword()) :: GenServer.on_start() 37 | def start_link(args) do 38 | ifname = Keyword.fetch!(args, :ifname) 39 | GenServer.start_link(__MODULE__, args, name: via_name(ifname)) 40 | end 41 | 42 | defp via_name(ifname) do 43 | {:via, Registry, {VintageNet.Interface.Registry, {__MODULE__, ifname}}} 44 | end 45 | 46 | @doc """ 47 | Initiate a scan of WiFi networks 48 | """ 49 | @spec scan(VintageNet.ifname()) :: :ok 50 | def scan(ifname) do 51 | GenServer.call(via_name(ifname), :scan) 52 | end 53 | 54 | @doc """ 55 | Polls for signal level info 56 | """ 57 | @spec signal_poll(VintageNet.ifname()) :: {:ok, any()} | {:error, any()} 58 | def signal_poll(ifname) do 59 | GenServer.call(via_name(ifname), :signal_poll) 60 | end 61 | 62 | @doc """ 63 | Enable reception of WiFi credentials via WPS 64 | """ 65 | @spec wps_pbc(VintageNet.ifname()) :: {:ok, any()} | {:error, any()} 66 | def wps_pbc(ifname) do 67 | GenServer.call(via_name(ifname), :wps_pbc) 68 | end 69 | 70 | @doc """ 71 | Send a raw command to the `wpa_supplicant` 72 | 73 | This doesn't do any kind of processing on the results and just returns whatever 74 | `wpa_supplicant` says. See [ctrl_iface](https://w1.fi/wpa_supplicant/devel/ctrl_iface_page.html) 75 | and `ctrl_iface.c` for options. 76 | 77 | iex> VintageNetWiFi.WPASupplicant.raw_command("wlan0", "GET_CAPABILITY modes") 78 | {:ok, "AP MESH"} 79 | """ 80 | @spec raw_command(VintageNet.ifname(), String.t()) :: {:ok, String.t()} | {:error, any()} 81 | def raw_command(ifname, command) do 82 | GenServer.call(via_name(ifname), {:raw_command, command}) 83 | end 84 | 85 | @impl GenServer 86 | def init(args) do 87 | wpa_supplicant = Keyword.fetch!(args, :wpa_supplicant) 88 | wpa_supplicant_conf_path = Keyword.fetch!(args, :wpa_supplicant_conf_path) 89 | 90 | control_dir = Keyword.fetch!(args, :control_path) 91 | ifname = Keyword.fetch!(args, :ifname) 92 | keep_alive_interval = Keyword.get(args, :keep_alive_interval, 60000) 93 | ap_mode = Keyword.get(args, :ap_mode, false) 94 | verbose = Keyword.get(args, :verbose, false) 95 | 96 | state = %{ 97 | wpa_supplicant: wpa_supplicant, 98 | wpa_supplicant_conf_path: wpa_supplicant_conf_path, 99 | control_dir: control_dir, 100 | keep_alive_interval: keep_alive_interval, 101 | ifname: ifname, 102 | ap_mode: ap_mode, 103 | verbose: verbose, 104 | access_points: %{}, 105 | clients: [], 106 | peers: [], 107 | current_ap: nil, 108 | eap_status: %EAPStatus{}, 109 | ll: nil, 110 | bssid_requester: nil 111 | } 112 | 113 | {:ok, state, {:continue, :continue}} 114 | end 115 | 116 | @impl GenServer 117 | def handle_continue(:continue, state) do 118 | # The control file paths depend whether the config uses AP mode and whether 119 | # the driver has a separate P2P interface. We find out based on which 120 | # control files appear. 121 | control_paths = get_control_paths(state) 122 | 123 | # Start the supplicant 124 | {:ok, _supplicant} = 125 | if state.wpa_supplicant != "" do 126 | # FIXME: This appears to be needed when restarting the wpa_supplicant. 127 | # It is an imperfect fix to an issue when running AP mode. Sometimes 128 | # AP mode would look like it came up, but you couldn't connect to it. 129 | # VintageNet.info reports that the interface is disconnected. 130 | Process.sleep(1000) 131 | 132 | # Erase old old control paths just in case they exist 133 | Enum.each(control_paths, &File.rm/1) 134 | 135 | verbose_flag = if state.verbose, do: ["-dd"], else: [] 136 | 137 | # -i ifname // which interface 138 | # -Dnl80211,wext // try the nl80211 driver first, then wext 139 | # -c config_file // use our config file 140 | # -dd // verbose 141 | args = [ 142 | "-i", 143 | state.ifname, 144 | "-Dnl80211,wext", 145 | "-c", 146 | state.wpa_supplicant_conf_path | verbose_flag 147 | ] 148 | 149 | MuonTrap.Daemon.start_link( 150 | state.wpa_supplicant, 151 | args, 152 | VintageNet.Command.add_muon_options(stderr_to_stdout: true, log_output: :debug) 153 | ) 154 | else 155 | # No wpa_supplicant. The assumption is that someone else started it. 156 | # Currently this is only for unit tests. 157 | {:ok, nil} 158 | end 159 | 160 | # Wait for the wpa_supplicant to create its control files. 161 | primary_path = 162 | case wait_for_control_file(control_paths) do 163 | [primary_path, secondary_path] -> 164 | {:ok, secondary_ll} = 165 | WPASupplicantLL.start_link(path: secondary_path, notification_pid: self()) 166 | 167 | {:ok, "OK\n"} = WPASupplicantLL.control_request(secondary_ll, "ATTACH") 168 | primary_path 169 | 170 | [primary_path] -> 171 | primary_path 172 | 173 | _ -> 174 | raise RuntimeError, 175 | "Couldn't find wpa_supplicant control files: #{inspect(control_paths)}" 176 | end 177 | 178 | {:ok, ll} = WPASupplicantLL.start_link(path: primary_path, notification_pid: self()) 179 | {:ok, "OK\n"} = WPASupplicantLL.control_request(ll, "ATTACH") 180 | 181 | {:ok, bssid_requester} = BSSIDRequester.start_link(ll: ll, notification_pid: self()) 182 | 183 | # Request a new AP list 184 | BSSIDRequester.get_all_access_points(bssid_requester, &update_all_access_points/2) 185 | 186 | new_state = %{state | ll: ll, bssid_requester: bssid_requester} 187 | 188 | # Make sure that the property table is in sync with our state 189 | update_clients_property(new_state) 190 | 191 | {:noreply, new_state, state.keep_alive_interval} 192 | end 193 | 194 | @impl GenServer 195 | def handle_call(:scan, _from, %{ap_mode: true} = state) do 196 | # When in AP mode, scans need to be forced so that they work. 197 | # The wpa_supplicant won't set the appropriate flag to make 198 | # this happen, so call a C program to do it. 199 | 200 | force_ap_scan = Application.app_dir(:vintage_net_wifi, ["priv", "force_ap_scan"]) 201 | 202 | case System.cmd(force_ap_scan, [state.ifname]) do 203 | {_output, 0} -> 204 | {:reply, :ok, state, state.keep_alive_interval} 205 | 206 | {_output, _nonzero} -> 207 | {:reply, {:error, "force_ap_scan failed"}, state, state.keep_alive_interval} 208 | end 209 | end 210 | 211 | def handle_call(:scan, _from, state) do 212 | response = 213 | case WPASupplicantLL.control_request(state.ll, "SCAN") do 214 | {:ok, <<"OK", _rest::binary>>} -> :ok 215 | {:ok, something_else} -> {:error, String.trim(something_else)} 216 | error -> error 217 | end 218 | 219 | {:reply, response, state, state.keep_alive_interval} 220 | end 221 | 222 | def handle_call(:signal_poll, _from, state) do 223 | response = get_signal_info(state.ll) 224 | {:reply, response, state, state.keep_alive_interval} 225 | end 226 | 227 | def handle_call(:wps_pbc, _from, state) do 228 | response = WPASupplicantLL.control_request(state.ll, "WPS_PBC") 229 | update_wps_credentials(state.ifname, nil) 230 | {:reply, response, state} 231 | end 232 | 233 | def handle_call({:raw_command, command}, _from, state) do 234 | response = WPASupplicantLL.control_request(state.ll, command) 235 | {:reply, response, state} 236 | end 237 | 238 | @impl GenServer 239 | def handle_info({:bssid_result, result, function}, state) do 240 | new_state = function.(state, result) 241 | {:noreply, new_state, state.keep_alive_interval} 242 | end 243 | 244 | def handle_info(:timeout, state) do 245 | case WPASupplicantLL.control_request(state.ll, "PING") do 246 | {:ok, <<"PONG", _rest::binary>>} -> 247 | {:noreply, state, state.keep_alive_interval} 248 | 249 | other -> 250 | raise "Bad PING response: #{inspect(other)}" 251 | end 252 | end 253 | 254 | def handle_info({VintageNetWiFi.WPASupplicantLL, _priority, message}, state) do 255 | notification = WPASupplicantDecoder.decode_notification(message) 256 | 257 | new_state = handle_notification(notification, state) 258 | {:noreply, new_state, new_state.keep_alive_interval} 259 | end 260 | 261 | defp handle_notification({:event, "CTRL-EVENT-SCAN-RESULTS"}, state) do 262 | # Request all of the known BSS IDs. This will be handled asynchronously 263 | # since there could be a lot. 264 | BSSIDRequester.get_all_access_points(state.bssid_requester, &update_all_access_points/2) 265 | state 266 | end 267 | 268 | defp handle_notification({:event, "CTRL-EVENT-BSS-ADDED", _index, bssid}, state) do 269 | BSSIDRequester.get_access_point_info(state.bssid_requester, bssid, &add_access_point/2) 270 | state 271 | end 272 | 273 | defp handle_notification({:event, "CTRL-EVENT-BSS-REMOVED", _index, bssid}, state) do 274 | # Even though the requester doesn't do anything with this, it needs to be sent 275 | # through to avoid the race condition where an BSS is added and then immediately 276 | # removed. A message could be in queue that adds a BSS that gets applied out 277 | # of order. 278 | BSSIDRequester.forget_access_point_info(state.bssid_requester, bssid, &forget_access_point/2) 279 | state 280 | end 281 | 282 | # Ignored 283 | defp handle_notification({:event, "CTRL-EVENT-SCAN-STARTED"}, state), do: state 284 | 285 | defp handle_notification({:event, "AP-STA-CONNECTED", client}, state) do 286 | if client in state.clients do 287 | state 288 | else 289 | clients = [client | state.clients] 290 | new_state = %{state | clients: clients} 291 | update_clients_property(new_state) 292 | new_state 293 | end 294 | end 295 | 296 | defp handle_notification({:event, "AP-STA-DISCONNECTED", client}, state) do 297 | clients = List.delete(state.clients, client) 298 | new_state = %{state | clients: clients} 299 | update_clients_property(new_state) 300 | new_state 301 | end 302 | 303 | defp handle_notification({:event, "CTRL-EVENT-CONNECTED", bssid, "completed", _}, state) do 304 | Logger.debug("Connected to AP: #{bssid}") 305 | 306 | case state.access_points[bssid] do 307 | nil -> 308 | # Unknown BSSID. Request info on it and use a placeholder in the meantime 309 | BSSIDRequester.get_access_point_info( 310 | state.bssid_requester, 311 | bssid, 312 | &update_current_access_point/2 313 | ) 314 | 315 | update_current_access_point(state, VintageNetWiFi.AccessPoint.new(bssid)) 316 | 317 | ap -> 318 | # Known BSSID, so no need to re-query wpa_supplicant 319 | # NOTE: This query has been known to timeout in the past. This was almost certainly due to 320 | # too many responses being outstanding and a message being dropped on the domain socket. 321 | # Since we should almost always know the AP already, not sending the request seems 322 | # ultimately safe since it avoids the issue altogether. 323 | update_current_access_point(state, ap) 324 | end 325 | end 326 | 327 | defp handle_notification({:event, "CTRL-EVENT-CONNECTED", bssid, status, _}, state) do 328 | Logger.debug("Unknown AP connection status: #{bssid} #{status}") 329 | new_state = %{state | current_ap: nil} 330 | update_current_access_point_property(new_state) 331 | new_state 332 | end 333 | 334 | defp handle_notification({:event, "CTRL-EVENT-DISCONNECTED", bssid, _}, state) do 335 | Logger.debug("AP disconnected: #{bssid}") 336 | new_state = %{state | current_ap: nil} 337 | update_current_access_point_property(new_state) 338 | new_state 339 | end 340 | 341 | defp handle_notification( 342 | {:event, "CTRL-EVENT-ASSOC-REJECT" = event_name, bssid, 343 | %{"status_code" => status_code} = event_data}, 344 | %{ifname: ifname} = state 345 | ) do 346 | Logger.debug("Association rejected for BSSID: #{bssid}, status code: #{status_code}") 347 | 348 | event = VintageNetWiFi.Event.new(event_name, event_data) 349 | 350 | update_wifi_event_property(ifname, event) 351 | state 352 | end 353 | 354 | defp handle_notification( 355 | {:event, "CTRL-EVENT-SSID-TEMP-DISABLED" = event_name, %{"ssid" => ssid} = event_data}, 356 | %{ifname: ifname} = state 357 | ) do 358 | Logger.debug("Access temporarily disabled to network: #{inspect(ssid)}") 359 | 360 | event = VintageNetWiFi.Event.new(event_name, event_data) 361 | 362 | update_wifi_event_property(ifname, event) 363 | state 364 | end 365 | 366 | defp handle_notification( 367 | {:event, "CTRL-EVENT-SSID-REENABLED" = event_name, %{"ssid" => ssid} = event_data}, 368 | %{ifname: ifname} = state 369 | ) do 370 | Logger.debug("Access re-enabled to network: #{inspect(ssid)}") 371 | event = VintageNetWiFi.Event.new(event_name, event_data) 372 | update_wifi_event_property(ifname, event) 373 | state 374 | end 375 | 376 | defp handle_notification( 377 | {:event, "CTRL-EVENT-NETWORK-NOT-FOUND" = event_name}, 378 | %{ifname: ifname} = state 379 | ) do 380 | Logger.debug("network not found") 381 | 382 | event = VintageNetWiFi.Event.new(event_name, %{}) 383 | 384 | update_wifi_event_property(ifname, event) 385 | state 386 | end 387 | 388 | defp handle_notification({:event, "CTRL-EVENT-EAP-STATUS", %{"status" => "started"}}, state) do 389 | new_state = %{ 390 | state 391 | | eap_status: %{state.eap_status | status: :started, timestamp: DateTime.utc_now()} 392 | } 393 | 394 | update_eap_status_property(new_state) 395 | new_state 396 | end 397 | 398 | defp handle_notification( 399 | {:event, "CTRL-EVENT-EAP-STATUS", 400 | %{"parameter" => method, "status" => "accept proposed method"}}, 401 | state 402 | ) do 403 | new_state = %{ 404 | state 405 | | eap_status: %{state.eap_status | method: method, timestamp: DateTime.utc_now()} 406 | } 407 | 408 | update_eap_status_property(new_state) 409 | new_state 410 | end 411 | 412 | defp handle_notification( 413 | {:event, "CTRL-EVENT-EAP-STATUS", 414 | %{"parameter" => "success", "status" => "remote certificate verification"}}, 415 | state 416 | ) do 417 | new_state = %{ 418 | state 419 | | eap_status: %{ 420 | state.eap_status 421 | | remote_certificate_verified?: true, 422 | timestamp: DateTime.utc_now() 423 | } 424 | } 425 | 426 | update_eap_status_property(new_state) 427 | new_state 428 | end 429 | 430 | defp handle_notification( 431 | {:event, "CTRL-EVENT-EAP-STATUS", 432 | %{"parameter" => "failure", "status" => "remote certificate verification"}}, 433 | state 434 | ) do 435 | new_state = %{ 436 | state 437 | | eap_status: %{ 438 | state.eap_status 439 | | remote_certificate_verified?: false, 440 | timestamp: DateTime.utc_now() 441 | } 442 | } 443 | 444 | update_eap_status_property(new_state) 445 | new_state 446 | end 447 | 448 | defp handle_notification( 449 | {:event, "CTRL-EVENT-EAP-STATUS", %{"parameter" => "failure", "status" => "completion"}}, 450 | state 451 | ) do 452 | new_state = %{ 453 | state 454 | | eap_status: %{state.eap_status | status: :failure, timestamp: DateTime.utc_now()} 455 | } 456 | 457 | update_eap_status_property(new_state) 458 | new_state 459 | end 460 | 461 | defp handle_notification( 462 | {:event, "CTRL-EVENT-EAP-STATUS", %{"parameter" => "success", "status" => "completion"}}, 463 | state 464 | ) do 465 | new_state = %{ 466 | state 467 | | eap_status: %{state.eap_status | status: :success, timestamp: DateTime.utc_now()} 468 | } 469 | 470 | update_eap_status_property(new_state) 471 | new_state 472 | end 473 | 474 | defp handle_notification( 475 | {:event, "CTRL-EVENT-EAP-PEER-CERT", 476 | %{"cert" => _cert, "depth" => _depth, "subject" => _subject}}, 477 | state 478 | ) do 479 | # TODO(Connor) - store cert on the eap-status 480 | state 481 | end 482 | 483 | defp handle_notification( 484 | {:event, "CTRL-EVENT-EAP-PEER-CERT", 485 | %{"hash" => _hash, "depth" => _depth, "subject" => _subject}}, 486 | state 487 | ) do 488 | # TODO(Connor) - store cert on the eap-status 489 | state 490 | end 491 | 492 | defp handle_notification({:event, "MESH-PEER-CONNECTED", bssid}, state) do 493 | BSSIDRequester.get_access_point_info(state.bssid_requester, bssid, &add_mesh_peer/2) 494 | state 495 | end 496 | 497 | defp handle_notification({:event, "MESH-PEER-DISCONNECTED", bssid}, state) do 498 | BSSIDRequester.forget_access_point_info(state.bssid_requester, bssid, &forget_mesh_peer/2) 499 | state 500 | end 501 | 502 | defp handle_notification({:event, "WPS-CRED-RECEIVED", msg}, state) do 503 | update_wps_credentials(state.ifname, msg) 504 | state 505 | end 506 | 507 | defp handle_notification({:event, "CTRL-EVENT-TERMINATING"}, _state) do 508 | # This really shouldn't happen. The only way I know how to cause this 509 | # is to send a SIGTERM to the wpa_supplicant. 510 | exit(:wpa_supplicant_terminated) 511 | end 512 | 513 | defp handle_notification({:info, message}, state) do 514 | Logger.debug("wpa_supplicant(#{state.ifname}): #{message}") 515 | state 516 | end 517 | 518 | defp handle_notification(unhandled, state) do 519 | Logger.debug("WPASupplicant ignoring #{inspect(unhandled)}") 520 | state 521 | end 522 | 523 | defp get_signal_info(ll) do 524 | with {:ok, raw_response} <- WPASupplicantLL.control_request(ll, "SIGNAL_POLL") do 525 | case raw_response do 526 | <<"FAIL", _rest::binary>> -> 527 | {:error, "FAIL"} 528 | 529 | _ -> 530 | case WPASupplicantDecoder.decode_kv_response(raw_response) do 531 | empty when empty == %{} -> 532 | {:error, :unknown} 533 | 534 | response -> 535 | center_frequency1 = response["CENTER_FRQ1"] |> string_to_integer() 536 | center_frequency2 = response["CENTER_FRQ2"] |> string_to_integer() 537 | frequency = response["FREQUENCY"] |> string_to_integer() 538 | linkspeed = response["LINKSPEED"] |> string_to_integer() 539 | signal_dbm = response["RSSI"] |> string_to_integer() 540 | width = response["WIDTH"] 541 | 542 | signal_info = 543 | VintageNetWiFi.SignalInfo.new( 544 | center_frequency1, 545 | center_frequency2, 546 | frequency, 547 | linkspeed, 548 | signal_dbm, 549 | width 550 | ) 551 | 552 | {:ok, signal_info} 553 | end 554 | end 555 | end 556 | end 557 | 558 | defp string_to_integer(nil), do: 0 559 | defp string_to_integer(s), do: String.to_integer(s) 560 | 561 | defp update_all_access_points(state, access_points) when is_map(access_points) do 562 | access_points = filter_access_points(access_points) 563 | new_state = %{state | access_points: access_points} 564 | 565 | update_access_points_property(new_state) 566 | new_state 567 | end 568 | 569 | defp filter_access_points(access_points_map) do 570 | Enum.reduce(access_points_map, %{}, fn 571 | {bssid, %VintageNetWiFi.AccessPoint{} = ap}, acc -> Map.put(acc, bssid, ap) 572 | {_bssid, _non_ap}, acc -> acc 573 | end) 574 | end 575 | 576 | defp add_access_point(state, %VintageNetWiFi.AccessPoint{} = ap) do 577 | new_access_points = Map.put(state.access_points, ap.bssid, ap) 578 | new_state = %{state | access_points: new_access_points} 579 | 580 | update_access_points_property(new_state) 581 | new_state 582 | end 583 | 584 | defp add_access_point(state, _non_ap) do 585 | # Ignore non-access points like mesh peers 586 | state 587 | end 588 | 589 | defp update_current_access_point(state, %VintageNetWiFi.AccessPoint{} = ap) do 590 | new_state = %{state | current_ap: ap} 591 | update_current_access_point_property(new_state) 592 | new_state 593 | end 594 | 595 | defp update_current_access_point(state, _other) do 596 | # For some reason this has returned a non-access point in the field. Just 597 | # keep whatever is there since someone's doing something weird with trying 598 | # to join a mesh. 599 | state 600 | end 601 | 602 | defp add_mesh_peer(state, %VintageNetWiFi.MeshPeer{} = peer) do 603 | new_peers = [peer | state.peers] 604 | new_state = %{state | peers: new_peers} 605 | update_peers_property(new_state) 606 | new_state 607 | end 608 | 609 | defp forget_mesh_peer(state, bssid) do 610 | new_peers = 611 | Enum.reject(state.peers, fn 612 | %{bssid: ^bssid} -> true 613 | _ -> false 614 | end) 615 | 616 | new_state = %{state | peers: new_peers} 617 | update_peers_property(new_state) 618 | new_state 619 | end 620 | 621 | defp forget_access_point(state, bssid) do 622 | new_access_points = Map.delete(state.access_points, bssid) 623 | 624 | new_state = %{state | access_points: new_access_points} 625 | update_access_points_property(new_state) 626 | new_state 627 | end 628 | 629 | defp update_access_points_property(state) do 630 | ap_list = Map.values(state.access_points) 631 | 632 | PropertyTable.put( 633 | VintageNet, 634 | ["interface", state.ifname, "wifi", "access_points"], 635 | ap_list 636 | ) 637 | end 638 | 639 | defp update_clients_property(state) do 640 | PropertyTable.put( 641 | VintageNet, 642 | ["interface", state.ifname, "wifi", "clients"], 643 | state.clients 644 | ) 645 | end 646 | 647 | defp update_peers_property(state) do 648 | PropertyTable.put( 649 | VintageNet, 650 | ["interface", state.ifname, "wifi", "peers"], 651 | state.peers 652 | ) 653 | end 654 | 655 | defp update_current_access_point_property(state) do 656 | PropertyTable.put( 657 | VintageNet, 658 | ["interface", state.ifname, "wifi", "current_ap"], 659 | state.current_ap 660 | ) 661 | end 662 | 663 | defp update_eap_status_property(state) do 664 | PropertyTable.put( 665 | VintageNet, 666 | ["interface", state.ifname, "eap_status"], 667 | state.eap_status 668 | ) 669 | end 670 | 671 | defp update_wps_credentials(ifname, credentials) do 672 | PropertyTable.put( 673 | VintageNet, 674 | ["interface", ifname, "wifi", "wps_credentials"], 675 | credentials 676 | ) 677 | end 678 | 679 | defp update_wifi_event_property(ifname, event) do 680 | PropertyTable.put( 681 | VintageNet, 682 | ["interface", ifname, "wifi", "event"], 683 | event 684 | ) 685 | end 686 | 687 | defp get_control_paths(%{control_dir: dir, ap_mode: true, ifname: ifname} = _state) do 688 | [Path.join(dir, "p2p-dev-#{ifname}"), Path.join(dir, ifname)] 689 | end 690 | 691 | defp get_control_paths(%{control_dir: dir, ifname: ifname}) do 692 | [Path.join(dir, ifname)] 693 | end 694 | 695 | defp wait_for_control_file(paths, time_left \\ 3000) 696 | 697 | defp wait_for_control_file(_paths, time_left) when time_left <= 0 do 698 | [] 699 | end 700 | 701 | defp wait_for_control_file(paths, time_left) do 702 | case Enum.filter(paths, &File.exists?/1) do 703 | [] -> 704 | Process.sleep(250) 705 | wait_for_control_file(paths, time_left - 250) 706 | 707 | found_paths when length(found_paths) < length(paths) -> 708 | # I don't think that it's guaranteed that all paths are always created, 709 | # so all this to work, but with a penalty just in case the others show 710 | # up momentarily. 711 | Process.sleep(100) 712 | Enum.filter(paths, &File.exists?/1) 713 | 714 | found_paths -> 715 | found_paths 716 | end 717 | end 718 | end 719 | -------------------------------------------------------------------------------- /lib/vintage_net_wifi/wpa_supplicant_decoder.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2019 Frank Hunleth 2 | # SPDX-FileCopyrightText: 2020 Connor Rigby 3 | # SPDX-FileCopyrightText: 2021 Dömötör Gulyás 4 | # 5 | # SPDX-License-Identifier: Apache-2.0 6 | # 7 | defmodule VintageNetWiFi.WPASupplicantDecoder do 8 | @moduledoc false 9 | alias VintageNetWiFi.WPSData 10 | 11 | require Logger 12 | 13 | @doc """ 14 | Decode notifications from the wpa_supplicant 15 | """ 16 | @spec decode_notification(binary()) :: 17 | {:event, String.t()} 18 | | {:event, String.t(), any()} 19 | | {:event, String.t(), any(), any()} 20 | | {:event, String.t(), any(), any(), any()} 21 | | {:info, String.t()} 22 | | {:interactive, String.t(), any(), any()} 23 | def decode_notification(<<"CTRL-REQ-", rest::binary>>) do 24 | [field, net_id, text] = String.split(rest, "-", parts: 3, trim: true) 25 | {:interactive, "CTRL-REQ-" <> field, String.to_integer(net_id), text} 26 | end 27 | 28 | def decode_notification(<<"CTRL-EVENT-BSS-ADDED", rest::binary>>) do 29 | [entry_id, bssid] = String.split(rest, " ", trim: true) 30 | {:event, "CTRL-EVENT-BSS-ADDED", String.to_integer(entry_id), bssid} 31 | end 32 | 33 | def decode_notification(<<"CTRL-EVENT-BSS-REMOVED", rest::binary>>) do 34 | [entry_id, bssid] = String.split(rest, " ", trim: true) 35 | {:event, "CTRL-EVENT-BSS-REMOVED", String.to_integer(entry_id), bssid} 36 | end 37 | 38 | # This message is just not shaped the same as others for some reason. 39 | def decode_notification(<<"CTRL-EVENT-CONNECTED", rest::binary>>) do 40 | ["-", "Connection", "to", bssid, status | info] = String.split(rest) 41 | 42 | info = 43 | Regex.scan(~r(\w+=[a-zA-Z0-9:\"_]+), Enum.join(info, " ")) 44 | |> Map.new(fn [str] -> 45 | [key, val] = String.split(str, "=") 46 | {key, unescape_string(val)} 47 | end) 48 | 49 | {:event, "CTRL-EVENT-CONNECTED", bssid, status, info} 50 | end 51 | 52 | def decode_notification(<<"CTRL-EVENT-DISCONNECTED", rest::binary>>) do 53 | decode_kv_notification("CTRL-EVENT-DISCONNECTED", rest) 54 | end 55 | 56 | # "CTRL-EVENT-REGDOM-CHANGE init=CORE" 57 | def decode_notification(<<"CTRL-EVENT-REGDOM-CHANGE", rest::binary>>) do 58 | decode_kv_notification("CTRL-EVENT-REGDOM-CHANGE", rest) 59 | end 60 | 61 | # "CTRL-EVENT-ASSOC-REJECT bssid=00:00:00:00:00:00 status_code=16" 62 | def decode_notification(<<"CTRL-EVENT-ASSOC-REJECT", rest::binary>>) do 63 | decode_kv_notification("CTRL-EVENT-ASSOC-REJECT", rest) 64 | end 65 | 66 | # "CTRL-EVENT-SSID-TEMP-DISABLED id=1 ssid=\"FarmbotConnect\" auth_failures=1 duration=10 reason=CONN_FAILED" 67 | def decode_notification(<<"CTRL-EVENT-SSID-TEMP-DISABLED", rest::binary>>) do 68 | decode_kv_notification("CTRL-EVENT-SSID-TEMP-DISABLED", rest) 69 | end 70 | 71 | # "CTRL-EVENT-SUBNET-STATUS-UPDATE status=0" 72 | def decode_notification(<<"CTRL-EVENT-SUBNET-STATUS-UPDATE", rest::binary>>) do 73 | decode_kv_notification("CTRL-EVENT-SUBNET-STATUS-UPDATE", rest) 74 | end 75 | 76 | # CTRL-EVENT-SSID-REENABLED id=1 ssid=\"FarmbotConnect\"" 77 | def decode_notification(<<"CTRL-EVENT-SSID-REENABLED", rest::binary>>) do 78 | decode_kv_notification("CTRL-EVENT-SSID-REENABLED", rest) 79 | end 80 | 81 | # "CTRL-EVENT-EAP-PEER-CERT depth=0 subject='/C=US/ST=California/L=San Luis Obispo/O=FarmBot Inc/CN=Connor Rigby/emailAddress=connor@farmbot.io' hash=ae7b11dc19b0ed3497540ac551d9730fd86380b3da9d494bb27cb8f2bda8fbd6" 82 | def decode_notification(<<"CTRL-EVENT-EAP-PEER-CERT ", rest::binary>>) do 83 | info = eap_peer_cert_decode(rest) 84 | {:event, "CTRL-EVENT-EAP-PEER-CERT", info} 85 | end 86 | 87 | def decode_notification(<<"CTRL-EVENT-EAP-STATUS", rest::binary>>) do 88 | info = 89 | Regex.scan(~r/\w+=(["'])(?:(?=(\\?))\2.)*?\1/, rest) 90 | |> Map.new(fn [str | _] -> 91 | [key, val] = String.split(str, "=", parts: 2) 92 | {key, unquote_string(val)} 93 | end) 94 | 95 | {:event, "CTRL-EVENT-EAP-STATUS", info} 96 | end 97 | 98 | def decode_notification(<<"CTRL-EVENT-EAP-FAILURE", rest::binary>>) do 99 | {:event, "CTRL-EVENT-EAP-FAILURE", String.trim(rest)} 100 | end 101 | 102 | def decode_notification(<<"CTRL-EVENT-EAP-METHOD", rest::binary>>) do 103 | {:event, "CTRL-EVENT-EAP-METHOD", String.trim(rest)} 104 | end 105 | 106 | def decode_notification(<<"CTRL-EVENT-EAP-PROPOSED-METHOD", rest::binary>>) do 107 | decode_kv_notification("CTRL-EVENT-EAP-PROPOSED-METHOD", rest) 108 | end 109 | 110 | def decode_notification(<<"CTRL-EVENT-", _type::binary>> = event) do 111 | {:event, String.trim_trailing(event)} 112 | end 113 | 114 | def decode_notification(<<"WPS-CRED-RECEIVED ", rest::binary>>) do 115 | {:event, "WPS-CRED-RECEIVED", WPSData.decode(rest)} 116 | end 117 | 118 | def decode_notification(<<"WPS-", _type::binary>> = event) do 119 | {:event, String.trim_trailing(event)} 120 | end 121 | 122 | def decode_notification(<<"AP-STA-CONNECTED ", mac::binary>>) do 123 | {:event, "AP-STA-CONNECTED", String.trim_trailing(mac)} 124 | end 125 | 126 | def decode_notification(<<"AP-STA-DISCONNECTED ", mac::binary>>) do 127 | {:event, "AP-STA-DISCONNECTED", String.trim_trailing(mac)} 128 | end 129 | 130 | # MESH-PEER-DISCONNECTED 00:00:00:00:00:00 131 | def decode_notification(<<"MESH-PEER-DISCONNECTED ", mac::binary>>) do 132 | {:event, "MESH-PEER-DISCONNECTED", String.trim_trailing(mac)} 133 | end 134 | 135 | # MESH-PEER-CONNECTED 00:00:00:00:00:00 136 | def decode_notification(<<"MESH-PEER-CONNECTED ", mac::binary>>) do 137 | {:event, "MESH-PEER-CONNECTED", String.trim_trailing(mac)} 138 | end 139 | 140 | # MESH-GROUP-STARTED ssid=\"my-mesh\" id=1 141 | def decode_notification(<<"MESH-GROUP-STARTED ", rest::binary>>) do 142 | decode_kv_notification("MESH-GROUP-STARTED", rest) 143 | end 144 | 145 | # MESH-GROUP-REMOVED mesh0 146 | def decode_notification(<<"MESH-GROUP-REMOVED ", ifname::binary>>) do 147 | {:event, "MESH-GROUP-REMOVED", String.trim_trailing(ifname)} 148 | end 149 | 150 | # MESH-SAE-AUTH-FAILURE addr=00:00:00:00:00:00 151 | def decode_notification(<<"MESH-SAE-AUTH-FAILURE ", rest::binary>>) do 152 | decode_kv_notification("MESH-SAE-AUTH-FAILURE", rest) 153 | end 154 | 155 | # MESH-SAE-AUTH-BLOCKED addr=00:00:00:00:00:00 duration=5 156 | def decode_notification(<<"MESH-SAE-AUTH-BLOCKED ", rest::binary>>) do 157 | decode_kv_notification("MESH-SAE-AUTH-BLOCKED", rest) 158 | end 159 | 160 | def decode_notification(string) do 161 | {:info, String.trim_trailing(string)} 162 | end 163 | 164 | defp eap_peer_cert_decode( 165 | binary, 166 | state \\ %{key?: true, in_quote?: false, key: <<>>, value: <<>>}, 167 | acc \\ %{} 168 | ) 169 | 170 | defp eap_peer_cert_decode(<<"=", rest::binary>>, %{key?: true} = state, acc) do 171 | eap_peer_cert_decode(rest, %{state | key?: false}, acc) 172 | end 173 | 174 | defp eap_peer_cert_decode(<<"\'", rest::binary>>, %{key?: false, in_quote?: false} = state, acc) do 175 | eap_peer_cert_decode(rest, %{state | in_quote?: true, value: state.value}, acc) 176 | end 177 | 178 | defp eap_peer_cert_decode(<<"\'", rest::binary>>, %{key?: false, in_quote?: true} = state, acc) do 179 | eap_peer_cert_decode(rest, %{state | in_quote?: false, value: state.value}, acc) 180 | end 181 | 182 | defp eap_peer_cert_decode(<<" ", rest::binary>>, %{key?: false, in_quote?: false} = state, acc) do 183 | eap_peer_cert_decode( 184 | rest, 185 | %{key?: true, in_quote?: false, key: <<>>, value: <<>>}, 186 | Map.put(acc, state.key, String.trim(state.value)) 187 | ) 188 | end 189 | 190 | defp eap_peer_cert_decode(<>, %{key?: true} = state, acc) do 191 | eap_peer_cert_decode(rest, %{state | key: state.key <> char}, acc) 192 | end 193 | 194 | defp eap_peer_cert_decode(<>, %{key?: false} = state, acc) do 195 | eap_peer_cert_decode(rest, %{state | value: state.value <> char}, acc) 196 | end 197 | 198 | defp eap_peer_cert_decode(<<>>, state, acc) do 199 | Map.put(acc, state.key, String.trim(state.value)) 200 | end 201 | 202 | defp decode_kv_notification(event, rest) do 203 | info = 204 | Regex.scan(~r(\w+=[\S*]+), rest) 205 | |> Map.new(fn [str] -> 206 | str = String.replace(str, "\'", "") 207 | [key, val] = String.split(str, "=", parts: 2) 208 | 209 | clean_val = val |> unquote_string() |> unescape_string() 210 | {key, clean_val} 211 | end) 212 | 213 | case Map.pop(info, "bssid") do 214 | {nil, _original} -> {:event, event, info} 215 | {bssid, new_info} -> {:event, event, bssid, new_info} 216 | end 217 | end 218 | 219 | @doc """ 220 | Decode a key-value response from the wpa_supplicant 221 | """ 222 | @spec decode_kv_response(String.t()) :: %{String.t() => String.t()} 223 | def decode_kv_response(resp) do 224 | resp 225 | |> String.split("\n", trim: true) 226 | |> decode_kv_pairs() 227 | end 228 | 229 | defp decode_kv_pairs(pairs) do 230 | Enum.reduce(pairs, %{}, fn pair, acc -> 231 | case String.split(pair, "=", parts: 2) do 232 | [key, value] -> 233 | clean_value = value |> String.trim_trailing() |> unescape_string() 234 | 235 | Map.put(acc, key, clean_value) 236 | 237 | _ -> 238 | # Skip 239 | acc 240 | end 241 | end) 242 | end 243 | 244 | defp unquote_string(<<"\"", _::binary>> = msg), do: String.trim(msg, "\"") 245 | defp unquote_string(<<"\'", _::binary>> = msg), do: String.trim(msg, "\'") 246 | defp unquote_string(other), do: other 247 | 248 | defp unescape_string(string) do 249 | unescape_string(string, []) 250 | |> Enum.reverse() 251 | |> :erlang.list_to_binary() 252 | end 253 | 254 | defp unescape_string("", acc), do: acc 255 | 256 | defp unescape_string(<>, acc) do 257 | value = String.to_integer(hex, 16) 258 | unescape_string(rest, [value | acc]) 259 | end 260 | 261 | defp unescape_string(<>, acc) do 262 | unescape_string(rest, [other | acc]) 263 | end 264 | 265 | @doc """ 266 | Parse WiFi access point flags 267 | 268 | See `wpa_supplicant/ctl_iface.c` and search for `flags=` for where this gets 269 | created. 270 | """ 271 | @spec parse_flags(String.t() | nil) :: [VintageNetWiFi.AccessPoint.flag()] 272 | def parse_flags(str) when is_binary(str) do 273 | flag_strings = String.split(str, ["]", "["], trim: true) 274 | 275 | # Old code depends on these atoms in the flags 276 | legacy_flags = Enum.flat_map(flag_strings, &parse_legacy_flag/1) 277 | 278 | # New code should look at these 279 | new_flags = Enum.flat_map(flag_strings, &parse_flag/1) 280 | 281 | legacy_flags ++ new_flags 282 | end 283 | 284 | def parse_flags(nil), do: [] 285 | 286 | # Old code depends on these 287 | defp parse_legacy_flag("WPA2-PSK-CCMP"), do: [:wpa2_psk_ccmp] 288 | defp parse_legacy_flag("WPA2-EAP-CCMP"), do: [:wpa2_eap_ccmp] 289 | defp parse_legacy_flag("WPA2-EAP-CCMP+TKIP"), do: [:wpa2_eap_ccmp_tkip] 290 | defp parse_legacy_flag("WPA2-PSK-CCMP+TKIP"), do: [:wpa2_psk_ccmp_tkip] 291 | defp parse_legacy_flag("WPA2-PSK+SAE-CCMP"), do: [:wpa2_psk_sae_ccmp] 292 | defp parse_legacy_flag("WPA2-SAE-CCMP"), do: [:wpa2_sae_ccmp] 293 | defp parse_legacy_flag("WPA2--CCMP"), do: [:wpa2_ccmp] 294 | defp parse_legacy_flag("WPA-PSK-CCMP"), do: [:wpa_psk_ccmp] 295 | defp parse_legacy_flag("WPA-PSK-CCMP+TKIP"), do: [:wpa_psk_ccmp_tkip] 296 | defp parse_legacy_flag("WPA-EAP-CCMP"), do: [:wpa_eap_ccmp] 297 | defp parse_legacy_flag("WPA-EAP-CCMP+TKIP"), do: [:wpa_eap_ccmp_tkip] 298 | defp parse_legacy_flag("RSN--CCMP"), do: [:rsn_ccmp] 299 | defp parse_legacy_flag(_), do: [] 300 | 301 | # This is a recursive descent parse for parsing each flag 302 | defp parse_flag(str) do 303 | str |> parse_flag([]) |> Enum.reverse() 304 | end 305 | 306 | # Parse the proto-key_mgmt-cipher style flags 307 | defp parse_flag("WPA-" <> rest, flags), do: parse_key_mgmt(rest, [:wpa | flags]) 308 | defp parse_flag("WPA2-" <> rest, flags), do: parse_key_mgmt(rest, [:wpa2 | flags]) 309 | defp parse_flag("RSN-" <> rest, flags), do: parse_key_mgmt(rest, [:rsn | flags]) 310 | defp parse_flag("OSEN-" <> rest, flags), do: parse_key_mgmt(rest, [:osen | flags]) 311 | 312 | # Parse standalone flags 313 | defp parse_flag("OWE-TRANS", flags), do: [:owe_trans | flags] 314 | defp parse_flag("OWE-TRANS-OPEN", flags), do: [:owe_trans_open | flags] 315 | defp parse_flag("WEP", flags), do: [:wep | flags] 316 | defp parse_flag("MESH", flags), do: [:mesh | flags] 317 | defp parse_flag("DMG", flags), do: [:dmg | flags] 318 | defp parse_flag("IBSS", flags), do: [:ibss | flags] 319 | defp parse_flag("ESS", flags), do: [:ess | flags] 320 | defp parse_flag("PBSS", flags), do: [:pbss | flags] 321 | defp parse_flag("P2P", flags), do: [:p2p | flags] 322 | defp parse_flag("HS20", flags), do: [:hs20 | flags] 323 | defp parse_flag("FILS", flags), do: [:fils | flags] 324 | defp parse_flag("FST", flags), do: [:fst | flags] 325 | defp parse_flag("UTF-8", flags), do: [:utf8 | flags] 326 | defp parse_flag("WPS", flags), do: [:wps | flags] 327 | defp parse_flag("SAE-H2E", flags), do: [:sae_h2e | flags] 328 | defp parse_flag("SAE-PK", flags), do: [:sae_pk | flags] 329 | 330 | defp parse_flag(other, flags) do 331 | Logger.warning("[wpa_supplicant] Unknown flag: #{other}") 332 | flags 333 | end 334 | 335 | # key_mgmt=one or more of the following separated by + signs 336 | # EAP,PSK,None,SAE,FT/EAP,FT/PSK,FT/SAE,EAP-SHA256,PSK-SHA256,EAP-SUITE-B,EAP-SUITE-B-192, 337 | # FILS-SHA256,FILS-SHA384,FT-FILS-SHA256,FT-FILS-SHA384,OWE,DPP,OSEN,"" 338 | # 339 | # IMPORTANT: These are tested in order, so it's critical that longer strings 340 | # are placed before any prefixes they contain!!! 341 | defp parse_key_mgmt("EAP-SHA256" <> rest, flags), 342 | do: parse_key_mgmt(rest, [:eap_sha256 | flags]) 343 | 344 | defp parse_key_mgmt("EAP-SUITE-B-192" <> rest, flags), 345 | do: parse_key_mgmt(rest, [:eap_suite_b_192 | flags]) 346 | 347 | defp parse_key_mgmt("EAP-SUITE-B" <> rest, flags), 348 | do: parse_key_mgmt(rest, [:eap_suite_b | flags]) 349 | 350 | defp parse_key_mgmt("PSK-SHA256" <> rest, flags), 351 | do: parse_key_mgmt(rest, [:psk_sha256 | flags]) 352 | 353 | defp parse_key_mgmt("FILS-SHA256" <> rest, flags), 354 | do: parse_key_mgmt(rest, [:fils_sha256 | flags]) 355 | 356 | defp parse_key_mgmt("FILS-SHA384" <> rest, flags), 357 | do: parse_key_mgmt(rest, [:fils_sha384 | flags]) 358 | 359 | defp parse_key_mgmt("FT-FILS-SHA256" <> rest, flags), 360 | do: parse_key_mgmt(rest, [:ft_fils_sha256 | flags]) 361 | 362 | defp parse_key_mgmt("FT-FILS-SHA384" <> rest, flags), 363 | do: parse_key_mgmt(rest, [:ft_fils_sha384 | flags]) 364 | 365 | defp parse_key_mgmt("None" <> rest, flags), do: parse_key_mgmt(rest, flags) 366 | defp parse_key_mgmt("EAP" <> rest, flags), do: parse_key_mgmt(rest, [:eap | flags]) 367 | defp parse_key_mgmt("PSK" <> rest, flags), do: parse_key_mgmt(rest, [:psk | flags]) 368 | defp parse_key_mgmt("SAE" <> rest, flags), do: parse_key_mgmt(rest, [:sae | flags]) 369 | defp parse_key_mgmt("FT/EAP" <> rest, flags), do: parse_key_mgmt(rest, [:ft_eap | flags]) 370 | defp parse_key_mgmt("FT/PSK" <> rest, flags), do: parse_key_mgmt(rest, [:ft_psk | flags]) 371 | defp parse_key_mgmt("FT/SAE" <> rest, flags), do: parse_key_mgmt(rest, [:ft_sae | flags]) 372 | defp parse_key_mgmt("OWE" <> rest, flags), do: parse_key_mgmt(rest, [:owe | flags]) 373 | defp parse_key_mgmt("DPP" <> rest, flags), do: parse_key_mgmt(rest, [:dpp | flags]) 374 | defp parse_key_mgmt("OSEN" <> rest, flags), do: parse_key_mgmt(rest, [:osen | flags]) 375 | defp parse_key_mgmt("-" <> rest, flags), do: parse_cipher(rest, flags) 376 | defp parse_key_mgmt("+" <> rest, flags), do: parse_key_mgmt(rest, flags) 377 | defp parse_key_mgmt("", flags), do: flags 378 | 379 | defp parse_key_mgmt(other, flags) do 380 | Logger.warning("[wpa_supplicant] Ignoring unknown key_mgmt flag: #{other}") 381 | flags 382 | end 383 | 384 | # See wpa_write_ciphers() for cipher list 385 | # ciphers=CCMP-256,GCMP-256,CCMP,GCMP,TKIP,AES-128-CMAC,BIP-GMAC-128,BIP-GMAC-256,BIP-CMAC-256,NONE,"" 386 | defp parse_cipher("CCMP-256" <> rest, flags), do: parse_cipher(rest, [:ccmp256 | flags]) 387 | defp parse_cipher("CCMP" <> rest, flags), do: parse_cipher(rest, [:ccmp | flags]) 388 | defp parse_cipher("GCMP-256" <> rest, flags), do: parse_cipher(rest, [:gcmp256 | flags]) 389 | defp parse_cipher("GCMP" <> rest, flags), do: parse_cipher(rest, [:gcmp | flags]) 390 | defp parse_cipher("TKIP" <> rest, flags), do: parse_cipher(rest, [:tkip | flags]) 391 | defp parse_cipher("AES-128-CMAC" <> rest, flags), do: parse_cipher(rest, [:aes128_cmac | flags]) 392 | defp parse_cipher("BIP-GMAC-128" <> rest, flags), do: parse_cipher(rest, [:bip_gmac128 | flags]) 393 | defp parse_cipher("BIP-GMAC-256" <> rest, flags), do: parse_cipher(rest, [:bip_gmac256 | flags]) 394 | defp parse_cipher("NONE" <> rest, flags), do: parse_cipher(rest, flags) 395 | defp parse_cipher("+" <> rest, flags), do: parse_cipher(rest, flags) 396 | defp parse_cipher("", flags), do: flags 397 | defp parse_cipher("-preauth", flags), do: [:preauth | flags] 398 | 399 | defp parse_cipher(other, flags) do 400 | Logger.warning("[wpa_supplicant] Ignoring unknown cipher flag: #{other}") 401 | flags 402 | end 403 | end 404 | -------------------------------------------------------------------------------- /lib/vintage_net_wifi/wpa_supplicant_ll.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2019 Frank Hunleth 2 | # 3 | # SPDX-License-Identifier: Apache-2.0 4 | # 5 | defmodule VintageNetWiFi.WPASupplicantLL do 6 | @moduledoc """ 7 | This modules provides a low-level interface for interacting with the `wpa_supplicant` 8 | 9 | Example use: 10 | 11 | ```elixir 12 | iex> {:ok, ws} = VintageNetWiFi.WPASupplicantLL.start_link(path: "/tmp/vintage_net/wpa_supplicant/wlan0", notification_pid: self()) 13 | {:ok, #PID<0.1795.0>} 14 | iex> VintageNetWiFi.WPASupplicantLL.control_request(ws, "ATTACH") 15 | {:ok, "OK\n"} 16 | iex> VintageNetWiFi.WPASupplicantLL.control_request(ws, "SCAN") 17 | {:ok, "OK\n"} 18 | iex> flush 19 | {VintageNetWiFi.WPASupplicant, 51, "CTRL-EVENT-SCAN-STARTED "} 20 | {VintageNetWiFi.WPASupplicant, 51, "CTRL-EVENT-BSS-ADDED 0 78:8a:20:87:7a:50"} 21 | {VintageNetWiFi.WPASupplicant, 51, "CTRL-EVENT-SCAN-RESULTS "} 22 | {VintageNetWiFi.WPASupplicant, 51, "CTRL-EVENT-NETWORK-NOT-FOUND "} 23 | :ok 24 | iex> VintageNetWiFi.WPASupplicantLL.control_request(ws, "BSS 0") 25 | {:ok, 26 | "id=0\nbssid=78:8a:20:82:7a:50\nfreq=2437\nbeacon_int=100\ncapabilities=0x0431\nqual=0\nnoise=-89\nlevel=-71\ntsf=0000333220048880\nage=14\nie=0008426f7062654c414e010882848b968c1298240301062a01003204b048606c0b0504000a00002d1aac011bffffff00000000000000000001000000000000000000003d1606080c000000000000000000000000000000000000007f080000000000000040dd180050f2020101000003a4000027a4000042435e0062322f00dd0900037f01010000ff7fdd1300156d00010100010237e58106788a20867a5030140100000fac040100000fac040100000fac020000\nflags=[WPA2-PSK-CCMP][ESS]\nssid=HelloWiFi\nsnr=18\nest_throughput=48000\nupdate_idx=1\nbeacon_ie=0008426f7062654c414e010882848b968c1298240301060504010300002a01003204b048606c0b0504000a00002d1aac011bffffff00000000000000000001000000000000000000003d1606080c000000000000000000000000000000000000007f080000000000000040dd180050f2020101000003a4000027a4000042435e0062322f00dd0900037f01010000ff7fdd1300156d00010100010237e58106788a20867a5030140100000fac040100000fac040100000fac020000\n"} 27 | ``` 28 | """ 29 | use GenServer 30 | require Logger 31 | 32 | defstruct control_file: nil, 33 | socket: nil, 34 | request_queue: :queue.new(), 35 | outstanding: nil, 36 | notification_pid: nil, 37 | request_timer: nil 38 | 39 | @doc """ 40 | Start the WPASupplicant low-level interface 41 | 42 | Pass the path to the wpa_supplicant control file. 43 | 44 | Notifications from the wpa_supplicant are sent to the process that 45 | calls this. 46 | """ 47 | @spec start_link(path: Path.t(), notification_pid: pid()) :: GenServer.on_start() 48 | def start_link(init_args) do 49 | GenServer.start_link(__MODULE__, init_args) 50 | end 51 | 52 | @spec control_request(GenServer.server(), binary()) :: {:ok, binary()} | {:error, any()} 53 | def control_request(server, request) do 54 | GenServer.call(server, {:control_request, request}) 55 | end 56 | 57 | @impl GenServer 58 | def init(init_args) do 59 | path = Keyword.fetch!(init_args, :path) 60 | pid = Keyword.fetch!(init_args, :notification_pid) 61 | 62 | # Blindly create the control interface's directory in case we beat 63 | # wpa_supplicant. 64 | _ = File.mkdir_p(Path.dirname(path)) 65 | 66 | # The path to our end of the socket so that wpa_supplicant can send us 67 | # notifications and responses 68 | our_path = path <> ".ex" 69 | 70 | # Blindly remove an old file just in case it exists from a previous run 71 | _ = File.rm(our_path) 72 | 73 | {:ok, socket} = 74 | :gen_udp.open(0, [:local, :binary, {:active, true}, {:ip, {:local, our_path}}]) 75 | 76 | state = %__MODULE__{ 77 | control_file: path, 78 | socket: socket, 79 | notification_pid: pid 80 | } 81 | 82 | {:ok, state} 83 | end 84 | 85 | @impl GenServer 86 | def handle_call({:control_request, message}, from, state) do 87 | new_state = 88 | state 89 | |> enqueue_request(message, from) 90 | |> maybe_send_request() 91 | 92 | {:noreply, new_state} 93 | end 94 | 95 | @impl GenServer 96 | def handle_info( 97 | {:udp, socket, _, 0, <, notification::binary>>}, 98 | %{socket: socket, notification_pid: pid} = state 99 | ) do 100 | send(pid, {__MODULE__, priority - ?0, notification}) 101 | {:noreply, state} 102 | end 103 | 104 | def handle_info({:udp, socket, _, 0, response}, %{socket: socket, outstanding: request} = state) 105 | when not is_nil(request) do 106 | {_message, from} = request 107 | _ = :timer.cancel(state.request_timer) 108 | 109 | GenServer.reply(from, {:ok, response}) 110 | 111 | new_state = %{state | outstanding: nil} |> maybe_send_request() 112 | {:noreply, new_state} 113 | end 114 | 115 | def handle_info(:request_timeout, %{outstanding: request} = state) 116 | when not is_nil(request) do 117 | {_message, from} = request 118 | 119 | GenServer.reply(from, {:error, :timeout}) 120 | new_state = %{state | outstanding: nil} |> maybe_send_request() 121 | 122 | {:noreply, new_state} 123 | end 124 | 125 | def handle_info(message, state) do 126 | Logger.error("wpa_supplicant_ll: unexpected message: #{inspect(message)}") 127 | {:noreply, state} 128 | end 129 | 130 | defp enqueue_request(state, message, from) do 131 | new_request_queue = :queue.in({message, from}, state.request_queue) 132 | 133 | %{state | request_queue: new_request_queue} 134 | end 135 | 136 | defp maybe_send_request(%{outstanding: nil} = state) do 137 | case :queue.out(state.request_queue) do 138 | {:empty, _} -> 139 | state 140 | 141 | {{:value, request}, new_queue} -> 142 | %{state | request_queue: new_queue} 143 | |> do_send_request(request) 144 | end 145 | end 146 | 147 | defp maybe_send_request(state), do: state 148 | 149 | defp do_send_request(state, {message, from} = request) do 150 | case :gen_udp.send(state.socket, {:local, state.control_file}, 0, message) do 151 | :ok -> 152 | {:ok, timer} = :timer.send_after(4000, :request_timeout) 153 | %{state | outstanding: request, request_timer: timer} 154 | 155 | error -> 156 | Logger.error("wpa_supplicant_ll: Error sending #{inspect(message)} (#{inspect(error)})") 157 | GenServer.reply(from, error) 158 | maybe_send_request(state) 159 | end 160 | end 161 | end 162 | -------------------------------------------------------------------------------- /lib/vintage_net_wifi/wps_data.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2021 WN 2 | # 3 | # SPDX-License-Identifier: Apache-2.0 4 | # 5 | defmodule VintageNetWiFi.WPSData do 6 | @moduledoc """ 7 | Utilities for handling WPS data 8 | """ 9 | 10 | @typedoc """ 11 | A map containing WPS data 12 | 13 | All keys are optional. Known keys use atoms. Unknown keys use their numeric 14 | value and their value is left as a raw binary. 15 | 16 | Known keys: 17 | 18 | * `:credential` - a map of WiFi credentials (also WPS data) 19 | * `:mac_address` - a MAC address in string form (i.e., `"aa:bb:cc:dd:ee:ff"`) 20 | * `:network_key` - a passphrase or PSK 21 | * `:network_index` - the key index 22 | """ 23 | @type t() :: %{ 24 | optional(:credential) => t(), 25 | optional(:mac_address) => binary(), 26 | optional(:network_key) => binary(), 27 | optional(:network_index) => non_neg_integer(), 28 | optional(0..65536) => binary() 29 | } 30 | 31 | @doc """ 32 | Decode WPS data 33 | 34 | The WPS data is expected to be in hex string form like what the 35 | wpa_supplicant reports. 36 | """ 37 | @spec decode(binary) :: {:ok, t()} | :error 38 | def decode(hex_string) when is_binary(hex_string) do 39 | with {:ok, raw_bytes} <- Base.decode16(hex_string, case: :mixed) do 40 | decode_all_tlv(raw_bytes, %{}) 41 | end 42 | end 43 | 44 | defp decode_all_tlv(<<>>, result), do: {:ok, result} 45 | 46 | defp decode_all_tlv(<>, result) do 47 | with {t, v} <- decode_tlv(tag, value) do 48 | decode_all_tlv(rest, Map.put(result, t, v)) 49 | end 50 | end 51 | 52 | defp decode_all_tlv(_unexpected, _result), do: :error 53 | 54 | defp decode_tlv(0x100E, value) do 55 | with {:ok, decoded} <- decode_all_tlv(value, %{}) do 56 | {:credential, decoded} 57 | end 58 | end 59 | 60 | defp decode_tlv(0x1045, value), do: {:ssid, value} 61 | defp decode_tlv(0x1027, value), do: {:network_key, value} 62 | 63 | defp decode_tlv(0x1020, <>) do 64 | mac = 65 | value 66 | |> Base.encode16() 67 | |> String.codepoints() 68 | |> Enum.chunk_every(2) 69 | |> Enum.join(":") 70 | 71 | {:mac_address, mac} 72 | end 73 | 74 | defp decode_tlv(0x1026, <>), do: {:network_index, n} 75 | defp decode_tlv(tag, value), do: {tag, value} 76 | end 77 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule VintageNetWiFi.MixProject do 2 | use Mix.Project 3 | 4 | @version "0.12.6" 5 | @source_url "https://github.com/nerves-networking/vintage_net_wifi" 6 | 7 | def project do 8 | [ 9 | app: :vintage_net_wifi, 10 | version: @version, 11 | elixir: "~> 1.14", 12 | elixirc_paths: elixirc_paths(Mix.env()), 13 | test_coverage: [tool: ExCoveralls], 14 | start_permanent: Mix.env() == :prod, 15 | compilers: [:elixir_make | Mix.compilers()], 16 | make_targets: ["all"], 17 | make_clean: ["mix_clean"], 18 | make_error_message: "", 19 | deps: deps(), 20 | dialyzer: dialyzer(), 21 | docs: docs(), 22 | package: package(), 23 | description: description(), 24 | preferred_cli_env: %{ 25 | docs: :docs, 26 | "hex.publish": :docs, 27 | "hex.build": :docs, 28 | credo: :test, 29 | "coveralls.circle": :test 30 | } 31 | ] 32 | end 33 | 34 | def application do 35 | [ 36 | extra_applications: [:crypto, :logger] 37 | ] 38 | end 39 | 40 | defp elixirc_paths(:test), do: ["lib", "test/support"] 41 | defp elixirc_paths(_), do: ["lib"] 42 | 43 | defp description do 44 | "WiFi networking for VintageNet" 45 | end 46 | 47 | defp package do 48 | %{ 49 | files: [ 50 | "CHANGELOG.md", 51 | "c_src/*.[ch]", 52 | "c_src/test-c99.sh", 53 | "lib", 54 | "LICENSES/*", 55 | "mix.exs", 56 | "Makefile", 57 | "NOTICE", 58 | "README.md", 59 | "REUSE.toml" 60 | ], 61 | licenses: ["Apache-2.0"], 62 | links: %{ 63 | "GitHub" => @source_url, 64 | "REUSE Compliance" => 65 | "https://api.reuse.software/info/github.com/nerves-networking/vintage_net_wifi" 66 | } 67 | } 68 | end 69 | 70 | defp deps do 71 | [ 72 | {:vintage_net, "~> 0.12.0 or ~> 0.13.0"}, 73 | {:credo, "~> 1.2", only: :test, runtime: false}, 74 | {:credo_binary_patterns, "~> 0.2.2", only: :test, runtime: false}, 75 | {:dialyxir, "~> 1.1", only: :dev, runtime: false}, 76 | {:elixir_make, "~> 0.6", runtime: false}, 77 | {:ex_doc, "~> 0.22", only: :docs, runtime: false}, 78 | {:excoveralls, "~> 0.13", only: :test, runtime: false} 79 | ] 80 | end 81 | 82 | defp dialyzer() do 83 | [ 84 | flags: [:missing_return, :extra_return, :unmatched_returns, :error_handling, :underspecs], 85 | plt_file: {:no_warn, "_build/plts/dialyzer.plt"} 86 | ] 87 | end 88 | 89 | defp docs do 90 | [ 91 | extras: ["README.md", "CHANGELOG.md"], 92 | main: "readme", 93 | source_ref: "v#{@version}", 94 | source_url: @source_url, 95 | skip_undefined_reference_warnings_on: ["CHANGELOG.md"] 96 | ] 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "beam_notify": {:hex, :beam_notify, "1.1.1", "da7dd04f16120bcab71f01ff81607ea38ad78af859cd87fad2012eac8feab034", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "5a09fc8449171da423f781de16b95cdec52763e60308345de6f15358c2382a42"}, 3 | "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 4 | "credo": {:hex, :credo, "1.7.11", "d3e805f7ddf6c9c854fd36f089649d7cf6ba74c42bc3795d587814e3c9847102", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "56826b4306843253a66e47ae45e98e7d284ee1f95d53d1612bb483f88a8cf219"}, 5 | "credo_binary_patterns": {:hex, :credo_binary_patterns, "0.2.6", "cfcaca0bc5c6447b96c5a03eff175c28f86c486be8e95d55b360fb90c2dd18bd", [:mix], [{:credo, "~> 1.6", [hex: :credo, repo: "hexpm", optional: false]}], "hexpm", "d36a2b56ad72bdf3183ccc81d7e7821e78c97de7c127bc8dd99a5f05ca702187"}, 6 | "dialyxir": {:hex, :dialyxir, "1.4.5", "ca1571ac18e0f88d4ab245f0b60fa31ff1b12cbae2b11bd25d207f865e8ae78a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b0fb08bb8107c750db5c0b324fa2df5ceaa0f9307690ee3c1f6ba5b9eb5d35c3"}, 7 | "earmark_parser": {:hex, :earmark_parser, "1.4.43", "34b2f401fe473080e39ff2b90feb8ddfeef7639f8ee0bbf71bb41911831d77c5", [:mix], [], "hexpm", "970a3cd19503f5e8e527a190662be2cee5d98eed1ff72ed9b3d1a3d466692de8"}, 8 | "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, 9 | "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, 10 | "ex_doc": {:hex, :ex_doc, "0.37.3", "f7816881a443cd77872b7d6118e8a55f547f49903aef8747dbcb345a75b462f9", [:mix], [{:earmark_parser, "~> 1.4.42", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "e6aebca7156e7c29b5da4daa17f6361205b2ae5f26e5c7d8ca0d3f7e18972233"}, 11 | "excoveralls": {:hex, :excoveralls, "0.18.5", "e229d0a65982613332ec30f07940038fe451a2e5b29bce2a5022165f0c9b157e", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "523fe8a15603f86d64852aab2abe8ddbd78e68579c8525ae765facc5eae01562"}, 12 | "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, 13 | "gen_state_machine": {:hex, :gen_state_machine, "3.0.0", "1e57f86a494e5c6b14137ebef26a7eb342b3b0070c7135f2d6768ed3f6b6cdff", [:mix], [], "hexpm", "0a59652574bebceb7309f6b749d2a41b45fdeda8dbb4da0791e355dd19f0ed15"}, 14 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 15 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 16 | "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, 17 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, 18 | "muontrap": {:hex, :muontrap, "1.6.0", "4e89bdd2cccaa8575f7160c06395ab26e1308e4a669b0f11992e1c8b6c601b03", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "8d8b3e7e337c3392cab902eb827fcd264a944b8bae2098e0bb5d1dd9d7f62056"}, 19 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 20 | "property_table": {:hex, :property_table, "0.3.0", "aa51e0eb5e9789edb45e1048223f7f30ffd4e4dd0e3bd4924d8fa22d7800f9f6", [:mix], [], "hexpm", "696289fe01a2d685eb460e5440e64736ec8a07d8e4748e2d573b12b590b931e3"}, 21 | "vintage_net": {:hex, :vintage_net, "0.13.6", "87b6426d4748dce32b530025236444932c4e18c5e68297f8a48c9a33a15891fa", [:make, :mix], [{:beam_notify, "~> 0.2.0 or ~> 1.0", [hex: :beam_notify, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:gen_state_machine, "~> 2.0.0 or ~> 2.1.0 or ~> 3.0.0", [hex: :gen_state_machine, repo: "hexpm", optional: false]}, {:muontrap, "~> 0.5.1 or ~> 0.6.0 or ~> 1.0", [hex: :muontrap, repo: "hexpm", optional: false]}, {:property_table, "~> 0.2.0 or ~> 0.3.0", [hex: :property_table, repo: "hexpm", optional: false]}], "hexpm", "f8410507f47dab294431802c4d501c50871c45fed74d279d1a97e34e8b4fed4c"}, 22 | } 23 | -------------------------------------------------------------------------------- /test/fixtures/root/bin/ip: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # SPDX-FileCopyrightText: 2020 Matt Ludwigs 3 | # 4 | # SPDX-License-Identifier: Apache-2.0 5 | # 6 | 7 | exit 1 8 | -------------------------------------------------------------------------------- /test/support/mock_wpa_supplicant.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2019 Frank Hunleth 2 | # 3 | # SPDX-License-Identifier: Apache-2.0 4 | # 5 | defmodule VintageNetWiFiTest.MockWPASupplicant do 6 | @moduledoc false 7 | use GenServer 8 | 9 | @spec start_link(Path.t()) :: GenServer.on_start() 10 | def start_link(path) do 11 | GenServer.start_link(__MODULE__, path) 12 | end 13 | 14 | @spec set_responses(GenServer.server(), map()) :: :ok 15 | def set_responses(server, responses) do 16 | GenServer.call(server, {:set_responses, responses}) 17 | end 18 | 19 | @spec get_requests(GenServer.server()) :: [] 20 | def get_requests(server) do 21 | GenServer.call(server, :get_requests) 22 | end 23 | 24 | @spec send_message(GenServer.server(), binary()) :: :ok 25 | def send_message(server, message) do 26 | GenServer.cast(server, {:send_message, message}) 27 | end 28 | 29 | @impl GenServer 30 | def init(path) do 31 | _ = File.rm(path) 32 | 33 | {:ok, socket} = :gen_udp.open(0, [:local, :binary, {:active, true}, {:ip, {:local, path}}]) 34 | 35 | {:ok, 36 | %{ 37 | socket_path: path, 38 | socket: socket, 39 | client_path: path <> ".ex", 40 | responses: %{}, 41 | requests: [] 42 | }} 43 | end 44 | 45 | @impl GenServer 46 | def handle_call({:set_responses, responses}, _from, state) do 47 | {:reply, :ok, %{state | responses: responses}} 48 | end 49 | 50 | def handle_call(:get_requests, _from, %{requests: requests} = state) do 51 | {:reply, requests, state} 52 | end 53 | 54 | @impl GenServer 55 | def handle_cast( 56 | {:send_message, message}, 57 | %{socket: socket, client_path: client_path} = state 58 | ) do 59 | case :gen_udp.send(socket, {:local, client_path}, 0, message) do 60 | :ok -> 61 | :ok 62 | 63 | {:error, reason} -> 64 | raise ":gen_udp.send failed to send to #{client_path}: #{inspect(reason)}" 65 | end 66 | 67 | {:noreply, state} 68 | end 69 | 70 | @impl GenServer 71 | def handle_info( 72 | {:udp, socket, from, 0, message}, 73 | %{socket: socket, responses: responses, requests: requests} = state 74 | ) do 75 | responses 76 | |> lookup(message) 77 | |> Enum.each(fn payload -> 78 | :ok = :gen_udp.send(socket, from, 0, payload) 79 | end) 80 | 81 | {:noreply, %{state | requests: requests ++ [message]}} 82 | end 83 | 84 | defp lookup(responses, message) do 85 | case Map.fetch(responses, message) do 86 | {:ok, response} -> 87 | List.wrap(response) 88 | 89 | :error -> 90 | raise RuntimeError, 91 | "No canned response for #{message}. If this is to be ignored, set the response to []" 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /test/support/utils.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2019 Frank Hunleth 2 | # SPDX-FileCopyrightText: 2021 Connor Rigby 3 | # 4 | # SPDX-License-Identifier: Apache-2.0 5 | # 6 | defmodule VintageNetWiFiTest.Utils do 7 | @moduledoc false 8 | 9 | @spec default_opts() :: keyword() 10 | def default_opts() do 11 | # Use the defaults in mix.exs, but normalize the paths to commands 12 | Application.get_all_env(:vintage_net) 13 | end 14 | 15 | @spec udhcpc_child_spec(VintageNet.ifname(), String.t()) :: Supervisor.child_spec() 16 | def udhcpc_child_spec(ifname, hostname) do 17 | beam_notify = Application.app_dir(:beam_notify, "priv/beam_notify") 18 | env = BEAMNotify.env(name: "vintage_net_comm", report_env: true) 19 | 20 | %{ 21 | id: :udhcpc, 22 | start: { 23 | VintageNet.Interface.IfupDaemon, 24 | :start_link, 25 | [ 26 | [ 27 | ifname: ifname, 28 | command: "udhcpc", 29 | args: [ 30 | "-f", 31 | "-i", 32 | ifname, 33 | "-x", 34 | "hostname:#{hostname}", 35 | "-s", 36 | beam_notify 37 | ], 38 | opts: [ 39 | stderr_to_stdout: true, 40 | log_output: :debug, 41 | log_prefix: "udhcpc(#{ifname}): ", 42 | env: env 43 | ] 44 | ] 45 | ] 46 | } 47 | } 48 | end 49 | 50 | @spec udhcpd_child_spec(VintageNet.ifname()) :: Supervisor.child_spec() 51 | def udhcpd_child_spec(ifname) do 52 | env = BEAMNotify.env(name: "vintage_net_comm", report_env: true) 53 | 54 | %{ 55 | id: :udhcpd, 56 | restart: :permanent, 57 | shutdown: 500, 58 | start: 59 | {MuonTrap.Daemon, :start_link, 60 | [ 61 | "udhcpd", 62 | [ 63 | "-f", 64 | "/tmp/vintage_net/udhcpd.conf.#{ifname}" 65 | ], 66 | [stderr_to_stdout: true, log_output: :debug, env: env] 67 | ]}, 68 | type: :worker 69 | } 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2019 Frank Hunleth 2 | # 3 | # SPDX-License-Identifier: Apache-2.0 4 | # 5 | defmodule Utils do 6 | @spec default_opts() :: keyword() 7 | def default_opts() do 8 | # Use the defaults in mix.exs, but normalize the paths to commands 9 | Application.get_all_env(:vintage_net) 10 | end 11 | end 12 | 13 | File.rm_rf!("test/tmp") 14 | 15 | # Networking support has enough pieces that are singleton in nature 16 | # that parallel running of tests can't be done. 17 | ExUnit.start(max_cases: 1) 18 | -------------------------------------------------------------------------------- /test/vintage_net_wifi/cookbook_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2021 Frank Hunleth 2 | # 3 | # SPDX-License-Identifier: Apache-2.0 4 | # 5 | defmodule VintageNetWiFi.CookbookTest do 6 | use ExUnit.Case 7 | 8 | alias VintageNetWiFi.Cookbook 9 | 10 | test "generic/2" do 11 | assert {:ok, 12 | %{ 13 | type: VintageNetWiFi, 14 | ipv4: %{method: :dhcp}, 15 | vintage_net_wifi: %{ 16 | networks: [ 17 | %{ 18 | key_mgmt: [:wpa_psk, :wpa_psk_sha256, :sae], 19 | psk: "my_passphrase", 20 | ssid: "my_ssid", 21 | ieee80211w: 1, 22 | sae_password: "my_passphrase" 23 | } 24 | ] 25 | } 26 | }} == Cookbook.generic("my_ssid", "my_passphrase") 27 | end 28 | 29 | test "open_wifi/2" do 30 | assert {:ok, 31 | %{ 32 | type: VintageNetWiFi, 33 | ipv4: %{method: :dhcp}, 34 | vintage_net_wifi: %{networks: [%{key_mgmt: :none, ssid: "free_wifi"}]} 35 | }} == Cookbook.open_wifi("free_wifi") 36 | 37 | assert {:error, :ssid_too_short} == Cookbook.open_wifi("") 38 | end 39 | 40 | test "wpa_psk/2" do 41 | assert {:ok, 42 | %{ 43 | type: VintageNetWiFi, 44 | ipv4: %{method: :dhcp}, 45 | vintage_net_wifi: %{ 46 | networks: [%{key_mgmt: :wpa_psk, psk: "my_passphrase", ssid: "my_ssid"}] 47 | } 48 | }} == Cookbook.wpa_psk("my_ssid", "my_passphrase") 49 | end 50 | 51 | test "wpa3_sae/2" do 52 | assert {:ok, 53 | %{ 54 | type: VintageNetWiFi, 55 | ipv4: %{method: :dhcp}, 56 | vintage_net_wifi: %{ 57 | networks: [ 58 | %{key_mgmt: :sae, ieee80211w: 2, sae_password: "my_passphrase", ssid: "my_ssid"} 59 | ] 60 | } 61 | }} == Cookbook.wpa3_sae("my_ssid", "my_passphrase") 62 | end 63 | 64 | test "wpa_eap_peap/2" do 65 | assert {:ok, 66 | %{ 67 | type: VintageNetWiFi, 68 | ipv4: %{method: :dhcp}, 69 | vintage_net_wifi: %{ 70 | networks: [ 71 | %{ 72 | key_mgmt: :wpa_eap, 73 | ssid: "corp_wifi", 74 | eap: "PEAP", 75 | identity: "username", 76 | password: "password", 77 | phase2: "auth=MSCHAPV2" 78 | } 79 | ] 80 | } 81 | }} == Cookbook.wpa_eap_peap("corp_wifi", "username", "password") 82 | end 83 | 84 | test "open_access_point/2" do 85 | assert {:ok, 86 | %{ 87 | type: VintageNetWiFi, 88 | ipv4: %{address: {192, 168, 24, 1}, method: :static, netmask: {255, 255, 255, 0}}, 89 | dhcpd: %{end: {192, 168, 24, 250}, start: {192, 168, 24, 10}}, 90 | vintage_net_wifi: %{networks: [%{key_mgmt: :none, mode: :ap, ssid: "my_network"}]} 91 | }} == Cookbook.open_access_point("my_network") 92 | 93 | assert {:ok, 94 | %{ 95 | type: VintageNetWiFi, 96 | ipv4: %{address: {10, 1, 2, 1}, method: :static, netmask: {255, 255, 255, 0}}, 97 | dhcpd: %{end: {10, 1, 2, 250}, start: {10, 1, 2, 10}}, 98 | vintage_net_wifi: %{networks: [%{key_mgmt: :none, mode: :ap, ssid: "another_net"}]} 99 | }} == Cookbook.open_access_point("another_net", "10.1.2.3") 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /test/vintage_net_wifi/event_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2021 Dömötör Gulyás 2 | # SPDX-FileCopyrightText: 2022 Connor Rigby 3 | # 4 | # SPDX-License-Identifier: Apache-2.0 5 | # 6 | defmodule VintageNetWiFi.EventTest do 7 | use ExUnit.Case 8 | alias VintageNetWiFi.Event 9 | 10 | doctest Event 11 | 12 | test "create CTRL-EVENT-ASSOC-REJECT" do 13 | assert Event.new( 14 | "CTRL-EVENT-ASSOC-REJECT", 15 | %{"bssid" => "ab:cd:ef:01:02:03", "status_code" => "1"} 16 | ) == %Event{ 17 | name: "CTRL-EVENT-ASSOC-REJECT", 18 | bssid: "ab:cd:ef:01:02:03", 19 | status_code: 1 20 | } 21 | end 22 | 23 | test "create CTRL-EVENT-SSID-TEMP-DISABLED" do 24 | assert Event.new( 25 | "CTRL-EVENT-SSID-TEMP-DISABLED", 26 | %{ 27 | "id" => "0", 28 | "ssid" => "abcdef010203", 29 | "auth_failures" => "1", 30 | "duration" => "10", 31 | "reason" => "CONN_FAILED" 32 | } 33 | ) == %Event{ 34 | name: "CTRL-EVENT-SSID-TEMP-DISABLED", 35 | id: 0, 36 | ssid: "abcdef010203", 37 | auth_failures: 1, 38 | duration: 10, 39 | reason: "CONN_FAILED" 40 | } 41 | end 42 | 43 | test "create CTRL-EVENT-SSID-REENABLED" do 44 | assert Event.new( 45 | "CTRL-EVENT-SSID-REENABLED", 46 | %{"id" => "0", "ssid" => "abcdef010203"} 47 | ) == %Event{ 48 | name: "CTRL-EVENT-SSID-REENABLED", 49 | id: 0, 50 | ssid: "abcdef010203" 51 | } 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /test/vintage_net_wifi/utils_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2019 Frank Hunleth 2 | # 3 | # SPDX-License-Identifier: Apache-2.0 4 | # 5 | defmodule VintageNetWiFi.UtilsTest do 6 | use ExUnit.Case 7 | 8 | alias VintageNetWiFi.Utils 9 | 10 | test "2.4 Ghz channels" do 11 | for channel <- 1..13 do 12 | info = Utils.frequency_info(2407 + channel * 5) 13 | assert info.channel == channel 14 | assert info.band == :wifi_2_4_ghz 15 | end 16 | 17 | info = Utils.frequency_info(2484) 18 | assert info.channel == 14 19 | assert info.band == :wifi_2_4_ghz 20 | end 21 | 22 | test "5 Ghz channels" do 23 | count_high = 24 | for channel <- 7..173 do 25 | case Utils.frequency_info(5035 + (channel - 7) * 5) do 26 | %{channel: ^channel, band: :wifi_5_ghz} -> 27 | 1 28 | 29 | %{channel: 0, band: :unknown} -> 30 | 0 31 | end 32 | end 33 | 34 | count_low = 35 | for channel <- 183..196 do 36 | case Utils.frequency_info(4915 + (channel - 183) * 5) do 37 | %{channel: ^channel, band: :wifi_5_ghz} -> 38 | 1 39 | 40 | %{channel: 0, band: :unknown} -> 41 | 0 42 | end 43 | end 44 | 45 | # There are 65 possible 5 GHz channels 46 | total = Enum.sum(count_low) + Enum.sum(count_high) 47 | assert total == 65 48 | end 49 | 50 | test "power increases monotonically and is in range for 2.4 GHz" do 51 | info = Utils.frequency_info(2484) 52 | percents = for dbm <- -130..0, do: info.dbm_to_percent.(dbm) 53 | 54 | assert 100 == Enum.max(percents) 55 | assert 1 == Enum.min(percents) 56 | 57 | assert Enum.sort(percents) == percents 58 | end 59 | 60 | test "power percent spot checks for 2.4 GHz" do 61 | info = Utils.frequency_info(2484) 62 | assert 93 == info.dbm_to_percent.(-34) 63 | assert 85 == info.dbm_to_percent.(-44) 64 | assert 74 == info.dbm_to_percent.(-54) 65 | assert 60 == info.dbm_to_percent.(-64) 66 | assert 42 == info.dbm_to_percent.(-74) 67 | assert 22 == info.dbm_to_percent.(-84) 68 | assert 1 == info.dbm_to_percent.(-94) 69 | end 70 | 71 | test "power increases monotonically and is in range for 5 GHz" do 72 | info = Utils.frequency_info(4915) 73 | percents = for dbm <- -130..0, do: info.dbm_to_percent.(dbm) 74 | 75 | assert 100 == Enum.max(percents) 76 | assert 1 == Enum.min(percents) 77 | 78 | assert Enum.sort(percents) == percents 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /test/vintage_net_wifi/wpa2_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2019 Frank Hunleth 2 | # 3 | # SPDX-License-Identifier: Apache-2.0 4 | # 5 | defmodule VintageNetWiFi.WPA2Test do 6 | use ExUnit.Case 7 | alias VintageNetWiFi.WPA2 8 | 9 | doctest WPA2 10 | 11 | test "returns error on bad passwords" do 12 | assert WPA2.to_psk( 13 | "SSID", 14 | "12345678901234567890123456789012345678901234567890123456789012345" 15 | ) == {:error, :password_too_long} 16 | 17 | assert WPA2.to_psk("SSID", <<1, 2, 3, 4, 5, 6, 7, 8>>) == {:error, :invalid_characters} 18 | 19 | assert WPA2.to_psk("SSID", "0123456") === {:error, :password_too_short} 20 | end 21 | 22 | test "emojis in SSIDs work" do 23 | assert WPA2.to_psk("🐢🐢🐢🐢🐢", "nervestraining") == 24 | {:ok, "EA188BD3959A678D78D0D12D0E5F2E7B070A0F787AACAEA4F3F0D856AE5D6F14"} 25 | end 26 | 27 | test "returns error on bad SSIDs" do 28 | assert WPA2.to_psk("12345678901234567890123456789012345", "password") 29 | end 30 | 31 | test "passes IEEE 802.11i test vectors" do 32 | # See IEEE Std 802.11i-2004 Appendix H.4 33 | assert WPA2.to_psk("IEEE", "password") == 34 | {:ok, 35 | "F42C6FC52DF0EBEF9EBB4B90B38A5F90" <> 36 | "2E83FE1B135A70E23AED762E9710A12E"} 37 | 38 | assert WPA2.to_psk("ThisIsASSID", "ThisIsAPassword") == 39 | {:ok, 40 | "0DC0D6EB90555ED6419756B9A15EC3E3" <> 41 | "209B63DF707DD508D14581F8982721AF"} 42 | 43 | assert WPA2.to_psk("ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") == 44 | {:ok, 45 | "BECB93866BB8C3832CB777C2F559807C" <> 46 | "8C59AFCB6EAE734885001300A981CC62"} 47 | end 48 | 49 | test "PSKs get passed through" do 50 | psk = "BECB93866BB8C3832CB777C2F559807C8C59AFCB6EAE734885001300A981CC62" 51 | 52 | assert WPA2.to_psk("anyssid", psk) == {:ok, psk} 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /test/vintage_net_wifi/wpa_supplicant_ll_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2019 Frank Hunleth 2 | # 3 | # SPDX-License-Identifier: Apache-2.0 4 | # 5 | defmodule VintageNetWiFi.WPASupplicantLLTest do 6 | use ExUnit.Case 7 | import ExUnit.CaptureLog 8 | 9 | alias VintageNetWiFi.WPASupplicantLL 10 | alias VintageNetWiFiTest.MockWPASupplicant 11 | 12 | setup do 13 | socket_path = "test_tmp/tmp_wpa_supplicant_socket" 14 | mock = start_supervised!({MockWPASupplicant, socket_path}) 15 | 16 | on_exit(fn -> 17 | _ = File.rm(socket_path) 18 | _ = File.rm(socket_path <> ".ex") 19 | end) 20 | 21 | {:ok, socket_path: socket_path, mock: mock} 22 | end 23 | 24 | test "receives notifications", context do 25 | start_supervised!({WPASupplicantLL, path: context.socket_path, notification_pid: self()}) 26 | 27 | MockWPASupplicant.send_message(context.mock, "<1>Hello") 28 | MockWPASupplicant.send_message(context.mock, "<2>Goodbye") 29 | 30 | assert_receive {VintageNetWiFi.WPASupplicantLL, 1, "Hello"} 31 | assert_receive {VintageNetWiFi.WPASupplicantLL, 2, "Goodbye"} 32 | end 33 | 34 | test "responds to requests", context do 35 | ll = start_supervised!({WPASupplicantLL, path: context.socket_path, notification_pid: self()}) 36 | 37 | MockWPASupplicant.set_responses(context.mock, %{"SCAN" => "OK"}) 38 | 39 | assert {:ok, "OK"} = WPASupplicantLL.control_request(ll, "SCAN") 40 | end 41 | 42 | test "ignores unexpected responses", context do 43 | # capture_log hides the "log message from WPASupplicantLL when it sees an unexpected message" 44 | capture_log(fn -> 45 | ll = 46 | start_supervised!({WPASupplicantLL, path: context.socket_path, notification_pid: self()}) 47 | 48 | MockWPASupplicant.send_message(context.mock, "Bad response") 49 | 50 | # Wait a bit here and simultaneously make sure we don't get a notification 51 | refute_receive {VintageNetWiFi.WPASupplicantLL, _priority, _message} 52 | 53 | # If WPASupplicantLL crashes, this will fail. Remove capture_log and look at the log messages. 54 | assert Process.alive?(ll) 55 | end) 56 | end 57 | 58 | test "handles notifications while waiting for a response", context do 59 | ll = start_supervised!({WPASupplicantLL, path: context.socket_path, notification_pid: self()}) 60 | 61 | MockWPASupplicant.set_responses(context.mock, %{"SCAN" => ["<1>Notification", "OK"]}) 62 | 63 | assert {:ok, "OK"} = WPASupplicantLL.control_request(ll, "SCAN") 64 | assert_receive {VintageNetWiFi.WPASupplicantLL, 1, "Notification"} 65 | assert MockWPASupplicant.get_requests(context.mock) == ["SCAN"] 66 | end 67 | 68 | test "multiple requests outstanding", context do 69 | ll = start_supervised!({WPASupplicantLL, path: context.socket_path, notification_pid: self()}) 70 | 71 | # The intention here is to start up enough processes to exercise 72 | # the multiple request outstanding code since it's currently not written 73 | # to make this easy to test. 74 | process_count = 100 75 | 76 | responses = for i <- 1..process_count, into: %{}, do: {"REQUEST#{i}", "OK#{i}"} 77 | 78 | MockWPASupplicant.set_responses(context.mock, responses) 79 | main_process = self() 80 | 81 | for i <- 1..process_count do 82 | spawn(fn -> 83 | request = "REQUEST#{i}" 84 | expected = "OK#{i}" 85 | assert {:ok, expected} == WPASupplicantLL.control_request(ll, request) 86 | send(main_process, "DONE#{i}") 87 | end) 88 | end 89 | 90 | # Wait for everything to complete 91 | for i <- 1..process_count do 92 | expected = "DONE#{i}" 93 | assert_receive ^expected, 5_000 94 | end 95 | end 96 | 97 | test "requests timeouts", context do 98 | ll = start_supervised!({WPASupplicantLL, path: context.socket_path, notification_pid: self()}) 99 | 100 | MockWPASupplicant.set_responses(context.mock, %{"Hello!" => []}) 101 | 102 | assert {:error, :timeout} == WPASupplicantLL.control_request(ll, "Hello!") 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /test/vintage_net_wifi/wps_data_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2021 WN 2 | # 3 | # SPDX-License-Identifier: Apache-2.0 4 | # 5 | defmodule VintageNetWiFi.WPSDataTest do 6 | use ExUnit.Case 7 | alias VintageNetWiFi.WPSData 8 | 9 | doctest WPSData 10 | 11 | test "decodes SSID and passphrase credentials" do 12 | assert WPSData.decode( 13 | "100e003e10260001011045000b574c414e2d414539343536100300020020100f00020008102700104142434445463936363039353639353710200006b217eac18f1d" 14 | ) == { 15 | :ok, 16 | %{ 17 | credential: %{ 18 | 4099 => <<0, 32>>, 19 | 4111 => <<0, 8>>, 20 | :network_key => "ABCDEF9660956957", 21 | :ssid => "WLAN-AE9456", 22 | :mac_address => "B2:17:EA:C1:8F:1D", 23 | :network_index => 1 24 | } 25 | } 26 | } 27 | end 28 | 29 | test "errors on malformed strings" do 30 | assert WPSData.decode("100e00310262") == :error 31 | assert WPSData.decode("Hello") == :error 32 | end 33 | end 34 | --------------------------------------------------------------------------------