diff --git a/ebb.toml b/ebb.toml new file mode 100644 index 0000000..c903a57 --- /dev/null +++ b/ebb.toml @@ -0,0 +1,20 @@ +# ebb configuration. +# +# ebb looks for this file at $EBB_CONFIG, ./ebb.toml, then /etc/ebb/ebb.toml, +# and refuses to start without it. +# +# Features are enabled by presence: a feature runs iff its section exists. +# When a section is present, all of its keys are required. + +[dhcp] +# If the ERTS user doesn't have privileges to bind to port 67, this will silently fail. +listen_port = 67 + +# IPv4 CIDR of the managed subnet. Leases are allocated from this range. +range = "172.16.0.0/12" + +# How long granted leases last. +lease_seconds = 3600 + +# How long an unaccepted OFFER is held before its IP is reclaimed. +offer_timeout_seconds = 300 diff --git a/include/dhcp.hrl b/include/dhcp.hrl index 63144ca..a7e5611 100644 --- a/include/dhcp.hrl +++ b/include/dhcp.hrl @@ -1,14 +1,8 @@ -define(DHCP_MAGIC_COOKIE, 16#63825363). - --define(DEFAULT_LEASE_SECONDS, 3600). % TODO %-define(MAX_LEASE_SECONDS, 86400). %-define(MIN_LEASE_SECONDS, 300). --define(DEFAULT_OFFER_TIMEOUT_SECONDS, 300). - --define(DEFAULT_CIDR_RANGE, "172.16.0.0/16"). - -record(dhcp_message, { % Header op :: op(), diff --git a/priv/scripts/osx/pxe-boot.sh b/priv/scripts/osx/pxe-boot.sh new file mode 100755 index 0000000..9203d7b --- /dev/null +++ b/priv/scripts/osx/pxe-boot.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Subnet must match ebb's ?DEFAULT_CIDR_RANGE ("172.16.0.0/16"). +NET_START="172.16.0.1" +NET_END="172.16.255.254" +NET_MASK="255.255.0.0" + +exec sudo qemu-system-x86_64 \ + -machine q35 \ + -cpu qemu64 \ + -m 512 \ + -netdev vmnet-host,id=net0,start-address="${NET_START}",end-address="${NET_END}",subnet-mask="${NET_MASK}" \ + -device e1000,netdev=net0,mac=52:54:00:12:34:56 \ + -boot n \ + -nographic \ + -no-reboot diff --git a/rebar.config b/rebar.config index 5496171..109c19b 100644 --- a/rebar.config +++ b/rebar.config @@ -2,6 +2,7 @@ {deps, [ %{khepri, {git, "https://github.com/rabbitmq/khepri", {branch, main}}}, {erl_cidr, "1.2.1"}, + {tomerl, "0.5.0"}, {eqwalizer_support, {git_subdir, "https://github.com/whatsapp/eqwalizer.git", {branch, "main"}, "eqwalizer_support"}} diff --git a/rebar.lock b/rebar.lock index 2586c72..5ed236c 100644 --- a/rebar.lock +++ b/rebar.lock @@ -1,13 +1,16 @@ {"1.2.0", [{<<"eqwalizer_support">>, {git_subdir,"https://github.com/whatsapp/eqwalizer.git", - {ref,"6370858ff6f8a02e3c853cf034cdf3c5a85034dc"}, + {ref,"01f151ca206bb019db0999a2c9c8c8aaae79bbab"}, "eqwalizer_support"}, 0}, - {<<"erl_cidr">>,{pkg,<<"erl_cidr">>,<<"1.2.1">>},0}]}. + {<<"erl_cidr">>,{pkg,<<"erl_cidr">>,<<"1.2.1">>},0}, + {<<"tomerl">>,{pkg,<<"tomerl">>,<<"0.5.0">>},0}]}. [ {pkg_hash,[ - {<<"erl_cidr">>, <<"921BB463BCF10EAD937AE491E5BCAC2823E550B339A9893AC6574518553CC067">>}]}, + {<<"erl_cidr">>, <<"921BB463BCF10EAD937AE491E5BCAC2823E550B339A9893AC6574518553CC067">>}, + {<<"tomerl">>, <<"6E7CEAC151AC573B87B985F3D2112F29933B89BA0693FA741318D899A17AC318">>}]}, {pkg_hash_ext,[ - {<<"erl_cidr">>, <<"FCF5D51B1CC1B26D1748AB39F9B614AB445AD17172A9396D48C6B5C15EA9EFCF">>}]} + {<<"erl_cidr">>, <<"FCF5D51B1CC1B26D1748AB39F9B614AB445AD17172A9396D48C6B5C15EA9EFCF">>}, + {<<"tomerl">>, <<"2A7FB62F9EBF0E75561B39255638BC2B805B437C86FEC538657E7C3B576979FA">>}]} ]. diff --git a/src/ebb.app.src b/src/ebb.app.src index 689c064..0b2fc9e 100644 --- a/src/ebb.app.src +++ b/src/ebb.app.src @@ -5,7 +5,14 @@ {mod, {ebb_app, []}}, {applications, [ kernel, - stdlib + stdlib, + tomerl + % NOTE: we also depend on the erl_cidr package, whose OTP app is + % confusingly named inet_cidr. Listing either name here breaks + % rebar3 dialyzer (app/package name mismatch). It is a pure + % library (no processes to start), so omitting it is safe in + % dev; a future release definition must include inet_cidr + % explicitly. ]}, {env, []}, {modules, []}, diff --git a/src/ebb_app.erl b/src/ebb_app.erl index ebd2ad4..1db2cac 100644 --- a/src/ebb_app.erl +++ b/src/ebb_app.erl @@ -10,7 +10,13 @@ -export([start/2, stop/1]). start(_StartType, _StartArgs) -> - ebb_sup:start_link(). + case ebb_config:load() of + ok -> + ebb_sup:start_link(); + {error, Reason} -> + logger:critical("Refusing to start: ~s", [Reason]), + {error, {config_error, lists:flatten(Reason)}} + end. stop(_State) -> ok. diff --git a/src/ebb_config.erl b/src/ebb_config.erl new file mode 100644 index 0000000..8d7a3ca --- /dev/null +++ b/src/ebb_config.erl @@ -0,0 +1,160 @@ +-module(ebb_config). +-moduledoc """ +Configuration for ebb, loaded from a TOML file into persistent terms. A +configuration file is required to boot ebb. The file is resolved in this order: + +1. The path in the `EBB_CONFIG` environment variable +2. `./ebb.toml` +3. `/etc/ebb/ebb.toml` +""". + +-export([load/0, get/1, enabled/1]). +%% Exported for tests +-export([load_file/1]). + +-define(PT_KEY, ?MODULE). +-define(LOCAL_PATH, "ebb.toml"). +-define(SYSTEM_PATH, "/etc/ebb/ebb.toml"). + +%%-------------------------------------------------------------------- +%% API +%%-------------------------------------------------------------------- + +-doc """ +Returns an error if no file can be found, the file is unreadable or +unparseable, or any required key is missing. +""". +-spec load() -> ok | {error, io_lib:chars()}. +load() -> + case resolve_path() of + {path, Path} -> + load_file(Path); + {error, Reason} -> + {error, Reason} + end. + +-doc """ +Load configuration from an explicit path. See also `load/0`. +""". +-spec load_file(file:name_all()) -> ok | {error, io_lib:chars()}. +load_file(Path) -> + case tomerl:read_file(Path) of + {ok, Raw} -> + try + store(Path, atomize(Raw)) + catch + throw:{unknown_key, Key} -> + {error, + io_lib:format("unknown configuration key \"~s\" in ~s", [ + Key, Path + ])} + end; + {error, Reason} -> + {error, io_lib:format("cannot read ~s: ~p", [Path, Reason])} + end. + +store(Path, Config) -> + case missing_keys(Config) of + [] -> + persistent_term:put(?PT_KEY, Config), + logger:notice("Loaded configuration from ~s, enabled features: ~p", [ + Path, [F || F <- maps:keys(features()), is_map_key(F, Config)] + ]), + ok; + Missing -> + {error, + io_lib:format("~s is missing required keys: ~s", [ + Path, string:join(Missing, ", ") + ])} + end. + +-doc """ +Fetch a configuration value by path, e.g. `get([dhcp, listen_port])`. +Crashes on unknown paths; see `features/0`. +""". +-spec get([atom()]) -> term(). +get(Path) when is_list(Path) -> + lists:foldl(fun maps:get/2, persistent_term:get(?PT_KEY), Path). + +-doc """ +Features are gated by presence: a feature is enabled iff its section +exists in the configuration file. +""". +-spec enabled(atom()) -> boolean(). +enabled(Feature) -> + is_map_key(Feature, persistent_term:get(?PT_KEY)). + +%%-------------------------------------------------------------------- +%% Internal +%%-------------------------------------------------------------------- + +%% Known feature sections and the keys each requires when present. +%% An absent section simply disables the feature. +features() -> + #{ + dhcp => [listen_port, range, lease_seconds, offer_timeout_seconds] + }. + +missing_keys(Config) -> + maps:fold( + fun(Feature, RequiredKeys, Acc) -> + case Config of + #{Feature := Section} -> + Acc ++ + [ + atom_to_list(Feature) ++ "." ++ atom_to_list(Key) + || Key <- RequiredKeys, not is_map_key(Key, Section) + ]; + #{} -> + Acc + end + end, + [], + features() + ). + +resolve_path() -> + case os:getenv("EBB_CONFIG") of + false -> search_default_paths(); + "" -> search_default_paths(); + Path -> explicit_path(Path) + end. + +search_default_paths() -> + case lists:search(fun filelib:is_regular/1, [?LOCAL_PATH, ?SYSTEM_PATH]) of + {value, Path} -> + {path, Path}; + false -> + {error, + io_lib:format( + "no configuration file found; provide ~s, ~s," + " or set EBB_CONFIG", + [?LOCAL_PATH, ?SYSTEM_PATH] + )} + end. + +explicit_path(Path) -> + case filelib:is_regular(Path) of + true -> + {path, Path}; + false -> + {error, + io_lib:format( + "EBB_CONFIG points at ~s, which does not exist" + " or is not a regular file", + [Path] + )} + end. + +%% tomerl produces binary keys; convert them (and table arrays) to the +%% atom-keyed shape used internally. +atomize(Map) when is_map(Map) -> + maps:fold( + fun(K, V, Acc) -> Acc#{binary_to_existing_atom(K) => atomize(V)} end, + #{}, + Map + ); +atomize(List) when is_list(List) -> + [atomize(V) || V <- List]; +atomize(Value) -> + Value. diff --git a/src/ebb_dhcp_pool_mem.erl b/src/ebb_dhcp_pool_mem.erl index 362e9b4..c0aca72 100644 --- a/src/ebb_dhcp_pool_mem.erl +++ b/src/ebb_dhcp_pool_mem.erl @@ -61,7 +61,7 @@ dump() -> init([]) -> {ok, #{ pool => [], - range => ?DEFAULT_CIDR_RANGE + range => ebb_config:get([dhcp, range]) }}. handle_call({get_offer, Msg}, _From, #{pool := Pool} = State) -> @@ -85,7 +85,7 @@ handle_call({create_offer, Msg}, _From, State) -> % otherwise give them the default Value; _ -> - ?DEFAULT_LEASE_SECONDS + ebb_config:get([dhcp, lease_seconds]) end, % See next_ip/2 deficiencies {ok, ClientIP} = next_ip(Pool, Range), @@ -95,7 +95,9 @@ handle_call({create_offer, Msg}, _From, State) -> subnet_mask = to_mask(Range), state = offered, expiration = erlang:send_after( - ?DEFAULT_OFFER_TIMEOUT_SECONDS * 1000, self(), {expire, ClientID} + ebb_config:get([dhcp, offer_timeout_seconds]) * 1000, + self(), + {expire, ClientID} ), duration = LeaseDuration }, diff --git a/src/ebb_dhcpd.erl b/src/ebb_dhcpd.erl index 8c7de6e..8407d96 100644 --- a/src/ebb_dhcpd.erl +++ b/src/ebb_dhcpd.erl @@ -30,7 +30,8 @@ start_link() -> gen_server:start_link({local, ?MODULE}, ?MODULE, [], []). init([]) -> - {ok, Socket} = gen_udp:open(67, [{active, once}, {broadcast, true}, binary]), + Port = ebb_config:get([dhcp, listen_port]), + {ok, Socket} = gen_udp:open(Port, [{active, once}, {broadcast, true}, binary]), {ok, #{ socket => Socket }}. @@ -254,7 +255,7 @@ server_ip(Socket) -> end. guess_server_ip() -> - Cidr = inet_cidr:parse(?DEFAULT_CIDR_RANGE), + Cidr = inet_cidr:parse(ebb_config:get([dhcp, range])), {Start, _End, _Prefix} = Cidr, {ok, Addrs} = inet:getifaddrs(), case @@ -275,5 +276,5 @@ send_broadcast(Socket, Packet) -> gen_udp:send(Socket, Bcast, 68, Packet). subnet_broadcast() -> - {_Start, End, _Prefix} = inet_cidr:parse(?DEFAULT_CIDR_RANGE), + {_Start, End, _Prefix} = inet_cidr:parse(ebb_config:get([dhcp, range])), End. diff --git a/src/ebb_provision_sup.erl b/src/ebb_provision_sup.erl new file mode 100644 index 0000000..f618bee --- /dev/null +++ b/src/ebb_provision_sup.erl @@ -0,0 +1,34 @@ +-module(ebb_provision_sup). +-moduledoc """ +Supervisor for the provisioning feature, including: + - the DHCP listener and its lease pool. + +Started by `ebb_sup` only when the `[dhcp]` section is present in the +configuration. +""". + +-behaviour(supervisor). + +-export([start_link/0]). +-export([init/1]). + +start_link() -> + supervisor:start_link({local, ?MODULE}, ?MODULE, []). + +init([]) -> + SupFlags = #{ + strategy => one_for_all, + intensity => 3, + period => 5 + }, + ChildSpecs = [ + #{ + id => ebb_dhcp_pool_mem, + start => {ebb_dhcp_pool_mem, start_link, []} + }, + #{ + id => ebb_dhcpd, + start => {ebb_dhcpd, start_link, []} + } + ], + {ok, {SupFlags, ChildSpecs}}. diff --git a/src/ebb_sup.erl b/src/ebb_sup.erl index 93aa3df..4c13714 100644 --- a/src/ebb_sup.erl +++ b/src/ebb_sup.erl @@ -1,14 +1,11 @@ -%%%------------------------------------------------------------------- -%% @doc ebb top level supervisor. -%% @end -%%%------------------------------------------------------------------- - -module(ebb_sup). +-moduledoc """ +ebb top level supervisor. +""". -behaviour(supervisor). -export([start_link/0]). - -export([init/1]). -define(SERVER, ?MODULE). @@ -16,31 +13,32 @@ start_link() -> supervisor:start_link({local, ?SERVER}, ?MODULE, []). -%% sup_flags() = #{strategy => strategy(), % optional -%% intensity => non_neg_integer(), % optional -%% period => pos_integer()} % optional -%% child_spec() = #{id => child_id(), % mandatory -%% start => mfargs(), % mandatory -%% restart => restart(), % optional -%% shutdown => shutdown(), % optional -%% type => worker(), % optional -%% modules => modules()} % optional init([]) -> SupFlags = #{ - strategy => one_for_all, + strategy => one_for_one, intensity => 3, period => 5 }, - ChildSpecs = [ - #{ - id => ebb_dhcp_pool_mem, - start => {ebb_dhcp_pool_mem, start_link, []} - }, - #{ - id => ebb_dhcpd, - start => {ebb_dhcpd, start_link, []} - } - ], + ChildSpecs = provision_specs(), + case ChildSpecs of + [] -> logger:warning("No features enabled, ebb is running idle"); + _ -> ok + end, {ok, {SupFlags, ChildSpecs}}. %% internal functions + +%% Provisioning, gated on the [dhcp] config section. +provision_specs() -> + case ebb_config:enabled(dhcp) of + true -> + [ + #{ + id => ebb_provision_sup, + start => {ebb_provision_sup, start_link, []}, + type => supervisor + } + ]; + false -> + [] + end.