### TODO

- [x] "/v0"
- [x] Double-check migration order; re-timestamp if necessary
- [x] Move `sites` fields to `configurations` so they can be updated
from API
- [x] #1240 -- it introduces possible race conditions for API requests
- [x] #1249 
- [ ] #1008 
- [ ] Final review

Signed-off-by: Jamil <jamilbk@users.noreply.github.com>
Co-authored-by: Andrew Dryga <andrew@dryga.com>
This commit is contained in:
Jamil
2022-12-31 12:58:11 -06:00
committed by GitHub
parent 963aca75cb
commit 1de24ff77e
180 changed files with 4128 additions and 2377 deletions

View File

@@ -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

View File

@@ -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, []},

View File

@@ -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\-\.\/]+)$).*'

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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}

View File

@@ -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)

View File

@@ -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;
}

View File

@@ -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.")
}
})

View File

@@ -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",

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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()"),

View File

@@ -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

View File

@@ -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,

View File

@@ -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

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 ->

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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")

View File

@@ -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"}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -4,7 +4,7 @@ defmodule FzHttpWeb.UserController do
"""
alias FzHttp.Users
alias FzHttpWeb.Authentication
alias FzHttpWeb.Auth.HTML.Authentication
use FzHttpWeb, :controller
require Logger

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -95,17 +95,17 @@
<%= if FzHttpWeb.DeviceView.can_configure_devices?(@current_user) do %>
<div class="field">
<%= label(f, :use_site_allowed_ips, "Use Default Allowed IPs", class: "label") %>
<%= label(f, :use_default_allowed_ips, "Use Default Allowed IPs", class: "label") %>
<div class="control">
<label class="radio">
<%= radio_button(f, :use_site_allowed_ips, true) %> Yes
<%= radio_button(f, :use_default_allowed_ips, true) %> Yes
</label>
<label class="radio">
<%= radio_button(f, :use_site_allowed_ips, false) %> No
<%= radio_button(f, :use_default_allowed_ips, false) %> No
</label>
</div>
<p class="help">
Default: <%= @allowed_ips %>
Default: <%= @default_client_allowed_ips %>
</p>
</div>
@@ -114,7 +114,7 @@
<div class="control">
<%= textarea(f, :allowed_ips,
class: "textarea #{input_error_class(f, :allowed_ips)}",
disabled: @use_site_allowed_ips
disabled: @use_default_allowed_ips
) %>
</div>
<p class="help is-danger">
@@ -123,17 +123,17 @@
</div>
<div class="field">
<%= label(f, :use_site_dns, "Use Default DNS Servers", class: "label") %>
<%= label(f, :use_default_dns, "Use Default DNS Servers", class: "label") %>
<div class="control">
<label class="radio">
<%= radio_button(f, :use_site_dns, true) %> Yes
<%= radio_button(f, :use_default_dns, true) %> Yes
</label>
<label class="radio">
<%= radio_button(f, :use_site_dns, false) %> No
<%= radio_button(f, :use_default_dns, false) %> No
</label>
</div>
<p class="help">
Default: <%= @dns %>
Default: <%= @default_client_dns %>
</p>
</div>
@@ -142,7 +142,7 @@
<div class="control">
<%= text_input(f, :dns,
class: "input #{input_error_class(f, :dns)}",
disabled: @use_site_dns
disabled: @use_default_dns
) %>
</div>
<p class="help is-danger">
@@ -151,17 +151,17 @@
</div>
<div class="field">
<%= label(f, :use_site_endpoint, "Use Default Endpoint", class: "label") %>
<%= label(f, :use_default_endpoint, "Use Default Endpoint", class: "label") %>
<div class="control">
<label class="radio">
<%= radio_button(f, :use_site_endpoint, true) %> Yes
<%= radio_button(f, :use_default_endpoint, true) %> Yes
</label>
<label class="radio">
<%= radio_button(f, :use_site_endpoint, false) %> No
<%= radio_button(f, :use_default_endpoint, false) %> No
</label>
</div>
<p class="help">
Default: <%= @endpoint %>
Default: <%= @default_client_endpoint %>
</p>
</div>
@@ -171,7 +171,7 @@
<div class="control">
<%= text_input(f, :endpoint,
class: "input #{input_error_class(f, :endpoint)}",
disabled: @use_site_endpoint
disabled: @use_default_endpoint
) %>
</div>
<p class="help is-danger">
@@ -180,17 +180,17 @@
</div>
<div class="field">
<%= label(f, :use_site_mtu, "Use Default MTU", class: "label") %>
<%= label(f, :use_default_mtu, "Use Default MTU", class: "label") %>
<div class="control">
<label class="radio">
<%= radio_button(f, :use_site_mtu, true) %> Yes
<%= radio_button(f, :use_default_mtu, true) %> Yes
</label>
<label class="radio">
<%= radio_button(f, :use_site_mtu, false) %> No
<%= radio_button(f, :use_default_mtu, false) %> No
</label>
</div>
<p class="help">
Default: <%= @mtu %>
Default: <%= @default_client_mtu %>
</p>
</div>
@@ -200,7 +200,7 @@
<div class="control">
<%= text_input(f, :mtu,
class: "input #{input_error_class(f, :mtu)}",
disabled: @use_site_mtu
disabled: @use_default_mtu
) %>
</div>
<p class="help is-danger">
@@ -209,19 +209,19 @@
</div>
<div class="field">
<%= label(f, :use_site_persistent_keepalive, "Use Default Persistent Keepalive",
<%= label(f, :use_default_persistent_keepalive, "Use Default Persistent Keepalive",
class: "label"
) %>
<div class="control">
<label class="radio">
<%= radio_button(f, :use_site_persistent_keepalive, true) %> Yes
<%= radio_button(f, :use_default_persistent_keepalive, true) %> Yes
</label>
<label class="radio">
<%= radio_button(f, :use_site_persistent_keepalive, false) %> No
<%= radio_button(f, :use_default_persistent_keepalive, false) %> No
</label>
</div>
<p class="help">
Default: <%= @persistent_keepalive %>
Default: <%= @default_client_persistent_keepalive %>
</p>
</div>
@@ -235,7 +235,7 @@
<div class="control">
<%= text_input(f, :persistent_keepalive,
class: "input #{input_error_class(f, :persistent_keepalive)}",
disabled: @use_site_persistent_keepalive
disabled: @use_default_persistent_keepalive
) %>
</div>
<p class="help is-danger">

View File

@@ -29,12 +29,13 @@
</div>
<div class="block">
<%= if length(@devices) > 0 do %>
<%= unless Enum.empty?(@devices) do %>
<table class="table is-bordered is-hoverable is-striped is-fullwidth">
<thead>
<tr>
<th>Name</th>
<th>Assigned Device IP</th>
<th>Tunnel IPv4</th>
<th>Tunnel IPv6</th>
<th>Public key</th>
<th>Created</th>
</tr>
@@ -47,10 +48,8 @@
<%= device.name %>
</.link>
</td>
<td class="code">
<span><%= device.ipv4 %></span>
<span><%= device.ipv6 %></span>
</td>
<td class="code"><%= device.ipv4 %></td>
<td class="code"><%= device.ipv6 %></td>
<td class="code"><%= device.public_key %></td>
<td
id={"device-#{device.id}-inserted-at"}

View File

@@ -3,7 +3,7 @@ defmodule FzHttpWeb.DeviceLive.Unprivileged.Show do
Shows a device for an unprivileged user.
"""
use FzHttpWeb, :live_view
alias FzHttp.Configurations, as: Conf
alias FzHttp.Devices
alias FzHttp.Users
@@ -43,7 +43,7 @@ defmodule FzHttpWeb.DeviceLive.Unprivileged.Show do
def delete_device(device, socket) do
if socket.assigns.current_user.id == device.user_id &&
(has_role?(socket.assigns.current_user, :admin) ||
Conf.get!(:allow_unprivileged_device_management)) do
FzHttp.Configurations.get!(:allow_unprivileged_device_management)) do
Devices.delete_device(device)
else
{:not_authorized}
@@ -56,7 +56,7 @@ defmodule FzHttpWeb.DeviceLive.Unprivileged.Show do
user: Users.get_user!(device.user_id),
page_title: device.name,
allowed_ips: Devices.allowed_ips(device),
port: Application.fetch_env!(:fz_vpn, :wireguard_port),
port: FzHttp.Config.fetch_env!(:fz_vpn, :wireguard_port),
dns: Devices.dns(device),
endpoint: Devices.endpoint(device),
mtu: Devices.mtu(device),

