diff --git a/config/sys.config b/config/sys.config new file mode 100644 index 0000000..49251b2 --- /dev/null +++ b/config/sys.config @@ -0,0 +1,12 @@ +[ + {kernel, [ + {logger, [ + {handler, default, logger_std_h, #{ + formatter => + {logger_formatter, #{ + template => [time, " ", level, ": ", msg, "\n"] + }} + }} + ]} + ]} +]. diff --git a/ebb.toml b/ebb.toml index c903a57..4e459b7 100644 --- a/ebb.toml +++ b/ebb.toml @@ -1,20 +1,11 @@ -# 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. +# ebb minimal configuration. +# unknown keys are rejected [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 +[[dhcp.subnet]] +cidr = "172.16.0.0/12" +interface = "pxe0" -# How long an unaccepted OFFER is held before its IP is reclaimed. -offer_timeout_seconds = 300 +[[dhcp.subnet.pool]] +range = "172.16.0.0/12" diff --git a/ebb.toml.sample-full b/ebb.toml.sample-full new file mode 100644 index 0000000..e512892 --- /dev/null +++ b/ebb.toml.sample-full @@ -0,0 +1,48 @@ +# ebb sample configuration. +# +# A section's presence enables it. Unknown keys refuse boot. +# Keys marked optional may be omitted; defaults are shown. +# All other keys are required. + +[dhcp] + +[[dhcp.subnet]] +cidr = "192.168.1.0/24" +interface = "eno1" + +# Optional. Netboot fields placed in the BOOTP header of replies. +next_server = "192.168.1.5" +boot_file = "ipxe.efi" + +# Optional. DHCP protocol options; a key's presence means "send it". +option.routers = "192.168.1.1" +option.domain_name_servers = "192.168.1.2" + +[[dhcp.subnet.pool]] +range = "192.168.1.50-192.168.1.100" +lease_seconds = 3600 # optional, default 3600 +offer_timeout_seconds = 300 # optional, default 300 +backend.type = "mem" # optional, default "mem" + +# backend.type selects the storage backend and which backend.* keys apply: +# "mem" no parameters +# "file" requires backend.path +[[dhcp.subnet.pool]] +range = "192.168.1.120-192.168.1.200" +backend.type = "file" +backend.path = "/var/lib/ebb/leases-eno1" + +[[dhcp.subnet]] +cidr = "10.20.0.0/24" +interface = "eno2" + +[[dhcp.subnet.pool]] +range = "10.20.0.50-10.20.0.200" + +# proxyDHCP: supply PXE boot information on a network whose addressing is +# owned by an existing DHCP server. Proxy interfaces must not overlap with +# subnet interfaces. +[[dhcp.proxy]] +interface = "eno3" +next_server = "10.30.0.5" +boot_file = "ipxe.efi" diff --git a/ebb.toml.sample-minimal b/ebb.toml.sample-minimal new file mode 100644 index 0000000..f69029c --- /dev/null +++ b/ebb.toml.sample-minimal @@ -0,0 +1,10 @@ +# ebb minimal configuration: serve one pool on one interface. + +[dhcp] + +[[dhcp.subnet]] +cidr = "192.168.1.0/24" +interface = "eno1" + +[[dhcp.subnet.pool]] +range = "192.168.1.50-192.168.1.100" diff --git a/include/dhcp.hrl b/include/dhcp.hrl index a7e5611..7048156 100644 --- a/include/dhcp.hrl +++ b/include/dhcp.hrl @@ -1,8 +1,14 @@ +-define(DHCP_PORT, 67). -define(DHCP_MAGIC_COOKIE, 16#63825363). % TODO %-define(MAX_LEASE_SECONDS, 86400). %-define(MIN_LEASE_SECONDS, 300). +%% Configuration defaults for omitted optional keys. +-define(DEFAULT_LEASE_SECONDS, 3600). +-define(DEFAULT_OFFER_TIMEOUT_SECONDS, 300). +-define(DEFAULT_POOL_BACKEND, "mem"). + -record(dhcp_message, { % Header op :: op(), diff --git a/rebar.config b/rebar.config index 109c19b..697eb22 100644 --- a/rebar.config +++ b/rebar.config @@ -13,7 +13,7 @@ ]}. {shell, [ - %% {config, "config/sys.config"}, + {config, "config/sys.config"}, {apps, [ebb]} ]}. diff --git a/src/ebb_config.erl b/src/ebb_config.erl index 8d7a3ca..4a21d7d 100644 --- a/src/ebb_config.erl +++ b/src/ebb_config.erl @@ -9,10 +9,7 @@ configuration file is required to boot ebb. The file is resolved in this order: """. -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"). @@ -26,55 +23,25 @@ unparseable, or any required key is missing. """. -spec load() -> ok | {error, io_lib:chars()}. load() -> - case resolve_path() of - {path, Path} -> - load_file(Path); + maybe + {path, File} ?= resolve_path(), + logger:notice("Reading configuration from ~p", [File]), + {ok, Config} ?= read_toml(File), + logger:notice("Parsed configuration as valid TOML"), + ok ?= ebb_config_validator:check_structure(Config), + logger:notice("Validated configuration structure"), + persistent_term:put(?MODULE, atomize(Config)) + else {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). + lists:foldl(fun maps:get/2, persistent_term:get(?MODULE), Path). -doc """ Features are gated by presence: a feature is enabled iff its section @@ -82,37 +49,12 @@ exists in the configuration file. """. -spec enabled(atom()) -> boolean(). enabled(Feature) -> - is_map_key(Feature, persistent_term:get(?PT_KEY)). + is_map_key(Feature, persistent_term:get(?MODULE)). %%-------------------------------------------------------------------- %% 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(); @@ -120,6 +62,14 @@ resolve_path() -> Path -> explicit_path(Path) end. +read_toml(File) -> + case tomerl:read_file(File) of + {ok, Raw} -> + {ok, Raw}; + {error, Reason} -> + {error, io_lib:format("cannot read ~s: ~p", [File, Reason])} + end. + search_default_paths() -> case lists:search(fun filelib:is_regular/1, [?LOCAL_PATH, ?SYSTEM_PATH]) of {value, Path} -> @@ -156,5 +106,7 @@ atomize(Map) when is_map(Map) -> ); atomize(List) when is_list(List) -> [atomize(V) || V <- List]; +atomize(Value) when is_binary(Value) -> + binary_to_list(Value); atomize(Value) -> Value. diff --git a/src/ebb_config_validator.erl b/src/ebb_config_validator.erl new file mode 100644 index 0000000..fd1f1a5 --- /dev/null +++ b/src/ebb_config_validator.erl @@ -0,0 +1,150 @@ +-module(ebb_config_validator). +-moduledoc """ +ebb configuration validator. ensures that configuration contains only valid +keys +""". +-export([schema/0, check_structure/1, format_errors/1]). + +-include("dhcp.hrl"). + +%% Keys only; values pass through unexamined. Schema nodes: +%% #{...} table; validated if present, absent stays absent +%% {array, #{...}} array of tables; each element validated +%% required leaf that must be present +%% optional leaf that may be absent +%% {default, V} leaf that gets V when absent +%% +%% Input keys not in the schema are errors. +schema() -> + #{ + dhcp => #{ + subnet => + {array, #{ + cidr => required, + interface => required, + next_server => optional, + boot_file => optional, + option => #{ + routers => optional, + domain_name_servers => optional + }, + pool => + {array, #{ + range => required, + lease_seconds => {default, ?DEFAULT_LEASE_SECONDS}, + offer_timeout_seconds => + {default, ?DEFAULT_OFFER_TIMEOUT_SECONDS}, + backend => #{ + type => {default, ?DEFAULT_POOL_BACKEND}, + path => optional + } + }} + }}, + proxy => + {array, #{ + interface => required, + next_server => required, + boot_file => required + }} + } + }. + +-doc """ +Structural pass: check the parsed TOML against `schema/0`. Keys only; +values pass through unexamined. +""". +-spec check_structure(map()) -> + ok | {error, [{Path :: [term()], Reason :: atom()}]}. +check_structure(Config) -> + case walk(schema(), Config, [], []) of + [] -> ok; + Errors -> {error, lists:reverse(Errors)} + end. + +-doc """ +Render the error list of `validate/1` as one line per error, e.g. +`dhcp.subnet[1].pool[2].optiion: unknown key`. +""". +-spec format_errors([{[term()], atom()}]) -> io_lib:chars(). +format_errors(Errors) -> + lists:join($\n, [format_error(E) || E <- Errors]). + +%%-------------------------------------------------------------------- +%% Internal +%%-------------------------------------------------------------------- + +%% walk(SchemaNode, Input, PathRev, Errors) -> Errors +%% +%% SchemaNode and Input descend the schema and the parsed TOML in +%% lockstep. PathRev is the current position, innermost first. Errors +%% accumulate; the walk never aborts. + +%% Table: sweep input for unknown keys and recurse into known ones, +%% then sweep the schema for missing required keys. +walk(Node, Input, PathRev, Errors) when is_map(Node), is_map(Input) -> + ByBin = #{atom_to_binary(A) => S || A := S <- Node}, + Errors1 = maps:fold( + fun(K, V, Acc) -> + case ByBin of + #{K := SubNode} -> walk(SubNode, V, [K | PathRev], Acc); + #{} -> err(unknown_key, [K | PathRev], Acc) + end + end, + Errors, + Input + ), + maps:fold( + fun + (A, required, Acc) -> + case is_map_key(atom_to_binary(A), Input) of + true -> Acc; + false -> err(missing_key, [A | PathRev], Acc) + end; + (_A, _SubNode, Acc) -> + Acc + end, + Errors1, + Node + ); +walk(Node, _Input, PathRev, Errors) when is_map(Node) -> + err(expected_table, PathRev, Errors); +%% Array of tables: recurse per element, 1-based index in the path. +walk({array, ElemNode}, Input, PathRev, Errors) when is_list(Input) -> + {_, Errors1} = lists:foldl( + fun(Elem, {I, Acc}) -> + {I + 1, walk(ElemNode, Elem, [I | PathRev], Acc)} + end, + {1, Errors}, + Input + ), + Errors1; +walk({array, _ElemNode}, _Input, PathRev, Errors) -> + err(expected_array, PathRev, Errors); +%% Leaves: the key exists in the schema, which is all we check. +walk(required, _V, _PathRev, Errors) -> + Errors; +walk(optional, _V, _PathRev, Errors) -> + Errors; +walk({default, _}, _V, _PathRev, Errors) -> + Errors. + +err(Reason, PathRev, Errors) -> + [{lists:reverse(PathRev), Reason} | Errors]. + +format_error({Path, Reason}) -> + io_lib:format("~s: ~s", [format_path(Path), format_reason(Reason)]). + +format_reason(unknown_key) -> "unknown key"; +format_reason(missing_key) -> "missing required key"; +format_reason(expected_table) -> "expected a table"; +format_reason(expected_array) -> "expected an array of tables ([[...]])". + +%% [dhcp, subnet, 1, pool, 2, range] -> "dhcp.subnet[1].pool[2].range" +format_path([First | Rest]) -> + [segment(First) | [dotted(S) || S <- Rest]]. + +dotted(I) when is_integer(I) -> io_lib:format("[~b]", [I]); +dotted(S) -> [$. | segment(S)]. + +segment(A) when is_atom(A) -> atom_to_list(A); +segment(B) when is_binary(B) -> binary_to_list(B). diff --git a/src/ebb_dhcp_pool_mem.erl b/src/ebb_dhcp_pool_mem.erl index c0aca72..c34c7fa 100644 --- a/src/ebb_dhcp_pool_mem.erl +++ b/src/ebb_dhcp_pool_mem.erl @@ -18,7 +18,7 @@ It should be used only for demo purposes. %% API -export([ - start_link/0, + start_link/1, get_offer/1, create_offer/1, accept_offer/1, @@ -33,8 +33,8 @@ It should be used only for demo purposes. handle_info/2 ]). -start_link() -> - gen_server:start_link({local, ?MODULE}, ?MODULE, [], []). +start_link(Range) -> + gen_server:start_link(?MODULE, [Range], []). -spec get_offer(dhcp_message()) -> {ok, dhcp_lease()} | {error, term()}. get_offer(Msg) -> @@ -58,10 +58,10 @@ accept_offer(Msg) -> dump() -> gen_server:call(?MODULE, dump). -init([]) -> +init([Range]) -> {ok, #{ pool => [], - range => ebb_config:get([dhcp, range]) + range => Range }}. handle_call({get_offer, Msg}, _From, #{pool := Pool} = State) -> diff --git a/src/ebb_dhcpd.erl b/src/ebb_dhcpd.erl index 8407d96..040587e 100644 --- a/src/ebb_dhcpd.erl +++ b/src/ebb_dhcpd.erl @@ -11,7 +11,7 @@ servers. %% API -export([ - start_link/0, + start_link/2, % for testing route_msg/2 ]). @@ -26,12 +26,16 @@ servers. code_change/3 ]). -start_link() -> - gen_server:start_link({local, ?MODULE}, ?MODULE, [], []). +start_link(Interface, Cidr) -> + gen_server:start_link(?MODULE, [Interface, Cidr], []). -init([]) -> - Port = ebb_config:get([dhcp, listen_port]), - {ok, Socket} = gen_udp:open(Port, [{active, once}, {broadcast, true}, binary]), +init([Interface, _Cidr]) -> + {ok, AllAddrs} = inet:getifaddrs(), + IfOpts = proplists:get_value(Interface, AllAddrs), + Addr = proplists:get_value(addr, IfOpts), + {ok, Socket} = gen_udp:open(?DHCP_PORT, [ + {ip, Addr}, {active, once}, {broadcast, true}, binary + ]), {ok, #{ socket => Socket }}. @@ -130,7 +134,8 @@ send_offer(DiscoverMsg, Offer, Socket) -> chaddr = ChAddr, giaddr = GiAddr, htype = HType, - hlen = HLen + hlen = HLen, + flags = Flags } = DiscoverMsg, % Pull the client IP out of the offer #dhcp_lease{ @@ -166,10 +171,14 @@ send_offer(DiscoverMsg, Offer, Socket) -> options = Options }, - % TODO: Change this to a unicast to the MAC - logger:notice("Sending OFFER ~p to ~p", [ClientIP, ChAddr]), OfferPacket = ebb_dhcp_packet:encode(OfferMsg), - send_broadcast(Socket, OfferPacket). + case Flags of + [broadcast] -> + logger:notice("Broadcasting OFFER ~p to ~p", [ClientIP, ChAddr]), + send_broadcast(Socket, OfferPacket); + [] -> + send_unicast(Socket, OfferPacket) + end. send_ack(RequestMsg, Lease, Socket) -> #dhcp_message{ @@ -275,6 +284,10 @@ send_broadcast(Socket, Packet) -> Bcast = subnet_broadcast(), gen_udp:send(Socket, Bcast, 68, Packet). +% TODO +send_unicast(Socket, Packet) -> + ok. + subnet_broadcast() -> {_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 deleted file mode 100644 index f618bee..0000000 --- a/src/ebb_provision_sup.erl +++ /dev/null @@ -1,34 +0,0 @@ --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 4c13714..f75cb6c 100644 --- a/src/ebb_sup.erl +++ b/src/ebb_sup.erl @@ -19,7 +19,8 @@ init([]) -> intensity => 3, period => 5 }, - ChildSpecs = provision_specs(), + % Any future subsystems can be added here, too + ChildSpecs = dhcp_specs(), case ChildSpecs of [] -> logger:warning("No features enabled, ebb is running idle"); _ -> ok @@ -29,13 +30,14 @@ init([]) -> %% internal functions %% Provisioning, gated on the [dhcp] config section. -provision_specs() -> +dhcp_specs() -> case ebb_config:enabled(dhcp) of true -> + Subnets = ebb_config:get([dhcp, subnet]), [ #{ - id => ebb_provision_sup, - start => {ebb_provision_sup, start_link, []}, + id => ebb_dhcp_sup, + start => {ebb_dhcp_sup, start_link, [Subnets]}, type => supervisor } ];