├── .env ├── rust-toolchain.toml ├── dora.jpg ├── dora-core ├── src │ ├── env.rs │ ├── prelude.rs │ ├── lib.rs │ ├── handler.rs │ ├── server │ │ ├── msg.rs │ │ ├── udp.rs │ │ ├── state.rs │ │ ├── ioctl.rs │ │ └── typemap.rs │ ├── metrics.rs │ └── config.rs └── Cargo.toml ├── docs ├── pic1.png ├── pic2.png ├── docker.md ├── creating_plugin.md ├── ddns.md ├── gns3.md └── pi_setup.md ├── modules ├── default.nix ├── options.nix ├── README.md └── config.nix ├── libs ├── ddns │ ├── examples │ │ ├── tsig.raw │ │ ├── tsig.conf │ │ └── tsig.rs │ ├── Cargo.toml │ └── src │ │ └── dhcid.rs ├── client-classification │ ├── README.md │ ├── Cargo.toml │ ├── src │ │ └── grammar.pest │ └── benches │ │ └── my_benchmark.rs ├── topo_sort │ └── Cargo.toml ├── env-parser │ ├── Cargo.toml │ └── src │ │ └── lib.rs ├── discovery │ ├── Cargo.toml │ └── src │ │ └── lib.rs ├── register_derive │ ├── src │ │ └── lib.rs │ ├── Cargo.toml │ └── README.md ├── client-protection │ ├── Cargo.toml │ └── src │ │ └── lib.rs ├── ip-manager │ ├── README.md │ └── Cargo.toml ├── register_derive_impl │ ├── Cargo.toml │ └── src │ │ └── lib.rs ├── config │ ├── src │ │ ├── wire │ │ │ ├── client_classes.rs │ │ │ └── mod.rs │ │ └── lib.rs │ ├── sample │ │ ├── config_v6_no_persist.yaml │ │ ├── config_v6_UUID.yaml │ │ ├── config_v6_EN.yaml │ │ ├── config_v6_LL.yaml │ │ ├── config_v6.yaml │ │ ├── circular_deps.yaml │ │ ├── config_v4.json │ │ ├── config_v4_simple.json │ │ ├── long_opts.yaml │ │ └── config.yaml │ └── Cargo.toml └── icmp-ping │ ├── Cargo.toml │ ├── src │ ├── errors.rs │ ├── socket.rs │ ├── shutdown.rs │ └── icmp.rs │ └── examples │ └── simple.rs ├── .dockerignore ├── .gitignore ├── .github ├── actions-rs │ └── grcov.yml └── workflows │ └── actions.yml ├── ddns-test ├── Cargo.toml ├── ddns.json └── src │ └── main.rs ├── .cargo └── config.toml ├── .editorconfig ├── dora-cfg ├── Cargo.toml ├── README.md └── src │ └── main.rs ├── plugins ├── message-type │ └── Cargo.toml ├── static-addr │ ├── Cargo.toml │ └── src │ │ └── lib.rs └── leases │ └── Cargo.toml ├── .vscode ├── settings.json └── extensions.json ├── migrations └── 20210824204854_initial.sql ├── Dockerfile ├── bin ├── tests │ ├── test_configs │ │ ├── cache_threshold.yaml │ │ ├── threshold.yaml │ │ ├── classes.yaml │ │ ├── vendor.yaml │ │ └── basic.yaml │ └── common │ │ ├── mod.rs │ │ ├── env.rs │ │ └── client.rs ├── Cargo.toml ├── README.md └── src │ └── main.rs ├── shell.nix ├── external-api └── Cargo.toml ├── package.nix ├── flake.nix ├── Cargo.toml └── util └── entrypoint.sh /.env: -------------------------------------------------------------------------------- 1 | DATABASE_URL="sqlite://${PWD}/em.db" 2 | -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "1.91.0" 3 | -------------------------------------------------------------------------------- /dora.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bluecatengineering/dora/HEAD/dora.jpg -------------------------------------------------------------------------------- /dora-core/src/env.rs: -------------------------------------------------------------------------------- 1 | //! environment variable parser 2 | pub use env_parser::*; 3 | -------------------------------------------------------------------------------- /docs/pic1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bluecatengineering/dora/HEAD/docs/pic1.png -------------------------------------------------------------------------------- /docs/pic2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bluecatengineering/dora/HEAD/docs/pic2.png -------------------------------------------------------------------------------- /modules/default.nix: -------------------------------------------------------------------------------- 1 | {...}: { 2 | imports = [ 3 | ./options.nix 4 | ./config.nix 5 | ]; 6 | } 7 | -------------------------------------------------------------------------------- /libs/ddns/examples/tsig.raw: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bluecatengineering/dora/HEAD/libs/ddns/examples/tsig.raw -------------------------------------------------------------------------------- /libs/client-classification/README.md: -------------------------------------------------------------------------------- 1 | # Client Classification 2 | 3 | See example.yaml in root for commented config file 4 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git/ 2 | .github/ 3 | .vscode/ 4 | 5 | *.db 6 | 7 | docs/ 8 | data/ 9 | # build directories 10 | target/ 11 | -------------------------------------------------------------------------------- /libs/ddns/examples/tsig.conf: -------------------------------------------------------------------------------- 1 | key "tsig-key" { 2 | algorithm hmac-sha512; 3 | secret "cOMu4V6xeuEMyGuOiwMtvQNEp+4XZNqSbjdwxyNmpYwfZoy/ZSUctWsYq7XKKuQFjkiIrUON5HV+9aozYCB58A=="; 4 | }; 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | out/ 2 | data/ 3 | target/ 4 | data/ 5 | *.db* 6 | .vscode/*.log 7 | server_id 8 | 9 | # Env files 10 | .envrc 11 | .direnv 12 | 13 | # Nix files 14 | flake.lock 15 | result 16 | -------------------------------------------------------------------------------- /modules/options.nix: -------------------------------------------------------------------------------- 1 | {lib, ...}: 2 | with lib; let 3 | moduleName = "dora"; 4 | in { 5 | ## Options 6 | options.services.${moduleName} = { 7 | enable = mkEnableOption "Enable ${moduleName}."; 8 | }; 9 | } 10 | -------------------------------------------------------------------------------- /libs/topo_sort/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "topo_sort" 3 | version = "0.1.0" 4 | edition = "2024" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | thiserror = { workspace = true } 10 | -------------------------------------------------------------------------------- /libs/env-parser/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "env-parser" 3 | version = "0.1.0" 4 | edition = "2024" 5 | license = "MPL-2.0" 6 | 7 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 8 | 9 | [dependencies] 10 | anyhow = { workspace = true } 11 | -------------------------------------------------------------------------------- /.github/actions-rs/grcov.yml: -------------------------------------------------------------------------------- 1 | branch: false 2 | ignore-not-existing: true 3 | llvm: true 4 | filter: covered 5 | output-type: lcov 6 | ignore: 7 | - "*.cargo/*" 8 | - "bin/*" 9 | - "dora-cfg/*" 10 | excl-start: "grcov-excl-start" 11 | excl-stop: "grcov-excl-stop" 12 | excl-line: "grcov-excl-line|#\\[derive\\(|//!" 13 | -------------------------------------------------------------------------------- /libs/discovery/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "discovery" 3 | version = "0.1.0" 4 | edition = "2024" 5 | license = "MPL-2.0" 6 | 7 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 8 | 9 | [dependencies] 10 | anyhow = { workspace = true } 11 | hickory-resolver = "0.25.2" 12 | -------------------------------------------------------------------------------- /ddns-test/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "ddns-test" 3 | version = "0.1.0" 4 | edition = "2024" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | anyhow = { workspace = true } 10 | serde = { workspace = true } 11 | serde_json = { workspace = true } 12 | -------------------------------------------------------------------------------- /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [net] 2 | git-fetch-with-cli = true 3 | 4 | [target.armv7-unknown-linux-gnueabihf] 5 | linker = "arm-linux-gnueabihf-gcc" 6 | 7 | [target.x86_64-unknown-linux-gnu] 8 | runner = "sudo -E" 9 | 10 | # Maybe there is a way to only run the integration tests, or remove this restriction... 11 | [env] 12 | RUST_TEST_THREADS = "1" 13 | -------------------------------------------------------------------------------- /libs/register_derive/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! # register_derive 2 | //! 3 | //! This is a macro to derive the `Register` implementation for dora, since 4 | //! its implementation is pretty mechanical, we can simplify things for users by 5 | //! providing a derive macro. 6 | //! 7 | #[doc(hidden)] 8 | pub use register_derive_impl::*; 9 | 10 | pub use dora_core::Register; 11 | -------------------------------------------------------------------------------- /libs/client-protection/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "client-protection" 3 | version = "0.1.0" 4 | edition = "2024" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | config = { path = "../config" } 10 | governor = "0.5.1" 11 | tracing = { workspace = true } 12 | dashmap = "5.4.0" 13 | -------------------------------------------------------------------------------- /libs/ip-manager/README.md: -------------------------------------------------------------------------------- 1 | # ip-manager 2 | 3 | IP acquisition & allocation 4 | 5 | ### sqlx-data.json 6 | 7 | This file is generated with: 8 | 9 | ``` 10 | cargo sqlx prepare --merged 11 | ``` 12 | 13 | but run from the workspace root. It may be possible to leave `sqlx-data.json` in the workspace root or come up with a better situation for offline building. I have no investigated thoroughly. 14 | -------------------------------------------------------------------------------- /libs/register_derive/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "register_derive" 3 | version = "0.1.0" 4 | authors = ["Bluecat Networks "] 5 | edition = "2024" 6 | license = "MPL-2.0" 7 | 8 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 9 | 10 | [dependencies] 11 | register_derive_impl = { path = "../register_derive_impl" } 12 | dora-core = { path = "../../dora-core" } 13 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | # Unix-style newlines with a newline ending every file 4 | [*] 5 | end_of_line = lf 6 | indent_style = space 7 | indent_size = 4 8 | insert_final_newline = true 9 | 10 | # Tab indentation (no size specified) 11 | [Makefile] 12 | indent_style = tab 13 | indent_size = 4 14 | 15 | # Indentation override for all JSON 16 | [*.json] 17 | indent_style = space 18 | indent_size = 4 19 | 20 | [*.yml] 21 | indent_size = 2 22 | -------------------------------------------------------------------------------- /dora-core/src/prelude.rs: -------------------------------------------------------------------------------- 1 | //! dora prelude 2 | 3 | pub use crate::{ 4 | anyhow::{self, Context, Result}, 5 | async_trait, dhcproto, 6 | handler::{Action, Plugin}, 7 | pnet::datalink::{MacAddr, NetworkInterface}, 8 | pnet::ipnetwork::{IpNetwork, Ipv4Network, Ipv6Network}, 9 | server::{context::MsgContext, state::State}, 10 | tokio, 11 | tracing::{self, debug, error, info, instrument, trace}, 12 | unix_udp_sock, 13 | }; 14 | 15 | pub use std::{io, sync::Arc}; 16 | -------------------------------------------------------------------------------- /libs/client-classification/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "client-classification" 3 | version = "0.1.0" 4 | edition = "2024" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | pest = "2.5.3" 10 | pest_derive = "2.5.3" 11 | thiserror = "1.0.30" 12 | hex = "0.4.3" 13 | dhcproto = { workspace = true } 14 | 15 | [dev-dependencies] 16 | criterion = "0.4.0" 17 | 18 | [[bench]] 19 | name = "my_benchmark" 20 | harness = false 21 | -------------------------------------------------------------------------------- /libs/register_derive_impl/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "register_derive_impl" 3 | version = "0.1.0" 4 | authors = ["Bluecat Networks "] 5 | edition = "2024" 6 | license = "MPL-2.0" 7 | 8 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 9 | 10 | [dependencies] 11 | quote = "1.0" 12 | syn = "1.0" 13 | proc-macro2 = "1" 14 | 15 | # dora 16 | dora-core = { path = "../../dora-core" } 17 | 18 | [lib] 19 | proc-macro = true 20 | -------------------------------------------------------------------------------- /libs/ddns/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "ddns" 3 | version = "0.1.0" 4 | edition = "2024" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | dora-core = { path = "../../dora-core" } 10 | config = { path = "../config" } 11 | base64 = { workspace = true } 12 | thiserror = { workspace = true } 13 | ring = "0.16.20" 14 | hex = "0.4" 15 | rand = { workspace = true } 16 | 17 | 18 | [[example]] 19 | name = "tsig" 20 | path = "examples/tsig.rs" 21 | -------------------------------------------------------------------------------- /libs/config/src/wire/client_classes.rs: -------------------------------------------------------------------------------- 1 | //! # Client Classes 2 | 3 | use serde::{Deserialize, Serialize}; 4 | 5 | use crate::wire::v4::Options; 6 | 7 | #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] 8 | pub struct ClientClasses { 9 | pub(crate) v4: Vec, 10 | } 11 | 12 | #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] 13 | pub struct ClientClass { 14 | pub(crate) name: String, 15 | pub(crate) assert: String, 16 | #[serde(default)] 17 | pub(crate) options: Options, 18 | } 19 | -------------------------------------------------------------------------------- /dora-cfg/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "dora-cfg" 3 | version = "0.1.0" 4 | edition = "2024" 5 | description = "dora is a DHCP server written from the ground up in Rust" 6 | license = "MPL-2.0" 7 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 8 | 9 | [dependencies] 10 | anyhow = { workspace = true } 11 | serde = { workspace = true } 12 | serde_json = { workspace = true } 13 | serde_yaml = { workspace = true } 14 | clap = { workspace = true } 15 | jsonschema = "0.16.0" 16 | 17 | config = { path = "../libs/config" } 18 | -------------------------------------------------------------------------------- /plugins/message-type/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "message-type" 3 | version = "0.1.0" 4 | edition = "2024" 5 | license = "MPL-2.0" 6 | 7 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 8 | 9 | [dependencies] 10 | dora-core = { path = "../../dora-core" } 11 | register_derive = { path = "../../libs/register_derive" } 12 | config = { path = "../../libs/config" } 13 | client-protection = { path = "../../libs/client-protection" } 14 | 15 | [dev-dependencies] 16 | serde_yaml = { workspace = true } 17 | tracing-test = "0.2.4" 18 | -------------------------------------------------------------------------------- /plugins/static-addr/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "static-addr" 3 | version = "0.1.0" 4 | edition = "2024" 5 | license = "MPL-2.0" 6 | 7 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 8 | 9 | [dependencies] 10 | dora-core = { path = "../../dora-core" } 11 | config = { path = "../../libs/config" } 12 | 13 | register_derive = { path = "../../libs/register_derive" } 14 | message-type = { path = "../message-type" } 15 | 16 | [dev-dependencies] 17 | serde_yaml = { workspace = true } 18 | tracing-test = "0.2.4" 19 | hex = "0.4" 20 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "search.exclude": { 3 | "**/.git": true, 4 | "**/build": true, 5 | "**/docker-context": true, 6 | "**/target": true, 7 | "**/resources": true 8 | }, 9 | "files.watcherExclude": { 10 | "**/.git/objects/**": true, 11 | "**/.git/subtree-cache/**": true, 12 | "**/target": true, 13 | "**/build": true 14 | }, 15 | "spellright.language": [ 16 | "en-US-10-1." 17 | ], 18 | "spellright.documentTypes": [ 19 | "markdown", 20 | "latex" 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /migrations/20210824204854_initial.sql: -------------------------------------------------------------------------------- 1 | -- Add migration script here 2 | -- would prefer to use an enum where the entry is either leased or 3 | -- on probation. expires_at for lease = true refers to when lease expires, 4 | -- if probabtion = true it is when the probation expires 5 | CREATE TABLE IF NOT EXISTS leases( 6 | ip INTEGER NOT NULL, 7 | client_id BLOB, 8 | leased BOOLEAN NOT NULL DEFAULT 0, 9 | expires_at INTEGER NOT NULL, 10 | network INTEGER NOT NULL, 11 | probation BOOLEAN NOT NULL DEFAULT 0, 12 | PRIMARY KEY(ip) 13 | ); 14 | CREATE INDEX idx_ip_expires on leases (ip, expires_at); -------------------------------------------------------------------------------- /docs/docker.md: -------------------------------------------------------------------------------- 1 | # Creating a dora docker image 2 | 3 | After checking out the ource, build dora in docker and create an image: 4 | 5 | ``` 6 | docker build -t dora . 7 | ``` 8 | 9 | Next, create a `data` directory if it does not exist, and put `config.yaml` in it. This directory will be used to read the config and to store your leases database file. 10 | 11 | ``` 12 | mkdir data 13 | touch data/config.yaml 14 | ``` 15 | 16 | (edit config.yaml) 17 | 18 | Then run the image you created with `--net=host` and with the data dir volume mounted: 19 | 20 | ``` 21 | docker run -it --rm --init --net=host -v "$(pwd)/data":/var/lib/dora dora 22 | ``` 23 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | 2 | { 3 | // See http://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations. 4 | // Extension identifier format: ${publisher}.${name}. Example: vscode.csharp 5 | 6 | // List of extensions which should be recommended for users of this workspace. 7 | "recommendations": [ 8 | "rust-lang.rust-analyzer", 9 | "EditorConfig.EditorConfig", 10 | "tamasfe.even-better-toml", 11 | "belfz.search-crates-io" 12 | ], 13 | // List of extensions recommended by VS Code that should not be recommended for users of this workspace. 14 | "unwantedRecommendations": [] 15 | } 16 | -------------------------------------------------------------------------------- /dora-cfg/README.md: -------------------------------------------------------------------------------- 1 | # dora config cli 2 | 3 | ``` 4 | dora-cfg 0.1.0 5 | dora is a DHCP server written from the ground up in Rust 6 | 7 | USAGE: 8 | dora-cfg --path --format 9 | 10 | OPTIONS: 11 | -f, --format print the parsed wire format or the dora internal config format 12 | [possible values: wire, internal] 13 | -h, --help Print help information 14 | -p, --path path to dora config. We will determine format from extension. If no 15 | extension, we will attempt JSON & YAML 16 | -V, --version Print version information 17 | ``` 18 | -------------------------------------------------------------------------------- /libs/icmp-ping/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "icmp-ping" 3 | version = "0.1.0" 4 | edition = "2024" 5 | license = "MPL-2.0" 6 | 7 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 8 | 9 | [dependencies] 10 | rand = "0.8" 11 | socket2 = { workspace = true } 12 | tokio = { workspace = true, features = ["time", "net", "sync", "macros"] } 13 | parking_lot = "0.12" 14 | pnet = { workspace = true } 15 | thiserror = { workspace = true } 16 | tracing = { workspace = true } 17 | dora-core = { path = "../../dora-core" } 18 | 19 | [dev-dependencies] 20 | tokio = { workspace = true } 21 | tracing-subscriber = "0.3" 22 | tracing-test = "0.2.4" 23 | -------------------------------------------------------------------------------- /modules/README.md: -------------------------------------------------------------------------------- 1 | # Nixos install 2 | 3 | Install dora under nixos. 4 | 5 | Make sure you have a configuration file, 6 | otherwise the systemd-unit will fail. 7 | 8 | ```sh 9 | touch /etc/config/dora/config.yaml 10 | ``` 11 | 12 | Add this flake url to your inputs in `flake.nix`. 13 | 14 | ```nix 15 | inputs = { 16 | dora = { 17 | url = "github:bluecatengineering/dora"; 18 | # inputs.nixpkgs.follows = "nixpkgs"; 19 | }; 20 | }; 21 | ``` 22 | 23 | Import the module. 24 | 25 | ```nix 26 | imports = [ 27 | inputs.dora.nixosModules.default 28 | ]; 29 | ``` 30 | 31 | Then enable it somewhere in your configuration. 32 | 33 | ```nix 34 | services.dora.enable = true; 35 | 36 | ``` 37 | -------------------------------------------------------------------------------- /libs/config/sample/config_v6_no_persist.yaml: -------------------------------------------------------------------------------- 1 | v6: 2 | server_id: 3 | type: LLT 4 | persist: false 5 | options: 6 | values: 7 | 23: 8 | type: ip_list 9 | value: 10 | - 2001:db8::1 11 | - 2001:db8::2 12 | networks: 13 | 2001:db8:1::/64: 14 | config: 15 | lease_time: 16 | default: 3600 17 | preferred_time: 18 | default: 3600 19 | options: 20 | values: 21 | 23: 22 | type: ip_list 23 | value: 24 | - 2001:db8::1 25 | - 2001:db8::2 26 | -------------------------------------------------------------------------------- /libs/icmp-ping/src/errors.rs: -------------------------------------------------------------------------------- 1 | #![allow(unused_assignments)] 2 | use crate::Token; 3 | 4 | #[derive(thiserror::Error, Debug)] 5 | pub enum Error { 6 | #[error("io error: {0:?}")] 7 | IoError(#[from] std::io::Error), 8 | #[error("timeout reached on seq_cnt: {seq_cnt:?} ident: {ident:?}")] 9 | Timeout { seq_cnt: u16, ident: u16 }, 10 | #[error("recv error on seq_cnt: {seq_cnt:?} ident: {ident:?}")] 11 | RecvError { 12 | seq_cnt: u16, 13 | ident: u16, 14 | #[source] 15 | err: tokio::sync::oneshot::error::RecvError, 16 | }, 17 | #[error("received mismatched reply for request: {seq_cnt:?} {payload:?}")] 18 | WrongReply { seq_cnt: u16, payload: Token }, 19 | } 20 | 21 | pub type Result = std::result::Result; 22 | -------------------------------------------------------------------------------- /libs/config/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "config" 3 | version = "0.1.0" 4 | edition = "2024" 5 | license = "MPL-2.0" 6 | 7 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 8 | 9 | [dependencies] 10 | anyhow = { workspace = true } 11 | base64 = { workspace = true } 12 | ipnet = { workspace = true } 13 | tracing = { workspace = true } 14 | serde_yaml = { workspace = true } 15 | serde_json = { workspace = true } 16 | serde = { workspace = true } 17 | hex = "0.4" 18 | phf = { version = "0.11", features = ["macros"] } 19 | rand = "0.8" 20 | rustls-pki-types = { workspace = true } 21 | 22 | dora-core = { path = "../../dora-core" } 23 | client-classification = { path = "../client-classification" } 24 | topo_sort = { path = "../topo_sort" } 25 | -------------------------------------------------------------------------------- /plugins/leases/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "leases" 3 | version = "0.1.0" 4 | edition = "2024" 5 | license = "MPL-2.0" 6 | 7 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 8 | 9 | [dependencies] 10 | dora-core = { path = "../../dora-core" } 11 | config = { path = "../../libs/config" } 12 | client-protection = { path = "../../libs/client-protection" } 13 | 14 | static-addr = { path = "../static-addr" } 15 | message-type = { path = "../message-type" } 16 | 17 | register_derive = { path = "../../libs/register_derive" } 18 | ip-manager = { path = "../../libs/ip-manager" } 19 | ddns = { path = "../../libs/ddns" } 20 | 21 | ipnet = { workspace = true } 22 | 23 | [dev-dependencies] 24 | serde_yaml = { workspace = true } 25 | tracing-test = "0.2.4" 26 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM rust:1.85.0 as builder 2 | # set workdir 3 | WORKDIR /usr/src/dora 4 | COPY . . 5 | # setup sqlx-cli 6 | RUN cargo install sqlx-cli 7 | RUN sqlx database create 8 | RUN sqlx migrate run 9 | # release build 10 | ARG BUILD_MODE=release 11 | RUN cargo build --${BUILD_MODE} --bin dora 12 | 13 | # run 14 | FROM ubuntu:latest 15 | RUN apt-get -qq update; \ 16 | apt-get -qq --no-install-recommends install \ 17 | dumb-init \ 18 | isc-dhcp-server \ 19 | iputils-ping \ 20 | iproute2 \ 21 | ca-certificates \ 22 | wget \ 23 | sudo; 24 | 25 | ARG BUILD_MODE=release 26 | COPY --from=builder /usr/src/dora/target/${BUILD_MODE}/dora /usr/local/bin/dora 27 | 28 | RUN mkdir -p /var/lib/dora/ 29 | 30 | COPY util/entrypoint.sh /entrypoint.sh 31 | ENTRYPOINT ["/entrypoint.sh"] 32 | -------------------------------------------------------------------------------- /libs/icmp-ping/examples/simple.rs: -------------------------------------------------------------------------------- 1 | use icmp_ping::{Icmpv4, Listener}; 2 | use tracing::{error, info}; 3 | 4 | #[tokio::main] 5 | async fn main() { 6 | tracing_subscriber::fmt::init(); 7 | let host = std::env::args() 8 | .nth(1) 9 | .unwrap_or_else(|| "127.0.0.1".to_string()); 10 | 11 | let ip = tokio::net::lookup_host(format!("{host}:0")) 12 | .await 13 | .expect("host lookup error") 14 | .next() 15 | .map(|val| val.ip()) 16 | .unwrap(); 17 | 18 | let listener = Listener::::new().unwrap(); 19 | let pinger = listener.pinger(ip); 20 | match pinger.ping(0).await { 21 | Ok(reply) => { 22 | info!(reply = ?reply.reply, time = ?reply.time); 23 | } 24 | Err(err) => error!(?err), 25 | }; 26 | } 27 | -------------------------------------------------------------------------------- /libs/config/sample/config_v6_UUID.yaml: -------------------------------------------------------------------------------- 1 | v6: 2 | server_id: 3 | type: UUID 4 | identifier: 451c810bf191a92abf3768dd1ed61f3a 5 | persist: true 6 | path: ./server_id 7 | options: 8 | values: 9 | 23: 10 | type: ip_list 11 | value: 12 | - 2001:db8::1 13 | - 2001:db8::2 14 | networks: 15 | 2001:db8:1::/64: 16 | config: 17 | lease_time: 18 | default: 3600 19 | preferred_time: 20 | default: 3600 21 | options: 22 | values: 23 | 23: 24 | type: ip_list 25 | value: 26 | - 2001:db8::1 27 | - 2001:db8::2 28 | -------------------------------------------------------------------------------- /bin/tests/test_configs/cache_threshold.yaml: -------------------------------------------------------------------------------- 1 | cache_threshold: 25 2 | interfaces: 3 | - dhcpsrv 4 | networks: 5 | 192.168.2.0/24: 6 | probation_period: 86400 7 | ranges: 8 | - 9 | start: 192.168.2.100 10 | end: 192.168.2.103 11 | config: 12 | lease_time: 13 | default: 3600 14 | options: 15 | values: 16 | subnet_mask: 17 | type: ip 18 | value: 192.168.1.1 19 | routers: 20 | type: ip 21 | value: [ 192.168.1.1 ] 22 | domain_name_servers: 23 | type: ip 24 | value: [ 1.1.1.1 ] 25 | -------------------------------------------------------------------------------- /libs/config/sample/config_v6_EN.yaml: -------------------------------------------------------------------------------- 1 | v6: 2 | server_id: 3 | type: EN 4 | identifier: 1122FFFE3842 5 | enterprise_id: 23 6 | persist: true 7 | path: ./server_id 8 | options: 9 | values: 10 | 23: 11 | type: ip_list 12 | value: 13 | - 2001:db8::1 14 | - 2001:db8::2 15 | networks: 16 | 2001:db8:1::/64: 17 | config: 18 | lease_time: 19 | default: 3600 20 | preferred_time: 21 | default: 3600 22 | options: 23 | values: 24 | 23: 25 | type: ip_list 26 | value: 27 | - 2001:db8::1 28 | - 2001:db8::2 29 | -------------------------------------------------------------------------------- /libs/config/sample/config_v6_LL.yaml: -------------------------------------------------------------------------------- 1 | v6: 2 | server_id: 3 | type: LL 4 | identifier: fe80::c981:b769:461a:bfb4 5 | hardware_type: 2 6 | persist: true 7 | path: ./server_id 8 | options: 9 | values: 10 | 23: 11 | type: ip_list 12 | value: 13 | - 2001:db8::1 14 | - 2001:db8::2 15 | networks: 16 | 2001:db8:1::/64: 17 | config: 18 | lease_time: 19 | default: 3600 20 | preferred_time: 21 | default: 3600 22 | options: 23 | values: 24 | 23: 25 | type: ip_list 26 | value: 27 | - 2001:db8::1 28 | - 2001:db8::2 29 | -------------------------------------------------------------------------------- /libs/config/sample/config_v6.yaml: -------------------------------------------------------------------------------- 1 | v6: 2 | server_id: 3 | type: LLT 4 | identifier: fe80::c981:b769:461a:bfb4 5 | time: 1111112 6 | hardware_type: 1 7 | persist: true 8 | path: ./server_id 9 | options: 10 | values: 11 | 23: 12 | type: ip_list 13 | value: 14 | - 2001:db8::1 15 | - 2001:db8::2 16 | networks: 17 | 2001:db8:1::/64: 18 | config: 19 | lease_time: 20 | default: 3600 21 | preferred_time: 22 | default: 3600 23 | options: 24 | values: 25 | 23: 26 | type: ip_list 27 | value: 28 | - 2001:db8::1 29 | - 2001:db8::2 30 | -------------------------------------------------------------------------------- /shell.nix: -------------------------------------------------------------------------------- 1 | {pkgs ? import {}, ...}: 2 | pkgs.mkShell { 3 | buildInputs = with pkgs.buildPackages; [ 4 | pkg-config 5 | openssl 6 | 7 | (rust-bin.fromRustupToolchainFile ./rust-toolchain.toml) 8 | rust-analyzer 9 | ]; 10 | 11 | # Production 12 | # DATABASE_URL = "sqlite:////var/lib/dora/leases.db?mode=rwc"; 13 | # DBEE_CONNECTIONS = "[ 14 | # { 15 | # \"name\": \"dora_db\", 16 | # \"type\": \"sqlite\", 17 | # \"url\": \"/var/lib/dora/leases.db?mode=rwc\" 18 | # } 19 | # ]"; 20 | 21 | # Development 22 | DATABASE_URL = "sqlite://./em.db?mode=rwc"; 23 | DBEE_CONNECTIONS = "[ 24 | { 25 | \"name\": \"dora_db\", 26 | \"type\": \"sqlite\", 27 | \"url\": \"./em.db?mode=rwc\" 28 | } 29 | ]"; 30 | 31 | # Fix jemalloc-sys build error on nixos 32 | # _FORTIFY_SOURCE = 0; 33 | hardeningDisable = ["all"]; 34 | } 35 | -------------------------------------------------------------------------------- /bin/tests/test_configs/threshold.yaml: -------------------------------------------------------------------------------- 1 | flood_protection_threshold: 2 | packets: 2 3 | secs: 5 4 | 5 | interfaces: 6 | - dhcpsrv 7 | networks: 8 | 192.168.2.0/24: 9 | probation_period: 86400 10 | ranges: 11 | - 12 | start: 192.168.2.100 13 | end: 192.168.2.103 14 | config: 15 | lease_time: 16 | default: 3600 17 | options: 18 | values: 19 | subnet_mask: 20 | type: ip 21 | value: 192.168.1.1 22 | routers: 23 | type: ip 24 | value: [ 192.168.1.1 ] 25 | domain_name_servers: 26 | type: ip 27 | value: [ 1.1.1.1 ] 28 | -------------------------------------------------------------------------------- /external-api/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "external-api" 3 | version = "0.1.0" 4 | authors = ["BlueCat Networks "] 5 | edition = "2024" 6 | license = "MPL-2.0" 7 | 8 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 9 | 10 | [dependencies] 11 | dora-core = { path = "../dora-core" } 12 | ip-manager = { path = "../libs/ip-manager" } 13 | config = { path = "../libs/config" } 14 | 15 | # libs 16 | anyhow = { workspace = true } 17 | axum = "0.7.5" 18 | chrono = "0.4.38" 19 | hex = "0.4.3" 20 | tokio = { workspace = true } 21 | tower-http = { version = "0.6.2", features = ["trace", "timeout"] } 22 | tokio-util = { workspace = true } 23 | tracing-futures = { workspace = true } 24 | tracing = { workspace = true } 25 | parking_lot = "0.12" 26 | serde = { workspace = true } 27 | serde_json = { workspace = true } 28 | prometheus = { workspace = true } 29 | ipnet = { workspace = true } 30 | 31 | 32 | [dev-dependencies] 33 | reqwest = { version = "0.12.4", default-features = false, features = [ 34 | "json", 35 | "rustls-tls", 36 | ] } 37 | -------------------------------------------------------------------------------- /libs/ip-manager/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "ip-manager" 3 | version = "0.1.0" 4 | edition = "2024" 5 | license = "MPL-2.0" 6 | workspace = "../../" 7 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 8 | 9 | [dependencies] 10 | config = { path = "../../libs/config" } 11 | icmp-ping = { path = "../icmp-ping" } 12 | client-protection = { path = "../client-protection" } 13 | 14 | async-trait = { workspace = true } 15 | ipnet = { workspace = true } 16 | thiserror = { workspace = true } 17 | tracing = { workspace = true, features = [ 18 | "log", 19 | ] } # TODO: do we need the log feature? 20 | chrono = "0.4.19" 21 | moka = { version = "0.10.0", features = ["future"] } 22 | # TODO: hopefully the rustls feature can go away, the lib requires it 23 | sqlx = { version = "0.5.13", features = [ 24 | "sqlite", 25 | "runtime-tokio-rustls", 26 | "chrono", 27 | "offline", 28 | ] } 29 | 30 | [dev-dependencies] 31 | tokio-test = "0.4.1" 32 | tracing = { workspace = true, features = ["log"] } 33 | tokio = { workspace = true } 34 | tracing-test = "0.2.4" 35 | rand = { workspace = true } 36 | -------------------------------------------------------------------------------- /dora-core/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! # dora 2 | //! 3 | #![warn( 4 | missing_debug_implementations, 5 | missing_docs, 6 | missing_copy_implementations, 7 | rust_2018_idioms, 8 | unreachable_pub, 9 | non_snake_case, 10 | non_upper_case_globals 11 | )] 12 | #![allow(clippy::cognitive_complexity)] 13 | #![deny(rustdoc::broken_intra_doc_links)] 14 | #![doc(test( 15 | no_crate_inject, 16 | attr(deny(warnings, rust_2018_idioms), allow(dead_code, unused_variables)) 17 | ))] 18 | pub use anyhow; 19 | pub use async_trait::async_trait; 20 | pub use chrono; 21 | pub use chrono_tz; 22 | pub use dhcproto; 23 | pub use hickory_proto; 24 | pub use pnet; 25 | pub use tokio; 26 | pub use tokio_stream; 27 | pub use tracing; 28 | pub use unix_udp_sock; 29 | 30 | pub use crate::server::Server; 31 | 32 | pub mod config; 33 | pub mod env; 34 | pub mod handler; 35 | pub mod metrics; 36 | pub mod prelude; 37 | pub mod server; 38 | 39 | /// Register a plugin with the server 40 | pub trait Register { 41 | /// add plugin to one of the server's plugin lists in the implementation of 42 | /// this method 43 | fn register(self, srv: &mut Server); 44 | } 45 | -------------------------------------------------------------------------------- /bin/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "dora-bin" 3 | version = "0.2.0" 4 | edition = "2024" 5 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 6 | default-run = "dora" 7 | license = "MPL-2.0" 8 | 9 | [dependencies] 10 | dora-core = { path = "../dora-core" } 11 | external-api = { path = "../external-api" } 12 | # plugins 13 | message-type = { path = "../plugins/message-type" } 14 | leases = { path = "../plugins/leases" } 15 | static-addr = { path = "../plugins/static-addr" } 16 | # libs 17 | ip-manager = { path = "../libs/ip-manager" } 18 | config = { path = "../libs/config" } 19 | tokio-util = { workspace = true } 20 | # external 21 | anyhow = { workspace = true } 22 | tracing-futures = { workspace = true } 23 | dotenv = "0.15.0" 24 | 25 | [dev-dependencies] 26 | mac_address = "1.1.1" 27 | derive_builder = "0.12.0" 28 | crossbeam-channel = "0.5.1" 29 | rand = "0.8" 30 | socket2 = { workspace = true } 31 | tracing-test = "0.2.4" 32 | 33 | [target.'cfg(not(target_env = "musl"))'.dependencies] 34 | jemallocator = { version = "0.5.0", features = ["background_threads"] } 35 | 36 | [[bin]] 37 | name = "dora" 38 | path = "src/main.rs" 39 | -------------------------------------------------------------------------------- /package.nix: -------------------------------------------------------------------------------- 1 | { 2 | pkgs ? import {}, 3 | lib, 4 | ... 5 | }: 6 | pkgs.rustPlatform.buildRustPackage { 7 | pname = "dora"; 8 | version = (builtins.fromTOML (lib.readFile ./bin/Cargo.toml)).package.version; 9 | 10 | src = ./.; 11 | cargoLock = { 12 | lockFile = ./Cargo.lock; 13 | }; 14 | 15 | # disable tests 16 | checkType = "debug"; 17 | doCheck = false; 18 | 19 | nativeBuildInputs = with pkgs; [ 20 | installShellFiles 21 | pkg-config 22 | ]; 23 | buildInputs = with pkgs; [ 24 | pkg-config 25 | openssl 26 | 27 | (rust-bin.fromRustupToolchainFile ./rust-toolchain.toml) 28 | ]; 29 | 30 | # We need to create a testing database for the binary to compile 31 | # because of post-build tests. 32 | DATABASE_URL = "sqlite://./em.db?mode=rwc"; 33 | preBuild = with pkgs; '' 34 | ${sqlx-cli}/bin/sqlx database create 35 | ${sqlx-cli}/bin/sqlx migrate run 36 | ''; 37 | 38 | # postInstall = with lib; '' 39 | # installShellCompletion --cmd ${pname}\ 40 | # --bash ./autocompletion/${pname}.bash \ 41 | # --fish ./autocompletion/${pname}.fish \ 42 | # --zsh ./autocompletion/_${pname} 43 | # ''; 44 | } 45 | -------------------------------------------------------------------------------- /bin/tests/test_configs/classes.yaml: -------------------------------------------------------------------------------- 1 | interfaces: 2 | - dhcpsrv 3 | networks: 4 | 192.168.2.0/24: 5 | probation_period: 86400 6 | ranges: 7 | - 8 | class: testclass 9 | start: 192.168.2.100 10 | end: 192.168.2.103 11 | config: 12 | lease_time: 13 | default: 3600 14 | options: 15 | values: 16 | subnet_mask: 17 | type: ip 18 | value: 192.168.1.1 19 | routers: 20 | type: ip 21 | value: [ 192.168.1.1 ] 22 | domain_name_servers: 23 | type: ip 24 | value: [ 1.1.1.1 ] 25 | 26 | client_classes: 27 | v4: 28 | - 29 | name: testclass 30 | assert: "substring(option[60].hex, 0, 7) == 'android'" 31 | options: 32 | values: 33 | vendor_extensions: 34 | type: u32 35 | value: [1, 2, 3, 4] 36 | 37 | -------------------------------------------------------------------------------- /libs/icmp-ping/src/socket.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | io, 3 | os::unix::io::{FromRawFd, IntoRawFd}, 4 | }; 5 | 6 | use socket2::{Domain, Protocol, Type}; 7 | use std::net::SocketAddr; 8 | use tokio::net::UdpSocket; 9 | 10 | pub struct Socket { 11 | pub(crate) socket: UdpSocket, 12 | } 13 | 14 | impl Socket { 15 | pub fn new(domain: Domain, type_: Type, protocol: Protocol) -> io::Result { 16 | let socket = socket2::Socket::new(domain, type_, Some(protocol))?; 17 | socket.set_nonblocking(true)?; 18 | #[cfg(windows)] 19 | let socket = UdpSocket::from_std(unsafe { 20 | std::net::UdpSocket::from_raw_socket(socket.into_raw_socket()) 21 | })?; 22 | #[cfg(unix)] 23 | let socket = 24 | UdpSocket::from_std(unsafe { std::net::UdpSocket::from_raw_fd(socket.into_raw_fd()) })?; 25 | 26 | Ok(Self { socket }) 27 | } 28 | 29 | pub async fn send_to(&self, buf: &[u8], target: &SocketAddr) -> io::Result { 30 | self.socket.send_to(buf, target).await 31 | } 32 | 33 | pub async fn recv(&self, buf: &mut [u8]) -> io::Result<(usize, SocketAddr)> { 34 | self.socket.recv_from(buf).await 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Dora - A rust DHCP server"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 6 | rust-overlay.url = "github:oxalica/rust-overlay"; 7 | flake-utils.url = "github:numtide/flake-utils"; 8 | flake-parts.url = "github:hercules-ci/flake-parts"; 9 | }; 10 | 11 | outputs = { 12 | self, 13 | nixpkgs, 14 | rust-overlay, 15 | flake-utils, 16 | flake-parts, 17 | } @ inputs: 18 | flake-parts.lib.mkFlake { 19 | inherit inputs; 20 | } { 21 | flake = { 22 | nixosModules = rec { 23 | default = dora; 24 | dora = ./modules/default.nix; 25 | }; 26 | }; 27 | systems = 28 | flake-utils.lib.allSystems; 29 | perSystem = { 30 | config, 31 | self, 32 | inputs, 33 | pkgs, 34 | system, 35 | ... 36 | }: let 37 | overlays = [(import rust-overlay)]; 38 | pkgs = import nixpkgs { 39 | inherit system overlays; 40 | }; 41 | in { 42 | devShells.default = pkgs.callPackage ./shell.nix {}; 43 | packages.default = pkgs.callPackage ./package.nix {}; 44 | }; 45 | }; 46 | } 47 | -------------------------------------------------------------------------------- /libs/config/sample/circular_deps.yaml: -------------------------------------------------------------------------------- 1 | chaddr_only: false 2 | # interfaces: 3 | # - wlan0 4 | client_classes: 5 | v4: 6 | - 7 | name: my_class 8 | assert: "member('c_class')" 9 | options: 10 | values: 11 | 6: 12 | type: ip 13 | value: [ 1.1.1.1 ] 14 | - 15 | name: a_class 16 | assert: "option[12].hex == 'hostname'" 17 | options: 18 | values: 19 | 6: 20 | type: ip 21 | value: [ 1.1.1.1 ] 22 | - 23 | name: b_class 24 | assert: "member('a_class') and pkt4.mac == 0xDEADBEEF" 25 | options: 26 | values: 27 | 6: 28 | type: ip 29 | value: [ 1.1.1.1 ] 30 | - 31 | name: c_class 32 | # circular 33 | assert: "member('a_class') and member('b_class') or member('my_class')" 34 | options: 35 | values: 36 | 6: 37 | type: ip 38 | value: [ 1.1.1.1 ] 39 | -------------------------------------------------------------------------------- /bin/tests/common/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod env; 2 | 3 | pub mod builder; 4 | #[allow(unused)] 5 | pub mod client; 6 | 7 | pub mod utils { 8 | use std::net::Ipv4Addr; 9 | 10 | use anyhow::{Result, bail}; 11 | use dora_core::dhcproto::v4; 12 | use mac_address::MacAddress; 13 | 14 | pub fn get_mac() -> MacAddress { 15 | mac_address::get_mac_address() 16 | .expect("unable to get MAC addr") 17 | .unwrap() 18 | } 19 | 20 | pub fn rand_mac() -> MacAddress { 21 | let mut mac = [0; 6]; 22 | for b in &mut mac { 23 | *b = rand::random::(); 24 | } 25 | MacAddress::new(mac) 26 | } 27 | 28 | pub fn get_sident(msg: &v4::Message) -> Result { 29 | if let Some(v4::DhcpOption::ServerIdentifier(ip)) = 30 | msg.opts().get(v4::OptionCode::ServerIdentifier) 31 | { 32 | Ok(*ip) 33 | } else { 34 | bail!("unreachable") 35 | } 36 | } 37 | 38 | pub fn default_request_list() -> Vec { 39 | vec![ 40 | v4::OptionCode::SubnetMask, 41 | v4::OptionCode::Router, 42 | v4::OptionCode::DomainNameServer, 43 | v4::OptionCode::DomainName, 44 | ] 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /bin/tests/test_configs/vendor.yaml: -------------------------------------------------------------------------------- 1 | interfaces: 2 | - dhcpsrv 3 | networks: 4 | 192.168.2.0/24: 5 | probation_period: 86400 6 | ranges: 7 | - 8 | class: testclass 9 | start: 192.168.2.100 10 | end: 192.168.2.103 11 | config: 12 | lease_time: 13 | default: 3600 14 | options: 15 | values: 16 | subnet_mask: 17 | type: ip 18 | value: 192.168.1.1 19 | routers: 20 | type: ip 21 | value: [ 192.168.1.1 ] 22 | domain_name_servers: 23 | type: ip 24 | value: [ 1.1.1.1 ] 25 | 26 | client_classes: 27 | v4: 28 | - 29 | name: testclass 30 | assert: "member('VENDOR_CLASS_docsis3.0')" 31 | options: 32 | values: 33 | vendor_extensions: 34 | type: u32 35 | value: [1, 2, 3, 4] 36 | - 37 | name: DROP 38 | assert: "member('VENDOR_CLASS_foobar')" 39 | -------------------------------------------------------------------------------- /dora-core/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "dora-core" 3 | version = "0.1.0" 4 | edition = "2024" 5 | authors = ["BlueCat Networks "] 6 | description = "dora is a DHCP server written from the ground up in Rust" 7 | license = "MPL-2.0" 8 | 9 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 10 | 11 | [dependencies] 12 | # local 13 | env-parser = { path = "../libs/env-parser" } 14 | topo_sort = { path = "../libs/topo_sort" } 15 | # third party 16 | async-trait = { workspace = true } 17 | anyhow = { workspace = true } 18 | bytes = { workspace = true } 19 | chrono = "0.4" 20 | chrono-tz = "0.6" 21 | dhcproto = { workspace = true } 22 | futures = { workspace = true } 23 | lazy_static = "1.4" 24 | tokio = { workspace = true } 25 | tokio-stream = "0.1" 26 | tokio-util = { workspace = true } 27 | tracing = { workspace = true } 28 | tracing-futures = { workspace = true } 29 | tracing-subscriber = { workspace = true } 30 | hickory-proto = { workspace = true } 31 | pin-project = "1.0" 32 | prometheus = { workspace = true } 33 | prometheus-static-metric = { workspace = true } 34 | rand = { workspace = true } 35 | clap = { workspace = true } 36 | socket2 = { workspace = true } 37 | libc = "0.2.126" 38 | unix-udp-sock = "0.8.0" 39 | pnet = { workspace = true } 40 | 41 | [dev-dependencies] 42 | tokio-test = "0.4.1" 43 | -------------------------------------------------------------------------------- /dora-core/src/handler.rs: -------------------------------------------------------------------------------- 1 | //! Plugins can register to various points in the request lifecycle 2 | //! by implementing one of these traits. 3 | use anyhow::Result; 4 | use async_trait::async_trait; 5 | 6 | pub(crate) use crate::server::{context::MsgContext, state::State}; 7 | 8 | /// Action for dora to take after the plugin returns 9 | #[derive(Debug, Eq, PartialEq, Copy, Clone)] 10 | pub enum Action { 11 | /// Respond with `decoded_resp_msg` from `MsgContext` 12 | Respond, 13 | /// Don't respond 14 | NoResponse, 15 | /// Continue executing the next plugin 16 | Continue, 17 | } 18 | 19 | /// define a plugin which will mutate a MsgContext where T is the Message type 20 | #[async_trait] 21 | pub trait Plugin: Send + Sync + 'static { 22 | /// what to execute during this step in the message lifecycle 23 | /// 24 | /// CANCEL-SAFETY: everything in handle must be cancel-safe. A top-level timeout can possibly kill this 25 | /// method 26 | async fn handle(&self, ctx: &mut MsgContext) -> Result; 27 | } 28 | 29 | /// A handler that is run after the response is returned. This moves the 30 | /// `MsgContext` instead of borrowing it, and as such only one such handler can 31 | /// be added. 32 | #[async_trait] 33 | pub trait PostResponse: Send + Sync + 'static { 34 | /// what to execute during this step in the message lifecycle 35 | async fn handle(&self, ctx: MsgContext); 36 | } 37 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = [ 3 | "bin", 4 | # main server code 5 | "dora-core", 6 | "dora-cfg", 7 | "ddns-test", 8 | # healthcheck/diagnostics,etc 9 | "external-api", 10 | # libs 11 | "libs/*", 12 | # plugins 13 | "plugins/*", 14 | ] 15 | resolver = "2" 16 | # default-members = ["bin"] 17 | 18 | [workspace.dependencies] 19 | hickory-proto = { version = "0.25.2", default-features = false, features = [ 20 | "dnssec-ring", 21 | "serde", 22 | ] } 23 | socket2 = { version = "0.5.6", features = [ 24 | "all", 25 | ] } # TODO: update when tokio sockets impl AsFd, then update unix-udp-sock 26 | anyhow = { version = "1.0", features = ["backtrace"] } 27 | async-trait = "0.1" 28 | bytes = "1.1" 29 | clap = { version = "4.5.4", features = ["derive", "env"] } 30 | base64 = "0.22.1" 31 | dhcproto = "0.14.0" 32 | futures = { version = "0.3", default-features = false, features = ["std"] } 33 | ipnet = { features = ["serde"], version = "2.4.0" } 34 | pnet = { features = ["serde", "std"], version = "0.34.0" } 35 | prometheus = "0.13.0" 36 | prometheus-static-metric = "0.5" 37 | tokio = { version = "1.37.0", features = ["full"] } 38 | tokio-util = { version = "0.7.0", features = ["codec", "net"] } 39 | tracing = "0.1.40" 40 | tracing-futures = "0.2.5" 41 | tracing-subscriber = { features = ["env-filter", "json"], version = "0.3" } 42 | thiserror = "1.0" 43 | rand = "0.8.5" 44 | serde = { version = "1.0", features = ["derive"] } 45 | serde_json = "1.0" 46 | serde_yaml = "0.8" 47 | rustls-pki-types = "1.13.1" -------------------------------------------------------------------------------- /libs/register_derive/README.md: -------------------------------------------------------------------------------- 1 | # register_derive 2 | 3 | derive macros for easily defining plugins for dora. 4 | 5 | ex. 6 | 7 | ```rust 8 | use dora_core::{ 9 | dhcproto::v4::Message, 10 | prelude::*, 11 | }; 12 | use register_derive::Register; 13 | use message_type::MsgType; 14 | 15 | #[derive(Register)] 16 | #[register(msg(Message))] 17 | #[register(plugin(MsgType))] 18 | pub struct PluginName; 19 | ``` 20 | 21 | Defines a new plugin called `PluginName` and a `Register` implementation. It is defined with a `v4::Message` type, i.e. for dhcpv4 only, and has a dependency on the `MsgType` plugin, meaning `MsgType` will always be called before `PluginName` in the plugin handler sequence. All of this will generate code that looks roughly like: 22 | 23 | ```rust 24 | #[automatically_derived] 25 | impl dora_core::Register for StaticAddr { 26 | fn register(self, srv: &mut dora_core::Server) { 27 | // some logging stuff omitted 28 | let this = std::sync::Arc::new(self); 29 | srv.plugin_order::(this, &[std::any::TypeId::of::()]); 30 | } 31 | } 32 | ``` 33 | 34 | **TODO**: automatic derives for generic message types not currently supported, i.e. if you want to derive `Register` for a plugin that is generic over v4/v6 (`T: Encodable + Decodable`) you will need to write a `Register` implementation by hand. We could improve the `register_derive_impl` so that if you don't include `#[register(Message)]` it will assume you want to define it generically, but this is not yet done. 35 | -------------------------------------------------------------------------------- /libs/icmp-ping/src/shutdown.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code)] 2 | use tokio::sync::broadcast; 3 | 4 | // shutdown code from https://github.com/tokio-rs/mini-redis 5 | /// Listens for the server shutdown signal. 6 | /// 7 | /// Shutdown is signalled using a `broadcast::Receiver`. Only a single value is 8 | /// ever sent. Once a value has been sent via the broadcast channel, the server 9 | /// should shutdown. 10 | /// 11 | /// The `Shutdown` struct listens for the signal and tracks that the signal has 12 | /// been received. Callers may query for whether the shutdown signal has been 13 | /// received or not. 14 | #[derive(Debug)] 15 | pub struct Shutdown { 16 | /// `true` if the shutdown signal has been received 17 | shutdown: bool, 18 | 19 | /// The receive half of the channel used to listen for shutdown. 20 | notify: broadcast::Receiver<()>, 21 | } 22 | 23 | impl Shutdown { 24 | /// Create a new `Shutdown` backed by the given `broadcast::Receiver`. 25 | pub(crate) fn new(notify: broadcast::Receiver<()>) -> Shutdown { 26 | Shutdown { 27 | shutdown: false, 28 | notify, 29 | } 30 | } 31 | 32 | /// Returns `true` if the shutdown signal has been received. 33 | pub(crate) fn is_shutdown(&self) -> bool { 34 | self.shutdown 35 | } 36 | 37 | /// Receive the shutdown notice, waiting if necessary. 38 | pub(crate) async fn recv(&mut self) { 39 | // If the shutdown signal has already been received, then return 40 | // immediately. 41 | if self.shutdown { 42 | return; 43 | } 44 | 45 | // Cannot receive a "lag error" as only one value is ever sent. 46 | let _ = self.notify.recv().await; 47 | 48 | // Remember that the signal has been received. 49 | self.shutdown = true; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /docs/creating_plugin.md: -------------------------------------------------------------------------------- 1 | # Creating a Plugin 2 | 3 | Plugins for dora are like middleware for HTTP servers. They modify the incoming message in some way and prepare the response. They additionally have the ability to short-circuit request processing by returning early, telling the server to respond or drop the message. 4 | 5 | Proc macros are included to make writing plugins easy. A basic plugin looks like this: 6 | 7 | ```rust 8 | use dora_core::{ 9 | dhcproto::v4::Message, 10 | prelude::*, 11 | }; 12 | use config::DhcpConfig; 13 | 14 | #[derive(Debug, Register)] 15 | #[register(msg(Message))] 16 | #[register(plugin())] 17 | pub struct MyPlugin { 18 | cfg: Arc, 19 | } 20 | 21 | #[async_trait] 22 | impl Plugin for MyPlugin { 23 | #[instrument(level = "debug", skip_all)] 24 | async fn handle(&self, ctx: &mut MsgContext) -> Result { 25 | Ok(Action::Continue) 26 | } 27 | } 28 | ``` 29 | 30 | After deriving the `Register` trait, you can use `#[register(msg(Message))]` to say that you want to 'register' this plugin to handle `dhcproto::v4::Message` type messages, DHCPv4 messages essentially. You can add an additional `#[register(msg(v6::Message))]` attribute if a plugin will be run on v6 messages also. Each `register(msg())` attribute requires a corresponding `impl Plugin<>` implementation. 31 | 32 | The `#[register(plugin())]` attribute tells dora what other plugins you want to run _before_ this plugin runs. This way you can make sure some other plugin always runs before `MyPlugin`. You can put multiple entries here to create dependencies. At startup, dora will do a topological sort to create a dependency path through the plugins which it will use at runtime. 33 | 34 | The `handle` method is where all the fun stuff happens. You can modify the `MsgContext` and can return `Action::Continue`, `Action::Respond`, or `Action::NoResponse`. 35 | -------------------------------------------------------------------------------- /libs/config/sample/config_v4.json: -------------------------------------------------------------------------------- 1 | { 2 | "chaddr_only": false, 3 | "flood_protection_threshold": { 4 | "packets": 3, 5 | "secs": 5 6 | }, 7 | "cache_threshold": 25, 8 | "networks": { 9 | "192.168.1.100/30": { 10 | "probation_period": 86400, 11 | "server_id": "192.168.1.1", 12 | "ranges": [ 13 | { 14 | "start": "192.168.1.100", 15 | "end": "192.168.1.103", 16 | "config": { 17 | "lease_time": { 18 | "default": 3600, 19 | "min": 1200, 20 | "max": 4800 21 | } 22 | }, 23 | "options": { 24 | "values": { 25 | "1": { 26 | "type": "ip", 27 | "value": "192.168.1.1" 28 | }, 29 | "3": { 30 | "type": "ip", 31 | "value": ["192.168.1.1"] 32 | }, 33 | "43": { 34 | "type": "sub_option", 35 | "value": { 36 | "1": { 37 | "type": "str", 38 | "value": "foobar" 39 | }, 40 | "2": { 41 | "type": "ip", 42 | "value": "1.2.3.4" 43 | } 44 | } 45 | } 46 | } 47 | } 48 | } 49 | ] 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /dora-core/src/server/msg.rs: -------------------------------------------------------------------------------- 1 | //! SerialMsg defines raw bytes and an addr 2 | use bytes::Bytes; 3 | use dhcproto::{Decodable, Encodable}; 4 | 5 | use std::{io, net::SocketAddr}; 6 | 7 | // use crate::udp::UdpRecv; 8 | 9 | /// A message pulled from TCP or UDP and serialized to bytes, stored with a 10 | /// [`SocketAddr`] 11 | /// 12 | /// [`SocketAddr`]: std::net::SocketAddr 13 | #[derive(Debug, Clone, PartialEq, Eq)] 14 | pub struct SerialMsg { 15 | message: Bytes, 16 | addr: SocketAddr, 17 | } 18 | 19 | impl SerialMsg { 20 | /// Construct a new `SerialMsg` and the source or destination address 21 | pub fn new(message: Bytes, addr: SocketAddr) -> Self { 22 | SerialMsg { message, addr } 23 | } 24 | 25 | /// Constructs a new `SerialMsg` from another `SerialMsg` and a `SocketAddr` 26 | pub fn from_msg(msg: &T, addr: SocketAddr) -> io::Result { 27 | Ok(SerialMsg { 28 | message: msg 29 | .to_vec() 30 | .map_err(|op| io::Error::new(io::ErrorKind::InvalidData, op))? 31 | .into(), 32 | addr, 33 | }) 34 | } 35 | /// Get a reference to the bytes 36 | pub fn bytes(&self) -> &[u8] { 37 | &self.message 38 | } 39 | 40 | /// Clone underlying `Bytes` pointer 41 | pub fn msg(&self) -> Bytes { 42 | self.message.clone() 43 | } 44 | 45 | /// Get the source or destination address (context dependent) 46 | pub fn addr(&self) -> SocketAddr { 47 | self.addr 48 | } 49 | 50 | /// Set the source or destination address 51 | pub fn set_addr(&mut self, addr: SocketAddr) { 52 | self.addr = addr; 53 | } 54 | 55 | /// Gets the bytes and address as a tuple 56 | pub fn contents(self) -> (Bytes, SocketAddr) { 57 | (self.message, self.addr) 58 | } 59 | 60 | /// Deserializes the inner data into a Message 61 | pub fn to_msg(&self) -> io::Result { 62 | T::from_bytes(&self.message).map_err(|op| io::Error::new(io::ErrorKind::InvalidData, op)) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /modules/config.nix: -------------------------------------------------------------------------------- 1 | { 2 | lib, 3 | config, 4 | inputs, 5 | pkgs, 6 | ... 7 | }: 8 | with lib; let 9 | moduleName = "dora"; 10 | cfg = config.services.${moduleName}; 11 | in 12 | mkIf cfg.enable { 13 | systemd.tmpfiles.rules = [ 14 | # Ensure working directory 15 | "Z '/var/lib/dora' 774 root users - -" 16 | "d '/var/lib/dora' 774 root users - -" 17 | 18 | # Ensure configuration directory 19 | "Z '/etc/dora' 774 root users - -" 20 | "d '/etc/dora' 774 root users - -" 21 | ]; 22 | 23 | # Run the dhcp server in a ystemd background service 24 | systemd.services.dora = { 25 | enable = true; 26 | description = "Dora - A rust DHCP server"; 27 | documentation = [ 28 | "https://github.com/bluecatengineering/dora" 29 | "dora --help" 30 | ]; 31 | after = [ 32 | "network.target" 33 | ]; 34 | wantedBy = ["multi-user.target"]; 35 | 36 | serviceConfig = with pkgs; let 37 | package = inputs.dora.packages.${system}.default; 38 | in { 39 | Type = "simple"; 40 | User = "root"; 41 | Group = "users"; 42 | Environment = "PATH=/run/current-system/sw/bin"; 43 | ExecStart = '' 44 | ${package}/bin/dora \ 45 | -c /etc/dora/config.yaml \ 46 | -d /var/lib/dora/leases.db 47 | ''; 48 | ExecStartPost = [ 49 | "-${pkgs.coreutils}/bin/chown -R root:users /var/lib/dora" 50 | "-${pkgs.coreutils}/bin/chmod -R 774 /var/lib/dora" 51 | ]; 52 | 53 | WorkingDirectory = "/var/lib/dora"; 54 | 55 | StandardInput = "null"; 56 | StandardOutput = "journal+console"; 57 | StandardError = "journal+console"; 58 | 59 | AmbientCapabilities = [ 60 | # Allow service to open a tcp or unix socket to listen to. 61 | "CAP_NET_BIND_SERVICE" 62 | # "CAP_NET_ADMIN" 63 | # "CAP_SYS_ADMIN" 64 | ]; 65 | }; 66 | }; 67 | 68 | environment.systemPackages = with pkgs; [ 69 | # Add dora to environment because you never know. 70 | inputs.dora.packages.${system}.default 71 | ]; 72 | } 73 | -------------------------------------------------------------------------------- /bin/README.md: -------------------------------------------------------------------------------- 1 | # dora 2 | 3 | `dora` can be run with both cli options or from environment variables, see help for more info: 4 | 5 | ``` 6 | ❯ cargo run -- --help 7 | 8 | dora is a DHCP server written from the ground up in Rust 9 | 10 | Usage: dora [OPTIONS] 11 | 12 | Options: 13 | -c, --config-path path to dora's config [env: CONFIG_PATH=] [default: /var/lib/dora/config.yaml] 14 | --v4-addr the v4 address to listen on [env: V4_ADDR=] [default: 0.0.0.0:67] 15 | --v6-addr the v6 address to listen on [env: V6_ADDR=] [default: [::]:547] 16 | --external-api the v6 address to listen on [env: EXTERNAL_API=] [default: [::]:3333] 17 | --timeout default timeout, dora will respond within this window or drop [env: TIMEOUT=] [default: 3] 18 | --max-live-msgs max live messages before new messages will begin to be dropped [env: MAX_LIVE_MSGS=] [default: 1000] 19 | --channel-size channel size for various mpsc chans [env: CHANNEL_SIZE=] [default: 10000] 20 | --threads How many threads are spawned, default is the # of logical CPU cores [env: THREADS=] 21 | --thread-name Worker thread name [env: THREAD_NAME=] [default: dora-dhcp-worker] 22 | --dora-id ID of this instance [env: DORA_ID=] [default: dora_id] 23 | --dora-log set the log level. All valid RUST_LOG arguments are accepted [env: DORA_LOG=] [default: info] 24 | -d Path to the database use "sqlite::memory:" for in mem db ex. "em.db" NOTE: in memory sqlite db connection idle timeout is 5 mins [env: DATABASE_URL=] [default: /var/lib/dora/leases.db] 25 | -h, --help Print help 26 | ``` 27 | 28 | ## Example 29 | 30 | Run on non-standard ports: 31 | 32 | ``` 33 | dora -c /path/to/config.yaml --v4-addr 0.0.0.0:9900 34 | ``` 35 | 36 | is equivalent to: 37 | 38 | ``` 39 | V4_ADDR="0.0.0.0:9900" CONFIG_PATH="/path/to/config.yaml" dora 40 | ``` 41 | 42 | Use `DORA_LOG` to control dora's log level. Takes same arguments as `RUST_LOG` 43 | -------------------------------------------------------------------------------- /libs/ddns/examples/tsig.rs: -------------------------------------------------------------------------------- 1 | use std::{fs::File, io::Read, str::FromStr}; 2 | 3 | use ddns::{ 4 | dhcid::{self, IdType}, 5 | update::Updater, 6 | }; 7 | use dora_core::{ 8 | anyhow::{self, Result}, 9 | config::trace, 10 | dhcproto::Name, 11 | hickory_proto::dnssec::{rdata::tsig::TsigAlgorithm, tsig::TSigner}, 12 | tokio::{self}, 13 | tracing::{debug, error}, 14 | }; 15 | 16 | #[tokio::main] 17 | async fn main() -> Result<()> { 18 | let trace_config = 19 | trace::Config::parse(&std::env::var("RUST_LOG").unwrap_or_else(|_| "debug".to_owned()))?; 20 | debug!(?trace_config); 21 | let pem_path = "./examples/tsig.raw".to_owned(); 22 | println!("loading key from: {}", pem_path); 23 | let mut key_file = File::open(pem_path).expect("could not find key file"); 24 | 25 | let mut key = Vec::new(); 26 | key_file 27 | .read_to_end(&mut key) 28 | .expect("error reading key file"); 29 | 30 | let Ok(_tsig) = TSigner::new( 31 | key, 32 | TsigAlgorithm::HmacSha512, 33 | Name::from_ascii("tsig-key").unwrap(), 34 | // ?? 35 | 300, 36 | ) else { 37 | error!("failed to create or retrieve tsigner"); 38 | anyhow::bail!("failed to create tsigner") 39 | }; 40 | 41 | let mut client = Updater::new(([127, 0, 0, 1], 53).into(), None).await?; 42 | // forward 43 | dbg!( 44 | client 45 | .forward( 46 | Name::from_str("example.com.").unwrap(), 47 | Name::from_str("update.example.com.").unwrap(), 48 | dhcid::DhcId::new(IdType::ClientId, [0x01, 0x02, 0x03, 0x04, 0x05, 0x06]), 49 | "1.2.3.4".parse().unwrap(), 50 | 1300, 51 | ) 52 | .await? 53 | ); 54 | // reverse 55 | // dbg!( 56 | // client 57 | // .reverse( 58 | // Name::from_str("other.example.com.").unwrap(), 59 | // dhcid::DhcId::new(IdType::ClientId, [0x01, 0x02, 0x03, 0x04, 0x05, 0x06]), 60 | // "192.168.2.1".parse().unwrap(), 61 | // 1300, 62 | // ) 63 | // .await? 64 | // ); 65 | 66 | Ok(()) 67 | } 68 | -------------------------------------------------------------------------------- /dora-core/src/server/udp.rs: -------------------------------------------------------------------------------- 1 | //! Functions/types for reading incoming message from UDP 2 | use dhcproto::{Decodable, Encodable}; 3 | use futures::ready; 4 | use pin_project::pin_project; 5 | // use tokio::net::UdpSocket; 6 | use tokio_stream::Stream; 7 | use tokio_util::codec::BytesCodec; // , udp::UdpFramed}; 8 | use unix_udp_sock::{UdpSocket, framed::UdpFramed}; 9 | 10 | use std::{ 11 | borrow::Borrow, 12 | io, 13 | marker::PhantomData, 14 | pin::Pin, 15 | sync::Arc, 16 | task::{self, Poll}, 17 | }; 18 | 19 | use crate::{ 20 | handler::{MsgContext, State}, 21 | server::msg::SerialMsg, 22 | }; 23 | 24 | /// Abstracts reading buffers off of a tokio `net::UdpStream` and converting 25 | /// that raw data into a stream of [`MsgContext`] 26 | /// 27 | /// [`MsgContext`]: crate::MsgContext 28 | #[pin_project] 29 | #[derive(Debug)] 30 | pub(crate) struct UdpStream { 31 | #[pin] 32 | stream: UdpFramed, 33 | state: Arc, 34 | _marker: PhantomData, 35 | } 36 | 37 | impl UdpStream 38 | where 39 | T: Decodable + Encodable, 40 | S: Borrow, 41 | { 42 | /// Create a new stream from a `UdpRecv`r and `State` 43 | pub(crate) fn new(stream: S, state: Arc) -> Self { 44 | // we just want a stream of bytes, messages will be decoded later 45 | UdpStream { 46 | stream: UdpFramed::new(stream, BytesCodec::new()), 47 | state, 48 | _marker: PhantomData, 49 | } 50 | } 51 | } 52 | 53 | impl Stream for UdpStream 54 | where 55 | T: Decodable + Encodable, 56 | S: Borrow, 57 | { 58 | type Item = io::Result>; 59 | 60 | fn poll_next(self: Pin<&mut Self>, cx: &mut task::Context<'_>) -> Poll> { 61 | let pin = self.project(); 62 | match ready!(pin.stream.poll_next(cx)) { 63 | Some(res) => { 64 | let (buf, meta) = res?; 65 | let msg = SerialMsg::new(buf.freeze(), meta.addr); 66 | Poll::Ready(Some(Ok(MsgContext::new(msg, meta, Arc::clone(pin.state))?))) 67 | } 68 | None => Poll::Ready(None), 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /libs/discovery/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! # discovery 2 | //! 3 | #![warn( 4 | missing_debug_implementations, 5 | missing_docs, 6 | missing_copy_implementations, 7 | rust_2018_idioms, 8 | unreachable_pub, 9 | non_snake_case, 10 | non_upper_case_globals 11 | )] 12 | #![allow(clippy::cognitive_complexity)] 13 | #![deny(rustdoc::broken_intra_doc_links)] 14 | #![doc(test( 15 | no_crate_inject, 16 | attr(deny(warnings, rust_2018_idioms), allow(dead_code, unused_variables)) 17 | ))] 18 | use anyhow::{Context, Result}; 19 | use hickory_resolver::config::ResolverOpts; 20 | use hickory_resolver::name_server::TokioConnectionProvider; 21 | use hickory_resolver::{Resolver, TokioResolver, lookup::Ipv4Lookup}; 22 | 23 | /// DNS service discovery 24 | #[derive(Debug)] 25 | pub struct DnsServiceDiscovery { 26 | resolver: TokioResolver, 27 | } 28 | 29 | impl DnsServiceDiscovery { 30 | /// Create a new service 31 | pub fn new() -> Result { 32 | Ok(Self { 33 | resolver: Resolver::builder(TokioConnectionProvider::default()) 34 | .context("failed to create tokio resolver")? 35 | .with_options(ResolverOpts::default()) 36 | .build(), 37 | }) 38 | } 39 | 40 | /// do a DNS lookup, returning a URL with the "http" schema 41 | /// ex. 42 | /// lookup_http("foobar.internal", 67) -> "http://1.2.3.4:67" 43 | pub async fn lookup_http(&self, addr: impl AsRef, port: u16) -> Result { 44 | self.lookup("http", addr, port).await 45 | } 46 | 47 | /// do a DNS lookup, returning a URL 48 | /// ex. 49 | /// lookup("http", "foobar.internal", 67) -> "http://1.2.3.4:67" 50 | pub async fn lookup( 51 | &self, 52 | schema: impl AsRef, 53 | addr: impl AsRef, 54 | port: u16, 55 | ) -> Result { 56 | let get_first = |iter: Ipv4Lookup| { 57 | iter.iter() 58 | .next() 59 | .map(|addr| format!("{}://{}:{}", schema.as_ref(), addr, port)) 60 | }; 61 | let addrs = self.resolver.ipv4_lookup(addr.as_ref()).await?; 62 | 63 | get_first(addrs).context("failed to lookup addr") 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /dora-core/src/server/state.rs: -------------------------------------------------------------------------------- 1 | //! Server state. Used to count how many live messages are processing in the 2 | //! system right now, and keep track of message id's 3 | use tokio::sync::Semaphore; 4 | 5 | use std::sync::{ 6 | Arc, 7 | atomic::{AtomicUsize, Ordering}, 8 | }; 9 | 10 | use crate::metrics::IN_FLIGHT; 11 | 12 | /// Represents the current Server state 13 | #[derive(Debug)] 14 | pub struct State { 15 | /// current live message count 16 | live_msgs: Arc, 17 | /// max live message count 18 | live_limit: usize, 19 | /// id to assign incoming messages 20 | next_id: AtomicUsize, 21 | } 22 | 23 | impl State { 24 | /// Create new state with a set max live message count 25 | pub fn new(max_live: usize) -> State { 26 | State { 27 | live_msgs: Arc::new(Semaphore::new(max_live)), 28 | live_limit: max_live, 29 | next_id: AtomicUsize::new(0), 30 | } 31 | } 32 | 33 | /// Increments the count of live in-flight messages 34 | pub async fn inc_live_msgs(&self) { 35 | // forget() must be used on the semaphore after acquire otherwise 36 | // it will add the permit back when the semaphore is dropped, 37 | // and we don't actually want to do that, we want to add it back 38 | // when MsgContext is dropped 39 | // 40 | // SAFETY: acquire returns an Err when the semaphore is closed, which we never 41 | // do 42 | self.live_msgs.acquire().await.unwrap().forget(); 43 | IN_FLIGHT.inc(); 44 | } 45 | 46 | /// Decrements the count of live in-flight messages 47 | #[inline] 48 | pub fn dec_live_msgs(&self) { 49 | self.live_msgs.add_permits(1); 50 | IN_FLIGHT.dec(); 51 | } 52 | 53 | /// Return the current number of live queries 54 | #[inline] 55 | pub fn live_msgs(&self) -> usize { 56 | self.live_limit - self.live_msgs.available_permits() 57 | } 58 | 59 | /// Increment the context id 60 | #[inline] 61 | pub fn inc_id(&self) -> usize { 62 | self.next_id.fetch_add(1, Ordering::Acquire) 63 | } 64 | 65 | /// Reset msgs count 66 | #[inline] 67 | pub fn reset_live(&self) { 68 | IN_FLIGHT.set(0); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /ddns-test/ddns.json: -------------------------------------------------------------------------------- 1 | { 2 | "DhcpDdns": { 3 | "ip-address": "127.0.0.1", 4 | "port": 53001, 5 | "forward-ddns": { 6 | "ddns-domains": [ 7 | { 8 | "name": "other.example.com.", 9 | "key-name": "", 10 | "dns-servers": [ 11 | { 12 | "ip-address": "8.8.8.8", 13 | "port": 53 14 | } 15 | ] 16 | }, 17 | { 18 | "name": "example.com.", 19 | "key-name": "", 20 | "dns-servers": [ 21 | { 22 | "ip-address": "8.8.8.8", 23 | "port": 53 24 | } 25 | ] 26 | }, 27 | { 28 | "name": "baz.other.example.com.", 29 | "key-name": "", 30 | "dns-servers": [ 31 | { 32 | "ip-address": "8.8.8.8", 33 | "port": 53 34 | } 35 | ] 36 | } 37 | ] 38 | }, 39 | "reverse-ddns": { 40 | "ddns-domains": [ 41 | { 42 | "name": "168.192.in-addr.arpa.", 43 | "key-name": "", 44 | "dns-servers": [ 45 | { 46 | "ip-address": "8.8.8.8", 47 | "port": 53 48 | } 49 | ] 50 | }, 51 | { 52 | "name": "8.8.8.8.in-addr.arpa.", 53 | "key-name": "", 54 | "dns-servers": [ 55 | { 56 | "ip-address": "8.8.8.8", 57 | "port": 53 58 | } 59 | ] 60 | } 61 | ] 62 | }, 63 | "tsig-keys": [ 64 | { 65 | "name": "tsig-key", 66 | "algorithm": "HMAC-SHA512", 67 | "secret": "cOMu4V6xeuEMyGuOiwMtvQNEp+4XZNqSbjdwxyNmpYwfZoy/ZSUctWsYq7XKKuQFjkiIrUON5HV+9aozYCB58A==" 68 | } 69 | ] 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /util/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | 6 | # Support docker run --init parameter which obsoletes the use of dumb-init, 7 | # but support dumb-init for those that still use it without --init 8 | if [ $$ -eq 1 ]; then 9 | run="exec /usr/bin/dumb-init --" 10 | else 11 | run="exec" 12 | fi 13 | 14 | # Single argument to command line is interface name 15 | if [ $# -eq 1 -a -n "$1" ]; then 16 | # skip wait-for-interface behavior if found in path 17 | if ! which "$1" >/dev/null; then 18 | # loop until interface is found, or we give up 19 | NEXT_WAIT_TIME=1 20 | until [ -e "/sys/class/net/$1" ] || [ $NEXT_WAIT_TIME -eq 4 ]; do 21 | sleep $(( NEXT_WAIT_TIME++ )) 22 | echo "Waiting for interface '$1' to become available... ${NEXT_WAIT_TIME}" 23 | done 24 | if [ -e "/sys/class/net/$1" ]; then 25 | IFACE="$1" 26 | fi 27 | fi 28 | fi 29 | 30 | # No arguments mean all interfaces 31 | if [ -z "$1" ]; then 32 | IFACE=" " 33 | fi 34 | 35 | if [ -n "$IFACE" ]; then 36 | # Run dhcpd for specified interface or all interfaces 37 | 38 | data_dir="/var/lib/dora" 39 | if [ ! -d "$data_dir" ]; then 40 | echo "Please ensure '$data_dir' folder is available." 41 | echo 'If you just want to keep your configuration in "data/", add -v "$(pwd)/data:/var/lib/dora" to the docker run command line.' 42 | exit 1 43 | fi 44 | 45 | dhcpd_conf="$data_dir/config.yaml" 46 | if [ ! -r "$dhcpd_conf" ]; then 47 | echo "Please ensure '$dhcpd_conf' exists and is readable." 48 | echo "Run the container with arguments 'man dhcpd.conf' if you need help with creating the configuration." 49 | exit 1 50 | fi 51 | 52 | uid=$(stat -c%u "$data_dir") 53 | gid=$(stat -c%g "$data_dir") 54 | groupmod -og $gid dhcpd 55 | usermod -ou $uid dhcpd 56 | 57 | [ -e "$data_dir/em.db" ] || touch "$data_dir/em.db" 58 | chown dhcpd:dhcpd "$data_dir/em.db" 59 | if [ -e "$data_dir/em.db~" ]; then 60 | chown dhcpd:dhcpd "$data_dir/em.db~" 61 | fi 62 | 63 | container_id=$(grep docker /proc/self/cgroup | sort -n | head -n 1 | cut -d: -f3 | cut -d/ -f3) 64 | if perl -e '($id,$name)=@ARGV;$short=substr $id,0,length $name;exit 1 if $name ne $short;exit 0' $container_id $HOSTNAME; then 65 | echo "You must add the 'docker run' option '--net=host' if you want to provide DHCP service to the host network." 66 | fi 67 | 68 | $run /usr/local/bin/dora 69 | else 70 | # Run another binary 71 | $run "$@" 72 | fi 73 | -------------------------------------------------------------------------------- /dora-core/src/server/ioctl.rs: -------------------------------------------------------------------------------- 1 | //! functions generated to interact with ioctl 2 | //! 3 | #![allow(missing_docs)] 4 | 5 | use std::{io, net::Ipv4Addr, os::unix::prelude::AsRawFd}; 6 | 7 | use dhcproto::v4; 8 | use socket2::SockRef; 9 | 10 | /// calls ioctl(fd, SIOCSARP, arpreq) to set `arpreq` in ARP cache 11 | /// 12 | /// # Safety 13 | /// fd must be a valid v4 socket. 14 | /// 15 | pub fn arp_set( 16 | soc: SockRef<'_>, 17 | yiaddr: Ipv4Addr, 18 | htype: v4::HType, 19 | chaddr: &[u8], 20 | ) -> io::Result<()> { 21 | let addr_in = libc::sockaddr_in { 22 | sin_family: libc::AF_INET as _, 23 | sin_port: v4::CLIENT_PORT.to_be(), 24 | sin_addr: libc::in_addr { 25 | s_addr: u32::from_ne_bytes(yiaddr.octets()), 26 | }, 27 | ..unsafe { std::mem::zeroed() } 28 | }; 29 | // memcpy to sockaddr for arp_req. sockaddr_in and sockaddr both 16 bytes 30 | let arp_pa: libc::sockaddr = unsafe { std::mem::transmute(addr_in) }; 31 | // create arp_ha (for hardware addr) 32 | let arp_ha = libc::sockaddr { 33 | sa_family: u8::from(htype) as _, 34 | sa_data: unsafe { super::ioctl::cpy_bytes::<14>(chaddr) }, 35 | }; 36 | 37 | let arp_req = libc::arpreq { 38 | arp_pa, 39 | arp_ha, 40 | arp_flags: libc::ATF_COM, 41 | // this line may or may not be necessary? dnsmasq does it but it seems to work without 42 | // arp_dev: unsafe { super::ioctl::cpy_bytes::<16>(device.as_bytes()) }, 43 | ..unsafe { std::mem::zeroed() } 44 | }; 45 | 46 | // conversion needed for musl target 47 | #[cfg(not(target_env = "musl"))] 48 | let siocsarp = libc::SIOCSARP; 49 | #[cfg(target_env = "musl")] 50 | let siocsarp = libc::SIOCSARP.try_into().unwrap(); 51 | 52 | let res = unsafe { libc::ioctl(soc.as_raw_fd(), siocsarp, &arp_req as *const libc::arpreq) }; 53 | if res == -1 { 54 | return Err(io::Error::last_os_error()); 55 | } 56 | Ok(()) 57 | } 58 | 59 | /// # Returns 60 | /// A zeroed out array of size `N` with all the `bytes` copied in. 61 | /// 62 | /// # Safety 63 | /// will create a new slice of `&[libc::c_char]` from the bytes. 64 | /// 65 | /// # Panics 66 | /// if `bytes.len() > N` 67 | pub unsafe fn cpy_bytes(bytes: &[u8]) -> [libc::c_char; N] { 68 | unsafe { 69 | let mut sa_data = [0; N]; 70 | let len = bytes.len(); 71 | 72 | sa_data[..len].copy_from_slice(std::slice::from_raw_parts( 73 | bytes.as_ptr() as *const libc::c_char, 74 | len, 75 | )); 76 | sa_data 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /libs/env-parser/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! convenience fns for parsing env vars 2 | #![warn( 3 | missing_debug_implementations, 4 | missing_docs, 5 | missing_copy_implementations, 6 | rust_2018_idioms, 7 | unreachable_pub, 8 | non_snake_case, 9 | non_upper_case_globals 10 | )] 11 | #![allow(clippy::cognitive_complexity)] 12 | #![deny(rustdoc::broken_intra_doc_links)] 13 | #![doc(test( 14 | no_crate_inject, 15 | attr(deny(warnings, rust_2018_idioms), allow(dead_code, unused_variables)) 16 | ))] 17 | use anyhow::Context; 18 | 19 | use std::{env, str}; 20 | 21 | /// Returns the value of the environment variable with the given key. If it 22 | /// doesn't exist, returns `default` Casts the value to the type of `default` 23 | /// # Examples 24 | /// ``` 25 | /// # use std::{env, io}; 26 | /// unsafe { env::set_var("KEY", "value"); } 27 | /// let val: String = env_parser::parse_var("KEY", "default_value").unwrap(); 28 | /// assert_eq!(val, "value"); 29 | /// unsafe { env::remove_var("KEY"); } 30 | /// 31 | /// let val: String = env_parser::parse_var("KEY", "default_value").unwrap(); 32 | /// assert_eq!(val, "default_value"); 33 | /// 34 | /// # Ok::<(), io::Error>(()) 35 | /// ``` 36 | pub fn parse_var(name: &str, default: S) -> Result::Err> 37 | where 38 | T: str::FromStr, 39 | S: ToString, 40 | { 41 | env::var(name) 42 | .unwrap_or_else(|_| default.to_string()) 43 | .parse::() 44 | } 45 | 46 | /// Returns the value of the environment variable with the given key, or None if 47 | /// it doesn't exist. 48 | pub fn parse_var_opt(name: &str) -> Option 49 | where 50 | T: str::FromStr, 51 | { 52 | env::var(name).ok()?.parse::().ok() 53 | } 54 | 55 | /// Calls [`parse_var`] but gives a default error message with the environment 56 | /// variable name in it 57 | /// 58 | /// [`parse_var`]: crate::parse_var 59 | pub fn parse_var_with_err(name: &str, default: S) -> anyhow::Result 60 | where 61 | T: str::FromStr, 62 | ::Err: std::error::Error + Send + Sync + 'static, 63 | S: ToString + Send, 64 | { 65 | parse_var::(name, default).with_context(|| format!("error parsing env var {name}")) 66 | } 67 | 68 | /// Returns whether an environment variable with the given key exists 69 | /// # Examples 70 | /// ``` 71 | /// # use std::env; 72 | /// unsafe { env::set_var("KEY", "value"); } 73 | /// assert!(env_parser::var_exists("KEY")); 74 | /// unsafe { env::remove_var("KEY"); } 75 | /// 76 | /// assert!(!env_parser::var_exists("KEY")); 77 | /// ``` 78 | pub fn var_exists(name: &str) -> bool { 79 | env::var(name).is_ok() 80 | } 81 | -------------------------------------------------------------------------------- /libs/client-classification/src/grammar.pest: -------------------------------------------------------------------------------- 1 | // integer = @{ ASCII_DIGIT+ } 2 | integer = @{ (ASCII_NONZERO_DIGIT ~ ASCII_DIGIT+ | ASCII_DIGIT) } 3 | 4 | signed_int = @{ minus? ~ integer } 5 | minus = { "-" } 6 | 7 | string = @{ "'" ~ ( "''" | (!"'" ~ ANY) )* ~ "'" } 8 | 9 | ip = @{ ASCII_DIGIT{1,3} ~ "." ~ ASCII_DIGIT{1,3} ~ "." ~ ASCII_DIGIT{1,3} ~ "." ~ ASCII_DIGIT{1,3} } 10 | 11 | hex = @{ "0x" ~ ASCII_HEX_DIGIT* } 12 | 13 | boolean = @{ "true" | "false" } 14 | 15 | all = @{ "all" } 16 | 17 | operation = _{ equal | neq | and | or } 18 | equal = { "==" } 19 | neq = { "!=" } 20 | or = { "or" } 21 | and = { "and" } 22 | 23 | 24 | option = { "option[" ~ integer ~ "]" } 25 | relay = { "relay4[" ~ integer ~ "]" } 26 | member = { "member(" ~ string ~ ")" } 27 | 28 | pkt = _{ 29 | pkt_mac 30 | | pkt_hlen 31 | | pkt_htype 32 | | pkt_ciaddr 33 | | pkt_giaddr 34 | | pkt_yiaddr 35 | | pkt_siaddr 36 | | pkt_msgtype 37 | | pkt_transid 38 | } 39 | pkt_mac = @{ "pkt4.mac" } 40 | pkt_hlen = @{ "pkt4.hlen" } 41 | pkt_htype = @{ "pkt4.htype" } 42 | pkt_ciaddr = @{ "pkt4.ciaddr" } 43 | pkt_giaddr = @{ "pkt4.giaddr" } 44 | pkt_yiaddr = @{ "pkt4.yiaddr" } 45 | pkt_siaddr = @{ "pkt4.siaddr" } 46 | pkt_msgtype = @{ "pkt4.msgtype" } 47 | pkt_transid = @{ "pkt4.transid" } 48 | 49 | pkt_base = _{ 50 | pkt_base_iface 51 | | pkt_base_src 52 | | pkt_base_dst 53 | | pkt_base_len 54 | } 55 | pkt_base_iface = @{ "pkt.iface" } 56 | pkt_base_src = @{ "pkt.src" } 57 | pkt_base_dst = @{ "pkt.dst" } 58 | pkt_base_len = @{ "pkt.len" } 59 | 60 | end = _{ 61 | signed_int | all 62 | } 63 | 64 | 65 | substring = { "substring(" ~ expr ~ "," ~ signed_int ~ "," ~ end ~ ")" } 66 | split = { "split(" ~ expr ~ "," ~ expr ~ "," ~ integer ~ ")" } 67 | concat = { "concat(" ~ expr ~ "," ~ expr ~ ")" } 68 | hexstring = { "hexstring(" ~ expr ~ "," ~ string ~ ")" } 69 | ifelse = { "ifelse(" ~ expr ~ "," ~ expr ~ "," ~ expr ~ ")" } 70 | 71 | expr = { prefix* ~ primary ~ postfix* ~ (operation ~ prefix* ~ primary ~ postfix* )* } 72 | 73 | prefix = _{ not } 74 | not = { "not" } 75 | 76 | postfix = _{ to_hex | to_text | exists | sub_opt } 77 | to_hex = { ".hex" } 78 | to_text = { ".text" } 79 | exists = { ".exists" } 80 | sub_opt = { "." ~ option } 81 | 82 | primary = _{ hex 83 | | ip 84 | | integer 85 | | string 86 | | boolean 87 | | option 88 | | relay 89 | | pkt 90 | | pkt_base 91 | | substring 92 | | concat 93 | | split 94 | | ifelse 95 | | hexstring 96 | | member 97 | | "(" ~ expr ~ ")" 98 | } 99 | 100 | predicate = _{ SOI ~ expr ~ EOI } 101 | 102 | WHITESPACE = _{ " " | "\t" | "\r" | "\n" } 103 | -------------------------------------------------------------------------------- /libs/client-classification/benches/my_benchmark.rs: -------------------------------------------------------------------------------- 1 | use std::collections::{HashMap, HashSet}; 2 | 3 | use client_classification::{Args, PacketDetails, ast}; 4 | use criterion::{Criterion, criterion_group, criterion_main}; 5 | use dhcproto::v4::{self, UnknownOption}; 6 | use pest::Parser; 7 | 8 | // use client_classification::{one, two}; 9 | 10 | fn criterion_benchmark(c: &mut Criterion) { 11 | c.bench_function( 12 | "substring('foobar, 0, 6) == 'foo' && option[61].hex == 'some_client_id'", 13 | |b| { 14 | b.iter(|| { 15 | let mut opts = HashMap::new(); 16 | opts.insert( 17 | 61.into(), 18 | UnknownOption::new(61.into(), b"some_client_id".to_vec()), 19 | ); 20 | 21 | let chaddr = &hex::decode("DEADBEEF").unwrap(); 22 | let tokens = ast::PredicateParser::parse( 23 | ast::Rule::expr, 24 | "substring('foobar', 0, 3) == 'foo' and option[61].hex == 'some_client_id'", 25 | ) 26 | .unwrap(); 27 | 28 | let args = Args { 29 | chaddr, 30 | opts, 31 | msg: &v4::Message::default(), 32 | member: HashSet::new(), 33 | pkt: PacketDetails::default(), 34 | }; 35 | client_classification::eval( 36 | &client_classification::ast::build_ast(tokens).unwrap(), 37 | &args, 38 | ) 39 | .unwrap() 40 | }) 41 | }, 42 | ); 43 | c.bench_function( 44 | "just eval: substring('foobar', 0, 6) == 'foo' && option[61].hex == 'some_client_id'", 45 | |b| { 46 | let tokens = ast::PredicateParser::parse( 47 | ast::Rule::expr, 48 | "substring('foobar', 0, 6) == 'foo' and option[61].hex == 'some_client_id'", 49 | ) 50 | .unwrap(); 51 | 52 | let ast = client_classification::ast::build_ast(tokens).unwrap(); 53 | 54 | b.iter(move || { 55 | let chaddr = &hex::decode("DEADBEEF").unwrap(); 56 | let mut opts = HashMap::new(); 57 | opts.insert( 58 | 61.into(), 59 | UnknownOption::new(61.into(), b"some_client_id".to_vec()), 60 | ); 61 | let args = Args { 62 | chaddr, 63 | opts, 64 | msg: &v4::Message::default(), 65 | member: HashSet::new(), 66 | pkt: PacketDetails::default(), 67 | }; 68 | client_classification::eval(&ast, &args).unwrap() 69 | }) 70 | }, 71 | ); 72 | } 73 | 74 | criterion_group!(benches, criterion_benchmark); 75 | criterion_main!(benches); 76 | -------------------------------------------------------------------------------- /libs/config/src/wire/mod.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, num::NonZeroU32, time::Duration}; 2 | 3 | use ipnet::Ipv4Net; 4 | use serde::{Deserialize, Serialize}; 5 | 6 | use crate::{LeaseTime, wire::client_classes::ClientClasses}; 7 | 8 | pub mod client_classes; 9 | pub mod v4; 10 | pub mod v6; 11 | 12 | /// top-level config type 13 | #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] 14 | pub struct Config { 15 | pub interfaces: Option>, 16 | #[serde(default = "default_chaddr_only")] 17 | pub chaddr_only: bool, 18 | pub flood_protection_threshold: Option, 19 | #[serde(default = "default_cache_threshold")] 20 | pub cache_threshold: u32, 21 | #[serde(default = "default_bootp_enable")] 22 | pub bootp_enable: bool, 23 | #[serde(default = "default_rapid_commit")] 24 | pub rapid_commit: bool, 25 | #[serde(default)] 26 | pub networks: HashMap, 27 | pub v6: Option, 28 | pub client_classes: Option, 29 | pub ddns: Option, 30 | } 31 | 32 | #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] 33 | pub struct FloodThreshold { 34 | pub packets: NonZeroU32, 35 | pub secs: NonZeroU32, 36 | } 37 | 38 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] 39 | pub struct MinMax { 40 | pub default: NonZeroU32, 41 | pub min: Option, 42 | pub max: Option, 43 | } 44 | 45 | pub const fn default_ping_to() -> u64 { 46 | 500 47 | } 48 | 49 | pub const fn default_authoritative() -> bool { 50 | true 51 | } 52 | 53 | pub const fn default_probation() -> u64 { 54 | 86_400 55 | } 56 | 57 | pub const fn default_chaddr_only() -> bool { 58 | false 59 | } 60 | 61 | pub const fn default_bootp_enable() -> bool { 62 | true 63 | } 64 | 65 | pub const fn default_rapid_commit() -> bool { 66 | false 67 | } 68 | 69 | pub fn default_cache_threshold() -> u32 { 70 | 0 71 | } 72 | 73 | impl From for LeaseTime { 74 | fn from(lease_time: MinMax) -> Self { 75 | let default = Duration::from_secs(lease_time.default.get() as u64); 76 | let min = lease_time 77 | .min 78 | .map(|n| Duration::from_secs(n.get() as u64)) 79 | .unwrap_or(default); 80 | let max = lease_time 81 | .max 82 | .map(|n| Duration::from_secs(n.get() as u64)) 83 | .unwrap_or(default); 84 | Self { default, min, max } 85 | } 86 | } 87 | 88 | #[derive(Serialize, Deserialize, Debug)] 89 | #[serde(untagged)] 90 | pub(crate) enum MaybeList { 91 | Val(T), 92 | List(Vec), 93 | } 94 | 95 | #[cfg(test)] 96 | mod tests { 97 | 98 | pub static EXAMPLE: &str = include_str!("../../../../example.yaml"); 99 | 100 | // test we can encode/decode example file 101 | #[test] 102 | fn test_example() { 103 | let cfg: crate::wire::Config = serde_yaml::from_str(EXAMPLE).unwrap(); 104 | println!("{cfg:#?}"); 105 | // back to the yaml 106 | let s = serde_yaml::to_string(&cfg).unwrap(); 107 | println!("{s}"); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /docs/ddns.md: -------------------------------------------------------------------------------- 1 | # DDNS 2 | 3 | We have added some early support for DDNS. Please report any issues you find. 4 | 5 | [4701](https://www.rfc-editor.org/rfc/rfc4701) <- DHCID rdata 6 | [4702](https://www.rfc-editor.org/rfc/rfc4702) <- Client FQDN option section 4 specifies "server behavior" based on fqdn flags 7 | [4703](https://www.rfc-editor.org/rfc/rfc4703) <- specifies the DHCID RR record that must be included in the DNS update 8 | 9 | It's worth looking at Kea's DDNS docs [here](https://kea.readthedocs.io/en/kea-2.0.0/arm/ddns.html#overview) 10 | 11 | I propose adding the following section to the dora `config.yaml` file. Upon receiving a client FQDN or hostname, we will search the `forward` and `reverse` lists for the longest matching server. See [server selection](https://kea.readthedocs.io/en/kea-2.0.0/arm/ddns.html#dns-server-selection). 12 | 13 | ``` 14 | ddns: 15 | # send updates. If the ddns section header is defined, enable_updates defaults to true 16 | enable_updates: true 17 | # default false. whether to override the client update FQDN flags 18 | override_client_updates: false 19 | # default false. whether to override the no update FQDN flags 20 | override_no_updates: false 21 | # list of forward DNS servers 22 | forward: 23 | - name: "example.com" 24 | key: "key_foo" # optional, must match key name in tsig_keys 25 | ip: 192.168.3.111 26 | # reverse servers list 27 | reverse: 28 | - name: "168.192.in-addr.arpa." 29 | key: "key_foo" # optional 30 | ip: 192.168.3.111 31 | # map of tsig keys. DNS servers reference these by name 32 | tsig_keys: 33 | key_foo: 34 | algorithm: "hmac-sha1" 35 | data: "" 36 | ``` 37 | 38 | If a hostname option is received, it is concatenated with a configured option 15 (domain name) to produce a fqdn, this fqdn is used for the DNS update. Not included in this draft is any other manipulation of the hostname option. 39 | 40 | There are a few config values that can change the behavior of the update. These options are similar to what is available in kea: 41 | 42 | `enable_updates`: should we process the client FQDN option? true/false 43 | `override_client_updates`: the client FQDN flag can have a flag telling the server that it wants to do the DNS update, setting this to true will _override_ that behavior and send back the relevant 'o' flag set to true. (see here: https://www.rfc-editor.org/rfc/rfc4702.html#section-4) 44 | `override_no_updates`: client FQDN flags can have a 'no update' flag set, if `override_no_updates` is true, then we will do the update anyway and set the override flag on response. 45 | 46 | The logic for client FQDN flag handling is largely in the `handle_flags` function, and was translated from [Keas flag handling](https://github.com/isc-projects/kea/blob/9c76b9a9e55b49ea407531b64783f6ec12546f42/src/lib/dhcpsrv/d2_client_mgr.cc#L115) 47 | 48 | As for the content of the DNS updates themselves, here is an example of a forward update created by hickory-dns-client 49 | 50 | ![fwd_update](https://user-images.githubusercontent.com/1128302/210460131-97bcf7f1-09aa-4c82-807f-7d5eb19542d3.png) 51 | 52 | The DHCID RR is created in accordance with [4701](https://www.rfc-editor.org/rfc/rfc4701#section-3.5). 53 | 54 | Here's a reverse update: 55 | 56 | ![rev_ip](https://user-images.githubusercontent.com/1128302/210626264-c7a1ddbb-1ecd-43a7-ac54-0278a37de3cd.png) 57 | -------------------------------------------------------------------------------- /dora-cfg/src/main.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use anyhow::{Context, Result}; 4 | use clap::{Parser, ValueEnum}; 5 | 6 | use config::wire; 7 | use serde::de::DeserializeOwned; 8 | 9 | #[derive(Parser, Debug, Clone, PartialEq, Eq)] 10 | #[clap(author, version, about, long_about = None)] 11 | /// Cli tool for parsing config & JSON schema 12 | pub struct Args { 13 | /// path to dora config. We will determine format from extension. If no extension, we will attempt JSON & YAML 14 | #[clap(short = 'p', long, value_parser)] 15 | pub path: PathBuf, 16 | /// print the parsed wire format or the dora internal config format 17 | #[clap(short = 'f', long, value_parser)] 18 | pub format: Option, 19 | /// path to JSON schema. Config must be in JSON format and use `.json` extension 20 | #[clap(short = 's', long, value_parser)] 21 | pub schema: Option, 22 | } 23 | 24 | #[derive(Parser, Debug, Clone, PartialEq, Eq, ValueEnum)] 25 | pub enum Format { 26 | Wire, 27 | Internal, 28 | } 29 | 30 | fn main() -> Result<()> { 31 | let args = Args::parse(); 32 | println!("found config at path = {}", args.path.display()); 33 | 34 | parse_schema(&args)?; 35 | if let Some(format) = &args.format { 36 | match format { 37 | Format::Wire => { 38 | let wire_cfg = parse_wire::(&args)?; 39 | println!("printing wire format"); 40 | println!("{wire_cfg:#?}"); 41 | } 42 | Format::Internal => { 43 | let cfg = config::v4::Config::try_from(parse_wire::(&args)?)?; 44 | println!("parsed wire format into dora internal format, pretty printing"); 45 | println!("{cfg:#?}"); 46 | } 47 | } 48 | } 49 | 50 | Ok(()) 51 | } 52 | 53 | fn parse_schema(args: &Args) -> Result<()> { 54 | if let Some(schema) = &args.schema { 55 | let parsed = serde_json::from_str::( 56 | &std::fs::read_to_string(schema).context("failed to find schema")?, 57 | )?; 58 | let input = parse_wire::(args)?; 59 | let validator = jsonschema::JSONSchema::options() 60 | .with_draft(jsonschema::Draft::Draft7) 61 | .compile(&parsed) 62 | .expect("failed to compile schema"); // can't use ? static lifetime on error 63 | // TODO: jsonschema crate has garbage error types! 64 | return match validator.validate(&input) { 65 | Err(errs) => { 66 | errs.for_each(|err| eprintln!("{err}")); 67 | Err(anyhow::anyhow!("failed to validate schema")) 68 | } 69 | _ => { 70 | println!("json schema validated"); 71 | Ok(()) 72 | } 73 | }; 74 | } 75 | Ok(()) 76 | } 77 | 78 | fn parse_wire(args: &Args) -> Result { 79 | let input = std::fs::read_to_string(&args.path).context("failed to find config")?; 80 | 81 | Ok(match args.path.extension() { 82 | Some(ext) if ext == "json" => serde_json::from_str(&input)?, 83 | Some(ext) if ext == "yaml" => serde_yaml::from_str(&input)?, 84 | _ => match serde_json::from_str(&input) { 85 | Ok(r) => r, 86 | Err(_err) => { 87 | println!("failed parsing from json, trying yaml"); 88 | serde_yaml::from_str(&input)? 89 | } 90 | }, 91 | }) 92 | } 93 | -------------------------------------------------------------------------------- /docs/gns3.md: -------------------------------------------------------------------------------- 1 | # Running dora in GNS3 2 | 3 | GNS3 is used to emulate virtual networks on a single machine. It has a nice GUI interface where you can drag & drop virtual machines and set them up in different network topologies. 4 | 5 | You can get instructions to download it here. 6 | 7 | It also supports docker containers, so you can run dora connected to the virtual nodes and test DHCP. 8 | 9 | ![dora network in GNS3](pic1.png) 10 | 11 | ## Setup 12 | 13 | - install gns3 14 | 15 | - clone the dora github repo https://github.com/bluecatengineering/dora 16 | 17 | - follow the instructions in `docs/docker.md` to create a dora docker image 18 | 19 | - In gns3 click `Preferences > Docker > Docker Containers > New` 20 | 21 | - Select the dora image from the list, for “start command” you can put `sh` to start a shell if you plan to start dora manually, otherwise you can put `DORA_LOG="debug" dora` 22 | 23 | - Under the “Advanced” tab, there is a box to add VOLUMES to the image. Add `/var/lib/dora/` as a volume like this: 24 | 25 | ![editing volume info](pic2.png) 26 | 27 | (this will create a `/var/lib/dora/` dir in the GNS3 project `~/GNS3/projects/${PROJECT_NAME}/project-files/docker//var/lib/dora/` 28 | 29 | - Add the dora docker image to gns3 30 | 31 | - In the main view, drag the newly created dora docker image into your network template 32 | 33 | - Right click on it and select “show in file manager” then navigate to the `/var/lib/dora/` dir (you may have to start it once for this to be created, or create it manually) 34 | 35 | - Add a config.yaml into this dir. This config will be volume-mounted into dora. The leases.db will also be persisted here between runs. 36 | 37 | - Right click on the dora image again and select “Edit config”. Here you can to give a static IP to eth0 so that dora will listen and respond on a specific interface and subnet. The subnet must match the subnet you are serving in the config.yaml 38 | 39 | ## Example configs for dora 40 | 41 | Under "Edit Config" for dora node: 42 | 43 | ``` 44 | auto eth0 45 | iface eth0 inet static 46 | address 192.168.5.1 47 | netmask 255.255.255.0 48 | gateway 192.168.5.1 49 | up echo nameserver 192.168.5.1 > /etc/resolv.conf 50 | ``` 51 | 52 | with config.yaml: 53 | 54 | ``` 55 | networks: 56 | 192.168.5.0/24: 57 | authoritative: true 58 | probation_period: 86400 59 | ranges: 60 | - 61 | start: 192.168.5.2 62 | end: 192.168.5.250 63 | config: 64 | lease_time: 65 | default: 3600 66 | min: 1200 67 | max: 4800 68 | options: 69 | values: 70 | subnet_mask: 71 | type: ip 72 | value: 255.255.255.0 73 | routers: 74 | type: ip 75 | value: 76 | - 192.168.5.1 77 | domain_name_servers: 78 | type: ip 79 | value: 80 | - 8.8.8.8 81 | broadcast_addr: 82 | type: ip 83 | value: 192.168.5.255 84 | 85 | ``` 86 | 87 | Now create your network configuration, and start all the nodes. You can then open a console in the dora docker container and run dora if you don’t have it set to run manually, then in the console of the VPCS nodes run `ip dhcp` to get an IP address. You can edit the VPCS nodes to automatically use DHCP on boot also. 88 | -------------------------------------------------------------------------------- /ddns-test/src/main.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use serde::{Deserialize, Serialize}; 3 | use std::net::{Ipv4Addr, UdpSocket}; 4 | 5 | /// @code 6 | /// { 7 | /// "change-type" : , 8 | /// "forward-change" : , 9 | /// "reverse-change" : , 10 | /// "fqdn" : "", 11 | /// "ip-address" : "
", 12 | /// "dhcid" : "", 13 | /// "lease-expires-on" : "", 14 | /// "lease-length" : , 15 | /// "use-conflict-resolution": 16 | /// } 17 | /// @endcode 18 | /// - change-type - indicates whether this request is to add or update 19 | /// DNS entries or to remove them. The value is an integer and is 20 | /// 0 for add/update and 1 for remove. 21 | /// - forward-change - indicates whether the forward (name to 22 | /// address) DNS zone should be updated. The value is a string 23 | /// representing a boolean. It is "true" if the zone should be updated 24 | /// and "false" if not. (Unlike the keyword, the boolean value is 25 | /// case-insensitive.) 26 | /// - reverse-change - indicates whether the reverse (address to 27 | /// name) DNS zone should be updated. The value is a string 28 | /// representing a boolean. It is "true" if the zone should be updated 29 | /// and "false" if not. (Unlike the keyword, the boolean value is 30 | /// case-insensitive.) 31 | /// - fqdn - fully qualified domain name such as "myhost.example.com.". 32 | /// (Note that a trailing dot will be appended if not supplied.) 33 | /// - ip-address - the IPv4 or IPv6 address of the client. The value 34 | /// is a string representing the IP address (e.g. "192.168.0.1" or 35 | /// "2001:db8:1::2"). 36 | /// - dhcid - identification of the DHCP client to whom the IP address has 37 | /// been leased. The value is a string containing an even number of 38 | /// hexadecimal digits without delimiters such as "2C010203040A7F8E3D" 39 | /// (case insensitive). 40 | /// - lease-expires-on - the date and time on which the lease expires. 41 | /// The value is a string of the form "yyyymmddHHMMSS" where: 42 | /// - yyyy - four digit year 43 | /// - mm - month of year (1-12), 44 | /// - dd - day of the month (1-31), 45 | /// - HH - hour of the day (0-23) 46 | /// - MM - minutes of the hour (0-59) 47 | /// - SS - seconds of the minute (0-59) 48 | /// - lease-length - the length of the lease in seconds. This is an 49 | /// integer and may range between 1 and 4294967295 (2^32 - 1) inclusive. 50 | /// - use-conflict-resolution - when true, follow RFC 4703 which uses 51 | /// DHCID records to prohibit multiple clients from updating an FQDN 52 | /// 53 | fn main() -> Result<()> { 54 | let soc = UdpSocket::bind("0.0.0.0:0")?; 55 | let update = NcrUpdate { 56 | change_type: 0, 57 | forward_change: true, 58 | reverse_change: false, 59 | fqdn: "example.com.".to_owned(), 60 | ip_address: Ipv4Addr::from([192, 168, 2, 1]), 61 | dhcid: "0102030405060708".to_owned(), 62 | lease_expires_on: "20130121132405".to_owned(), 63 | lease_length: 1300, 64 | use_conflict_resolution: true, 65 | }; 66 | let s = serde_json::to_string(&update)?; 67 | let len = s.len() as u16; 68 | println!("sending {s} {len}"); 69 | // expects two-byte len prepended 70 | let mut buf = vec![]; 71 | buf.extend(len.to_be_bytes()); 72 | buf.extend(s.as_bytes()); 73 | println!("{buf:#?}"); 74 | let r = soc.send_to(&buf, "127.0.0.1:53001")?; 75 | println!("sent size {r}"); 76 | let mut buf = vec![0; 1024]; 77 | let (len, from) = soc.recv_from(&mut buf)?; 78 | // response has buf len prepended also 79 | let buf_len = u16::from_be_bytes([buf[0], buf[1]]) as usize; 80 | println!("recvd len {len} from {from} buf_len {buf_len}"); 81 | let decoded: serde_json::Value = serde_json::from_slice(&buf[2..buf_len])?; 82 | println!("response {decoded}"); 83 | Ok(()) 84 | } 85 | 86 | #[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] 87 | #[serde(rename_all = "kebab-case")] 88 | pub struct NcrUpdate { 89 | change_type: u32, 90 | forward_change: bool, 91 | reverse_change: bool, 92 | fqdn: String, 93 | ip_address: Ipv4Addr, 94 | dhcid: String, 95 | lease_expires_on: String, 96 | lease_length: u32, 97 | use_conflict_resolution: bool, 98 | } 99 | -------------------------------------------------------------------------------- /.github/workflows/actions.yml: -------------------------------------------------------------------------------- 1 | # Based on https://github.com/actions-rs/meta/blob/master/recipes/msrv.md 2 | 3 | on: [push, pull_request] 4 | 5 | name: Actions 6 | 7 | jobs: 8 | check: 9 | name: Check 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | rust: 14 | - stable 15 | - beta 16 | steps: 17 | - name: Checkout sources 18 | uses: actions/checkout@v2 19 | 20 | - name: Install toolchain 21 | uses: actions-rs/toolchain@v1 22 | with: 23 | toolchain: ${{ matrix.rust }} 24 | override: true 25 | 26 | - name: Run cargo check 27 | uses: actions-rs/cargo@v1 28 | with: 29 | command: check 30 | args: --all-features 31 | env: 32 | SQLX_OFFLINE: true 33 | 34 | test: 35 | name: Test Suite 36 | runs-on: ubuntu-latest 37 | strategy: 38 | matrix: 39 | rust: 40 | - stable 41 | steps: 42 | - name: Checkout sources 43 | uses: actions/checkout@v2 44 | 45 | - name: Install toolchain 46 | uses: actions-rs/toolchain@v1 47 | with: 48 | toolchain: ${{ matrix.rust }} 49 | override: true 50 | 51 | - name: Run cargo test 52 | uses: actions-rs/cargo@v1 53 | with: 54 | command: test 55 | args: --all-features --exclude register_derive_impl --workspace 56 | env: 57 | SQLX_OFFLINE: true 58 | 59 | fmt: 60 | name: Rustfmt 61 | runs-on: ubuntu-latest 62 | strategy: 63 | matrix: 64 | rust: 65 | - stable 66 | steps: 67 | - name: Checkout sources 68 | uses: actions/checkout@v2 69 | 70 | - name: Install toolchain 71 | uses: actions-rs/toolchain@v1 72 | with: 73 | toolchain: ${{ matrix.rust }} 74 | override: true 75 | 76 | - name: Install rustfmt 77 | run: rustup component add rustfmt 78 | 79 | - name: Run cargo fmt 80 | uses: actions-rs/cargo@v1 81 | with: 82 | command: fmt 83 | args: --all -- --check 84 | 85 | clippy: 86 | name: Clippy 87 | runs-on: ubuntu-latest 88 | strategy: 89 | matrix: 90 | rust: 91 | - stable 92 | steps: 93 | - name: Checkout sources 94 | uses: actions/checkout@v2 95 | 96 | - name: Install toolchain 97 | uses: actions-rs/toolchain@v1 98 | with: 99 | toolchain: ${{ matrix.rust }} 100 | override: true 101 | 102 | - name: Install clippy 103 | run: rustup component add clippy 104 | 105 | - name: Run cargo clippy 106 | uses: actions-rs/cargo@v1 107 | with: 108 | command: clippy 109 | args: -- -D warnings 110 | env: 111 | SQLX_OFFLINE: true 112 | 113 | coverage: 114 | name: Run coverage 115 | runs-on: ubuntu-latest 116 | strategy: 117 | matrix: 118 | rust: 119 | - stable 120 | env: 121 | CARGO_INCREMENTAL: "0" 122 | RUSTFLAGS: "-Ccodegen-units=1 -Copt-level=0 -Clink-dead-code -Coverflow-checks=off" 123 | SQLX_OFFLINE: true 124 | steps: 125 | - name: Checkout sources 126 | uses: actions/checkout@v2 127 | 128 | - name: Install toolchain 129 | uses: actions-rs/toolchain@v1 130 | with: 131 | toolchain: ${{ matrix.rust }} 132 | override: true 133 | components: llvm-tools-preview 134 | - name: Install cargo-llvm-cov 135 | uses: taiki-e/install-action@cargo-llvm-cov 136 | - name: Generate code coverage 137 | id: coverage 138 | run: cargo llvm-cov --all-features --exclude register_derive_impl --workspace --no-fail-fast --lcov --output-path lcov.info 139 | env: 140 | NODE_COVERALLS_DEBUG: true 141 | # - name: Upload coverage to Codecov 142 | # uses: codecov/codecov-action@v3 143 | # with: 144 | # # token: ${{ secrets.CODECOV_TOKEN }} # not required for public repos 145 | # files: lcov.info 146 | # fail_ci_if_error: true 147 | - name: Coveralls upload 148 | uses: coverallsapp/github-action@master 149 | with: 150 | github-token: ${{ secrets.GITHUB_TOKEN }} 151 | path-to-lcov: lcov.info 152 | debug: true 153 | -------------------------------------------------------------------------------- /bin/tests/common/env.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | env, fs, 3 | process::{Child, Command}, 4 | thread, 5 | }; 6 | 7 | #[derive(Debug)] 8 | pub(crate) struct DhcpServerEnv { 9 | daemon: Child, 10 | db: String, 11 | netns: String, 12 | veth_cli: String, 13 | // veth_srv: String, 14 | // srv_ip: String, 15 | } 16 | 17 | impl DhcpServerEnv { 18 | pub(crate) fn start( 19 | config: &str, 20 | db: &str, 21 | netns: &str, 22 | veth_cli: &str, 23 | veth_srv: &str, 24 | srv_ip: &str, 25 | ) -> Self { 26 | create_test_net_namespace(netns); 27 | create_test_veth_nics(netns, srv_ip, veth_cli, veth_srv); 28 | Self { 29 | daemon: start_dhcp_server(config, netns, db), 30 | db: db.to_owned(), 31 | netns: netns.to_owned(), 32 | veth_cli: veth_cli.to_owned(), 33 | // veth_srv: veth_srv.to_owned(), 34 | // srv_ip: srv_ip.to_owned(), 35 | } 36 | } 37 | } 38 | 39 | impl Drop for DhcpServerEnv { 40 | fn drop(&mut self) { 41 | let db = &self.db; 42 | stop_dhcp_server(&mut self.daemon); 43 | remove_test_veth_nics(&self.veth_cli); 44 | remove_test_net_namespace(&self.netns); 45 | if let Err(err) = fs::remove_file(db) { 46 | eprintln!("{err:?}"); 47 | } 48 | if let Err(err) = fs::remove_file(format!("{db}-shm")) { 49 | eprintln!("{err:?}"); 50 | } 51 | if let Err(err) = fs::remove_file(format!("{db}-wal")) { 52 | eprintln!("{err:?}"); 53 | } 54 | } 55 | } 56 | 57 | fn create_test_net_namespace(netns: &str) { 58 | run_cmd(&format!("ip netns add {netns}")); 59 | } 60 | 61 | fn remove_test_net_namespace(netns: &str) { 62 | run_cmd_ignore_failure(&format!("ip netns del {netns}")); 63 | } 64 | 65 | fn create_test_veth_nics(netns: &str, srv_ip: &str, veth_cli: &str, veth_srv: &str) { 66 | run_cmd(&format!( 67 | "ip link add {veth_cli} type veth peer name {veth_srv}", 68 | )); 69 | run_cmd(&format!("ip link set {veth_cli} up")); 70 | run_cmd(&format!("ip link set {veth_srv} netns {netns}",)); 71 | run_cmd(&format!("ip netns exec {netns} ip link set {veth_srv} up",)); 72 | run_cmd(&format!( 73 | "ip netns exec {netns} ip addr add {srv_ip}/24 dev {veth_srv}", 74 | )); 75 | // TODO: remove this eventually 76 | run_cmd(&format!("ip addr add 192.168.2.99/24 dev {veth_cli}")); 77 | } 78 | 79 | fn remove_test_veth_nics(veth_cli: &str) { 80 | run_cmd_ignore_failure(&format!("ip link del {veth_cli}")); 81 | } 82 | 83 | fn start_dhcp_server(config: &str, netns: &str, db: &str) -> Child { 84 | let workspace_root = env::var("WORKSPACE_ROOT").unwrap_or_else(|_| "..".to_owned()); 85 | let bin_path = env!("CARGO_BIN_EXE_dora"); 86 | let config_path = format!("{workspace_root}/bin/tests/test_configs/{config}"); 87 | let dora_debug = format!( 88 | "{bin_path} -d={db} --config-path={config_path} --threads=2 --dora-log=debug --v4-addr=0.0.0.0:9900", 89 | ); 90 | let cmd = format!("ip netns exec {netns} {dora_debug}"); 91 | 92 | let cmds: Vec<&str> = cmd.split(' ').collect(); 93 | let mut child = Command::new(cmds[0]) 94 | .args(&cmds[1..]) 95 | .spawn() 96 | .expect("Failed to start DHCP server"); 97 | thread::sleep(std::time::Duration::from_secs(1)); 98 | if let Ok(Some(ret)) = child.try_wait() { 99 | panic!("Failed to start DHCP server {ret:?}"); 100 | } 101 | child 102 | } 103 | 104 | fn stop_dhcp_server(daemon: &mut Child) { 105 | daemon.kill().expect("Failed to stop DHCP server") 106 | } 107 | 108 | fn run_cmd(cmd: &str) -> String { 109 | let cmds: Vec<&str> = cmd.split(' ').collect(); 110 | let output = Command::new(cmds[0]) 111 | .args(&cmds[1..]) 112 | .output() 113 | .unwrap_or_else(|_| panic!("failed to execute command {cmd}")); 114 | if !output.status.success() { 115 | panic!("{}", String::from_utf8_lossy(&output.stderr)); 116 | } 117 | 118 | String::from_utf8(output.stdout).expect("Failed to convert file command output to String") 119 | } 120 | 121 | fn run_cmd_ignore_failure(cmd: &str) -> String { 122 | let cmds: Vec<&str> = cmd.split(' ').collect(); 123 | match Command::new(cmds[0]).args(&cmds[1..]).output() { 124 | Ok(o) => String::from_utf8(o.stdout).unwrap_or_default(), 125 | Err(e) => { 126 | eprintln!("Failed to execute command {cmd}: {e}"); 127 | "".to_string() 128 | } 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /libs/config/sample/config_v4_simple.json: -------------------------------------------------------------------------------- 1 | { 2 | "chaddr_only": false, 3 | "networks": { 4 | "192.168.5.0/24": { 5 | "reservations": [ 6 | { 7 | "ip": "192.168.5.100", 8 | "config": { 9 | "lease_time": { "default": 3600 } 10 | }, 11 | "match": { 12 | "options": { 13 | "values": { 14 | "60": { 15 | "type": "str", 16 | "value": "foobar" 17 | } 18 | } 19 | } 20 | }, 21 | "options": { 22 | "values": { 23 | "subnet_mask": { 24 | "type": "ip", 25 | "value": "255.255.255.0" 26 | }, 27 | "routers": { 28 | "type": "ip", 29 | "value": ["192.168.5.1"] 30 | }, 31 | "domain_name_servers": { 32 | "type": "ip", 33 | "value": ["192.168.5.1"] 34 | } 35 | } 36 | } 37 | } 38 | ], 39 | "probation_period": 86400, 40 | "server_id": "192.168.5.1", 41 | "ranges": [ 42 | { 43 | "start": "192.168.5.20", 44 | "end": "192.168.5.25", 45 | "config": { 46 | "lease_time": { 47 | "default": 3600 48 | } 49 | }, 50 | "options": { 51 | "values": { 52 | "subnet_mask": { 53 | "type": "ip", 54 | "value": "255.255.255.0" 55 | }, 56 | "routers": { 57 | "type": "ip", 58 | "value": ["192.168.5.1"] 59 | }, 60 | "domain_name_servers": { 61 | "type": "ip", 62 | "value": ["192.168.5.1"] 63 | }, 64 | "vendor_extensions": { 65 | "type": "sub_option", 66 | "value": { 67 | "1": { 68 | "type": "str", 69 | "value": "foobar" 70 | }, 71 | "2": { 72 | "type": "ip", 73 | "value": "1.2.3.4" 74 | } 75 | } 76 | } 77 | } 78 | } 79 | } 80 | ] 81 | }, 82 | "192.168.6.0/24": { 83 | "probation_period": 86400, 84 | "server_id": "192.168.6.1", 85 | "ranges": [ 86 | { 87 | "start": "192.168.6.20", 88 | "end": "192.168.6.25", 89 | "config": { 90 | "lease_time": { 91 | "default": 3600 92 | } 93 | }, 94 | "options": { 95 | "values": { 96 | "subnet_mask": { 97 | "type": "ip", 98 | "value": "255.255.255.0" 99 | }, 100 | "routers": { 101 | "type": "ip", 102 | "value": ["192.168.6.1"] 103 | }, 104 | "domain_name_servers": { 105 | "type": "ip", 106 | "value": ["192.168.6.1"] 107 | } 108 | } 109 | } 110 | } 111 | ] 112 | } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /bin/tests/test_configs/basic.yaml: -------------------------------------------------------------------------------- 1 | chaddr_only: false 2 | bootp_enable: true 3 | rapid_commit: true 4 | interfaces: 5 | - dhcpsrv 6 | networks: 7 | 192.168.1.100/30: 8 | probation_period: 86400 9 | ranges: 10 | - 11 | start: 192.168.1.100 12 | end: 192.168.1.103 13 | config: 14 | lease_time: 15 | default: 3600 16 | min: 1200 17 | max: 4800 18 | options: 19 | values: 20 | 1: 21 | type: ip 22 | value: 192.168.1.1 23 | 3: 24 | type: ip 25 | value: 26 | - 192.168.1.1 27 | 28 | 192.168.2.0/24: 29 | probation_period: 86400 30 | ranges: 31 | - 32 | start: 192.168.2.100 33 | end: 192.168.2.150 34 | config: 35 | lease_time: 36 | default: 3600 37 | min: 1200 38 | max: 4800 39 | options: 40 | values: 41 | 1: 42 | type: ip 43 | value: 192.168.2.1 44 | 3: 45 | type: ip 46 | value: 47 | - 192.168.2.1 48 | 40: 49 | type: str 50 | value: testdomain.com 51 | 253: 52 | type: hex 53 | value: 123ABC 54 | except: 55 | - 192.168.2.123 56 | - 192.168.2.124 57 | 58 | reservations: 59 | - 60 | ip: 192.168.2.160 61 | config: 62 | lease_time: 63 | default: 3600 64 | min: 1200 65 | max: 4800 66 | options: 67 | values: 68 | 1: 69 | type: ip 70 | value: 192.168.2.1 71 | 3: 72 | type: ip 73 | value: 74 | - 192.168.2.1 75 | match: 76 | options: 77 | values: 78 | 61: 79 | type: hex 80 | value: 001122334455 81 | - 82 | ip: 192.168.2.170 83 | config: 84 | lease_time: 85 | default: 3600 86 | min: 1200 87 | max: 4800 88 | options: 89 | values: 90 | 1: 91 | type: ip 92 | value: 10.10.0.1 93 | 3: 94 | type: ip 95 | value: 96 | - 10.10.0.1 97 | match: 98 | chaddr: aa:bb:cc:dd:ee:ff 99 | - 100 | ip: 192.168.2.165 101 | config: 102 | lease_time: 103 | default: 3600 104 | options: 105 | values: 106 | 1: 107 | type: ip 108 | value: 10.10.0.1 109 | 3: 110 | type: ip 111 | value: 112 | - 10.10.0.1 113 | match: 114 | chaddr: bb:bb:cc:dd:ee:ff 115 | 10.0.0.0/16: 116 | ranges: 117 | - 118 | start: 10.0.0.10 119 | end: 10.0.6.254 120 | config: 121 | lease_time: 122 | default: 3600 123 | min: 1200 124 | max: 4800 125 | options: 126 | values: 127 | 1: 128 | type: ip 129 | value: 10.10.0.1 130 | 3: 131 | type: ip 132 | value: 133 | - 10.10.0.1 134 | except: 135 | - 10.0.0.123 136 | - 10.0.0.124 137 | -------------------------------------------------------------------------------- /dora-core/src/server/typemap.rs: -------------------------------------------------------------------------------- 1 | //! Dynamic type map 2 | 3 | use std::{ 4 | any::{Any, TypeId}, 5 | collections::HashMap, 6 | fmt, 7 | hash::{BuildHasherDefault, Hasher}, 8 | }; 9 | 10 | /// A TypeId is already a hash, so we don't need to hash it 11 | #[derive(Default)] 12 | struct TypeIdHash(u64); 13 | 14 | impl Hasher for TypeIdHash { 15 | fn write(&mut self, _: &[u8]) { 16 | unreachable!("TypeId calls write_u64"); 17 | } 18 | 19 | #[inline] 20 | fn write_u64(&mut self, id: u64) { 21 | self.0 = id; 22 | } 23 | 24 | #[inline] 25 | fn finish(&self) -> u64 { 26 | self.0 27 | } 28 | } 29 | 30 | type AnyTypeMap = HashMap, BuildHasherDefault>; 31 | 32 | /// This is a HashMap of values, stored based on [`TypeId`]. Every type has a 33 | /// unique `TypeId` generated by the compiler, we are using this id to store in 34 | /// a value in a map of `Box` based on a type. Then retrieving that 35 | /// value based on its type by downcasting later. 36 | /// 37 | /// ``` 38 | /// # use dora_core::server::typemap::TypeMap; 39 | /// let mut map = TypeMap::new(); 40 | /// map.insert(10_usize); 41 | /// assert_eq!(map.get::().unwrap(), &10_usize); 42 | /// assert_eq!(map.remove::().unwrap(), 10_usize); 43 | /// ``` 44 | /// 45 | /// [`TypeId`]: std::any::TypeId 46 | #[derive(Default)] 47 | pub struct TypeMap { 48 | map: Option>, 49 | } 50 | 51 | impl TypeMap { 52 | /// Make a new `TypeMap`, does zero allocation 53 | #[inline] 54 | pub fn new() -> TypeMap { 55 | TypeMap { map: None } 56 | } 57 | 58 | /// Insert a type into the map. If the type already exists, it will be 59 | /// returned. 60 | /// 61 | /// ``` 62 | /// # use dora_core::server::typemap::TypeMap; 63 | /// let mut map = TypeMap::new(); 64 | /// assert!(map.insert(10_usize).is_none()); 65 | /// assert!(map.insert(10_u8).is_none()); 66 | /// assert_eq!(map.insert(15_usize), Some(10_usize)); 67 | /// ``` 68 | pub fn insert(&mut self, val: T) -> Option { 69 | self.map 70 | .get_or_insert_with(Box::default) 71 | .insert(TypeId::of::(), Box::new(val)) 72 | .and_then(|boxed| { 73 | (boxed as Box) 74 | .downcast() 75 | .ok() 76 | .map(|x| *x) 77 | }) 78 | } 79 | 80 | /// Get a reference to a type previously inserted 81 | /// 82 | /// ``` 83 | /// # use dora_core::server::typemap::TypeMap; 84 | /// let mut map = TypeMap::new(); 85 | /// assert!(map.get::().is_none()); 86 | /// map.insert(5i32); 87 | /// 88 | /// assert_eq!(map.get::(), Some(&5i32)); 89 | /// ``` 90 | pub fn get(&self) -> Option<&T> { 91 | self.map 92 | .as_ref() 93 | .and_then(|map| map.get(&TypeId::of::())) 94 | .and_then(|boxed| (**boxed).downcast_ref::()) 95 | } 96 | 97 | /// Get a mutable reference to a type previously inserted 98 | /// 99 | /// ``` 100 | /// # use dora_core::server::typemap::TypeMap; 101 | /// let mut map = TypeMap::new(); 102 | /// map.insert(String::from("Hello")); 103 | /// map.get_mut::().unwrap().push_str(" World"); 104 | /// 105 | /// assert_eq!(map.get::().unwrap(), "Hello World"); 106 | /// ``` 107 | pub fn get_mut(&mut self) -> Option<&mut T> { 108 | self.map 109 | .as_mut() 110 | .and_then(|map| map.get_mut(&TypeId::of::())) 111 | .and_then(|boxed| (**boxed).downcast_mut()) 112 | } 113 | 114 | /// Remove a type 115 | /// 116 | /// ``` 117 | /// # use dora_core::server::typemap::TypeMap; 118 | /// let mut map = TypeMap::new(); 119 | /// map.insert(10_usize); 120 | /// assert_eq!(map.remove::(), Some(10_usize)); 121 | /// assert!(map.get::().is_none()); 122 | /// ``` 123 | pub fn remove(&mut self) -> Option { 124 | self.map 125 | .as_mut() 126 | .and_then(|map| map.remove(&TypeId::of::())) 127 | .and_then(|boxed| { 128 | (boxed as Box) 129 | .downcast() 130 | .ok() 131 | .map(|x| *x) 132 | }) 133 | } 134 | 135 | /// Clear the `TypeMap` of all inserted values. 136 | /// 137 | /// ``` 138 | /// # use dora_core::server::typemap::TypeMap; 139 | /// let mut map = TypeMap::new(); 140 | /// map.insert(10_usize); 141 | /// map.clear(); 142 | /// 143 | /// assert!(map.get::().is_none()); 144 | /// ``` 145 | pub fn clear(&mut self) { 146 | if let Some(ref mut map) = self.map { 147 | map.clear(); 148 | } 149 | } 150 | } 151 | 152 | impl fmt::Debug for TypeMap { 153 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 154 | f.debug_struct("TypeMap").finish() 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /bin/src/main.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::cognitive_complexity)] 2 | use std::sync::Arc; 3 | 4 | use anyhow::{Context, Result, anyhow}; 5 | 6 | use config::DhcpConfig; 7 | use dora_core::{ 8 | Register, Server, 9 | config::{ 10 | cli::{self, Parser}, 11 | trace, 12 | }, 13 | dhcproto::{v4, v6}, 14 | tokio::{self, runtime::Builder, signal, task::JoinHandle}, 15 | tracing::*, 16 | }; 17 | use external_api::{ExternalApi, Health}; 18 | use ip_manager::{IpManager, sqlite::SqliteDb}; 19 | use leases::Leases; 20 | use message_type::MsgType; 21 | use static_addr::StaticAddr; 22 | 23 | #[cfg(not(target_env = "musl"))] 24 | use jemallocator::Jemalloc; 25 | use tokio_util::sync::CancellationToken; 26 | 27 | #[cfg(not(target_env = "musl"))] 28 | #[global_allocator] 29 | static GLOBAL: Jemalloc = Jemalloc; 30 | 31 | fn main() -> Result<()> { 32 | // parses from cli or environment var 33 | let config = cli::Config::parse(); 34 | let trace_config = trace::Config::parse(&config.dora_log)?; 35 | debug!(?config, ?trace_config); 36 | if let Err(err) = dotenv::dotenv() { 37 | debug!(?err, ".env file not loaded"); 38 | } 39 | 40 | let mut builder = Builder::new_multi_thread(); 41 | // configure thread name & enable IO/time 42 | builder.thread_name(&config.thread_name).enable_all(); 43 | // default num threads will be num logical CPUs 44 | // if we have a configured value here, set it 45 | if let Some(num) = config.threads { 46 | builder.worker_threads(num); 47 | } 48 | // build the runtime 49 | let rt = builder.build()?; 50 | 51 | rt.block_on(async move { 52 | match dora_core::tokio::spawn(async move { start(config).await }).await { 53 | Err(err) => error!(?err, "failed to start server"), 54 | Ok(Err(err)) => error!(?err, "exited with error"), 55 | Ok(_) => debug!("exiting..."), 56 | } 57 | }); 58 | 59 | Ok(()) 60 | } 61 | 62 | async fn start(config: cli::Config) -> Result<()> { 63 | let database_url = config.database_url.clone(); 64 | info!(?database_url, "using database at path"); 65 | let dora_id = config.dora_id.clone(); 66 | info!(?dora_id, "using id"); 67 | // setting DORA_ID for other plugins 68 | // TODO: Audit that the environment access only happens in single-threaded code. 69 | unsafe { std::env::set_var("DORA_ID", &dora_id) }; 70 | 71 | debug!("parsing DHCP config"); 72 | let dhcp_cfg = Arc::new(DhcpConfig::parse(&config.config_path)?); 73 | debug!("starting database"); 74 | let ip_mgr = Arc::new(IpManager::new(SqliteDb::new(database_url).await?)?); 75 | // start external api for healthchecks 76 | let api = ExternalApi::new( 77 | config.external_api, 78 | Arc::clone(&dhcp_cfg), 79 | Arc::clone(&ip_mgr), 80 | ); 81 | // start v4 server 82 | debug!("starting v4 server"); 83 | let mut v4: Server = 84 | Server::new(config.clone(), dhcp_cfg.v4().interfaces().to_owned())?; 85 | debug!("starting v4 plugins"); 86 | 87 | // perhaps with only one plugin chain we will just register deps here 88 | // in order? we could get rid of derive macros & topo sort 89 | MsgType::new(Arc::clone(&dhcp_cfg))?.register(&mut v4); 90 | StaticAddr::new(Arc::clone(&dhcp_cfg))?.register(&mut v4); 91 | // leases plugin 92 | 93 | Leases::new(Arc::clone(&dhcp_cfg), Arc::clone(&ip_mgr)).register(&mut v4); 94 | 95 | let v6 = if dhcp_cfg.has_v6() { 96 | // start v6 server 97 | info!("starting v6 server"); 98 | let mut v6: Server = 99 | Server::new(config.clone(), dhcp_cfg.v6().interfaces().to_owned())?; 100 | info!("starting v6 plugins"); 101 | MsgType::new(Arc::clone(&dhcp_cfg))?.register(&mut v6); 102 | Some(v6) 103 | } else { 104 | None 105 | }; 106 | 107 | debug!("changing health to good"); 108 | api.sender() 109 | .send(Health::Good) 110 | .await 111 | .context("error occurred in changing health status to Good")?; 112 | 113 | let token = CancellationToken::new(); 114 | // if dropped, will stop server 115 | let api_guard = api.start(token.clone()); 116 | match v6 { 117 | Some(v6) => { 118 | tokio::try_join!( 119 | flatten(tokio::spawn(v4.start(shutdown_signal(token.clone())))), 120 | flatten(tokio::spawn(v6.start(shutdown_signal(token.clone())))), 121 | )?; 122 | } 123 | None => { 124 | tokio::spawn(v4.start(shutdown_signal(token.clone()))).await??; 125 | } 126 | }; 127 | if let Err(err) = api_guard.await { 128 | error!(?err, "error waiting for web server API"); 129 | } 130 | Ok(()) 131 | } 132 | 133 | async fn flatten(handle: JoinHandle>) -> Result { 134 | match handle.await { 135 | Ok(Ok(result)) => Ok(result), 136 | Ok(Err(err)) => Err(err), 137 | Err(err) => Err(anyhow!(err)), 138 | } 139 | } 140 | 141 | async fn shutdown_signal(token: CancellationToken) -> Result<()> { 142 | let ret = signal::ctrl_c().await.map_err(|err| anyhow!(err)); 143 | token.cancel(); 144 | ret 145 | } 146 | -------------------------------------------------------------------------------- /libs/icmp-ping/src/icmp.rs: -------------------------------------------------------------------------------- 1 | use pnet::packet::{Packet, PrimitiveValues, icmp, icmpv6, ipv4}; 2 | 3 | use crate::{DEFAULT_TOKEN_SIZE, Token}; 4 | 5 | pub const ICMP_HEADER_SIZE: usize = 8; 6 | 7 | #[derive(thiserror::Error, Debug)] 8 | pub enum Error { 9 | #[error("invalid size")] 10 | InvalidSize, 11 | #[error("invalid packet")] 12 | InvalidPacket, 13 | #[error("ipv4 packet failed")] 14 | BadIpv4, 15 | } 16 | 17 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 18 | pub struct Icmpv4; 19 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 20 | pub struct Icmpv6; 21 | 22 | pub trait Proto {} 23 | 24 | impl Proto for Icmpv4 {} 25 | impl Proto for Icmpv6 {} 26 | 27 | pub trait Encode { 28 | fn encode(&self, buffer: &mut [u8]) -> Result<(), Error>; 29 | } 30 | 31 | #[derive(Debug, Clone)] 32 | pub struct EchoRequest<'a> { 33 | pub ident: u16, 34 | pub seq_cnt: u16, 35 | pub payload: &'a [u8], 36 | } 37 | 38 | impl PartialEq for EchoRequest<'_> { 39 | fn eq(&self, other: &Self) -> bool { 40 | // ident potentially will be altered by the kernel because we use DGRAM 41 | self.seq_cnt == other.seq_cnt && self.payload == other.payload 42 | } 43 | } 44 | 45 | // compare equality with EchoReply 46 | impl PartialEq for EchoRequest<'_> { 47 | fn eq(&self, other: &EchoReply) -> bool { 48 | self.seq_cnt == other.seq_cnt && self.payload == other.payload 49 | } 50 | } 51 | 52 | impl Encode for EchoRequest<'_> { 53 | fn encode(&self, buffer: &mut [u8]) -> Result<(), Error> { 54 | let mut packet = 55 | icmp::echo_request::MutableEchoRequestPacket::new(buffer).ok_or(Error::InvalidSize)?; 56 | packet.set_icmp_type(icmp::IcmpTypes::EchoRequest); 57 | packet.set_identifier(self.ident); 58 | packet.set_sequence_number(self.seq_cnt); 59 | packet.set_payload(self.payload); 60 | 61 | let checksum = 62 | icmp::checksum(&icmp::IcmpPacket::new(packet.packet()).ok_or(Error::InvalidSize)?); 63 | packet.set_checksum(checksum); 64 | Ok(()) 65 | } 66 | } 67 | 68 | impl Encode for EchoRequest<'_> { 69 | fn encode(&self, buffer: &mut [u8]) -> Result<(), Error> { 70 | // icmpv6::MutableIcmpv6Packet does not have a way to set ident and seq_cnt, so we'll do it manually here 71 | // set type 72 | buffer[0] = icmpv6::Icmpv6Types::EchoRequest.to_primitive_values().0; 73 | // set code 74 | buffer[1] = 0; 75 | // set ident 76 | buffer[4..=5].copy_from_slice(&self.ident.to_be_bytes()); 77 | // set seq_cnt 78 | buffer[6..=7].copy_from_slice(&self.seq_cnt.to_be_bytes()); 79 | // add our payload 80 | buffer[8..].copy_from_slice(self.payload); 81 | 82 | let checksum = icmp::checksum(&icmp::IcmpPacket::new(buffer).ok_or(Error::InvalidSize)?); 83 | buffer[2..=3].copy_from_slice(&checksum.to_be_bytes()); 84 | Ok(()) 85 | } 86 | } 87 | 88 | pub trait Decode: Sized { 89 | fn decode(buffer: &[u8], decode_header: bool) -> Result; 90 | } 91 | 92 | #[derive(Debug, Clone)] 93 | pub struct EchoReply { 94 | pub ident: u16, 95 | pub seq_cnt: u16, 96 | pub payload: Token, 97 | } 98 | 99 | impl PartialEq for EchoReply { 100 | fn eq(&self, other: &Self) -> bool { 101 | // ident potentially will be altered by the kernel because we use DGRAM 102 | self.seq_cnt == other.seq_cnt && self.payload == other.payload 103 | } 104 | } 105 | 106 | // compare equality with EchoRequest 107 | impl PartialEq> for EchoReply { 108 | fn eq(&self, other: &EchoRequest) -> bool { 109 | self.seq_cnt == other.seq_cnt && self.payload == other.payload 110 | } 111 | } 112 | 113 | impl Decode for EchoReply { 114 | fn decode(buffer: &[u8], decode_header: bool) -> Result { 115 | // needed for borrowck 116 | let ipv4_packet; 117 | let buffer = if decode_header { 118 | ipv4_packet = ipv4::Ipv4Packet::new(buffer).ok_or(Error::BadIpv4)?; 119 | ipv4_packet.payload() 120 | } else { 121 | buffer 122 | }; 123 | let packet = icmp::echo_reply::EchoReplyPacket::new(buffer).ok_or(Error::InvalidPacket)?; 124 | if buffer[ICMP_HEADER_SIZE..].len() != DEFAULT_TOKEN_SIZE { 125 | return Err(Error::InvalidSize); 126 | } 127 | let mut payload = [0; DEFAULT_TOKEN_SIZE]; 128 | payload.copy_from_slice(&buffer[ICMP_HEADER_SIZE..]); 129 | 130 | Ok(Self { 131 | ident: packet.get_identifier(), 132 | seq_cnt: packet.get_sequence_number(), 133 | payload, 134 | }) 135 | } 136 | } 137 | impl Decode for EchoReply { 138 | fn decode(buffer: &[u8], _decode_header: bool) -> Result { 139 | let packet = icmpv6::Icmpv6Packet::new(buffer).ok_or(Error::InvalidPacket)?; 140 | if !matches!(packet.get_icmpv6_type(), icmpv6::Icmpv6Types::EchoReply) { 141 | return Err(Error::InvalidPacket); 142 | } 143 | let icmp_payload = packet.payload(); 144 | let ident = u16::from_be_bytes([icmp_payload[0], icmp_payload[1]]); 145 | let seq_cnt = u16::from_be_bytes([icmp_payload[2], icmp_payload[3]]); 146 | let mut payload = [0; DEFAULT_TOKEN_SIZE]; 147 | payload.copy_from_slice(&buffer[ICMP_HEADER_SIZE..][..DEFAULT_TOKEN_SIZE]); 148 | 149 | Ok(Self { 150 | ident, 151 | seq_cnt, 152 | payload, 153 | }) 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /libs/ddns/src/dhcid.rs: -------------------------------------------------------------------------------- 1 | use std::fmt; 2 | 3 | use base64::{Engine, prelude::BASE64_STANDARD}; 4 | use dora_core::dhcproto::{Name, NameError, v4::HType}; 5 | use dora_core::hickory_proto::serialize::binary::BinEncoder; 6 | use ring::digest::{Context, SHA256}; 7 | 8 | #[derive(Debug, PartialEq, Eq, Clone)] 9 | pub struct DhcId { 10 | ty: IdType, 11 | id: Vec, 12 | } 13 | 14 | impl fmt::Display for DhcId { 15 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 16 | f.debug_struct("DhcId") 17 | .field("type", &self.ty) 18 | .field("id", &BASE64_STANDARD.encode(&self.id)) 19 | .finish() 20 | } 21 | } 22 | 23 | impl DhcId { 24 | /// if created with type Chaddr, only significant bytes (up to hlen) should be provided 25 | pub fn new>>(ty: IdType, id: T) -> Self { 26 | Self { ty, id: id.into() } 27 | } 28 | pub fn chaddr>>(id: T) -> Self { 29 | Self { 30 | ty: IdType::Chaddr, 31 | id: id.into(), 32 | } 33 | } 34 | pub fn client_id>>(id: T) -> Self { 35 | Self { 36 | ty: IdType::ClientId, 37 | id: id.into(), 38 | } 39 | } 40 | pub fn duid>>(id: T) -> Self { 41 | Self { 42 | ty: IdType::Duid, 43 | id: id.into(), 44 | } 45 | } 46 | pub fn id(&self) -> Vec { 47 | if self.ty == IdType::Chaddr { 48 | // https://www.rfc-editor.org/rfc/rfc4701#section-3.5.3 49 | let mut d = vec![0; self.id.len() + 1]; 50 | d[0] = HType::Eth.into(); 51 | d[1..].copy_from_slice(&self.id); 52 | d 53 | } else { 54 | self.id.clone() 55 | } 56 | } 57 | /// The DHCID RDATA has the following structure: 58 | /// 59 | /// < identifier-type > < digest-type > < digest > 60 | /// 61 | /// identifier-type: 62 | /// chaddr 0x0000 63 | /// client id 0x0001 64 | /// duid 0x0002 65 | /// the digest-type code is 0x01 for SHA256 66 | /// The input to the digest hash function is defined to be: 67 | /// digest = SHA-256(< identifier > < FQDN >) 68 | pub fn rdata(&self, fqdn: &Name) -> Result, NameError> { 69 | let mut cx = Context::new(&SHA256); 70 | // create new encoder 71 | let mut name_buf = Vec::new(); 72 | let mut enc = BinEncoder::new(&mut name_buf); 73 | fqdn.emit_as_canonical(&mut enc, true)?; 74 | // create digest 75 | let mut data = self.id(); 76 | 77 | data.extend_from_slice(&name_buf); 78 | cx.update(&data); 79 | let digest = cx.finish(); 80 | 81 | let mut buf: Vec = vec![0; 3 + digest.as_ref().len()]; 82 | buf[0] = 0x00; 83 | match self.ty { 84 | IdType::Chaddr => { 85 | buf[1] = 0x00; 86 | } 87 | IdType::ClientId => { 88 | buf[1] = 0x01; 89 | } 90 | IdType::Duid => { 91 | buf[1] = 0x02; 92 | } 93 | } 94 | buf[2] = 0x01; 95 | buf[3..].copy_from_slice(digest.as_ref()); 96 | 97 | Ok(buf) 98 | } 99 | } 100 | 101 | #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)] 102 | #[repr(u8)] 103 | pub enum IdType { 104 | Chaddr = 0x0000, 105 | ClientId = 0x0001, 106 | Duid = 0x0002, 107 | } 108 | 109 | #[cfg(test)] 110 | mod tests { 111 | use std::str::FromStr; 112 | 113 | use base64::{Engine, prelude::BASE64_STANDARD}; 114 | 115 | use super::*; 116 | 117 | // A DHCP server allocates the IPv4 address 192.0.2.2 to a client that 118 | // included the DHCP client-identifier option data 01:07:08:09:0a:0b:0c 119 | // in its DHCP request. The server updates the name "chi.example.com" 120 | // on the client's behalf and uses the DHCP client identifier option 121 | // data as input in forming a DHCID RR. The DHCID RDATA is formed by 122 | // setting the two type octets to the value 0x0001, the 1-octet digest 123 | // type to 1 for SHA-256, and performing a SHA-256 hash computation 124 | // across a buffer containing the seven octets from the client-id option 125 | // and the FQDN (represented as specified in Section 3.5). 126 | 127 | // chi.example.com. A 192.0.2.2 128 | // chi.example.com. DHCID ( AAEBOSD+XR3Os/0LozeXVqcNc7FwCfQdW 129 | // L3b/NaiUDlW2No= ) 130 | #[test] 131 | fn test_dhcid_client_id() { 132 | let dhcid = DhcId::new(IdType::ClientId, hex::decode("010708090a0b0c").unwrap()); 133 | let out = dhcid 134 | .rdata(&Name::from_str("chi.example.com.").unwrap()) 135 | .unwrap(); 136 | assert_eq!( 137 | BASE64_STANDARD.encode(out), 138 | "AAEBOSD+XR3Os/0LozeXVqcNc7FwCfQdWL3b/NaiUDlW2No=".to_owned() 139 | ); 140 | } 141 | 142 | // A DHCP server allocating the IPv4 address 192.0.2.3 to a client with 143 | // the Ethernet MAC address 01:02:03:04:05:06 using domain name 144 | // "client.example.com" uses the client's link-layer address to identify 145 | // the client. The DHCID RDATA is composed by setting the two type 146 | // octets to zero, the 1-octet digest type to 1 for SHA-256, and 147 | // performing an SHA-256 hash computation across a buffer containing the 148 | // 1-octet 'htype' value for Ethernet, 0x01, followed by the six octets 149 | // of the Ethernet MAC address, and the domain name (represented as 150 | // specified in Section 3.5). 151 | 152 | // client.example.com. A 192.0.2.3 153 | // client.example.com. DHCID ( AAABxLmlskllE0MVjd57zHcWmEH3pCQ6V 154 | // ytcKD//7es/deY= ) 155 | #[test] 156 | fn test_dhcid_chaddr() { 157 | let dhcid = DhcId::new(IdType::Chaddr, hex::decode("010203040506").unwrap()); 158 | let out = dhcid 159 | .rdata(&Name::from_str("client.example.com.").unwrap()) 160 | .unwrap(); 161 | assert_eq!( 162 | BASE64_STANDARD.encode(out), 163 | "AAABxLmlskllE0MVjd57zHcWmEH3pCQ6VytcKD//7es/deY=".to_owned() 164 | ); 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /docs/pi_setup.md: -------------------------------------------------------------------------------- 1 | # Setting up dora on the Pi 2 | 3 | You will need a Pi3 or later with either an onboard WiFi module or an external WiFi dongle. We also assume that you are running Raspbian (tested with 32-bit). 4 | 5 | ## 0. SSH into the Pi 6 | 7 | Ensure everything is up to date with: 8 | 9 | ```bash 10 | sudo apt update 11 | sudo apt full-upgrade 12 | ``` 13 | 14 | Then ensure you acquire the prerequisites: 15 | 16 | ```bash 17 | sudo apt-get -y install hostapd bridge-utils iptables gettext libdbus-1-dev libidn11-dev libnetfilter-conntrack-dev nettle-dev netfilter-persistent iptables-persistent 18 | ``` 19 | 20 | Also, if you have not yet installed Rust on your Pi, this can be achieved rather painlessly using [`rustup`](https://rustup.rs) 21 | 22 | ## Set up the Pi as an access point 23 | 24 | You may find [this guide](https://www.raspberrypi.com/documentation/computers/configuration.html#setting-up-a-routed-wireless-access-point) helpful, but here's our TL;DR to set up dora as an access point: 25 | 26 | ### 1. Configure the host access point daemon 27 | 28 | edit `/etc/hostapd/hostapd.conf`. Note that the `ssid` and `wpa_passphrase` that you specify here will be what you need to use to connect to the access point. 29 | 30 | ``` 31 | interface=wlan0 32 | # use `g` for 2.4 GHz and `a` for 5 GHz 33 | hw_mode=g 34 | # must be a channel available on `iw list` with an appropriate frequency for the `hw_mode` you specify 35 | channel=10 36 | # limit the frequencies used to those allowed in the country 37 | ieee80211d=1 38 | # the country code, see https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2#Current_codes 39 | country_code=CA 40 | # 802.11n support 41 | ieee80211n=1 42 | # QoS support, also required for full speed on 802.11n/ac/ax 43 | wmm_enabled=1 44 | # the name of the access point 45 | ssid=PI_AP 46 | # 1=wpa, 2=wep, 3=both 47 | auth_algs=1 48 | # WPA2 only 49 | wpa=2 50 | wpa_key_mgmt=WPA-PSK 51 | rsn_pairwise=CCMP 52 | wpa_passphrase=somepassword 53 | ``` 54 | 55 | ### 2. Define the Wireless Interface IP Configuration 56 | 57 | edit `/etc/dhcpcd.conf` and append: 58 | 59 | ``` 60 | interface wlan0 61 | # pick some static IP, this is the subnet we'll serve dora on 62 | static ip_address=192.168.5.1/24 63 | nohook wpa_supplicant 64 | ``` 65 | 66 | ### 3. Set up IP forwarding to eth0 67 | 68 | edit `/etc/sysctl.d/99-sysctl.conf` and either ensure the following lines are uncommented or append them: 69 | 70 | ``` 71 | net.ipv4.ip_forward=1 72 | net.ipv6.conf.all.forwarding=1 73 | ``` 74 | 75 | **_Note_**: You may also find it useful here to create `/etc/sysctl.d/routed-ap.conf` and set its contents to: 76 | 77 | ``` 78 | # Enable IPv4 routing 79 | net.ipv4.ip_forward=1 80 | ``` 81 | 82 | then reboot the Pi to ensure the configuration settings are properly applied: 83 | 84 | ```bash 85 | sudo reboot 86 | ``` 87 | 88 | once the Pi has rebooted, SSH back in and execute: 89 | 90 | ```bash 91 | sudo iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE 92 | sudo netfilter-persistent save 93 | ``` 94 | 95 | ## Set up & run dora/hostapd 96 | 97 | 1. get yourself a dora ARM binary. See the [README](../README.md#cross-compiling-to-arm) the section "Cross Compiling to ARM". Note that if you choose to build the binary directly on the Pi, you can simply [follow the build instructions](../README.md#buildrun). 98 | 99 | 1. Run dora, You can see dora's options with `dora --help`, you likely need to edit the config file. `dora`'s config is in a format that's easy to generate programmatically, not with manual editing as the first priority. 100 | 101 | A very simple config (IPv4 only) that matches how this guide has configured hostapd might look like: 102 | 103 | ```yaml 104 | interfaces: 105 | - wlan0 106 | networks: 107 | 192.168.5.0/24: 108 | probation_period: 86400 109 | server_id: 192.168.5.1 110 | ranges: 111 | - start: 192.168.5.2 112 | end: 192.168.5.250 113 | config: 114 | lease_time: 115 | default: 3600 116 | min: 1200 117 | max: 4800 118 | options: 119 | values: 120 | 1: # subnet mask (if not specified, comes from `interfaces`) 121 | type: ip 122 | value: 255.255.255.0 123 | 3: # router (if not specified, will come from `interfaces`) 124 | type: ip 125 | value: 126 | - 192.168.5.1 127 | 6: # domain name (if running a DNS server like dnsmasq also, use its IP) 128 | type: ip 129 | value: 130 | - 8.8.8.8 131 | 28: # broadcast addr (if not specified, comes from `interfaces`) 132 | type: ip 133 | value: 192.168.5.255 134 | ``` 135 | 136 | You may wish to save this minimal config to `pi.yaml` to try it out, or see [example.yaml](../example.yaml) for the full set of options. You can also use `dora --help` to see arguments. 137 | 138 | Run dora: 139 | 140 | After you have saved the above minimal config to `pi.yaml` in the workspace on the Pi, go ahead and [setup the sqlx database](../README.md#buildrun) if you haven't already. You should then be able to run the following (you may also need to substitute your path to the dora binary for `./dora` depending on whether you compiled it directly on the Pi or copied it from elsewhere): 141 | 142 | ``` 143 | sudo DORA_LOG="debug" ./dora -c pi.yaml -d em.db 144 | ``` 145 | 146 | You can delete `rm em.*` to wipe the database and start fresh. 147 | 148 | 1. Run hostapd 149 | 150 | ``` 151 | sudo hostapd -d /etc/hostapd/hostapd.conf 152 | ``` 153 | 154 | Try connecting to the `PI_AP` wirelessly (using `somepassword` if you have followed this guide precisely), you can check the dora logs to see if DHCP traffic is being received. 155 | 156 | ## Add to boot 157 | 158 | If everything works, it's time to add it all to start on boot 159 | 160 | ``` 161 | sudo systemctl unmask hostapd # this may or may not be necessary 162 | sudo systemctl enable hostapd 163 | sudo reboot 164 | ``` 165 | 166 | We don't have a way to add the dora binary to systemd at the moment, so it must be run manually. You probably want to SSH in to look at the logs anyway. There are a number of ways that you can ensure `dora` will continue to run beyond your SSH session (e.g. using [tmux](https://github.com/tmux/tmux/wiki), so feel free to use your favorite solution. 167 | -------------------------------------------------------------------------------- /libs/config/sample/long_opts.yaml: -------------------------------------------------------------------------------- 1 | chaddr_only: false 2 | # interfaces: 3 | # - wlan0 4 | networks: 5 | 192.168.1.100/30: 6 | probation_period: 86400 7 | server_id: 192.168.1.1 8 | ranges: 9 | - 10 | start: 192.168.1.100 11 | end: 192.168.1.103 12 | config: 13 | lease_time: 14 | default: 3600 15 | min: 1200 16 | max: 4800 17 | options: 18 | values: 19 | 1: 20 | type: ip 21 | value: 192.168.1.1 22 | 3: 23 | type: ip 24 | value: 25 | - 192.168.1.1 26 | - 192.168.1.1 27 | - 192.168.1.1 28 | - 192.168.1.1 29 | - 192.168.1.1 30 | - 192.168.1.1 31 | - 192.168.1.1 32 | - 192.168.1.1 33 | - 192.168.1.1 34 | - 192.168.1.1 35 | - 192.168.1.1 36 | - 192.168.1.1 37 | - 192.168.1.1 38 | - 192.168.1.1 39 | - 192.168.1.1 40 | - 192.168.1.1 41 | - 192.168.1.1 42 | - 192.168.1.1 43 | - 192.168.1.1 44 | - 192.168.1.1 45 | - 192.168.1.1 46 | - 192.168.1.1 47 | - 192.168.1.1 48 | - 192.168.1.1 49 | - 192.168.1.1 50 | - 192.168.1.1 51 | - 192.168.1.1 52 | - 192.168.1.1 53 | - 192.168.1.1 54 | - 192.168.1.1 55 | - 192.168.1.1 56 | - 192.168.1.1 57 | - 192.168.1.1 58 | - 192.168.1.1 59 | - 192.168.1.1 60 | - 192.168.1.1 61 | - 192.168.1.1 62 | - 192.168.1.1 63 | - 192.168.1.1 64 | - 192.168.1.1 65 | - 192.168.1.1 66 | - 192.168.1.1 67 | - 192.168.1.1 68 | - 192.168.1.1 69 | - 192.168.1.1 70 | - 192.168.1.1 71 | - 192.168.1.1 72 | - 192.168.1.1 73 | - 192.168.1.1 74 | - 192.168.1.1 75 | - 192.168.1.1 76 | - 192.168.1.1 77 | 43: 78 | type: sub_option 79 | value: 80 | 1: 81 | type: str 82 | value: foAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABobarAAA 83 | 2: 84 | type: ip 85 | value: 1.2.3.4 86 | 3: 87 | type: ip 88 | value: 89 | - 192.168.1.1 90 | - 192.168.1.1 91 | - 192.168.1.1 92 | - 192.168.1.1 93 | - 192.168.1.1 94 | - 192.168.1.1 95 | - 192.168.1.1 96 | - 192.168.1.1 97 | - 192.168.1.1 98 | - 192.168.1.1 99 | - 192.168.1.1 100 | - 192.168.1.1 101 | - 192.168.1.1 102 | - 192.168.1.1 103 | - 192.168.1.1 104 | - 192.168.1.1 105 | - 192.168.1.1 106 | - 192.168.1.1 107 | - 192.168.1.1 108 | - 192.168.1.1 109 | - 192.168.1.1 110 | - 192.168.1.1 111 | - 192.168.1.1 112 | - 192.168.1.1 113 | - 192.168.1.1 114 | - 192.168.1.1 115 | - 192.168.1.1 116 | - 192.168.1.1 117 | - 192.168.1.1 118 | - 192.168.1.1 119 | - 192.168.1.1 120 | - 192.168.1.1 121 | - 192.168.1.1 122 | - 192.168.1.1 123 | - 192.168.1.1 124 | - 192.168.1.1 125 | - 192.168.1.1 126 | - 192.168.1.1 127 | - 192.168.1.1 128 | - 192.168.1.1 129 | - 192.168.1.1 130 | - 192.168.1.1 131 | - 192.168.1.1 132 | - 192.168.1.1 133 | - 192.168.1.1 134 | - 192.168.1.1 135 | - 192.168.1.1 136 | - 192.168.1.1 137 | - 192.168.1.1 138 | - 192.168.1.1 139 | - 192.168.1.1 140 | - 192.168.1.1 141 | 142 | 143 | -------------------------------------------------------------------------------- /libs/config/sample/config.yaml: -------------------------------------------------------------------------------- 1 | chaddr_only: false 2 | flood_protection_threshold: 3 | packets: 3 4 | secs: 5 5 | cache_threshold: 25 6 | # interfaces: 7 | # - wlan0 8 | networks: 9 | 192.168.1.100/30: 10 | probation_period: 86400 11 | server_id: 192.168.1.1 12 | ranges: 13 | - 14 | start: 192.168.1.100 15 | end: 192.168.1.103 16 | config: 17 | lease_time: 18 | default: 3600 19 | min: 1200 20 | max: 4800 21 | options: 22 | values: 23 | 1: 24 | type: ip 25 | value: 192.168.1.1 26 | 3: 27 | type: ip 28 | value: 29 | - 192.168.1.1 30 | 43: 31 | type: sub_option 32 | value: 33 | 1: 34 | type: str 35 | value: "foobar" 36 | 2: 37 | type: ip 38 | value: 1.2.3.4 39 | 40 | 192.168.0.0/24: 41 | probation_period: 86400 42 | ranges: 43 | - 44 | start: 192.168.0.100 45 | end: 192.168.0.150 46 | config: 47 | lease_time: 48 | default: 3600 49 | min: 1200 50 | max: 4800 51 | options: 52 | values: 53 | 1: 54 | type: ip 55 | value: 192.168.0.1 56 | 3: 57 | type: ip 58 | value: 59 | - 192.168.0.1 60 | 40: 61 | type: str 62 | value: testdomain.com 63 | 253: 64 | type: hex 65 | value: 123ABC 66 | except: 67 | - 192.168.0.123 68 | - 192.168.0.124 69 | 70 | reservations: 71 | - 72 | ip: 192.168.0.160 73 | config: 74 | lease_time: 75 | default: 3600 76 | min: 1200 77 | max: 4800 78 | options: 79 | values: 80 | subnet_mask: 81 | type: ip 82 | value: 192.168.0.1 83 | routers: 84 | type: ip_list # for backwards compat testing 85 | value: 86 | - 192.168.0.1 87 | vendor_extensions: 88 | type: sub_option 89 | value: 90 | 1: 91 | type: str 92 | value: "foobar" 93 | 2: 94 | type: ip 95 | value: 1.2.3.4 96 | match: 97 | options: 98 | values: 99 | 61: 100 | type: hex 101 | value: 001122334455 102 | - 103 | ip: 192.168.0.170 104 | config: 105 | lease_time: 106 | default: 3600 107 | min: 1200 108 | max: 4800 109 | options: 110 | values: 111 | 1: 112 | type: ip 113 | value: 10.10.0.1 114 | 3: 115 | type: ip 116 | value: 117 | - 10.10.0.1 118 | match: 119 | chaddr: aa:bb:cc:dd:ee:ff 120 | 10.0.0.0/16: 121 | ranges: 122 | - 123 | start: 10.0.0.10 124 | end: 10.0.0.254 125 | class: my_class 126 | config: 127 | lease_time: 128 | default: 3600 129 | options: 130 | values: 131 | 1: 132 | type: ip 133 | value: 10.0.0.1 134 | 3: 135 | type: ip 136 | value: 137 | - 10.0.0.1 138 | - 139 | start: 10.0.1.10 140 | end: 10.0.1.254 141 | # class: my_class 142 | config: 143 | lease_time: 144 | default: 3600 145 | options: 146 | values: 147 | 1: 148 | type: ip 149 | value: 10.0.1.1 150 | 3: 151 | type: ip 152 | value: 153 | - 10.0.1.1 154 | client_classes: 155 | v4: 156 | - 157 | name: c_class 158 | assert: "member('a_class') and member('b_class')" 159 | options: 160 | values: 161 | 6: 162 | type: ip 163 | value: [ 1.1.1.1 ] 164 | - 165 | name: d_class 166 | assert: "member('b_class') and member('c_class')" 167 | options: 168 | values: 169 | 6: 170 | type: ip 171 | value: [ 1.1.1.1 ] 172 | - 173 | name: my_class 174 | assert: "pkt4.mac == 0xDEADBEEF" 175 | options: 176 | values: 177 | 6: 178 | type: ip 179 | value: [ 1.1.1.1 ] 180 | - 181 | name: a_class 182 | assert: "option[12].hex == 'hostname'" 183 | options: 184 | values: 185 | 6: 186 | type: ip 187 | value: [ 1.1.1.1 ] 188 | - 189 | name: b_class 190 | assert: "member('a_class') and pkt4.mac == 0xDEADBEEF" 191 | options: 192 | values: 193 | 6: 194 | type: ip 195 | value: [ 1.1.1.1 ] 196 | 197 | -------------------------------------------------------------------------------- /dora-core/src/metrics.rs: -------------------------------------------------------------------------------- 1 | #![allow(missing_docs)] // proc macros dont play nicely with docstrings 2 | 3 | //! # metrics 4 | //! 5 | //! contains statistics for server metrics 6 | use std::time::Instant; 7 | 8 | use lazy_static::lazy_static; 9 | use prometheus::{ 10 | HistogramOpts, HistogramVec, IntCounter, IntCounterVec, IntGauge, register_int_counter, 11 | register_int_counter_vec, register_int_gauge, 12 | }; 13 | use prometheus_static_metric::make_static_metric; 14 | 15 | make_static_metric! { 16 | pub label_enum MsgType { 17 | discover, 18 | request, 19 | decline, 20 | release, 21 | offer, 22 | ack, 23 | nak, 24 | inform, 25 | unknown, 26 | } 27 | pub struct RecvStats: IntCounter { 28 | "message_type" => MsgType 29 | } 30 | pub struct SentStats: IntCounter { 31 | "message_type" => MsgType 32 | } 33 | pub label_enum V6MsgType { 34 | solicit, 35 | advertise, 36 | request, 37 | confirm, 38 | renew, 39 | rebind, 40 | reply, 41 | release, 42 | decline, 43 | reconf, 44 | inforeq, 45 | relayforw, 46 | relayrepl, 47 | unknown, 48 | } 49 | pub struct V6RecvStats: IntCounter { 50 | "v6_message_type" => V6MsgType 51 | } 52 | pub struct V6SentStats: IntCounter { 53 | "v6_message_type" => V6MsgType 54 | } 55 | } 56 | 57 | lazy_static! { 58 | /// When the server started 59 | pub static ref START_TIME: Instant = Instant::now(); 60 | 61 | /// bytes sent DHCPv4 62 | pub static ref DHCPV4_BYTES_SENT: IntCounter = register_int_counter!("dhcpv4_bytes_sent", "DHCPv4 bytes sent").unwrap(); 63 | /// bytes sent DHCPv6 64 | pub static ref DHCPV6_BYTES_SENT: IntCounter = register_int_counter!("dhcpv6_bytes_sent", "DHCPv4 bytes sent").unwrap(); 65 | 66 | /// bytes recv DHCPv4 67 | pub static ref DHCPV4_BYTES_RECV: IntCounter = register_int_counter!("dhcpv4_bytes_recv", "DHCPv4 bytes recv").unwrap(); 68 | /// bytes recv DHCPv6 69 | pub static ref DHCPV6_BYTES_RECV: IntCounter = register_int_counter!("dhcpv6_bytes_recv", "DHCPv6 bytes recv").unwrap(); 70 | 71 | /// histogram of response times for DHCPv4 reply 72 | pub static ref DHCPV4_REPLY_DURATION: HistogramVec = HistogramVec::new( 73 | HistogramOpts::new("dhpcv4_duration", "dhcpv4 duration (seconds)"), 74 | &["type"] 75 | ) 76 | .unwrap(); 77 | 78 | /// histogram of response times for DHCPv6 reply 79 | pub static ref DHCPV6_REPLY_DURATION: HistogramVec = HistogramVec::new( 80 | HistogramOpts::new("dhcpv6_duration", "dhcpv6 duration (seconds)"), 81 | &["type"] 82 | ) 83 | .unwrap(); 84 | 85 | pub static ref RECV_COUNT_VEC: IntCounterVec = register_int_counter_vec!( 86 | "recv_type_counts", 87 | "Recv Type Counts", 88 | &["message_type"] 89 | ) 90 | .unwrap(); 91 | pub static ref SENT_COUNT_VEC: IntCounterVec = register_int_counter_vec!( 92 | "sent_type_counts", 93 | "Sent Type Counts", 94 | &["message_type"] 95 | ) 96 | .unwrap(); 97 | 98 | /// aggregate count of all recv'd messages types 99 | pub static ref RECV_TYPE_COUNT: RecvStats = RecvStats::from(&RECV_COUNT_VEC); 100 | 101 | /// aggregate count of all sent messages types 102 | pub static ref SENT_TYPE_COUNT: SentStats = SentStats::from(&SENT_COUNT_VEC); 103 | 104 | pub static ref V6_RECV_COUNT_VEC: IntCounterVec = register_int_counter_vec!( 105 | "v6_recv_type_counts", 106 | "V6 Recv Type Counts", 107 | &["v6_message_type"] 108 | ) 109 | .unwrap(); 110 | pub static ref V6_SENT_COUNT_VEC: IntCounterVec = register_int_counter_vec!( 111 | "v6_sent_type_counts", 112 | "V6 Sent Type Counts", 113 | &["v6_message_type"] 114 | ) 115 | .unwrap(); 116 | 117 | /// aggregate count of all recv'd messages types 118 | pub static ref V6_RECV_TYPE_COUNT: V6RecvStats = V6RecvStats::from(&V6_RECV_COUNT_VEC); 119 | 120 | /// aggregate count of all sent messages types 121 | pub static ref V6_SENT_TYPE_COUNT: V6SentStats = V6SentStats::from(&V6_SENT_COUNT_VEC); 122 | 123 | /// # of in flight msgs 124 | pub static ref IN_FLIGHT: IntGauge = 125 | register_int_gauge!("in_flight", "count of currently processing messages").unwrap(); 126 | 127 | // TODO: set in external-api 128 | /// # of declined IPs 129 | // pub static ref DECLINED_ADDRS: IntGauge = 130 | // register_int_gauge!("declined_addrs", "count of addresses currently on probation from decline").unwrap(); 131 | 132 | // TODO: set in external-api 133 | /// # of leased IPs 134 | // pub static ref LEASED_ADDRS: IntGauge = 135 | // register_int_gauge!("leased_addrs", "count of addresses currently leased").unwrap(); 136 | 137 | /// # of total addrs available 138 | pub static ref TOTAL_AVAILABLE_ADDRS: IntGauge = 139 | register_int_gauge!("total_available_addrs", "count of addresses currently leased").unwrap(); 140 | /// server uptime 141 | pub static ref UPTIME: IntGauge = register_int_gauge!("uptime", "server uptime (seconds)").unwrap(); 142 | 143 | // ICMP metrics 144 | 145 | /// ping request count 146 | pub static ref ICMPV4_REQUEST_COUNT: IntCounter = register_int_counter!("icmpv4_request_count", "count of ICMPv4 echo request").unwrap(); 147 | /// ping reply count 148 | pub static ref ICMPV4_REPLY_COUNT: IntCounter = register_int_counter!("icmpv4_reply_count", "count of ICMPv4 echo reply").unwrap(); 149 | 150 | 151 | /// ping request count 152 | pub static ref ICMPV6_REQUEST_COUNT: IntCounter = register_int_counter!("icmpv6_request_count", "count of ICMPv6 echo request").unwrap(); 153 | /// ping reply count 154 | pub static ref ICMPV6_REPLY_COUNT: IntCounter = register_int_counter!("icmpv6_reply_count", "count of ICMPv6 echo reply").unwrap(); 155 | 156 | 157 | /// histogram of response times for ping reply 158 | pub static ref ICMPV4_REPLY_DURATION: HistogramVec = HistogramVec::new( 159 | HistogramOpts::new("icmpv4_duration", "icmpv4 response time in seconds, only counts received pings"), 160 | &["reply"] 161 | ) 162 | .unwrap(); 163 | 164 | /// histogram of response times for ping reply v6 165 | pub static ref ICMPV6_REPLY_DURATION: HistogramVec = HistogramVec::new( 166 | HistogramOpts::new("icmpv6_duration", "icmpv6 response time in seconds, only counts received pings"), 167 | &["reply"] 168 | ) 169 | .unwrap(); 170 | 171 | // client protection metrics 172 | 173 | /// renew cached hit 174 | pub static ref RENEW_CACHE_HIT: IntCounter = register_int_counter!("renew_cache_hit_count", "count of renew cache hits inside of renewal time").unwrap(); 175 | /// flood threshold reached 176 | pub static ref FLOOD_THRESHOLD_COUNT: IntCounter = register_int_counter!("flood_threshold_count", "count of times flood threshold has been reached").unwrap(); 177 | } 178 | -------------------------------------------------------------------------------- /libs/register_derive_impl/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::large_enum_variant)] 2 | extern crate proc_macro; 3 | 4 | use proc_macro2::{Span, TokenStream}; 5 | use quote::quote; 6 | use syn::{ 7 | DeriveInput, Ident, Token, 8 | parse::{Parse, ParseStream}, 9 | parse_macro_input, parse_quote, 10 | punctuated::Punctuated, 11 | spanned::Spanned, 12 | }; 13 | 14 | #[proc_macro_derive(Register, attributes(register))] 15 | pub fn register_macro(input: proc_macro::TokenStream) -> proc_macro::TokenStream { 16 | let input = parse_macro_input!(input as DeriveInput); 17 | 18 | proc_macro::TokenStream::from(impl_register(input)) 19 | } 20 | 21 | fn impl_register(input: DeriveInput) -> TokenStream { 22 | let name = &input.ident; 23 | let ident_str = name.to_string(); 24 | let attrs = &input.attrs; 25 | 26 | let registered = match attrs 27 | .iter() 28 | .filter(|attr| attr.path.is_ident("register") && !is_doc_attr(attr)) 29 | .map(|attr| attr.parse_args_with(RegisterVariant::parse)) 30 | .collect::, _>>() 31 | { 32 | Ok(msgs) => msgs, 33 | Err(err) => { 34 | // let message = "#[derive(IntoPrimitive)] and #[num_enum(u8)] requires a variant marked with `#[num_enum(catch_all)`"; 35 | return syn::Error::new(Span::call_site(), err).to_compile_error(); 36 | } 37 | }; 38 | // dbg!(®istered); 39 | let msg_types = registered.iter().filter_map(|reg| match reg { 40 | RegisterVariant::Msg(d) => Some(d.clone()), 41 | _ => None, 42 | }); 43 | 44 | let generics = add_trait_bounds(input.generics); 45 | let (impl_generics, ty_generics, where_clause) = generics.split_for_impl(); 46 | 47 | let reg = registered 48 | .iter() 49 | .filter_map(|reg| match reg { 50 | RegisterVariant::Plugin(d) => Some(d.clone()), 51 | _ => None, 52 | }) 53 | .collect::>(); 54 | let reg_len = reg.len(); 55 | let dependencies = reg 56 | .into_iter() 57 | .enumerate() 58 | .map(|(i, dep)| { 59 | let method: Ident = Ident::new("plugin", Span::call_site()); 60 | let dependencies = dep 61 | .dependencies 62 | .iter() 63 | .map(|path| { 64 | quote! { 65 | std::any::TypeId::of::<#path>() 66 | } 67 | }) 68 | .collect::>(); 69 | // we dont want to clone the last item 70 | let new_this = if i == reg_len - 1 { 71 | // `this` comes from the scope created in the dora_core::Register block 72 | // at the end of impl_register 73 | quote! { this } 74 | } else { 75 | quote! { Arc::clone(&this) } 76 | }; 77 | 78 | if dependencies.is_empty() { 79 | quote! { 80 | srv.#method::(#new_this); 81 | } 82 | } else { 83 | // only call the _order method if there are dependencies 84 | let method = Ident::new(&format!("{method}_order"), Span::call_site()); 85 | quote! { 86 | srv.#method::(#new_this, &[ #(#dependencies),* ]); 87 | } 88 | } 89 | }) 90 | .collect::>(); 91 | 92 | // println!("{:#?}", msg_params); 93 | // let msg_param = msg_params.msg_type; 94 | // let storage_param = msg_params.1; 95 | // println!("{:#?}", impl_generics); 96 | // println!("{:#?}", ty_generics); 97 | msg_types.into_iter().map(|msg_type| { 98 | let msg_param = msg_type.msg; 99 | quote! { 100 | #[automatically_derived] 101 | impl #impl_generics dora_core::Register<#msg_param> for #name #ty_generics #where_clause { 102 | fn register(self, srv: &mut dora_core::Server<#msg_param>) { 103 | info!("{} plugin registered", #ident_str); 104 | let this = std::sync::Arc::new(self); 105 | #(#dependencies)* 106 | } 107 | } 108 | } 109 | }).collect() 110 | } 111 | 112 | // Add a bound `T: Send + Sync + 'static` to every type parameter T. 113 | fn add_trait_bounds(mut generics: syn::Generics) -> syn::Generics { 114 | // add send + sync + 'static bounds 115 | for param in &mut generics.params { 116 | if let syn::GenericParam::Type(ref mut type_param) = *param { 117 | type_param.bounds.push(parse_quote!(Send)); 118 | type_param.bounds.push(parse_quote!(Sync)); 119 | type_param.bounds.push(parse_quote!('static)); 120 | } 121 | } 122 | generics 123 | } 124 | 125 | // Whether the attribute is one like `#[ ...]` 126 | fn is_matching_attr(name: &str, attr: &syn::Attribute) -> bool { 127 | attr.path.segments.len() == 1 && attr.path.segments[0].ident == name 128 | } 129 | 130 | /// Checks for `#[doc ...]`, which is generated by doc comments. 131 | fn is_doc_attr(attr: &syn::Attribute) -> bool { 132 | is_matching_attr("doc", attr) 133 | } 134 | 135 | #[derive(Debug, Clone)] 136 | struct Deps { 137 | _keyword: kw::plugin, 138 | // method: syn::Ident, 139 | dependencies: Punctuated, 140 | } 141 | 142 | impl syn::parse::Parse for Deps { 143 | fn parse(input: syn::parse::ParseStream) -> syn::Result { 144 | let content; 145 | let _keyword = input.parse()?; 146 | 147 | syn::parenthesized!(content in input); 148 | let deps = content.parse_terminated(syn::Type::parse)?; 149 | 150 | Ok(Self { 151 | _keyword, 152 | // method, 153 | dependencies: deps, 154 | }) 155 | } 156 | } 157 | impl Spanned for Deps { 158 | fn span(&self) -> Span { 159 | self._keyword.span() 160 | } 161 | } 162 | 163 | mod kw { 164 | syn::custom_keyword!(msg); 165 | syn::custom_keyword!(plugin); 166 | } 167 | 168 | #[derive(Debug, Clone)] 169 | enum RegisterVariant { 170 | Msg(MsgVariant), 171 | Plugin(Deps), 172 | } 173 | 174 | impl Parse for RegisterVariant { 175 | fn parse(input: ParseStream<'_>) -> syn::Result { 176 | let lookahead = input.lookahead1(); 177 | if lookahead.peek(kw::msg) { 178 | input.parse().map(Self::Msg) 179 | } else if lookahead.peek(kw::plugin) { 180 | input.parse().map(Self::Plugin) 181 | } else { 182 | Err(lookahead.error()) 183 | } 184 | } 185 | } 186 | 187 | #[derive(Debug, Clone)] 188 | struct MsgVariant { 189 | _keyword: kw::msg, 190 | msg: syn::Type, 191 | } 192 | 193 | impl Parse for MsgVariant { 194 | fn parse(input: ParseStream) -> syn::Result { 195 | let content; 196 | let _keyword = input.parse()?; 197 | syn::parenthesized!(content in input); 198 | let msg = content.parse()?; 199 | 200 | Ok(Self { _keyword, msg }) 201 | } 202 | } 203 | 204 | impl Spanned for MsgVariant { 205 | fn span(&self) -> Span { 206 | self._keyword.span() 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /libs/config/src/lib.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | env, 3 | path::{Path, PathBuf}, 4 | time::Duration, 5 | }; 6 | 7 | use anyhow::{Context, Result, bail}; 8 | use rand::{self, RngCore}; 9 | use serde::{Deserialize, Serialize}; 10 | use tracing::debug; 11 | use wire::v6::ServerDuidInfo; 12 | 13 | pub mod client_classes; 14 | pub mod v4; 15 | pub mod v6; 16 | pub mod wire; 17 | 18 | use dora_core::dhcproto::v6::duid::Duid; 19 | use dora_core::pnet::{ 20 | self, 21 | datalink::NetworkInterface, 22 | ipnetwork::{IpNetwork, Ipv4Network}, 23 | }; 24 | 25 | /// server config 26 | #[derive(Debug, Clone, PartialEq, Eq, Default)] 27 | pub struct DhcpConfig { 28 | v4: v4::Config, 29 | path: Option, 30 | } 31 | 32 | impl DhcpConfig { 33 | pub fn v4(&self) -> &v4::Config { 34 | &self.v4 35 | } 36 | pub fn has_v6(&self) -> bool { 37 | self.v4.v6().is_some() 38 | } 39 | pub fn v6(&self) -> &v6::Config { 40 | self.v4.v6().unwrap() // v6 existence checked before starting plugins 41 | } 42 | pub fn path(&self) -> Option<&Path> { 43 | self.path.as_deref() 44 | } 45 | } 46 | 47 | /// server instance config 48 | #[derive(Debug, Clone, PartialEq, Eq)] 49 | pub struct EnvConfig { 50 | pub customer_id: String, 51 | pub fleet_id: String, 52 | pub branch_id: String, 53 | pub dora_id: String, 54 | } 55 | 56 | impl EnvConfig { 57 | pub fn new() -> Result { 58 | Ok(Self { 59 | customer_id: env::var("CUSTOMER_ID")?, 60 | fleet_id: env::var("FLEET_ID")?, 61 | branch_id: env::var("BRANCH_ID")?, 62 | dora_id: env::var("DORA_ID")?, 63 | }) 64 | } 65 | } 66 | 67 | impl DhcpConfig { 68 | /// attempts to decode the config first as JSON, then YAML, finally erroring if neither work 69 | pub fn parse>(path: P) -> Result { 70 | let path = path.as_ref(); 71 | let config = v4::Config::new( 72 | std::fs::read_to_string(path) 73 | .with_context(|| format!("failed to find config at {}", &path.display()))?, 74 | )?; 75 | debug!(?config); 76 | 77 | Ok(Self { 78 | v4: config, 79 | path: Some(path.to_path_buf()), 80 | }) 81 | } 82 | /// attempts to decode the config first as JSON, then YAML, finally erroring if neither work 83 | pub fn parse_str>(s: S) -> Result { 84 | let config = v4::Config::new(s.as_ref())?; 85 | debug!(?config); 86 | 87 | Ok(Self { 88 | v4: config, 89 | path: None, 90 | }) 91 | } 92 | } 93 | 94 | /// find the first up non-loopback interface, if a name is provided it must also match 95 | pub fn backup_ivp4_interface(interface: Option<&str>) -> Result { 96 | let interface = pnet::datalink::interfaces().into_iter().find(|e| { 97 | e.is_up() 98 | && !e.is_loopback() 99 | && !e.ips.is_empty() 100 | && interface.map(|i| i == e.name).unwrap_or(true) 101 | }); 102 | 103 | debug!(?interface); 104 | 105 | let ips = interface 106 | .as_ref() 107 | .map(|int| &int.ips) 108 | .context("no interface found")?; 109 | let ipv4 = ips 110 | .iter() 111 | .find_map(|net| match net { 112 | IpNetwork::V4(net) => Some(*net), 113 | _ => None, 114 | }) 115 | .with_context(|| format!("no IPv4 interface {:?}", interface.clone()))?; 116 | 117 | Ok(ipv4) 118 | } 119 | 120 | /// Returns: 121 | /// - interfaces matching the list supplied that are 'up' and have an IPv4 122 | /// - OR any 'up' interfaces that also have an IPv4 123 | pub fn v4_find_interfaces(interfaces: Option>) -> Result> { 124 | let found_interfaces = pnet::datalink::interfaces() 125 | .into_iter() 126 | .filter(|e| e.is_up() && !e.ips.is_empty() && e.ips.iter().any(|i| i.is_ipv4())) 127 | .collect::>(); 128 | found_or_default(found_interfaces, interfaces) 129 | } 130 | 131 | /// Returns: 132 | /// - interfaces matching the list supplied that are 'up' and have an IPv6 133 | /// - OR any 'up' interfaces that also have an IPv6 134 | pub fn v6_find_interfaces(interfaces: Option>) -> Result> { 135 | let found_interfaces = pnet::datalink::interfaces() 136 | .into_iter() 137 | .filter(|e| e.is_up() && !e.ips.is_empty() && e.ips.iter().any(|i| i.is_ipv6())) 138 | .collect::>(); 139 | found_or_default(found_interfaces, interfaces) 140 | } 141 | 142 | fn found_or_default( 143 | found_interfaces: Vec, 144 | interfaces: Option>, 145 | ) -> Result> { 146 | Ok(match interfaces { 147 | Some(interfaces) => interfaces 148 | .iter() 149 | .map( 150 | |interface| match found_interfaces.iter().find(|i| &i.name == interface) { 151 | Some(i) => Ok(i.clone()), 152 | None => bail!("unable to find interface {}", interface), 153 | }, 154 | ) 155 | .collect::, _>>()?, 156 | None => found_interfaces, 157 | }) 158 | } 159 | 160 | #[derive(Debug, Copy, Clone, PartialEq, Eq)] 161 | pub struct LeaseTime { 162 | default: Duration, 163 | min: Duration, 164 | max: Duration, 165 | } 166 | 167 | impl LeaseTime { 168 | pub fn new(default: Duration, min: Duration, max: Duration) -> Self { 169 | Self { default, min, max } 170 | } 171 | pub fn get_default(&self) -> Duration { 172 | self.default 173 | } 174 | pub fn get_min(&self) -> Duration { 175 | self.min 176 | } 177 | pub fn get_max(&self) -> Duration { 178 | self.max 179 | } 180 | /// calculate the lease time based on a possible requested time 181 | pub fn determine_lease(&self, requested: Option) -> (Duration, Duration, Duration) { 182 | let LeaseTime { default, min, max } = *self; 183 | match requested { 184 | // time must be larger than `min` and smaller than `max` 185 | Some(req) => { 186 | let t = req.clamp(min, max); 187 | (t, renew(t), rebind(t)) 188 | } 189 | None => (default, renew(default), rebind(default)), 190 | } 191 | } 192 | } 193 | 194 | pub fn renew(t: Duration) -> Duration { 195 | t / 2 196 | } 197 | 198 | pub fn rebind(t: Duration) -> Duration { 199 | t * 7 / 8 200 | } 201 | 202 | pub fn generate_random_bytes(len: usize) -> Vec { 203 | let mut ident = Vec::with_capacity(len); 204 | rand::thread_rng().fill_bytes(&mut ident); 205 | ident 206 | } 207 | 208 | #[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone)] 209 | pub struct PersistIdentifier { 210 | pub identifier: String, 211 | pub duid_config: ServerDuidInfo, 212 | } 213 | 214 | impl PersistIdentifier { 215 | pub fn to_json(&self, path: &Path) -> Result<()> { 216 | let file = std::fs::File::create(path)?; 217 | serde_json::to_writer_pretty(file, self)?; 218 | Ok(()) 219 | } 220 | 221 | pub fn from_json(path: &Path) -> Result { 222 | let file = std::fs::File::open(path)?; 223 | Ok(serde_json::from_reader(file)?) 224 | } 225 | 226 | pub fn duid(&self) -> Result { 227 | let duid_bytes = hex::decode(&self.identifier) 228 | .context("server identifier should be a valid hex string")?; 229 | Ok(Duid::from(duid_bytes)) 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /dora-core/src/config.rs: -------------------------------------------------------------------------------- 1 | //! dhcp server configs 2 | 3 | pub mod cli { 4 | //! Parse from either cli or env var 5 | 6 | /// default dhcpv6 multicast group 7 | pub static ALL_DHCP_RELAY_AGENTS_AND_SERVERS: Ipv6Addr = 8 | Ipv6Addr::new(0xff02, 0, 0, 0, 0, 0, 1, 2); 9 | // pub static ALL_ROUTERS_MULTICAST: &str = "[ff01::2]"; 10 | // pub static ALL_NODES_MULTICAST: &str = "[ff01::1]"; 11 | /// Default dhcpv4 addr 12 | pub static DEFAULT_V4_ADDR: &str = "0.0.0.0:67"; // default dhcpv4 port is 67 13 | /// Default dhcpv6 addr 14 | pub static DEFAULT_V6_ADDR: &str = "[::]:547"; // default dhcpv6 port is 547 15 | /// Default external api 16 | pub static DEFAULT_EXTERNAL_API: &str = "[::]:3333"; 17 | /// Default channel size for mpsc chans 18 | pub const DEFAULT_CHANNEL_SIZE: usize = 10_000; 19 | /// Max live messages -- Changing this value will effect memory 20 | /// usage in dora. The more live messages we hold onto the more memory will be 21 | /// used. At some point, the timeout will be hit and setting the live msg count 22 | /// higher will not affect % of timeouts 23 | pub const DEFAULT_MAX_LIVE_MSGS: usize = 1_000; 24 | /// Default timeout, we must respond within this window or we will time out 25 | pub const DEFAULT_TIMEOUT: u64 = 3; 26 | /// tokio worker thread name 27 | pub static DEFAULT_THREAD_NAME: &str = "dora-dhcp-worker"; 28 | /// the default path to config 29 | pub static DEFAULT_CONFIG_PATH: &str = "/var/lib/dora/config.yaml"; 30 | /// update default polling interval 31 | pub const DEFAULT_POLL: u64 = 60; 32 | /// default leases file path 33 | pub const DEFAULT_DATABASE_URL: &str = "/var/lib/dora/leases.db"; 34 | /// default dora id 35 | pub const DEFAULT_DORA_ID: &str = "dora_id"; 36 | /// default log level. Can use this argument or DORA_LOG env var 37 | pub const DEFAULT_DORA_LOG: &str = "info"; 38 | 39 | use std::{ 40 | net::{Ipv6Addr, SocketAddr}, 41 | path::PathBuf, 42 | time::Duration, 43 | }; 44 | 45 | pub use clap::Parser; 46 | use dhcproto::{v4, v6}; 47 | 48 | #[derive(Parser, Debug, Clone, PartialEq, Eq)] 49 | #[clap(author, name = "dora", bin_name = "dora", about, long_about = None)] 50 | /// parses from cli & environment var. dora will load `.env` in the same dir as the binary as well 51 | pub struct Config { 52 | /// path to dora's config 53 | #[clap( 54 | short, 55 | long, 56 | value_parser, 57 | env, 58 | default_value = DEFAULT_CONFIG_PATH 59 | )] 60 | pub config_path: PathBuf, 61 | /// the v4 address to listen on 62 | #[clap(long, env, value_parser, default_value = DEFAULT_V4_ADDR)] 63 | pub v4_addr: SocketAddr, 64 | /// the v6 address to listen on 65 | #[clap(long, env, value_parser, default_value = DEFAULT_V6_ADDR)] 66 | pub v6_addr: SocketAddr, 67 | /// the v6 address to listen on 68 | #[clap(long, env, value_parser, default_value = DEFAULT_EXTERNAL_API)] 69 | pub external_api: SocketAddr, 70 | /// default timeout, dora will respond within this window or drop 71 | #[clap(long, env, value_parser, default_value_t = DEFAULT_TIMEOUT)] 72 | pub timeout: u64, 73 | /// max live messages before new messages will begin to be dropped 74 | #[clap(long, env, value_parser, default_value_t = DEFAULT_MAX_LIVE_MSGS)] 75 | pub max_live_msgs: usize, 76 | /// channel size for various mpsc chans 77 | #[clap(long, env, value_parser, default_value_t = DEFAULT_CHANNEL_SIZE)] 78 | pub channel_size: usize, 79 | /// How many threads are spawned, default is the # of logical CPU cores 80 | #[clap(long, env, value_parser)] 81 | pub threads: Option, 82 | /// Worker thread name 83 | #[clap(long, env, value_parser, default_value = DEFAULT_THREAD_NAME)] 84 | pub thread_name: String, 85 | /// ID of this instance 86 | #[clap(long, env, value_parser, default_value = DEFAULT_DORA_ID)] 87 | pub dora_id: String, 88 | /// set the log level. All valid RUST_LOG arguments are accepted 89 | #[clap(long, env, value_parser, default_value = DEFAULT_DORA_LOG)] 90 | pub dora_log: String, 91 | /// Path to the database use "sqlite::memory:" for in mem db ex. "em.db" 92 | /// NOTE: in memory sqlite db connection idle timeout is 5 mins 93 | #[clap(short, env, value_parser, default_value = DEFAULT_DATABASE_URL)] 94 | pub database_url: String, 95 | } 96 | 97 | impl Config { 98 | /// Create new timeout as `Duration` 99 | pub fn timeout(&self) -> Duration { 100 | Duration::from_secs(self.timeout) 101 | } 102 | 103 | /// are we bound to the default dhcpv4 port? 104 | pub fn is_default_port_v4(&self) -> bool { 105 | self.v4_addr.port() == v4::SERVER_PORT 106 | } 107 | 108 | /// are we bound to the default dhcpv6 port? 109 | pub fn is_default_port_v6(&self) -> bool { 110 | self.v6_addr.port() == v6::SERVER_PORT 111 | } 112 | } 113 | } 114 | 115 | pub mod trace { 116 | //! tracing configuration 117 | use anyhow::Result; 118 | use tracing_subscriber::{ 119 | filter::EnvFilter, 120 | fmt::{ 121 | self, 122 | format::{Format, PrettyFields}, 123 | }, 124 | prelude::__tracing_subscriber_SubscriberExt, 125 | util::SubscriberInitExt, 126 | }; 127 | 128 | use std::str; 129 | 130 | use crate::env::parse_var_with_err; 131 | 132 | /// log as "json" or "standard" (unstructured) 133 | static DEFAULT_LOG_FORMAT: &str = "standard"; 134 | 135 | /// Configuration for `tokio` runtime 136 | #[derive(Debug)] 137 | pub struct Config { 138 | /// formatting to apply to logs 139 | pub log_frmt: String, 140 | } 141 | 142 | impl Config { 143 | /// Make new runtime config 144 | pub fn parse(dora_log: &str) -> Result { 145 | let log_frmt: String = parse_var_with_err("LOG_FORMAT", DEFAULT_LOG_FORMAT)?; 146 | 147 | // Log level comes from DORA_LOG 148 | let filter = EnvFilter::try_new(dora_log) 149 | .or_else(|_| EnvFilter::try_new("info"))? 150 | .add_directive("hyper=off".parse()?); 151 | 152 | match &log_frmt[..] { 153 | "json" => { 154 | tracing_subscriber::registry() 155 | .with(filter) 156 | .with(fmt::layer().json()) 157 | .init(); 158 | } 159 | "pretty" => { 160 | tracing_subscriber::registry() 161 | .with(filter) 162 | .with( 163 | fmt::layer() 164 | .event_format( 165 | Format::default().pretty().with_source_location(false), 166 | ) 167 | .fmt_fields(PrettyFields::new()), 168 | ) 169 | .init(); 170 | } 171 | _ => { 172 | tracing_subscriber::registry() 173 | .with(filter) 174 | .with(fmt::layer()) 175 | .init(); 176 | } 177 | } 178 | 179 | Ok(Self { log_frmt }) 180 | } 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /bin/tests/common/client.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fmt::{self, Debug}, 3 | marker::PhantomData, 4 | net::{IpAddr, SocketAddr, UdpSocket}, 5 | os::unix::prelude::{FromRawFd, IntoRawFd}, 6 | sync::Arc, 7 | thread, 8 | time::{Duration, Instant}, 9 | }; 10 | 11 | use anyhow::{Context, Result}; 12 | use chan::{Receiver, Sender}; 13 | use crossbeam_channel as chan; 14 | use dora_core::tracing::{debug, error, trace, warn}; 15 | 16 | use dora_core::dhcproto::{ 17 | decoder::{Decodable, Decoder}, 18 | encoder::Encodable, 19 | v4, v6, 20 | }; 21 | 22 | use super::builder::{ClientSettings, MsgType}; 23 | 24 | #[derive(Debug)] 25 | pub struct Client { 26 | args: ClientSettings, 27 | // zero sized value, the type parameter is just so 28 | // a client knows if it can return a v4 or v6 message type 29 | _marker: PhantomData, 30 | } 31 | 32 | impl Client { 33 | pub fn new(args: ClientSettings) -> Self { 34 | Self { 35 | args, 36 | _marker: PhantomData, 37 | } 38 | } 39 | 40 | fn spawn_send(&self, msg_type: MsgType, retry_rx: Receiver<()>, send: Arc) { 41 | thread::spawn({ 42 | let args = self.args.clone(); 43 | let send_count = args.send_retries; 44 | move || { 45 | let mut count = 0; 46 | while retry_rx.recv().is_ok() { 47 | if let Err(err) = try_send(&args, &msg_type, &send) { 48 | error!(?err, "error sending"); 49 | } 50 | count += 1; 51 | } 52 | if count >= send_count { 53 | warn!("max retries-- exiting"); 54 | } 55 | Ok::<_, anyhow::Error>(()) 56 | } 57 | }); 58 | } 59 | 60 | fn spawn_recv( 61 | &self, 62 | tx: Sender, 63 | recv: Arc, 64 | ) { 65 | thread::spawn({ 66 | let args = self.args.clone(); 67 | move || { 68 | if let Err(err) = try_recv::(&args, &tx, &recv) { 69 | error!(?err, "could not receive"); 70 | } 71 | Ok::<_, anyhow::Error>(()) 72 | } 73 | }); 74 | } 75 | 76 | fn send_recv( 77 | &mut self, 78 | msg_type: MsgType, 79 | ) -> Result { 80 | let start = Instant::now(); 81 | // TODO: make not just v4 sockets 82 | let bind_addr: SocketAddr = "0.0.0.0:0".parse()?; 83 | let socket = socket2::Socket::new(socket2::Domain::IPV4, socket2::Type::DGRAM, None)?; 84 | debug!("client socket created"); 85 | 86 | // SO_BINDTODEVICE 87 | if let Some(ref iface) = self.args.iface_name { 88 | socket 89 | .bind_device(Some(iface.as_bytes())) 90 | .context("failed to bind interface")?; 91 | debug!(?iface, "client socket bound to"); 92 | } 93 | socket.bind(&bind_addr.into())?; 94 | debug!(?bind_addr, "client socket bound to"); 95 | let send = Arc::new(unsafe { UdpSocket::from_raw_fd(socket.into_raw_fd()) }); 96 | let recv = Arc::clone(&send); 97 | 98 | // this channel is for receiving a decoded v4/v6 message 99 | let (tx, rx) = chan::bounded::(1); 100 | // this is for controlling when we send so we're able to retry 101 | let (retry_tx, retry_rx) = chan::bounded(1); 102 | self.spawn_recv(tx, recv); 103 | self.spawn_send(msg_type, retry_rx, send); 104 | 105 | let timeout = chan::tick(Duration::from_millis(self.args.timeout)); 106 | 107 | retry_tx.send(()).expect("retry channel send failed"); 108 | 109 | let mut count = 0; 110 | while count < self.args.send_retries { 111 | chan::select! { 112 | recv(rx) -> res => { 113 | match res { 114 | Ok(msg) => { 115 | return Ok(msg); 116 | } 117 | Err(err) => { 118 | error!(?err, "channel returned error"); 119 | break; 120 | } 121 | } 122 | } 123 | recv(timeout) -> _ => { 124 | debug!(elapsed = %PrettyDuration(start.elapsed()), "received timeout-- retrying"); 125 | count += 1; 126 | retry_tx.send(()).expect("retry channel send failed"); 127 | continue; 128 | } 129 | } 130 | } 131 | drop(retry_tx); 132 | 133 | Err(anyhow::anyhow!( 134 | "hit max retries-- failed to get a response" 135 | )) 136 | } 137 | } 138 | 139 | // Specialized in case `run` needs to print different output for v4/v6 140 | impl Client { 141 | pub fn run(&mut self, msg_type: MsgType) -> Result { 142 | let msg = self.send_recv::(msg_type)?; 143 | debug!(msg_type = ?msg.opts().msg_type(), %msg, "decoded"); 144 | Ok(msg) 145 | } 146 | } 147 | 148 | impl Client { 149 | pub fn run(&mut self, msg_type: MsgType) -> Result { 150 | let msg = self.send_recv::(msg_type)?; 151 | debug!(msg_type = ?msg.msg_type(), ?msg, "decoded"); 152 | Ok(msg) 153 | } 154 | } 155 | 156 | fn try_recv( 157 | args: &ClientSettings, 158 | tx: &Sender, 159 | recv: &Arc, 160 | ) -> Result<()> { 161 | let mut buf = vec![0; 1024]; 162 | let (len, _addr) = recv.recv_from(&mut buf)?; 163 | let msg = M::decode(&mut Decoder::new(&buf[..len]))?; 164 | tx.send_timeout(msg, Duration::from_secs(1))?; 165 | 166 | Ok(()) 167 | } 168 | 169 | fn try_send(args: &ClientSettings, msg_type: &MsgType, send: &Arc) -> Result<()> { 170 | let mut broadcast = false; 171 | let target: SocketAddr = match args.target { 172 | IpAddr::V4(addr) if addr.is_broadcast() => { 173 | send.set_broadcast(true)?; 174 | broadcast = true; 175 | (args.target, args.port).into() 176 | } 177 | IpAddr::V4(addr) => (addr, args.port).into(), 178 | // TODO: IPv6 179 | IpAddr::V6(addr) if addr.is_multicast() => { 180 | send.join_multicast_v6(&addr, 0)?; 181 | (addr, args.port).into() 182 | } 183 | IpAddr::V6(addr) => (IpAddr::V6(addr), args.port).into(), 184 | }; 185 | 186 | let msg = match msg_type { 187 | MsgType::Discover(args) => args.build(broadcast), 188 | MsgType::Request(args) => args.build(), 189 | MsgType::Decline(args) => args.build(), 190 | MsgType::BootP(args) => args.build(broadcast), 191 | }; 192 | 193 | debug!(msg_type = ?msg.opts().msg_type(), ?target, ?msg, "sending msg"); 194 | 195 | let res = send.send_to(&msg.to_vec()?[..], target)?; 196 | trace!(?res, "sent"); 197 | Ok(()) 198 | } 199 | 200 | #[derive(Copy, Clone, Default, PartialEq, Eq, PartialOrd, Ord)] 201 | pub struct PrettyDuration(Duration); 202 | 203 | impl fmt::Display for PrettyDuration { 204 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 205 | write!(f, "{}s", &self.0.as_secs_f32().to_string()[0..=4]) 206 | } 207 | } 208 | 209 | #[derive(Copy, Clone, Default, PartialEq, Eq, PartialOrd, Ord)] 210 | pub struct PrettyPrint(T); 211 | 212 | impl fmt::Display for PrettyPrint { 213 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 214 | write!(f, "{:#?}", &self.0) 215 | } 216 | } 217 | 218 | #[derive(Clone, Debug)] 219 | pub enum Response { 220 | V4(v4::Message), 221 | V6(v6::Message), 222 | } 223 | -------------------------------------------------------------------------------- /libs/client-protection/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! Client protection 2 | //! 3 | //! 4 | use config::v4::FloodThreshold; 5 | // TODO: consider switching both to Mutex>. 6 | // the caches are all locked immediately and written to, so dashmap is probably overkill 7 | // (governor uses dashmap internally by default by we can turn off the "dashmap" feature) 8 | use dashmap::DashMap; 9 | use governor::{Quota, RateLimiter, clock::DefaultClock, state::keyed::DefaultKeyedStateStore}; 10 | use tracing::{debug, trace}; 11 | 12 | use std::{ 13 | borrow::Borrow, 14 | fmt, 15 | hash::Hash, 16 | num::NonZeroU32, 17 | time::{Duration, Instant}, 18 | }; 19 | 20 | pub struct RenewThreshold { 21 | percentage: u64, 22 | cache: DashMap, 23 | } 24 | 25 | #[derive(Copy, Clone, Debug, PartialEq, Eq)] 26 | pub struct RenewExpiry { 27 | // when entry was created 28 | pub created: Instant, 29 | // % * lease_time 30 | pub percentage: Duration, 31 | // full lease time 32 | pub lease_time: Duration, 33 | } 34 | 35 | impl RenewExpiry { 36 | /// if the elapsed time is less than the fraction of lease time configured 37 | /// return the lease time remaining 38 | pub fn get_remaining(&self) -> Option { 39 | if self.created.elapsed() <= self.percentage { 40 | Some(self.lease_time - self.created.elapsed()) 41 | } else { 42 | None 43 | } 44 | } 45 | } 46 | 47 | impl RenewExpiry { 48 | pub fn new(now: Instant, lease_time: Duration, percentage: u64) -> Self { 49 | Self { 50 | percentage: Duration::from_secs((lease_time.as_secs() * percentage) / 100), 51 | created: now, 52 | lease_time, 53 | } 54 | } 55 | } 56 | 57 | impl RenewThreshold { 58 | pub fn new(percentage: u32) -> Self { 59 | Self { 60 | percentage: percentage as u64, 61 | cache: DashMap::new(), 62 | } 63 | } 64 | // insert id into cache with lease time, replacing existing entry 65 | pub fn insert(&self, id: K, lease_time: Duration) -> Option { 66 | let now = Instant::now(); 67 | self.cache 68 | .insert(id, RenewExpiry::new(now, lease_time, self.percentage)) 69 | } 70 | // test if threshold has been met for a given id 71 | pub fn threshold(&self, id: &Q) -> Option 72 | where 73 | K: Borrow, 74 | Q: Eq + Hash + ?Sized, 75 | { 76 | self.cache 77 | .get(id) 78 | .map(|e| *e) 79 | .and_then(|entry| entry.get_remaining()) 80 | } 81 | pub fn remove(&self, id: &K) -> Option<(K, RenewExpiry)> { 82 | self.cache.remove(id) 83 | } 84 | } 85 | 86 | pub struct FloodCache { 87 | rl: RateLimiter, DefaultClock>, 88 | } 89 | 90 | impl FloodCache 91 | where 92 | K: Eq + Hash + Clone + fmt::Debug, 93 | { 94 | pub fn new(cfg: FloodThreshold) -> Self { 95 | debug!( 96 | packets = cfg.packets(), 97 | period = cfg.period().as_secs(), 98 | "creating flood cache with following settings" 99 | ); 100 | // let rate = cfg.packets() / cfg.period().as_secs() as u32; 101 | // debug!("creating flood cache threshold {:?} packets/sec", rate); 102 | 103 | Self { 104 | #[allow(deprecated)] 105 | rl: RateLimiter::keyed( 106 | Quota::new( 107 | NonZeroU32::new(cfg.packets()).expect("conversion will not fail"), 108 | cfg.period(), 109 | ) 110 | .expect("don't pass Duration of 0"), 111 | ), 112 | } 113 | } 114 | pub fn is_allowed(&self, id: &K) -> bool { 115 | let res = self.rl.check_key(id); 116 | if let Err(not_until) = &res { 117 | trace!(?not_until, ?id, "reached threshold for client") 118 | } 119 | res.is_ok() 120 | } 121 | } 122 | 123 | #[cfg(test)] 124 | mod tests { 125 | use super::*; 126 | 127 | #[test] 128 | fn test_flood_threshold_packets() { 129 | let cache = FloodCache::new(FloodThreshold::new(2, Duration::from_secs(1))); 130 | assert!(cache.is_allowed(&[1, 2, 3, 4])); 131 | assert!(cache.is_allowed(&[1, 2, 3, 4])); 132 | 133 | // too many packets 134 | assert!(!cache.is_allowed(&[1, 2, 3, 4])); 135 | 136 | // wait for duration 137 | std::thread::sleep(Duration::from_millis(1_100)); 138 | // should be true now 139 | assert!(cache.is_allowed(&[1, 2, 3, 4])); 140 | assert!(cache.is_allowed(&[1, 2, 3, 4])); 141 | } 142 | 143 | #[test] 144 | fn test_flood_threshold_large_period() { 145 | let cache = FloodCache::new(FloodThreshold::new(2, Duration::from_secs(5))); 146 | assert!(cache.is_allowed(&[1, 2, 3, 4])); 147 | assert!(cache.is_allowed(&[1, 2, 3, 4])); 148 | 149 | // // too many packets 150 | // assert!(!cache.is_allowed(&[1, 2, 3, 4])); 151 | 152 | // // wait for duration 153 | // std::thread::sleep(Duration::from_millis(1_100)); 154 | // // should be true now 155 | // assert!(cache.is_allowed(&[1, 2, 3, 4])); 156 | // assert!(cache.is_allowed(&[1, 2, 3, 4])); 157 | } 158 | 159 | #[test] 160 | fn test_flood_threshold_multi() { 161 | let cache = FloodCache::new(FloodThreshold::new(2, Duration::from_secs(1))); 162 | assert!(cache.is_allowed(&[1, 2, 3, 4])); 163 | assert!(cache.is_allowed(&[1, 2, 3, 4])); 164 | assert!(!cache.is_allowed(&[1, 2, 3, 4])); 165 | 166 | // another client, independent threshold 167 | assert!(cache.is_allowed(&[4, 3, 2, 1])); 168 | assert!(cache.is_allowed(&[4, 3, 2, 1])); 169 | assert!(!cache.is_allowed(&[4, 3, 2, 1])); 170 | } 171 | 172 | #[test] 173 | fn test_renew_remaining() { 174 | let renew = RenewExpiry::new(Instant::now(), Duration::from_secs(5), 50); 175 | std::thread::sleep(Duration::from_secs(1)); 176 | assert_eq!( 177 | renew 178 | .get_remaining() 179 | .unwrap() 180 | .as_secs_f32() 181 | // round up 182 | .round(), 183 | 4. 184 | ); 185 | std::thread::sleep(Duration::from_secs(5)); 186 | assert!(renew.get_remaining().is_none()); 187 | } 188 | 189 | #[test] 190 | fn test_cache_threshold() { 191 | let cache = RenewThreshold::new(50); 192 | let lease_time = Duration::from_secs(2); 193 | let lease_time_b = Duration::from_secs(6); 194 | assert!(cache.insert([1, 2, 3, 4], lease_time).is_none()); 195 | 196 | // another client, independent threshold 197 | assert!(cache.insert([4, 3, 2, 1], lease_time_b).is_none()); 198 | 199 | // half of lease time passes 200 | std::thread::sleep(Duration::from_secs(1)); 201 | 202 | assert!(cache.threshold(&[1, 2, 3, 4]).is_none()); 203 | assert!(cache.threshold(&[1, 2, 3, 4]).is_none()); 204 | assert_eq!( 205 | cache 206 | .threshold(&[4, 3, 2, 1]) 207 | .unwrap() 208 | .as_secs_f32() 209 | .round(), 210 | 5. 211 | ); 212 | 213 | std::thread::sleep(Duration::from_secs(1)); 214 | assert_eq!( 215 | cache 216 | .threshold(&[4, 3, 2, 1]) 217 | .unwrap() 218 | .as_secs_f32() 219 | .round(), 220 | 4. 221 | ); 222 | 223 | std::thread::sleep(Duration::from_secs(2)); 224 | assert!(cache.threshold(&[4, 3, 2, 1]).is_none()); 225 | } 226 | 227 | #[test] 228 | fn test_cache_renew_0() { 229 | // threshold set to 0 means the cache will never return a cached lease 230 | let cache = RenewThreshold::new(0); 231 | let lease_time = Duration::from_secs(2); 232 | let lease_time_b = Duration::from_secs(6); 233 | assert!(cache.insert([1, 2, 3, 4], lease_time).is_none()); 234 | 235 | // another client, independent threshold 236 | assert!(cache.insert([4, 3, 2, 1], lease_time_b).is_none()); 237 | 238 | // half of lease time passes 239 | std::thread::sleep(Duration::from_secs(1)); 240 | 241 | assert!(cache.threshold(&[1, 2, 3, 4]).is_none()); 242 | assert!(cache.threshold(&[4, 3, 2, 1]).is_none()); 243 | std::thread::sleep(Duration::from_secs(3)); 244 | assert!(cache.threshold(&[4, 3, 2, 1]).is_none()); 245 | } 246 | } 247 | -------------------------------------------------------------------------------- /plugins/static-addr/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![warn( 2 | missing_debug_implementations, 3 | // missing_docs, // we shall remove thee, someday! 4 | rust_2018_idioms, 5 | unreachable_pub, 6 | non_snake_case, 7 | non_upper_case_globals 8 | )] 9 | #![deny(rustdoc::broken_intra_doc_links)] 10 | #![allow(clippy::cognitive_complexity)] 11 | 12 | use dora_core::{ 13 | dhcproto::v4::{Message, MessageType}, 14 | prelude::*, 15 | }; 16 | use register_derive::Register; 17 | 18 | use config::{DhcpConfig, v4::Reserved}; 19 | use message_type::{MatchedClasses, MsgType}; 20 | 21 | #[derive(Debug, Register)] 22 | #[register(msg(Message))] 23 | #[register(plugin(MsgType))] 24 | pub struct StaticAddr { 25 | cfg: Arc, 26 | } 27 | 28 | impl StaticAddr { 29 | pub fn new(cfg: Arc) -> Result { 30 | Ok(Self { cfg }) 31 | } 32 | } 33 | 34 | #[async_trait] 35 | impl Plugin for StaticAddr { 36 | #[instrument(level = "debug", skip_all)] 37 | async fn handle(&self, ctx: &mut MsgContext) -> Result { 38 | let req = ctx.msg(); 39 | let chaddr = req.chaddr().to_vec(); 40 | 41 | let subnet = ctx.subnet()?; 42 | 43 | // matched classes clone necessary because of ctx borrowck 44 | let classes = ctx.get_local::().map(|m| m.0.to_owned()); 45 | let classes = classes.as_deref(); 46 | if let Some(net) = self.cfg.v4().network(subnet) { 47 | // determine if we have a reservation based on mac 48 | if chaddr.len() == 6 { 49 | let mac = MacAddr::new( 50 | chaddr[0], chaddr[1], chaddr[2], chaddr[3], chaddr[4], chaddr[5], 51 | ); 52 | let bootp = self.cfg.v4().bootp_enabled(); 53 | if let Some(res) = net.get_reserved_mac(mac, classes) { 54 | // mac is present in our config 55 | return match req.opts().msg_type() { 56 | Some(MessageType::Discover) => self.discover(ctx, &chaddr, classes, res), 57 | Some(MessageType::Request) => self.request(ctx, &chaddr, classes, res), 58 | // no message type, but BOOTP enabled 59 | None if bootp => self.bootp(ctx, &chaddr, classes, res), 60 | // we have a reservation, but we didn't et a DISCOVER or REQUEST 61 | // drop the message 62 | _ => Ok(Action::NoResponse), 63 | }; 64 | } 65 | } 66 | 67 | // determine if we have a reservation based on opt 68 | if let Some(res) = net.search_reserved_opt(req.opts(), classes) { 69 | // matching opt is present in our config 70 | return match req.opts().msg_type().context("no message type found")? { 71 | MessageType::Discover => self.discover(ctx, &chaddr, classes, res), 72 | MessageType::Request => self.request(ctx, &chaddr, classes, res), 73 | // we have a reservation, but we didn't et a DISCOVER or REQUEST 74 | // drop the message 75 | _ => Ok(Action::NoResponse), 76 | }; 77 | } 78 | } 79 | Ok(Action::Continue) 80 | } 81 | } 82 | 83 | impl StaticAddr { 84 | fn discover( 85 | &self, 86 | ctx: &mut MsgContext, 87 | chaddr: &[u8], 88 | classes: Option<&[String]>, 89 | res: &Reserved, 90 | ) -> Result { 91 | let static_ip = res.ip(); 92 | let (lease, t1, t2) = res.lease().determine_lease(ctx.requested_lease_time()); 93 | debug!(?static_ip, ?chaddr, "use static requested ip"); 94 | ctx.resp_msg_mut() 95 | .context("response message must be set before static is run")? 96 | .set_yiaddr(static_ip); 97 | ctx.populate_opts_lease( 98 | &self.cfg.v4().collect_opts(res.opts(), classes), 99 | lease, 100 | t1, 101 | t2, 102 | ); 103 | Ok(Action::Continue) 104 | } 105 | 106 | /// populate BOOTP response. Some clients only accept messages with a min size of 300 bytes, 107 | /// that is not handled here. We would need to insert PAD opts until the byte size is reached. 108 | fn bootp( 109 | &self, 110 | ctx: &mut MsgContext, 111 | chaddr: &[u8], 112 | classes: Option<&[String]>, 113 | res: &Reserved, 114 | ) -> Result { 115 | let static_ip = res.ip(); 116 | debug!(?static_ip, ?chaddr, "BOOTREPLY using static ip"); 117 | ctx.resp_msg_mut() 118 | .context("response message must be set before static is run")? 119 | .set_yiaddr(static_ip); 120 | // populate opts with no lease time info 121 | ctx.populate_opts(&self.cfg.v4().collect_opts(res.opts(), classes)); 122 | // remove options that aren't allowed in a BOOTP response 123 | ctx.filter_dhcp_opts(); 124 | Ok(Action::Respond) 125 | } 126 | 127 | fn request( 128 | &self, 129 | ctx: &mut MsgContext, 130 | chaddr: &[u8], 131 | classes: Option<&[String]>, 132 | res: &Reserved, 133 | ) -> Result { 134 | let static_ip = res.ip(); 135 | // requested ip comes from opts or ciaddr 136 | let ip = if let Some(ip) = ctx.requested_ip() { 137 | ip 138 | } else { 139 | ctx.update_resp_msg(MessageType::Nak) 140 | .context("failed to set msg type")?; 141 | return Ok(Action::Respond); 142 | }; 143 | 144 | if ip != static_ip { 145 | debug!( 146 | ?chaddr, 147 | ?ip, 148 | ?static_ip, 149 | "configured static ip does not match" 150 | ); 151 | ctx.update_resp_msg(MessageType::Nak) 152 | .context("failed to set msg type")?; 153 | return Ok(Action::Respond); 154 | } 155 | 156 | let (lease, t1, t2) = res.lease().determine_lease(ctx.requested_lease_time()); 157 | ctx.resp_msg_mut() 158 | .context("response message must be set before static plugin is run")? 159 | .set_yiaddr(ip); 160 | ctx.populate_opts_lease( 161 | &self.cfg.v4().collect_opts(res.opts(), classes), 162 | lease, 163 | t1, 164 | t2, 165 | ); 166 | trace!(?ip, "populating response with static ip"); 167 | 168 | Ok(Action::Continue) 169 | } 170 | } 171 | 172 | #[cfg(test)] 173 | mod tests { 174 | use std::net::Ipv4Addr; 175 | 176 | use dora_core::dhcproto::v4; 177 | use tracing_test::traced_test; 178 | 179 | use super::*; 180 | use message_type::util; 181 | 182 | static SAMPLE_YAML: &str = include_str!("../../../libs/config/sample/config.yaml"); 183 | 184 | #[tokio::test] 185 | #[traced_test] 186 | async fn test_discover() -> Result<()> { 187 | let cfg = DhcpConfig::parse_str(SAMPLE_YAML).unwrap(); 188 | let plugin = StaticAddr::new(Arc::new(cfg.clone()))?; 189 | let mut ctx = util::blank_ctx( 190 | "192.168.0.1:67".parse()?, 191 | "192.168.0.1".parse()?, 192 | "192.168.0.1".parse()?, 193 | v4::MessageType::Discover, 194 | )?; 195 | ctx.msg_mut().set_chaddr(&hex::decode(b"aabbccddeeff")?); 196 | plugin.handle(&mut ctx).await?; 197 | 198 | assert_eq!( 199 | ctx.resp_msg().unwrap().yiaddr(), 200 | Ipv4Addr::new(192, 168, 0, 170) 201 | ); 202 | Ok(()) 203 | } 204 | 205 | #[tokio::test] 206 | #[traced_test] 207 | async fn test_request() -> Result<()> { 208 | let cfg = DhcpConfig::parse_str(SAMPLE_YAML).unwrap(); 209 | let plugin = StaticAddr::new(Arc::new(cfg.clone()))?; 210 | let mut ctx = util::blank_ctx( 211 | "192.168.0.1:67".parse()?, 212 | "192.168.0.1".parse()?, 213 | "192.168.0.1".parse()?, 214 | v4::MessageType::Request, 215 | )?; 216 | ctx.msg_mut().set_chaddr(&hex::decode(b"aabbccddeeff")?); 217 | ctx.msg_mut() 218 | .opts_mut() 219 | .insert(v4::DhcpOption::RequestedIpAddress(Ipv4Addr::new( 220 | 192, 168, 0, 170, 221 | ))); 222 | plugin.handle(&mut ctx).await?; 223 | 224 | assert_eq!( 225 | ctx.resp_msg().unwrap().yiaddr(), 226 | Ipv4Addr::new(192, 168, 0, 170) 227 | ); 228 | Ok(()) 229 | } 230 | } 231 | --------------------------------------------------------------------------------