From 58e48457ad1cd75bd39e7e8d3cfe730f6dc931d7 Mon Sep 17 00:00:00 2001 From: Jamil Date: Wed, 3 Aug 2022 12:34:30 -0700 Subject: [PATCH] Revert "Add initial rough version of port based rules (#874)" (#888) This reverts commit 55a311adecddac5472e77af7ac655bc197d16158. --- apps/fz_http/lib/fz_http/int4range.ex | 90 ----------- apps/fz_http/lib/fz_http/rules.ex | 10 +- apps/fz_http/lib/fz_http/rules/rule.ex | 24 +-- .../fz_http/lib/fz_http/rules/rule_setting.ex | 8 +- .../live/rule_live/rule_list_component.ex | 22 +-- .../rule_live/rule_list_component.html.heex | 35 +--- .../20220726205646_add_rule_port_range.exs | 51 ------ apps/fz_http/test/fz_http/events_test.exs | 25 +-- .../test/fz_http/repo/notifier_test.exs | 10 +- apps/fz_http/test/fz_http/rules_test.exs | 59 ------- apps/fz_http/test/support/test_helpers.ex | 6 - apps/fz_wall/lib/fz_wall/cli/helpers/nft.ex | 145 ++++------------- apps/fz_wall/lib/fz_wall/cli/helpers/sets.ex | 52 ++---- apps/fz_wall/lib/fz_wall/cli/live.ex | 151 ++++++++++-------- apps/fz_wall/lib/fz_wall/cli/sandbox.ex | 1 + .../fz_wall/test/fz_wall/cli/sandbox_test.exs | 4 + 16 files changed, 142 insertions(+), 551 deletions(-) delete mode 100644 apps/fz_http/lib/fz_http/int4range.ex delete mode 100644 apps/fz_http/priv/repo/migrations/20220726205646_add_rule_port_range.exs diff --git a/apps/fz_http/lib/fz_http/int4range.ex b/apps/fz_http/lib/fz_http/int4range.ex deleted file mode 100644 index 8f489273b..000000000 --- a/apps/fz_http/lib/fz_http/int4range.ex +++ /dev/null @@ -1,90 +0,0 @@ -defmodule FzHttp.Int4Range do - @moduledoc """ - Ecto type for Postgres' Int4Range type - """ - # Note: we represent a port range as a string: lower - upper for ease of use - # with Phoenix LiveView and nftables - use Ecto.Type - @format_error "Range Error: Bad format" - - def type, do: :int4range - - def cast(str) when is_binary(str) do - # We need to handle this case since postgre notifies - # before inserting the range in the database using this format - parse_str = - if String.starts_with?(str, ["[", "("]) do - &parse_bracket/1 - else - &parse_range/1 - end - - case parse_str.(str) do - {:ok, range} -> cast(range) - err -> err - end - end - - def cast([num, num]) when is_number(num) do - {:ok, Integer.to_string(num)} - end - - def cast([lower, upper]) when upper >= lower, do: {:ok, "#{lower} - #{upper}"} - def cast([_, _]), do: {:error, message: "Range Error: Lower bound higher than upper bound"} - - def load(%Postgrex.Range{ - lower: lower, - upper: upper, - lower_inclusive: lower_inclusive, - upper_inclusive: upper_inclusive - }) do - upper = if upper != :unbound, do: upper - to_num(!upper_inclusive), else: nil - lower = if lower != :unbound, do: lower + to_num(!lower_inclusive), else: nil - cast([lower, upper]) - end - - def dump(range) when is_binary(range) do - {:ok, range_list} = parse_range(range) - dump(range_list) - end - - def dump([lower, upper]) do - {:ok, - %Postgrex.Range{lower: lower, upper: upper, upper_inclusive: true, lower_inclusive: true}} - end - - def dump(_), do: :error - - defp parse_range(range) do - res = - String.trim(range) - |> String.split("-", trim: true, parts: 2) - |> Enum.map(&String.trim/1) - |> Enum.map(&Integer.parse/1) - - case res do - [{lower, _}, {upper, _}] -> {:ok, [lower, upper]} - [{num, _}] -> {:ok, [num, num]} - _ -> {:error, message: @format_error} - end - end - - defp parse_bracket(bracket) do - res = - Regex.named_captures( - ~r/(?[\[|(])\s*(?\d+),\s*(?\d+)\s*(?[\]|\)])/, - bracket - ) - - if is_nil(res) || Enum.any?(["lower", "upper", "start", "end"], &is_nil(res[&1])) do - {:error, message: @format_error} - else - lower = String.to_integer(res["lower"]) + to_num(res["start"] == "(") - upper = String.to_integer(res["upper"]) - to_num(res["end"] == ")") - {:ok, [lower, upper]} - end - end - - defp to_num(b) when b, do: 1 - defp to_num(_b), do: 0 -end diff --git a/apps/fz_http/lib/fz_http/rules.ex b/apps/fz_http/lib/fz_http/rules.ex index abaebae9a..b9edc319e 100644 --- a/apps/fz_http/lib/fz_http/rules.ex +++ b/apps/fz_http/lib/fz_http/rules.ex @@ -4,7 +4,7 @@ defmodule FzHttp.Rules do """ import Ecto.Query, warn: false - import Ecto.Changeset + alias FzHttp.{Repo, Rules.Rule, Rules.RuleSetting, Telemetry} def list_rules, do: Repo.all(Rule) @@ -38,14 +38,6 @@ defmodule FzHttp.Rules do |> Rule.changeset(attrs) end - def defaults(changeset) do - %{port_type: get_field(changeset, :port_type)} - end - - def defaults do - defaults(new_rule()) - end - def create_rule(attrs \\ %{}) do result = attrs diff --git a/apps/fz_http/lib/fz_http/rules/rule.ex b/apps/fz_http/lib/fz_http/rules/rule.ex index 301e28ebb..f67d0de07 100644 --- a/apps/fz_http/lib/fz_http/rules/rule.ex +++ b/apps/fz_http/lib/fz_http/rules/rule.ex @@ -7,15 +7,11 @@ defmodule FzHttp.Rules.Rule do import Ecto.Changeset @exclusion_msg "Destination overlaps with an existing rule" - @port_range_msg "Port is not within valid range" - @port_type_msg "Please specify a port-range for the given port type" schema "rules" do field :uuid, Ecto.UUID, autogenerate: true field :destination, EctoNetwork.INET, read_after_writes: true field :action, Ecto.Enum, values: [:drop, :accept], default: :drop - field :port_type, Ecto.Enum, values: [:tcp, :udp], default: nil - field :port_range, FzHttp.Int4Range, default: nil belongs_to :user, FzHttp.Users.User timestamps(type: :utc_datetime_usec) @@ -26,19 +22,9 @@ defmodule FzHttp.Rules.Rule do |> cast(attrs, [ :user_id, :action, - :destination, - :port_type, - :port_range + :destination ]) |> validate_required([:action, :destination]) - |> check_constraint(:port_range, - message: @port_range_msg, - name: :port_range_is_within_valid_values - ) - |> check_constraint(:port_type, - message: @port_type_msg, - name: :port_range_needs_type - ) |> exclusion_constraint(:destination, message: @exclusion_msg, name: :destination_overlap_excl_usr_rule @@ -47,13 +33,5 @@ defmodule FzHttp.Rules.Rule do message: @exclusion_msg, name: :destination_overlap_excl ) - |> exclusion_constraint(:destination, - message: @exclusion_msg, - name: :destination_overlap_excl_port - ) - |> exclusion_constraint(:destination, - message: @exclusion_msg, - name: :destination_overlap_excl_usr_rule_port - ) end end diff --git a/apps/fz_http/lib/fz_http/rules/rule_setting.ex b/apps/fz_http/lib/fz_http/rules/rule_setting.ex index 253dd7401..46d0740bc 100644 --- a/apps/fz_http/lib/fz_http/rules/rule_setting.ex +++ b/apps/fz_http/lib/fz_http/rules/rule_setting.ex @@ -12,23 +12,19 @@ defmodule FzHttp.Rules.RuleSetting do field :action, Ecto.Enum, values: [:drop, :accept] field :destination, :string field :user_id, :integer - field :port_type, Ecto.Enum, values: [:tcp, :udp], default: nil - field :port_range, FzHttp.Int4Range, default: nil end def parse(rule) when is_struct(rule) do %__MODULE__{ destination: decode(rule.destination), action: rule.action, - user_id: rule.user_id, - port_type: rule.port_type, - port_range: rule.port_range + user_id: rule.user_id } end def parse(rule) when is_map(rule) do %__MODULE__{} - |> cast(rule, [:action, :destination, :user_id, :port_type, :port_range]) + |> cast(rule, [:action, :destination, :user_id]) |> apply_changes() end end diff --git a/apps/fz_http/lib/fz_http_web/live/rule_live/rule_list_component.ex b/apps/fz_http/lib/fz_http_web/live/rule_live/rule_list_component.ex index dcab05dd8..72b3533a0 100644 --- a/apps/fz_http/lib/fz_http_web/live/rule_live/rule_list_component.ex +++ b/apps/fz_http/lib/fz_http_web/live/rule_live/rule_list_component.ex @@ -12,7 +12,6 @@ defmodule FzHttpWeb.RuleLive.RuleListComponent do {:ok, socket |> assign(assigns) - |> assign(Rules.defaults()) |> assign( action: action(assigns.id), rule_list: rule_list(assigns), @@ -21,23 +20,12 @@ defmodule FzHttpWeb.RuleLive.RuleListComponent do )} end - @impl Phoenix.LiveComponent - def handle_event("change", %{"rule" => rule_params}, socket) do - changeset = Rules.new_rule(rule_params) - - {:noreply, - socket - |> assign(:changeset, changeset) - |> assign(Rules.defaults(changeset))} - end - @impl true def handle_event("add_rule", %{"rule" => rule_params}, socket) do case Rules.create_rule(rule_params) do {:ok, _rule} -> {:noreply, - assign(socket, changeset: Rules.new_rule(), rule_list: rule_list(socket.assigns)) - |> assign(Rules.defaults())} + assign(socket, changeset: Rules.new_rule(), rule_list: rule_list(socket.assigns))} {:error, changeset} -> {:noreply, assign(socket, changeset: changeset)} @@ -86,12 +74,4 @@ defmodule FzHttpWeb.RuleLive.RuleListComponent do defp user_options(users) do Enum.map(users, fn {id, email} -> {email, id} end) end - - defp port_type_options do - %{TCP: :tcp, UDP: :udp} - end - - defp port_type_display(nil), do: nil - defp port_type_display(:tcp), do: "TCP" - defp port_type_display(:udp), do: "UDP" end diff --git a/apps/fz_http/lib/fz_http_web/live/rule_live/rule_list_component.html.heex b/apps/fz_http/lib/fz_http_web/live/rule_live/rule_list_component.html.heex index d90884117..78aa0d8e2 100644 --- a/apps/fz_http/lib/fz_http_web/live/rule_live/rule_list_component.html.heex +++ b/apps/fz_http/lib/fz_http_web/live/rule_live/rule_list_component.html.heex @@ -6,7 +6,7 @@

- <.form let={f} for={@changeset} id={"#{@action}-form"} phx-change="change" phx-target={@myself} phx-submit="add_rule"> + <.form let={f} for={@changeset} id={"#{@action}-form"} phx-target={@myself} phx-submit="add_rule"> <%= hidden_input f, :action, value: @action %>
@@ -23,31 +23,6 @@ prompt: "Optional user scope" %>
-
-
- <%= select f, - :port_type, - port_type_options(), - prompt: "Optional port range" %> -
-

- <%= error_tag f, :port_type %> -

-
-
-
- <%= text_input f, :port_range, - class: "input #{input_error_class(f, :port_range)}", - placeholder: "Port Range", - disabled: @port_type == nil %> -
-

- Formatted as 'start - stop' or 'port' e.g. 20-80 or 80 (Requires port type) -

-

- <%= error_tag f, :port_range %> -

-
<%= submit "Add", class: "button is-primary" %>
@@ -63,8 +38,6 @@ Destination User Scope - Port Type - Port Range @@ -79,12 +52,6 @@ <%= @users[rule.user_id] %> - - <%= port_type_display(rule.port_type) %> - - - <%= if rule.port_range != nil, do: rule.port_range %> - + "#{nft()} 'add chain inet #{@table_name} forward " <> "{ type filter hook forward priority 0 ; policy accept ; }'" ) @@ -115,20 +83,6 @@ defmodule FzWall.CLI.Helpers.Nft do setup_masquerade() end - @doc """ - Adds a regular nftable chain(not base) - """ - def add_chain(chain_name) do - exec!("#{nft()} 'add chain inet #{@table_name} #{chain_name}'") - end - - @doc """ - Deletes a regular nftable chain(not base) - """ - def delete_chain(chain_name) do - exec!("#{nft()} 'delete chain inet #{@table_name} #{chain_name}'") - end - defp setup_masquerade do if masquerade_ipv4?() do setup_masquerade(:ipv4) @@ -206,7 +160,7 @@ defmodule FzWall.CLI.Helpers.Nft do ) handle -> - exec!("#{nft()} delete rule inet #{@table_name} #{@main_chain} handle #{handle}") + exec!("#{nft()} delete rule inet #{@table_name} forward handle #{handle}") end end @@ -218,55 +172,14 @@ defmodule FzWall.CLI.Helpers.Nft do Regex.run(regex, rules, capture: :all_names) end - defp filter_set_type(:ip, false), do: "ipv4_addr" - defp filter_set_type(:ip6, false), do: "ipv6_addr" + defp set_type(:ip), do: "ipv4_addr" + defp set_type(:ip6), do: "ipv6_addr" - defp filter_set_type(ip_type, true), - do: "#{filter_set_type(ip_type, false)} . inet_proto . inet_service" - - defp dev_set_type(ip_type), do: filter_set_type(ip_type, false) - - defp rule_filter_match_str(type, dest_set, action, false) do + defp rule_match_str(type, nil, dest_set, action) do "#{type} daddr @#{dest_set} ct state != established #{action}" end - defp rule_filter_match_str(type, dest_set, action, true) do - "#{type} daddr . meta l4proto . th dport @#{dest_set} ct state != established #{action}" - end - - defp rule_dev_match_str(ip_type, source_set, jump_chain) do - "#{ip_type} saddr @#{source_set} jump #{jump_chain}" - end - - defp insert_rule(chain, rule_str) do - exec!(""" - #{nft()} 'insert rule inet #{@table_name} #{chain} #{rule_str}' - """) - end - - defp delete_elem_exec(set, elem) do - exec!(""" - #{nft()} 'delete element inet #{@table_name} #{set} { #{elem} }' - """) - end - - defp add_set(name, type) do - exec!(""" - #{nft()} 'add set inet #{@table_name} #{name} { type #{type} ; flags interval ; }' - """) - end - - defp add_elem_exec(set, elem) do - exec!(""" - #{nft()} 'add element inet #{@table_name} #{set} { #{elem} }' - """) - end - - def get_elem(ip) do - "#{standardized_inet(ip)}" - end - - def get_elem(ip, proto, ports) do - "#{standardized_inet(ip)} . #{proto} . #{ports}" + defp rule_match_str(type, source_set, dest_set, action) do + "#{type} saddr @#{source_set} #{rule_match_str(type, nil, dest_set, action)}" end end diff --git a/apps/fz_wall/lib/fz_wall/cli/helpers/sets.ex b/apps/fz_wall/lib/fz_wall/cli/helpers/sets.ex index 5689980a3..82e41f896 100644 --- a/apps/fz_wall/lib/fz_wall/cli/helpers/sets.ex +++ b/apps/fz_wall/lib/fz_wall/cli/helpers/sets.ex @@ -4,52 +4,34 @@ defmodule FzWall.CLI.Helpers.Sets do """ @actions [:drop, :accept] - @ip_types [:ip, :ip6] + @types [:ip, :ip6] - def list_filter_sets(user_id) do - Enum.flat_map( - [true, false], - fn layer4 -> - cross(@ip_types, @actions) - |> Enum.map(fn {ip_type, action} -> - %{ - name: get_filter_set_name(user_id, ip_type, action, layer4), - ip_type: ip_type, - action: action, - layer4: layer4 - } - end) - end - ) + def list_dest_sets(user_id) do + cross(@types, @actions) + |> Enum.map(fn {type, action} -> + %{name: get_dest_set_name(user_id, type, action), type: type} + end) end - def list_dev_sets(user_id) do - Enum.map(@ip_types, fn type -> %{name: get_device_set_name(user_id, type), ip_type: type} end) + def list_sets(nil), do: list_dest_sets(nil) + + def list_sets(user_id) do + list_dest_sets(user_id) ++ + Enum.map(@types, fn type -> %{name: get_device_set_name(user_id, type), type: type} end) end - def get_ip_types do - @ip_types + def get_types do + @types end def get_actions do @actions end - def get_device_set_name(user_id, type), do: "user#{user_id}_#{type}_devices" - def get_user_chain(nil), do: "forward" - def get_user_chain(user_id), do: "user#{user_id}" - - def get_filter_set_name(nil, ip_type, action, false), - do: "#{ip_type}_#{action}" - - def get_filter_set_name(user_id, ip_type, action, false), - do: "user#{user_id}_#{ip_type}_#{action}" - - def get_filter_set_name(nil, ip_type, action, true), - do: "#{ip_type}_#{action}_layer4" - - def get_filter_set_name(user_id, ip_type, action, true), - do: "user#{user_id}_#{ip_type}_#{action}_layer4" + def get_dest_set_name(nil, type, action), do: "#{type}_#{action}" + def get_dest_set_name(user_id, type, action), do: "user_#{user_id}_#{type}_#{action}" + def get_device_set_name(nil, _type), do: nil + def get_device_set_name(user_id, type), do: "user_#{user_id}_#{type}_devices" def cross([x | a], [y | b]) do [{x, y}] ++ cross([x], b) ++ cross(a, [y | b]) diff --git a/apps/fz_wall/lib/fz_wall/cli/live.ex b/apps/fz_wall/lib/fz_wall/cli/live.ex index 5f179c441..7ab11214d 100644 --- a/apps/fz_wall/lib/fz_wall/cli/live.ex +++ b/apps/fz_wall/lib/fz_wall/cli/live.ex @@ -8,6 +8,7 @@ defmodule FzWall.CLI.Live do import FzWall.CLI.Helpers.Sets import FzWall.CLI.Helpers.Nft + import FzCommon.CLI import FzCommon.FzNet, only: [ip_type: 1] require Logger @@ -18,81 +19,60 @@ defmodule FzWall.CLI.Live do teardown_table() setup_table() setup_chains() - setup_rules(nil) + setup_rules() end @doc """ Adds user sets and rules. """ def add_user(user_id) do - add_user_set(user_id) - add_chain(get_user_chain(user_id)) - set_jump_rule(user_id) - setup_rules(user_id) - end - - defp add_user_set(user_id) do - list_dev_sets(user_id) - |> Enum.map(fn set_spec -> add_dev_set(set_spec.name, set_spec.ip_type) end) - end - - defp delete_user_set(user_id) do - list_dev_sets(user_id) - |> Enum.map(fn set_spec -> delete_set(set_spec.name) end) + add_sets(user_id) + add_rules(user_id) end @doc """ Remove user sets and rules. """ def delete_user(user_id) do - delete_jump_rules(user_id) - delete_user_set(user_id) - delete_chain(get_user_chain(user_id)) - delete_filter_sets(user_id) + delete_rules(user_id) + delete_sets(user_id) end @doc """ Adds general sets and rules. """ - def setup_rules(user_id) do - add_filter_sets(user_id) - add_filter_rules(user_id) - end - - def set_jump_rule(user_id) do - list_dev_sets(user_id) - |> Enum.each(fn set_spec -> - insert_dev_rule(set_spec.ip_type, set_spec.name, get_user_chain(user_id)) - end) + def setup_rules do + add_sets(nil) + add_rules(nil) end @doc """ Adds device ip to the user's sets. """ def add_device(device) do - list_dev_sets(device.user_id) - |> Enum.each(fn set_spec -> add_elem(set_spec.name, device[set_spec.ip_type]) end) + get_types() + |> Enum.each(fn type -> add_to_set(device.user_id, device[type], type) end) end @doc """ Adds rule ip to its corresponding sets. """ def add_rule(rule) do - modify_elem(&add_elem/4, rule) + add_to_set(rule.user_id, rule.destination, proto(rule.destination), rule.action) end @doc """ Delete rule destination ip from its corresponding sets. """ def delete_rule(rule) do - modify_elem(&delete_elem/4, rule) + remove_from_set(rule.user_id, rule.destination, proto(rule.destination), rule.action) end @doc """ Eliminates device rules from its corresponding sets. """ def delete_device(device) do - get_ip_types() + get_types() |> Enum.each(fn type -> remove_from_set(device.user_id, device[type], type) end) end @@ -103,35 +83,54 @@ defmodule FzWall.CLI.Live do |> delete_elem(ip) end - defp add_filter_sets(user_id) do - list_filter_sets(user_id) - |> Enum.each(fn set_spec -> - add_filter_set(set_spec.name, set_spec.ip_type, set_spec.layer4) - end) + defp remove_from_set(user_id, ip, type, action) do + get_dest_set_name(user_id, type, action) + |> delete_elem(ip) end - defp delete_filter_sets(user_id) do - list_filter_sets(user_id) - |> Enum.each(fn set_spec -> delete_set(set_spec.name) end) + defp add_to_set(_user_id, nil, _type), do: :no_ip + + defp add_to_set(user_id, ip, type) do + get_device_set_name(user_id, type) + |> add_elem(ip) end - defp add_filter_rules(user_id) do - list_filter_sets(user_id) - |> Enum.each(fn set_spec -> - insert_filter_rule( - get_user_chain(user_id), - set_spec.ip_type, - set_spec.name, - set_spec.action, - set_spec.layer4 + defp add_to_set(user_id, ip, type, action) do + get_dest_set_name(user_id, type, action) + |> add_elem(ip) + end + + defp add_sets(user_id) do + list_sets(user_id) + |> Enum.each(&add_set/1) + end + + defp delete_sets(user_id) do + list_sets(user_id) + |> Enum.each(&delete_set/1) + end + + defp add_rules(user_id) do + cross(get_types(), get_actions()) + |> Enum.each(fn {type, action} -> + insert_rule( + type, + get_device_set_name(user_id, type), + get_dest_set_name(user_id, type, action), + action ) end) end - defp delete_jump_rules(user_id) do - list_dev_sets(user_id) - |> Enum.each(fn set_spec -> - remove_dev_rule(set_spec.ip_type, set_spec.name, get_user_chain(user_id)) + defp delete_rules(user_id) do + cross(get_types(), get_actions()) + |> Enum.each(fn {type, action} -> + remove_rule( + type, + get_device_set_name(user_id, type), + get_dest_set_name(user_id, type, action), + action + ) end) end @@ -142,24 +141,36 @@ defmodule FzWall.CLI.Live do Enum.each(rules, &add_rule/1) end - defp proto(ip) do - case ip_type("#{ip}") do - "IPv4" -> :ip - "IPv6" -> :ip6 - "unknown" -> raise "Unknown protocol." + def egress_address do + case :os.type() do + {:unix, :linux} -> + cmd = "ip address show dev #{egress_interface()} | grep 'inet ' | awk '{print $2}'" + + exec!(cmd) + |> String.trim() + |> String.split("/") + |> List.first() + + {:unix, :darwin} -> + cmd = "ipconfig getifaddr #{egress_interface()}" + + exec!(cmd) + |> String.trim() + + _ -> + raise "OS not supported (yet)" end end - defp modify_elem(action, rule) do - ip_type = proto(rule.destination) - port_type = rule.port_type - layer4 = port_type != nil + defp egress_interface do + Application.fetch_env!(:fz_wall, :egress_interface) + end - action.( - get_filter_set_name(rule.user_id, ip_type, rule.action, layer4), - rule.destination, - port_type, - rule.port_range - ) + defp proto(ip) do + case ip_type("#{ip}") do + "IPv4" -> "ip" + "IPv6" -> "ip6" + "unknown" -> raise "Unknown protocol." + end end end diff --git a/apps/fz_wall/lib/fz_wall/cli/sandbox.ex b/apps/fz_wall/lib/fz_wall/cli/sandbox.ex index a7ee5670f..4c1a57183 100644 --- a/apps/fz_wall/lib/fz_wall/cli/sandbox.ex +++ b/apps/fz_wall/lib/fz_wall/cli/sandbox.ex @@ -13,4 +13,5 @@ defmodule FzWall.CLI.Sandbox do def delete_device(_device), do: @default_returned def add_user(_user), do: @default_returned def delete_user(_user), do: @default_returned + def egress_address, do: "10.0.0.1" end diff --git a/apps/fz_wall/test/fz_wall/cli/sandbox_test.exs b/apps/fz_wall/test/fz_wall/cli/sandbox_test.exs index 0417dfd74..8a5ca1bda 100644 --- a/apps/fz_wall/test/fz_wall/cli/sandbox_test.exs +++ b/apps/fz_wall/test/fz_wall/cli/sandbox_test.exs @@ -2,4 +2,8 @@ defmodule FzWall.CLI.SandboxTest do use ExUnit.Case, async: true import FzWall.CLI + + test "egress_address()" do + assert is_binary(cli().egress_address()) + end end