From a8204b79882ae53888764d75d22b973bec60afe2 Mon Sep 17 00:00:00 2001 From: Jamil Bou Kheir Date: Fri, 4 Dec 2020 09:48:04 -0800 Subject: [PATCH] test rendering of config --- apps/fg_vpn/lib/fg_vpn/cli.ex | 57 ++++++++++++ apps/fg_vpn/lib/fg_vpn/config.ex | 117 ++++++++++++++++------- apps/fg_vpn/lib/fg_vpn/wg_cli.ex | 27 ------ apps/fg_vpn/test/fg_vpn/config_test.exs | 119 +++++++++++++++++------- 4 files changed, 229 insertions(+), 91 deletions(-) create mode 100644 apps/fg_vpn/lib/fg_vpn/cli.ex delete mode 100644 apps/fg_vpn/lib/fg_vpn/wg_cli.ex diff --git a/apps/fg_vpn/lib/fg_vpn/cli.ex b/apps/fg_vpn/lib/fg_vpn/cli.ex new file mode 100644 index 000000000..fcdf3525a --- /dev/null +++ b/apps/fg_vpn/lib/fg_vpn/cli.ex @@ -0,0 +1,57 @@ +defmodule FgVpn.CLI do + @moduledoc """ + Wraps command-line functionality of WireGuard for our purposes. + + Application startup: + - wg syncconf + + Consumed events: + - add device: + 1. start listening for new connections + 2. send pubkey when device connects + 3. when verification received from fg_http, add config entry + + - remove device: + 1. disconnect device if connected + 2. remove configuration entry + + Produced events: + - client connects: + 1. send IP, connection time to FgHttp + - change config + + Helpers: + - render_conf: re-renders configuration file (peer configurations specifically) + - sync_conf: calls "wg syncconf" + """ + + @default_interface_cmd "route | grep '^default' | grep -o '[^ ]*$'" + + @doc """ + Finds default egress interface on a Linux box. + """ + def default_interface do + case :os.type() do + {:unix, :linux} -> + case System.cmd("sh", ["-c", @default_interface_cmd]) do + {result, 0} -> + result + |> String.split() + |> List.first() + + {_error, _} -> + raise "Could not determine default egress interface from `#{@default_interface_cmd}`" + end + + {:unix, :darwin} -> + # XXX: Figure out what it means to have macOS as a host? + "en0" + end + end + + @doc """ + Calls wg genkey + """ + def gen_privkey do + end +end diff --git a/apps/fg_vpn/lib/fg_vpn/config.ex b/apps/fg_vpn/lib/fg_vpn/config.ex index cd7e96476..ab7552cb1 100644 --- a/apps/fg_vpn/lib/fg_vpn/config.ex +++ b/apps/fg_vpn/lib/fg_vpn/config.ex @@ -3,83 +3,136 @@ defmodule FgVpn.Config do Functions for reading / writing the WireGuard config """ + alias FgVpn.CLI alias Phoenix.PubSub use GenServer - @begin_sentinel "# BEGIN FIREGUARD-MANAGED PEER LIST" - @end_sentinel "# END FIREGUARD-MANAGED PEER LIST" + @config_header """ + # This file is being managed by the fireguard systemd service. Any changes + # will be overwritten eventually. + + """ def start_link(_) do - peers = read() - GenServer.start_link(__MODULE__, peers) + privkey = read_privkey() + peers = read_peers() || [] + default_int = CLI.default_interface() + + GenServer.start_link( + __MODULE__, + %{ + peers: peers, + privkey: privkey, + default_int: default_int + } + ) end @impl true - def init(peers) do + def init(config) do # Subscribe to PubSub from FgHttp application - {PubSub.subscribe(:fg_http_pub_sub, "config"), peers} + {PubSub.subscribe(:fg_http_pub_sub, "config"), config} end @impl true - def handle_info({:verify_device, pubkey}, pubkeys) do - new_peers = [pubkey | pubkeys] - write!(new_peers) - {:noreply, new_peers} + def handle_info({:verify_device, pubkey}, config) do + new_peers = [pubkey | config[:peers]] + new_config = %{config | peers: new_peers} + write!(new_config) + {:noreply, new_config} end @impl true - def handle_info({:remove_device, pubkey}, pubkeys) do - new_peers = List.delete(pubkeys, pubkey) - write!(new_peers) - {:noreply, new_peers} + def handle_info({:remove_device, pubkey}, config) do + new_peers = List.delete(config[:peers], pubkey) + new_config = %{config | peers: new_peers} + write!(new_config) + {:noreply, new_config} end @doc """ Writes configuration file. """ - def write!(peers) do + def write!(config) do Application.get_env(:fg_vpn, :wireguard_conf_path) - |> File.write!(render(peers)) + |> File.write!(render(config)) + end + + @doc """ + Reads PrivateKey from configuration file + """ + def read_privkey do + read_config_file() + |> extract_privkey() + end + + defp extract_privkey(config_str) do + ~r/PrivateKey = (.*)/ + |> Regex.scan(config_str || "") + |> List.flatten() + |> List.last() end @doc """ Reads configuration file and generates a list of pubkeys """ - def read do + def read_peers do + read_config_file() + |> extract_pubkeys() + end + + defp read_config_file do path = Application.get_env(:fg_vpn, :wireguard_conf_path) case File.read(path) do {:ok, str} -> str - |> String.split(@begin_sentinel) - |> List.last() - |> String.split(@end_sentinel) - |> List.first() - |> extract_pubkeys() - {:error, _reason} -> - [] + {:error, reason} -> + IO.puts(:stderr, "Could not read config: #{reason}") + nil end end @doc """ Extracts pubkeys from a configuration file snippet """ - def extract_pubkeys(conf_section) do - ~r/PublicKey = (.*)/ - |> Regex.scan(conf_section) - |> Enum.map(fn match_list -> List.last(match_list) end) + def extract_pubkeys(config_str) do + case config_str do + nil -> + nil + + _ -> + ~r/PublicKey = (.*)/ + |> Regex.scan(config_str) + |> Enum.map(fn match_list -> List.last(match_list) end) + end end @doc """ Renders WireGuard config in a deterministic way. """ - def render(peers) do - @begin_sentinel <> "\n" <> peers_to_config(peers) <> @end_sentinel + def render(config) do + @config_header <> interface_to_config(config) <> peers_to_config(config) end - defp peers_to_config(peers) do - Enum.map_join(peers, fn pubkey -> + defp interface_to_config(config) do + ~s""" + [Interface] + ListenPort = 51820 + PrivateKey = #{config[:privkey]} + PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -A FORWARD -o %i -j ACCEPT; iptables -t nat -A POSTROUTING -o #{ + config[:default_int] + } -j MASQUERADE + PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -j ACCEPT; iptables -t nat -D POSTROUTING -o #{ + config[:default_int] + } -j MASQUERADE + + """ + end + + defp peers_to_config(config) do + Enum.map_join(config[:peers], fn pubkey -> ~s""" # BEGIN PEER #{pubkey} [Peer] diff --git a/apps/fg_vpn/lib/fg_vpn/wg_cli.ex b/apps/fg_vpn/lib/fg_vpn/wg_cli.ex deleted file mode 100644 index 2fbe6955f..000000000 --- a/apps/fg_vpn/lib/fg_vpn/wg_cli.ex +++ /dev/null @@ -1,27 +0,0 @@ -defmodule FgVpn.WGCLI do - @moduledoc """ - Wraps command-line functionality of WireGuard for our purposes. - - Application startup: - - wg syncconf - - Consumed events: - - add device: - 1. start listening for new connections - 2. send pubkey when device connects - 3. when verification received from fg_http, add config entry - - - remove device: - 1. disconnect device if connected - 2. remove configuration entry - - Produced events: - - client connects: - 1. send IP, connection time to FgHttp - - change config - - Helpers: - - render_conf: re-renders configuration file (peer configurations specifically) - - sync_conf: calls "wg syncconf" - """ -end diff --git a/apps/fg_vpn/test/fg_vpn/config_test.exs b/apps/fg_vpn/test/fg_vpn/config_test.exs index 0f5066978..a3406e676 100644 --- a/apps/fg_vpn/test/fg_vpn/config_test.exs +++ b/apps/fg_vpn/test/fg_vpn/config_test.exs @@ -13,46 +13,101 @@ defmodule FgVpn.ConfigTest do # END PEER test-pubkey """ + @privkey """ + [Interface] + ListenPort = 51820 + PrivateKey = test-privkey + """ + + @rendered_config """ + # This file is being managed by the fireguard systemd service. Any changes + # will be overwritten eventually. + + [Interface] + ListenPort = 51820 + PrivateKey = rendered-privkey + PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -A FORWARD -o %i -j ACCEPT; iptables -t nat -A POSTROUTING -o noop -j MASQUERADE + PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -j ACCEPT; iptables -t nat -D POSTROUTING -o noop -j MASQUERADE + + # BEGIN PEER test-pubkey + [Peer] + PublicKey = test-pubkey + AllowedIPs = 0.0.0.0/0, ::/0 + # END PEER test-pubkey + """ + def write_config(config) do Application.get_env(:fg_vpn, :wireguard_conf_path) |> File.write!(config) end - setup %{stubbed_config: config} do - write_config(config) - test_pid = start_supervised!(Config) + describe "state" do + setup %{stubbed_config: config} do + write_config(config) + test_pid = start_supervised!(Config) - on_exit(fn -> - Application.get_env(:fg_vpn, :wireguard_conf_path) - |> File.rm!() - end) + on_exit(fn -> + Application.get_env(:fg_vpn, :wireguard_conf_path) + |> File.rm!() + end) - %{test_pid: test_pid} + %{test_pid: test_pid} + end + + @tag stubbed_config: @privkey + test "parses PrivateKey from config file", %{test_pid: test_pid} do + assert %{ + peers: [], + default_int: _, + privkey: "test-privkey" + } = :sys.get_state(test_pid) + end + + @tag stubbed_config: @single_peer + test "parses peers from config file", %{test_pid: test_pid} do + assert %{ + peers: ["test-pubkey"], + default_int: _, + privkey: nil + } = :sys.get_state(test_pid) + end + + @tag stubbed_config: @empty + test "writes peers to config when device is verified", %{test_pid: test_pid} do + send(test_pid, {:verify_device, "test-pubkey"}) + + # XXX: Avoid sleeping + Process.sleep(100) + + assert %{ + peers: ["test-pubkey"], + default_int: _, + privkey: nil + } = :sys.get_state(test_pid) + end + + @tag stubbed_config: @single_peer + test "removes peers from config when device is removed", %{test_pid: test_pid} do + send(test_pid, {:remove_device, "test-pubkey"}) + + # XXX: Avoid sleeping + Process.sleep(100) + + assert %{ + peers: [], + default_int: _, + privkey: nil + } = :sys.get_state(test_pid) + end end - @tag stubbed_config: @single_peer - test "parses peers from config file", %{test_pid: test_pid} do - state = :sys.get_state(test_pid) - assert state == ["test-pubkey"] - end - - @tag stubbed_config: @empty - test "writes peers to config when device is verified", %{test_pid: test_pid} do - send(test_pid, {:verify_device, "test-pubkey"}) - - # XXX: Avoid sleeping - Process.sleep(100) - - assert Config.read() == ["test-pubkey"] - end - - @tag stubbed_config: @single_peer - test "removes peers from config when device is removed", %{test_pid: test_pid} do - send(test_pid, {:remove_device, "test-pubkey"}) - - # XXX: Avoid sleeping - Process.sleep(100) - - assert Config.read() == [] + describe "loading / rendering" do + test "renders config" do + assert Config.render(%{ + default_int: "noop", + privkey: "rendered-privkey", + peers: ["test-pubkey"] + }) == @rendered_config + end end end