diff options
author | Johannes Stoelp <johannes.stoelp@gmail.com> | 2022-01-14 23:51:05 +0100 |
---|---|---|
committer | Johannes Stoelp <johannes.stoelp@gmail.com> | 2022-01-14 23:51:05 +0100 |
commit | f9928a1a08c57fe853888119a996c3acc98ee09d (patch) | |
tree | c9770b76ffcc281da141f3aa2c595600372c0fca | |
download | pio-nodemcuv2-dhcp-server-f9928a1a08c57fe853888119a996c3acc98ee09d.tar.gz pio-nodemcuv2-dhcp-server-f9928a1a08c57fe853888119a996c3acc98ee09d.zip |
Able to offer IP address + DNS/Gateway ...
Worked with devices at my hand.
-rw-r--r-- | .clang-format | 104 | ||||
-rw-r--r-- | .gitignore | 5 | ||||
-rw-r--r-- | LICENSE | 21 | ||||
-rw-r--r-- | Makefile | 20 | ||||
-rw-r--r-- | README.md | 141 | ||||
-rw-r--r-- | doc/dhcp.txt | 134 | ||||
-rw-r--r-- | lib/dhcp/dhcp.cc | 36 | ||||
-rw-r--r-- | lib/dhcp/dhcp.h | 116 | ||||
-rw-r--r-- | lib/dhcp/lease_db.h | 111 | ||||
-rw-r--r-- | lib/dhcp/types.h | 14 | ||||
-rw-r--r-- | lib/dhcp/utils.h | 31 | ||||
-rw-r--r-- | platformio.ini | 20 | ||||
-rw-r--r-- | src/main.cc | 349 | ||||
-rw-r--r-- | test/native/dhcp.cc | 65 | ||||
-rw-r--r-- | test/native/hash.cc | 71 | ||||
-rw-r--r-- | test/native/lease_db.cc | 85 |
16 files changed, 1323 insertions, 0 deletions
diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..cc75bce --- /dev/null +++ b/.clang-format @@ -0,0 +1,104 @@ +# dotfiles -- clang-format +# author: johannst +# doc : https://clang.llvm.org/docs/ClangFormatStyleOptions.html + +Language: Cpp +Standard: Auto + +AccessModifierOffset: -2 +AlignAfterOpenBracket: Align +AlignConsecutiveMacros: true +AlignConsecutiveAssignments: false +AlignConsecutiveDeclarations: false +AlignEscapedNewlines: Left +AlignOperands: true +AlignTrailingComments: true +AllowAllArgumentsOnNextLine: true +AllowAllConstructorInitializersOnNextLine: true +AllowAllParametersOfDeclarationOnNextLine: true +AllowShortBlocksOnASingleLine: false +AllowShortCaseLabelsOnASingleLine: false +AllowShortFunctionsOnASingleLine: Inline +AllowShortIfStatementsOnASingleLine: Never +AllowShortLambdasOnASingleLine: All +AllowShortLoopsOnASingleLine: false +AlwaysBreakAfterReturnType: None +AlwaysBreakBeforeMultilineStrings: true +AlwaysBreakTemplateDeclarations: Yes +BinPackArguments: true +BinPackParameters: true +BreakBeforeBraces: Custom +BraceWrapping: + AfterCaseLabel: false + AfterClass: false + AfterControlStatement: false + AfterEnum: false + AfterFunction: false + AfterNamespace: false + AfterStruct: false + AfterUnion: false + AfterExternBlock: false + BeforeCatch: false + BeforeElse: false + IndentBraces: false + SplitEmptyFunction: true + SplitEmptyRecord: true + SplitEmptyNamespace: true +BreakBeforeBinaryOperators: None +BreakBeforeTernaryOperators: true +BreakConstructorInitializers: AfterColon +BreakConstructorInitializersBeforeComma: false +BreakInheritanceList: BeforeColon +BreakBeforeInheritanceComma: false +BreakStringLiterals: true +ColumnLimit: 140 +CompactNamespaces: true +ConstructorInitializerAllOnOneLineOrOnePerLine: false +ConstructorInitializerIndentWidth: 2 +ContinuationIndentWidth: 4 +Cpp11BracedListStyle: true +DerivePointerAlignment: false +DisableFormat: false +ExperimentalAutoDetectBinPacking: false +FixNamespaceComments: true +IncludeBlocks: Preserve +# Could use `IncludeCategories` to define rules for includes +IndentCaseLabels: true +IndentPPDirectives: AfterHash +IndentWidth: 4 +IndentWrappedFunctionNames: false +KeepEmptyLinesAtTheStartOfBlocks: false +MacroBlockBegin: '' +MacroBlockEnd: '' +MaxEmptyLinesToKeep: 2 +NamespaceIndentation: All +PenaltyBreakAssignment: 2 +PenaltyBreakBeforeFirstCallParameter: 1 +PenaltyBreakComment: 300 +PenaltyBreakFirstLessLess: 120 +PenaltyBreakString: 1000 +PenaltyBreakTemplateDeclaration: 10 +PenaltyExcessCharacter: 1000000 +PenaltyReturnTypeOnItsOwnLine: 200 +PointerAlignment: Left +ReflowComments: true +SortIncludes: true +SortUsingDeclarations: true +SpaceAfterCStyleCast: false +SpaceAfterLogicalNot: false +SpaceAfterTemplateKeyword: false +SpaceBeforeAssignmentOperators: true +SpaceBeforeCpp11BracedList: false +SpaceBeforeCtorInitializerColon: true +SpaceBeforeInheritanceColon: true +SpaceBeforeParens: ControlStatements +SpaceBeforeRangeBasedForLoopColon: true +SpaceInEmptyParentheses: false +SpacesBeforeTrailingComments: 2 +SpacesInAngles: false +SpacesInCStyleCastParentheses: false +SpacesInContainerLiterals: true +SpacesInParentheses: false +SpacesInSquareBrackets: false +TabWidth: 4 +UseTab: Never diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..daa6f43 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.pio +.cache +.ccls +blob +compile_commands.json @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Johannes Stoelp + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..203276a --- /dev/null +++ b/Makefile @@ -0,0 +1,20 @@ +# Copyright (c) 2022 Johannes Stoelp + +build: + pio run + +run: + pio run -e nodemcuv2 -t upload && pio device monitor -b 115200 + +check: + pio test + +clean: + pio run -t clean + +help: + @echo "Targets:" + @echo " build - Build project." + @echo " run - Build & flash project and attach serial monitor." + @echo " check - Run tests." + @echo " clean - Clean project." diff --git a/README.md b/README.md new file mode 100644 index 0000000..5b1bb29 --- /dev/null +++ b/README.md @@ -0,0 +1,141 @@ +# pio-nodemcuv2-dhcp-server + +This repository implements a minimal [dhcp server][dhcp] on the [nodemcu v2] +board using the [arduino] framework and the [PlatformIO][pio] build +environment. + + +## DHCP protocol + +All details of the dhcp protocol required for this implementation can be found +in the following two RFCs +- [rfc2131]: Dynamic Host Configuration Protocol +- [rfc2132]: DHCP Options and BOOTP Vendor Extensions + +## Run + +Install PlatformIO core following the [installation guide][pio-install]. + +```shell +# Build the project. +pio run + +# Run the test (only added host native tests). +pio test + +# ... Attach nodemcu v2 board via USB. + +# Build and flash the embedded software. +pio run -e nodemcuv2 -t upload + +# Connect to the serial monitor. +pio device monitor -b 115200 +``` + +> Check the [Makefile](Makefile) for reference. + +## Configuration + +The wifi client and dhcp server are currently configured with the following +global variables in [main.cc](src/main.cc). + +```cpp +/// -- WIFI access configuration. + +static constexpr char STATION_SSID[] = "<SSID>"; +static constexpr char STATION_WPA2[] = "<WPA2PW>"; + +/// -- WIFI client config. + +static const IPAddress LOCAL_IP(10, 0, 0, 2); +static const IPAddress GATEWAY(10, 0, 0, 1); +static const IPAddress BROADCAST(10, 0, 0, 255); +static const IPAddress SUBNET(255, 255, 255, 0); +static const IPAddress DNS1(192, 168, 2, 1); + +/// -- DHCP lease config. + +static const IPAddress LEASE_START(10, 0, 0, 10); +static constexpr u32 LEASE_TIME_SECS = 8 * 60 * 60; /* 8h */ +``` + +## Why all this? + +My ultimate goal was to setup a **guest wifi** to isolate my home network while +offering wifi to my guests. +For that I planned to use of the baked in guest wifi feature of the router I +got from my ISP. + +In my home network I am running [pi-hole] for blocking ads and trackers, which +works by blocking those services on the DNS level. For this to function, all +the devices in my home network need to use the pi-hole node as DNS server. +This is typically done by letting the dhcp server distribute the DNS server to +use (so it does not need to be configured on each device). +Because the built-in dhcp server in the ISP router doesn't allow to configure +the DNS server I disabled that one and run the optional dhcp server which comes +with pi-hole. + +With that setup as starting point, one problem arises when using the guest wifi +on the ISP router. Guest devices connected to the guest wifi don't see a dhcp +server and hence don't get an IP address assigned. In turn, the setup is +useless as I don't want guests to statically configure an IP address :^) + +Therefore the plan was to write a minimal dhcp server running on a [nodemcu v2] +which would then linger in the guest wifi and distribute IP addresses to my +guests. + +> Why a nodemcu v2? Because it has an wireless antenna and I had one laying +> around collecting dust. + +So all in all my home setup would look something like the following. + +``` + Home +-----------+ Guest +---------------+ + Wifi (((-| ISP modem |-))) Wifi (((-| nodemcuv2 | + | & router | | (dhcp server) | + +-----+-----+ +---------------+ + | + +-------+-------+ + | pihole | + | (dhcp server) | + +---------------+ +``` + +## End of the story ... + +... I read the RFCs, developed & tested the dhcp server in my home network and +then learned that the guests are isolated and I couldn't disable that isolation +in the ISP router. + +At the end I couldn't deploy the guest wifi as planned but I had fun +implementing the dhcp server and I guess learned something :^) + +For now I switched over to offering guest wifi by running an access point on +the raspberry pi spanning a new sub-net and then doing NAT and iptables +isolation to protect my home network. It's not ideal but it's sufficient for +now. + +``` + Home +-----------+ + Wifi (((-| ISP modem | + | & router | + +-----+-----+ + | + +-------+-------+----------+ Guest + | pihole | NAT |-))) Wifi + | (dhcp server) | iptables | + +---------------+----------+ +``` + +## License + +This project is licensed under the [MIT](LICENSE) license. + +[dhcp]: https://en.wikipedia.org/wiki/Dynamic_Host_Configuration_Protocol +[rfc2131]: https://datatracker.ietf.org/doc/html/rfc2131 +[rfc2132]: https://datatracker.ietf.org/doc/html/rfc2132 +[pio]: https://platformio.org +[pio-install]: https://docs.platformio.org/en/latest//core/installation.html +[arduino]: https://docs.platformio.org/en/latest/frameworks/arduino.html +[pi-hole]: https://pi-hole.net +[nodemcu v2]: https://www.az-delivery.de/en/products/nodemcu diff --git a/doc/dhcp.txt b/doc/dhcp.txt new file mode 100644 index 0000000..686137b --- /dev/null +++ b/doc/dhcp.txt @@ -0,0 +1,134 @@ +dhcp protocol: https://datatracker.ietf.org/doc/html/rfc2131 +dhcp options : https://datatracker.ietf.org/doc/html/rfc2132 + +dhcp message: + + 0 1 2 3 + 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | op (1) | htype (1) | hlen (1) | hops (1) | + +---------------+---------------+---------------+---------------+ + | xid (4) | + +-------------------------------+-------------------------------+ + | secs (2) | flags (2) | + +-------------------------------+-------------------------------+ + | ciaddr (4) | + +---------------------------------------------------------------+ + | yiaddr (4) | + +---------------------------------------------------------------+ + | siaddr (4) | + +---------------------------------------------------------------+ + | giaddr (4) | + +---------------------------------------------------------------+ + | | + | chaddr (16) | + | | + | | + +---------------------------------------------------------------+ + | | + | sname (64) | + +---------------------------------------------------------------+ + | | + | file (128) | + +---------------------------------------------------------------+ + | | + | options (variable) | + +---------------------------------------------------------------+ + +dhcp message flags field: + + 1 1 1 1 1 1 + 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + |B| MBZ | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + + B: BROADCAST flag + MBZ: MUST BE ZERO (reserved for future use) + +dhcp message field description: + + FIELD OCTETS DESCRIPTION + ----- ------ ----------- + op 1 Message op code / message type. + 1 = BOOTREQUEST, 2 = BOOTREPLY + htype 1 Hardware address type, see ARP section in "Assigned + Numbers" RFC; e.g., '1' = 10mb ethernet. + hlen 1 Hardware address length (e.g. '6' for 10mb + ethernet). + hops 1 Client sets to zero, optionally used by relay agents + when booting via a relay agent. + xid 4 Transaction ID, a random number chosen by the + client, used by the client and server to associate + messages and responses between a client and a + server. + secs 2 Filled in by client, seconds elapsed since client + began address acquisition or renewal process. + flags 2 Flags ([0]: broadcast flag). + ciaddr 4 Client IP address; only filled in if client is in + BOUND, RENEW or REBINDING state and can respond + to ARP requests. + yiaddr 4 'your' (client) IP address. + siaddr 4 IP address of next server to use in bootstrap; + returned in DHCPOFFER, DHCPACK by server. + giaddr 4 Relay agent IP address, used in booting via a + relay agent. + chaddr 16 Client hardware address. + sname 64 Optional server host name, null terminated string. + file 128 Boot file name, null terminated string; "generic" + name or null in DHCPDISCOVER, fully qualified + directory-path name in DHCPOFFER. + options var Optional parameters field. See the options + documents for a list of defined options. + +- A DHCP server always returns its own address in the 'server identifier' + option. + +- Client must accept DHCP of at least 576 octets (312 octects of option) + +- Time values (eg lease) are relative times in seconds and should be treated + relative wo each nodes clock. + +- Services: + - Parameter repository (2.1), used for persistent storage of network + parameters. + - Dynamic nw address allocation (2.2) + +- Protocol + - op == BOOTREQUEST client -> server message + - op == BOOTREPLY server -> client message + - options[0:4] -> magic cookie {0x63, 0x82, 0x53, 0x63} + - option (53 - DHCP Message Type) required in all dhcp messages + + 1. Client broadcasts DHCPDISCOVER (53). + 2. Server respond with DHCPOFFER (53). + - yiaddr -> allocated address + - additional options + 3. Client broadcasts DHCPREQUEST (53). + - must include 'server identifier' option + - 'requested ip address' option must be set to 'yiaddr' offerened in 2. + 4. Server sends DHCPACK (53) to client. + - fill 'yiaddr' with allocated address + - add configuration parameters + 5. Client may send DHCPDECLINE (53) if it detects addr already in use. + 6. Client may send DHCPRELEASE (53). + +- Constructing dhcp message + - variable options must end with 'end (255)' option + - client -> server udp port 67 + - server -> client udp port 68 + - @server (msg received by client) + - giaddr !=0 -> using BOOTP relay -> send resp to @giaddr:67 + - giaddr == 0 && ciaddr != 0 -> send resp to @ciaddr:68 + - giaddr == 0 && ciaddr == 0 && flags.b == 1 -> send resp to broadcast 0xffff_ffff + - giaddr == 0 && ciaddr == 0 && flags.b == 0 -> send resp to client hw addr + yiaddr + +- Options: + - 'maximum DHCP message size' + - Client may negotiate lager dhcp messages. + - 'server identifier' + - A DHCP server always returns its own address in the option. + - 'client identifier' + - Client may pass explicit client identifier to server. + - 'DHCP message type' (REQUIRED) + - type of dhcp message diff --git a/lib/dhcp/dhcp.cc b/lib/dhcp/dhcp.cc new file mode 100644 index 0000000..1e5f785 --- /dev/null +++ b/lib/dhcp/dhcp.cc @@ -0,0 +1,36 @@ +// Copyright (c) 2022 Johannes Stoelp + +#include "dhcp.h" +#include "utils.h" + +std::optional<struct option_view> get_option(const u8* opt, usize len, dhcp_option search_tag) { + const u8* end = opt + len; + + while (opt < end) { + // Get tag of current option. + dhcp_option tag = from_raw<dhcp_option>(*opt++); + + if (tag == dhcp_option::END) { + break; + } + + // Extract length of current option. + usize len = *opt++; + + if (tag == search_tag) { + if (static_cast<usize>(end - opt) < len) { + // If length is malformed. + return std::nullopt; + } else { + // Option found. + return {{opt, len}}; + } + } + + // Advance option iterator to beginning of next option. + opt = opt + len; + } + + // Option not found. + return std::nullopt; +} diff --git a/lib/dhcp/dhcp.h b/lib/dhcp/dhcp.h new file mode 100644 index 0000000..ce904f0 --- /dev/null +++ b/lib/dhcp/dhcp.h @@ -0,0 +1,116 @@ +// Copyright (c) 2022 Johannes Stoelp +// +// dhcp protocol: https://datatracker.ietf.org/doc/html/rfc2131 +// dhcp options : https://datatracker.ietf.org/doc/html/rfc2132 + +#ifndef DHCP_H +#define DHCP_H + +#include "types.h" + +#include <optional> + +// -- Global constants. + +constexpr u16 DHCP_SERVER_PORT = 67; +constexpr u16 DHCP_CLIENT_PORT = 68; + +constexpr usize DHCP_MESSAGE_LEN = 576; +constexpr usize DHCP_MESSAGE_MIN_LEN = 243; + +constexpr u32 DHCP_OPTION_COOKIE = 0x63538263; + +enum class dhcp_operation : u8 { + BOOTREQUEST = 1, + BOOTREPLY, +}; + +enum class dhcp_option : u8 { + PAD = 0, + END = 255, + + // Vendor Extensions. + SUBNET_MASK = 1, + ROUTER = 3, + DNS = 6, + + // IP Layer Parameters per Interface. + BROADCAST_ADDR = 28, + + // DHCP Extensions. + REQUESTED_IP = 50, + IP_ADDRESS_LEASE_TIME, + OPTION_OVERLOAD, + DHCP_MESSAGE_TYPE, + SERVER_IDENTIFIER, + PARAMETER_REQUEST_LIST, + MESSAGE, + MAX_DHCP_MESSAGE_SIZE, + RENEWAL_TIME_T1, + REBINDING_TIME_T2, + CLASS_ID, + CLIENT_ID, +}; + +// -- DHCP message. + +enum class dhcp_message_type : u8 { + DHCP_DISCOVER = 1, + DHCP_OFFER, + DHCP_REQUEST, + DHCP_DECLINE, + DHCP_ACK, + DHCP_NAK, + DHCP_RELEAE, +}; + +struct dhcp_message { + dhcp_operation op; + u8 htype; + u8 hlen; + u8 hops; + u32 xid; + u16 secs; + u16 flags; + u32 ciaddr; + u32 yiaddr; + u32 siaddr; + u32 giaddr; + u8 chaddr[16]; + u8 sname[64]; + u8 file[128]; + u32 cookie; + u8 options[312 - 4 /* cookie len */]; +} __attribute__((packed)); + +static_assert(DHCP_MESSAGE_MIN_LEN == offsetof(dhcp_message, options) + 3 /* DHCP_MESSAGE_TYPE size */, + "Declare min size as dhcp_message with the DHCP_MESSAGE_TYPE option."); + +// -- DHCP Utilities. + +struct option_view { + const u8* data; + usize len; +}; + +// Search for the option with tag 'search_tag' in the options 'opt' of length 'len'. +std::optional<struct option_view> get_option(const u8* opt, usize len, dhcp_option search_tag); + +template<typename T> +constexpr T get_opt_val(const u8* opt_data) { + T ret = 0; + for (size_t i = 0; i < sizeof(T); ++i) { + ret = (ret << 8) | *opt_data++; + } + return ret; +} + +template<typename T> +u8* put_opt_val(u8* opt_data, T val) { + for (size_t i = sizeof(T); i; --i) { + *opt_data++ = (val >> ((i - 1) * 8)) & 0xff; + } + return opt_data; +} + +#endif diff --git a/lib/dhcp/lease_db.h b/lib/dhcp/lease_db.h new file mode 100644 index 0000000..86b959f --- /dev/null +++ b/lib/dhcp/lease_db.h @@ -0,0 +1,111 @@ +// Copyright (c) 2022 Johannes Stoelp + +#ifndef LEASE_DB_H +#define LEASE_DB_H + +#include "types.h" + +#include <array> +#include <optional> + +struct lease { + u32 client_hash; + usize lease_end; +}; + +// Lease database, for managing client leases, which includes +// - allocation of new leases +// - lookup of existing leases +// - update of existing leases +// - flushing of expired leases +// +// The database supports 'LEASE' number of clients. +// +// The lease APIs return an idx which represents the allocated client lease and +// should be interpreted as offset to the start address of the dhcp address +// range. +// +// For example, the dhcp server starts to assing addresses starting with +// 10.0.0.100, and the lease database returns idx=4, this would represent the +// allocated client address 10.0.0.104. +template<usize LEASES> +class lease_db { + public: + constexpr lease_db() = default; + + lease_db(const lease_db&) = delete; + lease_db& operator=(const lease_db&) = delete; + + // Try to allocate a new client lease for the client represented by + // 'client_hash'. + // + // If a lease could be allocated return the corresponding idx. + // + // If a lease for the client already exist or all leases are allocated, + // return nullopt. + // + // 'lease_end' sets the expiration time of the lease (should be absolute time). + std::optional<usize> new_lease(u32 client_hash, usize lease_end) { + if (get_lease(client_hash)) { + return std::nullopt; + } + + for (usize l = 0; client_hash != 0 && l < LEASES; ++l) { + if (leases[l].client_hash == 0) { + leases[l].client_hash = client_hash; + leases[l].lease_end = lease_end; + return l; + } + } + return std::nullopt; + } + + // Try to get the lease for the client if it exists. + std::optional<usize> get_lease(u32 client_hash) const { + for (usize l = 0; client_hash != 0 && l < LEASES; ++l) { + if (leases[l].client_hash == client_hash) { + return l; + } + } + return std::nullopt; + } + + // Update expiration time for client if the client has an allocated lease. + // Similar to 'new_lease' the 'lease_end' should be an absolute time value. + bool update_lease(u32 client_hash, usize lease_end) { + for (usize l = 0; client_hash != 0 && l < LEASES; ++l) { + if (leases[l].client_hash == client_hash) { + leases[l].lease_end = lease_end; + return true; + } + } + return false; + } + + // Check for expired leases and free them accordingly. + // 'curr_time' should be the current time as absolute time value. + void flush_expired(usize curr_time) { + for (lease& l : leases) { + if (l.lease_end <= curr_time) { + l.client_hash = 0; + l.lease_end = 0; + } + } + } + + // Get the number of active leases. + usize active_leases() const { + usize cnt = 0; + for (const lease& l : leases) { + if (l.client_hash != 0) { + ++cnt; + } + } + return cnt; + } + + private: + std::array<lease, LEASES> leases = {0, 0}; +}; + +#endif diff --git a/lib/dhcp/types.h b/lib/dhcp/types.h new file mode 100644 index 0000000..049de0f --- /dev/null +++ b/lib/dhcp/types.h @@ -0,0 +1,14 @@ +// Copyright (c) 2022 Johannes Stoelp + +#ifndef TYPES_H +#define TYPES_H + +#include <cstddef> +#include <cstdint> + +using u8 = uint8_t; +using u16 = uint16_t; +using u32 = uint32_t; +using usize = size_t; + +#endif diff --git a/lib/dhcp/utils.h b/lib/dhcp/utils.h new file mode 100644 index 0000000..9016007 --- /dev/null +++ b/lib/dhcp/utils.h @@ -0,0 +1,31 @@ +// Copyright (c) 2022 Johannes Stoelp + +#ifndef UTILS_H +#define UTILS_H + +#include <type_traits> + +// Convert from an underlying enum type into an enum variant. +template<typename E> +constexpr E from_raw(std::underlying_type_t<E> u) { + static_assert(std::is_enum_v<E>); + return static_cast<E>(u); +} + +// Convert from an enum variant into an underlying enum type. +template<typename E> +constexpr std::underlying_type_t<E> into_raw(E e) { + static_assert(std::is_enum_v<E>); + return static_cast<std::underlying_type_t<E>>(e); +} + +// Simple cyclic rotation hash function. +constexpr u32 hash(const u8* data, usize len) { + u32 hash = 0xa5a55a5a /* seed */; + for (usize i = 0; i < len; ++i) { + hash += ((hash << 25) | (hash >> 7) /* rrot(7) */) ^ data[i]; + } + return hash; +} + +#endif diff --git a/platformio.ini b/platformio.ini new file mode 100644 index 0000000..4fa5c0f --- /dev/null +++ b/platformio.ini @@ -0,0 +1,20 @@ +; Copyright (c) 2022 Johannes Stoelp + +; Build embedded target. +[env:nodemcuv2] +platform = espressif8266 +board = nodemcuv2 +framework = arduino +build_flags = -Wextra +; Ignore tests in test/native for this target. +test_ignore = native + +; Build host native target. +[env:native] +platform = native +targets = test +lib_deps = google/googletest@^1.10.0 +build_flags = -lpthread -lgtest_main +; Turn off compat mode. +; https://community.platformio.org/t/googletest-problem-with-compilation-process/12048/12 +lib_compat_mode = off diff --git a/src/main.cc b/src/main.cc new file mode 100644 index 0000000..e294a1d --- /dev/null +++ b/src/main.cc @@ -0,0 +1,349 @@ +// Copyright (c) 2022 Johannes Stoelp + +#include <dhcp.h> +#include <lease_db.h> +#include <utils.h> + +#include <ESP8266WiFi.h> +#include <WiFiUdp.h> + +/// -- WIFI access configuration. + +static constexpr char STATION_SSID[] = "<SSID>"; +static constexpr char STATION_WPA2[] = "<WPA2PW>"; + +/// -- WIFI client config. + +static const IPAddress LOCAL_IP(10, 0, 0, 2); +static const IPAddress GATEWAY(10, 0, 0, 1); +static const IPAddress BROADCAST(10, 0, 0, 255); +static const IPAddress SUBNET(255, 255, 255, 0); +static const IPAddress DNS1(192, 168, 2, 1); + +/// -- DHCP lease config. + +static const IPAddress LEASE_START(10, 0, 0, 10); +static constexpr u32 LEASE_TIME_SECS = 8 * 60 * 60; /* 8h */ + +/// -- DHCP message buffer. + +alignas(dhcp_message) static u8 MSG_BUFFER[DHCP_MESSAGE_LEN]; + +static_assert(sizeof(dhcp_message) <= sizeof(MSG_BUFFER), "UDP buffer must be big enough to hold dhcp_message!"); + +/// -- Lease DB. + +static lease_db<16> LEASE_DB; + +/// -- UDP io handler. + +static WiFiUDP UDP; + +/// -- Template specialization. + +template<> +inline IPAddress get_opt_val(const u8* opt_data) { + return IPAddress(opt_data[0], opt_data[1], opt_data[2], opt_data[3]); +} + +template<> +inline u8* put_opt_val(u8* opt_data, IPAddress addr) { + *opt_data++ = addr[0]; + *opt_data++ = addr[1]; + *opt_data++ = addr[2]; + *opt_data++ = addr[3]; + return opt_data; +} + +#define LOG_UART(uart, fmt, ...) \ + do { \ + if (uart) { \ + uart.printf(fmt "\r", ##__VA_ARGS__); \ + } \ + } while (0) + +#define LOG(fmt, ...) LOG_UART(Serial, fmt, ##__VA_ARGS__) + +static void setup_station_wifi() { + // Configure wifi in station mode. + WiFi.mode(WIFI_STA); + + // Configure static IP. + WiFi.config(LOCAL_IP, GATEWAY, SUBNET, DNS1); + + // Connect to SSID. + WiFi.begin(STATION_SSID, STATION_WPA2); + + // Wait until wifi is connected. + Serial.printf("Connecting to SSID = %s\n\r", STATION_SSID); + while (WiFi.status() != WL_CONNECTED) { + Serial.print('.'); + delay(500); + } + + Serial.printf("\n\rConnected to %s\n\r", STATION_SSID); + Serial.print(" local_ip : "); + Serial.println(WiFi.localIP()); + Serial.print(" gateway : "); + Serial.println(WiFi.gatewayIP()); + Serial.print(" subnet : "); + Serial.println(WiFi.subnetMask()); + Serial.print(" dns1 : "); + Serial.println(WiFi.dnsIP()); + Serial.print(" broadcast: "); + Serial.println(WiFi.broadcastIP()); +} + +void setup() { + // Initialize serial port for logging. + Serial.begin(115200); + + // Connect as client to wifi using global configuration. + setup_station_wifi(); + + // Start listening for udp messages. + UDP.begin(DHCP_SERVER_PORT); + + pinMode(LED_BUILTIN, OUTPUT); +} + +static void handle_dhcp_message(dhcp_message& msg, usize len); + +void loop() { + const usize npbytes = UDP.parsePacket(); + + // Only handle UDP packets with valid size wrt dhcp messages. + if (npbytes >= DHCP_MESSAGE_MIN_LEN && npbytes < sizeof(dhcp_message)) { + const usize nrbytes = UDP.read(MSG_BUFFER, npbytes); + + // Type pun buffer to to dhcp_message (cpp). + // Should be optimized out mainly due to alignment specification of buffer. + dhcp_message msg; + std::memcpy(&msg, MSG_BUFFER, sizeof(msg)); + + handle_dhcp_message(msg, nrbytes); + } else { + if (npbytes > 0) { + LOG("Ignored UDP message of size %d bytes\n", npbytes); + } + delay(500); + } +} + +// Try to unwrap an optional, return of optional doesn't hold a value. +#define TRY(expr) \ + ({ \ + auto optional = expr; \ + if (!optional) \ + return; \ + optional.value(); \ + }) + +// Get seconds since boot (absolute time value). +// We use this to maintain lease expiration times. +usize now_secs() { + return millis() / 1000; +} + +static void handle_dhcp_message(dhcp_message& msg, usize len) { + // Sanity check dhcp message. + if (msg.op != dhcp_operation::BOOTREQUEST || msg.cookie != DHCP_OPTION_COOKIE) { + return; + } + + // Length of the filled in client options. + const usize opt_len = len - (msg.options - (const u8*)&msg); + + // Each dhcp message must contain the dhcp message type (state in the protocol). + const auto msg_type = ({ + auto opt = TRY(get_option(msg.options, opt_len, dhcp_option::DHCP_MESSAGE_TYPE)); + from_raw<dhcp_message_type>(opt.data[0]); + }); + + // Remove expired leases. + LEASE_DB.flush_expired(now_secs()); + + // Compute client hash, using the CLIENT_ID option if available else use + // the hardware address. + u32 client_hash; + if (const auto client_id = get_option(msg.options, opt_len, dhcp_option::CLIENT_ID)) { + client_hash = hash(client_id->data, client_id->len); + } else { + client_hash = hash(msg.chaddr, msg.hlen); + } + + // Extract the dhcp options requested by the client (using 16 was sufficient in my case). + dhcp_option requested_param[16]; + usize requested_param_len = 0; + if (const auto opt = get_option(msg.options, opt_len, dhcp_option::PARAMETER_REQUEST_LIST)) { + requested_param_len = opt->len > sizeof(requested_param) ? sizeof(requested_param) : opt->len; + + for (usize i = 0; i < requested_param_len; ++i) { + requested_param[i] = from_raw<dhcp_option>(opt->data[i]); + } + } + + usize lease_id; + dhcp_message_type resp_msg; + + switch (msg_type) { + case dhcp_message_type::DHCP_DISCOVER: { + LOG("Received DHCP_DISCOVER client_hash=%x\n", client_hash); + + if (const auto lease = LEASE_DB.get_lease(client_hash)) { + // We already have a lease for this client. + lease_id = lease.value(); + } else { + // Allocate a new lease for this client and reserve for a short + // amount of time. + lease_id = TRY(LEASE_DB.new_lease(client_hash, now_secs() + 15 /* secs */)); + } + + // DHCP message type answer. + resp_msg = dhcp_message_type::DHCP_OFFER; + } break; + + case dhcp_message_type::DHCP_REQUEST: { + LOG("Received DHCP_REQUEST client_hash=%x\n", client_hash); + + // Get server identifier specified by client. + const auto server_id = ({ + auto op = TRY(get_option(msg.options, opt_len, dhcp_option::SERVER_IDENTIFIER)); + get_opt_val<IPAddress>(op.data); + }); + + // Check if dhcp message was ment for us. + if (server_id != LOCAL_IP) { + return; + } + + // Client is now requesting the offered lease, at that stage the + // lease should have been allocated. + lease_id = TRY(LEASE_DB.get_lease(client_hash)); + + // Update the lease db with the proper lease expiration time + // (absolute time). + LEASE_DB.update_lease(client_hash, now_secs() + LEASE_TIME_SECS /* secs */); + + // DHCP message type answer. + resp_msg = dhcp_message_type::DHCP_ACK; + } break; + + default: { + LOG("Received unexpected DHCP MESSAGE TYPE %d\n", into_raw(msg_type)); + return; + } + } + + // Craft response package. + + // Compute client id based on start address of dhcp range and lease idx. + auto client_addr = LEASE_START; + client_addr[3] = client_addr[3] + lease_id; + + // From rfc2131 Table 3: + // + // Field DHCPOFFER DHCPACK + // ----- --------- ------- + // 'op' BOOTREPLY BOOTREPLY + // 'htype' keep keep + // 'hlen' keep keep + // 'hops' 0 0 + // 'xid' keep keep + // 'secs' 0 0 + // 'ciaddr' 0 0 + // 'yiaddr' IP address offered to client + // 'siaddr' IP address of next bootstrap server + // 'flags' keep keep + // 'giaddr' keep keep + // 'chaddr' keep keep + // 'sname' Server host name or options + // 'file' Client boot file name or options + + msg.op = dhcp_operation::BOOTREPLY; + msg.hops = 0; + msg.secs = 0; + msg.ciaddr = 0; + msg.yiaddr = client_addr.v4(); + msg.siaddr = LOCAL_IP.v4(); + + // From rfc2131 Table 3: + // + // Option DHCPOFFER DHCPACK + // ------ --------- ------- + // IP address lease time MUST MUST (DHCPREQUEST) + // DHCP message type DHCPOFFER DHCPACK + // Server identifier MUST MUST + + u8* optp = msg.options; + + // DHCP message type. + *optp++ = into_raw(dhcp_option::DHCP_MESSAGE_TYPE); + *optp++ = 1 /* len */; + *optp++ = into_raw(resp_msg); + + // Server identifier. + *optp++ = into_raw(dhcp_option::SERVER_IDENTIFIER); + *optp++ = 4 /* len */; + optp = put_opt_val(optp, LOCAL_IP); + + // Lease time. + *optp++ = into_raw(dhcp_option::IP_ADDRESS_LEASE_TIME); + *optp++ = 4 /* len */; + optp = put_opt_val(optp, LEASE_TIME_SECS); + + // Renewal time. + *optp++ = into_raw(dhcp_option::RENEWAL_TIME_T1); + *optp++ = 4 /* len */; + optp = put_opt_val(optp, LEASE_TIME_SECS / 2); + + // Rebind time. + *optp++ = into_raw(dhcp_option::REBINDING_TIME_T2); + *optp++ = 4 /* len */; + optp = put_opt_val(optp, LEASE_TIME_SECS * 8 / 12); + + // Add options requested by client that we support. + for (usize i = 0; i < requested_param_len; ++i) { + auto opt = requested_param[i]; + switch (opt) { + case dhcp_option::SUBNET_MASK: { + // Subnet mask. + *optp++ = into_raw(opt); + *optp++ = 4 /* len */; + optp = put_opt_val(optp, SUBNET); + } break; + + case dhcp_option::ROUTER: { + // Router address. + *optp++ = into_raw(opt); + *optp++ = 4 /* len */; + optp = put_opt_val(optp, GATEWAY); + } break; + + case dhcp_option::DNS: { + // DNS address. + *optp++ = into_raw(opt); + *optp++ = 4 /* len */; + optp = put_opt_val(optp, DNS1); + } break; + + case dhcp_option::BROADCAST_ADDR: { + // Broadcast address. + *optp++ = into_raw(opt); + *optp++ = 4 /* len */; + optp = put_opt_val(optp, BROADCAST); + } break; + + default: + break; + } + } + + // End option end marker. + *optp++ = into_raw(dhcp_option::END); + + // Send out dhcp message. + UDP.beginPacket(BROADCAST, DHCP_CLIENT_PORT); + UDP.write((const u8*)&msg, optp - (u8*)&msg); + UDP.endPacket(); +} diff --git a/test/native/dhcp.cc b/test/native/dhcp.cc new file mode 100644 index 0000000..983c6c8 --- /dev/null +++ b/test/native/dhcp.cc @@ -0,0 +1,65 @@ +// Copyright (c) 2022 Johannes Stoelp + +#include <dhcp.h> +#include <utils.h> + +#include <gtest/gtest.h> + +TEST(option, get_available_opt) { + u8 opts[] = { + into_raw(dhcp_option::PAD), into_raw(dhcp_option::PAD), into_raw(dhcp_option::DHCP_MESSAGE_TYPE), 5 /* len */, 0, 1, 2, 3, 4, + into_raw(dhcp_option::END), + }; + + auto ov = get_option(opts, sizeof(opts), dhcp_option::DHCP_MESSAGE_TYPE); + + ASSERT_EQ(true, ov.has_value()); + ASSERT_EQ(0, ov.value().data[0]); + ASSERT_EQ(1, ov.value().data[1]); + ASSERT_EQ(2, ov.value().data[2]); + ASSERT_EQ(3, ov.value().data[3]); + ASSERT_EQ(4, ov.value().data[4]); + ASSERT_EQ(5, ov.value().len); +} + +TEST(option, maleformed_len) { + u8 opts[] = { + into_raw(dhcp_option::PAD), into_raw(dhcp_option::PAD), into_raw(dhcp_option::DHCP_MESSAGE_TYPE), 5 /* len */, 0, + into_raw(dhcp_option::END), + }; + + { + auto ov = get_option(opts, sizeof(opts), dhcp_option::DHCP_MESSAGE_TYPE); + + ASSERT_EQ(false, ov.has_value()); + } + { + auto ov = get_option(opts, sizeof(opts), dhcp_option::CLASS_ID); + + ASSERT_EQ(false, ov.has_value()); + } +} + +TEST(option, maleformed_missing_end) { + u8 opts[] = { + into_raw(dhcp_option::PAD), + into_raw(dhcp_option::PAD), + }; + + auto ov = get_option(opts, sizeof(opts), dhcp_option::DHCP_MESSAGE_TYPE); + + ASSERT_EQ(false, ov.has_value()); +} + +TEST(option, put_opt_val) { + u8 options[8] = {0}; + u32 val = 0xdeadbeef; + + u8* nextp = put_opt_val(options, val); + + ASSERT_EQ(options + 4, nextp); + ASSERT_EQ(0xde, options[0]); + ASSERT_EQ(0xad, options[1]); + ASSERT_EQ(0xbe, options[2]); + ASSERT_EQ(0xef, options[3]); +}
\ No newline at end of file diff --git a/test/native/hash.cc b/test/native/hash.cc new file mode 100644 index 0000000..8aa0a35 --- /dev/null +++ b/test/native/hash.cc @@ -0,0 +1,71 @@ +// Copyright (c) 2022 Johannes Stoelp + +#include <types.h> +#include <utils.h> + +#include <filesystem> +#include <fstream> +#include <gtest/gtest.h> +#include <iostream> +#include <vector> + +using ID = std::array<u8, 6>; +namespace fs = std::filesystem; + +std::vector<ID> read_blob() { + if (!fs::exists("blob")) { + std::system("dd if=/dev/urandom of=blob count=16 bs=1M"); + } + + std::vector<ID> ids; + ids.reserve(fs::file_size("blob") / sizeof(ID)); + + auto ifs = std::ifstream("blob"); + assert(ifs.is_open()); + ID id; + while (ifs.read((char*)id.data(), sizeof(id))) { + ids.push_back(id); + } + + return ids; +} + +TEST(hash, uniform_distribuation) { + constexpr usize BUCKETS = 64; + constexpr float BUCKET_SIZE = static_cast<float>(100) / BUCKETS; // Bucket size in percent. + constexpr float BUCKET_ERR = BUCKET_SIZE * 0.05 /* 5% */; // Allowed distribution error. + + const auto ids = read_blob(); + + usize cnt[BUCKETS] = {0}; + for (const auto& id : ids) { + u32 h = hash(id.data(), id.size()); + cnt[h % BUCKETS] += 1; + } + + for (usize b = 0; b < BUCKETS; ++b) { + const float dist = static_cast<float>(cnt[b]) / ids.size() * 100; + ASSERT_GT(dist, BUCKET_SIZE - BUCKET_ERR); + ASSERT_LT(dist, BUCKET_SIZE + BUCKET_ERR); + // printf("bucket %2ld: %5.2f (%ld)\n", b, dist, cnt[b]); + } +} + +TEST(hash, DISABLED_collisions) { + const auto ids = read_blob(); + + std::unordered_map<u32, usize> hits; + for (const auto& id : ids) { + u32 h = hash(id.data(), id.size()); + hits[h] = hits[h] + 1; + } + + usize collisions = 0; + for (const auto& hit : hits) { + if (hit.second > 1) { + ++collisions; + } + } + + printf("Hashed %ld values got %ld collisions\n", ids.size(), collisions); +} diff --git a/test/native/lease_db.cc b/test/native/lease_db.cc new file mode 100644 index 0000000..8f87912 --- /dev/null +++ b/test/native/lease_db.cc @@ -0,0 +1,85 @@ +// Copyright (c) 2022 Johannes Stoelp + +#include <lease_db.h> + +#include <gtest/gtest.h> + +TEST(lease_db, null_client_hash) { + lease_db<2> db; + + ASSERT_EQ(std::nullopt, db.new_lease(0, 0)); + ASSERT_EQ(std::nullopt, db.get_lease(0)); + ASSERT_EQ(0, db.active_leases()); +} + +TEST(lease_db, get_new_lease) { + lease_db<2> db; + + ASSERT_EQ(std::optional(0), db.new_lease(10, 0)); + ASSERT_EQ(std::optional(1), db.new_lease(20, 0)); + ASSERT_EQ(std::nullopt, db.new_lease(30, 0)); // exhausted + + ASSERT_EQ(std::optional(0), db.get_lease(10)); + ASSERT_EQ(std::optional(1), db.get_lease(20)); + ASSERT_EQ(std::nullopt, db.get_lease(30)); // exhausted + + ASSERT_EQ(2, db.active_leases()); +} + +TEST(lease_db, get_new_lease_twice) { + lease_db<2> db; + + ASSERT_EQ(std::optional(0), db.new_lease(10, 0)); + ASSERT_EQ(std::nullopt, db.new_lease(10, 0)); + + ASSERT_EQ(1, db.active_leases()); +} + +TEST(lease_db, flush_expired) { + lease_db<2> db; + + ASSERT_EQ(std::optional(0), db.new_lease(10, 100 /* lease end */)); + ASSERT_EQ(std::optional(1), db.new_lease(20, 200 /* lease end */)); + + db.flush_expired(50 /* current time */); + ASSERT_EQ(2, db.active_leases()); + ASSERT_EQ(std::optional(0), db.get_lease(10)); + ASSERT_EQ(std::optional(1), db.get_lease(20)); + + db.flush_expired(150 /* current time */); + ASSERT_EQ(1, db.active_leases()); + ASSERT_EQ(std::nullopt, db.get_lease(10)); + ASSERT_EQ(std::optional(1), db.get_lease(20)); + + db.flush_expired(250 /* current time */); + ASSERT_EQ(0, db.active_leases()); + ASSERT_EQ(std::nullopt, db.get_lease(10)); + ASSERT_EQ(std::nullopt, db.get_lease(20)); +} + +TEST(lease_db, update_lease) { + lease_db<2> db; + + ASSERT_EQ(std::optional(0), db.new_lease(10, 100 /* lease end */)); + ASSERT_EQ(std::optional(1), db.new_lease(20, 200 /* lease end */)); + + ASSERT_EQ(2, db.active_leases()); + + db.flush_expired(150 /* current time */); + ASSERT_EQ(1, db.active_leases()); + ASSERT_EQ(std::nullopt, db.get_lease(10)); + ASSERT_EQ(std::optional(1), db.get_lease(20)); + + ASSERT_EQ(false, db.update_lease(10, 300 /* lease end */)); + ASSERT_EQ(true, db.update_lease(20, 300 /* lease end */)); + + db.flush_expired(250 /* current time */); + ASSERT_EQ(1, db.active_leases()); + ASSERT_EQ(std::nullopt, db.get_lease(10)); + ASSERT_EQ(std::optional(1), db.get_lease(20)); + + db.flush_expired(350 /* current time */); + ASSERT_EQ(0, db.active_leases()); + ASSERT_EQ(std::nullopt, db.get_lease(10)); + ASSERT_EQ(std::nullopt, db.get_lease(20)); +} |