diff --git a/.codespellrc b/.codespellrc index 4cfb1b56b..e4fde4156 100644 --- a/.codespellrc +++ b/.codespellrc @@ -1,3 +1,3 @@ [codespell] -skip = ./cover,./vendor,./omnibus,*.json,yarn.lock,seeds.exs,./**/node_modules,./deps,./priv/static,./priv/plts,./**/priv/static,./.git,./docs/build,./_build +skip = ./erl_crash.dump,./apps/fz_http/erl_crash.dump,./cover,./vendor,./omnibus,*.json,yarn.lock,seeds.exs,./**/node_modules,./deps,./priv/static,./priv/plts,./**/priv/static,./.git,./docs/build,./_build ignore-words-list = keypair,keypairs,iif,statics,wee diff --git a/.credo.exs b/.credo.exs index a7e937340..b91764922 100644 --- a/.credo.exs +++ b/.credo.exs @@ -98,7 +98,6 @@ {Credo.Check.Readability.LargeNumbers, []}, {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]}, {Credo.Check.Readability.ModuleAttributeNames, []}, - {Credo.Check.Readability.ModuleDoc, []}, {Credo.Check.Readability.ModuleNames, []}, {Credo.Check.Readability.ParenthesesInCondition, []}, {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0da2f9dc6..c684de9fe 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -61,4 +61,4 @@ repos: - -b - master - --pattern - - '^(?!((chore|feat|feature|bug|fix|build|ci|docs|style|refactor|perf|test|revert)\/[a-zA-Z0-9\-\.\/]+)$).*' + - '^(?!((chore|feat|feature|bug|fix|build|ci|docs|style|refactor|perf|test|revert)\/[@a-zA-Z0-9\-\.\/]+)$).*' diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f287f63da..b67e860d8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -109,27 +109,6 @@ We use [pre-commit](https://pre-commit.com) to catch any static analysis issues before code is committed. Install with Homebrew: `brew install pre-commit` or pip: `pip install pre-commit`. -### The ENV file - -For running tests and developing Firezone outside of Docker, you'll need some -environment variables present in your shell's env. - -See .env.sample an example of what variables you need. We recommend copying this -file to `.env` and using a dotenv loader to apply this to your current shell -env. - -For example, run the following command to 'source' the environment variables -from .env on `mix test`: - -`env $(cat .env | grep -v \# | xargs) mix test` - -This will initialize everything and run the test suite. If you have no -failures, Firezone should be properly set up 🥳. - -At this point you should be able to sign in to -[http://localhost:4000](http://localhost:4000) with email `firezone@localhost` and -password `firezone1234`. - ### Bootstrapping To start the local development cluster, follow these steps: @@ -137,13 +116,17 @@ To start the local development cluster, follow these steps: ``` docker compose build docker compose up -d postgres -docker compose run --rm firezone mix ecto.setup +docker compose run --rm firezone mix do ecto.setup, ecto.seed docker compose up ``` Now you should be able to connect to `https://localhost/` and sign in with email `firezone@localhost` and password `firezone1234`. +The [`docker-compose.yml`](docker-compose.yml) file configures the Docker +development environment. If you make any changes you feel would benefit +all developers, feel free to open a PR to get them merged! + ### Ensure Everything Works There is a `client` container in the docker-compose configuration that diff --git a/Dockerfile.dev b/Dockerfile.dev index 459b5c841..91f0f2056 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -36,12 +36,12 @@ COPY apps/fz_vpn/mix.exs /var/app/apps/fz_vpn/mix.exs COPY apps/fz_wall/mix.exs /var/app/apps/fz_wall/mix.exs COPY mix.exs /var/app/mix.exs COPY mix.lock /var/app/mix.lock -RUN mix do deps.get --only dev, deps.compile --only dev, compile --only dev -RUN mix do deps.get --only test, deps.compile --only test, compile --only test +RUN mix do deps.get, deps.compile, compile # Copy more granular, dependency management files first to prevent # busting the Docker build cache unnecessarily COPY apps/fz_http/assets/package.json /var/app/apps/fz_http/assets/package.json +COPY apps/fz_http/assets/local_modules /var/app/apps/fz_http/assets/local_modules COPY apps/fz_http/assets/package-lock.json /var/app/apps/fz_http/assets/package-lock.json RUN npm install --prefix apps/fz_http/assets diff --git a/apps/fz_common/lib/fz_net.ex b/apps/fz_common/lib/fz_net.ex index 69ece9854..9b1a8dd71 100644 --- a/apps/fz_common/lib/fz_net.ex +++ b/apps/fz_common/lib/fz_net.ex @@ -2,19 +2,9 @@ defmodule FzCommon.FzNet do @moduledoc """ Network utility functions. """ - # credo:disable-for-next-line Credo.Check.Readability.MaxLineLength - @ip_regex ~r/((^\s*((([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))\s*$)|(^\s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))(%.+)?\s*$))/ + import FzCommon.FzRegex - @cidr4_regex ~r/^([0-9]{1,3}\.){3}[0-9]{1,3}(\/([0-9]|[1-2][0-9]|3[0-2]))$/ - - # credo:disable-for-next-line Credo.Check.Readability.MaxLineLength - @cidr6_regex ~r/^s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$/ - - # credo:disable-for-next-line Credo.Check.Readability.MaxLineLength - @host_regex ~r/\A(([a-zA-Z0-9](?:[a-zA-Z0-9\-]*[a-zA-Z0-9])?)\.)*([a-zA-Z0-9](?:[a-zA-Z0-9\-]*[a-zA-Z0-9])?)$\Z/ - @fqdn_regex ~r/\A(?=^.{4,253}$)(^((?!-)[a-zA-Z0-9-]{0,62}[a-zA-Z0-9]\.)+[a-zA-Z]{2,63}$)\z/ - - # XXX: Consider using InetCidr for this + # XXX: Consider using CIDR for this def ip_type(str) when is_binary(str) do charlist = str @@ -36,11 +26,11 @@ defmodule FzCommon.FzNet do end def valid_cidr?(cidr) when is_binary(cidr) do - String.match?(cidr, @cidr4_regex) or String.match?(cidr, @cidr6_regex) + String.match?(cidr, cidr4_regex()) or String.match?(cidr, cidr6_regex()) end def valid_ip?(ip) when is_binary(ip) do - String.match?(ip, @ip_regex) + String.match?(ip, ip_regex()) end @doc """ @@ -50,8 +40,8 @@ defmodule FzCommon.FzNet do if String.contains?(inet, "/") do inet # normalize CIDR - |> InetCidr.parse(true) - |> InetCidr.to_string() + |> CIDR.parse() + |> to_string() else {:ok, addr} = inet |> String.to_charlist() |> :inet.parse_address() :inet.ntoa(addr) |> List.to_string() @@ -59,11 +49,11 @@ defmodule FzCommon.FzNet do end def valid_fqdn?(fqdn) when is_binary(fqdn) do - String.match?(fqdn, @fqdn_regex) + String.match?(fqdn, fqdn_regex()) end def valid_hostname?(hostname) when is_binary(hostname) do - String.match?(hostname, @host_regex) + String.match?(hostname, host_regex()) end def to_complete_url(str) when is_binary(str) do diff --git a/apps/fz_common/lib/fz_regex.ex b/apps/fz_common/lib/fz_regex.ex new file mode 100644 index 000000000..5f6c1075b --- /dev/null +++ b/apps/fz_common/lib/fz_regex.ex @@ -0,0 +1,25 @@ +defmodule FzCommon.FzRegex do + @moduledoc """ + A common module to store RegExps used in FzCommon in order + to avoid linting and editor slowdown issues in other modules. + + What a time to be a developer, right? + """ + # credo:disable-for-next-line Credo.Check.Readability.MaxLineLength + @ip_regex ~r/((^\s*((([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))\s*$)|(^\s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))(%.+)?\s*$))/ + + @cidr4_regex ~r/^([0-9]{1,3}\.){3}[0-9]{1,3}(\/([0-9]|[1-2][0-9]|3[0-2]))$/ + + # credo:disable-for-next-line Credo.Check.Readability.MaxLineLength + @cidr6_regex ~r/^s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$/ + + # credo:disable-for-next-line Credo.Check.Readability.MaxLineLength + @host_regex ~r/\A(([a-zA-Z0-9](?:[a-zA-Z0-9\-]*[a-zA-Z0-9])?)\.)*([a-zA-Z0-9](?:[a-zA-Z0-9\-]*[a-zA-Z0-9])?)$\Z/ + @fqdn_regex ~r/\A(?=^.{4,253}$)(^((?!-)[a-zA-Z0-9-]{0,62}[a-zA-Z0-9]\.)+[a-zA-Z]{2,63}$)\z/ + + def ip_regex, do: @ip_regex + def cidr4_regex, do: @cidr4_regex + def cidr6_regex, do: @cidr6_regex + def host_regex, do: @host_regex + def fqdn_regex, do: @fqdn_regex +end diff --git a/apps/fz_common/mix.exs b/apps/fz_common/mix.exs index dca143e99..7eb9a65b9 100644 --- a/apps/fz_common/mix.exs +++ b/apps/fz_common/mix.exs @@ -34,7 +34,7 @@ defmodule FzCommon.MixProject do {:file_size, "~> 3.0.1"}, {:posthog, "~> 0.1"}, {:jason, "~> 1.2"}, - {:inet_cidr, "~> 1.0.0"} + {:cidr, github: "firezone/cidr-elixir"} # {: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} diff --git a/apps/fz_common/test/fz_net_test.exs b/apps/fz_common/test/fz_net_test.exs index 83ac6fb27..67419a4ee 100644 --- a/apps/fz_common/test/fz_net_test.exs +++ b/apps/fz_common/test/fz_net_test.exs @@ -127,11 +127,7 @@ defmodule FzCommon.FzNetTest do end end - @tag cases: [ - "<", - "{", - "[" - ] + @tag cases: ["<", "{", "["] test "returns {:error, _} for invalid URIs", %{cases: cases} do for subject <- cases do assert {:error, _} = FzNet.to_complete_url(subject) diff --git a/apps/fz_http/assets/css/main.scss b/apps/fz_http/assets/css/main.scss index 61fd64e0c..343cb87d4 100644 --- a/apps/fz_http/assets/css/main.scss +++ b/apps/fz_http/assets/css/main.scss @@ -8,6 +8,14 @@ nav.navbar { @extend .is-family-monospace; } +pre.multiline { + white-space: pre-wrap; /* Since CSS 2.1 */ + white-space: -moz-pre-wrap; /* Mozilla, since 1999 */ + white-space: -pre-wrap; /* Opera 4-6 */ + white-space: -o-pre-wrap; /* Opera 7 */ + word-wrap: break-word; /* Internet Explorer 5.5+ */ +} + .is-horizontally-scrollable { overflow-x: auto; } diff --git a/apps/fz_http/assets/js/event_listeners.js b/apps/fz_http/assets/js/event_listeners.js index 3d572f166..ad46fd487 100644 --- a/apps/fz_http/assets/js/event_listeners.js +++ b/apps/fz_http/assets/js/event_listeners.js @@ -16,3 +16,23 @@ document.addEventListener('DOMContentLoaded', () => { $span.innerHTML = FormatTimestamp($span.dataset.timestamp) }) }) + +document.addEventListener("firezone:clipcopy", (event) => { + if ("clipboard" in navigator) { + const text = event.target.textContent + navigator.clipboard.writeText(text) + const dispatcher = event.detail.dispatcher + const span = dispatcher.getElementsByTagName("span")[0] + const icon = dispatcher.getElementsByTagName("i")[0] + + span.classList.add("has-text-success") + icon.classList.replace("mdi-content-copy", "mdi-check-bold") + + setTimeout(() => { + span.classList.remove("has-text-success") + icon.classList.replace("mdi-check-bold", "mdi-content-copy") + }, 1000) + } else { + alert("Sorry, your browser does not support clipboard copy.") + } +}) diff --git a/apps/fz_http/assets/package-lock.json b/apps/fz_http/assets/package-lock.json index 71582ee62..aac7f5f18 100644 --- a/apps/fz_http/assets/package-lock.json +++ b/apps/fz_http/assets/package-lock.json @@ -36,31 +36,6 @@ "node": ">= 14.0.0" } }, - "../../../deps/phoenix": { - "version": "1.7.0-rc.0", - "license": "MIT" - }, - "../../../deps/phoenix_html": { - "version": "3.2.0" - }, - "../../../deps/phoenix_live_view": { - "version": "0.18.3", - "license": "MIT" - }, - "local_modules/admin-one-bulma-dashboard": { - "version": "1.5.5", - "dev": true, - "license": "MIT", - "dependencies": { - "bulma": "^0.9.0", - "bulma-checkbox": "^1.1.1", - "bulma-radio": "^1.1.1", - "bulma-responsive-tables": "^1.2.3", - "bulma-switch-control": "^1.1.1", - "bulma-upload-control": "^1.2.0", - "node-sass": "^7.0.1" - } - }, "node_modules/@babel/code-frame": { "version": "7.18.6", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.18.6.tgz", @@ -271,8 +246,19 @@ "dev": true }, "node_modules/admin-one-bulma-dashboard": { - "resolved": "local_modules/admin-one-bulma-dashboard", - "link": true + "version": "1.5.5", + "resolved": "file:local_modules/admin-one-bulma-dashboard", + "dev": true, + "license": "MIT", + "dependencies": { + "bulma": "^0.9.0", + "bulma-checkbox": "^1.1.1", + "bulma-radio": "^1.1.1", + "bulma-responsive-tables": "^1.2.3", + "bulma-switch-control": "^1.1.1", + "bulma-upload-control": "^1.2.0", + "node-sass": "^7.0.1" + } }, "node_modules/agent-base": { "version": "6.0.2", @@ -2526,16 +2512,18 @@ "dev": true }, "node_modules/phoenix": { - "resolved": "../../../deps/phoenix", - "link": true + "version": "1.7.0-rc.0", + "resolved": "file:../../../deps/phoenix", + "license": "MIT" }, "node_modules/phoenix_html": { - "resolved": "../../../deps/phoenix_html", - "link": true + "version": "3.2.0", + "resolved": "file:../../../deps/phoenix_html" }, "node_modules/phoenix_live_view": { - "resolved": "../../../deps/phoenix_live_view", - "link": true + "version": "0.18.3", + "resolved": "file:../../../deps/phoenix_live_view", + "license": "MIT" }, "node_modules/picomatch": { "version": "2.3.1", @@ -3690,7 +3678,8 @@ "dev": true }, "admin-one-bulma-dashboard": { - "version": "file:local_modules/admin-one-bulma-dashboard", + "version": "1.5.5", + "dev": true, "requires": { "bulma": "^0.9.0", "bulma-checkbox": "^1.1.1", @@ -5336,13 +5325,13 @@ "dev": true }, "phoenix": { - "version": "file:../../../deps/phoenix" + "version": "1.7.0-rc.0" }, "phoenix_html": { - "version": "file:../../../deps/phoenix_html" + "version": "3.2.0" }, "phoenix_live_view": { - "version": "file:../../../deps/phoenix_live_view" + "version": "0.18.3" }, "picomatch": { "version": "2.3.1", diff --git a/apps/fz_http/lib/fz_http.ex b/apps/fz_http/lib/fz_http.ex index cd38e2e7b..913700aa9 100644 --- a/apps/fz_http/lib/fz_http.ex +++ b/apps/fz_http/lib/fz_http.ex @@ -1,9 +1,21 @@ defmodule FzHttp do - @moduledoc """ - FzHttp keeps the contexts that define your domain - and business logic. + def schema do + quote do + use Ecto.Schema - Contexts are also responsible for managing your data, regardless - if it comes from the database, an external API or others. + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + @timestamps_opts [type: :utc_datetime_usec] + + @type id :: binary() + end + end + + @doc """ + When used, dispatch to the appropriate schema/context/changeset/query/etc. """ + defmacro __using__(which) when is_atom(which) do + apply(__MODULE__, which, []) + end end diff --git a/apps/fz_http/lib/fz_http/api_tokens.ex b/apps/fz_http/lib/fz_http/api_tokens.ex new file mode 100644 index 000000000..36aefa84a --- /dev/null +++ b/apps/fz_http/lib/fz_http/api_tokens.ex @@ -0,0 +1,62 @@ +defmodule FzHttp.ApiTokens do + @moduledoc """ + The ApiTokens context. + """ + + import Ecto.Query, warn: false + alias FzHttp.Repo + + alias FzHttp.ApiTokens.ApiToken + + def count_by_user_id(user_id) do + Repo.aggregate(from(a in ApiToken, where: a.user_id == ^user_id), :count) + end + + def list_api_tokens do + Repo.all(ApiToken) + end + + def list_api_tokens(user_id) do + Repo.all(from a in ApiToken, where: a.user_id == ^user_id) + end + + def get_api_token(id), do: Repo.get(ApiToken, id) + + def get_api_token!(id), do: Repo.get!(ApiToken, id) + + def get_unexpired_api_token(api_token_id) do + now = DateTime.utc_now() + + Repo.one( + from a in ApiToken, + where: a.id == ^api_token_id and a.expires_at >= ^now + ) + end + + def new_api_token(attrs \\ %{}) do + ApiToken.create_changeset(attrs) + end + + def create_user_api_token(%FzHttp.Users.User{} = user, params) do + changeset = + params + |> Enum.into(%{"user_id" => user.id}) + |> ApiToken.create_changeset(count_per_user: count_by_user_id(user.id)) + + with {:ok, api_token} <- Repo.insert(changeset) do + FzHttp.Telemetry.create_api_token() + {:ok, api_token} + end + end + + def api_token_expired?(%ApiToken{} = api_token) do + DateTime.diff(api_token.expires_at, DateTime.utc_now()) < 0 + end + + def delete_api_token(%ApiToken{} = api_token) do + with {:ok, api_token} <- Repo.delete(api_token) do + FzHttp.Telemetry.delete_api_token(api_token) + {:ok, api_token} + end + end +end diff --git a/apps/fz_http/lib/fz_http/api_tokens/api_token.ex b/apps/fz_http/lib/fz_http/api_tokens/api_token.ex new file mode 100644 index 000000000..3a111cbc6 --- /dev/null +++ b/apps/fz_http/lib/fz_http/api_tokens/api_token.ex @@ -0,0 +1,51 @@ +defmodule FzHttp.ApiTokens.ApiToken do + use FzHttp, :schema + import Ecto.Changeset + + @max_per_user 25 + + schema "api_tokens" do + field :expires_at, :utc_datetime_usec + + # Developer-friendly way to set expires_at + field :expires_in, :integer, virtual: true, default: 30 + + belongs_to :user, FzHttp.Users.User + + timestamps(updated_at: false) + end + + def create_changeset(attrs, opts \\ []) do + %__MODULE__{} + |> cast(attrs, ~w[ + user_id + expires_in + expires_at + ]a) + |> validate_required([:user_id, :expires_in]) + |> validate_number(:expires_in, greater_than_or_equal_to: 1, less_than_or_equal_to: 90) + |> resolve_expires_at() + |> validate_required(:expires_at) + |> assoc_constraint(:user) + |> maybe_validate_count_per_user(@max_per_user, opts[:count_per_user]) + end + + def max_per_user, do: @max_per_user + + defp resolve_expires_at(changeset) do + expires_at = + DateTime.utc_now() + |> DateTime.add(get_field(changeset, :expires_in), :day) + + put_change(changeset, :expires_at, expires_at) + end + + defp maybe_validate_count_per_user(changeset, max, num) when is_integer(num) and num >= max do + # XXX: This suffers from a race condition because the count happens in a separate transaction. + # At the moment it's not a big concern. Fixing it would require locking against INSERTs or DELETEs + # while counts are happening. + add_error(changeset, :base, "token limit of #{@max_per_user} reached") + end + + defp maybe_validate_count_per_user(changeset, _, _), do: changeset +end diff --git a/apps/fz_http/lib/fz_http/application.ex b/apps/fz_http/lib/fz_http/application.ex index 60f9381d3..ce56ebe38 100644 --- a/apps/fz_http/lib/fz_http/application.ex +++ b/apps/fz_http/lib/fz_http/application.ex @@ -22,17 +22,15 @@ defmodule FzHttp.Application do :ok end - defp children, do: children(Application.fetch_env!(:fz_http, :supervision_tree_mode)) + defp children, do: children(FzHttp.Config.fetch_env!(:fz_http, :supervision_tree_mode)) defp children(:full) do [ - {Cachex, name: :conf}, FzHttp.Server, FzHttp.Repo, {Postgrex.Notifications, [name: FzHttp.Repo.Notifications] ++ FzHttp.Repo.config()}, FzHttp.Repo.Notifier, FzHttp.Vault, - FzHttp.Configurations.Cache, FzHttpWeb.Endpoint, {Phoenix.PubSub, name: FzHttp.PubSub}, {FzHttp.Notifications, name: FzHttp.Notifications}, @@ -41,25 +39,23 @@ defmodule FzHttp.Application do FzHttp.TelemetryPingService, FzHttp.VpnSessionScheduler, FzHttp.OIDC.StartProxy, + FzHttp.SAML.StartProxy, {DynamicSupervisor, name: FzHttp.RefresherSupervisor, strategy: :one_for_one}, - FzHttp.OIDC.RefreshManager, - FzHttp.SAML.StartProxy + FzHttp.OIDC.RefreshManager ] end defp children(:test) do [ - {Cachex, name: :conf}, FzHttp.Server, FzHttp.Repo, FzHttp.Vault, - FzHttp.Configurations.Cache, FzHttpWeb.Endpoint, {FzHttp.OIDC.StartProxy, :test}, + {FzHttp.SAML.StartProxy, :test}, {Phoenix.PubSub, name: FzHttp.PubSub}, {FzHttp.Notifications, name: FzHttp.Notifications}, - FzHttpWeb.Presence, - FzHttp.SAML.StartProxy + FzHttpWeb.Presence ] end end diff --git a/apps/fz_http/lib/fz_http/conf/cache.ex b/apps/fz_http/lib/fz_http/conf/cache.ex deleted file mode 100644 index f807f4fa9..000000000 --- a/apps/fz_http/lib/fz_http/conf/cache.ex +++ /dev/null @@ -1,55 +0,0 @@ -defmodule FzHttp.Configurations.Cache do - @moduledoc """ - Manipulate cached configurations. - """ - - use GenServer, restart: :transient - - alias FzHttp.Configurations, as: Conf - - @name :conf - - def get(key) do - Cachex.get(@name, key) - end - - def get!(key) do - Cachex.get!(@name, key) - end - - def put(key, value) do - Cachex.put(@name, key, value) - end - - def put!(key, value) do - Cachex.put!(@name, key, value) - end - - def start_link(_) do - GenServer.start_link(__MODULE__, []) - end - - @no_fallback [:logo] - - @impl true - def init(_) do - configurations = - Conf.get_configuration!() - |> Map.from_struct() - |> Map.delete(:id) - - for {k, v} <- configurations do - # XXX: Remove fallbacks before 1.0? - v = - with nil <- v, true <- k not in @no_fallback do - Application.fetch_env!(:fz_http, k) - else - _ -> v - end - - {:ok, _} = put(k, v) - end - - :ignore - end -end diff --git a/apps/fz_http/lib/fz_http/conf/configuration.ex b/apps/fz_http/lib/fz_http/conf/configuration.ex deleted file mode 100644 index cc11d6c6e..000000000 --- a/apps/fz_http/lib/fz_http/conf/configuration.ex +++ /dev/null @@ -1,37 +0,0 @@ -defmodule FzHttp.Configurations.Configuration do - @moduledoc """ - App global configuration, singleton resource - """ - - use Ecto.Schema - import Ecto.Changeset - alias FzHttp.Configurations.Logo - - @primary_key {:id, :binary_id, autogenerate: true} - - schema "configurations" do - embeds_one :logo, Logo, on_replace: :update - field :local_auth_enabled, :boolean - field :allow_unprivileged_device_management, :boolean - field :allow_unprivileged_device_configuration, :boolean - field :openid_connect_providers, :map - field :saml_identity_providers, :map - field :disable_vpn_on_oidc_error, :boolean - - timestamps(type: :utc_datetime_usec) - end - - @doc false - def changeset(configuration, attrs) do - configuration - |> cast(attrs, [ - :local_auth_enabled, - :allow_unprivileged_device_management, - :allow_unprivileged_device_configuration, - :openid_connect_providers, - :saml_identity_providers, - :disable_vpn_on_oidc_error - ]) - |> cast_embed(:logo) - end -end diff --git a/apps/fz_http/lib/fz_http/conf/html_safe_map.ex b/apps/fz_http/lib/fz_http/conf/html_safe_map.ex deleted file mode 100644 index e81cd4b69..000000000 --- a/apps/fz_http/lib/fz_http/conf/html_safe_map.ex +++ /dev/null @@ -1,5 +0,0 @@ -defimpl Phoenix.HTML.Safe, for: Map do - def to_iodata(%{} = map) do - Jason.encode_to_iodata!(map, pretty: true) - end -end diff --git a/apps/fz_http/lib/fz_http/config.ex b/apps/fz_http/lib/fz_http/config.ex new file mode 100644 index 000000000..da29549b1 --- /dev/null +++ b/apps/fz_http/lib/fz_http/config.ex @@ -0,0 +1,70 @@ +defmodule FzHttp.Config do + @moduledoc """ + This module provides set of helper functions that are useful when reading application runtime configuration overrides + in test environment. + """ + + if Mix.env() != :test do + def maybe_put_env_override(_key, _value), do: :ok + def fetch_env!(app, key), do: Application.fetch_env!(app, key) + else + def maybe_put_env_override(key, value) do + _ = Process.put(key, value) + :ok + end + + @doc """ + Attempts to override application env configuration from one of 3 sources (in this exact order): + * takes it from process dictionary of a current process; + * takes it from process dictionary of a last process in $ancestors stack. + * takes it from process dictionary of a last process in $callers stack; + + This function is especially useful when some options (eg. request endpoint) needs to be overridden + in test environment (eg. to send those requests to Bypass). + """ + def fetch_env!(app, key) do + application_env = Application.fetch_env!(app, key) + + with :error <- fetch_process_value(key), + :error <- fetch_process_value(get_last_pid_from_pdict_list(:"$ancestors"), key), + :error <- fetch_process_value(get_last_pid_from_pdict_list(:"$callers"), key) do + application_env + else + {:ok, override} -> override + end + end + + defp fetch_process_value(key) do + case Process.get(key) do + nil -> :error + value -> {:ok, value} + end + end + + defp fetch_process_value(nil, _key) do + :error + end + + defp fetch_process_value(atom, key) when is_atom(atom) do + atom + |> Process.whereis() + |> fetch_process_value(key) + end + + defp fetch_process_value(pid, key) do + case :erlang.process_info(pid, :dictionary) do + {:dictionary, pdict} -> + Keyword.fetch(pdict, key) + + _other -> + :error + end + end + + defp get_last_pid_from_pdict_list(stack) do + if values = Process.get(stack) do + List.last(values) + end + end + end +end diff --git a/apps/fz_http/lib/fz_http/configurations.ex b/apps/fz_http/lib/fz_http/configurations.ex index ac4295ce5..d4896ac80 100644 --- a/apps/fz_http/lib/fz_http/configurations.ex +++ b/apps/fz_http/lib/fz_http/configurations.ex @@ -4,20 +4,39 @@ defmodule FzHttp.Configurations do """ import Ecto.Query, warn: false - import Ecto.Changeset - alias FzHttp.{Repo, Configurations.Configuration, Configurations.Cache} - defdelegate get(key), to: FzHttp.Configurations.Cache - defdelegate get!(key), to: FzHttp.Configurations.Cache + alias FzHttp.{Repo, Configurations.Configuration} + + def get!(key) do + Map.get(get_configuration!(), key) + end + + def put!(key, val) do + get_configuration!() + |> Configuration.changeset(%{key => val}) + |> Repo.update!() + end def get_configuration! do Repo.one!(Configuration) end - def auto_create_users?(field, provider) do - get!(field) - |> Map.get(provider) - |> Map.get("auto_create_users") + def get_provider_by_id(field, provider_id) do + FzHttp.Configurations.get!(field) + |> Enum.find(&(&1.id == provider_id)) + end + + def auto_create_users?(field, provider_id) do + FzHttp.Configurations.get!(field) + |> Enum.find(&(&1.id == provider_id)) + |> case do + nil -> raise RuntimeError, "Unknown provider #{provider_id}" + provider -> provider.auto_create_users + end + end + + def new_configuration(attrs \\ %{}) do + Configuration.changeset(%Configuration{}, attrs) end def change_configuration(%Configuration{} = config \\ get_configuration!()) do @@ -25,27 +44,30 @@ defmodule FzHttp.Configurations do end def update_configuration(%Configuration{} = config \\ get_configuration!(), attrs) do - config - |> Configuration.changeset(attrs) - |> prepare_changes(fn changeset -> - for {k, v} <- changeset.changes do - case v do - %Ecto.Changeset{} -> - Cache.put!(k, v.changes) + case Repo.update(Configuration.changeset(config, attrs)) do + {:ok, configuration} -> + FzHttp.SAML.StartProxy.restart() + FzHttp.OIDC.StartProxy.restart() - _ -> - Cache.put!(k, v) - end - end + {:ok, configuration} - changeset - end) - |> Repo.update() + error -> + error + end end def logo_types, do: ~w(Default URL Upload) def logo_type(nil), do: "Default" - def logo_type(%{url: _url}), do: "URL" - def logo_type(%{data: _data}), do: "Upload" + def logo_type(%{url: url}) when not is_nil(url), do: "URL" + def logo_type(%{data: data}) when not is_nil(data), do: "Upload" + + def vpn_sessions_expire? do + freq = vpn_duration() + freq > 0 && freq < Configuration.max_vpn_session_duration() + end + + def vpn_duration do + get_configuration!().vpn_session_duration + end end diff --git a/apps/fz_http/lib/fz_http/configurations/configuration.ex b/apps/fz_http/lib/fz_http/configurations/configuration.ex new file mode 100644 index 000000000..0236c0a2e --- /dev/null +++ b/apps/fz_http/lib/fz_http/configurations/configuration.ex @@ -0,0 +1,107 @@ +defmodule FzHttp.Configurations.Configuration do + @moduledoc """ + App global configuration, singleton resource + """ + use FzHttp, :schema + import Ecto.Changeset + + alias FzHttp.{ + Configurations.Logo, + Validators.Common + } + + @min_mtu 576 + @max_mtu 1500 + @min_persistent_keepalive 0 + @max_persistent_keepalive 120 + + # Postgres max int size is 4 bytes + @max_pg_integer 2_147_483_647 + @min_vpn_session_duration 0 + @max_vpn_session_duration @max_pg_integer + + schema "configurations" do + field :allow_unprivileged_device_management, :boolean + field :allow_unprivileged_device_configuration, :boolean + + field :local_auth_enabled, :boolean + field :disable_vpn_on_oidc_error, :boolean + + # The defaults for these fields are set in the following migration: + # apps/fz_http/priv/repo/migrations/20221224210654_fix_sites_nullable_fields.exs + # + # This will be changing in 0.8 and again when we have client apps, + # so this works for the time being. The important thing is allowing users + # to update these fields via the REST API since they were removed as + # environment variables in the above migration. This is important for users + # wishing to configure Firezone with automated Infrastructure tools like + # Terraform. + field :default_client_persistent_keepalive, :integer + field :default_client_mtu, :integer + field :default_client_endpoint, :string + field :default_client_dns, :string + field :default_client_allowed_ips, :string + + # XXX: Remove when this feature is refactored into config expiration feature + # and WireGuard keys are decoupled from devices to facilitate rotation. + # + # See https://github.com/firezone/firezone/issues/1236 + field :vpn_session_duration, :integer, read_after_writes: true + + embeds_one :logo, Logo, on_replace: :delete + + embeds_many :openid_connect_providers, + FzHttp.Configurations.Configuration.OpenIDConnectProvider, + on_replace: :delete + + embeds_many :saml_identity_providers, + FzHttp.Configurations.Configuration.SAMLIdentityProvider, + on_replace: :delete + + timestamps() + end + + @doc false + def changeset(configuration, attrs) do + configuration + |> cast(attrs, ~w[ + local_auth_enabled + allow_unprivileged_device_management + allow_unprivileged_device_configuration + disable_vpn_on_oidc_error + default_client_persistent_keepalive + default_client_mtu + default_client_endpoint + default_client_dns + default_client_allowed_ips + vpn_session_duration + ]a) + |> cast_embed(:logo) + |> cast_embed(:openid_connect_providers, + with: {FzHttp.Configurations.Configuration.OpenIDConnectProvider, :changeset, []} + ) + |> cast_embed(:saml_identity_providers, + with: {FzHttp.Configurations.Configuration.SAMLIdentityProvider, :changeset, []} + ) + |> Common.trim_change(:default_client_dns) + |> Common.trim_change(:default_client_allowed_ips) + |> Common.trim_change(:default_client_endpoint) + |> Common.validate_no_duplicates(:default_client_dns) + |> Common.validate_list_of_ips_or_cidrs(:default_client_allowed_ips) + |> Common.validate_no_duplicates(:default_client_allowed_ips) + |> validate_number(:default_client_mtu, + greater_than_or_equal_to: @min_mtu, + less_than_or_equal_to: @max_mtu + ) + |> validate_number(:default_client_persistent_keepalive, + greater_than_or_equal_to: @min_persistent_keepalive, + less_than_or_equal_to: @max_persistent_keepalive + ) + |> validate_number(:vpn_session_duration, + greater_than_or_equal_to: @min_vpn_session_duration, + less_than_or_equal_to: @max_vpn_session_duration + ) + end + + def max_vpn_session_duration, do: @max_vpn_session_duration +end diff --git a/apps/fz_http/lib/fz_http/conf/oidc_config.ex b/apps/fz_http/lib/fz_http/configurations/configuration/openid_connect_provider.ex similarity index 79% rename from apps/fz_http/lib/fz_http/conf/oidc_config.ex rename to apps/fz_http/lib/fz_http/configurations/configuration/openid_connect_provider.ex index 0abb9fe43..26f988511 100644 --- a/apps/fz_http/lib/fz_http/conf/oidc_config.ex +++ b/apps/fz_http/lib/fz_http/configurations/configuration/openid_connect_provider.ex @@ -1,12 +1,10 @@ -defmodule FzHttp.Conf.OIDCConfig do +defmodule FzHttp.Configurations.Configuration.OpenIDConnectProvider do @moduledoc """ OIDC Config virtual schema """ - use Ecto.Schema - + use FzHttp, :schema import Ecto.Changeset - import FzHttp.Validators.OpenIDConnect - import FzHttp.Validators.Common, only: [validate_uri: 2] + alias FzHttp.Validators @reserved_config_ids [ "identity", @@ -27,8 +25,8 @@ defmodule FzHttp.Conf.OIDCConfig do field :auto_create_users, :boolean, default: true end - def changeset(data) do - %__MODULE__{} + def changeset(struct \\ %__MODULE__{}, data) do + struct |> cast( data, [ @@ -55,8 +53,8 @@ defmodule FzHttp.Conf.OIDCConfig do ]) # Don't allow users to enter reserved config ids |> validate_exclusion(:id, @reserved_config_ids) - |> validate_discovery_document_uri() - |> validate_uri([ + |> Validators.OpenIDConnect.validate_discovery_document_uri() + |> Validators.Common.validate_uri([ :redirect_uri ]) end diff --git a/apps/fz_http/lib/fz_http/conf/saml_config.ex b/apps/fz_http/lib/fz_http/configurations/configuration/saml_identity_provider.ex similarity index 79% rename from apps/fz_http/lib/fz_http/conf/saml_config.ex rename to apps/fz_http/lib/fz_http/configurations/configuration/saml_identity_provider.ex index 1cd472c1b..9317b8930 100644 --- a/apps/fz_http/lib/fz_http/conf/saml_config.ex +++ b/apps/fz_http/lib/fz_http/configurations/configuration/saml_identity_provider.ex @@ -1,11 +1,10 @@ -defmodule FzHttp.Conf.SAMLConfig do +defmodule FzHttp.Configurations.Configuration.SAMLIdentityProvider do @moduledoc """ SAML Config virtual schema """ - use Ecto.Schema - + use FzHttp, :schema import Ecto.Changeset - import FzHttp.Validators.SAML + alias FzHttp.Validators @primary_key false embedded_schema do @@ -20,8 +19,8 @@ defmodule FzHttp.Conf.SAMLConfig do field :auto_create_users, :boolean, default: true end - def changeset(data) do - %__MODULE__{} + def changeset(struct \\ %__MODULE__{}, data) do + struct |> cast(data, [ :id, :label, @@ -39,6 +38,6 @@ defmodule FzHttp.Conf.SAMLConfig do :metadata, :auto_create_users ]) - |> validate_metadata() + |> Validators.SAML.validate_metadata() end end diff --git a/apps/fz_http/lib/fz_http/conf/logo.ex b/apps/fz_http/lib/fz_http/configurations/logo.ex similarity index 81% rename from apps/fz_http/lib/fz_http/conf/logo.ex rename to apps/fz_http/lib/fz_http/configurations/logo.ex index 066d9f458..1cf0fd52b 100644 --- a/apps/fz_http/lib/fz_http/conf/logo.ex +++ b/apps/fz_http/lib/fz_http/configurations/logo.ex @@ -2,10 +2,12 @@ defmodule FzHttp.Configurations.Logo do @moduledoc """ Embedded Schema for logo """ - - use Ecto.Schema + use FzHttp, :schema import Ecto.Changeset + # Singleton per configuration + @primary_key false + embedded_schema do field :url, :string field :data, :string diff --git a/apps/fz_http/lib/fz_http/connectivity_check_service.ex b/apps/fz_http/lib/fz_http/connectivity_check_service.ex index 1e78eac6e..6bd6a8a01 100644 --- a/apps/fz_http/lib/fz_http/connectivity_check_service.ex +++ b/apps/fz_http/lib/fz_http/connectivity_check_service.ex @@ -61,11 +61,11 @@ defmodule FzHttp.ConnectivityCheckService do end defp url do - Application.fetch_env!(:fz_http, :connectivity_checks_url) <> version() + FzHttp.Config.fetch_env!(:fz_http, :connectivity_checks_url) <> version() end defp http_client do - Application.fetch_env!(:fz_http, :http_client) + FzHttp.Config.fetch_env!(:fz_http, :http_client) end defp version do @@ -73,10 +73,10 @@ defmodule FzHttp.ConnectivityCheckService do end defp interval do - Application.fetch_env!(:fz_http, :connectivity_checks_interval) * 1_000 + FzHttp.Config.fetch_env!(:fz_http, :connectivity_checks_interval) * 1_000 end defp enabled? do - Application.fetch_env!(:fz_http, :connectivity_checks_enabled) + FzHttp.Config.fetch_env!(:fz_http, :connectivity_checks_enabled) end end diff --git a/apps/fz_http/lib/fz_http/connectivity_checks/connectivity_check.ex b/apps/fz_http/lib/fz_http/connectivity_checks/connectivity_check.ex index 7571d8984..1846f9b75 100644 --- a/apps/fz_http/lib/fz_http/connectivity_checks/connectivity_check.ex +++ b/apps/fz_http/lib/fz_http/connectivity_checks/connectivity_check.ex @@ -2,7 +2,7 @@ defmodule FzHttp.ConnectivityChecks.ConnectivityCheck do @moduledoc """ Manages the connectivity_checks table """ - use Ecto.Schema + use FzHttp, :schema import Ecto.Changeset @url_regex ~r<\Ahttps://ping(?:-dev)?\.firez\.one/\d+\.\d+\.\d+(?:\+git\.\d+\.[0-9a-fA-F]{7,})?\z> @@ -13,7 +13,7 @@ defmodule FzHttp.ConnectivityChecks.ConnectivityCheck do field :response_headers, :map field :url, :string - timestamps(type: :utc_datetime_usec) + timestamps(updated_at: false) end @doc false diff --git a/apps/fz_http/lib/fz_http/devices.ex b/apps/fz_http/lib/fz_http/devices.ex index 1b7f0489c..868278845 100644 --- a/apps/fz_http/lib/fz_http/devices.ex +++ b/apps/fz_http/lib/fz_http/devices.ex @@ -1,13 +1,17 @@ defmodule FzHttp.Devices do - @moduledoc """ - The Devices context. - """ - import Ecto.Changeset import Ecto.Query, warn: false - alias EctoNetwork.INET - alias FzHttp.{Devices.Device, Devices.DeviceSetting, Repo, Sites, Telemetry, Users, Users.User} + + alias FzHttp.{ + Configurations, + Devices.Device, + Devices.DeviceSetting, + Repo, + Telemetry, + Users, + Users.User + } require Logger @@ -22,7 +26,13 @@ defmodule FzHttp.Devices do end def count do - Repo.one(from d in Device, select: count(d.id)) + Repo.aggregate(Device, :count) + end + + def count(nil), do: 0 + + def count(user_id) do + Repo.one(from d in Device, where: d.user_id == ^user_id, select: count()) end def max_count_by_user_id do @@ -52,14 +62,11 @@ defmodule FzHttp.Devices do end def setting_projection(device) do - DeviceSetting.parse(device) + device + |> DeviceSetting.parse() |> Map.from_struct() end - def count(user_id) do - Repo.one(from d in Device, where: d.user_id == ^user_id, select: count()) - end - def get_device!(id), do: Repo.get!(Device, id) def create_device(attrs \\ %{}) do @@ -113,7 +120,7 @@ defmodule FzHttp.Devices do end def to_peer_list do - vpn_duration = Sites.vpn_duration() + vpn_duration = Configurations.vpn_duration() Repo.all( from d in Device, @@ -132,16 +139,7 @@ defmodule FzHttp.Devices do end def new_device(attrs \\ %{}) do - change_device( - %Device{}, - Map.merge( - %{ - "name" => Device.new_name(), - "preshared_key" => FzCommon.FzCrypto.psk() - }, - attrs - ) - ) + change_device(%Device{}, attrs) end def allowed_ips(device), do: config(device, :allowed_ips) @@ -150,9 +148,9 @@ defmodule FzHttp.Devices do def mtu(device), do: config(device, :mtu) def persistent_keepalive(device), do: config(device, :persistent_keepalive) - defp config(device, key) do - if Map.get(device, String.to_atom("use_site_#{key}")) do - Map.get(Sites.get_site!(), key) + def config(device, key) do + if Map.get(device, String.to_atom("use_default_#{key}")) do + Map.get(Configurations.get_configuration!(), String.to_atom("default_client_#{key}")) else Map.get(device, key) end @@ -160,11 +158,11 @@ defmodule FzHttp.Devices do def defaults(changeset) do ~w( - use_site_allowed_ips - use_site_dns - use_site_endpoint - use_site_mtu - use_site_persistent_keepalive + use_default_allowed_ips + use_default_dns + use_default_endpoint + use_default_mtu + use_default_persistent_keepalive )a |> Map.new(&{&1, get_field(changeset, &1)}) end @@ -197,8 +195,24 @@ defmodule FzHttp.Devices do end def decode(nil), do: nil + def decode(inet) when is_binary(inet), do: inet def decode(inet), do: INET.decode(inet) + @hash_range 2 ** 16 + def new_name(name \\ FzCommon.NameGenerator.generate()) do + hash = + name + |> :erlang.phash2(@hash_range) + |> Integer.to_string(16) + |> String.pad_leading(4, "0") + + if String.length(name) > 15 do + String.slice(name, 0..10) <> hash + else + name + end + end + defp psk_config(device) do if device.preshared_key do "PresharedKey = #{device.preshared_key}" @@ -287,10 +301,10 @@ defmodule FzHttp.Devices do defp field_empty?(_), do: false defp ipv4? do - Application.fetch_env!(:fz_http, :wireguard_ipv4_enabled) + FzHttp.Config.fetch_env!(:fz_http, :wireguard_ipv4_enabled) end defp ipv6? do - Application.fetch_env!(:fz_http, :wireguard_ipv6_enabled) + FzHttp.Config.fetch_env!(:fz_http, :wireguard_ipv6_enabled) end end diff --git a/apps/fz_http/lib/fz_http/devices/device.ex b/apps/fz_http/lib/fz_http/devices/device.ex index fb8b6b5ae..1ae8d9193 100644 --- a/apps/fz_http/lib/fz_http/devices/device.ex +++ b/apps/fz_http/lib/fz_http/devices/device.ex @@ -2,141 +2,114 @@ defmodule FzHttp.Devices.Device do @moduledoc """ Manages Device things """ - - use Ecto.Schema + use FzHttp, :schema import Ecto.Changeset + alias FzHttp.Validators.Common + alias FzHttp.Devices require Logger - import FzHttp.Validators.Common, - only: [ - trim: 2, - validate_omitted: 2, - validate_no_duplicates: 2, - validate_no_mask: 2, - validate_list_of_ips_or_cidrs: 2 - ] - - import FzHttp.Queries.INET - - alias FzHttp.{Devices, Users.User} - @description_max_length 2048 - # Fields for which to trim whitespace after cast, before validation - @whitespace_trimmed_fields ~w( - allowed_ips - dns - endpoint - name - description - )a + # WireGuard base64-encoded string length + @key_length 44 schema "devices" do field :rx_bytes, :integer field :tx_bytes, :integer - field :uuid, Ecto.UUID, autogenerate: true field :name, :string field :description, :string field :public_key, :string field :preshared_key, FzHttp.Encrypted.Binary - field :use_site_allowed_ips, :boolean, read_after_writes: true, default: true - field :use_site_dns, :boolean, read_after_writes: true, default: true - field :use_site_endpoint, :boolean, read_after_writes: true, default: true - field :use_site_mtu, :boolean, read_after_writes: true, default: true - field :use_site_persistent_keepalive, :boolean, read_after_writes: true, default: true + field :use_default_allowed_ips, :boolean, read_after_writes: true, default: true + field :use_default_dns, :boolean, read_after_writes: true, default: true + field :use_default_endpoint, :boolean, read_after_writes: true, default: true + field :use_default_mtu, :boolean, read_after_writes: true, default: true + field :use_default_persistent_keepalive, :boolean, read_after_writes: true, default: true field :endpoint, :string field :mtu, :integer field :persistent_keepalive, :integer field :allowed_ips, :string field :dns, :string field :remote_ip, EctoNetwork.INET - field :ipv4, EctoNetwork.INET, read_after_writes: true - field :ipv6, EctoNetwork.INET, read_after_writes: true + field :ipv4, EctoNetwork.INET + field :ipv6, EctoNetwork.INET + field :latest_handshake, :utc_datetime_usec field :key_regenerated_at, :utc_datetime_usec, read_after_writes: true - belongs_to :user, User + belongs_to :user, FzHttp.Users.User - timestamps(type: :utc_datetime_usec) + timestamps() end - def create_changeset(device \\ %__MODULE__{}, attrs) do - device - |> shared_cast(attrs) - |> put_next_ip(:ipv4) - |> put_next_ip(:ipv6) - |> shared_changeset() + @fields ~w[ + latest_handshake + rx_bytes + tx_bytes + use_default_allowed_ips + use_default_dns + use_default_endpoint + use_default_mtu + use_default_persistent_keepalive + allowed_ips + dns + endpoint + mtu + persistent_keepalive + remote_ip + ipv4 + ipv6 + user_id + name + description + public_key + preshared_key + key_regenerated_at + ]a + + @required_fields ~w[user_id name public_key]a + + def create_changeset(attrs) do + %__MODULE__{} + |> cast(attrs, @fields) + |> Common.put_default_value(:name, &FzHttp.Devices.new_name/0) + |> Common.put_default_value(:preshared_key, &FzCommon.FzCrypto.psk/0) + |> changeset() |> validate_max_devices() + |> validate_required(@required_fields) end def update_changeset(device, attrs) do device - |> shared_cast(attrs) - |> shared_changeset() + |> cast(attrs, @fields) + |> changeset() + |> validate_required(@required_fields) end - @hash_range 2 ** 16 - - def new_name(name \\ FzCommon.NameGenerator.generate()) do - hash = - name - |> :erlang.phash2(@hash_range) - |> Integer.to_string(16) - |> String.pad_leading(4, "0") - - if String.length(name) > 15 do - String.slice(name, 0..10) <> hash - else - name - end - end - - defp shared_cast(device, attrs) do - device - |> cast(attrs, [ - :latest_handshake, - :rx_bytes, - :tx_bytes, - :use_site_allowed_ips, - :use_site_dns, - :use_site_endpoint, - :use_site_mtu, - :use_site_persistent_keepalive, - :allowed_ips, - :dns, - :endpoint, - :mtu, - :persistent_keepalive, - :remote_ip, - :ipv4, - :ipv6, - :user_id, - :name, - :description, - :public_key, - :preshared_key, - :key_regenerated_at - ]) - |> trim(@whitespace_trimmed_fields) - end - - defp shared_changeset(changeset) do + defp changeset(changeset) do changeset - |> validate_required([ - :user_id, - :name, - :public_key - ]) - |> validate_required_unless_site([:endpoint]) - |> validate_omitted_if_site([ - :allowed_ips, - :dns, - :endpoint, - :persistent_keepalive, - :mtu - ]) - |> validate_list_of_ips_or_cidrs(:allowed_ips) - |> validate_no_duplicates(:dns) + |> Common.trim_change(:allowed_ips) + |> Common.trim_change(:dns) + |> Common.trim_change(:endpoint) + |> Common.trim_change(:name) + |> Common.trim_change(:description) + |> Common.validate_base64(:public_key) + |> Common.validate_base64(:preshared_key) + |> validate_length(:public_key, is: @key_length) + |> validate_length(:preshared_key, is: @key_length) + |> validate_length(:description, max: @description_max_length) + |> validate_length(:name, min: 1) + |> assoc_constraint(:user) + |> validate_required_unless_default([:endpoint]) + |> validate_omitted_if_default(~w[ + allowed_ips + dns + endpoint + persistent_keepalive + mtu + ]a) + |> Common.validate_list_of_ips_or_cidrs(:allowed_ips) + |> Common.validate_no_duplicates(:dns) |> validate_number(:persistent_keepalive, greater_than_or_equal_to: 0, less_than_or_equal_to: 120 @@ -145,25 +118,69 @@ defmodule FzHttp.Devices.Device do greater_than_or_equal_to: 576, less_than_or_equal_to: 1500 ) - |> validate_length(:description, max: @description_max_length) - |> validate_ipv4_required() - |> validate_ipv6_required() + |> prepare_changes(fn changeset -> + changeset + |> maybe_put_default_ip(:ipv4) + |> maybe_put_default_ip(:ipv6) + |> validate_exclusion(:ipv4, [ipv4_address()]) + |> validate_exclusion(:ipv6, [ipv6_address()]) + end) |> unique_constraint(:ipv4) |> unique_constraint(:ipv6) - |> validate_exclusion(:ipv4, [ipv4_address()]) - |> validate_exclusion(:ipv6, [ipv6_address()]) - |> validate_in_network(:ipv4) - |> validate_in_network(:ipv6) - |> validate_no_mask(:ipv4) - |> validate_no_mask(:ipv6) |> unique_constraint(:public_key) |> unique_constraint([:user_id, :name]) end + defp maybe_put_default_ip(changeset, field) do + if FzHttp.Config.fetch_env!(:fz_http, :"wireguard_#{field}_enabled") == true do + case fetch_field(changeset, field) do + {:data, nil} -> put_default_ip(changeset, field) + :error -> put_default_ip(changeset, field) + _ -> changeset + end + |> validate_required(field) + else + changeset + end + end + + defp put_default_ip(changeset, field) do + cidr_string = FzHttp.Config.fetch_env!(:fz_http, :"wireguard_#{field}_network") + {:ok, cidr_inet} = EctoNetwork.INET.cast(cidr_string) + cidr = CIDR.parse(cidr_string) + offset = Enum.random(2..(cidr.hosts - 2)) + + {:ok, gateway_address} = + FzHttp.Config.fetch_env!(:fz_http, :"wireguard_#{field}_address") + |> EctoNetwork.INET.cast() + + Devices.Device.Query.next_available_address(cidr_inet, offset, [gateway_address]) + |> FzHttp.Repo.one() + |> case do + nil -> add_error(changeset, :base, "CIDR #{cidr} is exhausted") + ip -> put_change(changeset, field, ip) + end + end + + defp ipv4_address do + FzHttp.Config.fetch_env!(:fz_http, :wireguard_ipv4_address) + |> EctoNetwork.INET.cast() + end + + defp ipv6_address do + FzHttp.Config.fetch_env!(:fz_http, :wireguard_ipv6_address) + |> EctoNetwork.INET.cast() + end + defp validate_max_devices(changeset) do - user_id = changeset.changes.user_id || changeset.data.user_id - count = Devices.count(user_id) - max_devices = Application.fetch_env!(:fz_http, :max_devices_per_user) + # XXX: This suffers from a race condition because the count happens in a separate transaction. + # At the moment it's not a big concern. Fixing it would require locking against INSERTs or DELETEs + # while counts are happening. + count = + get_field(changeset, :user_id) + |> Devices.count() + + max_devices = FzHttp.Config.fetch_env!(:fz_http, :max_devices_per_user) if count >= max_devices do add_error( @@ -176,99 +193,27 @@ defmodule FzHttp.Devices.Device do end end - defp validate_omitted_if_site(changeset, fields) when is_list(fields) do - validate_omitted(changeset, filter_site_fields(changeset, fields, use_site: true)) + defp validate_omitted_if_default(changeset, fields) when is_list(fields) do + Common.validate_omitted( + changeset, + filter_default_fields(changeset, fields, use_default: true) + ) end - defp validate_required_unless_site(changeset, fields) when is_list(fields) do - validate_required(changeset, filter_site_fields(changeset, fields, use_site: false)) + defp validate_required_unless_default(changeset, fields) when is_list(fields) do + validate_required(changeset, filter_default_fields(changeset, fields, use_default: false)) end - defp filter_site_fields(changeset, fields, use_site: use_site) when is_boolean(use_site) do + defp filter_default_fields(changeset, fields, use_default: use_default) + when is_boolean(use_default) do fields - |> Enum.map(fn field -> String.to_atom("use_site_#{field}") end) - |> Enum.filter(fn site_field -> get_field(changeset, site_field) == use_site end) + |> Enum.map(fn field -> String.to_atom("use_default_#{field}") end) + |> Enum.filter(fn default_field -> get_field(changeset, default_field) == use_default end) |> Enum.map(fn field -> field |> Atom.to_string() - |> String.trim("use_site_") + |> String.trim("use_default_") |> String.to_atom() end) end - - defp validate_ipv4_required(changeset) do - if Application.fetch_env!(:fz_http, :wireguard_ipv4_enabled) do - validate_required(changeset, :ipv4) - else - changeset - end - end - - defp validate_ipv6_required(changeset) do - if Application.fetch_env!(:fz_http, :wireguard_ipv6_enabled) do - validate_required(changeset, :ipv6) - else - changeset - end - end - - defp validate_in_network(%Ecto.Changeset{changes: %{ipv4: ip}} = changeset, :ipv4) do - net = Application.fetch_env!(:fz_http, :wireguard_ipv4_network) - add_net_error_if_outside_bounds(changeset, net, ip, :ipv4) - end - - defp validate_in_network(changeset, :ipv4), do: changeset - - defp validate_in_network(%Ecto.Changeset{changes: %{ipv6: ip}} = changeset, :ipv6) do - net = Application.fetch_env!(:fz_http, :wireguard_ipv6_network) - add_net_error_if_outside_bounds(changeset, net, ip, :ipv6) - end - - defp validate_in_network(changeset, :ipv6), do: changeset - - defp add_net_error_if_outside_bounds(changeset, net, ip, ip_type) do - %{address: address} = ip - cidr = CIDR.parse(net) - - if CIDR.match!(cidr, address) do - changeset - else - add_error(changeset, ip_type, "IP must be contained within network #{net}") - end - end - - defp put_next_ip(changeset, ip_type) when ip_type in [:ipv4, :ipv6] do - case changeset do - # Don't put a new ip if the user is trying to assign one manually - %Ecto.Changeset{changes: %{^ip_type => _ip}} -> - changeset - - _ -> - if ip = next_available(ip_type) do - put_change(changeset, ip_type, ip) - else - add_error( - changeset, - :base, - "#{ip_type} address pool is exhausted. Increase network size or remove some devices." - ) - end - end - end - - defp ipv4_address do - {:ok, inet} = - Application.fetch_env!(:fz_http, :wireguard_ipv4_address) - |> EctoNetwork.INET.cast() - - inet - end - - defp ipv6_address do - {:ok, inet} = - Application.fetch_env!(:fz_http, :wireguard_ipv6_address) - |> EctoNetwork.INET.cast() - - inet - end end diff --git a/apps/fz_http/lib/fz_http/devices/device/query.ex b/apps/fz_http/lib/fz_http/devices/device/query.ex new file mode 100644 index 000000000..31088887c --- /dev/null +++ b/apps/fz_http/lib/fz_http/devices/device/query.ex @@ -0,0 +1,126 @@ +defmodule FzHttp.Devices.Device.Query do + import Ecto.Query + + @doc """ + Returns IP address at given integer offset relative to start of CIDR range. + """ + defmacro offset_to_ip(field, cidr) do + quote do + fragment("host(?)::inet + ?", unquote(cidr), unquote(field)) + end + end + + @doc """ + Returns index of last IP address available for allocation in CIDR sequence. + + Notice: the very last address in CIDR is typically a broadcast address that we won't allow to use for devices. + """ + defmacro cidr_end_offset(cidr) do + quote do + fragment( + "host(broadcast(?))::inet - host(?)::inet - 1", + unquote(cidr), + unquote(cidr) + ) + end + end + + @doc """ + Acquires a transactional advisory lock for an IP address using "devices" table oid as namespace. + + To fit bigint offset into int lock identifier we rollover at the integer max value. + """ + defmacro acquire_advisory_lock(field) do + quote do + fragment( + "pg_try_advisory_xact_lock('devices'::regclass::oid::int, mod(?, 2147483647)::int)", + unquote(field) + ) + end + end + + def all do + from(device in FzHttp.Devices.Device, as: :device) + end + + @doc """ + This function returns a query to fetch next available IP address, it works in 2 steps: + + 1. It starts by forward-scanning starting for available addresses at `offset` in a given `network_cidr` + up to the end of CIDR range; + + 2. If forward-scan failed, scan backwards from the offset (exclusive) to start of CIDR range. + + During the search, addresses occupied by other devices or reserved are skipped. + + We also exclude first (X.X.X.0) and last (broadcast) address in a CIDR from a search range, + to prevent issues with legacy firewalls that consider them "class C" space network addresses. + """ + def next_available_address(network_cidr, offset, reserved_address) do + forward_search_queryable = + series_from_offset_inclusive_to_end_of_cidr(network_cidr, offset) + |> select_not_used_ips(network_cidr, reserved_address) + + reverse_search_queryable = + series_from_start_of_cidr_to_offset_exclusive(network_cidr, offset) + |> select_not_used_ips(network_cidr, reserved_address) + + union_all(forward_search_queryable, ^reverse_search_queryable) + |> limit(1) + end + + # Although sequences can work with inet types, we iterate over the sequence using an + # offset relative to start of the given CIDR range. + # + # This way is chosen because IPv6 can not be cast to bigint, so by using it directly + # we won't be able to increment/decrement it while building a sequence. + # + # At the same time offset will fit to bigint even for largest CIDR ranges that Firezone supports. + # + # XXX: We can make this code prettier once https://github.com/elixir-ecto/ecto/commit/8f7bb2665bce30dfab18cfed01585c96495575a6 is released. + defp series_from_offset_inclusive_to_end_of_cidr(network_cidr, offset) do + from( + i in fragment( + "SELECT generate_series((?)::bigint, (?)::bigint, ?) AS ip", + ^offset, + cidr_end_offset(^network_cidr), + 1 + ), + as: :q + ) + end + + defp series_from_start_of_cidr_to_offset_exclusive(_network_cidr, offset) do + from( + i in fragment( + "SELECT generate_series((?)::bigint, (?)::bigint, ?) AS ip", + ^(offset - 1), + 2, + -1 + ), + as: :q + ) + end + + defp select_not_used_ips(queryable, network_cidr, reserved_ips) do + queryable + |> where( + [q: q], + offset_to_ip(q.ip, ^network_cidr) not in subquery(used_ips_subquery(network_cidr)) + ) + |> where([q: q], offset_to_ip(q.ip, ^network_cidr) not in ^reserved_ips) + |> where([q: q], acquire_advisory_lock(q.ip) == true) + |> select([q: q], offset_to_ip(q.ip, ^network_cidr)) + end + + defp used_ips_subquery(queryable \\ all(), address) + + defp used_ips_subquery(queryable, %Postgrex.INET{address: address}) + when tuple_size(address) == 4 do + select(queryable, [device: device], device.ipv4) + end + + defp used_ips_subquery(queryable, %Postgrex.INET{address: _address}) do + select(queryable, [device: device], device.ipv6) + end +end diff --git a/apps/fz_http/lib/fz_http/devices/device_setting.ex b/apps/fz_http/lib/fz_http/devices/device_setting.ex index 1c25a2644..42e3eec20 100644 --- a/apps/fz_http/lib/fz_http/devices/device_setting.ex +++ b/apps/fz_http/lib/fz_http/devices/device_setting.ex @@ -1,35 +1,19 @@ defmodule FzHttp.Devices.DeviceSetting do - @moduledoc """ - Device setting parsed from either a Device struct or map. - """ - use Ecto.Schema - - import Ecto.Changeset - import FzHttp.Devices, only: [decode: 1] + use FzHttp, :schema + alias FzHttp.Devices @primary_key false embedded_schema do field :ip, :string field :ip6, :string - field :user_id, :integer + field :user_id, Ecto.UUID end - def parse(device) when is_struct(device) do + def parse(device_or_device_as_map) do %__MODULE__{ - ip: decode(device.ipv4), - ip6: decode(device.ipv6), - user_id: device.user_id + ip: Devices.decode(device_or_device_as_map.ipv4), + ip6: Devices.decode(device_or_device_as_map.ipv6), + user_id: device_or_device_as_map.user_id } end - - def parse(device) when is_map(device) do - device = - device - |> Map.put(:ip, Map.get(device, :ipv4)) - |> Map.put(:ip6, Map.get(device, :ipv6)) - - %__MODULE__{} - |> cast(device, [:ip, :ip6, :user_id]) - |> apply_changes() - end end diff --git a/apps/fz_http/lib/fz_http/devices/stats_updater.ex b/apps/fz_http/lib/fz_http/devices/stats_updater.ex index 22e0043ac..70d78de6a 100644 --- a/apps/fz_http/lib/fz_http/devices/stats_updater.ex +++ b/apps/fz_http/lib/fz_http/devices/stats_updater.ex @@ -33,7 +33,7 @@ defmodule FzHttp.Devices.StatsUpdater do # XXX: Come up with a better way to update devices in Sandbox mode defp device_to_update(public_key) do - if Application.fetch_env!(:fz_http, :sandbox) do + if FzHttp.Config.fetch_env!(:fz_http, :sandbox) do Repo.one( from Device, order_by: fragment("RANDOM()"), diff --git a/apps/fz_http/lib/fz_http/events.ex b/apps/fz_http/lib/fz_http/events.ex index 53cd8dd61..3cf31902e 100644 --- a/apps/fz_http/lib/fz_http/events.ex +++ b/apps/fz_http/lib/fz_http/events.ex @@ -9,7 +9,7 @@ defmodule FzHttp.Events do # set_config is used because devices need to be re-evaluated in case a # device is added to a User that's not active. - def add(subject, device) when subject == "devices" do + def add("devices", device) do with :ok <- GenServer.call(wall_pid(), {:add_device, Devices.setting_projection(device)}), :ok <- GenServer.call(vpn_pid(), {:set_config, Devices.to_peer_list()}) do :ok @@ -28,11 +28,11 @@ defmodule FzHttp.Events do end end - def add(subject, rule) when subject == "rules" do + def add("rules", rule) do GenServer.call(wall_pid(), {:add_rule, Rules.setting_projection(rule)}) end - def add(subject, user) when subject == "users" do + def add("users", user) do # Security note: It's important to let an exception here crash this service # otherwise, nft could have succeeded in adding the user's set but not the rules # this means that in `update_device` add_device can succeed adding the device to the user's set @@ -40,7 +40,7 @@ defmodule FzHttp.Events do GenServer.call(wall_pid(), {:add_user, Users.setting_projection(user)}) end - def delete(subject, device) when subject == "devices" do + def delete("devices", device) do with :ok <- GenServer.call(wall_pid(), {:delete_device, Devices.setting_projection(device)}), :ok <- GenServer.call(vpn_pid(), {:remove_peer, device.public_key}) do :ok @@ -59,11 +59,11 @@ defmodule FzHttp.Events do end end - def delete(subject, rule) when subject == "rules" do + def delete("rules", rule) do GenServer.call(wall_pid(), {:delete_rule, Rules.setting_projection(rule)}) end - def delete(subject, user) when subject == "users" do + def delete("users", user) do GenServer.call(wall_pid(), {:delete_user, Users.setting_projection(user)}) end diff --git a/apps/fz_http/lib/fz_http/int4range.ex b/apps/fz_http/lib/fz_http/int4range.ex index 8f489273b..512d6343c 100644 --- a/apps/fz_http/lib/fz_http/int4range.ex +++ b/apps/fz_http/lib/fz_http/int4range.ex @@ -5,7 +5,8 @@ defmodule FzHttp.Int4Range do # 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" + @format_error "bad format" + @cast_error "lower value cannot be higher than upper value" def type, do: :int4range @@ -30,7 +31,7 @@ defmodule FzHttp.Int4Range do 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 cast([_, _]), do: {:error, message: @cast_error} def load(%Postgrex.Range{ lower: lower, diff --git a/apps/fz_http/lib/fz_http/mailer.ex b/apps/fz_http/lib/fz_http/mailer.ex index 6f1ef70df..79cc4c1a9 100644 --- a/apps/fz_http/lib/fz_http/mailer.ex +++ b/apps/fz_http/lib/fz_http/mailer.ex @@ -18,7 +18,7 @@ defmodule FzHttpWeb.Mailer do def default_email do Email.new() - |> Email.from(Application.fetch_env!(:fz_http, FzHttpWeb.Mailer)[:from_email]) + |> Email.from(FzHttp.Config.fetch_env!(:fz_http, FzHttpWeb.Mailer)[:from_email]) end def configs_for(provider) do diff --git a/apps/fz_http/lib/fz_http/mfa/method.ex b/apps/fz_http/lib/fz_http/mfa/method.ex index a88ccc887..7a024932e 100644 --- a/apps/fz_http/lib/fz_http/mfa/method.ex +++ b/apps/fz_http/lib/fz_http/mfa/method.ex @@ -2,13 +2,8 @@ defmodule FzHttp.MFA.Method do @moduledoc """ Multi Factor Authentication methods """ - - use Ecto.Schema + use FzHttp, :schema import Ecto.Changeset - import FzHttp.Validators.Common, only: [trim: 2] - - @primary_key {:id, :binary_id, autogenerate: true} - @whitespace_trimmed_fields :name schema "mfa_methods" do field :name, :string @@ -16,18 +11,19 @@ defmodule FzHttp.MFA.Method do field :credential_id, :string field :last_used_at, :utc_datetime_usec field :payload, FzHttp.Encrypted.Map - field :user_id, :id field :secret, :string, virtual: true field :code, :string, virtual: true - timestamps(type: :utc_datetime_usec) + belongs_to :user, FzHttp.Users.User + + timestamps() end @doc false def changeset(method, attrs) do method |> cast(attrs, [:name, :type, :credential_id, :payload, :last_used_at, :secret, :code]) - |> trim(@whitespace_trimmed_fields) + |> update_change(:name, &String.trim/1) |> cast_payload() |> validate_required([:name, :type, :payload]) |> validate_code() diff --git a/apps/fz_http/lib/fz_http/oidc/connection.ex b/apps/fz_http/lib/fz_http/oidc/connection.ex index 19233845c..5aec6c8a0 100644 --- a/apps/fz_http/lib/fz_http/oidc/connection.ex +++ b/apps/fz_http/lib/fz_http/oidc/connection.ex @@ -2,8 +2,7 @@ defmodule FzHttp.OIDC.Connection do @moduledoc """ OIDC connections """ - - use Ecto.Schema + use FzHttp, :schema import Ecto.Changeset schema "oidc_connections" do @@ -11,9 +10,10 @@ defmodule FzHttp.OIDC.Connection do field :refresh_response, :map field :refresh_token, :string field :refreshed_at, :utc_datetime_usec - field :user_id, :id - timestamps(type: :utc_datetime_usec) + belongs_to :user, FzHttp.Users.User + + timestamps() end @doc false diff --git a/apps/fz_http/lib/fz_http/oidc/refresher.ex b/apps/fz_http/lib/fz_http/oidc/refresher.ex index 2ff6a4175..9180cc31f 100644 --- a/apps/fz_http/lib/fz_http/oidc/refresher.ex +++ b/apps/fz_http/lib/fz_http/oidc/refresher.ex @@ -6,7 +6,7 @@ defmodule FzHttp.OIDC.Refresher do import Ecto.{Changeset, Query} import FzHttpWeb.OIDC.Helpers - alias FzHttp.Configurations, as: Conf + alias FzHttp.{OIDC, OIDC.Connection, Repo, Users} require Logger @@ -33,14 +33,12 @@ defmodule FzHttp.OIDC.Refresher do {:stop, :shutdown, user_id} end - defp do_refresh(user_id, %{provider: provider_key, refresh_token: refresh_token} = conn) do - {:ok, provider} = atomize_provider(provider_key) - - Logger.info("Refreshing user\##{user_id} @ #{provider}...") + defp do_refresh(user_id, %{provider: provider_id, refresh_token: refresh_token} = conn) do + Logger.info("Refreshing user\##{user_id} @ #{provider_id}...") result = openid_connect().fetch_tokens( - provider, + provider_id, %{grant_type: "refresh_token", refresh_token: refresh_token} ) @@ -79,6 +77,6 @@ defmodule FzHttp.OIDC.Refresher do end defp enabled? do - Conf.get!(:disable_vpn_on_oidc_error) + FzHttp.Configurations.get!(:disable_vpn_on_oidc_error) end end diff --git a/apps/fz_http/lib/fz_http/oidc/start_proxy.ex b/apps/fz_http/lib/fz_http/oidc/start_proxy.ex index 2609d9c23..df6e7a8b9 100644 --- a/apps/fz_http/lib/fz_http/oidc/start_proxy.ex +++ b/apps/fz_http/lib/fz_http/oidc/start_proxy.ex @@ -1,11 +1,8 @@ defmodule FzHttp.OIDC.StartProxy do @moduledoc """ This proxy simply gets the relevant config at an appropriate timing - (after `FzHttp.Configurations.Cache` has started) and pass to `OpenIDConnect.Worker` """ - alias FzHttp.Configurations, as: Conf - require Logger def child_spec(arg) do @@ -13,43 +10,44 @@ defmodule FzHttp.OIDC.StartProxy do end def start_link(:test) do - auth_oidc_env = Conf.get!(:openid_connect_providers) - Conf.Cache.put!(:parsed_openid_connect_providers, parse(auth_oidc_env)) :ignore end def start_link(_) do - auth_oidc_env = Conf.get!(:openid_connect_providers) + FzHttp.Configurations.get!(:openid_connect_providers) + |> parse() + |> OpenIDConnect.Worker.start_link() + end - if parsed = auth_oidc_env && parse(auth_oidc_env) do - Conf.Cache.put!(:parsed_openid_connect_providers, parsed) - OpenIDConnect.Worker.start_link(parsed) - else - :ignore + # XXX: Remove when configurations support test fixtures + if Mix.env() == :test do + def restart, do: :ignore + else + def restart do + :ok = Supervisor.terminate_child(FzHttp.Supervisor, __MODULE__) + Supervisor.restart_child(FzHttp.Supervisor, __MODULE__) end end - defp parse(auth_oidc_env) when is_binary(auth_oidc_env) do - auth_oidc_env |> Jason.decode!() |> parse() - end + # Convert the configuration record to something openid_connect expects, + # atom-keyed configs eg. [provider: [client_id: "CLIENT_ID" ...]] + defp parse(nil), do: [] - defp parse(auth_oidc_config) when is_map(auth_oidc_config) do - external_url = Application.fetch_env!(:fz_http, :external_url) + defp parse(auth_oidc_config) when is_list(auth_oidc_config) do + external_url = FzHttp.Config.fetch_env!(:fz_http, :external_url) - # Convert Map to something openid_connect expects, atomic keyed configs - # eg. [provider: [client_id: "CLIENT_ID" ...]] - Enum.map(auth_oidc_config, fn {provider, settings} -> + Enum.map(auth_oidc_config, fn provider -> { - String.to_atom(provider), + provider.id, [ - discovery_document_uri: settings["discovery_document_uri"], - client_id: settings["client_id"], - client_secret: settings["client_secret"], + discovery_document_uri: provider.discovery_document_uri, + client_id: provider.client_id, + client_secret: provider.client_secret, redirect_uri: - settings["redirect_uri"] || "#{external_url}/auth/oidc/#{provider}/callback/", - response_type: settings["response_type"], - scope: settings["scope"], - label: settings["label"] + provider.redirect_uri || "#{external_url}/auth/oidc/#{provider.id}/callback/", + response_type: provider.response_type, + scope: provider.scope, + label: provider.label ] } end) diff --git a/apps/fz_http/lib/fz_http/queries/inet.ex b/apps/fz_http/lib/fz_http/queries/inet.ex deleted file mode 100644 index 84447f833..000000000 --- a/apps/fz_http/lib/fz_http/queries/inet.ex +++ /dev/null @@ -1,80 +0,0 @@ -defmodule FzHttp.Queries.INET do - @moduledoc """ - Raw SQL INET queries - """ - - # XXX: This needs to be an insert to avoid the deadlocks - @next_available_ipv4_query """ - WITH combined AS ( - SELECT $2 AS ipv4 - UNION ALL - SELECT devices.ipv4 FROM devices - ) - SELECT combined.ipv4 + 1 AS ipv4 - FROM combined - WHERE combined.ipv4 + 1 < host(broadcast($1))::INET - AND combined.ipv4 + 1 != $2 - AND combined.ipv4 >= host($1)::INET - AND NOT EXISTS ( - SELECT 1 from combined d2 WHERE d2.ipv4 = combined.ipv4 + 1 - ) - ORDER BY combined.ipv4 - LIMIT 1 - """ - - # XXX: This needs to be an insert to avoid the deadlocks - @next_available_ipv6_query """ - WITH combined AS ( - SELECT $2 AS ipv6 - UNION ALL - SELECT devices.ipv6 FROM devices - ) - SELECT combined.ipv6 + 1 AS ipv6 - FROM combined - WHERE combined.ipv6 + 1 < host(broadcast($1))::INET - AND combined.ipv6 + 1 != $2 - AND combined.ipv6 >= host($1)::INET - AND NOT EXISTS ( - SELECT 1 from combined d2 WHERE d2.ipv6 = combined.ipv6 + 1 - ) - ORDER BY combined.ipv6 - LIMIT 1 - """ - - def next_available(type) do - network = wireguard_network(type) - address = wireguard_address(type) - query = next_available_query(type) - - case FzHttp.Repo.query(query, [network, address]) do - {:ok, %Postgrex.Result{rows: [[%Postgrex.INET{} = inet]]}} -> - inet - - {:ok, %Postgrex.Result{rows: []}} -> - nil - - {:error, error} -> - raise(error) - end - end - - defp wireguard_network(type) do - network_key = "wireguard_#{type}_network" |> String.to_existing_atom() - {:ok, network} = EctoNetwork.INET.cast(Application.fetch_env!(:fz_http, network_key)) - network - end - - defp wireguard_address(type) do - address_key = "wireguard_#{type}_address" |> String.to_existing_atom() - {:ok, address} = EctoNetwork.INET.cast(Application.fetch_env!(:fz_http, address_key)) - address - end - - defp next_available_query(:ipv4) do - @next_available_ipv4_query - end - - defp next_available_query(:ipv6) do - @next_available_ipv6_query - end -end diff --git a/apps/fz_http/lib/fz_http/release.ex b/apps/fz_http/lib/fz_http/release.ex index ed43f38f0..d5d7133d0 100644 --- a/apps/fz_http/lib/fz_http/release.ex +++ b/apps/fz_http/lib/fz_http/release.ex @@ -21,16 +21,22 @@ defmodule FzHttp.Release do def create_admin_user do load_app() - if Repo.exists?(from u in User, where: u.email == ^email()) do - change_password(email(), default_password()) - reset_role(email(), :admin) - else - Users.create_admin_user( - email: email(), - password: default_password(), - password_confirmation: default_password() - ) - end + reply = + if Repo.exists?(from u in User, where: u.email == ^email()) do + change_password(email(), default_password()) + reset_role(email(), :admin) + else + Users.create_admin_user( + email: email(), + password: default_password(), + password_confirmation: default_password() + ) + end + + # Notify the user + IO.puts("Password reset! Check $HOME/.firezone/.env for sign in credentials.") + + reply end def change_password(email, password) do @@ -50,11 +56,11 @@ defmodule FzHttp.Release do end def repos do - Application.fetch_env!(@app, :ecto_repos) + FzHttp.Config.fetch_env!(@app, :ecto_repos) end defp email do - Application.fetch_env!(@app, :admin_email) + FzHttp.Config.fetch_env!(@app, :admin_email) end defp load_app do @@ -66,6 +72,6 @@ defmodule FzHttp.Release do end defp default_password do - Application.fetch_env!(@app, :default_admin_password) + FzHttp.Config.fetch_env!(@app, :default_admin_password) end end diff --git a/apps/fz_http/lib/fz_http/rules.ex b/apps/fz_http/lib/fz_http/rules.ex index 45c567c20..17128420a 100644 --- a/apps/fz_http/lib/fz_http/rules.ex +++ b/apps/fz_http/lib/fz_http/rules.ex @@ -7,7 +7,7 @@ defmodule FzHttp.Rules do import Ecto.Changeset alias FzHttp.{Repo, Rules.Rule, Rules.RuleSetting, Telemetry} - def port_rules_supported?, do: Application.fetch_env!(:fz_wall, :port_based_rules_supported) + def port_rules_supported?, do: FzHttp.Config.fetch_env!(:fz_wall, :port_based_rules_supported) defp scope(port_based_rules) when port_based_rules == true do Rule @@ -75,6 +75,12 @@ defmodule FzHttp.Rules do result end + def update_rule(%Rule{} = rule, attrs \\ %{}) do + rule + |> Rule.changeset(attrs) + |> Repo.update() + end + def delete_rule(%Rule{} = rule) do Telemetry.delete_rule() Repo.delete(rule) diff --git a/apps/fz_http/lib/fz_http/rules/rule.ex b/apps/fz_http/lib/fz_http/rules/rule.ex index 301e28ebb..dbd7fcff4 100644 --- a/apps/fz_http/lib/fz_http/rules/rule.ex +++ b/apps/fz_http/lib/fz_http/rules/rule.ex @@ -2,23 +2,22 @@ defmodule FzHttp.Rules.Rule do @moduledoc """ Not really sure what to write here. I'll update this later. """ - - use Ecto.Schema + use FzHttp, :schema 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" + @exclusion_msg "destination overlaps with an existing rule" + @port_range_msg "port is not within valid range" + @port_type_msg "port_type must be specified with port_range" 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) + timestamps() end def changeset(rule, attrs) do 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..dc26044d8 100644 --- a/apps/fz_http/lib/fz_http/rules/rule_setting.ex +++ b/apps/fz_http/lib/fz_http/rules/rule_setting.ex @@ -2,8 +2,7 @@ defmodule FzHttp.Rules.RuleSetting do @moduledoc """ Rule setting parsed from either a Rule struct or map. """ - use Ecto.Schema - + use FzHttp, :schema import Ecto.Changeset import FzHttp.Devices, only: [decode: 1] @@ -11,7 +10,7 @@ defmodule FzHttp.Rules.RuleSetting do embedded_schema do field :action, Ecto.Enum, values: [:drop, :accept] field :destination, :string - field :user_id, :integer + field :user_id, Ecto.UUID field :port_type, Ecto.Enum, values: [:tcp, :udp], default: nil field :port_range, FzHttp.Int4Range, default: nil end diff --git a/apps/fz_http/lib/fz_http/saml/start_proxy.ex b/apps/fz_http/lib/fz_http/saml/start_proxy.ex index 2cd7ecfe0..abf93ed41 100644 --- a/apps/fz_http/lib/fz_http/saml/start_proxy.ex +++ b/apps/fz_http/lib/fz_http/saml/start_proxy.ex @@ -1,30 +1,32 @@ defmodule FzHttp.SAML.StartProxy do @moduledoc """ This proxy starts Samly.Provider with proper configs - (after `FzHttp.Conf.Cache` has started) """ - alias FzHttp.Configurations, as: Conf - def child_spec(arg) do %{id: __MODULE__, start: {__MODULE__, :start_link, [arg]}} end + def start_link(:test) do + start_link(nil) + end + def start_link(_) do + providers = FzHttp.Configurations.get!(:saml_identity_providers) samly = Samly.Provider.start_link() - Application.fetch_env!(:samly, Samly.Provider) + FzHttp.Config.fetch_env!(:samly, Samly.Provider) |> set_service_provider() - |> set_identity_providers() + |> set_identity_providers(providers) |> refresh() samly end def set_service_provider(samly_configs) do - entity_id = Application.fetch_env!(:fz_http, :saml_entity_id) - keyfile = Application.fetch_env!(:fz_http, :saml_keyfile_path) - certfile = Application.fetch_env!(:fz_http, :saml_certfile_path) + entity_id = FzHttp.Config.fetch_env!(:fz_http, :saml_entity_id) + keyfile = FzHttp.Config.fetch_env!(:fz_http, :saml_keyfile_path) + certfile = FzHttp.Config.fetch_env!(:fz_http, :saml_certfile_path) # Only one service provider definition: us. Keyword.put(samly_configs, :service_providers, [ @@ -37,21 +39,23 @@ defmodule FzHttp.SAML.StartProxy do ]) end - def set_identity_providers(samly_configs, providers \\ Conf.get!(:saml_identity_providers)) do - external_url = Application.fetch_env!(:fz_http, :external_url) + def set_identity_providers(samly_configs, providers) do + external_url = FzHttp.Config.fetch_env!(:fz_http, :external_url) identity_providers = providers - |> Enum.map(fn {id, setting} -> + |> Enum.map(fn provider -> + # XXX We should not set default values here, instead they should be part + # of the changeset and always valid in database %{ - id: id, + id: provider.id, sp_id: "firezone", - metadata: Map.get(setting, "metadata"), - base_url: Map.get(setting, "base_url", Path.join(external_url, "/auth/saml")), - sign_requests: Map.get(setting, "sign_requests", true), - sign_metadata: Map.get(setting, "sign_metadata", true), - signed_assertion_in_resp: Map.get(setting, "signed_assertion_in_resp", true), - signed_envelopes_in_resp: Map.get(setting, "signed_envelopes_in_resp", true) + metadata: provider.metadata, + base_url: provider.base_url || Path.join(external_url, "/auth/saml"), + sign_requests: provider.sign_requests || true, + sign_metadata: provider.sign_metadata || true, + signed_assertion_in_resp: provider.signed_assertion_in_resp || true, + signed_envelopes_in_resp: provider.signed_envelopes_in_resp || true } end) @@ -62,4 +66,17 @@ defmodule FzHttp.SAML.StartProxy do Application.put_env(:samly, Samly.Provider, samly_configs) Samly.Provider.refresh_providers() end + + # XXX: This should be removed when the configurations singleton record is removed. + # + # Needed to prevent the test suite from recursively restarting this module as + # it put!()'s mock data + if Mix.env() == :test do + def restart, do: :ignore + else + def restart do + :ok = Supervisor.terminate_child(FzHttp.Supervisor, __MODULE__) + Supervisor.restart_child(FzHttp.Supervisor, __MODULE__) + end + end end diff --git a/apps/fz_http/lib/fz_http/sites.ex b/apps/fz_http/lib/fz_http/sites.ex deleted file mode 100644 index f61c5964b..000000000 --- a/apps/fz_http/lib/fz_http/sites.ex +++ /dev/null @@ -1,46 +0,0 @@ -defmodule FzHttp.Sites do - @moduledoc """ - The Sites context. - """ - - import Ecto.Query, warn: false - alias FzHttp.{Repo, Sites.Site} - - def new_site(attrs \\ %{}) do - Site.changeset(%Site{}, attrs) - end - - def get_site! do - get_site!(name: "default") - end - - def get_site!(name: name) do - Repo.one!( - from s in Site, - where: s.name == ^name - ) - end - - def get_site!(id) do - Repo.get!(Site, id) - end - - def change_site(%Site{} = site) do - Site.changeset(site, %{}) - end - - def update_site(%Site{} = site, attrs) do - site - |> Site.changeset(attrs) - |> Repo.update() - end - - def vpn_sessions_expire? do - freq = vpn_duration() - freq > 0 && freq < Site.max_vpn_session_duration() - end - - def vpn_duration do - get_site!().vpn_session_duration - end -end diff --git a/apps/fz_http/lib/fz_http/sites/site.ex b/apps/fz_http/lib/fz_http/sites/site.ex deleted file mode 100644 index 5cfbf9d6c..000000000 --- a/apps/fz_http/lib/fz_http/sites/site.ex +++ /dev/null @@ -1,74 +0,0 @@ -defmodule FzHttp.Sites.Site do - @moduledoc """ - Represents a VPN / Firewall site and its config. - """ - - use Ecto.Schema - import Ecto.Changeset - - import FzHttp.Validators.Common, - only: [ - trim: 2, - validate_list_of_ips_or_cidrs: 2, - validate_no_duplicates: 2 - ] - - @primary_key {:id, :binary_id, autogenerate: true} - @foreign_key_type :binary_id - - # Postgres max int size is 4 bytes - @max_pg_integer 2_147_483_647 - - @minute 60 - @hour 60 * @minute - @min_mtu 576 - @max_mtu 1500 - @min_persistent_keepalive 0 - @max_persistent_keepalive 1 * @hour - @min_vpn_session_duration 0 - @max_vpn_session_duration @max_pg_integer - @whitespace_trimmed_fields ~w(name dns allowed_ips endpoint)a - - schema "sites" do - field :name, :string - field :dns, :string - field :allowed_ips, :string - field :endpoint, :string - field :persistent_keepalive, :integer - field :mtu, :integer - field :vpn_session_duration, :integer - - timestamps(type: :utc_datetime_usec) - end - - def changeset(site, attrs) do - site - |> cast(attrs, [ - :name, - :dns, - :allowed_ips, - :endpoint, - :persistent_keepalive, - :mtu, - :vpn_session_duration - ]) - |> trim(@whitespace_trimmed_fields) - |> validate_required(:name) - |> validate_no_duplicates(:dns) - |> validate_list_of_ips_or_cidrs(:allowed_ips) - |> validate_no_duplicates(:allowed_ips) - |> validate_number(:mtu, greater_than_or_equal_to: @min_mtu, less_than_or_equal_to: @max_mtu) - |> validate_number(:persistent_keepalive, - greater_than_or_equal_to: @min_persistent_keepalive, - less_than_or_equal_to: @max_persistent_keepalive - ) - |> validate_number(:vpn_session_duration, - greater_than_or_equal_to: @min_vpn_session_duration, - less_than_or_equal_to: @max_vpn_session_duration - ) - end - - def max_vpn_session_duration do - @max_vpn_session_duration - end -end diff --git a/apps/fz_http/lib/fz_http/telemetry.ex b/apps/fz_http/lib/fz_http/telemetry.ex index c6b536fa3..ba685ce45 100644 --- a/apps/fz_http/lib/fz_http/telemetry.ex +++ b/apps/fz_http/lib/fz_http/telemetry.ex @@ -5,9 +5,25 @@ defmodule FzHttp.Telemetry do require Logger - alias FzHttp.Configurations, as: Conf alias FzHttp.{Devices, MFA, Users} + def create_api_token do + telemetry_module().capture( + "add_api_token", + common_fields() + ) + end + + def delete_api_token(api_token) do + telemetry_module().capture( + "delete_api_token", + common_fields() ++ + [ + api_token_created_at: api_token.inserted_at + ] + ) + end + def add_device do telemetry_module().capture( "add_device", @@ -75,9 +91,6 @@ defmodule FzHttp.Telemetry do telemetry_module().capture("ping", ping_data()) end - defp count(subject) when is_map(subject), do: count(Map.keys(subject)) - defp count(subject) when is_list(subject), do: length(subject) - # How far back to count handshakes as an active device @active_device_window 86_400 def ping_data do @@ -91,15 +104,18 @@ defmodule FzHttp.Telemetry do max_devices_for_users: Devices.max_count_by_user_id(), users_with_mfa: MFA.count_distinct_by_user_id(), users_with_mfa_totp: MFA.count_distinct_totp_by_user_id(), - openid_providers: count(Conf.get!(:parsed_openid_connect_providers)), - saml_providers: count(Conf.get!(:saml_identity_providers)), - unprivileged_device_management: Conf.get!(:allow_unprivileged_device_management), - unprivileged_device_configuration: Conf.get!(:allow_unprivileged_device_configuration), - local_authentication: Conf.get!(:local_auth_enabled), - disable_vpn_on_oidc_error: Conf.get!(:disable_vpn_on_oidc_error), + openid_providers: length(FzHttp.Configurations.get!(:openid_connect_providers)), + saml_providers: length(FzHttp.Configurations.get!(:saml_identity_providers)), + unprivileged_device_management: + FzHttp.Configurations.get!(:allow_unprivileged_device_management), + unprivileged_device_configuration: + FzHttp.Configurations.get!(:allow_unprivileged_device_configuration), + local_authentication: FzHttp.Configurations.get!(:local_auth_enabled), + disable_vpn_on_oidc_error: FzHttp.Configurations.get!(:disable_vpn_on_oidc_error), outbound_email: outbound_email?(), - external_database: external_database?(Map.new(conf(FzHttp.Repo))), - logo_type: Conf.logo_type(Conf.get!(:logo)) + external_database: + external_database?(Map.new(FzHttp.Config.fetch_env!(:fz_http, FzHttp.Repo))), + logo_type: FzHttp.Configurations.logo_type(FzHttp.Configurations.get!(:logo)) ] end @@ -109,7 +125,7 @@ defmodule FzHttp.Telemetry do defp common_fields do [ - distinct_id: conf(:telemetry_id), + distinct_id: FzHttp.Config.fetch_env!(:fz_http, :telemetry_id), fqdn: fqdn(), version: version(), kernel_version: "#{os_type()} #{os_version()}" @@ -117,12 +133,12 @@ defmodule FzHttp.Telemetry do end defp telemetry_module do - Application.fetch_env!(:fz_http, :telemetry_module) + FzHttp.Config.fetch_env!(:fz_http, :telemetry_module) end defp fqdn do :fz_http - |> Application.fetch_env!(FzHttpWeb.Endpoint) + |> FzHttp.Config.fetch_env!(FzHttpWeb.Endpoint) |> Keyword.get(:url) |> Keyword.get(:host) end @@ -146,7 +162,7 @@ defmodule FzHttp.Telemetry do end defp outbound_email? do - from_email = conf(FzHttpWeb.Mailer)[:from_email] + from_email = FzHttp.Config.fetch_env!(:fz_http, FzHttpWeb.Mailer)[:from_email] !is_nil(from_email) end @@ -170,8 +186,4 @@ defmodule FzHttp.Telemetry do "0.0.0" end end - - defp conf(key) do - Application.fetch_env!(:fz_http, key) - end end diff --git a/apps/fz_http/lib/fz_http/users.ex b/apps/fz_http/lib/fz_http/users.ex index 01dba9080..899d15d35 100644 --- a/apps/fz_http/lib/fz_http/users.ex +++ b/apps/fz_http/lib/fz_http/users.ex @@ -8,7 +8,6 @@ defmodule FzHttp.Users do alias FzHttp.Devices.Device alias FzHttp.Repo - alias FzHttp.Sites.Site alias FzHttp.Telemetry alias FzHttp.Users.User alias FzHttpWeb.Mailer @@ -57,6 +56,10 @@ defmodule FzHttp.Users do Repo.get_by(User, email: email) end + def get_by_email!(email) do + Repo.get_by!(User, email: email) + end + def create_admin_user(attrs) do create_user_with_role(attrs, :admin) end @@ -215,7 +218,7 @@ defmodule FzHttp.Users do end def vpn_session_expired?(user, duration) do - max = Site.max_vpn_session_duration() + max = FzHttp.Configurations.Configuration.max_vpn_session_duration() case duration do 0 -> diff --git a/apps/fz_http/lib/fz_http/users/user.ex b/apps/fz_http/lib/fz_http/users/user.ex index 15c93ab1e..b64b76bb4 100644 --- a/apps/fz_http/lib/fz_http/users/user.ex +++ b/apps/fz_http/lib/fz_http/users/user.ex @@ -2,22 +2,21 @@ defmodule FzHttp.Users.User do @moduledoc """ Represents a User. """ + use FzHttp, :schema + import Ecto.Changeset + import FzHttp.Users.PasswordHelpers + + alias FzHttp.{ + ApiTokens.ApiToken, + Devices.Device, + OIDC.Connection, + Validators.Common + } @min_password_length 12 @max_password_length 64 - use Ecto.Schema - import Ecto.Changeset - import FzHttp.Users.PasswordHelpers - import FzHttp.Validators.Common, only: [trim: 2] - - alias FzHttp.{Devices.Device, OIDC.Connection} - - # Fields for which to trim whitespace after cast, before validation - @whitespace_trimmed_fields :email - schema "users" do - field :uuid, Ecto.UUID, autogenerate: true field :role, Ecto.Enum, values: [:unprivileged, :admin], default: :unprivileged field :email, :string field :last_signed_in_at, :utc_datetime_usec @@ -33,10 +32,11 @@ defmodule FzHttp.Users.User do field :password_confirmation, :string, virtual: true field :current_password, :string, virtual: true - has_many :devices, Device, on_delete: :delete_all - has_many :oidc_connections, Connection, on_delete: :delete_all + has_many :devices, Device + has_many :oidc_connections, Connection + has_many :api_tokens, ApiToken - timestamps(type: :utc_datetime_usec) + timestamps() end def create_changeset(user, attrs \\ %{}) do @@ -47,7 +47,7 @@ defmodule FzHttp.Users.User do :password, :password_confirmation ]) - |> trim(@whitespace_trimmed_fields) + |> update_change(:email, &String.trim/1) |> validate_required([:email]) |> validate_password_equality() |> validate_length(:password, min: @min_password_length, max: @max_password_length) @@ -87,7 +87,7 @@ defmodule FzHttp.Users.User do def update_email(user, attrs) do user |> cast(attrs, [:email]) - |> trim(:email) + |> Common.trim_change(:email) |> validate_required([:email]) |> validate_format(:email, ~r/@/) end diff --git a/apps/fz_http/lib/fz_http/validators/common.ex b/apps/fz_http/lib/fz_http/validators/common.ex index 1b8d3340a..8a9a618bd 100644 --- a/apps/fz_http/lib/fz_http/validators/common.ex +++ b/apps/fz_http/lib/fz_http/validators/common.ex @@ -11,20 +11,6 @@ defmodule FzHttp.Validators.Common do valid_cidr?: 1 ] - defp do_trim(nil), do: nil - - defp do_trim(str) when is_binary(str), do: String.trim(str) - - def trim(changeset, field) when is_atom(field) do - trim(changeset, [field]) - end - - def trim(changeset, fields) when is_list(fields) do - Enum.reduce(fields, changeset, fn field, cs -> - update_change(cs, field, &do_trim/1) - end) - end - def validate_uri(changeset, fields) when is_list(fields) do Enum.reduce(fields, changeset, fn field, accumulated_changeset -> validate_uri(accumulated_changeset, field) @@ -80,6 +66,15 @@ defmodule FzHttp.Validators.Common do end) end + def validate_base64(changeset, field) do + validate_change(changeset, field, fn _cur, value -> + case Base.decode64(value) do + :error -> [{field, "must be a base64-encoded string"}] + {:ok, _decoded} -> [] + end + end) + end + def validate_omitted(changeset, fields) when is_list(fields) do Enum.reduce(fields, changeset, fn field, accumulated_changeset -> validate_omitted(accumulated_changeset, field) @@ -96,22 +91,6 @@ defmodule FzHttp.Validators.Common do end) end - def validate_no_mask(%Ecto.Changeset{changes: %{ipv4: %{netmask: nm}}} = changeset, :ipv4) - when nm in [nil, 32], - do: changeset - - def validate_no_mask(%Ecto.Changeset{changes: %{ipv6: %{netmask: nm}}} = changeset, :ipv6) - when nm in [nil, 128], - do: changeset - - def validate_no_mask(%Ecto.Changeset{changes: %{ipv4: %{netmask: _}}} = changeset, :ipv4), - do: netmask_error(changeset, :ipv4) - - def validate_no_mask(%Ecto.Changeset{changes: %{ipv6: %{netmask: _}}} = changeset, :ipv6), - do: netmask_error(changeset, :ipv6) - - def validate_no_mask(changeset, _), do: changeset - defp split_comma_list(text) do text |> String.split(",") @@ -126,11 +105,25 @@ defmodule FzHttp.Validators.Common do end end - defp netmask_error(changeset, ip_type) do - add_error( - changeset, - ip_type, - "Only IPs without netmask are supported." - ) + @doc """ + Puts the change if field is not changed or it's value is set to `nil`. + """ + def put_default_value(changeset, _field, nil) do + changeset + end + + def put_default_value(changeset, field, value) do + case fetch_field(changeset, field) do + {:data, nil} -> put_change(changeset, field, maybe_apply(value)) + :error -> put_change(changeset, field, maybe_apply(value)) + _ -> changeset + end + end + + defp maybe_apply(fun) when is_function(fun, 0), do: fun.() + defp maybe_apply(value), do: value + + def trim_change(changeset, field) do + update_change(changeset, field, &if(!is_nil(&1), do: String.trim(&1))) end end diff --git a/apps/fz_http/lib/fz_http_web.ex b/apps/fz_http/lib/fz_http_web.ex index f031820ae..adfa3123b 100644 --- a/apps/fz_http/lib/fz_http_web.ex +++ b/apps/fz_http/lib/fz_http_web.ex @@ -25,7 +25,6 @@ defmodule FzHttpWeb do import FzHttpWeb.Gettext import Phoenix.LiveView.Controller import FzHttpWeb.ControllerHelpers - alias FzHttp.Configurations, as: Conf unquote(verified_routes()) end @@ -52,10 +51,6 @@ defmodule FzHttpWeb do import FzHttpWeb.LiveHelpers unquote(verified_routes()) - - def render_common(template, assigns \\ []) do - render(FzHttpWeb.CommonView, template, assigns) - end end end @@ -63,7 +58,7 @@ defmodule FzHttpWeb do quote do use Phoenix.LiveView, layout: {FzHttpWeb.LayoutView, :live} import FzHttpWeb.LiveHelpers - alias FzHttp.Configurations, as: Conf + alias Phoenix.LiveView.JS unquote(view_helpers()) @@ -74,7 +69,7 @@ defmodule FzHttpWeb do quote do use Phoenix.LiveView, layout: nil import FzHttpWeb.LiveHelpers - alias FzHttp.Configurations, as: Conf + alias Phoenix.LiveView.JS unquote(view_helpers()) @@ -87,7 +82,6 @@ defmodule FzHttpWeb do use Phoenix.LiveComponent use Phoenix.Component, global_prefixes: ~w(x-) import FzHttpWeb.LiveHelpers - alias FzHttp.Configurations, as: Conf unquote(view_helpers()) end diff --git a/apps/fz_http/lib/fz_http_web/authentication.ex b/apps/fz_http/lib/fz_http_web/auth/html/authentication.ex similarity index 60% rename from apps/fz_http/lib/fz_http_web/authentication.ex rename to apps/fz_http/lib/fz_http_web/auth/html/authentication.ex index d877f9ce1..7111b5f8e 100644 --- a/apps/fz_http/lib/fz_http_web/authentication.ex +++ b/apps/fz_http/lib/fz_http_web/auth/html/authentication.ex @@ -1,23 +1,26 @@ -defmodule FzHttpWeb.Authentication do +defmodule FzHttpWeb.Auth.HTML.Authentication do @moduledoc """ - Authentication helpers. + HTML Authentication implementation module for Guardian. """ use Guardian, otp_app: :fz_http use FzHttpWeb, :controller - alias FzHttp.Configurations, as: Conf alias FzHttp.Telemetry alias FzHttp.Users alias FzHttp.Users.User import FzHttpWeb.OIDC.Helpers + require Logger + @guardian_token_name "guardian_default_token" + @impl Guardian def subject_for_token(resource, _claims) do - {:ok, to_string(resource.id)} + {:ok, resource.id} end + @impl Guardian def resource_from_claims(%{"sub" => id}) do case Users.get_user(id) do nil -> {:error, :resource_not_found} @@ -56,34 +59,32 @@ defmodule FzHttpWeb.Authentication do def sign_in(conn, user, auth) do Telemetry.login() Users.update_last_signed_in(user, auth) - %{provider: provider} = auth + %{provider: provider_id} = auth conn = - with :identity <- provider, + with :identity <- provider_id, true <- FzHttp.MFA.exists?(user) do Plug.Conn.put_session(conn, "mfa_required_at", DateTime.utc_now()) else _ -> conn end - |> Plug.Conn.put_session("login_method", provider) + # XXX: OIDC and SAML provider IDs can be strings, so normalize to string here + |> Plug.Conn.put_session("login_method", to_string(provider_id)) __MODULE__.Plug.sign_in(conn, user) end def sign_out(conn) do - with {:ok, provider_key} <- parse_provider(Plug.Conn.get_session(conn, "login_method")), - {:ok, provider} <- atomize_provider(provider_key), - {:ok, client_id} <- - parse_client_id(Conf.get!(:parsed_openid_connect_providers)[provider]), - {:ok, token} <- parse_token(Plug.Conn.get_session(conn, "id_token")), - {:ok, end_session_uri} <- - parse_end_session_uri( - openid_connect().end_session_uri(provider, %{ - client_id: client_id, - id_token_hint: token, - post_logout_redirect_uri: url(~p"/") - }) - ) do + with provider_id when not is_nil(provider_id) <- Plug.Conn.get_session(conn, "login_method"), + provider when not is_nil(provider) <- + FzHttp.Configurations.get_provider_by_id(:openid_connect_providers, provider_id), + token when not is_nil(token) <- Plug.Conn.get_session(conn, "id_token"), + end_session_uri when not is_nil(end_session_uri) <- + openid_connect().end_session_uri(provider_id, %{ + client_id: provider.client_id, + id_token_hint: token, + post_logout_redirect_uri: url(~p"/") + }) do conn |> __MODULE__.Plug.sign_out() |> Plug.Conn.configure_session(drop: true) @@ -97,19 +98,6 @@ defmodule FzHttpWeb.Authentication do end end - defp parse_provider(nil), do: {:error, "provider not present"} - defp parse_provider(p) when is_binary(p), do: {:ok, p} - defp parse_provider(p) when is_atom(p), do: {:ok, "#{p}"} - - defp parse_client_id(nil), do: {:error, "client_id missing"} - defp parse_client_id(c), do: {:ok, c[:client_id]} - - defp parse_token(nil), do: {:error, "token missing"} - defp parse_token(t), do: {:ok, t} - - defp parse_end_session_uri(nil), do: {:error, "end_session_uri missing"} - defp parse_end_session_uri(e), do: {:ok, e} - def get_current_user(%Plug.Conn{} = conn) do __MODULE__.Plug.current_resource(conn) end diff --git a/apps/fz_http/lib/fz_http_web/authentication/error_handler.ex b/apps/fz_http/lib/fz_http_web/auth/html/error_handler.ex similarity index 55% rename from apps/fz_http/lib/fz_http_web/authentication/error_handler.ex rename to apps/fz_http/lib/fz_http_web/auth/html/error_handler.ex index f14f06d30..eebfa9992 100644 --- a/apps/fz_http/lib/fz_http_web/authentication/error_handler.ex +++ b/apps/fz_http/lib/fz_http_web/auth/html/error_handler.ex @@ -1,11 +1,11 @@ -defmodule FzHttpWeb.Authentication.ErrorHandler do +defmodule FzHttpWeb.Auth.HTML.ErrorHandler do @moduledoc """ - Error Handler module implementation for Guardian. + HTML Error Handler module implementation for Guardian. """ use FzHttpWeb, :controller - alias FzHttpWeb.Authentication - import FzHttpWeb.ControllerHelpers, only: [root_path_for_role: 1] + alias FzHttpWeb.Auth.HTML.Authentication + import FzHttpWeb.ControllerHelpers, only: [root_path_for_user: 1] require Logger @behaviour Guardian.Plug.ErrorHandler @@ -15,7 +15,7 @@ defmodule FzHttpWeb.Authentication.ErrorHandler do user = Authentication.get_current_user(conn) conn - |> redirect(to: root_path_for_role(user.role)) + |> redirect(to: root_path_for_user(user)) end @impl Guardian.Plug.ErrorHandler @@ -25,13 +25,8 @@ defmodule FzHttpWeb.Authentication.ErrorHandler do end @impl Guardian.Plug.ErrorHandler - def auth_error(conn, {type, reason}, opts) do - Logger.warn(""" - ErrorHandler.auth_error: Could not validate user. - Type: #{type} - Reason: #{reason} - Opts: #{opts} - """) + def auth_error(conn, {type, reason}, _opts) do + Logger.info("Web auth error. Type: #{type}. Reason: #{reason}.") conn |> put_resp_content_type("text/plain") diff --git a/apps/fz_http/lib/fz_http_web/authentication/pipeline.ex b/apps/fz_http/lib/fz_http_web/auth/html/pipeline.ex similarity index 56% rename from apps/fz_http/lib/fz_http_web/authentication/pipeline.ex rename to apps/fz_http/lib/fz_http_web/auth/html/pipeline.ex index 8dfa41485..1704d5324 100644 --- a/apps/fz_http/lib/fz_http_web/authentication/pipeline.ex +++ b/apps/fz_http/lib/fz_http_web/auth/html/pipeline.ex @@ -1,12 +1,12 @@ -defmodule FzHttpWeb.Authentication.Pipeline do +defmodule FzHttpWeb.Auth.HTML.Pipeline do @moduledoc """ - Plug implementation module for Guardian. + HTML Plug implementation module for Guardian. """ use Guardian.Plug.Pipeline, otp_app: :fz_http, - error_handler: FzHttpWeb.Authentication.ErrorHandler, - module: FzHttpWeb.Authentication + error_handler: FzHttpWeb.Auth.HTML.ErrorHandler, + module: FzHttpWeb.Auth.HTML.Authentication @claims %{"typ" => "access"} diff --git a/apps/fz_http/lib/fz_http_web/auth/json/authentication.ex b/apps/fz_http/lib/fz_http_web/auth/json/authentication.ex new file mode 100644 index 000000000..2983ddd97 --- /dev/null +++ b/apps/fz_http/lib/fz_http_web/auth/json/authentication.ex @@ -0,0 +1,38 @@ +defmodule FzHttpWeb.Auth.JSON.Authentication do + @moduledoc """ + API Authentication implementation module for Guardian. + """ + use Guardian, otp_app: :fz_http + + alias FzHttp.{ + ApiTokens.ApiToken, + ApiTokens, + Users.User, + Users + } + + @impl Guardian + def subject_for_token(user, _claims) do + {:ok, user.id} + end + + @impl Guardian + def resource_from_claims(%{"api" => api_token_id}) do + with %ApiTokens.ApiToken{} = api_token <- ApiTokens.get_unexpired_api_token(api_token_id), + %Users.User{} = user <- Users.get_user(api_token.user_id) do + {:ok, user} + else + _ -> + {:error, :resource_not_found} + end + end + + def fz_encode_and_sign(%ApiToken{} = api_token, %User{} = user) do + claims = %{ + "api" => api_token.id, + "exp" => DateTime.to_unix(api_token.expires_at) + } + + Guardian.encode_and_sign(__MODULE__, user, claims) + end +end diff --git a/apps/fz_http/lib/fz_http_web/auth/json/error_handler.ex b/apps/fz_http/lib/fz_http_web/auth/json/error_handler.ex new file mode 100644 index 000000000..40f24cb3e --- /dev/null +++ b/apps/fz_http/lib/fz_http_web/auth/json/error_handler.ex @@ -0,0 +1,18 @@ +defmodule FzHttpWeb.Auth.JSON.ErrorHandler do + @moduledoc """ + API Error Handler module implementation for Guardian. + """ + use FzHttpWeb, :controller + require Logger + + @behaviour Guardian.Plug.ErrorHandler + + @impl Guardian.Plug.ErrorHandler + def auth_error(conn, {type, reason}, _opts) do + Logger.info("API auth error. Type: #{type}. Reason: #{reason}.") + + conn + |> put_resp_content_type("application/json") + |> send_resp(401, to_string(type)) + end +end diff --git a/apps/fz_http/lib/fz_http_web/auth/json/pipeline.ex b/apps/fz_http/lib/fz_http_web/auth/json/pipeline.ex new file mode 100644 index 000000000..16d858a38 --- /dev/null +++ b/apps/fz_http/lib/fz_http_web/auth/json/pipeline.ex @@ -0,0 +1,17 @@ +defmodule FzHttpWeb.Auth.JSON.Pipeline do + @moduledoc """ + API Plug implementation module for Guardian. + """ + + use Guardian.Plug.Pipeline, + otp_app: :fz_http, + error_handler: FzHttpWeb.Auth.JSON.ErrorHandler, + module: FzHttpWeb.Auth.JSON.Authentication + + # 90 days + @max_age 60 * 60 * 24 * 90 + + plug Guardian.Plug.VerifyHeader, max_age: @max_age + plug Guardian.Plug.EnsureAuthenticated + plug Guardian.Plug.LoadResource +end diff --git a/apps/fz_http/lib/fz_http_web/channels/notification_channel.ex b/apps/fz_http/lib/fz_http_web/channels/notification_channel.ex index 970da7074..b44affeda 100644 --- a/apps/fz_http/lib/fz_http_web/channels/notification_channel.ex +++ b/apps/fz_http/lib/fz_http_web/channels/notification_channel.ex @@ -52,7 +52,7 @@ defmodule FzHttpWeb.NotificationChannel do end defp presence_list(socket) do - ids_to_show = [Integer.to_string(socket.assigns.current_user.id)] + ids_to_show = [socket.assigns.current_user.id] Presence.list(socket) |> Map.take(ids_to_show) diff --git a/apps/fz_http/lib/fz_http_web/controller_helpers.ex b/apps/fz_http/lib/fz_http_web/controller_helpers.ex index 0f813046b..4583ab967 100644 --- a/apps/fz_http/lib/fz_http_web/controller_helpers.ex +++ b/apps/fz_http/lib/fz_http_web/controller_helpers.ex @@ -4,11 +4,13 @@ defmodule FzHttpWeb.ControllerHelpers do """ use FzHttpWeb, :helper - def root_path_for_role(:admin) do + alias FzHttp.Users.User + + def root_path_for_user(%User{role: :admin}) do ~p"/users" end - def root_path_for_role(:unprivileged) do + def root_path_for_user(%User{role: :unprivileged}) do ~p"/user_devices" end end diff --git a/apps/fz_http/lib/fz_http_web/controllers/auth_controller.ex b/apps/fz_http/lib/fz_http_web/controllers/auth_controller.ex index b32364a13..8ba003a67 100644 --- a/apps/fz_http/lib/fz_http_web/controllers/auth_controller.ex +++ b/apps/fz_http/lib/fz_http_web/controllers/auth_controller.ex @@ -8,7 +8,7 @@ defmodule FzHttpWeb.AuthController do @local_auth_providers [:identity, :magic_link] alias FzHttp.Users - alias FzHttpWeb.Authentication + alias FzHttpWeb.Auth.HTML.Authentication alias FzHttpWeb.OAuth.PKCE alias FzHttpWeb.OIDC.State alias FzHttpWeb.UserFromAuth @@ -21,8 +21,6 @@ defmodule FzHttpWeb.AuthController do plug Ueberauth def request(conn, _params) do - # XXX: Helpers.callback_url/1 generates the wrong URL behind nginx. - # This is a bug in Ueberauth. auth_path is used instead. path = ~p"/auth/identity/callback" conn @@ -30,9 +28,7 @@ defmodule FzHttpWeb.AuthController do end def callback(%{assigns: %{ueberauth_failure: %{errors: errors}}} = conn, _params) do - msg = - errors - |> Enum.map_join(". ", fn error -> error.message end) + msg = Enum.map_join(errors, ". ", fn error -> error.message end) conn |> put_flash(:error, msg) @@ -61,24 +57,24 @@ defmodule FzHttpWeb.AuthController do end end - def callback(conn, %{"provider" => provider_key, "state" => state} = params) do + def callback(conn, %{"provider" => provider_id, "state" => state} = params) + when is_binary(provider_id) do token_params = Map.merge(params, PKCE.token_params(conn)) - with {:ok, provider} <- atomize_provider(provider_key), - :ok <- State.verify_state(conn, state), - {:ok, tokens} <- openid_connect().fetch_tokens(provider, token_params), - {:ok, claims} <- openid_connect().verify(provider, tokens["id_token"]) do - case UserFromAuth.find_or_create(provider_key, claims) do + with :ok <- State.verify_state(conn, state), + {:ok, tokens} <- openid_connect().fetch_tokens(provider_id, token_params), + {:ok, claims} <- openid_connect().verify(provider_id, tokens["id_token"]) do + case UserFromAuth.find_or_create(provider_id, claims) do {:ok, user} -> # only first-time connect will include refresh token # XXX: Remove this when SCIM 2.0 is implemented with %{"refresh_token" => refresh_token} <- tokens do - FzHttp.OIDC.create_connection(user.id, provider_key, refresh_token) + FzHttp.OIDC.create_connection(user.id, provider_id, refresh_token) end conn |> put_session("id_token", tokens["id_token"]) - |> maybe_sign_in(user, %{provider: provider}) + |> maybe_sign_in(user, %{provider: provider_id}) {:error, reason} -> conn @@ -104,9 +100,16 @@ defmodule FzHttpWeb.AuthController do end end - def delete(conn, _params) do + # This can be called if the user attempts to visit one of the callback redirect URLs + # directly. + def callback(conn, params) do conn - |> Authentication.sign_out() + |> put_flash(:error, inspect(params) <> inspect(conn.assigns)) + |> redirect(to: ~p"/") + end + + def delete(conn, _params) do + Authentication.sign_out(conn) end def reset_password(conn, _params) do @@ -139,7 +142,7 @@ defmodule FzHttpWeb.AuthController do end end - def redirect_oidc_auth_uri(conn, %{"provider" => provider_key}) do + def redirect_oidc_auth_uri(conn, %{"provider" => provider_id}) when is_binary(provider_id) do verifier = PKCE.code_verifier() params = %{ @@ -149,27 +152,17 @@ defmodule FzHttpWeb.AuthController do code_challenge: PKCE.code_challenge(verifier) } - with {:ok, provider} <- atomize_provider(provider_key), - uri <- openid_connect().authorization_uri(provider, params) do - conn - |> PKCE.put_cookie(verifier) - |> State.put_cookie(params.state) - |> redirect(external: uri) - else - _ -> - msg = "OpenIDConnect error: provider #{provider_key} not found in config" - Logger.warn(msg) + uri = openid_connect().authorization_uri(provider_id, params) - conn - |> put_resp_content_type("text/plain") - |> send_resp(400, "OIDC Error. Check logs.") - |> halt() - end + conn + |> PKCE.put_cookie(verifier) + |> State.put_cookie(params.state) + |> redirect(external: uri) end - defp maybe_sign_in(conn, user, %{provider: provider} = auth) - when provider in @local_auth_providers do - if Conf.get!(:local_auth_enabled) do + defp maybe_sign_in(conn, user, %{provider: provider_key} = auth) + when is_atom(provider_key) and provider_key in @local_auth_providers do + if FzHttp.Configurations.get!(:local_auth_enabled) do do_sign_in(conn, user, auth) else conn @@ -186,6 +179,6 @@ defmodule FzHttpWeb.AuthController do |> Authentication.sign_in(user, auth) |> configure_session(renew: true) |> put_session(:live_socket_id, "users_socket:#{user.id}") - |> redirect(to: root_path_for_role(user.role)) + |> redirect(to: root_path_for_user(user)) end end diff --git a/apps/fz_http/lib/fz_http_web/controllers/json/configuration_controller.ex b/apps/fz_http/lib/fz_http_web/controllers/json/configuration_controller.ex new file mode 100644 index 000000000..4e56f3a3d --- /dev/null +++ b/apps/fz_http/lib/fz_http_web/controllers/json/configuration_controller.ex @@ -0,0 +1,21 @@ +defmodule FzHttpWeb.JSON.ConfigurationController do + use FzHttpWeb, :controller + + alias FzHttp.{Configurations.Configuration, Configurations} + + action_fallback FzHttpWeb.JSON.FallbackController + + def show(conn, _params) do + configuration = Configurations.get_configuration!() + render(conn, "show.json", configuration: configuration) + end + + def update(conn, %{"configuration" => params}) do + configuration = Configurations.get_configuration!() + + with {:ok, %Configuration{} = configuration} <- + Configurations.update_configuration(configuration, params) do + render(conn, "show.json", configuration: configuration) + end + end +end diff --git a/apps/fz_http/lib/fz_http_web/controllers/json/device_controller.ex b/apps/fz_http/lib/fz_http_web/controllers/json/device_controller.ex new file mode 100644 index 000000000..c3f65846e --- /dev/null +++ b/apps/fz_http/lib/fz_http_web/controllers/json/device_controller.ex @@ -0,0 +1,46 @@ +defmodule FzHttpWeb.JSON.DeviceController do + @moduledoc """ + REST API Controller for Devices. + """ + + use FzHttpWeb, :controller + + action_fallback FzHttpWeb.JSON.FallbackController + + alias FzHttp.Devices + + def index(conn, _params) do + devices = Devices.list_devices() + render(conn, "index.json", devices: devices) + end + + def create(conn, %{"device" => device_params}) do + with {:ok, device} <- Devices.create_device(device_params) do + conn + |> put_status(:created) + |> put_resp_header("location", ~p"/v0/devices/#{device}") + |> render("show.json", device: device) + end + end + + def show(conn, %{"id" => id}) do + device = Devices.get_device!(id) + render(conn, "show.json", device: device) + end + + def update(conn, %{"id" => id, "device" => device_params}) do + device = Devices.get_device!(id) + + with {:ok, device} <- Devices.update_device(device, device_params) do + render(conn, "show.json", device: device) + end + end + + def delete(conn, %{"id" => id}) do + device = Devices.get_device!(id) + + with {:ok, _device} <- Devices.delete_device(device) do + send_resp(conn, :no_content, "") + end + end +end diff --git a/apps/fz_http/lib/fz_http_web/controllers/json/fallback_controller.ex b/apps/fz_http/lib/fz_http_web/controllers/json/fallback_controller.ex new file mode 100644 index 000000000..052767cf3 --- /dev/null +++ b/apps/fz_http/lib/fz_http_web/controllers/json/fallback_controller.ex @@ -0,0 +1,30 @@ +defmodule FzHttpWeb.JSON.FallbackController do + @moduledoc """ + Translates controller action results into valid `Plug.Conn` responses. + + See `Phoenix.Controller.action_fallback/1` for more details. + """ + use FzHttpWeb, :controller + + # This clause is an example of how to handle resources that cannot be found. + def call(conn, {:error, :not_found}) do + conn + |> put_status(:not_found) + |> put_view(FzHttpWeb.ErrorView) + |> render("404.json") + end + + def call(conn, {:error, :internal_server_error}) do + conn + |> put_status(:internal_server_error) + |> put_view(FzHttpWeb.ErrorView) + |> render("500.json") + end + + def call(conn, {:error, %Ecto.Changeset{valid?: false} = changeset}) do + conn + |> put_status(422) + |> put_view(FzHttpWeb.JSON.ChangesetView) + |> render("error.json", changeset: changeset) + end +end diff --git a/apps/fz_http/lib/fz_http_web/controllers/json/rule_controller.ex b/apps/fz_http/lib/fz_http_web/controllers/json/rule_controller.ex new file mode 100644 index 000000000..7e05f4826 --- /dev/null +++ b/apps/fz_http/lib/fz_http_web/controllers/json/rule_controller.ex @@ -0,0 +1,47 @@ +defmodule FzHttpWeb.JSON.RuleController do + @moduledoc """ + REST API Controller for Rules. + """ + + use FzHttpWeb, :controller + + action_fallback FzHttpWeb.JSON.FallbackController + + alias FzHttp.Rules + + def index(conn, _params) do + # XXX: Add user-scoped rules + rules = Rules.list_rules() + render(conn, "index.json", rules: rules) + end + + def create(conn, %{"rule" => rule_params}) do + with {:ok, rule} <- Rules.create_rule(rule_params) do + conn + |> put_status(:created) + |> put_resp_header("location", ~p"/v0/rules/#{rule}") + |> render("show.json", rule: rule) + end + end + + def show(conn, %{"id" => id}) do + rule = Rules.get_rule!(id) + render(conn, "show.json", rule: rule) + end + + def update(conn, %{"id" => id, "rule" => rule_params}) do + rule = Rules.get_rule!(id) + + with {:ok, rule} <- Rules.update_rule(rule, rule_params) do + render(conn, "show.json", rule: rule) + end + end + + def delete(conn, %{"id" => id}) do + rule = Rules.get_rule!(id) + + with {:ok, _rule} <- Rules.delete_rule(rule) do + send_resp(conn, :no_content, "") + end + end +end diff --git a/apps/fz_http/lib/fz_http_web/controllers/json/user_controller.ex b/apps/fz_http/lib/fz_http_web/controllers/json/user_controller.ex new file mode 100644 index 000000000..a80d047c7 --- /dev/null +++ b/apps/fz_http/lib/fz_http_web/controllers/json/user_controller.ex @@ -0,0 +1,51 @@ +defmodule FzHttpWeb.JSON.UserController do + use FzHttpWeb, :controller + + alias FzHttp.Users + alias FzHttp.Users.User + + action_fallback FzHttpWeb.JSON.FallbackController + + def index(conn, _params) do + users = Users.list_users() + render(conn, "index.json", users: users) + end + + def create(conn, %{"user" => user_params}) do + with {:ok, %User{} = user} <- Users.create_user(user_params) do + conn + |> put_status(:created) + |> put_resp_header("location", ~p"/v0/users/#{user}") + |> render("show.json", user: user) + end + end + + def show(conn, %{"id" => id_or_email}) do + user = get_user_by_id_or_email(id_or_email) + render(conn, "show.json", user: user) + end + + def update(conn, %{"id" => id, "user" => user_params}) do + user = Users.get_user!(id) + + with {:ok, %User{} = user} <- Users.admin_update_user(user, user_params) do + render(conn, "show.json", user: user) + end + end + + def delete(conn, %{"id" => id}) do + user = Users.get_user!(id) + + with {:ok, %User{}} <- Users.delete_user(user) do + send_resp(conn, :no_content, "") + end + end + + defp get_user_by_id_or_email(id_or_email) do + if String.contains?(id_or_email, "@") do + Users.get_by_email!(id_or_email) + else + Users.get_user!(id_or_email) + end + end +end diff --git a/apps/fz_http/lib/fz_http_web/controllers/root_controller.ex b/apps/fz_http/lib/fz_http_web/controllers/root_controller.ex index 16f0561d3..5bf9a99f7 100644 --- a/apps/fz_http/lib/fz_http_web/controllers/root_controller.ex +++ b/apps/fz_http/lib/fz_http_web/controllers/root_controller.ex @@ -4,15 +4,13 @@ defmodule FzHttpWeb.RootController do """ use FzHttpWeb, :controller - alias FzHttp.Configurations, as: Conf - def index(conn, _params) do conn |> render( "auth.html", - local_enabled: Conf.get!(:local_auth_enabled), - openid_connect_providers: Conf.get!(:parsed_openid_connect_providers), - saml_identity_providers: Conf.get!(:saml_identity_providers) + local_enabled: FzHttp.Configurations.get!(:local_auth_enabled), + openid_connect_providers: FzHttp.Configurations.get!(:openid_connect_providers), + saml_identity_providers: FzHttp.Configurations.get!(:saml_identity_providers) ) end end diff --git a/apps/fz_http/lib/fz_http_web/controllers/user_controller.ex b/apps/fz_http/lib/fz_http_web/controllers/user_controller.ex index 2a66866cc..30c20283a 100644 --- a/apps/fz_http/lib/fz_http_web/controllers/user_controller.ex +++ b/apps/fz_http/lib/fz_http_web/controllers/user_controller.ex @@ -4,7 +4,7 @@ defmodule FzHttpWeb.UserController do """ alias FzHttp.Users - alias FzHttpWeb.Authentication + alias FzHttpWeb.Auth.HTML.Authentication use FzHttpWeb, :controller require Logger diff --git a/apps/fz_http/lib/fz_http_web/header_helpers.ex b/apps/fz_http/lib/fz_http_web/header_helpers.ex index 6c93b9212..d38268cec 100644 --- a/apps/fz_http/lib/fz_http_web/header_helpers.ex +++ b/apps/fz_http/lib/fz_http_web/header_helpers.ex @@ -5,9 +5,9 @@ defmodule FzHttpWeb.HeaderHelpers do @remote_ip_headers ["x-forwarded-for"] - def external_trusted_proxies, do: conf(:external_trusted_proxies) + def external_trusted_proxies, do: FzHttp.Config.fetch_env!(:fz_http, :external_trusted_proxies) - def clients, do: conf(:private_clients) + def clients, do: FzHttp.Config.fetch_env!(:fz_http, :private_clients) def proxied?, do: not (external_trusted_proxies() == false) @@ -18,8 +18,4 @@ defmodule FzHttpWeb.HeaderHelpers do clients: clients() ] end - - defp conf(key) when is_atom(key) do - Application.fetch_env!(:fz_http, key) - end end diff --git a/apps/fz_http/lib/fz_http_web/live/device_live/admin/show_live.ex b/apps/fz_http/lib/fz_http_web/live/device_live/admin/show_live.ex index 90e888793..b35dcbebe 100644 --- a/apps/fz_http/lib/fz_http_web/live/device_live/admin/show_live.ex +++ b/apps/fz_http/lib/fz_http_web/live/device_live/admin/show_live.ex @@ -51,7 +51,7 @@ defmodule FzHttpWeb.DeviceLive.Admin.Show do allowed_ips: Devices.allowed_ips(device), dns: Devices.dns(device), endpoint: Devices.endpoint(device), - port: Application.fetch_env!(:fz_vpn, :wireguard_port), + port: FzHttp.Config.fetch_env!(:fz_vpn, :wireguard_port), mtu: Devices.mtu(device), persistent_keepalive: Devices.persistent_keepalive(device), config: Devices.as_config(device) diff --git a/apps/fz_http/lib/fz_http_web/live/device_live/new_form_component.ex b/apps/fz_http/lib/fz_http_web/live/device_live/new_form_component.ex index 7ec85bfa3..bce96ed69 100644 --- a/apps/fz_http/lib/fz_http_web/live/device_live/new_form_component.ex +++ b/apps/fz_http/lib/fz_http_web/live/device_live/new_form_component.ex @@ -4,9 +4,7 @@ defmodule FzHttpWeb.DeviceLive.NewFormComponent do """ use FzHttpWeb, :live_component - alias FzHttp.Configurations, as: Conf alias FzHttp.Devices - alias FzHttp.Sites alias FzHttpWeb.ErrorHelpers @impl Phoenix.LiveComponent @@ -17,6 +15,13 @@ defmodule FzHttpWeb.DeviceLive.NewFormComponent do |> assign(:config, nil)} end + @default_fields ~w( + default_client_mtu + default_client_endpoint + default_client_persistent_keepalive + default_client_dns + default_client_allowed_ips + )a @impl Phoenix.LiveComponent def update(assigns, socket) do changeset = new_changeset(socket) @@ -25,9 +30,7 @@ defmodule FzHttpWeb.DeviceLive.NewFormComponent do socket |> assign(assigns) |> assign(:changeset, changeset) - |> assign( - Map.take(Sites.get_site!(), [:mtu, :endpoint, :persistent_keepalive, :dns, :allowed_ips]) - ) + |> assign(Map.take(FzHttp.Configurations.get_configuration!(), @default_fields)) |> assign(Devices.defaults(changeset))} end @@ -78,8 +81,8 @@ defmodule FzHttpWeb.DeviceLive.NewFormComponent do defp authorized_to_create?(socket) do has_role?(socket, :admin) || - (Conf.get!(:allow_unprivileged_device_management) && - to_string(socket.assigns.current_user.id) == to_string(socket.assigns.target_user_id)) + (FzHttp.Configurations.get!(:allow_unprivileged_device_management) && + socket.assigns.current_user.id == socket.assigns.target_user_id) end # update/2 is called twice: on load and then connect. @@ -87,9 +90,10 @@ defmodule FzHttpWeb.DeviceLive.NewFormComponent do # XXX: Clean this up using assign_new/3 defp new_changeset(socket) do if connected?(socket) do - Devices.new_device() + %{name: FzHttp.Devices.new_name()} else - Devices.new_device(%{"name" => nil}) + %{} end + |> Devices.new_device() end end diff --git a/apps/fz_http/lib/fz_http_web/live/device_live/new_form_component.html.heex b/apps/fz_http/lib/fz_http_web/live/device_live/new_form_component.html.heex index e20f26478..5891bd191 100644 --- a/apps/fz_http/lib/fz_http_web/live/device_live/new_form_component.html.heex +++ b/apps/fz_http/lib/fz_http_web/live/device_live/new_form_component.html.heex @@ -95,17 +95,17 @@ <%= if FzHttpWeb.DeviceView.can_configure_devices?(@current_user) do %>
- Default: <%= @allowed_ips %> + Default: <%= @default_client_allowed_ips %>
@@ -123,17 +123,17 @@
- Default: <%= @dns %> + Default: <%= @default_client_dns %>
@@ -151,17 +151,17 @@
- Default: <%= @endpoint %> + Default: <%= @default_client_endpoint %>
@@ -180,17 +180,17 @@
- Default: <%= @mtu %> + Default: <%= @default_client_mtu %>
@@ -209,19 +209,19 @@
- Default: <%= @persistent_keepalive %> + Default: <%= @default_client_persistent_keepalive %>
diff --git a/apps/fz_http/lib/fz_http_web/live/device_live/unprivileged/index.html.heex b/apps/fz_http/lib/fz_http_web/live/device_live/unprivileged/index.html.heex index 1e2a5c48d..20a925b79 100644 --- a/apps/fz_http/lib/fz_http_web/live/device_live/unprivileged/index.html.heex +++ b/apps/fz_http/lib/fz_http_web/live/device_live/unprivileged/index.html.heex @@ -29,12 +29,13 @@
| Name | -Assigned Device IP | +Tunnel IPv4 | +Tunnel IPv6 | Public key | Created | - <%= device.ipv4 %> - <%= device.ipv6 %> - | +<%= device.ipv4 %> | +<%= device.ipv6 %> | <%= device.public_key %> | user end)} else {:halt, not_authorized(socket)} diff --git a/apps/fz_http/lib/fz_http_web/live/mfa_live/auth_live.ex b/apps/fz_http/lib/fz_http_web/live/mfa_live/auth_live.ex index 2e2015cd4..96696b588 100644 --- a/apps/fz_http/lib/fz_http_web/live/mfa_live/auth_live.ex +++ b/apps/fz_http/lib/fz_http_web/live/mfa_live/auth_live.ex @@ -117,7 +117,7 @@ defmodule FzHttpWeb.MFALive.Auth do {:ok, _method} -> {:noreply, push_redirect(socket, - to: root_path_for_role(socket.assigns.current_user.role) + to: root_path_for_user(socket.assigns.current_user) )} {:error, changeset} -> diff --git a/apps/fz_http/lib/fz_http_web/live/modal_component.ex b/apps/fz_http/lib/fz_http_web/live/modal_component.ex index 7707701ee..bf57c94ad 100644 --- a/apps/fz_http/lib/fz_http_web/live/modal_component.ex +++ b/apps/fz_http/lib/fz_http_web/live/modal_component.ex @@ -32,7 +32,7 @@ defmodule FzHttpWeb.ModalComponent do | <% end %> -
- <%= device.ipv4 %>
- - <%= device.ipv6 %> - |
+ <%= device.ipv4 %> | +<%= device.ipv6 %> | <%= device.remote_ip %> | diff --git a/apps/fz_http/lib/fz_http_web/user_from_auth.ex b/apps/fz_http/lib/fz_http_web/user_from_auth.ex index ec92ba3d1..9f43ffec8 100644 --- a/apps/fz_http/lib/fz_http_web/user_from_auth.ex +++ b/apps/fz_http/lib/fz_http_web/user_from_auth.ex @@ -3,42 +3,40 @@ defmodule FzHttpWeb.UserFromAuth do Authenticates users. """ - alias FzHttp.Configurations, as: Conf alias FzHttp.Users - alias FzHttpWeb.Authentication - alias Ueberauth.Auth + alias FzHttpWeb.Auth.HTML.Authentication def find_or_create( - %Auth{ + %Ueberauth.Auth{ provider: :identity, - info: %Auth.Info{email: email}, - credentials: %Auth.Credentials{other: %{password: password}} + info: %Ueberauth.Auth.Info{email: email}, + credentials: %Ueberauth.Auth.Credentials{other: %{password: password}} } = _auth ) do Users.get_by_email(email) |> Authentication.authenticate(password) end # SAML - def find_or_create(:saml, provider_key, %{"email" => email}) do + def find_or_create(:saml, provider_id, %{"email" => email}) do case Users.get_by_email(email) do - nil -> maybe_create_user(:saml_identity_providers, provider_key, email) + nil -> maybe_create_user(:saml_identity_providers, provider_id, email) user -> {:ok, user} end end # OIDC - def find_or_create(provider_key, %{"email" => email, "sub" => _sub}) do + def find_or_create(provider_id, %{"email" => email, "sub" => _sub}) do case Users.get_by_email(email) do - nil -> maybe_create_user(:openid_connect_providers, provider_key, email) + nil -> maybe_create_user(:openid_connect_providers, provider_id, email) user -> {:ok, user} end end - defp maybe_create_user(idp_field, provider_key, email) do - if Conf.auto_create_users?(idp_field, provider_key) do + defp maybe_create_user(idp_field, provider_id, email) do + if FzHttp.Configurations.auto_create_users?(idp_field, provider_id) do Users.create_unprivileged_user(%{email: email}) else - {:error, "not found"} + {:error, "user not found and auto_create_users disabled"} end end end diff --git a/apps/fz_http/lib/fz_http_web/views/device_view.ex b/apps/fz_http/lib/fz_http_web/views/device_view.ex index 87354445f..5cfb48a4c 100644 --- a/apps/fz_http/lib/fz_http_web/views/device_view.ex +++ b/apps/fz_http/lib/fz_http_web/views/device_view.ex @@ -1,13 +1,12 @@ defmodule FzHttpWeb.DeviceView do use FzHttpWeb, :view - alias FzHttp.Configurations, as: Conf - def can_manage_devices?(user) do - has_role?(user, :admin) || Conf.get!(:allow_unprivileged_device_management) + has_role?(user, :admin) || FzHttp.Configurations.get!(:allow_unprivileged_device_management) end def can_configure_devices?(user) do - has_role?(user, :admin) || Conf.get!(:allow_unprivileged_device_configuration) + has_role?(user, :admin) || + FzHttp.Configurations.get!(:allow_unprivileged_device_configuration) end end diff --git a/apps/fz_http/lib/fz_http_web/views/json/changeset_view.ex b/apps/fz_http/lib/fz_http_web/views/json/changeset_view.ex new file mode 100644 index 000000000..bf29f87c6 --- /dev/null +++ b/apps/fz_http/lib/fz_http_web/views/json/changeset_view.ex @@ -0,0 +1,19 @@ +defmodule FzHttpWeb.JSON.ChangesetView do + use FzHttpWeb, :view + + @doc """ + Traverses and translates changeset errors. + + See `Ecto.Changeset.traverse_errors/2` and + `FzHttpWeb.ErrorHelpers.translate_error/1` for more details. + """ + def translate_errors(changeset) do + Ecto.Changeset.traverse_errors(changeset, &translate_error/1) + end + + def render("error.json", %{changeset: changeset}) do + # When encoded, the changeset returns its errors + # as a JSON object. So we just pass it forward. + %{errors: translate_errors(changeset)} + end +end diff --git a/apps/fz_http/lib/fz_http_web/views/json/configuration_view.ex b/apps/fz_http/lib/fz_http_web/views/json/configuration_view.ex new file mode 100644 index 000000000..b24142561 --- /dev/null +++ b/apps/fz_http/lib/fz_http_web/views/json/configuration_view.ex @@ -0,0 +1,36 @@ +defmodule FzHttpWeb.JSON.ConfigurationView do + @moduledoc """ + Handles JSON rendering of Configuration records. + """ + use FzHttpWeb, :view + + def render("index.json", %{configurations: configurations}) do + %{data: render_many(configurations, __MODULE__, "configuration.json")} + end + + def render("show.json", %{configuration: configuration}) do + %{data: render_one(configuration, __MODULE__, "configuration.json")} + end + + @keys_to_render ~w[ + id + logo + local_auth_enabled + allow_unprivileged_device_management + allow_unprivileged_device_configuration + openid_connect_providers + saml_identity_providers + disable_vpn_on_oidc_error + vpn_session_duration + default_client_persistent_keepalive + default_client_mtu + default_client_endpoint + default_client_dns + default_client_allowed_ips + inserted_at + updated_at + ]a + def render("configuration.json", %{configuration: configuration}) do + Map.take(configuration, @keys_to_render) + end +end diff --git a/apps/fz_http/lib/fz_http_web/views/json/device_view.ex b/apps/fz_http/lib/fz_http_web/views/json/device_view.ex new file mode 100644 index 000000000..16a23fe78 --- /dev/null +++ b/apps/fz_http/lib/fz_http_web/views/json/device_view.ex @@ -0,0 +1,56 @@ +defmodule FzHttpWeb.JSON.DeviceView do + @moduledoc """ + Handles JSON rendering of Device records. + """ + use FzHttpWeb, :view + + alias FzHttp.Devices + + def render("index.json", %{devices: devices}) do + %{data: render_many(devices, __MODULE__, "device.json")} + end + + def render("show.json", %{device: device}) do + %{data: render_one(device, __MODULE__, "device.json")} + end + + @keys_to_render ~w[ + id + rx_bytes + tx_bytes + name + description + public_key + preshared_key + use_default_allowed_ips + use_default_dns + use_default_endpoint + use_default_mtu + use_default_persistent_keepalive + endpoint + mtu + persistent_keepalive + allowed_ips + dns + remote_ip + ipv4 + ipv6 + latest_handshake + updated_at + inserted_at + user_id + ]a + def render("device.json", %{device: device}) do + Map.merge( + Map.take(device, @keys_to_render), + %{ + server_public_key: Application.get_env(:fz_vpn, :wireguard_public_key), + endpoint: Devices.config(device, :endpoint), + allowed_ips: Devices.config(device, :allowed_ips), + dns: Devices.config(device, :dns), + persistent_keepalive: Devices.config(device, :persistent_keepalive), + mtu: Devices.config(device, :mtu) + } + ) + end +end diff --git a/apps/fz_http/lib/fz_http_web/views/json/encoders/postgrex_inet.ex b/apps/fz_http/lib/fz_http_web/views/json/encoders/postgrex_inet.ex new file mode 100644 index 000000000..1a2042128 --- /dev/null +++ b/apps/fz_http/lib/fz_http_web/views/json/encoders/postgrex_inet.ex @@ -0,0 +1,5 @@ +defimpl Jason.Encoder, for: Postgrex.INET do + def encode(%Postgrex.INET{} = struct, opts) do + Jason.Encode.string("#{struct}", opts) + end +end diff --git a/apps/fz_http/lib/fz_http_web/views/json/rule_view.ex b/apps/fz_http/lib/fz_http_web/views/json/rule_view.ex new file mode 100644 index 000000000..ad16ce0f4 --- /dev/null +++ b/apps/fz_http/lib/fz_http_web/views/json/rule_view.ex @@ -0,0 +1,28 @@ +defmodule FzHttpWeb.JSON.RuleView do + @moduledoc """ + Handles JSON rendering of Rule records. + """ + use FzHttpWeb, :view + + def render("index.json", %{rules: rules}) do + %{data: render_many(rules, __MODULE__, "rule.json")} + end + + def render("show.json", %{rule: rule}) do + %{data: render_one(rule, __MODULE__, "rule.json")} + end + + @keys_to_render ~w[ + id + destination + action + port_type + port_range + user_id + inserted_at + updated_at + ]a + def render("rule.json", %{rule: rule}) do + Map.take(rule, @keys_to_render) + end +end diff --git a/apps/fz_http/lib/fz_http_web/views/json/user_view.ex b/apps/fz_http/lib/fz_http_web/views/json/user_view.ex new file mode 100644 index 000000000..b52fc1a1d --- /dev/null +++ b/apps/fz_http/lib/fz_http_web/views/json/user_view.ex @@ -0,0 +1,28 @@ +defmodule FzHttpWeb.JSON.UserView do + @moduledoc """ + Handles JSON rendering of User records. + """ + use FzHttpWeb, :view + + def render("index.json", %{users: users}) do + %{data: render_many(users, __MODULE__, "user.json")} + end + + def render("show.json", %{user: user}) do + %{data: render_one(user, __MODULE__, "user.json")} + end + + @keys_to_render ~w[ + id + role + email + last_signed_in_at + last_signed_in_method + disabled_at + inserted_at + updated_at + ]a + def render("user.json", %{user: user}) do + Map.take(user, @keys_to_render) + end +end diff --git a/apps/fz_http/mix.exs b/apps/fz_http/mix.exs index 6f328a144..c8e95d992 100644 --- a/apps/fz_http/mix.exs +++ b/apps/fz_http/mix.exs @@ -55,6 +55,7 @@ defmodule FzHttp.MixProject do defp deps do [ {:fz_common, in_umbrella: true}, + {:bypass, "~> 2.1", only: :test}, {:decimal, "~> 2.0"}, {:cloak, "~> 1.1"}, {:cloak_ecto, "~> 1.2"}, @@ -63,6 +64,7 @@ defmodule FzHttp.MixProject do {:mox, "~> 1.0.1", only: :test}, {:guardian, "~> 2.0"}, {:guardian_db, "~> 2.0"}, + # XXX: All github deps should use ref instead of always updating from master branch {:openid_connect, github: "firezone/openid_connect"}, {:esaml, github: "firezone/esaml", override: true}, {:samly, github: "firezone/samly"}, @@ -73,7 +75,8 @@ defmodule FzHttp.MixProject do {:phoenix_pubsub, "~> 2.0"}, {:phoenix_ecto, "~> 4.4"}, {:ecto_sql, "~> 3.7"}, - {:ecto_network, "~> 1.3"}, + {:ecto_network, + github: "firezone/ecto_network", ref: "7dfe65bcb6506fb0ed6050871b433f3f8b1c10cb"}, {:inflex, "~> 2.1"}, {:plug, "~> 1.13"}, {:postgrex, "~> 0.16"}, @@ -87,7 +90,6 @@ defmodule FzHttp.MixProject do {:phoenix_swoosh, "~> 1.0"}, {:gen_smtp, "~> 1.0"}, {:nimble_totp, "~> 0.2"}, - {:cachex, "~> 3.4"}, # XXX: Change this when hex package is updated {:cidr, github: "firezone/cidr-elixir"}, {:telemetry, "~> 1.0"}, diff --git a/apps/fz_http/priv/repo/migrations/20221129002233_update_on_delete_behavior.exs b/apps/fz_http/priv/repo/migrations/20221129002233_update_on_delete_behavior.exs new file mode 100644 index 000000000..5485e052c --- /dev/null +++ b/apps/fz_http/priv/repo/migrations/20221129002233_update_on_delete_behavior.exs @@ -0,0 +1,33 @@ +defmodule FzHttp.Repo.Migrations.UpdateOnDeleteBehavior do + use Ecto.Migration + + def change do + drop(constraint(:oidc_connections, "oidc_connections_user_id_fkey")) + + alter table(:oidc_connections) do + modify( + :user_id, + references(:users, on_delete: :delete_all), + null: false, + from: { + references(:users, on_delete: :nothing), + null: false + } + ) + end + + drop(constraint(:mfa_methods, "mfa_methods_user_id_fkey")) + + alter table(:mfa_methods) do + modify( + :user_id, + references(:users, on_delete: :delete_all), + null: false, + from: { + references(:users, on_delete: :nothing), + null: false + } + ) + end + end +end diff --git a/apps/fz_http/priv/repo/migrations/20221223190406_migrate_pks_to_uuid.exs b/apps/fz_http/priv/repo/migrations/20221223190406_migrate_pks_to_uuid.exs new file mode 100644 index 000000000..4dca044c0 --- /dev/null +++ b/apps/fz_http/priv/repo/migrations/20221223190406_migrate_pks_to_uuid.exs @@ -0,0 +1,186 @@ +defmodule FzHttp.Repo.Migrations.MigratePksToUuid do + use Ecto.Migration + + def change do + ## connectivity_checks + alter table("connectivity_checks") do + remove(:id) + add(:id, :binary_id, primary_key: true) + end + + ## devices + execute("DROP INDEX devices_uuid_index") + rename(table("devices"), :id, to: :id_tmp) + rename(table("devices"), :uuid, to: :id) + + alter table("devices") do + modify(:id, :binary_id, primary_key: true) + remove(:id_tmp) + end + + ## rules + execute("DROP INDEX rules_uuid_index") + rename(table("rules"), :id, to: :id_tmp) + rename(table("rules"), :uuid, to: :id) + + alter table("rules") do + modify(:id, :binary_id, primary_key: true) + remove(:id_tmp) + end + + ## oidc_connections + alter table("oidc_connections") do + remove(:id) + add(:id, :binary_id, primary_key: true) + end + + ## users + rename(table("users"), :id, to: :id_tmp) + rename(table("users"), :uuid, to: :id) + + ### devices refs to users + rename(table("devices"), :user_id, to: :user_id_tmp) + + execute( + "ALTER TABLE devices RENAME CONSTRAINT devices_user_id_fkey TO devices_user_id_tmp_fkey" + ) + + alter table("devices") do + add(:user_id, references(:users, type: :binary_id, on_delete: :delete_all)) + end + + execute( + "UPDATE devices SET user_id = (SELECT users.id FROM users WHERE users.id_tmp = devices.user_id_tmp)" + ) + + execute("ALTER TABLE devices ALTER COLUMN user_id SET NOT NULL") + + alter table("devices") do + remove(:user_id_tmp) + end + + create(index(:devices, [:user_id])) + create(unique_index(:devices, [:user_id, :name])) + + ### rules refs to users + rename(table("rules"), :user_id, to: :user_id_tmp) + + execute("ALTER TABLE rules RENAME CONSTRAINT rules_user_id_fkey TO rules_user_id_tmp_fkey") + + alter table("rules") do + add(:user_id, references(:users, type: :binary_id, on_delete: :delete_all)) + end + + execute( + "UPDATE rules SET user_id = (SELECT users.id FROM users WHERE users.id_tmp = rules.user_id_tmp)" + ) + + execute(""" + ALTER TABLE rules + DROP CONSTRAINT destination_overlap_excl + """) + + execute(""" + ALTER TABLE rules + ADD CONSTRAINT destination_overlap_excl + EXCLUDE USING gist (destination inet_ops WITH &&, action WITH =) + WHERE (user_id IS NULL AND port_range IS NULL) + """) + + execute(""" + ALTER TABLE rules + DROP CONSTRAINT destination_overlap_excl_port + """) + + execute(""" + ALTER TABLE rules + ADD CONSTRAINT destination_overlap_excl_port + EXCLUDE USING gist (destination inet_ops WITH &&, action WITH =, port_range WITH &&, port_type WITH =) + WHERE (user_id IS NULL AND port_range IS NOT NULL) + """) + + execute(""" + ALTER TABLE rules + DROP CONSTRAINT destination_overlap_excl_usr_rule + """) + + execute(""" + ALTER TABLE rules + ADD CONSTRAINT destination_overlap_excl_usr_rule + EXCLUDE USING gist (destination inet_ops WITH &&, user_id WITH =, action WITH =) + WHERE (user_id IS NOT NULL AND port_range IS NULL) + """) + + execute(""" + ALTER TABLE rules + DROP CONSTRAINT destination_overlap_excl_usr_rule_port + """) + + execute(""" + ALTER TABLE rules + ADD CONSTRAINT destination_overlap_excl_usr_rule_port + EXCLUDE USING gist (destination inet_ops WITH &&, user_id WITH =, action WITH =, port_range WITH &&, port_type WITH =) + WHERE (user_id IS NOT NULL AND port_range IS NOT NULL) + """) + + alter table("rules") do + remove(:user_id_tmp) + end + + ### oidc_connections refs to users + rename(table("oidc_connections"), :user_id, to: :user_id_tmp) + + execute( + "ALTER TABLE oidc_connections RENAME CONSTRAINT oidc_connections_user_id_fkey TO oidc_connections_user_id_tmp_fkey" + ) + + alter table("oidc_connections") do + add(:user_id, references(:users, type: :binary_id, on_delete: :delete_all)) + end + + execute( + "UPDATE oidc_connections SET user_id = (SELECT users.id FROM users WHERE users.id_tmp = oidc_connections.user_id_tmp)" + ) + + execute("ALTER TABLE oidc_connections ALTER COLUMN user_id SET NOT NULL") + + alter table("oidc_connections") do + remove(:user_id_tmp) + end + + create(unique_index(:oidc_connections, [:user_id, :provider])) + + ### mfa_methods refs to users + rename(table("mfa_methods"), :user_id, to: :user_id_tmp) + + execute( + "ALTER TABLE mfa_methods RENAME CONSTRAINT mfa_methods_user_id_fkey TO mfa_methods_user_id_tmp_fkey" + ) + + alter table("mfa_methods") do + add(:user_id, references(:users, type: :binary_id, on_delete: :delete_all)) + end + + execute( + "UPDATE mfa_methods SET user_id = (SELECT users.id FROM users WHERE users.id_tmp = mfa_methods.user_id_tmp)" + ) + + execute("ALTER TABLE mfa_methods ALTER COLUMN user_id SET NOT NULL") + + alter table("mfa_methods") do + remove(:user_id_tmp) + end + + create(index(:mfa_methods, [:user_id])) + + alter table("users") do + remove(:id_tmp) + end + + execute("ALTER INDEX users_uuid_index RENAME TO users_pkey") + + alter table("users") do + modify(:id, :binary_id, primary_key: true) + end + end +end diff --git a/apps/fz_http/priv/repo/migrations/20221223223357_migrate_datetimes_to_timestamptz.exs b/apps/fz_http/priv/repo/migrations/20221223223357_migrate_datetimes_to_timestamptz.exs new file mode 100644 index 000000000..1809f7fc3 --- /dev/null +++ b/apps/fz_http/priv/repo/migrations/20221223223357_migrate_datetimes_to_timestamptz.exs @@ -0,0 +1,47 @@ +defmodule FzHttp.Repo.Migrations.MigrateDatetimesToTimestamptz do + use Ecto.Migration + + def change do + alter table("configurations") do + modify(:inserted_at, :timestamptz, from: :utc_datetime_usec) + modify(:updated_at, :timestamptz, from: :utc_datetime_usec) + end + + alter table("sites") do + modify(:inserted_at, :timestamptz, from: :utc_datetime_usec) + modify(:updated_at, :timestamptz, from: :utc_datetime_usec) + end + + alter table("mfa_methods") do + modify(:inserted_at, :timestamptz, from: :utc_datetime_usec) + modify(:updated_at, :timestamptz, from: :utc_datetime_usec) + modify(:last_used_at, :timestamptz, from: :utc_datetime_usec) + end + + alter table("devices") do + modify(:inserted_at, :timestamptz, from: :utc_datetime_usec) + modify(:updated_at, :timestamptz, from: :utc_datetime_usec) + modify(:latest_handshake, :timestamptz, from: :utc_datetime_usec) + modify(:key_regenerated_at, :timestamptz, from: :utc_datetime_usec) + end + + alter table("oidc_connections") do + modify(:inserted_at, :timestamptz, from: :utc_datetime_usec) + modify(:updated_at, :timestamptz, from: :utc_datetime_usec) + modify(:refreshed_at, :timestamptz, from: :utc_datetime_usec) + end + + alter table("connectivity_checks") do + modify(:inserted_at, :timestamptz, from: :utc_datetime_usec) + remove(:updated_at, :timestamptz, null: false) + end + + alter table("users") do + modify(:inserted_at, :timestamptz, from: :utc_datetime_usec) + modify(:updated_at, :timestamptz, from: :utc_datetime_usec) + modify(:last_signed_in_at, :timestamptz, from: :utc_datetime_usec) + modify(:sign_in_token_created_at, :timestamptz, from: :utc_datetime_usec) + modify(:disabled_at, :timestamptz, from: :utc_datetime_usec) + end + end +end diff --git a/apps/fz_http/priv/repo/migrations/20221223223931_order_connectivity_checks_inserted_at_index.exs b/apps/fz_http/priv/repo/migrations/20221223223931_order_connectivity_checks_inserted_at_index.exs new file mode 100644 index 000000000..4d15bc9b6 --- /dev/null +++ b/apps/fz_http/priv/repo/migrations/20221223223931_order_connectivity_checks_inserted_at_index.exs @@ -0,0 +1,11 @@ +defmodule FzHttp.Repo.Migrations.OrderConnectivityChecksInsertedAtIndex do + use Ecto.Migration + + def change do + drop(index(:connectivity_checks, :inserted_at)) + + execute( + "CREATE INDEX connectivity_checks_inserted_at_index ON connectivity_checks (inserted_at DESC)" + ) + end +end diff --git a/apps/fz_http/priv/repo/migrations/20221224125052_users_fk_delete_all_constraint.exs b/apps/fz_http/priv/repo/migrations/20221224125052_users_fk_delete_all_constraint.exs deleted file mode 100644 index ca0cc981c..000000000 --- a/apps/fz_http/priv/repo/migrations/20221224125052_users_fk_delete_all_constraint.exs +++ /dev/null @@ -1,17 +0,0 @@ -defmodule FzHttp.Repo.Migrations.UsersFkDeleteAllConstraint do - use Ecto.Migration - - def change do - drop(constraint(:oidc_connections, "oidc_connections_user_id_fkey")) - - alter table(:oidc_connections) do - modify(:user_id, references(:users, on_delete: :delete_all), null: false) - end - - drop(constraint(:mfa_methods, "mfa_methods_user_id_fkey")) - - alter table(:mfa_methods) do - modify(:user_id, references(:users, on_delete: :delete_all), null: false) - end - end -end diff --git a/apps/fz_http/priv/repo/migrations/20221226044850_create_api_tokens.exs b/apps/fz_http/priv/repo/migrations/20221226044850_create_api_tokens.exs new file mode 100644 index 000000000..eafd4c5ed --- /dev/null +++ b/apps/fz_http/priv/repo/migrations/20221226044850_create_api_tokens.exs @@ -0,0 +1,17 @@ +defmodule FzHttp.Repo.Migrations.CreateApiTokens do + use Ecto.Migration + + def change do + create table(:api_tokens, primary_key: false) do + add(:id, :binary_id, primary_key: true) + add(:expires_at, :timestamptz) + + add(:user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false) + + timestamps(updated_at: false) + end + + create(index(:api_tokens, [:expires_at])) + create(index(:api_tokens, [:user_id])) + end +end diff --git a/apps/fz_http/priv/repo/migrations/20221226143651_move_sites_fields_to_configurations.exs b/apps/fz_http/priv/repo/migrations/20221226143651_move_sites_fields_to_configurations.exs new file mode 100644 index 000000000..e87743856 --- /dev/null +++ b/apps/fz_http/priv/repo/migrations/20221226143651_move_sites_fields_to_configurations.exs @@ -0,0 +1,91 @@ +defmodule FzHttp.Repo.Migrations.MoveSitesFieldsToConfigurations do + use Ecto.Migration + + @doc """ + XXX: The following env vars are used to configure interface settings + on bootup and so we don't want to store them in the DB or update them + at runtime. Leave them out of this migration. + + WIREGUARD_IPV4_ENABLED + WIREGUARD_IPV6_ENABLED + WIREGUARD_IPV4_NETWORK + WIREGUARD_IPV6_NETWORK + WIREGUARD_IPV4_ADDRESS + WIREGUARD_IPV6_ADDRESS + WIREGUARD_MTU + """ + def change do + alter table(:configurations) do + add(:default_client_persistent_keepalive, :integer) + add(:default_client_endpoint, :string) + add(:default_client_dns, :string) + add(:default_client_allowed_ips, :string) + + # XXX: Note this is different than the WIREGUARD_MTU env var which + # configures the server interface MTU. + add(:default_client_mtu, :integer) + + add(:vpn_session_duration, :integer, null: false, default: 0) + end + + # persistent_keepalive + execute(""" + UPDATE configurations + SET default_client_persistent_keepalive = ( + SELECT persistent_keepalive + FROM sites + WHERE sites.name = 'default' + ) + """) + + # endpoint + execute(""" + UPDATE configurations + SET default_client_endpoint = ( + SELECT endpoint + FROM sites + WHERE sites.name = 'default' + ) + """) + + # dns + execute(""" + UPDATE configurations + SET default_client_dns = ( + SELECT dns + FROM sites + WHERE sites.name = 'default' + ) + """) + + # allowed_ips + execute(""" + UPDATE configurations + SET default_client_allowed_ips = ( + SELECT allowed_ips + FROM sites + WHERE sites.name = 'default' + ) + """) + + # mtu + execute(""" + UPDATE configurations + SET default_client_mtu = ( + SELECT mtu + FROM sites + WHERE sites.name = 'default' + ) + """) + + # vpn_session_duration + execute(""" + UPDATE configurations + SET vpn_session_duration = ( + SELECT vpn_session_duration + FROM sites + WHERE sites.name = 'default' + ) + """) + end +end diff --git a/apps/fz_http/priv/repo/migrations/20221226171558_rename_use_site_to_use_default.exs b/apps/fz_http/priv/repo/migrations/20221226171558_rename_use_site_to_use_default.exs new file mode 100644 index 000000000..83a6caa29 --- /dev/null +++ b/apps/fz_http/priv/repo/migrations/20221226171558_rename_use_site_to_use_default.exs @@ -0,0 +1,11 @@ +defmodule FzHttp.Repo.Migrations.RenameUseSiteToUseDefault do + use Ecto.Migration + + def change do + rename(table(:devices), :use_site_allowed_ips, to: :use_default_allowed_ips) + rename(table(:devices), :use_site_dns, to: :use_default_dns) + rename(table(:devices), :use_site_endpoint, to: :use_default_endpoint) + rename(table(:devices), :use_site_mtu, to: :use_default_mtu) + rename(table(:devices), :use_site_persistent_keepalive, to: :use_default_persistent_keepalive) + end +end diff --git a/apps/fz_http/priv/repo/migrations/20221226193228_drop_sites.exs b/apps/fz_http/priv/repo/migrations/20221226193228_drop_sites.exs new file mode 100644 index 000000000..1ed36c3f7 --- /dev/null +++ b/apps/fz_http/priv/repo/migrations/20221226193228_drop_sites.exs @@ -0,0 +1,7 @@ +defmodule FzHttp.Repo.Migrations.DropSites do + use Ecto.Migration + + def change do + drop(table(:sites)) + end +end diff --git a/apps/fz_http/priv/repo/migrations/20221227181727_move_cache_fallbacks_to_configurations.exs b/apps/fz_http/priv/repo/migrations/20221227181727_move_cache_fallbacks_to_configurations.exs new file mode 100644 index 000000000..21269f861 --- /dev/null +++ b/apps/fz_http/priv/repo/migrations/20221227181727_move_cache_fallbacks_to_configurations.exs @@ -0,0 +1,56 @@ +defmodule FzHttp.Repo.Migrations.MoveCacheFallbacksToConfigurations do + use Ecto.Migration + + alias FzCommon.FzString + + def change do + local_auth_enabled = FzString.to_boolean(System.get_env("LOCAL_AUTH_ENABLED", "true")) + + disable_vpn_on_oidc_error = + FzString.to_boolean(System.get_env("DISABLE_VPN_ON_OIDC_ERROR", "false")) + + allow_unprivileged_device_management = + FzString.to_boolean(System.get_env("ALLOW_UNPRIVILEGED_DEVICE_MANAGEMENT", "true")) + + allow_unprivileged_device_configuration = + FzString.to_boolean(System.get_env("ALLOW_UNPRIVILEGED_DEVICE_CONFIGURATION", "true")) + + execute(""" + UPDATE configurations + SET local_auth_enabled = '#{local_auth_enabled}' WHERE configurations.local_auth_enabled IS NULL + """) + + execute(""" + UPDATE configurations + SET disable_vpn_on_oidc_error = '#{disable_vpn_on_oidc_error}' WHERE configurations.disable_vpn_on_oidc_error IS NULL + """) + + execute(""" + UPDATE configurations + SET allow_unprivileged_device_management = '#{allow_unprivileged_device_management}' WHERE configurations.allow_unprivileged_device_management IS NULL + """) + + execute(""" + UPDATE configurations + SET allow_unprivileged_device_configuration = '#{allow_unprivileged_device_configuration}' WHERE configurations.allow_unprivileged_device_configuration IS NULL + """) + + # Set defaults for providers + execute(""" + UPDATE configurations + SET openid_connect_providers = '{}'::json + WHERE openid_connect_providers IS NULL + """) + + execute(""" + UPDATE configurations + SET saml_identity_providers = '{}'::json + WHERE saml_identity_providers IS NULL + """) + + alter table(:configurations) do + modify(:openid_connect_providers, :map, default: %{}, null: false) + modify(:saml_identity_providers, :map, default: %{}, null: false) + end + end +end diff --git a/apps/fz_http/priv/repo/migrations/20221229154115_migrate_providers_configs.exs b/apps/fz_http/priv/repo/migrations/20221229154115_migrate_providers_configs.exs new file mode 100644 index 000000000..ec243825f --- /dev/null +++ b/apps/fz_http/priv/repo/migrations/20221229154115_migrate_providers_configs.exs @@ -0,0 +1,11 @@ +defmodule FzHttp.Repo.Migrations.MigrateProvidersConfigs do + use Ecto.Migration + + def change do + execute(""" + UPDATE configurations + SET openid_connect_providers = COALESCE((select jsonb_agg(o.item::jsonb || jsonb_build_object('id', key)) from jsonb_each(openid_connect_providers) as o(key, item)), '[]'::jsonb), + saml_identity_providers = COALESCE((select jsonb_agg(s.item::jsonb || jsonb_build_object('id', key)) from jsonb_each(saml_identity_providers) as s(key, item)), '[]'::jsonb) + """) + end +end diff --git a/apps/fz_http/priv/repo/seeds.exs b/apps/fz_http/priv/repo/seeds.exs index eb37f1304..bc97c9915 100644 --- a/apps/fz_http/priv/repo/seeds.exs +++ b/apps/fz_http/priv/repo/seeds.exs @@ -10,7 +10,11 @@ # We recommend using the bang functions (`insert!`, `update!` # and so on) as they will fail if something goes wrong. -alias FzHttp.{Devices, ConnectivityChecks, Users} +alias FzHttp.{ + ConnectivityChecks, + Devices, + Users +} {:ok, user} = Users.create_admin_user(%{ @@ -28,18 +32,16 @@ alias FzHttp.{Devices, ConnectivityChecks, Users} """, preshared_key: "C+Tte1echarIObr6rq+nFeYQ1QO5xo5N29ygDjMlpS8=", public_key: "pSLWbPiQ2mKh26IG1dMFQQWuAstFJXV91dNk+olzEjA=", - ipv4: "10.3.2.6", - ipv6: "fd00::3:2:6", mtu: 1280, persistent_keepalive: 25, allowed_ips: "0.0.0.0,::/0", endpoint: "elixir", dns: "127.0.0.11", - use_site_allowed_ips: false, - use_site_dns: false, - use_site_endpoint: false, - use_site_mtu: false, - use_site_persistent_keepalive: false + use_default_allowed_ips: false, + use_default_dns: false, + use_default_endpoint: false, + use_default_mtu: false, + use_default_persistent_keepalive: false }) {:ok, _device} = diff --git a/apps/fz_http/test/fz_http/api_tokens_test.exs b/apps/fz_http/test/fz_http/api_tokens_test.exs new file mode 100644 index 000000000..a76670980 --- /dev/null +++ b/apps/fz_http/test/fz_http/api_tokens_test.exs @@ -0,0 +1,50 @@ +defmodule FzHttp.ApiTokensTest do + use FzHttp.DataCase + + alias FzHttp.ApiTokens + + describe "api_tokens" do + alias FzHttp.ApiTokens.ApiToken + + import FzHttp.ApiTokensFixtures + import FzHttp.UsersFixtures + + @invalid_params %{"expires_in" => 0} + + test "list_api_tokens/0 returns all api_tokens" do + api_token = api_token() + assert ApiTokens.list_api_tokens() == [api_token] + end + + test "list_api_tokens/1 returns api_tokens scoped to a user" do + api_token1 = api_token() + api_token2 = api_token() + assert [api_token1] == ApiTokens.list_api_tokens(api_token1.user_id) + assert [api_token2] == ApiTokens.list_api_tokens(api_token2.user_id) + end + + test "get_api_token!/1 returns the api_token with given id" do + api_token = api_token() + assert ApiTokens.get_api_token!(api_token.id) == api_token + end + + test "create_user_api_token/2 with valid data creates a api_token" do + valid_params = %{ + "expires_in" => 1 + } + + assert {:ok, %ApiToken{} = api_token} = + ApiTokens.create_user_api_token(user(), valid_params) + + # Within 10 seconds + assert_in_delta DateTime.to_unix(api_token.expires_at), + DateTime.to_unix(DateTime.add(DateTime.utc_now(), 1, :day)), + 10 + end + + test "create_user_api_token/2 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = + ApiTokens.create_user_api_token(user(), @invalid_params) + end + end +end diff --git a/apps/fz_http/test/fz_http/configurations_test.exs b/apps/fz_http/test/fz_http/configurations_test.exs new file mode 100644 index 000000000..448a02e69 --- /dev/null +++ b/apps/fz_http/test/fz_http/configurations_test.exs @@ -0,0 +1,110 @@ +defmodule FzHttp.ConfigurationsTest do + use FzHttp.DataCase + + alias FzHttp.Configurations.Configuration + alias FzHttp.Configurations + + describe "trimmed fields" do + test "trims expected fields" do + changeset = + Configurations.new_configuration(%{ + "default_client_allowed_ips" => " foo ", + "default_client_dns" => " foo ", + "default_client_endpoint" => " foo " + }) + + assert %Ecto.Changeset{ + changes: %{ + default_client_allowed_ips: "foo", + default_client_dns: "foo", + default_client_endpoint: "foo" + } + } = changeset + end + end + + describe "update_configuration/2 with name-based default_client_dns" do + import FzHttp.ConfigurationsFixtures + + setup do + {:ok, configuration: configuration(%{})} + end + + @tag attrs: %{default_client_dns: "foobar.com"} + test "update_configuration/2 allows hosts for DNS", %{ + configuration: configuration, + attrs: attrs + } do + assert {:ok, _configuration} = Configurations.update_configuration(configuration, attrs) + end + + @tag attrs: %{default_client_dns: "foobar.com, google.com"} + test "update_configuration/2 allows list hosts for DNS", %{ + configuration: configuration, + attrs: attrs + } do + assert {:ok, _configuration} = Configurations.update_configuration(configuration, attrs) + end + end + + describe "configurations" do + import FzHttp.ConfigurationsFixtures + + @valid_configurations [ + %{ + "default_client_dns" => "8.8.8.8", + "default_client_allowed_ips" => "::/0", + "default_client_endpoint" => "172.10.10.10", + "default_client_persistent_keepalive" => "20", + "default_client_mtu" => "1280" + }, + %{ + "default_client_dns" => "8.8.8.8", + "default_client_allowed_ips" => "::/0", + "default_client_endpoint" => "foobar.example.com", + "default_client_persistent_keepalive" => "15", + "default_client_mtu" => "1280" + } + ] + @invalid_configuration %{ + "default_client_dns" => "foobar", + "default_client_allowed_ips" => "foobar", + "default_client_endpoint" => "foobar", + "default_client_persistent_keepalive" => "-120", + "default_client_mtu" => "1501" + } + + test "get_configuration/1 returns the configuration" do + configuration = configuration(%{}) + assert Configurations.get_configuration!() == configuration + end + + test "update_configuration/2 with valid data updates the configuration via provided configuration" do + configuration = Configurations.get_configuration!() + + for attrs <- @valid_configurations do + assert {:ok, %Configuration{}} = Configurations.update_configuration(configuration, attrs) + end + end + + test "update_configuration/2 with invalid data returns error changeset" do + configuration = Configurations.get_configuration!() + + assert {:error, %Ecto.Changeset{}} = + Configurations.update_configuration(configuration, @invalid_configuration) + + configuration = Configurations.get_configuration!() + + refute configuration.default_client_dns == "foobar" + refute configuration.default_client_allowed_ips == "foobar" + refute configuration.default_client_endpoint == "foobar" + refute configuration.default_client_persistent_keepalive == -120 + refute configuration.default_client_mtu == 1501 + end + + test "change_configuration/1 returns a configuration changeset" do + configuration = configuration(%{}) + assert %Ecto.Changeset{} = Configurations.change_configuration(configuration) + end + end +end diff --git a/apps/fz_http/test/fz_http/connectivity_check_service_test.exs b/apps/fz_http/test/fz_http/connectivity_check_service_test.exs index 7f716d783..ccf5bf384 100644 --- a/apps/fz_http/test/fz_http/connectivity_check_service_test.exs +++ b/apps/fz_http/test/fz_http/connectivity_check_service_test.exs @@ -2,9 +2,10 @@ defmodule FzHttp.ConnectivityCheckServiceTest do @moduledoc """ Tests the ConnectivityCheckService module. """ + use FzHttp.DataCase, async: true + alias Ecto.Adapters.SQL.Sandbox alias FzHttp.{ConnectivityChecks, ConnectivityCheckService, Repo} - use FzHttp.DataCase, async: true describe "post_request/0 valid url" do @expected_check %{ diff --git a/apps/fz_http/test/fz_http/devices/device/query_test.exs b/apps/fz_http/test/fz_http/devices/device/query_test.exs new file mode 100644 index 000000000..512ef6cd9 --- /dev/null +++ b/apps/fz_http/test/fz_http/devices/device/query_test.exs @@ -0,0 +1,131 @@ +defmodule FzHttp.Devices.Device.QueryTest do + use FzHttp.DataCase, async: true + import FzHttp.Devices.Device.Query + alias FzHttp.DevicesFixtures + + describe "next_available_address/3" do + test "selects available IPv4 in CIDR range at the offset" do + cidr = string_to_inet("10.3.2.0/29") + gateway_ip = string_to_inet("10.3.2.0") + offset = 3 + + queryable = next_available_address(cidr, offset, [gateway_ip]) + + assert Repo.one(queryable) == %Postgrex.INET{address: {10, 3, 2, 3}} + end + + test "skips addresses taken by the gateway" do + cidr = string_to_inet("10.3.3.0/29") + gateway_ip = string_to_inet("10.3.3.3") + offset = 3 + + queryable = next_available_address(cidr, offset, [gateway_ip]) + + assert Repo.one(queryable) == %Postgrex.INET{address: {10, 3, 3, 4}} + end + + test "forward scans available address after offset it it's assigned to a device" do + cidr = string_to_inet("10.3.4.0/29") + gateway_ip = string_to_inet("10.3.4.0") + offset = 3 + + queryable = next_available_address(cidr, offset, [gateway_ip]) + + DevicesFixtures.device(%{ipv4: "10.3.4.3"}) + DevicesFixtures.device(%{ipv4: "10.3.4.4"}) + assert Repo.one(queryable) == %Postgrex.INET{address: {10, 3, 4, 5}} + + DevicesFixtures.device(%{ipv4: "10.3.4.5"}) + assert Repo.one(queryable) == %Postgrex.INET{address: {10, 3, 4, 6}} + end + + test "backward scans available address if forward scan found not available IPs" do + cidr = string_to_inet("10.3.5.0/29") + gateway_ip = string_to_inet("10.3.5.0") + offset = 5 + + queryable = next_available_address(cidr, offset, [gateway_ip]) + + DevicesFixtures.device(%{ipv4: "10.3.5.5"}) + DevicesFixtures.device(%{ipv4: "10.3.5.6"}) + # Notice: end of range is 10.3.5.7 + # but it's a broadcast address that we don't allow to assign + assert Repo.one(queryable) == %Postgrex.INET{address: {10, 3, 5, 4}} + + DevicesFixtures.device(%{ipv4: "10.3.5.4"}) + assert Repo.one(queryable) == %Postgrex.INET{address: {10, 3, 5, 3}} + end + + test "selects nothing when CIDR range is exhausted" do + cidr = string_to_inet("10.3.6.0/30") + gateway_ip = string_to_inet("10.3.6.1") + offset = 1 + + DevicesFixtures.device(%{ipv4: "10.3.6.2"}) + queryable = next_available_address(cidr, offset, [gateway_ip]) + assert is_nil(Repo.one(queryable)) + + DevicesFixtures.device(%{ipv4: "10.3.6.1"}) + queryable = next_available_address(cidr, offset, []) + assert is_nil(Repo.one(queryable)) + + # Notice: real start of range is 10.3.6.0, + # but it's a typical gateway address that we don't allow to assign + end + + test "prevents two concurrent transactions from acquiring the same address" do + cidr = string_to_inet("10.3.7.0/29") + gateway_ip = string_to_inet("10.3.7.3") + offset = 3 + + queryable = next_available_address(cidr, offset, [gateway_ip]) + + test_pid = self() + + spawn(fn -> + Ecto.Adapters.SQL.Sandbox.unboxed_run(Repo, fn -> + Repo.transaction(fn -> + ip = Repo.one(queryable) + send(test_pid, {:ip, ip}) + Process.sleep(200) + end) + end) + end) + + ip1 = Repo.one(queryable) + assert_receive {:ip, ip2}, 1_000 + + assert Enum.sort([ip1, ip2]) == + Enum.sort([ + %Postgrex.INET{address: {10, 3, 7, 4}}, + %Postgrex.INET{address: {10, 3, 7, 5}} + ]) + end + + test "selects available IPv6 in CIDR range at the offset" do + cidr = string_to_inet("fd00::3:3:0/120") + gateway_ip = string_to_inet("fd00::3:3:3") + offset = 3 + + queryable = next_available_address(cidr, offset, [gateway_ip]) + + assert Repo.one(queryable) == %Postgrex.INET{address: {64_768, 0, 0, 0, 0, 3, 3, 4}} + end + + test "selects nothing when IPv6 CIDR range is exhausted" do + cidr = string_to_inet("fd00::3:2:0/126") + gateway_ip = string_to_inet("fd00::3:2:1") + offset = 3 + + DevicesFixtures.device(%{ipv6: "fd00::3:2:2"}) + + queryable = next_available_address(cidr, offset, [gateway_ip]) + assert is_nil(Repo.one(queryable)) + end + end + + defp string_to_inet(string) do + {:ok, inet} = EctoNetwork.INET.cast(string) + inet + end +end diff --git a/apps/fz_http/test/fz_http/devices_test.exs b/apps/fz_http/test/fz_http/devices_test.exs index 14dc773cd..1e1ffdebd 100644 --- a/apps/fz_http/test/fz_http/devices_test.exs +++ b/apps/fz_http/test/fz_http/devices_test.exs @@ -1,33 +1,10 @@ defmodule FzHttp.DevicesTest do - # XXX: Update the device IP query to be an insert - use FzHttp.DataCase, async: false + use FzHttp.DataCase, async: true + alias FzHttp.Devices alias FzHttp.DevicesFixtures alias FzHttp.Users - describe "trimmed fields" do - test "trims expected fields" do - changeset = - Devices.new_device(%{ - "allowed_ips" => " foo ", - "dns" => " foo ", - "endpoint" => " foo ", - "name" => " foo ", - "description" => " foo " - }) - - assert %Ecto.Changeset{ - changes: %{ - allowed_ips: "foo", - dns: "foo", - endpoint: "foo", - name: "foo", - description: "foo" - } - } = changeset - end - end - describe "count/0" do setup :create_devices @@ -64,38 +41,17 @@ defmodule FzHttp.DevicesTest do describe "create_device/1" do setup [:create_user, :create_device] - setup context do - if ipv4_network = context[:ipv4_network] do - restore_env(:wireguard_ipv4_network, ipv4_network, &on_exit/1) - else - context - end - end - - setup context do - if ipv6_network = context[:ipv6_network] do - restore_env(:wireguard_ipv6_network, ipv6_network, &on_exit/1) - else - context - end - end - - setup context do - if max_devices = context[:max_devices] do - restore_env(:max_devices_per_user, max_devices, &on_exit/1) - else - context - end - end - @device_attrs %{ name: "dummy", - public_key: "dummy", - user_id: nil + public_key: "CHqFuS+iL3FTog5F4Ceumqlk0CU4Cl/dyUP/9F9NDnI=", + user_id: nil, + ipv4: "100.64.0.2", + ipv6: "fd00::2" } - @tag max_devices: 1 test "prevents creating more than max_devices_per_user", %{device: device} do + FzHttp.Config.maybe_put_env_override(:max_devices_per_user, 1) + assert {:error, %Ecto.Changeset{ errors: [ @@ -113,49 +69,28 @@ defmodule FzHttp.DevicesTest do end test "creates devices with default ipv4", %{device: device} do - assert device.ipv4 == %Postgrex.INET{address: {10, 3, 2, 2}, netmask: 32} + refute is_nil(device.ipv4) end test "creates device with default ipv6", %{device: device} do - assert device.ipv6 == %Postgrex.INET{address: {64_768, 0, 0, 0, 0, 3, 2, 2}, netmask: 128} + refute is_nil(device.ipv6) end - test "generates preshared_key" do - assert String.length(Devices.new_device().changes.preshared_key) == 44 - end - - @tag ipv4_network: "10.3.2.0/30", - errors: [ - ipv4: {"can't be blank", [validation: :required]}, - base: - {"ipv4 address pool is exhausted. Increase network size or remove some devices.", []} - ] - test "sets error when ipv4 address pool is exhausted", %{ - ipv4_network: ipv4_network, - user: user, - errors: errors + test "returns error when device IP can't be assigned due to CIDR pool exhaustion", %{ + device: device } do - restore_env(:wireguard_ipv4_network, ipv4_network, &on_exit/1) + FzHttp.Config.maybe_put_env_override(:wireguard_ipv4_network, "10.3.2.0/30") + attrs = %{@device_attrs | ipv4: nil, ipv6: nil, user_id: device.user_id} - {:error, changeset} = Devices.create_device(%{@device_attrs | user_id: user.id}) - assert errors == changeset.errors + assert {:ok, _device} = Devices.create_device(attrs) + assert {:error, changeset} = Devices.create_device(attrs) + refute changeset.valid? + assert "CIDR 10.3.2.0/30 is exhausted" in errors_on(changeset).base end - @tag ipv6_network: "fd00::3:2:0/126", - errors: [ - ipv6: {"can't be blank", [validation: :required]}, - base: - {"ipv6 address pool is exhausted. Increase network size or remove some devices.", []} - ] - test "sets error when ipv6 address pool is exhausted", %{ - ipv6_network: ipv6_network, - user: user, - errors: errors - } do - restore_env(:wireguard_ipv6_network, ipv6_network, &on_exit/1) - - {:error, changeset} = Devices.create_device(%{@device_attrs | user_id: user.id}) - assert errors == changeset.errors + test "autogenerates preshared_key", %{user: user} do + assert {:ok, device} = Devices.create_device(%{@device_attrs | user_id: user.id}) + assert byte_size(device.preshared_key) == 44 end end @@ -187,11 +122,11 @@ defmodule FzHttp.DevicesTest do @attrs %{ name: "Go hard or go home.", allowed_ips: "0.0.0.0", - use_site_allowed_ips: false + use_default_allowed_ips: false } @valid_dns_attrs %{ - use_site_dns: false, + use_default_dns: false, dns: "1.1.1.1, 1.0.0.1, 2606:4700:4700::1111, 2606:4700:4700::1001" } @@ -200,27 +135,27 @@ defmodule FzHttp.DevicesTest do } @valid_allowed_ips_attrs %{ - use_site_allowed_ips: false, + use_default_allowed_ips: false, allowed_ips: "0.0.0.0/0, ::/0, ::0/0, 192.168.1.0/24" } @valid_endpoint_ipv4_attrs %{ - use_site_endpoint: false, + use_default_endpoint: false, endpoint: "5.5.5.5" } @valid_endpoint_ipv6_attrs %{ - use_site_endpoint: false, + use_default_endpoint: false, endpoint: "fd00::1" } @valid_endpoint_host_attrs %{ - use_site_endpoint: false, + use_default_endpoint: false, endpoint: "valid-endpoint.example.com" } @empty_endpoint_attrs %{ - use_site_endpoint: false, + use_default_endpoint: false, endpoint: "" } @@ -228,46 +163,14 @@ defmodule FzHttp.DevicesTest do allowed_ips: "1.1.1.1, 11, foobar" } - @fields_use_site [ - %{use_site_allowed_ips: true, allowed_ips: "1.1.1.1"}, - %{use_site_dns: true, dns: "1.1.1.1"}, - %{use_site_endpoint: true, endpoint: "1.1.1.1"}, - %{use_site_persistent_keepalive: true, persistent_keepalive: 1}, - %{use_site_mtu: true, mtu: 1000} + @fields_use_default [ + %{use_default_allowed_ips: true, allowed_ips: "1.1.1.1"}, + %{use_default_dns: true, dns: "1.1.1.1"}, + %{use_default_endpoint: true, endpoint: "1.1.1.1"}, + %{use_default_persistent_keepalive: true, persistent_keepalive: 1}, + %{use_default_mtu: true, mtu: 1000} ] - test "updates device with /32 netmask", %{device: device} do - ipv4 = "10.3.2.9/32" - {:ok, test_device} = Devices.update_device(device, %{ipv4: ipv4}) - assert "#{test_device.ipv4}" == "10.3.2.9" - end - - test "updates device with /128 netmask", %{device: device} do - ipv6 = "fd00::3:2:9/128" - {:ok, test_device} = Devices.update_device(device, %{ipv6: ipv6}) - assert "#{test_device.ipv6}" == "fd00::3:2:9" - end - - test "prevents updating device with ipv4 netmask", %{device: device} do - attrs = %{ipv4: "10.3.2.9/24"} - {:error, changeset} = Devices.update_device(device, attrs) - - assert changeset.errors[:ipv4] == { - "Only IPs without netmask are supported.", - [] - } - end - - test "prevents updating device with ipv6 netmask", %{device: device} do - attrs = %{ipv6: "fd00::3:2:9/120"} - {:error, changeset} = Devices.update_device(device, attrs) - - assert changeset.errors[:ipv6] == { - "Only IPs without netmask are supported.", - [] - } - end - test "updates device", %{device: device} do {:ok, test_device} = Devices.update_device(device, @attrs) assert @attrs = test_device @@ -293,11 +196,11 @@ defmodule FzHttp.DevicesTest do assert @valid_endpoint_host_attrs = test_device end - test "prevents updating fields if use_site_", %{device: device} do - for attrs <- @fields_use_site do + test "prevents updating fields if use_default_", %{device: device} do + for attrs <- @fields_use_default do field = Map.keys(attrs) - |> Enum.filter(fn attr -> !String.starts_with?(Atom.to_string(attr), "use_site_") end) + |> Enum.filter(fn attr -> !String.starts_with?(Atom.to_string(attr), "use_default_") end) |> List.first() assert {:error, changeset} = Devices.update_device(device, attrs) @@ -309,7 +212,7 @@ defmodule FzHttp.DevicesTest do end end - @tag attrs: %{use_site_dns: false, dns: "foobar.com"} + @tag attrs: %{use_default_dns: false, dns: "foobar.com"} test "allows hosts for DNS", %{attrs: attrs, device: device} do assert {:ok, _device} = Devices.update_device(device, attrs) end @@ -345,52 +248,6 @@ defmodule FzHttp.DevicesTest do [] } end - - test "prevents updating ipv4 to out of network", %{device: device} do - {:error, changeset} = Devices.update_device(device, %{ipv4: "172.16.0.1"}) - - assert changeset.errors[:ipv4] == { - "IP must be contained within network 10.3.2.0/24", - [] - } - end - - test "prevents updating ipv6 to out of network", %{device: device} do - {:error, changeset} = Devices.update_device(device, %{ipv6: "fd00::2:1:1"}) - - assert changeset.errors[:ipv6] == { - "IP must be contained within network fd00::3:2:0/120", - [] - } - end - - test "prevents updating ipv4 to wireguard address", %{device: device} do - ip = Application.fetch_env!(:fz_http, :wireguard_ipv4_address) - {:error, changeset} = Devices.update_device(device, %{ipv4: ip}) - - assert changeset.errors[:ipv4] == { - "is reserved", - [ - {:validation, :exclusion}, - {:enum, [%Postgrex.INET{address: {10, 3, 2, 1}, netmask: 32}]} - ] - } - end - - test "prevents updating ipv6 to wireguard address", %{device: device} do - {:error, changeset} = - Devices.update_device(device, %{ - ipv6: Application.fetch_env!(:fz_http, :wireguard_ipv6_address) - }) - - assert changeset.errors[:ipv6] == { - "is reserved", - [ - {:validation, :exclusion}, - {:enum, [%Postgrex.INET{address: {64_768, 0, 0, 0, 0, 3, 2, 1}, netmask: 128}]} - ] - } - end end describe "delete_device/1" do @@ -421,8 +278,7 @@ defmodule FzHttp.DevicesTest do setup [:create_device] test "renders all peers", %{device: device} do - assert Devices.to_peer_list() |> List.first() == %{ - preshared_key: nil, + assert Devices.to_peer_list() |> List.first() |> Map.delete(:preshared_key) == %{ public_key: device.public_key, inet: "#{device.ipv4}/32,#{device.ipv6}/128" } @@ -431,12 +287,12 @@ defmodule FzHttp.DevicesTest do describe "Device.new_name/0,1" do test "retains name with less than or equal to 15 chars" do - assert Devices.Device.new_name("12345") == "12345" - assert Devices.Device.new_name("1234567890ABCDE") == "1234567890ABCDE" + assert Devices.new_name("12345") == "12345" + assert Devices.new_name("1234567890ABCDE") == "1234567890ABCDE" end test "truncates long names that exceed 15 chars" do - assert Devices.Device.new_name("1234567890ABCDEF") == "1234567890A4772" + assert Devices.new_name("1234567890ABCDEF") == "1234567890A4772" end end @@ -446,8 +302,7 @@ defmodule FzHttp.DevicesTest do test "projects expected fields with device", %{device: device, user: user} do user_id = user.id - assert %{ip: "10.3.2.2", ip6: "fd00::3:2:2", user_id: ^user_id} = - Devices.setting_projection(device) + assert %{ip: _, ip6: _, user_id: ^user_id} = Devices.setting_projection(device) end test "projects expected fields with device map", %{device: device, user: user} do @@ -459,8 +314,7 @@ defmodule FzHttp.DevicesTest do |> Map.put(:ipv4, FzHttp.Devices.decode(device.ipv4)) |> Map.put(:ipv6, FzHttp.Devices.decode(device.ipv6)) - assert %{ip: "10.3.2.2", ip6: "fd00::3:2:2", user_id: ^user_id} = - Devices.setting_projection(device_map) + assert %{ip: _, ip6: _, user_id: ^user_id} = Devices.setting_projection(device_map) end end diff --git a/apps/fz_http/test/fz_http/events_test.exs b/apps/fz_http/test/fz_http/events_test.exs index ec8d4fce9..273ffa85c 100644 --- a/apps/fz_http/test/fz_http/events_test.exs +++ b/apps/fz_http/test/fz_http/events_test.exs @@ -26,14 +26,15 @@ defmodule FzHttp.EventsTest do assert :sys.get_state(Events.wall_pid()) == %{ users: MapSet.new(), - devices: MapSet.new([%{ip: "10.3.2.2", ip6: "fd00::3:2:2", user_id: user.id}]), + devices: + MapSet.new([%{ip: "#{device.ipv4}", ip6: "#{device.ipv6}", user_id: user.id}]), rules: MapSet.new() } assert :sys.get_state(Events.vpn_pid()) == %{ - "1" => %{ - allowed_ips: "10.3.2.2/32,fd00::3:2:2/128", - preshared_key: nil + device.public_key => %{ + allowed_ips: "#{device.ipv4}/32,#{device.ipv6}/128", + preshared_key: device.preshared_key } } end diff --git a/apps/fz_http/test/fz_http/queries_test.exs b/apps/fz_http/test/fz_http/queries_test.exs deleted file mode 100644 index 148b448ca..000000000 --- a/apps/fz_http/test/fz_http/queries_test.exs +++ /dev/null @@ -1,84 +0,0 @@ -defmodule FzHttp.QueriesTest do - use FzHttp.DataCase, async: false - - alias FzHttp.Queries.INET - - describe "next_available/1 when none available" do - @expected_ipv4 %Postgrex.INET{address: {10, 3, 2, 2}, netmask: nil} - @expected_ipv6 %Postgrex.INET{address: {64_768, 0, 0, 0, 0, 3, 2, 2}, netmask: nil} - - setup context do - if ipv4_network = context[:ipv4_network] do - restore_env(:wireguard_ipv4_network, ipv4_network, &on_exit/1) - else - context - end - end - - setup context do - if ipv6_network = context[:ipv6_network] do - restore_env(:wireguard_ipv6_network, ipv6_network, &on_exit/1) - else - context - end - end - - @tag ipv4_network: "10.3.2.2/32" - test "when ipv4 network is /32 returns null" do - assert is_nil(INET.next_available(:ipv4)) - end - - @tag ipv6_network: "fd00::3:2:2/128" - test "when ipv6 network is /128 returns null" do - assert is_nil(INET.next_available(:ipv6)) - end - end - - describe "next_available/1 when edge case" do - setup :create_device - - @expected_ipv4 %Postgrex.INET{address: {10, 3, 2, 2}, netmask: 32} - @expected_ipv6 %Postgrex.INET{address: {64_768, 0, 0, 0, 0, 3, 2, 2}, netmask: 128} - - setup context do - if ipv4_network = context[:ipv4_network] do - restore_env(:wireguard_ipv4_network, ipv4_network, &on_exit/1) - else - context - end - end - - setup context do - if ipv6_network = context[:ipv6_network] do - restore_env(:wireguard_ipv6_network, ipv6_network, &on_exit/1) - else - context - end - end - - @tag ipv4_network: "10.3.2.0/30" - test "when ipv4 network is /30 returns null", %{device: device} do - assert device.ipv4 == @expected_ipv4 - assert is_nil(INET.next_available(:ipv4)) - end - - @tag ipv6_network: "fd00::3:2:0/126" - test "when ipv6 network is /126 returns null", %{device: device} do - assert device.ipv6 == @expected_ipv6 - assert is_nil(INET.next_available(:ipv6)) - end - end - - describe "next_available/1 when available" do - @expected_ipv4 %Postgrex.INET{address: {10, 3, 2, 2}, netmask: nil} - @expected_ipv6 %Postgrex.INET{address: {64_768, 0, 0, 0, 0, 3, 2, 2}, netmask: nil} - - test "when ipv4 network is /24 returns 10.3.2.2" do - assert INET.next_available(:ipv4) == @expected_ipv4 - end - - test "when ipv6 network is /120 returns fd00::3:2:2" do - assert INET.next_available(:ipv6) == @expected_ipv6 - end - end -end diff --git a/apps/fz_http/test/fz_http/repo/notifier_test.exs b/apps/fz_http/test/fz_http/repo/notifier_test.exs index 3682ccd41..f19efda10 100644 --- a/apps/fz_http/test/fz_http/repo/notifier_test.exs +++ b/apps/fz_http/test/fz_http/repo/notifier_test.exs @@ -88,16 +88,16 @@ defmodule FzHttp.Repo.NotifierTest do Notifier.handle_event("devices", %{op: "INSERT", row: device}) expected_vpn_state = %{ - "1" => %{ - allowed_ips: "10.3.2.2/32,fd00::3:2:2/128", - preshared_key: nil + device.public_key => %{ + allowed_ips: "#{device.ipv4}/32,#{device.ipv6}/128", + preshared_key: device.preshared_key } } expected_wall_state = %{ users: MapSet.new([]), rules: MapSet.new([]), - devices: MapSet.new([%{ip: "10.3.2.2", ip6: "fd00::3:2:2", user_id: user.id}]) + devices: MapSet.new([%{ip: "#{device.ipv4}", ip6: "#{device.ipv6}", user_id: user.id}]) } assert :sys.get_state(Events.vpn_pid()) == expected_vpn_state diff --git a/apps/fz_http/test/fz_http/rules_test.exs b/apps/fz_http/test/fz_http/rules_test.exs index 9c1c6eea7..056192a83 100644 --- a/apps/fz_http/test/fz_http/rules_test.exs +++ b/apps/fz_http/test/fz_http/rules_test.exs @@ -3,6 +3,13 @@ defmodule FzHttp.RulesTest do alias FzHttp.Rules + setup do + FzHttp.Config.maybe_put_env_override(:wireguard_ipv4_network, "100.64.0.0/10") + FzHttp.Config.maybe_put_env_override(:wireguard_ipv6_network, "fd00::0/106") + + :ok + end + describe "list_rules/0" do setup [:create_rules] @@ -34,7 +41,7 @@ defmodule FzHttp.RulesTest do test "raises error when id does not exist", %{rule: _rule} do assert_raise(Ecto.NoResultsError, fn -> - Rules.get_rule!(0) + Rules.get_rule!(Ecto.UUID.generate()) end) end end @@ -75,7 +82,7 @@ defmodule FzHttp.RulesTest do {:error, changeset} = Rules.create_rule(%{destination: "10.0.0.0/24", port_range: "10-20"}) assert changeset.errors[:port_type] == - {"Please specify a port-range for the given port type", + {"port_type must be specified with port_range", [constraint: :check, constraint_name: "port_range_needs_type"]} end @@ -83,7 +90,7 @@ defmodule FzHttp.RulesTest do {:error, changeset} = Rules.create_rule(%{destination: "10.0.0.0/24", port_type: :tcp}) assert changeset.errors[:port_type] == - {"Please specify a port-range for the given port type", + {"port_type must be specified with port_range", [constraint: :check, constraint_name: "port_range_needs_type"]} end @@ -92,7 +99,7 @@ defmodule FzHttp.RulesTest do Rules.create_rule(%{destination: "10.0.0.0/24", port_type: :tcp, port_range: "10-90000"}) assert changeset.errors[:port_range] == - {"Port is not within valid range", + {"port is not within valid range", [constraint: :check, constraint_name: "port_range_is_within_valid_values"]} end @@ -101,11 +108,45 @@ defmodule FzHttp.RulesTest do Rules.create_rule(%{destination: "10.0.0.0/24", port_type: :tcp, port_range: "20-10"}) assert changeset.errors[:port_range] == - {"Range Error: Lower bound higher than upper bound", + {"lower value cannot be higher than upper value", [type: FzHttp.Int4Range, validation: :cast]} end end + describe "update_rule/2" do + setup [:create_rule] + + @tag params: %{ + "destination" => "123.123.123.123", + "action" => "accept", + "port_type" => "udp", + "port_range" => [1, 65_000] + } + test "updates rule with string params", %{rule: rule, params: params} do + assert {:ok, rule} = Rules.update_rule(rule, params) + assert rule.destination == %Postgrex.INET{address: {123, 123, 123, 123}} + assert rule.action == :accept + assert rule.port_type == :udp + assert rule.port_range == "1 - 65000" + end + + @tag attrs: %{ + destination: "123.123.123.123", + action: "accept", + port_type: "udp", + port_range: [1, 65_000] + } + test "updates rule with atom attrs", %{rule: rule, attrs: attrs} do + assert {:ok, rule} = Rules.update_rule(rule, attrs) + assert rule.destination == %Postgrex.INET{address: {123, 123, 123, 123}} + assert rule.action == :accept + assert rule.port_type == :udp + assert rule.port_range == "1 - 65000" + end + + # XXX: Do we want to allow changing a rule's user_id? + end + describe "delete_rule/1" do setup [:create_rule] diff --git a/apps/fz_http/test/fz_http/sites_test.exs b/apps/fz_http/test/fz_http/sites_test.exs deleted file mode 100644 index b30043742..000000000 --- a/apps/fz_http/test/fz_http/sites_test.exs +++ /dev/null @@ -1,105 +0,0 @@ -defmodule FzHttp.SitesTest do - use FzHttp.DataCase - - alias FzHttp.Sites.Site - alias FzHttp.Sites - import FzHttp.SitesFixtures - - describe "trimmed fields" do - test "trims expected fields" do - changeset = - Sites.new_site(%{ - "allowed_ips" => " foo ", - "dns" => " foo ", - "endpoint" => " foo ", - "name" => " foo " - }) - - assert %Ecto.Changeset{ - changes: %{ - allowed_ips: "foo", - dns: "foo", - endpoint: "foo", - name: "foo" - } - } = changeset - end - end - - describe "update_site/2 with name-based dns" do - setup do - {:ok, site: site_fixture()} - end - - @tag attrs: %{dns: "foobar.com"} - test "update_site/2 allows hosts for DNS", %{site: site, attrs: attrs} do - assert {:ok, _site} = Sites.update_site(site, attrs) - end - - @tag attrs: %{dns: "foobar.com, google.com"} - test "update_site/2 allows list hosts for DNS", %{site: site, attrs: attrs} do - assert {:ok, _site} = Sites.update_site(site, attrs) - end - end - - describe "sites" do - @valid_sites [ - %{ - "dns" => "8.8.8.8", - "allowed_ips" => "::/0", - "endpoint" => "172.10.10.10", - "persistent_keepalive" => "20", - "mtu" => "1280" - }, - %{ - "dns" => "8.8.8.8", - "allowed_ips" => "::/0", - "endpoint" => "foobar.example.com", - "persistent_keepalive" => "15", - "mtu" => "1280" - } - ] - @invalid_site %{ - "dns" => "foobar", - "allowed_ips" => "foobar", - "endpoint" => "foobar", - "persistent_keepalive" => "-120", - "mtu" => "1501" - } - - test "get_site/1 returns the site with given id" do - site = site_fixture() - assert Sites.get_site!(site.id) == site - end - - test "get_site!/1 returns the site with the given name" do - site = Sites.get_site!(name: "default") - assert site.name == "default" - end - - test "update_site/2 with valid data updates the site via provided site" do - site = Sites.get_site!(name: "default") - - for attrs <- @valid_sites do - assert {:ok, %Site{}} = Sites.update_site(site, attrs) - end - end - - test "update_site/2 with invalid data returns error changeset" do - site = Sites.get_site!(name: "default") - assert {:error, %Ecto.Changeset{}} = Sites.update_site(site, @invalid_site) - site = Sites.get_site!(name: "default") - - refute site.dns == "foobar" - refute site.allowed_ips == "foobar" - refute site.endpoint == "foobar" - refute site.persistent_keepalive == -120 - refute site.mtu == 1501 - end - - test "change_site/1 returns a site changeset" do - site = site_fixture() - assert %Ecto.Changeset{} = Sites.change_site(site) - end - end -end diff --git a/apps/fz_http/test/fz_http/telemetry_test.exs b/apps/fz_http/test/fz_http/telemetry_test.exs index 33a48e1a4..e27e19dc7 100644 --- a/apps/fz_http/test/fz_http/telemetry_test.exs +++ b/apps/fz_http/test/fz_http/telemetry_test.exs @@ -40,65 +40,68 @@ defmodule FzHttp.TelemetryTest do end describe "auth" do - setup context do - if context[:config] do - {key, value} = context[:config] - restore_env(key, value, &on_exit/1) - else - context - end - end - test "count openid providers" do + FzHttp.Configurations.put!( + :openid_connect_providers, + FzHttp.ConfigurationsFixtures.openid_connect_providers_attrs() + ) + ping_data = Telemetry.ping_data() assert ping_data[:openid_providers] == 7 end - @tag config: {:disable_vpn_on_oidc_error, true} test "disable vpn on oidc error enabled" do + FzHttp.Configurations.put!(:disable_vpn_on_oidc_error, true) + ping_data = Telemetry.ping_data() assert ping_data[:disable_vpn_on_oidc_error] end - @tag config: {:disable_vpn_on_oidc_error, false} test "disable vpn on oidc error disabled" do + FzHttp.Configurations.put!(:disable_vpn_on_oidc_error, false) + ping_data = Telemetry.ping_data() refute ping_data[:disable_vpn_on_oidc_error] end - @tag config: {:local_auth_enabled, true} test "local authentication enabled" do + FzHttp.Configurations.put!(:local_auth_enabled, true) + ping_data = Telemetry.ping_data() assert ping_data[:local_authentication] end - @tag config: {:local_auth_enabled, false} test "local authentication disabled" do + FzHttp.Configurations.put!(:local_auth_enabled, false) + ping_data = Telemetry.ping_data() refute ping_data[:local_authentication] end - @tag config: {:allow_unprivileged_device_management, true} test "unprivileged device management enabled" do + FzHttp.Configurations.put!(:allow_unprivileged_device_management, true) + ping_data = Telemetry.ping_data() assert ping_data[:unprivileged_device_management] end - @tag config: {:allow_unprivileged_device_configuration, true} test "unprivileged device configuration enabled" do + FzHttp.Configurations.put!(:allow_unprivileged_device_configuration, true) + ping_data = Telemetry.ping_data() assert ping_data[:unprivileged_device_configuration] end - @tag config: {:allow_unprivileged_device_configuration, false} test "unprivileged device configuration disabled" do + FzHttp.Configurations.put!(:allow_unprivileged_device_configuration, false) + ping_data = Telemetry.ping_data() refute ping_data[:unprivileged_device_configuration] @@ -106,33 +109,33 @@ defmodule FzHttp.TelemetryTest do end describe "database" do - setup context do - restore_env(FzHttp.Repo, context[:db_config], &on_exit/1) - end - - @tag db_config: [hostname: "localhost"] test "local hostname" do + FzHttp.Config.maybe_put_env_override(FzHttp.Repo, hostname: "localhost") + ping_data = Telemetry.ping_data() refute ping_data[:external_database] end - @tag db_config: [url: "postgres://127.0.0.1"] test "local url" do + FzHttp.Config.maybe_put_env_override(FzHttp.Repo, url: "postgres://127.0.0.1") + ping_data = Telemetry.ping_data() refute ping_data[:external_database] end - @tag db_config: [hostname: "firezone.dev"] test "external hostname" do + FzHttp.Config.maybe_put_env_override(FzHttp.Repo, hostname: "firezone.dev") + ping_data = Telemetry.ping_data() assert ping_data[:external_database] end - @tag db_config: [url: "postgres://firezone.dev"] test "external url" do + FzHttp.Config.maybe_put_env_override(FzHttp.Repo, url: "postgres://firezone.dev") + ping_data = Telemetry.ping_data() assert ping_data[:external_database] @@ -140,19 +143,17 @@ defmodule FzHttp.TelemetryTest do end describe "email" do - setup context do - restore_env(FzHttpWeb.Mailer, [from_email: context[:from_email]], &on_exit/1) - end - - @tag from_email: "test@firezone.dev" test "outbound set" do + FzHttp.Config.maybe_put_env_override(FzHttpWeb.Mailer, from_email: "test@firezone.dev") + ping_data = Telemetry.ping_data() assert ping_data[:outbound_email] end - @tag from_email: nil test "outbound unset" do + FzHttp.Config.maybe_put_env_override(FzHttpWeb.Mailer, from_email: nil) + ping_data = Telemetry.ping_data() refute ping_data[:outbound_email] diff --git a/apps/fz_http/test/fz_http/users_test.exs b/apps/fz_http/test/fz_http/users_test.exs index 5ddf46e89..0f1f5d06a 100644 --- a/apps/fz_http/test/fz_http/users_test.exs +++ b/apps/fz_http/test/fz_http/users_test.exs @@ -78,7 +78,7 @@ defmodule FzHttp.UsersTest do test "raises Ecto.NoResultsError for missing Users", %{user: _user} do assert_raise(Ecto.NoResultsError, fn -> - Users.get_user!(0) + Users.get_user!(Ecto.UUID.generate()) end) end end @@ -91,7 +91,7 @@ defmodule FzHttp.UsersTest do end test "returns nil if not found" do - assert nil == Users.get_user(0) + assert nil == Users.get_user(Ecto.UUID.generate()) end end diff --git a/apps/fz_http/test/fz_http_web/controllers/auth_controller_test.exs b/apps/fz_http/test/fz_http_web/controllers/auth_controller_test.exs index f79befacf..5e7c0144e 100644 --- a/apps/fz_http/test/fz_http_web/controllers/auth_controller_test.exs +++ b/apps/fz_http/test/fz_http_web/controllers/auth_controller_test.exs @@ -1,8 +1,21 @@ defmodule FzHttpWeb.AuthControllerTest do use FzHttpWeb.ConnCase, async: true - import Mox + setup do + FzHttp.Configurations.put!( + :openid_connect_providers, + FzHttp.ConfigurationsFixtures.openid_connect_providers_attrs() + ) + + FzHttp.Configurations.put!( + :saml_identity_providers, + [FzHttp.SAMLIdentityProviderFixtures.saml_attrs() |> Map.put("label", "SAML")] + ) + + %{} + end + describe "new" do setup [:create_user] @@ -11,10 +24,16 @@ defmodule FzHttpWeb.AuthControllerTest do test_conn = get(conn, ~p"/") - # Assert that we email, OIDC and Oauth2 buttons provided + # Assert that we have email, OIDC and Oauth2 buttons provided for expected <- [ "Sign in with email", "Sign in with OIDC Google", + "Sign in with OIDC Okta", + "Sign in with OIDC Auth0", + "Sign in with OIDC Azure", + "Sign in with OIDC Onelogin", + "Sign in with OIDC Keycloak", + "Sign in with OIDC Vault", "Sign in with SAML" ] do assert html_response(test_conn, 200) =~ expected @@ -37,6 +56,10 @@ defmodule FzHttpWeb.AuthControllerTest do describe "create session" do setup [:create_user] + test "GET /auth/identity/callback redirects to /", %{unauthed_conn: conn} do + assert redirected_to(get(conn, ~p"/auth/identity/callback")) == ~p"/" + end + test "invalid email", %{unauthed_conn: conn} do params = %{ "email" => "invalid@test", @@ -83,7 +106,7 @@ defmodule FzHttpWeb.AuthControllerTest do "password" => "password1234" } - restore_env(:local_auth_enabled, false, &on_exit/1) + FzHttp.Configurations.put!(:local_auth_enabled, false) test_conn = post(conn, ~p"/auth/identity/callback", params) assert text_response(test_conn, 401) == "Local auth disabled" @@ -221,7 +244,7 @@ defmodule FzHttpWeb.AuthControllerTest do end test "prevents signing in when local_auth_disabled", %{unauthed_conn: conn, user: user} do - restore_env(:local_auth_enabled, false, &on_exit/1) + FzHttp.Configurations.put!(:local_auth_enabled, false) test_conn = get(conn, ~p"/auth/magic/#{user.sign_in_token}") assert text_response(test_conn, 401) == "Local auth disabled" @@ -259,7 +282,7 @@ defmodule FzHttpWeb.AuthControllerTest do test "redirects to oidc auth uri", %{unauthed_conn: conn} do expect(OpenIDConnect.Mock, :authorization_uri, fn provider, _ -> case provider do - :google -> @oidc_auth_uri + "google" -> @oidc_auth_uri end end) diff --git a/apps/fz_http/test/fz_http_web/controllers/json/configuration_controller_test.exs b/apps/fz_http/test/fz_http_web/controllers/json/configuration_controller_test.exs new file mode 100644 index 000000000..2afbc933b --- /dev/null +++ b/apps/fz_http/test/fz_http_web/controllers/json/configuration_controller_test.exs @@ -0,0 +1,31 @@ +defmodule FzHttpWeb.JSON.ConfigurationControllerTest do + use FzHttpWeb.ConnCase, async: true + @moduletag api: true + + describe "show configuration" do + test "renders configuration", %{api_conn: conn} do + conn = get(conn, ~p"/v0/configuration") + assert json_response(conn, 200)["data"] + end + end + + describe "update configuration" do + test "renders configuration when data is valid", %{api_conn: conn} do + conn = put(conn, ~p"/v0/configuration", configuration: %{"local_auth_enabled" => true}) + + assert %{"local_auth_enabled" => true} = json_response(conn, 200)["data"] + assert FzHttp.Configurations.get!(:local_auth_enabled) == true + + conn = put(conn, ~p"/v0/configuration", configuration: %{"local_auth_enabled" => false}) + + assert %{"local_auth_enabled" => false} = json_response(conn, 200)["data"] + assert FzHttp.Configurations.get!(:local_auth_enabled) == false + end + + test "renders errors when data is invalid", %{api_conn: conn} do + conn = put(conn, ~p"/v0/configuration", configuration: %{"local_auth_enabled" => 123}) + + assert json_response(conn, 422)["errors"] == %{"local_auth_enabled" => ["is invalid"]} + end + end +end diff --git a/apps/fz_http/test/fz_http_web/controllers/json/device_controller_test.exs b/apps/fz_http/test/fz_http_web/controllers/json/device_controller_test.exs new file mode 100644 index 000000000..9787d3575 --- /dev/null +++ b/apps/fz_http/test/fz_http_web/controllers/json/device_controller_test.exs @@ -0,0 +1,80 @@ +defmodule FzHttpWeb.JSON.DeviceControllerTest do + use FzHttpWeb.ConnCase, async: true + @moduletag api: true + + describe "show device" do + setup :create_device + + test "shows device", %{api_conn: conn, device: %{id: id}} do + conn = get(conn, ~p"/v0/devices/#{id}") + assert %{"id" => ^id} = json_response(conn, 200)["data"] + end + end + + describe "create device" do + @params %{ + "name" => "create-name", + "description" => "create-description", + "public_key" => "CHqFuS+iL3FTog5F4Ceumqlk0CU4Cl/dyUP/9F9NDnI=", + "preshared_key" => "CHqFuS+iL3FTog5F4Ceumqlk0CU4Cl/dyUP/9F9NDnI=", + "use_default_allowed_ips" => false, + "use_default_dns" => false, + "use_default_endpoint" => false, + "use_default_mtu" => false, + "use_default_persistent_keepalive" => false, + "endpoint" => "9.9.9.9", + "mtu" => 999, + "persistent_keepalive" => 9, + "allowed_ips" => "0.0.0.0/0, ::/0, 1.1.1.1", + "dns" => "9.9.9.8", + "ipv4" => "100.64.0.2", + "ipv6" => "fd00::2" + } + + @tag params: @params + test "creates device", %{api_conn: conn, unprivileged_user: %{id: id}, params: params} do + conn = post(conn, ~p"/v0/devices", device: Map.merge(params, %{"user_id" => id})) + assert @params = json_response(conn, 201)["data"] + end + end + + describe "update device" do + setup :create_device + + @tag params: %{ + "name" => "json-update-device" + } + test "updates device", %{api_conn: conn, params: params, device: %{id: id}} do + conn = put(conn, ~p"/v0/devices/#{id}", device: params) + assert %{"id" => ^id} = json_response(conn, 200)["data"] + + conn = get(conn, ~p"/v0/devices/#{id}") + + assert %{ + "name" => "json-update-device" + } = json_response(conn, 200)["data"] + end + end + + describe "list devices" do + setup :create_devices + + test "lists devices", %{api_conn: conn, devices: devices} do + conn = get(conn, ~p"/v0/devices") + assert length(json_response(conn, 200)["data"]) == length(devices) + end + end + + describe "delete device" do + setup :create_device + + test "deletes device", %{api_conn: conn, device: device} do + conn = delete(conn, ~p"/v0/devices/#{device}") + assert response(conn, 204) + + assert_error_sent 404, fn -> + get(conn, ~p"/v0/devices/#{device}") + end + end + end +end diff --git a/apps/fz_http/test/fz_http_web/controllers/json/rule_controller_test.exs b/apps/fz_http/test/fz_http_web/controllers/json/rule_controller_test.exs new file mode 100644 index 000000000..34e7d33e0 --- /dev/null +++ b/apps/fz_http/test/fz_http_web/controllers/json/rule_controller_test.exs @@ -0,0 +1,64 @@ +defmodule FzHttpWeb.JSON.RuleControllerTest do + use FzHttpWeb.ConnCase, async: true + @moduletag api: true + + @rule_params %{ + "destination" => "5.5.5.5/24", + "action" => "accept", + "port_type" => "tcp", + "port_range" => "1 - 65000" + } + + describe "show rule" do + setup :create_rule + + test "shows rule", %{api_conn: conn, rule: %{id: id}} do + conn = get(conn, ~p"/v0/rules/#{id}") + assert %{"id" => ^id} = json_response(conn, 200)["data"] + end + end + + describe "create rule" do + @tag params: @rule_params + test "creates rule", %{api_conn: conn, unprivileged_user: user, params: params} do + conn = post(conn, ~p"/v0/rules", rule: Map.merge(params, %{"user_id" => user.id})) + assert @rule_params = json_response(conn, 201)["data"] + end + end + + describe "update rule" do + setup :create_rule + + @tag params: @rule_params + test "updates rule", %{api_conn: conn, params: params, rule: %{id: id}} do + conn = put(conn, ~p"/v0/rules/#{id}", rule: params) + assert %{"id" => ^id} = json_response(conn, 200)["data"] + + conn = get(conn, ~p"/v0/rules/#{id}") + + assert @rule_params = json_response(conn, 200)["data"] + end + end + + describe "list rules" do + setup :create_rules + + test "lists rules", %{api_conn: conn, rules: rules} do + conn = get(conn, ~p"/v0/rules") + assert length(json_response(conn, 200)["data"]) == length(rules) + end + end + + describe "delete rule" do + setup :create_rule + + test "deletes rule", %{api_conn: conn, rule: rule} do + conn = delete(conn, ~p"/v0/rules/#{rule}") + assert response(conn, 204) + + assert_error_sent 404, fn -> + get(conn, ~p"/v0/rules/#{rule}") + end + end + end +end diff --git a/apps/fz_http/test/fz_http_web/controllers/json/user_controller_test.exs b/apps/fz_http/test/fz_http_web/controllers/json/user_controller_test.exs new file mode 100644 index 000000000..b6d782095 --- /dev/null +++ b/apps/fz_http/test/fz_http_web/controllers/json/user_controller_test.exs @@ -0,0 +1,90 @@ +defmodule FzHttpWeb.JSON.UserControllerTest do + use FzHttpWeb.ConnCase, async: true + @moduletag api: true + + alias FzHttp.{ + Users, + Users.User + } + + @create_attrs %{ + "email" => "test@test.com", + "password" => "test1234test", + "password_confirmation" => "test1234test" + } + @update_attrs %{ + "email" => "test2@test.com" + } + @invalid_attrs %{ + "email" => "test@test.com", + "password" => "test1234" + } + + describe "index" do + test "lists all users", %{api_conn: conn} do + conn = get(conn, ~p"/v0/users") + + actual = + Users.list_users() + |> Enum.map(fn u -> u.id end) + |> Enum.sort() + + expected = + json_response(conn, 200)["data"] + |> Enum.map(fn m -> m["id"] end) + |> Enum.sort() + + assert actual == expected + end + end + + describe "create user" do + test "renders user when data is valid", %{api_conn: conn} do + conn = post(conn, ~p"/v0/users", user: @create_attrs) + assert %{"id" => id} = json_response(conn, 201)["data"] + + conn = get(conn, ~p"/v0/users/#{id}") + + assert %{ + "id" => ^id + } = json_response(conn, 200)["data"] + end + + test "renders errors when data is invalid", %{api_conn: conn} do + conn = post(conn, ~p"/v0/users", user: @invalid_attrs) + assert json_response(conn, 422)["errors"] != %{} + end + end + + describe "update user" do + test "renders user when data is valid", %{ + api_conn: conn, + unprivileged_user: %User{id: id} = user + } do + conn = put(conn, ~p"/v0/users/#{user}", user: @update_attrs) + assert %{"id" => ^id} = json_response(conn, 200)["data"] + + conn = get(conn, ~p"/v0/users/#{id}") + + assert %{ + "id" => ^id + } = json_response(conn, 200)["data"] + end + + test "renders errors when data is invalid", %{api_conn: conn, unprivileged_user: user} do + conn = put(conn, ~p"/v0/users/#{user}", user: @invalid_attrs) + assert json_response(conn, 422)["errors"] != %{} + end + end + + describe "delete user" do + test "deletes chosen user", %{api_conn: conn, unprivileged_user: user} do + conn = delete(conn, ~p"/v0/users/#{user}") + assert response(conn, 204) + + assert_error_sent 404, fn -> + get(conn, ~p"/v0/users/#{user}") + end + end + end +end diff --git a/apps/fz_http/test/fz_http_web/authentication_test.exs b/apps/fz_http/test/fz_http_web/html_authentication_test.exs similarity index 89% rename from apps/fz_http/test/fz_http_web/authentication_test.exs rename to apps/fz_http/test/fz_http_web/html_authentication_test.exs index 76949d4f9..1b50644af 100644 --- a/apps/fz_http/test/fz_http_web/authentication_test.exs +++ b/apps/fz_http/test/fz_http_web/html_authentication_test.exs @@ -1,7 +1,7 @@ -defmodule FzHttpWeb.AuthenticationTest do +defmodule FzHttpWeb.HTMLAuthenticationTest do use FzHttpWeb.ConnCase, async: true - alias FzHttpWeb.Authentication + alias FzHttpWeb.Auth.HTML.Authentication describe "authenticate/2" do setup :create_user diff --git a/apps/fz_http/test/fz_http_web/live/device_live/admin/index_test.exs b/apps/fz_http/test/fz_http_web/live/device_live/admin/index_test.exs index f67fc3999..3f727d14f 100644 --- a/apps/fz_http/test/fz_http_web/live/device_live/admin/index_test.exs +++ b/apps/fz_http/test/fz_http_web/live/device_live/admin/index_test.exs @@ -1,5 +1,5 @@ defmodule FzHttpWeb.DeviceLive.Admin.IndexTest do - use FzHttpWeb.ConnCase, async: false + use FzHttpWeb.ConnCase, async: true describe "authenticated/device list" do setup :create_devices diff --git a/apps/fz_http/test/fz_http_web/live/device_live/unprivileged/index_test.exs b/apps/fz_http/test/fz_http_web/live/device_live/unprivileged/index_test.exs index a10a45ec0..70f546fee 100644 --- a/apps/fz_http/test/fz_http_web/live/device_live/unprivileged/index_test.exs +++ b/apps/fz_http/test/fz_http_web/live/device_live/unprivileged/index_test.exs @@ -1,5 +1,5 @@ defmodule FzHttpWeb.DeviceLive.Unprivileged.IndexTest do - use FzHttpWeb.ConnCase, async: false + use FzHttpWeb.ConnCase, async: true describe "authenticated/device list" do test "includes the device name in the list", %{ @@ -28,7 +28,8 @@ defmodule FzHttpWeb.DeviceLive.Unprivileged.IndexTest do describe "authenticated device management disabled" do setup do - restore_env(:allow_unprivileged_device_management, false, &on_exit/1) + FzHttp.Configurations.put!(:allow_unprivileged_device_management, false) + :ok end test "prevents navigating to /user_devices/new", %{unprivileged_conn: conn} do @@ -48,19 +49,20 @@ defmodule FzHttpWeb.DeviceLive.Unprivileged.IndexTest do describe "authenticated device configuration disabled" do setup do - restore_env(:allow_unprivileged_device_configuration, false, &on_exit/1) + FzHttp.Configurations.put!(:allow_unprivileged_device_configuration, false) + :ok end @tag fields: ~w( - use_site_allowed_ips + use_default_allowed_ips allowed_ips - use_site_dns + use_default_dns dns - use_site_endpoint + use_default_endpoint endpoint - use_site_mtu + use_default_mtu mtu - use_site_persistent_keepalive + use_default_persistent_keepalive persistent_keepalive ipv4 ipv6 @@ -88,36 +90,6 @@ defmodule FzHttpWeb.DeviceLive.Unprivileged.IndexTest do assert html =~ "device[#{field}]" end end - - @tag params: %{"device" => %{"public_key" => "test-pubkey", "name" => "test-tunnel"}}, - error: "ipv4 address pool is exhausted. Increase network size or remove some devices." - test "Displays base error when IPv4 pool is exhausted", - %{params: params, unprivileged_conn: conn, error: error} do - path = ~p"/user_devices/new" - {:ok, view, _html} = live(conn, path) - - # A pool of size 1 is always exhausted - restore_env(:wireguard_ipv4_network, "10.0.0.1/32", &on_exit/1) - - assert view - |> element("#create-device") - |> render_submit(params) =~ error - end - - @tag params: %{"device" => %{"public_key" => "test-pubkey", "name" => "test-tunnel"}}, - error: "ipv6 address pool is exhausted. Increase network size or remove some devices." - test "Displays base error when IPv6 pool is exhausted", - %{params: params, unprivileged_conn: conn, error: error} do - path = ~p"/user_devices/new" - {:ok, view, _html} = live(conn, path) - - # A pool of size 1 is always exhausted - restore_env(:wireguard_ipv6_network, "fd00::3:2:0/128", &on_exit/1) - - assert view - |> element("#create-device") - |> render_submit(params) =~ error - end end describe "authenticated/creates device" do @@ -139,7 +111,12 @@ defmodule FzHttpWeb.DeviceLive.Unprivileged.IndexTest do new_view = view |> element("#create-device") - |> render_submit(%{"device" => %{"public_key" => "test-pubkey", "name" => "test-tunnel"}}) + |> render_submit(%{ + "device" => %{ + "public_key" => "8IkpsAXiqhqNdc9PJS76YeJjig4lyTBaf8Rm7gTApXk=", + "name" => "test-tunnel" + } + }) assert new_view =~ "Device added!" end diff --git a/apps/fz_http/test/fz_http_web/live/device_live/unprivileged/show_test.exs b/apps/fz_http/test/fz_http_web/live/device_live/unprivileged/show_test.exs index 36c40f9b3..3aca4ea09 100644 --- a/apps/fz_http/test/fz_http_web/live/device_live/unprivileged/show_test.exs +++ b/apps/fz_http/test/fz_http_web/live/device_live/unprivileged/show_test.exs @@ -42,7 +42,8 @@ defmodule FzHttpWeb.DeviceLive.Unprivileged.ShowTest do unprivileged_conn: conn } do {:ok, device: device} = create_device(user_id: user.id) - restore_env(:allow_unprivileged_device_management, false, &on_exit/1) + + FzHttp.Configurations.put!(:allow_unprivileged_device_management, false) path = ~p"/user_devices/#{device}" {:ok, _view, html} = live(conn, path) diff --git a/apps/fz_http/test/fz_http_web/live/setting_live/client_defaults_test.exs b/apps/fz_http/test/fz_http_web/live/setting_live/client_defaults_test.exs new file mode 100644 index 000000000..b4545b923 --- /dev/null +++ b/apps/fz_http/test/fz_http_web/live/setting_live/client_defaults_test.exs @@ -0,0 +1,168 @@ +defmodule FzHttpWeb.SettingLive.ClientDefaultsTest do + use FzHttpWeb.ConnCase, async: true + + alias FzHttp.Configurations + + describe "authenticated/client_defaults" do + @valid_allowed_ips %{ + "configuration" => %{"default_client_allowed_ips" => "1.1.1.1"} + } + @valid_dns %{ + "configuration" => %{"default_client_dns" => "1.1.1.1"} + } + @valid_endpoint %{ + "configuration" => %{"default_client_endpoint" => "1.1.1.1"} + } + @valid_persistent_keepalive %{ + "configuration" => %{"default_client_persistent_keepalive" => "1"} + } + @valid_mtu %{ + "configuration" => %{"default_client_mtu" => "1000"} + } + + @invalid_allowed_ips %{ + "configuration" => %{"default_client_allowed_ips" => "foobar"} + } + @invalid_persistent_keepalive %{ + "configuration" => %{"default_client_persistent_keepalive" => "-1"} + } + @invalid_mtu %{ + "configuration" => %{"default_client_mtu" => "0"} + } + + setup %{admin_conn: conn} do + path = ~p"/settings/client_defaults" + {:ok, view, html} = live(conn, path) + + %{html: html, view: view} + end + + test "renders current configuration", %{html: html} do + assert html =~ Configurations.get_configuration!().default_client_allowed_ips + assert html =~ Configurations.get_configuration!().default_client_dns + + assert html =~ """ + id="client_defaults_form_component_default_client_endpoint"\ + """ + + assert html =~ """ + id="client_defaults_form_component_default_client_persistent_keepalive"\ + """ + end + + test "updates default client allowed_ips", %{view: view} do + test_view = + view + |> element("#client_defaults_form_component") + |> render_submit(@valid_allowed_ips) + + refute test_view =~ "is invalid" + + assert test_view =~ """ + \ + """ + end + + test "updates default client dns", %{view: view} do + test_view = + view + |> element("#client_defaults_form_component") + |> render_submit(@valid_dns) + + refute test_view =~ "is invalid" + + assert test_view =~ """ + \ + """ + end + + test "updates default client endpoint", %{view: view} do + test_view = + view + |> element("#client_defaults_form_component") + |> render_submit(@valid_endpoint) + + refute test_view =~ "is invalid" + + assert test_view =~ """ + \ + """ + end + + test "updates default client persistent_keepalive", %{view: view} do + test_view = + view + |> element("#client_defaults_form_component") + |> render_submit(@valid_persistent_keepalive) + + refute test_view =~ "is invalid" + + assert test_view =~ """ + \ + """ + end + + test "updates default client mtu", %{view: view} do + test_view = + view + |> element("#client_defaults_form_component") + |> render_submit(@valid_mtu) + + refute test_view =~ "is invalid" + + assert test_view =~ """ + \ + """ + end + + test "prevents invalid allowed_ips", %{view: view} do + test_view = + view + |> element("#client_defaults_form_component") + |> render_submit(@invalid_allowed_ips) + + assert test_view =~ "is invalid" + + assert test_view =~ """ + \ + """ + end + + test "prevents invalid persistent_keepalive", %{view: view} do + test_view = + view + |> element("#client_defaults_form_component") + |> render_submit(@invalid_persistent_keepalive) + + assert test_view =~ "must be greater than or equal to 0" + + assert test_view =~ """ + \ + """ + end + + test "prevents invalid mtu", %{view: view} do + test_view = + view + |> element("#client_defaults_form_component") + |> render_submit(@invalid_mtu) + + assert test_view =~ "must be greater than or equal to 576" + + assert test_view =~ """ + \ + """ + end + end + + describe "unauthenticated/settings default" do + @tag :unauthed + test "mount redirects to session path", %{unauthed_conn: conn} do + path = ~p"/settings/client_defaults" + expected_path = ~p"/" + assert {:error, {:redirect, %{to: ^expected_path}}} = live(conn, path) + end + end +end diff --git a/apps/fz_http/test/fz_http_web/live/setting_live/customization_test.exs b/apps/fz_http/test/fz_http_web/live/setting_live/customization_test.exs index 4ef4a1dae..af7f22b3f 100644 --- a/apps/fz_http/test/fz_http_web/live/setting_live/customization_test.exs +++ b/apps/fz_http/test/fz_http_web/live/setting_live/customization_test.exs @@ -1,17 +1,9 @@ defmodule FzHttpWeb.SettingLive.CustomizationTest do use FzHttpWeb.ConnCase, async: true - alias FzHttp.Configurations, as: Conf - describe "logo" do setup %{admin_conn: conn} = context do - Conf.update_configuration(%{logo: context[:logo]}) - - on_exit(fn -> - # this is required because configuration is automatically reset (rolled back) - # after each run, but persistent terms are not. we need to manually reset it here. - Conf.Cache.put!(:logo, nil) - end) + FzHttp.Configurations.put!(:logo, context[:logo]) path = ~p"/settings/customization" {:ok, view, html} = live(conn, path) @@ -24,12 +16,12 @@ defmodule FzHttpWeb.SettingLive.CustomizationTest do assert html =~ ~s|value="Default" checked| end - @tag logo: %{"url" => "test"} + @tag logo: %{url: "test"} test "show url", %{html: html} do assert html =~ ~s|value="URL" checked| end - @tag logo: %{"data" => "test", "type" => "test"} + @tag logo: %{data: "test", type: "test"} test "show upload", %{html: html} do assert html =~ ~s|value="Upload" checked| end @@ -52,13 +44,13 @@ defmodule FzHttpWeb.SettingLive.CustomizationTest do |> render_click() =~ ~s|
|---|