View File

@@ -4,7 +4,7 @@ defmodule FzHttpWeb.LiveAuth do
"""
use Phoenix.Component
alias FzHttpWeb.Authentication
alias FzHttpWeb.Auth.HTML.Authentication
import FzHttpWeb.AuthorizationHelpers
require Logger
@@ -24,7 +24,7 @@ defmodule FzHttpWeb.LiveAuth do
%{role: :unprivileged} = user,
%{assigns: %{live_action: :new}, view: FzHttpWeb.DeviceLive.Unprivileged.Index} = socket
) do
if Application.fetch_env!(:fz_http, :allow_unprivileged_device_management) do
if FzHttp.Configurations.get!(:allow_unprivileged_device_management) do
{:cont, assign_new(socket, :current_user, fn -> user end)}
else
{:halt, not_authorized(socket)}

View File

@@ -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} ->

View File

@@ -32,7 +32,7 @@ defmodule FzHttpWeb.ModalComponent do
</div>
</section>
<footer class="modal-card-foot is-justify-content-flex-end">
<%= if !assigns[:hide_footer_content] do %>
<%= if !(assigns[:hide_footer_content] || @opts[:hide_footer_content]) do %>
<%= Phoenix.View.render(FzHttpWeb.SharedView, "submit_button.html",
button_text: @opts[:button_text],
form: @opts[:form]
@@ -48,14 +48,4 @@ defmodule FzHttpWeb.ModalComponent do
def handle_event("close", _, socket) do
{:noreply, push_patch(socket, to: socket.assigns.return_to)}
end
@impl Phoenix.LiveComponent
@doc """
XXX: This is needed due to a bug on pages with dropdowns.
Basically this modal receives the phx-click-away event and the
server crashes if this is not implemented.
"""
def handle_event("close_dropdown", _params, socket) do
{:noreply, socket}
end
end

View File

@@ -10,6 +10,30 @@
) %>
<% end %>
<%= if @live_action == :new_api_token do %>
<%= live_modal(
FzHttpWeb.SettingLive.NewApiTokenComponent,
return_to: ~p"/settings/account",
title: "Add API Token",
id: "new_api_token",
form: "api-token-form",
user: @current_user,
changeset: FzHttp.ApiTokens.new_api_token()
) %>
<% end %>
<%= if @live_action == :show_api_token do %>
<%= live_modal(
FzHttpWeb.SettingLive.ShowApiTokenComponent,
return_to: ~p"/settings/account",
title: "API Token #{@api_token_id}",
id: "show_api_token",
hide_footer_content: true,
user: @current_user,
api_token: @api_token
) %>
<% end %>
<%= if @live_action == :register_mfa do %>
<.live_component
module={FzHttpWeb.MFA.RegisterComponent}
@@ -96,6 +120,96 @@
</div>
</section>
<section class="section is-main-section">
<h4 class="title is-4">
API Tokens
</h4>
<div class="block">
<p>
Manage API tokens.
<a href="https://docs.firezone.dev/reference/rest-api/?utm_source=product">
Read more about API tokens -&gt;
</a>
</p>
</div>
<div class="block">
<%= if Enum.any?(@api_tokens) do %>
<table class="table is-bordered is-hoverable is-striped is-fullwidth">
<thead>
<tr>
<th>Created at</th>
<th>Identifier</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<%= for api_token <- @api_tokens do %>
<tr>
<td
data-timestamp={api_token.inserted_at}
phx-hook="FormatTimestamp"
id={"inserted_at-#{api_token.id}"}
>
</td>
<td>
<.link patch={~p"/settings/account/api_token/#{api_token}"}>
<%= api_token.id %>
</.link>
</td>
<td>
<%= if ApiTokens.api_token_expired?(api_token) do %>
<span class="icon has-text-danger is-small">
<i class="mdi mdi-close-circle"></i>
</span>
Expired at
<% else %>
<span class="icon has-text-success is-small">
<i class="mdi mdi-check-circle"></i>
</span>
Expires at
<% end %>
<span
data-timestamp={api_token.expires_at}
phx-hook="FormatTimestamp"
id={"expired-at-#{api_token.id}"}
>
</span>
</td>
<td>
<.link
data-confirm="Are you sure?"
phx-click="delete_api_token"
phx-value-id={api_token.id}
>
Delete
</.link>
</td>
</tr>
<% end %>
</tbody>
</table>
<% else %>
<p>
No API tokens.
</p>
<% end %>
</div>
<%= if length(@api_tokens) < FzHttp.ApiTokens.ApiToken.max_per_user() do %>
<.link patch={~p"/settings/account/api_token"} class="button">
<span class="icon is-small">
<i class="mdi mdi-plus"></i>
</span>
<span>Add API Token</span>
</.link>
<% end %>
</section>
<section class="section is-main-section">
<h4 class="title is-4">
Multi Factor Authentication

View File

@@ -4,20 +4,31 @@ defmodule FzHttpWeb.SettingLive.Account do
"""
use FzHttpWeb, :live_view
alias FzHttp.{MFA, Users}
alias FzHttpWeb.{Endpoint, Presence}
alias FzHttp.{
ApiTokens,
MFA,
Users
}
alias FzHttpWeb.{
Endpoint,
Presence
}
@live_sessions_topic "notification:session"
@page_title "Account Settings"
@page_subtitle "Configure settings related to your Firezone web portal account."
@impl Phoenix.LiveView
def mount(_params, _session, socket) do
def mount(params, _session, socket) do
Endpoint.subscribe(@live_sessions_topic)
{:ok,
socket
|> assign(:api_token_id, params["api_token_id"])
|> assign(:subscribe_link, subscribe_link())
|> assign(:allow_delete, length(Users.list_admins()) > 1)
|> assign(:api_tokens, ApiTokens.list_api_tokens(socket.assigns.current_user.id))
|> assign(:changeset, Users.change_user(socket.assigns.current_user))
|> assign(:methods, MFA.list_methods(socket.assigns.current_user))
|> assign(:page_title, @page_title)
@@ -29,15 +40,42 @@ defmodule FzHttpWeb.SettingLive.Account do
)}
end
@impl Phoenix.LiveView
def handle_params(%{"api_token_id" => api_token_id}, _url, socket) do
{:noreply,
socket
|> assign(:api_token, ApiTokens.get_api_token!(api_token_id))}
end
@impl Phoenix.LiveView
def handle_params(_params, _url, socket) do
admins = Users.list_admins()
{:noreply, assign(socket, :allow_delete, length(admins) > 1)}
{:noreply,
socket
|> assign(:allow_delete, length(Users.list_admins()) > 1)
|> assign(:api_tokens, ApiTokens.list_api_tokens(socket.assigns.current_user.id))}
end
@impl Phoenix.LiveView
def handle_event("delete_api_token", %{"id" => id}, socket) do
api_token = ApiTokens.get_api_token!(id)
if api_token.user_id == socket.assigns.current_user.id do
{:ok, _deleted} = ApiTokens.delete_api_token(api_token)
end
{:noreply,
socket
|> assign(:api_tokens, ApiTokens.list_api_tokens(socket.assigns.current_user.id))}
end
@impl Phoenix.LiveView
def handle_event("delete_authenticator", %{"id" => id}, socket) do
{:ok, _deleted} = id |> MFA.get_method!() |> MFA.delete_method()
method = MFA.get_method!(id)
# A user can only delete his/her own MFA method!
if method.user_id == socket.assigns.current_user.id do
{:ok, _deleted} = MFA.delete_method(method)
end
{:noreply,
socket
@@ -58,7 +96,7 @@ defmodule FzHttpWeb.SettingLive.Account do
end
defp get_metas(presences, user_id) do
get_in(presences, [to_string(user_id), :metas]) || []
get_in(presences, [user_id, :metas]) || []
end
defp subscribe_link do

View File

@@ -6,13 +6,13 @@
<section class="section is-main-section">
<%= render(FzHttpWeb.SharedView, "flash.html", assigns) %>
<h4 class="title is-4">Site Defaults</h4>
<h4 class="title is-4">Client Defaults</h4>
<div class="block">
<%= live_component(
FzHttpWeb.SettingLive.SiteFormComponent,
FzHttpWeb.SettingLive.ClientDefaultsFormComponent,
changeset: @changeset,
id: :site_form_component
id: :client_defaults_form_component
) %>
</div>
</section>

View File

@@ -0,0 +1,32 @@
defmodule FzHttpWeb.SettingLive.ClientDefaultsFormComponent do
@moduledoc """
Handles updating client defaults form.
"""
use FzHttpWeb, :live_component
alias FzHttp.Configurations
@impl Phoenix.LiveComponent
def update(assigns, socket) do
{:ok,
socket
|> assign(assigns)}
end
@impl Phoenix.LiveComponent
def handle_event("save", %{"configuration" => configuration_params}, socket) do
configuration = Configurations.get_configuration!()
case Configurations.update_configuration(configuration, configuration_params) do
{:ok, configuration} ->
{:noreply,
socket
|> assign(:changeset, Configurations.change_configuration(configuration))}
{:error, changeset} ->
{:noreply,
socket
|> assign(:changeset, changeset)}
end
end
end

View File

@@ -1,19 +1,19 @@
<div class="block">
<.form :let={f} for={@changeset} id={@id} phx-target={@myself} phx-submit="save">
<div class="field">
<%= label(f, :allowed_ips, "Allowed IPs", class: "label") %>
<%= label(f, :default_client_allowed_ips, "Allowed IPs", class: "label") %>
<div class="control">
<%= textarea(
f,
:allowed_ips,
:default_client_allowed_ips,
placeholder: "0.0.0.0/0, ::/0",
class: "textarea #{input_error_class(f, :allowed_ips)}"
class: "textarea #{input_error_class(f, :default_client_allowed_ips)}"
) %>
</div>
<p class="help is-danger">
<%= error_tag(f, :allowed_ips) %>
<%= error_tag(f, :default_client_allowed_ips) %>
</p>
<p class="help">
Configures the default AllowedIPs setting for devices.
@@ -25,40 +25,40 @@
</div>
<div class="field">
<%= label(f, :dns, "DNS Servers", class: "label") %>
<%= label(f, :default_client_dns, "DNS Servers", class: "label") %>
<div class="control">
<%= text_input(
f,
:dns,
:default_client_dns,
placeholder: "1.1.1.1, 1.0.0.1",
class: "input #{input_error_class(f, :dns)}"
class: "input #{input_error_class(f, :default_client_dns)}"
) %>
</div>
<p class="help is-danger">
<%= error_tag(f, :dns) %>
<%= error_tag(f, :default_client_dns) %>
</p>
<p class="help">
Comma-separated list of DNS servers to use for devices.
Leaving this blank will omit the <code>DNS</code> section in
generated device configs.
Leave this blank to omit the <code>DNS</code> section from
generated configs.
</p>
</div>
<div class="field">
<%= label(f, :endpoint, "Endpoint", class: "label") %>
<%= label(f, :default_client_endpoint, "Endpoint", class: "label") %>
<div class="control">
<%= text_input(
f,
:endpoint,
:default_client_endpoint,
placeholder: "firezone.example.com",
class: "input #{input_error_class(f, :endpoint)}"
class: "input #{input_error_class(f, :default_client_endpoint)}"
) %>
</div>
<p class="help is-danger">
<%= error_tag(f, :endpoint) %>
<%= error_tag(f, :default_client_endpoint) %>
</p>
<p class="help">
IPv4, IPv6 address, or FQDN that devices will be configured to connect
@@ -67,41 +67,41 @@
</div>
<div class="field">
<%= label(f, :persistent_keepalive, "Persistent Keepalive", class: "label") %>
<%= label(f, :default_client_persistent_keepalive, "Persistent Keepalive", class: "label") %>
<div class="control">
<%= text_input(
f,
:persistent_keepalive,
:default_client_persistent_keepalive,
placeholder: "25",
class: "input #{input_error_class(f, :persistent_keepalive)}"
class: "input #{input_error_class(f, :default_client_persistent_keepalive)}"
) %>
<p class="help is-danger">
<%= error_tag(f, :persistent_keepalive) %>
<%= error_tag(f, :default_client_persistent_keepalive) %>
</p>
<p class="help">
Interval in seconds to send persistent keepalive packets from clients. Most users won't need to change
this. Leave this blank to omit this field from generated configs.
Interval in seconds to send persistent keepalive packets from devices. Most users won't
need to change this. Leave this blank to omit this field from generated configs.
</p>
</div>
</div>
<div class="field">
<%= label(f, :mtu, "MTU", class: "label") %>
<%= label(f, :default_client_mtu, "MTU", class: "label") %>
<div class="control">
<%= text_input(
f,
:mtu,
:default_client_mtu,
placeholder: "1280",
class: "input #{input_error_class(f, :mtu)}"
class: "input #{input_error_class(f, :default_client_mtu)}"
) %>
</div>
<p class="help is-danger">
<%= error_tag(f, :mtu) %>
<%= error_tag(f, :default_client_mtu) %>
</p>
<p class="help">
WireGuard interface MTU for client configs. 1280 is a safe bet for most networks.
WireGuard interface MTU for devices. 1280 is a safe bet for most networks.
Leave this blank to omit this field from generated configs.
</p>
</div>

View File

@@ -1,13 +1,13 @@
defmodule FzHttpWeb.SettingLive.Site do
defmodule FzHttpWeb.SettingLive.ClientDefaults do
@moduledoc """
Manages the defaults view.
"""
use FzHttpWeb, :live_view
alias FzHttp.Sites
alias FzHttp.Configurations
@page_title "Site Settings"
@page_subtitle "Configure default WireGuard settings for this site."
@page_title "Client Defaults"
@page_subtitle "Configure default values for generating WireGuard client configurations."
@impl Phoenix.LiveView
def mount(_params, _session, socket) do
@@ -19,6 +19,6 @@ defmodule FzHttpWeb.SettingLive.Site do
end
defp changeset do
Sites.get_site!() |> Sites.change_site()
Configurations.get_configuration!() |> Configurations.change_configuration()
end
end

View File

@@ -18,7 +18,7 @@
<div class="block">
<div class="field">
<div class="control">
<%= for type <- Conf.logo_types do %>
<%= for type <- FzHttp.Configurations.logo_types do %>
<label class="radio">
<input
type="radio"

View File

@@ -4,16 +4,14 @@ defmodule FzHttpWeb.SettingLive.Customization do
"""
use FzHttpWeb, :live_view
alias FzHttp.Configurations, as: Conf
@max_logo_size 1024 ** 2
@page_title "Customization"
@page_subtitle "Customize the look and feel of your Firezone web portal."
@impl Phoenix.LiveView
def mount(_params, _session, socket) do
logo = Conf.get!(:logo)
logo_type = Conf.logo_type(logo)
logo = FzHttp.Configurations.get!(:logo)
logo_type = FzHttp.Configurations.logo_type(logo)
{:ok,
socket
@@ -39,14 +37,14 @@ defmodule FzHttpWeb.SettingLive.Customization do
@impl Phoenix.LiveView
def handle_event("save", %{"default" => "true"}, socket) do
{:ok, config} = Conf.update_configuration(%{logo: nil})
{:ok, config} = FzHttp.Configurations.update_configuration(%{logo: nil})
{:noreply, assign(socket, :logo, config.logo)}
end
@impl Phoenix.LiveView
def handle_event("save", %{"url" => url}, socket) do
{:ok, config} = Conf.update_configuration(%{logo: %{"url" => url}})
{:ok, config} = FzHttp.Configurations.update_configuration(%{logo: %{"url" => url}})
{:noreply, assign(socket, :logo, config.logo)}
end
@@ -61,7 +59,9 @@ defmodule FzHttpWeb.SettingLive.Customization do
# enforce OK, error from update_configuration instead of consume_uploaded_entry
{:ok, config} =
Conf.update_configuration(%{logo: %{"data" => data, "type" => entry.client_type}})
FzHttp.Configurations.update_configuration(%{
logo: %{"data" => data, "type" => entry.client_type}
})
{:ok, config}
end)

View File

@@ -0,0 +1,69 @@
defmodule FzHttpWeb.SettingLive.NewApiTokenComponent do
@moduledoc """
Live component to manage creating API Tokens
"""
use FzHttpWeb, :live_component
alias FzHttp.ApiTokens
def render(assigns) do
~H"""
<div>
<.form
:let={f}
for={@changeset}
autocomplete="off"
id="api-token-form"
phx-target={@myself}
phx-submit="save"
>
<%= if @changeset.action do %>
<div class="notification is-danger">
<div class="flash-error">
<%= error_tag(f, :base) %>
</div>
</div>
<% end %>
<div class="field is-horizontal">
<div class="field-label is-normal">
<%= label(f, :expires_in, class: "label") %>
</div>
<div class="field-body">
<div class="field is-expanded">
<div class="field has-addons">
<p class="control is-expanded">
<%= text_input(f, :expires_in, class: "input #{input_error_class(f, :expires_in)}") %>
</p>
<p class="control">
<a class="button is-static">
days
</a>
</p>
</div>
</div>
<div class="help is-danger">
<%= error_tag(f, :expires_in) %>
</div>
</div>
</div>
</.form>
</div>
"""
end
def handle_event("save", %{"api_token" => api_token_params}, socket) do
user = socket.assigns.user
case ApiTokens.create_user_api_token(user, api_token_params) do
{:ok, api_token} ->
{:noreply,
socket
|> push_patch(to: ~p"/settings/account/api_token/#{api_token}")}
{:error, changeset} ->
{:noreply,
socket
|> assign(:changeset, changeset)}
end
end
end

View File

@@ -4,8 +4,6 @@ defmodule FzHttpWeb.SettingLive.OIDCFormComponent do
"""
use FzHttpWeb, :live_component
alias FzHttp.Configurations, as: Conf
def render(assigns) do
~H"""
<div>
@@ -173,40 +171,42 @@ defmodule FzHttpWeb.SettingLive.OIDCFormComponent do
changeset =
assigns.providers
|> Map.get(assigns.provider_id, %{})
|> Map.put("id", assigns.provider_id)
|> FzHttp.Conf.OIDCConfig.changeset()
|> Map.put(:id, assigns.provider_id)
|> FzHttp.Configurations.Configuration.OpenIDConnectProvider.changeset()
{:ok,
socket
|> assign(assigns)
|> assign(:external_url, Application.fetch_env!(:fz_http, :external_url))
|> assign(:external_url, FzHttp.Config.fetch_env!(:fz_http, :external_url))
|> assign(:changeset, changeset)}
end
def handle_event("save", %{"oidc_config" => params}, socket) do
def handle_event("save", %{"open_id_connect_provider" => params}, socket) do
changeset =
params
|> FzHttp.Conf.OIDCConfig.changeset()
|> FzHttp.Configurations.Configuration.OpenIDConnectProvider.changeset()
|> Map.put(:action, :validate)
update =
case changeset do
%{valid?: true} ->
changeset
|> Ecto.Changeset.apply_changes()
|> Map.from_struct()
|> Map.new(fn {k, v} -> {to_string(k), v} end)
|> then(fn data ->
{id, data} = Map.pop(data, "id")
{:ok, _} =
changeset
|> Ecto.Changeset.apply_changes()
|> Map.from_struct()
|> Map.new(fn {k, v} -> {to_string(k), v} end)
|> then(fn data ->
id = Map.get(data, "id")
%{
openid_connect_providers:
socket.assigns.providers
|> Map.delete(socket.assigns.provider_id)
|> Map.put(id, data)
}
end)
|> Conf.update_configuration()
%{
openid_connect_providers:
socket.assigns.providers
|> Map.delete(socket.assigns.provider_id)
|> Map.put(id, data)
|> Map.values()
}
end)
|> FzHttp.Configurations.update_configuration()
_ ->
{:error, changeset}
@@ -214,9 +214,6 @@ defmodule FzHttpWeb.SettingLive.OIDCFormComponent do
case update do
{:ok, _config} ->
:ok = Supervisor.terminate_child(FzHttp.Supervisor, FzHttp.OIDC.StartProxy)
{:ok, _pid} = Supervisor.restart_child(FzHttp.Supervisor, FzHttp.OIDC.StartProxy)
{:noreply,
socket
|> put_flash(:info, "Updated successfully.")

View File

@@ -4,8 +4,6 @@ defmodule FzHttpWeb.SettingLive.SAMLFormComponent do
"""
use FzHttpWeb, :live_component
alias FzHttp.Configurations, as: Conf
def render(assigns) do
~H"""
<div>
@@ -192,16 +190,17 @@ defmodule FzHttpWeb.SettingLive.SAMLFormComponent do
end
def update(assigns, socket) do
external_url = Application.fetch_env!(:fz_http, :external_url)
external_url = FzHttp.Config.fetch_env!(:fz_http, :external_url)
changeset =
assigns.providers
|> Map.get(assigns.provider_id, %{})
|> Map.merge(%{
"id" => assigns.provider_id,
"base_url" => Path.join(external_url, "/auth/saml")
id: assigns.provider_id,
# XXX this should be part of changeset itself
base_url: Path.join(external_url, "/auth/saml")
})
|> FzHttp.Conf.SAMLConfig.changeset()
|> FzHttp.Configurations.Configuration.SAMLIdentityProvider.changeset()
{:ok,
socket
@@ -209,41 +208,39 @@ defmodule FzHttpWeb.SettingLive.SAMLFormComponent do
|> assign(:changeset, changeset)}
end
def handle_event("save", %{"saml_config" => params}, socket) do
def handle_event("save", %{"saml_identity_provider" => params}, socket) do
changeset =
params
|> FzHttp.Conf.SAMLConfig.changeset()
|> FzHttp.Configurations.Configuration.SAMLIdentityProvider.changeset()
|> Map.put(:action, :validate)
update =
case changeset do
%{valid?: true} ->
changeset
|> Ecto.Changeset.apply_changes()
|> Map.from_struct()
|> Map.new(fn {k, v} -> {to_string(k), v} end)
|> then(fn data ->
{id, data} = Map.pop(data, "id")
{:ok, _} =
changeset
|> Ecto.Changeset.apply_changes()
|> Map.from_struct()
|> Map.new(fn {k, v} -> {to_string(k), v} end)
|> then(fn data ->
id = Map.get(data, "id")
%{
saml_identity_providers:
socket.assigns.providers
|> Map.delete(socket.assigns.provider_id)
|> Map.put(id, data)
}
end)
|> Conf.update_configuration()
%{
saml_identity_providers:
socket.assigns.providers
|> Map.delete(socket.assigns.provider_id)
|> Map.put(id, data)
|> Map.values()
}
end)
|> FzHttp.Configurations.update_configuration()
_ ->
{:error, changeset}
end
case update do
{:ok, config} ->
Application.fetch_env!(:samly, Samly.Provider)
|> FzHttp.SAML.StartProxy.set_identity_providers(config.saml_identity_providers)
|> FzHttp.SAML.StartProxy.refresh()
{:ok, _config} ->
{:noreply,
socket
|> put_flash(:info, "Updated successfully.")

View File

@@ -33,7 +33,12 @@
<h4 class="title is-4">Authentication</h4>
<div class="block">
<.form :let={f} for={@site_changeset} phx-change="change" phx-submit="save_site">
<.form
:let={f}
for={@configuration_changeset}
phx-change="change"
phx-submit="save_configuration"
>
<div class="field">
<%= label(f, :vpn_session_duration, "Require Authentication For VPN Sessions",
class: "label"
@@ -73,8 +78,8 @@
type="checkbox"
phx-click="toggle"
phx-value-config="local_auth_enabled"
checked={Conf.get!(:local_auth_enabled)}
value={if(!Conf.get!(:local_auth_enabled), do: "on")}
checked={FzHttp.Configurations.get!(:local_auth_enabled)}
value={if(!FzHttp.Configurations.get!(:local_auth_enabled), do: "on")}
/>
<span class="check"></span>
</label>
@@ -95,8 +100,10 @@
type="checkbox"
phx-click="toggle"
phx-value-config="allow_unprivileged_device_management"
checked={Conf.get!(:allow_unprivileged_device_management)}
value={if(!Conf.get!(:allow_unprivileged_device_management), do: "on")}
checked={FzHttp.Configurations.get!(:allow_unprivileged_device_management)}
value={
if(!FzHttp.Configurations.get!(:allow_unprivileged_device_management), do: "on")
}
/>
<span class="check"></span>
</label>
@@ -119,8 +126,10 @@
type="checkbox"
phx-click="toggle"
phx-value-config="allow_unprivileged_device_configuration"
checked={Conf.get!(:allow_unprivileged_device_configuration)}
value={if(!Conf.get!(:allow_unprivileged_device_configuration), do: "on")}
checked={FzHttp.Configurations.get!(:allow_unprivileged_device_configuration)}
value={
if(!FzHttp.Configurations.get!(:allow_unprivileged_device_configuration), do: "on")
}
/>
<span class="check"></span>
</label>
@@ -132,8 +141,7 @@
<div class="block">
<p>
Single Sign-On can be configured in the main Firezone configuration
file. Refer to the
Configure OIDC or SAML Single Sign-On below. See the
<a href="https://docs.firezone.dev/authenticate/">Firezone documentation</a>
for more details.
</p>
@@ -152,8 +160,8 @@
type="checkbox"
phx-click="toggle"
phx-value-config="disable_vpn_on_oidc_error"
checked={Conf.get!(:disable_vpn_on_oidc_error)}
value={if(!Conf.get!(:disable_vpn_on_oidc_error), do: "on")}
checked={FzHttp.Configurations.get!(:disable_vpn_on_oidc_error)}
value={if(!FzHttp.Configurations.get!(:disable_vpn_on_oidc_error), do: "on")}
/>
<span class="check"></span>
</label>
@@ -178,10 +186,10 @@
<%= for {k, v} <- @oidc_configs do %>
<tr>
<td><%= k %></td>
<td><%= v["label"] %></td>
<td><%= v["client_id"] %></td>
<td><%= v["discovery_document_uri"] %></td>
<td><%= v["scope"] %></td>
<td><%= v.label %></td>
<td><%= v.client_id %></td>
<td><%= v.discovery_document_uri %></td>
<td><%= v.scope %></td>
<td>
<%= live_patch(to: ~p"/settings/security/oidc/#{k}/edit",
class: "button") do %>
@@ -223,9 +231,9 @@
<%= for {k, v} <- @saml_configs do %>
<tr>
<td><%= k %></td>
<td><%= v["label"] %></td>
<td><%= v.label %></td>
<td>
<div class="line-clamp"><%= v["metadata"] %></div>
<div class="line-clamp"><%= v.metadata %></div>
</td>
<td>
<%= live_patch(to: ~p"/settings/security/saml/#{k}/edit",

View File

@@ -7,29 +7,36 @@ defmodule FzHttpWeb.SettingLive.Security do
import Ecto.Changeset
import FzCommon.FzCrypto, only: [rand_string: 1]
alias FzHttp.Configurations, as: Conf
alias FzHttp.{Sites, Sites.Site}
alias FzHttp.Configurations
@page_title "Security Settings"
@page_subtitle "Configure security-related settings."
@impl Phoenix.LiveView
def mount(_params, _session, socket) do
config_changeset = Conf.change_configuration()
config_changeset = Configurations.change_configuration()
{:ok,
socket
|> assign(:form_changed, false)
|> assign(:session_duration_options, session_duration_options())
|> assign(:site_changeset, site_changeset())
|> assign(:configuration_changeset, configuration_changeset())
|> assign(:config_changeset, config_changeset)
|> assign(:oidc_configs, config_changeset.data.openid_connect_providers || %{})
|> assign(:saml_configs, config_changeset.data.saml_identity_providers || %{})
|> assign(:oidc_configs, map_providers(config_changeset.data.openid_connect_providers))
|> assign(:saml_configs, map_providers(config_changeset.data.saml_identity_providers))
|> assign(:field_titles, field_titles(config_changeset))
|> assign(:page_subtitle, @page_subtitle)
|> assign(:page_title, @page_title)}
end
defp map_providers(nil), do: %{}
defp map_providers(providers) when is_list(providers) do
for provider <- providers,
into: %{},
do: {provider.id, Map.from_struct(provider)}
end
@impl Phoenix.LiveView
def handle_params(params, _uri, socket) do
{:noreply, assign(socket, :id, params["id"])}
@@ -44,30 +51,32 @@ defmodule FzHttpWeb.SettingLive.Security do
@impl Phoenix.LiveView
def handle_event(
"save_site",
%{"site" => %{"vpn_session_duration" => vpn_session_duration}},
"save_configuration",
%{"configuration" => %{"vpn_session_duration" => vpn_session_duration}},
socket
) do
site = Sites.get_site!()
configuration = Configurations.get_configuration!()
case Sites.update_site(site, %{vpn_session_duration: vpn_session_duration}) do
{:ok, site} ->
case Configurations.update_configuration(configuration, %{
vpn_session_duration: vpn_session_duration
}) do
{:ok, configuration} ->
{:noreply,
socket
|> assign(:form_changed, false)
|> assign(:site_changeset, Sites.change_site(site))}
|> assign(:configuration_changeset, Configurations.change_configuration(configuration))}
{:error, site_changeset} ->
{:error, configuration_changeset} ->
{:noreply,
socket
|> assign(:site_changeset, site_changeset)}
|> assign(:configuration_changeset, configuration_changeset)}
end
end
@impl Phoenix.LiveView
def handle_event("toggle", %{"config" => config} = params, socket) do
toggle_value = !!params["value"]
{:ok, _conf} = Conf.update_configuration(%{config => toggle_value})
{:ok, _conf} = Configurations.update_configuration(%{config => toggle_value})
{:noreply, socket}
end
@@ -78,9 +87,11 @@ defmodule FzHttpWeb.SettingLive.Security do
field_key = Map.fetch!(@types, type)
providers =
get_in(socket.assigns.config_changeset, [Access.key!(:data), Access.key!(field_key)])
socket.assigns.config_changeset
|> get_in([Access.key!(:data), Access.key!(field_key)])
|> Enum.reject(&(&1.id == key))
{:ok, conf} = Conf.update_configuration(%{field_key => Map.delete(providers, key)})
{:ok, conf} = Configurations.update_configuration(%{field_key => providers})
{:noreply,
socket
@@ -94,7 +105,7 @@ defmodule FzHttpWeb.SettingLive.Security do
def session_duration_options do
[
Never: 0,
Once: Site.max_vpn_session_duration(),
Once: FzHttp.Configurations.Configuration.max_vpn_session_duration(),
"Every Hour": @hour,
"Every Day": @day,
"Every Week": 7 * @day,
@@ -103,9 +114,9 @@ defmodule FzHttpWeb.SettingLive.Security do
]
end
defp site_changeset do
Sites.get_site!()
|> Sites.change_site()
defp configuration_changeset do
Configurations.get_configuration!()
|> Configurations.change_configuration()
end
@fields ~w(

View File

@@ -0,0 +1,63 @@
defmodule FzHttpWeb.SettingLive.ShowApiTokenComponent do
use FzHttpWeb, :live_component
alias Phoenix.LiveView.JS
alias FzHttpWeb.Auth.JSON.Authentication
def update(assigns, socket) do
if connected?(socket) do
{:ok, secret, _claims} = Authentication.fz_encode_and_sign(assigns.api_token, assigns.user)
{:ok,
socket
|> assign(:secret, secret)}
else
{:ok, socket}
end
end
def render(assigns) do
~H"""
<div>
<%= if assigns[:secret] do %>
<div class="level">
<div class="level-left">
<h6 class="title is-6">
API token secret:
</h6>
</div>
<div class="level-right">
<button
class="button copy-button"
phx-click={JS.dispatch("firezone:clipcopy", to: "#api-token-secret")}
>
<span class="icon" title="Click to copy API token">
<i class="mdi mdi-content-copy"></i>
</span>
</button>
</div>
</div>
<div class="block">
<pre class="multiline"><code id="api-token-secret"><%= @secret %></code></pre>
</div>
<div class="block">
<p><strong>Warning!</strong> This token is sensitive data. Store it somewhere safe.</p>
</div>
<hr />
<div class="block">
<h6 class="title is-6">cURL example:</h6>
<pre><code><i># List all users</i>
curl -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <%= @secret %>' \
<%= Application.fetch_env!(:fz_http, :external_url) %>/v0/users</code></pre>
</div>
<div class="block has-text-right">
<a href="https://docs.firezone.dev/reference/rest-api?utm_source=product">
Explore the REST API docs -&gt;
</a>
</div>
<% end %>
</div>
"""
end
end

View File

@@ -1,32 +0,0 @@
defmodule FzHttpWeb.SettingLive.SiteFormComponent do
@moduledoc """
Handles updating site values.
"""
use FzHttpWeb, :live_component
alias FzHttp.Sites
@impl Phoenix.LiveComponent
def update(assigns, socket) do
{:ok,
socket
|> assign(assigns)}
end
@impl Phoenix.LiveComponent
def handle_event("save", %{"site" => site_params}, socket) do
site = Sites.get_site!()
case Sites.update_site(site, site_params) do
{:ok, site} ->
{:noreply,
socket
|> assign(:changeset, Sites.change_site(site))}
{:error, changeset} ->
{:noreply,
socket
|> assign(:changeset, changeset)}
end
end
end

View File

@@ -8,7 +8,6 @@ defmodule FzHttpWeb.SettingLive.Unprivileged.Account do
"""
use FzHttpWeb, :live_view
alias FzHttp.Configurations, as: Conf
alias FzHttp.{MFA, Users}
alias FzHttpWeb.{Endpoint, Presence}
@@ -22,7 +21,7 @@ defmodule FzHttpWeb.SettingLive.Unprivileged.Account do
{:ok,
socket
|> assign(:local_auth_enabled, Conf.get!(:local_auth_enabled))
|> assign(:local_auth_enabled, FzHttp.Configurations.get!(:local_auth_enabled))
|> assign(:changeset, Users.change_user(socket.assigns.current_user))
|> assign(:methods, MFA.list_methods(socket.assigns.current_user))
|> assign(:page_title, @page_title)
@@ -61,6 +60,6 @@ defmodule FzHttpWeb.SettingLive.Unprivileged.Account do
end
defp get_metas(presences, user_id) do
get_in(presences, [to_string(user_id), :metas]) || []
get_in(presences, [user_id, :metas]) || []
end
end

View File

@@ -37,7 +37,7 @@ defmodule FzHttpWeb.SidebarComponent do
<p class="menu-label">Settings</p>
<ul class="menu-list">
<li>
<%= live_redirect(to: ~p"/settings/site", class: nav_class(@path, "/settings/site")) do %>
<%= live_redirect(to: ~p"/settings/client_defaults", class: nav_class(@path, "/settings/client_defaults")) do %>
<span class="icon"><i class="mdi mdi-cog"></i></span>
<span class="menu-item-label">Defaults</span>
<% end %>

View File

@@ -5,7 +5,7 @@ defmodule FzHttpWeb.LiveHelpers do
https://bernheisel.com/blog/phoenix-liveview-and-views
"""
use Phoenix.Component
alias FzHttp.{Sites, Users}
alias FzHttp.{Configurations, Users}
def live_modal(component, opts) do
path = Keyword.fetch!(opts, :return_to)
@@ -36,19 +36,19 @@ defmodule FzHttpWeb.LiveHelpers do
end
def admin_email do
Application.fetch_env!(:fz_http, :admin_email)
FzHttp.Config.fetch_env!(:fz_http, :admin_email)
end
def vpn_sessions_expire? do
Sites.vpn_sessions_expire?()
Configurations.vpn_sessions_expire?()
end
def vpn_expires_at(user) do
Users.vpn_session_expires_at(user, Sites.vpn_duration())
Users.vpn_session_expires_at(user, Configurations.vpn_duration())
end
def vpn_expired?(user) do
Users.vpn_session_expired?(user, Sites.vpn_duration())
Users.vpn_session_expired?(user, Configurations.vpn_duration())
end
defp status_digit(response_code) when is_integer(response_code) do

View File

@@ -43,7 +43,7 @@ defmodule FzHttpWeb.OAuth.PKCE do
max_age: @pkce_valid_duration,
sign: true,
same_site: "Lax",
secure: Application.fetch_env!(:fz_http, :cookie_secure)
secure: FzHttp.Config.fetch_env!(:fz_http, :cookie_secure)
]
end
end

View File

@@ -3,14 +3,7 @@ defmodule FzHttpWeb.OIDC.Helpers do
Just some, ya know, helpers for OIDC flows.
"""
# openid_connect expects providers as keys...
def atomize_provider(key) do
{:ok, String.to_existing_atom(key)}
rescue
ArgumentError -> {:error, "OIDC Provider not found"}
end
def openid_connect do
Application.fetch_env!(:fz_http, :openid_connect)
FzHttp.Config.fetch_env!(:fz_http, :openid_connect)
end
end

View File

@@ -33,7 +33,7 @@ defmodule FzHttpWeb.OIDC.State do
max_age: @oidc_state_valid_duration,
sign: true,
same_site: "Lax",
secure: Application.fetch_env!(:fz_http, :cookie_secure)
secure: FzHttp.Config.fetch_env!(:fz_http, :cookie_secure)
]
end
end

View File

@@ -7,8 +7,8 @@ defmodule FzHttpWeb.Plug.Authorization do
use FzHttpWeb, :controller
import FzHttpWeb.ControllerHelpers, only: [root_path_for_role: 1]
alias FzHttpWeb.Authentication
alias FzHttp.Users.User
alias FzHttpWeb.Auth.HTML.Authentication
@not_authorized "Not authorized."
@@ -21,15 +21,16 @@ defmodule FzHttpWeb.Plug.Authorization do
def call(conn, role), do: require_user_with_role(conn, role)
def require_user_with_role(conn, role) do
user = Authentication.get_current_user(conn)
if user.role == role do
with %User{} = user <- Authentication.get_current_user(conn),
^role <- user.role do
conn
else
conn
|> put_flash(:error, @not_authorized)
|> redirect(to: root_path_for_role(user.role))
|> halt()
_ ->
conn
|> Authentication.sign_out()
|> put_flash(:error, @not_authorized)
|> redirect(to: ~p"/")
|> halt()
end
end
end

View File

@@ -16,6 +16,7 @@ defmodule FzHttpWeb.Router do
pipeline :api do
plug :accepts, ["json"]
plug FzHttpWeb.Auth.JSON.Pipeline
end
pipeline :require_admin_user do
@@ -35,8 +36,8 @@ defmodule FzHttpWeb.Router do
plug Guardian.Plug.EnsureNotAuthenticated
end
pipeline :guardian do
plug FzHttpWeb.Authentication.Pipeline
pipeline :html_auth do
plug FzHttpWeb.Auth.HTML.Pipeline
end
pipeline :samly do
@@ -48,7 +49,7 @@ defmodule FzHttpWeb.Router do
scope "/auth", FzHttpWeb do
pipe_through [
:browser,
:guardian,
:html_auth,
:require_unauthenticated
]
@@ -73,7 +74,7 @@ defmodule FzHttpWeb.Router do
scope "/", FzHttpWeb do
pipe_through [
:browser,
:guardian,
:html_auth,
:require_unauthenticated
]
@@ -83,7 +84,7 @@ defmodule FzHttpWeb.Router do
scope "/mfa", FzHttpWeb do
pipe_through([
:browser,
:guardian
:html_auth
])
live_session(
@@ -101,7 +102,7 @@ defmodule FzHttpWeb.Router do
scope "/", FzHttpWeb do
pipe_through [
:browser,
:guardian,
:html_auth,
:require_authenticated
]
@@ -112,7 +113,7 @@ defmodule FzHttpWeb.Router do
scope "/", FzHttpWeb do
pipe_through [
:browser,
:guardian,
:html_auth,
:require_authenticated,
:require_unprivileged_user
]
@@ -137,7 +138,7 @@ defmodule FzHttpWeb.Router do
scope "/", FzHttpWeb do
pipe_through [
:browser,
:guardian,
:html_auth,
:require_authenticated,
:require_admin_user
]
@@ -159,7 +160,7 @@ defmodule FzHttpWeb.Router do
live "/rules", RuleLive.Index, :index
live "/devices", DeviceLive.Admin.Index, :index
live "/devices/:id", DeviceLive.Admin.Show, :show
live "/settings/site", SettingLive.Site, :show
live "/settings/client_defaults", SettingLive.ClientDefaults, :show
live "/settings/security", SettingLive.Security, :show
live "/settings/security/oidc/:id/edit", SettingLive.Security, :edit_oidc
@@ -168,12 +169,23 @@ defmodule FzHttpWeb.Router do
live "/settings/account", SettingLive.Account, :show
live "/settings/account/edit", SettingLive.Account, :edit
live "/settings/account/register_mfa", SettingLive.Account, :register_mfa
live "/settings/account/api_token", SettingLive.Account, :new_api_token
live "/settings/account/api_token/:api_token_id", SettingLive.Account, :show_api_token
live "/settings/customization", SettingLive.Customization, :show
live "/diagnostics/connectivity_checks", ConnectivityCheckLive.Index, :index
live "/notifications", NotificationsLive.Index, :index
end
end
scope "/v0", FzHttpWeb.JSON do
pipe_through :api
resources "/configuration", ConfigurationController, singleton: true, only: [:show, :update]
resources "/users", UserController, except: [:new, :edit]
resources "/devices", DeviceController, except: [:new, :edit]
resources "/rules", RuleController, except: [:new, :edit]
end
if Mix.env() == :dev do
import Phoenix.LiveDashboard.Router

Some files were not shown because too many files have changed in this diff Show More