diff --git a/.tool-versions b/.tool-versions index 0e38921e8..40504e0ed 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,4 +1,4 @@ elixir 1.11.3-otp-23 -erlang 23.2.5 +erlang 23.2.7 nodejs 14.15.5 python 3.7.9 diff --git a/apps/fg_common/.formatter.exs b/apps/fg_common/.formatter.exs new file mode 100644 index 000000000..d2cda26ed --- /dev/null +++ b/apps/fg_common/.formatter.exs @@ -0,0 +1,4 @@ +# Used by "mix format" +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/apps/fg_common/.gitignore b/apps/fg_common/.gitignore new file mode 100644 index 000000000..ae9a7bc47 --- /dev/null +++ b/apps/fg_common/.gitignore @@ -0,0 +1,27 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where third-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Ignore package tarball (built via "mix hex.build"). +fg_common-*.tar + + +# Temporary files for e.g. tests +/tmp diff --git a/apps/fg_common/README.md b/apps/fg_common/README.md new file mode 100644 index 000000000..bb89d5d3c --- /dev/null +++ b/apps/fg_common/README.md @@ -0,0 +1,20 @@ +# FgCommon + +**TODO: Add description** + +## Installation + +If [available in Hex](https://hex.pm/docs/publish), the package can be installed +by adding `fg_common` to your list of dependencies in `mix.exs`: + +```elixir +def deps do + [ + {:fg_common, "~> 0.1.0"} + ] +end +``` + +Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) +and published on [HexDocs](https://hexdocs.pm). Once published, the docs can +be found at [https://hexdocs.pm/fg_common](https://hexdocs.pm/fg_common). diff --git a/apps/fg_common/lib/cli.ex b/apps/fg_common/lib/cli.ex new file mode 100644 index 000000000..2ccfa6bbe --- /dev/null +++ b/apps/fg_common/lib/cli.ex @@ -0,0 +1,22 @@ +defmodule FgCommon.CLI do + @moduledoc """ + Handles low-level CLI facilities. + """ + + defp bash(cmd) do + System.cmd("bash", ["-c", cmd]) + end + + def exec!(cmd) do + case bash(cmd) do + {result, 0} -> + result + + {error, _} -> + raise """ + Error executing command #{cmd} with error #{error}. + FireGuard cannot recover from this error. + """ + end + end +end diff --git a/apps/fg_common/lib/fg_common.ex b/apps/fg_common/lib/fg_common.ex new file mode 100644 index 000000000..1d13913b9 --- /dev/null +++ b/apps/fg_common/lib/fg_common.ex @@ -0,0 +1,5 @@ +defmodule FgCommon do + @moduledoc """ + Documentation for `FgCommon`. + """ +end diff --git a/apps/fg_common/mix.exs b/apps/fg_common/mix.exs new file mode 100644 index 000000000..72d1b2331 --- /dev/null +++ b/apps/fg_common/mix.exs @@ -0,0 +1,33 @@ +defmodule FgCommon.MixProject do + use Mix.Project + + def project do + [ + app: :fg_common, + version: "0.1.0", + build_path: "../../_build", + config_path: "../../config/config.exs", + deps_path: "../../deps", + lockfile: "../../mix.lock", + elixir: "~> 1.11", + start_permanent: Mix.env() == :prod, + deps: deps() + ] + end + + # Run "mix help compile.app" to learn about applications. + def application do + [ + extra_applications: [:logger] + ] + end + + # Run "mix help deps" to learn about dependencies. + defp deps do + [ + # {:dep_from_hexpm, "~> 0.3.0"}, + # {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"}, + # {:sibling_app_in_umbrella, in_umbrella: true} + ] + end +end diff --git a/apps/fg_common/test/fg_common_test.exs b/apps/fg_common/test/fg_common_test.exs new file mode 100644 index 000000000..34d8fc615 --- /dev/null +++ b/apps/fg_common/test/fg_common_test.exs @@ -0,0 +1,8 @@ +defmodule FgCommonTest do + use ExUnit.Case + doctest FgCommon + + test "greets the world" do + assert FgCommon.hello() == :world + end +end diff --git a/apps/fg_common/test/test_helper.exs b/apps/fg_common/test/test_helper.exs new file mode 100644 index 000000000..869559e70 --- /dev/null +++ b/apps/fg_common/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start() diff --git a/apps/fg_http/lib/fg_http/devices.ex b/apps/fg_http/lib/fg_http/devices.ex index 7e051e8ab..379efad77 100644 --- a/apps/fg_http/lib/fg_http/devices.ex +++ b/apps/fg_http/lib/fg_http/devices.ex @@ -4,7 +4,7 @@ defmodule FgHttp.Devices do """ import Ecto.Query, warn: false - alias FgHttp.{Devices.Device, Repo} + alias FgHttp.{Devices.Device, Repo, Util.FgCrypto} def list_devices do Repo.all(Device) @@ -52,6 +52,10 @@ defmodule FgHttp.Devices do Device.changeset(device, %{}) end + def rand_name do + "Device " <> FgCrypto.rand_string(8) + end + def to_peer_list do for device <- Repo.all(Device) do %{ diff --git a/apps/fg_http/lib/fg_http/devices/device.ex b/apps/fg_http/lib/fg_http/devices/device.ex index 1e65a6ec8..56736ed6a 100644 --- a/apps/fg_http/lib/fg_http/devices/device.ex +++ b/apps/fg_http/lib/fg_http/devices/device.ex @@ -15,7 +15,8 @@ defmodule FgHttp.Devices.Device do field :preshared_key, FgHttp.Encrypted.Binary field :private_key, FgHttp.Encrypted.Binary field :server_public_key, :string - field :last_ip, EctoNetwork.INET + field :remote_ip, EctoNetwork.INET + field :interface_address, EctoNetwork.INET field :last_seen_at, :utc_datetime_usec has_many :rules, Rule @@ -27,7 +28,8 @@ defmodule FgHttp.Devices.Device do def changeset(device, attrs) do device |> cast(attrs, [ - :last_ip, + :remote_ip, + :interface_address, :server_public_key, :private_key, :preshared_key, @@ -37,13 +39,16 @@ defmodule FgHttp.Devices.Device do ]) |> validate_required([ :user_id, + :interface_address, :name, :public_key, :server_public_key, :private_key, :preshared_key ]) - |> unique_constraint([:name, :public_key]) - |> unique_constraint([:name, :private_key]) + |> unique_constraint(:public_key) + |> unique_constraint(:private_key) + |> unique_constraint(:preshared_key) + |> unique_constraint([:user_id, :name]) end end diff --git a/apps/fg_http/lib/fg_http/ecto_enums.ex b/apps/fg_http/lib/fg_http/ecto_enums.ex index f8a57bad6..d2e552c5f 100644 --- a/apps/fg_http/lib/fg_http/ecto_enums.ex +++ b/apps/fg_http/lib/fg_http/ecto_enums.ex @@ -1,18 +1,4 @@ import EctoEnum # We only allow dropping or accepting packets for now -defenum(RuleActionEnum, :action, [:drop, :allow]) - -# See http://ipset.netfilter.org/iptables.man.html -defenum(RuleProtocolEnum, :protocol, [ - :all, - :tcp, - :udp, - :udplite, - :icmp, - :icmpv6, - :esp, - :ah, - :sctp, - :mh -]) +defenum(RuleActionEnum, :action, [:block, :allow]) diff --git a/apps/fg_http/lib/fg_http/repo.ex b/apps/fg_http/lib/fg_http/repo.ex index 0bdbfc516..bd728d7ac 100644 --- a/apps/fg_http/lib/fg_http/repo.ex +++ b/apps/fg_http/lib/fg_http/repo.ex @@ -5,10 +5,13 @@ defmodule FgHttp.Repo do alias FgHttp.Devices require Logger - import FgHttpWeb.EventHelpers + import FgHttpWeb.Events def init(_) do - # Notify FgVpn.Server the config has been loaded - send(vpn_pid(), {:set_config, Devices.to_peer_list()}) + # Set firewall rules + set_rules() + + # Set WireGuard peer config + set_config() end end diff --git a/apps/fg_http/lib/fg_http/rules.ex b/apps/fg_http/lib/fg_http/rules.ex index 364cf890f..7d31ced0f 100644 --- a/apps/fg_http/lib/fg_http/rules.ex +++ b/apps/fg_http/lib/fg_http/rules.ex @@ -6,7 +6,7 @@ defmodule FgHttp.Rules do import Ecto.Query, warn: false alias FgHttp.Repo - alias FgHttp.Rules.Rule + alias FgHttp.{Devices.Device, Rules.Rule} def get_rule!(id), do: Repo.get!(Rule, id) @@ -29,4 +29,23 @@ defmodule FgHttp.Rules do def change_rule(%Rule{} = rule) do Rule.changeset(rule, %{}) end + + def to_iptables do + query = + from d in Device, + join: r in Rule, + on: r.device_id == d.id, + where: r.enabled == true, + # :block enum is indexed 0 + order_by: r.action, + select: { + # Need to select both ipv4 and ipv6 since we don't know which the + # corresponding rule is. + {d.interface_address4, d.interface_address6}, + r.destination, + r.action + } + + Repo.all(query) + end end diff --git a/apps/fg_http/lib/fg_http/rules/rule.ex b/apps/fg_http/lib/fg_http/rules/rule.ex index 3d944008d..8d24b45d8 100644 --- a/apps/fg_http/lib/fg_http/rules/rule.ex +++ b/apps/fg_http/lib/fg_http/rules/rule.ex @@ -6,15 +6,12 @@ defmodule FgHttp.Rules.Rule do use Ecto.Schema import Ecto.Changeset - alias FgHttp.{Devices.Device} + alias FgHttp.{Devices, Devices.Device} schema "rules" do field :destination, EctoNetwork.INET - field :action, RuleActionEnum, default: "drop" - field :priority, :integer, default: 0 + field :action, RuleActionEnum, default: :block field :enabled, :boolean, default: true - field :port_number, :integer - field :protocol, RuleProtocolEnum, default: "all" belongs_to :device, Device @@ -25,15 +22,36 @@ defmodule FgHttp.Rules.Rule do rule |> cast(attrs, [ :device_id, - :priority, :action, :destination, - :port_number, - :protocol, :enabled ]) - |> validate_required([:device_id, :priority, :action, :destination, :protocol, :enabled]) - |> validate_number(:priority, greater_than_or_equal_to: 0, less_than_or_equal_to: 100) - |> validate_number(:port_number, greater_than_or_equal_to: 0, less_than_or_equal_to: 65_535) + |> validate_required([:device_id, :action, :destination, :enabled]) + end + + def iptables_spec(rule) do + device = Devices.get_device!(rule.device_id) + + source = + if ipv4?(rule) do + device.interface_address4 + else + device.interface_address6 + end + + {source, rule.destination, rule.action} + end + + defp ipv4?(rule) do + case parse_ipv4(rule) do + {:ok, _} -> true + {:error, _} -> false + end + end + + defp parse_ipv4(rule) do + rule.destination + |> String.to_charlist() + |> :inet.parse_ipv4_address() end end diff --git a/apps/fg_http/lib/fg_http_web/controllers/device_controller.ex b/apps/fg_http/lib/fg_http_web/controllers/device_controller.ex index 464566a06..2e822970a 100644 --- a/apps/fg_http/lib/fg_http_web/controllers/device_controller.ex +++ b/apps/fg_http/lib/fg_http_web/controllers/device_controller.ex @@ -16,32 +16,21 @@ defmodule FgHttpWeb.DeviceController do end def create(conn, _params) do - case event_module().create_device_sync() do - {:ok, device_attrs} -> - attributes = - Map.merge( - %{ - user_id: conn.assigns.session.id, - name: "Device #{DateTime.utc_now() |> DateTime.to_unix(:microsecond)}" - }, - device_attrs - ) + # XXX: Remove device from WireGuard if create isn't successful + {:device_created, device_attrs} = event_module().create_device() - case Devices.create_device(attributes) do - {:ok, device} -> - redirect(conn, to: Routes.device_path(conn, :show, device)) + attributes = + Map.merge(%{user_id: conn.assigns.session.id, name: Devices.rand_name()}, device_attrs) - {:error, %Ecto.Changeset{} = changeset} -> - msg = ErrorHelpers.aggregated_errors(changeset) + case Devices.create_device(attributes) do + {:ok, device} -> + redirect(conn, to: Routes.device_path(conn, :show, device)) - conn - |> put_flash(:error, "Error creating device. #{msg}") - |> redirect(to: Routes.device_path(conn, :index)) - end + {:error, %Ecto.Changeset{} = changeset} -> + msg = ErrorHelpers.aggregated_errors(changeset) - {:error, msg} -> conn - |> put_flash(:error, "Error creating device: #{msg}") + |> put_flash(:error, "Error creating device. #{msg}") |> redirect(to: Routes.device_path(conn, :index)) end end @@ -73,11 +62,20 @@ defmodule FgHttpWeb.DeviceController do def delete(conn, %{"id" => id}) do device = Devices.get_device!(id) - {:ok, _device} = Devices.delete_device(device) - conn - |> put_flash(:info, "Device deleted successfully.") - |> redirect(to: Routes.device_path(conn, :index)) + case Devices.delete_device(device) do + {:ok, _deleted_device} -> + {:device_deleted, _deleted_pubkey} = event_module().delete_device(device.public_key) + + conn + |> put_flash(:info, "Device deleted successfully.") + |> redirect(to: Routes.device_path(conn, :index)) + + {:error, msg} -> + conn + |> put_flash(:error, "Error deleting device: #{msg}") + |> redirect(to: Routes.device_path(conn, :index)) + end end defp event_module do diff --git a/apps/fg_http/lib/fg_http_web/controllers/rule_controller.ex b/apps/fg_http/lib/fg_http_web/controllers/rule_controller.ex index 14de105c8..e7c89c37d 100644 --- a/apps/fg_http/lib/fg_http_web/controllers/rule_controller.ex +++ b/apps/fg_http/lib/fg_http_web/controllers/rule_controller.ex @@ -13,19 +13,15 @@ defmodule FgHttpWeb.RuleController do render(conn, "index.html", device: device, rules: device.rules) end - def new(conn, %{"device_id" => device_id}) do - device = Devices.get_device!(device_id) - - changeset = Rules.change_rule(%Rule{device_id: device_id}) - render(conn, "new.html", changeset: changeset, device: device) - end - def create(conn, %{"device_id" => device_id, "rule" => rule_params}) do # XXX RBAC all_params = Map.merge(rule_params, %{"device_id" => device_id}) case Rules.create_rule(all_params) do {:ok, rule} -> + # XXX: Create in after-commit + :rule_added = add_rule_to_firewall(rule) + conn |> put_flash(:info, "Rule created successfully.") |> redirect(to: Routes.device_rule_path(conn, :index, rule.device_id)) @@ -36,41 +32,23 @@ defmodule FgHttpWeb.RuleController do end end - def show(conn, %{"id" => id}) do - rule = Rules.get_rule!(id) - render(conn, "show.html", rule: rule) - end - - def edit(conn, %{"id" => id}) do - rule = Rules.get_rule!(id) - device = Devices.get_device!(rule.device_id) - changeset = Rules.change_rule(rule) - - render(conn, "edit.html", rule: rule, device: device, changeset: changeset) - end - - def update(conn, %{"id" => id, "rule" => rule_params}) do - rule = Rules.get_rule!(id) - device = Devices.get_device!(rule.device_id) - - case Rules.update_rule(rule, rule_params) do - {:ok, rule} -> - conn - |> put_flash(:info, "Rule updated successfully.") - |> redirect(to: Routes.device_rule_path(conn, :index, rule.device_id)) - - {:error, changeset} -> - render(conn, "edit.html", rule: rule, device: device, changeset: changeset) - end - end - def delete(conn, %{"id" => id}) do rule = Rules.get_rule!(id) device_id = rule.device_id {:ok, _rule} = Rules.delete_rule(rule) + # XXX: Delete in after-commit + :rule_deleted = delete_rule_from_firewall(rule) + conn |> put_flash(:info, "Rule deleted successfully.") |> redirect(to: Routes.device_rule_path(conn, :index, device_id)) end + + defp add_rule_to_firewall(rule) do + GenServer.call() + end + + defp delete_rule_from_firewall(rule) do + end end diff --git a/apps/fg_http/lib/fg_http_web/event_helpers.ex b/apps/fg_http/lib/fg_http_web/event_helpers.ex index e81eca3ea..705c1cc5f 100644 --- a/apps/fg_http/lib/fg_http_web/event_helpers.ex +++ b/apps/fg_http/lib/fg_http_web/event_helpers.ex @@ -12,4 +12,14 @@ defmodule FgHttpWeb.EventHelpers do {:ok, vpn_pid} end end + + def wall_pid do + case :global.whereis_name(:fg_wall_server) do + :undefined -> + {:error, "VPN server process not registered in global registry."} + + wall_pid -> + {:ok, wall_pid} + end + end end diff --git a/apps/fg_http/lib/fg_http_web/events.ex b/apps/fg_http/lib/fg_http_web/events.ex index 23916a1c4..27c87c85e 100644 --- a/apps/fg_http/lib/fg_http_web/events.ex +++ b/apps/fg_http/lib/fg_http_web/events.ex @@ -4,25 +4,29 @@ defmodule FgHttpWeb.Events do """ import FgHttpWeb.EventHelpers + alias FgHttp.Devices - def create_device_sync do - case vpn_pid() do - {:ok, pid} -> - send(pid, {:create_device, self()}) + def create_device do + GenServer.call(vpn_pid(), {:create_device}) + end - receive do - {:device_created, privkey, pubkey, server_pubkey, psk} -> - {:ok, - %{ - private_key: privkey, - public_key: pubkey, - server_public_key: server_pubkey, - preshared_key: psk - }} - end + def delete_device(device_pubkey) do + GenServer.call(vpn_pid(), {:delete_device, device_pubkey}) + end - {:error, msg} -> - {:error, msg} - end + def add_rule(rule) do + GenServer.call(wall_pid(), {:add_rule, Rule.iptables_spec(rule)}) + end + + def delete_rule(rule) do + GenServer.call(wall_pid(), {:delete_rule, Rule.iptables_spec(rule)}) + end + + def set_config do + GenServer.call(vpn_pid(), {:set_config, Devices.to_peer_list()}) + end + + def set_rules do + GenServer.call(wall_pid(), {:set_rules, Rules.to_iptables()}) end end diff --git a/apps/fg_http/lib/fg_http_web/mock_events.ex b/apps/fg_http/lib/fg_http_web/mock_events.ex index c89bb9896..49dbcc4e6 100644 --- a/apps/fg_http/lib/fg_http_web/mock_events.ex +++ b/apps/fg_http/lib/fg_http_web/mock_events.ex @@ -7,8 +7,8 @@ defmodule FgHttpWeb.MockEvents do inside FgHttp and use that for the tests. """ - def create_device_sync do - {:ok, + def create_device do + {:device_created, %{ private_key: "privkey", public_key: "pubkey", @@ -16,4 +16,16 @@ defmodule FgHttpWeb.MockEvents do preshared_key: "preshared_key" }} end + + def delete_device(pubkey) do + {:device_deleted, pubkey} + end + + def add_rule(_rule) do + :rule_added + end + + def delete_rule(_rule) do + :rule_deleted + end end diff --git a/apps/fg_http/lib/fg_http_web/router.ex b/apps/fg_http/lib/fg_http_web/router.ex index e67b5167e..1267b0326 100644 --- a/apps/fg_http/lib/fg_http_web/router.ex +++ b/apps/fg_http/lib/fg_http_web/router.ex @@ -32,10 +32,10 @@ defmodule FgHttpWeb.Router do resources "/users", UserController, only: [:new, :create] resources "/devices", DeviceController, except: [:new] do - resources "/rules", RuleController, only: [:new, :index, :create] + resources "/rules", RuleController, only: [:index, :create] end - resources "/rules", RuleController, only: [:show, :update, :delete, :edit] + resources "/rules", RuleController, only: [:delete] resources "/session", SessionController, singleton: true, only: [:delete] resources "/sessions", SessionController, only: [:new, :create] diff --git a/apps/fg_http/lib/fg_http_web/templates/device/index.html.eex b/apps/fg_http/lib/fg_http_web/templates/device/index.html.eex index 470d3a20c..0fa6110ba 100644 --- a/apps/fg_http/lib/fg_http_web/templates/device/index.html.eex +++ b/apps/fg_http/lib/fg_http_web/templates/device/index.html.eex @@ -7,7 +7,7 @@ Name Rules Public key - Last IP + Remote IP @@ -17,7 +17,7 @@ <%= device.name %> <%= link rules_title(device), to: Routes.device_rule_path(@conn, :index, device) %> <%= device.public_key %> - <%= device.last_ip || "Never connected" %> + <%= device.remote_ip || "Never connected" %> <%= link "Show", to: Routes.device_path(@conn, :show, device) %> | diff --git a/apps/fg_http/priv/repo/migrations/20200228145810_create_devices.exs b/apps/fg_http/priv/repo/migrations/20200228145810_create_devices.exs index 90d613bd6..d4dc553a6 100644 --- a/apps/fg_http/priv/repo/migrations/20200228145810_create_devices.exs +++ b/apps/fg_http/priv/repo/migrations/20200228145810_create_devices.exs @@ -9,7 +9,9 @@ defmodule FgHttp.Repo.Migrations.CreateDevices do add :preshared_key, :bytea, null: false add :private_key, :bytea, null: false add :server_public_key, :string, null: false - add :last_ip, :inet + add :remote_ip, :inet + add :interface_address, :inet, null: false + add :last_seen_at, :utc_datetime_usec add :user_id, references(:users, on_delete: :delete_all), null: false timestamps(type: :utc_datetime_usec) @@ -18,6 +20,7 @@ defmodule FgHttp.Repo.Migrations.CreateDevices do create index(:devices, [:user_id]) create unique_index(:devices, [:public_key]) create unique_index(:devices, [:private_key]) - create unique_index(:devices, [:name]) + create unique_index(:devices, [:preshared_key]) + create unique_index(:devices, [:user_id, :name]) end end diff --git a/apps/fg_http/priv/repo/migrations/20200228154815_create_rules.exs b/apps/fg_http/priv/repo/migrations/20200228154815_create_rules.exs index 1fc9af1ca..4077fd95f 100644 --- a/apps/fg_http/priv/repo/migrations/20200228154815_create_rules.exs +++ b/apps/fg_http/priv/repo/migrations/20200228154815_create_rules.exs @@ -1,22 +1,19 @@ -defmodule FgHttp.Repo.Migrations.CreateRules do +defmodule FgHttp.Repo.Migrations.CreateBlacklistEntries do use Ecto.Migration def change do RuleActionEnum.create_type() - RuleProtocolEnum.create_type() create table(:rules) do - add :destination, :inet - add :protocol, RuleProtocolEnum.type(), default: "all", null: false - add :action, RuleActionEnum.type(), default: "drop", null: false - add :priority, :integer, default: 0, null: false - add :enabled, :boolean, default: false, null: false - add :port_number, :integer + add :destination, :inet, null: false + add :action, RuleActionEnum.type(), default: :block, null: false + add :enabled, :boolean, default: true, null: false add :device_id, references(:devices, on_delete: :delete_all), null: false timestamps(type: :utc_datetime_usec) end - create index(:rules, [:device_id]) + create index(:rules, [:device_id, :action]) + create index(:rules, [:enabled]) end end diff --git a/apps/fg_http/priv/repo/migrations/20201115200531_add_last_seen_at_to_devices.exs b/apps/fg_http/priv/repo/migrations/20201115200531_add_last_seen_at_to_devices.exs deleted file mode 100644 index 11095e29a..000000000 --- a/apps/fg_http/priv/repo/migrations/20201115200531_add_last_seen_at_to_devices.exs +++ /dev/null @@ -1,9 +0,0 @@ -defmodule FgHttp.Repo.Migrations.AddLastSeenAtToDevices do - use Ecto.Migration - - def change do - alter table(:devices) do - add :last_seen_at, :utc_datetime_usec - end - end -end diff --git a/apps/fg_http/priv/repo/seeds.exs b/apps/fg_http/priv/repo/seeds.exs index ae865e45a..9ca5c447d 100644 --- a/apps/fg_http/priv/repo/seeds.exs +++ b/apps/fg_http/priv/repo/seeds.exs @@ -10,7 +10,7 @@ # We recommend using the bang functions (`insert!`, `update!` # and so on) as they will fail if something goes wrong. -alias FgHttp.{Devices, Rules, Users} +alias FgHttp.{Devices, BlacklistEntries, Users} {:ok, user} = Users.create_user(%{ @@ -27,11 +27,12 @@ alias FgHttp.{Devices, Rules, Users} server_public_key: "QFvMfHTjlJN9cfUiK1w4XmxOomH6KRTCMrVC6z3TWFM=", private_key: "2JSZtpSHM+69Hm7L3BSGIymbq0byw39iWLevKESd1EM=", preshared_key: "hQS+GkbTWfEhueLM8RJ2anjC4RxzdgL4dpTIetHf6GU=", - last_ip: %Postgrex.INET{address: {127, 0, 0, 1}} + remote_ip: %Postgrex.INET{address: {127, 0, 0, 1}}, + interface_address: %Postgrex.INET{address: {10, 0, 0, 1}} }) {:ok, _rule} = - Rules.create_rule(%{ + BlacklistEntries.create_entry(%{ device_id: device.id, destination: %Postgrex.INET{address: {0, 0, 0, 0}, netmask: 0} }) diff --git a/apps/fg_http/test/support/fixtures.ex b/apps/fg_http/test/support/fixtures.ex index ef5a6a29a..a0cbfec4a 100644 --- a/apps/fg_http/test/support/fixtures.ex +++ b/apps/fg_http/test/support/fixtures.ex @@ -24,6 +24,7 @@ defmodule FgHttp.Fixtures do attrs |> Enum.into(%{user_id: user().id}) |> Enum.into(%{ + interface_address: "10.0.0.1", public_key: "test-pubkey", name: "factory", private_key: "test-privkey", diff --git a/apps/fg_vpn/lib/fg_vpn/cli.ex b/apps/fg_vpn/lib/fg_vpn/cli.ex index ec09a1bcb..7722cf46c 100644 --- a/apps/fg_vpn/lib/fg_vpn/cli.ex +++ b/apps/fg_vpn/lib/fg_vpn/cli.ex @@ -4,6 +4,6 @@ defmodule FgVpn.CLI do """ def cli do - Application.get_env(:fg_vpn, :cli) + Application.fetch_env!(:fg_vpn, :cli) end end diff --git a/apps/fg_vpn/lib/fg_vpn/cli/live.ex b/apps/fg_vpn/lib/fg_vpn/cli/live.ex index 91e8e4e7c..dd042544e 100644 --- a/apps/fg_vpn/lib/fg_vpn/cli/live.ex +++ b/apps/fg_vpn/lib/fg_vpn/cli/live.ex @@ -17,6 +17,8 @@ defmodule FgVpn.CLI.Live do @iface_name "wg-fireguard" + import FgCommon.CLI + def setup do create_interface() setup_iptables() @@ -82,19 +84,6 @@ defmodule FgVpn.CLI.Live do end end - def exec!(cmd) do - case bash(cmd) do - {result, 0} -> - result - - {error, _} -> - raise """ - Error executing command #{cmd} with error #{error}. - FireGuard cannot recover from this error. - """ - end - end - defp show(subcommand) do exec!("wg show #{@iface_name} #{subcommand}") end @@ -118,20 +107,22 @@ defmodule FgVpn.CLI.Live do end end + # XXX: Move to FgWall and call via PID? defp setup_iptables do - exec!( - "iptables -A FORWARD -i %i -j ACCEPT; iptables -A FORWARD -o %i -j ACCEPT; iptables -t nat -A POSTROUTING -o #{ - egress_interface() - } -j MASQUERADE" - ) + exec!("\ + iptables -A FORWARD -i %i -j ACCEPT;\ + iptables -A FORWARD -o %i -j ACCEPT; \ + iptables -t nat -A POSTROUTING -o #{egress_interface()} -j MASQUERADE\ + ") end + # XXX: Move to FgWall and call via PID? defp teardown_iptables do - exec!( - "iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -j ACCEPT; iptables -t nat -D POSTROUTING -o #{ - egress_interface() - } -j MASQUERADE" - ) + exec!("\ + iptables -D FORWARD -i %i -j ACCEPT;\ + iptables -D FORWARD -o %i -j ACCEPT;\ + iptables -t nat -D POSTROUTING -o #{egress_interface()} -j MASQUERADE\ + ") end defp up_interface do @@ -141,8 +132,4 @@ defmodule FgVpn.CLI.Live do defp down_interface do exec!("ifconfig #{@iface_name} down") end - - defp bash(cmd) do - System.cmd("bash", ["-c", cmd]) - end end diff --git a/apps/fg_vpn/lib/fg_vpn/config.ex b/apps/fg_vpn/lib/fg_vpn/config.ex index e16175d31..84f4a4065 100644 --- a/apps/fg_vpn/lib/fg_vpn/config.ex +++ b/apps/fg_vpn/lib/fg_vpn/config.ex @@ -1,6 +1,6 @@ defmodule FgVpn.Config do @moduledoc """ - Functions for managing the FireGuard configuration. + Functions for managing the WireGuard configuration. """ @default_interface_ip "172.16.59.1" @@ -9,8 +9,7 @@ defmodule FgVpn.Config do defstruct interface_ip: @default_interface_ip, listen_port: 51_820, - peers: MapSet.new([]), - uncommitted_peers: MapSet.new([]) + peers: MapSet.new([]) def render(config) do "private-key #{private_key()} listen-port #{config.listen_port} " <> diff --git a/apps/fg_vpn/lib/fg_vpn/server.ex b/apps/fg_vpn/lib/fg_vpn/server.ex index 96838b683..a8d5faf91 100644 --- a/apps/fg_vpn/lib/fg_vpn/server.ex +++ b/apps/fg_vpn/lib/fg_vpn/server.ex @@ -30,48 +30,34 @@ defmodule FgVpn.Server do end @impl true - def handle_info({:create_device, sender}, config) do + def handle_call({:create_device}, config) do server_pubkey = Config.public_key() {privkey, pubkey} = cli().genkey() psk = cli().genpsk() - uncommitted_peers = MapSet.put(config.uncommitted_peers, pubkey) - new_config = Map.put(config, :uncommitted_peers, uncommitted_peers) - send(sender, {:device_created, privkey, pubkey, server_pubkey, psk}) - {:noreply, new_config} - end - - @impl true - def handle_info({:commit_peer, %{} = attrs}, config) do new_config = - if MapSet.member?(config.uncommitted_peers, attrs[:public_key]) do - new_peer = Map.merge(%Peer{}, attrs) - new_peers = MapSet.put(config.peers, new_peer) - new_uncommitted_peers = MapSet.delete(config.uncommitted_peers, attrs[:public_key]) + Map.put( + config, + :peers, + MapSet.put(config.peers, pubkey) + ) - config - |> Map.put(:uncommitted_peers, new_uncommitted_peers) - |> Map.put(:peers, new_peers) - else - config - end - - apply(new_config) - - {:noreply, new_config} + {:reply, {:device_created, privkey, pubkey, server_pubkey, psk}, new_config} end @impl true - def handle_info({:remove_peer, pubkey}, config) do + def handle_call({:delete_device, pubkey}, config) do new_peers = MapSet.delete(config.peers, %Peer{public_key: pubkey}) new_config = %{config | peers: new_peers} apply(new_config) - {:noreply, new_config} + + {:reply, {:device_deleted, pubkey}, new_config} end @impl true - def handle_cast({:set_config, new_config}, _config) do - {:noreply, new_config} + def handle_call({:set_config, new_config}, _config) do + apply(new_config) + {:reply, :ok, new_config} end @doc """ diff --git a/apps/fg_vpn/mix.exs b/apps/fg_vpn/mix.exs index 38398eb1a..afc76f4a6 100644 --- a/apps/fg_vpn/mix.exs +++ b/apps/fg_vpn/mix.exs @@ -33,6 +33,7 @@ defmodule FgVpn.MixProject do # Run "mix help deps" to learn about dependencies. defp deps do [ + {:fg_common, in_umbrella: true}, {:credo, "~> 1.4", only: [:dev, :test], runtime: false}, {:excoveralls, "~> 0.13", only: :test} ] diff --git a/apps/fg_vpn/test/fg_vpn/config_test.exs b/apps/fg_vpn/test/fg_vpn/config_test.exs index 9771ab69a..0736ba96a 100644 --- a/apps/fg_vpn/test/fg_vpn/config_test.exs +++ b/apps/fg_vpn/test/fg_vpn/config_test.exs @@ -22,8 +22,7 @@ defmodule FgVpn.ConfigTest do allowed_ips: "test-allowed-ips", preshared_key: "test-preshared-key" } - ]), - uncommitted_peers: MapSet.new(["uncommitted-pubkey"]) + ]) } assert Config.render(config) == @populated_config diff --git a/apps/fg_vpn/test/fg_vpn/server_test.exs b/apps/fg_vpn/test/fg_vpn/server_test.exs index 6d38d2459..0c468fd34 100644 --- a/apps/fg_vpn/test/fg_vpn/server_test.exs +++ b/apps/fg_vpn/test/fg_vpn/server_test.exs @@ -26,21 +26,6 @@ defmodule FgVpn.ServerTest do assert [] = MapSet.to_list(:sys.get_state(test_pid).peers) end - @tag stubbed_config: @empty - test "writes peers to config when device is verified", %{test_pid: test_pid} do - send(test_pid, {:create_device, self()}) - - assert_receive {:device_created, _, _, _, _} - [pubkey | _tail] = MapSet.to_list(:sys.get_state(test_pid).uncommitted_peers) - - send(test_pid, {:commit_peer, %{public_key: pubkey}}) - - # XXX: Avoid sleeping - Process.sleep(100) - - assert MapSet.to_list(:sys.get_state(test_pid).peers) == [%Peer{public_key: pubkey}] - end - @tag stubbed_config: @single_peer test "removes peers from config when removed", %{test_pid: test_pid} do send(test_pid, {:remove_peer, "test-pubkey"}) diff --git a/apps/fg_wall/lib/fg_wall/application.ex b/apps/fg_wall/lib/fg_wall/application.ex index 05145dafa..d9a13edcb 100644 --- a/apps/fg_wall/lib/fg_wall/application.ex +++ b/apps/fg_wall/lib/fg_wall/application.ex @@ -6,7 +6,9 @@ defmodule FgWall.Application do use Application def start(_type, _args) do - children = [] + children = [ + FgWall.Server + ] # See https://hexdocs.pm/elixir/Supervisor.html # for other strategies and supported options diff --git a/apps/fg_wall/lib/fg_wall/cli.ex b/apps/fg_wall/lib/fg_wall/cli.ex new file mode 100644 index 000000000..47963f2cf --- /dev/null +++ b/apps/fg_wall/lib/fg_wall/cli.ex @@ -0,0 +1,9 @@ +defmodule FgWall.CLI do + @moduledoc """ + Determines adapter to use for CLI commands. + """ + + def cli do + Application.fetch_env!(:fg_wall, :cli) + end +end diff --git a/apps/fg_wall/lib/fg_wall/cli/live.ex b/apps/fg_wall/lib/fg_wall/cli/live.ex new file mode 100644 index 000000000..e43055a0a --- /dev/null +++ b/apps/fg_wall/lib/fg_wall/cli/live.ex @@ -0,0 +1,70 @@ +defmodule FgWall.CLI.Live do + @moduledoc """ + A low-level module for interacting with the iptables CLI. + + Rules operate on the iptables forward chain to block outgoing packets to + specified IP addresses, ports, and protocols from FireGuard device IPs. + + Note that iptables chains and rules are mutually exclusive between IPv4 and IPv6. + """ + + import FgCommon.CLI + + @setup_chain_cmd "iptables -N fireguard && iptables6 -N fireguard" + @teardown_chain_cmd "iptables -F fireguard &&\ + iptables -X fireguard &&\ + iptables6 -F fireguard &&\ + iptables6 -X fireguard" + + @doc """ + Sets up the FireGuard iptables chain. + """ + def setup do + exec!(@setup_chain_cmd) + end + + @doc """ + Flushes and removes the FireGuard iptables chain. + """ + def teardown do + exec!(@teardown_chain_cmd) + end + + @doc """ + Adds iptables rule. + """ + def add_rule({4, s, d, "block"}) do + exec!("iptables -A fireguard -s #{s} -d #{d} -j DROP") + end + + def add_rule({4, s, d, "allow"}) do + exec!("iptables -A fireguard -s #{s} -d #{d} -j ACCEPT") + end + + def add_rule({6, s, d, "block"}) do + exec!("iptables6 -A fireguard -s #{s} -d #{d} -j DROP") + end + + def add_rule({6, s, d, "allow"}) do + exec!("iptables6 -A fireguard -s #{s} -d #{d} -j ACCEPT") + end + + @doc """ + Deletes iptables rule. + """ + def delete_rule({4, s, d, "block"}) do + exec!("iptables -D fireguard -s #{s} -d #{d} -j DROP") + end + + def delete_rule({4, s, d, "allow"}) do + exec!("iptables -D fireguard -s #{s} -d #{d} -j ACCEPT") + end + + def delete_rule({6, s, d, "block"}) do + exec!("iptables6 -D fireguard -s #{s} -d #{d} -j DROP") + end + + def delete_rule({6, s, d, "allow"}) do + exec!("iptables6 -D fireguard -s #{s} -d #{d} -j ACCEPT") + end +end diff --git a/apps/fg_wall/lib/fg_wall/cli/sandbox.ex b/apps/fg_wall/lib/fg_wall/cli/sandbox.ex new file mode 100644 index 000000000..b1cffdf62 --- /dev/null +++ b/apps/fg_wall/lib/fg_wall/cli/sandbox.ex @@ -0,0 +1,7 @@ +defmodule FgWall.CLI.Sandbox do + @default_returned "" + + def add_rule(_rule_spec), do: @default_returned + def delete_rule(_rule_spec), do: @default_returned + def restore(_fg_http_rules), do: @default_returned +end diff --git a/apps/fg_wall/lib/fg_wall/server.ex b/apps/fg_wall/lib/fg_wall/server.ex new file mode 100644 index 000000000..a728a69a3 --- /dev/null +++ b/apps/fg_wall/lib/fg_wall/server.ex @@ -0,0 +1,40 @@ +defmodule FgWall.Server do + @moduledoc """ + Functions for applying firewall rules. + + Startup: + Clear firewall rules. + + Received events: + - set_rules: apply rules + """ + + use GenServer + import FgWall.CLI + + @process_opts Application.compile_env(:fg_wall, :server_process_opts) + + def start_link(_) do + GenServer.start_link(__MODULE__, %{}, @process_opts) + end + + @impl true + def init(rules) do + {:ok, rules} + end + + def handle_call({:add_rule, rule_spec}, rules) do + cli().add_rule(rule_spec) + {:reply, :rule_added, rules} + end + + def handle_call({:delete_rule, rule_spec}, rules) do + cli().delete_rule(rule_spec) + {:reply, :rule_deleted, rules} + end + + def handle_call({:set_rules, fg_http_rules}, rules) do + cli().restore(fg_http_rules) + {:reply, :rules_set, rules} + end +end diff --git a/apps/fg_wall/mix.exs b/apps/fg_wall/mix.exs index 345b9bd1c..0d4b28945 100644 --- a/apps/fg_wall/mix.exs +++ b/apps/fg_wall/mix.exs @@ -33,6 +33,7 @@ defmodule FgWall.MixProject do # Run "mix help deps" to learn about dependencies. defp deps do [ + {:fg_common, in_umbrella: true}, {:credo, "~> 1.4", only: [:dev, :test], runtime: false}, {:excoveralls, "~> 0.13", only: :test} ] diff --git a/apps/fg_wall/test/fg_wall_test.exs b/apps/fg_wall/test/fg_wall_test.exs index 671a9ae28..4cdae77bb 100644 --- a/apps/fg_wall/test/fg_wall_test.exs +++ b/apps/fg_wall/test/fg_wall_test.exs @@ -1,8 +1,4 @@ defmodule FgWallTest do use ExUnit.Case doctest FgWall - - test "greets the world" do - assert FgWall.hello() == :world - end end diff --git a/config/config.exs b/config/config.exs index e0fb536f8..07d4470b3 100644 --- a/config/config.exs +++ b/config/config.exs @@ -25,17 +25,25 @@ import Config config :phoenix, :json_library, Jason config :fg_http, - ecto_repos: [FgHttp.Repo] + ecto_repos: [FgHttp.Repo], + vpn_endpoint: "127.0.0.1:51820", + admin_user_email: "fireguard@localhost", + event_helpers_module: FgHttpWeb.Events.Device, + disable_signup: + (case System.get_env("DISABLE_SIGNUP") do + d when d in ["1", "yes"] -> true + _ -> false + end) + +config :fg_wall, + cli: FgWall.CLI.Sandbox, + server_process_opts: [] # This will be changed per-env config :fg_vpn, private_key: "UAeZoaY95pKZE1Glq28sI2GJDfGGRFtlb4KC6rjY2Gs=", - cli: FgVpn.CLI.Sandbox - -# This will be changed per-env by ENV vars -config :fg_http, - vpn_endpoint: "127.0.0.1:51820", - admin_user_email: "fireguard@localhost" + cli: FgVpn.CLI.Sandbox, + server_process_opts: [] # Configures the endpoint # These will be overridden at runtime in production by config/releases.exs @@ -44,10 +52,6 @@ config :fg_http, FgHttpWeb.Endpoint, render_errors: [view: FgHttpWeb.ErrorView, accepts: ~w(html json)], pubsub_server: FgHttp.PubSub -config :fg_http, :event_helpers_module, FgHttpWeb.Events.Device - -config :fg_vpn, :server_process_opts, [] - # Configures Elixir's Logger config :logger, :console, format: "$time $metadata[$level] $message\n", @@ -57,6 +61,7 @@ config :logger, :console, # of this file so it overrides the configuration defined above. import_config "#{Mix.env()}.exs" +# Configures the vault config :fg_http, FgHttp.Vault, ciphers: [ default: { @@ -72,10 +77,3 @@ config :fg_http, FgHttp.Vault, iv_length: 12 } ] - -config :fg_http, - disable_signup: - (case System.get_env("DISABLE_SIGNUP") do - d when d in ["1", "yes"] -> true - _ -> false - end) diff --git a/config/dev.exs b/config/dev.exs index bae01a2aa..9f0f1a61a 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -81,3 +81,4 @@ config :phoenix, :stacktrace_depth, 20 # Initialize plugs at runtime for faster development compilation config :phoenix, :plug_init_mode, :runtime config :fg_vpn, :server_process_opts, name: {:global, :fg_vpn_server} +config :fg_wall, :server_process_opts, name: {:global, :fg_wall_server} diff --git a/config/prod.exs b/config/prod.exs index 9b4716063..72daf9b3c 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -9,7 +9,13 @@ import Config # manifest is generated by the `mix phx.digest` task, # which you should run after static files are built and # before starting your production server. -config :fg_vpn, :server_process_opts, name: :fg_vpn_server +config :fg_vpn, + cli: FgVpn.CLI.Live, + server_process_opts: [name: {:global, :fg_vpn_server}] + +config :fg_wall, + cli: FgWall.CLI.Live, + server_process_opts: [name: {:global, :fg_wall_server}] config :fg_http, FgHttpWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json", @@ -72,9 +78,6 @@ config :fg_http, FgHttpWeb.Endpoint, server: true, force_ssl: [rewrite_on: [:x_forwarded_proto], hsts: true, host: nil] -config :fg_vpn, - cli: FgVpn.CLI.Live - # Do not print debug messages in production config :logger, level: :info